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

- 增加账户状态监听定时器,页面切换时自动校验账户归属,避免跨账户操作
- 新增登录回跳路径校验函数,限制仅允许指定页面回跳,防止非法跳转
- 课程页改为整篇连续阅读,底部仅一次标记已读,兼容历史记录
- 完善助手、对比页、资料填写页等多处的账户状态校验逻辑,防止错版数据提交
- 优化账户资料请求逻辑,增加版本校验,避免旧请求覆盖新数据
This commit is contained in:
hujun
2026-10-07 10:01:05 +08:00
parent 1723363b7e
commit 71b3c25695
37 changed files with 1389 additions and 323 deletions
+29 -4
View File
@@ -1,4 +1,5 @@
import { revisionParams } from './records.uts'
import { validRecordId } from './matches.uts'
import { requestJson } from '@/services/api.uts'
export type AssistantProduct = { id: string, name: string, state: string, stateLabel: string, demo: boolean }
export type AssistantContext = { revisionId: string, assessmentId: string, productIds: string[], title: string, products: AssistantProduct[], questions: string[], notice: string }
@@ -13,20 +14,41 @@ function contextView(data: UTSJSONObject): AssistantContext {
products: (data['products'] as UTSJSONObject[]).map((p: UTSJSONObject): AssistantProduct => ({ id: text(p, 'id'), name: text(p, 'name'), state: text(p, 'state'), stateLabel: text(p, 'stateLabel'), demo: p.getBoolean('isDemo') ?? false })),
questions: data['questions'] as string[], notice: text(data, 'notice') }
}
function chatView(data: UTSJSONObject): AssistantChat { return { id: text(data, 'chatId'), title: text(data, 'title'), context: contextView(data['context'] as UTSJSONObject) } }
function chatView(data: UTSJSONObject): AssistantChat {
const id = text(data, 'chatId')
if (!validRecordId(id)) throw new Error('返回的会话编号无效,请重新加载。')
return { id, title: text(data, 'title'), context: contextView(data['context'] as UTSJSONObject) }
}
// 回包仍须与本次选择一致;未指定版本时允许服务端解析成功版本,不跟随错版回包改绑定。
function checkContext(context: AssistantContext, assessmentId: string, ids: string[], revisionId: string): void {
if (context.assessmentId != assessmentId || context.productIds.join(',') != ids.join(',') || (revisionId.length > 0 && context.revisionId != revisionId)) throw new Error('返回的对话上下文与本次评估、产品或版本不一致,请重新加载。')
if ((assessmentId.length > 0 && !validRecordId(context.revisionId)) || (assessmentId.length == 0 && context.revisionId.length > 0) || context.products.map((item: AssistantProduct): string => item.id).join(',') != ids.join(',')) throw new Error('返回的对话上下文不完整,请重新加载。')
}
function turnView(data: UTSJSONObject): AssistantTurn { return { id: text(data, 'id'), requestId: text(data, 'requestId'), message: text(data, 'message'), reply: text(data, 'reply'), status: text(data, 'status'), attempt: data.getNumber('attempt') ?? 0, canRetry: data.getBoolean('canRetry') ?? false } }
function contextQuery(assessmentId: string, ids: string[], revisionId: string): UTSJSONObject { return { ...revisionParams(revisionId), contextAssessmentId: assessmentId, contextProductIds: ids.join(',') } }
export function assistantKey(): string { return 'chat_' + Date.now().toString() + '_' + Math.floor(Math.random() * 1000000000).toString() }
export function assistantRoute(assessmentId: string, ids: string[], from: string, chatId: string = '', revisionId: string = ''): string {
return '/packages/finance/assistant/assistant?from=' + from + (assessmentId.length > 0 ? '&assessmentId=' + assessmentId + '&productIds=' + ids.join(',') : '') + (chatId.length > 0 ? '&chatId=' + chatId : '') + (revisionId.length > 0 ? '&revisionId=' + revisionId : '')
}
export async function previewAssistant(assessmentId: string, ids: string[], revisionId: string = ''): Promise<AssistantContext> { return contextView(await requestJson('/assistant/context', 'GET', contextQuery(assessmentId, ids, revisionId))) }
export async function previewAssistant(assessmentId: string, ids: string[], revisionId: string = ''): Promise<AssistantContext> {
const result = contextView(await requestJson('/assistant/context', 'GET', contextQuery(assessmentId, ids, revisionId)))
checkContext(result, assessmentId, ids, revisionId)
return result
}
export async function loadAssistant(chatId: string, assessmentId: string, ids: string[], checkBinding: boolean, revisionId: string = ''): Promise<AssistantChat> {
const result = chatView(await requestJson('/assistant/chats/' + chatId, 'GET', checkBinding ? contextQuery(assessmentId, ids, revisionId) : new UTSJSONObject()))
if (result.id != chatId) throw new Error('返回的会话不一致,请重新加载。')
if (checkBinding) checkContext(result.context, assessmentId, ids, revisionId)
else {
checkContext(result.context, result.context.assessmentId, result.context.productIds, revisionId)
}
return result
}
export async function createAssistant(assessmentId: string, ids: string[], requestId: string, revisionId: string = ''): Promise<AssistantChat> {
const result = chatView(await requestJson('/assistant/chats', 'POST', { ...revisionParams(revisionId), contextAssessmentId: assessmentId.length > 0 ? assessmentId : null, contextProductIds: ids, requestId }))
checkContext(result.context, assessmentId, ids, revisionId)
return result
}
export async function createAssistant(assessmentId: string, ids: string[], requestId: string, revisionId: string = ''): Promise<AssistantChat> { return chatView(await requestJson('/assistant/chats', 'POST', { ...revisionParams(revisionId), contextAssessmentId: assessmentId.length > 0 ? assessmentId : null, contextProductIds: ids, requestId })) }
export async function assistantMessages(chatId: string, cursor: string): Promise<AssistantPage> {
const data = await requestJson('/assistant/chats/' + chatId + '/messages', 'GET', { limit: '20', cursor })
if (text(data, 'chatId') != chatId) throw new Error('消息不属于本次会话。')
@@ -35,7 +57,10 @@ export async function assistantMessages(chatId: string, cursor: string): Promise
export async function submitAssistant(chatId: string, requestId: string, message: string, attempt: number): Promise<AssistantTurn> {
const data = await requestJson('/assistant/chats/' + chatId + '/messages', 'POST', { requestId, message, retryAttempt: attempt })
if (text(data, 'chatId') != chatId) throw new Error('回复不属于本次会话。')
return turnView(data['item'] as UTSJSONObject)
const item = turnView(data['item'] as UTSJSONObject)
// 同键重放可能返回已推进的尝试;旧尝试或另一条问题不能确认本次发送成功。
if (item.requestId != requestId || item.message != message || item.attempt < attempt) throw new Error('发送回执与本次问题不一致,请重试本次发送。')
return item
}
export async function assistantHistory(cursor: string): Promise<AssistantHistoryPage> {
const data = await requestJson('/assistant/chats', 'GET', { limit: '20', cursor })
+14
View File
@@ -30,6 +30,20 @@ export function saveCompareSelection(assessmentId: string, ids: string[], revisi
export function compareQuery(assessmentId: string, ids: string[], revisionId: string = ''): string {
return '?assessmentId=' + encodeURIComponent(assessmentId) + '&productIds=' + ids.join(',') + (revisionId.length > 0 ? '&revisionId=' + revisionId : '')
}
// 登录回跳只接受已声明页面与编号,不接受任意URL。
export function matchReturnPath(page: string, assessmentId: string, rawIds: string, revisionId: string, productId: string = '', group: string = 'eligible', tab: string = 'explanation', from: string = ''): string {
if (!['matches','product','compare'].includes(page) || (revisionId.length > 0 && !validRecordId(revisionId))) return ''
if (assessmentId.length > 0 ? !validRecordId(assessmentId) : page != 'product') return ''
let ids: string[] = []
try { ids = parseCompareIds(rawIds) } catch (err) { return '' }
if (assessmentId.length == 0 && (revisionId.length > 0 || ids.length > 0)) return ''
if (page == 'matches' && !['eligible','ineligible','unknown'].includes(group)) return ''
if (page == 'product' && (!validRecordId(productId) || !['explanation','details','conditions'].includes(tab) || (assessmentId.length == 0 && tab == 'explanation') || !['','matches','product','compare','report','next-step','archive'].includes(from))) return ''
const query = assessmentId.length > 0 ? compareQuery(assessmentId, ids, revisionId) : '?'
if (page == 'matches') return '/packages/finance/matches/matches' + query + '&group=' + group
if (page == 'compare') return '/packages/finance/compare/compare' + query
return '/packages/finance/product/product' + query + (assessmentId.length > 0 ? '&' : '') + 'productId=' + productId + '&tab=' + tab + '&from=' + from
}
export function sameMatchSnapshot(a: MatchDetail, b: MatchDetail): boolean {
return a.assessmentId == b.assessmentId && a.revisionId == b.revisionId && a.resultVersion == b.resultVersion && a.snapshotId == b.snapshotId && a.ruleVersion == b.ruleVersion && a.orderingVersion == b.orderingVersion
}
+24 -7
View File
@@ -53,6 +53,9 @@ function productView(data: UTSJSONObject, publicMode: boolean): ProductView {
source: text(source, 'publisher') + ' · ' + text(source, 'title'), sourceRef: text(source, 'reference').startsWith('demo:airz-') ? '产品规则档案' : text(source, 'reference'), checkedOn: text(source, 'checkedOn'), conditions }
}
function matchView(data: UTSJSONObject): MatchView {
const snapshot = data['productSnapshot'] as UTSJSONObject
const productId = text(data, 'productId'); const version = data.getNumber('productVersion') ?? 0
if (!validRecordId(productId) || text(snapshot, 'productId') != productId || version < 1 || Math.floor(version) != version || snapshot.getNumber('productVersion') != version || !['eligible','ineligible','unknown'].includes(text(data, 'eligibility')) || !['available','unavailable','unknown'].includes(text(data, 'currentAvailability'))) throw new Error('产品冻结资料不完整,请重新加载。')
const evidence = data['evidence'] as UTSJSONObject[]
const ai = data['aiExplanation'] as UTSJSONObject
const explanations = (ai['reasonExplanations'] as UTSJSONObject[] | null) ?? []
@@ -78,32 +81,46 @@ function matchView(data: UTSJSONObject): MatchView {
}
export async function requestProductExplanation(id: string, productId: string, requestId: string, revisionId: string = ''): Promise<MatchView> {
const data = await requestJson('/assessments/' + encodeURIComponent(id) + '/matches/' + encodeURIComponent(productId) + '/explanation', 'POST', { ...revisionParams(revisionId), requestId })
const item = matchView(data['item'] as UTSJSONObject)
if ((revisionId.length > 0 && text(data, 'revisionId') != revisionId) || text(data, 'assessmentId') != id || item.id != productId) throw new Error('AI解释与本次产品不一致,请重新加载。')
return item
return detailRecord(data, id, productId, revisionId).item
}
export async function fetchMatchPage(id: string, group: string, cursor: string, revisionId: string = ''): Promise<MatchPage> {
const data = await requestJson('/assessments/' + encodeURIComponent(id) + '/matches', 'GET', { ...revisionParams(revisionId), eligibility: group, limit: '3', cursor })
const matching = data['matching'] as UTSJSONObject; const status = text(matching, 'status')
if ((revisionId.length > 0 && text(data, 'revisionId') != revisionId) || text(data, 'assessmentId') != id) throw new Error('匹配结果与本次评估不一致,请重新加载。')
const resolvedRevision = text(data, 'revisionId'); const version = data.getNumber('resultVersion') ?? 0
if (!validRecordId(resolvedRevision) || (revisionId.length > 0 && resolvedRevision != revisionId) || text(data, 'assessmentId') != id || version < 1 || Math.floor(version) != version) throw new Error('匹配结果与本次评估不一致,请重新加载。')
const items = (data['items'] as UTSJSONObject[]).map((v: UTSJSONObject): MatchView => matchView(v))
const hasMore = data.getBoolean('hasMore') ?? false; const nextCursor = text(data, 'nextCursor')
const ids: string[] = []
items.forEach((item: MatchView) => { if (item.state != group || ids.includes(item.id)) throw new Error('匹配分组或产品编号不一致,请重新加载。'); ids.push(item.id) })
if (!['completed','unavailable'].includes(status) || items.length > 3 || (hasMore && (items.length == 0 || nextCursor.length == 0)) || (!hasMore && nextCursor.length > 0) || (status == 'unavailable' && (items.length > 0 || hasMore))) throw new Error('匹配分页状态不完整,请重新加载。')
if (status == 'completed') {
if (text(matching, 'catalogSnapshotId').length == 0 || text(matching, 'ruleVersion').length == 0 || text(matching, 'orderingPolicyVersion').length == 0) throw new Error('匹配快照版本不完整,请重新加载。')
const countKeys = ['candidateCount','ineligibleCount','unknownCount']
countKeys.forEach((key: string) => { const value = matching.getNumber(key); if (value == null || value < 0 || Math.floor(value) != value) throw new Error('匹配统计不完整,请重新加载。') })
}
const reason = text(matching, 'reasonCode')
const count = (key: string): string => { const v = matching.getNumber(key); return v == null ? '—' : v.toString() }
const message = status == 'completed' ? '按本次评估时的产品条件判断,展示顺序不代表优劣。' : (reason == 'LEGACY_INPUT' ? '旧版报告未执行产品匹配,可重新评估获取新的结果。' : (reason == 'MATCH_RULES_UNPUBLISHED' ? '本次评估尚未启用产品匹配规则。' : '本次评估没有可用产品目录,未执行产品匹配。'))
return { assessmentId: id, revisionId: text(data, 'revisionId'), resultVersion: data.getNumber('resultVersion') ?? 0, snapshotId: text(matching, 'catalogSnapshotId'), ruleVersion: text(matching, 'ruleVersion'), orderingVersion: text(matching, 'orderingPolicyVersion'), status, message, eligible: count('candidateCount'), ineligible: count('ineligibleCount'), unknown: count('unknownCount'), items: (data['items'] as UTSJSONObject[]).map((v: UTSJSONObject): MatchView => matchView(v)), hasMore: data.getBoolean('hasMore') ?? false, cursor: text(data, 'nextCursor') }
return { assessmentId: id, revisionId: resolvedRevision, resultVersion: version, snapshotId: text(matching, 'catalogSnapshotId'), ruleVersion: text(matching, 'ruleVersion'), orderingVersion: text(matching, 'orderingPolicyVersion'), status, message, eligible: count('candidateCount'), ineligible: count('ineligibleCount'), unknown: count('unknownCount'), items, hasMore, cursor: nextCursor }
}
export async function fetchMatchDetail(id: string, productId: string, revisionId: string = ''): Promise<MatchView> {
return (await fetchMatchDetailRecord(id, productId, revisionId)).item
}
export async function fetchMatchDetailRecord(id: string, productId: string, revisionId: string = ''): Promise<MatchDetail> {
const data = await requestJson('/assessments/' + encodeURIComponent(id) + '/matches/' + encodeURIComponent(productId), 'GET', revisionParams(revisionId))
return detailRecord(data, id, productId, revisionId)
}
function detailRecord(data: UTSJSONObject, id: string, productId: string, revisionId: string): MatchDetail {
const matching = data['matching'] as UTSJSONObject
const item = matchView(data['item'] as UTSJSONObject)
const version = data.getNumber('resultVersion') ?? 0
const snapshotId = text(matching, 'catalogSnapshotId')
// URL只表达选择意图;读取后再次检查归属和冻结版本,不能用公共资料补齐失败列。
if ((revisionId.length > 0 && text(data, 'revisionId') != revisionId) || text(data, 'assessmentId') != id || item.id != productId || item.product.id != productId || version < 1 || snapshotId.length == 0 || text(matching, 'status') != 'completed') throw new Error('产品不属于本次冻结匹配结果,请返回重新选择。')
if (!validRecordId(text(data, 'revisionId')) || (revisionId.length > 0 && text(data, 'revisionId') != revisionId) || text(data, 'assessmentId') != id || item.id != productId || item.product.id != productId || version < 1 || Math.floor(version) != version || snapshotId.length == 0 || text(matching, 'ruleVersion').length == 0 || text(matching, 'orderingPolicyVersion').length == 0 || text(matching, 'status') != 'completed') throw new Error('产品不属于本次冻结匹配结果,请返回重新选择。')
return { assessmentId: id, revisionId: text(data, 'revisionId'), resultVersion: version, snapshotId, ruleVersion: text(matching, 'ruleVersion'), orderingVersion: text(matching, 'orderingPolicyVersion'), item }
}
export async function fetchPublicProduct(id: string): Promise<ProductView> {
return productView(await requestJson('/products/' + encodeURIComponent(id), 'GET', new UTSJSONObject()), true)
const result = productView(await requestJson('/products/' + encodeURIComponent(id), 'GET', new UTSJSONObject()), true)
if (result.id != id || result.version < 1 || Math.floor(result.version) != result.version) throw new Error('产品资料与所选产品不一致,请重新加载。')
return result
}
+43 -11
View File
@@ -14,6 +14,7 @@ export type ArchiveCompleteness = { percent: number | null, description: string
export async function fetchArchiveCompleteness(id: string, revisionId: string = ''): Promise<ArchiveCompleteness> {
const report = await requestJson('/assessments/' + encodeURIComponent(id) + '/report', 'GET', revisionParams(revisionId))
if (report.getString('assessmentId') != id) throw new Error('报告与当前档案不一致')
if (revisionId.length > 0 && report.getString('revisionId') != revisionId) throw new Error('报告版本不一致,请重新选择。')
if (report.getNumber('resultVersion') == 1) return { percent: null, description: '这份旧版报告未计算完整度,已提交资料仍可查看。' }
if (report.getNumber('resultVersion') != 2) throw new Error('报告格式暂不支持')
const completeness = report['completeness'] as UTSJSONObject | null
@@ -23,7 +24,14 @@ export async function fetchArchiveCompleteness(id: string, revisionId: string =
return { percent, description: '反映本次评估时的资料填写情况,不代表信用评分或审批结果。' }
}
function recordFrom(item: UTSJSONObject): AssessmentRecord {
function validAssessmentId(value: string): boolean { return /^[1-9][0-9]{0,9}$/.test(value) }
function recordFrom(item: UTSJSONObject, id: string = '', revisionId: string = ''): AssessmentRecord {
const recordId = draftText(item, 'assessmentId'); const revision = draftText(item, 'revisionId')
const revisionNo = item.getNumber('revisionNo')
if (!validAssessmentId(recordId) || !validAssessmentId(revision) || (id.length > 0 && recordId != id) || (revisionId.length > 0 && revision != revisionId)
|| !['personal', 'enterprise'].includes(draftText(item, 'type')) || !['processing', 'failed', 'succeeded'].includes(draftText(item, 'status'))
|| revisionNo == null || !Number.isInteger(revisionNo) || revisionNo < 1
|| (item.getBoolean('hasReport') == true && !validAssessmentId(draftText(item, 'publishedRevisionId')))) throw new Error('评估记录或版本不一致,请重新加载。')
const submitted = draftText(item, 'submittedAt')
const date = new Date(submitted)
const pad = (value: number): string => value < 10 ? '0' + value.toString() : value.toString()
@@ -126,6 +134,9 @@ export async function createAssessment(payload: UTSJSONObject): Promise<string>
const documents = payload['documents'] as UTSJSONObject[] | null
const grants = payload['authorizations'] as string[] | null
if ((documents != null && documents.length > 0) || (grants != null && grants.length > 0)) throw new Error('资料上传与外部查询授权尚未开放,请移除资料及授权选择后提交')
const editId = draftText(payload, 'editAssessmentId'); const baseRevision = draftText(payload, 'baseRevisionId')
if (editId.length > 0 && (!validAssessmentId(editId) || !validAssessmentId(baseRevision))) throw new Error('修改版本无效,请返回档案重新读取。')
if (!['personal', 'enterprise'].includes(draftText(payload, 'type'))) throw new Error('评估类型无效,请重新填写。')
const values = assessmentApiPayload(payload)
const form = new UTSJSONObject()
const textKeys = ['name', 'idCard', 'phone', 'city', 'occupation', 'company', 'enterpriseName', 'businessCode']
@@ -150,14 +161,15 @@ export async function createAssessment(payload: UTSJSONObject): Promise<string>
form['cityCode'] = cityIndex < 0 ? null : cityCodes[cityIndex]
// 输入v3恢复原资料表单,不提交已取消的资金需求字段;报告仍使用v2展示结构。
const body: UTSJSONObject = { inputVersion: 3, type: values['type'], moneyUnit: 'yuan', form, documents: [] as UTSJSONObject[], consentIds: [] as string[] }
const editId = draftText(payload, 'editAssessmentId')
if (editId.length > 0) body['baseRevisionId'] = draftText(payload, 'baseRevisionId')
if (editId.length > 0) body['baseRevisionId'] = baseRevision
// 新建和修改使用不同幂等作用域,回执丢失时安全重放原提交。
body['requestId'] = assessmentRequestKey(session['token'] as string, editId + ':' + JSON.stringify(body))
const path = editId.length > 0 ? '/assessments/' + encodeURIComponent(editId) + '/revisions' : '/assessments'
const result = await requestJson(path, 'POST', body)
const id = draftText(result, 'assessmentId')
if (id.length == 0) throw new Error('提交回执不完整,请重试')
const revision = draftText(result, 'revisionId')
if (!validAssessmentId(id) || !validAssessmentId(revision) || result.getString('submissionStatus') != 'accepted'
|| (editId.length > 0 && (id != editId || revision == baseRevision))) throw new Error('提交回执不一致,请使用原提交重试。')
return id
}
@@ -166,16 +178,34 @@ export async function fetchAssessments(cursor: string, type: string = ''): Promi
// 按主体在服务端过滤后分页,不能只过滤当前页而漏掉更早的企业/个人档案。
if (type.length > 0) params['type'] = type
const result = await requestJson('/assessments', 'GET', params)
const items = result['items'] as UTSJSONObject[] | null
return { items: items == null ? [] : items.map((item: UTSJSONObject): AssessmentRecord => recordFrom(item)), hasMore: result['hasMore'] == true, nextCursor: draftText(result, 'nextCursor') }
return assessmentPageFrom(result, cursor, type)
}
// 记录和版本分页必须属于当前选择,坏页不能推进游标或拼入另一评估。
function assessmentPageFrom(result: UTSJSONObject, cursor: string, type: string = '', id: string = ''): AssessmentPage {
const rawItems = result['items'] as UTSJSONObject[] | null
const more = result.getBoolean('hasMore'); const next = draftText(result, 'nextCursor')
if (rawItems == null || !Array.isArray(rawItems) || more == null) throw new Error('历史分页格式不完整,请重试。')
const items: AssessmentRecord[] = []; let previous = cursor
rawItems.forEach((item: UTSJSONObject) => {
const record = recordFrom(item, id)
const key = id.length > 0 ? record.revisionId : record.id
if ((type.length > 0 && record.type != type) || (previous.length > 0 && parseInt(key) >= parseInt(previous))) throw new Error('历史分页记录不一致,请重试。')
previous = key; items.push(record)
})
if ((more && (items.length == 0 || !validAssessmentId(next) || next != previous)) || (!more && next.length > 0)) throw new Error('历史分页游标不一致,请重试。')
return { items, hasMore: more, nextCursor: next }
}
export async function fetchAssessmentStatus(id: string): Promise<AssessmentRecord> {
return recordFrom(await requestJson('/assessments/' + encodeURIComponent(id) + '/status', 'GET', {}))
return recordFrom(await requestJson('/assessments/' + encodeURIComponent(id) + '/status', 'GET', {}), id)
}
export async function fetchAssessmentDetail(id: string, revisionId: string = ''): Promise<AssessmentDetail> {
const result = await requestJson('/assessments/' + encodeURIComponent(id), 'GET', revisionParams(revisionId))
if (result.getString('assessmentId') != id) throw new Error('评估详情与当前档案不一致')
if (revisionId.length > 0 && result.getString('revisionId') != revisionId) throw new Error('评估详情版本不一致,请重新选择。')
const record = recordFrom(result, id, revisionId)
const form = result['form'] as UTSJSONObject | null
if (form == null) throw new Error('评估详情不完整')
const fields: AssessmentField[] = []
@@ -209,19 +239,21 @@ export async function fetchAssessmentDetail(id: string, revisionId: string = '')
// “无”及数值0都是已填写,不能用真值判断遗漏零负债、零经营月数。
addField(incomeKeys.includes(key) ? 1 : (assetKeys.includes(key) ? 2 : 0), labels[index], value, raw != null && value.length > 0)
})
return { record: recordFrom(result), fields, sections }
return { record, fields, sections }
}
// 版本编号显式随请求传递,避免不同页面共享“当前版本”造成报告串版。
export function revisionParams(revisionId: string): UTSJSONObject { return revisionId.length > 0 ? { revisionId } : new UTSJSONObject() }
export async function fetchAssessmentRevisions(id: string, cursor: string): Promise<AssessmentPage> {
const result = await requestJson('/assessments/' + encodeURIComponent(id) + '/revisions', 'GET', { cursor })
return { items: (result['items'] as UTSJSONObject[]).map((item: UTSJSONObject): AssessmentRecord => recordFrom(item)), hasMore: result.getBoolean('hasMore') ?? false, nextCursor: draftText(result, 'nextCursor') }
return assessmentPageFrom(result, cursor, '', id)
}
export async function fetchAssessmentEdit(id: string): Promise<UTSJSONObject> {
const result = await requestJson('/assessments/' + encodeURIComponent(id) + '/edit', 'GET', {})
if (draftText(result, 'assessmentId') != id || result.getString('status') == 'processing') throw new Error('评估正在处理中,请完成后再修改。')
const form = result['form'] as UTSJSONObject
const record = recordFrom(result, id)
if (record.status == 'processing') throw new Error('评估正在处理中,请完成后再修改。')
const form = result['form'] as UTSJSONObject | null
if (form == null || Array.isArray(form) || typeof form != 'object' || result.getString('moneyUnit') != 'yuan') throw new Error('修改资料不完整,请返回档案重新读取。')
const payload: UTSJSONObject = { type: result['type'], editAssessmentId: id, baseRevisionId: result['revisionId'], step: 0 }
const money = ['monthlyIncome', 'annualRevenue', 'totalDebtBalance', 'monthlyLoanPayment', 'creditCardTotalLimit', 'creditCardUsedLimit']
const labels: UTSJSONObject = { married: '已婚', single: '未婚', divorced: '离异', widowed: '丧偶', yes: '有', no: '无', six_months_plus: '连续缴纳 6 个月以上', under_six_months: '缴纳不足 6 个月', none: '未缴纳' }
+111 -4
View File
@@ -3,17 +3,122 @@ import { APP_ENVIRONMENT } from '@/services/config.uts'
export type ReportDownload = { cancel: () => void, done: Promise<string> }
// #ifdef MP-WEIXIN
let previewFiles: string[] = []
const cleanPreviewFiles = () => {
previewFiles = previewFiles.filter((path: string): boolean => {
try { uni.getFileSystemManager().unlinkSync(path); return false } catch (_err) { return true }
})
}
// 系统文档预览期间保留临时文件,返回小程序后清理;不调用saveFile持久化报告。
uni.onAppShow(() => cleanPreviewFiles())
// #endif
export function downloadReportPdf(id: string, revisionId: string = ''): ReportDownload {
// 首版仅开放普通浏览器H5;其他端明确提示,不生成假文件或成功回执。
// #ifndef H5
// #ifndef H5 || MP-WEIXIN
return { cancel: () => {}, done: new Promise<string>((_resolve, reject) => { reject(new Error('当前客户端暂未开放PDF下载,请在普通浏览器打开H5并登录后下载。')) }) }
// #endif
// #ifdef MP-WEIXIN
cleanPreviewFiles()
let cancelled = false; let opening = false; let settled = false
let task: DownloadTask | null = null; let tempPath = ''; let rejectDownload: ((error: Error) => void) | null = null
const removeTemp = () => {
if (tempPath.length == 0) return
try { uni.getFileSystemManager().unlinkSync(tempPath) } catch (_err) { previewFiles.push(tempPath) }
tempPath = ''
}
const fail = (message: string) => {
removeTemp()
if (settled) return
settled = true; rejectDownload?.(new Error(message))
}
const stop = (message: string) => { fail(message); task?.abort() }
const cancel = () => {
// openDocument已经接管文件后,原页面可能隐藏/销毁,不能中止或删除正在预览的文件。
if (opening || settled) return
cancelled = true; stop('下载已取消。')
}
const done = new Promise<string>((resolve, reject) => {
rejectDownload = reject
if (!/^[1-9][0-9]{0,9}$/.test(id) || (revisionId.length > 0 && !/^[1-9][0-9]{0,9}$/.test(revisionId))) { fail('报告或版本编号无效,请重新打开本人报告。'); return }
const token = getSession()?.getString('token') ?? ''
if (token.length == 0) { fail('登录已过期,请重新登录后下载。'); return }
if (!API_BASE_URL.startsWith('https://')) { fail('小程序PDF下载需要配置HTTPS服务地址。'); return }
const current = (): boolean => {
if (settled) return false
if (cancelled || getSession()?.getString('token') != token) { fail('页面或登录状态已变化,本次下载已取消。'); return false }
return true
}
let mime = ''; let declaredSize = 0; let encoded = false
task = uni.downloadFile({
url: API_BASE_URL + '/assessments/' + id + '/report.pdf' + (revisionId.length > 0 ? '?revisionId=' + encodeURIComponent(revisionId) : ''),
header: { Authorization: 'Bearer ' + token, 'X-Requested-With': 'XMLHttpRequest' }, timeout: 45000,
success: (response) => {
tempPath = response.tempFilePath
if (!current()) { removeTemp(); return }
if (response.statusCode == 401) { clearSession(); fail('登录已过期,请重新登录后下载。'); return }
try {
const fs = uni.getFileSystemManager()
const size = (fs.statSync(tempPath, false) as UniNamespace.Stats).size
if (response.statusCode != 200 || mime != 'application/pdf') {
let message = 'PDF暂不可下载,请稍后重试;已保存报告不受影响。'
if (mime == 'application/json' && size > 0 && size <= 8192) {
const body = JSON.parse<UTSJSONObject>(fs.readFileSync(tempPath, 'utf8') as string)
const serverMessage = body?.getString('msg') ?? ''
if (serverMessage.length > 0 && serverMessage.length < 200) message = serverMessage
}
fail(message); return
}
if (size > 20971520) { fail('PDF文件过大,请联系管理员。'); return }
if (size < 100 || (!encoded && declaredSize > 0 && declaredSize != size)) { fail('下载内容不是完整PDF,请重试。'); return }
// 只读取头尾,避免将整份报告加载到小程序内存。
const start = fs.readFileSync(tempPath, 'ascii', 0, 5) as string
const tailLength = Math.min(size, 1024)
const end = fs.readFileSync(tempPath, 'ascii', size - tailLength, tailLength) as string
if (start != '%PDF-' || !end.includes('%%EOF')) { fail('下载内容不是完整PDF,请重试。'); return }
if (!current()) return
opening = true
uni.openDocument({ filePath: tempPath, fileType: 'pdf', showMenu: true,
success: () => {
if (!current()) return
previewFiles.push(tempPath); tempPath = ''; settled = true
resolve('PDF已打开,可通过右上角菜单保存或转发。')
}, fail: () => { opening = false; fail('PDF已下载但暂时无法打开,请重试或更新微信。') }
})
} catch (_err) { fail('PDF文件读取失败或内容异常,请重试。') }
},
fail: (error) => {
if (!current()) return
const reason = error.errMsg ?? ''
if (reason.includes('domain list')) fail('PDF下载域名未配置,请联系管理员。')
else if (reason.includes('timeout')) fail('PDF生成超时,请稍后重试。')
else fail('PDF下载失败,请检查网络后重试。')
}
})
task!.onHeadersReceived((response) => {
if (!current()) { task?.abort(); return }
const header = response.header as UTSJSONObject
Object.keys(header).forEach((key: string) => {
const value = header[key]?.toString() ?? ''
if (key.toLowerCase() == 'content-type') mime = value.split(';')[0].trim().toLowerCase()
if (key.toLowerCase() == 'content-length') declaredSize = Number(value)
if (key.toLowerCase() == 'content-encoding') encoded = value.length > 0 && value.toLowerCase() != 'identity'
})
if (declaredSize > 20971520) stop('PDF文件过大,请联系管理员。')
})
task!.onProgressUpdate((progress) => {
if (!current()) { task?.abort(); return }
if (progress.totalBytesWritten > 20971520 || progress.totalBytesExpectedToWrite > 20971520) stop('PDF文件过大,请联系管理员。')
})
})
return { cancel, done }
// #endif
// #ifdef H5
const controller = new AbortController()
let cancelled = false
const cancel = () => { cancelled = true; controller.abort() }
const done = async (): Promise<string> => {
if (!/^[1-9][0-9]{0,9}$/.test(id)) throw new Error('报告编号无效,请重新打开本人报告。')
if (!/^[1-9][0-9]{0,9}$/.test(id) || (revisionId.length > 0 && !/^[1-9][0-9]{0,9}$/.test(revisionId))) throw new Error('报告或版本编号无效,请重新打开本人报告。')
if (/MicroMessenger/i.test(navigator.userAgent)) throw new Error('微信内浏览器暂不支持此下载。请点右上角菜单,选择“在浏览器打开”,登录后重新点击下载PDF。')
const token = getSession()?.getString('token') ?? ''
if (token.length == 0) throw new Error('登录已过期,请重新登录后下载。')
@@ -28,6 +133,7 @@ export function downloadReportPdf(id: string, revisionId: string = ''): ReportDo
cache: 'no-store', credentials: 'omit', redirect: 'error', signal: controller.signal
})
current()
if (response == null) throw new Error('下载服务未返回文件,请重试。')
if (response.status == 401) { clearSession(); throw new Error('登录已过期,请重新登录后下载。') }
const mime = (response.headers.get('Content-Type') ?? '').split(';')[0].trim().toLowerCase()
if (!response.ok || mime != 'application/pdf') {
@@ -44,7 +150,8 @@ export function downloadReportPdf(id: string, revisionId: string = ''): ReportDo
current()
const start = new TextDecoder('ascii').decode(bytes.slice(0, 5))
const end = new TextDecoder('ascii').decode(bytes.slice(Math.max(0, bytes.byteLength - 1024)))
if (bytes.byteLength < 100 || bytes.byteLength > 20971520 || start != '%PDF-' || !end.includes('%%EOF')) throw new Error('下载内容不是完整PDF,请重试。')
const encoded = (response.headers.get('Content-Encoding') ?? '').toLowerCase()
if (bytes.byteLength < 100 || bytes.byteLength > 20971520 || ((encoded.length == 0 || encoded == 'identity') && declaredSize > 0 && declaredSize != bytes.byteLength) || start != '%PDF-' || !end.includes('%%EOF')) throw new Error('下载内容不是完整PDF,请重试。')
// 验证响应后才创建本机Blob。令牌只放请求头,不写URL、文件名或本机存储。
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }))
const link = document.createElement('a')