style: 全局 UI 主题皮肤重构与样式模块化

引入 Element Plus 主题定制和主题皮肤 composable,将全局
样式拆分为组件级独立 CSS 文件(侧边栏、顶栏、工作台等),
统一色彩变量和间距规范,重构所有视图和组件样式以适配新
主题系统,优化图表和知识图谱组件视觉表现,提取审计和差
旅报销相关子组件。
This commit is contained in:
caoxiaozhu
2026-05-27 09:17:57 +08:00
parent df49103f23
commit 2dcc72102d
112 changed files with 10983 additions and 8996 deletions

View File

@@ -0,0 +1,334 @@
<template>
<article class="skill-list panel">
<nav class="status-tabs" aria-label="能力类型">
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
:class="{ active: activeType === tab.id }"
@click="emit('update:activeType', tab.id)"
>
{{ tab.label }}
</button>
</nav>
<div class="list-toolbar">
<div class="filter-set">
<label class="search-filter">
<i class="mdi mdi-magnify"></i>
<input
:value="keyword"
type="search"
:placeholder="searchPlaceholder"
@input="emit('update:keyword', $event.target.value)"
/>
</label>
<AuditPickerFilter
id="domain"
title="选择业务域"
close-label="关闭业务域选择"
:active-filter-popover="activeFilterPopover"
:label="selectedDomainLabel"
:options="domainOptions"
:selected-value="selectedDomain"
@toggle="emit('toggle-filter-popover', $event)"
@close="emit('close-filter-popover')"
@select="selectFilter('domain', $event)"
/>
<AuditPickerFilter
v-if="showOwnerFilter"
id="owner"
title="选择负责人"
close-label="关闭负责人选择"
:active-filter-popover="activeFilterPopover"
:label="selectedOwnerLabel"
:options="ownerOptions"
:selected-value="selectedOwner"
@toggle="emit('toggle-filter-popover', $event)"
@close="emit('close-filter-popover')"
@select="selectFilter('owner', $event)"
/>
<AuditPickerFilter
v-if="showRiskLevelFilter"
id="riskLevel"
title="选择风险等级"
close-label="关闭风险等级选择"
:active-filter-popover="activeFilterPopover"
:label="selectedRiskLevelLabel"
:options="riskLevelOptions"
:selected-value="selectedRiskLevel"
@toggle="emit('toggle-filter-popover', $event)"
@close="emit('close-filter-popover')"
@select="selectFilter('riskLevel', $event)"
/>
<AuditPickerFilter
v-if="showRiskScenarioFilter"
id="riskScenario"
title="选择使用场景"
close-label="关闭使用场景选择"
:active-filter-popover="activeFilterPopover"
:label="selectedRiskScenarioLabel"
:options="riskScenarioOptions"
:selected-value="selectedRiskScenario"
@toggle="emit('toggle-filter-popover', $event)"
@close="emit('close-filter-popover')"
@select="selectFilter('riskScenario', $event)"
/>
<AuditPickerFilter
v-if="showOnlineFilter"
id="online"
title="选择上线状态"
close-label="关闭上线状态选择"
:active-filter-popover="activeFilterPopover"
:label="selectedOnlineStateLabel"
:options="onlineStateOptions"
:selected-value="selectedOnlineState"
@toggle="emit('toggle-filter-popover', $event)"
@close="emit('close-filter-popover')"
@select="selectFilter('online', $event)"
/>
<AuditPickerFilter
v-if="showEnabledFilter"
id="enabled"
title="选择启用状态"
close-label="关闭启用状态选择"
:active-filter-popover="activeFilterPopover"
:label="selectedEnabledStateLabel"
:options="enabledStateOptions"
:selected-value="selectedEnabledState"
@toggle="emit('toggle-filter-popover', $event)"
@close="emit('close-filter-popover')"
@select="selectFilter('enabled', $event)"
/>
<AuditPickerFilter
v-if="showStatusFilter"
id="status"
title="选择状态"
close-label="关闭状态选择"
:active-filter-popover="activeFilterPopover"
:label="selectedStatusLabel"
:options="statusOptions"
:selected-value="selectedStatus"
@toggle="emit('toggle-filter-popover', $event)"
@close="emit('close-filter-popover')"
@select="selectFilter('status', $event)"
/>
</div>
<div class="toolbar-actions">
<button
v-if="activeFilterTokens.length"
class="ghost-filter-btn"
type="button"
@click="emit('reset-filters')"
>
<i class="mdi mdi-filter-remove-outline"></i>
<span>清空筛选</span>
</button>
<button
class="create-btn"
type="button"
:disabled="!canCreateRiskRule"
@click="emit('create-risk-rule')"
>
<i class="mdi mdi-plus"></i>
<span>{{ createButtonLabel }}</span>
</button>
</div>
</div>
<p class="hint"><i class="mdi mdi-information-outline"></i> {{ hintText }}</p>
<div v-if="activeFilterTokens.length" class="active-filter-strip">
<span v-for="token in activeFilterTokens" :key="token" class="active-filter-chip">
{{ token }}
</span>
</div>
<div
class="table-wrap"
:class="{ 'is-empty': !loading && !errorMessage && !visibleSkills.length }"
>
<div v-if="loading" class="table-state">
<TableLoadingState
variant="panel"
:title="`${activeTabLabel}资产同步中`"
:message="`正在加载${activeTabLabel}资产`"
icon="mdi mdi-view-list-outline"
/>
</div>
<div v-else-if="errorMessage" class="table-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<p>{{ errorMessage }}</p>
</div>
<TableEmptyState
v-else-if="!visibleSkills.length"
:eyebrow="auditEmptyState.eyebrow"
:title="auditEmptyState.title"
:description="auditEmptyState.desc"
:icon="auditEmptyState.icon"
:action-label="auditEmptyState.actionLabel"
:action-icon="auditEmptyState.actionIcon"
:tone="auditEmptyState.tone"
:art-label="auditEmptyState.artLabel"
:tips="auditEmptyState.tips"
@action="emit('empty-action')"
/>
<table v-else>
<thead>
<tr>
<th>{{ tableColumns.name }}</th>
<th>{{ tableColumns.category }}</th>
<th>{{ tableColumns.owner }}</th>
<th>{{ tableColumns.scope }}</th>
<th v-if="showRuntimeColumn">{{ tableColumns.runtime }}</th>
<th v-if="showVersionColumn">{{ tableColumns.version }}</th>
<th v-if="showStatusColumn">{{ tableColumns.status || '状态' }}</th>
<th v-if="showMetricColumn">{{ tableColumns.metric }}</th>
<th v-if="showOnlineColumn">是否上线</th>
<th v-if="showEnabledColumn">是否启用</th>
<th>{{ tableColumns.updatedAt || '最近更新' }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="skill in visibleSkills"
:key="skill.id"
:class="{ 'is-disabled': skill.usesJsonRiskRule && skill.statusValue === 'generating' }"
@click="emit('open-asset-detail', skill)"
>
<td>
<div class="skill-name-cell">
<span class="skill-avatar" :class="skill.badgeTone">{{ skill.short }}</span>
<div>
<strong>{{ skill.name }}</strong>
<span class="skill-list-subtitle">{{ skill.listSubtitle || skill.summary }}</span>
</div>
</div>
</td>
<td>{{ skill.category }}</td>
<td>
<span
v-if="skill.usesJsonRiskRule"
class="json-risk-meta-badge"
:class="skill.riskLevelTone"
>
{{ skill.riskLevelLabel || '-' }}
</span>
<template v-else>{{ skill.owner }}</template>
</td>
<td><span class="scope-pill">{{ skill.scope }}</span></td>
<td v-if="showRuntimeColumn">{{ skill.model }}</td>
<td v-if="showVersionColumn">{{ skill.versionDisplay || skill.version }}</td>
<td v-if="showStatusColumn">
<span class="status-pill" :class="skill.statusTone">{{ skill.status }}</span>
</td>
<td v-if="showMetricColumn">{{ skill.hitRate }}</td>
<td v-if="showOnlineColumn">
<span class="status-pill" :class="skill.isOnlineTone">{{ skill.isOnlineLabel }}</span>
</td>
<td v-if="showEnabledColumn">
<span class="status-pill" :class="skill.isEnabledTone">{{ skill.isEnabledLabel }}</span>
</td>
<td>{{ skill.updatedAt }}</td>
</tr>
</tbody>
</table>
</div>
<footer v-if="!loading && !errorMessage && visibleSkills.length" class="list-foot">
<span class="page-summary">当前展示 {{ visibleSkills.length }} 条资产</span>
</footer>
</article>
</template>
<script setup>
import AuditPickerFilter from './AuditPickerFilter.vue'
import TableEmptyState from '../shared/TableEmptyState.vue'
import TableLoadingState from '../shared/TableLoadingState.vue'
defineOptions({
name: 'AuditAssetList'
})
defineProps({
tabs: { type: Array, default: () => [] },
activeType: { type: String, default: '' },
activeTabLabel: { type: String, default: '' },
keyword: { type: String, default: '' },
searchPlaceholder: { type: String, default: '' },
createButtonLabel: { type: String, default: '' },
hintText: { type: String, default: '' },
tableColumns: { type: Object, default: () => ({}) },
showRuntimeColumn: { type: Boolean, default: false },
showVersionColumn: { type: Boolean, default: false },
showMetricColumn: { type: Boolean, default: false },
showStatusColumn: { type: Boolean, default: false },
showOnlineColumn: { type: Boolean, default: false },
showEnabledColumn: { type: Boolean, default: false },
visibleSkills: { type: Array, default: () => [] },
auditEmptyState: { type: Object, default: () => ({}) },
loading: { type: Boolean, default: false },
errorMessage: { type: String, default: '' },
selectedDomain: { type: String, default: '' },
selectedOwner: { type: String, default: '' },
selectedRiskLevel: { type: String, default: '' },
selectedStatus: { type: String, default: '' },
selectedRiskScenario: { type: String, default: '' },
selectedOnlineState: { type: String, default: '' },
selectedEnabledState: { type: String, default: '' },
selectedDomainLabel: { type: String, default: '' },
selectedOwnerLabel: { type: String, default: '' },
selectedRiskLevelLabel: { type: String, default: '' },
selectedStatusLabel: { type: String, default: '' },
selectedRiskScenarioLabel: { type: String, default: '' },
selectedOnlineStateLabel: { type: String, default: '' },
selectedEnabledStateLabel: { type: String, default: '' },
showRiskScenarioFilter: { type: Boolean, default: false },
showOwnerFilter: { type: Boolean, default: false },
showRiskLevelFilter: { type: Boolean, default: false },
showStatusFilter: { type: Boolean, default: false },
showOnlineFilter: { type: Boolean, default: false },
showEnabledFilter: { type: Boolean, default: false },
domainOptions: { type: Array, default: () => [] },
ownerOptions: { type: Array, default: () => [] },
riskLevelOptions: { type: Array, default: () => [] },
statusOptions: { type: Array, default: () => [] },
riskScenarioOptions: { type: Array, default: () => [] },
onlineStateOptions: { type: Array, default: () => [] },
enabledStateOptions: { type: Array, default: () => [] },
activeFilterPopover: { type: String, default: '' },
activeFilterTokens: { type: Array, default: () => [] },
canCreateRiskRule: { type: Boolean, default: false }
})
const emit = defineEmits([
'update:activeType',
'update:keyword',
'toggle-filter-popover',
'close-filter-popover',
'select-filter',
'reset-filters',
'create-risk-rule',
'empty-action',
'open-asset-detail'
])
function selectFilter(type, value) {
emit('select-filter', type, value)
}
</script>
<style scoped src="../../assets/styles/views/audit-view.css"></style>
<style scoped src="../../assets/styles/views/audit-view-part2.css"></style>

View File

@@ -0,0 +1,173 @@
<template>
<section class="json-risk-editor-shell panel">
<header class="json-risk-editor-head asset-detail-topbar list-toolbar">
<div class="json-risk-editor-title asset-detail-topbar-main filter-set">
<div class="json-risk-head-copy">
<div class="json-risk-head-title-row">
<h2>{{ selectedSkill.name }}</h2>
</div>
<p class="json-risk-head-subtitle">
{{ selectedSkill.riskRuleSubtitle || '平台通用风险规则' }}
</p>
<div class="json-risk-head-meta">
<span v-if="selectedSkill.riskCategory">适用场景{{ selectedSkill.riskCategory }}</span>
<span>业务域{{ selectedSkill.category || '-' }}</span>
<span>最近更新{{ selectedSkill.updatedAt || '-' }}</span>
</div>
</div>
</div>
<div
class="json-risk-score-ring"
:class="selectedSkill.riskRuleScoreLevel || selectedSkill.riskRuleSeverity"
>
<strong>{{ selectedSkill.riskRuleScore ?? '--' }}</strong>
<span>风险分</span>
<em>{{ selectedSkill.riskRuleScoreLabel || selectedSkill.riskRuleSeverityLabel }}</em>
</div>
</header>
<div
v-if="selectedSkill.riskRuleGenerationFailed"
class="json-risk-generation-failure"
>
<i class="mdi mdi-alert-circle-outline"></i>
<div>
<h3>风险规则生成失败</h3>
<p>这条规则没有生成出可执行的 JSON 模板和流程图管理员可以删除后重新创建</p>
<small v-if="selectedSkill.riskRuleGenerationError">
失败原因{{ selectedSkill.riskRuleGenerationError }}
</small>
</div>
</div>
<div v-else class="json-risk-editor-body">
<section class="json-risk-main-stage">
<article class="detail-card panel json-risk-summary-card">
<div class="card-head">
<div>
<h3>基本信息</h3>
<p>这条规则的业务域风险等级创建时间上线状态和最近操作</p>
</div>
</div>
<div class="json-risk-meta-grid">
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">业务域</span>
<span class="json-risk-meta-value">{{ selectedSkill.category || '-' }}</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">适用场景</span>
<span class="json-risk-meta-value">{{ selectedSkill.riskCategory || selectedSkill.scope || '-' }}</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">业务环节</span>
<span class="json-risk-meta-value">{{ selectedSkill.businessStageLabel || '-' }}</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">风险等级</span>
<span class="json-risk-meta-value">
<span class="json-risk-meta-badge" :class="selectedSkill.riskRuleSeverity">
{{ selectedSkill.riskRuleSeverityLabel || '-' }}
</span>
</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">是否上线</span>
<span class="json-risk-meta-value">
<span class="json-risk-meta-badge" :class="selectedSkill.isOnlineValue ? 'test-passed' : 'test-pending'">
{{ selectedSkill.isOnlineLabel || '待上线' }}
</span>
</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">测试状态</span>
<span class="json-risk-meta-value">
<span class="json-risk-meta-badge" :class="riskRuleTestPassed ? 'test-passed' : 'test-pending'">
{{ riskRuleTestPassed ? '已确认通过' : '待测试确认' }}
</span>
</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">创建者</span>
<span class="json-risk-meta-value">{{ selectedSkill.creator || selectedSkill.publisher || '-' }}</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">创建时间</span>
<span class="json-risk-meta-value">
{{ selectedSkill.riskRuleCreatedAt || selectedSkill.updatedAt }}
<span v-if="selectedSkill.riskRuleAgeLabel" class="meta-value-hint">
({{ selectedSkill.riskRuleAgeLabel }})
</span>
</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">上线时间</span>
<span class="json-risk-meta-value">{{ selectedSkill.publishedAt || '-' }}</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">最后操作</span>
<span class="json-risk-meta-value">{{ selectedSkill.lastOperationLabel || '-' }}</span>
</div>
<div class="json-risk-meta-item full-width">
<span class="json-risk-meta-label">使用字段</span>
<span class="json-risk-meta-value">{{ selectedSkill.riskRuleFieldSummary || '-' }}</span>
</div>
</div>
</article>
<article
v-if="selectedSkill.riskRuleBusinessDescription"
class="detail-card panel json-risk-description-card"
>
<div class="card-head">
<div>
<h3>业务说明</h3>
<p>面向规则制定者和审核人的自然语言说明</p>
</div>
</div>
<p class="json-risk-description-text">{{ selectedSkill.riskRuleBusinessDescription }}</p>
<p
v-if="selectedSkill.riskRuleSourceRef"
class="json-risk-description-source"
>
来源{{ selectedSkill.riskRuleSourceRef }}
</p>
</article>
<article class="detail-card panel json-risk-flow-card">
<div class="card-head">
<div>
<h3>判断流程</h3>
<p>规则从业务单据开始读取字段证据后按判断依据决定是否进入复核</p>
</div>
</div>
<RiskRuleFlowDiagram
:svg="selectedSkill.riskRuleFlowDiagramSvg"
:flow="selectedSkill.riskRuleFlow"
:fields="selectedSkill.riskRuleFields"
:severity="selectedSkill.riskRuleSeverity"
:severity-label="selectedSkill.riskRuleSeverityLabel"
/>
</article>
</section>
</div>
</section>
</template>
<script setup>
import RiskRuleFlowDiagram from '../shared/RiskRuleFlowDiagram.vue'
defineOptions({
name: 'AuditJsonRiskRuleDetail'
})
defineProps({
selectedSkill: { type: Object, required: true },
riskRuleTestPassed: { type: Boolean, default: false }
})
</script>
<style scoped src="../../assets/styles/views/audit-view.css"></style>
<style scoped src="../../assets/styles/views/audit-view-part2.css"></style>

View File

@@ -0,0 +1,60 @@
<template>
<div class="picker-filter" :class="{ open: activeFilterPopover === id }">
<button
class="picker-trigger"
type="button"
:aria-expanded="activeFilterPopover === id"
aria-haspopup="dialog"
@click="emit('toggle', id)"
>
<span class="picker-label">{{ label }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="activeFilterPopover === id"
class="picker-popover"
role="dialog"
:aria-label="title"
>
<header>
<strong>{{ title }}</strong>
<button type="button" :aria-label="closeLabel" @click="emit('close')">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="picker-option-list">
<button
v-for="option in options"
:key="option.value || `all-${id}`"
type="button"
class="picker-option"
:class="{ active: selectedValue === option.value }"
@click="emit('select', option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</div>
</template>
<script setup>
defineOptions({
name: 'AuditPickerFilter'
})
defineProps({
id: { type: String, required: true },
title: { type: String, required: true },
closeLabel: { type: String, required: true },
activeFilterPopover: { type: String, default: '' },
label: { type: String, default: '' },
options: { type: Array, default: () => [] },
selectedValue: { type: [String, Number, Boolean], default: '' }
})
const emit = defineEmits(['toggle', 'close', 'select'])
</script>
<style scoped src="../../assets/styles/views/audit-view.css"></style>
<style scoped src="../../assets/styles/views/audit-view-part2.css"></style>

View File

@@ -0,0 +1,293 @@
<template>
<ConfirmDialog
:open="riskRuleCreateOpen"
badge="自然语言规则"
badge-tone="info"
title="新建风险规则"
description="默认创建费用类风险规则。选择业务环节和费用领域后填写规则标题与自然语言描述,系统会根据评分模型自动计算风险分数和等级。"
cancel-text="取消"
confirm-text="开始生成"
busy-text="生成中..."
confirm-tone="primary"
confirm-icon="mdi mdi-auto-fix"
:busy="riskRuleCreateBusy"
:close-on-mask="!riskRuleCreateBusy"
@close="emit('close-risk-rule-create')"
@confirm="emit('submit-risk-rule-create')"
>
<div class="risk-rule-create-form">
<label>
<span>业务环节</span>
<EnterpriseSelect
v-model="riskRuleCreateForm.business_stage"
:options="riskRuleBusinessStageOptions"
:disabled="riskRuleCreateBusy"
/>
</label>
<label>
<span>费用领域</span>
<EnterpriseSelect
v-model="riskRuleCreateForm.expense_category"
:options="riskRuleExpenseCategoryOptions"
:disabled="riskRuleCreateBusy"
/>
</label>
<label>
<span>是否上传附件</span>
<EnterpriseSelect
v-model="riskRuleCreateForm.requires_attachment"
:options="riskRuleAttachmentOptions"
:disabled="riskRuleCreateBusy"
/>
</label>
<label class="span-2">
<span>规则标题</span>
<input
v-model="riskRuleCreateForm.rule_title"
:disabled="riskRuleCreateBusy"
maxlength="80"
placeholder="例如:差旅目的地与票据城市一致性校验"
/>
</label>
<label class="span-2">
<span>自然语言规则</span>
<textarea
v-model="riskRuleCreateForm.natural_language"
:disabled="riskRuleCreateBusy"
placeholder="例如:住宿城市必须出现在本次差旅行程城市中,否则提示高风险并要求补充说明。"
></textarea>
</label>
</div>
</ConfirmDialog>
<RiskRuleTestDialog
:open="riskRuleTestOpen"
:rule="selectedSkill"
@close="emit('close-risk-rule-test')"
@report-saved="emit('report-saved', $event)"
/>
<ConfirmDialog
:open="riskRuleDeleteOpen"
badge="删除规则"
badge-tone="danger"
title="删除未发布风险规则"
description="该操作会删除规则草稿、版本记录和关联 JSON 文件。只有从未发布过的规则允许删除。"
cancel-text="取消"
confirm-text="确认删除"
busy-text="删除中..."
confirm-tone="danger"
confirm-icon="mdi mdi-delete-outline"
:busy="actionState === 'delete-risk-rule'"
@close="emit('close-delete-risk-rule')"
@confirm="emit('delete-selected-risk-rule')"
>
<div class="risk-rule-action-confirm">
<span>规则名称</span>
<strong>{{ selectedSkill?.name }}</strong>
</div>
</ConfirmDialog>
<ConfirmDialog
:open="riskRuleReturnOpen"
badge="回退规则"
badge-tone="warning"
title="回退风险规则"
description="回退后规则会回到草稿状态,编写人需要根据原因重新调整并测试。"
cancel-text="取消"
confirm-text="确认回退"
busy-text="回退中..."
confirm-tone="warning"
confirm-icon="mdi mdi-keyboard-return"
:busy="actionState === 'return-risk-rule'"
@close="emit('close-return-risk-rule')"
@confirm="emit('return-selected-risk-rule')"
>
<label class="risk-rule-action-note">
<span>回退原因</span>
<textarea
v-model="returnNoteModel"
rows="4"
:disabled="actionState === 'return-risk-rule'"
placeholder="请说明需要编写人调整的规则问题"
></textarea>
</label>
</ConfirmDialog>
<ConfirmDialog
:open="riskRulePublishOpen"
badge="发布上线"
badge-tone="info"
title="发布风险规则"
description="发布后该规则会进入真实业务风险扫描,只加载正式上线规则。"
cancel-text="取消"
confirm-text="确认发布"
busy-text="发布中..."
confirm-tone="primary"
confirm-icon="mdi mdi-rocket-launch-outline"
:busy="actionState === 'publish-risk-rule'"
@close="emit('close-publish-risk-rule')"
@confirm="emit('publish-selected-risk-rule')"
>
<div class="risk-rule-action-confirm">
<span>测试状态</span>
<strong>{{ riskRuleTestPassed ? '已确认通过' : '未确认通过' }}</strong>
</div>
</ConfirmDialog>
<ConfirmDialog
:open="Boolean(versionSwitchTarget)"
badge="切换版本"
badge-tone="info"
title="切换规则版本"
description="切换后编辑器只会替换当前展示内容,不会直接回滚后端当前版本。"
cancel-text="取消"
confirm-text="确认切换"
busy-text="切换中..."
confirm-tone="primary"
confirm-icon="mdi mdi-swap-horizontal"
@close="emit('cancel-version-switch')"
@confirm="emit('confirm-version-switch')"
>
<div class="version-modal-summary">
<div>
<span>当前展示版本</span>
<strong>{{ selectedSkill?.displayVersion }}</strong>
</div>
<i class="mdi mdi-arrow-right"></i>
<div>
<span>目标版本</span>
<strong>{{ versionSwitchTarget?.version }}</strong>
</div>
</div>
<div v-if="versionSwitchTarget" class="version-modal-note">
<strong>{{ versionSwitchTarget.note }}</strong>
<span>{{ versionSwitchTarget.time }}</span>
</div>
</ConfirmDialog>
<ConfirmDialog
:open="reviewSubmitOpen"
badge="提交审核"
badge-tone="info"
title="提交规则版本审核"
description="请先确认本次送审采用的版本号,并选择负责审核的高级管理员。若填写新的版本号,系统会将当前工作稿固化为该版本后再送审。"
cancel-text="取消"
confirm-text="确认提交"
busy-text="提交中..."
confirm-tone="primary"
confirm-icon="mdi mdi-send-outline"
:busy="actionState === 'review-pending'"
@close="emit('close-submit-review')"
@confirm="emit('submit-selected-rule-for-review')"
>
<div class="review-submit-form">
<label>
<span>送审版本号</span>
<input
v-model="reviewSubmitVersionModel"
type="text"
placeholder="例如v1.1.0"
:disabled="actionState === 'review-pending'"
/>
</label>
<label>
<span>审核人</span>
<EnterpriseSelect
v-model="reviewSubmitReviewerModel"
:options="reviewSubmitReviewerOptions"
:placeholder="reviewSubmitReviewerLoading ? '加载审核人中...' : '请选择高级管理员'"
:disabled="reviewSubmitReviewerLoading || actionState === 'review-pending'"
/>
</label>
<p
v-if="!reviewSubmitReviewerLoading && !hasReviewSubmitReviewers"
class="review-submit-hint"
>
当前没有可选的高级管理员请先在员工管理中配置具备管理员角色的员工
</p>
<div v-if="selectedSkillUsesJsonRisk" class="review-submit-test-state">
<span>测试确认</span>
<strong :class="{ passed: riskRuleTestPassed }">
{{ riskRuleTestPassed ? '当前版本已通过测试确认' : '当前版本尚未确认测试通过' }}
</strong>
<p>只有保存测试报告的风险规则才能提交给高级财务人员审核</p>
</div>
</div>
</ConfirmDialog>
</template>
<script setup>
import { computed } from 'vue'
import ConfirmDialog from '../shared/ConfirmDialog.vue'
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
import RiskRuleTestDialog from '../shared/RiskRuleTestDialog.vue'
defineOptions({
name: 'AuditRuleDialogs'
})
const props = defineProps({
selectedSkill: { type: Object, default: null },
versionSwitchTarget: { type: Object, default: null },
actionState: { type: String, default: '' },
riskRuleCreateOpen: { type: Boolean, default: false },
riskRuleCreateBusy: { type: Boolean, default: false },
riskRuleCreateForm: { type: Object, required: true },
riskRuleBusinessStageOptions: { type: Array, default: () => [] },
riskRuleExpenseCategoryOptions: { type: Array, default: () => [] },
riskRuleAttachmentOptions: { type: Array, default: () => [] },
riskRuleTestOpen: { type: Boolean, default: false },
riskRuleDeleteOpen: { type: Boolean, default: false },
riskRuleReturnOpen: { type: Boolean, default: false },
riskRulePublishOpen: { type: Boolean, default: false },
riskRuleReturnNote: { type: String, default: '' },
riskRuleTestPassed: { type: Boolean, default: false },
reviewSubmitOpen: { type: Boolean, default: false },
reviewSubmitVersion: { type: String, default: '' },
reviewSubmitReviewer: { type: String, default: '' },
reviewSubmitReviewerLoading: { type: Boolean, default: false },
reviewSubmitReviewerOptions: { type: Array, default: () => [] },
hasReviewSubmitReviewers: { type: Boolean, default: false },
selectedSkillUsesJsonRisk: { type: Boolean, default: false }
})
const emit = defineEmits([
'update:riskRuleReturnNote',
'update:reviewSubmitVersion',
'update:reviewSubmitReviewer',
'close-risk-rule-create',
'submit-risk-rule-create',
'close-risk-rule-test',
'report-saved',
'close-delete-risk-rule',
'delete-selected-risk-rule',
'close-return-risk-rule',
'return-selected-risk-rule',
'close-publish-risk-rule',
'publish-selected-risk-rule',
'cancel-version-switch',
'confirm-version-switch',
'close-submit-review',
'submit-selected-rule-for-review'
])
const returnNoteModel = computed({
get: () => props.riskRuleReturnNote,
set: (value) => emit('update:riskRuleReturnNote', value)
})
const reviewSubmitVersionModel = computed({
get: () => props.reviewSubmitVersion,
set: (value) => emit('update:reviewSubmitVersion', value)
})
const reviewSubmitReviewerModel = computed({
get: () => props.reviewSubmitReviewer,
set: (value) => emit('update:reviewSubmitReviewer', value)
})
</script>
<style scoped src="../../assets/styles/views/audit-view.css"></style>
<style scoped src="../../assets/styles/views/audit-view-part2.css"></style>

View File

@@ -0,0 +1,112 @@
<template>
<Transition name="drawer-fade">
<div v-if="open" class="rule-drawer-backdrop" @click.self="emit('close')">
<aside class="rule-drawer compare-drawer change-detail-drawer">
<header class="rule-drawer-head">
<div>
<span>最近修改</span>
<h3>修改详情</h3>
</div>
<button type="button" @click="emit('close')">
<i class="mdi mdi-close"></i>
</button>
</header>
<div v-if="record" class="compare-content change-detail-content">
<section class="change-detail-meta">
<article>
<span>修改人</span>
<strong>{{ record.actor }}</strong>
</article>
<article>
<span>修改时间</span>
<strong>{{ record.time }}</strong>
</article>
<article>
<span>修改工作表</span>
<strong>{{ record.changed_sheet_count }}</strong>
</article>
<article>
<span>变更单元格</span>
<strong>{{ record.changed_cell_count }}</strong>
</article>
</section>
<section class="compare-panel">
<header>
<strong>本次修改摘要</strong>
</header>
<p>{{ record.summary }}</p>
</section>
<section class="compare-panel">
<header>
<strong>工作表变化</strong>
</header>
<div v-if="sheetRows.length" class="compare-sheet-list">
<span
v-for="item in sheetRows"
:key="`${item.sheet_name}-${item.change_type}`"
:class="item.meta.tone"
>
{{ item.sheet_name }} · {{ item.meta.label }}
</span>
</div>
<p v-else>本次没有工作表级变化</p>
</section>
<section class="compare-panel compare-cell-panel">
<header>
<strong>单元格差异</strong>
<small>最多展示前 500 </small>
</header>
<div v-if="cellRows.length" class="compare-table-wrap">
<table>
<thead>
<tr>
<th>工作表</th>
<th>位置</th>
<th>类型</th>
<th>旧值</th>
<th>新值</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in cellRows"
:key="`${item.sheet_name}-${item.cell}`"
>
<td>{{ item.sheet_name }}</td>
<td>{{ item.cell }}</td>
<td><b :class="item.meta.tone">{{ item.meta.label }}</b></td>
<td>{{ item.before_value ?? '-' }}</td>
<td>{{ item.after_value ?? '-' }}</td>
</tr>
</tbody>
</table>
</div>
<p v-else>本次没有发现单元格级差异</p>
</section>
</div>
</aside>
</div>
</Transition>
</template>
<script setup>
defineOptions({
name: 'AuditSpreadsheetChangeDrawer'
})
defineProps({
open: { type: Boolean, default: false },
record: { type: Object, default: null },
sheetRows: { type: Array, default: () => [] },
cellRows: { type: Array, default: () => [] }
})
const emit = defineEmits(['close'])
</script>
<style scoped src="../../assets/styles/views/audit-view.css"></style>
<style scoped src="../../assets/styles/views/audit-view-part2.css"></style>

View File

@@ -0,0 +1,159 @@
<template>
<section class="spreadsheet-editor-shell panel">
<header class="spreadsheet-editor-head asset-detail-topbar list-toolbar">
<div class="spreadsheet-editor-title asset-detail-topbar-main filter-set">
<div class="skill-badge" :class="selectedSkill.badgeTone">{{ selectedSkill.typeLabel }}</div>
<div>
<h2>{{ selectedSkill.name }}</h2>
<p>{{ selectedSkill.summary || '当前资产尚未补充说明。' }}</p>
</div>
</div>
<div class="spreadsheet-editor-actions asset-detail-topbar-meta toolbar-actions">
<span class="spreadsheet-mode-pill">
{{ selectedSpreadsheetModeLabel }}
</span>
</div>
</header>
<input
ref="fileInput"
class="spreadsheet-upload-input"
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
@change="emit('file-change', $event)"
/>
<div class="spreadsheet-editor-body">
<section class="spreadsheet-main-stage">
<div class="spreadsheet-editor-meta">
<span><strong>文件</strong>{{ selectedSpreadsheetFileName }}</span>
<span><strong>负责人</strong>{{ selectedSkill.owner }}</span>
<span><strong>最近更新</strong>{{ selectedSkill.updatedAt }}</span>
</div>
<div class="spreadsheet-workbench">
<div
:key="spreadsheetOnlyOfficeHostId"
:id="spreadsheetOnlyOfficeHostId"
class="rule-spreadsheet-host"
:class="{ hidden: !spreadsheetOnlyOfficeReady && !spreadsheetOnlyOfficeError }"
></div>
<TableLoadingState
v-if="spreadsheetOnlyOfficeLoading"
class="rule-spreadsheet-state"
variant="overlay"
tone="sky"
message="正在加载 Excel 规则表"
icon="mdi mdi-table-large"
:show-skeleton="false"
/>
<div v-else-if="spreadsheetOnlyOfficeError" class="rule-spreadsheet-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<span>{{ spreadsheetOnlyOfficeError }}</span>
</div>
</div>
<footer class="spreadsheet-editor-foot">
<span>
{{
canEditSpreadsheetInline
? '可直接在线编辑;保存后,右侧会自动记录本次修改内容。'
: '当前为只读预览模式。'
}}
</span>
<span>右侧仅展示最近 30 次修改操作</span>
</footer>
</section>
<aside class="spreadsheet-change-center">
<header class="change-center-head">
<div>
<h3>最近修改</h3>
<p>展示最近 30 次保存后的具体改动</p>
</div>
</header>
<section class="change-center-section change-history-section">
<div v-if="selectedSpreadsheetChangeRecords.length" class="change-center-list">
<button
v-for="item in selectedSpreadsheetChangeRecords"
:key="`spreadsheet-change-${item.id || item.changed_at}-${item.actor}`"
type="button"
class="change-center-item change-record-item"
@click="emit('open-spreadsheet-change-detail', item)"
>
<div class="change-record-head">
<div>
<strong>{{ item.actor }}</strong>
<span>{{ item.time }}</span>
</div>
<b>{{ item.changeCountLabel }}</b>
</div>
<p>{{ item.summary }}</p>
<small v-if="item.sheetPreview.length">
涉及工作表{{ item.sheetPreview.join('') }}
<template v-if="item.remainingSheetCount"> {{ item.changedSheetNames.length }} </template>
</small>
<div v-if="item.previewChanges.length" class="change-record-preview">
<span
v-for="change in item.previewChanges"
:key="`${change.sheet_name}-${change.cell}`"
>
{{ change.sheet_name }}!{{ change.cell }}
{{ change.before_value ?? '-' }} {{ change.after_value ?? '-' }}
</span>
</div>
<small v-if="item.remainingChangeCount" class="change-record-more">
另有 {{ item.remainingChangeCount }} 处改动
</small>
</button>
</div>
<p v-else class="change-flow-empty">暂无修改记录</p>
</section>
</aside>
</div>
</section>
</template>
<script setup>
import { ref } from 'vue'
import TableLoadingState from '../shared/TableLoadingState.vue'
defineOptions({
name: 'AuditSpreadsheetRuleDetail'
})
defineProps({
selectedSkill: { type: Object, required: true },
selectedSpreadsheetModeLabel: { type: String, default: '' },
selectedSpreadsheetFileName: { type: String, default: '' },
selectedSpreadsheetChangeRecords: { type: Array, default: () => [] },
spreadsheetOnlyOfficeHostId: { type: String, required: true },
spreadsheetOnlyOfficeReady: { type: Boolean, default: false },
spreadsheetOnlyOfficeError: { type: String, default: '' },
spreadsheetOnlyOfficeLoading: { type: Boolean, default: false },
canEditSpreadsheetInline: { type: Boolean, default: false }
})
const emit = defineEmits(['file-change', 'open-spreadsheet-change-detail'])
const fileInput = ref(null)
function click() {
fileInput.value?.click()
}
function reset() {
if (fileInput.value) {
fileInput.value.value = ''
}
}
defineExpose({
click,
reset
})
</script>
<style scoped src="../../assets/styles/views/audit-view.css"></style>
<style scoped src="../../assets/styles/views/audit-view-part2.css"></style>

View File

@@ -0,0 +1,71 @@
<template>
<Transition name="drawer-fade">
<div v-if="open" class="rule-drawer-backdrop" @click.self="emit('close')">
<aside class="rule-drawer timeline-drawer">
<header class="rule-drawer-head">
<div>
<span>修改记录</span>
<h3>文档操作记录</h3>
</div>
<button type="button" @click="emit('close')">
<i class="mdi mdi-close"></i>
</button>
</header>
<TableLoadingState
v-if="loading"
class="rule-drawer-state"
variant="drawer"
message="正在加载操作记录"
icon="mdi mdi-history"
:show-skeleton="false"
/>
<div v-else-if="error" class="rule-drawer-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<span>{{ error }}</span>
</div>
<div v-else-if="items.length" class="rule-timeline-list">
<article
v-for="item in items"
:key="`${item.event_type}-${item.version}-${item.event_time}`"
class="rule-timeline-item"
>
<i :class="[item.meta.icon, item.meta.tone]"></i>
<div>
<header>
<strong>{{ item.meta.label }}</strong>
<span>{{ item.timeLabel }}</span>
</header>
<p>{{ item.description || item.note || '暂无补充说明' }}</p>
<small>操作人{{ item.actor }}</small>
</div>
</article>
</div>
<div v-else class="rule-drawer-state">
<i class="mdi mdi-history"></i>
<span>暂无操作记录</span>
</div>
</aside>
</div>
</Transition>
</template>
<script setup>
import TableLoadingState from '../shared/TableLoadingState.vue'
defineOptions({
name: 'AuditVersionTimelineDrawer'
})
defineProps({
open: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
error: { type: String, default: '' },
items: { type: Array, default: () => [] }
})
const emit = defineEmits(['close'])
</script>
<style scoped src="../../assets/styles/views/audit-view.css"></style>
<style scoped src="../../assets/styles/views/audit-view-part2.css"></style>

View File

@@ -347,8 +347,8 @@ const todoItems = [
suggestion: '补充客户单位、客户人数、我方陪同人员',
action: '去补充',
iconKey: 'hospitality',
color: '#0d9668',
accent: '#6ee7b7'
color: 'var(--theme-primary-active)',
accent: 'var(--theme-primary-soft-strong)'
},
{
title: '差旅报销单待提交',
@@ -356,8 +356,8 @@ const todoItems = [
suggestion: '补齐出发交通,可直接生成报销单',
action: '继续填写',
iconKey: 'travelDraft',
color: '#15803d',
accent: '#86efac'
color: 'var(--success-hover)',
accent: 'var(--success-line)'
},
{
title: '有 5 张票据未关联报销单',
@@ -365,8 +365,8 @@ const todoItems = [
suggestion: '其中 3 张疑似交通费,可合并生成交通报销',
action: '去整理',
iconKey: 'receipts',
color: '#2563eb',
accent: '#93c5fd'
color: 'var(--chart-blue)',
accent: 'var(--theme-primary-soft-strong)'
}
]
@@ -381,8 +381,8 @@ const progressItems = [
status: '主管审批中',
tone: 'success',
iconKey: 'flight',
color: '#0d9668',
accent: '#6ee7b7'
color: 'var(--theme-primary-active)',
accent: 'var(--theme-primary-soft-strong)'
},
{
id: 'transport',
@@ -392,8 +392,8 @@ const progressItems = [
status: '财务复核中',
tone: 'info',
iconKey: 'transport',
color: '#2563eb',
accent: '#93c5fd'
color: 'var(--chart-blue)',
accent: 'var(--theme-primary-soft-strong)'
},
{
id: 'office',
@@ -403,8 +403,8 @@ const progressItems = [
status: '已到账',
tone: 'mint',
iconKey: 'procurement',
color: '#059669',
accent: '#a7f3d0'
color: 'var(--success)',
accent: 'var(--success-line)'
}
]
@@ -453,710 +453,4 @@ watch(
)
</script>
<style scoped>
.workbench {
min-width: 0;
display: grid;
gap: 16px;
padding-bottom: 10px;
}
.assistant-hero {
position: relative;
overflow: hidden;
display: grid;
grid-template-columns: 228px minmax(0, 1fr);
gap: 18px;
padding: 20px 24px 20px 18px;
border: 1px solid rgba(16, 185, 129, 0.12);
background:
radial-gradient(circle at top left, rgba(16, 185, 129, 0.12), transparent 34%),
radial-gradient(circle at right 20%, rgba(59, 130, 246, 0.07), transparent 28%),
linear-gradient(135deg, #f7fffb 0%, #ffffff 48%, #f5fbff 100%);
}
.assistant-hero::before,
.assistant-hero::after {
content: "";
position: absolute;
border-radius: 999px;
background: rgba(16, 185, 129, 0.06);
pointer-events: none;
}
.assistant-hero::before {
right: -48px;
bottom: -58px;
width: 220px;
height: 220px;
}
.assistant-hero::after {
right: 92px;
top: -44px;
width: 140px;
height: 140px;
}
.assistant-visual {
position: relative;
min-height: 196px;
display: flex;
align-items: flex-end;
justify-content: flex-start;
padding: 0 0 10px 8px;
}
.assistant-visual::before {
content: "";
position: absolute;
inset: auto auto -78px -58px;
width: 264px;
height: 228px;
border-radius: 50%;
background: radial-gradient(circle at 48% 38%, rgba(255, 255, 255, 0.92) 0%, rgba(220, 252, 231, 0.84) 58%, rgba(220, 252, 231, 0) 100%);
pointer-events: none;
}
.assistant-visual::after {
content: "";
position: absolute;
left: 52px;
bottom: 18px;
width: 132px;
height: 18px;
border-radius: 999px;
background: rgba(16, 185, 129, 0.14);
filter: blur(12px);
pointer-events: none;
}
.assistant-glow {
position: absolute;
left: 24px;
bottom: 22px;
width: 176px;
height: 176px;
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.98) 0%, rgba(236, 253, 245, 0.9) 58%, rgba(236, 253, 245, 0) 100%);
box-shadow: 0 24px 48px rgba(16, 185, 129, 0.12);
pointer-events: none;
}
.assistant-image {
position: relative;
z-index: 1;
width: 184px;
max-width: 100%;
height: auto;
object-fit: contain;
object-position: left bottom;
filter: drop-shadow(0 22px 28px rgba(15, 23, 42, 0.16));
}
.assistant-copy {
position: relative;
z-index: 1;
display: grid;
gap: 10px;
align-content: center;
}
.assistant-copy h3 {
color: #0f172a;
font-size: 26px;
line-height: 1.25;
font-weight: 800;
}
.assistant-copy p {
max-width: 760px;
color: #5b6b83;
font-size: 14px;
line-height: 1.6;
}
.assistant-input {
display: flex;
align-items: center;
min-height: 48px;
padding: 4px 14px;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 12px;
background: rgba(255, 255, 255, 0.92);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
}
.assistant-file-input {
display: none;
}
.assistant-input textarea {
min-width: 0;
flex: 1;
height: 22px;
min-height: 22px;
max-height: 22px;
resize: none;
border: 0;
padding: 1px 0;
background: transparent;
color: #0f172a;
font-size: 15px;
line-height: 22px;
overflow: hidden;
}
.assistant-input textarea::placeholder {
color: #94a3b8;
}
.assistant-input textarea:focus {
outline: none;
}
.hero-action,
.secondary-action,
.ghost-action,
.row-action,
.link-action,
.row-link {
border: 0;
background: transparent;
}
.hero-action {
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 16px;
border-radius: 10px;
background: linear-gradient(135deg, #10b981, #059669);
color: #fff;
font-size: 14px;
font-weight: 800;
white-space: nowrap;
box-shadow: 0 10px 22px rgba(16, 185, 129, 0.18);
}
.hero-action .mdi,
.secondary-action .mdi,
.ghost-action .mdi {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
line-height: 1;
}
.hero-action span,
.secondary-action span,
.ghost-action span {
display: inline-flex;
align-items: center;
line-height: 1;
}
.assistant-tools {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.assistant-file-strip {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.assistant-file-note,
.assistant-file-chip {
display: inline-flex;
align-items: center;
min-height: 30px;
padding: 0 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
}
.assistant-file-note {
background: rgba(16, 185, 129, 0.1);
color: #047857;
}
.assistant-file-chip {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border: 1px solid rgba(148, 163, 184, 0.24);
background: rgba(255, 255, 255, 0.9);
color: #475569;
}
.assistant-file-clear {
border: 0;
background: transparent;
color: #64748b;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.ghost-action {
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 16px;
border: 1px solid rgba(15, 118, 110, 0.18);
border-radius: 10px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(244, 250, 247, 0.88));
color: #0f766e;
font-size: 14px;
font-weight: 700;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
0 6px 14px rgba(15, 118, 110, 0.06);
}
.ghost-action .mdi {
color: #10b981;
}
.secondary-action {
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 16px;
border: 1px solid rgba(59, 130, 246, 0.18);
border-radius: 10px;
background: linear-gradient(180deg, rgba(244, 249, 255, 0.96), rgba(234, 244, 255, 0.9));
color: #1d4ed8;
font-size: 14px;
font-weight: 700;
white-space: nowrap;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.92),
0 6px 14px rgba(37, 99, 235, 0.08);
}
.secondary-action .mdi {
color: #2563eb;
}
.hero-action:disabled,
.secondary-action:disabled,
.ghost-action:disabled {
cursor: not-allowed;
opacity: 0.68;
box-shadow: none;
}
.workbench-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.list-panel,
.policy-panel {
padding: 20px 22px;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.section-head h3 {
color: #0f172a;
font-size: 17px;
font-weight: 700;
}
.title-with-badge {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.alert-badge {
min-width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 7px;
border-radius: 999px;
background: #ef4444;
color: #fff;
font-size: 12px;
font-weight: 800;
line-height: 1;
box-shadow: 0 6px 14px rgba(239, 68, 68, 0.22);
}
.link-action {
display: inline-flex;
align-items: center;
gap: 4px;
color: #10b981;
font-size: 14px;
font-weight: 700;
}
.list-body {
display: grid;
}
.todo-row,
.progress-row {
display: grid;
grid-template-columns: 56px minmax(0, 1fr) auto;
gap: 14px;
align-items: center;
padding: 14px 0;
border-top: 1px solid #edf2f7;
}
.todo-row:first-child,
.progress-row:first-child {
padding-top: 4px;
border-top: 0;
}
.todo-copy {
min-width: 0;
}
.todo-copy strong {
display: block;
color: #0f172a;
font-size: 15px;
font-weight: 700;
line-height: 1.4;
}
.todo-copy p {
margin-top: 4px;
color: #6b7280;
font-size: 14px;
line-height: 1.5;
}
.todo-advice {
display: flex;
align-items: flex-start;
gap: 8px;
flex-wrap: wrap;
}
.todo-advice-label {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 8px;
border-radius: 999px;
background: #ecfdf5;
color: #059669;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.todo-advice-text {
color: #64748b;
}
.row-action {
height: 38px;
padding: 0 16px;
border: 1px solid rgba(16, 185, 129, 0.36);
border-radius: 10px;
color: #10b981;
font-size: 14px;
font-weight: 700;
white-space: nowrap;
}
.progress-row {
grid-template-columns: 56px minmax(0, 1fr) minmax(84px, auto) minmax(104px, auto);
gap: 14px 16px;
}
.progress-copy strong {
margin-bottom: 2px;
}
.progress-amount {
color: #0f172a;
font-size: 20px;
font-weight: 800;
line-height: 1;
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.progress-status {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 104px;
min-height: 34px;
padding: 6px 14px;
border-radius: 999px;
font-size: 13px;
font-weight: 800;
white-space: nowrap;
justify-self: end;
}
.progress-status.success,
.policy-status.success {
background: #eafaf2;
color: #16935f;
}
.progress-status.info,
.policy-status.info {
background: #eff6ff;
color: #3b82f6;
}
.progress-status.mint {
background: #edfdf5;
color: #10b981;
}
.policy-table {
border: 1px solid #e7edf5;
border-radius: 12px;
overflow: hidden;
}
.policy-row {
display: grid;
grid-template-columns: 2.2fr 2.4fr 1fr;
gap: 16px;
align-items: center;
min-height: 56px;
padding: 0 18px;
border-top: 1px solid #edf2f7;
}
.policy-head {
min-height: 44px;
background: #f8fbff;
color: #64748b;
font-size: 12px;
font-weight: 800;
border-top: 0;
}
.policy-row strong,
.policy-row span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.policy-row strong {
color: #0f172a;
font-size: 14px;
font-weight: 700;
}
.policy-row span {
color: #64748b;
font-size: 14px;
}
.policy-title-cell,
.policy-summary-cell {
justify-self: stretch;
text-align: left;
}
.policy-date-cell {
justify-self: center;
text-align: center;
}
@media (max-width: 1320px) {
.assistant-copy h3 {
font-size: 24px;
}
.policy-row {
grid-template-columns: 1.8fr 1.8fr 1fr;
}
}
@media (max-width: 1440px) {
.workbench {
gap: 14px;
}
.assistant-hero {
gap: 16px;
padding: 18px 20px 18px 16px;
}
.assistant-copy h3 {
font-size: 24px;
}
.assistant-visual {
min-height: 184px;
}
.assistant-image {
width: 172px;
}
.workbench-grid {
gap: 16px;
}
.list-panel,
.policy-panel {
padding: 18px 20px;
}
.policy-row {
min-height: 52px;
padding: 0 16px;
}
}
@media (max-width: 1080px) {
.assistant-hero {
grid-template-columns: 1fr;
gap: 8px;
}
.assistant-visual {
min-height: 188px;
justify-content: center;
padding: 0 0 8px;
}
.assistant-visual::before,
.assistant-visual::after,
.assistant-glow {
left: 50%;
transform: translateX(-50%);
}
.assistant-visual::before {
inset: auto auto -82px 50%;
}
.assistant-image {
width: 176px;
}
.workbench-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 860px) {
.assistant-hero,
.list-panel,
.policy-panel {
padding: 18px;
}
.assistant-input {
flex-direction: column;
align-items: stretch;
padding: 14px;
}
.assistant-visual {
min-height: 160px;
}
.assistant-glow {
width: 148px;
height: 148px;
}
.assistant-image {
width: 150px;
}
.assistant-input textarea {
height: 40px;
min-height: 40px;
max-height: 40px;
line-height: 1.5;
}
.hero-action,
.secondary-action,
.ghost-action,
.row-action {
width: 100%;
justify-content: center;
}
.assistant-file-chip {
max-width: 100%;
}
.todo-row,
.progress-row {
grid-template-columns: 56px minmax(0, 1fr);
}
.progress-amount {
grid-column: 2;
text-align: left;
font-size: 18px;
}
.row-action,
.progress-status {
grid-column: 2;
justify-self: start;
}
.policy-table {
border: 0;
border-radius: 0;
}
.policy-head {
display: none;
}
.policy-row {
grid-template-columns: 1fr;
gap: 8px;
padding: 16px 0;
border-top: 1px solid #edf2f7;
}
.policy-row strong,
.policy-row span {
white-space: normal;
}
}
</style>
<style scoped src="../../assets/styles/components/personal-workbench.css"></style>

View File

@@ -28,12 +28,22 @@ import {
BarElement,
Tooltip
} from 'chart.js'
import { resolveCssColor, useThemeColors } from '../../composables/useThemeColors.js'
ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip)
const props = defineProps({
items: { type: Array, required: true }
})
const themeColors = useThemeColors()
const resolvedItems = computed(() => {
const fallback = themeColors.value.chartPrimary
return props.items.map((item) => ({
...item,
resolvedColor: resolveCssColor(item.color, fallback)
}))
})
const medalClass = (idx) => {
if (idx === 0) return 'gold'
@@ -56,10 +66,10 @@ const formatValue = (value) => {
}
const chartData = computed(() => ({
labels: props.items.map((i) => i.name || i.shortName),
labels: resolvedItems.value.map((i) => i.name || i.shortName),
datasets: [{
data: props.items.map((i) => i.value || i.amount),
backgroundColor: props.items.map((i) => i.color),
data: resolvedItems.value.map((i) => i.value || i.amount),
backgroundColor: resolvedItems.value.map((i) => i.resolvedColor),
borderRadius: 6,
borderSkipped: false,
barPercentage: 0.7,
@@ -169,4 +179,4 @@ const chartOptions = {
position: relative;
height: 240px;
}
</style>
</style>

View File

@@ -16,6 +16,7 @@ import {
Tooltip
} from 'chart.js'
import { useAnimationProgress } from '../../composables/useAnimationProgress.js'
import { useThemeColors } from '../../composables/useThemeColors.js'
ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip, Legend)
@@ -34,6 +35,7 @@ const progress = useAnimationProgress([
() => props.occupied,
() => props.available
], 1000)
const themeColors = useThemeColors()
const currency = (value) =>
Number(value || 0).toLocaleString('zh-CN', {
@@ -77,7 +79,7 @@ const chartData = computed(() => ({
{
label: '已使用',
data: scaleSeries(usedPercent.value),
backgroundColor: '#13a66b',
backgroundColor: themeColors.value.chartPrimary,
borderRadius: 5,
borderSkipped: false,
stack: 'budgetUsage',
@@ -86,7 +88,7 @@ const chartData = computed(() => ({
{
label: '已占用',
data: scaleSeries(occupiedPercent.value),
backgroundColor: '#f59e0b',
backgroundColor: themeColors.value.warning,
borderRadius: 5,
borderSkipped: false,
stack: 'budgetUsage',

View File

@@ -9,7 +9,7 @@
</div>
<div class="donut-legend">
<div v-for="item in items" :key="item.name" class="legend-row">
<i :style="{ background: item.color }"></i>
<i :style="{ background: item.resolvedColor }"></i>
<span class="legend-name">{{ item.name }}</span>
<span class="legend-val">{{ item.display }}</span>
</div>
@@ -27,6 +27,7 @@ import {
Legend
} from 'chart.js'
import { useAnimationProgress } from '../../composables/useAnimationProgress.js'
import { resolveCssColor, useThemeColors } from '../../composables/useThemeColors.js'
ChartJS.register(ArcElement, Tooltip, Legend)
@@ -37,12 +38,21 @@ const props = defineProps({
})
const progress = useAnimationProgress([() => props.items], 1150)
const themeColors = useThemeColors()
const resolvedItems = computed(() => {
const fallback = themeColors.value.chartPrimary
return props.items.map((item) => ({
...item,
resolvedColor: resolveCssColor(item.color, fallback)
}))
})
const chartData = computed(() => ({
labels: props.items.map((i) => i.name),
labels: resolvedItems.value.map((i) => i.name),
datasets: [{
data: props.items.map((i) => Math.max(Number((i.value * progress.value).toFixed(1)), 0.001)),
backgroundColor: props.items.map((i) => i.color),
data: resolvedItems.value.map((i) => Math.max(Number((i.value * progress.value).toFixed(1)), 0.001)),
backgroundColor: resolvedItems.value.map((i) => i.resolvedColor),
borderWidth: 0,
cutout: '68%',
spacing: 3,

View File

@@ -33,6 +33,7 @@ import {
Tooltip
} from 'chart.js'
import { useAnimationProgress } from '../../composables/useAnimationProgress.js'
import { useThemeColors } from '../../composables/useThemeColors.js'
ChartJS.register(ArcElement, Tooltip)
@@ -46,12 +47,13 @@ const props = defineProps({
const ratioValue = computed(() => Number(props.ratio))
const progress = useAnimationProgress([() => props.ratio], 1150)
const animatedRatio = computed(() => Number((ratioValue.value * progress.value).toFixed(0)))
const themeColors = useThemeColors()
const chartData = computed(() => ({
labels: ['已执行', '剩余'],
datasets: [{
data: [animatedRatio.value, 100 - animatedRatio.value],
backgroundColor: ['#10b981', '#e2e8f0'],
backgroundColor: [themeColors.value.chartPrimary, '#e2e8f0'],
borderWidth: 0
}]
}))
@@ -101,7 +103,7 @@ const chartOptions = {
}
.gauge-center strong {
color: #10b981;
color: var(--chart-primary);
font-size: 22px;
font-weight: 700;
line-height: 1;

View File

@@ -1,8 +1,8 @@
<template>
<div class="log-trend-chart">
<div class="chart-legend">
<span><i style="background:#10b981"></i>日志总量</span>
<span><i style="background:#ef4444"></i>失败数</span>
<span><i :style="{ background: chartColors.primary }"></i>日志总量</span>
<span><i :style="{ background: chartColors.danger }"></i>失败数</span>
</div>
<div class="chart-body">
<Bar :data="chartData" :options="chartOptions" />
@@ -24,6 +24,7 @@ import {
Legend
} from 'chart.js'
import { useAnimationProgress } from '../../composables/useAnimationProgress.js'
import { useThemeColors } from '../../composables/useThemeColors.js'
ChartJS.register(CategoryScale, LinearScale, BarElement, PointElement, LineElement, Tooltip, Legend)
@@ -38,6 +39,11 @@ const progress = useAnimationProgress([
() => props.totals,
() => props.failures
], 1000)
const themeColors = useThemeColors()
const chartColors = computed(() => ({
primary: themeColors.value.chartPrimary,
danger: themeColors.value.chartDanger
}))
const scaleSeries = (series) =>
series.map((value) => Math.round(Number(value || 0) * progress.value))
@@ -50,7 +56,7 @@ const chartData = computed(() => ({
{
label: '日志总量',
data: scaleSeries(props.totals),
backgroundColor: '#10b981',
backgroundColor: chartColors.value.primary,
borderRadius: 4,
barPercentage: 0.58,
categoryPercentage: 0.56,
@@ -59,11 +65,11 @@ const chartData = computed(() => ({
{
label: '失败数',
data: scaleSeries(props.failures),
borderColor: '#ef4444',
borderColor: chartColors.value.danger,
backgroundColor: 'transparent',
borderWidth: 2,
pointBackgroundColor: '#ffffff',
pointBorderColor: '#ef4444',
pointBorderColor: chartColors.value.danger,
pointBorderWidth: 2,
pointRadius: 3,
pointHoverRadius: 5,

View File

@@ -1,9 +1,9 @@
<template>
<div class="trend-chart">
<div class="chart-legend">
<span><i style="background:#10b981"></i>申请量</span>
<span><i style="background:#3b82f6"></i>审批完成量</span>
<span><i style="background:#8b5cf6"></i>平均审批时长小时</span>
<span><i :style="{ background: chartColors.primary }"></i>申请量</span>
<span><i :style="{ background: chartColors.blue }"></i>审批完成量</span>
<span><i :style="{ background: chartColors.purple }"></i>平均审批时长小时</span>
</div>
<div class="chart-body">
<Bar :data="chartData" :options="chartOptions" />
@@ -26,6 +26,7 @@ import {
Legend
} from 'chart.js'
import { useAnimationProgress } from '../../composables/useAnimationProgress.js'
import { useThemeColors } from '../../composables/useThemeColors.js'
ChartJS.register(CategoryScale, LinearScale, BarElement, PointElement, LineElement, Filler, Tooltip, Legend)
@@ -42,6 +43,12 @@ const progress = useAnimationProgress([
() => props.approved,
() => props.avgHours
], 1200)
const themeColors = useThemeColors()
const chartColors = computed(() => ({
primary: themeColors.value.chartPrimary,
blue: themeColors.value.chartBlue,
purple: themeColors.value.chartPurple
}))
const scaleSeries = (series, decimals = 0) =>
series.map((value) => Number((Number(value) * progress.value).toFixed(decimals)))
@@ -52,7 +59,7 @@ const chartData = computed(() => ({
{
label: '申请量(单)',
data: scaleSeries(props.applications),
backgroundColor: '#10b981',
backgroundColor: chartColors.value.primary,
borderRadius: 4,
barPercentage: 0.6,
categoryPercentage: 0.5,
@@ -61,7 +68,7 @@ const chartData = computed(() => ({
{
label: '审批完成量(单)',
data: scaleSeries(props.approved),
backgroundColor: '#3b82f6',
backgroundColor: chartColors.value.blue,
borderRadius: 4,
barPercentage: 0.6,
categoryPercentage: 0.5,
@@ -70,11 +77,11 @@ const chartData = computed(() => ({
{
label: '平均审批时长(小时)',
data: scaleSeries(props.avgHours, 1),
borderColor: '#8b5cf6',
borderColor: chartColors.value.purple,
backgroundColor: 'transparent',
borderWidth: 2,
pointBackgroundColor: '#ffffff',
pointBorderColor: '#8b5cf6',
pointBorderColor: chartColors.value.purple,
pointBorderWidth: 2,
pointRadius: 3,
pointHoverRadius: 5,

View File

@@ -1,41 +1,57 @@
<template>
<section class="doc-filters" aria-label="单据筛选">
<div class="filter-item">
<label class="filter-item">
<span class="filter-label">申请月份</span>
<Dropdown v-model="docFilters.month" :options="docMonths" placeholder="选择月份" appendTo="body" class="filter-dropdown">
<template #option="{ option }">
{{ formatMonth(option) }}
</template>
<template #value="{ value }">
{{ formatMonth(value) }}
</template>
</Dropdown>
</div>
<div class="filter-item">
<EnterpriseSelect
v-model="docFilters.month"
:options="monthOptions"
placeholder="选择月份"
/>
</label>
<label class="filter-item">
<span class="filter-label">申请类型</span>
<Dropdown v-model="docFilters.type" :options="docTypes" placeholder="选择类型" appendTo="body" class="filter-dropdown" />
</div>
<div class="filter-item">
<EnterpriseSelect
v-model="docFilters.type"
:options="docTypes"
placeholder="选择类型"
/>
</label>
<label class="filter-item">
<span class="filter-label">单据状态</span>
<Dropdown v-model="docFilters.status" :options="docStatuses" placeholder="选择状态" appendTo="body" class="filter-dropdown" />
</div>
<EnterpriseSelect
v-model="docFilters.status"
:options="docStatuses"
placeholder="选择状态"
/>
</label>
</section>
</template>
<script setup>
import Dropdown from 'primevue/dropdown'
import { computed } from 'vue'
defineProps({
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
const props = defineProps({
docFilters: { type: Object, required: true },
docMonths: { type: Array, required: true },
docTypes: { type: Array, required: true },
docStatuses: { type: Array, required: true }
})
function formatMonth(m) {
if (!m) return ''
const [y, mm] = m.split('-')
return `${y}${parseInt(mm)}`
const monthOptions = computed(() =>
props.docMonths.map((month) => ({
label: formatMonth(month),
value: month
}))
)
function formatMonth(month) {
if (!month) return ''
const [year, rawMonth] = String(month).split('-')
return `${year}${Number.parseInt(rawMonth, 10)}`
}
</script>
@@ -43,12 +59,27 @@ function formatMonth(m) {
.doc-filters {
display: grid;
grid-template-columns: repeat(3, minmax(180px, 1fr));
gap: 14px;
padding: 16px 28px;
gap: 12px;
padding: 14px 24px;
border-bottom: 1px solid var(--line);
background: var(--surface);
}
.filter-item { display: grid; gap: 6px; }
.filter-label { color: var(--muted); font-size: 12px; font-weight: 700; }
.filter-dropdown { width: 100%; }
.filter-item {
display: grid;
gap: 6px;
}
.filter-label {
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
@media (max-width: 760px) {
.doc-filters {
grid-template-columns: 1fr;
padding-inline: 16px;
}
}
</style>

View File

@@ -1,35 +1,34 @@
<template>
<section class="filters" :class="{ compact }" aria-label="筛选条件">
<template v-if="!compact">
<label>
<span>法人主体</span>
<select v-model="filters.entity">
<option>全部主体</option>
<option>Northstar China Ltd.</option>
<option>Northstar Singapore Pte.</option>
<option>Northstar US Inc.</option>
</select>
</label>
<label>
<span>费用类型</span>
<select v-model="filters.category">
<option>全部费用</option>
<option>机票</option>
<option>酒店</option>
<option>火车/用车</option>
<option>餐补及杂费</option>
</select>
</label>
<label>
<span>风险等级</span>
<select v-model="filters.risk">
<option>全部风险</option>
<option>高风险</option>
<option>需解释</option>
<option>低风险</option>
</select>
<label class="filter-field">
<span>法人主体</span>
<EnterpriseSelect
v-model="filters.entity"
:options="entityOptions"
placeholder="选择主体"
/>
</label>
<label class="filter-field">
<span>费用类型</span>
<EnterpriseSelect
v-model="filters.category"
:options="categoryOptions"
placeholder="选择费用"
/>
</label>
<label class="filter-field">
<span>风险等级</span>
<EnterpriseSelect
v-model="filters.risk"
:options="riskOptions"
placeholder="选择风险"
/>
</label>
</template>
<div class="segmented-wrap" :class="{ compact }">
<span v-if="!compact">时间范围</span>
<div class="segmented" role="tablist" aria-label="处理视图">
@@ -48,6 +47,8 @@
</template>
<script setup>
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
defineProps({
filters: { type: Object, required: true },
ranges: { type: Array, required: true },
@@ -56,13 +57,35 @@ defineProps({
})
const emit = defineEmits(['update:activeRange'])
const entityOptions = [
'全部主体',
'Northstar China Ltd.',
'Northstar Singapore Pte.',
'Northstar US Inc.'
]
const categoryOptions = [
'全部费用',
'机票',
'酒店',
'火车/用车',
'餐补及杂费'
]
const riskOptions = [
'全部风险',
'高风险',
'需解释',
'低风险'
]
</script>
<style scoped>
.filters {
display: grid;
grid-template-columns: repeat(3, minmax(160px, 1fr)) auto;
gap: 14px;
gap: 12px;
padding: 0 16px 12px;
border-bottom: 1px solid var(--line);
background: #fff;
@@ -74,7 +97,7 @@ const emit = defineEmits(['update:activeRange'])
padding: 8px 16px;
}
.filters label,
.filter-field,
.segmented-wrap {
display: grid;
gap: 6px;
@@ -83,15 +106,6 @@ const emit = defineEmits(['update:activeRange'])
font-weight: 700;
}
.filters select {
height: 40px;
padding: 0 12px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #fff;
color: var(--ink);
}
.segmented-wrap {
justify-self: end;
}
@@ -99,38 +113,40 @@ const emit = defineEmits(['update:activeRange'])
.segmented {
align-self: end;
display: inline-flex;
gap: 0;
min-height: 40px;
padding: 3px;
border-radius: 10px;
background: #f1f5f9;
min-height: 36px;
padding: 2px;
border: 1px solid #d8dee8;
border-radius: 4px;
background: #f8fafc;
}
.segmented button {
position: relative;
min-height: 34px;
padding: 0 20px;
min-height: 30px;
padding: 0 16px;
border: none;
border-radius: 8px;
border-radius: 3px;
background: transparent;
color: #64748b;
font-size: 14px;
font-weight: 500;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
transition:
background 160ms var(--ease),
color 160ms var(--ease),
box-shadow 160ms var(--ease);
white-space: nowrap;
}
.segmented button:hover:not(.active) {
color: #334155;
background: rgba(255, 255, 255, 0.5);
background: #fff;
}
.segmented button.active {
background: #fff;
color: #1e293b;
font-weight: 600;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
color: var(--primary-active);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
}
@media (max-width: 980px) {

View File

@@ -244,514 +244,4 @@ onBeforeUnmount(() => {
</script>
<style scoped>
.rail {
--rail-motion-duration: 320ms;
--rail-motion-ease: cubic-bezier(0.22, 1, 0.36, 1);
--rail-fade-duration: 160ms;
position: sticky;
top: 0;
width: 100%;
height: var(--desktop-stage-height, 100dvh);
min-height: var(--desktop-stage-height, 100dvh);
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 251, 250, 0.96)), #fff;
border-right: 1px solid #dbe4ee;
box-shadow: 1px 0 0 rgba(15, 23, 42, 0.02);
z-index: 20;
}
.rail-brand {
position: relative;
min-height: 92px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
padding: 22px 16px 18px;
overflow: hidden;
transition:
padding var(--rail-motion-duration) var(--rail-motion-ease),
gap var(--rail-motion-duration) var(--rail-motion-ease);
}
.brand-mark {
flex: 0 0 auto;
width: 30px;
height: 30px;
display: grid;
place-items: center;
color: #07936f;
border-radius: 6px;
overflow: hidden;
transition:
width var(--rail-motion-duration) var(--rail-motion-ease),
height var(--rail-motion-duration) var(--rail-motion-ease),
margin var(--rail-motion-duration) var(--rail-motion-ease);
}
.custom-logo {
width: 100%;
height: 100%;
object-fit: contain;
}
.brand-mark svg {
width: 30px;
height: 30px;
fill: currentColor;
}
.brand-name {
flex: 1 1 auto;
min-width: 0;
max-width: 124px;
color: #0f172a;
font-size: 16px;
font-weight: 800;
white-space: nowrap;
overflow: hidden;
transition:
max-width var(--rail-motion-duration) var(--rail-motion-ease),
opacity var(--rail-fade-duration) var(--rail-motion-ease),
transform var(--rail-fade-duration) var(--rail-motion-ease);
}
.rail-collapse-btn {
position: absolute;
right: 16px;
top: 31px;
z-index: 2;
width: 30px;
height: 30px;
display: inline-grid;
place-items: center;
cursor: pointer;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 9px;
background: rgba(255, 255, 255, 0.86);
color: #64748b;
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.06);
transition:
top var(--rail-motion-duration) var(--rail-motion-ease),
right var(--rail-motion-duration) var(--rail-motion-ease),
transform var(--rail-motion-duration) var(--rail-motion-ease),
background 180ms var(--ease),
border-color 180ms var(--ease),
color 180ms var(--ease),
box-shadow 180ms var(--ease);
}
.rail-collapse-btn:hover {
border-color: rgba(16, 185, 129, 0.28);
background: #ecfdf5;
color: #059669;
}
.rail-collapse-btn .mdi {
font-size: 18px;
line-height: 1;
}
.rail-nav {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px 20px;
overflow-y: auto;
overflow-x: hidden;
flex: 1;
transition:
padding var(--rail-motion-duration) var(--rail-motion-ease),
gap var(--rail-motion-duration) var(--rail-motion-ease);
}
.nav-btn {
width: 100%;
min-height: 48px;
position: relative;
display: flex;
align-items: center;
gap: 12px;
padding: 0 12px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: #64748b;
text-align: left;
overflow: hidden;
transition:
padding var(--rail-motion-duration) var(--rail-motion-ease),
gap var(--rail-motion-duration) var(--rail-motion-ease),
background 180ms var(--ease),
border-color 180ms var(--ease),
color 180ms var(--ease);
}
.nav-btn:hover {
background: rgba(16, 185, 129, 0.07);
color: #0f9f78;
}
.nav-btn.active {
background: #ecfdf5;
border-color: rgba(16, 185, 129, 0.12);
color: #059669;
}
.nav-icon {
flex: 0 0 28px;
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: 7px;
color: currentColor;
transition:
flex-basis var(--rail-motion-duration) var(--rail-motion-ease),
width var(--rail-motion-duration) var(--rail-motion-ease),
height var(--rail-motion-duration) var(--rail-motion-ease);
}
.nav-btn :deep(svg) {
width: 19px;
height: 19px;
stroke: currentColor;
stroke-width: 2;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.nav-label {
flex: 1;
min-width: 0;
max-width: 128px;
color: currentColor;
font-size: 14px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
opacity: 1;
transition:
max-width var(--rail-motion-duration) var(--rail-motion-ease),
opacity var(--rail-fade-duration) var(--rail-motion-ease),
transform var(--rail-fade-duration) var(--rail-motion-ease);
}
.nav-badge {
flex: 0 0 auto;
min-width: 34px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 8px;
border-radius: 999px;
background: #ff5b67;
color: #fff;
font-size: 12px;
font-weight: 800;
transition:
min-width var(--rail-motion-duration) var(--rail-motion-ease),
max-width var(--rail-motion-duration) var(--rail-motion-ease),
padding var(--rail-motion-duration) var(--rail-motion-ease),
opacity var(--rail-fade-duration) var(--rail-motion-ease);
}
.nav-unread-dot {
flex: 0 0 auto;
width: 8px;
height: 8px;
border: 2px solid #fff;
border-radius: 999px;
background: #ef4444;
box-shadow: 0 6px 14px rgba(239, 68, 68, 0.26);
}
.rail-user {
position: relative;
min-width: 0;
min-height: 78px;
margin: 0;
padding: 16px 20px 18px;
border-top: 1px solid #edf2f7;
transition: padding var(--rail-motion-duration) var(--rail-motion-ease);
}
.user-summary {
position: relative;
min-width: 0;
min-height: 42px;
display: flex;
align-items: center;
gap: 10px;
padding: 4px;
color: #64748b;
border-radius: 12px;
cursor: pointer;
transition:
gap var(--rail-motion-duration) var(--rail-motion-ease),
padding var(--rail-motion-duration) var(--rail-motion-ease),
background 180ms var(--ease);
}
.rail-user:hover .user-summary {
background: rgba(255, 255, 255, 0.72);
}
.user-avatar {
flex: 0 0 36px;
width: 36px;
height: 36px;
display: grid;
place-items: center;
border: 2px solid #fff;
border-radius: 999px;
background: linear-gradient(135deg, #0f9f78, #65d6b4);
box-shadow: 0 6px 14px rgba(15, 159, 120, 0.18);
color: #fff;
font-size: 14px;
font-weight: 800;
transition:
flex-basis var(--rail-motion-duration) var(--rail-motion-ease),
width var(--rail-motion-duration) var(--rail-motion-ease),
height var(--rail-motion-duration) var(--rail-motion-ease);
}
.user-copy {
flex: 1;
min-width: 0;
max-width: 116px;
display: flex;
flex-direction: column;
gap: 2px;
opacity: 1;
transition:
max-width var(--rail-motion-duration) var(--rail-motion-ease),
opacity var(--rail-fade-duration) var(--rail-motion-ease),
transform var(--rail-fade-duration) var(--rail-motion-ease);
}
.user-copy strong {
color: #334155;
font-size: 14px;
font-weight: 750;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-copy span {
color: #64748b;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-summary .mdi {
flex: 0 0 18px;
font-size: 18px;
transition:
max-width var(--rail-motion-duration) var(--rail-motion-ease),
opacity var(--rail-fade-duration) var(--rail-motion-ease);
}
.user-menu {
position: absolute;
right: 20px;
bottom: calc(100% - 6px);
min-width: 132px;
padding: 8px;
border: 1px solid rgba(226, 232, 240, 0.96);
border-radius: 12px;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.1);
opacity: 0;
transform: translateY(8px);
pointer-events: none;
transition: all 180ms var(--ease);
z-index: 4;
}
.rail-user:hover .user-menu {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.user-menu-item {
width: 100%;
height: 38px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border: 0;
border-radius: 9px;
background: transparent;
color: #dc2626;
font-size: 13px;
font-weight: 700;
transition: all 180ms var(--ease);
}
/* ========================================= */
/* COLLAPSED STATE */
/* ========================================= */
.rail-collapsed .rail-brand {
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 8px;
min-height: 0;
padding: 24px 8px 14px;
}
.rail-collapsed .brand-mark {
width: 36px;
height: 36px;
margin: 0;
}
.rail-collapsed .brand-mark svg {
width: 34px;
height: 34px;
}
.rail-collapsed .brand-name {
position: absolute;
width: 1px;
height: 1px;
max-width: 0;
margin: 0;
opacity: 0;
overflow: hidden;
clip: rect(0 0 0 0);
pointer-events: none;
}
.rail-collapsed .rail-collapse-btn {
position: static;
top: auto;
right: auto;
align-self: center;
width: 36px;
height: 32px;
transform: none;
}
.rail-collapsed .rail-nav {
gap: 10px;
padding: 12px 8px;
}
.rail-collapsed .nav-btn {
justify-content: center;
padding: 0;
gap: 0;
}
.rail-collapsed .nav-icon {
width: 32px;
height: 32px;
flex: 0 0 32px;
}
.rail-collapsed .nav-label {
max-width: 0;
opacity: 0;
transform: translateX(-6px);
}
.rail-collapsed .nav-badge {
max-width: 0;
min-width: 0;
padding: 0;
opacity: 0;
overflow: hidden;
}
.rail-collapsed .nav-unread-dot {
position: absolute;
top: 10px;
right: 11px;
width: 9px;
height: 9px;
}
.rail-collapsed {
overflow: visible;
}
.rail-collapsed .rail-user {
position: relative;
z-index: 6;
padding: 14px 8px;
overflow: visible;
}
.rail-collapsed .user-summary {
justify-content: center;
padding: 4px;
gap: 0;
}
.rail-user-menu-floating {
position: fixed;
z-index: 12000;
min-width: 132px;
padding: 8px;
border: 1px solid rgba(226, 232, 240, 0.96);
border-radius: 12px;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.14);
transform: translateY(-50%);
animation: railUserMenuIn 180ms var(--rail-motion-ease) both;
}
.rail-user-menu-floating .user-menu-item {
width: 100%;
}
@keyframes railUserMenuIn {
from {
opacity: 0;
transform: translateY(-50%) translateX(-6px);
}
to {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
}
.rail-collapsed .user-copy,
.rail-collapsed .user-summary .mdi {
max-width: 0;
opacity: 0;
overflow: hidden;
transform: translateX(-6px);
}
@media (max-width: 980px) {
.rail {
position: relative;
height: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.rail *,
.rail *::before,
.rail *::after {
transition: none !important;
}
}
</style>
<style scoped src="../../assets/styles/components/sidebar-rail.css"></style>

View File

@@ -262,7 +262,7 @@ const workbenchKpis = computed(() => {
unit: '笔',
meta: '本月累计',
trend: monthlyCount > 0 ? 'up' : 'down',
color: '#10b981'
color: 'var(--theme-primary)'
},
{
label: '本月报销总金额',
@@ -299,10 +299,10 @@ const requestKpis = computed(() => {
const completed = Number(summary.completed ?? 0)
return [
{ label: '全部单据', value: total, delta: '当前', trend: 'up', arrow: 'mdi mdi-minus', color: '#10b981' },
{ label: '全部单据', value: total, delta: '当前', trend: 'up', arrow: 'mdi mdi-minus', color: 'var(--theme-primary)' },
{ label: '草稿', value: draft, delta: '待提交', trend: draft > 0 ? 'down' : 'up', arrow: draft > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus', color: '#f59e0b' },
{ label: '审批中', value: inProgress, delta: '处理中', trend: inProgress > 0 ? 'up' : 'down', arrow: inProgress > 0 ? 'mdi mdi-arrow-up' : 'mdi mdi-minus', color: '#3b82f6' },
{ label: '已完成', value: completed, delta: '已归档', trend: 'up', arrow: 'mdi mdi-arrow-up' , color: '#10b981' }
{ label: '已完成', value: completed, delta: '已归档', trend: 'up', arrow: 'mdi mdi-arrow-up' , color: 'var(--success)' }
]
})
@@ -314,10 +314,10 @@ const documentKpis = computed(() => {
const archived = Number(summary.archived ?? 0)
return [
{ label: '全部单据', value: total, delta: '当前', trend: 'up', arrow: 'mdi mdi-minus', color: '#10b981' },
{ label: '全部单据', value: total, delta: '当前', trend: 'up', arrow: 'mdi mdi-minus', color: 'var(--theme-primary)' },
{ label: '待提交', value: toSubmit, delta: '草稿待办', trend: toSubmit > 0 ? 'down' : 'up', arrow: toSubmit > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus', color: '#f59e0b' },
{ label: '待我处理', value: toProcess, delta: '审批待办', trend: toProcess > 0 ? 'down' : 'up', arrow: toProcess > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus', color: '#3b82f6' },
{ label: '已归档', value: archived, delta: '归档入账', trend: 'up', arrow: 'mdi mdi-arrow-up', color: '#10b981' }
{ label: '已归档', value: archived, delta: '归档入账', trend: 'up', arrow: 'mdi mdi-arrow-up', color: 'var(--success)' }
]
})
@@ -329,25 +329,25 @@ const logsKpis = computed(() => {
const failed = Number(summary.failed ?? 0)
return [
{ label: 'Hermes 总任务', value: total, unit: '条', meta: '当前', trend: 'up', color: '#10b981' },
{ label: 'Hermes 总任务', value: total, unit: '条', meta: '当前', trend: 'up', color: 'var(--theme-primary)' },
{ label: '运行中', value: running, unit: '条', meta: running > 0 ? '实时执行' : '暂无执行', trend: running > 0 ? 'up' : 'down', color: '#3b82f6' },
{ label: '已完成', value: completed, unit: '条', meta: total ? `占比 ${Math.round((completed / total) * 100)}%` : '等待数据', trend: 'up', color: '#10b981' },
{ label: '已完成', value: completed, unit: '条', meta: total ? `占比 ${Math.round((completed / total) * 100)}%` : '等待数据', trend: 'up', color: 'var(--success)' },
{ label: '失败数', value: failed, unit: '条', meta: failed > 0 ? '需要关注' : '运行正常', trend: failed > 0 ? 'down' : 'up', color: '#ef4444' }
]
})
const chatKpis = [
{ label: '今日已问数', value: 86, unit: '次', meta: '较昨日 +18', trend: 'up', color: '#10b981' },
{ label: '今日已问数', value: 86, unit: '次', meta: '较昨日 +18', trend: 'up', color: 'var(--theme-primary)' },
{ label: '已解决问题', value: 72, unit: '条', meta: '解决率 83.7%', trend: 'up', color: '#3b82f6' },
{ label: '知识命中率', value: '92.3', unit: '%', meta: '较昨日 +2.6%', trend: 'up', color: '#8b5cf6' },
{ label: '平均响应时长', value: 2.1, unit: 's', meta: '较昨日 -0.3s', trend: 'down', color: '#f59e0b' }
]
const approvalKpis = [
{ label: '待审批单据', value: 12, unit: '单', meta: '较昨日 +3', trend: 'up', color: '#059669' },
{ label: '待审批单据', value: 12, unit: '单', meta: '较昨日 +3', trend: 'up', color: 'var(--theme-primary)' },
{ label: '高风险单据', value: 4, unit: '单', meta: '较昨日 +1', trend: 'up', color: '#ef4444' },
{ label: '即将超时', value: 3, unit: '单', meta: '30 分钟内', trend: 'down', color: '#f59e0b' },
{ label: '今日已处理', value: 28, unit: '单', meta: '通过率 86%', trend: 'up', color: '#10b981' }
{ label: '今日已处理', value: 28, unit: '单', meta: '通过率 86%', trend: 'up', color: 'var(--success)' }
]
const knowledgeKpis = computed(() => {
@@ -360,7 +360,7 @@ const knowledgeKpis = computed(() => {
value: String(totalDocuments),
meta: '',
trend: 'up',
color: '#10b981'
color: 'var(--theme-primary)'
}
]
})
@@ -381,7 +381,7 @@ const employeeKpis = computed(() => {
unit: '人',
meta: `覆盖 ${departments} 个部门`,
trend: 'up',
color: '#10b981'
color: 'var(--theme-primary)'
},
{
label: '在职账号',
@@ -499,438 +499,4 @@ function buildPresetRangeLabel(label) {
}
</script>
<style scoped>
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 18px 24px 20px;
background: #fff;
}
.topbar.chat-mode {
padding-bottom: 16px;
border-bottom: 1px solid #eef2f7;
}
.title-group {
min-width: 0;
}
.eyebrow {
display: inline-block;
margin-bottom: 8px;
padding: 3px 10px;
border-radius: 6px;
background: linear-gradient(135deg, rgba(16, 185, 129, 0.10), rgba(59, 130, 246, 0.10));
color: #0d9668;
font-size: 11px;
font-weight: 800;
letter-spacing: 1.2px;
text-transform: uppercase;
}
.topbar h1 {
color: #0f172a;
font-size: 26px;
font-weight: 800;
letter-spacing: 0;
line-height: 1.2;
}
.topbar p {
margin-top: 6px;
max-width: 720px;
color: #64748b;
font-size: 14px;
line-height: 1.5;
}
.top-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 14px;
flex-wrap: wrap;
}
.range-combo {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
}
.range-shell {
height: 42px;
display: inline-flex;
align-items: center;
padding: 3px;
border: 1px solid #d7e0ea;
border-radius: 10px;
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
}
.range-meta {
height: 34px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border-right: 1px solid #e2e8f0;
color: #334155;
font-size: 13px;
font-weight: 650;
white-space: nowrap;
}
.range-meta .mdi {
color: #10b981;
}
.range-tabs {
display: inline-flex;
align-items: center;
gap: 2px;
padding-left: 3px;
}
.range-tabs button {
height: 34px;
min-width: 54px;
padding: 0 14px;
border: 0;
border-radius: 8px;
background: transparent;
color: #64748b;
font-size: 13px;
font-weight: 700;
white-space: nowrap;
transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease;
}
.range-tabs button:hover:not(.active) {
background: #f1f5f9;
color: #334155;
}
.range-tabs button.active {
background: #10b981;
color: #fff;
box-shadow: 0 6px 14px rgba(16, 185, 129, .18);
}
.custom-range-wrap {
position: relative;
}
.custom-range-btn {
height: 42px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 13px;
border: 1px solid #d7e0ea;
border-radius: 10px;
background: #fff;
color: #334155;
font-size: 13px;
font-weight: 750;
white-space: nowrap;
}
.custom-range-btn:hover,
.custom-range-btn.active {
border-color: rgba(16, 185, 129, .34);
background: #f6fffb;
color: #0f9f78;
}
.calendar-popover {
position: absolute;
top: calc(100% + 10px);
right: 0;
width: 336px;
z-index: 40;
display: grid;
gap: 14px;
padding: 16px;
border: 1px solid #d7e0ea;
border-radius: 12px;
background: #fff;
box-shadow: 0 18px 42px rgba(15, 23, 42, .16);
}
.calendar-popover header,
.calendar-popover footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.calendar-popover header strong {
color: #0f172a;
font-size: 15px;
}
.calendar-popover header button {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border: 0;
border-radius: 8px;
background: transparent;
color: #64748b;
}
.date-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.date-fields label {
display: grid;
gap: 6px;
}
.date-fields span {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.date-fields input {
width: 100%;
height: 38px;
padding: 0 9px;
border: 1px solid #d7e0ea;
border-radius: 8px;
color: #0f172a;
font-size: 13px;
}
.ghost-btn,
.apply-btn {
height: 36px;
padding: 0 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 750;
}
.ghost-btn {
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
}
.apply-btn {
border: 0;
background: #10b981;
color: #fff;
}
.apply-btn:disabled {
cursor: not-allowed;
background: #cbd5e1;
}
.kpi-chips {
display: flex;
gap: 10px;
}
.detail-alert-strip {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.detail-alert-pill {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 32px;
padding: 0 12px;
border: 1px solid #fed7aa;
border-radius: 999px;
background: #fff7ed;
color: #ea580c;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.detail-alert-pill i {
font-size: 14px;
}
.detail-alert-pill.success {
border-color: #bbf7d0;
background: #f0fdf4;
color: #059669;
}
.detail-alert-pill.danger {
border-color: #fecaca;
background: #fff1f2;
color: #dc2626;
}
.kpi-chip {
display: grid;
grid-template-columns: auto auto;
grid-template-rows: auto auto;
gap: 2px 10px;
padding: 8px 16px;
border-radius: 10px;
background: linear-gradient(135deg, color-mix(in srgb, var(--chip-color) 8%, #fff), color-mix(in srgb, var(--chip-color) 3%, #f8fafc));
border: 1px solid color-mix(in srgb, var(--chip-color) 18%, #e2e8f0);
}
.chip-value {
grid-row: 1 / 3;
align-self: center;
color: #0f172a;
font-size: 22px;
font-weight: 850;
line-height: 1;
}
.chip-value small {
font-size: 13px;
font-weight: 700;
margin-left: 2px;
}
.chip-label {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.chip-delta {
color: #94a3b8;
font-size: 11px;
font-weight: 700;
}
.chip-delta.up { color: #059669; }
.chip-delta.down { color: #f59e0b; }
.topbar-spacer {
flex: 1;
min-width: 0;
}
.create-top-btn {
height: 40px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 18px;
border: 0;
border-radius: 8px;
background: #059669;
color: #fff;
font-size: 14px;
font-weight: 750;
box-shadow: 0 8px 18px rgba(5, 150, 105, 0.18);
white-space: nowrap;
}
.create-top-btn:hover {
background: #047857;
}
@media (max-width: 1120px) {
.range-combo {
width: 100%;
justify-content: flex-end;
}
}
@media (max-width: 960px) {
.topbar {
flex-direction: column;
align-items: stretch;
}
.top-actions,
.search-wrap,
.search-wrap.wide,
.detail-alert-strip,
.month-chip,
.qa-filter,
.new-question-btn {
width: 100%;
}
.range-combo {
justify-content: stretch;
}
.range-shell {
flex: 1;
}
.range-meta {
flex: 1;
}
.custom-range-btn {
width: 100%;
justify-content: center;
}
.calendar-popover {
right: auto;
left: 0;
}
}
@media (max-width: 640px) {
.range-combo {
display: grid;
gap: 8px;
}
.range-shell {
height: auto;
display: grid;
gap: 4px;
}
.range-meta {
width: 100%;
border-right: 0;
border-bottom: 1px solid #e2e8f0;
justify-content: center;
}
.range-tabs {
width: 100%;
padding-left: 0;
}
.range-tabs button {
flex: 1;
padding: 0 8px;
}
.calendar-popover {
width: min(336px, calc(100vw - 32px));
}
.date-fields {
grid-template-columns: 1fr;
}
}
</style>
<style scoped src="../../assets/styles/components/top-bar.css"></style>

View File

@@ -357,7 +357,7 @@ function observeResize() {
}
.graph-eyebrow {
color: #0f766e;
color: var(--theme-primary-active);
font-size: 12px;
font-weight: 850;
}
@@ -515,7 +515,7 @@ function observeResize() {
}
.inspector-title span {
color: #0f766e;
color: var(--theme-primary-active);
font-size: 12px;
font-weight: 850;
}

View File

@@ -2,8 +2,8 @@
<div class="node-detail-panel">
<section class="detail-section">
<div class="detail-section-head">
<strong>节点说明</strong>
<span>{{ safeNode.type || '实体' }}</span>
<strong>节点摘要</strong>
<span>{{ safeNode.type || '未知类型' }}</span>
</div>
<div v-if="descriptionItems.length" class="description-list">
<p v-for="(description, index) in descriptionItems" :key="index">
@@ -11,7 +11,7 @@
</p>
</div>
<div v-else class="detail-empty compact">
当前节点暂无 LightRAG 描述完成新的归集后会从图谱属性中补充
暂无描述LightRAG 还没有为该节点生成可展示的摘要
</div>
</section>
@@ -22,11 +22,11 @@
</div>
<dl class="property-grid">
<div>
<dt>类型</dt>
<dd>{{ safeNode.type || '实体' }}</dd>
<dt>节点类型</dt>
<dd>{{ safeNode.type || '未知类型' }}</dd>
</div>
<div>
<dt>系数</dt>
<dt>联度</dt>
<dd>{{ safeNode.degree || 0 }}</dd>
</div>
<div>
@@ -53,7 +53,7 @@
<section class="detail-section relation-section">
<div class="detail-section-head">
<strong>关系语境</strong>
<strong>联关</strong>
<span>{{ relationRows.length }} </span>
</div>
<div v-if="relationRows.length" class="relation-detail-list">
@@ -72,7 +72,7 @@
</div>
</button>
</div>
<div v-else class="detail-empty compact">暂无关联关系</div>
<div v-else class="detail-empty compact">暂无关联关系</div>
</section>
</div>
</template>
@@ -139,7 +139,7 @@ const relationRows = computed(() => {
raw: relation,
peerName,
type: String(relation.type || '关联').trim(),
directionLabel: isOutgoing ? '指向' : '来',
directionLabel: isOutgoing ? '指向' : '来',
description: String(relation.description || '').trim(),
keywords: dedupeTextItems(relation.keywords).slice(0, 6)
}
@@ -224,7 +224,7 @@ function formatPropertyKey(key) {
.description-list p {
margin: 0;
padding: 9px;
border: 1px solid #dbeafe;
border: 1px solid color-mix(in srgb, var(--theme-primary) 18%, #ffffff);
border-radius: 8px;
background: #fff;
color: #1e293b;
@@ -288,8 +288,8 @@ function formatPropertyKey(key) {
align-items: center;
padding: 0 7px;
border-radius: 999px;
background: #e0f2fe;
color: #075985;
background: var(--theme-primary-light-9);
color: var(--theme-primary-active);
font-size: 11px;
font-weight: 800;
}
@@ -317,7 +317,7 @@ function formatPropertyKey(key) {
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 7px;
padding: 9px;
border: 1px solid #dbeafe;
border: 1px solid color-mix(in srgb, var(--theme-primary) 18%, #ffffff);
border-radius: 8px;
background: #fff;
cursor: pointer;
@@ -326,8 +326,8 @@ function formatPropertyKey(key) {
}
.relation-detail-list button:hover {
border-color: #60a5fa;
background: #eff6ff;
border-color: color-mix(in srgb, var(--theme-primary) 38%, #ffffff);
background: var(--theme-primary-light-9);
transform: translateY(-1px);
}
@@ -342,7 +342,7 @@ function formatPropertyKey(key) {
}
.relation-detail-list strong {
color: #1d4ed8;
color: var(--theme-primary-active);
}
.relation-detail-list p,
@@ -359,8 +359,8 @@ function formatPropertyKey(key) {
}
.keyword-row span {
background: #ecfdf5;
color: #047857;
background: var(--theme-primary-light-9);
color: var(--theme-primary-active);
}
.detail-empty {

View File

@@ -107,7 +107,7 @@ function formatElapsed(startedAt, finishedAt) {
}
.info-title span {
color: #0f766e;
color: var(--theme-primary-active);
font-size: 12px;
font-weight: 850;
}
@@ -129,8 +129,8 @@ function formatElapsed(startedAt, finishedAt) {
}
.info-title > strong.success {
background: #dcfce7;
color: #166534;
background: var(--success-soft);
color: var(--success-active);
}
.info-title > strong.warning {

View File

@@ -57,7 +57,7 @@ const model = computed(() => buildKnowledgeIngestLogModel(props.run))
}
.eyebrow {
color: #0f766e;
color: var(--theme-primary-active);
font-size: 12px;
font-weight: 800;
}
@@ -113,7 +113,7 @@ const model = computed(() => buildKnowledgeIngestLogModel(props.run))
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #0f766e, #2563eb);
background: linear-gradient(90deg, var(--theme-primary-active), var(--theme-secondary));
transition: width 0.24s ease;
}

View File

@@ -1,10 +1,12 @@
import { computed, ref, watch } from 'vue'
import { useThemeColors } from '../../composables/useThemeColors.js'
const MAX_VISIBLE_NODES = 72
const MAX_VISIBLE_EDGES = 180
const MAX_RELATION_PREVIEW = 40
const NODE_TONES = {
const BASE_NODE_TONES = {
hub: {
fill: '#2563eb',
stroke: '#dbeafe',
@@ -12,10 +14,10 @@ const NODE_TONES = {
shadow: 'rgba(37, 99, 235, 0.20)'
},
strong: {
fill: '#0f766e',
stroke: '#ccfbf1',
halo: '#5eead4',
shadow: 'rgba(15, 118, 110, 0.18)'
fill: '#3a7ca5',
stroke: '#eaf4fa',
halo: '#d4e8f3',
shadow: 'rgba(58, 124, 165, 0.18)'
},
accent: {
fill: '#d97706',
@@ -40,8 +42,18 @@ const NODE_TONES = {
export function useKnowledgeIngestGraph(props) {
const graphQuery = ref('')
const activeNodeId = ref('')
const themeColors = useThemeColors()
const allRelations = computed(() => normalizeRelations(props.graph?.relations))
const nodeTones = computed(() => ({
...BASE_NODE_TONES,
strong: {
fill: themeColors.value.chartPrimary,
stroke: themeColors.value.primarySoft,
halo: themeColors.value.primarySoft,
shadow: toRgba(themeColors.value.chartPrimary, 0.18)
}
}))
const rankedNodes = computed(() => buildRankedNodes(props.graph, allRelations.value))
const visibleNodes = computed(() => {
const query = graphQuery.value.toLowerCase()
@@ -83,7 +95,7 @@ export function useKnowledgeIngestGraph(props) {
selectedNodeRelations.value.filter((relation) => relation.source === selectedNode.value?.name)
)
const graphData = computed(() => ({
nodes: visibleNodes.value.map((node) => toG6Node(node)),
nodes: visibleNodes.value.map((node) => toG6Node(node, nodeTones.value)),
edges: visibleRelations.value
.map((relation, index) => toG6Edge(relation, index, nodeIdByName.value))
.filter(Boolean)
@@ -179,9 +191,9 @@ function buildRankedNodes(graph, relations) {
})
}
function toG6Node(node) {
function toG6Node(node, nodeTones) {
const tone = resolveNodeTone(node)
const palette = NODE_TONES[tone]
const palette = nodeTones[tone] || nodeTones.normal || BASE_NODE_TONES.normal
const size = clamp(34 + Math.sqrt(Math.max(node.degree, 1)) * 13, 38, node.rank === 1 ? 82 : 70)
const opacity = node.matchesQuery ? 1 : 0.24
@@ -241,6 +253,20 @@ function toG6Node(node) {
}
}
function toRgba(hexColor, alpha) {
const normalized = String(hexColor || '').trim()
const matched = normalized.match(/^#([0-9a-f]{6})$/i)
if (!matched) {
return `rgba(58, 124, 165, ${alpha})`
}
const value = matched[1]
const red = parseInt(value.slice(0, 2), 16)
const middle = parseInt(value.slice(2, 4), 16)
const blue = parseInt(value.slice(4, 6), 16)
return `rgba(${red}, ${middle}, ${blue}, ${alpha})`
}
function toG6Edge(relation, index, nodeIdByName) {
const sourceId = nodeIdByName.get(relation.source)
const targetId = nodeIdByName.get(relation.target)

View File

@@ -111,18 +111,18 @@ function handleCancel() {
-webkit-backdrop-filter: blur(10px);
}
.shared-confirm-card {
width: min(480px, 100%);
display: grid;
gap: 14px;
padding: 24px;
border: 1px solid rgba(16, 185, 129, 0.14);
border-radius: 24px;
background:
radial-gradient(circle at top left, rgba(16, 185, 129, 0.12), transparent 36%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(247, 250, 252, 0.98));
box-shadow: 0 28px 56px rgba(15, 23, 42, 0.18);
}
.shared-confirm-card {
width: min(480px, 100%);
display: grid;
gap: 14px;
padding: 24px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.14);
border-radius: 8px;
background:
radial-gradient(circle at top left, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.10), transparent 36%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(247, 250, 252, 0.98));
box-shadow: 0 28px 56px rgba(15, 23, 42, 0.18);
}
.shared-confirm-badge {
display: inline-flex;
@@ -135,20 +135,20 @@ function handleCancel() {
font-weight: 800;
}
.shared-confirm-badge.info {
background: rgba(59, 130, 246, 0.12);
color: #1d4ed8;
}
.shared-confirm-badge.info {
background: var(--theme-primary-soft);
color: var(--theme-primary-active);
}
.shared-confirm-badge.warning {
background: rgba(245, 158, 11, 0.14);
color: #b45309;
}
.shared-confirm-badge.danger {
background: rgba(239, 68, 68, 0.12);
color: #dc2626;
}
.shared-confirm-badge.warning {
background: var(--warning-soft);
color: var(--warning-active);
}
.shared-confirm-badge.danger {
background: var(--danger-soft);
color: var(--danger-hover);
}
.shared-confirm-card h4 {
margin: 0;
@@ -197,7 +197,7 @@ function handleCancel() {
justify-content: center;
gap: 8px;
padding: 0 16px;
border-radius: 14px;
border-radius: 6px;
font-size: 14px;
font-weight: 800;
transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease, background 160ms ease;
@@ -214,20 +214,20 @@ function handleCancel() {
color: #fff;
}
.shared-confirm-btn.confirm.primary {
background: linear-gradient(135deg, #10b981, #059669);
box-shadow: 0 12px 24px rgba(5, 150, 105, 0.22);
}
.shared-confirm-btn.confirm.danger {
background: linear-gradient(135deg, #ef4444, #dc2626);
box-shadow: 0 12px 24px rgba(220, 38, 38, 0.22);
}
.shared-confirm-btn.cancel:hover:not(:disabled) {
border-color: rgba(16, 185, 129, 0.3);
color: #047857;
}
.shared-confirm-btn.confirm.primary {
background: linear-gradient(135deg, var(--theme-primary), var(--theme-primary-active));
box-shadow: 0 12px 24px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.20);
}
.shared-confirm-btn.confirm.danger {
background: linear-gradient(135deg, var(--danger), var(--danger-hover));
box-shadow: 0 12px 24px rgba(var(--danger-rgb), 0.22);
}
.shared-confirm-btn.cancel:hover:not(:disabled) {
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.3);
color: var(--theme-primary-active);
}
.shared-confirm-btn.confirm:hover:not(:disabled) {
transform: translateY(-1px);
@@ -259,7 +259,7 @@ function handleCancel() {
width: min(360px, 100%);
gap: 8px;
padding: 16px;
border-radius: 12px;
border-radius: 6px;
background: #fff;
}
@@ -281,7 +281,7 @@ function handleCancel() {
min-width: 76px;
min-height: 30px;
padding: 0 10px;
border-radius: 7px;
border-radius: 4px;
font-size: 12px;
}
@@ -290,10 +290,10 @@ function handleCancel() {
padding: 18px;
}
.shared-confirm-card {
padding: 20px;
border-radius: 20px;
}
.shared-confirm-card {
padding: 20px;
border-radius: 8px;
}
.shared-confirm-card h4 {
font-size: 19px;

View File

@@ -0,0 +1,72 @@
<template>
<ElSelect
class="enterprise-select"
popper-class="enterprise-select-popper"
:model-value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:clearable="clearable"
:filterable="filterable"
:size="size"
:teleported="teleported"
@change="handleChange"
>
<ElOption
v-for="option in normalizedOptions"
:key="option.key"
:label="option.label"
:value="option.value"
:disabled="option.disabled"
/>
</ElSelect>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
modelValue: { type: [String, Number, Boolean], default: '' },
options: { type: Array, required: true },
placeholder: { type: String, default: '请选择' },
disabled: { type: Boolean, default: false },
clearable: { type: Boolean, default: false },
filterable: { type: Boolean, default: false },
size: { type: String, default: 'default' },
teleported: { type: Boolean, default: true }
})
const emit = defineEmits(['update:modelValue', 'change'])
const normalizedOptions = computed(() =>
props.options.map((option, index) => {
if (option && typeof option === 'object') {
const value = option.value ?? option.label ?? ''
return {
key: `${value}-${index}`,
label: String(option.label ?? value),
value,
disabled: Boolean(option.disabled)
}
}
return {
key: `${option}-${index}`,
label: String(option),
value: option,
disabled: false
}
})
)
function handleChange(value) {
emit('update:modelValue', value)
emit('change', value)
}
</script>
<style scoped>
.enterprise-select {
width: 100%;
}
</style>

View File

@@ -54,7 +54,7 @@ const props = defineProps({
artLabel: { type: String, default: 'EMPTY' },
actionLabel: { type: String, default: '' },
actionIcon: { type: String, default: '' },
tone: { type: String, default: 'emerald' },
tone: { type: String, default: 'theme' },
tips: {
type: Array,
default: () => []
@@ -66,11 +66,11 @@ defineEmits(['action'])
<style scoped>
.table-empty-state {
--accent: #10b981;
--accent-deep: #059669;
--accent-soft: rgba(16, 185, 129, 0.16);
--accent-line: rgba(16, 185, 129, 0.24);
--accent-mist: rgba(16, 185, 129, 0.08);
--accent: var(--theme-primary);
--accent-deep: var(--theme-primary-active);
--accent-soft: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.16);
--accent-line: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24);
--accent-mist: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08);
display: flex;
flex-direction: column;
align-items: center;
@@ -82,20 +82,21 @@ defineEmits(['action'])
width: 100%;
}
.table-empty-state.theme,
.table-empty-state.sky {
--accent: #0ea5e9;
--accent-deep: #0284c7;
--accent-soft: rgba(14, 165, 233, 0.16);
--accent-line: rgba(14, 165, 233, 0.24);
--accent-mist: rgba(14, 165, 233, 0.08);
--accent: var(--theme-primary);
--accent-deep: var(--theme-primary-active);
--accent-soft: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.16);
--accent-line: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24);
--accent-mist: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08);
}
.table-empty-state.amber {
--accent: #f59e0b;
--accent-deep: #d97706;
--accent-soft: rgba(245, 158, 11, 0.16);
--accent-line: rgba(245, 158, 11, 0.24);
--accent-mist: rgba(245, 158, 11, 0.08);
--accent: var(--warning);
--accent-deep: var(--warning-hover);
--accent-soft: rgba(var(--warning-rgb), 0.16);
--accent-line: rgba(var(--warning-rgb), 0.24);
--accent-mist: rgba(var(--warning-rgb), 0.08);
}
.table-empty-state.slate {
@@ -106,6 +107,14 @@ defineEmits(['action'])
--accent-mist: rgba(100, 116, 139, 0.08);
}
.table-empty-state.success {
--accent: var(--success);
--accent-deep: var(--success-hover);
--accent-soft: rgba(var(--success-rgb), 0.16);
--accent-line: rgba(var(--success-rgb), 0.24);
--accent-mist: rgba(var(--success-rgb), 0.08);
}
.table-empty-state__art {
position: relative;
width: min(240px, 100%);
@@ -144,7 +153,7 @@ defineEmits(['action'])
height: 82px;
padding: 14px 14px 12px;
border: 1px solid rgba(255, 255, 255, 0.88);
border-radius: 18px;
border-radius: 8px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(245, 248, 250, 0.94));
box-shadow: 0 18px 30px rgba(15, 23, 42, 0.08);
@@ -208,7 +217,7 @@ defineEmits(['action'])
display: grid;
place-items: center;
border: 1px solid rgba(255, 255, 255, 0.86);
border-radius: 24px;
border-radius: 10px;
background:
radial-gradient(circle at 30% 20%, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.3) 42%),
linear-gradient(135deg, var(--accent), var(--accent-deep));
@@ -287,7 +296,7 @@ defineEmits(['action'])
gap: 8px;
padding: 0 16px;
border: 1px solid transparent;
border-radius: 12px;
border-radius: 6px;
background: linear-gradient(135deg, var(--accent), var(--accent-deep));
color: #fff;
font-size: 13px;
@@ -316,13 +325,13 @@ defineEmits(['action'])
.table-empty-state__sheet {
width: 110px;
height: 74px;
border-radius: 16px;
border-radius: 8px;
}
.table-empty-state__badge {
width: 60px;
height: 60px;
border-radius: 20px;
border-radius: 10px;
}
.table-empty-state__badge i {

View File

@@ -28,8 +28,8 @@ const props = defineProps({
},
tone: {
type: String,
default: 'emerald',
validator: (value) => ['emerald', 'sky'].includes(value)
default: 'theme',
validator: (value) => ['theme', 'sky', 'success'].includes(value)
},
title: { type: String, default: '' },
message: { type: String, default: '' },
@@ -45,15 +45,21 @@ const ariaLabel = computed(() => [props.title, props.message].filter(Boolean).jo
<style scoped>
.table-loading {
--accent: #10b981;
--accent-deep: #059669;
--accent: var(--theme-primary);
--accent-deep: var(--theme-primary-active);
width: 100%;
color: #64748b;
}
.table-loading.theme,
.table-loading.sky {
--accent: #0ea5e9;
--accent-deep: #0284c7;
--accent: var(--theme-primary);
--accent-deep: var(--theme-primary-active);
}
.table-loading.success {
--accent: var(--success);
--accent-deep: var(--success-hover);
}
.table-loading.panel {
@@ -96,7 +102,7 @@ const ariaLabel = computed(() => [props.title, props.message].filter(Boolean).jo
gap: 8px;
min-height: 0;
padding: 0;
color: #0369a1;
color: #255b7d;
}
.table-loading__spinner {

View File

@@ -19,7 +19,7 @@ import { workbenchIconMap } from '../../utils/workbenchIconAssets.js'
const props = defineProps({
iconKey: { type: String, required: true },
color: { type: String, default: '#10b981' },
color: { type: String, default: 'var(--theme-primary)' },
accent: { type: String, default: '' }
})

View File

@@ -0,0 +1,623 @@
<template>
<div
class="insight-panel-shell"
:class="{ collapsed: !ui.showInsightPanel }"
:aria-hidden="(!ui.showInsightPanel).toString()"
>
<aside class="insight-panel">
<div
v-if="!ui.isKnowledgeSession"
class="insight-head"
:class="{ 'review-mode': ui.activeReviewPayload || ui.isReviewFlowDrawer }"
>
<div>
<div v-if="!ui.activeReviewPayload && !ui.isReviewFlowDrawer" class="insight-head-eyebrow">
<span class="intent-pill" :class="ui.currentInsight.intent">{{ ui.currentIntentLabel }}</span>
</div>
<div v-else class="review-insight-title-row">
<div class="review-insight-title-copy">
<h3>{{ ui.reviewDrawerTitle }}</h3>
</div>
</div>
<h3 v-if="!ui.activeReviewPayload && !ui.isReviewFlowDrawer">{{ ui.currentInsight.title }}</h3>
<p v-if="!ui.activeReviewPayload && !ui.isReviewFlowDrawer">{{ ui.currentInsight.summary }}</p>
</div>
<div v-if="ui.activeReviewPayload || ui.isReviewFlowDrawer" class="review-insight-tools">
<button
v-if="ui.activeReviewPayload && ui.reviewOverviewDrawerAvailable"
type="button"
class="review-insight-switch-icon-btn"
:class="{
available: true,
active: ui.isReviewOverviewDrawer
}"
:disabled="ui.submitting || ui.reviewActionBusy"
title="报销识别核对"
aria-label="报销识别核对"
@click="ui.switchToReviewOverviewDrawer"
>
<i :class="ui.isReviewOverviewDrawer ? 'mdi mdi-clipboard-check' : 'mdi mdi-clipboard-check-outline'"></i>
</button>
<button
v-if="ui.activeReviewPayload && ui.reviewDocumentDrawerAvailable"
type="button"
class="review-insight-switch-icon-btn"
:class="{
available: ui.reviewDocumentDrawerAvailable,
active: ui.reviewDocumentDrawerAvailable && ui.isReviewDocumentDrawer
}"
:disabled="ui.submitting || ui.reviewActionBusy"
title="单据识别"
aria-label="单据识别"
@click="ui.toggleReviewDocumentDrawer"
>
<i :class="ui.reviewDocumentDrawerIcon"></i>
</button>
<button
v-if="ui.activeReviewPayload && ui.reviewRiskDrawerAvailable"
type="button"
class="review-insight-switch-icon-btn risk"
:class="{
available: ui.reviewRiskDrawerAvailable,
active: ui.reviewRiskDrawerAvailable && ui.isReviewRiskDrawer
}"
:disabled="ui.submitting || ui.reviewActionBusy"
title="显示风险"
aria-label="显示风险"
@click="ui.toggleReviewRiskDrawer"
>
<i :class="ui.reviewRiskDrawerIcon"></i>
</button>
<button
type="button"
class="review-insight-switch-icon-btn flow"
:class="{
available: ui.reviewFlowDrawerAvailable,
active: ui.reviewFlowDrawerAvailable && ui.isReviewFlowDrawer,
running: ui.flowOverallStatusTone === 'running'
}"
:disabled="!ui.reviewFlowDrawerAvailable || ui.submitting || ui.reviewActionBusy"
title="调用流程"
aria-label="调用流程"
@click="ui.toggleReviewFlowDrawer"
>
<i :class="ui.reviewFlowDrawerIcon"></i>
</button>
</div>
<div class="confidence-card" v-if="!ui.activeReviewPayload && !ui.isReviewFlowDrawer">
<span>{{ ui.currentInsight.metricLabel }}</span>
<strong>{{ ui.currentInsight.metricValue }}</strong>
</div>
</div>
<Transition name="insight-switch" mode="out-in">
<div
:key="`${ui.activeSessionType}-${ui.currentInsight.intent}-${ui.currentInsight.title}-${ui.reviewDrawerMode}`"
class="insight-body"
:class="{ 'document-review-body': ui.isReviewDocumentDrawer }"
>
<template v-if="ui.isKnowledgeSession">
<section class="insight-card knowledge-hot-card">
<div class="card-head">
<h4>热门问题 Top 10</h4>
</div>
<div class="knowledge-question-list">
<button
v-for="(item, index) in ui.hotKnowledgeQuestions"
:key="item"
type="button"
class="knowledge-question-btn"
:disabled="ui.submitting || ui.reviewActionBusy || ui.deleteSessionBusy || ui.sessionSwitchBusy"
@click="ui.askHotKnowledgeQuestion(item)"
>
<span
class="knowledge-question-index"
:class="ui.resolveKnowledgeRankTone(index)"
>
{{ ui.resolveKnowledgeRankLabel(index) }}
</span>
<span class="knowledge-question-copy">{{ item }}</span>
<i class="mdi mdi-arrow-top-right"></i>
</button>
</div>
</section>
</template>
<template v-else-if="ui.isReviewFlowDrawer">
<section class="review-flow-panel">
<div class="review-flow-summary">
<span class="flow-status-chip" :class="ui.flowOverallStatusTone">{{ ui.flowOverallStatusText }}</span>
<span>总耗时 {{ ui.flowTotalDurationText }}</span>
<button
type="button"
class="flow-icon-btn"
:disabled="!ui.flowRunId || ui.flowRefreshBusy"
title="刷新流程"
aria-label="刷新流程"
@click="ui.refreshFlowRunDetail"
>
<i :class="ui.flowRefreshBusy ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-refresh'"></i>
</button>
</div>
<div v-if="ui.flowSteps.length" class="review-flow-list">
<article
v-for="(step, index) in ui.flowSteps"
:key="step.key"
class="flow-step-item"
:class="step.status"
>
<div class="flow-step-rail">
<span>{{ index + 1 }}</span>
</div>
<div class="flow-step-card">
<header>
<strong>{{ step.title }}</strong>
<div class="flow-step-side">
<span class="flow-step-status" :class="step.status">{{ ui.resolveFlowStepStatusLabel(step) }}</span>
<time>{{ ui.formatFlowStepDuration(step) }}</time>
</div>
</header>
<p class="flow-step-tool">工具{{ step.tool }}</p>
<p class="flow-step-detail">{{ ui.resolveFlowStepDetail(step) }}</p>
<p v-if="step.error" class="flow-step-error">{{ step.error }}</p>
</div>
</article>
</div>
<div v-else class="flow-empty-state compact">
<i class="mdi mdi-timeline-question-outline"></i>
<strong>暂无识别流程</strong>
<p>发起识别后这里会显示调用步骤和耗时</p>
</div>
</section>
</template>
<template v-else-if="ui.currentInsight.intent === 'agent' && ui.currentInsight.agent">
<template v-if="ui.activeReviewPayload">
<template v-if="ui.reviewOverviewDrawerAvailable && !ui.isReviewDocumentDrawer && !ui.isReviewRiskDrawer && !ui.isReviewFlowDrawer">
<section class="review-side-card review-side-overview-card">
<div class="review-side-intent-row">
<i class="mdi mdi-account-outline"></i>
<span>用户意图</span>
<strong>{{ ui.reviewIntentText }}</strong>
</div>
<section class="review-side-grid compact">
<article
v-for="item in ui.reviewFactCards"
:key="item.key"
class="review-side-metric-card"
:class="{
editable: item.editor,
editing: ui.reviewInlineEditorKey === item.key,
invalid: Boolean(ui.reviewInlineErrors[item.key]),
wide: item.wide
}"
@click="ui.openInlineReviewEditor(item.key)"
>
<span class="review-side-metric-icon">
<i :class="item.icon"></i>
</span>
<div class="review-side-metric-copy">
<small>{{ item.label }}</small>
<template v-if="ui.reviewInlineEditorKey === item.key && item.editor === 'date'">
<input
v-model="ui.reviewInlineForm[item.modelKey]"
class="review-inline-input"
:class="{ invalid: Boolean(ui.reviewInlineErrors[item.key]) }"
type="text"
:placeholder="`仅支持 ${ui.DATE_INPUT_FORMAT}`"
@click.stop
@input="ui.clearInlineReviewFieldError(item.key)"
@blur="ui.commitInlineReviewEditor"
@keydown.enter.prevent="ui.commitInlineReviewEditor"
/>
</template>
<template v-else-if="ui.reviewInlineEditorKey === item.key && item.editor === 'amount'">
<input
v-model="ui.reviewInlineForm[item.modelKey]"
class="review-inline-input"
:class="{ invalid: Boolean(ui.reviewInlineErrors[item.key]) }"
type="text"
:placeholder="item.placeholder"
@click.stop
@input="ui.clearInlineReviewFieldError(item.key)"
@blur="ui.commitInlineReviewEditor"
@keydown.enter.prevent="ui.commitInlineReviewEditor"
/>
</template>
<template v-else-if="ui.reviewInlineEditorKey === item.key && item.editor === 'text'">
<input
v-model="ui.reviewInlineForm[item.modelKey]"
class="review-inline-input"
:class="{ invalid: Boolean(ui.reviewInlineErrors[item.key]) }"
type="text"
:placeholder="item.placeholder"
@click.stop
@input="ui.clearInlineReviewFieldError(item.key)"
@blur="ui.commitInlineReviewEditor"
@keydown.enter.prevent="ui.commitInlineReviewEditor"
/>
</template>
<template v-else-if="ui.reviewInlineEditorKey === item.key && item.editor === 'textarea'">
<textarea
v-model="ui.reviewInlineForm[item.modelKey]"
class="review-inline-input review-inline-textarea"
:class="{ invalid: Boolean(ui.reviewInlineErrors[item.key]) }"
:placeholder="item.placeholder"
rows="3"
@click.stop
@input="ui.clearInlineReviewFieldError(item.key)"
@blur="ui.commitInlineReviewEditor"
@keydown.enter.stop
></textarea>
</template>
<template v-else-if="ui.reviewInlineEditorKey === item.key && item.editor === 'select'">
<div class="review-inline-select-list" @click.stop>
<button
v-for="scene in ui.REVIEW_SCENE_OPTIONS"
:key="scene"
type="button"
class="review-inline-select-option"
:class="{ active: ui.reviewInlineForm.scene_label === scene }"
@click.stop="ui.selectInlineScene(scene)"
>
{{ scene }}
</button>
<input
v-if="ui.reviewInlineForm.scene_label === ui.REVIEW_SCENE_OTHER_OPTION"
v-model="ui.reviewInlineForm.reason_value"
class="review-inline-input review-inline-select-custom"
:class="{ invalid: Boolean(ui.reviewInlineErrors[item.key]) }"
type="text"
placeholder="请输入具体事由"
@click.stop
@input="ui.clearInlineReviewFieldError(item.key)"
@blur="ui.commitInlineReviewEditor"
@keydown.enter.prevent="ui.commitInlineReviewEditor"
/>
</div>
</template>
<strong v-else :title="item.value">{{ item.value }}</strong>
<span v-if="ui.reviewInlineErrors[item.key]" class="review-inline-error">
{{ ui.reviewInlineErrors[item.key] }}
</span>
</div>
<span v-if="item.key !== 'attachments'" class="review-side-edit-hint">修改</span>
<span v-else class="review-side-edit-hint upload">{{ ui.reviewInlinePendingFiles.length ? '已选择' : '上传' }}</span>
</article>
</section>
</section>
<section class="review-side-card">
<div class="review-side-head">
<strong>报销分类</strong>
<span class="review-side-confidence">置信度 {{ ui.reviewPanelConfidence }}</span>
</div>
<div class="review-side-category-grid">
<button
v-for="item in ui.reviewCategoryOptions"
:key="item.key"
type="button"
class="review-side-category-card"
:class="{ active: item.active }"
@click="ui.selectReviewCategory(item)"
>
<div class="review-side-category-copy">
<strong>{{ item.label }}</strong>
<p>{{ item.is_other && ui.reviewSelectedOtherCategory ? ui.reviewSelectedOtherCategory : item.caption }}</p>
</div>
<i v-if="item.active" class="mdi mdi-check-circle review-side-group-check"></i>
</button>
</div>
<div v-if="ui.reviewOtherCategoryOpen" class="review-other-category-popover">
<button
v-for="item in ui.reviewOtherCategoryOptions"
:key="item.key"
type="button"
class="review-other-category-option"
:class="{ active: ui.reviewSelectedOtherCategory === item.label }"
@click="ui.selectReviewOtherCategory(item)"
>
{{ item.label }} · {{ item.confidenceLabel }}
</button>
</div>
</section>
</template>
<template v-else-if="ui.isReviewFlowDrawer">
<section class="review-flow-panel">
<div class="review-flow-summary">
<span class="flow-status-chip" :class="ui.flowOverallStatusTone">{{ ui.flowOverallStatusText }}</span>
<span>总耗时 {{ ui.flowTotalDurationText }}</span>
<button
type="button"
class="flow-icon-btn"
:disabled="!ui.flowRunId || ui.flowRefreshBusy"
title="刷新流程"
aria-label="刷新流程"
@click="ui.refreshFlowRunDetail"
>
<i :class="ui.flowRefreshBusy ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-refresh'"></i>
</button>
</div>
<div v-if="ui.flowSteps.length" class="review-flow-list">
<article
v-for="(step, index) in ui.flowSteps"
:key="step.key"
class="flow-step-item"
:class="step.status"
>
<div class="flow-step-rail">
<span>{{ index + 1 }}</span>
</div>
<div class="flow-step-card">
<header>
<strong>{{ step.title }}</strong>
<div class="flow-step-side">
<span class="flow-step-status" :class="step.status">{{ ui.resolveFlowStepStatusLabel(step) }}</span>
<time>{{ ui.formatFlowStepDuration(step) }}</time>
</div>
</header>
<p class="flow-step-tool">工具{{ step.tool }}</p>
<p class="flow-step-detail">{{ ui.resolveFlowStepDetail(step) }}</p>
<p v-if="step.error" class="flow-step-error">{{ step.error }}</p>
</div>
</article>
</div>
<div v-else class="flow-empty-state compact">
<i class="mdi mdi-timeline-question-outline"></i>
<strong>暂无识别流程</strong>
<p>发起识别后这里会显示调用步骤和耗时</p>
</div>
</section>
</template>
<template v-else-if="ui.isReviewDocumentDrawer">
<section class="review-side-card review-document-switch-card review-ticket-drawer">
<div class="review-side-head review-document-switch-head">
<div class="review-side-head-copy">
<strong>票据识别结果卡片</strong>
<p>逐张查看 OCR 结果可直接修正后再切回核对滑窗</p>
</div>
<div class="review-document-nav">
<button
type="button"
class="review-document-nav-btn"
:disabled="ui.activeReviewDocumentIndex === 0"
aria-label="上一张票据"
@click="ui.goReviewDocument(-1)"
>
<i class="mdi mdi-chevron-left"></i>
</button>
<span>{{ ui.activeReviewDocumentIndex + 1 }} / {{ ui.reviewDocumentCount }}</span>
<button
type="button"
class="review-document-nav-btn"
:disabled="ui.activeReviewDocumentIndex >= ui.reviewDocumentCount - 1"
aria-label="下一张票据"
@click="ui.goReviewDocument(1)"
>
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
</div>
<div v-if="ui.activeReviewDocument" class="review-document-stage">
<div class="review-document-stage-head">
<div class="review-document-stage-copy">
<strong :title="ui.activeReviewDocument.filename">{{ ui.activeReviewDocument.filename }}</strong>
</div>
</div>
<div class="review-document-meta-chip-row">
<span class="review-document-meta-chip">{{ ui.activeReviewDocument.documentTypeLabel }}</span>
<span class="review-document-meta-chip">{{ ui.activeReviewDocument.expenseTypeLabel }}</span>
<span class="review-document-meta-chip confidence">{{ ui.activeReviewDocument.confidenceLabel }}</span>
</div>
<div class="review-document-scroll">
<div
class="review-document-preview-card"
:class="[
ui.activeReviewDocumentPreview?.kind || 'file',
{ clickable: ui.canPreviewActiveReviewDocument }
]"
:role="ui.canPreviewActiveReviewDocument ? 'button' : null"
:tabindex="ui.canPreviewActiveReviewDocument ? 0 : null"
@click="ui.canPreviewActiveReviewDocument ? ui.openActiveReviewDocumentPreview() : null"
@keydown.enter.prevent="ui.canPreviewActiveReviewDocument ? ui.openActiveReviewDocumentPreview() : null"
@keydown.space.prevent="ui.canPreviewActiveReviewDocument ? ui.openActiveReviewDocumentPreview() : null"
>
<img
v-if="ui.activeReviewDocumentPreview?.kind === 'image' && ui.activeReviewDocumentPreview?.url"
:src="ui.activeReviewDocumentPreview.url"
:alt="ui.activeReviewDocument.filename"
/>
<div v-else-if="ui.activeReviewDocumentPreview?.kind === 'pdf'" class="review-document-preview-placeholder">
<i class="mdi mdi-file-pdf-box"></i>
<strong>PDF 票据文件</strong>
<p>当前文件还没有生成图片预览可先核对下方识别字段</p>
</div>
<div v-else class="review-document-preview-placeholder">
<i class="mdi mdi-file-search-outline"></i>
<strong>当前无可预览票据</strong>
<p>这张票据还没有可用预览可先核对下方识别字段</p>
</div>
</div>
<label class="review-document-edit-field summary">
<span>票据摘要</span>
<textarea
v-model="ui.activeReviewDocument.summary"
rows="3"
:disabled="ui.submitting || ui.reviewActionBusy"
placeholder="可根据票据图片修正 OCR 摘要"
></textarea>
</label>
<label class="review-document-edit-field">
<span>票据场景</span>
<input
v-model="ui.activeReviewDocument.scene_label"
type="text"
:disabled="ui.submitting || ui.reviewActionBusy"
placeholder="例如:出租车/网约车票据 / 火车/高铁票"
/>
</label>
<div v-if="ui.activeReviewDocument.fields.length" class="review-document-edit-grid">
<label
v-for="field in ui.activeReviewDocument.fields"
:key="`${ui.activeReviewDocument.filename}-${field.label}`"
class="review-document-edit-field"
>
<span>{{ field.label }}</span>
<input
v-model="field.value"
type="text"
:disabled="ui.submitting || ui.reviewActionBusy"
:placeholder="`修正 ${field.label}`"
/>
</label>
</div>
<div v-else class="review-side-empty compact">
<span class="review-side-empty-icon">
<i class="mdi mdi-text-recognition"></i>
</span>
<strong>暂无结构化字段</strong>
<p>当前只返回了摘要信息你仍然可以直接修改上面的票据摘要</p>
</div>
<div v-if="ui.activeReviewDocument.warnings?.length" class="review-document-warning-list">
<article
v-for="warning in ui.activeReviewDocument.warnings"
:key="`${ui.activeReviewDocument.filename}-${warning}`"
class="review-document-warning-item"
>
<i class="mdi mdi-alert-circle-outline"></i>
<span>{{ warning }}</span>
</article>
</div>
</div>
</div>
</section>
</template>
<template v-else-if="ui.isReviewRiskDrawer">
<section class="review-side-card review-side-risk-card">
<div class="review-side-head">
<div class="review-side-head-copy">
<strong>差旅合规提示</strong>
<p>结合票据识别结果与差旅规则逐项查看需要处理的风险点</p>
</div>
</div>
<p class="review-side-risk-summary">{{ ui.reviewRiskSummary }}</p>
<div v-if="ui.reviewRiskItems.length" class="review-side-risk-list">
<button
v-for="item in ui.reviewRiskItems"
:key="item.key"
type="button"
class="review-side-risk-item"
:class="item.level"
@click="ui.appendReviewRiskBriefToConversation(item)"
>
<span class="review-side-risk-icon" :title="item.levelLabel">
<i :class="item.icon"></i>
</span>
<span class="review-side-risk-copy">
<strong>{{ item.title }}</strong>
<p>{{ item.summary }}</p>
</span>
<span class="review-side-risk-meta">
<i class="mdi mdi-chevron-right"></i>
</span>
</button>
</div>
<div v-else-if="ui.reviewRiskEmpty" class="review-side-empty">
<span class="review-side-empty-icon">
<i class="mdi mdi-shield-check-outline"></i>
</span>
<strong>暂无风险提示</strong>
<p>当前没有需要额外处理的结构化风险点</p>
</div>
</section>
</template>
<button
v-if="ui.reviewHasUnsavedChanges"
type="button"
class="review-side-save-pill"
:disabled="ui.reviewActionBusy || ui.submitting"
@click="ui.saveInlineReviewChanges"
>
<i class="mdi mdi-content-save-outline"></i>
保存右侧修改
</button>
</template>
<section v-if="ui.currentInsight.agent.citations?.length && !ui.currentInsight.agent.queryPayload && !ui.activeReviewPayload" class="insight-card">
<div class="card-head">
<h4>制度依据</h4>
</div>
<div class="citation-stack">
<article v-for="item in ui.currentInsight.agent.citations" :key="item.code" class="citation-card">
<header>
<strong>{{ item.title }}</strong>
<span>{{ item.version || item.source_type }}</span>
</header>
<p>{{ item.excerpt || item.code }}</p>
</article>
</div>
</section>
<template v-if="!ui.activeReviewPayload">
<section class="insight-card primary">
<div class="card-head">
<h4>识别结果</h4>
</div>
<div class="note-block">
<strong>{{ ui.currentInsight.title }}</strong>
<p>{{ ui.currentInsight.summary }}</p>
</div>
</section>
<section v-if="ui.currentInsight.agent.riskFlags?.length" class="insight-card">
<div class="card-head">
<h4>风险标签</h4>
</div>
<div class="capability-chip-row">
<span v-for="item in ui.currentInsight.agent.riskFlags" :key="item" class="risk-chip">{{ item }}</span>
</div>
</section>
</template>
</template>
</div>
</Transition>
</aside>
</div>
</template>
<script>
export default {
name: 'TravelReimbursementInsightPanel',
props: {
ui: {
type: Object,
required: true
}
}
}
</script>
<style scoped src="../../assets/styles/components/travel-reimbursement-insight-panel.css"></style>

View File

@@ -0,0 +1,430 @@
<template>
<article
class="message-row"
:class="message.role"
>
<span class="message-avatar">
<img
:src="message.role === 'assistant' ? ui.aiAvatar : ui.userAvatar"
:alt="message.role === 'assistant' ? '财务助手头像' : '用户头像'"
/>
</span>
<div class="message-bubble" :class="ui.buildMessageBubbleClass(message)">
<header class="message-meta">
<strong>{{ message.role === 'assistant' ? (message.assistantName || ui.ASSISTANT_DISPLAY_NAME) : '我' }}</strong>
<time>{{ message.time }}</time>
</header>
<div
v-if="message.text && message.role === 'assistant' && message.reviewPayload && ui.buildReviewMainMessageText(message)"
class="review-summary message-answer-content message-answer-markdown"
v-html="ui.renderMarkdown(ui.buildReviewMainMessageText(message))"
@click="ui.handleAssistantMarkdownClick($event, message)"
></div>
<div
v-else-if="message.text && message.role !== 'assistant'"
class="message-answer-content message-answer-markdown message-rich-text"
v-html="ui.renderMarkdown(message.text)"
></div>
<div
v-else-if="message.text && message.role === 'assistant'"
class="message-answer-content message-answer-markdown"
v-html="ui.renderMarkdown(message.text)"
@click="ui.handleAssistantMarkdownClick($event, message)"
></div>
<div
v-if="message.role === 'assistant' && message.applicationPreview"
class="application-preview-table"
role="table"
aria-label="申请信息核对表"
>
<div class="application-preview-row head" role="row">
<span role="columnheader">字段</span>
<span role="columnheader">内容</span>
</div>
<div
v-for="row in ui.resolveApplicationPreviewRows(message)"
:key="`${message.id}-${row.key}`"
class="application-preview-row"
:class="{
missing: row.missing,
editable: row.editable && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy,
highlight: row.highlight
}"
role="row"
:tabindex="row.editable && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy ? 0 : -1"
:aria-label="row.editable ? `编辑${row.label}` : row.label"
@click="row.editable && !ui.isApplicationPreviewEditing(message, row.key) && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy && ui.openApplicationPreviewEditor(message, row.key, row.value)"
@keydown.enter.prevent="row.editable && !ui.isApplicationPreviewEditing(message, row.key) && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy && ui.openApplicationPreviewEditor(message, row.key, row.value)"
@keydown.space.prevent="row.editable && !ui.isApplicationPreviewEditing(message, row.key) && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy && ui.openApplicationPreviewEditor(message, row.key, row.value)"
>
<span class="application-preview-label" role="cell">{{ row.label }}</span>
<span class="application-preview-value" role="cell">
<input
v-if="ui.isApplicationPreviewEditing(message, row.key) && ui.resolveApplicationPreviewEditorControl(row.key) !== 'select'"
v-model="ui.applicationPreviewEditor.draftValue"
class="application-preview-input"
type="text"
autofocus
@click.stop
@keydown.stop="ui.handleApplicationPreviewEditorKeydown($event, message)"
@blur="ui.commitApplicationPreviewEditor(message)"
/>
<EnterpriseSelect
v-else-if="ui.isApplicationPreviewEditing(message, row.key)"
v-model="ui.applicationPreviewEditor.draftValue"
class="application-preview-input application-preview-select"
:options="ui.resolveApplicationPreviewEditorOptions(row.key)"
clearable
:teleported="false"
autofocus
@click.stop
@change="ui.commitApplicationPreviewEditor(message)"
@keydown.stop="ui.handleApplicationPreviewEditorKeydown($event, message)"
@blur="ui.commitApplicationPreviewEditor(message)"
/>
<template v-else>
<span class="application-preview-text">{{ row.value }}</span>
<button
v-if="row.editable"
type="button"
class="application-preview-edit-btn"
title="修改内容"
aria-label="修改内容"
:disabled="ui.submitting || ui.reviewActionBusy || ui.sessionSwitchBusy"
@click.stop="ui.openApplicationPreviewEditor(message, row.key, row.value)"
>
<i class="mdi mdi-pencil-outline"></i>
</button>
</template>
</span>
</div>
</div>
<div
v-if="message.role === 'assistant' && message.applicationPreview && ui.buildApplicationPreviewFooterText(message)"
class="application-preview-footer message-answer-content message-answer-markdown"
v-html="ui.renderMarkdown(ui.buildApplicationPreviewFooterText(message))"
@click="ui.handleAssistantMarkdownClick($event, message)"
></div>
<div
v-if="message.role === 'assistant' && message.welcomeQuickActions?.length"
class="welcome-quick-actions"
>
<p class="welcome-quick-actions-title">您可以对我进行以下操作</p>
<div class="welcome-quick-action-grid">
<button
v-for="action in message.welcomeQuickActions"
:key="`${message.id}-${action.label}`"
type="button"
class="welcome-quick-action-btn"
:disabled="ui.submitting || ui.reviewActionBusy || ui.sessionSwitchBusy"
@click="ui.runWelcomeQuickAction(action)"
>
<i :class="action.icon"></i>
<span>{{ action.label }}</span>
</button>
</div>
</div>
<div v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.meta?.length" class="message-meta-row">
<span
v-for="item in message.meta"
:key="item"
class="message-meta-chip"
:class="message.metaTone"
>
{{ item }}
</span>
</div>
<div
v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.suggestedActions?.length"
class="message-suggested-actions"
>
<button
v-for="action in message.suggestedActions"
:key="`${message.id}-${action.action_type}-${action.label}`"
type="button"
class="message-suggested-action-btn"
:class="{
selected: ui.isSuggestedActionSelected(message, action),
locked: message.suggestedActionsLocked
}"
:disabled="message.suggestedActionsLocked || ui.submitting || ui.reviewActionBusy || ui.sessionSwitchBusy"
@click="ui.handleSuggestedAction(message, action)"
>
<span class="message-suggested-action-icon" aria-hidden="true">
<i :class="action.icon || 'mdi mdi-shape-outline'"></i>
</span>
<span class="message-suggested-action-copy">
<span class="message-suggested-action-title">{{ action.label }}</span>
<small v-if="action.description">{{ action.description }}</small>
</span>
<i class="message-suggested-action-arrow mdi mdi-arrow-right" aria-hidden="true"></i>
</button>
</div>
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.riskFlags?.length" class="message-detail-block">
<strong>风险标签</strong>
<div class="message-detail-chip-row">
<span v-for="item in message.riskFlags" :key="item" class="message-risk-chip">{{ item }}</span>
</div>
</div>
<details
v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.citations?.length"
class="message-detail-block message-citation-disclosure"
>
<summary>
<strong>引用依据</strong>
<span>{{ message.citations.length }} </span>
<i class="mdi mdi-chevron-down"></i>
</summary>
<div class="message-citation-list">
<article v-for="item in message.citations" :key="`${message.id}-${item.code}`" class="message-citation-card">
<header>
<span>{{ item.title }}</span>
<small>{{ item.version || item.source_type }}</small>
</header>
<p>{{ item.excerpt || item.code }}</p>
</article>
</div>
</details>
<div
v-if="message.role === 'assistant' && !message.reviewPayload && message.queryPayload?.resultType === 'expense_claim_list'"
class="message-detail-block expense-query-block"
>
<strong>
{{ message.queryPayload.title || (message.queryPayload.selectionMode === 'draft_association' ? '选择关联草稿' : '最近 5 条筛选结果') }}
</strong>
<p v-if="ui.buildExpenseQueryWindowLabel(message.queryPayload)" class="expense-query-window-label">
{{ ui.buildExpenseQueryWindowLabel(message.queryPayload) }}
</p>
<div v-if="message.queryPayload.statusGroups?.length" class="expense-query-summary-row">
<span
v-for="item in message.queryPayload.statusGroups"
:key="`${message.id}-${item.key}`"
class="expense-query-summary-chip"
:class="item.key"
>
{{ item.label }} {{ item.count }}
</span>
</div>
<div v-if="message.queryPayload.records?.length" class="expense-query-record-list compact">
<button
v-for="record in ui.getExpenseQueryVisibleRecords(message.queryPayload)"
:key="`${message.id}-${record.claimId}`"
type="button"
class="expense-query-record-card"
:class="{
selectable: message.queryPayload.selectionMode === 'draft_association',
selected: message.selectedQueryRecordId === record.claimId || message.queryPayload.selectedClaimId === record.claimId,
locked: message.querySelectionLocked || message.queryPayload.selectionLocked
}"
:disabled="message.queryPayload.selectionMode === 'draft_association' && (message.querySelectionLocked || message.queryPayload.selectionLocked)"
@click="ui.handleExpenseQueryRecordClick(message, record)"
>
<div class="expense-query-record-main">
<div class="expense-query-record-top">
<strong>{{ record.claimNo }}</strong>
<span class="expense-query-record-status" :class="record.statusGroup || 'other'">
{{ record.statusLabel }}
</span>
</div>
<p>{{ record.summary }}</p>
<div class="expense-query-record-meta">
<span>{{ record.expenseTypeLabel }}</span>
<span>{{ record.dateDisplay }}</span>
<span>{{ record.amountDisplay }}</span>
</div>
<div v-if="record.riskItems?.length" class="expense-query-risk-row">
<button
v-for="risk in record.riskItems"
:key="`${message.id}-${record.claimId}-${risk.key}`"
type="button"
class="expense-query-risk-chip"
:class="risk.level"
@click.stop="ui.appendExpenseQueryRiskToConversation(record, risk)"
>
<span>{{ record.claimNo }}</span>
<strong>{{ risk.levelLabel }}</strong>
<em>{{ risk.title }}</em>
</button>
</div>
</div>
<i class="mdi mdi-chevron-right"></i>
</button>
<div
v-if="ui.getExpenseQueryTotalPages(message.queryPayload) > 1"
class="expense-query-pager"
>
<button
type="button"
class="expense-query-pager-btn"
:disabled="ui.getExpenseQueryActivePage(message.queryPayload) === 1"
aria-label="上一页"
@click="ui.shiftExpenseQueryPage(message, -1)"
>
<i class="mdi mdi-chevron-left"></i>
</button>
<div class="expense-query-pager-dots" aria-label="单据分页">
<button
v-for="page in ui.getExpenseQueryTotalPages(message.queryPayload)"
:key="`${message.id}-query-page-${page}`"
type="button"
class="expense-query-pager-dot"
:class="{ active: ui.getExpenseQueryActivePage(message.queryPayload) === page }"
:aria-label="` ${page} `"
@click="ui.setExpenseQueryPage(message, page)"
></button>
</div>
<button
type="button"
class="expense-query-pager-btn"
:disabled="ui.getExpenseQueryActivePage(message.queryPayload) === ui.getExpenseQueryTotalPages(message.queryPayload)"
aria-label="下一页"
@click="ui.shiftExpenseQueryPage(message, 1)"
>
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
</div>
<div v-else class="expense-query-empty">
<i class="mdi mdi-file-search-outline"></i>
<span>{{ message.queryPayload.emptyText || '当前没有可直接展开的近期待办单据。' }}</span>
</div>
<p
v-if="ui.buildExpenseQueryHint(message.queryPayload)"
class="expense-query-hint message-answer-markdown"
v-html="ui.renderMarkdown(ui.buildExpenseQueryHint(message.queryPayload))"
@click="ui.handleAssistantMarkdownClick($event, message)"
>
</p>
</div>
<div v-if="message.role === 'assistant' && message.reviewPayload" class="message-detail-block review-message-block">
<div class="review-plain-followup">
<template
v-for="followup in [ui.buildReviewPlainFollowupForMessage(message)]"
:key="`${message.id}-review-followup`"
>
<h3
class="review-plain-lead"
:class="{ danger: followup.tone === 'danger' }"
>
{{ followup.lead }}
</h3>
<p v-if="followup.summary" class="review-plain-summary">
{{ followup.summary }}
</p>
<ul v-if="followup.items.length" class="review-plain-list">
<li
v-for="item in followup.items"
:key="`${message.id}-${item.key}`"
>
<span class="review-plain-label">{{ item.label }}</span>
<span>{{ item.text }}</span>
</li>
</ul>
<p
v-for="line in followup.notes"
:key="`${message.id}-note-${line}`"
class="review-plain-note"
>
{{ line }}
</p>
<p v-if="ui.canUseInlineSaveDraft(message)" class="review-inline-save-copy">
请核查上面的关键信息您也可以暂时不处理上述的这些内容我可以帮你先保存为
<button
type="button"
class="review-inline-draft-link"
:disabled="ui.submitting || ui.reviewActionBusy || ui.sessionSwitchBusy"
@click="ui.handleInlineSaveDraft(message)"
>
草稿
</button>
</p>
</template>
<div
v-if="ui.buildReviewNextStepRichCopyForMessage(message)"
class="review-next-step-rich-copy message-answer-markdown"
v-html="ui.renderMarkdown(ui.buildReviewNextStepRichCopyForMessage(message))"
@click="ui.handleAssistantMarkdownClick($event, message)"
></div>
<div
v-if="ui.resolveReviewFooterActions(message.reviewPayload).length"
class="review-footer-actions"
>
<div class="review-footer-btn-row">
<button
v-for="action in ui.resolveReviewFooterActions(message.reviewPayload)"
:key="`${message.id}-${action.action_type}`"
type="button"
:class="['review-footer-btn', action.emphasis === 'primary' ? 'primary' : '']"
:disabled="ui.submitting || ui.reviewActionBusy || ui.sessionSwitchBusy"
@click="ui.handleReviewAction(message, action)"
>
{{ action.label || ui.buildReviewPrimaryButtonLabel(message.reviewPayload, message.draftPayload) }}
</button>
</div>
</div>
</div>
</div>
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.draftPayload" class="draft-preview">
<header>
<strong>{{ message.draftPayload.title }}</strong>
<span>待人工确认</span>
</header>
<pre>{{ message.draftPayload.body }}</pre>
</div>
<div v-if="message.attachments?.length" class="message-files">
<span v-for="file in message.attachments" :key="file" class="file-chip">
<i class="mdi mdi-paperclip"></i>
{{ file }}
</span>
</div>
</div>
</article>
</template>
<script>
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
export default {
name: 'TravelReimbursementMessageItem',
components: {
EnterpriseSelect
},
props: {
message: {
type: Object,
required: true
},
ui: {
type: Object,
required: true
}
}
}
</script>
<style scoped src="../../assets/styles/components/travel-reimbursement-message-item.css"></style>

View File

@@ -0,0 +1,62 @@
<template>
<ConfirmDialog
:open="open"
:badge="badge"
badge-tone="info"
:title="title"
:description="description"
cancel-text="返回核对"
:confirm-text="confirmText"
:busy-text="busyText"
confirm-tone="primary"
confirm-icon="mdi mdi-check-circle-outline"
:busy="busy"
@close="emit('close')"
@confirm="emit('confirm')"
>
<div class="submit-confirm-summary" aria-label="领导审批通过摘要">
<div class="submit-confirm-row">
<span>单据编号</span>
<strong>{{ documentNo }}</strong>
</div>
<div class="submit-confirm-row">
<span>当前节点</span>
<strong>{{ node }}</strong>
</div>
<div class="submit-confirm-row">
<span>{{ summaryLabel }}</span>
<strong>{{ nextStage }}</strong>
</div>
<div class="submit-confirm-row">
<span>{{ opinionTitle }}</span>
<strong>{{ normalizedOpinion }}</strong>
</div>
</div>
</ConfirmDialog>
</template>
<script setup>
import { computed } from 'vue'
import ConfirmDialog from '../shared/ConfirmDialog.vue'
const props = defineProps({
open: { type: Boolean, required: true },
badge: { type: String, required: true },
title: { type: String, required: true },
description: { type: String, required: true },
confirmText: { type: String, required: true },
busyText: { type: String, required: true },
busy: { type: Boolean, required: true },
documentNo: { type: [String, Number], required: true },
node: { type: String, default: '' },
summaryLabel: { type: String, required: true },
nextStage: { type: String, required: true },
opinionTitle: { type: String, required: true },
opinion: { type: String, default: '' }
})
const emit = defineEmits(['close', 'confirm'])
const normalizedOpinion = computed(() => props.opinion.trim() || '未填写')
</script>

View File

@@ -0,0 +1,31 @@
<template>
<ConfirmDialog
:open="open"
:badge="badge"
badge-tone="danger"
:title="title"
:description="description"
cancel-text="取消"
confirm-text="确认删除"
busy-text="删除中..."
confirm-tone="danger"
confirm-icon="mdi mdi-trash-can-outline"
:busy="busy"
@close="emit('close')"
@confirm="emit('confirm')"
/>
</template>
<script setup>
import ConfirmDialog from '../shared/ConfirmDialog.vue'
defineProps({
open: { type: Boolean, required: true },
badge: { type: String, required: true },
title: { type: String, required: true },
description: { type: String, required: true },
busy: { type: Boolean, required: true }
})
const emit = defineEmits(['close', 'confirm'])
</script>

View File

@@ -0,0 +1,23 @@
<template>
<ReturnReasonDialog
:open="open"
:title="title"
:description="description"
:busy="busy"
@close="emit('close')"
@confirm="emit('confirm', $event)"
/>
</template>
<script setup>
import ReturnReasonDialog from '../shared/ReturnReasonDialog.vue'
defineProps({
open: { type: Boolean, required: true },
title: { type: String, required: true },
description: { type: String, required: true },
busy: { type: Boolean, required: true }
})
const emit = defineEmits(['close', 'confirm'])
</script>