first commit
This commit is contained in:
6
.env
Normal file
6
.env
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# 基础配置
|
||||||
|
VITE_APP_TITLE=楠溪屿后台管理系统
|
||||||
|
|
||||||
|
# API基础URL
|
||||||
|
VITE_API_BASE_URL=/api
|
||||||
|
|
||||||
6
.env.development
Normal file
6
.env.development
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# 开发环境配置
|
||||||
|
VITE_APP_TITLE=楠溪屿后台管理系统(开发环境)
|
||||||
|
|
||||||
|
# API基础URL - 开发环境
|
||||||
|
VITE_API_BASE_URL=http://localhost:8080/api
|
||||||
|
|
||||||
6
.env.production
Normal file
6
.env.production
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# 生产环境配置
|
||||||
|
VITE_APP_TITLE=楠溪屿后台管理系统
|
||||||
|
|
||||||
|
# API基础URL - 生产环境
|
||||||
|
VITE_API_BASE_URL=/api
|
||||||
|
|
||||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
5
README.md
Normal file
5
README.md
Normal 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
13
index.html
Normal 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
2964
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
package.json
Normal file
39
package.json
Normal 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
1
public/vite.svg
Normal 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
17
src/App.vue
Normal 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
451
src/api/1panel.ts
Normal 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
6
src/api/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* API 模块导出
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 1Panel API
|
||||||
|
export * from './1panel'
|
||||||
1
src/assets/vue.svg
Normal file
1
src/assets/vue.svg
Normal 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
91
src/auto-imports.d.ts
vendored
Normal 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
87
src/components.d.ts
vendored
Normal 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']
|
||||||
|
}
|
||||||
|
}
|
||||||
336
src/components/ApprovalDrawer/index.vue
Normal file
336
src/components/ApprovalDrawer/index.vue
Normal 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>
|
||||||
133
src/components/DuplicateFileModal.vue
Normal file
133
src/components/DuplicateFileModal.vue
Normal 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>
|
||||||
85
src/components/DynamicMenu/index.vue
Normal file
85
src/components/DynamicMenu/index.vue
Normal 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>
|
||||||
694
src/components/FlowEditor/index.vue
Normal file
694
src/components/FlowEditor/index.vue
Normal 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>
|
||||||
41
src/components/HelloWorld.vue
Normal file
41
src/components/HelloWorld.vue
Normal 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>
|
||||||
221
src/components/ProjectUpload.vue
Normal file
221
src/components/ProjectUpload.vue
Normal 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>
|
||||||
612
src/components/UploadCore.vue
Normal file
612
src/components/UploadCore.vue
Normal 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
4
src/config/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* 配置模块统一导出
|
||||||
|
*/
|
||||||
|
export * from './project'
|
||||||
274
src/config/project.ts
Normal file
274
src/config/project.ts
Normal 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
544
src/layouts/MainLayout.vue
Normal 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
15
src/main.ts
Normal 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
458
src/mock/approval.ts
Normal 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
1071
src/mock/finance.ts
Normal file
File diff suppressed because it is too large
Load Diff
7
src/mock/index.ts
Normal file
7
src/mock/index.ts
Normal 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
204
src/mock/projects.ts
Normal 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
144
src/mock/user.ts
Normal 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
256
src/router/index.ts
Normal 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
12
src/stores/index.ts
Normal 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
292
src/stores/project.ts
Normal 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
117
src/stores/user.ts
Normal 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
48
src/style.css
Normal 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
199
src/types/approval.ts
Normal 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
946
src/types/finance.ts
Normal 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
9
src/types/index.ts
Normal 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
33
src/types/response.ts
Normal 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
28
src/types/upload.ts
Normal 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
39
src/types/user.ts
Normal 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
77
src/utils/common.ts
Normal 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
112
src/utils/request.ts
Normal 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
|
||||||
|
|
||||||
134
src/views/app/IframePage.vue
Normal file
134
src/views/app/IframePage.vue
Normal 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
33
src/views/error/404.vue
Normal 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>
|
||||||
|
|
||||||
671
src/views/finance/accounts/index.vue
Normal file
671
src/views/finance/accounts/index.vue
Normal 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>
|
||||||
898
src/views/finance/advanced-reports/index.vue
Normal file
898
src/views/finance/advanced-reports/index.vue
Normal 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>
|
||||||
711
src/views/finance/budget/index.vue
Normal file
711
src/views/finance/budget/index.vue
Normal 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>
|
||||||
652
src/views/finance/expense/index.vue
Normal file
652
src/views/finance/expense/index.vue
Normal 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>
|
||||||
608
src/views/finance/import/index.vue
Normal file
608
src/views/finance/import/index.vue
Normal 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>
|
||||||
620
src/views/finance/income/index.vue
Normal file
620
src/views/finance/income/index.vue
Normal 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>
|
||||||
147
src/views/finance/invoice/index.vue
Normal file
147
src/views/finance/invoice/index.vue
Normal 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>
|
||||||
765
src/views/finance/overview/index.vue
Normal file
765
src/views/finance/overview/index.vue
Normal 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>
|
||||||
800
src/views/finance/reimbursement/index.vue
Normal file
800
src/views/finance/reimbursement/index.vue
Normal 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>
|
||||||
776
src/views/finance/reports/index.vue
Normal file
776
src/views/finance/reports/index.vue
Normal 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>
|
||||||
354
src/views/finance/settlement/index.vue
Normal file
354
src/views/finance/settlement/index.vue
Normal 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
428
src/views/login/index.vue
Normal 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>
|
||||||
|
|
||||||
1005
src/views/platform/certificates/index.vue
Normal file
1005
src/views/platform/certificates/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
518
src/views/platform/menus/index.vue
Normal file
518
src/views/platform/menus/index.vue
Normal 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>
|
||||||
885
src/views/platform/projects/index.vue
Normal file
885
src/views/platform/projects/index.vue
Normal 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>
|
||||||
592
src/views/platform/upload/index.vue
Normal file
592
src/views/platform/upload/index.vue
Normal 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>
|
||||||
302
src/views/settings/city/index.vue
Normal file
302
src/views/settings/city/index.vue
Normal 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>
|
||||||
498
src/views/settings/dict/index.vue
Normal file
498
src/views/settings/dict/index.vue
Normal 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>
|
||||||
82
src/views/settings/index.vue
Normal file
82
src/views/settings/index.vue
Normal 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>
|
||||||
|
|
||||||
520
src/views/system/approval/index.vue
Normal file
520
src/views/system/approval/index.vue
Normal 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>
|
||||||
413
src/views/system/approval/instances.vue
Normal file
413
src/views/system/approval/instances.vue
Normal 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
21
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal 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
96
vite.config.ts
Normal 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}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user