feat: 优化账户切换时的页面状态一致性,增加登录回跳校验和课程阅读新逻辑
- 增加账户状态监听定时器,页面切换时自动校验账户归属,避免跨账户操作 - 新增登录回跳路径校验函数,限制仅允许指定页面回跳,防止非法跳转 - 课程页改为整篇连续阅读,底部仅一次标记已读,兼容历史记录 - 完善助手、对比页、资料填写页等多处的账户状态校验逻辑,防止错版数据提交 - 优化账户资料请求逻辑,增加版本校验,避免旧请求覆盖新数据
This commit is contained in:
@@ -2,8 +2,8 @@
|
||||
<view class="page" :style="pageStyle()"><AppHeader title="更换手机号" />
|
||||
<scroll-view scroll-y :style="contentStyle(96, false)"><view class="account-form-content" :style="bottomStyle(40)">
|
||||
<text class="form-title">验证并绑定新手机号</text><text class="form-description">请分别验证当前号码和新号码,确保账户由您本人操作。</text>
|
||||
<view class="form-card"><text class="form-label">当前手机号 {{ maskPhone(phone) }}</text><SecurityCode v-model="currentCode" :disabled="submitting" purpose="phone-current" @error="codeError" @sent="errorMessage = ''" /></view>
|
||||
<view class="form-card"><text class="form-label">新手机号</text><input v-model="newPhone" class="account-input new-phone" :disabled="submitting" type="number" maxlength="11" placeholder="请输入新的 11 位手机号" /><SecurityCode v-model="newCode" :disabled="submitting || !validPhone(newPhone) || newPhone == phone" purpose="phone-new" :phone="newPhone" @error="codeError" @sent="errorMessage = ''" /></view>
|
||||
<view class="form-card"><text class="form-label">当前手机号 {{ maskPhone(phone) }}</text><SecurityCode v-model="currentCode" :disabled="submitting" :active="pageVisible" :ownerToken="ownerToken" purpose="phone-current" @error="codeError" @sent="errorMessage = ''" /></view>
|
||||
<view class="form-card"><text class="form-label">新手机号</text><input v-model="newPhone" class="account-input new-phone" :disabled="submitting" type="number" maxlength="11" placeholder="请输入新的 11 位手机号" /><SecurityCode v-model="newCode" :disabled="submitting || !validPhone(newPhone) || newPhone == phone" :active="pageVisible" :ownerToken="ownerToken" purpose="phone-new" :phone="newPhone" @error="codeError" @sent="errorMessage = ''" /></view>
|
||||
<text class="form-hint">换绑成功后需要使用新手机号登录。若原号码已无法接收短信,请通过官方客服核验身份后处理。</text>
|
||||
<text v-if="errorMessage.length > 0" class="form-error">{{ errorMessage }}</text>
|
||||
<button class="primary-button form-submit" :disabled="submitting" @click="submit">{{ submitting ? '提交中…' : '确认更换' }}</button>
|
||||
@@ -18,20 +18,35 @@
|
||||
import { getAccountProfile, maskPhone, validPhone, changeAccountPhone } from '@/packages/account/services/account.uts'
|
||||
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
|
||||
const phone = ref(''); const newPhone = ref(''); const currentCode = ref(''); const newCode = ref(''); const submitting = ref(false); const errorMessage = ref('')
|
||||
onLoad(() => { if (getSession() == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }; phone.value = getAccountProfile().phone })
|
||||
const codeError = (message: string) => { errorMessage.value = message }
|
||||
const ownerToken = ref(''); const pageVisible = ref(false)
|
||||
let ownerId = ''; let generation = 0; let accountTimer = 0
|
||||
const clearSecrets = () => { currentCode.value = ''; newCode.value = ''; newPhone.value = '' }
|
||||
const stopAccountTimer = () => { clearInterval(accountTimer); accountTimer = 0 }
|
||||
const sameOwner = (): boolean => {
|
||||
if (!pageVisible.value) return false
|
||||
const session = getSession()
|
||||
if (ownerToken.value.length > 0 && session?.getString('token') == ownerToken.value && session?.getString('userId') == ownerId) return true
|
||||
generation++; clearSecrets(); phone.value = ''; errorMessage.value = ''; stopAccountTimer()
|
||||
uni.redirectTo({ url: session == null ? '/packages/account/login/login' : '/packages/account/security/security' })
|
||||
return false
|
||||
}
|
||||
onLoad(() => { const session = getSession(); ownerToken.value = session?.getString('token') ?? ''; ownerId = session?.getString('userId') ?? ''; phone.value = getAccountProfile().phone })
|
||||
onShow(() => { pageVisible.value = true; generation++; stopAccountTimer(); if (sameOwner()) accountTimer = setInterval(() => { sameOwner() }, 1000) })
|
||||
const codeError = (message: string) => { if (sameOwner()) errorMessage.value = message }
|
||||
const submit = async () => {
|
||||
if (submitting.value) return
|
||||
if (!sameOwner() || submitting.value) return
|
||||
submitting.value = true; errorMessage.value = ''
|
||||
const view = generation; const token = ownerToken.value; const userId = ownerId
|
||||
try {
|
||||
if (getAccountProfile().phone != phone.value) throw new Error('登录状态已变化,请重新进入页面')
|
||||
await changeAccountPhone(newPhone.value, currentCode.value, newCode.value)
|
||||
currentCode.value = ''; newCode.value = ''; clearSession(); uni.reLaunch({ url: '/packages/account/login/login' }); uni.showToast({ title: '手机号已更换,请重新登录', icon: 'none' })
|
||||
} catch (error) { errorMessage.value = error instanceof Error ? error.message : '更换失败,请稍后重试' }
|
||||
if (getSession()?.getString('token') != token || getSession()?.getString('userId') != userId) return
|
||||
clearSecrets(); clearSession()
|
||||
if (view == generation && pageVisible.value) { uni.reLaunch({ url: '/packages/account/login/login' }); uni.showToast({ title: '手机号已更换,请重新登录', icon: 'none' }) }
|
||||
} catch (error) { if (view == generation && sameOwner()) errorMessage.value = error instanceof Error ? error.message : '更换失败,请稍后重试' }
|
||||
finally { submitting.value = false }
|
||||
}
|
||||
onHide(() => { currentCode.value = ''; newCode.value = '' })
|
||||
onUnload(() => { currentCode.value = ''; newCode.value = '' })
|
||||
onHide(() => { pageVisible.value = false; generation++; stopAccountTimer(); clearSecrets(); errorMessage.value = '' })
|
||||
onUnload(() => { pageVisible.value = false; generation++; stopAccountTimer(); clearSecrets(); phone.value = '' })
|
||||
</script>
|
||||
<style>
|
||||
@import '../styles/forms.css';
|
||||
|
||||
@@ -3,23 +3,29 @@
|
||||
</template>
|
||||
<script setup lang="uts">
|
||||
import { requestSecurityCode } from '@/packages/account/services/account.uts'
|
||||
const props = defineProps({ modelValue: { type: String, default: '' }, purpose: { type: String, default: 'password' }, phone: { type: String, default: '' }, disabled: { type: Boolean, default: false } })
|
||||
import { getSession } from '@/services/api.uts'
|
||||
const props = defineProps({ modelValue: { type: String, default: '' }, purpose: { type: String, default: 'password' }, phone: { type: String, default: '' }, disabled: { type: Boolean, default: false }, active: { type: Boolean, default: true }, ownerToken: { type: String, default: '' } })
|
||||
const emit = defineEmits(['update:modelValue', 'error', 'sent'])
|
||||
const sending = ref(false); const countdown = ref(0)
|
||||
let timer = 0; let version = 0; let expiresAt = 0
|
||||
const reset = () => { version++; clearInterval(timer); countdown.value = 0; sending.value = false }
|
||||
const targetPhone = computed((): string => props.phone)
|
||||
watch(targetPhone, () => { reset(); emit('update:modelValue', '') })
|
||||
watch((): boolean => props.active, () => reset())
|
||||
watch((): string => props.ownerToken, () => { reset(); emit('update:modelValue', '') })
|
||||
const send = async () => {
|
||||
if (props.disabled || sending.value || countdown.value > 0) return
|
||||
if (!props.active || props.disabled || sending.value || countdown.value > 0) return
|
||||
const token = props.ownerToken
|
||||
if (token.length == 0 || getSession()?.getString('token') != token) { emit('error', '登录状态已变化,请重新进入页面'); return }
|
||||
const current = ++version; sending.value = true
|
||||
const valid = (): boolean => current == version && props.active && props.ownerToken == token && getSession()?.getString('token') == token
|
||||
try {
|
||||
await requestSecurityCode(props.purpose, props.phone)
|
||||
if (current != version) return
|
||||
if (!valid()) return
|
||||
expiresAt = Date.now() + 60000; countdown.value = 60
|
||||
timer = setInterval(() => { countdown.value = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000)); if (countdown.value == 0) clearInterval(timer) }, 1000)
|
||||
emit('sent'); uni.showToast({ title: '验证码已发送', icon: 'none' })
|
||||
} catch (error) { if (current == version) emit('error', error instanceof Error ? error.message : '发送失败,请稍后重试') }
|
||||
} catch (error) { if (valid()) emit('error', error instanceof Error ? error.message : '发送失败,请稍后重试') }
|
||||
finally { if (current == version) sending.value = false }
|
||||
}
|
||||
onUnmounted(() => reset())
|
||||
|
||||
@@ -20,59 +20,82 @@
|
||||
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
|
||||
const nickname = ref(''); const avatar = ref(''); const phone = ref(''); const saving = ref(false); const choosing = ref(false); const errorMessage = ref('')
|
||||
const avatarId = ref('')
|
||||
let originalName = ''; let originalAvatar = ''; let alive = true
|
||||
const dirty = (): boolean => nickname.value.trim() != originalName || avatar.value != originalAvatar
|
||||
let originalName = ''; let originalAvatar = ''; let ownerToken = ''; let ownerId = ''; let pageVisible = false; let generation = 0; let profileRequest = 0; let accountTimer = 0
|
||||
const stopAccountTimer = () => { clearInterval(accountTimer); accountTimer = 0 }
|
||||
const clearView = () => { nickname.value = ''; avatar.value = ''; phone.value = ''; avatarId.value = ''; originalName = ''; originalAvatar = ''; errorMessage.value = ''; saving.value = false; choosing.value = false }
|
||||
const sameOwner = (): boolean => {
|
||||
if (!pageVisible) return false
|
||||
const session = getSession()
|
||||
if (ownerToken.length > 0 && session?.getString('token') == ownerToken && session?.getString('userId') == ownerId) return true
|
||||
generation++; profileRequest++; clearView(); stopAccountTimer()
|
||||
uni.redirectTo({ url: session == null ? '/packages/account/login/login' : '/packages/account/profile/profile' })
|
||||
return false
|
||||
}
|
||||
const dirty = (): boolean => nickname.value.trim() != originalName || avatar.value != originalAvatar || avatarId.value.length > 0
|
||||
onLoad(() => {
|
||||
if (getSession() == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }
|
||||
const session = getSession(); ownerToken = session?.getString('token') ?? ''; ownerId = session?.getString('userId') ?? ''
|
||||
})
|
||||
onShow(() => {
|
||||
pageVisible = true; generation++; stopAccountTimer()
|
||||
if (!sameOwner()) return
|
||||
accountTimer = setInterval(() => { sameOwner() }, 1000)
|
||||
if (dirty() || saving.value) return
|
||||
const profile = getAccountProfile(); nickname.value = profile.nickname; avatar.value = profile.avatar; phone.value = profile.phone
|
||||
originalName = profile.nickname; originalAvatar = profile.avatar
|
||||
const request = ++profileRequest; const view = generation
|
||||
// 服务端资料优先;请求返回时已开始编辑则不覆盖用户输入。
|
||||
fetchAccountProfile().then((fresh) => {
|
||||
if (!alive || dirty()) return
|
||||
if (request != profileRequest || view != generation || !sameOwner() || dirty()) return
|
||||
nickname.value = fresh.nickname; avatar.value = fresh.avatar; phone.value = fresh.phone
|
||||
originalName = fresh.nickname; originalAvatar = fresh.avatar
|
||||
}).catch((error) => { if (alive) errorMessage.value = error instanceof Error ? error.message : '资料加载失败,请重试' })
|
||||
}).catch((error) => { if (request == profileRequest && view == generation && sameOwner()) errorMessage.value = error instanceof Error ? error.message : '资料加载失败,请重试' })
|
||||
})
|
||||
const leave = () => { if (getCurrentPages().length > 1) uni.navigateBack(); else uni.redirectTo({ url: '/packages/account/profile/profile' }) }
|
||||
const back = () => {
|
||||
if (saving.value) return
|
||||
if (!sameOwner() || saving.value || choosing.value) return
|
||||
if (!dirty()) { leave(); return }
|
||||
uni.showModal({ title: '放弃修改?', content: '当前修改尚未保存。', confirmText: '放弃修改', success: (result) => { if (result.confirm) { originalName = nickname.value.trim(); originalAvatar = avatar.value; leave() } } })
|
||||
const view = generation
|
||||
uni.showModal({ title: '放弃修改?', content: '当前修改尚未保存。', confirmText: '放弃修改', success: (result) => { if (result.confirm && view == generation && sameOwner()) leave() } })
|
||||
}
|
||||
onBackPress((event): boolean => { if (event.from == 'navigateBack') return false; if (saving.value || dirty()) { back(); return true }; return false })
|
||||
const loadAvatar = async (path: string) => {
|
||||
const loadAvatar = async (path: string, view: number) => {
|
||||
if (view != generation || !sameOwner()) return
|
||||
choosing.value = true; errorMessage.value = ''
|
||||
try {
|
||||
const result = await uploadAccountAvatar(path)
|
||||
if (alive) { avatar.value = result['avatarUrl'] as string; avatarId.value = result['attachmentId'] as string }
|
||||
if (view == generation && sameOwner()) { avatar.value = result['avatarUrl'] as string; avatarId.value = result['attachmentId'] as string }
|
||||
}
|
||||
catch (error) { if (alive) errorMessage.value = error instanceof Error ? error.message : '图片读取失败' }
|
||||
finally { if (alive) choosing.value = false }
|
||||
catch (error) { if (view == generation && sameOwner()) errorMessage.value = error instanceof Error ? error.message : '图片读取失败' }
|
||||
finally { choosing.value = false }
|
||||
}
|
||||
const chooseAvatar = () => {
|
||||
if (saving.value || choosing.value) return
|
||||
if (!sameOwner() || saving.value || choosing.value) return
|
||||
const view = generation
|
||||
uni.showActionSheet({ itemList: ['从相册选择', '恢复默认头像'], success: (action) => {
|
||||
if (view != generation || !sameOwner()) return
|
||||
if (action.tapIndex == 1) { avatar.value = ''; avatarId.value = 'default'; return }
|
||||
uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album'], success: (result) => {
|
||||
if (result.tempFilePaths.length == 0) return
|
||||
if (view != generation || !sameOwner() || result.tempFilePaths.length == 0) return
|
||||
if (result.tempFiles[0].size > 512000) { errorMessage.value = '请选择 500KB 以内的图片'; return }
|
||||
loadAvatar(result.tempFilePaths[0])
|
||||
}, fail: (error) => { if (!error.errMsg.includes('cancel')) errorMessage.value = '无法打开相册,请检查权限后重试' } })
|
||||
loadAvatar(result.tempFilePaths[0], view)
|
||||
}, fail: (error) => { if (view == generation && sameOwner() && !error.errMsg.includes('cancel')) errorMessage.value = '无法打开相册,请检查权限后重试' } })
|
||||
} })
|
||||
}
|
||||
const openPhone = () => { if (!saving.value && !choosing.value) uni.navigateTo({ url: '/packages/account/change-phone/change-phone' }) }
|
||||
const openPhone = () => { if (sameOwner() && !saving.value && !choosing.value) uni.navigateTo({ url: '/packages/account/change-phone/change-phone' }) }
|
||||
const save = async () => {
|
||||
if (saving.value || choosing.value) return
|
||||
if (!sameOwner() || saving.value || choosing.value) return
|
||||
const view = generation; profileRequest++
|
||||
errorMessage.value = ''; saving.value = true
|
||||
try {
|
||||
if (getAccountProfile().phone != phone.value) throw new Error('登录状态已变化,请重新进入页面')
|
||||
await saveAccountProfile(nickname.value, avatarId.value)
|
||||
originalName = nickname.value.trim(); originalAvatar = avatar.value
|
||||
if (view != generation || !sameOwner()) return
|
||||
originalName = nickname.value.trim(); originalAvatar = avatar.value; avatarId.value = ''
|
||||
uni.showToast({ title: '资料已保存', icon: 'success' }); leave()
|
||||
} catch (error) { errorMessage.value = error instanceof Error ? error.message : '保存失败,请稍后重试' }
|
||||
} catch (error) { if (view == generation && sameOwner()) errorMessage.value = error instanceof Error ? error.message : '保存失败,请稍后重试' }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
onUnload(() => { alive = false })
|
||||
onHide(() => { pageVisible = false; generation++; profileRequest++; stopAccountTimer() })
|
||||
onUnload(() => { pageVisible = false; generation++; profileRequest++; stopAccountTimer(); clearView() })
|
||||
</script>
|
||||
<style>
|
||||
.edit-content { padding: 28rpx 30rpx 40rpx; }
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
import { assistantReturnPath } from '@/services/assistant-return.uts'
|
||||
import { nextStepRoute } from '@/services/next-step-route.uts'
|
||||
import { reportRoute } from '@/services/report-route.uts'
|
||||
import { matchReturnPath } from '@/packages/finance/services/compare.uts'
|
||||
let afterLoginUrl = '/pages/home/home'
|
||||
onLoad((options) => {
|
||||
consumeInitialLaunch()
|
||||
@@ -50,6 +51,9 @@
|
||||
} else if (options?.['resumeReport'] == '1') {
|
||||
const target = reportRoute(options?.['assessmentId'] ?? '', options?.['tab'] ?? 'overview', options?.['revisionId'] ?? '')
|
||||
if (target.length > 0) afterLoginUrl = target
|
||||
} else if (options?.['resumeMatch'] == '1') {
|
||||
const target = matchReturnPath(options?.['matchPage'] ?? '', options?.['assessmentId'] ?? '', options?.['productIds'] ?? '', options?.['revisionId'] ?? '', options?.['productId'] ?? '', options?.['group'] ?? 'eligible', options?.['tab'] ?? 'explanation', options?.['from'] ?? '')
|
||||
if (target.length > 0) afterLoginUrl = target
|
||||
} else if (options?.['resumePoints'] == '1') {
|
||||
afterLoginUrl = '/packages/account/points/points'
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
<text class="form-description">{{ mode == 'password' ? '为保障账户安全,请先验证当前密码。' : '验证码将发送至绑定手机号 ' + maskPhone(phone) }}</text>
|
||||
<view class="form-card">
|
||||
<template v-if="mode == 'password'"><text class="form-label">当前密码</text><input v-model="oldPassword" class="account-input old-password" :disabled="submitting" :password="!visible" maxlength="64" placeholder="请输入当前密码" /></template>
|
||||
<template v-else><text class="form-label">短信验证码</text><SecurityCode v-model="code" :disabled="submitting" purpose="password" @error="codeError" @sent="errorMessage = ''" /></template>
|
||||
<template v-else><text class="form-label">短信验证码</text><SecurityCode v-model="code" :disabled="submitting" :active="pageVisible" :ownerToken="ownerToken" purpose="password" @error="codeError" @sent="errorMessage = ''" /></template>
|
||||
<text class="form-label">新密码</text><input v-model="newPassword" class="account-input new-password" :disabled="submitting" :password="!visible" maxlength="32" placeholder="8–32 位,包含字母和数字" />
|
||||
<text class="form-label">确认新密码</text><input v-model="confirmation" class="account-input confirm-password" :disabled="submitting" :password="!visible" maxlength="32" placeholder="请再次输入新密码" confirm-type="done" @confirm="submit" />
|
||||
<text class="visibility-control" @click="visible = !visible">{{ visible ? '隐藏密码' : '显示密码' }}</text>
|
||||
<text class="visibility-control" @click="toggleVisibility">{{ visible ? '隐藏密码' : '显示密码' }}</text>
|
||||
</view>
|
||||
<text class="form-hint">密码可包含英文符号,不支持空格。修改成功后请使用新密码重新登录。</text>
|
||||
<text v-if="errorMessage.length > 0" class="form-error">{{ errorMessage }}</text>
|
||||
@@ -25,27 +25,43 @@
|
||||
import { getAccountProfile, maskPhone, validNewPassword, validCode, changeAccountPassword } from '@/packages/account/services/account.uts'
|
||||
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
|
||||
const mode = ref('password'); const phone = ref(''); const oldPassword = ref(''); const newPassword = ref(''); const confirmation = ref(''); const code = ref(''); const visible = ref(false); const submitting = ref(false); const errorMessage = ref('')
|
||||
onLoad((options) => { if (getSession() == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }; phone.value = getAccountProfile().phone; if (options != null && options['mode'] == 'sms') mode.value = 'sms' })
|
||||
const ownerToken = ref(''); const pageVisible = ref(false)
|
||||
let ownerId = ''; let generation = 0; let accountTimer = 0
|
||||
onLoad((options) => { const session = getSession(); ownerToken.value = session?.getString('token') ?? ''; ownerId = session?.getString('userId') ?? ''; phone.value = getAccountProfile().phone; if (options != null && options['mode'] == 'sms') mode.value = 'sms' })
|
||||
const clearSecrets = () => { oldPassword.value = ''; newPassword.value = ''; confirmation.value = ''; code.value = ''; visible.value = false }
|
||||
const switchMode = () => { if (submitting.value) return; clearSecrets(); errorMessage.value = ''; mode.value = mode.value == 'password' ? 'sms' : 'password' }
|
||||
const codeError = (message: string) => { errorMessage.value = message }
|
||||
const stopAccountTimer = () => { clearInterval(accountTimer); accountTimer = 0 }
|
||||
const sameOwner = (): boolean => {
|
||||
if (!pageVisible.value) return false
|
||||
const session = getSession()
|
||||
if (ownerToken.value.length > 0 && session?.getString('token') == ownerToken.value && session?.getString('userId') == ownerId) return true
|
||||
generation++; clearSecrets(); phone.value = ''; errorMessage.value = ''; stopAccountTimer()
|
||||
uni.redirectTo({ url: session == null ? '/packages/account/login/login' : '/packages/account/security/security' })
|
||||
return false
|
||||
}
|
||||
const switchMode = () => { if (!sameOwner() || submitting.value) return; clearSecrets(); errorMessage.value = ''; mode.value = mode.value == 'password' ? 'sms' : 'password' }
|
||||
const toggleVisibility = () => { if (sameOwner()) visible.value = !visible.value }
|
||||
const codeError = (message: string) => { if (sameOwner()) errorMessage.value = message }
|
||||
onShow(() => { pageVisible.value = true; generation++; stopAccountTimer(); if (sameOwner()) accountTimer = setInterval(() => { sameOwner() }, 1000) })
|
||||
const submit = async () => {
|
||||
if (submitting.value) return
|
||||
if (!sameOwner() || submitting.value) return
|
||||
errorMessage.value = ''
|
||||
if (mode.value == 'password' && oldPassword.value.length == 0) { errorMessage.value = '请输入当前密码'; return }
|
||||
if (mode.value == 'sms' && !validCode(code.value)) { errorMessage.value = '请输入 6 位数字验证码'; return }
|
||||
if (!validNewPassword(newPassword.value)) { errorMessage.value = '新密码需为 8–32 位,包含字母和数字,可使用符号'; return }
|
||||
if (newPassword.value != confirmation.value) { errorMessage.value = '两次输入的新密码不一致'; return }
|
||||
submitting.value = true
|
||||
const view = generation; const token = ownerToken.value; const userId = ownerId
|
||||
try {
|
||||
if (getAccountProfile().phone != phone.value) throw new Error('登录状态已变化,请重新进入页面')
|
||||
await changeAccountPassword(mode.value, oldPassword.value, code.value, newPassword.value)
|
||||
clearSecrets(); clearSession(); uni.reLaunch({ url: '/packages/account/login/login' }); uni.showToast({ title: '密码已修改,请重新登录', icon: 'none' })
|
||||
} catch (error) { errorMessage.value = error instanceof Error ? error.message : '修改失败,请稍后重试' }
|
||||
// 服务端已撤销原账户会话;离页仍清理该会话,但不能影响新账户或跳转隐藏页面。
|
||||
if (getSession()?.getString('token') != token || getSession()?.getString('userId') != userId) return
|
||||
clearSecrets(); clearSession()
|
||||
if (view == generation && pageVisible.value) { uni.reLaunch({ url: '/packages/account/login/login' }); uni.showToast({ title: '密码已修改,请重新登录', icon: 'none' }) }
|
||||
} catch (error) { if (view == generation && sameOwner()) errorMessage.value = error instanceof Error ? error.message : '修改失败,请稍后重试' }
|
||||
finally { submitting.value = false }
|
||||
}
|
||||
onHide(() => clearSecrets())
|
||||
onUnload(() => clearSecrets())
|
||||
onHide(() => { pageVisible.value = false; generation++; stopAccountTimer(); clearSecrets(); errorMessage.value = '' })
|
||||
onUnload(() => { pageVisible.value = false; generation++; stopAccountTimer(); clearSecrets(); phone.value = '' })
|
||||
</script>
|
||||
<style>
|
||||
@import '../styles/forms.css';
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<view class="profile-card" @click="openAccount"><view class="avatar"><image class="profile-avatar" :src="avatar.length > 0 ? avatar : DEFAULT_AVATAR" mode="aspectFill" @error="avatar = ''" /></view><view class="profile-main"><text class="profile-name">{{ displayName }}</text><text class="profile-phone">{{ loggedIn ? maskedPhone : '登录后管理您的融资信息' }}</text></view><text v-if="!loggedIn" class="account-state">未登录</text><AppIcon v-if="loggedIn" name="chevron-right" :size="28" /></view>
|
||||
<view class="stat-row"><view class="stat-item" @click="startAssessment"><AppIcon name="edit-file" :size="42" /><text class="stat-label">开始评估</text></view><view class="stat-item" @click="openReport"><AppIcon name="file-text" :size="42" /><text class="stat-label">{{ reportLoading ? '正在查找…' : '最近报告' }}</text></view><view class="stat-item" @click="go('/packages/account/points/points')"><AppIcon name="points" :size="42" /><text class="stat-label">积分中心</text></view></view>
|
||||
<view class="menu-list"><view class="menu-row" v-for="item in menus" :key="item.name" @click="open(item.key)"><AppIcon :name="item.icon" :size="46" /><text class="menu-name">{{ item.name }}</text><text v-if="item.note.length > 0" class="menu-note">{{ item.note }}</text><AppIcon name="chevron-right" :size="28" /></view></view>
|
||||
<button class="logout" @click="logout">{{ loggedIn ? '退出登录' : '登录 / 注册' }}</button>
|
||||
<button class="logout" :disabled="loggingOut" @click="logout">{{ loggingOut ? '退出中…' : (loggedIn ? '退出登录' : '登录 / 注册') }}</button>
|
||||
<text class="version-note">AI融资 · 让融资更简单</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
@@ -34,29 +34,41 @@
|
||||
const maskedPhone = ref('')
|
||||
const avatar = ref('')
|
||||
const reportLoading = ref(false)
|
||||
let profileVisible = false; let reportRequest = 0
|
||||
onShow(() => {
|
||||
profileVisible = true
|
||||
const loggingOut = ref(false)
|
||||
let profileVisible = false; let reportRequest = 0; let generation = 0; let accountTimer = 0; let ownerToken = ''; let ownerId = ''
|
||||
const stopAccountTimer = () => { clearInterval(accountTimer); accountTimer = 0 }
|
||||
const syncProfile = () => {
|
||||
const request = ++generation; reportRequest++; reportLoading.value = false; loggingOut.value = false
|
||||
const session = getSession()
|
||||
ownerToken = session?.getString('token') ?? ''; ownerId = session?.getString('userId') ?? ''
|
||||
loggedIn.value = session != null
|
||||
const profile = getAccountProfile()
|
||||
maskedPhone.value = maskPhone(profile.phone); avatar.value = loggedIn.value ? profile.avatar : ''
|
||||
displayName.value = loggedIn.value ? profile.nickname : '登录 / 注册'
|
||||
if (loggedIn.value) fetchAccountProfile().then((fresh) => {
|
||||
if (!profileVisible || request != generation || getSession()?.getString('token') != ownerToken || getSession()?.getString('userId') != ownerId) return
|
||||
displayName.value = fresh.nickname; avatar.value = fresh.avatar; maskedPhone.value = maskPhone(fresh.phone)
|
||||
}).catch(() => { /* 读取失败保留已归属当前账户的缓存,不显示其他账户资料。 */ })
|
||||
})
|
||||
}
|
||||
const sameOwner = (): boolean => {
|
||||
if (!profileVisible) return false
|
||||
const session = getSession()
|
||||
if ((session?.getString('token') ?? '') == ownerToken && (session?.getString('userId') ?? '') == ownerId) return true
|
||||
syncProfile(); return false
|
||||
}
|
||||
onShow(() => { profileVisible = true; syncProfile(); stopAccountTimer(); accountTimer = setInterval(() => { sameOwner() }, 1000) })
|
||||
const go = (url: string) => { uni.navigateTo({ url }) }
|
||||
const startAssessment = () => {
|
||||
if (!sameOwner()) return
|
||||
const session = getSession()
|
||||
if (session == null) { go('/packages/account/login/login'); return }
|
||||
const token = session.getString('token') ?? ''
|
||||
const view = generation
|
||||
uni.showActionSheet({ itemList: ['个人评估', '企业评估'], success: (result) => {
|
||||
if (profileVisible && getSession()?.getString('token') == token) go('/packages/finance/entry/entry?type=' + (result.tapIndex == 0 ? 'personal' : 'enterprise'))
|
||||
if (view == generation && sameOwner()) go('/packages/finance/entry/entry?type=' + (result.tapIndex == 0 ? 'personal' : 'enterprise'))
|
||||
} })
|
||||
}
|
||||
const openReport = async () => {
|
||||
if (reportLoading.value) return
|
||||
if (!sameOwner() || reportLoading.value) return
|
||||
const session = getSession()
|
||||
if (session == null) { go('/packages/account/login/login'); return }
|
||||
const token = session.getString('token') ?? ''; const request = ++reportRequest
|
||||
@@ -84,22 +96,29 @@
|
||||
} finally { if (request == reportRequest) reportLoading.value = false }
|
||||
}
|
||||
// 离页或退出后,旧查找结果及弹窗回调不得把用户带到前一个账户的评估。
|
||||
onHide(() => { profileVisible = false; reportRequest++; reportLoading.value = false })
|
||||
onUnload(() => { profileVisible = false; reportRequest++; reportLoading.value = false })
|
||||
const hideProfile = () => { profileVisible = false; generation++; reportRequest++; reportLoading.value = false; stopAccountTimer(); displayName.value = '登录 / 注册'; avatar.value = ''; maskedPhone.value = ''; loggedIn.value = false }
|
||||
onHide(() => hideProfile())
|
||||
onUnload(() => hideProfile())
|
||||
const open = (key: string) => {
|
||||
if (!sameOwner()) return
|
||||
if (key == 'archive') go('/packages/finance/archive/archive')
|
||||
else if (key == 'help') go('/packages/content/help/help')
|
||||
else if (key == 'security') go(loggedIn.value ? '/packages/account/security/security' : '/packages/account/login/login')
|
||||
else if (key == 'privacy') uni.showActionSheet({ itemList: ['查看隐私说明', '管理资料与授权'], success: (result) => { if (result.tapIndex == 0) showAgreementNotice('隐私说明'); else if (result.tapIndex == 1) { go('/packages/finance/archive/archive') } } })
|
||||
else if (key == 'privacy') { const view = generation; uni.showActionSheet({ itemList: ['查看隐私说明', '管理资料与授权'], success: (result) => { if (view != generation || !sameOwner()) return; if (result.tapIndex == 0) showAgreementNotice('隐私说明'); else if (result.tapIndex == 1) { go('/packages/finance/archive/archive') } } }) }
|
||||
else if (key == 'about') uni.showModal({ title: '关于 AI融资', content: '版本 V1.0.0\n帮助您整理融资信息、了解融资知识与融资方案。评估结果仅供参考,不构成授信承诺。', showCancel: false, confirmText: '我知道了' })
|
||||
}
|
||||
const openAccount = () => { go(loggedIn.value ? '/packages/account/edit-profile/edit-profile' : '/packages/account/login/login') }
|
||||
const openAccount = () => { if (sameOwner()) go(loggedIn.value ? '/packages/account/edit-profile/edit-profile' : '/packages/account/login/login') }
|
||||
const logout = () => {
|
||||
if (!sameOwner() || loggingOut.value) return
|
||||
if (!loggedIn.value) { go('/packages/account/login/login'); return }
|
||||
uni.showModal({ title: '退出登录', content: '将清除本机账户缓存和未提交的填写内容,已提交的评估记录不受影响。', confirmText: '退出登录', success: (result) => { if (result.confirm) {
|
||||
logoutAccount().then(() => { uni.reLaunch({ url: '/packages/account/login/login' }) })
|
||||
.catch(() => { uni.showToast({ title: '退出失败,请检查网络后重试', icon: 'none' }) })
|
||||
} }, fail: (error) => { console.error('logout confirmation failed', error); uni.showToast({ title: '暂时无法打开确认框,请重试', icon: 'none' }) } })
|
||||
const view = generation; const token = ownerToken; const userId = ownerId
|
||||
uni.showModal({ title: '退出登录', content: '将清除本机账户缓存和未提交的填写内容,已提交的评估记录不受影响。', confirmText: '退出登录', success: (result) => {
|
||||
if (!result.confirm || view != generation || !sameOwner() || loggingOut.value) return
|
||||
loggingOut.value = true
|
||||
logoutAccount().then(() => { if (profileVisible && view == generation && getSession() == null) uni.reLaunch({ url: '/packages/account/login/login' }) })
|
||||
.catch(() => { if (profileVisible && view == generation && getSession()?.getString('token') == token && getSession()?.getString('userId') == userId) uni.showToast({ title: '退出失败,请检查网络后重试', icon: 'none' }) })
|
||||
.finally(() => { if (view == generation) loggingOut.value = false })
|
||||
}, fail: () => { if (view == generation && sameOwner()) uni.showToast({ title: '暂时无法打开确认框,请重试', icon: 'none' }) } })
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -19,8 +19,20 @@
|
||||
import { getAccountProfile, maskPhone } from '@/packages/account/services/account.uts'
|
||||
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
|
||||
const phone = ref('')
|
||||
onShow(() => { if (getSession() == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }; phone.value = getAccountProfile().phone })
|
||||
const go = (path: string) => { uni.navigateTo({ url: '/packages/account/' + path }) }
|
||||
let ownerToken = ''; let ownerId = ''; let pageVisible = false; let accountTimer = 0
|
||||
const stopAccountTimer = () => { clearInterval(accountTimer); accountTimer = 0 }
|
||||
const sameOwner = (): boolean => {
|
||||
if (!pageVisible) return false
|
||||
const session = getSession()
|
||||
if (ownerToken.length > 0 && session?.getString('token') == ownerToken && session?.getString('userId') == ownerId) return true
|
||||
phone.value = ''; stopAccountTimer()
|
||||
uni.redirectTo({ url: session == null ? '/packages/account/login/login' : '/packages/account/profile/profile' })
|
||||
return false
|
||||
}
|
||||
onShow(() => { pageVisible = true; const session = getSession(); ownerToken = session?.getString('token') ?? ''; ownerId = session?.getString('userId') ?? ''; stopAccountTimer(); if (!sameOwner()) return; phone.value = getAccountProfile().phone; accountTimer = setInterval(() => { sameOwner() }, 1000) })
|
||||
onHide(() => { pageVisible = false; stopAccountTimer(); phone.value = '' })
|
||||
onUnload(() => { pageVisible = false; stopAccountTimer(); phone.value = '' })
|
||||
const go = (path: string) => { if (sameOwner()) uni.navigateTo({ url: '/packages/account/' + path }) }
|
||||
</script>
|
||||
<style>
|
||||
.security-content { padding: 30rpx; }
|
||||
|
||||
@@ -4,6 +4,7 @@ import { draftText } from '@/services/assessment.uts'
|
||||
|
||||
export type AccountProfile = { nickname: string, avatar: string, phone: string }
|
||||
export const DEFAULT_AVATAR = '/packages/account/static/default-avatar.png'
|
||||
let profileWriteVersion = 0
|
||||
const profileKey = (phone: string): string => 'finance.account.profile:' + phone
|
||||
export const maskPhone = (phone: string): string => phone.length == 11 ? phone.substring(0, 3) + '****' + phone.substring(7) : phone
|
||||
export const validPhone = (phone: string): boolean => /^1[3-9][0-9]{9}$/.test(phone)
|
||||
@@ -17,7 +18,7 @@ export function requireAccount(): UTSJSONObject {
|
||||
}
|
||||
export function sameAccount(session: UTSJSONObject): boolean {
|
||||
const current = getSession()
|
||||
return current != null && draftText(current, 'token') == draftText(session, 'token') && draftText(current, 'phone') == draftText(session, 'phone')
|
||||
return current != null && draftText(current, 'token') == draftText(session, 'token') && draftText(current, 'userId') == draftText(session, 'userId') && draftText(current, 'phone') == draftText(session, 'phone')
|
||||
}
|
||||
export function getAccountProfile(): AccountProfile {
|
||||
const session = getSession()
|
||||
@@ -37,19 +38,26 @@ async function accountRequest(path: string, method: RequestMethod, payload: UTSJ
|
||||
requireAccount()
|
||||
return await requestJson(path, method, payload)
|
||||
}
|
||||
function cacheProfile(result: UTSJSONObject, phone: string): AccountProfile {
|
||||
const profile: AccountProfile = { phone, nickname: draftText(result, 'nickname'), avatar: draftText(result, 'avatarUrl') }
|
||||
function cacheProfile(result: UTSJSONObject, session: UTSJSONObject): AccountProfile {
|
||||
const phone = draftText(session, 'phone'); const name = result.getString('nickname')
|
||||
const avatar = result['avatarUrl']
|
||||
if (draftText(result, 'userId') != draftText(session, 'userId') || name == null || name.trim().length < 2 || name.length > 20
|
||||
|| (avatar != null && typeof avatar != 'string') || draftText(result, 'phoneMasked') != maskPhone(phone)) throw new Error('账户资料响应不完整或归属不符,请重试')
|
||||
const profile: AccountProfile = { phone, nickname: name, avatar: draftText(result, 'avatarUrl') }
|
||||
setUserStorage(profileKey(phone), { nickname: profile.nickname, avatar: profile.avatar })
|
||||
return profile
|
||||
}
|
||||
export async function fetchAccountProfile(): Promise<AccountProfile> {
|
||||
const session = requireAccount()
|
||||
const version = profileWriteVersion
|
||||
const result = await accountRequest('/account/profile', 'GET', {})
|
||||
if (!sameAccount(session)) throw new Error('登录状态已变化,请重新进入页面')
|
||||
return cacheProfile(result, draftText(session, 'phone'))
|
||||
// 保存完成前发出的旧读取不能回写昵称或头像缓存。
|
||||
if (version != profileWriteVersion) throw new Error('账户资料已更新,请重新读取')
|
||||
return cacheProfile(result, session)
|
||||
}
|
||||
export async function saveAccountProfile(nickname: string, avatarId: string): Promise<void> {
|
||||
const session = requireAccount(); const phone = draftText(session, 'phone')
|
||||
const session = requireAccount()
|
||||
const name = nickname.trim()
|
||||
if (name.length < 2 || name.length > 20) throw new Error('昵称请输入 2–20 个字符')
|
||||
const payload: UTSJSONObject = { nickname: name }
|
||||
@@ -57,12 +65,16 @@ export async function saveAccountProfile(nickname: string, avatarId: string): Pr
|
||||
else if (avatarId.length > 0) payload['avatarId'] = avatarId
|
||||
const result = await accountRequest('/account/profile', 'PUT', payload)
|
||||
if (!sameAccount(session)) throw new Error('登录状态已变化,请重新进入页面')
|
||||
cacheProfile(result, phone)
|
||||
profileWriteVersion++
|
||||
cacheProfile(result, session)
|
||||
}
|
||||
|
||||
export async function uploadAccountAvatar(path: string): Promise<UTSJSONObject> {
|
||||
requireAccount()
|
||||
return await uploadJson('/account/avatar', path)
|
||||
const session = requireAccount()
|
||||
const result = await uploadJson('/account/avatar', path)
|
||||
if (!sameAccount(session)) throw new Error('登录状态已变化,请重新进入页面')
|
||||
if (!/^[1-9][0-9]{0,9}$/.test(draftText(result, 'attachmentId')) || draftText(result, 'avatarUrl').length == 0) throw new Error('头像上传响应不完整,请重试')
|
||||
return result
|
||||
}
|
||||
export async function requestSecurityCode(purpose: string, newPhone: string): Promise<void> {
|
||||
if (!['password', 'phone-current', 'phone-new'].includes(purpose)) throw new Error('验证码用途无效')
|
||||
@@ -75,12 +87,14 @@ export async function changeAccountPassword(mode: string, oldPassword: string, c
|
||||
if (oldPassword.length == 0) throw new Error('请输入当前密码')
|
||||
if (oldPassword == newPassword) throw new Error('新密码不能与当前密码相同')
|
||||
} else if (mode != 'sms' || !validCode(code)) throw new Error('请输入 6 位数字验证码')
|
||||
await accountRequest('/account/password', 'PUT', { verification: mode, oldPassword: mode == 'password' ? oldPassword : '', code: mode == 'sms' ? code : '', newPassword })
|
||||
const result = await accountRequest('/account/password', 'PUT', { verification: mode, oldPassword: mode == 'password' ? oldPassword : '', code: mode == 'sms' ? code : '', newPassword })
|
||||
if (result['reLoginRequired'] != true) throw new Error('密码修改响应不完整,请重试')
|
||||
}
|
||||
export async function changeAccountPhone(newPhone: string, currentCode: string, newCode: string): Promise<void> {
|
||||
const session = requireAccount()
|
||||
if (!validPhone(newPhone)) throw new Error('请输入正确的新手机号')
|
||||
if (newPhone == draftText(session, 'phone')) throw new Error('新手机号不能与当前手机号相同')
|
||||
if (!validCode(currentCode) || !validCode(newCode)) throw new Error('请输入两组 6 位数字验证码')
|
||||
await accountRequest('/account/phone', 'PUT', { newPhone, currentCode, newCode })
|
||||
const result = await accountRequest('/account/phone', 'PUT', { newPhone, currentCode, newCode })
|
||||
if (result['reLoginRequired'] != true) throw new Error('手机号修改响应不完整,请重试')
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
<script setup lang="uts">
|
||||
import { usePageLayout } from '@/services/layout.uts'
|
||||
import { fetchLessons, FinanceLesson } from '@/services/content.uts'
|
||||
import { fetchLessons, FinanceLesson, validateContentAppend } from '@/services/content.uts'
|
||||
import AppHeader from '@/components/AppHeader.uvue'
|
||||
import AppIcon from '@/components/AppIcon.uvue'
|
||||
const { contentStyle, pageStyle } = usePageLayout()
|
||||
@@ -51,10 +51,11 @@ async function load(append: boolean) {
|
||||
if (!visible || loading.value || loadingMore.value || (append && !hasMore.value)) return
|
||||
const request = append ? generation : ++generation
|
||||
if (append) { loadingMore.value = true; moreError.value = '' }
|
||||
else { loading.value = true; error.value = ''; lessons.value = []; total.value = 0; hasMore.value = false; page = 1 }
|
||||
else { loading.value = true; error.value = ''; moreError.value = ''; lessons.value = []; total.value = 0; hasMore.value = false; page = 1 }
|
||||
try {
|
||||
const result = await fetchLessons(activeTopic.value, append ? page + 1 : 1)
|
||||
if (!visible || request != generation) return
|
||||
if (append) validateContentAppend(lessons.value.map((item: FinanceLesson): string => item.id), result.items.map((item: FinanceLesson): string => item.id))
|
||||
lessons.value = append ? lessons.value.concat(result.items) : result.items
|
||||
total.value = result.total; hasMore.value = result.hasMore
|
||||
if (append) page++
|
||||
|
||||
@@ -1,29 +1,22 @@
|
||||
<template>
|
||||
<view class="page" :style="pageStyle()">
|
||||
<view class="page page-white" :style="pageStyle()">
|
||||
<AppHeader title="课程详情" />
|
||||
<scroll-view scroll-y :scroll-into-view="scrollTarget" :style="contentStyle(96, false)">
|
||||
<scroll-view scroll-y :style="contentStyle(96, false)">
|
||||
<view v-if="loading" class="empty-content"><text class="empty-copy">正在加载课程…</text></view>
|
||||
<view v-else-if="error.length > 0" class="empty-content"><text class="empty-title">{{ error }}</text><button class="primary-button empty-button" @click="reload">重新加载</button></view>
|
||||
<view v-else-if="available" class="lesson-detail-content">
|
||||
<image class="lesson-cover" :src="lesson.image" mode="aspectFill"></image>
|
||||
<text class="lesson-detail-title">{{ lesson.title }}</text>
|
||||
<view class="lesson-detail-meta"><text class="lesson-tag">{{ lesson.tag }}</text><text class="lesson-duration">{{ lesson.duration }} · {{ lesson.chapters.length }} 节图文</text></view>
|
||||
<view class="lesson-detail-meta"><text class="lesson-tag">{{ lesson.tag }}</text><text class="lesson-duration">{{ lesson.duration }}阅读</text></view>
|
||||
<text class="lesson-intro">{{ lesson.intro }}</text>
|
||||
<view class="progress-head"><text class="progress-label">学习进度</text><text class="progress-value">已完成 {{ completedCount }}/{{ lesson.chapters.length }} 节</text></view>
|
||||
<view class="lesson-progress"><view class="progress-fill" :style="'width:' + progress + '%;'"></view></view>
|
||||
<view v-if="progressError.length > 0" class="progress-error"><text>{{ progressError }}</text><text class="progress-retry" @click="reload">刷新课程</text></view>
|
||||
<button class="primary-button start-learning" @click="start">{{ completedCount == 0 ? '开始学习' : (completedCount == lesson.chapters.length ? '回顾课程' : '继续学习') }}</button>
|
||||
<text class="chapter-heading">课程目录</text>
|
||||
<view class="chapter-card" v-for="(item, index) in lesson.chapters" :key="item.id" :id="'chapter-' + index">
|
||||
<view class="chapter-row" @click="toggleChapter(index)">
|
||||
<view :class="completed[index] ? 'chapter-index completed-index' : 'chapter-index'"><text :class="completed[index] ? 'index-text completed-text' : 'index-text'">{{ index + 1 }}</text></view>
|
||||
<view class="chapter-copy"><text class="chapter-title">{{ item.title }}</text><text :class="completed[index] ? 'chapter-state completed-state' : 'chapter-state'">{{ completed[index] ? '已完成 · 可再次阅读' : '点击展开阅读' }}</text></view>
|
||||
<AppIcon name="chevron-right" :size="28" :class="activeIndex == index ? 'chapter-arrow open-arrow' : 'chapter-arrow'" muted />
|
||||
</view>
|
||||
<view v-if="activeIndex == index" class="chapter-content">
|
||||
<text class="chapter-body">{{ item.body }}</text>
|
||||
<button :class="completed[index] ? 'chapter-done chapter-already-done' : 'chapter-done'" :disabled="completed[index] || saving" @click="completeChapter(index)">{{ completed[index] ? '本节已完成' : (saving ? '正在保存…' : (loggedIn ? '我已读完,完成本节' : '登录后记录学习进度')) }}</button>
|
||||
</view>
|
||||
<view v-for="item in lesson.chapters" :key="item.id" class="reading-section">
|
||||
<text class="reading-heading">{{ item.title }}</text>
|
||||
<text class="reading-body">{{ item.body }}</text>
|
||||
</view>
|
||||
<view class="reading-footer">
|
||||
<text class="reading-note">内容仅供信息参考,具体申请条件以相关机构官方说明为准。</text>
|
||||
<view v-if="progressError.length > 0" class="progress-error"><text>{{ progressError }}</text><text class="progress-retry" @click="reload">重新加载</text></view>
|
||||
<button :class="isRead ? 'read-button already-read' : 'read-button'" :disabled="isRead || saving || progressLoading" @click="markRead">{{ isRead ? '已读完' : (saving ? '正在保存…' : (progressLoading ? '正在读取状态…' : (loggedIn ? '我已读完' : '登录后标记已读'))) }}</button>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty-content"><text class="empty-title">暂未找到这门课程</text><text class="empty-copy">返回融资课堂,选择其他课程。</text><button class="primary-button empty-button" @click="goClassroom">返回课堂</button></view>
|
||||
@@ -34,105 +27,97 @@
|
||||
<script setup lang="uts">
|
||||
import { getSession } from '@/services/api.uts'
|
||||
import { usePageLayout } from '@/services/layout.uts'
|
||||
import { fetchLesson, fetchLessonProgress, completeLessonChapter, emptyLesson, FinanceLesson } from '@/services/content.uts'
|
||||
import { fetchLesson, fetchLessonProgress, completeLessonChapter, emptyLesson, FinanceLesson, validateLessonState } from '@/services/content.uts'
|
||||
import AppHeader from '@/components/AppHeader.uvue'
|
||||
import AppIcon from '@/components/AppIcon.uvue'
|
||||
const { contentStyle, pageStyle } = usePageLayout()
|
||||
const lesson = ref<FinanceLesson>(emptyLesson()); const available = ref(false); const loading = ref(false); const error = ref('')
|
||||
const completedIds = ref<string[]>([]); const activeIndex = ref(-1); const scrollTarget = ref('')
|
||||
const progressError = ref(''); const loggedIn = ref(false); const saving = ref(false)
|
||||
let id = ''; let visible = false; let generation = 0
|
||||
const completedIds = ref<string[]>([])
|
||||
const progressError = ref(''); const loggedIn = ref(false); const saving = ref(false); const progressLoading = ref(false)
|
||||
let id = ''; let visible = false; let generation = 0; let owner = ''; let accountTimer = 0
|
||||
let reloadAfterAccountChange: () => void = () => {}
|
||||
const tokenNow = (): string => getSession()?.getString('token') ?? ''
|
||||
const stopAccountTimer = () => { if (accountTimer != 0) clearInterval(accountTimer); accountTimer = 0 }
|
||||
function ensureAccount(): boolean {
|
||||
if (!visible) return false
|
||||
if (tokenNow() == owner) return true
|
||||
// 旧课程页不能用新账户提交完成记录;可见期也及时移除旧账户进度。
|
||||
completedIds.value = []; saving.value = false; reloadAfterAccountChange()
|
||||
return false
|
||||
}
|
||||
function current(request: number, token: string): boolean {
|
||||
if (!visible || request != generation) return false
|
||||
if (tokenNow() == token) return true
|
||||
// 学习内容公开,但完成记录只属于当前账户;切换后立即清除旧进度。
|
||||
generation++; completedIds.value = []; saving.value = false; reloadAfterAccountChange()
|
||||
ensureAccount()
|
||||
return false
|
||||
}
|
||||
async function reload() {
|
||||
if (!visible) return
|
||||
const request = ++generation; const token = tokenNow()
|
||||
owner = token; stopAccountTimer(); accountTimer = setInterval(() => { ensureAccount() }, 1000)
|
||||
loading.value = true; available.value = false; error.value = ''; progressError.value = ''; completedIds.value = []; saving.value = false
|
||||
loggedIn.value = token.length > 0
|
||||
loggedIn.value = token.length > 0; progressLoading.value = false
|
||||
try {
|
||||
const result = await fetchLesson(id)
|
||||
if (!current(request, token)) return
|
||||
lesson.value = result; activeIndex.value = -1; available.value = true
|
||||
lesson.value = result; available.value = true; loading.value = false
|
||||
if (token.length > 0) {
|
||||
progressLoading.value = true
|
||||
try {
|
||||
const state = await fetchLessonProgress(id)
|
||||
if (!current(request, token)) return
|
||||
if (state.lessonVersion != result.lessonVersion) { progressError.value = '课程已更新,请刷新后继续学习'; return }
|
||||
validateLessonState(result, state)
|
||||
completedIds.value = state.completedChapterIds
|
||||
} catch (err) { if (current(request, token)) progressError.value = err instanceof Error ? err.message : '进度加载失败,请重试' }
|
||||
}
|
||||
} catch (err) { if (current(request, token)) error.value = err instanceof Error ? err.message : '课程加载失败,请重试' }
|
||||
finally { if (request == generation) loading.value = false }
|
||||
finally { if (request == generation) { loading.value = false; progressLoading.value = false } }
|
||||
}
|
||||
reloadAfterAccountChange = () => { reload() }
|
||||
const completed = computed((): boolean[] => lesson.value.chapters.map((item): boolean => completedIds.value.includes(item.id)))
|
||||
const completedCount = computed((): number => completedIds.value.length)
|
||||
const progress = computed((): number => lesson.value.chapters.length > 0 ? Math.round(completedCount.value * 100 / lesson.value.chapters.length) : 0)
|
||||
const toggleChapter = (index: number) => { activeIndex.value = activeIndex.value == index ? -1 : index }
|
||||
const completeChapter = async (index: number) => {
|
||||
if (!available.value || saving.value || index < 0 || index >= lesson.value.chapters.length) return
|
||||
const isRead = computed((): boolean => lesson.value.chapters.length > 0 && lesson.value.chapters.every((item): boolean => completedIds.value.includes(item.id)))
|
||||
const markRead = async () => {
|
||||
if (!ensureAccount() || !available.value || loading.value || progressLoading.value || saving.value || isRead.value) return
|
||||
const token = tokenNow()
|
||||
if (token.length == 0) { uni.navigateTo({ url: '/packages/account/login/login?resumeLesson=1&lessonId=' + id }); return }
|
||||
if (progressError.value.length > 0) { uni.showToast({ title: '请先刷新课程和进度', icon: 'none' }); return }
|
||||
if (progressError.value.length > 0) { uni.showToast({ title: '请先重新加载已读状态', icon: 'none' }); return }
|
||||
const request = generation; saving.value = true; progressError.value = ''
|
||||
try {
|
||||
const state = await completeLessonChapter(id, lesson.value.chapters[index].id, lesson.value.lessonVersion)
|
||||
if (current(request, token)) completedIds.value = state.completedChapterIds
|
||||
} catch (err) { if (current(request, token)) progressError.value = err instanceof Error ? err.message : '进度保存失败,请重试' }
|
||||
// 沿用已上线的记录接口;一次操作补齐未完成段落,失败后可从已有记录继续。
|
||||
const content = lesson.value
|
||||
for (let index = 0; index < content.chapters.length; index++) {
|
||||
if (!current(request, token)) return
|
||||
if (completedIds.value.includes(content.chapters[index].id)) continue
|
||||
const state = await completeLessonChapter(id, content.chapters[index].id, content.lessonVersion)
|
||||
if (!current(request, token)) return
|
||||
validateLessonState(content, state); completedIds.value = state.completedChapterIds
|
||||
}
|
||||
if (!isRead.value) throw new Error('已读状态保存不完整,请重新加载后重试')
|
||||
} catch (err) { if (current(request, token)) progressError.value = err instanceof Error ? err.message : '已读状态保存失败,请重试' }
|
||||
finally { if (request == generation) saving.value = false }
|
||||
}
|
||||
const start = () => {
|
||||
let nextIndex = completed.value.findIndex((value: boolean): boolean => !value)
|
||||
if (nextIndex < 0) nextIndex = 0
|
||||
activeIndex.value = nextIndex; scrollTarget.value = ''
|
||||
nextTick(() => { scrollTarget.value = 'chapter-' + nextIndex })
|
||||
}
|
||||
const goClassroom = () => { uni.redirectTo({ url: '/packages/content/classroom/classroom' }) }
|
||||
onLoad((options) => { id = options?.['id'] ?? '' })
|
||||
onShow(() => { visible = true; reload() })
|
||||
onHide(() => { visible = false; generation++; completedIds.value = []; saving.value = false })
|
||||
onUnload(() => { visible = false; generation++; completedIds.value = [] })
|
||||
onHide(() => { visible = false; stopAccountTimer(); generation++; completedIds.value = []; saving.value = false; progressLoading.value = false })
|
||||
onUnload(() => { visible = false; stopAccountTimer(); generation++; completedIds.value = []; saving.value = false; progressLoading.value = false })
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.progress-error { color: #b64d35; font-size: 24rpx; line-height: 40rpx; margin-top: 16rpx; }
|
||||
.progress-retry { color: #0868f7; padding: 14rpx 0; }
|
||||
.lesson-detail-content { padding: 24rpx 28rpx 48rpx; }
|
||||
.lesson-detail-content { padding: 24rpx 30rpx 64rpx; }
|
||||
.lesson-cover { width: 100%; height: 260rpx; border-radius: 18rpx; }
|
||||
.lesson-detail-title { color: #172640; font-size: 36rpx; line-height: 50rpx; font-weight: 700; margin-top: 24rpx; }
|
||||
.lesson-detail-meta { flex-direction: row; justify-content: space-between; align-items: center; margin-top: 14rpx; }
|
||||
.lesson-tag { color: #0868f7; font-size: 24rpx; }
|
||||
.lesson-duration { color: #66758a; font-size: 23rpx; }
|
||||
.lesson-intro { color: #66758a; font-size: 26rpx; line-height: 44rpx; margin-top: 22rpx; }
|
||||
.progress-head { flex-direction: row; align-items: center; justify-content: space-between; margin-top: 26rpx; }
|
||||
.progress-label { color: #172640; font-size: 24rpx; font-weight: 600; }
|
||||
.progress-value { color: #0868f7; font-size: 23rpx; }
|
||||
.lesson-progress { height: 10rpx; margin-top: 12rpx; border-radius: 6rpx; background-color: #e1eafa; overflow: hidden; }
|
||||
.progress-fill { height: 100%; border-radius: 6rpx; background-color: #0868f7; }
|
||||
.start-learning { margin-top: 24rpx; }
|
||||
.chapter-heading { color: #172640; font-size: 30rpx; font-weight: 700; margin-top: 32rpx; margin-bottom: 14rpx; }
|
||||
.chapter-card { border: 1rpx solid #e5ecf5; background-color: #ffffff; border-radius: 16rpx; margin-bottom: 14rpx; overflow: hidden; }
|
||||
.chapter-row { min-height: 116rpx; padding: 20rpx; flex-direction: row; align-items: center; }
|
||||
.chapter-index { width: 48rpx; height: 48rpx; border-radius: 14rpx; background-color: #eaf2ff; align-items: center; justify-content: center; margin-right: 16rpx; flex-shrink: 0; }
|
||||
.index-text { color: #0868f7; font-size: 24rpx; font-weight: 700; }
|
||||
.completed-index { background-color: #eaf7ef; }
|
||||
.completed-text { color: #168454; }
|
||||
.chapter-copy { flex: 1; padding-right: 8rpx; }
|
||||
.chapter-title { color: #172640; font-size: 28rpx; line-height: 40rpx; font-weight: 600; }
|
||||
.chapter-state { color: #66758a; font-size: 22rpx; margin-top: 6rpx; }
|
||||
.completed-state { color: #168454; }
|
||||
.chapter-arrow { transform: rotate(90deg); }
|
||||
.open-arrow { transform: rotate(-90deg); }
|
||||
.chapter-content { padding: 0 22rpx 24rpx; }
|
||||
.chapter-body { color: #4d5e75; font-size: 27rpx; line-height: 48rpx; border-top: 1rpx solid #e8eef6; padding-top: 22rpx; }
|
||||
.chapter-done { margin-top: 24rpx; width: 100%; height: 80rpx; line-height: 80rpx; border-radius: 12rpx; background-color: #eaf2ff; color: #0868f7; font-size: 26rpx; font-weight: 600; }
|
||||
.chapter-already-done { color: #168454; background-color: #edf8f1; }
|
||||
.reading-section { margin-top: 32rpx; }
|
||||
.reading-heading { color: #172640; font-size: 30rpx; line-height: 46rpx; font-weight: 600; }
|
||||
.reading-body { color: #4d5e75; font-size: 28rpx; line-height: 52rpx; margin-top: 12rpx; }
|
||||
.reading-footer { border-top: 1rpx solid #e8eef6; padding-top: 24rpx; margin-top: 40rpx; }
|
||||
.reading-note { color: #66758a; font-size: 22rpx; line-height: 36rpx; }
|
||||
.read-button { margin-top: 28rpx; width: 100%; height: 88rpx; line-height: 88rpx; border-radius: 12rpx; background-color: #eaf2ff; color: #0868f7; font-size: 28rpx; font-weight: 600; }
|
||||
.already-read { color: #168454; background-color: #edf8f1; }
|
||||
.empty-content { padding: 100rpx 40rpx; align-items: center; }
|
||||
.empty-title { color: #172640; font-size: 32rpx; font-weight: 700; }
|
||||
.empty-copy { color: #66758a; font-size: 26rpx; margin-top: 20rpx; }
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
<script setup lang="uts">
|
||||
import { usePageLayout } from '@/services/layout.uts'
|
||||
import { fetchArticles, emptyArticle, NewsArticle } from '@/services/content.uts'
|
||||
import { fetchArticles, emptyArticle, NewsArticle, validateContentAppend } from '@/services/content.uts'
|
||||
import AppHeader from '@/components/AppHeader.uvue'
|
||||
import BottomNav from '@/components/BottomNav.uvue'
|
||||
const { contentStyle, pageStyle } = usePageLayout()
|
||||
@@ -44,11 +44,12 @@ async function load(append: boolean) {
|
||||
if (!visible || loading.value || loadingMore.value || (append && !hasMore.value)) return
|
||||
const request = append ? generation : ++generation
|
||||
if (append) { loadingMore.value = true; moreError.value = '' }
|
||||
else { loading.value = true; error.value = ''; articles.value = []; total.value = 0; hasMore.value = false; page = 1 }
|
||||
else { loading.value = true; error.value = ''; moreError.value = ''; articles.value = []; total.value = 0; hasMore.value = false; page = 1 }
|
||||
try {
|
||||
const result = await fetchArticles(tab.value == '推荐' ? '' : tab.value, append ? page + 1 : 1)
|
||||
// 换分类或离页后,旧列表响应不能覆盖新选择。
|
||||
if (!visible || request != generation) return
|
||||
if (append) validateContentAppend(articles.value.map((item: NewsArticle): string => item.id), result.items.map((item: NewsArticle): string => item.id))
|
||||
articles.value = append ? articles.value.concat(result.items) : result.items
|
||||
total.value = result.total; hasMore.value = result.hasMore
|
||||
if (append) page++
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
import AppHeader from '@/components/AppHeader.uvue'
|
||||
import { usePageLayout } from '@/services/layout.uts'
|
||||
import { searchContent, ContentSearchResult } from '@/packages/content/services/search.uts'
|
||||
import { validateContentAppend } from '@/services/content.uts'
|
||||
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
|
||||
type SearchTab = { id: string, label: string }
|
||||
const tabs: SearchTab[] = [{ id: 'all', label: '全部' }, { id: 'news', label: '新闻' }, { id: 'lesson', label: '融资课堂' }]
|
||||
@@ -53,9 +54,11 @@
|
||||
const total = ref(0); const hasMore = ref(false); const loadingMore = ref(false); const moreError = ref('')
|
||||
let page = 1
|
||||
let requestVersion = 0
|
||||
let visible = false
|
||||
const resetResults = () => { requestVersion++; results.value = []; total.value = 0; hasMore.value = false; loadingMore.value = false; moreError.value = ''; searched.value = false; loading.value = false; errorMessage.value = ''; listVersion.value++ }
|
||||
const clearSearch = () => { keyword.value = ''; resetResults() }
|
||||
async function load(append: boolean) {
|
||||
if (!visible) return
|
||||
if (append && (loading.value || loadingMore.value || !hasMore.value)) return
|
||||
keyword.value = keyword.value.trim()
|
||||
if (keyword.value.length == 0) { clearSearch(); return }
|
||||
@@ -66,12 +69,13 @@
|
||||
try {
|
||||
const result = await searchContent(keyword.value, activeType.value, append ? page + 1 : 1)
|
||||
// 输入、切换分类或退出页面后,忽略旧请求,避免结果被覆盖。
|
||||
if (currentRequest != requestVersion) return
|
||||
if (!visible || currentRequest != requestVersion) return
|
||||
if (append) validateContentAppend(results.value.map((item: ContentSearchResult): string => item.type + ':' + item.id), result.items.map((item: ContentSearchResult): string => item.type + ':' + item.id))
|
||||
results.value = append ? results.value.concat(result.items) : result.items
|
||||
total.value = result.total; hasMore.value = result.hasMore
|
||||
if (append) page++
|
||||
} catch (err) {
|
||||
if (currentRequest == requestVersion) {
|
||||
if (visible && currentRequest == requestVersion) {
|
||||
const message = err instanceof Error ? err.message : '请检查网络连接后重试'
|
||||
if (append) moreError.value = message; else errorMessage.value = message
|
||||
}
|
||||
@@ -85,9 +89,9 @@
|
||||
const route = item.type == 'news' ? '/packages/content/article/article' : '/packages/content/lesson/lesson'
|
||||
uni.navigateTo({ url: route + '?id=' + encodeURIComponent(item.id) })
|
||||
}
|
||||
onShow(() => { if (searched.value) search() })
|
||||
onHide(() => { requestVersion++; loading.value = false; loadingMore.value = false })
|
||||
onUnload(() => { requestVersion++ })
|
||||
onShow(() => { visible = true; if (searched.value) search() })
|
||||
onHide(() => { visible = false; requestVersion++; loading.value = false; loadingMore.value = false })
|
||||
onUnload(() => { visible = false; requestVersion++ })
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -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