Files
airz-uniapp/packages/finance/product/product.uvue
T
hujun 71b3c25695 feat: 优化账户切换时的页面状态一致性,增加登录回跳校验和课程阅读新逻辑
- 增加账户状态监听定时器,页面切换时自动校验账户归属,避免跨账户操作
- 新增登录回跳路径校验函数,限制仅允许指定页面回跳,防止非法跳转
- 课程页改为整篇连续阅读,底部仅一次标记已读,兼容历史记录
- 完善助手、对比页、资料填写页等多处的账户状态校验逻辑,防止错版数据提交
- 优化账户资料请求逻辑,增加版本校验,避免旧请求覆盖新数据
2026-10-07 10:01:05 +08:00

288 lines
29 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page product-page" :style="pageStyle()">
<AppHeader :title="assessmentId.length > 0 ? '银行匹配详情' : '产品详情'" :customBack="true" @back="back" />
<scroll-view scroll-y :style="contentStyle(96, false)">
<view v-if="pageActive" class="product-content">
<text v-if="loading" class="muted loading">正在读取产品资料…</text>
<view v-if="error.length > 0" class="error-panel"><text class="heading">暂时无法读取</text><text class="body">{{ error }}</text><button class="secondary" @click="load">重新加载</button><text class="link" @click="back">返回选择产品</text></view>
<template v-if="product != null">
<view class="bank-header">
<view class="bank-mark"><image class="bank-icon" src="/static/mobile/icons-generated/building.png" mode="aspectFit" /></view>
<view class="bank-info">
<view class="bank-title-row"><text class="bank-name">{{ product!.bank }}</text><text v-if="match != null" :class="match!.state == 'eligible' ? 'badge good' : (match!.state == 'ineligible' ? 'badge blocked' : 'badge pending')">{{ match!.state == 'eligible' ? '可供参考' : (match!.state == 'ineligible' ? '暂不推荐' : '待核实') }}</text></view>
<text v-if="match != null" :class="match!.state == 'eligible' ? 'state good-text' : 'state attention-text'">{{ match!.stateLabel }}</text>
<text class="product-name">{{ product!.name }}</text>
</view>
</view>
<view class="tabs">
<view v-if="assessmentId.length > 0" :class="tab == 'explanation' ? 'tab active' : 'tab'" @click="switchTab('explanation')"><text :class="tab == 'explanation' ? 'tab-label selected' : 'tab-label'">AI解释</text></view>
<view :class="tab == 'details' ? 'tab active' : 'tab'" @click="switchTab('details')"><text :class="tab == 'details' ? 'tab-label selected' : 'tab-label'">产品详情</text></view>
<view :class="tab == 'conditions' ? 'tab active' : 'tab'" @click="switchTab('conditions')"><text :class="tab == 'conditions' ? 'tab-label selected' : 'tab-label'">申请条件</text></view>
</view>
<template v-if="tab == 'explanation' && match != null">
<view class="reason-section">
<text class="heading good-text">{{ match!.state == 'eligible' ? '推荐依据(' + match!.positive.length + '条已满足条件)' : '已满足的条件(' + match!.positive.length + '条)' }}</text>
<text v-if="match!.positive.length == 0" class="empty-note">暂无已确认满足的条件,不补充未经验证的推荐理由。</text>
<view v-for="(reason, index) in positiveVisible" :key="reason.id" class="reason-row">
<text class="number green-number">{{ index + 1 }}</text>
<view class="reason-content"><text class="reason-title">{{ reasonTitle(reason) }}</text><text class="reason-detail">{{ reason.explanation.length > 0 ? reason.explanation : reason.evidence.join(';') }}</text><text class="evidence-toggle" @click="openReason = openReason == reason.id ? '' : reason.id">{{ openReason == reason.id ? '收起依据' : '查看依据' }}</text><view v-if="openReason == reason.id" class="evidence-box"><text v-for="line in reason.evidence" :key="line" class="evidence">{{ line }}</text></view></view>
</view>
<text v-if="match!.positive.length > 3" class="expand" @click="expanded = !expanded">{{ expanded ? '收起' : '展开其余' + (match!.positive.length - 3) + '条依据' }}</text>
</view>
<view class="reason-section attention-section">
<text class="heading attention-text">不推荐/需注意的点({{ attention.length }}条)</text>
<text v-if="attention.length == 0" class="empty-note">已知规则中暂无不符或待核实项,仍需核验资料,不代表审批结果。</text>
<view v-for="(reason, index) in attentionVisible" :key="reason.id" class="reason-row">
<text class="number orange-number">{{ index + 1 }}</text>
<view class="reason-content"><text class="reason-title">{{ reasonTitle(reason) }}</text><text class="reason-detail">{{ reason.explanation.length > 0 ? reason.explanation : reason.evidence.join(';') }}</text><text class="evidence-toggle" @click="openReason = openReason == reason.id ? '' : reason.id">{{ openReason == reason.id ? '收起依据' : '查看依据' }}</text><view v-if="openReason == reason.id" class="evidence-box"><text v-for="line in reason.evidence" :key="line" class="evidence">{{ line }}</text></view></view>
</view>
<text v-if="attention.length > 3" class="expand" @click="attentionExpanded = !attentionExpanded">{{ attentionExpanded ? '收起' : '展开其余' + (attention.length - 3) + '条注意事项' }}</text>
</view>
<view class="ai-panel">
<text class="link" @click="consult">我有疑问,咨询AI ›</text>
<text class="ai-title">AI详细解释</text>
<text v-if="match!.aiStatus == 'completed'" class="ai-note">已将逐条解释补充在上方,生成结果已保存,重新打开无需再次生成。</text>
<text v-else-if="aiPending" class="ai-note">{{ pollStopped ? '生成仍在进行,可稍后查看状态。' : '正在解释本次匹配依据,请稍候。离开页面后任务仍会继续。' }}</text>
<text v-else-if="match!.aiStatus == 'failed'" class="ai-note">解释生成未完成,规则依据不受影响。{{ match!.aiCanRetry ? '可以重新尝试。' : '本次已达到重试上限。' }}</text>
<text v-else class="ai-note">当前为规则依据。可让AI逐条说明条件含义和资料核对建议。</text>
<text v-if="aiError.length > 0" class="attention-text ai-note">{{ aiError }}</text>
<text v-if="!match!.aiCanRequest" class="ai-note">历史版本保留已保存的规则与解释。</text>
<button v-if="match!.aiCanRequest && !aiPending && match!.aiStatus != 'completed' && (match!.aiStatus != 'failed' || match!.aiCanRetry)" class="ai-button" :disabled="aiBusy" @click="generateExplanation">{{ aiBusy ? '正在提交…' : (match!.aiStatus == 'failed' ? '重试AI解释' : '生成AI详细解释') }}</button>
<button v-if="aiPending && (aiError.length > 0 || pollStopped)" class="ai-button" :disabled="aiBusy" @click="checkExplanation">重新查询状态</button>
</view>
</template>
<template v-if="tab == 'details'">
<view class="details-section"><text class="heading">产品信息</text>
<view class="row"><text class="label">额度范围</text><text class="value emphasis">{{ product!.amount }}</text></view>
<view class="row"><text class="label">年化利率</text><text class="value">{{ product!.rate }}</text></view>
<view class="row"><text class="label">贷款期限</text><text class="value">{{ product!.term }}</text></view>
<view class="row"><text class="label">适用主体</text><text class="value">{{ product!.objects }}</text></view>
<view class="row"><text class="label">适用地区</text><text class="value">{{ product!.regions }}</text></view>
<view class="row"><text class="label">有效期</text><text class="value">{{ product!.valid }}</text></view>
<view class="row"><text class="label">还款方式</text><text class="value">未提供</text></view>
<view class="row"><text class="label">办理渠道</text><text class="value">未提供</text></view>
<view class="row"><text class="label">审批时长</text><text class="value">未提供</text></view>
</view>
<view class="details-section"><text class="heading">资料来源</text><text class="body">{{ product!.source }}</text><text class="evidence">引用:{{ product!.sourceRef }}</text><text class="evidence">核对日期:{{ product!.checkedOn }}</text></view>
</template>
<template v-if="tab == 'conditions'">
<view class="details-section"><text class="heading">产品准入条件</text><text class="muted">公开条件不代表个人已符合,缺少核验依据时仍需核实。</text><text v-for="(condition, index) in product!.conditions" :key="index" class="condition">{{ condition }}</text></view>
<view class="details-section"><text class="heading">资料准备说明</text><text class="body">{{ product!.materials }}</text><text class="muted">仅提供材料引导,不向机构提交申请。实际条件和材料须向机构核实。</text></view>
</template>
<view class="footnotes"><text class="record-note">{{ assessmentId.length > 0 ? '本次评估 #' + assessmentId : '公共产品资料' }} · 产品版本 v{{ product!.version }}</text><text v-if="match != null" class="record-note">{{ match!.availabilityLabel }},历史参数与结论保留。</text><text v-if="match != null && !canPrepare" class="record-note">{{ match!.state != 'eligible' ? '请先核对不符条件或待核实项。' : '当前产品不可用或状态未知,暂不提供准备入口。' }}</text><text class="record-note">自填信息未经核验;本页面不承诺额度、放款或获批。</text><text v-if="assessmentId.length > 0" class="link" @click="openCompare">查看同类产品对比</text><text v-if="assessmentId.length > 0" class="link" @click="toMatches">返回本次匹配</text><text v-else class="link" @click="history">选择评估查看匹配结果</text></view>
</template>
</view>
</scroll-view>
<view v-if="pageActive && product != null" class="action-bar" :style="bottomStyle(22)">
<template v-if="match != null && canPrepare"><button class="footer-button secondary" @click="openCompare">查看同类产品对比</button><button class="footer-button primary" @click="prepare">准备申请</button></template>
<template v-else-if="match != null"><button class="footer-button secondary" @click="switchTab(tab == 'details' ? 'explanation' : 'details')">{{ tab == 'details' ? '查看匹配解释' : '查看产品详情' }}</button><button class="footer-button primary" @click="consult">我有疑问,咨询AI</button></template>
<button v-else class="footer-button primary" @click="switchTab(tab == 'conditions' ? 'details' : 'conditions')">{{ tab == 'conditions' ? '查看产品详情' : '查看申请条件' }}</button>
</view>
</view>
</template>
<script setup lang="uts">
import AppHeader from '@/components/AppHeader.uvue'
import { usePageLayout } from '@/services/layout.uts'
import { getSession } from '@/services/api.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')
const selectedIds = ref<string[]>([]); let from = ''
const product = ref<ProductView | null>(null); const match = ref<MatchView | null>(null)
const loading = ref(false); const error = ref(''); const expanded = ref(false)
const pageActive = ref(false); const attentionExpanded = ref(false); const openReason = ref('')
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))
const aiPending = computed((): boolean => match.value != null && ['queued','running'].includes(match.value!.aiStatus))
const canPrepare = computed((): boolean => match.value != null && match.value!.state == 'eligible' && match.value!.availability == 'available')
// 缩短重复的规则模板标题;核验限制仍保留在逐条依据和页尾,不改判定含义。
const reasonTitle = (reason: MatchReason): string => reason.text.replace('(资料尚需核验)。', '').replace('本产品已配置条件', '产品条件')
function stopPoll(): void { if (timer != 0) clearTimeout(timer); timer = 0 }
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 (current(request, token)) aiError.value = message
}
let pollAction: () => void = () => {}
function schedulePoll(): void {
stopPoll()
if (!visible || !aiPending.value) return
if (++pollCount > 60) { pollStopped.value = true; return }
timer = setTimeout(() => { timer = 0; pollAction() }, 3000)
}
async function refreshExplanation(): Promise<void> {
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)
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 (!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
try {
const result = await requestProductExplanation(assessmentId.value, productId.value, requestKey, revisionId.value)
if (current(request, token)) { match.value = result; requestKey = ''; schedulePoll() }
} catch (err) { explanationError(request, token, err instanceof Error ? err.message : 'AI解释请求失败,请重试')
} finally { if (current(request, token)) aiBusy.value = false }
}
const load = async () => {
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) { 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; 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 (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 = () => { 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 (!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 (ready() && canPrepare.value) uni.navigateTo({ url: '/packages/finance/next-step/next-step?assessmentId=' + assessmentId.value + (revisionId.value.length > 0 ? '&revisionId=' + revisionId.value : '') + '&productId=' + productId.value + '&section=prepare' }) }
const back = () => { if (owner.length > 0 && !current(generation, owner)) return; if (getCurrentPages().length > 1) uni.navigateBack(); else if (from == 'compare') uni.redirectTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value) }); else toMatches() }
const openCompare = () => {
if (!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)
}
saveCompareSelection(assessmentId.value, selectedIds.value, revisionId.value)
uni.navigateTo({ url: '/packages/finance/compare/compare' + compareQuery(assessmentId.value, selectedIds.value, revisionId.value) })
}
onLoad((options) => {
revisionId.value = options?.['revisionId'] ?? '';
if (revisionId.value.length > 0 && !validRecordId(revisionId.value)) routeError = '版本编号无效,请返回重新选择。'
assessmentId.value = options?.['assessmentId'] ?? ''; const oldId = options?.['id'] ?? ''
productId.value = options?.['productId'] ?? oldId
if (options?.['assessmentId'] != null && !validRecordId(assessmentId.value)) routeError = '评估编号无效,请从报告重新进入。'
if (!validRecordId(productId.value) || (oldId.length > 0 && oldId != productId.value)) routeError = '产品编号无效或冲突,请重新选择。'
tab.value = options?.['tab'] ?? (assessmentId.value.length > 0 ? 'explanation' : 'details')
if (!['explanation','details','conditions'].includes(tab.value) || (assessmentId.value.length == 0 && tab.value == 'explanation')) routeError = '页面栏目参数无效,请重新选择。'
from = options?.['from'] ?? ''
if (!['','matches','product','compare','report','next-step','archive'].includes(from)) routeError = '页面来源参数无效,请重新选择。'
if (options?.['productIds'] != null) {
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(); 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; }
.product-content { padding: 18rpx 32rpx 200rpx; }
.loading { padding: 36rpx 0; }
.bank-header { flex-direction: row; align-items: center; padding: 20rpx 0 10rpx; }
.bank-mark { width: 108rpx; height: 108rpx; border-radius: 54rpx; background-color: #edf5ff; align-items: center; justify-content: center; margin-right: 20rpx; flex-shrink: 0; }
.bank-icon { width: 86rpx; height: 86rpx; }
.bank-info { flex: 1; }
.bank-title-row { flex-direction: row; flex-wrap: wrap; align-items: center; }
.bank-name { color: #16253c; font-size: 33rpx; font-weight: 700; line-height: 48rpx; margin-right: 14rpx; }
.badge { font-size: 21rpx; line-height: 32rpx; padding: 3rpx 12rpx; border-radius: 6rpx; }
.good { color: #249a76; background-color: #e9f8f1; }
.blocked { color: #697387; background-color: #f0f2f6; }
.pending { color: #ac802d; background-color: #fff7e5; }
.state { font-size: 24rpx; line-height: 36rpx; margin-top: 5rpx; }
.product-name { color: #788294; font-size: 24rpx; line-height: 36rpx; margin-top: 3rpx; }
.demo { color: #8d96a4; font-size: 21rpx; line-height: 32rpx; margin-top: 10rpx; }
.tabs { flex-direction: row; border-bottom: 1rpx solid #e8edf4; margin-top: 18rpx; }
.tab { flex: 1; align-items: center; padding: 22rpx 0; }
.active { border-bottom: 4rpx solid #0868f7; }
.tab-label { font-size: 26rpx; color: #687588; }
.selected { color: #0868f7; font-weight: 700; }
.reason-section { margin-top: 28rpx; }
.attention-section { margin-top: 30rpx; }
.heading { font-size: 28rpx; color: #243752; font-weight: 700; line-height: 42rpx; }
.good-text { color: #259d79; }
.attention-text { color: #e37b55; }
.reason-row { flex-direction: row; align-items: flex-start; margin-top: 22rpx; }
.number { width: 42rpx; height: 42rpx; border-radius: 21rpx; color: #fff; font-size: 25rpx; line-height: 42rpx; text-align: center; flex-shrink: 0; margin-right: 16rpx; margin-top: 2rpx; }
.green-number { background-color: #35b38c; }
.orange-number { background-color: #f49162; }
.reason-content { flex: 1; }
.reason-title { color: #27364c; font-size: 28rpx; line-height: 42rpx; font-weight: 600; }
.reason-detail { color: #7b8594; font-size: 25rpx; line-height: 39rpx; margin-top: 4rpx; }
.evidence-toggle { color: #8596ad; font-size: 21rpx; padding: 8rpx 0; }
.evidence-box { background-color: #f5f8fc; border-radius: 8rpx; padding: 8rpx 14rpx 14rpx; margin-top: 4rpx; }
.evidence { color: #7f8b9b; font-size: 22rpx; line-height: 36rpx; margin-top: 8rpx; }
.expand { color: #4784c9; font-size: 23rpx; padding: 12rpx 0 0 58rpx; }
.empty-note { color: #8c97a6; font-size: 23rpx; line-height: 38rpx; margin-top: 16rpx; }
.ai-panel { background-color: #f0f6ff; border-radius: 12rpx; padding: 24rpx; margin-top: 34rpx; }
.ai-title { color: #316aac; font-size: 25rpx; font-weight: 600; }
.ai-note { color: #7890ab; font-size: 23rpx; line-height: 38rpx; margin-top: 10rpx; }
.ai-button { font-size: 24rpx; color: #0868f7; background-color: #fff; border: 1rpx solid #d6e6fb; border-radius: 10rpx; margin-top: 20rpx; }
.details-section { padding-top: 30rpx; }
.row { flex-direction: row; border-bottom: 1rpx solid #f0f3f7; padding: 23rpx 0; }
.label { color: #8190a3; width: 146rpx; font-size: 24rpx; line-height: 38rpx; }
.value { flex: 1; text-align: right; color: #35465f; font-size: 25rpx; line-height: 38rpx; }
.emphasis { color: #24578c; font-weight: 700; }
.body { color: #53657e; font-size: 25rpx; line-height: 42rpx; margin-top: 18rpx; }
.muted { color: #8694a7; font-size: 23rpx; line-height: 38rpx; margin-top: 12rpx; }
.condition { padding: 20rpx 0; border-bottom: 1rpx solid #edf1f7; font-size: 25rpx; color: #435b77; line-height: 42rpx; }
.footnotes { padding: 26rpx 0 0; margin-top: 28rpx; border-top: 1rpx solid #edf1f6; }
.record-note { color: #939dac; font-size: 21rpx; line-height: 36rpx; }
.link { font-size: 24rpx; color: #0868f7; padding: 22rpx 0; }
.error-panel { padding: 20rpx 0; }
.action-bar { position: fixed; bottom: 0; left: 0; right: 0; padding: 18rpx 32rpx 22rpx; flex-direction: row; background-color: #fff; border-top: 1rpx solid #edf1f7; }
.footer-button { flex: 1; height: 80rpx; line-height: 80rpx; padding: 0 10rpx; font-size: 25rpx; border-radius: 12rpx; margin: 0; }
.primary { background-color: #0868f7; color: #fff; }
.secondary { background-color: #edf4ff; color: #1769ca; font-size: 25rpx; border-radius: 12rpx; }
.action-bar .secondary { margin-right: 20rpx; }
/* #ifndef APP */
@media (min-width: 768px) { .product-content { max-width: 760px; margin: 0 auto; } .action-bar { max-width: 760px; margin: 0 auto; } }
/* #endif */
</style>