This commit is contained in:
suguo 2026-03-12 17:29:23 +08:00
commit 51b58fdf7d
23 changed files with 3621 additions and 0 deletions

24
.gitignore vendored Normal file
View File

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

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

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

2
README.md Normal file
View File

@ -0,0 +1,2 @@
# 古建筑保护信息化平台

12
index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>古建筑保护信息化平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

20
package.json Normal file
View File

@ -0,0 +1,20 @@
{
"name": "heritage-admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"pinia": "^3.0.4",
"vue": "^3.5.25",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.2",
"vite": "^7.3.1"
}
}

951
pnpm-lock.yaml Normal file
View File

@ -0,0 +1,951 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
pinia:
specifier: ^3.0.4
version: 3.0.4(vue@3.5.30)
vue:
specifier: ^3.5.25
version: 3.5.30
vue-router:
specifier: ^4.6.4
version: 4.6.4(vue@3.5.30)
devDependencies:
'@vitejs/plugin-vue':
specifier: ^6.0.2
version: 6.0.4(vite@7.3.1)(vue@3.5.30)
vite:
specifier: ^7.3.1
version: 7.3.1
packages:
'@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.28.5':
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.29.0':
resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/types@7.29.0':
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@esbuild/aix-ppc64@0.27.3':
resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.3':
resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.3':
resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.3':
resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.3':
resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.3':
resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.3':
resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.3':
resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.3':
resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.3':
resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.3':
resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.3':
resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.3':
resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.3':
resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.3':
resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.3':
resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.3':
resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.3':
resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.3':
resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.3':
resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.3':
resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.3':
resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.3':
resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.3':
resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.3':
resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.3':
resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@rolldown/pluginutils@1.0.0-rc.2':
resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==}
'@rollup/rollup-android-arm-eabi@4.59.0':
resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.59.0':
resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.59.0':
resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.59.0':
resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==}
cpu: [x64]
os: [darwin]
'@rollup/rollup-freebsd-arm64@4.59.0':
resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.59.0':
resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.59.0':
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.59.0':
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.59.0':
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.59.0':
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.59.0':
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.59.0':
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.59.0':
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.59.0':
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.59.0':
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.59.0':
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.59.0':
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
cpu: [x64]
os: [openbsd]
'@rollup/rollup-openharmony-arm64@4.59.0':
resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==}
cpu: [arm64]
os: [openharmony]
'@rollup/rollup-win32-arm64-msvc@4.59.0':
resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.59.0':
resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-gnu@4.59.0':
resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==}
cpu: [x64]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.59.0':
resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==}
cpu: [x64]
os: [win32]
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@vitejs/plugin-vue@6.0.4':
resolution: {integrity: sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==}
engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0
vue: ^3.2.25
'@vue/compiler-core@3.5.30':
resolution: {integrity: sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==}
'@vue/compiler-dom@3.5.30':
resolution: {integrity: sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==}
'@vue/compiler-sfc@3.5.30':
resolution: {integrity: sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==}
'@vue/compiler-ssr@3.5.30':
resolution: {integrity: sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==}
'@vue/devtools-api@6.6.4':
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
'@vue/devtools-api@7.7.9':
resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==}
'@vue/devtools-kit@7.7.9':
resolution: {integrity: sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==}
'@vue/devtools-shared@7.7.9':
resolution: {integrity: sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==}
'@vue/reactivity@3.5.30':
resolution: {integrity: sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==}
'@vue/runtime-core@3.5.30':
resolution: {integrity: sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==}
'@vue/runtime-dom@3.5.30':
resolution: {integrity: sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==}
'@vue/server-renderer@3.5.30':
resolution: {integrity: sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==}
peerDependencies:
vue: 3.5.30
'@vue/shared@3.5.30':
resolution: {integrity: sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==}
birpc@2.9.0:
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
copy-anything@4.0.5:
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
engines: {node: '>=18'}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
esbuild@0.27.3:
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
engines: {node: '>=18'}
hasBin: true
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
is-what@5.5.0:
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
engines: {node: '>=18'}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@4.0.3:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
pinia@3.0.4:
resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==}
peerDependencies:
typescript: '>=4.5.0'
vue: ^3.5.11
peerDependenciesMeta:
typescript:
optional: true
postcss@8.5.8:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14}
rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
rollup@4.59.0:
resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
speakingurl@14.0.1:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
superjson@2.2.6:
resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
engines: {node: '>=16'}
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
vite@7.3.1:
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
jiti: '>=1.21.0'
less: ^4.0.0
lightningcss: ^1.21.0
sass: ^1.70.0
sass-embedded: ^1.70.0
stylus: '>=0.54.8'
sugarss: ^5.0.0
terser: ^5.16.0
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
'@types/node':
optional: true
jiti:
optional: true
less:
optional: true
lightningcss:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
tsx:
optional: true
yaml:
optional: true
vue-router@4.6.4:
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
peerDependencies:
vue: ^3.5.0
vue@3.5.30:
resolution: {integrity: sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
snapshots:
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.28.5': {}
'@babel/parser@7.29.0':
dependencies:
'@babel/types': 7.29.0
'@babel/types@7.29.0':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@esbuild/aix-ppc64@0.27.3':
optional: true
'@esbuild/android-arm64@0.27.3':
optional: true
'@esbuild/android-arm@0.27.3':
optional: true
'@esbuild/android-x64@0.27.3':
optional: true
'@esbuild/darwin-arm64@0.27.3':
optional: true
'@esbuild/darwin-x64@0.27.3':
optional: true
'@esbuild/freebsd-arm64@0.27.3':
optional: true
'@esbuild/freebsd-x64@0.27.3':
optional: true
'@esbuild/linux-arm64@0.27.3':
optional: true
'@esbuild/linux-arm@0.27.3':
optional: true
'@esbuild/linux-ia32@0.27.3':
optional: true
'@esbuild/linux-loong64@0.27.3':
optional: true
'@esbuild/linux-mips64el@0.27.3':
optional: true
'@esbuild/linux-ppc64@0.27.3':
optional: true
'@esbuild/linux-riscv64@0.27.3':
optional: true
'@esbuild/linux-s390x@0.27.3':
optional: true
'@esbuild/linux-x64@0.27.3':
optional: true
'@esbuild/netbsd-arm64@0.27.3':
optional: true
'@esbuild/netbsd-x64@0.27.3':
optional: true
'@esbuild/openbsd-arm64@0.27.3':
optional: true
'@esbuild/openbsd-x64@0.27.3':
optional: true
'@esbuild/openharmony-arm64@0.27.3':
optional: true
'@esbuild/sunos-x64@0.27.3':
optional: true
'@esbuild/win32-arm64@0.27.3':
optional: true
'@esbuild/win32-ia32@0.27.3':
optional: true
'@esbuild/win32-x64@0.27.3':
optional: true
'@jridgewell/sourcemap-codec@1.5.5': {}
'@rolldown/pluginutils@1.0.0-rc.2': {}
'@rollup/rollup-android-arm-eabi@4.59.0':
optional: true
'@rollup/rollup-android-arm64@4.59.0':
optional: true
'@rollup/rollup-darwin-arm64@4.59.0':
optional: true
'@rollup/rollup-darwin-x64@4.59.0':
optional: true
'@rollup/rollup-freebsd-arm64@4.59.0':
optional: true
'@rollup/rollup-freebsd-x64@4.59.0':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.59.0':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.59.0':
optional: true
'@rollup/rollup-linux-arm64-musl@4.59.0':
optional: true
'@rollup/rollup-linux-loong64-gnu@4.59.0':
optional: true
'@rollup/rollup-linux-loong64-musl@4.59.0':
optional: true
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
optional: true
'@rollup/rollup-linux-ppc64-musl@4.59.0':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
optional: true
'@rollup/rollup-linux-riscv64-musl@4.59.0':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.59.0':
optional: true
'@rollup/rollup-linux-x64-gnu@4.59.0':
optional: true
'@rollup/rollup-linux-x64-musl@4.59.0':
optional: true
'@rollup/rollup-openbsd-x64@4.59.0':
optional: true
'@rollup/rollup-openharmony-arm64@4.59.0':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.59.0':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.59.0':
optional: true
'@rollup/rollup-win32-x64-gnu@4.59.0':
optional: true
'@rollup/rollup-win32-x64-msvc@4.59.0':
optional: true
'@types/estree@1.0.8': {}
'@vitejs/plugin-vue@6.0.4(vite@7.3.1)(vue@3.5.30)':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.2
vite: 7.3.1
vue: 3.5.30
'@vue/compiler-core@3.5.30':
dependencies:
'@babel/parser': 7.29.0
'@vue/shared': 3.5.30
entities: 7.0.1
estree-walker: 2.0.2
source-map-js: 1.2.1
'@vue/compiler-dom@3.5.30':
dependencies:
'@vue/compiler-core': 3.5.30
'@vue/shared': 3.5.30
'@vue/compiler-sfc@3.5.30':
dependencies:
'@babel/parser': 7.29.0
'@vue/compiler-core': 3.5.30
'@vue/compiler-dom': 3.5.30
'@vue/compiler-ssr': 3.5.30
'@vue/shared': 3.5.30
estree-walker: 2.0.2
magic-string: 0.30.21
postcss: 8.5.8
source-map-js: 1.2.1
'@vue/compiler-ssr@3.5.30':
dependencies:
'@vue/compiler-dom': 3.5.30
'@vue/shared': 3.5.30
'@vue/devtools-api@6.6.4': {}
'@vue/devtools-api@7.7.9':
dependencies:
'@vue/devtools-kit': 7.7.9
'@vue/devtools-kit@7.7.9':
dependencies:
'@vue/devtools-shared': 7.7.9
birpc: 2.9.0
hookable: 5.5.3
mitt: 3.0.1
perfect-debounce: 1.0.0
speakingurl: 14.0.1
superjson: 2.2.6
'@vue/devtools-shared@7.7.9':
dependencies:
rfdc: 1.4.1
'@vue/reactivity@3.5.30':
dependencies:
'@vue/shared': 3.5.30
'@vue/runtime-core@3.5.30':
dependencies:
'@vue/reactivity': 3.5.30
'@vue/shared': 3.5.30
'@vue/runtime-dom@3.5.30':
dependencies:
'@vue/reactivity': 3.5.30
'@vue/runtime-core': 3.5.30
'@vue/shared': 3.5.30
csstype: 3.2.3
'@vue/server-renderer@3.5.30(vue@3.5.30)':
dependencies:
'@vue/compiler-ssr': 3.5.30
'@vue/shared': 3.5.30
vue: 3.5.30
'@vue/shared@3.5.30': {}
birpc@2.9.0: {}
copy-anything@4.0.5:
dependencies:
is-what: 5.5.0
csstype@3.2.3: {}
entities@7.0.1: {}
esbuild@0.27.3:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.3
'@esbuild/android-arm': 0.27.3
'@esbuild/android-arm64': 0.27.3
'@esbuild/android-x64': 0.27.3
'@esbuild/darwin-arm64': 0.27.3
'@esbuild/darwin-x64': 0.27.3
'@esbuild/freebsd-arm64': 0.27.3
'@esbuild/freebsd-x64': 0.27.3
'@esbuild/linux-arm': 0.27.3
'@esbuild/linux-arm64': 0.27.3
'@esbuild/linux-ia32': 0.27.3
'@esbuild/linux-loong64': 0.27.3
'@esbuild/linux-mips64el': 0.27.3
'@esbuild/linux-ppc64': 0.27.3
'@esbuild/linux-riscv64': 0.27.3
'@esbuild/linux-s390x': 0.27.3
'@esbuild/linux-x64': 0.27.3
'@esbuild/netbsd-arm64': 0.27.3
'@esbuild/netbsd-x64': 0.27.3
'@esbuild/openbsd-arm64': 0.27.3
'@esbuild/openbsd-x64': 0.27.3
'@esbuild/openharmony-arm64': 0.27.3
'@esbuild/sunos-x64': 0.27.3
'@esbuild/win32-arm64': 0.27.3
'@esbuild/win32-ia32': 0.27.3
'@esbuild/win32-x64': 0.27.3
estree-walker@2.0.2: {}
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
fsevents@2.3.3:
optional: true
hookable@5.5.3: {}
is-what@5.5.0: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
mitt@3.0.1: {}
nanoid@3.3.11: {}
perfect-debounce@1.0.0: {}
picocolors@1.1.1: {}
picomatch@4.0.3: {}
pinia@3.0.4(vue@3.5.30):
dependencies:
'@vue/devtools-api': 7.7.9
vue: 3.5.30
postcss@8.5.8:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1
rfdc@1.4.1: {}
rollup@4.59.0:
dependencies:
'@types/estree': 1.0.8
optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.59.0
'@rollup/rollup-android-arm64': 4.59.0
'@rollup/rollup-darwin-arm64': 4.59.0
'@rollup/rollup-darwin-x64': 4.59.0
'@rollup/rollup-freebsd-arm64': 4.59.0
'@rollup/rollup-freebsd-x64': 4.59.0
'@rollup/rollup-linux-arm-gnueabihf': 4.59.0
'@rollup/rollup-linux-arm-musleabihf': 4.59.0
'@rollup/rollup-linux-arm64-gnu': 4.59.0
'@rollup/rollup-linux-arm64-musl': 4.59.0
'@rollup/rollup-linux-loong64-gnu': 4.59.0
'@rollup/rollup-linux-loong64-musl': 4.59.0
'@rollup/rollup-linux-ppc64-gnu': 4.59.0
'@rollup/rollup-linux-ppc64-musl': 4.59.0
'@rollup/rollup-linux-riscv64-gnu': 4.59.0
'@rollup/rollup-linux-riscv64-musl': 4.59.0
'@rollup/rollup-linux-s390x-gnu': 4.59.0
'@rollup/rollup-linux-x64-gnu': 4.59.0
'@rollup/rollup-linux-x64-musl': 4.59.0
'@rollup/rollup-openbsd-x64': 4.59.0
'@rollup/rollup-openharmony-arm64': 4.59.0
'@rollup/rollup-win32-arm64-msvc': 4.59.0
'@rollup/rollup-win32-ia32-msvc': 4.59.0
'@rollup/rollup-win32-x64-gnu': 4.59.0
'@rollup/rollup-win32-x64-msvc': 4.59.0
fsevents: 2.3.3
source-map-js@1.2.1: {}
speakingurl@14.0.1: {}
superjson@2.2.6:
dependencies:
copy-anything: 4.0.5
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
vite@7.3.1:
dependencies:
esbuild: 0.27.3
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
postcss: 8.5.8
rollup: 4.59.0
tinyglobby: 0.2.15
optionalDependencies:
fsevents: 2.3.3
vue-router@4.6.4(vue@3.5.30):
dependencies:
'@vue/devtools-api': 6.6.4
vue: 3.5.30
vue@3.5.30:
dependencies:
'@vue/compiler-dom': 3.5.30
'@vue/compiler-sfc': 3.5.30
'@vue/runtime-dom': 3.5.30
'@vue/server-renderer': 3.5.30(vue@3.5.30)
'@vue/shared': 3.5.30

1
public/vite.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 1.5 KiB

197
src/App.vue Normal file
View File

@ -0,0 +1,197 @@
<script setup>
import { computed } from 'vue'
import { RouterLink, RouterView, useRoute } from 'vue-router'
const route = useRoute()
const isAdmin = computed(() => route.path.startsWith('/admin'))
</script>
<template>
<div class="app">
<RouterView v-if="isAdmin" />
<template v-else>
<header class="topbar">
<div class="topbar__left">
<div class="brand">
<div class="brand__mark"></div>
<div class="brand__text">
<div class="brand__title">古建筑保护信息化平台</div>
<div class="brand__subtitle">Heritage Admin</div>
</div>
</div>
</div>
<nav class="topnav">
<RouterLink class="topnav__link" exact-active-class="is-active" to="/">首页</RouterLink>
<RouterLink class="topnav__link" active-class="is-active" to="/archives">资源档案</RouterLink>
<RouterLink class="topnav__link" active-class="is-active" to="/inspections">巡查监测</RouterLink>
<RouterLink class="topnav__link" active-class="is-active" to="/projects">修缮项目</RouterLink>
<RouterLink class="topnav__link" active-class="is-active" to="/analytics">统计分析</RouterLink>
</nav>
<div class="topbar__right">
<RouterLink class="login" active-class="is-active" to="/login">用户登录</RouterLink>
</div>
</header>
<main class="main">
<RouterView />
</main>
</template>
</div>
</template>
<style scoped>
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 16px;
padding: 12px 18px;
border-bottom: 1px solid var(--border);
background: rgba(255, 255, 255, 0.82);
backdrop-filter: blur(10px);
}
.topbar__left {
display: flex;
align-items: center;
}
.topbar__right {
display: flex;
align-items: center;
justify-content: flex-end;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
min-width: 260px;
}
.brand__mark {
width: 36px;
height: 36px;
border-radius: 10px;
display: grid;
place-items: center;
background: linear-gradient(135deg, #b48040, #205c40);
color: #fff;
font-weight: 800;
}
.brand__title {
font-weight: 800;
line-height: 1.2;
}
.brand__subtitle {
color: var(--muted);
font-size: 12px;
line-height: 1.2;
margin-top: 2px;
}
.topnav {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: center;
}
.topnav__link {
padding: 7px 9px;
border-radius: 10px;
color: var(--muted);
text-decoration: none;
border: 1px solid transparent;
}
.topnav__link:hover {
color: var(--text);
border-color: rgba(180, 128, 64, 0.35);
background: rgba(255, 255, 255, 0.7);
}
.topnav__link.is-active {
color: var(--text);
border-color: rgba(180, 128, 64, 0.45);
background: rgba(180, 128, 64, 0.08);
}
.login {
display: inline-flex;
align-items: center;
justify-content: center;
height: 30px;
padding: 0 10px;
border-radius: 10px;
text-decoration: none;
border-color: rgba(32, 92, 64, 0.25);
background: rgba(32, 92, 64, 0.12);
color: var(--text);
font-weight: 800;
border: 1px solid rgba(32, 92, 64, 0.25);
font-size: 13px;
}
.login:hover {
border-color: rgba(32, 92, 64, 0.35);
background: rgba(32, 92, 64, 0.18);
}
.login.is-active {
border-color: rgba(32, 92, 64, 0.35);
background: rgba(32, 92, 64, 0.18);
}
.main {
width: min(1200px, 100%);
margin: 0 auto;
flex: 1;
}
@media (max-width: 720px) {
.topbar {
grid-template-columns: 1fr auto;
grid-template-areas:
'left right'
'nav nav';
padding: 10px 12px;
}
.topbar__left {
grid-area: left;
}
.topbar__right {
grid-area: right;
}
.topnav {
grid-area: nav;
justify-content: center;
}
.brand {
min-width: 0;
}
.brand__title {
font-size: 14px;
}
.brand__subtitle {
display: none;
}
.topnav__link {
padding: 7px 9px;
}
}
</style>

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

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

After

Width:  |  Height:  |  Size: 496 B

34
src/lib/api.js Normal file
View File

@ -0,0 +1,34 @@
import { getToken } from './auth'
export async function apiFetch(path, options = {}) {
const headers = new Headers(options.headers || {})
if (!headers.has('Content-Type') && options.body != null) {
headers.set('Content-Type', 'application/json')
}
const token = getToken()
if (token && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`)
}
const res = await fetch(path, {
...options,
headers,
})
const text = await res.text()
let data = null
try {
data = text ? JSON.parse(text) : null
} catch {
data = text
}
if (!res.ok) {
const message = (data && data.data) || (data && data.message) || '请求失败'
throw new Error(message)
}
return data
}

40
src/lib/auth.js Normal file
View File

@ -0,0 +1,40 @@
const TOKEN_KEY = 'heritage.token'
const USER_KEY = 'heritage.user'
export function getToken() {
return localStorage.getItem(TOKEN_KEY) || ''
}
export function setToken(token) {
if (!token) return
localStorage.setItem(TOKEN_KEY, token)
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY)
}
export function getUser() {
const raw = localStorage.getItem(USER_KEY)
if (!raw) return null
try {
return JSON.parse(raw)
} catch {
return null
}
}
export function setUser(user) {
if (!user) return
localStorage.setItem(USER_KEY, JSON.stringify(user))
}
export function clearUser() {
localStorage.removeItem(USER_KEY)
}
export function clearAuth() {
clearToken()
clearUser()
}

7
src/main.js Normal file
View File

@ -0,0 +1,7 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { createPinia } from 'pinia'
import router from './router'
createApp(App).use(createPinia()).use(router).mount('#app')

245
src/pages/Analytics.vue Normal file
View File

@ -0,0 +1,245 @@
<script setup>
import { computed, ref } from 'vue'
const months = ref([
{ month: '10月', hazards: 2, repairs: 1 },
{ month: '11月', hazards: 3, repairs: 2 },
{ month: '12月', hazards: 1, repairs: 1 },
{ month: '1月', hazards: 4, repairs: 2 },
{ month: '2月', hazards: 2, repairs: 3 },
{ month: '3月', hazards: 3, repairs: 2 },
])
const maxHazards = computed(() => Math.max(...months.value.map((m) => m.hazards), 1))
const maxRepairs = computed(() => Math.max(...months.value.map((m) => m.repairs), 1))
const summary = computed(() => {
const hazards = months.value.reduce((acc, m) => acc + m.hazards, 0)
const repairs = months.value.reduce((acc, m) => acc + m.repairs, 0)
return {
buildings: 128,
inspections: 356,
hazards,
repairs,
}
})
</script>
<template>
<div class="page">
<div class="page__header">
<div>
<div class="title">统计分析</div>
<div class="subtitle">区域态势巡查隐患与修缮项目统计</div>
</div>
</div>
<section class="cards">
<div class="metric">
<div class="metric__label">在册古建筑</div>
<div class="metric__value">{{ summary.buildings }}</div>
<div class="metric__hint">覆盖建档与监管对象</div>
</div>
<div class="metric">
<div class="metric__label">巡查次数</div>
<div class="metric__value">{{ summary.inspections }}</div>
<div class="metric__hint"> 6 个月累计</div>
</div>
<div class="metric">
<div class="metric__label">隐患数量</div>
<div class="metric__value">{{ summary.hazards }}</div>
<div class="metric__hint">已纳入处置流程</div>
</div>
<div class="metric">
<div class="metric__label">修缮项目</div>
<div class="metric__value">{{ summary.repairs }}</div>
<div class="metric__hint">进行中与已完成</div>
</div>
</section>
<section class="panel">
<div class="panel__title"> 6 个月趋势</div>
<div class="chart">
<div v-for="m in months" :key="m.month" class="chart__col">
<div class="bars">
<div
class="bar is-hazard"
:style="{ height: `${Math.round((m.hazards / maxHazards) * 100)}%` }"
:title="`隐患:${m.hazards}`"
/>
<div
class="bar is-repair"
:style="{ height: `${Math.round((m.repairs / maxRepairs) * 100)}%` }"
:title="`修缮:${m.repairs}`"
/>
</div>
<div class="label">{{ m.month }}</div>
</div>
</div>
<div class="legend">
<span class="legend__item"><i class="dot is-hazard" />隐患</span>
<span class="legend__item"><i class="dot is-repair" />修缮</span>
</div>
</section>
</div>
</template>
<style scoped>
.page {
padding: 24px;
}
.page__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.title {
font-size: 20px;
font-weight: 800;
line-height: 1.2;
}
.subtitle {
margin-top: 6px;
color: var(--muted);
font-size: 13px;
}
.cards {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.metric {
border: 1px solid var(--border);
border-radius: 14px;
background: var(--surface);
padding: 14px;
}
.metric__label {
color: var(--muted);
font-size: 12px;
}
.metric__value {
margin-top: 6px;
font-size: 26px;
font-weight: 900;
letter-spacing: 0.4px;
}
.metric__hint {
margin-top: 6px;
color: var(--muted);
font-size: 12px;
}
.panel {
margin-top: 12px;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--surface);
padding: 14px;
}
.panel__title {
font-weight: 800;
}
.chart {
margin-top: 12px;
height: 220px;
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 10px;
align-items: end;
}
.chart__col {
display: grid;
gap: 8px;
justify-items: center;
}
.bars {
width: 100%;
height: 170px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
align-items: end;
}
.bar {
width: 100%;
border-radius: 10px 10px 6px 6px;
min-height: 10px;
}
.bar.is-hazard {
background: linear-gradient(180deg, rgba(180, 44, 44, 0.9), rgba(180, 44, 44, 0.45));
}
.bar.is-repair {
background: linear-gradient(180deg, rgba(32, 92, 64, 0.9), rgba(32, 92, 64, 0.45));
}
.label {
color: var(--muted);
font-size: 12px;
}
.legend {
margin-top: 12px;
display: flex;
gap: 12px;
color: var(--muted);
font-size: 12px;
}
.legend__item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 3px;
display: inline-block;
}
.dot.is-hazard {
background: rgba(180, 44, 44, 0.9);
}
.dot.is-repair {
background: rgba(32, 92, 64, 0.9);
}
@media (max-width: 1100px) {
.cards {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.page {
padding: 16px;
}
.cards {
grid-template-columns: 1fr;
}
.chart {
grid-template-columns: repeat(3, minmax(0, 1fr));
height: auto;
}
}
</style>

213
src/pages/Archives.vue Normal file
View File

@ -0,0 +1,213 @@
<script setup>
import { computed, ref } from 'vue'
const keyword = ref('')
const items = ref([
{
id: 'HZ-001',
name: '永宁寺大殿',
level: '省级文保',
location: '某省·某市',
status: '在册',
},
{
id: 'HZ-002',
name: '文庙大成殿',
level: '市级文保',
location: '某省·某县',
status: '在册',
},
{
id: 'HZ-003',
name: '城隍庙戏台',
level: '一般不可移动文物',
location: '某省·某区',
status: '在册',
},
])
const filtered = computed(() => {
const q = keyword.value.trim()
if (!q) return items.value
return items.value.filter((it) => {
const haystack = `${it.id} ${it.name} ${it.level} ${it.location} ${it.status}`
return haystack.includes(q)
})
})
function addSample() {
const nextId = String(items.value.length + 1).padStart(3, '0')
items.value.unshift({
id: `HZ-${nextId}`,
name: '新增古建筑(示例)',
level: '未定级',
location: '待补充',
status: '草稿',
})
}
</script>
<template>
<div class="page">
<div class="page__header">
<div>
<div class="title">资源档案</div>
<div class="subtitle">管理古建筑基础档案与关联资料</div>
</div>
<div class="actions">
<input v-model="keyword" class="input" placeholder="搜索:编号 / 名称 / 等级 / 地点" />
<button class="btn" type="button" @click="addSample">新增档案</button>
</div>
</div>
<div class="panel">
<div class="table">
<div class="row head">
<div>编号</div>
<div>名称</div>
<div>等级</div>
<div>地点</div>
<div>状态</div>
</div>
<div v-for="it in filtered" :key="it.id" class="row">
<div class="mono">{{ it.id }}</div>
<div class="strong">{{ it.name }}</div>
<div>{{ it.level }}</div>
<div>{{ it.location }}</div>
<div>
<span class="pill">{{ it.status }}</span>
</div>
</div>
<div v-if="filtered.length === 0" class="empty">暂无匹配数据</div>
</div>
</div>
</div>
</template>
<style scoped>
.page {
padding: 24px;
}
.page__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.title {
font-size: 20px;
font-weight: 800;
line-height: 1.2;
}
.subtitle {
margin-top: 6px;
color: var(--muted);
font-size: 13px;
}
.actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.input {
height: 34px;
padding: 0 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface);
min-width: 260px;
}
.btn {
height: 34px;
padding: 0 12px;
border-radius: 10px;
border: 1px solid rgba(180, 128, 64, 0.35);
background: rgba(180, 128, 64, 0.12);
color: var(--text);
font-weight: 700;
cursor: pointer;
}
.btn:hover {
background: rgba(180, 128, 64, 0.18);
}
.panel {
border: 1px solid var(--border);
border-radius: 14px;
background: var(--surface);
overflow: auto;
}
.table {
display: grid;
}
.row {
display: grid;
grid-template-columns: 140px 1.2fr 180px 220px 120px;
gap: 12px;
padding: 12px 14px;
border-top: 1px solid rgba(31, 35, 40, 0.06);
align-items: center;
min-width: 820px;
}
.row.head {
border-top: 0;
background: rgba(31, 35, 40, 0.03);
font-size: 12px;
color: var(--muted);
}
.mono {
font-variant-numeric: tabular-nums;
}
.strong {
font-weight: 700;
}
.pill {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid rgba(32, 92, 64, 0.25);
background: rgba(32, 92, 64, 0.08);
color: rgba(32, 92, 64, 0.9);
font-size: 12px;
}
.empty {
padding: 24px 14px;
color: var(--muted);
}
@media (max-width: 980px) {
.row {
grid-template-columns: 120px 1fr 160px 1fr 96px;
}
}
@media (max-width: 720px) {
.page {
padding: 16px;
}
.input {
min-width: 0;
width: min(360px, 100%);
}
}
</style>

120
src/pages/Home.vue Normal file
View File

@ -0,0 +1,120 @@
<script setup>
import { RouterLink } from 'vue-router'
</script>
<template>
<div class="home">
<section class="hero">
<div class="hero__badge">古建筑保护信息化平台</div>
<h1 class="hero__title">古建筑保护信息化平台</h1>
<p class="hero__subtitle">
汇聚基础档案巡查监测修缮项目与展示传播支撑古建筑全生命周期保护管理
</p>
</section>
<section class="grid">
<RouterLink class="card" to="/archives">
<div class="card__title">资源档案</div>
<div class="card__desc">建筑本体构件图纸与影像资料统一管理</div>
</RouterLink>
<RouterLink class="card" to="/inspections">
<div class="card__title">巡查监测</div>
<div class="card__desc">日常巡查隐患上报与监测数据可视化</div>
</RouterLink>
<RouterLink class="card" to="/projects">
<div class="card__title">修缮项目</div>
<div class="card__desc">立项审批施工与竣工全过程留痕</div>
</RouterLink>
<RouterLink class="card" to="/analytics">
<div class="card__title">统计分析</div>
<div class="card__desc">多维统计报表与区域态势分析</div>
</RouterLink>
</section>
</div>
</template>
<style scoped>
.home {
padding: 24px;
}
.hero {
border: 1px solid var(--border);
background: linear-gradient(135deg, rgba(180, 128, 64, 0.12), rgba(32, 92, 64, 0.08));
border-radius: 16px;
padding: 24px;
}
.hero__badge {
display: inline-flex;
align-items: center;
height: 28px;
padding: 0 12px;
border-radius: 999px;
border: 1px solid rgba(180, 128, 64, 0.35);
color: var(--muted);
background: rgba(255, 255, 255, 0.55);
}
.hero__title {
margin: 12px 0 8px;
font-size: 28px;
line-height: 1.2;
}
.hero__subtitle {
margin: 0;
color: var(--muted);
max-width: 60ch;
}
.grid {
margin-top: 18px;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.card {
display: block;
padding: 16px;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--surface);
text-decoration: none;
color: inherit;
transition: transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
}
.card:hover {
transform: translateY(-1px);
border-color: rgba(180, 128, 64, 0.45);
box-shadow: 0 12px 32px rgba(16, 24, 40, 0.08);
}
.card__title {
font-weight: 700;
}
.card__desc {
margin-top: 6px;
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
@media (max-width: 1100px) {
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 520px) {
.home {
padding: 16px;
}
.grid {
grid-template-columns: 1fr;
}
}
</style>

222
src/pages/Inspections.vue Normal file
View File

@ -0,0 +1,222 @@
<script setup>
import { computed, ref } from 'vue'
const status = ref('全部')
const records = ref([
{
id: 'XC-202603-001',
building: '永宁寺大殿',
inspector: '张三',
date: '2026-03-08',
result: '正常',
level: '低',
},
{
id: 'XC-202603-002',
building: '文庙大成殿',
inspector: '李四',
date: '2026-03-10',
result: '隐患',
level: '中',
},
{
id: 'XC-202603-003',
building: '城隍庙戏台',
inspector: '王五',
date: '2026-03-11',
result: '整改中',
level: '高',
},
])
const filtered = computed(() => {
if (status.value === '全部') return records.value
return records.value.filter((r) => r.result === status.value)
})
function createRecord() {
const next = String(records.value.length + 1).padStart(3, '0')
records.value.unshift({
id: `XC-202603-${next}`,
building: '(待选择)',
inspector: '(待填写)',
date: new Date().toISOString().slice(0, 10),
result: '正常',
level: '低',
})
}
function pillVariant(result) {
if (result === '隐患') return 'danger'
if (result === '整改中') return 'warn'
return 'ok'
}
</script>
<template>
<div class="page">
<div class="page__header">
<div>
<div class="title">巡查监测</div>
<div class="subtitle">巡查记录隐患处置与整改跟踪</div>
</div>
<div class="actions">
<select v-model="status" class="select">
<option>全部</option>
<option>正常</option>
<option>隐患</option>
<option>整改中</option>
</select>
<button class="btn" type="button" @click="createRecord">新建巡查</button>
</div>
</div>
<div class="panel">
<div class="table">
<div class="row head">
<div>巡查单号</div>
<div>巡查对象</div>
<div>巡查人</div>
<div>日期</div>
<div>结果</div>
<div>风险</div>
</div>
<div v-for="r in filtered" :key="r.id" class="row">
<div class="mono">{{ r.id }}</div>
<div class="strong">{{ r.building }}</div>
<div>{{ r.inspector }}</div>
<div class="mono">{{ r.date }}</div>
<div>
<span class="pill" :class="`is-${pillVariant(r.result)}`">{{ r.result }}</span>
</div>
<div>{{ r.level }}</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.page {
padding: 24px;
}
.page__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.title {
font-size: 20px;
font-weight: 800;
line-height: 1.2;
}
.subtitle {
margin-top: 6px;
color: var(--muted);
font-size: 13px;
}
.actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.select {
height: 34px;
padding: 0 10px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface);
}
.btn {
height: 34px;
padding: 0 12px;
border-radius: 10px;
border: 1px solid rgba(32, 92, 64, 0.25);
background: rgba(32, 92, 64, 0.12);
color: var(--text);
font-weight: 700;
cursor: pointer;
}
.btn:hover {
background: rgba(32, 92, 64, 0.18);
}
.panel {
border: 1px solid var(--border);
border-radius: 14px;
background: var(--surface);
overflow: auto;
}
.row {
display: grid;
grid-template-columns: 170px 1.2fr 120px 120px 96px 72px;
gap: 12px;
padding: 12px 14px;
border-top: 1px solid rgba(31, 35, 40, 0.06);
align-items: center;
min-width: 760px;
}
.row.head {
border-top: 0;
background: rgba(31, 35, 40, 0.03);
font-size: 12px;
color: var(--muted);
}
.mono {
font-variant-numeric: tabular-nums;
}
.strong {
font-weight: 700;
}
.pill {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid rgba(31, 35, 40, 0.18);
background: rgba(31, 35, 40, 0.06);
font-size: 12px;
}
.pill.is-ok {
border-color: rgba(32, 92, 64, 0.25);
background: rgba(32, 92, 64, 0.08);
color: rgba(32, 92, 64, 0.9);
}
.pill.is-warn {
border-color: rgba(180, 128, 64, 0.35);
background: rgba(180, 128, 64, 0.12);
color: rgba(120, 74, 20, 0.95);
}
.pill.is-danger {
border-color: rgba(180, 44, 44, 0.35);
background: rgba(180, 44, 44, 0.1);
color: rgba(140, 18, 18, 0.95);
}
@media (max-width: 720px) {
.page {
padding: 16px;
}
}
</style>

295
src/pages/Projects.vue Normal file
View File

@ -0,0 +1,295 @@
<script setup>
import { computed, ref } from 'vue'
const phase = ref('全部')
const projects = ref([
{
id: 'XS-2026-001',
name: '永宁寺大殿屋面修缮',
location: '某省·某市',
phase: '施工',
progress: 62,
updatedAt: '2026-03-09',
},
{
id: 'XS-2026-002',
name: '文庙大成殿结构加固',
location: '某省·某县',
phase: '审批',
progress: 28,
updatedAt: '2026-03-06',
},
{
id: 'XS-2026-003',
name: '城隍庙戏台彩绘修复',
location: '某省·某区',
phase: '验收',
progress: 92,
updatedAt: '2026-03-11',
},
])
const filtered = computed(() => {
if (phase.value === '全部') return projects.value
return projects.value.filter((p) => p.phase === phase.value)
})
function createProject() {
const next = String(projects.value.length + 1).padStart(3, '0')
projects.value.unshift({
id: `XS-2026-${next}`,
name: '新增修缮项目(示例)',
location: '待补充',
phase: '立项',
progress: 0,
updatedAt: new Date().toISOString().slice(0, 10),
})
}
function phaseClass(value) {
if (value === '施工') return 'is-doing'
if (value === '审批') return 'is-warn'
if (value === '验收') return 'is-ok'
return 'is-default'
}
</script>
<template>
<div class="page">
<div class="page__header">
<div>
<div class="title">修缮项目</div>
<div class="subtitle">立项审批施工验收全流程管理</div>
</div>
<div class="actions">
<select v-model="phase" class="select">
<option>全部</option>
<option>立项</option>
<option>审批</option>
<option>施工</option>
<option>验收</option>
</select>
<button class="btn" type="button" @click="createProject">新建项目</button>
</div>
</div>
<div class="grid">
<div v-for="p in filtered" :key="p.id" class="card">
<div class="card__top">
<div>
<div class="name">{{ p.name }}</div>
<div class="meta">
<span class="mono">{{ p.id }}</span>
<span class="dot">·</span>
<span>{{ p.location }}</span>
</div>
</div>
<span class="tag" :class="phaseClass(p.phase)">{{ p.phase }}</span>
</div>
<div class="progress">
<div class="progress__bar" :style="{ width: `${p.progress}%` }" />
</div>
<div class="progress__meta">
<span class="mono">{{ p.progress }}%</span>
<span class="muted">更新于 {{ p.updatedAt }}</span>
</div>
<div class="actions2">
<button class="ghost" type="button">项目详情</button>
<button class="ghost" type="button">过程资料</button>
<button class="ghost" type="button">进度记录</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.page {
padding: 24px;
}
.page__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.title {
font-size: 20px;
font-weight: 800;
line-height: 1.2;
}
.subtitle {
margin-top: 6px;
color: var(--muted);
font-size: 13px;
}
.actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.select {
height: 34px;
padding: 0 10px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface);
}
.btn {
height: 34px;
padding: 0 12px;
border-radius: 10px;
border: 1px solid rgba(180, 128, 64, 0.35);
background: rgba(180, 128, 64, 0.12);
color: var(--text);
font-weight: 700;
cursor: pointer;
}
.btn:hover {
background: rgba(180, 128, 64, 0.18);
}
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.card {
border: 1px solid var(--border);
border-radius: 14px;
background: var(--surface);
padding: 14px;
}
.card__top {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: flex-start;
}
.name {
font-weight: 800;
line-height: 1.35;
}
.meta {
margin-top: 6px;
color: var(--muted);
font-size: 12px;
}
.mono {
font-variant-numeric: tabular-nums;
}
.dot {
margin: 0 6px;
}
.tag {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid rgba(31, 35, 40, 0.18);
background: rgba(31, 35, 40, 0.06);
font-size: 12px;
white-space: nowrap;
}
.tag.is-doing {
border-color: rgba(32, 92, 64, 0.25);
background: rgba(32, 92, 64, 0.08);
color: rgba(32, 92, 64, 0.9);
}
.tag.is-warn {
border-color: rgba(180, 128, 64, 0.35);
background: rgba(180, 128, 64, 0.12);
color: rgba(120, 74, 20, 0.95);
}
.tag.is-ok {
border-color: rgba(72, 96, 164, 0.35);
background: rgba(72, 96, 164, 0.1);
color: rgba(35, 56, 120, 0.95);
}
.progress {
margin-top: 12px;
height: 10px;
border-radius: 999px;
background: rgba(31, 35, 40, 0.08);
overflow: hidden;
}
.progress__bar {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #b48040, #205c40);
}
.progress__meta {
margin-top: 10px;
display: flex;
justify-content: space-between;
gap: 10px;
color: var(--muted);
font-size: 12px;
}
.muted {
color: var(--muted);
}
.actions2 {
display: flex;
gap: 8px;
margin-top: 12px;
}
.ghost {
height: 30px;
padding: 0 10px;
border-radius: 10px;
border: 1px solid rgba(31, 35, 40, 0.12);
background: rgba(255, 255, 255, 0.7);
cursor: pointer;
color: var(--text);
}
.ghost:hover {
border-color: rgba(180, 128, 64, 0.35);
}
@media (max-width: 1100px) {
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.page {
padding: 16px;
}
.grid {
grid-template-columns: 1fr;
}
}
</style>

320
src/pages/QrLogin.vue Normal file
View File

@ -0,0 +1,320 @@
<script setup>
import { ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { apiFetch } from '../lib/api'
import { setToken, setUser } from '../lib/auth'
const appName = '古建筑保护信息化平台'
const token = ref(Math.random().toString(36).slice(2, 10).toUpperCase())
const router = useRouter()
const userName = ref('')
const password = ref('')
const errorText = ref('')
const loading = ref(false)
async function accountLogin() {
if (loading.value) return
errorText.value = ''
loading.value = true
try {
const res = await apiFetch('/api/login', {
method: 'POST',
body: JSON.stringify({
userName: userName.value,
password: password.value,
}),
})
setToken(res?.token || '')
setUser(res?.user || null)
router.push('/admin')
} catch (e) {
errorText.value = e?.message || '登录失败'
} finally {
loading.value = false
}
}
</script>
<template>
<div class="page">
<div class="panel">
<div class="panel__header">
<div class="title">用户登录</div>
<div class="subtitle">支持账号登录与扫码登录</div>
</div>
<div class="content">
<section class="card card--form">
<div class="card__head">
<div class="card__title">账号登录</div>
<RouterLink class="ghost" to="/">返回首页</RouterLink>
</div>
<div class="card__body">
<div class="form">
<label class="field">
<div class="label">账号</div>
<input v-model.trim="userName" class="input" placeholder="例如admin" autocomplete="username" />
</label>
<label class="field">
<div class="label">密码</div>
<input
v-model="password"
class="input"
placeholder="请输入密码"
type="password"
autocomplete="current-password"
@keyup.enter="accountLogin"
/>
</label>
<div v-if="errorText" class="error">{{ errorText }}</div>
<button class="btn btn--primary btn--wide" type="button" :disabled="loading" @click="accountLogin">
{{ loading ? '登录中...' : '登录' }}
</button>
</div>
</div>
</section>
<section class="card card--qr">
<div class="card__head">
<div class="card__title">扫码登录</div>
<button class="btn" type="button" @click="token = Math.random().toString(36).slice(2, 10).toUpperCase()">
刷新
</button>
</div>
<div class="card__body">
<div class="qr">
<div class="qr__inner">
<div class="qr__brand">{{ appName }}</div>
<div class="qr__token">TOKEN {{ token }}</div>
</div>
</div>
<ol class="tips__list">
<li>打开移动端应用/小程序进入扫码登录</li>
<li>对准二维码区域完成扫描</li>
<li>在移动端确认授权后自动登录</li>
</ol>
</div>
</section>
</div>
</div>
</div>
</template>
<style scoped>
.page {
min-height: calc(100vh - 60px);
padding: 28px 16px;
display: grid;
place-items: start center;
background:
radial-gradient(1200px 500px at 20% 0%, rgba(180, 128, 64, 0.15), transparent 60%),
radial-gradient(900px 500px at 90% 10%, rgba(32, 92, 64, 0.16), transparent 55%);
}
.panel {
width: min(920px, 100%);
border: 1px solid var(--border);
border-radius: 16px;
background: rgba(255, 255, 255, 0.86);
padding: 18px;
backdrop-filter: blur(10px);
box-shadow: 0 18px 40px rgba(31, 35, 40, 0.08);
}
.panel__header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(31, 35, 40, 0.06);
}
.title {
font-size: 20px;
font-weight: 900;
}
.subtitle {
color: var(--muted);
font-size: 13px;
}
.content {
margin-top: 16px;
display: grid;
grid-template-columns: 1fr 340px;
gap: 14px;
align-items: stretch;
}
.card {
border: 1px solid rgba(31, 35, 40, 0.08);
border-radius: 14px;
background: rgba(255, 255, 255, 0.9);
overflow: hidden;
display: grid;
grid-template-rows: auto 1fr;
}
.card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 12px;
border-bottom: 1px solid rgba(31, 35, 40, 0.06);
background: rgba(31, 35, 40, 0.02);
}
.card__title {
font-weight: 900;
}
.card__body {
padding: 14px 12px;
}
.qr__inner {
aspect-ratio: 1 / 1;
border-radius: 12px;
background: repeating-linear-gradient(
45deg,
rgba(31, 35, 40, 0.06),
rgba(31, 35, 40, 0.06) 10px,
rgba(31, 35, 40, 0.02) 10px,
rgba(31, 35, 40, 0.02) 20px
);
display: grid;
place-items: center;
text-align: center;
padding: 18px;
}
.qr__brand {
font-weight: 900;
}
.qr__token {
margin-top: 8px;
font-variant-numeric: tabular-nums;
color: var(--muted);
font-size: 12px;
}
.tips__list {
margin: 12px 0 0;
padding-left: 18px;
color: var(--muted);
font-size: 12px;
line-height: 1.8;
}
.qr {
border: 1px dashed rgba(31, 35, 40, 0.18);
border-radius: 14px;
padding: 12px;
background: rgba(31, 35, 40, 0.02);
}
.form {
display: grid;
gap: 10px;
}
.field {
display: grid;
gap: 6px;
}
.label {
font-size: 12px;
color: var(--muted);
font-weight: 700;
}
.input {
height: 34px;
padding: 0 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface);
outline: none;
}
.input:focus {
border-color: rgba(32, 92, 64, 0.35);
}
.error {
font-size: 12px;
color: rgba(180, 44, 44, 0.95);
}
.actions {
margin-top: 14px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn {
height: 34px;
padding: 0 12px;
border-radius: 10px;
border: 1px solid rgba(32, 92, 64, 0.25);
background: rgba(32, 92, 64, 0.12);
color: var(--text);
font-weight: 700;
cursor: pointer;
}
.btn:hover {
background: rgba(32, 92, 64, 0.18);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.75;
}
.btn--primary {
border-color: rgba(180, 128, 64, 0.35);
background: rgba(180, 128, 64, 0.16);
}
.btn--primary:hover {
background: rgba(180, 128, 64, 0.22);
}
.btn--wide {
justify-content: center;
width: 100%;
}
.ghost {
height: 34px;
padding: 0 12px;
border-radius: 10px;
border: 1px solid rgba(31, 35, 40, 0.12);
background: rgba(255, 255, 255, 0.7);
color: var(--text);
text-decoration: none;
display: inline-flex;
align-items: center;
}
.ghost:hover {
border-color: rgba(180, 128, 64, 0.35);
}
@media (max-width: 820px) {
.page {
padding: 18px 14px;
}
.content {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -0,0 +1,607 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { RouterLink, RouterView, useRouter } from 'vue-router'
import { apiFetch } from '../../lib/api'
import { clearAuth, getToken, getUser, setUser } from '../../lib/auth'
const router = useRouter()
const user = ref(getUser())
const menuOpen = ref(false)
const showUserModal = ref(false)
const showPasswordModal = ref(false)
const oldPassword = ref('')
const newPassword = ref('')
const newPassword2 = ref('')
const passwordError = ref('')
const passwordLoading = ref(false)
const menuRoot = ref(null)
const avatarText = computed(() => {
const name = user.value?.userName || '用户'
const t = String(name).trim()
return t ? t.slice(0, 1).toUpperCase() : 'U'
})
async function logout() {
try {
await apiFetch('/api/logout', { method: 'POST' })
} catch {
} finally {
clearAuth()
}
router.push('/')
}
async function loadMe() {
if (!getToken()) return
try {
const res = await apiFetch('/api/me')
if (res?.user) {
user.value = res.user
setUser(res.user)
}
} catch {
clearAuth()
router.push('/login')
}
}
function closeMenu() {
menuOpen.value = false
}
function onDocumentClick(e) {
const root = menuRoot.value
if (!root) return
if (root.contains(e.target)) return
closeMenu()
}
function openUser() {
closeMenu()
showUserModal.value = true
}
function openPassword() {
closeMenu()
passwordError.value = ''
oldPassword.value = ''
newPassword.value = ''
newPassword2.value = ''
showPasswordModal.value = true
}
async function submitPassword() {
if (passwordLoading.value) return
passwordError.value = ''
const oldPwd = oldPassword.value.trim()
const newPwd = newPassword.value.trim()
const newPwd2 = newPassword2.value.trim()
if (!oldPwd || !newPwd || !newPwd2) {
passwordError.value = '请填写完整'
return
}
if (newPwd.length < 6) {
passwordError.value = '新密码至少6位'
return
}
if (newPwd !== newPwd2) {
passwordError.value = '两次输入的新密码不一致'
return
}
passwordLoading.value = true
try {
await apiFetch('/api/password', {
method: 'POST',
body: JSON.stringify({
oldPassword: oldPwd,
newPassword: newPwd,
}),
})
showPasswordModal.value = false
} catch (e) {
passwordError.value = e?.message || '修改失败'
} finally {
passwordLoading.value = false
}
}
onMounted(() => {
loadMe()
document.addEventListener('click', onDocumentClick, true)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onDocumentClick, true)
})
</script>
<template>
<div class="admin">
<aside class="sider">
<RouterLink class="sider__brand" to="/admin">
<div class="mark"></div>
<div>
<div class="title">管理后台</div>
<div class="sub">古建筑保护信息化平台</div>
</div>
</RouterLink>
<nav class="menu">
<RouterLink class="item" active-class="is-active" to="/admin/archives">资源档案</RouterLink>
<RouterLink class="item" active-class="is-active" to="/admin/inspections">巡查监测</RouterLink>
<RouterLink class="item" active-class="is-active" to="/admin/projects">修缮项目</RouterLink>
<RouterLink class="item" active-class="is-active" to="/admin/analytics">统计分析</RouterLink>
</nav>
</aside>
<div class="main">
<header class="header">
<div class="header__left">古建筑保护信息化平台 · 管理后台</div>
<div class="header__right">
<div ref="menuRoot" class="userbox">
<button class="userbtn" type="button" @click.stop="menuOpen = !menuOpen">
<span class="avatar">{{ avatarText }}</span>
<span class="username">{{ user?.userName || '管理员' }}</span>
<span class="chev" :class="{ 'is-open': menuOpen }"></span>
</button>
<div v-if="menuOpen" class="menu2">
<button class="menu2__item" type="button" @click="openUser">当前用户</button>
<button class="menu2__item" type="button" @click="openPassword">修改密码</button>
<div class="menu2__divider" />
<button class="menu2__item is-danger" type="button" @click="logout">退出登录</button>
</div>
</div>
</div>
</header>
<div class="content">
<RouterView />
</div>
</div>
</div>
<div v-if="showUserModal" class="modal" @click.self="showUserModal = false">
<div class="dialog">
<div class="dialog__head">
<div class="dialog__title">当前用户</div>
<button class="iconbtn" type="button" @click="showUserModal = false">×</button>
</div>
<div class="dialog__body">
<div class="kv">
<div class="k">用户名</div>
<div class="v">{{ user?.userName || '-' }}</div>
</div>
<div class="kv">
<div class="k">用户ID</div>
<div class="v mono">{{ user?.id || '-' }}</div>
</div>
<div class="kv">
<div class="k">角色ID</div>
<div class="v mono">{{ user?.roleId || '-' }}</div>
</div>
</div>
<div class="dialog__foot">
<button class="btn2" type="button" @click="showUserModal = false">关闭</button>
</div>
</div>
</div>
<div v-if="showPasswordModal" class="modal" @click.self="showPasswordModal = false">
<div class="dialog">
<div class="dialog__head">
<div class="dialog__title">修改密码</div>
<button class="iconbtn" type="button" @click="showPasswordModal = false">×</button>
</div>
<div class="dialog__body">
<label class="field">
<div class="label">旧密码</div>
<input v-model="oldPassword" class="input" type="password" autocomplete="current-password" />
</label>
<label class="field">
<div class="label">新密码</div>
<input v-model="newPassword" class="input" type="password" autocomplete="new-password" />
</label>
<label class="field">
<div class="label">确认新密码</div>
<input v-model="newPassword2" class="input" type="password" autocomplete="new-password" @keyup.enter="submitPassword" />
</label>
<div v-if="passwordError" class="error">{{ passwordError }}</div>
</div>
<div class="dialog__foot">
<button class="btn2" type="button" @click="showPasswordModal = false">取消</button>
<button class="btn2 btn2--primary" type="button" :disabled="passwordLoading" @click="submitPassword">
{{ passwordLoading ? '提交中...' : '确认修改' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.admin {
min-height: 100vh;
display: grid;
grid-template-columns: 240px 1fr;
}
.sider {
position: sticky;
top: 0;
height: 100vh;
border-right: 1px solid var(--border);
background: var(--surface);
padding: 14px;
}
.sider__brand {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 10px 14px;
border-bottom: 1px solid rgba(31, 35, 40, 0.06);
text-decoration: none;
}
.sider__brand:hover .title {
color: var(--text);
}
.sider__brand:hover .sub {
color: rgba(31, 35, 40, 0.72);
}
.mark {
width: 36px;
height: 36px;
border-radius: 10px;
display: grid;
place-items: center;
background: linear-gradient(135deg, #b48040, #205c40);
color: #fff;
font-weight: 900;
}
.title {
font-weight: 900;
line-height: 1.2;
}
.sub {
margin-top: 2px;
font-size: 12px;
color: var(--muted);
}
.menu {
margin-top: 12px;
display: grid;
gap: 6px;
}
.item {
height: 36px;
padding: 0 12px;
border-radius: 10px;
display: flex;
align-items: center;
text-decoration: none;
color: var(--muted);
border: 1px solid transparent;
}
.item:hover {
color: var(--text);
border-color: rgba(180, 128, 64, 0.25);
background: rgba(180, 128, 64, 0.08);
}
.item.is-active {
color: var(--text);
border-color: rgba(32, 92, 64, 0.28);
background: rgba(32, 92, 64, 0.1);
font-weight: 800;
}
.main {
min-width: 0;
display: flex;
flex-direction: column;
}
.header {
position: sticky;
top: 0;
z-index: 5;
height: 52px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 18px;
border-bottom: 1px solid var(--border);
background: rgba(255, 255, 255, 0.86);
backdrop-filter: blur(10px);
}
.header__left {
font-weight: 800;
}
.header__right {
display: flex;
align-items: center;
gap: 10px;
}
.userbox {
position: relative;
}
.userbtn {
height: 34px;
padding: 0 10px 0 6px;
border-radius: 999px;
border: 1px solid rgba(31, 35, 40, 0.12);
background: rgba(255, 255, 255, 0.7);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text);
font-weight: 800;
}
.userbtn:hover {
border-color: rgba(180, 128, 64, 0.35);
}
.avatar {
width: 26px;
height: 26px;
border-radius: 999px;
display: grid;
place-items: center;
background: linear-gradient(135deg, #b48040, #205c40);
color: #fff;
font-weight: 900;
font-size: 12px;
}
.username {
font-size: 13px;
color: rgba(31, 35, 40, 0.8);
}
.chev {
font-size: 12px;
color: rgba(31, 35, 40, 0.55);
transition: transform 0.15s ease;
margin-left: 2px;
}
.chev.is-open {
transform: rotate(180deg);
}
.menu2 {
position: absolute;
right: 0;
top: calc(100% + 8px);
min-width: 180px;
background: rgba(255, 255, 255, 0.98);
border: 1px solid rgba(31, 35, 40, 0.12);
border-radius: 12px;
box-shadow: 0 12px 30px rgba(31, 35, 40, 0.12);
overflow: hidden;
z-index: 50;
}
.menu2__item {
width: 100%;
text-align: left;
padding: 10px 12px;
border: 0;
background: transparent;
cursor: pointer;
font-weight: 800;
color: rgba(31, 35, 40, 0.85);
}
.menu2__item:hover {
background: rgba(31, 35, 40, 0.04);
}
.menu2__item.is-danger {
color: rgba(180, 44, 44, 0.95);
}
.menu2__divider {
height: 1px;
background: rgba(31, 35, 40, 0.08);
}
.modal {
position: fixed;
inset: 0;
background: rgba(31, 35, 40, 0.35);
display: grid;
place-items: center;
padding: 18px;
z-index: 100;
}
.dialog {
width: min(520px, 100%);
border-radius: 16px;
border: 1px solid rgba(31, 35, 40, 0.12);
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 18px 40px rgba(31, 35, 40, 0.18);
overflow: hidden;
}
.dialog__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid rgba(31, 35, 40, 0.08);
}
.dialog__title {
font-weight: 900;
}
.iconbtn {
width: 32px;
height: 32px;
border-radius: 10px;
border: 1px solid rgba(31, 35, 40, 0.12);
background: rgba(255, 255, 255, 0.7);
cursor: pointer;
font-size: 18px;
line-height: 1;
}
.iconbtn:hover {
border-color: rgba(180, 128, 64, 0.35);
}
.dialog__body {
padding: 14px;
display: grid;
gap: 10px;
}
.dialog__foot {
padding: 12px 14px;
border-top: 1px solid rgba(31, 35, 40, 0.08);
display: flex;
justify-content: flex-end;
gap: 10px;
}
.kv {
display: grid;
grid-template-columns: 88px 1fr;
gap: 10px;
align-items: baseline;
padding: 10px 12px;
border: 1px solid rgba(31, 35, 40, 0.08);
border-radius: 12px;
background: rgba(31, 35, 40, 0.02);
}
.k {
font-size: 12px;
color: var(--muted);
font-weight: 800;
}
.v {
font-weight: 800;
color: rgba(31, 35, 40, 0.86);
}
.mono {
font-variant-numeric: tabular-nums;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
word-break: break-all;
}
.field {
display: grid;
gap: 6px;
}
.label {
font-size: 12px;
color: var(--muted);
font-weight: 800;
}
.input {
height: 34px;
padding: 0 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface);
outline: none;
}
.input:focus {
border-color: rgba(32, 92, 64, 0.35);
}
.error {
font-size: 12px;
color: rgba(180, 44, 44, 0.95);
font-weight: 700;
}
.btn2 {
height: 34px;
padding: 0 12px;
border-radius: 10px;
border: 1px solid rgba(31, 35, 40, 0.12);
background: rgba(255, 255, 255, 0.7);
cursor: pointer;
font-weight: 800;
color: rgba(31, 35, 40, 0.85);
}
.btn2:hover {
border-color: rgba(180, 128, 64, 0.35);
}
.btn2:disabled {
cursor: not-allowed;
opacity: 0.75;
}
.btn2--primary {
border-color: rgba(180, 128, 64, 0.35);
background: rgba(180, 128, 64, 0.16);
}
.btn2--primary:hover {
background: rgba(180, 128, 64, 0.22);
}
.content {
width: 100%;
margin: 0;
padding: 0 12px;
}
@media (max-width: 980px) {
.admin {
grid-template-columns: 88px 1fr;
}
.sider__brand .title,
.sider__brand .sub {
display: none;
}
.item {
justify-content: center;
padding: 0;
}
}
@media (max-width: 720px) {
.admin {
grid-template-columns: 1fr;
}
.sider {
position: static;
height: auto;
}
.menu {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.item {
justify-content: center;
padding: 0 8px;
text-align: center;
}
}
</style>

View File

@ -0,0 +1,190 @@
<script setup>
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
const now = new Date()
const dateText = computed(() => {
const y = now.getFullYear()
const m = String(now.getMonth() + 1).padStart(2, '0')
const d = String(now.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
})
const metrics = [
{ label: '在册古建筑', value: 128, hint: '建档对象' },
{ label: '本月巡查', value: 42, hint: '含日常与专项' },
{ label: '隐患处置', value: 8, hint: '整改跟踪中' },
{ label: '在建项目', value: 5, hint: '施工阶段' },
]
const tasks = [
{ title: '隐患复核:文庙大成殿', meta: '优先级:中 · 截止:本周' },
{ title: '项目进度:永宁寺大殿屋面修缮', meta: '阶段:施工 · 更新:昨日' },
{ title: '巡查计划:重点片区专项巡查', meta: '执行人:张三 · 时间:明日' },
]
</script>
<template>
<div class="page">
<div class="head">
<div>
<div class="title">仪表盘</div>
<div class="sub">欢迎回来 · {{ dateText }}</div>
</div>
<div class="actions">
<RouterLink class="action" to="/admin/archives">新建档案</RouterLink>
<RouterLink class="action" to="/admin/inspections">新建巡查</RouterLink>
<RouterLink class="action" to="/admin/projects">新建项目</RouterLink>
</div>
</div>
<section class="grid">
<div v-for="m in metrics" :key="m.label" class="card">
<div class="label">{{ m.label }}</div>
<div class="value">{{ m.value }}</div>
<div class="hint">{{ m.hint }}</div>
</div>
</section>
<section class="panel">
<div class="panel__title">待办事项</div>
<div class="list">
<div v-for="t in tasks" :key="t.title" class="item">
<div class="item__title">{{ t.title }}</div>
<div class="item__meta">{{ t.meta }}</div>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.page {
padding: 24px;
}
.head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.title {
font-size: 20px;
font-weight: 900;
line-height: 1.2;
}
.sub {
margin-top: 6px;
color: var(--muted);
font-size: 13px;
}
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.action {
height: 32px;
padding: 0 10px;
border-radius: 10px;
border: 1px solid rgba(31, 35, 40, 0.12);
background: rgba(255, 255, 255, 0.7);
color: var(--text);
text-decoration: none;
display: inline-flex;
align-items: center;
font-weight: 800;
font-size: 13px;
}
.action:hover {
border-color: rgba(180, 128, 64, 0.35);
}
.grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.card {
border: 1px solid var(--border);
border-radius: 14px;
background: var(--surface);
padding: 14px;
}
.label {
color: var(--muted);
font-size: 12px;
}
.value {
margin-top: 8px;
font-size: 28px;
font-weight: 900;
}
.hint {
margin-top: 6px;
color: var(--muted);
font-size: 12px;
}
.panel {
margin-top: 12px;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--surface);
padding: 14px;
}
.panel__title {
font-weight: 900;
}
.list {
margin-top: 10px;
display: grid;
gap: 10px;
}
.item {
padding: 12px;
border-radius: 12px;
border: 1px solid rgba(31, 35, 40, 0.08);
background: rgba(31, 35, 40, 0.02);
}
.item__title {
font-weight: 800;
}
.item__meta {
margin-top: 6px;
color: var(--muted);
font-size: 12px;
}
@media (max-width: 1100px) {
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.page {
padding: 16px;
}
.grid {
grid-template-columns: 1fr;
}
}
</style>

67
src/router/index.js Normal file
View File

@ -0,0 +1,67 @@
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../pages/Home.vue'
import Archives from '../pages/Archives.vue'
import Inspections from '../pages/Inspections.vue'
import Projects from '../pages/Projects.vue'
import Analytics from '../pages/Analytics.vue'
import QrLogin from '../pages/QrLogin.vue'
import AdminLayout from '../pages/admin/AdminLayout.vue'
import AdminDashboard from '../pages/admin/Dashboard.vue'
import { getToken } from '../lib/auth'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'home',
component: Home,
},
{
path: '/archives',
name: 'archives',
component: Archives,
},
{
path: '/inspections',
name: 'inspections',
component: Inspections,
},
{
path: '/projects',
name: 'projects',
component: Projects,
},
{
path: '/analytics',
name: 'analytics',
component: Analytics,
},
{
path: '/login',
name: 'login',
component: QrLogin,
},
{
path: '/admin',
component: AdminLayout,
children: [
{ path: '', name: 'admin-home', component: AdminDashboard },
{ path: 'dashboard', redirect: '/admin' },
{ path: 'archives', name: 'admin-archives', component: Archives },
{ path: 'inspections', name: 'admin-inspections', component: Inspections },
{ path: 'projects', name: 'admin-projects', component: Projects },
{ path: 'analytics', name: 'admin-analytics', component: Analytics },
],
},
],
})
router.beforeEach((to) => {
if (to.path.startsWith('/admin') && !getToken()) {
return { path: '/login' }
}
return true
})
export default router

35
src/style.css Normal file
View File

@ -0,0 +1,35 @@
:root {
--bg: #f6f7f9;
--surface: #ffffff;
--text: #1f2328;
--muted: rgba(31, 35, 40, 0.68);
--border: rgba(31, 35, 40, 0.12);
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
Arial, 'Noto Sans', 'Liberation Sans', sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html,
body {
height: 100%;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
}
a {
color: inherit;
}
* {
box-sizing: border-box;
}

15
vite.config.js Normal file
View File

@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})