Files
airz-uniapp/packages/finance/product/product.uvue
hujun 2c9d3feab5 feat: 完善登录流程、UI适配及协议文档更新
本次提交包含以下修改:
1. 新增微信登录相关服务及页面逻辑,实现首次微信绑定手机号流程
2. 优化产品匹配卡片 UI,移除不必要状态显示
3. 调整首页 scroll-view 属性及 banner 背景样式
4. 完善积分规则校验逻辑,增加时区及策略格式验证
5. 更新协议草案文档,补充资料清单及功能说明
6. 修复分析结果路由路径,适配新的匹配结果页面
7. 调整品牌 Logo 安卓端字体对齐样式
2026-10-22 11:24:15 +08:00

298 lines
30 KiB
Plaintext
Raw Permalink 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 && matchPercentage(match!.state, match!.score).length > 0" :class="match!.state == 'eligible' ? 'badge good' : (match!.state == 'ineligible' ? 'badge blocked' : 'badge pending')">{{ '匹配度 ' + matchPercentage(match!.state, match!.score) }}</text></view>
<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 v-if="reason.explanation.length > 0" class="reason-detail">{{ reason.explanation }}</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 v-if="reason.explanation.length > 0" class="reason-detail">{{ reason.explanation }}</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>
</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 as productHasValue, matchPercentage as productMatchPercentage } from '@/packages/finance/services/matches.uts'
const hasProductValue = (value: string): boolean => productHasValue(value)
const matchPercentage = (state: string, score: number | null): string => productMatchPercentage(state, score)
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(/[((]\s*匹配度\s*(?:[+\-−]|加|减)\s*\d+(?:\.\d+)?\s*分\s*[))]/g, '').trim()
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>