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:
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>
|
||||
Reference in New Issue
Block a user