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:
chiguyong 2026-03-22 01:25:11 +08:00
parent eb399474f4
commit 1bcc0facd2
48 changed files with 5327 additions and 513 deletions

95
dev-server.mjs Normal file
View File

@ -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}`);
});

422
package-lock.json generated
View File

@ -17,9 +17,9 @@
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.42.0", "@playwright/test": "^1.42.0",
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"@vitejs/plugin-vue": "^5.0.0", "@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.4.0", "typescript": "^5.4.0",
"vite": "^5.2.0", "vite": "^5.2.14",
"vitest": "^1.4.0", "vitest": "^1.4.0",
"vue-tsc": "^2.0.0" "vue-tsc": "^2.0.0"
} }
@ -129,9 +129,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5", "version": "0.20.2",
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -146,9 +146,9 @@
} }
}, },
"node_modules/@esbuild/android-arm": { "node_modules/@esbuild/android-arm": {
"version": "0.21.5", "version": "0.20.2",
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.20.2.tgz",
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -163,9 +163,9 @@
} }
}, },
"node_modules/@esbuild/android-arm64": { "node_modules/@esbuild/android-arm64": {
"version": "0.21.5", "version": "0.20.2",
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz",
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -180,9 +180,9 @@
} }
}, },
"node_modules/@esbuild/android-x64": { "node_modules/@esbuild/android-x64": {
"version": "0.21.5", "version": "0.20.2",
"resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.20.2.tgz",
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -197,9 +197,9 @@
} }
}, },
"node_modules/@esbuild/darwin-arm64": { "node_modules/@esbuild/darwin-arm64": {
"version": "0.21.5", "version": "0.20.2",
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz",
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -214,9 +214,9 @@
} }
}, },
"node_modules/@esbuild/darwin-x64": { "node_modules/@esbuild/darwin-x64": {
"version": "0.21.5", "version": "0.20.2",
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz",
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -231,9 +231,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-arm64": { "node_modules/@esbuild/freebsd-arm64": {
"version": "0.21.5", "version": "0.20.2",
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz",
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -248,9 +248,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-x64": { "node_modules/@esbuild/freebsd-x64": {
"version": "0.21.5", "version": "0.20.2",
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz",
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -265,9 +265,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm": { "node_modules/@esbuild/linux-arm": {
"version": "0.21.5", "version": "0.20.2",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz",
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -282,9 +282,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm64": { "node_modules/@esbuild/linux-arm64": {
"version": "0.21.5", "version": "0.20.2",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz",
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -299,9 +299,9 @@
} }
}, },
"node_modules/@esbuild/linux-ia32": { "node_modules/@esbuild/linux-ia32": {
"version": "0.21.5", "version": "0.20.2",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz",
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -316,9 +316,9 @@
} }
}, },
"node_modules/@esbuild/linux-loong64": { "node_modules/@esbuild/linux-loong64": {
"version": "0.21.5", "version": "0.20.2",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz",
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@ -333,9 +333,9 @@
} }
}, },
"node_modules/@esbuild/linux-mips64el": { "node_modules/@esbuild/linux-mips64el": {
"version": "0.21.5", "version": "0.20.2",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz",
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==",
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
@ -350,9 +350,9 @@
} }
}, },
"node_modules/@esbuild/linux-ppc64": { "node_modules/@esbuild/linux-ppc64": {
"version": "0.21.5", "version": "0.20.2",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz",
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -367,9 +367,9 @@
} }
}, },
"node_modules/@esbuild/linux-riscv64": { "node_modules/@esbuild/linux-riscv64": {
"version": "0.21.5", "version": "0.20.2",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz",
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -384,9 +384,9 @@
} }
}, },
"node_modules/@esbuild/linux-s390x": { "node_modules/@esbuild/linux-s390x": {
"version": "0.21.5", "version": "0.20.2",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz",
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@ -401,9 +401,9 @@
} }
}, },
"node_modules/@esbuild/linux-x64": { "node_modules/@esbuild/linux-x64": {
"version": "0.21.5", "version": "0.20.2",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz",
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -418,9 +418,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-x64": { "node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5", "version": "0.20.2",
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz",
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -435,9 +435,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-x64": { "node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5", "version": "0.20.2",
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz",
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -452,9 +452,9 @@
} }
}, },
"node_modules/@esbuild/sunos-x64": { "node_modules/@esbuild/sunos-x64": {
"version": "0.21.5", "version": "0.20.2",
"resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz",
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -469,9 +469,9 @@
} }
}, },
"node_modules/@esbuild/win32-arm64": { "node_modules/@esbuild/win32-arm64": {
"version": "0.21.5", "version": "0.20.2",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz",
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -486,9 +486,9 @@
} }
}, },
"node_modules/@esbuild/win32-ia32": { "node_modules/@esbuild/win32-ia32": {
"version": "0.21.5", "version": "0.20.2",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz",
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -503,9 +503,9 @@
} }
}, },
"node_modules/@esbuild/win32-x64": { "node_modules/@esbuild/win32-x64": {
"version": "0.21.5", "version": "0.20.2",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz",
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -555,9 +555,9 @@
} }
}, },
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.59.0", "version": "4.59.1",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.1.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "integrity": "sha512-xB0b51TB7IfDEzAojXahmr+gfA00uYVInJGgNNkeQG6RPnCPGr7udsylFLTubuIUSRE6FkcI1NElyRt83PP5oQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -569,9 +569,9 @@
] ]
}, },
"node_modules/@rollup/rollup-android-arm64": { "node_modules/@rollup/rollup-android-arm64": {
"version": "4.59.0", "version": "4.59.1",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.1.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "integrity": "sha512-XOjPId0qwSDKHaIsdzHJtKCxX0+nH8MhBwvrNsT7tVyKmdTx1jJ4XzN5RZXCdTzMpufLb+B8llTC0D8uCrLhcw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -583,9 +583,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.59.0", "version": "4.59.1",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.1.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "integrity": "sha512-vQuRd28p0gQpPrS6kppd8IrWmFo42U8Pz1XLRjSZXq5zCqyMDYFABT7/sywL11mO1EL10Qhh7MVPEwkG8GiBeg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -597,9 +597,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.59.0", "version": "4.59.1",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.1.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "integrity": "sha512-x6VG6U29+Ivlnajrg1IHdzXeAwSoEHBFVO+CtC9Brugx6de712CUJobRUxsIA0KYrQvCmzNrMPFTT1A4CCqNTg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -611,9 +611,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-arm64": { "node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.59.0", "version": "4.59.1",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.1.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "integrity": "sha512-Sgi0Uo6t1YCHJMNO3Y8+bm+SvOanUGkoZKn/VJPwYUe2kp31X5KnXmzKd/NjW8iA3gFcfNZ64zh14uOGrIllCQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -625,9 +625,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-x64": { "node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.59.0", "version": "4.59.1",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.1.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "integrity": "sha512-AM4xnwEZwukdhk7laMWfzWu9JGSVnJd+Fowt6Fd7QW1nrf3h0Hp7Qx5881M4aqrUlKBCybOxz0jofvIIfl7C5g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -639,9 +639,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": { "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.59.0", "version": "4.59.1",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.1.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "integrity": "sha512-KUizqxpwaR2AZdAUsMWfL/C94pUu7TKpoPd88c8yFVixJ+l9hejkrwoK5Zj3wiNh65UeyryKnJyxL1b7yNqFQA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -653,9 +653,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-musleabihf": { "node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.59.0", "version": "4.59.1",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.1.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "integrity": "sha512-MZoQ/am77ckJtZGFAtPucgUuJWiop3m2R3lw7tC0QCcbfl4DRhQUBUkHWCkcrT3pqy5Mzv5QQgY6Dmlba6iTWg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -667,9 +667,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-gnu": { "node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.59.0", "version": "4.59.1",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.1.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "integrity": "sha512-Sez95TP6xGjkWB1608EfhCX1gdGrO5wzyN99VqzRtC17x/1bhw5VU1V0GfKUwbW/Xr1J8mSasoFoJa6Y7aGGSA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -681,9 +681,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-musl": { "node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.59.0", "version": "4.59.1",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.1.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "integrity": "sha512-9Cs2Seq98LWNOJzR89EGTZoiP8EkZ9UbQhBlDgfAkM6asVna1xJ04W2CLYWDN/RpUgOjtQvcv8wQVi1t5oQazA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -695,9 +695,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-gnu": { "node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.59.0", "version": "4.59.1",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.1.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "integrity": "sha512-n9yqttftgFy7IrNEnHy1bOp6B4OSe8mJDiPkT7EqlM9FnKOwUMnCK62ixW0Kd9Clw0/wgvh8+SqaDXMFvw3KqQ==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@ -709,9 +709,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-musl": { "node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.59.0", "version": "4.59.1",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.1.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "integrity": "sha512-SfpNXDzVTqs/riak4xXcLpq5gIQWsqGWMhN1AGRQKB4qGSs4r0sEs3ervXPcE1O9RsQ5bm8Muz6zmQpQnPss1g==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@ -723,9 +723,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-gnu": { "node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.59.0", "version": "4.59.1",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.1.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "integrity": "sha512-LjaChED0wQnjKZU+tsmGbN+9nN1XhaWUkAlSbTdhpEseCS4a15f/Q8xC2BN4GDKRzhhLZpYtJBZr2NZhR0jvNw==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -737,9 +737,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-musl": { "node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.59.0", "version": "4.59.1",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.1.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "integrity": "sha512-ojW7iTJSIs4pwB2xV6QXGwNyDctvXOivYllttuPbXguuKDX5vwpqYJsHc6D2LZzjDGHML414Tuj3LvVPe1CT1A==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -751,9 +751,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-gnu": { "node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.59.0", "version": "4.59.1",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.1.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "integrity": "sha512-FP+Q6WTcxxvsr0wQczhSE+tOZvFPV8A/mUE6mhZYFW9/eea/y/XqAgRoLLMuE9Cz0hfX5bi7p116IWoB+P237A==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -765,9 +765,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-musl": { "node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.59.0", "version": "4.59.1",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.1.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "integrity": "sha512-L1uD9b/Ig8Z+rn1KttCJjwhN1FgjRMBKsPaBsDKkfUl7GfFq71pU4vWCnpOsGljycFEbkHWARZLf4lMYg3WOLw==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -779,9 +779,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-s390x-gnu": { "node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.59.0", "version": "4.59.1",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.1.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "integrity": "sha512-EZc9NGTk/oSUzzOD4nYY4gIjteo2M3CiozX6t1IXGCOdgxJTlVu/7EdPeiqeHPSIrxkLhavqpBAUCfvC6vBOug==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@ -793,9 +793,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.59.0", "version": "4.59.1",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.1.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "integrity": "sha512-NQ9KyU1Anuy59L8+HHOKM++CoUxrQWrZWXRik4BJFm+7i5NP6q/SW43xIBr80zzt+PDBJ7LeNmloQGfa0JGk0w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -807,9 +807,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-musl": { "node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.59.0", "version": "4.59.1",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.1.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "integrity": "sha512-GZkLk2t6naywsveSFBsEb0PLU+JC9ggVjbndsbG20VPhar6D1gkMfCx4NfP9owpovBXTN+eRdqGSkDGIxPHhmQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -821,9 +821,9 @@
] ]
}, },
"node_modules/@rollup/rollup-openbsd-x64": { "node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.59.0", "version": "4.59.1",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.1.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "integrity": "sha512-1hjG9Jpl2KDOetr64iQd8AZAEjkDUUK5RbDkYWsViYLC1op1oNzdjMJeFiofcGhqbNTaY2kfgqowE7DILifsrA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -835,9 +835,9 @@
] ]
}, },
"node_modules/@rollup/rollup-openharmony-arm64": { "node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.59.0", "version": "4.59.1",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.1.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "integrity": "sha512-ARoKfflk0SiiYm3r1fmF73K/yB+PThmOwfWCk1sr7x/k9dc3uGLWuEE9if+Pw21el8MSpp3TMnG5vLNsJ/MMGQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -849,9 +849,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-arm64-msvc": { "node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.59.0", "version": "4.59.1",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.1.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "integrity": "sha512-oOST61G6VM45Mz2vdzWMr1s2slI7y9LqxEV5fCoWi2MDONmMvgsJVHSXxce/I2xOSZPTZ47nDPOl1tkwKWSHcw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -863,9 +863,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-ia32-msvc": { "node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.59.0", "version": "4.59.1",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.1.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "integrity": "sha512-x5WgLi5dWpRz7WclKBGEF15LcWTh0ewrHM6Cq4A+WUbkysUMZNeqt05bwPonOQ3ihPS/WMhAZV5zB1DfnI4Sxg==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -877,9 +877,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-gnu": { "node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.59.0", "version": "4.59.1",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.1.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "integrity": "sha512-wS+zHAJRVP5zOL0e+a3V3E/NTEwM2HEvvNKoDy5Xcfs0o8lljxn+EAFPkUsxihBdmDq1JWzXmmB9cbssCPdxxw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -891,9 +891,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-msvc": { "node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.59.0", "version": "4.59.1",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.1.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "integrity": "sha512-rhHyrMeLpErT/C7BxcEsU4COHQUzHyrPYW5tOZUeUhziNtRuYxmDWvqQqzpuUt8xpOgmbKa1btGXfnA/ANVO+g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -939,9 +939,9 @@
} }
}, },
"node_modules/@vitejs/plugin-vue": { "node_modules/@vitejs/plugin-vue": {
"version": "5.2.4", "version": "5.2.1",
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.1.tgz",
"integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", "integrity": "sha512-cxh314tzaWwOLqVes2gnnCtvBDcM1UMdn+iFR+UjAn411dPT3tOmqrJjbMd7koZpMAmBM/GqeV4n9ge7JSiJJQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -1610,9 +1610,9 @@
} }
}, },
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.21.5", "version": "0.20.2",
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.20.2.tgz",
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
@ -1623,29 +1623,29 @@
"node": ">=12" "node": ">=12"
}, },
"optionalDependencies": { "optionalDependencies": {
"@esbuild/aix-ppc64": "0.21.5", "@esbuild/aix-ppc64": "0.20.2",
"@esbuild/android-arm": "0.21.5", "@esbuild/android-arm": "0.20.2",
"@esbuild/android-arm64": "0.21.5", "@esbuild/android-arm64": "0.20.2",
"@esbuild/android-x64": "0.21.5", "@esbuild/android-x64": "0.20.2",
"@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-arm64": "0.20.2",
"@esbuild/darwin-x64": "0.21.5", "@esbuild/darwin-x64": "0.20.2",
"@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-arm64": "0.20.2",
"@esbuild/freebsd-x64": "0.21.5", "@esbuild/freebsd-x64": "0.20.2",
"@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm": "0.20.2",
"@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-arm64": "0.20.2",
"@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-ia32": "0.20.2",
"@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-loong64": "0.20.2",
"@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-mips64el": "0.20.2",
"@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-ppc64": "0.20.2",
"@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-riscv64": "0.20.2",
"@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-s390x": "0.20.2",
"@esbuild/linux-x64": "0.21.5", "@esbuild/linux-x64": "0.20.2",
"@esbuild/netbsd-x64": "0.21.5", "@esbuild/netbsd-x64": "0.20.2",
"@esbuild/openbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.20.2",
"@esbuild/sunos-x64": "0.21.5", "@esbuild/sunos-x64": "0.20.2",
"@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-arm64": "0.20.2",
"@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-ia32": "0.20.2",
"@esbuild/win32-x64": "0.21.5" "@esbuild/win32-x64": "0.20.2"
} }
}, },
"node_modules/estree-walker": { "node_modules/estree-walker": {
@ -2336,9 +2336,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.59.0", "version": "4.59.1",
"resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.59.0.tgz", "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.59.1.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "integrity": "sha512-iZKH8BeoCwTCBTZBZWQQMreekd4mdomwdjIQ40GC1oZm6o+8PnNMIxFOiCsGMWeS8iDJ7KZcl7KwmKk/0HOQpA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -2352,31 +2352,31 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm-eabi": "4.59.1",
"@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-android-arm64": "4.59.1",
"@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.1",
"@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.1",
"@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.1",
"@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.1",
"@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.1",
"@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.1",
"@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.1",
"@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.1",
"@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.1",
"@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.1",
"@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.1",
"@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.1",
"@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.1",
"@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.1",
"@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.1",
"@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.1",
"@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.1",
"@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.1",
"@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.1",
"@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.1",
"@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.1",
"@rollup/rollup-win32-x64-msvc": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.1",
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
@ -2568,15 +2568,15 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.21", "version": "5.2.14",
"resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", "resolved": "https://registry.npmmirror.com/vite/-/vite-5.2.14.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "integrity": "sha512-TFQLuwWLPms+NBNlh0D9LZQ+HXW471COABxw/9TEUBrjuHMo9BrYBPrN/SYAwIuVL+rLerycxiLT41t4f5MZpA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.20.1",
"postcss": "^8.4.43", "postcss": "^8.4.38",
"rollup": "^4.20.0" "rollup": "^4.13.0"
}, },
"bin": { "bin": {
"vite": "bin/vite.js" "vite": "bin/vite.js"
@ -2595,7 +2595,6 @@
"less": "*", "less": "*",
"lightningcss": "^1.21.0", "lightningcss": "^1.21.0",
"sass": "*", "sass": "*",
"sass-embedded": "*",
"stylus": "*", "stylus": "*",
"sugarss": "*", "sugarss": "*",
"terser": "^5.4.0" "terser": "^5.4.0"
@ -2613,9 +2612,6 @@
"sass": { "sass": {
"optional": true "optional": true
}, },
"sass-embedded": {
"optional": true
},
"stylus": { "stylus": {
"optional": true "optional": true
}, },

View File

@ -4,6 +4,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:simple": "node dev-server.mjs",
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest", "test": "vitest",
@ -17,12 +18,12 @@
"vue-router": "^4.3.0" "vue-router": "^4.3.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.42.0",
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"@vitejs/plugin-vue": "^5.0.0", "@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.4.0", "typescript": "^5.4.0",
"vite": "^5.2.0", "vite": "^5.2.14",
"vitest": "^1.4.0", "vitest": "^1.4.0",
"vue-tsc": "^2.0.0", "vue-tsc": "^2.0.0"
"@playwright/test": "^1.42.0"
} }
} }

26
src/api/permission.ts Normal file
View File

@ -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}`)
}

View File

@ -2,29 +2,37 @@ import request from '@/utils/request'
import type { Role } from '@/types' import type { Role } from '@/types'
export const getRoles = () => { export const getRoles = () => {
return request.get<Role[]>('/roles') return request.get<Role[]>('/api/roles')
} }
export const getRole = (id: string) => { export const getRole = (id: string) => {
return request.get<Role>(`/roles/${id}`) return request.get<Role>(`/api/roles/${id}`)
} }
export const getRolesByProject = (projectId: string) => { 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>) => { 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>) => { 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) => { export const deleteRole = (id: string) => {
return request.delete(`/roles/${id}`) return request.delete(`/api/roles/${id}`)
} }
export const assignPermissions = (roleId: string, permissionIds: string[]) => { 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}`)
} }

View File

@ -2,29 +2,49 @@ import request from '@/utils/request'
import type { User } from '@/types' import type { User } from '@/types'
export const getUsers = () => { export const getUsers = () => {
return request.get<User[]>('/users') return request.get<User[]>('/api/users')
} }
export const getUser = (id: string) => { export const getUser = (id: string) => {
return request.get<User>(`/users/${id}`) return request.get<User>(`/api/users/${id}`)
} }
export const createUser = (data: Partial<User>) => { 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>) => { 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) => { 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) => { 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[]) => { 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}`)
} }

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

65
src/components/index.ts Normal file
View File

@ -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'

View File

@ -5,6 +5,7 @@ import 'ant-design-vue/dist/reset.css'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import './style.css' import './style.css'
import './styles/page-common.css'
const app = createApp(App) const app = createApp(App)

View File

@ -39,6 +39,12 @@ const router = createRouter({
component: () => import('@/views/system/Permissions.vue'), component: () => import('@/views/system/Permissions.vue'),
meta: { title: '权限管理' } meta: { title: '权限管理' }
}, },
{
path: 'system/audit',
name: 'Audit',
component: () => import('@/views/system/Audit.vue'),
meta: { title: '审计日志' }
},
{ {
path: 'project/list', path: 'project/list',
name: 'ProjectList', name: 'ProjectList',
@ -50,7 +56,7 @@ const router = createRouter({
] ]
}) })
router.beforeEach((to, from, next) => { router.beforeEach((to, _from, next) => {
const userStore = useUserStore() const userStore = useUserStore()
if (to.path !== '/login' && !userStore.isLoggedIn()) { if (to.path !== '/login' && !userStore.isLoggedIn()) {
next('/login') next('/login')

View File

@ -9,12 +9,18 @@ export const useUserStore = defineStore('user', () => {
const login = async (data: LoginRequest) => { const login = async (data: LoginRequest) => {
const res = await loginApi(data) const res = await loginApi(data)
token.value = res.data.token const loginData = res.data.data
userInfo.value = { if (!loginData?.token) {
username: res.data.username, throw new Error('登录失败未获取到token')
realName: res.data.realName
} }
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 () => { const logout = async () => {
@ -22,9 +28,29 @@ export const useUserStore = defineStore('user', () => {
token.value = '' token.value = ''
userInfo.value = null userInfo.value = null
localStorage.removeItem('token') 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 { return {
token, token,

View File

@ -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 中记录

360
src/styles/DESIGN_SPEC.md Normal file
View File

@ -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`
### 登录卡片
- 宽度420pxPC、100% - 32px移动端
- 内边距48px 40pxPC、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 区域
- Logo48px × 48px12px 圆角
- 标题24pxfont-weight: 600letter-spacing: 0.5px
- 副标题14pxcolor: #8c8c8c
### 表单
- 登录按钮:高度 44px字号 16pxletter-spacing: 2px
- 按钮渐变:`linear-gradient(135deg, #1890ff 0%, #096dd9 100%)`
- 按钮阴影:`0 2px 8px rgba(24, 144, 255, 0.3)`
- 输入框圆角8px
- 输入框图标color: #bfbfbffocus 时变为 #1890ff
### 表单额外项
- 记住登录 + 忘记密码flex, space-between
- 忘记密码链接color: #1890ffhover 时 #40a9ff
### 底部提示
- 居中12pxcolor: #bfbfbf
- 距离表单32px
---
## 组件库规划
### 基础组件
| 组件 | 说明 |
|------|------|
| PageHeader | 页面标题区 |
| FilterBar | 筛选栏 |
| TableCard | 表格卡片 |
| StatCard | 统计卡片 |
| ListCard | 列表卡片 |
| DrawerForm | 抽屉表单 |
| StatusTag | 状态标签 |
| ConfirmPopover | 确认气泡 |
### 业务组件
| 组件 | 说明 |
|------|------|
| UserSelect | 用户选择器 |
| RoleSelect | 角色选择器 |
| ProjectSelect | 项目选择器 |
| DateRangePicker | 日期范围选择 |
### 页面模板
| 模板 | 说明 |
|------|------|
| ListPage | 列表页模板 |
| DashboardPage | 仪表盘模板 |
| FormPage | 表单页模板 |

76
src/styles/common.css Normal file
View File

@ -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;
}

249
src/styles/page-common.css Normal file
View File

@ -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;
}
}

View File

@ -2,7 +2,7 @@ import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'ax
import type { ApiResponse } from '@/types' import type { ApiResponse } from '@/types'
const request: AxiosInstance = axios.create({ const request: AxiosInstance = axios.create({
baseURL: '/api', baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api',
timeout: 10000 timeout: 10000
}) })
@ -25,7 +25,7 @@ request.interceptors.response.use(
if (res.code !== 200) { if (res.code !== 200) {
return Promise.reject(new Error(res.message || 'Error')) return Promise.reject(new Error(res.message || 'Error'))
} }
return res return response
}, },
(error: AxiosError) => { (error: AxiosError) => {
if (error.response?.status === 401) { if (error.response?.status === 401) {

View File

@ -1,23 +1,189 @@
<script setup lang="ts"> <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 = [ const router = useRouter()
{ title: '用户总数', value: 0 },
{ title: '角色总数', value: 0 }, const stats = [
{ title: '项目总数', value: 0 }, { label: '用户总数', value: '1,286', change: '+12.5%', up: true, icon: UserOutlined },
{ title: '空间节点', value: 0 } { 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> </script>
<template> <template>
<div> <div class="page-container">
<h2>仪表盘</h2> <!-- 页面标题 -->
<Row :gutter="16" style="margin-top: 24px"> <div class="page-header">
<Col :span="6" v-for="stat in statistics" :key="stat.title"> <h2 class="page-title">仪表盘</h2>
<Card> <span class="header-date">{{ new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' }) }}</span>
<Statistic :title="stat.title" :value="stat.value" /> </div>
</Card>
<!-- 统计卡片 -->
<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> </Col>
</Row> </Row>
</div> </div>
</template> </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>

View File

@ -1,30 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
import { h } from 'vue'
import { RouterView, useRouter } from 'vue-router' import { RouterView, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { Layout, Menu, Button } from 'ant-design-vue' import { Layout, Menu, Button } from 'ant-design-vue'
import type { MenuProps } from 'ant-design-vue'
import { import {
DashboardOutlined, DashboardOutlined,
UserOutlined, UserOutlined,
TeamOutlined, TeamOutlined,
AppstoreOutlined, AppstoreOutlined,
BuildingOutlined, BuildOutlined,
LogoutOutlined LogoutOutlined,
AuditOutlined
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue'
const { Header, Sider, Content } = Layout const { Header, Sider, Content } = Layout
const router = useRouter() const router = useRouter()
const userStore = useUserStore() const userStore = useUserStore()
const menuItems = [ const menuItems: MenuProps['items'] = [
{ key: '/dashboard', icon: DashboardOutlined, label: '仪表盘' }, { key: '/dashboard', icon: () => h(DashboardOutlined), label: '仪表盘' },
{ key: '/system/users', icon: UserOutlined, label: '用户管理' }, { key: '/system/users', icon: () => h(UserOutlined), label: '用户管理' },
{ key: '/system/roles', icon: TeamOutlined, label: '角色管理' }, { key: '/system/roles', icon: () => h(TeamOutlined), label: '角色管理' },
{ key: '/system/permissions', icon: AppstoreOutlined, label: '权限管理' }, { key: '/system/permissions', icon: () => h(AppstoreOutlined), label: '权限管理' },
{ key: '/project/list', icon: BuildingOutlined, label: '项目管理' } { key: '/system/audit', icon: () => h(AuditOutlined), label: '审计日志' },
{ key: '/project/list', icon: () => h(BuildOutlined), label: '项目管理' }
] ]
const handleMenuClick = ({ key }: { key: string }) => { const handleMenuClick = (e: any) => {
router.push(key) router.push(e.key)
} }
const handleLogout = async () => { const handleLogout = async () => {
@ -51,7 +55,7 @@ const handleLogout = async () => {
<LogoutOutlined /> 退出 <LogoutOutlined /> 退出
</Button> </Button>
</Header> </Header>
<Content style="margin: 16px; padding: 24px; background: #fff; min-height: 280px"> <Content style="margin: 16px; background: #fff; min-height: 280px">
<RouterView /> <RouterView />
</Content> </Content>
</Layout> </Layout>

View File

@ -2,6 +2,7 @@
import { reactive, ref } from 'vue' import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
const router = useRouter() const router = useRouter()
@ -9,7 +10,8 @@ const userStore = useUserStore()
const formState = reactive({ const formState = reactive({
username: '', username: '',
password: '' password: '',
remember: true
}) })
const loading = ref(false) const loading = ref(false)
@ -22,11 +24,15 @@ const handleSubmit = async () => {
loading.value = true loading.value = true
try { try {
console.log('开始登录请求...')
await userStore.login(formState) await userStore.login(formState)
console.log('登录成功')
message.success('登录成功') message.success('登录成功')
router.push('/') window.location.href = '/'
} catch (error: any) { } catch (error: unknown) {
message.error(error.message || '登录失败') console.error('登录失败:', error)
const errorMessage = error instanceof Error ? error.message : '登录失败,请检查用户名和密码'
message.error(errorMessage)
} finally { } finally {
loading.value = false loading.value = false
} }
@ -35,54 +41,216 @@ const handleSubmit = async () => {
<template> <template>
<div class="login-container"> <div class="login-container">
<div class="login-box"> <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> <h1 class="title">Ether 物业管理系统</h1>
<a-form :model="formState" @finish="handleSubmit" layout="vertical"> <p class="subtitle">智慧物业 · 便捷生活</p>
</div>
<a-form :model="formState" @finish="handleSubmit" layout="vertical" class="login-form">
<a-form-item label="用户名" name="username"> <a-form-item label="用户名" name="username">
<a-input <a-input
v-model:value="formState.username" v-model:value="formState.username"
placeholder="请输入用户名" placeholder="请输入用户名"
size="large" size="large"
/> allow-clear
>
<template #prefix>
<UserOutlined class="input-icon" />
</template>
</a-input>
</a-form-item> </a-form-item>
<a-form-item label="密码" name="password"> <a-form-item label="密码" name="password">
<a-input-password <a-input-password
v-model:value="formState.password" v-model:value="formState.password"
placeholder="请输入密码" placeholder="请输入密码"
size="large" size="large"
/> >
<template #prefix>
<LockOutlined class="input-icon" />
</template>
</a-input-password>
</a-form-item> </a-form-item>
<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-button>
</a-form-item> </a-form-item>
</a-form> </a-form>
<div class="login-footer">
<span>推荐使用 Chrome Edge 浏览器</span>
</div>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.login-container { .login-container {
position: relative;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
min-height: 100vh; min-height: 100vh;
background: #f5f5f5; background: linear-gradient(135deg, #f5f7fa 0%, #e4e8ec 100%);
} }
.login-box { .login-background {
width: 400px; position: absolute;
padding: 40px; 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; background: #fff;
border-radius: 8px; border-radius: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 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 { .title {
text-align: center; margin: 0 0 8px;
margin-bottom: 32px;
font-size: 24px; 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> </style>

View File

@ -1,26 +1,52 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' 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 { getProjects, createProject, updateProject, deleteProject } from '@/api/project'
import type { Project } from '@/types' 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 = [ const columns = [
{ title: '项目编码', dataIndex: 'code', key: 'code' }, { title: '项目编码', dataIndex: 'code', key: 'code', width: 120 },
{ title: '项目名称', dataIndex: 'name', key: 'name' }, { title: '项目名称', dataIndex: 'name', key: 'name', width: 200 },
{ title: '地址', dataIndex: 'address', key: 'address' }, { title: '地址', dataIndex: 'address', key: 'address', ellipsis: true },
{ title: '状态', dataIndex: 'status', key: 'status' }, { title: '状态', dataIndex: 'status', key: 'status', width: 80 },
{ title: '操作', key: 'action', width: 150 } { title: '操作', key: 'action', width: 120, fixed: 'right' }
] ]
const projects = ref<Project[]>([]) const projects = ref<Project[]>([])
const loading = ref(false) const loading = ref(false)
const modalVisible = ref(false) const drawerVisible = ref(false)
const editingProject = ref<Project | null>(null) const drawerTitle = ref('')
const [form] = Form.useForm() const formRef = ref()
const submitting = ref(false)
const formState = ref<ProjectFormData>({
id: '',
code: '',
name: '',
description: '',
address: '',
province: '',
city: '',
district: '',
status: 'ACTIVE'
})
const statusOptions = [ const statusOptions = [
{ value: 'ACTIVE', label: '正常' }, { value: 'ACTIVE', label: '正常', color: 'success' },
{ value: 'DISABLED', label: '禁用' } { value: 'DISABLED', label: '禁用', color: 'error' }
] ]
const fetchProjects = async () => { const fetchProjects = async () => {
@ -28,7 +54,7 @@ const fetchProjects = async () => {
try { try {
const res = await getProjects() const res = await getProjects()
projects.value = res.data projects.value = res.data
} catch (error) { } catch {
message.error('获取项目列表失败') message.error('获取项目列表失败')
} finally { } finally {
loading.value = false loading.value = false
@ -36,15 +62,35 @@ const fetchProjects = async () => {
} }
const handleAdd = () => { const handleAdd = () => {
editingProject.value = null drawerTitle.value = '新增项目'
form.resetFields() formState.value = {
modalVisible.value = true id: '',
code: '',
name: '',
description: '',
address: '',
province: '',
city: '',
district: '',
status: 'ACTIVE'
}
drawerVisible.value = true
} }
const handleEdit = (record: Project) => { const handleEdit = (record: Project) => {
editingProject.value = record drawerTitle.value = '编辑项目'
form.setFieldsValue(record) formState.value = {
modalVisible.value = true 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) => { const handleDelete = async (id: string) => {
@ -52,87 +98,154 @@ const handleDelete = async (id: string) => {
await deleteProject(id) await deleteProject(id)
message.success('删除成功') message.success('删除成功')
fetchProjects() fetchProjects()
} catch (error) { } catch {
message.error('删除失败') message.error('删除失败')
} }
} }
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
const values = await form.validate() await formRef.value.validate()
if (editingProject.value) { submitting.value = true
await updateProject(editingProject.value.id, values)
if (formState.value.id) {
await updateProject(formState.value.id, formState.value)
message.success('更新成功') message.success('更新成功')
} else { } else {
await createProject(values) await createProject(formState.value)
message.success('创建成功') message.success('创建成功')
} }
modalVisible.value = false drawerVisible.value = false
fetchProjects() fetchProjects()
} catch (error) { } catch (error: any) {
if (error.errorFields) return
message.error('操作失败') 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) onMounted(fetchProjects)
</script> </script>
<template> <template>
<div> <div class="page-container">
<div style="margin-bottom: 16px"> <!-- 页面标题 -->
<Button type="primary" @click="handleAdd">新增项目</Button> <div class="page-header">
<h2 class="page-title">项目管理</h2>
<div class="page-header-actions">
<Button type="primary" @click="handleAdd">
<PlusOutlined /> 新增项目
</Button>
</div> </div>
</div>
<!-- 表格 -->
<div class="table-card">
<Table <Table
:columns="columns" :columns="columns"
:data-source="projects" :data-source="projects"
:loading="loading" :loading="loading"
:row-key="(record: Project) => record.id" :row-key="(record: Project) => record.id"
:pagination="{ pageSize: 10, showSizeChanger: true, showTotal: (total: number) => `共 ${total} 条` }"
> >
<template #bodyCell="{ column, record }"> <template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'"> <template v-if="column.key === 'status'">
<a-tag :color="record.status === 'ACTIVE' ? 'green' : 'red'"> <a-tag :color="getStatusColor(record.status)">
{{ record.status === 'ACTIVE' ? '正常' : '禁用' }} {{ getStatusLabel(record.status) }}
</a-tag> </a-tag>
</template> </template>
<template v-else-if="column.key === 'action'"> <template v-else-if="column.key === 'action'">
<Button type="link" size="small" @click="handleEdit(record)">编辑</Button> <Space>
<Button type="link" size="small" danger @click="handleDelete(record.id)">删除</Button> <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>
</template> </template>
</Table> </Table>
</div>
<Modal <!-- 抽屉 -->
v-model:open="modalVisible" <Drawer
:title="editingProject ? '编辑项目' : '新增项目'" v-model:open="drawerVisible"
@ok="handleSubmit" :title="drawerTitle"
width="600px" width="560px"
:footer-style="{ textAlign: 'right' }"
@close="handleClose"
> >
<Form :form="form" layout="vertical"> <Form
<Form.Item name="code" label="项目编码" :rules="[{ required: true, message: '请输入项目编码' }]"> ref="formRef"
<Input :disabled="!!editingProject" /> :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>
<Form.Item name="name" label="项目名称" :rules="[{ required: true, message: '请输入项目名称' }]"> <Form.Item label="项目名称" name="name">
<Input /> <Input v-model:value="formState.name" placeholder="请输入项目名称" />
</Form.Item> </Form.Item>
<Form.Item name="description" label="描述"> <Form.Item label="描述" name="description">
<Input.TextArea /> <Input.TextArea v-model:value="formState.description" placeholder="请输入描述" :rows="2" />
</Form.Item> </Form.Item>
<Form.Item name="address" label="地址"> <Form.Item label="地址" name="address">
<Input /> <Input v-model:value="formState.address" placeholder="请输入详细地址" />
</Form.Item> </Form.Item>
<Form.Item name="province" label="省份"> <Form.Item label="省份" name="province">
<Input /> <Input v-model:value="formState.province" placeholder="请输入省份" />
</Form.Item> </Form.Item>
<Form.Item name="city" label="城市"> <Form.Item label="城市" name="city">
<Input /> <Input v-model:value="formState.city" placeholder="请输入城市" />
</Form.Item> </Form.Item>
<Form.Item name="district" label="区县"> <Form.Item label="区县" name="district">
<Input /> <Input v-model:value="formState.district" placeholder="请输入区县" />
</Form.Item> </Form.Item>
<Form.Item name="status" label="状态" initial-value="ACTIVE"> <Form.Item label="状态" name="status">
<Select :options="statusOptions" /> <Select v-model:value="formState.status" :options="statusOptions" />
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> <template #footer>
<Space>
<Button @click="handleClose">取消</Button>
<Button type="primary" :loading="submitting" @click="handleSubmit">确定</Button>
</Space>
</template>
</Drawer>
</div> </div>
</template> </template>

200
src/views/system/Audit.vue Normal file
View File

@ -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>

View File

@ -1,54 +1,288 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted, reactive } from 'vue'
import { Table, Button, message } from 'ant-design-vue' import { Table, Tag, message } from 'ant-design-vue'
import type { Permission } from '@/types' import type { Permission } from '@/types'
import { getPermissions, createPermission, updatePermission, deletePermission } from '@/api/permission'
const columns = [ // eslint-disable-next-line @typescript-eslint/no-explicit-any
{ title: '权限编码', dataIndex: 'code', key: 'code' }, const columns: any[] = [
{ title: '权限名称', dataIndex: 'name', key: 'name' }, { title: '权限编码', dataIndex: 'code', key: 'code', width: 150 },
{ title: '类型', dataIndex: 'type', key: 'type' }, { title: '权限名称', dataIndex: 'name', key: 'name', width: 150 },
{ title: '资源', dataIndex: 'resource', key: 'resource' }, { title: '类型', dataIndex: 'type', key: 'type', width: 100 },
{ title: '方法', dataIndex: 'method', key: 'method' }, { title: '资源', dataIndex: 'resource', key: 'resource', width: 180 },
{ title: '描述', dataIndex: 'description', key: 'description' } { 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 permissions = ref<Permission[]>([])
const loading = ref(false) const loading = ref(false)
const modalVisible = ref(false)
const modalTitle = ref('新建权限')
const editingId = ref<string | null>(null)
const typeMap: Record<string, string> = { //
MENU: '菜单', const searchKeyword = ref('')
BUTTON: '按钮',
API: '接口' //
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 () => { const fetchPermissions = async () => {
loading.value = true loading.value = true
try { try {
const res = await fetch('/api/permissions').then(r => r.json()) const res = await getPermissions()
permissions.value = res.data || [] permissions.value = res.data || []
} catch (error) { pagination.total = permissions.value.length
} catch {
message.error('获取权限列表失败') message.error('获取权限列表失败')
} finally { } finally {
loading.value = false 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) onMounted(fetchPermissions)
</script> </script>
<template> <template>
<div> <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 <Table
:columns="columns" :columns="columns"
:data-source="permissions" :data-source="permissions"
:loading="loading" :loading="loading"
:row-key="(record: Permission) => record.id" :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 #bodyCell="{ column, record }"> <template #bodyCell="{ column, record }">
<template v-if="column.key === 'type'"> <template v-if="column.key === 'type'">
<a-tag>{{ typeMap[record.type] || record.type }}</a-tag> <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>
</template> </template>
</Table> </Table>
</div> </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> </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>

View File

@ -1,31 +1,52 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' 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 { getRoles, createRole, updateRole, deleteRole } from '@/api/role'
import type { Role } from '@/types' import type { Role } from '@/types'
const columns = [ const columns = [
{ title: '角色编码', dataIndex: 'code', key: 'code' }, { title: '角色编码', dataIndex: 'code', key: 'code', width: 120 },
{ title: '角色名称', dataIndex: 'name', key: 'name' }, { title: '角色名称', dataIndex: 'name', key: 'name', width: 150 },
{ title: '描述', dataIndex: 'description', key: 'description' }, { title: '类型', dataIndex: 'type', key: 'type', width: 100 },
{ title: '状态', dataIndex: 'status', key: 'status' }, { title: '数据权限', dataIndex: 'dataScope', key: 'dataScope', width: 100 },
{ title: '操作', key: 'action', width: 150 } { 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 roles = ref<Role[]>([])
const loading = ref(false) const loading = ref(false)
const modalVisible = ref(false) const drawerVisible = ref(false)
const editingRole = ref<Role | null>(null) const drawerTitle = ref('')
const [form] = Form.useForm() const formRef = ref()
const submitting = ref(false)
const formState = ref({
id: '',
code: '',
name: '',
description: '',
type: '',
dataScope: 'SELF',
status: 'ACTIVE'
})
const statusOptions = [ const statusOptions = [
{ value: 'ACTIVE', label: '正常' }, { value: 'ACTIVE', label: '正常', color: 'success' },
{ value: 'DISABLED', label: '禁用' } { value: 'DISABLED', label: '禁用', color: 'error' }
] ]
const typeOptions = [ const typeOptions = [
{ value: 'SYSTEM', label: '系统角色' }, { 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 () => { const fetchRoles = async () => {
@ -33,7 +54,7 @@ const fetchRoles = async () => {
try { try {
const res = await getRoles() const res = await getRoles()
roles.value = res.data roles.value = res.data
} catch (error) { } catch {
message.error('获取角色列表失败') message.error('获取角色列表失败')
} finally { } finally {
loading.value = false loading.value = false
@ -41,15 +62,31 @@ const fetchRoles = async () => {
} }
const handleAdd = () => { const handleAdd = () => {
editingRole.value = null drawerTitle.value = '新增角色'
form.resetFields() formState.value = {
modalVisible.value = true id: '',
code: '',
name: '',
description: '',
type: '',
dataScope: 'SELF',
status: 'ACTIVE'
}
drawerVisible.value = true
} }
const handleEdit = (record: Role) => { const handleEdit = (record: Role) => {
editingRole.value = record drawerTitle.value = '编辑角色'
form.setFieldsValue(record) formState.value = {
modalVisible.value = true 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) => { const handleDelete = async (id: string) => {
@ -57,78 +94,183 @@ const handleDelete = async (id: string) => {
await deleteRole(id) await deleteRole(id)
message.success('删除成功') message.success('删除成功')
fetchRoles() fetchRoles()
} catch (error) { } catch {
message.error('删除失败') message.error('删除失败')
} }
} }
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
const values = await form.validate() await formRef.value.validate()
if (editingRole.value) { submitting.value = true
await updateRole(editingRole.value.id, values)
if (formState.value.id) {
await updateRole(formState.value.id, formState.value)
message.success('更新成功') message.success('更新成功')
} else { } else {
await createRole(values) await createRole(formState.value)
message.success('创建成功') message.success('创建成功')
} }
modalVisible.value = false drawerVisible.value = false
fetchRoles() fetchRoles()
} catch (error) { } catch (error: any) {
if (error.errorFields) return
message.error('操作失败') 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) onMounted(fetchRoles)
</script> </script>
<template> <template>
<div> <div class="page-container">
<div style="margin-bottom: 16px"> <!-- 页面标题 -->
<Button type="primary" @click="handleAdd">新增角色</Button> <div class="page-header">
<h2 class="page-title">角色管理</h2>
<div class="page-header-actions">
<Button type="primary" @click="handleAdd">
<PlusOutlined /> 新增角色
</Button>
</div> </div>
</div>
<!-- 表格 -->
<div class="table-card">
<Table <Table
:columns="columns" :columns="columns"
:data-source="roles" :data-source="roles"
:loading="loading" :loading="loading"
:row-key="(record: Role) => record.id" :row-key="(record: Role) => record.id"
:pagination="{ pageSize: 10, showSizeChanger: true, showTotal: (total: number) => `共 ${total} 条` }"
> >
<template #bodyCell="{ column, record }"> <template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'"> <template v-if="column.key === 'type'">
<a-tag :color="record.status === 'ACTIVE' ? 'green' : 'red'"> <a-tag>{{ getTypeLabel(record.type) }}</a-tag>
{{ record.status === 'ACTIVE' ? '正常' : '禁用' }} </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> </a-tag>
</template> </template>
<template v-else-if="column.key === 'action'"> <template v-else-if="column.key === 'action'">
<Button type="link" size="small" @click="handleEdit(record)">编辑</Button> <Space>
<Button type="link" size="small" danger @click="handleDelete(record.id)">删除</Button> <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>
</template> </template>
</Table> </Table>
</div>
<Modal <!-- 抽屉 -->
v-model:open="modalVisible" <Drawer
:title="editingRole ? '编辑角色' : '新增角色'" v-model:open="drawerVisible"
@ok="handleSubmit" :title="drawerTitle"
width="600px" width="480px"
:footer-style="{ textAlign: 'right' }"
@close="handleClose"
> >
<Form :form="form" layout="vertical"> <Form
<Form.Item name="code" label="角色编码" :rules="[{ required: true, message: '请输入角色编码' }]"> ref="formRef"
<Input :disabled="!!editingRole" /> :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>
<Form.Item name="name" label="角色名称" :rules="[{ required: true, message: '请输入角色名称' }]"> <Form.Item label="角色名称" name="name">
<Input /> <Input v-model:value="formState.name" placeholder="请输入角色名称" />
</Form.Item> </Form.Item>
<Form.Item name="description" label="描述"> <Form.Item label="描述" name="description">
<Input.TextArea /> <Input.TextArea v-model:value="formState.description" placeholder="请输入描述" :rows="3" />
</Form.Item> </Form.Item>
<Form.Item name="type" label="类型"> <Form.Item label="类型" name="type">
<Select :options="typeOptions" /> <Select v-model:value="formState.type" :options="typeOptions" placeholder="请选择类型" />
</Form.Item> </Form.Item>
<Form.Item name="status" label="状态" initial-value="ACTIVE"> <Form.Item label="数据权限" name="dataScope">
<Select :options="statusOptions" /> <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.Item>
</Form> </Form>
</Modal> <template #footer>
<Space>
<Button @click="handleClose">取消</Button>
<Button type="primary" :loading="submitting" @click="handleSubmit">确定</Button>
</Space>
</template>
</Drawer>
</div> </div>
</template> </template>

View File

@ -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>

View File

@ -1,133 +1,294 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' 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 { getUsers, createUser, updateUser, deleteUser } from '@/api/user'
import type { User } from '@/types' import type { User } from '@/types'
//
const columns = [ const columns = [
{ title: '用户名', dataIndex: 'username', key: 'username' }, { title: '用户名', dataIndex: 'username', key: 'username', width: 120 },
{ title: '姓名', dataIndex: 'realName', key: 'realName' }, { title: '姓名', dataIndex: 'realName', key: 'realName', width: 100 },
{ title: '手机', dataIndex: 'phone', key: 'phone' }, { title: '手机', dataIndex: 'phone', key: 'phone', width: 120 },
{ title: '状态', dataIndex: 'status', key: 'status' }, { title: '邮箱', dataIndex: 'email', key: 'email', ellipsis: true },
{ title: '操作', key: 'action', width: 150 } { title: '状态', dataIndex: 'status', key: 'status', width: 80 },
{ title: '操作', key: 'action', width: 120, fixed: 'right' }
] ]
//
const users = ref<User[]>([]) const users = ref<User[]>([])
const loading = ref(false) const loading = ref(false)
const modalVisible = ref(false) const drawerVisible = ref(false)
const editingUser = ref<User | null>(null) const drawerTitle = ref('')
const [form] = Form.useForm() 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 = [ const statusOptions = [
{ value: 'ACTIVE', label: '正常' }, { value: 'ACTIVE', label: '正常', color: 'success' },
{ value: 'LOCKED', label: '锁定' }, { value: 'LOCKED', label: '锁定', color: 'warning' },
{ value: 'DISABLED', label: '禁用' } { value: 'DISABLED', label: '禁用', color: 'error' }
] ]
//
const fetchUsers = async () => { const fetchUsers = async () => {
loading.value = true loading.value = true
try { 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 users.value = res.data
} catch (error) { } catch {
message.error('获取用户列表失败') message.error('获取用户列表失败')
} finally { } finally {
loading.value = false loading.value = false
} }
} }
//
const handleSearch = () => {
fetchUsers()
}
//
const handleReset = () => {
searchKeyword.value = ''
searchStatus.value = ''
fetchUsers()
}
//
const handleAdd = () => { const handleAdd = () => {
editingUser.value = null drawerTitle.value = '新增用户'
form.resetFields() formState.value = {
modalVisible.value = true id: '',
username: '',
password: '',
realName: '',
phone: '',
email: '',
status: 'ACTIVE'
}
drawerVisible.value = true
} }
//
const handleEdit = (record: User) => { const handleEdit = (record: User) => {
editingUser.value = record drawerTitle.value = '编辑用户'
form.setFieldsValue(record) formState.value = {
modalVisible.value = true 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) => { const handleDelete = async (id: string) => {
try { try {
await deleteUser(id) await deleteUser(id)
message.success('删除成功') message.success('删除成功')
fetchUsers() fetchUsers()
} catch (error) { } catch {
message.error('删除失败') message.error('删除失败')
} }
} }
//
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
const values = await form.validate() await formRef.value.validate()
if (editingUser.value) { submitting.value = true
await updateUser(editingUser.value.id, values)
const data = { ...formState.value }
if (!data.password) delete data.password
if (formState.value.id) {
await updateUser(formState.value.id, data)
message.success('更新成功') message.success('更新成功')
} else { } else {
await createUser(values) await createUser(data)
message.success('创建成功') message.success('创建成功')
} }
modalVisible.value = false
drawerVisible.value = false
fetchUsers() fetchUsers()
} catch (error) { } catch (error: any) {
if (error.errorFields) return
message.error('操作失败') 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) onMounted(fetchUsers)
</script> </script>
<template> <template>
<div> <div class="page-container">
<div style="margin-bottom: 16px"> <!-- 页面标题 -->
<Button type="primary" @click="handleAdd">新增用户</Button> <div class="page-header">
<h2 class="page-title">用户管理</h2>
<div class="page-header-actions">
<Button type="primary" @click="handleAdd">
<PlusOutlined /> 新增用户
</Button>
</div> </div>
</div>
<!-- 筛选区 -->
<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 <Table
:columns="columns" :columns="columns"
:data-source="users" :data-source="users"
:loading="loading" :loading="loading"
:row-key="(record: User) => record.id" :row-key="(record: User) => record.id"
:pagination="{ pageSize: 10, showSizeChanger: true, showTotal: (total: number) => `共 ${total} 条` }"
> >
<template #bodyCell="{ column, record }"> <template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'"> <template v-if="column.key === 'status'">
<a-tag :color="record.status === 'ACTIVE' ? 'green' : 'red'"> <a-tag :color="getStatusColor(record.status)">
{{ record.status === 'ACTIVE' ? '正常' : record.status === 'LOCKED' ? '锁定' : '禁用' }} {{ getStatusLabel(record.status) }}
</a-tag> </a-tag>
</template> </template>
<template v-else-if="column.key === 'action'"> <template v-else-if="column.key === 'action'">
<Button type="link" size="small" @click="handleEdit(record)">编辑</Button> <Space>
<Button type="link" size="small" danger @click="handleDelete(record.id)">删除</Button> <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>
</template> </template>
</Table> </Table>
</div>
<Modal <!-- 抽屉 -->
v-model:open="modalVisible" <Drawer
:title="editingUser ? '编辑用户' : '新增用户'" v-model:open="drawerVisible"
@ok="handleSubmit" :title="drawerTitle"
width="600px" width="480px"
:footer-style="{ textAlign: 'right' }"
@close="handleClose"
> >
<Form :form="form" layout="vertical"> <Form
<Form.Item name="username" label="用户名" :rules="[{ required: true, message: '请输入用户名' }]"> ref="formRef"
<Input :disabled="!!editingUser" /> :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>
<Form.Item v-if="!editingUser" name="password" label="密码" :rules="[{ required: true, message: '请输入密码' }]"> <Form.Item v-if="!formState.id" label="密码" name="password">
<Input.Password /> <Input.Password v-model:value="formState.password" placeholder="请输入密码" />
</Form.Item> </Form.Item>
<Form.Item name="realName" label="姓名"> <Form.Item label="姓名" name="realName">
<Input /> <Input v-model:value="formState.realName" placeholder="请输入姓名" />
</Form.Item> </Form.Item>
<Form.Item name="phone" label="手机"> <Form.Item label="手机" name="phone">
<Input /> <Input v-model:value="formState.phone" placeholder="请输入手机号" />
</Form.Item> </Form.Item>
<Form.Item name="email" label="邮箱"> <Form.Item label="邮箱" name="email">
<Input /> <Input v-model:value="formState.email" placeholder="请输入邮箱" />
</Form.Item> </Form.Item>
<Form.Item name="status" label="状态" initial-value="ACTIVE"> <Form.Item label="状态" name="status">
<Select :options="statusOptions" /> <Select v-model:value="formState.status" :options="statusOptions" />
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> <template #footer>
<Space>
<Button @click="handleClose">取消</Button>
<Button type="primary" :loading="submitting" @click="handleSubmit">确定</Button>
</Space>
</template>
</Drawer>
</div> </div>
</template> </template>

9
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@ -10,7 +10,8 @@ export default defineConfig({
} }
}, },
server: { server: {
port: 3000, host: '0.0.0.0',
port: 5175,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:8080', target: 'http://localhost:8080',