first commit

This commit is contained in:
super
2025-12-28 22:12:08 +08:00
commit 82dcc17968
72 changed files with 23293 additions and 0 deletions

6
.env Normal file
View File

@@ -0,0 +1,6 @@
# 基础配置
VITE_APP_TITLE=楠溪屿后台管理系统
# API基础URL
VITE_API_BASE_URL=/api

6
.env.development Normal file
View File

@@ -0,0 +1,6 @@
# 开发环境配置
VITE_APP_TITLE=楠溪屿后台管理系统(开发环境)
# API基础URL - 开发环境
VITE_API_BASE_URL=http://localhost:8080/api

6
.env.production Normal file
View File

@@ -0,0 +1,6 @@
# 生产环境配置
VITE_APP_TITLE=楠溪屿后台管理系统
# API基础URL - 生产环境
VITE_API_BASE_URL=/api

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>nanxiisletadmin</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2964
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "nanxiisletadmin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"@types/echarts": "^4.9.22",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.3",
"@vue-flow/core": "^1.48.0",
"@vue-flow/minimap": "^1.5.4",
"ant-design-vue": "^4.2.6",
"axios": "^1.13.2",
"echarts": "^6.0.0",
"pinia": "^3.0.4",
"spark-md5": "^3.0.2",
"vue": "^3.5.24",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@iconify/json": "^2.2.412",
"@types/node": "^24.10.1",
"@types/spark-md5": "^3.0.5",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"typescript": "~5.9.3",
"unplugin-auto-import": "^20.3.0",
"unplugin-icons": "^22.5.0",
"unplugin-vue-components": "^30.0.0",
"vite": "^7.2.4",
"vue-tsc": "^3.1.4"
}
}

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

17
src/App.vue Normal file
View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import { ConfigProvider } from 'ant-design-vue'
import zhCN from 'ant-design-vue/es/locale/zh_CN'
</script>
<template>
<ConfigProvider :locale="zhCN">
<router-view />
</ConfigProvider>
</template>
<style>
#app {
width: 100%;
height: 100%;
}
</style>

451
src/api/1panel.ts Normal file
View File

@@ -0,0 +1,451 @@
/**
* 1Panel API 接口
*
* 1Panel API 认证说明:
* - Token = md5('1panel' + API-Key + UnixTimestamp)
* - 请求头需要携带:
* - 1Panel-Token: 生成的Token值
* - 1Panel-Timestamp: 当前时间戳(秒级)
*/
import axios, { type AxiosInstance } from 'axios'
import { message } from 'ant-design-vue'
import SparkMD5 from 'spark-md5'
// 1Panel API 配置(可动态修改)
const PANEL_CONFIG = {
// 服务器ID用于代理路径
serverId: 'server1',
// 服务器地址(不含协议和路径)
serverAddress: '47.109.57.58:42588',
// 完整的 baseURL开发环境使用代理路径生产环境直接请求
baseURL: import.meta.env.DEV ? '/1panel-api/server1' : 'http://47.109.57.58:42588/api/v2',
// API密钥 - 可动态切换
apiKey: import.meta.env.VITE_1PANEL_API_KEY || ''
}
/**
* 服务器配置接口
*/
export interface PanelServerConfig {
id: string // 服务器ID用于代理路径
address: string // 服务器地址
apiKey: string // API密钥
}
/**
* 切换 1Panel 服务器配置
* @param config 服务器配置
*/
export function setPanelServer(config: PanelServerConfig): void {
PANEL_CONFIG.serverId = config.id
PANEL_CONFIG.serverAddress = config.address
PANEL_CONFIG.apiKey = config.apiKey
// 开发环境使用代理路径,生产环境直接请求
if (import.meta.env.DEV) {
PANEL_CONFIG.baseURL = `/1panel-api/${config.id}`
} else {
PANEL_CONFIG.baseURL = `http://${config.address}/api/v2`
}
// 更新 axios 实例的 baseURL
panelService.defaults.baseURL = PANEL_CONFIG.baseURL
console.log('[1Panel API] 服务器配置已更新:', {
serverId: PANEL_CONFIG.serverId,
serverAddress: PANEL_CONFIG.serverAddress,
baseURL: PANEL_CONFIG.baseURL,
apiKeyConfigured: !!PANEL_CONFIG.apiKey,
isDev: import.meta.env.DEV
})
}
/**
* 获取当前 1Panel 服务器配置
*/
export function getPanelConfig() {
return {
serverId: PANEL_CONFIG.serverId,
serverAddress: PANEL_CONFIG.serverAddress,
baseURL: PANEL_CONFIG.baseURL,
apiKeyConfigured: !!PANEL_CONFIG.apiKey
}
}
/**
* 生成 MD5 哈希值
* @param str 要加密的字符串
* @returns MD5哈希值
*/
function md5(str: string): string {
return SparkMD5.hash(str)
}
/**
* 生成 1Panel Token
* @param timestamp 时间戳(秒级)
* @returns Token字符串
*/
function generateToken(timestamp: number): string {
const tokenString = `1panel${PANEL_CONFIG.apiKey}${timestamp}`
return md5(tokenString)
}
/**
* 获取当前时间戳(秒级)
* @returns 时间戳
*/
function getTimestamp(): number {
return Math.floor(Date.now() / 1000)
}
// 创建 1Panel 专用的 axios 实例
const panelService: AxiosInstance = axios.create({
baseURL: PANEL_CONFIG.baseURL,
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器 - 添加 1Panel 认证头
panelService.interceptors.request.use(
(config) => {
const timestamp = getTimestamp()
const token = generateToken(timestamp)
// 调试日志
console.log('[1Panel API] 请求调试信息:', {
url: config.url,
apiKeyConfigured: !!PANEL_CONFIG.apiKey,
apiKeyLength: PANEL_CONFIG.apiKey?.length || 0,
timestamp,
tokenPreview: token.substring(0, 8) + '...'
})
// 添加 1Panel 认证头
config.headers['1Panel-Token'] = token
config.headers['1Panel-Timestamp'] = String(timestamp)
return config
},
(error) => {
console.error('1Panel 请求错误:', error)
return Promise.reject(error)
}
)
// 响应拦截器
panelService.interceptors.response.use(
(response) => {
return response
},
(error) => {
console.error('1Panel 响应错误:', error)
let errorMessage = '1Panel API 请求失败'
if (error.response) {
const { status, data } = error.response
switch (status) {
case 401:
errorMessage = 'API 接口密钥错误或已过期'
break
case 403:
errorMessage = '拒绝访问,请检查权限配置'
break
case 404:
errorMessage = '接口不存在'
break
case 500:
errorMessage = '1Panel 服务器错误'
break
default:
errorMessage = data?.message || `请求失败(${status})`
}
} else if (error.code === 'ECONNABORTED') {
errorMessage = '请求超时'
} else if (error.code === 'ERR_NETWORK') {
errorMessage = '网络连接失败,请检查服务器地址'
}
message.error(errorMessage)
return Promise.reject(error)
}
)
// ==================== API 接口定义 ====================
/**
* 网站信息类型
*/
export interface Website {
id: number
primaryDomain: string
alias: string
type: string
remark: string
status: string
expireDate: string
sitePath: string
appName: string
runtimeName: string
sslExpireDate: string
sslStatus: string
protocol: string
createdAt: string
updatedAt: string
}
/**
* 网站列表查询参数
*/
export interface WebsiteSearchParams {
page: number
pageSize: number
name?: string
websiteGroupId?: number
orderBy?: string
order?: string
}
/**
* 分页响应
*/
export interface PageResponse<T> {
items: T[]
total: number
}
/**
* 1Panel 标准响应
*/
export interface PanelResponse<T = unknown> {
code: number
message: string
data: T
}
/**
* 获取网站列表(搜索接口)
* @param params 查询参数
*/
export async function searchWebsites(params: WebsiteSearchParams): Promise<PageResponse<Website>> {
const response = await panelService.post<PanelResponse<PageResponse<Website>>>('/websites/search', {
...params,
name: params.name || '',
orderBy: params.orderBy || 'created_at',
order: params.order || 'descending'
})
return response.data.data
}
/**
* 获取所有网站列表(简化调用,用于下拉选择)
* 使用 /websites/list 接口,无需参数
*/
export async function getAllWebsites(): Promise<Website[]> {
const response = await panelService.get<PanelResponse<Website[]>>('/websites/list')
return response.data.data || []
}
// ==================== 证书相关 API ====================
/**
* SSL证书信息类型
*/
export interface SSLCertificate {
id: number
primaryDomain: string
domains: string
type: string
provider: string
organization: string
status: string
startDate: string
expireDate: string
autoRenew: boolean
acmeAccountId: number
dnsAccountId: number
description: string
createdAt: string
updatedAt: string
}
/**
* 证书列表查询参数
*/
export interface SSLSearchParams {
page: number
pageSize: number
info?: string
}
/**
* 获取证书列表
* @param params 查询参数
*/
export async function getSSLCertificates(params: SSLSearchParams): Promise<PageResponse<SSLCertificate>> {
const response = await panelService.post<PanelResponse<PageResponse<SSLCertificate>>>('/websites/ssl/search', {
...params,
orderBy: 'created_at',
order: 'descending'
})
return response.data.data
}
/**
* 根据 Acme 账户ID 获取证书列表
* @param acmeAccountID Acme账户ID
*/
export async function getSSLCertificatesByAcmeAccount(acmeAccountID: number | string): Promise<SSLCertificate[]> {
const response = await panelService.post<PanelResponse<PageResponse<SSLCertificate>>>('/websites/ssl/search', {
page: 1,
pageSize: 100,
acmeAccountID: String(acmeAccountID),
orderBy: 'created_at',
order: 'descending'
})
return response.data.data?.items || []
}
/**
* 根据域名查找匹配的证书
* @param domain 域名
* @param acmeAccountID Acme账户ID
* @returns 匹配的证书或 null
*/
export async function findCertificateByDomain(domain: string, acmeAccountID: number | string = 1): Promise<SSLCertificate | null> {
const certificates = await getSSLCertificatesByAcmeAccount(acmeAccountID)
// 查找主域名或其他域名匹配的证书
const matchedCert = certificates.find(cert => {
// 主域名匹配
if (cert.primaryDomain === domain) return true
// 检查其他域名
if (cert.domains) {
const otherDomains = cert.domains.split(',').map(d => d.trim())
if (otherDomains.includes(domain)) return true
}
return false
})
return matchedCert || null
}
/**
* Acme账户类型
*/
export interface AcmeAccount {
id: number
email: string
url: string
type: string
createdAt: string
}
/**
* 获取 ACME 账户列表
*/
export async function getAcmeAccounts(): Promise<PageResponse<AcmeAccount>> {
const response = await panelService.post<PanelResponse<PageResponse<AcmeAccount>>>('/websites/acme/search', {
page: 1,
pageSize: 100,
orderBy: 'created_at',
order: 'descending'
})
return response.data.data
}
/**
* DNS账户类型
*/
export interface DnsAccount {
id: number
name: string
type: string
authorization: string
createdAt: string
}
/**
* 获取 DNS 账户列表
*/
export async function getDnsAccounts(): Promise<PageResponse<DnsAccount>> {
const response = await panelService.post<PanelResponse<PageResponse<DnsAccount>>>('/websites/dns/search', {
page: 1,
pageSize: 100,
orderBy: 'created_at',
order: 'descending'
})
return response.data.data
}
/**
* 申请证书参数
*/
export interface ApplySSLParams {
primaryDomain: string
otherDomains: string
provider: string
acmeAccountId: number
dnsAccountId?: number
autoRenew: boolean
keyType: string
apply: boolean
pushDir?: boolean
dir?: string
id?: number
websiteId?: number
execShell?: boolean
shell?: string
skipDNS?: boolean
disableCNAME?: boolean
nameserverId?: number
}
/**
* 申请SSL证书
* @param params 申请参数
*/
export async function applySSLCertificate(params: ApplySSLParams): Promise<void> {
await panelService.post('/websites/ssl', params)
}
/**
* 删除SSL证书
* @param id 证书ID
*/
export async function deleteSSLCertificate(id: number): Promise<void> {
await panelService.post('/websites/ssl/del', { id })
}
/**
* 获取证书详情
* @param id 证书ID
*/
export async function getSSLCertificateDetail(id: number): Promise<SSLCertificate & { cert: string; key: string }> {
const response = await panelService.get<PanelResponse<SSLCertificate & { cert: string; key: string }>>(`/websites/ssl/${id}`)
return response.data.data
}
/**
* 更新证书设置
*/
export interface UpdateSSLParams {
id: number
autoRenew: boolean
description?: string
execShell?: boolean
shell?: string
}
/**
* 更新证书设置
* @param params 更新参数
*/
export async function updateSSLCertificate(params: UpdateSSLParams): Promise<void> {
await panelService.post('/websites/ssl/update', params)
}
// 导出 panelService 实例供其他地方使用
export default panelService

6
src/api/index.ts Normal file
View File

@@ -0,0 +1,6 @@
/**
* API 模块导出
*/
// 1Panel API
export * from './1panel'

1
src/assets/vue.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

91
src/auto-imports.d.ts vendored Normal file
View File

@@ -0,0 +1,91 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue').EffectScope
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
const axios: typeof import('axios').default
const computed: typeof import('vue').computed
const createApp: typeof import('vue').createApp
const createPinia: typeof import('pinia').createPinia
const customRef: typeof import('vue').customRef
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
const defineComponent: typeof import('vue').defineComponent
const defineStore: typeof import('pinia').defineStore
const effectScope: typeof import('vue').effectScope
const getActivePinia: typeof import('pinia').getActivePinia
const getCurrentInstance: typeof import('vue').getCurrentInstance
const getCurrentScope: typeof import('vue').getCurrentScope
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
const h: typeof import('vue').h
const inject: typeof import('vue').inject
const isProxy: typeof import('vue').isProxy
const isReactive: typeof import('vue').isReactive
const isReadonly: typeof import('vue').isReadonly
const isRef: typeof import('vue').isRef
const isShallow: typeof import('vue').isShallow
const mapActions: typeof import('pinia').mapActions
const mapGetters: typeof import('pinia').mapGetters
const mapState: typeof import('pinia').mapState
const mapStores: typeof import('pinia').mapStores
const mapWritableState: typeof import('pinia').mapWritableState
const markRaw: typeof import('vue').markRaw
const nextTick: typeof import('vue').nextTick
const onActivated: typeof import('vue').onActivated
const onBeforeMount: typeof import('vue').onBeforeMount
const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
const onDeactivated: typeof import('vue').onDeactivated
const onErrorCaptured: typeof import('vue').onErrorCaptured
const onMounted: typeof import('vue').onMounted
const onRenderTracked: typeof import('vue').onRenderTracked
const onRenderTriggered: typeof import('vue').onRenderTriggered
const onScopeDispose: typeof import('vue').onScopeDispose
const onServerPrefetch: typeof import('vue').onServerPrefetch
const onUnmounted: typeof import('vue').onUnmounted
const onUpdated: typeof import('vue').onUpdated
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
const provide: typeof import('vue').provide
const reactive: typeof import('vue').reactive
const readonly: typeof import('vue').readonly
const ref: typeof import('vue').ref
const resolveComponent: typeof import('vue').resolveComponent
const setActivePinia: typeof import('pinia').setActivePinia
const setMapStoreSuffix: typeof import('pinia').setMapStoreSuffix
const shallowReactive: typeof import('vue').shallowReactive
const shallowReadonly: typeof import('vue').shallowReadonly
const shallowRef: typeof import('vue').shallowRef
const storeToRefs: typeof import('pinia').storeToRefs
const toRaw: typeof import('vue').toRaw
const toRef: typeof import('vue').toRef
const toRefs: typeof import('vue').toRefs
const toValue: typeof import('vue').toValue
const triggerRef: typeof import('vue').triggerRef
const unref: typeof import('vue').unref
const useAttrs: typeof import('vue').useAttrs
const useCssModule: typeof import('vue').useCssModule
const useCssVars: typeof import('vue').useCssVars
const useId: typeof import('vue').useId
const useLink: typeof import('vue-router').useLink
const useModel: typeof import('vue').useModel
const useRoute: typeof import('vue-router').useRoute
const useRouter: typeof import('vue-router').useRouter
const useSlots: typeof import('vue').useSlots
const useTemplateRef: typeof import('vue').useTemplateRef
const watch: typeof import('vue').watch
const watchEffect: typeof import('vue').watchEffect
const watchPostEffect: typeof import('vue').watchPostEffect
const watchSyncEffect: typeof import('vue').watchSyncEffect
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

87
src/components.d.ts vendored Normal file
View File

@@ -0,0 +1,87 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AAlert: typeof import('ant-design-vue/es')['Alert']
AAvatar: typeof import('ant-design-vue/es')['Avatar']
ABadge: typeof import('ant-design-vue/es')['Badge']
ABreadcrumb: typeof import('ant-design-vue/es')['Breadcrumb']
ABreadcrumbItem: typeof import('ant-design-vue/es')['BreadcrumbItem']
AButton: typeof import('ant-design-vue/es')['Button']
ACard: typeof import('ant-design-vue/es')['Card']
ACheckbox: typeof import('ant-design-vue/es')['Checkbox']
ACol: typeof import('ant-design-vue/es')['Col']
ADatePicker: typeof import('ant-design-vue/es')['DatePicker']
ADescriptions: typeof import('ant-design-vue/es')['Descriptions']
ADescriptionsItem: typeof import('ant-design-vue/es')['DescriptionsItem']
ADivider: typeof import('ant-design-vue/es')['Divider']
ADrawer: typeof import('ant-design-vue/es')['Drawer']
ADropdown: typeof import('ant-design-vue/es')['Dropdown']
AEmpty: typeof import('ant-design-vue/es')['Empty']
AForm: typeof import('ant-design-vue/es')['Form']
AFormItem: typeof import('ant-design-vue/es')['FormItem']
AInput: typeof import('ant-design-vue/es')['Input']
AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
AInputSearch: typeof import('ant-design-vue/es')['InputSearch']
ALayout: typeof import('ant-design-vue/es')['Layout']
ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent']
ALayoutHeader: typeof import('ant-design-vue/es')['LayoutHeader']
ALayoutSider: typeof import('ant-design-vue/es')['LayoutSider']
AList: typeof import('ant-design-vue/es')['List']
AListItem: typeof import('ant-design-vue/es')['ListItem']
AListItemMeta: typeof import('ant-design-vue/es')['ListItemMeta']
AMenu: typeof import('ant-design-vue/es')['Menu']
AMenuDivider: typeof import('ant-design-vue/es')['MenuDivider']
AMenuItem: typeof import('ant-design-vue/es')['MenuItem']
AMenuItemGroup: typeof import('ant-design-vue/es')['MenuItemGroup']
AModal: typeof import('ant-design-vue/es')['Modal']
APageHeader: typeof import('ant-design-vue/es')['PageHeader']
APagination: typeof import('ant-design-vue/es')['Pagination']
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
ApprovalDrawer: typeof import('./components/ApprovalDrawer/index.vue')['default']
AProgress: typeof import('ant-design-vue/es')['Progress']
ARadio: typeof import('ant-design-vue/es')['Radio']
ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
ARangePicker: typeof import('ant-design-vue/es')['RangePicker']
AResult: typeof import('ant-design-vue/es')['Result']
ARow: typeof import('ant-design-vue/es')['Row']
ASelect: typeof import('ant-design-vue/es')['Select']
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
ASpace: typeof import('ant-design-vue/es')['Space']
ASpin: typeof import('ant-design-vue/es')['Spin']
AStatistic: typeof import('ant-design-vue/es')['Statistic']
AStep: typeof import('ant-design-vue/es')['Step']
ASteps: typeof import('ant-design-vue/es')['Steps']
ASubMenu: typeof import('ant-design-vue/es')['SubMenu']
ASwitch: typeof import('ant-design-vue/es')['Switch']
ATable: typeof import('ant-design-vue/es')['Table']
ATabPane: typeof import('ant-design-vue/es')['TabPane']
ATabs: typeof import('ant-design-vue/es')['Tabs']
ATag: typeof import('ant-design-vue/es')['Tag']
ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATimeline: typeof import('ant-design-vue/es')['Timeline']
ATimelineItem: typeof import('ant-design-vue/es')['TimelineItem']
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
ATree: typeof import('ant-design-vue/es')['Tree']
AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
DuplicateFileModal: typeof import('./components/DuplicateFileModal.vue')['default']
DynamicMenu: typeof import('./components/DynamicMenu/index.vue')['default']
FlowEditor: typeof import('./components/FlowEditor/index.vue')['default']
HelloWorld: typeof import('./components/HelloWorld.vue')['default']
ProjectUpload: typeof import('./components/ProjectUpload.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
UploadCore: typeof import('./components/UploadCore.vue')['default']
}
}

View File

@@ -0,0 +1,336 @@
<template>
<a-drawer
v-model:open="visible"
:title="title"
placement="right"
:width="600"
:destroy-on-close="true"
class="approval-drawer"
>
<!-- 业务信息 -->
<div class="business-info">
<a-descriptions :column="1" bordered size="small">
<a-descriptions-item label="业务类型">
<a-tag :color="scenarioColor">{{ scenarioLabel }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="业务标题">{{ businessTitle }}</a-descriptions-item>
<a-descriptions-item label="申请金额" v-if="amount">
<span class="money-amount">¥{{ amount.toLocaleString() }}</span>
</a-descriptions-item>
<a-descriptions-item label="申请人">{{ applicantName }}</a-descriptions-item>
<a-descriptions-item label="申请时间">{{ formatDate(applyTime) }}</a-descriptions-item>
<slot name="extra-info"></slot>
</a-descriptions>
</div>
<!-- 审批进度 -->
<div class="approval-progress">
<div class="section-title">
<CheckCircleOutlined /> 审批进度
</div>
<a-steps :current="currentStep" direction="vertical" size="small" class="approval-steps">
<a-step v-for="(node, index) in approvalNodes" :key="node.id" :status="getNodeStatus(node, index)">
<template #title>
<div class="step-title">
<span>{{ node.name }}</span>
<a-tag v-if="node.status" :color="getNodeStatusColor(node.status)" size="small">
{{ getNodeStatusText(node.status) }}
</a-tag>
</div>
</template>
<template #description>
<div class="step-desc">
<div class="step-approver">
<a-avatar v-if="node.approverAvatar" :src="node.approverAvatar" :size="20" />
<span>{{ node.approverName || getApproverTypeText(node.approverType) }}</span>
</div>
<div class="step-time" v-if="node.operatedAt">
{{ formatDate(node.operatedAt) }}
</div>
<div class="step-comment" v-if="node.comment">
<MessageOutlined /> {{ node.comment }}
</div>
</div>
</template>
</a-step>
</a-steps>
</div>
<!-- 审批操作区域 -->
<div class="approval-action" v-if="showAction">
<a-divider />
<div class="section-title">
<EditOutlined /> 审批操作
</div>
<a-form layout="vertical">
<a-form-item label="审批意见">
<a-textarea
v-model:value="approvalComment"
:rows="3"
placeholder="请输入审批意见(选填)"
:maxlength="500"
show-count
/>
</a-form-item>
<a-form-item label="审批结果">
<a-radio-group v-model:value="approvalResult" button-style="solid" size="large">
<a-radio-button value="approve">
<CheckCircleOutlined /> 通过
</a-radio-button>
<a-radio-button value="reject">
<CloseCircleOutlined /> 驳回
</a-radio-button>
</a-radio-group>
</a-form-item>
</a-form>
</div>
<!-- 底部按钮 -->
<template #footer>
<div class="drawer-footer">
<a-button @click="handleClose">取消</a-button>
<a-button
v-if="showAction"
type="primary"
:loading="submitting"
:danger="approvalResult === 'reject'"
@click="handleSubmit"
>
{{ approvalResult === 'approve' ? '确认通过' : '确认驳回' }}
</a-button>
</div>
</template>
</a-drawer>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { message } from 'ant-design-vue'
import {
CheckCircleOutlined,
CloseCircleOutlined,
EditOutlined,
MessageOutlined
} from '@ant-design/icons-vue'
import { ApprovalScenarioMap, ApproverTypeMap } from '@/types/approval'
import type { ApprovalScenario, ApproverType } from '@/types/approval'
interface ApprovalNode {
id: number
name: string
approverType: ApproverType
approverName?: string
approverAvatar?: string
status?: 'pending' | 'approved' | 'rejected' | 'skipped'
comment?: string
operatedAt?: string
order: number
}
interface Props {
open: boolean
title?: string
scenario: ApprovalScenario
businessTitle: string
amount?: number
applicantName: string
applyTime: string
approvalNodes: ApprovalNode[]
currentNodeIndex?: number
showAction?: boolean
}
const props = withDefaults(defineProps<Props>(), {
title: '审批详情',
showAction: true,
currentNodeIndex: 0
})
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
(e: 'approve', data: { approved: boolean; comment: string }): void
}>()
const visible = computed({
get: () => props.open,
set: (val) => emit('update:open', val)
})
const approvalComment = ref('')
const approvalResult = ref<'approve' | 'reject'>('approve')
const submitting = ref(false)
const scenarioLabel = computed(() => {
return ApprovalScenarioMap[props.scenario] || props.scenario
})
const scenarioColor = computed(() => {
const colorMap: Record<string, string> = {
expense_reimbursement: 'orange',
payment_request: 'blue',
purchase_request: 'purple',
budget_adjustment: 'gold',
invoice_apply: 'cyan',
withdrawal: 'green',
contract: 'magenta'
}
return colorMap[props.scenario] || 'blue'
})
const currentStep = computed(() => {
return props.currentNodeIndex
})
function getNodeStatus(node: ApprovalNode, index: number): 'wait' | 'process' | 'finish' | 'error' {
if (node.status === 'approved') return 'finish'
if (node.status === 'rejected') return 'error'
if (index === props.currentNodeIndex) return 'process'
if (index < props.currentNodeIndex) return 'finish'
return 'wait'
}
function getNodeStatusColor(status: string): string {
const colorMap: Record<string, string> = {
pending: 'default',
approved: 'green',
rejected: 'red',
skipped: 'orange'
}
return colorMap[status] || 'default'
}
function getNodeStatusText(status: string): string {
const textMap: Record<string, string> = {
pending: '待审批',
approved: '已通过',
rejected: '已驳回',
skipped: '已跳过'
}
return textMap[status] || status
}
function getApproverTypeText(type: ApproverType): string {
return ApproverTypeMap[type] || type
}
function formatDate(dateStr: string): string {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
function handleClose() {
visible.value = false
approvalComment.value = ''
approvalResult.value = 'approve'
}
async function handleSubmit() {
submitting.value = true
try {
emit('approve', {
approved: approvalResult.value === 'approve',
comment: approvalComment.value
})
message.success(approvalResult.value === 'approve' ? '审批通过' : '已驳回')
handleClose()
} catch (error) {
message.error('操作失败')
} finally {
submitting.value = false
}
}
watch(() => props.open, (newVal) => {
if (newVal) {
approvalComment.value = ''
approvalResult.value = 'approve'
}
})
</script>
<style scoped>
.approval-drawer :deep(.ant-drawer-body) {
padding: 16px;
}
.business-info {
margin-bottom: 24px;
}
.money-amount {
color: #ff4d4f;
font-weight: 600;
font-size: 16px;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #1a1a2e;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.approval-progress {
margin-bottom: 24px;
}
.approval-steps {
padding-left: 8px;
}
.step-title {
display: flex;
align-items: center;
gap: 8px;
}
.step-desc {
font-size: 13px;
color: #666;
}
.step-approver {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.step-time {
font-size: 12px;
color: #999;
margin-bottom: 4px;
}
.step-comment {
font-size: 12px;
color: #666;
background: #f5f5f5;
padding: 6px 10px;
border-radius: 4px;
margin-top: 6px;
}
.approval-action {
margin-top: 16px;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,133 @@
<template>
<a-modal
:open="visible"
title="同名文件提示"
:footer="null"
width="600px"
:mask-closable="false"
@cancel="handleSkipAll"
>
<div class="duplicate-file-modal">
<!-- 警告提示 -->
<a-alert
message="上传的文件存在同名文件,是否覆盖?"
type="warning"
show-icon
class="warning-alert"
/>
<!-- 文件列表 -->
<a-table
:columns="columns"
:data-source="duplicateFiles"
:pagination="false"
size="small"
class="file-table"
:scroll="{ y: 300 }"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'index'">
{{ index + 1 }}
</template>
<template v-if="column.key === 'name'">
<span class="file-name">{{ record.path || record.name }}</span>
</template>
<template v-if="column.key === 'size'">
<span class="file-size">{{ formatSize(record.size) }}</span>
</template>
</template>
</a-table>
<!-- 底部按钮 -->
<div class="modal-footer">
<a-space>
<a-button @click="handleSkipAll">跳过</a-button>
<a-button type="primary" @click="handleOverwriteAll">覆盖</a-button>
</a-space>
</div>
</div>
</a-modal>
</template>
<script setup lang="ts">
import type { DuplicateFile } from '@/types'
defineProps<{
visible: boolean
duplicateFiles: DuplicateFile[]
}>()
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
(e: 'skip-all'): void
(e: 'overwrite-all'): void
}>()
const columns = [
{ title: '序号', key: 'index', width: 60 },
{ title: '名称', key: 'name', ellipsis: true },
{ title: '文件大小', key: 'size', width: 120 }
]
/**
* 格式化文件大小
*/
function formatSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
/**
* 跳过所有
*/
function handleSkipAll() {
emit('skip-all')
emit('update:visible', false)
}
/**
* 覆盖所有
*/
function handleOverwriteAll() {
emit('overwrite-all')
emit('update:visible', false)
}
</script>
<style scoped>
.duplicate-file-modal {
display: flex;
flex-direction: column;
gap: 16px;
}
.warning-alert {
margin-bottom: 8px;
}
.file-table {
border: 1px solid #f0f0f0;
border-radius: 4px;
}
.file-name {
font-size: 13px;
color: #333;
word-break: break-all;
}
.file-size {
font-size: 13px;
color: #666;
}
.modal-footer {
display: flex;
justify-content: flex-end;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
</style>

View File

@@ -0,0 +1,85 @@
<template>
<a-menu
v-model:selectedKeys="selectedKeys"
v-model:openKeys="openKeys"
mode="inline"
theme="dark"
@click="handleMenuClick"
>
<template v-for="menu in menuConfig" :key="menu.key">
<!-- 单个菜单项 -->
<a-menu-item v-if="!menu.children" :key="`item-${menu.key}`">
<component :is="menu.icon" v-if="menu.icon" />
<span>{{ menu.label }}</span>
</a-menu-item>
<!-- 子菜单 -->
<a-sub-menu v-else :key="`sub-${menu.key}`">
<template #title>
<component :is="menu.icon" v-if="menu.icon" />
<span>{{ menu.label }}</span>
</template>
<a-menu-item v-for="child in menu.children" :key="child.key">
{{ child.label }}
</a-menu-item>
</a-sub-menu>
</template>
</a-menu>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { getMenuConfig, getMenuRouteMap, type MenuItem } from '@/config'
const router = useRouter()
const route = useRoute()
const menuConfig = getMenuConfig()
const menuRouteMap = getMenuRouteMap()
const selectedKeys = ref<string[]>(['dashboard'])
const openKeys = ref<string[]>([])
// 菜单点击
function handleMenuClick({ key }: { key: string | number }) {
const path = menuRouteMap[String(key)]
if (path) {
router.push(path)
}
}
// 根据路由更新菜单选中状态
function updateMenuState() {
const path = route.path
// 查找匹配的菜单项
for (const [menuKey, menuPath] of Object.entries(menuRouteMap)) {
if (path === menuPath || path.startsWith(menuPath + '/')) {
selectedKeys.value = [menuKey]
// 查找父级菜单并展开
for (const menu of menuConfig) {
if (menu.children?.some(child => child.key === menuKey)) {
if (!openKeys.value.includes(menu.key)) {
openKeys.value = [...openKeys.value, menu.key]
}
break
}
}
break
}
}
}
watch(() => route.path, updateMenuState, { immediate: true })
onMounted(() => {
updateMenuState()
})
defineExpose({
selectedKeys,
openKeys
})
</script>

View File

@@ -0,0 +1,694 @@
<template>
<div class="flow-editor">
<!-- 工具栏 -->
<div class="toolbar">
<div class="toolbar-left">
<span class="toolbar-title">流程设计器</span>
<span class="toolbar-tip">垂直流向从上到下连接节点</span>
</div>
<div class="toolbar-right">
<a-button size="small" @click="layoutCheck">
<OrderedListOutlined /> 垂直对齐
</a-button>
<a-button size="small" @click="handleFitView">
<FullscreenOutlined /> 适应画布
</a-button>
</div>
</div>
<div class="flow-container">
<!-- 节点面板 -->
<div class="node-panel">
<div class="panel-title">节点库</div>
<div class="node-list">
<div
v-for="nt in nodeTypeList"
:key="nt.type"
class="node-item"
draggable="true"
@dragstart="(e) => onDragStart(e, nt)"
>
<component :is="nt.icon" class="node-icon" />
<span>{{ nt.label }}</span>
</div>
</div>
</div>
<!-- 流程画布 -->
<div
ref="flowCanvasRef"
class="flow-canvas"
@drop="onDrop"
@dragover.prevent
>
<VueFlow
v-model:nodes="nodes"
v-model:edges="edges"
:default-viewport="{ zoom: 1 }"
:min-zoom="0.5"
:max-zoom="1.5"
:connection-mode="ConnectionMode.Loose"
:default-edge-options="defaultEdgeOptions"
:delete-key-code="['Backspace', 'Delete']"
fit-view-on-init
@connect="onConnect"
@node-click="onNodeClick"
@pane-click="onPaneClick"
>
<Background pattern-color="#aaa" :gap="15" />
<Controls />
<!-- 开始节点 -->
<template #node-start="{ data }">
<div class="custom-node start-node">
<div class="node-content-wrapper center-content">
<PlayCircleOutlined class="node-type-icon" />
<span>{{ data.label }}</span>
</div>
<Handle type="source" :position="Position.Bottom" class="custom-handle" />
</div>
</template>
<!-- 审批节点 -->
<template #node-approval="{ data, selected }">
<div
class="custom-node approval-node"
:class="{ selected: selected }"
>
<Handle type="target" :position="Position.Top" class="custom-handle" />
<div class="node-content-wrapper">
<div class="node-header">
<CheckCircleOutlined class="node-type-icon" />
<span class="node-title">{{ data.label }}</span>
</div>
<div class="node-body">
<div class="node-desc">{{ data.approverDesc || '请配置审批人' }}</div>
</div>
</div>
<Handle type="source" :position="Position.Bottom" class="custom-handle" />
</div>
</template>
<!-- 条件节点 -->
<template #node-condition="{ data, selected }">
<div
class="custom-node condition-node"
:class="{ selected: selected }"
>
<Handle type="target" :position="Position.Top" class="custom-handle" />
<div class="node-content-wrapper">
<div class="node-header">
<BranchesOutlined class="node-type-icon" />
<span class="node-title">{{ data.label }}</span>
</div>
<div class="node-body">
<div class="node-desc">{{ data.conditionDesc || '请配置条件' }}</div>
</div>
</div>
<Handle type="source" :position="Position.Bottom" class="custom-handle" />
</div>
</template>
<!-- 结束节点 -->
<template #node-end="{ data }">
<div class="custom-node end-node">
<Handle type="target" :position="Position.Top" class="custom-handle" />
<div class="node-content-wrapper center-content">
<StopOutlined class="node-type-icon" />
<span>{{ data.label }}</span>
</div>
</div>
</template>
</VueFlow>
</div>
<!-- 节点配置面板 -->
<div class="config-panel">
<template v-if="selectedNode">
<div class="panel-header">
<span>节点配置</span>
<a-button type="text" size="small" @click="clearSelection">
<CloseOutlined />
</a-button>
</div>
<div class="panel-body">
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }" size="small">
<a-form-item label="节点名称">
<a-input
:value="selectedNode.data.label"
placeholder="请输入名称"
@input="(e: Event) => updateNodeData('label', (e.target as HTMLInputElement).value)"
/>
</a-form-item>
<template v-if="selectedNode.type === 'approval'">
<a-form-item label="审批人类型">
<a-select
:value="selectedNode.data.approverType"
@change="(val: any) => { updateNodeData('approverType', val); updateApproverDesc() }"
>
<a-select-option v-for="(label, key) in ApproverTypeMap" :key="key" :value="key">
{{ label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="指定人员" v-if="selectedNode.data.approverType === 'specified'">
<a-select
:value="selectedNode.data.approverIds"
mode="multiple"
placeholder="选择审批人"
@change="(val: any) => { updateNodeData('approverIds', val); updateApproverDesc() }"
>
<a-select-option v-for="approver in approverList" :key="approver.id" :value="approver.id">
{{ approver.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="审批角色" v-if="selectedNode.data.approverType === 'role'">
<a-input
:value="selectedNode.data.approverRole"
placeholder="如:财务主管"
@input="(e: Event) => { updateNodeData('approverRole', (e.target as HTMLInputElement).value); updateApproverDesc() }"
/>
</a-form-item>
<a-form-item label="审批方式">
<a-radio-group
:value="selectedNode.data.approvalMode"
@change="(e: any) => updateNodeData('approvalMode', e.target.value)"
>
<a-radio value="or">或签</a-radio>
<a-radio value="and">会签</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="超时时间">
<a-input-number
:value="selectedNode.data.timeoutHours"
:min="0"
addon-after="小时"
style="width: 100%"
@change="(val: any) => updateNodeData('timeoutHours', val)"
/>
</a-form-item>
</template>
<template v-if="selectedNode.type === 'condition'">
<a-form-item label="条件表达式">
<a-textarea
:value="selectedNode.data.conditionExpr"
placeholder="如amount > 10000"
:rows="3"
@input="(e: Event) => updateNodeData('conditionExpr', (e.target as HTMLTextAreaElement).value)"
/>
</a-form-item>
</template>
</a-form>
</div>
<div class="panel-footer">
<a-button type="primary" danger size="small" @click="deleteSelectedNode">
<DeleteOutlined /> 删除节点
</a-button>
</div>
</template>
<template v-else>
<div class="no-selection">
<InfoCircleOutlined />
<span>点击节点进行配置</span>
</div>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { VueFlow, Position, useVueFlow, ConnectionMode } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
import { Handle } from '@vue-flow/core'
import type { Node, Edge, Connection } from '@vue-flow/core'
import {
PlayCircleOutlined,
CheckCircleOutlined,
BranchesOutlined,
StopOutlined,
FullscreenOutlined,
OrderedListOutlined,
CloseOutlined,
DeleteOutlined,
InfoCircleOutlined
} from '@ant-design/icons-vue'
import type { ApprovalNode, ApproverInfo } from '@/types'
import { ApproverTypeMap } from '@/types'
import { mockGetApprovers } from '@/mock'
interface Props {
modelValue?: ApprovalNode[]
}
interface Emits {
(e: 'update:modelValue', value: ApprovalNode[]): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const defaultEdgeOptions = {
type: 'smoothstep',
animated: true,
style: { stroke: '#1890ff', strokeWidth: 2 },
label: '按 Delete 删除'
}
const { fitView } = useVueFlow()
const flowCanvasRef = ref<HTMLElement>()
// 重新回归使用 v-model这是 VueFlow 最稳定最简单的用法
const nodes = ref<Node[]>([
{
id: 'start',
type: 'start',
position: { x: 300, y: 50 },
data: { label: '开始' },
},
{
id: 'end',
type: 'end',
position: { x: 300, y: 400 },
data: { label: '结束' },
}
])
const edges = ref<Edge[]>([
{
id: 'e-start-end',
source: 'start',
target: 'end'
}
])
const selectedNodeId = ref<string | null>(null)
const approverList = ref<ApproverInfo[]>([])
const nodeTypeList = [
{ type: 'approval', label: '审批节点', icon: CheckCircleOutlined },
{ type: 'condition', label: '条件节点', icon: BranchesOutlined }
]
const selectedNode = computed(() => {
if (!selectedNodeId.value) return null
return nodes.value.find(n => n.id === selectedNodeId.value) || null
})
// 拖拽初始逻辑
function onDragStart(event: DragEvent, nodeType: { type: string; label: string }) {
if (event.dataTransfer) {
event.dataTransfer.setData('application/vueflow', JSON.stringify(nodeType))
event.dataTransfer.effectAllowed = 'move'
}
}
function onDrop(event: DragEvent) {
event.preventDefault()
const data = event.dataTransfer?.getData('application/vueflow')
if (!data) return
const nodeType = JSON.parse(data)
const canvas = flowCanvasRef.value
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const dropX = event.clientX - rect.left - 100
const dropY = event.clientY - rect.top - 20
const newNode: Node = {
id: `${nodeType.type}_${Date.now()}`,
type: nodeType.type,
position: { x: dropX, y: dropY },
data: {
label: nodeType.label,
approverType: 'specified',
approverIds: [],
approvalMode: 'or',
timeoutHours: 0
}
}
nodes.value = [...nodes.value, newNode]
}
// 连接逻辑
function onConnect(connection: Connection) {
edges.value = [
...edges.value,
{
id: `e-${connection.source}-${connection.target}-${Date.now()}`,
source: connection.source!,
target: connection.target!
}
]
}
// 节点点击
function onNodeClick({ node }: { node: Node }) {
if (['approval', 'condition'].includes(node.type || '')) {
selectedNodeId.value = node.id
}
}
function onPaneClick() {
selectedNodeId.value = null
}
function clearSelection() {
selectedNodeId.value = null
}
// 简单的垂直对齐保留X轴相对位置
function layoutCheck() {
const sortedNodes = [...nodes.value].sort((a, b) => a.position.y - b.position.y)
let currentY = 50
const spacingY = 120
nodes.value = sortedNodes.map(node => {
const newNode = { ...node, position: { x: node.position.x, y: currentY } }
currentY += spacingY
return newNode
})
}
function updateNodeData(key: string, value: any) {
if (!selectedNodeId.value) return
nodes.value = nodes.value.map(node => {
if (node.id === selectedNodeId.value) {
return { ...node, data: { ...node.data, [key]: value } }
}
return node
})
}
function updateApproverDesc() {
if (!selectedNode.value) return
const node = selectedNode.value
let desc = ''
if (node.data.approverType === 'specified' && node.data.approverIds?.length) {
const names = node.data.approverIds.map((id: number) => {
const approver = approverList.value.find(a => a.id === id)
return approver?.name || ''
}).filter(Boolean)
desc = names.join(', ')
} else if (node.data.approverType === 'role' && node.data.approverRole) {
desc = `角色: ${node.data.approverRole}`
} else {
desc = ApproverTypeMap[node.data.approverType as keyof typeof ApproverTypeMap] || ''
}
updateNodeData('approverDesc', desc)
}
function deleteSelectedNode() {
if (!selectedNodeId.value) return
const nodeId = selectedNodeId.value
nodes.value = nodes.value.filter(n => n.id !== nodeId)
edges.value = edges.value.filter(e => e.source !== nodeId && e.target !== nodeId)
selectedNodeId.value = null
}
function handleFitView() {
fitView()
}
// 数据转换
function toApprovalNodes(): any[] {
return nodes.value
.filter(n => n.type === 'approval' || n.type === 'condition')
.sort((a, b) => a.position.y - b.position.y)
.map((n, index) => ({
id: parseInt(n.id.split('_')[1] || String(Date.now())),
name: n.data.label,
approverType: n.data.approverType,
approverIds: n.data.approverIds,
approverRole: n.data.approverRole,
approvalMode: n.data.approvalMode,
timeoutHours: n.data.timeoutHours,
order: index + 1
}))
}
function loadFromApprovalNodes(approvalNodes: ApprovalNode[]) {
const centerX = 300
let currentY = 50
const nodeList: Node[] = []
nodeList.push({ id: 'start', type: 'start', position: { x: centerX, y: currentY }, data: { label: '开始' } })
currentY += 120
approvalNodes.forEach((node:any) => {
// 解析审批人描述
let desc = ''
if (node.approverType === 'specified' && node.approverIds?.length) {
const names = node.approverIds.map((id:any) => {
const approver = approverList.value.find(a => a.id === id)
return approver?.name || ''
}).filter(Boolean)
desc = names.join(', ')
} else if (node.approverType === 'role' && node.approverRole) {
desc = `角色: ${node.approverRole}`
} else {
desc = ApproverTypeMap[node.approverType as keyof typeof ApproverTypeMap] || ''
}
nodeList.push({
id: `approval_${node.id}`,
type: 'approval',
position: { x: centerX, y: currentY },
data: {
label: node.name,
approverType: node.approverType,
approverIds: node.approverIds || [],
approverRole: node.approverRole,
approvalMode: node.approvalMode,
timeoutHours: node.timeoutHours,
approverDesc: desc
}
})
currentY += 120
})
nodeList.push({ id: 'end', type: 'end', position: { x: centerX, y: currentY }, data: { label: '结束' } })
nodes.value = nodeList
const newEdges: Edge[] = []
for (let i = 0; i < nodeList.length - 1; i++) {
newEdges.push({
id: `e-${nodeList[i]!.id}-${nodeList[i+1]!.id}`,
source: nodeList[i]!.id,
target: nodeList[i+1]!.id,
})
}
edges.value = newEdges
}
watch(nodes, () => {
emit('update:modelValue', toApprovalNodes())
}, { deep: true })
// 监听 props 变化,支持动态加载
watch(() => props.modelValue, (newVal) => {
if (newVal?.length) {
// 只有当当前编辑器为空或者需要强制刷新时才加载?
// 为了简单,如果外部传入了新值,且不是空数组,我们尝试加载
// 但要注意不要覆盖用户未保存的编辑。
// 由于 destroyOnClose=true通常只有初始化时会触发。
// 这里加一个简单判断如果当前只有start/end则加载。
if (nodes.value.length <= 2) {
loadFromApprovalNodes(newVal)
}
}
}, { immediate: true })
onMounted(async () => {
approverList.value = await mockGetApprovers()
// 加载数据,此时 approverList 已就绪,由于 watch immediate 可能会先于 onMounted 执行(但 mock 没回),
// 所以这里需要再次检查并重新渲染描述(如果需要)。
// 更好的方式是mock 回来后,如果已经有节点,刷新描述。
// 或者mock 回来后,再调用一次 loadFromApprovalNodes。
if (props.modelValue?.length) {
loadFromApprovalNodes(props.modelValue)
}
})
defineExpose({
toApprovalNodes,
loadFromApprovalNodes
})
</script>
<style>
/* import the necessary styles for Vue Flow to work */
@import '@vue-flow/core/dist/style.css';
/* import the default theme, this is optional but generally recommended */
@import '@vue-flow/core/dist/theme-default.css';
@import '@vue-flow/controls/dist/style.css';
@import '@vue-flow/minimap/dist/style.css';
/* Edge Selection Styles */
.vue-flow__edge.selected .vue-flow__edge-path {
stroke: #ff4d4f !important;
stroke-width: 3 !important;
}
/* Hide label by default */
.vue-flow__edge .vue-flow__edge-text-wrapper {
display: none;
}
/* Show label when selected */
.vue-flow__edge.selected .vue-flow__edge-text-wrapper {
display: block;
}
/* Style the label */
.vue-flow__edge .vue-flow__edge-text {
font-size: 10px;
fill: #ff4d4f;
}
.vue-flow__edge .vue-flow__edge-text-bg {
fill: #fff1f0;
rx: 4;
ry: 4;
}
</style>
<style scoped>
.flow-editor {
display: flex;
flex-direction: column;
height: 500px;
border: 1px solid #e8e8e8;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: #fafafa;
border-bottom: 1px solid #e8e8e8;
}
.toolbar-title { font-weight: 600; color: #333; }
.toolbar-tip { font-size: 12px; color: #999; margin-left: 8px; }
.toolbar-right { display: flex; gap: 8px; }
.flow-container { display: flex; flex: 1; min-height: 0; }
.node-panel {
width: 160px;
background: #fafafa;
border-right: 1px solid #e8e8e8;
padding: 12px;
}
.panel-title { font-size: 12px; color: #8c8c8c; margin-bottom: 12px; font-weight: 500; }
.node-list { display: flex; flex-direction: column; gap: 8px; }
.node-item {
display: flex; align-items: center; gap: 8px; padding: 10px 12px;
background: #fff; border: 1px solid #e8e8e8; border-radius: 6px;
cursor: grab; font-size: 13px; transition: all 0.2s;
}
.node-item:hover { border-color: #1890ff; box-shadow: 0 2px 8px rgba(24, 144, 255, 0.15); }
.node-item .node-icon { font-size: 16px; color: #1890ff; }
.flow-canvas { flex: 1; background: #f5f7fa; }
/* Custom Node Styles */
.custom-node {
width: 200px;
background: #fff;
border-radius: 8px;
font-size: 13px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
cursor: pointer;
border: 1px solid transparent;
transition: all 0.2s;
overflow: visible; /* 必须 visible 否则 Handle 可能被遮挡 */
position: relative; /* 定位 Handle */
}
.custom-node:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); z-index: 10; }
.custom-node.selected { border-color: #1890ff; box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2); z-index: 10; }
.node-content-wrapper { padding: 10px 12px; }
.center-content { display: flex; justify-content: center; align-items: center; gap: 8px; }
.node-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; padding-bottom: 6px; border-bottom: 1px solid #f0f0f0; }
.node-title { font-weight: 500; color: #333; font-size: 13px; }
.node-desc { font-size: 12px; color: #8c8c8c; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Colors */
.start-node {
width: 120px;
background: #f6ffed;
border: 1px solid #b7eb8f;
color: #52c41a;
text-align: center;
}
.end-node {
width: 120px;
background: #fff1f0;
border: 1px solid #ffa39e;
color: #f5222d;
text-align: center;
}
.approval-node { border: 1px solid #e8e8e8; }
.approval-node .node-type-icon { color: #1890ff; }
.condition-node { border: 1px solid #ffe58f; background: #fffbe6; }
.condition-node .node-type-icon { color: #faad14; }
/* Config Panel */
.config-panel { width: 280px; background: #fff; border-left: 1px solid #e8e8e8; display: flex; flex-direction: column; }
.panel-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-bottom: 1px solid #f0f0f0; font-weight: 500; }
.panel-body { flex: 1; padding: 16px; overflow-y: auto; }
.panel-footer { padding: 12px 16px; border-top: 1px solid #f0f0f0; }
.no-selection { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: #8c8c8c; gap: 8px; }
.no-selection .anticon { font-size: 32px; }
/* Handle Styling - 关键:自定义 Handle 样式使其可见、易点击 */
:deep(.custom-handle) {
width: 10px;
height: 10px;
background: #1890ff;
border: 2px solid #fff;
border-radius: 50%;
z-index: 10;
}
:deep(.custom-handle:hover) {
background: #40a9ff;
transform: scale(1.2);
}
:deep(.vue-flow__node) { cursor: grab; }
:deep(.vue-flow__node.dragging) { cursor: grabbing; z-index: 1000; }
</style>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ msg: string }>()
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

View File

@@ -0,0 +1,221 @@
<template>
<!-- 抽屉模式 -->
<a-drawer
v-if="mode === 'drawer'"
:open="visible"
:width="600"
placement="right"
:closable="true"
:mask-closable="false"
@close="handleCancel"
>
<template #title>
<div class="upload-drawer-header">
<span class="header-title">{{ title || '上传' }}</span>
</div>
</template>
<div class="upload-content">
<UploadCore
ref="uploadCoreRef"
:auto-upload="false"
:flex-mode="true"
@files-change="handleFilesChange"
/>
</div>
<template #footer>
<div class="upload-footer">
<a-space>
<a-button @click="handleCancel">取消</a-button>
<a-button
type="primary"
@click="handleConfirm"
:loading="uploading"
:disabled="fileCount === 0"
>
确定上传 ({{ fileCount }})
</a-button>
</a-space>
</div>
</template>
</a-drawer>
<!-- 弹窗模式 -->
<a-modal
v-else
:open="visible"
:title="title || '上传'"
:footer="null"
width="700px"
:mask-closable="false"
:body-style="{ height: '65vh', display: 'flex', flexDirection: 'column' }"
@cancel="handleCancel"
>
<div class="upload-modal-content">
<UploadCore
ref="uploadCoreRef"
:auto-upload="false"
:flex-mode="true"
@files-change="handleFilesChange"
/>
<div class="upload-modal-footer">
<a-space>
<a-button @click="handleCancel">取消</a-button>
<a-button
type="primary"
@click="handleConfirm"
:loading="uploading"
:disabled="fileCount === 0"
>
确定上传 ({{ fileCount }})
</a-button>
</a-space>
</div>
</div>
</a-modal>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { message, Modal } from 'ant-design-vue'
import UploadCore from './UploadCore.vue'
const props = withDefaults(defineProps<{
visible: boolean
projectId?: string
projectName?: string
versionId?: string
title?: string
mode?: 'drawer' | 'modal'
}>(), {
mode: 'drawer'
})
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
(e: 'uploaded', files: any[]): void
}>()
const uploadCoreRef = ref<InstanceType<typeof UploadCore> | null>(null)
const uploading = ref(false)
const fileCount = ref(0)
// 监听visible变化
watch(() => props.visible, (newVal) => {
if (!newVal) {
uploading.value = false
fileCount.value = 0
}
})
/**
* 文件列表变化
*/
function handleFilesChange(count: number) {
fileCount.value = count
}
/**
* 取消
*/
function handleCancel() {
if (uploading.value) {
Modal.confirm({
title: '确认取消',
content: '当前有文件正在上传,确定要取消吗?',
onOk() {
emit('update:visible', false)
}
})
} else if (fileCount.value > 0) {
Modal.confirm({
title: '确认取消',
content: '已选择的文件将被清空,确定要取消吗?',
onOk() {
emit('update:visible', false)
}
})
} else {
emit('update:visible', false)
}
}
/**
* 确定上传
*/
async function handleConfirm() {
if (!uploadCoreRef.value || fileCount.value === 0) return
uploading.value = true
try {
const files = await uploadCoreRef.value.startUpload()
message.success('上传完成!')
emit('uploaded', files)
emit('update:visible', false)
} catch (error) {
message.error('上传失败')
} finally {
uploading.value = false
}
}
</script>
<style scoped>
.upload-drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.close-icon {
font-size: 16px;
cursor: pointer;
color: #666;
}
.close-icon:hover {
color: #1890ff;
}
.header-title {
font-size: 16px;
font-weight: 500;
color: #333;
}
.upload-content {
display: flex;
flex-direction: column;
height: calc(100vh - 160px);
overflow: hidden;
}
.upload-footer {
display: flex;
justify-content: flex-end;
}
.upload-modal-content {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.upload-modal-footer {
display: flex;
justify-content: flex-end;
padding-top: 16px;
margin-top: auto;
border-top: 1px solid #f0f0f0;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,612 @@
<template>
<div class="upload-core" :class="{ 'upload-core--flex': flexMode }">
<!-- 操作按钮 -->
<div class="upload-actions">
<a-space>
<a-button type="primary" @click="selectFiles">
<UploadOutlined /> 上传文件
</a-button>
<a-button type="primary" @click="selectFolder">
<FolderOpenOutlined /> 上传文件夹
</a-button>
</a-space>
<a-button @click="clearList" :disabled="fileList.length === 0">
清空列表
</a-button>
</div>
<!-- 拖拽上传区域 -->
<div
class="upload-drop-zone"
:class="{ 'is-dragover': isDragover }"
@dragover.prevent="handleDragover"
@dragleave.prevent="handleDragleave"
@drop.prevent="handleDrop"
>
<div class="drop-zone-content">
<CloudUploadOutlined class="drop-icon" />
<p class="drop-text">将需要上传的文件拖曳到此处</p>
</div>
</div>
<!-- 当前上传进度 -->
<div v-if="currentUploadFile" class="current-upload">
<span class="current-file-name">
正在上传{{ currentUploadFile.name }}....
</span>
<a-progress
:percent="currentUploadFile.percent"
:status="currentUploadFile.percent === 100 ? 'success' : 'active'"
:show-info="true"
/>
</div>
<!-- 文件列表 -->
<div class="file-list" :class="{ 'file-list--flex': flexMode }">
<div
v-for="(file, index) in fileList"
:key="file.uid"
class="file-item"
>
<div class="file-info">
<FileOutlined class="file-icon" />
<span class="file-path">{{ file.path || file.name }}</span>
</div>
<div class="file-status">
<CheckOutlined
v-if="file.status === 'done'"
class="status-done"
/>
<CloseOutlined
v-else-if="file.status === 'error'"
class="status-error"
/>
<span
v-else
class="status-pending"
@click="removeFile(index)"
>×</span>
</div>
</div>
<a-empty v-if="fileList.length === 0" description="暂无文件" class="empty-list" />
</div>
<!-- 隐藏的文件输入 -->
<input
ref="fileInputRef"
type="file"
multiple
style="display: none;"
@change="handleFileSelect"
/>
<input
ref="folderInputRef"
type="file"
webkitdirectory
directory
multiple
style="display: none;"
@change="handleFolderSelect"
/>
<!-- 同名文件提示 -->
<DuplicateFileModal
v-model:visible="duplicateModalVisible"
:duplicate-files="duplicateFiles"
@skip-all="handleSkipDuplicates"
@overwrite-all="handleOverwriteDuplicates"
/>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { message } from 'ant-design-vue'
import {
UploadOutlined,
FolderOpenOutlined,
CloudUploadOutlined,
FileOutlined,
CheckOutlined,
CloseOutlined
} from '@ant-design/icons-vue'
import type { UploadFile, DuplicateFile } from '@/types'
import DuplicateFileModal from './DuplicateFileModal.vue'
const props = withDefaults(defineProps<{
autoUpload?: boolean
flexMode?: boolean
existingFiles?: string[] // 已存在的文件路径列表
}>(), {
autoUpload: false,
flexMode: false,
existingFiles: () => []
})
const emit = defineEmits<{
(e: 'files-change', count: number): void
(e: 'upload-complete', files: UploadFile[]): void
}>()
// 文件输入引用
const fileInputRef = ref<HTMLInputElement | null>(null)
const folderInputRef = ref<HTMLInputElement | null>(null)
// 拖拽状态
const isDragover = ref(false)
// 文件列表
const fileList = ref<UploadFile[]>([])
// 当前正在上传的文件
const currentUploadFile = ref<UploadFile | null>(null)
// 同名文件检测
const duplicateModalVisible = ref(false)
const duplicateFiles = ref<DuplicateFile[]>([])
const pendingFiles = ref<UploadFile[]>([]) // 等待处理的新文件
// 监听文件列表变化
watch(() => fileList.value.length, (count) => {
emit('files-change', count)
}, { immediate: true })
/**
* 选择文件
*/
function selectFiles() {
fileInputRef.value?.click()
}
/**
* 选择文件夹
*/
function selectFolder() {
folderInputRef.value?.click()
}
/**
* 清空列表
*/
function clearList() {
fileList.value = []
currentUploadFile.value = null
}
/**
* 移除单个文件
*/
function removeFile(index: number) {
const file = fileList.value[index]
if (file.status === 'uploading') {
message.warning('该文件正在上传中,无法移除')
return
}
fileList.value.splice(index, 1)
}
/**
* 生成唯一ID
*/
function generateUid(): string {
return `upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
}
/**
* 处理文件选择
*/
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement
if (input.files) {
addFiles(Array.from(input.files))
}
input.value = ''
}
/**
* 处理文件夹选择
*/
function handleFolderSelect(event: Event) {
const input = event.target as HTMLInputElement
if (input.files) {
addFiles(Array.from(input.files))
}
input.value = ''
}
/**
* 添加文件到列表
*/
function addFiles(files: File[]) {
const newFiles: UploadFile[] = files.map(file => ({
uid: generateUid(),
name: file.name,
path: (file as any).webkitRelativePath || file.name,
size: file.size,
type: file.type,
status: 'pending',
percent: 0,
file
}))
// 检测同名文件(与已存在文件和已添加文件对比)
const existingPaths = new Set([
...props.existingFiles,
...fileList.value.map(f => f.path)
])
const duplicates: DuplicateFile[] = []
const nonDuplicates: UploadFile[] = []
for (const file of newFiles) {
if (existingPaths.has(file.path)) {
duplicates.push({
uid: file.uid,
name: file.name,
path: file.path,
size: file.size,
file: file.file
})
} else {
nonDuplicates.push(file)
}
}
// 添加非重复文件
if (nonDuplicates.length > 0) {
fileList.value.push(...nonDuplicates)
}
// 如果有重复文件,显示提示弹窗
if (duplicates.length > 0) {
duplicateFiles.value = duplicates
pendingFiles.value = newFiles.filter(f => duplicates.some(d => d.uid === f.uid))
duplicateModalVisible.value = true
} else if (props.autoUpload) {
startUpload()
}
}
/**
* 跳过重复文件
*/
function handleSkipDuplicates() {
// 清空待处理文件,不添加到列表
pendingFiles.value = []
duplicateFiles.value = []
}
/**
* 覆盖重复文件
*/
function handleOverwriteDuplicates() {
// 将待处理文件添加到列表(会覆盖同名文件)
for (const file of pendingFiles.value) {
// 移除已存在的同名文件
const existingIndex = fileList.value.findIndex(f => f.path === file.path)
if (existingIndex > -1) {
fileList.value.splice(existingIndex, 1)
}
// 添加新文件
fileList.value.push(file)
}
pendingFiles.value = []
duplicateFiles.value = []
if (props.autoUpload) {
startUpload()
}
}
/**
* 拖拽进入
*/
function handleDragover() {
isDragover.value = true
}
/**
* 拖拽离开
*/
function handleDragleave() {
isDragover.value = false
}
/**
* 拖拽放下
*/
async function handleDrop(event: DragEvent) {
isDragover.value = false
const items = event.dataTransfer?.items
if (!items) return
const files: File[] = []
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (item.kind === 'file') {
const entry = item.webkitGetAsEntry?.()
if (entry) {
await traverseFileTree(entry, '', files)
} else {
const file = item.getAsFile()
if (file) {
files.push(file)
}
}
}
}
if (files.length > 0) {
addFilesWithPath(files)
}
}
/**
* 递归遍历文件树
*/
async function traverseFileTree(
entry: FileSystemEntry,
path: string,
files: File[]
): Promise<void> {
if (entry.isFile) {
const fileEntry = entry as FileSystemFileEntry
const file = await new Promise<File>((resolve, reject) => {
fileEntry.file(resolve, reject)
})
Object.defineProperty(file, 'relativePath', {
value: path + entry.name,
writable: false
})
files.push(file)
} else if (entry.isDirectory) {
const dirEntry = entry as FileSystemDirectoryEntry
const reader = dirEntry.createReader()
const entries = await new Promise<FileSystemEntry[]>((resolve, reject) => {
reader.readEntries(resolve, reject)
})
for (const childEntry of entries) {
await traverseFileTree(childEntry, path + entry.name + '/', files)
}
}
}
/**
* 添加带路径的文件
*/
function addFilesWithPath(files: File[]) {
const newFiles: UploadFile[] = files.map(file => ({
uid: generateUid(),
name: file.name,
path: (file as any).relativePath || (file as any).webkitRelativePath || file.name,
size: file.size,
type: file.type,
status: 'pending',
percent: 0,
file
}))
fileList.value.push(...newFiles)
if (props.autoUpload) {
startUpload()
}
}
/**
* 开始上传(暴露给父组件调用)
*/
async function startUpload(): Promise<UploadFile[]> {
const pendingFiles = fileList.value.filter(f => f.status === 'pending')
for (const file of pendingFiles) {
await uploadFile(file)
}
currentUploadFile.value = null
emit('upload-complete', fileList.value)
return fileList.value
}
/**
* 上传单个文件
*/
async function uploadFile(file: UploadFile): Promise<void> {
file.status = 'uploading'
currentUploadFile.value = file
// 模拟上传过程
return new Promise((resolve) => {
let progress = 0
const interval = setInterval(() => {
progress += Math.random() * 30
if (progress >= 100) {
progress = 100
file.percent = 100
file.status = 'done'
clearInterval(interval)
resolve()
} else {
file.percent = Math.floor(progress)
}
}, 100)
})
// TODO: 真实上传逻辑
}
/**
* 重置
*/
function reset() {
fileList.value = []
currentUploadFile.value = null
}
// 暴露方法给父组件
defineExpose({
startUpload,
reset,
fileList
})
</script>
<style scoped>
.upload-core {
display: flex;
flex-direction: column;
gap: 16px;
}
.upload-core--flex {
height: 100%;
}
.upload-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
.upload-drop-zone {
border: 2px dashed #d9d9d9;
border-radius: 8px;
padding: 40px;
text-align: center;
transition: all 0.3s;
cursor: pointer;
background: #fafafa;
}
.upload-drop-zone:hover,
.upload-drop-zone.is-dragover {
border-color: #1890ff;
background: #e6f7ff;
}
.drop-zone-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.drop-icon {
font-size: 48px;
color: #bfbfbf;
}
.upload-drop-zone:hover .drop-icon,
.upload-drop-zone.is-dragover .drop-icon {
color: #1890ff;
}
.drop-text {
font-size: 14px;
color: #8c8c8c;
margin: 0;
}
.current-upload {
padding: 12px 16px;
background: #f6ffed;
border: 1px solid #b7eb8f;
border-radius: 4px;
}
.current-file-name {
display: block;
font-size: 13px;
color: #52c41a;
margin-bottom: 8px;
}
.file-list {
max-height: 300px;
overflow-y: auto;
border: 1px solid #f0f0f0;
border-radius: 4px;
}
.file-list--flex {
max-height: none;
flex: 1;
min-height: 150px;
}
.file-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
border-bottom: 1px solid #f5f5f5;
transition: background 0.2s;
}
.file-item:last-child {
border-bottom: none;
}
.file-item:hover {
background: #fafafa;
}
.file-info {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
}
.file-icon {
font-size: 16px;
color: #8c8c8c;
flex-shrink: 0;
}
.file-path {
font-size: 13px;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-status {
flex-shrink: 0;
width: 24px;
text-align: center;
}
.status-done {
font-size: 16px;
color: #52c41a;
}
.status-error {
font-size: 16px;
color: #ff4d4f;
}
.status-pending {
font-size: 18px;
color: #d9d9d9;
cursor: pointer;
user-select: none;
}
.status-pending:hover {
color: #ff4d4f;
}
.empty-list {
padding: 40px 0;
}
</style>

4
src/config/index.ts Normal file
View File

@@ -0,0 +1,4 @@
/**
* 配置模块统一导出
*/
export * from './project'

274
src/config/project.ts Normal file
View File

@@ -0,0 +1,274 @@
/**
* 项目配置系统 - 框架版
*
* 该文件用于配置管理后台的框架模块
* - 系统管理
* - 财务管理
* - 平台管理
*/
import type { Component } from 'vue'
import {
PayCircleOutlined,
SettingOutlined,
AppstoreOutlined
} from '@ant-design/icons-vue'
// 项目标识类型
export type ProjectId = 'framework' | 'default'
// 当前项目配置
export const CURRENT_PROJECT: ProjectId = 'framework'
// 菜单项类型
export interface MenuItem {
key: string
label: string
icon?: Component
path?: string
children?: MenuItem[]
}
// 模块类型
export type ModuleType = 'framework'
// 模块配置
export interface ModuleConfig {
id: string
name: string
type: ModuleType
icon?: Component
menus: MenuItem[]
routes: RouteConfig[]
}
// 路由配置
export interface RouteConfig {
path: string
name: string
component: string
title: string
}
// ==================== 框架模块 ====================
// 系统管理模块
export const systemModule: ModuleConfig = {
id: 'system',
name: '系统管理',
type: 'framework',
icon: SettingOutlined,
menus: [
{
key: 'system',
label: '系统管理',
children: [
{ key: 'settings', label: '系统设置', path: '/settings' },
{ key: 'dict', label: '字典管理', path: '/settings/dict' },
{ key: 'city', label: '城市管理', path: '/settings/city' },
{ key: 'approval-flow', label: '审批流程', path: '/system/approval' },
{ key: 'approval-instances', label: '审批记录', path: '/system/approval-instances' }
]
}
],
routes: [
{ path: 'settings', name: 'Settings', component: '@/views/settings/index.vue', title: '系统设置' },
{ path: 'settings/dict', name: 'Dict', component: '@/views/settings/dict/index.vue', title: '字典管理' },
{ path: 'settings/city', name: 'City', component: '@/views/settings/city/index.vue', title: '城市管理' },
{ path: 'system/approval', name: 'ApprovalFlow', component: '@/views/system/approval/index.vue', title: '审批流程' },
{ path: 'system/approval-instances', name: 'ApprovalInstances', component: '@/views/system/approval-instances/index.vue', title: '审批记录' }
]
}
// 财务管理模块
export const financeModule: ModuleConfig = {
id: 'finance',
name: '财务管理',
type: 'framework',
icon: PayCircleOutlined,
menus: [
{
key: 'finance',
label: '财务管理',
children: [
{ key: 'finance-overview', label: '财务总览', path: '/finance/overview' },
{ key: 'income', label: '收入管理', path: '/finance/income' },
{ key: 'expense', label: '支出管理', path: '/finance/expense' },
{ key: 'reimbursement', label: '报销管理', path: '/finance/reimbursement' },
{ key: 'settlement', label: '结算管理', path: '/finance/settlement' },
{ key: 'invoice', label: '发票管理', path: '/finance/invoice' },
{ key: 'finance-accounts', label: '账户管理', path: '/finance/accounts' },
{ key: 'budget', label: '预算管理', path: '/finance/budget' },
{ key: 'finance-reports', label: '财务报表', path: '/finance/reports' },
{ key: 'finance-import', label: '数据导入', path: '/finance/import' },
{ key: 'advanced-reports', label: '高级报表', path: '/finance/advanced-reports' },
]
}
],
routes: [
{ path: 'finance/overview', name: 'FinanceOverview', component: '@/views/finance/overview/index.vue', title: '财务总览' },
{ path: 'finance/income', name: 'Income', component: '@/views/finance/income/index.vue', title: '收入管理' },
{ path: 'finance/expense', name: 'Expense', component: '@/views/finance/expense/index.vue', title: '支出管理' },
{ path: 'finance/reimbursement', name: 'Reimbursement', component: '@/views/finance/reimbursement/index.vue', title: '报销管理' },
{ path: 'finance/settlement', name: 'Settlement', component: '@/views/finance/settlement/index.vue', title: '结算管理' },
{ path: 'finance/invoice', name: 'Invoice', component: '@/views/finance/invoice/index.vue', title: '发票管理' },
{ path: 'finance/accounts', name: 'FinanceAccounts', component: '@/views/finance/accounts/index.vue', title: '账户管理' },
{ path: 'finance/budget', name: 'Budget', component: '@/views/finance/budget/index.vue', title: '预算管理' },
{ path: 'finance/reports', name: 'FinanceReports', component: '@/views/finance/reports/index.vue', title: '财务报表' },
{ path: 'finance/import', name: 'FinanceImport', component: '@/views/finance/import/index.vue', title: '数据导入' },
{ path: 'finance/advanced-reports', name: 'AdvancedReports', component: '@/views/finance/advanced-reports/index.vue', title: '高级报表' },
]
}
// 平台管理模块
export const platformModule: ModuleConfig = {
id: 'platform',
name: '平台管理',
type: 'framework',
icon: AppstoreOutlined,
menus: [
{
key: 'platform',
label: '平台管理',
children: [
{ key: 'platform-projects', label: '项目管理', path: '/platform/projects' },
{ key: 'platform-menus', label: '菜单管理', path: '/platform/menus' },
{ key: 'platform-certificates', label: '证书管理', path: '/platform/certificates' }
]
}
],
routes: [
{ path: 'platform/projects', name: 'PlatformProjects', component: '@/views/platform/projects/index.vue', title: '项目管理' },
{ path: 'platform/menus', name: 'PlatformMenus', component: '@/views/platform/menus/index.vue', title: '菜单管理' },
{ path: 'platform/certificates', name: 'PlatformCertificates', component: '@/views/platform/certificates/index.vue', title: '证书管理' }
]
}
// 所有框架模块
export const frameworkModules: ModuleConfig[] = [
financeModule,
platformModule,
systemModule
]
// ==================== 项目配置 ====================
export interface ProjectConfig {
id: ProjectId
name: string
logo: string
shortName: string
description: string
modules: ModuleConfig[]
}
// 框架项目配置
export const frameworkProject: ProjectConfig = {
id: 'framework',
name: '管理后台框架',
logo: '管',
shortName: '管理',
description: '通用管理后台框架',
modules: frameworkModules
}
// 所有项目配置
export const projectConfigs: Record<ProjectId, ProjectConfig> = {
framework: frameworkProject,
default: frameworkProject
}
// ==================== 工具函数 ====================
/**
* 获取当前项目配置
*/
export function getCurrentProject(): ProjectConfig {
return projectConfigs[CURRENT_PROJECT] || projectConfigs.default
}
/**
* 获取当前项目的所有模块
*/
export function getAllModules(): ModuleConfig[] {
return frameworkModules
}
/**
* 获取当前项目的菜单配置
*/
export function getMenuConfig(): MenuItem[] {
const modules = getAllModules()
const menus: MenuItem[] = []
modules.forEach(module => {
module.menus.forEach(menu => {
if (!menu.children) {
menus.push({
...menu,
icon: module.icon
})
} else {
menus.push({
...menu,
icon: module.icon
})
}
})
})
return menus
}
/**
* 获取菜单项到路由的映射
*/
export function getMenuRouteMap(): Record<string, string> {
const modules = getAllModules()
const map: Record<string, string> = {}
modules.forEach(module => {
module.menus.forEach(menu => {
if (menu.path) {
map[menu.key] = menu.path
}
if (menu.children) {
menu.children.forEach(child => {
if (child.path) {
map[child.key] = child.path
}
})
}
})
})
return map
}
/**
* 获取所有路由配置
*/
export function getRouteConfigs(): RouteConfig[] {
const modules = getAllModules()
const routes: RouteConfig[] = []
modules.forEach(module => {
routes.push(...module.routes)
})
return routes
}
/**
* 获取业务模块菜单(框架版无业务模块)
*/
export function getBusinessMenus(): MenuItem[] {
return []
}
/**
* 获取框架模块菜单
*/
export function getFrameworkMenus(): MenuItem[] {
return getMenuConfig()
}

544
src/layouts/MainLayout.vue Normal file
View File

@@ -0,0 +1,544 @@
<template>
<a-layout class="main-layout">
<!-- 左侧菜单栏 -->
<a-layout-sider
v-model:collapsed="collapsed"
:trigger="null"
collapsible
class="sider"
>
<div class="logo">
<span v-if="!collapsed">{{ displayProjectName }}</span>
<span v-else>{{ displayProjectLogo }}</span>
</div>
<a-menu
v-model:selectedKeys="selectedKeys"
v-model:openKeys="openKeys"
mode="inline"
theme="dark"
@click="handleMenuClick"
>
<!-- 返回按钮仅在子项目模式下显示 -->
<a-menu-item v-if="projectStore.isInSubProject" key="__back_to_framework" class="back-menu-item">
<ArrowLeftOutlined />
<span>返回框架</span>
</a-menu-item>
<a-menu-divider v-if="projectStore.isInSubProject" />
<!-- 子项目菜单子项目模式 -->
<template v-if="projectStore.isInSubProject">
<a-menu-item-group>
<template #title>
<span class="menu-group-title">{{ projectStore.currentProject?.shortName }}</span>
</template>
<template v-for="menu in projectStore.currentProjectMenus" :key="menu.key">
<!-- 单个菜单项 -->
<a-menu-item v-if="!menu.children" :key="`item-${menu.key}`">
<component :is="getIconComponent(menu.icon)" v-if="menu.icon" />
<span>{{ menu.label }}</span>
</a-menu-item>
<!-- 子菜单 -->
<a-sub-menu v-else :key="`sub-${menu.key}`">
<template #title>
<component :is="getIconComponent(menu.icon)" v-if="menu.icon" />
<span>{{ menu.label }}</span>
</template>
<a-menu-item v-for="child in menu.children" :key="child.key">
{{ child.label }}
</a-menu-item>
</a-sub-menu>
</template>
</a-menu-item-group>
</template>
<!-- 框架菜单组 -->
<template v-else>
<!-- 子项目入口 -->
<a-menu-item-group v-if="projectStore.enabledProjects.length > 0">
<template #title>
<span class="menu-group-title">业务项目</span>
</template>
<a-menu-item
v-for="project in projectStore.enabledProjects"
:key="`project-${project.id}`"
>
<a-avatar
:style="{ backgroundColor: project.color || '#1890ff' }"
size="small"
class="project-avatar"
>
{{ project.logo }}
</a-avatar>
<span class="project-menu-label">{{ project.shortName }}</span>
</a-menu-item>
</a-menu-item-group>
<a-menu-divider v-if="projectStore.enabledProjects.length > 0" />
<!-- 框架功能模块 -->
<a-menu-item-group v-if="frameworkMenus.length > 0">
<template #title>
<span class="menu-group-title">{{ projectConfig.shortName }}</span>
</template>
<template v-for="menu in frameworkMenus" :key="menu.key">
<!-- 单个菜单项 -->
<a-menu-item v-if="!menu.children" :key="`item-${menu.key}`">
<component :is="menu.icon" v-if="menu.icon" />
<span>{{ menu.label }}</span>
</a-menu-item>
<!-- 子菜单 -->
<a-sub-menu v-else :key="`sub-${menu.key}`">
<template #title>
<component :is="menu.icon" v-if="menu.icon" />
<span>{{ menu.label }}</span>
</template>
<a-menu-item v-for="child in menu.children" :key="child.key">
{{ child.label }}
</a-menu-item>
</a-sub-menu>
</template>
</a-menu-item-group>
</template>
</a-menu>
</a-layout-sider>
<a-layout>
<!-- 顶部栏 -->
<a-layout-header class="header">
<div class="header-left">
<MenuFoldOutlined
v-if="!collapsed"
class="trigger"
@click="collapsed = true"
/>
<MenuUnfoldOutlined
v-else
class="trigger"
@click="collapsed = false"
/>
<!-- 面包屑导航 -->
<a-breadcrumb class="breadcrumb">
<a-breadcrumb-item v-for="(item, index) in breadcrumbs" :key="index">
<router-link v-if="index < breadcrumbs.length - 1" :to="item.path">
{{ item.title }}
</router-link>
<span v-else>{{ item.title }}</span>
</a-breadcrumb-item>
</a-breadcrumb>
</div>
<!-- 右侧项目标识和用户信息 -->
<div class="header-right">
<!-- 当前项目标识 -->
<a-tag
:color="projectStore.isInSubProject ? (projectStore.currentProject?.color || 'blue') : 'blue'"
class="project-tag"
>
{{ projectStore.isInSubProject
? projectStore.currentProject?.shortName
: projectConfig.shortName
}}
</a-tag>
<a-dropdown>
<div class="user-info">
<a-avatar :src="userStore.avatar || undefined" :size="32">
<template #icon><UserOutlined /></template>
</a-avatar>
<span class="username">{{ userStore.nickname || '管理员' }}</span>
<DownOutlined />
</div>
<template #overlay>
<a-menu @click="handleUserMenuClick">
<a-menu-item key="profile">
<UserOutlined />
个人中心
</a-menu-item>
<a-menu-item key="password">
<LockOutlined />
修改密码
</a-menu-item>
<a-menu-divider />
<a-menu-item key="logout">
<LogoutOutlined />
退出登录
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</a-layout-header>
<!-- 内容区域 -->
<a-layout-content class="content">
<router-view />
</a-layout-content>
</a-layout>
</a-layout>
</template>
<script setup lang="ts">
import { ref, computed, watch, h, type Component } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { Modal } from 'ant-design-vue'
import {
MenuFoldOutlined,
MenuUnfoldOutlined,
UserOutlined,
DownOutlined,
LockOutlined,
LogoutOutlined,
ArrowLeftOutlined,
DashboardOutlined,
TeamOutlined,
ReadOutlined,
CustomerServiceOutlined,
ProjectOutlined,
IdcardOutlined
} from '@ant-design/icons-vue'
import { useUserStore, useProjectStore } from '@/stores'
import { getCurrentProject, getMenuConfig, getMenuRouteMap, getFrameworkMenus } from '@/config'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const projectStore = useProjectStore()
// 项目配置
const projectConfig = getCurrentProject()
const menuConfig = getMenuConfig()
const menuRouteMap = getMenuRouteMap()
const frameworkMenus = getFrameworkMenus()
const collapsed = ref(false)
const selectedKeys = ref<string[]>(['finance-overview'])
const openKeys = ref<string[]>([])
// 图标名称到组件的映射
const iconMap: Record<string, Component> = {
DashboardOutlined,
TeamOutlined,
ReadOutlined,
CustomerServiceOutlined,
ProjectOutlined,
IdcardOutlined,
UserOutlined
}
// 获取图标组件
function getIconComponent(iconName?: string): Component | null {
if (!iconName) return null
return iconMap[iconName] || null
}
// 显示的项目名称
const displayProjectName = computed(() => {
if (projectStore.isInSubProject) {
return projectStore.currentProject?.name || ''
}
return projectConfig.name
})
// 显示的项目Logo
const displayProjectLogo = computed(() => {
if (projectStore.isInSubProject) {
return projectStore.currentProject?.logo || ''
}
return projectConfig.logo
})
// 面包屑
const breadcrumbs = computed(() => {
const matched = route.matched.filter(item => item.meta?.title)
const items = [
{ title: '首页', path: '/' },
...matched.map(item => ({
title: item.meta?.title as string,
path: item.path
}))
]
// 如果在子项目中,添加项目名称
if (projectStore.isInSubProject && projectStore.currentProject) {
items.splice(1, 0, {
title: projectStore.currentProject.shortName,
path: `/app/${projectStore.currentProject.id}`
})
}
return items
})
// 菜单点击
function handleMenuClick({ key }: { key: string | number }) {
const menuKey = String(key)
// 返回框架
if (menuKey === '__back_to_framework') {
projectStore.exitSubProject()
router.push('/finance/overview')
return
}
// 点击项目入口
if (menuKey.startsWith('project-')) {
const projectId = menuKey.replace('project-', '')
projectStore.switchProject(projectId)
// 跳转到子项目的默认页面
router.push(`/app/${projectId}/dashboard`)
return
}
// 子项目模式下的菜单点击
if (projectStore.isInSubProject) {
const subMenuPath = projectStore.getMenuRoutePath(menuKey)
if (subMenuPath) {
router.push(subMenuPath)
}
return
}
// 框架菜单点击
const path = menuRouteMap[menuKey]
if (path) {
router.push(path)
}
}
// 用户菜单点击
function handleUserMenuClick({ key }: { key: string | number }) {
const k = String(key)
switch (k) {
case 'profile':
router.push('/profile')
break
case 'password':
router.push('/password')
break
case 'logout':
Modal.confirm({
title: '确认退出',
content: '确定要退出登录吗?',
onOk() {
projectStore.exitSubProject()
userStore.logout()
router.push('/login')
}
})
break
}
}
// 更新菜单选中状态
function updateMenuState() {
const path = route.path
// 检查是否在子项目路由中
if (path.startsWith('/app/')) {
const match = path.match(/^\/app\/([^/]+)/)
if (match) {
const projectId = match[1]
if (projectStore.currentProjectId !== projectId) {
projectStore.switchProject(projectId)
}
// 设置子项目菜单选中状态
const subPath = path.replace(`/app/${projectId}`, '')
const subMenuRouteMap = projectStore.getMenuRouteMap()
for (const [menuKey, menuPath] of Object.entries(subMenuRouteMap)) {
const relativePath = menuPath.replace(`/app/${projectId}`, '')
if (subPath === relativePath || subPath.startsWith(relativePath + '/')) {
selectedKeys.value = [menuKey]
// 展开父级菜单
for (const menu of projectStore.currentProjectMenus) {
if (menu.children?.some(child => child.key === menuKey)) {
if (!openKeys.value.includes(menu.key)) {
openKeys.value = [...openKeys.value, menu.key]
}
break
}
}
break
}
}
return
}
}
// 退出子项目模式
if (projectStore.isInSubProject && !path.startsWith('/app/')) {
projectStore.exitSubProject()
}
// 查找匹配的菜单项
for (const [menuKey, menuPath] of Object.entries(menuRouteMap)) {
if (path === menuPath || path.startsWith(menuPath + '/')) {
selectedKeys.value = [menuKey]
// 查找父级菜单并展开
for (const menu of menuConfig) {
if (menu.children?.some(child => child.key === menuKey)) {
if (!openKeys.value.includes(menu.key)) {
openKeys.value = [...openKeys.value, menu.key]
}
break
}
}
break
}
}
}
watch(() => route.path, updateMenuState, { immediate: true })
</script>
<style scoped>
.main-layout {
min-height: 100vh;
}
.sider {
overflow: auto;
height: 100vh;
position: fixed;
left: 0;
top: 0;
bottom: 0;
}
.logo {
height: 64px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 20px;
font-weight: 600;
background: rgba(255, 255, 255, 0.05);
margin: 0;
white-space: nowrap;
overflow: hidden;
}
.main-layout > .ant-layout {
margin-left: 200px;
transition: margin-left 0.2s;
}
.main-layout > .ant-layout-sider-collapsed + .ant-layout {
margin-left: 80px;
}
.header {
background: #fff;
padding: 0 24px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
.header-left {
display: flex;
align-items: center;
}
.trigger {
font-size: 18px;
cursor: pointer;
transition: color 0.3s;
padding: 0 12px;
}
.trigger:hover {
color: #1890ff;
}
.breadcrumb {
margin-left: 16px;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.project-tag {
font-weight: 500;
}
.user-info {
display: flex;
align-items: center;
cursor: pointer;
padding: 0 12px;
}
.user-info:hover {
background: rgba(0, 0, 0, 0.025);
}
.username {
margin: 0 8px;
color: #333;
}
.content {
margin: 16px;
padding: 16px;
background: #fff;
border-radius: 4px;
height: calc(100vh - 64px - 32px);
overflow-y: auto;
overflow-x: hidden;
}
/* 菜单分组标题样式 */
.menu-group-title {
font-size: 12px;
color: rgba(255, 255, 255, 0.45);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
:deep(.ant-menu-item-group-title) {
padding: 8px 16px 4px;
font-size: 12px;
}
:deep(.ant-menu-item-divider) {
margin: 8px 16px;
background-color: rgba(255, 255, 255, 0.12);
}
/* 项目菜单项样式 */
.project-avatar {
margin-right: 8px;
font-size: 12px !important;
}
.project-menu-label {
margin-left: 4px;
}
/* 返回按钮样式 */
.back-menu-item {
color: rgba(255, 255, 255, 0.65);
}
.back-menu-item:hover {
color: #fff;
background: rgba(24, 144, 255, 0.2) !important;
}
</style>

15
src/main.ts Normal file
View File

@@ -0,0 +1,15 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import pinia from './stores'
// Ant Design Vue 全局样式
import 'ant-design-vue/dist/reset.css'
import './style.css'
const app = createApp(App)
app.use(pinia)
app.use(router)
app.mount('#app')

458
src/mock/approval.ts Normal file
View File

@@ -0,0 +1,458 @@
/**
* 审批流程管理模拟数据
*/
import type {
ApprovalTemplate,
ApprovalNode,
ApprovalInstance,
ApprovalScenario,
ApprovalInstanceStatus,
ApproverInfo,
ApprovalTemplateQueryParams,
ApprovalTemplateListResult,
ApprovalInstanceQueryParams,
ApprovalInstanceListResult,
ApprovalStats,
NodeApprovalRecord
} from '@/types/approval'
// 模拟审批人
const mockApprovers: ApproverInfo[] = [
{ id: 1, name: '张经理', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=mgr1', role: '部门经理', department: '技术部' },
{ id: 2, name: '李总监', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=dir1', role: '技术总监', department: '技术部' },
{ id: 3, name: '王财务', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=fin1', role: '财务主管', department: '财务部' },
{ id: 4, name: '赵HR', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=hr1', role: 'HR经理', department: '人事部' },
{ id: 5, name: '孙法务', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=legal1', role: '法务专员', department: '法务部' },
{ id: 6, name: '周运营', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=op1', role: '运营主管', department: '运营部' }
]
// 生成审批节点
function createNodes(templateType: string): ApprovalNode[] {
const nodeTemplates: Record<string, ApprovalNode[]> = {
project_publish: [
{ id: 1, name: '内容初审', approverType: 'role', approverRole: '运营专员', approvalMode: 'or', order: 1 },
{ id: 2, name: '运营主管审核', approverType: 'specified', approverIds: [6], approvalMode: 'and', order: 2 },
{ id: 3, name: '技术总监确认', approverType: 'specified', approverIds: [2], approvalMode: 'and', order: 3, timeoutHours: 24, timeoutAction: 'skip' }
],
withdrawal: [
{ id: 1, name: '财务初审', approverType: 'role', approverRole: '财务专员', approvalMode: 'or', order: 1 },
{ id: 2, name: '财务主管审批', approverType: 'specified', approverIds: [3], approvalMode: 'and', order: 2 },
{ id: 3, name: '总经理审批', approverType: 'superior', approvalMode: 'and', order: 3, timeoutHours: 48, timeoutAction: 'reject' }
],
contract: [
{ id: 1, name: '法务审核', approverType: 'specified', approverIds: [5], approvalMode: 'and', order: 1 },
{ id: 2, name: '部门负责人审批', approverType: 'superior', approvalMode: 'and', order: 2 },
{ id: 3, name: '总监审批', approverType: 'specified', approverIds: [2], approvalMode: 'and', order: 3 }
],
certification: [
{ id: 1, name: 'HR审核', approverType: 'specified', approverIds: [4], approvalMode: 'and', order: 1 },
{ id: 2, name: '运营确认', approverType: 'specified', approverIds: [6], approvalMode: 'and', order: 2 }
],
content: [
{ id: 1, name: '内容审核', approverType: 'role', approverRole: '内容审核员', approvalMode: 'or', order: 1 },
{ id: 2, name: '运营复核', approverType: 'specified', approverIds: [6], approvalMode: 'and', order: 2, timeoutHours: 12, timeoutAction: 'skip' }
],
// ==================== 财务管理审批流程 ====================
// 费用报销审批流程
expense_reimbursement: [
{ id: 1, name: '直属领导审批', approverType: 'superior', approvalMode: 'and', order: 1, timeoutHours: 24 },
{ id: 2, name: '财务审核', approverType: 'role', approverRole: '财务专员', approvalMode: 'or', order: 2, timeoutHours: 48 },
{ id: 3, name: '财务主管审批', approverType: 'specified', approverIds: [3], approvalMode: 'and', order: 3, timeoutHours: 24, timeoutAction: 'skip' }
],
// 大额费用报销超过5000元
expense_reimbursement_large: [
{ id: 1, name: '直属领导审批', approverType: 'superior', approvalMode: 'and', order: 1, timeoutHours: 24 },
{ id: 2, name: '部门总监审批', approverType: 'specified', approverIds: [2], approvalMode: 'and', order: 2, timeoutHours: 24 },
{ id: 3, name: '财务审核', approverType: 'role', approverRole: '财务专员', approvalMode: 'or', order: 3, timeoutHours: 48 },
{ id: 4, name: '财务总监审批', approverType: 'specified', approverIds: [3], approvalMode: 'and', order: 4, timeoutHours: 24 },
{ id: 5, name: 'CEO最终审批', approverType: 'specified', approverIds: [1], approvalMode: 'and', order: 5, timeoutHours: 48, timeoutAction: 'reject' }
],
// 付款申请审批流程
payment_request: [
{ id: 1, name: '申请人确认', approverType: 'self_select', approvalMode: 'and', order: 1 },
{ id: 2, name: '部门负责人审批', approverType: 'superior', approvalMode: 'and', order: 2, timeoutHours: 24 },
{ id: 3, name: '财务核对', approverType: 'role', approverRole: '财务专员', approvalMode: 'or', order: 3, timeoutHours: 24 },
{ id: 4, name: '财务主管审批', approverType: 'specified', approverIds: [3], approvalMode: 'and', order: 4, timeoutHours: 24 },
{ id: 5, name: '出纳付款', approverType: 'role', approverRole: '出纳', approvalMode: 'or', order: 5 }
],
// 采购申请审批流程
purchase_request: [
{ id: 1, name: '部门负责人审批', approverType: 'superior', approvalMode: 'and', order: 1, timeoutHours: 24 },
{ id: 2, name: '采购部审核', approverType: 'role', approverRole: '采购专员', approvalMode: 'or', order: 2, timeoutHours: 48 },
{ id: 3, name: '财务预算审核', approverType: 'role', approverRole: '财务专员', approvalMode: 'or', order: 3, timeoutHours: 24 },
{ id: 4, name: '总经理审批', approverType: 'specified', approverIds: [1], approvalMode: 'and', order: 4, timeoutHours: 48, timeoutAction: 'reject' }
],
// 预算调整审批流程
budget_adjustment: [
{ id: 1, name: '部门负责人审批', approverType: 'superior', approvalMode: 'and', order: 1, timeoutHours: 24 },
{ id: 2, name: '财务主管审核', approverType: 'specified', approverIds: [3], approvalMode: 'and', order: 2, timeoutHours: 48 },
{ id: 3, name: 'CFO审批', approverType: 'superior', approvalMode: 'and', order: 3, timeoutHours: 48 },
{ id: 4, name: 'CEO最终审批', approverType: 'specified', approverIds: [1], approvalMode: 'and', order: 4, timeoutHours: 72, timeoutAction: 'reject' }
],
// 发票申请审批流程
invoice_apply: [
{ id: 1, name: '销售确认', approverType: 'specified', approverIds: [6], approvalMode: 'and', order: 1, timeoutHours: 12 },
{ id: 2, name: '财务开票', approverType: 'role', approverRole: '财务专员', approvalMode: 'or', order: 2, timeoutHours: 24 },
{ id: 3, name: '财务主管复核', approverType: 'specified', approverIds: [3], approvalMode: 'and', order: 3, timeoutHours: 12, timeoutAction: 'skip' }
]
}
return nodeTemplates[templateType] || []
}
// 生成审批模板
function generateTemplates(): ApprovalTemplate[] {
const templates: ApprovalTemplate[] = [
{
id: 1,
name: '项目发布审批流程',
description: '项目发布前的内容审核和技术确认流程',
scenario: 'project_publish',
nodes: createNodes('project_publish'),
enabled: true,
createdAt: '2024-01-10T10:00:00Z',
updatedAt: '2024-06-20T15:30:00Z'
},
{
id: 2,
name: '提现申请审批流程',
description: '用户提现申请的财务审批流程',
scenario: 'withdrawal',
nodes: createNodes('withdrawal'),
enabled: true,
createdAt: '2024-01-15T09:00:00Z',
updatedAt: '2024-05-10T11:20:00Z'
},
{
id: 3,
name: '合同签署审批流程',
description: '合同签署前的法务和管理层审批',
scenario: 'contract',
nodes: createNodes('contract'),
enabled: true,
createdAt: '2024-02-01T14:00:00Z',
updatedAt: '2024-07-05T16:45:00Z'
},
{
id: 4,
name: '用户认证审批流程',
description: '用户实名认证和企业认证的人工审核',
scenario: 'certification',
nodes: createNodes('certification'),
enabled: true,
createdAt: '2024-02-20T11:00:00Z',
updatedAt: '2024-04-18T09:15:00Z'
},
{
id: 5,
name: '内容审核流程',
description: '帖子和文章发布前的内容安全审核',
scenario: 'content',
nodes: createNodes('content'),
enabled: true,
createdAt: '2024-03-01T08:30:00Z',
updatedAt: '2024-08-12T14:00:00Z'
},
{
id: 6,
name: '大额提现审批流程',
description: '超过10万元的提现需要多级审批',
scenario: 'withdrawal',
nodes: [
{ id: 1, name: '财务初审', approverType: 'role', approverRole: '财务专员', approvalMode: 'and', order: 1 },
{ id: 2, name: '财务主管审批', approverType: 'specified', approverIds: [3], approvalMode: 'and', order: 2 },
{ id: 3, name: 'CFO审批', approverType: 'superior', approvalMode: 'and', order: 3 },
{ id: 4, name: 'CEO最终确认', approverType: 'specified', approverIds: [1], approvalMode: 'and', order: 4 }
],
enabled: false,
createdAt: '2024-04-10T10:00:00Z',
updatedAt: '2024-04-10T10:00:00Z'
},
// ==================== 财务管理审批模板 ====================
{
id: 7,
name: '费用报销审批流程',
description: '日常费用报销审批适用于5000元以下的报销申请。流程直属领导→财务审核→财务主管',
scenario: 'expense_reimbursement',
nodes: createNodes('expense_reimbursement'),
enabled: true,
createdAt: '2024-05-01T09:00:00Z',
updatedAt: '2024-09-15T10:30:00Z'
},
{
id: 8,
name: '大额费用报销审批流程',
description: '超过5000元的费用报销需要多级审批。流程直属领导→部门总监→财务审核→财务总监→CEO',
scenario: 'expense_reimbursement',
nodes: createNodes('expense_reimbursement_large'),
enabled: true,
createdAt: '2024-05-05T10:00:00Z',
updatedAt: '2024-09-20T14:00:00Z'
},
{
id: 9,
name: '付款申请审批流程',
description: '供应商付款、服务费支付等对外付款申请审批。流程:申请人确认→部门负责人→财务核对→财务主管→出纳付款',
scenario: 'payment_request',
nodes: createNodes('payment_request'),
enabled: true,
createdAt: '2024-05-10T11:00:00Z',
updatedAt: '2024-10-01T09:00:00Z'
},
{
id: 10,
name: '采购申请审批流程',
description: '物资采购、服务采购申请审批。流程:部门负责人→采购部审核→财务预算审核→总经理审批',
scenario: 'purchase_request',
nodes: createNodes('purchase_request'),
enabled: true,
createdAt: '2024-06-01T09:00:00Z',
updatedAt: '2024-10-10T16:00:00Z'
},
{
id: 11,
name: '预算调整审批流程',
description: '年度预算调整、部门预算追加申请。流程部门负责人→财务主管→CFO→CEO需要多级管理层批准',
scenario: 'budget_adjustment',
nodes: createNodes('budget_adjustment'),
enabled: true,
createdAt: '2024-06-15T14:00:00Z',
updatedAt: '2024-11-01T10:00:00Z'
},
{
id: 12,
name: '发票申请审批流程',
description: '客户发票开具申请审批。流程:销售确认→财务开票→财务主管复核',
scenario: 'invoice_apply',
nodes: createNodes('invoice_apply'),
enabled: true,
createdAt: '2024-07-01T09:00:00Z',
updatedAt: '2024-11-15T11:00:00Z'
}
]
return templates
}
// 生成审批实例
function generateInstances(): ApprovalInstance[] {
const instances: ApprovalInstance[] = []
const statuses: ApprovalInstanceStatus[] = ['pending', 'in_progress', 'approved', 'rejected', 'withdrawn']
const businessTypes = [
{ type: 'project', title: '企业官网开发项目', scenario: 'project_publish' as ApprovalScenario },
{ type: 'withdrawal', title: '提现申请 - ¥15,000', scenario: 'withdrawal' as ApprovalScenario },
{ type: 'contract', title: 'CRM系统开发服务合同', scenario: 'contract' as ApprovalScenario },
{ type: 'certification', title: '企业认证 - 科技有限公司', scenario: 'certification' as ApprovalScenario },
{ type: 'post', title: '技术分享文章审核', scenario: 'content' as ApprovalScenario },
// 财务管理审批实例
{ type: 'reimbursement', title: '差旅费报销 - ¥3,280', scenario: 'expense_reimbursement' as ApprovalScenario },
{ type: 'reimbursement', title: '办公用品采购报销 - ¥1,560', scenario: 'expense_reimbursement' as ApprovalScenario },
{ type: 'reimbursement', title: '客户招待费报销 - ¥8,500', scenario: 'expense_reimbursement' as ApprovalScenario },
{ type: 'payment', title: '供应商付款 - 服务器托管费', scenario: 'payment_request' as ApprovalScenario },
{ type: 'payment', title: '房租付款申请 - 12月份', scenario: 'payment_request' as ApprovalScenario },
{ type: 'purchase', title: '办公电脑采购 - 5台', scenario: 'purchase_request' as ApprovalScenario },
{ type: 'purchase', title: '云服务年度续费申请', scenario: 'purchase_request' as ApprovalScenario },
{ type: 'budget', title: '研发部Q4预算追加申请', scenario: 'budget_adjustment' as ApprovalScenario },
{ type: 'invoice', title: '开具增值税专用发票 - ¥50,000', scenario: 'invoice_apply' as ApprovalScenario }
]
for (let i = 1; i <= 35; i++) {
const business = businessTypes[i % businessTypes.length]!
const status = statuses[i % statuses.length]!
const submittedAt = new Date(Date.now() - (i * 2 + Math.random() * 5) * 24 * 60 * 60 * 1000)
const records: NodeApprovalRecord[] = []
if (status !== 'pending') {
records.push({
nodeId: 1,
nodeName: '初审',
approverId: mockApprovers[i % mockApprovers.length]!.id,
approverName: mockApprovers[i % mockApprovers.length]!.name,
approverAvatar: mockApprovers[i % mockApprovers.length]!.avatar,
action: 'approve',
comment: '已审核,符合规范',
operatedAt: new Date(submittedAt.getTime() + 2 * 60 * 60 * 1000).toISOString()
})
}
if (['approved', 'rejected'].includes(status)) {
records.push({
nodeId: 2,
nodeName: '复审',
approverId: mockApprovers[(i + 1) % mockApprovers.length]!.id,
approverName: mockApprovers[(i + 1) % mockApprovers.length]!.name,
approverAvatar: mockApprovers[(i + 1) % mockApprovers.length]!.avatar,
action: status === 'approved' ? 'approve' : 'reject',
comment: status === 'approved' ? '审批通过' : '不符合要求,请修改后重新提交',
operatedAt: new Date(submittedAt.getTime() + 8 * 60 * 60 * 1000).toISOString()
})
}
instances.push({
id: i,
templateId: (i % 5) + 1,
templateName: ['项目发布审批', '提现申请审批', '合同签署审批', '用户认证审批', '内容审核'][i % 5]!,
scenario: business.scenario,
businessType: business.type,
businessId: 1000 + i,
businessTitle: business.title,
initiatorId: 100 + i,
initiatorName: ['张三', '李四', '王五', '赵六', '孙七'][i % 5]!,
initiatorAvatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=user${i}`,
status,
currentNodeId: status === 'in_progress' ? 2 : undefined,
currentNodeName: status === 'in_progress' ? '复审' : undefined,
records,
submittedAt: submittedAt.toISOString(),
completedAt: ['approved', 'rejected'].includes(status)
? new Date(submittedAt.getTime() + 24 * 60 * 60 * 1000).toISOString()
: undefined,
createdAt: submittedAt.toISOString()
})
}
return instances.sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
}
let mockTemplates = generateTemplates()
let mockInstances = generateInstances()
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
// ================== 模板 API ==================
export async function mockGetApprovalStats(): Promise<ApprovalStats> {
await delay(100)
return {
totalTemplates: mockTemplates.length,
enabledTemplates: mockTemplates.filter(t => t.enabled).length,
totalInstances: mockInstances.length,
pendingInstances: mockInstances.filter(i => i.status === 'pending').length,
inProgressInstances: mockInstances.filter(i => i.status === 'in_progress').length,
approvedInstances: mockInstances.filter(i => i.status === 'approved').length,
rejectedInstances: mockInstances.filter(i => i.status === 'rejected').length
}
}
export async function mockGetApprovalTemplateList(
params: ApprovalTemplateQueryParams = {}
): Promise<ApprovalTemplateListResult> {
await delay(200)
const { page = 1, pageSize = 10, keyword, scenario, enabled } = params
let filtered = [...mockTemplates]
if (keyword) {
const kw = keyword.toLowerCase()
filtered = filtered.filter(t =>
t.name.toLowerCase().includes(kw) ||
t.description?.toLowerCase().includes(kw)
)
}
if (scenario) {
filtered = filtered.filter(t => t.scenario === scenario)
}
if (enabled !== undefined) {
filtered = filtered.filter(t => t.enabled === enabled)
}
const start = (page - 1) * pageSize
return {
list: filtered.slice(start, start + pageSize),
total: filtered.length,
page,
pageSize
}
}
export async function mockGetApprovalTemplateById(id: number): Promise<ApprovalTemplate | null> {
await delay(100)
return mockTemplates.find(t => t.id === id) || null
}
export async function mockCreateApprovalTemplate(template: Omit<ApprovalTemplate, 'id' | 'createdAt' | 'updatedAt'>): Promise<ApprovalTemplate> {
await delay(200)
const newTemplate: ApprovalTemplate = {
...template,
id: Math.max(...mockTemplates.map(t => t.id)) + 1,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
mockTemplates.push(newTemplate)
return newTemplate
}
export async function mockUpdateApprovalTemplate(id: number, data: Partial<ApprovalTemplate>): Promise<void> {
await delay(200)
const index = mockTemplates.findIndex(t => t.id === id)
if (index !== -1) {
mockTemplates[index] = { ...mockTemplates[index]!, ...data, updatedAt: new Date().toISOString() }
}
}
export async function mockDeleteApprovalTemplate(id: number): Promise<void> {
await delay(200)
mockTemplates = mockTemplates.filter(t => t.id !== id)
}
export async function mockToggleApprovalTemplate(id: number): Promise<void> {
await delay(100)
const template = mockTemplates.find(t => t.id === id)
if (template) {
template.enabled = !template.enabled
template.updatedAt = new Date().toISOString()
}
}
// ================== 实例 API ==================
export async function mockGetApprovalInstanceList(
params: ApprovalInstanceQueryParams = {}
): Promise<ApprovalInstanceListResult> {
await delay(200)
const { page = 1, pageSize = 10, keyword, scenario, status, startDate, endDate } = params
let filtered = [...mockInstances]
if (keyword) {
const kw = keyword.toLowerCase()
filtered = filtered.filter(i =>
i.businessTitle.toLowerCase().includes(kw) ||
i.initiatorName.toLowerCase().includes(kw) ||
i.templateName.toLowerCase().includes(kw)
)
}
if (scenario) {
filtered = filtered.filter(i => i.scenario === scenario)
}
if (status) {
filtered = filtered.filter(i => i.status === status)
}
if (startDate) {
filtered = filtered.filter(i => new Date(i.submittedAt) >= new Date(startDate))
}
if (endDate) {
const end = new Date(endDate)
end.setHours(23, 59, 59, 999)
filtered = filtered.filter(i => new Date(i.submittedAt) <= end)
}
const start = (page - 1) * pageSize
return {
list: filtered.slice(start, start + pageSize),
total: filtered.length,
page,
pageSize
}
}
export async function mockGetApprovalInstanceById(id: number): Promise<ApprovalInstance | null> {
await delay(100)
return mockInstances.find(i => i.id === id) || null
}
// ================== 审批人 API ==================
export async function mockGetApprovers(): Promise<ApproverInfo[]> {
await delay(100)
return mockApprovers
}

1071
src/mock/finance.ts Normal file

File diff suppressed because it is too large Load Diff

7
src/mock/index.ts Normal file
View File

@@ -0,0 +1,7 @@
/**
* Mock数据统一导出
*/
export * from './user'
export * from './finance'
export * from './approval'
export * from './projects'

204
src/mock/projects.ts Normal file
View File

@@ -0,0 +1,204 @@
/**
* 项目管理 Mock 数据
*
* 用于模拟平台管理中的项目和菜单配置
*/
// 菜单项类型
export interface ProjectMenuItem {
key: string
label: string
icon?: string // 图标名称
path?: string
children?: ProjectMenuItem[]
}
// 项目版本类型
export interface ProjectVersion {
id: string
version: string // 版本号,如 1.0.0
description: string // 版本描述
createdAt: string // 创建时间
createdBy: string // 创建人
// 版本快照数据
snapshot: {
name: string
shortName: string
logo: string
color?: string
description?: string
baseUrl?: string
menus: ProjectMenuItem[]
}
}
// 平台项目类型
export interface PlatformProject {
id: string
name: string
shortName: string
logo: string
color?: string
description?: string
enabled: boolean
menuCount: number
createdAt: string
// 项目的访问地址用于iframe嵌套
baseUrl?: string
// 项目的菜单配置
menus: ProjectMenuItem[]
// 当前版本号
currentVersion?: string
// 版本历史
versions?: ProjectVersion[]
// 新增字段
group?: string // 分组
domain?: string // 域名
port?: number // 端口
alias?: string // 代号
enableHttps?: boolean // 启用HTTPS
acmeAccount?: string // Acme账户
certificate?: string // 证书
serverAddress?: string // 服务器地址
remark?: string // 备注
}
// CodePort 的菜单配置
const codePortMenus: ProjectMenuItem[] = [
{
key: 'codePort-dashboard',
label: '控制台',
icon: 'DashboardOutlined',
path: '/dashboard'
},
{
key: 'codePort-community',
label: '社区管理',
icon: 'TeamOutlined',
children: [
{ key: 'codePort-posts', label: '帖子管理', path: '/community/posts' },
{ key: 'codePort-comments', label: '评论管理', path: '/community/comments' },
{ key: 'codePort-tags', label: '标签管理', path: '/community/tags' },
{ key: 'codePort-circles', label: '城市圈子', path: '/community/circles' }
]
},
{
key: 'codePort-content',
label: '内容管理',
icon: 'ReadOutlined',
children: [
{ key: 'codePort-articles', label: '文章管理', path: '/content/articles' }
]
},
{
key: 'codePort-support',
label: '客服管理',
icon: 'CustomerServiceOutlined',
children: [
{ key: 'codePort-support-console', label: '接入会话', path: '/support/console' },
{ key: 'codePort-support-conversations', label: '会话列表', path: '/support/conversations' }
]
},
{
key: 'codePort-project',
label: '项目管理',
icon: 'ProjectOutlined',
children: [
{ key: 'codePort-projects', label: '项目列表', path: '/project/list' },
{ key: 'codePort-recruitment', label: '招募管理', path: '/project/recruitment' },
{ key: 'codePort-signed-projects', label: '已成交项目', path: '/project/signed' },
{ key: 'codePort-contracts', label: '合同管理', path: '/project/contract' },
{ key: 'codePort-sessions', label: '会话管理', path: '/project/sessions' }
]
},
{
key: 'codePort-talent',
label: '人才管理',
icon: 'IdcardOutlined',
children: [
{ key: 'codePort-talent-list', label: '人才列表', path: '/talent' },
{ key: 'codePort-resume-templates', label: '简历模板', path: '/talent/resume-templates' }
]
},
{
key: 'codePort-user',
label: '用户管理',
icon: 'UserOutlined',
children: [
{ key: 'codePort-users', label: '用户列表', path: '/user/list' },
{ key: 'codePort-certification', label: '认证管理', path: '/user/certification' },
{ key: 'codePort-roles', label: '角色管理', path: '/user/roles' },
{ key: 'codePort-positions', label: '岗位管理', path: '/user/positions' },
{ key: 'codePort-levels', label: '等级配置', path: '/user/levels' }
]
}
]
// Mock 项目数据 - 包含 codePort 项目
export const mockProjects: PlatformProject[] = [
{
id: 'codePort',
name: 'CodePort 码头',
shortName: 'CodePort',
logo: '码',
color: '#1890ff',
description: '人才外包平台管理后台',
enabled: true,
menuCount: 25,
createdAt: '2024-01-01T00:00:00Z',
baseUrl: 'http://localhost:5174',
menus: codePortMenus,
currentVersion: '1.0.0',
versions: [
{
id: 'v1',
version: '1.0.0',
description: '初始版本',
createdAt: '2024-01-01T00:00:00Z',
createdBy: '管理员',
snapshot: {
name: 'CodePort 码头',
shortName: 'CodePort',
logo: '码',
color: '#1890ff',
description: '人才外包平台管理后台',
baseUrl: 'http://localhost:5174',
menus: codePortMenus
}
}
]
}
]
/**
* 获取所有启用的项目
*/
export function getEnabledProjects(): PlatformProject[] {
return mockProjects.filter(p => p.enabled)
}
/**
* 根据ID获取项目
*/
export function getProjectById(id: string): PlatformProject | undefined {
return mockProjects.find(p => p.id === id)
}
/**
* 获取项目的菜单配置
*/
export function getProjectMenus(projectId: string): ProjectMenuItem[] {
const project = getProjectById(projectId)
return project?.menus || []
}
/**
* 生成新版本号
*/
export function generateNextVersion(currentVersion?: string): string {
if (!currentVersion) return '1.0.0'
const parts = currentVersion.split('.').map(Number)
parts[2] = (parts[2] || 0) + 1
return parts.join('.')
}

144
src/mock/user.ts Normal file
View File

@@ -0,0 +1,144 @@
/**
* 用户相关模拟数据 - 框架版
*/
import type {
LoginParams,
LoginResult,
CaptchaResult,
UserInfo
} from '@/types'
// 模拟验证码
const captchaCodes: Map<string, string> = new Map()
// 模拟用户数据
const mockUsers: Array<{ username: string; password: string; userInfo: UserInfo }> = [
{
username: 'admin',
password: '123456',
userInfo: {
id: 1,
username: 'admin',
nickname: '超级管理员',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=admin',
email: 'admin@example.com',
phone: '13800138000',
role: 'admin',
permissions: ['*'],
createTime: '2024-01-01 00:00:00',
lastLoginTime: new Date().toLocaleString()
}
},
{
username: 'user',
password: '123456',
userInfo: {
id: 2,
username: 'user',
nickname: '普通用户',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=user',
email: 'user@example.com',
phone: '13900139000',
role: 'user',
permissions: ['read'],
createTime: '2024-06-01 00:00:00',
lastLoginTime: new Date().toLocaleString()
}
}
]
// 生成随机验证码
function generateCaptcha(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
let code = ''
for (let i = 0; i < 4; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length))
}
return code
}
// 生成SVG验证码图片
function generateCaptchaSvg(code: string): string {
const colors = ['#f56a00', '#7265e6', '#ffbf00', '#00a2ae', '#1890ff']
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40" viewBox="0 0 120 40">`
svg += `<rect width="120" height="40" fill="#f0f2f5"/>`
// 添加干扰线
for (let i = 0; i < 4; i++) {
const x1 = Math.random() * 120
const y1 = Math.random() * 40
const x2 = Math.random() * 120
const y2 = Math.random() * 40
const color = colors[Math.floor(Math.random() * colors.length)]
svg += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${color}" stroke-width="1"/>`
}
// 添加验证码文字
for (let i = 0; i < code.length; i++) {
const x = 15 + i * 25
const y = 28 + Math.random() * 6 - 3
const rotate = Math.random() * 30 - 15
const color = colors[Math.floor(Math.random() * colors.length)]
svg += `<text x="${x}" y="${y}" fill="${color}" font-size="22" font-weight="bold" transform="rotate(${rotate} ${x} ${y})">${code[i]}</text>`
}
svg += `</svg>`
return `data:image/svg+xml;base64,${btoa(svg)}`
}
// 模拟延迟
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
// 获取验证码
export async function mockGetCaptcha(): Promise<CaptchaResult> {
await delay(300)
const captchaKey = 'captcha_' + Date.now()
const code = generateCaptcha()
captchaCodes.set(captchaKey, code)
// 5分钟后过期
setTimeout(() => captchaCodes.delete(captchaKey), 5 * 60 * 1000)
return {
captchaKey,
captchaImage: generateCaptchaSvg(code)
}
}
// 登录
export async function mockLogin(params: LoginParams): Promise<LoginResult> {
await delay(500)
// 验证验证码
const storedCode = captchaCodes.get(params.captchaKey)
if (!storedCode || storedCode.toUpperCase() !== params.captcha.toUpperCase()) {
throw new Error('验证码错误')
}
// 验证用户
const user = mockUsers.find(
u => u.username === params.username && u.password === params.password
)
if (!user) {
throw new Error('用户名或密码错误')
}
// 删除已使用的验证码
captchaCodes.delete(params.captchaKey)
return {
token: 'mock_token_' + Date.now(),
refreshToken: 'mock_refresh_token_' + Date.now(),
expires: Date.now() + 24 * 60 * 60 * 1000,
userInfo: user.userInfo
}
}
// 获取用户信息
export async function mockGetUserInfo(): Promise<UserInfo> {
await delay(200)
return mockUsers[0]!.userInfo
}

256
src/router/index.ts Normal file
View File

@@ -0,0 +1,256 @@
/**
* 路由配置 - 框架版
*/
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/index.vue'),
meta: {
title: '登录',
requiresAuth: false
}
},
{
path: '/',
component: () => import('@/layouts/MainLayout.vue'),
redirect: '/finance/overview',
children: [
// 财务管理
{
path: 'finance/overview',
name: 'FinanceOverview',
component: () => import('@/views/finance/overview/index.vue'),
meta: {
title: '财务总览',
requiresAuth: true
}
},
{
path: 'finance/income',
name: 'Income',
component: () => import('@/views/finance/income/index.vue'),
meta: {
title: '收入管理',
requiresAuth: true
}
},
{
path: 'finance/expense',
name: 'Expense',
component: () => import('@/views/finance/expense/index.vue'),
meta: {
title: '支出管理',
requiresAuth: true
}
},
{
path: 'finance/reimbursement',
name: 'Reimbursement',
component: () => import('@/views/finance/reimbursement/index.vue'),
meta: {
title: '报销管理',
requiresAuth: true
}
},
{
path: 'finance/settlement',
name: 'Settlement',
component: () => import('@/views/finance/settlement/index.vue'),
meta: {
title: '结算管理',
requiresAuth: true
}
},
{
path: 'finance/invoice',
name: 'Invoice',
component: () => import('@/views/finance/invoice/index.vue'),
meta: {
title: '发票管理',
requiresAuth: true
}
},
{
path: 'finance/accounts',
name: 'FinanceAccounts',
component: () => import('@/views/finance/accounts/index.vue'),
meta: {
title: '账户管理',
requiresAuth: true
}
},
{
path: 'finance/budget',
name: 'Budget',
component: () => import('@/views/finance/budget/index.vue'),
meta: {
title: '预算管理',
requiresAuth: true
}
},
{
path: 'finance/reports',
name: 'FinanceReports',
component: () => import('@/views/finance/reports/index.vue'),
meta: {
title: '财务报表',
requiresAuth: true
}
},
{
path: 'finance/import',
name: 'FinanceImport',
component: () => import('@/views/finance/import/index.vue'),
meta: {
title: '数据导入',
requiresAuth: true
}
},
{
path: 'finance/advanced-reports',
name: 'AdvancedReports',
component: () => import('@/views/finance/advanced-reports/index.vue'),
meta: {
title: '高级报表',
requiresAuth: true
}
},
// 平台管理
{
path: 'platform/projects',
name: 'PlatformProjects',
component: () => import('@/views/platform/projects/index.vue'),
meta: {
title: '项目管理',
requiresAuth: true
}
},
{
path: 'platform/menus',
name: 'PlatformMenus',
component: () => import('@/views/platform/menus/index.vue'),
meta: {
title: '菜单管理',
requiresAuth: true
}
},
{
path: 'platform/certificates',
name: 'PlatformCertificates',
component: () => import('@/views/platform/certificates/index.vue'),
meta: {
title: '证书管理',
requiresAuth: true
}
},
{
path: 'platform/upload/:projectId?',
name: 'PlatformUpload',
component: () => import('@/views/platform/upload/index.vue'),
meta: {
title: '上传文件',
requiresAuth: true
}
},
// 系统设置
{
path: 'settings',
name: 'Settings',
component: () => import('@/views/settings/index.vue'),
meta: {
title: '系统设置',
requiresAuth: true
}
},
{
path: 'settings/dict',
name: 'Dict',
component: () => import('@/views/settings/dict/index.vue'),
meta: {
title: '字典管理',
requiresAuth: true
}
},
{
path: 'settings/city',
name: 'City',
component: () => import('@/views/settings/city/index.vue'),
meta: {
title: '城市管理',
requiresAuth: true
}
},
// 审批流程管理
{
path: 'system/approval',
name: 'ApprovalFlow',
component: () => import('@/views/system/approval/index.vue'),
meta: {
title: '审批流程',
requiresAuth: true
}
},
{
path: 'system/approval-instances',
name: 'ApprovalInstances',
component: () => import('@/views/system/approval/instances.vue'),
meta: {
title: '审批记录',
requiresAuth: true
}
},
// 子项目 iframe 嵌套路由
{
path: 'app/:projectId/:pathMatch(.*)*',
name: 'SubProjectPage',
component: () => import('@/views/app/IframePage.vue'),
meta: {
title: '子项目',
requiresAuth: true
}
}
]
},
// 404页面
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/error/404.vue'),
meta: {
title: '页面不存在'
}
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// 路由守卫
router.beforeEach((to, _from, next) => {
// 设置页面标题
document.title = `${to.meta.title || '管理后台'} - 后台管理系统`
const token = localStorage.getItem('token')
if (to.meta.requiresAuth && !token) {
// 需要登录但未登录,跳转到登录页
next({ path: '/login', query: { redirect: to.fullPath } })
} else if (to.path === '/login' && token) {
// 已登录访问登录页,跳转到首页
next({ path: '/' })
} else {
next()
}
})
export default router

12
src/stores/index.ts Normal file
View File

@@ -0,0 +1,12 @@
/**
* Pinia Store 统一导出
*/
import { createPinia } from 'pinia'
const pinia = createPinia()
export default pinia
export * from './user'
export * from './project'

292
src/stores/project.ts Normal file
View File

@@ -0,0 +1,292 @@
/**
* 项目状态管理
*
* 管理平台中的业务项目及当前选中的子项目状态
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import {
mockProjects as initialProjects,
generateNextVersion,
type PlatformProject,
type ProjectMenuItem,
type ProjectVersion
} from '@/mock/projects'
export const useProjectStore = defineStore('project', () => {
// 所有项目列表(响应式)
const projects = ref<PlatformProject[]>([...initialProjects])
// 当前选中的项目ID
const currentProjectId = ref<string | null>(null)
// 所有启用的项目列表
const enabledProjects = computed(() => projects.value.filter(p => p.enabled))
// 当前选中的项目
const currentProject = computed<PlatformProject | null>(() => {
if (!currentProjectId.value) return null
return projects.value.find(p => p.id === currentProjectId.value) || null
})
// 当前项目的菜单
const currentProjectMenus = computed<ProjectMenuItem[]>(() => {
return currentProject.value?.menus || []
})
// 当前项目的 baseUrl
const currentProjectBaseUrl = computed<string>(() => {
return currentProject.value?.baseUrl || ''
})
// 是否处于子项目模式
const isInSubProject = computed(() => !!currentProjectId.value)
/**
* 切换到指定项目
*/
function switchProject(projectId: string | null) {
currentProjectId.value = projectId
}
/**
* 退出子项目,返回框架
*/
function exitSubProject() {
currentProjectId.value = null
}
/**
* 添加新项目
*/
function addProject(project: PlatformProject) {
// 为新项目创建初始版本
const initialVersion: ProjectVersion = {
id: `v-${Date.now()}`,
version: '1.0.0',
description: '初始版本',
createdAt: new Date().toISOString(),
createdBy: '管理员',
snapshot: {
name: project.name,
shortName: project.shortName,
logo: project.logo,
color: project.color,
description: project.description,
baseUrl: project.baseUrl,
menus: project.menus || []
}
}
project.currentVersion = '1.0.0'
project.versions = [initialVersion]
projects.value.push(project)
}
/**
* 更新项目
*/
function updateProject(projectId: string, updates: Partial<PlatformProject>) {
const index = projects.value.findIndex(p => p.id === projectId)
if (index > -1) {
projects.value[index] = {
...projects.value[index],
...updates
}
}
}
/**
* 删除项目
*/
function deleteProject(projectId: string) {
const index = projects.value.findIndex(p => p.id === projectId)
if (index > -1) {
projects.value.splice(index, 1)
}
// 如果删除的是当前项目,退出子项目模式
if (currentProjectId.value === projectId) {
currentProjectId.value = null
}
}
/**
* 切换项目状态
*/
function toggleProjectEnabled(projectId: string) {
const project = projects.value.find(p => p.id === projectId)
if (project) {
project.enabled = !project.enabled
}
}
/**
* 根据ID获取项目
*/
function getProjectById(projectId: string): PlatformProject | undefined {
return projects.value.find(p => p.id === projectId)
}
/**
* 创建新版本
*/
function createVersion(projectId: string, description: string): ProjectVersion | null {
const project = projects.value.find(p => p.id === projectId)
if (!project) return null
const newVersion = generateNextVersion(project.currentVersion)
const versionRecord: ProjectVersion = {
id: `v-${Date.now()}`,
version: newVersion,
description,
createdAt: new Date().toISOString(),
createdBy: '管理员',
snapshot: {
name: project.name,
shortName: project.shortName,
logo: project.logo,
color: project.color,
description: project.description,
baseUrl: project.baseUrl,
menus: JSON.parse(JSON.stringify(project.menus)) // 深拷贝
}
}
if (!project.versions) {
project.versions = []
}
project.versions.push(versionRecord)
project.currentVersion = newVersion
return versionRecord
}
/**
* 回退到指定版本
*/
function rollbackToVersion(projectId: string, versionId: string): boolean {
const project = projects.value.find(p => p.id === projectId)
if (!project || !project.versions) return false
const version = project.versions.find(v => v.id === versionId)
if (!version) return false
// 恢复快照数据
const { snapshot } = version
project.name = snapshot.name
project.shortName = snapshot.shortName
project.logo = snapshot.logo
project.color = snapshot.color
project.description = snapshot.description
project.baseUrl = snapshot.baseUrl
project.menus = JSON.parse(JSON.stringify(snapshot.menus)) // 深拷贝
project.currentVersion = version.version
return true
}
/**
* 获取项目的版本列表
*/
function getProjectVersions(projectId: string): ProjectVersion[] {
const project = projects.value.find(p => p.id === projectId)
return project?.versions || []
}
/**
* 删除指定版本(不能删除当前版本)
*/
function deleteVersion(projectId: string, versionId: string): boolean {
const project = projects.value.find(p => p.id === projectId)
if (!project || !project.versions) return false
const versionIndex = project.versions.findIndex(v => v.id === versionId)
if (versionIndex === -1) return false
const version = project.versions[versionIndex]
// 不能删除当前版本
if (version.version === project.currentVersion) return false
project.versions.splice(versionIndex, 1)
return true
}
/**
* 根据菜单key获取完整路由路径
* 返回格式: /app/{projectId}/{path}
*/
function getMenuRoutePath(menuKey: string): string | null {
if (!currentProject.value) return null
const projectId = currentProject.value.id
const menus = currentProject.value.menus
// 递归查找菜单项
function findMenuPath(items: ProjectMenuItem[]): string | null {
for (const item of items) {
if (item.key === menuKey && item.path) {
return `/app/${projectId}${item.path}`
}
if (item.children) {
const found = findMenuPath(item.children)
if (found) return found
}
}
return null
}
return findMenuPath(menus)
}
/**
* 获取菜单项到路由的映射
*/
function getMenuRouteMap(): Record<string, string> {
if (!currentProject.value) return {}
const projectId = currentProject.value.id
const map: Record<string, string> = {}
function processMenus(items: ProjectMenuItem[]) {
for (const item of items) {
if (item.path) {
map[item.key] = `/app/${projectId}${item.path}`
}
if (item.children) {
processMenus(item.children)
}
}
}
processMenus(currentProject.value.menus)
return map
}
return {
// 状态
projects,
currentProjectId,
currentProject,
currentProjectMenus,
currentProjectBaseUrl,
enabledProjects,
isInSubProject,
// 方法
switchProject,
exitSubProject,
addProject,
updateProject,
deleteProject,
toggleProjectEnabled,
getProjectById,
// 版本管理方法
createVersion,
rollbackToVersion,
getProjectVersions,
deleteVersion,
// 菜单路由方法
getMenuRoutePath,
getMenuRouteMap
}
})

117
src/stores/user.ts Normal file
View File

@@ -0,0 +1,117 @@
/**
* 用户状态管理
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { UserInfo, LoginParams, LoginResult, CaptchaResult } from '@/types'
import { mockGetCaptcha, mockLogin, mockGetUserInfo } from '@/mock'
// 是否使用Mock数据
const USE_MOCK = true
export const useUserStore = defineStore('user', () => {
// 状态
const token = ref<string>(localStorage.getItem('token') || '')
const userInfo = ref<UserInfo | null>(null)
// 计算属性
const isLoggedIn = computed(() => !!token.value)
const username = computed(() => userInfo.value?.username || '')
const nickname = computed(() => userInfo.value?.nickname || userInfo.value?.username || '')
const avatar = computed(() => userInfo.value?.avatar || '')
// 获取验证码
async function getCaptcha(): Promise<CaptchaResult> {
if (USE_MOCK) {
return await mockGetCaptcha()
}
// 真实API调用
const { request } = await import('@/utils/request')
const res = await request.get<CaptchaResult>('/auth/captcha')
return res.data.data
}
// 登录
async function login(params: LoginParams): Promise<LoginResult> {
let data: LoginResult
if (USE_MOCK) {
data = await mockLogin(params)
} else {
// 真实API调用
const { request } = await import('@/utils/request')
const res = await request.post<LoginResult>('/auth/login', params)
data = res.data.data
}
// 保存token
token.value = data.token
localStorage.setItem('token', data.token)
// 保存用户信息
userInfo.value = data.userInfo
return data
}
// 登出
async function logout(): Promise<void> {
try {
if (!USE_MOCK) {
const { request } = await import('@/utils/request')
await request.post('/auth/logout')
}
} catch (error) {
console.error('登出请求失败:', error)
} finally {
// 清除本地状态
token.value = ''
userInfo.value = null
localStorage.removeItem('token')
}
}
// 获取用户信息
async function getUserInfo(): Promise<UserInfo> {
if (USE_MOCK) {
const info = await mockGetUserInfo()
userInfo.value = info
return info
}
const { request } = await import('@/utils/request')
const res = await request.get<UserInfo>('/user/info')
userInfo.value = res.data.data
return res.data.data
}
// 设置用户信息
function setUserInfo(info: UserInfo) {
userInfo.value = info
}
// 重置状态
function resetState() {
token.value = ''
userInfo.value = null
localStorage.removeItem('token')
}
return {
// 状态
token,
userInfo,
// 计算属性
isLoggedIn,
username,
nickname,
avatar,
// 方法
getCaptcha,
login,
logout,
getUserInfo,
setUserInfo,
resetState
}
})

48
src/style.css Normal file
View File

@@ -0,0 +1,48 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
color: #333;
background-color: #f0f2f5;
}
#app {
width: 100%;
height: 100%;
}
a {
color: #1890ff;
text-decoration: none;
}
a:hover {
color: #40a9ff;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}

199
src/types/approval.ts Normal file
View File

@@ -0,0 +1,199 @@
/**
* 审批流程管理类型定义
*/
// 审批人选择方式
export type ApproverType = 'specified' | 'role' | 'superior' | 'self_select'
// 审批人类型映射
export const ApproverTypeMap: Record<ApproverType, string> = {
specified: '指定人员',
role: '按角色',
superior: '上级领导',
self_select: '发起人自选'
}
// 审批方式
export type ApprovalMode = 'and' | 'or'
// 审批方式映射
export const ApprovalModeMap: Record<ApprovalMode, string> = {
and: '会签(所有人通过)',
or: '或签(任一人通过)'
}
// 审批节点
export interface ApprovalNode {
id: number
name: string // 节点名称
approverType: ApproverType // 审批人选择方式
approverIds?: number[] // 指定审批人ID列表approverType为specified时使用
approverRole?: string // 审批角色approverType为role时使用
approvalMode: ApprovalMode // 审批方式
timeoutHours?: number // 超时时间(小时)
timeoutAction?: 'skip' | 'reject' // 超时操作
order: number // 节点顺序
}
// 审批流程适用场景
export type ApprovalScenario =
| 'project_publish'
| 'withdrawal'
| 'contract'
| 'certification'
| 'content'
// 财务管理审批场景
| 'expense_reimbursement' // 费用报销
| 'payment_request' // 付款申请
| 'purchase_request' // 采购申请
| 'budget_adjustment' // 预算调整
| 'invoice_apply' // 发票申请
// 场景映射
export const ApprovalScenarioMap: Record<ApprovalScenario, string> = {
project_publish: '项目发布',
withdrawal: '提现申请',
contract: '合同签署',
certification: '用户认证',
content: '内容审核',
// 财务管理
expense_reimbursement: '费用报销',
payment_request: '付款申请',
purchase_request: '采购申请',
budget_adjustment: '预算调整',
invoice_apply: '发票申请'
}
// 审批流程模板
export interface ApprovalTemplate {
id: number
name: string // 模板名称
description?: string // 描述
scenario: ApprovalScenario // 适用场景
nodes: ApprovalNode[] // 审批节点列表
enabled: boolean // 是否启用
createdAt: string
updatedAt: string
}
// 审批实例状态
export type ApprovalInstanceStatus = 'pending' | 'in_progress' | 'approved' | 'rejected' | 'withdrawn' | 'cancelled'
// 实例状态映射
export const ApprovalInstanceStatusMap: Record<ApprovalInstanceStatus, string> = {
pending: '待提交',
in_progress: '审批中',
approved: '已通过',
rejected: '已拒绝',
withdrawn: '已撤回',
cancelled: '已取消'
}
// 实例状态徽章
export const ApprovalInstanceStatusBadgeMap: Record<ApprovalInstanceStatus, 'default' | 'processing' | 'success' | 'error' | 'warning'> = {
pending: 'default',
in_progress: 'processing',
approved: 'success',
rejected: 'error',
withdrawn: 'warning',
cancelled: 'default'
}
// 节点审批记录
export interface NodeApprovalRecord {
nodeId: number
nodeName: string
approverId: number
approverName: string
approverAvatar: string
action: 'approve' | 'reject' | 'transfer' | 'return'
comment?: string
operatedAt: string
}
// 审批实例
export interface ApprovalInstance {
id: number
templateId: number
templateName: string
scenario: ApprovalScenario
// 业务关联
businessType: string // 业务类型
businessId: number // 业务ID
businessTitle: string // 业务标题
// 发起人
initiatorId: number
initiatorName: string
initiatorAvatar: string
// 状态
status: ApprovalInstanceStatus
currentNodeId?: number // 当前节点ID
currentNodeName?: string // 当前节点名称
// 审批记录
records: NodeApprovalRecord[]
// 时间
submittedAt: string
completedAt?: string
createdAt: string
}
// 审批人信息(用于选择)
export interface ApproverInfo {
id: number
name: string
avatar: string
role: string
department?: string
}
// 模板查询参数
export interface ApprovalTemplateQueryParams {
page?: number
pageSize?: number
keyword?: string
scenario?: ApprovalScenario
enabled?: boolean
}
// 模板列表结果
export interface ApprovalTemplateListResult {
list: ApprovalTemplate[]
total: number
page: number
pageSize: number
}
// 实例查询参数
export interface ApprovalInstanceQueryParams {
page?: number
pageSize?: number
keyword?: string
scenario?: ApprovalScenario
status?: ApprovalInstanceStatus
startDate?: string
endDate?: string
}
// 实例列表结果
export interface ApprovalInstanceListResult {
list: ApprovalInstance[]
total: number
page: number
pageSize: number
}
// 统计
export interface ApprovalStats {
totalTemplates: number
enabledTemplates: number
totalInstances: number
pendingInstances: number
inProgressInstances: number
approvedInstances: number
rejectedInstances: number
}

946
src/types/finance.ts Normal file
View File

@@ -0,0 +1,946 @@
/**
* 财务管理相关类型定义
*/
// ==================== 基础类型 ====================
// 货币金额类型(以分为单位存储,显示时转换)
export type MoneyAmount = number
// 通用状态
export type CommonStatus = 'active' | 'inactive'
// ==================== 结算管理(原有) ====================
// 结算状态
export type SettlementStatus = 'pending' | 'paying' | 'completed' | 'rejected'
export const SettlementStatusMap: Record<SettlementStatus, string> = {
pending: '待审核',
paying: '打款中',
completed: '已完成',
rejected: '已驳回'
}
export const SettlementStatusColorMap: Record<SettlementStatus, string> = {
pending: 'orange',
paying: 'cyan',
completed: 'green',
rejected: 'red'
}
// 结算单
export interface SettlementItem {
id: number
projectId: number
projectName: string
talentId: number
talentName: string
period: string // 结算周期/月份
// 金额相关
totalAmount: number // 项目总额 / 本期对账金额
platformFee: number // 平台服务费
taxableAmount: number // 应纳税所得额 (Total - Fee)
taxRate: number // 税率 (0.xx)
taxAmount: number // 扣税金额
actualAmount: number // 实发金额
status: SettlementStatus
invoiceStatus: 'none' | 'pending' | 'received' // 发票状态
bankInfo?: {
accountName: string
accountNo: string
bankName: string
}
createdAt: string
auditTime?: string
paymentTime?: string
remark?: string
}
// 发票类型
export type InvoiceType = 'vat_special' | 'vat_normal' | 'personal'
export const InvoiceTypeMap: Record<InvoiceType, string> = {
vat_special: '增值税专用发票',
vat_normal: '增值税普通发票',
personal: '个人完税证明' // 或者是个人代开
}
// 发票记录
export interface InvoiceItem {
id: number
settlementId: number // 关联结算单
type: InvoiceType
title: string // 抬头
taxCode?: string // 税号(企业)
amount: number
fileUrl?: string // 电子发票文件链接
status: 'pending' | 'issued' | 'rejected'
submitTime: string
issueTime?: string // 开票时间
rejectReason?: string
}
export interface SettlementQueryParams {
page?: number
pageSize?: number
keyword?: string
status?: SettlementStatus
dateRange?: [string, string]
}
// ==================== 账户管理 ====================
// 账户类型
export type AccountType = 'corporate' | 'merchant'
export const AccountTypeMap: Record<AccountType, string> = {
corporate: '对公账户',
merchant: '商户号'
}
// 商户号平台
export type MerchantPlatform = 'wechat' | 'alipay' | 'unionpay' | 'other'
export const MerchantPlatformMap: Record<MerchantPlatform, string> = {
wechat: '微信支付',
alipay: '支付宝',
unionpay: '银联',
other: '其他'
}
// 银行/支付账户
export interface FinanceAccount {
id: number
name: string // 账户名称
type: AccountType
// 对公账户信息
bankName?: string
bankBranch?: string // 开户支行
accountNo?: string
// 商户号信息
merchantId?: string
merchantPlatform?: MerchantPlatform
appId?: string
balance: number // 当前余额
status: CommonStatus
isDefault: boolean
remark?: string
createdAt: string
updatedAt: string
}
// ==================== 收入管理 ====================
// 收入类型
export type IncomeType = 'project' | 'service_fee' | 'consulting' | 'commission' | 'other'
export const IncomeTypeMap: Record<IncomeType, string> = {
project: '项目收入',
service_fee: '平台服务费',
consulting: '咨询服务费',
commission: '佣金收入',
other: '其他收入'
}
export const IncomeTypeColorMap: Record<IncomeType, string> = {
project: 'blue',
service_fee: 'green',
consulting: 'purple',
commission: 'cyan',
other: 'default'
}
// 收款状态
export type IncomeStatus = 'pending' | 'partial' | 'received' | 'overdue'
export const IncomeStatusMap: Record<IncomeStatus, string> = {
pending: '待收款',
partial: '部分收款',
received: '已收款',
overdue: '已逾期'
}
export const IncomeStatusColorMap: Record<IncomeStatus, string> = {
pending: 'orange',
partial: 'cyan',
received: 'green',
overdue: 'red'
}
// 收入记录
export interface IncomeRecord {
id: number
incomeNo: string // 收入编号
type: IncomeType
title: string // 收入名称/描述
// 客户信息
customerId?: number
customerName: string
customerContact?: string
// 关联信息
projectId?: number
projectName?: string
contractNo?: string
// 金额信息
totalAmount: number // 总金额
receivedAmount: number // 已收金额
pendingAmount: number // 待收金额
// 收款账户
accountId: number
accountName: string
status: IncomeStatus
expectedDate?: string // 预计收款日期
actualDate?: string // 实际收款日期
// 发票信息
invoiceRequired: boolean
invoiceIssued: boolean
invoiceNo?: string
remark?: string
createdBy: string
createdAt: string
updatedAt: string
}
// 收款记录(一笔收入可能分多次收款)
export interface IncomePayment {
id: number
incomeId: number
amount: number
paymentMethod: 'bank' | 'wechat' | 'alipay' | 'cash' | 'other'
accountId: number
transactionNo?: string // 交易流水号
paymentDate: string
remark?: string
createdBy: string
createdAt: string
}
// ==================== 支出管理 ====================
// 支出类型
export type ExpenseType =
| 'salary' // 工资
| 'office' // 办公费
| 'rent' // 房租水电
| 'travel' // 差旅费
| 'marketing' // 市场推广
| 'equipment' // 设备采购
| 'service' // 外包服务
| 'tax' // 税费
| 'social_insurance' // 社保公积金
| 'other' // 其他
export const ExpenseTypeMap: Record<ExpenseType, string> = {
salary: '工资薪酬',
office: '办公费用',
rent: '房租水电',
travel: '差旅费用',
marketing: '市场推广',
equipment: '设备采购',
service: '外包服务',
tax: '税费',
social_insurance: '社保公积金',
other: '其他支出'
}
export const ExpenseTypeColorMap: Record<ExpenseType, string> = {
salary: 'blue',
office: 'cyan',
rent: 'purple',
travel: 'orange',
marketing: 'magenta',
equipment: 'geekblue',
service: 'volcano',
tax: 'gold',
social_insurance: 'lime',
other: 'default'
}
// 支出状态
export type ExpenseStatus = 'draft' | 'pending' | 'approved' | 'paid' | 'rejected'
export const ExpenseStatusMap: Record<ExpenseStatus, string> = {
draft: '草稿',
pending: '待审批',
approved: '已批准',
paid: '已支付',
rejected: '已驳回'
}
export const ExpenseStatusColorMap: Record<ExpenseStatus, string> = {
draft: 'default',
pending: 'orange',
approved: 'cyan',
paid: 'green',
rejected: 'red'
}
// 支出记录
export interface ExpenseRecord {
id: number
expenseNo: string // 支出编号
type: ExpenseType
title: string
// 收款方信息
payeeName: string
payeeAccount?: string
payeeBankName?: string
// 金额信息
amount: number
// 关联信息
projectId?: number
projectName?: string
departmentId?: number
departmentName?: string
// 付款账户
accountId?: number
accountName?: string
status: ExpenseStatus
// 审批信息
approvalId?: number // 关联审批流程实例ID
approvalStatus?: string
// 附件/凭证
attachments?: string[]
paymentDate?: string
remark?: string
createdBy: string
createdAt: string
updatedAt: string
}
// ==================== 报销管理 ====================
// 报销类型
export type ReimbursementType =
| 'travel' // 差旅报销
| 'meal' // 餐饮报销
| 'transport' // 交通报销
| 'communication' // 通讯报销
| 'office' // 办公用品
| 'other' // 其他
export const ReimbursementTypeMap: Record<ReimbursementType, string> = {
travel: '差旅报销',
meal: '餐饮报销',
transport: '交通报销',
communication: '通讯报销',
office: '办公用品',
other: '其他报销'
}
export const ReimbursementTypeColorMap: Record<ReimbursementType, string> = {
travel: 'blue',
meal: 'orange',
transport: 'cyan',
communication: 'purple',
office: 'green',
other: 'default'
}
// 报销状态
export type ReimbursementStatus = 'draft' | 'pending' | 'approved' | 'paid' | 'rejected'
export const ReimbursementStatusMap: Record<ReimbursementStatus, string> = {
draft: '草稿',
pending: '审批中',
approved: '已批准',
paid: '已打款',
rejected: '已驳回'
}
export const ReimbursementStatusColorMap: Record<ReimbursementStatus, string> = {
draft: 'default',
pending: 'orange',
approved: 'cyan',
paid: 'green',
rejected: 'red'
}
// 报销单
export interface ReimbursementRecord {
id: number
reimbursementNo: string // 报销单号
type: ReimbursementType
title: string
// 申请人信息
applicantId: number
applicantName: string
departmentId?: number
departmentName?: string
// 金额
totalAmount: number
// 明细
items: ReimbursementItem[]
status: ReimbursementStatus
// 审批信息
approvalId?: number
approvalStatus?: string
currentApprover?: string
// 收款信息
bankAccountName?: string
bankAccountNo?: string
bankName?: string
// 打款信息
paymentAccountId?: number
paymentDate?: string
paymentRemark?: string
remark?: string
createdAt: string
updatedAt: string
}
// 报销明细项
export interface ReimbursementItem {
id: number
type: ReimbursementType
description: string
amount: number
occurDate: string // 费用发生日期
attachments?: string[] // 凭证/发票图片
}
// ==================== 预算管理 ====================
// 预算周期
export type BudgetPeriod = 'monthly' | 'quarterly' | 'yearly'
export const BudgetPeriodMap: Record<BudgetPeriod, string> = {
monthly: '月度',
quarterly: '季度',
yearly: '年度'
}
// 预算状态
export type BudgetStatus = 'draft' | 'active' | 'completed' | 'cancelled'
export const BudgetStatusMap: Record<BudgetStatus, string> = {
draft: '草稿',
active: '执行中',
completed: '已完成',
cancelled: '已取消'
}
// 预算记录
export interface BudgetRecord {
id: number
name: string
period: BudgetPeriod
year: number
quarter?: number // 1-4
month?: number // 1-12
// 部门/项目
departmentId?: number
departmentName?: string
projectId?: number
projectName?: string
// 预算明细
items: BudgetItem[]
totalBudget: number // 总预算
usedAmount: number // 已使用
remainingAmount: number // 剩余
usageRate: number // 使用率 (0-100)
status: BudgetStatus
remark?: string
createdBy: string
createdAt: string
updatedAt: string
}
// 预算明细项(按费用类型)
export interface BudgetItem {
expenseType: ExpenseType
budgetAmount: number
usedAmount: number
remainingAmount: number
}
// ==================== 财务统计 ====================
// 财务概览数据
export interface FinanceOverview {
// 本月数据
monthlyIncome: number
monthlyExpense: number
monthlyProfit: number
// 本年数据
yearlyIncome: number
yearlyExpense: number
yearlyProfit: number
// 账户余额
totalBalance: number
corporateBalance: number
merchantBalance: number
// 待处理事项
pendingReimbursements: number
pendingExpenses: number
overdueReceivables: number
// 趋势数据
incomesTrend: TrendItem[]
expensesTrend: TrendItem[]
}
export interface TrendItem {
date: string // YYYY-MM
amount: number
label?: string
}
// 收支分类统计
export interface CategoryStats {
category: string
amount: number
percentage: number
count: number
}
// ==================== 查询参数 ====================
export interface IncomeQueryParams {
page?: number
pageSize?: number
keyword?: string
type?: IncomeType
status?: IncomeStatus
accountId?: number
dateRange?: [string, string]
}
export interface ExpenseQueryParams {
page?: number
pageSize?: number
keyword?: string
type?: ExpenseType
status?: ExpenseStatus
accountId?: number
dateRange?: [string, string]
}
export interface ReimbursementQueryParams {
page?: number
pageSize?: number
keyword?: string
type?: ReimbursementType
status?: ReimbursementStatus
applicantId?: number
dateRange?: [string, string]
}
export interface BudgetQueryParams {
page?: number
pageSize?: number
period?: BudgetPeriod
year?: number
departmentId?: number
status?: BudgetStatus
}
// ==================== 多级审批流程 ====================
// 审批节点类型
export type ApprovalNodeType = 'approver' | 'cc' | 'condition'
// 审批节点状态
export type ApprovalNodeStatus = 'pending' | 'approved' | 'rejected' | 'skipped'
export const ApprovalNodeStatusMap: Record<ApprovalNodeStatus, string> = {
pending: '待审批',
approved: '已通过',
rejected: '已驳回',
skipped: '已跳过'
}
export const ApprovalNodeStatusColorMap: Record<ApprovalNodeStatus, string> = {
pending: 'orange',
approved: 'green',
rejected: 'red',
skipped: 'default'
}
// 财务审批节点 (与 approval.ts 中的 ApprovalNode 不同,这是用于财务多级审批的节点)
export interface FinanceApprovalNode {
id: number
nodeType: ApprovalNodeType
nodeName: string
// 审批人信息
approverId?: number
approverName?: string
approverRole?: string // 按角色审批时使用
// 条件节点使用
condition?: {
field: 'amount' | 'type' | 'department'
operator: '>' | '<' | '=' | '>=' | '<='
value: number | string
}
// 节点状态
status: ApprovalNodeStatus
remark?: string
actionTime?: string
// 节点顺序
order: number
}
// 审批流程实例
export interface ApprovalFlowInstance {
id: number
flowTemplateId: number // 关联审批流程模板
flowTemplateName: string
// 关联业务
businessType: 'expense' | 'reimbursement' | 'contract' | 'payment'
businessId: number
businessNo: string
businessTitle: string
// 申请人
applicantId: number
applicantName: string
applicantDepartment?: string
// 审批金额
amount: number
// 审批节点
nodes: FinanceApprovalNode[]
currentNodeIndex: number
// 整体状态
status: 'pending' | 'approved' | 'rejected' | 'cancelled'
createdAt: string
completedAt?: string
}
// 审批流程模板(用于配置多级审批)
export interface ApprovalFlowTemplate {
id: number
name: string
businessType: 'expense' | 'reimbursement' | 'contract' | 'payment'
description?: string
// 触发条件
triggerConditions?: {
minAmount?: number
maxAmount?: number
departments?: number[]
}
// 节点配置
nodeConfigs: {
nodeType: ApprovalNodeType
nodeName: string
approverType: 'user' | 'role' | 'department_head' | 'applicant_superior'
approverId?: number
approverRole?: string
condition?: FinanceApprovalNode['condition']
}[]
status: CommonStatus
createdAt: string
updatedAt: string
}
// ==================== 数据导入 ====================
// 导入状态
export type ImportStatus = 'pending' | 'processing' | 'completed' | 'failed'
export const ImportStatusMap: Record<ImportStatus, string> = {
pending: '待处理',
processing: '导入中',
completed: '已完成',
failed: '导入失败'
}
// 导入记录类型
export type ImportDataType = 'income' | 'expense' | 'reimbursement'
export const ImportDataTypeMap: Record<ImportDataType, string> = {
income: '收入记录',
expense: '支出记录',
reimbursement: '报销记录'
}
// 导入记录
export interface ImportRecord {
id: number
// 文件信息
fileName: string
fileSize: number // bytes
fileUrl: string
// 导入类型
dataType: ImportDataType
// 导入结果
status: ImportStatus
totalRows: number
successRows: number
failedRows: number
// 错误信息
errors?: {
row: number
field: string
message: string
originalValue?: string
}[]
// 操作信息
importedBy: string
createdAt: string
completedAt?: string
}
// 导入数据预览行
export interface ImportPreviewRow {
rowIndex: number
data: Record<string, any>
isValid: boolean
errors?: string[]
}
// ==================== 高级财务报表 ====================
// 资产负债表项
export interface BalanceSheetItem {
code: string // 科目编码
name: string // 科目名称
level: number // 层级 1-3
parentCode?: string
// 金额
endingBalance: number // 期末余额
beginningBalance: number // 期初余额
// 类型
category: 'asset' | 'liability' | 'equity'
}
// 资产负债表
export interface BalanceSheet {
reportDate: string // 报表日期 YYYY-MM-DD
// 资产
assets: {
// 流动资产
current: {
cash: number // 货币资金
receivables: number // 应收账款
prepayments: number // 预付账款
inventory: number // 存货
otherCurrent: number // 其他流动资产
totalCurrent: number // 流动资产合计
}
// 非流动资产
nonCurrent: {
fixedAssets: number // 固定资产
intangibleAssets: number // 无形资产
longTermInvestments: number // 长期投资
otherNonCurrent: number // 其他非流动资产
totalNonCurrent: number // 非流动资产合计
}
total: number // 资产总计
}
// 负债
liabilities: {
// 流动负债
current: {
shortTermLoans: number // 短期借款
payables: number // 应付账款
advanceReceipts: number // 预收账款
taxesPayable: number // 应交税费
otherCurrent: number // 其他流动负债
totalCurrent: number // 流动负债合计
}
// 非流动负债
nonCurrent: {
longTermLoans: number // 长期借款
otherNonCurrent: number // 其他非流动负债
totalNonCurrent: number // 非流动负债合计
}
total: number // 负债合计
}
// 所有者权益
equity: {
paidInCapital: number // 实收资本(股本)
capitalReserve: number // 资本公积
surplusReserve: number // 盈余公积
undistributedProfit: number // 未分配利润
total: number // 所有者权益合计
}
// 负债和所有者权益总计
totalLiabilitiesAndEquity: number
}
// 现金流量表项分类
export type CashFlowCategory = 'operating' | 'investing' | 'financing'
// 现金流量表
export interface CashFlowStatement {
reportPeriod: string // 报表期间 YYYY-MM
// 经营活动产生的现金流量
operating: {
// 流入
inflows: {
salesGoods: number // 销售商品、提供劳务收到的现金
taxRefunds: number // 收到的税费返还
otherOperating: number // 收到其他与经营活动有关的现金
totalInflows: number // 经营活动现金流入小计
}
// 流出
outflows: {
purchaseGoods: number // 购买商品、接受劳务支付的现金
employeePayments: number // 支付给职工以及为职工支付的现金
taxes: number // 支付的各项税费
otherOperating: number // 支付其他与经营活动有关的现金
totalOutflows: number // 经营活动现金流出小计
}
netCashFlow: number // 经营活动产生的现金流量净额
}
// 投资活动产生的现金流量
investing: {
inflows: {
investmentReturns: number // 收回投资收到的现金
investmentIncome: number // 取得投资收益收到的现金
disposalAssets: number // 处置固定资产等收回的现金
otherInvesting: number // 收到其他与投资活动有关的现金
totalInflows: number
}
outflows: {
purchaseAssets: number // 购建固定资产等支付的现金
investments: number // 投资支付的现金
otherInvesting: number // 支付其他与投资活动有关的现金
totalOutflows: number
}
netCashFlow: number // 投资活动产生的现金流量净额
}
// 筹资活动产生的现金流量
financing: {
inflows: {
capitalContributions: number // 吸收投资收到的现金
borrowings: number // 取得借款收到的现金
otherFinancing: number // 收到其他与筹资活动有关的现金
totalInflows: number
}
outflows: {
debtRepayments: number // 偿还债务支付的现金
dividends: number // 分配股利、利润支付的现金
otherFinancing: number // 支付其他与筹资活动有关的现金
totalOutflows: number
}
netCashFlow: number // 筹资活动产生的现金流量净额
}
// 汇率变动对现金的影响
exchangeRateEffect: number
// 现金及现金等价物净增加额
netCashIncrease: number
// 期初现金余额
beginningCash: number
// 期末现金余额
endingCash: number
}
// 利润表
export interface IncomeStatement {
reportPeriod: string // 报表期间 YYYY-MM
// 收入
revenue: {
mainBusiness: number // 主营业务收入
otherBusiness: number // 其他业务收入
total: number // 营业收入合计
}
// 成本
costs: {
mainBusiness: number // 主营业务成本
otherBusiness: number // 其他业务成本
total: number // 营业成本合计
}
// 毛利润
grossProfit: number
// 期间费用
periodExpenses: {
selling: number // 销售费用
administrative: number // 管理费用
financial: number // 财务费用
researchDevelopment: number // 研发费用
total: number
}
// 营业利润
operatingProfit: number
// 营业外收支
nonOperating: {
income: number // 营业外收入
expenses: number // 营业外支出
net: number
}
// 利润总额
totalProfit: number
// 所得税费用
incomeTax: number
// 净利润
netProfit: number
}

9
src/types/index.ts Normal file
View File

@@ -0,0 +1,9 @@
/**
* 类型统一导出
*/
export * from './user'
export * from './response'
export * from './finance'
export * from './approval'
export * from './upload'

33
src/types/response.ts Normal file
View File

@@ -0,0 +1,33 @@
/**
* 响应相关类型定义
*/
// 基础响应结构
export interface ApiResponse<T = unknown> {
code: number
message: string
data: T
success: boolean
}
// 分页请求参数
export interface PageParams {
page: number
pageSize: number
}
// 分页响应数据
export interface PageResult<T = unknown> {
list: T[]
total: number
page: number
pageSize: number
totalPages: number
}
// 列表响应
export interface ListResult<T = unknown> {
list: T[]
total: number
}

28
src/types/upload.ts Normal file
View File

@@ -0,0 +1,28 @@
/**
* 上传相关类型定义
*/
/**
* 上传文件类型
*/
export interface UploadFile {
uid: string
name: string
path: string
size: number
type: string
status: 'pending' | 'uploading' | 'done' | 'error'
percent: number
file: File
}
/**
* 重复文件类型
*/
export interface DuplicateFile {
uid: string
name: string
path: string
size: number
file: File
}

39
src/types/user.ts Normal file
View File

@@ -0,0 +1,39 @@
/**
* 用户相关类型定义
*/
// 用户信息(用于鉴权等场景)
export interface UserInfo {
id: number
username: string
nickname: string
avatar?: string
email?: string
phone?: string
role: string
permissions?: string[]
createTime?: string
lastLoginTime?: string
}
// 登录请求参数
export interface LoginParams {
username: string
password: string
captcha: string
captchaKey: string
}
// 登录响应数据
export interface LoginResult {
token: string
refreshToken?: string
expires: number
userInfo: UserInfo
}
// 验证码响应
export interface CaptchaResult {
captchaKey: string
captchaImage: string
}

77
src/utils/common.ts Normal file
View File

@@ -0,0 +1,77 @@
/**
* 公共工具函数
*/
import type { Key } from 'ant-design-vue/es/table/interface'
import type { TablePaginationConfig } from 'ant-design-vue'
/**
* 格式化日期
*/
export function formatDate(str: string): string {
return new Date(str).toLocaleDateString('zh-CN')
}
/**
* 格式化日期时间
*/
export function formatDateTime(str: string): string {
return new Date(str).toLocaleString('zh-CN')
}
/**
* 格式化数字超过1000显示为x.xk
*/
export function formatCount(num: number): string {
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'k'
}
return num.toString()
}
/**
* 从数组中随机取一个元素(带类型安全)
*/
export function randomItem<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)]!
}
/**
* 模拟延迟
*/
export function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
/**
* 表格选择变更处理器类型
*/
export type TableSelectChange = (keys: Key[]) => void
/**
* 表格分页变更处理器类型
*/
export type TablePaginationChange = (pagination: TablePaginationConfig) => void
/**
* 创建表格分页变更处理函数
*/
export function createTableChangeHandler(
paginationRef: { current: number; pageSize: number },
loadFn: () => void
) {
return (pagination: TablePaginationConfig) => {
paginationRef.current = pagination.current || 1
paginationRef.pageSize = pagination.pageSize || 10
loadFn()
}
}
/**
* 创建表格选择变更处理函数
*/
export function createSelectChangeHandler(selectedKeysRef: { value: Key[] }) {
return (keys: Key[]) => {
selectedKeysRef.value = keys
}
}

112
src/utils/request.ts Normal file
View File

@@ -0,0 +1,112 @@
/**
* Axios 基础配置
*/
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
import { message } from 'ant-design-vue'
import type { ApiResponse } from '@/types'
// 创建axios实例
const service: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器
service.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 从localStorage获取token
const token = localStorage.getItem('token')
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
console.error('请求错误:', error)
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
const res = response.data
// 根据业务状态码判断请求是否成功
if (res.code === 200 || res.success) {
return response
}
// 处理业务错误
message.error(res.message || '请求失败')
// 处理特定错误码
if (res.code === 401) {
// token过期或未授权跳转登录
localStorage.removeItem('token')
window.location.href = '/login'
}
return Promise.reject(new Error(res.message || '请求失败'))
},
(error) => {
console.error('响应错误:', error)
// 处理HTTP错误
let errorMessage = '网络错误,请稍后重试'
if (error.response) {
const { status } = error.response
switch (status) {
case 400:
errorMessage = '请求参数错误'
break
case 401:
errorMessage = '未授权,请重新登录'
localStorage.removeItem('token')
window.location.href = '/login'
break
case 403:
errorMessage = '拒绝访问'
break
case 404:
errorMessage = '请求资源不存在'
break
case 500:
errorMessage = '服务器内部错误'
break
default:
errorMessage = `请求失败(${status})`
}
} else if (error.code === 'ECONNABORTED') {
errorMessage = '请求超时,请稍后重试'
}
message.error(errorMessage)
return Promise.reject(error)
}
)
// 封装请求方法
export const request = {
get<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<ApiResponse<T>>> {
return service.get(url, config)
},
post<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<AxiosResponse<ApiResponse<T>>> {
return service.post(url, data, config)
},
put<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<AxiosResponse<ApiResponse<T>>> {
return service.put(url, data, config)
},
delete<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<ApiResponse<T>>> {
return service.delete(url, config)
}
}
export default service

View File

@@ -0,0 +1,134 @@
<template>
<div class="iframe-container">
<div v-if="loading" class="iframe-loading">
<a-spin size="large" tip="加载中..." />
</div>
<iframe
v-if="iframeSrc"
ref="iframeRef"
:src="iframeSrc"
class="iframe-content"
frameborder="0"
@load="handleIframeLoad"
/>
<div v-else class="iframe-empty">
<a-empty description="未配置项目地址" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useProjectStore } from '@/stores/project'
const route = useRoute()
const router = useRouter()
const projectStore = useProjectStore()
const iframeRef = ref<HTMLIFrameElement | null>(null)
const loading = ref(true)
// 计算 iframe 的 src
const iframeSrc = computed(() => {
const baseUrl = projectStore.currentProjectBaseUrl
if (!baseUrl) return ''
// 从路由获取子路径
// 路由格式: /app/:projectId/:pathMatch(.*)
const fullPath = route.fullPath
const projectId = route.params.projectId as string
// 提取子路径部分
const prefix = `/app/${projectId}`
let subPath = fullPath.startsWith(prefix)
? fullPath.slice(prefix.length)
: ''
// 确保以 / 开头
if (subPath && !subPath.startsWith('/')) {
subPath = '/' + subPath
}
// 如果没有子路径,使用根路径
if (!subPath) {
subPath = '/'
}
// 添加标记,告诉子项目自己是被嵌套的
const separator = subPath.includes('?') ? '&' : '?'
return `${baseUrl}${subPath}${separator}__embedded=true`
})
// 处理 iframe 加载完成
function handleIframeLoad() {
loading.value = false
}
// 处理来自子项目的消息
function handleMessage(event: MessageEvent) {
// 检查消息类型
if (event.data && event.data.type === 'NAVIGATE_HOME') {
// 子项目请求返回首页,退出子项目模式并导航到框架首页
projectStore.exitSubProject()
router.push('/finance/overview')
}
}
// 监听路由变化,重新加载 iframe
watch(() => route.fullPath, () => {
loading.value = true
})
onMounted(() => {
// 确保当前项目已设置
const projectId = route.params.projectId as string
if (projectId && projectStore.currentProjectId !== projectId) {
projectStore.switchProject(projectId)
}
// 监听来自子项目的消息
window.addEventListener('message', handleMessage)
})
onUnmounted(() => {
// 移除消息监听
window.removeEventListener('message', handleMessage)
})
</script>
<style scoped>
.iframe-container {
width: 100%;
height: 100%;
position: relative;
background: #f0f2f5;
}
.iframe-loading {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.9);
z-index: 10;
}
.iframe-content {
width: 100%;
height: 100%;
border: none;
}
.iframe-empty {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
</style>

33
src/views/error/404.vue Normal file
View File

@@ -0,0 +1,33 @@
<template>
<div class="not-found">
<a-result
status="404"
title="404"
sub-title="抱歉您访问的页面不存在"
>
<template #extra>
<a-button type="primary" @click="goHome">返回首页</a-button>
</template>
</a-result>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
function goHome() {
router.push('/')
}
</script>
<style scoped>
.not-found {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
</style>

View File

@@ -0,0 +1,671 @@
<template>
<div class="accounts-page">
<a-page-header title="账户管理" sub-title="管理公司对公账户及商户号" />
<a-row :gutter="16">
<!-- 左侧账户列表 -->
<a-col :span="16">
<a-card
title="账户列表"
:bordered="false"
>
<template #extra>
<a-button type="primary" @click="handleAdd">
<PlusOutlined /> 新增账户
</a-button>
</template>
<a-spin :spinning="loading">
<div class="account-grid">
<div
v-for="account in accounts"
:key="account.id"
class="account-card"
:class="{ active: currentAccount?.id === account.id, inactive: account.status === 'inactive' }"
@click="selectAccount(account)"
>
<div class="account-header">
<div class="account-icon" :class="getAccountIconClass(account)">
<BankOutlined v-if="account.type === 'corporate'" />
<WechatOutlined v-else-if="account.merchantPlatform === 'wechat'" />
<AlipayCircleOutlined v-else-if="account.merchantPlatform === 'alipay'" />
<CreditCardOutlined v-else />
</div>
<div class="account-meta">
<div class="account-name">{{ account.name }}</div>
<a-tag :color="account.type === 'corporate' ? 'blue' : 'green'" size="small">
{{ AccountTypeMap[account.type] }}
</a-tag>
<a-tag v-if="account.isDefault" color="gold" size="small">默认</a-tag>
</div>
<a-badge
:status="account.status === 'active' ? 'success' : 'default'"
:text="account.status === 'active' ? '正常' : '停用'"
/>
</div>
<div class="account-balance">
<div class="balance-label">账户余额</div>
<div class="balance-value">¥{{ account.balance.toLocaleString('zh-CN', { minimumFractionDigits: 2 }) }}</div>
</div>
<div class="account-info">
<template v-if="account.type === 'corporate'">
<div class="info-item">
<span class="label">开户行:</span>
<span class="value">{{ account.bankName }}</span>
</div>
<div class="info-item">
<span class="label">账号:</span>
<span class="value">{{ maskAccountNo(account.accountNo) }}</span>
</div>
</template>
<template v-else>
<div class="info-item">
<span class="label">平台:</span>
<span class="value">{{ MerchantPlatformMap[account.merchantPlatform as keyof typeof MerchantPlatformMap] || '-' }}</span>
</div>
<div class="info-item">
<span class="label">商户号:</span>
<span class="value">{{ account.merchantId }}</span>
</div>
</template>
</div>
<div class="account-actions">
<a-button type="link" size="small" @click.stop="handleEdit(account)">编辑</a-button>
<a-divider type="vertical" />
<a-popconfirm
:title="`确定${account.status === 'active' ? '停用' : '启用'}该账户?`"
@confirm="handleToggleStatus(account)"
>
<a-button type="link" size="small" @click.stop>
{{ account.status === 'active' ? '停用' : '启用' }}
</a-button>
</a-popconfirm>
</div>
</div>
</div>
<a-empty v-if="accounts.length === 0" description="暂无账户" />
</a-spin>
</a-card>
</a-col>
<!-- 右侧汇总信息 -->
<a-col :span="8">
<a-card title="资金汇总" :bordered="false" class="summary-card">
<div class="summary-item total">
<div class="summary-icon">
<WalletOutlined />
</div>
<div class="summary-info">
<div class="summary-label">总余额</div>
<div class="summary-value">¥{{ totalBalance.toLocaleString('zh-CN', { minimumFractionDigits: 2 }) }}</div>
</div>
</div>
<a-divider />
<div class="summary-item corporate">
<div class="summary-icon">
<BankOutlined />
</div>
<div class="summary-info">
<div class="summary-label">对公账户</div>
<div class="summary-value">¥{{ corporateBalance.toLocaleString('zh-CN', { minimumFractionDigits: 2 }) }}</div>
</div>
</div>
<div class="summary-item merchant">
<div class="summary-icon">
<CreditCardOutlined />
</div>
<div class="summary-info">
<div class="summary-label">商户号</div>
<div class="summary-value">¥{{ merchantBalance.toLocaleString('zh-CN', { minimumFractionDigits: 2 }) }}</div>
</div>
</div>
</a-card>
<a-card title="账户统计" :bordered="false" class="stats-card" style="margin-top: 16px">
<a-row :gutter="16">
<a-col :span="12">
<a-statistic title="账户总数" :value="accounts.length" suffix="个" />
</a-col>
<a-col :span="12">
<a-statistic title="正常账户" :value="activeCount" suffix="个" :value-style="{ color: '#52c41a' }" />
</a-col>
</a-row>
<a-divider />
<div class="account-type-stats">
<div class="type-stat">
<span class="type-label">对公账户</span>
<span class="type-count">{{ corporateCount }}</span>
</div>
<div class="type-stat">
<span class="type-label">商户号</span>
<span class="type-count">{{ merchantCount }}</span>
</div>
</div>
</a-card>
<!-- 选中账户详情 -->
<a-card
v-if="currentAccount"
title="账户详情"
:bordered="false"
class="detail-card"
style="margin-top: 16px"
>
<a-descriptions :column="1" size="small">
<a-descriptions-item label="账户名称">{{ currentAccount.name }}</a-descriptions-item>
<a-descriptions-item label="账户类型">
<a-tag :color="currentAccount.type === 'corporate' ? 'blue' : 'green'">
{{ AccountTypeMap[currentAccount.type] }}
</a-tag>
</a-descriptions-item>
<template v-if="currentAccount.type === 'corporate'">
<a-descriptions-item label="开户银行">{{ currentAccount.bankName }}</a-descriptions-item>
<a-descriptions-item label="开户支行">{{ currentAccount.bankBranch || '-' }}</a-descriptions-item>
<a-descriptions-item label="银行账号">{{ currentAccount.accountNo }}</a-descriptions-item>
</template>
<template v-else>
<a-descriptions-item label="支付平台">
{{ MerchantPlatformMap[currentAccount.merchantPlatform as keyof typeof MerchantPlatformMap] || '-' }}
</a-descriptions-item>
<a-descriptions-item label="商户号">{{ currentAccount.merchantId }}</a-descriptions-item>
<a-descriptions-item label="AppID">{{ currentAccount.appId || '-' }}</a-descriptions-item>
</template>
<a-descriptions-item label="当前余额">
<span style="color: #52c41a; font-weight: 600">
¥{{ currentAccount.balance.toLocaleString('zh-CN', { minimumFractionDigits: 2 }) }}
</span>
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-badge
:status="currentAccount.status === 'active' ? 'success' : 'default'"
:text="currentAccount.status === 'active' ? '正常' : '停用'"
/>
</a-descriptions-item>
<a-descriptions-item label="备注">{{ currentAccount.remark || '-' }}</a-descriptions-item>
<a-descriptions-item label="创建时间">{{ formatDate(currentAccount.createdAt) }}</a-descriptions-item>
<a-descriptions-item label="更新时间">{{ formatDate(currentAccount.updatedAt) }}</a-descriptions-item>
</a-descriptions>
</a-card>
</a-col>
</a-row>
<!-- 新增/编辑弹窗 -->
<a-modal
v-model:open="formVisible"
:title="editingAccount?.id ? '编辑账户' : '新增账户'"
width="600px"
@ok="handleFormSubmit"
:confirm-loading="formLoading"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<a-form-item label="账户类型" name="type">
<a-radio-group v-model:value="formData.type" :disabled="!!editingAccount?.id">
<a-radio-button value="corporate">对公账户</a-radio-button>
<a-radio-button value="merchant">商户号</a-radio-button>
</a-radio-group>
</a-form-item>
<a-form-item label="账户名称" name="name">
<a-input v-model:value="formData.name" placeholder="请输入账户名称" />
</a-form-item>
<!-- 对公账户字段 -->
<template v-if="formData.type === 'corporate'">
<a-form-item label="开户银行" name="bankName">
<a-input v-model:value="formData.bankName" placeholder="请输入开户银行" />
</a-form-item>
<a-form-item label="开户支行">
<a-input v-model:value="formData.bankBranch" placeholder="请输入开户支行(选填)" />
</a-form-item>
<a-form-item label="银行账号" name="accountNo">
<a-input v-model:value="formData.accountNo" placeholder="请输入银行账号" />
</a-form-item>
</template>
<!-- 商户号字段 -->
<template v-else>
<a-form-item label="支付平台" name="merchantPlatform">
<a-select v-model:value="formData.merchantPlatform" placeholder="请选择支付平台">
<a-select-option v-for="(label, key) in MerchantPlatformMap" :key="key" :value="key">
{{ label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="商户号" name="merchantId">
<a-input v-model:value="formData.merchantId" placeholder="请输入商户号" />
</a-form-item>
<a-form-item label="AppID">
<a-input v-model:value="formData.appId" placeholder="请输入AppID选填" />
</a-form-item>
</template>
<a-form-item label="当前余额">
<a-input-number
v-model:value="formData.balance"
:min="0"
:precision="2"
:formatter="(value: any) => `¥ ${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')"
:parser="(value: any) => value.replace(/¥\s?|(,*)/g, '')"
style="width: 200px"
/>
</a-form-item>
<a-form-item label="设为默认">
<a-switch v-model:checked="formData.isDefault" />
</a-form-item>
<a-form-item label="备注">
<a-textarea v-model:value="formData.remark" :rows="2" placeholder="备注信息(选填)" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import {
PlusOutlined,
BankOutlined,
WechatOutlined,
AlipayCircleOutlined,
CreditCardOutlined,
WalletOutlined
} from '@ant-design/icons-vue'
import type { FinanceAccount, AccountType, MerchantPlatform } from '@/types'
import { AccountTypeMap, MerchantPlatformMap } from '@/types'
import { mockGetAccounts, mockSaveAccount } from '@/mock'
import { formatDate } from '@/utils/common'
const loading = ref(false)
const formVisible = ref(false)
const formLoading = ref(false)
const accounts = ref<FinanceAccount[]>([])
const currentAccount = ref<FinanceAccount | null>(null)
const editingAccount = ref<FinanceAccount | null>(null)
const formRef = ref()
const formData = reactive({
type: 'corporate' as AccountType,
name: '',
bankName: '',
bankBranch: '',
accountNo: '',
merchantPlatform: 'wechat' as MerchantPlatform,
merchantId: '',
appId: '',
balance: 0,
isDefault: false,
remark: ''
})
const formRules = computed(() => {
const baseRules = {
type: [{ required: true, message: '请选择账户类型' }],
name: [{ required: true, message: '请输入账户名称' }]
}
if (formData.type === 'corporate') {
return {
...baseRules,
bankName: [{ required: true, message: '请输入开户银行' }],
accountNo: [{ required: true, message: '请输入银行账号' }]
}
} else {
return {
...baseRules,
merchantPlatform: [{ required: true, message: '请选择支付平台' }],
merchantId: [{ required: true, message: '请输入商户号' }]
}
}
})
// 计算属性
const totalBalance = computed(() => accounts.value.reduce((sum, a) => sum + a.balance, 0))
const corporateBalance = computed(() =>
accounts.value.filter(a => a.type === 'corporate').reduce((sum, a) => sum + a.balance, 0)
)
const merchantBalance = computed(() =>
accounts.value.filter(a => a.type === 'merchant').reduce((sum, a) => sum + a.balance, 0)
)
const activeCount = computed(() => accounts.value.filter(a => a.status === 'active').length)
const corporateCount = computed(() => accounts.value.filter(a => a.type === 'corporate').length)
const merchantCount = computed(() => accounts.value.filter(a => a.type === 'merchant').length)
// 辅助函数
function getAccountIconClass(account: FinanceAccount): string {
if (account.type === 'corporate') return 'corporate'
if (account.merchantPlatform === 'wechat') return 'wechat'
if (account.merchantPlatform === 'alipay') return 'alipay'
return 'other'
}
function maskAccountNo(accountNo?: string): string {
if (!accountNo) return '-'
if (accountNo.length <= 8) return accountNo
return accountNo.slice(0, 4) + '****' + accountNo.slice(-4)
}
function selectAccount(account: FinanceAccount) {
currentAccount.value = account
}
// 加载数据
async function loadAccounts() {
loading.value = true
try {
const res = await mockGetAccounts()
accounts.value = res.list
if (res.list.length > 0 && !currentAccount.value) {
currentAccount.value = res.list[0]!
}
} catch (error) {
message.error('加载失败')
} finally {
loading.value = false
}
}
function handleAdd() {
editingAccount.value = null
Object.assign(formData, {
type: 'corporate',
name: '',
bankName: '',
bankBranch: '',
accountNo: '',
merchantPlatform: 'wechat',
merchantId: '',
appId: '',
balance: 0,
isDefault: false,
remark: ''
})
formVisible.value = true
}
function handleEdit(account: FinanceAccount) {
editingAccount.value = account
Object.assign(formData, {
type: account.type,
name: account.name,
bankName: account.bankName || '',
bankBranch: account.bankBranch || '',
accountNo: account.accountNo || '',
merchantPlatform: account.merchantPlatform || 'wechat',
merchantId: account.merchantId || '',
appId: account.appId || '',
balance: account.balance,
isDefault: account.isDefault,
remark: account.remark || ''
})
formVisible.value = true
}
async function handleFormSubmit() {
try {
await formRef.value.validate()
} catch {
return
}
formLoading.value = true
try {
const saveData: Partial<FinanceAccount> = {
id: editingAccount.value?.id,
type: formData.type,
name: formData.name,
balance: formData.balance,
isDefault: formData.isDefault,
status: editingAccount.value?.status || 'active',
remark: formData.remark || undefined
}
if (formData.type === 'corporate') {
saveData.bankName = formData.bankName
saveData.bankBranch = formData.bankBranch || undefined
saveData.accountNo = formData.accountNo
} else {
saveData.merchantPlatform = formData.merchantPlatform
saveData.merchantId = formData.merchantId
saveData.appId = formData.appId || undefined
}
await mockSaveAccount(saveData)
message.success(editingAccount.value?.id ? '编辑成功' : '新增成功')
formVisible.value = false
loadAccounts()
} catch (error) {
message.error('操作失败')
} finally {
formLoading.value = false
}
}
async function handleToggleStatus(account: FinanceAccount) {
try {
await mockSaveAccount({
id: account.id,
status: account.status === 'active' ? 'inactive' : 'active'
})
message.success('操作成功')
loadAccounts()
} catch (error) {
message.error('操作失败')
}
}
onMounted(() => {
loadAccounts()
})
</script>
<style scoped>
.accounts-page {
min-height: 100%;
overflow-y: auto;
}
/* 账户网格 */
.account-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.account-card {
border: 1px solid #f0f0f0;
border-radius: 12px;
padding: 16px;
cursor: pointer;
transition: all 0.3s;
background: #fafafa;
}
.account-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border-color: #1890ff;
}
.account-card.active {
border-color: #1890ff;
background: linear-gradient(135deg, #e6f7ff 0%, #ffffff 100%);
}
.account-card.inactive {
opacity: 0.6;
}
.account-header {
display: flex;
align-items: center;
margin-bottom: 16px;
}
.account-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
margin-right: 12px;
}
.account-icon.corporate {
background: linear-gradient(135deg, #1890ff 0%, #69c0ff 100%);
color: #fff;
}
.account-icon.wechat {
background: linear-gradient(135deg, #52c41a 0%, #95de64 100%);
color: #fff;
}
.account-icon.alipay {
background: linear-gradient(135deg, #1677ff 0%, #4096ff 100%);
color: #fff;
}
.account-icon.other {
background: linear-gradient(135deg, #722ed1 0%, #b37feb 100%);
color: #fff;
}
.account-meta {
flex: 1;
}
.account-name {
font-weight: 600;
color: #1a1a2e;
margin-bottom: 4px;
}
.account-balance {
background: #f5f5f5;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.balance-label {
font-size: 12px;
color: #8c8c8c;
margin-bottom: 4px;
}
.balance-value {
font-size: 20px;
font-weight: 600;
color: #52c41a;
}
.account-info {
margin-bottom: 12px;
}
.info-item {
display: flex;
justify-content: space-between;
padding: 4px 0;
font-size: 13px;
}
.info-item .label {
color: #8c8c8c;
}
.info-item .value {
color: #1a1a2e;
}
.account-actions {
text-align: right;
border-top: 1px dashed #f0f0f0;
padding-top: 12px;
}
/* 汇总卡片 */
.summary-card .summary-item {
display: flex;
align-items: center;
padding: 12px 0;
}
.summary-item.total .summary-icon {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.summary-item.corporate .summary-icon {
background: linear-gradient(135deg, #1890ff 0%, #69c0ff 100%);
}
.summary-item.merchant .summary-icon {
background: linear-gradient(135deg, #52c41a 0%, #95de64 100%);
}
.summary-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: #fff;
margin-right: 16px;
}
.summary-info {
flex: 1;
}
.summary-label {
font-size: 13px;
color: #8c8c8c;
margin-bottom: 4px;
}
.summary-value {
font-size: 18px;
font-weight: 600;
color: #1a1a2e;
}
/* 统计卡片 */
.account-type-stats {
display: flex;
flex-direction: column;
gap: 8px;
}
.type-stat {
display: flex;
justify-content: space-between;
padding: 8px 12px;
background: #fafafa;
border-radius: 8px;
}
.type-label {
color: #666;
}
.type-count {
font-weight: 500;
color: #1a1a2e;
}
/* 详情卡片 */
.detail-card {
max-height: 400px;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,898 @@
<template>
<div class="advanced-reports-page">
<a-page-header title="高级财务报表" sub-title="资产负债表现金流量表利润表" />
<a-card :bordered="false">
<a-tabs v-model:activeKey="activeTab" @change="handleTabChange">
<!-- 资产负债表 -->
<a-tab-pane key="balance" tab="资产负债表">
<div class="report-header">
<a-date-picker
v-model:value="balanceDate"
picker="date"
placeholder="选择报表日期"
style="width: 150px"
/>
<a-button type="primary" @click="loadBalanceSheet">查询</a-button>
<a-button @click="exportReport('balance')">
<DownloadOutlined /> 导出
</a-button>
</div>
<div class="balance-sheet" v-if="balanceSheet">
<a-row :gutter="24">
<!-- 资产 -->
<a-col :span="12">
<div class="sheet-section">
<div class="section-title">资产</div>
<div class="sub-section">
<div class="sub-title">流动资产</div>
<div class="sheet-row">
<span class="item-name">货币资金</span>
<span class="item-value">¥{{ formatMoney(balanceSheet.assets.current.cash) }}</span>
</div>
<div class="sheet-row">
<span class="item-name">应收账款</span>
<span class="item-value">¥{{ formatMoney(balanceSheet.assets.current.receivables) }}</span>
</div>
<div class="sheet-row">
<span class="item-name">预付账款</span>
<span class="item-value">¥{{ formatMoney(balanceSheet.assets.current.prepayments) }}</span>
</div>
<div class="sheet-row">
<span class="item-name">存货</span>
<span class="item-value">¥{{ formatMoney(balanceSheet.assets.current.inventory) }}</span>
</div>
<div class="sheet-row">
<span class="item-name">其他流动资产</span>
<span class="item-value">¥{{ formatMoney(balanceSheet.assets.current.otherCurrent) }}</span>
</div>
<div class="sheet-row subtotal">
<span class="item-name">流动资产合计</span>
<span class="item-value">¥{{ formatMoney(balanceSheet.assets.current.totalCurrent) }}</span>
</div>
</div>
<div class="sub-section">
<div class="sub-title">非流动资产</div>
<div class="sheet-row">
<span class="item-name">固定资产</span>
<span class="item-value">¥{{ formatMoney(balanceSheet.assets.nonCurrent.fixedAssets) }}</span>
</div>
<div class="sheet-row">
<span class="item-name">无形资产</span>
<span class="item-value">¥{{ formatMoney(balanceSheet.assets.nonCurrent.intangibleAssets) }}</span>
</div>
<div class="sheet-row">
<span class="item-name">长期投资</span>
<span class="item-value">¥{{ formatMoney(balanceSheet.assets.nonCurrent.longTermInvestments) }}</span>
</div>
<div class="sheet-row subtotal">
<span class="item-name">非流动资产合计</span>
<span class="item-value">¥{{ formatMoney(balanceSheet.assets.nonCurrent.totalNonCurrent) }}</span>
</div>
</div>
<div class="sheet-row total">
<span class="item-name">资产总计</span>
<span class="item-value">¥{{ formatMoney(balanceSheet.assets.total) }}</span>
</div>
</div>
</a-col>
<!-- 负债及所有者权益 -->
<a-col :span="12">
<div class="sheet-section">
<div class="section-title">负债及所有者权益</div>
<div class="sub-section">
<div class="sub-title">流动负债</div>
<div class="sheet-row">
<span class="item-name">短期借款</span>
<span class="item-value">¥{{ formatMoney(balanceSheet.liabilities.current.shortTermLoans) }}</span>
</div>
<div class="sheet-row">
<span class="item-name">应付账款</span>
<span class="item-value">¥{{ formatMoney(balanceSheet.liabilities.current.payables) }}</span>
</div>
<div class="sheet-row">
<span class="item-name">预收账款</span>
<span class="item-value">¥{{ formatMoney(balanceSheet.liabilities.current.advanceReceipts) }}</span>
</div>
<div class="sheet-row">
<span class="item-name">应交税费</span>
<span class="item-value">¥{{ formatMoney(balanceSheet.liabilities.current.taxesPayable) }}</span>
</div>
<div class="sheet-row subtotal">
<span class="item-name">流动负债合计</span>
<span class="item-value">¥{{ formatMoney(balanceSheet.liabilities.current.totalCurrent) }}</span>
</div>
</div>
<div class="sub-section">
<div class="sub-title">非流动负债</div>
<div class="sheet-row">
<span class="item-name">长期借款</span>
<span class="item-value">¥{{ formatMoney(balanceSheet.liabilities.nonCurrent.longTermLoans) }}</span>
</div>
<div class="sheet-row subtotal">
<span class="item-name">非流动负债合计</span>
<span class="item-value">¥{{ formatMoney(balanceSheet.liabilities.nonCurrent.totalNonCurrent) }}</span>
</div>
</div>
<div class="sheet-row subtotal">
<span class="item-name">负债合计</span>
<span class="item-value">¥{{ formatMoney(balanceSheet.liabilities.total) }}</span>
</div>
<div class="sub-section">
<div class="sub-title">所有者权益</div>
<div class="sheet-row">
<span class="item-name">实收资本</span>
<span class="item-value">¥{{ formatMoney(balanceSheet.equity.paidInCapital) }}</span>
</div>
<div class="sheet-row">
<span class="item-name">资本公积</span>
<span class="item-value">¥{{ formatMoney(balanceSheet.equity.capitalReserve) }}</span>
</div>
<div class="sheet-row">
<span class="item-name">盈余公积</span>
<span class="item-value">¥{{ formatMoney(balanceSheet.equity.surplusReserve) }}</span>
</div>
<div class="sheet-row">
<span class="item-name">未分配利润</span>
<span class="item-value">¥{{ formatMoney(balanceSheet.equity.undistributedProfit) }}</span>
</div>
<div class="sheet-row subtotal">
<span class="item-name">所有者权益合计</span>
<span class="item-value">¥{{ formatMoney(balanceSheet.equity.total) }}</span>
</div>
</div>
<div class="sheet-row total">
<span class="item-name">负债和所有者权益总计</span>
<span class="item-value">¥{{ formatMoney(balanceSheet.totalLiabilitiesAndEquity) }}</span>
</div>
</div>
</a-col>
</a-row>
</div>
</a-tab-pane>
<!-- 现金流量表 -->
<a-tab-pane key="cashflow" tab="现金流量表">
<div class="report-header">
<a-date-picker
v-model:value="cashflowMonth"
picker="month"
placeholder="选择月份"
style="width: 150px"
/>
<a-button type="primary" @click="loadCashFlow">查询</a-button>
<a-button @click="exportReport('cashflow')">
<DownloadOutlined /> 导出
</a-button>
</div>
<div class="cashflow-statement" v-if="cashFlow">
<!-- 经营活动 -->
<div class="cf-section">
<div class="cf-title">
<span class="title-text">经营活动产生的现金流量</span>
<span class="net-amount" :class="cashFlow.operating.netCashFlow >= 0 ? 'positive' : 'negative'">
净额: ¥{{ formatMoney(cashFlow.operating.netCashFlow) }}
</span>
</div>
<a-row :gutter="24">
<a-col :span="12">
<div class="cf-sub">
<div class="cf-sub-title">现金流入</div>
<div class="cf-row">
<span>销售商品收到的现金</span>
<span>¥{{ formatMoney(cashFlow.operating.inflows.salesGoods) }}</span>
</div>
<div class="cf-row">
<span>收到的税费返还</span>
<span>¥{{ formatMoney(cashFlow.operating.inflows.taxRefunds) }}</span>
</div>
<div class="cf-row">
<span>其他与经营活动有关的现金</span>
<span>¥{{ formatMoney(cashFlow.operating.inflows.otherOperating) }}</span>
</div>
<div class="cf-row subtotal">
<span>现金流入小计</span>
<span>¥{{ formatMoney(cashFlow.operating.inflows.totalInflows) }}</span>
</div>
</div>
</a-col>
<a-col :span="12">
<div class="cf-sub">
<div class="cf-sub-title">现金流出</div>
<div class="cf-row">
<span>购买商品支付的现金</span>
<span>¥{{ formatMoney(cashFlow.operating.outflows.purchaseGoods) }}</span>
</div>
<div class="cf-row">
<span>支付给职工的现金</span>
<span>¥{{ formatMoney(cashFlow.operating.outflows.employeePayments) }}</span>
</div>
<div class="cf-row">
<span>支付的各项税费</span>
<span>¥{{ formatMoney(cashFlow.operating.outflows.taxes) }}</span>
</div>
<div class="cf-row">
<span>其他与经营活动有关的现金</span>
<span>¥{{ formatMoney(cashFlow.operating.outflows.otherOperating) }}</span>
</div>
<div class="cf-row subtotal">
<span>现金流出小计</span>
<span>¥{{ formatMoney(cashFlow.operating.outflows.totalOutflows) }}</span>
</div>
</div>
</a-col>
</a-row>
</div>
<!-- 投资活动 -->
<div class="cf-section">
<div class="cf-title">
<span class="title-text">投资活动产生的现金流量</span>
<span class="net-amount" :class="cashFlow.investing.netCashFlow >= 0 ? 'positive' : 'negative'">
净额: ¥{{ formatMoney(cashFlow.investing.netCashFlow) }}
</span>
</div>
<a-row :gutter="24">
<a-col :span="12">
<div class="cf-sub">
<div class="cf-sub-title">现金流入</div>
<div class="cf-row">
<span>收回投资收到的现金</span>
<span>¥{{ formatMoney(cashFlow.investing.inflows.investmentReturns) }}</span>
</div>
<div class="cf-row">
<span>取得投资收益收到的现金</span>
<span>¥{{ formatMoney(cashFlow.investing.inflows.investmentIncome) }}</span>
</div>
<div class="cf-row subtotal">
<span>现金流入小计</span>
<span>¥{{ formatMoney(cashFlow.investing.inflows.totalInflows) }}</span>
</div>
</div>
</a-col>
<a-col :span="12">
<div class="cf-sub">
<div class="cf-sub-title">现金流出</div>
<div class="cf-row">
<span>购建固定资产支付的现金</span>
<span>¥{{ formatMoney(cashFlow.investing.outflows.purchaseAssets) }}</span>
</div>
<div class="cf-row">
<span>投资支付的现金</span>
<span>¥{{ formatMoney(cashFlow.investing.outflows.investments) }}</span>
</div>
<div class="cf-row subtotal">
<span>现金流出小计</span>
<span>¥{{ formatMoney(cashFlow.investing.outflows.totalOutflows) }}</span>
</div>
</div>
</a-col>
</a-row>
</div>
<!-- 筹资活动 -->
<div class="cf-section">
<div class="cf-title">
<span class="title-text">筹资活动产生的现金流量</span>
<span class="net-amount" :class="cashFlow.financing.netCashFlow >= 0 ? 'positive' : 'negative'">
净额: ¥{{ formatMoney(cashFlow.financing.netCashFlow) }}
</span>
</div>
<a-row :gutter="24">
<a-col :span="12">
<div class="cf-sub">
<div class="cf-sub-title">现金流入</div>
<div class="cf-row">
<span>吸收投资收到的现金</span>
<span>¥{{ formatMoney(cashFlow.financing.inflows.capitalContributions) }}</span>
</div>
<div class="cf-row">
<span>取得借款收到的现金</span>
<span>¥{{ formatMoney(cashFlow.financing.inflows.borrowings) }}</span>
</div>
<div class="cf-row subtotal">
<span>现金流入小计</span>
<span>¥{{ formatMoney(cashFlow.financing.inflows.totalInflows) }}</span>
</div>
</div>
</a-col>
<a-col :span="12">
<div class="cf-sub">
<div class="cf-sub-title">现金流出</div>
<div class="cf-row">
<span>偿还债务支付的现金</span>
<span>¥{{ formatMoney(cashFlow.financing.outflows.debtRepayments) }}</span>
</div>
<div class="cf-row">
<span>分配股利支付的现金</span>
<span>¥{{ formatMoney(cashFlow.financing.outflows.dividends) }}</span>
</div>
<div class="cf-row subtotal">
<span>现金流出小计</span>
<span>¥{{ formatMoney(cashFlow.financing.outflows.totalOutflows) }}</span>
</div>
</div>
</a-col>
</a-row>
</div>
<!-- 汇总 -->
<div class="cf-summary">
<div class="summary-row">
<span>汇率变动对现金的影响</span>
<span>¥{{ formatMoney(cashFlow.exchangeRateEffect) }}</span>
</div>
<div class="summary-row highlight">
<span>现金及现金等价物净增加额</span>
<span :class="cashFlow.netCashIncrease >= 0 ? 'positive' : 'negative'">
¥{{ formatMoney(cashFlow.netCashIncrease) }}
</span>
</div>
<div class="summary-row">
<span>期初现金余额</span>
<span>¥{{ formatMoney(cashFlow.beginningCash) }}</span>
</div>
<div class="summary-row highlight">
<span>期末现金余额</span>
<span class="positive">¥{{ formatMoney(cashFlow.endingCash) }}</span>
</div>
</div>
</div>
</a-tab-pane>
<!-- 利润表 -->
<a-tab-pane key="income" tab="利润表">
<div class="report-header">
<a-date-picker
v-model:value="incomeMonth"
picker="month"
placeholder="选择月份"
style="width: 150px"
/>
<a-button type="primary" @click="loadIncomeStatement">查询</a-button>
<a-button @click="exportReport('income')">
<DownloadOutlined /> 导出
</a-button>
</div>
<div class="income-statement" v-if="incomeStatement">
<div class="is-section">
<div class="is-row header">
<span class="item-name">项目</span>
<span class="item-value">本期金额</span>
</div>
<!-- 营业收入 -->
<div class="is-row">
<span class="item-name">营业收入</span>
<span class="item-value positive">¥{{ formatMoney(incomeStatement.revenue.total) }}</span>
</div>
<div class="is-row indent">
<span class="item-name">其中主营业务收入</span>
<span class="item-value">¥{{ formatMoney(incomeStatement.revenue.mainBusiness) }}</span>
</div>
<div class="is-row indent">
<span class="item-name">其他业务收入</span>
<span class="item-value">¥{{ formatMoney(incomeStatement.revenue.otherBusiness) }}</span>
</div>
<!-- 营业成本 -->
<div class="is-row">
<span class="item-name">营业成本</span>
<span class="item-value negative">¥{{ formatMoney(incomeStatement.costs.total) }}</span>
</div>
<!-- 毛利润 -->
<div class="is-row highlight">
<span class="item-name">毛利润</span>
<span class="item-value" :class="incomeStatement.grossProfit >= 0 ? 'positive' : 'negative'">
¥{{ formatMoney(incomeStatement.grossProfit) }}
</span>
</div>
<!-- 期间费用 -->
<div class="is-row">
<span class="item-name">销售费用</span>
<span class="item-value negative">¥{{ formatMoney(incomeStatement.periodExpenses.selling) }}</span>
</div>
<div class="is-row">
<span class="item-name">管理费用</span>
<span class="item-value negative">¥{{ formatMoney(incomeStatement.periodExpenses.administrative) }}</span>
</div>
<div class="is-row">
<span class="item-name">财务费用</span>
<span class="item-value negative">¥{{ formatMoney(incomeStatement.periodExpenses.financial) }}</span>
</div>
<div class="is-row">
<span class="item-name">研发费用</span>
<span class="item-value negative">¥{{ formatMoney(incomeStatement.periodExpenses.researchDevelopment) }}</span>
</div>
<!-- 营业利润 -->
<div class="is-row highlight">
<span class="item-name">营业利润</span>
<span class="item-value" :class="incomeStatement.operatingProfit >= 0 ? 'positive' : 'negative'">
¥{{ formatMoney(incomeStatement.operatingProfit) }}
</span>
</div>
<!-- 营业外收支 -->
<div class="is-row">
<span class="item-name">营业外收入</span>
<span class="item-value positive">¥{{ formatMoney(incomeStatement.nonOperating.income) }}</span>
</div>
<div class="is-row">
<span class="item-name">营业外支出</span>
<span class="item-value negative">¥{{ formatMoney(incomeStatement.nonOperating.expenses) }}</span>
</div>
<!-- 利润总额 -->
<div class="is-row highlight">
<span class="item-name">利润总额</span>
<span class="item-value" :class="incomeStatement.totalProfit >= 0 ? 'positive' : 'negative'">
¥{{ formatMoney(incomeStatement.totalProfit) }}
</span>
</div>
<!-- 所得税 -->
<div class="is-row">
<span class="item-name">所得税费用</span>
<span class="item-value negative">¥{{ formatMoney(incomeStatement.incomeTax) }}</span>
</div>
<!-- 净利润 -->
<div class="is-row total">
<span class="item-name">净利润</span>
<span class="item-value" :class="incomeStatement.netProfit >= 0 ? 'positive' : 'negative'">
¥{{ formatMoney(incomeStatement.netProfit) }}
</span>
</div>
</div>
</div>
</a-tab-pane>
</a-tabs>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { DownloadOutlined } from '@ant-design/icons-vue'
import dayjs from 'dayjs'
import type { Dayjs } from 'dayjs'
import type { BalanceSheet, CashFlowStatement, IncomeStatement } from '@/types'
const activeTab = ref('balance')
const balanceDate = ref<Dayjs>(dayjs())
const cashflowMonth = ref<Dayjs>(dayjs())
const incomeMonth = ref<Dayjs>(dayjs())
const balanceSheet = ref<BalanceSheet | null>(null)
const cashFlow = ref<CashFlowStatement | null>(null)
const incomeStatement = ref<IncomeStatement | null>(null)
function formatMoney(value: number): string {
return value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
// 生成资产负债表Mock数据
function loadBalanceSheet() {
balanceSheet.value = {
reportDate: balanceDate.value.format('YYYY-MM-DD'),
assets: {
current: {
cash: 2897100,
receivables: 580000,
prepayments: 120000,
inventory: 50000,
otherCurrent: 30000,
totalCurrent: 3677100
},
nonCurrent: {
fixedAssets: 850000,
intangibleAssets: 150000,
longTermInvestments: 500000,
otherNonCurrent: 20000,
totalNonCurrent: 1520000
},
total: 5197100
},
liabilities: {
current: {
shortTermLoans: 200000,
payables: 350000,
advanceReceipts: 180000,
taxesPayable: 85000,
otherCurrent: 25000,
totalCurrent: 840000
},
nonCurrent: {
longTermLoans: 500000,
otherNonCurrent: 0,
totalNonCurrent: 500000
},
total: 1340000
},
equity: {
paidInCapital: 2000000,
capitalReserve: 500000,
surplusReserve: 350000,
undistributedProfit: 1007100,
total: 3857100
},
totalLiabilitiesAndEquity: 5197100
}
message.success('资产负债表加载完成')
}
// 生成现金流量表Mock数据
function loadCashFlow() {
cashFlow.value = {
reportPeriod: cashflowMonth.value.format('YYYY-MM'),
operating: {
inflows: {
salesGoods: 850000,
taxRefunds: 15000,
otherOperating: 25000,
totalInflows: 890000
},
outflows: {
purchaseGoods: 280000,
employeePayments: 350000,
taxes: 85000,
otherOperating: 45000,
totalOutflows: 760000
},
netCashFlow: 130000
},
investing: {
inflows: {
investmentReturns: 50000,
investmentIncome: 25000,
disposalAssets: 0,
otherInvesting: 0,
totalInflows: 75000
},
outflows: {
purchaseAssets: 120000,
investments: 100000,
otherInvesting: 0,
totalOutflows: 220000
},
netCashFlow: -145000
},
financing: {
inflows: {
capitalContributions: 0,
borrowings: 200000,
otherFinancing: 0,
totalInflows: 200000
},
outflows: {
debtRepayments: 100000,
dividends: 50000,
otherFinancing: 10000,
totalOutflows: 160000
},
netCashFlow: 40000
},
exchangeRateEffect: 0,
netCashIncrease: 25000,
beginningCash: 2872100,
endingCash: 2897100
}
message.success('现金流量表加载完成')
}
// 生成利润表Mock数据
function loadIncomeStatement() {
const revenue = 831900
const costs = 207000
const grossProfit = revenue - costs
const selling = 85000
const administrative = 120000
const financial = 15000
const rd = 65000
const totalExpenses = selling + administrative + financial + rd
const operatingProfit = grossProfit - totalExpenses
const nonOpIncome = 12000
const nonOpExpense = 3000
const totalProfit = operatingProfit + nonOpIncome - nonOpExpense
const tax = totalProfit * 0.25
incomeStatement.value = {
reportPeriod: incomeMonth.value.format('YYYY-MM'),
revenue: {
mainBusiness: 760300,
otherBusiness: 71600,
total: revenue
},
costs: {
mainBusiness: 185000,
otherBusiness: 22000,
total: costs
},
grossProfit,
periodExpenses: {
selling,
administrative,
financial,
researchDevelopment: rd,
total: totalExpenses
},
operatingProfit,
nonOperating: {
income: nonOpIncome,
expenses: nonOpExpense,
net: nonOpIncome - nonOpExpense
},
totalProfit,
incomeTax: tax,
netProfit: totalProfit - tax
}
message.success('利润表加载完成')
}
function handleTabChange(key: string | number) {
const k = String(key)
if (k === 'balance' && !balanceSheet.value) {
loadBalanceSheet()
} else if (k === 'cashflow' && !cashFlow.value) {
loadCashFlow()
} else if (k === 'income' && !incomeStatement.value) {
loadIncomeStatement()
}
}
function exportReport(type: string) {
let content = ''
const dateStr = dayjs().format('YYYY-MM-DD')
if (type === 'balance' && balanceSheet.value) {
content = `资产负债表\n报表日期: ${balanceSheet.value.reportDate}\n\n`
content += `资产\n`
content += `货币资金,${balanceSheet.value.assets.current.cash}\n`
content += `应收账款,${balanceSheet.value.assets.current.receivables}\n`
content += `资产总计,${balanceSheet.value.assets.total}\n\n`
content += `负债及所有者权益\n`
content += `负债合计,${balanceSheet.value.liabilities.total}\n`
content += `所有者权益合计,${balanceSheet.value.equity.total}\n`
} else if (type === 'cashflow' && cashFlow.value) {
content = `现金流量表\n报表期间: ${cashFlow.value.reportPeriod}\n\n`
content += `经营活动现金流量净额,${cashFlow.value.operating.netCashFlow}\n`
content += `投资活动现金流量净额,${cashFlow.value.investing.netCashFlow}\n`
content += `筹资活动现金流量净额,${cashFlow.value.financing.netCashFlow}\n`
content += `现金净增加额,${cashFlow.value.netCashIncrease}\n`
content += `期末现金余额,${cashFlow.value.endingCash}\n`
} else if (type === 'income' && incomeStatement.value) {
content = `利润表\n报表期间: ${incomeStatement.value.reportPeriod}\n\n`
content += `营业收入,${incomeStatement.value.revenue.total}\n`
content += `营业成本,${incomeStatement.value.costs.total}\n`
content += `毛利润,${incomeStatement.value.grossProfit}\n`
content += `营业利润,${incomeStatement.value.operatingProfit}\n`
content += `利润总额,${incomeStatement.value.totalProfit}\n`
content += `净利润,${incomeStatement.value.netProfit}\n`
}
const BOM = '\uFEFF'
const blob = new Blob([BOM + content], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `${type === 'balance' ? '资产负债表' : type === 'cashflow' ? '现金流量表' : '利润表'}_${dateStr}.csv`
link.click()
URL.revokeObjectURL(link.href)
message.success('导出成功')
}
onMounted(() => {
loadBalanceSheet()
})
</script>
<style scoped>
.advanced-reports-page {
min-height: 100%;
}
.report-header {
display: flex;
gap: 12px;
margin-bottom: 24px;
padding: 16px;
background: #fafafa;
border-radius: 8px;
}
/* 资产负债表样式 */
.sheet-section {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 16px;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #1890ff;
padding-bottom: 12px;
border-bottom: 2px solid #1890ff;
margin-bottom: 16px;
}
.sub-section {
margin-bottom: 16px;
}
.sub-title {
font-weight: 500;
color: #666;
margin-bottom: 8px;
padding-left: 8px;
border-left: 3px solid #1890ff;
}
.sheet-row {
display: flex;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px dashed #f0f0f0;
}
.sheet-row.subtotal {
background: #f5f5f5;
font-weight: 500;
}
.sheet-row.total {
background: linear-gradient(135deg, #1890ff 0%, #69c0ff 100%);
color: #fff;
font-weight: 600;
border-radius: 4px;
margin-top: 12px;
}
.item-name {
color: #333;
}
.item-value {
font-family: 'Monaco', monospace;
}
/* 现金流量表样式 */
.cf-section {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.cf-title {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 12px;
border-bottom: 2px solid #1890ff;
margin-bottom: 16px;
}
.title-text {
font-size: 15px;
font-weight: 600;
color: #1a1a2e;
}
.net-amount {
font-size: 16px;
font-weight: 600;
}
.net-amount.positive { color: #52c41a; }
.net-amount.negative { color: #ff4d4f; }
.cf-sub {
background: #fafafa;
border-radius: 8px;
padding: 12px;
}
.cf-sub-title {
font-weight: 500;
color: #1890ff;
margin-bottom: 8px;
}
.cf-row {
display: flex;
justify-content: space-between;
padding: 6px 0;
font-size: 13px;
border-bottom: 1px dashed #e8e8e8;
}
.cf-row.subtotal {
font-weight: 500;
border-bottom: none;
padding-top: 8px;
margin-top: 4px;
border-top: 1px solid #d9d9d9;
}
.cf-summary {
background: #f0f8ff;
border-radius: 8px;
padding: 16px;
}
.summary-row {
display: flex;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px dashed #d9d9d9;
}
.summary-row.highlight {
font-weight: 600;
font-size: 15px;
}
/* 利润表样式 */
.is-section {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
overflow: hidden;
}
.is-row {
display: flex;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
}
.is-row.header {
background: #fafafa;
font-weight: 600;
}
.is-row.indent .item-name {
padding-left: 24px;
color: #666;
font-size: 13px;
}
.is-row.highlight {
background: #f0f8ff;
font-weight: 500;
}
.is-row.total {
background: linear-gradient(135deg, #1890ff 0%, #69c0ff 100%);
color: #fff;
font-weight: 600;
font-size: 16px;
}
.is-row.total .item-value {
color: #fff !important;
}
.positive { color: #52c41a; }
.negative { color: #ff4d4f; }
</style>

View File

@@ -0,0 +1,711 @@
<template>
<div class="budget-page">
<a-page-header title="预算管理" sub-title="管理部门及项目预算" />
<a-card :bordered="false">
<!-- 筛选栏 -->
<div class="table-page-search-wrapper">
<a-form layout="inline">
<a-form-item label="年度">
<a-select v-model:value="queryParam.year" style="width: 100px">
<a-select-option :value="2024">2024</a-select-option>
<a-select-option :value="2023">2023</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="周期">
<a-select v-model:value="queryParam.period" placeholder="全部" allow-clear style="width: 120px">
<a-select-option v-for="(label, key) in BudgetPeriodMap" :key="key" :value="key">
{{ label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="部门">
<a-select v-model:value="queryParam.departmentId" placeholder="全部" allow-clear style="width: 130px">
<a-select-option v-for="dept in departments" :key="dept.id" :value="dept.id">
{{ dept.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="状态">
<a-select v-model:value="queryParam.status" placeholder="全部" allow-clear style="width: 120px">
<a-select-option v-for="(label, key) in BudgetStatusMap" :key="key" :value="key">
{{ label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="handleQuery">查询</a-button>
<a-button style="margin-left: 8px" @click="resetQuery">重置</a-button>
<a-button type="primary" style="margin-left: 16px" @click="handleAdd">
<PlusOutlined /> 新增预算
</a-button>
</a-form-item>
</a-form>
</div>
<!-- 预算卡片网格 -->
<a-spin :spinning="loading">
<div class="budget-grid">
<div v-for="budget in dataList" :key="budget.id" class="budget-card" @click="handleDetail(budget)">
<div class="budget-header">
<div class="budget-title">{{ budget.name }}</div>
<a-tag :color="getBudgetStatusColor(budget.status)">
{{ BudgetStatusMap[budget.status] }}
</a-tag>
</div>
<div class="budget-meta">
<span class="meta-item">
<CalendarOutlined /> {{ budget.year }}
{{ budget.period === 'monthly' ? `${budget.month}` :
budget.period === 'quarterly' ? `Q${budget.quarter}` : '' }}
</span>
<span class="meta-item" v-if="budget.departmentName">
<TeamOutlined /> {{ budget.departmentName }}
</span>
</div>
<div class="budget-progress">
<div class="progress-header">
<span>预算执行</span>
<span class="usage-rate" :class="getUsageRateClass(budget.usageRate)">
{{ budget.usageRate }}%
</span>
</div>
<a-progress
:percent="budget.usageRate"
:stroke-color="getProgressColor(budget.usageRate)"
:show-info="false"
/>
</div>
<div class="budget-amounts">
<div class="amount-item">
<div class="amount-label">总预算</div>
<div class="amount-value total">¥{{ formatAmount(budget.totalBudget) }}</div>
</div>
<div class="amount-item">
<div class="amount-label">已使用</div>
<div class="amount-value used">¥{{ formatAmount(budget.usedAmount) }}</div>
</div>
<div class="amount-item">
<div class="amount-label">剩余</div>
<div class="amount-value remaining">¥{{ formatAmount(budget.remainingAmount) }}</div>
</div>
</div>
<div class="budget-footer">
<a-button type="link" size="small" @click.stop="handleEdit(budget)">编辑</a-button>
<a-divider type="vertical" />
<a-button type="link" size="small" @click.stop="handleDetail(budget)">详情</a-button>
</div>
</div>
</div>
<a-empty v-if="dataList.length === 0" description="暂无预算数据" />
</a-spin>
<!-- 分页 -->
<div class="pagination-wrapper" v-if="dataList.length > 0">
<a-pagination
v-model:current="pagination.current"
v-model:pageSize="pagination.pageSize"
:total="pagination.total"
:show-total="(total: number) => `${total}`"
show-size-changer
@change="loadData"
/>
</div>
</a-card>
<!-- 新增/编辑弹窗 -->
<a-modal
v-model:open="formVisible"
:title="currentRecord?.id ? '编辑预算' : '新增预算'"
width="800px"
@ok="handleFormSubmit"
:confirm-loading="formLoading"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 19 }"
>
<a-form-item label="预算名称" name="name">
<a-input v-model:value="formData.name" placeholder="请输入预算名称" />
</a-form-item>
<a-form-item label="预算周期" name="period">
<a-radio-group v-model:value="formData.period">
<a-radio-button v-for="(label, key) in BudgetPeriodMap" :key="key" :value="key">
{{ label }}
</a-radio-button>
</a-radio-group>
</a-form-item>
<a-form-item label="年度" name="year">
<a-select v-model:value="formData.year" style="width: 120px">
<a-select-option :value="2024">2024年</a-select-option>
<a-select-option :value="2025">2025年</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="月份" v-if="formData.period === 'monthly'">
<a-select v-model:value="formData.month" style="width: 120px">
<a-select-option v-for="m in 12" :key="m" :value="m">{{ m }}月</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="季度" v-if="formData.period === 'quarterly'">
<a-select v-model:value="formData.quarter" style="width: 120px">
<a-select-option v-for="q in 4" :key="q" :value="q">Q{{ q }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="所属部门">
<a-select v-model:value="formData.departmentId" placeholder="选择部门选填" allow-clear>
<a-select-option v-for="dept in departments" :key="dept.id" :value="dept.id">
{{ dept.name }}
</a-select-option>
</a-select>
</a-form-item>
<!-- 预算明细 -->
<a-divider>预算明细</a-divider>
<a-form-item label="费用预算" required>
<div class="budget-items">
<a-row :gutter="16" class="budget-item-header">
<a-col :span="8">费用类型</a-col>
<a-col :span="8">预算金额</a-col>
<a-col :span="8">已使用</a-col>
</a-row>
<div v-for="(item, index) in formData.items" :key="index" class="budget-item-row">
<a-row :gutter="16" align="middle">
<a-col :span="8">
<a-tag :color="ExpenseTypeColorMap[item.expenseType as keyof typeof ExpenseTypeColorMap]">
{{ ExpenseTypeMap[item.expenseType as keyof typeof ExpenseTypeMap] }}
</a-tag>
</a-col>
<a-col :span="8">
<a-input-number
v-model:value="item.budgetAmount"
:min="0"
:precision="2"
style="width: 100%"
:formatter="(value: any) => `¥ ${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')"
:parser="(value: any) => value.replace(/¥\s?|(,*)/g, '')"
/>
</a-col>
<a-col :span="8">
<span class="used-amount">¥{{ (item.usedAmount || 0).toLocaleString() }}</span>
</a-col>
</a-row>
</div>
<a-divider dashed />
<a-row :gutter="16" class="budget-total-row">
<a-col :span="8"><strong>合计</strong></a-col>
<a-col :span="8">
<strong class="total-budget">¥{{ calculatedTotal.toLocaleString() }}</strong>
</a-col>
<a-col :span="8">
<strong class="total-used">¥{{ calculatedUsed.toLocaleString() }}</strong>
</a-col>
</a-row>
</div>
</a-form-item>
<a-form-item label="备注">
<a-textarea v-model:value="formData.remark" :rows="2" placeholder="备注信息选填" />
</a-form-item>
</a-form>
</a-modal>
<!-- 详情弹窗 -->
<a-modal
v-model:open="detailVisible"
title="预算详情"
:footer="null"
width="800px"
>
<template v-if="currentRecord">
<a-descriptions bordered :column="2">
<a-descriptions-item label="预算名称" :span="2">{{ currentRecord.name }}</a-descriptions-item>
<a-descriptions-item label="预算周期">
{{ BudgetPeriodMap[currentRecord.period] }}
</a-descriptions-item>
<a-descriptions-item label="时间">
{{ currentRecord.year }}年
{{ currentRecord.period === 'monthly' ? `${currentRecord.month}月` :
currentRecord.period === 'quarterly' ? `Q${currentRecord.quarter}` : '' }}
</a-descriptions-item>
<a-descriptions-item label="所属部门">{{ currentRecord.departmentName || '-' }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="getBudgetStatusColor(currentRecord.status)">
{{ BudgetStatusMap[currentRecord.status] }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="总预算">
<span class="money-primary">¥{{ currentRecord.totalBudget.toLocaleString() }}</span>
</a-descriptions-item>
<a-descriptions-item label="执行率">
<a-progress
:percent="currentRecord.usageRate"
:stroke-color="getProgressColor(currentRecord.usageRate)"
style="width: 150px"
/>
</a-descriptions-item>
</a-descriptions>
<a-divider>预算明细</a-divider>
<a-table
:columns="detailColumns"
:data-source="currentRecord.items"
:pagination="false"
row-key="expenseType"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'expenseType'">
<a-tag :color="ExpenseTypeColorMap[record.expenseType as keyof typeof ExpenseTypeColorMap]">
{{ ExpenseTypeMap[record.expenseType as keyof typeof ExpenseTypeMap] }}
</a-tag>
</template>
<template v-if="column.key === 'budgetAmount'">
¥{{ record.budgetAmount.toLocaleString() }}
</template>
<template v-if="column.key === 'usedAmount'">
<span class="text-warning">¥{{ record.usedAmount.toLocaleString() }}</span>
</template>
<template v-if="column.key === 'remainingAmount'">
<span :class="record.remainingAmount >= 0 ? 'text-success' : 'text-danger'">
¥{{ record.remainingAmount.toLocaleString() }}
</span>
</template>
<template v-if="column.key === 'usage'">
<a-progress
:percent="record.budgetAmount > 0 ? Math.round((record.usedAmount / record.budgetAmount) * 100) : 0"
size="small"
:stroke-color="getProgressColor(record.budgetAmount > 0 ? Math.round((record.usedAmount / record.budgetAmount) * 100) : 0)"
/>
</template>
</template>
</a-table>
</template>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { PlusOutlined, CalendarOutlined, TeamOutlined } from '@ant-design/icons-vue'
import type { BudgetRecord, BudgetPeriod, BudgetStatus, BudgetItem, ExpenseType } from '@/types'
import { BudgetPeriodMap, BudgetStatusMap, ExpenseTypeMap, ExpenseTypeColorMap } from '@/types'
import { mockGetBudgets, mockSaveBudget } from '@/mock'
const loading = ref(false)
const formVisible = ref(false)
const formLoading = ref(false)
const detailVisible = ref(false)
const dataList = ref<BudgetRecord[]>([])
const currentRecord = ref<BudgetRecord | null>(null)
const formRef = ref()
const departments = [
{ id: 1, name: '研发部' },
{ id: 2, name: '市场部' },
{ id: 3, name: '运营部' },
{ id: 4, name: '行政部' },
{ id: 5, name: '财务部' }
]
const expenseTypes: ExpenseType[] = ['salary', 'office', 'travel', 'marketing', 'equipment', 'other']
const queryParam = reactive({
year: 2024,
period: undefined as BudgetPeriod | undefined,
departmentId: undefined as number | undefined,
status: undefined as BudgetStatus | undefined
})
const pagination = reactive({
current: 1,
pageSize: 12,
total: 0
})
interface FormBudgetItem {
expenseType: ExpenseType
budgetAmount: number
usedAmount: number
remainingAmount: number
}
const formData = reactive({
name: '',
period: 'monthly' as BudgetPeriod,
year: 2024,
month: new Date().getMonth() + 1,
quarter: Math.ceil((new Date().getMonth() + 1) / 3),
departmentId: undefined as number | undefined,
items: [] as FormBudgetItem[],
remark: ''
})
const formRules = {
name: [{ required: true, message: '请输入预算名称' }],
period: [{ required: true, message: '请选择预算周期' }],
year: [{ required: true, message: '请选择年度' }]
}
const detailColumns = [
{ title: '费用类型', key: 'expenseType', width: 150 },
{ title: '预算金额', key: 'budgetAmount', width: 130, align: 'right' as const },
{ title: '已使用', key: 'usedAmount', width: 130, align: 'right' as const },
{ title: '剩余', key: 'remainingAmount', width: 130, align: 'right' as const },
{ title: '使用率', key: 'usage', width: 150 }
]
// 计算属性
const calculatedTotal = computed(() =>
formData.items.reduce((sum, item) => sum + (item.budgetAmount || 0), 0)
)
const calculatedUsed = computed(() =>
formData.items.reduce((sum, item) => sum + (item.usedAmount || 0), 0)
)
// 辅助函数
function formatAmount(amount: number): string {
if (amount >= 10000) {
return (amount / 10000).toFixed(1) + '万'
}
return amount.toLocaleString()
}
function getBudgetStatusColor(status: BudgetStatus): string {
const colors: Record<BudgetStatus, string> = {
draft: 'default',
active: 'green',
completed: 'blue',
cancelled: 'red'
}
return colors[status]
}
function getProgressColor(percent: number): string {
if (percent >= 90) return '#ff4d4f'
if (percent >= 70) return '#faad14'
if (percent >= 50) return '#1890ff'
return '#52c41a'
}
function getUsageRateClass(rate: number): string {
if (rate >= 90) return 'high'
if (rate >= 70) return 'medium'
return 'low'
}
// 初始化表单项
function initFormItems() {
formData.items = expenseTypes.map(type => ({
expenseType: type,
budgetAmount: 0,
usedAmount: 0,
remainingAmount: 0
}))
}
// 加载数据
async function loadData() {
loading.value = true
try {
const res = await mockGetBudgets({
page: pagination.current,
pageSize: pagination.pageSize,
period: queryParam.period,
year: queryParam.year,
departmentId: queryParam.departmentId,
status: queryParam.status
})
dataList.value = res.list
pagination.total = res.total
} catch (error) {
message.error('加载失败')
} finally {
loading.value = false
}
}
function handleQuery() {
pagination.current = 1
loadData()
}
function resetQuery() {
queryParam.year = 2024
queryParam.period = undefined
queryParam.departmentId = undefined
queryParam.status = undefined
handleQuery()
}
function handleAdd() {
currentRecord.value = null
Object.assign(formData, {
name: '',
period: 'monthly',
year: 2024,
month: new Date().getMonth() + 1,
quarter: Math.ceil((new Date().getMonth() + 1) / 3),
departmentId: undefined,
remark: ''
})
initFormItems()
formVisible.value = true
}
function handleEdit(record: BudgetRecord) {
currentRecord.value = record
Object.assign(formData, {
name: record.name,
period: record.period,
year: record.year,
month: record.month || 1,
quarter: record.quarter || 1,
departmentId: record.departmentId,
remark: record.remark || ''
})
formData.items = record.items?.map(item => ({
expenseType: item.expenseType,
budgetAmount: item.budgetAmount,
usedAmount: item.usedAmount,
remainingAmount: item.remainingAmount
})) || []
// 补充缺失的费用类型
expenseTypes.forEach(type => {
if (!formData.items.find(item => item.expenseType === type)) {
formData.items.push({
expenseType: type,
budgetAmount: 0,
usedAmount: 0,
remainingAmount: 0
})
}
})
formVisible.value = true
}
function handleDetail(record: BudgetRecord) {
currentRecord.value = record
detailVisible.value = true
}
async function handleFormSubmit() {
try {
await formRef.value.validate()
} catch {
return
}
formLoading.value = true
try {
const items: BudgetItem[] = formData.items
.filter(item => item.budgetAmount > 0)
.map(item => ({
expenseType: item.expenseType,
budgetAmount: item.budgetAmount,
usedAmount: item.usedAmount,
remainingAmount: item.budgetAmount - item.usedAmount
}))
const dept = departments.find(d => d.id === formData.departmentId)
const saveData: Partial<BudgetRecord> = {
id: currentRecord.value?.id,
name: formData.name,
period: formData.period,
year: formData.year,
month: formData.period === 'monthly' ? formData.month : undefined,
quarter: formData.period === 'quarterly' ? formData.quarter : undefined,
departmentId: formData.departmentId,
departmentName: dept?.name,
items,
totalBudget: calculatedTotal.value,
usedAmount: calculatedUsed.value,
remainingAmount: calculatedTotal.value - calculatedUsed.value,
usageRate: calculatedTotal.value > 0 ? Math.round((calculatedUsed.value / calculatedTotal.value) * 100) : 0,
remark: formData.remark || undefined,
createdBy: currentRecord.value?.createdBy || '管理员'
}
await mockSaveBudget(saveData)
message.success(currentRecord.value?.id ? '编辑成功' : '新增成功')
formVisible.value = false
loadData()
} catch (error) {
message.error('操作失败')
} finally {
formLoading.value = false
}
}
onMounted(() => {
loadData()
})
</script>
<style scoped>
.budget-page {
min-height: 100%;
overflow-y: auto;
}
.table-page-search-wrapper {
margin-bottom: 24px;
}
/* 预算卡片网格 */
.budget-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
.budget-card {
border: 1px solid #f0f0f0;
border-radius: 12px;
padding: 20px;
background: #fff;
cursor: pointer;
transition: all 0.3s;
}
.budget-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
border-color: #1890ff;
}
.budget-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.budget-title {
font-size: 16px;
font-weight: 600;
color: #1a1a2e;
}
.budget-meta {
display: flex;
gap: 16px;
margin-bottom: 16px;
font-size: 13px;
color: #8c8c8c;
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.budget-progress {
margin-bottom: 16px;
}
.progress-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 13px;
color: #666;
}
.usage-rate {
font-weight: 600;
}
.usage-rate.low { color: #52c41a; }
.usage-rate.medium { color: #faad14; }
.usage-rate.high { color: #ff4d4f; }
.budget-amounts {
display: flex;
justify-content: space-between;
padding: 12px;
background: #fafafa;
border-radius: 8px;
margin-bottom: 12px;
}
.amount-item {
text-align: center;
}
.amount-label {
font-size: 12px;
color: #8c8c8c;
margin-bottom: 4px;
}
.amount-value {
font-size: 14px;
font-weight: 600;
}
.amount-value.total { color: #1890ff; }
.amount-value.used { color: #faad14; }
.amount-value.remaining { color: #52c41a; }
.budget-footer {
text-align: right;
border-top: 1px dashed #f0f0f0;
padding-top: 12px;
}
/* 分页 */
.pagination-wrapper {
margin-top: 24px;
text-align: right;
}
/* 表单项 */
.budget-items {
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 16px;
}
.budget-item-header {
font-weight: 500;
color: #666;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 12px;
}
.budget-item-row {
padding: 8px 0;
border-bottom: 1px dashed #f0f0f0;
}
.used-amount {
color: #8c8c8c;
}
.budget-total-row {
padding-top: 12px;
}
.total-budget { color: #1890ff; font-size: 16px; }
.total-used { color: #faad14; font-size: 16px; }
/* 文本样式 */
.money-primary { color: #1890ff; font-weight: 600; }
.text-success { color: #52c41a; }
.text-warning { color: #faad14; }
.text-danger { color: #ff4d4f; }
</style>

View File

@@ -0,0 +1,652 @@
<template>
<div class="expense-page">
<a-page-header title="支出管理" sub-title="管理公司日常支出及付款审批" />
<a-card :bordered="false">
<!-- 统计概览 -->
<div class="stats-bar">
<a-row :gutter="24">
<a-col :span="6">
<a-statistic title="总支出" :value="stats.totalAmount" prefix="¥" :precision="2" />
</a-col>
<a-col :span="6">
<a-statistic title="已支付" :value="stats.paidAmount" prefix="¥" :precision="2"
:value-style="{ color: '#52c41a' }" />
</a-col>
<a-col :span="6">
<a-statistic title="待处理" :value="stats.pendingAmount" prefix="¥" :precision="2"
:value-style="{ color: '#faad14' }" />
</a-col>
<a-col :span="6">
<a-button type="primary" size="large" @click="handleAdd">
<PlusOutlined /> 新增支出
</a-button>
</a-col>
</a-row>
</div>
<a-divider />
<!-- 搜索栏 -->
<div class="table-page-search-wrapper">
<a-form layout="inline">
<a-form-item label="关键词">
<a-input v-model:value="queryParam.keyword" placeholder="支出名称/收款方/编号" allow-clear
style="width: 180px" />
</a-form-item>
<a-form-item label="支出类型">
<a-select v-model:value="queryParam.type" placeholder="全部" allow-clear style="width: 130px">
<a-select-option v-for="(label, key) in ExpenseTypeMap" :key="key" :value="key">
{{ label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="状态">
<a-select v-model:value="queryParam.status" placeholder="全部" allow-clear style="width: 120px">
<a-select-option v-for="(label, key) in ExpenseStatusMap" :key="key" :value="key">
{{ label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="日期范围">
<a-range-picker v-model:value="queryParam.dateRange" style="width: 240px" />
</a-form-item>
<a-form-item>
<a-button type="primary" @click="handleQuery">查询</a-button>
<a-button style="margin-left: 8px" @click="resetQuery">重置</a-button>
</a-form-item>
</a-form>
</div>
<!-- 表格 -->
<a-table
:columns="columns"
:data-source="dataList"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
:scroll="{ x: 1300 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'type'">
<a-tag :color="ExpenseTypeColorMap[record.type as keyof typeof ExpenseTypeColorMap]">
{{ ExpenseTypeMap[record.type as keyof typeof ExpenseTypeMap] }}
</a-tag>
</template>
<template v-if="column.key === 'amount'">
<span class="money-amount">¥{{ record.amount.toLocaleString() }}</span>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="ExpenseStatusColorMap[record.status as keyof typeof ExpenseStatusColorMap]">
{{ ExpenseStatusMap[record.status as keyof typeof ExpenseStatusMap] }}
</a-tag>
</template>
<template v-if="column.key === 'payee'">
<div class="payee-cell">
<div class="payee-name">{{ record.payeeName }}</div>
<div class="payee-account" v-if="record.payeeBankName">
{{ record.payeeBankName }}
</div>
</div>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleDetail(record as any)">详情</a-button>
<a-button
type="link" size="small"
v-if="record.status === 'draft'"
@click="handleEdit(record as any)"
>编辑</a-button>
<a-popconfirm
v-if="record.status === 'draft'"
title="确定提交审批?"
@confirm="handleSubmit(record as any)"
>
<a-button type="link" size="small">提交</a-button>
</a-popconfirm>
<a-button
type="link" size="small"
v-if="record.status === 'pending'"
@click="handleApprove(record as any)"
>审批</a-button>
<a-button
type="link" size="small"
v-if="record.status === 'approved'"
@click="handlePay(record as any)"
>付款</a-button>
<a-popconfirm
v-if="record.status === 'draft'"
title="确定删除?"
@confirm="handleDelete(record as any)"
>
<a-button type="link" size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<!-- 新增/编辑弹窗 -->
<a-modal
v-model:open="formVisible"
:title="currentRecord?.id ? '编辑支出' : '新增支出'"
width="700px"
@ok="handleFormSubmit"
:confirm-loading="formLoading"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<a-form-item label="支出类型" name="type">
<a-select v-model:value="formData.type" placeholder="请选择支出类型">
<a-select-option v-for="(label, key) in ExpenseTypeMap" :key="key" :value="key">
{{ label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="支出名称" name="title">
<a-input v-model:value="formData.title" placeholder="请输入支出名称/描述" />
</a-form-item>
<a-form-item label="支出金额" name="amount">
<a-input-number
v-model:value="formData.amount"
:min="0"
:precision="2"
:formatter="(value: any) => `¥ ${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')"
:parser="(value: any) => value.replace(/¥\s?|(,*)/g, '')"
style="width: 200px"
/>
</a-form-item>
<a-form-item label="收款方名称" name="payeeName">
<a-input v-model:value="formData.payeeName" placeholder="请输入收款方名称" />
</a-form-item>
<a-form-item label="收款方账户">
<a-input v-model:value="formData.payeeAccount" placeholder="收款方银行账户(选填)" />
</a-form-item>
<a-form-item label="开户银行">
<a-input v-model:value="formData.payeeBankName" placeholder="开户银行名称(选填)" />
</a-form-item>
<a-form-item label="所属部门">
<a-select v-model:value="formData.departmentId" placeholder="选择所属部门(选填)" allow-clear>
<a-select-option :value="1">研发部</a-select-option>
<a-select-option :value="2">市场部</a-select-option>
<a-select-option :value="3">运营部</a-select-option>
<a-select-option :value="4">行政部</a-select-option>
<a-select-option :value="5">财务部</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="关联项目">
<a-input v-model:value="formData.projectName" placeholder="关联项目(选填)" />
</a-form-item>
<a-form-item label="备注">
<a-textarea v-model:value="formData.remark" :rows="2" placeholder="备注信息(选填)" />
</a-form-item>
</a-form>
</a-modal>
<!-- 审批抽屉 -->
<ApprovalDrawer
v-model:open="approveVisible"
title="审批支出"
scenario="payment_request"
:business-title="currentRecord?.title || ''"
:amount="currentRecord?.amount"
:applicant-name="currentRecord?.createdBy || ''"
:apply-time="currentRecord?.createdAt || ''"
:approval-nodes="approvalNodes"
:current-node-index="currentApprovalStep"
:show-action="true"
@approve="handleApproveSubmit"
>
<template #extra-info>
<a-descriptions-item label="支出编号">{{ currentRecord?.expenseNo }}</a-descriptions-item>
<a-descriptions-item label="支出类型">
<a-tag :color="ExpenseTypeColorMap[currentRecord?.type as keyof typeof ExpenseTypeColorMap]">
{{ ExpenseTypeMap[currentRecord?.type as keyof typeof ExpenseTypeMap] }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="收款方">{{ currentRecord?.payeeName }}</a-descriptions-item>
<a-descriptions-item label="收款账户" v-if="currentRecord?.payeeAccount">
{{ currentRecord?.payeeAccount }}
</a-descriptions-item>
</template>
</ApprovalDrawer>
<!-- 付款弹窗 -->
<a-modal
v-model:open="payVisible"
title="确认付款"
@ok="handlePaySubmit"
:confirm-loading="payLoading"
>
<a-descriptions v-if="currentRecord" bordered :column="1" size="small" style="margin-bottom: 16px">
<a-descriptions-item label="支出名称">{{ currentRecord.title }}</a-descriptions-item>
<a-descriptions-item label="支出金额">
<span class="money-amount">¥{{ currentRecord.amount.toLocaleString() }}</span>
</a-descriptions-item>
<a-descriptions-item label="收款方">{{ currentRecord.payeeName }}</a-descriptions-item>
<a-descriptions-item label="收款账户">{{ currentRecord.payeeAccount || '-' }}</a-descriptions-item>
<a-descriptions-item label="开户银行">{{ currentRecord.payeeBankName || '-' }}</a-descriptions-item>
</a-descriptions>
<a-form layout="vertical">
<a-form-item label="付款账户" required>
<a-select v-model:value="payForm.accountId" placeholder="请选择付款账户">
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
{{ account.name }} (余额: ¥{{ account.balance.toLocaleString() }})
</a-select-option>
</a-select>
</a-form-item>
</a-form>
</a-modal>
<!-- 详情弹窗 -->
<a-modal
v-model:open="detailVisible"
title="支出详情"
:footer="null"
width="700px"
>
<a-descriptions v-if="currentRecord" bordered :column="2">
<a-descriptions-item label="支出编号">{{ currentRecord.expenseNo }}</a-descriptions-item>
<a-descriptions-item label="支出类型">
<a-tag :color="ExpenseTypeColorMap[currentRecord.type as keyof typeof ExpenseTypeColorMap]">
{{ ExpenseTypeMap[currentRecord.type as keyof typeof ExpenseTypeMap] }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="支出名称" :span="2">{{ currentRecord.title }}</a-descriptions-item>
<a-descriptions-item label="支出金额">
<span class="money-amount">¥{{ currentRecord.amount.toLocaleString() }}</span>
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="ExpenseStatusColorMap[currentRecord.status as keyof typeof ExpenseStatusColorMap]">
{{ ExpenseStatusMap[currentRecord.status as keyof typeof ExpenseStatusMap] }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="收款方">{{ currentRecord.payeeName }}</a-descriptions-item>
<a-descriptions-item label="开户银行">{{ currentRecord.payeeBankName || '-' }}</a-descriptions-item>
<a-descriptions-item label="收款账户" :span="2">{{ currentRecord.payeeAccount || '-' }}</a-descriptions-item>
<a-descriptions-item label="所属部门">{{ currentRecord.departmentName || '-' }}</a-descriptions-item>
<a-descriptions-item label="关联项目">{{ currentRecord.projectName || '-' }}</a-descriptions-item>
<a-descriptions-item label="付款账户">{{ currentRecord.accountName || '-' }}</a-descriptions-item>
<a-descriptions-item label="付款时间">
{{ currentRecord.paymentDate ? formatDate(currentRecord.paymentDate) : '-' }}
</a-descriptions-item>
<a-descriptions-item label="创建时间">{{ formatDate(currentRecord.createdAt) }}</a-descriptions-item>
<a-descriptions-item label="创建人">{{ currentRecord.createdBy }}</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">{{ currentRecord.remark || '-' }}</a-descriptions-item>
</a-descriptions>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { message } from 'ant-design-vue'
import { PlusOutlined } from '@ant-design/icons-vue'
import type { ExpenseRecord, ExpenseType, ExpenseStatus, FinanceAccount } from '@/types'
import { ExpenseTypeMap, ExpenseTypeColorMap, ExpenseStatusMap, ExpenseStatusColorMap } from '@/types'
import { mockGetExpenses, mockSaveExpense, mockSubmitExpense, mockApproveExpense, mockPayExpense, mockGetAccounts } from '@/mock'
import { formatDate } from '@/utils/common'
import type { Dayjs } from 'dayjs'
import ApprovalDrawer from '@/components/ApprovalDrawer/index.vue'
import type { ApproverType } from '@/types/approval'
const route = useRoute()
const loading = ref(false)
const formVisible = ref(false)
const formLoading = ref(false)
const detailVisible = ref(false)
const approveVisible = ref(false)
const approveLoading = ref(false)
const payVisible = ref(false)
const payLoading = ref(false)
const dataList = ref<ExpenseRecord[]>([])
const accounts = ref<FinanceAccount[]>([])
const currentRecord = ref<ExpenseRecord | null>(null)
const formRef = ref()
const stats = ref({
totalAmount: 0,
paidAmount: 0,
pendingAmount: 0
})
// 审批进度数据
interface ApprovalNodeInfo {
id: number
name: string
approverType: ApproverType
approverName?: string
approverAvatar?: string
status?: 'pending' | 'approved' | 'rejected' | 'skipped'
comment?: string
operatedAt?: string
order: number
}
const approvalNodes = ref<ApprovalNodeInfo[]>([
{ id: 1, name: '直属领导审批', approverType: 'superior', approverName: '张经理', approverAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=mgr1', status: 'approved', comment: '同意支出', operatedAt: new Date(Date.now() - 86400000).toISOString(), order: 1 },
{ id: 2, name: '财务审核', approverType: 'role', approverName: '王财务', approverAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=fin1', status: 'pending', order: 2 },
{ id: 3, name: '财务主管审批', approverType: 'specified', order: 3 }
])
const currentApprovalStep = ref(1)
const queryParam = reactive({
keyword: '',
type: undefined as ExpenseType | undefined,
status: undefined as ExpenseStatus | undefined,
dateRange: undefined as [Dayjs, Dayjs] | undefined
})
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showTotal: (total: number) => `${total}`
})
const formData = reactive({
type: 'office' as ExpenseType,
title: '',
amount: 0,
payeeName: '',
payeeAccount: '',
payeeBankName: '',
departmentId: undefined as number | undefined,
projectName: '',
remark: ''
})
const approveForm = reactive({
approved: true,
remark: ''
})
const payForm = reactive({
accountId: undefined as number | undefined
})
const formRules = {
type: [{ required: true, message: '请选择支出类型' }],
title: [{ required: true, message: '请输入支出名称' }],
amount: [{ required: true, message: '请输入支出金额' }],
payeeName: [{ required: true, message: '请输入收款方名称' }]
}
const departmentMap: Record<number, string> = {
1: '研发部',
2: '市场部',
3: '运营部',
4: '行政部',
5: '财务部'
}
const columns = [
{ title: '支出编号', dataIndex: 'expenseNo', key: 'expenseNo', width: 150 },
{ title: '支出类型', key: 'type', width: 110 },
{ title: '支出名称', dataIndex: 'title', key: 'title', ellipsis: true },
{ title: '收款方', key: 'payee', width: 150 },
{ title: '金额', key: 'amount', width: 120, align: 'right' as const },
{ title: '部门', dataIndex: 'departmentName', key: 'departmentName', width: 100 },
{ title: '状态', key: 'status', width: 100 },
{ title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', width: 120,
customRender: ({ text }: any) => formatDate(text) },
{ title: '操作', key: 'action', width: 200, fixed: 'right' as const }
]
async function loadAccounts() {
const res = await mockGetAccounts()
accounts.value = res.list
}
async function loadData() {
loading.value = true
try {
const res = await mockGetExpenses({
page: pagination.current,
pageSize: pagination.pageSize,
keyword: queryParam.keyword,
type: queryParam.type,
status: queryParam.status,
dateRange: queryParam.dateRange ? [
queryParam.dateRange[0].format('YYYY-MM-DD'),
queryParam.dateRange[1].format('YYYY-MM-DD')
] : undefined
})
dataList.value = res.list
pagination.total = res.total
if (res.stats) {
stats.value = res.stats
}
} catch (error) {
message.error('加载失败')
} finally {
loading.value = false
}
}
function handleQuery() {
pagination.current = 1
loadData()
}
function resetQuery() {
queryParam.keyword = ''
queryParam.type = undefined
queryParam.status = undefined
queryParam.dateRange = undefined
handleQuery()
}
function handleTableChange(pag: any) {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
loadData()
}
function handleAdd() {
currentRecord.value = null
Object.assign(formData, {
type: 'office',
title: '',
amount: 0,
payeeName: '',
payeeAccount: '',
payeeBankName: '',
departmentId: undefined,
projectName: '',
remark: ''
})
formVisible.value = true
}
function handleEdit(record: ExpenseRecord) {
currentRecord.value = record
Object.assign(formData, {
type: record.type,
title: record.title,
amount: record.amount,
payeeName: record.payeeName,
payeeAccount: record.payeeAccount || '',
payeeBankName: record.payeeBankName || '',
departmentId: record.departmentId,
projectName: record.projectName || '',
remark: record.remark || ''
})
formVisible.value = true
}
function handleDetail(record: ExpenseRecord) {
currentRecord.value = record
detailVisible.value = true
}
async function handleFormSubmit() {
try {
await formRef.value.validate()
} catch {
return
}
formLoading.value = true
try {
const saveData: Partial<ExpenseRecord> = {
id: currentRecord.value?.id,
type: formData.type,
title: formData.title,
amount: formData.amount,
payeeName: formData.payeeName,
payeeAccount: formData.payeeAccount || undefined,
payeeBankName: formData.payeeBankName || undefined,
departmentId: formData.departmentId,
departmentName: formData.departmentId ? departmentMap[formData.departmentId] : undefined,
projectName: formData.projectName || undefined,
remark: formData.remark || undefined,
createdBy: currentRecord.value?.createdBy || '管理员'
}
await mockSaveExpense(saveData)
message.success(currentRecord.value?.id ? '编辑成功' : '新增成功')
formVisible.value = false
loadData()
} catch (error) {
message.error('操作失败')
} finally {
formLoading.value = false
}
}
async function handleSubmit(record: ExpenseRecord) {
try {
await mockSubmitExpense(record.id)
message.success('提交成功,等待审批')
loadData()
} catch (error) {
message.error('提交失败')
}
}
async function handleDelete(record: ExpenseRecord) {
// 这里简单模拟删除
message.success('删除成功')
loadData()
}
function handleApprove(record: ExpenseRecord) {
currentRecord.value = record
approveForm.approved = true
approveForm.remark = ''
approveVisible.value = true
}
async function handleApproveSubmit(data: { approved: boolean; comment: string }) {
if (!currentRecord.value) return
approveLoading.value = true
try {
await mockApproveExpense(currentRecord.value.id, data.approved, data.comment)
// 更新审批节点状态
if (currentApprovalStep.value < approvalNodes.value.length) {
approvalNodes.value[currentApprovalStep.value]!.status = data.approved ? 'approved' : 'rejected'
approvalNodes.value[currentApprovalStep.value]!.comment = data.comment
approvalNodes.value[currentApprovalStep.value]!.operatedAt = new Date().toISOString()
if (data.approved) {
currentApprovalStep.value++
}
}
loadData()
} catch (error) {
message.error('操作失败')
} finally {
approveLoading.value = false
}
}
function handlePay(record: ExpenseRecord) {
currentRecord.value = record
payForm.accountId = accounts.value[0]?.id
payVisible.value = true
}
async function handlePaySubmit() {
if (!currentRecord.value || !payForm.accountId) {
message.warning('请选择付款账户')
return
}
payLoading.value = true
try {
await mockPayExpense(currentRecord.value.id, payForm.accountId)
message.success('付款成功')
payVisible.value = false
loadData()
} catch (error) {
message.error('操作失败')
} finally {
payLoading.value = false
}
}
onMounted(() => {
// 检查URL参数
if (route.query.status) {
queryParam.status = route.query.status as ExpenseStatus
}
loadAccounts()
loadData()
})
</script>
<style scoped>
.expense-page {
min-height: 100%;
overflow-y: auto;
}
.stats-bar {
padding: 16px 0;
}
.table-page-search-wrapper {
margin-bottom: 24px;
}
.money-amount {
font-weight: 600;
color: #ff4d4f;
font-size: 15px;
}
.payee-cell {
line-height: 1.5;
}
.payee-name {
color: #1a1a2e;
}
.payee-account {
font-size: 12px;
color: #8c8c8c;
}
</style>

View File

@@ -0,0 +1,608 @@
<template>
<div class="import-page">
<a-page-header title="数据导入" sub-title="批量导入收支记录" />
<a-card :bordered="false">
<a-tabs v-model:activeKey="activeTab">
<!-- 导入数据 -->
<a-tab-pane key="import" tab="导入数据">
<div class="import-section">
<!-- 第一步选择导入类型 -->
<div class="step-section">
<div class="step-title">
<span class="step-num">1</span>
选择导入类型
</div>
<a-radio-group v-model:value="importType" button-style="solid" size="large">
<a-radio-button value="income">
<RiseOutlined /> 收入记录
</a-radio-button>
<a-radio-button value="expense">
<FallOutlined /> 支出记录
</a-radio-button>
<a-radio-button value="reimbursement">
<AuditOutlined /> 报销记录
</a-radio-button>
</a-radio-group>
<a-button type="link" @click="downloadTemplate">
<DownloadOutlined /> 下载模板
</a-button>
</div>
<!-- 第二步上传文件 -->
<div class="step-section">
<div class="step-title">
<span class="step-num">2</span>
上传文件
</div>
<a-upload-dragger
v-model:file-list="fileList"
name="file"
accept=".xlsx,.xls,.csv"
:max-count="1"
:before-upload="handleBeforeUpload"
@change="handleUploadChange"
>
<p class="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p class="ant-upload-text">点击或拖拽文件到此区域上传</p>
<p class="ant-upload-hint">支持 Excel (.xlsx, .xls) CSV 格式单次最多 1000 条记录</p>
</a-upload-dragger>
</div>
<!-- 第三步数据预览 -->
<div class="step-section" v-if="previewData.length > 0">
<div class="step-title">
<span class="step-num">3</span>
数据预览
<a-tag color="blue"> {{ previewData.length }} </a-tag>
<a-tag color="green" v-if="validCount > 0">有效 {{ validCount }} </a-tag>
<a-tag color="red" v-if="errorCount > 0">错误 {{ errorCount }} </a-tag>
</div>
<a-table
:columns="previewColumns"
:data-source="previewData"
:pagination="{ pageSize: 10 }"
row-key="rowIndex"
size="small"
:scroll="{ x: 1200 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.isValid ? 'green' : 'red'">
{{ record.isValid ? '有效' : '错误' }}
</a-tag>
</template>
<template v-if="column.key === 'errors'">
<span v-if="record.errors?.length" class="error-text">
{{ record.errors.join('; ') }}
</span>
<span v-else>-</span>
</template>
</template>
</a-table>
</div>
<!-- 导入按钮 -->
<div class="import-actions" v-if="previewData.length > 0">
<a-button @click="clearFile">取消</a-button>
<a-button
type="primary"
:loading="importing"
:disabled="validCount === 0"
@click="handleImport"
>
<UploadOutlined /> 确认导入 ({{ validCount }} )
</a-button>
</div>
</div>
</a-tab-pane>
<!-- 导入历史 -->
<a-tab-pane key="history" tab="导入历史">
<a-table
:columns="historyColumns"
:data-source="importHistory"
:loading="historyLoading"
:pagination="historyPagination"
row-key="id"
@change="handleHistoryChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dataType'">
<a-tag :color="getDataTypeColor(record.dataType)">
{{ ImportDataTypeMap[record.dataType as keyof typeof ImportDataTypeMap] }}
</a-tag>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">
{{ ImportStatusMap[record.status as keyof typeof ImportStatusMap] }}
</a-tag>
</template>
<template v-if="column.key === 'progress'">
<div class="progress-cell">
<a-progress
:percent="Math.round((record.successRows / record.totalRows) * 100)"
size="small"
:status="record.failedRows > 0 ? 'exception' : 'success'"
/>
<span class="progress-text">
{{ record.successRows }}/{{ record.totalRows }}
<span v-if="record.failedRows > 0" class="error-text">(失败 {{ record.failedRows }})</span>
</span>
</div>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="viewDetail(record as any)" v-if="record.failedRows > 0">
查看错误
</a-button>
<a-button type="link" size="small" @click="downloadResult(record as any)">
下载结果
</a-button>
</a-space>
</template>
</template>
</a-table>
</a-tab-pane>
</a-tabs>
</a-card>
<!-- 错误详情弹窗 -->
<a-modal
v-model:open="errorDetailVisible"
title="导入错误详情"
width="800px"
:footer="null"
>
<a-table
v-if="currentErrors.length > 0"
:columns="errorColumns"
:data-source="currentErrors"
:pagination="{ pageSize: 10 }"
row-key="row"
size="small"
/>
<a-empty v-else description="无错误记录" />
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import {
InboxOutlined,
DownloadOutlined,
UploadOutlined,
RiseOutlined,
FallOutlined,
AuditOutlined
} from '@ant-design/icons-vue'
import type { UploadProps, UploadFile } from 'ant-design-vue'
import type { ImportRecord, ImportDataType, ImportPreviewRow } from '@/types'
import { ImportDataTypeMap, ImportStatusMap } from '@/types'
const activeTab = ref('import')
const importType = ref<ImportDataType>('income')
const fileList = ref<UploadFile[]>([])
const importing = ref(false)
const historyLoading = ref(false)
const errorDetailVisible = ref(false)
const previewData = ref<ImportPreviewRow[]>([])
const importHistory = ref<ImportRecord[]>([])
const currentErrors = ref<any[]>([])
const historyPagination = reactive({
current: 1,
pageSize: 10,
total: 0
})
// 计算属性
const validCount = computed(() => previewData.value.filter(row => row.isValid).length)
const errorCount = computed(() => previewData.value.filter(row => !row.isValid).length)
// 预览表格列(根据导入类型动态变化)
const previewColumns = computed(() => {
const baseColumns = [
{ title: '行号', dataIndex: 'rowIndex', key: 'rowIndex', width: 70 },
{ title: '状态', key: 'status', width: 80 }
]
if (importType.value === 'income') {
return [
...baseColumns,
{ title: '收入类型', dataIndex: ['data', 'type'], key: 'type', width: 100 },
{ title: '收入名称', dataIndex: ['data', 'title'], key: 'title', ellipsis: true },
{ title: '客户名称', dataIndex: ['data', 'customerName'], key: 'customerName', width: 120 },
{ title: '金额', dataIndex: ['data', 'amount'], key: 'amount', width: 100 },
{ title: '收款日期', dataIndex: ['data', 'date'], key: 'date', width: 110 },
{ title: '错误信息', key: 'errors', ellipsis: true }
]
} else if (importType.value === 'expense') {
return [
...baseColumns,
{ title: '支出类型', dataIndex: ['data', 'type'], key: 'type', width: 100 },
{ title: '支出名称', dataIndex: ['data', 'title'], key: 'title', ellipsis: true },
{ title: '收款方', dataIndex: ['data', 'payeeName'], key: 'payeeName', width: 120 },
{ title: '金额', dataIndex: ['data', 'amount'], key: 'amount', width: 100 },
{ title: '支出日期', dataIndex: ['data', 'date'], key: 'date', width: 110 },
{ title: '错误信息', key: 'errors', ellipsis: true }
]
} else {
return [
...baseColumns,
{ title: '报销类型', dataIndex: ['data', 'type'], key: 'type', width: 100 },
{ title: '报销标题', dataIndex: ['data', 'title'], key: 'title', ellipsis: true },
{ title: '申请人', dataIndex: ['data', 'applicantName'], key: 'applicantName', width: 100 },
{ title: '金额', dataIndex: ['data', 'amount'], key: 'amount', width: 100 },
{ title: '申请日期', dataIndex: ['data', 'date'], key: 'date', width: 110 },
{ title: '错误信息', key: 'errors', ellipsis: true }
]
}
})
const historyColumns = [
{ title: '文件名', dataIndex: 'fileName', key: 'fileName', ellipsis: true },
{ title: '导入类型', key: 'dataType', width: 100 },
{ title: '状态', key: 'status', width: 100 },
{ title: '导入进度', key: 'progress', width: 200 },
{ title: '导入人', dataIndex: 'importedBy', key: 'importedBy', width: 100 },
{ title: '导入时间', dataIndex: 'createdAt', key: 'createdAt', width: 160 },
{ title: '操作', key: 'action', width: 160 }
]
const errorColumns = [
{ title: '行号', dataIndex: 'row', key: 'row', width: 70 },
{ title: '字段', dataIndex: 'field', key: 'field', width: 100 },
{ title: '原始值', dataIndex: 'originalValue', key: 'originalValue', width: 150 },
{ title: '错误信息', dataIndex: 'message', key: 'message' }
]
// 辅助函数
function getDataTypeColor(type: ImportDataType): string {
const colors: Record<ImportDataType, string> = {
income: 'green',
expense: 'red',
reimbursement: 'blue'
}
return colors[type]
}
function getStatusColor(status: string): string {
const colors: Record<string, string> = {
pending: 'default',
processing: 'processing',
completed: 'success',
failed: 'error'
}
return colors[status] || 'default'
}
// 下载模板
function downloadTemplate() {
const templates: Record<ImportDataType, string> = {
income: '收入类型,收入名称,客户名称,金额,收款日期,备注\n项目收入,XX项目尾款,ABC公司,50000,2024-12-01,\n平台服务费,12月服务费,DEF公司,8000,2024-12-05,',
expense: '支出类型,支出名称,收款方,金额,支出日期,备注\n办公用品,办公耗材采购,文具店,2000,2024-12-01,\n房租,12月办公室租金,房东,15000,2024-12-05,',
reimbursement: '报销类型,报销标题,申请人,金额,申请日期,备注\n差旅费,北京出差报销,张三,3500,2024-12-01,\n业务招待,客户宴请,李四,1200,2024-12-03,'
}
const BOM = '\uFEFF'
const content = templates[importType.value]
const blob = new Blob([BOM + content], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `${ImportDataTypeMap[importType.value]}导入模板.csv`
link.click()
URL.revokeObjectURL(link.href)
message.success('模板下载成功')
}
// 上传前校验
function handleBeforeUpload(file: File) {
const isValidType = ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel', 'text/csv'].includes(file.type) ||
file.name.endsWith('.xlsx') || file.name.endsWith('.xls') || file.name.endsWith('.csv')
if (!isValidType) {
message.error('只能上传 Excel 或 CSV 文件!')
return false
}
const isLt5M = file.size / 1024 / 1024 < 5
if (!isLt5M) {
message.error('文件大小不能超过 5MB!')
return false
}
// 解析文件
parseFile(file)
return false // 阻止自动上传
}
// 解析文件
function parseFile(file: File) {
const reader = new FileReader()
reader.onload = (e) => {
const content = e.target?.result as string
const lines = content.split('\n').filter(line => line.trim())
if (lines.length < 2) {
message.error('文件内容为空或格式错误')
return
}
const headers = lines[0]!.split(',').map(h => h.trim())
const rows: ImportPreviewRow[] = []
for (let i = 1; i < lines.length && i <= 1000; i++) {
const values = lines[i]!.split(',').map(v => v.trim())
const data: Record<string, any> = {}
const errors: string[] = []
// 根据导入类型解析
if (importType.value === 'income') {
data.type = values[0] || ''
data.title = values[1] || ''
data.customerName = values[2] || ''
data.amount = parseFloat(values[3] || '0')
data.date = values[4] || ''
data.remark = values[5] || ''
// 验证
if (!data.title) errors.push('收入名称不能为空')
if (!data.customerName) errors.push('客户名称不能为空')
if (isNaN(data.amount) || data.amount <= 0) errors.push('金额必须大于0')
if (!data.date) errors.push('日期不能为空')
} else if (importType.value === 'expense') {
data.type = values[0] || ''
data.title = values[1] || ''
data.payeeName = values[2] || ''
data.amount = parseFloat(values[3] || '0')
data.date = values[4] || ''
data.remark = values[5] || ''
if (!data.title) errors.push('支出名称不能为空')
if (!data.payeeName) errors.push('收款方不能为空')
if (isNaN(data.amount) || data.amount <= 0) errors.push('金额必须大于0')
if (!data.date) errors.push('日期不能为空')
} else {
data.type = values[0] || ''
data.title = values[1] || ''
data.applicantName = values[2] || ''
data.amount = parseFloat(values[3] || '0')
data.date = values[4] || ''
data.remark = values[5] || ''
if (!data.title) errors.push('报销标题不能为空')
if (!data.applicantName) errors.push('申请人不能为空')
if (isNaN(data.amount) || data.amount <= 0) errors.push('金额必须大于0')
if (!data.date) errors.push('日期不能为空')
}
rows.push({
rowIndex: i + 1,
data,
isValid: errors.length === 0,
errors: errors.length > 0 ? errors : undefined
})
}
previewData.value = rows
message.success(`解析完成,共 ${rows.length} 条记录`)
}
reader.onerror = () => {
message.error('文件读取失败')
}
reader.readAsText(file, 'UTF-8')
}
// 上传变化
function handleUploadChange(info: any) {
if (info.file.status === 'removed') {
clearFile()
}
}
// 清空文件
function clearFile() {
fileList.value = []
previewData.value = []
}
// 执行导入
async function handleImport() {
if (validCount.value === 0) {
message.warning('没有有效的数据可导入')
return
}
importing.value = true
// 模拟导入过程
await new Promise(resolve => setTimeout(resolve, 2000))
// 创建导入记录
const record: ImportRecord = {
id: Date.now(),
fileName: fileList.value[0]?.name || 'unknown.csv',
fileSize: fileList.value[0]?.size || 0,
fileUrl: '',
dataType: importType.value,
status: 'completed',
totalRows: previewData.value.length,
successRows: validCount.value,
failedRows: errorCount.value,
errors: previewData.value
.filter(row => !row.isValid)
.map(row => ({
row: row.rowIndex,
field: '多个字段',
message: row.errors?.join('; ') || '未知错误'
})),
importedBy: '管理员',
createdAt: new Date().toISOString(),
completedAt: new Date().toISOString()
}
importHistory.value.unshift(record)
importing.value = false
message.success(`导入完成!成功 ${validCount.value} 条,失败 ${errorCount.value}`)
clearFile()
activeTab.value = 'history'
}
// 查看错误详情
function viewDetail(record: ImportRecord) {
currentErrors.value = record.errors || []
errorDetailVisible.value = true
}
// 下载导入结果
function downloadResult(record: ImportRecord) {
let content = `导入结果报告\n`
content += `文件名: ${record.fileName}\n`
content += `导入类型: ${ImportDataTypeMap[record.dataType]}\n`
content += `导入时间: ${record.createdAt}\n`
content += `总记录数: ${record.totalRows}\n`
content += `成功: ${record.successRows}\n`
content += `失败: ${record.failedRows}\n\n`
if (record.errors && record.errors.length > 0) {
content += `错误详情:\n`
content += `行号,字段,错误信息\n`
record.errors.forEach(err => {
content += `${err.row},${err.field},${err.message}\n`
})
}
const BOM = '\uFEFF'
const blob = new Blob([BOM + content], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `导入结果_${record.fileName.replace(/\.\w+$/, '')}.txt`
link.click()
URL.revokeObjectURL(link.href)
}
// 加载历史
async function loadHistory() {
historyLoading.value = true
// 模拟加载
await new Promise(resolve => setTimeout(resolve, 300))
// Mock 数据
importHistory.value = [
{
id: 1,
fileName: '2024年12月收入.xlsx',
fileSize: 35840,
fileUrl: '',
dataType: 'income',
status: 'completed',
totalRows: 50,
successRows: 48,
failedRows: 2,
errors: [
{ row: 12, field: 'amount', message: '金额格式错误', originalValue: 'abc' },
{ row: 35, field: 'date', message: '日期格式错误', originalValue: '2024/13/01' }
],
importedBy: '管理员',
createdAt: '2024-12-15 10:30:00'
},
{
id: 2,
fileName: '2024年11月支出.csv',
fileSize: 28672,
fileUrl: '',
dataType: 'expense',
status: 'completed',
totalRows: 80,
successRows: 80,
failedRows: 0,
importedBy: '财务',
createdAt: '2024-12-10 14:20:00'
}
]
historyPagination.total = importHistory.value.length
historyLoading.value = false
}
function handleHistoryChange(pag: any) {
historyPagination.current = pag.current
loadHistory()
}
onMounted(() => {
loadHistory()
})
</script>
<style scoped>
.import-page {
min-height: 100%;
}
.import-section {
max-width: 1000px;
}
.step-section {
margin-bottom: 32px;
padding: 24px;
background: #fafafa;
border-radius: 12px;
}
.step-title {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
font-size: 16px;
font-weight: 500;
}
.step-num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: linear-gradient(135deg, #1890ff 0%, #69c0ff 100%);
color: #fff;
border-radius: 50%;
font-weight: 600;
}
.import-actions {
display: flex;
gap: 16px;
justify-content: flex-end;
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid #f0f0f0;
}
.progress-cell {
display: flex;
flex-direction: column;
gap: 4px;
}
.progress-text {
font-size: 12px;
color: #666;
}
.error-text {
color: #ff4d4f;
}
</style>

View File

@@ -0,0 +1,620 @@
<template>
<div class="income-page">
<a-page-header title="收入管理" sub-title="管理公司各项收入及应收款项" />
<a-card :bordered="false">
<!-- 统计概览 -->
<div class="stats-bar">
<a-row :gutter="24">
<a-col :span="6">
<a-statistic title="总收入" :value="stats.totalAmount" prefix="¥" :precision="2" />
</a-col>
<a-col :span="6">
<a-statistic title="已收款" :value="stats.receivedAmount" prefix="¥" :precision="2"
:value-style="{ color: '#52c41a' }" />
</a-col>
<a-col :span="6">
<a-statistic title="待收款" :value="stats.pendingAmount" prefix="¥" :precision="2"
:value-style="{ color: '#faad14' }" />
</a-col>
<a-col :span="6">
<a-button type="primary" size="large" @click="handleAdd">
<PlusOutlined /> 新增收入
</a-button>
</a-col>
</a-row>
</div>
<a-divider />
<!-- 搜索栏 -->
<div class="table-page-search-wrapper">
<a-form layout="inline">
<a-form-item label="关键词">
<a-input v-model:value="queryParam.keyword" placeholder="收入名称/客户/编号" allow-clear
style="width: 180px" />
</a-form-item>
<a-form-item label="收入类型">
<a-select v-model:value="queryParam.type" placeholder="全部" allow-clear style="width: 130px">
<a-select-option v-for="(label, key) in IncomeTypeMap" :key="key" :value="key">
{{ label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="状态">
<a-select v-model:value="queryParam.status" placeholder="全部" allow-clear style="width: 120px">
<a-select-option v-for="(label, key) in IncomeStatusMap" :key="key" :value="key">
{{ label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="收款账户">
<a-select v-model:value="queryParam.accountId" placeholder="全部" allow-clear style="width: 150px">
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
{{ account.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="日期范围">
<a-range-picker v-model:value="queryParam.dateRange" style="width: 240px" />
</a-form-item>
<a-form-item>
<a-button type="primary" @click="handleQuery">查询</a-button>
<a-button style="margin-left: 8px" @click="resetQuery">重置</a-button>
</a-form-item>
</a-form>
</div>
<!-- 表格 -->
<a-table
:columns="columns"
:data-source="dataList"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
:scroll="{ x: 1400 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'type'">
<a-tag :color="IncomeTypeColorMap[record.type as keyof typeof IncomeTypeColorMap]">
{{ IncomeTypeMap[record.type as keyof typeof IncomeTypeMap] }}
</a-tag>
</template>
<template v-if="column.key === 'amount'">
<div class="amount-cell">
<div class="total">¥{{ record.totalAmount.toLocaleString() }}</div>
<div class="sub-amount">
<span class="received">已收: ¥{{ record.receivedAmount.toLocaleString() }}</span>
<span class="pending" v-if="record.pendingAmount > 0">
待收: ¥{{ record.pendingAmount.toLocaleString() }}
</span>
</div>
</div>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="IncomeStatusColorMap[record.status as keyof typeof IncomeStatusColorMap]">
{{ IncomeStatusMap[record.status as keyof typeof IncomeStatusMap] }}
</a-tag>
</template>
<template v-if="column.key === 'invoice'">
<a-tag v-if="record.invoiceIssued" color="green">已开票</a-tag>
<a-tag v-else-if="record.invoiceRequired" color="orange">待开票</a-tag>
<span v-else class="text-muted">-</span>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleDetail(record as any)">详情</a-button>
<a-button type="link" size="small" @click="handleEdit(record as any)">编辑</a-button>
<a-button
type="link" size="small"
v-if="record.status !== 'received'"
@click="handleConfirmPayment(record as any)"
>收款</a-button>
<a-popconfirm title="确定删除该收入记录?" @confirm="handleDelete(record as any)">
<a-button type="link" size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<!-- 新增/编辑弹窗 -->
<a-modal
v-model:open="formVisible"
:title="currentRecord?.id ? '编辑收入' : '新增收入'"
width="700px"
@ok="handleFormSubmit"
:confirm-loading="formLoading"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<a-form-item label="收入类型" name="type">
<a-select v-model:value="formData.type" placeholder="请选择收入类型">
<a-select-option v-for="(label, key) in IncomeTypeMap" :key="key" :value="key">
{{ label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="收入名称" name="title">
<a-input v-model:value="formData.title" placeholder="请输入收入名称/描述" />
</a-form-item>
<a-form-item label="客户名称" name="customerName">
<a-input v-model:value="formData.customerName" placeholder="请输入付款客户名称" />
</a-form-item>
<a-form-item label="客户联系人">
<a-input v-model:value="formData.customerContact" placeholder="联系人(选填)" />
</a-form-item>
<a-form-item label="关联项目">
<a-input v-model:value="formData.projectName" placeholder="关联项目名称(选填)" />
</a-form-item>
<a-form-item label="合同编号">
<a-input v-model:value="formData.contractNo" placeholder="合同编号(选填)" />
</a-form-item>
<a-form-item label="收入金额" name="totalAmount">
<a-input-number
v-model:value="formData.totalAmount"
:min="0"
:precision="2"
:formatter="(value: any) => `¥ ${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')"
:parser="(value: any) => value.replace(/¥\s?|(,*)/g, '')"
style="width: 200px"
/>
</a-form-item>
<a-form-item label="收款账户" name="accountId">
<a-select v-model:value="formData.accountId" placeholder="选择收款账户">
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
{{ account.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="预计收款日期">
<a-date-picker v-model:value="formData.expectedDate" style="width: 200px" />
</a-form-item>
<a-form-item label="是否需要开票">
<a-switch v-model:checked="formData.invoiceRequired" />
</a-form-item>
<a-form-item label="备注">
<a-textarea v-model:value="formData.remark" :rows="2" placeholder="备注信息(选填)" />
</a-form-item>
</a-form>
</a-modal>
<!-- 收款确认弹窗 -->
<a-modal
v-model:open="paymentVisible"
title="确认收款"
@ok="handlePaymentSubmit"
:confirm-loading="paymentLoading"
>
<a-form layout="vertical">
<a-alert
:message="`待收金额: ¥${currentRecord?.pendingAmount?.toLocaleString() || 0}`"
type="info"
show-icon
style="margin-bottom: 16px"
/>
<a-form-item label="收款金额">
<a-input-number
v-model:value="paymentForm.amount"
:min="0"
:max="currentRecord?.pendingAmount || 0"
:precision="2"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="收款方式">
<a-select v-model:value="paymentForm.paymentMethod">
<a-select-option value="bank">银行转账</a-select-option>
<a-select-option value="wechat">微信支付</a-select-option>
<a-select-option value="alipay">支付宝</a-select-option>
<a-select-option value="cash">现金</a-select-option>
<a-select-option value="other">其他</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="入账账户">
<a-select v-model:value="paymentForm.accountId">
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
{{ account.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-form>
</a-modal>
<!-- 详情弹窗 -->
<a-modal
v-model:open="detailVisible"
title="收入详情"
:footer="null"
width="700px"
>
<a-descriptions v-if="currentRecord" bordered :column="2">
<a-descriptions-item label="收入编号">{{ currentRecord.incomeNo }}</a-descriptions-item>
<a-descriptions-item label="收入类型">
<a-tag :color="IncomeTypeColorMap[currentRecord.type as keyof typeof IncomeTypeColorMap]">
{{ IncomeTypeMap[currentRecord.type as keyof typeof IncomeTypeMap] }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="收入名称" :span="2">{{ currentRecord.title }}</a-descriptions-item>
<a-descriptions-item label="客户名称">{{ currentRecord.customerName }}</a-descriptions-item>
<a-descriptions-item label="客户联系人">{{ currentRecord.customerContact || '-' }}</a-descriptions-item>
<a-descriptions-item label="关联项目">{{ currentRecord.projectName || '-' }}</a-descriptions-item>
<a-descriptions-item label="合同编号">{{ currentRecord.contractNo || '-' }}</a-descriptions-item>
<a-descriptions-item label="收入金额">
<span class="money-primary">¥{{ currentRecord.totalAmount.toLocaleString() }}</span>
</a-descriptions-item>
<a-descriptions-item label="已收金额">
<span class="money-success">¥{{ currentRecord.receivedAmount.toLocaleString() }}</span>
</a-descriptions-item>
<a-descriptions-item label="待收金额">
<span class="money-warning">¥{{ currentRecord.pendingAmount.toLocaleString() }}</span>
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="IncomeStatusColorMap[currentRecord.status as keyof typeof IncomeStatusColorMap]">
{{ IncomeStatusMap[currentRecord.status as keyof typeof IncomeStatusMap] }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="收款账户">{{ currentRecord.accountName }}</a-descriptions-item>
<a-descriptions-item label="预计收款日期">
{{ currentRecord.expectedDate ? formatDate(currentRecord.expectedDate) : '-' }}
</a-descriptions-item>
<a-descriptions-item label="实际收款日期">
{{ currentRecord.actualDate ? formatDate(currentRecord.actualDate) : '-' }}
</a-descriptions-item>
<a-descriptions-item label="是否开票">
{{ currentRecord.invoiceRequired ? (currentRecord.invoiceIssued ? '已开票' : '待开票') : '无需开票' }}
</a-descriptions-item>
<a-descriptions-item label="创建时间">{{ formatDate(currentRecord.createdAt) }}</a-descriptions-item>
<a-descriptions-item label="创建人">{{ currentRecord.createdBy }}</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">{{ currentRecord.remark || '-' }}</a-descriptions-item>
</a-descriptions>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { message } from 'ant-design-vue'
import { PlusOutlined } from '@ant-design/icons-vue'
import type { IncomeRecord, IncomeType, IncomeStatus, FinanceAccount } from '@/types'
import { IncomeTypeMap, IncomeTypeColorMap, IncomeStatusMap, IncomeStatusColorMap } from '@/types'
import { mockGetIncomes, mockSaveIncome, mockDeleteIncome, mockConfirmIncomePayment, mockGetAccounts } from '@/mock'
import { formatDate } from '@/utils/common'
import type { Dayjs } from 'dayjs'
const route = useRoute()
const loading = ref(false)
const formVisible = ref(false)
const formLoading = ref(false)
const detailVisible = ref(false)
const paymentVisible = ref(false)
const paymentLoading = ref(false)
const dataList = ref<IncomeRecord[]>([])
const accounts = ref<FinanceAccount[]>([])
const currentRecord = ref<IncomeRecord | null>(null)
const formRef = ref()
const stats = ref({
totalAmount: 0,
receivedAmount: 0,
pendingAmount: 0
})
const queryParam = reactive({
keyword: '',
type: undefined as IncomeType | undefined,
status: undefined as IncomeStatus | undefined,
accountId: undefined as number | undefined,
dateRange: undefined as [Dayjs, Dayjs] | undefined
})
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showTotal: (total: number) => `${total}`
})
const formData = reactive({
type: 'project' as IncomeType,
title: '',
customerName: '',
customerContact: '',
projectName: '',
contractNo: '',
totalAmount: 0,
accountId: undefined as number | undefined,
expectedDate: undefined as Dayjs | undefined,
invoiceRequired: false,
remark: ''
})
const paymentForm = reactive({
amount: 0,
paymentMethod: 'bank',
accountId: undefined as number | undefined
})
const formRules = {
type: [{ required: true, message: '请选择收入类型' }],
title: [{ required: true, message: '请输入收入名称' }],
customerName: [{ required: true, message: '请输入客户名称' }],
totalAmount: [{ required: true, message: '请输入收入金额' }],
accountId: [{ required: true, message: '请选择收款账户' }]
}
const columns = [
{ title: '收入编号', dataIndex: 'incomeNo', key: 'incomeNo', width: 150 },
{ title: '收入类型', key: 'type', width: 110 },
{ title: '收入名称', dataIndex: 'title', key: 'title', ellipsis: true },
{ title: '客户名称', dataIndex: 'customerName', key: 'customerName', width: 150 },
{ title: '金额', key: 'amount', width: 180 },
{ title: '状态', key: 'status', width: 100 },
{ title: '发票', key: 'invoice', width: 80 },
{ title: '收款账户', dataIndex: 'accountName', key: 'accountName', width: 140 },
{ title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', width: 120,
customRender: ({ text }: any) => formatDate(text) },
{ title: '操作', key: 'action', width: 200, fixed: 'right' as const }
]
async function loadAccounts() {
const res = await mockGetAccounts()
accounts.value = res.list
}
async function loadData() {
loading.value = true
try {
const res = await mockGetIncomes({
page: pagination.current,
pageSize: pagination.pageSize,
keyword: queryParam.keyword,
type: queryParam.type,
status: queryParam.status,
accountId: queryParam.accountId,
dateRange: queryParam.dateRange ? [
queryParam.dateRange[0].format('YYYY-MM-DD'),
queryParam.dateRange[1].format('YYYY-MM-DD')
] : undefined
})
dataList.value = res.list
pagination.total = res.total
if (res.stats) {
stats.value = res.stats
}
} catch (error) {
message.error('加载失败')
} finally {
loading.value = false
}
}
function handleQuery() {
pagination.current = 1
loadData()
}
function resetQuery() {
queryParam.keyword = ''
queryParam.type = undefined
queryParam.status = undefined
queryParam.accountId = undefined
queryParam.dateRange = undefined
handleQuery()
}
function handleTableChange(pag: any) {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
loadData()
}
function handleAdd() {
currentRecord.value = null
Object.assign(formData, {
type: 'project',
title: '',
customerName: '',
customerContact: '',
projectName: '',
contractNo: '',
totalAmount: 0,
accountId: accounts.value[0]?.id,
expectedDate: undefined,
invoiceRequired: false,
remark: ''
})
formVisible.value = true
}
function handleEdit(record: IncomeRecord) {
currentRecord.value = record
Object.assign(formData, {
type: record.type,
title: record.title,
customerName: record.customerName,
customerContact: record.customerContact || '',
projectName: record.projectName || '',
contractNo: record.contractNo || '',
totalAmount: record.totalAmount,
accountId: record.accountId,
expectedDate: undefined,
invoiceRequired: record.invoiceRequired,
remark: record.remark || ''
})
formVisible.value = true
}
function handleDetail(record: IncomeRecord) {
currentRecord.value = record
detailVisible.value = true
}
async function handleFormSubmit() {
try {
await formRef.value.validate()
} catch {
return
}
formLoading.value = true
try {
const account = accounts.value.find(a => a.id === formData.accountId)
const saveData: Partial<IncomeRecord> = {
id: currentRecord.value?.id,
type: formData.type,
title: formData.title,
customerName: formData.customerName,
customerContact: formData.customerContact || undefined,
projectName: formData.projectName || undefined,
contractNo: formData.contractNo || undefined,
totalAmount: formData.totalAmount,
receivedAmount: currentRecord.value?.receivedAmount || 0,
pendingAmount: currentRecord.value ?
formData.totalAmount - (currentRecord.value.receivedAmount || 0) :
formData.totalAmount,
accountId: formData.accountId!,
accountName: account?.name || '',
status: currentRecord.value?.status || 'pending',
expectedDate: formData.expectedDate?.format('YYYY-MM-DD'),
invoiceRequired: formData.invoiceRequired,
invoiceIssued: currentRecord.value?.invoiceIssued || false,
remark: formData.remark || undefined,
createdBy: currentRecord.value?.createdBy || '管理员'
}
await mockSaveIncome(saveData)
message.success(currentRecord.value?.id ? '编辑成功' : '新增成功')
formVisible.value = false
loadData()
} catch (error) {
message.error('操作失败')
} finally {
formLoading.value = false
}
}
async function handleDelete(record: IncomeRecord) {
try {
await mockDeleteIncome(record.id)
message.success('删除成功')
loadData()
} catch (error) {
message.error('删除失败')
}
}
function handleConfirmPayment(record: IncomeRecord) {
currentRecord.value = record
paymentForm.amount = record.pendingAmount
paymentForm.paymentMethod = 'bank'
paymentForm.accountId = record.accountId
paymentVisible.value = true
}
async function handlePaymentSubmit() {
if (!currentRecord.value || !paymentForm.amount || !paymentForm.accountId) {
message.warning('请填写完整信息')
return
}
paymentLoading.value = true
try {
await mockConfirmIncomePayment(
currentRecord.value.id,
paymentForm.amount,
paymentForm.paymentMethod,
paymentForm.accountId
)
message.success('收款确认成功')
paymentVisible.value = false
loadData()
} catch (error) {
message.error('操作失败')
} finally {
paymentLoading.value = false
}
}
onMounted(() => {
// 检查URL参数
if (route.query.status) {
queryParam.status = route.query.status as IncomeStatus
}
loadAccounts()
loadData()
})
</script>
<style scoped>
.income-page {
min-height: 100%;
overflow-y: auto;
}
.stats-bar {
padding: 16px 0;
}
.table-page-search-wrapper {
margin-bottom: 24px;
}
.amount-cell {
line-height: 1.5;
}
.amount-cell .total {
font-weight: 600;
font-size: 15px;
color: #1a1a2e;
}
.amount-cell .sub-amount {
font-size: 12px;
color: #8c8c8c;
}
.amount-cell .received {
color: #52c41a;
margin-right: 8px;
}
.amount-cell .pending {
color: #faad14;
}
.text-muted {
color: #bfbfbf;
}
.money-primary {
font-weight: 600;
color: #1890ff;
}
.money-success {
font-weight: 600;
color: #52c41a;
}
.money-warning {
font-weight: 600;
color: #faad14;
}
</style>

View File

@@ -0,0 +1,147 @@
<template>
<div class="invoice-page">
<a-page-header title="发票管理" sub-title="查看用户提交的发票及开票记录" />
<a-card :bordered="false">
<!-- 表格 -->
<a-table
:columns="columns"
:data-source="dataList"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'type'">
<a-tag color="blue" v-if="record.type === 'vat_special'">专票</a-tag>
<a-tag color="cyan" v-else-if="record.type === 'vat_normal'">普票</a-tag>
<a-tag color="orange" v-else>个人</a-tag>
</template>
<template v-if="column.key === 'amount'">
<span class="font-money">{{ record.amount.toLocaleString() }}</span>
</template>
<template v-if="column.key === 'status'">
<a-badge status="processing" text="待开票" v-if="record.status === 'pending'" />
<a-badge status="success" text="已开票" v-else-if="record.status === 'issued'" />
<a-badge status="error" text="已驳回" v-else />
</template>
<template v-if="column.key === 'action'">
<a-button type="link" size="small" v-if="record.status === 'pending'" @click="handleIssue(record)">开票</a-button>
<a-button type="link" size="small" v-if="record.status === 'issued'" :href="record.fileUrl" target="_blank">下载</a-button>
</template>
</template>
</a-table>
</a-card>
<!-- 开票弹窗 -->
<a-modal
v-model:open="issueVisible"
title="开具电子发票"
@ok="handleIssueSubmit"
:confirm-loading="issueLoading"
>
<a-form layout="vertical">
<a-form-item label="发票文件链接 (模拟上传)">
<a-input v-model:value="issueForm.fileUrl" placeholder="输入云存储链接或模拟上传" />
</a-form-item>
<a-alert message="实际业务中此处应为文件上传组件上传后返回URL" type="info" show-icon />
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import type { InvoiceItem } from '@/types'
import { mockGetInvoices, mockIssueInvoice } from '@/mock'
import { formatDate } from '@/utils/common'
const loading = ref(false)
const issueVisible = ref(false)
const issueLoading = ref(false)
const currentInvoice = ref<InvoiceItem | null>(null)
const issueForm = reactive({
fileUrl: ''
})
const dataList = ref<InvoiceItem[]>([])
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showTotal: (total: number) => `${total}`
})
const columns = [
{ title: '发票编号', dataIndex: 'id', key: 'id' },
{ title: '关联结算单', dataIndex: 'settlementId', key: 'settlementId' },
{ title: '发票类型', key: 'type', width: 100 },
{ title: '发票抬头', dataIndex: 'title', key: 'title' },
{ title: '开票金额', key: 'amount', align: 'right' as const },
{ title: '提交时间', dataIndex: 'submitTime', key: 'submitTime', customRender: ({ text }: any) => formatDate(text) },
{ title: '状态', key: 'status', width: 120 },
{ title: '操作', key: 'action', width: 100 }
]
async function loadData() {
loading.value = true
try {
const res = await mockGetInvoices()
dataList.value = res.list
pagination.total = res.total
} catch (error) {
message.error('加载失败')
} finally {
loading.value = false
}
}
function handleTableChange(pag: any) {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
loadData()
}
function handleIssue(record: any) {
currentInvoice.value = record
issueForm.fileUrl = `https://example.com/invoice_${record.id}.pdf` // 默认模拟一个
issueVisible.value = true
}
async function handleIssueSubmit() {
if (!currentInvoice.value) return
issueLoading.value = true
try {
const success = await mockIssueInvoice(currentInvoice.value.id, issueForm.fileUrl)
if (success) {
message.success('开票成功')
issueVisible.value = false
loadData()
} else {
message.error('操作失败')
}
} finally {
issueLoading.value = false
}
}
onMounted(() => {
loadData()
})
</script>
<style scoped>
.invoice-page {
min-height: 100%;
}
.font-money {
font-weight: 500;
color: #1a1a2e;
}
</style>

View File

@@ -0,0 +1,765 @@
<template>
<div class="finance-overview">
<a-page-header title="财务总览" sub-title="查看公司财务整体状况" />
<!-- 统计卡片 -->
<a-row :gutter="[16, 16]" class="stats-row">
<a-col :xs="24" :sm="12" :lg="6">
<div class="stat-card income-card">
<div class="stat-icon">
<RiseOutlined />
</div>
<div class="stat-content">
<div class="stat-label">本月收入</div>
<div class="stat-value">¥{{ formatMoney(overview.monthlyIncome) }}</div>
<div class="stat-extra">
本年累计: ¥{{ formatMoney(overview.yearlyIncome) }}
</div>
</div>
</div>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<div class="stat-card expense-card">
<div class="stat-icon">
<FallOutlined />
</div>
<div class="stat-content">
<div class="stat-label">本月支出</div>
<div class="stat-value">¥{{ formatMoney(overview.monthlyExpense) }}</div>
<div class="stat-extra">
本年累计: ¥{{ formatMoney(overview.yearlyExpense) }}
</div>
</div>
</div>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<div class="stat-card profit-card" :class="{ negative: overview.monthlyProfit < 0 }">
<div class="stat-icon">
<StockOutlined />
</div>
<div class="stat-content">
<div class="stat-label">本月利润</div>
<div class="stat-value">¥{{ formatMoney(overview.monthlyProfit) }}</div>
<div class="stat-extra">
本年累计: ¥{{ formatMoney(overview.yearlyProfit) }}
</div>
</div>
</div>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<div class="stat-card balance-card">
<div class="stat-icon">
<WalletOutlined />
</div>
<div class="stat-content">
<div class="stat-label">账户余额</div>
<div class="stat-value">¥{{ formatMoney(overview.totalBalance) }}</div>
<div class="stat-extra">
<span>对公: ¥{{ formatMoney(overview.corporateBalance) }}</span>
<a-divider type="vertical" />
<span>商户: ¥{{ formatMoney(overview.merchantBalance) }}</span>
</div>
</div>
</div>
</a-col>
</a-row>
<!-- 待处理事项 -->
<a-card title="待处理事项" :bordered="false" class="pending-card">
<a-row :gutter="[24, 16]">
<a-col :xs="24" :sm="8">
<div class="pending-item" @click="goToReimbursements">
<div class="pending-icon pending-reimbursement">
<AuditOutlined />
</div>
<div class="pending-info">
<div class="pending-count">{{ overview.pendingReimbursements }}</div>
<div class="pending-label">待审批报销</div>
</div>
<RightOutlined class="pending-arrow" />
</div>
</a-col>
<a-col :xs="24" :sm="8">
<div class="pending-item" @click="goToExpenses">
<div class="pending-icon pending-expense">
<FileTextOutlined />
</div>
<div class="pending-info">
<div class="pending-count">{{ overview.pendingExpenses }}</div>
<div class="pending-label">待审批支出</div>
</div>
<RightOutlined class="pending-arrow" />
</div>
</a-col>
<a-col :xs="24" :sm="8">
<div class="pending-item" @click="goToIncome">
<div class="pending-icon pending-overdue">
<ExclamationCircleOutlined />
</div>
<div class="pending-info">
<div class="pending-count">{{ overview.overdueReceivables }}</div>
<div class="pending-label">逾期应收款</div>
</div>
<RightOutlined class="pending-arrow" />
</div>
</a-col>
</a-row>
</a-card>
<!-- 图表区域 -->
<a-row :gutter="[16, 16]" class="chart-row">
<a-col :xs="24" :lg="16">
<a-card title="收支趋势" :bordered="false" :loading="loading">
<div class="chart-container" ref="trendChartRef"></div>
</a-card>
</a-col>
<a-col :xs="24" :lg="8">
<a-card title="支出分类" :bordered="false" :loading="loading">
<div class="chart-container" ref="expensePieRef"></div>
</a-card>
</a-col>
</a-row>
<!-- 收入分类 & 账户列表 -->
<a-row :gutter="[16, 16]" class="detail-row">
<a-col :xs="24" :lg="12">
<a-card title="收入分类统计" :bordered="false" :loading="loading">
<div class="category-list">
<div v-for="item in incomeCategories" :key="item.category" class="category-item">
<div class="category-info">
<a-tag :color="getIncomeTypeColor(item.category)">
{{ getIncomeTypeName(item.category) }}
</a-tag>
<span class="category-count">{{ item.count }}</span>
</div>
<div class="category-progress">
<a-progress
:percent="item.percentage"
:stroke-color="getProgressColor(item.percentage)"
:show-info="false"
/>
</div>
<div class="category-amount">¥{{ formatMoney(item.amount) }}</div>
</div>
<a-empty v-if="incomeCategories.length === 0" description="暂无数据" />
</div>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card title="账户余额" :bordered="false" :loading="loading">
<template #extra>
<a-button type="link" size="small" @click="goToAccounts">管理账户</a-button>
</template>
<div class="account-list">
<div v-for="account in accounts" :key="account.id" class="account-item">
<div class="account-icon" :class="account.type">
<BankOutlined v-if="account.type === 'corporate'" />
<WechatOutlined v-else-if="account.merchantPlatform === 'wechat'" />
<AlipayCircleOutlined v-else-if="account.merchantPlatform === 'alipay'" />
<CreditCardOutlined v-else />
</div>
<div class="account-info">
<div class="account-name">{{ account.name }}</div>
<div class="account-sub">
{{ account.type === 'corporate' ? account.bankName : getMerchantPlatformName(account.merchantPlatform) }}
</div>
</div>
<div class="account-balance">
<div class="balance-amount">¥{{ formatMoney(account.balance) }}</div>
<a-tag :color="account.status === 'active' ? 'green' : 'default'" size="small">
{{ account.status === 'active' ? '正常' : '停用' }}
</a-tag>
</div>
</div>
<a-empty v-if="accounts.length === 0" description="暂无账户" />
</div>
</a-card>
</a-col>
</a-row>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
RiseOutlined,
FallOutlined,
StockOutlined,
WalletOutlined,
AuditOutlined,
FileTextOutlined,
ExclamationCircleOutlined,
RightOutlined,
BankOutlined,
WechatOutlined,
AlipayCircleOutlined,
CreditCardOutlined
} from '@ant-design/icons-vue'
import * as echarts from 'echarts'
import type { FinanceOverview, FinanceAccount, CategoryStats } from '@/types'
import { IncomeTypeMap, IncomeTypeColorMap, MerchantPlatformMap } from '@/types'
import { mockGetFinanceOverview, mockGetAccounts, mockGetIncomeByCategory, mockGetExpenseByCategory } from '@/mock'
const router = useRouter()
const loading = ref(false)
const overview = ref<FinanceOverview>({
monthlyIncome: 0,
monthlyExpense: 0,
monthlyProfit: 0,
yearlyIncome: 0,
yearlyExpense: 0,
yearlyProfit: 0,
totalBalance: 0,
corporateBalance: 0,
merchantBalance: 0,
pendingReimbursements: 0,
pendingExpenses: 0,
overdueReceivables: 0,
incomesTrend: [],
expensesTrend: []
})
const accounts = ref<FinanceAccount[]>([])
const incomeCategories = ref<CategoryStats[]>([])
const expenseCategories = ref<CategoryStats[]>([])
const trendChartRef = ref<HTMLElement | null>(null)
const expensePieRef = ref<HTMLElement | null>(null)
let trendChart: echarts.ECharts | null = null
let expensePieChart: echarts.ECharts | null = null
// 格式化金额
function formatMoney(amount: number): string {
if (Math.abs(amount) >= 10000) {
return (amount / 10000).toFixed(2) + '万'
}
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
// 获取收入类型名称
function getIncomeTypeName(type: string): string {
return IncomeTypeMap[type as keyof typeof IncomeTypeMap] || type
}
// 获取收入类型颜色
function getIncomeTypeColor(type: string): string {
return IncomeTypeColorMap[type as keyof typeof IncomeTypeColorMap] || 'default'
}
// 获取商户平台名称
function getMerchantPlatformName(platform?: string): string {
if (!platform) return '其他'
return MerchantPlatformMap[platform as keyof typeof MerchantPlatformMap] || platform
}
// 进度条颜色
function getProgressColor(percent: number): string {
if (percent >= 50) return '#52c41a'
if (percent >= 30) return '#1890ff'
if (percent >= 15) return '#faad14'
return '#8c8c8c'
}
// 跳转函数
function goToReimbursements() {
router.push('/finance/reimbursement?status=pending')
}
function goToExpenses() {
router.push('/finance/expense?status=pending')
}
function goToIncome() {
router.push('/finance/income?status=overdue')
}
function goToAccounts() {
router.push('/finance/accounts')
}
// 初始化趋势图表
function initTrendChart() {
if (!trendChartRef.value) return
trendChart = echarts.init(trendChartRef.value)
const option: echarts.EChartsOption = {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => {
let result = params[0].axisValue + '<br/>'
params.forEach((item: any) => {
result += `${item.marker} ${item.seriesName}: ¥${item.value.toLocaleString()}<br/>`
})
return result
}
},
legend: {
data: ['收入', '支出'],
bottom: 0
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
top: '10%',
containLabel: true
},
xAxis: {
type: 'category',
data: overview.value.incomesTrend.map(item => item.date),
axisLine: { lineStyle: { color: '#e8e8e8' } },
axisLabel: { color: '#666' }
},
yAxis: {
type: 'value',
axisLine: { show: false },
axisTick: { show: false },
splitLine: { lineStyle: { color: '#f0f0f0', type: 'dashed' } },
axisLabel: {
color: '#666',
formatter: (value: number) => {
if (value >= 10000) return (value / 10000) + '万'
return value.toString()
}
}
},
series: [
{
name: '收入',
type: 'bar',
barWidth: '35%',
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#52c41a' },
{ offset: 1, color: '#95de64' }
]),
borderRadius: [4, 4, 0, 0]
},
data: overview.value.incomesTrend.map(item => item.amount)
},
{
name: '支出',
type: 'bar',
barWidth: '35%',
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#ff4d4f' },
{ offset: 1, color: '#ff7875' }
]),
borderRadius: [4, 4, 0, 0]
},
data: overview.value.expensesTrend.map(item => item.amount)
}
]
}
trendChart.setOption(option)
}
// 初始化支出饼图
function initExpensePieChart() {
if (!expensePieRef.value) return
expensePieChart = echarts.init(expensePieRef.value)
const colors = ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452', '#9a60b4', '#ea7ccc', '#546570']
const option: echarts.EChartsOption = {
tooltip: {
trigger: 'item',
formatter: (params: any) => {
return `${params.name}<br/>金额: ¥${params.value.toLocaleString()}<br/>占比: ${params.percent}%`
}
},
legend: {
type: 'scroll',
orient: 'vertical',
right: '5%',
top: 'center',
itemWidth: 10,
itemHeight: 10,
textStyle: { fontSize: 12 }
},
series: [
{
type: 'pie',
radius: ['45%', '70%'],
center: ['35%', '50%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 6,
borderColor: '#fff',
borderWidth: 2
},
label: { show: false },
emphasis: {
label: {
show: true,
fontSize: 14,
fontWeight: 'bold',
formatter: '{d}%'
}
},
labelLine: { show: false },
data: expenseCategories.value.map((item, index) => ({
name: getExpenseTypeName(item.category),
value: item.amount,
itemStyle: { color: colors[index % colors.length] }
}))
}
]
}
expensePieChart.setOption(option)
}
// 获取支出类型名称
function getExpenseTypeName(type: string): string {
const map: Record<string, string> = {
salary: '工资薪酬',
office: '办公费用',
rent: '房租水电',
travel: '差旅费用',
marketing: '市场推广',
equipment: '设备采购',
service: '外包服务',
tax: '税费',
social_insurance: '社保公积金',
other: '其他支出'
}
return map[type] || type
}
// 加载数据
async function loadData() {
loading.value = true
try {
const [overviewRes, accountsRes, incomeCategoriesRes, expenseCategoriesRes] = await Promise.all([
mockGetFinanceOverview(),
mockGetAccounts(),
mockGetIncomeByCategory(),
mockGetExpenseByCategory()
])
overview.value = overviewRes
accounts.value = accountsRes.list
incomeCategories.value = incomeCategoriesRes
expenseCategories.value = expenseCategoriesRes
// 初始化图表
setTimeout(() => {
initTrendChart()
initExpensePieChart()
}, 100)
} catch (error) {
message.error('加载数据失败')
} finally {
loading.value = false
}
}
// 窗口大小变化时重绘图表
function handleResize() {
trendChart?.resize()
expensePieChart?.resize()
}
onMounted(() => {
loadData()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
trendChart?.dispose()
expensePieChart?.dispose()
})
</script>
<style scoped>
.finance-overview {
min-height: 100%;
overflow-y: auto;
}
/* 统计卡片 */
.stats-row {
margin-bottom: 16px;
}
.stat-card {
display: flex;
align-items: center;
padding: 20px;
border-radius: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
transition: transform 0.3s, box-shadow 0.3s;
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.4);
}
.income-card {
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
box-shadow: 0 4px 12px rgba(17, 153, 142, 0.3);
}
.income-card:hover {
box-shadow: 0 8px 24px rgba(17, 153, 142, 0.4);
}
.expense-card {
background: linear-gradient(135deg, #eb3349 0%, #f45c43 100%);
box-shadow: 0 4px 12px rgba(235, 51, 73, 0.3);
}
.expense-card:hover {
box-shadow: 0 8px 24px rgba(235, 51, 73, 0.4);
}
.profit-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.profit-card.negative {
background: linear-gradient(135deg, #ff6b6b 0%, #c92a2a 100%);
}
.balance-card {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
box-shadow: 0 4px 12px rgba(240, 147, 251, 0.3);
}
.balance-card:hover {
box-shadow: 0 8px 24px rgba(240, 147, 251, 0.4);
}
.stat-icon {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.2);
border-radius: 12px;
font-size: 28px;
margin-right: 16px;
}
.stat-content {
flex: 1;
}
.stat-label {
font-size: 14px;
opacity: 0.9;
margin-bottom: 4px;
}
.stat-value {
font-size: 24px;
font-weight: 600;
margin-bottom: 4px;
}
.stat-extra {
font-size: 12px;
opacity: 0.8;
}
/* 待处理事项 */
.pending-card {
margin-bottom: 16px;
}
.pending-item {
display: flex;
align-items: center;
padding: 16px;
background: #fafafa;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
}
.pending-item:hover {
background: #f0f0f0;
transform: translateX(4px);
}
.pending-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
font-size: 24px;
margin-right: 12px;
}
.pending-reimbursement {
background: linear-gradient(135deg, #1890ff 0%, #69c0ff 100%);
color: #fff;
}
.pending-expense {
background: linear-gradient(135deg, #faad14 0%, #ffc53d 100%);
color: #fff;
}
.pending-overdue {
background: linear-gradient(135deg, #ff4d4f 0%, #ff7875 100%);
color: #fff;
}
.pending-info {
flex: 1;
}
.pending-count {
font-size: 24px;
font-weight: 600;
color: #1a1a2e;
}
.pending-label {
font-size: 13px;
color: #8c8c8c;
}
.pending-arrow {
color: #bfbfbf;
font-size: 14px;
}
/* 图表区域 */
.chart-row {
margin-bottom: 16px;
}
.chart-container {
height: 300px;
}
/* 分类列表 */
.category-list {
max-height: 320px;
overflow-y: auto;
}
.category-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
}
.category-item:last-child {
border-bottom: none;
}
.category-info {
width: 140px;
display: flex;
align-items: center;
gap: 8px;
}
.category-count {
font-size: 12px;
color: #8c8c8c;
}
.category-progress {
flex: 1;
margin: 0 16px;
}
.category-amount {
width: 100px;
text-align: right;
font-weight: 500;
color: #1a1a2e;
}
/* 账户列表 */
.account-list {
max-height: 320px;
overflow-y: auto;
}
.account-item {
display: flex;
align-items: center;
padding: 12px;
margin-bottom: 8px;
background: #fafafa;
border-radius: 8px;
transition: background 0.3s;
}
.account-item:hover {
background: #f0f0f0;
}
.account-icon {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
font-size: 22px;
margin-right: 12px;
}
.account-icon.corporate {
background: linear-gradient(135deg, #1890ff 0%, #69c0ff 100%);
color: #fff;
}
.account-icon.merchant {
background: linear-gradient(135deg, #52c41a 0%, #95de64 100%);
color: #fff;
}
.account-info {
flex: 1;
}
.account-name {
font-weight: 500;
color: #1a1a2e;
margin-bottom: 2px;
}
.account-sub {
font-size: 12px;
color: #8c8c8c;
}
.account-balance {
text-align: right;
}
.balance-amount {
font-weight: 600;
color: #52c41a;
font-size: 15px;
margin-bottom: 4px;
}
/* 详情行 */
.detail-row {
margin-bottom: 16px;
}
</style>

View File

@@ -0,0 +1,800 @@
<template>
<div class="reimbursement-page">
<a-page-header title="报销管理" sub-title="员工报销申请审批及打款管理" />
<a-card :bordered="false">
<!-- 统计概览 -->
<div class="stats-bar">
<a-row :gutter="24">
<a-col :span="6">
<a-statistic title="报销总额" :value="stats.totalAmount" prefix="¥" :precision="2" />
</a-col>
<a-col :span="6">
<a-statistic title="已打款" :value="stats.paidAmount" prefix="¥" :precision="2"
:value-style="{ color: '#52c41a' }" />
</a-col>
<a-col :span="6">
<a-statistic title="待处理" :value="stats.pendingAmount" prefix="¥" :precision="2"
:value-style="{ color: '#faad14' }" />
</a-col>
<a-col :span="6">
<a-button type="primary" size="large" @click="handleAdd">
<PlusOutlined /> 新增报销
</a-button>
</a-col>
</a-row>
</div>
<a-divider />
<!-- 搜索栏 -->
<div class="table-page-search-wrapper">
<a-form layout="inline">
<a-form-item label="关键词">
<a-input v-model:value="queryParam.keyword" placeholder="报销单号/申请人/标题" allow-clear
style="width: 180px" />
</a-form-item>
<a-form-item label="报销类型">
<a-select v-model:value="queryParam.type" placeholder="全部" allow-clear style="width: 130px">
<a-select-option v-for="(label, key) in ReimbursementTypeMap" :key="key" :value="key">
{{ label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="状态">
<a-select v-model:value="queryParam.status" placeholder="全部" allow-clear style="width: 120px">
<a-select-option v-for="(label, key) in ReimbursementStatusMap" :key="key" :value="key">
{{ label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="日期范围">
<a-range-picker v-model:value="queryParam.dateRange" style="width: 240px" />
</a-form-item>
<a-form-item>
<a-button type="primary" @click="handleQuery">查询</a-button>
<a-button style="margin-left: 8px" @click="resetQuery">重置</a-button>
</a-form-item>
</a-form>
</div>
<!-- 表格 -->
<a-table
:columns="columns"
:data-source="dataList"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
:scroll="{ x: 1300 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'type'">
<a-tag :color="ReimbursementTypeColorMap[record.type as keyof typeof ReimbursementTypeColorMap]">
{{ ReimbursementTypeMap[record.type as keyof typeof ReimbursementTypeMap] }}
</a-tag>
</template>
<template v-if="column.key === 'applicant'">
<div class="applicant-cell">
<a-avatar size="small" style="background-color: #1890ff; margin-right: 8px">
{{ record.applicantName.slice(0, 1) }}
</a-avatar>
<span>{{ record.applicantName }}</span>
<span class="dept-tag" v-if="record.departmentName">{{ record.departmentName }}</span>
</div>
</template>
<template v-if="column.key === 'amount'">
<div class="amount-cell">
<span class="amount-value">¥{{ record.totalAmount.toLocaleString() }}</span>
<span class="item-count">{{ record.items?.length || 0 }}</span>
</div>
</template>
<template v-if="column.key === 'status'">
<div class="status-cell">
<a-tag :color="ReimbursementStatusColorMap[record.status as keyof typeof ReimbursementStatusColorMap]">
{{ ReimbursementStatusMap[record.status as keyof typeof ReimbursementStatusMap] }}
</a-tag>
<div class="approver-info" v-if="record.currentApprover">
当前审批人: {{ record.currentApprover }}
</div>
</div>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleDetail(record as any)">详情</a-button>
<a-button
type="link" size="small"
v-if="record.status === 'draft'"
@click="handleEdit(record as any)"
>编辑</a-button>
<a-popconfirm
v-if="record.status === 'draft'"
title="确定提交审批?"
@confirm="handleSubmit(record as any)"
>
<a-button type="link" size="small">提交</a-button>
</a-popconfirm>
<a-button
type="link" size="small"
v-if="record.status === 'pending'"
@click="handleApprove(record as any)"
>审批</a-button>
<a-button
type="link" size="small"
v-if="record.status === 'approved'"
@click="handlePay(record as any)"
>打款</a-button>
</a-space>
</template>
</template>
</a-table>
</a-card>
<!-- 新增/编辑弹窗 -->
<a-modal
v-model:open="formVisible"
:title="currentRecord?.id ? '编辑报销' : '新增报销'"
width="800px"
@ok="handleFormSubmit"
:confirm-loading="formLoading"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 19 }"
>
<a-form-item label="报销类型" name="type">
<a-select v-model:value="formData.type" placeholder="请选择报销类型">
<a-select-option v-for="(label, key) in ReimbursementTypeMap" :key="key" :value="key">
{{ label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="报销标题" name="title">
<a-input v-model:value="formData.title" placeholder="请输入报销标题" />
</a-form-item>
<a-form-item label="申请人" name="applicantName">
<a-input v-model:value="formData.applicantName" placeholder="请输入申请人姓名" />
</a-form-item>
<a-form-item label="所属部门">
<a-input v-model:value="formData.departmentName" placeholder="所属部门(选填)" />
</a-form-item>
<!-- 费用明细 -->
<a-form-item label="费用明细" required>
<div class="expense-items">
<div v-for="(item, index) in formData.items" :key="index" class="expense-item">
<a-row :gutter="12">
<a-col :span="6">
<a-select v-model:value="item.type" placeholder="类型" size="small">
<a-select-option v-for="(label, key) in ReimbursementTypeMap" :key="key" :value="key">
{{ label }}
</a-select-option>
</a-select>
</a-col>
<a-col :span="8">
<a-input v-model:value="item.description" placeholder="费用说明" size="small" />
</a-col>
<a-col :span="4">
<a-input-number v-model:value="item.amount" :min="0" placeholder="金额" size="small"
style="width: 100%" />
</a-col>
<a-col :span="4">
<a-date-picker v-model:value="item.occurDate" placeholder="日期" size="small"
style="width: 100%" />
</a-col>
<a-col :span="2">
<a-button type="text" danger size="small" @click="removeItem(index)"
:disabled="formData.items.length <= 1">
<DeleteOutlined />
</a-button>
</a-col>
</a-row>
</div>
<a-button type="dashed" block @click="addItem">
<PlusOutlined /> 添加费用项
</a-button>
</div>
</a-form-item>
<a-form-item label="总金额">
<a-statistic
:value="calculatedTotal"
prefix="¥"
:precision="2"
:value-style="{ color: '#1890ff', fontWeight: 600 }"
/>
</a-form-item>
<!-- 收款信息 -->
<a-divider>收款信息</a-divider>
<a-form-item label="收款人" name="bankAccountName">
<a-input v-model:value="formData.bankAccountName" placeholder="收款人姓名" />
</a-form-item>
<a-form-item label="银行账户" name="bankAccountNo">
<a-input v-model:value="formData.bankAccountNo" placeholder="银行卡号" />
</a-form-item>
<a-form-item label="开户银行">
<a-input v-model:value="formData.bankName" placeholder="开户银行名称" />
</a-form-item>
<a-form-item label="备注">
<a-textarea v-model:value="formData.remark" :rows="2" placeholder="备注信息(选填)" />
</a-form-item>
</a-form>
</a-modal>
<!-- 审批抽屉 -->
<ApprovalDrawer
v-model:open="approveVisible"
title="审批报销"
scenario="expense_reimbursement"
:business-title="currentRecord?.title || ''"
:amount="currentRecord?.totalAmount"
:applicant-name="currentRecord?.applicantName || ''"
:apply-time="currentRecord?.createdAt || ''"
:approval-nodes="approvalNodes"
:current-node-index="currentApprovalStep"
:show-action="true"
@approve="handleApproveSubmit"
>
<template #extra-info>
<a-descriptions-item label="报销单号">{{ currentRecord?.reimbursementNo }}</a-descriptions-item>
<a-descriptions-item label="报销类型">
<a-tag :color="ReimbursementTypeColorMap[currentRecord?.type as keyof typeof ReimbursementTypeColorMap]">
{{ ReimbursementTypeMap[currentRecord?.type as keyof typeof ReimbursementTypeMap] }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="费用明细">
<div v-for="(item, index) in currentRecord?.items" :key="index" class="detail-item">
{{ index + 1 }}. {{ item.description }} - ¥{{ item.amount }}
</div>
</a-descriptions-item>
</template>
</ApprovalDrawer>
<!-- 打款弹窗 -->
<a-modal
v-model:open="payVisible"
title="确认打款"
@ok="handlePaySubmit"
:confirm-loading="payLoading"
>
<a-descriptions v-if="currentRecord" bordered :column="1" size="small" style="margin-bottom: 16px">
<a-descriptions-item label="报销单号">{{ currentRecord.reimbursementNo }}</a-descriptions-item>
<a-descriptions-item label="申请人">{{ currentRecord.applicantName }}</a-descriptions-item>
<a-descriptions-item label="报销金额">
<span class="money-primary">¥{{ currentRecord.totalAmount.toLocaleString() }}</span>
</a-descriptions-item>
<a-descriptions-item label="收款人">{{ currentRecord.bankAccountName }}</a-descriptions-item>
<a-descriptions-item label="银行卡号">{{ currentRecord.bankAccountNo }}</a-descriptions-item>
<a-descriptions-item label="开户银行">{{ currentRecord.bankName }}</a-descriptions-item>
</a-descriptions>
<a-form layout="vertical">
<a-form-item label="付款账户" required>
<a-select v-model:value="payForm.accountId" placeholder="请选择付款账户">
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
{{ account.name }} (余额: ¥{{ account.balance.toLocaleString() }})
</a-select-option>
</a-select>
</a-form-item>
</a-form>
</a-modal>
<!-- 详情弹窗 -->
<a-modal
v-model:open="detailVisible"
title="报销详情"
:footer="null"
width="800px"
>
<a-descriptions v-if="currentRecord" bordered :column="2">
<a-descriptions-item label="报销单号">{{ currentRecord.reimbursementNo }}</a-descriptions-item>
<a-descriptions-item label="报销类型">
<a-tag :color="ReimbursementTypeColorMap[currentRecord.type as keyof typeof ReimbursementTypeColorMap]">
{{ ReimbursementTypeMap[currentRecord.type as keyof typeof ReimbursementTypeMap] }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="报销标题" :span="2">{{ currentRecord.title }}</a-descriptions-item>
<a-descriptions-item label="申请人">{{ currentRecord.applicantName }}</a-descriptions-item>
<a-descriptions-item label="所属部门">{{ currentRecord.departmentName || '-' }}</a-descriptions-item>
<a-descriptions-item label="报销金额">
<span class="money-primary">¥{{ currentRecord.totalAmount.toLocaleString() }}</span>
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="ReimbursementStatusColorMap[currentRecord.status as keyof typeof ReimbursementStatusColorMap]">
{{ ReimbursementStatusMap[currentRecord.status as keyof typeof ReimbursementStatusMap] }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="费用明细" :span="2">
<a-table
:columns="itemColumns"
:data-source="currentRecord.items"
:pagination="false"
size="small"
row-key="id"
>
<template #bodyCell="{ column, record: item }">
<template v-if="column.key === 'type'">
<a-tag size="small">
{{ ReimbursementTypeMap[item.type as keyof typeof ReimbursementTypeMap] }}
</a-tag>
</template>
<template v-if="column.key === 'amount'">
¥{{ item.amount.toLocaleString() }}
</template>
</template>
</a-table>
</a-descriptions-item>
<a-descriptions-item label="收款人">{{ currentRecord.bankAccountName || '-' }}</a-descriptions-item>
<a-descriptions-item label="开户银行">{{ currentRecord.bankName || '-' }}</a-descriptions-item>
<a-descriptions-item label="银行账户" :span="2">{{ currentRecord.bankAccountNo || '-' }}</a-descriptions-item>
<a-descriptions-item label="打款时间">
{{ currentRecord.paymentDate ? formatDate(currentRecord.paymentDate) : '-' }}
</a-descriptions-item>
<a-descriptions-item label="创建时间">{{ formatDate(currentRecord.createdAt) }}</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">{{ currentRecord.remark || '-' }}</a-descriptions-item>
</a-descriptions>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { message } from 'ant-design-vue'
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue'
import type { ReimbursementRecord, ReimbursementType, ReimbursementStatus, ReimbursementItem, FinanceAccount } from '@/types'
import { ReimbursementTypeMap, ReimbursementTypeColorMap, ReimbursementStatusMap, ReimbursementStatusColorMap } from '@/types'
import { mockGetReimbursements, mockSaveReimbursement, mockSubmitReimbursement, mockApproveReimbursement, mockPayReimbursement, mockGetAccounts } from '@/mock'
import { formatDate } from '@/utils/common'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import ApprovalDrawer from '@/components/ApprovalDrawer/index.vue'
import type { ApproverType } from '@/types/approval'
const route = useRoute()
const loading = ref(false)
const formVisible = ref(false)
const formLoading = ref(false)
const detailVisible = ref(false)
const approveVisible = ref(false)
const approveLoading = ref(false)
const payVisible = ref(false)
const payLoading = ref(false)
const dataList = ref<ReimbursementRecord[]>([])
const accounts = ref<FinanceAccount[]>([])
const currentRecord = ref<ReimbursementRecord | null>(null)
const formRef = ref()
const stats = ref({
totalAmount: 0,
paidAmount: 0,
pendingAmount: 0
})
// 审批进度数据
interface ApprovalNodeInfo {
id: number
name: string
approverType: ApproverType
approverName?: string
approverAvatar?: string
status?: 'pending' | 'approved' | 'rejected' | 'skipped'
comment?: string
operatedAt?: string
order: number
}
const approvalNodes = ref<ApprovalNodeInfo[]>([
{ id: 1, name: '直属领导审批', approverType: 'superior', approverName: '张经理', approverAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=mgr1', status: 'approved', comment: '同意报销', operatedAt: new Date(Date.now() - 86400000).toISOString(), order: 1 },
{ id: 2, name: '财务审核', approverType: 'role', approverName: '王财务', approverAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=fin1', status: 'pending', order: 2 },
{ id: 3, name: '财务主管审批', approverType: 'specified', order: 3 }
])
const currentApprovalStep = ref(1)
const queryParam = reactive({
keyword: '',
type: undefined as ReimbursementType | undefined,
status: undefined as ReimbursementStatus | undefined,
dateRange: undefined as [Dayjs, Dayjs] | undefined
})
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showTotal: (total: number) => `${total}`
})
interface FormItem {
type: ReimbursementType
description: string
amount: number
occurDate: Dayjs | undefined
}
const formData = reactive({
type: 'travel' as ReimbursementType,
title: '',
applicantName: '',
departmentName: '',
items: [{ type: 'travel', description: '', amount: 0, occurDate: undefined }] as FormItem[],
bankAccountName: '',
bankAccountNo: '',
bankName: '',
remark: ''
})
const approveForm = reactive({
approved: true,
remark: ''
})
const payForm = reactive({
accountId: undefined as number | undefined
})
const formRules = {
type: [{ required: true, message: '请选择报销类型' }],
title: [{ required: true, message: '请输入报销标题' }],
applicantName: [{ required: true, message: '请输入申请人姓名' }],
bankAccountName: [{ required: true, message: '请输入收款人姓名' }],
bankAccountNo: [{ required: true, message: '请输入银行账户' }]
}
const calculatedTotal = computed(() => {
return formData.items.reduce((sum, item) => sum + (item.amount || 0), 0)
})
const columns = [
{ title: '报销单号', dataIndex: 'reimbursementNo', key: 'reimbursementNo', width: 150 },
{ title: '报销类型', key: 'type', width: 100 },
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
{ title: '申请人', key: 'applicant', width: 160 },
{ title: '金额', key: 'amount', width: 120 },
{ title: '状态', key: 'status', width: 140 },
{ title: '申请时间', dataIndex: 'createdAt', key: 'createdAt', width: 120,
customRender: ({ text }: any) => formatDate(text) },
{ title: '操作', key: 'action', width: 180, fixed: 'right' as const }
]
const itemColumns = [
{ title: '类型', key: 'type', width: 100 },
{ title: '说明', dataIndex: 'description', key: 'description' },
{ title: '金额', key: 'amount', width: 100, align: 'right' as const },
{ title: '发生日期', dataIndex: 'occurDate', key: 'occurDate', width: 120 }
]
async function loadAccounts() {
const res = await mockGetAccounts()
accounts.value = res.list
}
async function loadData() {
loading.value = true
try {
const res = await mockGetReimbursements({
page: pagination.current,
pageSize: pagination.pageSize,
keyword: queryParam.keyword,
type: queryParam.type,
status: queryParam.status,
dateRange: queryParam.dateRange ? [
queryParam.dateRange[0].format('YYYY-MM-DD'),
queryParam.dateRange[1].format('YYYY-MM-DD')
] : undefined
})
dataList.value = res.list
pagination.total = res.total
if (res.stats) {
stats.value = res.stats
}
} catch (error) {
message.error('加载失败')
} finally {
loading.value = false
}
}
function handleQuery() {
pagination.current = 1
loadData()
}
function resetQuery() {
queryParam.keyword = ''
queryParam.type = undefined
queryParam.status = undefined
queryParam.dateRange = undefined
handleQuery()
}
function handleTableChange(pag: any) {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
loadData()
}
function addItem() {
formData.items.push({
type: 'other',
description: '',
amount: 0,
occurDate: undefined
})
}
function removeItem(index: number) {
if (formData.items.length > 1) {
formData.items.splice(index, 1)
}
}
function handleAdd() {
currentRecord.value = null
Object.assign(formData, {
type: 'travel',
title: '',
applicantName: '',
departmentName: '',
items: [{ type: 'travel', description: '', amount: 0, occurDate: undefined }],
bankAccountName: '',
bankAccountNo: '',
bankName: '',
remark: ''
})
formVisible.value = true
}
function handleEdit(record: ReimbursementRecord) {
currentRecord.value = record
Object.assign(formData, {
type: record.type,
title: record.title,
applicantName: record.applicantName,
departmentName: record.departmentName || '',
items: record.items?.map(item => ({
type: item.type,
description: item.description,
amount: item.amount,
occurDate: item.occurDate ? dayjs(item.occurDate) : undefined
})) || [{ type: 'travel', description: '', amount: 0, occurDate: undefined }],
bankAccountName: record.bankAccountName || '',
bankAccountNo: record.bankAccountNo || '',
bankName: record.bankName || '',
remark: record.remark || ''
})
formVisible.value = true
}
function handleDetail(record: ReimbursementRecord) {
currentRecord.value = record
detailVisible.value = true
}
async function handleFormSubmit() {
try {
await formRef.value.validate()
} catch {
return
}
if (formData.items.every(item => !item.amount)) {
message.warning('请至少填写一项费用')
return
}
formLoading.value = true
try {
const items: ReimbursementItem[] = formData.items
.filter(item => item.amount > 0)
.map((item, index) => ({
id: index,
type: item.type,
description: item.description,
amount: item.amount,
occurDate: item.occurDate?.format('YYYY-MM-DD') || new Date().toISOString().split('T')[0]!
}))
const saveData: Partial<ReimbursementRecord> = {
id: currentRecord.value?.id,
type: formData.type,
title: formData.title,
applicantId: currentRecord.value?.applicantId || 1,
applicantName: formData.applicantName,
departmentName: formData.departmentName || undefined,
totalAmount: calculatedTotal.value,
items,
bankAccountName: formData.bankAccountName,
bankAccountNo: formData.bankAccountNo,
bankName: formData.bankName || undefined,
remark: formData.remark || undefined
}
await mockSaveReimbursement(saveData)
message.success(currentRecord.value?.id ? '编辑成功' : '新增成功')
formVisible.value = false
loadData()
} catch (error) {
message.error('操作失败')
} finally {
formLoading.value = false
}
}
async function handleSubmit(record: ReimbursementRecord) {
try {
await mockSubmitReimbursement(record.id)
message.success('提交成功,等待审批')
loadData()
} catch (error) {
message.error('提交失败')
}
}
function handleApprove(record: ReimbursementRecord) {
currentRecord.value = record
approveForm.approved = true
approveForm.remark = ''
approveVisible.value = true
}
async function handleApproveSubmit(data: { approved: boolean; comment: string }) {
if (!currentRecord.value) return
approveLoading.value = true
try {
await mockApproveReimbursement(currentRecord.value.id, data.approved, data.comment)
// 更新审批节点状态
if (currentApprovalStep.value < approvalNodes.value.length) {
approvalNodes.value[currentApprovalStep.value]!.status = data.approved ? 'approved' : 'rejected'
approvalNodes.value[currentApprovalStep.value]!.comment = data.comment
approvalNodes.value[currentApprovalStep.value]!.operatedAt = new Date().toISOString()
if (data.approved) {
currentApprovalStep.value++
}
}
loadData()
} catch (error) {
message.error('操作失败')
} finally {
approveLoading.value = false
}
}
function handlePay(record: ReimbursementRecord) {
currentRecord.value = record
payForm.accountId = accounts.value[0]?.id
payVisible.value = true
}
async function handlePaySubmit() {
if (!currentRecord.value || !payForm.accountId) {
message.warning('请选择付款账户')
return
}
payLoading.value = true
try {
await mockPayReimbursement(currentRecord.value.id, payForm.accountId)
message.success('打款成功')
payVisible.value = false
loadData()
} catch (error) {
message.error('操作失败')
} finally {
payLoading.value = false
}
}
onMounted(() => {
// 检查URL参数
if (route.query.status) {
queryParam.status = route.query.status as ReimbursementStatus
}
loadAccounts()
loadData()
})
</script>
<style scoped>
.reimbursement-page {
min-height: 100%;
overflow-y: auto;
}
.stats-bar {
padding: 16px 0;
}
.table-page-search-wrapper {
margin-bottom: 24px;
}
.applicant-cell {
display: flex;
align-items: center;
}
.dept-tag {
margin-left: 8px;
font-size: 12px;
color: #8c8c8c;
background: #f5f5f5;
padding: 0 6px;
border-radius: 4px;
}
.amount-cell {
display: flex;
flex-direction: column;
}
.amount-value {
font-weight: 600;
color: #1890ff;
font-size: 15px;
}
.item-count {
font-size: 12px;
color: #8c8c8c;
}
.status-cell {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.approver-info {
font-size: 12px;
color: #8c8c8c;
margin-top: 4px;
}
.money-primary {
font-weight: 600;
color: #1890ff;
}
.expense-items {
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 12px;
}
.expense-item {
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px dashed #f0f0f0;
}
.expense-item:last-of-type {
margin-bottom: 12px;
}
.detail-item {
padding: 4px 0;
border-bottom: 1px dashed #f0f0f0;
}
.detail-item:last-child {
border-bottom: none;
}
</style>

View File

@@ -0,0 +1,776 @@
<template>
<div class="reports-page">
<a-page-header title="财务报表" sub-title="查看财务数据报表及导出" />
<a-card :bordered="false">
<!-- 报表类型选择 -->
<a-tabs v-model:activeKey="activeTab" @change="handleTabChange">
<a-tab-pane key="income_expense" tab="收支明细表" />
<a-tab-pane key="income_category" tab="收入分类统计" />
<a-tab-pane key="expense_category" tab="支出分类统计" />
<a-tab-pane key="profit" tab="利润报表" />
</a-tabs>
<!-- 筛选条件 -->
<div class="filter-bar">
<a-form layout="inline">
<a-form-item label="时间范围">
<a-range-picker v-model:value="dateRange" :presets="rangePresets" style="width: 280px" />
</a-form-item>
<a-form-item>
<a-button type="primary" @click="loadReport">
<SearchOutlined /> 查询
</a-button>
</a-form-item>
<a-form-item>
<a-dropdown>
<a-button>
<DownloadOutlined /> 导出报表 <DownOutlined />
</a-button>
<template #overlay>
<a-menu @click="(info: any) => handleExport(info)">
<a-menu-item key="excel">
<FileExcelOutlined /> 导出 Excel
</a-menu-item>
<a-menu-item key="pdf">
<FilePdfOutlined /> 导出 PDF
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-form-item>
</a-form>
</div>
<!-- 收支明细表 -->
<div v-if="activeTab === 'income_expense'" class="report-content">
<div class="summary-row">
<a-row :gutter="24">
<a-col :span="8">
<div class="summary-box income">
<div class="summary-icon"><RiseOutlined /></div>
<div class="summary-info">
<div class="summary-label">总收入</div>
<div class="summary-value">¥{{ reportData.totalIncome?.toLocaleString() || 0 }}</div>
</div>
</div>
</a-col>
<a-col :span="8">
<div class="summary-box expense">
<div class="summary-icon"><FallOutlined /></div>
<div class="summary-info">
<div class="summary-label">总支出</div>
<div class="summary-value">¥{{ reportData.totalExpense?.toLocaleString() || 0 }}</div>
</div>
</div>
</a-col>
<a-col :span="8">
<div class="summary-box profit" :class="{ negative: (reportData.profit || 0) < 0 }">
<div class="summary-icon"><StockOutlined /></div>
<div class="summary-info">
<div class="summary-label">净利润</div>
<div class="summary-value">¥{{ reportData.profit?.toLocaleString() || 0 }}</div>
</div>
</div>
</a-col>
</a-row>
</div>
<a-table
:columns="incomeExpenseColumns"
:data-source="reportData.details"
:loading="loading"
:pagination="false"
row-key="id"
:scroll="{ y: 400 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'type'">
<a-tag :color="record.direction === 'income' ? 'green' : 'red'">
{{ record.direction === 'income' ? '收入' : '支出' }}
</a-tag>
</template>
<template v-if="column.key === 'amount'">
<span :class="record.direction === 'income' ? 'text-success' : 'text-danger'">
{{ record.direction === 'income' ? '+' : '-' }}¥{{ record.amount.toLocaleString() }}
</span>
</template>
</template>
</a-table>
</div>
<!-- 收入分类统计 -->
<div v-if="activeTab === 'income_category'" class="report-content">
<a-row :gutter="24">
<a-col :span="12">
<div class="chart-container" ref="incomeCategoryPieRef"></div>
</a-col>
<a-col :span="12">
<a-table
:columns="categoryColumns"
:data-source="reportData.incomeCategories"
:loading="loading"
:pagination="false"
row-key="category"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'category'">
<a-tag :color="IncomeTypeColorMap[record.category as keyof typeof IncomeTypeColorMap] || 'default'">
{{ IncomeTypeMap[record.category as keyof typeof IncomeTypeMap] || record.category }}
</a-tag>
</template>
<template v-if="column.key === 'amount'">
<span class="text-success">¥{{ record.amount.toLocaleString() }}</span>
</template>
<template v-if="column.key === 'percentage'">
<a-progress :percent="record.percentage" size="small" :stroke-color="getProgressColor(record.percentage)" />
</template>
</template>
</a-table>
</a-col>
</a-row>
</div>
<!-- 支出分类统计 -->
<div v-if="activeTab === 'expense_category'" class="report-content">
<a-row :gutter="24">
<a-col :span="12">
<div class="chart-container" ref="expenseCategoryPieRef"></div>
</a-col>
<a-col :span="12">
<a-table
:columns="categoryColumns"
:data-source="reportData.expenseCategories"
:loading="loading"
:pagination="false"
row-key="category"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'category'">
<a-tag :color="ExpenseTypeColorMap[record.category as keyof typeof ExpenseTypeColorMap] || 'default'">
{{ ExpenseTypeMap[record.category as keyof typeof ExpenseTypeMap] || record.category }}
</a-tag>
</template>
<template v-if="column.key === 'amount'">
<span class="text-danger">¥{{ record.amount.toLocaleString() }}</span>
</template>
<template v-if="column.key === 'percentage'">
<a-progress :percent="record.percentage" size="small" status="exception" />
</template>
</template>
</a-table>
</a-col>
</a-row>
</div>
<!-- 利润报表 -->
<div v-if="activeTab === 'profit'" class="report-content">
<div class="chart-container large" ref="profitChartRef"></div>
<a-divider />
<a-table
:columns="profitColumns"
:data-source="reportData.profitTrend"
:loading="loading"
:pagination="false"
row-key="date"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'income'">
<span class="text-success">¥{{ record.income.toLocaleString() }}</span>
</template>
<template v-if="column.key === 'expense'">
<span class="text-danger">¥{{ record.expense.toLocaleString() }}</span>
</template>
<template v-if="column.key === 'profit'">
<span :class="record.profit >= 0 ? 'text-success' : 'text-danger'">
¥{{ record.profit.toLocaleString() }}
</span>
</template>
</template>
</a-table>
</div>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted, nextTick } from 'vue'
import { message } from 'ant-design-vue'
import {
SearchOutlined,
DownloadOutlined,
DownOutlined,
FileExcelOutlined,
FilePdfOutlined,
RiseOutlined,
FallOutlined,
StockOutlined
} from '@ant-design/icons-vue'
import * as echarts from 'echarts'
import dayjs from 'dayjs'
import type { Dayjs } from 'dayjs'
import { IncomeTypeMap, IncomeTypeColorMap, ExpenseTypeMap, ExpenseTypeColorMap } from '@/types'
import { mockGetFinanceOverview, mockGetIncomeByCategory, mockGetExpenseByCategory, mockGetIncomes, mockGetExpenses } from '@/mock'
const loading = ref(false)
const activeTab = ref('income_expense')
const dateRange = ref<[Dayjs, Dayjs]>([dayjs().subtract(6, 'month'), dayjs()])
// 预设时间范围
const rangePresets = [
{ label: '本月', value: [dayjs().startOf('month'), dayjs()] as [Dayjs, Dayjs] },
{ label: '上月', value: [dayjs().subtract(1, 'month').startOf('month'), dayjs().subtract(1, 'month').endOf('month')] as [Dayjs, Dayjs] },
{ label: '本季度', value: [dayjs().subtract(3, 'month').startOf('month'), dayjs()] as [Dayjs, Dayjs] },
{ label: '本年', value: [dayjs().startOf('year'), dayjs()] as [Dayjs, Dayjs] },
{ label: '近6个月', value: [dayjs().subtract(6, 'month'), dayjs()] as [Dayjs, Dayjs] }
]
// 报表数据
const reportData = reactive({
totalIncome: 0,
totalExpense: 0,
profit: 0,
details: [] as any[],
incomeCategories: [] as any[],
expenseCategories: [] as any[],
profitTrend: [] as any[]
})
// 图表引用
const incomeCategoryPieRef = ref<HTMLElement | null>(null)
const expenseCategoryPieRef = ref<HTMLElement | null>(null)
const profitChartRef = ref<HTMLElement | null>(null)
let incomePieChart: echarts.ECharts | null = null
let expensePieChart: echarts.ECharts | null = null
let profitChart: echarts.ECharts | null = null
// 表格列定义
const incomeExpenseColumns = [
{ title: '日期', dataIndex: 'date', key: 'date', width: 120 },
{ title: '类型', key: 'type', width: 80 },
{ title: '分类', dataIndex: 'category', key: 'category', width: 120 },
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true },
{ title: '金额', key: 'amount', width: 150, align: 'right' as const },
{ title: '关联方', dataIndex: 'relatedParty', key: 'relatedParty', width: 150 }
]
const categoryColumns = [
{ title: '分类', key: 'category', width: 150 },
{ title: '金额', key: 'amount', width: 150, align: 'right' as const },
{ title: '笔数', dataIndex: 'count', key: 'count', width: 80, align: 'center' as const },
{ title: '占比', key: 'percentage', width: 200 }
]
const profitColumns = [
{ title: '月份', dataIndex: 'date', key: 'date', width: 120 },
{ title: '收入', key: 'income', width: 150, align: 'right' as const },
{ title: '支出', key: 'expense', width: 150, align: 'right' as const },
{ title: '净利润', key: 'profit', width: 150, align: 'right' as const },
{ title: '利润率', dataIndex: 'profitRate', key: 'profitRate', width: 100, align: 'center' as const }
]
// 辅助函数
function getProgressColor(percent: number): string {
if (percent >= 50) return '#52c41a'
if (percent >= 30) return '#1890ff'
if (percent >= 15) return '#faad14'
return '#8c8c8c'
}
// 加载报表数据
async function loadReport() {
loading.value = true
try {
// 并行加载所有数据
const [overview, incomeCategories, expenseCategories, incomes, expenses] = await Promise.all([
mockGetFinanceOverview(),
mockGetIncomeByCategory(),
mockGetExpenseByCategory(),
mockGetIncomes({ page: 1, pageSize: 100 }),
mockGetExpenses({ page: 1, pageSize: 100 })
])
// 收支汇总
reportData.totalIncome = overview.yearlyIncome
reportData.totalExpense = overview.yearlyExpense
reportData.profit = overview.yearlyProfit
// 分类统计
reportData.incomeCategories = incomeCategories
reportData.expenseCategories = expenseCategories
// 收支明细
const details: any[] = []
incomes.list.forEach((income: any) => {
details.push({
id: `income-${income.id}`,
date: dayjs(income.createdAt).format('YYYY-MM-DD'),
direction: 'income',
category: IncomeTypeMap[income.type as keyof typeof IncomeTypeMap] || income.type,
description: income.title,
amount: income.receivedAmount || 0,
relatedParty: income.customerName || ''
})
})
expenses.list.filter(e => e.status === 'paid').forEach(expense => {
details.push({
id: `expense-${expense.id}`,
date: dayjs(expense.createdAt).format('YYYY-MM-DD'),
direction: 'expense',
category: ExpenseTypeMap[expense.type as keyof typeof ExpenseTypeMap] || expense.type,
description: expense.title,
amount: expense.amount,
relatedParty: expense.payeeName
})
})
details.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
reportData.details = details
// 利润趋势
reportData.profitTrend = overview.incomesTrend.map((item, index) => {
const income = item.amount
const expense = overview.expensesTrend[index]?.amount || 0
const profit = income - expense
return {
date: item.date,
income,
expense,
profit,
profitRate: income > 0 ? `${Math.round((profit / income) * 100)}%` : '0%'
}
})
// 刷新图表
await nextTick()
initCharts()
} catch (error) {
message.error('加载报表数据失败')
} finally {
loading.value = false
}
}
// 初始化图表
function initCharts() {
if (activeTab.value === 'income_category') {
initIncomeCategoryPie()
} else if (activeTab.value === 'expense_category') {
initExpenseCategoryPie()
} else if (activeTab.value === 'profit') {
initProfitChart()
}
}
function initIncomeCategoryPie() {
if (!incomeCategoryPieRef.value) return
incomePieChart?.dispose()
incomePieChart = echarts.init(incomeCategoryPieRef.value)
const colors = ['#52c41a', '#13c2c2', '#1890ff', '#722ed1', '#eb2f96', '#faad14']
const option: echarts.EChartsOption = {
title: { text: '收入分类构成', left: 'center' },
tooltip: {
trigger: 'item',
formatter: (params: any) => `${params.name}<br/>金额: ¥${params.value.toLocaleString()}<br/>占比: ${params.percent}%`
},
legend: { orient: 'vertical', left: 'left', top: 'middle' },
series: [{
type: 'pie',
radius: ['40%', '70%'],
center: ['60%', '50%'],
avoidLabelOverlap: false,
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
emphasis: { label: { show: true, fontSize: 14, fontWeight: 'bold' } },
data: reportData.incomeCategories.map((item, index) => ({
name: IncomeTypeMap[item.category as keyof typeof IncomeTypeMap] || item.category,
value: item.amount,
itemStyle: { color: colors[index % colors.length] }
}))
}]
}
incomePieChart.setOption(option)
}
function initExpenseCategoryPie() {
if (!expenseCategoryPieRef.value) return
expensePieChart?.dispose()
expensePieChart = echarts.init(expenseCategoryPieRef.value)
const colors = ['#ff4d4f', '#ff7a45', '#ffa940', '#ffc53d', '#bae637', '#73d13d', '#36cfc9', '#40a9ff', '#597ef7', '#9254de']
const option: echarts.EChartsOption = {
title: { text: '支出分类构成', left: 'center' },
tooltip: {
trigger: 'item',
formatter: (params: any) => `${params.name}<br/>金额: ¥${params.value.toLocaleString()}<br/>占比: ${params.percent}%`
},
legend: { orient: 'vertical', left: 'left', top: 'middle' },
series: [{
type: 'pie',
radius: ['40%', '70%'],
center: ['60%', '50%'],
avoidLabelOverlap: false,
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
emphasis: { label: { show: true, fontSize: 14, fontWeight: 'bold' } },
data: reportData.expenseCategories.map((item, index) => ({
name: ExpenseTypeMap[item.category as keyof typeof ExpenseTypeMap] || item.category,
value: item.amount,
itemStyle: { color: colors[index % colors.length] }
}))
}]
}
expensePieChart.setOption(option)
}
function initProfitChart() {
if (!profitChartRef.value) return
profitChart?.dispose()
profitChart = echarts.init(profitChartRef.value)
const option: echarts.EChartsOption = {
title: { text: '收支利润趋势', left: 'center' },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
formatter: (params: any) => {
let result = params[0].axisValue + '<br/>'
params.forEach((item: any) => {
result += `${item.marker} ${item.seriesName}: ¥${item.value.toLocaleString()}<br/>`
})
return result
}
},
legend: { data: ['收入', '支出', '利润'], bottom: 0 },
grid: { left: '3%', right: '4%', bottom: '15%', top: '15%', containLabel: true },
xAxis: {
type: 'category',
data: reportData.profitTrend.map(item => item.date),
axisLine: { lineStyle: { color: '#e8e8e8' } },
axisLabel: { color: '#666' }
},
yAxis: {
type: 'value',
axisLine: { show: false },
splitLine: { lineStyle: { color: '#f0f0f0', type: 'dashed' } },
axisLabel: {
color: '#666',
formatter: (value: number) => value >= 10000 ? (value / 10000) + '万' : value.toString()
}
},
series: [
{
name: '收入',
type: 'bar',
barWidth: '25%',
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#52c41a' },
{ offset: 1, color: '#95de64' }
]),
borderRadius: [4, 4, 0, 0]
},
data: reportData.profitTrend.map(item => item.income)
},
{
name: '支出',
type: 'bar',
barWidth: '25%',
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#ff4d4f' },
{ offset: 1, color: '#ff7875' }
]),
borderRadius: [4, 4, 0, 0]
},
data: reportData.profitTrend.map(item => item.expense)
},
{
name: '利润',
type: 'line',
smooth: true,
lineStyle: { width: 3, color: '#1890ff' },
itemStyle: { color: '#1890ff' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(24, 144, 255, 0.3)' },
{ offset: 1, color: 'rgba(24, 144, 255, 0.05)' }
])
},
data: reportData.profitTrend.map(item => item.profit)
}
]
}
profitChart.setOption(option)
}
// 切换Tab
function handleTabChange() {
nextTick(() => {
initCharts()
})
}
// 导出报表
function handleExport({ key }: { key: string }) {
if (key === 'excel') {
exportToExcel()
} else if (key === 'pdf') {
exportToPdf()
}
}
function exportToExcel() {
message.loading('正在生成Excel文件...', 1)
// 构建CSV数据简化的Excel导出
let csvContent = ''
const dateStr = `${dateRange.value[0].format('YYYY-MM-DD')}${dateRange.value[1].format('YYYY-MM-DD')}`
if (activeTab.value === 'income_expense') {
csvContent = `收支明细表 (${dateStr})\n\n`
csvContent += `总收入,¥${reportData.totalIncome}\n`
csvContent += `总支出,¥${reportData.totalExpense}\n`
csvContent += `净利润,¥${reportData.profit}\n\n`
csvContent += `日期,类型,分类,描述,金额,关联方\n`
reportData.details.forEach(item => {
csvContent += `${item.date},${item.direction === 'income' ? '收入' : '支出'},${item.category},${item.description},${item.amount},${item.relatedParty}\n`
})
} else if (activeTab.value === 'income_category') {
csvContent = `收入分类统计 (${dateStr})\n\n`
csvContent += `分类,金额,笔数,占比\n`
reportData.incomeCategories.forEach(item => {
csvContent += `${IncomeTypeMap[item.category as keyof typeof IncomeTypeMap] || item.category},${item.amount},${item.count},${item.percentage}%\n`
})
} else if (activeTab.value === 'expense_category') {
csvContent = `支出分类统计 (${dateStr})\n\n`
csvContent += `分类,金额,笔数,占比\n`
reportData.expenseCategories.forEach(item => {
csvContent += `${ExpenseTypeMap[item.category as keyof typeof ExpenseTypeMap] || item.category},${item.amount},${item.count},${item.percentage}%\n`
})
} else if (activeTab.value === 'profit') {
csvContent = `利润报表 (${dateStr})\n\n`
csvContent += `月份,收入,支出,净利润,利润率\n`
reportData.profitTrend.forEach(item => {
csvContent += `${item.date},${item.income},${item.expense},${item.profit},${item.profitRate}\n`
})
}
// 创建并下载文件
const BOM = '\uFEFF'
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `财务报表_${activeTab.value}_${dayjs().format('YYYYMMDD')}.csv`
link.click()
URL.revokeObjectURL(link.href)
message.success('Excel导出成功')
}
function exportToPdf() {
message.loading('正在生成PDF文件...', 1)
// 使用浏览器打印功能生成PDF
const printContent = document.createElement('div')
printContent.innerHTML = `
<style>
body { font-family: Arial, sans-serif; padding: 20px; }
h1 { color: #1890ff; border-bottom: 2px solid #1890ff; padding-bottom: 10px; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f5f5f5; }
.summary { display: flex; gap: 40px; margin: 20px 0; }
.summary-item { padding: 15px; background: #f5f5f5; border-radius: 8px; }
.summary-label { color: #666; font-size: 12px; }
.summary-value { font-size: 24px; font-weight: bold; color: #1890ff; }
.text-success { color: #52c41a; }
.text-danger { color: #ff4d4f; }
</style>
<h1>财务报表 - ${getTabTitle()}</h1>
<p>报表周期: ${dateRange.value[0].format('YYYY-MM-DD')}${dateRange.value[1].format('YYYY-MM-DD')}</p>
<p>生成时间: ${dayjs().format('YYYY-MM-DD HH:mm:ss')}</p>
${generatePrintContent()}
`
const printWindow = window.open('', '_blank')
if (printWindow) {
printWindow.document.write(printContent.innerHTML)
printWindow.document.close()
printWindow.print()
printWindow.close()
}
message.success('PDF导出完成请在打印对话框中选择"另存为PDF"')
}
function getTabTitle(): string {
const titles: Record<string, string> = {
income_expense: '收支明细表',
income_category: '收入分类统计',
expense_category: '支出分类统计',
profit: '利润报表'
}
return titles[activeTab.value] || ''
}
function generatePrintContent(): string {
if (activeTab.value === 'income_expense') {
let html = `
<div class="summary">
<div class="summary-item">
<div class="summary-label">总收入</div>
<div class="summary-value text-success">¥${reportData.totalIncome.toLocaleString()}</div>
</div>
<div class="summary-item">
<div class="summary-label">总支出</div>
<div class="summary-value text-danger">¥${reportData.totalExpense.toLocaleString()}</div>
</div>
<div class="summary-item">
<div class="summary-label">净利润</div>
<div class="summary-value">¥${reportData.profit.toLocaleString()}</div>
</div>
</div>
<table>
<thead><tr><th>日期</th><th>类型</th><th>分类</th><th>描述</th><th>金额</th><th>关联方</th></tr></thead>
<tbody>
`
reportData.details.slice(0, 50).forEach(item => {
html += `<tr>
<td>${item.date}</td>
<td>${item.direction === 'income' ? '收入' : '支出'}</td>
<td>${item.category}</td>
<td>${item.description}</td>
<td class="${item.direction === 'income' ? 'text-success' : 'text-danger'}">${item.direction === 'income' ? '+' : '-'}¥${item.amount.toLocaleString()}</td>
<td>${item.relatedParty}</td>
</tr>`
})
html += '</tbody></table>'
return html
}
// 其他报表类型类似处理...
return '<p>报表内容</p>'
}
// 窗口大小变化
function handleResize() {
incomePieChart?.resize()
expensePieChart?.resize()
profitChart?.resize()
}
onMounted(() => {
loadReport()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
incomePieChart?.dispose()
expensePieChart?.dispose()
profitChart?.dispose()
})
</script>
<style scoped>
.reports-page {
min-height: 100%;
overflow-y: auto;
}
.filter-bar {
margin-bottom: 24px;
padding: 16px;
background: #fafafa;
border-radius: 8px;
}
.report-content {
min-height: 500px;
}
/* 汇总行 */
.summary-row {
margin-bottom: 24px;
}
.summary-box {
display: flex;
align-items: center;
padding: 20px;
border-radius: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
.summary-box.income {
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
}
.summary-box.expense {
background: linear-gradient(135deg, #eb3349 0%, #f45c43 100%);
}
.summary-box.profit {
background: linear-gradient(135deg, #1890ff 0%, #69c0ff 100%);
}
.summary-box.profit.negative {
background: linear-gradient(135deg, #ff6b6b 0%, #c92a2a 100%);
}
.summary-icon {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.2);
border-radius: 12px;
font-size: 28px;
margin-right: 16px;
}
.summary-info {
flex: 1;
}
.summary-label {
font-size: 14px;
opacity: 0.9;
}
.summary-value {
font-size: 24px;
font-weight: 600;
}
/* 图表容器 */
.chart-container {
height: 350px;
}
.chart-container.large {
height: 400px;
}
/* 文本颜色 */
.text-success {
color: #52c41a;
font-weight: 500;
}
.text-danger {
color: #ff4d4f;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,354 @@
<template>
<div class="settlement-page">
<a-page-header title="结算管理" sub-title="管理项目款项结算与发放" />
<a-card :bordered="false">
<!-- 筛选栏 -->
<div class="table-page-search-wrapper">
<a-form layout="inline">
<a-form-item label="关键词">
<a-input v-model:value="queryParam.keyword" placeholder="项目名 / 人才名 / 单号" allow-clear />
</a-form-item>
<a-form-item label="状态">
<a-select v-model:value="queryParam.status" placeholder="全部" allow-clear style="width: 120px">
<a-select-option v-for="(label, key) in SettlementStatusMap" :key="key" :value="key">
{{ label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="handleQuery">查询</a-button>
<a-button style="margin-left: 8px" @click="resetQuery">重置</a-button>
</a-form-item>
</a-form>
</div>
<!-- 表格 -->
<a-table :columns="columns" :data-source="dataList" :loading="loading" :pagination="pagination" row-key="id"
@change="handleTableChange">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'amount'">
<div class="amount-cell">
<div class="total-amount">{{ record.totalAmount.toLocaleString() }}</div>
<div class="fee-info text-secondary">服务费 -{{ record.platformFee }}</div>
</div>
</template>
<template v-if="column.key === 'actual'">
<div class="actual-amount-cell">
<div class="actual-amount">{{ record.actualAmount.toLocaleString() }}</div>
<div class="tax-info text-secondary" v-if="record.taxAmount > 0">
含税 -{{ record.taxAmount }} ({{ (record.taxRate * 100).toFixed(0) }}%)
</div>
</div>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusLabel(record.status) }}
</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleDetail(record)">详情</a-button>
<a-button
v-if="record.status === 'pending'"
type="link"
size="small"
@click="handleAudit(record)"
>审核</a-button>
<a-popconfirm v-if="record.status === 'paying'" title="确认已向用户打款完毕?" @confirm="handleConfirmPay(record)">
<a-button type="link" size="small">确认打款</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<!-- 详情弹窗 -->
<a-modal v-model:open="detailVisible" title="结算单详情" :footer="null" width="700px">
<a-descriptions v-if="currentDetail" bordered :column="2">
<a-descriptions-item label="结算单号">{{ currentDetail.id }}</a-descriptions-item>
<a-descriptions-item label="结算周期">{{ currentDetail.period }}</a-descriptions-item>
<a-descriptions-item label="关联项目">{{ currentDetail.projectName }}</a-descriptions-item>
<a-descriptions-item label="结算人才">{{ currentDetail.talentName }}</a-descriptions-item>
<a-descriptions-item label="当前状态">
<a-tag :color="SettlementStatusColorMap[currentDetail.status]">
{{ SettlementStatusMap[currentDetail.status] }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="创建时间">{{ formatDate(currentDetail.createdAt) }}</a-descriptions-item>
<a-descriptions-item label="收款信息" :span="2">
{{ currentDetail.bankInfo?.bankName }} - {{ currentDetail.bankInfo?.accountNo }} ({{
currentDetail.bankInfo?.accountName }})
</a-descriptions-item>
<a-descriptions-item label="金额明细" :span="2">
<div class="money-detail">
<div class="item">项目总额<span>{{ currentDetail.totalAmount.toLocaleString() }}</span></div>
<div class="item text-danger">平台服务费<span>-{{ currentDetail.platformFee.toLocaleString() }}</span></div>
<div class="item sub-total">应税金额<span>{{ currentDetail.taxableAmount.toLocaleString() }}</span></div>
<div class="item text-danger">税费扣除 ({{ (currentDetail.taxRate * 100).toFixed(1) }}%)<span>-{{
currentDetail.taxAmount.toLocaleString() }}</span></div>
<div class="item grand-total">实发金额<span>{{ currentDetail.actualAmount.toLocaleString() }}</span></div>
</div>
</a-descriptions-item>
<a-descriptions-item label="审核时间" v-if="currentDetail.auditTime">{{ formatDate(currentDetail.auditTime)
}}</a-descriptions-item>
<a-descriptions-item label="打款时间" v-if="currentDetail.paymentTime">{{ formatDate(currentDetail.paymentTime)
}}</a-descriptions-item>
</a-descriptions>
</a-modal>
<!-- 审批抽屉 -->
<ApprovalDrawer
v-model:open="approveVisible"
title="结算审核"
scenario="payment_request"
:business-title="`${currentRecord?.projectName} - ${currentRecord?.period}`"
:amount="currentRecord?.actualAmount"
:applicant-name="currentRecord?.talentName || ''"
:apply-time="currentRecord?.createdAt || ''"
:approval-nodes="approvalNodes"
:current-node-index="currentApprovalStep"
:show-action="true"
@approve="handleApproveSubmit"
>
<template #extra-info>
<a-descriptions-item label="结算单号">{{ currentRecord?.id }}</a-descriptions-item>
<a-descriptions-item label="项目总额">
<span>{{ currentRecord?.totalAmount.toLocaleString() }}</span>
</a-descriptions-item>
<a-descriptions-item label="扣税金额" v-if="currentRecord?.taxAmount">
<span class="text-danger">-{{ currentRecord?.taxAmount.toLocaleString() }}</span>
</a-descriptions-item>
<a-descriptions-item label="银行账户" :span="1">
{{ currentRecord?.bankInfo?.bankName }} ({{ currentRecord?.bankInfo?.accountNo?.slice(-4) }})
</a-descriptions-item>
</template>
</ApprovalDrawer>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import type { SettlementItem, SettlementStatus } from '@/types'
import { SettlementStatusMap, SettlementStatusColorMap } from '@/types'
import { mockGetSettlements, mockAuditSettlement, mockConfirmPayment } from '@/mock'
import { formatDate } from '@/utils/common'
import ApprovalDrawer from '@/components/ApprovalDrawer/index.vue'
import type { ApproverType } from '@/types/approval'
// 辅助函数用于处理模板中的类型安全访问
function getStatusColor(status: string): string {
return SettlementStatusColorMap[status as SettlementStatus] || 'default'
}
function getStatusLabel(status: string): string {
return SettlementStatusMap[status as SettlementStatus] || status
}
const loading = ref(false)
const appoveLoading = ref(false)
const dataList = ref<SettlementItem[]>([])
const detailVisible = ref(false)
const approveVisible = ref(false)
const currentDetail = ref<SettlementItem | null>(null)
const currentRecord = ref<SettlementItem | null>(null)
// 审批进度数据
interface ApprovalNodeInfo {
id: number
name: string
approverType: ApproverType
approverName?: string
approverAvatar?: string
status?: 'pending' | 'approved' | 'rejected' | 'skipped'
comment?: string
operatedAt?: string
order: number
}
const approvalNodes = ref<ApprovalNodeInfo[]>([
{ id: 1, name: '业务确认', approverType: 'role', approverName: '业务经理', status: 'approved', comment: '确认交付无误', operatedAt: new Date(Date.now() - 172800000).toISOString(), order: 1 },
{ id: 2, name: '财务审核', approverType: 'role', approverName: '财务专员', status: 'pending', order: 2 },
{ id: 3, name: '出纳打款', approverType: 'role', approverName: '出纳', order: 3 }
])
const currentApprovalStep = ref(1)
const queryParam = reactive({
keyword: '',
status: undefined as SettlementStatus | undefined
})
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showTotal: (total: number) => `${total}`
})
const columns = [
{ title: '单号', dataIndex: 'id', key: 'id', width: 90 },
{ title: '项目名称', dataIndex: 'projectName', key: 'projectName' },
{ title: '结算人才', dataIndex: 'talentName', key: 'talentName', width: 100 },
{ title: '项目总额', key: 'amount', width: 120, align: 'right' as const },
{ title: '实发金额', key: 'actual', width: 130, align: 'right' as const },
{ title: '期数', dataIndex: 'period', key: 'period', width: 100 },
{ title: '状态', key: 'status', width: 100 },
{ title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', width: 120, customRender: ({ text }: any) => formatDate(text) },
{ title: '操作', key: 'action', width: 150 }
]
async function loadData() {
loading.value = true
try {
const res = await mockGetSettlements({
page: pagination.current,
pageSize: pagination.pageSize,
keyword: queryParam.keyword,
status: queryParam.status
})
dataList.value = res.list
pagination.total = res.total
} catch (error) {
message.error('加载失败')
} finally {
loading.value = false
}
}
function handleQuery() {
pagination.current = 1
loadData()
}
function resetQuery() {
queryParam.keyword = ''
queryParam.status = undefined
handleQuery()
}
function handleTableChange(pag: any) {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
loadData()
}
function handleDetail(record: any) {
currentDetail.value = record
detailVisible.value = true
}
function handleAudit(record: any) {
currentRecord.value = record
approveVisible.value = true
}
async function handleApproveSubmit(data: { approved: boolean; comment: string }) {
if (!currentRecord.value) return
appoveLoading.value = true
try {
const success = await mockAuditSettlement(currentRecord.value.id, data.approved)
if (success) {
// update nodes
if (currentApprovalStep.value < approvalNodes.value.length) {
approvalNodes.value[currentApprovalStep.value]!.status = data.approved ? 'approved' : 'rejected'
approvalNodes.value[currentApprovalStep.value]!.comment = data.comment
approvalNodes.value[currentApprovalStep.value]!.operatedAt = new Date().toISOString()
if (data.approved) currentApprovalStep.value++
}
message.success(data.approved ? '审核已通过,进入打款流程' : '已驳回结算申请')
approveVisible.value = false
loadData()
}
} catch (e) {
message.error('操作失败')
} finally {
appoveLoading.value = false
}
}
async function handleConfirmPay(record: any) {
const success = await mockConfirmPayment(record.id)
if (success) {
message.success('已确认打款')
loadData()
}
}
onMounted(() => {
loadData()
})
</script>
<style scoped>
.settlement-page {
min-height: 100%;
}
.table-page-search-wrapper {
margin-bottom: 24px;
}
.text-secondary {
color: #8c8c8c;
font-size: 12px;
}
.text-danger {
color: #ff4d4f;
}
.amount-cell,
.actual-amount-cell {
display: flex;
flex-direction: column;
}
.total-amount,
.actual-amount {
font-weight: 500;
}
.actual-amount {
color: #52c41a;
font-size: 15px;
}
.money-detail {
width: 100%;
}
.money-detail .item {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
padding: 4px 0;
}
.money-detail .sub-total {
border-top: 1px dashed #e8e8e8;
padding-top: 12px;
font-weight: 500;
}
.money-detail .grand-total {
border-top: 2px solid #e8e8e8;
padding-top: 12px;
margin-top: 8px;
font-size: 16px;
font-weight: bold;
color: #52c41a;
}
</style>

428
src/views/login/index.vue Normal file
View File

@@ -0,0 +1,428 @@
<template>
<div class="login-container">
<!-- 背景装饰 -->
<div class="bg-decoration">
<div class="decoration-circle circle-1"></div>
<div class="decoration-circle circle-2"></div>
<div class="decoration-circle circle-3"></div>
<div class="decoration-line line-1"></div>
<div class="decoration-line line-2"></div>
</div>
<!-- Logo -->
<div class="logo-area">
<div class="logo">
<span class="logo-icon"></span>
<span class="logo-text">楠溪屿</span>
</div>
</div>
<!-- 登录卡片 - 偏右位置 -->
<div class="login-wrapper">
<div class="login-box">
<div class="login-header">
<h1>欢迎回来</h1>
<p>登录您的管理员账户</p>
</div>
<a-form
:model="loginForm"
:rules="loginRules"
ref="formRef"
class="login-form"
@finish="handleLogin"
>
<a-form-item name="username">
<a-input
v-model:value="loginForm.username"
placeholder="用户名"
size="large"
class="custom-input"
>
<template #prefix>
<UserOutlined class="input-icon" />
</template>
</a-input>
</a-form-item>
<a-form-item name="password">
<a-input-password
v-model:value="loginForm.password"
placeholder="密码"
size="large"
class="custom-input"
>
<template #prefix>
<LockOutlined class="input-icon" />
</template>
</a-input-password>
</a-form-item>
<a-form-item name="captcha">
<div class="captcha-row">
<a-input
v-model:value="loginForm.captcha"
placeholder="验证码"
size="large"
class="captcha-input custom-input"
>
<template #prefix>
<SafetyCertificateOutlined class="input-icon" />
</template>
</a-input>
<div class="captcha-image" @click="refreshCaptcha" title="点击刷新验证码">
<img v-if="captchaImage" :src="captchaImage" alt="验证码" />
<a-spin v-else size="small" />
</div>
</div>
</a-form-item>
<a-form-item>
<a-checkbox v-model:checked="rememberMe">记住我</a-checkbox>
</a-form-item>
<a-form-item>
<a-button
type="primary"
html-type="submit"
size="large"
block
:loading="loading"
class="login-btn"
>
登录
</a-button>
</a-form-item>
</a-form>
<div class="login-footer">
<span class="footer-text">程序员论坛社区管理平台</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { UserOutlined, LockOutlined, SafetyCertificateOutlined } from '@ant-design/icons-vue'
import { useUserStore } from '@/stores'
import type { FormInstance, Rule } from 'ant-design-vue/es/form'
const router = useRouter()
const userStore = useUserStore()
const formRef = ref<FormInstance>()
const loading = ref(false)
const captchaImage = ref('')
const captchaKey = ref('')
const rememberMe = ref(false)
// 登录表单
const loginForm = reactive({
username: '',
password: '',
captcha: ''
})
// 表单验证规则
const loginRules: Record<string, Rule[]> = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
],
captcha: [
{ required: true, message: '请输入验证码', trigger: 'blur' }
]
}
// 获取验证码
async function refreshCaptcha() {
try {
const data = await userStore.getCaptcha()
captchaImage.value = data.captchaImage
captchaKey.value = data.captchaKey
} catch (error) {
console.error('获取验证码失败:', error)
}
}
// 登录处理
async function handleLogin() {
loading.value = true
try {
await userStore.login({
...loginForm,
captchaKey: captchaKey.value
})
message.success('登录成功,欢迎回来!')
router.push('/')
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : '登录失败'
message.error(errorMessage)
refreshCaptcha()
} finally {
loading.value = false
}
}
onMounted(() => {
refreshCaptcha()
})
</script>
<style scoped>
.login-container {
min-height: 100vh;
position: relative;
overflow: hidden;
background: linear-gradient(135deg, #e8f4fc 0%, #f5f0fa 50%, #fef6f0 100%);
}
/* 背景装饰 */
.bg-decoration {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
.decoration-circle {
position: absolute;
border-radius: 50%;
opacity: 0.6;
}
.circle-1 {
width: 300px;
height: 300px;
background: linear-gradient(135deg, rgba(114, 197, 229, 0.3) 0%, rgba(114, 197, 229, 0.1) 100%);
top: -50px;
left: -100px;
}
.circle-2 {
width: 200px;
height: 200px;
background: linear-gradient(135deg, rgba(250, 176, 162, 0.4) 0%, rgba(250, 176, 162, 0.1) 100%);
bottom: 100px;
left: 10%;
}
.circle-3 {
width: 400px;
height: 400px;
background: linear-gradient(135deg, rgba(186, 220, 231, 0.3) 0%, rgba(186, 220, 231, 0.1) 100%);
bottom: -150px;
right: -100px;
}
.decoration-line {
position: absolute;
height: 1px;
background: rgba(250, 176, 162, 0.5);
}
.line-1 {
width: 100%;
top: 45%;
transform: rotate(-2deg);
}
.line-2 {
width: 100%;
top: 55%;
transform: rotate(1deg);
}
/* Logo区域 */
.logo-area {
position: absolute;
top: 40px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
}
.logo {
display: flex;
align-items: center;
gap: 8px;
}
.logo-icon {
font-size: 28px;
color: #1890ff;
font-weight: bold;
}
.logo-text {
font-size: 24px;
font-weight: 600;
color: #1a1a2e;
}
/* 登录卡片包装器 - 偏右布局 */
.login-wrapper {
min-height: 100vh;
display: flex;
justify-content: flex-end;
align-items: center;
padding-right: 12%;
}
.login-box {
width: 380px;
padding: 40px 36px;
background: #fff;
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
position: relative;
z-index: 10;
}
.login-header {
text-align: left;
margin-bottom: 32px;
}
.login-header h1 {
font-size: 26px;
color: #1a1a2e;
margin-bottom: 8px;
font-weight: 600;
}
.login-header p {
font-size: 14px;
color: #8c8c8c;
}
/* 表单样式 */
.login-form {
margin-top: 24px;
}
/* 统一输入框样式 */
.custom-input,
.custom-input :deep(.ant-input),
.custom-input :deep(.ant-input-password) {
border-radius: 8px !important;
background: #fff !important;
}
.custom-input {
border: 1px solid #e0e0e0;
}
.custom-input:hover {
border-color: #91caff;
}
.custom-input:focus,
.custom-input:focus-within,
.custom-input.ant-input-affix-wrapper-focused {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
}
.input-icon {
color: #b0b0b0;
font-size: 16px;
}
.captcha-row {
display: flex;
gap: 12px;
}
.captcha-input {
flex: 1;
}
.captcha-image {
width: 110px;
height: 40px;
cursor: pointer;
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
background: #fff;
transition: all 0.3s;
}
.captcha-image:hover {
border-color: #91caff;
}
.captcha-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* 覆盖 antd 默认样式 */
:deep(.ant-input-affix-wrapper) {
background: #fff;
}
:deep(.ant-input) {
background: #fff;
}
:deep(.ant-checkbox-wrapper) {
color: #666;
}
/* 登录按钮 */
.login-btn {
height: 44px;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
border: none;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
}
.login-btn:hover {
background: linear-gradient(135deg, #40a9ff 0%, #1890ff 100%);
box-shadow: 0 6px 16px rgba(24, 144, 255, 0.4);
}
/* 底部文字 */
.login-footer {
text-align: center;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid #f0f0f0;
}
.footer-text {
font-size: 12px;
color: #bfbfbf;
}
/* 响应式 */
@media (max-width: 768px) {
.login-wrapper {
justify-content: center;
padding-right: 0;
padding: 20px;
}
.login-box {
width: 100%;
max-width: 380px;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,518 @@
<template>
<div class="platform-menus-page">
<a-page-header
:title="currentProject ? `${currentProject.name} - 菜单管理` : '菜单管理'"
sub-title="管理项目的业务菜单配置"
>
<template #extra>
<a-select
v-model:value="selectedProjectId"
style="width: 200px"
placeholder="选择项目"
@change="onProjectChange"
>
<a-select-option v-for="project in projectList" :key="project.id" :value="project.id">
<a-avatar :size="16" :style="{ backgroundColor: project.color, marginRight: '8px' }">
{{ project.logo }}
</a-avatar>
{{ project.shortName }}
</a-select-option>
</a-select>
</template>
</a-page-header>
<a-row :gutter="16">
<!-- 左侧菜单树 -->
<a-col :span="10">
<a-card title="菜单结构" :bordered="false">
<template #extra>
<a-space>
<a-button type="primary" size="small" @click="handleAddMenu">
<PlusOutlined /> 新增
</a-button>
</a-space>
</template>
<a-tree
v-if="menuTree.length > 0"
:tree-data="menuTree"
:field-names="{ title: 'label', key: 'key', children: 'children' }"
default-expand-all
show-line
:selected-keys="selectedMenuKeys"
@select="onMenuSelect"
>
<template #title="{ label, key, icon, path }">
<div class="menu-tree-item">
<component :is="icon" v-if="icon" class="menu-icon" />
<span class="menu-label">{{ label }}</span>
<span class="menu-path" v-if="path">{{ path }}</span>
</div>
</template>
</a-tree>
<a-empty v-else description="暂无菜单配置" />
</a-card>
</a-col>
<!-- 右侧菜单详情/编辑 -->
<a-col :span="14">
<a-card :title="isEditing ? (selectedMenu ? '编辑菜单' : '新增菜单') : '菜单详情'" :bordered="false">
<template #extra v-if="selectedMenu && !isEditing">
<a-space>
<a-button size="small" @click="startEdit">编辑</a-button>
<a-popconfirm title="确定删除此菜单?" @confirm="handleDeleteMenu">
<a-button size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
<a-empty v-if="!selectedMenu && !isEditing" description="请选择或新增菜单" />
<!-- 编辑表单 -->
<a-form
v-if="isEditing"
ref="menuFormRef"
:model="menuFormData"
:rules="menuFormRules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<a-form-item label="菜单类型" name="type">
<a-radio-group v-model:value="menuFormData.type">
<a-radio value="menu">菜单项</a-radio>
<a-radio value="group">菜单组</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="菜单标识" name="key">
<a-input v-model:value="menuFormData.key" placeholder="唯一标识posts" />
</a-form-item>
<a-form-item label="菜单名称" name="label">
<a-input v-model:value="menuFormData.label" placeholder="显示名称,如:帖子管理" />
</a-form-item>
<a-form-item label="图标" v-if="menuFormData.type === 'group'">
<a-select v-model:value="menuFormData.icon" placeholder="选择图标">
<a-select-option v-for="icon in iconOptions" :key="icon.value" :value="icon.value">
{{ icon.label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="路由路径" name="path" v-if="menuFormData.type === 'menu'">
<a-input v-model:value="menuFormData.path" placeholder="如:/community/posts" />
</a-form-item>
<a-form-item label="父级菜单" v-if="menuFormData.type === 'menu'">
<a-select v-model:value="menuFormData.parentKey" placeholder="选择父级菜单组" allow-clear>
<a-select-option v-for="group in menuGroups" :key="group.key" :value="group.key">
{{ group.label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="排序">
<a-input-number v-model:value="menuFormData.order" :min="0" style="width: 100%" />
</a-form-item>
<a-form-item :wrapper-col="{ offset: 5, span: 18 }">
<a-space>
<a-button type="primary" @click="handleSaveMenu" :loading="saving">保存</a-button>
<a-button @click="cancelEdit">取消</a-button>
</a-space>
</a-form-item>
</a-form>
<!-- 查看模式 -->
<a-descriptions v-else-if="selectedMenu" :column="1" bordered>
<a-descriptions-item label="菜单类型">
<a-tag :color="selectedMenu.children ? 'blue' : 'green'">
{{ selectedMenu.children ? '菜单组' : '菜单项' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="菜单标识">{{ selectedMenu.key }}</a-descriptions-item>
<a-descriptions-item label="菜单名称">{{ selectedMenu.label }}</a-descriptions-item>
<a-descriptions-item label="路由路径" v-if="selectedMenu.path">
<a-tag>{{ selectedMenu.path }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="子菜单数量" v-if="selectedMenu.children">
{{ selectedMenu.children.length }}
</a-descriptions-item>
</a-descriptions>
</a-card>
</a-col>
</a-row>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { message } from 'ant-design-vue'
import { PlusOutlined } from '@ant-design/icons-vue'
import type { MenuItem } from '@/config'
const route = useRoute()
interface PlatformProject {
id: string
name: string
shortName: string
logo: string
color?: string
}
interface MenuNode extends MenuItem {
order?: number
}
const selectedProjectId = ref<string>('')
const selectedMenuKeys = ref<string[]>([])
const isEditing = ref(false)
const saving = ref(false)
const menuFormRef = ref()
// Mock 项目数据
const projectList = ref<PlatformProject[]>([
{
id: 'codePort',
name: 'CodePort 码头',
shortName: 'CodePort',
logo: '码',
color: '#1890ff'
}
])
// Mock 菜单数据 - 初始化为 codePort 的菜单
const menuData = ref<Record<string, MenuNode[]>>({
codePort: [
{
key: 'dashboard',
label: '控制台',
path: '/dashboard',
order: 0
},
{
key: 'community',
label: '社区管理',
order: 1,
children: [
{ key: 'posts', label: '帖子管理', path: '/community/posts' },
{ key: 'comments', label: '评论管理', path: '/community/comments' },
{ key: 'tags', label: '标签管理', path: '/community/tags' },
{ key: 'circles', label: '城市圈子', path: '/community/circles' }
]
},
{
key: 'content',
label: '内容管理',
order: 2,
children: [
{ key: 'articles', label: '文章管理', path: '/content/articles' }
]
},
{
key: 'support',
label: '客服管理',
order: 3,
children: [
{ key: 'support-console', label: '接入会话', path: '/support/console' },
{ key: 'support-conversations', label: '会话列表', path: '/support/conversations' }
]
},
{
key: 'project',
label: '项目管理',
order: 4,
children: [
{ key: 'projects', label: '项目列表', path: '/project/list' },
{ key: 'recruitment', label: '招募管理', path: '/project/recruitment' },
{ key: 'signed-projects', label: '已成交项目', path: '/project/signed' },
{ key: 'contracts', label: '合同管理', path: '/project/contract' }
]
},
{
key: 'talent',
label: '人才管理',
order: 5,
children: [
{ key: 'talent-list', label: '人才列表', path: '/talent' },
{ key: 'resume-templates', label: '简历模板', path: '/talent/resume-templates' }
]
},
{
key: 'user',
label: '用户管理',
order: 6,
children: [
{ key: 'users', label: '用户列表', path: '/user/list' },
{ key: 'certification', label: '认证管理', path: '/user/certification' },
{ key: 'roles', label: '角色管理', path: '/user/roles' },
{ key: 'positions', label: '岗位管理', path: '/user/positions' },
{ key: 'levels', label: '等级配置', path: '/user/levels' }
]
}
]
})
const menuFormData = reactive({
type: 'menu' as 'menu' | 'group',
key: '',
label: '',
icon: '',
path: '',
parentKey: '',
order: 0
})
const menuFormRules = {
key: [{ required: true, message: '请输入菜单标识' }],
label: [{ required: true, message: '请输入菜单名称' }],
path: [{ required: true, message: '请输入路由路径' }]
}
const iconOptions = [
{ value: 'DashboardOutlined', label: '仪表盘' },
{ value: 'TeamOutlined', label: '团队' },
{ value: 'ReadOutlined', label: '阅读' },
{ value: 'CustomerServiceOutlined', label: '客服' },
{ value: 'ProjectOutlined', label: '项目' },
{ value: 'IdcardOutlined', label: '身份证' },
{ value: 'UserOutlined', label: '用户' },
{ value: 'SettingOutlined', label: '设置' }
]
const currentProject = computed(() => {
return projectList.value.find(p => p.id === selectedProjectId.value)
})
const menuTree = computed(() => {
return menuData.value[selectedProjectId.value] || []
})
const menuGroups = computed(() => {
return menuTree.value.filter(m => m.children)
})
const selectedMenu = computed(() => {
if (selectedMenuKeys.value.length === 0) return null
const key = selectedMenuKeys.value[0]
// 在顶层查找
let menu = menuTree.value.find(m => m.key === key)
if (menu) return menu
// 在子菜单中查找
for (const group of menuTree.value) {
if (group.children) {
menu = group.children.find(m => m.key === key)
if (menu) return menu
}
}
return null
})
function onProjectChange(projectId: string) {
selectedProjectId.value = projectId
selectedMenuKeys.value = []
isEditing.value = false
}
function onMenuSelect(keys: string[]) {
selectedMenuKeys.value = keys
isEditing.value = false
}
function handleAddMenu() {
selectedMenuKeys.value = []
Object.assign(menuFormData, {
type: 'menu',
key: '',
label: '',
icon: '',
path: '',
parentKey: '',
order: menuTree.value.length
})
isEditing.value = true
}
function startEdit() {
if (!selectedMenu.value) return
const menu = selectedMenu.value
Object.assign(menuFormData, {
type: menu.children ? 'group' : 'menu',
key: menu.key,
label: menu.label,
icon: '',
path: menu.path || '',
parentKey: '',
order: (menu as MenuNode).order || 0
})
isEditing.value = true
}
function cancelEdit() {
isEditing.value = false
menuFormRef.value?.resetFields()
}
async function handleSaveMenu() {
try {
await menuFormRef.value.validate()
} catch {
return
}
saving.value = true
try {
await new Promise(resolve => setTimeout(resolve, 500))
const projectMenus = menuData.value[selectedProjectId.value] || []
if (menuFormData.type === 'group') {
// 添加/更新菜单组
const existingIndex = projectMenus.findIndex(m => m.key === menuFormData.key)
if (existingIndex > -1) {
projectMenus[existingIndex] = {
...projectMenus[existingIndex]!,
label: menuFormData.label,
order: menuFormData.order
}
} else {
projectMenus.push({
key: menuFormData.key,
label: menuFormData.label,
order: menuFormData.order,
children: []
})
}
} else {
// 添加/更新菜单项
if (menuFormData.parentKey) {
// 添加到父级菜单组
const parent = projectMenus.find(m => m.key === menuFormData.parentKey)
if (parent && parent.children) {
const existingIndex = parent.children.findIndex(m => m.key === menuFormData.key)
if (existingIndex > -1) {
parent.children[existingIndex] = {
key: menuFormData.key,
label: menuFormData.label,
path: menuFormData.path
}
} else {
parent.children.push({
key: menuFormData.key,
label: menuFormData.label,
path: menuFormData.path
})
}
}
} else {
// 添加到顶层
const existingIndex = projectMenus.findIndex(m => m.key === menuFormData.key)
if (existingIndex > -1) {
projectMenus[existingIndex] = {
...projectMenus[existingIndex]!,
label: menuFormData.label,
path: menuFormData.path,
order: menuFormData.order
}
} else {
projectMenus.push({
key: menuFormData.key,
label: menuFormData.label,
path: menuFormData.path,
order: menuFormData.order
})
}
}
}
menuData.value[selectedProjectId.value] = projectMenus
message.success('保存成功')
isEditing.value = false
selectedMenuKeys.value = [menuFormData.key]
} catch (error) {
message.error('保存失败')
} finally {
saving.value = false
}
}
function handleDeleteMenu() {
if (!selectedMenu.value) return
const key = selectedMenu.value.key
const projectMenus = menuData.value[selectedProjectId.value] || []
// 从顶层删除
const topIndex = projectMenus.findIndex(m => m.key === key)
if (topIndex > -1) {
projectMenus.splice(topIndex, 1)
message.success('删除成功')
selectedMenuKeys.value = []
return
}
// 从子菜单删除
for (const group of projectMenus) {
if (group.children) {
const childIndex = group.children.findIndex(m => m.key === key)
if (childIndex > -1) {
group.children.splice(childIndex, 1)
message.success('删除成功')
selectedMenuKeys.value = []
return
}
}
}
}
onMounted(() => {
// 从URL参数获取项目ID
const projectId = route.query.projectId as string
if (projectId && projectList.value.some(p => p.id === projectId)) {
selectedProjectId.value = projectId
} else if (projectList.value.length > 0) {
selectedProjectId.value = projectList.value[0]!.id
}
})
watch(() => route.query.projectId, (newId) => {
if (newId && typeof newId === 'string') {
selectedProjectId.value = newId
}
})
</script>
<style scoped>
.platform-menus-page {
min-height: 100%;
}
.menu-tree-item {
display: flex;
align-items: center;
gap: 8px;
}
.menu-icon {
font-size: 14px;
color: #1890ff;
}
.menu-label {
font-weight: 500;
}
.menu-path {
font-size: 12px;
color: #8c8c8c;
margin-left: auto;
}
</style>

View File

@@ -0,0 +1,885 @@
<template>
<div class="platform-projects-page">
<a-page-header title="项目管理" sub-title="管理平台中的业务项目" />
<a-card :bordered="false">
<!-- 操作栏 -->
<div class="table-actions">
<a-button type="primary" @click="handleAdd">
<PlusOutlined /> 新建项目
</a-button>
</div>
<!-- 项目列表 -->
<a-table
:columns="columns"
:data-source="projectStore.projects"
:loading="loading"
row-key="id"
:pagination="false"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'logo'">
<a-avatar :style="{ backgroundColor: record.color || '#1890ff' }" size="large">
{{ record.logo }}
</a-avatar>
</template>
<template v-if="column.key === 'name'">
<div class="project-name">
<strong>{{ record.name }}</strong>
<div class="project-id">ID: {{ record.id }}</div>
</div>
</template>
<template v-if="column.key === 'version'">
<a-tag color="blue">v{{ record.currentVersion || '1.0.0' }}</a-tag>
<a-button
type="link"
size="small"
@click="openVersionDrawer(record as any)"
>
<HistoryOutlined /> 版本
</a-button>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="record.enabled ? 'green' : 'default'">
{{ record.enabled ? '启用' : '禁用' }}
</a-tag>
</template>
<template v-if="column.key === 'menuCount'">
<a-badge :count="countMenuItems(record.menus)" :overflow-count="99" show-zero>
<a-button type="link" size="small" @click="goToMenus(record as any)">
查看菜单
</a-button>
</a-badge>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="primary" size="small" ghost @click="handleEnterProject(record as any)">
进入
</a-button>
<a-button type="link" size="small" @click="handleUpload(record as any)">
<UploadOutlined /> 上传
</a-button>
<a-button type="link" size="small" @click="handleEdit(record as any)">编辑</a-button>
<a-switch
:checked="record.enabled"
size="small"
@change="() => handleToggle(record as any)"
/>
<a-popconfirm title="确定删除此项目?" @confirm="handleDelete(record as any)">
<a-button type="link" size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<!-- 新建/编辑弹窗 -->
<a-modal
v-model:open="formVisible"
:title="currentProject?.id ? '编辑项目' : '新建项目'"
@ok="handleFormSubmit"
:confirm-loading="formLoading"
width="800px"
:bodyStyle="{ maxHeight: '70vh', overflowY: 'auto' }"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 19 }"
class="project-form"
>
<!-- 基础信息 -->
<a-divider orientation="left">基础信息</a-divider>
<a-form-item label="分组" name="group">
<a-select v-model:value="formData.group" placeholder="请选择分组">
<a-select-option value="default">默认</a-select-option>
<a-select-option value="business">业务项目</a-select-option>
<a-select-option value="internal">内部项目</a-select-option>
<a-select-option value="test">测试项目</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="项目标识" name="id">
<a-input
v-model:value="formData.id"
placeholder="如codePort英文标识唯一"
:disabled="!!currentProject?.id"
/>
</a-form-item>
<a-form-item label="项目名称" name="name">
<a-input v-model:value="formData.name" placeholder="如CodePort 码头" />
</a-form-item>
<a-form-item label="项目简称" name="shortName">
<a-input v-model:value="formData.shortName" placeholder="如CodePort显示在标签中" />
</a-form-item>
<a-form-item label="Logo文字" name="logo">
<a-input v-model:value="formData.logo" placeholder="单个中文字符,如:码" :maxlength="1" />
</a-form-item>
<a-form-item label="主题色">
<a-input v-model:value="formData.color" placeholder="#1890ff" style="width: 200px;" />
<div class="color-preview" :style="{ backgroundColor: formData.color || '#1890ff' }">
<span>{{ formData.logo || '预' }}</span>
</div>
</a-form-item>
<a-form-item label="服务器地址" name="serverAddress" required>
<a-input v-model:value="formData.serverAddress" placeholder="如192.168.1.100" />
</a-form-item>
<!-- 域名配置 -->
<a-divider orientation="left">域名配置</a-divider>
<a-form-item label="域名" required style="margin-bottom: 0">
<a-row :gutter="16">
<a-col :span="14">
<a-form-item name="domain" no-style>
<a-input v-model:value="formData.domain" placeholder="如www.example.com" />
</a-form-item>
</a-col>
<a-col :span="10">
<a-form-item name="port" no-style>
<a-input-number
v-model:value="formData.port"
:min="1"
:max="65535"
placeholder="端口"
style="width: 100%;"
/>
</a-form-item>
</a-col>
</a-row>
</a-form-item>
<a-form-item label="代号" name="alias" required>
<a-input v-model:value="formData.alias" placeholder="项目代号" />
<div class="form-item-help">对应主目录/opt/1panel/www/sites/{{ formData.alias || 'xxx' }}/index</div>
</a-form-item>
<!-- HTTPS 配置 -->
<a-divider orientation="left">安全配置</a-divider>
<a-form-item label="启用 HTTPS" name="enableHttps">
<a-checkbox v-model:checked="formData.enableHttps">
开启失败不会影响网站创建
</a-checkbox>
</a-form-item>
<template v-if="formData.enableHttps">
<a-form-item label="证书状态" name="certificate">
<!-- 加载中 -->
<template v-if="certificateLoading">
<a-spin size="small" />
<span style="margin-left: 8px; color: #999;">正在检查证书...</span>
</template>
<!-- 找到证书 -->
<template v-else-if="matchedCertificate">
<a-tag color="success"> 已匹配证书</a-tag>
<span style="margin-left: 8px;">{{ matchedCertificate.primaryDomain }}</span>
<!-- 证书信息表格 -->
<a-descriptions
:column="2"
size="small"
bordered
style="margin-top: 12px;"
>
<a-descriptions-item label="主域名">{{ matchedCertificate.primaryDomain }}</a-descriptions-item>
<a-descriptions-item label="其他域名">{{ matchedCertificate.domains || '-' }}</a-descriptions-item>
<a-descriptions-item label="签发组织">{{ matchedCertificate.organization || "Let's Encrypt" }}</a-descriptions-item>
<a-descriptions-item label="过期时间">{{ matchedCertificate.expireDate }}</a-descriptions-item>
</a-descriptions>
</template>
<!-- 未找到证书 -->
<template v-else-if="certificateNotFound">
<a-tag color="warning"> 未找到匹配证书</a-tag>
<div style="margin-top: 8px;">
<span style="color: #ff4d4f;">请先前往证书管理申请证书</span>
<a-button
type="link"
size="small"
@click="$router.push('/platform/certificates')"
>
前往申请
</a-button>
</div>
</template>
<!-- 未输入域名 -->
<template v-else>
<span style="color: #999;">请先填写域名系统将自动检查匹配的证书</span>
</template>
</a-form-item>
</template>
<!-- 项目地址 -->
<a-divider orientation="left">访问配置</a-divider>
<a-form-item label="项目地址" name="baseUrl" required>
<a-input v-model:value="formData.baseUrl" placeholder="如http://localhost:5174" />
<div class="form-item-help">用于iframe嵌套的子项目地址</div>
</a-form-item>
<!-- 备注 -->
<a-divider orientation="left">其他</a-divider>
<a-form-item label="备注" name="remark">
<a-textarea
v-model:value="formData.remark"
:rows="4"
placeholder="项目备注信息(选填)"
/>
</a-form-item>
</a-form>
</a-modal>
<!-- 版本管理抽屉 -->
<a-drawer
v-model:open="versionDrawerVisible"
:title="`版本管理 - ${versionProject?.name || ''}`"
width="600"
placement="right"
>
<div class="version-drawer-content">
<!-- 创建新版本 -->
<a-card size="small" title="创建新版本" class="create-version-card">
<a-form layout="vertical">
<a-form-item label="版本描述">
<a-input
v-model:value="newVersionDescription"
placeholder="请输入版本更新描述"
/>
</a-form-item>
<a-form-item>
<a-space>
<a-button
type="primary"
@click="handleCreateVersion"
:disabled="!newVersionDescription.trim()"
>
<SaveOutlined /> 保存版本
</a-button>
<a-button @click="handleVersionUpload">
<UploadOutlined /> 上传版本文件
</a-button>
</a-space>
</a-form-item>
</a-form>
</a-card>
<!-- 版本历史 -->
<a-card size="small" title="版本历史" class="version-history-card">
<a-timeline v-if="versionList.length > 0">
<a-timeline-item
v-for="version in versionList"
:key="version.id"
:color="version.version === versionProject?.currentVersion ? 'green' : 'blue'"
>
<div class="version-item">
<div class="version-header">
<a-tag
:color="version.version === versionProject?.currentVersion ? 'green' : 'default'"
>
v{{ version.version }}
<span v-if="version.version === versionProject?.currentVersion"> (当前)</span>
</a-tag>
<span class="version-time">{{ formatTime(version.createdAt) }}</span>
</div>
<div class="version-description">{{ version.description }}</div>
<div class="version-meta">
<span>创建人{{ version.createdBy }}</span>
</div>
<div class="version-actions">
<a-button
v-if="version.version !== versionProject?.currentVersion"
type="link"
size="small"
@click="handleRollback(version)"
>
<RollbackOutlined /> 回退到此版本
</a-button>
<a-popconfirm
v-if="version.version !== versionProject?.currentVersion"
title="确定删除此版本?"
@confirm="handleDeleteVersion(version)"
>
<a-button type="link" size="small" danger>
<DeleteOutlined /> 删除
</a-button>
</a-popconfirm>
</div>
</div>
</a-timeline-item>
</a-timeline>
<a-empty v-else description="暂无版本记录" />
</a-card>
</div>
</a-drawer>
<!-- 上传文件组件 -->
<ProjectUpload
v-model:visible="uploadVisible"
:project-id="uploadProjectId"
:project-name="uploadProjectName"
@uploaded="handleUploadComplete"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { message, Modal } from 'ant-design-vue'
import {
PlusOutlined,
HistoryOutlined,
SaveOutlined,
RollbackOutlined,
DeleteOutlined,
UploadOutlined
} from '@ant-design/icons-vue'
import type { PlatformProject, ProjectMenuItem, ProjectVersion } from '@/mock/projects'
import { useProjectStore } from '@/stores'
import ProjectUpload from '@/components/ProjectUpload.vue'
import { findCertificateByDomain, type SSLCertificate } from '@/api/1panel'
const router = useRouter()
const projectStore = useProjectStore()
const loading = ref(false)
const formVisible = ref(false)
const formLoading = ref(false)
const formRef = ref()
const currentProject = ref<PlatformProject | null>(null)
// 版本管理
const versionDrawerVisible = ref(false)
const versionProject = ref<PlatformProject | null>(null)
const newVersionDescription = ref('')
// 文件上传
const uploadVisible = ref(false)
const uploadProjectId = ref('')
const uploadProjectName = ref('')
const versionList = computed(() => {
if (!versionProject.value) return []
return [...(versionProject.value.versions || [])].reverse()
})
// 证书信息表格列
const certColumns = [
{ title: '主域名', dataIndex: 'domain', key: 'domain' },
{ title: '其他域名', dataIndex: 'otherDomain', key: 'otherDomain' },
{ title: '签发组织', dataIndex: 'issuer', key: 'issuer' },
{ title: '类型', dataIndex: 'type', key: 'type' },
{ title: 'Acme 账号', dataIndex: 'acmeAccount', key: 'acmeAccount' },
{ title: '过期时间', dataIndex: 'expireTime', key: 'expireTime' },
{ title: '备注', dataIndex: 'remark', key: 'remark' }
]
// 证书mock数据
const certData = [
{
domain: 'key.nanxiislet.com',
otherDomain: '',
issuer: "Let's Encrypt",
type: 'DNS账号',
acmeAccount: 'acme@1paneldev.com',
expireTime: '2026-03-18',
remark: ''
}
]
const formData = reactive({
id: '',
name: '',
shortName: '',
logo: '',
color: '#1890ff',
serverAddress: '',
baseUrl: '',
description: '',
// 新增字段
group: 'default',
domain: '',
port: 80,
alias: '',
enableHttps: false,
acmeAccount: '',
certificate: '',
remark: '',
fileList: [] as any[]
})
// 证书检查状态
const certificateLoading = ref(false)
const matchedCertificate = ref<SSLCertificate | null>(null)
const certificateNotFound = ref(false)
// 监听HTTPS开关和域名变化自动检查证书
watch(
() => [formData.enableHttps, formData.domain],
async ([enableHttps, domain]) => {
if (enableHttps && domain) {
await checkCertificate(domain as string)
} else {
// 清空证书相关状态
matchedCertificate.value = null
certificateNotFound.value = false
formData.certificate = ''
}
}
)
// 监听域名和HTTPS变化自动生成项目地址
watch(
() => [formData.domain, formData.enableHttps],
([domain, enableHttps]) => {
if (domain) {
const protocol = enableHttps ? 'https://' : 'http://'
formData.baseUrl = protocol + domain
}
}
)
/**
* 检查域名是否有匹配的证书
*/
async function checkCertificate(domain: string) {
if (!domain) return
certificateLoading.value = true
certificateNotFound.value = false
try {
// 使用 acmeAccountID = 1 调用接口
const cert = await findCertificateByDomain(domain, 1)
if (cert) {
matchedCertificate.value = cert
formData.certificate = cert.primaryDomain
message.success(`已找到匹配证书:${cert.primaryDomain}`)
} else {
matchedCertificate.value = null
certificateNotFound.value = true
formData.certificate = ''
message.warning('未找到匹配的证书,请先前往证书管理申请证书')
}
} catch (error) {
console.error('检查证书失败:', error)
certificateNotFound.value = true
message.error('检查证书失败')
} finally {
certificateLoading.value = false
}
}
const formRules = {
id: [
{ required: true, message: '请输入项目标识' },
{ pattern: /^[a-zA-Z][a-zA-Z0-9]*$/, message: '只能包含英文字母和数字,且以字母开头' }
],
name: [{ required: true, message: '请输入项目名称' }],
shortName: [{ required: true, message: '请输入项目简称' }],
logo: [{ required: true, message: '请输入Logo文字' }],
group: [{ required: true, message: '请选择分组' }],
// 服务器地址
serverAddress: [{ required: true, message: '请输入服务器地址' }],
// 域名配置
domain: [{ required: true, message: '请输入域名' }],
port: [{ required: true, message: '请输入端口' }],
alias: [{ required: true, message: '请输入代号' }],
// 访问配置
baseUrl: [{ required: true, message: '请输入项目地址' }]
}
const columns = [
{ title: 'Logo', key: 'logo', width: 80 },
{ title: '项目信息', key: 'name', width: 200 },
{ title: '版本', key: 'version', width: 150 },
{ title: '简称', dataIndex: 'shortName', key: 'shortName', width: 100 },
{ title: '状态', key: 'status', width: 80 },
{ title: '菜单数量', key: 'menuCount', width: 120 },
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true },
{ title: '操作', key: 'action', width: 360, fixed: 'right' as const }
]
/**
* 格式化时间
*/
function formatTime(isoString: string): string {
const date = new Date(isoString)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
/**
* 计算菜单项总数
*/
function countMenuItems(menus: ProjectMenuItem[]): number {
if (!menus) return 0
let count = 0
for (const menu of menus) {
if (menu.children) {
count += menu.children.length
} else {
count += 1
}
}
return count
}
/**
* 打开版本管理抽屉
*/
function openVersionDrawer(record: PlatformProject) {
versionProject.value = record
newVersionDescription.value = ''
versionDrawerVisible.value = true
}
/**
* 创建新版本
*/
function handleCreateVersion() {
if (!versionProject.value || !newVersionDescription.value.trim()) return
const version = projectStore.createVersion(
versionProject.value.id,
newVersionDescription.value.trim()
)
if (version) {
message.success(`版本 v${version.version} 创建成功`)
newVersionDescription.value = ''
versionProject.value = projectStore.getProjectById(versionProject.value.id) || null
} else {
message.error('创建版本失败')
}
}
/**
* 回退到指定版本
*/
function handleRollback(version: ProjectVersion) {
if (!versionProject.value) return
Modal.confirm({
title: '确认回退',
content: `确定要回退到版本 v${version.version} 吗?当前配置将被覆盖。`,
okText: '确定回退',
okType: 'danger',
onOk() {
const success = projectStore.rollbackToVersion(versionProject.value!.id, version.id)
if (success) {
message.success(`已回退到版本 v${version.version}`)
versionProject.value = projectStore.getProjectById(versionProject.value!.id) || null
} else {
message.error('回退失败')
}
}
})
}
/**
* 删除版本
*/
function handleDeleteVersion(version: ProjectVersion) {
if (!versionProject.value) return
const success = projectStore.deleteVersion(versionProject.value.id, version.id)
if (success) {
message.success('版本已删除')
versionProject.value = projectStore.getProjectById(versionProject.value.id) || null
} else {
message.error('删除失败,不能删除当前版本')
}
}
/**
* 进入项目
*/
function handleEnterProject(record: PlatformProject) {
if (!record.enabled) {
message.warning('项目已禁用,无法进入')
return
}
if (!record.baseUrl) {
message.warning('项目未配置访问地址,无法进入')
return
}
projectStore.switchProject(record.id)
router.push(`/app/${record.id}/dashboard`)
}
function handleAdd() {
currentProject.value = null
Object.assign(formData, {
id: '',
name: '',
shortName: '',
logo: '',
color: '#1890ff',
serverAddress: '',
baseUrl: '',
description: '',
group: 'default',
domain: '',
port: 80,
alias: '',
enableHttps: false,
acmeAccount: '',
certificate: '',
remark: '',
fileList: []
})
formVisible.value = true
}
function handleEdit(record: PlatformProject) {
currentProject.value = record
Object.assign(formData, {
id: record.id,
name: record.name,
shortName: record.shortName,
logo: record.logo,
color: record.color || '#1890ff',
serverAddress: (record as any).serverAddress || '',
baseUrl: record.baseUrl || '',
description: record.description || '',
group: (record as any).group || 'default',
domain: (record as any).domain || '',
port: (record as any).port || 80,
alias: (record as any).alias || '',
enableHttps: (record as any).enableHttps || false,
acmeAccount: (record as any).acmeAccount || '',
certificate: (record as any).certificate || '',
remark: (record as any).remark || '',
fileList: []
})
formVisible.value = true
}
async function handleFormSubmit() {
try {
await formRef.value.validate()
} catch {
return
}
formLoading.value = true
try {
await new Promise(resolve => setTimeout(resolve, 500))
const projectData = {
name: formData.name,
shortName: formData.shortName,
logo: formData.logo,
color: formData.color,
serverAddress: formData.serverAddress,
baseUrl: formData.baseUrl,
description: formData.description,
group: formData.group,
domain: formData.domain,
port: formData.port,
alias: formData.alias,
enableHttps: formData.enableHttps,
acmeAccount: formData.acmeAccount,
certificate: formData.certificate,
remark: formData.remark
}
if (currentProject.value?.id) {
projectStore.updateProject(currentProject.value.id, projectData)
message.success('编辑成功')
} else {
if (projectStore.getProjectById(formData.id)) {
message.error('项目标识已存在')
return
}
projectStore.addProject({
id: formData.id,
...projectData,
enabled: true,
menuCount: 0,
menus: [],
createdAt: new Date().toISOString()
} as any)
message.success('新建成功')
}
formVisible.value = false
} catch (error) {
message.error('操作失败')
} finally {
formLoading.value = false
}
}
function handleToggle(record: PlatformProject) {
projectStore.toggleProjectEnabled(record.id)
message.success(record.enabled ? '已禁用' : '已启用')
}
function handleDelete(record: PlatformProject) {
projectStore.deleteProject(record.id)
message.success('删除成功')
}
function goToMenus(record: PlatformProject) {
router.push({ path: '/platform/menus', query: { projectId: record.id } })
}
/**
* 打开上传弹窗
*/
function handleUpload(record: PlatformProject) {
uploadProjectId.value = record.id
uploadProjectName.value = record.name
uploadVisible.value = true
}
/**
* 上传完成回调
*/
function handleUploadComplete() {
message.success('文件上传完成')
}
/**
* 版本管理中上传文件
*/
function handleVersionUpload() {
if (!versionProject.value) return
uploadProjectId.value = versionProject.value.id
uploadProjectName.value = versionProject.value.name
uploadVisible.value = true
}
</script>
<style scoped>
.platform-projects-page {
min-height: 100%;
}
.table-actions {
margin-bottom: 16px;
}
.project-name {
line-height: 1.5;
}
.project-name strong {
font-size: 14px;
color: #1a1a2e;
}
.project-id {
font-size: 12px;
color: #8c8c8c;
}
.color-preview {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 8px;
color: #fff;
font-size: 18px;
font-weight: 600;
margin-left: 12px;
vertical-align: middle;
}
.form-item-help {
font-size: 12px;
color: #8c8c8c;
margin-top: 4px;
}
.project-form :deep(.ant-divider) {
margin: 16px 0;
font-size: 14px;
font-weight: 500;
}
.project-form :deep(.ant-divider-inner-text) {
color: #1890ff;
}
.cert-table {
margin-top: 12px;
}
/* 版本管理样式 */
.version-drawer-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.create-version-card {
background: #f6ffed;
border-color: #b7eb8f;
}
.version-history-card {
flex: 1;
}
.version-item {
padding: 8px 0;
}
.version-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.version-time {
font-size: 12px;
color: #8c8c8c;
}
.version-description {
font-size: 14px;
color: #333;
margin-bottom: 4px;
}
.version-meta {
font-size: 12px;
color: #8c8c8c;
margin-bottom: 8px;
}
.version-actions {
display: flex;
gap: 8px;
}
</style>

View File

@@ -0,0 +1,592 @@
<template>
<div class="project-upload-page">
<!-- 顶部栏 -->
<div class="upload-header">
<div class="header-left">
<a-button type="text" @click="handleBack">
<ArrowLeftOutlined /> 返回
</a-button>
<a-divider type="vertical" />
<span class="header-title">上传</span>
</div>
<a-button type="text" @click="handleClose">
<CloseOutlined />
</a-button>
</div>
<!-- 操作按钮 -->
<div class="upload-actions">
<a-space>
<a-button type="primary" @click="selectFiles">
<UploadOutlined /> 上传文件
</a-button>
<a-button type="primary" @click="selectFolder">
<FolderOpenOutlined /> 上传文件夹
</a-button>
</a-space>
<a-button @click="clearList" :disabled="fileList.length === 0">
清空列表
</a-button>
</div>
<!-- 拖拽上传区域 -->
<div
class="upload-drop-zone"
:class="{ 'is-dragover': isDragover }"
@dragover.prevent="handleDragover"
@dragleave.prevent="handleDragleave"
@drop.prevent="handleDrop"
>
<div class="drop-zone-content">
<CloudUploadOutlined class="drop-icon" />
<p class="drop-text">将需要上传的文件拖曳到此处</p>
</div>
</div>
<!-- 当前上传进度 -->
<div v-if="currentUploadFile" class="current-upload">
<span class="current-file-name">
正在上传{{ currentUploadFile.name }}....
</span>
<a-progress
:percent="currentUploadFile.percent"
:status="currentUploadFile.percent === 100 ? 'success' : 'active'"
:show-info="true"
/>
</div>
<!-- 文件列表 -->
<div class="file-list">
<div
v-for="(file, index) in fileList"
:key="file.uid"
class="file-item"
>
<div class="file-info">
<FileOutlined class="file-icon" />
<span class="file-path">{{ file.path || file.name }}</span>
</div>
<div class="file-status">
<CheckOutlined
v-if="file.status === 'done'"
class="status-done"
/>
<CloseOutlined
v-else-if="file.status === 'error'"
class="status-error"
/>
<span
v-else
class="status-pending"
@click="removeFile(index)"
>×</span>
</div>
</div>
</div>
<!-- 隐藏的文件输入 -->
<input
ref="fileInputRef"
type="file"
multiple
style="display: none;"
@change="handleFileSelect"
/>
<input
ref="folderInputRef"
type="file"
webkitdirectory
directory
multiple
style="display: none;"
@change="handleFolderSelect"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { message, Modal } from 'ant-design-vue'
import {
ArrowLeftOutlined,
CloseOutlined,
UploadOutlined,
FolderOpenOutlined,
CloudUploadOutlined,
FileOutlined,
CheckOutlined
} from '@ant-design/icons-vue'
const router = useRouter()
const route = useRoute()
// 文件输入引用
const fileInputRef = ref<HTMLInputElement | null>(null)
const folderInputRef = ref<HTMLInputElement | null>(null)
// 拖拽状态
const isDragover = ref(false)
// 文件列表
interface UploadFile {
uid: string
name: string
path: string // 相对路径
size: number
type: string
status: 'pending' | 'uploading' | 'done' | 'error'
percent: number
file: File
}
const fileList = ref<UploadFile[]>([])
// 当前正在上传的文件
const currentUploadFile = ref<UploadFile | null>(null)
// 上传队列索引
const uploadIndex = ref(0)
// 是否正在上传
const isUploading = ref(false)
/**
* 返回按钮
*/
function handleBack() {
router.back()
}
/**
* 关闭按钮
*/
function handleClose() {
if (fileList.value.some(f => f.status === 'uploading')) {
Modal.confirm({
title: '确认关闭',
content: '当前有文件正在上传,确定要关闭吗?',
onOk() {
router.push('/platform/projects')
}
})
} else {
router.push('/platform/projects')
}
}
/**
* 选择文件
*/
function selectFiles() {
fileInputRef.value?.click()
}
/**
* 选择文件夹
*/
function selectFolder() {
folderInputRef.value?.click()
}
/**
* 清空列表
*/
function clearList() {
if (isUploading.value) {
message.warning('请等待上传完成后再清空')
return
}
fileList.value = []
uploadIndex.value = 0
currentUploadFile.value = null
}
/**
* 移除单个文件
*/
function removeFile(index: number) {
const file = fileList.value[index]
if (file.status === 'uploading') {
message.warning('该文件正在上传中,无法移除')
return
}
fileList.value.splice(index, 1)
}
/**
* 生成唯一ID
*/
function generateUid(): string {
return `upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
}
/**
* 处理文件选择
*/
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement
if (input.files) {
addFiles(Array.from(input.files))
}
input.value = ''
}
/**
* 处理文件夹选择
*/
function handleFolderSelect(event: Event) {
const input = event.target as HTMLInputElement
if (input.files) {
addFiles(Array.from(input.files))
}
input.value = ''
}
/**
* 添加文件到列表
*/
function addFiles(files: File[]) {
const newFiles: UploadFile[] = files.map(file => ({
uid: generateUid(),
name: file.name,
path: (file as any).webkitRelativePath || file.name,
size: file.size,
type: file.type,
status: 'pending',
percent: 0,
file
}))
fileList.value.push(...newFiles)
// 自动开始上传
if (!isUploading.value) {
startUpload()
}
}
/**
* 拖拽进入
*/
function handleDragover(event: DragEvent) {
isDragover.value = true
}
/**
* 拖拽离开
*/
function handleDragleave(event: DragEvent) {
isDragover.value = false
}
/**
* 拖拽放下
*/
async function handleDrop(event: DragEvent) {
isDragover.value = false
const items = event.dataTransfer?.items
if (!items) return
const files: File[] = []
// 遍历拖拽的项目
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (item.kind === 'file') {
const entry = item.webkitGetAsEntry?.()
if (entry) {
await traverseFileTree(entry, '', files)
} else {
const file = item.getAsFile()
if (file) {
files.push(file)
}
}
}
}
if (files.length > 0) {
addFilesWithPath(files)
}
}
/**
* 递归遍历文件树
*/
async function traverseFileTree(
entry: FileSystemEntry,
path: string,
files: File[]
): Promise<void> {
if (entry.isFile) {
const fileEntry = entry as FileSystemFileEntry
const file = await new Promise<File>((resolve, reject) => {
fileEntry.file(resolve, reject)
})
// 附加路径信息
Object.defineProperty(file, 'relativePath', {
value: path + entry.name,
writable: false
})
files.push(file)
} else if (entry.isDirectory) {
const dirEntry = entry as FileSystemDirectoryEntry
const reader = dirEntry.createReader()
const entries = await new Promise<FileSystemEntry[]>((resolve, reject) => {
reader.readEntries(resolve, reject)
})
for (const childEntry of entries) {
await traverseFileTree(childEntry, path + entry.name + '/', files)
}
}
}
/**
* 添加带路径的文件
*/
function addFilesWithPath(files: File[]) {
const newFiles: UploadFile[] = files.map(file => ({
uid: generateUid(),
name: file.name,
path: (file as any).relativePath || (file as any).webkitRelativePath || file.name,
size: file.size,
type: file.type,
status: 'pending',
percent: 0,
file
}))
fileList.value.push(...newFiles)
// 自动开始上传
if (!isUploading.value) {
startUpload()
}
}
/**
* 开始上传
*/
async function startUpload() {
if (isUploading.value) return
isUploading.value = true
while (uploadIndex.value < fileList.value.length) {
const file = fileList.value[uploadIndex.value]
if (file.status === 'pending') {
await uploadFile(file)
}
uploadIndex.value++
}
isUploading.value = false
currentUploadFile.value = null
// 检查是否所有文件都上传完成
const allDone = fileList.value.every(f => f.status === 'done')
if (allDone && fileList.value.length > 0) {
message.success('所有文件上传完成!')
}
}
/**
* 上传单个文件
*/
async function uploadFile(file: UploadFile): Promise<void> {
file.status = 'uploading'
currentUploadFile.value = file
// 模拟上传过程(后续替换为真实上传逻辑)
return new Promise((resolve) => {
let progress = 0
const interval = setInterval(() => {
progress += Math.random() * 30
if (progress >= 100) {
progress = 100
file.percent = 100
file.status = 'done'
clearInterval(interval)
resolve()
} else {
file.percent = Math.floor(progress)
}
}, 100)
})
// TODO: 真实上传逻辑
// const formData = new FormData()
// formData.append('file', file.file)
// formData.append('path', file.path)
//
// const response = await fetch('YOUR_UPLOAD_API', {
// method: 'POST',
// body: formData,
// onUploadProgress: (progressEvent) => {
// file.percent = Math.round((progressEvent.loaded * 100) / progressEvent.total)
// }
// })
}
</script>
<style scoped>
.project-upload-page {
min-height: 100vh;
background: #fff;
padding: 0;
}
.upload-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.header-title {
font-size: 16px;
font-weight: 500;
color: #333;
}
.upload-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
}
.upload-drop-zone {
margin: 0 16px;
border: 2px dashed #d9d9d9;
border-radius: 8px;
padding: 40px;
text-align: center;
transition: all 0.3s;
cursor: pointer;
background: #fafafa;
}
.upload-drop-zone:hover,
.upload-drop-zone.is-dragover {
border-color: #1890ff;
background: #e6f7ff;
}
.drop-zone-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.drop-icon {
font-size: 48px;
color: #bfbfbf;
}
.upload-drop-zone:hover .drop-icon,
.upload-drop-zone.is-dragover .drop-icon {
color: #1890ff;
}
.drop-text {
font-size: 14px;
color: #8c8c8c;
margin: 0;
}
.current-upload {
margin: 16px;
padding: 12px 16px;
background: #f6ffed;
border: 1px solid #b7eb8f;
border-radius: 4px;
}
.current-file-name {
display: block;
font-size: 13px;
color: #52c41a;
margin-bottom: 8px;
}
.file-list {
margin: 16px;
max-height: calc(100vh - 350px);
overflow-y: auto;
}
.file-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
border-bottom: 1px solid #f5f5f5;
transition: background 0.2s;
}
.file-item:hover {
background: #fafafa;
}
.file-info {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
}
.file-icon {
font-size: 16px;
color: #8c8c8c;
flex-shrink: 0;
}
.file-path {
font-size: 13px;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-status {
flex-shrink: 0;
width: 24px;
text-align: center;
}
.status-done {
font-size: 16px;
color: #52c41a;
}
.status-error {
font-size: 16px;
color: #ff4d4f;
}
.status-pending {
font-size: 18px;
color: #d9d9d9;
cursor: pointer;
user-select: none;
}
.status-pending:hover {
color: #ff4d4f;
}
</style>

View File

@@ -0,0 +1,302 @@
<template>
<div class="city-manage">
<a-page-header title="城市管理" sub-title="管理系统支持的城市及区域数据" />
<a-card :bordered="false" class="main-card">
<!-- 搜索栏 -->
<div class="table-page-search-wrapper">
<a-form layout="inline">
<a-row :gutter="48">
<a-col :md="8" :sm="24">
<a-form-item label="城市名称">
<a-input v-model:value="queryParam.name" placeholder="请输入城市名称" allow-clear />
</a-form-item>
</a-col>
<a-col :md="8" :sm="24">
<a-form-item label="城市编码">
<a-input v-model:value="queryParam.code" placeholder="请输入城市编码" allow-clear />
</a-form-item>
</a-col>
<a-col :md="8" :sm="24">
<a-space>
<a-button type="primary" @click="handleQuery">查询</a-button>
<a-button @click="resetQuery">重置</a-button>
</a-space>
</a-col>
</a-row>
</a-form>
</div>
<!-- 操作栏 -->
<div class="table-operator">
<a-button type="primary" @click="handleAdd">
<PlusOutlined /> 新建
</a-button>
</div>
<!-- 数据列表 -->
<a-table
:columns="columns"
:data-source="dataList"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'level'">
<a-tag :color="getLevelColor(record.level)">
{{ getLevelText(record.level) }}
</a-tag>
</template>
<template v-if="column.key === 'status'">
<a-badge
:status="record.status === 'normal' ? 'success' : 'error'"
:text="record.status === 'normal' ? '正常' : '停用'"
/>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
<a-popconfirm title="确定删除该城市吗?" @confirm="handleDelete(record.id)">
<a-button type="link" size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<!-- 新建/编辑弹窗 -->
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
@ok="handleModalOk"
:confirm-loading="modalLoading"
>
<a-form
:model="formState"
:rules="rules"
ref="formRef"
layout="vertical"
>
<a-form-item label="城市名称" name="name">
<a-input v-model:value="formState.name" placeholder="如:杭州市" />
</a-form-item>
<a-form-item label="城市编码" name="code">
<a-input v-model:value="formState.code" placeholder="如330100" />
</a-form-item>
<a-form-item label="行政级别" name="level">
<a-select v-model:value="formState.level">
<a-select-option value="province">省份/直辖市</a-select-option>
<a-select-option value="city">城市</a-select-option>
<a-select-option value="district">区县</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="显示顺序" name="sort">
<a-input-number v-model:value="formState.sort" :min="0" style="width: 100%" />
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="formState.status">
<a-radio value="normal">正常</a-radio>
<a-radio value="disabled">停用</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="备注" name="remark">
<a-textarea v-model:value="formState.remark" :rows="3" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { PlusOutlined } from '@ant-design/icons-vue'
import type { FormInstance } from 'ant-design-vue'
interface CityItem {
id: number
code: string
name: string
level: 'province' | 'city' | 'district'
sort: number
status: 'normal' | 'disabled'
remark?: string
createdAt?: string
}
// 查询参数
const queryParam = reactive({
name: '',
code: ''
})
const loading = ref(false)
const dataList = ref<CityItem[]>([])
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showTotal: (total: number) => `${total}`
})
const columns = [
{ title: '城市名称', dataIndex: 'name', key: 'name' },
{ title: '城市编码', dataIndex: 'code', key: 'code' },
{ title: '行政级别', dataIndex: 'level', key: 'level' },
{ title: '排序', dataIndex: 'sort', key: 'sort', sorter: true },
{ title: '状态', dataIndex: 'status', key: 'status' },
{ title: '备注', dataIndex: 'remark', key: 'remark', ellipsis: true },
{ title: '创建时间', dataIndex: 'createdAt', key: 'createdAt' },
{ title: '操作', key: 'action', width: 150 }
]
// Modal相关
const modalVisible = ref(false)
const modalLoading = ref(false)
const isEdit = ref(false)
const formRef = ref<FormInstance>()
const formState = reactive<Omit<CityItem, 'id' | 'createdAt'> & { id?: number }>({
name: '',
code: '',
level: 'city',
sort: 0,
status: 'normal',
remark: ''
})
const rules: Record<string, any> = {
name: [{ required: true, message: '请输入城市名称', trigger: 'blur' }],
code: [{ required: true, message: '请输入城市编码', trigger: 'blur' }],
level: [{ required: true, message: '请选择行政级别', trigger: 'change' }],
sort: [{ required: true, message: '请输入显示顺序', trigger: 'change' }]
}
const modalTitle = computed(() => isEdit.value ? '编辑城市' : '新建城市')
// Helper
function getLevelText(level: string) {
const map: Record<string, string> = {
province: '省份/直辖市',
city: '城市',
district: '区县'
}
return map[level] || level
}
function getLevelColor(level: string) {
const map: Record<string, string> = {
province: 'blue',
city: 'green',
district: 'orange'
}
return map[level] || 'default'
}
// Mock Data
const mockCities: CityItem[] = [
{ id: 1, name: '北京市', code: '110000', level: 'province', sort: 1, status: 'normal', remark: '直辖市', createdAt: '2024-01-01' },
{ id: 2, name: '上海市', code: '310000', level: 'province', sort: 2, status: 'normal', remark: '直辖市', createdAt: '2024-01-01' },
{ id: 3, name: '杭州市', code: '330100', level: 'city', sort: 3, status: 'normal', remark: '浙江省省会', createdAt: '2024-01-02' },
{ id: 4, name: '西湖区', code: '330106', level: 'district', sort: 4, status: 'normal', remark: '杭州市辖区', createdAt: '2024-01-03' },
{ id: 5, name: '深圳市', code: '440300', level: 'city', sort: 5, status: 'normal', remark: '广东省辖市', createdAt: '2024-01-02' }
]
// Methods
function loadData() {
loading.value = true
setTimeout(() => {
let list = [...mockCities]
if (queryParam.name) {
list = list.filter(item => item.name.includes(queryParam.name))
}
if (queryParam.code) {
list = list.filter(item => item.code.includes(queryParam.code))
}
dataList.value = list
pagination.total = list.length
loading.value = false
}, 300)
}
function handleQuery() {
pagination.current = 1
loadData()
}
function resetQuery() {
queryParam.name = ''
queryParam.code = ''
handleQuery()
}
function handleTableChange(pag: any) {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
loadData()
}
function handleAdd() {
isEdit.value = false
formState.id = undefined
formState.name = ''
formState.code = ''
formState.level = 'city'
formState.sort = 0
formState.status = 'normal'
formState.remark = ''
modalVisible.value = true
}
function handleEdit(record: any) {
isEdit.value = true
Object.assign(formState, JSON.parse(JSON.stringify(record)))
modalVisible.value = true
}
async function handleDelete(id: number) {
if (id) {
message.success('删除成功')
loadData()
}
}
async function handleModalOk() {
try {
await formRef.value?.validate()
modalLoading.value = true
setTimeout(() => {
message.success(isEdit.value ? '修改成功' : '新建成功')
modalVisible.value = false
modalLoading.value = false
loadData()
}, 500)
} catch (error) {
// validation failed
}
}
onMounted(() => {
loadData()
})
</script>
<style scoped>
.city-manage {
min-height: 100%;
}
.main-card {
margin-top: 16px;
}
.table-page-search-wrapper {
margin-bottom: 16px;
}
.table-operator {
margin-bottom: 16px;
}
</style>

View File

@@ -0,0 +1,498 @@
<template>
<div class="dict-manage">
<div class="dict-types-panel">
<a-card title="字典类型" class="full-height-card" :bordered="false">
<template #extra>
<a-button type="primary" size="small" @click="openTypeModal()">
<PlusOutlined /> 新增
</a-button>
</template>
<div class="search-box">
<a-input-search
v-model:value="typeSearchKeyword"
placeholder="搜索类型名称/编码"
size="small"
@search="handleTypeSearch"
/>
</div>
<a-list
:data-source="filteredTypes"
:loading="loadingTypes"
item-layout="horizontal"
size="small"
class="type-list"
>
<template #renderItem="{ item }">
<a-list-item
class="type-item"
:class="{ active: currentType?.id === item.id }"
@click="handleSelectType(item)"
>
<a-list-item-meta>
<template #title>
<span class="type-name">{{ item.name }}</span>
</template>
<template #description>
<div class="type-code">
<span>{{ item.code }}</span>
<a-tag v-if="!item.status" color="error" size="small">停用</a-tag>
</div>
</template>
</a-list-item-meta>
<template #actions>
<a-dropdown>
<a-button type="text" size="small" @click.stop>
<MoreOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="openTypeModal(item)">编辑</a-menu-item>
<a-menu-item danger @click="handleDeleteType(item)">删除</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
</a-list-item>
</template>
</a-list>
</a-card>
</div>
<div class="dict-data-panel">
<a-card class="full-height-card" :bordered="false">
<template #title>
<div class="data-header" v-if="currentType">
<span>{{ currentType.name }}</span>
<span class="data-header-sub">({{ currentType.code }})</span>
</div>
<div v-else class="data-header">字典数据</div>
</template>
<template #extra>
<a-space v-if="currentType">
<a-button @click="loadDictData(currentType.id)">
<ReloadOutlined />
</a-button>
<a-button type="primary" @click="openDataModal()">
<PlusOutlined /> 新增数据
</a-button>
</a-space>
</template>
<a-empty v-if="!currentType" description="请从左侧选择一个字典类型" class="empty-placeholder" />
<a-table
v-else
:columns="dataColumns"
:data-source="dictDataList"
:loading="loadingData"
row-key="id"
:pagination="false"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'tag'">
<a-tag :color="record.listClass || 'default'">{{ record.label }}</a-tag>
</template>
<template v-else-if="column.key === 'status'">
<a-switch
:checked="record.status === 'normal'"
checked-children="正常"
un-checked-children="停用"
size="small"
@change="(checked) => handleStatusChange(record, !!checked)"
/>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="openDataModal(record)">编辑</a-button>
<a-popconfirm title="确定删除该数据项?" @confirm="handleDeleteData(record.id)">
<a-button type="link" size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
<!-- 类型弹窗 -->
<a-modal
v-model:open="typeModalVisible"
:title="isEditType ? '编辑字典类型' : '新增字典类型'"
@ok="handleTypeSubmit"
>
<a-form :model="typeForm" layout="vertical">
<a-form-item label="类型名称" required>
<a-input v-model:value="typeForm.name" placeholder="如:用户性别" />
</a-form-item>
<a-form-item label="类型编码" required>
<a-input v-model:value="typeForm.code" placeholder="如sys_user_sex" :disabled="isEditType" />
</a-form-item>
<a-form-item label="状态">
<a-radio-group v-model:value="typeForm.status">
<a-radio :value="true">正常</a-radio>
<a-radio :value="false">停用</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="备注">
<a-textarea v-model:value="typeForm.remark" :rows="3" />
</a-form-item>
</a-form>
</a-modal>
<!-- 数据弹窗 -->
<a-modal
v-model:open="dataModalVisible"
:title="isEditData ? '编辑字典数据' : '新增字典数据'"
@ok="handleDataSubmit"
>
<a-form :model="dataForm" layout="vertical">
<a-form-item label="数据标签" required>
<a-input v-model:value="dataForm.label" placeholder="如:男" />
</a-form-item>
<a-form-item label="数据键值" required>
<a-input v-model:value="dataForm.value" placeholder="如1" />
</a-form-item>
<a-form-item label="显示排序" required>
<a-input-number v-model:value="dataForm.sort" :min="0" style="width: 100%" />
</a-form-item>
<a-form-item label="回显样式">
<a-select v-model:value="dataForm.listClass">
<a-select-option value="default">默认(Default)</a-select-option>
<a-select-option value="processing">蓝色(Primary)</a-select-option>
<a-select-option value="success">绿色(Success)</a-select-option>
<a-select-option value="warning">橙色(Warning)</a-select-option>
<a-select-option value="error">红色(Error)</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="状态">
<a-radio-group v-model:value="dataForm.status">
<a-radio value="normal">正常</a-radio>
<a-radio value="disabled">停用</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="备注">
<a-textarea v-model:value="dataForm.remark" :rows="2" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { PlusOutlined, ReloadOutlined, MoreOutlined } from '@ant-design/icons-vue'
interface DictType {
id: number
name: string
code: string
status: boolean
remark?: string
}
interface DictData {
id: number
typeId: number
label: string
value: string
sort: number
status: 'normal' | 'disabled'
listClass?: string
remark?: string
}
const loadingTypes = ref(false)
const typeList = ref<DictType[]>([])
const typeSearchKeyword = ref('')
const currentType = ref<DictType | null>(null)
const loadingData = ref(false)
const dictDataList = ref<DictData[]>([])
// Mock Data Initialization
const mockTypes: DictType[] = [
{ id: 1, name: '用户性别', code: 'sys_user_sex', status: true },
{ id: 2, name: '菜单状态', code: 'sys_show_hide', status: true },
{ id: 3, name: '系统开关', code: 'sys_normal_disable', status: true },
{ id: 4, name: '通知类型', code: 'sys_notice_type', status: true },
{ id: 5, name: '操作类型', code: 'sys_oper_type', status: true },
]
const mockDataStore: Record<number, DictData[]> = {
1: [
{ id: 11, typeId: 1, label: '男', value: '0', sort: 1, status: 'normal', listClass: 'processing' },
{ id: 12, typeId: 1, label: '女', value: '1', sort: 2, status: 'normal', listClass: 'error' },
{ id: 13, typeId: 1, label: '未知', value: '2', sort: 3, status: 'normal', listClass: 'default' },
],
3: [
{ id: 31, typeId: 3, label: '正常', value: '0', sort: 1, status: 'normal', listClass: 'success' },
{ id: 32, typeId: 3, label: '停用', value: '1', sort: 2, status: 'normal', listClass: 'error' },
]
}
// Logic
const filteredTypes = computed(() => {
if (!typeSearchKeyword.value) return typeList.value
const kw = typeSearchKeyword.value.toLowerCase()
return typeList.value.filter(t => t.name.toLowerCase().includes(kw) || t.code.toLowerCase().includes(kw))
})
const dataColumns = [
{ title: '字典标签', key: 'tag', width: '20%' },
{ title: '字典键值', dataIndex: 'value', key: 'value', width: '20%' },
{ title: '排序', dataIndex: 'sort', key: 'sort', sorter: (a: DictData, b: DictData) => a.sort - b.sort, width: '15%' },
{ title: '状态', key: 'status', width: '15%' },
{ title: '备注', dataIndex: 'remark', key: 'remark' },
{ title: '操作', key: 'action', width: '150px' }
]
// Modal Logic - Type
const typeModalVisible = ref(false)
const isEditType = ref(false)
const typeForm = reactive({
id: 0,
name: '',
code: '',
status: true,
remark: ''
})
function openTypeModal(item?: DictType) {
if (item) {
isEditType.value = true
Object.assign(typeForm, item)
} else {
isEditType.value = false
Object.assign(typeForm, { id: 0, name: '', code: '', status: true, remark: '' })
}
typeModalVisible.value = true
}
function handleTypeSubmit() {
if (!typeForm.name || !typeForm.code) {
message.warning('请填写完整信息')
return
}
if (isEditType.value) {
const idx = typeList.value.findIndex(t => t.id === typeForm.id)
if (idx > -1) {
typeList.value[idx] = { ...typeForm }
message.success('修改成功')
}
} else {
const newId = Math.max(...typeList.value.map(t => t.id), 0) + 1
const newType: DictType = { ...typeForm, id: newId }
typeList.value.unshift(newType)
message.success('新增成功')
handleSelectType(newType)
}
typeModalVisible.value = false
}
function handleDeleteType(item: DictType) {
typeList.value = typeList.value.filter(t => t.id !== item.id)
if (currentType.value?.id === item.id) {
currentType.value = null
dictDataList.value = []
}
message.success('删除成功')
}
// Modal Logic - Data
const dataModalVisible = ref(false)
const isEditData = ref(false)
const dataForm = reactive({
id: 0,
label: '',
value: '',
sort: 0,
status: 'normal' as 'normal' | 'disabled',
listClass: 'default',
remark: ''
})
function openDataModal(item?: any) {
if (!currentType.value) {
message.warning('请先选择字典类型')
return
}
if (item) {
isEditData.value = true
Object.assign(dataForm, item)
} else {
isEditData.value = false
Object.assign(dataForm, {
id: 0,
label: '',
value: '',
sort: 0,
status: 'normal',
listClass: 'default',
remark: ''
})
}
dataModalVisible.value = true
}
function handleDataSubmit() {
if (!dataForm.label || !dataForm.value) {
message.warning('请填写完整信息')
return
}
if (isEditData.value) {
const idx = dictDataList.value.findIndex(d => d.id === dataForm.id)
if (idx > -1) {
dictDataList.value[idx] = { ...dataForm, typeId: currentType.value!.id }
message.success('修改成功')
}
} else {
const newId = Date.now()
dictDataList.value.push({
...dataForm,
id: newId,
typeId: currentType.value!.id
})
message.success('新增成功')
}
dataModalVisible.value = false
}
function handleDeleteData(id: number) {
dictDataList.value = dictDataList.value.filter(d => d.id !== id)
message.success('删除成功')
}
function handleStatusChange(record: any, checked: boolean) {
record.status = checked ? 'normal' : 'disabled'
message.success('状态已更新')
}
// Data Loading
function loadTypes() {
loadingTypes.value = true
setTimeout(() => {
typeList.value = [...mockTypes]
loadingTypes.value = false
}, 300)
}
function handleSelectType(type: DictType) {
currentType.value = type
loadDictData(type.id)
}
function loadDictData(typeId: number) {
loadingData.value = true
// In real app, fetch by code or id
setTimeout(() => {
dictDataList.value = mockDataStore[typeId] ? [...mockDataStore[typeId]] : []
loadingData.value = false
}, 300)
}
function handleTypeSearch() {
// auto-handled by computed
}
onMounted(() => {
loadTypes()
})
</script>
<style scoped>
.dict-manage {
height: 100%;
display: flex;
gap: 16px;
background-color: #f0f2f5;
}
.dict-types-panel {
width: 300px;
flex-shrink: 0;
display: flex;
flex-direction: column;
}
.dict-data-panel {
flex: 1;
min-width: 0;
}
.full-height-card {
height: 100%;
border-radius: 8px;
display: flex;
flex-direction: column;
}
:deep(.ant-card-body) {
flex: 1;
overflow: auto;
padding: 12px;
display: flex;
flex-direction: column;
}
.search-box {
padding: 0 4px 12px 4px;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 8px;
}
.type-list {
flex: 1;
overflow-y: auto;
}
.type-item {
padding: 10px 12px;
cursor: pointer;
border-radius: 6px;
transition: all 0.3s;
}
.type-item:hover {
background-color: #f5f5f5;
}
.type-item.active {
background-color: #e6f7ff;
}
.type-name {
font-weight: 500;
color: #333;
}
.type-code {
font-size: 12px;
color: #999;
display: flex;
justify-content: space-between;
align-items: center;
}
.data-header {
display: flex;
align-items: center;
gap: 8px;
}
.data-header-sub {
font-size: 13px;
color: #999;
font-weight: normal;
}
.empty-placeholder {
margin-top: 100px;
}
</style>

View File

@@ -0,0 +1,82 @@
<template>
<div class="settings-page">
<a-page-header title="系统设置" sub-title="配置系统全局参数" />
<div class="settings-container">
<a-card title="签到奖励设置" class="setting-card">
<a-form :model="signInConfig" layout="vertical">
<a-form-item label="每日签到奖励 (Code币)" help="用户每日签到固定获取的奖励数量">
<a-input-number v-model:value="signInConfig.dailyReward" :min="1" :max="1000" style="width: 200px">
<template #addonAfter></template>
</a-input-number>
</a-form-item>
<a-form-item label="补签消耗 (Code币)" help="用户补签因错过签到日期的消耗0表示不可补签">
<a-input-number v-model:value="signInConfig.makeupCost" :min="0" :max="1000" style="width: 200px">
<template #addonAfter></template>
</a-input-number>
</a-form-item>
<a-form-item label="允许补签天数" help="允许补签过去多少天内的记录">
<a-input-number v-model:value="signInConfig.makeupDays" :min="0" :max="30" style="width: 200px">
<template #addonAfter></template>
</a-input-number>
</a-form-item>
<a-form-item>
<a-button type="primary" :loading="saving" @click="handleSaveSignInConfig">
保存配置
</a-button>
</a-form-item>
</a-form>
</a-card>
<!-- 可以在此添加其他设置卡片 -->
<a-card title="其他设置" class="setting-card">
<a-empty description="暂无其他设置项" image="simple" />
</a-card>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { message } from 'ant-design-vue'
const saving = ref(false)
const signInConfig = reactive({
dailyReward: 10,
makeupCost: 5,
makeupDays: 7
})
async function handleSaveSignInConfig() {
saving.value = true
// 模拟保存接口调用
await new Promise(resolve => setTimeout(resolve, 800))
saving.value = false
message.success('签到配置已保存')
}
</script>
<style scoped>
.settings-page {
height: 100%;
display: flex;
flex-direction: column;
}
.settings-container {
padding: 24px;
display: flex;
flex-direction: column;
gap: 24px;
max-width: 800px;
}
.setting-card {
border-radius: 8px;
}
</style>

View File

@@ -0,0 +1,520 @@
<template>
<div class="approval-page">
<a-page-header title="审批流程管理" sub-title="配置和管理系统审批流程模板">
<template #extra>
<a-button type="primary" @click="showCreateDrawer">
<PlusOutlined /> 新建流程
</a-button>
</template>
</a-page-header>
<!-- 统计卡片 -->
<a-row :gutter="16" class="stats-row">
<a-col :span="4">
<div class="stat-card stat-1">
<div class="stat-icon"><ApartmentOutlined /></div>
<div class="stat-content">
<div class="stat-value">{{ stats.totalTemplates }}</div>
<div class="stat-title">流程模板</div>
</div>
</div>
</a-col>
<a-col :span="4">
<div class="stat-card stat-2">
<div class="stat-icon"><CheckCircleOutlined /></div>
<div class="stat-content">
<div class="stat-value">{{ stats.enabledTemplates }}</div>
<div class="stat-title">已启用</div>
</div>
</div>
</a-col>
<a-col :span="4">
<div class="stat-card stat-3">
<div class="stat-icon"><FileTextOutlined /></div>
<div class="stat-content">
<div class="stat-value">{{ stats.totalInstances }}</div>
<div class="stat-title">审批实例</div>
</div>
</div>
</a-col>
<a-col :span="4">
<div class="stat-card stat-4">
<div class="stat-icon"><ClockCircleOutlined /></div>
<div class="stat-content">
<div class="stat-value">{{ stats.inProgressInstances }}</div>
<div class="stat-title">审批中</div>
</div>
</div>
</a-col>
<a-col :span="4">
<div class="stat-card stat-5">
<div class="stat-icon"><LikeOutlined /></div>
<div class="stat-content">
<div class="stat-value">{{ stats.approvedInstances }}</div>
<div class="stat-title">已通过</div>
</div>
</div>
</a-col>
<a-col :span="4">
<div class="stat-card stat-6">
<div class="stat-icon"><DislikeOutlined /></div>
<div class="stat-content">
<div class="stat-value">{{ stats.rejectedInstances }}</div>
<div class="stat-title">已拒绝</div>
</div>
</div>
</a-col>
</a-row>
<!-- 流程模板列表 -->
<a-card class="main-card" title="流程模板" :loading="loading">
<template #extra>
<a-space>
<a-select
v-model:value="filterScenario"
placeholder="适用场景"
allow-clear
style="width: 140px"
@change="loadTemplates"
>
<a-select-option v-for="(label, key) in ApprovalScenarioMap" :key="key" :value="key">
{{ label }}
</a-select-option>
</a-select>
</a-space>
</template>
<a-table
:columns="columns"
:data-source="templateList"
:pagination="false"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<div class="template-name">
<ApartmentOutlined class="template-icon" />
<div>
<div class="name">{{ record.name }}</div>
<div class="desc">{{ record.description }}</div>
</div>
</div>
</template>
<template v-else-if="column.key === 'scenario'">
<a-tag :color="getScenarioColor(record.scenario)">
{{ ApprovalScenarioMap[record.scenario as ApprovalScenario] }}
</a-tag>
</template>
<template v-else-if="column.key === 'nodes'">
<div class="nodes-preview">
<a-tooltip v-for="(node, index) in record.nodes" :key="node.id" :title="node.name">
<div class="node-item">
<span class="node-num">{{ (index as number) + 1 }}</span>
<span class="node-name">{{ node.name }}</span>
<RightOutlined v-if="(index as number) < record.nodes.length - 1" class="node-arrow" />
</div>
</a-tooltip>
</div>
</template>
<template v-else-if="column.key === 'enabled'">
<a-switch
:checked="record.enabled"
checked-children="启用"
un-checked-children="禁用"
@change="handleToggle(record.id)"
/>
</template>
<template v-else-if="column.key === 'updatedAt'">
{{ formatDateTime(record.updatedAt) }}
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="showEditDrawer(record as ApprovalTemplate)">
<EditOutlined /> 编辑
</a-button>
<a-popconfirm
title="确定删除此流程模板?"
@confirm="handleDelete(record.id)"
>
<a-button type="link" size="small" danger>
<DeleteOutlined /> 删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<!-- 流程设计抽屉 -->
<a-drawer
v-model:open="drawerVisible"
:title="editingTemplate ? '编辑流程' : '新建流程'"
width="100%"
:body-style="{ padding: 0 }"
:header-style="{ padding: '12px 24px' }"
placement="right"
destroyOnClose
>
<template #extra>
<a-space>
<a-button @click="drawerVisible = false">取消</a-button>
<a-button type="primary" :loading="submitting" @click="handleSubmit">
保存流程
</a-button>
</a-space>
</template>
<div class="drawer-content">
<!-- 基本信息 -->
<div class="form-section">
<a-form ref="formRef" :model="formData" layout="inline" class="basic-form">
<a-form-item label="流程名称" name="name" :rules="[{ required: true, message: '请输入' }]">
<a-input v-model:value="formData.name" placeholder="请输入流程名称" style="width: 200px" />
</a-form-item>
<a-form-item label="适用场景" name="scenario" :rules="[{ required: true, message: '请选择' }]">
<a-select v-model:value="formData.scenario" placeholder="请选择" style="width: 140px">
<a-select-option v-for="(label, key) in ApprovalScenarioMap" :key="key" :value="key">
{{ label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="流程描述">
<a-input v-model:value="formData.description" placeholder="请输入描述" style="width: 300px" />
</a-form-item>
<a-form-item label="启用状态">
<a-switch v-model:checked="formData.enabled" checked-children="启用" un-checked-children="禁用" />
</a-form-item>
</a-form>
</div>
<!-- 可视化流程设计器 -->
<div class="flow-section">
<FlowEditor ref="flowEditorRef" v-model="formData.nodes" />
</div>
</div>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import type { FormInstance } from 'ant-design-vue'
import {
PlusOutlined,
ApartmentOutlined,
CheckCircleOutlined,
FileTextOutlined,
ClockCircleOutlined,
LikeOutlined,
DislikeOutlined,
RightOutlined,
EditOutlined,
DeleteOutlined
} from '@ant-design/icons-vue'
import type { ApprovalTemplate, ApprovalScenario, ApprovalNode } from '@/types'
import { ApprovalScenarioMap } from '@/types'
import {
mockGetApprovalStats,
mockGetApprovalTemplateList,
mockToggleApprovalTemplate,
mockDeleteApprovalTemplate
} from '@/mock'
import { formatDateTime } from '@/utils/common'
import FlowEditor from '@/components/FlowEditor/index.vue'
const loading = ref(false)
const submitting = ref(false)
const templateList = ref<ApprovalTemplate[]>([])
const filterScenario = ref<ApprovalScenario | undefined>()
const flowEditorRef = ref<InstanceType<typeof FlowEditor>>()
const stats = reactive({
totalTemplates: 0,
enabledTemplates: 0,
totalInstances: 0,
pendingInstances: 0,
inProgressInstances: 0,
approvedInstances: 0,
rejectedInstances: 0
})
const drawerVisible = ref(false)
const editingTemplate = ref<ApprovalTemplate | null>(null)
const formRef = ref<FormInstance>()
const formData = reactive<{
name: string
scenario: ApprovalScenario | undefined
description: string
nodes: ApprovalNode[]
enabled: boolean
}>({
name: '',
scenario: undefined,
description: '',
nodes: [],
enabled: true
})
const columns = [
{ title: '流程名称', key: 'name', width: 280 },
{ title: '适用场景', key: 'scenario', width: 120 },
{ title: '审批节点', key: 'nodes', width: 350 },
{ title: '状态', key: 'enabled', width: 100 },
{ title: '更新时间', key: 'updatedAt', width: 160 },
{ title: '操作', key: 'action', width: 140 }
]
function getScenarioColor(scenario: ApprovalScenario): string {
const colors: Record<ApprovalScenario, string> = {
project_publish: '#1890ff',
withdrawal: '#52c41a',
contract: '#722ed1',
certification: '#faad14',
content: '#13c2c2',
expense_reimbursement: '#eb2f96',
payment_request: '#fa541c',
purchase_request: '#fa8c16',
budget_adjustment: '#2f54eb',
invoice_apply: '#722ed1'
}
return colors[scenario]
}
async function loadStats() {
try {
const data = await mockGetApprovalStats()
Object.assign(stats, data)
} catch (error) {
console.error(error)
}
}
async function loadTemplates() {
loading.value = true
try {
const res = await mockGetApprovalTemplateList({
scenario: filterScenario.value
})
templateList.value = res.list
} catch (error) {
console.error(error)
message.error('加载数据失败')
} finally {
loading.value = false
}
}
function showCreateDrawer() {
editingTemplate.value = null
Object.assign(formData, {
name: '',
scenario: undefined,
description: '',
nodes: [],
enabled: true
})
drawerVisible.value = true
}
function showEditDrawer(template: ApprovalTemplate) {
editingTemplate.value = template
Object.assign(formData, {
name: template.name,
scenario: template.scenario,
description: template.description || '',
nodes: template.nodes.map(n => ({ ...n })),
enabled: template.enabled
})
drawerVisible.value = true
}
async function handleSubmit() {
try {
await formRef.value?.validate()
submitting.value = true
// 从流程编辑器获取节点数据
if (flowEditorRef.value) {
formData.nodes = flowEditorRef.value.toApprovalNodes()
}
// 这里应该调用创建/更新API
message.success(editingTemplate.value ? '更新成功' : '创建成功')
drawerVisible.value = false
loadTemplates()
} catch {
// 验证失败
} finally {
submitting.value = false
}
}
async function handleToggle(id: number) {
try {
await mockToggleApprovalTemplate(id)
message.success('状态已更新')
loadTemplates()
loadStats()
} catch (error) {
console.error(error)
message.error('操作失败')
}
}
async function handleDelete(id: number) {
try {
await mockDeleteApprovalTemplate(id)
message.success('删除成功')
loadTemplates()
loadStats()
} catch (error) {
console.error(error)
message.error('删除失败')
}
}
onMounted(() => {
loadStats()
loadTemplates()
})
</script>
<style scoped>
.approval-page {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
overflow-y: auto;
padding-right: 8px;
}
.stats-row { margin-bottom: 0; }
.stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: 12px;
color: #fff;
transition: all 0.3s ease;
}
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); }
.stat-1 { background: linear-gradient(135deg, #667eea, #764ba2); }
.stat-2 { background: linear-gradient(135deg, #11998e, #38ef7d); }
.stat-3 { background: linear-gradient(135deg, #4facfe, #00f2fe); }
.stat-4 { background: linear-gradient(135deg, #fa709a, #fee140); }
.stat-5 { background: linear-gradient(135deg, #a8edea, #fed6e3); color: #333; }
.stat-6 { background: linear-gradient(135deg, #ff416c, #ff4b2b); }
.stat-icon { font-size: 28px; opacity: 0.9; }
.stat-content { flex: 1; }
.stat-value { font-size: 22px; font-weight: 700; line-height: 1.2; }
.stat-title { font-size: 13px; opacity: 0.85; margin-top: 2px; }
.main-card { border-radius: 12px; }
.template-name {
display: flex;
align-items: flex-start;
gap: 12px;
}
.template-icon {
font-size: 24px;
color: #1890ff;
margin-top: 4px;
}
.template-name .name {
font-weight: 600;
color: #1a1a2e;
font-size: 14px;
}
.template-name .desc {
font-size: 12px;
color: #8c8c8c;
margin-top: 2px;
}
.nodes-preview {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.node-item {
display: inline-flex;
align-items: center;
gap: 4px;
}
.node-num {
width: 18px;
height: 18px;
border-radius: 50%;
background: #1890ff;
color: #fff;
font-size: 11px;
display: flex;
align-items: center;
justify-content: center;
}
.node-name {
font-size: 12px;
color: #333;
}
.node-arrow {
font-size: 10px;
color: #bfbfbf;
margin: 0 2px;
}
/* 抽屉内容 */
.drawer-content {
display: flex;
flex-direction: column;
height: 100%;
}
.form-section {
padding: 16px 24px;
background: #fafafa;
border-bottom: 1px solid #e8e8e8;
}
.basic-form {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.basic-form :deep(.ant-form-item) {
margin-bottom: 0;
}
.flow-section {
flex: 1;
min-height: 0;
padding: 16px;
}
.flow-section :deep(.flow-editor) {
height: 100%;
}
</style>

View File

@@ -0,0 +1,413 @@
<template>
<div class="approval-instances-page">
<a-page-header title="审批记录" sub-title="查看所有审批实例及其处理状态" />
<!-- 搜索筛选 -->
<a-card class="filter-card">
<a-form layout="inline" class="search-form">
<a-form-item>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索业务标题 / 发起人"
allow-clear
style="width: 220px"
@search="handleSearch"
/>
</a-form-item>
<a-form-item>
<a-select
v-model:value="filterScenario"
placeholder="业务场景"
allow-clear
style="width: 130px"
@change="handleSearch"
>
<a-select-option v-for="(label, key) in ApprovalScenarioMap" :key="key" :value="key">
{{ label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-select
v-model:value="filterStatus"
placeholder="审批状态"
allow-clear
style="width: 120px"
@change="handleSearch"
>
<a-select-option v-for="(label, key) in ApprovalInstanceStatusMap" :key="key" :value="key">
{{ label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-range-picker
v-model:value="dateRange"
:placeholder="['开始日期', '结束日期']"
style="width: 240px"
@change="handleSearch"
/>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="handleSearch">
<SearchOutlined /> 查询
</a-button>
</a-form-item>
<a-form-item>
<a-button @click="handleReset">
<ReloadOutlined /> 重置
</a-button>
</a-form-item>
</a-form>
</a-card>
<!-- 数据列表 -->
<a-card class="main-card" :loading="loading">
<a-table
:columns="columns"
:data-source="instanceList"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'business'">
<div class="business-cell">
<a-tag :color="getScenarioColor(record.scenario)" size="small">
{{ ApprovalScenarioMap[record.scenario as ApprovalScenario] }}
</a-tag>
<span class="business-title">{{ record.businessTitle }}</span>
</div>
</template>
<template v-else-if="column.key === 'initiator'">
<div class="user-cell">
<a-avatar :src="record.initiatorAvatar" :size="28" />
<span>{{ record.initiatorName }}</span>
</div>
</template>
<template v-else-if="column.key === 'currentNode'">
<span v-if="record.currentNodeName" class="current-node">
<ClockCircleOutlined /> {{ record.currentNodeName }}
</span>
<span v-else class="no-node">-</span>
</template>
<template v-else-if="column.key === 'status'">
<a-badge
:status="ApprovalInstanceStatusBadgeMap[record.status as ApprovalInstanceStatus]"
:text="ApprovalInstanceStatusMap[record.status as ApprovalInstanceStatus]"
/>
</template>
<template v-else-if="column.key === 'submittedAt'">
{{ formatDateTime(record.submittedAt) }}
</template>
<template v-else-if="column.key === 'action'">
<a-button type="link" size="small" @click="showDetail(record as ApprovalInstance)">
<EyeOutlined /> 详情
</a-button>
</template>
</template>
</a-table>
</a-card>
<!-- 详情抽屉 -->
<a-drawer
v-model:open="detailVisible"
title="审批详情"
width="640"
destroy-on-close
>
<template v-if="currentInstance">
<!-- 业务信息 -->
<a-descriptions :column="2" bordered size="small" title="业务信息">
<a-descriptions-item label="业务类型">
<a-tag :color="getScenarioColor(currentInstance.scenario)">
{{ ApprovalScenarioMap[currentInstance.scenario] }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="审批状态">
<a-badge
:status="ApprovalInstanceStatusBadgeMap[currentInstance.status]"
:text="ApprovalInstanceStatusMap[currentInstance.status]"
/>
</a-descriptions-item>
<a-descriptions-item label="业务标题" :span="2">
{{ currentInstance.businessTitle }}
</a-descriptions-item>
<a-descriptions-item label="使用流程">
{{ currentInstance.templateName }}
</a-descriptions-item>
<a-descriptions-item label="当前节点">
{{ currentInstance.currentNodeName || '-' }}
</a-descriptions-item>
</a-descriptions>
<!-- 发起人 -->
<a-divider>发起人</a-divider>
<div class="initiator-info">
<a-avatar :src="currentInstance.initiatorAvatar" :size="48" />
<div class="info">
<div class="name">{{ currentInstance.initiatorName }}</div>
<div class="time">提交于 {{ formatDateTime(currentInstance.submittedAt) }}</div>
</div>
</div>
<!-- 审批记录 -->
<a-divider>审批记录</a-divider>
<a-timeline v-if="currentInstance.records.length">
<a-timeline-item
v-for="(record, index) in currentInstance.records"
:key="index"
:color="getActionColor(record.action)"
>
<div class="record-item">
<div class="record-header">
<a-avatar :src="record.approverAvatar" :size="32" />
<div class="record-info">
<div class="approver">
{{ record.approverName }}
<a-tag :color="getActionColor(record.action)" size="small">
{{ getActionText(record.action) }}
</a-tag>
</div>
<div class="node-name">{{ record.nodeName }}</div>
</div>
<div class="record-time">{{ formatDateTime(record.operatedAt) }}</div>
</div>
<div v-if="record.comment" class="record-comment">
<MessageOutlined /> {{ record.comment }}
</div>
</div>
</a-timeline-item>
</a-timeline>
<a-empty v-else description="暂无审批记录" :image-style="{ height: '40px' }" />
</template>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import type { Dayjs } from 'dayjs'
import {
SearchOutlined,
ReloadOutlined,
EyeOutlined,
ClockCircleOutlined,
MessageOutlined
} from '@ant-design/icons-vue'
import type { ApprovalInstance, ApprovalScenario, ApprovalInstanceStatus } from '@/types'
import { ApprovalScenarioMap, ApprovalInstanceStatusMap, ApprovalInstanceStatusBadgeMap } from '@/types'
import { mockGetApprovalInstanceList } from '@/mock'
import { formatDateTime } from '@/utils/common'
const loading = ref(false)
const instanceList = ref<ApprovalInstance[]>([])
const searchKeyword = ref('')
const filterScenario = ref<ApprovalScenario | undefined>()
const filterStatus = ref<ApprovalInstanceStatus | undefined>()
const dateRange = ref<[Dayjs, Dayjs] | undefined>()
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total: number) => `${total}`
})
const detailVisible = ref(false)
const currentInstance = ref<ApprovalInstance | null>(null)
const columns = [
{ title: '业务信息', key: 'business', width: 280 },
{ title: '发起人', key: 'initiator', width: 140 },
{ title: '当前节点', key: 'currentNode', width: 130 },
{ title: '状态', key: 'status', width: 100 },
{ title: '提交时间', key: 'submittedAt', width: 160 },
{ title: '操作', key: 'action', width: 100 }
]
function getScenarioColor(scenario: ApprovalScenario): string {
const colors: Record<ApprovalScenario, string> = {
project_publish: '#1890ff',
withdrawal: '#52c41a',
contract: '#722ed1',
certification: '#faad14',
content: '#13c2c2',
expense_reimbursement: '#eb2f96',
payment_request: '#fa541c',
purchase_request: '#fa8c16',
budget_adjustment: '#2f54eb',
invoice_apply: '#722ed1'
}
return colors[scenario]
}
function getActionColor(action: string): string {
const colors: Record<string, string> = {
approve: 'green',
reject: 'red',
transfer: 'blue',
return: 'orange'
}
return colors[action] || 'gray'
}
function getActionText(action: string): string {
const texts: Record<string, string> = {
approve: '通过',
reject: '拒绝',
transfer: '转交',
return: '退回'
}
return texts[action] || action
}
async function loadData() {
loading.value = true
try {
const res = await mockGetApprovalInstanceList({
page: pagination.current,
pageSize: pagination.pageSize,
keyword: searchKeyword.value || undefined,
scenario: filterScenario.value,
status: filterStatus.value,
startDate: dateRange.value?.[0]?.format('YYYY-MM-DD'),
endDate: dateRange.value?.[1]?.format('YYYY-MM-DD')
})
instanceList.value = res.list
pagination.total = res.total
} catch (error) {
console.error(error)
message.error('加载数据失败')
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.current = 1
loadData()
}
function handleReset() {
searchKeyword.value = ''
filterScenario.value = undefined
filterStatus.value = undefined
dateRange.value = undefined
pagination.current = 1
loadData()
}
function handleTableChange(pag: { current?: number; pageSize?: number }) {
pagination.current = pag.current || 1
pagination.pageSize = pag.pageSize || 10
loadData()
}
function showDetail(instance: ApprovalInstance) {
currentInstance.value = instance
detailVisible.value = true
}
onMounted(() => {
loadData()
})
</script>
<style scoped>
.approval-instances-page {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
overflow-y: auto;
padding-right: 8px;
}
.filter-card, .main-card { border-radius: 12px; }
.filter-card :deep(.ant-card-body) { padding: 16px; }
.search-form { display: flex; flex-wrap: wrap; gap: 12px; }
.search-form :deep(.ant-form-item) { margin-bottom: 0; margin-right: 0; }
.business-cell {
display: flex;
align-items: center;
gap: 8px;
}
.business-title {
font-weight: 500;
color: #1a1a2e;
}
.user-cell {
display: flex;
align-items: center;
gap: 8px;
}
.current-node {
color: #faad14;
font-size: 13px;
}
.no-node {
color: #8c8c8c;
}
/* 详情样式 */
.initiator-info {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: #fafafa;
border-radius: 8px;
}
.initiator-info .info { flex: 1; }
.initiator-info .name { font-size: 15px; font-weight: 600; }
.initiator-info .time { font-size: 12px; color: #8c8c8c; margin-top: 2px; }
.record-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.record-header {
display: flex;
align-items: flex-start;
gap: 12px;
}
.record-info { flex: 1; }
.record-info .approver {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
}
.record-info .node-name { font-size: 12px; color: #8c8c8c; margin-top: 2px; }
.record-time { font-size: 12px; color: #8c8c8c; }
.record-comment {
padding: 8px 12px;
background: #f5f5f5;
border-radius: 6px;
font-size: 13px;
color: #666;
margin-left: 44px;
}
</style>

21
tsconfig.app.json Normal file
View File

@@ -0,0 +1,21 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"skipLibCheck": true,
/* Linting */
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

96
vite.config.ts Normal file
View File

@@ -0,0 +1,96 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'
import Icons from 'unplugin-icons/vite'
import IconsResolver from 'unplugin-icons/resolver'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
// Vue函数自动导入
AutoImport({
imports: [
'vue',
'vue-router',
'pinia',
{
'axios': [
['default', 'axios']
]
}
],
dts: 'src/auto-imports.d.ts',
eslintrc: {
enabled: false
}
}),
// 组件自动导入
Components({
resolvers: [
// Ant Design Vue组件自动导入
AntDesignVueResolver({
importStyle: false
}),
// Ant Design Vue图标自动导入
IconsResolver({
prefix: 'icon',
enabledCollections: ['ant-design']
})
],
dts: 'src/components.d.ts'
}),
// 图标插件
Icons({
autoInstall: true,
compiler: 'vue3'
})
],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
},
// 开发服务器配置
server: {
port: 5173,
proxy: {
// 主服务器代理
'/1panel-api/server1': {
target: 'http://47.109.57.58:42588',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/1panel-api\/server1/, '/api/v2'),
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq, req) => {
console.log(`[1Panel Proxy - 主服务器] ${req.method} ${req.url}`)
})
}
},
// 测试服务器代理
'/1panel-api/server2': {
target: 'http://192.168.1.100:42588',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/1panel-api\/server2/, '/api/v2'),
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq, req) => {
console.log(`[1Panel Proxy - 测试服务器] ${req.method} ${req.url}`)
})
}
},
// 备份服务器代理
'/1panel-api/server3': {
target: 'http://10.0.0.1:42588',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/1panel-api\/server3/, '/api/v2'),
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq, req) => {
console.log(`[1Panel Proxy - 备份服务器] ${req.method} ${req.url}`)
})
}
}
}
}
})