style: 全局 UI 主题皮肤重构与样式模块化
引入 Element Plus 主题定制和主题皮肤 composable,将全局 样式拆分为组件级独立 CSS 文件(侧边栏、顶栏、工作台等), 统一色彩变量和间距规范,重构所有视图和组件样式以适配新 主题系统,优化图表和知识图谱组件视觉表现,提取审计和差 旅报销相关子组件。
This commit is contained in:
334
web/src/components/audit/AuditAssetList.vue
Normal file
334
web/src/components/audit/AuditAssetList.vue
Normal 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>
|
||||
173
web/src/components/audit/AuditJsonRiskRuleDetail.vue
Normal file
173
web/src/components/audit/AuditJsonRiskRuleDetail.vue
Normal 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>
|
||||
60
web/src/components/audit/AuditPickerFilter.vue
Normal file
60
web/src/components/audit/AuditPickerFilter.vue
Normal 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>
|
||||
293
web/src/components/audit/AuditRuleDialogs.vue
Normal file
293
web/src/components/audit/AuditRuleDialogs.vue
Normal 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>
|
||||
112
web/src/components/audit/AuditSpreadsheetChangeDrawer.vue
Normal file
112
web/src/components/audit/AuditSpreadsheetChangeDrawer.vue
Normal 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>
|
||||
159
web/src/components/audit/AuditSpreadsheetRuleDetail.vue
Normal file
159
web/src/components/audit/AuditSpreadsheetRuleDetail.vue
Normal 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>
|
||||
71
web/src/components/audit/AuditVersionTimelineDrawer.vue
Normal file
71
web/src/components/audit/AuditVersionTimelineDrawer.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
72
web/src/components/shared/EnterpriseSelect.vue
Normal file
72
web/src/components/shared/EnterpriseSelect.vue
Normal 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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: '' }
|
||||
})
|
||||
|
||||
|
||||
623
web/src/components/travel/TravelReimbursementInsightPanel.vue
Normal file
623
web/src/components/travel/TravelReimbursementInsightPanel.vue
Normal 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>
|
||||
430
web/src/components/travel/TravelReimbursementMessageItem.vue
Normal file
430
web/src/components/travel/TravelReimbursementMessageItem.vue
Normal 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>
|
||||
62
web/src/components/travel/TravelRequestApprovalDialog.vue
Normal file
62
web/src/components/travel/TravelRequestApprovalDialog.vue
Normal 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>
|
||||
31
web/src/components/travel/TravelRequestDeleteDialog.vue
Normal file
31
web/src/components/travel/TravelRequestDeleteDialog.vue
Normal 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>
|
||||
23
web/src/components/travel/TravelRequestReturnDialog.vue
Normal file
23
web/src/components/travel/TravelRequestReturnDialog.vue
Normal 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>
|
||||
Reference in New Issue
Block a user