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:
2026-04-29 23:35:56 +08:00
parent 7141e1d11a
commit e54ebd072a
15 changed files with 845 additions and 199 deletions

View File

@@ -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>

View File

@@ -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; }

View File

@@ -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>

View 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>

View File

@@ -1,5 +1,6 @@
<template>
<section class="filters" aria-label="筛选条件">
<section class="filters" :class="{ compact }" aria-label="筛选条件">
<template v-if="!compact">
<label>
<span>法人主体</span>
<select v-model="filters.entity">
@@ -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>
@@ -28,6 +29,9 @@
<option>低风险</option>
</select>
</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"
@@ -39,6 +43,7 @@
{{ 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>

View File

@@ -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>

View File

@@ -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>
批量通过
<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>
<button class="btn primary" type="button" @click="emit('openChat')">
<span v-html="messageIcon"></span>
合规对话
</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>

View File

@@ -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
}
}

View File

@@ -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() {

View File

@@ -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} 已退回,系统将通知申请人补充材料。`
}

View File

@@ -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"/>')
}

View File

@@ -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'
}

View File

@@ -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%。' }
]

View File

@@ -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')

View File

@@ -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
# ----------------------------------------------------------