feat: 优化账户切换时的页面状态一致性,增加登录回跳校验和课程阅读新逻辑

- 增加账户状态监听定时器,页面切换时自动校验账户归属,避免跨账户操作
- 新增登录回跳路径校验函数,限制仅允许指定页面回跳,防止非法跳转
- 课程页改为整篇连续阅读,底部仅一次标记已读,兼容历史记录
- 完善助手、对比页、资料填写页等多处的账户状态校验逻辑,防止错版数据提交
- 优化账户资料请求逻辑,增加版本校验,避免旧请求覆盖新数据
This commit is contained in:
hujun
2026-10-07 10:01:05 +08:00
parent 1723363b7e
commit 71b3c25695
37 changed files with 1389 additions and 323 deletions
+30 -18
View File
@@ -80,7 +80,7 @@
const loading = ref(false); const error = ref(''); const listError = ref(''); const listLoading = ref(false); const hasMore = ref(false)
const completeness = ref<number | null>(null); const completenessFailed = ref(false)
const completenessMessage = ref('提交评估后,查看对应报告的资料完整度。')
let cursor = ''; let generation = 0; let reportRequest = 0; let visible = false; let routeError = ''; let routeId = ''; let owner = ''
let cursor = ''; let generation = 0; let reportRequest = 0; let visible = false; let routeError = ''; let routeId = ''; let owner = ''; let accountTimer = 0
const sections = computed((): ArchiveSection[] => detail.value != null ? detail.value!.sections : [
{ id: 'basic', icon: 'file-text', title: '基本信息', note: type.value == 'enterprise' ? '企业与联系人信息' : '身份与联系方式', fields: [], provided: 0 },
{ id: 'income', icon: 'income', title: type.value == 'enterprise' ? '经营信息' : '职业与收入', note: type.value == 'enterprise' ? '经营行业、月数与营收' : '职业、收入与缴纳情况', fields: [], provided: 0 },
@@ -98,14 +98,25 @@
completenessMessage.value = '提交评估后,查看对应报告的资料完整度。'
error.value = ''; listError.value = ''; listLoading.value = false
}
const stopAccountTimer = () => { if (accountTimer != 0) clearInterval(accountTimer); accountTimer = 0 }
const invalidateAccount = () => {
const session = getSession()
generation++; clearView(); loading.value = false; selectedId.value = routeId; historyOpen.value = false; owner = session?.getString('token') ?? ''; stopAccountTimer()
if (session == null) uni.redirectTo({ url: '/packages/account/login/login' })
else error.value = '登录状态已变化,请重新加载档案。'
}
const startAccountTimer = () => {
stopAccountTimer()
if (owner.length > 0) accountTimer = setInterval(() => {
if (visible && (getSession()?.getString('token') ?? '') != owner) invalidateAccount()
}, 1000)
}
const active = (request: number, token: string): boolean => {
if (!visible || request != generation) return false
const session = getSession()
if (session?.getString('token') == token) return true
// 包括报告/历史分页返回401的情况:立即移除已显示资料,不只忽略迟到响应。
generation++; clearView(); loading.value = false; selectedId.value = routeId
if (session == null) uni.redirectTo({ url: '/packages/account/login/login' })
else error.value = '登录状态已变化,请重新加载档案。'
invalidateAccount()
return false
}
const readCompleteness = async (id: string, request: number, token: string) => {
@@ -140,14 +151,14 @@
} catch (err) { if (active(request, token)) versionsError.value = err instanceof Error ? err.message : '版本历史读取失败' }
finally { if (active(request, token)) versionsLoading.value = false }
}
const moreVersions = () => { if (!versionsLoading.value) readVersions(versions.value.length > 0, generation, owner) }
const chooseVersion = (id: string) => { uni.redirectTo({ url: '/packages/finance/archive/archive?assessmentId=' + selectedId.value + '&revisionId=' + id }) }
const editAssessment = () => { if (detail.value != null) uni.navigateTo({ url: '/packages/finance/assessment/assessment?editId=' + selectedId.value }) }
const moreVersions = () => { if (active(generation, owner) && !versionsLoading.value) readVersions(versions.value.length > 0, generation, owner) }
const chooseVersion = (id: string) => { if (active(generation, owner) && detail.value != null) uni.redirectTo({ url: '/packages/finance/archive/archive?assessmentId=' + selectedId.value + '&revisionId=' + id }) }
const editAssessment = () => { if (active(generation, owner) && detail.value != null) uni.navigateTo({ url: '/packages/finance/assessment/assessment?editId=' + selectedId.value }) }
const refresh = async () => {
const request = ++generation; clearView(); loading.value = true
const session = getSession()
if (session == null) { loading.value = false; uni.redirectTo({ url: '/packages/account/login/login' }); return }
const token = session.getString('token') ?? ''; owner = token
const token = session.getString('token') ?? ''; owner = token; startAccountTimer()
if (routeError.length > 0) { error.value = routeError; loading.value = false; completenessMessage.value = '请先选择有效的评估记录。'; return }
try {
// 显式ID先校验本人详情;失败不能自动切到最新记录,掩盖越权或已删除状态。
@@ -172,6 +183,7 @@
loading.value = false
if (detail.value != null) {
await readVersions(false, request, token)
if (!active(request, token) || detail.value == null) return
if (detail.value!.record.status == 'succeeded') await readCompleteness(detail.value!.record.id, request, token)
else completenessMessage.value = detail.value!.record.status == 'failed' ? '本次分析未完成,暂没有报告完整度;已提交资料已保留。' : '本次分析尚未完成,报告生成后可查看完整度。'
}
@@ -179,18 +191,18 @@
if (active(request, token)) { error.value = err instanceof Error ? err.message : '档案读取失败,请重试'; completenessMessage.value = '档案暂不可读取,请重新加载或选择其他记录。' }
} finally { if (active(request, token)) loading.value = false }
}
const retryList = () => { if (!listLoading.value) readList(false, generation, owner) }
const loadMore = () => { if (!listLoading.value && hasMore.value) readList(true, generation, owner) }
const retryCompleteness = () => { if (detail.value != null && detail.value!.record.status == 'succeeded') readCompleteness(detail.value!.record.id, generation, owner) }
const toggleSection = (id: string) => { if (detail.value != null) expanded.value = expanded.value == id ? '' : id }
const retryList = () => { if (active(generation, owner) && !listLoading.value) readList(false, generation, owner) }
const loadMore = () => { if (active(generation, owner) && !listLoading.value && hasMore.value) readList(true, generation, owner) }
const retryCompleteness = () => { if (active(generation, owner) && detail.value != null && detail.value!.record.status == 'succeeded') readCompleteness(detail.value!.record.id, generation, owner) }
const toggleSection = (id: string) => { if (active(generation, owner) && detail.value != null) expanded.value = expanded.value == id ? '' : id }
// 类型与显式历史选择留在路由里,刷新重新鉴权后仍查看同一记录;不保存草稿或资料缓存。
const switchType = (next: string) => { if (next != type.value) uni.redirectTo({ url: '/packages/finance/archive/archive?type=' + next }) }
const chooseRecord = (id: string) => { if (id == selectedId.value) { historyOpen.value = false; return }; uni.redirectTo({ url: '/packages/finance/archive/archive?type=' + type.value + '&assessmentId=' + encodeURIComponent(id) }) }
const chooseRecord = (id: string) => { if (!active(generation, owner)) return; if (id == selectedId.value) { historyOpen.value = false; return }; uni.redirectTo({ url: '/packages/finance/archive/archive?type=' + type.value + '&assessmentId=' + encodeURIComponent(id) }) }
const showLatest = () => { uni.redirectTo({ url: '/packages/finance/archive/archive?type=' + type.value }) }
const start = () => { clearAssessmentFlow(); uni.navigateTo({ url: '/packages/finance/assessment/assessment?type=' + type.value }) }
const openPreviousReport = () => { if (detail.value != null) uni.navigateTo({ url: '/packages/finance/report/report?assessmentId=' + selectedId.value + '&revisionId=' + detail.value!.record.publishedRevisionId }) }
const openPreviousReport = () => { if (active(generation, owner) && detail.value != null) uni.navigateTo({ url: '/packages/finance/report/report?assessmentId=' + selectedId.value + '&revisionId=' + detail.value!.record.publishedRevisionId }) }
const openAnalysis = () => {
if (detail.value == null) return
if (!active(generation, owner) || detail.value == null) return
const page = detail.value!.record.status == 'succeeded' ? 'report/report' : 'analyzing/analyzing'
uni.navigateTo({ url: '/packages/finance/' + page + '?assessmentId=' + encodeURIComponent(detail.value!.record.id) + (page == 'report/report' ? '&revisionId=' + detail.value!.record.revisionId : '') })
}
@@ -202,13 +214,13 @@
if (options?.['assessmentId'] != null && !/^[1-9][0-9]{0,9}$/.test(routeId)) routeError = '评估编号无效,请重新选择历史记录。'
})
onShow(() => {
visible = true
visible = true; stopAccountTimer()
if (getSession()?.getString('token') != owner) { selectedId.value = routeId; historyOpen.value = false }
refresh()
})
// 离开即清空敏感内容;账户切换、快速换档和迟到响应均不能恢复旧账户资料。
onHide(() => { visible = false; generation++; clearView(); loading.value = false })
onUnload(() => { visible = false; generation++; clearView() })
onHide(() => { visible = false; stopAccountTimer(); generation++; clearView(); loading.value = false })
onUnload(() => { visible = false; stopAccountTimer(); generation++; clearView() })
</script>
<style>
.archive-content { padding: 26rpx 30rpx 70rpx; }
+36 -10
View File
@@ -77,7 +77,7 @@
const step = ref(0)
const assessmentType = ref('personal')
const form = ref<AssessmentForm>(emptyAssessmentForm())
let ownerToken = ''; let goingToConfirm = false
let ownerToken = ''; let goingToConfirm = false; let visible = false; let accountTimer = 0
const sameOwner = (): boolean => { const session = getSession(); return session != null && session['token'] == ownerToken }
const customCity = ref('')
const cityChanged = (city: string) => { form.value.city = city; if (city != '其他城市') customCity.value = '' }
@@ -88,6 +88,24 @@
const titles = ['基本信息', '经营 / 收入信息', '资产 / 负债信息', '资料上传 / 授权']
const uploadNames = computed((): string[] => assessmentType.value == 'enterprise' ? ['营业执照', '银行流水', '经营证明'] : ['身份证正面', '身份证反面', '银行卡', '收入证明'])
let initialized = false
const stopAccountTimer = () => { if (accountTimer != 0) clearInterval(accountTimer); accountTimer = 0 }
const ensureOwner = (): boolean => {
if (!visible) return false
if (sameOwner()) return true
const session = getSession()
ownerToken = session?.getString('token') ?? ''
editGeneration++; initialized = false; stopAccountTimer(); clearAssessmentFlow()
form.value = emptyAssessmentForm(); documents.value = []; customCity.value = ''; step.value = 0
documentConsent.value = false; dataConsent.value = false; authorizations.value.forEach((item) => { item.selected = false })
baseRevisionId = ''; editLoading.value = false; editError.value = '账户已变化,请返回重新填写。'
if (session == null) uni.redirectTo({ url: '/packages/account/login/login' })
return false
}
const startAccountTimer = () => {
stopAccountTimer()
if (ownerToken.length == 0) return
accountTimer = setInterval(() => { if (visible && !sameOwner()) ensureOwner() }, 1000)
}
const rememberInput = () => {
if (!initialized || !sameOwner()) return
const source = form.value
@@ -110,15 +128,15 @@
initialized = true
rememberInput()
}
const switchType = (type: string) => { if (editId.value.length > 0 || assessmentType.value == type) return; rememberInput(); restore(type); step.value = 0; rememberInput() }
const switchType = (type: string) => { if (!ensureOwner() || editId.value.length > 0 || assessmentType.value == type) return; rememberInput(); restore(type); step.value = 0; rememberInput() }
const loadEdit = async (type: string) => {
const ticket = ++editGeneration; editLoading.value = true
try {
const saved = await fetchAssessmentEdit(editId.value)
if (ticket != editGeneration || !sameOwner()) return
if (ticket != editGeneration || !ensureOwner()) return
baseRevisionId = saved.getString('baseRevisionId') ?? ''
writeAssessmentFlow(ownerToken, saved); restore(saved.getString('type') ?? type)
} catch (err) { if (ticket == editGeneration && sameOwner()) editError.value = err instanceof Error ? err.message : '资料读取失败,请返回重试。' }
} catch (err) { if (ticket == editGeneration && ensureOwner()) editError.value = err instanceof Error ? err.message : '资料读取失败,请返回重试。' }
finally { if (ticket == editGeneration) editLoading.value = false }
}
onLoad((options) => {
@@ -134,13 +152,16 @@
loadEdit(type)
})
onShow(() => {
goingToConfirm = false
if (initialized && !sameOwner()) { initialized = false; form.value = emptyAssessmentForm(); clearAssessmentFlow(); uni.redirectTo({ url: '/packages/account/login/login' }) }
visible = true; stopAccountTimer()
goingToConfirm = false
if (initialized && !sameOwner()) ensureOwner()
const input = getAssessmentInput(assessmentType.value)
if (initialized && input != null && typeof input['step'] == 'number') step.value = Math.max(0, Math.min(3, input['step'] as number))
startAccountTimer()
})
// 离开填写流程即丢弃未提交内容;进入确认页时只在内存传递。
onHide(() => {
visible = false; stopAccountTimer()
if (!goingToConfirm) {
editGeneration++; if (editId.value.length > 0) editError.value = '已离开修改流程,请返回档案重新进入。'
// 离页清空资料但不切换步骤,避免卸载时又挂载另一组原生输入组件。
@@ -149,7 +170,7 @@
authorizations.value.forEach((item) => { item.selected = false })
}
})
onUnload(() => { editGeneration++; initialized = false; clearAssessmentFlow() })
onUnload(() => { visible = false; stopAccountTimer(); editGeneration++; initialized = false; clearAssessmentFlow() })
// 页内前后步骤只维护当前内存状态,返回入口后不保留填写内容。
onBackPress((): boolean => { rememberInput(); if (step.value > 0) { step.value--; rememberInput(); return true }; return false })
const previous = () => { rememberInput(); if (step.value > 0) step.value--; rememberInput() }
@@ -160,17 +181,22 @@
uni.showToast({ title: name + '已失效,请重新选择', icon: 'none' })
}
const chooseDocument = (name: string) => {
if (!ensureOwner()) return
const ticket = editGeneration
uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album', 'camera'], success: (result) => {
if (result.tempFilePaths.length == 0) return
if (ticket != editGeneration || !ensureOwner() || result.tempFilePaths.length == 0) return
documents.value = documents.value.filter((file) => file.name != name)
const document: SelectedDocument = { name, path: result.tempFilePaths[0] }
documents.value.push(document); rememberInput()
}, fail: (error) => { if (!error.errMsg.includes('cancel')) uni.showToast({ title: '选择失败,请重试或检查相册权限', icon: 'none' }) } })
}, fail: (error) => { if (ticket == editGeneration && ensureOwner() && !error.errMsg.includes('cancel')) uni.showToast({ title: '选择失败,请重试或检查相册权限', icon: 'none' }) } })
}
const documentAction = (name: string) => {
if (!ensureOwner()) return
const ticket = editGeneration
const path = documentPath(name)
if (path.length == 0) { chooseDocument(name); return }
uni.showActionSheet({ itemList: ['预览图片', '重新选择', '移除资料'], success: (result) => {
if (ticket != editGeneration || !ensureOwner()) return
if (result.tapIndex == 0) uni.previewImage({ urls: [path], fail: () => { uni.showToast({ title: '本地图片已失效,请重新选择', icon: 'none' }) } })
else if (result.tapIndex == 1) chooseDocument(name)
else if (result.tapIndex == 2) { documents.value = documents.value.filter((file) => file.name != name); rememberInput() }
@@ -196,7 +222,7 @@
if (message.length == 0) return true
uni.showToast({ title: message, icon: 'none' }); return false
}
const next = () => { if (editLoading.value || editError.value.length > 0 || !sameOwner()) return; if (!validateStep()) return; if (step.value < 3) { step.value++; rememberInput() } else { rememberInput(); goingToConfirm = true; uni.navigateTo({ url: '/packages/finance/confirm/confirm', fail: () => { goingToConfirm = false } }) } }
const next = () => { if (!ensureOwner() || editLoading.value || editError.value.length > 0) return; if (!validateStep()) return; if (step.value < 3) { step.value++; rememberInput() } else { rememberInput(); goingToConfirm = true; uni.navigateTo({ url: '/packages/finance/confirm/confirm', fail: () => { goingToConfirm = false } }) } }
</script>
<style>
@@ -136,6 +136,8 @@
}
const transmit = async () => {
if (sending.value || context.value == null) return
// 发请求前核对账户,避免轮询尚未发现切换时将旧草稿带新Token发送。
if (!current(generation, scope)) return
const ticket = generation; const token = scope; sending.value = true; sendError.value = ''; uncertain.value = true
try {
if (chatId.value.length == 0) { const chat = await createAssistant(assessmentId.value, productIds.value, createKey, revisionId.value); if (!current(ticket, token)) return; chatId.value = chat.id; updateUrl() }
+34 -16
View File
@@ -76,6 +76,21 @@
// 首屏并排展示短参数;长证据保留原宽表,展开后读取完整内容,不截断判断依据。
const overviewRows: CompareRow[] = [{ id: 'state', label: '匹配状态', height: 0 }, { id: 'amount', label: '额度范围', height: 0 }, { id: 'term', label: '期限范围', height: 0 }, { id: 'rate', label: '年化利率', height: 0 }]
let visible = false; let generation = 0
let owner = ''; let accountTimer = 0; let frozenDetail: MatchDetail | null = null
const stopAccountTimer = () => { if (accountTimer != 0) clearInterval(accountTimer); accountTimer = 0 }
const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeMatch=1&matchPage=compare' + compareQuery(assessmentId.value, ids.value, revisionId.value).replace('?', '&') }) }
const current = (request: number, token: string): boolean => {
if (!visible || request != generation) return false
if (getSession()?.getString('token') == token) return true
generation++; columns.value = []; frozenDetail = null; detailsOpen.value = false; stopAccountTimer()
if (getSession() == null) login(); else { ids.value = []; routeError.value = '账户已切换,请返回本次匹配重新读取。' }
return false
}
const updateUrl = () => {
// #ifdef WEB
history.replaceState(history.state, '', location.pathname + location.search + '#/packages/finance/compare/compare' + compareQuery(assessmentId.value, ids.value, revisionId.value))
// #endif
}
const rows: CompareRow[] = [
{ id: 'availability', label: '当前状态', height: 160 },
{ id: 'amount', label: '额度范围', height: 120 }, { id: 'term', label: '期限范围', height: 120 }, { id: 'rate', label: '年化利率', height: 120 },
@@ -121,40 +136,41 @@
return '未提供'
}
const loadColumn = async (id: string) => {
if (!current(generation, owner)) return
const column = columns.value.find((c: CompareColumn): boolean => c.id == id)
if (column == null || column.loading) return
const session = getSession()
if (session == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }
if (session == null) { login(); return }
const token = session.getString('token') ?? ''; const request = generation
column.loading = true; column.error = ''; column.data = null
try {
const result = await fetchMatchDetailRecord(assessmentId.value, id, revisionId.value)
if (visible && request == generation && getSession()?.getString('token') == token) { if (revisionId.value.length == 0) revisionId.value = result.revisionId; if (revisionId.value != result.revisionId) throw new Error('评估版本已变化,请重新加载对比。'); column.data = result }
if (!current(request, token)) return
if (revisionId.value.length > 0 && revisionId.value != result.revisionId) throw new Error('评估版本已变化,请重新加载对比。')
if (frozenDetail != null && !sameMatchSnapshot(frozenDetail!, result)) throw new Error('冻结结果版本不一致,请重试或返回重新选择。')
if (frozenDetail == null) frozenDetail = result
revisionId.value = result.revisionId; column.data = result
saveCompareSelection(assessmentId.value, ids.value, revisionId.value); updateUrl()
} catch (err) {
if (!visible || request != generation) return
if (getSession()?.getString('token') != token) {
generation++; columns.value = []
if (getSession() == null) uni.redirectTo({ url: '/packages/account/login/login' })
else routeError.value = '账户已切换,请返回本次匹配重新读取。'
return
}
column.error = err instanceof Error ? err.message : '本列读取失败,请重试。'
if (current(request, token)) column.error = err instanceof Error ? err.message : '本列读取失败,请重试。'
}
finally { if (request == generation) column.loading = false }
}
const toMatches = () => {
if (owner.length > 0 && routeError.value.length == 0 && !current(generation, owner)) return
if (!validRecordId(assessmentId.value)) { uni.redirectTo({ url: '/packages/finance/archive/archive' }); return }
uni.redirectTo({ url: '/packages/finance/matches/matches' + compareQuery(assessmentId.value, ids.value, revisionId.value) })
}
const back = () => { if (getCurrentPages().length > 1) uni.navigateBack(); else toMatches() }
const consult = () => { uni.navigateTo({ url: assistantRoute(assessmentId.value, ids.value, 'compare', '', revisionId.value) }) }
const consult = () => { if (current(generation, owner) && complete.value) uni.navigateTo({ url: assistantRoute(assessmentId.value, ids.value, 'compare', '', revisionId.value) }); else if (routeError.value.length == 0) uni.showToast({ title: '请先读取完整对比后再咨询AI', icon: 'none' }) }
const remove = (id: string) => {
if (!current(generation, owner)) return
const next = ids.value.filter((value: string): boolean => value != id)
saveCompareSelection(assessmentId.value, next, revisionId.value)
// 用新路由替换当前页,使刷新/分享恢复实际选择,而不是已移除的旧ID。
uni.redirectTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, next, revisionId.value) })
}
const detail = (id: string) => { uni.navigateTo({ url: '/packages/finance/product/product' + compareQuery(assessmentId.value, ids.value, revisionId.value) + '&productId=' + id + '&tab=explanation&from=compare' }) }
const detail = (id: string) => { if (current(generation, owner)) uni.navigateTo({ url: '/packages/finance/product/product' + compareQuery(assessmentId.value, ids.value, revisionId.value) + '&productId=' + id + '&tab=explanation&from=compare' }) }
onLoad((options) => {
revisionId.value = options?.['revisionId'] ?? '';
if (revisionId.value.length > 0 && !validRecordId(revisionId.value)) routeError.value = '版本编号无效,请返回重新选择。'
@@ -164,15 +180,17 @@
catch (err) { routeError.value = err instanceof Error ? err.message : '对比参数无效' }
})
onShow(() => {
visible = true; generation++
visible = true; generation++; stopAccountTimer(); frozenDetail = null
if (routeError.value.length > 0) return
if (getSession() == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }
if (getSession() == null) { login(); return }
owner = getSession()?.getString('token') ?? ''
accountTimer = setInterval(() => { current(generation, owner) }, 1000)
// 依URL顺序固定列位;仅最多三个详情请求并行,单列失败不影响其他列。
columns.value = ids.value.map((id: string): CompareColumn => { return { id, loading: false, error: '', data: null } })
ids.value.forEach((id: string) => { loadColumn(id) })
})
onHide(() => { visible = false; generation++; columns.value = [] })
onUnload(() => { visible = false; generation++; columns.value = [] })
onHide(() => { visible = false; stopAccountTimer(); generation++; columns.value = []; frozenDetail = null })
onUnload(() => { visible = false; stopAccountTimer(); generation++; columns.value = []; frozenDetail = null })
</script>
<style>
.overview-table { border: 1rpx solid #dfe8f3; border-radius: 14rpx; overflow: hidden; background-color: #fff; }
+26 -4
View File
@@ -18,6 +18,7 @@
<script setup lang="uts">
import { wanAmountValid } from '@/services/money.uts'
import { getSession } from '@/services/api.uts'
import { usePageLayout } from '@/services/layout.uts'
import AppHeader from '@/components/AppHeader.uvue'
import { submitAssessment } from '@/packages/finance/services/api.uts'
@@ -30,6 +31,17 @@
const assessmentType = ref('personal')
const sections = ref<InfoSection[]>([])
const payload = ref<UTSJSONObject>({ source: 'mobile', confirmed: false })
let visible = false; let generation = 0; let ownerToken = ''; let accountTimer = 0
const stopAccountTimer = () => { if (accountTimer != 0) clearInterval(accountTimer); accountTimer = 0 }
const clearView = () => { hasInput.value = false; sections.value = []; payload.value = { source: 'mobile', confirmed: false }; submitting.value = false }
const sameOwner = (): boolean => {
if (!visible) return false
if (ownerToken.length > 0 && getSession()?.getString('token') == ownerToken) return true
generation++; clearView(); clearAssessmentFlow(); stopAccountTimer()
if (getSession() == null) uni.redirectTo({ url: '/packages/account/login/login' })
else uni.showToast({ title: '账户已变化,请重新填写。', icon: 'none' })
return false
}
const mask = (value: string, left: number, right: number): string => { if (value.length <= left + right) return value; return value.substring(0, left) + '****' + value.substring(value.length - right) }
const displayValue = (saved: UTSJSONObject, key: string): string => { const value = draftText(saved, key); return value.length > 0 ? value : '未填写' }
const appendRow = (rows: InfoRow[], label: string, value: string) => { const row: InfoRow = { label, value }; rows.push(row) }
@@ -37,7 +49,7 @@
const loadInput = () => {
assessmentType.value = activeAssessmentType()
const saved = getAssessmentInput(assessmentType.value)
if (saved == null) { hasInput.value = false; sections.value = []; return }
if (saved == null) { clearView(); return }
hasInput.value = true
const enterprise = assessmentType.value == 'enterprise'
const basicRows: InfoRow[] = []
@@ -79,12 +91,19 @@
]
payload.value = { ...saved, source: 'mobile', confirmed: true }
}
onShow(() => loadInput())
onShow(() => {
visible = true; generation++; ownerToken = getSession()?.getString('token') ?? ''
loadInput(); stopAccountTimer()
if (sameOwner()) accountTimer = setInterval(() => { sameOwner() }, 1000)
})
onHide(() => { visible = false; generation++; stopAccountTimer(); clearView() })
onUnload(() => {
visible = false; generation++; stopAccountTimer(); clearView()
const pages = getCurrentPages()
if (!pages.some((page): boolean => page.route == 'packages/finance/assessment/assessment')) clearAssessmentFlow()
})
const edit = (step: number) => {
if (!sameOwner()) return
const input = getAssessmentInput(assessmentType.value)
if (input != null) input['step'] = step
const pages = getCurrentPages()
@@ -92,6 +111,7 @@
else uni.navigateTo({ url: '/packages/finance/assessment/assessment?type=' + assessmentType.value })
}
const submit = async () => {
if (!sameOwner()) return
if (!hasInput.value) { edit(0); return }
if (submitting.value) return
const saved = payload.value
@@ -105,14 +125,16 @@
const assetError = assetValidationMessage(formFromInput(saved))
if (assetError.length > 0) { uni.showToast({ title: assetError, icon: 'none' }); edit(2); return }
submitting.value = true
const request = ++generation
try {
const id = await submitAssessment(payload.value)
if (request != generation || !sameOwner()) return
clearAssessmentFlow()
// 清空本次填写流程和页面栈;修改须重新读取最新版本,防止返回旧表单重复提交。
uni.reLaunch({ url: '/packages/finance/analyzing/analyzing?assessmentId=' + encodeURIComponent(id) })
}
catch (error) { uni.showToast({ title: error instanceof Error ? error.message : '提交失败,请重试', icon: 'none' }) }
finally { submitting.value = false }
catch (error) { if (request == generation && sameOwner()) uni.showToast({ title: error instanceof Error ? error.message : '提交失败,请重试', icon: 'none' }) }
finally { if (request == generation && visible) submitting.value = false }
}
</script>
+42 -19
View File
@@ -15,6 +15,7 @@
<view v-if="error.length == 0 && assessmentId.length > 0" class="compare-bar"><text class="compare-caption">已选 {{ selectedIds.length }} / 3 项,可跨分组选择</text><button class="compare-button" :disabled="selectedIds.length < 2" @click="openCompare">对比所选产品</button></view>
<text v-if="loading && matches.length == 0" class="state-copy">正在读取本次匹配结果…</text>
<view v-if="error.length > 0" class="state-panel"><text class="state-title">暂时无法读取</text><text class="muted">{{ error }}</text><button class="secondary" @click="retry">重新加载</button><text class="link" @click="history">返回融资档案</text></view>
<view v-if="pageError.length > 0" class="state-panel"><text class="muted">{{ pageError }}</text><button class="secondary" :disabled="loading" @click="loadMore">重试加载更多</button></view>
<view v-if="!loading && error.length == 0 && matches.length == 0" class="state-panel"><text class="state-title">{{ status == 'unavailable' ? '本次未执行匹配' : (group == 'eligible' ? '暂无符合已知条件的产品' : (group == 'ineligible' ? '暂无条件不符的产品' : '暂无待核实的产品')) }}</text><text class="muted">{{ status == 'unavailable' ? '历史结果不会自动补算。可从融资档案发起新的评估。' : '可切换分组查看其他结果,或返回报告了解资料准备情况。' }}</text><text class="link" @click="report">查看本次报告</text></view>
<view v-for="item in matches" :key="item.id" class="card" @click="openProduct(item.id)">
<view class="card-head"><view class="mark"><text class="mark-text">融</text></view><view class="identity"><text class="name">{{ item.product.name }}</text><text class="bank">{{ item.product.bank }} · {{ item.product.objects }}</text></view></view>
@@ -26,7 +27,7 @@
<view class="card-foot"><text class="version">评估时版本 v{{ item.product.version }}</text><text class="detail-link">查看依据与详情 ›</text></view>
<button class="choose-button" @click.stop="toggleCompare(item.id)">{{ selectedIds.includes(item.id) ? '已加入对比 · 点击移除' : '加入对比' }}</button>
</view>
<button v-if="hasMore" class="secondary" :disabled="loading" @click="loadMore">{{ loading ? '正在加载…' : '查看更多' }}</button>
<button v-if="hasMore && pageError.length == 0" class="secondary" :disabled="loading" @click="loadMore">{{ loading ? '正在加载…' : '查看更多' }}</button>
<text v-if="matches.length > 0 && !hasMore" class="end-note">已显示本组全部结果 · 顺序不代表产品优劣</text>
<text class="link" @click="report">返回本次报告</text>
</template>
@@ -38,56 +39,78 @@
import AppHeader from '@/components/AppHeader.uvue'
import { usePageLayout } from '@/services/layout.uts'
import { getSession } from '@/services/api.uts'
import { MatchView, MatchReason, fetchMatchPage, validRecordId } from '@/packages/finance/services/matches.uts'
import { parseCompareIds, compareSelection, saveCompareSelection, compareQuery } from '@/packages/finance/services/compare.uts'
import { MatchView, MatchReason, MatchPage, fetchMatchPage, validRecordId } from '@/packages/finance/services/matches.uts'
import { parseCompareIds, compareSelection, saveCompareSelection, compareQuery, matchReturnPath } from '@/packages/finance/services/compare.uts'
const { pageStyle, contentStyle } = usePageLayout()
type MatchGroup = { id: string, label: string }
const groups: MatchGroup[] = [{ id: 'eligible', label: '符合条件' }, { id: 'ineligible', label: '条件不符' }, { id: 'unknown', label: '待核实' }]
const revisionId = ref(''); const assessmentId = ref(''); const group = ref('eligible'); const matches = ref<MatchView[]>([])
const selectedIds = ref<string[]>([])
const pageActive = ref(false)
const loading = ref(false); const error = ref(''); const message = ref(''); const status = ref('')
const loading = ref(false); const error = ref(''); const pageError = ref(''); const message = ref(''); const status = ref('')
const eligible = ref('—'); const ineligible = ref('—'); const unknown = ref('—'); const hasMore = ref(false)
let cursor = ''; let generation = 0; let visible = false; let routeError = ''; let owner = ''
let firstPage: MatchPage | null = null; let accountTimer = 0
const count = (id: string): string => id == 'eligible' ? eligible.value : (id == 'ineligible' ? ineligible.value : unknown.value)
const preview = (item: MatchView): MatchReason[] => item.state == 'ineligible' ? item.blocking.slice(0, 3) : (item.state == 'unknown' ? item.cautions.slice(0, 3) : item.positive.slice(0, 3))
const clear = () => { matches.value = []; message.value = ''; status.value = ''; hasMore.value = false; cursor = ''; eligible.value = '—'; ineligible.value = '—'; unknown.value = '—' }
const clear = () => { matches.value = []; message.value = ''; status.value = ''; pageError.value = ''; hasMore.value = false; cursor = ''; firstPage = null; eligible.value = '—'; ineligible.value = '—'; unknown.value = '—' }
const stopAccountTimer = () => { if (accountTimer != 0) clearInterval(accountTimer); accountTimer = 0 }
const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeMatch=1&matchPage=matches' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value).replace('?', '&') + '&group=' + group.value }) }
const current = (request: number, token: string): boolean => {
if (!visible || request != generation) return false
if (getSession()?.getString('token') == token) return true
generation++; clear(); loading.value = false; stopAccountTimer()
if (getSession() == null) login(); else error.value = '账户已切换,请重新加载本人匹配。'
selectedIds.value = []
return false
}
const updateUrl = () => {
// #ifdef WEB
window.history.replaceState(window.history.state, '', location.pathname + location.search + '#' + matchReturnPath('matches', assessmentId.value, selectedIds.value.join(','), revisionId.value, '', group.value))
// #endif
}
const load = async (append: boolean) => {
const request = ++generation
if (!append) clear()
error.value = ''
if (!append) { stopAccountTimer(); clear() }
error.value = ''; pageError.value = ''
if (routeError.length > 0) { error.value = routeError; loading.value = false; return }
if (assessmentId.value.length == 0) { loading.value = false; return }
const session = getSession()
if (session == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }
const token = session.getString('token') ?? ''; owner = token; loading.value = true
if (session == null) { loading.value = false; login(); return }
const token = session.getString('token') ?? ''; if (owner.length > 0 && owner != token) selectedIds.value = []; owner = token; loading.value = true
if (accountTimer == 0) accountTimer = setInterval(() => { current(generation, owner) }, 1000)
try {
const result = await fetchMatchPage(assessmentId.value, group.value, append ? cursor : '', revisionId.value)
if (!visible || request != generation || getSession()?.getString('token') != token) return
if (!current(request, token)) return
// 分页只追加同一冻结结果,失败时保留已读行与原游标供局部重试。
if (append && (firstPage == null || firstPage!.assessmentId != result.assessmentId || firstPage!.revisionId != result.revisionId || firstPage!.resultVersion != result.resultVersion || firstPage!.snapshotId != result.snapshotId || firstPage!.ruleVersion != result.ruleVersion || firstPage!.orderingVersion != result.orderingVersion || firstPage!.status != result.status || firstPage!.eligible != result.eligible || firstPage!.ineligible != result.ineligible || firstPage!.unknown != result.unknown || result.items.some((item: MatchView): boolean => matches.value.some((old: MatchView): boolean => old.id == item.id)) || (result.hasMore && result.cursor == cursor))) throw new Error('分页与已读取的冻结结果不一致,请重试或重新加载。')
revisionId.value = result.revisionId
if (!append) firstPage = result
saveCompareSelection(assessmentId.value, selectedIds.value, revisionId.value); updateUrl()
matches.value = append ? matches.value.concat(result.items) : result.items
message.value = result.message; status.value = result.status; eligible.value = result.eligible; ineligible.value = result.ineligible; unknown.value = result.unknown
cursor = result.cursor; hasMore.value = result.hasMore
} catch (err) { if (visible && request == generation) error.value = err instanceof Error ? err.message : '匹配结果读取失败' }
} catch (err) { if (current(request, token)) { const message = err instanceof Error ? err.message : '匹配结果读取失败'; if (append) pageError.value = message; else error.value = message } }
finally { if (request == generation) loading.value = false }
}
const selectGroup = (id: string) => { if (id == group.value) return; group.value = id; load(false) }
const selectGroup = (id: string) => { if (!current(generation, owner) || id == group.value) return; group.value = id; load(false) }
const retry = () => { load(false) }
const loadMore = () => { if (!loading.value && hasMore.value) load(true) }
const loadMore = () => { if (current(generation, owner) && !loading.value && hasMore.value) load(true) }
const history = () => { uni.navigateTo({ url: '/packages/finance/archive/archive' }) }
const start = () => { uni.navigateTo({ url: '/packages/finance/entry/entry' }) }
const report = () => { if (validRecordId(assessmentId.value)) uni.redirectTo({ url: '/packages/finance/report/report?assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') }); else history() }
const report = () => { if (owner.length > 0 && !current(generation, owner)) return; if (validRecordId(assessmentId.value)) uni.redirectTo({ url: '/packages/finance/report/report?assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') }); else history() }
const back = () => { if (getCurrentPages().length > 1) uni.navigateBack(); else if (assessmentId.value.length > 0) report(); else history() }
const toggleCompare = (id: string) => {
if (!current(generation, owner) || !matches.value.some((item: MatchView): boolean => item.id == id)) return
if (selectedIds.value.includes(id)) selectedIds.value = selectedIds.value.filter((value: string): boolean => value != id)
else {
if (selectedIds.value.length >= 3) { uni.showToast({ title: '最多选择3个产品,请先移除一项', icon: 'none' }); return }
selectedIds.value.push(id)
}
saveCompareSelection(assessmentId.value, selectedIds.value, revisionId.value)
saveCompareSelection(assessmentId.value, selectedIds.value, revisionId.value); updateUrl()
}
const openCompare = () => { if (selectedIds.value.length >= 2) uni.navigateTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value) }) }
const openProduct = (id: string) => { uni.navigateTo({ url: '/packages/finance/product/product' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value) + '&productId=' + id + '&tab=explanation&from=matches' }) }
const openCompare = () => { if (current(generation, owner) && selectedIds.value.length >= 2) uni.navigateTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value) }) }
const openProduct = (id: string) => { if (current(generation, owner) && matches.value.some((item: MatchView): boolean => item.id == id)) uni.navigateTo({ url: '/packages/finance/product/product' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value) + '&productId=' + id + '&tab=explanation&from=matches' }) }
onLoad((options) => {
revisionId.value = options?.['revisionId'] ?? '';
if (revisionId.value.length > 0 && !validRecordId(revisionId.value)) routeError = '版本编号无效,请返回重新选择。'
@@ -103,8 +126,8 @@
})
onShow(() => { visible = true; pageActive.value = true; selectedIds.value = compareSelection(assessmentId.value, revisionId.value); if (getSession()?.getString('token') != owner) clear(); load(false) })
// 先关闭内容渲染再清空资料,避免卸载途中因空数组重新挂载状态节点。
onHide(() => { visible = false; pageActive.value = false; generation++; clear(); loading.value = false; error.value = '' })
onUnload(() => { visible = false; pageActive.value = false; generation++; clear() })
onHide(() => { visible = false; pageActive.value = false; stopAccountTimer(); generation++; clear(); loading.value = false; error.value = '' })
onUnload(() => { visible = false; pageActive.value = false; stopAccountTimer(); generation++; clear() })
</script>
<style>
.compare-bar { background-color: #fff; border-radius: 16rpx; padding: 20rpx; margin-bottom: 22rpx; }
+5 -3
View File
@@ -6,17 +6,18 @@
<text v-if="loading" class="body">正在读取本次报告与产品状态…</text>
<view v-if="error.length > 0" class="panel"><text class="heading">暂时无法查看行动建议</text><text class="body">{{ error }}</text><button v-if="routeError.length == 0" class="secondary" @click="load">重新加载</button><text class="link" @click="history">前往融资档案 ›</text></view>
<template v-if="report != null && !loading">
<text class="eyebrow">评估记录 #{{ assessmentId }} · {{ assessmentType == 'enterprise' ? '企业' : '个人' }}评估</text>
<text class="eyebrow">评估记录 #{{ assessmentId }} · {{ assessmentType == 'enterprise' ? '企业' : '个人' }}评估 · 第 {{ report!.revisionNo }} 版报告</text>
<text v-if="report!.isPreviousResult" class="revision-notice">当前查看第 {{ report!.revisionNo }} 版结果。最新提交{{ report!.currentStatus == 'processing' ? '正在评估,成功后更新结果。' : (report!.currentStatus == 'failed' ? '评估失败,已保留上次成功结果。' : '已有新版本,可从档案查看。') }}</text>
<text class="intro">根据本次报告,安排您的下一步</text>
<view class="actions">
<view class="action-row" @click="prepare"><image class="action-icon" src="/packages/finance/static/action-prepare.svg" /><view class="action-copy"><text class="action-title">{{ productId.length > 0 && !canPrepare ? '核对产品条件' : '准备申请' }}</text><text class="action-note">{{ productId.length == 0 ? '选择本次匹配产品,查看材料准备说明' : '核对产品条件,了解所需材料' }}</text></view><text class="chevron">›</text></view>
<view class="action-row" @click="prepare"><image class="action-icon" src="/packages/finance/static/action-prepare.svg" /><view class="action-copy"><text class="action-title">{{ report!.matching.status != 'completed' ? '查看准备说明' : (productId.length > 0 && !canPrepare ? '核对产品条件' : '准备申请') }}</text><text class="action-note">{{ report!.matching.status != 'completed' ? '本报告没有产品匹配结果,可查看准备说明' : (productId.length == 0 ? '选择本次匹配产品,查看材料准备说明' : '核对产品条件,了解所需材料') }}</text></view><text class="chevron">›</text></view>
<view class="action-row" @click="openReport"><image class="action-icon" src="/packages/finance/static/action-report.svg" /><view class="action-copy"><text class="action-title">查看已保存报告</text><text class="action-note">报告已保存至融资档案,可随时查看</text></view><text class="chevron">›</text></view>
<view class="action-row" @click="optimize"><image class="action-icon" src="/packages/finance/static/action-improve.svg" /><view class="action-copy"><text class="action-title">优化资料</text><text class="action-note">核对待补充与待核实项,修改后重新评估</text></view><text class="chevron">›</text></view>
<view class="action-row last" @click="consult"><image class="action-icon" src="/packages/finance/static/action-chat.svg" /><view class="action-copy"><text class="action-title">咨询AI</text><text class="action-note">结合本次报告,为您解释准备方向</text></view><text class="chevron">›</text></view>
</view>
<view v-if="section == 'prepare'" id="prepare-section" class="panel">
<text class="heading">材料准备</text>
<text v-if="productId.length == 0" class="body">请先选择本次匹配中的产品,再查看对应材料。本页不会自动替您选择产品。</text>
<text v-if="productId.length == 0" class="body">{{ report!.matching.status == 'completed' ? '请先选择本次匹配中的产品,再查看对应材料。本页不会自动替您选择产品。' : '本报告没有可读取的产品匹配结果,暂不提供产品材料。您仍可查看报告、优化资料或咨询AI。' }}</text>
<template v-else-if="matchError.length > 0"><text class="body">{{ matchError }}</text><button class="secondary" @click="load">重新核对产品</button></template>
<template v-else-if="match != null">
<text class="product-title">{{ match!.product.name }}</text><text class="note">{{ match!.product.bank }} · 评估时版本 v{{ match!.product.version }}</text>
@@ -151,6 +152,7 @@
.page { background-color: #fff; }
.content { padding: 30rpx 36rpx 64rpx; }
.eyebrow { font-size: 22rpx; color: #91a0b2; }
.revision-notice { font-size: 24rpx; color: #9a753d; line-height: 38rpx; margin-top: 16rpx; }
.intro { font-size: 27rpx; color: #536882; margin-top: 16rpx; line-height: 42rpx; }
.actions { margin-top: 26rpx; }
.action-row { flex-direction: row; align-items: center; padding: 30rpx 0; border-bottom: 1rpx solid #eef2f7; }
+42 -25
View File
@@ -84,8 +84,8 @@
import AppHeader from '@/components/AppHeader.uvue'
import { usePageLayout } from '@/services/layout.uts'
import { getSession } from '@/services/api.uts'
import { MatchView, MatchReason, ProductView, fetchMatchDetailRecord, fetchPublicProduct, validRecordId, requestProductExplanation } from '@/packages/finance/services/matches.uts'
import { parseCompareIds, compareSelection, saveCompareSelection, compareQuery } from '@/packages/finance/services/compare.uts'
import { MatchView, MatchReason, MatchDetail, ProductView, fetchMatchDetailRecord, fetchPublicProduct, validRecordId, requestProductExplanation } from '@/packages/finance/services/matches.uts'
import { parseCompareIds, compareSelection, saveCompareSelection, compareQuery, sameMatchSnapshot, matchReturnPath } from '@/packages/finance/services/compare.uts'
import { assistantRoute } from '@/packages/finance/services/assistant.uts'
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
const revisionId = ref(''); const assessmentId = ref(''); const productId = ref(''); const tab = ref('details')
@@ -96,6 +96,7 @@
const aiBusy = ref(false); const aiError = ref(''); const pollStopped = ref(false)
let timer: number = 0; let pollCount = 0; let requestKey = ''
let routeError = ''; let visible = false; let generation = 0
let owner = ''; let accountTimer = 0; let frozenDetail: MatchDetail | null = null
const positiveVisible = computed((): MatchReason[] => match.value == null ? [] : (expanded.value ? match.value!.positive : match.value!.positive.slice(0, 3)))
const attention = computed((): MatchReason[] => match.value == null ? [] : match.value!.blocking.concat(match.value!.cautions))
const attentionVisible = computed((): MatchReason[] => attentionExpanded.value ? attention.value : attention.value.slice(0, 3))
@@ -104,10 +105,23 @@
// 缩短重复的规则模板标题;核验限制仍保留在逐条依据和页尾,不改判定含义。
const reasonTitle = (reason: MatchReason): string => reason.text.replace('(资料尚需核验)。', '').replace('本产品已配置条件', '产品条件')
function stopPoll(): void { if (timer != 0) clearTimeout(timer); timer = 0 }
const current = (request: number, token: string): boolean => visible && generation == request && getSession()?.getString('token') == token
const stopAccountTimer = () => { if (accountTimer != 0) clearInterval(accountTimer); accountTimer = 0 }
const login = () => { uni.redirectTo({ url: '/packages/account/login/login?resumeMatch=1&matchPage=product&assessmentId=' + assessmentId.value + '&revisionId=' + revisionId.value + '&productIds=' + selectedIds.value.join(',') + '&productId=' + productId.value + '&tab=' + tab.value + '&from=' + from }) }
const current = (request: number, token: string): boolean => {
if (!visible || generation != request) return false
if (getSession()?.getString('token') == token) return true
generation++; stopPoll(); stopAccountTimer(); product.value = null; match.value = null; frozenDetail = null; openReason.value = ''; requestKey = ''; loading.value = false; aiBusy.value = false; aiError.value = ''
if (getSession() == null) login(); else error.value = '账户已切换,请重新加载本人产品资料。'
selectedIds.value = []
return false
}
const ready = (): boolean => current(generation, owner) && product.value != null && !loading.value
const updateUrl = () => {
// #ifdef WEB
window.history.replaceState(window.history.state, '', location.pathname + location.search + '#' + matchReturnPath('product', assessmentId.value, selectedIds.value.join(','), revisionId.value, productId.value, 'eligible', tab.value, from))
// #endif
}
const explanationError = (request: number, token: string, message: string) => {
if (!visible || generation != request) return
if (getSession() == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }
if (current(request, token)) aiError.value = message
}
let pollAction: () => void = () => {}
@@ -118,21 +132,22 @@
timer = setTimeout(() => { timer = 0; pollAction() }, 3000)
}
async function refreshExplanation(): Promise<void> {
if (aiBusy.value || match.value == null) return
const request = generation; const token = getSession()?.getString('token') ?? ''
if (!ready() || aiBusy.value || match.value == null) return
const request = generation; const token = owner
aiError.value = ''; aiBusy.value = true
try {
const detail = await fetchMatchDetailRecord(assessmentId.value, productId.value, revisionId.value)
const result = detail.item
if (current(request, token)) { revisionId.value = detail.revisionId; match.value = result; schedulePoll() }
if (!current(request, token)) return
if (frozenDetail == null || !sameMatchSnapshot(frozenDetail!, detail)) throw new Error('解释状态与本次冻结结果不一致,请重新加载。')
match.value = detail.item; schedulePoll()
} catch (err) { explanationError(request, token, err instanceof Error ? err.message : '解释状态读取失败,请重试')
} finally { if (current(request, token)) aiBusy.value = false }
}
pollAction = () => { refreshExplanation() }
const checkExplanation = () => { pollCount = 0; pollStopped.value = false; refreshExplanation() }
const generateExplanation = async () => {
if (aiBusy.value || match.value == null || aiPending.value || match.value!.aiStatus == 'completed') return
const request = generation; const token = getSession()?.getString('token') ?? ''
if (!ready() || aiBusy.value || match.value == null || !match.value!.aiCanRequest || (match.value!.aiStatus == 'failed' && !match.value!.aiCanRetry) || aiPending.value || match.value!.aiStatus == 'completed') return
const request = generation; const token = owner
// 网络结果不确定时保留同一个幂等键,只有收到明确失败状态后才开始新的尝试。
if (requestKey.length == 0) requestKey = 'explain_' + Date.now().toString() + '_' + Math.random().toString(36).slice(2, 12)
aiBusy.value = true; aiError.value = ''; pollCount = 0; pollStopped.value = false
@@ -143,40 +158,41 @@
} finally { if (current(request, token)) aiBusy.value = false }
}
const load = async () => {
stopPoll(); pollCount = 0; pollStopped.value = false; aiError.value = ''
const request = ++generation; product.value = null; match.value = null; error.value = ''; expanded.value = false
stopPoll(); stopAccountTimer(); pollCount = 0; pollStopped.value = false; aiError.value = ''; aiBusy.value = false
const request = ++generation; product.value = null; match.value = null; frozenDetail = null; error.value = ''; expanded.value = false; loading.value = false
if (routeError.length > 0) { error.value = routeError; return }
const session = getSession()
if (session == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }
const token = session.getString('token') ?? ''; loading.value = true
if (session == null) { login(); return }
const token = session.getString('token') ?? ''; if (owner.length > 0 && owner != token) { selectedIds.value = []; requestKey = '' }; owner = token; loading.value = true
accountTimer = setInterval(() => { current(generation, owner) }, 1000)
try {
// 带评估编号始终读取冻结结果;404/越权/失败不能降级为公共资料。
if (assessmentId.value.length > 0) {
const detail = await fetchMatchDetailRecord(assessmentId.value, productId.value, revisionId.value)
const result = detail.item
if (current(request, token)) { revisionId.value = detail.revisionId; match.value = result; product.value = result.product; schedulePoll() }
if (current(request, token)) { revisionId.value = detail.revisionId; frozenDetail = detail; match.value = result; product.value = result.product; saveCompareSelection(assessmentId.value, selectedIds.value, revisionId.value); updateUrl(); schedulePoll() }
} else {
const result = await fetchPublicProduct(productId.value)
if (current(request, token)) product.value = result
}
} catch (err) { if (visible && generation == request) error.value = err instanceof Error ? err.message : '产品读取失败' }
} catch (err) { if (current(request, token)) error.value = err instanceof Error ? err.message : '产品读取失败' }
finally { if (generation == request) loading.value = false }
}
const history = () => { uni.navigateTo({ url: '/packages/finance/archive/archive' }) }
// 仅携带选择ID;助手会重新校验归属并读取冻结事实,进入页面不会调用模型。
const consult = () => { uni.navigateTo({ url: assistantRoute(assessmentId.value, [productId.value], 'product', '', revisionId.value) }) }
function toMatches(): void { if (assessmentId.value.length > 0) uni.redirectTo({ url: '/packages/finance/matches/matches' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value) }); else history() }
const consult = () => { if (ready() && match.value != null) uni.navigateTo({ url: assistantRoute(assessmentId.value, [productId.value], 'product', '', revisionId.value) }) }
function toMatches(): void { if (owner.length > 0 && !current(generation, owner)) return; if (assessmentId.value.length > 0) uni.redirectTo({ url: '/packages/finance/matches/matches' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value) }); else history() }
// 替换当前路由保留栏目、来源与对比选择,刷新后仍停留在同一栏。
const switchTab = (value: string) => {
if (value == tab.value) return
if (!ready() || value == tab.value) return
const query = assessmentId.value.length > 0 ? compareQuery(assessmentId.value, selectedIds.value, revisionId.value) + '&' : '?'
uni.redirectTo({ url: '/packages/finance/product/product' + query + 'productId=' + productId.value + '&tab=' + value + '&from=' + from })
}
// 下一步页重新核对当前可用性,避免沿用产品详情进入时的旧状态。
const prepare = () => { if (canPrepare.value) uni.navigateTo({ url: '/packages/finance/next-step/next-step?assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') + '&productId=' + productId.value + '&section=prepare' }) }
const back = () => { if (getCurrentPages().length > 1) uni.navigateBack(); else if (from == 'compare') uni.redirectTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value) }); else toMatches() }
const prepare = () => { if (ready() && canPrepare.value) uni.navigateTo({ url: '/packages/finance/next-step/next-step?assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') + '&productId=' + productId.value + '&section=prepare' }) }
const back = () => { if (owner.length > 0 && !current(generation, owner)) return; if (getCurrentPages().length > 1) uni.navigateBack(); else if (from == 'compare') uni.redirectTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value) }); else toMatches() }
const openCompare = () => {
if (match.value == null) return
if (!ready() || match.value == null) return
if (!selectedIds.value.includes(productId.value)) {
if (selectedIds.value.length >= 3) { uni.showToast({ title: '已选满3项,请在对比页调整选择', icon: 'none' }); return }
selectedIds.value.push(productId.value)
@@ -199,10 +215,11 @@
try { saveCompareSelection(assessmentId.value, parseCompareIds(options!['productIds']!), revisionId.value) }
catch (err) { routeError = err instanceof Error ? err.message : '对比参数无效' }
}
if (matchReturnPath('product', assessmentId.value, options?.['productIds'] ?? '', revisionId.value, productId.value, 'eligible', tab.value, from).length == 0) routeError = '产品页面参数无效,请返回重新选择。'
})
onShow(() => { visible = true; pageActive.value = true; selectedIds.value = compareSelection(assessmentId.value, revisionId.value); load() })
onHide(() => { pageActive.value = false; visible = false; stopPoll(); generation++; product.value = null; match.value = null; error.value = ''; aiError.value = ''; aiBusy.value = false; loading.value = false; openReason.value = '' })
onUnload(() => { pageActive.value = false; visible = false; stopPoll(); generation++; product.value = null; match.value = null; requestKey = '' })
onHide(() => { pageActive.value = false; visible = false; stopPoll(); stopAccountTimer(); generation++; product.value = null; match.value = null; frozenDetail = null; error.value = ''; aiError.value = ''; aiBusy.value = false; loading.value = false; openReason.value = '' })
onUnload(() => { pageActive.value = false; visible = false; stopPoll(); stopAccountTimer(); generation++; product.value = null; match.value = null; frozenDetail = null; requestKey = '' })
</script>
<style>
.product-page { background-color: #fff; }
+29 -4
View File
@@ -1,4 +1,5 @@
import { revisionParams } from './records.uts'
import { validRecordId } from './matches.uts'
import { requestJson } from '@/services/api.uts'
export type AssistantProduct = { id: string, name: string, state: string, stateLabel: string, demo: boolean }
export type AssistantContext = { revisionId: string, assessmentId: string, productIds: string[], title: string, products: AssistantProduct[], questions: string[], notice: string }
@@ -13,20 +14,41 @@ function contextView(data: UTSJSONObject): AssistantContext {
products: (data['products'] as UTSJSONObject[]).map((p: UTSJSONObject): AssistantProduct => ({ id: text(p, 'id'), name: text(p, 'name'), state: text(p, 'state'), stateLabel: text(p, 'stateLabel'), demo: p.getBoolean('isDemo') ?? false })),
questions: data['questions'] as string[], notice: text(data, 'notice') }
}
function chatView(data: UTSJSONObject): AssistantChat { return { id: text(data, 'chatId'), title: text(data, 'title'), context: contextView(data['context'] as UTSJSONObject) } }
function chatView(data: UTSJSONObject): AssistantChat {
const id = text(data, 'chatId')
if (!validRecordId(id)) throw new Error('返回的会话编号无效,请重新加载。')
return { id, title: text(data, 'title'), context: contextView(data['context'] as UTSJSONObject) }
}
// 回包仍须与本次选择一致;未指定版本时允许服务端解析成功版本,不跟随错版回包改绑定。
function checkContext(context: AssistantContext, assessmentId: string, ids: string[], revisionId: string): void {
if (context.assessmentId != assessmentId || context.productIds.join(',') != ids.join(',') || (revisionId.length > 0 && context.revisionId != revisionId)) throw new Error('返回的对话上下文与本次评估、产品或版本不一致,请重新加载。')
if ((assessmentId.length > 0 && !validRecordId(context.revisionId)) || (assessmentId.length == 0 && context.revisionId.length > 0) || context.products.map((item: AssistantProduct): string => item.id).join(',') != ids.join(',')) throw new Error('返回的对话上下文不完整,请重新加载。')
}
function turnView(data: UTSJSONObject): AssistantTurn { return { id: text(data, 'id'), requestId: text(data, 'requestId'), message: text(data, 'message'), reply: text(data, 'reply'), status: text(data, 'status'), attempt: data.getNumber('attempt') ?? 0, canRetry: data.getBoolean('canRetry') ?? false } }
function contextQuery(assessmentId: string, ids: string[], revisionId: string): UTSJSONObject { return { ...revisionParams(revisionId), contextAssessmentId: assessmentId, contextProductIds: ids.join(',') } }
export function assistantKey(): string { return 'chat_' + Date.now().toString() + '_' + Math.floor(Math.random() * 1000000000).toString() }
export function assistantRoute(assessmentId: string, ids: string[], from: string, chatId: string = '', revisionId: string = ''): string {
return '/packages/finance/assistant/assistant?from=' + from + (assessmentId.length > 0 ? '&assessmentId=' + assessmentId + '&productIds=' + ids.join(',') : '') + (chatId.length > 0 ? '&chatId=' + chatId : '') + (revisionId.length > 0 ? '&revisionId=' + revisionId : '')
}
export async function previewAssistant(assessmentId: string, ids: string[], revisionId: string = ''): Promise<AssistantContext> { return contextView(await requestJson('/assistant/context', 'GET', contextQuery(assessmentId, ids, revisionId))) }
export async function previewAssistant(assessmentId: string, ids: string[], revisionId: string = ''): Promise<AssistantContext> {
const result = contextView(await requestJson('/assistant/context', 'GET', contextQuery(assessmentId, ids, revisionId)))
checkContext(result, assessmentId, ids, revisionId)
return result
}
export async function loadAssistant(chatId: string, assessmentId: string, ids: string[], checkBinding: boolean, revisionId: string = ''): Promise<AssistantChat> {
const result = chatView(await requestJson('/assistant/chats/' + chatId, 'GET', checkBinding ? contextQuery(assessmentId, ids, revisionId) : new UTSJSONObject()))
if (result.id != chatId) throw new Error('返回的会话不一致,请重新加载。')
if (checkBinding) checkContext(result.context, assessmentId, ids, revisionId)
else {
checkContext(result.context, result.context.assessmentId, result.context.productIds, revisionId)
}
return result
}
export async function createAssistant(assessmentId: string, ids: string[], requestId: string, revisionId: string = ''): Promise<AssistantChat> {
const result = chatView(await requestJson('/assistant/chats', 'POST', { ...revisionParams(revisionId), contextAssessmentId: assessmentId.length > 0 ? assessmentId : null, contextProductIds: ids, requestId }))
checkContext(result.context, assessmentId, ids, revisionId)
return result
}
export async function createAssistant(assessmentId: string, ids: string[], requestId: string, revisionId: string = ''): Promise<AssistantChat> { return chatView(await requestJson('/assistant/chats', 'POST', { ...revisionParams(revisionId), contextAssessmentId: assessmentId.length > 0 ? assessmentId : null, contextProductIds: ids, requestId })) }
export async function assistantMessages(chatId: string, cursor: string): Promise<AssistantPage> {
const data = await requestJson('/assistant/chats/' + chatId + '/messages', 'GET', { limit: '20', cursor })
if (text(data, 'chatId') != chatId) throw new Error('消息不属于本次会话。')
@@ -35,7 +57,10 @@ export async function assistantMessages(chatId: string, cursor: string): Promise
export async function submitAssistant(chatId: string, requestId: string, message: string, attempt: number): Promise<AssistantTurn> {
const data = await requestJson('/assistant/chats/' + chatId + '/messages', 'POST', { requestId, message, retryAttempt: attempt })
if (text(data, 'chatId') != chatId) throw new Error('回复不属于本次会话。')
return turnView(data['item'] as UTSJSONObject)
const item = turnView(data['item'] as UTSJSONObject)
// 同键重放可能返回已推进的尝试;旧尝试或另一条问题不能确认本次发送成功。
if (item.requestId != requestId || item.message != message || item.attempt < attempt) throw new Error('发送回执与本次问题不一致,请重试本次发送。')
return item
}
export async function assistantHistory(cursor: string): Promise<AssistantHistoryPage> {
const data = await requestJson('/assistant/chats', 'GET', { limit: '20', cursor })
+14
View File
@@ -30,6 +30,20 @@ export function saveCompareSelection(assessmentId: string, ids: string[], revisi
export function compareQuery(assessmentId: string, ids: string[], revisionId: string = ''): string {
return '?assessmentId=' + encodeURIComponent(assessmentId) + '&productIds=' + ids.join(',') + (revisionId.length > 0 ? '&revisionId=' + revisionId : '')
}
// 登录回跳只接受已声明页面与编号,不接受任意URL。
export function matchReturnPath(page: string, assessmentId: string, rawIds: string, revisionId: string, productId: string = '', group: string = 'eligible', tab: string = 'explanation', from: string = ''): string {
if (!['matches','product','compare'].includes(page) || (revisionId.length > 0 && !validRecordId(revisionId))) return ''
if (assessmentId.length > 0 ? !validRecordId(assessmentId) : page != 'product') return ''
let ids: string[] = []
try { ids = parseCompareIds(rawIds) } catch (err) { return '' }
if (assessmentId.length == 0 && (revisionId.length > 0 || ids.length > 0)) return ''
if (page == 'matches' && !['eligible','ineligible','unknown'].includes(group)) return ''
if (page == 'product' && (!validRecordId(productId) || !['explanation','details','conditions'].includes(tab) || (assessmentId.length == 0 && tab == 'explanation') || !['','matches','product','compare','report','next-step','archive'].includes(from))) return ''
const query = assessmentId.length > 0 ? compareQuery(assessmentId, ids, revisionId) : '?'
if (page == 'matches') return '/packages/finance/matches/matches' + query + '&group=' + group
if (page == 'compare') return '/packages/finance/compare/compare' + query
return '/packages/finance/product/product' + query + (assessmentId.length > 0 ? '&' : '') + 'productId=' + productId + '&tab=' + tab + '&from=' + from
}
export function sameMatchSnapshot(a: MatchDetail, b: MatchDetail): boolean {
return a.assessmentId == b.assessmentId && a.revisionId == b.revisionId && a.resultVersion == b.resultVersion && a.snapshotId == b.snapshotId && a.ruleVersion == b.ruleVersion && a.orderingVersion == b.orderingVersion
}
+24 -7
View File
@@ -53,6 +53,9 @@ function productView(data: UTSJSONObject, publicMode: boolean): ProductView {
source: text(source, 'publisher') + ' · ' + text(source, 'title'), sourceRef: text(source, 'reference').startsWith('demo:airz-') ? '产品规则档案' : text(source, 'reference'), checkedOn: text(source, 'checkedOn'), conditions }
}
function matchView(data: UTSJSONObject): MatchView {
const snapshot = data['productSnapshot'] as UTSJSONObject
const productId = text(data, 'productId'); const version = data.getNumber('productVersion') ?? 0
if (!validRecordId(productId) || text(snapshot, 'productId') != productId || version < 1 || Math.floor(version) != version || snapshot.getNumber('productVersion') != version || !['eligible','ineligible','unknown'].includes(text(data, 'eligibility')) || !['available','unavailable','unknown'].includes(text(data, 'currentAvailability'))) throw new Error('产品冻结资料不完整,请重新加载。')
const evidence = data['evidence'] as UTSJSONObject[]
const ai = data['aiExplanation'] as UTSJSONObject
const explanations = (ai['reasonExplanations'] as UTSJSONObject[] | null) ?? []
@@ -78,32 +81,46 @@ function matchView(data: UTSJSONObject): MatchView {
}
export async function requestProductExplanation(id: string, productId: string, requestId: string, revisionId: string = ''): Promise<MatchView> {
const data = await requestJson('/assessments/' + encodeURIComponent(id) + '/matches/' + encodeURIComponent(productId) + '/explanation', 'POST', { ...revisionParams(revisionId), requestId })
const item = matchView(data['item'] as UTSJSONObject)
if ((revisionId.length > 0 && text(data, 'revisionId') != revisionId) || text(data, 'assessmentId') != id || item.id != productId) throw new Error('AI解释与本次产品不一致,请重新加载。')
return item
return detailRecord(data, id, productId, revisionId).item
}
export async function fetchMatchPage(id: string, group: string, cursor: string, revisionId: string = ''): Promise<MatchPage> {
const data = await requestJson('/assessments/' + encodeURIComponent(id) + '/matches', 'GET', { ...revisionParams(revisionId), eligibility: group, limit: '3', cursor })
const matching = data['matching'] as UTSJSONObject; const status = text(matching, 'status')
if ((revisionId.length > 0 && text(data, 'revisionId') != revisionId) || text(data, 'assessmentId') != id) throw new Error('匹配结果与本次评估不一致,请重新加载。')
const resolvedRevision = text(data, 'revisionId'); const version = data.getNumber('resultVersion') ?? 0
if (!validRecordId(resolvedRevision) || (revisionId.length > 0 && resolvedRevision != revisionId) || text(data, 'assessmentId') != id || version < 1 || Math.floor(version) != version) throw new Error('匹配结果与本次评估不一致,请重新加载。')
const items = (data['items'] as UTSJSONObject[]).map((v: UTSJSONObject): MatchView => matchView(v))
const hasMore = data.getBoolean('hasMore') ?? false; const nextCursor = text(data, 'nextCursor')
const ids: string[] = []
items.forEach((item: MatchView) => { if (item.state != group || ids.includes(item.id)) throw new Error('匹配分组或产品编号不一致,请重新加载。'); ids.push(item.id) })
if (!['completed','unavailable'].includes(status) || items.length > 3 || (hasMore && (items.length == 0 || nextCursor.length == 0)) || (!hasMore && nextCursor.length > 0) || (status == 'unavailable' && (items.length > 0 || hasMore))) throw new Error('匹配分页状态不完整,请重新加载。')
if (status == 'completed') {
if (text(matching, 'catalogSnapshotId').length == 0 || text(matching, 'ruleVersion').length == 0 || text(matching, 'orderingPolicyVersion').length == 0) throw new Error('匹配快照版本不完整,请重新加载。')
const countKeys = ['candidateCount','ineligibleCount','unknownCount']
countKeys.forEach((key: string) => { const value = matching.getNumber(key); if (value == null || value < 0 || Math.floor(value) != value) throw new Error('匹配统计不完整,请重新加载。') })
}
const reason = text(matching, 'reasonCode')
const count = (key: string): string => { const v = matching.getNumber(key); return v == null ? '—' : v.toString() }
const message = status == 'completed' ? '按本次评估时的产品条件判断,展示顺序不代表优劣。' : (reason == 'LEGACY_INPUT' ? '旧版报告未执行产品匹配,可重新评估获取新的结果。' : (reason == 'MATCH_RULES_UNPUBLISHED' ? '本次评估尚未启用产品匹配规则。' : '本次评估没有可用产品目录,未执行产品匹配。'))
return { assessmentId: id, revisionId: text(data, 'revisionId'), resultVersion: data.getNumber('resultVersion') ?? 0, snapshotId: text(matching, 'catalogSnapshotId'), ruleVersion: text(matching, 'ruleVersion'), orderingVersion: text(matching, 'orderingPolicyVersion'), status, message, eligible: count('candidateCount'), ineligible: count('ineligibleCount'), unknown: count('unknownCount'), items: (data['items'] as UTSJSONObject[]).map((v: UTSJSONObject): MatchView => matchView(v)), hasMore: data.getBoolean('hasMore') ?? false, cursor: text(data, 'nextCursor') }
return { assessmentId: id, revisionId: resolvedRevision, resultVersion: version, snapshotId: text(matching, 'catalogSnapshotId'), ruleVersion: text(matching, 'ruleVersion'), orderingVersion: text(matching, 'orderingPolicyVersion'), status, message, eligible: count('candidateCount'), ineligible: count('ineligibleCount'), unknown: count('unknownCount'), items, hasMore, cursor: nextCursor }
}
export async function fetchMatchDetail(id: string, productId: string, revisionId: string = ''): Promise<MatchView> {
return (await fetchMatchDetailRecord(id, productId, revisionId)).item
}
export async function fetchMatchDetailRecord(id: string, productId: string, revisionId: string = ''): Promise<MatchDetail> {
const data = await requestJson('/assessments/' + encodeURIComponent(id) + '/matches/' + encodeURIComponent(productId), 'GET', revisionParams(revisionId))
return detailRecord(data, id, productId, revisionId)
}
function detailRecord(data: UTSJSONObject, id: string, productId: string, revisionId: string): MatchDetail {
const matching = data['matching'] as UTSJSONObject
const item = matchView(data['item'] as UTSJSONObject)
const version = data.getNumber('resultVersion') ?? 0
const snapshotId = text(matching, 'catalogSnapshotId')
// URL只表达选择意图;读取后再次检查归属和冻结版本,不能用公共资料补齐失败列。
if ((revisionId.length > 0 && text(data, 'revisionId') != revisionId) || text(data, 'assessmentId') != id || item.id != productId || item.product.id != productId || version < 1 || snapshotId.length == 0 || text(matching, 'status') != 'completed') throw new Error('产品不属于本次冻结匹配结果,请返回重新选择。')
if (!validRecordId(text(data, 'revisionId')) || (revisionId.length > 0 && text(data, 'revisionId') != revisionId) || text(data, 'assessmentId') != id || item.id != productId || item.product.id != productId || version < 1 || Math.floor(version) != version || snapshotId.length == 0 || text(matching, 'ruleVersion').length == 0 || text(matching, 'orderingPolicyVersion').length == 0 || text(matching, 'status') != 'completed') throw new Error('产品不属于本次冻结匹配结果,请返回重新选择。')
return { assessmentId: id, revisionId: text(data, 'revisionId'), resultVersion: version, snapshotId, ruleVersion: text(matching, 'ruleVersion'), orderingVersion: text(matching, 'orderingPolicyVersion'), item }
}
export async function fetchPublicProduct(id: string): Promise<ProductView> {
return productView(await requestJson('/products/' + encodeURIComponent(id), 'GET', new UTSJSONObject()), true)
const result = productView(await requestJson('/products/' + encodeURIComponent(id), 'GET', new UTSJSONObject()), true)
if (result.id != id || result.version < 1 || Math.floor(result.version) != result.version) throw new Error('产品资料与所选产品不一致,请重新加载。')
return result
}
+43 -11
View File
@@ -14,6 +14,7 @@ export type ArchiveCompleteness = { percent: number | null, description: string
export async function fetchArchiveCompleteness(id: string, revisionId: string = ''): Promise<ArchiveCompleteness> {
const report = await requestJson('/assessments/' + encodeURIComponent(id) + '/report', 'GET', revisionParams(revisionId))
if (report.getString('assessmentId') != id) throw new Error('报告与当前档案不一致')
if (revisionId.length > 0 && report.getString('revisionId') != revisionId) throw new Error('报告版本不一致,请重新选择。')
if (report.getNumber('resultVersion') == 1) return { percent: null, description: '这份旧版报告未计算完整度,已提交资料仍可查看。' }
if (report.getNumber('resultVersion') != 2) throw new Error('报告格式暂不支持')
const completeness = report['completeness'] as UTSJSONObject | null
@@ -23,7 +24,14 @@ export async function fetchArchiveCompleteness(id: string, revisionId: string =
return { percent, description: '反映本次评估时的资料填写情况,不代表信用评分或审批结果。' }
}
function recordFrom(item: UTSJSONObject): AssessmentRecord {
function validAssessmentId(value: string): boolean { return /^[1-9][0-9]{0,9}$/.test(value) }
function recordFrom(item: UTSJSONObject, id: string = '', revisionId: string = ''): AssessmentRecord {
const recordId = draftText(item, 'assessmentId'); const revision = draftText(item, 'revisionId')
const revisionNo = item.getNumber('revisionNo')
if (!validAssessmentId(recordId) || !validAssessmentId(revision) || (id.length > 0 && recordId != id) || (revisionId.length > 0 && revision != revisionId)
|| !['personal', 'enterprise'].includes(draftText(item, 'type')) || !['processing', 'failed', 'succeeded'].includes(draftText(item, 'status'))
|| revisionNo == null || !Number.isInteger(revisionNo) || revisionNo < 1
|| (item.getBoolean('hasReport') == true && !validAssessmentId(draftText(item, 'publishedRevisionId')))) throw new Error('评估记录或版本不一致,请重新加载。')
const submitted = draftText(item, 'submittedAt')
const date = new Date(submitted)
const pad = (value: number): string => value < 10 ? '0' + value.toString() : value.toString()
@@ -126,6 +134,9 @@ export async function createAssessment(payload: UTSJSONObject): Promise<string>
const documents = payload['documents'] as UTSJSONObject[] | null
const grants = payload['authorizations'] as string[] | null
if ((documents != null && documents.length > 0) || (grants != null && grants.length > 0)) throw new Error('资料上传与外部查询授权尚未开放,请移除资料及授权选择后提交')
const editId = draftText(payload, 'editAssessmentId'); const baseRevision = draftText(payload, 'baseRevisionId')
if (editId.length > 0 && (!validAssessmentId(editId) || !validAssessmentId(baseRevision))) throw new Error('修改版本无效,请返回档案重新读取。')
if (!['personal', 'enterprise'].includes(draftText(payload, 'type'))) throw new Error('评估类型无效,请重新填写。')
const values = assessmentApiPayload(payload)
const form = new UTSJSONObject()
const textKeys = ['name', 'idCard', 'phone', 'city', 'occupation', 'company', 'enterpriseName', 'businessCode']
@@ -150,14 +161,15 @@ export async function createAssessment(payload: UTSJSONObject): Promise<string>
form['cityCode'] = cityIndex < 0 ? null : cityCodes[cityIndex]
// 输入v3恢复原资料表单,不提交已取消的资金需求字段;报告仍使用v2展示结构。
const body: UTSJSONObject = { inputVersion: 3, type: values['type'], moneyUnit: 'yuan', form, documents: [] as UTSJSONObject[], consentIds: [] as string[] }
const editId = draftText(payload, 'editAssessmentId')
if (editId.length > 0) body['baseRevisionId'] = draftText(payload, 'baseRevisionId')
if (editId.length > 0) body['baseRevisionId'] = baseRevision
// 新建和修改使用不同幂等作用域,回执丢失时安全重放原提交。
body['requestId'] = assessmentRequestKey(session['token'] as string, editId + ':' + JSON.stringify(body))
const path = editId.length > 0 ? '/assessments/' + encodeURIComponent(editId) + '/revisions' : '/assessments'
const result = await requestJson(path, 'POST', body)
const id = draftText(result, 'assessmentId')
if (id.length == 0) throw new Error('提交回执不完整,请重试')
const revision = draftText(result, 'revisionId')
if (!validAssessmentId(id) || !validAssessmentId(revision) || result.getString('submissionStatus') != 'accepted'
|| (editId.length > 0 && (id != editId || revision == baseRevision))) throw new Error('提交回执不一致,请使用原提交重试。')
return id
}
@@ -166,16 +178,34 @@ export async function fetchAssessments(cursor: string, type: string = ''): Promi
// 按主体在服务端过滤后分页,不能只过滤当前页而漏掉更早的企业/个人档案。
if (type.length > 0) params['type'] = type
const result = await requestJson('/assessments', 'GET', params)
const items = result['items'] as UTSJSONObject[] | null
return { items: items == null ? [] : items.map((item: UTSJSONObject): AssessmentRecord => recordFrom(item)), hasMore: result['hasMore'] == true, nextCursor: draftText(result, 'nextCursor') }
return assessmentPageFrom(result, cursor, type)
}
// 记录和版本分页必须属于当前选择,坏页不能推进游标或拼入另一评估。
function assessmentPageFrom(result: UTSJSONObject, cursor: string, type: string = '', id: string = ''): AssessmentPage {
const rawItems = result['items'] as UTSJSONObject[] | null
const more = result.getBoolean('hasMore'); const next = draftText(result, 'nextCursor')
if (rawItems == null || !Array.isArray(rawItems) || more == null) throw new Error('历史分页格式不完整,请重试。')
const items: AssessmentRecord[] = []; let previous = cursor
rawItems.forEach((item: UTSJSONObject) => {
const record = recordFrom(item, id)
const key = id.length > 0 ? record.revisionId : record.id
if ((type.length > 0 && record.type != type) || (previous.length > 0 && parseInt(key) >= parseInt(previous))) throw new Error('历史分页记录不一致,请重试。')
previous = key; items.push(record)
})
if ((more && (items.length == 0 || !validAssessmentId(next) || next != previous)) || (!more && next.length > 0)) throw new Error('历史分页游标不一致,请重试。')
return { items, hasMore: more, nextCursor: next }
}
export async function fetchAssessmentStatus(id: string): Promise<AssessmentRecord> {
return recordFrom(await requestJson('/assessments/' + encodeURIComponent(id) + '/status', 'GET', {}))
return recordFrom(await requestJson('/assessments/' + encodeURIComponent(id) + '/status', 'GET', {}), id)
}
export async function fetchAssessmentDetail(id: string, revisionId: string = ''): Promise<AssessmentDetail> {
const result = await requestJson('/assessments/' + encodeURIComponent(id), 'GET', revisionParams(revisionId))
if (result.getString('assessmentId') != id) throw new Error('评估详情与当前档案不一致')
if (revisionId.length > 0 && result.getString('revisionId') != revisionId) throw new Error('评估详情版本不一致,请重新选择。')
const record = recordFrom(result, id, revisionId)
const form = result['form'] as UTSJSONObject | null
if (form == null) throw new Error('评估详情不完整')
const fields: AssessmentField[] = []
@@ -209,19 +239,21 @@ export async function fetchAssessmentDetail(id: string, revisionId: string = '')
// “无”及数值0都是已填写,不能用真值判断遗漏零负债、零经营月数。
addField(incomeKeys.includes(key) ? 1 : (assetKeys.includes(key) ? 2 : 0), labels[index], value, raw != null && value.length > 0)
})
return { record: recordFrom(result), fields, sections }
return { record, fields, sections }
}
// 版本编号显式随请求传递,避免不同页面共享“当前版本”造成报告串版。
export function revisionParams(revisionId: string): UTSJSONObject { return revisionId.length > 0 ? { revisionId } : new UTSJSONObject() }
export async function fetchAssessmentRevisions(id: string, cursor: string): Promise<AssessmentPage> {
const result = await requestJson('/assessments/' + encodeURIComponent(id) + '/revisions', 'GET', { cursor })
return { items: (result['items'] as UTSJSONObject[]).map((item: UTSJSONObject): AssessmentRecord => recordFrom(item)), hasMore: result.getBoolean('hasMore') ?? false, nextCursor: draftText(result, 'nextCursor') }
return assessmentPageFrom(result, cursor, '', id)
}
export async function fetchAssessmentEdit(id: string): Promise<UTSJSONObject> {
const result = await requestJson('/assessments/' + encodeURIComponent(id) + '/edit', 'GET', {})
if (draftText(result, 'assessmentId') != id || result.getString('status') == 'processing') throw new Error('评估正在处理中,请完成后再修改。')
const form = result['form'] as UTSJSONObject
const record = recordFrom(result, id)
if (record.status == 'processing') throw new Error('评估正在处理中,请完成后再修改。')
const form = result['form'] as UTSJSONObject | null
if (form == null || Array.isArray(form) || typeof form != 'object' || result.getString('moneyUnit') != 'yuan') throw new Error('修改资料不完整,请返回档案重新读取。')
const payload: UTSJSONObject = { type: result['type'], editAssessmentId: id, baseRevisionId: result['revisionId'], step: 0 }
const money = ['monthlyIncome', 'annualRevenue', 'totalDebtBalance', 'monthlyLoanPayment', 'creditCardTotalLimit', 'creditCardUsedLimit']
const labels: UTSJSONObject = { married: '已婚', single: '未婚', divorced: '离异', widowed: '丧偶', yes: '有', no: '无', six_months_plus: '连续缴纳 6 个月以上', under_six_months: '缴纳不足 6 个月', none: '未缴纳' }
+111 -4
View File
@@ -3,17 +3,122 @@ import { APP_ENVIRONMENT } from '@/services/config.uts'
export type ReportDownload = { cancel: () => void, done: Promise<string> }
// #ifdef MP-WEIXIN
let previewFiles: string[] = []
const cleanPreviewFiles = () => {
previewFiles = previewFiles.filter((path: string): boolean => {
try { uni.getFileSystemManager().unlinkSync(path); return false } catch (_err) { return true }
})
}
// 系统文档预览期间保留临时文件,返回小程序后清理;不调用saveFile持久化报告。
uni.onAppShow(() => cleanPreviewFiles())
// #endif
export function downloadReportPdf(id: string, revisionId: string = ''): ReportDownload {
// 首版仅开放普通浏览器H5;其他端明确提示,不生成假文件或成功回执。
// #ifndef H5
// #ifndef H5 || MP-WEIXIN
return { cancel: () => {}, done: new Promise<string>((_resolve, reject) => { reject(new Error('当前客户端暂未开放PDF下载,请在普通浏览器打开H5并登录后下载。')) }) }
// #endif
// #ifdef MP-WEIXIN
cleanPreviewFiles()
let cancelled = false; let opening = false; let settled = false
let task: DownloadTask | null = null; let tempPath = ''; let rejectDownload: ((error: Error) => void) | null = null
const removeTemp = () => {
if (tempPath.length == 0) return
try { uni.getFileSystemManager().unlinkSync(tempPath) } catch (_err) { previewFiles.push(tempPath) }
tempPath = ''
}
const fail = (message: string) => {
removeTemp()
if (settled) return
settled = true; rejectDownload?.(new Error(message))
}
const stop = (message: string) => { fail(message); task?.abort() }
const cancel = () => {
// openDocument已经接管文件后,原页面可能隐藏/销毁,不能中止或删除正在预览的文件。
if (opening || settled) return
cancelled = true; stop('下载已取消。')
}
const done = new Promise<string>((resolve, reject) => {
rejectDownload = reject
if (!/^[1-9][0-9]{0,9}$/.test(id) || (revisionId.length > 0 && !/^[1-9][0-9]{0,9}$/.test(revisionId))) { fail('报告或版本编号无效,请重新打开本人报告。'); return }
const token = getSession()?.getString('token') ?? ''
if (token.length == 0) { fail('登录已过期,请重新登录后下载。'); return }
if (!API_BASE_URL.startsWith('https://')) { fail('小程序PDF下载需要配置HTTPS服务地址。'); return }
const current = (): boolean => {
if (settled) return false
if (cancelled || getSession()?.getString('token') != token) { fail('页面或登录状态已变化,本次下载已取消。'); return false }
return true
}
let mime = ''; let declaredSize = 0; let encoded = false
task = uni.downloadFile({
url: API_BASE_URL + '/assessments/' + id + '/report.pdf' + (revisionId.length > 0 ? '?revisionId=' + encodeURIComponent(revisionId) : ''),
header: { Authorization: 'Bearer ' + token, 'X-Requested-With': 'XMLHttpRequest' }, timeout: 45000,
success: (response) => {
tempPath = response.tempFilePath
if (!current()) { removeTemp(); return }
if (response.statusCode == 401) { clearSession(); fail('登录已过期,请重新登录后下载。'); return }
try {
const fs = uni.getFileSystemManager()
const size = (fs.statSync(tempPath, false) as UniNamespace.Stats).size
if (response.statusCode != 200 || mime != 'application/pdf') {
let message = 'PDF暂不可下载,请稍后重试;已保存报告不受影响。'
if (mime == 'application/json' && size > 0 && size <= 8192) {
const body = JSON.parse<UTSJSONObject>(fs.readFileSync(tempPath, 'utf8') as string)
const serverMessage = body?.getString('msg') ?? ''
if (serverMessage.length > 0 && serverMessage.length < 200) message = serverMessage
}
fail(message); return
}
if (size > 20971520) { fail('PDF文件过大,请联系管理员。'); return }
if (size < 100 || (!encoded && declaredSize > 0 && declaredSize != size)) { fail('下载内容不是完整PDF,请重试。'); return }
// 只读取头尾,避免将整份报告加载到小程序内存。
const start = fs.readFileSync(tempPath, 'ascii', 0, 5) as string
const tailLength = Math.min(size, 1024)
const end = fs.readFileSync(tempPath, 'ascii', size - tailLength, tailLength) as string
if (start != '%PDF-' || !end.includes('%%EOF')) { fail('下载内容不是完整PDF,请重试。'); return }
if (!current()) return
opening = true
uni.openDocument({ filePath: tempPath, fileType: 'pdf', showMenu: true,
success: () => {
if (!current()) return
previewFiles.push(tempPath); tempPath = ''; settled = true
resolve('PDF已打开,可通过右上角菜单保存或转发。')
}, fail: () => { opening = false; fail('PDF已下载但暂时无法打开,请重试或更新微信。') }
})
} catch (_err) { fail('PDF文件读取失败或内容异常,请重试。') }
},
fail: (error) => {
if (!current()) return
const reason = error.errMsg ?? ''
if (reason.includes('domain list')) fail('PDF下载域名未配置,请联系管理员。')
else if (reason.includes('timeout')) fail('PDF生成超时,请稍后重试。')
else fail('PDF下载失败,请检查网络后重试。')
}
})
task!.onHeadersReceived((response) => {
if (!current()) { task?.abort(); return }
const header = response.header as UTSJSONObject
Object.keys(header).forEach((key: string) => {
const value = header[key]?.toString() ?? ''
if (key.toLowerCase() == 'content-type') mime = value.split(';')[0].trim().toLowerCase()
if (key.toLowerCase() == 'content-length') declaredSize = Number(value)
if (key.toLowerCase() == 'content-encoding') encoded = value.length > 0 && value.toLowerCase() != 'identity'
})
if (declaredSize > 20971520) stop('PDF文件过大,请联系管理员。')
})
task!.onProgressUpdate((progress) => {
if (!current()) { task?.abort(); return }
if (progress.totalBytesWritten > 20971520 || progress.totalBytesExpectedToWrite > 20971520) stop('PDF文件过大,请联系管理员。')
})
})
return { cancel, done }
// #endif
// #ifdef H5
const controller = new AbortController()
let cancelled = false
const cancel = () => { cancelled = true; controller.abort() }
const done = async (): Promise<string> => {
if (!/^[1-9][0-9]{0,9}$/.test(id)) throw new Error('报告编号无效,请重新打开本人报告。')
if (!/^[1-9][0-9]{0,9}$/.test(id) || (revisionId.length > 0 && !/^[1-9][0-9]{0,9}$/.test(revisionId))) throw new Error('报告或版本编号无效,请重新打开本人报告。')
if (/MicroMessenger/i.test(navigator.userAgent)) throw new Error('微信内浏览器暂不支持此下载。请点右上角菜单,选择“在浏览器打开”,登录后重新点击下载PDF。')
const token = getSession()?.getString('token') ?? ''
if (token.length == 0) throw new Error('登录已过期,请重新登录后下载。')
@@ -28,6 +133,7 @@ export function downloadReportPdf(id: string, revisionId: string = ''): ReportDo
cache: 'no-store', credentials: 'omit', redirect: 'error', signal: controller.signal
})
current()
if (response == null) throw new Error('下载服务未返回文件,请重试。')
if (response.status == 401) { clearSession(); throw new Error('登录已过期,请重新登录后下载。') }
const mime = (response.headers.get('Content-Type') ?? '').split(';')[0].trim().toLowerCase()
if (!response.ok || mime != 'application/pdf') {
@@ -44,7 +150,8 @@ export function downloadReportPdf(id: string, revisionId: string = ''): ReportDo
current()
const start = new TextDecoder('ascii').decode(bytes.slice(0, 5))
const end = new TextDecoder('ascii').decode(bytes.slice(Math.max(0, bytes.byteLength - 1024)))
if (bytes.byteLength < 100 || bytes.byteLength > 20971520 || start != '%PDF-' || !end.includes('%%EOF')) throw new Error('下载内容不是完整PDF,请重试。')
const encoded = (response.headers.get('Content-Encoding') ?? '').toLowerCase()
if (bytes.byteLength < 100 || bytes.byteLength > 20971520 || ((encoded.length == 0 || encoded == 'identity') && declaredSize > 0 && declaredSize != bytes.byteLength) || start != '%PDF-' || !end.includes('%%EOF')) throw new Error('下载内容不是完整PDF,请重试。')
// 验证响应后才创建本机Blob。令牌只放请求头,不写URL、文件名或本机存储。
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }))
const link = document.createElement('a')