fix: 修复登录功能 - API响应嵌套结构、路由跳转、CORS配置
关键修复:
- stores/user.ts: API响应是{code,message,data{}}结构,使用res.data.data获取token
- Login.vue: 使用window.location.href替代router.push进行跳转
- router/index.ts: 修复路由守卫isLoggedIn检查逻辑
- utils/request.ts: baseURL改为绝对路径http://localhost:8080/api
- 新增dev-server.mjs: 带API代理的开发服务器(兼容ClashX)
- package.json: 添加dev:simple脚本
- 新增组件库: TableCard、Pagination、PermissionTree等
- 新增页面: Audit、UserDetail
已知问题修复:
- Vite dev server与ClashX代理冲突问题
Co-authored-by: Trae AI
This commit is contained in:
parent
eb399474f4
commit
1bcc0facd2
|
|
@ -0,0 +1,95 @@
|
|||
import http from 'http';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DIST_DIR = path.join(__dirname, 'dist');
|
||||
const PORT = 5175;
|
||||
const API_TARGET = 'http://localhost:8080';
|
||||
|
||||
const MIME_TYPES = {
|
||||
'.html': 'text/html',
|
||||
'.js': 'application/javascript',
|
||||
'.css': 'text/css',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
};
|
||||
|
||||
function proxyRequest(req, res) {
|
||||
const url = API_TARGET + req.url;
|
||||
console.log(`[Proxy] ${req.method} ${req.url} -> ${url}`);
|
||||
|
||||
const options = {
|
||||
hostname: 'localhost',
|
||||
port: 8080,
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers: req.headers,
|
||||
};
|
||||
|
||||
const proxyReq = http.request(options, (proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||
proxyRes.pipe(res);
|
||||
});
|
||||
|
||||
proxyReq.on('error', (err) => {
|
||||
console.error(`[Proxy Error] ${err.message}`);
|
||||
res.writeHead(502, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Proxy error', message: err.message }));
|
||||
});
|
||||
|
||||
req.pipe(proxyReq);
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
console.log(`${new Date().toISOString()} ${req.method} ${req.url}`);
|
||||
|
||||
// CORS headers
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Proxy API requests
|
||||
if (req.url.startsWith('/api')) {
|
||||
proxyRequest(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
let filePath = path.join(DIST_DIR, req.url === '/' ? 'index.html' : req.url);
|
||||
|
||||
// SPA fallback - 如果文件不存在,返回 index.html
|
||||
if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
|
||||
filePath = path.join(DIST_DIR, 'index.html');
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath);
|
||||
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'no-cache'
|
||||
});
|
||||
res.end(content);
|
||||
} catch (err) {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, '127.0.0.1', () => {
|
||||
console.log(`Dev server running at http://127.0.0.1:${PORT}/`);
|
||||
console.log(`Serving: ${DIST_DIR}`);
|
||||
console.log(`API proxy: -> ${API_TARGET}`);
|
||||
});
|
||||
|
|
@ -17,9 +17,9 @@
|
|||
"devDependencies": {
|
||||
"@playwright/test": "^1.42.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"typescript": "^5.4.0",
|
||||
"vite": "^5.2.0",
|
||||
"vite": "^5.2.14",
|
||||
"vitest": "^1.4.0",
|
||||
"vue-tsc": "^2.0.0"
|
||||
}
|
||||
|
|
@ -129,9 +129,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
|
||||
"integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -146,9 +146,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
|
||||
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.20.2.tgz",
|
||||
"integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -163,9 +163,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz",
|
||||
"integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -180,9 +180,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.20.2.tgz",
|
||||
"integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -197,9 +197,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz",
|
||||
"integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -214,9 +214,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz",
|
||||
"integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -231,9 +231,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz",
|
||||
"integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -248,9 +248,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz",
|
||||
"integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -265,9 +265,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
|
||||
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz",
|
||||
"integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -282,9 +282,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz",
|
||||
"integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -299,9 +299,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
|
||||
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz",
|
||||
"integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
|
|
@ -316,9 +316,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
|
||||
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz",
|
||||
"integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
|
|
@ -333,9 +333,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
|
||||
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz",
|
||||
"integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
|
|
@ -350,9 +350,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
|
||||
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz",
|
||||
"integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -367,9 +367,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
|
||||
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz",
|
||||
"integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
|
|
@ -384,9 +384,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
|
||||
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz",
|
||||
"integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
|
|
@ -401,9 +401,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz",
|
||||
"integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -418,9 +418,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz",
|
||||
"integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -435,9 +435,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz",
|
||||
"integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -452,9 +452,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz",
|
||||
"integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -469,9 +469,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz",
|
||||
"integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -486,9 +486,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
|
||||
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz",
|
||||
"integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
|
|
@ -503,9 +503,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz",
|
||||
"integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -555,9 +555,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.1.tgz",
|
||||
"integrity": "sha512-xB0b51TB7IfDEzAojXahmr+gfA00uYVInJGgNNkeQG6RPnCPGr7udsylFLTubuIUSRE6FkcI1NElyRt83PP5oQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -569,9 +569,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.1.tgz",
|
||||
"integrity": "sha512-XOjPId0qwSDKHaIsdzHJtKCxX0+nH8MhBwvrNsT7tVyKmdTx1jJ4XzN5RZXCdTzMpufLb+B8llTC0D8uCrLhcw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -583,9 +583,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.1.tgz",
|
||||
"integrity": "sha512-vQuRd28p0gQpPrS6kppd8IrWmFo42U8Pz1XLRjSZXq5zCqyMDYFABT7/sywL11mO1EL10Qhh7MVPEwkG8GiBeg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -597,9 +597,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.1.tgz",
|
||||
"integrity": "sha512-x6VG6U29+Ivlnajrg1IHdzXeAwSoEHBFVO+CtC9Brugx6de712CUJobRUxsIA0KYrQvCmzNrMPFTT1A4CCqNTg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -611,9 +611,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.1.tgz",
|
||||
"integrity": "sha512-Sgi0Uo6t1YCHJMNO3Y8+bm+SvOanUGkoZKn/VJPwYUe2kp31X5KnXmzKd/NjW8iA3gFcfNZ64zh14uOGrIllCQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -625,9 +625,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.1.tgz",
|
||||
"integrity": "sha512-AM4xnwEZwukdhk7laMWfzWu9JGSVnJd+Fowt6Fd7QW1nrf3h0Hp7Qx5881M4aqrUlKBCybOxz0jofvIIfl7C5g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -639,9 +639,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.1.tgz",
|
||||
"integrity": "sha512-KUizqxpwaR2AZdAUsMWfL/C94pUu7TKpoPd88c8yFVixJ+l9hejkrwoK5Zj3wiNh65UeyryKnJyxL1b7yNqFQA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -653,9 +653,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.1.tgz",
|
||||
"integrity": "sha512-MZoQ/am77ckJtZGFAtPucgUuJWiop3m2R3lw7tC0QCcbfl4DRhQUBUkHWCkcrT3pqy5Mzv5QQgY6Dmlba6iTWg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -667,9 +667,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.1.tgz",
|
||||
"integrity": "sha512-Sez95TP6xGjkWB1608EfhCX1gdGrO5wzyN99VqzRtC17x/1bhw5VU1V0GfKUwbW/Xr1J8mSasoFoJa6Y7aGGSA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -681,9 +681,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.1.tgz",
|
||||
"integrity": "sha512-9Cs2Seq98LWNOJzR89EGTZoiP8EkZ9UbQhBlDgfAkM6asVna1xJ04W2CLYWDN/RpUgOjtQvcv8wQVi1t5oQazA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -695,9 +695,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.1.tgz",
|
||||
"integrity": "sha512-n9yqttftgFy7IrNEnHy1bOp6B4OSe8mJDiPkT7EqlM9FnKOwUMnCK62ixW0Kd9Clw0/wgvh8+SqaDXMFvw3KqQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
|
|
@ -709,9 +709,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.1.tgz",
|
||||
"integrity": "sha512-SfpNXDzVTqs/riak4xXcLpq5gIQWsqGWMhN1AGRQKB4qGSs4r0sEs3ervXPcE1O9RsQ5bm8Muz6zmQpQnPss1g==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
|
|
@ -723,9 +723,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.1.tgz",
|
||||
"integrity": "sha512-LjaChED0wQnjKZU+tsmGbN+9nN1XhaWUkAlSbTdhpEseCS4a15f/Q8xC2BN4GDKRzhhLZpYtJBZr2NZhR0jvNw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -737,9 +737,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.1.tgz",
|
||||
"integrity": "sha512-ojW7iTJSIs4pwB2xV6QXGwNyDctvXOivYllttuPbXguuKDX5vwpqYJsHc6D2LZzjDGHML414Tuj3LvVPe1CT1A==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -751,9 +751,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.1.tgz",
|
||||
"integrity": "sha512-FP+Q6WTcxxvsr0wQczhSE+tOZvFPV8A/mUE6mhZYFW9/eea/y/XqAgRoLLMuE9Cz0hfX5bi7p116IWoB+P237A==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
|
|
@ -765,9 +765,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.1.tgz",
|
||||
"integrity": "sha512-L1uD9b/Ig8Z+rn1KttCJjwhN1FgjRMBKsPaBsDKkfUl7GfFq71pU4vWCnpOsGljycFEbkHWARZLf4lMYg3WOLw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
|
|
@ -779,9 +779,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.1.tgz",
|
||||
"integrity": "sha512-EZc9NGTk/oSUzzOD4nYY4gIjteo2M3CiozX6t1IXGCOdgxJTlVu/7EdPeiqeHPSIrxkLhavqpBAUCfvC6vBOug==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
|
|
@ -793,9 +793,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.1.tgz",
|
||||
"integrity": "sha512-NQ9KyU1Anuy59L8+HHOKM++CoUxrQWrZWXRik4BJFm+7i5NP6q/SW43xIBr80zzt+PDBJ7LeNmloQGfa0JGk0w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -807,9 +807,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.1.tgz",
|
||||
"integrity": "sha512-GZkLk2t6naywsveSFBsEb0PLU+JC9ggVjbndsbG20VPhar6D1gkMfCx4NfP9owpovBXTN+eRdqGSkDGIxPHhmQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -821,9 +821,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.1.tgz",
|
||||
"integrity": "sha512-1hjG9Jpl2KDOetr64iQd8AZAEjkDUUK5RbDkYWsViYLC1op1oNzdjMJeFiofcGhqbNTaY2kfgqowE7DILifsrA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -835,9 +835,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.1.tgz",
|
||||
"integrity": "sha512-ARoKfflk0SiiYm3r1fmF73K/yB+PThmOwfWCk1sr7x/k9dc3uGLWuEE9if+Pw21el8MSpp3TMnG5vLNsJ/MMGQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -849,9 +849,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.1.tgz",
|
||||
"integrity": "sha512-oOST61G6VM45Mz2vdzWMr1s2slI7y9LqxEV5fCoWi2MDONmMvgsJVHSXxce/I2xOSZPTZ47nDPOl1tkwKWSHcw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -863,9 +863,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.1.tgz",
|
||||
"integrity": "sha512-x5WgLi5dWpRz7WclKBGEF15LcWTh0ewrHM6Cq4A+WUbkysUMZNeqt05bwPonOQ3ihPS/WMhAZV5zB1DfnI4Sxg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
|
|
@ -877,9 +877,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.1.tgz",
|
||||
"integrity": "sha512-wS+zHAJRVP5zOL0e+a3V3E/NTEwM2HEvvNKoDy5Xcfs0o8lljxn+EAFPkUsxihBdmDq1JWzXmmB9cbssCPdxxw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -891,9 +891,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.1.tgz",
|
||||
"integrity": "sha512-rhHyrMeLpErT/C7BxcEsU4COHQUzHyrPYW5tOZUeUhziNtRuYxmDWvqQqzpuUt8xpOgmbKa1btGXfnA/ANVO+g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -939,9 +939,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
"version": "5.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
|
||||
"integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.1.tgz",
|
||||
"integrity": "sha512-cxh314tzaWwOLqVes2gnnCtvBDcM1UMdn+iFR+UjAn411dPT3tOmqrJjbMd7koZpMAmBM/GqeV4n9ge7JSiJJQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -1610,9 +1610,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz",
|
||||
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.20.2.tgz",
|
||||
"integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
|
|
@ -1623,29 +1623,29 @@
|
|||
"node": ">=12"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.21.5",
|
||||
"@esbuild/android-arm": "0.21.5",
|
||||
"@esbuild/android-arm64": "0.21.5",
|
||||
"@esbuild/android-x64": "0.21.5",
|
||||
"@esbuild/darwin-arm64": "0.21.5",
|
||||
"@esbuild/darwin-x64": "0.21.5",
|
||||
"@esbuild/freebsd-arm64": "0.21.5",
|
||||
"@esbuild/freebsd-x64": "0.21.5",
|
||||
"@esbuild/linux-arm": "0.21.5",
|
||||
"@esbuild/linux-arm64": "0.21.5",
|
||||
"@esbuild/linux-ia32": "0.21.5",
|
||||
"@esbuild/linux-loong64": "0.21.5",
|
||||
"@esbuild/linux-mips64el": "0.21.5",
|
||||
"@esbuild/linux-ppc64": "0.21.5",
|
||||
"@esbuild/linux-riscv64": "0.21.5",
|
||||
"@esbuild/linux-s390x": "0.21.5",
|
||||
"@esbuild/linux-x64": "0.21.5",
|
||||
"@esbuild/netbsd-x64": "0.21.5",
|
||||
"@esbuild/openbsd-x64": "0.21.5",
|
||||
"@esbuild/sunos-x64": "0.21.5",
|
||||
"@esbuild/win32-arm64": "0.21.5",
|
||||
"@esbuild/win32-ia32": "0.21.5",
|
||||
"@esbuild/win32-x64": "0.21.5"
|
||||
"@esbuild/aix-ppc64": "0.20.2",
|
||||
"@esbuild/android-arm": "0.20.2",
|
||||
"@esbuild/android-arm64": "0.20.2",
|
||||
"@esbuild/android-x64": "0.20.2",
|
||||
"@esbuild/darwin-arm64": "0.20.2",
|
||||
"@esbuild/darwin-x64": "0.20.2",
|
||||
"@esbuild/freebsd-arm64": "0.20.2",
|
||||
"@esbuild/freebsd-x64": "0.20.2",
|
||||
"@esbuild/linux-arm": "0.20.2",
|
||||
"@esbuild/linux-arm64": "0.20.2",
|
||||
"@esbuild/linux-ia32": "0.20.2",
|
||||
"@esbuild/linux-loong64": "0.20.2",
|
||||
"@esbuild/linux-mips64el": "0.20.2",
|
||||
"@esbuild/linux-ppc64": "0.20.2",
|
||||
"@esbuild/linux-riscv64": "0.20.2",
|
||||
"@esbuild/linux-s390x": "0.20.2",
|
||||
"@esbuild/linux-x64": "0.20.2",
|
||||
"@esbuild/netbsd-x64": "0.20.2",
|
||||
"@esbuild/openbsd-x64": "0.20.2",
|
||||
"@esbuild/sunos-x64": "0.20.2",
|
||||
"@esbuild/win32-arm64": "0.20.2",
|
||||
"@esbuild/win32-ia32": "0.20.2",
|
||||
"@esbuild/win32-x64": "0.20.2"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
|
|
@ -2336,9 +2336,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.59.0.tgz",
|
||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.59.1.tgz",
|
||||
"integrity": "sha512-iZKH8BeoCwTCBTZBZWQQMreekd4mdomwdjIQ40GC1oZm6o+8PnNMIxFOiCsGMWeS8iDJ7KZcl7KwmKk/0HOQpA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -2352,31 +2352,31 @@
|
|||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.59.0",
|
||||
"@rollup/rollup-android-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-x64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-arm64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-x64": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-musl": "4.59.0",
|
||||
"@rollup/rollup-openbsd-x64": "4.59.0",
|
||||
"@rollup/rollup-openharmony-arm64": "4.59.0",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.59.0",
|
||||
"@rollup/rollup-android-arm-eabi": "4.59.1",
|
||||
"@rollup/rollup-android-arm64": "4.59.1",
|
||||
"@rollup/rollup-darwin-arm64": "4.59.1",
|
||||
"@rollup/rollup-darwin-x64": "4.59.1",
|
||||
"@rollup/rollup-freebsd-arm64": "4.59.1",
|
||||
"@rollup/rollup-freebsd-x64": "4.59.1",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.59.1",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.59.1",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.59.1",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.59.1",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.59.1",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.59.1",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.59.1",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.59.1",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.59.1",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.59.1",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.59.1",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.59.1",
|
||||
"@rollup/rollup-linux-x64-musl": "4.59.1",
|
||||
"@rollup/rollup-openbsd-x64": "4.59.1",
|
||||
"@rollup/rollup-openharmony-arm64": "4.59.1",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.59.1",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.59.1",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.59.1",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.59.1",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
|
|
@ -2568,15 +2568,15 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz",
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"version": "5.2.14",
|
||||
"resolved": "https://registry.npmmirror.com/vite/-/vite-5.2.14.tgz",
|
||||
"integrity": "sha512-TFQLuwWLPms+NBNlh0D9LZQ+HXW471COABxw/9TEUBrjuHMo9BrYBPrN/SYAwIuVL+rLerycxiLT41t4f5MZpA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
"rollup": "^4.20.0"
|
||||
"esbuild": "^0.20.1",
|
||||
"postcss": "^8.4.38",
|
||||
"rollup": "^4.13.0"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
|
|
@ -2595,7 +2595,6 @@
|
|||
"less": "*",
|
||||
"lightningcss": "^1.21.0",
|
||||
"sass": "*",
|
||||
"sass-embedded": "*",
|
||||
"stylus": "*",
|
||||
"sugarss": "*",
|
||||
"terser": "^5.4.0"
|
||||
|
|
@ -2613,9 +2612,6 @@
|
|||
"sass": {
|
||||
"optional": true
|
||||
},
|
||||
"sass-embedded": {
|
||||
"optional": true
|
||||
},
|
||||
"stylus": {
|
||||
"optional": true
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:simple": "node dev-server.mjs",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
|
|
@ -17,12 +18,12 @@
|
|||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.42.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"typescript": "^5.4.0",
|
||||
"vite": "^5.2.0",
|
||||
"vite": "^5.2.14",
|
||||
"vitest": "^1.4.0",
|
||||
"vue-tsc": "^2.0.0",
|
||||
"@playwright/test": "^1.42.0"
|
||||
"vue-tsc": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import request from '@/utils/request'
|
||||
import type { Permission } from '@/types'
|
||||
|
||||
export const getPermissions = () => {
|
||||
return request.get<Permission[]>('/api/permissions')
|
||||
}
|
||||
|
||||
export const getPermission = (id: string) => {
|
||||
return request.get<Permission>(`/api/permissions/${id}`)
|
||||
}
|
||||
|
||||
export const createPermission = (data: Partial<Permission>) => {
|
||||
return request.post<Permission>('/api/permissions', data)
|
||||
}
|
||||
|
||||
export const updatePermission = (id: string, data: Partial<Permission>) => {
|
||||
return request.put<Permission>(`/api/permissions/${id}`, data)
|
||||
}
|
||||
|
||||
export const deletePermission = (id: string) => {
|
||||
return request.delete(`/api/permissions/${id}`)
|
||||
}
|
||||
|
||||
export const getPermissionsByModule = (module: string) => {
|
||||
return request.get<Permission[]>(`/api/permissions/module/${module}`)
|
||||
}
|
||||
|
|
@ -2,29 +2,37 @@ import request from '@/utils/request'
|
|||
import type { Role } from '@/types'
|
||||
|
||||
export const getRoles = () => {
|
||||
return request.get<Role[]>('/roles')
|
||||
return request.get<Role[]>('/api/roles')
|
||||
}
|
||||
|
||||
export const getRole = (id: string) => {
|
||||
return request.get<Role>(`/roles/${id}`)
|
||||
return request.get<Role>(`/api/roles/${id}`)
|
||||
}
|
||||
|
||||
export const getRolesByProject = (projectId: string) => {
|
||||
return request.get<Role[]>(`/roles/project/${projectId}`)
|
||||
return request.get<Role[]>(`/api/roles/project/${projectId}`)
|
||||
}
|
||||
|
||||
export const createRole = (data: Partial<Role>) => {
|
||||
return request.post<Role>('/roles', data)
|
||||
return request.post<Role>('/api/roles', data)
|
||||
}
|
||||
|
||||
export const updateRole = (id: string, data: Partial<Role>) => {
|
||||
return request.put<Role>(`/roles/${id}`, data)
|
||||
return request.put<Role>(`/api/roles/${id}`, data)
|
||||
}
|
||||
|
||||
export const deleteRole = (id: string) => {
|
||||
return request.delete(`/roles/${id}`)
|
||||
return request.delete(`/api/roles/${id}`)
|
||||
}
|
||||
|
||||
export const assignPermissions = (roleId: string, permissionIds: string[]) => {
|
||||
return request.post(`/roles/${roleId}/permissions`, permissionIds)
|
||||
return request.post(`/api/roles/${roleId}/permissions`, permissionIds)
|
||||
}
|
||||
|
||||
export const getUserRoles = (userId: string) => {
|
||||
return request.get<Role[]>(`/api/users/${userId}/roles`)
|
||||
}
|
||||
|
||||
export const removeRoleFromUser = (userId: string, roleId: string) => {
|
||||
return request.delete(`/api/users/${userId}/roles/${roleId}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,29 +2,49 @@ import request from '@/utils/request'
|
|||
import type { User } from '@/types'
|
||||
|
||||
export const getUsers = () => {
|
||||
return request.get<User[]>('/users')
|
||||
return request.get<User[]>('/api/users')
|
||||
}
|
||||
|
||||
export const getUser = (id: string) => {
|
||||
return request.get<User>(`/users/${id}`)
|
||||
return request.get<User>(`/api/users/${id}`)
|
||||
}
|
||||
|
||||
export const createUser = (data: Partial<User>) => {
|
||||
return request.post<User>('/users', data)
|
||||
return request.post<User>('/api/users', data)
|
||||
}
|
||||
|
||||
export const updateUser = (id: string, data: Partial<User>) => {
|
||||
return request.put<User>(`/users/${id}`, data)
|
||||
return request.put<User>(`/api/users/${id}`, data)
|
||||
}
|
||||
|
||||
export const deleteUser = (id: string) => {
|
||||
return request.delete(`/users/${id}`)
|
||||
return request.delete(`/api/users/${id}`)
|
||||
}
|
||||
|
||||
export const updatePassword = (id: string, oldPassword: string, newPassword: string) => {
|
||||
return request.put(`/users/${id}/password`, { oldPassword, newPassword })
|
||||
return request.put(`/api/users/${id}/password`, { oldPassword, newPassword })
|
||||
}
|
||||
|
||||
export const assignRoles = (userId: string, roleIds: string[]) => {
|
||||
return request.post(`/users/${userId}/roles`, roleIds)
|
||||
return request.post(`/api/users/${userId}/roles`, roleIds)
|
||||
}
|
||||
|
||||
export interface UserProject {
|
||||
id: string
|
||||
userId: string
|
||||
projectId: string
|
||||
roleInProject: 'leader' | 'member' | 'viewer'
|
||||
joinedAt: string
|
||||
}
|
||||
|
||||
export const getUserProjects = (userId: string) => {
|
||||
return request.get<UserProject[]>(`/api/users/${userId}/projects`)
|
||||
}
|
||||
|
||||
export const addUserToProject = (userId: string, projectId: string, roleInProject: string) => {
|
||||
return request.post(`/api/users/${userId}/projects`, { projectId, roleInProject })
|
||||
}
|
||||
|
||||
export const removeUserFromProject = (userId: string, projectId: string) => {
|
||||
return request.delete(`/api/users/${userId}/projects/${projectId}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<div v-if="title || $slots.header" class="card-header">
|
||||
<h3 v-if="title" class="card-title">
|
||||
<span v-if="icon" class="card-icon">{{ icon }}</span>
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div v-if="$slots.extra" class="card-extra">
|
||||
<slot name="extra"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.card-extra {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<script setup lang="ts">
|
||||
interface Props {
|
||||
column?: number
|
||||
bordered?: boolean
|
||||
size?: 'default' | 'middle' | 'small'
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
column: 2,
|
||||
bordered: true,
|
||||
size: 'small'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="description-list">
|
||||
<a-descriptions :column="column" :bordered="bordered" :size="size">
|
||||
<slot></slot>
|
||||
</a-descriptions>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.description-list {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
modelValue?: string
|
||||
label?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
label: '邮箱',
|
||||
required: false,
|
||||
disabled: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'change', value: string): void
|
||||
}>()
|
||||
|
||||
const value = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => {
|
||||
emit('update:modelValue', val!)
|
||||
emit('change', val!)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-form-item :label="label" name="email" :required="required">
|
||||
<a-input
|
||||
v-model:value="value"
|
||||
:disabled="disabled"
|
||||
placeholder="请输入邮箱"
|
||||
/>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { InboxOutlined, SearchOutlined, LockOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
interface Props {
|
||||
type?: 'empty' | 'search' | 'permission'
|
||||
title?: string
|
||||
description?: string
|
||||
actionText?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'empty'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'action'): void
|
||||
}>()
|
||||
|
||||
const iconMap = {
|
||||
empty: InboxOutlined,
|
||||
search: SearchOutlined,
|
||||
permission: LockOutlined
|
||||
}
|
||||
|
||||
const currentIcon = computed(() => iconMap[props.type!] || InboxOutlined)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<component :is="currentIcon" />
|
||||
</div>
|
||||
<div class="empty-title">{{ title }}</div>
|
||||
<div v-if="description" class="empty-description">{{ description }}</div>
|
||||
<a-button
|
||||
v-if="actionText"
|
||||
type="primary"
|
||||
@click="emit('action')"
|
||||
>
|
||||
{{ actionText }}
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
color: #d9d9d9;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 16px;
|
||||
color: #262626;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 24px;
|
||||
max-width: 300px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
<script setup lang="ts">
|
||||
import { WifiOutlined, CloudServerOutlined, FileSearchOutlined, CloseCircleOutlined } from '@ant-design/icons-vue'
|
||||
import { Result, Button } from 'ant-design-vue'
|
||||
|
||||
interface Props {
|
||||
type?: 'network' | 'server' | '404' | 'error'
|
||||
title?: string
|
||||
description?: string
|
||||
showBack?: boolean
|
||||
showRetry?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'network',
|
||||
title: '',
|
||||
description: '',
|
||||
showBack: false,
|
||||
showRetry: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'retry'): void
|
||||
(e: 'back'): void
|
||||
}>()
|
||||
|
||||
const configMap: Record<string, { icon: any; defaultTitle: string; defaultDesc: string }> = {
|
||||
network: {
|
||||
icon: WifiOutlined,
|
||||
defaultTitle: '网络连接失败',
|
||||
defaultDesc: '请检查网络后重试'
|
||||
},
|
||||
server: {
|
||||
icon: CloudServerOutlined,
|
||||
defaultTitle: '服务异常',
|
||||
defaultDesc: '请稍后重试或联系管理员'
|
||||
},
|
||||
'404': {
|
||||
icon: FileSearchOutlined,
|
||||
defaultTitle: '页面不存在',
|
||||
defaultDesc: '请检查URL是否正确'
|
||||
},
|
||||
error: {
|
||||
icon: CloseCircleOutlined,
|
||||
defaultTitle: '操作失败',
|
||||
defaultDesc: ''
|
||||
}
|
||||
}
|
||||
|
||||
const currentConfig = () => configMap[props.type] || configMap.network
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Result
|
||||
:status="type === 'error' ? 'error' : '500'"
|
||||
:title="title || currentConfig().defaultTitle"
|
||||
:sub-title="description || currentConfig().defaultDesc"
|
||||
>
|
||||
<template #icon>
|
||||
<div class="error-icon-wrapper">
|
||||
<component :is="currentConfig().icon" />
|
||||
</div>
|
||||
</template>
|
||||
<template #extra>
|
||||
<Space>
|
||||
<Button v-if="showRetry" @click="emit('retry')">
|
||||
重试
|
||||
</Button>
|
||||
<Button v-if="showBack" type="primary" @click="emit('back')">
|
||||
返回
|
||||
</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</Result>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.error-icon-wrapper {
|
||||
font-size: 64px;
|
||||
color: #d9d9d9;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
layout?: 'horizontal' | 'vertical'
|
||||
gap?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
layout: 'horizontal',
|
||||
gap: 12
|
||||
})
|
||||
|
||||
const gapStyle = computed(() => ({
|
||||
gap: `${props.gap}px`
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="filter-bar" :class="[`layout-${layout}`]" :style="gapStyle">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-bar {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.layout-horizontal {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.layout-vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<script setup lang="ts">
|
||||
interface Props {
|
||||
text?: string
|
||||
fullscreen?: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="fullscreen" class="loading-fullscreen">
|
||||
<div class="loading-content">
|
||||
<a-spin size="large" />
|
||||
<div class="loading-text">{{ text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="loading-center">
|
||||
<a-spin size="large" />
|
||||
<div class="loading-text">{{ text }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.loading-fullscreen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.loading-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
showBack?: boolean
|
||||
actions?: { icon: string; text: string; onClick?: () => void }[]
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const handleBack = () => {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<div class="page-header-left">
|
||||
<button v-if="showBack" class="back-btn" @click="handleBack">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M12.5 15L7.5 10L12.5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<h2 class="page-title">{{ title }}</h2>
|
||||
</div>
|
||||
<div v-if="actions && actions.length > 0" class="page-header-actions">
|
||||
<button
|
||||
v-for="(action, index) in actions"
|
||||
:key="index"
|
||||
class="header-action-btn"
|
||||
@click="action.onClick"
|
||||
>
|
||||
{{ action.text }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #595959;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: #f5f5f5;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-action-btn {
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
color: #1890ff;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.header-action-btn:hover {
|
||||
background: #e6f7ff;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
current?: number
|
||||
total?: number
|
||||
pageSize?: number
|
||||
pageSizes?: number[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
current: 1,
|
||||
total: 0,
|
||||
pageSize: 10,
|
||||
pageSizes: () => [10, 20, 50, 100]
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:current', value: number): void
|
||||
(e: 'update:pageSize', value: number): void
|
||||
(e: 'change', current: number, pageSize: number): void
|
||||
}>()
|
||||
|
||||
const current = computed({
|
||||
get: () => props.current,
|
||||
set: (val) => emit('update:current', val)
|
||||
})
|
||||
|
||||
const handleChange = (page: number, size: number) => {
|
||||
emit('update:pageSize', size)
|
||||
emit('change', page, size)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="table-pagination">
|
||||
<a-pagination
|
||||
v-model:current="current"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:show-size-changer="true"
|
||||
:show-quick-jumper="true"
|
||||
:page-size-options="pageSizes.map(String)"
|
||||
:show-total="(total: number) => `共 ${total} 条`"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 16px 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
modelValue?: string
|
||||
label?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
showStrength?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
label: '密码',
|
||||
required: false,
|
||||
disabled: false,
|
||||
showStrength: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'change', value: string): void
|
||||
}>()
|
||||
|
||||
const value = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => {
|
||||
emit('update:modelValue', val!)
|
||||
emit('change', val!)
|
||||
}
|
||||
})
|
||||
|
||||
const level = computed(() => {
|
||||
if (!props.modelValue) return 0
|
||||
const p = props.modelValue
|
||||
let score = 0
|
||||
if (p.length >= 6) score++
|
||||
if (p.length >= 8) score++
|
||||
if (/[a-z]/.test(p) && /[A-Z]/.test(p)) score++
|
||||
if (/\d/.test(p)) score++
|
||||
if (/[!@#$%^&*(),.?":{}|<>]/.test(p)) score++
|
||||
return Math.min(score, 4)
|
||||
})
|
||||
|
||||
const levelText = computed(() => ['', '弱', '中', '强', '很强'][level.value])
|
||||
const levelColors = ['', '#ff4d4f', '#faad14', '#52c41a', '#1890ff']
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-form-item :label="label" name="password" :required="required">
|
||||
<a-input-password
|
||||
v-model:value="value"
|
||||
:disabled="disabled"
|
||||
placeholder="请输入密码,6-20位"
|
||||
/>
|
||||
<div v-if="showStrength && modelValue" class="password-strength">
|
||||
<div class="strength-bar">
|
||||
<div
|
||||
v-for="i in 4"
|
||||
:key="i"
|
||||
class="strength-segment"
|
||||
:class="{ active: i <= level }"
|
||||
:style="{ backgroundColor: i <= level ? levelColors[level] : '#f0f0f0' }"
|
||||
/>
|
||||
</div>
|
||||
<span class="strength-text" :style="{ color: levelColors[level] }">{{ levelText }}</span>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.password-strength {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.strength-bar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
.strength-segment {
|
||||
height: 4px;
|
||||
flex: 1;
|
||||
border-radius: 2px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
.strength-text {
|
||||
font-size: 12px;
|
||||
min-width: 32px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
<template>
|
||||
<a-tree
|
||||
v-model:selectedKeys="selectedKeys"
|
||||
v-model:checkedKeys="checkedKeys"
|
||||
checkable
|
||||
:tree-data="treeData"
|
||||
:field-names="{ children: 'children', title: 'name', key: 'id' }"
|
||||
>
|
||||
<template #title="{ name, type }">
|
||||
<span>{{ name }}</span>
|
||||
<a-tag :color="getTypeColor(type)" style="margin-left: 8px">
|
||||
{{ getTypeLabel(type) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-tree>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { Permission } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
permissions: Permission[]
|
||||
selected?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:selected', value: string[]): void
|
||||
}>()
|
||||
|
||||
const selectedKeys = ref<string[]>(props.selected || [])
|
||||
const checkedKeys = ref<string[]>(props.selected || [])
|
||||
|
||||
interface TreeNode {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
children?: TreeNode[]
|
||||
}
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
MENU: 'blue',
|
||||
BUTTON: 'green',
|
||||
API: 'purple',
|
||||
MENU_API: 'blue'
|
||||
}
|
||||
return map[type] || 'default'
|
||||
}
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
MENU: '菜单',
|
||||
BUTTON: '按钮',
|
||||
API: '接口',
|
||||
MENU_API: '菜单权限'
|
||||
}
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
const buildPermissionTree = (permissions: Permission[]): TreeNode[] => {
|
||||
// 按类型分组
|
||||
const menuPerms = permissions.filter(p => p.type === 'MENU')
|
||||
const buttonApiPerms = permissions.filter(p => p.type === 'BUTTON' || p.type === 'API')
|
||||
|
||||
const result: TreeNode[] = []
|
||||
|
||||
// 菜单权限组
|
||||
if (menuPerms.length > 0) {
|
||||
result.push({
|
||||
id: 'group-menu',
|
||||
name: '菜单权限',
|
||||
type: 'MENU_API',
|
||||
children: menuPerms.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
type: p.type
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
// 按钮/API权限组
|
||||
if (buttonApiPerms.length > 0) {
|
||||
result.push({
|
||||
id: 'group-button-api',
|
||||
name: '按钮/接口权限',
|
||||
type: 'BUTTON_API',
|
||||
children: buttonApiPerms.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
type: p.type
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const treeData = computed(() => buildPermissionTree(props.permissions))
|
||||
|
||||
watch(
|
||||
() => props.selected,
|
||||
(val) => {
|
||||
selectedKeys.value = val || []
|
||||
checkedKeys.value = val || []
|
||||
}
|
||||
)
|
||||
|
||||
watch(selectedKeys, (val) => {
|
||||
emit('update:selected', val)
|
||||
})
|
||||
</script>
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
modelValue?: string
|
||||
label?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
label: '手机号',
|
||||
required: false,
|
||||
disabled: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'change', value: string): void
|
||||
}>()
|
||||
|
||||
const value = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => {
|
||||
emit('update:modelValue', val!)
|
||||
emit('change', val!)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-form-item :label="label" name="phone" :required="required">
|
||||
<a-input
|
||||
v-model:value="value"
|
||||
:disabled="disabled"
|
||||
placeholder="请输入手机号"
|
||||
:maxlength="11"
|
||||
/>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
<script setup lang="ts">
|
||||
interface Props {
|
||||
avatar?: string
|
||||
name?: string
|
||||
role?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
avatar: '',
|
||||
name: '',
|
||||
role: '',
|
||||
size: 64
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="profile-card">
|
||||
<a-avatar :size="size" :src="avatar">
|
||||
{{ name?.charAt(0) || '?' }}
|
||||
</a-avatar>
|
||||
<div class="profile-info">
|
||||
<div class="profile-name">{{ name }}</div>
|
||||
<div v-if="role" class="profile-role">{{ role }}</div>
|
||||
</div>
|
||||
<div v-if="$slots.extra" class="profile-extra">
|
||||
<slot name="extra"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.profile-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.profile-name {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
}
|
||||
.profile-role {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
.profile-extra {
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { getProjects } from '@/api/project'
|
||||
import type { Project } from '@/types'
|
||||
|
||||
interface Props {
|
||||
modelValue?: string | string[]
|
||||
multiple?: boolean
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
multiple: false,
|
||||
disabled: false,
|
||||
placeholder: '请选择项目'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | string[]): void
|
||||
(e: 'change', value: string | string[]): void
|
||||
}>()
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const loading = ref(false)
|
||||
const selectedValue = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => {
|
||||
emit('update:modelValue', val!)
|
||||
emit('change', val!)
|
||||
}
|
||||
})
|
||||
|
||||
const options = computed(() =>
|
||||
projects.value.map((project) => ({
|
||||
value: project.code,
|
||||
label: project.name
|
||||
}))
|
||||
)
|
||||
|
||||
const fetchProjects = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getProjects()
|
||||
projects.value = res.data || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, () => {
|
||||
if (projects.value.length === 0) {
|
||||
fetchProjects()
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-select
|
||||
v-model:value="selectedValue"
|
||||
:options="options"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:mode="multiple ? 'multiple' : undefined"
|
||||
:show-search="true"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { getRoles } from '@/api/role'
|
||||
import type { Role } from '@/types'
|
||||
|
||||
interface Props {
|
||||
modelValue?: string | string[]
|
||||
multiple?: boolean
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
multiple: false,
|
||||
disabled: false,
|
||||
placeholder: '请选择角色'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | string[]): void
|
||||
(e: 'change', value: string | string[]): void
|
||||
}>()
|
||||
|
||||
const roles = ref<Role[]>([])
|
||||
const loading = ref(false)
|
||||
const selectedValue = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => {
|
||||
emit('update:modelValue', val!)
|
||||
emit('change', val!)
|
||||
}
|
||||
})
|
||||
|
||||
const options = computed(() =>
|
||||
roles.value.map((role) => ({
|
||||
value: role.id,
|
||||
label: role.name
|
||||
}))
|
||||
)
|
||||
|
||||
const fetchRoles = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getRoles()
|
||||
roles.value = res.data || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, () => {
|
||||
if (roles.value.length === 0) {
|
||||
fetchRoles()
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-select
|
||||
v-model:value="selectedValue"
|
||||
:options="options"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:mode="multiple ? 'multiple' : undefined"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
value: string | number
|
||||
change?: string
|
||||
up?: boolean
|
||||
icon?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
change: '-',
|
||||
up: true,
|
||||
color: '#1890FF'
|
||||
})
|
||||
|
||||
const changeClass = computed(() => {
|
||||
if (props.change === '-') return ''
|
||||
return props.up ? 'up' : 'down'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" :style="{ backgroundColor: color + '15', color: color }">
|
||||
<slot name="icon"></slot>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">{{ title }}</div>
|
||||
<div class="stat-value">{{ value }}</div>
|
||||
<div v-if="change !== '-'" class="stat-change" :class="changeClass">
|
||||
<span v-if="up" class="arrow">↑</span>
|
||||
<span v-else class="arrow">↓</span>
|
||||
{{ change }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.stat-change {
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.stat-change.up {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.stat-change.down {
|
||||
color: #f5222d;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
modelValue?: string
|
||||
options?: { value: string; label: string; color?: string }[]
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
options: () => [
|
||||
{ value: 'ACTIVE', label: '正常', color: 'success' },
|
||||
{ value: 'LOCKED', label: '锁定', color: 'warning' },
|
||||
{ value: 'DISABLED', label: '禁用', color: 'error' }
|
||||
],
|
||||
placeholder: '请选择状态'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'change', value: string): void
|
||||
}>()
|
||||
|
||||
const value = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => {
|
||||
emit('update:modelValue', val!)
|
||||
emit('change', val!)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-select v-model:value="value" :options="options" :placeholder="placeholder" />
|
||||
</template>
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
status: string
|
||||
map?: Record<string, { color: string; label: string }>
|
||||
defaultColor?: string
|
||||
defaultLabel?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
map: () => ({
|
||||
ACTIVE: { color: 'success', label: '正常' },
|
||||
LOCKED: { color: 'warning', label: '锁定' },
|
||||
DISABLED: { color: 'error', label: '禁用' }
|
||||
}),
|
||||
defaultColor: 'default',
|
||||
defaultLabel: ''
|
||||
})
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
success: '#52c41a',
|
||||
warning: '#faad14',
|
||||
error: '#f5222d',
|
||||
default: '#8c8c8c'
|
||||
}
|
||||
|
||||
const bgMap: Record<string, string> = {
|
||||
success: '#f6ffed',
|
||||
warning: '#fffbe6',
|
||||
error: '#fff1f0',
|
||||
default: '#f5f5f5'
|
||||
}
|
||||
|
||||
const currentStatus = computed(() => {
|
||||
return props.map[props.status] || { color: props.defaultColor, label: props.defaultLabel || props.status }
|
||||
})
|
||||
|
||||
const tagColor = computed(() => colorMap[currentStatus.value.color] || colorMap.default)
|
||||
const tagBg = computed(() => bgMap[currentStatus.value.color] || bgMap.default)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="status-tag" :style="{ color: tagColor, backgroundColor: tagBg }">
|
||||
{{ currentStatus.label }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
<script setup lang="ts">
|
||||
import { EditOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
||||
import { Popconfirm } from 'ant-design-vue'
|
||||
|
||||
interface ActionItem {
|
||||
key: string
|
||||
label: string
|
||||
icon?: any
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
actions?: ActionItem[]
|
||||
showEdit?: boolean
|
||||
showDelete?: boolean
|
||||
editText?: string
|
||||
deleteText?: string
|
||||
deleteTitle?: string
|
||||
deleteDescription?: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'edit'): void
|
||||
(e: 'delete'): void
|
||||
(e: 'action', key: string): void
|
||||
}>()
|
||||
|
||||
const handleAction = (key: string) => {
|
||||
emit('action', key)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="table-actions">
|
||||
<!-- 自定义操作 -->
|
||||
<template v-for="action in actions" :key="action.key">
|
||||
<a-button
|
||||
v-if="action.danger"
|
||||
type="link"
|
||||
danger
|
||||
size="small"
|
||||
@click="handleAction(action.key)"
|
||||
>
|
||||
<component v-if="action.icon" :is="action.icon" />
|
||||
{{ action.label }}
|
||||
</a-button>
|
||||
<a-button
|
||||
v-else
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleAction(action.key)"
|
||||
>
|
||||
<component v-if="action.icon" :is="action.icon" />
|
||||
{{ action.label }}
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<!-- 编辑按钮 -->
|
||||
<a-button
|
||||
v-if="showEdit"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="emit('edit')"
|
||||
>
|
||||
<EditOutlined /> {{ editText }}
|
||||
</a-button>
|
||||
|
||||
<!-- 删除按钮 -->
|
||||
<Popconfirm
|
||||
v-if="showDelete"
|
||||
:title="deleteTitle"
|
||||
:description="deleteDescription"
|
||||
ok-text="确认"
|
||||
cancel-text="取消"
|
||||
@confirm="emit('delete')"
|
||||
>
|
||||
<a-button type="link" danger size="small">
|
||||
<DeleteOutlined /> {{ deleteText }}
|
||||
</a-button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title?: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="table-card">
|
||||
<div v-if="title || $slots.header" class="table-card-header">
|
||||
<h3 v-if="title" class="table-card-title">
|
||||
<span v-if="icon" class="title-icon">{{ icon }}</span>
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div v-if="$slots.extra" class="table-card-extra">
|
||||
<slot name="extra"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-card-body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.table-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.table-card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.table-card-extra {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
<script setup lang="ts">
|
||||
import { ReloadOutlined, ExportOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
interface Props {
|
||||
showRefresh?: boolean
|
||||
showExport?: boolean
|
||||
exportText?: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'refresh'): void
|
||||
(e: 'export'): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="table-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<slot name="left"></slot>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<slot name="right"></slot>
|
||||
<a-button v-if="showRefresh" @click="emit('refresh')">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button v-if="showExport" type="default" @click="emit('export')">
|
||||
<template #icon><ExportOutlined /></template>
|
||||
{{ exportText }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { getUsers } from '@/api/user'
|
||||
import type { User } from '@/types'
|
||||
|
||||
interface Props {
|
||||
modelValue?: string | string[]
|
||||
multiple?: boolean
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
multiple: false,
|
||||
disabled: false,
|
||||
placeholder: '请选择用户'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | string[]): void
|
||||
(e: 'change', value: string | string[]): void
|
||||
}>()
|
||||
|
||||
const users = ref<User[]>([])
|
||||
const loading = ref(false)
|
||||
const selectedValue = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => {
|
||||
emit('update:modelValue', val!)
|
||||
emit('change', val!)
|
||||
}
|
||||
})
|
||||
|
||||
const options = computed(() =>
|
||||
users.value.map((user) => ({
|
||||
value: user.id,
|
||||
label: `${user.realName || user.username} (${user.username})`
|
||||
}))
|
||||
)
|
||||
|
||||
const filterUser = (input: string, option: any) => {
|
||||
return option.label.toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
|
||||
const fetchUsers = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getUsers()
|
||||
users.value = res.data || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, () => {
|
||||
if (users.value.length === 0) {
|
||||
fetchUsers()
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-select
|
||||
v-model:value="selectedValue"
|
||||
:options="options"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:mode="multiple ? 'multiple' : undefined"
|
||||
:show-search="true"
|
||||
:filter-option="filterUser"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* Ether Admin 组件库
|
||||
*
|
||||
* 基础组件:
|
||||
* - PageHeader: 页面标题区
|
||||
* - Card: 通用卡片
|
||||
* - TableCard: 表格卡片
|
||||
* - StatCard: 统计卡片
|
||||
* - FilterBar: 筛选栏
|
||||
* - StatusTag: 状态标签
|
||||
*
|
||||
* 状态组件:
|
||||
* - EmptyState: 空状态
|
||||
* - ErrorState: 错误状态
|
||||
* - LoadingState: 加载状态
|
||||
*
|
||||
* 表格组件:
|
||||
* - TableToolbar: 表格工具栏
|
||||
* - TableActions: 表格行操作
|
||||
* - Pagination: 分页器
|
||||
*
|
||||
* 业务组件:
|
||||
* - UserSelect: 用户选择器
|
||||
* - RoleSelect: 角色选择器
|
||||
* - ProjectSelect: 项目选择器
|
||||
* - StatusSelect: 状态选择器
|
||||
* - PhoneItem: 手机号表单项
|
||||
* - EmailItem: 邮箱表单项
|
||||
* - PasswordItem: 密码表单项(含强度)
|
||||
* - DescriptionList: 描述列表
|
||||
* - ProfileCard: 个人资料卡片
|
||||
*/
|
||||
|
||||
// 基础组件
|
||||
export { default as PageHeader } from './PageHeader/index.vue'
|
||||
export { default as Card } from './Card/index.vue'
|
||||
export { default as TableCard } from './TableCard/index.vue'
|
||||
export { default as StatCard } from './StatCard/index.vue'
|
||||
export { default as FilterBar } from './FilterBar/index.vue'
|
||||
export { default as StatusTag } from './StatusTag/index.vue'
|
||||
|
||||
// 状态组件
|
||||
export { default as EmptyState } from './EmptyState/index.vue'
|
||||
export { default as ErrorState } from './ErrorState/index.vue'
|
||||
export { default as LoadingState } from './LoadingState/index.vue'
|
||||
|
||||
// 表格组件
|
||||
export { default as TableToolbar } from './TableToolbar/index.vue'
|
||||
export { default as TableActions } from './TableActions/index.vue'
|
||||
export { default as Pagination } from './Pagination/index.vue'
|
||||
|
||||
// 业务组件 - 选择器
|
||||
export { default as UserSelect } from './UserSelect/index.vue'
|
||||
export { default as RoleSelect } from './RoleSelect/index.vue'
|
||||
export { default as ProjectSelect } from './ProjectSelect/index.vue'
|
||||
export { default as StatusSelect } from './StatusSelect/index.vue'
|
||||
|
||||
// 业务组件 - 表单项
|
||||
export { default as PhoneItem } from './PhoneItem/index.vue'
|
||||
export { default as EmailItem } from './EmailItem/index.vue'
|
||||
export { default as PasswordItem } from './PasswordItem/index.vue'
|
||||
|
||||
// 业务组件 - 详情
|
||||
export { default as DescriptionList } from './DescriptionList/index.vue'
|
||||
export { default as ProfileCard } from './ProfileCard/index.vue'
|
||||
|
|
@ -5,6 +5,7 @@ import 'ant-design-vue/dist/reset.css'
|
|||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
import './styles/page-common.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,12 @@ const router = createRouter({
|
|||
component: () => import('@/views/system/Permissions.vue'),
|
||||
meta: { title: '权限管理' }
|
||||
},
|
||||
{
|
||||
path: 'system/audit',
|
||||
name: 'Audit',
|
||||
component: () => import('@/views/system/Audit.vue'),
|
||||
meta: { title: '审计日志' }
|
||||
},
|
||||
{
|
||||
path: 'project/list',
|
||||
name: 'ProjectList',
|
||||
|
|
@ -50,7 +56,7 @@ const router = createRouter({
|
|||
]
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const userStore = useUserStore()
|
||||
if (to.path !== '/login' && !userStore.isLoggedIn()) {
|
||||
next('/login')
|
||||
|
|
|
|||
|
|
@ -9,12 +9,18 @@ export const useUserStore = defineStore('user', () => {
|
|||
|
||||
const login = async (data: LoginRequest) => {
|
||||
const res = await loginApi(data)
|
||||
token.value = res.data.token
|
||||
userInfo.value = {
|
||||
username: res.data.username,
|
||||
realName: res.data.realName
|
||||
const loginData = res.data.data
|
||||
if (!loginData?.token) {
|
||||
throw new Error('登录失败:未获取到token')
|
||||
}
|
||||
localStorage.setItem('token', res.data.token)
|
||||
const newToken = loginData.token
|
||||
token.value = newToken
|
||||
userInfo.value = {
|
||||
username: loginData.username,
|
||||
realName: loginData.realName
|
||||
}
|
||||
localStorage.setItem('token', newToken)
|
||||
localStorage.setItem('userInfo', JSON.stringify(userInfo.value))
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
|
|
@ -22,9 +28,29 @@ export const useUserStore = defineStore('user', () => {
|
|||
token.value = ''
|
||||
userInfo.value = null
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('userInfo')
|
||||
}
|
||||
|
||||
const isLoggedIn = () => !!token.value
|
||||
const isLoggedIn = () => {
|
||||
const storedToken = localStorage.getItem('token')
|
||||
if (!storedToken || storedToken === 'null' || storedToken === 'undefined') {
|
||||
return false
|
||||
}
|
||||
const parts = storedToken.split('.')
|
||||
if (parts.length !== 3) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(atob(parts[1]))
|
||||
if (payload.exp && payload.exp * 1000 < Date.now()) {
|
||||
logout()
|
||||
return false
|
||||
}
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,895 @@
|
|||
# Ether 组件库规范
|
||||
|
||||
## 目录
|
||||
1. [业务组件](#业务组件)
|
||||
2. [状态场景](#状态场景)
|
||||
3. [交互规范](#交互规范)
|
||||
|
||||
---
|
||||
|
||||
## 业务组件
|
||||
|
||||
### 1. Select 选择器
|
||||
|
||||
#### UserSelect 用户选择器
|
||||
```vue
|
||||
<template>
|
||||
<a-select
|
||||
v-model:value="selectedUserId"
|
||||
:options="userOptions"
|
||||
placeholder="请选择用户"
|
||||
show-search
|
||||
:filter-option="filterUser"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 属性
|
||||
interface Props {
|
||||
modelValue?: string // v-model
|
||||
multiple?: boolean // 多选模式,默认 false
|
||||
disabled?: boolean // 禁用状态,默认 false
|
||||
placeholder?: string // 占位文本
|
||||
}
|
||||
|
||||
// 事件
|
||||
// emits: ['update:modelValue', 'change']
|
||||
</script>
|
||||
```
|
||||
|
||||
#### RoleSelect 角色选择器
|
||||
```vue
|
||||
<template>
|
||||
<a-select
|
||||
v-model:value="selectedRoleIds"
|
||||
:options="roleOptions"
|
||||
placeholder="请选择角色"
|
||||
multiple
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
#### ProjectSelect 项目选择器
|
||||
```vue
|
||||
<template>
|
||||
<a-select
|
||||
v-model:value="selectedProjectCode"
|
||||
:options="projectOptions"
|
||||
placeholder="请选择项目"
|
||||
show-search
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
#### StatusSelect 状态选择器
|
||||
```vue
|
||||
<template>
|
||||
<a-select v-model:value="status">
|
||||
<a-select-option value="ACTIVE">正常</a-select-option>
|
||||
<a-select-option value="LOCKED">锁定</a-select-option>
|
||||
<a-select-option value="DISABLED">禁用</a-select-option>
|
||||
</a-select>
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. FormItem 表单项
|
||||
|
||||
#### PhoneItem 手机号表单项
|
||||
```vue
|
||||
<template>
|
||||
<a-form-item name="phone" label="手机号">
|
||||
<a-input v-model:value="form.phone" placeholder="请输入手机号" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 验证规则:1开头11位数字 -->
|
||||
```
|
||||
|
||||
#### EmailItem 邮箱表单项
|
||||
```vue
|
||||
<template>
|
||||
<a-form-item name="email" label="邮箱">
|
||||
<a-input v-model:value="form.email" placeholder="请输入邮箱" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 验证规则:标准邮箱格式 -->
|
||||
```
|
||||
|
||||
#### PasswordItem 密码表单项
|
||||
```vue
|
||||
<template>
|
||||
<a-form-item name="password" label="密码">
|
||||
<a-input-password v-model:value="form.password" placeholder="请输入密码" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 验证规则:6-20位,包含大小写和数字 -->
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Table 表格增强
|
||||
|
||||
#### Pagination 分页器
|
||||
```vue
|
||||
<template>
|
||||
<a-pagination
|
||||
v-model:current="current"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:show-size-changer="true"
|
||||
:show-total="(total) => `共 ${total} 条`"
|
||||
@change="handlePageChange"
|
||||
@showSizeChange="handleSizeChange"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
#### TableToolbar 表格工具栏
|
||||
```vue
|
||||
<template>
|
||||
<div class="table-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<slot name="left"></slot>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<slot name="right"></slot>
|
||||
<a-button @click="handleRefresh">
|
||||
<ReloadOutlined /> 刷新
|
||||
</a-button>
|
||||
<a-button @click="handleExport" v-if="showExport">
|
||||
<ExportOutlined /> 导出
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
#### TableActions 行操作
|
||||
```vue
|
||||
<template>
|
||||
<div class="table-actions">
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">
|
||||
<EditOutlined /> 编辑
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
title="确认删除?"
|
||||
ok-text="确认"
|
||||
cancel-text="取消"
|
||||
@confirm="handleDelete(record)"
|
||||
>
|
||||
<a-button type="link" danger size="small">
|
||||
<DeleteOutlined /> 删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Detail 详情组件
|
||||
|
||||
#### DescriptionList 描述列表
|
||||
```vue
|
||||
<template>
|
||||
<a-descriptions :column="2" bordered size="small">
|
||||
<a-descriptions-item label="用户名">
|
||||
{{ user.username }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="姓名">
|
||||
{{ user.realName }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="手机">
|
||||
{{ user.phone }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="邮箱">
|
||||
{{ user.email }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<StatusTag :status="user.status" />
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</template>
|
||||
```
|
||||
|
||||
#### ProfileCard 个人资料卡片
|
||||
```vue
|
||||
<template>
|
||||
<div class="profile-card">
|
||||
<a-avatar :size="64" :src="user.avatar">
|
||||
{{ user.realName?.charAt(0) }}
|
||||
</a-avatar>
|
||||
<div class="profile-info">
|
||||
<div class="profile-name">{{ user.realName }}</div>
|
||||
<div class="profile-role">{{ user.roleName }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 状态场景
|
||||
|
||||
### 1. 空状态 EmptyState
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<InboxOutlined />
|
||||
</div>
|
||||
<div class="empty-title">{{ title }}</div>
|
||||
<div class="empty-description">{{ description }}</div>
|
||||
<a-button v-if="actionText" type="primary" @click="handleAction">
|
||||
{{ actionText }}
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
interface Props {
|
||||
icon?: string // 图标,默认 InboxOutlined
|
||||
title?: string // 标题,默认"暂无数据"
|
||||
description?: string // 描述
|
||||
actionText?: string // 操作按钮文字
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
icon: 'InboxOutlined',
|
||||
title: '暂无数据',
|
||||
description: '',
|
||||
actionText: ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
color: #d9d9d9;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 16px;
|
||||
color: #262626;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 24px;
|
||||
max-width: 300px;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
**使用场景:**
|
||||
| 场景 | 图标 | 标题 | 描述 | 操作 |
|
||||
|------|------|------|------|------|
|
||||
| 列表无数据 | InboxOutlined | 暂无数据 | - | 可选:立即添加 |
|
||||
| 搜索无结果 | SearchOutlined | 未找到结果 | 请尝试其他关键词 | 可选:清除筛选 |
|
||||
| 无权限 | LockOutlined | 无访问权限 | 您没有访问该页面的权限 | 可选:申请权限 |
|
||||
|
||||
---
|
||||
|
||||
### 2. 加载状态 LoadingState
|
||||
|
||||
#### Skeleton 骨架屏
|
||||
```vue
|
||||
<template>
|
||||
<div class="skeleton">
|
||||
<a-skeleton :active="true" :paragraph="{ rows: 4 }" :title="true" />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**骨架屏使用场景:**
|
||||
- 页面首次加载
|
||||
- 数据量较大的列表
|
||||
- 表单页面
|
||||
|
||||
#### Spin 加载中
|
||||
```vue
|
||||
<template>
|
||||
<div class="loading-spin">
|
||||
<a-spin size="large" />
|
||||
<div class="loading-text">{{ text }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.loading-spin {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
**Spin 使用场景:**
|
||||
- 按钮加载状态
|
||||
- 抽屉/弹窗内容加载
|
||||
- 局部操作加载
|
||||
|
||||
---
|
||||
|
||||
### 3. 错误状态 ErrorState
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="error-state">
|
||||
<div class="error-icon">
|
||||
<CloseCircleOutlined />
|
||||
</div>
|
||||
<div class="error-title">{{ title }}</div>
|
||||
<div class="error-description">{{ description }}</div>
|
||||
<div class="error-actions">
|
||||
<a-button @click="handleRetry">重试</a-button>
|
||||
<a-button v-if="showBack" type="primary" @click="handleBack">返回</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**错误状态类型:**
|
||||
| 类型 | 图标 | 标题 | 描述示例 |
|
||||
|------|------|------|----------|
|
||||
| 网络错误 | WifiOutlined | 网络连接失败 | 请检查网络后重试 |
|
||||
| 服务器错误 | ServerOutlined | 服务异常 | 请稍后重试或联系管理员 |
|
||||
| 权限不足 | LockOutlined | 无权限访问 | 您没有权限执行此操作 |
|
||||
| 404 | FileSearchOutlined | 页面不存在 | 请检查URL是否正确 |
|
||||
| 操作失败 | CloseCircleOutlined | 操作失败 | {{ errorMessage }} |
|
||||
|
||||
---
|
||||
|
||||
### 4. 成功反馈 SuccessState
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<a-result
|
||||
status="success"
|
||||
title="{{ title }}"
|
||||
sub-title="{{ subTitle }}"
|
||||
>
|
||||
<template #extra>
|
||||
<a-button @click="handleClose">关闭</a-button>
|
||||
<a-button type="primary" @click="handleContinue">
|
||||
继续操作
|
||||
</a-button>
|
||||
</template>
|
||||
</a-result>
|
||||
</template>
|
||||
```
|
||||
|
||||
**使用场景:**
|
||||
- 操作成功确认
|
||||
- 提交成功反馈
|
||||
|
||||
---
|
||||
|
||||
### 5. 确认对话框 ConfirmDialog
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="visible"
|
||||
:title="title"
|
||||
:ok-text="okText"
|
||||
:cancel-text="cancelText"
|
||||
:ok-button-props="{ danger: isDangerous }"
|
||||
@ok="handleOk"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<div class="confirm-content">
|
||||
<div class="confirm-icon" :class="type">
|
||||
<component :is="iconComponent" />
|
||||
</div>
|
||||
<div class="confirm-message">{{ message }}</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
type ConfirmType = 'info' | 'success' | 'warning' | 'error' | 'danger'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
title?: string
|
||||
message: string
|
||||
type?: ConfirmType
|
||||
okText?: string
|
||||
cancelText?: string
|
||||
isDangerous?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: '确认操作',
|
||||
type: 'info',
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
isDangerous: false
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
**类型对应:**
|
||||
| 类型 | 图标 | 颜色 | 用途 |
|
||||
|------|------|------|------|
|
||||
| info | InfoCircleOutlined | #1890FF | 一般信息确认 |
|
||||
| success | CheckCircleOutlined | #52C41A | 成功确认 |
|
||||
| warning | WarningOutlined | #FAAD14 | 警告确认 |
|
||||
| error | CloseCircleOutlined | #F5222D | 错误确认 |
|
||||
| danger | ExclamationCircleOutlined | #F5222D | 危险操作确认 |
|
||||
|
||||
---
|
||||
|
||||
## 交互规范
|
||||
|
||||
### 1. 过渡动画
|
||||
|
||||
#### 时长规范
|
||||
| 类型 | 时长 | 用途 |
|
||||
|------|------|------|
|
||||
| 快速 | 150ms | 微交互、hover效果 |
|
||||
| 正常 | 200ms | 一般状态变化 |
|
||||
| 缓慢 | 300ms | 页面过渡、模态框 |
|
||||
| 渐入 | 400ms | 内容加载、列表展开 |
|
||||
|
||||
#### 缓动函数
|
||||
```css
|
||||
/* 标准缓出 - 推荐用于大部分动画 */
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* 进入动画 */
|
||||
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||
|
||||
/* 退出动画 */
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
|
||||
```
|
||||
|
||||
#### 抽屉过渡
|
||||
```vue
|
||||
<style scoped>
|
||||
.drawer-enter-active,
|
||||
.drawer-leave-active {
|
||||
transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.drawer-enter-from,
|
||||
.drawer-leave-to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 手势交互
|
||||
|
||||
#### 滑动操作
|
||||
```vue
|
||||
<template>
|
||||
<a-swipe-actions>
|
||||
<template #left>
|
||||
<a-button type="primary" @click="handleAccept">通过</a-button>
|
||||
<a-button type="danger" @click="handleReject">拒绝</a-button>
|
||||
</template>
|
||||
<div class="swipe-content">
|
||||
<!-- 内容 -->
|
||||
</div>
|
||||
</a-swipe-actions>
|
||||
</template>
|
||||
```
|
||||
|
||||
**使用场景:**
|
||||
- 移动端列表项操作
|
||||
- 审批流程滑动操作
|
||||
|
||||
#### 下拉刷新
|
||||
```vue
|
||||
<template>
|
||||
<a-pull-to-refresh @refresh="handleRefresh">
|
||||
<div class="content">
|
||||
<!-- 内容 -->
|
||||
</div>
|
||||
</a-pull-to-refresh>
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 快捷键规范
|
||||
|
||||
| 快捷键 | 动作 | 场景 |
|
||||
|--------|------|------|
|
||||
| Enter | 提交/确认 | 表单、对话框 |
|
||||
| Escape | 取消/关闭 | 模态框、抽屉 |
|
||||
| Ctrl+S | 保存 | 表单编辑页 |
|
||||
| Ctrl+R | 刷新 | 列表页 |
|
||||
| Ctrl+F | 聚焦搜索 | 列表页 |
|
||||
| Ctrl+N | 新增 | 列表页 |
|
||||
|
||||
---
|
||||
|
||||
### 4. 触控反馈
|
||||
|
||||
#### 按压状态
|
||||
```css
|
||||
.button:active {
|
||||
opacity: 0.8;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
```
|
||||
|
||||
#### 列表项触控
|
||||
```css
|
||||
.list-item:active {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
min-height: 44px; /* 触控友好高度 */
|
||||
padding: 12px 16px;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. 表单交互
|
||||
|
||||
#### 实时校验
|
||||
```vue
|
||||
<template>
|
||||
<a-form
|
||||
:model="formState"
|
||||
:rules="rules"
|
||||
@finish="onFinish"
|
||||
>
|
||||
<a-form-item name="email" validate-status="error" help="请输入正确的邮箱格式">
|
||||
<a-input v-model:value="formState.email" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</template>
|
||||
```
|
||||
|
||||
#### 错误提示位置
|
||||
- 输入框下方
|
||||
- 红色文字
|
||||
- 12px 字号
|
||||
- 8px 上边距
|
||||
|
||||
---
|
||||
|
||||
### 6. 通知提示
|
||||
|
||||
#### Message 消息提示
|
||||
| 类型 | 用途 | 时长 |
|
||||
|------|------|------|
|
||||
| success | 操作成功 | 3s |
|
||||
| error | 操作失败 | 无限制(需手动关闭) |
|
||||
| warning | 警告信息 | 4s |
|
||||
| info | 一般信息 | 3s |
|
||||
|
||||
#### Notification 通知
|
||||
- 右上角弹出
|
||||
- 右上角关闭
|
||||
- 自动隐藏(info/success: 4s, warning: 6s, error: 不自动关闭)
|
||||
|
||||
---
|
||||
|
||||
### 7. 触控 vs 鼠标交互差异
|
||||
|
||||
| 元素 | 鼠标场景 | 触控场景 |
|
||||
|------|----------|----------|
|
||||
| 按钮 hover | 显示 hover 效果 | 不显示 |
|
||||
| 下拉菜单 | hover 展开 | 点击展开 |
|
||||
| 表格排序 | 点击表头 | 点击表头 |
|
||||
| 卡片详情 | hover 显示操作 | 点击进入详情 |
|
||||
| 删除确认 | 可直接点击 | 必须确认 |
|
||||
|
||||
---
|
||||
|
||||
### 8. 无障碍规范
|
||||
|
||||
#### 焦点管理
|
||||
```vue
|
||||
<!-- 弹窗打开时聚焦到第一个输入框 -->
|
||||
<a-modal @afterOpen="focusFirstInput">
|
||||
<a-input ref="firstInput" />
|
||||
</a-modal>
|
||||
```
|
||||
|
||||
#### ARIA 标签
|
||||
```vue
|
||||
<a-button aria-label="删除该项">删除</a-button>
|
||||
|
||||
<a-table>
|
||||
<a-table-column title="操作" aria-label="操作列" />
|
||||
</a-table>
|
||||
```
|
||||
|
||||
#### 对比度
|
||||
- 正文文字:≥ 4.5:1
|
||||
- 大文字(≥18px):≥ 3:1
|
||||
- 图形和 UI 组件:≥ 3:1
|
||||
|
||||
---
|
||||
|
||||
## 组件开发模板
|
||||
|
||||
```vue
|
||||
<!-- 组件文件: src/components/{ComponentName}/index.vue -->
|
||||
|
||||
<template>
|
||||
<div class="{{ kebab-case componentName }}">
|
||||
<!-- 内容 -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* {{ ComponentName }} 组件
|
||||
*
|
||||
* 使用说明:
|
||||
* - 支持 xxx 功能
|
||||
* - 通过 v-model:xxx 双向绑定
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
/** 参数说明 */
|
||||
propName?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
propName: 'default',
|
||||
disabled: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'change', value: string): void
|
||||
}>()
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.{{ kebab-case componentName }} {
|
||||
/* 样式 */
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## 组件发布检查清单
|
||||
|
||||
- [ ] TypeScript 类型定义完整
|
||||
- [ ] Props 有默认值和类型
|
||||
- [ ] Emits 有 TypeScript 类型
|
||||
- [ ] 样式已 scoped
|
||||
- [ ] 有组件文档注释
|
||||
- [ ] 支持 v-model(如果适用)
|
||||
- [ ] 已在 index.ts 导出
|
||||
- [ ] 已在 CLAUDE.md 记录
|
||||
|
||||
---
|
||||
|
||||
## 组件扩展计划
|
||||
|
||||
### 选择器组件
|
||||
|
||||
#### UserSelect 用户选择器
|
||||
| 状态 | 功能 | 说明 |
|
||||
|------|------|------|
|
||||
| ✅ 已支持 | 本地搜索 | filter-option |
|
||||
| ✅ 已支持 | 多选模式 | multiple prop |
|
||||
| ✅ 已支持 | 禁用状态 | disabled prop |
|
||||
| 🔲 待开发 | 远程搜索 | remote-search prop |
|
||||
| 🔲 待开发 | 分组展示 | group prop |
|
||||
| 🔲 待开发 | 树形数据 | treeData prop |
|
||||
| 🔲 待开发 | 懒加载 | lazy-load prop |
|
||||
| 🔲 待开发 | 允许创建 | allow-create prop |
|
||||
|
||||
#### RoleSelect 角色选择器
|
||||
| 状态 | 功能 | 说明 |
|
||||
|------|------|------|
|
||||
| ✅ 已支持 | 多选模式 | multiple prop |
|
||||
| ✅ 已支持 | 禁用状态 | disabled prop |
|
||||
| 🔲 待开发 | 角色类型筛选 | type prop |
|
||||
| 🔲 待开发 | 树形角色 | treeData prop |
|
||||
| 🔲 待开发 | 懒加载 | lazy-load prop |
|
||||
|
||||
#### ProjectSelect 项目选择器
|
||||
| 状态 | 功能 | 说明 |
|
||||
|------|------|------|
|
||||
| ✅ 已支持 | 搜索功能 | show-search |
|
||||
| ✅ 已支持 | 单选模式 | - |
|
||||
| 🔲 待开发 | 多选模式 | multiple prop |
|
||||
| 🔲 待开发 | 区域筛选 | region prop |
|
||||
| 🔲 待开发 | 懒加载 | lazy-load prop |
|
||||
|
||||
#### StatusSelect 状态选择器
|
||||
| 状态 | 功能 | 说明 |
|
||||
|------|------|------|
|
||||
| ✅ 已支持 | 默认选项 | ACTIVE/LOCKED/DISABLED |
|
||||
| ✅ 已支持 | 自定义选项 | options prop |
|
||||
| 🔲 待开发 | 颜色配置 | color-map prop |
|
||||
| 🔲 待开发 | 禁用状态 | disabled prop |
|
||||
| 🔲 待开发 | 状态组 | groups prop |
|
||||
|
||||
---
|
||||
|
||||
### 表单组件
|
||||
|
||||
#### PhoneItem 手机号表单项
|
||||
| 状态 | 功能 | 说明 |
|
||||
|------|------|------|
|
||||
| ✅ 已支持 | 11位验证 | 1开头的数字 |
|
||||
| ✅ 已支持 | 最大长度 | maxlength=11 |
|
||||
| ✅ 已支持 | 标签配置 | label prop |
|
||||
| 🔲 待开发 | 前缀选择 | country-code prop |
|
||||
| 🔲 待开发 | 实时格式化 | format prop |
|
||||
| 🔲 待开发 | 自动补全 | autocomplete |
|
||||
|
||||
#### EmailItem 邮箱表单项
|
||||
| 状态 | 功能 | 说明 |
|
||||
|------|------|------|
|
||||
| ✅ 已支持 | 邮箱格式验证 | type: email |
|
||||
| ✅ 已支持 | 标签配置 | label prop |
|
||||
| 🔲 待开发 | 域名白名单 | allowed-domains |
|
||||
| 🔲 待开发 | 自动补全 | autocomplete |
|
||||
|
||||
#### PasswordItem 密码表单项
|
||||
| 状态 | 功能 | 说明 |
|
||||
|------|------|------|
|
||||
| ✅ 已支持 | 强度指示器 | show-strength |
|
||||
| ✅ 已支持 | 弱/中/强/很强 | 4级强度 |
|
||||
| ✅ 已支持 | 标签配置 | label prop |
|
||||
| 🔲 待开发 | 强度规则配置 | strength-rules |
|
||||
| 🔲 待开发 | 显示/隐藏切换 | visibility-toggle |
|
||||
| 🔲 待开发 | 密码生成器 | generator prop |
|
||||
|
||||
---
|
||||
|
||||
### 表格增强组件
|
||||
|
||||
#### Pagination 分页器
|
||||
| 状态 | 功能 | 说明 |
|
||||
|------|------|------|
|
||||
| ✅ 已支持 | 页码切换 | v-model:current |
|
||||
| ✅ 已支持 | 每页条数 | v-model:pageSize |
|
||||
| ✅ 已支持 | 总数显示 | show-total |
|
||||
| ✅ 已支持 | sizeChanger | show-size-changer |
|
||||
| 🔲 待开发 | 快速跳转 | show-quick-jumper |
|
||||
| 🔲 待开发 | 简洁模式 | simple prop |
|
||||
| 🔲 待开发 | 受控模式 | controlled |
|
||||
|
||||
#### TableToolbar 表格工具栏
|
||||
| 状态 | 功能 | 说明 |
|
||||
|------|------|------|
|
||||
| ✅ 已支持 | 刷新按钮 | showRefresh |
|
||||
| ✅ 已支持 | 导出按钮 | showExport |
|
||||
| ✅ 已支持 | 左侧插槽 | #left |
|
||||
| ✅ 已支持 | 右侧插槽 | #right |
|
||||
| 🔲 待开发 | 列配置 | column-setting |
|
||||
| 🔲 待开发 | 密度切换 | density-switcher |
|
||||
| 🔲 待开发 | 全屏切换 | fullscreen |
|
||||
|
||||
#### TableActions 表格行操作
|
||||
| 状态 | 功能 | 说明 |
|
||||
|------|------|------|
|
||||
| ✅ 已支持 | 编辑按钮 | showEdit |
|
||||
| ✅ 已支持 | 删除按钮 | showDelete |
|
||||
| ✅ 已支持 | 自定义操作 | actions prop |
|
||||
| ✅ 已支持 | 删除确认 | Popconfirm |
|
||||
| 🔲 待开发 | 更多操作 | more-actions dropdown |
|
||||
| 🔲 待开发 | 成功反馈 | success-message |
|
||||
| 🔲 待开发 | 二次确认配置 | confirm-title/description |
|
||||
|
||||
---
|
||||
|
||||
### 详情组件
|
||||
|
||||
#### DescriptionList 描述列表
|
||||
| 状态 | 功能 | 说明 |
|
||||
|------|------|------|
|
||||
| ✅ 已支持 | 多列布局 | column prop |
|
||||
| ✅ 已支持 | 边框样式 | bordered |
|
||||
| ✅ 已支持 | 尺寸配置 | size prop |
|
||||
| 🔲 待开发 | 响应式列数 | responsive prop |
|
||||
| 🔲 待开发 | 可编辑模式 | editable prop |
|
||||
| 🔲 待开发 | 折叠展示 | collapsible |
|
||||
| 🔲 待开发 | 标签-内容对齐 | labelAlign |
|
||||
|
||||
#### ProfileCard 个人资料卡片
|
||||
| 状态 | 功能 | 说明 |
|
||||
|------|------|------|
|
||||
| ✅ 已支持 | 头像展示 | avatar prop |
|
||||
| ✅ 已支持 | 名称展示 | name prop |
|
||||
| ✅ 已支持 | 角色展示 | role prop |
|
||||
| ✅ 已支持 | 扩展插槽 | #extra |
|
||||
| 🔲 待开发 | 头像尺寸 | size prop |
|
||||
| 🔲 待开发 | 联系方式 | contact prop |
|
||||
| 🔲 待开发 | 操作按钮 | actions prop |
|
||||
| 🔲 待开发 | 背景配置 | background prop |
|
||||
|
||||
---
|
||||
|
||||
### 状态组件
|
||||
|
||||
#### EmptyState 空状态
|
||||
| 状态 | 功能 | 说明 |
|
||||
|------|------|------|
|
||||
| ✅ 已支持 | 默认空状态 | InboxOutlined |
|
||||
| ✅ 已支持 | 搜索无结果 | SearchOutlined |
|
||||
| ✅ 已支持 | 无权限 | LockOutlined |
|
||||
| ✅ 已支持 | 操作按钮 | actionText |
|
||||
| 🔲 待开发 | 自定义图标 | icon slot |
|
||||
| 🔲 待开发 | 图片模式 | image prop |
|
||||
|
||||
#### ErrorState 错误状态
|
||||
| 状态 | 功能 | 说明 |
|
||||
|------|------|------|
|
||||
| ✅ 已支持 | 网络错误 | WifiOutlined |
|
||||
| ✅ 已支持 | 服务器错误 | CloudServerOutlined |
|
||||
| ✅ 已支持 | 404 | FileSearchOutlined |
|
||||
| ✅ 已支持 | 操作失败 | CloseCircleOutlined |
|
||||
| ✅ 已支持 | 重试按钮 | showRetry |
|
||||
| ✅ 已支持 | 返回按钮 | showBack |
|
||||
| 🔲 待开发 | 自定义图标 | icon slot |
|
||||
|
||||
#### LoadingState 加载状态
|
||||
| 状态 | 功能 | 说明 |
|
||||
|------|------|------|
|
||||
| ✅ 已支持 | 全屏加载 | fullscreen prop |
|
||||
| ✅ 已支持 | 局部加载 | - |
|
||||
| 🔲 待开发 | 骨架屏 | skeleton prop |
|
||||
| 🔲 待开发 | 进度显示 | progress prop |
|
||||
| 🔲 待开发 | 加载文案 | text prop |
|
||||
|
||||
---
|
||||
|
||||
### 扩展优先级说明
|
||||
|
||||
| 优先级 | 标记 | 说明 |
|
||||
|--------|------|------|
|
||||
| P0 | 🔴 紧急 | 影响核心流程,必须尽快实现 |
|
||||
| P1 | 🟡 重要 | 提升效率,尽快安排 |
|
||||
| P2 | 🔵 一般 | 锦上添花,按需实现 |
|
||||
| P3 | ⚪ 考察 | 考虑中,暂不实现 |
|
||||
|
||||
---
|
||||
|
||||
### 升级流程
|
||||
|
||||
1. **需求提出** → 在对应组件的扩展计划中新增条目
|
||||
2. **评审确认** → 确认是否真的需要,还是抽象过度
|
||||
3. **实现开发** → 参考组件开发模板
|
||||
4. **文档更新** → 更新扩展计划状态 + 更新使用示例
|
||||
5. **发布记录** → 在 CHANGELOG 中记录
|
||||
|
|
@ -0,0 +1,360 @@
|
|||
# Ether 智慧物业管理平台 - 设计规范
|
||||
|
||||
## 目录
|
||||
1. [设计原则](#设计原则)
|
||||
2. [色彩系统](#色彩系统)
|
||||
3. [字体系统](#字体系统)
|
||||
4. [间距系统](#间距系统)
|
||||
5. [圆角系统](#圆角系统)
|
||||
6. [阴影系统](#阴影系统)
|
||||
7. [页面布局](#页面布局)
|
||||
8. [PC端组件规范](#pc端组件规范)
|
||||
9. [移动端组件规范](#移动端组件规范)
|
||||
10. [登录页设计规范](#登录页设计规范)
|
||||
|
||||
---
|
||||
|
||||
## 设计原则
|
||||
|
||||
### 核心原则
|
||||
1. **专业严谨**:适合物业管理行业的正式感
|
||||
2. **效率优先**:减少操作步骤,优化工作流程
|
||||
3. **一致性**:统一组件样式和交互模式
|
||||
4. **可访问性**:考虑长时间使用的舒适度
|
||||
|
||||
### 视觉原则
|
||||
- 简洁清晰,层次分明
|
||||
- 信息密度适中
|
||||
- 交互不打断工作流
|
||||
|
||||
---
|
||||
|
||||
## 色彩系统
|
||||
|
||||
### 主色
|
||||
| 名称 | 色值 | 用途 |
|
||||
|------|------|------|
|
||||
| 主色 | `#1890FF` | 按钮、链接、图标高亮 |
|
||||
| 主色深 | `#096DD9` | 按钮 hover、渐变 |
|
||||
| 主色浅 | `#40A9FF` | hover 状态 |
|
||||
|
||||
### 状态色
|
||||
| 名称 | 色值 | 用途 |
|
||||
|------|------|------|
|
||||
| 成功 | `#52C41A` | 成功状态、正常状态 |
|
||||
| 警告 | `#FAAD14` | 警告状态 |
|
||||
| 错误 | `#F5222D` | 错误状态、删除按钮 |
|
||||
| 锁定 | `#FF4D4F` | 数字徽章 |
|
||||
|
||||
### 中性色
|
||||
| 名称 | 色值 | 用途 |
|
||||
|------|------|------|
|
||||
| 标题文字 | `#1A1A1A` | 一级标题 |
|
||||
| 正文文字 | `#262626` | 二级标题、正文 |
|
||||
| 次要文字 | `#595959` | 辅助说明 |
|
||||
| 占位文字 | `#8C8C8C` | 标签、占位符 |
|
||||
| 禁用文字 | `#BFBFBF` | 禁用状态 |
|
||||
| 边框 | `#E8E8E8` | 卡片边框、分隔线 |
|
||||
| 背景灰 | `#F0F0F0` | 表格斑马线 |
|
||||
| 页面背景 | `#F5F7FA` | 页面背景 |
|
||||
| 卡片背景 | `#FFFFFF` | 卡片、表格 |
|
||||
|
||||
---
|
||||
|
||||
## 字体系统
|
||||
|
||||
### PC端
|
||||
| 名称 | 字号 | 字重 | 用途 |
|
||||
|------|------|------|------|
|
||||
| 页面标题 | 20px | 500 | 页面大标题 |
|
||||
| 卡片标题 | 16px | 500 | 卡片标题 |
|
||||
| 正文 | 14px | 400 | 正文内容 |
|
||||
| 辅助文字 | 13px | 400 | 辅助说明 |
|
||||
| 标签 | 12px | 400 | 小标签、时间 |
|
||||
|
||||
### 移动端
|
||||
| 名称 | 字号 | 字重 | 用途 |
|
||||
|------|------|------|------|
|
||||
| 页面标题 | 18px | 600 | 页面大标题 |
|
||||
| 卡片标题 | 15px | 500 | 卡片标题 |
|
||||
| 正文 | 14px | 400 | 正文内容 |
|
||||
| 辅助文字 | 12px | 400 | 辅助说明 |
|
||||
| 标签 | 11px | 400 | 小标签 |
|
||||
|
||||
### 字体规范
|
||||
- 主字体:系统默认字体栈 `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial`
|
||||
- 英文字体:`'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto`
|
||||
- 行高:1.5 倍字号
|
||||
|
||||
---
|
||||
|
||||
## 间距系统
|
||||
|
||||
### PC端
|
||||
| 名称 | 间距 | 用途 |
|
||||
|------|------|------|
|
||||
| xs | 4px | 紧凑间距 |
|
||||
| sm | 8px | 元素内间距 |
|
||||
| md | 16px | 组件间距 |
|
||||
| lg | 24px | 区块间距 |
|
||||
| xl | 32px | 大区块间距 |
|
||||
| xxl | 48px | 页面边距 |
|
||||
|
||||
### 移动端
|
||||
| 名称 | 间距 | 用途 |
|
||||
|------|------|------|
|
||||
| xs | 4px | 紧凑间距 |
|
||||
| sm | 8px | 元素内间距 |
|
||||
| md | 12px | 组件间距 |
|
||||
| lg | 16px | 区块间距 |
|
||||
| xl | 24px | 大区块间距 |
|
||||
|
||||
---
|
||||
|
||||
## 圆角系统
|
||||
|
||||
| 名称 | 大小 | 用途 |
|
||||
|------|------|------|
|
||||
| 小圆角 | 4px | 按钮、输入框、标签 |
|
||||
| 中圆角 | 8px | 卡片、面板 |
|
||||
| 大圆角 | 12px | 模态框、抽屉 |
|
||||
| 全圆角 | 50% | 头像、徽章 |
|
||||
|
||||
---
|
||||
|
||||
## 阴影系统
|
||||
|
||||
### PC端
|
||||
| 名称 | 样式 | 用途 |
|
||||
|------|------|------|
|
||||
| 无阴影 | `none` | 默认扁平 |
|
||||
| 悬浮阴影 | `0 4px 12px rgba(24, 144, 255, 0.15)` | 卡片 hover |
|
||||
| 登录卡片 | `0 4px 24px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04)` | 登录卡片 |
|
||||
|
||||
### 移动端
|
||||
| 名称 | 样式 | 用途 |
|
||||
|------|------|------|
|
||||
| 无阴影 | `none` | 默认扁平 |
|
||||
| 轻阴影 | `0 2px 8px rgba(0, 0, 0, 0.08)` | 卡片悬浮 |
|
||||
|
||||
---
|
||||
|
||||
## 页面布局
|
||||
|
||||
### PC端 Layout
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Sidebar (200px) │ Header (64px) │
|
||||
│ - Logo ├────────────────────┤
|
||||
│ - Navigation │ │
|
||||
│ │ Content Area │
|
||||
│ │ - Page Header │
|
||||
│ │ - Filter Bar │
|
||||
│ │ - Table/Card │
|
||||
│ │ │
|
||||
└─────────────────────┴────────────────────┘
|
||||
```
|
||||
|
||||
### 移动端 Layout
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ Header (44px) │
|
||||
├─────────────────────┤
|
||||
│ │
|
||||
│ Content Area │
|
||||
│ - Full Width │
|
||||
│ - Pull to Refresh │
|
||||
│ │
|
||||
├─────────────────────┤
|
||||
│ Tab Bar (可选) │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PC端组件规范
|
||||
|
||||
### 页面结构
|
||||
```html
|
||||
<div class="page-container"> <!-- 页面容器 padding: 24px -->
|
||||
<div class="page-header"> <!-- 标题区 margin-bottom: 24px -->
|
||||
<h2 class="page-title">标题</h2> <!-- 20px, font-weight: 500 -->
|
||||
<div class="page-header-actions"> <!-- 操作按钮区 -->
|
||||
<Button>新增</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar"> <!-- 筛选区 padding: 16px, background: #fafafa -->
|
||||
<!-- 筛选控件 -->
|
||||
</div>
|
||||
|
||||
<div class="table-card"> <!-- 表格区 padding: 24px, border: 1px solid #e8e8e8 -->
|
||||
<!-- 表格内容 -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 页面标题区
|
||||
- `.page-header`: flex, space-between, align-items: center, margin-bottom: 24px
|
||||
- `.page-title`: font-size: 20px, font-weight: 500, color: #262626
|
||||
- `.page-header-actions`: flex, gap: 12px
|
||||
|
||||
### 筛选栏
|
||||
- `.filter-bar`: padding: 16px, background: #fafafa, border-radius: 8px
|
||||
- 使用 `<Space>` 组件,wrap: true
|
||||
|
||||
### 卡片
|
||||
- `.card`: padding: 24px, background: #fff, border: 1px solid #e8e8e8, border-radius: 8px
|
||||
- `.card-title`: font-size: 16px, font-weight: 500, color: #262626, margin-bottom: 20px
|
||||
|
||||
### 表格卡片
|
||||
- `.table-card`: padding: 24px, background: #fff, border: 1px solid #e8e8e8, border-radius: 8px
|
||||
|
||||
### 统计卡片
|
||||
- `.stats-row`: display: grid, grid-template-columns: repeat(4, 1fr), gap: 24px
|
||||
- `.stat-card`: padding: 24px, background: #fff, border: 1px solid #e8e8e8, border-radius: 8px
|
||||
|
||||
### 抽屉
|
||||
- 宽度:默认 480px,宽版 640px
|
||||
- 放置:右侧
|
||||
- 头部:56px 高度,底部边框
|
||||
- 底部:56px 高度,顶部边框,按钮右对齐
|
||||
|
||||
### 按钮
|
||||
- Primary: background: #1890FF, color: #fff, border-radius: 4px
|
||||
- Default: background: #fff, border: 1px solid #d9d9d9
|
||||
- Text: background: transparent, color: #595959
|
||||
- Danger: color: #FF4D4F
|
||||
- 高度:32px(默认)、40px(大按钮)、24px(小按钮)
|
||||
- 图标按钮:使用 `<Button type="link">`
|
||||
|
||||
### 标签
|
||||
- `.status-tag`: padding: 2px 8px, border-radius: 4px, font-size: 12px
|
||||
- 成功:background: #f6ffed, color: #52c41a
|
||||
- 警告:background: #fffbe6, color: #faad14
|
||||
- 错误:background: #fff1f0, color: #f5222d
|
||||
- 禁用:background: #f5f5f5, color: #8c8c8c
|
||||
|
||||
### 列表项
|
||||
- `.list-container`: display: flex, flex-direction: column, gap: 12px
|
||||
- `.list-item`: display: flex, justify-content: space-between, padding: 12px 0, border-bottom: 1px solid #f0f0f0
|
||||
|
||||
---
|
||||
|
||||
## 移动端组件规范
|
||||
|
||||
### 页面结构
|
||||
```html
|
||||
<div class="mobile-page"> <!-- 页面容器 padding: 12px -->
|
||||
<div class="mobile-header"> <!-- 标题区 -->
|
||||
<h2 class="mobile-title">标题</h2> <!-- 18px, font-weight: 600 -->
|
||||
</div>
|
||||
|
||||
<div class="mobile-card"> <!-- 卡片区 -->
|
||||
<!-- 内容 -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 页面标题区
|
||||
- `.mobile-header`: margin-bottom: 16px
|
||||
- `.mobile-title`: font-size: 18px, font-weight: 600, color: #1a1a1a
|
||||
|
||||
### 卡片
|
||||
- `.mobile-card`: padding: 16px, background: #fff, border-radius: 8px, margin-bottom: 12px
|
||||
- `.mobile-card-title`: font-size: 15px, font-weight: 500, margin-bottom: 12px
|
||||
|
||||
### 列表
|
||||
- `.mobile-list-item`: padding: 12px 0, border-bottom: 1px solid #f0f0f0
|
||||
- 单行列表项高度 ≥ 44px(触控目标)
|
||||
|
||||
### 按钮
|
||||
- Primary: width: 100%, height: 44px, border-radius: 8px
|
||||
- 次要按钮: width: 100%, height: 40px, border-radius: 8px
|
||||
|
||||
### 表单
|
||||
- 输入框高度:44px(触控友好)
|
||||
- 标签字号:14px
|
||||
- 错误提示:红色,12px
|
||||
|
||||
### 抽屉(全屏抽屉)
|
||||
- 移动端建议使用全屏抽屉
|
||||
- 顶部固定操作栏
|
||||
- 内容区域可滚动
|
||||
|
||||
### 响应式断点
|
||||
- 手机:< 576px
|
||||
- 平板:576px - 992px
|
||||
- 桌面:≥ 992px
|
||||
|
||||
---
|
||||
|
||||
## 登录页设计规范
|
||||
|
||||
### 布局
|
||||
- 全屏居中布局:`min-height: 100vh`
|
||||
- 登录卡片居中:`position: relative, flex, center, align-items: center`
|
||||
|
||||
### 登录卡片
|
||||
- 宽度:420px(PC)、100% - 32px(移动端)
|
||||
- 内边距:48px 40px(PC)、32px 24px(移动端)
|
||||
- 圆角:16px
|
||||
- 阴影:`0 4px 24px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04)`
|
||||
- 悬浮效果:`0 8px 32px rgba(24, 144, 255, 0.12), 0 2px 4px rgba(0, 0, 0, 0.04)`
|
||||
|
||||
### 背景
|
||||
- 渐变:`linear-gradient(135deg, #f5f7fa 0%, #e4e8ec 100%)`
|
||||
- 点状纹理:`radial-gradient(circle, rgba(24, 144, 255, 0.15) 1px, transparent 1px)`
|
||||
- 网格纹理:`linear-gradient(rgba(24, 144, 255, 0.08) 1px, transparent 1px)`
|
||||
- 大光晕:`radial-gradient(ellipse 80% 50% at 50% 120%, rgba(24, 144, 255, 0.25) 0%, transparent 60%)`
|
||||
|
||||
### Logo 区域
|
||||
- Logo:48px × 48px,12px 圆角
|
||||
- 标题:24px,font-weight: 600,letter-spacing: 0.5px
|
||||
- 副标题:14px,color: #8c8c8c
|
||||
|
||||
### 表单
|
||||
- 登录按钮:高度 44px,字号 16px,letter-spacing: 2px
|
||||
- 按钮渐变:`linear-gradient(135deg, #1890ff 0%, #096dd9 100%)`
|
||||
- 按钮阴影:`0 2px 8px rgba(24, 144, 255, 0.3)`
|
||||
- 输入框圆角:8px
|
||||
- 输入框图标:color: #bfbfbf,focus 时变为 #1890ff
|
||||
|
||||
### 表单额外项
|
||||
- 记住登录 + 忘记密码:flex, space-between
|
||||
- 忘记密码链接:color: #1890ff,hover 时 #40a9ff
|
||||
|
||||
### 底部提示
|
||||
- 居中,12px,color: #bfbfbf
|
||||
- 距离表单:32px
|
||||
|
||||
---
|
||||
|
||||
## 组件库规划
|
||||
|
||||
### 基础组件
|
||||
| 组件 | 说明 |
|
||||
|------|------|
|
||||
| PageHeader | 页面标题区 |
|
||||
| FilterBar | 筛选栏 |
|
||||
| TableCard | 表格卡片 |
|
||||
| StatCard | 统计卡片 |
|
||||
| ListCard | 列表卡片 |
|
||||
| DrawerForm | 抽屉表单 |
|
||||
| StatusTag | 状态标签 |
|
||||
| ConfirmPopover | 确认气泡 |
|
||||
|
||||
### 业务组件
|
||||
| 组件 | 说明 |
|
||||
|------|------|
|
||||
| UserSelect | 用户选择器 |
|
||||
| RoleSelect | 角色选择器 |
|
||||
| ProjectSelect | 项目选择器 |
|
||||
| DateRangePicker | 日期范围选择 |
|
||||
|
||||
### 页面模板
|
||||
| 模板 | 说明 |
|
||||
|------|------|
|
||||
| ListPage | 列表页模板 |
|
||||
| DashboardPage | 仪表盘模板 |
|
||||
| FormPage | 表单页模板 |
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
/* 全局通用样式 - Ether Admin Design System */
|
||||
|
||||
/* 页面容器 */
|
||||
.page-container {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* 页面标题区 */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 筛选栏 */
|
||||
.filter-bar {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* 卡片容器 */
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
|
||||
/* 表格卡片 */
|
||||
.table-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* 两栏布局 */
|
||||
.two-column {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* 状态标签颜色 */
|
||||
.status-success {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: #f5222d;
|
||||
}
|
||||
|
||||
.status-default {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
/* 页面通用样式 - Ether Admin */
|
||||
|
||||
/* ========== 页面容器 ========== */
|
||||
.page-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* ========== 页面标题区 ========== */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ========== 筛选栏 ========== */
|
||||
.filter-bar {
|
||||
margin-bottom: 12px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.filter-bar :deep(.ant-space) {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ========== 卡片容器 ========== */
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
margin: 0 0 12px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.card-title .anticon {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
/* ========== 表格卡片 ========== */
|
||||
.table-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* ========== 两栏布局 ========== */
|
||||
.two-column {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.two-column-equal {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ========== 内容行间距 ========== */
|
||||
.content-row {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.content-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ========== 列表样式 ========== */
|
||||
.list-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.list-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.list-item-title {
|
||||
font-size: 14px;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.list-item-meta {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
/* ========== 统计卡片 ========== */
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #e6f7ff;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.stat-change {
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.stat-change.up {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.stat-change.down {
|
||||
color: #f5222d;
|
||||
}
|
||||
|
||||
/* ========== 快捷入口 ========== */
|
||||
.action-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.action-item:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 18px;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.action-title {
|
||||
font-size: 14px;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
/* ========== 响应式 ========== */
|
||||
@media (max-width: 992px) {
|
||||
.stats-row {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.two-column,
|
||||
.two-column-equal {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.page-container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.action-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.card,
|
||||
.table-card {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'ax
|
|||
import type { ApiResponse } from '@/types'
|
||||
|
||||
const request: AxiosInstance = axios.create({
|
||||
baseURL: '/api',
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api',
|
||||
timeout: 10000
|
||||
})
|
||||
|
||||
|
|
@ -25,7 +25,7 @@ request.interceptors.response.use(
|
|||
if (res.code !== 200) {
|
||||
return Promise.reject(new Error(res.message || 'Error'))
|
||||
}
|
||||
return res
|
||||
return response
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
if (error.response?.status === 401) {
|
||||
|
|
|
|||
|
|
@ -1,23 +1,189 @@
|
|||
<script setup lang="ts">
|
||||
import { Card, Statistic, Row, Col } from 'ant-design-vue'
|
||||
import {
|
||||
ApartmentOutlined,
|
||||
ArrowDownOutlined,
|
||||
ArrowUpOutlined,
|
||||
BarChartOutlined,
|
||||
BellOutlined,
|
||||
ProjectOutlined,
|
||||
SettingOutlined,
|
||||
SoundOutlined,
|
||||
TeamOutlined,
|
||||
ThunderboltOutlined,
|
||||
ToolOutlined,
|
||||
UnorderedListOutlined,
|
||||
UserOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { Col, Row } from 'ant-design-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const statistics = [
|
||||
{ title: '用户总数', value: 0 },
|
||||
{ title: '角色总数', value: 0 },
|
||||
{ title: '项目总数', value: 0 },
|
||||
{ title: '空间节点', value: 0 }
|
||||
const router = useRouter()
|
||||
|
||||
const stats = [
|
||||
{ label: '用户总数', value: '1,286', change: '+12.5%', up: true, icon: UserOutlined },
|
||||
{ label: '角色总数', value: '8', change: '-', up: true, icon: TeamOutlined },
|
||||
{ label: '项目总数', value: '24', change: '+8.3%', up: true, icon: ProjectOutlined },
|
||||
{ label: '空间节点', value: '156', change: '-2.1%', up: false, icon: ApartmentOutlined }
|
||||
]
|
||||
|
||||
const todos = [
|
||||
{ title: '待处理工单', count: 12 },
|
||||
{ title: '待审核报修', count: 5 },
|
||||
{ title: '待回复投诉', count: 3 },
|
||||
{ title: '待确认缴费', count: 8 },
|
||||
{ title: '待分配任务', count: 2 }
|
||||
]
|
||||
|
||||
const actions = [
|
||||
{ title: '用户管理', path: '/users', icon: UserOutlined },
|
||||
{ title: '工单处理', path: '/workorders', icon: ToolOutlined },
|
||||
{ title: '公告发布', path: '/notices', icon: BellOutlined },
|
||||
{ title: '系统设置', path: '/settings', icon: SettingOutlined }
|
||||
]
|
||||
|
||||
const notices = [
|
||||
{ title: '系统将于今晚进行例行维护', time: '2小时前' },
|
||||
{ title: '新版本功能上线通知', time: '昨天' },
|
||||
{ title: '物业费缴纳提醒', time: '3天前' }
|
||||
]
|
||||
|
||||
const chartData = [65, 78, 52, 91, 68, 85, 73]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h2>仪表盘</h2>
|
||||
<Row :gutter="16" style="margin-top: 24px">
|
||||
<Col :span="6" v-for="stat in statistics" :key="stat.title">
|
||||
<Card>
|
||||
<Statistic :title="stat.title" :value="stat.value" />
|
||||
</Card>
|
||||
<div class="page-container">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">仪表盘</h2>
|
||||
<span class="header-date">{{ new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' }) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-row">
|
||||
<div v-for="s in stats" :key="s.label" class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<component :is="s.icon" />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">{{ s.label }}</div>
|
||||
<div class="stat-value">{{ s.value }}</div>
|
||||
<div v-if="s.change !== '-'" class="stat-change" :class="s.up ? 'up' : 'down'">
|
||||
<component :is="s.up ? ArrowUpOutlined : ArrowDownOutlined" />
|
||||
{{ s.change }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图表 + 待办 -->
|
||||
<Row :gutter="24" class="content-row">
|
||||
<Col :xs="24" :lg="16">
|
||||
<div class="card">
|
||||
<h3 class="card-title">
|
||||
<BarChartOutlined /> 数据趋势
|
||||
</h3>
|
||||
<div class="chart">
|
||||
<div v-for="(v, i) in chartData" :key="i" class="bar-item">
|
||||
<div class="bar" :style="{ height: v + '%' }"></div>
|
||||
<span class="bar-label">周{{ ['一', '二', '三', '四', '五', '六', '日'][i] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Col :xs="24" :lg="8">
|
||||
<div class="card">
|
||||
<h3 class="card-title">
|
||||
<UnorderedListOutlined /> 待办任务
|
||||
</h3>
|
||||
<div class="list-container">
|
||||
<div v-for="t in todos" :key="t.title" class="list-item">
|
||||
<span class="list-item-title">{{ t.title }}</span>
|
||||
<span class="todo-count">{{ t.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 快捷入口 + 公告 -->
|
||||
<Row :gutter="24" class="content-row">
|
||||
<Col :xs="24" :lg="12">
|
||||
<div class="card">
|
||||
<h3 class="card-title">
|
||||
<ThunderboltOutlined /> 快捷入口
|
||||
</h3>
|
||||
<div class="action-list">
|
||||
<div v-for="a in actions" :key="a.title" class="action-item" @click="router.push(a.path)">
|
||||
<component :is="a.icon" class="action-icon" />
|
||||
<span class="action-title">{{ a.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Col :xs="24" :lg="12">
|
||||
<div class="card">
|
||||
<h3 class="card-title">
|
||||
<SoundOutlined /> 系统公告
|
||||
</h3>
|
||||
<div class="list-container">
|
||||
<div v-for="n in notices" :key="n.title" class="list-item">
|
||||
<span class="list-item-title">{{ n.title }}</span>
|
||||
<span class="list-item-meta">{{ n.time }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 仅保留 Dashboard 特有样式 */
|
||||
.header-date {
|
||||
color: #8c8c8c;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chart {
|
||||
height: 200px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-around;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.bar-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.bar {
|
||||
width: 32px;
|
||||
background: #1890ff;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.todo-count {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #ff4d4f;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,30 +1,34 @@
|
|||
<script setup lang="ts">
|
||||
import { h } from 'vue'
|
||||
import { RouterView, useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { Layout, Menu, Button } from 'ant-design-vue'
|
||||
import type { MenuProps } from 'ant-design-vue'
|
||||
import {
|
||||
DashboardOutlined,
|
||||
UserOutlined,
|
||||
TeamOutlined,
|
||||
AppstoreOutlined,
|
||||
BuildingOutlined,
|
||||
LogoutOutlined
|
||||
BuildOutlined,
|
||||
LogoutOutlined,
|
||||
AuditOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
|
||||
const { Header, Sider, Content } = Layout
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const menuItems = [
|
||||
{ key: '/dashboard', icon: DashboardOutlined, label: '仪表盘' },
|
||||
{ key: '/system/users', icon: UserOutlined, label: '用户管理' },
|
||||
{ key: '/system/roles', icon: TeamOutlined, label: '角色管理' },
|
||||
{ key: '/system/permissions', icon: AppstoreOutlined, label: '权限管理' },
|
||||
{ key: '/project/list', icon: BuildingOutlined, label: '项目管理' }
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{ key: '/dashboard', icon: () => h(DashboardOutlined), label: '仪表盘' },
|
||||
{ key: '/system/users', icon: () => h(UserOutlined), label: '用户管理' },
|
||||
{ key: '/system/roles', icon: () => h(TeamOutlined), label: '角色管理' },
|
||||
{ key: '/system/permissions', icon: () => h(AppstoreOutlined), label: '权限管理' },
|
||||
{ key: '/system/audit', icon: () => h(AuditOutlined), label: '审计日志' },
|
||||
{ key: '/project/list', icon: () => h(BuildOutlined), label: '项目管理' }
|
||||
]
|
||||
|
||||
const handleMenuClick = ({ key }: { key: string }) => {
|
||||
router.push(key)
|
||||
const handleMenuClick = (e: any) => {
|
||||
router.push(e.key)
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
|
|
@ -51,7 +55,7 @@ const handleLogout = async () => {
|
|||
<LogoutOutlined /> 退出
|
||||
</Button>
|
||||
</Header>
|
||||
<Content style="margin: 16px; padding: 24px; background: #fff; min-height: 280px">
|
||||
<Content style="margin: 16px; background: #fff; min-height: 280px">
|
||||
<RouterView />
|
||||
</Content>
|
||||
</Layout>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const router = useRouter()
|
||||
|
|
@ -9,7 +10,8 @@ const userStore = useUserStore()
|
|||
|
||||
const formState = reactive({
|
||||
username: '',
|
||||
password: ''
|
||||
password: '',
|
||||
remember: true
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
|
|
@ -22,11 +24,15 @@ const handleSubmit = async () => {
|
|||
|
||||
loading.value = true
|
||||
try {
|
||||
console.log('开始登录请求...')
|
||||
await userStore.login(formState)
|
||||
console.log('登录成功')
|
||||
message.success('登录成功')
|
||||
router.push('/')
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '登录失败')
|
||||
window.location.href = '/'
|
||||
} catch (error: unknown) {
|
||||
console.error('登录失败:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : '登录失败,请检查用户名和密码'
|
||||
message.error(errorMessage)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
|
@ -35,54 +41,216 @@ const handleSubmit = async () => {
|
|||
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-box">
|
||||
<h1 class="title">Ether 物业管理系统</h1>
|
||||
<a-form :model="formState" @finish="handleSubmit" layout="vertical">
|
||||
<div class="login-background">
|
||||
<div class="login-pattern"></div>
|
||||
</div>
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<div class="logo">
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="48" height="48" rx="12" fill="#1890FF"/>
|
||||
<path d="M14 24L24 14L34 24L24 34L14 24Z" fill="white" fill-opacity="0.9"/>
|
||||
<path d="M24 14V34M14 24L34 24" stroke="white" stroke-opacity="0.6" stroke-width="2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="title">Ether 物业管理系统</h1>
|
||||
<p class="subtitle">智慧物业 · 便捷生活</p>
|
||||
</div>
|
||||
|
||||
<a-form :model="formState" @finish="handleSubmit" layout="vertical" class="login-form">
|
||||
<a-form-item label="用户名" name="username">
|
||||
<a-input
|
||||
v-model:value="formState.username"
|
||||
placeholder="请输入用户名"
|
||||
size="large"
|
||||
/>
|
||||
allow-clear
|
||||
>
|
||||
<template #prefix>
|
||||
<UserOutlined class="input-icon" />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="密码" name="password">
|
||||
<a-input-password
|
||||
v-model:value="formState.password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
/>
|
||||
>
|
||||
<template #prefix>
|
||||
<LockOutlined class="input-icon" />
|
||||
</template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" html-type="submit" :loading="loading" block size="large">
|
||||
登录
|
||||
<div class="form-extras">
|
||||
<a-checkbox v-model:checked="formState.remember">记住登录</a-checkbox>
|
||||
<a class="forgot-link">忘记密码?</a>
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" html-type="submit" :loading="loading" block size="large" class="login-btn">
|
||||
登 录
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<div class="login-footer">
|
||||
<span>推荐使用 Chrome 或 Edge 浏览器</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #e4e8ec 100%);
|
||||
}
|
||||
|
||||
.login-box {
|
||||
width: 400px;
|
||||
padding: 40px;
|
||||
.login-background {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-pattern {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
/* 点状纹理 */
|
||||
radial-gradient(circle, rgba(24, 144, 255, 0.15) 1px, transparent 1px),
|
||||
/* 网格纹理 */
|
||||
linear-gradient(rgba(24, 144, 255, 0.08) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(24, 144, 255, 0.08) 1px, transparent 1px),
|
||||
/* 大光晕效果 */
|
||||
radial-gradient(ellipse 80% 50% at 50% 120%, rgba(24, 144, 255, 0.25) 0%, transparent 60%),
|
||||
radial-gradient(circle at 10% 20%, rgba(24, 144, 255, 0.2) 0%, transparent 40%),
|
||||
radial-gradient(circle at 90% 80%, rgba(24, 144, 255, 0.15) 0%, transparent 45%);
|
||||
background-size: 24px 24px, 40px 40px, 40px 40px, 100% 100%, 100% 100%, 100% 100%;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
position: relative;
|
||||
width: 420px;
|
||||
padding: 48px 40px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 16px;
|
||||
box-shadow:
|
||||
0 4px 24px rgba(0, 0, 0, 0.06),
|
||||
0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.login-card:hover {
|
||||
box-shadow:
|
||||
0 8px 32px rgba(24, 144, 255, 0.12),
|
||||
0 2px 4px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: inline-flex;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
margin: 0 0 8px;
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
color: #bfbfbf;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
:deep(.ant-input-affix-wrapper:hover .input-icon),
|
||||
:deep(.ant-input:focus + .input-icon) {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.form-extras {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.forgot-link {
|
||||
font-size: 14px;
|
||||
color: #1890ff;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.forgot-link:hover {
|
||||
color: #40a9ff;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
height: 44px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 2px;
|
||||
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.3);
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
background: linear-gradient(135deg, #40a9ff 0%, #1890ff 100%);
|
||||
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.login-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 32px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #bfbfbf;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item-label > label) {
|
||||
font-size: 14px;
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
:deep(.ant-input-affix-wrapper) {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
:deep(.ant-input-affix-wrapper:focus),
|
||||
:deep(.ant-input-affix-wrapper-focused) {
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
:deep(.ant-checkbox-wrapper) {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,26 +1,52 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Table, Button, Modal, Form, Input, Select, message } from 'ant-design-vue'
|
||||
import { Table, Button, Drawer, Form, Input, Select, Space, Popconfirm, message } from 'ant-design-vue'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import { getProjects, createProject, updateProject, deleteProject } from '@/api/project'
|
||||
import type { Project } from '@/types'
|
||||
|
||||
interface ProjectFormData {
|
||||
id?: string
|
||||
code?: string
|
||||
name?: string
|
||||
description?: string
|
||||
address?: string
|
||||
province?: string
|
||||
city?: string
|
||||
district?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '项目编码', dataIndex: 'code', key: 'code' },
|
||||
{ title: '项目名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '地址', dataIndex: 'address', key: 'address' },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status' },
|
||||
{ title: '操作', key: 'action', width: 150 }
|
||||
{ title: '项目编码', dataIndex: 'code', key: 'code', width: 120 },
|
||||
{ title: '项目名称', dataIndex: 'name', key: 'name', width: 200 },
|
||||
{ title: '地址', dataIndex: 'address', key: 'address', ellipsis: true },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 80 },
|
||||
{ title: '操作', key: 'action', width: 120, fixed: 'right' }
|
||||
]
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const loading = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const editingProject = ref<Project | null>(null)
|
||||
const [form] = Form.useForm()
|
||||
const drawerVisible = ref(false)
|
||||
const drawerTitle = ref('')
|
||||
const formRef = ref()
|
||||
const submitting = ref(false)
|
||||
|
||||
const formState = ref<ProjectFormData>({
|
||||
id: '',
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
address: '',
|
||||
province: '',
|
||||
city: '',
|
||||
district: '',
|
||||
status: 'ACTIVE'
|
||||
})
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'ACTIVE', label: '正常' },
|
||||
{ value: 'DISABLED', label: '禁用' }
|
||||
{ value: 'ACTIVE', label: '正常', color: 'success' },
|
||||
{ value: 'DISABLED', label: '禁用', color: 'error' }
|
||||
]
|
||||
|
||||
const fetchProjects = async () => {
|
||||
|
|
@ -28,7 +54,7 @@ const fetchProjects = async () => {
|
|||
try {
|
||||
const res = await getProjects()
|
||||
projects.value = res.data
|
||||
} catch (error) {
|
||||
} catch {
|
||||
message.error('获取项目列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
|
@ -36,15 +62,35 @@ const fetchProjects = async () => {
|
|||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
editingProject.value = null
|
||||
form.resetFields()
|
||||
modalVisible.value = true
|
||||
drawerTitle.value = '新增项目'
|
||||
formState.value = {
|
||||
id: '',
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
address: '',
|
||||
province: '',
|
||||
city: '',
|
||||
district: '',
|
||||
status: 'ACTIVE'
|
||||
}
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (record: Project) => {
|
||||
editingProject.value = record
|
||||
form.setFieldsValue(record)
|
||||
modalVisible.value = true
|
||||
drawerTitle.value = '编辑项目'
|
||||
formState.value = {
|
||||
id: record.id,
|
||||
code: record.code,
|
||||
name: record.name,
|
||||
description: record.description || '',
|
||||
address: record.address || '',
|
||||
province: record.province || '',
|
||||
city: record.city || '',
|
||||
district: record.district || '',
|
||||
status: record.status
|
||||
}
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
|
|
@ -52,87 +98,154 @@ const handleDelete = async (id: string) => {
|
|||
await deleteProject(id)
|
||||
message.success('删除成功')
|
||||
fetchProjects()
|
||||
} catch (error) {
|
||||
} catch {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validate()
|
||||
if (editingProject.value) {
|
||||
await updateProject(editingProject.value.id, values)
|
||||
await formRef.value.validate()
|
||||
submitting.value = true
|
||||
|
||||
if (formState.value.id) {
|
||||
await updateProject(formState.value.id, formState.value)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await createProject(values)
|
||||
await createProject(formState.value)
|
||||
message.success('创建成功')
|
||||
}
|
||||
modalVisible.value = false
|
||||
drawerVisible.value = false
|
||||
fetchProjects()
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.errorFields) return
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
formRef.value?.resetFields()
|
||||
drawerVisible.value = false
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
ACTIVE: 'success',
|
||||
DISABLED: 'error'
|
||||
}
|
||||
return map[status] || 'default'
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
ACTIVE: '正常',
|
||||
DISABLED: '禁用'
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
onMounted(fetchProjects)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div style="margin-bottom: 16px">
|
||||
<Button type="primary" @click="handleAdd">新增项目</Button>
|
||||
<div class="page-container">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">项目管理</h2>
|
||||
<div class="page-header-actions">
|
||||
<Button type="primary" @click="handleAdd">
|
||||
<PlusOutlined /> 新增项目
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data-source="projects"
|
||||
:loading="loading"
|
||||
:row-key="(record: Project) => record.id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 'ACTIVE' ? 'green' : 'red'">
|
||||
{{ record.status === 'ACTIVE' ? '正常' : '禁用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<Button type="link" size="small" @click="handleEdit(record)">编辑</Button>
|
||||
<Button type="link" size="small" danger @click="handleDelete(record.id)">删除</Button>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
|
||||
<Modal
|
||||
v-model:open="modalVisible"
|
||||
:title="editingProject ? '编辑项目' : '新增项目'"
|
||||
@ok="handleSubmit"
|
||||
width="600px"
|
||||
<!-- 表格 -->
|
||||
<div class="table-card">
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data-source="projects"
|
||||
:loading="loading"
|
||||
:row-key="(record: Project) => record.id"
|
||||
:pagination="{ pageSize: 10, showSizeChanger: true, showTotal: (total: number) => `共 ${total} 条` }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusLabel(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<Space>
|
||||
<Button type="link" size="small" @click="handleEdit(record)">
|
||||
<EditOutlined /> 编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认删除"
|
||||
description="删除后不可恢复,是否继续?"
|
||||
ok-text="确认"
|
||||
cancel-text="取消"
|
||||
@confirm="handleDelete(record.id)"
|
||||
>
|
||||
<Button type="link" danger size="small">
|
||||
<DeleteOutlined /> 删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- 抽屉 -->
|
||||
<Drawer
|
||||
v-model:open="drawerVisible"
|
||||
:title="drawerTitle"
|
||||
width="560px"
|
||||
:footer-style="{ textAlign: 'right' }"
|
||||
@close="handleClose"
|
||||
>
|
||||
<Form :form="form" layout="vertical">
|
||||
<Form.Item name="code" label="项目编码" :rules="[{ required: true, message: '请输入项目编码' }]">
|
||||
<Input :disabled="!!editingProject" />
|
||||
<Form
|
||||
ref="formRef"
|
||||
:model="formState"
|
||||
layout="vertical"
|
||||
:rules="{
|
||||
code: [{ required: true, message: '请输入项目编码' }],
|
||||
name: [{ required: true, message: '请输入项目名称' }]
|
||||
}"
|
||||
>
|
||||
<Form.Item label="项目编码" name="code">
|
||||
<Input v-model:value="formState.code" :disabled="!!formState.id" placeholder="请输入项目编码" />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="项目名称" :rules="[{ required: true, message: '请输入项目名称' }]">
|
||||
<Input />
|
||||
<Form.Item label="项目名称" name="name">
|
||||
<Input v-model:value="formState.name" placeholder="请输入项目名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea />
|
||||
<Form.Item label="描述" name="description">
|
||||
<Input.TextArea v-model:value="formState.description" placeholder="请输入描述" :rows="2" />
|
||||
</Form.Item>
|
||||
<Form.Item name="address" label="地址">
|
||||
<Input />
|
||||
<Form.Item label="地址" name="address">
|
||||
<Input v-model:value="formState.address" placeholder="请输入详细地址" />
|
||||
</Form.Item>
|
||||
<Form.Item name="province" label="省份">
|
||||
<Input />
|
||||
<Form.Item label="省份" name="province">
|
||||
<Input v-model:value="formState.province" placeholder="请输入省份" />
|
||||
</Form.Item>
|
||||
<Form.Item name="city" label="城市">
|
||||
<Input />
|
||||
<Form.Item label="城市" name="city">
|
||||
<Input v-model:value="formState.city" placeholder="请输入城市" />
|
||||
</Form.Item>
|
||||
<Form.Item name="district" label="区县">
|
||||
<Input />
|
||||
<Form.Item label="区县" name="district">
|
||||
<Input v-model:value="formState.district" placeholder="请输入区县" />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态" initial-value="ACTIVE">
|
||||
<Select :options="statusOptions" />
|
||||
<Form.Item label="状态" name="status">
|
||||
<Select v-model:value="formState.status" :options="statusOptions" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
<template #footer>
|
||||
<Space>
|
||||
<Button @click="handleClose">取消</Button>
|
||||
<Button type="primary" :loading="submitting" @click="handleSubmit">确定</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,200 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Table, Button, Space, Input, Select, DatePicker, Tag, message } from 'ant-design-vue'
|
||||
import { SearchOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
// 审计日志类型
|
||||
interface AuditLog {
|
||||
id: string
|
||||
time: string
|
||||
operator: string
|
||||
type: 'PERMISSION' | 'ROLE' | 'PROJECT'
|
||||
content: string
|
||||
target: string
|
||||
ip: string
|
||||
}
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{ title: '时间', dataIndex: 'time', key: 'time', width: 180 },
|
||||
{ title: '操作用户', dataIndex: 'operator', key: 'operator', width: 120 },
|
||||
{ title: '操作类型', dataIndex: 'type', key: 'type', width: 100 },
|
||||
{ title: '操作内容', dataIndex: 'content', key: 'content', ellipsis: true },
|
||||
{ title: '目标对象', dataIndex: 'target', key: 'target', width: 150 },
|
||||
{ title: 'IP地址', dataIndex: 'ip', key: 'ip', width: 140 }
|
||||
]
|
||||
|
||||
// 数据
|
||||
const logs = ref<AuditLog[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
// 筛选
|
||||
const filters = ref({
|
||||
type: undefined as string | undefined,
|
||||
dateRange: [] as [dayjs.Dayjs, dayjs.Dayjs] | null,
|
||||
operator: ''
|
||||
})
|
||||
|
||||
// 操作类型选项
|
||||
const typeOptions = [
|
||||
{ value: 'PERMISSION', label: '权限变更' },
|
||||
{ value: 'ROLE', label: '角色分配' },
|
||||
{ value: 'PROJECT', label: '项目参与' }
|
||||
]
|
||||
|
||||
// 模拟数据
|
||||
const mockLogs: AuditLog[] = [
|
||||
{
|
||||
id: '1',
|
||||
time: '2026-03-21 10:30:25',
|
||||
operator: 'admin',
|
||||
type: 'PERMISSION',
|
||||
content: '修改用户「张三」的项目权限,添加「数据导出」权限',
|
||||
target: '用户:张三',
|
||||
ip: '192.168.1.100'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
time: '2026-03-21 09:15:42',
|
||||
operator: 'admin',
|
||||
type: 'ROLE',
|
||||
content: '为用户「李四」分配「项目经理」角色',
|
||||
target: '用户:李四',
|
||||
ip: '192.168.1.100'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
time: '2026-03-20 16:45:33',
|
||||
operator: 'manager',
|
||||
type: 'PROJECT',
|
||||
content: '将「王五」从「智慧社区项目」中移除',
|
||||
target: '智慧社区项目',
|
||||
ip: '192.168.1.105'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
time: '2026-03-20 14:20:18',
|
||||
operator: 'admin',
|
||||
type: 'PERMISSION',
|
||||
content: '撤销用户「赵六」的「系统管理」权限',
|
||||
target: '用户:赵六',
|
||||
ip: '192.168.1.100'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
time: '2026-03-19 11:05:56',
|
||||
operator: 'manager',
|
||||
type: 'ROLE',
|
||||
content: '更新角色「审计员」的权限配置',
|
||||
target: '角色:审计员',
|
||||
ip: '192.168.1.105'
|
||||
}
|
||||
]
|
||||
|
||||
// 获取操作类型标签
|
||||
const getTypeColor = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
PERMISSION: 'blue',
|
||||
ROLE: 'green',
|
||||
PROJECT: 'orange'
|
||||
}
|
||||
return map[type] || 'default'
|
||||
}
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
PERMISSION: '权限变更',
|
||||
ROLE: '角色分配',
|
||||
PROJECT: '项目参与'
|
||||
}
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
const loadData = () => {
|
||||
loading.value = true
|
||||
// 模拟API延迟
|
||||
setTimeout(() => {
|
||||
logs.value = mockLogs.filter((log) => {
|
||||
// 按类型筛选
|
||||
if (filters.value.type && log.type !== filters.value.type) return false
|
||||
// 按用户筛选
|
||||
if (filters.value.operator && !log.operator.includes(filters.value.operator)) return false
|
||||
// 按日期范围筛选
|
||||
if (filters.value.dateRange && filters.value.dateRange.length === 2) {
|
||||
const logDate = dayjs(log.time).startOf('day')
|
||||
const [start, end] = filters.value.dateRange
|
||||
if (logDate.isBefore(start, 'day') || logDate.isAfter(end, 'day')) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
loading.value = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
filters.value = {
|
||||
type: undefined,
|
||||
dateRange: null,
|
||||
operator: ''
|
||||
}
|
||||
loadData()
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">操作审计日志</h2>
|
||||
</div>
|
||||
|
||||
<!-- 筛选区 -->
|
||||
<div class="filter-bar">
|
||||
<Space wrap>
|
||||
<Select
|
||||
v-model:value="filters.type"
|
||||
placeholder="操作类型"
|
||||
:options="typeOptions"
|
||||
allow-clear
|
||||
style="width: 140px"
|
||||
/>
|
||||
<DatePicker.RangePicker v-model:value="filters.dateRange" style="width: 260px" />
|
||||
<Input
|
||||
v-model:value="filters.operator"
|
||||
placeholder="操作用户"
|
||||
style="width: 140px"
|
||||
/>
|
||||
<Button type="primary" @click="loadData">
|
||||
<SearchOutlined /> 查询
|
||||
</Button>
|
||||
<Button @click="handleReset">
|
||||
<ReloadOutlined /> 重置
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<div class="table-card">
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data-source="logs"
|
||||
:loading="loading"
|
||||
:row-key="(record: AuditLog) => record.id"
|
||||
:pagination="{ pageSize: 10, showSizeChanger: true, showTotal: (total: number) => `共 ${total} 条` }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'type'">
|
||||
<Tag :color="getTypeColor(record.type)">
|
||||
{{ getTypeLabel(record.type) }}
|
||||
</Tag>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,54 +1,288 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Table, Button, message } from 'ant-design-vue'
|
||||
import { ref, onMounted, reactive } from 'vue'
|
||||
import { Table, Tag, message } from 'ant-design-vue'
|
||||
import type { Permission } from '@/types'
|
||||
import { getPermissions, createPermission, updatePermission, deletePermission } from '@/api/permission'
|
||||
|
||||
const columns = [
|
||||
{ title: '权限编码', dataIndex: 'code', key: 'code' },
|
||||
{ title: '权限名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '类型', dataIndex: 'type', key: 'type' },
|
||||
{ title: '资源', dataIndex: 'resource', key: 'resource' },
|
||||
{ title: '方法', dataIndex: 'method', key: 'method' },
|
||||
{ title: '描述', dataIndex: 'description', key: 'description' }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const columns: any[] = [
|
||||
{ title: '权限编码', dataIndex: 'code', key: 'code', width: 150 },
|
||||
{ title: '权限名称', dataIndex: 'name', key: 'name', width: 150 },
|
||||
{ title: '类型', dataIndex: 'type', key: 'type', width: 100 },
|
||||
{ title: '资源', dataIndex: 'resource', key: 'resource', width: 180 },
|
||||
{ title: '方法', dataIndex: 'method', key: 'method', width: 80 },
|
||||
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true },
|
||||
{ title: '操作', key: 'action', width: 150, fixed: 'right' }
|
||||
]
|
||||
|
||||
const permissions = ref<Permission[]>([])
|
||||
const loading = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const modalTitle = ref('新建权限')
|
||||
const editingId = ref<string | null>(null)
|
||||
|
||||
const typeMap: Record<string, string> = {
|
||||
MENU: '菜单',
|
||||
BUTTON: '按钮',
|
||||
API: '接口'
|
||||
// 搜索相关
|
||||
const searchKeyword = ref('')
|
||||
|
||||
// 分页相关
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total: number) => `共 ${total} 条`
|
||||
})
|
||||
|
||||
// 表单数据
|
||||
const formState = reactive({
|
||||
code: '',
|
||||
name: '',
|
||||
type: 'MENU',
|
||||
resource: '',
|
||||
method: 'GET',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
code: [{ required: true, message: '请输入权限代码' }],
|
||||
name: [{ required: true, message: '请输入权限名称' }],
|
||||
type: [{ required: true, message: '请选择类型' }]
|
||||
}
|
||||
|
||||
const typeOptions = [
|
||||
{ value: 'MENU', label: '菜单' },
|
||||
{ value: 'BUTTON', label: '按钮' },
|
||||
{ value: 'API', label: '接口' }
|
||||
]
|
||||
|
||||
const methodOptions = [
|
||||
{ value: 'GET', label: 'GET' },
|
||||
{ value: 'POST', label: 'POST' },
|
||||
{ value: 'PUT', label: 'PUT' },
|
||||
{ value: 'DELETE', label: 'DELETE' }
|
||||
]
|
||||
|
||||
const fetchPermissions = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetch('/api/permissions').then(r => r.json())
|
||||
const res = await getPermissions()
|
||||
permissions.value = res.data || []
|
||||
} catch (error) {
|
||||
pagination.total = permissions.value.length
|
||||
} catch {
|
||||
message.error('获取权限列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
MENU: 'blue',
|
||||
BUTTON: 'green',
|
||||
API: 'purple'
|
||||
}
|
||||
return map[type] || 'default'
|
||||
}
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
MENU: '菜单',
|
||||
BUTTON: '按钮',
|
||||
API: '接口'
|
||||
}
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
formState.code = ''
|
||||
formState.name = ''
|
||||
formState.type = 'MENU'
|
||||
formState.resource = ''
|
||||
formState.method = 'GET'
|
||||
formState.description = ''
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
const openCreateModal = () => {
|
||||
resetForm()
|
||||
modalTitle.value = '新建权限'
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const openEditModal = (record: Permission) => {
|
||||
editingId.value = record.id
|
||||
formState.code = record.code
|
||||
formState.name = record.name
|
||||
formState.type = record.type
|
||||
formState.resource = record.resource || ''
|
||||
formState.method = record.method || 'GET'
|
||||
formState.description = record.description || ''
|
||||
modalTitle.value = '编辑权限'
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updatePermission(editingId.value, formState)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await createPermission(formState)
|
||||
message.success('创建成功')
|
||||
}
|
||||
modalVisible.value = false
|
||||
fetchPermissions()
|
||||
} catch {
|
||||
message.error(editingId.value ? '更新失败' : '创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deletePermission(id)
|
||||
message.success('删除成功')
|
||||
fetchPermissions()
|
||||
} catch {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleTableChange = (pag: any) => {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
fetchPermissions()
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1
|
||||
fetchPermissions()
|
||||
}
|
||||
|
||||
onMounted(fetchPermissions)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data-source="permissions"
|
||||
:loading="loading"
|
||||
:row-key="(record: Permission) => record.id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'type'">
|
||||
<a-tag>{{ typeMap[record.type] || record.type }}</a-tag>
|
||||
<div class="page-container">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">权限管理</h2>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<div class="table-card">
|
||||
<div class="table-toolbar">
|
||||
<a-input
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索权限编码或名称"
|
||||
style="width: 240px"
|
||||
@press-enter="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data-source="permissions"
|
||||
:loading="loading"
|
||||
:row-key="(record: Permission) => record.id"
|
||||
:pagination="pagination"
|
||||
:scroll="{ x: 1000 }"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #title>
|
||||
<div class="table-title">
|
||||
<span>权限列表</span>
|
||||
<a-button type="primary" @click="openCreateModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新建
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'type'">
|
||||
<Tag :color="getTypeColor(record.type)">
|
||||
{{ getTypeLabel(record.type) }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="openEditModal(record as any)">
|
||||
编辑
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
title="确定删除该权限吗?"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
@confirm="handleDelete(record.id)"
|
||||
>
|
||||
<a-button type="link" size="small" danger>
|
||||
删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- 新建/编辑模态框 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="modalTitle"
|
||||
:width="500"
|
||||
@ok="handleSubmit"
|
||||
@cancel="modalVisible = false"
|
||||
>
|
||||
<a-form
|
||||
:model="formState"
|
||||
:rules="formRules"
|
||||
layout="vertical"
|
||||
class="permission-form"
|
||||
>
|
||||
<a-form-item label="权限代码" name="code">
|
||||
<a-input v-model:value="formState.code" placeholder="如: system:user:view" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="权限名称" name="name">
|
||||
<a-input v-model:value="formState.name" placeholder="如: 查看用户" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="类型" name="type">
|
||||
<a-select v-model:value="formState.type" :options="typeOptions" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="资源路径" name="resource">
|
||||
<a-input v-model:value="formState.resource" placeholder="如: /api/users" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="请求方法" name="method">
|
||||
<a-select v-model:value="formState.method" :options="methodOptions" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="描述" name="description">
|
||||
<a-textarea v-model:value="formState.description" :rows="3" placeholder="权限描述" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-toolbar {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.table-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.permission-form {
|
||||
padding-top: 8px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,31 +1,52 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Table, Button, Modal, Form, Input, Select, message } from 'ant-design-vue'
|
||||
import { Table, Button, Drawer, Input, Select, Form, Space, Popconfirm, message } from 'ant-design-vue'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
||||
import { getRoles, createRole, updateRole, deleteRole } from '@/api/role'
|
||||
import type { Role } from '@/types'
|
||||
|
||||
const columns = [
|
||||
{ title: '角色编码', dataIndex: 'code', key: 'code' },
|
||||
{ title: '角色名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '描述', dataIndex: 'description', key: 'description' },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status' },
|
||||
{ title: '操作', key: 'action', width: 150 }
|
||||
{ title: '角色编码', dataIndex: 'code', key: 'code', width: 120 },
|
||||
{ title: '角色名称', dataIndex: 'name', key: 'name', width: 150 },
|
||||
{ title: '类型', dataIndex: 'type', key: 'type', width: 100 },
|
||||
{ title: '数据权限', dataIndex: 'dataScope', key: 'dataScope', width: 100 },
|
||||
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 80 },
|
||||
{ title: '操作', key: 'action', width: 120, fixed: 'right' }
|
||||
]
|
||||
|
||||
const roles = ref<Role[]>([])
|
||||
const loading = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const editingRole = ref<Role | null>(null)
|
||||
const [form] = Form.useForm()
|
||||
const drawerVisible = ref(false)
|
||||
const drawerTitle = ref('')
|
||||
const formRef = ref()
|
||||
const submitting = ref(false)
|
||||
|
||||
const formState = ref({
|
||||
id: '',
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
type: '',
|
||||
dataScope: 'SELF',
|
||||
status: 'ACTIVE'
|
||||
})
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'ACTIVE', label: '正常' },
|
||||
{ value: 'DISABLED', label: '禁用' }
|
||||
{ value: 'ACTIVE', label: '正常', color: 'success' },
|
||||
{ value: 'DISABLED', label: '禁用', color: 'error' }
|
||||
]
|
||||
|
||||
const typeOptions = [
|
||||
{ value: 'SYSTEM', label: '系统角色' },
|
||||
{ value: 'PROJECT', label: '项目角色' }
|
||||
{ value: 'PROJECT', label: '项目角色' },
|
||||
{ value: 'DEPARTMENT', label: '部门级' }
|
||||
]
|
||||
|
||||
const dataScopeOptions = [
|
||||
{ value: 'ALL', label: '全部数据', color: 'red' },
|
||||
{ value: 'PROJECT', label: '本项目数据', color: 'blue' },
|
||||
{ value: 'SELF', label: '本人数据', color: 'green' }
|
||||
]
|
||||
|
||||
const fetchRoles = async () => {
|
||||
|
|
@ -33,7 +54,7 @@ const fetchRoles = async () => {
|
|||
try {
|
||||
const res = await getRoles()
|
||||
roles.value = res.data
|
||||
} catch (error) {
|
||||
} catch {
|
||||
message.error('获取角色列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
|
@ -41,15 +62,31 @@ const fetchRoles = async () => {
|
|||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
editingRole.value = null
|
||||
form.resetFields()
|
||||
modalVisible.value = true
|
||||
drawerTitle.value = '新增角色'
|
||||
formState.value = {
|
||||
id: '',
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
type: '',
|
||||
dataScope: 'SELF',
|
||||
status: 'ACTIVE'
|
||||
}
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (record: Role) => {
|
||||
editingRole.value = record
|
||||
form.setFieldsValue(record)
|
||||
modalVisible.value = true
|
||||
drawerTitle.value = '编辑角色'
|
||||
formState.value = {
|
||||
id: record.id,
|
||||
code: record.code,
|
||||
name: record.name,
|
||||
description: record.description || '',
|
||||
type: record.type || '',
|
||||
dataScope: record.dataScope || 'SELF',
|
||||
status: record.status
|
||||
}
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
|
|
@ -57,78 +94,183 @@ const handleDelete = async (id: string) => {
|
|||
await deleteRole(id)
|
||||
message.success('删除成功')
|
||||
fetchRoles()
|
||||
} catch (error) {
|
||||
} catch {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validate()
|
||||
if (editingRole.value) {
|
||||
await updateRole(editingRole.value.id, values)
|
||||
await formRef.value.validate()
|
||||
submitting.value = true
|
||||
|
||||
if (formState.value.id) {
|
||||
await updateRole(formState.value.id, formState.value)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await createRole(values)
|
||||
await createRole(formState.value)
|
||||
message.success('创建成功')
|
||||
}
|
||||
modalVisible.value = false
|
||||
drawerVisible.value = false
|
||||
fetchRoles()
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.errorFields) return
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
formRef.value?.resetFields()
|
||||
drawerVisible.value = false
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
ACTIVE: 'success',
|
||||
DISABLED: 'error'
|
||||
}
|
||||
return map[status] || 'default'
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
ACTIVE: '正常',
|
||||
DISABLED: '禁用'
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
SYSTEM: 'SYSTEM',
|
||||
PROJECT: 'PROJECT',
|
||||
DEPARTMENT: 'DEPARTMENT'
|
||||
}
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
const getDataScopeLabel = (dataScope: string) => {
|
||||
const map: Record<string, string> = {
|
||||
ALL: 'ALL',
|
||||
PROJECT: 'PROJECT',
|
||||
SELF: 'SELF'
|
||||
}
|
||||
return map[dataScope] || dataScope
|
||||
}
|
||||
|
||||
const getDataScopeColor = (dataScope: string) => {
|
||||
const map: Record<string, string> = {
|
||||
ALL: 'red',
|
||||
PROJECT: 'blue',
|
||||
SELF: 'green'
|
||||
}
|
||||
return map[dataScope] || 'default'
|
||||
}
|
||||
|
||||
onMounted(fetchRoles)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div style="margin-bottom: 16px">
|
||||
<Button type="primary" @click="handleAdd">新增角色</Button>
|
||||
<div class="page-container">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">角色管理</h2>
|
||||
<div class="page-header-actions">
|
||||
<Button type="primary" @click="handleAdd">
|
||||
<PlusOutlined /> 新增角色
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data-source="roles"
|
||||
:loading="loading"
|
||||
:row-key="(record: Role) => record.id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 'ACTIVE' ? 'green' : 'red'">
|
||||
{{ record.status === 'ACTIVE' ? '正常' : '禁用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<Button type="link" size="small" @click="handleEdit(record)">编辑</Button>
|
||||
<Button type="link" size="small" danger @click="handleDelete(record.id)">删除</Button>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
|
||||
<Modal
|
||||
v-model:open="modalVisible"
|
||||
:title="editingRole ? '编辑角色' : '新增角色'"
|
||||
@ok="handleSubmit"
|
||||
width="600px"
|
||||
<!-- 表格 -->
|
||||
<div class="table-card">
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data-source="roles"
|
||||
:loading="loading"
|
||||
:row-key="(record: Role) => record.id"
|
||||
:pagination="{ pageSize: 10, showSizeChanger: true, showTotal: (total: number) => `共 ${total} 条` }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'type'">
|
||||
<a-tag>{{ getTypeLabel(record.type) }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'dataScope'">
|
||||
<a-tag :color="getDataScopeColor(record.dataScope)">
|
||||
{{ getDataScopeLabel(record.dataScope) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusLabel(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<Space>
|
||||
<Button type="link" size="small" @click="handleEdit(record)">
|
||||
<EditOutlined /> 编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认删除"
|
||||
description="删除后不可恢复,是否继续?"
|
||||
ok-text="确认"
|
||||
cancel-text="取消"
|
||||
@confirm="handleDelete(record.id)"
|
||||
>
|
||||
<Button type="link" danger size="small">
|
||||
<DeleteOutlined /> 删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- 抽屉 -->
|
||||
<Drawer
|
||||
v-model:open="drawerVisible"
|
||||
:title="drawerTitle"
|
||||
width="480px"
|
||||
:footer-style="{ textAlign: 'right' }"
|
||||
@close="handleClose"
|
||||
>
|
||||
<Form :form="form" layout="vertical">
|
||||
<Form.Item name="code" label="角色编码" :rules="[{ required: true, message: '请输入角色编码' }]">
|
||||
<Input :disabled="!!editingRole" />
|
||||
<Form
|
||||
ref="formRef"
|
||||
:model="formState"
|
||||
layout="vertical"
|
||||
:rules="{
|
||||
code: [{ required: true, message: '请输入角色编码' }],
|
||||
name: [{ required: true, message: '请输入角色名称' }]
|
||||
}"
|
||||
>
|
||||
<Form.Item label="角色编码" name="code">
|
||||
<Input v-model:value="formState.code" :disabled="!!formState.id" placeholder="请输入角色编码" />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="角色名称" :rules="[{ required: true, message: '请输入角色名称' }]">
|
||||
<Input />
|
||||
<Form.Item label="角色名称" name="name">
|
||||
<Input v-model:value="formState.name" placeholder="请输入角色名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea />
|
||||
<Form.Item label="描述" name="description">
|
||||
<Input.TextArea v-model:value="formState.description" placeholder="请输入描述" :rows="3" />
|
||||
</Form.Item>
|
||||
<Form.Item name="type" label="类型">
|
||||
<Select :options="typeOptions" />
|
||||
<Form.Item label="类型" name="type">
|
||||
<Select v-model:value="formState.type" :options="typeOptions" placeholder="请选择类型" />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态" initial-value="ACTIVE">
|
||||
<Select :options="statusOptions" />
|
||||
<Form.Item label="数据权限" name="dataScope">
|
||||
<Select v-model:value="formState.dataScope" :options="dataScopeOptions" placeholder="请选择数据权限" />
|
||||
</Form.Item>
|
||||
<Form.Item label="状态" name="status">
|
||||
<Select v-model:value="formState.status" :options="statusOptions" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
<template #footer>
|
||||
<Space>
|
||||
<Button @click="handleClose">取消</Button>
|
||||
<Button type="primary" :loading="submitting" @click="handleSubmit">确定</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,357 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, h } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Tag, Button, Table, Select, Modal, Form, Space, Popconfirm, message } from 'ant-design-vue'
|
||||
import { PlusOutlined, ArrowLeftOutlined } from '@ant-design/icons-vue'
|
||||
import { getUserRoles, removeRoleFromUser, getRoles } from '@/api/role'
|
||||
import { getUserProjects, addUserToProject, removeUserFromProject, assignRoles } from '@/api/user'
|
||||
import { getProjects } from '@/api/project'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userId = route.params.id as string
|
||||
|
||||
// ========== 角色分配 ==========
|
||||
const userRoles = ref<any[]>([])
|
||||
const allRoles = ref<any[]>([])
|
||||
const roleModalVisible = ref(false)
|
||||
const selectedRoleIds = ref<string[]>([])
|
||||
const loadingRoles = ref(false)
|
||||
const submittingRoles = ref(false)
|
||||
|
||||
const fetchUserRoles = async () => {
|
||||
loadingRoles.value = true
|
||||
try {
|
||||
const res = await getUserRoles(userId)
|
||||
userRoles.value = res.data
|
||||
} catch {
|
||||
message.error('获取用户角色失败')
|
||||
} finally {
|
||||
loadingRoles.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openRoleModal = async () => {
|
||||
allRoles.value = []
|
||||
selectedRoleIds.value = []
|
||||
roleModalVisible.value = true
|
||||
try {
|
||||
const res = await getRoles()
|
||||
allRoles.value = res.data
|
||||
selectedRoleIds.value = userRoles.value.map(r => r.id)
|
||||
} catch {
|
||||
message.error('获取角色列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleAssignRoles = async () => {
|
||||
if (selectedRoleIds.value.length === 0) {
|
||||
message.warning('请至少选择一个角色')
|
||||
return
|
||||
}
|
||||
submittingRoles.value = true
|
||||
try {
|
||||
await assignRoles(userId, selectedRoleIds.value)
|
||||
message.success('角色分配成功')
|
||||
roleModalVisible.value = false
|
||||
fetchUserRoles()
|
||||
} catch {
|
||||
message.error('角色分配失败')
|
||||
} finally {
|
||||
submittingRoles.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveRole = async (roleId: string) => {
|
||||
try {
|
||||
await removeRoleFromUser(userId, roleId)
|
||||
message.success('移除角色成功')
|
||||
fetchUserRoles()
|
||||
} catch {
|
||||
message.error('移除角色失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 项目参与 ==========
|
||||
interface UserProjectItem {
|
||||
id: string
|
||||
projectId: string
|
||||
projectName: string
|
||||
projectCode: string
|
||||
roleInProject: 'leader' | 'member' | 'viewer'
|
||||
joinedAt: string
|
||||
}
|
||||
|
||||
const userProjects = ref<UserProjectItem[]>([])
|
||||
const allProjects = ref<any[]>([])
|
||||
const projectModalVisible = ref(false)
|
||||
const selectedProjectId = ref<string>('')
|
||||
const selectedProjectRole = ref<'leader' | 'member' | 'viewer'>('member')
|
||||
const loadingProjects = ref(false)
|
||||
const submittingProject = ref(false)
|
||||
|
||||
const projectColumns = [
|
||||
{ title: '项目名称', dataIndex: 'projectName', key: 'projectName' },
|
||||
{ title: '项目编码', dataIndex: 'projectCode', key: 'projectCode', width: 140 },
|
||||
{
|
||||
title: '项目角色',
|
||||
dataIndex: 'roleInProject',
|
||||
key: 'roleInProject',
|
||||
width: 120,
|
||||
customRender: ({ record }: { record: UserProjectItem }) => {
|
||||
const roleOptions = [
|
||||
{ value: 'leader', label: 'Leader' },
|
||||
{ value: 'member', label: 'Member' },
|
||||
{ value: 'viewer', label: 'Viewer' }
|
||||
]
|
||||
return h(Select, {
|
||||
value: record.roleInProject as string,
|
||||
options: roleOptions,
|
||||
style: 'width: 100px',
|
||||
size: 'small',
|
||||
onChange: (value: string) => handleProjectRoleChange(value)
|
||||
} as any)
|
||||
}
|
||||
},
|
||||
{ title: '加入时间', dataIndex: 'joinedAt', key: 'joinedAt', width: 180 },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 80,
|
||||
customRender: ({ record }: { record: UserProjectItem }) => {
|
||||
return h(Popconfirm, {
|
||||
title: '确认移除',
|
||||
description: '是否移除该项目?',
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
onConfirm: () => handleRemoveProject(record.projectId)
|
||||
}, () => h(Button, { type: 'link', danger: true, size: 'small' }, () => '移除'))
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const fetchUserProjects = async () => {
|
||||
loadingProjects.value = true
|
||||
try {
|
||||
const res = await getUserProjects(userId)
|
||||
userProjects.value = res.data.map((p: any) => ({
|
||||
...p,
|
||||
projectName: p.projectName || p.name,
|
||||
projectCode: p.projectCode || p.code
|
||||
}))
|
||||
} catch {
|
||||
message.error('获取用户项目失败')
|
||||
} finally {
|
||||
loadingProjects.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openProjectModal = async () => {
|
||||
selectedProjectId.value = ''
|
||||
selectedProjectRole.value = 'member'
|
||||
projectModalVisible.value = true
|
||||
try {
|
||||
const res = await getProjects()
|
||||
const addedProjectIds = userProjects.value.map(p => p.projectId)
|
||||
allProjects.value = res.data.filter((p: any) => !addedProjectIds.includes(p.id))
|
||||
} catch {
|
||||
message.error('获取项目列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddProject = async () => {
|
||||
if (!selectedProjectId.value) {
|
||||
message.warning('请选择项目')
|
||||
return
|
||||
}
|
||||
submittingProject.value = true
|
||||
try {
|
||||
await addUserToProject(userId, selectedProjectId.value, selectedProjectRole.value)
|
||||
message.success('添加成功')
|
||||
projectModalVisible.value = false
|
||||
fetchUserProjects()
|
||||
} catch {
|
||||
message.error('添加失败')
|
||||
} finally {
|
||||
submittingProject.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveProject = async (projectId: string) => {
|
||||
try {
|
||||
await removeUserFromProject(userId, projectId)
|
||||
message.success('移除成功')
|
||||
fetchUserProjects()
|
||||
} catch {
|
||||
message.error('移除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleProjectRoleChange = async (_newRole: string) => {
|
||||
message.info('项目角色变更功能待实现')
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/system/users')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchUserRoles()
|
||||
fetchUserProjects()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="user-detail-page">
|
||||
<a-card>
|
||||
<div class="page-header">
|
||||
<Space>
|
||||
<Button @click="goBack">
|
||||
<ArrowLeftOutlined /> 返回
|
||||
</Button>
|
||||
<h2 class="page-title">用户详情</h2>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<a-tabs>
|
||||
<a-tab-pane key="roles" tab="角色分配">
|
||||
<div class="tab-content">
|
||||
<div class="tab-toolbar">
|
||||
<Button type="primary" @click="openRoleModal">
|
||||
<PlusOutlined /> 分配角色
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="roles-container">
|
||||
<Tag
|
||||
v-for="role in userRoles"
|
||||
:key="role.id"
|
||||
closable
|
||||
color="blue"
|
||||
@close="handleRemoveRole(role.id)"
|
||||
>
|
||||
{{ role.name }}
|
||||
</Tag>
|
||||
<span v-if="userRoles.length === 0" class="empty-text">暂无分配角色</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="projects" tab="项目参与">
|
||||
<div class="tab-content">
|
||||
<div class="tab-toolbar">
|
||||
<Button type="primary" @click="openProjectModal">
|
||||
<PlusOutlined /> 添加到项目
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
:columns="projectColumns"
|
||||
:data-source="userProjects"
|
||||
:loading="loadingProjects"
|
||||
:row-key="(record: UserProjectItem) => record.id"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
/>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-card>
|
||||
|
||||
<!-- 角色选择弹窗 -->
|
||||
<Modal
|
||||
v-model:open="roleModalVisible"
|
||||
title="分配角色"
|
||||
:loading="submittingRoles"
|
||||
@ok="handleAssignRoles"
|
||||
>
|
||||
<div class="role-select">
|
||||
<Select
|
||||
v-model:value="selectedRoleIds"
|
||||
mode="multiple"
|
||||
placeholder="请选择角色"
|
||||
:options="allRoles.map(r => ({ value: r.id, label: r.name }))"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- 项目选择弹窗 -->
|
||||
<Modal
|
||||
v-model:open="projectModalVisible"
|
||||
title="添加到项目"
|
||||
:loading="submittingProject"
|
||||
@ok="handleAddProject"
|
||||
>
|
||||
<div class="project-select">
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="选择项目">
|
||||
<Select
|
||||
v-model:value="selectedProjectId"
|
||||
placeholder="请选择项目"
|
||||
:options="allProjects.map(p => ({ value: p.id, label: p.name }))"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="项目角色">
|
||||
<Select
|
||||
v-model:value="selectedProjectRole"
|
||||
:options="[
|
||||
{ value: 'leader', label: 'Leader' },
|
||||
{ value: 'member', label: 'Member' },
|
||||
{ value: 'viewer', label: 'Viewer' }
|
||||
]"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-detail-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.tab-toolbar {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.roles-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.roles-container :deep(.ant-tag) {
|
||||
padding: 4px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.role-select,
|
||||
.project-select {
|
||||
padding: 8px 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,133 +1,294 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Table, Button, Modal, Form, Input, Select, message } from 'ant-design-vue'
|
||||
import { Table, Button, Drawer, Input, Select, Form, Space, Popconfirm, message } from 'ant-design-vue'
|
||||
import { PlusOutlined, SearchOutlined, ReloadOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
||||
import { getUsers, createUser, updateUser, deleteUser } from '@/api/user'
|
||||
import type { User } from '@/types'
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{ title: '用户名', dataIndex: 'username', key: 'username' },
|
||||
{ title: '姓名', dataIndex: 'realName', key: 'realName' },
|
||||
{ title: '手机', dataIndex: 'phone', key: 'phone' },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status' },
|
||||
{ title: '操作', key: 'action', width: 150 }
|
||||
{ title: '用户名', dataIndex: 'username', key: 'username', width: 120 },
|
||||
{ title: '姓名', dataIndex: 'realName', key: 'realName', width: 100 },
|
||||
{ title: '手机', dataIndex: 'phone', key: 'phone', width: 120 },
|
||||
{ title: '邮箱', dataIndex: 'email', key: 'email', ellipsis: true },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 80 },
|
||||
{ title: '操作', key: 'action', width: 120, fixed: 'right' }
|
||||
]
|
||||
|
||||
// 数据
|
||||
const users = ref<User[]>([])
|
||||
const loading = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const editingUser = ref<User | null>(null)
|
||||
const [form] = Form.useForm()
|
||||
const drawerVisible = ref(false)
|
||||
const drawerTitle = ref('')
|
||||
const formRef = ref()
|
||||
const submitting = ref(false)
|
||||
|
||||
// 筛选
|
||||
const searchKeyword = ref('')
|
||||
const searchStatus = ref('')
|
||||
|
||||
// 表单
|
||||
const formState = ref({
|
||||
id: '',
|
||||
username: '',
|
||||
password: '',
|
||||
realName: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
status: 'ACTIVE'
|
||||
})
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'ACTIVE', label: '正常' },
|
||||
{ value: 'LOCKED', label: '锁定' },
|
||||
{ value: 'DISABLED', label: '禁用' }
|
||||
{ value: 'ACTIVE', label: '正常', color: 'success' },
|
||||
{ value: 'LOCKED', label: '锁定', color: 'warning' },
|
||||
{ value: 'DISABLED', label: '禁用', color: 'error' }
|
||||
]
|
||||
|
||||
// 获取用户列表
|
||||
const fetchUsers = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getUsers()
|
||||
const params: any = {}
|
||||
if (searchKeyword.value) params.keyword = searchKeyword.value
|
||||
if (searchStatus.value) params.status = searchStatus.value
|
||||
const res = await getUsers(params)
|
||||
users.value = res.data
|
||||
} catch (error) {
|
||||
} catch {
|
||||
message.error('获取用户列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
fetchUsers()
|
||||
}
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
searchKeyword.value = ''
|
||||
searchStatus.value = ''
|
||||
fetchUsers()
|
||||
}
|
||||
|
||||
// 新增
|
||||
const handleAdd = () => {
|
||||
editingUser.value = null
|
||||
form.resetFields()
|
||||
modalVisible.value = true
|
||||
drawerTitle.value = '新增用户'
|
||||
formState.value = {
|
||||
id: '',
|
||||
username: '',
|
||||
password: '',
|
||||
realName: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
status: 'ACTIVE'
|
||||
}
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (record: User) => {
|
||||
editingUser.value = record
|
||||
form.setFieldsValue(record)
|
||||
modalVisible.value = true
|
||||
drawerTitle.value = '编辑用户'
|
||||
formState.value = {
|
||||
id: record.id,
|
||||
username: record.username,
|
||||
password: '',
|
||||
realName: record.realName || '',
|
||||
phone: record.phone || '',
|
||||
email: record.email || '',
|
||||
status: record.status
|
||||
}
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteUser(id)
|
||||
message.success('删除成功')
|
||||
fetchUsers()
|
||||
} catch (error) {
|
||||
} catch {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 提交
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validate()
|
||||
if (editingUser.value) {
|
||||
await updateUser(editingUser.value.id, values)
|
||||
await formRef.value.validate()
|
||||
submitting.value = true
|
||||
|
||||
const data = { ...formState.value }
|
||||
if (!data.password) delete data.password
|
||||
|
||||
if (formState.value.id) {
|
||||
await updateUser(formState.value.id, data)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await createUser(values)
|
||||
await createUser(data)
|
||||
message.success('创建成功')
|
||||
}
|
||||
modalVisible.value = false
|
||||
|
||||
drawerVisible.value = false
|
||||
fetchUsers()
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.errorFields) return
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭抽屉
|
||||
const handleClose = () => {
|
||||
formRef.value?.resetFields()
|
||||
drawerVisible.value = false
|
||||
}
|
||||
|
||||
// 获取状态显示
|
||||
const getStatusColor = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
ACTIVE: 'success',
|
||||
LOCKED: 'warning',
|
||||
DISABLED: 'error'
|
||||
}
|
||||
return map[status] || 'default'
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
ACTIVE: '正常',
|
||||
LOCKED: '锁定',
|
||||
DISABLED: '禁用'
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
onMounted(fetchUsers)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div style="margin-bottom: 16px">
|
||||
<Button type="primary" @click="handleAdd">新增用户</Button>
|
||||
<div class="page-container">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">用户管理</h2>
|
||||
<div class="page-header-actions">
|
||||
<Button type="primary" @click="handleAdd">
|
||||
<PlusOutlined /> 新增用户
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data-source="users"
|
||||
:loading="loading"
|
||||
:row-key="(record: User) => record.id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 'ACTIVE' ? 'green' : 'red'">
|
||||
{{ record.status === 'ACTIVE' ? '正常' : record.status === 'LOCKED' ? '锁定' : '禁用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<Button type="link" size="small" @click="handleEdit(record)">编辑</Button>
|
||||
<Button type="link" size="small" danger @click="handleDelete(record.id)">删除</Button>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
|
||||
<Modal
|
||||
v-model:open="modalVisible"
|
||||
:title="editingUser ? '编辑用户' : '新增用户'"
|
||||
@ok="handleSubmit"
|
||||
width="600px"
|
||||
<!-- 筛选区 -->
|
||||
<div class="filter-bar">
|
||||
<Space>
|
||||
<Input
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索用户名/姓名/手机"
|
||||
style="width: 240px"
|
||||
@pressEnter="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
</Input>
|
||||
<Select
|
||||
v-model:value="searchStatus"
|
||||
placeholder="状态"
|
||||
style="width: 120px"
|
||||
:options="statusOptions"
|
||||
allow-clear
|
||||
/>
|
||||
<Button type="primary" @click="handleSearch">查询</Button>
|
||||
<Button @click="handleReset">
|
||||
<ReloadOutlined /> 重置
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<div class="table-card">
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data-source="users"
|
||||
:loading="loading"
|
||||
:row-key="(record: User) => record.id"
|
||||
:pagination="{ pageSize: 10, showSizeChanger: true, showTotal: (total: number) => `共 ${total} 条` }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusLabel(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<Space>
|
||||
<Button type="link" size="small" @click="handleEdit(record)">
|
||||
<EditOutlined /> 编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认删除"
|
||||
description="删除后不可恢复,是否继续?"
|
||||
ok-text="确认"
|
||||
cancel-text="取消"
|
||||
@confirm="handleDelete(record.id)"
|
||||
>
|
||||
<Button type="link" danger size="small">
|
||||
<DeleteOutlined /> 删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- 抽屉 -->
|
||||
<Drawer
|
||||
v-model:open="drawerVisible"
|
||||
:title="drawerTitle"
|
||||
width="480px"
|
||||
:footer-style="{ textAlign: 'right' }"
|
||||
@close="handleClose"
|
||||
>
|
||||
<Form :form="form" layout="vertical">
|
||||
<Form.Item name="username" label="用户名" :rules="[{ required: true, message: '请输入用户名' }]">
|
||||
<Input :disabled="!!editingUser" />
|
||||
<Form
|
||||
ref="formRef"
|
||||
:model="formState"
|
||||
layout="vertical"
|
||||
:rules="{
|
||||
username: [{ required: true, message: '请输入用户名' }],
|
||||
password: [{ required: !formState.id, message: '请输入密码' }],
|
||||
realName: [{ required: true, message: '请输入姓名' }],
|
||||
phone: [{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式错误' }],
|
||||
email: [{ type: 'email', message: '邮箱格式错误' }]
|
||||
}"
|
||||
>
|
||||
<Form.Item label="用户名" name="username">
|
||||
<Input v-model:value="formState.username" :disabled="!!formState.id" placeholder="请输入用户名" />
|
||||
</Form.Item>
|
||||
<Form.Item v-if="!editingUser" name="password" label="密码" :rules="[{ required: true, message: '请输入密码' }]">
|
||||
<Input.Password />
|
||||
<Form.Item v-if="!formState.id" label="密码" name="password">
|
||||
<Input.Password v-model:value="formState.password" placeholder="请输入密码" />
|
||||
</Form.Item>
|
||||
<Form.Item name="realName" label="姓名">
|
||||
<Input />
|
||||
<Form.Item label="姓名" name="realName">
|
||||
<Input v-model:value="formState.realName" placeholder="请输入姓名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="手机">
|
||||
<Input />
|
||||
<Form.Item label="手机" name="phone">
|
||||
<Input v-model:value="formState.phone" placeholder="请输入手机号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="email" label="邮箱">
|
||||
<Input />
|
||||
<Form.Item label="邮箱" name="email">
|
||||
<Input v-model:value="formState.email" placeholder="请输入邮箱" />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态" initial-value="ACTIVE">
|
||||
<Select :options="statusOptions" />
|
||||
<Form.Item label="状态" name="status">
|
||||
<Select v-model:value="formState.status" :options="statusOptions" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
<template #footer>
|
||||
<Space>
|
||||
<Button @click="handleClose">取消</Button>
|
||||
<Button type="primary" :loading="submitting" @click="handleSubmit">确定</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
|
@ -10,7 +10,8 @@ export default defineConfig({
|
|||
}
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
port: 5175,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
|
|
|
|||
Loading…
Reference in New Issue