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:
37
web/src/composables/useAnimationProgress.js
Normal file
37
web/src/composables/useAnimationProgress.js
Normal 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
|
||||
}
|
||||
169
web/src/composables/useAppShell.js
Normal file
169
web/src/composables/useAppShell.js
Normal 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
|
||||
}
|
||||
}
|
||||
102
web/src/composables/useChat.js
Normal file
102
web/src/composables/useChat.js
Normal 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.6h,SLA 达成率 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
|
||||
}
|
||||
}
|
||||
36
web/src/composables/useLoginView.js
Normal file
36
web/src/composables/useLoginView.js
Normal 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
|
||||
}
|
||||
}
|
||||
83
web/src/composables/useNavigation.js
Normal file
83
web/src/composables/useNavigation.js
Normal 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 }
|
||||
}
|
||||
113
web/src/composables/useOverviewView.js
Normal file
113
web/src/composables/useOverviewView.js
Normal 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
|
||||
}
|
||||
}
|
||||
60
web/src/composables/useRequests.js
Normal file
60
web/src/composables/useRequests.js
Normal 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
|
||||
}
|
||||
}
|
||||
13
web/src/composables/useToast.js
Normal file
13
web/src/composables/useToast.js
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user