Files
airz-uniapp/packages/finance/product/product.uvue
T
hujun c95482a0f3 feat: 实现协议全文与同意记录功能
新增协议全文后台管理、版本化发布及本人同意记录功能,支持可撤销授权;完善资料与授权相关边界提示,调整评估提交时的资料与授权校验逻辑,同步更新相关页面文案与路由。
2026-10-14 14:53:13 +08:00

298 lines
31 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="product!.logo.length > 0 ? product!.logo : '/static/mobile/icons-generated/building.png'" @error="product!.logo = ''" mode="aspectFit" /></view>
<view class="bank-info">
<view class="bank-title-row"><text v-if="product!.bank.length > 0" 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!.score != null ? '匹配度 ' + match!.score!.toString() + '%' : '可供参考') : (match!.state == 'ineligible' ? '暂不推荐' : match!.stateLabel) }}</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 v-if="hasProductValue(product!.amount) || hasProductValue(product!.rate)" class="product-summary"><text v-if="hasProductValue(product!.amount)" class="summary-tag">额度范围 {{ product!.amount }}</text><text v-if="hasProductValue(product!.rate)" class="summary-tag">年化利率 {{ product!.rate }}</text></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 v-if="hasProductValue(product!.amount)" class="row"><text class="label">额度范围</text><text class="value emphasis">{{ product!.amount }}</text></view>
<view v-if="hasProductValue(product!.rate)" class="row"><text class="label">年化利率</text><text class="value">{{ product!.rate }}</text></view>
<view v-if="hasProductValue(product!.term)" 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 v-if="hasProductValue(product!.repayment)" class="row"><text class="label">还款方式</text><text class="value">{{ product!.repayment }}</text></view>
<view v-if="hasProductValue(product!.channel)" class="row"><text class="label">办理渠道</text><text class="value">{{ product!.channel }}</text></view>
<view v-if="hasProductValue(product!.approval)" class="row"><text class="label">审批时长</text><text class="value">{{ product!.approval }}</text></view>
</view>
<view v-if="hasProductValue(product!.source) || hasProductValue(product!.sourceRef) || hasProductValue(product!.checkedOn)" class="details-section"><text class="heading">资料来源</text><text v-if="hasProductValue(product!.source)" class="body">{{ product!.source }}</text><text v-if="hasProductValue(product!.sourceRef)" class="evidence">引用:{{ product!.sourceRef }}</text><text v-if="hasProductValue(product!.checkedOn)" 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><view v-for="(material, index) in product!.materialList" :key="index" class="material-item"><text class="condition">{{ material.name }} · {{ material.required ? '必需' : '补充' }} · {{ material.objects }}</text><text v-if="material.note.length > 0" class="evidence">{{ material.note }}</text></view><text v-if="product!.materialList.length == 0 || product!.materials != '未提供'" 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 v-if="match != null && match!.score != null" class="record-note">匹配度表示条件匹配程度,不代表获批概率。</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="consultAdvisor">咨询顾问</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, hasProductValue } 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 consultAdvisor = () => {
if (!ready() || product.value == null) return
if (match.value != null && match.value!.availability != 'available') { uni.showToast({ title: '产品当前不可用,请联系平台核对。', icon: 'none' }); return }
const phone = product.value!.consultPhone
if (phone.length == 0) { uni.showToast({ title: '暂未配置顾问电话,请联系平台。', icon: 'none' }); return }
uni.makePhoneCall({ phoneNumber: phone })
}
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; }
.product-summary { flex-direction: row; flex-wrap: wrap; margin-top: 12rpx; }
.summary-tag { color: #1670ed; background-color: #edf4ff; font-size: 23rpx; line-height: 38rpx; padding: 4rpx 12rpx; border-radius: 6rpx; margin-right: 10rpx; margin-top: 6rpx; }
.material-item { margin-top: 12rpx; }
.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>