feat: 接入内容服务端真实接口并优化页面交互
- 迁移内容数据到服务端接口,移除本地模拟数据 - 首页新增推荐课程加载与未读消息检查 - 资讯、课程、搜索页面支持分页加载与错误重试 - 登录页新增恢复课程、消息、帮助页的路由逻辑 - 评估分析页新增处理中状态动效与阶段提示 - API 环境切换到正式域名 airz.hqling.cloud
This commit is contained in:
@@ -33,7 +33,13 @@
|
||||
let afterLoginUrl = '/pages/home/home'
|
||||
onLoad((options) => {
|
||||
consumeInitialLaunch()
|
||||
if (options?.['resumeAssistant'] == '1') {
|
||||
if (options?.['resumeLesson'] == '1' && /^[1-9][0-9]{0,9}$/.test(options?.['lessonId'] ?? '')) {
|
||||
afterLoginUrl = '/packages/content/lesson/lesson?id=' + options?.['lessonId']
|
||||
} else if (options?.['resumeMessages'] == '1') {
|
||||
afterLoginUrl = '/packages/account/messages/messages'
|
||||
} else if (options?.['resumeHelp'] == '1') {
|
||||
afterLoginUrl = '/packages/content/help/help?feedback=1'
|
||||
} else if (options?.['resumeAssistant'] == '1') {
|
||||
const target = assistantReturnPath(options?.['assessmentId'] ?? '', options?.['productIds'] ?? '', options?.['chatId'] ?? '', options?.['from'] ?? '', options?.['revisionId'] ?? '')
|
||||
if (target.length > 0) afterLoginUrl = target
|
||||
} else if (options?.['resumeNextStep'] == '1') {
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
<template>
|
||||
<view class="page" :style="pageStyle()">
|
||||
<AppHeader title="消息中心" :back="false" />
|
||||
<view class="message-tabs"><view v-for="item in tabs" :key="item" :class="tab == item ? 'message-tab active-tab' : 'message-tab'" @click="tab = item"><text :class="tab == item ? 'tab-text active-text' : 'tab-text'">{{ item }}</text></view></view>
|
||||
<scroll-view scroll-y :style="contentStyle(314, false)">
|
||||
<view class="message-tabs"><view v-for="item in tabs" :key="item.type" :class="tab == item.type ? 'message-tab active-tab' : 'message-tab'" @click="changeTab(item.type)"><text :class="tab == item.type ? 'tab-text active-text' : 'tab-text'">{{ item.label }}</text></view></view>
|
||||
<!-- 扣除顶栏96、分类88和底栏130,避免列表末端落到固定导航下。 -->
|
||||
<scroll-view scroll-y :style="contentStyle(314, false)" @scrolltolower="autoMore">
|
||||
<view class="messages-content">
|
||||
<view class="message-summary"><text class="summary-text">{{ unreadCount > 0 ? unreadCount + ' 条未读消息' : '消息已全部阅读' }}</text><view class="read-all" @click="markAllRead"><text class="read-all-text">全部已读</text></view></view>
|
||||
<view class="message-summary"><text class="summary-text">{{ loading ? '正在加载消息…' : (unreadCount > 0 ? unreadCount + ' 条未读消息' : (notices.length > 0 ? '消息已全部阅读' : '消息中心')) }}</text><view class="read-all" @click="markAllRead"><text :class="marking || loading || snapshotId == '0' || snapshotId.length == 0 ? 'read-all-text disabled-text' : 'read-all-text'">{{ marking ? '正在标记…' : '全部已读' }}</text></view></view>
|
||||
<view v-if="error.length > 0" class="message-state"><text class="state-title">{{ error }}</text><text class="state-action" @click="reload">重新加载</text></view>
|
||||
<view v-if="!loading && error.length == 0 && notices.length == 0" class="message-state"><text class="state-title">{{ tab == 'all' ? '暂无消息' : '暂无' + categoryLabel }}</text><text class="state-note">{{ tab == 'all' ? '评估状态更新后,会在这里通知您' : '当前分类暂无消息' }}</text></view>
|
||||
<text v-if="actionError.length > 0" class="action-error">{{ actionError }}</text>
|
||||
<view class="notice-list">
|
||||
<view class="notice" v-for="(item, index) in filteredNotices" :key="item.id" :class="index == filteredNotices.length - 1 ? 'last-notice' : ''" @click="openNotice(item)">
|
||||
<view class="notice-icon" :class="'notice-icon-' + item.icon"><view class="notice-icon-surface"><image class="notice-symbol" :src="'/packages/account/static/notifications/' + item.icon + '.svg'" mode="aspectFit" /></view><view v-if="item.unread" class="unread"></view></view>
|
||||
<view class="notice-copy"><view class="notice-head"><text class="notice-title">{{ item.title }}</text><text class="notice-time">{{ item.time }}</text></view><text class="notice-desc">{{ item.desc }}</text><text class="notice-category">{{ item.category }}</text></view>
|
||||
<view class="notice" v-for="(item, index) in notices" :key="item.id" :class="index == notices.length - 1 ? 'last-notice' : ''" @click="openNotice(item)">
|
||||
<view class="notice-icon" :class="'notice-icon-' + item.icon"><view class="notice-icon-surface"><image class="notice-symbol" :src="'/packages/account/static/notifications/' + item.icon + '.svg'" mode="aspectFit" /></view><view v-if="!item.isRead" class="unread"></view></view>
|
||||
<view class="notice-copy"><view class="notice-head"><text class="notice-title">{{ item.title }}</text><text class="notice-time">{{ item.time }}</text></view><text class="notice-desc">{{ item.content }}</text><text class="notice-category">{{ opening == item.id ? '正在打开…' : item.category }}</text></view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="message-footer">点击消息查看完整内容</text>
|
||||
<text v-if="paginationError.length > 0" class="action-error">{{ paginationError }}</text>
|
||||
<text v-if="loadingMore" class="message-footer">正在加载更多…</text>
|
||||
<text v-else-if="nextBeforeId.length > 0" class="message-footer state-action" @click="more">加载更多</text>
|
||||
<text v-else-if="notices.length > 0" class="message-footer">已显示全部消息</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<BottomNav active="message" />
|
||||
@@ -19,41 +26,86 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { getUserStorage, setUserStorage } from '@/services/storage.uts'
|
||||
|
||||
import { getSession } from '@/services/api.uts'
|
||||
import { UserMessage, fetchMessages, fetchMessage, readMessage, readAllMessages } from '@/services/messages.uts'
|
||||
import { usePageLayout } from '@/services/layout.uts'
|
||||
import AppHeader from '@/components/AppHeader.uvue'
|
||||
import BottomNav from '@/components/BottomNav.uvue'
|
||||
const { contentStyle, pageStyle } = usePageLayout()
|
||||
const tabs: string[] = ['全部', '系统通知', '服务通知', '活动消息']
|
||||
const tab = ref('全部')
|
||||
type Notice = { id: string, icon: string, category: string, title: string, desc: string, body: string, time: string, unread: boolean }
|
||||
const notices = ref<Notice[]>([
|
||||
{ id: 'assessment-guide', icon: 'approval', category: '服务通知', title: '融资评估使用指南', desc: '完成资料填写,了解自己的融资需求。', body: '从首页选择个人或企业融资入口,按步骤填写基本信息、经营或收入、资产与负债情况。提交前请核对资料,评估结果仅供参考,具体申请条件以机构审核为准。', time: '10:30', unread: true },
|
||||
{ id: 'repayment-guide', icon: 'payout', category: '服务通知', title: '还款安排提醒', desc: '申请前,记得核对每期应还金额与日期。', body: '比较融资方案时,除利率外还应核对还款方式、每期应还金额与到期本金。正式签约后,请以机构提供的合同和还款计划为准,通过官方渠道查询账户状态。', time: '09:15', unread: false },
|
||||
{ id: 'invite-guide', icon: 'gift', category: '活动消息', title: '邀请积分说明', desc: '邀请前先了解积分规则与适用范围。', body: '可在积分中心查看邀请入口与积分说明。积分发放条件、有效期及可用权益以活动页面公布的规则为准,请勿向他人承诺固定收益或贷款结果。', time: '08:42', unread: false },
|
||||
{ id: 'privacy-guide', icon: 'system-bell', category: '系统通知', title: '个人信息保护提醒', desc: '留意授权范围,保护账号与验证码。', body: '提交资料或授权数据前,请阅读相应说明,确认使用目的、数据范围和授权期限。不要向他人泄露账户密码、短信验证码或银行支付信息。', time: '昨天', unread: false }
|
||||
])
|
||||
const filteredNotices = computed((): Notice[] => tab.value == '全部' ? notices.value : notices.value.filter((item: Notice): boolean => item.category == tab.value))
|
||||
const unreadCount = computed((): number => notices.value.filter((item: Notice): boolean => item.unread).length)
|
||||
onShow(() => {
|
||||
notices.value.forEach((item: Notice): void => {
|
||||
if (getUserStorage('finance.notice.read.' + item.id) == true) item.unread = false
|
||||
})
|
||||
})
|
||||
const markRead = (id: string): void => {
|
||||
const item = notices.value.find((value: Notice): boolean => value.id == id)
|
||||
if (item == null) return
|
||||
item.unread = false
|
||||
setUserStorage('finance.notice.read.' + id, true)
|
||||
}
|
||||
const openNotice = (item: Notice): void => {
|
||||
markRead(item.id)
|
||||
uni.showModal({ title: item.title, content: item.body, showCancel: false, confirmText: '知道了' })
|
||||
}
|
||||
const markAllRead = (): void => {
|
||||
notices.value.forEach((item: Notice): void => { markRead(item.id) })
|
||||
}
|
||||
type MessageTab = { type: string, label: string }
|
||||
const tabs: MessageTab[] = [{ type: 'all', label: '全部' }, { type: 'system', label: '系统通知' }, { type: 'service', label: '服务通知' }, { type: 'activity', label: '活动消息' }]
|
||||
const tab = ref('all'); const notices = ref<UserMessage[]>([]); const unreadCount = ref(0)
|
||||
const categoryLabel = computed((): string => tabs.find((item: MessageTab): boolean => item.type == tab.value)?.label ?? '')
|
||||
const snapshotId = ref(''); const nextBeforeId = ref(''); const loading = ref(false); const loadingMore = ref(false)
|
||||
const marking = ref(false); const opening = ref(''); const error = ref(''); const actionError = ref(''); const paginationError = ref('')
|
||||
let visible = false; let generation = 0
|
||||
const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeMessages=1' }) }
|
||||
const reset = () => { notices.value = []; unreadCount.value = 0; snapshotId.value = ''; nextBeforeId.value = ''; error.value = ''; actionError.value = ''; paginationError.value = ''; marking.value = false; opening.value = ''; loading.value = false; loadingMore.value = false }
|
||||
// UTS原生端不支持局部函数前向引用,账户切换回调在加载函数声明后绑定。
|
||||
let reloadAfterAccountChange: () => void = () => {}
|
||||
// 离页、换分类和账户切换后,旧请求不能恢复上一账户的消息或触发业务跳转。
|
||||
function current(request: number, token: string): boolean {
|
||||
if (!visible || request != generation) return false
|
||||
if (getSession()?.getString('token') == token) return true
|
||||
generation++; reset()
|
||||
if (getSession() == null) login(); else reloadAfterAccountChange()
|
||||
return false
|
||||
}
|
||||
async function load(append: boolean) {
|
||||
if (!visible || loading.value || loadingMore.value || marking.value || opening.value.length > 0) return
|
||||
const token = getSession()?.getString('token') ?? ''
|
||||
if (token.length == 0) { reset(); login(); return }
|
||||
const request = append ? generation : ++generation
|
||||
if (append) { if (nextBeforeId.value.length == 0) return; loadingMore.value = true; paginationError.value = '' }
|
||||
else { reset(); loading.value = true }
|
||||
try {
|
||||
const result = await fetchMessages(tab.value, append ? nextBeforeId.value : '', append ? snapshotId.value : '')
|
||||
if (!current(request, token)) return
|
||||
notices.value = append ? notices.value.concat(result.items) : result.items
|
||||
snapshotId.value = result.snapshotId; nextBeforeId.value = result.nextBeforeId; unreadCount.value = result.unreadCount
|
||||
} catch (err) {
|
||||
if (current(request, token)) {
|
||||
const message = err instanceof Error ? err.message : '消息加载失败,请重试'
|
||||
if (append) paginationError.value = message; else error.value = message
|
||||
}
|
||||
} finally { if (request == generation) { loading.value = false; loadingMore.value = false } }
|
||||
}
|
||||
function reload() { load(false) }
|
||||
reloadAfterAccountChange = reload
|
||||
const more = () => { load(true) }
|
||||
// 自动触底失败后保留提示,由用户手动重试,避免滚动事件连续覆盖错误。
|
||||
const autoMore = () => { if (paginationError.value.length == 0) more() }
|
||||
const changeTab = (type: string) => { if (type == tab.value || marking.value || opening.value.length > 0) return; generation++; reset(); tab.value = type; reload() }
|
||||
const openNotice = async (item: UserMessage) => {
|
||||
if (opening.value.length > 0 || marking.value || loading.value || loadingMore.value) return
|
||||
const token = getSession()?.getString('token') ?? ''; if (token.length == 0) { login(); return }
|
||||
const request = generation; opening.value = item.id; actionError.value = ''
|
||||
try {
|
||||
const detail = await fetchMessage(item.id)
|
||||
if (!current(request, token)) return
|
||||
const unread = await readMessage(item.id)
|
||||
if (!current(request, token)) return
|
||||
item.isRead = true; unreadCount.value = unread
|
||||
uni.showModal({ title: detail.item.title, content: detail.item.content + (detail.targetUnavailable ? '\n关联评估已不可用,可从融资档案查看其他记录。' : ''),
|
||||
showCancel: detail.targetUrl.length > 0, cancelText: '关闭', confirmText: detail.targetLabel.length > 0 ? detail.targetLabel : '知道了',
|
||||
success: (result) => { if (result.confirm && detail.targetUrl.length > 0 && current(request, token)) uni.navigateTo({ url: detail.targetUrl }) } })
|
||||
} catch (err) { if (current(request, token)) actionError.value = err instanceof Error ? err.message : '消息读取失败,请重试' }
|
||||
finally { if (request == generation) opening.value = '' }
|
||||
}
|
||||
const markAllRead = async () => {
|
||||
if (marking.value || opening.value.length > 0 || loading.value || loadingMore.value || snapshotId.value.length == 0 || snapshotId.value == '0') return
|
||||
const token = getSession()?.getString('token') ?? ''; if (token.length == 0) { login(); return }
|
||||
const request = generation; marking.value = true; actionError.value = ''
|
||||
try {
|
||||
const unread = await readAllMessages(snapshotId.value)
|
||||
if (!current(request, token)) return
|
||||
notices.value.forEach((item: UserMessage) => { item.isRead = true }); unreadCount.value = unread
|
||||
} catch (err) { if (current(request, token)) actionError.value = err instanceof Error ? err.message : '标记失败,请重试' }
|
||||
finally { if (request == generation) marking.value = false }
|
||||
}
|
||||
onShow(() => { visible = true; generation++; reset(); reload() })
|
||||
onHide(() => { visible = false; generation++; reset() })
|
||||
onUnload(() => { visible = false; generation++; reset() })
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -67,6 +119,12 @@
|
||||
.summary-text { color: #66758a; font-size: 23rpx; }
|
||||
.read-all { padding: 14rpx 0 14rpx 24rpx; }
|
||||
.read-all-text { color: #0868f7; font-size: 23rpx; }
|
||||
.disabled-text { color: #a1adbd; }
|
||||
.message-state { padding: 56rpx 18rpx; align-items: center; }
|
||||
.state-title { color: #52657e; font-size: 27rpx; text-align: center; line-height: 42rpx; }
|
||||
.state-note { color: #8291a5; font-size: 23rpx; margin-top: 18rpx; text-align: center; line-height: 36rpx; }
|
||||
.state-action { color: #0868f7; font-size: 25rpx; padding: 18rpx; }
|
||||
.action-error { color: #b64d35; font-size: 23rpx; line-height: 36rpx; padding: 16rpx 0; }
|
||||
.notice-list { background-color: #ffffff; border-radius: 18rpx; padding: 0 20rpx; }
|
||||
.notice { padding: 26rpx 0; border-bottom: 1rpx solid #e8eef6; flex-direction: row; align-items: center; }
|
||||
.last-notice { border-bottom-width: 0; }
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
<view class="page page-white" :style="pageStyle()">
|
||||
<AppHeader title="新闻详情" />
|
||||
<scroll-view scroll-y :style="contentStyle(96, false)">
|
||||
<view v-if="available" class="detail-content">
|
||||
<view v-if="loading" class="empty-content"><text class="empty-copy">正在加载资讯…</text></view>
|
||||
<view v-else-if="error.length > 0" class="empty-content"><text class="empty-title">{{ error }}</text><button class="primary-button empty-button" @click="reload">重新加载</button></view>
|
||||
<view v-else-if="available" class="detail-content">
|
||||
<text class="detail-category">{{ article.category }}</text><text class="detail-title">{{ article.title }}</text>
|
||||
<text class="detail-source">{{ article.source }}</text><text class="detail-meta">{{ article.date }} · {{ article.readingTime }}阅读</text>
|
||||
<image class="detail-image" :src="article.image" mode="aspectFill"></image>
|
||||
@@ -16,22 +18,26 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { usePageLayout } from '@/services/layout.uts'
|
||||
import { newsArticles, findNewsArticle, NewsArticle } from '@/packages/content/services/content.uts'
|
||||
import AppHeader from '@/components/AppHeader.uvue'
|
||||
const { contentStyle, pageStyle } = usePageLayout()
|
||||
const article = ref<NewsArticle>(newsArticles[0])
|
||||
const available = ref(true)
|
||||
onLoad((options) => {
|
||||
if (options == null) return
|
||||
const id = options['id'] == null ? '' : options['id'] as string
|
||||
const legacyTitle = options['title'] == null ? '' : options['title'] as string
|
||||
if (id == '' && legacyTitle == '') return
|
||||
const selected = findNewsArticle(id, legacyTitle)
|
||||
available.value = selected != null
|
||||
if (selected != null) article.value = selected
|
||||
})
|
||||
const goNews = (): void => { uni.redirectTo({ url: '/packages/content/news/news' }) }
|
||||
import { usePageLayout } from '@/services/layout.uts'
|
||||
import { fetchArticle, emptyArticle, NewsArticle } from '@/services/content.uts'
|
||||
import AppHeader from '@/components/AppHeader.uvue'
|
||||
const { contentStyle, pageStyle } = usePageLayout()
|
||||
const article = ref<NewsArticle>(emptyArticle()); const available = ref(false); const loading = ref(false); const error = ref('')
|
||||
let id = ''; let visible = false; let generation = 0
|
||||
async function reload() {
|
||||
const request = ++generation; loading.value = true; available.value = false; error.value = ''
|
||||
try {
|
||||
const result = await fetchArticle(id)
|
||||
if (!visible || request != generation) return
|
||||
article.value = result; available.value = true
|
||||
} catch (err) { if (visible && request == generation) error.value = err instanceof Error ? err.message : '资讯加载失败,请重试' }
|
||||
finally { if (request == generation) loading.value = false }
|
||||
}
|
||||
onLoad((options) => { id = options?.['id'] ?? '' })
|
||||
onShow(() => { visible = true; reload() })
|
||||
onHide(() => { visible = false; generation++ })
|
||||
onUnload(() => { visible = false; generation++ })
|
||||
const goNews = () => { uni.redirectTo({ url: '/packages/content/news/news' }) }
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -15,11 +15,16 @@
|
||||
</view>
|
||||
</view>
|
||||
<view class="lesson-section">
|
||||
<view class="section-head"><text class="section-title">{{ activeTopic == '' ? '精选课程' : activeTopicTitle }}</text><view class="all-lessons" @click="activeTopic = ''"><text class="lesson-count">{{ activeTopic == '' ? lessons.length + ' 门课程' : '查看全部' }}</text></view></view>
|
||||
<view class="section-head"><text class="section-title">{{ activeTopic == '' ? '精选课程' : activeTopicTitle }}</text><view class="all-lessons" @click="selectTopic('')"><text class="lesson-count">{{ activeTopic == '' ? total + ' 门课程' : '查看全部' }}</text></view></view>
|
||||
<view v-if="loading" class="content-state"><text>正在加载课程…</text></view>
|
||||
<view v-else-if="error.length > 0" class="content-state"><text>{{ error }}</text><text class="content-retry" @click="reload">重新加载</text></view>
|
||||
<view v-else-if="lessons.length == 0" class="content-state"><text>暂无课程</text></view>
|
||||
<view class="lesson-card" v-for="item in lessons" :key="item.id" @click="openLesson(item.id)">
|
||||
<image :src="item.image" mode="aspectFill" class="lesson-card-image"></image>
|
||||
<view class="lesson-copy"><text class="lesson-title">{{ item.title }}</text><text class="lesson-desc">{{ item.desc }}</text><view class="lesson-meta"><text class="lesson-duration">{{ item.duration }}</text><text class="lesson-tag">{{ item.tag }}</text></view></view>
|
||||
</view>
|
||||
<text v-if="moreError.length > 0" class="content-state">{{ moreError }}</text>
|
||||
<text v-if="hasMore" class="content-retry" @click="more">{{ loadingMore ? '正在加载…' : '加载更多' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
@@ -27,28 +32,51 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { usePageLayout } from '@/services/layout.uts'
|
||||
import { financeLessons, FinanceLesson } from '@/packages/content/services/content.uts'
|
||||
import AppHeader from '@/components/AppHeader.uvue'
|
||||
import AppIcon from '@/components/AppIcon.uvue'
|
||||
const { contentStyle, pageStyle } = usePageLayout()
|
||||
type LessonTopic = { id: string, title: string, desc: string, icon: string }
|
||||
const topics: LessonTopic[] = [
|
||||
{ id: 'credit', title: '个人信用提升', desc: '了解信用记录与还款能力', icon: 'user' },
|
||||
{ id: 'business', title: '小微经营融资', desc: '梳理经营材料与融资成本', icon: 'building' },
|
||||
{ id: 'documents', title: '材料准备清单', desc: '按申请场景,逐项检查资料', icon: 'file-text' }
|
||||
]
|
||||
const activeTopic = ref('')
|
||||
const activeTopicTitle = computed((): string => topics.find((item: LessonTopic): boolean => item.id == activeTopic.value)?.title ?? '精选课程')
|
||||
const lessons = computed((): FinanceLesson[] => {
|
||||
if (activeTopic.value == '') return financeLessons
|
||||
return financeLessons.filter((item: FinanceLesson): boolean => item.topics.includes(activeTopic.value))
|
||||
})
|
||||
const selectTopic = (id: string): void => { activeTopic.value = activeTopic.value == id ? '' : id }
|
||||
const openLesson = (id: string): void => { uni.navigateTo({ url: '/packages/content/lesson/lesson?id=' + id }) }
|
||||
import { usePageLayout } from '@/services/layout.uts'
|
||||
import { fetchLessons, FinanceLesson } from '@/services/content.uts'
|
||||
import AppHeader from '@/components/AppHeader.uvue'
|
||||
import AppIcon from '@/components/AppIcon.uvue'
|
||||
const { contentStyle, pageStyle } = usePageLayout()
|
||||
type LessonTopic = { id: string, title: string, desc: string, icon: string }
|
||||
const topics: LessonTopic[] = [
|
||||
{ id: 'credit', title: '个人信用提升', desc: '了解信用记录与还款能力', icon: 'user' },
|
||||
{ id: 'business', title: '小微经营融资', desc: '梳理经营材料与融资成本', icon: 'building' },
|
||||
{ id: 'documents', title: '材料准备清单', desc: '按申请场景,逐项检查资料', icon: 'file-text' }
|
||||
]
|
||||
const activeTopic = ref(''); const lessons = ref<FinanceLesson[]>([]); const total = ref(0); const hasMore = ref(false)
|
||||
const loading = ref(false); const loadingMore = ref(false); const error = ref(''); const moreError = ref('')
|
||||
let generation = 0; let visible = false; let page = 1
|
||||
const activeTopicTitle = computed((): string => topics.find((item: LessonTopic): boolean => item.id == activeTopic.value)?.title ?? '精选课程')
|
||||
async function load(append: boolean) {
|
||||
if (!visible || loading.value || loadingMore.value || (append && !hasMore.value)) return
|
||||
const request = append ? generation : ++generation
|
||||
if (append) { loadingMore.value = true; moreError.value = '' }
|
||||
else { loading.value = true; error.value = ''; lessons.value = []; total.value = 0; hasMore.value = false; page = 1 }
|
||||
try {
|
||||
const result = await fetchLessons(activeTopic.value, append ? page + 1 : 1)
|
||||
if (!visible || request != generation) return
|
||||
lessons.value = append ? lessons.value.concat(result.items) : result.items
|
||||
total.value = result.total; hasMore.value = result.hasMore
|
||||
if (append) page++
|
||||
} catch (err) {
|
||||
if (visible && request == generation) {
|
||||
const message = err instanceof Error ? err.message : '课程加载失败,请重试'
|
||||
if (append) moreError.value = message; else error.value = message
|
||||
}
|
||||
} finally { if (request == generation) { loading.value = false; loadingMore.value = false } }
|
||||
}
|
||||
function reload() { load(false) }
|
||||
const more = () => { load(true) }
|
||||
const selectTopic = (id: string) => { generation++; loading.value = false; loadingMore.value = false; activeTopic.value = activeTopic.value == id ? '' : id; reload() }
|
||||
const openLesson = (id: string) => { uni.navigateTo({ url: '/packages/content/lesson/lesson?id=' + id }) }
|
||||
onShow(() => { visible = true; loading.value = false; loadingMore.value = false; reload() })
|
||||
onHide(() => { visible = false; generation++ })
|
||||
onUnload(() => { visible = false; generation++ })
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.content-state { padding: 30rpx 0; color: #66758a; font-size: 25rpx; line-height: 40rpx; }
|
||||
.content-retry { color: #0868f7; font-size: 25rpx; padding: 22rpx 0; }
|
||||
.classroom-content { padding: 24rpx 28rpx 48rpx; }
|
||||
.classroom-hero { border-radius: 20rpx; background-color: #e8f2ff; flex-direction: row; overflow: hidden; align-items: center; }
|
||||
.hero-copy { flex: 1; padding: 28rpx 12rpx 28rpx 26rpx; }
|
||||
|
||||
+144
-11
@@ -2,8 +2,9 @@
|
||||
<view class="page" :style="pageStyle()">
|
||||
<AppHeader title="帮助与反馈" />
|
||||
<view class="help-body" :style="contentStyle(96, false)">
|
||||
<view class="help-tabs"><view :class="tab == 'faq' ? 'help-tab active-tab' : 'help-tab'" @click="changeTab('faq')"><text :class="tab == 'faq' ? 'tab-text active-text' : 'tab-text'">常见问题</text></view><view :class="tab == 'feedback' ? 'help-tab active-tab' : 'help-tab'" @click="changeTab('feedback')"><text :class="tab == 'feedback' ? 'tab-text active-text' : 'tab-text'">意见反馈</text></view></view>
|
||||
<scroll-view scroll-y class="help-scroll">
|
||||
<view class="help-content">
|
||||
<view v-if="tab == 'faq'" class="help-content">
|
||||
<text class="help-title">常见问题</text><text class="help-description">关于账户和融资使用,您可能想了解这些</text>
|
||||
<view v-if="loading" class="help-state"><text class="state-text">正在加载…</text></view>
|
||||
<view v-else-if="errorMessage.length > 0" class="help-state"><text class="state-text">{{ errorMessage }}</text><button class="retry-button" @click="load">重新加载</button></view>
|
||||
@@ -18,6 +19,30 @@
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
<view v-else class="feedback-content">
|
||||
<template v-if="loggedIn">
|
||||
<text class="section-title">提交反馈</text>
|
||||
<textarea v-model="draft" :disabled="sending" :maxlength="255" class="feedback-input" placeholder="请填写遇到的问题或建议" />
|
||||
<view class="feedback-count"><text class="count-text">{{ draft.length }}/255</text></view>
|
||||
<text v-if="submitError.length > 0" class="feedback-error">{{ submitError }}</text>
|
||||
<text v-if="submitted" class="feedback-success">反馈已提交</text>
|
||||
<button class="primary-button feedback-submit" :disabled="sending || draft.trim().length == 0" @click="send">{{ sending ? '提交中…' : (submitError.length > 0 ? '重试提交' : '提交反馈') }}</button>
|
||||
<view class="feedback-list-head"><text class="section-title">我的反馈</text><text class="refresh-link" @click="reloadFeedback">刷新</text></view>
|
||||
<view v-if="feedbackLoading" class="feedback-state"><text class="state-text">正在加载…</text></view>
|
||||
<view v-else-if="feedbackError.length > 0" class="feedback-state"><text class="state-text">{{ feedbackError }}</text><button class="retry-button" @click="reloadFeedback">重新加载</button></view>
|
||||
<view v-else-if="records.length == 0" class="feedback-state"><text class="state-text">暂无反馈记录</text></view>
|
||||
<template v-else>
|
||||
<view v-for="item in records" :key="item.id" class="feedback-item">
|
||||
<view class="feedback-meta"><text :class="item.status == 'resolved' ? 'feedback-status replied-status' : 'feedback-status'">{{ statusLabel(item.status) }}</text><text class="feedback-time">{{ item.createdAt }}</text></view>
|
||||
<text class="feedback-copy" :selectable="true">{{ item.content }}</text>
|
||||
<view v-if="item.reply.length > 0" class="feedback-reply"><text class="reply-label">处理回复</text><text class="reply-copy" :selectable="true">{{ item.reply }}</text><text class="reply-time">{{ item.updatedAt }}</text></view>
|
||||
</view>
|
||||
<text v-if="moreError.length > 0" class="feedback-error">{{ moreError }}</text>
|
||||
<text v-if="hasMore" class="feedback-more" @click="loadMore">{{ loadingMore ? '正在加载…' : (moreError.length > 0 ? '重试加载' : '加载更多') }}</text>
|
||||
</template>
|
||||
</template>
|
||||
<view v-else class="help-state"><text class="state-text">登录后可提交和查看反馈</text><button class="retry-button" @click="login">去登录</button></view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="contact-bar" :style="bottomStyle(20)"><button class="primary-button contact-button" @click="contact">联系客服</button></view>
|
||||
</view>
|
||||
@@ -27,14 +52,20 @@
|
||||
import AppHeader from '@/components/AppHeader.uvue'
|
||||
import AppIcon from '@/components/AppIcon.uvue'
|
||||
import { usePageLayout } from '@/services/layout.uts'
|
||||
import { getSession } from '@/services/api.uts'
|
||||
import { helpCategories, HelpQuestion, fetchHelpQuestions, contactCustomerService } from '@/packages/content/services/help.uts'
|
||||
import { FeedbackItem, feedbackKey, submitFeedback, fetchFeedback } from '@/packages/content/services/feedback.uts'
|
||||
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
|
||||
type HelpGroup = { category: string, items: HelpQuestion[] }
|
||||
const questions = ref<HelpQuestion[]>([])
|
||||
const expanded = ref('')
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
let alive = true
|
||||
const tab = ref('faq'); const loggedIn = ref(false); const draft = ref(''); const sending = ref(false)
|
||||
const submitError = ref(''); const submitted = ref(false); const records = ref<FeedbackItem[]>([])
|
||||
const feedbackLoading = ref(false); const loadingMore = ref(false); const feedbackError = ref(''); const moreError = ref(''); const hasMore = ref(false)
|
||||
let visible = false; let generation = 0; let faqRequest = 0; let listRequest = 0; let page = 0
|
||||
let owner = ''; let requestKey = ''; let requestText = ''
|
||||
const groups = computed((): HelpGroup[] => {
|
||||
const results: HelpGroup[] = []
|
||||
helpCategories.forEach((category: string) => {
|
||||
@@ -44,20 +75,100 @@
|
||||
return results
|
||||
})
|
||||
const toggle = (id: string) => { expanded.value = expanded.value == id ? '' : id }
|
||||
const contact = () => { contactCustomerService() }
|
||||
const load = async () => {
|
||||
if (loading.value) return
|
||||
loading.value = true; errorMessage.value = ''
|
||||
try { const items = await fetchHelpQuestions(); if (alive) questions.value = items }
|
||||
catch (_error) { if (alive) errorMessage.value = '暂时无法加载帮助内容,请稍后重试' }
|
||||
finally { if (alive) loading.value = false }
|
||||
const statusLabel = (status: string): string => {
|
||||
if (status == 'pending') return '待处理'
|
||||
if (status == 'processing') return '处理中'
|
||||
if (status == 'resolved') return '已回复'
|
||||
if (status == 'closed') return '已关闭'
|
||||
return '待核对'
|
||||
}
|
||||
onLoad(() => { load() })
|
||||
onUnload(() => { alive = false })
|
||||
const contact = () => { contactCustomerService() }
|
||||
const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeHelp=1' }) }
|
||||
const clearFeedback = () => {
|
||||
draft.value = ''; records.value = []; submitError.value = ''; submitted.value = false; sending.value = false
|
||||
feedbackLoading.value = false; loadingMore.value = false; feedbackError.value = ''; moreError.value = ''; hasMore.value = false
|
||||
requestKey = ''; requestText = ''; page = 0; listRequest++
|
||||
}
|
||||
let reloadAfterAccountChange: () => void = () => {}
|
||||
// 离页或切换账户后,旧提交和列表响应不得回填私有反馈。
|
||||
function current(ticket: number, token: string): boolean {
|
||||
if (!visible || ticket != generation) return false
|
||||
const sessionToken = getSession()?.getString('token') ?? ''
|
||||
if (sessionToken == token) return true
|
||||
generation++; clearFeedback(); owner = sessionToken; loggedIn.value = owner.length > 0
|
||||
if (loggedIn.value) reloadAfterAccountChange()
|
||||
return false
|
||||
}
|
||||
const load = async () => {
|
||||
if (!visible || loading.value) return
|
||||
const ticket = ++faqRequest
|
||||
loading.value = true; errorMessage.value = ''
|
||||
try { const items = await fetchHelpQuestions(); if (visible && ticket == faqRequest) questions.value = items }
|
||||
catch (_error) { if (visible && ticket == faqRequest) errorMessage.value = '暂时无法加载帮助内容,请稍后重试' }
|
||||
finally { if (ticket == faqRequest) loading.value = false }
|
||||
}
|
||||
async function loadFeedback(append: boolean) {
|
||||
if (!visible || !loggedIn.value || feedbackLoading.value || loadingMore.value || sending.value) return
|
||||
const token = getSession()?.getString('token') ?? ''; const ticket = generation
|
||||
if (!current(ticket, owner) || token.length == 0 || (append && !hasMore.value)) return
|
||||
const request = ++listRequest; const nextPage = append ? page + 1 : 1
|
||||
if (append) { loadingMore.value = true; moreError.value = '' }
|
||||
else { feedbackLoading.value = true; feedbackError.value = ''; moreError.value = ''; records.value = []; hasMore.value = false }
|
||||
try {
|
||||
const result = await fetchFeedback(nextPage)
|
||||
if (!current(ticket, token) || request != listRequest) return
|
||||
if (append) { result.items.forEach((item: FeedbackItem) => { if (!records.value.some((old: FeedbackItem): boolean => old.id == item.id)) records.value.push(item) }) }
|
||||
else records.value = result.items
|
||||
page = result.page; hasMore.value = result.hasMore
|
||||
} catch (error) {
|
||||
if (current(ticket, token) && request == listRequest) {
|
||||
const message = error instanceof Error ? error.message : '反馈记录加载失败,请重试'
|
||||
if (append) moreError.value = message; else feedbackError.value = message
|
||||
}
|
||||
} finally { if (ticket == generation && request == listRequest) { feedbackLoading.value = false; loadingMore.value = false } }
|
||||
}
|
||||
function reloadFeedback() { loadFeedback(false) }
|
||||
reloadAfterAccountChange = reloadFeedback
|
||||
const loadMore = () => { loadFeedback(true) }
|
||||
const changeTab = (value: string) => {
|
||||
tab.value = value
|
||||
if (value == 'feedback') reloadFeedback(); else if (questions.value.length == 0) load()
|
||||
}
|
||||
const send = async () => {
|
||||
if (sending.value || draft.value.trim().length == 0) return
|
||||
const token = getSession()?.getString('token') ?? ''; const ticket = generation
|
||||
if (!current(ticket, owner)) return
|
||||
if (token.length == 0) { login(); return }
|
||||
const content = draft.value.trim()
|
||||
// 同一段内容的网络失败重试复用请求键;成功后才清空,避免生成重复反馈。
|
||||
if (requestKey.length == 0 || requestText != content) { requestKey = feedbackKey(); requestText = content }
|
||||
listRequest++; feedbackLoading.value = false; loadingMore.value = false
|
||||
sending.value = true; submitError.value = ''; submitted.value = false
|
||||
try {
|
||||
await submitFeedback(requestText, requestKey)
|
||||
if (!current(ticket, token)) return
|
||||
draft.value = ''; requestKey = ''; requestText = ''; submitted.value = true
|
||||
} catch (error) { if (current(ticket, token)) submitError.value = error instanceof Error ? error.message : '提交失败,请重试' }
|
||||
finally { if (ticket == generation) { sending.value = false; if (submitted.value) reloadFeedback() } }
|
||||
}
|
||||
watch(draft, () => { if (draft.value.length > 0) submitted.value = false })
|
||||
onLoad((options) => { if (options?.['feedback'] == '1') tab.value = 'feedback' })
|
||||
onShow(() => {
|
||||
visible = true; generation++; const token = getSession()?.getString('token') ?? ''
|
||||
if (token != owner) clearFeedback()
|
||||
owner = token; loggedIn.value = token.length > 0; load(); if (tab.value == 'feedback') reloadFeedback()
|
||||
})
|
||||
onHide(() => { visible = false; generation++; faqRequest++; listRequest++; loading.value = false; feedbackLoading.value = false; loadingMore.value = false; sending.value = false; records.value = [] })
|
||||
onUnload(() => { visible = false; generation++; faqRequest++; clearFeedback() })
|
||||
</script>
|
||||
<style>
|
||||
.help-body { overflow: hidden; }
|
||||
.help-scroll { flex: 1; min-height: 0; }
|
||||
.help-tabs { flex-shrink: 0; flex-direction: row; height: 88rpx; padding: 0 30rpx; background-color: #ffffff; border-bottom: 1rpx solid #e8eef6; }
|
||||
.help-tab { flex: 1; align-items: center; justify-content: center; border-bottom: 4rpx solid transparent; }
|
||||
.active-tab { border-bottom-color: #0868f7; }
|
||||
.tab-text { color: #66758a; font-size: 28rpx; }
|
||||
.active-text { color: #0868f7; font-weight: 600; }
|
||||
.help-content { padding: 32rpx 30rpx 40rpx; }
|
||||
.help-title { color: #23344e; font-size: 38rpx; line-height: 54rpx; font-weight: 700; }
|
||||
.help-description { color: #738198; font-size: 25rpx; line-height: 40rpx; margin-top: 12rpx; }
|
||||
@@ -77,4 +188,26 @@
|
||||
.retry-button { color: #0868f7; background-color: #e8f2ff; font-size: 26rpx; margin-top: 26rpx; padding: 0 30rpx; height: 76rpx; line-height: 76rpx; border-radius: 12rpx; }
|
||||
.contact-bar { flex-shrink: 0; padding: 20rpx 30rpx; border-top: 1rpx solid #e8eef6; background-color: #ffffff; }
|
||||
.contact-button { margin: 0; }
|
||||
.feedback-content { padding: 30rpx 30rpx 48rpx; }
|
||||
.section-title { font-size: 30rpx; line-height: 44rpx; color: #29394f; font-weight: 600; }
|
||||
.feedback-input { width: 100%; height: 220rpx; margin-top: 22rpx; padding: 22rpx; background-color: #ffffff; border: 1rpx solid #dbe3ee; border-radius: 12rpx; color: #29394f; font-size: 27rpx; line-height: 42rpx; }
|
||||
.feedback-count { align-items: flex-end; padding: 12rpx 0; }
|
||||
.count-text { color: #738198; font-size: 23rpx; line-height: 34rpx; }
|
||||
.feedback-submit { margin: 18rpx 0 0; }
|
||||
.feedback-error { color: #b64d35; font-size: 25rpx; line-height: 40rpx; padding: 12rpx 0; }
|
||||
.feedback-success { color: #258064; font-size: 25rpx; line-height: 40rpx; }
|
||||
.feedback-list-head { flex-direction: row; align-items: center; justify-content: space-between; margin-top: 42rpx; padding-top: 26rpx; border-top: 1rpx solid #dbe3ee; }
|
||||
.refresh-link { font-size: 25rpx; color: #0868f7; padding: 10rpx 0 10rpx 24rpx; }
|
||||
.feedback-state { padding: 52rpx 16rpx; align-items: center; }
|
||||
.feedback-item { padding: 26rpx 0; border-bottom: 1rpx solid #dbe3ee; }
|
||||
.feedback-meta { flex-direction: row; align-items: center; justify-content: space-between; flex-wrap: wrap; }
|
||||
.feedback-status { color: #9b6c23; font-size: 24rpx; line-height: 38rpx; }
|
||||
.replied-status { color: #258064; }
|
||||
.feedback-time { color: #738198; font-size: 22rpx; line-height: 38rpx; }
|
||||
.feedback-copy { color: #29394f; font-size: 27rpx; line-height: 42rpx; margin-top: 14rpx; }
|
||||
.feedback-reply { border-left: 4rpx solid #8ebba9; padding-left: 20rpx; margin-top: 22rpx; }
|
||||
.reply-label { color: #258064; font-size: 24rpx; line-height: 36rpx; font-weight: 600; }
|
||||
.reply-copy { color: #52657e; font-size: 25rpx; line-height: 42rpx; margin-top: 8rpx; }
|
||||
.reply-time { color: #738198; font-size: 22rpx; line-height: 36rpx; margin-top: 10rpx; }
|
||||
.feedback-more { text-align: center; color: #0868f7; font-size: 25rpx; line-height: 40rpx; padding: 26rpx; }
|
||||
</style>
|
||||
|
||||
@@ -2,16 +2,19 @@
|
||||
<view class="page" :style="pageStyle()">
|
||||
<AppHeader title="课程详情" />
|
||||
<scroll-view scroll-y :scroll-into-view="scrollTarget" :style="contentStyle(96, false)">
|
||||
<view v-if="available" class="lesson-detail-content">
|
||||
<view v-if="loading" class="empty-content"><text class="empty-copy">正在加载课程…</text></view>
|
||||
<view v-else-if="error.length > 0" class="empty-content"><text class="empty-title">{{ error }}</text><button class="primary-button empty-button" @click="reload">重新加载</button></view>
|
||||
<view v-else-if="available" class="lesson-detail-content">
|
||||
<image class="lesson-cover" :src="lesson.image" mode="aspectFill"></image>
|
||||
<text class="lesson-detail-title">{{ lesson.title }}</text>
|
||||
<view class="lesson-detail-meta"><text class="lesson-tag">{{ lesson.tag }}</text><text class="lesson-duration">{{ lesson.duration }} · {{ lesson.chapters.length }} 节图文</text></view>
|
||||
<text class="lesson-intro">{{ lesson.intro }}</text>
|
||||
<view class="progress-head"><text class="progress-label">学习进度</text><text class="progress-value">已完成 {{ completedCount }}/{{ lesson.chapters.length }} 节</text></view>
|
||||
<view class="lesson-progress"><view class="progress-fill" :style="'width:' + progress + '%;'"></view></view>
|
||||
<view v-if="progressError.length > 0" class="progress-error"><text>{{ progressError }}</text><text class="progress-retry" @click="reload">刷新课程</text></view>
|
||||
<button class="primary-button start-learning" @click="start">{{ completedCount == 0 ? '开始学习' : (completedCount == lesson.chapters.length ? '回顾课程' : '继续学习') }}</button>
|
||||
<text class="chapter-heading">课程目录</text>
|
||||
<view class="chapter-card" v-for="(item, index) in lesson.chapters" :key="item.title" :id="'chapter-' + index">
|
||||
<view class="chapter-card" v-for="(item, index) in lesson.chapters" :key="item.id" :id="'chapter-' + index">
|
||||
<view class="chapter-row" @click="toggleChapter(index)">
|
||||
<view :class="completed[index] ? 'chapter-index completed-index' : 'chapter-index'"><text :class="completed[index] ? 'index-text completed-text' : 'index-text'">{{ index + 1 }}</text></view>
|
||||
<view class="chapter-copy"><text class="chapter-title">{{ item.title }}</text><text :class="completed[index] ? 'chapter-state completed-state' : 'chapter-state'">{{ completed[index] ? '已完成 · 可再次阅读' : '点击展开阅读' }}</text></view>
|
||||
@@ -19,7 +22,7 @@
|
||||
</view>
|
||||
<view v-if="activeIndex == index" class="chapter-content">
|
||||
<text class="chapter-body">{{ item.body }}</text>
|
||||
<button :class="completed[index] ? 'chapter-done chapter-already-done' : 'chapter-done'" :disabled="completed[index]" @click="completeChapter(index)">{{ completed[index] ? '本节已完成' : '我已读完,完成本节' }}</button>
|
||||
<button :class="completed[index] ? 'chapter-done chapter-already-done' : 'chapter-done'" :disabled="completed[index] || saving" @click="completeChapter(index)">{{ completed[index] ? '本节已完成' : (saving ? '正在保存…' : (loggedIn ? '我已读完,完成本节' : '登录后记录学习进度')) }}</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -29,54 +32,77 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { getUserStorage, setUserStorage } from '@/services/storage.uts'
|
||||
|
||||
import { usePageLayout } from '@/services/layout.uts'
|
||||
import { financeLessons, findFinanceLesson, FinanceLesson } from '@/packages/content/services/content.uts'
|
||||
import AppHeader from '@/components/AppHeader.uvue'
|
||||
import AppIcon from '@/components/AppIcon.uvue'
|
||||
const { contentStyle, pageStyle } = usePageLayout()
|
||||
const lesson = ref<FinanceLesson>(financeLessons[0])
|
||||
const available = ref(true)
|
||||
const completed = ref<boolean[]>([])
|
||||
const activeIndex = ref(-1)
|
||||
const scrollTarget = ref('')
|
||||
const storageKey = (index: number): string => 'finance.lesson.' + lesson.value.id + '.chapter.' + index
|
||||
const restoreProgress = (): void => {
|
||||
completed.value = lesson.value.chapters.map((_item, index): boolean => getUserStorage(storageKey(index)) == true)
|
||||
}
|
||||
const completedCount = computed((): number => completed.value.filter((value: boolean): boolean => value).length)
|
||||
const progress = computed((): number => Math.round(completedCount.value * 100 / lesson.value.chapters.length))
|
||||
onLoad((options) => {
|
||||
if (options != null) {
|
||||
const id = options['id'] == null ? '' : options['id'] as string
|
||||
const legacyTitle = options['title'] == null ? '' : options['title'] as string
|
||||
if (id != '' || legacyTitle != '') {
|
||||
const selected = findFinanceLesson(id, legacyTitle)
|
||||
available.value = selected != null
|
||||
if (selected != null) lesson.value = selected
|
||||
}
|
||||
}
|
||||
restoreProgress()
|
||||
})
|
||||
const toggleChapter = (index: number): void => { activeIndex.value = activeIndex.value == index ? -1 : index }
|
||||
const completeChapter = (index: number): void => {
|
||||
setUserStorage(storageKey(index), true)
|
||||
const next = completed.value.slice()
|
||||
next[index] = true
|
||||
completed.value = next
|
||||
}
|
||||
const start = (): void => {
|
||||
let nextIndex = completed.value.findIndex((value: boolean): boolean => !value)
|
||||
if (nextIndex < 0) nextIndex = 0
|
||||
activeIndex.value = nextIndex
|
||||
scrollTarget.value = ''
|
||||
nextTick(() => { scrollTarget.value = 'chapter-' + nextIndex })
|
||||
}
|
||||
const goClassroom = (): void => { uni.redirectTo({ url: '/packages/content/classroom/classroom' }) }
|
||||
import { getSession } from '@/services/api.uts'
|
||||
import { usePageLayout } from '@/services/layout.uts'
|
||||
import { fetchLesson, fetchLessonProgress, completeLessonChapter, emptyLesson, FinanceLesson } from '@/services/content.uts'
|
||||
import AppHeader from '@/components/AppHeader.uvue'
|
||||
import AppIcon from '@/components/AppIcon.uvue'
|
||||
const { contentStyle, pageStyle } = usePageLayout()
|
||||
const lesson = ref<FinanceLesson>(emptyLesson()); const available = ref(false); const loading = ref(false); const error = ref('')
|
||||
const completedIds = ref<string[]>([]); const activeIndex = ref(-1); const scrollTarget = ref('')
|
||||
const progressError = ref(''); const loggedIn = ref(false); const saving = ref(false)
|
||||
let id = ''; let visible = false; let generation = 0
|
||||
let reloadAfterAccountChange: () => void = () => {}
|
||||
const tokenNow = (): string => getSession()?.getString('token') ?? ''
|
||||
function current(request: number, token: string): boolean {
|
||||
if (!visible || request != generation) return false
|
||||
if (tokenNow() == token) return true
|
||||
// 学习内容公开,但完成记录只属于当前账户;切换后立即清除旧进度。
|
||||
generation++; completedIds.value = []; saving.value = false; reloadAfterAccountChange()
|
||||
return false
|
||||
}
|
||||
async function reload() {
|
||||
const request = ++generation; const token = tokenNow()
|
||||
loading.value = true; available.value = false; error.value = ''; progressError.value = ''; completedIds.value = []; saving.value = false
|
||||
loggedIn.value = token.length > 0
|
||||
try {
|
||||
const result = await fetchLesson(id)
|
||||
if (!current(request, token)) return
|
||||
lesson.value = result; activeIndex.value = -1; available.value = true
|
||||
if (token.length > 0) {
|
||||
try {
|
||||
const state = await fetchLessonProgress(id)
|
||||
if (!current(request, token)) return
|
||||
if (state.lessonVersion != result.lessonVersion) { progressError.value = '课程已更新,请刷新后继续学习'; return }
|
||||
completedIds.value = state.completedChapterIds
|
||||
} catch (err) { if (current(request, token)) progressError.value = err instanceof Error ? err.message : '进度加载失败,请重试' }
|
||||
}
|
||||
} catch (err) { if (current(request, token)) error.value = err instanceof Error ? err.message : '课程加载失败,请重试' }
|
||||
finally { if (request == generation) loading.value = false }
|
||||
}
|
||||
reloadAfterAccountChange = () => { reload() }
|
||||
const completed = computed((): boolean[] => lesson.value.chapters.map((item): boolean => completedIds.value.includes(item.id)))
|
||||
const completedCount = computed((): number => completedIds.value.length)
|
||||
const progress = computed((): number => lesson.value.chapters.length > 0 ? Math.round(completedCount.value * 100 / lesson.value.chapters.length) : 0)
|
||||
const toggleChapter = (index: number) => { activeIndex.value = activeIndex.value == index ? -1 : index }
|
||||
const completeChapter = async (index: number) => {
|
||||
if (!available.value || saving.value || index < 0 || index >= lesson.value.chapters.length) return
|
||||
const token = tokenNow()
|
||||
if (token.length == 0) { uni.navigateTo({ url: '/packages/account/login/login?resumeLesson=1&lessonId=' + id }); return }
|
||||
if (progressError.value.length > 0) { uni.showToast({ title: '请先刷新课程和进度', icon: 'none' }); return }
|
||||
const request = generation; saving.value = true; progressError.value = ''
|
||||
try {
|
||||
const state = await completeLessonChapter(id, lesson.value.chapters[index].id, lesson.value.lessonVersion)
|
||||
if (current(request, token)) completedIds.value = state.completedChapterIds
|
||||
} catch (err) { if (current(request, token)) progressError.value = err instanceof Error ? err.message : '进度保存失败,请重试' }
|
||||
finally { if (request == generation) saving.value = false }
|
||||
}
|
||||
const start = () => {
|
||||
let nextIndex = completed.value.findIndex((value: boolean): boolean => !value)
|
||||
if (nextIndex < 0) nextIndex = 0
|
||||
activeIndex.value = nextIndex; scrollTarget.value = ''
|
||||
nextTick(() => { scrollTarget.value = 'chapter-' + nextIndex })
|
||||
}
|
||||
const goClassroom = () => { uni.redirectTo({ url: '/packages/content/classroom/classroom' }) }
|
||||
onLoad((options) => { id = options?.['id'] ?? '' })
|
||||
onShow(() => { visible = true; reload() })
|
||||
onHide(() => { visible = false; generation++; completedIds.value = []; saving.value = false })
|
||||
onUnload(() => { visible = false; generation++; completedIds.value = [] })
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.progress-error { color: #b64d35; font-size: 24rpx; line-height: 40rpx; margin-top: 16rpx; }
|
||||
.progress-retry { color: #0868f7; padding: 14rpx 0; }
|
||||
.lesson-detail-content { padding: 24rpx 28rpx 48rpx; }
|
||||
.lesson-cover { width: 100%; height: 260rpx; border-radius: 18rpx; }
|
||||
.lesson-detail-title { color: #172640; font-size: 36rpx; line-height: 50rpx; font-weight: 700; margin-top: 24rpx; }
|
||||
|
||||
@@ -2,21 +2,26 @@
|
||||
<view class="page" :style="pageStyle()">
|
||||
<AppHeader title="新闻" :back="false" />
|
||||
<view class="news-tabs">
|
||||
<view v-for="item in tabs" :key="item" :class="tab == item ? 'news-tab active-tab' : 'news-tab'" @click="tab = item"><text :class="tab == item ? 'tab-text active-text' : 'tab-text'">{{ item }}</text></view>
|
||||
<view v-for="item in tabs" :key="item" :class="tab == item ? 'news-tab active-tab' : 'news-tab'" @click="changeTab(item)"><text :class="tab == item ? 'tab-text active-text' : 'tab-text'">{{ item }}</text></view>
|
||||
</view>
|
||||
<scroll-view scroll-y :style="contentStyle(314, false)">
|
||||
<view class="news-content">
|
||||
<view v-if="tab == '推荐'" class="headline" @click="openArticle(headline.id)">
|
||||
<view class="headline-copy"><text class="headline-tag">政策观察</text><text class="headline-title">{{ headline.title }}</text><text class="headline-date">{{ headline.date }}</text></view>
|
||||
<view v-if="tab == '推荐' && articles.length > 0" class="headline" @click="openArticle(headline.id)">
|
||||
<view class="headline-copy"><text class="headline-tag">{{ headline.category }}</text><text class="headline-title">{{ headline.title }}</text><text class="headline-date">{{ headline.date }}</text></view>
|
||||
<image class="headline-art" :src="headline.image" mode="aspectFill"></image>
|
||||
</view>
|
||||
<view class="list-head"><text class="list-title">{{ tab == '推荐' ? '最新资讯' : tab }}</text><text class="list-count">{{ filteredArticles.length }} 篇</text></view>
|
||||
<view class="list-head"><text class="list-title">{{ tab == '推荐' ? '最新资讯' : tab }}</text><text class="list-count">{{ total }} 篇</text></view>
|
||||
<view v-if="loading" class="content-state"><text>正在加载资讯…</text></view>
|
||||
<view v-else-if="error.length > 0" class="content-state"><text>{{ error }}</text><text class="content-retry" @click="reload">重新加载</text></view>
|
||||
<view v-else-if="articles.length == 0" class="content-state"><text>暂无资讯</text></view>
|
||||
<view class="article-list">
|
||||
<view class="article" v-for="(item, index) in filteredArticles" :key="item.id" :class="index == filteredArticles.length - 1 ? 'last-article' : ''" @click="openArticle(item.id)">
|
||||
<view class="article-copy"><text class="article-title">{{ item.title }}</text><text class="article-summary">{{ item.summary }}</text><text class="article-meta">{{ item.category }} · {{ item.date }}</text></view>
|
||||
<image class="article-art" :src="item.image" mode="aspectFill"></image>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="moreError.length > 0" class="content-state">{{ moreError }}</text>
|
||||
<text v-if="hasMore" class="content-retry" @click="more">{{ loadingMore ? '正在加载…' : '加载更多' }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<BottomNav active="news" />
|
||||
@@ -24,22 +29,48 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { usePageLayout } from '@/services/layout.uts'
|
||||
import { newsArticles, NewsArticle } from '@/packages/content/services/content.uts'
|
||||
import AppHeader from '@/components/AppHeader.uvue'
|
||||
import BottomNav from '@/components/BottomNav.uvue'
|
||||
const { contentStyle, pageStyle } = usePageLayout()
|
||||
const tabs: string[] = ['推荐', '企业融资', '个人融资', '政策解读']
|
||||
const tab = ref('推荐')
|
||||
const headline: NewsArticle = newsArticles[0]
|
||||
const filteredArticles = computed((): NewsArticle[] => {
|
||||
if (tab.value == '推荐') return newsArticles.filter((item: NewsArticle): boolean => item.id != headline.id)
|
||||
return newsArticles.filter((item: NewsArticle): boolean => item.category == tab.value)
|
||||
})
|
||||
const openArticle = (id: string): void => { uni.navigateTo({ url: '/packages/content/article/article?id=' + id }) }
|
||||
import { usePageLayout } from '@/services/layout.uts'
|
||||
import { fetchArticles, emptyArticle, NewsArticle } from '@/services/content.uts'
|
||||
import AppHeader from '@/components/AppHeader.uvue'
|
||||
import BottomNav from '@/components/BottomNav.uvue'
|
||||
const { contentStyle, pageStyle } = usePageLayout()
|
||||
const tabs: string[] = ['推荐', '企业融资', '个人融资', '政策解读']
|
||||
const tab = ref('推荐'); const articles = ref<NewsArticle[]>([]); const total = ref(0); const hasMore = ref(false)
|
||||
const loading = ref(false); const loadingMore = ref(false); const error = ref(''); const moreError = ref('')
|
||||
let generation = 0; let visible = false; let page = 1
|
||||
const headline = computed((): NewsArticle => articles.value.length > 0 ? articles.value[0] : emptyArticle())
|
||||
const filteredArticles = computed((): NewsArticle[] => tab.value == '推荐' ? articles.value.slice(1) : articles.value)
|
||||
async function load(append: boolean) {
|
||||
if (!visible || loading.value || loadingMore.value || (append && !hasMore.value)) return
|
||||
const request = append ? generation : ++generation
|
||||
if (append) { loadingMore.value = true; moreError.value = '' }
|
||||
else { loading.value = true; error.value = ''; articles.value = []; total.value = 0; hasMore.value = false; page = 1 }
|
||||
try {
|
||||
const result = await fetchArticles(tab.value == '推荐' ? '' : tab.value, append ? page + 1 : 1)
|
||||
// 换分类或离页后,旧列表响应不能覆盖新选择。
|
||||
if (!visible || request != generation) return
|
||||
articles.value = append ? articles.value.concat(result.items) : result.items
|
||||
total.value = result.total; hasMore.value = result.hasMore
|
||||
if (append) page++
|
||||
} catch (err) {
|
||||
if (visible && request == generation) {
|
||||
const message = err instanceof Error ? err.message : '资讯加载失败,请重试'
|
||||
if (append) moreError.value = message; else error.value = message
|
||||
}
|
||||
} finally { if (request == generation) { loading.value = false; loadingMore.value = false } }
|
||||
}
|
||||
function reload() { load(false) }
|
||||
const more = () => { load(true) }
|
||||
const changeTab = (value: string) => { if (value == tab.value) return; generation++; loading.value = false; loadingMore.value = false; tab.value = value; reload() }
|
||||
const openArticle = (id: string) => { uni.navigateTo({ url: '/packages/content/article/article?id=' + id }) }
|
||||
onShow(() => { visible = true; loading.value = false; loadingMore.value = false; reload() })
|
||||
onHide(() => { visible = false; generation++ })
|
||||
onUnload(() => { visible = false; generation++ })
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.content-state { padding: 30rpx 0; color: #66758a; font-size: 25rpx; line-height: 40rpx; }
|
||||
.content-retry { color: #0868f7; font-size: 25rpx; padding: 22rpx 0; }
|
||||
.news-tabs { height: 88rpx; flex-direction: row; align-items: center; background-color: #ffffff; border-bottom: 1rpx solid #e8eef6; padding: 0 22rpx; }
|
||||
.news-tab { flex: 1; height: 88rpx; justify-content: center; align-items: center; border-bottom: 4rpx solid transparent; }
|
||||
.active-tab { border-bottom-color: #0868f7; }
|
||||
|
||||
@@ -22,11 +22,13 @@
|
||||
<view v-else-if="errorMessage.length > 0" class="state-panel"><text class="state-title">搜索暂时不可用</text><text class="state-copy">{{ errorMessage }}</text><button class="retry-button" @click="search">重新搜索</button></view>
|
||||
<view v-else-if="results.length == 0" class="state-panel"><view class="empty-symbol"><image class="empty-icon" src="/static/icons/search.svg" mode="aspectFit" /></view><text class="state-title">没有找到相关内容</text><text class="state-copy">试试更简短的关键词,或切换搜索分类</text></view>
|
||||
<view v-else>
|
||||
<text class="result-count">找到 {{ results.length }} 条相关内容</text>
|
||||
<text class="result-count">找到 {{ total }} 条相关内容</text>
|
||||
<view class="result-card" v-for="item in results" :key="item.type + ':' + item.id" @click="openResult(item)">
|
||||
<view class="result-copy"><view class="result-heading"><text :class="item.type == 'news' ? 'result-kind' : 'result-kind lesson-kind'">{{ item.type == 'news' ? '新闻' : '融资课堂' }}</text><text class="result-category">{{ item.category }}</text></view><text class="result-title">{{ item.title }}</text><text class="result-summary">{{ item.summary }}</text><text class="result-meta">{{ item.meta }}</text></view>
|
||||
<image v-if="item.image.length > 0" class="result-image" :src="item.image" mode="aspectFill" />
|
||||
</view>
|
||||
<text v-if="moreError.length > 0" class="state-copy">{{ moreError }}</text>
|
||||
<button v-if="hasMore" class="retry-button" @click="more">{{ loadingMore ? '正在加载…' : '加载更多' }}</button>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
@@ -48,29 +50,43 @@
|
||||
const errorMessage = ref('')
|
||||
const results = ref<ContentSearchResult[]>([])
|
||||
const listVersion = ref(0)
|
||||
const total = ref(0); const hasMore = ref(false); const loadingMore = ref(false); const moreError = ref('')
|
||||
let page = 1
|
||||
let requestVersion = 0
|
||||
const resetResults = () => { requestVersion++; results.value = []; searched.value = false; loading.value = false; errorMessage.value = ''; listVersion.value++ }
|
||||
const resetResults = () => { requestVersion++; results.value = []; total.value = 0; hasMore.value = false; loadingMore.value = false; moreError.value = ''; searched.value = false; loading.value = false; errorMessage.value = ''; listVersion.value++ }
|
||||
const clearSearch = () => { keyword.value = ''; resetResults() }
|
||||
const search = async () => {
|
||||
async function load(append: boolean) {
|
||||
if (append && (loading.value || loadingMore.value || !hasMore.value)) return
|
||||
keyword.value = keyword.value.trim()
|
||||
if (keyword.value.length == 0) { clearSearch(); return }
|
||||
uni.hideKeyboard()
|
||||
const currentRequest = ++requestVersion
|
||||
searched.value = true; loading.value = true; errorMessage.value = ''; results.value = []; listVersion.value++
|
||||
const currentRequest = append ? requestVersion : ++requestVersion
|
||||
if (append) { loadingMore.value = true; moreError.value = '' }
|
||||
else { searched.value = true; loading.value = true; loadingMore.value = false; errorMessage.value = ''; moreError.value = ''; results.value = []; total.value = 0; hasMore.value = false; page = 1; listVersion.value++ }
|
||||
try {
|
||||
const items = await searchContent(keyword.value, activeType.value)
|
||||
const result = await searchContent(keyword.value, activeType.value, append ? page + 1 : 1)
|
||||
// 输入、切换分类或退出页面后,忽略旧请求,避免结果被覆盖。
|
||||
if (currentRequest == requestVersion) results.value = items
|
||||
} catch (_error) {
|
||||
if (currentRequest == requestVersion) errorMessage.value = '请检查网络连接后重试'
|
||||
} finally { if (currentRequest == requestVersion) loading.value = false }
|
||||
if (currentRequest != requestVersion) return
|
||||
results.value = append ? results.value.concat(result.items) : result.items
|
||||
total.value = result.total; hasMore.value = result.hasMore
|
||||
if (append) page++
|
||||
} catch (err) {
|
||||
if (currentRequest == requestVersion) {
|
||||
const message = err instanceof Error ? err.message : '请检查网络连接后重试'
|
||||
if (append) moreError.value = message; else errorMessage.value = message
|
||||
}
|
||||
} finally { if (currentRequest == requestVersion) { loading.value = false; loadingMore.value = false } }
|
||||
}
|
||||
const search = () => { load(false) }
|
||||
const more = () => { load(true) }
|
||||
const selectType = (type: string) => { if (activeType.value == type) return; activeType.value = type; if (searched.value) search() }
|
||||
const searchWord = (word: string) => { keyword.value = word; search() }
|
||||
const openResult = (item: ContentSearchResult) => {
|
||||
const route = item.type == 'news' ? '/packages/content/article/article' : '/packages/content/lesson/lesson'
|
||||
uni.navigateTo({ url: route + '?id=' + encodeURIComponent(item.id) })
|
||||
}
|
||||
onShow(() => { if (searched.value) search() })
|
||||
onHide(() => { requestVersion++; loading.value = false; loadingMore.value = false })
|
||||
onUnload(() => { requestVersion++ })
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,104 +1 @@
|
||||
export type ContentSection = { title: string, body: string }
|
||||
export type NewsArticle = {
|
||||
id: string, title: string, summary: string, category: string,
|
||||
date: string, source: string, readingTime: string, image: string, sections: ContentSection[]
|
||||
}
|
||||
export type FinanceLesson = {
|
||||
id: string, title: string, desc: string, duration: string, tag: string,
|
||||
image: string, intro: string, topics: string[], chapters: ContentSection[]
|
||||
}
|
||||
|
||||
// 新闻与课程独立建模,接入后端时可分别替换为对应接口。
|
||||
export const newsArticles: NewsArticle[] = [
|
||||
{
|
||||
id: 'inclusive-finance', title: '普惠金融政策,企业申请前应关注什么?',
|
||||
summary: '看清适用主体、申请条件与材料要求,找到适合企业的支持方式。', category: '政策解读',
|
||||
date: '2026-09-08', source: 'AI融资编辑整理', readingTime: '4 分钟', image: '/packages/content/static/news-city.png',
|
||||
sections: [
|
||||
{ title: '先核对政策适用范围', body: '普惠金融支持措施通常对经营主体、企业规模和经营所在地设有适用条件。阅读政策时,先核对发文机构、生效时间与适用范围,再确认企业是否满足基本要求。' },
|
||||
{ title: '区分贷款、担保与贴息', body: '信贷产品、融资担保和财政贴息的申请渠道与审核流程可能不同。获得政策支持不等于贷款必然获批,额度、利率和期限仍需以金融机构的实际审核结果为准。' },
|
||||
{ title: '从官方渠道核实并准备资料', body: '建议通过当地政府、金融管理部门或银行官方网站核实细则,准备营业执照、经营流水、纳税记录与资金用途说明。本文提供阅读方法,不代表具体政策承诺。' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'personal-credit', title: '申请个人信用融资,先核对这三项信息',
|
||||
summary: '征信记录、收入稳定性与现有负债,是申请前的重点检查项。', category: '个人融资',
|
||||
date: '2026-09-05', source: 'AI融资编辑整理', readingTime: '3 分钟', image: '/static/mobile/images/news-calculator.png',
|
||||
sections: [
|
||||
{ title: '查看信用记录是否准确', body: '通过官方渠道查询个人信用报告,核对账户、还款记录与查询记录。发现错误信息时,应通过正规异议渠道处理,不轻信付费“修复征信”承诺。' },
|
||||
{ title: '整理可以核验的收入证明', body: '根据职业情况整理工资流水、经营收款、社保或纳税记录,确保填写的收入与材料一致。不同机构认可的材料范围有所不同,请以申请页面或机构说明为准。' },
|
||||
{ title: '量力确定额度与月供', body: '将现有贷款和信用卡应还金额纳入每月支出,再评估新增还款是否可持续。避免为尝试额度而短期内集中向多家机构提交申请。' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'loan-comparison', title: '比较融资产品时,别只看宣传利率',
|
||||
summary: '年化利率、附加费用与还款方式,需要放在一起比较。', category: '个人融资',
|
||||
date: '2026-09-03', source: 'AI融资编辑整理', readingTime: '3 分钟', image: '/packages/content/static/news-city.png',
|
||||
sections: [
|
||||
{ title: '先统一利率口径', body: '日利率、月利率与年化利率并不是同一个口径。比较时应查看机构披露的年化利率及计算方式,尤其注意“低至”利率所附的适用条件。' },
|
||||
{ title: '将费用计入总成本', body: '核对合同是否包含担保费、服务费等支出,以及提前还款是否有额外费用。应以正式合同、还款计划和实际审核结果为准。' },
|
||||
{ title: '让还款安排匹配现金流', body: '等额本息、等额本金、先息后本的还款节奏各不相同。除了总利息,还应关注每期应还金额和到期本金,避免因最后一期集中还款造成压力。' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'business-documents', title: '小微企业融资:常见材料如何提前整理',
|
||||
summary: '按主体、经营和用途三类整理资料,减少补充沟通。', category: '企业融资',
|
||||
date: '2026-08-30', source: 'AI融资编辑整理', readingTime: '4 分钟', image: '/packages/content/static/news-business.png',
|
||||
sections: [
|
||||
{ title: '主体资料保持一致', body: '核对营业执照、实际经营地址、法定代表人及联系人信息。涉及经营主体或股权变更时,准备相应说明,避免不同材料之间的信息不一致。' },
|
||||
{ title: '整理持续经营的证明', body: '经营流水、纳税记录、采购或销售合同可以帮助机构了解业务情况。按照时间顺序整理,并区分经营性收入、借款与其他往来款。' },
|
||||
{ title: '说明资金用途与还款来源', body: '将资金需求对应到采购、周转或设备投入等具体用途,并准备与之匹配的凭证。不同机构要求不同,正式提交前请核对其最新材料清单。' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
export const financeLessons: FinanceLesson[] = [
|
||||
{
|
||||
id: 'credit-basics', title: '个人信用申请准备课', desc: '用三步梳理信用、收入与负债', duration: '6 分钟',
|
||||
tag: '个人融资', image: '/static/mobile/images/news-calculator.png', topics: ['credit', 'documents'],
|
||||
intro: '从自己的实际情况出发,建立一份清晰的申请前检查清单。完成每一节阅读后,可手动记录学习进度。',
|
||||
chapters: [
|
||||
{ title: '读懂自己的信用记录', body: '先通过官方渠道获取个人信用报告,核对账户数量、当前负债与还款记录。\n\n对确有错误的信息,按官方异议流程申请核查;真实的不良记录不能通过付费承诺消除。\n\n本节练习:记录需要核对的账户,并确认最近的还款安排。' },
|
||||
{ title: '整理收入与还款支出', body: '把稳定收入、必要生活支出及已有月供分别列出,观察每月可用于新增还款的资金是否充足。\n\n奖金、临时收入与不确定的经营回款不宜直接视为稳定收入。预留应急资金也有助于避免还款压力。\n\n本节练习:用最近几个月的实际流水,估算可持续的月供范围。' },
|
||||
{ title: '核对材料并选择申请渠道', body: '按照目标机构的清单准备身份证明、收入证明及相关授权,检查姓名、工作与收入信息是否一致。\n\n通过银行或持牌机构的官方渠道申请,不向不明个人账户支付所谓“保证通过”的费用。审批结果以机构审核为准。\n\n本节练习:列出已准备和待补充的材料,提交前再核对一次。' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'business-checklist', title: '小微经营融资准备课', desc: '把经营情况整理成完整材料清单', duration: '8 分钟',
|
||||
tag: '企业融资', image: '/packages/content/static/news-business.png', topics: ['business', 'documents'],
|
||||
intro: '围绕真实经营、资金用途和还款来源,分步骤整理企业融资所需的信息。具体材料要求以申请机构为准。',
|
||||
chapters: [
|
||||
{ title: '核对企业主体与经营信息', body: '核对营业执照、经营地址、法定代表人与实际控制人信息,确保材料在有效期内。\n\n介绍企业业务时,可以说明主要产品、客户类型和经营年限。信息以真实经营情况为准,无需为了申请夸大规模。\n\n本节练习:核对主体证件与申请表中各项信息是否一致。' },
|
||||
{ title: '归集流水与纳税记录', body: '按照申请机构要求的时间范围整理经营账户流水、纳税记录和业务合同。将经营收入与借款、股东往来等款项区分开。\n\n对季节性波动或较大的单笔交易,准备可核验的业务说明,帮助机构理解经营现金流。\n\n本节练习:按月份归档资料,标记需要补充解释的收支。' },
|
||||
{ title: '写清资金用途与还款计划', body: '将需求拆解为采购、订单周转或设备投入等实际用途,并评估资金回笼时间。\n\n融资期限应尽量与经营周期相匹配,避免依赖不确定的下一笔借款偿还到期本金。\n\n本节练习:写下所需金额、预计使用时间与还款来源。' },
|
||||
{ title: '检查授权范围与提交清单', body: '提交前阅读数据授权说明,确认授权对象、用途、范围与期限。不要将账户密码、短信验证码交给不明中介。\n\n将所有附件与机构清单逐项比对,保留提交记录,后续按机构通知补充材料。\n\n本节练习:完成材料清单核对,并确认使用的是官方申请渠道。' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'loan-cost', title: '利率与还款方式入门', desc: '看懂总成本,选好还款节奏', duration: '5 分钟',
|
||||
tag: '基础知识', image: '/packages/content/static/news-policy.png', topics: ['credit', 'business'],
|
||||
intro: '把利率、期限和还款方式放在一起理解,学会用统一口径比较融资方案。',
|
||||
chapters: [
|
||||
{ title: '比较利率先看口径', body: '比较产品时,优先查看正式披露的年化利率及计算方式。日息、月息或“每万元每天几元”的宣传表达不能直接互相比高低。\n\n实际适用利率通常与审核结果相关,页面显示的最低利率不代表每个人都能获得。\n\n本节练习:找到一份产品说明中的年化利率、适用条件与费用说明。' },
|
||||
{ title: '认识不同的还款节奏', body: '等额本息通常每期还款额相近;等额本金的本金部分相同,随着剩余本金减少,利息逐步降低。\n\n先息后本需要在到期时集中偿还本金。每月压力较小并不意味着到期压力也小,应提前规划本金来源。\n\n本节练习:对照还款计划,找出金额最大的一期。' },
|
||||
{ title: '把费用与现金流一起算', body: '核对担保费、服务费与提前还款规则,将必要费用计入总成本。比较方案时还要统一金额与期限。\n\n选择能被自身现金流持续覆盖的还款安排,不只追求额度或最长的期限。最终以合同约定和实际审核结果为准。\n\n本节练习:记录总还款额、每期应还金额及提前还款条件。' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
export function findNewsArticle(id: string, legacyTitle: string): NewsArticle | null {
|
||||
let articleId = id
|
||||
if (legacyTitle == 'headline' || legacyTitle == '普惠金融新政解读' || legacyTitle == '普惠金融新政解读:小微企业迎来融资利好') articleId = 'inclusive-finance'
|
||||
if (legacyTitle == '如何提高个人贷款通过率') articleId = 'personal-credit'
|
||||
if (legacyTitle == '银行最新贷款产品汇总') articleId = 'loan-comparison'
|
||||
if (legacyTitle == '小微企业融资材料清单') articleId = 'business-documents'
|
||||
return newsArticles.find((item: NewsArticle): boolean => item.id == articleId || item.title == legacyTitle) ?? null
|
||||
}
|
||||
|
||||
export function findFinanceLesson(id: string, legacyTitle: string): FinanceLesson | null {
|
||||
let lessonId = id
|
||||
if (id == '0' || legacyTitle == '个人信用提升' || legacyTitle == '如何提高个人贷款通过率') lessonId = 'credit-basics'
|
||||
if (id == '1' || legacyTitle == '小微经营融资' || legacyTitle == '材料准备清单' || legacyTitle == '小微企业融资材料清单') lessonId = 'business-checklist'
|
||||
if (id == '2' || legacyTitle == '读懂贷款利率与还款方式') lessonId = 'loan-cost'
|
||||
return financeLessons.find((item: FinanceLesson): boolean => item.id == lessonId || item.title == legacyTitle) ?? null
|
||||
}
|
||||
export { ContentSection, NewsArticle, FinanceLesson, fetchArticles, fetchArticle, fetchLessons, fetchLesson, emptyArticle, emptyLesson } from '@/services/content.uts'
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { requestJson } from '@/services/api.uts'
|
||||
|
||||
export type FeedbackItem = { id: string, content: string, status: string, reply: string, createdAt: string, updatedAt: string }
|
||||
export type FeedbackPage = { items: FeedbackItem[], page: number, total: number, hasMore: boolean }
|
||||
|
||||
function feedbackView(data: UTSJSONObject): FeedbackItem {
|
||||
const id = data.getString('id') ?? ''; const content = data.getString('content') ?? ''
|
||||
if (id.length == 0) throw new Error('反馈响应不完整')
|
||||
return { id, content, status: data.getString('status') ?? 'pending', reply: data.getString('reply') ?? '',
|
||||
createdAt: data.getString('createdAt') ?? '', updatedAt: data.getString('updatedAt') ?? '' }
|
||||
}
|
||||
|
||||
export function feedbackKey(): string { return 'feedback_' + Date.now().toString() + '_' + Math.floor(Math.random() * 1000000000).toString() }
|
||||
export function feedbackStatus(status: string): string {
|
||||
if (status == 'pending') return '待处理'
|
||||
if (status == 'processing') return '处理中'
|
||||
if (status == 'resolved') return '已回复'
|
||||
if (status == 'closed') return '已关闭'
|
||||
return '待核对'
|
||||
}
|
||||
export async function submitFeedback(content: string, requestId: string): Promise<FeedbackItem> {
|
||||
const attachmentIds: string[] = []
|
||||
return feedbackView(await requestJson('/feedback', 'POST', { content, requestId, attachmentIds }))
|
||||
}
|
||||
export async function fetchFeedback(page: number): Promise<FeedbackPage> {
|
||||
const data = await requestJson('/feedback', 'GET', { page: page.toString(), pageSize: '20' })
|
||||
const rows = data['items'] as UTSJSONObject[] | null
|
||||
if (rows == null) throw new Error('反馈记录加载失败')
|
||||
return { items: rows.map((item: UTSJSONObject): FeedbackItem => feedbackView(item)), page: data.getNumber('page') ?? page,
|
||||
total: data.getNumber('total') ?? 0, hasMore: data.getBoolean('hasMore') ?? false }
|
||||
}
|
||||
@@ -1,20 +1,8 @@
|
||||
import { API_DEMO_ENABLED, API_BASE_URL, requestJson } from '@/services/api.uts'
|
||||
import { requestJson } from '@/services/api.uts'
|
||||
|
||||
export type HelpQuestion = { id: string, category: string, question: string, answer: string }
|
||||
export const helpCategories: string[] = ['账户使用', '融资评估', '资料与隐私']
|
||||
const questions: HelpQuestion[] = [
|
||||
{ id: 'profile', category: '账户使用', question: '如何修改头像和昵称?', answer: '在“我的”页面,点击顶部头像、昵称和手机号所在的区域进入修改资料。更换头像或编辑昵称后,点击“保存资料”即可。昵称不会替代融资档案中的实名信息。' },
|
||||
{ id: 'password', category: '账户使用', question: '忘记登录密码怎么办?', answer: '可先使用手机号验证码登录,再进入“我的 → 账户安全 → 验证码重设密码”。如果记得当前密码,也可以选择“修改登录密码”。修改成功后需要重新登录。' },
|
||||
{ id: 'phone', category: '账户使用', question: '如何更换绑定手机号?', answer: '进入“我的 → 账户安全 → 绑定手机号”,填写新手机号,并分别验证当前号码与新号码。换绑成功后使用新号码登录。如果原号码无法接收短信,需要通过官方客服核验身份。' },
|
||||
{ id: 'assessment', category: '融资评估', question: '如何开始融资评估?', answer: '从首页选择个人或企业融资入口,依次填写基本信息、收入或经营信息、资产与负债情况,再核对并确认。已有资料可在“我的融资档案”中继续完善。' },
|
||||
{ id: 'result', category: '融资评估', question: '评估结果是否代表贷款已经获批?', answer: '不代表。评估结果用于帮助了解融资需求和方案,不是金融机构的授信或放款承诺。实际额度、利率、审批与放款结果,以相关机构的审核和合同为准。' },
|
||||
{ id: 'amount', category: '融资评估', question: '资产与负债信息应该怎么填写?', answer: '信用贷款笔数、信用卡张数填写非负整数,没有则填 0。欠款余额、月还款金额、信用卡额度与已使用额度统一以万元填写,最多保留六位小数,最高 99999.999999 万元;房产、车辆按实际情况选择“有”或“无”。' },
|
||||
{ id: 'files', category: '资料与隐私', question: '选择资料或勾选授权会立即上传、查询吗?', answer: '当前选择的资料仅用于本机预览,勾选授权仅保存您的选择,不会自动上传资料或查询外部数据。使用相关服务前,请确认授权对象、用途和范围。' },
|
||||
{ id: 'draft', category: '资料与隐私', question: '退出登录后,填写的资料还在吗?', answer: '退出登录会结束当前会话,本机保存的评估草稿仍会保留。个人与企业评估分别保存。清理应用数据、更换设备或卸载应用可能使本地资料丢失,请勿将本机草稿视为云端备份。' }
|
||||
]
|
||||
|
||||
export async function fetchHelpQuestions(): Promise<HelpQuestion[]> {
|
||||
if (API_DEMO_ENABLED) return questions.slice()
|
||||
const response = await requestJson('/help/faqs', 'GET', {})
|
||||
const items = response['items'] as UTSJSONObject[] | null
|
||||
if (items == null) throw new Error('帮助内容加载失败')
|
||||
|
||||
@@ -1,48 +1 @@
|
||||
import { API_DEMO_ENABLED, API_BASE_URL, requestJson } from '@/services/api.uts'
|
||||
import { newsArticles, financeLessons, ContentSection } from './content.uts'
|
||||
|
||||
export type ContentSearchResult = {
|
||||
id: string, type: string, title: string, summary: string, category: string, meta: string, image: string
|
||||
}
|
||||
|
||||
const sectionText = (sections: ContentSection[]): string => sections.map((item: ContentSection): string => item.title + ' ' + item.body).join(' ')
|
||||
const matches = (text: string, words: string[]): boolean => words.every((word: string): boolean => text.toLowerCase().includes(word))
|
||||
const readText = (item: UTSJSONObject, key: string): string => typeof item[key] == 'string' ? item[key] as string : ''
|
||||
|
||||
// type: all / news / lesson。接入后端时返回相同类型的 items,详情沿用内容 ID。
|
||||
export async function searchContent(keyword: string, type: string): Promise<ContentSearchResult[]> {
|
||||
const query = keyword.trim().replace(/\s+/g, ' ')
|
||||
const results: ContentSearchResult[] = []
|
||||
if (query.length == 0) return results
|
||||
if (!API_DEMO_ENABLED) {
|
||||
const response = await requestJson('/content/search', 'GET', { keyword: query, type })
|
||||
const items = response['items'] as UTSJSONObject[] | null
|
||||
if (items == null) throw new Error('搜索结果加载失败,请重试')
|
||||
items.forEach((item: UTSJSONObject) => {
|
||||
const kind = readText(item, 'type'); const id = readText(item, 'id'); const title = readText(item, 'title')
|
||||
if ((kind == 'news' || kind == 'lesson') && (type == 'all' || type == kind) && id.length > 0 && title.length > 0) {
|
||||
const result: ContentSearchResult = { id, type: kind, title, summary: readText(item, 'summary'), category: readText(item, 'category'), meta: readText(item, 'meta'), image: readText(item, 'image') }
|
||||
results.push(result)
|
||||
}
|
||||
})
|
||||
return results
|
||||
}
|
||||
const words = query.toLowerCase().split(' ')
|
||||
if (type == 'all' || type == 'news') {
|
||||
newsArticles.forEach((item) => {
|
||||
if (matches(item.title + ' ' + item.summary + ' ' + item.category + ' ' + sectionText(item.sections), words)) {
|
||||
const result: ContentSearchResult = { id: item.id, type: 'news', title: item.title, summary: item.summary, category: item.category, meta: item.date, image: item.image }
|
||||
results.push(result)
|
||||
}
|
||||
})
|
||||
}
|
||||
if (type == 'all' || type == 'lesson') {
|
||||
financeLessons.forEach((item) => {
|
||||
if (matches(item.title + ' ' + item.desc + ' ' + item.tag + ' ' + item.intro + ' ' + sectionText(item.chapters), words)) {
|
||||
const result: ContentSearchResult = { id: item.id, type: 'lesson', title: item.title, summary: item.desc, category: item.tag, meta: item.duration + ' · ' + item.chapters.length + ' 节', image: item.image }
|
||||
results.push(result)
|
||||
}
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
export { ContentSearchResult, SearchPage, searchContent } from '@/services/content.uts'
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
<view class="safe" :style="statusStyle(80)"></view>
|
||||
<text class="analyzing-title">{{ title }}</text>
|
||||
<text class="analyzing-sub">正式提交记录 · {{ assessmentId }}</text>
|
||||
<view class="orbital"><view class="orbit orbit-one"></view><view class="orbit orbit-two"></view><view class="ai-core"><image src="/static/mobile/images/robot-cutout.png" mode="aspectFit" class="ai-core-image"></image></view></view>
|
||||
<view class="orbital"><view class="orbit orbit-one"></view><view class="orbit orbit-two"></view><view class="scan-ring" :style="scanRingStyle()"></view><view class="ai-core"><image src="/static/mobile/images/robot-cutout.png" mode="aspectFit" class="ai-core-image"></image></view></view>
|
||||
<text class="status-message">{{ message }}</text>
|
||||
<view v-if="processing" class="analysis-pulse"><view class="pulse-dot" :style="pulseDotStyle()"></view><text class="stage-label">{{ stageLabel }}</text><text class="pulse-dots" :style="pulseDotsStyle()">···</text></view>
|
||||
<text v-if="error.length > 0" class="status-message">{{ error }}</text>
|
||||
<text v-if="canRetry" class="view-now" @click="retry">{{ retrying ? '正在提交…' : '重新分析' }}</text>
|
||||
<text v-if="succeeded" class="view-now" @click="report">查看分析报告</text>
|
||||
@@ -20,9 +21,25 @@
|
||||
const { statusStyle, pageStyle } = usePageLayout()
|
||||
const title = ref('正在读取评估状态')
|
||||
const message = ref(''); const error = ref(''); const assessmentId = ref('')
|
||||
const canRetry = ref(false); const succeeded = ref(false); const retrying = ref(false)
|
||||
const canRetry = ref(false); const succeeded = ref(false); const retrying = ref(false); const processing = ref(false); const stageLabel = ref('正在准备分析')
|
||||
let retryKey = ''; let retryTask = ''; let retryOwner = ''
|
||||
let timer = 0; let visible = false; let generation = 0
|
||||
let timer = 0; let motionTimer = 0; let visible = false; let generation = 0
|
||||
// 动效只反映服务端 processing 状态,不用视觉帧推断分析进度或完成时间。
|
||||
const motionPhase = ref(0)
|
||||
const phaseRadians = (): number => motionPhase.value * Math.PI * 2
|
||||
const scanRingStyle = (): string => {
|
||||
if (!processing.value) return 'opacity:0;transform:scale(.78);'
|
||||
const phase = phaseRadians()
|
||||
const progress = (Math.sin(phase) + 1) / 2
|
||||
return 'opacity:' + (0.12 + progress * 0.62).toString() + ';transform:scale(' + (0.78 + progress * 0.52).toString() + ');'
|
||||
}
|
||||
const pulseDotStyle = (): string => 'opacity:' + (0.5 + ((Math.sin(phaseRadians()) + 1) / 2) * 0.5).toString() + ';transform:scale(' + (0.82 + ((Math.sin(phaseRadians()) + 1) / 2) * 0.26).toString() + ');'
|
||||
const pulseDotsStyle = (): string => 'opacity:' + (0.25 + ((Math.sin(phaseRadians() - Math.PI / 2) + 1) / 2) * 0.75).toString() + ';'
|
||||
const stopMotion = () => { if (motionTimer != 0) { clearInterval(motionTimer); motionTimer = 0 }; motionPhase.value = 0 }
|
||||
const startMotion = () => {
|
||||
stopMotion()
|
||||
motionTimer = setInterval(() => { if (!processing.value) { stopMotion(); return }; motionPhase.value = (motionPhase.value + 0.035) % 1 }, 80)
|
||||
}
|
||||
async function loadStatus(): Promise<void> {
|
||||
clearTimeout(timer)
|
||||
if (assessmentId.value.length == 0) { title.value = '缺少评估记录'; message.value = '请从融资档案选择已提交的记录。'; return }
|
||||
@@ -35,6 +52,9 @@
|
||||
const result = await fetchAssessmentStatus(assessmentId.value)
|
||||
if (!visible || request != generation || getSession()?.getString('token') != owner) return
|
||||
canRetry.value = result.canRetry; succeeded.value = result.status == 'succeeded'
|
||||
const nextProcessing = result.status == 'processing'
|
||||
if (nextProcessing != processing.value) { processing.value = nextProcessing; if (nextProcessing) startMotion(); else stopMotion() }
|
||||
stageLabel.value = result.stage == 'queued' ? '正在排队,分析任务即将开始' : '正在整理资料,生成分析报告'
|
||||
if (retryTask != result.taskId || retryOwner != owner) { retryKey = ''; retryTask = result.taskId; retryOwner = owner }
|
||||
title.value = result.statusLabel
|
||||
message.value = result.message.length > 0 ? result.message : (result.status == 'processing' ? (result.stage == 'queued' ? '资料已保存,等待分析。您可以稍后从档案查看。' : '正在分析自填资料,您可以稍后从档案查看。') : '分析已完成,可查看本次报告。')
|
||||
@@ -43,7 +63,7 @@
|
||||
} catch (err) {
|
||||
if (visible && request == generation && getSession()?.getString('token') == owner) {
|
||||
// 查询失败不能沿用旧任务的重试/报告按钮,也不能把网络错误当成分析失败。
|
||||
canRetry.value = false; succeeded.value = false; message.value = '暂时无法获取最新状态,请刷新状态。已提交的资料无需重新填写。'
|
||||
canRetry.value = false; succeeded.value = false; processing.value = false; stopMotion(); message.value = '暂时无法获取最新状态,请刷新状态。已提交的资料无需重新填写。'
|
||||
title.value = '状态加载失败'; error.value = err instanceof Error ? err.message : '请稍后重试'
|
||||
}
|
||||
}
|
||||
@@ -70,8 +90,8 @@
|
||||
const archive = () => { uni.reLaunch({ url: '/packages/finance/archive/archive?assessmentId=' + encodeURIComponent(assessmentId.value) }) }
|
||||
onLoad((options) => { if (options != null) assessmentId.value = options['assessmentId'] ?? '' })
|
||||
onShow(() => { visible = true; loadStatus() })
|
||||
onHide(() => { visible = false; generation++; clearTimeout(timer); canRetry.value = false; succeeded.value = false; message.value = ''; error.value = '' })
|
||||
onUnload(() => { visible = false; generation++; clearTimeout(timer) })
|
||||
onHide(() => { visible = false; generation++; clearTimeout(timer); stopMotion(); canRetry.value = false; succeeded.value = false; processing.value = false; message.value = ''; error.value = '' })
|
||||
onUnload(() => { visible = false; generation++; clearTimeout(timer); stopMotion() })
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -84,7 +104,9 @@
|
||||
.orbit-one { width: 350rpx; height: 350rpx; }
|
||||
.orbit-two { width: 280rpx; height: 280rpx; border-color: rgba(57, 235, 255, .5); }
|
||||
.ai-core { width: 220rpx; height: 220rpx; border-radius: 50%; background-color: rgba(10, 102, 247, .32); box-shadow: 0 0 70rpx rgba(0, 165, 255, .48); align-items: center; justify-content: center; }
|
||||
.scan-ring { transition: transform 80ms linear, opacity 80ms linear; }
|
||||
.ai-core-image { width: 185rpx; height: 185rpx; border-radius: 50%; }
|
||||
.scan-ring { position: absolute; width: 210rpx; height: 210rpx; border: 2rpx solid rgba(70, 216, 255, .85); border-radius: 50%; opacity: 0; }
|
||||
.analysis-list { margin-top: 30rpx; width: 390rpx; }
|
||||
.analysis-item { flex-direction: row; align-items: center; margin-top: 23rpx; font-size: 26rpx; color: #d8e6ff; }
|
||||
.analysis-label { font-size: 27rpx; color: #d8e6ff; }
|
||||
@@ -92,6 +114,11 @@
|
||||
.analysis-check { width: 38rpx; height: 38rpx; border-radius: 50%; border: 2rpx solid #4c73a7; align-items: center; justify-content: center; margin-right: 17rpx; }
|
||||
.analysis-check.done { background-color: #158aff; border-color: #41d7ff; color: white; }
|
||||
.status-message { margin: 32rpx; color: #d8e6ff; font-size: 28rpx; line-height: 44rpx; text-align: center; }
|
||||
.analysis-pulse { flex-direction: row; align-items: center; justify-content: center; margin-top: -12rpx; min-height: 34rpx; }
|
||||
.pulse-dot { width: 12rpx; height: 12rpx; border-radius: 50%; background-color: #43ddff; box-shadow: 0 0 16rpx rgba(67, 221, 255, .9); }
|
||||
.stage-label { margin-left: 12rpx; color: #9fc8ff; font-size: 23rpx; }
|
||||
.pulse-dots { width: 40rpx; margin-left: 2rpx; color: #63dcff; font-size: 24rpx; letter-spacing: 4rpx; }
|
||||
.estimate { margin-top: 64rpx; color: #8faeda; font-size: 22rpx; }
|
||||
.view-now { margin-top: 28rpx; color: #62c8ff; font-size: 23rpx; }
|
||||
.pulse-dot, .pulse-dots { transition: transform 80ms linear, opacity 80ms linear; }
|
||||
</style>
|
||||
|
||||
@@ -79,16 +79,8 @@
|
||||
type AdviceGroup = { id: string, label: string, loading: boolean, error: string, items: MatchView[] }
|
||||
const { contentStyle, pageStyle, statusStyle, bottomStyle, navigationTitleStyle, navigationActionStyle } = usePageLayout()
|
||||
const reportTitleStyle = (): string => {
|
||||
// 小程序标题须同时避让PDF按钮和右侧胶囊,窄屏也不能覆盖操作入口。
|
||||
// #ifdef MP-WEIXIN
|
||||
const windowWidth = uni.getWindowInfo().windowWidth
|
||||
const capsule = uni.getMenuButtonBoundingClientRect()
|
||||
const reserved = capsule.left > 0 && capsule.left < windowWidth ? windowWidth - capsule.left + 8 : 104
|
||||
return 'left:50px;right:' + (reserved + 40) + 'px;'
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN
|
||||
// 报告标题使用对称左右边距,保持屏幕中心对齐并避开微信胶囊。
|
||||
return navigationTitleStyle()
|
||||
// #endif
|
||||
}
|
||||
const tabs: ReportTab[] = [{ id: 'overview', label: '评估概况' }, { id: 'matches', label: '匹配结果' }, { id: 'improvements', label: '改善建议' }]
|
||||
const revisionId = ref(''); const assessmentId = ref(''); const tab = ref('overview'); const loading = ref(false); const error = ref(''); const routeError = ref('')
|
||||
|
||||
Reference in New Issue
Block a user