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:
122
src/App.vue
Normal file
122
src/App.vue
Normal 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>
|
||||
94
src/assets/styles/global.css
Normal file
94
src/assets/styles/global.css
Normal 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; }
|
||||
}
|
||||
63
src/components/business/RequestTable.vue
Normal file
63
src/components/business/RequestTable.vue
Normal 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>
|
||||
69
src/components/layout/FilterBar.vue
Normal file
69
src/components/layout/FilterBar.vue
Normal 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>
|
||||
70
src/components/layout/SidebarRail.vue
Normal file
70
src/components/layout/SidebarRail.vue
Normal 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>
|
||||
56
src/components/layout/TopBar.vue
Normal file
56
src/components/layout/TopBar.vue
Normal 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>
|
||||
27
src/components/shared/InfoRow.vue
Normal file
27
src/components/shared/InfoRow.vue
Normal 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>
|
||||
21
src/components/shared/PanelHead.vue
Normal file
21
src/components/shared/PanelHead.vue
Normal 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>
|
||||
31
src/components/shared/ToastNotification.vue
Normal file
31
src/components/shared/ToastNotification.vue
Normal 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>
|
||||
63
src/composables/useChat.js
Normal file
63
src/composables/useChat.js
Normal 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
|
||||
}
|
||||
}
|
||||
24
src/composables/useNavigation.js
Normal file
24
src/composables/useNavigation.js
Normal 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 }
|
||||
}
|
||||
37
src/composables/useRequests.js
Normal file
37
src/composables/useRequests.js
Normal 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
|
||||
}
|
||||
}
|
||||
13
src/composables/useToast.js
Normal file
13
src/composables/useToast.js
Normal 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
5
src/data/auditTrail.js
Normal 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
11
src/data/icons.js
Normal 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
20
src/data/metrics.js
Normal 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
5
src/data/policies.js
Normal 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
14
src/data/requests.js
Normal 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
5
src/main.js
Normal 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
31
src/views/AuditView.vue
Normal 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
167
src/views/ChatView.vue
Normal 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>
|
||||
94
src/views/OverviewView.vue
Normal file
94
src/views/OverviewView.vue
Normal 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>
|
||||
31
src/views/PoliciesView.vue
Normal file
31
src/views/PoliciesView.vue
Normal 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>
|
||||
20
src/views/RequestsView.vue
Normal file
20
src/views/RequestsView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user