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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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