feat: enhance layout components, data layer and global styles
- SidebarRail, TopBar, FilterBar: improved navigation and filtering UX - metrics.js, requests.js: expanded data with multi-range trend series - composables: enhanced useChat, useNavigation, useRequests - global.css: refined design tokens and utility classes - Add DocFilterBar component and LoginView page Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
62
src/App.vue
62
src/App.vue
@@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<div class="app">
|
||||
<!-- Login Page -->
|
||||
<LoginView v-if="!loggedIn" @login="handleLogin" />
|
||||
|
||||
<!-- Main App -->
|
||||
<div v-else class="app">
|
||||
<SidebarRail
|
||||
:nav-items="navItems"
|
||||
:active-view="activeView"
|
||||
@@ -11,19 +15,30 @@
|
||||
<TopBar
|
||||
:current-view="currentView"
|
||||
:search="search"
|
||||
:active-view="activeView"
|
||||
@update:search="search = $event"
|
||||
@batch-approve="toast('已筛出 23 个低风险单据,可进入批量通过确认。')"
|
||||
@open-chat="openChat"
|
||||
@new-application="openNewChat"
|
||||
/>
|
||||
|
||||
<FilterBar
|
||||
v-if="activeView !== 'chat'"
|
||||
:compact="activeView === 'overview'"
|
||||
:filters="filters"
|
||||
:ranges="ranges"
|
||||
:active-range="activeRange"
|
||||
@update:active-range="activeRange = $event"
|
||||
/>
|
||||
|
||||
<DocFilterBar
|
||||
v-if="activeView === 'chat'"
|
||||
:doc-filters="docFilters"
|
||||
:doc-months="docMonths"
|
||||
:doc-types="docTypes"
|
||||
:doc-statuses="docStatuses"
|
||||
/>
|
||||
|
||||
<section class="workarea">
|
||||
<OverviewView
|
||||
v-if="activeView === 'overview'"
|
||||
@@ -35,16 +50,18 @@
|
||||
|
||||
<ChatView
|
||||
v-else-if="activeView === 'chat'"
|
||||
ref="chatViewRef"
|
||||
:documents="filteredDocuments"
|
||||
:doc-search="docSearch"
|
||||
:messages="messages"
|
||||
:uploaded-files="uploadedFiles"
|
||||
:active-case="activeCase"
|
||||
:quick-prompts="prompts"
|
||||
:quick-prompts="travelPrompts"
|
||||
:draft="draft"
|
||||
:message-list="messageList"
|
||||
@send="sendMessage"
|
||||
@upload="handleUpload"
|
||||
@draft="draft = $event"
|
||||
@select-case="openChat"
|
||||
@approve-case="toast(`${activeCase?.id} 已生成通过意见。`)"
|
||||
@reject-case="toast(`${activeCase?.id} 已转人工复核。`)"
|
||||
/>
|
||||
@@ -73,23 +90,49 @@ 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 DocFilterBar from './components/layout/DocFilterBar.vue'
|
||||
import ToastNotification from './components/shared/ToastNotification.vue'
|
||||
import LoginView from './views/LoginView.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 { ref, computed, reactive } from 'vue'
|
||||
import { useNavigation, navItems } from './composables/useNavigation.js'
|
||||
import { useRequests } from './composables/useRequests.js'
|
||||
import { useChat } from './composables/useChat.js'
|
||||
import { useToast } from './composables/useToast.js'
|
||||
import { documents, docTypes, docStatuses, docMonths } from './data/requests.js'
|
||||
|
||||
const loggedIn = ref(false)
|
||||
|
||||
function handleLogin() {
|
||||
loggedIn.value = true
|
||||
}
|
||||
|
||||
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 { messages, draft, uploadedFiles, messageList, activeCase, prompts, sendMessage, handleUpload, openChat, openNewChat } = useChat(activeView)
|
||||
const { toastText, toast } = useToast()
|
||||
|
||||
/* ── Document management state ── */
|
||||
const docSearch = ref('')
|
||||
const docFilters = reactive({ month: '2026-04', type: '全部类型', status: '全部状态' })
|
||||
const travelPrompts = ['帮我提交出差申请', '预订机票', '预订酒店', '预订火车票', '查询差旅政策']
|
||||
|
||||
const filteredDocuments = computed(() => {
|
||||
const key = docSearch.value.trim().toLowerCase()
|
||||
return documents.filter((doc) => {
|
||||
const matchesSearch = !key || `${doc.id}${doc.applicant}${doc.destination}${doc.type}`.toLowerCase().includes(key)
|
||||
const matchesType = docFilters.type === '全部类型' || doc.type === docFilters.type
|
||||
const matchesStatus = docFilters.status === '全部状态' || doc.status === docFilters.status
|
||||
const matchesMonth = doc.date.startsWith(docFilters.month)
|
||||
return matchesSearch && matchesType && matchesStatus && matchesMonth
|
||||
})
|
||||
})
|
||||
|
||||
function handleApprove(request) {
|
||||
const msg = approveRequest(request)
|
||||
toast(msg)
|
||||
@@ -105,18 +148,17 @@ function handleReject(request) {
|
||||
.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);
|
||||
grid-template-columns: 256px minmax(0, 1fr);
|
||||
background: var(--bg);
|
||||
}
|
||||
.main { min-width: 0; display: grid; grid-template-rows: auto auto minmax(0, 1fr); }
|
||||
.workarea { overflow: auto; padding: 22px 28px 34px; }
|
||||
.workarea { overflow: auto; padding: 24px; }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.app { grid-template-columns: 72px minmax(0, 1fr); }
|
||||
.app { grid-template-columns: 236px minmax(0, 1fr); }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.app { display: block; }
|
||||
.workarea { padding: 18px 16px 28px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
:root {
|
||||
--bg: #f6f8fb;
|
||||
--bg: #f8fafc;
|
||||
--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;
|
||||
--ink: #1e293b;
|
||||
--text: #334155;
|
||||
--muted: #64748b;
|
||||
--line: #e2e8f0;
|
||||
--line-strong: #cbd5e1;
|
||||
--primary: #10b981;
|
||||
--primary-soft: #ecfdf5;
|
||||
--secondary: #3b82f6;
|
||||
--purple: #8b5cf6;
|
||||
--orange: #f59e0b;
|
||||
--red: #ef4444;
|
||||
--success: #10b981;
|
||||
--success-soft: #ecfdf5;
|
||||
--warning: #f59e0b;
|
||||
--warning-soft: #fffbeb;
|
||||
--danger: #ef4444;
|
||||
--danger-soft: #fef2f2;
|
||||
--nav: #0b1220;
|
||||
--nav-muted: #7d89a5;
|
||||
--radius: 8px;
|
||||
@@ -26,11 +30,11 @@
|
||||
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; }
|
||||
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 3px solid rgba(16,185,129,.20); outline-offset: 2px; }
|
||||
|
||||
.eyebrow { color: var(--primary); font-size: 11px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
|
||||
.eyebrow { color: var(--primary); font-size: 12px; font-weight: 600; letter-spacing: .08em; 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; }
|
||||
h1 { margin-top: 4px; color: var(--ink); font-size: 24px; line-height: 1.25; font-weight: 700; }
|
||||
|
||||
.btn {
|
||||
min-height: 42px;
|
||||
@@ -59,13 +63,12 @@ h1 { margin-top: 4px; color: var(--ink); font-size: clamp(25px, 3vw, 36px); line
|
||||
.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);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.10), 0 1px 2px rgba(0,0,0,.06);
|
||||
transition: box-shadow 160ms var(--ease);
|
||||
}
|
||||
.panel:hover { transform: translateY(-2px); border-color: rgba(51,92,255,.22); box-shadow: 0 16px 42px rgba(16,24,40,.08); }
|
||||
.panel:hover { box-shadow: 0 4px 10px rgba(15,23,42,.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; }
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<span class="badge" :class="request.status">{{ request.verdict }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :class="request.sla.includes('51') ? 'danger' : ''">{{ request.sla }}</span>
|
||||
<span class="badge" :class="request.slaStatus">{{ request.sla }}</span>
|
||||
</td>
|
||||
<td>{{ request.risk }}</td>
|
||||
<td>
|
||||
|
||||
54
src/components/layout/DocFilterBar.vue
Normal file
54
src/components/layout/DocFilterBar.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<section class="doc-filters" aria-label="单据筛选">
|
||||
<div class="filter-item">
|
||||
<span class="filter-label">申请月份</span>
|
||||
<Dropdown v-model="docFilters.month" :options="docMonths" placeholder="选择月份" appendTo="body" class="filter-dropdown">
|
||||
<template #option="{ option }">
|
||||
{{ formatMonth(option) }}
|
||||
</template>
|
||||
<template #value="{ value }">
|
||||
{{ formatMonth(value) }}
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<span class="filter-label">申请类型</span>
|
||||
<Dropdown v-model="docFilters.type" :options="docTypes" placeholder="选择类型" appendTo="body" class="filter-dropdown" />
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<span class="filter-label">单据状态</span>
|
||||
<Dropdown v-model="docFilters.status" :options="docStatuses" placeholder="选择状态" appendTo="body" class="filter-dropdown" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Dropdown from 'primevue/dropdown'
|
||||
|
||||
defineProps({
|
||||
docFilters: { type: Object, required: true },
|
||||
docMonths: { type: Array, required: true },
|
||||
docTypes: { type: Array, required: true },
|
||||
docStatuses: { type: Array, required: true }
|
||||
})
|
||||
|
||||
function formatMonth(m) {
|
||||
if (!m) return ''
|
||||
const [y, mm] = m.split('-')
|
||||
return `${y}年${parseInt(mm)}月`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.doc-filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(180px, 1fr));
|
||||
gap: 14px;
|
||||
padding: 16px 28px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
.filter-item { display: grid; gap: 6px; }
|
||||
.filter-label { color: var(--muted); font-size: 12px; font-weight: 700; }
|
||||
.filter-dropdown { width: 100%; }
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<section class="filters" aria-label="筛选条件">
|
||||
<label>
|
||||
<section class="filters" :class="{ compact }" aria-label="筛选条件">
|
||||
<template v-if="!compact">
|
||||
<label>
|
||||
<span>法人主体</span>
|
||||
<select v-model="filters.entity">
|
||||
<option>全部主体</option>
|
||||
@@ -13,10 +14,10 @@
|
||||
<span>费用类型</span>
|
||||
<select v-model="filters.category">
|
||||
<option>全部费用</option>
|
||||
<option>差旅交通</option>
|
||||
<option>住宿</option>
|
||||
<option>业务招待</option>
|
||||
<option>办公采购</option>
|
||||
<option>机票</option>
|
||||
<option>酒店</option>
|
||||
<option>火车/用车</option>
|
||||
<option>餐补及杂费</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
@@ -27,17 +28,21 @@
|
||||
<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>
|
||||
</label>
|
||||
</template>
|
||||
<div class="segmented-wrap" :class="{ compact }">
|
||||
<span v-if="!compact">时间范围</span>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -46,7 +51,8 @@
|
||||
defineProps({
|
||||
filters: { type: Object, required: true },
|
||||
ranges: { type: Array, required: true },
|
||||
activeRange: { type: String, required: true }
|
||||
activeRange: { type: String, required: true },
|
||||
compact: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:activeRange'])
|
||||
@@ -55,15 +61,89 @@ const emit = defineEmits(['update:activeRange'])
|
||||
<style scoped>
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(180px, 1fr)) auto;
|
||||
grid-template-columns: repeat(3, minmax(160px, 1fr)) auto;
|
||||
gap: 14px;
|
||||
padding: 16px 28px;
|
||||
padding: 0 16px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.filters.compact {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.filters label,
|
||||
.segmented-wrap {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.filters select {
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.segmented-wrap {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.segmented {
|
||||
align-self: end;
|
||||
display: inline-grid;
|
||||
grid-auto-flow: column;
|
||||
gap: 8px;
|
||||
min-height: 30px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.segmented button {
|
||||
min-height: 30px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.segmented button.active {
|
||||
background: #f1f5f9;
|
||||
color: #1e293b;
|
||||
font-weight: 500;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.filters {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.segmented-wrap {
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.filters {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.segmented {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.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>
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
<template>
|
||||
<aside class="rail" aria-label="主导航">
|
||||
<div class="mark">RO</div>
|
||||
<div class="rail-brand">
|
||||
<div class="brand-mark">星</div>
|
||||
<div class="brand-copy">
|
||||
<strong>星海科技</strong>
|
||||
</div>
|
||||
<button class="brand-toggle" type="button" aria-label="打开合规对话" @click="emit('openChat')">
|
||||
<i class="pi pi-angle-left"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav class="rail-nav">
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
@@ -8,30 +17,33 @@
|
||||
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>
|
||||
<span class="nav-icon" v-html="item.icon"></span>
|
||||
<span class="nav-copy">
|
||||
<strong>{{ item.label }}</strong>
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
<button class="nav-btn muted" type="button" aria-label="打开合规对话" title="打开合规对话" @click="emit('openChat')">
|
||||
<span v-html="messageIcon"></span>
|
||||
</button>
|
||||
|
||||
<div class="rail-user">
|
||||
<div class="user-avatar">张</div>
|
||||
<div class="user-copy">
|
||||
<strong>张晓明</strong>
|
||||
<span>财务管理员</span>
|
||||
</div>
|
||||
<i class="pi pi-angle-down"></i>
|
||||
</div>
|
||||
</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>
|
||||
@@ -41,30 +53,164 @@ const messageIcon = icons.message
|
||||
height: 100dvh;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
gap: 18px;
|
||||
padding: 18px 12px;
|
||||
background: var(--nav);
|
||||
color: #fff;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
border-right: 1px solid var(--line);
|
||||
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;
|
||||
|
||||
.rail-brand,
|
||||
.rail-user {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rail-brand {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.brand-mark,
|
||||
.user-avatar {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 6px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
background: #e2f6ef;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.brand-copy,
|
||||
.user-copy,
|
||||
.nav-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.brand-copy strong,
|
||||
.user-copy strong,
|
||||
.nav-copy strong {
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-copy span,
|
||||
.nav-copy small {
|
||||
margin-top: 2px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.brand-toggle {
|
||||
width: 28px;
|
||||
min-height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--nav-muted);
|
||||
transition: background 160ms var(--ease), color 160ms var(--ease), transform 160ms var(--ease);
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.rail-nav {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
align-content: start;
|
||||
padding: 16px 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #475569;
|
||||
text-align: left;
|
||||
transition: background 160ms var(--ease), color 160ms var(--ease);
|
||||
}
|
||||
|
||||
.nav-btn:hover,
|
||||
.nav-btn.active {
|
||||
background: rgba(16,185,129,.10);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.nav-btn :deep(svg) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.rail-user {
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.rail-user .pi {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.rail {
|
||||
position: relative;
|
||||
height: auto;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.rail-nav {
|
||||
grid-auto-flow: row;
|
||||
}
|
||||
}
|
||||
.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; }
|
||||
.rail {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.rail-nav {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
min-width: 190px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,56 +1,163 @@
|
||||
<template>
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<div class="eyebrow">Global finance operations</div>
|
||||
<div class="title-group">
|
||||
<div class="eyebrow">Smart Expense 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>
|
||||
<span class="search-wrap">
|
||||
<i class="pi pi-search search-icon"></i>
|
||||
<input :value="search" type="search" :placeholder="isChat ? '搜索单号、申请人、目的地' : '搜索申请人、单号、费用类型'" @input="emit('update:search', $event.target.value)" />
|
||||
</span>
|
||||
|
||||
<div v-if="!isChat" class="date-chip">
|
||||
<i class="pi pi-calendar"></i>
|
||||
<span>2024-07-06 ~ 2024-07-12</span>
|
||||
</div>
|
||||
|
||||
<template v-if="isChat">
|
||||
<button class="top-btn primary" type="button" @click="emit('newApplication')">
|
||||
<i class="pi pi-plus"></i>
|
||||
<span>新建出差申请</span>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="top-btn primary" type="button" @click="emit('openChat')">
|
||||
<i class="pi pi-sparkles"></i>
|
||||
<span>AI 助手</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { icons } from '../../data/icons.js'
|
||||
import { computed } from 'vue'
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
currentView: { type: Object, required: true },
|
||||
search: { type: String, default: '' }
|
||||
search: { type: String, default: '' },
|
||||
activeView: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:search', 'batchApprove', 'openChat'])
|
||||
const emit = defineEmits(['update:search', 'batchApprove', 'openChat', 'newApplication'])
|
||||
|
||||
const searchIcon = icons.search
|
||||
const checkIcon = icons.check
|
||||
const messageIcon = icons.message
|
||||
const isChat = computed(() => props.activeView === 'chat')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
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);
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.title-group {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar p {
|
||||
margin-top: 4px;
|
||||
max-width: 720px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.top-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-wrap {
|
||||
position: relative;
|
||||
width: 256px;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 13px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-wrap input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 14px 0 38px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.search-wrap input:focus {
|
||||
border-color: #10b981;
|
||||
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.16);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.date-chip {
|
||||
height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 32px 0 12px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.date-chip .pi {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.top-btn {
|
||||
height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 16px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 160ms ease;
|
||||
}
|
||||
|
||||
.top-btn.primary {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.top-btn.primary:hover {
|
||||
background: #0ea672;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.topbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 18px 16px;
|
||||
}
|
||||
|
||||
.top-actions,
|
||||
.search-wrap,
|
||||
.date-chip {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.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>
|
||||
|
||||
@@ -10,12 +10,21 @@ export function useChat(activeView) {
|
||||
|
||||
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 '建议先核对政策阈值和附件完整性,再决定通过、退回补件或转人工复核。'
|
||||
if (text.includes('出差申请') || text.includes('出差'))
|
||||
return '好的,我来帮您处理出差申请。请提供以下信息:\n1. 出发城市和目的地\n2. 出差日期和天数\n3. 出差事由\n4. 预计费用预算'
|
||||
if (text.includes('机票') || text.includes('飞机'))
|
||||
return '我来帮您查询机票信息。请告诉我:\n1. 出发城市 → 目的城市\n2. 出发日期\n3. 偏好的时间段(上午/下午/晚间)\n4. 舱位要求(经济舱/商务舱)'
|
||||
if (text.includes('酒店') || text.includes('住宿'))
|
||||
return '好的,帮您查找合适的酒店。请提供:\n1. 入住城市和区域偏好\n2. 入住和退房日期\n3. 星级/价位要求\n4. 是否需要含早餐'
|
||||
if (text.includes('火车票') || text.includes('高铁'))
|
||||
return '帮您查询火车票。请告诉我:\n1. 出发站 → 到达站\n2. 出行日期\n3. 座位偏好(二等座/一等座/商务座)\n4. 偏好的出发时间段'
|
||||
if (text.includes('审批意见'))
|
||||
return c ? `${c.id} 建议审批意见:费用归属与预算中心匹配,建议保留业务说明后通过。` : '请先选择一份单据再生成审批意见。'
|
||||
if (text.includes('补件'))
|
||||
return '补件优先级:业务目的说明、行程或客户名单、直属经理确认记录。'
|
||||
if (text.includes('差旅政策') || text.includes('政策'))
|
||||
return '当前差旅政策要点:\n• 住宿标准:一线城市 600 元/晚,二线城市 400 元/晚\n• 机票:优先经济舱,3 小时以上可申请商务舱\n• 高铁:优先二等座,4 小时以上可申请一等座\n• 每日餐饮补贴:120 元'
|
||||
return '好的,我已记录您的需求。请问还需要什么帮助?我还可以帮您查询差旅政策、预订机票酒店火车票等。'
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
@@ -56,8 +65,13 @@ export function useChat(activeView) {
|
||||
nextTick(() => messageList.value?.scrollTo({ top: messageList.value.scrollHeight }))
|
||||
}
|
||||
|
||||
function openNewChat() {
|
||||
activeCase.value = null
|
||||
activeView.value = 'chat'
|
||||
}
|
||||
|
||||
return {
|
||||
messages, draft, uploadedFiles, messageList, activeCase, prompts,
|
||||
sendMessage, handleUpload, openChat, scrollToBottom
|
||||
sendMessage, handleUpload, openChat, openNewChat, scrollToBottom
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,46 @@ 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 建议和制度命中记录。' }
|
||||
{
|
||||
id: 'overview',
|
||||
label: '总览',
|
||||
navHint: '运营指标与趋势',
|
||||
icon: icons.dashboard,
|
||||
title: '企业报销智能运营台',
|
||||
desc: '面向财务共享中心的审批、风控、SLA与自动化运营看板'
|
||||
},
|
||||
{
|
||||
id: 'chat',
|
||||
label: '审批中心',
|
||||
navHint: 'AI 助手与单据处理',
|
||||
icon: icons.message,
|
||||
title: '单据管理中心',
|
||||
desc: '管理出差申请、报销单据,AI 辅助发起申请与智能审核。'
|
||||
},
|
||||
{
|
||||
id: 'requests',
|
||||
label: '报销单',
|
||||
navHint: '待审队列与风险处理',
|
||||
icon: icons.list,
|
||||
title: '报销申请队列',
|
||||
desc: '按风险、补件状态和 AI 建议处理待审单据。'
|
||||
},
|
||||
{
|
||||
id: 'policies',
|
||||
label: '政策规则',
|
||||
navHint: '制度与校验规则',
|
||||
icon: icons.file,
|
||||
title: '政策规则中心',
|
||||
desc: '维护差旅、招待、采购和发票校验规则。'
|
||||
},
|
||||
{
|
||||
id: 'audit',
|
||||
label: '审计追踪',
|
||||
navHint: '关键动作与日志',
|
||||
icon: icons.audit,
|
||||
title: '审计追踪',
|
||||
desc: '查看关键审批动作、AI 建议和制度命中记录。'
|
||||
}
|
||||
]
|
||||
|
||||
export function useNavigation() {
|
||||
|
||||
@@ -3,30 +3,52 @@ import { initialRequests } from '../data/requests.js'
|
||||
|
||||
export function useRequests() {
|
||||
const requests = ref(initialRequests)
|
||||
const entityMap = {
|
||||
'Northstar China Ltd.': 'Northstar China Ltd.',
|
||||
'Northstar Singapore Pte.': 'Northstar Singapore Pte.',
|
||||
'Northstar US Inc.': 'Northstar US Inc.'
|
||||
}
|
||||
const search = ref('')
|
||||
const filters = reactive({ entity: '全部主体', category: '全部费用', risk: '全部风险' })
|
||||
const ranges = ['今日', '本周', '本月']
|
||||
const activeRange = ref('今日')
|
||||
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
|
||||
const matchesEntity = filters.entity === '全部主体' || item.entity === entityMap[filters.entity]
|
||||
const matchesCategory = filters.category === '全部费用' || item.category === filters.category
|
||||
const matchesRisk = filters.risk === '全部风险'
|
||||
|| (filters.risk === '高风险' && item.status === 'danger')
|
||||
|| (filters.risk === '需解释' && item.status === 'warning')
|
||||
|| (filters.risk === '低风险' && item.status === 'success')
|
||||
const matchesRange = activeRange.value === '本月'
|
||||
|| (activeRange.value === '本周' && item.range !== '本月')
|
||||
|| (activeRange.value === '今日' && item.range === '今日')
|
||||
return matchesSearch && matchesEntity && matchesCategory && matchesRisk && matchesRange
|
||||
})
|
||||
})
|
||||
|
||||
function updateRequest(requestId, updates) {
|
||||
requests.value = requests.value.map((item) => (item.id === requestId ? { ...item, ...updates } : item))
|
||||
}
|
||||
|
||||
function approveRequest(request) {
|
||||
request.verdict = '已通过'
|
||||
request.status = 'success'
|
||||
updateRequest(request.id, {
|
||||
verdict: '已通过',
|
||||
status: 'success',
|
||||
risk: '已完成人工确认'
|
||||
})
|
||||
return `${request.id} 已标记为通过,审计日志已更新。`
|
||||
}
|
||||
|
||||
function rejectRequest(request) {
|
||||
request.verdict = '已退回补件'
|
||||
request.status = 'danger'
|
||||
updateRequest(request.id, {
|
||||
verdict: '已退回补件',
|
||||
status: 'danger',
|
||||
risk: '待申请人补充差旅行程与票据'
|
||||
})
|
||||
return `${request.id} 已退回,系统将通知申请人补充材料。`
|
||||
}
|
||||
|
||||
|
||||
@@ -7,5 +7,6 @@ export const icons = {
|
||||
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"/>')
|
||||
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"/>'),
|
||||
plus: iconPath('<path d="M12 5v14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M5 12h14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>')
|
||||
}
|
||||
|
||||
@@ -1,20 +1,134 @@
|
||||
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 metricBlueprints = [
|
||||
{
|
||||
key: 'pendingCount',
|
||||
label: '待审批单据',
|
||||
unit: '单',
|
||||
accent: '#10b981',
|
||||
icon: 'pi pi-file',
|
||||
trend: 'down',
|
||||
change: '12.5%',
|
||||
delta: '较昨日 -18 单'
|
||||
},
|
||||
{
|
||||
key: 'pendingAmount',
|
||||
label: '待处理金额',
|
||||
accent: '#3b82f6',
|
||||
icon: 'pi pi-wallet',
|
||||
trend: 'up',
|
||||
change: '8.3%',
|
||||
delta: '较昨日 +¥27,400'
|
||||
},
|
||||
{
|
||||
key: 'avgSla',
|
||||
label: '平均审批时长',
|
||||
unit: 'h',
|
||||
accent: '#8b5cf6',
|
||||
icon: 'pi pi-clock',
|
||||
trend: 'down',
|
||||
change: '14.8%',
|
||||
delta: '较昨日 -1.2h'
|
||||
},
|
||||
{
|
||||
key: 'autoPassRate',
|
||||
label: '自动审单通过率',
|
||||
unit: '%',
|
||||
accent: '#16a34a',
|
||||
icon: 'pi pi-shield',
|
||||
trend: 'up',
|
||||
change: '6.2%',
|
||||
delta: '较昨日 +4.6%'
|
||||
},
|
||||
{
|
||||
key: 'riskCount',
|
||||
label: '异常预警单',
|
||||
unit: '单',
|
||||
accent: '#ef4444',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
trend: 'up',
|
||||
change: '16.7%',
|
||||
delta: '较昨日 +2 单'
|
||||
},
|
||||
{
|
||||
key: 'slaRate',
|
||||
label: 'SLA 达成率',
|
||||
unit: '%',
|
||||
accent: '#10b981',
|
||||
icon: 'pi pi-check-circle',
|
||||
trend: 'up',
|
||||
change: '3.1%',
|
||||
delta: '较昨日 +2.9%'
|
||||
}
|
||||
]
|
||||
|
||||
export const trendRanges = ['近12天', '近7天', '近30天']
|
||||
|
||||
export const trendSeries = {
|
||||
'近12天': {
|
||||
labels: ['07-01', '07-02', '07-03', '07-04', '07-05', '07-06', '07-07', '07-08', '07-09', '07-10', '07-12'],
|
||||
applications: [140, 105, 175, 195, 155, 70, 65, 60, 185, 200, 220],
|
||||
approved: [110, 85, 130, 125, 110, 60, 55, 50, 145, 150, 170],
|
||||
avgHours: [10, 8, 9, 7, 7, 6.8, 6, 6.5, 7, 8, 7.5]
|
||||
},
|
||||
'近7天': {
|
||||
labels: ['04-23', '04-24', '04-25', '04-26', '04-27', '04-28', '04-29'],
|
||||
applications: [72, 68, 109, 121, 134, 142, 128],
|
||||
approved: [58, 54, 92, 101, 116, 121, 110],
|
||||
avgHours: [6.9, 6.5, 6.8, 7.1, 7.4, 7.0, 6.8]
|
||||
},
|
||||
'近30天': {
|
||||
labels: ['03-31', '04-03', '04-06', '04-09', '04-12', '04-15', '04-18', '04-21', '04-24', '04-27'],
|
||||
applications: [82, 90, 96, 114, 120, 111, 126, 132, 119, 138],
|
||||
approved: [68, 76, 80, 95, 100, 93, 102, 110, 101, 117],
|
||||
avgHours: [9.2, 8.8, 8.4, 8.0, 7.7, 7.4, 7.2, 6.9, 6.8, 6.7]
|
||||
}
|
||||
}
|
||||
|
||||
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)' }
|
||||
{ name: '机票', value: 182000, color: '#16a34a' },
|
||||
{ name: '酒店', value: 146000, color: '#3b82f6' },
|
||||
{ name: '火车/用车', value: 78600, color: '#f59e0b' },
|
||||
{ name: '餐补及杂费', value: 55000, color: '#8b5cf6' }
|
||||
]
|
||||
|
||||
export const auditMix = [
|
||||
{ name: '建议通过', value: '42%', color: '#335cff' },
|
||||
{ name: '自动通过', value: '26%', color: '#0e9384' },
|
||||
{ name: '需补件', value: '18%', color: '#f79009' },
|
||||
{ name: '高风险', value: '14%', color: '#d92d20' }
|
||||
export const exceptionMix = [
|
||||
{ name: '住宿超标', value: 5, color: '#ef4444' },
|
||||
{ name: '重复报销', value: 3, color: '#f59e0b' },
|
||||
{ name: '行程缺失', value: 3, color: '#8b5cf6' },
|
||||
{ name: '发票异常', value: 3, color: '#3b82f6' }
|
||||
]
|
||||
|
||||
export const departmentRangeOptions = ['本周', '本月', '本季度']
|
||||
|
||||
export const bottlenecks = [
|
||||
{
|
||||
name: '李文静',
|
||||
role: '财务经理',
|
||||
duration: '12.4 h',
|
||||
status: '较慢',
|
||||
tone: 'danger',
|
||||
avatar: '李'
|
||||
},
|
||||
{
|
||||
name: '王志强',
|
||||
role: '财务专员',
|
||||
duration: '8.7 h',
|
||||
status: '偏慢',
|
||||
tone: 'warning',
|
||||
avatar: '王'
|
||||
},
|
||||
{
|
||||
name: '刘思雨',
|
||||
role: '费用审核员',
|
||||
duration: '5.2 h',
|
||||
status: '正常',
|
||||
tone: 'success',
|
||||
avatar: '刘'
|
||||
}
|
||||
]
|
||||
|
||||
export const budgetSummary = {
|
||||
ratio: 76,
|
||||
total: '¥2,800,000',
|
||||
used: '¥2,128,000',
|
||||
left: '¥672,000'
|
||||
}
|
||||
|
||||
@@ -1,14 +1,37 @@
|
||||
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: '供应商与历史黑名单相似' }
|
||||
{ id: 'REQ-2026-0418', person: '刘倩', dept: '销售 · 华东区域', entity: 'Northstar China Ltd.', range: '今日', category: '机票', amount: '¥8,460', verdict: '可通过但需备注', status: 'warning', sla: '19h', slaStatus: 'warning', risk: '改签说明缺失,公务舱价格高于差标 12%' },
|
||||
{ id: 'REQ-2026-0422', person: '韩阳', dept: '解决方案 · 北区', entity: 'Northstar China Ltd.', range: '本周', category: '酒店', amount: '¥3,280', verdict: '等待补件', status: 'warning', sla: '27h', slaStatus: 'warning', risk: '缺少出差行程单,酒店单晚超标 8%' },
|
||||
{ id: 'REQ-2026-0431', person: '王鑫', dept: '运营管理 · 总部', entity: 'Northstar Singapore Pte.', range: '本周', category: '火车/用车', amount: '¥1,224', verdict: '规则全通过', status: 'success', sla: '4h', slaStatus: 'success', risk: '无明显风险' },
|
||||
{ id: 'REQ-2026-0436', person: '陈嘉', dept: '市场 · 品牌活动', entity: 'Northstar US Inc.', range: '本月', category: '餐补及杂费', amount: '¥2,680', verdict: '建议人工复核', status: 'danger', sla: '51h', slaStatus: 'danger', risk: '发票号码重复,疑似重复报销' },
|
||||
{ id: 'REQ-2026-0441', person: '赵敏', dept: '研发 · 后端组', entity: 'Northstar China Ltd.', range: '今日', category: '酒店', amount: '¥2,940', verdict: '规则全通过', status: 'success', sla: '6h', slaStatus: 'success', risk: '无明显风险' },
|
||||
{ id: 'REQ-2026-0443', person: '周晨', dept: '销售 · 华南区域', entity: 'Northstar China Ltd.', range: '本周', category: '机票', amount: '¥6,520', verdict: '建议人工复核', status: 'danger', sla: '33h', slaStatus: 'danger', risk: '航班时间与出差申请不一致' },
|
||||
{ id: 'REQ-2026-0448', person: '李娜', dept: '客户成功 · 华北', entity: 'Northstar Singapore Pte.', range: '本周', category: '火车/用车', amount: '¥1,860', verdict: '规则全通过', status: 'success', sla: '5h', slaStatus: 'success', risk: '无明显风险' },
|
||||
{ id: 'REQ-2026-0452', person: '孙博', dept: '采购中心', entity: 'Northstar US Inc.', range: '本月', category: '酒店', amount: '¥4,780', verdict: '等待补件', status: 'warning', sla: '29h', slaStatus: 'warning', risk: '缺少住宿发票原件' },
|
||||
{ id: 'REQ-2026-0455', person: '马骁', dept: '市场 · 品牌活动', entity: 'Northstar US Inc.', range: '本月', category: '机票', amount: '¥7,340', verdict: '规则全通过', status: 'success', sla: '8h', slaStatus: 'success', risk: '无明显风险' },
|
||||
{ id: 'REQ-2026-0458', person: '高宁', dept: '运营管理 · 总部', entity: 'Northstar Singapore Pte.', range: '今日', category: '餐补及杂费', amount: '¥980', verdict: '可通过但需备注', status: 'warning', sla: '11h', slaStatus: 'warning', risk: '餐补天数与行程存在 1 天偏差' },
|
||||
{ id: 'REQ-2026-0462', person: '何川', dept: '解决方案 · 北区', entity: 'Northstar China Ltd.', range: '本月', category: '机票', amount: '¥5,460', verdict: '规则全通过', status: 'success', sla: '7h', slaStatus: 'success', risk: '无明显风险' },
|
||||
{ id: 'REQ-2026-0466', person: '宋雨', dept: '研发 · 后端组', entity: 'Northstar China Ltd.', range: '本周', category: '酒店', amount: '¥3,660', verdict: '已退回补件', status: 'danger', sla: '41h', slaStatus: 'danger', risk: '入住城市与项目地点不一致' }
|
||||
]
|
||||
|
||||
export const documents = [
|
||||
{ id: 'DOC-2026-0401', type: '出差申请', typeTag: 'travel', applicant: '刘倩', dept: '销售 · 华东区域', date: '2026-04-18', amount: '¥8,460', status: '审批中', statusClass: 'warning', conclusion: '待审核', destination: '上海→杭州', days: 3 },
|
||||
{ id: 'DOC-2026-0402', type: '酒店预订', typeTag: 'hotel', applicant: '韩阳', dept: '解决方案 · 北区', date: '2026-04-22', amount: '¥1,280', status: '已通过', statusClass: 'success', conclusion: '规则全通过', destination: '北京·望京凯悦', days: 2 },
|
||||
{ id: 'DOC-2026-0403', type: '机票预订', typeTag: 'flight', applicant: '王鑫', dept: '运营管理 · 总部', date: '2026-04-25', amount: '¥2,340', status: '已完成', statusClass: 'success', conclusion: '合规无风险', destination: '北京→深圳', days: 1 },
|
||||
{ id: 'DOC-2026-0404', type: '出差申请', typeTag: 'travel', applicant: '陈嘉', dept: '市场 · 品牌活动', date: '2026-04-26', amount: '¥12,680', status: '待补件', statusClass: 'danger', conclusion: '需补充行程说明', destination: '上海→成都', days: 4 },
|
||||
{ id: 'DOC-2026-0405', type: '火车票预订', typeTag: 'train', applicant: '赵敏', dept: '研发 · 后端组', date: '2026-04-27', amount: '¥553', status: '审批中', statusClass: 'warning', conclusion: '待审核', destination: '杭州→南京', days: 1 },
|
||||
{ id: 'DOC-2026-0406', type: '酒店预订', typeTag: 'hotel', applicant: '刘倩', dept: '销售 · 华东区域', date: '2026-04-28', amount: '¥2,100', status: '已退回', statusClass: 'danger', conclusion: '住宿超标 23%', destination: '杭州·西湖国宾馆', days: 2 },
|
||||
{ id: 'DOC-2026-0407', type: '机票预订', typeTag: 'flight', applicant: '韩阳', dept: '解决方案 · 北区', date: '2026-04-28', amount: '¥3,800', status: '已通过', statusClass: 'success', conclusion: '规则全通过', destination: '北京→广州', days: 1 },
|
||||
{ id: 'DOC-2026-0408', type: '出差申请', typeTag: 'travel', applicant: '王鑫', dept: '运营管理 · 总部', date: '2026-04-29', amount: '¥4,200', status: '审批中', statusClass: 'warning', conclusion: '预算校验中', destination: '深圳→厦门', days: 2 }
|
||||
]
|
||||
|
||||
export const docTypes = ['全部类型', '出差申请', '机票预订', '酒店预订', '火车票预订']
|
||||
export const docStatuses = ['全部状态', '审批中', '已通过', '已完成', '待补件', '已退回']
|
||||
export const docMonths = ['2026-04', '2026-03', '2026-02', '2026-01']
|
||||
|
||||
export const prompts = ['生成审批意见', '列出补件清单', '解释为什么拦截', '生成审计摘要']
|
||||
|
||||
export const initialMessages = [
|
||||
{ id: 1, role: 'agent', text: '我已读取单据、发票、行程和公司差旅制度。当前建议:可通过,但需要保留会议说明。' },
|
||||
{ id: 1, role: 'agent', text: '我已读取单据、发票、行程和公司差旅制度。当前建议:可通过,但需要补充改签说明。' },
|
||||
{ id: 2, role: 'user', text: '请列出这张单据的主要风险。' },
|
||||
{ id: 3, role: 'agent', text: '主要风险:住宿单晚均价超标准 17.4%,并且需要人工确认会议附件。' }
|
||||
{ id: 3, role: 'agent', text: '主要风险:缺少改签说明,且舱位价格高于差旅标准 12%。' }
|
||||
]
|
||||
|
||||
18
src/main.js
18
src/main.js
@@ -1,5 +1,21 @@
|
||||
import { createApp } from 'vue'
|
||||
import { MotionPlugin } from '@vueuse/motion'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Aura from '@primevue/themes/aura'
|
||||
import 'primeicons/primeicons.css'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).use(MotionPlugin).mount('#app')
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(MotionPlugin)
|
||||
app.use(PrimeVue, {
|
||||
theme: {
|
||||
preset: Aura,
|
||||
options: {
|
||||
darkModeSelector: '.dark',
|
||||
cssLayer: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
71
start.sh
71
start.sh
@@ -32,59 +32,48 @@ fi
|
||||
info "Node.js $(node -v) | npm $(npm -v)"
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Detect WSL + Windows node_modules platform mismatch
|
||||
# WSL on a Windows-mounted repo should reuse Windows Node
|
||||
# ----------------------------------------------------------
|
||||
is_wsl() {
|
||||
grep -qi microsoft /proc/version 2>/dev/null
|
||||
}
|
||||
|
||||
check_platform_mismatch() {
|
||||
local rollup_dir="node_modules/@rollup"
|
||||
if [ ! -d "$rollup_dir" ]; then
|
||||
return 1
|
||||
fi
|
||||
# List installed rollup platform packages
|
||||
local platforms
|
||||
platforms="$(ls -1 "$rollup_dir" 2>/dev/null | grep -E '^rollup-(win|linux)')"
|
||||
if [ -z "$platforms" ]; then
|
||||
return 1
|
||||
fi
|
||||
# Running on WSL/Linux but has Windows rollup bindings
|
||||
if echo "$platforms" | grep -q "win32"; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
is_windows_mount() {
|
||||
case "$SCRIPT_DIR" in
|
||||
/mnt/*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Install dependencies if node_modules is missing or mismatched
|
||||
# ----------------------------------------------------------
|
||||
NEED_INSTALL=false
|
||||
|
||||
if [ ! -d "node_modules" ]; then
|
||||
warn "node_modules not found"
|
||||
NEED_INSTALL=true
|
||||
elif is_wsl && check_platform_mismatch; then
|
||||
warn "Detected WSL with Windows node_modules (rollup platform mismatch)"
|
||||
warn "Removing node_modules to reinstall with correct platform bindings..."
|
||||
# WSL can't delete locked Windows .exe/.node files, use PowerShell instead
|
||||
if is_wsl && is_windows_mount && command -v powershell.exe &>/dev/null && command -v wslpath &>/dev/null; then
|
||||
WIN_PATH="$(wslpath -w "$SCRIPT_DIR")"
|
||||
if command -v powershell.exe &>/dev/null; then
|
||||
powershell.exe -NoProfile -Command "Remove-Item -Recurse -Force '${WIN_PATH}\\node_modules','${WIN_PATH}\\package-lock.json'" 2>/dev/null || true
|
||||
elif command -v cmd.exe &>/dev/null; then
|
||||
cmd.exe /c "rd /s /q \"${WIN_PATH}\\node_modules\"" 2>/dev/null || true
|
||||
cmd.exe /c "del /f /q \"${WIN_PATH}\\package-lock.json\"" 2>/dev/null || true
|
||||
else
|
||||
rm -rf node_modules package-lock.json 2>/dev/null || true
|
||||
fi
|
||||
# Fallback: clean up anything remaining via WSL
|
||||
rm -rf node_modules package-lock.json 2>/dev/null || true
|
||||
NEED_INSTALL=true
|
||||
WIN_PATH_PS="${WIN_PATH//\'/\'\'}"
|
||||
info "Detected WSL on a Windows-mounted project"
|
||||
info "Using Windows npm to avoid cross-platform node_modules installs"
|
||||
info "Access: http://127.0.0.1:5173"
|
||||
echo ""
|
||||
exec powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Set-Location -LiteralPath '$WIN_PATH_PS'; npm start"
|
||||
fi
|
||||
|
||||
if [ "$NEED_INSTALL" = true ]; then
|
||||
# ----------------------------------------------------------
|
||||
# Install dependencies only when they are missing or unusable
|
||||
# ----------------------------------------------------------
|
||||
dependencies_ready() {
|
||||
[ -d "node_modules" ] || return 1
|
||||
[ -f "node_modules/vite/bin/vite.js" ] || return 1
|
||||
[ -e "node_modules/.bin/vite" ] || [ -e "node_modules/.bin/vite.cmd" ] || return 1
|
||||
|
||||
node -e "require('rollup')" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
if ! dependencies_ready; then
|
||||
warn "Dependencies are missing or incomplete"
|
||||
info "Running npm install..."
|
||||
npm install
|
||||
|
||||
if ! dependencies_ready; then
|
||||
error "Dependencies are still incomplete after npm install. Try deleting node_modules and running npm install manually."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ----------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user