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:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,5 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
web/.vite/
|
||||
.omc/
|
||||
.omx/
|
||||
.claude/
|
||||
|
||||
22
README.md
Normal file
22
README.md
Normal 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
12
server/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Server
|
||||
|
||||
后端目录。
|
||||
|
||||
当前仓库还没有正式后端实现,这里先独立出 `server/`,后续服务端代码统一放在这里,避免再和前端工程混在根目录或 `web/` 里。
|
||||
|
||||
建议后续结构:
|
||||
|
||||
- `server/src/`:业务代码
|
||||
- `server/config/`:配置
|
||||
- `server/scripts/`:启动、迁移、初始化脚本
|
||||
- `server/tests/`:后端测试
|
||||
1
server/src/.gitkeep
Normal file
1
server/src/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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
@@ -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
83
start.sh
83
start.sh
@@ -1,86 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ============================================================
|
||||
# X-Financial Reimbursement Admin - Start Script
|
||||
# ============================================================
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
cd "$SCRIPT_DIR/web"
|
||||
|
||||
# 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
|
||||
exec ./start.sh
|
||||
|
||||
0
package-lock.json → web/package-lock.json
generated
0
package-lock.json → web/package-lock.json
generated
@@ -25,7 +25,8 @@
|
||||
'requests-main': activeView === 'requests',
|
||||
'approval-main': activeView === 'approval',
|
||||
'policies-main': activeView === 'policies',
|
||||
'audit-main': activeView === 'audit'
|
||||
'audit-main': activeView === 'audit',
|
||||
'employees-main': activeView === 'employees'
|
||||
}"
|
||||
>
|
||||
<TopBar
|
||||
@@ -44,7 +45,7 @@
|
||||
/>
|
||||
|
||||
<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'"
|
||||
:filters="filters"
|
||||
:ranges="ranges"
|
||||
@@ -59,7 +60,8 @@
|
||||
'requests-workarea': activeView === 'requests',
|
||||
'approval-workarea': activeView === 'approval',
|
||||
'policies-workarea': activeView === 'policies',
|
||||
'audit-workarea': activeView === 'audit'
|
||||
'audit-workarea': activeView === 'audit',
|
||||
'employees-workarea': activeView === 'employees'
|
||||
}"
|
||||
>
|
||||
<OverviewView
|
||||
@@ -113,7 +115,9 @@
|
||||
|
||||
<PoliciesView v-else-if="activeView === 'policies'" />
|
||||
|
||||
<AuditView v-else />
|
||||
<AuditView v-else-if="activeView === 'audit'" />
|
||||
|
||||
<EmployeeManagementView v-else />
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -147,170 +151,51 @@ 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'
|
||||
import { useAppShell } from './composables/useAppShell.js'
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
const {
|
||||
activeCase,
|
||||
activeRange,
|
||||
activeView,
|
||||
closeRequestDetail,
|
||||
closeSmartEntry,
|
||||
customRange,
|
||||
detailMode,
|
||||
docSearch,
|
||||
draft,
|
||||
filteredDocuments,
|
||||
filteredRequests,
|
||||
filters,
|
||||
handleApprove,
|
||||
handleLogin,
|
||||
handleNavigate,
|
||||
handleOpenChat,
|
||||
handleRecoverPassword,
|
||||
handleReject,
|
||||
handleSsoLogin,
|
||||
handleUpload,
|
||||
loggedIn,
|
||||
messageList,
|
||||
messages,
|
||||
navItems,
|
||||
openRequestDetail,
|
||||
openSmartEntry,
|
||||
openTravelCreate,
|
||||
ranges,
|
||||
search,
|
||||
selectedTravelRequest,
|
||||
sendMessage,
|
||||
smartEntryContext,
|
||||
smartEntryOpen,
|
||||
smartEntrySessionId,
|
||||
toast,
|
||||
toastText,
|
||||
topBarView,
|
||||
travelPrompts,
|
||||
uploadedFiles
|
||||
} = useAppShell()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.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>
|
||||
<style scoped src="./assets/styles/app.css"></style>
|
||||
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 937 KiB After Width: | Height: | Size: 937 KiB |
|
Before Width: | Height: | Size: 287 KiB After Width: | Height: | Size: 287 KiB |
49
web/src/assets/styles/app.css
Normal file
49
web/src/assets/styles/app.css
Normal 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; }
|
||||
}
|
||||
@@ -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-page {
|
||||
height: 100%;
|
||||
@@ -2466,4 +1714,3 @@ tbody tr:last-child td { border-bottom: 0; }
|
||||
.footer-right { width: 100%; }
|
||||
.action-btn { flex: 1; }
|
||||
}
|
||||
</style>
|
||||
676
web/src/assets/styles/views/audit-view.css
Normal file
676
web/src/assets/styles/views/audit-view.css
Normal 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;
|
||||
}
|
||||
}
|
||||
77
web/src/assets/styles/views/chat-view.css
Normal file
77
web/src/assets/styles/views/chat-view.css
Normal 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; }
|
||||
}
|
||||
658
web/src/assets/styles/views/employee-management-view.css
Normal file
658
web/src/assets/styles/views/employee-management-view.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
position: relative;
|
||||
min-height: 100dvh;
|
||||
@@ -852,4 +685,3 @@ const LogoMark = {
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
387
web/src/assets/styles/views/overview-view.css
Normal file
387
web/src/assets/styles/views/overview-view.css
Normal 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;
|
||||
}
|
||||
}
|
||||
643
web/src/assets/styles/views/policies-view.css
Normal file
643
web/src/assets/styles/views/policies-view.css
Normal 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;
|
||||
}
|
||||
}
|
||||
523
web/src/assets/styles/views/requests-view.css
Normal file
523
web/src/assets/styles/views/requests-view.css
Normal 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;
|
||||
}
|
||||
}
|
||||
756
web/src/assets/styles/views/travel-reimbursement-create-view.css
Normal file
756
web/src/assets/styles/views/travel-reimbursement-create-view.css
Normal 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;
|
||||
}
|
||||
}
|
||||
1164
web/src/assets/styles/views/travel-request-detail-view.css
Normal file
1164
web/src/assets/styles/views/travel-request-detail-view.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,7 @@
|
||||
<div class="assistant-input">
|
||||
<textarea
|
||||
v-model="assistantDraft"
|
||||
rows="2"
|
||||
rows="1"
|
||||
placeholder="例如:我昨天请客户吃饭花了 860 元,还打车去了客户公司"
|
||||
@keydown.ctrl.enter.prevent="openAssistantWithDraft"
|
||||
/>
|
||||
@@ -364,8 +364,8 @@ const policyItems = [
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: 68px;
|
||||
padding: 10px 10px 10px 14px;
|
||||
min-height: 52px;
|
||||
padding: 6px 8px 6px 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
@@ -375,14 +375,17 @@ const policyItems = [
|
||||
.assistant-input textarea {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
min-height: 42px;
|
||||
height: 24px;
|
||||
min-height: 24px;
|
||||
max-height: 24px;
|
||||
resize: none;
|
||||
border: 0;
|
||||
padding: 4px 0;
|
||||
padding: 1px 0;
|
||||
background: transparent;
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
line-height: 22px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.assistant-input textarea::placeholder {
|
||||
@@ -403,7 +406,7 @@ const policyItems = [
|
||||
}
|
||||
|
||||
.hero-action {
|
||||
height: 40px;
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
@@ -745,7 +748,10 @@ const policyItems = [
|
||||
}
|
||||
|
||||
.assistant-input textarea {
|
||||
min-height: 72px;
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
max-height: 40px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hero-action,
|
||||
@@ -56,7 +56,8 @@ const sidebarMeta = {
|
||||
approval: { label: '审批中心', badge: '12' },
|
||||
chat: { label: 'AI助手' },
|
||||
policies: { label: '知识管理' },
|
||||
audit: { label: '技能中心' }
|
||||
audit: { label: '技能中心' },
|
||||
employees: { label: '员工管理' }
|
||||
}
|
||||
|
||||
const decoratedNavItems = computed(() =>
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<header class="topbar" :class="{ 'chat-mode': isChat }">
|
||||
<div class="title-group">
|
||||
<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>
|
||||
</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 v-else-if="isApproval">
|
||||
169
web/src/composables/useAppShell.js
Normal file
169
web/src/composables/useAppShell.js
Normal 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
|
||||
}
|
||||
}
|
||||
36
web/src/composables/useLoginView.js
Normal file
36
web/src/composables/useLoginView.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,14 @@ export const navItems = [
|
||||
icon: icons.skill,
|
||||
title: '技能中心',
|
||||
desc: '统一管理技能的触发规则、提示词结构、输出约束与上线版本'
|
||||
},
|
||||
{
|
||||
id: 'employees',
|
||||
label: '员工管理',
|
||||
navHint: '员工档案、岗位与角色权限',
|
||||
icon: icons.users,
|
||||
title: '员工管理',
|
||||
desc: '集中维护员工基础信息、职级部门岗位,以及管理员、财务人员、使用者和高级管理人员等系统角色'
|
||||
}
|
||||
]
|
||||
|
||||
113
web/src/composables/useOverviewView.js
Normal file
113
web/src/composables/useOverviewView.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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"/>'),
|
||||
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"/>'),
|
||||
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"/>'),
|
||||
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"/>'),
|
||||
209
web/src/scripts/App.js
Normal file
209
web/src/scripts/App.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
494
web/src/views/ApprovalCenterView.vue
Normal file
494
web/src/views/ApprovalCenterView.vue
Normal 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
286
web/src/views/AuditView.vue
Normal 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
178
web/src/views/ChatView.vue
Normal 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>
|
||||
308
web/src/views/EmployeeManagementView.vue
Normal file
308
web/src/views/EmployeeManagementView.vue
Normal 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
154
web/src/views/LoginView.vue
Normal 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>
|
||||
149
web/src/views/OverviewView.vue
Normal file
149
web/src/views/OverviewView.vue
Normal 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>
|
||||
196
web/src/views/PoliciesView.vue
Normal file
196
web/src/views/PoliciesView.vue
Normal 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>
|
||||
127
web/src/views/RequestsView.vue
Normal file
127
web/src/views/RequestsView.vue
Normal 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>
|
||||
338
web/src/views/TravelReimbursementCreateView.vue
Normal file
338
web/src/views/TravelReimbursementCreateView.vue
Normal 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>
|
||||
317
web/src/views/TravelRequestDetailView.vue
Normal file
317
web/src/views/TravelRequestDetailView.vue
Normal 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>
|
||||
284
web/src/views/scripts/ApprovalCenterView.js
Normal file
284
web/src/views/scripts/ApprovalCenterView.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
249
web/src/views/scripts/AuditView.js
Normal file
249
web/src/views/scripts/AuditView.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
101
web/src/views/scripts/ChatView.js
Normal file
101
web/src/views/scripts/ChatView.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
202
web/src/views/scripts/EmployeeManagementView.js
Normal file
202
web/src/views/scripts/EmployeeManagementView.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
42
web/src/views/scripts/LoginView.js
Normal file
42
web/src/views/scripts/LoginView.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
130
web/src/views/scripts/OverviewView.js
Normal file
130
web/src/views/scripts/OverviewView.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
244
web/src/views/scripts/PoliciesView.js
Normal file
244
web/src/views/scripts/PoliciesView.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
116
web/src/views/scripts/RequestsView.js
Normal file
116
web/src/views/scripts/RequestsView.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
443
web/src/views/scripts/TravelReimbursementCreateView.js
Normal file
443
web/src/views/scripts/TravelReimbursementCreateView.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
451
web/src/views/scripts/TravelRequestDetailView.js
Normal file
451
web/src/views/scripts/TravelRequestDetailView.js
Normal 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
86
web/start.sh
Normal 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
|
||||
Reference in New Issue
Block a user