功能更新

This commit is contained in:
李琦
2026-09-30 17:00:29 +08:00
parent 1768c533f9
commit 7e08954598
22 changed files with 4606 additions and 42 deletions

View File

@@ -9,8 +9,6 @@
"preview": "vite preview"
},
"dependencies": {
"tailwindcss": "^4.1.4",
"vue": "^3.5.13",
"@ant-design-vue/pro-layout": "^3.2.5",
"@ant-design/icons-vue": "^7.0.1",
"@fortawesome/fontawesome-free": "^6.7.2",
@@ -18,11 +16,14 @@
"@vueuse/head": "^2.0.0",
"ant-design-vue": "^4.1.1",
"axios": "^1.6.2",
"exceljs": "^4.4.0",
"lucide-vue-next": "^0.507.0",
"md-editor-v3": "^7.1.0",
"pinia": "^2.1.7",
"swiper": "^11.0.5",
"tailwindcss": "^4.1.4",
"vue": "^3.5.13",
"vue-router": "^4.2.5",
"exceljs": "^4.4.0",
"xlsx": "^0.18.5"
},
"devDependencies": {

687
pnpm-lock.yaml generated
View File

@@ -35,6 +35,9 @@ importers:
lucide-vue-next:
specifier: ^0.507.0
version: 0.507.0(vue@3.5.13)
md-editor-v3:
specifier: ^7.1.0
version: 7.1.0(vue@3.5.13)
pinia:
specifier: ^2.1.7
version: 2.3.1(vue@3.5.13)
@@ -109,6 +112,96 @@ packages:
resolution: {integrity: sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==}
engines: {node: '>=6.9.0'}
'@codemirror/autocomplete@6.20.3':
resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==}
'@codemirror/commands@6.11.1':
resolution: {integrity: sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==}
'@codemirror/lang-angular@0.1.4':
resolution: {integrity: sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==}
'@codemirror/lang-cpp@6.0.3':
resolution: {integrity: sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==}
'@codemirror/lang-css@6.3.1':
resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
'@codemirror/lang-go@6.0.1':
resolution: {integrity: sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==}
'@codemirror/lang-html@6.4.12':
resolution: {integrity: sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==}
'@codemirror/lang-java@6.0.2':
resolution: {integrity: sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==}
'@codemirror/lang-javascript@6.2.5':
resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==}
'@codemirror/lang-jinja@6.0.1':
resolution: {integrity: sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw==}
'@codemirror/lang-json@6.0.2':
resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
'@codemirror/lang-less@6.0.2':
resolution: {integrity: sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==}
'@codemirror/lang-liquid@6.3.2':
resolution: {integrity: sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw==}
'@codemirror/lang-markdown@6.5.2':
resolution: {integrity: sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==}
'@codemirror/lang-php@6.0.2':
resolution: {integrity: sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==}
'@codemirror/lang-python@6.2.1':
resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==}
'@codemirror/lang-rust@6.0.2':
resolution: {integrity: sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==}
'@codemirror/lang-sass@6.0.2':
resolution: {integrity: sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==}
'@codemirror/lang-sql@6.10.0':
resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==}
'@codemirror/lang-vue@0.1.3':
resolution: {integrity: sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==}
'@codemirror/lang-wast@6.0.2':
resolution: {integrity: sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==}
'@codemirror/lang-xml@6.1.0':
resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==}
'@codemirror/lang-yaml@6.1.3':
resolution: {integrity: sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==}
'@codemirror/language-data@6.5.2':
resolution: {integrity: sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==}
'@codemirror/language@6.12.4':
resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==}
'@codemirror/legacy-modes@6.5.4':
resolution: {integrity: sha512-/cZr6qZyl08iYNLGsJ862CXXNI51LryRFRE40ejgoIjXZz0C1rGkD3/Ek5jM/8w1ceRjqtt4qx/KLMh4zBTgew==}
'@codemirror/lint@6.9.7':
resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==}
'@codemirror/search@6.7.2':
resolution: {integrity: sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==}
'@codemirror/state@6.7.6':
resolution: {integrity: sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==}
'@codemirror/view@6.43.13':
resolution: {integrity: sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==}
'@ctrl/tinycolor@3.6.1':
resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==}
engines: {node: '>=10'}
@@ -282,6 +375,65 @@ packages:
'@jridgewell/sourcemap-codec@1.5.0':
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
'@lezer/common@1.5.3':
resolution: {integrity: sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==}
'@lezer/cpp@1.1.6':
resolution: {integrity: sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA==}
'@lezer/css@1.3.8':
resolution: {integrity: sha512-EJn1zcL9qoDptief6ipWKZKLiOpXkxSe0+t8CH9oiMVcZlq7NBWrjCqnc/41EIjeo/ITj1gFFiATdTkaJDL+Og==}
'@lezer/go@1.0.1':
resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==}
'@lezer/highlight@1.2.5':
resolution: {integrity: sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==}
'@lezer/html@1.3.13':
resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==}
'@lezer/java@1.1.4':
resolution: {integrity: sha512-gQylJ2xHV2xXsVqtnAcJrcROvLZJaQiC0tR//+q0k961BxqvyYttusPkJ8d9T1fPDJKHJW2etUH8XbIn9jhVUg==}
'@lezer/javascript@1.5.5':
resolution: {integrity: sha512-sWg4yX1J6XW67AaAynVt0iwF0M5c+np36TEu+P2ifAJ8haRYvHnWDV28r1jdwnJehWCwXECutAUy56K4RBZIyg==}
'@lezer/json@1.0.3':
resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==}
'@lezer/lr@1.4.10':
resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==}
'@lezer/markdown@1.7.2':
resolution: {integrity: sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==}
'@lezer/php@1.0.6':
resolution: {integrity: sha512-QJ2xNXPmsm/Z3IpThq8fnJrEIVpxhsIoj6GZBW0JYEd/l9zxpJZW216X4fzqQY4vgpdGIumypvI4uQjd4tnYtw==}
'@lezer/python@1.1.19':
resolution: {integrity: sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==}
'@lezer/rust@1.0.3':
resolution: {integrity: sha512-XxErOjZzQ7yJt1agUT4fu9qQvESZ3acgoxpPaPTPOiUx+duCjaVAtZGFIgphkHxlN05djdVAIOy/wItShMEjqQ==}
'@lezer/sass@1.1.0':
resolution: {integrity: sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==}
'@lezer/xml@1.0.6':
resolution: {integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==}
'@lezer/yaml@1.0.4':
resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==}
'@lucide/vue@1.48.0':
resolution: {integrity: sha512-3/Nwx/RsqQ4ZrrSML1GNsSWCSDyLnBgoF/eEr1q9G6iZymn3+WKtzIj7Dbc/Nxrt9WOPuGrJ58S+CBiamK+36w==}
peerDependencies:
vue: '>=3.0.1'
'@marijn/find-cluster-break@1.0.4':
resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==}
'@parcel/watcher-android-arm64@2.5.1':
resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==}
engines: {node: '>= 10.0.0'}
@@ -581,6 +733,15 @@ packages:
'@types/estree@1.0.7':
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
'@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
'@types/markdown-it@14.2.0':
resolution: {integrity: sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==}
'@types/mdurl@2.0.0':
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
'@types/node@14.18.63':
resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==}
@@ -601,6 +762,15 @@ packages:
peerDependencies:
vue: '>=2.7 || >=3'
'@vavt/copy2clipboard@1.0.3':
resolution: {integrity: sha512-HtG48r2FBYp9eRvGB3QGmtRBH1zzRRAVvFbGgFstOwz4/DDaNiX0uZc3YVKPydqgOav26pibr9MtoCaWxn7aeA==}
'@vavt/markdown-theme@6.0.1':
resolution: {integrity: sha512-S6HClmYtnrBuAm+6aeAKRtMwy6cTv+7QlGQYlpNHwumoFr3x86X6uGtJbvrv3ReoKx40vCOJR7h+1OiflKJOWA==}
'@vavt/util@2.1.2':
resolution: {integrity: sha512-L3UbSJthJwr3wq0x93O5TrCepimrmVZaIl2ciZbeL18G5++gBhJXNhcH7RcVk/6rr3SavWOvwhig0mqRLoR7dw==}
'@vitejs/plugin-vue@5.2.3':
resolution: {integrity: sha512-IYSLEQj4LgZZuoVpdSUCw3dIynTWQgPlaRP6iAvMle4My0HdYwr5g5wQAfwOeHQBmYwEkqF70nRpSilr6PoUDg==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -667,6 +837,9 @@ packages:
resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==}
engines: {node: '>= 10'}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
array-tree-filter@2.1.0:
resolution: {integrity: sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==}
@@ -740,6 +913,9 @@ packages:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
codemirror@6.0.2:
resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
codepage@1.15.0:
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
engines: {node: '>=0.8'}
@@ -748,6 +924,9 @@ packages:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
compress-commons@4.1.2:
resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==}
engines: {node: '>= 10'}
@@ -773,6 +952,12 @@ packages:
resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==}
engines: {node: '>= 10'}
crelt@1.0.7:
resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==}
cssfilter@0.0.10:
resolution: {integrity: sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==}
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
@@ -972,6 +1157,11 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
json5@2.2.3:
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
engines: {node: '>=6'}
hasBin: true
jszip@3.10.2:
resolution: {integrity: sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==}
@@ -1050,6 +1240,9 @@ packages:
resolution: {integrity: sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==}
engines: {node: '>= 12.0.0'}
linkify-it@5.0.2:
resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==}
listenercount@1.0.1:
resolution: {integrity: sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==}
@@ -1112,10 +1305,37 @@ packages:
magic-string@0.30.17:
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
markdown-it-image-figures@2.1.1:
resolution: {integrity: sha512-mwXSQ2nPeVUzCMIE3HlLvjRioopiqyJLNph0pyx38yf9mpqFDhNGnMpAXF9/A2Xv0oiF2cVyg9xwfF0HNAz05g==}
engines: {node: '>=12.0.0'}
peerDependencies:
markdown-it: '*'
markdown-it-sub@2.0.0:
resolution: {integrity: sha512-iCBKgwCkfQBRg2vApy9vx1C1Tu6D8XYo8NvevI3OlwzBRmiMtsJ2sXupBgEA7PPxiDwNni3qIUkhZ6j5wofDUA==}
markdown-it-sup@2.0.0:
resolution: {integrity: sha512-5VgmdKlkBd8sgXuoDoxMpiU+BiEt3I49GItBzzw7Mxq9CxvnhE/k09HFli09zgfFDRixDQDfDxi0mgBCXtaTvA==}
markdown-it@14.3.2:
resolution: {integrity: sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==}
hasBin: true
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
md-editor-v3@7.1.0:
resolution: {integrity: sha512-+byIdwES9aGEeDDxcxx7LiVX8g31VgwJqETtfFvGjsGuwEAuMeuPZ6vV8/vltNa+SCBrigbwQxLb1R47mxaWfg==}
peerDependencies:
vue: ^3.5.3
mdurl@2.1.0:
resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==}
medium-zoom@1.1.0:
resolution: {integrity: sha512-ewyDsp7k4InCUp3jRmwHBRFGyjBimKps/AJLjRSox+2q/2H4p/PNpQf+pwONWlJiOudkBXtbdmVbFjqyybfTmQ==}
micromatch@4.0.8:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
@@ -1203,6 +1423,10 @@ packages:
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
punycode.js@2.3.1:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'}
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
@@ -1268,6 +1492,9 @@ packages:
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
style-mod@4.1.4:
resolution: {integrity: sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==}
stylis@4.3.6:
resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==}
@@ -1305,6 +1532,9 @@ packages:
traverse@0.3.9:
resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==}
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
unhead@1.11.20:
resolution: {integrity: sha512-3AsNQC0pjwlLqEYHLjtichGWankK8yqmocReITecmpB1H0aOabeESueyy+8X1gyJx4ftZVwo9hqQ4O3fPWffCA==}
@@ -1389,6 +1619,9 @@ packages:
typescript:
optional: true
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
warning@4.0.3:
resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
@@ -1411,6 +1644,11 @@ packages:
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
xss@1.0.15:
resolution: {integrity: sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==}
engines: {node: '>= 0.10.0'}
hasBin: true
zhead@2.2.4:
resolution: {integrity: sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag==}
@@ -1460,6 +1698,257 @@ snapshots:
'@babel/helper-string-parser': 7.25.9
'@babel/helper-validator-identifier': 7.25.9
'@codemirror/autocomplete@6.20.3':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.3
'@codemirror/commands@6.11.1':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.3
'@codemirror/lang-angular@0.1.4':
dependencies:
'@codemirror/lang-html': 6.4.12
'@codemirror/lang-javascript': 6.2.5
'@codemirror/language': 6.12.4
'@lezer/common': 1.5.3
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@codemirror/lang-cpp@6.0.3':
dependencies:
'@codemirror/language': 6.12.4
'@lezer/cpp': 1.1.6
'@codemirror/lang-css@6.3.1':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.3
'@lezer/css': 1.3.8
'@codemirror/lang-go@6.0.1':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.3
'@lezer/go': 1.0.1
'@codemirror/lang-html@6.4.12':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/lang-css': 6.3.1
'@codemirror/lang-javascript': 6.2.5
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.3
'@lezer/css': 1.3.8
'@lezer/html': 1.3.13
'@codemirror/lang-java@6.0.2':
dependencies:
'@codemirror/language': 6.12.4
'@lezer/java': 1.1.4
'@codemirror/lang-javascript@6.2.5':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/lint': 6.9.7
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.3
'@lezer/javascript': 1.5.5
'@codemirror/lang-jinja@6.0.1':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/lang-html': 6.4.12
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.3
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@codemirror/lang-json@6.0.2':
dependencies:
'@codemirror/language': 6.12.4
'@lezer/json': 1.0.3
'@codemirror/lang-less@6.0.2':
dependencies:
'@codemirror/lang-css': 6.3.1
'@codemirror/language': 6.12.4
'@lezer/common': 1.5.3
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@codemirror/lang-liquid@6.3.2':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/lang-html': 6.4.12
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.3
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@codemirror/lang-markdown@6.5.2':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/lang-html': 6.4.12
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.3
'@lezer/markdown': 1.7.2
'@codemirror/lang-php@6.0.2':
dependencies:
'@codemirror/lang-html': 6.4.12
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.3
'@lezer/php': 1.0.6
'@codemirror/lang-python@6.2.1':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.3
'@lezer/python': 1.1.19
'@codemirror/lang-rust@6.0.2':
dependencies:
'@codemirror/language': 6.12.4
'@lezer/rust': 1.0.3
'@codemirror/lang-sass@6.0.2':
dependencies:
'@codemirror/lang-css': 6.3.1
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.3
'@lezer/sass': 1.1.0
'@codemirror/lang-sql@6.10.0':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.3
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@codemirror/lang-vue@0.1.3':
dependencies:
'@codemirror/lang-html': 6.4.12
'@codemirror/lang-javascript': 6.2.5
'@codemirror/language': 6.12.4
'@lezer/common': 1.5.3
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@codemirror/lang-wast@6.0.2':
dependencies:
'@codemirror/language': 6.12.4
'@lezer/common': 1.5.3
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@codemirror/lang-xml@6.1.0':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.3
'@lezer/xml': 1.0.6
'@codemirror/lang-yaml@6.1.3':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.3
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@lezer/yaml': 1.0.4
'@codemirror/language-data@6.5.2':
dependencies:
'@codemirror/lang-angular': 0.1.4
'@codemirror/lang-cpp': 6.0.3
'@codemirror/lang-css': 6.3.1
'@codemirror/lang-go': 6.0.1
'@codemirror/lang-html': 6.4.12
'@codemirror/lang-java': 6.0.2
'@codemirror/lang-javascript': 6.2.5
'@codemirror/lang-jinja': 6.0.1
'@codemirror/lang-json': 6.0.2
'@codemirror/lang-less': 6.0.2
'@codemirror/lang-liquid': 6.3.2
'@codemirror/lang-markdown': 6.5.2
'@codemirror/lang-php': 6.0.2
'@codemirror/lang-python': 6.2.1
'@codemirror/lang-rust': 6.0.2
'@codemirror/lang-sass': 6.0.2
'@codemirror/lang-sql': 6.10.0
'@codemirror/lang-vue': 0.1.3
'@codemirror/lang-wast': 6.0.2
'@codemirror/lang-xml': 6.1.0
'@codemirror/lang-yaml': 6.1.3
'@codemirror/language': 6.12.4
'@codemirror/legacy-modes': 6.5.4
'@codemirror/language@6.12.4':
dependencies:
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.3
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
style-mod: 4.1.4
'@codemirror/legacy-modes@6.5.4':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/lint@6.9.7':
dependencies:
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
crelt: 1.0.7
'@codemirror/search@6.7.2':
dependencies:
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
crelt: 1.0.7
'@codemirror/state@6.7.6':
dependencies:
'@marijn/find-cluster-break': 1.0.4
'@codemirror/view@6.43.13':
dependencies:
'@codemirror/state': 6.7.6
crelt: 1.0.7
style-mod: 4.1.4
w3c-keyname: 2.2.8
'@ctrl/tinycolor@3.6.1': {}
'@emotion/hash@0.9.2': {}
@@ -1564,6 +2053,105 @@ snapshots:
'@jridgewell/sourcemap-codec@1.5.0': {}
'@lezer/common@1.5.3': {}
'@lezer/cpp@1.1.6':
dependencies:
'@lezer/common': 1.5.3
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@lezer/css@1.3.8':
dependencies:
'@lezer/common': 1.5.3
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@lezer/go@1.0.1':
dependencies:
'@lezer/common': 1.5.3
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@lezer/highlight@1.2.5':
dependencies:
'@lezer/common': 1.5.3
'@lezer/html@1.3.13':
dependencies:
'@lezer/common': 1.5.3
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@lezer/java@1.1.4':
dependencies:
'@lezer/common': 1.5.3
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@lezer/javascript@1.5.5':
dependencies:
'@lezer/common': 1.5.3
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@lezer/json@1.0.3':
dependencies:
'@lezer/common': 1.5.3
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@lezer/lr@1.4.10':
dependencies:
'@lezer/common': 1.5.3
'@lezer/markdown@1.7.2':
dependencies:
'@lezer/common': 1.5.3
'@lezer/highlight': 1.2.5
'@lezer/php@1.0.6':
dependencies:
'@lezer/common': 1.5.3
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@lezer/python@1.1.19':
dependencies:
'@lezer/common': 1.5.3
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@lezer/rust@1.0.3':
dependencies:
'@lezer/common': 1.5.3
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@lezer/sass@1.1.0':
dependencies:
'@lezer/common': 1.5.3
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@lezer/xml@1.0.6':
dependencies:
'@lezer/common': 1.5.3
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@lezer/yaml@1.0.4':
dependencies:
'@lezer/common': 1.5.3
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@lucide/vue@1.48.0(vue@3.5.13)':
dependencies:
vue: 3.5.13
'@marijn/find-cluster-break@1.0.4': {}
'@parcel/watcher-android-arm64@2.5.1':
optional: true
@@ -1757,6 +2345,15 @@ snapshots:
'@types/estree@1.0.7': {}
'@types/linkify-it@5.0.0': {}
'@types/markdown-it@14.2.0':
dependencies:
'@types/linkify-it': 5.0.0
'@types/mdurl': 2.0.0
'@types/mdurl@2.0.0': {}
'@types/node@14.18.63': {}
'@unhead/dom@1.11.20':
@@ -1787,6 +2384,12 @@ snapshots:
unhead: 1.11.20
vue: 3.5.13
'@vavt/copy2clipboard@1.0.3': {}
'@vavt/markdown-theme@6.0.1': {}
'@vavt/util@2.1.2': {}
'@vitejs/plugin-vue@5.2.3(vite@6.3.3(jiti@2.4.2)(lightningcss@1.29.2)(sass@1.88.0))(vue@3.5.13)':
dependencies:
vite: 6.3.3(jiti@2.4.2)(lightningcss@1.29.2)(sass@1.88.0)
@@ -1920,6 +2523,8 @@ snapshots:
tar-stream: 2.2.0
zip-stream: 4.1.1
argparse@2.0.1: {}
array-tree-filter@2.1.0: {}
async-validator@4.2.5: {}
@@ -1998,12 +2603,24 @@ snapshots:
dependencies:
readdirp: 4.1.2
codemirror@6.0.2:
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/commands': 6.11.1
'@codemirror/language': 6.12.4
'@codemirror/lint': 6.9.7
'@codemirror/search': 6.7.2
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
codepage@1.15.0: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
commander@2.20.3: {}
compress-commons@4.1.2:
dependencies:
buffer-crc32: 0.2.13
@@ -2026,6 +2643,10 @@ snapshots:
crc-32: 1.2.2
readable-stream: 3.6.2
crelt@1.0.7: {}
cssfilter@0.0.10: {}
csstype@3.1.3: {}
dayjs@1.11.13: {}
@@ -2235,6 +2856,8 @@ snapshots:
js-tokens@4.0.0: {}
json5@2.2.3: {}
jszip@3.10.2:
dependencies:
lie: 3.3.0
@@ -2295,6 +2918,10 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.29.2
lightningcss-win32-x64-msvc: 1.29.2
linkify-it@5.0.2:
dependencies:
uc.micro: 2.1.0
listenercount@1.0.1: {}
lodash-es@4.17.21: {}
@@ -2339,8 +2966,55 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.0
markdown-it-image-figures@2.1.1(markdown-it@14.3.2):
dependencies:
markdown-it: 14.3.2
markdown-it-sub@2.0.0: {}
markdown-it-sup@2.0.0: {}
markdown-it@14.3.2:
dependencies:
argparse: 2.0.1
entities: 4.5.0
linkify-it: 5.0.2
mdurl: 2.1.0
punycode.js: 2.3.1
uc.micro: 2.1.0
math-intrinsics@1.1.0: {}
md-editor-v3@7.1.0(vue@3.5.13):
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/commands': 6.11.1
'@codemirror/lang-markdown': 6.5.2
'@codemirror/language': 6.12.4
'@codemirror/language-data': 6.5.2
'@codemirror/search': 6.7.2
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/highlight': 1.2.5
'@lucide/vue': 1.48.0(vue@3.5.13)
'@types/markdown-it': 14.2.0
'@vavt/copy2clipboard': 1.0.3
'@vavt/markdown-theme': 6.0.1
'@vavt/util': 2.1.2
codemirror: 6.0.2
json5: 2.2.3
markdown-it: 14.3.2
markdown-it-image-figures: 2.1.1(markdown-it@14.3.2)
markdown-it-sub: 2.0.0
markdown-it-sup: 2.0.0
medium-zoom: 1.1.0
vue: 3.5.13
xss: 1.0.15
mdurl@2.1.0: {}
medium-zoom@1.1.0: {}
micromatch@4.0.8:
dependencies:
braces: 3.0.3
@@ -2413,6 +3087,8 @@ snapshots:
proxy-from-env@1.1.0: {}
punycode.js@2.3.1: {}
readable-stream@2.3.8:
dependencies:
core-util-is: 1.0.3
@@ -2505,6 +3181,8 @@ snapshots:
dependencies:
safe-buffer: 5.2.1
style-mod@4.1.4: {}
stylis@4.3.6: {}
swiper@11.2.6: {}
@@ -2537,6 +3215,8 @@ snapshots:
traverse@0.3.9: {}
uc.micro@2.1.0: {}
unhead@1.11.20:
dependencies:
'@unhead/dom': 1.11.20
@@ -2597,6 +3277,8 @@ snapshots:
'@vue/server-renderer': 3.5.13(vue@3.5.13)
'@vue/shared': 3.5.13
w3c-keyname@2.2.8: {}
warning@4.0.3:
dependencies:
loose-envify: 1.4.0
@@ -2619,6 +3301,11 @@ snapshots:
xmlchars@2.2.0: {}
xss@1.0.15:
dependencies:
commander: 2.20.3
cssfilter: 0.0.10
zhead@2.2.4: {}
zip-stream@4.1.1:

View File

@@ -25,6 +25,9 @@ service.interceptors.request.use(
);
// 响应拦截器
// 401 跳转防抖:并发请求同时 401 时只提示/跳转一次
let redirectingToLogin = false
service.interceptors.response.use(
response => {
const {code, result, message} = response.data;
@@ -32,13 +35,17 @@ service.interceptors.response.use(
return result;
} else {
if (code === 401) {
msg.error(message).then(r => {
// 登录态失效(已退出/token 过期):立即清态并回登录页。
// 不能等 message.close 的 promise —— 那会让页面卡在原处约 3 秒才跳(用户感知为「不跳转」)。
if (!redirectingToLogin) {
redirectingToLogin = true
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/'
msg.destroy()
})
return;
msg.error(message || '登录已过期,请重新登录');
const redirect = encodeURIComponent(window.location.pathname + window.location.search)
window.location.href = `/login?redirect=${redirect}`
}
return Promise.reject({code, message, result});
}
// 4009 = 布局版本冲突(CAS 失败)。契约约定:该错误不携带最新布局(result 恒为 null),
// 需由调用方(设计器)自行重拉详情并提示,故仅此一个 code 走 reject,
@@ -69,10 +76,13 @@ const post = (url, data = {}) => {
return service.post(url, data);
};
// 封装上传文件请求
const upload = (url, file) => {
// 封装上传文件请求(fields 用于附加表单字段,如上传场景 scene)
const upload = (url, file, fields = {}) => {
const formData = new FormData();
formData.append('file', file);
Object.keys(fields).forEach((k) => {
if (fields[k] !== undefined && fields[k] !== null) formData.append(k, fields[k]);
});
return service.post(url, formData, {
headers: {
'Content-Type': 'multipart/form-data'

View File

@@ -0,0 +1,26 @@
import { get, post, upload } from '../request.js'
/**
* 附件管理接口(迭代-2026-09-29)。
*
* 所有上传(含用户端头像)都登记在附件表;删除为「记录软删 + 文件本体按引用检查」:
* 响应 removed_file=false 表示文件仍被头像/封面等引用,仅移除了登记记录。
*/
/**
* 管理端上传。scene:cover=服务封面(仅图片 ≤5MB)| attachment=通用附件(≤20MB)。
* 返回 { id, url, path, file_name, ext, size_bytes };库内只存 path(相对路径)。
*/
export function adminUploadApi(file, scene) {
return upload('admin/upload', file, { scene })
}
/** 附件列表。params: { keyword, scene, page, pageSize } */
export function attachmentListApi(params) {
return get('admin/attachment/list', params)
}
/** 删除附件。返回 { removed_file } */
export function attachmentDeleteApi(data) {
return post('admin/attachment/delete', data)
}

160
src/api/request/project.js Normal file
View File

@@ -0,0 +1,160 @@
import { get, post } from '../request.js'
/**
* 开发项目管理接口(迭代-2026-09-30 三期)。
*
* 流程:建单(生成绑定令牌)→ 生成绑定小程序码(/qrcode/...)→ 用户扫码绑定;
* 阶段/小节点/交付物均为即时保存接口。阶段状态变更 / 交付物上传 / 交付 会推站内信给用户。
*/
export const PROJECT_STATUS_OPTIONS = [
{ value: 1, label: '进行中', color: 'processing' },
{ value: 2, label: '已交付', color: 'success' },
{ value: 3, label: '已关闭', color: 'default' },
]
export const STAGE_STATUS_OPTIONS = [
{ value: 0, label: '未开始' },
{ value: 1, label: '进行中' },
{ value: 2, label: '已完成' },
]
export function projectStatusLabel(status) {
return PROJECT_STATUS_OPTIONS.find((s) => s.value === status)?.label || '未知'
}
export function stageStatusLabel(status) {
return STAGE_STATUS_OPTIONS.find((s) => s.value === status)?.label || '未开始'
}
/** 项目列表 */
export function projectListApi(params) {
return get('admin/service/project/list', params)
}
/** 项目详情(管理态全量:阶段/节点/文件) */
export function projectDetailApi(id) {
return get('admin/service/project/detail', { id })
}
/**
* 保存项目(新增 id=0 会生成绑定令牌并返回;userId>0 直接绑定该用户)。
* @param {object} data { id, title, description, priceCents, priceNote, dueNote, status, userId }
*/
export function projectSaveApi(data) {
return post('admin/service/project/save', data)
}
/** 删除项目(软删;阶段/节点/文件一并清除) */
export function projectDeleteApi(data) {
return post('admin/service/project/delete', data)
}
/** 生成/刷新绑定小程序码(微信 getUnlimited) */
export function projectQrcodeApi(data) {
return post('admin/service/project/qrcode', data)
}
/** 保存阶段 */
export function projectStageSaveApi(data) {
return post('admin/service/project/stage/save', data)
}
/** 删除阶段(节点/文件一并删除) */
export function projectStageDeleteApi(data) {
return post('admin/service/project/stage/delete', data)
}
/** 保存小节点(isDone 即时生效) */
export function projectNodeSaveApi(data) {
return post('admin/service/project/node/save', data)
}
/** 删除小节点 */
export function projectNodeDeleteApi(data) {
return post('admin/service/project/node/delete', data)
}
/** 登记交付物(先走 adminUploadApi 上传拿 path) */
export function projectFileAddApi(data) {
return post('admin/service/project/file/add', data)
}
/** 删除交付物登记 */
export function projectFileDeleteApi(data) {
return post('admin/service/project/file/delete', data)
}
// ---------- 四期:维护模板 / 付款分期模板 / 分期到账 / 维护期 / 提醒设置 ----------
/** 维护模板列表(含停用) */
export function maintainPlanListApi() {
return get('admin/service/maintain-plan/list')
}
/** 保存维护模板 { id, name, freeYears, yearlyFeeCents, sourceOnly, sort, isEnabled, remark } */
export function maintainPlanSaveApi(data) {
return post('admin/service/maintain-plan/save', data)
}
/** 删除维护模板 */
export function maintainPlanDeleteApi(data) {
return post('admin/service/maintain-plan/delete', data)
}
/** 付款分期模板列表(含停用) */
export function payPlanListApi() {
return get('admin/service/pay-plan/list')
}
/** 保存付款分期模板 { id, name, schedule(JSON 串), sort, isEnabled, remark } */
export function payPlanSaveApi(data) {
return post('admin/service/pay-plan/save', data)
}
/** 删除付款分期模板 */
export function payPlanDeleteApi(data) {
return post('admin/service/pay-plan/delete', data)
}
/** 标记分期到账 { id, paidNote }(用户端推站内信) */
export function projectPayMarkApi(data) {
return post('admin/service/project/pay/mark', data)
}
/** 撤销分期到账 */
export function projectPayUnmarkApi(data) {
return post('admin/service/project/pay/unmark', data)
}
/** 维护费续收一年 */
export function projectMaintainRenewApi(data) {
return post('admin/service/project/maintain/renew', data)
}
/** 调整维护期起算日 { id, startAt: 'YYYY-MM-DD' } */
export function projectMaintainAdjustApi(data) {
return post('admin/service/project/maintain/adjust', data)
}
/** 维护费提醒设置读取 */
export function maintainRemindGetApi() {
return get('admin/service/maintain-remind/get')
}
/** 维护费提醒设置保存 { remindDays, adminNotifyUserId } */
export function maintainRemindSaveApi(data) {
return post('admin/service/maintain-remind/save', data)
}
/** 分期触发节点文案(与后端 consts.go 对齐) */
export const PAY_TRIGGER_OPTIONS = [
{ value: 'start', label: '开工前(定金)' },
{ value: 'mid_demo', label: '中期演示确认后' },
{ value: 'online_before', label: '上线前' },
{ value: 'online_after', label: '上线后' },
]
export function payTriggerLabel(trigger) {
return PAY_TRIGGER_OPTIONS.find((t) => t.value === trigger)?.label || trigger
}

View File

@@ -0,0 +1,87 @@
import { get, post } from '../request.js'
/**
* 软件开发服务二期接口(迭代-2026-09-30):介绍页模块 / 作品案例 / 报价申请。
*
* 契约约定:请求体 camelCase / 响应 snake_case(与 software.js 一致)。
* 图片库内只存相对路径:保存时提交上传接口返回的 path;响应里的 *_url 已是
* 后端按 site.base_url 拼好的绝对地址,直接展示。
*/
/** 介绍页模块类型(与后端 consts.go 对齐;每种类型对应一种预设排版布局) */
export const SERVICE_BLOCK_KINDS = [
{ value: 'hero', label: '头图横幅', hint: '大标题 + 标语,页面开场;标题支持 **高亮** 与换行' },
{ value: 'flow', label: '服务流程', hint: '时间轴排版;正文每行「标题|描述」,如 需求沟通|澄清目标与边界' },
{ value: 'checklist', label: '交付清单', hint: '勾选卡片;正文每行一项,如 需求文档' },
{ value: 'price', label: '价格卡', hint: '三列价格卡;正文每行「名称|描述|价格」,如 官网展示型|官网搭建|3万起' },
{ value: 'rich', label: '富文本', hint: 'Markdown 图文混排,适合大段介绍' },
{ value: 'image_text', label: '图文', hint: '一张图 + 说明文字,上图下文' },
{ value: 'image_list', label: '图片列表', hint: '多图网格展示,点击可预览' },
{ value: 'developer', label: '开发者简介', hint: '橙环头像 + 一句话亮点 + 技术栈行(介绍文字选填)' },
{ value: 'works', label: '作品模块入口', hint: '展示作品精选封面,点击进作品列表' },
{ value: 'goods', label: '服务入口', hint: '不单独成卡;作为「查看全部服务」并入「服务与价格」区块底部(需配合价格卡区块)' },
]
export function serviceBlockKindLabel(kind) {
return SERVICE_BLOCK_KINDS.find((k) => k.value === kind)?.label || kind
}
// ---------- 介绍页模块 ----------
/** 模块列表(含停用,按 sort 升序) */
export function serviceBlockListApi() {
return get('admin/service/block/list')
}
/**
* 保存模块(新增 id=0 / 编辑 id>0)。
* @param {object} data { id, kind, title, subtitle, imagePath, images[], content, sort, isEnabled }
*/
export function serviceBlockSaveApi(data) {
return post('admin/service/block/save', data)
}
/** 删除模块 */
export function serviceBlockDeleteApi(data) {
return post('admin/service/block/delete', data)
}
// ---------- 作品案例 ----------
/** 作品列表(管理端,含下架) */
export function serviceWorkListApi(params) {
return get('admin/service/work/list', params)
}
/** 作品详情(编辑回填,含 content_md 与图集) */
export function serviceWorkDetailApi(id) {
return get('admin/service/work/detail', { id })
}
/**
* 保存作品(新增 id=0 / 编辑 id>0)。
* @param {object} data { id, category, title, summary, contentMd, coverPath, images[], priceCents, priceNote, sort, isEnabled }
*/
export function serviceWorkSaveApi(data) {
return post('admin/service/work/save', data)
}
/** 删除作品(软删除) */
export function serviceWorkDeleteApi(data) {
return post('admin/service/work/delete', data)
}
// ---------- 报价申请 ----------
/** 报价申请列表(status 传 undefined = 全部) */
export function serviceQuoteListApi(params) {
return get('admin/service/quote/list', params)
}
/**
* 处理报价申请:填报价金额(分)+ 说明 + 状态。保存后向用户推站内信。
* @param {object} data { id, status, quoteCents, quoteNote }
*/
export function serviceQuoteHandleApi(data) {
return post('admin/service/quote/handle', data)
}

View File

@@ -0,0 +1,18 @@
import { get, post } from '../request.js'
/**
* 系统配置接口(迭代-2026-09-29)。
*
* 白名单键读写(当前仅 site.base_url —— 图片等静态资源的对外拼接前缀,
* 留空时后端按请求 Host 动态拼接)。键值存 settings 表,保存立即生效。
*/
/** 读取配置项列表:[{ key, value, remark }] */
export function settingGetApi() {
return get('admin/setting/get')
}
/** 保存一项配置 { key, value } */
export function settingSaveApi(data) {
return post('admin/setting/save', data)
}

View File

@@ -0,0 +1,49 @@
import { get, post } from '../request.js'
/**
* 软件开发服务接口(迭代-2026-09-29)。
*
* 契约约定:请求体 camelCase / 响应 snake_case(与 promo.js 一致)。
* 响应 cover_url 已是后端按 site.base_url 拼好的绝对地址,前端直接展示;
* 保存时提交 coverPath(上传接口返回的相对路径),库内不落域名。
*/
/** 服务分类(与后端 consts.go 对齐) */
export const GOODS_CATEGORY_OPTIONS = [
{ value: 1, label: '网站开发系列' },
{ value: 2, label: '小程序开发系列' },
{ value: 3, label: 'App 开发系列' },
{ value: 4, label: '桌面程序开发系列' },
]
export function goodsCategoryLabel(category) {
return GOODS_CATEGORY_OPTIONS.find((c) => c.value === category)?.label || '其他'
}
/** 分(int)→ 元展示串('面议' 由调用方处理 0 值) */
export function yuanFromCents(cents) {
return (Number(cents || 0) / 100).toFixed(2).replace(/\.00$/, '')
}
/** 服务列表(管理端,含下架) */
export function goodsListApi(params) {
return get('admin/goods/list', params)
}
/** 服务详情(编辑回填,含 content_md) */
export function goodsDetailApi(id) {
return get('admin/goods/detail', { id })
}
/**
* 保存服务(新增 id=0 / 编辑 id>0)。
* @param {object} data { id, category, title, summary, contentMd, priceCents, priceNote, coverPath, sort, isEnabled }
*/
export function goodsSaveApi(data) {
return post('admin/goods/save', data)
}
/** 删除服务(软删除) */
export function goodsDeleteApi(data) {
return post('admin/goods/delete', data)
}

View File

@@ -0,0 +1,156 @@
<script setup>
/**
* 品牌标(Canvas 手绘版)。
*
* 用 canvas 画「橘子汽水」品牌块:圆角渐变底 + 白色火箭 + 舷窗 + 尾焰 + 星点 + 高光。
* 为什么用 canvas:品牌图形带渐变/光晕层次,SVG 内联写法已不够(用户点名要 canvas 绘制);
* devicePixelRatio 适配保证高分屏不糊。品牌色与 tokens.css 的 --brand-* 定值一致(配色铁律)。
*/
import { onMounted, ref, watch } from 'vue'
const props = defineProps({
/** 逻辑边长(px),正方形 */
size: { type: Number, default: 36 },
/** 圆角(px),不传按 size 的 30% */
radius: { type: Number, default: 0 },
})
const canvasRef = ref(null)
function draw() {
const canvas = canvasRef.value
if (!canvas)
return
const dpr = Math.min(window.devicePixelRatio || 1, 3)
const s = props.size
canvas.width = Math.round(s * dpr)
canvas.height = Math.round(s * dpr)
canvas.style.width = `${s}px`
canvas.style.height = `${s}px`
const ctx = canvas.getContext('2d')
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, s, s)
const r = props.radius || s * 0.3
// ── 底:圆角橙色渐变 ──
const grad = ctx.createLinearGradient(0, 0, s, s)
grad.addColorStop(0, '#FF9F4D')
grad.addColorStop(1, '#FB4E14')
ctx.beginPath()
ctx.roundRect(0, 0, s, s, r)
ctx.fillStyle = grad
ctx.fill()
// ── 左上斜向高光(玻璃感)──
ctx.save()
ctx.beginPath()
ctx.roundRect(0, 0, s, s, r)
ctx.clip()
const shine = ctx.createLinearGradient(0, 0, s * 0.9, s * 0.9)
shine.addColorStop(0, 'rgba(255,255,255,0.30)')
shine.addColorStop(0.45, 'rgba(255,255,255,0.06)')
shine.addColorStop(1, 'rgba(255,255,255,0)')
ctx.fillStyle = shine
ctx.fillRect(0, 0, s, s)
ctx.restore()
// 坐标系归一到 64 逻辑单位
const k = s / 64
ctx.save()
ctx.scale(k, k)
// ── 火箭(白色主体,微暖描边)──
ctx.translate(32, 34)
ctx.rotate(-0.5) // 朝右上 45° 飞行姿态
// 尾焰(黄→橙,先画,被机身压住根部)
const flame = ctx.createLinearGradient(0, 10, 0, 26)
flame.addColorStop(0, '#FFD24D')
flame.addColorStop(1, 'rgba(251,78,20,0)')
ctx.beginPath()
ctx.moveTo(-4.5, 10)
ctx.quadraticCurveTo(0, 27, 4.5, 10)
ctx.closePath()
ctx.fillStyle = flame
ctx.fill()
// 侧鳍(左右两片,暖橙)
ctx.fillStyle = '#FFE3B0'
ctx.beginPath()
ctx.moveTo(-6.5, 2)
ctx.quadraticCurveTo(-12, 8, -9.5, 14)
ctx.quadraticCurveTo(-6, 10, -5.4, 5.5)
ctx.closePath()
ctx.fill()
ctx.beginPath()
ctx.moveTo(6.5, 2)
ctx.quadraticCurveTo(12, 8, 9.5, 14)
ctx.quadraticCurveTo(6, 10, 5.4, 5.5)
ctx.closePath()
ctx.fill()
// 机身(白色泪滴)
ctx.beginPath()
ctx.moveTo(0, -17)
ctx.quadraticCurveTo(8.5, -8, 7.5, 3)
ctx.quadraticCurveTo(6, 10, 0, 11.5)
ctx.quadraticCurveTo(-6, 10, -7.5, 3)
ctx.quadraticCurveTo(-8.5, -8, 0, -17)
ctx.closePath()
ctx.fillStyle = '#FFFFFF'
ctx.shadowColor = 'rgba(120, 40, 0, 0.28)'
ctx.shadowBlur = 5
ctx.shadowOffsetY = 1.5
ctx.fill()
ctx.shadowColor = 'transparent'
// 舷窗(奶油底 + 橙环 + 高光点)
ctx.beginPath()
ctx.arc(0, -4.5, 4.2, 0, Math.PI * 2)
ctx.fillStyle = '#FFE8DC'
ctx.fill()
ctx.lineWidth = 1.8
ctx.strokeStyle = '#FF9F4D'
ctx.stroke()
ctx.beginPath()
ctx.arc(-1.4, -5.9, 1.1, 0, Math.PI * 2)
ctx.fillStyle = 'rgba(255,255,255,0.9)'
ctx.fill()
ctx.restore()
// ── 星点(右上 + 左下,白色不同透明度)──
const star = (cx, cy, rr, alpha) => {
ctx.beginPath()
for (let i = 0; i < 4; i++) {
const a = (Math.PI / 2) * i + Math.PI / 4
ctx.moveTo(cx + Math.cos(a) * rr, cy + Math.sin(a) * rr)
ctx.lineTo(cx, cy)
}
ctx.strokeStyle = `rgba(255,255,255,${alpha})`
ctx.lineWidth = 1.6
ctx.lineCap = 'round'
ctx.stroke()
ctx.beginPath()
ctx.arc(cx, cy, rr * 0.32, 0, Math.PI * 2)
ctx.fillStyle = `rgba(255,255,255,${alpha})`
ctx.fill()
}
star(50, 14, 4.6, 0.95)
star(13, 47, 3.4, 0.7)
}
onMounted(draw)
watch(() => props.size, draw)
</script>
<template>
<canvas ref="canvasRef" class="brand-logo" aria-label="工具百宝箱" />
</template>
<style scoped>
.brand-logo {
display: block;
}
</style>

View File

@@ -1,13 +1,14 @@
<script setup lang="ts">
import { computed, h, ref, watchEffect } from 'vue'
import { computed, h, ref, watch, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useAppStore } from '@/stores/app'
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons-vue'
import BrandLogo from '@/components/BrandLogo.vue'
import {
Building2, CalendarDays, FileSearch, History, LayoutDashboard, LayoutGrid, LayoutTemplate,
MapPin, Medal, MessageSquare, Monitor, Moon, PenTool, Receipt, Send, Settings, Sun, Ticket,
Upload, UserCog, Users, Wrench,
BadgeDollarSign, Blocks, Briefcase, Building2, CalendarDays, Code2, FileSearch, FolderOpen, History,
LayoutDashboard, LayoutGrid, LayoutTemplate, MapPin, Medal, MessageSquare, Monitor, PanelsTopLeft,
PenTool, Receipt, Send, Settings, SlidersHorizontal, Sun, Ticket, Upload, UserCog, Users, Wrench,
} from 'lucide-vue-next'
import { hasPermission, PERM } from '@/utils/permissions'
@@ -22,7 +23,12 @@ const themeLabel = computed(
() => ({ light: '浅色', dark: '深色', auto: '跟随系统' })[appStore.mode] || '浅色',
)
const collapsed = ref(false)
// 侧栏折叠:本地记忆(localStorage tb:sidebar:collapsed),刷新后恢复上次的展开/折叠状态
const COLLAPSE_KEY = 'tb:sidebar:collapsed'
const collapsed = ref(localStorage.getItem(COLLAPSE_KEY) === '1')
watch(collapsed, (v) => {
localStorage.setItem(COLLAPSE_KEY, v ? '1' : '0')
})
/** 菜单图标统一 16px(lucide 线性风与玻璃主题一致) */
const icon = (Comp: Parameters<typeof h>[0]) => h(Comp, { size: 16 })
@@ -40,6 +46,21 @@ const menus = computed(() => {
{ key: '/users', label: '用户管理', icon: icon(Users) },
{ key: '/orders', label: '订单管理', icon: icon(Receipt), perm: PERM.ORDER_READ },
{ key: '/feedbacks', label: '反馈管理', icon: icon(MessageSquare) },
// 软件开发服务(迭代-2026-09-29 起):服务商品 / 介绍页编辑 / 作品案例 / 报价申请,
// 全组挂 goods:write(与后端 admin_perm.go 对齐),附件列表仍为独立单页全员可见
{
key: 'software',
label: '软件服务',
icon: icon(Code2),
children: [
{ key: '/software', label: '服务商品', icon: icon(Code2), perm: PERM.GOODS_WRITE },
{ key: '/service/page', label: '介绍页编辑', icon: icon(PanelsTopLeft), perm: PERM.GOODS_WRITE },
{ key: '/service/works', label: '作品案例', icon: icon(Briefcase), perm: PERM.GOODS_WRITE },
{ key: '/service/quotes', label: '报价申请', icon: icon(BadgeDollarSign), perm: PERM.GOODS_WRITE },
{ key: '/service/projects', label: '开发项目', icon: icon(Blocks), perm: PERM.GOODS_WRITE },
],
},
{ key: '/attachments', label: '附件管理', icon: icon(FolderOpen) },
{
key: 'promo',
label: '优惠',
@@ -71,6 +92,7 @@ const menus = computed(() => {
children: [
{ key: '/admins', label: '管理员', icon: icon(UserCog), perm: PERM.ADMIN_MANAGE },
{ key: '/audit', label: '操作审计', icon: icon(History), perm: PERM.AUDIT_READ },
{ key: '/system/config', label: '系统配置', icon: icon(SlidersHorizontal), perm: PERM.ADMIN_MANAGE },
],
},
]
@@ -134,12 +156,9 @@ function logout() {
class="app-shell__sider"
>
<div class="app-shell__brand">
<!-- 品牌标:橙色渐变圆角块 + 火箭贴纸 -->
<!-- 品牌标:canvas 手绘(渐变底 + 火箭 + 舷窗 + 尾焰 + 星点),见 components/BrandLogo.vue -->
<div class="app-shell__logo">
<svg viewBox="0 0 64 64" class="w-6 h-6">
<path d="M32 4c9 7 13 17 13 27l-5 9H24l-5-9C19 21 23 11 32 4z" fill="#fff" />
<circle cx="32" cy="24" r="6" fill="#FFE8DC" />
</svg>
<BrandLogo :size="36" />
</div>
<div v-show="!collapsed" class="app-shell__brand-text">
<div class="app-shell__brand-name">工具百宝箱</div>
@@ -225,11 +244,9 @@ function logout() {
flex: 0 0 auto;
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: linear-gradient(135deg, var(--brand-400), var(--brand-600));
display: flex;
align-items: center;
justify-content: center;
/* 渐变底已由 BrandLogo 的 canvas 自绘,容器只承担投影(避免圆角不同步露底) */
background: transparent;
border-radius: calc(36px * 0.3);
box-shadow: var(--shadow-2);
}

View File

@@ -21,6 +21,13 @@ const routes = [
// 订单管理 + 退款:查看挂 order:read(只读角色可见),退款动作挂 order:write
{ path: 'orders', name: 'OrderList', component: () => import('@/views/orders/OrderList.vue'), meta: { title: '订单管理', perm: 'order:read' } },
{ path: 'feedbacks', name: 'FeedbackList', component: () => import('@/views/feedbacks/FeedbackList.vue'), meta: { title: '反馈管理' } },
// 软件开发服务(迭代-2026-09-29):服务商品 / 附件管理;二期(09-30)介绍页编辑 / 作品案例 / 报价申请
{ path: 'software', name: 'SoftwareManage', component: () => import('@/views/software/SoftwareManage.vue'), meta: { title: '服务商品', group: '软件服务', perm: 'goods:write' } },
{ path: 'service/page', name: 'ServicePageEdit', component: () => import('@/views/service/ServicePageEdit.vue'), meta: { title: '介绍页编辑', group: '软件服务', perm: 'goods:write' } },
{ path: 'service/works', name: 'ServiceWorks', component: () => import('@/views/service/WorksManage.vue'), meta: { title: '作品案例', group: '软件服务', perm: 'goods:write' } },
{ path: 'service/quotes', name: 'ServiceQuotes', component: () => import('@/views/service/QuotesManage.vue'), meta: { title: '报价申请', group: '软件服务', perm: 'goods:write' } },
{ path: 'service/projects', name: 'ServiceProjects', component: () => import('@/views/service/ProjectsManage.vue'), meta: { title: '开发项目', group: '软件服务', perm: 'goods:write' } },
{ path: 'attachments', name: 'AttachmentManage', component: () => import('@/views/attachments/AttachmentManage.vue'), meta: { title: '附件管理' } },
// 优惠(N6 承前):优惠码 / 券模板 / 发券记录,挂 quota:write 权限点(与后端 admin_perm.go 对齐)
{ path: 'promo/list', name: 'PromoList', component: () => import('@/views/promo/PromoList.vue'), meta: { title: '优惠码', group: '优惠', perm: 'quota:write' } },
{ path: 'coupon/template', name: 'CouponTemplate', component: () => import('@/views/coupon/CouponTemplate.vue'), meta: { title: '券模板', group: '优惠', perm: 'quota:write' } },
@@ -34,6 +41,7 @@ const routes = [
// 系统(T17):管理员与操作审计,按角色权限隐藏入口
{ path: 'admins', name: 'AdminManage', component: () => import('@/views/admins/AdminManage.vue'), meta: { title: '管理员', group: '系统', perm: 'admin:manage' } },
{ path: 'audit', name: 'AuditLog', component: () => import('@/views/audit/AuditLog.vue'), meta: { title: '操作审计', group: '系统', perm: 'audit:read' } },
{ path: 'system/config', name: 'SystemConfig', component: () => import('@/views/system/SystemConfig.vue'), meta: { title: '系统配置', group: '系统', perm: 'admin:manage' } },
],
},
]
@@ -48,10 +56,12 @@ const router = createRouter({
router.beforeEach((to) => {
const userStore = useUserStore()
if (to.name !== 'Login' && !userStore.isAuthenticated) {
// 登录态 = token + 用户信息都健在(任一缺失 → 登录页;拦截器 401 也会清态跳转,这里是兜底)
const hasToken = !!localStorage.getItem('token')
if (to.name !== 'Login' && (!hasToken || !userStore.isAuthenticated)) {
return { name: 'Login', query: { redirect: to.fullPath } }
}
if (to.name === 'Login' && userStore.isAuthenticated) {
if (to.name === 'Login' && hasToken && userStore.isAuthenticated) {
return { path: '/' }
}
// 前端权限:无权访问的路由回落到看板(服务端 AdminPerm 仍会二次校验)

View File

@@ -351,6 +351,45 @@
color: var(--text-2);
}
/* 弹层高度约束:整体绝不高于视口,长表单(如服务编辑的 Markdown 编辑器)
在 body 内部滚动,头部与底部按钮恒定可见。216px ≈ 头部 72 + 底部 72 + 上下留白 */
.app-modal-wrap .ant-modal {
max-width: calc(100vw - 32px);
}
.app-modal-wrap .ant-modal-body {
max-height: calc(100vh - 216px);
min-height: 120px;
overflow-y: auto;
/* 纵向内部滚动;横向不出滚动条(antd Row 的 gutter 负 margin 会产生 ~8px 假溢出) */
overflow-x: hidden;
overscroll-behavior: contain;
/* 在 content 自带 24px 之上再补 12px 横向呼吸感(宽弹窗表单/编辑器不贴边) */
padding: 2px 12px 4px;
}
/* ===== 全局滚动条美化:细圆角、主题色、悬停加深(暗色经 token 自动适配) ===== */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border-2);
border-radius: 999px;
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--text-4);
}
* {
scrollbar-width: thin;
scrollbar-color: var(--border-2) transparent;
}
.app-modal-wrap .ant-modal-footer .ant-btn-primary {
background: var(--color-primary);
border-color: var(--color-primary);

View File

@@ -16,6 +16,9 @@ export const PERM = {
AUDIT_READ: 'audit:read',
ORDER_READ: 'order:read',
ORDER_WRITE: 'order:write',
// 软件开发服务 / 附件管理(迭代-2026-09-29,与后端 admin_perm.go 对齐)
GOODS_WRITE: 'goods:write',
ATTACHMENT_WRITE: 'attachment:write',
}
const ROLE_PERMISSIONS = {
@@ -23,11 +26,13 @@ const ROLE_PERMISSIONS = {
PERM.USER_WRITE, PERM.TOOL_WRITE, PERM.MODULE_WRITE, PERM.LEVEL_WRITE,
PERM.FEEDBACK_WRITE, PERM.QUOTA_WRITE, PERM.EVENT_WRITE,
PERM.ADMIN_MANAGE, PERM.AUDIT_READ, PERM.ORDER_READ, PERM.ORDER_WRITE,
PERM.GOODS_WRITE, PERM.ATTACHMENT_WRITE,
],
operator: [
PERM.USER_WRITE, PERM.TOOL_WRITE, PERM.MODULE_WRITE, PERM.LEVEL_WRITE,
PERM.FEEDBACK_WRITE, PERM.QUOTA_WRITE, PERM.EVENT_WRITE,
PERM.ORDER_READ, PERM.ORDER_WRITE,
PERM.GOODS_WRITE, PERM.ATTACHMENT_WRITE,
],
readonly: [
PERM.ORDER_READ,

View File

@@ -2,6 +2,8 @@
import { reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { LockOutlined, UserOutlined } from '@ant-design/icons-vue'
import BrandLogo from '@/components/BrandLogo.vue'
import { useUserStore } from '@/stores/user'
const router = useRouter()
@@ -54,27 +56,34 @@ async function onSubmit() {
<div class="login__card glass glass-strong">
<div class="login__brand">
<div class="login__logo">
<svg viewBox="0 0 64 64" class="w-8 h-8">
<path d="M32 4c9 7 13 17 13 27l-5 9H24l-5-9C19 21 23 11 32 4z" fill="#fff" />
<circle cx="32" cy="24" r="6" fill="#FFE8DC" />
</svg>
<BrandLogo :size="56" />
</div>
<div class="login__title">工具百宝箱</div>
<div class="login__subtitle">管理后台</div>
<div class="login__subtitle">管理后台 · 登录以继续</div>
</div>
<a-form layout="vertical" @submit.prevent="onSubmit">
<a-form-item label="账号">
<a-input v-model:value="form.username" placeholder="请输入账号" size="large" />
<a-input v-model:value="form.username" placeholder="请输入账号" size="large" autocomplete="username">
<template #prefix><UserOutlined class="login__icon" /></template>
</a-input>
</a-form-item>
<a-form-item label="密码">
<a-input-password v-model:value="form.password" placeholder="请输入密码" size="large" />
<a-input-password v-model:value="form.password" placeholder="请输入密码" size="large" autocomplete="current-password">
<template #prefix><LockOutlined class="login__icon" /></template>
</a-input-password>
</a-form-item>
<a-checkbox v-model:checked="form.remember" class="mb-4">记住我</a-checkbox>
<a-button type="primary" html-type="submit" block size="large" :loading="loading">登 录</a-button>
<div class="login__row">
<a-checkbox v-model:checked="form.remember">记住我</a-checkbox>
</div>
<a-button type="primary" html-type="submit" block size="large" class="login__btn" :loading="loading">
登 录
</a-button>
</a-form>
<div class="login__tip">开发环境种子账号:admin / admin123</div>
<div class="login__tip">
开发环境种子账号 <code>admin / admin123</code>
</div>
</div>
</div>
</template>
@@ -154,12 +163,10 @@ async function onSubmit() {
width: 56px;
height: 56px;
margin: 0 auto var(--space-4);
border-radius: var(--radius-lg);
background: linear-gradient(135deg, var(--brand-400), var(--brand-600));
display: flex;
align-items: center;
justify-content: center;
/* 渐变底由 BrandLogo canvas 自绘,容器只承担投影 */
border-radius: 17px;
box-shadow: var(--shadow-3);
overflow: hidden;
}
.login__title {
@@ -175,6 +182,20 @@ async function onSubmit() {
color: var(--text-3);
}
.login__row {
display: flex;
align-items: center;
margin-bottom: var(--space-4);
}
.login__icon {
color: var(--text-3);
}
.login__btn:not(:disabled) {
box-shadow: 0 8px 20px rgba(251, 78, 20, 0.28);
}
.login__tip {
margin-top: var(--space-6);
text-align: center;
@@ -182,6 +203,15 @@ async function onSubmit() {
color: var(--text-3);
}
.login__tip code {
padding: 2px 8px;
border-radius: var(--radius-sm);
background: var(--bg-surface-2);
border: 1px solid var(--border-1);
color: var(--text-2);
font-size: var(--font-xs);
}
.login :deep(.ant-btn-primary) {
background: var(--color-primary);
border-color: var(--color-primary);

View File

@@ -0,0 +1,246 @@
<script setup>
/**
* 附件管理(迭代-2026-09-29)。
*
* 所有上传(用户头像 / 服务封面 / 通用附件)统一登记在附件表:查看(图片缩略预览、
* 打开原文件)、复制链接、删除(记录软删 + 文件本体按引用检查)、上传日期。
* 删除按钮与上传入口挂 attachment:write;列表本身任何登录管理员可看。
*/
import { computed, onMounted, reactive, ref } from 'vue'
import { message } from 'ant-design-vue'
import { FileText } from 'lucide-vue-next'
import { useUserStore } from '@/stores/user'
import { hasPermission, PERM } from '@/utils/permissions'
import { adminUploadApi, attachmentDeleteApi, attachmentListApi } from '@/api/request/attachment'
import PageShell from '@/components/PageShell.vue'
import GlassCard from '@/components/GlassCard.vue'
import StateBlock from '@/components/StateBlock.vue'
import { appConfirm } from '@/components/AppModal.vue'
const userStore = useUserStore()
const canWrite = computed(() => hasPermission(userStore.user?.role_value, PERM.ATTACHMENT_WRITE))
const SCENE_OPTIONS = [
{ value: 'avatar', label: '头像' },
{ value: 'cover', label: '服务封面' },
{ value: 'attachment', label: '通用附件' },
]
const SCENE_LABELS = Object.fromEntries(SCENE_OPTIONS.map((s) => [s.value, s.label]))
// ===== 列表查询态 =====
const loading = ref(false)
const loadError = ref('')
const list = ref([])
const total = ref(0)
const query = reactive({ page: 1, pageSize: 20, keyword: '', scene: undefined })
const columns = [
{ title: '预览', key: 'preview', width: 88 },
{ title: '文件名', key: 'file_name' },
{ title: '场景', key: 'scene', width: 100 },
{ title: '大小', key: 'size', width: 90, align: 'right' },
{ title: '上传者', key: 'uploader', width: 110 },
{ title: '上传日期', dataIndex: 'created_at', key: 'created_at', width: 170 },
{ title: '操作', key: 'action', width: 170, align: 'right' },
]
function buildParams() {
return {
page: query.page,
pageSize: query.pageSize,
keyword: query.keyword || undefined,
scene: query.scene || undefined,
}
}
async function load() {
loading.value = true
loadError.value = ''
try {
const res = await attachmentListApi(buildParams())
if (res) {
list.value = res.list || []
total.value = res.total || 0
}
} catch {
loadError.value = '加载失败,请稍后重试'
} finally {
loading.value = false
}
}
onMounted(load)
function applyFilters() {
query.page = 1
load()
}
function onTableChange(pag) {
query.page = pag.current
query.pageSize = pag.pageSize
load()
}
function sizeText(bytes) {
if (!bytes) return '—'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
}
function uploaderText(record) {
const who = record.uploader_type === 'admin' ? '管理员' : '用户'
return `${who}#${record.uploader_id || '-'}`
}
async function copyLink(record) {
try {
await navigator.clipboard.writeText(record.url)
message.success('链接已复制')
} catch {
message.warning('复制失败,请手动复制:' + record.url)
}
}
function confirmDelete(record) {
appConfirm({
title: `确认删除「${record.file_name}」?`,
content: record.is_referenced
? '该文件仍被头像 / 封面等引用:本次仅移除登记记录,文件本体保留。'
: '文件不再被任何内容引用,登记记录与文件本体将一并删除。',
okType: 'danger',
okText: '删除',
async onOk() {
const res = await attachmentDeleteApi({ id: record.id })
if (res && !res.removed_file) message.info('已移除记录;文件仍被引用,本体保留')
else message.success('已删除')
load()
},
})
}
// ===== 通用附件上传 =====
const uploading = ref(false)
async function customUploadAttachment({ file, onSuccess, onError }) {
uploading.value = true
try {
const res = await adminUploadApi(file, 'attachment')
if (res) message.success('上传成功')
onSuccess?.(res)
applyFilters()
} catch (e) {
onError?.(e)
} finally {
uploading.value = false
}
}
function beforeAttachmentUpload(file) {
if (file.size > 20 * 1024 * 1024) {
message.warning('附件不能超过 20MB')
return false
}
return true
}
</script>
<template>
<PageShell title="附件管理" desc="所有上传文件统一登记:查看、复制链接、删除(被引用的文件仅移除记录),并记录上传日期与上传者">
<template #actions>
<a-input-search
v-model:value="query.keyword" placeholder="搜索文件名" allow-clear
style="width: 220px" @search="applyFilters"
/>
<a-select v-model:value="query.scene" placeholder="全部场景" allow-clear style="width: 140px" @change="applyFilters">
<a-select-option v-for="s in SCENE_OPTIONS" :key="s.value" :value="s.value">{{ s.label }}</a-select-option>
</a-select>
<a-upload
v-if="canWrite" accept=".jpg,.jpeg,.png,.webp,.gif,.pdf,.zip,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md"
:show-upload-list="false" :custom-request="customUploadAttachment" :before-upload="beforeAttachmentUpload"
>
<a-button type="primary" :loading="uploading">上传附件</a-button>
</a-upload>
</template>
<GlassCard>
<StateBlock :loading="loading" :error="loadError" :empty="!list.length" empty-text="还没有上传记录" @retry="load">
<a-table
:columns="columns" :data-source="list" row-key="id"
:pagination="{ current: query.page, pageSize: query.pageSize, total, showSizeChanger: false, showTotal: (t) => `共 ${t} 个文件` }"
@change="onTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'preview'">
<a-image
v-if="record.is_image" :src="record.url" alt="预览"
:width="56" :height="56" :preview="true"
style="border-radius: var(--radius-sm); object-fit: cover"
/>
<div v-else class="file-chip" :title="record.url">
<FileText :size="18" />
</div>
</template>
<template v-else-if="column.key === 'file_name'">
<div class="file-cell">
<div class="file-cell__name" :title="record.file_name">{{ record.file_name }}</div>
<div class="file-cell__meta">{{ record.ext }} · {{ record.md5?.slice(0, 8) }}</div>
</div>
</template>
<template v-else-if="column.key === 'scene'">
<a-tag :color="record.scene === 'avatar' ? 'blue' : record.scene === 'cover' ? 'orange' : 'default'">
{{ SCENE_LABELS[record.scene] || record.scene }}
</a-tag>
</template>
<template v-else-if="column.key === 'size'">
{{ sizeText(record.size_bytes) }}
</template>
<template v-else-if="column.key === 'uploader'">
{{ uploaderText(record) }}
</template>
<template v-else-if="column.key === 'action'">
<a-space :size="0">
<a-button type="link" size="small" @click="copyLink(record)">复制链接</a-button>
<a-button type="link" size="small">
<a :href="record.url" target="_blank" rel="noopener" class="open-link">打开</a>
</a-button>
<a-button v-if="canWrite" type="link" size="small" danger @click="confirmDelete(record)">删除</a-button>
</a-space>
</template>
</template>
</a-table>
</StateBlock>
</GlassCard>
</PageShell>
</template>
<style scoped>
.file-chip {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-1);
background: var(--bg-surface-2);
color: var(--text-3);
}
.file-cell__name {
color: var(--text-1);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 320px;
}
.file-cell__meta {
margin-top: 2px;
color: var(--text-3);
font-size: var(--font-xs);
}
.open-link {
color: inherit;
text-decoration: none;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,231 @@
<script setup>
/**
* 报价申请管理(迭代-2026-09-30)。
*
* 用户在小程序端提交开发需求(可关联作品)→ 本页查看与处理:
* 填报价金额 + 报价说明 + 状态(已报价 / 已成交 / 已关闭)→ 保存后向用户推站内信。
*/
import { computed, onMounted, reactive, ref } from 'vue'
import { message } from 'ant-design-vue'
import { useUserStore } from '@/stores/user'
import { hasPermission, PERM } from '@/utils/permissions'
import { yuanFromCents } from '@/api/request/software'
import { serviceQuoteHandleApi, serviceQuoteListApi } from '@/api/request/service'
import PageShell from '@/components/PageShell.vue'
import GlassCard from '@/components/GlassCard.vue'
import StateBlock from '@/components/StateBlock.vue'
import AppModal from '@/components/AppModal.vue'
const userStore = useUserStore()
const canWrite = computed(() => hasPermission(userStore.user?.role_value, PERM.GOODS_WRITE))
const STATUS_OPTIONS = [
{ value: 0, label: '待处理', color: 'warning' },
{ value: 1, label: '已报价', color: 'processing' },
{ value: 2, label: '已成交', color: 'success' },
{ value: 3, label: '已关闭', color: 'default' },
]
const columns = [
{ title: '用户 / 联系方式', key: 'user', width: 170 },
{ title: '关联作品', key: 'work', width: 150 },
{ title: '需求描述', key: 'requirement' },
{ title: '预算', dataIndex: 'budget', key: 'budget', width: 110 },
{ title: '状态', key: 'status', width: 100 },
{ title: '报价 / 说明', key: 'quote', width: 220 },
{ title: '提交时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
{ title: '操作', key: 'action', width: 100, align: 'right' },
]
function statusMeta(status) {
return STATUS_OPTIONS.find((s) => s.value === status) || { label: '未知', color: 'default' }
}
const loading = ref(false)
const loadError = ref('')
const list = ref([])
const total = ref(0)
const query = reactive({ page: 1, pageSize: 20, status: undefined })
async function load() {
loading.value = true
loadError.value = ''
try {
const res = await serviceQuoteListApi({
page: query.page,
pageSize: query.pageSize,
status: query.status ?? undefined,
})
if (res) {
list.value = res.list || []
total.value = res.total || 0
}
} catch {
loadError.value = '加载失败,请稍后重试'
} finally {
loading.value = false
}
}
onMounted(load)
function onTableChange(pag) {
query.page = pag.current
query.pageSize = pag.pageSize
load()
}
function priceText(record) {
if (!record.quote_cents) return '—'
return `¥${yuanFromCents(record.quote_cents)}`
}
// ===== 处理弹窗 =====
const modalOpen = ref(false)
const saving = ref(false)
const form = reactive({ id: 0, status: 1, quoteYuan: null, quoteNote: '' })
function openHandle(record) {
form.id = record.id
form.status = record.status === 0 ? 1 : record.status
form.quoteYuan = record.quote_cents ? Number((record.quote_cents / 100).toFixed(2)) : null
form.quoteNote = record.quote_note || ''
modalOpen.value = true
}
async function save() {
saving.value = true
try {
await serviceQuoteHandleApi({
id: form.id,
status: form.status,
quoteCents: form.quoteYuan != null ? Math.round(form.quoteYuan * 100) : 0,
quoteNote: form.quoteNote?.trim() || '',
})
message.success('已保存,站内信已通知用户')
modalOpen.value = false
load()
} catch {
/* 拦截器已提示 */
} finally {
saving.value = false
}
}
</script>
<template>
<PageShell title="报价申请" desc="用户提交的开发需求与报价申请:填写报价与说明后选择状态保存,用户会在小程序站内信收到处理结果">
<template #actions>
<a-select v-model:value="query.status" placeholder="全部状态" allow-clear style="width: 140px" @change="() => { query.page = 1; load() }">
<a-select-option v-for="s in STATUS_OPTIONS" :key="s.value" :value="s.value">{{ s.label }}</a-select-option>
</a-select>
<a-button @click="load">刷新</a-button>
</template>
<GlassCard>
<StateBlock :loading="loading" :error="loadError" :empty="!list.length" empty-text="暂无报价申请" @retry="load">
<a-table
:columns="columns" :data-source="list" row-key="id"
:pagination="{ current: query.page, pageSize: query.pageSize, total, showSizeChanger: false, showTotal: (t) => `共 ${t} 条` }"
@change="onTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'user'">
<div class="user-cell">
<div class="user-cell__name">{{ record.user_nickname || `用户 ${record.user_id}` }}</div>
<div class="user-cell__contact">{{ record.contact || '—' }}</div>
</div>
</template>
<template v-else-if="column.key === 'work'">
<span>{{ record.work_title || '通用申请' }}</span>
</template>
<template v-else-if="column.key === 'requirement'">
<a-tooltip :title="record.requirement" placement="topLeft">
<div class="req-cell">{{ record.requirement }}</div>
</a-tooltip>
</template>
<template v-else-if="column.key === 'budget'">
<span>{{ record.budget || '—' }}</span>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="statusMeta(record.status).color">{{ record.status_text || statusMeta(record.status).label }}</a-tag>
</template>
<template v-else-if="column.key === 'quote'">
<div class="quote-cell">
<span class="quote-cell__price">{{ priceText(record) }}</span>
<div v-if="record.quote_note" class="quote-cell__note">{{ record.quote_note }}</div>
</div>
</template>
<template v-else-if="column.key === 'created_at'">
<span>{{ record.created_at }}</span>
</template>
<template v-else-if="column.key === 'action'">
<a-button v-if="canWrite" type="link" size="small" @click="openHandle(record)">
{{ record.status === 0 ? '处理' : '再次处理' }}
</a-button>
</template>
</template>
</a-table>
</StateBlock>
</GlassCard>
<AppModal
v-model:open="modalOpen" title="处理报价申请" :width="640"
:confirm-loading="saving" ok-text="保存并通知用户" :mask-closable="false" @ok="save"
>
<a-form layout="vertical">
<a-form-item label="报价金额(元,留空 = 面议)">
<a-input-number v-model:value="form.quoteYuan" :min="0" :precision="2" style="width: 100%" placeholder="如 8000" />
</a-form-item>
<a-form-item label="报价说明(用户可见)">
<a-textarea
v-model:value="form.quoteNote" :maxlength="500" show-count :rows="3"
placeholder="如:含小程序端 + 管理后台,交付源码,周期约 4 周"
/>
</a-form-item>
<a-form-item label="状态">
<a-radio-group v-model:value="form.status">
<a-radio v-for="s in STATUS_OPTIONS.filter((x) => x.value !== 0)" :key="s.value" :value="s.value">
{{ s.label }}
</a-radio>
</a-radio-group>
</a-form-item>
<a-alert
message="保存后立即向该用户推送站内信(未读徽标 + 消息中心可见)"
type="info" show-icon style="margin-top: 8px"
/>
</a-form>
</AppModal>
</PageShell>
</template>
<style scoped>
.user-cell__name {
color: var(--text-1);
font-weight: 600;
}
.user-cell__contact {
margin-top: 2px;
color: var(--text-3);
font-size: var(--font-xs);
}
.req-cell {
max-width: 360px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-1);
}
.quote-cell__price {
color: var(--color-primary);
font-weight: 600;
}
.quote-cell__note {
margin-top: 2px;
color: var(--text-3);
font-size: var(--font-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 210px;
}
</style>

View File

@@ -0,0 +1,485 @@
<script setup>
/**
* 服务介绍页编辑(迭代-2026-09-30)。
*
* 小程序端「软件开发服务」介绍页由若干**模块**拼装而成(预设排版布局):
* hero 头图横幅 / rich 富文本 / image_text 图文 / image_list 图片列表 /
* developer 开发者简介 / works 作品模块入口 / goods 服务清单入口。
*
* 本页按 sort 列出全部模块:可新增 / 编辑 / 删除 / 停用 / 调排序,保存后 C 端即时生效。
* 图片只存相对路径(admin/upload 返回的 path),响应 *_url 为后端拼好的绝对地址。
*/
import { computed, onMounted, reactive, ref } from 'vue'
import { message } from 'ant-design-vue'
import { MdEditor } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
import { useAppStore } from '@/stores/app'
import { useUserStore } from '@/stores/user'
import { hasPermission, PERM } from '@/utils/permissions'
import {
SERVICE_BLOCK_KINDS, serviceBlockDeleteApi, serviceBlockKindLabel,
serviceBlockListApi, serviceBlockSaveApi,
} from '@/api/request/service'
import { adminUploadApi } from '@/api/request/attachment'
import PageShell from '@/components/PageShell.vue'
import GlassCard from '@/components/GlassCard.vue'
import StateBlock from '@/components/StateBlock.vue'
import AppModal, { appConfirm } from '@/components/AppModal.vue'
const appStore = useAppStore()
const userStore = useUserStore()
const canWrite = computed(() => hasPermission(userStore.user?.role_value, PERM.GOODS_WRITE))
const loading = ref(false)
const loadError = ref('')
const list = ref([])
async function load() {
loading.value = true
loadError.value = ''
try {
const res = await serviceBlockListApi()
list.value = res?.list || []
} catch {
loadError.value = '加载失败,请稍后重试'
} finally {
loading.value = false
}
}
onMounted(load)
function kindMeta(kind) {
return SERVICE_BLOCK_KINDS.find((k) => k.value === kind) || {}
}
/** 模块类型 → 编辑弹窗里展示哪些字段(预设布局的字段差异收敛在这一处) */
function fieldsOf(kind) {
return {
title: kind !== 'image_list',
subtitle: ['hero', 'works', 'goods', 'developer'].includes(kind),
image: ['image_text', 'developer'].includes(kind),
images: kind === 'image_list',
content: ['rich', 'image_text', 'flow', 'checklist', 'price', 'developer'].includes(kind),
contentLabel: {
rich: '正文(Markdown,小程序端按主题渲染)',
image_text: '说明文字(支持 Markdown)',
flow: '流程步骤(每行「标题|描述」,如 需求沟通|澄清目标与边界)',
checklist: '清单项(每行一项,如 需求文档)',
price: '价格卡(每行「名称|描述|价格」,如 官网展示型|官网搭建|3万起)',
developer: '开发者介绍(支持 Markdown)',
}[kind] || '正文',
imageLabel: kind === 'developer' ? '开发者头像(jpg / png / webp,≤5MB)' : '配图(jpg / png / webp,≤5MB)',
}
}
// ===== 编辑表单 =====
const modalOpen = ref(false)
const saving = ref(false)
const form = reactive({
id: 0, kind: 'rich', title: '', subtitle: '', imagePath: '', imageUrl: '',
images: [], content: '', sort: 0, isEnabled: 1,
})
function toForm(record) {
const f = fieldsOf(record.kind)
form.id = record.id || 0
form.kind = record.kind || 'rich'
form.title = record.title || ''
form.subtitle = record.subtitle || ''
form.imagePath = record.image_path || ''
form.imageUrl = record.image_url || ''
// images 只有启用该字段的类型才回填,避免切类型时把残留图存回去
form.images = f.images ? [...(record.images || [])] : []
form.content = record.content || ''
form.sort = record.sort ?? 0
form.isEnabled = record.isEnabled ?? 1
}
function openCreate() {
toForm({ kind: 'rich', sort: (list.value.length + 1) * 10 })
modalOpen.value = true
}
function openEdit(record) {
toForm(record)
modalOpen.value = true
}
function switchKind(kind) {
form.kind = kind
// 类型切换时清空不属于新布局的字段,避免存入脏数据
const f = fieldsOf(kind)
if (!f.image) { form.imagePath = ''; form.imageUrl = '' }
if (!f.images) form.images = []
if (!f.content) form.content = ''
}
async function save() {
saving.value = true
try {
await serviceBlockSaveApi({
id: form.id,
kind: form.kind,
title: form.title?.trim() || '',
subtitle: form.subtitle?.trim() || '',
imagePath: form.imagePath || '',
images: form.images || [],
content: form.content || '',
sort: form.sort ?? 0,
isEnabled: form.isEnabled,
})
message.success('已保存')
modalOpen.value = false
load()
} catch {
/* 拦截器已提示 */
} finally {
saving.value = false
}
}
function confirmDelete(record) {
appConfirm({
title: `确认删除「${serviceBlockKindLabel(record.kind)}」模块?`,
content: `「${record.title || '未命名'}」删除后小程序端介绍页立即少一个模块。`,
okType: 'danger',
okText: '删除',
async onOk() {
await serviceBlockDeleteApi({ id: record.id })
message.success('已删除')
load()
},
})
}
async function toggleEnabled(record) {
try {
await serviceBlockSaveApi({
id: record.id,
kind: record.kind,
title: record.title,
subtitle: record.subtitle,
imagePath: record.image_path,
images: record.images || [],
content: record.content || '',
sort: record.sort,
isEnabled: record.is_enabled === 1 ? 0 : 1,
})
message.success(record.is_enabled === 1 ? '已停用' : '已启用')
load()
} catch {
/* 拦截器已提示 */
}
}
// ===== 图片上传 =====
const uploading = ref(false)
const multiUploading = ref(false)
async function customUploadImage({ file, onSuccess, onError }) {
uploading.value = true
try {
const res = await adminUploadApi(file, 'cover')
if (res) {
form.imagePath = res.path
form.imageUrl = res.url
message.success('上传成功')
}
onSuccess?.(res)
} catch (e) {
onError?.(e)
} finally {
uploading.value = false
}
}
function beforeImageUpload(file) {
const ok = ['image/jpeg', 'image/png', 'image/webp'].includes(file.type)
if (!ok) message.warning('仅支持 jpg / png / webp 格式')
else if (file.size > 5 * 1024 * 1024) message.warning('图片不能超过 5MB')
return ok && file.size <= 5 * 1024 * 1024
}
/** 图集相对路径 → 预览地址(上传接口响应里带 url,缓存匹配;找不到就直接展示 path) */
const multiPreviews = computed(() => form.images.map((p) => {
const hit = (previewCache.value || {})[p]
return { path: p, url: hit || p }
}))
const previewCache = ref({})
async function onMultiUpload({ file, onSuccess, onError }) {
try {
const res = await adminUploadApi(file, 'cover')
if (res) {
form.images.push(res.path)
previewCache.value[res.path] = res.url
message.success('已添加一张图片')
}
onSuccess?.(res)
} catch (e) {
onError?.(e)
}
}
function removeMultiImage(index) {
form.images.splice(index, 1)
}
</script>
<template>
<PageShell title="介绍页编辑" desc="小程序「软件开发服务」介绍页的模块化排版:新增 / 编辑 / 排序 / 停用模块,保存后小程序端即时生效">
<template #actions>
<a-button v-if="canWrite" type="primary" @click="openCreate">新增模块</a-button>
</template>
<GlassCard>
<StateBlock :loading="loading" :error="loadError" :empty="!list.length" empty-text="还没有模块,点右上角「新增模块」搭建介绍页" @retry="load">
<div class="block-list">
<div v-for="b in list" :key="b.id" class="block-row" :class="{ 'block-row--off': b.is_enabled !== 1 }">
<div class="block-row__badge">
{{ serviceBlockKindLabel(b.kind) }}
</div>
<img v-if="b.image_url" :src="b.image_url" class="block-row__img" alt="模块图">
<div v-else-if="b.image_urls?.length" class="block-row__imgs">
<img v-for="(u, i) in b.image_urls.slice(0, 4)" :key="i" :src="u" alt="模块图">
<span v-if="b.image_urls.length > 4" class="block-row__more">+{{ b.image_urls.length - 4 }}</span>
</div>
<div class="block-row__body">
<div class="block-row__title">
{{ b.title || '未命名' }}
</div>
<div class="block-row__sub">
{{ b.subtitle || b.content?.replace(/[#*`\n-]/g, ' ').slice(0, 80) || kindMeta(b.kind).hint || '—' }}
</div>
</div>
<div class="block-row__meta">
<span class="block-row__sort">排序 {{ b.sort }}</span>
<a-tag :color="b.is_enabled === 1 ? 'success' : 'default'">
{{ b.is_enabled === 1 ? '启用' : '停用' }}
</a-tag>
</div>
<a-space :size="0" class="block-row__actions">
<a-button v-if="canWrite" type="link" size="small" @click="openEdit(b)">编辑</a-button>
<a-button v-if="canWrite" type="link" size="small" @click="toggleEnabled(b)">
{{ b.is_enabled === 1 ? '停用' : '启用' }}
</a-button>
<a-button v-if="canWrite" type="link" size="small" danger @click="confirmDelete(b)">删除</a-button>
</a-space>
</div>
</div>
</StateBlock>
</GlassCard>
<AppModal
v-model:open="modalOpen" :title="(form.id ? '编辑模块:' : '新增模块:') + serviceBlockKindLabel(form.kind)" :width="1040"
:confirm-loading="saving" ok-text="保存" :mask-closable="false" @ok="save"
>
<a-form layout="vertical" class="block-form">
<a-row :gutter="16">
<a-col :span="form.id ? 8 : 16">
<a-form-item label="模块类型" required>
<a-select v-model:value="form.kind" :disabled="!!form.id" @change="switchKind">
<a-select-option v-for="k in SERVICE_BLOCK_KINDS" :key="k.value" :value="k.value">
{{ k.label }}({{ k.hint }})
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="排序值(越小越靠前)">
<a-input-number v-model:value="form.sort" :min="0" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="启用">
<a-switch :checked="form.isEnabled === 1" @change="(v) => (form.isEnabled = v ? 1 : 0)" />
</a-form-item>
</a-col>
</a-row>
<a-row v-if="fieldsOf(form.kind).title || fieldsOf(form.kind).subtitle" :gutter="16">
<a-col v-if="fieldsOf(form.kind).title" :span="12">
<a-form-item :label="form.kind === 'developer' ? '开发者名称' : '标题'">
<a-input v-model:value="form.title" :maxlength="128" placeholder="模块标题" />
</a-form-item>
</a-col>
<a-col v-if="fieldsOf(form.kind).subtitle" :span="12">
<a-form-item :label="form.kind === 'developer' ? '身份 / 角色一句话' : '副标题'">
<a-input v-model:value="form.subtitle" :maxlength="255" placeholder="展示在标题下方的一行说明" />
</a-form-item>
</a-col>
</a-row>
<a-form-item v-if="fieldsOf(form.kind).image" :label="fieldsOf(form.kind).imageLabel">
<div class="cover-row">
<div v-if="form.imageUrl" class="cover-preview">
<img :src="form.imageUrl" alt="图片预览">
<a-button type="link" danger size="small" @click="form.imagePath = ''; form.imageUrl = ''">移除</a-button>
</div>
<a-upload
v-else accept=".jpg,.jpeg,.png,.webp" :show-upload-list="false"
:custom-request="customUploadImage" :before-upload="beforeImageUpload"
>
<a-button :loading="uploading">选择图片上传</a-button>
</a-upload>
</div>
</a-form-item>
<a-form-item v-if="fieldsOf(form.kind).images" label="图片列表(多图网格展示,点击预览;最多 9 张)">
<div class="multi-row">
<div v-for="(img, i) in multiPreviews" :key="img.path + i" class="multi-row__item">
<img :src="img.url" alt="图片">
<a-button type="text" danger size="small" class="multi-row__del" @click="removeMultiImage(i)">移除</a-button>
</div>
<a-upload
v-if="form.images.length < 9" accept=".jpg,.jpeg,.png,.webp" :show-upload-list="false"
:custom-request="onMultiUpload" :before-upload="beforeImageUpload"
>
<a-button :loading="multiUploading">+ 添加图片</a-button>
</a-upload>
</div>
</a-form-item>
<a-form-item v-if="fieldsOf(form.kind).content" :label="fieldsOf(form.kind).contentLabel">
<MdEditor
v-model="form.content" class="md-editor"
:theme="appStore.isDark ? 'dark' : 'light'"
:style="{ background: 'var(--bg-surface)' }"
placeholder="支持标题 / 列表 / 图片 / 表格 / 代码块等 Markdown 语法"
:footers="[]" no-upload-img
/>
</a-form-item>
</a-form>
</AppModal>
</PageShell>
</template>
<style scoped>
.block-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.block-row {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 16px;
border: 1px solid var(--border-1);
border-radius: var(--radius-md);
background: var(--bg-surface);
}
.block-row--off {
opacity: 0.55;
}
.block-row__badge {
flex-shrink: 0;
padding: 4px 10px;
border-radius: var(--radius-sm);
background: var(--color-primary-bg, #eef4ff);
color: var(--color-primary);
font-size: var(--font-xs);
font-weight: 600;
white-space: nowrap;
}
.block-row__img {
flex-shrink: 0;
width: 72px;
height: 46px;
object-fit: cover;
border-radius: var(--radius-sm);
border: 1px solid var(--border-1);
}
.block-row__imgs {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 4px;
}
.block-row__imgs img {
width: 44px;
height: 32px;
object-fit: cover;
border-radius: 4px;
border: 1px solid var(--border-1);
}
.block-row__more {
color: var(--text-3);
font-size: var(--font-xs);
}
.block-row__body {
flex: 1;
min-width: 0;
}
.block-row__title {
color: var(--text-1);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.block-row__sub {
margin-top: 2px;
color: var(--text-3);
font-size: var(--font-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.block-row__meta {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 10px;
}
.block-row__sort {
color: var(--text-3);
font-size: var(--font-xs);
white-space: nowrap;
}
.block-row__actions {
flex-shrink: 0;
}
.cover-row {
display: flex;
align-items: center;
gap: var(--space-3);
}
.cover-preview {
display: flex;
align-items: center;
gap: var(--space-2);
}
.cover-preview img {
width: 120px;
height: 72px;
object-fit: cover;
border-radius: var(--radius-sm);
border: 1px solid var(--border-1);
}
.multi-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
align-items: flex-start;
}
.multi-row__item {
display: flex;
flex-direction: column;
gap: 2px;
align-items: center;
}
.multi-row__item img {
width: 96px;
height: 64px;
object-fit: cover;
border-radius: var(--radius-sm);
border: 1px solid var(--border-1);
}
.md-editor {
--md-bk-color: var(--bg-surface) !important;
border-radius: var(--radius-md);
overflow: hidden;
min-height: 260px;
}
</style>

View File

@@ -0,0 +1,496 @@
<script setup>
/**
* 作品案例管理(迭代-2026-09-30)。
*
* 小程序「软件开发服务」介绍页的作品模块数据源:四系列分类、封面 + 详情图集、
* 参考报价(分存储、元编辑)、排序、上下架、Markdown 详情。
* C 端作品列表 / 详情页仅展示已上架(is_enabled=1)的作品。
*/
import { computed, onMounted, reactive, ref } from 'vue'
import { message } from 'ant-design-vue'
import { MdEditor } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
import { useAppStore } from '@/stores/app'
import { useUserStore } from '@/stores/user'
import { hasPermission, PERM } from '@/utils/permissions'
import {
GOODS_CATEGORY_OPTIONS, yuanFromCents,
} from '@/api/request/software'
import {
serviceWorkDeleteApi, serviceWorkDetailApi, serviceWorkListApi, serviceWorkSaveApi,
} from '@/api/request/service'
import { adminUploadApi } from '@/api/request/attachment'
import PageShell from '@/components/PageShell.vue'
import GlassCard from '@/components/GlassCard.vue'
import StateBlock from '@/components/StateBlock.vue'
import AppModal, { appConfirm } from '@/components/AppModal.vue'
const appStore = useAppStore()
const userStore = useUserStore()
const canWrite = computed(() => hasPermission(userStore.user?.role_value, PERM.GOODS_WRITE))
// ===== 列表查询态 =====
const loading = ref(false)
const loadError = ref('')
const list = ref([])
const total = ref(0)
const query = reactive({
page: 1, pageSize: 20, keyword: '', category: undefined, isEnabled: undefined,
})
const columns = [
{ title: '封面', key: 'cover', width: 96 },
{ title: '名称', key: 'title' },
{ title: '分类', dataIndex: 'category_name', key: 'category', width: 130 },
{ title: '参考报价', key: 'price', width: 120 },
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 80 },
{ title: '状态', key: 'status', width: 90 },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
{ title: '操作', key: 'action', width: 130, align: 'right' },
]
async function load() {
loading.value = true
loadError.value = ''
try {
const res = await serviceWorkListApi({
page: query.page,
pageSize: query.pageSize,
keyword: query.keyword || undefined,
category: query.category || undefined,
isEnabled: query.isEnabled ?? undefined,
})
if (res) {
list.value = res.list || []
total.value = res.total || 0
}
} catch {
loadError.value = '加载失败,请稍后重试'
} finally {
loading.value = false
}
}
onMounted(load)
function applyFilters() {
query.page = 1
load()
}
function resetFilters() {
query.keyword = ''
query.category = undefined
query.isEnabled = undefined
applyFilters()
}
function onTableChange(pag) {
query.page = pag.current
query.pageSize = pag.pageSize
load()
}
function priceText(record) {
if (!record.price_cents) return '面议'
return `¥${yuanFromCents(record.price_cents)}${record.price_note || ''}`
}
// ===== 编辑表单 =====
const modalOpen = ref(false)
const saving = ref(false)
const detailLoading = ref(false)
const form = reactive({
id: 0, category: 1, title: '', summary: '', contentMd: '',
coverPath: '', coverUrl: '', images: [], priceYuan: null, priceNote: '', sort: 0, isEnabled: 1,
})
function toForm(detail) {
form.id = detail.id || 0
form.category = detail.category || 1
form.title = detail.title || ''
form.summary = detail.summary || ''
form.contentMd = detail.content_md || ''
form.coverPath = detail.cover_path || ''
form.coverUrl = detail.cover_url || ''
form.images = [...(detail.images || [])]
form.priceYuan = detail.price_cents ? Number((detail.price_cents / 100).toFixed(2)) : null
form.priceNote = detail.price_note || ''
form.sort = detail.sort ?? 0
form.isEnabled = detail.is_enabled ?? 1
}
function openCreate() {
toForm({})
modalOpen.value = true
}
async function openEdit(record) {
modalOpen.value = true
detailLoading.value = true
try {
const detail = await serviceWorkDetailApi(record.id)
if (detail) toForm(detail)
} catch {
/* 拦截器已提示 */
} finally {
detailLoading.value = false
}
}
async function save() {
if (!form.title?.trim()) {
message.warning('请填写作品名称')
return
}
saving.value = true
try {
await serviceWorkSaveApi({
id: form.id,
category: form.category,
title: form.title.trim(),
summary: form.summary?.trim() || '',
contentMd: form.contentMd || '',
coverPath: form.coverPath || '',
images: form.images || [],
priceCents: form.priceYuan != null ? Math.round(form.priceYuan * 100) : 0,
priceNote: form.priceNote?.trim() || '',
sort: form.sort ?? 0,
isEnabled: form.isEnabled,
})
message.success('已保存')
modalOpen.value = false
load()
} catch {
/* 拦截器已提示 */
} finally {
saving.value = false
}
}
function confirmDelete(record) {
appConfirm({
title: `确认删除作品「${record.title}」?`,
content: '删除后小程序端立即不可见(已有报价申请不受影响,其存的是标题快照)。',
okType: 'danger',
okText: '删除',
async onOk() {
await serviceWorkDeleteApi({ id: record.id })
message.success('已删除')
load()
},
})
}
async function toggleEnabled(record) {
const next = record.is_enabled === 1 ? 0 : 1
try {
// 快捷上下架必须带全量字段保存(save 是整行覆盖),先取详情,避免清空正文/图集
const detail = await serviceWorkDetailApi(record.id)
if (!detail) return
await serviceWorkSaveApi({
id: detail.id,
category: detail.category,
title: detail.title,
summary: detail.summary,
contentMd: detail.content_md || '',
coverPath: detail.cover_path,
images: detail.images || [],
priceCents: detail.price_cents,
priceNote: detail.price_note,
sort: detail.sort,
isEnabled: next,
})
message.success(next === 1 ? '已上架' : '已下架')
load()
} catch {
/* 拦截器已提示 */
}
}
// ===== 图片上传 =====
const uploading = ref(false)
const multiUploading = ref(false)
const previewCache = ref({})
async function customUploadCover({ file, onSuccess, onError }) {
uploading.value = true
try {
const res = await adminUploadApi(file, 'cover')
if (res) {
form.coverPath = res.path
form.coverUrl = res.url
message.success('封面上传成功')
}
onSuccess?.(res)
} catch (e) {
onError?.(e)
} finally {
uploading.value = false
}
}
async function onMultiUpload({ file, onSuccess, onError }) {
multiUploading.value = true
try {
const res = await adminUploadApi(file, 'cover')
if (res) {
form.images.push(res.path)
previewCache.value[res.path] = res.url
message.success('已添加一张图片')
}
onSuccess?.(res)
} catch (e) {
onError?.(e)
} finally {
multiUploading.value = false
}
}
function beforeImageUpload(file) {
const ok = ['image/jpeg', 'image/png', 'image/webp'].includes(file.type)
if (!ok) message.warning('仅支持 jpg / png / webp 格式')
else if (file.size > 5 * 1024 * 1024) message.warning('图片不能超过 5MB')
return ok && file.size <= 5 * 1024 * 1024
}
function removeCover() {
form.coverPath = ''
form.coverUrl = ''
}
function removeMultiImage(index) {
form.images.splice(index, 1)
}
const multiPreviews = computed(() => form.images.map((p) => ({
path: p,
url: previewCache.value[p] || p,
})))
</script>
<template>
<PageShell title="作品案例" desc="软件开发作品案例:封面、图集、参考报价与 Markdown 详情,上架后出现在小程序介绍页的作品模块">
<template #actions>
<a-input-search
v-model:value="query.keyword" placeholder="搜索作品名称" allow-clear
style="width: 220px" @search="applyFilters"
/>
<a-select v-model:value="query.category" placeholder="全部分类" allow-clear style="width: 150px" @change="applyFilters">
<a-select-option v-for="c in GOODS_CATEGORY_OPTIONS" :key="c.value" :value="c.value">{{ c.label }}</a-select-option>
</a-select>
<a-select v-model:value="query.isEnabled" placeholder="全部状态" allow-clear style="width: 120px" @change="applyFilters">
<a-select-option :value="1">已上架</a-select-option>
<a-select-option :value="0">已下架</a-select-option>
</a-select>
<a-button @click="resetFilters">重置</a-button>
<a-button v-if="canWrite" type="primary" @click="openCreate">新增作品</a-button>
</template>
<GlassCard>
<StateBlock :loading="loading" :error="loadError" :empty="!list.length" empty-text="还没有作品案例,点右上角「新增作品」创建" @retry="load">
<a-table
:columns="columns" :data-source="list" row-key="id"
:pagination="{ current: query.page, pageSize: query.pageSize, total, showSizeChanger: false, showTotal: (t) => `共 ${t} 个作品` }"
@change="onTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'cover'">
<a-image
v-if="record.cover_url" :src="record.cover_url" alt="封面"
:width="64" :height="40" :preview="true"
style="border-radius: var(--radius-sm); object-fit: cover"
/>
<span v-else class="cover-empty">无封面</span>
</template>
<template v-else-if="column.key === 'title'">
<div class="title-cell">
<div class="title-cell__name">{{ record.title }}</div>
<div class="title-cell__summary">{{ record.summary || '—' }}</div>
</div>
</template>
<template v-else-if="column.key === 'price'">
<span :class="record.price_cents ? 'price' : 'price price--free'">{{ priceText(record) }}</span>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.is_enabled === 1 ? 'success' : 'default'">
{{ record.is_enabled === 1 ? '已上架' : '已下架' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space :size="0">
<a-button v-if="canWrite" type="link" size="small" @click="openEdit(record)">编辑</a-button>
<a-button v-if="canWrite" type="link" size="small" @click="toggleEnabled(record)">
{{ record.is_enabled === 1 ? '下架' : '上架' }}
</a-button>
<a-button v-if="canWrite" type="link" size="small" danger @click="confirmDelete(record)">删除</a-button>
</a-space>
</template>
</template>
</a-table>
</StateBlock>
</GlassCard>
<AppModal
v-model:open="modalOpen" :title="form.id ? '编辑作品' : '新增作品'" :width="1040"
:confirm-loading="saving" ok-text="保存" :mask-closable="false" @ok="save"
>
<a-spin :spinning="detailLoading">
<a-form layout="vertical" class="work-form">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="所属系列" required>
<a-select v-model:value="form.category">
<a-select-option v-for="c in GOODS_CATEGORY_OPTIONS" :key="c.value" :value="c.value">{{ c.label }}</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="作品名称" required>
<a-input v-model:value="form.title" :maxlength="128" placeholder="如:连锁餐饮点单小程序" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="简介(列表卡片展示)">
<a-textarea v-model:value="form.summary" :maxlength="500" show-count :rows="2" placeholder="一句话介绍这个作品" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="参考报价(元,0 或留空 = 面议)">
<a-input-number v-model:value="form.priceYuan" :min="0" :precision="2" style="width: 100%" placeholder="如 8000" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="报价备注">
<a-input v-model:value="form.priceNote" :maxlength="64" placeholder="如:起 / 视需求而定" />
</a-form-item>
</a-col>
<a-col :span="4">
<a-form-item label="排序值">
<a-input-number v-model:value="form.sort" :min="0" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="4">
<a-form-item label="上架">
<a-switch :checked="form.isEnabled === 1" @change="(v) => (form.isEnabled = v ? 1 : 0)" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="封面图(jpg / png / webp,≤5MB)">
<div class="cover-row">
<div v-if="form.coverUrl" class="cover-preview">
<img :src="form.coverUrl" alt="封面预览">
<a-button type="link" danger size="small" @click="removeCover">移除</a-button>
</div>
<a-upload
v-else accept=".jpg,.jpeg,.png,.webp" :show-upload-list="false"
:custom-request="customUploadCover" :before-upload="beforeImageUpload"
>
<a-button :loading="uploading">选择图片上传</a-button>
</a-upload>
</div>
</a-form-item>
<a-form-item label="详情图集(作品详情页网格展示,最多 9 张)">
<div class="multi-row">
<div v-for="(img, i) in multiPreviews" :key="img.path + i" class="multi-row__item">
<img :src="img.url" alt="图集">
<a-button type="link" danger size="small" @click="removeMultiImage(i)">移除</a-button>
</div>
<a-upload
v-if="form.images.length < 9" accept=".jpg,.jpeg,.png,.webp" :show-upload-list="false"
:custom-request="onMultiUpload" :before-upload="beforeImageUpload"
>
<a-button :loading="multiUploading">+ 添加图片</a-button>
</a-upload>
</div>
</a-form-item>
<a-form-item label="详情说明(Markdown,小程序端按主题样式解析渲染)">
<MdEditor
v-model="form.contentMd" class="md-editor"
:theme="appStore.isDark ? 'dark' : 'light'"
:style="{ background: 'var(--bg-surface)' }"
placeholder="作品亮点 / 技术栈 / 功能清单等"
:footers="[]" no-upload-img
/>
</a-form-item>
</a-form>
</a-spin>
</AppModal>
</PageShell>
</template>
<style scoped>
.cover-empty {
color: var(--text-3);
font-size: var(--font-xs);
}
.title-cell {
min-width: 0;
}
.title-cell__name {
color: var(--text-1);
font-weight: 600;
}
.title-cell__summary {
margin-top: 2px;
color: var(--text-3);
font-size: var(--font-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 280px;
}
.price {
color: var(--color-primary);
font-weight: 600;
}
.price--free {
color: var(--text-3);
font-weight: 400;
}
.cover-row {
display: flex;
align-items: center;
gap: var(--space-3);
}
.cover-preview {
display: flex;
align-items: center;
gap: var(--space-2);
}
.cover-preview img {
width: 96px;
height: 60px;
object-fit: cover;
border-radius: var(--radius-sm);
border: 1px solid var(--border-1);
}
.multi-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
align-items: flex-start;
}
.multi-row__item {
display: flex;
flex-direction: column;
gap: 2px;
align-items: center;
}
.multi-row__item img {
width: 96px;
height: 64px;
object-fit: cover;
border-radius: var(--radius-sm);
border: 1px solid var(--border-1);
}
.md-editor {
--md-bk-color: var(--bg-surface) !important;
border-radius: var(--radius-md);
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,443 @@
<script setup>
/**
* 软件服务管理(迭代-2026-09-29)。
*
* 软件开发服务商品 CRUD:四系列(网站/小程序/App/桌面),支持定价(分存储、元编辑)、
* 排序、上下架、封面上传(admin/upload scene=cover,库内只存相对路径)、
* Markdown 详细说明(md-editor-v3 编辑 + 实时预览,主题跟随暗色三态)。
*/
import { computed, onMounted, reactive, ref } from 'vue'
import { message } from 'ant-design-vue'
import { MdEditor } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
import { useAppStore } from '@/stores/app'
import { useUserStore } from '@/stores/user'
import { hasPermission, PERM } from '@/utils/permissions'
import {
GOODS_CATEGORY_OPTIONS, goodsCategoryLabel, goodsDeleteApi, goodsDetailApi,
goodsListApi, goodsSaveApi, yuanFromCents,
} from '@/api/request/software'
import { adminUploadApi } from '@/api/request/attachment'
import PageShell from '@/components/PageShell.vue'
import GlassCard from '@/components/GlassCard.vue'
import StateBlock from '@/components/StateBlock.vue'
import AppModal, { appConfirm } from '@/components/AppModal.vue'
const appStore = useAppStore()
const userStore = useUserStore()
const canWrite = computed(() => hasPermission(userStore.user?.role_value, PERM.GOODS_WRITE))
// ===== 列表查询态 =====
const loading = ref(false)
const loadError = ref('')
const list = ref([])
const total = ref(0)
const query = reactive({
page: 1, pageSize: 20, keyword: '', category: undefined, isEnabled: undefined,
sortBy: undefined, sortOrder: undefined,
})
const columns = [
{ title: '封面', key: 'cover', width: 96 },
{ title: '名称', key: 'title', sorter: true, sorterKey: 'title' },
{ title: '分类', dataIndex: 'category_name', key: 'category', width: 130 },
{ title: '价格', key: 'price', sorter: true, sorterKey: 'price', width: 120 },
{ title: '排序', dataIndex: 'sort', key: 'sort', sorter: true, sorterKey: 'sort', width: 80 },
{ title: '状态', key: 'status', width: 90 },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', sorter: true, sorterKey: 'created_at', width: 170 },
{ title: '操作', key: 'action', width: 130, align: 'right' },
]
function buildParams() {
return {
page: query.page,
pageSize: query.pageSize,
keyword: query.keyword || undefined,
category: query.category || undefined,
isEnabled: query.isEnabled ?? undefined,
sortBy: query.sortBy,
sortOrder: query.sortOrder,
}
}
async function load() {
loading.value = true
loadError.value = ''
try {
const res = await goodsListApi(buildParams())
if (res) {
list.value = res.list || []
total.value = res.total || 0
}
} catch {
loadError.value = '加载失败,请稍后重试'
} finally {
loading.value = false
}
}
onMounted(load)
function applyFilters() {
query.page = 1
load()
}
function resetFilters() {
query.keyword = ''
query.category = undefined
query.isEnabled = undefined
applyFilters()
}
function onTableChange(pag, _filters, sorter) {
query.page = pag.current
query.pageSize = pag.pageSize
if (sorter?.order && sorter.column?.sorterKey) {
query.sortBy = sorter.column.sorterKey
query.sortOrder = sorter.order === 'ascend' ? 'asc' : 'desc'
} else {
query.sortBy = undefined
query.sortOrder = undefined
}
load()
}
function priceText(record) {
if (!record.price_cents) return '面议'
return `¥${yuanFromCents(record.price_cents)}${record.price_note || ''}`
}
// ===== 编辑表单态(id=0 新增)=====
const modalOpen = ref(false)
const saving = ref(false)
const detailLoading = ref(false)
const form = reactive({
id: 0, category: 1, title: '', summary: '', contentMd: '',
priceYuan: null, priceNote: '', coverPath: '', coverUrl: '', sort: 0, isEnabled: 1,
})
function toForm(detail) {
form.id = detail.id || 0
form.category = detail.category || 1
form.title = detail.title || ''
form.summary = detail.summary || ''
form.contentMd = detail.content_md || ''
form.priceYuan = detail.price_cents ? Number((detail.price_cents / 100).toFixed(2)) : null
form.priceNote = detail.price_note || ''
form.coverPath = detail.cover_path || ''
form.coverUrl = detail.cover_url || ''
form.sort = detail.sort ?? 0
form.isEnabled = detail.is_enabled ?? 1
}
function openCreate() {
toForm({})
modalOpen.value = true
}
async function openEdit(record) {
modalOpen.value = true
detailLoading.value = true
try {
const detail = await goodsDetailApi(record.id)
if (detail) toForm(detail)
} catch {
/* 拦截器已提示 */
} finally {
detailLoading.value = false
}
}
async function save() {
if (!form.title?.trim()) {
message.warning('请填写服务名称')
return
}
saving.value = true
try {
await goodsSaveApi({
id: form.id,
category: form.category,
title: form.title.trim(),
summary: form.summary?.trim() || '',
contentMd: form.contentMd || '',
priceCents: form.priceYuan != null ? Math.round(form.priceYuan * 100) : 0,
priceNote: form.priceNote?.trim() || '',
coverPath: form.coverPath || '',
sort: form.sort ?? 0,
isEnabled: form.isEnabled,
})
message.success('已保存')
modalOpen.value = false
load()
} catch {
/* 拦截器已提示 */
} finally {
saving.value = false
}
}
function confirmDelete(record) {
appConfirm({
title: `确认删除服务「${record.title}」?`,
content: '删除后小程序端立即不可见(数据保留,可恢复联系管理员)。',
okType: 'danger',
okText: '删除',
async onOk() {
await goodsDeleteApi({ id: record.id })
message.success('已删除')
load()
},
})
}
async function toggleEnabled(record) {
const next = record.is_enabled === 1 ? 0 : 1
try {
// 快捷上下架必须带全量字段保存(save 是整行覆盖),先取详情拿 content_md,避免清空正文
const detail = await goodsDetailApi(record.id)
if (!detail) return
await goodsSaveApi({
id: detail.id,
category: detail.category,
title: detail.title,
summary: detail.summary,
contentMd: detail.content_md || '',
priceCents: detail.price_cents,
priceNote: detail.price_note,
coverPath: detail.cover_path,
sort: detail.sort,
isEnabled: next,
})
message.success(next === 1 ? '已上架' : '已下架')
load()
} catch {
/* 拦截器已提示 */
}
}
// ===== 封面上传 =====
const uploading = ref(false)
async function customUploadCover({ file, onSuccess, onError }) {
uploading.value = true
try {
const res = await adminUploadApi(file, 'cover')
if (res) {
form.coverPath = res.path
form.coverUrl = res.url
message.success('封面上传成功')
}
onSuccess?.(res)
} catch (e) {
onError?.(e)
} finally {
uploading.value = false
}
}
function beforeCoverUpload(file) {
const ok = ['image/jpeg', 'image/png', 'image/webp'].includes(file.type)
if (!ok) message.warning('仅支持 jpg / png / webp 格式')
else if (file.size > 5 * 1024 * 1024) message.warning('封面不能超过 5MB')
return ok && file.size <= 5 * 1024 * 1024
}
function removeCover() {
form.coverPath = ''
form.coverUrl = ''
}
</script>
<template>
<PageShell title="软件服务" desc="软件开发服务商品:网站 / 小程序 / App / 桌面程序四系列,支持定价、排序、封面与 Markdown 详细说明">
<template #actions>
<a-input-search
v-model:value="query.keyword" placeholder="搜索服务名称" allow-clear
style="width: 220px" @search="applyFilters"
/>
<a-select v-model:value="query.category" placeholder="全部分类" allow-clear style="width: 150px" @change="applyFilters">
<a-select-option v-for="c in GOODS_CATEGORY_OPTIONS" :key="c.value" :value="c.value">{{ c.label }}</a-select-option>
</a-select>
<a-select v-model:value="query.isEnabled" placeholder="全部状态" allow-clear style="width: 120px" @change="applyFilters">
<a-select-option :value="1">已上架</a-select-option>
<a-select-option :value="0">已下架</a-select-option>
</a-select>
<a-button @click="resetFilters">重置</a-button>
<a-button v-if="canWrite" type="primary" @click="openCreate">新增服务</a-button>
</template>
<GlassCard>
<StateBlock :loading="loading" :error="loadError" :empty="!list.length" empty-text="还没有服务商品,点右上角「新增服务」创建" @retry="load">
<a-table
:columns="columns" :data-source="list" row-key="id"
:pagination="{ current: query.page, pageSize: query.pageSize, total, showSizeChanger: false, showTotal: (t) => `共 ${t} 个服务` }"
@change="onTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'cover'">
<a-image
v-if="record.cover_url" :src="record.cover_url" alt="封面"
:width="64" :height="40" :preview="true"
style="border-radius: var(--radius-sm); object-fit: cover"
/>
<span v-else class="cover-empty">无封面</span>
</template>
<template v-else-if="column.key === 'title'">
<div class="title-cell">
<div class="title-cell__name">{{ record.title }}</div>
<div class="title-cell__summary">{{ record.summary || '—' }}</div>
</div>
</template>
<template v-else-if="column.key === 'price'">
<span :class="record.price_cents ? 'price' : 'price price--free'">{{ priceText(record) }}</span>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.is_enabled === 1 ? 'success' : 'default'">
{{ record.is_enabled === 1 ? '已上架' : '已下架' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space :size="0">
<a-button v-if="canWrite" type="link" size="small" @click="openEdit(record)">编辑</a-button>
<a-button v-if="canWrite" type="link" size="small" @click="toggleEnabled(record)">
{{ record.is_enabled === 1 ? '下架' : '上架' }}
</a-button>
<a-button v-if="canWrite" type="link" size="small" danger @click="confirmDelete(record)">删除</a-button>
</a-space>
</template>
</template>
</a-table>
</StateBlock>
</GlassCard>
<AppModal
v-model:open="modalOpen" :title="form.id ? '编辑服务' : '新增服务'" :width="1040"
:confirm-loading="saving" ok-text="保存" :mask-closable="false" @ok="save"
>
<a-spin :spinning="detailLoading">
<a-form layout="vertical" class="goods-form">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="所属系列" required>
<a-select v-model:value="form.category">
<a-select-option v-for="c in GOODS_CATEGORY_OPTIONS" :key="c.value" :value="c.value">{{ c.label }}</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="服务名称" required>
<a-input v-model:value="form.title" :maxlength="128" placeholder="如:企业展示型小程序" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="简介(列表卡片展示)">
<a-textarea v-model:value="form.summary" :maxlength="500" show-count :rows="2" placeholder="一句话卖点,小程序端列表与卡片展示" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="定价(元,0 或留空 = 面议)">
<a-input-number v-model:value="form.priceYuan" :min="0" :precision="2" style="width: 100%" placeholder="如 2999" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="价格备注">
<a-input v-model:value="form.priceNote" :maxlength="64" placeholder="如:起 / 项目" />
</a-form-item>
</a-col>
<a-col :span="4">
<a-form-item label="排序值">
<a-input-number v-model:value="form.sort" :min="0" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="4">
<a-form-item label="上架">
<a-switch :checked="form.isEnabled === 1" @change="(v) => (form.isEnabled = v ? 1 : 0)" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="封面图(jpg / png / webp,≤5MB)">
<div class="cover-row">
<div v-if="form.coverUrl" class="cover-preview">
<img :src="form.coverUrl" alt="封面预览">
<a-button type="link" danger size="small" @click="removeCover">移除</a-button>
</div>
<a-upload
v-else accept=".jpg,.jpeg,.png,.webp" :show-upload-list="false"
:custom-request="customUploadCover" :before-upload="beforeCoverUpload"
>
<a-button :loading="uploading">选择图片上传</a-button>
</a-upload>
</div>
</a-form-item>
<a-form-item label="详细说明(Markdown,小程序端按主题样式解析渲染)">
<MdEditor
v-model="form.contentMd" class="md-editor"
:theme="appStore.isDark ? 'dark' : 'light'"
:style="{ background: 'var(--bg-surface)' }"
placeholder="支持标题 / 列表 / 图片 / 表格 / 代码块等 Markdown 语法"
:footers="[]" no-upload-img
/>
</a-form-item>
</a-form>
</a-spin>
</AppModal>
</PageShell>
</template>
<style scoped>
.cover-empty {
color: var(--text-3);
font-size: var(--font-xs);
}
.title-cell {
min-width: 0;
}
.title-cell__name {
color: var(--text-1);
font-weight: 600;
}
.title-cell__summary {
margin-top: 2px;
color: var(--text-3);
font-size: var(--font-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 280px;
}
.price {
color: var(--color-primary);
font-weight: 600;
}
.price--free {
color: var(--text-3);
font-weight: 400;
}
.cover-row {
display: flex;
align-items: center;
gap: var(--space-3);
}
.cover-preview {
display: flex;
align-items: center;
gap: var(--space-2);
}
.cover-preview img {
width: 96px;
height: 60px;
object-fit: cover;
border-radius: var(--radius-sm);
border: 1px solid var(--border-1);
}
.md-editor {
--md-bk-color: var(--bg-surface) !important;
border-radius: var(--radius-md);
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,261 @@
<script setup>
/**
* 系统配置(迭代-2026-09-30 重构)。
*
* 左侧 tab 分区(选中 tab 持久化到 localStorage `tb:syscfg:tab`,下次进入自动恢复):
* - 基础配置:site.base_url(图片等静态资源的对外拼接前缀);
* - 首页功能卡:便捷功能卡背景图,key 对齐小程序 `features.ts` 的 FEATURE_ENTRIES
* (note 我的笔记 / event 年会查座 / service 软件开发),存 settings 的
* `home.feature.covers`(JSON:key → 相对路径),小程序端经 /app/config 拉取。
*
* 配置一律走 settings 表(约定 S9);保存立即生效(后端 30s 缓存自动失效)。
*/
import { onMounted, reactive, ref } from 'vue'
import { message } from 'ant-design-vue'
import { adminUploadApi } from '@/api/request/attachment'
import { settingGetApi, settingSaveApi } from '@/api/request/setting'
import PageShell from '@/components/PageShell.vue'
import GlassCard from '@/components/GlassCard.vue'
import StateBlock from '@/components/StateBlock.vue'
const TAB_KEY = 'tb:syscfg:tab'
/** 首页功能卡清单:key 必须与小程序 src/config/features.ts 的 FEATURE_ENTRIES 逐字对齐 */
const FEATURE_TABS = [
{ key: 'note', label: '我的笔记', hint: '建议横图 750×480 以上,深色背景配白字更清晰' },
{ key: 'event', label: '年会查座', hint: '建议横图 750×480 以上' },
{ key: 'service', label: '软件开发', hint: '建议竖向大卡图 600×960 以上(三卡时为左侧大图卡)' },
]
const loading = ref(false)
const loadError = ref('')
// 左侧 tab 本地记忆:进页面恢复上次所在分区
const activeTab = ref(localStorage.getItem(TAB_KEY) || 'base')
// ===== 基础配置 =====
const form = reactive({ baseUrl: '' })
const savingBase = ref(false)
// ===== 首页功能卡背景图 =====
const covers = reactive({ data: {} }) // { note: '/static/...', ... }(相对路径,同源 /static 代理可直接预览)
const coversDirty = ref(false)
const uploadingKey = ref('')
function onTabChange(key) {
activeTab.value = key
// 本地记忆:下次进入自动恢复上次所在分区
localStorage.setItem(TAB_KEY, key)
}
async function load() {
loading.value = true
loadError.value = ''
try {
const res = await settingGetApi()
for (const item of res?.items || []) {
if (item.key === 'site.base_url') form.baseUrl = item.value || ''
if (item.key === 'home.feature.covers') {
let parsed = {}
try {
parsed = JSON.parse(item.value || '{}') || {}
} catch {
parsed = {}
}
covers.data = { ...parsed }
}
}
coversDirty.value = false
} catch {
loadError.value = '加载失败,请稍后重试'
} finally {
loading.value = false
}
}
onMounted(load)
async function customUploadCover(key, { file, onSuccess, onError }) {
uploadingKey.value = key
try {
const res = await adminUploadApi(file, 'cover')
if (res) {
covers.data[key] = res.path
coversDirty.value = true
message.success('已上传,记得点「保存配置」')
}
onSuccess?.(res)
} catch (e) {
onError?.(e)
} finally {
uploadingKey.value = ''
}
}
function beforeCoverUpload(file) {
const ok = ['image/jpeg', 'image/png', 'image/webp'].includes(file.type)
if (!ok) message.warning('仅支持 jpg / png / webp 格式')
else if (file.size > 5 * 1024 * 1024) message.warning('图片不能超过 5MB')
return ok && file.size <= 5 * 1024 * 1024
}
function removeCover(key) {
delete covers.data[key]
coversDirty.value = true
}
async function saveCovers() {
savingCovers.value = true
try {
await settingSaveApi({ key: 'home.feature.covers', value: JSON.stringify(covers.data) })
message.success('已保存,小程序端下次拉取即生效')
coversDirty.value = false
} catch {
/* 拦截器已提示 */
} finally {
savingCovers.value = false
}
}
async function saveBase() {
const v = (form.baseUrl || '').trim()
if (v && !/^https?:\/\//.test(v)) {
message.warning('请填写以 http(s):// 开头的完整地址,或留空')
return
}
savingBase.value = true
try {
await settingSaveApi({ key: 'site.base_url', value: v })
message.success('已保存,立即生效')
load()
} catch {
/* 拦截器已提示 */
} finally {
savingBase.value = false
}
}
</script>
<template>
<PageShell title="系统配置" desc="站点级参数。配置存 settings 表,保存后立即生效(约 30 秒内全网生效)">
<GlassCard>
<StateBlock :loading="loading" :error="loadError" :empty="false" @retry="load">
<a-tabs tab-position="left" v-model:activeKey="activeTab" class="cfg-tabs" @tab-click="onTabChange">
<!-- 基础配置 -->
<a-tab-pane key="base" tab="基础配置">
<a-form layout="vertical" style="max-width: 560px">
<a-form-item label="图片域名(site.base_url)">
<a-input
v-model:value="form.baseUrl" allow-clear
placeholder="如 https://api.example.com(留空 = 按请求域名动态拼接)"
/>
<div class="field-tip">
图片等静态资源的对外拼接前缀。数据库只存相对路径(/static/...),输出时用它拼成完整地址;
留空则按当前请求的域名动态拼接(本地联调推荐留空)。末尾的 / 会自动去掉。
</div>
</a-form-item>
<a-button type="primary" :loading="savingBase" @click="saveBase">保存</a-button>
</a-form>
</a-tab-pane>
<!-- 首页功能卡背景图 -->
<a-tab-pane key="featureCovers" tab="首页功能卡">
<a-alert
type="info" show-icon class="covers-tip"
message="小程序首页「便捷功能」卡的背景图。三卡时为「1 大 2 小」拼图布局,四卡及以上为横滑卡片;未配置的卡使用主题渐变色兜底。"
/>
<div class="cover-list">
<div v-for="f in FEATURE_TABS" :key="f.key" class="cover-item">
<div class="cover-item__preview">
<img v-if="covers.data[f.key]" :src="covers.data[f.key]" alt="背景图预览">
<div v-else class="cover-item__empty">未配置</div>
</div>
<div class="cover-item__meta">
<div class="cover-item__label">{{ f.label }}</div>
<div class="cover-item__hint">{{ f.hint }}</div>
<a-space class="cover-item__acts">
<a-upload
accept=".jpg,.jpeg,.png,.webp" :show-upload-list="false"
:custom-request="(o) => customUploadCover(f.key, o)"
:before-upload="beforeCoverUpload"
>
<a-button size="small" :loading="uploadingKey === f.key">
{{ covers.data[f.key] ? '更换图片' : '上传图片' }}
</a-button>
</a-upload>
<a-button v-if="covers.data[f.key]" size="small" danger @click="removeCover(f.key)">移除</a-button>
</a-space>
</div>
</div>
</div>
<a-button type="primary" :loading="savingCovers" :disabled="!coversDirty" @click="saveCovers">
保存配置
</a-button>
</a-tab-pane>
</a-tabs>
</StateBlock>
</GlassCard>
</PageShell>
</template>
<style scoped>
.cfg-tabs {
min-height: 380px;
}
.cfg-tabs :deep(.ant-tabs-tab) {
padding: 10px 18px;
}
.field-tip {
margin-top: var(--space-2);
color: var(--text-3);
font-size: var(--font-xs);
line-height: 1.6;
}
.covers-tip {
margin-bottom: var(--space-4);
}
.cover-list {
display: flex;
flex-direction: column;
gap: var(--space-4);
margin-bottom: var(--space-5);
}
.cover-item {
display: flex;
gap: var(--space-4);
align-items: center;
padding: var(--space-3);
border: 1px solid var(--border-1);
border-radius: var(--radius-md);
background: var(--bg-surface-2);
}
.cover-item__preview {
flex-shrink: 0;
width: 148px;
height: 84px;
border-radius: var(--radius-sm);
overflow: hidden;
border: 1px solid var(--border-1);
background: var(--bg-page);
}
.cover-item__preview img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.cover-item__empty {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-4);
font-size: var(--font-xs);
}
.cover-item__label {
color: var(--text-1);
font-weight: 600;
}
.cover-item__hint {
margin: 4px 0 10px;
color: var(--text-3);
font-size: var(--font-xs);
}
</style>

View File

@@ -30,6 +30,12 @@ export default defineConfig({
'/qrcode': {
target: 'http://127.0.0.1:8000',
changeOrigin: true
},
// 上传的静态资源(头像/封面/附件):库内只存相对路径(/static/...),
// 附件管理与系统配置页的预览按相对路径直出,需同源代理(不做 rewrite)
'/static': {
target: 'http://127.0.0.1:8000',
changeOrigin: true
}
}
}