feat: refactor monolithic App.vue into modular Vue component architecture

- Extract 711-line App.vue into 15+ focused files across 5 directories
- Add data layer (icons, metrics, policies, auditTrail, requests)
- Add composables (useNavigation, useRequests, useChat, useToast)
- Add layout components (SidebarRail, TopBar, FilterBar)
- Add shared components (PanelHead, InfoRow, ToastNotification)
- Add business component (RequestTable) and 5 view components
- Extract global CSS to assets/styles/global.css
- Add start.sh with WSL/Windows cross-platform support
- Add .gitignore for node_modules, dist, and IDE dirs
This commit is contained in:
2026-04-28 17:20:52 +08:00
commit 7141e1d11a
40 changed files with 10133 additions and 0 deletions

122
src/App.vue Normal file
View File

@@ -0,0 +1,122 @@
<template>
<div class="app">
<SidebarRail
:nav-items="navItems"
:active-view="activeView"
@navigate="setView"
@open-chat="openChat"
/>
<main class="main">
<TopBar
:current-view="currentView"
:search="search"
@update:search="search = $event"
@batch-approve="toast('已筛出 23 个低风险单据可进入批量通过确认')"
@open-chat="openChat"
/>
<FilterBar
v-if="activeView !== 'chat'"
:filters="filters"
:ranges="ranges"
:active-range="activeRange"
@update:active-range="activeRange = $event"
/>
<section class="workarea">
<OverviewView
v-if="activeView === 'overview'"
:filtered-requests="filteredRequests"
@ask="openChat"
@approve="handleApprove"
@reject="handleReject"
/>
<ChatView
v-else-if="activeView === 'chat'"
ref="chatViewRef"
:messages="messages"
:uploaded-files="uploadedFiles"
:active-case="activeCase"
:quick-prompts="prompts"
:draft="draft"
:message-list="messageList"
@send="sendMessage"
@upload="handleUpload"
@draft="draft = $event"
@approve-case="toast(`${activeCase?.id} 已生成通过意见`)"
@reject-case="toast(`${activeCase?.id} 已转人工复核`)"
/>
<RequestsView
v-else-if="activeView === 'requests'"
:filtered-requests="filteredRequests"
@ask="openChat"
@approve="handleApprove"
@reject="handleReject"
/>
<PoliciesView v-else-if="activeView === 'policies'" />
<AuditView v-else />
</section>
</main>
<ToastNotification :toast-text="toastText" />
</div>
</template>
<script setup>
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 OverviewView from './views/OverviewView.vue'
import ChatView from './views/ChatView.vue'
import RequestsView from './views/RequestsView.vue'
import PoliciesView from './views/PoliciesView.vue'
import AuditView from './views/AuditView.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'
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 } = useChat(activeView)
const { toastText, toast } = useToast()
function handleApprove(request) {
const msg = approveRequest(request)
toast(msg)
}
function handleReject(request) {
const msg = rejectRequest(request)
toast(msg)
}
</script>
<style scoped>
.app {
min-height: 100dvh;
display: grid;
grid-template-columns: 76px minmax(0, 1fr);
background:
radial-gradient(circle at 22% -12%, rgba(51,92,255,.10), transparent 34%),
linear-gradient(180deg, #fff 0, var(--bg) 260px);
}
.main { min-width: 0; display: grid; grid-template-rows: auto auto minmax(0, 1fr); }
.workarea { overflow: auto; padding: 22px 28px 34px; }
@media (max-width: 1180px) {
.app { grid-template-columns: 72px minmax(0, 1fr); }
}
@media (max-width: 760px) {
.app { display: block; }
}
</style>

View File

@@ -0,0 +1,94 @@
:root {
--bg: #f6f8fb;
--surface: #fff;
--surface-soft: #f9fbff;
--ink: #101828;
--text: #344054;
--muted: #667085;
--line: #e4e7ec;
--line-strong: #d0d5dd;
--primary: #335cff;
--primary-soft: #eef3ff;
--success: #0e9384;
--success-soft: #e7f8f5;
--warning: #b54708;
--warning-soft: #fff4e5;
--danger: #b42318;
--danger-soft: #ffebe9;
--nav: #0b1220;
--nav-muted: #7d89a5;
--radius: 8px;
--ease: cubic-bezier(.2, .8, .2, 1);
font-family: Inter, "SF Pro Display", "Segoe UI", "PingFang SC", "Microsoft YaHei", Arial, sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100dvh; color: var(--text); background: var(--bg); }
button, input, select, textarea { font: inherit; }
button { cursor: pointer; }
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 3px solid rgba(51,92,255,.24); outline-offset: 2px; }
.eyebrow { color: var(--primary); font-size: 11px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
h1, h2, h3, p { margin: 0; }
h1 { margin-top: 4px; color: var(--ink); font-size: clamp(25px, 3vw, 36px); line-height: 1.1; }
.btn {
min-height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 14px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
color: var(--text);
font-weight: 700;
transition: transform 180ms var(--ease), box-shadow 180ms var(--ease), background 180ms var(--ease), border-color 180ms var(--ease);
}
.btn:hover { transform: translateY(-1px); box-shadow: 0 10px 24px rgba(16,24,40,.08); }
.btn:active, .mini-btn:active, .chip:active, .nav-btn:active { transform: scale(.97); }
.btn.primary { border-color: transparent; background: var(--primary); color: #fff; box-shadow: 0 12px 24px rgba(51,92,255,.22); }
.btn.success { border-color: transparent; background: var(--success); color: #fff; }
.btn.danger { border-color: rgba(180,35,24,.18); background: var(--danger-soft); color: var(--danger); }
.btn.ghost { background: transparent; }
.badge { display: inline-flex; min-height: 26px; align-items: center; padding: 4px 9px; border-radius: 999px; background: var(--primary-soft); color: var(--primary); font-size: 12px; font-weight: 780; white-space: nowrap; }
.badge.success { background: var(--success-soft); color: var(--success); }
.badge.warning { background: var(--warning-soft); color: var(--warning); }
.badge.danger { background: var(--danger-soft); color: var(--danger); }
.panel {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
box-shadow: 0 1px 2px rgba(16,24,40,.04);
transition: transform 220ms var(--ease), box-shadow 220ms var(--ease), border-color 220ms var(--ease);
}
.panel:hover { transform: translateY(-2px); border-color: rgba(51,92,255,.22); box-shadow: 0 16px 42px rgba(16,24,40,.08); }
.mini-btn { min-height: 34px; padding: 0 10px; border: 1px solid var(--line); border-radius: 7px; background: #fff; color: var(--text); font-size: 12px; font-weight: 750; }
@keyframes grow { from { transform: scaleX(0); transform-origin: left; } to { transform: scaleX(1); transform-origin: left; } }
@keyframes fadeUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.message-list-enter-active, .message-list-leave-active { transition: opacity 220ms var(--ease), transform 220ms var(--ease); }
.message-list-enter-from { opacity: 0; transform: translateY(8px) scale(.98); }
.message-list-leave-to { opacity: 0; transform: translateY(-6px) scale(.98); }
@media (max-width: 1180px) {
.metric-strip, .overview-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 760px) {
.topbar { flex-direction: column; padding: 18px 16px; }
.top-actions, .search, .btn { width: 100%; }
.filters, .metric-strip, .overview-grid, .donut-layout, .dialog-body, .dialog-foot, .review-summary, .chat-hero { grid-template-columns: 1fr; }
.filters, .workarea { padding-inline: 16px; }
.bar-row { grid-template-columns: 1fr; gap: 6px; }
.bar-row strong { text-align: left; }
.case-panel { border-left: 0; border-top: 1px solid var(--line); }
.review-summary { grid-column: auto; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 1ms !important; transition-duration: 1ms !important; scroll-behavior: auto !important; }
}

View File

@@ -0,0 +1,63 @@
<template>
<article class="panel queue-panel" :class="{ expanded }">
<PanelHead eyebrow="Approval queue" title="待处理报销申请" note="可直接通过、退回,或把当前单据带入合规对话继续追问。" />
<div class="table-wrap">
<table>
<thead>
<tr>
<th v-for="col in columns" :key="col">{{ col }}</th>
</tr>
</thead>
<tbody>
<tr v-for="request in requests" :key="request.id">
<td>
<strong>{{ request.person }}</strong>
<p>{{ request.dept }}</p>
</td>
<td>
<strong>{{ request.category }} · {{ request.amount }}</strong>
<p>{{ request.id }}</p>
</td>
<td>
<span class="badge" :class="request.status">{{ request.verdict }}</span>
</td>
<td>
<span class="badge" :class="request.sla.includes('51') ? 'danger' : ''">{{ request.sla }}</span>
</td>
<td>{{ request.risk }}</td>
<td>
<div class="row-actions">
<button class="mini-btn" @click="emit('approve', request)">通过</button>
<button class="mini-btn" @click="emit('ask', request)">询问 AI</button>
<button class="mini-btn" @click="emit('reject', request)">退回</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</article>
</template>
<script setup>
import PanelHead from '../shared/PanelHead.vue'
defineProps({
requests: { type: Array, required: true },
expanded: Boolean
})
const emit = defineEmits(['ask', 'approve', 'reject'])
const columns = ['申请人', '费用与金额', 'AI 结论', 'SLA', '关键风险', '操作']
</script>
<style scoped>
.queue-panel { padding: 20px; }
.table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius); }
table { width: 100%; min-width: 860px; border-collapse: collapse; }
th, td { padding: 14px 16px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: middle; }
th { background: var(--surface-soft); color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
td strong { color: var(--ink); }
td p { margin-top: 4px; color: var(--muted); font-size: 12px; }
.row-actions { display: flex; gap: 8px; flex-wrap: wrap; }
</style>

View File

@@ -0,0 +1,69 @@
<template>
<section class="filters" aria-label="筛选条件">
<label>
<span>法人主体</span>
<select v-model="filters.entity">
<option>全部主体</option>
<option>Northstar China Ltd.</option>
<option>Northstar Singapore Pte.</option>
<option>Northstar US Inc.</option>
</select>
</label>
<label>
<span>费用类型</span>
<select v-model="filters.category">
<option>全部费用</option>
<option>差旅交通</option>
<option>住宿</option>
<option>业务招待</option>
<option>办公采购</option>
</select>
</label>
<label>
<span>风险等级</span>
<select v-model="filters.risk">
<option>全部风险</option>
<option>高风险</option>
<option>需解释</option>
<option>低风险</option>
</select>
</label>
<div class="segmented" role="tablist" aria-label="处理视图">
<button
v-for="range in ranges"
:key="range"
:class="{ active: activeRange === range }"
type="button"
@click="emit('update:activeRange', range)"
>
{{ range }}
</button>
</div>
</section>
</template>
<script setup>
defineProps({
filters: { type: Object, required: true },
ranges: { type: Array, required: true },
activeRange: { type: String, required: true }
})
const emit = defineEmits(['update:activeRange'])
</script>
<style scoped>
.filters {
display: grid;
grid-template-columns: repeat(3, minmax(180px, 1fr)) auto;
gap: 14px;
padding: 16px 28px;
border-bottom: 1px solid var(--line);
background: var(--surface);
}
.filters label { display: grid; gap: 6px; color: var(--muted); font-size: 12px; font-weight: 700; }
.filters select { height: 42px; padding: 0 12px; border: 1px solid var(--line); border-radius: var(--radius); background: #fff; color: var(--ink); }
.segmented { align-self: end; display: inline-grid; grid-auto-flow: column; gap: 4px; min-height: 42px; padding: 4px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface-soft); }
.segmented button { min-height: 32px; padding: 0 13px; border: 0; border-radius: 6px; background: transparent; color: var(--muted); font-weight: 700; }
.segmented button.active { background: #fff; color: var(--ink); box-shadow: 0 1px 2px rgba(16,24,40,.08); }
</style>

View File

@@ -0,0 +1,70 @@
<template>
<aside class="rail" aria-label="主导航">
<div class="mark">RO</div>
<nav class="rail-nav">
<button
v-for="item in navItems"
:key="item.id"
class="nav-btn"
:class="{ active: activeView === item.id }"
type="button"
:aria-label="item.label"
:title="item.label"
@click="emit('navigate', item.id)"
>
<span v-html="item.icon"></span>
</button>
</nav>
<button class="nav-btn muted" type="button" aria-label="打开合规对话" title="打开合规对话" @click="emit('openChat')">
<span v-html="messageIcon"></span>
</button>
</aside>
</template>
<script setup>
import { icons } from '../../data/icons.js'
defineProps({
navItems: { type: Array, required: true },
activeView: { type: String, required: true }
})
const emit = defineEmits(['navigate', 'openChat'])
const messageIcon = icons.message
</script>
<style scoped>
.rail {
position: sticky;
top: 0;
height: 100dvh;
display: grid;
grid-template-rows: auto 1fr auto;
gap: 18px;
padding: 18px 12px;
background: var(--nav);
color: #fff;
z-index: 20;
}
.mark { width: 48px; height: 48px; display: grid; place-items: center; border-radius: 14px; background: linear-gradient(135deg,#fff,#9db2ff); color: #10215c; font-weight: 850; }
.rail-nav { display: grid; gap: 10px; align-content: start; }
.nav-btn {
width: 48px;
min-height: 48px;
display: grid;
place-items: center;
border: 0;
border-radius: 14px;
background: transparent;
color: var(--nav-muted);
transition: background 160ms var(--ease), color 160ms var(--ease), transform 160ms var(--ease);
}
.nav-btn:hover, .nav-btn.active { background: rgba(255,255,255,.1); color: #fff; transform: translateY(-1px); }
.nav-btn svg { width: 18px; height: 18px; stroke: currentColor; stroke-width: 2; fill: none; stroke-linecap: round; stroke-linejoin: round; }
@media (max-width: 760px) {
.rail { position: sticky; height: auto; grid-template-columns: auto 1fr auto; grid-template-rows: none; padding: 10px 12px; }
.rail-nav { display: flex; overflow-x: auto; }
}
</style>

View File

@@ -0,0 +1,56 @@
<template>
<header class="topbar">
<div>
<div class="eyebrow">Global finance operations</div>
<h1>{{ currentView.title }}</h1>
<p>{{ currentView.desc }}</p>
</div>
<div class="top-actions">
<label class="search">
<span v-html="searchIcon"></span>
<input :value="search" type="search" placeholder="搜索申请人、单号、费用类型" @input="emit('update:search', $event.target.value)" />
</label>
<button class="btn" type="button" @click="emit('batchApprove')">
<span v-html="checkIcon"></span>
批量通过
</button>
<button class="btn primary" type="button" @click="emit('openChat')">
<span v-html="messageIcon"></span>
合规对话
</button>
</div>
</header>
</template>
<script setup>
import { icons } from '../../data/icons.js'
defineProps({
currentView: { type: Object, required: true },
search: { type: String, default: '' }
})
const emit = defineEmits(['update:search', 'batchApprove', 'openChat'])
const searchIcon = icons.search
const checkIcon = icons.check
const messageIcon = icons.message
</script>
<style scoped>
.topbar {
display: flex;
justify-content: space-between;
gap: 24px;
padding: 22px 28px;
border-bottom: 1px solid var(--line);
background: rgba(255,255,255,.9);
backdrop-filter: blur(14px);
}
.topbar p { margin-top: 6px; color: var(--muted); font-size: 13px; }
.top-actions { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: wrap; }
.search { position: relative; min-width: 300px; }
.search span { position: absolute; left: 13px; top: 50%; width: 18px; height: 18px; transform: translateY(-50%); color: var(--muted); }
.search span svg { width: 18px; height: 18px; stroke: currentColor; stroke-width: 2; fill: none; stroke-linecap: round; stroke-linejoin: round; }
.search input { width: 100%; height: 42px; padding: 0 14px 0 40px; border: 1px solid var(--line); border-radius: var(--radius); }
</style>

View File

@@ -0,0 +1,27 @@
<template>
<div class="info-row">
<div class="rank">{{ rank }}</div>
<div>
<strong>{{ title }}</strong>
<p>{{ note }}</p>
</div>
<span class="badge" :class="tone">{{ badge }}</span>
</div>
</template>
<script setup>
defineProps({
rank: String,
title: String,
note: String,
badge: String,
tone: String
})
</script>
<style scoped>
.info-row { display: grid; grid-template-columns: auto 1fr auto; gap: 14px; align-items: start; padding: 16px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface-soft); }
.rank { min-width: 34px; height: 34px; display: grid; place-items: center; border-radius: 8px; background: #fff; color: var(--primary); font-size: 12px; font-weight: 850; box-shadow: inset 0 0 0 1px var(--line); }
.info-row strong { color: var(--ink); }
.info-row p { margin-top: 4px; color: var(--muted); }
</style>

View File

@@ -0,0 +1,21 @@
<template>
<div class="panel-head">
<div class="eyebrow">{{ eyebrow }}</div>
<h2>{{ title }}</h2>
<p>{{ note }}</p>
</div>
</template>
<script setup>
defineProps({
eyebrow: String,
title: String,
note: String
})
</script>
<style scoped>
.panel-head { margin-bottom: 18px; }
.panel-head h2 { margin-top: 4px; color: var(--ink); font-size: 20px; }
.panel-head p { margin-top: 6px; color: var(--muted); font-size: 13px; }
</style>

View File

@@ -0,0 +1,31 @@
<template>
<Transition name="toast">
<div v-if="toastText" class="toast" role="status" aria-live="polite">{{ toastText }}</div>
</Transition>
</template>
<script setup>
defineProps({
toastText: String
})
</script>
<style scoped>
.toast {
position: fixed;
right: 22px;
bottom: 22px;
max-width: min(380px, calc(100vw - 44px));
padding: 14px 16px;
border-radius: 12px;
background: #0b1220;
color: #fff;
box-shadow: 0 18px 48px rgba(16,24,40,.16);
z-index: 120;
animation: fadeUp 180ms var(--ease) both;
}
.toast-enter-active { transition: opacity 180ms var(--ease), transform 180ms var(--ease); }
.toast-leave-active { transition: opacity 160ms var(--ease), transform 160ms var(--ease); }
.toast-enter-from { opacity: 0; transform: translateY(10px); }
.toast-leave-to { opacity: 0; transform: translateY(-6px); }
</style>

View File

@@ -0,0 +1,63 @@
import { nextTick, ref } from 'vue'
import { initialMessages, prompts } from '../data/requests.js'
export function useChat(activeView) {
const messages = ref([...initialMessages])
const draft = ref('')
const uploadedFiles = ref([])
const messageList = ref(null)
const activeCase = ref(null)
function agentReply(text) {
const c = activeCase.value
if (!c) return '建议先核对政策阈值和附件完整性,再决定通过、退回补件或转人工复核。'
if (text.includes('审批')) return `${c.id} 建议审批意见:发票验真通过,费用归属与预算中心匹配;${c.risk} 已触发规则提示,建议保留业务说明后通过。`
if (text.includes('补件')) return '补件优先级:业务目的说明、行程或客户名单、直属经理确认记录。'
if (text.includes('拦截')) return `拦截原因是 ${c.risk},该风险需要财务复核并留下制度依据。`
if (text.includes('审计')) return `审计摘要:${c.person} 提交 ${c.amount} 报销,命中 ${c.risk},系统已保留 AI 判断。`
return '建议先核对政策阈值和附件完整性,再决定通过、退回补件或转人工复核。'
}
function scrollToBottom() {
nextTick(() => messageList.value?.scrollTo({ top: messageList.value.scrollHeight, behavior: 'smooth' }))
}
function sendMessage() {
const text = draft.value.trim()
if (!text) return false
messages.value.push({ id: Date.now(), role: 'user', text })
draft.value = ''
setTimeout(() => {
messages.value.push({ id: Date.now() + 1, role: 'agent', text: agentReply(text) })
scrollToBottom()
}, 260)
return true
}
function handleUpload(event) {
uploadedFiles.value = Array.from(event.target.files ?? []).map((file) => ({
name: file.name,
size: file.size
}))
if (uploadedFiles.value.length) {
const names = uploadedFiles.value.map((file) => file.name).join('、')
messages.value.push({
id: Date.now(),
role: 'agent',
text: `已接收 ${uploadedFiles.value.length} 个附件:${names}。我会优先核对发票验真、费用标准、预算归属和必备审批材料。`
})
scrollToBottom()
}
}
function openChat(request) {
activeCase.value = request
activeView.value = 'chat'
nextTick(() => messageList.value?.scrollTo({ top: messageList.value.scrollHeight }))
}
return {
messages, draft, uploadedFiles, messageList, activeCase, prompts,
sendMessage, handleUpload, openChat, scrollToBottom
}
}

View File

@@ -0,0 +1,24 @@
import { computed, ref } from 'vue'
import { icons } from '../data/icons.js'
export const navItems = [
{ id: 'overview', label: '运营总览', icon: icons.dashboard, title: '企业报销智能运营台', desc: '面向财务共享中心的审批、风控、SLA 与智能体协同工作台。' },
{ id: 'chat', label: '合规对话', icon: icons.message, title: 'AI 合规对话', desc: '上传单据、追问制度依据,并生成可留痕的审核建议。' },
{ id: 'requests', label: '报销队列', icon: icons.list, title: '报销申请队列', desc: '按风险、补件状态和 AI 建议处理待审单据。' },
{ id: 'policies', label: '政策规则', icon: icons.file, title: '政策规则中心', desc: '维护差旅、招待、采购和发票校验规则。' },
{ id: 'audit', label: '审计追踪', icon: icons.audit, title: '审计追踪', desc: '查看关键审批动作、AI 建议和制度命中记录。' }
]
export function useNavigation() {
const activeView = ref('overview')
const currentView = computed(
() => navItems.find((item) => item.id === activeView.value) ?? navItems[0]
)
function setView(view) {
activeView.value = view
}
return { activeView, currentView, setView, navItems }
}

View File

@@ -0,0 +1,37 @@
import { computed, reactive, ref } from 'vue'
import { initialRequests } from '../data/requests.js'
export function useRequests() {
const requests = ref(initialRequests)
const search = ref('')
const filters = reactive({ entity: '全部主体', category: '全部费用', risk: '全部风险' })
const ranges = ['今日', '本周', '本月']
const activeRange = ref('今日')
const filteredRequests = computed(() => {
const key = search.value.trim().toLowerCase()
return requests.value.filter((item) => {
const matchesSearch = !key || `${item.id}${item.person}${item.category}${item.risk}`.toLowerCase().includes(key)
const matchesCategory = filters.category === '全部费用' || item.category.includes(filters.category.replace('交通', ''))
const matchesRisk = filters.risk === '全部风险' || (filters.risk === '高风险' ? item.status === 'danger' : item.verdict.includes(filters.risk.replace('低风险', '通过')))
return matchesSearch && matchesCategory && matchesRisk
})
})
function approveRequest(request) {
request.verdict = '已通过'
request.status = 'success'
return `${request.id} 已标记为通过,审计日志已更新。`
}
function rejectRequest(request) {
request.verdict = '已退回补件'
request.status = 'danger'
return `${request.id} 已退回,系统将通知申请人补充材料。`
}
return {
requests, search, filters, ranges, activeRange,
filteredRequests, approveRequest, rejectRequest
}
}

View File

@@ -0,0 +1,13 @@
import { ref } from 'vue'
export function useToast() {
const toastText = ref('')
function toast(text) {
toastText.value = text
clearTimeout(toast.timer)
toast.timer = setTimeout(() => { toastText.value = '' }, 3200)
}
return { toastText, toast }
}

5
src/data/auditTrail.js Normal file
View File

@@ -0,0 +1,5 @@
export const auditTrail = [
{ time: '09:40', title: '规则 A1 被财务复核放行', note: '保留会议说明并写入审批意见。', badge: '完成', tone: 'success' },
{ time: '09:18', title: '重复发票拦截', note: 'REQ-2026-0416 已转人工核查。', badge: '阻断', tone: 'danger' },
{ time: '08:52', title: '自动补件提醒发送', note: '11 位员工收到业务招待纪要提醒。', badge: '执行中', tone: 'primary' }
]

11
src/data/icons.js Normal file
View File

@@ -0,0 +1,11 @@
const iconPath = (content) => `<svg viewBox="0 0 24 24" aria-hidden="true">${content}</svg>`
export const icons = {
dashboard: iconPath('<path d="M3 13h8V3H3z"/><path d="M13 21h8V11h-8z"/><path d="M13 3h8v6h-8z"/><path d="M3 21h8v-6H3z"/>'),
list: iconPath('<path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/>'),
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"/>'),
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"/>'),
message: iconPath('<path d="M21 15a4 4 0 0 1-4 4H7l-4 4V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z"/>')
}

20
src/data/metrics.js Normal file
View File

@@ -0,0 +1,20 @@
export const metrics = [
{ label: '本月报销额', value: '¥1,286,400', delta: '+8.4%', note: '待审批 ¥361,600', color: '#335cff', tone: '' },
{ label: '平均处理周期', value: '18.6h', delta: '-2.1h', note: '业务补件等待下降', color: '#0e9384', tone: '' },
{ label: '超 SLA 单据', value: '37', delta: '需处理', note: '12 单超过 48 小时', color: '#f79009', tone: 'warn' },
{ label: '高风险拦截', value: '16', delta: '+5', note: '重复发票、异常供应商', color: '#d92d20', tone: 'bad' }
]
export const spendByCategory = [
{ name: '差旅交通', value: '¥390k', width: '92%', color: 'linear-gradient(90deg,#335cff,#6f8cff)' },
{ name: '住宿', value: '¥310k', width: '73%', color: 'linear-gradient(90deg,#0e9384,#56b8aa)' },
{ name: '业务招待', value: '¥240k', width: '57%', color: 'linear-gradient(90deg,#f79009,#ffb64d)' },
{ name: '办公采购', value: '¥180k', width: '42%', color: 'linear-gradient(90deg,#6941c6,#8d68de)' }
]
export const auditMix = [
{ name: '建议通过', value: '42%', color: '#335cff' },
{ name: '自动通过', value: '26%', color: '#0e9384' },
{ name: '需补件', value: '18%', color: '#f79009' },
{ name: '高风险', value: '14%', color: '#d92d20' }
]

5
src/data/policies.js Normal file
View File

@@ -0,0 +1,5 @@
export const policies = [
{ code: 'A1', title: '差旅住宿标准', note: '按城市、职级、会议峰值期动态判断。', badge: '启用', tone: 'success' },
{ code: 'A2', title: '发票查重与验真', note: '票号、税号、金额、抬头四重校验。', badge: '启用', tone: 'success' },
{ code: 'A3', title: '业务招待材料前置', note: '客户名单、拜访纪要、审批单缺一不可。', badge: '建议强化', tone: 'warning' }
]

14
src/data/requests.js Normal file
View File

@@ -0,0 +1,14 @@
export const initialRequests = [
{ id: 'REQ-2026-0418', person: '刘倩', dept: '销售 · 华东区域', category: '差旅报销', amount: '¥8,460', verdict: '可通过但需备注', status: 'warning', sla: '51h', risk: '住宿超标 17.4%' },
{ id: 'REQ-2026-0422', person: '韩阳', dept: '解决方案 · 北区', category: '业务招待', amount: '¥1,980', verdict: '等待补件', status: 'warning', sla: '22h', risk: '缺少客户拜访纪要' },
{ id: 'REQ-2026-0431', person: '王鑫', dept: '运营管理 · 总部', category: '通勤交通', amount: '¥1,224', verdict: '规则全通过', status: 'success', sla: '4h', risk: '无明显风险' },
{ id: 'REQ-2026-0436', person: '陈嘉', dept: '市场 · 品牌活动', category: '活动采购', amount: '¥12,680', verdict: '建议人工复核', status: 'danger', sla: '36h', risk: '供应商与历史黑名单相似' }
]
export const prompts = ['生成审批意见', '列出补件清单', '解释为什么拦截', '生成审计摘要']
export const initialMessages = [
{ id: 1, role: 'agent', text: '我已读取单据、发票、行程和公司差旅制度。当前建议:可通过,但需要保留会议说明。' },
{ id: 2, role: 'user', text: '请列出这张单据的主要风险。' },
{ id: 3, role: 'agent', text: '主要风险:住宿单晚均价超标准 17.4%,并且需要人工确认会议附件。' }
]

5
src/main.js Normal file
View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import { MotionPlugin } from '@vueuse/motion'
import App from './App.vue'
createApp(App).use(MotionPlugin).mount('#app')

31
src/views/AuditView.vue Normal file
View File

@@ -0,0 +1,31 @@
<template>
<section class="view single">
<article class="panel">
<PanelHead eyebrow="Audit trail" title="近期关键动作" note="保留审批判断、AI 建议和人工处理动作。" />
<div class="list">
<InfoRow
v-for="event in auditTrail"
:key="event.title"
:rank="event.time"
:title="event.title"
:note="event.note"
:badge="event.badge"
:tone="event.tone"
/>
</div>
</article>
</section>
</template>
<script setup>
import PanelHead from '../components/shared/PanelHead.vue'
import InfoRow from '../components/shared/InfoRow.vue'
import { auditTrail } from '../data/auditTrail.js'
</script>
<style scoped>
.view { display: grid; gap: 22px; animation: fadeUp 220ms var(--ease) both; }
.view.single { max-width: 1120px; }
.panel { padding: 20px; }
.list { display: grid; gap: 12px; }
</style>

167
src/views/ChatView.vue Normal file
View File

@@ -0,0 +1,167 @@
<template>
<section class="view chat-view">
<article class="panel chat-shell">
<header class="chat-hero">
<div>
<div class="eyebrow">Compliance conversation</div>
<h2>上传单据询问 AI 是否合规</h2>
<p>把发票行程单合同附件或审批说明放到同一个上下文里AI 会按制度预算和审计留痕给出建议</p>
</div>
<label class="upload-card">
<span class="upload-icon" v-html="fileIcon"></span>
<strong>上传单据</strong>
<small>PDF图片Excel 或压缩包</small>
<input type="file" multiple @change="emit('upload', $event)" />
</label>
</header>
<div class="upload-list" aria-live="polite">
<span v-for="file in uploadedFiles" :key="file.name" class="file-pill">{{ file.name }}</span>
<span v-if="!uploadedFiles.length" class="file-pill muted">尚未上传文件可直接选择现有报销单追问</span>
</div>
<div class="dialog-body chat-body">
<section
v-motion
class="review-summary"
aria-label="审核摘要"
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0, transition: { delay: 0.08, duration: 0.3 } }"
>
<div class="risk-ring"><strong>82</strong><span>可信分</span></div>
<div>
<h3>建议有条件通过</h3>
<p>发票验真与预算归属通过当前风险集中在 {{ activeCase?.risk }}</p>
<div class="summary-pills">
<span>制度命中 3</span>
<span>附件完整度 86%</span>
<span>SLA {{ activeCase?.sla }}</span>
</div>
</div>
</section>
<div class="messages" ref="messageList" aria-live="polite">
<TransitionGroup name="message-list">
<div v-for="message in messages" :key="message.id" class="message" :class="message.role">
<span>{{ message.role === 'user' ? 'Reviewer' : 'Finance AI' }}</span>
<p>{{ message.text }}</p>
</div>
</TransitionGroup>
</div>
<aside class="case-panel">
<h3>单据上下文</h3>
<dl>
<div><dt>单据编号</dt><dd>{{ activeCase?.id }}</dd></div>
<div><dt>申请人</dt><dd>{{ activeCase?.person }}</dd></div>
<div><dt>金额</dt><dd>{{ activeCase?.amount }}</dd></div>
<div><dt>风险点</dt><dd>{{ activeCase?.risk }}</dd></div>
</dl>
<h3>快捷追问</h3>
<div class="quick-prompts">
<button v-for="prompt in quickPrompts" :key="prompt" class="chip" type="button" @click="emit('draft', prompt)">{{ prompt }}</button>
</div>
</aside>
</div>
<footer class="dialog-foot chat-foot">
<textarea v-model="localDraft" placeholder="询问合规问题,例如:这张住宿发票是否超标?需要补哪些材料?" @keydown.ctrl.enter.prevent="emit('send')"></textarea>
<button class="btn success" type="button" @click="emit('approveCase')">通过</button>
<button class="btn danger" type="button" @click="emit('rejectCase')">转人工</button>
<button class="btn primary" type="button" @click="emit('send')">发送</button>
</footer>
</article>
</section>
</template>
<script setup>
import { icons } from '../data/icons.js'
const props = defineProps({
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', 'update:draft'])
const fileIcon = icons.file
import { computed } from 'vue'
const localDraft = computed({
get: () => props.draft,
set: (val) => emit('update:draft', val)
})
</script>
<style scoped>
.chat-view { max-width: 1280px; }
.view { display: grid; gap: 22px; animation: fadeUp 220ms var(--ease) both; }
.chat-shell { min-height: calc(100dvh - 210px); display: grid; grid-template-rows: auto auto minmax(420px, 1fr) auto; overflow: hidden; padding: 0; }
.chat-hero {
display: grid;
grid-template-columns: minmax(0,1fr) 260px;
gap: 18px;
align-items: stretch;
padding: 24px;
border-bottom: 1px solid var(--line);
background:
radial-gradient(circle at 12% 10%, rgba(51,92,255,.14), transparent 28%),
linear-gradient(135deg, #fff, #f7fbff);
}
.chat-hero h2 { margin-top: 4px; color: var(--ink); font-size: 28px; }
.chat-hero p { max-width: 780px; margin-top: 8px; color: var(--muted); line-height: 1.6; }
.upload-card {
position: relative;
min-height: 148px;
display: grid;
place-items: center;
align-content: center;
gap: 6px;
padding: 18px;
border: 1px dashed rgba(51,92,255,.36);
border-radius: var(--radius);
background: rgba(255,255,255,.72);
color: var(--text);
cursor: pointer;
transition: transform 180ms var(--ease), border-color 180ms var(--ease), box-shadow 180ms var(--ease);
}
.upload-card:hover { transform: translateY(-2px); border-color: var(--primary); box-shadow: 0 18px 42px rgba(51,92,255,.12); }
.upload-card input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.upload-icon { width: 42px; height: 42px; display: grid; place-items: center; border-radius: 12px; background: var(--primary-soft); color: var(--primary); }
.upload-icon svg { width: 20px; height: 20px; stroke: currentColor; stroke-width: 2; fill: none; }
.upload-card strong { color: var(--ink); }
.upload-card small { color: var(--muted); }
.upload-list { display: flex; flex-wrap: wrap; gap: 8px; padding: 14px 24px; border-bottom: 1px solid var(--line); background: #fff; }
.file-pill { min-height: 30px; display: inline-flex; align-items: center; padding: 0 10px; border-radius: 999px; background: var(--success-soft); color: var(--success); font-size: 12px; font-weight: 750; }
.file-pill.muted { background: var(--surface-soft); color: var(--muted); }
.dialog-body { min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr) 300px; grid-template-rows: auto minmax(0,1fr); }
.chat-body { border-bottom: 1px solid var(--line); }
.review-summary { grid-column: 1 / -1; display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 16px; padding: 18px 24px; border-bottom: 1px solid var(--line); background: #fff; }
.risk-ring { width: 82px; aspect-ratio: 1; display: grid; place-items: center; border-radius: 50%; background: radial-gradient(circle,#fff 0 55%,transparent 56%), conic-gradient(var(--success) 0 82%, #e4e7ec 82% 100%); }
.risk-ring strong { color: var(--ink); font-size: 24px; line-height: 1; }
.risk-ring span { color: var(--muted); font-size: 11px; }
.review-summary h3 { color: var(--ink); }
.review-summary p { margin-top: 5px; color: var(--muted); }
.summary-pills { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.summary-pills span { min-height: 28px; display: inline-flex; align-items: center; padding: 0 10px; border-radius: 999px; background: var(--primary-soft); color: var(--primary); font-size: 12px; font-weight: 750; }
.messages { min-height: 0; overflow: auto; display: grid; align-content: start; gap: 12px; padding: 22px 24px; background: linear-gradient(180deg,#fbfcff,#f6f8fb); }
.message { max-width: 82%; display: grid; gap: 6px; will-change: transform, opacity; }
.message.user { justify-self: end; }
.message span { color: var(--muted); font-size: 11px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
.message p { padding: 13px 15px; border: 1px solid var(--line); border-radius: 16px 16px 16px 6px; background: #fff; box-shadow: 0 8px 24px rgba(16,24,40,.05); }
.message.user p { border-color: transparent; border-radius: 16px 16px 6px 16px; background: linear-gradient(135deg,var(--primary),#2446d8); color: #fff; box-shadow: 0 14px 30px rgba(51,92,255,.20); }
.case-panel { overflow: auto; padding: 22px; border-left: 1px solid var(--line); background: rgba(255,255,255,.72); }
.case-panel h3 { margin: 0 0 12px; color: var(--ink); }
dl { margin: 0 0 18px; display: grid; gap: 10px; }
dl div { display: flex; justify-content: space-between; gap: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--line); }
dt { color: var(--muted); font-size: 12px; }
dd { margin: 0; color: var(--ink); font-weight: 750; text-align: right; }
.quick-prompts { display: flex; flex-wrap: wrap; gap: 8px; }
.chip { min-height: 34px; padding: 0 10px; border: 1px solid var(--line); border-radius: 999px; background: #fff; color: var(--text); font-size: 12px; font-weight: 700; transition: transform 160ms var(--ease), border-color 160ms var(--ease), background 160ms var(--ease); }
.chip:hover { border-color: rgba(51,92,255,.28); background: var(--primary-soft); color: var(--primary); }
.dialog-foot { display: grid; grid-template-columns: minmax(0,1fr) auto auto auto; gap: 10px; padding: 16px 20px; border-top: 1px solid var(--line); background: #fff; }
.chat-foot { border-top: 0; }
textarea { min-height: 48px; max-height: 116px; resize: vertical; padding: 12px; border: 1px solid var(--line); border-radius: var(--radius); }
</style>

View File

@@ -0,0 +1,94 @@
<template>
<section class="view">
<div class="metric-strip">
<article
v-for="(metric, index) in metrics"
:key="metric.label"
v-motion
class="metric"
:initial="{ opacity: 0, y: 16 }"
:enter="{ opacity: 1, y: 0, transition: { delay: index * 0.07, duration: 0.36 } }"
:style="{ '--accent': metric.color }"
>
<div class="metric-top">
<span>{{ metric.label }}</span>
<b :class="metric.tone">{{ metric.delta }}</b>
</div>
<strong>{{ metric.value }}</strong>
<small>{{ metric.note }}</small>
</article>
</div>
<div class="overview-grid">
<article class="panel spend-panel">
<PanelHead eyebrow="Category spend" title="费用类型月度支出" note="用企业报销常见科目展示本月费用压力。" />
<div class="bar-chart" role="img" aria-label="费用类型月度支出柱状图">
<div v-for="item in spendByCategory" :key="item.name" class="bar-row">
<span>{{ item.name }}</span>
<div class="bar-track"><div class="bar-fill" :style="{ width: item.width, background: item.color }"></div></div>
<strong>{{ item.value }}</strong>
</div>
</div>
</article>
<article class="panel">
<PanelHead eyebrow="Compliance mix" title="审核结论占比" note="把自动通过、需补件和高风险分层展示。" />
<div class="donut-layout">
<div class="donut" aria-label="审核结论环形图"><strong>68%</strong><span>可自动处理</span></div>
<div class="legend">
<div v-for="item in auditMix" :key="item.name" class="legend-row">
<i :style="{ background: item.color }"></i><span>{{ item.name }}</span><strong>{{ item.value }}</strong>
</div>
</div>
</div>
</article>
</div>
<RequestTable :requests="filteredRequests" @ask="emit('ask', $event)" @approve="emit('approve', $event)" @reject="emit('reject', $event)" />
</section>
</template>
<script setup>
import PanelHead from '../components/shared/PanelHead.vue'
import RequestTable from '../components/business/RequestTable.vue'
import { metrics, spendByCategory, auditMix } from '../data/metrics.js'
defineProps({
filteredRequests: { type: Array, required: true }
})
const emit = defineEmits(['ask', 'approve', 'reject'])
</script>
<style scoped>
.view { display: grid; gap: 22px; animation: fadeUp 220ms var(--ease) both; }
.metric-strip { display: grid; grid-template-columns: repeat(4, minmax(190px, 1fr)); gap: 16px; }
.metric {
min-height: 128px; padding: 18px; border-top: 3px solid var(--accent);
border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface);
box-shadow: 0 1px 2px rgba(16,24,40,.04);
transition: transform 220ms var(--ease), box-shadow 220ms var(--ease), border-color 220ms var(--ease);
}
.metric:hover { transform: translateY(-2px); border-color: rgba(51,92,255,.22); box-shadow: 0 16px 42px rgba(16,24,40,.08); }
.metric-top { display: flex; justify-content: space-between; gap: 10px; color: var(--muted); font-size: 12px; font-weight: 800; text-transform: uppercase; }
.metric-top b { padding: 4px 8px; border-radius: 999px; background: var(--success-soft); color: var(--success); font-size: 12px; }
.metric-top b.warn { background: var(--warning-soft); color: var(--warning); }
.metric-top b.bad { background: var(--danger-soft); color: var(--danger); }
.metric strong { display: block; margin-top: 16px; color: var(--ink); font-size: 30px; line-height: 1; font-variant-numeric: tabular-nums; }
.metric small { display: block; margin-top: 10px; color: var(--muted); }
.overview-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(360px, .65fr); gap: 22px; }
.spend-panel { padding: 20px; }
.bar-chart { display: grid; gap: 14px; }
.bar-row { display: grid; grid-template-columns: 94px minmax(0, 1fr) 70px; align-items: center; gap: 14px; font-weight: 700; }
.bar-track { height: 36px; overflow: hidden; border-radius: 7px; background: #f2f4f7; }
.bar-fill { height: 100%; border-radius: inherit; animation: grow 520ms var(--ease) both; }
.bar-row strong { color: var(--ink); text-align: right; font-variant-numeric: tabular-nums; }
.donut-layout { display: grid; grid-template-columns: 190px minmax(0, 1fr); align-items: center; gap: 20px; }
.donut { width: 174px; aspect-ratio: 1; display: grid; place-items: center; border-radius: 50%; background: radial-gradient(circle,#fff 0 52%,transparent 53%), conic-gradient(#335cff 0 42%,#0e9384 42% 68%,#f79009 68% 86%,#d92d20 86% 100%); }
.donut strong { display: block; color: var(--ink); font-size: 28px; text-align: center; }
.donut span { color: var(--muted); font-size: 12px; }
.legend { display: grid; gap: 10px; }
.legend-row { display: grid; grid-template-columns: 12px 1fr auto; align-items: center; gap: 10px; padding-bottom: 10px; border-bottom: 1px solid var(--line); }
.legend-row i { width: 12px; height: 12px; border-radius: 999px; }
.legend-row strong { color: var(--ink); }
</style>

View File

@@ -0,0 +1,31 @@
<template>
<section class="view single">
<article class="panel">
<PanelHead eyebrow="Policy automation" title="规则运行状态" note="把关键政策、阈值和命中表现集中维护。" />
<div class="list">
<InfoRow
v-for="policy in policies"
:key="policy.code"
:rank="policy.code"
:title="policy.title"
:note="policy.note"
:badge="policy.badge"
:tone="policy.tone"
/>
</div>
</article>
</section>
</template>
<script setup>
import PanelHead from '../components/shared/PanelHead.vue'
import InfoRow from '../components/shared/InfoRow.vue'
import { policies } from '../data/policies.js'
</script>
<style scoped>
.view { display: grid; gap: 22px; animation: fadeUp 220ms var(--ease) both; }
.view.single { max-width: 1120px; }
.panel { padding: 20px; }
.list { display: grid; gap: 12px; }
</style>

View File

@@ -0,0 +1,20 @@
<template>
<section class="view single">
<RequestTable :requests="filteredRequests" expanded @ask="emit('ask', $event)" @approve="emit('approve', $event)" @reject="emit('reject', $event)" />
</section>
</template>
<script setup>
import RequestTable from '../components/business/RequestTable.vue'
defineProps({
filteredRequests: { type: Array, required: true }
})
const emit = defineEmits(['ask', 'approve', 'reject'])
</script>
<style scoped>
.view { display: grid; gap: 22px; animation: fadeUp 220ms var(--ease) both; }
.view.single { max-width: 1120px; }
</style>