refactor: split project into web and server directories

- Move frontend to web/ directory
- Add server/ directory for backend
- Restructure project for前后端分离架构

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 11:00:38 +08:00
parent 9a7b0794a1
commit 9785fb527b
85 changed files with 10474 additions and 10047 deletions

4
.gitignore vendored
View File

@@ -1,5 +1,9 @@
node_modules/ node_modules/
dist/ dist/
.vite/
web/node_modules/
web/dist/
web/.vite/
.omc/ .omc/
.omx/ .omx/
.claude/ .claude/

22
README.md Normal file
View File

@@ -0,0 +1,22 @@
# X-Financial
项目结构已按前后端拆开:
- `web/`:前端工程(当前 Vue + Vite 项目)
- `server/`:后端工程目录
- `docs/`:方案和阶段文档
- `UI/`:界面参考稿
- `document/`:业务文档
从根目录启动前端:
```bash
./start.sh
```
或手动进入前端目录:
```bash
cd web
npm run dev
```

File diff suppressed because it is too large Load Diff

12
server/README.md Normal file
View File

@@ -0,0 +1,12 @@
# Server
后端目录。
当前仓库还没有正式后端实现,这里先独立出 `server/`,后续服务端代码统一放在这里,避免再和前端工程混在根目录或 `web/` 里。
建议后续结构:
- `server/src/`:业务代码
- `server/config/`:配置
- `server/scripts/`:启动、迁移、初始化脚本
- `server/tests/`:后端测试

1
server/src/.gitkeep Normal file
View File

@@ -0,0 +1 @@

File diff suppressed because it is too large Load Diff

View File

@@ -1,341 +0,0 @@
<template>
<section class="qa-view">
<div class="qa-layout">
<aside class="left-column">
<article class="panel side-panel conversation-list">
<header>
<h3>问答会话</h3>
<button class="outline-action" type="button" @click="emit('draft', '')">
<i class="mdi mdi-plus"></i>
<span>新建会话</span>
</button>
</header>
<div class="session-scroll">
<button
v-for="item in sessions"
:key="item.title"
class="session-row"
:class="{ active: item.active }"
type="button"
@click="applyPrompt(item.title)"
>
<span><i class="mdi mdi-message-processing-outline"></i></span>
<strong>{{ item.title }}</strong>
<time>{{ item.time }}</time>
</button>
</div>
</article>
</aside>
<article class="panel chat-panel">
<div ref="localMessageList" class="message-stream" aria-live="polite">
<div class="talk-row user">
<span class="avatar user-avatar"></span>
<div class="talk-content">
<header><strong>张明</strong><time>10:32</time></header>
<p class="user-question">北京出差酒店超标报销怎么处理</p>
</div>
</div>
<div class="talk-row assistant">
<span class="avatar assistant-avatar"><i class="mdi mdi-robot-outline"></i></span>
<div class="talk-content">
<header><strong>财务AI助手</strong><time>10:32</time></header>
<div class="answer-card">
<section>
<h4>结论</h4>
<p>酒店费用超过标准的部分原则上不予报销特殊情况可申请例外报销</p>
</section>
<section>
<h4>处理建议</h4>
<ul>
<li>超标部分由个人自理或按制度退回保留超标说明和相关凭证</li>
<li>符合公司相关政策的可提交佐证材料申请例外报销</li>
</ul>
</section>
<section>
<h4>适用规则</h4>
<ul>
<li>差旅报销管理办法2024第十二条住宿标准及超标处理</li>
<li>费用报销审批流程附件1国内差旅住宿标准</li>
</ul>
</section>
<footer>
<span>是否有帮助</span>
<button type="button" aria-label="有帮助"><i class="mdi mdi-thumb-up-outline"></i></button>
<button type="button" aria-label="无帮助"><i class="mdi mdi-thumb-down-outline"></i></button>
</footer>
</div>
</div>
</div>
<div class="talk-row user">
<span class="avatar user-avatar"></span>
<div class="talk-content">
<header><strong>张明</strong><time>10:35</time></header>
<p class="user-question">如果出差地公司名称不一致还能报销吗</p>
</div>
</div>
<div class="talk-row assistant">
<span class="avatar assistant-avatar"><i class="mdi mdi-robot-outline"></i></span>
<div class="talk-content">
<header><strong>财务AI助手</strong><time>10:35</time></header>
<div class="answer-card compact">
<section>
<h4>结论</h4>
<p>一般情况下差旅地与参会公司名称不一致需按异常处理建议提供情况说明并加盖公章或补充邀请材料</p>
</section>
<section>
<h4>适用规则</h4>
<ul>
<li>发票管理规定及失误销细则第二章发票基本要求</li>
<li>差旅报销管理办法附件1报销凭证要求</li>
</ul>
</section>
</div>
</div>
</div>
<div v-for="message in messages" :key="message.id" class="talk-row" :class="message.role === 'user' ? 'user' : 'assistant'">
<span class="avatar" :class="message.role === 'user' ? 'user-avatar' : 'assistant-avatar'">
<template v-if="message.role === 'user'"></template>
<i v-else class="mdi mdi-robot-outline"></i>
</span>
<div class="talk-content">
<header>
<strong>{{ message.role === 'user' ? '我' : '财务AI助手' }}</strong>
<time>刚刚</time>
</header>
<p :class="message.role === 'user' ? 'user-question' : 'agent-answer'">{{ message.text }}</p>
</div>
</div>
</div>
<div class="composer-wrap">
<div class="prompt-toolbar">
<span>猜你想问</span>
<button v-for="prompt in visiblePrompts" :key="prompt.text" type="button" @click="applyPrompt(prompt.text)">
<i :class="prompt.icon"></i>
{{ prompt.short }}
</button>
<button class="icon-refresh" type="button" aria-label="换一批问题" @click="rotatePrompts">
<i class="mdi mdi-refresh"></i>
</button>
</div>
<div class="composer">
<textarea
:value="draft"
rows="2"
placeholder="请输入你的问题,例如:差旅报销特殊标准是什么?"
@input="emit('draft', $event.target.value)"
@keydown.ctrl.enter.prevent="emit('send')"
></textarea>
<button class="send-button" type="button" aria-label="发送问题" @click="emit('send')">
<i class="mdi mdi-send"></i>
</button>
</div>
</div>
</article>
<aside class="right-column">
<article class="panel info-panel hot-top-panel">
<header>
<h3><i class="mdi mdi-fire"></i> 热门问题 Top10</h3>
<button type="button" @click="rotatePrompts">换一批 <i class="mdi mdi-refresh"></i></button>
</header>
<div class="top-question-list">
<button v-for="(item, index) in hotQuestions" :key="item" type="button" @click="applyPrompt(item)">
<strong>{{ String(index + 1).padStart(2, '0') }}</strong>
<span>{{ item }}</span>
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
</article>
<article class="panel info-panel similar-panel">
<header>
<h3>相似历史问题</h3>
<button type="button">查看全部 <i class="mdi mdi-chevron-right"></i></button>
</header>
<div class="similar-scroll">
<button v-for="item in similarQuestions" :key="item.text" class="similar-row" type="button" @click="applyPrompt(item.text)">
<span><i class="mdi mdi-file-question-outline"></i>{{ item.text }}</span>
<strong>{{ item.score }}</strong>
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
</article>
</aside>
</div>
</section>
</template>
<script setup>
import { computed, nextTick, ref, watch } from 'vue'
const props = defineProps({
documents: { type: Array, required: true },
docSearch: { type: String, default: '' },
messages: { type: Array, required: true },
uploadedFiles: { type: Array, required: true },
activeCase: { type: Object, default: null },
quickPrompts: { type: Array, required: true },
draft: { type: String, default: '' },
messageList: { type: Object, default: null }
})
const emit = defineEmits(['send', 'upload', 'draft', 'approveCase', 'rejectCase', 'selectCase'])
const localMessageList = ref(null)
const promptPage = ref(0)
const sessions = [
{ title: '北京出差,酒店超标报销怎么处理?', time: '10:32', active: true },
{ title: '发票抬头不一致怎么办', time: '09:48' },
{ title: '借款冲销流程', time: '昨天' },
{ title: '预算占用失败处理', time: '昨天' },
{ title: '招待费报销范围', time: '05-11' },
{ title: '差旅住宿标准如何匹配城市级别?', time: '05-10' },
{ title: '电子发票验真失败如何处理?', time: '05-09' },
{ title: '跨部门项目费用怎么归集?', time: '05-08' },
{ title: '会议费和招待费如何区分?', time: '05-07' },
{ title: '超预算申请需要哪些审批节点?', time: '05-06' },
{ title: '海外差旅汇率按哪天计算?', time: '05-05' },
{ title: '员工退票手续费是否可报销?', time: '05-04' }
]
const prompts = [
{ icon: 'mdi mdi-bed-outline', short: '差旅标准', text: '差旅报销特殊标准是什么?' },
{ icon: 'mdi mdi-receipt-text-outline', short: '发票规范', text: '发票丢失如何处理?' },
{ icon: 'mdi mdi-cash-refund', short: '借款冲销', text: '借款多久内需要冲销?' },
{ icon: 'mdi mdi-file-chart-outline', short: '预算冲突', text: '预算不足如何申请?' },
{ icon: 'mdi mdi-shield-check-outline', short: '审批要求', text: '酒店超标后如何申请例外报销?' },
{ icon: 'mdi mdi-office-building-marker', short: '住宿标准', text: '差旅住宿标准按什么规则执行?' },
{ icon: 'mdi mdi-file-question-outline', short: '材料补齐', text: '报销附件缺失怎么补交?' },
{ icon: 'mdi mdi-alert-circle-outline', short: '风险等级', text: '哪些情况会触发中风险?' }
]
const visiblePrompts = computed(() => prompts.slice((promptPage.value % 2) * 4, (promptPage.value % 2) * 4 + 4))
const hotQuestions = [
'差旅报销标准是什么?',
'酒店超标后如何申请例外报销?',
'发票丢失如何处理?',
'借款多久内需要冲销?',
'预算超支如何申请?',
'招待费报销需要哪些凭证?',
'发票抬头不一致是否允许报销?',
'报销附件缺失怎么补交?',
'差旅住宿标准按什么规则执行?',
'电子发票验真失败如何处理?'
]
const similarQuestions = [
{ text: '酒店超标后如何申请例外报销?', score: '96%' },
{ text: '发票抬头不一致是否允许报销?', score: '92%' },
{ text: '差旅住宿标准按什么规则执行?', score: '89%' },
{ text: '预算不足时能否先提交报销?', score: '86%' },
{ text: '电子发票验真失败是否可以先报销?', score: '84%' },
{ text: '跨部门项目费用如何归集?', score: '81%' },
{ text: '招待费报销需要哪些凭证?', score: '78%' },
{ text: '借款冲销逾期会影响报销吗?', score: '76%' }
]
function rotatePrompts() {
promptPage.value += 1
}
function applyPrompt(text) {
emit('draft', text)
}
watch(
() => props.messages.length,
() => {
nextTick(() => localMessageList.value?.scrollTo({ top: localMessageList.value.scrollHeight, behavior: 'smooth' }))
}
)
</script>
<style scoped>
.qa-view { height: 100%; min-height: 0; display: grid; grid-template-rows: minmax(0, 1fr); gap: 0; overflow: hidden; animation: fadeUp 240ms var(--ease) both; }
.qa-layout { height: 100%; min-height: 0; display: grid; grid-template-columns: 330px minmax(0, 1fr) 395px; gap: 14px; overflow: hidden; }
.left-column, .right-column { height: 100%; min-height: 0; overflow: hidden; }
.left-column { display: grid; grid-template-rows: minmax(0, 1fr); }
.right-column { display: grid; grid-template-rows: minmax(0, 1.06fr) minmax(0, .94fr); gap: 12px; }
.side-panel, .info-panel { min-height: 0; padding: 16px 20px; overflow: hidden; }
.conversation-list, .info-panel { display: grid; grid-template-rows: auto minmax(0, 1fr); }
.side-panel header, .info-panel header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.side-panel h3, .info-panel h3 { display: inline-flex; align-items: center; gap: 8px; color: #0f172a; font-size: 17px; font-weight: 850; }
.outline-action, .info-panel header button { height: 34px; display: inline-flex; align-items: center; gap: 6px; border: 1px solid #d8e3ed; border-radius: 8px; background: #fff; color: #0f9f78; font-size: 13px; font-weight: 750; white-space: nowrap; }
.outline-action { padding: 0 12px; }
.info-panel header button { border: 0; color: #64748b; }
.session-scroll, .top-question-list, .similar-scroll { min-height: 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #cbd5e1 transparent; }
.session-scroll { display: grid; align-content: start; gap: 4px; padding-right: 4px; }
.session-row { width: 100%; min-height: 48px; display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 0 10px; border: 0; border-radius: 8px; background: transparent; color: #334155; text-align: left; }
.session-row.active, .session-row:hover { background: #eaf8f1; color: #0f8f68; }
.session-row span { color: #10b981; }
.session-row strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 700; }
.session-row time { color: #94a3b8; font-size: 12px; }
.chat-panel { height: 100%; min-height: 0; display: grid; grid-template-rows: minmax(0, 1fr) auto; overflow: hidden; }
.message-stream { min-height: 0; display: grid; align-content: start; gap: 16px; padding: 16px 18px 8px; overflow-y: auto; scrollbar-width: thin; }
.talk-row { display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 12px; align-items: start; }
.avatar { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 999px; color: #fff; font-size: 15px; font-weight: 850; }
.user-avatar { background: linear-gradient(135deg, #26364d, #61748a); }
.assistant-avatar { background: #10b981; font-size: 20px; }
.talk-content header { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
.talk-content header strong { color: #334155; font-size: 14px; font-weight: 800; }
.talk-content header time { color: #94a3b8; font-size: 12px; }
.user-question { display: inline-block; margin: 0; padding: 9px 16px; border-radius: 8px; background: #e8f5ef; color: #334155; font-size: 14px; line-height: 1.5; }
.answer-card, .agent-answer { max-width: 760px; border: 1px solid #dce5ef; border-radius: 10px; background: #fff; color: #334155; box-shadow: 0 8px 24px rgba(15, 23, 42, .04); }
.answer-card { display: grid; gap: 10px; padding: 13px 18px; }
.answer-card.compact { gap: 10px; }
.answer-card h4 { margin: 0 0 5px; color: #10a272; font-size: 13px; font-weight: 850; }
.answer-card p, .answer-card ul { margin: 0; font-size: 14px; line-height: 1.58; }
.answer-card ul { padding-left: 18px; }
.answer-card footer { display: flex; align-items: center; justify-content: flex-end; gap: 10px; color: #64748b; font-size: 12px; }
.answer-card footer button { width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: 6px; background: transparent; color: #64748b; }
.answer-card footer button:hover { background: #f1f5f9; color: #0f9f78; }
.agent-answer { margin: 0; padding: 12px 16px; font-size: 14px; line-height: 1.65; }
.composer-wrap { border-top: 1px solid #eef2f7; padding: 10px 14px 12px; background: #fff; }
.prompt-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; overflow-x: auto; }
.prompt-toolbar span { flex: 0 0 auto; color: #64748b; font-size: 13px; font-weight: 800; }
.prompt-toolbar button { height: 34px; flex: 0 0 auto; display: inline-flex; align-items: center; gap: 7px; padding: 0 14px; border: 1px solid #dce5ef; border-radius: 8px; background: #fff; color: #334155; font-size: 13px; font-weight: 750; }
.prompt-toolbar button i { color: #10b981; }
.prompt-toolbar .icon-refresh { width: 34px; padding: 0; justify-content: center; }
.composer { min-height: 64px; display: grid; grid-template-columns: minmax(0, 1fr) 48px; align-items: center; gap: 10px; padding: 8px; border: 1px solid #cbd8e5; border-radius: 8px; background: linear-gradient(180deg, #fff, #fbfdff); box-shadow: 0 1px 2px rgba(15, 23, 42, .04); transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease; }
.composer:focus-within { border-color: rgba(16, 185, 129, .58); background: #fff; box-shadow: 0 0 0 3px rgba(16, 185, 129, .11), 0 10px 24px rgba(15, 23, 42, .06); }
.composer textarea { height: 48px; min-height: 48px; resize: none; border: 0; padding: 5px 8px; background: transparent; color: #0f172a; font-size: 14px; line-height: 1.55; }
.composer textarea::placeholder { color: #94a3b8; }
.composer textarea:focus { outline: none; }
.send-button { width: 48px; height: 48px; display: grid; place-items: center; border: 0; border-radius: 8px; background: #10b981; color: #fff; font-size: 20px; box-shadow: 0 8px 18px rgba(16, 185, 129, .20); transition: background 160ms ease, transform 160ms ease, box-shadow 160ms ease; }
.send-button:hover { background: #0ea672; box-shadow: 0 10px 22px rgba(16, 185, 129, .24); }
.send-button:active { transform: scale(.96); }
.hot-top-panel h3 i { color: #ef4444; }
.top-question-list { display: grid; align-content: start; gap: 8px; padding-right: 4px; }
.top-question-list button { min-height: 42px; display: grid; grid-template-columns: 34px minmax(0, 1fr) 14px; align-items: center; gap: 10px; padding: 0 8px; border: 1px solid #e2e8f0; border-radius: 8px; background: #fff; color: #334155; text-align: left; }
.top-question-list button:hover { border-color: rgba(16, 185, 129, .32); color: #0f9f78; }
.top-question-list strong { color: #10b981; font-size: 13px; font-weight: 850; font-variant-numeric: tabular-nums; }
.top-question-list span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 750; }
.top-question-list i { color: #94a3b8; }
.similar-panel { display: grid; }
.similar-scroll { display: grid; align-content: start; padding-right: 4px; }
.similar-row { min-height: 46px; display: grid; grid-template-columns: minmax(0, 1fr) 48px 14px; align-items: center; gap: 10px; border: 0; border-top: 1px solid #eef2f7; background: transparent; color: #334155; text-align: left; }
.similar-row:first-child { border-top: 0; }
.similar-row span { min-width: 0; display: inline-flex; align-items: center; gap: 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 700; }
.similar-row span i { color: #64748b; font-size: 17px; }
.similar-row strong { height: 26px; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; background: #e8f8f0; color: #15945f; font-size: 13px; font-weight: 850; }
.similar-row > i { color: #94a3b8; }
@media (max-width: 1480px) { .qa-layout { grid-template-columns: 300px minmax(0, 1fr) 360px; } }
@media (max-width: 1280px) {
.qa-layout { grid-template-columns: 1fr; overflow-y: auto; }
.left-column, .right-column { grid-template-columns: repeat(2, minmax(0, 1fr)); overflow: visible; }
}
@media (max-width: 760px) {
.left-column, .right-column { grid-template-columns: 1fr; }
.composer { grid-template-columns: minmax(0, 1fr) 48px; }
}
</style>

View File

@@ -1,610 +0,0 @@
<template>
<section class="dashboard">
<div class="kpi-grid">
<article
v-for="metric in kpiMetrics"
:key="metric.label"
class="kpi-card panel"
:style="{ '--accent': metric.accent, '--delay': `${metric.delay}ms` }"
>
<div class="kpi-head">
<span class="kpi-icon"><i :class="metric.icon"></i></span>
<span class="kpi-label">{{ metric.label }}</span>
</div>
<strong class="kpi-value">{{ metric.displayValue }}</strong>
<div class="kpi-trend">
<span class="kpi-badge" :class="metric.trend">
<i :class="metric.trend === 'down' ? 'mdi mdi-arrow-down' : 'mdi mdi-arrow-up'"></i>
{{ metric.changeText }}
</span>
<span class="kpi-delta">{{ metric.delta }}</span>
</div>
</article>
</div>
<div class="content-grid top-grid">
<article class="panel dashboard-card trend-panel">
<div class="card-head">
<h3>报销申请与审批趋势 <i class="mdi mdi-information-outline"></i></h3>
<select v-model="activeTrendRange" class="card-select" aria-label="趋势时间范围">
<option v-for="range in trendRanges" :key="range">{{ range }}</option>
</select>
</div>
<TrendChart
:labels="activeTrend.labels"
:applications="activeTrend.applications"
:approved="activeTrend.approved"
:avg-hours="activeTrend.avgHours"
/>
</article>
<article class="panel dashboard-card donut-panel">
<div class="card-head">
<h3>费用结构 <i class="mdi mdi-information-outline"></i></h3>
</div>
<DonutChart :items="spendLegend" center-value="¥361.6K" center-label="待处理金额" />
<p class="panel-note">* 百分比为占待处理金额比例</p>
</article>
<article class="panel dashboard-card donut-panel">
<div class="card-head">
<h3>风险异常分布 <i class="mdi mdi-information-outline"></i></h3>
</div>
<DonutChart :items="riskLegend" :center-value="`${riskTotal}`" center-label="异常预警单" />
<p class="panel-note">* 近30天数据</p>
</article>
</div>
<div class="content-grid bottom-grid">
<article class="panel dashboard-card rank-panel">
<div class="card-head">
<h3>部门报销排行待处理金额 <i class="mdi mdi-information-outline"></i></h3>
<select v-model="activeDepartmentRange" class="card-select" aria-label="部门排行时间范围">
<option v-for="range in departmentRangeOptions" :key="range">{{ range }}</option>
</select>
</div>
<BarChart :items="rankedDepartments" />
</article>
<article class="panel dashboard-card bottleneck-panel">
<div class="card-head">
<h3>审批瓶颈平均处理时长 <i class="mdi mdi-information-outline"></i></h3>
</div>
<div class="bottleneck-list">
<div
v-for="(item, index) in bottlenecks"
:key="item.name"
class="bottleneck-row"
:style="{ '--delay': `${index * 70}ms` }"
>
<div class="reviewer">
<div class="reviewer-avatar">{{ item.avatar }}</div>
<div>
<strong>{{ item.name }}</strong>
<span>{{ item.role }}</span>
</div>
</div>
<div class="reviewer-stats">
<strong>{{ item.duration }}</strong>
<span class="status-tag" :class="item.tone">{{ item.status }}</span>
</div>
</div>
</div>
<button type="button" class="text-link">查看全部 <i class="mdi mdi-chevron-right"></i></button>
</article>
<article class="panel dashboard-card budget-panel">
<div class="card-head">
<h3>预算执行率本月 <i class="mdi mdi-information-outline"></i></h3>
</div>
<GaugeChart
:ratio="budgetSummary.ratio"
:total="budgetSummary.total"
:used="budgetSummary.used"
:left="budgetSummary.left"
/>
<button type="button" class="text-link">查看详情 <i class="mdi mdi-chevron-right"></i></button>
</article>
</div>
<PersonalWorkbench @open-assistant="emit('ask')" />
</section>
</template>
<script setup>
import { computed, ref } from 'vue'
import {
metricBlueprints,
trendRanges,
trendSeries,
spendByCategory,
exceptionMix,
departmentRangeOptions,
bottlenecks,
budgetSummary
} from '../data/metrics.js'
import TrendChart from '../components/charts/TrendChart.vue'
import DonutChart from '../components/charts/DonutChart.vue'
import BarChart from '../components/charts/BarChart.vue'
import GaugeChart from '../components/charts/GaugeChart.vue'
import PersonalWorkbench from '../components/business/PersonalWorkbench.vue'
defineProps({
filteredRequests: { type: Array, required: true }
})
const emit = defineEmits(['ask'])
const activeTrendRange = ref(trendRanges[0])
const activeDepartmentRange = ref(departmentRangeOptions[0])
const demoTotals = {
pendingCount: 128,
pendingAmount: 361600,
avgSla: 6.8,
autoPassRate: 78,
riskCount: 14,
slaRate: 96
}
const demoDepartments = [
{ name: '销售部', amount: 182000, color: '#10b981' },
{ name: '研发中心', amount: 146000, color: '#3b82f6' },
{ name: '市场部', amount: 96000, color: '#f59e0b' },
{ name: '运营部', amount: 68600, color: '#8b5cf6' },
{ name: '行政部', amount: 48300, color: '#3b82f6' }
]
const formatCompact = (value) => {
if (value >= 1_000_000) return `¥${(value / 1_000_000).toFixed(1)}M`
if (value >= 1_000) return `¥${(value / 1_000).toFixed(1)}K`
return `¥${value}`
}
const formatCurrency = (value) => formatCompact(value)
const formatMetricValue = (metric, value) => {
if (metric.key === 'pendingAmount') return formatCurrency(Math.round(value))
if (metric.key === 'avgSla') return `${value.toFixed(1)} ${metric.unit}`
if (metric.unit === '%') return `${Math.round(value)} ${metric.unit}`
if (metric.unit) return `${Math.round(value)} ${metric.unit}`
return `${Math.round(value)}`
}
const kpiMetrics = computed(() => metricBlueprints.map((metric, index) => {
const rawValue = demoTotals[metric.key]
const displayValue = formatMetricValue(metric, rawValue)
return {
...metric,
displayValue,
changeText: metric.change,
delay: index * 55
}
}))
const activeTrend = computed(() => trendSeries[activeTrendRange.value])
const spendTotal = computed(() => spendByCategory.reduce((sum, item) => sum + item.value, 0))
const riskTotal = computed(() => exceptionMix.reduce((sum, item) => sum + item.value, 0))
const spendLegend = computed(() => spendByCategory.map((item) => ({
...item,
display: `${Math.round((item.value / spendTotal.value) * 100)}%`
})))
const riskLegend = computed(() => exceptionMix.map((item) => ({
...item,
display: `${item.value}`
})))
const rankedDepartments = computed(() => {
const rows = demoDepartments
const max = Math.max(...rows.map((item) => item.amount), 1)
return rows.slice(0, 5).map((item, index) => ({
...item,
rank: index + 1,
shortName: item.name,
amountLabel: formatCurrency(item.amount),
width: `${Math.max((item.amount / max) * 100, 18)}%`,
color: item.color
}))
})
</script>
<style scoped>
.dashboard {
display: grid;
gap: 16px;
animation: fadeUp 260ms var(--ease) both;
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px;
}
.kpi-card {
position: relative;
padding: 12px 14px 10px;
display: flex;
flex-direction: column;
border-left: 3px solid var(--accent);
animation: dashboardItemIn 520ms var(--ease) both;
animation-delay: var(--delay, 0ms);
transition: box-shadow 200ms ease, transform 200ms ease;
}
.kpi-card:hover {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
transform: translateY(-1px);
}
.kpi-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.kpi-icon {
width: 26px;
height: 26px;
display: grid;
place-items: center;
border-radius: 7px;
background: color-mix(in srgb, var(--accent) 10%, white);
color: var(--accent);
font-size: 14px;
flex: 0 0 auto;
animation: iconPop 560ms var(--ease) both;
animation-delay: calc(var(--delay, 0ms) + 100ms);
}
.kpi-label {
color: #64748b;
font-size: 11px;
font-weight: 500;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.kpi-value {
display: block;
min-height: 22px;
color: #0f172a;
font-size: clamp(16px, 1.2vw, 20px);
line-height: 1;
font-weight: 800;
font-variant-numeric: tabular-nums;
white-space: nowrap;
margin-bottom: 6px;
letter-spacing: 0;
}
.kpi-trend {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding-top: 6px;
border-top: 1px solid #f1f5f9;
}
.kpi-badge {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 1px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 700;
line-height: 1.45;
}
.kpi-badge.up {
background: rgba(239, 68, 68, 0.08);
color: #dc2626;
}
.kpi-badge.down {
background: rgba(22, 163, 74, 0.08);
color: #16a34a;
}
.kpi-badge .mdi {
font-size: 11px;
}
.kpi-delta {
color: #94a3b8;
font-size: 10px;
white-space: nowrap;
}
.content-grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 24px;
}
.dashboard-card {
padding: 20px;
transition: box-shadow 200ms ease, transform 200ms ease;
animation: dashboardItemIn 560ms var(--ease) both;
}
.top-grid .dashboard-card:nth-child(1) { animation-delay: 80ms; }
.top-grid .dashboard-card:nth-child(2) { animation-delay: 150ms; }
.top-grid .dashboard-card:nth-child(3) { animation-delay: 220ms; }
.bottom-grid .dashboard-card:nth-child(1) { animation-delay: 290ms; }
.bottom-grid .dashboard-card:nth-child(2) { animation-delay: 360ms; }
.bottom-grid .dashboard-card:nth-child(3) { animation-delay: 430ms; }
.dashboard-card:hover {
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
transform: translateY(-1px);
}
.trend-panel,
.rank-panel {
grid-column: span 6;
}
.donut-panel,
.bottleneck-panel,
.budget-panel {
grid-column: span 3;
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.card-head h3 {
color: #1e293b;
font-size: 16px;
font-weight: 600;
line-height: 1.35;
}
.card-head .mdi {
color: #94a3b8;
font-size: 12px;
vertical-align: 1px;
}
.card-select {
height: 30px;
min-width: 82px;
padding: 0 8px;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #fff;
color: #334155;
font-size: 14px;
}
.panel-note {
margin-top: 8px;
color: #64748b;
font-size: 12px;
text-align: center;
}
.bottleneck-panel,
.budget-panel {
display: flex;
flex-direction: column;
}
.bottleneck-panel .text-link,
.budget-panel .text-link {
margin-top: auto;
}
.bottleneck-list {
flex: 1;
display: grid;
gap: 16px;
align-content: center;
}
.bottleneck-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
animation: listRowIn 460ms var(--ease) both;
animation-delay: var(--delay, 0ms);
}
.reviewer {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.reviewer-avatar {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 999px;
background: #e2f6ef;
color: #047857;
font-size: 13px;
font-weight: 700;
flex: 0 0 auto;
}
.reviewer strong,
.reviewer-stats strong {
display: block;
color: #1e293b;
font-size: 14px;
font-weight: 500;
}
.reviewer span,
.reviewer-stats span {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 12px;
}
.reviewer-stats {
text-align: right;
}
.status-tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
}
.status-tag.danger {
background: rgba(239,68,68,.10);
color: #ef4444;
}
.status-tag.warning {
background: rgba(245,158,11,.10);
color: #f59e0b;
}
.status-tag.success {
background: rgba(16,185,129,.10);
color: #16a34a;
}
.text-link {
width: 100%;
margin-top: 16px;
display: inline-flex;
align-items: center;
justify-content: space-between;
padding: 16px 0 0;
border: 0;
border-top: 1px solid #f1f5f9;
background: transparent;
color: #10b981;
font-size: 14px;
}
@keyframes dashboardItemIn {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes listRowIn {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes iconPop {
0% {
opacity: 0;
transform: scale(.82);
}
70% {
opacity: 1;
transform: scale(1.04);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.kpi-card,
.dashboard-card,
.bottleneck-row {
animation: none;
}
}
@media (max-width: 1320px) {
.kpi-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.trend-panel,
.rank-panel {
grid-column: span 12;
}
.donut-panel,
.bottleneck-panel,
.budget-panel {
grid-column: span 6;
}
}
@media (max-width: 860px) {
.kpi-grid,
.content-grid {
grid-template-columns: 1fr;
}
.trend-panel,
.rank-panel,
.donut-panel,
.bottleneck-panel,
.budget-panel {
grid-column: span 1;
}
.card-head {
align-items: flex-start;
flex-direction: column;
}
.donut-wrap {
grid-template-columns: 1fr;
}
.rank-row {
grid-template-columns: 24px 64px minmax(0, 1fr);
}
.rank-value {
grid-column: 2 / -1;
}
.budget-summary {
grid-template-columns: 1fr;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,740 +0,0 @@
<template>
<section class="travel-page">
<article class="travel-list panel">
<nav class="status-tabs" aria-label="差旅报销状态">
<button
v-for="tab in tabs"
:key="tab"
type="button"
:class="{ active: activeTab === tab }"
@click="activeTab = tab"
>
{{ tab }}
</button>
</nav>
<div class="list-toolbar">
<div class="filter-set">
<div class="list-search">
<i class="mdi mdi-magnify"></i>
<input type="search" placeholder="搜索申请人、单号、费用类型..." />
</div>
<div class="date-range-filter" :class="{ open: datePopover }">
<button class="filter-btn date-range-trigger" type="button" @click="datePopover = !datePopover">
<span class="date-range-label">{{ dateRangeLabel }}</span>
<i class="mdi mdi-calendar"></i>
</button>
<div v-if="datePopover" class="date-range-popover" role="dialog" aria-label="选择时间段">
<header>
<strong>选择时间段</strong>
<button type="button" aria-label="关闭" @click="datePopover = false"><i class="mdi mdi-close"></i></button>
</header>
<div class="date-range-fields">
<label>
<span>开始日期</span>
<input v-model="rangeStart" type="date" />
</label>
<label>
<span>结束日期</span>
<input v-model="rangeEnd" type="date" />
</label>
</div>
<footer>
<button class="ghost-btn" type="button" @click="datePopover = false">取消</button>
<button class="apply-btn" type="button" :disabled="!rangeStart || !rangeEnd" @click="applyDateRange">应用</button>
</footer>
</div>
</div>
<button v-for="filter in filters" :key="filter" type="button" class="filter-btn">
<span>{{ filter }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
</div>
<button class="create-request-btn" type="button" @click="emit('create-request')">
<i class="mdi mdi-plus-circle-outline"></i>
<span>发起报销</span>
</button>
</div>
<p class="hint"><i class="mdi mdi-information-outline"></i> 点击任意行可查看单据详情</p>
<div class="table-wrap">
<table>
<colgroup>
<col class="col-id">
<col class="col-reason">
<col class="col-city">
<col class="col-period">
<col class="col-apply">
<col class="col-amount">
<col class="col-node">
<col class="col-approval">
<col class="col-travel">
</colgroup>
<thead>
<tr>
<th>单号</th>
<th>出差事由</th>
<th>出差城市</th>
<th>出差时间</th>
<th>申请时间</th>
<th>申请金额</th>
<th>当前节点</th>
<th>审批状态</th>
<th>商旅状态</th>
</tr>
</thead>
<tbody>
<tr v-for="row in visibleRows" :key="row.id" @click="emit('ask', row)">
<td><strong class="doc-id">{{ row.id }}</strong></td>
<td>{{ row.reason }}</td>
<td>{{ row.city }}</td>
<td>{{ row.period }}</td>
<td>{{ row.applyTime }}</td>
<td>{{ row.amount }}</td>
<td>{{ row.node }}</td>
<td><span class="status-tag" :class="row.approvalTone">{{ row.approval }}</span></td>
<td><span class="status-tag" :class="row.travelTone">{{ row.travel }}</span></td>
</tr>
</tbody>
</table>
</div>
<footer class="list-foot">
<span class="page-summary"> {{ totalCount }} 目前第 {{ currentPage }} </span>
<div class="pager" aria-label="分页">
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--"><i class="mdi mdi-chevron-left"></i></button>
<button v-for="p in totalPages" :key="p" class="page-number" :class="{ active: currentPage === p }" type="button" :aria-current="currentPage === p ? 'page' : undefined" @click="currentPage = p">{{ p }}</button>
<button class="page-nav" type="button" :disabled="currentPage === totalPages" aria-label="下一页" @click="currentPage++"><i class="mdi mdi-chevron-right"></i></button>
</div>
<div class="page-size-wrap">
<button class="page-size" type="button" @click="pageSizeOpen = !pageSizeOpen">
{{ pageSize }} / <i class="mdi mdi-chevron-down"></i>
</button>
<div v-if="pageSizeOpen" class="page-size-dropdown" role="listbox">
<button v-for="size in pageSizes" :key="size" type="button" role="option" :aria-selected="pageSize === size" :class="{ active: pageSize === size }" @click="changePageSize(size)">{{ size }} /</button>
</div>
</div>
</footer>
</article>
</section>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
defineProps({
filteredRequests: { type: Array, required: true }
})
const emit = defineEmits(['ask', 'approve', 'reject', 'create-request'])
const activeTab = ref('全部')
const tabs = ['全部', '待提交', '审批中', '待出行', '已完成']
const filters = ['报销状态', '出差城市', '费用类型']
const datePopover = ref(false)
const rangeStart = ref('')
const rangeEnd = ref('')
const appliedStart = ref('')
const appliedEnd = ref('')
const dateRangeLabel = computed(() => {
if (appliedStart.value && appliedEnd.value) return `${appliedStart.value} ~ ${appliedEnd.value}`
return '选择时间段'
})
function applyDateRange() {
if (!rangeStart.value || !rangeEnd.value) return
appliedStart.value = rangeStart.value
appliedEnd.value = rangeEnd.value
datePopover.value = false
}
const rows = [
{ id: 'BR240715001', reason: '华东区域客户拜访', city: '上海、苏州、杭州', period: '07-14~07-17 (4天)', applyTime: '2024-07-13', amount: '¥4,280.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '待预订酒店', travelTone: 'warning' },
{ id: 'BR240714010', reason: '年度战略合作伙伴会议', city: '北京', period: '07-15~07-16 (2天)', applyTime: '2024-07-12', amount: '¥1,860.00', node: '待提交', approval: '待提交', approvalTone: 'info', travel: '待订机票', travelTone: 'warning' },
{ id: 'BR240713008', reason: '产品培训与交流', city: '深圳', period: '07-10~07-12 (3天)', applyTime: '2024-07-09', amount: '¥2,150.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240712001', reason: '客户方案汇报', city: '上海', period: '07-08~07-11 (4天)', applyTime: '2024-07-07', amount: '¥3,680.00', node: '财务审核', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240711005', reason: '华南区域市场调研', city: '广州、佛山', period: '07-09~07-11 (3天)', applyTime: '2024-07-06', amount: '¥1,920.00', node: '待提交', approval: '待提交', approvalTone: 'info', travel: '无需预订', travelTone: 'neutral' },
{ id: 'BR240710003', reason: '供应商现场考察', city: '东莞', period: '07-06~07-07 (2天)', applyTime: '2024-07-05', amount: '¥680.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240709005', reason: '客户方案汇报', city: '北京', period: '07-06~07-08 (3天)', applyTime: '2024-07-05', amount: '¥1,980.00', node: '财务审核', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240708012', reason: '供应商现场考察', city: '广州', period: '07-04~07-05 (2天)', applyTime: '2024-07-03', amount: '¥860.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '无需预订', travelTone: 'neutral' },
{ id: 'BR240707003', reason: '项目启动会', city: '成都', period: '07-01~07-03 (3天)', applyTime: '2024-06-29', amount: '¥2,420.00', node: '财务审核', approval: '审批中', approvalTone: 'info', travel: '待预订酒店', travelTone: 'warning' },
{ id: 'BR240706009', reason: '客户拜访与市场调研', city: '南京、合肥', period: '06-28~06-30 (3天)', applyTime: '2024-06-26', amount: '¥1,750.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240705007', reason: '技术交流会', city: '武汉', period: '06-25~06-26 (2天)', applyTime: '2024-06-23', amount: '¥1,120.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '无需预订', travelTone: 'neutral' },
{ id: 'BR240704004', reason: '渠道合作洽谈', city: '西安', period: '06-20~06-21 (2天)', applyTime: '2024-06-18', amount: '¥780.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240703011', reason: '新员工入职培训', city: '长沙', period: '06-18~06-19 (2天)', applyTime: '2024-06-16', amount: '¥920.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240702006', reason: '季度业绩复盘会', city: '杭州', period: '06-15~06-16 (2天)', applyTime: '2024-06-13', amount: '¥1,350.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240701002', reason: '智慧金融峰会参展', city: '上海', period: '06-12~06-14 (3天)', applyTime: '2024-06-10', amount: '¥5,680.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240630009', reason: '西南区域渠道拓展', city: '重庆、贵阳', period: '06-10~06-13 (4天)', applyTime: '2024-06-08', amount: '¥3,450.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240629003', reason: '信息安全合规审计', city: '深圳', period: '06-08~06-09 (2天)', applyTime: '2024-06-06', amount: '¥1,180.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '无需预订', travelTone: 'neutral' },
{ id: 'BR240628007', reason: '产学研合作对接', city: '南京', period: '06-05~06-07 (3天)', applyTime: '2024-06-03', amount: '¥2,260.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240627001', reason: 'ERP系统上线支持', city: '青岛', period: '06-03~06-05 (3天)', applyTime: '2024-06-01', amount: '¥1,960.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240626004', reason: '大客户续约洽谈', city: '天津', period: '06-01~06-02 (2天)', applyTime: '2024-05-29', amount: '¥890.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240625010', reason: '区域销售团队建设', city: '厦门', period: '05-28~05-30 (3天)', applyTime: '2024-05-26', amount: '¥2,780.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240624002', reason: '供应链管理系统演示', city: '苏州', period: '05-25~05-26 (2天)', applyTime: '2024-05-23', amount: '¥650.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '无需预订', travelTone: 'neutral' },
{ id: 'BR240623008', reason: '行业白皮书发布会', city: '北京', period: '05-22~05-23 (2天)', applyTime: '2024-05-20', amount: '¥1,560.00', node: '待提交', approval: '待提交', approvalTone: 'info', travel: '待订机票', travelTone: 'warning' },
{ id: 'BR240622005', reason: '跨部门协同工作坊', city: '大连', period: '05-20~05-22 (3天)', applyTime: '2024-05-18', amount: '¥2,340.00', node: '待提交', approval: '待提交', approvalTone: 'info', travel: '无需预订', travelTone: 'neutral' },
{ id: 'BR240621003', reason: '数字化转型的客户交流', city: '深圳、珠海', period: '05-16~05-18 (3天)', applyTime: '2024-05-14', amount: '¥3,120.00', node: '待提交', approval: '待提交', approvalTone: 'info', travel: '待预订酒店', travelTone: 'warning' },
{ id: 'BR240620006', reason: '年中预算评审会', city: '上海', period: '05-13~05-14 (2天)', applyTime: '2024-05-11', amount: '¥1,480.00', node: '财务审核', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240619001', reason: '医疗行业解决方案展', city: '成都', period: '05-10~05-12 (3天)', applyTime: '2024-05-08', amount: '¥3,860.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240618009', reason: '东北区域客户回访', city: '沈阳、长春', period: '05-06~05-09 (4天)', applyTime: '2024-05-04', amount: '¥4,520.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '待订机票', travelTone: 'warning' },
{ id: 'BR240617007', reason: '大数据平台技术对接', city: '杭州', period: '05-03~05-05 (3天)', applyTime: '2024-05-01', amount: '¥2,180.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240616004', reason: '国际业务合规培训', city: '北京', period: '04-28~04-30 (3天)', applyTime: '2024-04-26', amount: '¥2,960.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' }
]
const currentPage = ref(1)
const pageSize = ref(10)
const pageSizes = [10, 20, 50]
const pageSizeOpen = ref(false)
function changePageSize(size) {
pageSize.value = size
pageSizeOpen.value = false
currentPage.value = 1
}
const filteredRows = computed(() => {
if (activeTab.value === '全部') return rows
return rows.filter((row) => row.approval === activeTab.value || row.travel.includes(activeTab.value.replace('待出行', '待订')))
})
const totalCount = computed(() => filteredRows.value.length)
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
const visibleRows = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredRows.value.slice(start, start + pageSize.value)
})
watch(activeTab, () => { currentPage.value = 1 })
</script>
<style scoped>
.travel-page {
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: minmax(0, 1fr);
gap: 14px;
animation: fadeUp 220ms var(--ease) both;
overflow: hidden;
}
.travel-list {
min-height: 0;
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
padding: 16px 18px;
overflow: hidden;
}
.list-search {
position: relative;
width: 220px;
}
.list-search .mdi {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #64748b;
font-size: 15px;
}
.list-search input {
width: 100%;
height: 38px;
padding: 0 12px 0 36px;
border: 1px solid #d7e0ea;
border-radius: 8px;
background: #fff;
color: #0f172a;
font-size: 13px;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.list-search input::placeholder {
color: #8da0b4;
}
.list-search input:focus {
border-color: #10b981;
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.14);
outline: none;
}
.status-tabs {
display: flex;
gap: 28px;
margin-top: 14px;
border-bottom: 1px solid #dbe4ee;
}
.status-tabs button {
position: relative;
min-height: 36px;
border: 0;
background: transparent;
color: #64748b;
font-size: 14px;
font-weight: 750;
}
.status-tabs button.active {
color: #059669;
}
.status-tabs button.active::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 3px;
border-radius: 999px 999px 0 0;
background: #10b981;
}
.list-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 14px;
}
.filter-set {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.create-request-btn {
min-height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 18px;
border: 0;
border-radius: 10px;
background: linear-gradient(135deg, #10b981, #059669);
color: #fff;
font-size: 14px;
font-weight: 800;
white-space: nowrap;
box-shadow: 0 10px 24px rgba(5, 150, 105, 0.2);
transition: transform 160ms ease, box-shadow 160ms ease, filter 160ms ease;
}
.create-request-btn:hover {
transform: translateY(-1px);
box-shadow: 0 14px 28px rgba(5, 150, 105, 0.24);
filter: saturate(1.02);
}
.filter-btn,
.page-size {
min-height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
padding: 0 14px;
border-radius: 8px;
font-size: 14px;
font-weight: 750;
white-space: nowrap;
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
}
.filter-btn {
min-width: 120px;
justify-content: space-between;
}
.date-range-filter {
position: relative;
}
.date-range-trigger {
min-width: 160px;
}
.date-range-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 110px;
}
.date-range-popover {
position: absolute;
top: calc(100% + 8px);
left: 0;
width: 320px;
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);
}
.date-range-popover header,
.date-range-popover footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.date-range-popover header strong {
color: #0f172a;
font-size: 15px;
}
.date-range-popover header button {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border: 0;
border-radius: 8px;
background: transparent;
color: #64748b;
}
.date-range-popover header button:hover {
background: #f1f5f9;
color: #0f172a;
}
.date-range-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.date-range-fields label {
display: grid;
gap: 6px;
}
.date-range-fields span {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.date-range-fields input {
width: 100%;
height: 38px;
padding: 0 9px;
border: 1px solid #d7e0ea;
border-radius: 8px;
color: #0f172a;
font-size: 13px;
}
.date-range-fields input:focus {
border-color: #10b981;
box-shadow: 0 0 0 3px rgba(16, 185, 129, .12);
outline: none;
}
.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;
}
.filter-btn:hover,
.page-size:hover {
border-color: rgba(16, 185, 129, .32);
color: #0f9f78;
}
.hint {
display: inline-flex;
align-items: center;
gap: 7px;
margin-top: 10px;
color: #64748b;
font-size: 13px;
}
.hint .mdi {
color: #64748b;
}
.table-wrap {
margin-top: 10px;
overflow-x: auto;
overflow-y: auto;
border: 1px solid #edf2f7;
border-radius: 10px;
}
table {
height: 100%;
width: 100%;
min-width: 1140px;
border-collapse: collapse;
table-layout: fixed;
}
colgroup col {
width: 10%;
}
th,
td {
padding: 13px 12px;
border-bottom: 1px solid #edf2f7;
color: #24324a;
font-size: 14px;
line-height: 1.35;
text-align: center;
vertical-align: middle;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
th {
position: sticky;
top: 0;
z-index: 1;
background: #f7fafc;
color: #64748b;
font-size: 13px;
font-weight: 800;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
tbody tr {
cursor: pointer;
}
tbody tr:hover {
background: linear-gradient(90deg, rgba(16, 185, 129, .08), rgba(16, 185, 129, .03));
}
tbody tr:last-child td {
border-bottom: 0;
}
.doc-id {
color: #059669;
font-weight: 800;
}
.status-tag {
min-height: 24px;
display: inline-flex;
align-items: center;
padding: 0 9px;
border: 1px solid transparent;
border-radius: 6px;
font-size: 12px;
font-weight: 750;
white-space: nowrap;
}
.status-tag.info {
border-color: #bfdbfe;
background: #eff6ff;
color: #2563eb;
}
.status-tag.success {
border-color: #bbf7d0;
background: #ecfdf5;
color: #059669;
}
.status-tag.warning {
border-color: #fed7aa;
background: #fff7ed;
color: #f97316;
}
.status-tag.neutral {
border-color: #cbd5e1;
background: #f8fafc;
color: #475569;
}
.list-foot {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
margin-top: 24px;
}
.page-summary {
color: #64748b;
font-size: 14px;
font-weight: 650;
}
.pager {
display: inline-flex;
justify-content: center;
gap: 6px;
padding: 4px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
}
.pager button {
width: 32px;
height: 32px;
border: 0;
border-radius: 9px;
background: transparent;
color: #334155;
font-size: 14px;
font-weight: 800;
transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease;
}
.pager button:hover:not(.active) {
background: #fff;
color: #059669;
box-shadow: 0 1px 4px rgba(15, 23, 42, .08);
}
.pager button.active {
background: #059669;
color: #fff;
box-shadow: 0 8px 16px rgba(5, 150, 105, .20);
}
.page-nav {
color: #64748b;
}
.page-size {
justify-self: end;
min-width: 112px;
border-radius: 10px;
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
}
.page-size-wrap {
position: relative;
justify-self: end;
}
.page-size-dropdown {
position: absolute;
bottom: calc(100% + 6px);
right: 0;
z-index: 40;
display: grid;
border: 1px solid #d7e0ea;
border-radius: 10px;
background: #fff;
box-shadow: 0 12px 32px rgba(15, 23, 42, .14);
overflow: hidden;
}
.page-size-dropdown button {
height: 36px;
display: grid;
place-items: center;
border: 0;
border-radius: 0;
background: transparent;
color: #334155;
font-size: 13px;
font-weight: 750;
white-space: nowrap;
padding: 0 20px;
transition: background 120ms ease, color 120ms ease;
}
.page-size-dropdown button:hover {
background: #f0fdf4;
color: #059669;
}
.page-size-dropdown button.active {
background: #059669;
color: #fff;
}
@media (max-width: 1200px) {
.list-toolbar,
.list-foot {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.travel-list {
padding: 16px;
}
.status-tabs {
gap: 18px;
overflow-x: auto;
}
.filter-btn,
.page-size {
width: 100%;
}
.filter-set {
width: 100%;
}
.list-foot {
display: grid;
justify-items: stretch;
}
.pager,
.page-size {
justify-self: stretch;
}
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,86 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# ============================================================
# X-Financial Reimbursement Admin - Start Script
# ============================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR" cd "$SCRIPT_DIR/web"
# Colors exec ./start.sh
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
# ----------------------------------------------------------
# Check Node.js
# ----------------------------------------------------------
if ! command -v node &>/dev/null; then
error "Node.js is not installed. Install it first: https://nodejs.org"
fi
if ! command -v npm &>/dev/null; then
error "npm is not installed. It should come with Node.js."
fi
info "Node.js $(node -v) | npm $(npm -v)"
# ----------------------------------------------------------
# WSL on a Windows-mounted repo should reuse Windows Node
# ----------------------------------------------------------
is_wsl() {
grep -qi microsoft /proc/version 2>/dev/null
}
is_windows_mount() {
case "$SCRIPT_DIR" in
/mnt/*) return 0 ;;
*) return 1 ;;
esac
}
if is_wsl && is_windows_mount && command -v powershell.exe &>/dev/null && command -v wslpath &>/dev/null; then
WIN_PATH="$(wslpath -w "$SCRIPT_DIR")"
WIN_PATH_PS="${WIN_PATH//\'/\'\'}"
info "Detected WSL on a Windows-mounted project"
info "Using Windows npm to avoid cross-platform node_modules installs"
info "Access: http://127.0.0.1:5173"
echo ""
exec powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Set-Location -LiteralPath '$WIN_PATH_PS'; npm start"
fi
# ----------------------------------------------------------
# Install dependencies only when they are missing or unusable
# ----------------------------------------------------------
dependencies_ready() {
[ -d "node_modules" ] || return 1
[ -f "node_modules/vite/bin/vite.js" ] || return 1
[ -e "node_modules/.bin/vite" ] || [ -e "node_modules/.bin/vite.cmd" ] || return 1
node -e "require('rollup')" >/dev/null 2>&1
}
if ! dependencies_ready; then
warn "Dependencies are missing or incomplete"
info "Running npm install..."
npm install
if ! dependencies_ready; then
error "Dependencies are still incomplete after npm install. Try deleting node_modules and running npm install manually."
fi
fi
# ----------------------------------------------------------
# Start dev server
# ----------------------------------------------------------
info "Starting X-Financial Reimbursement Admin..."
info "Access: http://127.0.0.1:5173"
echo ""
exec npm start

View File

@@ -25,7 +25,8 @@
'requests-main': activeView === 'requests', 'requests-main': activeView === 'requests',
'approval-main': activeView === 'approval', 'approval-main': activeView === 'approval',
'policies-main': activeView === 'policies', 'policies-main': activeView === 'policies',
'audit-main': activeView === 'audit' 'audit-main': activeView === 'audit',
'employees-main': activeView === 'employees'
}" }"
> >
<TopBar <TopBar
@@ -44,7 +45,7 @@
/> />
<FilterBar <FilterBar
v-if="activeView !== 'chat' && activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'policies' && activeView !== 'audit'" v-if="activeView !== 'chat' && activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'employees'"
:compact="activeView === 'overview'" :compact="activeView === 'overview'"
:filters="filters" :filters="filters"
:ranges="ranges" :ranges="ranges"
@@ -59,7 +60,8 @@
'requests-workarea': activeView === 'requests', 'requests-workarea': activeView === 'requests',
'approval-workarea': activeView === 'approval', 'approval-workarea': activeView === 'approval',
'policies-workarea': activeView === 'policies', 'policies-workarea': activeView === 'policies',
'audit-workarea': activeView === 'audit' 'audit-workarea': activeView === 'audit',
'employees-workarea': activeView === 'employees'
}" }"
> >
<OverviewView <OverviewView
@@ -113,7 +115,9 @@
<PoliciesView v-else-if="activeView === 'policies'" /> <PoliciesView v-else-if="activeView === 'policies'" />
<AuditView v-else /> <AuditView v-else-if="activeView === 'audit'" />
<EmployeeManagementView v-else />
</section> </section>
</main> </main>
@@ -147,170 +151,51 @@ import RequestsView from './views/RequestsView.vue'
import ApprovalCenterView from './views/ApprovalCenterView.vue' import ApprovalCenterView from './views/ApprovalCenterView.vue'
import PoliciesView from './views/PoliciesView.vue' import PoliciesView from './views/PoliciesView.vue'
import AuditView from './views/AuditView.vue' import AuditView from './views/AuditView.vue'
import EmployeeManagementView from './views/EmployeeManagementView.vue'
import { ref, computed } from 'vue' import { useAppShell } from './composables/useAppShell.js'
import { useNavigation, navItems } from './composables/useNavigation.js'
import { useRequests } from './composables/useRequests.js'
import { useChat } from './composables/useChat.js'
import { useToast } from './composables/useToast.js'
import { documents } from './data/requests.js'
const loggedIn = ref(false) const {
const travelCreateMode = ref(false) activeCase,
const detailMode = ref(false) activeRange,
const selectedTravelRequest = ref(null) activeView,
const smartEntryOpen = ref(false) closeRequestDetail,
const smartEntryContext = ref({ prompt: '', source: 'requests', request: null }) closeSmartEntry,
const smartEntrySessionId = ref(0) customRange,
detailMode,
function handleLogin(credentials) { docSearch,
if (credentials.username && credentials.password) { draft,
loggedIn.value = true filteredDocuments,
} filteredRequests,
} filters,
handleApprove,
function handleRecoverPassword() { handleLogin,
toast('请联系系统管理员重置密码。') handleNavigate,
} handleOpenChat,
handleRecoverPassword,
function handleSsoLogin() { handleReject,
toast('SSO 登录通道建设中。') handleSsoLogin,
} handleUpload,
loggedIn,
const { activeView, currentView, setView } = useNavigation() messageList,
const { requests, search, filters, ranges, activeRange, filteredRequests, approveRequest, rejectRequest } = useRequests() messages,
const { messages, draft, uploadedFiles, messageList, activeCase, prompts, sendMessage, handleUpload, openChat, openNewChat } = useChat(activeView) navItems,
const { toastText, toast } = useToast() openRequestDetail,
openSmartEntry,
const docSearch = ref('') openTravelCreate,
const customRange = ref({ start: '2024-07-06', end: '2024-07-12' }) ranges,
const travelPrompts = ['帮我提交出差申请', '预订机票', '预订酒店', '预订火车票', '查询差旅政策'] search,
selectedTravelRequest,
const topBarView = computed(() => { sendMessage,
if (detailMode.value) { smartEntryContext,
return { smartEntryOpen,
title: '差旅报销详情', smartEntrySessionId,
desc: '查看报销单据详情、票据识别与审批进度' toast,
} toastText,
} topBarView,
return currentView.value travelPrompts,
}) uploadedFiles
} = useAppShell()
const filteredDocuments = computed(() => {
const key = docSearch.value.trim().toLowerCase()
return documents.filter((doc) => {
const matchesSearch = !key || `${doc.id}${doc.applicant}${doc.destination}${doc.type}`.toLowerCase().includes(key)
return matchesSearch
})
})
function handleApprove(request) {
const msg = approveRequest(request)
toast(msg)
}
function handleReject(request) {
const msg = rejectRequest(request)
toast(msg)
}
function handleNavigate(view) {
travelCreateMode.value = false
detailMode.value = false
selectedTravelRequest.value = null
smartEntryOpen.value = false
setView(view)
}
function handleOpenChat(request) {
travelCreateMode.value = false
openChat(request)
}
function openTravelCreate() {
smartEntryOpen.value = true
travelCreateMode.value = false
detailMode.value = false
selectedTravelRequest.value = null
smartEntryContext.value = { prompt: '', source: 'topbar', request: null }
smartEntrySessionId.value += 1
}
function openSmartEntry(payload = {}) {
smartEntryOpen.value = true
travelCreateMode.value = false
detailMode.value = false
selectedTravelRequest.value = null
smartEntryContext.value = {
prompt: payload.prompt ?? '',
source: payload.source ?? 'workbench',
request: payload.request ?? null
}
smartEntrySessionId.value += 1
}
function closeSmartEntry() {
smartEntryOpen.value = false
}
function openRequestDetail(request) {
selectedTravelRequest.value = request
detailMode.value = true
activeView.value = 'requests'
}
function closeRequestDetail() {
detailMode.value = false
selectedTravelRequest.value = null
}
</script> </script>
<style scoped> <style scoped src="./assets/styles/app.css"></style>
.app {
min-height: 100dvh;
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
background: var(--bg);
}
.main { min-width: 0; display: grid; grid-template-rows: auto auto minmax(0, 1fr); }
.main.overview-main {
grid-template-rows: auto minmax(0, 1fr);
}
.main.workbench-main {
grid-template-rows: auto minmax(0, 1fr);
}
.main.chat-main {
height: 100dvh;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
}
.main.requests-main,
.main.approval-main,
.main.policies-main,
.main.audit-main {
height: 100dvh;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
}
.workarea { overflow: auto; padding: 24px; }
.workarea.chat-workarea {
min-height: 0;
overflow: hidden;
}
.workarea.requests-workarea,
.workarea.approval-workarea,
.workarea.policies-workarea,
.workarea.audit-workarea {
min-height: 0;
overflow: hidden;
padding: 20px 24px;
}
@media (max-width: 1180px) {
.app { grid-template-columns: 220px minmax(0, 1fr); }
}
@media (max-width: 760px) {
.app { display: block; }
.workarea { padding: 18px 16px 28px; }
}
</style>

View File

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

Before

Width:  |  Height:  |  Size: 937 KiB

After

Width:  |  Height:  |  Size: 937 KiB

View File

Before

Width:  |  Height:  |  Size: 287 KiB

After

Width:  |  Height:  |  Size: 287 KiB

View File

@@ -0,0 +1,49 @@
.app {
min-height: 100dvh;
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
background: var(--bg);
}
.main { min-width: 0; display: grid; grid-template-rows: auto auto minmax(0, 1fr); }
.main.overview-main {
grid-template-rows: auto minmax(0, 1fr);
}
.main.workbench-main {
grid-template-rows: auto minmax(0, 1fr);
}
.main.chat-main {
height: 100dvh;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
}
.main.requests-main,
.main.approval-main,
.main.policies-main,
.main.audit-main,
.main.employees-main {
height: 100dvh;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
}
.workarea { overflow: auto; padding: 24px; }
.workarea.chat-workarea {
min-height: 0;
overflow: hidden;
}
.workarea.requests-workarea,
.workarea.approval-workarea,
.workarea.policies-workarea,
.workarea.audit-workarea,
.workarea.employees-workarea {
min-height: 0;
overflow: hidden;
padding: 20px 24px;
}
@media (max-width: 1180px) {
.app { grid-template-columns: 220px minmax(0, 1fr); }
}
@media (max-width: 760px) {
.app { display: block; }
.workarea { padding: 18px 16px 28px; }
}

View File

@@ -1,755 +1,3 @@
<template>
<section class="approval-page">
<!-- Detail Modal Overlay -->
<Teleport to="body">
<Transition name="detail-modal">
<div v-if="false && selectedRow" class="detail-overlay" @click.self="selectedRow = null">
<div class="detail-modal">
<!-- Modal Header -->
<header class="modal-header">
<div class="header-left">
<div class="req-badge">{{ selectedRow.id }}</div>
<div class="header-title-group">
<h2>{{ selectedRow.type }}审批详情</h2>
<p>申请人{{ selectedRow.applicant }} · {{ selectedRow.department }} · {{ selectedRow.time }}</p>
</div>
</div>
<div class="header-right">
<div class="header-indicator" :class="selectedRow.riskTone">
<i class="mdi" :class="selectedRow.riskTone === 'high' ? 'mdi-alert-circle' : selectedRow.riskTone === 'medium' ? 'mdi-alert' : 'mdi-shield-check'"></i>
<span>{{ selectedRow.risk }}</span>
</div>
<div class="header-indicator status" :class="selectedRow.statusTone">
<span>{{ selectedRow.node }}</span>
</div>
<button class="close-btn" type="button" aria-label="关闭" @click="selectedRow = null">
<i class="mdi mdi-close"></i>
</button>
</div>
</header>
<!-- Progress Bar -->
<div class="modal-progress">
<div class="progress-track">
<div v-for="(step, idx) in approvalSteps" :key="step.label" class="progress-node" :class="{ done: step.done, active: step.active, current: step.current }">
<span class="node-dot">
<i v-if="step.done" class="mdi mdi-check"></i>
<template v-else>{{ step.index }}</template>
</span>
<div class="node-label">
<strong>{{ step.label }}</strong>
<small>{{ step.time }}</small>
</div>
<span v-if="idx < approvalSteps.length - 1" class="node-line" :class="{ filled: step.done || step.active }"></span>
</div>
</div>
</div>
<!-- Modal Body -->
<div class="modal-body">
<div class="body-grid">
<!-- Left Column -->
<div class="body-main">
<!-- 费用摘要 -->
<article class="content-card">
<div class="card-header">
<div class="card-title">
<i class="mdi mdi-clipboard-text-outline"></i>
<h3>费用摘要</h3>
</div>
</div>
<div class="metrics-strip">
<div class="metric-block amount">
<span class="metric-label">报销金额</span>
<strong class="metric-value">{{ selectedRow.amount }}</strong>
</div>
<div class="metric-block">
<span class="metric-label">SLA 剩余</span>
<strong class="metric-value sla" :class="selectedRow.slaTone">
<i class="mdi mdi-clock-outline"></i>
{{ selectedRow.sla }}
</strong>
</div>
<div class="metric-block">
<span class="metric-label">费用明细</span>
<strong class="metric-value">5 </strong>
</div>
<div class="metric-block">
<span class="metric-label">附件材料</span>
<strong class="metric-value">6 </strong>
</div>
</div>
<div class="summary-grid">
<div v-for="item in summaryItems" :key="item.label" class="summary-cell">
<div class="cell-icon"><i :class="item.icon"></i></div>
<div class="cell-content">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</div>
</article>
<!-- 费用明细 -->
<article class="content-card">
<div class="card-header">
<div class="card-title">
<i class="mdi mdi-receipt-text-outline"></i>
<h3>费用明细</h3>
</div>
<span class="card-badge">合计 6,920</span>
</div>
<div class="expense-table-wrap">
<table class="expense-table">
<thead>
<tr>
<th>费用项目</th>
<th>说明</th>
<th class="right">金额</th>
<th class="center">是否超标</th>
</tr>
</thead>
<tbody>
<tr v-for="item in expenseItems" :key="item.name">
<td><strong>{{ item.name }}</strong></td>
<td>{{ item.desc }}</td>
<td class="right">{{ item.amount }}</td>
<td class="center">
<span class="over-badge" :class="item.tone">
<i class="mdi" :class="item.tone === 'ok' ? 'mdi-check-circle' : 'mdi-alert-circle'"></i>
{{ item.status }}
</span>
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="2"><strong>合计</strong></td>
<td class="right"><strong class="total-amount">6,920</strong></td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</article>
<!-- 审批意见 -->
<article class="content-card">
<div class="card-header">
<div class="card-title">
<i class="mdi mdi-comment-text-outline"></i>
<h3>审批意见</h3>
</div>
</div>
<div class="opinion-wrap">
<textarea rows="4" placeholder="请输入审批意见..."></textarea>
</div>
</article>
</div>
<!-- Right Column -->
<aside class="body-side">
<!-- AI 风险识别 -->
<article class="side-card risk-card">
<div class="card-header">
<div class="card-title">
<i class="mdi mdi-robot-outline"></i>
<h3>AI 风险识别</h3>
</div>
<div class="risk-total high">
<span>综合风险</span>
<strong></strong>
</div>
</div>
<div class="risk-items">
<div v-for="risk in riskItems" :key="risk.text" class="risk-row" :class="risk.tone">
<div class="risk-icon">
<i :class="risk.icon"></i>
</div>
<span class="risk-text">{{ risk.text }}</span>
<span class="risk-level" :class="risk.tone">{{ risk.level }}</span>
</div>
</div>
<div class="risk-note">
<strong>AI 审核建议</strong>
<p>优先补齐酒店入住清单并复核出租车发票抬头与超标费用说明完成后可继续流转</p>
</div>
</article>
<!-- 附件材料 -->
<article class="side-card">
<div class="card-header">
<div class="card-title">
<i class="mdi mdi-paperclip"></i>
<h3>附件材料</h3>
</div>
<span class="card-badge warn">1 份缺失</span>
</div>
<div class="attachment-list-side">
<div v-for="file in attachments" :key="file.name" class="attachment-row" :class="{ missing: file.missing }">
<div class="file-icon-sm" :class="file.iconClass">
<i :class="file.icon"></i>
</div>
<div class="file-detail">
<strong>{{ file.name }}</strong>
<span>{{ file.size }}</span>
</div>
</div>
</div>
</article>
</aside>
</div>
</div>
<!-- Modal Footer -->
<footer class="modal-footer">
<div class="footer-left">
<button class="action-btn back" type="button" @click="selectedRow = null">
<i class="mdi mdi-arrow-left"></i>
<span>返回列表</span>
</button>
</div>
<div class="footer-right">
<button class="action-btn supplement" type="button">
<i class="mdi mdi-undo"></i>
<span>补充材料</span>
</button>
<button class="action-btn reject" type="button">
<i class="mdi mdi-close-circle-outline"></i>
<span>驳回</span>
</button>
<button class="action-btn approve" type="button">
<i class="mdi mdi-check-circle-outline"></i>
<span>通过</span>
</button>
</div>
</footer>
</div>
</div>
</Transition>
</Teleport>
<div v-if="selectedRow" class="approval-detail">
<div class="detail-scroll">
<article class="detail-hero panel">
<div class="applicant-card">
<div class="portrait">{{ selectedRow.avatar }}</div>
<div>
<h2>{{ selectedRow.applicant }} <span>{{ selectedRow.department }}</span></h2>
<p>提交时间 <strong>{{ selectedRow.time }}</strong></p>
</div>
</div>
<div class="hero-stat">
<span>金额</span>
<strong>{{ selectedRow.amount }}</strong>
</div>
<div class="hero-stat">
<span>风险等级</span>
<b :class="['risk-pill', selectedRow.riskTone]">{{ selectedRow.risk }}</b>
</div>
<div class="hero-stat">
<span>当前状态</span>
<b class="state-pill">{{ selectedRow.node }}</b>
</div>
<div class="hero-stat">
<span>SLA 剩余时间</span>
<strong class="countdown"><i class="mdi mdi-clock-outline"></i> 剩余 {{ selectedRow.sla }}</strong>
</div>
<div class="hero-summary-panel">
<div v-for="item in heroSummaryItems" :key="item.label" class="hero-summary-item">
<div class="hero-summary-label">
<span class="hero-summary-icon"><i :class="item.icon"></i></span>
<span>{{ item.label }}</span>
</div>
<strong>{{ item.value }}</strong>
</div>
</div>
<div class="progress-block">
<div class="progress-head">
<h3>当前进度</h3>
</div>
<div class="progress-line">
<div v-for="step in approvalSteps" :key="step.label" class="progress-step" :class="{ active: step.active, current: step.current }">
<span>
<i
v-if="step.current"
v-motion
class="current-progress-ring"
:initial="currentProgressRingMotion.initial"
:enter="currentProgressRingMotion.enter"
aria-hidden="true"
></i>
<i v-if="step.done" class="mdi mdi-check"></i>
<template v-else>{{ step.index }}</template>
</span>
<strong>{{ step.label }}</strong>
<small>{{ step.time }}</small>
</div>
</div>
</div>
</article>
<div class="detail-grid">
<section class="detail-left">
<article class="detail-card panel">
<div class="detail-card-head">
<div>
<h3>费用明细</h3>
<p>按发生时间逐笔展示附件与 AI 风险直接在表内完成核对</p>
</div>
<span class="detail-total">{{ expenseTotal }}</span>
</div>
<div class="detail-expense-table">
<table>
<thead>
<tr>
<th>时间</th>
<th>费用项目</th>
<th>说明</th>
<th>金额</th>
<th>附件材料</th>
<th>AI 风险识别</th>
</tr>
</thead>
<tbody>
<template v-for="item in expenseItems" :key="item.id">
<tr>
<td class="expense-time">
<strong>{{ item.time }}</strong>
<span>{{ item.dayLabel }}</span>
</td>
<td class="expense-type">
<strong>{{ item.name }}</strong>
<span>{{ item.category }}</span>
</td>
<td class="expense-desc">
<strong>{{ item.desc }}</strong>
<span>{{ item.detail }}</span>
</td>
<td class="expense-amount">
<strong>{{ item.amount }}</strong>
<span v-if="item.tone !== 'ok'" :class="['over-tag', item.tone]">{{ item.status }}</span>
</td>
<td class="expense-attachment">
<div class="expense-attachment-main">
<span :class="['attachment-pill', item.attachmentTone]">{{ item.attachmentStatus }}</span>
<button
v-if="item.attachments.length"
class="inline-action"
type="button"
@click="toggleExpenseAttachments(item.id)"
>
{{ expandedExpenseId === item.id ? '' : '' }}
</button>
</div>
<span class="attachment-hint">{{ item.attachmentHint }}</span>
</td>
<td class="expense-risk">
<template v-if="showExpenseRisk(item)">
<span :class="['risk-inline-tag', item.riskTone]">{{ item.riskLabel }}</span>
<p>{{ item.riskText }}</p>
</template>
</td>
</tr>
<tr v-if="expandedExpenseId === item.id" class="expense-expand-row">
<td colspan="6">
<div class="expense-files">
<span v-for="file in item.attachments" :key="file" class="expense-file-chip">
<i class="mdi mdi-paperclip"></i>
{{ file }}
</span>
</div>
</td>
</tr>
</template>
<tr class="total-row">
<td colspan="3">合计</td>
<td>{{ expenseTotal }}</td>
<td>{{ uploadedExpenseCount }} 项已上传票据</td>
<td>1 项待补材料1 项需补充超标说明</td>
</tr>
</tbody>
</table>
</div>
</article>
<article class="detail-card panel">
<h3>审批意见</h3>
<textarea rows="3" placeholder="输入审批意见..."></textarea>
</article>
</section>
</div>
</div>
<footer class="detail-actions">
<button class="back-action" type="button" @click="selectedRow = null">
<i class="mdi mdi-arrow-left"></i>
<span>退回列表</span>
</button>
<div class="approval-action-group" aria-label="审批操作">
<button class="approve-action" type="button"><i class="mdi mdi-check-circle-outline"></i> 通过</button>
<button class="reject-action" type="button"><i class="mdi mdi-close-circle-outline"></i> 驳回</button>
<button class="supplement-action" type="button"><i class="mdi mdi-undo"></i> 补充</button>
</div>
</footer>
</div>
<!-- Approval List -->
<article v-else class="approval-list panel">
<nav class="status-tabs" aria-label="审批状态">
<button
v-for="tab in tabs"
:key="tab"
type="button"
:class="{ active: activeTab === tab }"
@click="activeTab = tab"
>
{{ tab }}
</button>
</nav>
<div class="list-toolbar">
<div class="filter-set">
<button v-for="filter in filters" :key="filter" type="button" class="filter-btn">
<span>{{ filter }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
</div>
</div>
<p class="hint"><i class="mdi mdi-information-outline"></i> 点击单据行查看审批详情</p>
<div class="table-wrap">
<table>
<colgroup>
<col><col><col><col><col><col><col><col><col><col><col>
</colgroup>
<thead>
<tr>
<th>单号</th>
<th>申请人</th>
<th>申请部门</th>
<th>报销类型</th>
<th>金额</th>
<th>提交时间 <i class="mdi mdi-sort"></i></th>
<th>风险等级</th>
<th>SLA剩余</th>
<th>当前节点</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in visibleRows" :key="row.id" :class="{ spotlight: row.spotlight }" @click="selectedRow = row">
<td><strong class="doc-id">{{ row.id }}</strong></td>
<td>
<span class="person">
<span class="avatar">{{ row.avatar }}</span>
{{ row.applicant }}
</span>
</td>
<td>{{ row.department }}</td>
<td>{{ row.type }}</td>
<td>{{ row.amount }}</td>
<td>{{ row.time }}</td>
<td><span class="risk-tag" :class="row.riskTone">{{ row.risk }}</span></td>
<td><strong class="sla" :class="row.slaTone">{{ row.sla }}</strong></td>
<td>{{ row.node }}</td>
<td><span class="status-tag" :class="row.statusTone">{{ row.status }}</span></td>
<td>
<button class="more-btn" type="button" aria-label="查看审批详情" @click.stop="selectedRow = row">
<i class="mdi mdi-dots-horizontal"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<footer class="list-foot">
<span class="page-summary"> 126 当前第 1 </span>
<div class="pager" aria-label="分页">
<button class="page-nav" type="button" aria-label="上一页"><i class="mdi mdi-chevron-left"></i></button>
<button class="page-number active" type="button" aria-current="page">1</button>
<button class="page-number" type="button">2</button>
<button class="page-number" type="button">3</button>
<button class="page-number" type="button">4</button>
<button class="page-number" type="button">5</button>
<span>...</span>
<button class="page-number" type="button">13</button>
<button class="page-nav" type="button" aria-label="下一页"><i class="mdi mdi-chevron-right"></i></button>
</div>
<button class="page-size" type="button">10 / <i class="mdi mdi-chevron-down"></i></button>
</footer>
</article>
</section>
</template>
<script setup>
import { computed, ref } from 'vue'
const activeTab = ref('全部待审')
const selectedRow = ref(null)
const expandedExpenseId = ref(null)
const tabs = ['全部待审', '高风险', '即将超时', '已处理']
const filters = ['法人主体', '费用类型', '风险等级', '金额区间', '所属部门']
const rows = [
{ id: 'RE240712001', applicant: '李文静', avatar: '李', department: '市场部', type: '差旅报销', amount: '¥3,680', time: '07-12 09:20', risk: '中风险', riskTone: 'medium', sla: '4.2h', slaTone: 'safe', node: '财务审批', status: '待审批', statusTone: 'pending' },
{ id: 'RE240712002', applicant: '王志强', avatar: '王', department: '销售部', type: '招待费', amount: '¥1,280', time: '07-12 08:15', risk: '低风险', riskTone: 'low', sla: '8.5h', slaTone: 'safe', node: '部门负责人', status: '待审批', statusTone: 'pending' },
{ id: 'RE240711098', applicant: '刘思雨', avatar: '刘', department: '市场部', type: '差旅报销', amount: '¥6,920', time: '07-11 18:46', risk: '高风险', riskTone: 'high', sla: '0.8h', slaTone: 'danger', node: '财务审批', status: '即将超时', statusTone: 'urgent', spotlight: true },
{ id: 'RE240711087', applicant: '陈晓琳', avatar: '陈', department: '行政部', type: '办公采购', amount: '¥860', time: '07-11 17:32', risk: '低风险', riskTone: 'low', sla: '6.1h', slaTone: 'safe', node: '预算校验', status: '待审批', statusTone: 'pending' },
{ id: 'RE240711076', applicant: '赵明', avatar: '赵', department: '研发中心', type: '其他费用', amount: '¥4,250', time: '07-11 15:10', risk: '中风险', riskTone: 'medium', sla: '2.4h', slaTone: 'warning', node: '部门负责人', status: '待审批', statusTone: 'pending' },
{ id: 'RE240711065', applicant: '孙楠', avatar: '孙', department: '财务部', type: '招待费', amount: '¥560', time: '07-11 13:42', risk: '低风险', riskTone: 'low', sla: '5.7h', slaTone: 'safe', node: '财务审批', status: '待审批', statusTone: 'pending' },
{ id: 'RE240711054', applicant: '周晓彤', avatar: '周', department: '市场部', type: '办公采购', amount: '¥2,150', time: '07-11 11:28', risk: '中风险', riskTone: 'medium', sla: '1.9h', slaTone: 'warning', node: '预算校验', status: '即将超时', statusTone: 'urgent' },
{ id: 'RE240711043', applicant: '吴磊', avatar: '吴', department: '销售部', type: '其他费用', amount: '¥980', time: '07-11 09:05', risk: '低风险', riskTone: 'low', sla: '7.3h', slaTone: 'safe', node: '部门负责人', status: '待审批', statusTone: 'pending' }
]
const visibleRows = computed(() => {
if (activeTab.value === '全部待审') return rows
if (activeTab.value === '高风险') return rows.filter((row) => row.risk === '高风险')
if (activeTab.value === '即将超时') return rows.filter((row) => row.status === '即将超时')
return rows.slice(0, 3).map((row) => ({ ...row, status: '已处理', statusTone: 'done' }))
})
const approvalSteps = [
{ index: 1, label: '提交申请', time: '07-11 08:46', done: true, active: true },
{ index: 2, label: '票据识别', time: '07-11 08:48', done: true, active: true },
{ index: 3, label: '费用归类', time: '07-11 08:49', done: true, active: true },
{ index: 4, label: '部门负责人审批', time: '07-11 11:28', done: true, active: true },
{ index: 5, label: '财务审批', time: '进行中', active: true, current: true },
{ index: 6, label: '归档入账', time: '待处理' }
]
const summaryItems = [
{ label: '行程', value: '北京 → 上海', icon: 'mdi mdi-map-marker-path' },
{ label: '出差区间', value: '07-10 至 07-11', icon: 'mdi mdi-clock-outline' },
{ label: '票据关联', value: '8 条明细 / 7 份材料', icon: 'mdi mdi-file-document-multiple-outline' },
{ label: '成本归属', value: '市场部 · CC-MKT-01', icon: 'mdi mdi-account-group-outline' },
{ label: '支付方式', value: '企业垫付', icon: 'mdi mdi-credit-card-outline' }
]
const heroSummaryItems = computed(() => [
{ label: '单号', value: selectedRow.value?.id ?? '-', icon: 'mdi mdi-pound-box-outline' },
{ label: '报销类型', value: selectedRow.value?.type ?? '-', icon: 'mdi mdi-briefcase-outline' },
...summaryItems
])
const currentProgressRingMotion = {
initial: {
scale: 1,
opacity: 0.34,
},
enter: {
scale: [1, 1.42, 1.78],
opacity: [0.34, 0.16, 0],
transition: {
duration: 3.2,
repeat: Infinity,
repeatType: 'loop',
repeatDelay: 0.85,
ease: 'easeOut',
times: [0, 0.5, 1],
},
},
}
const expenseItems = [
{
id: 'flight-1',
time: '07-10 07:25',
dayLabel: '周三',
name: '机票',
category: '交通',
desc: '北京首都 → 上海虹桥',
detail: 'MU5103 往返经济舱,含行程单',
amount: '¥2,180',
status: '未超标',
tone: 'ok',
attachmentStatus: '已上传',
attachmentTone: 'ok',
attachmentHint: '电子行程单与机票发票齐全',
attachments: ['电子行程单.pdf', '机票发票.pdf'],
riskLabel: '低风险',
riskTone: 'low',
riskText: '票面信息与行程匹配。'
},
{
id: 'taxi-1',
time: '07-10 10:35',
dayLabel: '周三',
name: '出租车',
category: '市内交通',
desc: '虹桥机场 → 静安酒店',
detail: '落地后前往酒店,含过路费',
amount: '¥86',
status: '未超标',
tone: 'ok',
attachmentStatus: '已上传',
attachmentTone: 'ok',
attachmentHint: '已上传 1 张发票',
attachments: ['出租车发票-0710-01.jpg'],
riskLabel: '中风险',
riskTone: 'medium',
riskText: '高峰加价较高,建议顺带核对上车点。'
},
{
id: 'metro-1',
time: '07-10 18:20',
dayLabel: '周三',
name: '地铁',
category: '市内交通',
desc: '静安酒店 → 客户园区',
detail: '2 号线换乘,通勤交通',
amount: '¥12',
status: '未超标',
tone: 'ok',
attachmentStatus: '已上传',
attachmentTone: 'ok',
attachmentHint: '已上传电子票据',
attachments: ['地铁电子票据-0710.png'],
riskLabel: '低风险',
riskTone: 'low',
riskText: '路线与拜访行程一致。'
},
{
id: 'taxi-2',
time: '07-11 08:40',
dayLabel: '周四',
name: '出租车',
category: '市内交通',
desc: '静安酒店 → 客户园区',
detail: '次日早会前往客户现场',
amount: '¥42',
status: '未超标',
tone: 'ok',
attachmentStatus: '未上传',
attachmentTone: 'missing',
attachmentHint: '缺少对应发票',
attachments: [],
riskLabel: '高风险',
riskTone: 'high',
riskText: '票据缺失,当前无法完成交通费核验。'
},
{
id: 'taxi-3',
time: '07-11 20:55',
dayLabel: '周四',
name: '出租车',
category: '返程交通',
desc: '客户园区 → 虹桥机场',
detail: '夜间返程,触发超标校验',
amount: '¥136',
status: '超标 ¥28',
tone: 'bad',
attachmentStatus: '已上传',
attachmentTone: 'ok',
attachmentHint: '已上传 1 张发票',
attachments: ['出租车发票-0711-02.jpg'],
riskLabel: '中风险',
riskTone: 'medium',
riskText: '金额超差旅标准 ¥28需补充业务说明。'
},
{
id: 'hotel-1',
time: '07-10 至 07-11',
dayLabel: '2 晚',
name: '酒店',
category: '住宿',
desc: '上海静安商务酒店',
detail: '标准大床房 2 晚,含早餐',
amount: '¥2,480',
status: '未超标',
tone: 'ok',
attachmentStatus: '部分上传',
attachmentTone: 'partial',
attachmentHint: '发票已上传,入住清单缺失',
attachments: ['酒店发票.jpg'],
riskLabel: '高风险',
riskTone: 'high',
riskText: '缺少入住清单,住宿真实性待补证。'
},
{
id: 'meal-1',
time: '07-10 至 07-11',
dayLabel: '2 天',
name: '餐补',
category: '补贴',
desc: '差旅餐补',
detail: '按差旅制度自动计算',
amount: '¥372',
status: '未超标',
tone: 'ok',
attachmentStatus: '免上传',
attachmentTone: 'neutral',
attachmentHint: '制度型补贴无需票据',
attachments: [],
riskLabel: '低风险',
riskTone: 'low',
riskText: '系统自动核算,无额外异常。'
},
{
id: 'other-1',
time: '07-11 09:10',
dayLabel: '周四',
name: '其他',
category: '杂费',
desc: '行李寄存 / 打印费',
detail: '客户提案资料打印与寄存服务',
amount: '¥1,612',
status: '未超标',
tone: 'ok',
attachmentStatus: '已上传',
attachmentTone: 'ok',
attachmentHint: '已上传 2 份附件',
attachments: ['打印服务发票.jpg', '行李寄存凭证.jpg'],
riskLabel: '低风险',
riskTone: 'low',
riskText: '用途清晰,金额在授权范围内。'
}
]
const expenseTotal = '¥6,920'
const uploadedExpenseCount = computed(() => expenseItems.filter((item) => item.attachments.length).length)
const showExpenseRisk = (item) => item.riskTone === 'medium' || item.riskTone === 'high'
const toggleExpenseAttachments = (id) => {
expandedExpenseId.value = expandedExpenseId.value === id ? null : id
}
const attachments = [
{ name: '机票.pdf', size: '256 KB', icon: 'mdi mdi-file-pdf-box', iconClass: 'pdf' },
{ name: '酒店发票.jpg', size: '412 KB', icon: 'mdi mdi-image', iconClass: 'img' },
{ name: '行程单.pdf', size: '198 KB', icon: 'mdi mdi-file-pdf-box', iconClass: 'pdf' },
{ name: '出租车发票1.jpg', size: '128 KB', icon: 'mdi mdi-image', iconClass: 'img' },
{ name: '出租车发票2.jpg', size: '132 KB', icon: 'mdi mdi-image', iconClass: 'img' },
{ name: '酒店入住清单', size: '缺失', icon: 'mdi mdi-minus-circle', iconClass: 'miss', missing: true }
]
const riskItems = [
{ text: '酒店入住清单缺失', level: '高', tone: 'high', icon: 'mdi mdi-alert-circle' },
{ text: '1 笔出租车费用超差旅标准 ¥28', level: '中', tone: 'medium', icon: 'mdi mdi-alert' },
{ text: '发票抬头识别为个人,建议核对', level: '中', tone: 'medium', icon: 'mdi mdi-lightbulb-on' }
]
const flowItems = [
{ label: '提交申请', desc: '刘思雨 提交申请', time: '07-11 08:46', icon: 'mdi mdi-check' },
{ label: '票据识别', desc: 'AI 自动识别完成', time: '07-11 08:48', icon: 'mdi mdi-check' },
{ label: '费用归类', desc: '费用归类完成', time: '07-11 08:49', icon: 'mdi mdi-check' },
{ label: '部门负责人审批', desc: '李文静 已通过', time: '07-11 11:28', icon: 'mdi mdi-check' },
{ label: '财务审批', desc: '张晓明 审批中', time: '进行中', icon: 'mdi mdi-circle-slice-8', current: true },
{ label: '归档入账', desc: '待处理', time: '-', icon: 'mdi mdi-circle-outline', pending: true }
]
</script>
<style scoped>
/* ────────── Approval List ────────── */ /* ────────── Approval List ────────── */
.approval-page { .approval-page {
height: 100%; height: 100%;
@@ -2466,4 +1714,3 @@ tbody tr:last-child td { border-bottom: 0; }
.footer-right { width: 100%; } .footer-right { width: 100%; }
.action-btn { flex: 1; } .action-btn { flex: 1; }
} }
</style>

View File

@@ -0,0 +1,676 @@
.skill-center {
height: 100%;
min-height: 0;
}
.skill-view-enter-active,
.skill-view-leave-active {
transition: opacity 220ms ease, transform 300ms var(--ease);
}
.skill-view-enter-from,
.skill-view-leave-to {
opacity: 0;
transform: translateY(14px);
}
.skill-list,
.skill-detail {
height: 100%;
min-height: 0;
}
.skill-detail {
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
gap: 12px;
}
.skill-list {
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr);
padding: 18px 20px;
}
.status-tabs {
display: flex;
gap: 18px;
padding-bottom: 12px;
border-bottom: 1px solid #edf2f7;
}
.status-tabs button {
position: relative;
border: 0;
background: transparent;
color: #64748b;
font-size: 14px;
font-weight: 760;
}
.status-tabs button.active {
color: #0f172a;
}
.status-tabs button.active::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -13px;
height: 3px;
border-radius: 999px;
background: #10b981;
}
.list-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 0 10px;
}
.filter-set {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.filter-btn,
.create-btn,
.row-action {
min-height: 36px;
border-radius: 8px;
font-size: 13px;
font-weight: 760;
}
.filter-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 12px;
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
}
.create-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 14px;
border: 0;
background: #059669;
color: #fff;
box-shadow: 0 8px 18px rgba(5, 150, 105, 0.18);
}
.hint {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0 0 12px;
color: #64748b;
font-size: 12px;
}
.hint .mdi {
color: #94a3b8;
}
.table-wrap {
min-height: 0;
overflow: auto;
border: 1px solid #edf2f7;
border-radius: 12px;
}
table {
width: 100%;
min-width: 1120px;
border-collapse: collapse;
}
th,
td {
padding: 14px 12px;
border-bottom: 1px solid #edf2f7;
text-align: left;
vertical-align: middle;
color: #334155;
font-size: 12px;
}
th {
background: #f8fafc;
color: #64748b;
font-weight: 800;
white-space: nowrap;
}
tbody tr {
cursor: pointer;
transition: background 180ms ease;
}
tbody tr:hover {
background: #f8fbff;
}
tbody tr.spotlight {
background: linear-gradient(90deg, rgba(16, 185, 129, 0.05), rgba(59, 130, 246, 0.03));
}
.skill-name-cell {
display: grid;
grid-template-columns: 38px minmax(0, 1fr);
gap: 10px;
align-items: center;
}
.skill-avatar {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border-radius: 11px;
color: #fff;
font-size: 13px;
font-weight: 900;
}
.skill-avatar.emerald { background: linear-gradient(135deg, #10b981, #059669); }
.skill-avatar.violet { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
.skill-avatar.blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
.skill-avatar.amber { background: linear-gradient(135deg, #f59e0b, #ea580c); }
.skill-name-cell strong {
display: block;
color: #0f172a;
font-size: 13px;
font-weight: 850;
}
.skill-name-cell span:last-child {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.scope-pill,
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 26px;
padding: 0 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
white-space: nowrap;
}
.scope-pill {
background: #f1f5f9;
color: #475569;
}
.status-pill.success {
background: #dcfce7;
color: #059669;
}
.status-pill.warning {
background: #fff7ed;
color: #ea580c;
}
.status-pill.draft {
background: #eef2ff;
color: #6366f1;
}
.row-action {
padding: 0 12px;
border: 1px solid rgba(16, 185, 129, 0.32);
background: #fff;
color: #059669;
}
.detail-scroll {
height: 100%;
overflow: auto;
display: grid;
gap: 16px;
}
.detail-hero {
display: grid;
gap: 18px;
padding: 18px 20px;
}
.hero-title {
min-width: 0;
}
.skill-badge {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 8px;
border-radius: 999px;
color: #fff;
font-size: 11px;
font-weight: 800;
}
.skill-badge.emerald { background: linear-gradient(135deg, #10b981, #059669); }
.skill-badge.violet { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
.skill-badge.blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
.skill-badge.amber { background: linear-gradient(135deg, #f59e0b, #ea580c); }
.hero-title h2 {
margin-top: 10px;
color: #0f172a;
font-size: 24px;
font-weight: 850;
}
.hero-title p {
margin-top: 8px;
max-width: 820px;
color: #64748b;
font-size: 14px;
line-height: 1.6;
}
.hero-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.hero-stat {
padding: 14px 16px;
border-radius: 12px;
background: linear-gradient(180deg, #ffffff, #f8fafc);
border: 1px solid #edf2f7;
}
.hero-stat span {
display: block;
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.hero-stat strong {
display: block;
margin-top: 8px;
color: #0f172a;
font-size: 20px;
font-weight: 850;
}
.detail-grid {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.78fr);
gap: 16px;
}
.detail-main,
.detail-side {
display: grid;
gap: 16px;
align-content: start;
}
.detail-card,
.side-card {
padding: 18px;
}
.card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.card-head h3 {
color: #0f172a;
font-size: 16px;
font-weight: 850;
}
.card-head p {
margin-top: 6px;
color: #64748b;
font-size: 13px;
line-height: 1.5;
}
.edit-badge {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
background: #ecfeff;
color: #0891b2;
font-size: 12px;
font-weight: 800;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.field {
display: grid;
gap: 7px;
}
.field.span-2 {
grid-column: span 2;
}
.field span {
color: #64748b;
font-size: 12px;
font-weight: 800;
}
.field input,
.field textarea,
.prompt-block textarea {
width: 100%;
border: 1px solid #d7e0ea;
border-radius: 10px;
background: #fff;
color: #0f172a;
font-size: 13px;
line-height: 1.55;
padding: 10px 12px;
resize: vertical;
}
.prompt-stack {
display: grid;
gap: 14px;
}
.prompt-block {
padding: 14px;
border: 1px solid #edf2f7;
border-radius: 12px;
background: #fbfdff;
}
.prompt-block header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.prompt-block strong {
color: #0f172a;
font-size: 14px;
font-weight: 850;
}
.prompt-block header span {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.contract-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.contract-panel {
padding: 14px;
border: 1px solid #edf2f7;
border-radius: 12px;
background: #fbfdff;
}
.contract-panel h4 {
margin: 0 0 10px;
color: #0f172a;
font-size: 14px;
font-weight: 850;
}
.contract-panel ul {
display: grid;
gap: 8px;
margin: 0;
padding-left: 18px;
color: #475569;
font-size: 13px;
line-height: 1.6;
}
.test-row,
.tool-row,
.history-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
padding: 12px 0;
border-top: 1px solid #edf2f7;
}
.test-row:first-child,
.tool-row:first-child,
.history-row:first-child {
border-top: 0;
padding-top: 0;
}
.test-row strong,
.tool-row strong,
.history-row strong {
display: block;
color: #0f172a;
font-size: 13px;
font-weight: 800;
}
.test-row span,
.tool-row span,
.history-row span,
.history-row small {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.test-state,
.tool-state {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
white-space: nowrap;
}
.test-state.success,
.tool-state.safe {
background: #dcfce7;
color: #059669;
}
.test-state.warning,
.tool-state.active {
background: #fff7ed;
color: #ea580c;
}
.tag-list {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.tag-list span {
min-height: 28px;
display: inline-flex;
align-items: center;
padding: 0 10px;
border-radius: 999px;
background: #eff6ff;
color: #2563eb;
font-size: 12px;
font-weight: 800;
}
.publish-card {
display: grid;
gap: 14px;
}
.publish-card p,
.publish-summary span {
margin-top: 6px;
color: #64748b;
font-size: 13px;
line-height: 1.55;
}
.publish-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 42px;
padding: 0 12px;
border-radius: 10px;
background: #f8fafc;
}
.publish-summary strong {
color: #059669;
font-size: 14px;
font-weight: 850;
}
.detail-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 0 0;
border-top: 1px solid #e5eaf0;
}
.detail-action-group {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.back-action,
.minor-action,
.major-action {
height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 760;
}
.back-action {
border: 1px solid #d7e0ea;
background: #fff;
color: #475569;
}
.minor-action {
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
}
.major-action {
border: 1px solid #059669;
background: #059669;
color: #fff;
box-shadow: 0 4px 12px rgba(5, 150, 105, .16);
}
.mini-btn.primary {
border-color: transparent;
background: #059669;
color: #fff;
}
@media (max-width: 1320px) {
.hero-stats,
.form-grid,
.contract-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.detail-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 860px) {
.skill-list,
.detail-card,
.side-card,
.detail-hero {
padding: 16px;
}
.list-toolbar,
.card-head,
.detail-actions,
.detail-action-group {
flex-direction: column;
align-items: stretch;
}
.status-tabs,
.filter-set {
overflow-x: auto;
}
.hero-stats,
.form-grid,
.contract-grid {
grid-template-columns: 1fr;
}
.field.span-2 {
grid-column: span 1;
}
}

View File

@@ -0,0 +1,77 @@
.qa-view { height: 100%; min-height: 0; display: grid; grid-template-rows: minmax(0, 1fr); gap: 0; overflow: hidden; animation: fadeUp 240ms var(--ease) both; }
.qa-layout { height: 100%; min-height: 0; display: grid; grid-template-columns: 330px minmax(0, 1fr) 395px; gap: 14px; overflow: hidden; }
.left-column, .right-column { height: 100%; min-height: 0; overflow: hidden; }
.left-column { display: grid; grid-template-rows: minmax(0, 1fr); }
.right-column { display: grid; grid-template-rows: minmax(0, 1.06fr) minmax(0, .94fr); gap: 12px; }
.side-panel, .info-panel { min-height: 0; padding: 16px 20px; overflow: hidden; }
.conversation-list, .info-panel { display: grid; grid-template-rows: auto minmax(0, 1fr); }
.side-panel header, .info-panel header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.side-panel h3, .info-panel h3 { display: inline-flex; align-items: center; gap: 8px; color: #0f172a; font-size: 17px; font-weight: 850; }
.outline-action, .info-panel header button { height: 34px; display: inline-flex; align-items: center; gap: 6px; border: 1px solid #d8e3ed; border-radius: 8px; background: #fff; color: #0f9f78; font-size: 13px; font-weight: 750; white-space: nowrap; }
.outline-action { padding: 0 12px; }
.info-panel header button { border: 0; color: #64748b; }
.session-scroll, .top-question-list, .similar-scroll { min-height: 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #cbd5e1 transparent; }
.session-scroll { display: grid; align-content: start; gap: 4px; padding-right: 4px; }
.session-row { width: 100%; min-height: 48px; display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 0 10px; border: 0; border-radius: 8px; background: transparent; color: #334155; text-align: left; }
.session-row.active, .session-row:hover { background: #eaf8f1; color: #0f8f68; }
.session-row span { color: #10b981; }
.session-row strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 700; }
.session-row time { color: #94a3b8; font-size: 12px; }
.chat-panel { height: 100%; min-height: 0; display: grid; grid-template-rows: minmax(0, 1fr) auto; overflow: hidden; }
.message-stream { min-height: 0; display: grid; align-content: start; gap: 16px; padding: 16px 18px 8px; overflow-y: auto; scrollbar-width: thin; }
.talk-row { display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 12px; align-items: start; }
.avatar { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 999px; color: #fff; font-size: 15px; font-weight: 850; }
.user-avatar { background: linear-gradient(135deg, #26364d, #61748a); }
.assistant-avatar { background: #10b981; font-size: 20px; }
.talk-content header { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
.talk-content header strong { color: #334155; font-size: 14px; font-weight: 800; }
.talk-content header time { color: #94a3b8; font-size: 12px; }
.user-question { display: inline-block; margin: 0; padding: 9px 16px; border-radius: 8px; background: #e8f5ef; color: #334155; font-size: 14px; line-height: 1.5; }
.answer-card, .agent-answer { max-width: 760px; border: 1px solid #dce5ef; border-radius: 10px; background: #fff; color: #334155; box-shadow: 0 8px 24px rgba(15, 23, 42, .04); }
.answer-card { display: grid; gap: 10px; padding: 13px 18px; }
.answer-card.compact { gap: 10px; }
.answer-card h4 { margin: 0 0 5px; color: #10a272; font-size: 13px; font-weight: 850; }
.answer-card p, .answer-card ul { margin: 0; font-size: 14px; line-height: 1.58; }
.answer-card ul { padding-left: 18px; }
.answer-card footer { display: flex; align-items: center; justify-content: flex-end; gap: 10px; color: #64748b; font-size: 12px; }
.answer-card footer button { width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: 6px; background: transparent; color: #64748b; }
.answer-card footer button:hover { background: #f1f5f9; color: #0f9f78; }
.agent-answer { margin: 0; padding: 12px 16px; font-size: 14px; line-height: 1.65; }
.composer-wrap { border-top: 1px solid #eef2f7; padding: 10px 14px 12px; background: #fff; }
.prompt-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; overflow-x: auto; }
.prompt-toolbar span { flex: 0 0 auto; color: #64748b; font-size: 13px; font-weight: 800; }
.prompt-toolbar button { height: 34px; flex: 0 0 auto; display: inline-flex; align-items: center; gap: 7px; padding: 0 14px; border: 1px solid #dce5ef; border-radius: 8px; background: #fff; color: #334155; font-size: 13px; font-weight: 750; }
.prompt-toolbar button i { color: #10b981; }
.prompt-toolbar .icon-refresh { width: 34px; padding: 0; justify-content: center; }
.composer { min-height: 64px; display: grid; grid-template-columns: minmax(0, 1fr) 48px; align-items: center; gap: 10px; padding: 8px; border: 1px solid #cbd8e5; border-radius: 8px; background: linear-gradient(180deg, #fff, #fbfdff); box-shadow: 0 1px 2px rgba(15, 23, 42, .04); transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease; }
.composer:focus-within { border-color: rgba(16, 185, 129, .58); background: #fff; box-shadow: 0 0 0 3px rgba(16, 185, 129, .11), 0 10px 24px rgba(15, 23, 42, .06); }
.composer textarea { height: 48px; min-height: 48px; resize: none; border: 0; padding: 5px 8px; background: transparent; color: #0f172a; font-size: 14px; line-height: 1.55; }
.composer textarea::placeholder { color: #94a3b8; }
.composer textarea:focus { outline: none; }
.send-button { width: 48px; height: 48px; display: grid; place-items: center; border: 0; border-radius: 8px; background: #10b981; color: #fff; font-size: 20px; box-shadow: 0 8px 18px rgba(16, 185, 129, .20); transition: background 160ms ease, transform 160ms ease, box-shadow 160ms ease; }
.send-button:hover { background: #0ea672; box-shadow: 0 10px 22px rgba(16, 185, 129, .24); }
.send-button:active { transform: scale(.96); }
.hot-top-panel h3 i { color: #ef4444; }
.top-question-list { display: grid; align-content: start; gap: 8px; padding-right: 4px; }
.top-question-list button { min-height: 42px; display: grid; grid-template-columns: 34px minmax(0, 1fr) 14px; align-items: center; gap: 10px; padding: 0 8px; border: 1px solid #e2e8f0; border-radius: 8px; background: #fff; color: #334155; text-align: left; }
.top-question-list button:hover { border-color: rgba(16, 185, 129, .32); color: #0f9f78; }
.top-question-list strong { color: #10b981; font-size: 13px; font-weight: 850; font-variant-numeric: tabular-nums; }
.top-question-list span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 750; }
.top-question-list i { color: #94a3b8; }
.similar-panel { display: grid; }
.similar-scroll { display: grid; align-content: start; padding-right: 4px; }
.similar-row { min-height: 46px; display: grid; grid-template-columns: minmax(0, 1fr) 48px 14px; align-items: center; gap: 10px; border: 0; border-top: 1px solid #eef2f7; background: transparent; color: #334155; text-align: left; }
.similar-row:first-child { border-top: 0; }
.similar-row span { min-width: 0; display: inline-flex; align-items: center; gap: 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 700; }
.similar-row span i { color: #64748b; font-size: 17px; }
.similar-row strong { height: 26px; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; background: #e8f8f0; color: #15945f; font-size: 13px; font-weight: 850; }
.similar-row > i { color: #94a3b8; }
@media (max-width: 1480px) { .qa-layout { grid-template-columns: 300px minmax(0, 1fr) 360px; } }
@media (max-width: 1280px) {
.qa-layout { grid-template-columns: 1fr; overflow-y: auto; }
.left-column, .right-column { grid-template-columns: repeat(2, minmax(0, 1fr)); overflow: visible; }
}
@media (max-width: 760px) {
.left-column, .right-column { grid-template-columns: 1fr; }
.composer { grid-template-columns: minmax(0, 1fr) 48px; }
}

View File

@@ -0,0 +1,658 @@
.employee-center {
height: 100%;
min-height: 0;
}
.employee-view-enter-active,
.employee-view-leave-active {
transition: opacity 220ms ease, transform 280ms var(--ease);
}
.employee-view-enter-from,
.employee-view-leave-to {
opacity: 0;
transform: translateY(14px);
}
.employee-list,
.employee-detail {
height: 100%;
min-height: 0;
}
.employee-list {
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr);
padding: 18px 20px;
}
.employee-detail {
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
gap: 12px;
}
.status-tabs {
display: flex;
gap: 18px;
padding-bottom: 12px;
border-bottom: 1px solid #edf2f7;
}
.status-tabs button {
position: relative;
border: 0;
background: transparent;
color: #64748b;
font-size: 14px;
font-weight: 760;
}
.status-tabs button.active {
color: #0f172a;
}
.status-tabs button.active::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -13px;
height: 3px;
border-radius: 999px;
background: #10b981;
}
.list-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 0 10px;
}
.filter-set {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.list-search {
position: relative;
width: 240px;
}
.list-search .mdi {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #64748b;
font-size: 15px;
}
.list-search input {
width: 100%;
height: 38px;
padding: 0 12px 0 36px;
border: 1px solid #d7e0ea;
border-radius: 8px;
background: #fff;
color: #0f172a;
font-size: 13px;
}
.filter-btn,
.create-btn,
.row-action {
min-height: 36px;
border-radius: 8px;
font-size: 13px;
font-weight: 760;
}
.filter-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 12px;
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
}
.create-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 14px;
border: 0;
background: #059669;
color: #fff;
box-shadow: 0 8px 18px rgba(5, 150, 105, 0.18);
}
.hint {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0 0 12px;
color: #64748b;
font-size: 12px;
}
.table-wrap {
min-height: 0;
overflow: auto;
border: 1px solid #edf2f7;
border-radius: 12px;
}
table {
width: 100%;
min-width: 1320px;
border-collapse: collapse;
}
th,
td {
padding: 14px 12px;
border-bottom: 1px solid #edf2f7;
text-align: center;
vertical-align: middle;
color: #334155;
font-size: 12px;
}
th {
background: #f8fafc;
color: #64748b;
font-weight: 800;
white-space: nowrap;
}
tbody tr {
cursor: pointer;
transition: background 180ms ease;
}
tbody tr:hover {
background: #f8fbff;
}
tbody tr.spotlight {
background: linear-gradient(90deg, rgba(16, 185, 129, 0.05), rgba(59, 130, 246, 0.03));
}
.employee-cell {
display: grid;
grid-template-columns: 38px minmax(0, 1fr);
gap: 10px;
align-items: center;
text-align: left;
}
.employee-avatar {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border-radius: 11px;
background: linear-gradient(135deg, #10b981, #059669);
color: #fff;
font-size: 13px;
font-weight: 900;
}
.employee-cell strong {
display: block;
color: #0f172a;
font-size: 13px;
font-weight: 850;
}
.employee-cell span:last-child {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.level-pill,
.status-pill,
.role-pill,
.more-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 26px;
padding: 0 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
white-space: nowrap;
}
.level-pill {
background: #eef2ff;
color: #4f46e5;
}
.status-pill.success {
background: #dcfce7;
color: #059669;
}
.status-pill.warning {
background: #fff7ed;
color: #ea580c;
}
.status-pill.neutral {
background: #f1f5f9;
color: #475569;
}
.role-stack {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.role-pill {
background: #ecfdf5;
color: #059669;
}
.more-pill {
background: #f1f5f9;
color: #475569;
}
.row-action {
padding: 0 12px;
border: 1px solid rgba(16, 185, 129, 0.32);
background: #fff;
color: #059669;
}
.detail-scroll {
height: 100%;
overflow: auto;
display: grid;
gap: 16px;
}
.detail-hero {
display: grid;
gap: 18px;
padding: 18px 20px;
}
.hero-profile {
display: flex;
align-items: center;
gap: 16px;
}
.hero-avatar {
width: 64px;
height: 64px;
display: grid;
place-items: center;
border-radius: 18px;
background: linear-gradient(135deg, #10b981, #047857);
color: #fff;
font-size: 24px;
font-weight: 900;
}
.hero-tag {
display: inline-flex;
align-items: center;
min-height: 26px;
padding: 0 10px;
border-radius: 999px;
background: #eff6ff;
color: #2563eb;
font-size: 12px;
font-weight: 800;
}
.hero-copy h2 {
margin-top: 10px;
color: #0f172a;
font-size: 24px;
font-weight: 850;
}
.hero-copy p {
margin-top: 8px;
color: #64748b;
font-size: 14px;
line-height: 1.6;
}
.hero-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.hero-stat {
padding: 14px 16px;
border-radius: 12px;
background: linear-gradient(180deg, #ffffff, #f8fafc);
border: 1px solid #edf2f7;
}
.hero-stat span {
display: block;
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.hero-stat strong {
display: block;
margin-top: 8px;
color: #0f172a;
font-size: 18px;
font-weight: 850;
}
.detail-grid {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.82fr);
gap: 16px;
}
.detail-main,
.detail-side {
display: grid;
gap: 16px;
align-content: start;
}
.detail-card,
.side-card {
padding: 18px;
}
.card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.card-head h3 {
color: #0f172a;
font-size: 16px;
font-weight: 850;
}
.card-head p {
margin-top: 6px;
color: #64748b;
font-size: 13px;
line-height: 1.5;
}
.count-badge {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
background: #ecfeff;
color: #0891b2;
font-size: 12px;
font-weight: 800;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.field {
display: grid;
gap: 7px;
}
.field span {
color: #64748b;
font-size: 12px;
font-weight: 800;
}
.field input {
width: 100%;
border: 1px solid #d7e0ea;
border-radius: 10px;
background: #fff;
color: #0f172a;
font-size: 13px;
line-height: 1.55;
padding: 10px 12px;
}
.role-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.role-card {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
gap: 12px;
align-items: start;
padding: 14px;
border: 1px solid #edf2f7;
border-radius: 14px;
background: #fbfdff;
}
.role-card.active {
border-color: rgba(16, 185, 129, 0.32);
background: linear-gradient(180deg, rgba(240, 253, 244, 0.85), #ffffff);
}
.role-card input {
margin-top: 3px;
}
.role-copy strong {
display: block;
color: #0f172a;
font-size: 14px;
font-weight: 850;
}
.role-copy p {
margin-top: 6px;
color: #64748b;
font-size: 12px;
line-height: 1.55;
}
.tag-list {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.bullet-list {
display: grid;
gap: 8px;
margin: 14px 0 0;
padding-left: 18px;
color: #475569;
font-size: 13px;
line-height: 1.6;
}
.history-list {
display: grid;
}
.history-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
padding: 12px 0;
border-top: 1px solid #edf2f7;
}
.history-row:first-child {
border-top: 0;
padding-top: 0;
}
.history-row strong {
display: block;
color: #0f172a;
font-size: 13px;
font-weight: 800;
}
.history-row span,
.history-row small {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.publish-card {
display: grid;
gap: 14px;
}
.publish-card p,
.publish-summary span {
margin-top: 6px;
color: #64748b;
font-size: 13px;
line-height: 1.55;
}
.publish-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 42px;
padding: 0 12px;
border-radius: 10px;
background: #f8fafc;
}
.publish-summary strong {
color: #059669;
font-size: 14px;
font-weight: 850;
}
.detail-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 0 0;
border-top: 1px solid #e5eaf0;
}
.detail-action-group {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.back-action,
.minor-action,
.major-action {
height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 760;
}
.back-action {
border: 1px solid #d7e0ea;
background: #fff;
color: #475569;
}
.minor-action {
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
}
.major-action {
border: 1px solid #059669;
background: #059669;
color: #fff;
box-shadow: 0 4px 12px rgba(5, 150, 105, 0.16);
}
@media (max-width: 1320px) {
.hero-stats,
.form-grid,
.role-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.detail-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 860px) {
.employee-list,
.detail-card,
.side-card,
.detail-hero {
padding: 16px;
}
.list-toolbar,
.card-head,
.detail-actions,
.detail-action-group,
.hero-profile {
flex-direction: column;
align-items: stretch;
}
.status-tabs,
.filter-set {
overflow-x: auto;
}
.list-search {
width: 100%;
}
.hero-stats,
.form-grid,
.role-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -1,170 +1,3 @@
<template>
<main class="login-page">
<header class="page-brand">
<LogoMark />
<strong>星海科技</strong>
</header>
<section class="hero">
<p class="eyebrow-text">Smart Expense Operations</p>
<h1>企业报销智能运营台</h1>
<p class="hero-lead">让报销审批更智能更高效</p>
<p class="hero-sub">智能审单 · 自动化审批 · 风险预警 · SLA 监控 · 数据驱动决策</p>
<div class="hero-stage" aria-hidden="true">
<span class="flow-line flow-a"></span>
<span class="flow-line flow-b"></span>
<span class="flow-line flow-c"></span>
<div class="metric-card amount">
<span>报销金额趋势</span>
<strong>361,600</strong>
<small>较昨日 <b class="up"> 8.3%</b></small>
<div class="mini-bars"><i></i><i></i><i></i><i></i></div>
</div>
<div class="document-card">
<span>报销单</span>
<i></i><i></i><i></i>
<b class="doc-check"><i class="mdi mdi-check"></i></b>
</div>
<img class="shield-art" src="../assets/security-shield.png" alt="" />
<div class="round-badge ai">AI</div>
<div class="metric-card risk">
<span>风险预警</span>
<strong><i class="mdi mdi-alert"></i> 14 </strong>
<small>较昨日 <b class="danger"> 16.7%</b></small>
</div>
<div class="metric-card audit">
<span>审批效率</span>
<strong>78%</strong>
<small>较昨日 <b class="up"> 6.2%</b></small>
</div>
<div class="metric-card sla">
<span>SLA 达成率</span>
<strong>96%</strong>
<small>较昨日 <b class="up"> 3.1%</b></small>
</div>
</div>
<div class="feature-strip" aria-label="核心能力">
<article v-for="item in features" :key="item.title">
<span :class="item.tone"><i :class="item.icon"></i></span>
<div>
<strong>{{ item.title }}</strong>
<p>{{ item.desc }}</p>
</div>
</article>
</div>
</section>
<section class="login-card" aria-label="登录表单">
<div class="card-brand">
<LogoMark />
<strong>星海科技</strong>
</div>
<header class="card-head">
<h2>欢迎登录</h2>
<p>登录企业报销智能运营台</p>
</header>
<form class="login-form" @submit.prevent="emit('login', { username, password })">
<label class="field">
<span class="sr-only">账号</span>
<i class="mdi mdi-account-outline"></i>
<input v-model="username" type="text" placeholder="请输入账号 / 邮箱 / 手机号" autocomplete="username" required />
</label>
<label class="field">
<span class="sr-only">密码</span>
<i class="mdi mdi-lock-outline"></i>
<input
v-model="password"
:type="showPassword ? 'text' : 'password'"
placeholder="请输入密码"
autocomplete="current-password"
required
/>
<button
class="field-icon-btn"
type="button"
:aria-label="showPassword ? '隐藏密码' : '显示密码'"
@click="showPassword = !showPassword"
>
<i :class="showPassword ? 'mdi mdi-eye' : 'mdi mdi-eye-slash'"></i>
</button>
</label>
<label class="field">
<span class="sr-only">企业或租户</span>
<i class="mdi mdi-office-building"></i>
<input v-model="tenant" type="text" placeholder="请输入企业 / 租户(选填)" />
<button class="field-icon-btn" type="button" aria-label="展开企业列表">
<i class="mdi mdi-chevron-down"></i>
</button>
</label>
<div class="form-meta">
<label class="remember">
<input v-model="remember" type="checkbox" />
<span>记住我</span>
</label>
<button type="button" class="link-btn" @click="emit('recover-password')">忘记密码?</button>
</div>
<button class="submit-btn" type="submit">登录</button>
<div class="divider"><span></span></div>
<button class="sso-btn" type="button" @click="emit('sso-login')">
<i class="mdi mdi-shield-outline"></i>
<span>SSO 单点登录</span>
</button>
</form>
<footer class="security-note">
<i class="mdi mdi-lock-outline"></i>
<span>安全登录 · 数据加密传输 · 如需帮助请联系管理员</span>
</footer>
</section>
</main>
</template>
<script setup>
import { ref } from 'vue'
const emit = defineEmits(['login', 'recover-password', 'sso-login'])
const username = ref('')
const password = ref('')
const tenant = ref('')
const remember = ref(true)
const showPassword = ref(false)
const features = [
{ title: '智能审单', desc: 'AI 自动识别票据与规则,提升准确率与效率', icon: 'mdi mdi-file-document-outline', tone: 'green' },
{ title: '异常预警', desc: '多维风险识别与预警,主动防控风险', icon: 'mdi mdi-bell-outline', tone: 'red' },
{ title: 'SLA 监控', desc: '实时监控服务水平协议,保障审批及时性', icon: 'mdi mdi-sync', tone: 'blue' }
]
const LogoMark = {
template: `
<span class="logo-mark" aria-hidden="true">
<svg viewBox="0 0 36 36">
<path d="M19.8 4.5c5.7 1.1 9.9 5.7 10.5 11.6-2.8-.9-5.5-.7-7.9.6-2.8 1.5-4.5 4.3-5.2 8.2-4.4-2.8-6.5-6.5-6.3-11.1.2-4.2 3.5-7.8 8.9-9.3Z" />
<path d="M9 7.6c-3 3.5-4 7.3-2.9 11.2 1.2 4.2 4.6 7 10.1 8.5-2 1.8-4.6 2.6-7.6 2.3C5.1 26.7 3.5 23.1 3.7 19 4 14.4 5.7 10.6 9 7.6Z" />
</svg>
</span>
`
}
</script>
<style scoped>
.login-page { .login-page {
position: relative; position: relative;
min-height: 100dvh; min-height: 100dvh;
@@ -852,4 +685,3 @@ const LogoMark = {
gap: 10px; gap: 10px;
} }
} }
</style>

View File

@@ -0,0 +1,387 @@
.dashboard {
display: grid;
gap: 16px;
animation: fadeUp 260ms var(--ease) both;
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px;
}
.kpi-card {
position: relative;
padding: 12px 14px 10px;
display: flex;
flex-direction: column;
border-left: 3px solid var(--accent);
animation: dashboardItemIn 520ms var(--ease) both;
animation-delay: var(--delay, 0ms);
transition: box-shadow 200ms ease, transform 200ms ease;
}
.kpi-card:hover {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
transform: translateY(-1px);
}
.kpi-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.kpi-icon {
width: 26px;
height: 26px;
display: grid;
place-items: center;
border-radius: 7px;
background: color-mix(in srgb, var(--accent) 10%, white);
color: var(--accent);
font-size: 14px;
flex: 0 0 auto;
animation: iconPop 560ms var(--ease) both;
animation-delay: calc(var(--delay, 0ms) + 100ms);
}
.kpi-label {
color: #64748b;
font-size: 11px;
font-weight: 500;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.kpi-value {
display: block;
min-height: 22px;
color: #0f172a;
font-size: clamp(16px, 1.2vw, 20px);
line-height: 1;
font-weight: 800;
font-variant-numeric: tabular-nums;
white-space: nowrap;
margin-bottom: 6px;
letter-spacing: 0;
}
.kpi-trend {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding-top: 6px;
border-top: 1px solid #f1f5f9;
}
.kpi-badge {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 1px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 700;
line-height: 1.45;
}
.kpi-badge.up {
background: rgba(239, 68, 68, 0.08);
color: #dc2626;
}
.kpi-badge.down {
background: rgba(22, 163, 74, 0.08);
color: #16a34a;
}
.kpi-badge .mdi {
font-size: 11px;
}
.kpi-delta {
color: #94a3b8;
font-size: 10px;
white-space: nowrap;
}
.content-grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 24px;
}
.dashboard-card {
padding: 20px;
transition: box-shadow 200ms ease, transform 200ms ease;
animation: dashboardItemIn 560ms var(--ease) both;
}
.top-grid .dashboard-card:nth-child(1) { animation-delay: 80ms; }
.top-grid .dashboard-card:nth-child(2) { animation-delay: 150ms; }
.top-grid .dashboard-card:nth-child(3) { animation-delay: 220ms; }
.bottom-grid .dashboard-card:nth-child(1) { animation-delay: 290ms; }
.bottom-grid .dashboard-card:nth-child(2) { animation-delay: 360ms; }
.bottom-grid .dashboard-card:nth-child(3) { animation-delay: 430ms; }
.dashboard-card:hover {
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
transform: translateY(-1px);
}
.trend-panel,
.rank-panel {
grid-column: span 6;
}
.donut-panel,
.bottleneck-panel,
.budget-panel {
grid-column: span 3;
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.card-head h3 {
color: #1e293b;
font-size: 16px;
font-weight: 600;
line-height: 1.35;
}
.card-head .mdi {
color: #94a3b8;
font-size: 12px;
vertical-align: 1px;
}
.card-select {
height: 30px;
min-width: 82px;
padding: 0 8px;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #fff;
color: #334155;
font-size: 14px;
}
.panel-note {
margin-top: 8px;
color: #64748b;
font-size: 12px;
text-align: center;
}
.bottleneck-panel,
.budget-panel {
display: flex;
flex-direction: column;
}
.bottleneck-panel .text-link,
.budget-panel .text-link {
margin-top: auto;
}
.bottleneck-list {
flex: 1;
display: grid;
gap: 16px;
align-content: center;
}
.bottleneck-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
animation: listRowIn 460ms var(--ease) both;
animation-delay: var(--delay, 0ms);
}
.reviewer {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.reviewer-avatar {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 999px;
background: #e2f6ef;
color: #047857;
font-size: 13px;
font-weight: 700;
flex: 0 0 auto;
}
.reviewer strong,
.reviewer-stats strong {
display: block;
color: #1e293b;
font-size: 14px;
font-weight: 500;
}
.reviewer span,
.reviewer-stats span {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 12px;
}
.reviewer-stats {
text-align: right;
}
.status-tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
}
.status-tag.danger {
background: rgba(239,68,68,.10);
color: #ef4444;
}
.status-tag.warning {
background: rgba(245,158,11,.10);
color: #f59e0b;
}
.status-tag.success {
background: rgba(16,185,129,.10);
color: #16a34a;
}
.text-link {
width: 100%;
margin-top: 16px;
display: inline-flex;
align-items: center;
justify-content: space-between;
padding: 16px 0 0;
border: 0;
border-top: 1px solid #f1f5f9;
background: transparent;
color: #10b981;
font-size: 14px;
}
@keyframes dashboardItemIn {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes listRowIn {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes iconPop {
0% {
opacity: 0;
transform: scale(.82);
}
70% {
opacity: 1;
transform: scale(1.04);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.kpi-card,
.dashboard-card,
.bottleneck-row {
animation: none;
}
}
@media (max-width: 1320px) {
.kpi-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.trend-panel,
.rank-panel {
grid-column: span 12;
}
.donut-panel,
.bottleneck-panel,
.budget-panel {
grid-column: span 6;
}
}
@media (max-width: 860px) {
.kpi-grid,
.content-grid {
grid-template-columns: 1fr;
}
.trend-panel,
.rank-panel,
.donut-panel,
.bottleneck-panel,
.budget-panel {
grid-column: span 1;
}
.card-head {
align-items: flex-start;
flex-direction: column;
}
.donut-wrap {
grid-template-columns: 1fr;
}
.rank-row {
grid-template-columns: 24px 64px minmax(0, 1fr);
}
.rank-value {
grid-column: 2 / -1;
}
.budget-summary {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,643 @@
.knowledge-page {
height: 100%;
min-height: 0;
overflow: hidden;
animation: fadeUp 220ms var(--ease) both;
}
.knowledge-grid {
height: 100%;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 0;
gap: 0;
transition: grid-template-columns 320ms var(--ease), gap 320ms var(--ease);
}
.knowledge-grid.has-preview {
grid-template-columns: minmax(560px, 1fr) minmax(420px, 0.82fr);
gap: 16px;
}
.knowledge-main,
.preview-column {
min-width: 0;
min-height: 0;
}
.knowledge-main {
overflow: hidden;
}
.library-panel {
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
padding: 16px 18px;
overflow: hidden;
}
.panel-title {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.panel-title h2,
.preview-head h2 {
color: #0f172a;
font-size: 16px;
font-weight: 850;
}
.panel-title p,
.preview-head p {
margin-top: 6px;
color: #64748b;
font-size: 13px;
line-height: 1.5;
}
.preview-hint {
min-height: 28px;
display: inline-flex;
align-items: center;
padding: 0 10px;
border-radius: 999px;
background: #f1f5f9;
color: #64748b;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
transition: background 220ms ease, color 220ms ease;
}
.preview-hint.active {
background: #dcfce7;
color: #059669;
}
.library-body {
min-height: 0;
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
gap: 14px;
margin-top: 16px;
}
.folder-rail {
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 12px;
border-right: 1px solid #edf2f7;
padding-right: 12px;
}
.folder-search {
height: 36px;
display: grid;
grid-template-columns: 18px minmax(0, 1fr) 24px;
align-items: center;
gap: 6px;
padding: 0 8px;
border: 1px solid #d7e0ea;
border-radius: 8px;
color: #64748b;
}
.folder-search input {
min-width: 0;
border: 0;
color: #0f172a;
font-size: 13px;
}
.folder-search input:focus {
outline: none;
}
.folder-search button {
border: 0;
background: transparent;
color: #64748b;
}
.folder-tree {
min-height: 0;
display: grid;
align-content: start;
gap: 6px;
overflow-y: auto;
}
.folder-tree button {
min-height: 34px;
display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 0 9px;
border: 0;
border-radius: 7px;
background: transparent;
color: #334155;
font-size: 13px;
text-align: left;
}
.folder-tree button.active {
background: #dcfce7;
color: #059669;
font-weight: 850;
}
.folder-tree b {
min-width: 24px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: #f1f5f9;
color: #64748b;
font-size: 11px;
}
.new-folder-btn {
min-height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid rgba(16, 185, 129, .28);
border-radius: 8px;
background: #f0fdf4;
color: #059669;
font-size: 13px;
font-weight: 850;
}
.document-area {
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 12px;
}
.upload-zone {
min-height: 112px;
display: grid;
place-items: center;
align-content: center;
gap: 8px;
border: 1px dashed #93c5fd;
border-radius: 10px;
background: #f8fbff;
color: #334155;
text-align: center;
}
.upload-zone i {
color: #2563eb;
font-size: 31px;
}
.upload-zone strong {
font-size: 13px;
font-weight: 850;
}
.upload-zone span {
color: #64748b;
font-size: 12px;
}
.doc-table-wrap {
min-height: 0;
overflow: auto;
}
table {
width: 100%;
min-width: 690px;
border-collapse: collapse;
}
th,
td {
padding: 12px 10px;
border-bottom: 1px solid #edf2f7;
color: #24324a;
font-size: 12px;
line-height: 1.35;
text-align: left;
vertical-align: middle;
}
th {
background: #f7fafc;
color: #64748b;
font-weight: 800;
white-space: nowrap;
}
.doc-row {
cursor: pointer;
transition: background 180ms ease, box-shadow 180ms ease;
}
.doc-row:hover {
background: #f8fbff;
}
.doc-row.selected {
background: linear-gradient(90deg, rgba(16, 185, 129, 0.08), rgba(59, 130, 246, 0.04));
box-shadow: inset 3px 0 0 #10b981;
}
.file-name {
display: inline-flex;
align-items: center;
gap: 7px;
font-weight: 750;
white-space: nowrap;
}
.file-name .pdf,
.viewer-filetype.pdf { color: #ef4444; }
.file-name .word,
.viewer-filetype.word { color: #2563eb; }
.file-name .excel,
.viewer-filetype.excel { color: #10b981; }
.doc-tag {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 7px;
border-radius: 6px;
background: #f1f5f9;
color: #64748b;
font-size: 11px;
font-weight: 750;
}
.state-tag {
min-height: 22px;
display: inline-flex;
align-items: center;
padding: 0 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 800;
white-space: nowrap;
}
.state-tag.success {
background: #dcfce7;
color: #059669;
}
.state-tag.warning {
background: #ffedd5;
color: #f97316;
}
.more-btn {
border: 0;
background: transparent;
color: #2563eb;
}
.list-foot {
display: grid;
grid-template-columns: auto auto 1fr auto;
align-items: center;
gap: 10px;
color: #64748b;
font-size: 13px;
}
.list-foot button {
min-height: 32px;
border: 1px solid #d7e0ea;
border-radius: 8px;
background: #fff;
color: #334155;
font-weight: 750;
}
.pager {
display: inline-flex;
gap: 6px;
}
.pager button {
width: 32px;
padding: 0;
}
.pager button.active {
border-color: #059669;
background: #059669;
color: #fff;
}
.list-foot input {
width: 42px;
height: 30px;
border: 1px solid #d7e0ea;
border-radius: 7px;
text-align: center;
}
.preview-column {
min-width: 0;
min-height: 0;
overflow: hidden;
}
.preview-panel {
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
padding: 18px;
overflow: hidden;
}
.preview-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
}
.preview-kicker {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 8px;
border-radius: 999px;
background: #ecfdf5;
color: #059669;
font-size: 11px;
font-weight: 800;
}
.preview-actions {
display: flex;
align-items: center;
gap: 8px;
}
.mini-action,
.icon-action,
.viewer-toolbar-actions button {
border: 1px solid #d7e0ea;
border-radius: 8px;
background: #fff;
color: #334155;
}
.mini-action {
min-height: 34px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 12px;
font-size: 12px;
font-weight: 800;
}
.icon-action {
width: 34px;
height: 34px;
display: grid;
place-items: center;
}
.preview-meta {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid #edf2f7;
}
.preview-meta span {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
background: #f8fafc;
color: #475569;
font-size: 12px;
font-weight: 700;
}
.preview-viewer {
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 12px;
margin-top: 14px;
}
.viewer-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 44px;
padding: 0 12px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #f8fafc;
}
.viewer-filetype {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 800;
}
.viewer-toolbar-actions {
display: inline-flex;
gap: 8px;
}
.viewer-toolbar-actions button {
width: 32px;
height: 32px;
display: grid;
place-items: center;
}
.page-stage {
min-height: 0;
overflow: auto;
display: grid;
gap: 16px;
padding-right: 4px;
}
.page-sheet {
display: grid;
gap: 16px;
padding: 18px;
border: 1px solid #e2e8f0;
border-radius: 14px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06);
animation: previewSheetIn 360ms var(--ease) both;
animation-delay: var(--page-delay, 0ms);
}
.page-title {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.page-title strong {
color: #0f172a;
font-size: 15px;
font-weight: 850;
}
.page-title span,
.page-title b {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.summary-item {
display: grid;
gap: 6px;
padding: 12px;
border-radius: 10px;
background: #f8fafc;
}
.summary-item span {
color: #64748b;
font-size: 11px;
font-weight: 700;
}
.summary-item strong {
color: #0f172a;
font-size: 14px;
font-weight: 850;
}
.page-content {
display: grid;
gap: 12px;
}
.content-block {
padding: 14px;
border-radius: 12px;
background: #ffffff;
border: 1px solid #edf2f7;
}
.content-block h3 {
margin: 0 0 8px;
color: #0f172a;
font-size: 13px;
font-weight: 850;
}
.content-block ul {
display: grid;
gap: 8px;
margin: 0;
padding-left: 18px;
color: #475569;
font-size: 12px;
line-height: 1.6;
}
.preview-panel-enter-active,
.preview-panel-leave-active {
transition: opacity 240ms ease, transform 320ms var(--ease);
}
.preview-panel-enter-from,
.preview-panel-leave-to {
opacity: 0;
transform: translateX(24px) scale(0.98);
}
@keyframes previewSheetIn {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 1320px) {
.knowledge-grid.has-preview {
grid-template-columns: minmax(0, 1fr) minmax(360px, 0.78fr);
}
}
@media (max-width: 1080px) {
.knowledge-grid,
.knowledge-grid.has-preview {
grid-template-columns: 1fr;
gap: 16px;
overflow-y: auto;
}
.library-body {
grid-template-columns: 1fr;
}
.folder-rail {
border-right: 0;
border-bottom: 1px solid #edf2f7;
padding: 0 0 12px;
}
}
@media (max-width: 760px) {
.panel-title,
.preview-head,
.viewer-toolbar {
flex-direction: column;
align-items: stretch;
}
.summary-grid,
.list-foot {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,523 @@
.travel-page {
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: minmax(0, 1fr);
gap: 14px;
animation: fadeUp 220ms var(--ease) both;
overflow: hidden;
}
.travel-list {
min-height: 0;
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
padding: 16px 18px;
overflow: hidden;
}
.list-search {
position: relative;
width: 220px;
}
.list-search .mdi {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #64748b;
font-size: 15px;
}
.list-search input {
width: 100%;
height: 38px;
padding: 0 12px 0 36px;
border: 1px solid #d7e0ea;
border-radius: 8px;
background: #fff;
color: #0f172a;
font-size: 13px;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.list-search input::placeholder {
color: #8da0b4;
}
.list-search input:focus {
border-color: #10b981;
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.14);
outline: none;
}
.status-tabs {
display: flex;
gap: 28px;
margin-top: 14px;
border-bottom: 1px solid #dbe4ee;
}
.status-tabs button {
position: relative;
min-height: 36px;
border: 0;
background: transparent;
color: #64748b;
font-size: 14px;
font-weight: 750;
}
.status-tabs button.active {
color: #059669;
}
.status-tabs button.active::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 3px;
border-radius: 999px 999px 0 0;
background: #10b981;
}
.list-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 14px;
}
.filter-set {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.create-request-btn {
min-height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 18px;
border: 0;
border-radius: 10px;
background: linear-gradient(135deg, #10b981, #059669);
color: #fff;
font-size: 14px;
font-weight: 800;
white-space: nowrap;
box-shadow: 0 10px 24px rgba(5, 150, 105, 0.2);
transition: transform 160ms ease, box-shadow 160ms ease, filter 160ms ease;
}
.create-request-btn:hover {
transform: translateY(-1px);
box-shadow: 0 14px 28px rgba(5, 150, 105, 0.24);
filter: saturate(1.02);
}
.filter-btn,
.page-size {
min-height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
padding: 0 14px;
border-radius: 8px;
font-size: 14px;
font-weight: 750;
white-space: nowrap;
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
}
.filter-btn {
min-width: 120px;
justify-content: space-between;
}
.date-range-filter {
position: relative;
}
.date-range-trigger {
min-width: 160px;
}
.date-range-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 110px;
}
.date-range-popover {
position: absolute;
top: calc(100% + 8px);
left: 0;
width: 320px;
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);
}
.date-range-popover header,
.date-range-popover footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.date-range-popover header strong {
color: #0f172a;
font-size: 15px;
}
.date-range-popover header button {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border: 0;
border-radius: 8px;
background: transparent;
color: #64748b;
}
.date-range-popover header button:hover {
background: #f1f5f9;
color: #0f172a;
}
.date-range-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.date-range-fields label {
display: grid;
gap: 6px;
}
.date-range-fields span {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.date-range-fields input {
width: 100%;
height: 38px;
padding: 0 9px;
border: 1px solid #d7e0ea;
border-radius: 8px;
color: #0f172a;
font-size: 13px;
}
.date-range-fields input:focus {
border-color: #10b981;
box-shadow: 0 0 0 3px rgba(16, 185, 129, .12);
outline: none;
}
.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;
}
.filter-btn:hover,
.page-size:hover {
border-color: rgba(16, 185, 129, .32);
color: #0f9f78;
}
.hint {
display: inline-flex;
align-items: center;
gap: 7px;
margin-top: 10px;
color: #64748b;
font-size: 13px;
}
.hint .mdi {
color: #64748b;
}
.table-wrap {
margin-top: 10px;
overflow-x: auto;
overflow-y: auto;
border: 1px solid #edf2f7;
border-radius: 10px;
}
table {
height: 100%;
width: 100%;
min-width: 1140px;
border-collapse: collapse;
table-layout: fixed;
}
colgroup col {
width: 10%;
}
th,
td {
padding: 13px 12px;
border-bottom: 1px solid #edf2f7;
color: #24324a;
font-size: 14px;
line-height: 1.35;
text-align: center;
vertical-align: middle;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
th {
position: sticky;
top: 0;
z-index: 1;
background: #f7fafc;
color: #64748b;
font-size: 13px;
font-weight: 800;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
tbody tr {
cursor: pointer;
}
tbody tr:hover {
background: linear-gradient(90deg, rgba(16, 185, 129, .08), rgba(16, 185, 129, .03));
}
tbody tr:last-child td {
border-bottom: 0;
}
.doc-id {
color: #059669;
font-weight: 800;
}
.status-tag {
min-height: 24px;
display: inline-flex;
align-items: center;
padding: 0 9px;
border: 1px solid transparent;
border-radius: 6px;
font-size: 12px;
font-weight: 750;
white-space: nowrap;
}
.status-tag.info {
border-color: #bfdbfe;
background: #eff6ff;
color: #2563eb;
}
.status-tag.success {
border-color: #bbf7d0;
background: #ecfdf5;
color: #059669;
}
.status-tag.warning {
border-color: #fed7aa;
background: #fff7ed;
color: #f97316;
}
.status-tag.neutral {
border-color: #cbd5e1;
background: #f8fafc;
color: #475569;
}
.list-foot {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
margin-top: 24px;
}
.page-summary {
color: #64748b;
font-size: 14px;
font-weight: 650;
}
.pager {
display: inline-flex;
justify-content: center;
gap: 6px;
padding: 4px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
}
.pager button {
width: 32px;
height: 32px;
border: 0;
border-radius: 9px;
background: transparent;
color: #334155;
font-size: 14px;
font-weight: 800;
transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease;
}
.pager button:hover:not(.active) {
background: #fff;
color: #059669;
box-shadow: 0 1px 4px rgba(15, 23, 42, .08);
}
.pager button.active {
background: #059669;
color: #fff;
box-shadow: 0 8px 16px rgba(5, 150, 105, .20);
}
.page-nav {
color: #64748b;
}
.page-size {
justify-self: end;
min-width: 112px;
border-radius: 10px;
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
}
.page-size-wrap {
position: relative;
justify-self: end;
}
.page-size-dropdown {
position: absolute;
bottom: calc(100% + 6px);
right: 0;
z-index: 40;
display: grid;
border: 1px solid #d7e0ea;
border-radius: 10px;
background: #fff;
box-shadow: 0 12px 32px rgba(15, 23, 42, .14);
overflow: hidden;
}
.page-size-dropdown button {
height: 36px;
display: grid;
place-items: center;
border: 0;
border-radius: 0;
background: transparent;
color: #334155;
font-size: 13px;
font-weight: 750;
white-space: nowrap;
padding: 0 20px;
transition: background 120ms ease, color 120ms ease;
}
.page-size-dropdown button:hover {
background: #f0fdf4;
color: #059669;
}
.page-size-dropdown button.active {
background: #059669;
color: #fff;
}
@media (max-width: 1200px) {
.list-toolbar,
.list-foot {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.travel-list {
padding: 16px;
}
.status-tabs {
gap: 18px;
overflow-x: auto;
}
.filter-btn,
.page-size {
width: 100%;
}
.filter-set {
width: 100%;
}
.list-foot {
display: grid;
justify-items: stretch;
}
.pager,
.page-size {
justify-self: stretch;
}
}

View File

@@ -0,0 +1,756 @@
.assistant-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: grid;
place-items: center;
background: rgba(15, 23, 42, 0.46);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.assistant-modal {
width: min(1480px, calc(100vw - 48px));
height: min(920px, calc(100vh - 40px));
display: grid;
grid-template-rows: auto minmax(0, 1fr);
border-radius: 28px;
background:
radial-gradient(circle at top right, rgba(16, 185, 129, 0.08), transparent 24%),
linear-gradient(180deg, #fbfdff 0%, #f6f9fc 100%);
box-shadow:
0 24px 80px rgba(15, 23, 42, 0.22),
0 2px 12px rgba(15, 23, 42, 0.08);
overflow: hidden;
}
.assistant-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 24px 28px 20px;
border-bottom: 1px solid #e5edf5;
background: rgba(255, 255, 255, 0.82);
}
.assistant-header-main {
display: flex;
align-items: flex-start;
gap: 16px;
min-width: 0;
}
.assistant-badge {
min-height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 14px;
border-radius: 999px;
background: rgba(16, 185, 129, 0.12);
color: #059669;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.assistant-header h2 {
color: #0f172a;
font-size: 24px;
font-weight: 900;
}
.assistant-header p {
margin-top: 4px;
color: #64748b;
font-size: 14px;
line-height: 1.6;
}
.assistant-header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.source-pill {
min-height: 34px;
display: inline-flex;
align-items: center;
padding: 0 14px;
border-radius: 999px;
background: #eff6ff;
color: #2563eb;
font-size: 13px;
font-weight: 800;
white-space: nowrap;
}
.close-btn {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border: 1px solid #d7e0ea;
border-radius: 999px;
background: #fff;
color: #64748b;
font-size: 18px;
}
.assistant-layout {
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 18px;
padding: 18px;
}
.assistant-layout.has-insight {
grid-template-columns: minmax(0, 1.18fr) 420px;
}
.dialog-panel,
.insight-panel {
min-width: 0;
min-height: 0;
border: 1px solid #e7eef6;
border-radius: 24px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.04);
}
.dialog-panel {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden;
}
.dialog-toolbar {
display: flex;
gap: 10px;
flex-wrap: wrap;
padding: 18px 20px 14px;
border-bottom: 1px solid #eef2f7;
}
.shortcut-chip {
min-height: 36px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 14px;
border: 1px solid #dbe6f0;
border-radius: 999px;
background: #fff;
color: #334155;
font-size: 13px;
font-weight: 800;
white-space: nowrap;
}
.shortcut-chip i {
color: #059669;
}
.message-list {
min-height: 0;
display: grid;
align-content: start;
gap: 16px;
padding: 20px;
overflow-y: auto;
}
.message-row {
display: grid;
grid-template-columns: 38px minmax(0, 1fr);
align-items: start;
gap: 12px;
}
.message-row.user {
grid-template-columns: minmax(0, 1fr) 38px;
}
.message-row.user .message-avatar {
order: 2;
background: #dbeafe;
color: #2563eb;
}
.message-row.user .message-bubble {
order: 1;
justify-self: end;
background: linear-gradient(135deg, rgba(37, 99, 235, 0.10), rgba(37, 99, 235, 0.04));
border-color: rgba(37, 99, 235, 0.16);
}
.message-avatar {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border-radius: 999px;
background: #dff7ee;
color: #059669;
font-size: 20px;
}
.message-bubble {
max-width: min(100%, 720px);
padding: 14px 16px;
border: 1px solid #e1e8f0;
border-radius: 20px;
background: #fff;
color: #24324a;
line-height: 1.65;
}
.message-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.message-meta strong {
color: #0f172a;
font-size: 13px;
font-weight: 850;
}
.message-meta time {
color: #94a3b8;
font-size: 12px;
}
.message-bubble p {
color: #334155;
font-size: 14px;
}
.message-files,
.composer-files {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 10px;
}
.file-chip {
min-height: 28px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 10px;
border-radius: 999px;
background: #f1f5f9;
color: #475569;
font-size: 12px;
font-weight: 700;
}
.file-chip.active {
background: #eef6ff;
color: #2563eb;
}
.composer {
padding: 0 20px 20px;
}
.hidden-file-input {
display: none;
}
.composer-shell {
border: 1px solid #d6e1ea;
border-radius: 22px;
background: #fff;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.05);
overflow: hidden;
}
.composer-shell textarea {
width: 100%;
min-height: 84px;
resize: none;
border: 0;
padding: 18px 18px 8px;
background: transparent;
color: #0f172a;
font-size: 15px;
line-height: 1.65;
}
.composer-shell textarea::placeholder {
color: #94a3b8;
}
.composer-shell textarea:focus {
outline: none;
}
.composer-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 14px 14px;
}
.composer-tools {
display: flex;
align-items: center;
gap: 10px;
}
.tool-btn,
.send-btn {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 0;
border-radius: 14px;
}
.tool-btn {
background: #f1f5f9;
color: #475569;
font-size: 20px;
}
.composer-tip {
color: #94a3b8;
font-size: 12px;
font-weight: 700;
}
.send-btn {
background: #10b981;
color: #fff;
font-size: 18px;
box-shadow: 0 10px 22px rgba(16, 185, 129, 0.22);
}
.send-btn:disabled {
opacity: 0.48;
cursor: not-allowed;
box-shadow: none;
}
.insight-panel {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
}
.insight-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 20px;
border-bottom: 1px solid #eef2f7;
}
.intent-pill {
min-height: 28px;
display: inline-flex;
align-items: center;
padding: 0 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
}
.intent-pill.welcome {
background: #eef2ff;
color: #4f46e5;
}
.intent-pill.draft {
background: #ecfdf5;
color: #059669;
}
.intent-pill.approval {
background: #fff7ed;
color: #ea580c;
}
.intent-pill.recognition {
background: #eff6ff;
color: #2563eb;
}
.intent-pill.note {
background: #fdf2f8;
color: #db2777;
}
.insight-head h3 {
margin-top: 10px;
color: #0f172a;
font-size: 20px;
font-weight: 900;
line-height: 1.3;
}
.insight-head p {
margin-top: 6px;
color: #64748b;
font-size: 13px;
line-height: 1.6;
}
.confidence-card {
min-width: 92px;
padding: 10px 12px;
border-radius: 16px;
background: #f8fafc;
text-align: right;
}
.confidence-card span {
display: block;
color: #94a3b8;
font-size: 11px;
font-weight: 800;
}
.confidence-card strong {
display: block;
margin-top: 4px;
color: #0f172a;
font-size: 22px;
font-weight: 900;
}
.insight-body {
min-height: 0;
display: grid;
align-content: start;
gap: 14px;
padding: 18px 20px 20px;
overflow-y: auto;
}
.insight-card {
padding: 16px;
border: 1px solid #e7eef6;
border-radius: 20px;
background: #fff;
}
.insight-card.primary {
background: linear-gradient(180deg, #ffffff 0%, #f9fbff 100%);
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.card-head h4 {
color: #0f172a;
font-size: 15px;
font-weight: 850;
}
.status-pill {
min-height: 28px;
display: inline-flex;
align-items: center;
padding: 0 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.status-pill.success {
background: #ecfdf5;
color: #059669;
}
.status-pill.warning {
background: #fff7ed;
color: #ea580c;
}
.status-pill.note {
background: #fdf2f8;
color: #db2777;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.metric-grid.single {
grid-template-columns: 1fr;
}
.metric-item {
padding: 12px 14px;
border-radius: 16px;
background: #f8fafc;
}
.metric-item span {
display: block;
color: #94a3b8;
font-size: 11px;
font-weight: 800;
}
.metric-item strong {
display: block;
margin-top: 6px;
color: #0f172a;
font-size: 14px;
font-weight: 850;
line-height: 1.5;
}
.timeline-list,
.bullet-list {
display: grid;
gap: 12px;
padding: 0;
margin: 0;
list-style: none;
}
.timeline-list li {
display: grid;
grid-template-columns: 14px minmax(0, 1fr);
gap: 12px;
align-items: start;
}
.timeline-dot {
width: 10px;
height: 10px;
margin-top: 5px;
border-radius: 999px;
background: #cbd5e1;
}
.timeline-list li.done .timeline-dot,
.timeline-list li.current .timeline-dot {
background: #10b981;
}
.timeline-list strong {
display: block;
color: #0f172a;
font-size: 13px;
font-weight: 800;
}
.timeline-list p,
.bullet-list li,
.welcome-card p,
.note-block p {
color: #64748b;
font-size: 13px;
line-height: 1.6;
}
.receipt-list {
display: grid;
gap: 10px;
}
.receipt-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 12px 14px;
border-radius: 16px;
background: #f8fafc;
}
.receipt-row strong,
.welcome-card strong,
.note-block strong {
color: #0f172a;
font-size: 14px;
font-weight: 850;
}
.receipt-row p,
.receipt-row span {
color: #64748b;
font-size: 12px;
}
.receipt-side {
text-align: right;
}
.receipt-side strong {
display: block;
}
.note-block {
display: grid;
gap: 8px;
padding: 14px;
border-radius: 16px;
background: #f8fafc;
}
.note-block span {
color: #94a3b8;
font-size: 11px;
font-weight: 800;
}
.welcome-grid {
display: grid;
gap: 12px;
}
.welcome-card {
padding: 14px;
border-radius: 18px;
background: #f8fafc;
}
.welcome-card i {
color: #10b981;
font-size: 20px;
}
.welcome-card strong {
display: block;
margin-top: 10px;
}
.assistant-modal-enter-active,
.assistant-modal-leave-active {
transition: opacity 220ms ease;
}
.assistant-modal-enter-active .assistant-modal,
.assistant-modal-leave-active .assistant-modal {
transition: transform 260ms ease, opacity 220ms ease;
}
.assistant-modal-enter-from,
.assistant-modal-leave-to {
opacity: 0;
}
.assistant-modal-enter-from .assistant-modal,
.assistant-modal-leave-to .assistant-modal {
transform: translateY(10px) scale(0.985);
opacity: 0;
}
.insight-switch-enter-active,
.insight-switch-leave-active {
transition: opacity 180ms ease, transform 180ms ease;
}
.insight-switch-enter-from,
.insight-switch-leave-to {
opacity: 0;
transform: translateY(8px);
}
.insight-panel-enter-active,
.insight-panel-leave-active {
transition: opacity 220ms ease, transform 240ms ease;
}
.insight-panel-enter-from,
.insight-panel-leave-to {
opacity: 0;
transform: translateX(18px);
}
@media (max-width: 1280px) {
.assistant-layout {
grid-template-columns: 1fr;
}
.insight-panel {
min-height: 320px;
}
}
@media (max-width: 760px) {
.assistant-modal {
width: 100vw;
height: 100vh;
border-radius: 0;
}
.assistant-header {
padding: 18px 18px 16px;
align-items: flex-start;
flex-direction: column;
}
.assistant-header-actions {
width: 100%;
justify-content: space-between;
}
.assistant-layout {
padding: 14px;
}
.dialog-toolbar {
padding: 16px 16px 12px;
}
.shortcut-chip {
width: 100%;
justify-content: center;
}
.message-list {
padding: 16px;
}
.message-row,
.message-row.user {
grid-template-columns: 34px minmax(0, 1fr);
}
.message-row.user .message-avatar {
order: 0;
}
.message-row.user .message-bubble {
order: 0;
justify-self: stretch;
}
.composer {
padding: 0 16px 16px;
}
.composer-foot {
align-items: flex-end;
}
.metric-grid {
grid-template-columns: 1fr;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,7 @@
<div class="assistant-input"> <div class="assistant-input">
<textarea <textarea
v-model="assistantDraft" v-model="assistantDraft"
rows="2" rows="1"
placeholder="例如:我昨天请客户吃饭花了 860 元,还打车去了客户公司" placeholder="例如:我昨天请客户吃饭花了 860 元,还打车去了客户公司"
@keydown.ctrl.enter.prevent="openAssistantWithDraft" @keydown.ctrl.enter.prevent="openAssistantWithDraft"
/> />
@@ -364,8 +364,8 @@ const policyItems = [
display: flex; display: flex;
align-items: center; align-items: center;
gap: 14px; gap: 14px;
min-height: 68px; min-height: 52px;
padding: 10px 10px 10px 14px; padding: 6px 8px 6px 14px;
border: 1px solid rgba(148, 163, 184, 0.28); border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 12px; border-radius: 12px;
background: rgba(255, 255, 255, 0.92); background: rgba(255, 255, 255, 0.92);
@@ -375,14 +375,17 @@ const policyItems = [
.assistant-input textarea { .assistant-input textarea {
min-width: 0; min-width: 0;
flex: 1; flex: 1;
min-height: 42px; height: 24px;
min-height: 24px;
max-height: 24px;
resize: none; resize: none;
border: 0; border: 0;
padding: 4px 0; padding: 1px 0;
background: transparent; background: transparent;
color: #0f172a; color: #0f172a;
font-size: 15px; font-size: 15px;
line-height: 1.55; line-height: 22px;
overflow: hidden;
} }
.assistant-input textarea::placeholder { .assistant-input textarea::placeholder {
@@ -403,7 +406,7 @@ const policyItems = [
} }
.hero-action { .hero-action {
height: 40px; height: 36px;
padding: 0 20px; padding: 0 20px;
border-radius: 10px; border-radius: 10px;
background: linear-gradient(135deg, #10b981, #059669); background: linear-gradient(135deg, #10b981, #059669);
@@ -745,7 +748,10 @@ const policyItems = [
} }
.assistant-input textarea { .assistant-input textarea {
min-height: 72px; height: 40px;
min-height: 40px;
max-height: 40px;
line-height: 1.5;
} }
.hero-action, .hero-action,

View File

@@ -56,7 +56,8 @@ const sidebarMeta = {
approval: { label: '审批中心', badge: '12' }, approval: { label: '审批中心', badge: '12' },
chat: { label: 'AI助手' }, chat: { label: 'AI助手' },
policies: { label: '知识管理' }, policies: { label: '知识管理' },
audit: { label: '技能中心' } audit: { label: '技能中心' },
employees: { label: '员工管理' }
} }
const decoratedNavItems = computed(() => const decoratedNavItems = computed(() =>

View File

@@ -1,4 +1,4 @@
<template> <template>
<header class="topbar" :class="{ 'chat-mode': isChat }"> <header class="topbar" :class="{ 'chat-mode': isChat }">
<div class="title-group"> <div class="title-group">
<div class="eyebrow">{{ isChat ? 'Smart Finance Q&A' : 'Smart Expense Operations' }}</div> <div class="eyebrow">{{ isChat ? 'Smart Finance Q&A' : 'Smart Expense Operations' }}</div>
@@ -91,11 +91,6 @@
<span class="chip-delta" :class="kpi.trend">{{ kpi.delta }} <i :class="kpi.arrow"></i></span> <span class="chip-delta" :class="kpi.trend">{{ kpi.delta }} <i :class="kpi.arrow"></i></span>
</div> </div>
</div> </div>
<div class="topbar-spacer"></div>
<button class="create-top-btn" type="button" @click="emit('newApplication')">
<i class="mdi mdi-plus"></i>
<span>发起报销</span>
</button>
</template> </template>
<template v-else-if="isApproval"> <template v-else-if="isApproval">

View File

@@ -0,0 +1,169 @@
import { computed, ref } from 'vue'
import { useNavigation, navItems } from './useNavigation.js'
import { useRequests } from './useRequests.js'
import { useChat } from './useChat.js'
import { useToast } from './useToast.js'
import { documents } from '../data/requests.js'
export function useAppShell() {
const loggedIn = ref(false)
const travelCreateMode = ref(false)
const detailMode = ref(false)
const selectedTravelRequest = ref(null)
const smartEntryOpen = ref(false)
const smartEntryContext = ref({ prompt: '', source: 'requests', request: null })
const smartEntrySessionId = ref(0)
const { activeView, currentView, setView } = useNavigation()
const { requests, search, filters, ranges, activeRange, filteredRequests, approveRequest, rejectRequest } = useRequests()
const { messages, draft, uploadedFiles, messageList, activeCase, prompts, sendMessage, handleUpload, openChat, openNewChat } = useChat(activeView)
const { toastText, toast } = useToast()
const docSearch = ref('')
const customRange = ref({ start: '2024-07-06', end: '2024-07-12' })
const travelPrompts = ['帮我提交出差申请', '预订机票', '预订酒店', '预订火车票', '查询差旅政策']
const topBarView = computed(() => {
if (detailMode.value) {
return {
title: '差旅报销详情',
desc: '查看报销单据详情、票据识别与审批进度'
}
}
return currentView.value
})
const filteredDocuments = computed(() => {
const key = docSearch.value.trim().toLowerCase()
return documents.filter((doc) => {
const matchesSearch = !key || `${doc.id}${doc.applicant}${doc.destination}${doc.type}`.toLowerCase().includes(key)
return matchesSearch
})
})
function handleLogin(credentials) {
if (credentials.username && credentials.password) {
loggedIn.value = true
}
}
function handleRecoverPassword() {
toast('请联系系统管理员重置密码。')
}
function handleSsoLogin() {
toast('SSO 登录通道建设中。')
}
function handleApprove(request) {
const msg = approveRequest(request)
toast(msg)
}
function handleReject(request) {
const msg = rejectRequest(request)
toast(msg)
}
function handleNavigate(view) {
travelCreateMode.value = false
detailMode.value = false
selectedTravelRequest.value = null
smartEntryOpen.value = false
setView(view)
}
function handleOpenChat(request) {
travelCreateMode.value = false
openChat(request)
}
function openTravelCreate() {
smartEntryOpen.value = true
travelCreateMode.value = false
detailMode.value = false
selectedTravelRequest.value = null
smartEntryContext.value = { prompt: '', source: 'topbar', request: null }
smartEntrySessionId.value += 1
}
function openSmartEntry(payload = {}) {
smartEntryOpen.value = true
travelCreateMode.value = false
if (payload.source !== 'detail') {
detailMode.value = false
selectedTravelRequest.value = null
}
smartEntryContext.value = {
prompt: payload.prompt ?? '',
source: payload.source ?? 'workbench',
request: payload.request ?? selectedTravelRequest.value
}
smartEntrySessionId.value += 1
}
function closeSmartEntry() {
smartEntryOpen.value = false
}
function openRequestDetail(request) {
selectedTravelRequest.value = request
detailMode.value = true
activeView.value = 'requests'
}
function closeRequestDetail() {
detailMode.value = false
selectedTravelRequest.value = null
}
return {
activeCase,
activeRange,
activeView,
closeRequestDetail,
closeSmartEntry,
currentView,
customRange,
detailMode,
docSearch,
draft,
filteredDocuments,
filteredRequests,
filters,
handleApprove,
handleLogin,
handleNavigate,
handleOpenChat,
handleRecoverPassword,
handleReject,
handleSsoLogin,
handleUpload,
loggedIn,
messageList,
messages,
navItems,
openChat,
openNewChat,
openRequestDetail,
openSmartEntry,
openTravelCreate,
prompts,
ranges,
requests,
search,
selectedTravelRequest,
sendMessage,
setView,
smartEntryContext,
smartEntryOpen,
smartEntrySessionId,
toast,
toastText,
topBarView,
travelCreateMode,
travelPrompts,
uploadedFiles
}
}

View File

@@ -0,0 +1,36 @@
import { ref } from 'vue'
export function useLoginView() {
const username = ref('')
const password = ref('')
const tenant = ref('')
const remember = ref(true)
const showPassword = ref(false)
const features = [
{ title: '智能审单', desc: 'AI 自动识别票据与规则,提升准确率与效率', icon: 'mdi mdi-file-document-outline', tone: 'green' },
{ title: '异常预警', desc: '多维风险识别与预警,主动防控风险', icon: 'mdi mdi-bell-outline', tone: 'red' },
{ title: 'SLA 监控', desc: '实时监控服务水平协议,保障审批及时性', icon: 'mdi mdi-sync', tone: 'blue' }
]
const LogoMark = {
template: `
<span class="logo-mark" aria-hidden="true">
<svg viewBox="0 0 36 36">
<path d="M19.8 4.5c5.7 1.1 9.9 5.7 10.5 11.6-2.8-.9-5.5-.7-7.9.6-2.8 1.5-4.5 4.3-5.2 8.2-4.4-2.8-6.5-6.5-6.3-11.1.2-4.2 3.5-7.8 8.9-9.3Z" />
<path d="M9 7.6c-3 3.5-4 7.3-2.9 11.2 1.2 4.2 4.6 7 10.1 8.5-2 1.8-4.6 2.6-7.6 2.3C5.1 26.7 3.5 23.1 3.7 19 4 14.4 5.7 10.6 9 7.6Z" />
</svg>
</span>
`
}
return {
features,
LogoMark,
password,
remember,
showPassword,
tenant,
username
}
}

View File

@@ -57,6 +57,14 @@ export const navItems = [
icon: icons.skill, icon: icons.skill,
title: '技能中心', title: '技能中心',
desc: '统一管理技能的触发规则、提示词结构、输出约束与上线版本' desc: '统一管理技能的触发规则、提示词结构、输出约束与上线版本'
},
{
id: 'employees',
label: '员工管理',
navHint: '员工档案、岗位与角色权限',
icon: icons.users,
title: '员工管理',
desc: '集中维护员工基础信息、职级部门岗位,以及管理员、财务人员、使用者和高级管理人员等系统角色'
} }
] ]

View File

@@ -0,0 +1,113 @@
import { computed, ref } from 'vue'
import {
metricBlueprints,
trendRanges,
trendSeries,
spendByCategory,
exceptionMix,
departmentRangeOptions,
bottlenecks,
budgetSummary
} from '../data/metrics.js'
export function useOverviewView() {
const activeTrendRange = ref(trendRanges[0])
const activeDepartmentRange = ref(departmentRangeOptions[0])
const demoTotals = {
pendingCount: 128,
pendingAmount: 361600,
avgSla: 6.8,
autoPassRate: 78,
riskCount: 14,
slaRate: 96
}
const demoDepartments = [
{ name: '销售部', amount: 182000, color: '#10b981' },
{ name: '研发中心', amount: 146000, color: '#3b82f6' },
{ name: '市场部', amount: 96000, color: '#f59e0b' },
{ name: '运营部', amount: 68600, color: '#8b5cf6' },
{ name: '行政部', amount: 48300, color: '#3b82f6' }
]
const formatCompact = (value) => {
if (value >= 1_000_000) return `¥${(value / 1_000_000).toFixed(1)}M`
if (value >= 1_000) return `¥${(value / 1_000).toFixed(1)}K`
return `¥${value}`
}
const formatCurrency = (value) => formatCompact(value)
const formatMetricValue = (metric, value) => {
if (metric.key === 'pendingAmount') return formatCurrency(Math.round(value))
if (metric.key === 'avgSla') return `${value.toFixed(1)} ${metric.unit}`
if (metric.unit === '%') return `${Math.round(value)} ${metric.unit}`
if (metric.unit) return `${Math.round(value)} ${metric.unit}`
return `${Math.round(value)}`
}
const kpiMetrics = computed(() => metricBlueprints.map((metric, index) => {
const rawValue = demoTotals[metric.key]
const displayValue = formatMetricValue(metric, rawValue)
return {
...metric,
displayValue,
changeText: metric.change,
delay: index * 55
}
}))
const activeTrend = computed(() => trendSeries[activeTrendRange.value])
const spendTotal = computed(() => spendByCategory.reduce((sum, item) => sum + item.value, 0))
const riskTotal = computed(() => exceptionMix.reduce((sum, item) => sum + item.value, 0))
const spendLegend = computed(() => spendByCategory.map((item) => ({
...item,
display: `${Math.round((item.value / spendTotal.value) * 100)}%`
})))
const riskLegend = computed(() => exceptionMix.map((item) => ({
...item,
display: `${item.value}`
})))
const rankedDepartments = computed(() => {
const rows = demoDepartments
const max = Math.max(...rows.map((item) => item.amount), 1)
return rows.slice(0, 5).map((item, index) => ({
...item,
rank: index + 1,
shortName: item.name,
amountLabel: formatCurrency(item.amount),
width: `${Math.max((item.amount / max) * 100, 18)}%`,
color: item.color
}))
})
return {
activeDepartmentRange,
activeTrend,
activeTrendRange,
bottlenecks,
budgetSummary,
departmentRangeOptions,
exceptionMix,
formatCompact,
formatCurrency,
formatMetricValue,
kpiMetrics,
metricBlueprints,
rankedDepartments,
riskLegend,
riskTotal,
spendByCategory,
spendLegend,
spendTotal,
trendRanges,
trendSeries
}
}

View File

@@ -7,6 +7,7 @@ export const icons = {
approval: iconPath('<path d="M9 11l2 2 4-5"/><path d="M20 12v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h8"/><path d="M17 3h4v4"/>'), approval: iconPath('<path d="M9 11l2 2 4-5"/><path d="M20 12v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h8"/><path d="M17 3h4v4"/>'),
file: iconPath('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h5"/>'), file: iconPath('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h5"/>'),
skill: iconPath('<path d="M12 3 9.5 8.5 3 11l6.5 2.5L12 19l2.5-5.5L21 11l-6.5-2.5z"/><path d="M19 19l.9 2 .9-2 2-.9-2-.9-.9-2-.9 2-2 .9z"/><path d="M5 5l.6 1.4L7 7l-1.4.6L5 9l-.6-1.4L3 7l1.4-.6z"/>'), skill: iconPath('<path d="M12 3 9.5 8.5 3 11l6.5 2.5L12 19l2.5-5.5L21 11l-6.5-2.5z"/><path d="M19 19l.9 2 .9-2 2-.9-2-.9-.9-2-.9 2-2 .9z"/><path d="M5 5l.6 1.4L7 7l-1.4.6L5 9l-.6-1.4L3 7l1.4-.6z"/>'),
users: iconPath('<path d="M16 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2"/><circle cx="9.5" cy="7" r="4"/><path d="M20 8v6"/><path d="M23 11h-6"/>'),
audit: iconPath('<path d="M12 8v4l3 3"/><path d="M3.05 11a9 9 0 1 1 .5 4"/><path d="M3 4v7h7"/>'), audit: iconPath('<path d="M12 8v4l3 3"/><path d="M3.05 11a9 9 0 1 1 .5 4"/><path d="M3 4v7h7"/>'),
search: iconPath('<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>'), search: iconPath('<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>'),
check: iconPath('<path d="M20 6 9 17l-5-5"/>'), check: iconPath('<path d="M20 6 9 17l-5-5"/>'),

209
web/src/scripts/App.js Normal file
View File

@@ -0,0 +1,209 @@
import '../assets/styles/global.css'
import SidebarRail from '../components/layout/SidebarRail.vue'
import TopBar from '../components/layout/TopBar.vue'
import FilterBar from '../components/layout/FilterBar.vue'
import ToastNotification from '../components/shared/ToastNotification.vue'
import LoginView from '../views/LoginView.vue'
import OverviewView from '../views/OverviewView.vue'
import PersonalWorkbenchView from '../views/PersonalWorkbenchView.vue'
import ChatView from '../views/ChatView.vue'
import TravelReimbursementCreateView from '../views/TravelReimbursementCreateView.vue'
import TravelRequestDetailView from '../views/TravelRequestDetailView.vue'
import RequestsView from '../views/RequestsView.vue'
import ApprovalCenterView from '../views/ApprovalCenterView.vue'
import PoliciesView from '../views/PoliciesView.vue'
import AuditView from '../views/AuditView.vue'
import EmployeeManagementView from '../views/EmployeeManagementView.vue'
import { ref, computed } from 'vue'
import { useNavigation, navItems } from '../composables/useNavigation.js'
import { useRequests } from '../composables/useRequests.js'
import { useChat } from '../composables/useChat.js'
import { useToast } from '../composables/useToast.js'
import { documents } from '../data/requests.js'
export default {
name: 'App',
components: {
SidebarRail,
TopBar,
FilterBar,
ToastNotification,
LoginView,
OverviewView,
PersonalWorkbenchView,
ChatView,
TravelReimbursementCreateView,
TravelRequestDetailView,
RequestsView,
ApprovalCenterView,
PoliciesView,
AuditView,
EmployeeManagementView
},
setup() {
const loggedIn = ref(false)
const travelCreateMode = ref(false)
const detailMode = ref(false)
const selectedTravelRequest = ref(null)
const smartEntryOpen = ref(false)
const smartEntryContext = ref({ prompt: '', source: 'requests', request: null })
const smartEntrySessionId = ref(0)
function handleLogin(credentials) {
if (credentials.username && credentials.password) {
loggedIn.value = true
}
}
function handleRecoverPassword() {
toast('请联系系统管理员重置密码。')
}
function handleSsoLogin() {
toast('SSO 登录通道建设中。')
}
const { activeView, currentView, setView } = useNavigation()
const { requests, search, filters, ranges, activeRange, filteredRequests, approveRequest, rejectRequest } = useRequests()
const { messages, draft, uploadedFiles, messageList, activeCase, prompts, sendMessage, handleUpload, openChat, openNewChat } = useChat(activeView)
const { toastText, toast } = useToast()
const docSearch = ref('')
const customRange = ref({ start: '2024-07-06', end: '2024-07-12' })
const travelPrompts = ['帮我提交出差申请', '预订机票', '预订酒店', '预订火车票', '查询差旅政策']
const topBarView = computed(() => {
if (detailMode.value) {
return {
title: '差旅报销详情',
desc: '查看报销单据详情、票据识别与审批进度'
}
}
return currentView.value
})
const filteredDocuments = computed(() => {
const key = docSearch.value.trim().toLowerCase()
return documents.filter((doc) => {
const matchesSearch = !key || `${doc.id}${doc.applicant}${doc.destination}${doc.type}`.toLowerCase().includes(key)
return matchesSearch
})
})
function handleApprove(request) {
const msg = approveRequest(request)
toast(msg)
}
function handleReject(request) {
const msg = rejectRequest(request)
toast(msg)
}
function handleNavigate(view) {
travelCreateMode.value = false
detailMode.value = false
selectedTravelRequest.value = null
smartEntryOpen.value = false
setView(view)
}
function handleOpenChat(request) {
travelCreateMode.value = false
openChat(request)
}
function openTravelCreate() {
smartEntryOpen.value = true
travelCreateMode.value = false
detailMode.value = false
selectedTravelRequest.value = null
smartEntryContext.value = { prompt: '', source: 'topbar', request: null }
smartEntrySessionId.value += 1
}
function openSmartEntry(payload = {}) {
smartEntryOpen.value = true
travelCreateMode.value = false
if (payload.source !== 'detail') {
detailMode.value = false
selectedTravelRequest.value = null
}
smartEntryContext.value = {
prompt: payload.prompt ?? '',
source: payload.source ?? 'workbench',
request: payload.request ?? selectedTravelRequest.value
}
smartEntrySessionId.value += 1
}
function closeSmartEntry() {
smartEntryOpen.value = false
}
function openRequestDetail(request) {
selectedTravelRequest.value = request
detailMode.value = true
activeView.value = 'requests'
}
function closeRequestDetail() {
detailMode.value = false
selectedTravelRequest.value = null
}
return {
loggedIn,
travelCreateMode,
detailMode,
selectedTravelRequest,
smartEntryOpen,
smartEntryContext,
smartEntrySessionId,
handleLogin,
handleRecoverPassword,
handleSsoLogin,
activeView,
currentView,
setView,
requests,
search,
filters,
ranges,
activeRange,
filteredRequests,
approveRequest,
rejectRequest,
messages,
draft,
uploadedFiles,
messageList,
activeCase,
prompts,
sendMessage,
handleUpload,
openChat,
openNewChat,
toastText,
toast,
docSearch,
customRange,
travelPrompts,
topBarView,
filteredDocuments,
handleApprove,
handleReject,
handleNavigate,
handleOpenChat,
openTravelCreate,
openSmartEntry,
closeSmartEntry,
openRequestDetail,
closeRequestDetail,
navItems
}
}
}

View File

@@ -0,0 +1,494 @@
<template>
<section class="approval-page">
<!-- Detail Modal Overlay -->
<Teleport to="body">
<Transition name="detail-modal">
<div v-if="false && selectedRow" class="detail-overlay" @click.self="selectedRow = null">
<div class="detail-modal">
<!-- Modal Header -->
<header class="modal-header">
<div class="header-left">
<div class="req-badge">{{ selectedRow.id }}</div>
<div class="header-title-group">
<h2>{{ selectedRow.type }}审批详情</h2>
<p>申请人{{ selectedRow.applicant }} · {{ selectedRow.department }} · {{ selectedRow.time }}</p>
</div>
</div>
<div class="header-right">
<div class="header-indicator" :class="selectedRow.riskTone">
<i class="mdi" :class="selectedRow.riskTone === 'high' ? 'mdi-alert-circle' : selectedRow.riskTone === 'medium' ? 'mdi-alert' : 'mdi-shield-check'"></i>
<span>{{ selectedRow.risk }}</span>
</div>
<div class="header-indicator status" :class="selectedRow.statusTone">
<span>{{ selectedRow.node }}</span>
</div>
<button class="close-btn" type="button" aria-label="关闭" @click="selectedRow = null">
<i class="mdi mdi-close"></i>
</button>
</div>
</header>
<!-- Progress Bar -->
<div class="modal-progress">
<div class="progress-track">
<div v-for="(step, idx) in approvalSteps" :key="step.label" class="progress-node" :class="{ done: step.done, active: step.active, current: step.current }">
<span class="node-dot">
<i v-if="step.done" class="mdi mdi-check"></i>
<template v-else>{{ step.index }}</template>
</span>
<div class="node-label">
<strong>{{ step.label }}</strong>
<small>{{ step.time }}</small>
</div>
<span v-if="idx < approvalSteps.length - 1" class="node-line" :class="{ filled: step.done || step.active }"></span>
</div>
</div>
</div>
<!-- Modal Body -->
<div class="modal-body">
<div class="body-grid">
<!-- Left Column -->
<div class="body-main">
<!-- 费用摘要 -->
<article class="content-card">
<div class="card-header">
<div class="card-title">
<i class="mdi mdi-clipboard-text-outline"></i>
<h3>费用摘要</h3>
</div>
</div>
<div class="metrics-strip">
<div class="metric-block amount">
<span class="metric-label">报销金额</span>
<strong class="metric-value">{{ selectedRow.amount }}</strong>
</div>
<div class="metric-block">
<span class="metric-label">SLA 剩余</span>
<strong class="metric-value sla" :class="selectedRow.slaTone">
<i class="mdi mdi-clock-outline"></i>
{{ selectedRow.sla }}
</strong>
</div>
<div class="metric-block">
<span class="metric-label">费用明细</span>
<strong class="metric-value">5 </strong>
</div>
<div class="metric-block">
<span class="metric-label">附件材料</span>
<strong class="metric-value">6 </strong>
</div>
</div>
<div class="summary-grid">
<div v-for="item in summaryItems" :key="item.label" class="summary-cell">
<div class="cell-icon"><i :class="item.icon"></i></div>
<div class="cell-content">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</div>
</article>
<!-- 费用明细 -->
<article class="content-card">
<div class="card-header">
<div class="card-title">
<i class="mdi mdi-receipt-text-outline"></i>
<h3>费用明细</h3>
</div>
<span class="card-badge">合计 6,920</span>
</div>
<div class="expense-table-wrap">
<table class="expense-table">
<thead>
<tr>
<th>费用项目</th>
<th>说明</th>
<th class="right">金额</th>
<th class="center">是否超标</th>
</tr>
</thead>
<tbody>
<tr v-for="item in expenseItems" :key="item.name">
<td><strong>{{ item.name }}</strong></td>
<td>{{ item.desc }}</td>
<td class="right">{{ item.amount }}</td>
<td class="center">
<span class="over-badge" :class="item.tone">
<i class="mdi" :class="item.tone === 'ok' ? 'mdi-check-circle' : 'mdi-alert-circle'"></i>
{{ item.status }}
</span>
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="2"><strong>合计</strong></td>
<td class="right"><strong class="total-amount">6,920</strong></td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</article>
<!-- 审批意见 -->
<article class="content-card">
<div class="card-header">
<div class="card-title">
<i class="mdi mdi-comment-text-outline"></i>
<h3>审批意见</h3>
</div>
</div>
<div class="opinion-wrap">
<textarea rows="4" placeholder="请输入审批意见..."></textarea>
</div>
</article>
</div>
<!-- Right Column -->
<aside class="body-side">
<!-- AI 风险识别 -->
<article class="side-card risk-card">
<div class="card-header">
<div class="card-title">
<i class="mdi mdi-robot-outline"></i>
<h3>AI 风险识别</h3>
</div>
<div class="risk-total high">
<span>综合风险</span>
<strong></strong>
</div>
</div>
<div class="risk-items">
<div v-for="risk in riskItems" :key="risk.text" class="risk-row" :class="risk.tone">
<div class="risk-icon">
<i :class="risk.icon"></i>
</div>
<span class="risk-text">{{ risk.text }}</span>
<span class="risk-level" :class="risk.tone">{{ risk.level }}</span>
</div>
</div>
<div class="risk-note">
<strong>AI 审核建议</strong>
<p>优先补齐酒店入住清单并复核出租车发票抬头与超标费用说明完成后可继续流转</p>
</div>
</article>
<!-- 附件材料 -->
<article class="side-card">
<div class="card-header">
<div class="card-title">
<i class="mdi mdi-paperclip"></i>
<h3>附件材料</h3>
</div>
<span class="card-badge warn">1 份缺失</span>
</div>
<div class="attachment-list-side">
<div v-for="file in attachments" :key="file.name" class="attachment-row" :class="{ missing: file.missing }">
<div class="file-icon-sm" :class="file.iconClass">
<i :class="file.icon"></i>
</div>
<div class="file-detail">
<strong>{{ file.name }}</strong>
<span>{{ file.size }}</span>
</div>
</div>
</div>
</article>
</aside>
</div>
</div>
<!-- Modal Footer -->
<footer class="modal-footer">
<div class="footer-left">
<button class="action-btn back" type="button" @click="selectedRow = null">
<i class="mdi mdi-arrow-left"></i>
<span>返回列表</span>
</button>
</div>
<div class="footer-right">
<button class="action-btn supplement" type="button">
<i class="mdi mdi-undo"></i>
<span>补充材料</span>
</button>
<button class="action-btn reject" type="button">
<i class="mdi mdi-close-circle-outline"></i>
<span>驳回</span>
</button>
<button class="action-btn approve" type="button">
<i class="mdi mdi-check-circle-outline"></i>
<span>通过</span>
</button>
</div>
</footer>
</div>
</div>
</Transition>
</Teleport>
<div v-if="selectedRow" class="approval-detail">
<div class="detail-scroll">
<article class="detail-hero panel">
<div class="applicant-card">
<div class="portrait">{{ selectedRow.avatar }}</div>
<div>
<h2>{{ selectedRow.applicant }} <span>{{ selectedRow.department }}</span></h2>
<p>提交时间 <strong>{{ selectedRow.time }}</strong></p>
</div>
</div>
<div class="hero-stat">
<span>金额</span>
<strong>{{ selectedRow.amount }}</strong>
</div>
<div class="hero-stat">
<span>风险等级</span>
<b :class="['risk-pill', selectedRow.riskTone]">{{ selectedRow.risk }}</b>
</div>
<div class="hero-stat">
<span>当前状态</span>
<b class="state-pill">{{ selectedRow.node }}</b>
</div>
<div class="hero-stat">
<span>SLA 剩余时间</span>
<strong class="countdown"><i class="mdi mdi-clock-outline"></i> 剩余 {{ selectedRow.sla }}</strong>
</div>
<div class="hero-summary-panel">
<div v-for="item in heroSummaryItems" :key="item.label" class="hero-summary-item">
<div class="hero-summary-label">
<span class="hero-summary-icon"><i :class="item.icon"></i></span>
<span>{{ item.label }}</span>
</div>
<strong>{{ item.value }}</strong>
</div>
</div>
<div class="progress-block">
<div class="progress-head">
<h3>当前进度</h3>
</div>
<div class="progress-line">
<div v-for="step in approvalSteps" :key="step.label" class="progress-step" :class="{ active: step.active, current: step.current }">
<span>
<i
v-if="step.current"
v-motion
class="current-progress-ring"
:initial="currentProgressRingMotion.initial"
:enter="currentProgressRingMotion.enter"
aria-hidden="true"
></i>
<i v-if="step.done" class="mdi mdi-check"></i>
<template v-else>{{ step.index }}</template>
</span>
<strong>{{ step.label }}</strong>
<small>{{ step.time }}</small>
</div>
</div>
</div>
</article>
<div class="detail-grid">
<section class="detail-left">
<article class="detail-card panel">
<div class="detail-card-head">
<div>
<h3>费用明细</h3>
<p>按发生时间逐笔展示附件与 AI 风险直接在表内完成核对</p>
</div>
<span class="detail-total">{{ expenseTotal }}</span>
</div>
<div class="detail-expense-table">
<table>
<thead>
<tr>
<th>时间</th>
<th>费用项目</th>
<th>说明</th>
<th>金额</th>
<th>附件材料</th>
<th>AI 风险识别</th>
</tr>
</thead>
<tbody>
<template v-for="item in expenseItems" :key="item.id">
<tr>
<td class="expense-time">
<strong>{{ item.time }}</strong>
<span>{{ item.dayLabel }}</span>
</td>
<td class="expense-type">
<strong>{{ item.name }}</strong>
<span>{{ item.category }}</span>
</td>
<td class="expense-desc">
<strong>{{ item.desc }}</strong>
<span>{{ item.detail }}</span>
</td>
<td class="expense-amount">
<strong>{{ item.amount }}</strong>
<span v-if="item.tone !== 'ok'" :class="['over-tag', item.tone]">{{ item.status }}</span>
</td>
<td class="expense-attachment">
<div class="expense-attachment-main">
<span :class="['attachment-pill', item.attachmentTone]">{{ item.attachmentStatus }}</span>
<button
v-if="item.attachments.length"
class="inline-action"
type="button"
@click="toggleExpenseAttachments(item.id)"
>
{{ expandedExpenseId === item.id ? '收起附件' : '查看附件' }}
</button>
</div>
<span class="attachment-hint">{{ item.attachmentHint }}</span>
</td>
<td class="expense-risk">
<template v-if="showExpenseRisk(item)">
<span :class="['risk-inline-tag', item.riskTone]">{{ item.riskLabel }}</span>
<p>{{ item.riskText }}</p>
</template>
</td>
</tr>
<tr v-if="expandedExpenseId === item.id" class="expense-expand-row">
<td colspan="6">
<div class="expense-files">
<span v-for="file in item.attachments" :key="file" class="expense-file-chip">
<i class="mdi mdi-paperclip"></i>
{{ file }}
</span>
</div>
</td>
</tr>
</template>
<tr class="total-row">
<td colspan="3">合计</td>
<td>{{ expenseTotal }}</td>
<td>{{ uploadedExpenseCount }} 项已上传票据</td>
<td>1 项待补材料1 项需补充超标说明</td>
</tr>
</tbody>
</table>
</div>
</article>
<article class="detail-card panel">
<h3>审批意见</h3>
<textarea rows="3" placeholder="输入审批意见..."></textarea>
</article>
</section>
</div>
</div>
<footer class="detail-actions">
<button class="back-action" type="button" @click="selectedRow = null">
<i class="mdi mdi-arrow-left"></i>
<span>退回列表</span>
</button>
<div class="approval-action-group" aria-label="审批操作">
<button class="approve-action" type="button"><i class="mdi mdi-check-circle-outline"></i> 通过</button>
<button class="reject-action" type="button"><i class="mdi mdi-close-circle-outline"></i> 驳回</button>
<button class="supplement-action" type="button"><i class="mdi mdi-undo"></i> 补充</button>
</div>
</footer>
</div>
<!-- Approval List -->
<article v-else class="approval-list panel">
<nav class="status-tabs" aria-label="审批状态">
<button
v-for="tab in tabs"
:key="tab"
type="button"
:class="{ active: activeTab === tab }"
@click="activeTab = tab"
>
{{ tab }}
</button>
</nav>
<div class="list-toolbar">
<div class="filter-set">
<button v-for="filter in filters" :key="filter" type="button" class="filter-btn">
<span>{{ filter }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
</div>
</div>
<p class="hint"><i class="mdi mdi-information-outline"></i> 点击单据行查看审批详情</p>
<div class="table-wrap">
<table>
<colgroup>
<col><col><col><col><col><col><col><col><col><col><col>
</colgroup>
<thead>
<tr>
<th>单号</th>
<th>申请人</th>
<th>申请部门</th>
<th>报销类型</th>
<th>金额</th>
<th>提交时间 <i class="mdi mdi-sort"></i></th>
<th>风险等级</th>
<th>SLA剩余</th>
<th>当前节点</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in visibleRows" :key="row.id" :class="{ spotlight: row.spotlight }" @click="selectedRow = row">
<td><strong class="doc-id">{{ row.id }}</strong></td>
<td>
<span class="person">
<span class="avatar">{{ row.avatar }}</span>
{{ row.applicant }}
</span>
</td>
<td>{{ row.department }}</td>
<td>{{ row.type }}</td>
<td>{{ row.amount }}</td>
<td>{{ row.time }}</td>
<td><span class="risk-tag" :class="row.riskTone">{{ row.risk }}</span></td>
<td><strong class="sla" :class="row.slaTone">{{ row.sla }}</strong></td>
<td>{{ row.node }}</td>
<td><span class="status-tag" :class="row.statusTone">{{ row.status }}</span></td>
<td>
<button class="more-btn" type="button" aria-label="查看审批详情" @click.stop="selectedRow = row">
<i class="mdi mdi-dots-horizontal"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<footer class="list-foot">
<span class="page-summary"> 126 当前第 1 </span>
<div class="pager" aria-label="分页">
<button class="page-nav" type="button" aria-label="上一页"><i class="mdi mdi-chevron-left"></i></button>
<button class="page-number active" type="button" aria-current="page">1</button>
<button class="page-number" type="button">2</button>
<button class="page-number" type="button">3</button>
<button class="page-number" type="button">4</button>
<button class="page-number" type="button">5</button>
<span>...</span>
<button class="page-number" type="button">13</button>
<button class="page-nav" type="button" aria-label="下一页"><i class="mdi mdi-chevron-right"></i></button>
</div>
<button class="page-size" type="button">10 / <i class="mdi mdi-chevron-down"></i></button>
</footer>
</article>
</section>
</template>
<script src="./scripts/ApprovalCenterView.js"></script>
<style scoped src="../assets/styles/views/approval-center-view.css"></style>

286
web/src/views/AuditView.vue Normal file
View File

@@ -0,0 +1,286 @@
<template>
<section class="skill-center">
<Transition name="skill-view" mode="out-in">
<article v-if="selectedSkill" key="detail" class="skill-detail">
<div class="detail-scroll">
<section class="detail-hero panel">
<div class="hero-title">
<div class="skill-badge" :class="selectedSkill.badgeTone">{{ selectedSkill.scope }}</div>
<h2>{{ selectedSkill.name }}</h2>
<p>{{ selectedSkill.summary }}</p>
</div>
<div class="hero-stats">
<div class="hero-stat">
<span>版本</span>
<strong>{{ selectedSkill.version }}</strong>
</div>
<div class="hero-stat">
<span>状态</span>
<b :class="['status-pill', selectedSkill.statusTone]">{{ selectedSkill.status }}</b>
</div>
<div class="hero-stat">
<span>触发命中率</span>
<strong>{{ selectedSkill.hitRate }}</strong>
</div>
<div class="hero-stat">
<span>负责人</span>
<strong>{{ selectedSkill.owner }}</strong>
</div>
</div>
</section>
<div class="detail-grid">
<section class="detail-main">
<article class="detail-card panel">
<div class="card-head">
<div>
<h3>基础配置</h3>
<p>定义 skill 的定位适用场景和默认执行策略</p>
</div>
<button class="mini-btn">保存草稿</button>
</div>
<div class="form-grid">
<label class="field">
<span>技能名称</span>
<input :value="selectedSkill.name" />
</label>
<label class="field">
<span>技能分类</span>
<input :value="selectedSkill.category" />
</label>
<label class="field span-2">
<span>适用描述</span>
<textarea rows="3" :value="selectedSkill.summary"></textarea>
</label>
<label class="field">
<span>触发方式</span>
<input :value="selectedSkill.triggerMode" />
</label>
<label class="field">
<span>默认模型</span>
<input :value="selectedSkill.model" />
</label>
</div>
</article>
<article class="detail-card panel">
<div class="card-head">
<div>
<h3>提示词结构</h3>
<p>按系统约束输入期望输出格式三个层级组织 skill 行为</p>
</div>
<span class="edit-badge">{{ selectedSkill.promptSections.length }} </span>
</div>
<div class="prompt-stack">
<section v-for="section in selectedSkill.promptSections" :key="section.title" class="prompt-block">
<header>
<strong>{{ section.title }}</strong>
<span>{{ section.intent }}</span>
</header>
<textarea rows="5" :value="section.content"></textarea>
</section>
</div>
</article>
<article class="detail-card panel">
<div class="card-head">
<div>
<h3>输出契约与测试样例</h3>
<p>确保 skill 在高频场景下输出稳定格式清晰</p>
</div>
<button class="mini-btn primary">运行测试</button>
</div>
<div class="contract-grid">
<div class="contract-panel">
<h4>输出要求</h4>
<ul>
<li v-for="rule in selectedSkill.outputRules" :key="rule">{{ rule }}</li>
</ul>
</div>
<div class="contract-panel">
<h4>测试样例</h4>
<div v-for="test in selectedSkill.tests" :key="test.name" class="test-row">
<div>
<strong>{{ test.name }}</strong>
<span>{{ test.input }}</span>
</div>
<b :class="['test-state', test.tone]">{{ test.result }}</b>
</div>
</div>
</div>
</article>
</section>
<aside class="detail-side">
<article class="side-card panel">
<div class="card-head">
<div>
<h3>触发规则</h3>
<p>当前命中策略</p>
</div>
</div>
<div class="tag-list">
<span v-for="item in selectedSkill.triggers" :key="item">{{ item }}</span>
</div>
</article>
<article class="side-card panel">
<div class="card-head">
<div>
<h3>工具权限</h3>
<p>可调用能力</p>
</div>
</div>
<div class="tool-list">
<div v-for="tool in selectedSkill.tools" :key="tool.name" class="tool-row">
<div>
<strong>{{ tool.name }}</strong>
<span>{{ tool.scope }}</span>
</div>
<b :class="['tool-state', tool.tone]">{{ tool.mode }}</b>
</div>
</div>
</article>
<article class="side-card panel">
<div class="card-head">
<div>
<h3>版本历史</h3>
<p>最近变更</p>
</div>
</div>
<div class="history-list">
<div v-for="item in selectedSkill.history" :key="item.version" class="history-row">
<strong>{{ item.version }}</strong>
<span>{{ item.note }}</span>
<small>{{ item.time }}</small>
</div>
</div>
</article>
<article class="side-card panel publish-card">
<div>
<h3>发布控制</h3>
<p>当前配置已通过核心检查可进入灰度或正式发布</p>
</div>
<div class="publish-summary">
<span>最近评审2026-05-05 14:20</span>
<strong>可发布</strong>
</div>
</article>
</aside>
</div>
</div>
<footer class="detail-actions">
<button class="back-action" type="button" @click="selectedSkill = null">
<i class="mdi mdi-arrow-left"></i>
<span>返回技能列表</span>
</button>
<div class="detail-action-group">
<button class="minor-action" type="button">
<i class="mdi mdi-content-save-outline"></i>
<span>保存草稿</span>
</button>
<button class="minor-action" type="button">
<i class="mdi mdi-flask-outline"></i>
<span>运行测试</span>
</button>
<button class="major-action" type="button">
<i class="mdi mdi-rocket-launch-outline"></i>
<span>正式上线</span>
</button>
</div>
</footer>
</article>
<article v-else key="list" class="skill-list panel">
<nav class="status-tabs" aria-label="技能状态">
<button
v-for="tab in tabs"
:key="tab"
type="button"
:class="{ active: activeTab === tab }"
@click="activeTab = tab"
>
{{ tab }}
</button>
</nav>
<div class="list-toolbar">
<div class="filter-set">
<button v-for="filter in filters" :key="filter" type="button" class="filter-btn">
<span>{{ filter }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
</div>
<button class="create-btn" type="button">
<i class="mdi mdi-plus"></i>
<span>新建 Skill</span>
</button>
</div>
<p class="hint"><i class="mdi mdi-information-outline"></i> 点击任意技能行进入设计与编辑界面</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>技能名称</th>
<th>分类</th>
<th>负责人</th>
<th>适用范围</th>
<th>模型</th>
<th>版本</th>
<th>状态</th>
<th>命中率</th>
<th>最近更新</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="skill in visibleSkills"
:key="skill.id"
:class="{ spotlight: skill.spotlight }"
@click="selectedSkill = skill"
>
<td>
<div class="skill-name-cell">
<span class="skill-avatar" :class="skill.badgeTone">{{ skill.short }}</span>
<div>
<strong>{{ skill.name }}</strong>
<span>{{ skill.summary }}</span>
</div>
</div>
</td>
<td>{{ skill.category }}</td>
<td>{{ skill.owner }}</td>
<td><span class="scope-pill">{{ skill.scope }}</span></td>
<td>{{ skill.model }}</td>
<td>{{ skill.version }}</td>
<td><span class="status-pill" :class="skill.statusTone">{{ skill.status }}</span></td>
<td>{{ skill.hitRate }}</td>
<td>{{ skill.updatedAt }}</td>
<td>
<button class="row-action" type="button" @click.stop="selectedSkill = skill">
编辑
</button>
</td>
</tr>
</tbody>
</table>
</div>
</article>
</Transition>
</section>
</template>
<script src="./scripts/AuditView.js"></script>
<style scoped src="../assets/styles/views/audit-view.css"></style>

178
web/src/views/ChatView.vue Normal file
View File

@@ -0,0 +1,178 @@
<template>
<section class="qa-view">
<div class="qa-layout">
<aside class="left-column">
<article class="panel side-panel conversation-list">
<header>
<h3>问答会话</h3>
<button class="outline-action" type="button" @click="emit('draft', '')">
<i class="mdi mdi-plus"></i>
<span>新建会话</span>
</button>
</header>
<div class="session-scroll">
<button
v-for="item in sessions"
:key="item.title"
class="session-row"
:class="{ active: item.active }"
type="button"
@click="applyPrompt(item.title)"
>
<span><i class="mdi mdi-message-processing-outline"></i></span>
<strong>{{ item.title }}</strong>
<time>{{ item.time }}</time>
</button>
</div>
</article>
</aside>
<article class="panel chat-panel">
<div ref="localMessageList" class="message-stream" aria-live="polite">
<div class="talk-row user">
<span class="avatar user-avatar"></span>
<div class="talk-content">
<header><strong>张明</strong><time>10:32</time></header>
<p class="user-question">北京出差酒店超标报销怎么处理</p>
</div>
</div>
<div class="talk-row assistant">
<span class="avatar assistant-avatar"><i class="mdi mdi-robot-outline"></i></span>
<div class="talk-content">
<header><strong>财务AI助手</strong><time>10:32</time></header>
<div class="answer-card">
<section>
<h4>结论</h4>
<p>酒店费用超过标准的部分原则上不予报销特殊情况可申请例外报销</p>
</section>
<section>
<h4>处理建议</h4>
<ul>
<li>超标部分由个人自理或按制度退回保留超标说明和相关凭证</li>
<li>符合公司相关政策的可提交佐证材料申请例外报销</li>
</ul>
</section>
<section>
<h4>适用规则</h4>
<ul>
<li>差旅报销管理办法2024第十二条住宿标准及超标处理</li>
<li>费用报销审批流程附件1国内差旅住宿标准</li>
</ul>
</section>
<footer>
<span>是否有帮助</span>
<button type="button" aria-label="有帮助"><i class="mdi mdi-thumb-up-outline"></i></button>
<button type="button" aria-label="无帮助"><i class="mdi mdi-thumb-down-outline"></i></button>
</footer>
</div>
</div>
</div>
<div class="talk-row user">
<span class="avatar user-avatar"></span>
<div class="talk-content">
<header><strong>张明</strong><time>10:35</time></header>
<p class="user-question">如果出差地公司名称不一致还能报销吗</p>
</div>
</div>
<div class="talk-row assistant">
<span class="avatar assistant-avatar"><i class="mdi mdi-robot-outline"></i></span>
<div class="talk-content">
<header><strong>财务AI助手</strong><time>10:35</time></header>
<div class="answer-card compact">
<section>
<h4>结论</h4>
<p>一般情况下差旅地与参会公司名称不一致需按异常处理建议提供情况说明并加盖公章或补充邀请材料</p>
</section>
<section>
<h4>适用规则</h4>
<ul>
<li>发票管理规定及失误销细则第二章发票基本要求</li>
<li>差旅报销管理办法附件1报销凭证要求</li>
</ul>
</section>
</div>
</div>
</div>
<div v-for="message in messages" :key="message.id" class="talk-row" :class="message.role === 'user' ? 'user' : 'assistant'">
<span class="avatar" :class="message.role === 'user' ? 'user-avatar' : 'assistant-avatar'">
<template v-if="message.role === 'user'"></template>
<i v-else class="mdi mdi-robot-outline"></i>
</span>
<div class="talk-content">
<header>
<strong>{{ message.role === 'user' ? '我' : '财务AI助手' }}</strong>
<time>刚刚</time>
</header>
<p :class="message.role === 'user' ? 'user-question' : 'agent-answer'">{{ message.text }}</p>
</div>
</div>
</div>
<div class="composer-wrap">
<div class="prompt-toolbar">
<span>猜你想问</span>
<button v-for="prompt in visiblePrompts" :key="prompt.text" type="button" @click="applyPrompt(prompt.text)">
<i :class="prompt.icon"></i>
{{ prompt.short }}
</button>
<button class="icon-refresh" type="button" aria-label="换一批问题" @click="rotatePrompts">
<i class="mdi mdi-refresh"></i>
</button>
</div>
<div class="composer">
<textarea
:value="draft"
rows="2"
placeholder="请输入你的问题,例如:差旅报销特殊标准是什么?"
@input="emit('draft', $event.target.value)"
@keydown.ctrl.enter.prevent="emit('send')"
></textarea>
<button class="send-button" type="button" aria-label="发送问题" @click="emit('send')">
<i class="mdi mdi-send"></i>
</button>
</div>
</div>
</article>
<aside class="right-column">
<article class="panel info-panel hot-top-panel">
<header>
<h3><i class="mdi mdi-fire"></i> 热门问题 Top10</h3>
<button type="button" @click="rotatePrompts">换一批 <i class="mdi mdi-refresh"></i></button>
</header>
<div class="top-question-list">
<button v-for="(item, index) in hotQuestions" :key="item" type="button" @click="applyPrompt(item)">
<strong>{{ String(index + 1).padStart(2, '0') }}</strong>
<span>{{ item }}</span>
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
</article>
<article class="panel info-panel similar-panel">
<header>
<h3>相似历史问题</h3>
<button type="button">查看全部 <i class="mdi mdi-chevron-right"></i></button>
</header>
<div class="similar-scroll">
<button v-for="item in similarQuestions" :key="item.text" class="similar-row" type="button" @click="applyPrompt(item.text)">
<span><i class="mdi mdi-file-question-outline"></i>{{ item.text }}</span>
<strong>{{ item.score }}</strong>
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
</article>
</aside>
</div>
</section>
</template>
<script src="./scripts/ChatView.js"></script>
<style scoped src="../assets/styles/views/chat-view.css"></style>

View File

@@ -0,0 +1,308 @@
<template>
<section class="employee-center">
<Transition name="employee-view" mode="out-in">
<article v-if="selectedEmployee" key="detail" class="employee-detail">
<div class="detail-scroll">
<section class="detail-hero panel">
<div class="hero-profile">
<div class="hero-avatar">{{ selectedEmployee.avatar }}</div>
<div class="hero-copy">
<div class="hero-tag">{{ selectedEmployee.employeeNo }}</div>
<h2>{{ selectedEmployee.name }}</h2>
<p>{{ selectedEmployee.department }} / {{ selectedEmployee.position }} / {{ selectedEmployee.grade }}</p>
</div>
</div>
<div class="hero-stats">
<div class="hero-stat">
<span>账号状态</span>
<strong>{{ selectedEmployee.status }}</strong>
</div>
<div class="hero-stat">
<span>直属上级</span>
<strong>{{ selectedEmployee.manager }}</strong>
</div>
<div class="hero-stat">
<span>财务归口</span>
<strong>{{ selectedEmployee.financeOwner }}</strong>
</div>
<div class="hero-stat">
<span>角色数量</span>
<strong>{{ selectedEmployee.roles.length }}</strong>
</div>
</div>
</section>
<div class="detail-grid">
<section class="detail-main">
<article class="detail-card panel">
<div class="card-head">
<div>
<h3>基础信息</h3>
<p>维护员工编号联系方式入职日期与常用档案信息</p>
</div>
</div>
<div class="form-grid">
<label class="field">
<span>员工姓名</span>
<input :value="selectedEmployee.name" />
</label>
<label class="field">
<span>员工编号</span>
<input :value="selectedEmployee.employeeNo" />
</label>
<label class="field">
<span>性别</span>
<input :value="selectedEmployee.gender" />
</label>
<label class="field">
<span>年龄</span>
<input :value="selectedEmployee.age" />
</label>
<label class="field">
<span>出生日期</span>
<input :value="selectedEmployee.birthDate" />
</label>
<label class="field">
<span>手机号</span>
<input :value="selectedEmployee.phone" />
</label>
<label class="field">
<span>邮箱</span>
<input :value="selectedEmployee.email" />
</label>
<label class="field">
<span>入职日期</span>
<input :value="selectedEmployee.joinDate" />
</label>
<label class="field">
<span>办公地点</span>
<input :value="selectedEmployee.location" />
</label>
</div>
</article>
<article class="detail-card panel">
<div class="card-head">
<div>
<h3>组织与岗位</h3>
<p>配置部门岗位职级和管理归属决定审批链路和数据访问边界</p>
</div>
</div>
<div class="form-grid">
<label class="field">
<span>所属部门</span>
<input :value="selectedEmployee.department" />
</label>
<label class="field">
<span>岗位</span>
<input :value="selectedEmployee.position" />
</label>
<label class="field">
<span>职级</span>
<input :value="selectedEmployee.grade" />
</label>
<label class="field">
<span>直属上级</span>
<input :value="selectedEmployee.manager" />
</label>
<label class="field">
<span>财务归口</span>
<input :value="selectedEmployee.financeOwner" />
</label>
<label class="field">
<span>成本中心</span>
<input :value="selectedEmployee.costCenter" />
</label>
</div>
</article>
<article class="detail-card panel">
<div class="card-head">
<div>
<h3>系统角色分配</h3>
<p>为员工分配管理员财务人员使用者高级管理人员等业务角色</p>
</div>
<span class="count-badge">{{ selectedEmployee.roles.length }} 个角色</span>
</div>
<div class="role-grid">
<label
v-for="role in roleOptions"
:key="role.id"
class="role-card"
:class="{ active: selectedEmployee.roles.includes(role.label) }"
>
<input type="checkbox" :checked="selectedEmployee.roles.includes(role.label)" />
<div class="role-copy">
<strong>{{ role.label }}</strong>
<p>{{ role.desc }}</p>
</div>
</label>
</div>
</article>
</section>
<aside class="detail-side">
<article class="side-card panel">
<div class="card-head">
<div>
<h3>当前权限摘要</h3>
<p>角色组合带来的系统可见范围</p>
</div>
</div>
<div class="tag-list">
<span v-for="role in selectedEmployee.roles" :key="role">{{ role }}</span>
</div>
<ul class="bullet-list">
<li v-for="item in selectedEmployee.permissions" :key="item">{{ item }}</li>
</ul>
</article>
<article class="side-card panel">
<div class="card-head">
<div>
<h3>最近变更</h3>
<p>查看角色与档案调整记录</p>
</div>
</div>
<div class="history-list">
<div v-for="item in selectedEmployee.history" :key="item.time" class="history-row">
<strong>{{ item.action }}</strong>
<span>{{ item.owner }}</span>
<small>{{ item.time }}</small>
</div>
</div>
</article>
<article class="side-card panel publish-card">
<div>
<h3>生效状态</h3>
<p>当前修改将在保存后同步到审批报销知识和权限模块</p>
</div>
<div class="publish-summary">
<span>上次同步{{ selectedEmployee.lastSync }}</span>
<strong>{{ selectedEmployee.syncState }}</strong>
</div>
</article>
</aside>
</div>
</div>
<footer class="detail-actions">
<button class="back-action" type="button" @click="selectedEmployee = null">
<i class="mdi mdi-arrow-left"></i>
<span>返回员工列表</span>
</button>
<div class="detail-action-group">
<button class="minor-action" type="button">
<i class="mdi mdi-content-save-outline"></i>
<span>保存草稿</span>
</button>
<button class="minor-action" type="button">
<i class="mdi mdi-account-cancel-outline"></i>
<span>停用账号</span>
</button>
<button class="major-action" type="button">
<i class="mdi mdi-check-circle-outline"></i>
<span>保存并生效</span>
</button>
</div>
</footer>
</article>
<article v-else key="list" class="employee-list panel">
<nav class="status-tabs" aria-label="员工状态筛选">
<button
v-for="tab in tabs"
:key="tab"
type="button"
:class="{ active: activeTab === tab }"
@click="activeTab = tab"
>
{{ tab }}
</button>
</nav>
<div class="list-toolbar">
<div class="filter-set">
<div class="list-search">
<i class="mdi mdi-magnify"></i>
<input type="search" placeholder="搜索员工姓名、工号、部门或岗位..." />
</div>
<button v-for="filter in filters" :key="filter" type="button" class="filter-btn">
<span>{{ filter }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
</div>
<button class="create-btn" type="button">
<i class="mdi mdi-plus"></i>
<span>新增员工</span>
</button>
</div>
<p class="hint"><i class="mdi mdi-information-outline"></i> 点击任意员工行可进入基础信息与角色权限编辑界面</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>员工</th>
<th>工号</th>
<th>部门</th>
<th>岗位</th>
<th>职级</th>
<th>直属上级</th>
<th>财务归口</th>
<th>系统角色</th>
<th>状态</th>
<th>最近更新</th>
</tr>
</thead>
<tbody>
<tr
v-for="employee in visibleEmployees"
:key="employee.id"
:class="{ spotlight: employee.spotlight }"
@click="selectedEmployee = employee"
>
<td>
<div class="employee-cell">
<span class="employee-avatar">{{ employee.avatar }}</span>
<div>
<strong>{{ employee.name }}</strong>
<span>{{ employee.email }}</span>
</div>
</div>
</td>
<td>{{ employee.employeeNo }}</td>
<td>{{ employee.department }}</td>
<td>{{ employee.position }}</td>
<td><span class="level-pill">{{ employee.grade }}</span></td>
<td>{{ employee.manager }}</td>
<td>{{ employee.financeOwner }}</td>
<td>
<div class="role-stack">
<span v-for="role in employee.roles.slice(0, 2)" :key="role" class="role-pill">{{ role }}</span>
<span v-if="employee.roles.length > 2" class="more-pill">+{{ employee.roles.length - 2 }}</span>
</div>
</td>
<td><span class="status-pill" :class="employee.statusTone">{{ employee.status }}</span></td>
<td>{{ employee.updatedAt }}</td>
</tr>
</tbody>
</table>
</div>
</article>
</Transition>
</section>
</template>
<script src="./scripts/EmployeeManagementView.js"></script>
<style scoped src="../assets/styles/views/employee-management-view.css"></style>

154
web/src/views/LoginView.vue Normal file
View File

@@ -0,0 +1,154 @@
<template>
<main class="login-page">
<header class="page-brand">
<LogoMark />
<strong>星海科技</strong>
</header>
<section class="hero">
<p class="eyebrow-text">Smart Expense Operations</p>
<h1>企业报销智能运营台</h1>
<p class="hero-lead">让报销审批更智能更高效</p>
<p class="hero-sub">智能审单 · 自动化审批 · 风险预警 · SLA 监控 · 数据驱动决策</p>
<div class="hero-stage" aria-hidden="true">
<span class="flow-line flow-a"></span>
<span class="flow-line flow-b"></span>
<span class="flow-line flow-c"></span>
<div class="metric-card amount">
<span>报销金额趋势</span>
<strong>361,600</strong>
<small>较昨日 <b class="up"> 8.3%</b></small>
<div class="mini-bars"><i></i><i></i><i></i><i></i></div>
</div>
<div class="document-card">
<span>报销单</span>
<i></i><i></i><i></i>
<b class="doc-check"><i class="mdi mdi-check"></i></b>
</div>
<img class="shield-art" src="../assets/security-shield.png" alt="" />
<div class="round-badge ai">AI</div>
<div class="metric-card risk">
<span>风险预警</span>
<strong><i class="mdi mdi-alert"></i> 14 </strong>
<small>较昨日 <b class="danger"> 16.7%</b></small>
</div>
<div class="metric-card audit">
<span>审批效率</span>
<strong>78%</strong>
<small>较昨日 <b class="up"> 6.2%</b></small>
</div>
<div class="metric-card sla">
<span>SLA 达成率</span>
<strong>96%</strong>
<small>较昨日 <b class="up"> 3.1%</b></small>
</div>
</div>
<div class="feature-strip" aria-label="核心能力">
<article v-for="item in features" :key="item.title">
<span :class="item.tone"><i :class="item.icon"></i></span>
<div>
<strong>{{ item.title }}</strong>
<p>{{ item.desc }}</p>
</div>
</article>
</div>
</section>
<section class="login-card" aria-label="登录表单">
<div class="card-brand">
<LogoMark />
<strong>星海科技</strong>
</div>
<header class="card-head">
<h2>欢迎登录</h2>
<p>登录企业报销智能运营台</p>
</header>
<form class="login-form" @submit.prevent="emit('login', { username, password })">
<label class="field">
<span class="sr-only">账号</span>
<i class="mdi mdi-account-outline"></i>
<input v-model="username" type="text" placeholder="请输入账号 / 邮箱 / 手机号" autocomplete="username" required />
</label>
<label class="field">
<span class="sr-only">密码</span>
<i class="mdi mdi-lock-outline"></i>
<input
v-model="password"
:type="showPassword ? 'text' : 'password'"
placeholder="请输入密码"
autocomplete="current-password"
required
/>
<button
class="field-icon-btn"
type="button"
:aria-label="showPassword ? '隐藏密码' : '显示密码'"
@click="showPassword = !showPassword"
>
<i :class="showPassword ? 'mdi mdi-eye' : 'mdi mdi-eye-slash'"></i>
</button>
</label>
<label class="field">
<span class="sr-only">企业或租户</span>
<i class="mdi mdi-office-building"></i>
<input v-model="tenant" type="text" placeholder="请输入企业 / 租户(选填)" />
<button class="field-icon-btn" type="button" aria-label="展开企业列表">
<i class="mdi mdi-chevron-down"></i>
</button>
</label>
<div class="form-meta">
<label class="remember">
<input v-model="remember" type="checkbox" />
<span>记住我</span>
</label>
<button type="button" class="link-btn" @click="emit('recover-password')">忘记密码?</button>
</div>
<button class="submit-btn" type="submit">登录</button>
<div class="divider"><span></span></div>
<button class="sso-btn" type="button" @click="emit('sso-login')">
<i class="mdi mdi-shield-outline"></i>
<span>SSO 单点登录</span>
</button>
</form>
<footer class="security-note">
<i class="mdi mdi-lock-outline"></i>
<span>安全登录 · 数据加密传输 · 如需帮助请联系管理员</span>
</footer>
</section>
</main>
</template>
<script setup>
import { useLoginView } from '../composables/useLoginView.js'
const emit = defineEmits(['login', 'recover-password', 'sso-login'])
const {
features,
LogoMark,
password,
remember,
showPassword,
tenant,
username
} = useLoginView()
</script>
<style scoped src="../assets/styles/views/login-view.css"></style>

View File

@@ -0,0 +1,149 @@
<template>
<section class="dashboard">
<div class="kpi-grid">
<article
v-for="metric in kpiMetrics"
:key="metric.label"
class="kpi-card panel"
:style="{ '--accent': metric.accent, '--delay': `${metric.delay}ms` }"
>
<div class="kpi-head">
<span class="kpi-icon"><i :class="metric.icon"></i></span>
<span class="kpi-label">{{ metric.label }}</span>
</div>
<strong class="kpi-value">{{ metric.displayValue }}</strong>
<div class="kpi-trend">
<span class="kpi-badge" :class="metric.trend">
<i :class="metric.trend === 'down' ? 'mdi mdi-arrow-down' : 'mdi mdi-arrow-up'"></i>
{{ metric.changeText }}
</span>
<span class="kpi-delta">{{ metric.delta }}</span>
</div>
</article>
</div>
<div class="content-grid top-grid">
<article class="panel dashboard-card trend-panel">
<div class="card-head">
<h3>报销申请与审批趋势 <i class="mdi mdi-information-outline"></i></h3>
<select v-model="activeTrendRange" class="card-select" aria-label="趋势时间范围">
<option v-for="range in trendRanges" :key="range">{{ range }}</option>
</select>
</div>
<TrendChart
:labels="activeTrend.labels"
:applications="activeTrend.applications"
:approved="activeTrend.approved"
:avg-hours="activeTrend.avgHours"
/>
</article>
<article class="panel dashboard-card donut-panel">
<div class="card-head">
<h3>费用结构 <i class="mdi mdi-information-outline"></i></h3>
</div>
<DonutChart :items="spendLegend" center-value="¥361.6K" center-label="待处理金额" />
<p class="panel-note">* 百分比为占待处理金额比例</p>
</article>
<article class="panel dashboard-card donut-panel">
<div class="card-head">
<h3>风险异常分布 <i class="mdi mdi-information-outline"></i></h3>
</div>
<DonutChart :items="riskLegend" :center-value="`${riskTotal}`" center-label="异常预警单" />
<p class="panel-note">* 近30天数据</p>
</article>
</div>
<div class="content-grid bottom-grid">
<article class="panel dashboard-card rank-panel">
<div class="card-head">
<h3>部门报销排行待处理金额 <i class="mdi mdi-information-outline"></i></h3>
<select v-model="activeDepartmentRange" class="card-select" aria-label="部门排行时间范围">
<option v-for="range in departmentRangeOptions" :key="range">{{ range }}</option>
</select>
</div>
<BarChart :items="rankedDepartments" />
</article>
<article class="panel dashboard-card bottleneck-panel">
<div class="card-head">
<h3>审批瓶颈平均处理时长 <i class="mdi mdi-information-outline"></i></h3>
</div>
<div class="bottleneck-list">
<div
v-for="(item, index) in bottlenecks"
:key="item.name"
class="bottleneck-row"
:style="{ '--delay': `${index * 70}ms` }"
>
<div class="reviewer">
<div class="reviewer-avatar">{{ item.avatar }}</div>
<div>
<strong>{{ item.name }}</strong>
<span>{{ item.role }}</span>
</div>
</div>
<div class="reviewer-stats">
<strong>{{ item.duration }}</strong>
<span class="status-tag" :class="item.tone">{{ item.status }}</span>
</div>
</div>
</div>
<button type="button" class="text-link">查看全部 <i class="mdi mdi-chevron-right"></i></button>
</article>
<article class="panel dashboard-card budget-panel">
<div class="card-head">
<h3>预算执行率本月 <i class="mdi mdi-information-outline"></i></h3>
</div>
<GaugeChart
:ratio="budgetSummary.ratio"
:total="budgetSummary.total"
:used="budgetSummary.used"
:left="budgetSummary.left"
/>
<button type="button" class="text-link">查看详情 <i class="mdi mdi-chevron-right"></i></button>
</article>
</div>
</section>
</template>
<script setup>
import TrendChart from '../components/charts/TrendChart.vue'
import DonutChart from '../components/charts/DonutChart.vue'
import BarChart from '../components/charts/BarChart.vue'
import GaugeChart from '../components/charts/GaugeChart.vue'
import { useOverviewView } from '../composables/useOverviewView.js'
defineProps({
filteredRequests: { type: Array, required: true }
})
const emit = defineEmits(['ask'])
const {
activeDepartmentRange,
activeTrend,
activeTrendRange,
bottlenecks,
budgetSummary,
departmentRangeOptions,
kpiMetrics,
rankedDepartments,
riskLegend,
riskTotal,
spendLegend,
trendRanges
} = useOverviewView()
</script>
<style scoped src="../assets/styles/views/overview-view.css"></style>

View File

@@ -0,0 +1,196 @@
<template>
<section class="knowledge-page">
<div class="knowledge-grid" :class="{ 'has-preview': selectedDocument }">
<section class="knowledge-main">
<article class="library-panel panel">
<header class="panel-title">
<div>
<h2>文档库 / 文件夹</h2>
<p>默认展示文件列表点击具体文件后可在右侧展开预览</p>
</div>
<span class="preview-hint" :class="{ active: selectedDocument }">
{{ selectedDocument ? '预览已展开' : '点击文件可预览' }}
</span>
</header>
<div class="library-body">
<aside class="folder-rail">
<label class="folder-search">
<i class="mdi mdi-magnify"></i>
<input v-model="folderSearch" type="search" placeholder="搜索文件夹" />
<button type="button" aria-label="新增文件夹"><i class="mdi mdi-plus"></i></button>
</label>
<nav class="folder-tree" aria-label="知识库文件夹">
<button
v-for="folder in filteredFolders"
:key="folder.name"
type="button"
:class="{ active: activeFolder === folder.name }"
@click="activeFolder = folder.name"
>
<i :class="folder.icon"></i>
<span>{{ folder.name }}</span>
<b>{{ folder.count }}</b>
</button>
</nav>
<button class="new-folder-btn" type="button">
<i class="mdi mdi-plus"></i>
<span>新建文件夹</span>
</button>
</aside>
<section class="document-area">
<div class="upload-zone">
<i class="mdi mdi-cloud-upload"></i>
<strong>拖拽文档到此处或点击上传</strong>
<span>支持 PDF / Word / Excel / PPT 文档单个文件不超过 100MB</span>
</div>
<div class="doc-table-wrap">
<table>
<thead>
<tr>
<th>文件名称</th>
<th>标签</th>
<th>上传时间 <i class="mdi mdi-arrow-down"></i></th>
<th>版本</th>
<th>状态</th>
<th>上传人</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="doc in filteredDocuments"
:key="doc.name"
class="doc-row"
:class="{ selected: selectedDocument?.name === doc.name }"
@click="selectedDocument = doc"
>
<td>
<span class="file-name">
<i :class="doc.icon"></i>
{{ doc.name }}
</span>
</td>
<td>
<span class="doc-tag">{{ doc.tag }}</span>
</td>
<td>{{ doc.time }}</td>
<td>{{ doc.version }}</td>
<td><span class="state-tag" :class="doc.stateTone">{{ doc.state }}</span></td>
<td>{{ doc.owner }}</td>
<td>
<button class="more-btn" type="button" aria-label="更多操作" @click.stop>
<i class="mdi mdi-dots-horizontal"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<footer class="list-foot">
<span> {{ filteredDocuments.length }} </span>
<button type="button">10/ <i class="mdi mdi-chevron-down"></i></button>
<div class="pager" aria-label="分页">
<button type="button" aria-label="上一页"><i class="mdi mdi-chevron-left"></i></button>
<button class="active" type="button" aria-current="page">1</button>
<button type="button">2</button>
<button type="button" aria-label="下一页"><i class="mdi mdi-chevron-right"></i></button>
</div>
<label>前往 <input value="1" aria-label="页码" /> </label>
</footer>
</section>
</div>
</article>
</section>
<Transition name="preview-panel">
<aside v-if="selectedDocument" class="preview-column">
<article class="preview-panel panel">
<header class="preview-head">
<div>
<span class="preview-kicker">文件预览</span>
<h2>{{ selectedDocument.name }}</h2>
<p>{{ selectedDocument.summary }}</p>
</div>
<div class="preview-actions">
<button type="button" class="mini-action">
<i class="mdi mdi-download"></i>
<span>下载</span>
</button>
<button type="button" class="icon-action" aria-label="关闭预览" @click="selectedDocument = null">
<i class="mdi mdi-close"></i>
</button>
</div>
</header>
<div class="preview-meta">
<span><i class="mdi mdi-tag-outline"></i>{{ selectedDocument.tag }}</span>
<span><i class="mdi mdi-history"></i>{{ selectedDocument.time }}</span>
<span><i class="mdi mdi-account-circle-outline"></i>{{ selectedDocument.owner }}</span>
<span><i class="mdi mdi-source-branch"></i>{{ selectedDocument.version }}</span>
</div>
<div class="preview-viewer">
<div class="viewer-toolbar">
<div class="viewer-filetype" :class="selectedDocument.fileType">
<i :class="selectedDocument.icon"></i>
<span>{{ selectedDocument.fileTypeLabel }}</span>
</div>
<div class="viewer-toolbar-actions">
<button type="button"><i class="mdi mdi-magnify-minus-outline"></i></button>
<button type="button"><i class="mdi mdi-magnify-plus-outline"></i></button>
<button type="button"><i class="mdi mdi-fit-to-page-outline"></i></button>
</div>
</div>
<div class="page-stage">
<article
v-for="(page, index) in selectedDocument.previewPages"
:key="`${selectedDocument.name}-${index}`"
class="page-sheet"
:style="{ '--page-delay': `${index * 70}ms` }"
>
<header class="page-title">
<div>
<strong>{{ page.title }}</strong>
<span>{{ page.subtitle }}</span>
</div>
<b> {{ index + 1 }} </b>
</header>
<section class="page-summary">
<div class="summary-grid">
<div v-for="item in page.stats" :key="item.label" class="summary-item">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</section>
<section class="page-content">
<div v-for="block in page.blocks" :key="block.heading" class="content-block">
<h3>{{ block.heading }}</h3>
<ul>
<li v-for="line in block.lines" :key="line">{{ line }}</li>
</ul>
</div>
</section>
</article>
</div>
</div>
</article>
</aside>
</Transition>
</div>
</section>
</template>
<script src="./scripts/PoliciesView.js"></script>
<style scoped src="../assets/styles/views/policies-view.css"></style>

View File

@@ -0,0 +1,127 @@
<template>
<section class="travel-page">
<article class="travel-list panel">
<nav class="status-tabs" aria-label="差旅报销状态">
<button
v-for="tab in tabs"
:key="tab"
type="button"
:class="{ active: activeTab === tab }"
@click="activeTab = tab"
>
{{ tab }}
</button>
</nav>
<div class="list-toolbar">
<div class="filter-set">
<div class="list-search">
<i class="mdi mdi-magnify"></i>
<input type="search" placeholder="搜索申请人、单号、费用类型..." />
</div>
<div class="date-range-filter" :class="{ open: datePopover }">
<button class="filter-btn date-range-trigger" type="button" @click="datePopover = !datePopover">
<span class="date-range-label">{{ dateRangeLabel }}</span>
<i class="mdi mdi-calendar"></i>
</button>
<div v-if="datePopover" class="date-range-popover" role="dialog" aria-label="选择时间段">
<header>
<strong>选择时间段</strong>
<button type="button" aria-label="关闭" @click="datePopover = false"><i class="mdi mdi-close"></i></button>
</header>
<div class="date-range-fields">
<label>
<span>开始日期</span>
<input v-model="rangeStart" type="date" />
</label>
<label>
<span>结束日期</span>
<input v-model="rangeEnd" type="date" />
</label>
</div>
<footer>
<button class="ghost-btn" type="button" @click="datePopover = false">取消</button>
<button class="apply-btn" type="button" :disabled="!rangeStart || !rangeEnd" @click="applyDateRange">应用</button>
</footer>
</div>
</div>
<button v-for="filter in filters" :key="filter" type="button" class="filter-btn">
<span>{{ filter }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
</div>
<button class="create-request-btn" type="button" @click="emit('create-request')">
<i class="mdi mdi-plus-circle-outline"></i>
<span>发起报销</span>
</button>
</div>
<p class="hint"><i class="mdi mdi-information-outline"></i> 点击任意行可查看单据详情</p>
<div class="table-wrap">
<table>
<colgroup>
<col class="col-id">
<col class="col-reason">
<col class="col-city">
<col class="col-period">
<col class="col-apply">
<col class="col-amount">
<col class="col-node">
<col class="col-approval">
<col class="col-travel">
</colgroup>
<thead>
<tr>
<th>单号</th>
<th>出差事由</th>
<th>出差城市</th>
<th>出差时间</th>
<th>申请时间</th>
<th>申请金额</th>
<th>当前节点</th>
<th>审批状态</th>
<th>商旅状态</th>
</tr>
</thead>
<tbody>
<tr v-for="row in visibleRows" :key="row.id" @click="emit('ask', row)">
<td><strong class="doc-id">{{ row.id }}</strong></td>
<td>{{ row.reason }}</td>
<td>{{ row.city }}</td>
<td>{{ row.period }}</td>
<td>{{ row.applyTime }}</td>
<td>{{ row.amount }}</td>
<td>{{ row.node }}</td>
<td><span class="status-tag" :class="row.approvalTone">{{ row.approval }}</span></td>
<td><span class="status-tag" :class="row.travelTone">{{ row.travel }}</span></td>
</tr>
</tbody>
</table>
</div>
<footer class="list-foot">
<span class="page-summary"> {{ totalCount }} 目前第 {{ currentPage }} </span>
<div class="pager" aria-label="分页">
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--"><i class="mdi mdi-chevron-left"></i></button>
<button v-for="p in totalPages" :key="p" class="page-number" :class="{ active: currentPage === p }" type="button" :aria-current="currentPage === p ? 'page' : undefined" @click="currentPage = p">{{ p }}</button>
<button class="page-nav" type="button" :disabled="currentPage === totalPages" aria-label="下一页" @click="currentPage++"><i class="mdi mdi-chevron-right"></i></button>
</div>
<div class="page-size-wrap">
<button class="page-size" type="button" @click="pageSizeOpen = !pageSizeOpen">
{{ pageSize }} / <i class="mdi mdi-chevron-down"></i>
</button>
<div v-if="pageSizeOpen" class="page-size-dropdown" role="listbox">
<button v-for="size in pageSizes" :key="size" type="button" role="option" :aria-selected="pageSize === size" :class="{ active: pageSize === size }" @click="changePageSize(size)">{{ size }} /</button>
</div>
</div>
</footer>
</article>
</section>
</template>
<script src="./scripts/RequestsView.js"></script>
<style scoped src="../assets/styles/views/requests-view.css"></style>

View File

@@ -0,0 +1,338 @@
<template>
<Teleport to="body">
<Transition name="assistant-modal">
<div class="assistant-overlay" @click.self="emit('close')">
<section class="assistant-modal">
<header class="assistant-header">
<div class="assistant-header-main">
<span class="assistant-badge">AI Workspace</span>
<div>
<h2>统一对话工作台</h2>
<p>个人工作台发起报销智能录入统一走这里右侧会根据你的意图实时切换状态视图</p>
</div>
</div>
<div class="assistant-header-actions">
<span class="source-pill">{{ sourceLabel }}</span>
<button class="close-btn" type="button" aria-label="关闭对话工作台" @click="emit('close')">
<i class="mdi mdi-close"></i>
</button>
</div>
</header>
<div class="assistant-layout" :class="{ 'has-insight': showInsightPanel }">
<section class="dialog-panel">
<div class="dialog-toolbar">
<button
v-for="shortcut in shortcuts"
:key="shortcut.label"
type="button"
class="shortcut-chip"
@click="runShortcut(shortcut.prompt)"
>
<i :class="shortcut.icon"></i>
<span>{{ shortcut.label }}</span>
</button>
</div>
<div ref="messageListRef" class="message-list" aria-live="polite">
<article
v-for="message in messages"
:key="message.id"
class="message-row"
:class="message.role"
>
<span class="message-avatar">
<i :class="message.role === 'assistant' ? 'mdi mdi-robot-excited-outline' : 'mdi mdi-account-circle-outline'"></i>
</span>
<div class="message-bubble">
<header class="message-meta">
<strong>{{ message.role === 'assistant' ? 'AI 助手' : '我' }}</strong>
<time>{{ message.time }}</time>
</header>
<p>{{ message.text }}</p>
<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>
</div>
<form class="composer" @submit.prevent="submitComposer">
<input
ref="fileInputRef"
class="hidden-file-input"
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png,.webp,.doc,.docx,.xls,.xlsx"
@change="handleFilesChange"
/>
<div class="composer-shell">
<textarea
v-model="composerDraft"
rows="3"
:placeholder="composerPlaceholder"
@keydown.ctrl.enter.prevent="submitComposer"
/>
<div v-if="attachedFiles.length" class="composer-files">
<span v-for="file in attachedFiles" :key="file.name" class="file-chip active">
<i class="mdi mdi-paperclip"></i>
{{ file.name }}
</span>
</div>
<div class="composer-foot">
<div class="composer-tools">
<button type="button" class="tool-btn" aria-label="上传附件" @click="triggerFileUpload">
<i class="mdi mdi-paperclip"></i>
</button>
<span class="composer-tip">Ctrl + Enter 发送</span>
</div>
<button class="send-btn" type="submit" :disabled="!canSubmit" aria-label="发送">
<i class="mdi mdi-send"></i>
</button>
</div>
</div>
</form>
</section>
<Transition name="insight-panel">
<aside v-if="showInsightPanel" class="insight-panel">
<div class="insight-head">
<div>
<span class="intent-pill" :class="currentInsight.intent">{{ currentIntentLabel }}</span>
<h3>{{ currentInsight.title }}</h3>
<p>{{ currentInsight.summary }}</p>
</div>
<div class="confidence-card">
<span>意图识别</span>
<strong>{{ currentInsight.confidence }}%</strong>
</div>
</div>
<Transition name="insight-switch" mode="out-in">
<div :key="currentInsight.intent + currentInsight.title" class="insight-body">
<template v-if="currentInsight.intent === 'approval'">
<section class="insight-card primary">
<div class="card-head">
<h4>审批状态</h4>
<span class="status-pill warning">{{ currentInsight.status.currentStatus }}</span>
</div>
<div class="metric-grid">
<div class="metric-item">
<span>单号</span>
<strong>{{ currentInsight.status.requestId }}</strong>
</div>
<div class="metric-item">
<span>当前节点</span>
<strong>{{ currentInsight.status.currentNode }}</strong>
</div>
<div class="metric-item">
<span>下一处理人</span>
<strong>{{ currentInsight.status.nextOwner }}</strong>
</div>
<div class="metric-item">
<span>预计完成</span>
<strong>{{ currentInsight.status.eta }}</strong>
</div>
</div>
</section>
<section class="insight-card">
<div class="card-head">
<h4>流程节点</h4>
</div>
<ol class="timeline-list">
<li
v-for="step in currentInsight.status.timeline"
:key="step.label"
:class="step.state"
>
<span class="timeline-dot"></span>
<div>
<strong>{{ step.label }}</strong>
<p>{{ step.time }}</p>
</div>
</li>
</ol>
</section>
<section class="insight-card">
<div class="card-head">
<h4>待处理提醒</h4>
</div>
<ul class="bullet-list">
<li v-for="item in currentInsight.status.actions" :key="item">{{ item }}</li>
</ul>
</section>
</template>
<template v-else-if="currentInsight.intent === 'recognition'">
<section class="insight-card primary">
<div class="card-head">
<h4>识别结果</h4>
<span class="status-pill success">{{ currentInsight.recognition.state }}</span>
</div>
<div class="metric-grid">
<div class="metric-item">
<span>关联单号</span>
<strong>{{ currentInsight.recognition.requestId }}</strong>
</div>
<div class="metric-item">
<span>识别附件</span>
<strong>{{ currentInsight.recognition.fileCount }} </strong>
</div>
<div class="metric-item">
<span>建议金额</span>
<strong>{{ currentInsight.recognition.amount }}</strong>
</div>
<div class="metric-item">
<span>完整度</span>
<strong>{{ currentInsight.recognition.completeness }}</strong>
</div>
</div>
</section>
<section class="insight-card">
<div class="card-head">
<h4>票据明细</h4>
</div>
<div class="receipt-list">
<article v-for="item in currentInsight.recognition.receipts" :key="item.name" class="receipt-row">
<div>
<strong>{{ item.name }}</strong>
<p>{{ item.type }}</p>
</div>
<div class="receipt-side">
<strong>{{ item.amount }}</strong>
<span>{{ item.confidence }}</span>
</div>
</article>
</div>
</section>
<section class="insight-card">
<div class="card-head">
<h4>识别建议</h4>
</div>
<ul class="bullet-list">
<li v-for="item in currentInsight.recognition.suggestions" :key="item">{{ item }}</li>
</ul>
</section>
</template>
<template v-else-if="currentInsight.intent === 'note'">
<section class="insight-card primary">
<div class="card-head">
<h4>补充说明</h4>
<span class="status-pill note">{{ currentInsight.note.state }}</span>
</div>
<div class="note-block">
<span>关联单号</span>
<strong>{{ currentInsight.note.requestId }}</strong>
<p>{{ currentInsight.note.generatedNote }}</p>
</div>
</section>
<section class="insight-card">
<div class="card-head">
<h4>影响范围</h4>
</div>
<ul class="bullet-list">
<li v-for="item in currentInsight.note.impacts" :key="item">{{ item }}</li>
</ul>
</section>
<section class="insight-card">
<div class="card-head">
<h4>下一步</h4>
</div>
<div class="metric-grid single">
<div class="metric-item">
<span>当前处理人</span>
<strong>{{ currentInsight.note.owner }}</strong>
</div>
<div class="metric-item">
<span>建议动作</span>
<strong>{{ currentInsight.note.nextAction }}</strong>
</div>
</div>
</section>
</template>
<template v-else-if="currentInsight.intent === 'draft'">
<section class="insight-card primary">
<div class="card-head">
<h4>报销草稿</h4>
<span class="status-pill success">{{ currentInsight.draft.state }}</span>
</div>
<div class="metric-grid">
<div class="metric-item">
<span>单号</span>
<strong>{{ currentInsight.draft.requestId }}</strong>
</div>
<div class="metric-item">
<span>报销类型</span>
<strong>{{ currentInsight.draft.type }}</strong>
</div>
<div class="metric-item">
<span>预计金额</span>
<strong>{{ currentInsight.draft.amount }}</strong>
</div>
<div class="metric-item">
<span>当前进度</span>
<strong>{{ currentInsight.draft.progress }}</strong>
</div>
</div>
</section>
<section class="insight-card">
<div class="card-head">
<h4>费用建议</h4>
</div>
<div class="receipt-list">
<article v-for="item in currentInsight.draft.items" :key="item.name" class="receipt-row">
<div>
<strong>{{ item.name }}</strong>
<p>{{ item.desc }}</p>
</div>
<div class="receipt-side">
<strong>{{ item.amount }}</strong>
<span>{{ item.tag }}</span>
</div>
</article>
</div>
</section>
<section class="insight-card">
<div class="card-head">
<h4>待补信息</h4>
</div>
<ul class="bullet-list">
<li v-for="item in currentInsight.draft.missing" :key="item">{{ item }}</li>
</ul>
</section>
</template>
</div>
</Transition>
</aside>
</Transition>
</div>
</section>
</div>
</Transition>
</Teleport>
</template>
<script src="./scripts/TravelReimbursementCreateView.js"></script>
<style scoped src="../assets/styles/views/travel-reimbursement-create-view.css"></style>

View File

@@ -0,0 +1,317 @@
<template>
<section class="approval-page">
<Teleport to="body">
<Transition name="detail-modal">
<div v-if="aiEntryOpen" class="detail-overlay" @click.self="closeAiEntry">
<div class="detail-modal ai-entry-modal">
<header class="modal-header">
<div class="header-left">
<div class="req-badge">AI</div>
<div class="header-title-group">
<h2>智能录入费用明细</h2>
<p>描述票据行程或费用场景AI 会整理成可追加的费用条目</p>
</div>
</div>
<div class="header-right">
<button class="close-btn" type="button" aria-label="关闭" @click="closeAiEntry">
<i class="mdi mdi-close"></i>
</button>
</div>
</header>
<div class="modal-body ai-entry-body">
<div class="ai-entry-grid">
<section class="ai-chat-card">
<input
ref="aiFileInput"
class="ai-file-input"
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png,.webp,.doc,.docx,.xls,.xlsx"
@change="handleAiFilesChange"
/>
<div class="ai-chat-scroll">
<article
v-for="message in aiMessages"
:key="message.id"
class="ai-chat-bubble"
:class="message.role"
>
<span class="ai-chat-avatar">
<i :class="message.role === 'assistant' ? 'mdi mdi-robot-outline' : 'mdi mdi-account-circle-outline'"></i>
</span>
<div class="ai-chat-content">
<header>
<strong>{{ message.role === 'assistant' ? 'AI 录入助手' : '我' }}</strong>
</header>
<p>{{ message.text }}</p>
</div>
</article>
</div>
<div class="ai-composer">
<div class="ai-composer-surface">
<textarea
v-model="aiDraft"
rows="3"
placeholder="例如7月12日从上海虹桥到杭州东高铁二等座236元已上传车票和行程单。"
/>
<div class="ai-composer-actions">
<button class="ai-upload-btn" type="button" aria-label="上传单据" @click="triggerAiUpload">
<i class="mdi mdi-paperclip"></i>
</button>
<button class="ai-send-btn" type="button" aria-label="发送给 AI" :disabled="!canSendAiEntry" @click="sendAiEntry">
<i class="mdi mdi-send"></i>
</button>
</div>
</div>
<div v-if="uploadedAiFiles.length" class="ai-upload-list">
<span v-for="file in uploadedAiFiles" :key="file.name" class="ai-upload-chip">
<i class="mdi mdi-paperclip"></i>
{{ file.name }}
</span>
</div>
</div>
</section>
<aside class="ai-preview-card">
<div class="ai-preview-head">
<div>
<h3>识别结果</h3>
<p>确认后会直接追加到费用明细表</p>
</div>
<span v-if="pendingAiExpense" class="attachment-pill neutral">待确认</span>
</div>
<div v-if="pendingAiExpense" class="ai-preview-fields">
<div class="preview-field">
<span>日期</span>
<strong>{{ pendingAiExpense.time }} {{ pendingAiExpense.dayLabel }}</strong>
</div>
<div class="preview-field">
<span>费用项目</span>
<strong>{{ pendingAiExpense.name }}</strong>
</div>
<div class="preview-field">
<span>分类</span>
<strong>{{ pendingAiExpense.category }}</strong>
</div>
<div class="preview-field">
<span>金额</span>
<strong>{{ pendingAiExpense.amount }}</strong>
</div>
<div class="preview-field full">
<span>说明</span>
<strong>{{ pendingAiExpense.desc }}</strong>
<p>{{ pendingAiExpense.detail }}</p>
</div>
<div class="preview-field full">
<span>附件</span>
<strong>{{ pendingAiExpense.attachmentStatus }}</strong>
<p>{{ pendingAiExpense.attachmentHint }}</p>
</div>
<div class="ai-preview-actions">
<button class="ai-preview-secondary" type="button" @click="regenerateAiEntry">
<i class="mdi mdi-refresh"></i>
重新生成
</button>
<button class="ai-preview-primary" type="button" @click="applyAiExpense">
<i class="mdi mdi-plus-circle-outline"></i>
加入费用明细
</button>
</div>
</div>
<div v-else class="ai-preview-empty">
<i class="mdi mdi-robot-outline"></i>
<p>发送一段费用描述后这里会生成结构化结果</p>
</div>
</aside>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
<div class="approval-detail">
<div class="detail-scroll">
<article class="detail-hero panel">
<div class="applicant-card">
<div class="portrait">{{ profile.avatar }}</div>
<div>
<h2>{{ profile.name }} <span>{{ profile.department }}</span></h2>
<p>申请时间 <strong>{{ request.applyTime }}</strong></p>
</div>
</div>
<div class="hero-stat">
<span>金额</span>
<strong>{{ expenseTotal }}</strong>
</div>
<div class="hero-stat">
<span>审批状态</span>
<b class="state-pill">{{ request.node }}</b>
</div>
<div class="hero-stat">
<span>商旅状态</span>
<b :class="['risk-pill', request.travelTone]">{{ request.travel }}</b>
</div>
<div class="hero-stat">
<span>申请状态</span>
<strong class="countdown"><i class="mdi mdi-clock-outline"></i> {{ request.approval }}</strong>
</div>
<div class="hero-summary-panel">
<div v-for="item in heroSummaryItems" :key="item.label" class="hero-summary-item">
<div class="hero-summary-label">
<span class="hero-summary-icon"><i :class="item.icon"></i></span>
<span>{{ item.label }}</span>
</div>
<strong>{{ item.value }}</strong>
</div>
</div>
<div class="progress-block">
<div class="progress-head">
<h3>当前进度</h3>
</div>
<div class="progress-line">
<div
v-for="step in progressSteps"
:key="step.label"
class="progress-step"
:class="{ active: step.active, current: step.current }"
>
<span>
<i
v-if="step.current"
v-motion
class="current-progress-ring"
:initial="currentProgressRingMotion.initial"
:enter="currentProgressRingMotion.enter"
aria-hidden="true"
></i>
<i v-if="step.done" class="mdi mdi-check"></i>
<template v-else>{{ step.index }}</template>
</span>
<strong>{{ step.label }}</strong>
<small>{{ step.time }}</small>
</div>
</div>
</div>
</article>
<div class="detail-grid">
<section class="detail-left">
<article class="detail-card panel">
<div class="detail-card-head">
<div>
<h3>费用明细</h3>
<p>按发生时间逐笔展示附件与系统校验直接在表内完成核对</p>
</div>
<button class="smart-entry-btn" type="button" @click="openAiEntry">
<i class="mdi mdi-robot-outline"></i>
<span>智能录入</span>
</button>
</div>
<div class="detail-expense-table">
<table>
<thead>
<tr>
<th>时间</th>
<th>费用项目</th>
<th>说明</th>
<th>金额</th>
<th>附件材料</th>
<th>系统校验</th>
</tr>
</thead>
<tbody>
<template v-for="item in expenseItems" :key="item.id">
<tr>
<td class="expense-time">
<strong>{{ item.time }}</strong>
<span>{{ item.dayLabel }}</span>
</td>
<td class="expense-type">
<strong>{{ item.name }}</strong>
<span>{{ item.category }}</span>
</td>
<td class="expense-desc">
<strong>{{ item.desc }}</strong>
<span>{{ item.detail }}</span>
</td>
<td class="expense-amount">
<strong>{{ item.amount }}</strong>
<span v-if="item.tone !== 'ok'" :class="['over-tag', item.tone]">{{ item.status }}</span>
</td>
<td class="expense-attachment">
<div class="expense-attachment-main">
<span :class="['attachment-pill', item.attachmentTone]">{{ item.attachmentStatus }}</span>
<button
v-if="item.attachments.length"
class="inline-action"
type="button"
@click="toggleExpenseAttachments(item.id)"
>
{{ expandedExpenseId === item.id ? '收起附件' : '查看附件' }}
</button>
</div>
<span class="attachment-hint">{{ item.attachmentHint }}</span>
</td>
<td class="expense-risk">
<template v-if="showExpenseRisk(item)">
<span :class="['risk-inline-tag', item.riskTone]">{{ item.riskLabel }}</span>
<p>{{ item.riskText }}</p>
</template>
</td>
</tr>
<tr v-if="expandedExpenseId === item.id" class="expense-expand-row">
<td colspan="6">
<div class="expense-files">
<span v-for="file in item.attachments" :key="file" class="expense-file-chip">
<i class="mdi mdi-paperclip"></i>
{{ file }}
</span>
</div>
</td>
</tr>
</template>
<tr class="total-row">
<td colspan="3">合计</td>
<td>{{ expenseTotal }}</td>
<td>{{ uploadedExpenseCount }} 项已上传票据</td>
<td>1 项待补材料1 项需补充超标说明</td>
</tr>
</tbody>
</table>
</div>
</article>
<article class="detail-card panel">
<h3>申请说明</h3>
<textarea rows="3" :value="detailNote" placeholder="输入申请说明..." />
</article>
</section>
</div>
</div>
<footer class="detail-actions">
<button class="back-action" type="button" @click="emit('backToRequests')">
<i class="mdi mdi-arrow-left"></i>
<span>退回列表</span>
</button>
<div class="approval-action-group" aria-label="申请操作">
<button class="approve-action" type="button"><i class="mdi mdi-send-circle-outline"></i> 提交审批</button>
<button class="reject-action" type="button"><i class="mdi mdi-close-circle-outline"></i> 撤回申请</button>
<button class="supplement-action" type="button"><i class="mdi mdi-pencil-outline"></i> 编辑申请</button>
</div>
</footer>
</div>
</section>
</template>
<script src="./scripts/TravelRequestDetailView.js"></script>
<style scoped src="../assets/styles/views/travel-request-detail-view.css"></style>

View File

@@ -0,0 +1,284 @@
import { computed, ref } from 'vue'
export default {
name: 'ApprovalCenterView' ,
setup(props, { emit }) {
const activeTab = ref('全部待审')
const selectedRow = ref(null)
const expandedExpenseId = ref(null)
const tabs = ['全部待审', '高风险', '即将超时', '已处理']
const filters = ['法人主体', '费用类型', '风险等级', '金额区间', '所属部门']
const rows = [
{ id: 'RE240712001', applicant: '李文静', avatar: '李', department: '市场部', type: '差旅报销', amount: '¥3,680', time: '07-12 09:20', risk: '中风险', riskTone: 'medium', sla: '4.2h', slaTone: 'safe', node: '财务审批', status: '待审批', statusTone: 'pending' },
{ id: 'RE240712002', applicant: '王志强', avatar: '王', department: '销售部', type: '招待费', amount: '¥1,280', time: '07-12 08:15', risk: '低风险', riskTone: 'low', sla: '8.5h', slaTone: 'safe', node: '部门负责人', status: '待审批', statusTone: 'pending' },
{ id: 'RE240711098', applicant: '刘思雨', avatar: '刘', department: '市场部', type: '差旅报销', amount: '¥6,920', time: '07-11 18:46', risk: '高风险', riskTone: 'high', sla: '0.8h', slaTone: 'danger', node: '财务审批', status: '即将超时', statusTone: 'urgent', spotlight: true },
{ id: 'RE240711087', applicant: '陈晓琳', avatar: '陈', department: '行政部', type: '办公采购', amount: '¥860', time: '07-11 17:32', risk: '低风险', riskTone: 'low', sla: '6.1h', slaTone: 'safe', node: '预算校验', status: '待审批', statusTone: 'pending' },
{ id: 'RE240711076', applicant: '赵明', avatar: '赵', department: '研发中心', type: '其他费用', amount: '¥4,250', time: '07-11 15:10', risk: '中风险', riskTone: 'medium', sla: '2.4h', slaTone: 'warning', node: '部门负责人', status: '待审批', statusTone: 'pending' },
{ id: 'RE240711065', applicant: '孙楠', avatar: '孙', department: '财务部', type: '招待费', amount: '¥560', time: '07-11 13:42', risk: '低风险', riskTone: 'low', sla: '5.7h', slaTone: 'safe', node: '财务审批', status: '待审批', statusTone: 'pending' },
{ id: 'RE240711054', applicant: '周晓彤', avatar: '周', department: '市场部', type: '办公采购', amount: '¥2,150', time: '07-11 11:28', risk: '中风险', riskTone: 'medium', sla: '1.9h', slaTone: 'warning', node: '预算校验', status: '即将超时', statusTone: 'urgent' },
{ id: 'RE240711043', applicant: '吴磊', avatar: '吴', department: '销售部', type: '其他费用', amount: '¥980', time: '07-11 09:05', risk: '低风险', riskTone: 'low', sla: '7.3h', slaTone: 'safe', node: '部门负责人', status: '待审批', statusTone: 'pending' }
]
const visibleRows = computed(() => {
if (activeTab.value === '全部待审') return rows
if (activeTab.value === '高风险') return rows.filter((row) => row.risk === '高风险')
if (activeTab.value === '即将超时') return rows.filter((row) => row.status === '即将超时')
return rows.slice(0, 3).map((row) => ({ ...row, status: '已处理', statusTone: 'done' }))
})
const approvalSteps = [
{ index: 1, label: '提交申请', time: '07-11 08:46', done: true, active: true },
{ index: 2, label: '票据识别', time: '07-11 08:48', done: true, active: true },
{ index: 3, label: '费用归类', time: '07-11 08:49', done: true, active: true },
{ index: 4, label: '部门负责人审批', time: '07-11 11:28', done: true, active: true },
{ index: 5, label: '财务审批', time: '进行中', active: true, current: true },
{ index: 6, label: '归档入账', time: '待处理' }
]
const summaryItems = [
{ label: '行程', value: '北京 → 上海', icon: 'mdi mdi-map-marker-path' },
{ label: '出差区间', value: '07-10 至 07-11', icon: 'mdi mdi-clock-outline' },
{ label: '票据关联', value: '8 条明细 / 7 份材料', icon: 'mdi mdi-file-document-multiple-outline' },
{ label: '成本归属', value: '市场部 · CC-MKT-01', icon: 'mdi mdi-account-group-outline' },
{ label: '支付方式', value: '企业垫付', icon: 'mdi mdi-credit-card-outline' }
]
const heroSummaryItems = computed(() => [
{ label: '单号', value: selectedRow.value?.id ?? '-', icon: 'mdi mdi-pound-box-outline' },
{ label: '报销类型', value: selectedRow.value?.type ?? '-', icon: 'mdi mdi-briefcase-outline' },
...summaryItems
])
const currentProgressRingMotion = {
initial: {
scale: 1,
opacity: 0.34,
},
enter: {
scale: [1, 1.42, 1.78],
opacity: [0.34, 0.16, 0],
transition: {
duration: 3.2,
repeat: Infinity,
repeatType: 'loop',
repeatDelay: 0.85,
ease: 'easeOut',
times: [0, 0.5, 1],
},
},
}
const expenseItems = [
{
id: 'flight-1',
time: '07-10 07:25',
dayLabel: '周三',
name: '机票',
category: '交通',
desc: '北京首都 → 上海虹桥',
detail: 'MU5103 往返经济舱,含行程单',
amount: '¥2,180',
status: '未超标',
tone: 'ok',
attachmentStatus: '已上传',
attachmentTone: 'ok',
attachmentHint: '电子行程单与机票发票齐全',
attachments: ['电子行程单.pdf', '机票发票.pdf'],
riskLabel: '低风险',
riskTone: 'low',
riskText: '票面信息与行程匹配。'
},
{
id: 'taxi-1',
time: '07-10 10:35',
dayLabel: '周三',
name: '出租车',
category: '市内交通',
desc: '虹桥机场 → 静安酒店',
detail: '落地后前往酒店,含过路费',
amount: '¥86',
status: '未超标',
tone: 'ok',
attachmentStatus: '已上传',
attachmentTone: 'ok',
attachmentHint: '已上传 1 张发票',
attachments: ['出租车发票-0710-01.jpg'],
riskLabel: '中风险',
riskTone: 'medium',
riskText: '高峰加价较高,建议顺带核对上车点。'
},
{
id: 'metro-1',
time: '07-10 18:20',
dayLabel: '周三',
name: '地铁',
category: '市内交通',
desc: '静安酒店 → 客户园区',
detail: '2 号线换乘,通勤交通',
amount: '¥12',
status: '未超标',
tone: 'ok',
attachmentStatus: '已上传',
attachmentTone: 'ok',
attachmentHint: '已上传电子票据',
attachments: ['地铁电子票据-0710.png'],
riskLabel: '低风险',
riskTone: 'low',
riskText: '路线与拜访行程一致。'
},
{
id: 'taxi-2',
time: '07-11 08:40',
dayLabel: '周四',
name: '出租车',
category: '市内交通',
desc: '静安酒店 → 客户园区',
detail: '次日早会前往客户现场',
amount: '¥42',
status: '未超标',
tone: 'ok',
attachmentStatus: '未上传',
attachmentTone: 'missing',
attachmentHint: '缺少对应发票',
attachments: [],
riskLabel: '高风险',
riskTone: 'high',
riskText: '票据缺失,当前无法完成交通费核验。'
},
{
id: 'taxi-3',
time: '07-11 20:55',
dayLabel: '周四',
name: '出租车',
category: '返程交通',
desc: '客户园区 → 虹桥机场',
detail: '夜间返程,触发超标校验',
amount: '¥136',
status: '超标 ¥28',
tone: 'bad',
attachmentStatus: '已上传',
attachmentTone: 'ok',
attachmentHint: '已上传 1 张发票',
attachments: ['出租车发票-0711-02.jpg'],
riskLabel: '中风险',
riskTone: 'medium',
riskText: '金额超差旅标准 ¥28需补充业务说明。'
},
{
id: 'hotel-1',
time: '07-10 至 07-11',
dayLabel: '2 晚',
name: '酒店',
category: '住宿',
desc: '上海静安商务酒店',
detail: '标准大床房 2 晚,含早餐',
amount: '¥2,480',
status: '未超标',
tone: 'ok',
attachmentStatus: '部分上传',
attachmentTone: 'partial',
attachmentHint: '发票已上传,入住清单缺失',
attachments: ['酒店发票.jpg'],
riskLabel: '高风险',
riskTone: 'high',
riskText: '缺少入住清单,住宿真实性待补证。'
},
{
id: 'meal-1',
time: '07-10 至 07-11',
dayLabel: '2 天',
name: '餐补',
category: '补贴',
desc: '差旅餐补',
detail: '按差旅制度自动计算',
amount: '¥372',
status: '未超标',
tone: 'ok',
attachmentStatus: '免上传',
attachmentTone: 'neutral',
attachmentHint: '制度型补贴无需票据',
attachments: [],
riskLabel: '低风险',
riskTone: 'low',
riskText: '系统自动核算,无额外异常。'
},
{
id: 'other-1',
time: '07-11 09:10',
dayLabel: '周四',
name: '其他',
category: '杂费',
desc: '行李寄存 / 打印费',
detail: '客户提案资料打印与寄存服务',
amount: '¥1,612',
status: '未超标',
tone: 'ok',
attachmentStatus: '已上传',
attachmentTone: 'ok',
attachmentHint: '已上传 2 份附件',
attachments: ['打印服务发票.jpg', '行李寄存凭证.jpg'],
riskLabel: '低风险',
riskTone: 'low',
riskText: '用途清晰,金额在授权范围内。'
}
]
const expenseTotal = '¥6,920'
const uploadedExpenseCount = computed(() => expenseItems.filter((item) => item.attachments.length).length)
const showExpenseRisk = (item) => item.riskTone === 'medium' || item.riskTone === 'high'
const toggleExpenseAttachments = (id) => {
expandedExpenseId.value = expandedExpenseId.value === id ? null : id
}
const attachments = [
{ name: '机票.pdf', size: '256 KB', icon: 'mdi mdi-file-pdf-box', iconClass: 'pdf' },
{ name: '酒店发票.jpg', size: '412 KB', icon: 'mdi mdi-image', iconClass: 'img' },
{ name: '行程单.pdf', size: '198 KB', icon: 'mdi mdi-file-pdf-box', iconClass: 'pdf' },
{ name: '出租车发票1.jpg', size: '128 KB', icon: 'mdi mdi-image', iconClass: 'img' },
{ name: '出租车发票2.jpg', size: '132 KB', icon: 'mdi mdi-image', iconClass: 'img' },
{ name: '酒店入住清单', size: '缺失', icon: 'mdi mdi-minus-circle', iconClass: 'miss', missing: true }
]
const riskItems = [
{ text: '酒店入住清单缺失', level: '高', tone: 'high', icon: 'mdi mdi-alert-circle' },
{ text: '1 笔出租车费用超差旅标准 ¥28', level: '中', tone: 'medium', icon: 'mdi mdi-alert' },
{ text: '发票抬头识别为个人,建议核对', level: '中', tone: 'medium', icon: 'mdi mdi-lightbulb-on' }
]
const flowItems = [
{ label: '提交申请', desc: '刘思雨 提交申请', time: '07-11 08:46', icon: 'mdi mdi-check' },
{ label: '票据识别', desc: 'AI 自动识别完成', time: '07-11 08:48', icon: 'mdi mdi-check' },
{ label: '费用归类', desc: '费用归类完成', time: '07-11 08:49', icon: 'mdi mdi-check' },
{ label: '部门负责人审批', desc: '李文静 已通过', time: '07-11 11:28', icon: 'mdi mdi-check' },
{ label: '财务审批', desc: '张晓明 审批中', time: '进行中', icon: 'mdi mdi-circle-slice-8', current: true },
{ label: '归档入账', desc: '待处理', time: '-', icon: 'mdi mdi-circle-outline', pending: true }
]
return {
activeTab,
selectedRow,
expandedExpenseId,
tabs,
filters,
rows,
visibleRows,
approvalSteps,
summaryItems,
heroSummaryItems,
currentProgressRingMotion,
expenseItems,
expenseTotal,
uploadedExpenseCount,
showExpenseRisk,
toggleExpenseAttachments,
attachments,
riskItems,
flowItems
}
}
}

View File

@@ -0,0 +1,249 @@
import { computed, ref } from 'vue'
export default {
name: 'AuditView' ,
setup(props, { emit }) {
const tabs = ['全部技能', '已上线', '草稿中', '待评审', '异常告警']
const filters = ['按分类筛选', '按模型筛选', '按负责人筛选']
const activeTab = ref(tabs[0])
const selectedSkill = ref(null)
const skills = [
{
id: 'SKL-001',
short: 'TR',
name: '差旅申请助手',
summary: '生成出差申请、补齐行程信息并关联预订动作。',
category: '流程型 Skill',
owner: '张晓明',
scope: '员工自助',
model: 'GPT-5.4',
version: 'v2.3',
status: '已上线',
statusTone: 'success',
hitRate: '92.6%',
updatedAt: '2026-05-05 09:20',
badgeTone: 'emerald',
triggerMode: '显式入口 + 语义触发',
spotlight: true,
promptSections: [
{
title: '系统定位',
intent: '约束 Skill 目标与边界',
content: '负责帮助员工完成差旅申请草稿生成、行程补齐和预订前核对。禁止直接跳过必要审批节点。'
},
{
title: '输入预期',
intent: '定义需要抽取的字段',
content: '抽取出发地、目的地、出差日期、事由、同行人、预算中心与是否需要预订机票/酒店。缺失时逐步追问。'
},
{
title: '输出格式',
intent: '约束最终返回结构',
content: '输出申请摘要、缺失项清单、下一步操作建议。若信息齐全,生成结构化草稿并提示用户确认。'
}
],
outputRules: [
'优先返回结构化摘要,再给行动建议。',
'缺失信息必须列成 checklist不可混写在段落里。',
'遇到预算冲突时必须提示人工审批节点。'
],
tests: [
{ name: '基础申请生成', input: '北京到上海,后天出差两天', result: '通过', tone: 'success' },
{ name: '缺失预算中心追问', input: '我要去深圳见客户', result: '通过', tone: 'success' },
{ name: '异常日期冲突', input: '返回日期早于出发日期', result: '待修复', tone: 'warning' }
],
triggers: ['差旅申请', '出差申请', '预订机票', '补齐行程'],
tools: [
{ name: '预订系统 API', scope: '机票 / 酒店查询', mode: '只读', tone: 'safe' },
{ name: '报销草稿生成器', scope: '创建申请草稿', mode: '写入', tone: 'active' },
{ name: '预算中心校验', scope: '预算占用校验', mode: '校验', tone: 'safe' }
],
history: [
{ version: 'v2.3', note: '补充预算冲突追问逻辑', time: '05-05 09:20' },
{ version: 'v2.2', note: '优化酒店预订字段抽取', time: '05-01 17:45' },
{ version: 'v2.1', note: '新增同行人识别', time: '04-28 11:10' }
]
},
{
id: 'SKL-002',
short: 'AU',
name: '审批意见生成器',
summary: '基于单据、风险点和制度命中结果生成审批意见。',
category: '审核型 Skill',
owner: '李文静',
scope: '财务审批',
model: 'GPT-5.4',
version: 'v1.8',
status: '待评审',
statusTone: 'warning',
hitRate: '88.4%',
updatedAt: '2026-05-04 19:10',
badgeTone: 'violet',
triggerMode: '审批中心按钮触发',
promptSections: [
{
title: '系统定位',
intent: '聚焦审批建议生成',
content: '读取单据、制度命中和风险标签后,生成可直接复用的审批意见,不代替最终审批决定。'
},
{
title: '输入预期',
intent: '依赖字段',
content: '依赖报销类型、金额、风险项、附件齐备情况、历史审批结论。'
},
{
title: '输出格式',
intent: '生成标准话术',
content: '输出通过 / 驳回 / 补件三种意见模板,并附上判断依据。'
}
],
outputRules: [
'意见必须引用风险点或制度条款作为依据。',
'驳回类结论需明确补充动作。',
'避免输出过长段落,优先三段式表达。'
],
tests: [
{ name: '高风险驳回意见', input: '重复发票 + 缺附件', result: '通过', tone: 'success' },
{ name: '低风险通过意见', input: '规则全通过', result: '通过', tone: 'success' },
{ name: '混合场景表达', input: '超标但说明充分', result: '评审中', tone: 'warning' }
],
triggers: ['生成审批意见', '通过意见', '驳回意见', '补件说明'],
tools: [
{ name: '审批单据上下文', scope: '当前单据读取', mode: '只读', tone: 'safe' },
{ name: '制度命中服务', scope: '条款引用', mode: '校验', tone: 'safe' },
{ name: '审批结果写回', scope: '保存意见', mode: '写入', tone: 'active' }
],
history: [
{ version: 'v1.8', note: '调整高风险话术严谨度', time: '05-04 19:10' },
{ version: 'v1.7', note: '补充制度条款引用模板', time: '05-02 10:30' }
]
},
{
id: 'SKL-003',
short: 'KB',
name: '知识检索编排器',
summary: '根据问题意图匹配制度、FAQ 与最近更新文档。',
category: '知识型 Skill',
owner: '王磊',
scope: '知识管理',
model: 'GPT-5.2',
version: 'v3.1',
status: '已上线',
statusTone: 'success',
hitRate: '94.1%',
updatedAt: '2026-05-03 15:40',
badgeTone: 'blue',
triggerMode: '问答语义召回',
promptSections: [
{
title: '系统定位',
intent: '文档命中与答案编排',
content: '识别问题主题后优先召回制度文档、FAQ 与近期更新资料,再组织成引用式回答。'
},
{
title: '输入预期',
intent: '需要识别的意图',
content: '识别报销、发票、差旅、借款、预算等主题,以及用户是否在追问例外情况。'
},
{
title: '输出格式',
intent: '答案结构',
content: '先结论,再条款引用,再相关文档链接。若知识不足,明确提示未命中。'
}
],
outputRules: [
'必须区分“制度原文依据”和“解释性建议”。',
'引用命中不足时,不可编造制度条款。',
'输出需附上最近更新时间。'
],
tests: [
{ name: '标准知识问答', input: '住宿超标怎么办', result: '通过', tone: 'success' },
{ name: '跨文档综合问答', input: '差旅借款后如何冲销', result: '通过', tone: 'success' }
],
triggers: ['制度查询', '差旅标准', '发票规范', '借款冲销'],
tools: [
{ name: '知识库索引', scope: '文档召回', mode: '只读', tone: 'safe' },
{ name: 'FAQ 排序器', scope: '答案重排', mode: '校验', tone: 'safe' }
],
history: [
{ version: 'v3.1', note: '加入最近更新知识优先级', time: '05-03 15:40' },
{ version: 'v3.0', note: '知识命中格式重构', time: '04-29 18:20' }
]
},
{
id: 'SKL-004',
short: 'RK',
name: '风险解释助手',
summary: '向员工解释拦截原因,并给出补件或修正建议。',
category: '解释型 Skill',
owner: '陈杰',
scope: '员工自助',
model: 'GPT-5.4-Mini',
version: 'v1.4',
status: '草稿中',
statusTone: 'draft',
hitRate: '79.8%',
updatedAt: '2026-05-02 11:05',
badgeTone: 'amber',
triggerMode: '风险拦截后提示入口',
promptSections: [
{
title: '系统定位',
intent: '解释风控结论',
content: '将复杂风控规则解释成员工可执行的修正动作,不暴露内部评分细节。'
},
{
title: '输入预期',
intent: '关注异常标签',
content: '读取异常标签、相关票据、制度限制和当前流程节点。'
},
{
title: '输出格式',
intent: '行动导向',
content: '按“原因 - 影响 - 处理建议”输出,不使用过于生硬的审计口吻。'
}
],
outputRules: [
'建议必须可以执行,避免空泛表述。',
'不展示内部风控分值。',
'涉及附件缺失时输出具体材料名称。'
],
tests: [
{ name: '住宿超标解释', input: '酒店单晚超标 18%', result: '通过', tone: 'success' },
{ name: '重复发票风险解释', input: '发票号重复', result: '待修复', tone: 'warning' }
],
triggers: ['为什么被拦截', '风险原因', '补件说明'],
tools: [
{ name: '风险标签读取', scope: '异常原因', mode: '只读', tone: 'safe' },
{ name: '制度比对服务', scope: '规则解释', mode: '校验', tone: 'safe' }
],
history: [
{ version: 'v1.4', note: '新增补件导向模板', time: '05-02 11:05' },
{ version: 'v1.3', note: '优化语气控制', time: '04-30 16:48' }
]
}
]
const visibleSkills = computed(() => {
if (activeTab.value === '全部技能') return skills
const map = {
已上线: '已上线',
草稿中: '草稿中',
待评审: '待评审',
异常告警: '异常告警'
}
return skills.filter((item) => item.status === map[activeTab.value])
})
return {
tabs,
filters,
activeTab,
selectedSkill,
skills,
visibleSkills
}
}
}

View File

@@ -0,0 +1,101 @@
import { computed, nextTick, ref, watch } from 'vue'
export default {
name: 'ChatView',
props: {
documents: { type: Array, required: true },
docSearch: { type: String, default: '' },
messages: { type: Array, required: true },
uploadedFiles: { type: Array, required: true },
activeCase: { type: Object, default: null },
quickPrompts: { type: Array, required: true },
draft: { type: String, default: '' },
messageList: { type: Object, default: null }
},
emits: ['send', 'upload', 'draft', 'approveCase', 'rejectCase', 'selectCase'] ,
setup(props, { emit }) {
const localMessageList = ref(null)
const promptPage = ref(0)
const sessions = [
{ title: '北京出差,酒店超标报销怎么处理?', time: '10:32', active: true },
{ title: '发票抬头不一致怎么办', time: '09:48' },
{ title: '借款冲销流程', time: '昨天' },
{ title: '预算占用失败处理', time: '昨天' },
{ title: '招待费报销范围', time: '05-11' },
{ title: '差旅住宿标准如何匹配城市级别?', time: '05-10' },
{ title: '电子发票验真失败如何处理?', time: '05-09' },
{ title: '跨部门项目费用怎么归集?', time: '05-08' },
{ title: '会议费和招待费如何区分?', time: '05-07' },
{ title: '超预算申请需要哪些审批节点?', time: '05-06' },
{ title: '海外差旅汇率按哪天计算?', time: '05-05' },
{ title: '员工退票手续费是否可报销?', time: '05-04' }
]
const prompts = [
{ icon: 'mdi mdi-bed-outline', short: '差旅标准', text: '差旅报销特殊标准是什么?' },
{ icon: 'mdi mdi-receipt-text-outline', short: '发票规范', text: '发票丢失如何处理?' },
{ icon: 'mdi mdi-cash-refund', short: '借款冲销', text: '借款多久内需要冲销?' },
{ icon: 'mdi mdi-file-chart-outline', short: '预算冲突', text: '预算不足如何申请?' },
{ icon: 'mdi mdi-shield-check-outline', short: '审批要求', text: '酒店超标后如何申请例外报销?' },
{ icon: 'mdi mdi-office-building-marker', short: '住宿标准', text: '差旅住宿标准按什么规则执行?' },
{ icon: 'mdi mdi-file-question-outline', short: '材料补齐', text: '报销附件缺失怎么补交?' },
{ icon: 'mdi mdi-alert-circle-outline', short: '风险等级', text: '哪些情况会触发中风险?' }
]
const visiblePrompts = computed(() => prompts.slice((promptPage.value % 2) * 4, (promptPage.value % 2) * 4 + 4))
const hotQuestions = [
'差旅报销标准是什么?',
'酒店超标后如何申请例外报销?',
'发票丢失如何处理?',
'借款多久内需要冲销?',
'预算超支如何申请?',
'招待费报销需要哪些凭证?',
'发票抬头不一致是否允许报销?',
'报销附件缺失怎么补交?',
'差旅住宿标准按什么规则执行?',
'电子发票验真失败如何处理?'
]
const similarQuestions = [
{ text: '酒店超标后如何申请例外报销?', score: '96%' },
{ text: '发票抬头不一致是否允许报销?', score: '92%' },
{ text: '差旅住宿标准按什么规则执行?', score: '89%' },
{ text: '预算不足时能否先提交报销?', score: '86%' },
{ text: '电子发票验真失败是否可以先报销?', score: '84%' },
{ text: '跨部门项目费用如何归集?', score: '81%' },
{ text: '招待费报销需要哪些凭证?', score: '78%' },
{ text: '借款冲销逾期会影响报销吗?', score: '76%' }
]
function rotatePrompts() {
promptPage.value += 1
}
function applyPrompt(text) {
emit('draft', text)
}
watch(
() => props.messages.length,
() => {
nextTick(() => localMessageList.value?.scrollTo({ top: localMessageList.value.scrollHeight, behavior: 'smooth' }))
}
)
return {
emit,
localMessageList,
promptPage,
sessions,
prompts,
visiblePrompts,
hotQuestions,
similarQuestions,
rotatePrompts,
applyPrompt
}
}
}

View File

@@ -0,0 +1,202 @@
import { computed, ref } from 'vue'
export default {
name: 'EmployeeManagementView' ,
setup(props, { emit }) {
const tabs = ['全部员工', '在职', '试用中', '停用']
const filters = ['按部门筛选', '按职级筛选', '按系统角色筛选']
const activeTab = ref(tabs[0])
const selectedEmployee = ref(null)
const roleOptions = [
{ id: 'user', label: '使用者', desc: '可以发起报销、查看个人单据和使用 AI 助手。' },
{ id: 'finance', label: '财务人员', desc: '可以处理复核、查看财务知识与风险校验结果。' },
{ id: 'manager', label: '管理员', desc: '可以维护员工档案、组织结构和角色权限。' },
{ id: 'executive', label: '高级管理人员', desc: '可以查看跨部门数据看板与关键审批结果。' },
{ id: 'approver', label: '审批负责人', desc: '可以处理审批中心中的待审单据。' },
{ id: 'auditor', label: '审计观察员', desc: '可以查看变更记录和权限调整历史。' }
]
const employees = [
{
id: 'EMP-001',
avatar: '张',
name: '张晓晴',
employeeNo: 'E10234',
department: '财务共享中心',
position: '费用运营经理',
grade: 'M3',
manager: '李文静',
financeOwner: '华东财务组',
roles: ['管理员', '财务人员', '审批负责人'],
status: '在职',
statusTone: 'success',
gender: '女',
age: '32',
birthDate: '1994-08-12',
email: 'xiaoqing.zhang@xfinance.com',
phone: '138 1023 4567',
joinDate: '2021-03-15',
location: '上海',
costCenter: 'CC-2108',
updatedAt: '2026-05-06 10:24',
lastSync: '2026-05-06 10:24',
syncState: '待生效',
spotlight: true,
permissions: [
'可查看审批中心全部待审单据',
'可配置员工角色与部门归属',
'可查看知识管理与技能中心配置'
],
history: [
{ action: '新增“审批负责人”角色', owner: '系统管理员 · 王敏', time: '今天 10:24' },
{ action: '调整财务归口为华东财务组', owner: '组织管理员 · 陈硕', time: '昨天 18:10' }
]
},
{
id: 'EMP-002',
avatar: '李',
name: '李文静',
employeeNo: 'E10018',
department: '总经办',
position: '高级财务总监',
grade: 'D2',
manager: 'CEO',
financeOwner: '集团财务',
roles: ['高级管理人员', '审批负责人'],
status: '在职',
statusTone: 'success',
gender: '女',
age: '39',
birthDate: '1987-03-26',
email: 'wenjing.li@xfinance.com',
phone: '139 0018 7688',
joinDate: '2018-06-21',
location: '上海',
costCenter: 'CC-1001',
updatedAt: '2026-05-05 16:20',
lastSync: '2026-05-05 16:20',
syncState: '已同步',
permissions: [
'可查看集团层面的审批看板',
'可处理高金额报销的最终审批',
'可查看部门预算执行情况'
],
history: [
{ action: '更新高级管理人员可见范围', owner: '系统管理员 · 王敏', time: '05-05 16:20' }
]
},
{
id: 'EMP-003',
avatar: '王',
name: '王敏',
employeeNo: 'E10867',
department: '人力与组织',
position: '组织发展主管',
grade: 'P6',
manager: '陈嘉',
financeOwner: '总部财务',
roles: ['管理员', '审计观察员'],
status: '在职',
statusTone: 'success',
gender: '女',
age: '30',
birthDate: '1996-11-05',
email: 'min.wang@xfinance.com',
phone: '136 8867 1200',
joinDate: '2022-08-08',
location: '杭州',
costCenter: 'CC-3206',
updatedAt: '2026-05-05 09:18',
lastSync: '2026-05-05 09:18',
syncState: '已同步',
permissions: [
'可维护组织结构与岗位映射',
'可查看员工角色分配历史'
],
history: [
{ action: '新增“审计观察员”角色', owner: '系统管理员 · 张晓晴', time: '05-05 09:18' }
]
},
{
id: 'EMP-004',
avatar: '陈',
name: '陈嘉',
employeeNo: 'E11602',
department: '销售运营',
position: '区域销售经理',
grade: 'M2',
manager: '李文静',
financeOwner: '华南财务组',
roles: ['使用者', '审批负责人'],
status: '试用中',
statusTone: 'warning',
gender: '男',
age: '29',
birthDate: '1997-02-18',
email: 'jia.chen@xfinance.com',
phone: '137 1602 9901',
joinDate: '2026-03-01',
location: '深圳',
costCenter: 'CC-4102',
updatedAt: '2026-05-04 14:12',
lastSync: '2026-05-04 14:12',
syncState: '已同步',
permissions: [
'可发起个人报销与出差申请',
'可处理本部门基础审批'
],
history: [
{ action: '完成试用期角色初始化', owner: '组织管理员 · 王敏', time: '05-04 14:12' }
]
},
{
id: 'EMP-005',
avatar: '赵',
name: '赵雨辰',
employeeNo: 'E11991',
department: '研发中心',
position: '产品经理',
grade: 'P5',
manager: '陈嘉',
financeOwner: '总部财务',
roles: ['使用者'],
status: '停用',
statusTone: 'neutral',
gender: '男',
age: '27',
birthDate: '1999-06-09',
email: 'yuchen.zhao@xfinance.com',
phone: '135 1991 3300',
joinDate: '2023-11-18',
location: '北京',
costCenter: 'CC-5209',
updatedAt: '2026-05-01 11:06',
lastSync: '2026-05-01 11:06',
syncState: '已同步',
permissions: [
'当前账号停用,仅保留历史单据查看记录'
],
history: [
{ action: '账号状态变更为停用', owner: '系统管理员 · 王敏', time: '05-01 11:06' }
]
}
]
const visibleEmployees = computed(() => {
if (activeTab.value === '全部员工') return employees
return employees.filter((item) => item.status === activeTab.value)
})
return {
tabs,
filters,
activeTab,
selectedEmployee,
roleOptions,
employees,
visibleEmployees
}
}
}

View File

@@ -0,0 +1,42 @@
import { ref } from 'vue'
export default {
name: 'LoginView',
emits: ['login', 'recover-password', 'sso-login'] ,
setup(props, { emit }) {
const username = ref('')
const password = ref('')
const tenant = ref('')
const remember = ref(true)
const showPassword = ref(false)
const features = [
{ title: '智能审单', desc: 'AI 自动识别票据与规则,提升准确率与效率', icon: 'mdi mdi-file-document-outline', tone: 'green' },
{ title: '异常预警', desc: '多维风险识别与预警,主动防控风险', icon: 'mdi mdi-bell-outline', tone: 'red' },
{ title: 'SLA 监控', desc: '实时监控服务水平协议,保障审批及时性', icon: 'mdi mdi-sync', tone: 'blue' }
]
const LogoMark = {
template: `
<span class="logo-mark" aria-hidden="true">
<svg viewBox="0 0 36 36">
<path d="M19.8 4.5c5.7 1.1 9.9 5.7 10.5 11.6-2.8-.9-5.5-.7-7.9.6-2.8 1.5-4.5 4.3-5.2 8.2-4.4-2.8-6.5-6.5-6.3-11.1.2-4.2 3.5-7.8 8.9-9.3Z" />
<path d="M9 7.6c-3 3.5-4 7.3-2.9 11.2 1.2 4.2 4.6 7 10.1 8.5-2 1.8-4.6 2.6-7.6 2.3C5.1 26.7 3.5 23.1 3.7 19 4 14.4 5.7 10.6 9 7.6Z" />
</svg>
</span>
`
}
return {
emit,
username,
password,
tenant,
remember,
showPassword,
features,
LogoMark
}
}
}

View File

@@ -0,0 +1,130 @@
import { computed, ref } from 'vue'
import {
metricBlueprints,
trendRanges,
trendSeries,
spendByCategory,
exceptionMix,
departmentRangeOptions,
bottlenecks,
budgetSummary
} from '../../data/metrics.js'
import TrendChart from '../../components/charts/TrendChart.vue'
import DonutChart from '../../components/charts/DonutChart.vue'
import BarChart from '../../components/charts/BarChart.vue'
import GaugeChart from '../../components/charts/GaugeChart.vue'
import PersonalWorkbench from '../../components/business/PersonalWorkbench.vue'
export default {
name: 'OverviewView',
components: { TrendChart, DonutChart, BarChart, GaugeChart, PersonalWorkbench },
props: {
filteredRequests: { type: Array, required: true }
},
emits: ['ask'] ,
setup(props, { emit }) {
const activeTrendRange = ref(trendRanges[0])
const activeDepartmentRange = ref(departmentRangeOptions[0])
const demoTotals = {
pendingCount: 128,
pendingAmount: 361600,
avgSla: 6.8,
autoPassRate: 78,
riskCount: 14,
slaRate: 96
}
const demoDepartments = [
{ name: '销售部', amount: 182000, color: '#10b981' },
{ name: '研发中心', amount: 146000, color: '#3b82f6' },
{ name: '市场部', amount: 96000, color: '#f59e0b' },
{ name: '运营部', amount: 68600, color: '#8b5cf6' },
{ name: '行政部', amount: 48300, color: '#3b82f6' }
]
const formatCompact = (value) => {
if (value >= 1_000_000) return `¥${(value / 1_000_000).toFixed(1)}M`
if (value >= 1_000) return `¥${(value / 1_000).toFixed(1)}K`
return `¥${value}`
}
const formatCurrency = (value) => formatCompact(value)
const formatMetricValue = (metric, value) => {
if (metric.key === 'pendingAmount') return formatCurrency(Math.round(value))
if (metric.key === 'avgSla') return `${value.toFixed(1)} ${metric.unit}`
if (metric.unit === '%') return `${Math.round(value)} ${metric.unit}`
if (metric.unit) return `${Math.round(value)} ${metric.unit}`
return `${Math.round(value)}`
}
const kpiMetrics = computed(() => metricBlueprints.map((metric, index) => {
const rawValue = demoTotals[metric.key]
const displayValue = formatMetricValue(metric, rawValue)
return {
...metric,
displayValue,
changeText: metric.change,
delay: index * 55
}
}))
const activeTrend = computed(() => trendSeries[activeTrendRange.value])
const spendTotal = computed(() => spendByCategory.reduce((sum, item) => sum + item.value, 0))
const riskTotal = computed(() => exceptionMix.reduce((sum, item) => sum + item.value, 0))
const spendLegend = computed(() => spendByCategory.map((item) => ({
...item,
display: `${Math.round((item.value / spendTotal.value) * 100)}%`
})))
const riskLegend = computed(() => exceptionMix.map((item) => ({
...item,
display: `${item.value}`
})))
const rankedDepartments = computed(() => {
const rows = demoDepartments
const max = Math.max(...rows.map((item) => item.amount), 1)
return rows.slice(0, 5).map((item, index) => ({
...item,
rank: index + 1,
shortName: item.name,
amountLabel: formatCurrency(item.amount),
width: `${Math.max((item.amount / max) * 100, 18)}%`,
color: item.color
}))
})
return {
emit,
activeTrendRange,
activeDepartmentRange,
demoTotals,
demoDepartments,
formatCompact,
formatCurrency,
formatMetricValue,
kpiMetrics,
activeTrend,
spendTotal,
riskTotal,
spendLegend,
riskLegend,
rankedDepartments,
metricBlueprints,
trendRanges,
trendSeries,
spendByCategory,
exceptionMix,
departmentRangeOptions,
bottlenecks,
budgetSummary
}
}
}

View File

@@ -0,0 +1,244 @@
import { computed, ref } from 'vue'
export default {
name: 'PoliciesView' ,
setup(props, { emit }) {
const folderSearch = ref('')
const activeFolder = ref('差旅规范')
const selectedDocument = ref(null)
const folders = [
{ name: '财务知识库', count: 36, icon: 'mdi mdi-folder' },
{ name: '制度政策', count: 8, icon: 'mdi mdi-folder' },
{ name: '报销制度', count: 12, icon: 'mdi mdi-folder-open' },
{ name: '差旅规范', count: 18, icon: 'mdi mdi-folder' },
{ name: '发票管理', count: 14, icon: 'mdi mdi-folder' },
{ name: '税务合规', count: 16, icon: 'mdi mdi-folder' },
{ name: '预算管理', count: 9, icon: 'mdi mdi-folder' },
{ name: '财务共享', count: 7, icon: 'mdi mdi-folder' },
{ name: '培训资料', count: 6, icon: 'mdi mdi-folder' },
{ name: '常见问答', count: 11, icon: 'mdi mdi-folder' }
]
const documents = [
{
name: '差旅报销管理办法2024版',
folder: '差旅规范',
tag: '差旅 / 制度',
time: '2024-05-12 14:35',
version: 'v3.2',
state: '已生效',
stateTone: 'success',
owner: '张明',
icon: 'mdi mdi-file-document-outline-pdf pdf',
fileType: 'pdf',
fileTypeLabel: 'PDF 预览',
summary: '面向员工与财务共享团队的差旅费用标准、审批边界和附件要求。',
previewPages: [
{
title: '差旅报销管理办法2024版',
subtitle: '住宿、交通、审批与附件要求',
stats: [
{ label: '适用范围', value: '全员' },
{ label: '生效日期', value: '2024-05-12' },
{ label: '更新重点', value: '住宿标准' }
],
blocks: [
{
heading: '一、适用范围',
lines: ['适用于国内差旅申请、预订、报销与借款冲销。', '共享中心审核以出差申请、票据与预算中心为准。']
},
{
heading: '二、住宿标准',
lines: ['一线城市单晚标准 650 元,超标需附业务说明。', '连续住宿超过 3 晚需补充行程与客户拜访记录。']
}
]
},
{
title: '审批与附件要求',
subtitle: '流程节点与必要凭证',
stats: [
{ label: '附件校验', value: '7 项' },
{ label: '审批节点', value: '4 级' },
{ label: '自动拦截', value: '超标 / 重复' }
],
blocks: [
{
heading: '三、审批规则',
lines: ['直属主管审批通过后进入财务复核。', '超预算或超标申请需追加部门负责人审批。']
},
{
heading: '四、附件清单',
lines: ['机票行程单、酒店发票、住宿水单、出租车发票。', '如存在改签、退票或异常情况,需补充说明材料。']
}
]
}
]
},
{
name: '发票查验规范及操作指引',
folder: '发票管理',
tag: '发票 / 操作',
time: '2024-05-10 10:22',
version: 'v1.5',
state: '已生效',
stateTone: 'success',
owner: '李娜',
icon: 'mdi mdi-file-document-outline-word word',
fileType: 'word',
fileTypeLabel: 'Word 预览',
summary: '说明发票验真路径、异常票据处理方式以及入账留痕要求。',
previewPages: [
{
title: '发票查验规范及操作指引',
subtitle: '验真流程与异常识别',
stats: [
{ label: '查验入口', value: '3 个' },
{ label: '异常类型', value: '6 类' },
{ label: '责任角色', value: '财务专员' }
],
blocks: [
{
heading: '一、查验入口',
lines: ['优先通过税务查验接口进行自动验真。', '无法自动识别时转人工核验并保留截图。']
},
{
heading: '二、异常票据',
lines: ['票面抬头不一致、号码重复、跨月补录需重点标注。', '出现红冲票据时需关联原单据并补充说明。']
}
]
}
]
},
{
name: '费用报销标准细则2024',
folder: '报销制度',
tag: '报销 / 标准',
time: '2024-05-08 09:16',
version: 'v2.1',
state: '已生效',
stateTone: 'success',
owner: '王磊',
icon: 'mdi mdi-file-document-outline-pdf pdf',
fileType: 'pdf',
fileTypeLabel: 'PDF 预览',
summary: '定义招待、差旅、办公采购与培训等费用类型的标准与限制。',
previewPages: [
{
title: '费用报销标准细则2024',
subtitle: '费用口径与报销边界',
stats: [
{ label: '费用大类', value: '8 类' },
{ label: '更新日期', value: '2024-05-08' },
{ label: '重点事项', value: '招待 / 交通' }
],
blocks: [
{
heading: '一、业务招待',
lines: ['需填写客户单位、参与人数及招待事由。', '单次超过 2000 元需上传审批邮件或会议纪要。']
},
{
heading: '二、交通与差旅',
lines: ['市内交通按真实票据报销,超标部分需说明。', '夜间出行或跨城交通需关联出差申请。']
}
]
}
]
},
{
name: '差旅费用标准对照表(国内)',
folder: '差旅规范',
tag: '差旅 / 标准',
time: '2024-05-05 08:20',
version: 'v1.3',
state: '审批中',
stateTone: 'warning',
owner: '陈杰',
icon: 'mdi mdi-file-document-outline-excel excel',
fileType: 'excel',
fileTypeLabel: 'Excel 预览',
summary: '各城市住宿、餐补与交通等级对照表,供申请与审核环节快速查询。',
previewPages: [
{
title: '差旅费用标准对照表(国内)',
subtitle: '城市维度对照',
stats: [
{ label: '覆盖城市', value: '48 个' },
{ label: '住宿档位', value: '4 级' },
{ label: '餐补标准', value: '日维度' }
],
blocks: [
{
heading: '一、住宿标准',
lines: ['北京 / 上海 / 深圳650 元 / 晚。', '新一线城市500 元 / 晚,其余城市按 380 元 / 晚执行。']
},
{
heading: '二、交通等级',
lines: ['总监及以上可乘坐高铁商务座或机票公务舱。', '其他员工默认经济舱、高铁二等座。']
}
]
}
]
},
{
name: '借款管理办法及流程',
folder: '财务共享',
tag: '借款 / 流程',
time: '2024-05-03 11:05',
version: 'v1.0',
state: '已生效',
stateTone: 'success',
owner: '刘洋',
icon: 'mdi mdi-file-document-outline-pdf pdf',
fileType: 'pdf',
fileTypeLabel: 'PDF 预览',
summary: '覆盖差旅借款、项目借款和借款冲销的全流程要求。',
previewPages: [
{
title: '借款管理办法及流程',
subtitle: '借款申请与冲销闭环',
stats: [
{ label: '适用场景', value: '差旅 / 项目' },
{ label: '冲销时限', value: '30 天' },
{ label: '审批路径', value: '3 级' }
],
blocks: [
{
heading: '一、借款申请',
lines: ['借款申请需绑定预算中心与费用类型。', '超过 5000 元需部门负责人额外审批。']
},
{
heading: '二、冲销要求',
lines: ['借款发生后 30 日内完成报销与冲销。', '逾期未冲销将纳入月度风险提醒。']
}
]
}
]
}
]
const filteredFolders = computed(() => {
const key = folderSearch.value.trim()
if (!key) return folders
return folders.filter((folder) => folder.name.includes(key))
})
const filteredDocuments = computed(() =>
documents.filter((doc) => {
const inFolder = activeFolder.value ? doc.folder === activeFolder.value : true
return inFolder
})
)
return {
folderSearch,
activeFolder,
selectedDocument,
folders,
documents,
filteredFolders,
filteredDocuments
}
}
}

View File

@@ -0,0 +1,116 @@
import { computed, ref, watch } from 'vue'
export default {
name: 'RequestsView',
props: {
filteredRequests: { type: Array, required: true }
},
emits: ['ask', 'approve', 'reject', 'create-request'] ,
setup(props, { emit }) {
const activeTab = ref('全部')
const tabs = ['全部', '待提交', '审批中', '待出行', '已完成']
const filters = ['报销状态', '出差城市', '费用类型']
const datePopover = ref(false)
const rangeStart = ref('')
const rangeEnd = ref('')
const appliedStart = ref('')
const appliedEnd = ref('')
const dateRangeLabel = computed(() => {
if (appliedStart.value && appliedEnd.value) return `${appliedStart.value} ~ ${appliedEnd.value}`
return '选择时间段'
})
function applyDateRange() {
if (!rangeStart.value || !rangeEnd.value) return
appliedStart.value = rangeStart.value
appliedEnd.value = rangeEnd.value
datePopover.value = false
}
const rows = [
{ id: 'BR240715001', reason: '华东区域客户拜访', city: '上海、苏州、杭州', period: '07-14~07-17 (4天)', applyTime: '2024-07-13', amount: '¥4,280.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '待预订酒店', travelTone: 'warning' },
{ id: 'BR240714010', reason: '年度战略合作伙伴会议', city: '北京', period: '07-15~07-16 (2天)', applyTime: '2024-07-12', amount: '¥1,860.00', node: '待提交', approval: '待提交', approvalTone: 'info', travel: '待订机票', travelTone: 'warning' },
{ id: 'BR240713008', reason: '产品培训与交流', city: '深圳', period: '07-10~07-12 (3天)', applyTime: '2024-07-09', amount: '¥2,150.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240712001', reason: '客户方案汇报', city: '上海', period: '07-08~07-11 (4天)', applyTime: '2024-07-07', amount: '¥3,680.00', node: '财务审核', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240711005', reason: '华南区域市场调研', city: '广州、佛山', period: '07-09~07-11 (3天)', applyTime: '2024-07-06', amount: '¥1,920.00', node: '待提交', approval: '待提交', approvalTone: 'info', travel: '无需预订', travelTone: 'neutral' },
{ id: 'BR240710003', reason: '供应商现场考察', city: '东莞', period: '07-06~07-07 (2天)', applyTime: '2024-07-05', amount: '¥680.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240709005', reason: '客户方案汇报', city: '北京', period: '07-06~07-08 (3天)', applyTime: '2024-07-05', amount: '¥1,980.00', node: '财务审核', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240708012', reason: '供应商现场考察', city: '广州', period: '07-04~07-05 (2天)', applyTime: '2024-07-03', amount: '¥860.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '无需预订', travelTone: 'neutral' },
{ id: 'BR240707003', reason: '项目启动会', city: '成都', period: '07-01~07-03 (3天)', applyTime: '2024-06-29', amount: '¥2,420.00', node: '财务审核', approval: '审批中', approvalTone: 'info', travel: '待预订酒店', travelTone: 'warning' },
{ id: 'BR240706009', reason: '客户拜访与市场调研', city: '南京、合肥', period: '06-28~06-30 (3天)', applyTime: '2024-06-26', amount: '¥1,750.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240705007', reason: '技术交流会', city: '武汉', period: '06-25~06-26 (2天)', applyTime: '2024-06-23', amount: '¥1,120.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '无需预订', travelTone: 'neutral' },
{ id: 'BR240704004', reason: '渠道合作洽谈', city: '西安', period: '06-20~06-21 (2天)', applyTime: '2024-06-18', amount: '¥780.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240703011', reason: '新员工入职培训', city: '长沙', period: '06-18~06-19 (2天)', applyTime: '2024-06-16', amount: '¥920.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240702006', reason: '季度业绩复盘会', city: '杭州', period: '06-15~06-16 (2天)', applyTime: '2024-06-13', amount: '¥1,350.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240701002', reason: '智慧金融峰会参展', city: '上海', period: '06-12~06-14 (3天)', applyTime: '2024-06-10', amount: '¥5,680.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240630009', reason: '西南区域渠道拓展', city: '重庆、贵阳', period: '06-10~06-13 (4天)', applyTime: '2024-06-08', amount: '¥3,450.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240629003', reason: '信息安全合规审计', city: '深圳', period: '06-08~06-09 (2天)', applyTime: '2024-06-06', amount: '¥1,180.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '无需预订', travelTone: 'neutral' },
{ id: 'BR240628007', reason: '产学研合作对接', city: '南京', period: '06-05~06-07 (3天)', applyTime: '2024-06-03', amount: '¥2,260.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240627001', reason: 'ERP系统上线支持', city: '青岛', period: '06-03~06-05 (3天)', applyTime: '2024-06-01', amount: '¥1,960.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240626004', reason: '大客户续约洽谈', city: '天津', period: '06-01~06-02 (2天)', applyTime: '2024-05-29', amount: '¥890.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240625010', reason: '区域销售团队建设', city: '厦门', period: '05-28~05-30 (3天)', applyTime: '2024-05-26', amount: '¥2,780.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240624002', reason: '供应链管理系统演示', city: '苏州', period: '05-25~05-26 (2天)', applyTime: '2024-05-23', amount: '¥650.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '无需预订', travelTone: 'neutral' },
{ id: 'BR240623008', reason: '行业白皮书发布会', city: '北京', period: '05-22~05-23 (2天)', applyTime: '2024-05-20', amount: '¥1,560.00', node: '待提交', approval: '待提交', approvalTone: 'info', travel: '待订机票', travelTone: 'warning' },
{ id: 'BR240622005', reason: '跨部门协同工作坊', city: '大连', period: '05-20~05-22 (3天)', applyTime: '2024-05-18', amount: '¥2,340.00', node: '待提交', approval: '待提交', approvalTone: 'info', travel: '无需预订', travelTone: 'neutral' },
{ id: 'BR240621003', reason: '数字化转型的客户交流', city: '深圳、珠海', period: '05-16~05-18 (3天)', applyTime: '2024-05-14', amount: '¥3,120.00', node: '待提交', approval: '待提交', approvalTone: 'info', travel: '待预订酒店', travelTone: 'warning' },
{ id: 'BR240620006', reason: '年中预算评审会', city: '上海', period: '05-13~05-14 (2天)', applyTime: '2024-05-11', amount: '¥1,480.00', node: '财务审核', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240619001', reason: '医疗行业解决方案展', city: '成都', period: '05-10~05-12 (3天)', applyTime: '2024-05-08', amount: '¥3,860.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240618009', reason: '东北区域客户回访', city: '沈阳、长春', period: '05-06~05-09 (4天)', applyTime: '2024-05-04', amount: '¥4,520.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '待订机票', travelTone: 'warning' },
{ id: 'BR240617007', reason: '大数据平台技术对接', city: '杭州', period: '05-03~05-05 (3天)', applyTime: '2024-05-01', amount: '¥2,180.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240616004', reason: '国际业务合规培训', city: '北京', period: '04-28~04-30 (3天)', applyTime: '2024-04-26', amount: '¥2,960.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' }
]
const currentPage = ref(1)
const pageSize = ref(10)
const pageSizes = [10, 20, 50]
const pageSizeOpen = ref(false)
function changePageSize(size) {
pageSize.value = size
pageSizeOpen.value = false
currentPage.value = 1
}
const filteredRows = computed(() => {
if (activeTab.value === '全部') return rows
return rows.filter((row) => row.approval === activeTab.value || row.travel.includes(activeTab.value.replace('待出行', '待订')))
})
const totalCount = computed(() => filteredRows.value.length)
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
const visibleRows = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredRows.value.slice(start, start + pageSize.value)
})
watch(activeTab, () => { currentPage.value = 1 })
return {
emit,
activeTab,
tabs,
filters,
datePopover,
rangeStart,
rangeEnd,
appliedStart,
appliedEnd,
dateRangeLabel,
applyDateRange,
rows,
currentPage,
pageSize,
pageSizes,
pageSizeOpen,
changePageSize,
filteredRows,
totalCount,
totalPages,
visibleRows
}
}
}

View File

@@ -0,0 +1,443 @@
import { computed, nextTick, onMounted, ref } from 'vue'
export default {
name: 'TravelReimbursementCreateView',
props: {
initialPrompt: {
type: String,
default: ''
},
entrySource: {
type: String,
default: 'requests'
},
requestContext: {
type: Object,
default: null
}
},
emits: ['close'] ,
setup(props, { emit }) {
const DEFAULT_REQUEST = {
id: 'BR240712001',
reason: '客户方案汇报',
city: '上海',
period: '07-08 ~ 07-11',
applyTime: '2024-07-07',
amount: '¥3,680.00',
node: '财务复核',
approval: '主管审批中',
travel: '已订酒店 / 机票'
}
const SOURCE_LABELS = {
workbench: '来自个人工作台',
topbar: '来自发起报销',
detail: '来自智能录入',
upload: '来自附件上传',
requests: '来自报销列表'
}
let messageSeed = 0
const fileInputRef = ref(null)
const messageListRef = ref(null)
const composerDraft = ref('')
const attachedFiles = ref([])
const messages = ref([])
const currentInsight = ref({
intent: 'welcome',
confidence: 0,
title: '',
summary: '',
welcome: { cards: [] }
})
const linkedRequest = computed(() => sanitizeRequest(props.requestContext))
const sourceLabel = computed(() => SOURCE_LABELS[props.entrySource] ?? '来自 AI 工作台')
const canSubmit = computed(() => Boolean(composerDraft.value.trim() || attachedFiles.value.length))
const showInsightPanel = computed(() => currentInsight.value.intent !== 'welcome')
const composerPlaceholder = computed(() => {
if (props.entrySource === 'detail') {
return `例如:帮我看一下 ${linkedRequest.value.id} 现在到哪个审批节点,或者补充超标说明。`
}
return '例如:帮我发起差旅报销、查一下审批节点,或者识别我刚上传的票据。'
})
const currentIntentLabel = computed(() => {
const labels = {
welcome: '等待输入',
draft: '报销草稿',
approval: '审批查询',
recognition: '单据识别',
note: '补充说明'
}
return labels[currentInsight.value.intent] ?? 'AI 处理中'
})
const shortcuts = computed(() => [
{ label: '查审批节点', icon: 'mdi mdi-timeline-clock-outline', prompt: `帮我看一下 ${linkedRequest.value.id} 现在到哪个审批节点了` },
{ label: '识别上传单据', icon: 'mdi mdi-file-search-outline', prompt: '我上传了几张票据,帮我识别并给出录入结果' },
{ label: '补充报销说明', icon: 'mdi mdi-text-box-edit-outline', prompt: `帮我给 ${linkedRequest.value.id} 补一段费用说明` },
{ label: '生成报销草稿', icon: 'mdi mdi-file-document-edit-outline', prompt: '我要发起一笔差旅费申请报销,请帮我先生成草稿' }
])
messages.value = [
createMessage(
'assistant',
buildGreeting(),
[]
)
]
onMounted(() => {
currentInsight.value = buildWelcomeInsight()
if (props.initialPrompt?.trim()) {
composerDraft.value = props.initialPrompt.trim()
submitComposer()
} else {
nextTick(scrollToBottom)
}
})
function sanitizeRequest(request) {
if (!request) return { ...DEFAULT_REQUEST }
return {
id: request.id ?? DEFAULT_REQUEST.id,
reason: request.reason ?? DEFAULT_REQUEST.reason,
city: request.city ?? DEFAULT_REQUEST.city,
period: request.period ?? DEFAULT_REQUEST.period,
applyTime: request.applyTime ?? DEFAULT_REQUEST.applyTime,
amount: request.amount ?? DEFAULT_REQUEST.amount,
node: request.node ?? DEFAULT_REQUEST.node,
approval: request.approval ?? DEFAULT_REQUEST.approval,
travel: request.travel ?? DEFAULT_REQUEST.travel
}
}
function buildGreeting() {
if (props.entrySource === 'detail') {
return `已进入统一对话工作台,当前关联单据 ${linkedRequest.value.id}。你可以直接问审批节点、补充说明,或继续上传票据。`
}
return '这里是统一对话入口。你可以直接发起报销、查询审批节点,或者上传单据让我识别。'
}
function buildWelcomeInsight() {
return {
intent: 'welcome',
confidence: 86,
title: props.entrySource === 'detail' ? `已关联 ${linkedRequest.value.id}` : '先告诉我你要处理什么',
summary: props.entrySource === 'detail'
? '右侧会跟随你的提问切换成审批状态、识别结果或补充说明界面。'
: '无论是发起报销、查审批还是识别票据,这里都共用一个对话入口。',
welcome: {
cards: [
{ icon: 'mdi mdi-timeline-clock-outline', title: '审批查询', desc: '识别到审批、节点、状态等意图时,右侧切到流程状态。' },
{ icon: 'mdi mdi-file-search-outline', title: '票据识别', desc: '上传附件后展示识别结果、建议金额和缺失材料。' },
{ icon: 'mdi mdi-text-box-check-outline', title: '补充说明', desc: '补充超标、夜间交通、业务招待等说明时,右侧给出结构化备注。' }
]
}
}
}
function createMessage(role, text, attachments = []) {
messageSeed += 1
return {
id: `msg-${messageSeed}`,
role,
text,
attachments,
time: nowTime()
}
}
function nowTime() {
return new Date().toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
function triggerFileUpload() {
fileInputRef.value?.click()
}
function handleFilesChange(event) {
attachedFiles.value = Array.from(event.target.files ?? [])
}
function runShortcut(prompt) {
composerDraft.value = prompt
submitComposer()
}
function submitComposer() {
if (!canSubmit.value) return
const rawText = composerDraft.value.trim()
const fileNames = attachedFiles.value.map((file) => file.name)
const userText = rawText || `我上传了 ${fileNames.length} 份单据,请帮我识别并录入。`
messages.value.push(createMessage('user', userText, fileNames))
const insight = analyzeIntent(userText, fileNames)
currentInsight.value = insight
messages.value.push(createMessage('assistant', insight.reply))
composerDraft.value = ''
attachedFiles.value = []
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
nextTick(scrollToBottom)
}
function scrollToBottom() {
if (!messageListRef.value) return
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
}
function analyzeIntent(text, files) {
if (isRecognitionIntent(text, files)) return buildRecognitionInsight(text, files)
if (isApprovalIntent(text)) return buildApprovalInsight(text)
if (isNoteIntent(text)) return buildNoteInsight(text)
return buildDraftInsight(text, files)
}
function isRecognitionIntent(text, files) {
return files.length > 0 || /(上传|附件|票据|发票|单据|识别|ocr)/i.test(text)
}
function isApprovalIntent(text) {
return /(审批|节点|状态|进度|流程|卡在哪|到哪了|通过了吗|驳回)/.test(text)
}
function isNoteIntent(text) {
return /(说明|备注|补充|原因|超标|夜间|特殊情况|备注一下)/.test(text)
}
function buildApprovalInsight(text) {
const requestId = extractRequestId(text) || linkedRequest.value.id
const timeline = [
{ label: '提交申请', time: `${linkedRequest.value.applyTime} 09:18`, state: 'done' },
{ label: '票据识别', time: `${linkedRequest.value.applyTime} 09:22`, state: 'done' },
{ label: '直属主管审批', time: '今天 10:46', state: 'done' },
{ label: linkedRequest.value.node, time: '进行中', state: 'current' },
{ label: '归档入账', time: '待处理', state: 'pending' }
]
return {
intent: 'approval',
confidence: 95,
title: `${requestId} 的审批状态`,
summary: `当前在 ${linkedRequest.value.node},右侧已经切到流程状态界面。`,
reply: `我识别到你是在查询审批节点。${requestId} 当前处于 ${linkedRequest.value.node},下一步预计由财务在今天 17:30 前处理。`,
status: {
requestId,
currentStatus: linkedRequest.value.approval,
currentNode: linkedRequest.value.node,
nextOwner: '财务共享中心 · 王敏',
eta: '今天 17:30 前',
timeline,
actions: [
'若 17:30 后仍未推进,可提醒财务共享中心处理。',
'当前不建议重复提交,避免流程串单。',
'如果要补充说明,直接在当前对话里继续输入即可。'
]
}
}
}
function buildRecognitionInsight(text, files) {
const requestId = extractRequestId(text) || linkedRequest.value.id
const receipts = buildReceiptItems(text, files)
const total = receipts.reduce((sum, item) => sum + parseCurrency(item.amount), 0)
const amount = formatCurrency(total || guessAmount(text) || 3680)
const completeness = files.length >= 2 ? '资料较完整' : '仍需补件'
return {
intent: 'recognition',
confidence: files.length ? 97 : 90,
title: '已切换到单据识别视图',
summary: `识别到 ${receipts.length} 条候选费用,建议关联到 ${requestId}`,
reply: `我识别到你是在上传或识别单据。右侧已经展示识别结果、建议金额和缺失材料。`,
recognition: {
state: files.length ? '识别完成' : '待补附件',
requestId,
fileCount: Math.max(files.length, 1),
amount,
completeness,
receipts,
suggestions: [
files.length ? '可直接生成费用明细草稿。' : '建议补传票据原件,识别结果会更稳定。',
'金额和费用分类已经给出,确认后即可写入报销单。',
'如果有多张单据属于同一行程,可以继续上传,右侧会合并结果。'
]
}
}
}
function buildNoteInsight(text) {
const requestId = extractRequestId(text) || linkedRequest.value.id
const noteType = /超标|夜间/.test(text) ? '特殊场景说明' : '补充报销说明'
const generatedNote = /超标|夜间/.test(text)
? '因客户会议结束较晚,产生夜间交通费用,已保留行程截图与打车凭证,申请按实际发生金额报销。'
: '本次费用与客户现场沟通及方案汇报直接相关,单据与行程已对应关联,请按当前草稿继续流转。'
return {
intent: 'note',
confidence: 93,
title: `${requestId} 的补充说明`,
summary: `识别到你是在补充备注,右侧切到说明整理界面。`,
reply: `我识别到你是在补充说明。右侧已经生成结构化备注,可直接作为对应单号的附加说明。`,
note: {
requestId,
state: noteType,
generatedNote,
impacts: [
'会同步显示给当前审批节点处理人。',
'若涉及超标或夜间交通,审批意见会优先查看这段说明。',
'继续补充金额、参与人或业务背景时,我会自动更新说明版本。'
],
owner: linkedRequest.value.node,
nextAction: '继续补充或提交当前说明'
}
}
}
function buildDraftInsight(text, files) {
const requestId = linkedRequest.value.id
const items = buildDraftItems(text, files)
const total = items.reduce((sum, item) => sum + parseCurrency(item.amount), 0)
return {
intent: 'draft',
confidence: 91,
title: '已切换到报销草稿视图',
summary: '识别到你是在发起报销或继续填写草稿,右侧展示当前建议明细。',
reply: '我识别到你是在发起或继续整理报销。右侧已经切到草稿视图,展示建议费用明细和待补信息。',
draft: {
state: files.length ? '可继续完善' : '草稿已生成',
requestId,
type: inferDraftType(text),
amount: formatCurrency(total || guessAmount(text) || 3280),
progress: files.length ? '已录入基础信息' : '待补票据',
items,
missing: [
'补充至少一份原始票据或行程截图。',
'确认出差事由、城市和发生日期是否完整。',
'如有业务招待或特殊交通,请补充关联说明。'
]
}
}
}
function extractRequestId(text) {
return text.match(/BR\d{6,}/i)?.[0] ?? ''
}
function inferDraftType(text) {
if (/招待|客户|用餐/.test(text)) return '业务招待报销'
if (/交通|打车|高铁|机票/.test(text)) return '交通费用报销'
return '差旅费申请报销'
}
function buildDraftItems(text, files) {
const items = []
if (/高铁|火车|车票/.test(text)) {
items.push({ name: '高铁 / 火车票', desc: '建议录入为城际交通', amount: '¥236.00', tag: '交通' })
}
if (/机票|航班/.test(text)) {
items.push({ name: '机票', desc: '建议录入为航空出行', amount: '¥1,280.00', tag: '交通' })
}
if (/酒店|住宿/.test(text)) {
items.push({ name: '酒店住宿', desc: '建议录入为住宿费用', amount: '¥780.00', tag: '住宿' })
}
if (/打车|出租车|网约车/.test(text)) {
items.push({ name: '市内交通', desc: '建议合并同日打车订单', amount: '¥126.00', tag: '交通' })
}
if (/餐|招待|客户/.test(text)) {
items.push({ name: '业务招待', desc: '建议补充参与人和业务目的', amount: '¥860.00', tag: '招待' })
}
if (!items.length) {
items.push({
name: '差旅综合费用',
desc: files.length ? '已根据附件生成候选明细' : '根据描述先生成一版草稿',
amount: files.length ? '¥3,280.00' : '¥2,680.00',
tag: '草稿'
})
}
return items
}
function buildReceiptItems(text, files) {
if (files.length) {
return files.map((file, index) => {
const type = inferFileType(file, text, index)
const baseAmount = guessAmount(file) || guessAmount(text) || (index + 1) * 180 + 120
return {
name: file,
type,
amount: formatCurrency(baseAmount),
confidence: `${92 - index}%`
}
})
}
return buildDraftItems(text, files).map((item, index) => ({
name: item.name,
type: item.tag,
amount: item.amount,
confidence: `${94 - index}%`
}))
}
function inferFileType(fileName, text, index) {
const name = `${fileName} ${text}`
if (/酒店|住宿/.test(name)) return '住宿单据'
if (/机票|航班/.test(name)) return '航空出行'
if (/高铁|火车|车票/.test(name)) return '城际交通'
if (/打车|出租车|网约车/.test(name)) return '市内交通'
return index === 0 ? '费用主票据' : '补充附件'
}
function guessAmount(text) {
const match = String(text).match(/(\d+(?:\.\d{1,2})?)/)
return match ? Number.parseFloat(match[1]) : 0
}
function parseCurrency(value) {
return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0
}
function formatCurrency(value) {
return `¥${Number(value).toFixed(2)}`
}
return {
emit,
fileInputRef,
messageListRef,
composerDraft,
attachedFiles,
messages,
currentInsight,
linkedRequest,
sourceLabel,
canSubmit,
showInsightPanel,
composerPlaceholder,
currentIntentLabel,
shortcuts,
triggerFileUpload,
handleFilesChange,
runShortcut,
submitComposer
}
}
}

View File

@@ -0,0 +1,451 @@
import { computed, ref } from 'vue'
export default {
name: 'TravelRequestDetailView',
props: {
request: {
type: Object,
default: () => ({})
}
},
emits: ['backToRequests', 'openAssistant'] ,
setup(props, { emit }) {
const expandedExpenseId = ref(null)
const aiEntryOpen = ref(false)
const aiDraft = ref('')
const aiFileInput = ref(null)
const aiEntrySeed = ref(2)
const pendingAiExpense = ref(null)
const uploadedAiFiles = ref([])
const expenseItems = ref([
{
id: 'exp-1',
time: '07-08',
dayLabel: '第 1 天',
name: '高铁票',
category: '交通',
desc: '上海虹桥 -> 杭州东',
detail: '客户方案汇报前往现场',
amount: '¥236.00',
status: '规则通过',
tone: 'ok',
attachmentStatus: '2 份附件',
attachmentHint: '车票 + 行程单',
attachmentTone: 'ok',
attachments: ['高铁票.pdf', '行程单.pdf'],
riskLabel: '规则通过',
riskText: '票据与行程匹配',
riskTone: 'low'
},
{
id: 'exp-2',
time: '07-09',
dayLabel: '第 2 天',
name: '酒店住宿',
category: '住宿',
desc: '杭州西湖商务酒店',
detail: '1 晚住宿,含早餐',
amount: '¥1,180.00',
status: '待补材料',
tone: 'bad',
attachmentStatus: '缺 1 份',
attachmentHint: '缺少入住清单',
attachmentTone: 'partial',
attachments: ['酒店发票.jpg'],
riskLabel: '待补材料',
riskText: '需补酒店入住清单',
riskTone: 'medium'
},
{
id: 'exp-3',
time: '07-10',
dayLabel: '第 3 天',
name: '出租车',
category: '市内交通',
desc: '客户公司往返酒店',
detail: '含夜间打车 2 次',
amount: '¥128.00',
status: '需说明',
tone: 'bad',
attachmentStatus: '3 份附件',
attachmentHint: '发票已上传',
attachmentTone: 'ok',
attachments: ['出租车发票1.jpg', '出租车发票2.jpg', '打车订单.png'],
riskLabel: '超标说明',
riskText: '1 笔夜间交通需补充说明',
riskTone: 'medium'
},
{
id: 'exp-4',
time: '07-11',
dayLabel: '第 4 天',
name: '餐补',
category: '补贴',
desc: '差旅餐补',
detail: '按 4 天标准自动计算',
amount: '¥320.00',
status: '规则通过',
tone: 'ok',
attachmentStatus: '系统生成',
attachmentHint: '无需上传附件',
attachmentTone: 'neutral',
attachments: [],
riskLabel: '规则通过',
riskText: '补贴标准校验通过',
riskTone: 'low'
}
])
const request = computed(() => ({
id: props.request?.id ?? 'BR240712001',
reason: props.request?.reason ?? '客户方案汇报',
city: props.request?.city ?? '上海',
period: props.request?.period ?? '07-08~07-11 (4天)',
applyTime: props.request?.applyTime ?? '2024-07-07',
amount: props.request?.amount ?? '¥3,680.00',
node: props.request?.node ?? '财务审核',
approval: props.request?.approval ?? '审批中',
approvalTone: props.request?.approvalTone ?? 'info',
travel: props.request?.travel ?? '已订酒店/机票',
travelTone: props.request?.travelTone ?? 'low'
}))
const profile = {
name: '张晓明',
department: '财务管理员',
avatar: '张'
}
const summaryItems = [
{ label: '出差城市', value: request.value.city, icon: 'mdi mdi-map-marker-path' },
{ label: '出差区间', value: request.value.period, icon: 'mdi mdi-clock-outline' },
{ label: '票据关联', value: '6 条明细 / 5 份材料', icon: 'mdi mdi-file-document-multiple-outline' },
{ label: '商旅状态', value: request.value.travel, icon: 'mdi mdi-airplane' },
{ label: '出差事由', value: request.value.reason, icon: 'mdi mdi-briefcase-outline' }
]
const heroSummaryItems = computed(() => [
{ label: '单号', value: request.value.id, icon: 'mdi mdi-pound-box-outline' },
{ label: '申请类型', value: '差旅费申请/报销', icon: 'mdi mdi-tag-multiple' },
...summaryItems
])
const currentProgressRingMotion = {
initial: {
scale: 1,
opacity: 0.34,
},
enter: {
scale: [1, 1.42, 1.78],
opacity: [0.34, 0.16, 0],
transition: {
duration: 3.2,
repeat: Infinity,
repeatType: 'loop',
repeatDelay: 0.85,
ease: 'easeOut',
times: [0, 0.5, 1],
},
},
}
const progressSteps = computed(() => {
return [
{ index: 1, label: '提交申请', time: '07-11 08:46', done: true, active: true },
{ index: 2, label: '票据识别', time: '07-11 08:48', done: true, active: true },
{ index: 3, label: '费用归类', time: '07-11 08:49', done: true, active: true },
{ index: 4, label: '部门负责人审批', time: '07-11 11:28', done: true, active: true },
{ index: 5, label: '财务审批', time: '进行中', active: true, current: true },
{ index: 6, label: '归档入账', time: '待处理' }
]
})
const expenseTotal = computed(() => {
const total = expenseItems.value.reduce((sum, item) => sum + parseCurrency(item.amount), 0)
return formatCurrency(total)
})
const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length)
const canSendAiEntry = computed(() => Boolean(aiDraft.value.trim() || uploadedAiFiles.value.length))
const detailNote = '本次出差用于客户方案汇报与现场沟通,需覆盖往返交通、住宿及市内交通费用。已完成主要票据上传,待补酒店入住清单后即可进入完整审批流程。'
function toggleExpenseAttachments(id) {
expandedExpenseId.value = expandedExpenseId.value === id ? null : id
}
function showExpenseRisk(item) {
return Boolean(item.riskText)
}
function openAiEntry() {
aiEntryOpen.value = false
emit('openAssistant', {
source: 'detail',
prompt: '',
request: request.value
})
}
function closeAiEntry() {
aiEntryOpen.value = false
aiDraft.value = ''
pendingAiExpense.value = null
uploadedAiFiles.value = []
if (aiFileInput.value) {
aiFileInput.value.value = ''
}
}
function parseCurrency(value) {
return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0
}
function formatCurrency(value) {
return `${value.toFixed(2)}`
}
function buildNextExpenseId() {
aiEntrySeed.value += 1
return `exp-ai-${aiEntrySeed.value}`
}
function inferExpenseCategory(text) {
if (/高铁|火车|机票|航班|打车|出租车|地铁|公交|交通/.test(text)) return '交通'
if (/酒店|住宿|房费/.test(text)) return '住宿'
if (/餐|午饭|晚饭|早餐|餐补/.test(text)) return '餐饮'
return '其他'
}
function inferExpenseName(text, category) {
if (/高铁/.test(text)) return '高铁票'
if (/机票|航班/.test(text)) return '机票'
if (/出租车|打车/.test(text)) return '出租车'
if (/酒店|住宿/.test(text)) return '酒店住宿'
if (/餐补/.test(text)) return '餐补'
if (/餐|午饭|晚饭|早餐/.test(text)) return '餐饮'
return `${category}费用`
}
function inferAttachments(text, uploadedFiles = []) {
if (uploadedFiles.length) {
return {
status: `${uploadedFiles.length} 份附件`,
hint: uploadedFiles.map((file) => file.name).join(' + '),
tone: 'ok',
files: uploadedFiles.map((file) => file.name),
}
}
if (/无需|免附件|系统生成/.test(text)) {
return {
status: '系统生成',
hint: '无需上传附件',
tone: 'neutral',
files: [],
}
}
const uploaded = /已上传|上传了|附上|附件/.test(text)
const receipt = /发票/.test(text)
const itinerary = /行程单/.test(text)
const ticket = /车票|机票/.test(text)
const hotelList = /入住清单/.test(text)
const files = []
if (receipt) files.push('发票.jpg')
if (itinerary) files.push('行程单.pdf')
if (ticket && !files.includes('票据.pdf')) files.push('票据.pdf')
if (hotelList) files.push('入住清单.pdf')
if (uploaded || files.length) {
return {
status: `${Math.max(files.length, 1)} 份附件`,
hint: files.length ? files.join(' + ') : '已上传附件待识别',
tone: 'ok',
files: files.length ? files : ['附件1.jpg'],
}
}
return {
status: '缺 1 份',
hint: '待补上传票据原件',
tone: 'missing',
files: [],
}
}
function inferRisk(text, attachmentTone) {
if (/夜间|超标|说明/.test(text)) {
return {
status: '需说明',
tone: 'bad',
riskLabel: '超标说明',
riskText: '识别到特殊场景,建议补充费用说明',
riskTone: 'medium',
}
}
if (attachmentTone === 'missing' || attachmentTone === 'partial') {
return {
status: '待补材料',
tone: 'bad',
riskLabel: '待补材料',
riskText: '附件不完整,需补齐后再提交审批',
riskTone: 'medium',
}
}
return {
status: '规则通过',
tone: 'ok',
riskLabel: '规则通过',
riskText: 'AI 识别通过,字段已结构化',
riskTone: 'low',
}
}
function extractDateLabel(text) {
const match = text.match(/(\d{1,2})月(\d{1,2})日|(\d{1,2})[-/.](\d{1,2})/)
if (!match) {
return { time: '07-12', dayLabel: `${expenseItems.value.length + 1}` }
}
const month = String(match[1] || match[3] || '07').padStart(2, '0')
const day = String(match[2] || match[4] || '12').padStart(2, '0')
return { time: `${month}-${day}`, dayLabel: `${expenseItems.value.length + 1}` }
}
function extractAmount(text) {
const match = text.match(/(\d+(?:\.\d{1,2})?)\s*元/)
return formatCurrency(Number.parseFloat(match?.[1] || '0'))
}
function buildAiExpense(text) {
const category = inferExpenseCategory(text)
const name = inferExpenseName(text, category)
const dateInfo = extractDateLabel(text)
const attachments = inferAttachments(text, uploadedAiFiles.value)
const risk = inferRisk(text, attachments.tone)
return {
id: buildNextExpenseId(),
time: dateInfo.time,
dayLabel: dateInfo.dayLabel,
name,
category,
desc: text.slice(0, 24),
detail: text,
amount: extractAmount(text),
status: risk.status,
tone: risk.tone,
attachmentStatus: attachments.status,
attachmentHint: attachments.hint,
attachmentTone: attachments.tone,
attachments: attachments.files,
riskLabel: risk.riskLabel,
riskText: risk.riskText,
riskTone: risk.riskTone,
}
}
const aiMessages = ref([
{
id: 'ai-msg-1',
role: 'assistant',
text: '请直接描述费用场景、日期、金额和是否已上传票据,我会整理成费用明细。',
},
])
function sendAiEntry() {
const text = aiDraft.value.trim() || `已上传 ${uploadedAiFiles.value.length} 份单据,请根据附件识别费用。`
if (!text && !uploadedAiFiles.value.length) return
aiMessages.value.push({
id: `ai-msg-user-${Date.now()}`,
role: 'user',
text: uploadedAiFiles.value.length ? `${text}\n附件:${uploadedAiFiles.value.map((file) => file.name).join('、')}` : text,
})
pendingAiExpense.value = buildAiExpense(text)
aiMessages.value.push({
id: `ai-msg-assistant-${Date.now()}`,
role: 'assistant',
text: `已识别为 ${pendingAiExpense.value.name},金额 ${pendingAiExpense.value.amount},可直接加入费用明细。`,
})
aiDraft.value = ''
}
function regenerateAiEntry() {
if (!pendingAiExpense.value) return
const sourceText = pendingAiExpense.value.detail
pendingAiExpense.value = buildAiExpense(sourceText.replace('待补上传票据原件', '已上传发票'))
aiMessages.value.push({
id: `ai-msg-regenerate-${Date.now()}`,
role: 'assistant',
text: '已重新整理识别结果,你可以继续确认后加入费用明细。',
})
}
function applyAiExpense() {
if (!pendingAiExpense.value) return
expenseItems.value.push({ ...pendingAiExpense.value })
expandedExpenseId.value = pendingAiExpense.value.id
aiMessages.value.push({
id: `ai-msg-apply-${Date.now()}`,
role: 'assistant',
text: '该费用条目已加入下方费用明细表。',
})
pendingAiExpense.value = null
aiDraft.value = ''
uploadedAiFiles.value = []
if (aiFileInput.value) {
aiFileInput.value.value = ''
}
aiEntryOpen.value = false
}
function triggerAiUpload() {
aiFileInput.value?.click()
}
function handleAiFilesChange(event) {
const files = Array.from(event.target.files ?? [])
uploadedAiFiles.value = files
}
return {
emit,
expandedExpenseId,
aiEntryOpen,
aiDraft,
aiFileInput,
aiEntrySeed,
pendingAiExpense,
uploadedAiFiles,
expenseItems,
request,
profile,
summaryItems,
heroSummaryItems,
currentProgressRingMotion,
progressSteps,
expenseTotal,
uploadedExpenseCount,
canSendAiEntry,
detailNote,
toggleExpenseAttachments,
showExpenseRisk,
openAiEntry,
closeAiEntry,
aiMessages,
sendAiEntry,
regenerateAiEntry,
applyAiExpense,
triggerAiUpload,
handleAiFilesChange
}
}
}

86
web/start.sh Normal file
View File

@@ -0,0 +1,86 @@
#!/usr/bin/env bash
set -euo pipefail
# ============================================================
# X-Financial Reimbursement Admin - Start Script
# ============================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
# ----------------------------------------------------------
# Check Node.js
# ----------------------------------------------------------
if ! command -v node &>/dev/null; then
error "Node.js is not installed. Install it first: https://nodejs.org"
fi
if ! command -v npm &>/dev/null; then
error "npm is not installed. It should come with Node.js."
fi
info "Node.js $(node -v) | npm $(npm -v)"
# ----------------------------------------------------------
# WSL on a Windows-mounted repo should reuse Windows Node
# ----------------------------------------------------------
is_wsl() {
grep -qi microsoft /proc/version 2>/dev/null
}
is_windows_mount() {
case "$SCRIPT_DIR" in
/mnt/*) return 0 ;;
*) return 1 ;;
esac
}
if is_wsl && is_windows_mount && command -v powershell.exe &>/dev/null && command -v wslpath &>/dev/null; then
WIN_PATH="$(wslpath -w "$SCRIPT_DIR")"
WIN_PATH_PS="${WIN_PATH//\'/\'\'}"
info "Detected WSL on a Windows-mounted project"
info "Using Windows npm to avoid cross-platform node_modules installs"
info "Access: http://127.0.0.1:5173"
echo ""
exec powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Set-Location -LiteralPath '$WIN_PATH_PS'; npm start"
fi
# ----------------------------------------------------------
# Install dependencies only when they are missing or unusable
# ----------------------------------------------------------
dependencies_ready() {
[ -d "node_modules" ] || return 1
[ -f "node_modules/vite/bin/vite.js" ] || return 1
[ -e "node_modules/.bin/vite" ] || [ -e "node_modules/.bin/vite.cmd" ] || return 1
node -e "require('rollup')" >/dev/null 2>&1
}
if ! dependencies_ready; then
warn "Dependencies are missing or incomplete"
info "Running npm install..."
npm install
if ! dependencies_ready; then
error "Dependencies are still incomplete after npm install. Try deleting node_modules and running npm install manually."
fi
fi
# ----------------------------------------------------------
# Start dev server
# ----------------------------------------------------------
info "Starting X-Financial Reimbursement Admin..."
info "Access: http://127.0.0.1:5173"
echo ""
exec npm start