Files
hfb_sys/frontend/src/features/admin/components/ProcessTimeline.vue
T
yml2213 dff0577385 feat(checkout): 修复后置损耗结算全额归号主、支持角色隔离实时试算与全流程UI重构
- fix(pricing): 后置消耗/损耗金额 100% 划归号主收益,超出预收部分平台不抽成,避免被平台费吸收
- feat(checkout): 前端新增结账实时试算预览组件 (CheckoutTrialPreview + useCheckoutSimulator),角色隔离展示(仅见自身收入/退款,屏蔽平台服务费)
- refactor(admin): 重构交易全流程时间线 (ProcessTimeline.vue),将平铺挤压指标重构成4大分类仪表盘微型卡片矩阵,升级可视化业务状态流转徽章与阶段色彩主题
- style(admin): 优化支付与交接流水元信息网格布局,防止超宽屏下长单行拉伸
2026-09-13 10:19:33 +08:00

1139 lines
25 KiB
Vue
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.
<script setup lang="ts">
import { formatCentWithSymbol } from '@/shared/utils/money'
import { formatDateTime } from '@/shared/utils/time'
export interface ProcessTimelineEvent {
id: number
stage: string
action: string
actor_type: string
actor_id: number
actor_name: string
target_type?: string
target_id?: number
target_name?: string
content?: string
reason?: string
payload?: Record<string, unknown>
attachment_urls?: string[]
state_before?: Record<string, unknown>
state_after?: Record<string, unknown>
created_at: string
}
const props = defineProps<{ events: ProcessTimelineEvent[]; emptyText?: string }>()
const actionLabels: Record<string, string> = {
owner_handoff_submitted: '号主提交交接',
platform_handoff_submitted: '客服代交接',
admin_force_handoff: '客服确认交接',
renter_received_confirmed: '租客确认收号',
checkout_submitted: '租客发起结账',
checkout_countered: '修改结账方案',
checkout_confirmed: '号主确认结账',
checkout_accepted: '租客接受结账方案',
platform_checkout_countered: '客服修改结账方案',
platform_checkout_confirmed: '客服确认结账',
platform_checkout_dispute_opened: '客服发起结账争议',
offline_settlement_confirmed: '确认线下结算',
deposit_held: '暂扣押金',
deposit_released: '归还暂扣押金',
pickup_created: '创建提号',
pickup_completed: '完成提号',
pickup_profit_updated: '修改提号利润',
pickup_financial_adjusted: '创建财务调整',
pickup_adjustment_settled: '确认财务调整',
pickup_offline_settlement_confirmed: '确认提号线下结算',
pickup_cancelled: '取消提号',
}
const stageLabels: Record<string, string> = {
handoff: '交接',
checkout: '结账',
settlement: '结算',
dispute: '争议',
pickup: '提号',
}
const payloadLabels: Record<string, string> = {
checkout_id: '结账单ID',
round: '协商轮次',
turn: '等待确认方',
proposed_by: '方案提出人',
rent_amount_cent: '实际结算租金',
owner_rent_amount_cent: '号主基础租金',
platform_fee_cent: '平台服务费',
deposit_amount_cent: '订单押金',
consumable_amount_cent: '消耗品金额',
coin_consumed_m: '哈夫币消耗',
deposit_deduct_amount_cent: '押金赔付扣除',
renter_refund_amount_cent: '退还租客',
owner_income_amount_cent: '号主最终收入',
shortfall_cent: '押金不足差额',
overshoot_amount_cent: '打超金额',
offline_settlement_amount_cent: '线下待结算',
deposit_hold_amount_cent: '暂扣金额',
profit_amount_cent: '利润金额',
settle_amount_cent: '结算给号主',
profit_delta_cent: '利润调整',
settle_delta_cent: '结算调整',
settlement_mode: '结算方式',
account_source: '账号来源',
source_channel: '来源渠道',
}
const primaryPayloadKeys = new Set([
'round',
'turn',
'rent_amount_cent',
'renter_refund_amount_cent',
'owner_income_amount_cent',
'offline_settlement_amount_cent',
'profit_amount_cent',
'settle_amount_cent',
])
const stateLabels: Record<string, Record<string, string>> = {
order_status: {
pending_payment: '待支付',
pending_handoff: '待交接',
renting: '租用中',
overdue: '已逾期',
pending_checkout_confirm: '待号主确认结账',
pending_checkout_accept: '待租客确认结账',
completed: '已完成',
cancelled: '已取消',
closed: '已关闭',
abnormal: '异常',
},
handoff_status: {
none: '未开始',
pending_owner: '待号主交接',
pending_renter_confirm: '待租客确认收号',
received: '租客已收号',
pending_owner_checkout: '待号主确认结账',
pending_renter_checkout: '待租客确认结账',
returned: '已归还',
},
settlement_status: {
unsettled: '未结算',
pending: '结算待确认',
settled: '已结算',
arbitrated: '仲裁结算',
},
offline_settlement_status: { none: '无需线下结算', pending: '待线下结算', settled: '已线下结算' },
pickup_status: {
pending: '待处理',
processing: '处理中',
completed: '已完成',
cancelled: '已取消',
},
}
const stateFieldLabels: Record<string, string> = {
order_status: '订单状态',
handoff_status: '交接状态',
settlement_status: '结算状态',
offline_settlement_status: '线下结算',
pickup_status: '提号状态',
}
interface PayloadItem {
key: string
label: string
value: string
variant: 'income' | 'refund' | 'fee' | 'deduct' | 'primary' | 'neutral'
}
interface PayloadCategory {
id: string
title: string
icon: string
items: PayloadItem[]
}
const CATEGORY_DEFINITIONS: {
id: string
title: string
icon: string
keys: string[]
}[] = [
{
id: 'financial',
title: '资金结算明细',
icon: '💰',
keys: [
'owner_rent_amount_cent',
'platform_fee_cent',
'owner_income_amount_cent',
'renter_refund_amount_cent',
'rent_amount_cent',
'offline_settlement_amount_cent',
'profit_amount_cent',
'settle_amount_cent',
'profit_delta_cent',
'settle_delta_cent',
],
},
{
id: 'deposit',
title: '押金与赔付扣除',
icon: '🛡️',
keys: [
'deposit_amount_cent',
'deposit_deduct_amount_cent',
'overshoot_amount_cent',
'shortfall_cent',
'deposit_hold_amount_cent',
],
},
{
id: 'gameplay',
title: '消耗与游戏指标',
icon: '🎮',
keys: ['coin_consumed_m', 'consumable_amount_cent'],
},
{
id: 'meta',
title: '流程单据与属性',
icon: '📋',
keys: [
'checkout_id',
'round',
'turn',
'proposed_by',
'settlement_mode',
'account_source',
'source_channel',
],
},
]
function actionLabel(action: string) {
return actionLabels[action] || action
}
function stageLabel(stage: string) {
return stageLabels[stage] || '流程'
}
function actorLabel(item: ProcessTimelineEvent) {
if (item.actor_name) return item.actor_name
if (item.actor_type === 'system') return '系统'
return `${item.actor_type === 'admin' ? '客服' : '用户'} ID ${item.actor_id}`
}
function actorRole(item: ProcessTimelineEvent) {
if (item.actor_type === 'admin') return '客服操作'
if (item.actor_type === 'system') return '系统处理'
return '用户操作'
}
function payloadRows(payload?: Record<string, unknown>) {
if (!payload || typeof payload !== 'object') return []
return Object.entries(payload).filter(
([, value]) => value !== null && value !== undefined && value !== ''
)
}
function primaryPayloadRows(item: ProcessTimelineEvent) {
return payloadRows(item.payload).filter(([key]) => primaryPayloadKeys.has(key))
}
function detailPayloadRows(item: ProcessTimelineEvent) {
return payloadRows(item.payload).filter(([key]) => !primaryPayloadKeys.has(key))
}
function payloadLabel(key: string) {
return payloadLabels[key] || key
}
function payloadValue(key: string, value: unknown) {
if (key.endsWith('_cent')) return formatCentWithSymbol(Number(value || 0))
if (key === 'coin_consumed_m') return `${value} M`
if (key === 'round') return `第 ${value} 轮`
if (key === 'turn')
return value === 'owner' ? '号主' : value === 'renter' ? '租客' : String(value)
if (key === 'account_source') return value === 'external' ? '外部上传' : '站内上传'
if (key === 'settlement_mode')
return value === 'platform_managed' ? '平台线下结算' : '号主钱包结算'
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
function itemVariant(key: string): PayloadItem['variant'] {
if (
key === 'owner_income_amount_cent' ||
key === 'profit_amount_cent' ||
key === 'settle_amount_cent'
) {
return 'income'
}
if (key === 'renter_refund_amount_cent') {
return 'refund'
}
if (key === 'platform_fee_cent') {
return 'fee'
}
if (
key === 'deposit_deduct_amount_cent' ||
key === 'overshoot_amount_cent' ||
key === 'shortfall_cent' ||
key === 'deposit_hold_amount_cent'
) {
return 'deduct'
}
if (key === 'rent_amount_cent' || key === 'deposit_amount_cent') {
return 'primary'
}
return 'neutral'
}
function categorizedPayload(item: ProcessTimelineEvent): PayloadCategory[] {
const detailRows = detailPayloadRows(item)
if (detailRows.length === 0) return []
const rowMap = new Map<string, unknown>(detailRows)
const usedKeys = new Set<string>()
const result: PayloadCategory[] = []
for (const cat of CATEGORY_DEFINITIONS) {
const items: PayloadItem[] = []
for (const k of cat.keys) {
if (rowMap.has(k)) {
usedKeys.add(k)
items.push({
key: k,
label: payloadLabel(k),
value: payloadValue(k, rowMap.get(k)),
variant: itemVariant(k),
})
}
}
if (items.length > 0) {
result.push({
id: cat.id,
title: cat.title,
icon: cat.icon,
items,
})
}
}
const remainingKeys = detailRows.filter(([k]) => !usedKeys.has(k))
if (remainingKeys.length > 0) {
const gameplayItems: PayloadItem[] = []
const metaItems: PayloadItem[] = []
for (const [k, v] of remainingKeys) {
const isMoney = k.endsWith('_cent')
const pItem: PayloadItem = {
key: k,
label: payloadLabel(k),
value: payloadValue(k, v),
variant: itemVariant(k),
}
if (isMoney) {
const finCat = result.find(c => c.id === 'financial')
if (finCat) {
finCat.items.push(pItem)
continue
}
}
if (
typeof v === 'number' ||
k.includes('kill') ||
k.includes('count') ||
k.includes('coin')
) {
gameplayItems.push(pItem)
} else {
metaItems.push(pItem)
}
}
if (gameplayItems.length > 0) {
const existing = result.find(c => c.id === 'gameplay')
if (existing) {
existing.items.push(...gameplayItems)
} else {
result.push({
id: 'gameplay',
title: '消耗与游戏指标',
icon: '🎮',
items: gameplayItems,
})
}
}
if (metaItems.length > 0) {
const existing = result.find(c => c.id === 'meta')
if (existing) {
existing.items.push(...metaItems)
} else {
result.push({
id: 'meta',
title: '其他属性与单据',
icon: '📋',
items: metaItems,
})
}
}
}
return result
}
function stateValue(key: string, value: unknown) {
const raw = String(value || '')
return stateLabels[key]?.[raw] || raw || '-'
}
function stateChanges(item: ProcessTimelineEvent) {
const before = item.state_before || {}
const after = item.state_after || {}
return Object.keys(stateFieldLabels)
.filter(key => before[key] !== undefined && before[key] !== after[key])
.map(key => ({
label: stateFieldLabels[key],
before: stateValue(key, before[key]),
after: stateValue(key, after[key]),
}))
}
function stateToneClass(val: string) {
if (
['已完成', '已结算', '租客已收号', '已归还', '处理完成', '已线下结算'].some(s =>
val.includes(s)
)
) {
return 'tone-success'
}
if (['租用中', '处理中'].some(s => val.includes(s))) {
return 'tone-primary'
}
if (['待', '等待', '未结算', '未开始'].some(s => val.includes(s))) {
return 'tone-warning'
}
if (['已取消', '已关闭', '异常', '争议'].some(s => val.includes(s))) {
return 'tone-danger'
}
return 'tone-neutral'
}
function hasMore(item: ProcessTimelineEvent) {
return detailPayloadRows(item).length > 0 || stateChanges(item).length > 0
}
</script>
<template>
<el-empty v-if="props.events.length === 0" :description="props.emptyText || '暂无过程记录'" />
<div v-else class="process-timeline">
<article
v-for="item in props.events"
:key="item.id"
class="process-item"
:class="`process-item-${item.stage}`"
>
<span class="process-dot" :class="`dot-${item.stage}`"></span>
<header class="process-heading">
<div class="heading-main">
<span class="stage-badge" :class="`stage-${item.stage}`">
{{ stageLabel(item.stage) }}
</span>
<strong class="action-title">{{ actionLabel(item.action) }}</strong>
</div>
<time class="process-time">{{ formatDateTime(item.created_at) }}</time>
</header>
<div class="process-people">
<span class="actor-badge" :class="`actor-${item.actor_type}`">
{{ actorRole(item) }}
</span>
<span class="actor-name">{{ actorLabel(item) }}</span>
<span v-if="item.target_name" class="target-text">
<span class="target-arrow">➔</span> 关联对象:{{ item.target_name }}
</span>
</div>
<div v-if="item.content" class="process-content-card">
<div class="content-text">{{ item.content }}</div>
</div>
<div v-if="item.reason" class="process-reason-card">
<strong class="reason-tag">备注说明</strong>
<span class="reason-text">{{ item.reason }}</span>
</div>
<!-- High-visibility Summary Badges -->
<div v-if="primaryPayloadRows(item).length" class="process-summary-grid">
<div
v-for="[key, value] in primaryPayloadRows(item)"
:key="key"
class="summary-pill"
:class="`summary-pill-${itemVariant(key)}`"
>
<span class="summary-label">{{ payloadLabel(key) }}</span>
<strong class="summary-val">{{ payloadValue(key, value) }}</strong>
</div>
</div>
<!-- Expandable Structured Details -->
<details v-if="hasMore(item)" class="process-details">
<summary class="process-details-summary">
<span class="toggle-left">
<span class="toggle-arrow">▾</span>
<span class="toggle-title">查看完整明细与状态变化</span>
</span>
<span class="toggle-badge">
{{ detailPayloadRows(item).length }} 项明细指标<span v-if="stateChanges(item).length">
· {{ stateChanges(item).length }} 项状态变化</span
>
</span>
</summary>
<div class="process-details-body">
<!-- Categorized Detail Groups -->
<div v-for="cat in categorizedPayload(item)" :key="cat.id" class="detail-category-block">
<div class="category-header">
<span class="category-icon">{{ cat.icon }}</span>
<span class="category-title">{{ cat.title }}</span>
<span class="category-count">({{ cat.items.length }})</span>
</div>
<div class="category-grid">
<div
v-for="sub in cat.items"
:key="sub.key"
class="metric-box"
:class="`metric-box-${sub.variant}`"
>
<div class="metric-label">{{ sub.label }}</div>
<div class="metric-value">{{ sub.value }}</div>
</div>
</div>
</div>
<!-- Business State Changes Flow -->
<div v-if="stateChanges(item).length" class="state-changes-block">
<div class="category-header">
<span class="category-icon">🔄</span>
<span class="category-title">业务状态流转</span>
<span class="category-count">({{ stateChanges(item).length }})</span>
</div>
<div class="state-flow-list">
<div v-for="change in stateChanges(item)" :key="change.label" class="state-flow-item">
<span class="state-field-badge">{{ change.label }}</span>
<span class="state-node state-node-before">{{ change.before }}</span>
<span class="state-arrow">➔</span>
<span class="state-node state-node-after" :class="stateToneClass(change.after)">
{{ change.after }}
</span>
</div>
</div>
</div>
</div>
</details>
<div v-if="item.attachment_urls?.length" class="process-attachments">
<el-image
v-for="url in item.attachment_urls"
:key="url"
:src="url"
:preview-src-list="item.attachment_urls"
preview-teleported
fit="cover"
/>
</div>
</article>
</div>
</template>
<style scoped>
.process-timeline {
display: grid;
gap: 14px;
padding-left: 6px;
position: relative;
}
.process-item {
position: relative;
padding: 16px 20px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #ffffff;
box-shadow: 0 1px 3px 0 rgba(15, 23, 42, 0.04);
transition:
box-shadow 0.2s ease,
border-color 0.2s ease;
}
.process-item:hover {
border-color: #cbd5e1;
box-shadow: 0 4px 6px -1px rgba(15, 23, 42, 0.06);
}
.process-item::before {
content: '';
position: absolute;
top: -15px;
bottom: calc(100% - 1px);
left: -2px;
width: 2px;
background: #e2e8f0;
}
.process-item:first-child::before {
display: none;
}
.process-dot {
position: absolute;
left: -7px;
top: 22px;
width: 12px;
height: 12px;
border: 2px solid #ffffff;
border-radius: 50%;
background: #ff6a00;
box-shadow: 0 0 0 3px #fed7aa;
}
.dot-checkout {
background: #10b981;
box-shadow: 0 0 0 3px #d1fae5;
}
.dot-handoff {
background: #3b82f6;
box-shadow: 0 0 0 3px #dbeafe;
}
.dot-settlement {
background: #8b5cf6;
box-shadow: 0 0 0 3px #ede9fe;
}
.dot-dispute {
background: #f43f5e;
box-shadow: 0 0 0 3px #ffe4e6;
}
.dot-pickup {
background: #f59e0b;
box-shadow: 0 0 0 3px #fef3c7;
}
.process-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.heading-main {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.action-title {
color: #0f172a;
font-size: 15px;
font-weight: 700;
}
.stage-badge {
flex: none;
padding: 3px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 700;
border: 1px solid transparent;
}
.stage-checkout {
background: #ecfdf5;
color: #047857;
border-color: #a7f3d0;
}
.stage-handoff {
background: #eff6ff;
color: #1d4ed8;
border-color: #bfdbfe;
}
.stage-settlement {
background: #f5f3ff;
color: #6d28d9;
border-color: #ddd6fe;
}
.stage-dispute {
background: #fff1f2;
color: #be123c;
border-color: #fecaca;
}
.stage-pickup {
background: #fffbeb;
color: #b45309;
border-color: #fde68a;
}
.process-time {
flex: none;
color: #94a3b8;
font-size: 12px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.process-people {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 12px;
margin-top: 8px;
font-size: 12px;
}
.actor-badge {
padding: 2px 7px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.actor-admin {
background: #fffbeb;
color: #92400e;
border: 1px solid #fde68a;
}
.actor-system {
background: #e0f2fe;
color: #0369a1;
border: 1px solid #bae6fd;
}
.actor-user {
background: #f1f5f9;
color: #475569;
border: 1px solid #e2e8f0;
}
.actor-name {
font-weight: 600;
color: #334155;
}
.target-text {
color: #64748b;
display: inline-flex;
align-items: center;
gap: 4px;
}
.target-arrow {
color: #94a3b8;
}
.process-content-card {
margin-top: 10px;
padding: 10px 14px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-left: 3px solid #3b82f6;
}
.content-text {
color: #334155;
font-size: 13px;
line-height: 1.65;
white-space: pre-wrap;
}
.process-reason-card {
margin-top: 8px;
padding: 8px 12px;
border-radius: 8px;
background: #fffbeb;
border: 1px solid #fef3c7;
border-left: 3px solid #f59e0b;
color: #92400e;
font-size: 12px;
line-height: 1.5;
}
.reason-tag {
font-weight: 700;
margin-right: 8px;
}
.process-summary-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.summary-pill {
display: inline-flex;
align-items: baseline;
gap: 6px;
padding: 6px 12px;
border-radius: 8px;
font-size: 12px;
border: 1px solid transparent;
}
.summary-pill-income {
background: #ecfdf5;
border-color: #a7f3d0;
color: #065f46;
}
.summary-pill-income .summary-val {
color: #047857;
font-weight: 700;
font-size: 13px;
}
.summary-pill-refund {
background: #eff6ff;
border-color: #bfdbfe;
color: #1e40af;
}
.summary-pill-refund .summary-val {
color: #1d4ed8;
font-weight: 700;
font-size: 13px;
}
.summary-pill-fee {
background: #f5f3ff;
border-color: #ddd6fe;
color: #5b21b6;
}
.summary-pill-fee .summary-val {
color: #6d28d9;
font-weight: 700;
font-size: 13px;
}
.summary-pill-deduct {
background: #fff7ed;
border-color: #fed7aa;
color: #9a3412;
}
.summary-pill-deduct .summary-val {
color: #c2410c;
font-weight: 700;
font-size: 13px;
}
.summary-pill-primary {
background: #f1f5f9;
border-color: #cbd5e1;
color: #334155;
}
.summary-pill-primary .summary-val {
color: #0f172a;
font-weight: 700;
font-size: 13px;
}
.summary-pill-neutral {
background: #f8fafc;
border-color: #e2e8f0;
color: #64748b;
}
.summary-pill-neutral .summary-val {
color: #1e293b;
font-weight: 600;
}
.summary-label {
font-weight: 500;
}
.summary-val {
font-variant-numeric: tabular-nums;
}
.process-details {
margin-top: 12px;
}
.process-details-summary {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
cursor: pointer;
user-select: none;
transition:
background-color 0.2s ease,
border-color 0.2s ease;
list-style: none;
}
.process-details-summary::-webkit-details-marker {
display: none;
}
.process-details-summary:hover {
background: #f1f5f9;
border-color: #cbd5e1;
}
.toggle-left {
display: flex;
align-items: center;
gap: 8px;
}
.toggle-arrow {
display: inline-block;
font-size: 12px;
color: #2563eb;
transition: transform 0.2s ease;
transform: rotate(-90deg);
}
.process-details[open] .toggle-arrow {
transform: rotate(0deg);
}
.toggle-title {
font-size: 12px;
font-weight: 600;
color: #2563eb;
}
.toggle-badge {
font-size: 12px;
color: #64748b;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 2px 8px;
}
.process-details-body {
margin-top: 10px;
padding: 14px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fafbfc;
display: flex;
flex-direction: column;
gap: 14px;
}
.detail-category-block,
.state-changes-block {
display: flex;
flex-direction: column;
gap: 8px;
}
.category-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
color: #334155;
padding-bottom: 4px;
border-bottom: 1px dashed #e2e8f0;
}
.category-icon {
font-size: 13px;
}
.category-count {
font-size: 11px;
color: #94a3b8;
font-weight: 500;
}
.category-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 8px 10px;
}
.metric-box {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 8px 12px;
display: flex;
flex-direction: column;
gap: 3px;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.metric-box:hover {
border-color: #cbd5e1;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.metric-label {
font-size: 12px;
color: #64748b;
}
.metric-value {
font-size: 14px;
font-weight: 700;
color: #0f172a;
font-variant-numeric: tabular-nums;
word-break: break-word;
}
.metric-box-income .metric-value {
color: #059669;
}
.metric-box-refund .metric-value {
color: #2563eb;
}
.metric-box-fee .metric-value {
color: #7c3aed;
}
.metric-box-deduct .metric-value {
color: #c2410c;
}
.metric-box-primary .metric-value {
color: #0f172a;
}
.state-flow-list {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
}
.state-flow-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 12px;
}
.state-field-badge {
font-weight: 600;
color: #475569;
background: #f1f5f9;
padding: 1px 6px;
border-radius: 4px;
}
.state-node {
padding: 1px 7px;
border-radius: 4px;
font-weight: 500;
}
.state-node-before {
background: #f8fafc;
color: #64748b;
border: 1px solid #e2e8f0;
}
.state-arrow {
color: #3b82f6;
font-size: 10px;
}
.state-node-after {
font-weight: 600;
}
.tone-success {
background: #ecfdf5;
color: #047857;
border: 1px solid #a7f3d0;
}
.tone-primary {
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}
.tone-warning {
background: #fffbeb;
color: #b45309;
border: 1px solid #fde68a;
}
.tone-danger {
background: #fef2f2;
color: #b91c1c;
border: 1px solid #fecaca;
}
.tone-neutral {
background: #f8fafc;
color: #475569;
border: 1px solid #e2e8f0;
}
.process-attachments {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.process-attachments :deep(.el-image) {
width: 76px;
height: 76px;
border: 1px solid #e2e8f0;
border-radius: 6px;
}
@media (max-width: 700px) {
.process-item {
padding: 14px 14px 14px 16px;
}
.process-heading {
align-items: flex-start;
flex-direction: column;
gap: 4px;
}
.process-summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.category-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>