refactor: split project into web and server directories

- Move frontend to web/ directory
- Add server/ directory for backend
- Restructure project for前后端分离架构

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 11:00:38 +08:00
parent 9a7b0794a1
commit 9785fb527b
85 changed files with 10474 additions and 10047 deletions

834
web/demo/main_demo.html Normal file
View File

@@ -0,0 +1,834 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>企业报销智能运营台</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.8/dist/chart.umd.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#10b981',
secondary: '#3b82f6',
purple: '#8b5cf6',
orange: '#f59e0b',
red: '#ef4444',
gray: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
}
},
fontFamily: {
inter: ['Inter', 'sans-serif'],
},
}
}
}
</script>
<style type="text/tailwindcss">
@layer utilities {
.content-auto {
content-visibility: auto;
}
.card-shadow {
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
.sidebar-item-active {
background-color: rgba(16, 185, 129, 0.1);
color: #10b981;
}
}
</style>
</head>
<body class="font-inter bg-gray-50 text-gray-800 min-h-screen flex flex-col">
<div class="flex flex-1 overflow-hidden">
<!-- 左侧侧边栏 -->
<aside class="w-64 bg-white border-r border-gray-200 flex flex-col">
<!-- Logo区域 -->
<div class="p-4 border-b border-gray-200">
<div class="flex items-center space-x-2">
<div class="w-8 h-8 bg-primary rounded-md flex items-center justify-center">
<i class="fa fa-leaf text-white"></i>
</div>
<span class="font-semibold text-lg">星海科技</span>
<button class="ml-auto text-gray-400 hover:text-gray-600">
<i class="fa fa-angle-left"></i>
</button>
</div>
</div>
<!-- 导航菜单 -->
<nav class="flex-1 py-4 overflow-y-auto">
<ul class="space-y-1 px-3">
<li>
<a href="#" class="flex items-center px-3 py-2.5 rounded-md sidebar-item-active">
<i class="fa fa-th-large w-5 text-center"></i>
<span class="ml-3">总览</span>
</a>
</li>
<li>
<a href="#" class="flex items-center px-3 py-2.5 rounded-md text-gray-600 hover:bg-gray-100">
<i class="fa fa-file-text-o w-5 text-center"></i>
<span class="ml-3">报销单</span>
</a>
</li>
<li>
<a href="#" class="flex items-center px-3 py-2.5 rounded-md text-gray-600 hover:bg-gray-100">
<i class="fa fa-check-square-o w-5 text-center"></i>
<span class="ml-3">审批中心</span>
<span class="ml-auto bg-red text-white text-xs px-2 py-0.5 rounded-full">128</span>
</a>
</li>
<li>
<a href="#" class="flex items-center px-3 py-2.5 rounded-md text-gray-600 hover:bg-gray-100">
<i class="fa fa-exclamation-triangle w-5 text-center"></i>
<span class="ml-3">风险预警</span>
</a>
</li>
<li>
<a href="#" class="flex items-center px-3 py-2.5 rounded-md text-gray-600 hover:bg-gray-100">
<i class="fa fa-ticket w-5 text-center"></i>
<span class="ml-3">发票中心</span>
</a>
</li>
<li>
<a href="#" class="flex items-center px-3 py-2.5 rounded-md text-gray-600 hover:bg-gray-100">
<i class="fa fa-pie-chart w-5 text-center"></i>
<span class="ml-3">预算控制</span>
</a>
</li>
<li>
<a href="#" class="flex items-center px-3 py-2.5 rounded-md text-gray-600 hover:bg-gray-100">
<i class="fa fa-users w-5 text-center"></i>
<span class="ml-3">员工服务</span>
</a>
</li>
<li>
<a href="#" class="flex items-center px-3 py-2.5 rounded-md text-gray-600 hover:bg-gray-100">
<i class="fa fa-cog w-5 text-center"></i>
<span class="ml-3">设置</span>
</a>
</li>
</ul>
</nav>
<!-- 用户信息 -->
<div class="p-4 border-t border-gray-200">
<div class="flex items-center space-x-3">
<img src="https://picsum.photos/id/64/40/40" alt="用户头像" class="w-8 h-8 rounded-full object-cover">
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">张晓明</p>
<p class="text-xs text-gray-500 truncate">财务管理员</p>
</div>
<button class="text-gray-400 hover:text-gray-600">
<i class="fa fa-angle-down"></i>
</button>
</div>
</div>
</aside>
<!-- 主内容区域 -->
<main class="flex-1 overflow-y-auto bg-gray-50">
<!-- 顶部导航栏 -->
<header class="bg-white border-b border-gray-200 sticky top-0 z-10">
<div class="flex items-center justify-between p-4">
<div>
<div class="text-xs text-primary font-medium uppercase tracking-wider">Smart Expense Operations</div>
<h1 class="text-2xl font-bold text-gray-800 mt-1">企业报销智能运营台</h1>
<p class="text-sm text-gray-500 mt-1">面向财务共享中心的审批、风控、SLA与自动化运营看板</p>
</div>
<div class="flex items-center space-x-4">
<!-- 搜索框 -->
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fa fa-search text-gray-400"></i>
</div>
<input type="text" placeholder="搜索申请人、单号、费用类型..." class="pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary w-64">
</div>
<!-- 日期选择器 -->
<div class="relative">
<select class="appearance-none pl-3 pr-8 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-white">
<option>2024-07-06 ~ 2024-07-12</option>
<option>2024-07-01 ~ 2024-07-07</option>
<option>2024-06-30 ~ 2024-07-06</option>
</select>
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<i class="fa fa-calendar-o text-gray-400"></i>
</div>
</div>
<!-- AI助手按钮 -->
<button class="bg-primary hover:bg-primary/90 text-white px-4 py-2 rounded-md flex items-center space-x-2 transition-colors">
<i class="fa fa-magic"></i>
<span>AI助手</span>
</button>
</div>
</div>
<!-- 时间筛选标签 -->
<div class="flex items-center justify-end px-4 pb-3 space-x-2">
<button class="px-3 py-1 text-sm rounded-md bg-white border border-gray-300 hover:bg-gray-50">今日</button>
<button class="px-3 py-1 text-sm rounded-md bg-gray-100 border border-gray-300 font-medium">本周</button>
<button class="px-3 py-1 text-sm rounded-md bg-white border border-gray-300 hover:bg-gray-50">本月</button>
</div>
</header>
<!-- 仪表盘内容 -->
<div class="p-6">
<!-- 统计卡片行 -->
<div class="grid grid-cols-6 gap-6 mb-6">
<!-- 待审批单据 -->
<div class="bg-white rounded-lg p-5 card-shadow">
<div class="flex items-start justify-between">
<div>
<p class="text-sm text-gray-500 mb-1">待审批单据</p>
<p class="text-2xl font-bold text-gray-800">128 <span class="text-sm font-normal text-gray-500"></span></p>
</div>
<div class="w-10 h-10 rounded-full bg-green-50 flex items-center justify-center">
<i class="fa fa-file-text-o text-primary text-lg"></i>
</div>
</div>
<div class="mt-3 flex items-center text-sm">
<span class="text-green-600 flex items-center">
<i class="fa fa-arrow-down mr-1"></i> 12.5%
</span>
<span class="text-gray-500 ml-2">较昨日 -18 单</span>
</div>
</div>
<!-- 待处理金额 -->
<div class="bg-white rounded-lg p-5 card-shadow">
<div class="flex items-start justify-between">
<div>
<p class="text-sm text-gray-500 mb-1">待处理金额</p>
<p class="text-2xl font-bold text-gray-800">¥361,600</p>
</div>
<div class="w-10 h-10 rounded-full bg-blue-50 flex items-center justify-center">
<i class="fa fa-yen text-secondary text-lg"></i>
</div>
</div>
<div class="mt-3 flex items-center text-sm">
<span class="text-red-600 flex items-center">
<i class="fa fa-arrow-up mr-1"></i> 8.3%
</span>
<span class="text-gray-500 ml-2">较昨日 +¥27,400</span>
</div>
</div>
<!-- 平均审批时长 -->
<div class="bg-white rounded-lg p-5 card-shadow">
<div class="flex items-start justify-between">
<div>
<p class="text-sm text-gray-500 mb-1">平均审批时长</p>
<p class="text-2xl font-bold text-gray-800">6.8 <span class="text-sm font-normal text-gray-500">h</span></p>
</div>
<div class="w-10 h-10 rounded-full bg-purple-50 flex items-center justify-center">
<i class="fa fa-clock-o text-purple text-lg"></i>
</div>
</div>
<div class="mt-3 flex items-center text-sm">
<span class="text-green-600 flex items-center">
<i class="fa fa-arrow-down mr-1"></i> 14.8%
</span>
<span class="text-gray-500 ml-2">较昨日 -1.2h</span>
</div>
</div>
<!-- 自动审单通过率 -->
<div class="bg-white rounded-lg p-5 card-shadow">
<div class="flex items-start justify-between">
<div>
<p class="text-sm text-gray-500 mb-1">自动审单通过率</p>
<p class="text-2xl font-bold text-gray-800">78 <span class="text-sm font-normal text-gray-500">%</span></p>
</div>
<div class="w-10 h-10 rounded-full bg-green-50 flex items-center justify-center">
<i class="fa fa-shield text-primary text-lg"></i>
</div>
</div>
<div class="mt-3 flex items-center text-sm">
<span class="text-green-600 flex items-center">
<i class="fa fa-arrow-up mr-1"></i> 6.2%
</span>
<span class="text-gray-500 ml-2">较昨日 +4.6%</span>
</div>
</div>
<!-- 异常预警单 -->
<div class="bg-white rounded-lg p-5 card-shadow">
<div class="flex items-start justify-between">
<div>
<p class="text-sm text-gray-500 mb-1">异常预警单</p>
<p class="text-2xl font-bold text-gray-800">14 <span class="text-sm font-normal text-gray-500"></span></p>
</div>
<div class="w-10 h-10 rounded-full bg-red-50 flex items-center justify-center">
<i class="fa fa-exclamation-triangle text-red text-lg"></i>
</div>
</div>
<div class="mt-3 flex items-center text-sm">
<span class="text-red-600 flex items-center">
<i class="fa fa-arrow-up mr-1"></i> 16.7%
</span>
<span class="text-gray-500 ml-2">较昨日 +2 单</span>
</div>
</div>
<!-- SLA达成率 -->
<div class="bg-white rounded-lg p-5 card-shadow">
<div class="flex items-start justify-between">
<div>
<p class="text-sm text-gray-500 mb-1">SLA达成率</p>
<p class="text-2xl font-bold text-gray-800">96 <span class="text-sm font-normal text-gray-500">%</span></p>
</div>
<div class="w-10 h-10 rounded-full bg-green-50 flex items-center justify-center">
<i class="fa fa-check-circle text-primary text-lg"></i>
</div>
</div>
<div class="mt-3 flex items-center text-sm">
<span class="text-green-600 flex items-center">
<i class="fa fa-arrow-up mr-1"></i> 3.1%
</span>
<span class="text-gray-500 ml-2">较昨日 +2.9%</span>
</div>
</div>
</div>
<!-- 图表行1 -->
<div class="grid grid-cols-12 gap-6 mb-6">
<!-- 报销申请与审批趋势 -->
<div class="col-span-6 bg-white rounded-lg p-5 card-shadow">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-gray-800">报销申请与审批趋势 <i class="fa fa-info-circle text-gray-400 text-xs ml-1"></i></h3>
<select class="text-sm border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-primary">
<option>近12天</option>
<option>近7天</option>
<option>近30天</option>
</select>
</div>
<div class="h-64">
<canvas id="trendChart"></canvas>
</div>
</div>
<!-- 费用结构 -->
<div class="col-span-3 bg-white rounded-lg p-5 card-shadow">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-gray-800">费用结构 <i class="fa fa-info-circle text-gray-400 text-xs ml-1"></i></h3>
</div>
<div class="flex items-center justify-center h-48">
<canvas id="expenseChart"></canvas>
</div>
<p class="text-xs text-gray-500 text-center mt-2">* 百分比为占待处理金额比例</p>
</div>
<!-- 风险异常分布 -->
<div class="col-span-3 bg-white rounded-lg p-5 card-shadow">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-gray-800">风险异常分布 <i class="fa fa-info-circle text-gray-400 text-xs ml-1"></i></h3>
</div>
<div class="flex items-center justify-center h-48">
<canvas id="riskChart"></canvas>
</div>
<p class="text-xs text-gray-500 text-center mt-2">* 近30天数据</p>
</div>
</div>
<!-- 图表行2 -->
<div class="grid grid-cols-12 gap-6">
<!-- 部门报销排行 -->
<div class="col-span-6 bg-white rounded-lg p-5 card-shadow">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-gray-800">部门报销排行(待处理金额) <i class="fa fa-info-circle text-gray-400 text-xs ml-1"></i></h3>
<select class="text-sm border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-primary">
<option>本周</option>
<option>本月</option>
<option>本季度</option>
</select>
</div>
<div class="space-y-4">
<div class="flex items-center">
<span class="w-6 h-6 rounded-full bg-orange text-white flex items-center justify-center text-xs font-medium mr-3">1</span>
<span class="w-16 text-sm text-gray-600">销售部</span>
<div class="flex-1 h-6 bg-gray-100 rounded-full overflow-hidden">
<div class="h-full bg-primary rounded-full" style="width: 100%"></div>
</div>
<span class="ml-3 text-sm font-medium text-gray-700">¥182,000</span>
</div>
<div class="flex items-center">
<span class="w-6 h-6 rounded-full bg-gray-400 text-white flex items-center justify-center text-xs font-medium mr-3">2</span>
<span class="w-16 text-sm text-gray-600">研发中心</span>
<div class="flex-1 h-6 bg-gray-100 rounded-full overflow-hidden">
<div class="h-full bg-secondary rounded-full" style="width: 80%"></div>
</div>
<span class="ml-3 text-sm font-medium text-gray-700">¥146,000</span>
</div>
<div class="flex items-center">
<span class="w-6 h-6 rounded-full bg-orange/80 text-white flex items-center justify-center text-xs font-medium mr-3">3</span>
<span class="w-16 text-sm text-gray-600">市场部</span>
<div class="flex-1 h-6 bg-gray-100 rounded-full overflow-hidden">
<div class="h-full bg-orange rounded-full" style="width: 53%"></div>
</div>
<span class="ml-3 text-sm font-medium text-gray-700">¥96,000</span>
</div>
<div class="flex items-center">
<span class="w-6 h-6 rounded-full bg-gray-500 text-white flex items-center justify-center text-xs font-medium mr-3">4</span>
<span class="w-16 text-sm text-gray-600">运营部</span>
<div class="flex-1 h-6 bg-gray-100 rounded-full overflow-hidden">
<div class="h-full bg-purple rounded-full" style="width: 38%"></div>
</div>
<span class="ml-3 text-sm font-medium text-gray-700">¥68,600</span>
</div>
<div class="flex items-center">
<span class="w-6 h-6 rounded-full bg-gray-600 text-white flex items-center justify-center text-xs font-medium mr-3">5</span>
<span class="w-16 text-sm text-gray-600">行政部</span>
<div class="flex-1 h-6 bg-gray-100 rounded-full overflow-hidden">
<div class="h-full bg-blue-500 rounded-full" style="width: 27%"></div>
</div>
<span class="ml-3 text-sm font-medium text-gray-700">¥48,300</span>
</div>
</div>
</div>
<!-- 审批瓶颈 -->
<div class="col-span-3 bg-white rounded-lg p-5 card-shadow">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-gray-800">审批瓶颈(平均处理时长) <i class="fa fa-info-circle text-gray-400 text-xs ml-1"></i></h3>
</div>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center">
<img src="https://picsum.photos/id/64/32/32" alt="李文静" class="w-8 h-8 rounded-full mr-3">
<div>
<p class="text-sm font-medium text-gray-800">李文静</p>
<p class="text-xs text-gray-500">财务经理</p>
</div>
</div>
<div class="text-right">
<p class="text-sm font-medium text-gray-800">12.4 h</p>
<span class="text-xs bg-red/10 text-red px-2 py-0.5 rounded">较慢</span>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center">
<img src="https://picsum.photos/id/91/32/32" alt="王志强" class="w-8 h-8 rounded-full mr-3">
<div>
<p class="text-sm font-medium text-gray-800">王志强</p>
<p class="text-xs text-gray-500">财务专员</p>
</div>
</div>
<div class="text-right">
<p class="text-sm font-medium text-gray-800">8.7 h</p>
<span class="text-xs bg-orange/10 text-orange px-2 py-0.5 rounded">偏慢</span>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center">
<img src="https://picsum.photos/id/55/32/32" alt="刘思雨" class="w-8 h-8 rounded-full mr-3">
<div>
<p class="text-sm font-medium text-gray-800">刘思雨</p>
<p class="text-xs text-gray-500">费用审核员</p>
</div>
</div>
<div class="text-right">
<p class="text-sm font-medium text-gray-800">5.2 h</p>
<span class="text-xs bg-green/10 text-green-600 px-2 py-0.5 rounded">正常</span>
</div>
</div>
</div>
<div class="mt-4 pt-4 border-t border-gray-100">
<a href="#" class="text-sm text-primary hover:text-primary/80 flex items-center justify-between">
查看全部
<i class="fa fa-angle-right"></i>
</a>
</div>
</div>
<!-- 预算执行率 -->
<div class="col-span-3 bg-white rounded-lg p-5 card-shadow">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-gray-800">预算执行率(本月) <i class="fa fa-info-circle text-gray-400 text-xs ml-1"></i></h3>
</div>
<div class="flex items-center justify-center h-32">
<canvas id="budgetChart"></canvas>
</div>
<div class="grid grid-cols-3 gap-2 mt-4 text-center">
<div>
<p class="text-xs text-gray-500">预算总额</p>
<p class="text-sm font-medium text-gray-800">¥2,800,000</p>
</div>
<div>
<p class="text-xs text-gray-500">已执行</p>
<p class="text-sm font-medium text-gray-800">¥2,128,000</p>
</div>
<div>
<p class="text-xs text-gray-500">剩余可用</p>
<p class="text-sm font-medium text-gray-800">¥672,000</p>
</div>
</div>
<div class="mt-4 pt-4 border-t border-gray-100">
<a href="#" class="text-sm text-primary hover:text-primary/80 flex items-center justify-between">
查看详情
<i class="fa fa-angle-right"></i>
</a>
</div>
</div>
</div>
</div>
</main>
</div>
<script>
// 报销申请与审批趋势图
const trendCtx = document.getElementById('trendChart').getContext('2d');
new Chart(trendCtx, {
type: 'bar',
data: {
labels: ['07-01', '07-02', '07-03', '07-04', '07-05', '07-06', '07-07', '07-08', '07-09', '07-10', '07-12'],
datasets: [
{
label: '申请量(单)',
data: [140, 105, 175, 195, 155, 70, 65, 60, 185, 200, 220],
backgroundColor: '#10b981',
borderRadius: 4,
barPercentage: 0.6,
categoryPercentage: 0.5
},
{
label: '审批完成量(单)',
data: [110, 85, 130, 125, 110, 60, 55, 50, 145, 150, 170],
backgroundColor: '#3b82f6',
borderRadius: 4,
barPercentage: 0.6,
categoryPercentage: 0.5
},
{
label: '平均审批时长(小时)',
data: [10, 8, 9, 7, 7, 6.8, 6, 6.5, 7, 8, 7.5],
borderColor: '#8b5cf6',
backgroundColor: 'transparent',
borderWidth: 2,
pointBackgroundColor: '#ffffff',
pointBorderColor: '#8b5cf6',
pointBorderWidth: 2,
pointRadius: 4,
type: 'line',
yAxisID: 'y1'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: {
position: 'top',
align: 'start',
labels: {
boxWidth: 10,
usePointStyle: true,
pointStyle: 'circle'
}
},
tooltip: {
backgroundColor: 'rgba(255, 255, 255, 0.95)',
titleColor: '#1e293b',
bodyColor: '#64748b',
borderColor: '#e2e8f0',
borderWidth: 1,
padding: 12,
boxPadding: 6,
usePointStyle: true,
callbacks: {
label: function(context) {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
label += context.parsed.y;
}
return label;
}
}
}
},
scales: {
x: {
grid: {
display: false
},
ticks: {
color: '#64748b',
font: {
size: 11
}
}
},
y: {
beginAtZero: true,
max: 250,
grid: {
color: '#f1f5f9'
},
ticks: {
color: '#64748b',
font: {
size: 11
},
stepSize: 50
}
},
y1: {
position: 'right',
beginAtZero: true,
max: 15,
grid: {
display: false
},
ticks: {
color: '#64748b',
font: {
size: 11
},
stepSize: 3
}
}
}
}
});
// 费用结构图
const expenseCtx = document.getElementById('expenseChart').getContext('2d');
new Chart(expenseCtx, {
type: 'doughnut',
data: {
labels: ['机票', '酒店', '火车/用车', '餐补及杂费'],
datasets: [{
data: [182000, 146000, 78600, 55000],
backgroundColor: ['#10b981', '#3b82f6', '#f59e0b', '#8b5cf6'],
borderWidth: 0,
borderRadius: 0,
cutout: '70%'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: {
boxWidth: 10,
usePointStyle: true,
pointStyle: 'circle',
padding: 15,
font: {
size: 12
}
}
},
tooltip: {
backgroundColor: 'rgba(255, 255, 255, 0.95)',
titleColor: '#1e293b',
bodyColor: '#64748b',
borderColor: '#e2e8f0',
borderWidth: 1,
padding: 12,
boxPadding: 6,
usePointStyle: true,
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.parsed;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return `${label}: ¥${value.toLocaleString()} (${percentage}%)`;
}
}
}
}
},
plugins: [{
id: 'centerText',
beforeDraw: function(chart) {
const width = chart.width;
const height = chart.height;
const ctx = chart.ctx;
ctx.restore();
const fontSize = (height / 160).toFixed(2);
ctx.font = `bold ${fontSize}em Inter`;
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
const text = '¥361,600';
const textX = width / 2;
const textY = height / 2 - 10;
ctx.fillStyle = '#1e293b';
ctx.fillText(text, textX, textY);
ctx.font = `${fontSize * 0.6}em Inter`;
ctx.fillStyle = '#64748b';
ctx.fillText('待处理金额', textX, textY + 20);
ctx.save();
}
}]
});
// 风险异常分布图
const riskCtx = document.getElementById('riskChart').getContext('2d');
new Chart(riskCtx, {
type: 'doughnut',
data: {
labels: ['住宿超标', '重复报销', '行程缺失', '发票异常'],
datasets: [{
data: [5, 3, 3, 3],
backgroundColor: ['#ef4444', '#f59e0b', '#8b5cf6', '#3b82f6'],
borderWidth: 0,
borderRadius: 0,
cutout: '70%'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: {
boxWidth: 10,
usePointStyle: true,
pointStyle: 'circle',
padding: 15,
font: {
size: 12
}
}
},
tooltip: {
backgroundColor: 'rgba(255, 255, 255, 0.95)',
titleColor: '#1e293b',
bodyColor: '#64748b',
borderColor: '#e2e8f0',
borderWidth: 1,
padding: 12,
boxPadding: 6,
usePointStyle: true,
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.parsed;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return `${label}: ${value} 单 (${percentage}%)`;
}
}
}
}
},
plugins: [{
id: 'centerText',
beforeDraw: function(chart) {
const width = chart.width;
const height = chart.height;
const ctx = chart.ctx;
ctx.restore();
const fontSize = (height / 160).toFixed(2);
ctx.font = `bold ${fontSize}em Inter`;
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
const text = '14';
const textX = width / 2;
const textY = height / 2 - 10;
ctx.fillStyle = '#1e293b';
ctx.fillText(text, textX, textY);
ctx.font = `${fontSize * 0.6}em Inter`;
ctx.fillStyle = '#64748b';
ctx.fillText('异常预警单', textX, textY + 20);
ctx.save();
}
}]
});
// 预算执行率图
const budgetCtx = document.getElementById('budgetChart').getContext('2d');
new Chart(budgetCtx, {
type: 'doughnut',
data: {
datasets: [{
data: [76, 24],
backgroundColor: ['#10b981', '#e2e8f0'],
borderWidth: 0,
borderRadius: 0,
cutout: '75%'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
enabled: false
}
},
rotation: -90,
circumference: 180
},
plugins: [{
id: 'centerText',
beforeDraw: function(chart) {
const width = chart.width;
const height = chart.height;
const ctx = chart.ctx;
ctx.restore();
const fontSize = (height / 80).toFixed(2);
ctx.font = `bold ${fontSize}em Inter`;
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
const text = '76%';
const textX = width / 2;
const textY = height / 2 + 10;
ctx.fillStyle = '#10b981';
ctx.fillText(text, textX, textY);
ctx.font = `${fontSize * 0.5}em Inter`;
ctx.fillStyle = '#64748b';
ctx.fillText('已执行', textX, textY + 25);
ctx.save();
}
}]
});
</script>
</body>
</html>

14
web/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/lxgw-wenkai/1.501/lxgw-wenkai.min.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css" />
<title>ReimburseOps - 企业报销智能运营台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1868
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
web/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "x-financial-reimbursement-admin",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"start": "vite --host 127.0.0.1",
"dev": "vite --host 127.0.0.1",
"build": "vite build",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"@primevue/themes": "^4.5.4",
"@vitejs/plugin-vue": "^5.2.4",
"@vueuse/motion": "^3.0.3",
"chart.js": "^4.5.1",
"primeicons": "^7.0.0",
"primevue": "^4.5.5",
"vite": "^5.4.19",
"vue": "^3.5.13",
"vue-chartjs": "^5.3.3"
}
}

201
web/src/App.vue Normal file
View File

@@ -0,0 +1,201 @@
<template>
<!-- Login Page -->
<LoginView
v-if="!loggedIn"
@login="handleLogin"
@recover-password="handleRecoverPassword"
@sso-login="handleSsoLogin"
/>
<!-- Main App -->
<div v-else class="app">
<SidebarRail
:nav-items="navItems"
:active-view="activeView"
@navigate="handleNavigate"
@open-chat="handleOpenChat"
/>
<main
class="main"
:class="{
'chat-main': activeView === 'chat',
'overview-main': activeView === 'overview',
'workbench-main': activeView === 'workbench',
'requests-main': activeView === 'requests',
'approval-main': activeView === 'approval',
'policies-main': activeView === 'policies',
'audit-main': activeView === 'audit',
'employees-main': activeView === 'employees'
}"
>
<TopBar
:current-view="topBarView"
:search="search"
:active-view="activeView"
:ranges="ranges"
:active-range="activeRange"
:custom-range="customRange"
@update:search="search = $event"
@update:active-range="activeRange = $event"
@update:custom-range="customRange = $event"
@batch-approve="toast('已筛出 23 个低风险单据可进入批量通过确认')"
@open-chat="handleOpenChat"
@new-application="openTravelCreate"
/>
<FilterBar
v-if="activeView !== 'chat' && activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'employees'"
:compact="activeView === 'overview'"
:filters="filters"
:ranges="ranges"
:active-range="activeRange"
@update:active-range="activeRange = $event"
/>
<section
class="workarea"
:class="{
'chat-workarea': activeView === 'chat',
'requests-workarea': activeView === 'requests',
'approval-workarea': activeView === 'approval',
'policies-workarea': activeView === 'policies',
'audit-workarea': activeView === 'audit',
'employees-workarea': activeView === 'employees'
}"
>
<OverviewView
v-if="activeView === 'overview'"
:filtered-requests="filteredRequests"
@ask="handleOpenChat"
@approve="handleApprove"
@reject="handleReject"
/>
<PersonalWorkbenchView
v-else-if="activeView === 'workbench'"
@open-assistant="openSmartEntry"
/>
<ChatView
v-else-if="activeView === 'chat'"
:documents="filteredDocuments"
:doc-search="docSearch"
:messages="messages"
:uploaded-files="uploadedFiles"
:active-case="activeCase"
:quick-prompts="travelPrompts"
:draft="draft"
:message-list="messageList"
@send="sendMessage"
@upload="handleUpload"
@draft="draft = $event"
@select-case="handleOpenChat"
@approve-case="toast(`${activeCase?.id} 已生成通过意见`)"
@reject-case="toast(`${activeCase?.id} 已转人工复核`)"
/>
<TravelRequestDetailView
v-else-if="activeView === 'requests' && detailMode"
:request="selectedTravelRequest"
@back-to-requests="closeRequestDetail"
@open-assistant="openSmartEntry"
/>
<RequestsView
v-else-if="activeView === 'requests'"
:filtered-requests="filteredRequests"
@ask="openRequestDetail"
@approve="handleApprove"
@reject="handleReject"
@create-request="openTravelCreate"
/>
<ApprovalCenterView v-else-if="activeView === 'approval'" />
<PoliciesView v-else-if="activeView === 'policies'" />
<AuditView v-else-if="activeView === 'audit'" />
<EmployeeManagementView v-else />
</section>
</main>
<TravelReimbursementCreateView
v-if="smartEntryOpen"
:key="smartEntrySessionId"
:initial-prompt="smartEntryContext.prompt"
:entry-source="smartEntryContext.source"
:request-context="smartEntryContext.request"
@close="closeSmartEntry"
/>
</div>
<ToastNotification :toast-text="toastText" />
</template>
<script setup>
import './assets/styles/global.css'
import SidebarRail from './components/layout/SidebarRail.vue'
import TopBar from './components/layout/TopBar.vue'
import FilterBar from './components/layout/FilterBar.vue'
import ToastNotification from './components/shared/ToastNotification.vue'
import LoginView from './views/LoginView.vue'
import OverviewView from './views/OverviewView.vue'
import PersonalWorkbenchView from './views/PersonalWorkbenchView.vue'
import ChatView from './views/ChatView.vue'
import TravelReimbursementCreateView from './views/TravelReimbursementCreateView.vue'
import TravelRequestDetailView from './views/TravelRequestDetailView.vue'
import RequestsView from './views/RequestsView.vue'
import ApprovalCenterView from './views/ApprovalCenterView.vue'
import PoliciesView from './views/PoliciesView.vue'
import AuditView from './views/AuditView.vue'
import EmployeeManagementView from './views/EmployeeManagementView.vue'
import { useAppShell } from './composables/useAppShell.js'
const {
activeCase,
activeRange,
activeView,
closeRequestDetail,
closeSmartEntry,
customRange,
detailMode,
docSearch,
draft,
filteredDocuments,
filteredRequests,
filters,
handleApprove,
handleLogin,
handleNavigate,
handleOpenChat,
handleRecoverPassword,
handleReject,
handleSsoLogin,
handleUpload,
loggedIn,
messageList,
messages,
navItems,
openRequestDetail,
openSmartEntry,
openTravelCreate,
ranges,
search,
selectedTravelRequest,
sendMessage,
smartEntryContext,
smartEntryOpen,
smartEntrySessionId,
toast,
toastText,
topBarView,
travelPrompts,
uploadedFiles
} = useAppShell()
</script>
<style scoped src="./assets/styles/app.css"></style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 937 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

View File

@@ -0,0 +1,49 @@
.app {
min-height: 100dvh;
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
background: var(--bg);
}
.main { min-width: 0; display: grid; grid-template-rows: auto auto minmax(0, 1fr); }
.main.overview-main {
grid-template-rows: auto minmax(0, 1fr);
}
.main.workbench-main {
grid-template-rows: auto minmax(0, 1fr);
}
.main.chat-main {
height: 100dvh;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
}
.main.requests-main,
.main.approval-main,
.main.policies-main,
.main.audit-main,
.main.employees-main {
height: 100dvh;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
}
.workarea { overflow: auto; padding: 24px; }
.workarea.chat-workarea {
min-height: 0;
overflow: hidden;
}
.workarea.requests-workarea,
.workarea.approval-workarea,
.workarea.policies-workarea,
.workarea.audit-workarea,
.workarea.employees-workarea {
min-height: 0;
overflow: hidden;
padding: 20px 24px;
}
@media (max-width: 1180px) {
.app { grid-template-columns: 220px minmax(0, 1fr); }
}
@media (max-width: 760px) {
.app { display: block; }
.workarea { padding: 18px 16px 28px; }
}

View File

@@ -0,0 +1,98 @@
:root {
--bg: #f8fafc;
--surface: #fff;
--surface-soft: #f9fbff;
--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;
--ease: cubic-bezier(.2, .8, .2, 1);
font-family: "LXGW WenKai", Inter, "SF Pro Display", "PingFang SC", sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100dvh; color: var(--text); background: var(--bg); }
.mdi { line-height: 1; vertical-align: middle; }
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(16,185,129,.20); outline-offset: 2px; }
.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: 24px; line-height: 1.25; font-weight: 700; }
.btn {
min-height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 14px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
color: var(--text);
font-weight: 700;
transition: transform 180ms var(--ease), box-shadow 180ms var(--ease), background 180ms var(--ease), border-color 180ms var(--ease);
}
.btn:hover { transform: translateY(-1px); box-shadow: 0 10px 24px rgba(16,24,40,.08); }
.btn:active, .mini-btn:active, .chip:active, .nav-btn:active { transform: scale(.97); }
.btn.primary { border-color: transparent; background: var(--primary); color: #fff; box-shadow: 0 12px 24px rgba(51,92,255,.22); }
.btn.success { border-color: transparent; background: var(--success); color: #fff; }
.btn.danger { border-color: rgba(180,35,24,.18); background: var(--danger-soft); color: var(--danger); }
.btn.ghost { background: transparent; }
.badge { display: inline-flex; min-height: 26px; align-items: center; padding: 4px 9px; border-radius: 999px; background: var(--primary-soft); color: var(--primary); font-size: 12px; font-weight: 780; white-space: nowrap; }
.badge.success { background: var(--success-soft); color: var(--success); }
.badge.warning { background: var(--warning-soft); color: var(--warning); }
.badge.danger { background: var(--danger-soft); color: var(--danger); }
.panel {
border-radius: var(--radius);
background: var(--surface);
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 { 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; }
@keyframes grow { from { transform: scaleX(0); transform-origin: left; } to { transform: scaleX(1); transform-origin: left; } }
@keyframes fadeUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.message-list-enter-active, .message-list-leave-active { transition: opacity 220ms var(--ease), transform 220ms var(--ease); }
.message-list-enter-from { opacity: 0; transform: translateY(8px) scale(.98); }
.message-list-leave-to { opacity: 0; transform: translateY(-6px) scale(.98); }
@media (max-width: 1180px) {
.metric-strip, .overview-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 760px) {
.topbar { flex-direction: column; padding: 18px 16px; }
.top-actions, .search, .btn { width: 100%; }
.filters, .metric-strip, .overview-grid, .donut-layout, .dialog-body, .dialog-foot, .review-summary, .chat-hero { grid-template-columns: 1fr; }
.filters, .workarea { padding-inline: 16px; }
.bar-row { grid-template-columns: 1fr; gap: 6px; }
.bar-row strong { text-align: left; }
.case-panel { border-left: 0; border-top: 1px solid var(--line); }
.review-summary { grid-column: auto; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 1ms !important; transition-duration: 1ms !important; scroll-behavior: auto !important; }
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,676 @@
.skill-center {
height: 100%;
min-height: 0;
}
.skill-view-enter-active,
.skill-view-leave-active {
transition: opacity 220ms ease, transform 300ms var(--ease);
}
.skill-view-enter-from,
.skill-view-leave-to {
opacity: 0;
transform: translateY(14px);
}
.skill-list,
.skill-detail {
height: 100%;
min-height: 0;
}
.skill-detail {
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
gap: 12px;
}
.skill-list {
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr);
padding: 18px 20px;
}
.status-tabs {
display: flex;
gap: 18px;
padding-bottom: 12px;
border-bottom: 1px solid #edf2f7;
}
.status-tabs button {
position: relative;
border: 0;
background: transparent;
color: #64748b;
font-size: 14px;
font-weight: 760;
}
.status-tabs button.active {
color: #0f172a;
}
.status-tabs button.active::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -13px;
height: 3px;
border-radius: 999px;
background: #10b981;
}
.list-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 0 10px;
}
.filter-set {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.filter-btn,
.create-btn,
.row-action {
min-height: 36px;
border-radius: 8px;
font-size: 13px;
font-weight: 760;
}
.filter-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 12px;
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
}
.create-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 14px;
border: 0;
background: #059669;
color: #fff;
box-shadow: 0 8px 18px rgba(5, 150, 105, 0.18);
}
.hint {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0 0 12px;
color: #64748b;
font-size: 12px;
}
.hint .mdi {
color: #94a3b8;
}
.table-wrap {
min-height: 0;
overflow: auto;
border: 1px solid #edf2f7;
border-radius: 12px;
}
table {
width: 100%;
min-width: 1120px;
border-collapse: collapse;
}
th,
td {
padding: 14px 12px;
border-bottom: 1px solid #edf2f7;
text-align: left;
vertical-align: middle;
color: #334155;
font-size: 12px;
}
th {
background: #f8fafc;
color: #64748b;
font-weight: 800;
white-space: nowrap;
}
tbody tr {
cursor: pointer;
transition: background 180ms ease;
}
tbody tr:hover {
background: #f8fbff;
}
tbody tr.spotlight {
background: linear-gradient(90deg, rgba(16, 185, 129, 0.05), rgba(59, 130, 246, 0.03));
}
.skill-name-cell {
display: grid;
grid-template-columns: 38px minmax(0, 1fr);
gap: 10px;
align-items: center;
}
.skill-avatar {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border-radius: 11px;
color: #fff;
font-size: 13px;
font-weight: 900;
}
.skill-avatar.emerald { background: linear-gradient(135deg, #10b981, #059669); }
.skill-avatar.violet { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
.skill-avatar.blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
.skill-avatar.amber { background: linear-gradient(135deg, #f59e0b, #ea580c); }
.skill-name-cell strong {
display: block;
color: #0f172a;
font-size: 13px;
font-weight: 850;
}
.skill-name-cell span:last-child {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.scope-pill,
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 26px;
padding: 0 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
white-space: nowrap;
}
.scope-pill {
background: #f1f5f9;
color: #475569;
}
.status-pill.success {
background: #dcfce7;
color: #059669;
}
.status-pill.warning {
background: #fff7ed;
color: #ea580c;
}
.status-pill.draft {
background: #eef2ff;
color: #6366f1;
}
.row-action {
padding: 0 12px;
border: 1px solid rgba(16, 185, 129, 0.32);
background: #fff;
color: #059669;
}
.detail-scroll {
height: 100%;
overflow: auto;
display: grid;
gap: 16px;
}
.detail-hero {
display: grid;
gap: 18px;
padding: 18px 20px;
}
.hero-title {
min-width: 0;
}
.skill-badge {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 8px;
border-radius: 999px;
color: #fff;
font-size: 11px;
font-weight: 800;
}
.skill-badge.emerald { background: linear-gradient(135deg, #10b981, #059669); }
.skill-badge.violet { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
.skill-badge.blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
.skill-badge.amber { background: linear-gradient(135deg, #f59e0b, #ea580c); }
.hero-title h2 {
margin-top: 10px;
color: #0f172a;
font-size: 24px;
font-weight: 850;
}
.hero-title p {
margin-top: 8px;
max-width: 820px;
color: #64748b;
font-size: 14px;
line-height: 1.6;
}
.hero-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.hero-stat {
padding: 14px 16px;
border-radius: 12px;
background: linear-gradient(180deg, #ffffff, #f8fafc);
border: 1px solid #edf2f7;
}
.hero-stat span {
display: block;
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.hero-stat strong {
display: block;
margin-top: 8px;
color: #0f172a;
font-size: 20px;
font-weight: 850;
}
.detail-grid {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.78fr);
gap: 16px;
}
.detail-main,
.detail-side {
display: grid;
gap: 16px;
align-content: start;
}
.detail-card,
.side-card {
padding: 18px;
}
.card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.card-head h3 {
color: #0f172a;
font-size: 16px;
font-weight: 850;
}
.card-head p {
margin-top: 6px;
color: #64748b;
font-size: 13px;
line-height: 1.5;
}
.edit-badge {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
background: #ecfeff;
color: #0891b2;
font-size: 12px;
font-weight: 800;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.field {
display: grid;
gap: 7px;
}
.field.span-2 {
grid-column: span 2;
}
.field span {
color: #64748b;
font-size: 12px;
font-weight: 800;
}
.field input,
.field textarea,
.prompt-block textarea {
width: 100%;
border: 1px solid #d7e0ea;
border-radius: 10px;
background: #fff;
color: #0f172a;
font-size: 13px;
line-height: 1.55;
padding: 10px 12px;
resize: vertical;
}
.prompt-stack {
display: grid;
gap: 14px;
}
.prompt-block {
padding: 14px;
border: 1px solid #edf2f7;
border-radius: 12px;
background: #fbfdff;
}
.prompt-block header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.prompt-block strong {
color: #0f172a;
font-size: 14px;
font-weight: 850;
}
.prompt-block header span {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.contract-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.contract-panel {
padding: 14px;
border: 1px solid #edf2f7;
border-radius: 12px;
background: #fbfdff;
}
.contract-panel h4 {
margin: 0 0 10px;
color: #0f172a;
font-size: 14px;
font-weight: 850;
}
.contract-panel ul {
display: grid;
gap: 8px;
margin: 0;
padding-left: 18px;
color: #475569;
font-size: 13px;
line-height: 1.6;
}
.test-row,
.tool-row,
.history-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
padding: 12px 0;
border-top: 1px solid #edf2f7;
}
.test-row:first-child,
.tool-row:first-child,
.history-row:first-child {
border-top: 0;
padding-top: 0;
}
.test-row strong,
.tool-row strong,
.history-row strong {
display: block;
color: #0f172a;
font-size: 13px;
font-weight: 800;
}
.test-row span,
.tool-row span,
.history-row span,
.history-row small {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.test-state,
.tool-state {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
white-space: nowrap;
}
.test-state.success,
.tool-state.safe {
background: #dcfce7;
color: #059669;
}
.test-state.warning,
.tool-state.active {
background: #fff7ed;
color: #ea580c;
}
.tag-list {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.tag-list span {
min-height: 28px;
display: inline-flex;
align-items: center;
padding: 0 10px;
border-radius: 999px;
background: #eff6ff;
color: #2563eb;
font-size: 12px;
font-weight: 800;
}
.publish-card {
display: grid;
gap: 14px;
}
.publish-card p,
.publish-summary span {
margin-top: 6px;
color: #64748b;
font-size: 13px;
line-height: 1.55;
}
.publish-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 42px;
padding: 0 12px;
border-radius: 10px;
background: #f8fafc;
}
.publish-summary strong {
color: #059669;
font-size: 14px;
font-weight: 850;
}
.detail-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 0 0;
border-top: 1px solid #e5eaf0;
}
.detail-action-group {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.back-action,
.minor-action,
.major-action {
height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 760;
}
.back-action {
border: 1px solid #d7e0ea;
background: #fff;
color: #475569;
}
.minor-action {
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
}
.major-action {
border: 1px solid #059669;
background: #059669;
color: #fff;
box-shadow: 0 4px 12px rgba(5, 150, 105, .16);
}
.mini-btn.primary {
border-color: transparent;
background: #059669;
color: #fff;
}
@media (max-width: 1320px) {
.hero-stats,
.form-grid,
.contract-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.detail-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 860px) {
.skill-list,
.detail-card,
.side-card,
.detail-hero {
padding: 16px;
}
.list-toolbar,
.card-head,
.detail-actions,
.detail-action-group {
flex-direction: column;
align-items: stretch;
}
.status-tabs,
.filter-set {
overflow-x: auto;
}
.hero-stats,
.form-grid,
.contract-grid {
grid-template-columns: 1fr;
}
.field.span-2 {
grid-column: span 1;
}
}

View File

@@ -0,0 +1,77 @@
.qa-view { height: 100%; min-height: 0; display: grid; grid-template-rows: minmax(0, 1fr); gap: 0; overflow: hidden; animation: fadeUp 240ms var(--ease) both; }
.qa-layout { height: 100%; min-height: 0; display: grid; grid-template-columns: 330px minmax(0, 1fr) 395px; gap: 14px; overflow: hidden; }
.left-column, .right-column { height: 100%; min-height: 0; overflow: hidden; }
.left-column { display: grid; grid-template-rows: minmax(0, 1fr); }
.right-column { display: grid; grid-template-rows: minmax(0, 1.06fr) minmax(0, .94fr); gap: 12px; }
.side-panel, .info-panel { min-height: 0; padding: 16px 20px; overflow: hidden; }
.conversation-list, .info-panel { display: grid; grid-template-rows: auto minmax(0, 1fr); }
.side-panel header, .info-panel header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.side-panel h3, .info-panel h3 { display: inline-flex; align-items: center; gap: 8px; color: #0f172a; font-size: 17px; font-weight: 850; }
.outline-action, .info-panel header button { height: 34px; display: inline-flex; align-items: center; gap: 6px; border: 1px solid #d8e3ed; border-radius: 8px; background: #fff; color: #0f9f78; font-size: 13px; font-weight: 750; white-space: nowrap; }
.outline-action { padding: 0 12px; }
.info-panel header button { border: 0; color: #64748b; }
.session-scroll, .top-question-list, .similar-scroll { min-height: 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #cbd5e1 transparent; }
.session-scroll { display: grid; align-content: start; gap: 4px; padding-right: 4px; }
.session-row { width: 100%; min-height: 48px; display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 0 10px; border: 0; border-radius: 8px; background: transparent; color: #334155; text-align: left; }
.session-row.active, .session-row:hover { background: #eaf8f1; color: #0f8f68; }
.session-row span { color: #10b981; }
.session-row strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 700; }
.session-row time { color: #94a3b8; font-size: 12px; }
.chat-panel { height: 100%; min-height: 0; display: grid; grid-template-rows: minmax(0, 1fr) auto; overflow: hidden; }
.message-stream { min-height: 0; display: grid; align-content: start; gap: 16px; padding: 16px 18px 8px; overflow-y: auto; scrollbar-width: thin; }
.talk-row { display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 12px; align-items: start; }
.avatar { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 999px; color: #fff; font-size: 15px; font-weight: 850; }
.user-avatar { background: linear-gradient(135deg, #26364d, #61748a); }
.assistant-avatar { background: #10b981; font-size: 20px; }
.talk-content header { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
.talk-content header strong { color: #334155; font-size: 14px; font-weight: 800; }
.talk-content header time { color: #94a3b8; font-size: 12px; }
.user-question { display: inline-block; margin: 0; padding: 9px 16px; border-radius: 8px; background: #e8f5ef; color: #334155; font-size: 14px; line-height: 1.5; }
.answer-card, .agent-answer { max-width: 760px; border: 1px solid #dce5ef; border-radius: 10px; background: #fff; color: #334155; box-shadow: 0 8px 24px rgba(15, 23, 42, .04); }
.answer-card { display: grid; gap: 10px; padding: 13px 18px; }
.answer-card.compact { gap: 10px; }
.answer-card h4 { margin: 0 0 5px; color: #10a272; font-size: 13px; font-weight: 850; }
.answer-card p, .answer-card ul { margin: 0; font-size: 14px; line-height: 1.58; }
.answer-card ul { padding-left: 18px; }
.answer-card footer { display: flex; align-items: center; justify-content: flex-end; gap: 10px; color: #64748b; font-size: 12px; }
.answer-card footer button { width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: 6px; background: transparent; color: #64748b; }
.answer-card footer button:hover { background: #f1f5f9; color: #0f9f78; }
.agent-answer { margin: 0; padding: 12px 16px; font-size: 14px; line-height: 1.65; }
.composer-wrap { border-top: 1px solid #eef2f7; padding: 10px 14px 12px; background: #fff; }
.prompt-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; overflow-x: auto; }
.prompt-toolbar span { flex: 0 0 auto; color: #64748b; font-size: 13px; font-weight: 800; }
.prompt-toolbar button { height: 34px; flex: 0 0 auto; display: inline-flex; align-items: center; gap: 7px; padding: 0 14px; border: 1px solid #dce5ef; border-radius: 8px; background: #fff; color: #334155; font-size: 13px; font-weight: 750; }
.prompt-toolbar button i { color: #10b981; }
.prompt-toolbar .icon-refresh { width: 34px; padding: 0; justify-content: center; }
.composer { min-height: 64px; display: grid; grid-template-columns: minmax(0, 1fr) 48px; align-items: center; gap: 10px; padding: 8px; border: 1px solid #cbd8e5; border-radius: 8px; background: linear-gradient(180deg, #fff, #fbfdff); box-shadow: 0 1px 2px rgba(15, 23, 42, .04); transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease; }
.composer:focus-within { border-color: rgba(16, 185, 129, .58); background: #fff; box-shadow: 0 0 0 3px rgba(16, 185, 129, .11), 0 10px 24px rgba(15, 23, 42, .06); }
.composer textarea { height: 48px; min-height: 48px; resize: none; border: 0; padding: 5px 8px; background: transparent; color: #0f172a; font-size: 14px; line-height: 1.55; }
.composer textarea::placeholder { color: #94a3b8; }
.composer textarea:focus { outline: none; }
.send-button { width: 48px; height: 48px; display: grid; place-items: center; border: 0; border-radius: 8px; background: #10b981; color: #fff; font-size: 20px; box-shadow: 0 8px 18px rgba(16, 185, 129, .20); transition: background 160ms ease, transform 160ms ease, box-shadow 160ms ease; }
.send-button:hover { background: #0ea672; box-shadow: 0 10px 22px rgba(16, 185, 129, .24); }
.send-button:active { transform: scale(.96); }
.hot-top-panel h3 i { color: #ef4444; }
.top-question-list { display: grid; align-content: start; gap: 8px; padding-right: 4px; }
.top-question-list button { min-height: 42px; display: grid; grid-template-columns: 34px minmax(0, 1fr) 14px; align-items: center; gap: 10px; padding: 0 8px; border: 1px solid #e2e8f0; border-radius: 8px; background: #fff; color: #334155; text-align: left; }
.top-question-list button:hover { border-color: rgba(16, 185, 129, .32); color: #0f9f78; }
.top-question-list strong { color: #10b981; font-size: 13px; font-weight: 850; font-variant-numeric: tabular-nums; }
.top-question-list span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 750; }
.top-question-list i { color: #94a3b8; }
.similar-panel { display: grid; }
.similar-scroll { display: grid; align-content: start; padding-right: 4px; }
.similar-row { min-height: 46px; display: grid; grid-template-columns: minmax(0, 1fr) 48px 14px; align-items: center; gap: 10px; border: 0; border-top: 1px solid #eef2f7; background: transparent; color: #334155; text-align: left; }
.similar-row:first-child { border-top: 0; }
.similar-row span { min-width: 0; display: inline-flex; align-items: center; gap: 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 700; }
.similar-row span i { color: #64748b; font-size: 17px; }
.similar-row strong { height: 26px; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; background: #e8f8f0; color: #15945f; font-size: 13px; font-weight: 850; }
.similar-row > i { color: #94a3b8; }
@media (max-width: 1480px) { .qa-layout { grid-template-columns: 300px minmax(0, 1fr) 360px; } }
@media (max-width: 1280px) {
.qa-layout { grid-template-columns: 1fr; overflow-y: auto; }
.left-column, .right-column { grid-template-columns: repeat(2, minmax(0, 1fr)); overflow: visible; }
}
@media (max-width: 760px) {
.left-column, .right-column { grid-template-columns: 1fr; }
.composer { grid-template-columns: minmax(0, 1fr) 48px; }
}

View File

@@ -0,0 +1,658 @@
.employee-center {
height: 100%;
min-height: 0;
}
.employee-view-enter-active,
.employee-view-leave-active {
transition: opacity 220ms ease, transform 280ms var(--ease);
}
.employee-view-enter-from,
.employee-view-leave-to {
opacity: 0;
transform: translateY(14px);
}
.employee-list,
.employee-detail {
height: 100%;
min-height: 0;
}
.employee-list {
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr);
padding: 18px 20px;
}
.employee-detail {
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
gap: 12px;
}
.status-tabs {
display: flex;
gap: 18px;
padding-bottom: 12px;
border-bottom: 1px solid #edf2f7;
}
.status-tabs button {
position: relative;
border: 0;
background: transparent;
color: #64748b;
font-size: 14px;
font-weight: 760;
}
.status-tabs button.active {
color: #0f172a;
}
.status-tabs button.active::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -13px;
height: 3px;
border-radius: 999px;
background: #10b981;
}
.list-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 0 10px;
}
.filter-set {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.list-search {
position: relative;
width: 240px;
}
.list-search .mdi {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #64748b;
font-size: 15px;
}
.list-search input {
width: 100%;
height: 38px;
padding: 0 12px 0 36px;
border: 1px solid #d7e0ea;
border-radius: 8px;
background: #fff;
color: #0f172a;
font-size: 13px;
}
.filter-btn,
.create-btn,
.row-action {
min-height: 36px;
border-radius: 8px;
font-size: 13px;
font-weight: 760;
}
.filter-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 12px;
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
}
.create-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 14px;
border: 0;
background: #059669;
color: #fff;
box-shadow: 0 8px 18px rgba(5, 150, 105, 0.18);
}
.hint {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0 0 12px;
color: #64748b;
font-size: 12px;
}
.table-wrap {
min-height: 0;
overflow: auto;
border: 1px solid #edf2f7;
border-radius: 12px;
}
table {
width: 100%;
min-width: 1320px;
border-collapse: collapse;
}
th,
td {
padding: 14px 12px;
border-bottom: 1px solid #edf2f7;
text-align: center;
vertical-align: middle;
color: #334155;
font-size: 12px;
}
th {
background: #f8fafc;
color: #64748b;
font-weight: 800;
white-space: nowrap;
}
tbody tr {
cursor: pointer;
transition: background 180ms ease;
}
tbody tr:hover {
background: #f8fbff;
}
tbody tr.spotlight {
background: linear-gradient(90deg, rgba(16, 185, 129, 0.05), rgba(59, 130, 246, 0.03));
}
.employee-cell {
display: grid;
grid-template-columns: 38px minmax(0, 1fr);
gap: 10px;
align-items: center;
text-align: left;
}
.employee-avatar {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border-radius: 11px;
background: linear-gradient(135deg, #10b981, #059669);
color: #fff;
font-size: 13px;
font-weight: 900;
}
.employee-cell strong {
display: block;
color: #0f172a;
font-size: 13px;
font-weight: 850;
}
.employee-cell span:last-child {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.level-pill,
.status-pill,
.role-pill,
.more-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 26px;
padding: 0 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
white-space: nowrap;
}
.level-pill {
background: #eef2ff;
color: #4f46e5;
}
.status-pill.success {
background: #dcfce7;
color: #059669;
}
.status-pill.warning {
background: #fff7ed;
color: #ea580c;
}
.status-pill.neutral {
background: #f1f5f9;
color: #475569;
}
.role-stack {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.role-pill {
background: #ecfdf5;
color: #059669;
}
.more-pill {
background: #f1f5f9;
color: #475569;
}
.row-action {
padding: 0 12px;
border: 1px solid rgba(16, 185, 129, 0.32);
background: #fff;
color: #059669;
}
.detail-scroll {
height: 100%;
overflow: auto;
display: grid;
gap: 16px;
}
.detail-hero {
display: grid;
gap: 18px;
padding: 18px 20px;
}
.hero-profile {
display: flex;
align-items: center;
gap: 16px;
}
.hero-avatar {
width: 64px;
height: 64px;
display: grid;
place-items: center;
border-radius: 18px;
background: linear-gradient(135deg, #10b981, #047857);
color: #fff;
font-size: 24px;
font-weight: 900;
}
.hero-tag {
display: inline-flex;
align-items: center;
min-height: 26px;
padding: 0 10px;
border-radius: 999px;
background: #eff6ff;
color: #2563eb;
font-size: 12px;
font-weight: 800;
}
.hero-copy h2 {
margin-top: 10px;
color: #0f172a;
font-size: 24px;
font-weight: 850;
}
.hero-copy p {
margin-top: 8px;
color: #64748b;
font-size: 14px;
line-height: 1.6;
}
.hero-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.hero-stat {
padding: 14px 16px;
border-radius: 12px;
background: linear-gradient(180deg, #ffffff, #f8fafc);
border: 1px solid #edf2f7;
}
.hero-stat span {
display: block;
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.hero-stat strong {
display: block;
margin-top: 8px;
color: #0f172a;
font-size: 18px;
font-weight: 850;
}
.detail-grid {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.82fr);
gap: 16px;
}
.detail-main,
.detail-side {
display: grid;
gap: 16px;
align-content: start;
}
.detail-card,
.side-card {
padding: 18px;
}
.card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.card-head h3 {
color: #0f172a;
font-size: 16px;
font-weight: 850;
}
.card-head p {
margin-top: 6px;
color: #64748b;
font-size: 13px;
line-height: 1.5;
}
.count-badge {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
background: #ecfeff;
color: #0891b2;
font-size: 12px;
font-weight: 800;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.field {
display: grid;
gap: 7px;
}
.field span {
color: #64748b;
font-size: 12px;
font-weight: 800;
}
.field input {
width: 100%;
border: 1px solid #d7e0ea;
border-radius: 10px;
background: #fff;
color: #0f172a;
font-size: 13px;
line-height: 1.55;
padding: 10px 12px;
}
.role-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.role-card {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
gap: 12px;
align-items: start;
padding: 14px;
border: 1px solid #edf2f7;
border-radius: 14px;
background: #fbfdff;
}
.role-card.active {
border-color: rgba(16, 185, 129, 0.32);
background: linear-gradient(180deg, rgba(240, 253, 244, 0.85), #ffffff);
}
.role-card input {
margin-top: 3px;
}
.role-copy strong {
display: block;
color: #0f172a;
font-size: 14px;
font-weight: 850;
}
.role-copy p {
margin-top: 6px;
color: #64748b;
font-size: 12px;
line-height: 1.55;
}
.tag-list {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.bullet-list {
display: grid;
gap: 8px;
margin: 14px 0 0;
padding-left: 18px;
color: #475569;
font-size: 13px;
line-height: 1.6;
}
.history-list {
display: grid;
}
.history-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
padding: 12px 0;
border-top: 1px solid #edf2f7;
}
.history-row:first-child {
border-top: 0;
padding-top: 0;
}
.history-row strong {
display: block;
color: #0f172a;
font-size: 13px;
font-weight: 800;
}
.history-row span,
.history-row small {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.publish-card {
display: grid;
gap: 14px;
}
.publish-card p,
.publish-summary span {
margin-top: 6px;
color: #64748b;
font-size: 13px;
line-height: 1.55;
}
.publish-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 42px;
padding: 0 12px;
border-radius: 10px;
background: #f8fafc;
}
.publish-summary strong {
color: #059669;
font-size: 14px;
font-weight: 850;
}
.detail-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 0 0;
border-top: 1px solid #e5eaf0;
}
.detail-action-group {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.back-action,
.minor-action,
.major-action {
height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 760;
}
.back-action {
border: 1px solid #d7e0ea;
background: #fff;
color: #475569;
}
.minor-action {
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
}
.major-action {
border: 1px solid #059669;
background: #059669;
color: #fff;
box-shadow: 0 4px 12px rgba(5, 150, 105, 0.16);
}
@media (max-width: 1320px) {
.hero-stats,
.form-grid,
.role-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.detail-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 860px) {
.employee-list,
.detail-card,
.side-card,
.detail-hero {
padding: 16px;
}
.list-toolbar,
.card-head,
.detail-actions,
.detail-action-group,
.hero-profile {
flex-direction: column;
align-items: stretch;
}
.status-tabs,
.filter-set {
overflow-x: auto;
}
.list-search {
width: 100%;
}
.hero-stats,
.form-grid,
.role-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,687 @@
.login-page {
position: relative;
min-height: 100dvh;
display: grid;
grid-template-columns: minmax(620px, .96fr) minmax(520px, .84fr);
justify-content: center;
align-items: center;
gap: clamp(32px, 4.8vw, 76px);
padding: 48px clamp(40px, 5vw, 86px);
overflow: hidden;
background:
linear-gradient(120deg, rgba(236,253,245,.72), transparent 34%),
linear-gradient(105deg, #f8fafc 0%, #f4fbf8 44%, #f8fafc 100%);
}
.login-page::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
background:
linear-gradient(90deg, rgba(15,23,42,.045) 1px, transparent 1px),
linear-gradient(0deg, rgba(15,23,42,.04) 1px, transparent 1px),
radial-gradient(circle at 28% 72%, rgba(16,185,129,.12), transparent 28%),
radial-gradient(circle at 75% 22%, rgba(245,158,11,.08), transparent 30%);
background-size: 72px 72px, 72px 72px, auto, auto;
mask-image: linear-gradient(100deg, rgba(0,0,0,.7), rgba(0,0,0,.32) 48%, rgba(0,0,0,.16));
pointer-events: none;
}
.login-page::after {
content: "";
position: absolute;
left: -9vw;
top: 13vh;
z-index: 0;
width: min(820px, 58vw);
height: min(560px, 64vh);
border: 1px solid rgba(148,163,184,.22);
border-radius: 18px;
background:
linear-gradient(90deg, transparent 0 28%, rgba(15,23,42,.055) 28% calc(28% + 1px), transparent calc(28% + 1px)),
repeating-linear-gradient(0deg, transparent 0 35px, rgba(15,23,42,.05) 36px),
linear-gradient(135deg, rgba(255,255,255,.74), rgba(236,253,245,.32));
box-shadow: 0 34px 80px rgba(15,23,42,.08);
transform: rotate(-7deg);
pointer-events: none;
}
.page-brand {
position: absolute;
top: 38px;
left: clamp(42px, 6vw, 86px);
z-index: 2;
display: inline-flex;
align-items: center;
gap: 10px;
color: #111827;
font-size: 22px;
font-weight: 900;
}
:deep(.logo-mark) {
width: 34px;
height: 34px;
display: inline-grid;
place-items: center;
color: #059669;
}
:deep(.logo-mark svg) {
width: 34px;
height: 34px;
fill: currentColor;
}
.hero {
position: relative;
z-index: 1;
align-self: stretch;
display: grid;
align-content: center;
justify-items: start;
padding-top: 40px;
transform: translateX(34px);
}
.eyebrow-text {
color: #059669;
font-size: 14px;
font-weight: 900;
letter-spacing: .08em;
text-transform: uppercase;
}
.hero h1 {
margin-top: 16px;
color: #0f172a;
font-size: clamp(38px, 3.8vw, 54px);
line-height: 1.12;
font-weight: 950;
}
.hero-lead {
margin-top: 14px;
color: #111827;
font-size: clamp(23px, 2.15vw, 31px);
font-weight: 800;
}
.hero-sub {
margin-top: 14px;
color: #64748b;
font-size: 16px;
font-weight: 600;
}
.hero-stage {
position: relative;
width: min(760px, 100%);
height: 350px;
margin-top: 22px;
margin-left: 0;
}
.hero-stage::before {
content: "";
position: absolute;
left: 285px;
bottom: 38px;
width: 230px;
height: 62px;
border-radius: 50%;
background: linear-gradient(90deg, rgba(16,185,129,.16), rgba(245,158,11,.12));
filter: blur(4px);
}
.flow-line {
position: absolute;
z-index: 0;
display: block;
border: 1px solid rgba(16,185,129,.18);
border-left: 0;
border-bottom: 0;
border-radius: 0 22px 0 0;
}
.flow-line::after {
content: "";
position: absolute;
right: -3px;
top: -4px;
width: 8px;
height: 8px;
border-radius: 999px;
background: #10b981;
box-shadow: 0 0 0 5px rgba(16,185,129,.12);
}
.flow-a {
left: 190px;
top: 76px;
width: 170px;
height: 72px;
}
.flow-b {
left: 190px;
bottom: 96px;
width: 142px;
height: 82px;
transform: scaleY(-1);
}
.flow-c {
right: 182px;
top: 96px;
width: 132px;
height: 70px;
transform: scaleX(-1);
}
.metric-card,
.document-card,
.round-badge {
position: absolute;
border: 1px solid rgba(215, 224, 234, .86);
background: rgba(255,255,255,.78);
box-shadow: 0 18px 36px rgba(65, 88, 110, .10);
backdrop-filter: blur(16px);
}
.metric-card {
z-index: 2;
width: 166px;
min-height: 110px;
display: grid;
gap: 7px;
padding: 17px 18px;
border-radius: 14px;
}
.metric-card span {
color: #334155;
font-size: 13px;
font-weight: 800;
}
.metric-card strong {
color: #0f172a;
font-size: 25px;
line-height: 1;
font-weight: 900;
}
.metric-card small {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.up { color: #059669; }
.danger { color: #ef4444; }
.amount { left: 20px; top: 20px; }
.risk { left: 42px; bottom: 24px; }
.audit { right: 22px; top: 24px; }
.sla { right: 40px; bottom: 20px; }
.mini-bars {
height: 30px;
display: flex;
align-items: end;
gap: 6px;
margin-top: 2px;
}
.mini-bars i {
width: 14px;
border-radius: 4px 4px 0 0;
background: linear-gradient(180deg, #93c5fd, #dbeafe);
}
.mini-bars i:nth-child(1) { height: 11px; }
.mini-bars i:nth-child(2) { height: 18px; }
.mini-bars i:nth-child(3) { height: 24px; }
.mini-bars i:nth-child(4) { height: 32px; }
.document-card {
z-index: 1;
left: 286px;
top: 44px;
width: 220px;
height: 214px;
padding: 28px 28px;
border-radius: 16px;
transform: rotate(2deg);
}
.document-card span {
color: #1e293b;
font-size: 18px;
font-weight: 900;
}
.document-card > i {
display: block;
height: 10px;
margin-top: 22px;
border-radius: 999px;
background: #e4ebf5;
}
.document-card > i:nth-of-type(2) { width: 78%; margin-top: 16px; }
.document-card > i:nth-of-type(3) { width: 54%; margin-top: 16px; }
.doc-check {
position: absolute;
right: -16px;
bottom: -12px;
width: 54px;
height: 54px;
display: grid;
place-items: center;
border-radius: 999px;
background: linear-gradient(135deg, #6ee7b7, #059669);
color: #fff;
font-size: 27px;
box-shadow: 0 14px 28px rgba(5,150,105,.25);
}
.shield-art {
position: absolute;
z-index: 3;
left: 316px;
bottom: 0;
width: 155px;
height: 155px;
object-fit: contain;
filter: drop-shadow(0 22px 24px rgba(125, 91, 54, .16));
}
.round-badge {
z-index: 4;
width: 58px;
height: 58px;
display: grid;
place-items: center;
border-radius: 999px;
color: #3b82f6;
font-size: 24px;
font-weight: 950;
}
.round-badge.ai {
left: 258px;
top: 30px;
width: 52px;
height: 52px;
color: #3b82f6;
font-size: 21px;
box-shadow: 0 14px 30px rgba(59,130,246,.14);
}
.feature-strip {
width: min(760px, 100%);
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
margin-top: 18px;
margin-left: 0;
}
.feature-strip article {
min-height: 78px;
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
align-items: center;
gap: 12px;
padding: 12px 14px;
border: 1px solid rgba(215, 224, 234, .82);
border-radius: 13px;
background: rgba(255,255,255,.76);
box-shadow: 0 12px 30px rgba(65, 88, 110, .08);
backdrop-filter: blur(16px);
}
.feature-strip article > span {
width: 40px;
height: 40px;
display: grid;
place-items: center;
border-radius: 11px;
font-size: 21px;
}
.feature-strip .green { background: #dff7ee; color: #059669; }
.feature-strip .red { background: #fee2e2; color: #ef4444; }
.feature-strip .blue { background: #dbeafe; color: #3b82f6; }
.feature-strip strong {
color: #0f172a;
font-size: 15px;
font-weight: 900;
}
.feature-strip p {
display: block;
margin-top: 3px;
color: #64748b;
font-size: 11.5px;
line-height: 1.45;
}
.login-card {
position: relative;
z-index: 1;
width: 100%;
max-width: 560px;
justify-self: center;
display: grid;
padding: 58px 60px 44px;
border: 1px solid rgba(215, 224, 234, .96);
border-radius: 20px;
background: rgba(255,255,255,.86);
box-shadow: 0 24px 64px rgba(65, 88, 110, .16);
backdrop-filter: blur(18px);
}
.card-brand {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 12px;
color: #0f172a;
font-size: 22px;
font-weight: 950;
}
.card-head {
margin-top: 24px;
text-align: center;
}
.card-head h2 {
color: #0f172a;
font-size: 34px;
line-height: 1.15;
font-weight: 950;
}
.card-head p {
margin-top: 12px;
color: #64748b;
font-size: 16px;
}
.login-form {
display: grid;
gap: 16px;
margin-top: 30px;
}
.field {
position: relative;
display: flex;
align-items: center;
min-height: 52px;
}
.field > .mdi {
position: absolute;
left: 16px;
color: #64748b;
font-size: 19px;
}
.field input {
width: 100%;
height: 52px;
padding: 0 50px 0 48px;
border: 1px solid #d7e0ea;
border-radius: 8px;
background: rgba(255,255,255,.86);
color: #0f172a;
font-size: 15px;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.field input::placeholder {
color: #94a3b8;
}
.field input:focus {
border-color: #10b981;
background: #fff;
box-shadow: 0 0 0 3px rgba(16,185,129,.13);
outline: none;
}
.field-icon-btn {
position: absolute;
right: 12px;
width: 34px;
height: 34px;
display: grid;
place-items: center;
border: 0;
border-radius: 8px;
background: transparent;
color: #64748b;
}
.field-icon-btn:hover {
background: #f1f5f9;
color: #059669;
}
.form-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 2px;
}
.remember {
display: inline-flex;
align-items: center;
gap: 8px;
color: #334155;
font-size: 14px;
}
.remember input {
width: 16px;
height: 16px;
accent-color: #059669;
}
.link-btn {
border: 0;
background: transparent;
color: #2563eb;
font-size: 14px;
font-weight: 700;
}
.submit-btn,
.sso-btn {
height: 52px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
border-radius: 8px;
font-size: 17px;
font-weight: 900;
}
.submit-btn {
margin-top: 4px;
border: 0;
background: linear-gradient(135deg, #10b981, #059669);
color: #fff;
box-shadow: 0 16px 30px rgba(5,150,105,.20);
}
.submit-btn:hover {
background: linear-gradient(135deg, #13c990, #047857);
}
.divider {
position: relative;
display: grid;
place-items: center;
height: 28px;
color: #94a3b8;
font-size: 13px;
}
.divider::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 1px;
background: #e2e8f0;
}
.divider span {
position: relative;
padding: 0 16px;
background: rgba(255,255,255,.9);
}
.sso-btn {
border: 1px solid #10b981;
background: rgba(255,255,255,.78);
color: #059669;
}
.sso-btn:hover {
background: #ecfdf5;
}
.security-note {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 34px;
color: #64748b;
font-size: 13px;
}
.security-note .mdi {
color: #94a3b8;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 1280px) {
.login-page {
grid-template-columns: minmax(520px, 1fr) minmax(480px, 540px);
gap: 44px;
padding-inline: 48px;
}
.hero-stage {
transform: scale(.88);
transform-origin: left center;
margin-bottom: -32px;
}
.feature-strip {
width: 520px;
gap: 14px;
margin-left: 0;
}
.login-card { max-width: 500px; }
}
@media (max-height: 840px) and (min-width: 981px) {
.hero {
padding-top: 18px;
}
.hero-stage {
margin-top: 16px;
transform: scale(.9);
transform-origin: left center;
margin-bottom: -22px;
}
.login-card {
padding-block: 38px 28px;
}
.card-head {
margin-top: 18px;
}
.login-form {
gap: 14px;
margin-top: 24px;
}
.security-note {
margin-top: 24px;
}
}
@media (max-width: 980px) {
.login-page {
min-height: 100dvh;
grid-template-columns: 1fr;
padding: 92px 28px 28px;
overflow: auto;
}
.page-brand {
top: 24px;
left: 24px;
}
.hero {
display: none;
transform: none;
}
.login-card {
max-width: 520px;
padding: 40px 28px 30px;
}
}
@media (max-width: 520px) {
.login-page {
padding-inline: 22px;
}
.login-card {
padding: 32px 22px 24px;
border-radius: 14px;
}
.card-head h2 {
font-size: 30px;
}
.form-meta {
align-items: flex-start;
flex-direction: column;
gap: 10px;
}
}

View File

@@ -0,0 +1,387 @@
.dashboard {
display: grid;
gap: 16px;
animation: fadeUp 260ms var(--ease) both;
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px;
}
.kpi-card {
position: relative;
padding: 12px 14px 10px;
display: flex;
flex-direction: column;
border-left: 3px solid var(--accent);
animation: dashboardItemIn 520ms var(--ease) both;
animation-delay: var(--delay, 0ms);
transition: box-shadow 200ms ease, transform 200ms ease;
}
.kpi-card:hover {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
transform: translateY(-1px);
}
.kpi-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.kpi-icon {
width: 26px;
height: 26px;
display: grid;
place-items: center;
border-radius: 7px;
background: color-mix(in srgb, var(--accent) 10%, white);
color: var(--accent);
font-size: 14px;
flex: 0 0 auto;
animation: iconPop 560ms var(--ease) both;
animation-delay: calc(var(--delay, 0ms) + 100ms);
}
.kpi-label {
color: #64748b;
font-size: 11px;
font-weight: 500;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.kpi-value {
display: block;
min-height: 22px;
color: #0f172a;
font-size: clamp(16px, 1.2vw, 20px);
line-height: 1;
font-weight: 800;
font-variant-numeric: tabular-nums;
white-space: nowrap;
margin-bottom: 6px;
letter-spacing: 0;
}
.kpi-trend {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding-top: 6px;
border-top: 1px solid #f1f5f9;
}
.kpi-badge {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 1px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 700;
line-height: 1.45;
}
.kpi-badge.up {
background: rgba(239, 68, 68, 0.08);
color: #dc2626;
}
.kpi-badge.down {
background: rgba(22, 163, 74, 0.08);
color: #16a34a;
}
.kpi-badge .mdi {
font-size: 11px;
}
.kpi-delta {
color: #94a3b8;
font-size: 10px;
white-space: nowrap;
}
.content-grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 24px;
}
.dashboard-card {
padding: 20px;
transition: box-shadow 200ms ease, transform 200ms ease;
animation: dashboardItemIn 560ms var(--ease) both;
}
.top-grid .dashboard-card:nth-child(1) { animation-delay: 80ms; }
.top-grid .dashboard-card:nth-child(2) { animation-delay: 150ms; }
.top-grid .dashboard-card:nth-child(3) { animation-delay: 220ms; }
.bottom-grid .dashboard-card:nth-child(1) { animation-delay: 290ms; }
.bottom-grid .dashboard-card:nth-child(2) { animation-delay: 360ms; }
.bottom-grid .dashboard-card:nth-child(3) { animation-delay: 430ms; }
.dashboard-card:hover {
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
transform: translateY(-1px);
}
.trend-panel,
.rank-panel {
grid-column: span 6;
}
.donut-panel,
.bottleneck-panel,
.budget-panel {
grid-column: span 3;
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.card-head h3 {
color: #1e293b;
font-size: 16px;
font-weight: 600;
line-height: 1.35;
}
.card-head .mdi {
color: #94a3b8;
font-size: 12px;
vertical-align: 1px;
}
.card-select {
height: 30px;
min-width: 82px;
padding: 0 8px;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #fff;
color: #334155;
font-size: 14px;
}
.panel-note {
margin-top: 8px;
color: #64748b;
font-size: 12px;
text-align: center;
}
.bottleneck-panel,
.budget-panel {
display: flex;
flex-direction: column;
}
.bottleneck-panel .text-link,
.budget-panel .text-link {
margin-top: auto;
}
.bottleneck-list {
flex: 1;
display: grid;
gap: 16px;
align-content: center;
}
.bottleneck-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
animation: listRowIn 460ms var(--ease) both;
animation-delay: var(--delay, 0ms);
}
.reviewer {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.reviewer-avatar {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 999px;
background: #e2f6ef;
color: #047857;
font-size: 13px;
font-weight: 700;
flex: 0 0 auto;
}
.reviewer strong,
.reviewer-stats strong {
display: block;
color: #1e293b;
font-size: 14px;
font-weight: 500;
}
.reviewer span,
.reviewer-stats span {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 12px;
}
.reviewer-stats {
text-align: right;
}
.status-tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
}
.status-tag.danger {
background: rgba(239,68,68,.10);
color: #ef4444;
}
.status-tag.warning {
background: rgba(245,158,11,.10);
color: #f59e0b;
}
.status-tag.success {
background: rgba(16,185,129,.10);
color: #16a34a;
}
.text-link {
width: 100%;
margin-top: 16px;
display: inline-flex;
align-items: center;
justify-content: space-between;
padding: 16px 0 0;
border: 0;
border-top: 1px solid #f1f5f9;
background: transparent;
color: #10b981;
font-size: 14px;
}
@keyframes dashboardItemIn {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes listRowIn {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes iconPop {
0% {
opacity: 0;
transform: scale(.82);
}
70% {
opacity: 1;
transform: scale(1.04);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.kpi-card,
.dashboard-card,
.bottleneck-row {
animation: none;
}
}
@media (max-width: 1320px) {
.kpi-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.trend-panel,
.rank-panel {
grid-column: span 12;
}
.donut-panel,
.bottleneck-panel,
.budget-panel {
grid-column: span 6;
}
}
@media (max-width: 860px) {
.kpi-grid,
.content-grid {
grid-template-columns: 1fr;
}
.trend-panel,
.rank-panel,
.donut-panel,
.bottleneck-panel,
.budget-panel {
grid-column: span 1;
}
.card-head {
align-items: flex-start;
flex-direction: column;
}
.donut-wrap {
grid-template-columns: 1fr;
}
.rank-row {
grid-template-columns: 24px 64px minmax(0, 1fr);
}
.rank-value {
grid-column: 2 / -1;
}
.budget-summary {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,643 @@
.knowledge-page {
height: 100%;
min-height: 0;
overflow: hidden;
animation: fadeUp 220ms var(--ease) both;
}
.knowledge-grid {
height: 100%;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 0;
gap: 0;
transition: grid-template-columns 320ms var(--ease), gap 320ms var(--ease);
}
.knowledge-grid.has-preview {
grid-template-columns: minmax(560px, 1fr) minmax(420px, 0.82fr);
gap: 16px;
}
.knowledge-main,
.preview-column {
min-width: 0;
min-height: 0;
}
.knowledge-main {
overflow: hidden;
}
.library-panel {
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
padding: 16px 18px;
overflow: hidden;
}
.panel-title {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.panel-title h2,
.preview-head h2 {
color: #0f172a;
font-size: 16px;
font-weight: 850;
}
.panel-title p,
.preview-head p {
margin-top: 6px;
color: #64748b;
font-size: 13px;
line-height: 1.5;
}
.preview-hint {
min-height: 28px;
display: inline-flex;
align-items: center;
padding: 0 10px;
border-radius: 999px;
background: #f1f5f9;
color: #64748b;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
transition: background 220ms ease, color 220ms ease;
}
.preview-hint.active {
background: #dcfce7;
color: #059669;
}
.library-body {
min-height: 0;
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
gap: 14px;
margin-top: 16px;
}
.folder-rail {
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 12px;
border-right: 1px solid #edf2f7;
padding-right: 12px;
}
.folder-search {
height: 36px;
display: grid;
grid-template-columns: 18px minmax(0, 1fr) 24px;
align-items: center;
gap: 6px;
padding: 0 8px;
border: 1px solid #d7e0ea;
border-radius: 8px;
color: #64748b;
}
.folder-search input {
min-width: 0;
border: 0;
color: #0f172a;
font-size: 13px;
}
.folder-search input:focus {
outline: none;
}
.folder-search button {
border: 0;
background: transparent;
color: #64748b;
}
.folder-tree {
min-height: 0;
display: grid;
align-content: start;
gap: 6px;
overflow-y: auto;
}
.folder-tree button {
min-height: 34px;
display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 0 9px;
border: 0;
border-radius: 7px;
background: transparent;
color: #334155;
font-size: 13px;
text-align: left;
}
.folder-tree button.active {
background: #dcfce7;
color: #059669;
font-weight: 850;
}
.folder-tree b {
min-width: 24px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: #f1f5f9;
color: #64748b;
font-size: 11px;
}
.new-folder-btn {
min-height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid rgba(16, 185, 129, .28);
border-radius: 8px;
background: #f0fdf4;
color: #059669;
font-size: 13px;
font-weight: 850;
}
.document-area {
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 12px;
}
.upload-zone {
min-height: 112px;
display: grid;
place-items: center;
align-content: center;
gap: 8px;
border: 1px dashed #93c5fd;
border-radius: 10px;
background: #f8fbff;
color: #334155;
text-align: center;
}
.upload-zone i {
color: #2563eb;
font-size: 31px;
}
.upload-zone strong {
font-size: 13px;
font-weight: 850;
}
.upload-zone span {
color: #64748b;
font-size: 12px;
}
.doc-table-wrap {
min-height: 0;
overflow: auto;
}
table {
width: 100%;
min-width: 690px;
border-collapse: collapse;
}
th,
td {
padding: 12px 10px;
border-bottom: 1px solid #edf2f7;
color: #24324a;
font-size: 12px;
line-height: 1.35;
text-align: left;
vertical-align: middle;
}
th {
background: #f7fafc;
color: #64748b;
font-weight: 800;
white-space: nowrap;
}
.doc-row {
cursor: pointer;
transition: background 180ms ease, box-shadow 180ms ease;
}
.doc-row:hover {
background: #f8fbff;
}
.doc-row.selected {
background: linear-gradient(90deg, rgba(16, 185, 129, 0.08), rgba(59, 130, 246, 0.04));
box-shadow: inset 3px 0 0 #10b981;
}
.file-name {
display: inline-flex;
align-items: center;
gap: 7px;
font-weight: 750;
white-space: nowrap;
}
.file-name .pdf,
.viewer-filetype.pdf { color: #ef4444; }
.file-name .word,
.viewer-filetype.word { color: #2563eb; }
.file-name .excel,
.viewer-filetype.excel { color: #10b981; }
.doc-tag {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 7px;
border-radius: 6px;
background: #f1f5f9;
color: #64748b;
font-size: 11px;
font-weight: 750;
}
.state-tag {
min-height: 22px;
display: inline-flex;
align-items: center;
padding: 0 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 800;
white-space: nowrap;
}
.state-tag.success {
background: #dcfce7;
color: #059669;
}
.state-tag.warning {
background: #ffedd5;
color: #f97316;
}
.more-btn {
border: 0;
background: transparent;
color: #2563eb;
}
.list-foot {
display: grid;
grid-template-columns: auto auto 1fr auto;
align-items: center;
gap: 10px;
color: #64748b;
font-size: 13px;
}
.list-foot button {
min-height: 32px;
border: 1px solid #d7e0ea;
border-radius: 8px;
background: #fff;
color: #334155;
font-weight: 750;
}
.pager {
display: inline-flex;
gap: 6px;
}
.pager button {
width: 32px;
padding: 0;
}
.pager button.active {
border-color: #059669;
background: #059669;
color: #fff;
}
.list-foot input {
width: 42px;
height: 30px;
border: 1px solid #d7e0ea;
border-radius: 7px;
text-align: center;
}
.preview-column {
min-width: 0;
min-height: 0;
overflow: hidden;
}
.preview-panel {
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
padding: 18px;
overflow: hidden;
}
.preview-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
}
.preview-kicker {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 8px;
border-radius: 999px;
background: #ecfdf5;
color: #059669;
font-size: 11px;
font-weight: 800;
}
.preview-actions {
display: flex;
align-items: center;
gap: 8px;
}
.mini-action,
.icon-action,
.viewer-toolbar-actions button {
border: 1px solid #d7e0ea;
border-radius: 8px;
background: #fff;
color: #334155;
}
.mini-action {
min-height: 34px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 12px;
font-size: 12px;
font-weight: 800;
}
.icon-action {
width: 34px;
height: 34px;
display: grid;
place-items: center;
}
.preview-meta {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid #edf2f7;
}
.preview-meta span {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
background: #f8fafc;
color: #475569;
font-size: 12px;
font-weight: 700;
}
.preview-viewer {
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 12px;
margin-top: 14px;
}
.viewer-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 44px;
padding: 0 12px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #f8fafc;
}
.viewer-filetype {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 800;
}
.viewer-toolbar-actions {
display: inline-flex;
gap: 8px;
}
.viewer-toolbar-actions button {
width: 32px;
height: 32px;
display: grid;
place-items: center;
}
.page-stage {
min-height: 0;
overflow: auto;
display: grid;
gap: 16px;
padding-right: 4px;
}
.page-sheet {
display: grid;
gap: 16px;
padding: 18px;
border: 1px solid #e2e8f0;
border-radius: 14px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06);
animation: previewSheetIn 360ms var(--ease) both;
animation-delay: var(--page-delay, 0ms);
}
.page-title {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.page-title strong {
color: #0f172a;
font-size: 15px;
font-weight: 850;
}
.page-title span,
.page-title b {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.summary-item {
display: grid;
gap: 6px;
padding: 12px;
border-radius: 10px;
background: #f8fafc;
}
.summary-item span {
color: #64748b;
font-size: 11px;
font-weight: 700;
}
.summary-item strong {
color: #0f172a;
font-size: 14px;
font-weight: 850;
}
.page-content {
display: grid;
gap: 12px;
}
.content-block {
padding: 14px;
border-radius: 12px;
background: #ffffff;
border: 1px solid #edf2f7;
}
.content-block h3 {
margin: 0 0 8px;
color: #0f172a;
font-size: 13px;
font-weight: 850;
}
.content-block ul {
display: grid;
gap: 8px;
margin: 0;
padding-left: 18px;
color: #475569;
font-size: 12px;
line-height: 1.6;
}
.preview-panel-enter-active,
.preview-panel-leave-active {
transition: opacity 240ms ease, transform 320ms var(--ease);
}
.preview-panel-enter-from,
.preview-panel-leave-to {
opacity: 0;
transform: translateX(24px) scale(0.98);
}
@keyframes previewSheetIn {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 1320px) {
.knowledge-grid.has-preview {
grid-template-columns: minmax(0, 1fr) minmax(360px, 0.78fr);
}
}
@media (max-width: 1080px) {
.knowledge-grid,
.knowledge-grid.has-preview {
grid-template-columns: 1fr;
gap: 16px;
overflow-y: auto;
}
.library-body {
grid-template-columns: 1fr;
}
.folder-rail {
border-right: 0;
border-bottom: 1px solid #edf2f7;
padding: 0 0 12px;
}
}
@media (max-width: 760px) {
.panel-title,
.preview-head,
.viewer-toolbar {
flex-direction: column;
align-items: stretch;
}
.summary-grid,
.list-foot {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,523 @@
.travel-page {
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: minmax(0, 1fr);
gap: 14px;
animation: fadeUp 220ms var(--ease) both;
overflow: hidden;
}
.travel-list {
min-height: 0;
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
padding: 16px 18px;
overflow: hidden;
}
.list-search {
position: relative;
width: 220px;
}
.list-search .mdi {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #64748b;
font-size: 15px;
}
.list-search input {
width: 100%;
height: 38px;
padding: 0 12px 0 36px;
border: 1px solid #d7e0ea;
border-radius: 8px;
background: #fff;
color: #0f172a;
font-size: 13px;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.list-search input::placeholder {
color: #8da0b4;
}
.list-search input:focus {
border-color: #10b981;
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.14);
outline: none;
}
.status-tabs {
display: flex;
gap: 28px;
margin-top: 14px;
border-bottom: 1px solid #dbe4ee;
}
.status-tabs button {
position: relative;
min-height: 36px;
border: 0;
background: transparent;
color: #64748b;
font-size: 14px;
font-weight: 750;
}
.status-tabs button.active {
color: #059669;
}
.status-tabs button.active::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 3px;
border-radius: 999px 999px 0 0;
background: #10b981;
}
.list-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 14px;
}
.filter-set {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.create-request-btn {
min-height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 18px;
border: 0;
border-radius: 10px;
background: linear-gradient(135deg, #10b981, #059669);
color: #fff;
font-size: 14px;
font-weight: 800;
white-space: nowrap;
box-shadow: 0 10px 24px rgba(5, 150, 105, 0.2);
transition: transform 160ms ease, box-shadow 160ms ease, filter 160ms ease;
}
.create-request-btn:hover {
transform: translateY(-1px);
box-shadow: 0 14px 28px rgba(5, 150, 105, 0.24);
filter: saturate(1.02);
}
.filter-btn,
.page-size {
min-height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
padding: 0 14px;
border-radius: 8px;
font-size: 14px;
font-weight: 750;
white-space: nowrap;
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
}
.filter-btn {
min-width: 120px;
justify-content: space-between;
}
.date-range-filter {
position: relative;
}
.date-range-trigger {
min-width: 160px;
}
.date-range-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 110px;
}
.date-range-popover {
position: absolute;
top: calc(100% + 8px);
left: 0;
width: 320px;
z-index: 40;
display: grid;
gap: 14px;
padding: 16px;
border: 1px solid #d7e0ea;
border-radius: 12px;
background: #fff;
box-shadow: 0 18px 42px rgba(15, 23, 42, .16);
}
.date-range-popover header,
.date-range-popover footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.date-range-popover header strong {
color: #0f172a;
font-size: 15px;
}
.date-range-popover header button {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border: 0;
border-radius: 8px;
background: transparent;
color: #64748b;
}
.date-range-popover header button:hover {
background: #f1f5f9;
color: #0f172a;
}
.date-range-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.date-range-fields label {
display: grid;
gap: 6px;
}
.date-range-fields span {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.date-range-fields input {
width: 100%;
height: 38px;
padding: 0 9px;
border: 1px solid #d7e0ea;
border-radius: 8px;
color: #0f172a;
font-size: 13px;
}
.date-range-fields input:focus {
border-color: #10b981;
box-shadow: 0 0 0 3px rgba(16, 185, 129, .12);
outline: none;
}
.ghost-btn,
.apply-btn {
height: 36px;
padding: 0 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 750;
}
.ghost-btn {
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
}
.apply-btn {
border: 0;
background: #10b981;
color: #fff;
}
.apply-btn:disabled {
cursor: not-allowed;
background: #cbd5e1;
}
.filter-btn:hover,
.page-size:hover {
border-color: rgba(16, 185, 129, .32);
color: #0f9f78;
}
.hint {
display: inline-flex;
align-items: center;
gap: 7px;
margin-top: 10px;
color: #64748b;
font-size: 13px;
}
.hint .mdi {
color: #64748b;
}
.table-wrap {
margin-top: 10px;
overflow-x: auto;
overflow-y: auto;
border: 1px solid #edf2f7;
border-radius: 10px;
}
table {
height: 100%;
width: 100%;
min-width: 1140px;
border-collapse: collapse;
table-layout: fixed;
}
colgroup col {
width: 10%;
}
th,
td {
padding: 13px 12px;
border-bottom: 1px solid #edf2f7;
color: #24324a;
font-size: 14px;
line-height: 1.35;
text-align: center;
vertical-align: middle;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
th {
position: sticky;
top: 0;
z-index: 1;
background: #f7fafc;
color: #64748b;
font-size: 13px;
font-weight: 800;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
tbody tr {
cursor: pointer;
}
tbody tr:hover {
background: linear-gradient(90deg, rgba(16, 185, 129, .08), rgba(16, 185, 129, .03));
}
tbody tr:last-child td {
border-bottom: 0;
}
.doc-id {
color: #059669;
font-weight: 800;
}
.status-tag {
min-height: 24px;
display: inline-flex;
align-items: center;
padding: 0 9px;
border: 1px solid transparent;
border-radius: 6px;
font-size: 12px;
font-weight: 750;
white-space: nowrap;
}
.status-tag.info {
border-color: #bfdbfe;
background: #eff6ff;
color: #2563eb;
}
.status-tag.success {
border-color: #bbf7d0;
background: #ecfdf5;
color: #059669;
}
.status-tag.warning {
border-color: #fed7aa;
background: #fff7ed;
color: #f97316;
}
.status-tag.neutral {
border-color: #cbd5e1;
background: #f8fafc;
color: #475569;
}
.list-foot {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
margin-top: 24px;
}
.page-summary {
color: #64748b;
font-size: 14px;
font-weight: 650;
}
.pager {
display: inline-flex;
justify-content: center;
gap: 6px;
padding: 4px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
}
.pager button {
width: 32px;
height: 32px;
border: 0;
border-radius: 9px;
background: transparent;
color: #334155;
font-size: 14px;
font-weight: 800;
transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease;
}
.pager button:hover:not(.active) {
background: #fff;
color: #059669;
box-shadow: 0 1px 4px rgba(15, 23, 42, .08);
}
.pager button.active {
background: #059669;
color: #fff;
box-shadow: 0 8px 16px rgba(5, 150, 105, .20);
}
.page-nav {
color: #64748b;
}
.page-size {
justify-self: end;
min-width: 112px;
border-radius: 10px;
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
}
.page-size-wrap {
position: relative;
justify-self: end;
}
.page-size-dropdown {
position: absolute;
bottom: calc(100% + 6px);
right: 0;
z-index: 40;
display: grid;
border: 1px solid #d7e0ea;
border-radius: 10px;
background: #fff;
box-shadow: 0 12px 32px rgba(15, 23, 42, .14);
overflow: hidden;
}
.page-size-dropdown button {
height: 36px;
display: grid;
place-items: center;
border: 0;
border-radius: 0;
background: transparent;
color: #334155;
font-size: 13px;
font-weight: 750;
white-space: nowrap;
padding: 0 20px;
transition: background 120ms ease, color 120ms ease;
}
.page-size-dropdown button:hover {
background: #f0fdf4;
color: #059669;
}
.page-size-dropdown button.active {
background: #059669;
color: #fff;
}
@media (max-width: 1200px) {
.list-toolbar,
.list-foot {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.travel-list {
padding: 16px;
}
.status-tabs {
gap: 18px;
overflow-x: auto;
}
.filter-btn,
.page-size {
width: 100%;
}
.filter-set {
width: 100%;
}
.list-foot {
display: grid;
justify-items: stretch;
}
.pager,
.page-size {
justify-self: stretch;
}
}

View File

@@ -0,0 +1,756 @@
.assistant-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: grid;
place-items: center;
background: rgba(15, 23, 42, 0.46);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.assistant-modal {
width: min(1480px, calc(100vw - 48px));
height: min(920px, calc(100vh - 40px));
display: grid;
grid-template-rows: auto minmax(0, 1fr);
border-radius: 28px;
background:
radial-gradient(circle at top right, rgba(16, 185, 129, 0.08), transparent 24%),
linear-gradient(180deg, #fbfdff 0%, #f6f9fc 100%);
box-shadow:
0 24px 80px rgba(15, 23, 42, 0.22),
0 2px 12px rgba(15, 23, 42, 0.08);
overflow: hidden;
}
.assistant-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 24px 28px 20px;
border-bottom: 1px solid #e5edf5;
background: rgba(255, 255, 255, 0.82);
}
.assistant-header-main {
display: flex;
align-items: flex-start;
gap: 16px;
min-width: 0;
}
.assistant-badge {
min-height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 14px;
border-radius: 999px;
background: rgba(16, 185, 129, 0.12);
color: #059669;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.assistant-header h2 {
color: #0f172a;
font-size: 24px;
font-weight: 900;
}
.assistant-header p {
margin-top: 4px;
color: #64748b;
font-size: 14px;
line-height: 1.6;
}
.assistant-header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.source-pill {
min-height: 34px;
display: inline-flex;
align-items: center;
padding: 0 14px;
border-radius: 999px;
background: #eff6ff;
color: #2563eb;
font-size: 13px;
font-weight: 800;
white-space: nowrap;
}
.close-btn {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border: 1px solid #d7e0ea;
border-radius: 999px;
background: #fff;
color: #64748b;
font-size: 18px;
}
.assistant-layout {
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 18px;
padding: 18px;
}
.assistant-layout.has-insight {
grid-template-columns: minmax(0, 1.18fr) 420px;
}
.dialog-panel,
.insight-panel {
min-width: 0;
min-height: 0;
border: 1px solid #e7eef6;
border-radius: 24px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.04);
}
.dialog-panel {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden;
}
.dialog-toolbar {
display: flex;
gap: 10px;
flex-wrap: wrap;
padding: 18px 20px 14px;
border-bottom: 1px solid #eef2f7;
}
.shortcut-chip {
min-height: 36px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 14px;
border: 1px solid #dbe6f0;
border-radius: 999px;
background: #fff;
color: #334155;
font-size: 13px;
font-weight: 800;
white-space: nowrap;
}
.shortcut-chip i {
color: #059669;
}
.message-list {
min-height: 0;
display: grid;
align-content: start;
gap: 16px;
padding: 20px;
overflow-y: auto;
}
.message-row {
display: grid;
grid-template-columns: 38px minmax(0, 1fr);
align-items: start;
gap: 12px;
}
.message-row.user {
grid-template-columns: minmax(0, 1fr) 38px;
}
.message-row.user .message-avatar {
order: 2;
background: #dbeafe;
color: #2563eb;
}
.message-row.user .message-bubble {
order: 1;
justify-self: end;
background: linear-gradient(135deg, rgba(37, 99, 235, 0.10), rgba(37, 99, 235, 0.04));
border-color: rgba(37, 99, 235, 0.16);
}
.message-avatar {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border-radius: 999px;
background: #dff7ee;
color: #059669;
font-size: 20px;
}
.message-bubble {
max-width: min(100%, 720px);
padding: 14px 16px;
border: 1px solid #e1e8f0;
border-radius: 20px;
background: #fff;
color: #24324a;
line-height: 1.65;
}
.message-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.message-meta strong {
color: #0f172a;
font-size: 13px;
font-weight: 850;
}
.message-meta time {
color: #94a3b8;
font-size: 12px;
}
.message-bubble p {
color: #334155;
font-size: 14px;
}
.message-files,
.composer-files {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 10px;
}
.file-chip {
min-height: 28px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 10px;
border-radius: 999px;
background: #f1f5f9;
color: #475569;
font-size: 12px;
font-weight: 700;
}
.file-chip.active {
background: #eef6ff;
color: #2563eb;
}
.composer {
padding: 0 20px 20px;
}
.hidden-file-input {
display: none;
}
.composer-shell {
border: 1px solid #d6e1ea;
border-radius: 22px;
background: #fff;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.05);
overflow: hidden;
}
.composer-shell textarea {
width: 100%;
min-height: 84px;
resize: none;
border: 0;
padding: 18px 18px 8px;
background: transparent;
color: #0f172a;
font-size: 15px;
line-height: 1.65;
}
.composer-shell textarea::placeholder {
color: #94a3b8;
}
.composer-shell textarea:focus {
outline: none;
}
.composer-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 14px 14px;
}
.composer-tools {
display: flex;
align-items: center;
gap: 10px;
}
.tool-btn,
.send-btn {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 0;
border-radius: 14px;
}
.tool-btn {
background: #f1f5f9;
color: #475569;
font-size: 20px;
}
.composer-tip {
color: #94a3b8;
font-size: 12px;
font-weight: 700;
}
.send-btn {
background: #10b981;
color: #fff;
font-size: 18px;
box-shadow: 0 10px 22px rgba(16, 185, 129, 0.22);
}
.send-btn:disabled {
opacity: 0.48;
cursor: not-allowed;
box-shadow: none;
}
.insight-panel {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
}
.insight-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 20px;
border-bottom: 1px solid #eef2f7;
}
.intent-pill {
min-height: 28px;
display: inline-flex;
align-items: center;
padding: 0 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
}
.intent-pill.welcome {
background: #eef2ff;
color: #4f46e5;
}
.intent-pill.draft {
background: #ecfdf5;
color: #059669;
}
.intent-pill.approval {
background: #fff7ed;
color: #ea580c;
}
.intent-pill.recognition {
background: #eff6ff;
color: #2563eb;
}
.intent-pill.note {
background: #fdf2f8;
color: #db2777;
}
.insight-head h3 {
margin-top: 10px;
color: #0f172a;
font-size: 20px;
font-weight: 900;
line-height: 1.3;
}
.insight-head p {
margin-top: 6px;
color: #64748b;
font-size: 13px;
line-height: 1.6;
}
.confidence-card {
min-width: 92px;
padding: 10px 12px;
border-radius: 16px;
background: #f8fafc;
text-align: right;
}
.confidence-card span {
display: block;
color: #94a3b8;
font-size: 11px;
font-weight: 800;
}
.confidence-card strong {
display: block;
margin-top: 4px;
color: #0f172a;
font-size: 22px;
font-weight: 900;
}
.insight-body {
min-height: 0;
display: grid;
align-content: start;
gap: 14px;
padding: 18px 20px 20px;
overflow-y: auto;
}
.insight-card {
padding: 16px;
border: 1px solid #e7eef6;
border-radius: 20px;
background: #fff;
}
.insight-card.primary {
background: linear-gradient(180deg, #ffffff 0%, #f9fbff 100%);
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.card-head h4 {
color: #0f172a;
font-size: 15px;
font-weight: 850;
}
.status-pill {
min-height: 28px;
display: inline-flex;
align-items: center;
padding: 0 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.status-pill.success {
background: #ecfdf5;
color: #059669;
}
.status-pill.warning {
background: #fff7ed;
color: #ea580c;
}
.status-pill.note {
background: #fdf2f8;
color: #db2777;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.metric-grid.single {
grid-template-columns: 1fr;
}
.metric-item {
padding: 12px 14px;
border-radius: 16px;
background: #f8fafc;
}
.metric-item span {
display: block;
color: #94a3b8;
font-size: 11px;
font-weight: 800;
}
.metric-item strong {
display: block;
margin-top: 6px;
color: #0f172a;
font-size: 14px;
font-weight: 850;
line-height: 1.5;
}
.timeline-list,
.bullet-list {
display: grid;
gap: 12px;
padding: 0;
margin: 0;
list-style: none;
}
.timeline-list li {
display: grid;
grid-template-columns: 14px minmax(0, 1fr);
gap: 12px;
align-items: start;
}
.timeline-dot {
width: 10px;
height: 10px;
margin-top: 5px;
border-radius: 999px;
background: #cbd5e1;
}
.timeline-list li.done .timeline-dot,
.timeline-list li.current .timeline-dot {
background: #10b981;
}
.timeline-list strong {
display: block;
color: #0f172a;
font-size: 13px;
font-weight: 800;
}
.timeline-list p,
.bullet-list li,
.welcome-card p,
.note-block p {
color: #64748b;
font-size: 13px;
line-height: 1.6;
}
.receipt-list {
display: grid;
gap: 10px;
}
.receipt-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 12px 14px;
border-radius: 16px;
background: #f8fafc;
}
.receipt-row strong,
.welcome-card strong,
.note-block strong {
color: #0f172a;
font-size: 14px;
font-weight: 850;
}
.receipt-row p,
.receipt-row span {
color: #64748b;
font-size: 12px;
}
.receipt-side {
text-align: right;
}
.receipt-side strong {
display: block;
}
.note-block {
display: grid;
gap: 8px;
padding: 14px;
border-radius: 16px;
background: #f8fafc;
}
.note-block span {
color: #94a3b8;
font-size: 11px;
font-weight: 800;
}
.welcome-grid {
display: grid;
gap: 12px;
}
.welcome-card {
padding: 14px;
border-radius: 18px;
background: #f8fafc;
}
.welcome-card i {
color: #10b981;
font-size: 20px;
}
.welcome-card strong {
display: block;
margin-top: 10px;
}
.assistant-modal-enter-active,
.assistant-modal-leave-active {
transition: opacity 220ms ease;
}
.assistant-modal-enter-active .assistant-modal,
.assistant-modal-leave-active .assistant-modal {
transition: transform 260ms ease, opacity 220ms ease;
}
.assistant-modal-enter-from,
.assistant-modal-leave-to {
opacity: 0;
}
.assistant-modal-enter-from .assistant-modal,
.assistant-modal-leave-to .assistant-modal {
transform: translateY(10px) scale(0.985);
opacity: 0;
}
.insight-switch-enter-active,
.insight-switch-leave-active {
transition: opacity 180ms ease, transform 180ms ease;
}
.insight-switch-enter-from,
.insight-switch-leave-to {
opacity: 0;
transform: translateY(8px);
}
.insight-panel-enter-active,
.insight-panel-leave-active {
transition: opacity 220ms ease, transform 240ms ease;
}
.insight-panel-enter-from,
.insight-panel-leave-to {
opacity: 0;
transform: translateX(18px);
}
@media (max-width: 1280px) {
.assistant-layout {
grid-template-columns: 1fr;
}
.insight-panel {
min-height: 320px;
}
}
@media (max-width: 760px) {
.assistant-modal {
width: 100vw;
height: 100vh;
border-radius: 0;
}
.assistant-header {
padding: 18px 18px 16px;
align-items: flex-start;
flex-direction: column;
}
.assistant-header-actions {
width: 100%;
justify-content: space-between;
}
.assistant-layout {
padding: 14px;
}
.dialog-toolbar {
padding: 16px 16px 12px;
}
.shortcut-chip {
width: 100%;
justify-content: center;
}
.message-list {
padding: 16px;
}
.message-row,
.message-row.user {
grid-template-columns: 34px minmax(0, 1fr);
}
.message-row.user .message-avatar {
order: 0;
}
.message-row.user .message-bubble {
order: 0;
justify-self: stretch;
}
.composer {
padding: 0 16px 16px;
}
.composer-foot {
align-items: flex-end;
}
.metric-grid {
grid-template-columns: 1fr;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,802 @@
<template>
<section class="workbench">
<PanelHead
v-if="showHeader"
eyebrow="Personal Workspace"
title="个人工作台"
note="把今天要处理的待办、报销进度和制度更新集中到一个入口。"
/>
<article class="panel assistant-hero">
<div class="assistant-visual" aria-hidden="true">
<div class="assistant-core">
<img class="assistant-image" :src="robotAssistant" alt="" />
</div>
</div>
<div class="assistant-copy">
<span class="assistant-tag">AI 报销助手</span>
<h3>描述费用或上传票据AI 直接帮你判断怎么报</h3>
<p>自动识别报销类别核对附件完整性并生成可继续提交的报销草稿</p>
<div class="assistant-input">
<textarea
v-model="assistantDraft"
rows="1"
placeholder="例如:我昨天请客户吃饭花了 860 元,还打车去了客户公司"
@keydown.ctrl.enter.prevent="openAssistantWithDraft"
/>
<button type="button" class="hero-action" @click="openAssistantWithDraft">开始识别</button>
</div>
<div class="assistant-tools">
<button type="button" class="ghost-action" @click="emit('openAssistant', { prompt: '', source: 'upload' })">
<i class="mdi mdi-upload-outline"></i>
<span>上传票据</span>
</button>
<div class="assistant-skills">
<span v-for="item in assistantSkills" :key="item">{{ item }}</span>
</div>
</div>
</div>
</article>
<div class="workbench-grid">
<article class="panel list-panel">
<div class="section-head">
<div class="title-with-badge">
<h3>今日待办</h3>
<span class="alert-badge">{{ todoAlertCount }}</span>
</div>
<button type="button" class="link-action">查看全部 <i class="mdi mdi-chevron-right"></i></button>
</div>
<div class="list-body">
<div v-for="item in todoItems" :key="item.title" class="todo-row">
<div class="todo-icon" :style="{ '--icon-color': item.color }">
<i :class="item.icon"></i>
</div>
<div class="todo-copy">
<strong>{{ item.title }}</strong>
<p class="todo-advice">
<span class="todo-advice-label">{{ item.tipLabel }}</span>
<span class="todo-advice-text">{{ item.suggestion }}</span>
</p>
</div>
<button type="button" class="row-action" @click="emit('openAssistant')">{{ item.action }}</button>
</div>
</div>
</article>
<article class="panel list-panel">
<div class="section-head">
<div class="title-with-badge">
<h3>报销进度</h3>
<span class="alert-badge">{{ progressAlertCount }}</span>
</div>
<button type="button" class="link-action">查看全部 <i class="mdi mdi-chevron-right"></i></button>
</div>
<div class="list-body">
<div v-for="item in progressItems" :key="item.id" class="progress-row">
<div class="todo-icon" :style="{ '--icon-color': item.color }">
<i :class="item.icon"></i>
</div>
<div class="todo-copy progress-copy">
<strong>{{ item.title }}</strong>
<p>提交时间{{ item.date }}</p>
</div>
<strong class="progress-amount">{{ item.amount }}</strong>
<span class="progress-status" :class="item.tone">{{ item.status }}</span>
</div>
</div>
</article>
</div>
<article class="panel policy-panel">
<div class="section-head">
<h3>最新报销制度</h3>
<button type="button" class="link-action">查看全部 <i class="mdi mdi-chevron-right"></i></button>
</div>
<div class="policy-table">
<div class="policy-head policy-row">
<span class="policy-title-cell">制度名称</span>
<span class="policy-summary-cell">摘要</span>
<span class="policy-date-cell">发布日期</span>
</div>
<div v-for="item in policyItems" :key="item.name" class="policy-row">
<strong class="policy-title-cell">{{ item.name }}</strong>
<span class="policy-summary-cell">{{ item.summary }}</span>
<span class="policy-date-cell">{{ item.date }}</span>
</div>
</div>
</article>
</section>
</template>
<script setup>
import { ref } from 'vue'
import PanelHead from '../shared/PanelHead.vue'
import robotAssistant from '../../assets/robot-assistant.png'
defineProps({
showHeader: { type: Boolean, default: true }
})
const emit = defineEmits(['openAssistant'])
const assistantDraft = ref('')
function openAssistantWithDraft() {
emit('openAssistant', {
prompt: assistantDraft.value.trim(),
source: 'workbench'
})
}
const assistantSkills = ['识别报销类别', '检查缺少材料', '生成报销草稿']
const todoItems = [
{
title: '业务招待报销建议补参与人员',
tipLabel: 'AI 建议',
suggestion: '补充客户单位、客户人数、我方陪同人员',
action: '去补充',
icon: 'mdi mdi-account-group-outline',
color: '#10b981'
},
{
title: '差旅报销单待提交',
tipLabel: 'AI 建议',
suggestion: '补齐出发交通,可直接生成报销单',
action: '继续填写',
icon: 'mdi mdi-briefcase-outline',
color: '#16a34a'
},
{
title: '有 5 张票据未关联报销单',
tipLabel: 'AI 建议',
suggestion: '其中 3 张疑似交通费,可合并生成交通报销',
action: '去整理',
icon: 'mdi mdi-receipt-text-outline',
color: '#3b82f6'
}
]
const todoAlertCount = todoItems.length
const progressItems = [
{
id: 'travel',
title: '差旅报销',
amount: '¥3,280',
date: '2026-05-03',
status: '主管审批中',
tone: 'success',
icon: 'mdi mdi-airplane',
color: '#10b981'
},
{
id: 'transport',
title: '交通报销',
amount: '¥126',
date: '2026-05-02',
status: '财务复核中',
tone: 'info',
icon: 'mdi mdi-car-outline',
color: '#3b82f6'
},
{
id: 'office',
title: '办公采购',
amount: '¥458',
date: '2026-05-01',
status: '已到账',
tone: 'mint',
icon: 'mdi mdi-cart-outline',
color: '#16a34a'
}
]
const progressAlertCount = progressItems.filter((item) => item.status !== '已到账').length
const policyItems = [
{
name: '差旅报销管理办法2026版',
summary: '更新住宿标准与交通等级规则',
date: '2026-05-04'
},
{
name: '业务招待费用报销规范',
summary: '明确参与人员与事由填写要求',
date: '2026-05-02'
},
{
name: '交通费用报销细则',
summary: '补充网约车与停车费报销说明',
date: '2026-04-28'
},
{
name: '票据与附件提交规范通知',
summary: '统一附件命名与上传要求',
date: '2026-04-25'
}
]
</script>
<style scoped>
.workbench {
display: grid;
gap: 16px;
}
.assistant-hero {
position: relative;
overflow: hidden;
display: grid;
grid-template-columns: 164px minmax(0, 1fr);
gap: 24px;
padding: 24px 26px;
border: 1px solid rgba(16, 185, 129, 0.12);
background:
radial-gradient(circle at top left, rgba(16, 185, 129, 0.12), transparent 34%),
radial-gradient(circle at right 20%, rgba(59, 130, 246, 0.07), transparent 28%),
linear-gradient(135deg, #f7fffb 0%, #ffffff 48%, #f5fbff 100%);
}
.assistant-hero::before,
.assistant-hero::after {
content: "";
position: absolute;
border-radius: 999px;
background: rgba(16, 185, 129, 0.06);
pointer-events: none;
}
.assistant-hero::before {
right: -48px;
bottom: -58px;
width: 220px;
height: 220px;
}
.assistant-hero::after {
right: 92px;
top: -44px;
width: 140px;
height: 140px;
}
.assistant-visual {
position: relative;
display: grid;
place-items: center;
}
.assistant-core {
position: relative;
z-index: 1;
width: 132px;
height: 132px;
display: grid;
place-items: center;
border-radius: 36px;
background: linear-gradient(180deg, #ffffff 0%, #ecfdf5 100%);
box-shadow:
0 20px 44px rgba(15, 23, 42, 0.08),
inset 0 -10px 18px rgba(16, 185, 129, 0.10);
color: #0f9f78;
}
.assistant-core::before,
.assistant-core::after {
content: "";
position: absolute;
background: #d1fae5;
}
.assistant-core::before {
top: -12px;
width: 14px;
height: 14px;
border-radius: 999px;
box-shadow: 0 0 0 6px rgba(16, 185, 129, 0.10);
}
.assistant-core::after {
top: -4px;
width: 2px;
height: 16px;
}
.assistant-core .mdi {
font-size: 68px;
}
.assistant-image {
width: 104px;
height: 104px;
object-fit: contain;
filter: drop-shadow(0 12px 20px rgba(15, 23, 42, 0.12));
}
.assistant-copy {
position: relative;
z-index: 1;
display: grid;
gap: 14px;
align-content: center;
}
.assistant-tag {
display: inline-flex;
width: fit-content;
align-items: center;
padding: 6px 12px;
border-radius: 999px;
background: rgba(16, 185, 129, 0.10);
color: #0f9f78;
font-size: 12px;
font-weight: 800;
}
.assistant-copy h3 {
color: #0f172a;
font-size: 28px;
line-height: 1.25;
font-weight: 800;
}
.assistant-copy p {
max-width: 760px;
color: #5b6b83;
font-size: 15px;
line-height: 1.7;
}
.assistant-input {
display: flex;
align-items: center;
gap: 14px;
min-height: 52px;
padding: 6px 8px 6px 14px;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 12px;
background: rgba(255, 255, 255, 0.92);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
}
.assistant-input textarea {
min-width: 0;
flex: 1;
height: 24px;
min-height: 24px;
max-height: 24px;
resize: none;
border: 0;
padding: 1px 0;
background: transparent;
color: #0f172a;
font-size: 15px;
line-height: 22px;
overflow: hidden;
}
.assistant-input textarea::placeholder {
color: #94a3b8;
}
.assistant-input textarea:focus {
outline: none;
}
.hero-action,
.ghost-action,
.row-action,
.link-action,
.row-link {
border: 0;
background: transparent;
}
.hero-action {
height: 36px;
padding: 0 20px;
border-radius: 10px;
background: linear-gradient(135deg, #10b981, #059669);
color: #fff;
font-size: 14px;
font-weight: 800;
white-space: nowrap;
box-shadow: 0 10px 22px rgba(16, 185, 129, 0.18);
}
.assistant-tools {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.ghost-action {
height: 40px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 16px;
border: 1px solid rgba(16, 185, 129, 0.34);
border-radius: 10px;
background: rgba(255, 255, 255, 0.72);
color: #0f9f78;
font-size: 14px;
font-weight: 700;
}
.assistant-skills {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
color: #22a06b;
font-size: 14px;
font-weight: 700;
}
.assistant-skills span {
position: relative;
}
.assistant-skills span + span::before {
content: "";
position: absolute;
left: -7px;
top: 50%;
width: 1px;
height: 14px;
background: rgba(16, 185, 129, 0.22);
transform: translateY(-50%);
}
.workbench-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.list-panel,
.policy-panel {
padding: 20px 22px;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.section-head h3 {
color: #0f172a;
font-size: 17px;
font-weight: 700;
}
.title-with-badge {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.alert-badge {
min-width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 7px;
border-radius: 999px;
background: #ef4444;
color: #fff;
font-size: 12px;
font-weight: 800;
line-height: 1;
box-shadow: 0 6px 14px rgba(239, 68, 68, 0.22);
}
.link-action {
display: inline-flex;
align-items: center;
gap: 4px;
color: #10b981;
font-size: 14px;
font-weight: 700;
}
.list-body {
display: grid;
}
.todo-row,
.progress-row {
display: grid;
grid-template-columns: 48px minmax(0, 1fr) auto;
gap: 14px;
align-items: center;
padding: 14px 0;
border-top: 1px solid #edf2f7;
}
.todo-row:first-child,
.progress-row:first-child {
padding-top: 4px;
border-top: 0;
}
.todo-icon {
width: 48px;
height: 48px;
display: grid;
place-items: center;
border-radius: 14px;
background: color-mix(in srgb, var(--icon-color) 12%, white);
color: var(--icon-color);
font-size: 24px;
}
.todo-copy {
min-width: 0;
}
.todo-copy strong {
display: block;
color: #0f172a;
font-size: 15px;
font-weight: 700;
line-height: 1.4;
}
.todo-copy p {
margin-top: 4px;
color: #6b7280;
font-size: 14px;
line-height: 1.5;
}
.todo-advice {
display: flex;
align-items: flex-start;
gap: 8px;
flex-wrap: wrap;
}
.todo-advice-label {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 8px;
border-radius: 999px;
background: #ecfdf5;
color: #059669;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.todo-advice-text {
color: #64748b;
}
.row-action {
height: 38px;
padding: 0 16px;
border: 1px solid rgba(16, 185, 129, 0.36);
border-radius: 10px;
color: #10b981;
font-size: 14px;
font-weight: 700;
white-space: nowrap;
}
.progress-row {
grid-template-columns: 48px minmax(0, 1fr) minmax(84px, auto) minmax(104px, auto);
gap: 14px 16px;
}
.progress-copy strong {
margin-bottom: 2px;
}
.progress-amount {
color: #0f172a;
font-size: 20px;
font-weight: 800;
line-height: 1;
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.progress-status {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 104px;
min-height: 34px;
padding: 6px 14px;
border-radius: 999px;
font-size: 13px;
font-weight: 800;
white-space: nowrap;
justify-self: end;
}
.progress-status.success,
.policy-status.success {
background: #eafaf2;
color: #16935f;
}
.progress-status.info,
.policy-status.info {
background: #eff6ff;
color: #3b82f6;
}
.progress-status.mint {
background: #edfdf5;
color: #10b981;
}
.policy-table {
border: 1px solid #e7edf5;
border-radius: 12px;
overflow: hidden;
}
.policy-row {
display: grid;
grid-template-columns: 2.2fr 2.4fr 1fr;
gap: 16px;
align-items: center;
min-height: 56px;
padding: 0 18px;
border-top: 1px solid #edf2f7;
}
.policy-head {
min-height: 44px;
background: #f8fbff;
color: #64748b;
font-size: 12px;
font-weight: 800;
border-top: 0;
}
.policy-row strong,
.policy-row span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.policy-row strong {
color: #0f172a;
font-size: 14px;
font-weight: 700;
}
.policy-row span {
color: #64748b;
font-size: 14px;
}
.policy-title-cell,
.policy-summary-cell {
justify-self: stretch;
text-align: left;
}
.policy-date-cell {
justify-self: center;
text-align: center;
}
@media (max-width: 1320px) {
.assistant-copy h3 {
font-size: 24px;
}
.policy-row {
grid-template-columns: 1.8fr 1.8fr 1fr;
}
}
@media (max-width: 1080px) {
.assistant-hero {
grid-template-columns: 1fr;
}
.assistant-visual {
justify-content: flex-start;
}
.workbench-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 860px) {
.assistant-hero,
.list-panel,
.policy-panel {
padding: 18px;
}
.assistant-input {
flex-direction: column;
align-items: stretch;
padding: 14px;
}
.assistant-input textarea {
height: 40px;
min-height: 40px;
max-height: 40px;
line-height: 1.5;
}
.hero-action,
.ghost-action,
.row-action {
width: 100%;
justify-content: center;
}
.todo-row,
.progress-row {
grid-template-columns: 48px minmax(0, 1fr);
}
.progress-amount {
grid-column: 2;
text-align: left;
font-size: 18px;
}
.row-action,
.progress-status {
grid-column: 2;
justify-self: start;
}
.policy-table {
border: 0;
border-radius: 0;
}
.policy-head {
display: none;
}
.policy-row {
grid-template-columns: 1fr;
gap: 8px;
padding: 16px 0;
border-top: 1px solid #edf2f7;
}
.policy-row strong,
.policy-row span {
white-space: normal;
}
}
</style>

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.slaStatus">{{ 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,172 @@
<template>
<div class="bar-chart">
<div class="rank-labels">
<div v-for="(item, idx) in items" :key="item.name" class="rank-label">
<span class="rank-badge" :class="medalClass(idx)">
<svg v-if="idx < 3" width="18" height="18" viewBox="0 0 18 18">
<circle cx="9" cy="9" r="8" :fill="medalFill(idx)" />
<text x="9" y="13" text-anchor="middle" fill="#fff" font-size="10" font-weight="700">{{ idx + 1 }}</text>
</svg>
<template v-else>{{ idx + 1 }}</template>
</span>
<span class="rank-name">{{ item.name || item.shortName }}</span>
</div>
</div>
<div class="chart-area">
<Bar :data="chartData" :options="chartOptions" />
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { Bar } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Tooltip
} from 'chart.js'
ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip)
const props = defineProps({
items: { type: Array, required: true }
})
const medalClass = (idx) => {
if (idx === 0) return 'gold'
if (idx === 1) return 'silver'
if (idx === 2) return 'bronze'
return ''
}
const medalFill = (idx) => {
if (idx === 0) return '#f59e0b'
if (idx === 1) return '#94a3b8'
if (idx === 2) return '#cd7f32'
return '#94a3b8'
}
const formatValue = (value) => {
if (value >= 1_000_000) return `¥${(value / 1_000_000).toFixed(1)}M`
if (value >= 1_000) return `¥${(value / 1_000).toFixed(1)}K`
return `¥${value}`
}
const chartData = computed(() => ({
labels: props.items.map((i) => i.name || i.shortName),
datasets: [{
data: props.items.map((i) => i.value || i.amount),
backgroundColor: props.items.map((i) => i.color),
borderRadius: 6,
borderSkipped: false,
barPercentage: 0.7,
categoryPercentage: 0.85
}]
}))
const chartOptions = {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
layout: {
padding: { left: 0, right: 12 }
},
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(255,255,255,0.96)',
titleColor: '#1e293b',
bodyColor: '#64748b',
borderColor: '#e2e8f0',
borderWidth: 1,
padding: 10,
boxPadding: 4,
cornerRadius: 6,
callbacks: {
title: () => '',
label: (ctx) => ` ${formatValue(ctx.parsed.x)}`
}
}
},
scales: {
x: {
beginAtZero: true,
grid: {
color: '#f1f5f9',
drawTicks: false
},
ticks: {
color: '#94a3b8',
font: { size: 11 },
padding: 4,
callback: (value) => formatValue(value)
},
border: { display: false }
},
y: {
grid: { display: false },
border: { display: false },
ticks: { display: false }
}
}
}
</script>
<style scoped>
.bar-chart {
display: flex;
width: 100%;
gap: 8px;
}
.rank-labels {
flex: 0 0 auto;
display: flex;
flex-direction: column;
justify-content: space-around;
padding: 6px 0;
}
.rank-label {
display: flex;
align-items: center;
gap: 6px;
height: 34px;
white-space: nowrap;
}
.rank-badge {
width: 20px;
height: 20px;
display: grid;
place-items: center;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
color: #fff;
}
.rank-badge:not(.gold):not(.silver):not(.bronze) {
background: #cbd5e1;
}
.rank-badge svg {
display: block;
}
.rank-name {
color: #475569;
font-size: 13px;
font-weight: 500;
}
.chart-area {
flex: 1;
min-width: 0;
position: relative;
height: 240px;
}
</style>

View File

@@ -0,0 +1,194 @@
<template>
<div class="donut-chart">
<div class="donut-body">
<Doughnut :data="chartData" :options="chartOptions" />
<div class="donut-center">
<strong>{{ centerValue }}</strong>
<span>{{ centerLabel }}</span>
</div>
</div>
<div class="donut-legend">
<div v-for="item in items" :key="item.name" class="legend-row">
<i :style="{ background: item.color }"></i>
<span class="legend-name">{{ item.name }}</span>
<span class="legend-val">{{ item.display }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { Doughnut } from 'vue-chartjs'
import {
Chart as ChartJS,
ArcElement,
Tooltip,
Legend
} from 'chart.js'
import { useAnimationProgress } from '../../composables/useAnimationProgress.js'
ChartJS.register(ArcElement, Tooltip, Legend)
const props = defineProps({
items: { type: Array, required: true },
centerValue: { type: String, required: true },
centerLabel: { type: String, required: true }
})
const progress = useAnimationProgress([() => props.items], 1150)
const chartData = computed(() => ({
labels: props.items.map((i) => i.name),
datasets: [{
data: props.items.map((i) => Math.max(Number((i.value * progress.value).toFixed(1)), 0.001)),
backgroundColor: props.items.map((i) => i.color),
borderWidth: 0,
cutout: '68%',
spacing: 3,
borderRadius: 4
}]
}))
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
animation: {
animateRotate: true,
animateScale: true,
duration: 900,
easing: 'easeOutQuart'
},
transitions: {
active: {
animation: {
duration: 180
}
}
},
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(255,255,255,0.96)',
titleColor: '#1e293b',
bodyColor: '#64748b',
borderColor: '#e2e8f0',
borderWidth: 1,
padding: 10,
boxPadding: 4,
cornerRadius: 6,
usePointStyle: true,
callbacks: {
label: (ctx) => {
const total = ctx.dataset.data.reduce((a, b) => a + b, 0) || 1
const pct = ((ctx.parsed / total) * 100).toFixed(1)
return ` ${ctx.label}: ${pct}%`
}
}
}
}
}
</script>
<style scoped>
.donut-chart {
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 240px;
}
.donut-body {
position: relative;
width: 100%;
height: 150px;
margin: 0 auto;
margin-top: 16px;
}
.donut-center {
position: absolute;
inset: 0;
display: grid;
place-items: center;
place-content: center;
pointer-events: none;
text-align: center;
animation: donutCenterIn 620ms ease both;
animation-delay: 360ms;
}
.donut-center strong {
color: #1e293b;
font-size: 16px;
font-weight: 700;
line-height: 1;
}
.donut-center span {
margin-top: 4px;
color: #64748b;
font-size: 11px;
}
.donut-legend {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 16px;
animation: donutLegendIn 560ms ease both;
animation-delay: 480ms;
}
.legend-row {
display: flex;
align-items: center;
gap: 6px;
}
.legend-row i {
width: 8px;
height: 8px;
border-radius: 2px;
flex: 0 0 auto;
}
.legend-name {
color: #475569;
font-size: 12px;
}
.legend-val {
margin-left: auto;
color: #94a3b8;
font-size: 11px;
}
@keyframes donutCenterIn {
from {
opacity: 0;
transform: scale(.92);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes donutLegendIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.donut-center,
.donut-legend {
animation: none;
}
}
</style>

View File

@@ -0,0 +1,168 @@
<template>
<div class="gauge-chart">
<div class="gauge-body">
<Doughnut :data="chartData" :options="chartOptions" />
<div class="gauge-center">
<strong>{{ animatedRatio }}%</strong>
<span>已执行</span>
</div>
</div>
<div class="gauge-summary">
<div>
<span>预算总额</span>
<strong>{{ total }}</strong>
</div>
<div>
<span>已执行</span>
<strong>{{ used }}</strong>
</div>
<div>
<span>剩余可用</span>
<strong>{{ left }}</strong>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { Doughnut } from 'vue-chartjs'
import {
Chart as ChartJS,
ArcElement,
Tooltip
} from 'chart.js'
import { useAnimationProgress } from '../../composables/useAnimationProgress.js'
ChartJS.register(ArcElement, Tooltip)
const props = defineProps({
ratio: { type: [Number, String], required: true },
total: { type: String, required: true },
used: { type: String, required: true },
left: { type: String, required: true }
})
const ratioValue = computed(() => Number(props.ratio))
const progress = useAnimationProgress([() => props.ratio], 1150)
const animatedRatio = computed(() => Number((ratioValue.value * progress.value).toFixed(0)))
const chartData = computed(() => ({
labels: ['已执行', '剩余'],
datasets: [{
data: [animatedRatio.value, 100 - animatedRatio.value],
backgroundColor: ['#10b981', '#e2e8f0'],
borderWidth: 0
}]
}))
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
rotation: -90,
circumference: 180,
cutout: '65%',
animation: {
animateRotate: true,
duration: 900,
easing: 'easeOutQuart'
},
plugins: {
legend: { display: false },
tooltip: { enabled: false }
}
}
</script>
<style scoped>
.gauge-chart {
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
justify-content: center;
}
.gauge-body {
position: relative;
height: 100px;
width: 100%;
}
.gauge-center {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
text-align: center;
pointer-events: none;
animation: gaugeCenterIn 620ms ease both;
animation-delay: 360ms;
}
.gauge-center strong {
color: #10b981;
font-size: 22px;
font-weight: 700;
line-height: 1;
}
.gauge-center span {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 11px;
}
.gauge-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
text-align: center;
animation: gaugeSummaryIn 560ms ease both;
animation-delay: 500ms;
}
.gauge-summary span {
display: block;
color: #64748b;
font-size: 12px;
}
.gauge-summary strong {
display: block;
margin-top: 4px;
color: #1e293b;
font-size: 13px;
font-weight: 500;
}
@keyframes gaugeCenterIn {
from {
opacity: 0;
transform: translateX(-50%) translateY(8px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
@keyframes gaugeSummaryIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.gauge-center,
.gauge-summary {
animation: none;
}
}
</style>

View File

@@ -0,0 +1,216 @@
<template>
<div class="trend-chart">
<div class="chart-legend">
<span><i style="background:#10b981"></i>申请量</span>
<span><i style="background:#3b82f6"></i>审批完成量</span>
<span><i style="background:#8b5cf6"></i>平均审批时长小时</span>
</div>
<div class="chart-body">
<Bar :data="chartData" :options="chartOptions" />
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { Bar } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
PointElement,
LineElement,
Filler,
Tooltip,
Legend
} from 'chart.js'
import { useAnimationProgress } from '../../composables/useAnimationProgress.js'
ChartJS.register(CategoryScale, LinearScale, BarElement, PointElement, LineElement, Filler, Tooltip, Legend)
const props = defineProps({
labels: { type: Array, required: true },
applications: { type: Array, required: true },
approved: { type: Array, required: true },
avgHours: { type: Array, required: true }
})
const progress = useAnimationProgress([
() => props.labels,
() => props.applications,
() => props.approved,
() => props.avgHours
], 1200)
const scaleSeries = (series, decimals = 0) =>
series.map((value) => Number((Number(value) * progress.value).toFixed(decimals)))
const chartData = computed(() => ({
labels: props.labels,
datasets: [
{
label: '申请量(单)',
data: scaleSeries(props.applications),
backgroundColor: '#10b981',
borderRadius: 4,
barPercentage: 0.6,
categoryPercentage: 0.5,
order: 2
},
{
label: '审批完成量(单)',
data: scaleSeries(props.approved),
backgroundColor: '#3b82f6',
borderRadius: 4,
barPercentage: 0.6,
categoryPercentage: 0.5,
order: 2
},
{
label: '平均审批时长(小时)',
data: scaleSeries(props.avgHours, 1),
borderColor: '#8b5cf6',
backgroundColor: 'transparent',
borderWidth: 2,
pointBackgroundColor: '#ffffff',
pointBorderColor: '#8b5cf6',
pointBorderWidth: 2,
pointRadius: 3,
pointHoverRadius: 5,
type: 'line',
yAxisID: 'y1',
order: 1
}
]
}))
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 900,
easing: 'easeOutQuart'
},
interaction: {
mode: 'index',
intersect: false
},
events: ['click', 'mousemove', 'mouseout'],
plugins: {
legend: {
display: false
},
tooltip: {
enabled: false,
external: externalTooltip
}
},
scales: {
x: {
grid: { display: false },
ticks: {
color: '#64748b',
font: { size: 11 }
}
},
y: {
beginAtZero: true,
max: 250,
grid: { color: '#f1f5f9' },
ticks: {
color: '#64748b',
font: { size: 11 },
stepSize: 50
}
},
y1: {
position: 'right',
beginAtZero: true,
max: 15,
grid: { display: false },
ticks: {
color: '#64748b',
font: { size: 11 },
stepSize: 3
}
}
}
}
function externalTooltip(context) {
const { chart, tooltip } = context
let el = chart.canvas.parentNode.querySelector('.chart-tooltip')
if (!el) {
el = document.createElement('div')
el.classList.add('chart-tooltip')
el.style.cssText =
'position:absolute;background:#fff;border:1px solid #e2e8f0;border-radius:8px;padding:12px 16px;pointer-events:none;transition:opacity .15s,transform .15s;font-family:Inter,system-ui,sans-serif;font-size:13px;box-shadow:0 4px 12px rgba(0,0,0,.08);z-index:10;opacity:0;transform:translateY(4px)'
chart.canvas.parentNode.appendChild(el)
}
if (tooltip.opacity === 0) {
el.style.opacity = '0'
return
}
if (tooltip.body) {
const titleLines = tooltip.title || []
const bodyLines = tooltip.body.map((b) => b.lines)
const colors = tooltip.labelColors
const dot = (color, text) =>
`<div style="display:flex;align-items:center;gap:6px;margin-top:4px"><span style="width:8px;height:8px;border-radius:50%;background:${color};flex-shrink:0"></span><span style="color:#64748b">${text}</span></div>`
el.innerHTML =
`<div style="font-weight:600;color:#1e293b;margin-bottom:4px">${titleLines.join('')}</div>` +
bodyLines
.map((lines, i) =>
lines.map((line) => dot(colors[i]?.backgroundColor || colors[i]?.borderColor || '#999', line))
)
.join('')
}
const { offsetLeft, offsetTop } = chart.canvas
const left = offsetLeft + tooltip.caretX
const top = offsetTop + tooltip.caretY
el.style.opacity = '1'
el.style.transform = 'translateY(0)'
el.style.left = `${left}px`
el.style.top = `${top - el.offsetHeight - 12}px`
el.style.transform = `translate(-50%, 0)`
}
</script>
<style scoped>
.trend-chart {
height: 280px;
display: flex;
flex-direction: column;
}
.chart-legend {
display: flex;
align-items: center;
gap: 16px;
color: #475569;
font-size: 12px;
margin-bottom: 12px;
}
.chart-legend i {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 2px;
margin-right: 4px;
vertical-align: middle;
}
.chart-body {
flex: 1;
min-height: 0;
}
</style>

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

@@ -0,0 +1,156 @@
<template>
<section class="filters" :class="{ compact }" aria-label="筛选条件">
<template v-if="!compact">
<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>
</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>
<script setup>
defineProps({
filters: { type: Object, required: true },
ranges: { type: Array, required: true },
activeRange: { type: String, required: true },
compact: { type: Boolean, default: false }
})
const emit = defineEmits(['update:activeRange'])
</script>
<style scoped>
.filters {
display: grid;
grid-template-columns: repeat(3, minmax(160px, 1fr)) auto;
gap: 14px;
padding: 0 16px 12px;
border-bottom: 1px solid var(--line);
background: #fff;
}
.filters.compact {
display: flex;
justify-content: flex-end;
padding: 8px 16px;
}
.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-flex;
gap: 0;
min-height: 40px;
padding: 3px;
border-radius: 10px;
background: #f1f5f9;
}
.segmented button {
position: relative;
min-height: 34px;
padding: 0 20px;
border: none;
border-radius: 8px;
background: transparent;
color: #64748b;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.segmented button:hover:not(.active) {
color: #334155;
background: rgba(255, 255, 255, 0.5);
}
.segmented button.active {
background: #fff;
color: #1e293b;
font-weight: 600;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
}
@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%;
}
}
</style>

View File

@@ -0,0 +1,323 @@
<template>
<aside class="rail" aria-label="主导航">
<div class="rail-brand">
<div class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 36 36">
<path d="M19.8 4.5c5.7 1.1 9.9 5.7 10.5 11.6-2.8-.9-5.5-.7-7.9.6-2.8 1.5-4.5 4.3-5.2 8.2-4.4-2.8-6.5-6.5-6.3-11.1.2-4.2 3.5-7.8 8.9-9.3Z" />
<path d="M9 7.6c-3 3.5-4 7.3-2.9 11.2 1.2 4.2 4.6 7 10.1 8.5-2 1.8-4.6 2.6-7.6 2.3C5.1 26.7 3.5 23.1 3.7 19 4 14.4 5.7 10.6 9 7.6Z" />
</svg>
</div>
<strong class="brand-name">星海科技</strong>
<button class="brand-toggle" type="button" aria-label="打开 AI 助手" @click="emit('openChat')">
<i class="mdi mdi-chevron-double-left"></i>
</button>
</div>
<nav class="rail-nav" aria-label="功能导航">
<button
v-for="item in decoratedNavItems"
:key="item.id"
class="nav-btn"
:class="{ active: activeView === item.id }"
type="button"
@click="emit('navigate', item.id)"
>
<span class="nav-icon" v-html="item.icon"></span>
<span class="nav-label">{{ item.displayLabel }}</span>
<span v-if="item.badge" class="nav-badge">{{ item.badge }}</span>
</button>
</nav>
<button class="rail-user" type="button" aria-label="打开用户菜单">
<span class="user-avatar"></span>
<span class="user-copy">
<strong>张晓明</strong>
<span>财务管理员</span>
</span>
<i class="mdi mdi-chevron-down"></i>
</button>
</aside>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
navItems: { type: Array, required: true },
activeView: { type: String, required: true }
})
const emit = defineEmits(['navigate', 'openChat'])
const sidebarMeta = {
overview: { label: '总览' },
workbench: { label: '个人工作台' },
requests: { label: '差旅申请/报销' },
approval: { label: '审批中心', badge: '12' },
chat: { label: 'AI助手' },
policies: { label: '知识管理' },
audit: { label: '技能中心' },
employees: { label: '员工管理' }
}
const decoratedNavItems = computed(() =>
props.navItems.map((item) => ({
...item,
displayLabel: sidebarMeta[item.id]?.label ?? item.label,
badge: sidebarMeta[item.id]?.badge
}))
)
</script>
<style scoped>
.rail {
position: sticky;
top: 0;
height: 100dvh;
display: grid;
grid-template-rows: auto 1fr auto;
background:
linear-gradient(180deg, rgba(255,255,255,.98), rgba(248,251,250,.96)),
#fff;
border-right: 1px solid #dbe4ee;
box-shadow: 1px 0 0 rgba(15,23,42,.02);
z-index: 20;
}
.rail-brand {
min-height: 86px;
display: grid;
grid-template-columns: 32px minmax(0, 1fr) 28px;
align-items: center;
gap: 10px;
padding: 22px 20px 18px;
}
.brand-mark {
width: 30px;
height: 30px;
display: grid;
place-items: center;
color: #07936f;
}
.brand-mark svg {
width: 30px;
height: 30px;
fill: currentColor;
}
.brand-name {
min-width: 0;
color: #0f172a;
font-size: 16px;
font-weight: 800;
letter-spacing: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.brand-toggle {
width: 28px;
height: 28px;
display: grid;
place-items: center;
border: 0;
border-radius: 7px;
background: transparent;
color: #718096;
transition: background 180ms var(--ease), color 180ms var(--ease);
}
.brand-toggle:hover {
background: #eef7f4;
color: #07936f;
}
.rail-nav {
display: grid;
align-content: start;
gap: 14px;
padding: 16px 20px;
overflow-y: auto;
}
.nav-btn {
width: 100%;
min-height: 48px;
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
padding: 0 12px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: #64748b;
text-align: left;
transition:
background 180ms var(--ease),
border-color 180ms var(--ease),
color 180ms var(--ease),
box-shadow 180ms var(--ease);
}
.nav-btn:hover {
background: rgba(16,185,129,.07);
color: #0f9f78;
}
.nav-btn.active {
background: linear-gradient(90deg, rgba(16,185,129,.16), rgba(16,185,129,.08));
border-color: rgba(16,185,129,.10);
color: #059669;
box-shadow: inset 3px 0 0 #10b981;
}
.nav-icon {
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: 7px;
color: currentColor;
}
.nav-btn :deep(svg) {
width: 19px;
height: 19px;
stroke: currentColor;
stroke-width: 2;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.nav-label {
min-width: 0;
color: currentColor;
font-size: 14px;
font-weight: 750;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nav-badge {
min-width: 34px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 8px;
border-radius: 999px;
background: #ff5b67;
color: #fff;
font-size: 12px;
font-weight: 800;
line-height: 1;
}
.rail-user {
min-width: 0;
min-height: 74px;
display: grid;
grid-template-columns: 38px minmax(0, 1fr) 22px;
align-items: center;
gap: 10px;
margin: 0;
padding: 16px 20px 18px;
border: 0;
border-top: 1px solid transparent;
background: transparent;
color: #64748b;
text-align: left;
transition: background 180ms var(--ease), border-color 180ms var(--ease);
}
.rail-user:hover {
border-top-color: #e2e8f0;
background: rgba(255,255,255,.72);
}
.user-avatar {
width: 36px;
height: 36px;
display: grid;
place-items: center;
border: 2px solid #fff;
border-radius: 999px;
background: linear-gradient(135deg, #0f9f78, #65d6b4);
box-shadow: 0 6px 14px rgba(15,159,120,.18);
color: #fff;
font-size: 14px;
font-weight: 800;
}
.user-copy {
min-width: 0;
display: grid;
gap: 2px;
}
.user-copy strong {
color: #334155;
font-size: 14px;
font-weight: 750;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-copy span {
color: #64748b;
font-size: 12px;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rail-user .mdi {
justify-self: end;
color: #718096;
font-size: 13px;
}
@media (max-width: 980px) {
.rail {
position: relative;
height: auto;
}
}
@media (max-width: 760px) {
.rail {
border-right: 0;
border-bottom: 1px solid #dbe4ee;
}
.rail-brand {
min-height: 68px;
padding: 16px;
}
.rail-nav {
display: flex;
gap: 10px;
padding: 8px 16px 16px;
overflow-x: auto;
}
.nav-btn {
min-width: 148px;
}
.rail-user {
display: none;
}
}
</style>

View File

@@ -0,0 +1,635 @@
<template>
<header class="topbar" :class="{ 'chat-mode': isChat }">
<div class="title-group">
<div class="eyebrow">{{ isChat ? 'Smart Finance Q&A' : 'Smart Expense Operations' }}</div>
<h1>{{ currentView.title }}</h1>
<p>{{ currentView.desc }}</p>
</div>
<div class="top-actions">
<template v-if="isChat">
<div class="kpi-chips">
<div v-for="kpi in chatKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
<span class="chip-value">{{ kpi.value }}<small>{{ kpi.unit }}</small></span>
<span class="chip-label">{{ kpi.label }}</span>
<span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
</div>
</div>
</template>
<template v-else-if="isOverview">
<div class="range-combo" aria-label="首页时间范围">
<div class="range-shell">
<span class="range-meta">
<i class="mdi mdi-calendar"></i>
<span>{{ activeDateLabel }}</span>
</span>
<div class="range-tabs" role="tablist" aria-label="时间范围">
<button
v-for="option in rangeOptions"
:key="option.value"
type="button"
role="tab"
:aria-selected="activeRange === option.value"
:class="{ active: activeRange === option.value }"
@click="setRange(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
<div class="custom-range-wrap">
<button
class="custom-range-btn"
type="button"
:class="{ active: isCustomRange }"
:aria-expanded="calendarOpen"
aria-haspopup="dialog"
@click="calendarOpen = !calendarOpen"
>
<i class="mdi mdi-calendar-plus"></i>
<span>选择时间段</span>
</button>
<div v-if="calendarOpen" class="calendar-popover" role="dialog" aria-label="选择看板时间段">
<header>
<strong>选择看板时间段</strong>
<button type="button" aria-label="关闭日期选择" @click="calendarOpen = false">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="date-fields">
<label>
<span>开始日期</span>
<input v-model="draftStart" type="date" />
</label>
<label>
<span>结束日期</span>
<input v-model="draftEnd" type="date" />
</label>
</div>
<footer>
<button class="ghost-btn" type="button" @click="calendarOpen = false">取消</button>
<button class="apply-btn" type="button" :disabled="!canApplyCustomRange" @click="applyCustomRange">
应用
</button>
</footer>
</div>
</div>
</div>
</template>
<template v-else-if="isRequests">
<div class="kpi-chips">
<div v-for="kpi in requestKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
<span class="chip-value">{{ kpi.value }}<small></small></span>
<span class="chip-label">{{ kpi.label }}</span>
<span class="chip-delta" :class="kpi.trend">{{ kpi.delta }} <i :class="kpi.arrow"></i></span>
</div>
</div>
</template>
<template v-else-if="isApproval">
<div class="kpi-chips">
<div v-for="kpi in approvalKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
<span class="chip-value">{{ kpi.value }}<small>{{ kpi.unit }}</small></span>
<span class="chip-label">{{ kpi.label }}</span>
<span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
</div>
</div>
<div class="topbar-spacer"></div>
<button class="create-top-btn" type="button">
<i class="mdi mdi-check-circle"></i>
<span>批量通过</span>
</button>
</template>
<template v-else-if="isPolicies">
<div class="kpi-chips">
<div v-for="kpi in knowledgeKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
<span class="chip-value">{{ kpi.value }}</span>
<span class="chip-label">{{ kpi.label }}</span>
<span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
</div>
</div>
</template>
</div>
</header>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
const props = defineProps({
currentView: { type: Object, required: true },
search: { type: String, default: '' },
activeView: { type: String, default: '' },
ranges: { type: Array, default: () => [] },
activeRange: { type: String, default: '' },
customRange: {
type: Object,
default: () => ({ start: '2024-07-06', end: '2024-07-12' })
}
})
const emit = defineEmits([
'update:search',
'update:activeRange',
'update:customRange',
'batchApprove',
'openChat',
'newApplication'
])
const isChat = computed(() => props.activeView === 'chat')
const isOverview = computed(() => props.activeView === 'overview')
const isRequests = computed(() => props.activeView === 'requests')
const isApproval = computed(() => props.activeView === 'approval')
const isPolicies = computed(() => props.activeView === 'policies')
const requestKpis = [
{ label: '全部单据', value: 30, delta: '+8', trend: 'up', arrow: 'mdi mdi-arrow-up', color: '#10b981' },
{ label: '待提交', value: 5, delta: '-1', trend: 'down', arrow: 'mdi mdi-arrow-down', color: '#f59e0b' },
{ label: '审批中', value: 8, delta: '+2', trend: 'up', arrow: 'mdi mdi-arrow-up', color: '#3b82f6' },
{ label: '已完成', value: 17, delta: '+7', trend: 'up', arrow: 'mdi mdi-arrow-up', color: '#10b981' }
]
const chatKpis = [
{ label: '今日已问数', value: 86, unit: '次', meta: '较昨日 +18', trend: 'up', color: '#10b981' },
{ label: '已解决问题', value: 72, unit: '条', meta: '解决率 83.7%', trend: 'up', color: '#3b82f6' },
{ label: '知识命中率', value: '92.3', unit: '%', meta: '较昨日 +2.6%', trend: 'up', color: '#8b5cf6' },
{ label: '平均响应时长', value: 2.1, unit: 's', meta: '较昨日 -0.3s', trend: 'down', color: '#f59e0b' }
]
const approvalKpis = [
{ label: '待审批单据', value: 12, unit: '单', meta: '较昨日 +3', trend: 'up', color: '#059669' },
{ label: '高风险单据', value: 4, unit: '单', meta: '较昨日 +1', trend: 'up', color: '#ef4444' },
{ label: '即将超时', value: 3, unit: '单', meta: '30 分钟内', trend: 'down', color: '#f59e0b' },
{ label: '今日已处理', value: 28, unit: '单', meta: '通过率 86%', trend: 'up', color: '#10b981' }
]
const knowledgeKpis = [
{ label: '文档总数', value: '1,248', meta: '较上周 +68', trend: 'up', icon: 'mdi mdi-file-document-outline', color: '#10b981' },
{ label: '文件夹总数', value: '36', meta: '较上周 +2', trend: 'up', icon: 'mdi mdi-folder-outline', color: '#3b82f6' },
{ label: '问答总量', value: '8,562', meta: '较上周 +321', trend: 'up', icon: 'mdi mdi-comment-text-multiple-outline', color: '#8b5cf6' },
{ label: '知识命中率', value: '87.3%', meta: '较上周 +1.2%', trend: 'up', icon: 'mdi mdi-bullseye-arrow', color: '#f59e0b' }
]
const calendarOpen = ref(false)
const draftStart = ref(props.customRange.start)
const draftEnd = ref(props.customRange.end)
const fallbackRangeLabels = ['今日', '本周', '本月']
const dateLabels = {
'今日': '2024-07-12',
'本周': '2024-07-06 ~ 2024-07-12',
'本月': '2024-07'
}
const rangeOptions = computed(() =>
props.ranges.map((range, index) => ({
value: range,
label: fallbackRangeLabels[index] ?? String(range)
}))
)
const activeOption = computed(() =>
rangeOptions.value.find((option) => option.value === props.activeRange) ?? rangeOptions.value[0]
)
const isCustomRange = computed(() => props.activeRange === 'custom')
const activeDateLabel = computed(() => {
if (isCustomRange.value) return formatRangeLabel(props.customRange.start, props.customRange.end)
return dateLabels[activeOption.value?.label] ?? '2024-07-06 ~ 2024-07-12'
})
const canApplyCustomRange = computed(() =>
Boolean(draftStart.value && draftEnd.value && draftStart.value <= draftEnd.value)
)
watch(
() => props.customRange,
(range) => {
draftStart.value = range.start
draftEnd.value = range.end
},
{ deep: true }
)
function setRange(range) {
emit('update:activeRange', range)
calendarOpen.value = false
}
function applyCustomRange() {
if (!canApplyCustomRange.value) return
emit('update:customRange', { start: draftStart.value, end: draftEnd.value })
emit('update:activeRange', 'custom')
calendarOpen.value = false
}
function formatRangeLabel(start, end) {
if (!start || !end) return '选择时间段'
if (start === end) return start
return `${start} ~ ${end}`
}
</script>
<style scoped>
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 18px 24px 20px;
background: #fff;
}
.topbar.chat-mode {
padding-bottom: 16px;
border-bottom: 1px solid #eef2f7;
}
.title-group {
min-width: 0;
}
.eyebrow {
display: inline-block;
margin-bottom: 8px;
padding: 3px 10px;
border-radius: 6px;
background: linear-gradient(135deg, rgba(16, 185, 129, 0.10), rgba(59, 130, 246, 0.10));
color: #0d9668;
font-size: 11px;
font-weight: 800;
letter-spacing: 1.2px;
text-transform: uppercase;
}
.topbar h1 {
color: #0f172a;
font-size: 26px;
font-weight: 800;
letter-spacing: 0;
line-height: 1.2;
}
.topbar p {
margin-top: 6px;
max-width: 720px;
color: #64748b;
font-size: 14px;
line-height: 1.5;
}
.top-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 14px;
flex-wrap: wrap;
}
.range-combo {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
}
.range-shell {
height: 42px;
display: inline-flex;
align-items: center;
padding: 3px;
border: 1px solid #d7e0ea;
border-radius: 10px;
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
}
.range-meta {
height: 34px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border-right: 1px solid #e2e8f0;
color: #334155;
font-size: 13px;
font-weight: 650;
white-space: nowrap;
}
.range-meta .mdi {
color: #10b981;
}
.range-tabs {
display: inline-flex;
align-items: center;
gap: 2px;
padding-left: 3px;
}
.range-tabs button {
height: 34px;
min-width: 54px;
padding: 0 14px;
border: 0;
border-radius: 8px;
background: transparent;
color: #64748b;
font-size: 13px;
font-weight: 700;
white-space: nowrap;
transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease;
}
.range-tabs button:hover:not(.active) {
background: #f1f5f9;
color: #334155;
}
.range-tabs button.active {
background: #10b981;
color: #fff;
box-shadow: 0 6px 14px rgba(16, 185, 129, .18);
}
.custom-range-wrap {
position: relative;
}
.custom-range-btn {
height: 42px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 13px;
border: 1px solid #d7e0ea;
border-radius: 10px;
background: #fff;
color: #334155;
font-size: 13px;
font-weight: 750;
white-space: nowrap;
}
.custom-range-btn:hover,
.custom-range-btn.active {
border-color: rgba(16, 185, 129, .34);
background: #f6fffb;
color: #0f9f78;
}
.calendar-popover {
position: absolute;
top: calc(100% + 10px);
right: 0;
width: 336px;
z-index: 40;
display: grid;
gap: 14px;
padding: 16px;
border: 1px solid #d7e0ea;
border-radius: 12px;
background: #fff;
box-shadow: 0 18px 42px rgba(15, 23, 42, .16);
}
.calendar-popover header,
.calendar-popover footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.calendar-popover header strong {
color: #0f172a;
font-size: 15px;
}
.calendar-popover header button {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border: 0;
border-radius: 8px;
background: transparent;
color: #64748b;
}
.date-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.date-fields label {
display: grid;
gap: 6px;
}
.date-fields span {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.date-fields input {
width: 100%;
height: 38px;
padding: 0 9px;
border: 1px solid #d7e0ea;
border-radius: 8px;
color: #0f172a;
font-size: 13px;
}
.ghost-btn,
.apply-btn {
height: 36px;
padding: 0 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 750;
}
.ghost-btn {
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
}
.apply-btn {
border: 0;
background: #10b981;
color: #fff;
}
.apply-btn:disabled {
cursor: not-allowed;
background: #cbd5e1;
}
.kpi-chips {
display: flex;
gap: 10px;
}
.kpi-chip {
display: grid;
grid-template-columns: auto auto;
grid-template-rows: auto auto;
gap: 2px 10px;
padding: 8px 16px;
border-radius: 10px;
background: linear-gradient(135deg, color-mix(in srgb, var(--chip-color) 8%, #fff), color-mix(in srgb, var(--chip-color) 3%, #f8fafc));
border: 1px solid color-mix(in srgb, var(--chip-color) 18%, #e2e8f0);
}
.chip-value {
grid-row: 1 / 3;
align-self: center;
color: #0f172a;
font-size: 22px;
font-weight: 850;
line-height: 1;
}
.chip-value small {
font-size: 13px;
font-weight: 700;
margin-left: 2px;
}
.chip-label {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.chip-delta {
color: #94a3b8;
font-size: 11px;
font-weight: 700;
}
.chip-delta.up { color: #059669; }
.chip-delta.down { color: #f59e0b; }
.topbar-spacer {
flex: 1;
min-width: 0;
}
.create-top-btn {
height: 40px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 18px;
border: 0;
border-radius: 8px;
background: #059669;
color: #fff;
font-size: 14px;
font-weight: 750;
box-shadow: 0 8px 18px rgba(5, 150, 105, 0.18);
white-space: nowrap;
}
.create-top-btn:hover {
background: #047857;
}
@media (max-width: 1120px) {
.range-combo {
width: 100%;
justify-content: flex-end;
}
}
@media (max-width: 960px) {
.topbar {
flex-direction: column;
align-items: stretch;
}
.top-actions,
.search-wrap,
.search-wrap.wide,
.month-chip,
.qa-filter,
.new-question-btn {
width: 100%;
}
.range-combo {
justify-content: stretch;
}
.range-shell {
flex: 1;
}
.range-meta {
flex: 1;
}
.custom-range-btn {
width: 100%;
justify-content: center;
}
.calendar-popover {
right: auto;
left: 0;
}
}
@media (max-width: 640px) {
.range-combo {
display: grid;
gap: 8px;
}
.range-shell {
height: auto;
display: grid;
gap: 4px;
}
.range-meta {
width: 100%;
border-right: 0;
border-bottom: 1px solid #e2e8f0;
justify-content: center;
}
.range-tabs {
width: 100%;
padding-left: 0;
}
.range-tabs button {
flex: 1;
padding: 0 8px;
}
.calendar-popover {
width: min(336px, calc(100vw - 32px));
}
.date-fields {
grid-template-columns: 1fr;
}
}
</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,141 @@
<template>
<span ref="root" class="rv" :aria-label="value">
<template v-for="(char, i) in chars" :key="`${i}-${charKinds[i]}`">
<span
v-if="isDigit(char)"
class="rv-col"
:style="{ '--y': `-${targetOffset(char)}em`, '--wait': `${i * 28 + 120}ms` }"
aria-hidden="true"
>
<span class="rv-strip" :class="{ go: on }">
<span v-for="d in len" :key="d" class="rv-d">{{ (d - 1) % 10 }}</span>
</span>
</span>
<span v-else class="rv-static" :class="{ currency: char === '¥', unit: /[a-zA-Z%]/.test(char) }" aria-hidden="true">{{ char }}</span>
</template>
</span>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const props = defineProps({
value: { type: String, required: true },
preRoll: { type: Number, default: 2 }
})
const PRE = props.preRoll * 10
const chars = computed(() => props.value.split(''))
const charKinds = computed(() => chars.value.map((char) => isDigit(char) ? 'digit' : 'static'))
const len = (props.preRoll + 1) * 10
function isDigit(c) { return /\d/.test(c) }
function targetOffset(c) { return PRE + Number(c) }
const on = ref(false)
const root = ref(null)
let observer = null
function roll() {
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
on.value = true
return
}
on.value = false
nextTick(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
on.value = true
})
})
})
}
onMounted(() => {
if (!('IntersectionObserver' in window)) {
roll()
return
}
observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
roll()
observer?.disconnect()
}
}, { threshold: 0.2 })
if (root.value) observer.observe(root.value)
})
onBeforeUnmount(() => observer?.disconnect())
watch(() => props.value, roll)
</script>
<style scoped>
.rv {
display: inline-flex;
align-items: center;
line-height: 1;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.rv-col {
display: inline-block;
position: relative;
width: .64em;
height: 1em;
line-height: 1;
overflow: hidden;
vertical-align: 0;
}
.rv-strip {
display: block;
transform: translate3d(0, 0, 0);
will-change: transform;
}
.rv-strip.go {
animation: digitRoll 2.35s cubic-bezier(.45, 0, .18, 1) var(--wait, 0ms) both;
}
.rv-d {
height: 1em;
display: block;
text-align: center;
line-height: 1;
}
.rv-static {
display: inline-flex;
align-items: center;
height: 1em;
line-height: 1;
}
.rv-static.currency {
margin-right: .02em;
}
.rv-static.unit {
margin-left: .04em;
}
@keyframes digitRoll {
from {
transform: translate3d(0, 0, 0);
}
to {
transform: translate3d(0, var(--y, 0), 0);
}
}
@media (prefers-reduced-motion: reduce) {
.rv-strip {
animation: none;
transform: translateY(var(--y, 0));
}
}
</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>

View File

@@ -0,0 +1,37 @@
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
export function useAnimationProgress(deps = [], duration = 1100) {
const progress = ref(0)
let frameId = 0
const easeOutCubic = (value) => 1 - Math.pow(1 - value, 3)
function start() {
cancelAnimationFrame(frameId)
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
progress.value = 1
return
}
const startedAt = performance.now()
progress.value = 0
function tick(now) {
const elapsed = Math.min((now - startedAt) / duration, 1)
progress.value = easeOutCubic(elapsed)
if (elapsed < 1) frameId = requestAnimationFrame(tick)
}
frameId = requestAnimationFrame(tick)
}
onMounted(start)
onBeforeUnmount(() => cancelAnimationFrame(frameId))
if (deps.length) {
watch(deps, start)
}
return progress
}

View File

@@ -0,0 +1,169 @@
import { computed, ref } from 'vue'
import { useNavigation, navItems } from './useNavigation.js'
import { useRequests } from './useRequests.js'
import { useChat } from './useChat.js'
import { useToast } from './useToast.js'
import { documents } from '../data/requests.js'
export function useAppShell() {
const loggedIn = ref(false)
const travelCreateMode = ref(false)
const detailMode = ref(false)
const selectedTravelRequest = ref(null)
const smartEntryOpen = ref(false)
const smartEntryContext = ref({ prompt: '', source: 'requests', request: null })
const smartEntrySessionId = ref(0)
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, openNewChat } = useChat(activeView)
const { toastText, toast } = useToast()
const docSearch = ref('')
const customRange = ref({ start: '2024-07-06', end: '2024-07-12' })
const travelPrompts = ['帮我提交出差申请', '预订机票', '预订酒店', '预订火车票', '查询差旅政策']
const topBarView = computed(() => {
if (detailMode.value) {
return {
title: '差旅报销详情',
desc: '查看报销单据详情、票据识别与审批进度'
}
}
return currentView.value
})
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)
return matchesSearch
})
})
function handleLogin(credentials) {
if (credentials.username && credentials.password) {
loggedIn.value = true
}
}
function handleRecoverPassword() {
toast('请联系系统管理员重置密码。')
}
function handleSsoLogin() {
toast('SSO 登录通道建设中。')
}
function handleApprove(request) {
const msg = approveRequest(request)
toast(msg)
}
function handleReject(request) {
const msg = rejectRequest(request)
toast(msg)
}
function handleNavigate(view) {
travelCreateMode.value = false
detailMode.value = false
selectedTravelRequest.value = null
smartEntryOpen.value = false
setView(view)
}
function handleOpenChat(request) {
travelCreateMode.value = false
openChat(request)
}
function openTravelCreate() {
smartEntryOpen.value = true
travelCreateMode.value = false
detailMode.value = false
selectedTravelRequest.value = null
smartEntryContext.value = { prompt: '', source: 'topbar', request: null }
smartEntrySessionId.value += 1
}
function openSmartEntry(payload = {}) {
smartEntryOpen.value = true
travelCreateMode.value = false
if (payload.source !== 'detail') {
detailMode.value = false
selectedTravelRequest.value = null
}
smartEntryContext.value = {
prompt: payload.prompt ?? '',
source: payload.source ?? 'workbench',
request: payload.request ?? selectedTravelRequest.value
}
smartEntrySessionId.value += 1
}
function closeSmartEntry() {
smartEntryOpen.value = false
}
function openRequestDetail(request) {
selectedTravelRequest.value = request
detailMode.value = true
activeView.value = 'requests'
}
function closeRequestDetail() {
detailMode.value = false
selectedTravelRequest.value = null
}
return {
activeCase,
activeRange,
activeView,
closeRequestDetail,
closeSmartEntry,
currentView,
customRange,
detailMode,
docSearch,
draft,
filteredDocuments,
filteredRequests,
filters,
handleApprove,
handleLogin,
handleNavigate,
handleOpenChat,
handleRecoverPassword,
handleReject,
handleSsoLogin,
handleUpload,
loggedIn,
messageList,
messages,
navItems,
openChat,
openNewChat,
openRequestDetail,
openSmartEntry,
openTravelCreate,
prompts,
ranges,
requests,
search,
selectedTravelRequest,
sendMessage,
setView,
smartEntryContext,
smartEntryOpen,
smartEntrySessionId,
toast,
toastText,
topBarView,
travelCreateMode,
travelPrompts,
uploadedFiles
}
}

View File

@@ -0,0 +1,102 @@
import { nextTick, ref } from 'vue'
const initialMessages = [
{
id: 1,
role: 'agent',
text: '我已读取当前报销、发票、行程和制度命中情况。当前建议:优先处理即将超时与高风险单据。'
},
{
id: 2,
role: 'user',
text: '请列出今天最需要关注的风险。'
},
{
id: 3,
role: 'agent',
text: '主要风险包括3 笔单据将在 30 分钟内超时,市场部存在 2 笔高风险差旅报销,另有 1 笔报销缺少酒店入住清单。'
}
]
export const prompts = ['生成审批意见', '列出补件清单', '解释风险原因', '生成运营简报']
export function useChat(activeView) {
const messages = ref([...initialMessages])
const draft = ref('')
const uploadedFiles = ref([])
const messageList = ref(null)
const activeCase = ref(null)
function agentReply(text) {
const c = activeCase.value
if (text.includes('超时') || text.includes('SLA')) {
return '当前最紧急的是 3 笔即将超时单据,建议先按剩余处理时长排序,并把缺附件单据转给申请人补齐。'
}
if (text.includes('高风险') || text.includes('风险')) {
return '高风险主要集中在市场部差旅报销,风险点包括住宿超标、重复发票疑似命中、行程说明缺失。建议人工复核后再通过。'
}
if (text.includes('部门')) {
return '从待处理金额看,销售部与研发中心占比最高;从异常占比看,市场部更需要优先关注。'
}
if (text.includes('简报')) {
return '运营简报:今日待审批 12 单,高风险 4 单,平均审批 5.6hSLA 达成率 96%。建议优先处理差旅报销和即将超时单据。'
}
if (text.includes('补件') || text.includes('附件')) {
return '建议补件清单:酒店入住水单、完整行程单、发票原件或验真结果、直属经理确认记录。'
}
if (text.includes('审批意见')) {
return c
? `${c.id} 建议审批意见:费用归属与预算中心基本匹配,请补充必要说明后通过。`
: '建议审批意见:当前单据存在待确认项,请先完成风险核查和附件补齐后再审批。'
}
return '收到。我可以继续帮你拆解异常原因、比较部门趋势、生成审批意见,或整理一份今日报销运营简报。'
}
function scrollToBottom() {
nextTick(() => messageList.value?.scrollTo({ top: messageList.value.scrollHeight, behavior: 'smooth' }))
}
function sendMessage() {
const text = draft.value.trim()
if (!text) return false
messages.value.push({ id: Date.now(), role: 'user', text })
draft.value = ''
setTimeout(() => {
messages.value.push({ id: Date.now() + 1, role: 'agent', text: agentReply(text) })
scrollToBottom()
}, 260)
return true
}
function handleUpload(event) {
uploadedFiles.value = Array.from(event.target.files ?? []).map((file) => ({
name: file.name,
size: file.size
}))
if (uploadedFiles.value.length) {
const names = uploadedFiles.value.map((file) => file.name).join('、')
messages.value.push({
id: Date.now(),
role: 'agent',
text: `已接收 ${uploadedFiles.value.length} 个附件:${names}。我会优先核对发票验真、费用标准、预算归属和必要审批材料。`
})
scrollToBottom()
}
}
function openChat(request) {
activeCase.value = request
activeView.value = 'chat'
nextTick(() => messageList.value?.scrollTo({ top: messageList.value.scrollHeight }))
}
function openNewChat() {
activeCase.value = null
activeView.value = 'chat'
}
return {
messages, draft, uploadedFiles, messageList, activeCase, prompts,
sendMessage, handleUpload, openChat, openNewChat, scrollToBottom
}
}

View File

@@ -0,0 +1,36 @@
import { ref } from 'vue'
export function useLoginView() {
const username = ref('')
const password = ref('')
const tenant = ref('')
const remember = ref(true)
const showPassword = ref(false)
const features = [
{ title: '智能审单', desc: 'AI 自动识别票据与规则,提升准确率与效率', icon: 'mdi mdi-file-document-outline', tone: 'green' },
{ title: '异常预警', desc: '多维风险识别与预警,主动防控风险', icon: 'mdi mdi-bell-outline', tone: 'red' },
{ title: 'SLA 监控', desc: '实时监控服务水平协议,保障审批及时性', icon: 'mdi mdi-sync', tone: 'blue' }
]
const LogoMark = {
template: `
<span class="logo-mark" aria-hidden="true">
<svg viewBox="0 0 36 36">
<path d="M19.8 4.5c5.7 1.1 9.9 5.7 10.5 11.6-2.8-.9-5.5-.7-7.9.6-2.8 1.5-4.5 4.3-5.2 8.2-4.4-2.8-6.5-6.5-6.3-11.1.2-4.2 3.5-7.8 8.9-9.3Z" />
<path d="M9 7.6c-3 3.5-4 7.3-2.9 11.2 1.2 4.2 4.6 7 10.1 8.5-2 1.8-4.6 2.6-7.6 2.3C5.1 26.7 3.5 23.1 3.7 19 4 14.4 5.7 10.6 9 7.6Z" />
</svg>
</span>
`
}
return {
features,
LogoMark,
password,
remember,
showPassword,
tenant,
username
}
}

View File

@@ -0,0 +1,83 @@
import { computed, ref } from 'vue'
import { icons } from '../data/icons.js'
export const navItems = [
{
id: 'overview',
label: '总览',
navHint: '运营指标与趋势',
icon: icons.dashboard,
title: '企业报销智能运营台',
desc: '面向财务共享中心的审批、风控、SLA与自动化运营看板'
},
{
id: 'workbench',
label: '个人工作台',
navHint: '今日待办与报销进度',
icon: icons.workspace,
title: '个人工作台',
desc: '集中处理今日待办、查看报销进度,并快速进入 AI 报销助手'
},
{
id: 'requests',
label: '差旅申请/报销',
navHint: '差旅单据与发起申请',
icon: icons.list,
title: '差旅申请/报销',
desc: '查看员工差旅报销单据、跟踪进度、发起新申请'
},
{
id: 'approval',
label: '审批中心',
navHint: '待审批单据与批量处理',
icon: icons.approval,
title: '审批中心',
desc: '统一处理待审批单据,聚焦效率、风险与 SLA'
},
{
id: 'chat',
label: 'AI助手',
navHint: '财务知识问答与制度解释',
icon: icons.message,
title: '财务AI助手',
desc: '面向员工与财务场景的智能问答助手,提供制度解读、报销指引与常见问题解答'
},
{
id: 'policies',
label: '知识管理',
navHint: '制度、文档与知识库',
icon: icons.file,
title: '财务知识管理中心',
desc: '上传制度文档、沉淀财务知识、构建面向员工问答与知识管理的统一知识库'
},
{
id: 'audit',
label: '技能中心',
navHint: 'Skill 设计与版本配置',
icon: icons.skill,
title: '技能中心',
desc: '统一管理技能的触发规则、提示词结构、输出约束与上线版本'
},
{
id: 'employees',
label: '员工管理',
navHint: '员工档案、岗位与角色权限',
icon: icons.users,
title: '员工管理',
desc: '集中维护员工基础信息、职级部门岗位,以及管理员、财务人员、使用者和高级管理人员等系统角色'
}
]
export function useNavigation() {
const activeView = ref('overview')
const currentView = computed(
() => navItems.find((item) => item.id === activeView.value) ?? navItems[0]
)
function setView(view) {
activeView.value = view
}
return { activeView, currentView, setView, navItems }
}

View File

@@ -0,0 +1,113 @@
import { computed, ref } from 'vue'
import {
metricBlueprints,
trendRanges,
trendSeries,
spendByCategory,
exceptionMix,
departmentRangeOptions,
bottlenecks,
budgetSummary
} from '../data/metrics.js'
export function useOverviewView() {
const activeTrendRange = ref(trendRanges[0])
const activeDepartmentRange = ref(departmentRangeOptions[0])
const demoTotals = {
pendingCount: 128,
pendingAmount: 361600,
avgSla: 6.8,
autoPassRate: 78,
riskCount: 14,
slaRate: 96
}
const demoDepartments = [
{ name: '销售部', amount: 182000, color: '#10b981' },
{ name: '研发中心', amount: 146000, color: '#3b82f6' },
{ name: '市场部', amount: 96000, color: '#f59e0b' },
{ name: '运营部', amount: 68600, color: '#8b5cf6' },
{ name: '行政部', amount: 48300, color: '#3b82f6' }
]
const formatCompact = (value) => {
if (value >= 1_000_000) return `¥${(value / 1_000_000).toFixed(1)}M`
if (value >= 1_000) return `¥${(value / 1_000).toFixed(1)}K`
return `¥${value}`
}
const formatCurrency = (value) => formatCompact(value)
const formatMetricValue = (metric, value) => {
if (metric.key === 'pendingAmount') return formatCurrency(Math.round(value))
if (metric.key === 'avgSla') return `${value.toFixed(1)} ${metric.unit}`
if (metric.unit === '%') return `${Math.round(value)} ${metric.unit}`
if (metric.unit) return `${Math.round(value)} ${metric.unit}`
return `${Math.round(value)}`
}
const kpiMetrics = computed(() => metricBlueprints.map((metric, index) => {
const rawValue = demoTotals[metric.key]
const displayValue = formatMetricValue(metric, rawValue)
return {
...metric,
displayValue,
changeText: metric.change,
delay: index * 55
}
}))
const activeTrend = computed(() => trendSeries[activeTrendRange.value])
const spendTotal = computed(() => spendByCategory.reduce((sum, item) => sum + item.value, 0))
const riskTotal = computed(() => exceptionMix.reduce((sum, item) => sum + item.value, 0))
const spendLegend = computed(() => spendByCategory.map((item) => ({
...item,
display: `${Math.round((item.value / spendTotal.value) * 100)}%`
})))
const riskLegend = computed(() => exceptionMix.map((item) => ({
...item,
display: `${item.value}`
})))
const rankedDepartments = computed(() => {
const rows = demoDepartments
const max = Math.max(...rows.map((item) => item.amount), 1)
return rows.slice(0, 5).map((item, index) => ({
...item,
rank: index + 1,
shortName: item.name,
amountLabel: formatCurrency(item.amount),
width: `${Math.max((item.amount / max) * 100, 18)}%`,
color: item.color
}))
})
return {
activeDepartmentRange,
activeTrend,
activeTrendRange,
bottlenecks,
budgetSummary,
departmentRangeOptions,
exceptionMix,
formatCompact,
formatCurrency,
formatMetricValue,
kpiMetrics,
metricBlueprints,
rankedDepartments,
riskLegend,
riskTotal,
spendByCategory,
spendLegend,
spendTotal,
trendRanges,
trendSeries
}
}

View File

@@ -0,0 +1,60 @@
import { computed, reactive, ref } from 'vue'
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 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 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 === 'custom'
|| 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) {
updateRequest(request.id, {
verdict: '已通过',
status: 'success',
risk: '已完成人工确认'
})
return `${request.id} 已标记为通过,审计日志已更新。`
}
function rejectRequest(request) {
updateRequest(request.id, {
verdict: '已退回补件',
status: 'danger',
risk: '待申请人补充差旅行程与票据'
})
return `${request.id} 已退回,系统将通知申请人补充材料。`
}
return {
requests, search, filters, ranges, activeRange,
filteredRequests, approveRequest, rejectRequest
}
}

View File

@@ -0,0 +1,13 @@
import { ref } from 'vue'
export function useToast() {
const toastText = ref('')
function toast(text) {
toastText.value = text
clearTimeout(toast.timer)
toast.timer = setTimeout(() => { toastText.value = '' }, 3200)
}
return { toastText, toast }
}

View File

@@ -0,0 +1,5 @@
export const auditTrail = [
{ time: '09:40', title: '规则 A1 被财务复核放行', note: '保留会议说明并写入审批意见。', badge: '完成', tone: 'success' },
{ time: '09:18', title: '重复发票拦截', note: 'REQ-2026-0416 已转人工核查。', badge: '阻断', tone: 'danger' },
{ time: '08:52', title: '自动补件提醒发送', note: '11 位员工收到业务招待纪要提醒。', badge: '执行中', tone: 'primary' }
]

16
web/src/data/icons.js Normal file
View File

@@ -0,0 +1,16 @@
const iconPath = (content) => `<svg viewBox="0 0 24 24" aria-hidden="true">${content}</svg>`
export const icons = {
dashboard: iconPath('<path d="M3 13h8V3H3z"/><path d="M13 21h8V11h-8z"/><path d="M13 3h8v6h-8z"/><path d="M3 21h8v-6H3z"/>'),
workspace: iconPath('<path d="M4 20h16"/><path d="M6 20V8a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v12"/><path d="M9 10h6"/><path d="M9 14h6"/><path d="M12 3v3"/>'),
list: iconPath('<path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/>'),
approval: iconPath('<path d="M9 11l2 2 4-5"/><path d="M20 12v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h8"/><path d="M17 3h4v4"/>'),
file: iconPath('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h5"/>'),
skill: iconPath('<path d="M12 3 9.5 8.5 3 11l6.5 2.5L12 19l2.5-5.5L21 11l-6.5-2.5z"/><path d="M19 19l.9 2 .9-2 2-.9-2-.9-.9-2-.9 2-2 .9z"/><path d="M5 5l.6 1.4L7 7l-1.4.6L5 9l-.6-1.4L3 7l1.4-.6z"/>'),
users: iconPath('<path d="M16 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2"/><circle cx="9.5" cy="7" r="4"/><path d="M20 8v6"/><path d="M23 11h-6"/>'),
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"/>'),
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"/>')
}

134
web/src/data/metrics.js Normal file
View File

@@ -0,0 +1,134 @@
export const metricBlueprints = [
{
key: 'pendingCount',
label: '待审批单据',
unit: '单',
accent: '#10b981',
icon: 'mdi mdi-file-document-outline',
trend: 'down',
change: '12.5%',
delta: '较昨日 -18 单'
},
{
key: 'pendingAmount',
label: '待处理金额',
accent: '#3b82f6',
icon: 'mdi mdi-wallet',
trend: 'up',
change: '8.3%',
delta: '较昨日 +¥27,400'
},
{
key: 'avgSla',
label: '平均审批时长',
unit: 'h',
accent: '#8b5cf6',
icon: 'mdi mdi-clock-outline',
trend: 'down',
change: '14.8%',
delta: '较昨日 -1.2h'
},
{
key: 'autoPassRate',
label: '自动审单通过率',
unit: '%',
accent: '#16a34a',
icon: 'mdi mdi-shield-outline',
trend: 'up',
change: '6.2%',
delta: '较昨日 +4.6%'
},
{
key: 'riskCount',
label: '异常预警单',
unit: '单',
accent: '#ef4444',
icon: 'mdi mdi-alert',
trend: 'up',
change: '16.7%',
delta: '较昨日 +2 单'
},
{
key: 'slaRate',
label: 'SLA 达成率',
unit: '%',
accent: '#10b981',
icon: 'mdi mdi-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: 182000, color: '#16a34a' },
{ name: '酒店', value: 146000, color: '#3b82f6' },
{ name: '火车/用车', value: 78600, color: '#f59e0b' },
{ name: '餐补及杂费', value: 55000, color: '#8b5cf6' }
]
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'
}

5
web/src/data/policies.js Normal file
View File

@@ -0,0 +1,5 @@
export const policies = [
{ code: 'A1', title: '差旅住宿标准', note: '按城市、职级、会议峰值期动态判断。', badge: '启用', tone: 'success' },
{ code: 'A2', title: '发票查重与验真', note: '票号、税号、金额、抬头四重校验。', badge: '启用', tone: 'success' },
{ code: 'A3', title: '业务招待材料前置', note: '客户名单、拜访纪要、审批单缺一不可。', badge: '建议强化', tone: 'warning' }
]

37
web/src/data/requests.js Normal file
View File

@@ -0,0 +1,37 @@
export const initialRequests = [
{ 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: 2, role: 'user', text: '请列出这张单据的主要风险。' },
{ id: 3, role: 'agent', text: '主要风险:缺少改签说明,且舱位价格高于差旅标准 12%。' }
]

21
web/src/main.js Normal file
View File

@@ -0,0 +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'
const app = createApp(App)
app.use(MotionPlugin)
app.use(PrimeVue, {
theme: {
preset: Aura,
options: {
darkModeSelector: '.dark',
cssLayer: false
}
}
})
app.mount('#app')

209
web/src/scripts/App.js Normal file
View File

@@ -0,0 +1,209 @@
import '../assets/styles/global.css'
import SidebarRail from '../components/layout/SidebarRail.vue'
import TopBar from '../components/layout/TopBar.vue'
import FilterBar from '../components/layout/FilterBar.vue'
import ToastNotification from '../components/shared/ToastNotification.vue'
import LoginView from '../views/LoginView.vue'
import OverviewView from '../views/OverviewView.vue'
import PersonalWorkbenchView from '../views/PersonalWorkbenchView.vue'
import ChatView from '../views/ChatView.vue'
import TravelReimbursementCreateView from '../views/TravelReimbursementCreateView.vue'
import TravelRequestDetailView from '../views/TravelRequestDetailView.vue'
import RequestsView from '../views/RequestsView.vue'
import ApprovalCenterView from '../views/ApprovalCenterView.vue'
import PoliciesView from '../views/PoliciesView.vue'
import AuditView from '../views/AuditView.vue'
import EmployeeManagementView from '../views/EmployeeManagementView.vue'
import { ref, computed } 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 } from '../data/requests.js'
export default {
name: 'App',
components: {
SidebarRail,
TopBar,
FilterBar,
ToastNotification,
LoginView,
OverviewView,
PersonalWorkbenchView,
ChatView,
TravelReimbursementCreateView,
TravelRequestDetailView,
RequestsView,
ApprovalCenterView,
PoliciesView,
AuditView,
EmployeeManagementView
},
setup() {
const loggedIn = ref(false)
const travelCreateMode = ref(false)
const detailMode = ref(false)
const selectedTravelRequest = ref(null)
const smartEntryOpen = ref(false)
const smartEntryContext = ref({ prompt: '', source: 'requests', request: null })
const smartEntrySessionId = ref(0)
function handleLogin(credentials) {
if (credentials.username && credentials.password) {
loggedIn.value = true
}
}
function handleRecoverPassword() {
toast('请联系系统管理员重置密码。')
}
function handleSsoLogin() {
toast('SSO 登录通道建设中。')
}
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, openNewChat } = useChat(activeView)
const { toastText, toast } = useToast()
const docSearch = ref('')
const customRange = ref({ start: '2024-07-06', end: '2024-07-12' })
const travelPrompts = ['帮我提交出差申请', '预订机票', '预订酒店', '预订火车票', '查询差旅政策']
const topBarView = computed(() => {
if (detailMode.value) {
return {
title: '差旅报销详情',
desc: '查看报销单据详情、票据识别与审批进度'
}
}
return currentView.value
})
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)
return matchesSearch
})
})
function handleApprove(request) {
const msg = approveRequest(request)
toast(msg)
}
function handleReject(request) {
const msg = rejectRequest(request)
toast(msg)
}
function handleNavigate(view) {
travelCreateMode.value = false
detailMode.value = false
selectedTravelRequest.value = null
smartEntryOpen.value = false
setView(view)
}
function handleOpenChat(request) {
travelCreateMode.value = false
openChat(request)
}
function openTravelCreate() {
smartEntryOpen.value = true
travelCreateMode.value = false
detailMode.value = false
selectedTravelRequest.value = null
smartEntryContext.value = { prompt: '', source: 'topbar', request: null }
smartEntrySessionId.value += 1
}
function openSmartEntry(payload = {}) {
smartEntryOpen.value = true
travelCreateMode.value = false
if (payload.source !== 'detail') {
detailMode.value = false
selectedTravelRequest.value = null
}
smartEntryContext.value = {
prompt: payload.prompt ?? '',
source: payload.source ?? 'workbench',
request: payload.request ?? selectedTravelRequest.value
}
smartEntrySessionId.value += 1
}
function closeSmartEntry() {
smartEntryOpen.value = false
}
function openRequestDetail(request) {
selectedTravelRequest.value = request
detailMode.value = true
activeView.value = 'requests'
}
function closeRequestDetail() {
detailMode.value = false
selectedTravelRequest.value = null
}
return {
loggedIn,
travelCreateMode,
detailMode,
selectedTravelRequest,
smartEntryOpen,
smartEntryContext,
smartEntrySessionId,
handleLogin,
handleRecoverPassword,
handleSsoLogin,
activeView,
currentView,
setView,
requests,
search,
filters,
ranges,
activeRange,
filteredRequests,
approveRequest,
rejectRequest,
messages,
draft,
uploadedFiles,
messageList,
activeCase,
prompts,
sendMessage,
handleUpload,
openChat,
openNewChat,
toastText,
toast,
docSearch,
customRange,
travelPrompts,
topBarView,
filteredDocuments,
handleApprove,
handleReject,
handleNavigate,
handleOpenChat,
openTravelCreate,
openSmartEntry,
closeSmartEntry,
openRequestDetail,
closeRequestDetail,
navItems
}
}
}

View File

@@ -0,0 +1,494 @@
<template>
<section class="approval-page">
<!-- Detail Modal Overlay -->
<Teleport to="body">
<Transition name="detail-modal">
<div v-if="false && selectedRow" class="detail-overlay" @click.self="selectedRow = null">
<div class="detail-modal">
<!-- Modal Header -->
<header class="modal-header">
<div class="header-left">
<div class="req-badge">{{ selectedRow.id }}</div>
<div class="header-title-group">
<h2>{{ selectedRow.type }}审批详情</h2>
<p>申请人{{ selectedRow.applicant }} · {{ selectedRow.department }} · {{ selectedRow.time }}</p>
</div>
</div>
<div class="header-right">
<div class="header-indicator" :class="selectedRow.riskTone">
<i class="mdi" :class="selectedRow.riskTone === 'high' ? 'mdi-alert-circle' : selectedRow.riskTone === 'medium' ? 'mdi-alert' : 'mdi-shield-check'"></i>
<span>{{ selectedRow.risk }}</span>
</div>
<div class="header-indicator status" :class="selectedRow.statusTone">
<span>{{ selectedRow.node }}</span>
</div>
<button class="close-btn" type="button" aria-label="关闭" @click="selectedRow = null">
<i class="mdi mdi-close"></i>
</button>
</div>
</header>
<!-- Progress Bar -->
<div class="modal-progress">
<div class="progress-track">
<div v-for="(step, idx) in approvalSteps" :key="step.label" class="progress-node" :class="{ done: step.done, active: step.active, current: step.current }">
<span class="node-dot">
<i v-if="step.done" class="mdi mdi-check"></i>
<template v-else>{{ step.index }}</template>
</span>
<div class="node-label">
<strong>{{ step.label }}</strong>
<small>{{ step.time }}</small>
</div>
<span v-if="idx < approvalSteps.length - 1" class="node-line" :class="{ filled: step.done || step.active }"></span>
</div>
</div>
</div>
<!-- Modal Body -->
<div class="modal-body">
<div class="body-grid">
<!-- Left Column -->
<div class="body-main">
<!-- 费用摘要 -->
<article class="content-card">
<div class="card-header">
<div class="card-title">
<i class="mdi mdi-clipboard-text-outline"></i>
<h3>费用摘要</h3>
</div>
</div>
<div class="metrics-strip">
<div class="metric-block amount">
<span class="metric-label">报销金额</span>
<strong class="metric-value">{{ selectedRow.amount }}</strong>
</div>
<div class="metric-block">
<span class="metric-label">SLA 剩余</span>
<strong class="metric-value sla" :class="selectedRow.slaTone">
<i class="mdi mdi-clock-outline"></i>
{{ selectedRow.sla }}
</strong>
</div>
<div class="metric-block">
<span class="metric-label">费用明细</span>
<strong class="metric-value">5 </strong>
</div>
<div class="metric-block">
<span class="metric-label">附件材料</span>
<strong class="metric-value">6 </strong>
</div>
</div>
<div class="summary-grid">
<div v-for="item in summaryItems" :key="item.label" class="summary-cell">
<div class="cell-icon"><i :class="item.icon"></i></div>
<div class="cell-content">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</div>
</article>
<!-- 费用明细 -->
<article class="content-card">
<div class="card-header">
<div class="card-title">
<i class="mdi mdi-receipt-text-outline"></i>
<h3>费用明细</h3>
</div>
<span class="card-badge">合计 6,920</span>
</div>
<div class="expense-table-wrap">
<table class="expense-table">
<thead>
<tr>
<th>费用项目</th>
<th>说明</th>
<th class="right">金额</th>
<th class="center">是否超标</th>
</tr>
</thead>
<tbody>
<tr v-for="item in expenseItems" :key="item.name">
<td><strong>{{ item.name }}</strong></td>
<td>{{ item.desc }}</td>
<td class="right">{{ item.amount }}</td>
<td class="center">
<span class="over-badge" :class="item.tone">
<i class="mdi" :class="item.tone === 'ok' ? 'mdi-check-circle' : 'mdi-alert-circle'"></i>
{{ item.status }}
</span>
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="2"><strong>合计</strong></td>
<td class="right"><strong class="total-amount">6,920</strong></td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</article>
<!-- 审批意见 -->
<article class="content-card">
<div class="card-header">
<div class="card-title">
<i class="mdi mdi-comment-text-outline"></i>
<h3>审批意见</h3>
</div>
</div>
<div class="opinion-wrap">
<textarea rows="4" placeholder="请输入审批意见..."></textarea>
</div>
</article>
</div>
<!-- Right Column -->
<aside class="body-side">
<!-- AI 风险识别 -->
<article class="side-card risk-card">
<div class="card-header">
<div class="card-title">
<i class="mdi mdi-robot-outline"></i>
<h3>AI 风险识别</h3>
</div>
<div class="risk-total high">
<span>综合风险</span>
<strong></strong>
</div>
</div>
<div class="risk-items">
<div v-for="risk in riskItems" :key="risk.text" class="risk-row" :class="risk.tone">
<div class="risk-icon">
<i :class="risk.icon"></i>
</div>
<span class="risk-text">{{ risk.text }}</span>
<span class="risk-level" :class="risk.tone">{{ risk.level }}</span>
</div>
</div>
<div class="risk-note">
<strong>AI 审核建议</strong>
<p>优先补齐酒店入住清单并复核出租车发票抬头与超标费用说明完成后可继续流转</p>
</div>
</article>
<!-- 附件材料 -->
<article class="side-card">
<div class="card-header">
<div class="card-title">
<i class="mdi mdi-paperclip"></i>
<h3>附件材料</h3>
</div>
<span class="card-badge warn">1 份缺失</span>
</div>
<div class="attachment-list-side">
<div v-for="file in attachments" :key="file.name" class="attachment-row" :class="{ missing: file.missing }">
<div class="file-icon-sm" :class="file.iconClass">
<i :class="file.icon"></i>
</div>
<div class="file-detail">
<strong>{{ file.name }}</strong>
<span>{{ file.size }}</span>
</div>
</div>
</div>
</article>
</aside>
</div>
</div>
<!-- Modal Footer -->
<footer class="modal-footer">
<div class="footer-left">
<button class="action-btn back" type="button" @click="selectedRow = null">
<i class="mdi mdi-arrow-left"></i>
<span>返回列表</span>
</button>
</div>
<div class="footer-right">
<button class="action-btn supplement" type="button">
<i class="mdi mdi-undo"></i>
<span>补充材料</span>
</button>
<button class="action-btn reject" type="button">
<i class="mdi mdi-close-circle-outline"></i>
<span>驳回</span>
</button>
<button class="action-btn approve" type="button">
<i class="mdi mdi-check-circle-outline"></i>
<span>通过</span>
</button>
</div>
</footer>
</div>
</div>
</Transition>
</Teleport>
<div v-if="selectedRow" class="approval-detail">
<div class="detail-scroll">
<article class="detail-hero panel">
<div class="applicant-card">
<div class="portrait">{{ selectedRow.avatar }}</div>
<div>
<h2>{{ selectedRow.applicant }} <span>{{ selectedRow.department }}</span></h2>
<p>提交时间 <strong>{{ selectedRow.time }}</strong></p>
</div>
</div>
<div class="hero-stat">
<span>金额</span>
<strong>{{ selectedRow.amount }}</strong>
</div>
<div class="hero-stat">
<span>风险等级</span>
<b :class="['risk-pill', selectedRow.riskTone]">{{ selectedRow.risk }}</b>
</div>
<div class="hero-stat">
<span>当前状态</span>
<b class="state-pill">{{ selectedRow.node }}</b>
</div>
<div class="hero-stat">
<span>SLA 剩余时间</span>
<strong class="countdown"><i class="mdi mdi-clock-outline"></i> 剩余 {{ selectedRow.sla }}</strong>
</div>
<div class="hero-summary-panel">
<div v-for="item in heroSummaryItems" :key="item.label" class="hero-summary-item">
<div class="hero-summary-label">
<span class="hero-summary-icon"><i :class="item.icon"></i></span>
<span>{{ item.label }}</span>
</div>
<strong>{{ item.value }}</strong>
</div>
</div>
<div class="progress-block">
<div class="progress-head">
<h3>当前进度</h3>
</div>
<div class="progress-line">
<div v-for="step in approvalSteps" :key="step.label" class="progress-step" :class="{ active: step.active, current: step.current }">
<span>
<i
v-if="step.current"
v-motion
class="current-progress-ring"
:initial="currentProgressRingMotion.initial"
:enter="currentProgressRingMotion.enter"
aria-hidden="true"
></i>
<i v-if="step.done" class="mdi mdi-check"></i>
<template v-else>{{ step.index }}</template>
</span>
<strong>{{ step.label }}</strong>
<small>{{ step.time }}</small>
</div>
</div>
</div>
</article>
<div class="detail-grid">
<section class="detail-left">
<article class="detail-card panel">
<div class="detail-card-head">
<div>
<h3>费用明细</h3>
<p>按发生时间逐笔展示附件与 AI 风险直接在表内完成核对</p>
</div>
<span class="detail-total">{{ expenseTotal }}</span>
</div>
<div class="detail-expense-table">
<table>
<thead>
<tr>
<th>时间</th>
<th>费用项目</th>
<th>说明</th>
<th>金额</th>
<th>附件材料</th>
<th>AI 风险识别</th>
</tr>
</thead>
<tbody>
<template v-for="item in expenseItems" :key="item.id">
<tr>
<td class="expense-time">
<strong>{{ item.time }}</strong>
<span>{{ item.dayLabel }}</span>
</td>
<td class="expense-type">
<strong>{{ item.name }}</strong>
<span>{{ item.category }}</span>
</td>
<td class="expense-desc">
<strong>{{ item.desc }}</strong>
<span>{{ item.detail }}</span>
</td>
<td class="expense-amount">
<strong>{{ item.amount }}</strong>
<span v-if="item.tone !== 'ok'" :class="['over-tag', item.tone]">{{ item.status }}</span>
</td>
<td class="expense-attachment">
<div class="expense-attachment-main">
<span :class="['attachment-pill', item.attachmentTone]">{{ item.attachmentStatus }}</span>
<button
v-if="item.attachments.length"
class="inline-action"
type="button"
@click="toggleExpenseAttachments(item.id)"
>
{{ expandedExpenseId === item.id ? '收起附件' : '查看附件' }}
</button>
</div>
<span class="attachment-hint">{{ item.attachmentHint }}</span>
</td>
<td class="expense-risk">
<template v-if="showExpenseRisk(item)">
<span :class="['risk-inline-tag', item.riskTone]">{{ item.riskLabel }}</span>
<p>{{ item.riskText }}</p>
</template>
</td>
</tr>
<tr v-if="expandedExpenseId === item.id" class="expense-expand-row">
<td colspan="6">
<div class="expense-files">
<span v-for="file in item.attachments" :key="file" class="expense-file-chip">
<i class="mdi mdi-paperclip"></i>
{{ file }}
</span>
</div>
</td>
</tr>
</template>
<tr class="total-row">
<td colspan="3">合计</td>
<td>{{ expenseTotal }}</td>
<td>{{ uploadedExpenseCount }} 项已上传票据</td>
<td>1 项待补材料1 项需补充超标说明</td>
</tr>
</tbody>
</table>
</div>
</article>
<article class="detail-card panel">
<h3>审批意见</h3>
<textarea rows="3" placeholder="输入审批意见..."></textarea>
</article>
</section>
</div>
</div>
<footer class="detail-actions">
<button class="back-action" type="button" @click="selectedRow = null">
<i class="mdi mdi-arrow-left"></i>
<span>退回列表</span>
</button>
<div class="approval-action-group" aria-label="审批操作">
<button class="approve-action" type="button"><i class="mdi mdi-check-circle-outline"></i> 通过</button>
<button class="reject-action" type="button"><i class="mdi mdi-close-circle-outline"></i> 驳回</button>
<button class="supplement-action" type="button"><i class="mdi mdi-undo"></i> 补充</button>
</div>
</footer>
</div>
<!-- Approval List -->
<article v-else class="approval-list panel">
<nav class="status-tabs" aria-label="审批状态">
<button
v-for="tab in tabs"
:key="tab"
type="button"
:class="{ active: activeTab === tab }"
@click="activeTab = tab"
>
{{ tab }}
</button>
</nav>
<div class="list-toolbar">
<div class="filter-set">
<button v-for="filter in filters" :key="filter" type="button" class="filter-btn">
<span>{{ filter }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
</div>
</div>
<p class="hint"><i class="mdi mdi-information-outline"></i> 点击单据行查看审批详情</p>
<div class="table-wrap">
<table>
<colgroup>
<col><col><col><col><col><col><col><col><col><col><col>
</colgroup>
<thead>
<tr>
<th>单号</th>
<th>申请人</th>
<th>申请部门</th>
<th>报销类型</th>
<th>金额</th>
<th>提交时间 <i class="mdi mdi-sort"></i></th>
<th>风险等级</th>
<th>SLA剩余</th>
<th>当前节点</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in visibleRows" :key="row.id" :class="{ spotlight: row.spotlight }" @click="selectedRow = row">
<td><strong class="doc-id">{{ row.id }}</strong></td>
<td>
<span class="person">
<span class="avatar">{{ row.avatar }}</span>
{{ row.applicant }}
</span>
</td>
<td>{{ row.department }}</td>
<td>{{ row.type }}</td>
<td>{{ row.amount }}</td>
<td>{{ row.time }}</td>
<td><span class="risk-tag" :class="row.riskTone">{{ row.risk }}</span></td>
<td><strong class="sla" :class="row.slaTone">{{ row.sla }}</strong></td>
<td>{{ row.node }}</td>
<td><span class="status-tag" :class="row.statusTone">{{ row.status }}</span></td>
<td>
<button class="more-btn" type="button" aria-label="查看审批详情" @click.stop="selectedRow = row">
<i class="mdi mdi-dots-horizontal"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<footer class="list-foot">
<span class="page-summary"> 126 当前第 1 </span>
<div class="pager" aria-label="分页">
<button class="page-nav" type="button" aria-label="上一页"><i class="mdi mdi-chevron-left"></i></button>
<button class="page-number active" type="button" aria-current="page">1</button>
<button class="page-number" type="button">2</button>
<button class="page-number" type="button">3</button>
<button class="page-number" type="button">4</button>
<button class="page-number" type="button">5</button>
<span>...</span>
<button class="page-number" type="button">13</button>
<button class="page-nav" type="button" aria-label="下一页"><i class="mdi mdi-chevron-right"></i></button>
</div>
<button class="page-size" type="button">10 / <i class="mdi mdi-chevron-down"></i></button>
</footer>
</article>
</section>
</template>
<script src="./scripts/ApprovalCenterView.js"></script>
<style scoped src="../assets/styles/views/approval-center-view.css"></style>

286
web/src/views/AuditView.vue Normal file
View File

@@ -0,0 +1,286 @@
<template>
<section class="skill-center">
<Transition name="skill-view" mode="out-in">
<article v-if="selectedSkill" key="detail" class="skill-detail">
<div class="detail-scroll">
<section class="detail-hero panel">
<div class="hero-title">
<div class="skill-badge" :class="selectedSkill.badgeTone">{{ selectedSkill.scope }}</div>
<h2>{{ selectedSkill.name }}</h2>
<p>{{ selectedSkill.summary }}</p>
</div>
<div class="hero-stats">
<div class="hero-stat">
<span>版本</span>
<strong>{{ selectedSkill.version }}</strong>
</div>
<div class="hero-stat">
<span>状态</span>
<b :class="['status-pill', selectedSkill.statusTone]">{{ selectedSkill.status }}</b>
</div>
<div class="hero-stat">
<span>触发命中率</span>
<strong>{{ selectedSkill.hitRate }}</strong>
</div>
<div class="hero-stat">
<span>负责人</span>
<strong>{{ selectedSkill.owner }}</strong>
</div>
</div>
</section>
<div class="detail-grid">
<section class="detail-main">
<article class="detail-card panel">
<div class="card-head">
<div>
<h3>基础配置</h3>
<p>定义 skill 的定位适用场景和默认执行策略</p>
</div>
<button class="mini-btn">保存草稿</button>
</div>
<div class="form-grid">
<label class="field">
<span>技能名称</span>
<input :value="selectedSkill.name" />
</label>
<label class="field">
<span>技能分类</span>
<input :value="selectedSkill.category" />
</label>
<label class="field span-2">
<span>适用描述</span>
<textarea rows="3" :value="selectedSkill.summary"></textarea>
</label>
<label class="field">
<span>触发方式</span>
<input :value="selectedSkill.triggerMode" />
</label>
<label class="field">
<span>默认模型</span>
<input :value="selectedSkill.model" />
</label>
</div>
</article>
<article class="detail-card panel">
<div class="card-head">
<div>
<h3>提示词结构</h3>
<p>按系统约束输入期望输出格式三个层级组织 skill 行为</p>
</div>
<span class="edit-badge">{{ selectedSkill.promptSections.length }} </span>
</div>
<div class="prompt-stack">
<section v-for="section in selectedSkill.promptSections" :key="section.title" class="prompt-block">
<header>
<strong>{{ section.title }}</strong>
<span>{{ section.intent }}</span>
</header>
<textarea rows="5" :value="section.content"></textarea>
</section>
</div>
</article>
<article class="detail-card panel">
<div class="card-head">
<div>
<h3>输出契约与测试样例</h3>
<p>确保 skill 在高频场景下输出稳定格式清晰</p>
</div>
<button class="mini-btn primary">运行测试</button>
</div>
<div class="contract-grid">
<div class="contract-panel">
<h4>输出要求</h4>
<ul>
<li v-for="rule in selectedSkill.outputRules" :key="rule">{{ rule }}</li>
</ul>
</div>
<div class="contract-panel">
<h4>测试样例</h4>
<div v-for="test in selectedSkill.tests" :key="test.name" class="test-row">
<div>
<strong>{{ test.name }}</strong>
<span>{{ test.input }}</span>
</div>
<b :class="['test-state', test.tone]">{{ test.result }}</b>
</div>
</div>
</div>
</article>
</section>
<aside class="detail-side">
<article class="side-card panel">
<div class="card-head">
<div>
<h3>触发规则</h3>
<p>当前命中策略</p>
</div>
</div>
<div class="tag-list">
<span v-for="item in selectedSkill.triggers" :key="item">{{ item }}</span>
</div>
</article>
<article class="side-card panel">
<div class="card-head">
<div>
<h3>工具权限</h3>
<p>可调用能力</p>
</div>
</div>
<div class="tool-list">
<div v-for="tool in selectedSkill.tools" :key="tool.name" class="tool-row">
<div>
<strong>{{ tool.name }}</strong>
<span>{{ tool.scope }}</span>
</div>
<b :class="['tool-state', tool.tone]">{{ tool.mode }}</b>
</div>
</div>
</article>
<article class="side-card panel">
<div class="card-head">
<div>
<h3>版本历史</h3>
<p>最近变更</p>
</div>
</div>
<div class="history-list">
<div v-for="item in selectedSkill.history" :key="item.version" class="history-row">
<strong>{{ item.version }}</strong>
<span>{{ item.note }}</span>
<small>{{ item.time }}</small>
</div>
</div>
</article>
<article class="side-card panel publish-card">
<div>
<h3>发布控制</h3>
<p>当前配置已通过核心检查可进入灰度或正式发布</p>
</div>
<div class="publish-summary">
<span>最近评审2026-05-05 14:20</span>
<strong>可发布</strong>
</div>
</article>
</aside>
</div>
</div>
<footer class="detail-actions">
<button class="back-action" type="button" @click="selectedSkill = null">
<i class="mdi mdi-arrow-left"></i>
<span>返回技能列表</span>
</button>
<div class="detail-action-group">
<button class="minor-action" type="button">
<i class="mdi mdi-content-save-outline"></i>
<span>保存草稿</span>
</button>
<button class="minor-action" type="button">
<i class="mdi mdi-flask-outline"></i>
<span>运行测试</span>
</button>
<button class="major-action" type="button">
<i class="mdi mdi-rocket-launch-outline"></i>
<span>正式上线</span>
</button>
</div>
</footer>
</article>
<article v-else key="list" class="skill-list panel">
<nav class="status-tabs" aria-label="技能状态">
<button
v-for="tab in tabs"
:key="tab"
type="button"
:class="{ active: activeTab === tab }"
@click="activeTab = tab"
>
{{ tab }}
</button>
</nav>
<div class="list-toolbar">
<div class="filter-set">
<button v-for="filter in filters" :key="filter" type="button" class="filter-btn">
<span>{{ filter }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
</div>
<button class="create-btn" type="button">
<i class="mdi mdi-plus"></i>
<span>新建 Skill</span>
</button>
</div>
<p class="hint"><i class="mdi mdi-information-outline"></i> 点击任意技能行进入设计与编辑界面</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>技能名称</th>
<th>分类</th>
<th>负责人</th>
<th>适用范围</th>
<th>模型</th>
<th>版本</th>
<th>状态</th>
<th>命中率</th>
<th>最近更新</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="skill in visibleSkills"
:key="skill.id"
:class="{ spotlight: skill.spotlight }"
@click="selectedSkill = skill"
>
<td>
<div class="skill-name-cell">
<span class="skill-avatar" :class="skill.badgeTone">{{ skill.short }}</span>
<div>
<strong>{{ skill.name }}</strong>
<span>{{ skill.summary }}</span>
</div>
</div>
</td>
<td>{{ skill.category }}</td>
<td>{{ skill.owner }}</td>
<td><span class="scope-pill">{{ skill.scope }}</span></td>
<td>{{ skill.model }}</td>
<td>{{ skill.version }}</td>
<td><span class="status-pill" :class="skill.statusTone">{{ skill.status }}</span></td>
<td>{{ skill.hitRate }}</td>
<td>{{ skill.updatedAt }}</td>
<td>
<button class="row-action" type="button" @click.stop="selectedSkill = skill">
编辑
</button>
</td>
</tr>
</tbody>
</table>
</div>
</article>
</Transition>
</section>
</template>
<script src="./scripts/AuditView.js"></script>
<style scoped src="../assets/styles/views/audit-view.css"></style>

178
web/src/views/ChatView.vue Normal file
View File

@@ -0,0 +1,178 @@
<template>
<section class="qa-view">
<div class="qa-layout">
<aside class="left-column">
<article class="panel side-panel conversation-list">
<header>
<h3>问答会话</h3>
<button class="outline-action" type="button" @click="emit('draft', '')">
<i class="mdi mdi-plus"></i>
<span>新建会话</span>
</button>
</header>
<div class="session-scroll">
<button
v-for="item in sessions"
:key="item.title"
class="session-row"
:class="{ active: item.active }"
type="button"
@click="applyPrompt(item.title)"
>
<span><i class="mdi mdi-message-processing-outline"></i></span>
<strong>{{ item.title }}</strong>
<time>{{ item.time }}</time>
</button>
</div>
</article>
</aside>
<article class="panel chat-panel">
<div ref="localMessageList" class="message-stream" aria-live="polite">
<div class="talk-row user">
<span class="avatar user-avatar"></span>
<div class="talk-content">
<header><strong>张明</strong><time>10:32</time></header>
<p class="user-question">北京出差酒店超标报销怎么处理</p>
</div>
</div>
<div class="talk-row assistant">
<span class="avatar assistant-avatar"><i class="mdi mdi-robot-outline"></i></span>
<div class="talk-content">
<header><strong>财务AI助手</strong><time>10:32</time></header>
<div class="answer-card">
<section>
<h4>结论</h4>
<p>酒店费用超过标准的部分原则上不予报销特殊情况可申请例外报销</p>
</section>
<section>
<h4>处理建议</h4>
<ul>
<li>超标部分由个人自理或按制度退回保留超标说明和相关凭证</li>
<li>符合公司相关政策的可提交佐证材料申请例外报销</li>
</ul>
</section>
<section>
<h4>适用规则</h4>
<ul>
<li>差旅报销管理办法2024第十二条住宿标准及超标处理</li>
<li>费用报销审批流程附件1国内差旅住宿标准</li>
</ul>
</section>
<footer>
<span>是否有帮助</span>
<button type="button" aria-label="有帮助"><i class="mdi mdi-thumb-up-outline"></i></button>
<button type="button" aria-label="无帮助"><i class="mdi mdi-thumb-down-outline"></i></button>
</footer>
</div>
</div>
</div>
<div class="talk-row user">
<span class="avatar user-avatar"></span>
<div class="talk-content">
<header><strong>张明</strong><time>10:35</time></header>
<p class="user-question">如果出差地公司名称不一致还能报销吗</p>
</div>
</div>
<div class="talk-row assistant">
<span class="avatar assistant-avatar"><i class="mdi mdi-robot-outline"></i></span>
<div class="talk-content">
<header><strong>财务AI助手</strong><time>10:35</time></header>
<div class="answer-card compact">
<section>
<h4>结论</h4>
<p>一般情况下差旅地与参会公司名称不一致需按异常处理建议提供情况说明并加盖公章或补充邀请材料</p>
</section>
<section>
<h4>适用规则</h4>
<ul>
<li>发票管理规定及失误销细则第二章发票基本要求</li>
<li>差旅报销管理办法附件1报销凭证要求</li>
</ul>
</section>
</div>
</div>
</div>
<div v-for="message in messages" :key="message.id" class="talk-row" :class="message.role === 'user' ? 'user' : 'assistant'">
<span class="avatar" :class="message.role === 'user' ? 'user-avatar' : 'assistant-avatar'">
<template v-if="message.role === 'user'"></template>
<i v-else class="mdi mdi-robot-outline"></i>
</span>
<div class="talk-content">
<header>
<strong>{{ message.role === 'user' ? '我' : '财务AI助手' }}</strong>
<time>刚刚</time>
</header>
<p :class="message.role === 'user' ? 'user-question' : 'agent-answer'">{{ message.text }}</p>
</div>
</div>
</div>
<div class="composer-wrap">
<div class="prompt-toolbar">
<span>猜你想问</span>
<button v-for="prompt in visiblePrompts" :key="prompt.text" type="button" @click="applyPrompt(prompt.text)">
<i :class="prompt.icon"></i>
{{ prompt.short }}
</button>
<button class="icon-refresh" type="button" aria-label="换一批问题" @click="rotatePrompts">
<i class="mdi mdi-refresh"></i>
</button>
</div>
<div class="composer">
<textarea
:value="draft"
rows="2"
placeholder="请输入你的问题,例如:差旅报销特殊标准是什么?"
@input="emit('draft', $event.target.value)"
@keydown.ctrl.enter.prevent="emit('send')"
></textarea>
<button class="send-button" type="button" aria-label="发送问题" @click="emit('send')">
<i class="mdi mdi-send"></i>
</button>
</div>
</div>
</article>
<aside class="right-column">
<article class="panel info-panel hot-top-panel">
<header>
<h3><i class="mdi mdi-fire"></i> 热门问题 Top10</h3>
<button type="button" @click="rotatePrompts">换一批 <i class="mdi mdi-refresh"></i></button>
</header>
<div class="top-question-list">
<button v-for="(item, index) in hotQuestions" :key="item" type="button" @click="applyPrompt(item)">
<strong>{{ String(index + 1).padStart(2, '0') }}</strong>
<span>{{ item }}</span>
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
</article>
<article class="panel info-panel similar-panel">
<header>
<h3>相似历史问题</h3>
<button type="button">查看全部 <i class="mdi mdi-chevron-right"></i></button>
</header>
<div class="similar-scroll">
<button v-for="item in similarQuestions" :key="item.text" class="similar-row" type="button" @click="applyPrompt(item.text)">
<span><i class="mdi mdi-file-question-outline"></i>{{ item.text }}</span>
<strong>{{ item.score }}</strong>
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
</article>
</aside>
</div>
</section>
</template>
<script src="./scripts/ChatView.js"></script>
<style scoped src="../assets/styles/views/chat-view.css"></style>

View File

@@ -0,0 +1,308 @@
<template>
<section class="employee-center">
<Transition name="employee-view" mode="out-in">
<article v-if="selectedEmployee" key="detail" class="employee-detail">
<div class="detail-scroll">
<section class="detail-hero panel">
<div class="hero-profile">
<div class="hero-avatar">{{ selectedEmployee.avatar }}</div>
<div class="hero-copy">
<div class="hero-tag">{{ selectedEmployee.employeeNo }}</div>
<h2>{{ selectedEmployee.name }}</h2>
<p>{{ selectedEmployee.department }} / {{ selectedEmployee.position }} / {{ selectedEmployee.grade }}</p>
</div>
</div>
<div class="hero-stats">
<div class="hero-stat">
<span>账号状态</span>
<strong>{{ selectedEmployee.status }}</strong>
</div>
<div class="hero-stat">
<span>直属上级</span>
<strong>{{ selectedEmployee.manager }}</strong>
</div>
<div class="hero-stat">
<span>财务归口</span>
<strong>{{ selectedEmployee.financeOwner }}</strong>
</div>
<div class="hero-stat">
<span>角色数量</span>
<strong>{{ selectedEmployee.roles.length }}</strong>
</div>
</div>
</section>
<div class="detail-grid">
<section class="detail-main">
<article class="detail-card panel">
<div class="card-head">
<div>
<h3>基础信息</h3>
<p>维护员工编号联系方式入职日期与常用档案信息</p>
</div>
</div>
<div class="form-grid">
<label class="field">
<span>员工姓名</span>
<input :value="selectedEmployee.name" />
</label>
<label class="field">
<span>员工编号</span>
<input :value="selectedEmployee.employeeNo" />
</label>
<label class="field">
<span>性别</span>
<input :value="selectedEmployee.gender" />
</label>
<label class="field">
<span>年龄</span>
<input :value="selectedEmployee.age" />
</label>
<label class="field">
<span>出生日期</span>
<input :value="selectedEmployee.birthDate" />
</label>
<label class="field">
<span>手机号</span>
<input :value="selectedEmployee.phone" />
</label>
<label class="field">
<span>邮箱</span>
<input :value="selectedEmployee.email" />
</label>
<label class="field">
<span>入职日期</span>
<input :value="selectedEmployee.joinDate" />
</label>
<label class="field">
<span>办公地点</span>
<input :value="selectedEmployee.location" />
</label>
</div>
</article>
<article class="detail-card panel">
<div class="card-head">
<div>
<h3>组织与岗位</h3>
<p>配置部门岗位职级和管理归属决定审批链路和数据访问边界</p>
</div>
</div>
<div class="form-grid">
<label class="field">
<span>所属部门</span>
<input :value="selectedEmployee.department" />
</label>
<label class="field">
<span>岗位</span>
<input :value="selectedEmployee.position" />
</label>
<label class="field">
<span>职级</span>
<input :value="selectedEmployee.grade" />
</label>
<label class="field">
<span>直属上级</span>
<input :value="selectedEmployee.manager" />
</label>
<label class="field">
<span>财务归口</span>
<input :value="selectedEmployee.financeOwner" />
</label>
<label class="field">
<span>成本中心</span>
<input :value="selectedEmployee.costCenter" />
</label>
</div>
</article>
<article class="detail-card panel">
<div class="card-head">
<div>
<h3>系统角色分配</h3>
<p>为员工分配管理员财务人员使用者高级管理人员等业务角色</p>
</div>
<span class="count-badge">{{ selectedEmployee.roles.length }} 个角色</span>
</div>
<div class="role-grid">
<label
v-for="role in roleOptions"
:key="role.id"
class="role-card"
:class="{ active: selectedEmployee.roles.includes(role.label) }"
>
<input type="checkbox" :checked="selectedEmployee.roles.includes(role.label)" />
<div class="role-copy">
<strong>{{ role.label }}</strong>
<p>{{ role.desc }}</p>
</div>
</label>
</div>
</article>
</section>
<aside class="detail-side">
<article class="side-card panel">
<div class="card-head">
<div>
<h3>当前权限摘要</h3>
<p>角色组合带来的系统可见范围</p>
</div>
</div>
<div class="tag-list">
<span v-for="role in selectedEmployee.roles" :key="role">{{ role }}</span>
</div>
<ul class="bullet-list">
<li v-for="item in selectedEmployee.permissions" :key="item">{{ item }}</li>
</ul>
</article>
<article class="side-card panel">
<div class="card-head">
<div>
<h3>最近变更</h3>
<p>查看角色与档案调整记录</p>
</div>
</div>
<div class="history-list">
<div v-for="item in selectedEmployee.history" :key="item.time" class="history-row">
<strong>{{ item.action }}</strong>
<span>{{ item.owner }}</span>
<small>{{ item.time }}</small>
</div>
</div>
</article>
<article class="side-card panel publish-card">
<div>
<h3>生效状态</h3>
<p>当前修改将在保存后同步到审批报销知识和权限模块</p>
</div>
<div class="publish-summary">
<span>上次同步{{ selectedEmployee.lastSync }}</span>
<strong>{{ selectedEmployee.syncState }}</strong>
</div>
</article>
</aside>
</div>
</div>
<footer class="detail-actions">
<button class="back-action" type="button" @click="selectedEmployee = null">
<i class="mdi mdi-arrow-left"></i>
<span>返回员工列表</span>
</button>
<div class="detail-action-group">
<button class="minor-action" type="button">
<i class="mdi mdi-content-save-outline"></i>
<span>保存草稿</span>
</button>
<button class="minor-action" type="button">
<i class="mdi mdi-account-cancel-outline"></i>
<span>停用账号</span>
</button>
<button class="major-action" type="button">
<i class="mdi mdi-check-circle-outline"></i>
<span>保存并生效</span>
</button>
</div>
</footer>
</article>
<article v-else key="list" class="employee-list panel">
<nav class="status-tabs" aria-label="员工状态筛选">
<button
v-for="tab in tabs"
:key="tab"
type="button"
:class="{ active: activeTab === tab }"
@click="activeTab = tab"
>
{{ tab }}
</button>
</nav>
<div class="list-toolbar">
<div class="filter-set">
<div class="list-search">
<i class="mdi mdi-magnify"></i>
<input type="search" placeholder="搜索员工姓名、工号、部门或岗位..." />
</div>
<button v-for="filter in filters" :key="filter" type="button" class="filter-btn">
<span>{{ filter }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
</div>
<button class="create-btn" type="button">
<i class="mdi mdi-plus"></i>
<span>新增员工</span>
</button>
</div>
<p class="hint"><i class="mdi mdi-information-outline"></i> 点击任意员工行可进入基础信息与角色权限编辑界面</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>员工</th>
<th>工号</th>
<th>部门</th>
<th>岗位</th>
<th>职级</th>
<th>直属上级</th>
<th>财务归口</th>
<th>系统角色</th>
<th>状态</th>
<th>最近更新</th>
</tr>
</thead>
<tbody>
<tr
v-for="employee in visibleEmployees"
:key="employee.id"
:class="{ spotlight: employee.spotlight }"
@click="selectedEmployee = employee"
>
<td>
<div class="employee-cell">
<span class="employee-avatar">{{ employee.avatar }}</span>
<div>
<strong>{{ employee.name }}</strong>
<span>{{ employee.email }}</span>
</div>
</div>
</td>
<td>{{ employee.employeeNo }}</td>
<td>{{ employee.department }}</td>
<td>{{ employee.position }}</td>
<td><span class="level-pill">{{ employee.grade }}</span></td>
<td>{{ employee.manager }}</td>
<td>{{ employee.financeOwner }}</td>
<td>
<div class="role-stack">
<span v-for="role in employee.roles.slice(0, 2)" :key="role" class="role-pill">{{ role }}</span>
<span v-if="employee.roles.length > 2" class="more-pill">+{{ employee.roles.length - 2 }}</span>
</div>
</td>
<td><span class="status-pill" :class="employee.statusTone">{{ employee.status }}</span></td>
<td>{{ employee.updatedAt }}</td>
</tr>
</tbody>
</table>
</div>
</article>
</Transition>
</section>
</template>
<script src="./scripts/EmployeeManagementView.js"></script>
<style scoped src="../assets/styles/views/employee-management-view.css"></style>

154
web/src/views/LoginView.vue Normal file
View File

@@ -0,0 +1,154 @@
<template>
<main class="login-page">
<header class="page-brand">
<LogoMark />
<strong>星海科技</strong>
</header>
<section class="hero">
<p class="eyebrow-text">Smart Expense Operations</p>
<h1>企业报销智能运营台</h1>
<p class="hero-lead">让报销审批更智能更高效</p>
<p class="hero-sub">智能审单 · 自动化审批 · 风险预警 · SLA 监控 · 数据驱动决策</p>
<div class="hero-stage" aria-hidden="true">
<span class="flow-line flow-a"></span>
<span class="flow-line flow-b"></span>
<span class="flow-line flow-c"></span>
<div class="metric-card amount">
<span>报销金额趋势</span>
<strong>361,600</strong>
<small>较昨日 <b class="up"> 8.3%</b></small>
<div class="mini-bars"><i></i><i></i><i></i><i></i></div>
</div>
<div class="document-card">
<span>报销单</span>
<i></i><i></i><i></i>
<b class="doc-check"><i class="mdi mdi-check"></i></b>
</div>
<img class="shield-art" src="../assets/security-shield.png" alt="" />
<div class="round-badge ai">AI</div>
<div class="metric-card risk">
<span>风险预警</span>
<strong><i class="mdi mdi-alert"></i> 14 </strong>
<small>较昨日 <b class="danger"> 16.7%</b></small>
</div>
<div class="metric-card audit">
<span>审批效率</span>
<strong>78%</strong>
<small>较昨日 <b class="up"> 6.2%</b></small>
</div>
<div class="metric-card sla">
<span>SLA 达成率</span>
<strong>96%</strong>
<small>较昨日 <b class="up"> 3.1%</b></small>
</div>
</div>
<div class="feature-strip" aria-label="核心能力">
<article v-for="item in features" :key="item.title">
<span :class="item.tone"><i :class="item.icon"></i></span>
<div>
<strong>{{ item.title }}</strong>
<p>{{ item.desc }}</p>
</div>
</article>
</div>
</section>
<section class="login-card" aria-label="登录表单">
<div class="card-brand">
<LogoMark />
<strong>星海科技</strong>
</div>
<header class="card-head">
<h2>欢迎登录</h2>
<p>登录企业报销智能运营台</p>
</header>
<form class="login-form" @submit.prevent="emit('login', { username, password })">
<label class="field">
<span class="sr-only">账号</span>
<i class="mdi mdi-account-outline"></i>
<input v-model="username" type="text" placeholder="请输入账号 / 邮箱 / 手机号" autocomplete="username" required />
</label>
<label class="field">
<span class="sr-only">密码</span>
<i class="mdi mdi-lock-outline"></i>
<input
v-model="password"
:type="showPassword ? 'text' : 'password'"
placeholder="请输入密码"
autocomplete="current-password"
required
/>
<button
class="field-icon-btn"
type="button"
:aria-label="showPassword ? '隐藏密码' : '显示密码'"
@click="showPassword = !showPassword"
>
<i :class="showPassword ? 'mdi mdi-eye' : 'mdi mdi-eye-slash'"></i>
</button>
</label>
<label class="field">
<span class="sr-only">企业或租户</span>
<i class="mdi mdi-office-building"></i>
<input v-model="tenant" type="text" placeholder="请输入企业 / 租户(选填)" />
<button class="field-icon-btn" type="button" aria-label="展开企业列表">
<i class="mdi mdi-chevron-down"></i>
</button>
</label>
<div class="form-meta">
<label class="remember">
<input v-model="remember" type="checkbox" />
<span>记住我</span>
</label>
<button type="button" class="link-btn" @click="emit('recover-password')">忘记密码?</button>
</div>
<button class="submit-btn" type="submit">登录</button>
<div class="divider"><span></span></div>
<button class="sso-btn" type="button" @click="emit('sso-login')">
<i class="mdi mdi-shield-outline"></i>
<span>SSO 单点登录</span>
</button>
</form>
<footer class="security-note">
<i class="mdi mdi-lock-outline"></i>
<span>安全登录 · 数据加密传输 · 如需帮助请联系管理员</span>
</footer>
</section>
</main>
</template>
<script setup>
import { useLoginView } from '../composables/useLoginView.js'
const emit = defineEmits(['login', 'recover-password', 'sso-login'])
const {
features,
LogoMark,
password,
remember,
showPassword,
tenant,
username
} = useLoginView()
</script>
<style scoped src="../assets/styles/views/login-view.css"></style>

View File

@@ -0,0 +1,149 @@
<template>
<section class="dashboard">
<div class="kpi-grid">
<article
v-for="metric in kpiMetrics"
:key="metric.label"
class="kpi-card panel"
:style="{ '--accent': metric.accent, '--delay': `${metric.delay}ms` }"
>
<div class="kpi-head">
<span class="kpi-icon"><i :class="metric.icon"></i></span>
<span class="kpi-label">{{ metric.label }}</span>
</div>
<strong class="kpi-value">{{ metric.displayValue }}</strong>
<div class="kpi-trend">
<span class="kpi-badge" :class="metric.trend">
<i :class="metric.trend === 'down' ? 'mdi mdi-arrow-down' : 'mdi mdi-arrow-up'"></i>
{{ metric.changeText }}
</span>
<span class="kpi-delta">{{ metric.delta }}</span>
</div>
</article>
</div>
<div class="content-grid top-grid">
<article class="panel dashboard-card trend-panel">
<div class="card-head">
<h3>报销申请与审批趋势 <i class="mdi mdi-information-outline"></i></h3>
<select v-model="activeTrendRange" class="card-select" aria-label="趋势时间范围">
<option v-for="range in trendRanges" :key="range">{{ range }}</option>
</select>
</div>
<TrendChart
:labels="activeTrend.labels"
:applications="activeTrend.applications"
:approved="activeTrend.approved"
:avg-hours="activeTrend.avgHours"
/>
</article>
<article class="panel dashboard-card donut-panel">
<div class="card-head">
<h3>费用结构 <i class="mdi mdi-information-outline"></i></h3>
</div>
<DonutChart :items="spendLegend" center-value="¥361.6K" center-label="待处理金额" />
<p class="panel-note">* 百分比为占待处理金额比例</p>
</article>
<article class="panel dashboard-card donut-panel">
<div class="card-head">
<h3>风险异常分布 <i class="mdi mdi-information-outline"></i></h3>
</div>
<DonutChart :items="riskLegend" :center-value="`${riskTotal}`" center-label="异常预警单" />
<p class="panel-note">* 近30天数据</p>
</article>
</div>
<div class="content-grid bottom-grid">
<article class="panel dashboard-card rank-panel">
<div class="card-head">
<h3>部门报销排行待处理金额 <i class="mdi mdi-information-outline"></i></h3>
<select v-model="activeDepartmentRange" class="card-select" aria-label="部门排行时间范围">
<option v-for="range in departmentRangeOptions" :key="range">{{ range }}</option>
</select>
</div>
<BarChart :items="rankedDepartments" />
</article>
<article class="panel dashboard-card bottleneck-panel">
<div class="card-head">
<h3>审批瓶颈平均处理时长 <i class="mdi mdi-information-outline"></i></h3>
</div>
<div class="bottleneck-list">
<div
v-for="(item, index) in bottlenecks"
:key="item.name"
class="bottleneck-row"
:style="{ '--delay': `${index * 70}ms` }"
>
<div class="reviewer">
<div class="reviewer-avatar">{{ item.avatar }}</div>
<div>
<strong>{{ item.name }}</strong>
<span>{{ item.role }}</span>
</div>
</div>
<div class="reviewer-stats">
<strong>{{ item.duration }}</strong>
<span class="status-tag" :class="item.tone">{{ item.status }}</span>
</div>
</div>
</div>
<button type="button" class="text-link">查看全部 <i class="mdi mdi-chevron-right"></i></button>
</article>
<article class="panel dashboard-card budget-panel">
<div class="card-head">
<h3>预算执行率本月 <i class="mdi mdi-information-outline"></i></h3>
</div>
<GaugeChart
:ratio="budgetSummary.ratio"
:total="budgetSummary.total"
:used="budgetSummary.used"
:left="budgetSummary.left"
/>
<button type="button" class="text-link">查看详情 <i class="mdi mdi-chevron-right"></i></button>
</article>
</div>
</section>
</template>
<script setup>
import TrendChart from '../components/charts/TrendChart.vue'
import DonutChart from '../components/charts/DonutChart.vue'
import BarChart from '../components/charts/BarChart.vue'
import GaugeChart from '../components/charts/GaugeChart.vue'
import { useOverviewView } from '../composables/useOverviewView.js'
defineProps({
filteredRequests: { type: Array, required: true }
})
const emit = defineEmits(['ask'])
const {
activeDepartmentRange,
activeTrend,
activeTrendRange,
bottlenecks,
budgetSummary,
departmentRangeOptions,
kpiMetrics,
rankedDepartments,
riskLegend,
riskTotal,
spendLegend,
trendRanges
} = useOverviewView()
</script>
<style scoped src="../assets/styles/views/overview-view.css"></style>

View File

@@ -0,0 +1,9 @@
<template>
<PersonalWorkbench :show-header="false" @open-assistant="emit('openAssistant', $event)" />
</template>
<script setup>
import PersonalWorkbench from '../components/business/PersonalWorkbench.vue'
const emit = defineEmits(['openAssistant'])
</script>

View File

@@ -0,0 +1,196 @@
<template>
<section class="knowledge-page">
<div class="knowledge-grid" :class="{ 'has-preview': selectedDocument }">
<section class="knowledge-main">
<article class="library-panel panel">
<header class="panel-title">
<div>
<h2>文档库 / 文件夹</h2>
<p>默认展示文件列表点击具体文件后可在右侧展开预览</p>
</div>
<span class="preview-hint" :class="{ active: selectedDocument }">
{{ selectedDocument ? '预览已展开' : '点击文件可预览' }}
</span>
</header>
<div class="library-body">
<aside class="folder-rail">
<label class="folder-search">
<i class="mdi mdi-magnify"></i>
<input v-model="folderSearch" type="search" placeholder="搜索文件夹" />
<button type="button" aria-label="新增文件夹"><i class="mdi mdi-plus"></i></button>
</label>
<nav class="folder-tree" aria-label="知识库文件夹">
<button
v-for="folder in filteredFolders"
:key="folder.name"
type="button"
:class="{ active: activeFolder === folder.name }"
@click="activeFolder = folder.name"
>
<i :class="folder.icon"></i>
<span>{{ folder.name }}</span>
<b>{{ folder.count }}</b>
</button>
</nav>
<button class="new-folder-btn" type="button">
<i class="mdi mdi-plus"></i>
<span>新建文件夹</span>
</button>
</aside>
<section class="document-area">
<div class="upload-zone">
<i class="mdi mdi-cloud-upload"></i>
<strong>拖拽文档到此处或点击上传</strong>
<span>支持 PDF / Word / Excel / PPT 文档单个文件不超过 100MB</span>
</div>
<div class="doc-table-wrap">
<table>
<thead>
<tr>
<th>文件名称</th>
<th>标签</th>
<th>上传时间 <i class="mdi mdi-arrow-down"></i></th>
<th>版本</th>
<th>状态</th>
<th>上传人</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="doc in filteredDocuments"
:key="doc.name"
class="doc-row"
:class="{ selected: selectedDocument?.name === doc.name }"
@click="selectedDocument = doc"
>
<td>
<span class="file-name">
<i :class="doc.icon"></i>
{{ doc.name }}
</span>
</td>
<td>
<span class="doc-tag">{{ doc.tag }}</span>
</td>
<td>{{ doc.time }}</td>
<td>{{ doc.version }}</td>
<td><span class="state-tag" :class="doc.stateTone">{{ doc.state }}</span></td>
<td>{{ doc.owner }}</td>
<td>
<button class="more-btn" type="button" aria-label="更多操作" @click.stop>
<i class="mdi mdi-dots-horizontal"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<footer class="list-foot">
<span> {{ filteredDocuments.length }} </span>
<button type="button">10/ <i class="mdi mdi-chevron-down"></i></button>
<div class="pager" aria-label="分页">
<button type="button" aria-label="上一页"><i class="mdi mdi-chevron-left"></i></button>
<button class="active" type="button" aria-current="page">1</button>
<button type="button">2</button>
<button type="button" aria-label="下一页"><i class="mdi mdi-chevron-right"></i></button>
</div>
<label>前往 <input value="1" aria-label="页码" /> </label>
</footer>
</section>
</div>
</article>
</section>
<Transition name="preview-panel">
<aside v-if="selectedDocument" class="preview-column">
<article class="preview-panel panel">
<header class="preview-head">
<div>
<span class="preview-kicker">文件预览</span>
<h2>{{ selectedDocument.name }}</h2>
<p>{{ selectedDocument.summary }}</p>
</div>
<div class="preview-actions">
<button type="button" class="mini-action">
<i class="mdi mdi-download"></i>
<span>下载</span>
</button>
<button type="button" class="icon-action" aria-label="关闭预览" @click="selectedDocument = null">
<i class="mdi mdi-close"></i>
</button>
</div>
</header>
<div class="preview-meta">
<span><i class="mdi mdi-tag-outline"></i>{{ selectedDocument.tag }}</span>
<span><i class="mdi mdi-history"></i>{{ selectedDocument.time }}</span>
<span><i class="mdi mdi-account-circle-outline"></i>{{ selectedDocument.owner }}</span>
<span><i class="mdi mdi-source-branch"></i>{{ selectedDocument.version }}</span>
</div>
<div class="preview-viewer">
<div class="viewer-toolbar">
<div class="viewer-filetype" :class="selectedDocument.fileType">
<i :class="selectedDocument.icon"></i>
<span>{{ selectedDocument.fileTypeLabel }}</span>
</div>
<div class="viewer-toolbar-actions">
<button type="button"><i class="mdi mdi-magnify-minus-outline"></i></button>
<button type="button"><i class="mdi mdi-magnify-plus-outline"></i></button>
<button type="button"><i class="mdi mdi-fit-to-page-outline"></i></button>
</div>
</div>
<div class="page-stage">
<article
v-for="(page, index) in selectedDocument.previewPages"
:key="`${selectedDocument.name}-${index}`"
class="page-sheet"
:style="{ '--page-delay': `${index * 70}ms` }"
>
<header class="page-title">
<div>
<strong>{{ page.title }}</strong>
<span>{{ page.subtitle }}</span>
</div>
<b> {{ index + 1 }} </b>
</header>
<section class="page-summary">
<div class="summary-grid">
<div v-for="item in page.stats" :key="item.label" class="summary-item">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</section>
<section class="page-content">
<div v-for="block in page.blocks" :key="block.heading" class="content-block">
<h3>{{ block.heading }}</h3>
<ul>
<li v-for="line in block.lines" :key="line">{{ line }}</li>
</ul>
</div>
</section>
</article>
</div>
</div>
</article>
</aside>
</Transition>
</div>
</section>
</template>
<script src="./scripts/PoliciesView.js"></script>
<style scoped src="../assets/styles/views/policies-view.css"></style>

View File

@@ -0,0 +1,127 @@
<template>
<section class="travel-page">
<article class="travel-list panel">
<nav class="status-tabs" aria-label="差旅报销状态">
<button
v-for="tab in tabs"
:key="tab"
type="button"
:class="{ active: activeTab === tab }"
@click="activeTab = tab"
>
{{ tab }}
</button>
</nav>
<div class="list-toolbar">
<div class="filter-set">
<div class="list-search">
<i class="mdi mdi-magnify"></i>
<input type="search" placeholder="搜索申请人、单号、费用类型..." />
</div>
<div class="date-range-filter" :class="{ open: datePopover }">
<button class="filter-btn date-range-trigger" type="button" @click="datePopover = !datePopover">
<span class="date-range-label">{{ dateRangeLabel }}</span>
<i class="mdi mdi-calendar"></i>
</button>
<div v-if="datePopover" class="date-range-popover" role="dialog" aria-label="选择时间段">
<header>
<strong>选择时间段</strong>
<button type="button" aria-label="关闭" @click="datePopover = false"><i class="mdi mdi-close"></i></button>
</header>
<div class="date-range-fields">
<label>
<span>开始日期</span>
<input v-model="rangeStart" type="date" />
</label>
<label>
<span>结束日期</span>
<input v-model="rangeEnd" type="date" />
</label>
</div>
<footer>
<button class="ghost-btn" type="button" @click="datePopover = false">取消</button>
<button class="apply-btn" type="button" :disabled="!rangeStart || !rangeEnd" @click="applyDateRange">应用</button>
</footer>
</div>
</div>
<button v-for="filter in filters" :key="filter" type="button" class="filter-btn">
<span>{{ filter }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
</div>
<button class="create-request-btn" type="button" @click="emit('create-request')">
<i class="mdi mdi-plus-circle-outline"></i>
<span>发起报销</span>
</button>
</div>
<p class="hint"><i class="mdi mdi-information-outline"></i> 点击任意行可查看单据详情</p>
<div class="table-wrap">
<table>
<colgroup>
<col class="col-id">
<col class="col-reason">
<col class="col-city">
<col class="col-period">
<col class="col-apply">
<col class="col-amount">
<col class="col-node">
<col class="col-approval">
<col class="col-travel">
</colgroup>
<thead>
<tr>
<th>单号</th>
<th>出差事由</th>
<th>出差城市</th>
<th>出差时间</th>
<th>申请时间</th>
<th>申请金额</th>
<th>当前节点</th>
<th>审批状态</th>
<th>商旅状态</th>
</tr>
</thead>
<tbody>
<tr v-for="row in visibleRows" :key="row.id" @click="emit('ask', row)">
<td><strong class="doc-id">{{ row.id }}</strong></td>
<td>{{ row.reason }}</td>
<td>{{ row.city }}</td>
<td>{{ row.period }}</td>
<td>{{ row.applyTime }}</td>
<td>{{ row.amount }}</td>
<td>{{ row.node }}</td>
<td><span class="status-tag" :class="row.approvalTone">{{ row.approval }}</span></td>
<td><span class="status-tag" :class="row.travelTone">{{ row.travel }}</span></td>
</tr>
</tbody>
</table>
</div>
<footer class="list-foot">
<span class="page-summary"> {{ totalCount }} 目前第 {{ currentPage }} </span>
<div class="pager" aria-label="分页">
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--"><i class="mdi mdi-chevron-left"></i></button>
<button v-for="p in totalPages" :key="p" class="page-number" :class="{ active: currentPage === p }" type="button" :aria-current="currentPage === p ? 'page' : undefined" @click="currentPage = p">{{ p }}</button>
<button class="page-nav" type="button" :disabled="currentPage === totalPages" aria-label="下一页" @click="currentPage++"><i class="mdi mdi-chevron-right"></i></button>
</div>
<div class="page-size-wrap">
<button class="page-size" type="button" @click="pageSizeOpen = !pageSizeOpen">
{{ pageSize }} / <i class="mdi mdi-chevron-down"></i>
</button>
<div v-if="pageSizeOpen" class="page-size-dropdown" role="listbox">
<button v-for="size in pageSizes" :key="size" type="button" role="option" :aria-selected="pageSize === size" :class="{ active: pageSize === size }" @click="changePageSize(size)">{{ size }} /</button>
</div>
</div>
</footer>
</article>
</section>
</template>
<script src="./scripts/RequestsView.js"></script>
<style scoped src="../assets/styles/views/requests-view.css"></style>

View File

@@ -0,0 +1,338 @@
<template>
<Teleport to="body">
<Transition name="assistant-modal">
<div class="assistant-overlay" @click.self="emit('close')">
<section class="assistant-modal">
<header class="assistant-header">
<div class="assistant-header-main">
<span class="assistant-badge">AI Workspace</span>
<div>
<h2>统一对话工作台</h2>
<p>个人工作台发起报销智能录入统一走这里右侧会根据你的意图实时切换状态视图</p>
</div>
</div>
<div class="assistant-header-actions">
<span class="source-pill">{{ sourceLabel }}</span>
<button class="close-btn" type="button" aria-label="关闭对话工作台" @click="emit('close')">
<i class="mdi mdi-close"></i>
</button>
</div>
</header>
<div class="assistant-layout" :class="{ 'has-insight': showInsightPanel }">
<section class="dialog-panel">
<div class="dialog-toolbar">
<button
v-for="shortcut in shortcuts"
:key="shortcut.label"
type="button"
class="shortcut-chip"
@click="runShortcut(shortcut.prompt)"
>
<i :class="shortcut.icon"></i>
<span>{{ shortcut.label }}</span>
</button>
</div>
<div ref="messageListRef" class="message-list" aria-live="polite">
<article
v-for="message in messages"
:key="message.id"
class="message-row"
:class="message.role"
>
<span class="message-avatar">
<i :class="message.role === 'assistant' ? 'mdi mdi-robot-excited-outline' : 'mdi mdi-account-circle-outline'"></i>
</span>
<div class="message-bubble">
<header class="message-meta">
<strong>{{ message.role === 'assistant' ? 'AI 助手' : '我' }}</strong>
<time>{{ message.time }}</time>
</header>
<p>{{ message.text }}</p>
<div v-if="message.attachments?.length" class="message-files">
<span v-for="file in message.attachments" :key="file" class="file-chip">
<i class="mdi mdi-paperclip"></i>
{{ file }}
</span>
</div>
</div>
</article>
</div>
<form class="composer" @submit.prevent="submitComposer">
<input
ref="fileInputRef"
class="hidden-file-input"
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png,.webp,.doc,.docx,.xls,.xlsx"
@change="handleFilesChange"
/>
<div class="composer-shell">
<textarea
v-model="composerDraft"
rows="3"
:placeholder="composerPlaceholder"
@keydown.ctrl.enter.prevent="submitComposer"
/>
<div v-if="attachedFiles.length" class="composer-files">
<span v-for="file in attachedFiles" :key="file.name" class="file-chip active">
<i class="mdi mdi-paperclip"></i>
{{ file.name }}
</span>
</div>
<div class="composer-foot">
<div class="composer-tools">
<button type="button" class="tool-btn" aria-label="上传附件" @click="triggerFileUpload">
<i class="mdi mdi-paperclip"></i>
</button>
<span class="composer-tip">Ctrl + Enter 发送</span>
</div>
<button class="send-btn" type="submit" :disabled="!canSubmit" aria-label="发送">
<i class="mdi mdi-send"></i>
</button>
</div>
</div>
</form>
</section>
<Transition name="insight-panel">
<aside v-if="showInsightPanel" class="insight-panel">
<div class="insight-head">
<div>
<span class="intent-pill" :class="currentInsight.intent">{{ currentIntentLabel }}</span>
<h3>{{ currentInsight.title }}</h3>
<p>{{ currentInsight.summary }}</p>
</div>
<div class="confidence-card">
<span>意图识别</span>
<strong>{{ currentInsight.confidence }}%</strong>
</div>
</div>
<Transition name="insight-switch" mode="out-in">
<div :key="currentInsight.intent + currentInsight.title" class="insight-body">
<template v-if="currentInsight.intent === 'approval'">
<section class="insight-card primary">
<div class="card-head">
<h4>审批状态</h4>
<span class="status-pill warning">{{ currentInsight.status.currentStatus }}</span>
</div>
<div class="metric-grid">
<div class="metric-item">
<span>单号</span>
<strong>{{ currentInsight.status.requestId }}</strong>
</div>
<div class="metric-item">
<span>当前节点</span>
<strong>{{ currentInsight.status.currentNode }}</strong>
</div>
<div class="metric-item">
<span>下一处理人</span>
<strong>{{ currentInsight.status.nextOwner }}</strong>
</div>
<div class="metric-item">
<span>预计完成</span>
<strong>{{ currentInsight.status.eta }}</strong>
</div>
</div>
</section>
<section class="insight-card">
<div class="card-head">
<h4>流程节点</h4>
</div>
<ol class="timeline-list">
<li
v-for="step in currentInsight.status.timeline"
:key="step.label"
:class="step.state"
>
<span class="timeline-dot"></span>
<div>
<strong>{{ step.label }}</strong>
<p>{{ step.time }}</p>
</div>
</li>
</ol>
</section>
<section class="insight-card">
<div class="card-head">
<h4>待处理提醒</h4>
</div>
<ul class="bullet-list">
<li v-for="item in currentInsight.status.actions" :key="item">{{ item }}</li>
</ul>
</section>
</template>
<template v-else-if="currentInsight.intent === 'recognition'">
<section class="insight-card primary">
<div class="card-head">
<h4>识别结果</h4>
<span class="status-pill success">{{ currentInsight.recognition.state }}</span>
</div>
<div class="metric-grid">
<div class="metric-item">
<span>关联单号</span>
<strong>{{ currentInsight.recognition.requestId }}</strong>
</div>
<div class="metric-item">
<span>识别附件</span>
<strong>{{ currentInsight.recognition.fileCount }} </strong>
</div>
<div class="metric-item">
<span>建议金额</span>
<strong>{{ currentInsight.recognition.amount }}</strong>
</div>
<div class="metric-item">
<span>完整度</span>
<strong>{{ currentInsight.recognition.completeness }}</strong>
</div>
</div>
</section>
<section class="insight-card">
<div class="card-head">
<h4>票据明细</h4>
</div>
<div class="receipt-list">
<article v-for="item in currentInsight.recognition.receipts" :key="item.name" class="receipt-row">
<div>
<strong>{{ item.name }}</strong>
<p>{{ item.type }}</p>
</div>
<div class="receipt-side">
<strong>{{ item.amount }}</strong>
<span>{{ item.confidence }}</span>
</div>
</article>
</div>
</section>
<section class="insight-card">
<div class="card-head">
<h4>识别建议</h4>
</div>
<ul class="bullet-list">
<li v-for="item in currentInsight.recognition.suggestions" :key="item">{{ item }}</li>
</ul>
</section>
</template>
<template v-else-if="currentInsight.intent === 'note'">
<section class="insight-card primary">
<div class="card-head">
<h4>补充说明</h4>
<span class="status-pill note">{{ currentInsight.note.state }}</span>
</div>
<div class="note-block">
<span>关联单号</span>
<strong>{{ currentInsight.note.requestId }}</strong>
<p>{{ currentInsight.note.generatedNote }}</p>
</div>
</section>
<section class="insight-card">
<div class="card-head">
<h4>影响范围</h4>
</div>
<ul class="bullet-list">
<li v-for="item in currentInsight.note.impacts" :key="item">{{ item }}</li>
</ul>
</section>
<section class="insight-card">
<div class="card-head">
<h4>下一步</h4>
</div>
<div class="metric-grid single">
<div class="metric-item">
<span>当前处理人</span>
<strong>{{ currentInsight.note.owner }}</strong>
</div>
<div class="metric-item">
<span>建议动作</span>
<strong>{{ currentInsight.note.nextAction }}</strong>
</div>
</div>
</section>
</template>
<template v-else-if="currentInsight.intent === 'draft'">
<section class="insight-card primary">
<div class="card-head">
<h4>报销草稿</h4>
<span class="status-pill success">{{ currentInsight.draft.state }}</span>
</div>
<div class="metric-grid">
<div class="metric-item">
<span>单号</span>
<strong>{{ currentInsight.draft.requestId }}</strong>
</div>
<div class="metric-item">
<span>报销类型</span>
<strong>{{ currentInsight.draft.type }}</strong>
</div>
<div class="metric-item">
<span>预计金额</span>
<strong>{{ currentInsight.draft.amount }}</strong>
</div>
<div class="metric-item">
<span>当前进度</span>
<strong>{{ currentInsight.draft.progress }}</strong>
</div>
</div>
</section>
<section class="insight-card">
<div class="card-head">
<h4>费用建议</h4>
</div>
<div class="receipt-list">
<article v-for="item in currentInsight.draft.items" :key="item.name" class="receipt-row">
<div>
<strong>{{ item.name }}</strong>
<p>{{ item.desc }}</p>
</div>
<div class="receipt-side">
<strong>{{ item.amount }}</strong>
<span>{{ item.tag }}</span>
</div>
</article>
</div>
</section>
<section class="insight-card">
<div class="card-head">
<h4>待补信息</h4>
</div>
<ul class="bullet-list">
<li v-for="item in currentInsight.draft.missing" :key="item">{{ item }}</li>
</ul>
</section>
</template>
</div>
</Transition>
</aside>
</Transition>
</div>
</section>
</div>
</Transition>
</Teleport>
</template>
<script src="./scripts/TravelReimbursementCreateView.js"></script>
<style scoped src="../assets/styles/views/travel-reimbursement-create-view.css"></style>

View File

@@ -0,0 +1,317 @@
<template>
<section class="approval-page">
<Teleport to="body">
<Transition name="detail-modal">
<div v-if="aiEntryOpen" class="detail-overlay" @click.self="closeAiEntry">
<div class="detail-modal ai-entry-modal">
<header class="modal-header">
<div class="header-left">
<div class="req-badge">AI</div>
<div class="header-title-group">
<h2>智能录入费用明细</h2>
<p>描述票据行程或费用场景AI 会整理成可追加的费用条目</p>
</div>
</div>
<div class="header-right">
<button class="close-btn" type="button" aria-label="关闭" @click="closeAiEntry">
<i class="mdi mdi-close"></i>
</button>
</div>
</header>
<div class="modal-body ai-entry-body">
<div class="ai-entry-grid">
<section class="ai-chat-card">
<input
ref="aiFileInput"
class="ai-file-input"
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png,.webp,.doc,.docx,.xls,.xlsx"
@change="handleAiFilesChange"
/>
<div class="ai-chat-scroll">
<article
v-for="message in aiMessages"
:key="message.id"
class="ai-chat-bubble"
:class="message.role"
>
<span class="ai-chat-avatar">
<i :class="message.role === 'assistant' ? 'mdi mdi-robot-outline' : 'mdi mdi-account-circle-outline'"></i>
</span>
<div class="ai-chat-content">
<header>
<strong>{{ message.role === 'assistant' ? 'AI 录入助手' : '我' }}</strong>
</header>
<p>{{ message.text }}</p>
</div>
</article>
</div>
<div class="ai-composer">
<div class="ai-composer-surface">
<textarea
v-model="aiDraft"
rows="3"
placeholder="例如7月12日从上海虹桥到杭州东高铁二等座236元已上传车票和行程单。"
/>
<div class="ai-composer-actions">
<button class="ai-upload-btn" type="button" aria-label="上传单据" @click="triggerAiUpload">
<i class="mdi mdi-paperclip"></i>
</button>
<button class="ai-send-btn" type="button" aria-label="发送给 AI" :disabled="!canSendAiEntry" @click="sendAiEntry">
<i class="mdi mdi-send"></i>
</button>
</div>
</div>
<div v-if="uploadedAiFiles.length" class="ai-upload-list">
<span v-for="file in uploadedAiFiles" :key="file.name" class="ai-upload-chip">
<i class="mdi mdi-paperclip"></i>
{{ file.name }}
</span>
</div>
</div>
</section>
<aside class="ai-preview-card">
<div class="ai-preview-head">
<div>
<h3>识别结果</h3>
<p>确认后会直接追加到费用明细表</p>
</div>
<span v-if="pendingAiExpense" class="attachment-pill neutral">待确认</span>
</div>
<div v-if="pendingAiExpense" class="ai-preview-fields">
<div class="preview-field">
<span>日期</span>
<strong>{{ pendingAiExpense.time }} {{ pendingAiExpense.dayLabel }}</strong>
</div>
<div class="preview-field">
<span>费用项目</span>
<strong>{{ pendingAiExpense.name }}</strong>
</div>
<div class="preview-field">
<span>分类</span>
<strong>{{ pendingAiExpense.category }}</strong>
</div>
<div class="preview-field">
<span>金额</span>
<strong>{{ pendingAiExpense.amount }}</strong>
</div>
<div class="preview-field full">
<span>说明</span>
<strong>{{ pendingAiExpense.desc }}</strong>
<p>{{ pendingAiExpense.detail }}</p>
</div>
<div class="preview-field full">
<span>附件</span>
<strong>{{ pendingAiExpense.attachmentStatus }}</strong>
<p>{{ pendingAiExpense.attachmentHint }}</p>
</div>
<div class="ai-preview-actions">
<button class="ai-preview-secondary" type="button" @click="regenerateAiEntry">
<i class="mdi mdi-refresh"></i>
重新生成
</button>
<button class="ai-preview-primary" type="button" @click="applyAiExpense">
<i class="mdi mdi-plus-circle-outline"></i>
加入费用明细
</button>
</div>
</div>
<div v-else class="ai-preview-empty">
<i class="mdi mdi-robot-outline"></i>
<p>发送一段费用描述后这里会生成结构化结果</p>
</div>
</aside>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
<div class="approval-detail">
<div class="detail-scroll">
<article class="detail-hero panel">
<div class="applicant-card">
<div class="portrait">{{ profile.avatar }}</div>
<div>
<h2>{{ profile.name }} <span>{{ profile.department }}</span></h2>
<p>申请时间 <strong>{{ request.applyTime }}</strong></p>
</div>
</div>
<div class="hero-stat">
<span>金额</span>
<strong>{{ expenseTotal }}</strong>
</div>
<div class="hero-stat">
<span>审批状态</span>
<b class="state-pill">{{ request.node }}</b>
</div>
<div class="hero-stat">
<span>商旅状态</span>
<b :class="['risk-pill', request.travelTone]">{{ request.travel }}</b>
</div>
<div class="hero-stat">
<span>申请状态</span>
<strong class="countdown"><i class="mdi mdi-clock-outline"></i> {{ request.approval }}</strong>
</div>
<div class="hero-summary-panel">
<div v-for="item in heroSummaryItems" :key="item.label" class="hero-summary-item">
<div class="hero-summary-label">
<span class="hero-summary-icon"><i :class="item.icon"></i></span>
<span>{{ item.label }}</span>
</div>
<strong>{{ item.value }}</strong>
</div>
</div>
<div class="progress-block">
<div class="progress-head">
<h3>当前进度</h3>
</div>
<div class="progress-line">
<div
v-for="step in progressSteps"
:key="step.label"
class="progress-step"
:class="{ active: step.active, current: step.current }"
>
<span>
<i
v-if="step.current"
v-motion
class="current-progress-ring"
:initial="currentProgressRingMotion.initial"
:enter="currentProgressRingMotion.enter"
aria-hidden="true"
></i>
<i v-if="step.done" class="mdi mdi-check"></i>
<template v-else>{{ step.index }}</template>
</span>
<strong>{{ step.label }}</strong>
<small>{{ step.time }}</small>
</div>
</div>
</div>
</article>
<div class="detail-grid">
<section class="detail-left">
<article class="detail-card panel">
<div class="detail-card-head">
<div>
<h3>费用明细</h3>
<p>按发生时间逐笔展示附件与系统校验直接在表内完成核对</p>
</div>
<button class="smart-entry-btn" type="button" @click="openAiEntry">
<i class="mdi mdi-robot-outline"></i>
<span>智能录入</span>
</button>
</div>
<div class="detail-expense-table">
<table>
<thead>
<tr>
<th>时间</th>
<th>费用项目</th>
<th>说明</th>
<th>金额</th>
<th>附件材料</th>
<th>系统校验</th>
</tr>
</thead>
<tbody>
<template v-for="item in expenseItems" :key="item.id">
<tr>
<td class="expense-time">
<strong>{{ item.time }}</strong>
<span>{{ item.dayLabel }}</span>
</td>
<td class="expense-type">
<strong>{{ item.name }}</strong>
<span>{{ item.category }}</span>
</td>
<td class="expense-desc">
<strong>{{ item.desc }}</strong>
<span>{{ item.detail }}</span>
</td>
<td class="expense-amount">
<strong>{{ item.amount }}</strong>
<span v-if="item.tone !== 'ok'" :class="['over-tag', item.tone]">{{ item.status }}</span>
</td>
<td class="expense-attachment">
<div class="expense-attachment-main">
<span :class="['attachment-pill', item.attachmentTone]">{{ item.attachmentStatus }}</span>
<button
v-if="item.attachments.length"
class="inline-action"
type="button"
@click="toggleExpenseAttachments(item.id)"
>
{{ expandedExpenseId === item.id ? '收起附件' : '查看附件' }}
</button>
</div>
<span class="attachment-hint">{{ item.attachmentHint }}</span>
</td>
<td class="expense-risk">
<template v-if="showExpenseRisk(item)">
<span :class="['risk-inline-tag', item.riskTone]">{{ item.riskLabel }}</span>
<p>{{ item.riskText }}</p>
</template>
</td>
</tr>
<tr v-if="expandedExpenseId === item.id" class="expense-expand-row">
<td colspan="6">
<div class="expense-files">
<span v-for="file in item.attachments" :key="file" class="expense-file-chip">
<i class="mdi mdi-paperclip"></i>
{{ file }}
</span>
</div>
</td>
</tr>
</template>
<tr class="total-row">
<td colspan="3">合计</td>
<td>{{ expenseTotal }}</td>
<td>{{ uploadedExpenseCount }} 项已上传票据</td>
<td>1 项待补材料1 项需补充超标说明</td>
</tr>
</tbody>
</table>
</div>
</article>
<article class="detail-card panel">
<h3>申请说明</h3>
<textarea rows="3" :value="detailNote" placeholder="输入申请说明..." />
</article>
</section>
</div>
</div>
<footer class="detail-actions">
<button class="back-action" type="button" @click="emit('backToRequests')">
<i class="mdi mdi-arrow-left"></i>
<span>退回列表</span>
</button>
<div class="approval-action-group" aria-label="申请操作">
<button class="approve-action" type="button"><i class="mdi mdi-send-circle-outline"></i> 提交审批</button>
<button class="reject-action" type="button"><i class="mdi mdi-close-circle-outline"></i> 撤回申请</button>
<button class="supplement-action" type="button"><i class="mdi mdi-pencil-outline"></i> 编辑申请</button>
</div>
</footer>
</div>
</section>
</template>
<script src="./scripts/TravelRequestDetailView.js"></script>
<style scoped src="../assets/styles/views/travel-request-detail-view.css"></style>

View File

@@ -0,0 +1,284 @@
import { computed, ref } from 'vue'
export default {
name: 'ApprovalCenterView' ,
setup(props, { emit }) {
const activeTab = ref('全部待审')
const selectedRow = ref(null)
const expandedExpenseId = ref(null)
const tabs = ['全部待审', '高风险', '即将超时', '已处理']
const filters = ['法人主体', '费用类型', '风险等级', '金额区间', '所属部门']
const rows = [
{ id: 'RE240712001', applicant: '李文静', avatar: '李', department: '市场部', type: '差旅报销', amount: '¥3,680', time: '07-12 09:20', risk: '中风险', riskTone: 'medium', sla: '4.2h', slaTone: 'safe', node: '财务审批', status: '待审批', statusTone: 'pending' },
{ id: 'RE240712002', applicant: '王志强', avatar: '王', department: '销售部', type: '招待费', amount: '¥1,280', time: '07-12 08:15', risk: '低风险', riskTone: 'low', sla: '8.5h', slaTone: 'safe', node: '部门负责人', status: '待审批', statusTone: 'pending' },
{ id: 'RE240711098', applicant: '刘思雨', avatar: '刘', department: '市场部', type: '差旅报销', amount: '¥6,920', time: '07-11 18:46', risk: '高风险', riskTone: 'high', sla: '0.8h', slaTone: 'danger', node: '财务审批', status: '即将超时', statusTone: 'urgent', spotlight: true },
{ id: 'RE240711087', applicant: '陈晓琳', avatar: '陈', department: '行政部', type: '办公采购', amount: '¥860', time: '07-11 17:32', risk: '低风险', riskTone: 'low', sla: '6.1h', slaTone: 'safe', node: '预算校验', status: '待审批', statusTone: 'pending' },
{ id: 'RE240711076', applicant: '赵明', avatar: '赵', department: '研发中心', type: '其他费用', amount: '¥4,250', time: '07-11 15:10', risk: '中风险', riskTone: 'medium', sla: '2.4h', slaTone: 'warning', node: '部门负责人', status: '待审批', statusTone: 'pending' },
{ id: 'RE240711065', applicant: '孙楠', avatar: '孙', department: '财务部', type: '招待费', amount: '¥560', time: '07-11 13:42', risk: '低风险', riskTone: 'low', sla: '5.7h', slaTone: 'safe', node: '财务审批', status: '待审批', statusTone: 'pending' },
{ id: 'RE240711054', applicant: '周晓彤', avatar: '周', department: '市场部', type: '办公采购', amount: '¥2,150', time: '07-11 11:28', risk: '中风险', riskTone: 'medium', sla: '1.9h', slaTone: 'warning', node: '预算校验', status: '即将超时', statusTone: 'urgent' },
{ id: 'RE240711043', applicant: '吴磊', avatar: '吴', department: '销售部', type: '其他费用', amount: '¥980', time: '07-11 09:05', risk: '低风险', riskTone: 'low', sla: '7.3h', slaTone: 'safe', node: '部门负责人', status: '待审批', statusTone: 'pending' }
]
const visibleRows = computed(() => {
if (activeTab.value === '全部待审') return rows
if (activeTab.value === '高风险') return rows.filter((row) => row.risk === '高风险')
if (activeTab.value === '即将超时') return rows.filter((row) => row.status === '即将超时')
return rows.slice(0, 3).map((row) => ({ ...row, status: '已处理', statusTone: 'done' }))
})
const approvalSteps = [
{ index: 1, label: '提交申请', time: '07-11 08:46', done: true, active: true },
{ index: 2, label: '票据识别', time: '07-11 08:48', done: true, active: true },
{ index: 3, label: '费用归类', time: '07-11 08:49', done: true, active: true },
{ index: 4, label: '部门负责人审批', time: '07-11 11:28', done: true, active: true },
{ index: 5, label: '财务审批', time: '进行中', active: true, current: true },
{ index: 6, label: '归档入账', time: '待处理' }
]
const summaryItems = [
{ label: '行程', value: '北京 → 上海', icon: 'mdi mdi-map-marker-path' },
{ label: '出差区间', value: '07-10 至 07-11', icon: 'mdi mdi-clock-outline' },
{ label: '票据关联', value: '8 条明细 / 7 份材料', icon: 'mdi mdi-file-document-multiple-outline' },
{ label: '成本归属', value: '市场部 · CC-MKT-01', icon: 'mdi mdi-account-group-outline' },
{ label: '支付方式', value: '企业垫付', icon: 'mdi mdi-credit-card-outline' }
]
const heroSummaryItems = computed(() => [
{ label: '单号', value: selectedRow.value?.id ?? '-', icon: 'mdi mdi-pound-box-outline' },
{ label: '报销类型', value: selectedRow.value?.type ?? '-', icon: 'mdi mdi-briefcase-outline' },
...summaryItems
])
const currentProgressRingMotion = {
initial: {
scale: 1,
opacity: 0.34,
},
enter: {
scale: [1, 1.42, 1.78],
opacity: [0.34, 0.16, 0],
transition: {
duration: 3.2,
repeat: Infinity,
repeatType: 'loop',
repeatDelay: 0.85,
ease: 'easeOut',
times: [0, 0.5, 1],
},
},
}
const expenseItems = [
{
id: 'flight-1',
time: '07-10 07:25',
dayLabel: '周三',
name: '机票',
category: '交通',
desc: '北京首都 → 上海虹桥',
detail: 'MU5103 往返经济舱,含行程单',
amount: '¥2,180',
status: '未超标',
tone: 'ok',
attachmentStatus: '已上传',
attachmentTone: 'ok',
attachmentHint: '电子行程单与机票发票齐全',
attachments: ['电子行程单.pdf', '机票发票.pdf'],
riskLabel: '低风险',
riskTone: 'low',
riskText: '票面信息与行程匹配。'
},
{
id: 'taxi-1',
time: '07-10 10:35',
dayLabel: '周三',
name: '出租车',
category: '市内交通',
desc: '虹桥机场 → 静安酒店',
detail: '落地后前往酒店,含过路费',
amount: '¥86',
status: '未超标',
tone: 'ok',
attachmentStatus: '已上传',
attachmentTone: 'ok',
attachmentHint: '已上传 1 张发票',
attachments: ['出租车发票-0710-01.jpg'],
riskLabel: '中风险',
riskTone: 'medium',
riskText: '高峰加价较高,建议顺带核对上车点。'
},
{
id: 'metro-1',
time: '07-10 18:20',
dayLabel: '周三',
name: '地铁',
category: '市内交通',
desc: '静安酒店 → 客户园区',
detail: '2 号线换乘,通勤交通',
amount: '¥12',
status: '未超标',
tone: 'ok',
attachmentStatus: '已上传',
attachmentTone: 'ok',
attachmentHint: '已上传电子票据',
attachments: ['地铁电子票据-0710.png'],
riskLabel: '低风险',
riskTone: 'low',
riskText: '路线与拜访行程一致。'
},
{
id: 'taxi-2',
time: '07-11 08:40',
dayLabel: '周四',
name: '出租车',
category: '市内交通',
desc: '静安酒店 → 客户园区',
detail: '次日早会前往客户现场',
amount: '¥42',
status: '未超标',
tone: 'ok',
attachmentStatus: '未上传',
attachmentTone: 'missing',
attachmentHint: '缺少对应发票',
attachments: [],
riskLabel: '高风险',
riskTone: 'high',
riskText: '票据缺失,当前无法完成交通费核验。'
},
{
id: 'taxi-3',
time: '07-11 20:55',
dayLabel: '周四',
name: '出租车',
category: '返程交通',
desc: '客户园区 → 虹桥机场',
detail: '夜间返程,触发超标校验',
amount: '¥136',
status: '超标 ¥28',
tone: 'bad',
attachmentStatus: '已上传',
attachmentTone: 'ok',
attachmentHint: '已上传 1 张发票',
attachments: ['出租车发票-0711-02.jpg'],
riskLabel: '中风险',
riskTone: 'medium',
riskText: '金额超差旅标准 ¥28需补充业务说明。'
},
{
id: 'hotel-1',
time: '07-10 至 07-11',
dayLabel: '2 晚',
name: '酒店',
category: '住宿',
desc: '上海静安商务酒店',
detail: '标准大床房 2 晚,含早餐',
amount: '¥2,480',
status: '未超标',
tone: 'ok',
attachmentStatus: '部分上传',
attachmentTone: 'partial',
attachmentHint: '发票已上传,入住清单缺失',
attachments: ['酒店发票.jpg'],
riskLabel: '高风险',
riskTone: 'high',
riskText: '缺少入住清单,住宿真实性待补证。'
},
{
id: 'meal-1',
time: '07-10 至 07-11',
dayLabel: '2 天',
name: '餐补',
category: '补贴',
desc: '差旅餐补',
detail: '按差旅制度自动计算',
amount: '¥372',
status: '未超标',
tone: 'ok',
attachmentStatus: '免上传',
attachmentTone: 'neutral',
attachmentHint: '制度型补贴无需票据',
attachments: [],
riskLabel: '低风险',
riskTone: 'low',
riskText: '系统自动核算,无额外异常。'
},
{
id: 'other-1',
time: '07-11 09:10',
dayLabel: '周四',
name: '其他',
category: '杂费',
desc: '行李寄存 / 打印费',
detail: '客户提案资料打印与寄存服务',
amount: '¥1,612',
status: '未超标',
tone: 'ok',
attachmentStatus: '已上传',
attachmentTone: 'ok',
attachmentHint: '已上传 2 份附件',
attachments: ['打印服务发票.jpg', '行李寄存凭证.jpg'],
riskLabel: '低风险',
riskTone: 'low',
riskText: '用途清晰,金额在授权范围内。'
}
]
const expenseTotal = '¥6,920'
const uploadedExpenseCount = computed(() => expenseItems.filter((item) => item.attachments.length).length)
const showExpenseRisk = (item) => item.riskTone === 'medium' || item.riskTone === 'high'
const toggleExpenseAttachments = (id) => {
expandedExpenseId.value = expandedExpenseId.value === id ? null : id
}
const attachments = [
{ name: '机票.pdf', size: '256 KB', icon: 'mdi mdi-file-pdf-box', iconClass: 'pdf' },
{ name: '酒店发票.jpg', size: '412 KB', icon: 'mdi mdi-image', iconClass: 'img' },
{ name: '行程单.pdf', size: '198 KB', icon: 'mdi mdi-file-pdf-box', iconClass: 'pdf' },
{ name: '出租车发票1.jpg', size: '128 KB', icon: 'mdi mdi-image', iconClass: 'img' },
{ name: '出租车发票2.jpg', size: '132 KB', icon: 'mdi mdi-image', iconClass: 'img' },
{ name: '酒店入住清单', size: '缺失', icon: 'mdi mdi-minus-circle', iconClass: 'miss', missing: true }
]
const riskItems = [
{ text: '酒店入住清单缺失', level: '高', tone: 'high', icon: 'mdi mdi-alert-circle' },
{ text: '1 笔出租车费用超差旅标准 ¥28', level: '中', tone: 'medium', icon: 'mdi mdi-alert' },
{ text: '发票抬头识别为个人,建议核对', level: '中', tone: 'medium', icon: 'mdi mdi-lightbulb-on' }
]
const flowItems = [
{ label: '提交申请', desc: '刘思雨 提交申请', time: '07-11 08:46', icon: 'mdi mdi-check' },
{ label: '票据识别', desc: 'AI 自动识别完成', time: '07-11 08:48', icon: 'mdi mdi-check' },
{ label: '费用归类', desc: '费用归类完成', time: '07-11 08:49', icon: 'mdi mdi-check' },
{ label: '部门负责人审批', desc: '李文静 已通过', time: '07-11 11:28', icon: 'mdi mdi-check' },
{ label: '财务审批', desc: '张晓明 审批中', time: '进行中', icon: 'mdi mdi-circle-slice-8', current: true },
{ label: '归档入账', desc: '待处理', time: '-', icon: 'mdi mdi-circle-outline', pending: true }
]
return {
activeTab,
selectedRow,
expandedExpenseId,
tabs,
filters,
rows,
visibleRows,
approvalSteps,
summaryItems,
heroSummaryItems,
currentProgressRingMotion,
expenseItems,
expenseTotal,
uploadedExpenseCount,
showExpenseRisk,
toggleExpenseAttachments,
attachments,
riskItems,
flowItems
}
}
}

View File

@@ -0,0 +1,249 @@
import { computed, ref } from 'vue'
export default {
name: 'AuditView' ,
setup(props, { emit }) {
const tabs = ['全部技能', '已上线', '草稿中', '待评审', '异常告警']
const filters = ['按分类筛选', '按模型筛选', '按负责人筛选']
const activeTab = ref(tabs[0])
const selectedSkill = ref(null)
const skills = [
{
id: 'SKL-001',
short: 'TR',
name: '差旅申请助手',
summary: '生成出差申请、补齐行程信息并关联预订动作。',
category: '流程型 Skill',
owner: '张晓明',
scope: '员工自助',
model: 'GPT-5.4',
version: 'v2.3',
status: '已上线',
statusTone: 'success',
hitRate: '92.6%',
updatedAt: '2026-05-05 09:20',
badgeTone: 'emerald',
triggerMode: '显式入口 + 语义触发',
spotlight: true,
promptSections: [
{
title: '系统定位',
intent: '约束 Skill 目标与边界',
content: '负责帮助员工完成差旅申请草稿生成、行程补齐和预订前核对。禁止直接跳过必要审批节点。'
},
{
title: '输入预期',
intent: '定义需要抽取的字段',
content: '抽取出发地、目的地、出差日期、事由、同行人、预算中心与是否需要预订机票/酒店。缺失时逐步追问。'
},
{
title: '输出格式',
intent: '约束最终返回结构',
content: '输出申请摘要、缺失项清单、下一步操作建议。若信息齐全,生成结构化草稿并提示用户确认。'
}
],
outputRules: [
'优先返回结构化摘要,再给行动建议。',
'缺失信息必须列成 checklist不可混写在段落里。',
'遇到预算冲突时必须提示人工审批节点。'
],
tests: [
{ name: '基础申请生成', input: '北京到上海,后天出差两天', result: '通过', tone: 'success' },
{ name: '缺失预算中心追问', input: '我要去深圳见客户', result: '通过', tone: 'success' },
{ name: '异常日期冲突', input: '返回日期早于出发日期', result: '待修复', tone: 'warning' }
],
triggers: ['差旅申请', '出差申请', '预订机票', '补齐行程'],
tools: [
{ name: '预订系统 API', scope: '机票 / 酒店查询', mode: '只读', tone: 'safe' },
{ name: '报销草稿生成器', scope: '创建申请草稿', mode: '写入', tone: 'active' },
{ name: '预算中心校验', scope: '预算占用校验', mode: '校验', tone: 'safe' }
],
history: [
{ version: 'v2.3', note: '补充预算冲突追问逻辑', time: '05-05 09:20' },
{ version: 'v2.2', note: '优化酒店预订字段抽取', time: '05-01 17:45' },
{ version: 'v2.1', note: '新增同行人识别', time: '04-28 11:10' }
]
},
{
id: 'SKL-002',
short: 'AU',
name: '审批意见生成器',
summary: '基于单据、风险点和制度命中结果生成审批意见。',
category: '审核型 Skill',
owner: '李文静',
scope: '财务审批',
model: 'GPT-5.4',
version: 'v1.8',
status: '待评审',
statusTone: 'warning',
hitRate: '88.4%',
updatedAt: '2026-05-04 19:10',
badgeTone: 'violet',
triggerMode: '审批中心按钮触发',
promptSections: [
{
title: '系统定位',
intent: '聚焦审批建议生成',
content: '读取单据、制度命中和风险标签后,生成可直接复用的审批意见,不代替最终审批决定。'
},
{
title: '输入预期',
intent: '依赖字段',
content: '依赖报销类型、金额、风险项、附件齐备情况、历史审批结论。'
},
{
title: '输出格式',
intent: '生成标准话术',
content: '输出通过 / 驳回 / 补件三种意见模板,并附上判断依据。'
}
],
outputRules: [
'意见必须引用风险点或制度条款作为依据。',
'驳回类结论需明确补充动作。',
'避免输出过长段落,优先三段式表达。'
],
tests: [
{ name: '高风险驳回意见', input: '重复发票 + 缺附件', result: '通过', tone: 'success' },
{ name: '低风险通过意见', input: '规则全通过', result: '通过', tone: 'success' },
{ name: '混合场景表达', input: '超标但说明充分', result: '评审中', tone: 'warning' }
],
triggers: ['生成审批意见', '通过意见', '驳回意见', '补件说明'],
tools: [
{ name: '审批单据上下文', scope: '当前单据读取', mode: '只读', tone: 'safe' },
{ name: '制度命中服务', scope: '条款引用', mode: '校验', tone: 'safe' },
{ name: '审批结果写回', scope: '保存意见', mode: '写入', tone: 'active' }
],
history: [
{ version: 'v1.8', note: '调整高风险话术严谨度', time: '05-04 19:10' },
{ version: 'v1.7', note: '补充制度条款引用模板', time: '05-02 10:30' }
]
},
{
id: 'SKL-003',
short: 'KB',
name: '知识检索编排器',
summary: '根据问题意图匹配制度、FAQ 与最近更新文档。',
category: '知识型 Skill',
owner: '王磊',
scope: '知识管理',
model: 'GPT-5.2',
version: 'v3.1',
status: '已上线',
statusTone: 'success',
hitRate: '94.1%',
updatedAt: '2026-05-03 15:40',
badgeTone: 'blue',
triggerMode: '问答语义召回',
promptSections: [
{
title: '系统定位',
intent: '文档命中与答案编排',
content: '识别问题主题后优先召回制度文档、FAQ 与近期更新资料,再组织成引用式回答。'
},
{
title: '输入预期',
intent: '需要识别的意图',
content: '识别报销、发票、差旅、借款、预算等主题,以及用户是否在追问例外情况。'
},
{
title: '输出格式',
intent: '答案结构',
content: '先结论,再条款引用,再相关文档链接。若知识不足,明确提示未命中。'
}
],
outputRules: [
'必须区分“制度原文依据”和“解释性建议”。',
'引用命中不足时,不可编造制度条款。',
'输出需附上最近更新时间。'
],
tests: [
{ name: '标准知识问答', input: '住宿超标怎么办', result: '通过', tone: 'success' },
{ name: '跨文档综合问答', input: '差旅借款后如何冲销', result: '通过', tone: 'success' }
],
triggers: ['制度查询', '差旅标准', '发票规范', '借款冲销'],
tools: [
{ name: '知识库索引', scope: '文档召回', mode: '只读', tone: 'safe' },
{ name: 'FAQ 排序器', scope: '答案重排', mode: '校验', tone: 'safe' }
],
history: [
{ version: 'v3.1', note: '加入最近更新知识优先级', time: '05-03 15:40' },
{ version: 'v3.0', note: '知识命中格式重构', time: '04-29 18:20' }
]
},
{
id: 'SKL-004',
short: 'RK',
name: '风险解释助手',
summary: '向员工解释拦截原因,并给出补件或修正建议。',
category: '解释型 Skill',
owner: '陈杰',
scope: '员工自助',
model: 'GPT-5.4-Mini',
version: 'v1.4',
status: '草稿中',
statusTone: 'draft',
hitRate: '79.8%',
updatedAt: '2026-05-02 11:05',
badgeTone: 'amber',
triggerMode: '风险拦截后提示入口',
promptSections: [
{
title: '系统定位',
intent: '解释风控结论',
content: '将复杂风控规则解释成员工可执行的修正动作,不暴露内部评分细节。'
},
{
title: '输入预期',
intent: '关注异常标签',
content: '读取异常标签、相关票据、制度限制和当前流程节点。'
},
{
title: '输出格式',
intent: '行动导向',
content: '按“原因 - 影响 - 处理建议”输出,不使用过于生硬的审计口吻。'
}
],
outputRules: [
'建议必须可以执行,避免空泛表述。',
'不展示内部风控分值。',
'涉及附件缺失时输出具体材料名称。'
],
tests: [
{ name: '住宿超标解释', input: '酒店单晚超标 18%', result: '通过', tone: 'success' },
{ name: '重复发票风险解释', input: '发票号重复', result: '待修复', tone: 'warning' }
],
triggers: ['为什么被拦截', '风险原因', '补件说明'],
tools: [
{ name: '风险标签读取', scope: '异常原因', mode: '只读', tone: 'safe' },
{ name: '制度比对服务', scope: '规则解释', mode: '校验', tone: 'safe' }
],
history: [
{ version: 'v1.4', note: '新增补件导向模板', time: '05-02 11:05' },
{ version: 'v1.3', note: '优化语气控制', time: '04-30 16:48' }
]
}
]
const visibleSkills = computed(() => {
if (activeTab.value === '全部技能') return skills
const map = {
已上线: '已上线',
草稿中: '草稿中',
待评审: '待评审',
异常告警: '异常告警'
}
return skills.filter((item) => item.status === map[activeTab.value])
})
return {
tabs,
filters,
activeTab,
selectedSkill,
skills,
visibleSkills
}
}
}

View File

@@ -0,0 +1,101 @@
import { computed, nextTick, ref, watch } from 'vue'
export default {
name: 'ChatView',
props: {
documents: { type: Array, required: true },
docSearch: { type: String, default: '' },
messages: { type: Array, required: true },
uploadedFiles: { type: Array, required: true },
activeCase: { type: Object, default: null },
quickPrompts: { type: Array, required: true },
draft: { type: String, default: '' },
messageList: { type: Object, default: null }
},
emits: ['send', 'upload', 'draft', 'approveCase', 'rejectCase', 'selectCase'] ,
setup(props, { emit }) {
const localMessageList = ref(null)
const promptPage = ref(0)
const sessions = [
{ title: '北京出差,酒店超标报销怎么处理?', time: '10:32', active: true },
{ title: '发票抬头不一致怎么办', time: '09:48' },
{ title: '借款冲销流程', time: '昨天' },
{ title: '预算占用失败处理', time: '昨天' },
{ title: '招待费报销范围', time: '05-11' },
{ title: '差旅住宿标准如何匹配城市级别?', time: '05-10' },
{ title: '电子发票验真失败如何处理?', time: '05-09' },
{ title: '跨部门项目费用怎么归集?', time: '05-08' },
{ title: '会议费和招待费如何区分?', time: '05-07' },
{ title: '超预算申请需要哪些审批节点?', time: '05-06' },
{ title: '海外差旅汇率按哪天计算?', time: '05-05' },
{ title: '员工退票手续费是否可报销?', time: '05-04' }
]
const prompts = [
{ icon: 'mdi mdi-bed-outline', short: '差旅标准', text: '差旅报销特殊标准是什么?' },
{ icon: 'mdi mdi-receipt-text-outline', short: '发票规范', text: '发票丢失如何处理?' },
{ icon: 'mdi mdi-cash-refund', short: '借款冲销', text: '借款多久内需要冲销?' },
{ icon: 'mdi mdi-file-chart-outline', short: '预算冲突', text: '预算不足如何申请?' },
{ icon: 'mdi mdi-shield-check-outline', short: '审批要求', text: '酒店超标后如何申请例外报销?' },
{ icon: 'mdi mdi-office-building-marker', short: '住宿标准', text: '差旅住宿标准按什么规则执行?' },
{ icon: 'mdi mdi-file-question-outline', short: '材料补齐', text: '报销附件缺失怎么补交?' },
{ icon: 'mdi mdi-alert-circle-outline', short: '风险等级', text: '哪些情况会触发中风险?' }
]
const visiblePrompts = computed(() => prompts.slice((promptPage.value % 2) * 4, (promptPage.value % 2) * 4 + 4))
const hotQuestions = [
'差旅报销标准是什么?',
'酒店超标后如何申请例外报销?',
'发票丢失如何处理?',
'借款多久内需要冲销?',
'预算超支如何申请?',
'招待费报销需要哪些凭证?',
'发票抬头不一致是否允许报销?',
'报销附件缺失怎么补交?',
'差旅住宿标准按什么规则执行?',
'电子发票验真失败如何处理?'
]
const similarQuestions = [
{ text: '酒店超标后如何申请例外报销?', score: '96%' },
{ text: '发票抬头不一致是否允许报销?', score: '92%' },
{ text: '差旅住宿标准按什么规则执行?', score: '89%' },
{ text: '预算不足时能否先提交报销?', score: '86%' },
{ text: '电子发票验真失败是否可以先报销?', score: '84%' },
{ text: '跨部门项目费用如何归集?', score: '81%' },
{ text: '招待费报销需要哪些凭证?', score: '78%' },
{ text: '借款冲销逾期会影响报销吗?', score: '76%' }
]
function rotatePrompts() {
promptPage.value += 1
}
function applyPrompt(text) {
emit('draft', text)
}
watch(
() => props.messages.length,
() => {
nextTick(() => localMessageList.value?.scrollTo({ top: localMessageList.value.scrollHeight, behavior: 'smooth' }))
}
)
return {
emit,
localMessageList,
promptPage,
sessions,
prompts,
visiblePrompts,
hotQuestions,
similarQuestions,
rotatePrompts,
applyPrompt
}
}
}

View File

@@ -0,0 +1,202 @@
import { computed, ref } from 'vue'
export default {
name: 'EmployeeManagementView' ,
setup(props, { emit }) {
const tabs = ['全部员工', '在职', '试用中', '停用']
const filters = ['按部门筛选', '按职级筛选', '按系统角色筛选']
const activeTab = ref(tabs[0])
const selectedEmployee = ref(null)
const roleOptions = [
{ id: 'user', label: '使用者', desc: '可以发起报销、查看个人单据和使用 AI 助手。' },
{ id: 'finance', label: '财务人员', desc: '可以处理复核、查看财务知识与风险校验结果。' },
{ id: 'manager', label: '管理员', desc: '可以维护员工档案、组织结构和角色权限。' },
{ id: 'executive', label: '高级管理人员', desc: '可以查看跨部门数据看板与关键审批结果。' },
{ id: 'approver', label: '审批负责人', desc: '可以处理审批中心中的待审单据。' },
{ id: 'auditor', label: '审计观察员', desc: '可以查看变更记录和权限调整历史。' }
]
const employees = [
{
id: 'EMP-001',
avatar: '张',
name: '张晓晴',
employeeNo: 'E10234',
department: '财务共享中心',
position: '费用运营经理',
grade: 'M3',
manager: '李文静',
financeOwner: '华东财务组',
roles: ['管理员', '财务人员', '审批负责人'],
status: '在职',
statusTone: 'success',
gender: '女',
age: '32',
birthDate: '1994-08-12',
email: 'xiaoqing.zhang@xfinance.com',
phone: '138 1023 4567',
joinDate: '2021-03-15',
location: '上海',
costCenter: 'CC-2108',
updatedAt: '2026-05-06 10:24',
lastSync: '2026-05-06 10:24',
syncState: '待生效',
spotlight: true,
permissions: [
'可查看审批中心全部待审单据',
'可配置员工角色与部门归属',
'可查看知识管理与技能中心配置'
],
history: [
{ action: '新增“审批负责人”角色', owner: '系统管理员 · 王敏', time: '今天 10:24' },
{ action: '调整财务归口为华东财务组', owner: '组织管理员 · 陈硕', time: '昨天 18:10' }
]
},
{
id: 'EMP-002',
avatar: '李',
name: '李文静',
employeeNo: 'E10018',
department: '总经办',
position: '高级财务总监',
grade: 'D2',
manager: 'CEO',
financeOwner: '集团财务',
roles: ['高级管理人员', '审批负责人'],
status: '在职',
statusTone: 'success',
gender: '女',
age: '39',
birthDate: '1987-03-26',
email: 'wenjing.li@xfinance.com',
phone: '139 0018 7688',
joinDate: '2018-06-21',
location: '上海',
costCenter: 'CC-1001',
updatedAt: '2026-05-05 16:20',
lastSync: '2026-05-05 16:20',
syncState: '已同步',
permissions: [
'可查看集团层面的审批看板',
'可处理高金额报销的最终审批',
'可查看部门预算执行情况'
],
history: [
{ action: '更新高级管理人员可见范围', owner: '系统管理员 · 王敏', time: '05-05 16:20' }
]
},
{
id: 'EMP-003',
avatar: '王',
name: '王敏',
employeeNo: 'E10867',
department: '人力与组织',
position: '组织发展主管',
grade: 'P6',
manager: '陈嘉',
financeOwner: '总部财务',
roles: ['管理员', '审计观察员'],
status: '在职',
statusTone: 'success',
gender: '女',
age: '30',
birthDate: '1996-11-05',
email: 'min.wang@xfinance.com',
phone: '136 8867 1200',
joinDate: '2022-08-08',
location: '杭州',
costCenter: 'CC-3206',
updatedAt: '2026-05-05 09:18',
lastSync: '2026-05-05 09:18',
syncState: '已同步',
permissions: [
'可维护组织结构与岗位映射',
'可查看员工角色分配历史'
],
history: [
{ action: '新增“审计观察员”角色', owner: '系统管理员 · 张晓晴', time: '05-05 09:18' }
]
},
{
id: 'EMP-004',
avatar: '陈',
name: '陈嘉',
employeeNo: 'E11602',
department: '销售运营',
position: '区域销售经理',
grade: 'M2',
manager: '李文静',
financeOwner: '华南财务组',
roles: ['使用者', '审批负责人'],
status: '试用中',
statusTone: 'warning',
gender: '男',
age: '29',
birthDate: '1997-02-18',
email: 'jia.chen@xfinance.com',
phone: '137 1602 9901',
joinDate: '2026-03-01',
location: '深圳',
costCenter: 'CC-4102',
updatedAt: '2026-05-04 14:12',
lastSync: '2026-05-04 14:12',
syncState: '已同步',
permissions: [
'可发起个人报销与出差申请',
'可处理本部门基础审批'
],
history: [
{ action: '完成试用期角色初始化', owner: '组织管理员 · 王敏', time: '05-04 14:12' }
]
},
{
id: 'EMP-005',
avatar: '赵',
name: '赵雨辰',
employeeNo: 'E11991',
department: '研发中心',
position: '产品经理',
grade: 'P5',
manager: '陈嘉',
financeOwner: '总部财务',
roles: ['使用者'],
status: '停用',
statusTone: 'neutral',
gender: '男',
age: '27',
birthDate: '1999-06-09',
email: 'yuchen.zhao@xfinance.com',
phone: '135 1991 3300',
joinDate: '2023-11-18',
location: '北京',
costCenter: 'CC-5209',
updatedAt: '2026-05-01 11:06',
lastSync: '2026-05-01 11:06',
syncState: '已同步',
permissions: [
'当前账号停用,仅保留历史单据查看记录'
],
history: [
{ action: '账号状态变更为停用', owner: '系统管理员 · 王敏', time: '05-01 11:06' }
]
}
]
const visibleEmployees = computed(() => {
if (activeTab.value === '全部员工') return employees
return employees.filter((item) => item.status === activeTab.value)
})
return {
tabs,
filters,
activeTab,
selectedEmployee,
roleOptions,
employees,
visibleEmployees
}
}
}

View File

@@ -0,0 +1,42 @@
import { ref } from 'vue'
export default {
name: 'LoginView',
emits: ['login', 'recover-password', 'sso-login'] ,
setup(props, { emit }) {
const username = ref('')
const password = ref('')
const tenant = ref('')
const remember = ref(true)
const showPassword = ref(false)
const features = [
{ title: '智能审单', desc: 'AI 自动识别票据与规则,提升准确率与效率', icon: 'mdi mdi-file-document-outline', tone: 'green' },
{ title: '异常预警', desc: '多维风险识别与预警,主动防控风险', icon: 'mdi mdi-bell-outline', tone: 'red' },
{ title: 'SLA 监控', desc: '实时监控服务水平协议,保障审批及时性', icon: 'mdi mdi-sync', tone: 'blue' }
]
const LogoMark = {
template: `
<span class="logo-mark" aria-hidden="true">
<svg viewBox="0 0 36 36">
<path d="M19.8 4.5c5.7 1.1 9.9 5.7 10.5 11.6-2.8-.9-5.5-.7-7.9.6-2.8 1.5-4.5 4.3-5.2 8.2-4.4-2.8-6.5-6.5-6.3-11.1.2-4.2 3.5-7.8 8.9-9.3Z" />
<path d="M9 7.6c-3 3.5-4 7.3-2.9 11.2 1.2 4.2 4.6 7 10.1 8.5-2 1.8-4.6 2.6-7.6 2.3C5.1 26.7 3.5 23.1 3.7 19 4 14.4 5.7 10.6 9 7.6Z" />
</svg>
</span>
`
}
return {
emit,
username,
password,
tenant,
remember,
showPassword,
features,
LogoMark
}
}
}

View File

@@ -0,0 +1,130 @@
import { computed, ref } from 'vue'
import {
metricBlueprints,
trendRanges,
trendSeries,
spendByCategory,
exceptionMix,
departmentRangeOptions,
bottlenecks,
budgetSummary
} from '../../data/metrics.js'
import TrendChart from '../../components/charts/TrendChart.vue'
import DonutChart from '../../components/charts/DonutChart.vue'
import BarChart from '../../components/charts/BarChart.vue'
import GaugeChart from '../../components/charts/GaugeChart.vue'
import PersonalWorkbench from '../../components/business/PersonalWorkbench.vue'
export default {
name: 'OverviewView',
components: { TrendChart, DonutChart, BarChart, GaugeChart, PersonalWorkbench },
props: {
filteredRequests: { type: Array, required: true }
},
emits: ['ask'] ,
setup(props, { emit }) {
const activeTrendRange = ref(trendRanges[0])
const activeDepartmentRange = ref(departmentRangeOptions[0])
const demoTotals = {
pendingCount: 128,
pendingAmount: 361600,
avgSla: 6.8,
autoPassRate: 78,
riskCount: 14,
slaRate: 96
}
const demoDepartments = [
{ name: '销售部', amount: 182000, color: '#10b981' },
{ name: '研发中心', amount: 146000, color: '#3b82f6' },
{ name: '市场部', amount: 96000, color: '#f59e0b' },
{ name: '运营部', amount: 68600, color: '#8b5cf6' },
{ name: '行政部', amount: 48300, color: '#3b82f6' }
]
const formatCompact = (value) => {
if (value >= 1_000_000) return `¥${(value / 1_000_000).toFixed(1)}M`
if (value >= 1_000) return `¥${(value / 1_000).toFixed(1)}K`
return `¥${value}`
}
const formatCurrency = (value) => formatCompact(value)
const formatMetricValue = (metric, value) => {
if (metric.key === 'pendingAmount') return formatCurrency(Math.round(value))
if (metric.key === 'avgSla') return `${value.toFixed(1)} ${metric.unit}`
if (metric.unit === '%') return `${Math.round(value)} ${metric.unit}`
if (metric.unit) return `${Math.round(value)} ${metric.unit}`
return `${Math.round(value)}`
}
const kpiMetrics = computed(() => metricBlueprints.map((metric, index) => {
const rawValue = demoTotals[metric.key]
const displayValue = formatMetricValue(metric, rawValue)
return {
...metric,
displayValue,
changeText: metric.change,
delay: index * 55
}
}))
const activeTrend = computed(() => trendSeries[activeTrendRange.value])
const spendTotal = computed(() => spendByCategory.reduce((sum, item) => sum + item.value, 0))
const riskTotal = computed(() => exceptionMix.reduce((sum, item) => sum + item.value, 0))
const spendLegend = computed(() => spendByCategory.map((item) => ({
...item,
display: `${Math.round((item.value / spendTotal.value) * 100)}%`
})))
const riskLegend = computed(() => exceptionMix.map((item) => ({
...item,
display: `${item.value}`
})))
const rankedDepartments = computed(() => {
const rows = demoDepartments
const max = Math.max(...rows.map((item) => item.amount), 1)
return rows.slice(0, 5).map((item, index) => ({
...item,
rank: index + 1,
shortName: item.name,
amountLabel: formatCurrency(item.amount),
width: `${Math.max((item.amount / max) * 100, 18)}%`,
color: item.color
}))
})
return {
emit,
activeTrendRange,
activeDepartmentRange,
demoTotals,
demoDepartments,
formatCompact,
formatCurrency,
formatMetricValue,
kpiMetrics,
activeTrend,
spendTotal,
riskTotal,
spendLegend,
riskLegend,
rankedDepartments,
metricBlueprints,
trendRanges,
trendSeries,
spendByCategory,
exceptionMix,
departmentRangeOptions,
bottlenecks,
budgetSummary
}
}
}

View File

@@ -0,0 +1,244 @@
import { computed, ref } from 'vue'
export default {
name: 'PoliciesView' ,
setup(props, { emit }) {
const folderSearch = ref('')
const activeFolder = ref('差旅规范')
const selectedDocument = ref(null)
const folders = [
{ name: '财务知识库', count: 36, icon: 'mdi mdi-folder' },
{ name: '制度政策', count: 8, icon: 'mdi mdi-folder' },
{ name: '报销制度', count: 12, icon: 'mdi mdi-folder-open' },
{ name: '差旅规范', count: 18, icon: 'mdi mdi-folder' },
{ name: '发票管理', count: 14, icon: 'mdi mdi-folder' },
{ name: '税务合规', count: 16, icon: 'mdi mdi-folder' },
{ name: '预算管理', count: 9, icon: 'mdi mdi-folder' },
{ name: '财务共享', count: 7, icon: 'mdi mdi-folder' },
{ name: '培训资料', count: 6, icon: 'mdi mdi-folder' },
{ name: '常见问答', count: 11, icon: 'mdi mdi-folder' }
]
const documents = [
{
name: '差旅报销管理办法2024版',
folder: '差旅规范',
tag: '差旅 / 制度',
time: '2024-05-12 14:35',
version: 'v3.2',
state: '已生效',
stateTone: 'success',
owner: '张明',
icon: 'mdi mdi-file-document-outline-pdf pdf',
fileType: 'pdf',
fileTypeLabel: 'PDF 预览',
summary: '面向员工与财务共享团队的差旅费用标准、审批边界和附件要求。',
previewPages: [
{
title: '差旅报销管理办法2024版',
subtitle: '住宿、交通、审批与附件要求',
stats: [
{ label: '适用范围', value: '全员' },
{ label: '生效日期', value: '2024-05-12' },
{ label: '更新重点', value: '住宿标准' }
],
blocks: [
{
heading: '一、适用范围',
lines: ['适用于国内差旅申请、预订、报销与借款冲销。', '共享中心审核以出差申请、票据与预算中心为准。']
},
{
heading: '二、住宿标准',
lines: ['一线城市单晚标准 650 元,超标需附业务说明。', '连续住宿超过 3 晚需补充行程与客户拜访记录。']
}
]
},
{
title: '审批与附件要求',
subtitle: '流程节点与必要凭证',
stats: [
{ label: '附件校验', value: '7 项' },
{ label: '审批节点', value: '4 级' },
{ label: '自动拦截', value: '超标 / 重复' }
],
blocks: [
{
heading: '三、审批规则',
lines: ['直属主管审批通过后进入财务复核。', '超预算或超标申请需追加部门负责人审批。']
},
{
heading: '四、附件清单',
lines: ['机票行程单、酒店发票、住宿水单、出租车发票。', '如存在改签、退票或异常情况,需补充说明材料。']
}
]
}
]
},
{
name: '发票查验规范及操作指引',
folder: '发票管理',
tag: '发票 / 操作',
time: '2024-05-10 10:22',
version: 'v1.5',
state: '已生效',
stateTone: 'success',
owner: '李娜',
icon: 'mdi mdi-file-document-outline-word word',
fileType: 'word',
fileTypeLabel: 'Word 预览',
summary: '说明发票验真路径、异常票据处理方式以及入账留痕要求。',
previewPages: [
{
title: '发票查验规范及操作指引',
subtitle: '验真流程与异常识别',
stats: [
{ label: '查验入口', value: '3 个' },
{ label: '异常类型', value: '6 类' },
{ label: '责任角色', value: '财务专员' }
],
blocks: [
{
heading: '一、查验入口',
lines: ['优先通过税务查验接口进行自动验真。', '无法自动识别时转人工核验并保留截图。']
},
{
heading: '二、异常票据',
lines: ['票面抬头不一致、号码重复、跨月补录需重点标注。', '出现红冲票据时需关联原单据并补充说明。']
}
]
}
]
},
{
name: '费用报销标准细则2024',
folder: '报销制度',
tag: '报销 / 标准',
time: '2024-05-08 09:16',
version: 'v2.1',
state: '已生效',
stateTone: 'success',
owner: '王磊',
icon: 'mdi mdi-file-document-outline-pdf pdf',
fileType: 'pdf',
fileTypeLabel: 'PDF 预览',
summary: '定义招待、差旅、办公采购与培训等费用类型的标准与限制。',
previewPages: [
{
title: '费用报销标准细则2024',
subtitle: '费用口径与报销边界',
stats: [
{ label: '费用大类', value: '8 类' },
{ label: '更新日期', value: '2024-05-08' },
{ label: '重点事项', value: '招待 / 交通' }
],
blocks: [
{
heading: '一、业务招待',
lines: ['需填写客户单位、参与人数及招待事由。', '单次超过 2000 元需上传审批邮件或会议纪要。']
},
{
heading: '二、交通与差旅',
lines: ['市内交通按真实票据报销,超标部分需说明。', '夜间出行或跨城交通需关联出差申请。']
}
]
}
]
},
{
name: '差旅费用标准对照表(国内)',
folder: '差旅规范',
tag: '差旅 / 标准',
time: '2024-05-05 08:20',
version: 'v1.3',
state: '审批中',
stateTone: 'warning',
owner: '陈杰',
icon: 'mdi mdi-file-document-outline-excel excel',
fileType: 'excel',
fileTypeLabel: 'Excel 预览',
summary: '各城市住宿、餐补与交通等级对照表,供申请与审核环节快速查询。',
previewPages: [
{
title: '差旅费用标准对照表(国内)',
subtitle: '城市维度对照',
stats: [
{ label: '覆盖城市', value: '48 个' },
{ label: '住宿档位', value: '4 级' },
{ label: '餐补标准', value: '日维度' }
],
blocks: [
{
heading: '一、住宿标准',
lines: ['北京 / 上海 / 深圳650 元 / 晚。', '新一线城市500 元 / 晚,其余城市按 380 元 / 晚执行。']
},
{
heading: '二、交通等级',
lines: ['总监及以上可乘坐高铁商务座或机票公务舱。', '其他员工默认经济舱、高铁二等座。']
}
]
}
]
},
{
name: '借款管理办法及流程',
folder: '财务共享',
tag: '借款 / 流程',
time: '2024-05-03 11:05',
version: 'v1.0',
state: '已生效',
stateTone: 'success',
owner: '刘洋',
icon: 'mdi mdi-file-document-outline-pdf pdf',
fileType: 'pdf',
fileTypeLabel: 'PDF 预览',
summary: '覆盖差旅借款、项目借款和借款冲销的全流程要求。',
previewPages: [
{
title: '借款管理办法及流程',
subtitle: '借款申请与冲销闭环',
stats: [
{ label: '适用场景', value: '差旅 / 项目' },
{ label: '冲销时限', value: '30 天' },
{ label: '审批路径', value: '3 级' }
],
blocks: [
{
heading: '一、借款申请',
lines: ['借款申请需绑定预算中心与费用类型。', '超过 5000 元需部门负责人额外审批。']
},
{
heading: '二、冲销要求',
lines: ['借款发生后 30 日内完成报销与冲销。', '逾期未冲销将纳入月度风险提醒。']
}
]
}
]
}
]
const filteredFolders = computed(() => {
const key = folderSearch.value.trim()
if (!key) return folders
return folders.filter((folder) => folder.name.includes(key))
})
const filteredDocuments = computed(() =>
documents.filter((doc) => {
const inFolder = activeFolder.value ? doc.folder === activeFolder.value : true
return inFolder
})
)
return {
folderSearch,
activeFolder,
selectedDocument,
folders,
documents,
filteredFolders,
filteredDocuments
}
}
}

View File

@@ -0,0 +1,116 @@
import { computed, ref, watch } from 'vue'
export default {
name: 'RequestsView',
props: {
filteredRequests: { type: Array, required: true }
},
emits: ['ask', 'approve', 'reject', 'create-request'] ,
setup(props, { emit }) {
const activeTab = ref('全部')
const tabs = ['全部', '待提交', '审批中', '待出行', '已完成']
const filters = ['报销状态', '出差城市', '费用类型']
const datePopover = ref(false)
const rangeStart = ref('')
const rangeEnd = ref('')
const appliedStart = ref('')
const appliedEnd = ref('')
const dateRangeLabel = computed(() => {
if (appliedStart.value && appliedEnd.value) return `${appliedStart.value} ~ ${appliedEnd.value}`
return '选择时间段'
})
function applyDateRange() {
if (!rangeStart.value || !rangeEnd.value) return
appliedStart.value = rangeStart.value
appliedEnd.value = rangeEnd.value
datePopover.value = false
}
const rows = [
{ id: 'BR240715001', reason: '华东区域客户拜访', city: '上海、苏州、杭州', period: '07-14~07-17 (4天)', applyTime: '2024-07-13', amount: '¥4,280.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '待预订酒店', travelTone: 'warning' },
{ id: 'BR240714010', reason: '年度战略合作伙伴会议', city: '北京', period: '07-15~07-16 (2天)', applyTime: '2024-07-12', amount: '¥1,860.00', node: '待提交', approval: '待提交', approvalTone: 'info', travel: '待订机票', travelTone: 'warning' },
{ id: 'BR240713008', reason: '产品培训与交流', city: '深圳', period: '07-10~07-12 (3天)', applyTime: '2024-07-09', amount: '¥2,150.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240712001', reason: '客户方案汇报', city: '上海', period: '07-08~07-11 (4天)', applyTime: '2024-07-07', amount: '¥3,680.00', node: '财务审核', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240711005', reason: '华南区域市场调研', city: '广州、佛山', period: '07-09~07-11 (3天)', applyTime: '2024-07-06', amount: '¥1,920.00', node: '待提交', approval: '待提交', approvalTone: 'info', travel: '无需预订', travelTone: 'neutral' },
{ id: 'BR240710003', reason: '供应商现场考察', city: '东莞', period: '07-06~07-07 (2天)', applyTime: '2024-07-05', amount: '¥680.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240709005', reason: '客户方案汇报', city: '北京', period: '07-06~07-08 (3天)', applyTime: '2024-07-05', amount: '¥1,980.00', node: '财务审核', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240708012', reason: '供应商现场考察', city: '广州', period: '07-04~07-05 (2天)', applyTime: '2024-07-03', amount: '¥860.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '无需预订', travelTone: 'neutral' },
{ id: 'BR240707003', reason: '项目启动会', city: '成都', period: '07-01~07-03 (3天)', applyTime: '2024-06-29', amount: '¥2,420.00', node: '财务审核', approval: '审批中', approvalTone: 'info', travel: '待预订酒店', travelTone: 'warning' },
{ id: 'BR240706009', reason: '客户拜访与市场调研', city: '南京、合肥', period: '06-28~06-30 (3天)', applyTime: '2024-06-26', amount: '¥1,750.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240705007', reason: '技术交流会', city: '武汉', period: '06-25~06-26 (2天)', applyTime: '2024-06-23', amount: '¥1,120.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '无需预订', travelTone: 'neutral' },
{ id: 'BR240704004', reason: '渠道合作洽谈', city: '西安', period: '06-20~06-21 (2天)', applyTime: '2024-06-18', amount: '¥780.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240703011', reason: '新员工入职培训', city: '长沙', period: '06-18~06-19 (2天)', applyTime: '2024-06-16', amount: '¥920.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240702006', reason: '季度业绩复盘会', city: '杭州', period: '06-15~06-16 (2天)', applyTime: '2024-06-13', amount: '¥1,350.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240701002', reason: '智慧金融峰会参展', city: '上海', period: '06-12~06-14 (3天)', applyTime: '2024-06-10', amount: '¥5,680.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240630009', reason: '西南区域渠道拓展', city: '重庆、贵阳', period: '06-10~06-13 (4天)', applyTime: '2024-06-08', amount: '¥3,450.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240629003', reason: '信息安全合规审计', city: '深圳', period: '06-08~06-09 (2天)', applyTime: '2024-06-06', amount: '¥1,180.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '无需预订', travelTone: 'neutral' },
{ id: 'BR240628007', reason: '产学研合作对接', city: '南京', period: '06-05~06-07 (3天)', applyTime: '2024-06-03', amount: '¥2,260.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240627001', reason: 'ERP系统上线支持', city: '青岛', period: '06-03~06-05 (3天)', applyTime: '2024-06-01', amount: '¥1,960.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240626004', reason: '大客户续约洽谈', city: '天津', period: '06-01~06-02 (2天)', applyTime: '2024-05-29', amount: '¥890.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240625010', reason: '区域销售团队建设', city: '厦门', period: '05-28~05-30 (3天)', applyTime: '2024-05-26', amount: '¥2,780.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240624002', reason: '供应链管理系统演示', city: '苏州', period: '05-25~05-26 (2天)', applyTime: '2024-05-23', amount: '¥650.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '无需预订', travelTone: 'neutral' },
{ id: 'BR240623008', reason: '行业白皮书发布会', city: '北京', period: '05-22~05-23 (2天)', applyTime: '2024-05-20', amount: '¥1,560.00', node: '待提交', approval: '待提交', approvalTone: 'info', travel: '待订机票', travelTone: 'warning' },
{ id: 'BR240622005', reason: '跨部门协同工作坊', city: '大连', period: '05-20~05-22 (3天)', applyTime: '2024-05-18', amount: '¥2,340.00', node: '待提交', approval: '待提交', approvalTone: 'info', travel: '无需预订', travelTone: 'neutral' },
{ id: 'BR240621003', reason: '数字化转型的客户交流', city: '深圳、珠海', period: '05-16~05-18 (3天)', applyTime: '2024-05-14', amount: '¥3,120.00', node: '待提交', approval: '待提交', approvalTone: 'info', travel: '待预订酒店', travelTone: 'warning' },
{ id: 'BR240620006', reason: '年中预算评审会', city: '上海', period: '05-13~05-14 (2天)', applyTime: '2024-05-11', amount: '¥1,480.00', node: '财务审核', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240619001', reason: '医疗行业解决方案展', city: '成都', period: '05-10~05-12 (3天)', applyTime: '2024-05-08', amount: '¥3,860.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240618009', reason: '东北区域客户回访', city: '沈阳、长春', period: '05-06~05-09 (4天)', applyTime: '2024-05-04', amount: '¥4,520.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '待订机票', travelTone: 'warning' },
{ id: 'BR240617007', reason: '大数据平台技术对接', city: '杭州', period: '05-03~05-05 (3天)', applyTime: '2024-05-01', amount: '¥2,180.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240616004', reason: '国际业务合规培训', city: '北京', period: '04-28~04-30 (3天)', applyTime: '2024-04-26', amount: '¥2,960.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' }
]
const currentPage = ref(1)
const pageSize = ref(10)
const pageSizes = [10, 20, 50]
const pageSizeOpen = ref(false)
function changePageSize(size) {
pageSize.value = size
pageSizeOpen.value = false
currentPage.value = 1
}
const filteredRows = computed(() => {
if (activeTab.value === '全部') return rows
return rows.filter((row) => row.approval === activeTab.value || row.travel.includes(activeTab.value.replace('待出行', '待订')))
})
const totalCount = computed(() => filteredRows.value.length)
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
const visibleRows = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredRows.value.slice(start, start + pageSize.value)
})
watch(activeTab, () => { currentPage.value = 1 })
return {
emit,
activeTab,
tabs,
filters,
datePopover,
rangeStart,
rangeEnd,
appliedStart,
appliedEnd,
dateRangeLabel,
applyDateRange,
rows,
currentPage,
pageSize,
pageSizes,
pageSizeOpen,
changePageSize,
filteredRows,
totalCount,
totalPages,
visibleRows
}
}
}

View File

@@ -0,0 +1,443 @@
import { computed, nextTick, onMounted, ref } from 'vue'
export default {
name: 'TravelReimbursementCreateView',
props: {
initialPrompt: {
type: String,
default: ''
},
entrySource: {
type: String,
default: 'requests'
},
requestContext: {
type: Object,
default: null
}
},
emits: ['close'] ,
setup(props, { emit }) {
const DEFAULT_REQUEST = {
id: 'BR240712001',
reason: '客户方案汇报',
city: '上海',
period: '07-08 ~ 07-11',
applyTime: '2024-07-07',
amount: '¥3,680.00',
node: '财务复核',
approval: '主管审批中',
travel: '已订酒店 / 机票'
}
const SOURCE_LABELS = {
workbench: '来自个人工作台',
topbar: '来自发起报销',
detail: '来自智能录入',
upload: '来自附件上传',
requests: '来自报销列表'
}
let messageSeed = 0
const fileInputRef = ref(null)
const messageListRef = ref(null)
const composerDraft = ref('')
const attachedFiles = ref([])
const messages = ref([])
const currentInsight = ref({
intent: 'welcome',
confidence: 0,
title: '',
summary: '',
welcome: { cards: [] }
})
const linkedRequest = computed(() => sanitizeRequest(props.requestContext))
const sourceLabel = computed(() => SOURCE_LABELS[props.entrySource] ?? '来自 AI 工作台')
const canSubmit = computed(() => Boolean(composerDraft.value.trim() || attachedFiles.value.length))
const showInsightPanel = computed(() => currentInsight.value.intent !== 'welcome')
const composerPlaceholder = computed(() => {
if (props.entrySource === 'detail') {
return `例如:帮我看一下 ${linkedRequest.value.id} 现在到哪个审批节点,或者补充超标说明。`
}
return '例如:帮我发起差旅报销、查一下审批节点,或者识别我刚上传的票据。'
})
const currentIntentLabel = computed(() => {
const labels = {
welcome: '等待输入',
draft: '报销草稿',
approval: '审批查询',
recognition: '单据识别',
note: '补充说明'
}
return labels[currentInsight.value.intent] ?? 'AI 处理中'
})
const shortcuts = computed(() => [
{ label: '查审批节点', icon: 'mdi mdi-timeline-clock-outline', prompt: `帮我看一下 ${linkedRequest.value.id} 现在到哪个审批节点了` },
{ label: '识别上传单据', icon: 'mdi mdi-file-search-outline', prompt: '我上传了几张票据,帮我识别并给出录入结果' },
{ label: '补充报销说明', icon: 'mdi mdi-text-box-edit-outline', prompt: `帮我给 ${linkedRequest.value.id} 补一段费用说明` },
{ label: '生成报销草稿', icon: 'mdi mdi-file-document-edit-outline', prompt: '我要发起一笔差旅费申请报销,请帮我先生成草稿' }
])
messages.value = [
createMessage(
'assistant',
buildGreeting(),
[]
)
]
onMounted(() => {
currentInsight.value = buildWelcomeInsight()
if (props.initialPrompt?.trim()) {
composerDraft.value = props.initialPrompt.trim()
submitComposer()
} else {
nextTick(scrollToBottom)
}
})
function sanitizeRequest(request) {
if (!request) return { ...DEFAULT_REQUEST }
return {
id: request.id ?? DEFAULT_REQUEST.id,
reason: request.reason ?? DEFAULT_REQUEST.reason,
city: request.city ?? DEFAULT_REQUEST.city,
period: request.period ?? DEFAULT_REQUEST.period,
applyTime: request.applyTime ?? DEFAULT_REQUEST.applyTime,
amount: request.amount ?? DEFAULT_REQUEST.amount,
node: request.node ?? DEFAULT_REQUEST.node,
approval: request.approval ?? DEFAULT_REQUEST.approval,
travel: request.travel ?? DEFAULT_REQUEST.travel
}
}
function buildGreeting() {
if (props.entrySource === 'detail') {
return `已进入统一对话工作台,当前关联单据 ${linkedRequest.value.id}。你可以直接问审批节点、补充说明,或继续上传票据。`
}
return '这里是统一对话入口。你可以直接发起报销、查询审批节点,或者上传单据让我识别。'
}
function buildWelcomeInsight() {
return {
intent: 'welcome',
confidence: 86,
title: props.entrySource === 'detail' ? `已关联 ${linkedRequest.value.id}` : '先告诉我你要处理什么',
summary: props.entrySource === 'detail'
? '右侧会跟随你的提问切换成审批状态、识别结果或补充说明界面。'
: '无论是发起报销、查审批还是识别票据,这里都共用一个对话入口。',
welcome: {
cards: [
{ icon: 'mdi mdi-timeline-clock-outline', title: '审批查询', desc: '识别到审批、节点、状态等意图时,右侧切到流程状态。' },
{ icon: 'mdi mdi-file-search-outline', title: '票据识别', desc: '上传附件后展示识别结果、建议金额和缺失材料。' },
{ icon: 'mdi mdi-text-box-check-outline', title: '补充说明', desc: '补充超标、夜间交通、业务招待等说明时,右侧给出结构化备注。' }
]
}
}
}
function createMessage(role, text, attachments = []) {
messageSeed += 1
return {
id: `msg-${messageSeed}`,
role,
text,
attachments,
time: nowTime()
}
}
function nowTime() {
return new Date().toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
function triggerFileUpload() {
fileInputRef.value?.click()
}
function handleFilesChange(event) {
attachedFiles.value = Array.from(event.target.files ?? [])
}
function runShortcut(prompt) {
composerDraft.value = prompt
submitComposer()
}
function submitComposer() {
if (!canSubmit.value) return
const rawText = composerDraft.value.trim()
const fileNames = attachedFiles.value.map((file) => file.name)
const userText = rawText || `我上传了 ${fileNames.length} 份单据,请帮我识别并录入。`
messages.value.push(createMessage('user', userText, fileNames))
const insight = analyzeIntent(userText, fileNames)
currentInsight.value = insight
messages.value.push(createMessage('assistant', insight.reply))
composerDraft.value = ''
attachedFiles.value = []
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
nextTick(scrollToBottom)
}
function scrollToBottom() {
if (!messageListRef.value) return
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
}
function analyzeIntent(text, files) {
if (isRecognitionIntent(text, files)) return buildRecognitionInsight(text, files)
if (isApprovalIntent(text)) return buildApprovalInsight(text)
if (isNoteIntent(text)) return buildNoteInsight(text)
return buildDraftInsight(text, files)
}
function isRecognitionIntent(text, files) {
return files.length > 0 || /(上传|附件|票据|发票|单据|识别|ocr)/i.test(text)
}
function isApprovalIntent(text) {
return /(审批|节点|状态|进度|流程|卡在哪|到哪了|通过了吗|驳回)/.test(text)
}
function isNoteIntent(text) {
return /(说明|备注|补充|原因|超标|夜间|特殊情况|备注一下)/.test(text)
}
function buildApprovalInsight(text) {
const requestId = extractRequestId(text) || linkedRequest.value.id
const timeline = [
{ label: '提交申请', time: `${linkedRequest.value.applyTime} 09:18`, state: 'done' },
{ label: '票据识别', time: `${linkedRequest.value.applyTime} 09:22`, state: 'done' },
{ label: '直属主管审批', time: '今天 10:46', state: 'done' },
{ label: linkedRequest.value.node, time: '进行中', state: 'current' },
{ label: '归档入账', time: '待处理', state: 'pending' }
]
return {
intent: 'approval',
confidence: 95,
title: `${requestId} 的审批状态`,
summary: `当前在 ${linkedRequest.value.node},右侧已经切到流程状态界面。`,
reply: `我识别到你是在查询审批节点。${requestId} 当前处于 ${linkedRequest.value.node},下一步预计由财务在今天 17:30 前处理。`,
status: {
requestId,
currentStatus: linkedRequest.value.approval,
currentNode: linkedRequest.value.node,
nextOwner: '财务共享中心 · 王敏',
eta: '今天 17:30 前',
timeline,
actions: [
'若 17:30 后仍未推进,可提醒财务共享中心处理。',
'当前不建议重复提交,避免流程串单。',
'如果要补充说明,直接在当前对话里继续输入即可。'
]
}
}
}
function buildRecognitionInsight(text, files) {
const requestId = extractRequestId(text) || linkedRequest.value.id
const receipts = buildReceiptItems(text, files)
const total = receipts.reduce((sum, item) => sum + parseCurrency(item.amount), 0)
const amount = formatCurrency(total || guessAmount(text) || 3680)
const completeness = files.length >= 2 ? '资料较完整' : '仍需补件'
return {
intent: 'recognition',
confidence: files.length ? 97 : 90,
title: '已切换到单据识别视图',
summary: `识别到 ${receipts.length} 条候选费用,建议关联到 ${requestId}`,
reply: `我识别到你是在上传或识别单据。右侧已经展示识别结果、建议金额和缺失材料。`,
recognition: {
state: files.length ? '识别完成' : '待补附件',
requestId,
fileCount: Math.max(files.length, 1),
amount,
completeness,
receipts,
suggestions: [
files.length ? '可直接生成费用明细草稿。' : '建议补传票据原件,识别结果会更稳定。',
'金额和费用分类已经给出,确认后即可写入报销单。',
'如果有多张单据属于同一行程,可以继续上传,右侧会合并结果。'
]
}
}
}
function buildNoteInsight(text) {
const requestId = extractRequestId(text) || linkedRequest.value.id
const noteType = /超标|夜间/.test(text) ? '特殊场景说明' : '补充报销说明'
const generatedNote = /超标|夜间/.test(text)
? '因客户会议结束较晚,产生夜间交通费用,已保留行程截图与打车凭证,申请按实际发生金额报销。'
: '本次费用与客户现场沟通及方案汇报直接相关,单据与行程已对应关联,请按当前草稿继续流转。'
return {
intent: 'note',
confidence: 93,
title: `${requestId} 的补充说明`,
summary: `识别到你是在补充备注,右侧切到说明整理界面。`,
reply: `我识别到你是在补充说明。右侧已经生成结构化备注,可直接作为对应单号的附加说明。`,
note: {
requestId,
state: noteType,
generatedNote,
impacts: [
'会同步显示给当前审批节点处理人。',
'若涉及超标或夜间交通,审批意见会优先查看这段说明。',
'继续补充金额、参与人或业务背景时,我会自动更新说明版本。'
],
owner: linkedRequest.value.node,
nextAction: '继续补充或提交当前说明'
}
}
}
function buildDraftInsight(text, files) {
const requestId = linkedRequest.value.id
const items = buildDraftItems(text, files)
const total = items.reduce((sum, item) => sum + parseCurrency(item.amount), 0)
return {
intent: 'draft',
confidence: 91,
title: '已切换到报销草稿视图',
summary: '识别到你是在发起报销或继续填写草稿,右侧展示当前建议明细。',
reply: '我识别到你是在发起或继续整理报销。右侧已经切到草稿视图,展示建议费用明细和待补信息。',
draft: {
state: files.length ? '可继续完善' : '草稿已生成',
requestId,
type: inferDraftType(text),
amount: formatCurrency(total || guessAmount(text) || 3280),
progress: files.length ? '已录入基础信息' : '待补票据',
items,
missing: [
'补充至少一份原始票据或行程截图。',
'确认出差事由、城市和发生日期是否完整。',
'如有业务招待或特殊交通,请补充关联说明。'
]
}
}
}
function extractRequestId(text) {
return text.match(/BR\d{6,}/i)?.[0] ?? ''
}
function inferDraftType(text) {
if (/招待|客户|用餐/.test(text)) return '业务招待报销'
if (/交通|打车|高铁|机票/.test(text)) return '交通费用报销'
return '差旅费申请报销'
}
function buildDraftItems(text, files) {
const items = []
if (/高铁|火车|车票/.test(text)) {
items.push({ name: '高铁 / 火车票', desc: '建议录入为城际交通', amount: '¥236.00', tag: '交通' })
}
if (/机票|航班/.test(text)) {
items.push({ name: '机票', desc: '建议录入为航空出行', amount: '¥1,280.00', tag: '交通' })
}
if (/酒店|住宿/.test(text)) {
items.push({ name: '酒店住宿', desc: '建议录入为住宿费用', amount: '¥780.00', tag: '住宿' })
}
if (/打车|出租车|网约车/.test(text)) {
items.push({ name: '市内交通', desc: '建议合并同日打车订单', amount: '¥126.00', tag: '交通' })
}
if (/餐|招待|客户/.test(text)) {
items.push({ name: '业务招待', desc: '建议补充参与人和业务目的', amount: '¥860.00', tag: '招待' })
}
if (!items.length) {
items.push({
name: '差旅综合费用',
desc: files.length ? '已根据附件生成候选明细' : '根据描述先生成一版草稿',
amount: files.length ? '¥3,280.00' : '¥2,680.00',
tag: '草稿'
})
}
return items
}
function buildReceiptItems(text, files) {
if (files.length) {
return files.map((file, index) => {
const type = inferFileType(file, text, index)
const baseAmount = guessAmount(file) || guessAmount(text) || (index + 1) * 180 + 120
return {
name: file,
type,
amount: formatCurrency(baseAmount),
confidence: `${92 - index}%`
}
})
}
return buildDraftItems(text, files).map((item, index) => ({
name: item.name,
type: item.tag,
amount: item.amount,
confidence: `${94 - index}%`
}))
}
function inferFileType(fileName, text, index) {
const name = `${fileName} ${text}`
if (/酒店|住宿/.test(name)) return '住宿单据'
if (/机票|航班/.test(name)) return '航空出行'
if (/高铁|火车|车票/.test(name)) return '城际交通'
if (/打车|出租车|网约车/.test(name)) return '市内交通'
return index === 0 ? '费用主票据' : '补充附件'
}
function guessAmount(text) {
const match = String(text).match(/(\d+(?:\.\d{1,2})?)/)
return match ? Number.parseFloat(match[1]) : 0
}
function parseCurrency(value) {
return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0
}
function formatCurrency(value) {
return `¥${Number(value).toFixed(2)}`
}
return {
emit,
fileInputRef,
messageListRef,
composerDraft,
attachedFiles,
messages,
currentInsight,
linkedRequest,
sourceLabel,
canSubmit,
showInsightPanel,
composerPlaceholder,
currentIntentLabel,
shortcuts,
triggerFileUpload,
handleFilesChange,
runShortcut,
submitComposer
}
}
}

View File

@@ -0,0 +1,451 @@
import { computed, ref } from 'vue'
export default {
name: 'TravelRequestDetailView',
props: {
request: {
type: Object,
default: () => ({})
}
},
emits: ['backToRequests', 'openAssistant'] ,
setup(props, { emit }) {
const expandedExpenseId = ref(null)
const aiEntryOpen = ref(false)
const aiDraft = ref('')
const aiFileInput = ref(null)
const aiEntrySeed = ref(2)
const pendingAiExpense = ref(null)
const uploadedAiFiles = ref([])
const expenseItems = ref([
{
id: 'exp-1',
time: '07-08',
dayLabel: '第 1 天',
name: '高铁票',
category: '交通',
desc: '上海虹桥 -> 杭州东',
detail: '客户方案汇报前往现场',
amount: '¥236.00',
status: '规则通过',
tone: 'ok',
attachmentStatus: '2 份附件',
attachmentHint: '车票 + 行程单',
attachmentTone: 'ok',
attachments: ['高铁票.pdf', '行程单.pdf'],
riskLabel: '规则通过',
riskText: '票据与行程匹配',
riskTone: 'low'
},
{
id: 'exp-2',
time: '07-09',
dayLabel: '第 2 天',
name: '酒店住宿',
category: '住宿',
desc: '杭州西湖商务酒店',
detail: '1 晚住宿,含早餐',
amount: '¥1,180.00',
status: '待补材料',
tone: 'bad',
attachmentStatus: '缺 1 份',
attachmentHint: '缺少入住清单',
attachmentTone: 'partial',
attachments: ['酒店发票.jpg'],
riskLabel: '待补材料',
riskText: '需补酒店入住清单',
riskTone: 'medium'
},
{
id: 'exp-3',
time: '07-10',
dayLabel: '第 3 天',
name: '出租车',
category: '市内交通',
desc: '客户公司往返酒店',
detail: '含夜间打车 2 次',
amount: '¥128.00',
status: '需说明',
tone: 'bad',
attachmentStatus: '3 份附件',
attachmentHint: '发票已上传',
attachmentTone: 'ok',
attachments: ['出租车发票1.jpg', '出租车发票2.jpg', '打车订单.png'],
riskLabel: '超标说明',
riskText: '1 笔夜间交通需补充说明',
riskTone: 'medium'
},
{
id: 'exp-4',
time: '07-11',
dayLabel: '第 4 天',
name: '餐补',
category: '补贴',
desc: '差旅餐补',
detail: '按 4 天标准自动计算',
amount: '¥320.00',
status: '规则通过',
tone: 'ok',
attachmentStatus: '系统生成',
attachmentHint: '无需上传附件',
attachmentTone: 'neutral',
attachments: [],
riskLabel: '规则通过',
riskText: '补贴标准校验通过',
riskTone: 'low'
}
])
const request = computed(() => ({
id: props.request?.id ?? 'BR240712001',
reason: props.request?.reason ?? '客户方案汇报',
city: props.request?.city ?? '上海',
period: props.request?.period ?? '07-08~07-11 (4天)',
applyTime: props.request?.applyTime ?? '2024-07-07',
amount: props.request?.amount ?? '¥3,680.00',
node: props.request?.node ?? '财务审核',
approval: props.request?.approval ?? '审批中',
approvalTone: props.request?.approvalTone ?? 'info',
travel: props.request?.travel ?? '已订酒店/机票',
travelTone: props.request?.travelTone ?? 'low'
}))
const profile = {
name: '张晓明',
department: '财务管理员',
avatar: '张'
}
const summaryItems = [
{ label: '出差城市', value: request.value.city, icon: 'mdi mdi-map-marker-path' },
{ label: '出差区间', value: request.value.period, icon: 'mdi mdi-clock-outline' },
{ label: '票据关联', value: '6 条明细 / 5 份材料', icon: 'mdi mdi-file-document-multiple-outline' },
{ label: '商旅状态', value: request.value.travel, icon: 'mdi mdi-airplane' },
{ label: '出差事由', value: request.value.reason, icon: 'mdi mdi-briefcase-outline' }
]
const heroSummaryItems = computed(() => [
{ label: '单号', value: request.value.id, icon: 'mdi mdi-pound-box-outline' },
{ label: '申请类型', value: '差旅费申请/报销', icon: 'mdi mdi-tag-multiple' },
...summaryItems
])
const currentProgressRingMotion = {
initial: {
scale: 1,
opacity: 0.34,
},
enter: {
scale: [1, 1.42, 1.78],
opacity: [0.34, 0.16, 0],
transition: {
duration: 3.2,
repeat: Infinity,
repeatType: 'loop',
repeatDelay: 0.85,
ease: 'easeOut',
times: [0, 0.5, 1],
},
},
}
const progressSteps = computed(() => {
return [
{ index: 1, label: '提交申请', time: '07-11 08:46', done: true, active: true },
{ index: 2, label: '票据识别', time: '07-11 08:48', done: true, active: true },
{ index: 3, label: '费用归类', time: '07-11 08:49', done: true, active: true },
{ index: 4, label: '部门负责人审批', time: '07-11 11:28', done: true, active: true },
{ index: 5, label: '财务审批', time: '进行中', active: true, current: true },
{ index: 6, label: '归档入账', time: '待处理' }
]
})
const expenseTotal = computed(() => {
const total = expenseItems.value.reduce((sum, item) => sum + parseCurrency(item.amount), 0)
return formatCurrency(total)
})
const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length)
const canSendAiEntry = computed(() => Boolean(aiDraft.value.trim() || uploadedAiFiles.value.length))
const detailNote = '本次出差用于客户方案汇报与现场沟通,需覆盖往返交通、住宿及市内交通费用。已完成主要票据上传,待补酒店入住清单后即可进入完整审批流程。'
function toggleExpenseAttachments(id) {
expandedExpenseId.value = expandedExpenseId.value === id ? null : id
}
function showExpenseRisk(item) {
return Boolean(item.riskText)
}
function openAiEntry() {
aiEntryOpen.value = false
emit('openAssistant', {
source: 'detail',
prompt: '',
request: request.value
})
}
function closeAiEntry() {
aiEntryOpen.value = false
aiDraft.value = ''
pendingAiExpense.value = null
uploadedAiFiles.value = []
if (aiFileInput.value) {
aiFileInput.value.value = ''
}
}
function parseCurrency(value) {
return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0
}
function formatCurrency(value) {
return `${value.toFixed(2)}`
}
function buildNextExpenseId() {
aiEntrySeed.value += 1
return `exp-ai-${aiEntrySeed.value}`
}
function inferExpenseCategory(text) {
if (/高铁|火车|机票|航班|打车|出租车|地铁|公交|交通/.test(text)) return '交通'
if (/酒店|住宿|房费/.test(text)) return '住宿'
if (/餐|午饭|晚饭|早餐|餐补/.test(text)) return '餐饮'
return '其他'
}
function inferExpenseName(text, category) {
if (/高铁/.test(text)) return '高铁票'
if (/机票|航班/.test(text)) return '机票'
if (/出租车|打车/.test(text)) return '出租车'
if (/酒店|住宿/.test(text)) return '酒店住宿'
if (/餐补/.test(text)) return '餐补'
if (/餐|午饭|晚饭|早餐/.test(text)) return '餐饮'
return `${category}费用`
}
function inferAttachments(text, uploadedFiles = []) {
if (uploadedFiles.length) {
return {
status: `${uploadedFiles.length} 份附件`,
hint: uploadedFiles.map((file) => file.name).join(' + '),
tone: 'ok',
files: uploadedFiles.map((file) => file.name),
}
}
if (/无需|免附件|系统生成/.test(text)) {
return {
status: '系统生成',
hint: '无需上传附件',
tone: 'neutral',
files: [],
}
}
const uploaded = /已上传|上传了|附上|附件/.test(text)
const receipt = /发票/.test(text)
const itinerary = /行程单/.test(text)
const ticket = /车票|机票/.test(text)
const hotelList = /入住清单/.test(text)
const files = []
if (receipt) files.push('发票.jpg')
if (itinerary) files.push('行程单.pdf')
if (ticket && !files.includes('票据.pdf')) files.push('票据.pdf')
if (hotelList) files.push('入住清单.pdf')
if (uploaded || files.length) {
return {
status: `${Math.max(files.length, 1)} 份附件`,
hint: files.length ? files.join(' + ') : '已上传附件待识别',
tone: 'ok',
files: files.length ? files : ['附件1.jpg'],
}
}
return {
status: '缺 1 份',
hint: '待补上传票据原件',
tone: 'missing',
files: [],
}
}
function inferRisk(text, attachmentTone) {
if (/夜间|超标|说明/.test(text)) {
return {
status: '需说明',
tone: 'bad',
riskLabel: '超标说明',
riskText: '识别到特殊场景,建议补充费用说明',
riskTone: 'medium',
}
}
if (attachmentTone === 'missing' || attachmentTone === 'partial') {
return {
status: '待补材料',
tone: 'bad',
riskLabel: '待补材料',
riskText: '附件不完整,需补齐后再提交审批',
riskTone: 'medium',
}
}
return {
status: '规则通过',
tone: 'ok',
riskLabel: '规则通过',
riskText: 'AI 识别通过,字段已结构化',
riskTone: 'low',
}
}
function extractDateLabel(text) {
const match = text.match(/(\d{1,2})月(\d{1,2})日|(\d{1,2})[-/.](\d{1,2})/)
if (!match) {
return { time: '07-12', dayLabel: `${expenseItems.value.length + 1}` }
}
const month = String(match[1] || match[3] || '07').padStart(2, '0')
const day = String(match[2] || match[4] || '12').padStart(2, '0')
return { time: `${month}-${day}`, dayLabel: `${expenseItems.value.length + 1}` }
}
function extractAmount(text) {
const match = text.match(/(\d+(?:\.\d{1,2})?)\s*元/)
return formatCurrency(Number.parseFloat(match?.[1] || '0'))
}
function buildAiExpense(text) {
const category = inferExpenseCategory(text)
const name = inferExpenseName(text, category)
const dateInfo = extractDateLabel(text)
const attachments = inferAttachments(text, uploadedAiFiles.value)
const risk = inferRisk(text, attachments.tone)
return {
id: buildNextExpenseId(),
time: dateInfo.time,
dayLabel: dateInfo.dayLabel,
name,
category,
desc: text.slice(0, 24),
detail: text,
amount: extractAmount(text),
status: risk.status,
tone: risk.tone,
attachmentStatus: attachments.status,
attachmentHint: attachments.hint,
attachmentTone: attachments.tone,
attachments: attachments.files,
riskLabel: risk.riskLabel,
riskText: risk.riskText,
riskTone: risk.riskTone,
}
}
const aiMessages = ref([
{
id: 'ai-msg-1',
role: 'assistant',
text: '请直接描述费用场景、日期、金额和是否已上传票据,我会整理成费用明细。',
},
])
function sendAiEntry() {
const text = aiDraft.value.trim() || `已上传 ${uploadedAiFiles.value.length} 份单据,请根据附件识别费用。`
if (!text && !uploadedAiFiles.value.length) return
aiMessages.value.push({
id: `ai-msg-user-${Date.now()}`,
role: 'user',
text: uploadedAiFiles.value.length ? `${text}\n附件:${uploadedAiFiles.value.map((file) => file.name).join('、')}` : text,
})
pendingAiExpense.value = buildAiExpense(text)
aiMessages.value.push({
id: `ai-msg-assistant-${Date.now()}`,
role: 'assistant',
text: `已识别为 ${pendingAiExpense.value.name},金额 ${pendingAiExpense.value.amount},可直接加入费用明细。`,
})
aiDraft.value = ''
}
function regenerateAiEntry() {
if (!pendingAiExpense.value) return
const sourceText = pendingAiExpense.value.detail
pendingAiExpense.value = buildAiExpense(sourceText.replace('待补上传票据原件', '已上传发票'))
aiMessages.value.push({
id: `ai-msg-regenerate-${Date.now()}`,
role: 'assistant',
text: '已重新整理识别结果,你可以继续确认后加入费用明细。',
})
}
function applyAiExpense() {
if (!pendingAiExpense.value) return
expenseItems.value.push({ ...pendingAiExpense.value })
expandedExpenseId.value = pendingAiExpense.value.id
aiMessages.value.push({
id: `ai-msg-apply-${Date.now()}`,
role: 'assistant',
text: '该费用条目已加入下方费用明细表。',
})
pendingAiExpense.value = null
aiDraft.value = ''
uploadedAiFiles.value = []
if (aiFileInput.value) {
aiFileInput.value.value = ''
}
aiEntryOpen.value = false
}
function triggerAiUpload() {
aiFileInput.value?.click()
}
function handleAiFilesChange(event) {
const files = Array.from(event.target.files ?? [])
uploadedAiFiles.value = files
}
return {
emit,
expandedExpenseId,
aiEntryOpen,
aiDraft,
aiFileInput,
aiEntrySeed,
pendingAiExpense,
uploadedAiFiles,
expenseItems,
request,
profile,
summaryItems,
heroSummaryItems,
currentProgressRingMotion,
progressSteps,
expenseTotal,
uploadedExpenseCount,
canSendAiEntry,
detailNote,
toggleExpenseAttachments,
showExpenseRisk,
openAiEntry,
closeAiEntry,
aiMessages,
sendAiEntry,
regenerateAiEntry,
applyAiExpense,
triggerAiUpload,
handleAiFilesChange
}
}
}

86
web/start.sh Normal file
View File

@@ -0,0 +1,86 @@
#!/usr/bin/env bash
set -euo pipefail
# ============================================================
# X-Financial Reimbursement Admin - Start Script
# ============================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
# ----------------------------------------------------------
# Check Node.js
# ----------------------------------------------------------
if ! command -v node &>/dev/null; then
error "Node.js is not installed. Install it first: https://nodejs.org"
fi
if ! command -v npm &>/dev/null; then
error "npm is not installed. It should come with Node.js."
fi
info "Node.js $(node -v) | npm $(npm -v)"
# ----------------------------------------------------------
# WSL on a Windows-mounted repo should reuse Windows Node
# ----------------------------------------------------------
is_wsl() {
grep -qi microsoft /proc/version 2>/dev/null
}
is_windows_mount() {
case "$SCRIPT_DIR" in
/mnt/*) return 0 ;;
*) return 1 ;;
esac
}
if is_wsl && is_windows_mount && command -v powershell.exe &>/dev/null && command -v wslpath &>/dev/null; then
WIN_PATH="$(wslpath -w "$SCRIPT_DIR")"
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
# ----------------------------------------------------------
# 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
# ----------------------------------------------------------
# Start dev server
# ----------------------------------------------------------
info "Starting X-Financial Reimbursement Admin..."
info "Access: http://127.0.0.1:5173"
echo ""
exec npm start

6
web/vite.config.js Normal file
View File

@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()]
})