feat: 优化账户切换时的页面状态一致性,增加登录回跳校验和课程阅读新逻辑
- 增加账户状态监听定时器,页面切换时自动校验账户归属,避免跨账户操作 - 新增登录回跳路径校验函数,限制仅允许指定页面回跳,防止非法跳转 - 课程页改为整篇连续阅读,底部仅一次标记已读,兼容历史记录 - 完善助手、对比页、资料填写页等多处的账户状态校验逻辑,防止错版数据提交 - 优化账户资料请求逻辑,增加版本校验,避免旧请求覆盖新数据
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 + '§ion=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 + '§ion=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; }
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: '未缴纳' }
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user