mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-05 16:00:21 +08:00
merge upstream main
This commit is contained in:
6954
frontend/package-lock.json
generated
6954
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -19,9 +19,12 @@
|
||||
"@vueuse/core": "^10.7.0",
|
||||
"axios": "^1.6.2",
|
||||
"chart.js": "^4.4.1",
|
||||
"dompurify": "^3.3.1",
|
||||
"driver.js": "^1.4.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"marked": "^17.0.1",
|
||||
"pinia": "^2.1.7",
|
||||
"qrcode": "^1.5.4",
|
||||
"vue": "^3.4.0",
|
||||
"vue-chartjs": "^5.3.0",
|
||||
"vue-i18n": "^9.14.5",
|
||||
@@ -29,9 +32,11 @@
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
|
||||
192
frontend/pnpm-lock.yaml
generated
192
frontend/pnpm-lock.yaml
generated
@@ -20,15 +20,24 @@ importers:
|
||||
chart.js:
|
||||
specifier: ^4.4.1
|
||||
version: 4.5.1
|
||||
dompurify:
|
||||
specifier: ^3.3.1
|
||||
version: 3.3.1
|
||||
driver.js:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
file-saver:
|
||||
specifier: ^2.0.5
|
||||
version: 2.0.5
|
||||
marked:
|
||||
specifier: ^17.0.1
|
||||
version: 17.0.1
|
||||
pinia:
|
||||
specifier: ^2.1.7
|
||||
version: 2.3.1(typescript@5.6.3)(vue@3.5.26(typescript@5.6.3))
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
vue:
|
||||
specifier: ^3.4.0
|
||||
version: 3.5.26(typescript@5.6.3)
|
||||
@@ -45,6 +54,9 @@ importers:
|
||||
specifier: ^0.18.5
|
||||
version: 0.18.5
|
||||
devDependencies:
|
||||
'@types/dompurify':
|
||||
specifier: ^3.0.5
|
||||
version: 3.2.0
|
||||
'@types/file-saver':
|
||||
specifier: ^2.0.7
|
||||
version: 2.0.7
|
||||
@@ -54,6 +66,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^20.10.5
|
||||
version: 20.19.27
|
||||
'@types/qrcode':
|
||||
specifier: ^1.5.6
|
||||
version: 1.5.6
|
||||
'@typescript-eslint/eslint-plugin':
|
||||
specifier: ^7.18.0
|
||||
version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1)(typescript@5.6.3)
|
||||
@@ -1239,56 +1254,67 @@ packages:
|
||||
resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.54.0':
|
||||
resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.54.0':
|
||||
resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.54.0':
|
||||
resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.54.0':
|
||||
resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.54.0':
|
||||
resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==}
|
||||
@@ -1443,6 +1469,10 @@ packages:
|
||||
'@types/debug@4.1.12':
|
||||
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
|
||||
|
||||
'@types/dompurify@3.2.0':
|
||||
resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
|
||||
deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
|
||||
|
||||
'@types/estree-jsx@1.0.5':
|
||||
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
|
||||
|
||||
@@ -1479,6 +1509,9 @@ packages:
|
||||
'@types/parse-json@4.0.2':
|
||||
resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==}
|
||||
|
||||
'@types/qrcode@1.5.6':
|
||||
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
|
||||
|
||||
'@types/react@19.2.7':
|
||||
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
|
||||
|
||||
@@ -1832,6 +1865,10 @@ packages:
|
||||
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
camelcase@5.3.1:
|
||||
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
caniuse-lite@1.0.30001761:
|
||||
resolution: {integrity: sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==}
|
||||
|
||||
@@ -1895,6 +1932,9 @@ packages:
|
||||
classnames@2.5.1:
|
||||
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
|
||||
|
||||
cliui@6.0.0:
|
||||
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||
|
||||
clsx@1.2.1:
|
||||
resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -2164,6 +2204,10 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decamelize@1.2.0:
|
||||
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
decimal.js@10.6.0:
|
||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||
|
||||
@@ -2198,6 +2242,9 @@ packages:
|
||||
didyoumean@1.2.2:
|
||||
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
||||
|
||||
dijkstrajs@1.0.3:
|
||||
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||
|
||||
dir-glob@3.0.1:
|
||||
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2424,6 +2471,10 @@ packages:
|
||||
find-root@1.1.0:
|
||||
resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==}
|
||||
|
||||
find-up@4.1.0:
|
||||
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
find-up@5.0.0:
|
||||
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2488,6 +2539,10 @@ packages:
|
||||
function-bind@1.1.2:
|
||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||
|
||||
get-caller-file@2.0.5:
|
||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||
engines: {node: 6.* || 8.* || >= 10.*}
|
||||
|
||||
get-east-asian-width@1.4.0:
|
||||
resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2856,6 +2911,10 @@ packages:
|
||||
lit@3.3.2:
|
||||
resolution: {integrity: sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==}
|
||||
|
||||
locate-path@5.0.0:
|
||||
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
locate-path@6.0.0:
|
||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -3239,14 +3298,26 @@ packages:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
p-limit@2.3.0:
|
||||
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
p-limit@3.1.0:
|
||||
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
p-locate@4.1.0:
|
||||
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
p-locate@5.0.0:
|
||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
p-try@2.2.0:
|
||||
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
package-json-from-dist@1.0.1:
|
||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||
|
||||
@@ -3341,6 +3412,10 @@ packages:
|
||||
pkg-types@1.3.1:
|
||||
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
|
||||
|
||||
pngjs@5.0.0:
|
||||
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
points-on-curve@0.2.0:
|
||||
resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
|
||||
|
||||
@@ -3421,6 +3496,11 @@ packages:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
qrcode@1.5.4:
|
||||
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
hasBin: true
|
||||
|
||||
query-string@9.3.1:
|
||||
resolution: {integrity: sha512-5fBfMOcDi5SA9qj5jZhWAcTtDfKF5WFdd2uD9nVNlbxVv1baq65aALy6qofpNEGELHvisjjasxQp7BlM9gvMzw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -3664,6 +3744,13 @@ packages:
|
||||
remark-stringify@11.0.0:
|
||||
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
|
||||
|
||||
require-directory@2.1.1:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
require-main-filename@2.0.0:
|
||||
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||
|
||||
requires-port@1.0.0:
|
||||
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
|
||||
|
||||
@@ -3739,6 +3826,9 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
set-blocking@2.0.0:
|
||||
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
|
||||
|
||||
set-value@2.0.1:
|
||||
resolution: {integrity: sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -4263,6 +4353,9 @@ packages:
|
||||
resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
which-module@2.0.1:
|
||||
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||
|
||||
which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -4285,6 +4378,10 @@ packages:
|
||||
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -4324,10 +4421,21 @@ packages:
|
||||
xmlchars@2.2.0:
|
||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||
|
||||
y18n@4.0.3:
|
||||
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||
|
||||
yaml@1.10.2:
|
||||
resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
yargs@15.4.1:
|
||||
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
yocto-queue@0.1.0:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -5806,6 +5914,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
|
||||
'@types/dompurify@3.2.0':
|
||||
dependencies:
|
||||
dompurify: 3.3.1
|
||||
|
||||
'@types/estree-jsx@1.0.5':
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
@@ -5838,6 +5950,10 @@ snapshots:
|
||||
|
||||
'@types/parse-json@4.0.2': {}
|
||||
|
||||
'@types/qrcode@1.5.6':
|
||||
dependencies:
|
||||
'@types/node': 20.19.27
|
||||
|
||||
'@types/react@19.2.7':
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
@@ -6321,6 +6437,8 @@ snapshots:
|
||||
|
||||
camelcase-css@2.0.1: {}
|
||||
|
||||
camelcase@5.3.1: {}
|
||||
|
||||
caniuse-lite@1.0.30001761: {}
|
||||
|
||||
ccount@2.0.1: {}
|
||||
@@ -6395,6 +6513,12 @@ snapshots:
|
||||
|
||||
classnames@2.5.1: {}
|
||||
|
||||
cliui@6.0.0:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
wrap-ansi: 6.2.0
|
||||
|
||||
clsx@1.2.1: {}
|
||||
|
||||
clsx@2.1.1: {}
|
||||
@@ -6668,6 +6792,8 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decamelize@1.2.0: {}
|
||||
|
||||
decimal.js@10.6.0: {}
|
||||
|
||||
decode-named-character-reference@1.2.0:
|
||||
@@ -6694,6 +6820,8 @@ snapshots:
|
||||
|
||||
didyoumean@1.2.2: {}
|
||||
|
||||
dijkstrajs@1.0.3: {}
|
||||
|
||||
dir-glob@3.0.1:
|
||||
dependencies:
|
||||
path-type: 4.0.0
|
||||
@@ -6978,6 +7106,11 @@ snapshots:
|
||||
|
||||
find-root@1.1.0: {}
|
||||
|
||||
find-up@4.1.0:
|
||||
dependencies:
|
||||
locate-path: 5.0.0
|
||||
path-exists: 4.0.0
|
||||
|
||||
find-up@5.0.0:
|
||||
dependencies:
|
||||
locate-path: 6.0.0
|
||||
@@ -7029,6 +7162,8 @@ snapshots:
|
||||
|
||||
function-bind@1.1.2: {}
|
||||
|
||||
get-caller-file@2.0.5: {}
|
||||
|
||||
get-east-asian-width@1.4.0: {}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
@@ -7521,6 +7656,10 @@ snapshots:
|
||||
lit-element: 4.2.2
|
||||
lit-html: 3.3.2
|
||||
|
||||
locate-path@5.0.0:
|
||||
dependencies:
|
||||
p-locate: 4.1.0
|
||||
|
||||
locate-path@6.0.0:
|
||||
dependencies:
|
||||
p-locate: 5.0.0
|
||||
@@ -8194,14 +8333,24 @@ snapshots:
|
||||
type-check: 0.4.0
|
||||
word-wrap: 1.2.5
|
||||
|
||||
p-limit@2.3.0:
|
||||
dependencies:
|
||||
p-try: 2.2.0
|
||||
|
||||
p-limit@3.1.0:
|
||||
dependencies:
|
||||
yocto-queue: 0.1.0
|
||||
|
||||
p-locate@4.1.0:
|
||||
dependencies:
|
||||
p-limit: 2.3.0
|
||||
|
||||
p-locate@5.0.0:
|
||||
dependencies:
|
||||
p-limit: 3.1.0
|
||||
|
||||
p-try@2.2.0: {}
|
||||
|
||||
package-json-from-dist@1.0.1: {}
|
||||
|
||||
package-manager-detector@1.6.0: {}
|
||||
@@ -8284,6 +8433,8 @@ snapshots:
|
||||
mlly: 1.8.0
|
||||
pathe: 2.0.3
|
||||
|
||||
pngjs@5.0.0: {}
|
||||
|
||||
points-on-curve@0.2.0: {}
|
||||
|
||||
points-on-path@0.2.1:
|
||||
@@ -8352,6 +8503,12 @@ snapshots:
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
qrcode@1.5.4:
|
||||
dependencies:
|
||||
dijkstrajs: 1.0.3
|
||||
pngjs: 5.0.0
|
||||
yargs: 15.4.1
|
||||
|
||||
query-string@9.3.1:
|
||||
dependencies:
|
||||
decode-uri-component: 0.4.1
|
||||
@@ -8703,6 +8860,10 @@ snapshots:
|
||||
mdast-util-to-markdown: 2.1.2
|
||||
unified: 11.0.5
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
require-main-filename@2.0.0: {}
|
||||
|
||||
requires-port@1.0.0: {}
|
||||
|
||||
reselect@5.1.1: {}
|
||||
@@ -8788,6 +8949,8 @@ snapshots:
|
||||
|
||||
semver@7.7.3: {}
|
||||
|
||||
set-blocking@2.0.0: {}
|
||||
|
||||
set-value@2.0.1:
|
||||
dependencies:
|
||||
extend-shallow: 2.0.1
|
||||
@@ -9298,6 +9461,8 @@ snapshots:
|
||||
tr46: 5.1.1
|
||||
webidl-conversions: 7.0.0
|
||||
|
||||
which-module@2.0.1: {}
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
@@ -9313,6 +9478,12 @@ snapshots:
|
||||
|
||||
word@0.3.0: {}
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
@@ -9345,8 +9516,29 @@ snapshots:
|
||||
|
||||
xmlchars@2.2.0: {}
|
||||
|
||||
y18n@4.0.3: {}
|
||||
|
||||
yaml@1.10.2: {}
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
dependencies:
|
||||
camelcase: 5.3.1
|
||||
decamelize: 1.2.0
|
||||
|
||||
yargs@15.4.1:
|
||||
dependencies:
|
||||
cliui: 6.0.0
|
||||
decamelize: 1.2.0
|
||||
find-up: 4.1.0
|
||||
get-caller-file: 2.0.5
|
||||
require-directory: 2.1.1
|
||||
require-main-filename: 2.0.0
|
||||
set-blocking: 2.0.0
|
||||
string-width: 4.2.3
|
||||
which-module: 2.0.1
|
||||
y18n: 4.0.3
|
||||
yargs-parser: 18.1.3
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
zustand@3.7.2(react@19.2.3):
|
||||
|
||||
71
frontend/src/api/admin/announcements.ts
Normal file
71
frontend/src/api/admin/announcements.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Admin Announcements API endpoints
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type {
|
||||
Announcement,
|
||||
AnnouncementUserReadStatus,
|
||||
BasePaginationResponse,
|
||||
CreateAnnouncementRequest,
|
||||
UpdateAnnouncementRequest
|
||||
} from '@/types'
|
||||
|
||||
export async function list(
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
filters?: {
|
||||
status?: string
|
||||
search?: string
|
||||
}
|
||||
): Promise<BasePaginationResponse<Announcement>> {
|
||||
const { data } = await apiClient.get<BasePaginationResponse<Announcement>>('/admin/announcements', {
|
||||
params: { page, page_size: pageSize, ...filters }
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getById(id: number): Promise<Announcement> {
|
||||
const { data } = await apiClient.get<Announcement>(`/admin/announcements/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function create(request: CreateAnnouncementRequest): Promise<Announcement> {
|
||||
const { data } = await apiClient.post<Announcement>('/admin/announcements', request)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function update(id: number, request: UpdateAnnouncementRequest): Promise<Announcement> {
|
||||
const { data } = await apiClient.put<Announcement>(`/admin/announcements/${id}`, request)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteAnnouncement(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/announcements/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getReadStatus(
|
||||
id: number,
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
search: string = ''
|
||||
): Promise<BasePaginationResponse<AnnouncementUserReadStatus>> {
|
||||
const { data } = await apiClient.get<BasePaginationResponse<AnnouncementUserReadStatus>>(
|
||||
`/admin/announcements/${id}/read-status`,
|
||||
{ params: { page, page_size: pageSize, search } }
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
const announcementsAPI = {
|
||||
list,
|
||||
getById,
|
||||
create,
|
||||
update,
|
||||
delete: deleteAnnouncement,
|
||||
getReadStatus
|
||||
}
|
||||
|
||||
export default announcementsAPI
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface TrendParams {
|
||||
account_id?: number
|
||||
group_id?: number
|
||||
stream?: boolean
|
||||
billing_type?: number | null
|
||||
}
|
||||
|
||||
export interface TrendResponse {
|
||||
@@ -78,6 +79,7 @@ export interface ModelStatsParams {
|
||||
account_id?: number
|
||||
group_id?: number
|
||||
stream?: boolean
|
||||
billing_type?: number | null
|
||||
}
|
||||
|
||||
export interface ModelStatsResponse {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type {
|
||||
Group,
|
||||
AdminGroup,
|
||||
GroupPlatform,
|
||||
CreateGroupRequest,
|
||||
UpdateGroupRequest,
|
||||
@@ -31,8 +31,8 @@ export async function list(
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<PaginatedResponse<Group>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<Group>>('/admin/groups', {
|
||||
): Promise<PaginatedResponse<AdminGroup>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<AdminGroup>>('/admin/groups', {
|
||||
params: {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
@@ -48,8 +48,8 @@ export async function list(
|
||||
* @param platform - Optional platform filter
|
||||
* @returns List of all active groups
|
||||
*/
|
||||
export async function getAll(platform?: GroupPlatform): Promise<Group[]> {
|
||||
const { data } = await apiClient.get<Group[]>('/admin/groups/all', {
|
||||
export async function getAll(platform?: GroupPlatform): Promise<AdminGroup[]> {
|
||||
const { data } = await apiClient.get<AdminGroup[]>('/admin/groups/all', {
|
||||
params: platform ? { platform } : undefined
|
||||
})
|
||||
return data
|
||||
@@ -60,7 +60,7 @@ export async function getAll(platform?: GroupPlatform): Promise<Group[]> {
|
||||
* @param platform - Platform to filter by
|
||||
* @returns List of groups for the specified platform
|
||||
*/
|
||||
export async function getByPlatform(platform: GroupPlatform): Promise<Group[]> {
|
||||
export async function getByPlatform(platform: GroupPlatform): Promise<AdminGroup[]> {
|
||||
return getAll(platform)
|
||||
}
|
||||
|
||||
@@ -69,8 +69,8 @@ export async function getByPlatform(platform: GroupPlatform): Promise<Group[]> {
|
||||
* @param id - Group ID
|
||||
* @returns Group details
|
||||
*/
|
||||
export async function getById(id: number): Promise<Group> {
|
||||
const { data } = await apiClient.get<Group>(`/admin/groups/${id}`)
|
||||
export async function getById(id: number): Promise<AdminGroup> {
|
||||
const { data } = await apiClient.get<AdminGroup>(`/admin/groups/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -79,8 +79,8 @@ export async function getById(id: number): Promise<Group> {
|
||||
* @param groupData - Group data
|
||||
* @returns Created group
|
||||
*/
|
||||
export async function create(groupData: CreateGroupRequest): Promise<Group> {
|
||||
const { data } = await apiClient.post<Group>('/admin/groups', groupData)
|
||||
export async function create(groupData: CreateGroupRequest): Promise<AdminGroup> {
|
||||
const { data } = await apiClient.post<AdminGroup>('/admin/groups', groupData)
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -90,8 +90,8 @@ export async function create(groupData: CreateGroupRequest): Promise<Group> {
|
||||
* @param updates - Fields to update
|
||||
* @returns Updated group
|
||||
*/
|
||||
export async function update(id: number, updates: UpdateGroupRequest): Promise<Group> {
|
||||
const { data } = await apiClient.put<Group>(`/admin/groups/${id}`, updates)
|
||||
export async function update(id: number, updates: UpdateGroupRequest): Promise<AdminGroup> {
|
||||
const { data } = await apiClient.put<AdminGroup>(`/admin/groups/${id}`, updates)
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ export async function deleteGroup(id: number): Promise<{ message: string }> {
|
||||
* @param status - New status
|
||||
* @returns Updated group
|
||||
*/
|
||||
export async function toggleStatus(id: number, status: 'active' | 'inactive'): Promise<Group> {
|
||||
export async function toggleStatus(id: number, status: 'active' | 'inactive'): Promise<AdminGroup> {
|
||||
return update(id, { status })
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import accountsAPI from './accounts'
|
||||
import proxiesAPI from './proxies'
|
||||
import redeemAPI from './redeem'
|
||||
import promoAPI from './promo'
|
||||
import announcementsAPI from './announcements'
|
||||
import settingsAPI from './settings'
|
||||
import systemAPI from './system'
|
||||
import subscriptionsAPI from './subscriptions'
|
||||
@@ -30,6 +31,7 @@ export const adminAPI = {
|
||||
proxies: proxiesAPI,
|
||||
redeem: redeemAPI,
|
||||
promo: promoAPI,
|
||||
announcements: announcementsAPI,
|
||||
settings: settingsAPI,
|
||||
system: systemAPI,
|
||||
subscriptions: subscriptionsAPI,
|
||||
@@ -48,6 +50,7 @@ export {
|
||||
proxiesAPI,
|
||||
redeemAPI,
|
||||
promoAPI,
|
||||
announcementsAPI,
|
||||
settingsAPI,
|
||||
systemAPI,
|
||||
subscriptionsAPI,
|
||||
|
||||
@@ -781,6 +781,7 @@ export interface OpsAdvancedSettings {
|
||||
ignore_count_tokens_errors: boolean
|
||||
ignore_context_canceled: boolean
|
||||
ignore_no_available_accounts: boolean
|
||||
ignore_invalid_api_key_errors: boolean
|
||||
auto_refresh_enabled: boolean
|
||||
auto_refresh_interval_seconds: number
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ export interface SystemSettings {
|
||||
// Registration settings
|
||||
registration_enabled: boolean
|
||||
email_verify_enabled: boolean
|
||||
promo_code_enabled: boolean
|
||||
password_reset_enabled: boolean
|
||||
totp_enabled: boolean // TOTP 双因素认证
|
||||
totp_encryption_key_configured: boolean // TOTP 加密密钥是否已配置
|
||||
// Default settings
|
||||
default_balance: number
|
||||
default_concurrency: number
|
||||
@@ -23,6 +27,9 @@ export interface SystemSettings {
|
||||
contact_info: string
|
||||
doc_url: string
|
||||
home_content: string
|
||||
hide_ccs_import_button: boolean
|
||||
purchase_subscription_enabled: boolean
|
||||
purchase_subscription_url: string
|
||||
// SMTP settings
|
||||
smtp_host: string
|
||||
smtp_port: number
|
||||
@@ -63,6 +70,9 @@ export interface SystemSettings {
|
||||
export interface UpdateSettingsRequest {
|
||||
registration_enabled?: boolean
|
||||
email_verify_enabled?: boolean
|
||||
promo_code_enabled?: boolean
|
||||
password_reset_enabled?: boolean
|
||||
totp_enabled?: boolean // TOTP 双因素认证
|
||||
default_balance?: number
|
||||
default_concurrency?: number
|
||||
site_name?: string
|
||||
@@ -72,6 +82,9 @@ export interface UpdateSettingsRequest {
|
||||
contact_info?: string
|
||||
doc_url?: string
|
||||
home_content?: string
|
||||
hide_ccs_import_button?: boolean
|
||||
purchase_subscription_enabled?: boolean
|
||||
purchase_subscription_url?: string
|
||||
smtp_host?: string
|
||||
smtp_port?: number
|
||||
smtp_username?: string
|
||||
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
* List all subscriptions with pagination
|
||||
* @param page - Page number (default: 1)
|
||||
* @param pageSize - Items per page (default: 20)
|
||||
* @param filters - Optional filters (status, user_id, group_id)
|
||||
* @param filters - Optional filters (status, user_id, group_id, sort_by, sort_order)
|
||||
* @returns Paginated list of subscriptions
|
||||
*/
|
||||
export async function list(
|
||||
@@ -27,6 +27,8 @@ export async function list(
|
||||
status?: 'active' | 'expired' | 'revoked'
|
||||
user_id?: number
|
||||
group_id?: number
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
},
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type { UsageLog, UsageQueryParams, PaginatedResponse } from '@/types'
|
||||
import type { AdminUsageLog, UsageQueryParams, PaginatedResponse } from '@/types'
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
@@ -31,6 +31,46 @@ export interface SimpleApiKey {
|
||||
user_id: number
|
||||
}
|
||||
|
||||
export interface UsageCleanupFilters {
|
||||
start_time: string
|
||||
end_time: string
|
||||
user_id?: number
|
||||
api_key_id?: number
|
||||
account_id?: number
|
||||
group_id?: number
|
||||
model?: string | null
|
||||
stream?: boolean | null
|
||||
billing_type?: number | null
|
||||
}
|
||||
|
||||
export interface UsageCleanupTask {
|
||||
id: number
|
||||
status: string
|
||||
filters: UsageCleanupFilters
|
||||
created_by: number
|
||||
deleted_rows: number
|
||||
error_message?: string | null
|
||||
canceled_by?: number | null
|
||||
canceled_at?: string | null
|
||||
started_at?: string | null
|
||||
finished_at?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CreateUsageCleanupTaskRequest {
|
||||
start_date: string
|
||||
end_date: string
|
||||
user_id?: number
|
||||
api_key_id?: number
|
||||
account_id?: number
|
||||
group_id?: number
|
||||
model?: string | null
|
||||
stream?: boolean | null
|
||||
billing_type?: number | null
|
||||
timezone?: string
|
||||
}
|
||||
|
||||
export interface AdminUsageQueryParams extends UsageQueryParams {
|
||||
user_id?: number
|
||||
}
|
||||
@@ -45,8 +85,8 @@ export interface AdminUsageQueryParams extends UsageQueryParams {
|
||||
export async function list(
|
||||
params: AdminUsageQueryParams,
|
||||
options?: { signal?: AbortSignal }
|
||||
): Promise<PaginatedResponse<UsageLog>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/admin/usage', {
|
||||
): Promise<PaginatedResponse<AdminUsageLog>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<AdminUsageLog>>('/admin/usage', {
|
||||
params,
|
||||
signal: options?.signal
|
||||
})
|
||||
@@ -108,11 +148,51 @@ export async function searchApiKeys(userId?: number, keyword?: string): Promise<
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* List usage cleanup tasks (admin only)
|
||||
* @param params - Query parameters for pagination
|
||||
* @returns Paginated list of cleanup tasks
|
||||
*/
|
||||
export async function listCleanupTasks(
|
||||
params: { page?: number; page_size?: number },
|
||||
options?: { signal?: AbortSignal }
|
||||
): Promise<PaginatedResponse<UsageCleanupTask>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<UsageCleanupTask>>('/admin/usage/cleanup-tasks', {
|
||||
params,
|
||||
signal: options?.signal
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a usage cleanup task (admin only)
|
||||
* @param payload - Cleanup task parameters
|
||||
* @returns Created cleanup task
|
||||
*/
|
||||
export async function createCleanupTask(payload: CreateUsageCleanupTaskRequest): Promise<UsageCleanupTask> {
|
||||
const { data } = await apiClient.post<UsageCleanupTask>('/admin/usage/cleanup-tasks', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a usage cleanup task (admin only)
|
||||
* @param taskId - Task ID to cancel
|
||||
*/
|
||||
export async function cancelCleanupTask(taskId: number): Promise<{ id: number; status: string }> {
|
||||
const { data } = await apiClient.post<{ id: number; status: string }>(
|
||||
`/admin/usage/cleanup-tasks/${taskId}/cancel`
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export const adminUsageAPI = {
|
||||
list,
|
||||
getStats,
|
||||
searchUsers,
|
||||
searchApiKeys
|
||||
searchApiKeys,
|
||||
listCleanupTasks,
|
||||
createCleanupTask,
|
||||
cancelCleanupTask
|
||||
}
|
||||
|
||||
export default adminUsageAPI
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type { User, UpdateUserRequest, PaginatedResponse } from '@/types'
|
||||
import type { AdminUser, UpdateUserRequest, PaginatedResponse } from '@/types'
|
||||
|
||||
/**
|
||||
* List all users with pagination
|
||||
@@ -26,7 +26,7 @@ export async function list(
|
||||
options?: {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<PaginatedResponse<User>> {
|
||||
): Promise<PaginatedResponse<AdminUser>> {
|
||||
// Build params with attribute filters in attr[id]=value format
|
||||
const params: Record<string, any> = {
|
||||
page,
|
||||
@@ -44,8 +44,7 @@ export async function list(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { data } = await apiClient.get<PaginatedResponse<User>>('/admin/users', {
|
||||
const { data } = await apiClient.get<PaginatedResponse<AdminUser>>('/admin/users', {
|
||||
params,
|
||||
signal: options?.signal
|
||||
})
|
||||
@@ -57,8 +56,8 @@ export async function list(
|
||||
* @param id - User ID
|
||||
* @returns User details
|
||||
*/
|
||||
export async function getById(id: number): Promise<User> {
|
||||
const { data } = await apiClient.get<User>(`/admin/users/${id}`)
|
||||
export async function getById(id: number): Promise<AdminUser> {
|
||||
const { data } = await apiClient.get<AdminUser>(`/admin/users/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -73,8 +72,8 @@ export async function create(userData: {
|
||||
balance?: number
|
||||
concurrency?: number
|
||||
allowed_groups?: number[] | null
|
||||
}): Promise<User> {
|
||||
const { data } = await apiClient.post<User>('/admin/users', userData)
|
||||
}): Promise<AdminUser> {
|
||||
const { data } = await apiClient.post<AdminUser>('/admin/users', userData)
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -84,8 +83,8 @@ export async function create(userData: {
|
||||
* @param updates - Fields to update
|
||||
* @returns Updated user
|
||||
*/
|
||||
export async function update(id: number, updates: UpdateUserRequest): Promise<User> {
|
||||
const { data } = await apiClient.put<User>(`/admin/users/${id}`, updates)
|
||||
export async function update(id: number, updates: UpdateUserRequest): Promise<AdminUser> {
|
||||
const { data } = await apiClient.put<AdminUser>(`/admin/users/${id}`, updates)
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -112,8 +111,8 @@ export async function updateBalance(
|
||||
balance: number,
|
||||
operation: 'set' | 'add' | 'subtract' = 'set',
|
||||
notes?: string
|
||||
): Promise<User> {
|
||||
const { data } = await apiClient.post<User>(`/admin/users/${id}/balance`, {
|
||||
): Promise<AdminUser> {
|
||||
const { data } = await apiClient.post<AdminUser>(`/admin/users/${id}/balance`, {
|
||||
balance,
|
||||
operation,
|
||||
notes: notes || ''
|
||||
@@ -127,7 +126,7 @@ export async function updateBalance(
|
||||
* @param concurrency - New concurrency limit
|
||||
* @returns Updated user
|
||||
*/
|
||||
export async function updateConcurrency(id: number, concurrency: number): Promise<User> {
|
||||
export async function updateConcurrency(id: number, concurrency: number): Promise<AdminUser> {
|
||||
return update(id, { concurrency })
|
||||
}
|
||||
|
||||
@@ -137,7 +136,7 @@ export async function updateConcurrency(id: number, concurrency: number): Promis
|
||||
* @param status - New status
|
||||
* @returns Updated user
|
||||
*/
|
||||
export async function toggleStatus(id: number, status: 'active' | 'disabled'): Promise<User> {
|
||||
export async function toggleStatus(id: number, status: 'active' | 'disabled'): Promise<AdminUser> {
|
||||
return update(id, { status })
|
||||
}
|
||||
|
||||
|
||||
26
frontend/src/api/announcements.ts
Normal file
26
frontend/src/api/announcements.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* User Announcements API endpoints
|
||||
*/
|
||||
|
||||
import { apiClient } from './client'
|
||||
import type { UserAnnouncement } from '@/types'
|
||||
|
||||
export async function list(unreadOnly: boolean = false): Promise<UserAnnouncement[]> {
|
||||
const { data } = await apiClient.get<UserAnnouncement[]>('/announcements', {
|
||||
params: unreadOnly ? { unread_only: 1 } : {}
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function markRead(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.post<{ message: string }>(`/announcements/${id}/read`)
|
||||
return data
|
||||
}
|
||||
|
||||
const announcementsAPI = {
|
||||
list,
|
||||
markRead
|
||||
}
|
||||
|
||||
export default announcementsAPI
|
||||
|
||||
@@ -11,9 +11,23 @@ import type {
|
||||
CurrentUserResponse,
|
||||
SendVerifyCodeRequest,
|
||||
SendVerifyCodeResponse,
|
||||
PublicSettings
|
||||
PublicSettings,
|
||||
TotpLoginResponse,
|
||||
TotpLogin2FARequest
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
* Login response type - can be either full auth or 2FA required
|
||||
*/
|
||||
export type LoginResponse = AuthResponse | TotpLoginResponse
|
||||
|
||||
/**
|
||||
* Type guard to check if login response requires 2FA
|
||||
*/
|
||||
export function isTotp2FARequired(response: LoginResponse): response is TotpLoginResponse {
|
||||
return 'requires_2fa' in response && response.requires_2fa === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Store authentication token in localStorage
|
||||
*/
|
||||
@@ -38,11 +52,28 @@ export function clearAuthToken(): void {
|
||||
|
||||
/**
|
||||
* User login
|
||||
* @param credentials - Username and password
|
||||
* @param credentials - Email and password
|
||||
* @returns Authentication response with token and user data, or 2FA required response
|
||||
*/
|
||||
export async function login(credentials: LoginRequest): Promise<LoginResponse> {
|
||||
const { data } = await apiClient.post<LoginResponse>('/auth/login', credentials)
|
||||
|
||||
// Only store token if 2FA is not required
|
||||
if (!isTotp2FARequired(data)) {
|
||||
setAuthToken(data.access_token)
|
||||
localStorage.setItem('auth_user', JSON.stringify(data.user))
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete login with 2FA code
|
||||
* @param request - Temp token and TOTP code
|
||||
* @returns Authentication response with token and user data
|
||||
*/
|
||||
export async function login(credentials: LoginRequest): Promise<AuthResponse> {
|
||||
const { data } = await apiClient.post<AuthResponse>('/auth/login', credentials)
|
||||
export async function login2FA(request: TotpLogin2FARequest): Promise<AuthResponse> {
|
||||
const { data } = await apiClient.post<AuthResponse>('/auth/login/2fa', request)
|
||||
|
||||
// Store token and user data
|
||||
setAuthToken(data.access_token)
|
||||
@@ -133,8 +164,61 @@ export async function validatePromoCode(code: string): Promise<ValidatePromoCode
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgot password request
|
||||
*/
|
||||
export interface ForgotPasswordRequest {
|
||||
email: string
|
||||
turnstile_token?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgot password response
|
||||
*/
|
||||
export interface ForgotPasswordResponse {
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Request password reset link
|
||||
* @param request - Email and optional Turnstile token
|
||||
* @returns Response with message
|
||||
*/
|
||||
export async function forgotPassword(request: ForgotPasswordRequest): Promise<ForgotPasswordResponse> {
|
||||
const { data } = await apiClient.post<ForgotPasswordResponse>('/auth/forgot-password', request)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset password request
|
||||
*/
|
||||
export interface ResetPasswordRequest {
|
||||
email: string
|
||||
token: string
|
||||
new_password: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset password response
|
||||
*/
|
||||
export interface ResetPasswordResponse {
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset password with token
|
||||
* @param request - Email, token, and new password
|
||||
* @returns Response with message
|
||||
*/
|
||||
export async function resetPassword(request: ResetPasswordRequest): Promise<ResetPasswordResponse> {
|
||||
const { data } = await apiClient.post<ResetPasswordResponse>('/auth/reset-password', request)
|
||||
return data
|
||||
}
|
||||
|
||||
export const authAPI = {
|
||||
login,
|
||||
login2FA,
|
||||
isTotp2FARequired,
|
||||
register,
|
||||
getCurrentUser,
|
||||
logout,
|
||||
@@ -144,7 +228,9 @@ export const authAPI = {
|
||||
clearAuthToken,
|
||||
getPublicSettings,
|
||||
sendVerifyCode,
|
||||
validatePromoCode
|
||||
validatePromoCode,
|
||||
forgotPassword,
|
||||
resetPassword
|
||||
}
|
||||
|
||||
export default authAPI
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
export { apiClient } from './client'
|
||||
|
||||
// Auth API
|
||||
export { authAPI } from './auth'
|
||||
export { authAPI, isTotp2FARequired, type LoginResponse } from './auth'
|
||||
|
||||
// User APIs
|
||||
export { keysAPI } from './keys'
|
||||
@@ -15,6 +15,8 @@ export { usageAPI } from './usage'
|
||||
export { userAPI } from './user'
|
||||
export { redeemAPI, type RedeemHistoryItem } from './redeem'
|
||||
export { userGroupsAPI } from './groups'
|
||||
export { totpAPI } from './totp'
|
||||
export { default as announcementsAPI } from './announcements'
|
||||
|
||||
// Admin APIs
|
||||
export { adminAPI } from './admin'
|
||||
|
||||
@@ -14,7 +14,9 @@ export interface RedeemHistoryItem {
|
||||
status: string
|
||||
used_at: string
|
||||
created_at: string
|
||||
// 订阅类型专用字段
|
||||
// Notes from admin for admin_balance/admin_concurrency types
|
||||
notes?: string
|
||||
// Subscription-specific fields
|
||||
group_id?: number
|
||||
validity_days?: number
|
||||
group?: {
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface RedisConfig {
|
||||
port: number
|
||||
password: string
|
||||
db: number
|
||||
enable_tls: boolean
|
||||
}
|
||||
|
||||
export interface AdminConfig {
|
||||
|
||||
83
frontend/src/api/totp.ts
Normal file
83
frontend/src/api/totp.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* TOTP (2FA) API endpoints
|
||||
* Handles Two-Factor Authentication with Google Authenticator
|
||||
*/
|
||||
|
||||
import { apiClient } from './client'
|
||||
import type {
|
||||
TotpStatus,
|
||||
TotpSetupRequest,
|
||||
TotpSetupResponse,
|
||||
TotpEnableRequest,
|
||||
TotpEnableResponse,
|
||||
TotpDisableRequest,
|
||||
TotpVerificationMethod
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
* Get TOTP status for current user
|
||||
* @returns TOTP status including enabled state and feature availability
|
||||
*/
|
||||
export async function getStatus(): Promise<TotpStatus> {
|
||||
const { data } = await apiClient.get<TotpStatus>('/user/totp/status')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get verification method for TOTP operations
|
||||
* @returns Method ('email' or 'password') required for setup/disable
|
||||
*/
|
||||
export async function getVerificationMethod(): Promise<TotpVerificationMethod> {
|
||||
const { data } = await apiClient.get<TotpVerificationMethod>('/user/totp/verification-method')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email verification code for TOTP operations
|
||||
* @returns Success response
|
||||
*/
|
||||
export async function sendVerifyCode(): Promise<{ success: boolean }> {
|
||||
const { data } = await apiClient.post<{ success: boolean }>('/user/totp/send-code')
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate TOTP setup - generates secret and QR code
|
||||
* @param request - Email code or password depending on verification method
|
||||
* @returns Setup response with secret, QR code URL, and setup token
|
||||
*/
|
||||
export async function initiateSetup(request?: TotpSetupRequest): Promise<TotpSetupResponse> {
|
||||
const { data } = await apiClient.post<TotpSetupResponse>('/user/totp/setup', request || {})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete TOTP setup by verifying the code
|
||||
* @param request - TOTP code and setup token
|
||||
* @returns Enable response with success status and enabled timestamp
|
||||
*/
|
||||
export async function enable(request: TotpEnableRequest): Promise<TotpEnableResponse> {
|
||||
const { data } = await apiClient.post<TotpEnableResponse>('/user/totp/enable', request)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable TOTP for current user
|
||||
* @param request - Email code or password depending on verification method
|
||||
* @returns Success response
|
||||
*/
|
||||
export async function disable(request: TotpDisableRequest): Promise<{ success: boolean }> {
|
||||
const { data } = await apiClient.post<{ success: boolean }>('/user/totp/disable', request)
|
||||
return data
|
||||
}
|
||||
|
||||
export const totpAPI = {
|
||||
getStatus,
|
||||
getVerificationMethod,
|
||||
sendVerifyCode,
|
||||
initiateSetup,
|
||||
enable,
|
||||
disable
|
||||
}
|
||||
|
||||
export default totpAPI
|
||||
@@ -1,18 +1,32 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Main Status Badge -->
|
||||
<button
|
||||
v-if="isTempUnschedulable"
|
||||
type="button"
|
||||
:class="['badge text-xs', statusClass, 'cursor-pointer']"
|
||||
:title="t('admin.accounts.status.viewTempUnschedDetails')"
|
||||
@click="handleTempUnschedClick"
|
||||
>
|
||||
{{ statusText }}
|
||||
</button>
|
||||
<span v-else :class="['badge text-xs', statusClass]">
|
||||
{{ statusText }}
|
||||
</span>
|
||||
<!-- Rate Limit Display (429) - Two-line layout -->
|
||||
<div v-if="isRateLimited" class="flex flex-col items-center gap-1">
|
||||
<span class="badge text-xs badge-warning">{{ t('admin.accounts.status.rateLimited') }}</span>
|
||||
<span class="text-[11px] text-gray-400 dark:text-gray-500">{{ rateLimitCountdown }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Overload Display (529) - Two-line layout -->
|
||||
<div v-else-if="isOverloaded" class="flex flex-col items-center gap-1">
|
||||
<span class="badge text-xs badge-danger">{{ t('admin.accounts.status.overloaded') }}</span>
|
||||
<span class="text-[11px] text-gray-400 dark:text-gray-500">{{ overloadCountdown }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Main Status Badge (shown when not rate limited/overloaded) -->
|
||||
<template v-else>
|
||||
<button
|
||||
v-if="isTempUnschedulable"
|
||||
type="button"
|
||||
:class="['badge text-xs', statusClass, 'cursor-pointer']"
|
||||
:title="t('admin.accounts.status.viewTempUnschedDetails')"
|
||||
@click="handleTempUnschedClick"
|
||||
>
|
||||
{{ statusText }}
|
||||
</button>
|
||||
<span v-else :class="['badge text-xs', statusClass]">
|
||||
{{ statusText }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Error Info Indicator -->
|
||||
<div v-if="hasError && account.error_message" class="group/error relative">
|
||||
@@ -42,7 +56,6 @@
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rate Limit Indicator (429) -->
|
||||
<div v-if="isRateLimited" class="group relative">
|
||||
<span
|
||||
@@ -108,8 +121,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Account } from '@/types'
|
||||
import { formatTime } from '@/utils/format'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { formatCountdownWithSuffix } from '@/utils/format'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -163,6 +175,16 @@ const hasError = computed(() => {
|
||||
return props.account.status === 'error'
|
||||
})
|
||||
|
||||
// Computed: countdown text for rate limit (429)
|
||||
const rateLimitCountdown = computed(() => {
|
||||
return formatCountdownWithSuffix(props.account.rate_limit_reset_at)
|
||||
})
|
||||
|
||||
// Computed: countdown text for overload (529)
|
||||
const overloadCountdown = computed(() => {
|
||||
return formatCountdownWithSuffix(props.account.overload_until)
|
||||
})
|
||||
|
||||
// Computed: status badge class
|
||||
const statusClass = computed(() => {
|
||||
if (hasError.value) {
|
||||
@@ -171,7 +193,7 @@ const statusClass = computed(() => {
|
||||
if (isTempUnschedulable.value) {
|
||||
return 'badge-warning'
|
||||
}
|
||||
if (!props.account.schedulable || isRateLimited.value || isOverloaded.value) {
|
||||
if (!props.account.schedulable) {
|
||||
return 'badge-gray'
|
||||
}
|
||||
switch (props.account.status) {
|
||||
@@ -197,9 +219,6 @@ const statusText = computed(() => {
|
||||
if (!props.account.schedulable) {
|
||||
return t('admin.accounts.status.paused')
|
||||
}
|
||||
if (isRateLimited.value || isOverloaded.value) {
|
||||
return t('admin.accounts.status.limited')
|
||||
}
|
||||
return t(`admin.accounts.status.${props.account.status}`)
|
||||
})
|
||||
|
||||
@@ -207,5 +226,4 @@ const handleTempUnschedClick = () => {
|
||||
if (!isTempUnschedulable.value) return
|
||||
emit('show-temp-unsched', props.account)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@@ -292,8 +292,11 @@ const loadAvailableModels = async () => {
|
||||
if (availableModels.value.length > 0) {
|
||||
if (props.account.platform === 'gemini') {
|
||||
const preferred =
|
||||
availableModels.value.find((m) => m.id === 'gemini-2.0-flash') ||
|
||||
availableModels.value.find((m) => m.id === 'gemini-2.5-flash') ||
|
||||
availableModels.value.find((m) => m.id === 'gemini-2.5-pro') ||
|
||||
availableModels.value.find((m) => m.id === 'gemini-3-pro')
|
||||
availableModels.value.find((m) => m.id === 'gemini-3-flash-preview') ||
|
||||
availableModels.value.find((m) => m.id === 'gemini-3-pro-preview')
|
||||
selectedModelId.value = preferred?.id || availableModels.value[0].id
|
||||
} else {
|
||||
// Try to select Sonnet as default, otherwise use first model
|
||||
|
||||
@@ -648,7 +648,7 @@ import { ref, watch, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Proxy, Group } from '@/types'
|
||||
import type { Proxy, AdminGroup } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||
@@ -659,7 +659,7 @@ interface Props {
|
||||
show: boolean
|
||||
accountIds: number[]
|
||||
proxies: Proxy[]
|
||||
groups: Group[]
|
||||
groups: AdminGroup[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
@@ -1159,9 +1159,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Intercept Warmup Requests (Anthropic/Antigravity) -->
|
||||
<!-- Intercept Warmup Requests (Anthropic only) -->
|
||||
<div
|
||||
v-if="form.platform === 'anthropic' || form.platform === 'antigravity'"
|
||||
v-if="form.platform === 'anthropic'"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -1191,6 +1191,190 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quota Control Section (Anthropic OAuth/SetupToken only) -->
|
||||
<div
|
||||
v-if="form.platform === 'anthropic' && accountCategory === 'oauth-based'"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
|
||||
>
|
||||
<div class="mb-3">
|
||||
<h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaControl.title') }}</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.quotaControl.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Window Cost Limit -->
|
||||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.windowCost.label') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.quotaControl.windowCost.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="windowCostEnabled = !windowCostEnabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
windowCostEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
windowCostEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="windowCostEnabled" class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.quotaControl.windowCost.limit') }}</label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">$</span>
|
||||
<input
|
||||
v-model.number="windowCostLimit"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
class="input pl-7"
|
||||
:placeholder="t('admin.accounts.quotaControl.windowCost.limitPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<p class="input-hint">{{ t('admin.accounts.quotaControl.windowCost.limitHint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.quotaControl.windowCost.stickyReserve') }}</label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">$</span>
|
||||
<input
|
||||
v-model.number="windowCostStickyReserve"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
class="input pl-7"
|
||||
:placeholder="t('admin.accounts.quotaControl.windowCost.stickyReservePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<p class="input-hint">{{ t('admin.accounts.quotaControl.windowCost.stickyReserveHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Limit -->
|
||||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.sessionLimit.label') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.quotaControl.sessionLimit.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="sessionLimitEnabled = !sessionLimitEnabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
sessionLimitEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
sessionLimitEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="sessionLimitEnabled" class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.quotaControl.sessionLimit.maxSessions') }}</label>
|
||||
<input
|
||||
v-model.number="maxSessions"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
class="input"
|
||||
:placeholder="t('admin.accounts.quotaControl.sessionLimit.maxSessionsPlaceholder')"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.accounts.quotaControl.sessionLimit.maxSessionsHint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.quotaControl.sessionLimit.idleTimeout') }}</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model.number="sessionIdleTimeout"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
class="input pr-12"
|
||||
:placeholder="t('admin.accounts.quotaControl.sessionLimit.idleTimeoutPlaceholder')"
|
||||
/>
|
||||
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">{{ t('common.minutes') }}</span>
|
||||
</div>
|
||||
<p class="input-hint">{{ t('admin.accounts.quotaControl.sessionLimit.idleTimeoutHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TLS Fingerprint -->
|
||||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.tlsFingerprint.label') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.quotaControl.tlsFingerprint.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="tlsFingerprintEnabled = !tlsFingerprintEnabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
tlsFingerprintEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
tlsFingerprintEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session ID Masking -->
|
||||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.sessionIdMasking.label') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.quotaControl.sessionIdMasking.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="sessionIdMaskingEnabled = !sessionIdMaskingEnabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
sessionIdMaskingEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
sessionIdMaskingEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.proxy') }}</label>
|
||||
<ProxySelector v-model="form.proxy_id" :proxies="proxies" />
|
||||
@@ -1214,7 +1398,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.billingRateMultiplier') }}</label>
|
||||
<input v-model.number="form.rate_multiplier" type="number" min="0" step="0.01" class="input" />
|
||||
<input v-model.number="form.rate_multiplier" type="number" min="0" step="0.001" class="input" />
|
||||
<p class="input-hint">{{ t('admin.accounts.billingRateMultiplierHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1632,7 +1816,7 @@ import {
|
||||
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
|
||||
import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
|
||||
import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth'
|
||||
import type { Proxy, Group, AccountPlatform, AccountType } from '@/types'
|
||||
import type { Proxy, AdminGroup, AccountPlatform, AccountType } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||
@@ -1678,7 +1862,7 @@ const apiKeyHint = computed(() => {
|
||||
interface Props {
|
||||
show: boolean
|
||||
proxies: Proxy[]
|
||||
groups: Group[]
|
||||
groups: AdminGroup[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -1763,6 +1947,16 @@ const geminiAIStudioOAuthEnabled = ref(false)
|
||||
const showAdvancedOAuth = ref(false)
|
||||
const showGeminiHelpDialog = ref(false)
|
||||
|
||||
// Quota control state (Anthropic OAuth/SetupToken only)
|
||||
const windowCostEnabled = ref(false)
|
||||
const windowCostLimit = ref<number | null>(null)
|
||||
const windowCostStickyReserve = ref<number | null>(null)
|
||||
const sessionLimitEnabled = ref(false)
|
||||
const maxSessions = ref<number | null>(null)
|
||||
const sessionIdleTimeout = ref<number | null>(null)
|
||||
const tlsFingerprintEnabled = ref(false)
|
||||
const sessionIdMaskingEnabled = ref(false)
|
||||
|
||||
// Gemini tier selection (used as fallback when auto-detection is unavailable/fails)
|
||||
const geminiTierGoogleOne = ref<'google_one_free' | 'google_ai_pro' | 'google_ai_ultra'>('google_one_free')
|
||||
const geminiTierGcp = ref<'gcp_standard' | 'gcp_enterprise'>('gcp_standard')
|
||||
@@ -2140,6 +2334,15 @@ const resetForm = () => {
|
||||
customErrorCodeInput.value = null
|
||||
interceptWarmupRequests.value = false
|
||||
autoPauseOnExpired.value = true
|
||||
// Reset quota control state
|
||||
windowCostEnabled.value = false
|
||||
windowCostLimit.value = null
|
||||
windowCostStickyReserve.value = null
|
||||
sessionLimitEnabled.value = false
|
||||
maxSessions.value = null
|
||||
sessionIdleTimeout.value = null
|
||||
tlsFingerprintEnabled.value = false
|
||||
sessionIdMaskingEnabled.value = false
|
||||
tempUnschedEnabled.value = false
|
||||
tempUnschedRules.value = []
|
||||
geminiOAuthType.value = 'code_assist'
|
||||
@@ -2407,7 +2610,32 @@ const handleAnthropicExchange = async (authCode: string) => {
|
||||
...proxyConfig
|
||||
})
|
||||
|
||||
const extra = oauth.buildExtraInfo(tokenInfo)
|
||||
// Build extra with quota control settings
|
||||
const baseExtra = oauth.buildExtraInfo(tokenInfo) || {}
|
||||
const extra: Record<string, unknown> = { ...baseExtra }
|
||||
|
||||
// Add window cost limit settings
|
||||
if (windowCostEnabled.value && windowCostLimit.value != null && windowCostLimit.value > 0) {
|
||||
extra.window_cost_limit = windowCostLimit.value
|
||||
extra.window_cost_sticky_reserve = windowCostStickyReserve.value ?? 10
|
||||
}
|
||||
|
||||
// Add session limit settings
|
||||
if (sessionLimitEnabled.value && maxSessions.value != null && maxSessions.value > 0) {
|
||||
extra.max_sessions = maxSessions.value
|
||||
extra.session_idle_timeout_minutes = sessionIdleTimeout.value ?? 5
|
||||
}
|
||||
|
||||
// Add TLS fingerprint settings
|
||||
if (tlsFingerprintEnabled.value) {
|
||||
extra.enable_tls_fingerprint = true
|
||||
}
|
||||
|
||||
// Add session ID masking settings
|
||||
if (sessionIdMaskingEnabled.value) {
|
||||
extra.session_id_masking_enabled = true
|
||||
}
|
||||
|
||||
const credentials = {
|
||||
...tokenInfo,
|
||||
...(interceptWarmupRequests.value ? { intercept_warmup_requests: true } : {})
|
||||
@@ -2475,7 +2703,32 @@ const handleCookieAuth = async (sessionKey: string) => {
|
||||
...proxyConfig
|
||||
})
|
||||
|
||||
const extra = oauth.buildExtraInfo(tokenInfo)
|
||||
// Build extra with quota control settings
|
||||
const baseExtra = oauth.buildExtraInfo(tokenInfo) || {}
|
||||
const extra: Record<string, unknown> = { ...baseExtra }
|
||||
|
||||
// Add window cost limit settings
|
||||
if (windowCostEnabled.value && windowCostLimit.value != null && windowCostLimit.value > 0) {
|
||||
extra.window_cost_limit = windowCostLimit.value
|
||||
extra.window_cost_sticky_reserve = windowCostStickyReserve.value ?? 10
|
||||
}
|
||||
|
||||
// Add session limit settings
|
||||
if (sessionLimitEnabled.value && maxSessions.value != null && maxSessions.value > 0) {
|
||||
extra.max_sessions = maxSessions.value
|
||||
extra.session_idle_timeout_minutes = sessionIdleTimeout.value ?? 5
|
||||
}
|
||||
|
||||
// Add TLS fingerprint settings
|
||||
if (tlsFingerprintEnabled.value) {
|
||||
extra.enable_tls_fingerprint = true
|
||||
}
|
||||
|
||||
// Add session ID masking settings
|
||||
if (sessionIdMaskingEnabled.value) {
|
||||
extra.session_id_masking_enabled = true
|
||||
}
|
||||
|
||||
const accountName = keys.length > 1 ? `${form.name} #${i + 1}` : form.name
|
||||
|
||||
// Merge interceptWarmupRequests into credentials
|
||||
|
||||
@@ -512,9 +512,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Intercept Warmup Requests (Anthropic/Antigravity) -->
|
||||
<!-- Intercept Warmup Requests (Anthropic only) -->
|
||||
<div
|
||||
v-if="account?.platform === 'anthropic' || account?.platform === 'antigravity'"
|
||||
v-if="account?.platform === 'anthropic'"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -566,7 +566,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.billingRateMultiplier') }}</label>
|
||||
<input v-model.number="form.rate_multiplier" type="number" min="0" step="0.01" class="input" />
|
||||
<input v-model.number="form.rate_multiplier" type="number" min="0" step="0.001" class="input" />
|
||||
<p class="input-hint">{{ t('admin.accounts.billingRateMultiplierHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -732,6 +732,60 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TLS Fingerprint -->
|
||||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.tlsFingerprint.label') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.quotaControl.tlsFingerprint.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="tlsFingerprintEnabled = !tlsFingerprintEnabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
tlsFingerprintEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
tlsFingerprintEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session ID Masking -->
|
||||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.sessionIdMasking.label') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.quotaControl.sessionIdMasking.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="sessionIdMaskingEnabled = !sessionIdMaskingEnabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
sessionIdMaskingEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
sessionIdMaskingEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
@@ -829,7 +883,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Account, Proxy, Group } from '@/types'
|
||||
import type { Account, Proxy, AdminGroup } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
@@ -847,7 +901,7 @@ interface Props {
|
||||
show: boolean
|
||||
account: Account | null
|
||||
proxies: Proxy[]
|
||||
groups: Group[]
|
||||
groups: AdminGroup[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -904,6 +958,8 @@ const windowCostStickyReserve = ref<number | null>(null)
|
||||
const sessionLimitEnabled = ref(false)
|
||||
const maxSessions = ref<number | null>(null)
|
||||
const sessionIdleTimeout = ref<number | null>(null)
|
||||
const tlsFingerprintEnabled = ref(false)
|
||||
const sessionIdMaskingEnabled = ref(false)
|
||||
|
||||
// Computed: current preset mappings based on platform
|
||||
const presetMappings = computed(() => getPresetMappingsByPlatform(props.account?.platform || 'anthropic'))
|
||||
@@ -1237,6 +1293,8 @@ function loadQuotaControlSettings(account: Account) {
|
||||
sessionLimitEnabled.value = false
|
||||
maxSessions.value = null
|
||||
sessionIdleTimeout.value = null
|
||||
tlsFingerprintEnabled.value = false
|
||||
sessionIdMaskingEnabled.value = false
|
||||
|
||||
// Only applies to Anthropic OAuth/SetupToken accounts
|
||||
if (account.platform !== 'anthropic' || (account.type !== 'oauth' && account.type !== 'setup-token')) {
|
||||
@@ -1255,6 +1313,16 @@ function loadQuotaControlSettings(account: Account) {
|
||||
maxSessions.value = account.max_sessions
|
||||
sessionIdleTimeout.value = account.session_idle_timeout_minutes ?? 5
|
||||
}
|
||||
|
||||
// Load TLS fingerprint setting
|
||||
if (account.enable_tls_fingerprint === true) {
|
||||
tlsFingerprintEnabled.value = true
|
||||
}
|
||||
|
||||
// Load session ID masking setting
|
||||
if (account.session_id_masking_enabled === true) {
|
||||
sessionIdMaskingEnabled.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function formatTempUnschedKeywords(value: unknown) {
|
||||
@@ -1407,6 +1475,20 @@ const handleSubmit = async () => {
|
||||
delete newExtra.session_idle_timeout_minutes
|
||||
}
|
||||
|
||||
// TLS fingerprint setting
|
||||
if (tlsFingerprintEnabled.value) {
|
||||
newExtra.enable_tls_fingerprint = true
|
||||
} else {
|
||||
delete newExtra.enable_tls_fingerprint
|
||||
}
|
||||
|
||||
// Session ID masking setting
|
||||
if (sessionIdMaskingEnabled.value) {
|
||||
newExtra.session_id_masking_enabled = true
|
||||
} else {
|
||||
delete newExtra.session_id_masking_enabled
|
||||
}
|
||||
|
||||
updatePayload.extra = newExtra
|
||||
}
|
||||
|
||||
|
||||
@@ -1,50 +1,78 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="show && position" class="action-menu-content fixed z-[9999] w-52 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800" :style="{ top: position.top + 'px', left: position.left + 'px' }">
|
||||
<div class="py-1">
|
||||
<template v-if="account">
|
||||
<button @click="$emit('test', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="play" size="sm" class="text-green-500" :stroke-width="2" />
|
||||
{{ t('admin.accounts.testConnection') }}
|
||||
</button>
|
||||
<button @click="$emit('stats', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="chart" size="sm" class="text-indigo-500" />
|
||||
{{ t('admin.accounts.viewStats') }}
|
||||
</button>
|
||||
<template v-if="account.type === 'oauth' || account.type === 'setup-token'">
|
||||
<button @click="$emit('reauth', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-blue-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="link" size="sm" />
|
||||
{{ t('admin.accounts.reAuthorize') }}
|
||||
<div v-if="show && position">
|
||||
<!-- Backdrop: click anywhere outside to close -->
|
||||
<div class="fixed inset-0 z-[9998]" @click="emit('close')"></div>
|
||||
<div
|
||||
class="action-menu-content fixed z-[9999] w-52 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800"
|
||||
:style="{ top: position.top + 'px', left: position.left + 'px' }"
|
||||
@click.stop
|
||||
>
|
||||
<div class="py-1">
|
||||
<template v-if="account">
|
||||
<button @click="$emit('test', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="play" size="sm" class="text-green-500" :stroke-width="2" />
|
||||
{{ t('admin.accounts.testConnection') }}
|
||||
</button>
|
||||
<button @click="$emit('refresh-token', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-purple-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="refresh" size="sm" />
|
||||
{{ t('admin.accounts.refreshToken') }}
|
||||
<button @click="$emit('stats', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="chart" size="sm" class="text-indigo-500" />
|
||||
{{ t('admin.accounts.viewStats') }}
|
||||
</button>
|
||||
<template v-if="account.type === 'oauth' || account.type === 'setup-token'">
|
||||
<button @click="$emit('reauth', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-blue-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="link" size="sm" />
|
||||
{{ t('admin.accounts.reAuthorize') }}
|
||||
</button>
|
||||
<button @click="$emit('refresh-token', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-purple-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="refresh" size="sm" />
|
||||
{{ t('admin.accounts.refreshToken') }}
|
||||
</button>
|
||||
</template>
|
||||
<div v-if="account.status === 'error' || isRateLimited || isOverloaded" class="my-1 border-t border-gray-100 dark:border-dark-700"></div>
|
||||
<button v-if="account.status === 'error'" @click="$emit('reset-status', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-yellow-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="sync" size="sm" />
|
||||
{{ t('admin.accounts.resetStatus') }}
|
||||
</button>
|
||||
<button v-if="isRateLimited || isOverloaded" @click="$emit('clear-rate-limit', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-amber-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="clock" size="sm" />
|
||||
{{ t('admin.accounts.clearRateLimit') }}
|
||||
</button>
|
||||
</template>
|
||||
<div v-if="account.status === 'error' || isRateLimited || isOverloaded" class="my-1 border-t border-gray-100 dark:border-dark-700"></div>
|
||||
<button v-if="account.status === 'error'" @click="$emit('reset-status', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-yellow-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="sync" size="sm" />
|
||||
{{ t('admin.accounts.resetStatus') }}
|
||||
</button>
|
||||
<button v-if="isRateLimited || isOverloaded" @click="$emit('clear-rate-limit', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-amber-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="clock" size="sm" />
|
||||
{{ t('admin.accounts.clearRateLimit') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, watch, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Icon } from '@/components/icons'
|
||||
import type { Account } from '@/types'
|
||||
|
||||
const props = defineProps<{ show: boolean; account: Account | null; position: { top: number; left: number } | null }>()
|
||||
defineEmits(['close', 'test', 'stats', 'reauth', 'refresh-token', 'reset-status', 'clear-rate-limit'])
|
||||
const emit = defineEmits(['close', 'test', 'stats', 'reauth', 'refresh-token', 'reset-status', 'clear-rate-limit'])
|
||||
const { t } = useI18n()
|
||||
const isRateLimited = computed(() => props.account?.rate_limit_reset_at && new Date(props.account.rate_limit_reset_at) > new Date())
|
||||
const isOverloaded = computed(() => props.account?.overload_until && new Date(props.account.overload_until) > new Date())
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') emit('close')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
} else {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<slot name="before"></slot>
|
||||
<button @click="$emit('refresh')" :disabled="loading" class="btn btn-secondary">
|
||||
<Icon name="refresh" size="md" :class="[loading ? 'animate-spin' : '']" />
|
||||
</button>
|
||||
<slot name="after"></slot>
|
||||
<button @click="$emit('sync')" class="btn btn-secondary">{{ t('admin.accounts.syncFromCrs') }}</button>
|
||||
<button @click="$emit('create')" class="btn btn-primary">{{ t('admin.accounts.createAccount') }}</button>
|
||||
</div>
|
||||
|
||||
@@ -232,8 +232,11 @@ const loadAvailableModels = async () => {
|
||||
if (availableModels.value.length > 0) {
|
||||
if (props.account.platform === 'gemini') {
|
||||
const preferred =
|
||||
availableModels.value.find((m) => m.id === 'gemini-2.0-flash') ||
|
||||
availableModels.value.find((m) => m.id === 'gemini-2.5-flash') ||
|
||||
availableModels.value.find((m) => m.id === 'gemini-2.5-pro') ||
|
||||
availableModels.value.find((m) => m.id === 'gemini-3-pro')
|
||||
availableModels.value.find((m) => m.id === 'gemini-3-flash-preview') ||
|
||||
availableModels.value.find((m) => m.id === 'gemini-3-pro-preview')
|
||||
selectedModelId.value = preferred?.id || availableModels.value[0].id
|
||||
} else {
|
||||
// Try to select Sonnet as default, otherwise use first model
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:show="show"
|
||||
:title="t('admin.announcements.readStatus')"
|
||||
width="extra-wide"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('admin.announcements.searchUsers')"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<button @click="load" :disabled="loading" class="btn btn-secondary" :title="t('common.refresh')">
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataTable :columns="columns" :data="items" :loading="loading">
|
||||
<template #cell-email="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-balance="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">${{ Number(value ?? 0).toFixed(2) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-eligible="{ value }">
|
||||
<span :class="['badge', value ? 'badge-success' : 'badge-gray']">
|
||||
{{ value ? t('admin.announcements.eligible') : t('common.no') }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-read_at="{ value }">
|
||||
<span class="text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ value ? formatDateTime(value) : t('admin.announcements.unread') }}
|
||||
</span>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
@update:pageSize="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end">
|
||||
<button type="button" class="btn btn-secondary" @click="handleClose">{{ t('common.close') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import type { AnnouncementUserReadStatus } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
announcementId: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const search = ref('')
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
})
|
||||
|
||||
const items = ref<AnnouncementUserReadStatus[]>([])
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'email', label: t('common.email') },
|
||||
{ key: 'username', label: t('admin.users.columns.username') },
|
||||
{ key: 'balance', label: t('common.balance') },
|
||||
{ key: 'eligible', label: t('admin.announcements.eligible') },
|
||||
{ key: 'read_at', label: t('admin.announcements.readAt') }
|
||||
])
|
||||
|
||||
let currentController: AbortController | null = null
|
||||
|
||||
async function load() {
|
||||
if (!props.show || !props.announcementId) return
|
||||
|
||||
if (currentController) currentController.abort()
|
||||
currentController = new AbortController()
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await adminAPI.announcements.getReadStatus(
|
||||
props.announcementId,
|
||||
pagination.page,
|
||||
pagination.page_size,
|
||||
search.value
|
||||
)
|
||||
|
||||
items.value = res.items
|
||||
pagination.total = res.total
|
||||
pagination.pages = res.pages
|
||||
pagination.page = res.page
|
||||
pagination.page_size = res.page_size
|
||||
} catch (error: any) {
|
||||
if (currentController.signal.aborted || error?.name === 'AbortError') return
|
||||
console.error('Failed to load read status:', error)
|
||||
appStore.showError(error.response?.data?.detail || t('admin.announcements.failedToLoadReadStatus'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
pagination.page = page
|
||||
load()
|
||||
}
|
||||
|
||||
function handlePageSizeChange(pageSize: number) {
|
||||
pagination.page_size = pageSize
|
||||
pagination.page = 1
|
||||
load()
|
||||
}
|
||||
|
||||
let searchDebounceTimer: number | null = null
|
||||
function handleSearch() {
|
||||
if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = window.setTimeout(() => {
|
||||
pagination.page = 1
|
||||
load()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(v) => {
|
||||
if (!v) return
|
||||
pagination.page = 1
|
||||
load()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.announcementId,
|
||||
() => {
|
||||
if (!props.show) return
|
||||
pagination.page = 1
|
||||
load()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
// noop
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,408 @@
|
||||
<template>
|
||||
<div class="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-700 dark:bg-dark-800/50">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.announcements.form.targetingMode') }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ mode === 'all' ? t('admin.announcements.form.targetingAll') : t('admin.announcements.form.targetingCustom') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="radio"
|
||||
name="announcement-targeting-mode"
|
||||
value="all"
|
||||
:checked="mode === 'all'"
|
||||
@change="setMode('all')"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{{ t('admin.announcements.form.targetingAll') }}
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="radio"
|
||||
name="announcement-targeting-mode"
|
||||
value="custom"
|
||||
:checked="mode === 'custom'"
|
||||
@change="setMode('custom')"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{{ t('admin.announcements.form.targetingCustom') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === 'custom'" class="mt-4 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
OR
|
||||
<span class="ml-1 text-xs font-normal text-gray-500 dark:text-dark-400">
|
||||
({{ anyOf.length }}/50)
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
:disabled="anyOf.length >= 50"
|
||||
@click="addOrGroup"
|
||||
>
|
||||
<Icon name="plus" size="sm" class="mr-1" />
|
||||
{{ t('admin.announcements.form.addOrGroup') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="anyOf.length === 0" class="rounded-xl border border-dashed border-gray-300 p-4 text-sm text-gray-500 dark:border-dark-600 dark:text-dark-400">
|
||||
{{ t('admin.announcements.form.targetingCustom') }}: {{ t('admin.announcements.form.addOrGroup') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(group, groupIndex) in anyOf"
|
||||
:key="groupIndex"
|
||||
class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-dark-700 dark:bg-dark-800"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.announcements.form.targetingCustom') }} #{{ groupIndex + 1 }}
|
||||
<span class="ml-2 text-xs font-normal text-gray-500 dark:text-dark-400">AND ({{ (group.all_of?.length || 0) }}/50)</span>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ t('admin.announcements.form.addAndCondition') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
@click="removeOrGroup(groupIndex)"
|
||||
>
|
||||
<Icon name="trash" size="sm" class="mr-1" />
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-3">
|
||||
<div
|
||||
v-for="(cond, condIndex) in (group.all_of || [])"
|
||||
:key="condIndex"
|
||||
class="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-dark-700 dark:bg-dark-900/30"
|
||||
>
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-end">
|
||||
<div class="w-full md:w-52">
|
||||
<label class="input-label">{{ t('admin.announcements.form.conditionType') }}</label>
|
||||
<Select
|
||||
:model-value="cond.type"
|
||||
:options="conditionTypeOptions"
|
||||
@update:model-value="(v) => setConditionType(groupIndex, condIndex, v as any)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="cond.type === 'subscription'" class="flex-1">
|
||||
<label class="input-label">{{ t('admin.announcements.form.selectPackages') }}</label>
|
||||
<GroupSelector
|
||||
v-model="subscriptionSelections[groupIndex][condIndex]"
|
||||
:groups="groups"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-1 flex-col gap-3 sm:flex-row">
|
||||
<div class="w-full sm:w-44">
|
||||
<label class="input-label">{{ t('admin.announcements.form.operator') }}</label>
|
||||
<Select
|
||||
:model-value="cond.operator"
|
||||
:options="balanceOperatorOptions"
|
||||
@update:model-value="(v) => setOperator(groupIndex, condIndex, v as any)"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full sm:flex-1">
|
||||
<label class="input-label">{{ t('admin.announcements.form.balanceValue') }}</label>
|
||||
<input
|
||||
:value="String(cond.value ?? '')"
|
||||
type="number"
|
||||
step="any"
|
||||
class="input"
|
||||
@input="(e) => setBalanceValue(groupIndex, condIndex, (e.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
@click="removeAndCondition(groupIndex, condIndex)"
|
||||
>
|
||||
<Icon name="trash" size="sm" class="mr-1" />
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
:disabled="(group.all_of?.length || 0) >= 50"
|
||||
@click="addAndCondition(groupIndex)"
|
||||
>
|
||||
<Icon name="plus" size="sm" class="mr-1" />
|
||||
{{ t('admin.announcements.form.addAndCondition') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="validationError" class="rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-900/30 dark:bg-red-900/10 dark:text-red-300">
|
||||
{{ validationError }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type {
|
||||
AdminGroup,
|
||||
AnnouncementTargeting,
|
||||
AnnouncementCondition,
|
||||
AnnouncementConditionGroup,
|
||||
AnnouncementConditionType,
|
||||
AnnouncementOperator
|
||||
} from '@/types'
|
||||
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import GroupSelector from '@/components/common/GroupSelector.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: AnnouncementTargeting
|
||||
groups: AdminGroup[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: AnnouncementTargeting): void
|
||||
}>()
|
||||
|
||||
const anyOf = computed(() => props.modelValue?.any_of ?? [])
|
||||
|
||||
type Mode = 'all' | 'custom'
|
||||
const mode = computed<Mode>(() => (anyOf.value.length === 0 ? 'all' : 'custom'))
|
||||
|
||||
const conditionTypeOptions = computed(() => [
|
||||
{ value: 'subscription', label: t('admin.announcements.form.conditionSubscription') },
|
||||
{ value: 'balance', label: t('admin.announcements.form.conditionBalance') }
|
||||
])
|
||||
|
||||
const balanceOperatorOptions = computed(() => [
|
||||
{ value: 'gt', label: t('admin.announcements.operators.gt') },
|
||||
{ value: 'gte', label: t('admin.announcements.operators.gte') },
|
||||
{ value: 'lt', label: t('admin.announcements.operators.lt') },
|
||||
{ value: 'lte', label: t('admin.announcements.operators.lte') },
|
||||
{ value: 'eq', label: t('admin.announcements.operators.eq') }
|
||||
])
|
||||
|
||||
function setMode(next: Mode) {
|
||||
if (next === 'all') {
|
||||
emit('update:modelValue', { any_of: [] })
|
||||
return
|
||||
}
|
||||
if (anyOf.value.length === 0) {
|
||||
emit('update:modelValue', { any_of: [{ all_of: [defaultSubscriptionCondition()] }] })
|
||||
}
|
||||
}
|
||||
|
||||
function defaultSubscriptionCondition(): AnnouncementCondition {
|
||||
return {
|
||||
type: 'subscription' as AnnouncementConditionType,
|
||||
operator: 'in' as AnnouncementOperator,
|
||||
group_ids: []
|
||||
}
|
||||
}
|
||||
|
||||
function defaultBalanceCondition(): AnnouncementCondition {
|
||||
return {
|
||||
type: 'balance' as AnnouncementConditionType,
|
||||
operator: 'gte' as AnnouncementOperator,
|
||||
value: 0
|
||||
}
|
||||
}
|
||||
|
||||
type TargetingDraft = {
|
||||
any_of: AnnouncementConditionGroup[]
|
||||
}
|
||||
|
||||
function updateTargeting(mutator: (draft: TargetingDraft) => void) {
|
||||
const draft: TargetingDraft = JSON.parse(JSON.stringify(props.modelValue ?? { any_of: [] }))
|
||||
if (!draft.any_of) draft.any_of = []
|
||||
mutator(draft)
|
||||
emit('update:modelValue', draft)
|
||||
}
|
||||
|
||||
function addOrGroup() {
|
||||
updateTargeting((draft) => {
|
||||
if (draft.any_of.length >= 50) return
|
||||
draft.any_of.push({ all_of: [defaultSubscriptionCondition()] })
|
||||
})
|
||||
}
|
||||
|
||||
function removeOrGroup(groupIndex: number) {
|
||||
updateTargeting((draft) => {
|
||||
draft.any_of.splice(groupIndex, 1)
|
||||
})
|
||||
}
|
||||
|
||||
function addAndCondition(groupIndex: number) {
|
||||
updateTargeting((draft) => {
|
||||
const group = draft.any_of[groupIndex]
|
||||
if (!group.all_of) group.all_of = []
|
||||
if (group.all_of.length >= 50) return
|
||||
group.all_of.push(defaultSubscriptionCondition())
|
||||
})
|
||||
}
|
||||
|
||||
function removeAndCondition(groupIndex: number, condIndex: number) {
|
||||
updateTargeting((draft) => {
|
||||
const group = draft.any_of[groupIndex]
|
||||
if (!group?.all_of) return
|
||||
group.all_of.splice(condIndex, 1)
|
||||
})
|
||||
}
|
||||
|
||||
function setConditionType(groupIndex: number, condIndex: number, nextType: AnnouncementConditionType) {
|
||||
updateTargeting((draft) => {
|
||||
const group = draft.any_of[groupIndex]
|
||||
if (!group?.all_of) return
|
||||
|
||||
if (nextType === 'subscription') {
|
||||
group.all_of[condIndex] = defaultSubscriptionCondition()
|
||||
} else {
|
||||
group.all_of[condIndex] = defaultBalanceCondition()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function setOperator(groupIndex: number, condIndex: number, op: AnnouncementOperator) {
|
||||
updateTargeting((draft) => {
|
||||
const group = draft.any_of[groupIndex]
|
||||
if (!group?.all_of) return
|
||||
|
||||
const cond = group.all_of[condIndex]
|
||||
if (!cond) return
|
||||
|
||||
cond.operator = op
|
||||
})
|
||||
}
|
||||
|
||||
function setBalanceValue(groupIndex: number, condIndex: number, raw: string) {
|
||||
const n = raw === '' ? 0 : Number(raw)
|
||||
updateTargeting((draft) => {
|
||||
const group = draft.any_of[groupIndex]
|
||||
if (!group?.all_of) return
|
||||
|
||||
const cond = group.all_of[condIndex]
|
||||
if (!cond) return
|
||||
|
||||
cond.value = Number.isFinite(n) ? n : 0
|
||||
})
|
||||
}
|
||||
|
||||
// We keep group_ids selection in a parallel reactive map because GroupSelector is numeric list.
|
||||
// Then we mirror it back to targeting.group_ids via a watcher.
|
||||
const subscriptionSelections = reactive<Record<number, Record<number, number[]>>>({})
|
||||
|
||||
function ensureSelectionPath(groupIndex: number, condIndex: number) {
|
||||
if (!subscriptionSelections[groupIndex]) subscriptionSelections[groupIndex] = {}
|
||||
if (!subscriptionSelections[groupIndex][condIndex]) subscriptionSelections[groupIndex][condIndex] = []
|
||||
}
|
||||
|
||||
// Sync from modelValue to subscriptionSelections (one-way: model -> local state)
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => {
|
||||
const groups = v?.any_of ?? []
|
||||
for (let gi = 0; gi < groups.length; gi++) {
|
||||
const allOf = groups[gi]?.all_of ?? []
|
||||
for (let ci = 0; ci < allOf.length; ci++) {
|
||||
const c = allOf[ci]
|
||||
if (c?.type === 'subscription') {
|
||||
ensureSelectionPath(gi, ci)
|
||||
// Only update if different to avoid triggering unnecessary updates
|
||||
const newIds = (c.group_ids ?? []).slice()
|
||||
const currentIds = subscriptionSelections[gi]?.[ci] ?? []
|
||||
if (JSON.stringify(newIds.sort()) !== JSON.stringify(currentIds.sort())) {
|
||||
subscriptionSelections[gi][ci] = newIds
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Sync from subscriptionSelections to modelValue (one-way: local state -> model)
|
||||
// Use a debounced approach to avoid infinite loops
|
||||
let syncTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
watch(
|
||||
() => subscriptionSelections,
|
||||
() => {
|
||||
// Debounce the sync to avoid rapid fire updates
|
||||
if (syncTimeout) clearTimeout(syncTimeout)
|
||||
|
||||
syncTimeout = setTimeout(() => {
|
||||
// Build the new targeting state
|
||||
const newTargeting: TargetingDraft = JSON.parse(JSON.stringify(props.modelValue ?? { any_of: [] }))
|
||||
if (!newTargeting.any_of) newTargeting.any_of = []
|
||||
|
||||
const groups = newTargeting.any_of ?? []
|
||||
for (let gi = 0; gi < groups.length; gi++) {
|
||||
const allOf = groups[gi]?.all_of ?? []
|
||||
for (let ci = 0; ci < allOf.length; ci++) {
|
||||
const c = allOf[ci]
|
||||
if (c?.type === 'subscription') {
|
||||
ensureSelectionPath(gi, ci)
|
||||
c.operator = 'in' as AnnouncementOperator
|
||||
c.group_ids = (subscriptionSelections[gi]?.[ci] ?? []).slice()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only emit if there's an actual change (deep comparison)
|
||||
if (JSON.stringify(props.modelValue) !== JSON.stringify(newTargeting)) {
|
||||
emit('update:modelValue', newTargeting)
|
||||
}
|
||||
}, 0)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const validationError = computed(() => {
|
||||
if (mode.value !== 'custom') return ''
|
||||
|
||||
const groups = anyOf.value
|
||||
if (groups.length === 0) return t('admin.announcements.form.addOrGroup')
|
||||
|
||||
if (groups.length > 50) return 'any_of > 50'
|
||||
|
||||
for (const g of groups) {
|
||||
const allOf = g?.all_of ?? []
|
||||
if (allOf.length === 0) return t('admin.announcements.form.addAndCondition')
|
||||
if (allOf.length > 50) return 'all_of > 50'
|
||||
|
||||
for (const c of allOf) {
|
||||
if (c.type === 'subscription') {
|
||||
if (!c.group_ids || c.group_ids.length === 0) return t('admin.announcements.form.selectPackages')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
</script>
|
||||
380
frontend/src/components/admin/usage/UsageCleanupDialog.vue
Normal file
380
frontend/src/components/admin/usage/UsageCleanupDialog.vue
Normal file
@@ -0,0 +1,380 @@
|
||||
<template>
|
||||
<BaseDialog :show="show" :title="t('admin.usage.cleanup.title')" width="wide" @close="handleClose">
|
||||
<div class="space-y-4">
|
||||
<UsageFilters
|
||||
v-model="localFilters"
|
||||
v-model:startDate="localStartDate"
|
||||
v-model:endDate="localEndDate"
|
||||
:exporting="false"
|
||||
:show-actions="false"
|
||||
@change="noop"
|
||||
/>
|
||||
|
||||
<div class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
|
||||
{{ t('admin.usage.cleanup.warning') }}
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 p-4 dark:border-dark-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||
{{ t('admin.usage.cleanup.recentTasks') }}
|
||||
</h4>
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="loadTasks">
|
||||
{{ t('common.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 space-y-2">
|
||||
<div v-if="tasksLoading" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.usage.cleanup.loadingTasks') }}
|
||||
</div>
|
||||
<div v-else-if="tasks.length === 0" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.usage.cleanup.noTasks') }}
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
class="flex flex-col gap-2 rounded-lg border border-gray-100 px-3 py-2 text-sm text-gray-600 dark:border-dark-700 dark:text-gray-300"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="statusClass(task.status)" class="rounded-full px-2 py-0.5 text-xs font-semibold">
|
||||
{{ statusLabel(task.status) }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-400">#{{ task.id }}</span>
|
||||
<button
|
||||
v-if="canCancel(task)"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs text-rose-600 hover:text-rose-700 dark:text-rose-300"
|
||||
@click="openCancelConfirm(task)"
|
||||
>
|
||||
{{ t('admin.usage.cleanup.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">
|
||||
{{ formatDateTime(task.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{{ t('admin.usage.cleanup.range') }}: {{ formatRange(task) }}</span>
|
||||
<span>{{ t('admin.usage.cleanup.deletedRows') }}: {{ task.deleted_rows.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="task.error_message" class="text-xs text-rose-500">
|
||||
{{ task.error_message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
v-if="tasksTotal > tasksPageSize"
|
||||
class="mt-4"
|
||||
:total="tasksTotal"
|
||||
:page="tasksPage"
|
||||
:page-size="tasksPageSize"
|
||||
:page-size-options="[5]"
|
||||
:show-page-size-selector="false"
|
||||
:show-jump="true"
|
||||
@update:page="handleTaskPageChange"
|
||||
@update:pageSize="handleTaskPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" class="btn btn-secondary" @click="handleClose">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger" :disabled="submitting" @click="openConfirm">
|
||||
{{ submitting ? t('admin.usage.cleanup.submitting') : t('admin.usage.cleanup.submit') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
:show="confirmVisible"
|
||||
:title="t('admin.usage.cleanup.confirmTitle')"
|
||||
:message="t('admin.usage.cleanup.confirmMessage')"
|
||||
:confirm-text="t('admin.usage.cleanup.confirmSubmit')"
|
||||
danger
|
||||
@confirm="submitCleanup"
|
||||
@cancel="confirmVisible = false"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:show="cancelConfirmVisible"
|
||||
:title="t('admin.usage.cleanup.cancelConfirmTitle')"
|
||||
:message="t('admin.usage.cleanup.cancelConfirmMessage')"
|
||||
:confirm-text="t('admin.usage.cleanup.cancelConfirm')"
|
||||
danger
|
||||
@confirm="cancelTask"
|
||||
@cancel="cancelConfirmVisible = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import UsageFilters from '@/components/admin/usage/UsageFilters.vue'
|
||||
import { adminUsageAPI } from '@/api/admin/usage'
|
||||
import type { AdminUsageQueryParams, UsageCleanupTask, CreateUsageCleanupTaskRequest } from '@/api/admin/usage'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
filters: AdminUsageQueryParams
|
||||
startDate: string
|
||||
endDate: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const localFilters = ref<AdminUsageQueryParams>({})
|
||||
const localStartDate = ref('')
|
||||
const localEndDate = ref('')
|
||||
|
||||
const tasks = ref<UsageCleanupTask[]>([])
|
||||
const tasksLoading = ref(false)
|
||||
const tasksPage = ref(1)
|
||||
const tasksPageSize = ref(5)
|
||||
const tasksTotal = ref(0)
|
||||
const submitting = ref(false)
|
||||
const confirmVisible = ref(false)
|
||||
const cancelConfirmVisible = ref(false)
|
||||
const canceling = ref(false)
|
||||
const cancelTarget = ref<UsageCleanupTask | null>(null)
|
||||
let pollTimer: number | null = null
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
const resetFilters = () => {
|
||||
localFilters.value = { ...props.filters }
|
||||
localStartDate.value = props.startDate
|
||||
localEndDate.value = props.endDate
|
||||
localFilters.value.start_date = localStartDate.value
|
||||
localFilters.value.end_date = localEndDate.value
|
||||
tasksPage.value = 1
|
||||
tasksTotal.value = 0
|
||||
}
|
||||
|
||||
const startPolling = () => {
|
||||
stopPolling()
|
||||
pollTimer = window.setInterval(() => {
|
||||
loadTasks()
|
||||
}, 10000)
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollTimer !== null) {
|
||||
window.clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
stopPolling()
|
||||
confirmVisible.value = false
|
||||
cancelConfirmVisible.value = false
|
||||
canceling.value = false
|
||||
cancelTarget.value = null
|
||||
submitting.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const statusLabel = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
pending: t('admin.usage.cleanup.status.pending'),
|
||||
running: t('admin.usage.cleanup.status.running'),
|
||||
succeeded: t('admin.usage.cleanup.status.succeeded'),
|
||||
failed: t('admin.usage.cleanup.status.failed'),
|
||||
canceled: t('admin.usage.cleanup.status.canceled')
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
const statusClass = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
pending: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-200',
|
||||
running: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-200',
|
||||
succeeded: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200',
|
||||
failed: 'bg-rose-100 text-rose-700 dark:bg-rose-500/20 dark:text-rose-200',
|
||||
canceled: 'bg-gray-200 text-gray-600 dark:bg-dark-600 dark:text-gray-300'
|
||||
}
|
||||
return map[status] || 'bg-gray-100 text-gray-600'
|
||||
}
|
||||
|
||||
const formatDateTime = (value?: string | null) => {
|
||||
if (!value) return '--'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
const formatRange = (task: UsageCleanupTask) => {
|
||||
const start = formatDateTime(task.filters.start_time)
|
||||
const end = formatDateTime(task.filters.end_time)
|
||||
return `${start} ~ ${end}`
|
||||
}
|
||||
|
||||
const getUserTimezone = () => {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
} catch {
|
||||
return 'UTC'
|
||||
}
|
||||
}
|
||||
|
||||
const loadTasks = async () => {
|
||||
if (!props.show) return
|
||||
tasksLoading.value = true
|
||||
try {
|
||||
const res = await adminUsageAPI.listCleanupTasks({
|
||||
page: tasksPage.value,
|
||||
page_size: tasksPageSize.value
|
||||
})
|
||||
tasks.value = res.items || []
|
||||
tasksTotal.value = res.total || 0
|
||||
if (res.page) {
|
||||
tasksPage.value = res.page
|
||||
}
|
||||
if (res.page_size) {
|
||||
tasksPageSize.value = res.page_size
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load cleanup tasks:', error)
|
||||
appStore.showError(t('admin.usage.cleanup.loadFailed'))
|
||||
} finally {
|
||||
tasksLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleTaskPageChange = (page: number) => {
|
||||
tasksPage.value = page
|
||||
loadTasks()
|
||||
}
|
||||
|
||||
const handleTaskPageSizeChange = (size: number) => {
|
||||
if (!Number.isFinite(size) || size <= 0) return
|
||||
tasksPageSize.value = size
|
||||
tasksPage.value = 1
|
||||
loadTasks()
|
||||
}
|
||||
|
||||
const openConfirm = () => {
|
||||
confirmVisible.value = true
|
||||
}
|
||||
|
||||
const canCancel = (task: UsageCleanupTask) => {
|
||||
return task.status === 'pending' || task.status === 'running'
|
||||
}
|
||||
|
||||
const openCancelConfirm = (task: UsageCleanupTask) => {
|
||||
cancelTarget.value = task
|
||||
cancelConfirmVisible.value = true
|
||||
}
|
||||
|
||||
const buildPayload = (): CreateUsageCleanupTaskRequest | null => {
|
||||
if (!localStartDate.value || !localEndDate.value) {
|
||||
appStore.showError(t('admin.usage.cleanup.missingRange'))
|
||||
return null
|
||||
}
|
||||
|
||||
const payload: CreateUsageCleanupTaskRequest = {
|
||||
start_date: localStartDate.value,
|
||||
end_date: localEndDate.value,
|
||||
timezone: getUserTimezone()
|
||||
}
|
||||
|
||||
if (localFilters.value.user_id && localFilters.value.user_id > 0) {
|
||||
payload.user_id = localFilters.value.user_id
|
||||
}
|
||||
if (localFilters.value.api_key_id && localFilters.value.api_key_id > 0) {
|
||||
payload.api_key_id = localFilters.value.api_key_id
|
||||
}
|
||||
if (localFilters.value.account_id && localFilters.value.account_id > 0) {
|
||||
payload.account_id = localFilters.value.account_id
|
||||
}
|
||||
if (localFilters.value.group_id && localFilters.value.group_id > 0) {
|
||||
payload.group_id = localFilters.value.group_id
|
||||
}
|
||||
if (localFilters.value.model) {
|
||||
payload.model = localFilters.value.model
|
||||
}
|
||||
if (localFilters.value.stream !== null && localFilters.value.stream !== undefined) {
|
||||
payload.stream = localFilters.value.stream
|
||||
}
|
||||
if (localFilters.value.billing_type !== null && localFilters.value.billing_type !== undefined) {
|
||||
payload.billing_type = localFilters.value.billing_type
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
const submitCleanup = async () => {
|
||||
const payload = buildPayload()
|
||||
if (!payload) {
|
||||
confirmVisible.value = false
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
confirmVisible.value = false
|
||||
try {
|
||||
await adminUsageAPI.createCleanupTask(payload)
|
||||
appStore.showSuccess(t('admin.usage.cleanup.submitSuccess'))
|
||||
loadTasks()
|
||||
} catch (error) {
|
||||
console.error('Failed to create cleanup task:', error)
|
||||
appStore.showError(t('admin.usage.cleanup.submitFailed'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cancelTask = async () => {
|
||||
const task = cancelTarget.value
|
||||
if (!task) {
|
||||
cancelConfirmVisible.value = false
|
||||
return
|
||||
}
|
||||
canceling.value = true
|
||||
cancelConfirmVisible.value = false
|
||||
try {
|
||||
await adminUsageAPI.cancelCleanupTask(task.id)
|
||||
appStore.showSuccess(t('admin.usage.cleanup.cancelSuccess'))
|
||||
loadTasks()
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel cleanup task:', error)
|
||||
appStore.showError(t('admin.usage.cleanup.cancelFailed'))
|
||||
} finally {
|
||||
canceling.value = false
|
||||
cancelTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (show) {
|
||||
resetFilters()
|
||||
loadTasks()
|
||||
startPolling()
|
||||
} else {
|
||||
stopPolling()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
})
|
||||
</script>
|
||||
@@ -127,6 +127,12 @@
|
||||
<Select v-model="filters.stream" :options="streamTypeOptions" @change="emitChange" />
|
||||
</div>
|
||||
|
||||
<!-- Billing Type Filter -->
|
||||
<div class="w-full sm:w-auto sm:min-w-[200px]">
|
||||
<label class="input-label">{{ t('admin.usage.billingType') }}</label>
|
||||
<Select v-model="filters.billing_type" :options="billingTypeOptions" @change="emitChange" />
|
||||
</div>
|
||||
|
||||
<!-- Group Filter -->
|
||||
<div class="w-full sm:w-auto sm:min-w-[200px]">
|
||||
<label class="input-label">{{ t('admin.usage.group') }}</label>
|
||||
@@ -147,10 +153,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Right: actions -->
|
||||
<div class="flex w-full flex-wrap items-center justify-end gap-3 sm:w-auto">
|
||||
<div v-if="showActions" class="flex w-full flex-wrap items-center justify-end gap-3 sm:w-auto">
|
||||
<button type="button" @click="$emit('reset')" class="btn btn-secondary">
|
||||
{{ t('common.reset') }}
|
||||
</button>
|
||||
<button type="button" @click="$emit('cleanup')" class="btn btn-danger">
|
||||
{{ t('admin.usage.cleanup.button') }}
|
||||
</button>
|
||||
<button type="button" @click="$emit('export')" :disabled="exporting" class="btn btn-primary">
|
||||
{{ t('usage.exportExcel') }}
|
||||
</button>
|
||||
@@ -174,16 +183,20 @@ interface Props {
|
||||
exporting: boolean
|
||||
startDate: string
|
||||
endDate: string
|
||||
showActions?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showActions: true
|
||||
})
|
||||
const emit = defineEmits([
|
||||
'update:modelValue',
|
||||
'update:startDate',
|
||||
'update:endDate',
|
||||
'change',
|
||||
'reset',
|
||||
'export'
|
||||
'export',
|
||||
'cleanup'
|
||||
])
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -221,6 +234,12 @@ const streamTypeOptions = ref<SelectOption[]>([
|
||||
{ value: false, label: t('usage.sync') }
|
||||
])
|
||||
|
||||
const billingTypeOptions = ref<SelectOption[]>([
|
||||
{ value: null, label: t('admin.usage.allBillingTypes') },
|
||||
{ value: 0, label: t('admin.usage.billingTypeBalance') },
|
||||
{ value: 1, label: t('admin.usage.billingTypeSubscription') }
|
||||
])
|
||||
|
||||
const emitChange = () => emit('change')
|
||||
|
||||
const updateStartDate = (value: string) => {
|
||||
|
||||
@@ -239,7 +239,7 @@ import { formatDateTime } from '@/utils/format'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { UsageLog } from '@/types'
|
||||
import type { AdminUsageLog } from '@/types'
|
||||
|
||||
defineProps(['data', 'loading'])
|
||||
const { t } = useI18n()
|
||||
@@ -247,12 +247,12 @@ const { t } = useI18n()
|
||||
// Tooltip state - cost
|
||||
const tooltipVisible = ref(false)
|
||||
const tooltipPosition = ref({ x: 0, y: 0 })
|
||||
const tooltipData = ref<UsageLog | null>(null)
|
||||
const tooltipData = ref<AdminUsageLog | null>(null)
|
||||
|
||||
// Tooltip state - token
|
||||
const tokenTooltipVisible = ref(false)
|
||||
const tokenTooltipPosition = ref({ x: 0, y: 0 })
|
||||
const tokenTooltipData = ref<UsageLog | null>(null)
|
||||
const tokenTooltipData = ref<AdminUsageLog | null>(null)
|
||||
|
||||
const cols = computed(() => [
|
||||
{ key: 'user', label: t('admin.usage.user'), sortable: false },
|
||||
@@ -296,7 +296,7 @@ const formatDuration = (ms: number | null | undefined): string => {
|
||||
}
|
||||
|
||||
// Cost tooltip functions
|
||||
const showTooltip = (event: MouseEvent, row: UsageLog) => {
|
||||
const showTooltip = (event: MouseEvent, row: AdminUsageLog) => {
|
||||
const target = event.currentTarget as HTMLElement
|
||||
const rect = target.getBoundingClientRect()
|
||||
tooltipData.value = row
|
||||
@@ -311,7 +311,7 @@ const hideTooltip = () => {
|
||||
}
|
||||
|
||||
// Token tooltip functions
|
||||
const showTokenTooltip = (event: MouseEvent, row: UsageLog) => {
|
||||
const showTokenTooltip = (event: MouseEvent, row: AdminUsageLog) => {
|
||||
const target = event.currentTarget as HTMLElement
|
||||
const rect = target.getBoundingClientRect()
|
||||
tokenTooltipData.value = row
|
||||
|
||||
@@ -39,10 +39,10 @@ import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { User, Group } from '@/types'
|
||||
import type { AdminUser, Group } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
|
||||
const props = defineProps<{ show: boolean, user: User | null }>()
|
||||
const props = defineProps<{ show: boolean, user: AdminUser | null }>()
|
||||
const emit = defineEmits(['close', 'success']); const { t } = useI18n(); const appStore = useAppStore()
|
||||
|
||||
const groups = ref<Group[]>([]); const selectedIds = ref<number[]>([]); const loading = ref(false); const submitting = ref(false)
|
||||
@@ -56,4 +56,4 @@ const handleSave = async () => {
|
||||
appStore.showSuccess(t('admin.users.allowedGroupsUpdated')); emit('success'); emit('close')
|
||||
} catch (error) { console.error('Failed to update allowed groups:', error) } finally { submitting.value = false }
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -32,10 +32,10 @@ import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import type { User, ApiKey } from '@/types'
|
||||
import type { AdminUser, ApiKey } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
|
||||
const props = defineProps<{ show: boolean, user: User | null }>()
|
||||
const props = defineProps<{ show: boolean, user: AdminUser | null }>()
|
||||
defineEmits(['close']); const { t } = useI18n()
|
||||
const apiKeys = ref<ApiKey[]>([]); const loading = ref(false)
|
||||
|
||||
@@ -44,4 +44,4 @@ const load = async () => {
|
||||
if (!props.user) return; loading.value = true
|
||||
try { const res = await adminAPI.users.getUserApiKeys(props.user.id); apiKeys.value = res.items || [] } catch (error) { console.error('Failed to load API keys:', error) } finally { loading.value = false }
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -29,10 +29,10 @@ import { reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { User } from '@/types'
|
||||
import type { AdminUser } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
|
||||
const props = defineProps<{ show: boolean, user: User | null, operation: 'add' | 'subtract' }>()
|
||||
const props = defineProps<{ show: boolean, user: AdminUser | null, operation: 'add' | 'subtract' }>()
|
||||
const emit = defineEmits(['close', 'success']); const { t } = useI18n(); const appStore = useAppStore()
|
||||
|
||||
const submitting = ref(false); const form = reactive({ amount: 0, notes: '' })
|
||||
|
||||
@@ -56,12 +56,12 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { User, UserAttributeValuesMap } from '@/types'
|
||||
import type { AdminUser, UserAttributeValuesMap } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import UserAttributeForm from '@/components/user/UserAttributeForm.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const props = defineProps<{ show: boolean, user: User | null }>()
|
||||
const props = defineProps<{ show: boolean, user: AdminUser | null }>()
|
||||
const emit = defineEmits(['close', 'success'])
|
||||
const { t } = useI18n(); const appStore = useAppStore(); const { copyToClipboard } = useClipboard()
|
||||
|
||||
|
||||
176
frontend/src/components/auth/TotpLoginModal.vue
Normal file
176
frontend/src/components/auth/TotpLoginModal.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div class="fixed inset-0 bg-black/50 transition-opacity"></div>
|
||||
|
||||
<div class="relative w-full max-w-md transform rounded-xl bg-white p-6 shadow-xl transition-all dark:bg-dark-800">
|
||||
<!-- Header -->
|
||||
<div class="mb-6 text-center">
|
||||
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30">
|
||||
<svg class="h-6 w-6 text-primary-600 dark:text-primary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mt-4 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('profile.totp.loginTitle') }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('profile.totp.loginHint') }}
|
||||
</p>
|
||||
<p v-if="userEmailMasked" class="mt-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ userEmailMasked }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Code Input -->
|
||||
<div class="mb-6">
|
||||
<div class="flex justify-center gap-2">
|
||||
<input
|
||||
v-for="(_, index) in 6"
|
||||
:key="index"
|
||||
:ref="(el) => setInputRef(el, index)"
|
||||
type="text"
|
||||
maxlength="1"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]"
|
||||
class="h-12 w-10 rounded-lg border border-gray-300 text-center text-lg font-semibold focus:border-primary-500 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
|
||||
:disabled="verifying"
|
||||
@input="handleCodeInput($event, index)"
|
||||
@keydown="handleKeydown($event, index)"
|
||||
@paste="handlePaste"
|
||||
/>
|
||||
</div>
|
||||
<!-- Loading indicator -->
|
||||
<div v-if="verifying" class="mt-3 flex items-center justify-center gap-2 text-sm text-gray-500">
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary-500"></div>
|
||||
{{ t('common.verifying') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="error" class="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Cancel button only -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary w-full"
|
||||
:disabled="verifying"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
defineProps<{
|
||||
tempToken: string
|
||||
userEmailMasked?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
verify: [code: string]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const verifying = ref(false)
|
||||
const error = ref('')
|
||||
const code = ref<string[]>(['', '', '', '', '', ''])
|
||||
const inputRefs = ref<(HTMLInputElement | null)[]>([])
|
||||
|
||||
// Watch for code changes and auto-submit when 6 digits are entered
|
||||
watch(
|
||||
() => code.value.join(''),
|
||||
(newCode) => {
|
||||
if (newCode.length === 6 && !verifying.value) {
|
||||
emit('verify', newCode)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
defineExpose({
|
||||
setVerifying: (value: boolean) => { verifying.value = value },
|
||||
setError: (message: string) => {
|
||||
error.value = message
|
||||
code.value = ['', '', '', '', '', '']
|
||||
// Clear input DOM values
|
||||
inputRefs.value.forEach(input => {
|
||||
if (input) input.value = ''
|
||||
})
|
||||
nextTick(() => {
|
||||
inputRefs.value[0]?.focus()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const setInputRef = (el: any, index: number) => {
|
||||
inputRefs.value[index] = el as HTMLInputElement | null
|
||||
}
|
||||
|
||||
const handleCodeInput = (event: Event, index: number) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const value = input.value.replace(/[^0-9]/g, '')
|
||||
code.value[index] = value
|
||||
|
||||
if (value && index < 5) {
|
||||
nextTick(() => {
|
||||
inputRefs.value[index + 1]?.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent, index: number) => {
|
||||
if (event.key === 'Backspace') {
|
||||
const input = event.target as HTMLInputElement
|
||||
// If current cell is empty and not the first, move to previous cell
|
||||
if (!input.value && index > 0) {
|
||||
event.preventDefault()
|
||||
inputRefs.value[index - 1]?.focus()
|
||||
}
|
||||
// Otherwise, let the browser handle the backspace naturally
|
||||
// The input event will sync code.value via handleCodeInput
|
||||
}
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
event.preventDefault()
|
||||
const pastedData = event.clipboardData?.getData('text') || ''
|
||||
const digits = pastedData.replace(/[^0-9]/g, '').slice(0, 6).split('')
|
||||
|
||||
// Update both the ref and the input elements
|
||||
digits.forEach((digit, index) => {
|
||||
code.value[index] = digit
|
||||
if (inputRefs.value[index]) {
|
||||
inputRefs.value[index]!.value = digit
|
||||
}
|
||||
})
|
||||
|
||||
// Clear remaining inputs if pasted less than 6 digits
|
||||
for (let i = digits.length; i < 6; i++) {
|
||||
code.value[i] = ''
|
||||
if (inputRefs.value[i]) {
|
||||
inputRefs.value[i]!.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const focusIndex = Math.min(digits.length, 5)
|
||||
nextTick(() => {
|
||||
inputRefs.value[focusIndex]?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
inputRefs.value[0]?.focus()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
626
frontend/src/components/common/AnnouncementBell.vue
Normal file
626
frontend/src/components/common/AnnouncementBell.vue
Normal file
@@ -0,0 +1,626 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 铃铛按钮 -->
|
||||
<button
|
||||
@click="openModal"
|
||||
class="relative flex h-9 w-9 items-center justify-center rounded-lg text-gray-600 transition-all hover:bg-gray-100 hover:scale-105 dark:text-gray-400 dark:hover:bg-dark-800"
|
||||
:class="{ 'text-blue-600 dark:text-blue-400': unreadCount > 0 }"
|
||||
:aria-label="t('announcements.title')"
|
||||
>
|
||||
<Icon name="bell" size="md" />
|
||||
<!-- 未读红点 -->
|
||||
<span
|
||||
v-if="unreadCount > 0"
|
||||
class="absolute right-1 top-1 flex h-2 w-2"
|
||||
>
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500 opacity-75"></span>
|
||||
<span class="relative inline-flex h-2 w-2 rounded-full bg-red-500"></span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- 公告列表 Modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="modal-fade">
|
||||
<div
|
||||
v-if="isModalOpen"
|
||||
class="fixed inset-0 z-[100] flex items-start justify-center overflow-y-auto bg-gradient-to-br from-black/70 via-black/60 to-black/70 p-4 pt-[8vh] backdrop-blur-md"
|
||||
@click="closeModal"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-[620px] overflow-hidden rounded-3xl bg-white shadow-2xl ring-1 ring-black/5 dark:bg-dark-800 dark:ring-white/10"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Header with Gradient -->
|
||||
<div class="relative overflow-hidden border-b border-gray-100/80 bg-gradient-to-br from-blue-50/50 to-indigo-50/30 px-6 py-5 dark:border-dark-700/50 dark:from-blue-900/10 dark:to-indigo-900/5">
|
||||
<div class="relative z-10 flex items-start justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 text-white shadow-lg shadow-blue-500/30">
|
||||
<Icon name="bell" size="sm" />
|
||||
</div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('announcements.title') }}
|
||||
</h2>
|
||||
</div>
|
||||
<p v-if="unreadCount > 0" class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span class="font-medium text-blue-600 dark:text-blue-400">{{ unreadCount }}</span>
|
||||
{{ t('announcements.unread') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="unreadCount > 0"
|
||||
@click="markAllAsRead"
|
||||
:disabled="loading"
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 text-xs font-medium text-white shadow-lg shadow-blue-500/30 transition-all hover:bg-blue-700 hover:shadow-xl disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-600"
|
||||
>
|
||||
{{ t('announcements.markAllRead') }}
|
||||
</button>
|
||||
<button
|
||||
@click="closeModal"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg bg-white/50 text-gray-500 backdrop-blur-sm transition-all hover:bg-white hover:text-gray-700 dark:bg-dark-700/50 dark:text-gray-400 dark:hover:bg-dark-700 dark:hover:text-gray-300"
|
||||
:aria-label="t('common.close')"
|
||||
>
|
||||
<Icon name="x" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Decorative gradient -->
|
||||
<div class="absolute right-0 top-0 h-full w-48 bg-gradient-to-l from-indigo-100/20 to-transparent dark:from-indigo-900/10"></div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="max-h-[65vh] overflow-y-auto">
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-16">
|
||||
<div class="relative">
|
||||
<div class="h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-blue-600 dark:border-dark-600 dark:border-t-blue-400"></div>
|
||||
<div class="absolute inset-0 h-12 w-12 animate-pulse rounded-full border-4 border-blue-400/30"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Announcements List -->
|
||||
<div v-else-if="announcements.length > 0">
|
||||
<div
|
||||
v-for="item in announcements"
|
||||
:key="item.id"
|
||||
class="group relative flex items-center gap-4 border-b border-gray-100 px-6 py-4 transition-all hover:bg-gray-50 dark:border-dark-700 dark:hover:bg-dark-700/30"
|
||||
:class="{ 'bg-blue-50/30 dark:bg-blue-900/5': !item.read_at }"
|
||||
style="min-height: 72px"
|
||||
@click="openDetail(item)"
|
||||
>
|
||||
<!-- Status Indicator -->
|
||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center">
|
||||
<div
|
||||
v-if="!item.read_at"
|
||||
class="relative flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-indigo-600 text-white shadow-lg shadow-blue-500/30"
|
||||
>
|
||||
<!-- Pulse ring -->
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-xl bg-blue-400 opacity-75"></span>
|
||||
<!-- Icon -->
|
||||
<svg class="relative z-10 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gray-100 text-gray-400 dark:bg-dark-700 dark:text-gray-600"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex min-w-0 flex-1 items-center justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="truncate text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ item.title }}
|
||||
</h3>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<time class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ formatRelativeTime(item.created_at) }}
|
||||
</time>
|
||||
<span
|
||||
v-if="!item.read_at"
|
||||
class="inline-flex items-center gap-1 rounded-md bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-300"
|
||||
>
|
||||
<span class="relative flex h-1.5 w-1.5">
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-500 opacity-75"></span>
|
||||
<span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-blue-600"></span>
|
||||
</span>
|
||||
{{ t('announcements.unread') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow -->
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400 transition-transform group-hover:translate-x-1 dark:text-gray-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unread indicator bar -->
|
||||
<div
|
||||
v-if="!item.read_at"
|
||||
class="absolute left-0 top-0 h-full w-1 bg-gradient-to-b from-blue-500 to-indigo-600"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="flex flex-col items-center justify-center py-16">
|
||||
<div class="relative mb-4">
|
||||
<div class="flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-gray-100 to-gray-200 dark:from-dark-700 dark:to-dark-600">
|
||||
<Icon name="inbox" size="xl" class="text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<div class="absolute -right-1 -top-1 flex h-6 w-6 items-center justify-center rounded-full bg-green-500 text-white">
|
||||
<svg class="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('announcements.empty') }}</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('announcements.emptyDescription') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- 公告详情 Modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="modal-fade">
|
||||
<div
|
||||
v-if="detailModalOpen && selectedAnnouncement"
|
||||
class="fixed inset-0 z-[110] flex items-start justify-center overflow-y-auto bg-gradient-to-br from-black/70 via-black/60 to-black/70 p-4 pt-[6vh] backdrop-blur-md"
|
||||
@click="closeDetail"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-[780px] overflow-hidden rounded-3xl bg-white shadow-2xl ring-1 ring-black/5 dark:bg-dark-800 dark:ring-white/10"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Header with Decorative Elements -->
|
||||
<div class="relative overflow-hidden border-b border-gray-100 bg-gradient-to-br from-blue-50/80 via-indigo-50/50 to-purple-50/30 px-8 py-6 dark:border-dark-700 dark:from-blue-900/20 dark:via-indigo-900/10 dark:to-purple-900/5">
|
||||
<!-- Decorative background elements -->
|
||||
<div class="absolute right-0 top-0 h-full w-64 bg-gradient-to-l from-indigo-100/30 to-transparent dark:from-indigo-900/20"></div>
|
||||
<div class="absolute -right-8 -top-8 h-32 w-32 rounded-full bg-gradient-to-br from-blue-400/20 to-indigo-500/20 blur-3xl"></div>
|
||||
<div class="absolute -left-4 -bottom-4 h-24 w-24 rounded-full bg-gradient-to-tr from-purple-400/20 to-pink-500/20 blur-2xl"></div>
|
||||
|
||||
<div class="relative z-10 flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Icon and Category -->
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-indigo-600 text-white shadow-lg shadow-blue-500/30">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="rounded-lg bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
|
||||
{{ t('announcements.title') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="!selectedAnnouncement.read_at"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-gradient-to-r from-blue-500 to-indigo-600 px-2.5 py-1 text-xs font-medium text-white shadow-lg shadow-blue-500/30"
|
||||
>
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-white opacity-75"></span>
|
||||
<span class="relative inline-flex h-2 w-2 rounded-full bg-white"></span>
|
||||
</span>
|
||||
{{ t('announcements.unread') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h2 class="mb-3 text-2xl font-bold leading-tight text-gray-900 dark:text-white">
|
||||
{{ selectedAnnouncement.title }}
|
||||
</h2>
|
||||
|
||||
<!-- Meta Info -->
|
||||
<div class="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<time>{{ formatRelativeWithDateTime(selectedAnnouncement.created_at) }}</time>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
<span>{{ selectedAnnouncement.read_at ? t('announcements.read') : t('announcements.unread') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Close button -->
|
||||
<button
|
||||
@click="closeDetail"
|
||||
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-white/50 text-gray-500 backdrop-blur-sm transition-all hover:bg-white hover:text-gray-700 hover:shadow-lg dark:bg-dark-700/50 dark:text-gray-400 dark:hover:bg-dark-700 dark:hover:text-gray-300"
|
||||
:aria-label="t('common.close')"
|
||||
>
|
||||
<Icon name="x" size="md" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body with Enhanced Markdown -->
|
||||
<div class="max-h-[60vh] overflow-y-auto bg-white px-8 py-8 dark:bg-dark-800">
|
||||
<!-- Content with decorative border -->
|
||||
<div class="relative">
|
||||
<!-- Decorative left border -->
|
||||
<div class="absolute left-0 top-0 bottom-0 w-1 rounded-full bg-gradient-to-b from-blue-500 via-indigo-500 to-purple-500"></div>
|
||||
|
||||
<div class="pl-6">
|
||||
<div
|
||||
class="markdown-body prose prose-sm max-w-none dark:prose-invert"
|
||||
v-html="renderMarkdown(selectedAnnouncement.content)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer with Actions -->
|
||||
<div class="border-t border-gray-100 bg-gray-50/50 px-8 py-5 dark:border-dark-700 dark:bg-dark-900/30">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ selectedAnnouncement.read_at ? t('announcements.readStatus') : t('announcements.markReadHint') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click="closeDetail"
|
||||
class="rounded-xl border border-gray-300 bg-white px-5 py-2.5 text-sm font-medium text-gray-700 shadow-sm transition-all hover:bg-gray-50 hover:shadow dark:border-dark-600 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
|
||||
>
|
||||
{{ t('common.close') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!selectedAnnouncement.read_at"
|
||||
@click="markAsReadAndClose(selectedAnnouncement.id)"
|
||||
class="rounded-xl bg-gradient-to-r from-blue-600 to-indigo-600 px-5 py-2.5 text-sm font-medium text-white shadow-lg shadow-blue-500/30 transition-all hover:shadow-xl hover:scale-105"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{{ t('announcements.markRead') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { announcementsAPI } from '@/api'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { formatRelativeTime, formatRelativeWithDateTime } from '@/utils/format'
|
||||
import type { UserAnnouncement } from '@/types'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// Configure marked
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
})
|
||||
|
||||
// State
|
||||
const announcements = ref<UserAnnouncement[]>([])
|
||||
const isModalOpen = ref(false)
|
||||
const detailModalOpen = ref(false)
|
||||
const selectedAnnouncement = ref<UserAnnouncement | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
// Computed
|
||||
const unreadCount = computed(() =>
|
||||
announcements.value.filter((a) => !a.read_at).length
|
||||
)
|
||||
|
||||
// Methods
|
||||
function renderMarkdown(content: string): string {
|
||||
if (!content) return ''
|
||||
const html = marked.parse(content) as string
|
||||
return DOMPurify.sanitize(html)
|
||||
}
|
||||
|
||||
async function loadAnnouncements() {
|
||||
try {
|
||||
loading.value = true
|
||||
const allAnnouncements = await announcementsAPI.list(false)
|
||||
announcements.value = allAnnouncements.slice(0, 20)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load announcements:', err)
|
||||
appStore.showError(err?.message || t('common.unknownError'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
isModalOpen.value = true
|
||||
if (announcements.value.length === 0) {
|
||||
loadAnnouncements()
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
isModalOpen.value = false
|
||||
}
|
||||
|
||||
function openDetail(announcement: UserAnnouncement) {
|
||||
selectedAnnouncement.value = announcement
|
||||
detailModalOpen.value = true
|
||||
if (!announcement.read_at) {
|
||||
markAsRead(announcement.id)
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
detailModalOpen.value = false
|
||||
selectedAnnouncement.value = null
|
||||
}
|
||||
|
||||
async function markAsRead(id: number) {
|
||||
try {
|
||||
await announcementsAPI.markRead(id)
|
||||
const announcement = announcements.value.find((a) => a.id === id)
|
||||
if (announcement) {
|
||||
announcement.read_at = new Date().toISOString()
|
||||
}
|
||||
if (selectedAnnouncement.value?.id === id) {
|
||||
selectedAnnouncement.value.read_at = new Date().toISOString()
|
||||
}
|
||||
} catch (err: any) {
|
||||
appStore.showError(err?.message || t('common.unknownError'))
|
||||
}
|
||||
}
|
||||
|
||||
async function markAsReadAndClose(id: number) {
|
||||
await markAsRead(id)
|
||||
appStore.showSuccess(t('announcements.markedAsRead'))
|
||||
closeDetail()
|
||||
}
|
||||
|
||||
async function markAllAsRead() {
|
||||
try {
|
||||
loading.value = true
|
||||
const unreadAnnouncements = announcements.value.filter((a) => !a.read_at)
|
||||
await Promise.all(unreadAnnouncements.map((a) => announcementsAPI.markRead(a.id)))
|
||||
announcements.value.forEach((a) => {
|
||||
if (!a.read_at) {
|
||||
a.read_at = new Date().toISOString()
|
||||
}
|
||||
})
|
||||
appStore.showSuccess(t('announcements.allMarkedAsRead'))
|
||||
} catch (err: any) {
|
||||
appStore.showError(err?.message || t('common.unknownError'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
if (detailModalOpen.value) {
|
||||
closeDetail()
|
||||
} else if (isModalOpen.value) {
|
||||
closeModal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
loadAnnouncements()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
// Restore body overflow in case component is unmounted while modals are open
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
|
||||
watch([isModalOpen, detailModalOpen], ([modal, detail]) => {
|
||||
if (modal || detail) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Modal Animations */
|
||||
.modal-fade-enter-active {
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.modal-fade-leave-active {
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 1, 1);
|
||||
}
|
||||
|
||||
.modal-fade-enter-from,
|
||||
.modal-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-fade-enter-from > div {
|
||||
transform: scale(0.94) translateY(-12px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-fade-leave-to > div {
|
||||
transform: scale(0.96) translateY(-8px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
.overflow-y-auto::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(to bottom, #cbd5e1, #94a3b8);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dark .overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(to bottom, #4b5563, #374151);
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(to bottom, #94a3b8, #64748b);
|
||||
}
|
||||
|
||||
.dark .overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(to bottom, #6b7280, #4b5563);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* Enhanced Markdown Styles */
|
||||
.markdown-body {
|
||||
@apply text-[15px] leading-[1.75];
|
||||
@apply text-gray-700 dark:text-gray-300;
|
||||
}
|
||||
|
||||
.markdown-body h1 {
|
||||
@apply mb-6 mt-8 border-b border-gray-200 pb-3 text-3xl font-bold text-gray-900 dark:border-dark-600 dark:text-white;
|
||||
}
|
||||
|
||||
.markdown-body h2 {
|
||||
@apply mb-4 mt-7 border-b border-gray-100 pb-2 text-2xl font-bold text-gray-900 dark:border-dark-700 dark:text-white;
|
||||
}
|
||||
|
||||
.markdown-body h3 {
|
||||
@apply mb-3 mt-6 text-xl font-semibold text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.markdown-body h4 {
|
||||
@apply mb-2 mt-5 text-lg font-semibold text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.markdown-body p {
|
||||
@apply mb-4 leading-relaxed;
|
||||
}
|
||||
|
||||
.markdown-body a {
|
||||
@apply font-medium text-blue-600 underline decoration-blue-600/30 decoration-2 underline-offset-2 transition-all hover:decoration-blue-600 dark:text-blue-400 dark:decoration-blue-400/30 dark:hover:decoration-blue-400;
|
||||
}
|
||||
|
||||
.markdown-body ul,
|
||||
.markdown-body ol {
|
||||
@apply mb-4 ml-6 space-y-2;
|
||||
}
|
||||
|
||||
.markdown-body ul {
|
||||
@apply list-disc;
|
||||
}
|
||||
|
||||
.markdown-body ol {
|
||||
@apply list-decimal;
|
||||
}
|
||||
|
||||
.markdown-body li {
|
||||
@apply leading-relaxed;
|
||||
@apply pl-2;
|
||||
}
|
||||
|
||||
.markdown-body li::marker {
|
||||
@apply text-blue-600 dark:text-blue-400;
|
||||
}
|
||||
|
||||
.markdown-body blockquote {
|
||||
@apply relative my-5 border-l-4 border-blue-500 bg-blue-50/50 py-3 pl-5 pr-4 italic text-gray-700 dark:border-blue-400 dark:bg-blue-900/10 dark:text-gray-300;
|
||||
}
|
||||
|
||||
.markdown-body blockquote::before {
|
||||
content: '"';
|
||||
@apply absolute -left-1 top-0 text-5xl font-serif text-blue-500/20 dark:text-blue-400/20;
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
@apply rounded-lg bg-gray-100 px-2 py-1 text-[13px] font-mono text-pink-600 dark:bg-dark-700 dark:text-pink-400;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
@apply my-5 overflow-x-auto rounded-xl border border-gray-200 bg-gray-50 p-5 dark:border-dark-600 dark:bg-dark-900/50;
|
||||
}
|
||||
|
||||
.markdown-body pre code {
|
||||
@apply bg-transparent p-0 text-[13px] text-gray-800 dark:text-gray-200;
|
||||
}
|
||||
|
||||
.markdown-body hr {
|
||||
@apply my-8 border-0 border-t-2 border-gray-200 dark:border-dark-700;
|
||||
}
|
||||
|
||||
.markdown-body table {
|
||||
@apply mb-5 w-full overflow-hidden rounded-lg border border-gray-200 dark:border-dark-600;
|
||||
}
|
||||
|
||||
.markdown-body th,
|
||||
.markdown-body td {
|
||||
@apply border-r border-b border-gray-200 px-4 py-3 text-left dark:border-dark-600;
|
||||
}
|
||||
|
||||
.markdown-body th:last-child,
|
||||
.markdown-body td:last-child {
|
||||
@apply border-r-0;
|
||||
}
|
||||
|
||||
.markdown-body tr:last-child td {
|
||||
@apply border-b-0;
|
||||
}
|
||||
|
||||
.markdown-body th {
|
||||
@apply bg-gradient-to-br from-blue-50 to-indigo-50 font-semibold text-gray-900 dark:from-blue-900/20 dark:to-indigo-900/10 dark:text-white;
|
||||
}
|
||||
|
||||
.markdown-body tbody tr {
|
||||
@apply transition-colors hover:bg-gray-50 dark:hover:bg-dark-700/30;
|
||||
}
|
||||
|
||||
.markdown-body img {
|
||||
@apply my-5 max-w-full rounded-xl border border-gray-200 shadow-md dark:border-dark-600;
|
||||
}
|
||||
|
||||
.markdown-body strong {
|
||||
@apply font-semibold text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.markdown-body em {
|
||||
@apply italic text-gray-600 dark:text-gray-400;
|
||||
}
|
||||
</style>
|
||||
@@ -181,6 +181,10 @@ import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<{
|
||||
sort: [key: string, order: 'asc' | 'desc']
|
||||
}>()
|
||||
|
||||
// 表格容器引用
|
||||
const tableWrapperRef = ref<HTMLElement | null>(null)
|
||||
const isScrollable = ref(false)
|
||||
@@ -279,18 +283,149 @@ interface Props {
|
||||
expandableActions?: boolean
|
||||
actionsCount?: number // 操作按钮总数,用于判断是否需要展开功能
|
||||
rowKey?: string | ((row: any) => string | number)
|
||||
/**
|
||||
* Default sort configuration (only applied when there is no persisted sort state)
|
||||
*/
|
||||
defaultSortKey?: string
|
||||
defaultSortOrder?: 'asc' | 'desc'
|
||||
/**
|
||||
* Persist sort state (key + order) to localStorage using this key.
|
||||
* If provided, DataTable will load the stored sort state on mount.
|
||||
*/
|
||||
sortStorageKey?: string
|
||||
/**
|
||||
* Enable server-side sorting mode. When true, clicking sort headers
|
||||
* will emit 'sort' events instead of performing client-side sorting.
|
||||
*/
|
||||
serverSideSort?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
stickyFirstColumn: true,
|
||||
stickyActionsColumn: true,
|
||||
expandableActions: true
|
||||
expandableActions: true,
|
||||
defaultSortOrder: 'asc',
|
||||
serverSideSort: false
|
||||
})
|
||||
|
||||
const sortKey = ref<string>('')
|
||||
const sortOrder = ref<'asc' | 'desc'>('asc')
|
||||
const actionsExpanded = ref(false)
|
||||
|
||||
type PersistedSortState = {
|
||||
key: string
|
||||
order: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
const collator = new Intl.Collator(undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base'
|
||||
})
|
||||
|
||||
const getSortableKeys = () => {
|
||||
const keys = new Set<string>()
|
||||
for (const col of props.columns) {
|
||||
if (col.sortable) keys.add(col.key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
const normalizeSortKey = (candidate: string) => {
|
||||
if (!candidate) return ''
|
||||
const sortableKeys = getSortableKeys()
|
||||
return sortableKeys.has(candidate) ? candidate : ''
|
||||
}
|
||||
|
||||
const normalizeSortOrder = (candidate: any): 'asc' | 'desc' => {
|
||||
return candidate === 'desc' ? 'desc' : 'asc'
|
||||
}
|
||||
|
||||
const readPersistedSortState = (): PersistedSortState | null => {
|
||||
if (!props.sortStorageKey) return null
|
||||
try {
|
||||
const raw = localStorage.getItem(props.sortStorageKey)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as Partial<PersistedSortState>
|
||||
const key = normalizeSortKey(typeof parsed.key === 'string' ? parsed.key : '')
|
||||
if (!key) return null
|
||||
return { key, order: normalizeSortOrder(parsed.order) }
|
||||
} catch (e) {
|
||||
console.error('[DataTable] Failed to read persisted sort state:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const writePersistedSortState = (state: PersistedSortState) => {
|
||||
if (!props.sortStorageKey) return
|
||||
try {
|
||||
localStorage.setItem(props.sortStorageKey, JSON.stringify(state))
|
||||
} catch (e) {
|
||||
console.error('[DataTable] Failed to persist sort state:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const resolveInitialSortState = (): PersistedSortState | null => {
|
||||
const persisted = readPersistedSortState()
|
||||
if (persisted) return persisted
|
||||
|
||||
const key = normalizeSortKey(props.defaultSortKey || '')
|
||||
if (!key) return null
|
||||
return { key, order: normalizeSortOrder(props.defaultSortOrder) }
|
||||
}
|
||||
|
||||
const applySortState = (state: PersistedSortState | null) => {
|
||||
if (!state) return
|
||||
sortKey.value = state.key
|
||||
sortOrder.value = state.order
|
||||
}
|
||||
|
||||
const isNullishOrEmpty = (value: any) => value === null || value === undefined || value === ''
|
||||
|
||||
const toFiniteNumberOrNull = (value: any): number | null => {
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : null
|
||||
if (typeof value === 'boolean') return value ? 1 : 0
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
const n = Number(trimmed)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const toSortableString = (value: any): string => {
|
||||
if (value === null || value === undefined) return ''
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
||||
if (value instanceof Date) return value.toISOString()
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
const compareSortValues = (a: any, b: any): number => {
|
||||
const aEmpty = isNullishOrEmpty(a)
|
||||
const bEmpty = isNullishOrEmpty(b)
|
||||
if (aEmpty && bEmpty) return 0
|
||||
if (aEmpty) return 1
|
||||
if (bEmpty) return -1
|
||||
|
||||
const aNum = toFiniteNumberOrNull(a)
|
||||
const bNum = toFiniteNumberOrNull(b)
|
||||
if (aNum !== null && bNum !== null) {
|
||||
if (aNum === bNum) return 0
|
||||
return aNum < bNum ? -1 : 1
|
||||
}
|
||||
|
||||
const aStr = toSortableString(a)
|
||||
const bStr = toSortableString(b)
|
||||
const res = collator.compare(aStr, bStr)
|
||||
if (res === 0) return 0
|
||||
return res < 0 ? -1 : 1
|
||||
}
|
||||
const resolveRowKey = (row: any, index: number) => {
|
||||
if (typeof props.rowKey === 'function') {
|
||||
const key = props.rowKey(row)
|
||||
@@ -323,26 +458,39 @@ watch(actionsExpanded, async () => {
|
||||
})
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
let newOrder: 'asc' | 'desc' = 'asc'
|
||||
if (sortKey.value === key) {
|
||||
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
newOrder = sortOrder.value === 'asc' ? 'desc' : 'asc'
|
||||
}
|
||||
|
||||
if (props.serverSideSort) {
|
||||
// Server-side sort mode: emit event and update internal state for UI feedback
|
||||
sortKey.value = key
|
||||
sortOrder.value = 'asc'
|
||||
sortOrder.value = newOrder
|
||||
emit('sort', key, newOrder)
|
||||
} else {
|
||||
// Client-side sort mode: just update internal state
|
||||
sortKey.value = key
|
||||
sortOrder.value = newOrder
|
||||
}
|
||||
}
|
||||
|
||||
const sortedData = computed(() => {
|
||||
if (!sortKey.value || !props.data) return props.data
|
||||
// Server-side sort mode: return data as-is (server handles sorting)
|
||||
if (props.serverSideSort || !sortKey.value || !props.data) return props.data
|
||||
|
||||
return [...props.data].sort((a, b) => {
|
||||
const aVal = a[sortKey.value]
|
||||
const bVal = b[sortKey.value]
|
||||
const key = sortKey.value
|
||||
const order = sortOrder.value
|
||||
|
||||
if (aVal === bVal) return 0
|
||||
|
||||
const comparison = aVal > bVal ? 1 : -1
|
||||
return sortOrder.value === 'asc' ? comparison : -comparison
|
||||
})
|
||||
// Stable sort (tie-break with original index) to avoid jitter when values are equal.
|
||||
return props.data
|
||||
.map((row, index) => ({ row, index }))
|
||||
.sort((a, b) => {
|
||||
const cmp = compareSortValues(a.row?.[key], b.row?.[key])
|
||||
if (cmp !== 0) return order === 'asc' ? cmp : -cmp
|
||||
return a.index - b.index
|
||||
})
|
||||
.map(item => item.row)
|
||||
})
|
||||
|
||||
const hasActionsColumn = computed(() => {
|
||||
@@ -396,6 +544,51 @@ const getAdaptivePaddingClass = () => {
|
||||
return 'px-6' // 24px (原始值)
|
||||
}
|
||||
}
|
||||
|
||||
// Init + keep persisted sort state consistent with current columns
|
||||
const didInitSort = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
const initial = resolveInitialSortState()
|
||||
applySortState(initial)
|
||||
didInitSort.value = true
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.columns,
|
||||
() => {
|
||||
// If current sort key is no longer sortable/visible, fall back to default/persisted.
|
||||
const normalized = normalizeSortKey(sortKey.value)
|
||||
if (!sortKey.value) {
|
||||
const initial = resolveInitialSortState()
|
||||
applySortState(initial)
|
||||
return
|
||||
}
|
||||
|
||||
if (!normalized) {
|
||||
const fallback = resolveInitialSortState()
|
||||
if (fallback) {
|
||||
applySortState(fallback)
|
||||
} else {
|
||||
sortKey.value = ''
|
||||
sortOrder.value = 'asc'
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
[sortKey, sortOrder],
|
||||
([nextKey, nextOrder]) => {
|
||||
if (!didInitSort.value) return
|
||||
if (!props.sortStorageKey) return
|
||||
const key = normalizeSortKey(nextKey)
|
||||
if (!key) return
|
||||
writePersistedSortState({ key, order: normalizeSortOrder(nextOrder) })
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -42,13 +42,13 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import GroupBadge from './GroupBadge.vue'
|
||||
import type { Group, GroupPlatform } from '@/types'
|
||||
import type { AdminGroup, GroupPlatform } from '@/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Props {
|
||||
modelValue: number[]
|
||||
groups: Group[]
|
||||
groups: AdminGroup[]
|
||||
platform?: GroupPlatform // Optional platform filter
|
||||
mixedScheduling?: boolean // For antigravity accounts: allow anthropic/gemini groups
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
</p>
|
||||
|
||||
<!-- Page size selector -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<div v-if="showPageSizeSelector" class="flex items-center space-x-2">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300"
|
||||
>{{ t('pagination.perPage') }}:</span
|
||||
>
|
||||
@@ -49,6 +49,22 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showJump" class="flex items-center space-x-2">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('pagination.jumpTo') }}</span>
|
||||
<input
|
||||
v-model="jumpPage"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="totalPages"
|
||||
class="input w-20 text-sm"
|
||||
:placeholder="t('pagination.jumpPlaceholder')"
|
||||
@keyup.enter="submitJump"
|
||||
/>
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="submitJump">
|
||||
{{ t('pagination.jumpAction') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop pagination buttons -->
|
||||
@@ -102,7 +118,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import Select from './Select.vue'
|
||||
@@ -114,6 +130,8 @@ interface Props {
|
||||
page: number
|
||||
pageSize: number
|
||||
pageSizeOptions?: number[]
|
||||
showPageSizeSelector?: boolean
|
||||
showJump?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -122,7 +140,9 @@ interface Emits {
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
pageSizeOptions: () => [10, 20, 50, 100]
|
||||
pageSizeOptions: () => [10, 20, 50, 100],
|
||||
showPageSizeSelector: true,
|
||||
showJump: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
@@ -146,6 +166,8 @@ const pageSizeSelectOptions = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
const jumpPage = ref('')
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const pages: (number | string)[] = []
|
||||
const maxVisible = 7
|
||||
@@ -196,6 +218,16 @@ const handlePageSizeChange = (value: string | number | boolean | null) => {
|
||||
const newPageSize = typeof value === 'string' ? parseInt(value) : value
|
||||
emit('update:pageSize', newPageSize)
|
||||
}
|
||||
|
||||
const submitJump = () => {
|
||||
const value = jumpPage.value.trim()
|
||||
if (!value) return
|
||||
const pageNum = Number.parseInt(value, 10)
|
||||
if (Number.isNaN(pageNum)) return
|
||||
const nextPage = Math.min(Math.max(pageNum, 1), totalPages.value)
|
||||
jumpPage.value = ''
|
||||
goToPage(nextPage)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -13,6 +13,9 @@ A generic data table component with sorting, loading states, and custom cell ren
|
||||
- `columns: Column[]` - Array of column definitions with key, label, sortable, and formatter
|
||||
- `data: any[]` - Array of data objects to display
|
||||
- `loading?: boolean` - Show loading skeleton
|
||||
- `defaultSortKey?: string` - Default sort key (only used if no persisted sort state)
|
||||
- `defaultSortOrder?: 'asc' | 'desc'` - Default sort order (default: `asc`)
|
||||
- `sortStorageKey?: string` - Persist sort state (key + order) to localStorage
|
||||
- `rowKey?: string | (row: any) => string | number` - Row key field or resolver (defaults to `row.id`, falls back to index)
|
||||
|
||||
**Slots:**
|
||||
|
||||
@@ -107,6 +107,9 @@ const icons = {
|
||||
database: 'M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125',
|
||||
cube: 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4',
|
||||
|
||||
// Notification
|
||||
bell: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9',
|
||||
|
||||
// Misc
|
||||
bolt: 'M13 10V3L4 14h7v7l9-11h-7z',
|
||||
sparkles: 'M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z',
|
||||
|
||||
@@ -443,7 +443,7 @@ $env:ANTHROPIC_AUTH_TOKEN="${apiKey}"`
|
||||
}
|
||||
|
||||
function generateGeminiCliContent(baseUrl: string, apiKey: string): FileConfig {
|
||||
const model = 'gemini-2.5-pro'
|
||||
const model = 'gemini-2.0-flash'
|
||||
const modelComment = t('keys.useKeyModal.gemini.modelComment')
|
||||
let path: string
|
||||
let content: string
|
||||
@@ -548,14 +548,22 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
|
||||
}
|
||||
}
|
||||
const geminiModels = {
|
||||
'gemini-3-pro-high': { name: 'Gemini 3 Pro High' },
|
||||
'gemini-3-pro-low': { name: 'Gemini 3 Pro Low' },
|
||||
'gemini-3-pro-preview': { name: 'Gemini 3 Pro Preview' },
|
||||
'gemini-3-pro-image': { name: 'Gemini 3 Pro Image' },
|
||||
'gemini-3-flash': { name: 'Gemini 3 Flash' },
|
||||
'gemini-2.5-flash-thinking': { name: 'Gemini 2.5 Flash Thinking' },
|
||||
'gemini-2.0-flash': { name: 'Gemini 2.0 Flash' },
|
||||
'gemini-2.5-flash': { name: 'Gemini 2.5 Flash' },
|
||||
'gemini-2.5-flash-lite': { name: 'Gemini 2.5 Flash Lite' }
|
||||
'gemini-2.5-pro': { name: 'Gemini 2.5 Pro' },
|
||||
'gemini-3-flash-preview': { name: 'Gemini 3 Flash Preview' },
|
||||
'gemini-3-pro-preview': { name: 'Gemini 3 Pro Preview' }
|
||||
}
|
||||
|
||||
const antigravityGeminiModels = {
|
||||
'gemini-2.5-flash': { name: 'Gemini 2.5 Flash' },
|
||||
'gemini-2.5-flash-lite': { name: 'Gemini 2.5 Flash Lite' },
|
||||
'gemini-2.5-flash-thinking': { name: 'Gemini 2.5 Flash Thinking' },
|
||||
'gemini-3-flash': { name: 'Gemini 3 Flash' },
|
||||
'gemini-3-pro-low': { name: 'Gemini 3 Pro Low' },
|
||||
'gemini-3-pro-high': { name: 'Gemini 3 Pro High' },
|
||||
'gemini-3-pro-preview': { name: 'Gemini 3 Pro Preview' },
|
||||
'gemini-3-pro-image': { name: 'Gemini 3 Pro Image' }
|
||||
}
|
||||
const claudeModels = {
|
||||
'claude-opus-4-5-thinking': { name: 'Claude Opus 4.5 Thinking' },
|
||||
@@ -575,7 +583,7 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
|
||||
} else if (platform === 'antigravity-gemini') {
|
||||
provider[platform].npm = '@ai-sdk/google'
|
||||
provider[platform].name = 'Antigravity (Gemini)'
|
||||
provider[platform].models = geminiModels
|
||||
provider[platform].models = antigravityGeminiModels
|
||||
} else if (platform === 'openai') {
|
||||
provider[platform].models = openaiModels
|
||||
}
|
||||
|
||||
@@ -21,8 +21,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Docs + Language + Subscriptions + Balance + User Dropdown -->
|
||||
<!-- Right: Announcements + Docs + Language + Subscriptions + Balance + User Dropdown -->
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Announcement Bell -->
|
||||
<AnnouncementBell v-if="user" />
|
||||
|
||||
<!-- Docs Link -->
|
||||
<a
|
||||
v-if="docUrl"
|
||||
@@ -210,6 +213,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore, useAuthStore, useOnboardingStore } from '@/stores'
|
||||
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
|
||||
import SubscriptionProgressMini from '@/components/common/SubscriptionProgressMini.vue'
|
||||
import AnnouncementBell from '@/components/common/AnnouncementBell.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -319,6 +319,21 @@ const ServerIcon = {
|
||||
)
|
||||
}
|
||||
|
||||
const BellIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75V9a6 6 0 10-12 0v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const TicketIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
@@ -421,6 +436,16 @@ const userNavItems = computed(() => {
|
||||
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
|
||||
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
|
||||
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
|
||||
...(appStore.cachedPublicSettings?.purchase_subscription_enabled
|
||||
? [
|
||||
{
|
||||
path: '/purchase',
|
||||
label: t('nav.buySubscription'),
|
||||
icon: CreditCardIcon,
|
||||
hideInSimpleMode: true
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
|
||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon }
|
||||
]
|
||||
@@ -433,6 +458,16 @@ const personalNavItems = computed(() => {
|
||||
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
|
||||
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
|
||||
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
|
||||
...(appStore.cachedPublicSettings?.purchase_subscription_enabled
|
||||
? [
|
||||
{
|
||||
path: '/purchase',
|
||||
label: t('nav.buySubscription'),
|
||||
icon: CreditCardIcon,
|
||||
hideInSimpleMode: true
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
|
||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon }
|
||||
]
|
||||
@@ -450,6 +485,7 @@ const adminNavItems = computed(() => {
|
||||
{ path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon, hideInSimpleMode: true },
|
||||
{ path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
|
||||
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
|
||||
{ path: '/admin/announcements', label: t('nav.announcements'), icon: BellIcon },
|
||||
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
|
||||
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true },
|
||||
{ path: '/admin/promo-codes', label: t('nav.promoCodes'), icon: GiftIcon, hideInSimpleMode: true },
|
||||
|
||||
154
frontend/src/components/user/profile/ProfileTotpCard.vue
Normal file
154
frontend/src/components/user/profile/ProfileTotpCard.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{{ t('profile.totp.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('profile.totp.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="px-6 py-6">
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
|
||||
<!-- Feature disabled globally -->
|
||||
<div v-else-if="status && !status.feature_enabled" class="flex items-center gap-4 py-4">
|
||||
<div class="flex-shrink-0 rounded-full bg-gray-100 p-3 dark:bg-dark-700">
|
||||
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('profile.totp.featureDisabled') }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('profile.totp.featureDisabledHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2FA Enabled -->
|
||||
<div v-else-if="status?.enabled" class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex-shrink-0 rounded-full bg-green-100 p-3 dark:bg-green-900/30">
|
||||
<svg class="h-6 w-6 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
{{ t('profile.totp.enabled') }}
|
||||
</p>
|
||||
<p v-if="status.enabled_at" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('profile.totp.enabledAt') }}: {{ formatDate(status.enabled_at) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-danger"
|
||||
@click="showDisableDialog = true"
|
||||
>
|
||||
{{ t('profile.totp.disable') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 2FA Not Enabled -->
|
||||
<div v-else class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex-shrink-0 rounded-full bg-gray-100 p-3 dark:bg-dark-700">
|
||||
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('profile.totp.notEnabled') }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('profile.totp.notEnabledHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="showSetupModal = true"
|
||||
>
|
||||
{{ t('profile.totp.enable') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Setup Modal -->
|
||||
<TotpSetupModal
|
||||
v-if="showSetupModal"
|
||||
@close="showSetupModal = false"
|
||||
@success="handleSetupSuccess"
|
||||
/>
|
||||
|
||||
<!-- Disable Dialog -->
|
||||
<TotpDisableDialog
|
||||
v-if="showDisableDialog"
|
||||
@close="showDisableDialog = false"
|
||||
@success="handleDisableSuccess"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { totpAPI } from '@/api'
|
||||
import type { TotpStatus } from '@/types'
|
||||
import TotpSetupModal from './TotpSetupModal.vue'
|
||||
import TotpDisableDialog from './TotpDisableDialog.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const loading = ref(true)
|
||||
const status = ref<TotpStatus | null>(null)
|
||||
const showSetupModal = ref(false)
|
||||
const showDisableDialog = ref(false)
|
||||
|
||||
const loadStatus = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
status.value = await totpAPI.getStatus()
|
||||
} catch (error) {
|
||||
console.error('Failed to load TOTP status:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetupSuccess = () => {
|
||||
showSetupModal.value = false
|
||||
loadStatus()
|
||||
}
|
||||
|
||||
const handleDisableSuccess = () => {
|
||||
showDisableDialog.value = false
|
||||
loadStatus()
|
||||
}
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
// Backend returns Unix timestamp in seconds, convert to milliseconds
|
||||
const date = new Date(timestamp * 1000)
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStatus()
|
||||
})
|
||||
</script>
|
||||
179
frontend/src/components/user/profile/TotpDisableDialog.vue
Normal file
179
frontend/src/components/user/profile/TotpDisableDialog.vue
Normal file
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto" @click.self="$emit('close')">
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div class="fixed inset-0 bg-black/50 transition-opacity" @click="$emit('close')"></div>
|
||||
|
||||
<div class="relative w-full max-w-md transform rounded-xl bg-white p-6 shadow-xl transition-all dark:bg-dark-800">
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
|
||||
<svg class="h-6 w-6 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mt-4 text-center text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('profile.totp.disableTitle') }}
|
||||
</h3>
|
||||
<p class="mt-2 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('profile.totp.disableWarning') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading verification method -->
|
||||
<div v-if="methodLoading" class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
|
||||
<form v-else @submit.prevent="handleDisable" class="space-y-4">
|
||||
<!-- Email verification -->
|
||||
<div v-if="verificationMethod === 'email'">
|
||||
<label class="input-label">{{ t('profile.totp.emailCode') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="form.emailCode"
|
||||
type="text"
|
||||
maxlength="6"
|
||||
inputmode="numeric"
|
||||
class="input flex-1"
|
||||
:placeholder="t('profile.totp.enterEmailCode')"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary whitespace-nowrap"
|
||||
:disabled="sendingCode || codeCooldown > 0"
|
||||
@click="handleSendCode"
|
||||
>
|
||||
{{ codeCooldown > 0 ? `${codeCooldown}s` : (sendingCode ? t('common.sending') : t('profile.totp.sendCode')) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password verification -->
|
||||
<div v-else>
|
||||
<label for="password" class="input-label">
|
||||
{{ t('profile.currentPassword') }}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="input"
|
||||
:placeholder="t('profile.totp.enterPassword')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="error" class="rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button type="button" class="btn btn-secondary" @click="$emit('close')">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-danger"
|
||||
:disabled="loading || !canSubmit"
|
||||
>
|
||||
{{ loading ? t('common.processing') : t('profile.totp.confirmDisable') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { totpAPI } from '@/api'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const methodLoading = ref(true)
|
||||
const verificationMethod = ref<'email' | 'password'>('password')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const sendingCode = ref(false)
|
||||
const codeCooldown = ref(0)
|
||||
const form = ref({
|
||||
emailCode: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
if (verificationMethod.value === 'email') {
|
||||
return form.value.emailCode.length === 6
|
||||
}
|
||||
return form.value.password.length > 0
|
||||
})
|
||||
|
||||
const loadVerificationMethod = async () => {
|
||||
methodLoading.value = true
|
||||
try {
|
||||
const method = await totpAPI.getVerificationMethod()
|
||||
verificationMethod.value = method.method
|
||||
} catch (err: any) {
|
||||
appStore.showError(err.response?.data?.message || t('common.error'))
|
||||
emit('close')
|
||||
} finally {
|
||||
methodLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendCode = async () => {
|
||||
sendingCode.value = true
|
||||
try {
|
||||
await totpAPI.sendVerifyCode()
|
||||
appStore.showSuccess(t('profile.totp.codeSent'))
|
||||
// Start cooldown
|
||||
codeCooldown.value = 60
|
||||
const timer = setInterval(() => {
|
||||
codeCooldown.value--
|
||||
if (codeCooldown.value <= 0) {
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, 1000)
|
||||
} catch (err: any) {
|
||||
appStore.showError(err.response?.data?.message || t('profile.totp.sendCodeFailed'))
|
||||
} finally {
|
||||
sendingCode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisable = async () => {
|
||||
if (!canSubmit.value) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const request = verificationMethod.value === 'email'
|
||||
? { email_code: form.value.emailCode }
|
||||
: { password: form.value.password }
|
||||
|
||||
await totpAPI.disable(request)
|
||||
appStore.showSuccess(t('profile.totp.disableSuccess'))
|
||||
emit('success')
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.message || t('profile.totp.disableFailed')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadVerificationMethod()
|
||||
})
|
||||
</script>
|
||||
400
frontend/src/components/user/profile/TotpSetupModal.vue
Normal file
400
frontend/src/components/user/profile/TotpSetupModal.vue
Normal file
@@ -0,0 +1,400 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto" @click.self="$emit('close')">
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div class="fixed inset-0 bg-black/50 transition-opacity" @click="$emit('close')"></div>
|
||||
|
||||
<div class="relative w-full max-w-md transform rounded-xl bg-white p-6 shadow-xl transition-all dark:bg-dark-800">
|
||||
<!-- Header -->
|
||||
<div class="mb-6 text-center">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('profile.totp.setupTitle') }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ stepDescription }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 0: Identity Verification -->
|
||||
<div v-if="step === 0" class="space-y-6">
|
||||
<!-- Loading verification method -->
|
||||
<div v-if="methodLoading" class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Email verification -->
|
||||
<div v-if="verificationMethod === 'email'" class="space-y-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('profile.totp.emailCode') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="verifyForm.emailCode"
|
||||
type="text"
|
||||
maxlength="6"
|
||||
inputmode="numeric"
|
||||
class="input flex-1"
|
||||
:placeholder="t('profile.totp.enterEmailCode')"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary whitespace-nowrap"
|
||||
:disabled="sendingCode || codeCooldown > 0"
|
||||
@click="handleSendCode"
|
||||
>
|
||||
{{ codeCooldown > 0 ? `${codeCooldown}s` : (sendingCode ? t('common.sending') : t('profile.totp.sendCode')) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password verification -->
|
||||
<div v-else class="space-y-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('profile.currentPassword') }}</label>
|
||||
<input
|
||||
v-model="verifyForm.password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="input"
|
||||
:placeholder="t('profile.totp.enterPassword')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="verifyError" class="rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
|
||||
{{ verifyError }}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button type="button" class="btn btn-secondary" @click="$emit('close')">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="!canProceedFromVerify || setupLoading"
|
||||
@click="handleVerifyAndSetup"
|
||||
>
|
||||
{{ setupLoading ? t('common.loading') : t('common.next') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Show QR Code -->
|
||||
<div v-if="step === 1" class="space-y-6">
|
||||
<!-- QR Code and Secret -->
|
||||
<template v-if="setupData">
|
||||
<div class="flex justify-center">
|
||||
<div class="rounded-lg border border-gray-200 p-4 bg-white dark:border-dark-600 dark:bg-white">
|
||||
<img :src="qrCodeDataUrl" alt="QR Code" class="h-48 w-48" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||
{{ t('profile.totp.manualEntry') }}
|
||||
</p>
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<code class="rounded bg-gray-100 px-3 py-2 font-mono text-sm dark:bg-dark-700">
|
||||
{{ setupData.secret }}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1.5 text-gray-500 hover:bg-gray-100 dark:hover:bg-dark-700"
|
||||
@click="copySecret"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button type="button" class="btn btn-secondary" @click="$emit('close')">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="!setupData"
|
||||
@click="step = 2"
|
||||
>
|
||||
{{ t('common.next') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Verify Code -->
|
||||
<div v-if="step === 2" class="space-y-6">
|
||||
<form @submit.prevent="handleVerify">
|
||||
<div class="mb-6">
|
||||
<label class="input-label text-center block mb-3">
|
||||
{{ t('profile.totp.enterCode') }}
|
||||
</label>
|
||||
<div class="flex justify-center gap-2">
|
||||
<input
|
||||
v-for="(_, index) in 6"
|
||||
:key="index"
|
||||
:ref="(el) => setInputRef(el, index)"
|
||||
type="text"
|
||||
maxlength="1"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]"
|
||||
class="h-12 w-10 rounded-lg border border-gray-300 text-center text-lg font-semibold focus:border-primary-500 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
|
||||
@input="handleCodeInput($event, index)"
|
||||
@keydown="handleKeydown($event, index)"
|
||||
@paste="handlePaste"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" class="btn btn-secondary" @click="step = 1">
|
||||
{{ t('common.back') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="verifying || code.join('').length !== 6"
|
||||
>
|
||||
{{ verifying ? t('common.verifying') : t('profile.totp.verify') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick, watch, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { totpAPI } from '@/api'
|
||||
import type { TotpSetupResponse } from '@/types'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// Step: 0 = verify identity, 1 = QR code, 2 = verify TOTP code
|
||||
const step = ref(0)
|
||||
const methodLoading = ref(true)
|
||||
const verificationMethod = ref<'email' | 'password'>('password')
|
||||
const verifyForm = ref({ emailCode: '', password: '' })
|
||||
const verifyError = ref('')
|
||||
const sendingCode = ref(false)
|
||||
const codeCooldown = ref(0)
|
||||
|
||||
const setupLoading = ref(false)
|
||||
const setupData = ref<TotpSetupResponse | null>(null)
|
||||
const verifying = ref(false)
|
||||
const error = ref('')
|
||||
const code = ref<string[]>(['', '', '', '', '', ''])
|
||||
const inputRefs = ref<(HTMLInputElement | null)[]>([])
|
||||
const qrCodeDataUrl = ref('')
|
||||
|
||||
const stepDescription = computed(() => {
|
||||
switch (step.value) {
|
||||
case 0:
|
||||
return verificationMethod.value === 'email'
|
||||
? t('profile.totp.verifyEmailFirst')
|
||||
: t('profile.totp.verifyPasswordFirst')
|
||||
case 1:
|
||||
return t('profile.totp.setupStep1')
|
||||
case 2:
|
||||
return t('profile.totp.setupStep2')
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const canProceedFromVerify = computed(() => {
|
||||
if (verificationMethod.value === 'email') {
|
||||
return verifyForm.value.emailCode.length === 6
|
||||
}
|
||||
return verifyForm.value.password.length > 0
|
||||
})
|
||||
|
||||
// Generate QR code as base64 when setupData changes
|
||||
watch(
|
||||
() => setupData.value?.qr_code_url,
|
||||
async (url) => {
|
||||
if (url) {
|
||||
try {
|
||||
qrCodeDataUrl.value = await QRCode.toDataURL(url, {
|
||||
width: 200,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff'
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to generate QR code:', err)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const setInputRef = (el: any, index: number) => {
|
||||
inputRefs.value[index] = el as HTMLInputElement | null
|
||||
}
|
||||
|
||||
const handleCodeInput = (event: Event, index: number) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const value = input.value.replace(/[^0-9]/g, '')
|
||||
code.value[index] = value
|
||||
|
||||
if (value && index < 5) {
|
||||
nextTick(() => {
|
||||
inputRefs.value[index + 1]?.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent, index: number) => {
|
||||
if (event.key === 'Backspace') {
|
||||
const input = event.target as HTMLInputElement
|
||||
// If current cell is empty and not the first, move to previous cell
|
||||
if (!input.value && index > 0) {
|
||||
event.preventDefault()
|
||||
inputRefs.value[index - 1]?.focus()
|
||||
}
|
||||
// Otherwise, let the browser handle the backspace naturally
|
||||
// The input event will sync code.value via handleCodeInput
|
||||
}
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
event.preventDefault()
|
||||
const pastedData = event.clipboardData?.getData('text') || ''
|
||||
const digits = pastedData.replace(/[^0-9]/g, '').slice(0, 6).split('')
|
||||
|
||||
// Update both the ref and the input elements
|
||||
digits.forEach((digit, index) => {
|
||||
code.value[index] = digit
|
||||
if (inputRefs.value[index]) {
|
||||
inputRefs.value[index]!.value = digit
|
||||
}
|
||||
})
|
||||
|
||||
// Clear remaining inputs if pasted less than 6 digits
|
||||
for (let i = digits.length; i < 6; i++) {
|
||||
code.value[i] = ''
|
||||
if (inputRefs.value[i]) {
|
||||
inputRefs.value[i]!.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const focusIndex = Math.min(digits.length, 5)
|
||||
nextTick(() => {
|
||||
inputRefs.value[focusIndex]?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const copySecret = async () => {
|
||||
if (setupData.value) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(setupData.value.secret)
|
||||
appStore.showSuccess(t('common.copied'))
|
||||
} catch {
|
||||
appStore.showError(t('common.copyFailed'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadVerificationMethod = async () => {
|
||||
methodLoading.value = true
|
||||
try {
|
||||
const method = await totpAPI.getVerificationMethod()
|
||||
verificationMethod.value = method.method
|
||||
} catch (err: any) {
|
||||
appStore.showError(err.response?.data?.message || t('common.error'))
|
||||
emit('close')
|
||||
} finally {
|
||||
methodLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendCode = async () => {
|
||||
sendingCode.value = true
|
||||
try {
|
||||
await totpAPI.sendVerifyCode()
|
||||
appStore.showSuccess(t('profile.totp.codeSent'))
|
||||
// Start cooldown
|
||||
codeCooldown.value = 60
|
||||
const timer = setInterval(() => {
|
||||
codeCooldown.value--
|
||||
if (codeCooldown.value <= 0) {
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, 1000)
|
||||
} catch (err: any) {
|
||||
appStore.showError(err.response?.data?.message || t('profile.totp.sendCodeFailed'))
|
||||
} finally {
|
||||
sendingCode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerifyAndSetup = async () => {
|
||||
setupLoading.value = true
|
||||
verifyError.value = ''
|
||||
|
||||
try {
|
||||
const request = verificationMethod.value === 'email'
|
||||
? { email_code: verifyForm.value.emailCode }
|
||||
: { password: verifyForm.value.password }
|
||||
|
||||
setupData.value = await totpAPI.initiateSetup(request)
|
||||
step.value = 1
|
||||
} catch (err: any) {
|
||||
verifyError.value = err.response?.data?.message || t('profile.totp.setupFailed')
|
||||
} finally {
|
||||
setupLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerify = async () => {
|
||||
const totpCode = code.value.join('')
|
||||
if (totpCode.length !== 6 || !setupData.value) return
|
||||
|
||||
verifying.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
await totpAPI.enable({
|
||||
totp_code: totpCode,
|
||||
setup_token: setupData.value.setup_token
|
||||
})
|
||||
appStore.showSuccess(t('profile.totp.enableSuccess'))
|
||||
emit('success')
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.message || t('profile.totp.verifyFailed')
|
||||
code.value = ['', '', '', '', '', '']
|
||||
nextTick(() => {
|
||||
inputRefs.value[0]?.focus()
|
||||
})
|
||||
} finally {
|
||||
verifying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadVerificationMethod()
|
||||
})
|
||||
</script>
|
||||
@@ -17,6 +17,7 @@ export interface OAuthState {
|
||||
export interface TokenInfo {
|
||||
org_uuid?: string
|
||||
account_uuid?: string
|
||||
email_address?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
@@ -160,6 +161,9 @@ export function useAccountOAuth() {
|
||||
if (tokenInfo.account_uuid) {
|
||||
extra.account_uuid = tokenInfo.account_uuid
|
||||
}
|
||||
if (tokenInfo.email_address) {
|
||||
extra.email_address = tokenInfo.email_address
|
||||
}
|
||||
return Object.keys(extra).length > 0 ? extra : undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -43,13 +43,13 @@ export const claudeModels = [
|
||||
|
||||
// Google Gemini
|
||||
const geminiModels = [
|
||||
'gemini-2.0-flash', 'gemini-2.0-flash-lite-preview', 'gemini-2.0-flash-exp',
|
||||
'gemini-2.0-pro-exp', 'gemini-2.0-flash-thinking-exp',
|
||||
'gemini-2.5-pro-exp-03-25', 'gemini-2.5-pro-preview-03-25',
|
||||
'gemini-3-pro-preview',
|
||||
'gemini-1.5-pro', 'gemini-1.5-pro-latest',
|
||||
'gemini-1.5-flash', 'gemini-1.5-flash-latest', 'gemini-1.5-flash-8b',
|
||||
'gemini-exp-1206'
|
||||
// Keep in sync with backend curated Gemini lists.
|
||||
// This list is intentionally conservative (models commonly available across OAuth/API key).
|
||||
'gemini-2.0-flash',
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-pro',
|
||||
'gemini-3-flash-preview',
|
||||
'gemini-3-pro-preview'
|
||||
]
|
||||
|
||||
// 智谱 GLM
|
||||
@@ -229,9 +229,8 @@ const openaiPresetMappings = [
|
||||
|
||||
const geminiPresetMappings = [
|
||||
{ label: 'Flash 2.0', from: 'gemini-2.0-flash', to: 'gemini-2.0-flash', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
|
||||
{ label: 'Flash Lite', from: 'gemini-2.0-flash-lite-preview', to: 'gemini-2.0-flash-lite-preview', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
||||
{ label: '1.5 Pro', from: 'gemini-1.5-pro', to: 'gemini-1.5-pro', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||
{ label: '1.5 Flash', from: 'gemini-1.5-flash', to: 'gemini-1.5-flash', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' }
|
||||
{ label: '2.5 Flash', from: 'gemini-2.5-flash', to: 'gemini-2.5-flash', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
||||
{ label: '2.5 Pro', from: 'gemini-2.5-pro', to: 'gemini-2.5-pro', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }
|
||||
]
|
||||
|
||||
// =====================
|
||||
|
||||
@@ -69,7 +69,9 @@ export default {
|
||||
port: 'Port',
|
||||
password: 'Password (optional)',
|
||||
database: 'Database',
|
||||
passwordPlaceholder: 'Password'
|
||||
passwordPlaceholder: 'Password',
|
||||
enableTls: 'Enable TLS',
|
||||
enableTlsHint: 'Use TLS when connecting to Redis (public CA certs)'
|
||||
},
|
||||
admin: {
|
||||
title: 'Admin Account',
|
||||
@@ -146,7 +148,10 @@ export default {
|
||||
balance: 'Balance',
|
||||
available: 'Available',
|
||||
copiedToClipboard: 'Copied to clipboard',
|
||||
copied: 'Copied',
|
||||
copyFailed: 'Failed to copy',
|
||||
verifying: 'Verifying...',
|
||||
processing: 'Processing...',
|
||||
contactSupport: 'Contact Support',
|
||||
add: 'Add',
|
||||
invalidEmail: 'Please enter a valid email address',
|
||||
@@ -169,13 +174,20 @@ export default {
|
||||
justNow: 'Just now',
|
||||
minutesAgo: '{n}m ago',
|
||||
hoursAgo: '{n}h ago',
|
||||
daysAgo: '{n}d ago'
|
||||
daysAgo: '{n}d ago',
|
||||
countdown: {
|
||||
daysHours: '{d}d {h}h',
|
||||
hoursMinutes: '{h}h {m}m',
|
||||
minutes: '{m}m',
|
||||
withSuffix: '{time} to lift'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Navigation
|
||||
nav: {
|
||||
dashboard: 'Dashboard',
|
||||
announcements: 'Announcements',
|
||||
apiKeys: 'API Keys',
|
||||
usage: 'Usage',
|
||||
redeem: 'Redeem',
|
||||
@@ -197,6 +209,7 @@ export default {
|
||||
logout: 'Logout',
|
||||
github: 'GitHub',
|
||||
mySubscriptions: 'My Subscriptions',
|
||||
buySubscription: 'Purchase Subscription',
|
||||
docs: 'Docs'
|
||||
},
|
||||
|
||||
@@ -265,7 +278,36 @@ export default {
|
||||
code: 'Code',
|
||||
state: 'State',
|
||||
fullUrl: 'Full URL'
|
||||
}
|
||||
},
|
||||
// Forgot password
|
||||
forgotPassword: 'Forgot password?',
|
||||
forgotPasswordTitle: 'Reset Your Password',
|
||||
forgotPasswordHint: 'Enter your email address and we will send you a link to reset your password.',
|
||||
sendResetLink: 'Send Reset Link',
|
||||
sendingResetLink: 'Sending...',
|
||||
sendResetLinkFailed: 'Failed to send reset link. Please try again.',
|
||||
resetEmailSent: 'Reset Link Sent',
|
||||
resetEmailSentHint: 'If an account exists with this email, you will receive a password reset link shortly. Please check your inbox and spam folder.',
|
||||
backToLogin: 'Back to Login',
|
||||
rememberedPassword: 'Remembered your password?',
|
||||
// Reset password
|
||||
resetPasswordTitle: 'Set New Password',
|
||||
resetPasswordHint: 'Enter your new password below.',
|
||||
newPassword: 'New Password',
|
||||
newPasswordPlaceholder: 'Enter your new password',
|
||||
confirmPassword: 'Confirm Password',
|
||||
confirmPasswordPlaceholder: 'Confirm your new password',
|
||||
confirmPasswordRequired: 'Please confirm your password',
|
||||
passwordsDoNotMatch: 'Passwords do not match',
|
||||
resetPassword: 'Reset Password',
|
||||
resettingPassword: 'Resetting...',
|
||||
resetPasswordFailed: 'Failed to reset password. Please try again.',
|
||||
passwordResetSuccess: 'Password Reset Successful',
|
||||
passwordResetSuccessHint: 'Your password has been reset. You can now sign in with your new password.',
|
||||
invalidResetLink: 'Invalid Reset Link',
|
||||
invalidResetLinkHint: 'This password reset link is invalid or has expired. Please request a new one.',
|
||||
requestNewResetLink: 'Request New Reset Link',
|
||||
invalidOrExpiredToken: 'The password reset link is invalid or has expired. Please request a new one.'
|
||||
},
|
||||
|
||||
// Dashboard
|
||||
@@ -548,7 +590,46 @@ export default {
|
||||
passwordsNotMatch: 'New passwords do not match',
|
||||
passwordTooShort: 'Password must be at least 8 characters long',
|
||||
passwordChangeSuccess: 'Password changed successfully',
|
||||
passwordChangeFailed: 'Failed to change password'
|
||||
passwordChangeFailed: 'Failed to change password',
|
||||
// TOTP 2FA
|
||||
totp: {
|
||||
title: 'Two-Factor Authentication (2FA)',
|
||||
description: 'Enhance account security with Google Authenticator or similar apps',
|
||||
enabled: 'Enabled',
|
||||
enabledAt: 'Enabled at',
|
||||
notEnabled: 'Not Enabled',
|
||||
notEnabledHint: 'Enable two-factor authentication to enhance account security',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
featureDisabled: 'Feature Unavailable',
|
||||
featureDisabledHint: 'Two-factor authentication has not been enabled by the administrator',
|
||||
setupTitle: 'Set Up Two-Factor Authentication',
|
||||
setupStep1: 'Scan the QR code below with your authenticator app',
|
||||
setupStep2: 'Enter the 6-digit code from your app',
|
||||
manualEntry: "Can't scan? Enter the key manually:",
|
||||
enterCode: 'Enter 6-digit code',
|
||||
verify: 'Verify',
|
||||
setupFailed: 'Failed to get setup information',
|
||||
verifyFailed: 'Invalid code, please try again',
|
||||
enableSuccess: 'Two-factor authentication enabled',
|
||||
disableTitle: 'Disable Two-Factor Authentication',
|
||||
disableWarning: 'After disabling, you will no longer need a verification code to log in. This may reduce your account security.',
|
||||
enterPassword: 'Enter your current password to confirm',
|
||||
confirmDisable: 'Confirm Disable',
|
||||
disableSuccess: 'Two-factor authentication disabled',
|
||||
disableFailed: 'Failed to disable, please check your password',
|
||||
loginTitle: 'Two-Factor Authentication',
|
||||
loginHint: 'Enter the 6-digit code from your authenticator app',
|
||||
loginFailed: 'Verification failed, please try again',
|
||||
// New translations for email verification
|
||||
verifyEmailFirst: 'Please verify your email first',
|
||||
verifyPasswordFirst: 'Please verify your identity first',
|
||||
emailCode: 'Email Verification Code',
|
||||
enterEmailCode: 'Enter 6-digit code',
|
||||
sendCode: 'Send Code',
|
||||
codeSent: 'Verification code sent to your email',
|
||||
sendCodeFailed: 'Failed to send verification code'
|
||||
}
|
||||
},
|
||||
|
||||
// Empty States
|
||||
@@ -573,7 +654,10 @@ export default {
|
||||
previous: 'Previous',
|
||||
next: 'Next',
|
||||
perPage: 'Per page',
|
||||
goToPage: 'Go to page {page}'
|
||||
goToPage: 'Go to page {page}',
|
||||
jumpTo: 'Jump to',
|
||||
jumpPlaceholder: 'Page',
|
||||
jumpAction: 'Go'
|
||||
},
|
||||
|
||||
// Errors
|
||||
@@ -674,6 +758,7 @@ export default {
|
||||
updating: 'Updating...',
|
||||
columns: {
|
||||
user: 'User',
|
||||
email: 'Email',
|
||||
username: 'Username',
|
||||
notes: 'Notes',
|
||||
role: 'Role',
|
||||
@@ -957,7 +1042,7 @@ export default {
|
||||
title: 'Subscription Management',
|
||||
description: 'Manage user subscriptions and quota limits',
|
||||
assignSubscription: 'Assign Subscription',
|
||||
extendSubscription: 'Extend Subscription',
|
||||
adjustSubscription: 'Adjust Subscription',
|
||||
revokeSubscription: 'Revoke Subscription',
|
||||
allStatus: 'All Status',
|
||||
allGroups: 'All Groups',
|
||||
@@ -972,6 +1057,7 @@ export default {
|
||||
resetInHoursMinutes: 'Resets in {hours}h {minutes}m',
|
||||
resetInDaysHours: 'Resets in {days}d {hours}h',
|
||||
daysRemaining: 'days remaining',
|
||||
remainingDays: 'Remaining days',
|
||||
noExpiration: 'No expiration',
|
||||
status: {
|
||||
active: 'Active',
|
||||
@@ -990,28 +1076,32 @@ export default {
|
||||
user: 'User',
|
||||
group: 'Subscription Group',
|
||||
validityDays: 'Validity (Days)',
|
||||
extendDays: 'Extend by (Days)'
|
||||
adjustDays: 'Adjust by (Days)'
|
||||
},
|
||||
selectUser: 'Select a user',
|
||||
selectGroup: 'Select a subscription group',
|
||||
groupHint: 'Only groups with subscription billing type are shown',
|
||||
validityHint: 'Number of days the subscription will be valid',
|
||||
extendingFor: 'Extending subscription for',
|
||||
adjustingFor: 'Adjusting subscription for',
|
||||
currentExpiration: 'Current expiration',
|
||||
adjustDaysPlaceholder: 'Positive to extend, negative to shorten',
|
||||
adjustHint: 'Enter positive number to extend, negative to shorten (remaining days must be > 0)',
|
||||
assign: 'Assign',
|
||||
assigning: 'Assigning...',
|
||||
extend: 'Extend',
|
||||
extending: 'Extending...',
|
||||
adjust: 'Adjust',
|
||||
adjusting: 'Adjusting...',
|
||||
revoke: 'Revoke',
|
||||
noSubscriptionsYet: 'No subscriptions yet',
|
||||
assignFirstSubscription: 'Assign a subscription to get started.',
|
||||
subscriptionAssigned: 'Subscription assigned successfully',
|
||||
subscriptionExtended: 'Subscription extended successfully',
|
||||
subscriptionAdjusted: 'Subscription adjusted successfully',
|
||||
subscriptionRevoked: 'Subscription revoked successfully',
|
||||
failedToLoad: 'Failed to load subscriptions',
|
||||
failedToAssign: 'Failed to assign subscription',
|
||||
failedToExtend: 'Failed to extend subscription',
|
||||
failedToAdjust: 'Failed to adjust subscription',
|
||||
failedToRevoke: 'Failed to revoke subscription',
|
||||
adjustWouldExpire: 'Remaining days after adjustment must be greater than 0',
|
||||
adjustOutOfRange: 'Adjustment days must be between -36500 and 36500',
|
||||
pleaseSelectUser: 'Please select a user',
|
||||
pleaseSelectGroup: 'Please select a group',
|
||||
validityDaysRequired: 'Please enter a valid number of days (at least 1)',
|
||||
@@ -1024,6 +1114,13 @@ export default {
|
||||
title: 'Account Management',
|
||||
description: 'Manage AI platform accounts and credentials',
|
||||
createAccount: 'Create Account',
|
||||
autoRefresh: 'Auto Refresh',
|
||||
enableAutoRefresh: 'Enable auto refresh',
|
||||
refreshInterval5s: '5 seconds',
|
||||
refreshInterval10s: '10 seconds',
|
||||
refreshInterval15s: '15 seconds',
|
||||
refreshInterval30s: '30 seconds',
|
||||
autoRefreshCountdown: 'Auto refresh: {seconds}s',
|
||||
syncFromCrs: 'Sync from CRS',
|
||||
syncFromCrsTitle: 'Sync Accounts from CRS',
|
||||
syncFromCrsDesc:
|
||||
@@ -1085,6 +1182,8 @@ export default {
|
||||
cooldown: 'Cooldown',
|
||||
paused: 'Paused',
|
||||
limited: 'Limited',
|
||||
rateLimited: 'Rate Limited',
|
||||
overloaded: 'Overloaded',
|
||||
tempUnschedulable: 'Temp Unschedulable',
|
||||
rateLimitedUntil: 'Rate limited until {time}',
|
||||
scopeRateLimitedUntil: '{scope} rate limited until {time}',
|
||||
@@ -1106,6 +1205,7 @@ export default {
|
||||
todayStats: 'Today Stats',
|
||||
groups: 'Groups',
|
||||
usageWindows: 'Usage Windows',
|
||||
proxy: 'Proxy',
|
||||
lastUsed: 'Last Used',
|
||||
expiresAt: 'Expires At',
|
||||
actions: 'Actions'
|
||||
@@ -1296,6 +1396,14 @@ export default {
|
||||
idleTimeout: 'Idle Timeout',
|
||||
idleTimeoutPlaceholder: '5',
|
||||
idleTimeoutHint: 'Sessions will be released after idle timeout'
|
||||
},
|
||||
tlsFingerprint: {
|
||||
label: 'TLS Fingerprint Simulation',
|
||||
hint: 'Simulate Node.js/Claude Code client TLS fingerprint'
|
||||
},
|
||||
sessionIdMasking: {
|
||||
label: 'Session ID Masking',
|
||||
hint: 'When enabled, fixes the session ID in metadata.user_id for 15 minutes, making upstream think requests come from the same session'
|
||||
}
|
||||
},
|
||||
expired: 'Expired',
|
||||
@@ -1858,6 +1966,73 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
// Announcements
|
||||
announcements: {
|
||||
title: 'Announcements',
|
||||
description: 'Create announcements and target by conditions',
|
||||
createAnnouncement: 'Create Announcement',
|
||||
editAnnouncement: 'Edit Announcement',
|
||||
deleteAnnouncement: 'Delete Announcement',
|
||||
searchAnnouncements: 'Search announcements...',
|
||||
status: 'Status',
|
||||
allStatus: 'All Status',
|
||||
columns: {
|
||||
title: 'Title',
|
||||
status: 'Status',
|
||||
targeting: 'Targeting',
|
||||
timeRange: 'Schedule',
|
||||
createdAt: 'Created At',
|
||||
actions: 'Actions'
|
||||
},
|
||||
statusLabels: {
|
||||
draft: 'Draft',
|
||||
active: 'Active',
|
||||
archived: 'Archived'
|
||||
},
|
||||
form: {
|
||||
title: 'Title',
|
||||
content: 'Content (Markdown supported)',
|
||||
status: 'Status',
|
||||
startsAt: 'Starts At',
|
||||
endsAt: 'Ends At',
|
||||
startsAtHint: 'Leave empty to start immediately',
|
||||
endsAtHint: 'Leave empty to never expire',
|
||||
targetingMode: 'Targeting',
|
||||
targetingAll: 'All users',
|
||||
targetingCustom: 'Custom rules',
|
||||
addOrGroup: 'Add OR group',
|
||||
addAndCondition: 'Add AND condition',
|
||||
conditionType: 'Condition type',
|
||||
conditionSubscription: 'Subscription',
|
||||
conditionBalance: 'Balance',
|
||||
operator: 'Operator',
|
||||
balanceValue: 'Balance threshold',
|
||||
selectPackages: 'Select packages'
|
||||
},
|
||||
operators: {
|
||||
gt: '>',
|
||||
gte: '≥',
|
||||
lt: '<',
|
||||
lte: '≤',
|
||||
eq: '='
|
||||
},
|
||||
targetingSummaryAll: 'All users',
|
||||
targetingSummaryCustom: 'Custom ({groups} groups)',
|
||||
timeImmediate: 'Immediate',
|
||||
timeNever: 'Never',
|
||||
readStatus: 'Read Status',
|
||||
eligible: 'Eligible',
|
||||
readAt: 'Read at',
|
||||
unread: 'Unread',
|
||||
searchUsers: 'Search users...',
|
||||
failedToLoad: 'Failed to load announcements',
|
||||
failedToCreate: 'Failed to create announcement',
|
||||
failedToUpdate: 'Failed to update announcement',
|
||||
failedToDelete: 'Failed to delete announcement',
|
||||
failedToLoadReadStatus: 'Failed to load read status',
|
||||
deleteConfirm: 'Are you sure you want to delete this announcement? This action cannot be undone.'
|
||||
},
|
||||
|
||||
// Promo Codes
|
||||
promo: {
|
||||
title: 'Promo Code Management',
|
||||
@@ -1944,7 +2119,43 @@ export default {
|
||||
cacheCreationTokens: 'Cache Creation Tokens',
|
||||
cacheReadTokens: 'Cache Read Tokens',
|
||||
failedToLoad: 'Failed to load usage records',
|
||||
ipAddress: 'IP'
|
||||
billingType: 'Billing Type',
|
||||
allBillingTypes: 'All Billing Types',
|
||||
billingTypeBalance: 'Balance',
|
||||
billingTypeSubscription: 'Subscription',
|
||||
ipAddress: 'IP',
|
||||
cleanup: {
|
||||
button: 'Cleanup',
|
||||
title: 'Cleanup Usage Records',
|
||||
warning: 'Cleanup is irreversible and will affect historical stats.',
|
||||
submit: 'Submit Cleanup',
|
||||
submitting: 'Submitting...',
|
||||
confirmTitle: 'Confirm Cleanup',
|
||||
confirmMessage: 'Are you sure you want to submit this cleanup task? This action cannot be undone.',
|
||||
confirmSubmit: 'Confirm Cleanup',
|
||||
cancel: 'Cancel',
|
||||
cancelConfirmTitle: 'Confirm Cancel',
|
||||
cancelConfirmMessage: 'Are you sure you want to cancel this cleanup task?',
|
||||
cancelConfirm: 'Confirm Cancel',
|
||||
cancelSuccess: 'Cleanup task canceled',
|
||||
cancelFailed: 'Failed to cancel cleanup task',
|
||||
recentTasks: 'Recent Cleanup Tasks',
|
||||
loadingTasks: 'Loading tasks...',
|
||||
noTasks: 'No cleanup tasks yet',
|
||||
range: 'Range',
|
||||
deletedRows: 'Deleted',
|
||||
missingRange: 'Please select a date range',
|
||||
submitSuccess: 'Cleanup task created',
|
||||
submitFailed: 'Failed to create cleanup task',
|
||||
loadFailed: 'Failed to load cleanup tasks',
|
||||
status: {
|
||||
pending: 'Pending',
|
||||
running: 'Running',
|
||||
succeeded: 'Succeeded',
|
||||
failed: 'Failed',
|
||||
canceled: 'Canceled'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Ops Monitoring
|
||||
@@ -2597,6 +2808,8 @@ export default {
|
||||
ignoreContextCanceledHint: 'When enabled, client disconnect (context canceled) errors will not be written to the error log.',
|
||||
ignoreNoAvailableAccounts: 'Ignore no available accounts errors',
|
||||
ignoreNoAvailableAccountsHint: 'When enabled, "No available accounts" errors will not be written to the error log (not recommended; usually a config issue).',
|
||||
ignoreInvalidApiKeyErrors: 'Ignore invalid API key errors',
|
||||
ignoreInvalidApiKeyErrorsHint: 'When enabled, invalid or missing API key errors (INVALID_API_KEY, API_KEY_REQUIRED) will not be written to the error log.',
|
||||
autoRefresh: 'Auto Refresh',
|
||||
enableAutoRefresh: 'Enable auto refresh',
|
||||
enableAutoRefreshHint: 'Automatically refresh dashboard data at a fixed interval.',
|
||||
@@ -2690,7 +2903,15 @@ export default {
|
||||
enableRegistration: 'Enable Registration',
|
||||
enableRegistrationHint: 'Allow new users to register',
|
||||
emailVerification: 'Email Verification',
|
||||
emailVerificationHint: 'Require email verification for new registrations'
|
||||
emailVerificationHint: 'Require email verification for new registrations',
|
||||
promoCode: 'Promo Code',
|
||||
promoCodeHint: 'Allow users to use promo codes during registration',
|
||||
passwordReset: 'Password Reset',
|
||||
passwordResetHint: 'Allow users to reset their password via email',
|
||||
totp: 'Two-Factor Authentication (2FA)',
|
||||
totpHint: 'Allow users to use authenticator apps like Google Authenticator',
|
||||
totpKeyNotConfigured:
|
||||
'Please configure TOTP_ENCRYPTION_KEY in environment variables first. Generate a key with: openssl rand -hex 32'
|
||||
},
|
||||
turnstile: {
|
||||
title: 'Cloudflare Turnstile',
|
||||
@@ -2760,7 +2981,20 @@ export default {
|
||||
homeContent: 'Home Page Content',
|
||||
homeContentPlaceholder: 'Enter custom content for the home page. Supports Markdown & HTML. If a URL is entered, it will be displayed as an iframe.',
|
||||
homeContentHint: 'Customize the home page content. Supports Markdown/HTML. If you enter a URL (starting with http:// or https://), it will be used as an iframe src to embed an external page. When set, the default status information will no longer be displayed.',
|
||||
homeContentIframeWarning: '⚠️ iframe mode note: Some websites have X-Frame-Options or CSP security policies that prevent embedding in iframes. If the page appears blank or shows an error, please verify the target website allows embedding, or consider using HTML mode to build your own content.'
|
||||
homeContentIframeWarning: '⚠️ iframe mode note: Some websites have X-Frame-Options or CSP security policies that prevent embedding in iframes. If the page appears blank or shows an error, please verify the target website allows embedding, or consider using HTML mode to build your own content.',
|
||||
hideCcsImportButton: 'Hide CCS Import Button',
|
||||
hideCcsImportButtonHint: 'When enabled, the "Import to CCS" button will be hidden on the API Keys page'
|
||||
},
|
||||
purchase: {
|
||||
title: 'Purchase Page',
|
||||
description: 'Show a "Purchase Subscription" entry in the sidebar and open the configured URL in an iframe',
|
||||
enabled: 'Show Purchase Entry',
|
||||
enabledHint: 'Only shown in standard mode (not simple mode)',
|
||||
url: 'Purchase URL',
|
||||
urlPlaceholder: 'https://example.com/purchase',
|
||||
urlHint: 'Must be an absolute http(s) URL',
|
||||
iframeWarning:
|
||||
'⚠️ iframe note: Some websites block embedding via X-Frame-Options or CSP (frame-ancestors). If the page is blank, provide an "Open in new tab" alternative.'
|
||||
},
|
||||
smtp: {
|
||||
title: 'SMTP Settings',
|
||||
@@ -2907,6 +3141,42 @@ export default {
|
||||
retry: 'Retry'
|
||||
},
|
||||
|
||||
// Purchase Subscription Page
|
||||
purchase: {
|
||||
title: 'Purchase Subscription',
|
||||
description: 'Purchase a subscription via the embedded page',
|
||||
openInNewTab: 'Open in new tab',
|
||||
notEnabledTitle: 'Feature not enabled',
|
||||
notEnabledDesc: 'The administrator has not enabled the purchase page. Please contact admin.',
|
||||
notConfiguredTitle: 'Purchase URL not configured',
|
||||
notConfiguredDesc:
|
||||
'The administrator enabled the entry but has not configured a purchase URL. Please contact admin.'
|
||||
},
|
||||
|
||||
// Announcements Page
|
||||
announcements: {
|
||||
title: 'Announcements',
|
||||
description: 'View system announcements',
|
||||
unreadOnly: 'Show unread only',
|
||||
markRead: 'Mark as read',
|
||||
markAllRead: 'Mark all as read',
|
||||
viewAll: 'View all announcements',
|
||||
markedAsRead: 'Marked as read',
|
||||
allMarkedAsRead: 'All announcements marked as read',
|
||||
newCount: '{count} new announcement | {count} new announcements',
|
||||
readAt: 'Read at',
|
||||
read: 'Read',
|
||||
unread: 'Unread',
|
||||
startsAt: 'Starts at',
|
||||
endsAt: 'Ends at',
|
||||
empty: 'No announcements',
|
||||
emptyUnread: 'No unread announcements',
|
||||
total: 'announcements',
|
||||
emptyDescription: 'There are no system announcements at this time',
|
||||
readStatus: 'You have read this announcement',
|
||||
markReadHint: 'Click "Mark as read" to mark this announcement'
|
||||
},
|
||||
|
||||
// User Subscriptions Page
|
||||
userSubscriptions: {
|
||||
title: 'My Subscriptions',
|
||||
|
||||
@@ -66,7 +66,9 @@ export default {
|
||||
port: '端口',
|
||||
password: '密码(可选)',
|
||||
database: '数据库',
|
||||
passwordPlaceholder: '密码'
|
||||
passwordPlaceholder: '密码',
|
||||
enableTls: '启用 TLS',
|
||||
enableTlsHint: '连接 Redis 时使用 TLS(公共 CA 证书)'
|
||||
},
|
||||
admin: {
|
||||
title: '管理员账户',
|
||||
@@ -143,7 +145,10 @@ export default {
|
||||
balance: '余额',
|
||||
available: '可用',
|
||||
copiedToClipboard: '已复制到剪贴板',
|
||||
copied: '已复制',
|
||||
copyFailed: '复制失败',
|
||||
verifying: '验证中...',
|
||||
processing: '处理中...',
|
||||
contactSupport: '联系客服',
|
||||
add: '添加',
|
||||
invalidEmail: '请输入有效的邮箱地址',
|
||||
@@ -166,13 +171,20 @@ export default {
|
||||
justNow: '刚刚',
|
||||
minutesAgo: '{n}分钟前',
|
||||
hoursAgo: '{n}小时前',
|
||||
daysAgo: '{n}天前'
|
||||
daysAgo: '{n}天前',
|
||||
countdown: {
|
||||
daysHours: '{d}d {h}h',
|
||||
hoursMinutes: '{h}h {m}m',
|
||||
minutes: '{m}m',
|
||||
withSuffix: '{time} 后解除'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Navigation
|
||||
nav: {
|
||||
dashboard: '仪表盘',
|
||||
announcements: '公告',
|
||||
apiKeys: 'API 密钥',
|
||||
usage: '使用记录',
|
||||
redeem: '兑换',
|
||||
@@ -194,6 +206,7 @@ export default {
|
||||
logout: '退出登录',
|
||||
github: 'GitHub',
|
||||
mySubscriptions: '我的订阅',
|
||||
buySubscription: '购买订阅',
|
||||
docs: '文档'
|
||||
},
|
||||
|
||||
@@ -262,7 +275,36 @@ export default {
|
||||
code: '授权码',
|
||||
state: '状态',
|
||||
fullUrl: '完整URL'
|
||||
}
|
||||
},
|
||||
// 忘记密码
|
||||
forgotPassword: '忘记密码?',
|
||||
forgotPasswordTitle: '重置密码',
|
||||
forgotPasswordHint: '输入您的邮箱地址,我们将向您发送密码重置链接。',
|
||||
sendResetLink: '发送重置链接',
|
||||
sendingResetLink: '发送中...',
|
||||
sendResetLinkFailed: '发送重置链接失败,请重试。',
|
||||
resetEmailSent: '重置链接已发送',
|
||||
resetEmailSentHint: '如果该邮箱已注册,您将很快收到密码重置链接。请检查您的收件箱和垃圾邮件文件夹。',
|
||||
backToLogin: '返回登录',
|
||||
rememberedPassword: '想起密码了?',
|
||||
// 重置密码
|
||||
resetPasswordTitle: '设置新密码',
|
||||
resetPasswordHint: '请在下方输入您的新密码。',
|
||||
newPassword: '新密码',
|
||||
newPasswordPlaceholder: '输入新密码',
|
||||
confirmPassword: '确认密码',
|
||||
confirmPasswordPlaceholder: '再次输入新密码',
|
||||
confirmPasswordRequired: '请确认您的密码',
|
||||
passwordsDoNotMatch: '两次输入的密码不一致',
|
||||
resetPassword: '重置密码',
|
||||
resettingPassword: '重置中...',
|
||||
resetPasswordFailed: '重置密码失败,请重试。',
|
||||
passwordResetSuccess: '密码重置成功',
|
||||
passwordResetSuccessHint: '您的密码已重置。现在可以使用新密码登录。',
|
||||
invalidResetLink: '无效的重置链接',
|
||||
invalidResetLinkHint: '此密码重置链接无效或已过期。请重新请求一个新链接。',
|
||||
requestNewResetLink: '请求新的重置链接',
|
||||
invalidOrExpiredToken: '密码重置链接无效或已过期。请重新请求一个新链接。'
|
||||
},
|
||||
|
||||
// Dashboard
|
||||
@@ -544,7 +586,46 @@ export default {
|
||||
passwordsNotMatch: '两次输入的密码不一致',
|
||||
passwordTooShort: '密码至少需要 8 个字符',
|
||||
passwordChangeSuccess: '密码修改成功',
|
||||
passwordChangeFailed: '密码修改失败'
|
||||
passwordChangeFailed: '密码修改失败',
|
||||
// TOTP 2FA
|
||||
totp: {
|
||||
title: '双因素认证 (2FA)',
|
||||
description: '使用 Google Authenticator 等应用增强账户安全',
|
||||
enabled: '已启用',
|
||||
enabledAt: '启用时间',
|
||||
notEnabled: '未启用',
|
||||
notEnabledHint: '启用双因素认证可以增强账户安全性',
|
||||
enable: '启用',
|
||||
disable: '禁用',
|
||||
featureDisabled: '功能未开放',
|
||||
featureDisabledHint: '管理员尚未开放双因素认证功能',
|
||||
setupTitle: '设置双因素认证',
|
||||
setupStep1: '使用认证器应用扫描下方二维码',
|
||||
setupStep2: '输入应用显示的 6 位验证码',
|
||||
manualEntry: '无法扫码?手动输入密钥:',
|
||||
enterCode: '输入 6 位验证码',
|
||||
verify: '验证',
|
||||
setupFailed: '获取设置信息失败',
|
||||
verifyFailed: '验证码错误,请重试',
|
||||
enableSuccess: '双因素认证已启用',
|
||||
disableTitle: '禁用双因素认证',
|
||||
disableWarning: '禁用后,登录时将不再需要验证码。这可能会降低您的账户安全性。',
|
||||
enterPassword: '请输入当前密码确认',
|
||||
confirmDisable: '确认禁用',
|
||||
disableSuccess: '双因素认证已禁用',
|
||||
disableFailed: '禁用失败,请检查密码是否正确',
|
||||
loginTitle: '双因素认证',
|
||||
loginHint: '请输入您认证器应用显示的 6 位验证码',
|
||||
loginFailed: '验证失败,请重试',
|
||||
// New translations for email verification
|
||||
verifyEmailFirst: '请先验证您的邮箱',
|
||||
verifyPasswordFirst: '请先验证您的身份',
|
||||
emailCode: '邮箱验证码',
|
||||
enterEmailCode: '请输入 6 位验证码',
|
||||
sendCode: '发送验证码',
|
||||
codeSent: '验证码已发送到您的邮箱',
|
||||
sendCodeFailed: '发送验证码失败'
|
||||
}
|
||||
},
|
||||
|
||||
// Empty States
|
||||
@@ -569,7 +650,10 @@ export default {
|
||||
previous: '上一页',
|
||||
next: '下一页',
|
||||
perPage: '每页',
|
||||
goToPage: '跳转到第 {page} 页'
|
||||
goToPage: '跳转到第 {page} 页',
|
||||
jumpTo: '跳转页',
|
||||
jumpPlaceholder: '页码',
|
||||
jumpAction: '跳转'
|
||||
},
|
||||
|
||||
// Errors
|
||||
@@ -1033,7 +1117,7 @@ export default {
|
||||
title: '订阅管理',
|
||||
description: '管理用户订阅和配额限制',
|
||||
assignSubscription: '分配订阅',
|
||||
extendSubscription: '延长订阅',
|
||||
adjustSubscription: '调整订阅',
|
||||
revokeSubscription: '撤销订阅',
|
||||
allStatus: '全部状态',
|
||||
allGroups: '全部分组',
|
||||
@@ -1048,6 +1132,7 @@ export default {
|
||||
resetInHoursMinutes: '{hours} 小时 {minutes} 分钟后重置',
|
||||
resetInDaysHours: '{days} 天 {hours} 小时后重置',
|
||||
daysRemaining: '天剩余',
|
||||
remainingDays: '剩余天数',
|
||||
noExpiration: '无过期时间',
|
||||
status: {
|
||||
active: '生效中',
|
||||
@@ -1066,28 +1151,32 @@ export default {
|
||||
user: '用户',
|
||||
group: '订阅分组',
|
||||
validityDays: '有效期(天)',
|
||||
extendDays: '延长天数'
|
||||
adjustDays: '调整天数'
|
||||
},
|
||||
selectUser: '选择用户',
|
||||
selectGroup: '选择订阅分组',
|
||||
groupHint: '仅显示订阅计费类型的分组',
|
||||
validityHint: '订阅的有效天数',
|
||||
extendingFor: '为以下用户延长订阅',
|
||||
adjustingFor: '为以下用户调整订阅',
|
||||
currentExpiration: '当前到期时间',
|
||||
adjustDaysPlaceholder: '正数延长,负数缩短',
|
||||
adjustHint: '输入正数延长订阅,负数缩短订阅(缩短后剩余天数需大于0)',
|
||||
assign: '分配',
|
||||
assigning: '分配中...',
|
||||
extend: '延长',
|
||||
extending: '延长中...',
|
||||
adjust: '调整',
|
||||
adjusting: '调整中...',
|
||||
revoke: '撤销',
|
||||
noSubscriptionsYet: '暂无订阅',
|
||||
assignFirstSubscription: '分配一个订阅以开始使用。',
|
||||
subscriptionAssigned: '订阅分配成功',
|
||||
subscriptionExtended: '订阅延长成功',
|
||||
subscriptionAdjusted: '订阅调整成功',
|
||||
subscriptionRevoked: '订阅撤销成功',
|
||||
failedToLoad: '加载订阅列表失败',
|
||||
failedToAssign: '分配订阅失败',
|
||||
failedToExtend: '延长订阅失败',
|
||||
failedToAdjust: '调整订阅失败',
|
||||
failedToRevoke: '撤销订阅失败',
|
||||
adjustWouldExpire: '调整后剩余天数必须大于0',
|
||||
adjustOutOfRange: '调整天数必须在 -36500 到 36500 之间',
|
||||
pleaseSelectUser: '请选择用户',
|
||||
pleaseSelectGroup: '请选择分组',
|
||||
validityDaysRequired: '请输入有效的天数(至少1天)',
|
||||
@@ -1099,6 +1188,13 @@ export default {
|
||||
title: '账号管理',
|
||||
description: '管理 AI 平台账号和 Cookie',
|
||||
createAccount: '添加账号',
|
||||
autoRefresh: '自动刷新',
|
||||
enableAutoRefresh: '启用自动刷新',
|
||||
refreshInterval5s: '5 秒',
|
||||
refreshInterval10s: '10 秒',
|
||||
refreshInterval15s: '15 秒',
|
||||
refreshInterval30s: '30 秒',
|
||||
autoRefreshCountdown: '自动刷新:{seconds}s',
|
||||
syncFromCrs: '从 CRS 同步',
|
||||
syncFromCrsTitle: '从 CRS 同步账号',
|
||||
syncFromCrsDesc:
|
||||
@@ -1154,6 +1250,7 @@ export default {
|
||||
todayStats: '今日统计',
|
||||
groups: '分组',
|
||||
usageWindows: '用量窗口',
|
||||
proxy: '代理',
|
||||
lastUsed: '最近使用',
|
||||
expiresAt: '过期时间',
|
||||
actions: '操作'
|
||||
@@ -1207,6 +1304,8 @@ export default {
|
||||
cooldown: '冷却中',
|
||||
paused: '暂停',
|
||||
limited: '限流',
|
||||
rateLimited: '限流中',
|
||||
overloaded: '过载中',
|
||||
tempUnschedulable: '临时不可调度',
|
||||
rateLimitedUntil: '限流中,重置时间:{time}',
|
||||
scopeRateLimitedUntil: '{scope} 限流中,重置时间:{time}',
|
||||
@@ -1429,6 +1528,14 @@ export default {
|
||||
idleTimeout: '空闲超时',
|
||||
idleTimeoutPlaceholder: '5',
|
||||
idleTimeoutHint: '会话空闲超时后自动释放'
|
||||
},
|
||||
tlsFingerprint: {
|
||||
label: 'TLS 指纹模拟',
|
||||
hint: '模拟 Node.js/Claude Code 客户端的 TLS 指纹'
|
||||
},
|
||||
sessionIdMasking: {
|
||||
label: '会话 ID 伪装',
|
||||
hint: '启用后将在 15 分钟内固定 metadata.user_id 中的 session ID,使上游认为请求来自同一会话'
|
||||
}
|
||||
},
|
||||
expired: '已过期',
|
||||
@@ -2006,6 +2113,73 @@ export default {
|
||||
failedToDelete: '删除兑换码失败'
|
||||
},
|
||||
|
||||
// Announcements
|
||||
announcements: {
|
||||
title: '公告管理',
|
||||
description: '创建公告并按条件投放',
|
||||
createAnnouncement: '创建公告',
|
||||
editAnnouncement: '编辑公告',
|
||||
deleteAnnouncement: '删除公告',
|
||||
searchAnnouncements: '搜索公告...',
|
||||
status: '状态',
|
||||
allStatus: '全部状态',
|
||||
columns: {
|
||||
title: '标题',
|
||||
status: '状态',
|
||||
targeting: '展示条件',
|
||||
timeRange: '有效期',
|
||||
createdAt: '创建时间',
|
||||
actions: '操作'
|
||||
},
|
||||
statusLabels: {
|
||||
draft: '草稿',
|
||||
active: '展示中',
|
||||
archived: '已归档'
|
||||
},
|
||||
form: {
|
||||
title: '标题',
|
||||
content: '内容(支持 Markdown)',
|
||||
status: '状态',
|
||||
startsAt: '开始时间',
|
||||
endsAt: '结束时间',
|
||||
startsAtHint: '留空表示立即生效',
|
||||
endsAtHint: '留空表示永久生效',
|
||||
targetingMode: '展示条件',
|
||||
targetingAll: '所有用户',
|
||||
targetingCustom: '按条件',
|
||||
addOrGroup: '添加 OR 条件组',
|
||||
addAndCondition: '添加 AND 条件',
|
||||
conditionType: '条件类型',
|
||||
conditionSubscription: '订阅套餐',
|
||||
conditionBalance: '余额',
|
||||
operator: '运算符',
|
||||
balanceValue: '余额阈值',
|
||||
selectPackages: '选择套餐'
|
||||
},
|
||||
operators: {
|
||||
gt: '>',
|
||||
gte: '≥',
|
||||
lt: '<',
|
||||
lte: '≤',
|
||||
eq: '='
|
||||
},
|
||||
targetingSummaryAll: '全部用户',
|
||||
targetingSummaryCustom: '自定义({groups} 组)',
|
||||
timeImmediate: '立即',
|
||||
timeNever: '永久',
|
||||
readStatus: '已读情况',
|
||||
eligible: '符合条件',
|
||||
readAt: '已读时间',
|
||||
unread: '未读',
|
||||
searchUsers: '搜索用户...',
|
||||
failedToLoad: '加载公告失败',
|
||||
failedToCreate: '创建公告失败',
|
||||
failedToUpdate: '更新公告失败',
|
||||
failedToDelete: '删除公告失败',
|
||||
failedToLoadReadStatus: '加载已读情况失败',
|
||||
deleteConfirm: '确定要删除该公告吗?此操作无法撤销。'
|
||||
},
|
||||
|
||||
// Promo Codes
|
||||
promo: {
|
||||
title: '优惠码管理',
|
||||
@@ -2092,7 +2266,43 @@ export default {
|
||||
cacheCreationTokens: '缓存创建 Token',
|
||||
cacheReadTokens: '缓存读取 Token',
|
||||
failedToLoad: '加载使用记录失败',
|
||||
ipAddress: 'IP'
|
||||
billingType: '计费类型',
|
||||
allBillingTypes: '全部计费类型',
|
||||
billingTypeBalance: '钱包余额',
|
||||
billingTypeSubscription: '订阅套餐',
|
||||
ipAddress: 'IP',
|
||||
cleanup: {
|
||||
button: '清理',
|
||||
title: '清理使用记录',
|
||||
warning: '清理不可恢复,且会影响历史统计回看。',
|
||||
submit: '提交清理',
|
||||
submitting: '提交中...',
|
||||
confirmTitle: '确认清理',
|
||||
confirmMessage: '确定要提交清理任务吗?清理不可恢复。',
|
||||
confirmSubmit: '确认清理',
|
||||
cancel: '取消任务',
|
||||
cancelConfirmTitle: '确认取消',
|
||||
cancelConfirmMessage: '确定要取消该清理任务吗?',
|
||||
cancelConfirm: '确认取消',
|
||||
cancelSuccess: '清理任务已取消',
|
||||
cancelFailed: '取消清理任务失败',
|
||||
recentTasks: '最近清理任务',
|
||||
loadingTasks: '正在加载任务...',
|
||||
noTasks: '暂无清理任务',
|
||||
range: '时间范围',
|
||||
deletedRows: '删除数量',
|
||||
missingRange: '请选择时间范围',
|
||||
submitSuccess: '清理任务已创建',
|
||||
submitFailed: '创建清理任务失败',
|
||||
loadFailed: '加载清理任务失败',
|
||||
status: {
|
||||
pending: '待执行',
|
||||
running: '执行中',
|
||||
succeeded: '已完成',
|
||||
failed: '失败',
|
||||
canceled: '已取消'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Ops Monitoring
|
||||
@@ -2750,7 +2960,9 @@ export default {
|
||||
ignoreContextCanceled: '忽略客户端断连错误',
|
||||
ignoreContextCanceledHint: '启用后,客户端主动断开连接(context canceled)的错误将不会写入错误日志。',
|
||||
ignoreNoAvailableAccounts: '忽略无可用账号错误',
|
||||
ignoreNoAvailableAccountsHint: '启用后,“No available accounts” 错误将不会写入错误日志(不推荐,这通常是配置问题)。',
|
||||
ignoreNoAvailableAccountsHint: '启用后,"No available accounts" 错误将不会写入错误日志(不推荐,这通常是配置问题)。',
|
||||
ignoreInvalidApiKeyErrors: '忽略无效 API Key 错误',
|
||||
ignoreInvalidApiKeyErrorsHint: '启用后,无效或缺失 API Key 的错误(INVALID_API_KEY、API_KEY_REQUIRED)将不会写入错误日志。',
|
||||
autoRefresh: '自动刷新',
|
||||
enableAutoRefresh: '启用自动刷新',
|
||||
enableAutoRefreshHint: '自动刷新仪表板数据,启用后会定期拉取最新数据。',
|
||||
@@ -2844,7 +3056,15 @@ export default {
|
||||
enableRegistration: '开放注册',
|
||||
enableRegistrationHint: '允许新用户注册',
|
||||
emailVerification: '邮箱验证',
|
||||
emailVerificationHint: '新用户注册时需要验证邮箱'
|
||||
emailVerificationHint: '新用户注册时需要验证邮箱',
|
||||
promoCode: '优惠码',
|
||||
promoCodeHint: '允许用户在注册时使用优惠码',
|
||||
passwordReset: '忘记密码',
|
||||
passwordResetHint: '允许用户通过邮箱重置密码',
|
||||
totp: '双因素认证 (2FA)',
|
||||
totpHint: '允许用户使用 Google Authenticator 等应用进行二次验证',
|
||||
totpKeyNotConfigured:
|
||||
'请先在环境变量中配置 TOTP_ENCRYPTION_KEY。使用命令 openssl rand -hex 32 生成密钥。'
|
||||
},
|
||||
turnstile: {
|
||||
title: 'Cloudflare Turnstile',
|
||||
@@ -2912,7 +3132,20 @@ export default {
|
||||
homeContent: '首页内容',
|
||||
homeContentPlaceholder: '在此输入首页内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性。',
|
||||
homeContentHint: '自定义首页内容,支持 Markdown/HTML。如果输入的是链接(以 http:// 或 https:// 开头),则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。设置后首页的状态信息将不再显示。',
|
||||
homeContentIframeWarning: '⚠️ iframe 模式提示:部分网站设置了 X-Frame-Options 或 CSP 安全策略,禁止被嵌入到 iframe 中。如果页面显示空白或报错,请确认目标网站允许被嵌入,或考虑使用 HTML 模式自行构建页面内容。'
|
||||
homeContentIframeWarning: '⚠️ iframe 模式提示:部分网站设置了 X-Frame-Options 或 CSP 安全策略,禁止被嵌入到 iframe 中。如果页面显示空白或报错,请确认目标网站允许被嵌入,或考虑使用 HTML 模式自行构建页面内容。',
|
||||
hideCcsImportButton: '隐藏 CCS 导入按钮',
|
||||
hideCcsImportButtonHint: '启用后将在 API Keys 页面隐藏"导入 CCS"按钮'
|
||||
},
|
||||
purchase: {
|
||||
title: '购买订阅页面',
|
||||
description: '在侧边栏展示“购买订阅”入口,并在页面内通过 iframe 打开指定链接',
|
||||
enabled: '显示购买订阅入口',
|
||||
enabledHint: '仅在标准模式(非简单模式)下展示',
|
||||
url: '购买页面 URL',
|
||||
urlPlaceholder: 'https://example.com/purchase',
|
||||
urlHint: '必须是完整的 http(s) 链接',
|
||||
iframeWarning:
|
||||
'⚠️ iframe 提示:部分网站会通过 X-Frame-Options 或 CSP(frame-ancestors)禁止被 iframe 嵌入,出现空白时可引导用户使用“新窗口打开”。'
|
||||
},
|
||||
smtp: {
|
||||
title: 'SMTP 设置',
|
||||
@@ -3058,6 +3291,41 @@ export default {
|
||||
retry: '重试'
|
||||
},
|
||||
|
||||
// Purchase Subscription Page
|
||||
purchase: {
|
||||
title: '购买订阅',
|
||||
description: '通过内嵌页面完成订阅购买',
|
||||
openInNewTab: '新窗口打开',
|
||||
notEnabledTitle: '该功能未开启',
|
||||
notEnabledDesc: '管理员暂未开启购买订阅入口,请联系管理员。',
|
||||
notConfiguredTitle: '购买链接未配置',
|
||||
notConfiguredDesc: '管理员已开启入口,但尚未配置购买订阅链接,请联系管理员。'
|
||||
},
|
||||
|
||||
// Announcements Page
|
||||
announcements: {
|
||||
title: '公告',
|
||||
description: '查看系统公告',
|
||||
unreadOnly: '仅显示未读',
|
||||
markRead: '标记已读',
|
||||
markAllRead: '全部已读',
|
||||
viewAll: '查看全部公告',
|
||||
markedAsRead: '已标记为已读',
|
||||
allMarkedAsRead: '所有公告已标记为已读',
|
||||
newCount: '有 {count} 条新公告',
|
||||
readAt: '已读时间',
|
||||
read: '已读',
|
||||
unread: '未读',
|
||||
startsAt: '开始时间',
|
||||
endsAt: '结束时间',
|
||||
empty: '暂无公告',
|
||||
emptyUnread: '暂无未读公告',
|
||||
total: '条公告',
|
||||
emptyDescription: '暂时没有任何系统公告',
|
||||
readStatus: '您已阅读此公告',
|
||||
markReadHint: '点击"已读"标记此公告'
|
||||
},
|
||||
|
||||
// User Subscriptions Page
|
||||
userSubscriptions: {
|
||||
title: '我的订阅',
|
||||
|
||||
@@ -79,6 +79,24 @@ const routes: RouteRecordRaw[] = [
|
||||
title: 'LinuxDo OAuth Callback'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
name: 'ForgotPassword',
|
||||
component: () => import('@/views/auth/ForgotPasswordView.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: 'Forgot Password'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/reset-password',
|
||||
name: 'ResetPassword',
|
||||
component: () => import('@/views/auth/ResetPasswordView.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: 'Reset Password'
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== User Routes ====================
|
||||
{
|
||||
@@ -157,6 +175,18 @@ const routes: RouteRecordRaw[] = [
|
||||
descriptionKey: 'userSubscriptions.description'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/purchase',
|
||||
name: 'PurchaseSubscription',
|
||||
component: () => import('@/views/user/PurchaseSubscriptionView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: false,
|
||||
title: 'Purchase Subscription',
|
||||
titleKey: 'purchase.title',
|
||||
descriptionKey: 'purchase.description'
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== Admin Routes ====================
|
||||
{
|
||||
@@ -235,6 +265,18 @@ const routes: RouteRecordRaw[] = [
|
||||
descriptionKey: 'admin.accounts.description'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/announcements',
|
||||
name: 'AdminAnnouncements',
|
||||
component: () => import('@/views/admin/AnnouncementsView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: 'Announcements',
|
||||
titleKey: 'admin.announcements.title',
|
||||
descriptionKey: 'admin.announcements.description'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/proxies',
|
||||
name: 'AdminProxies',
|
||||
|
||||
@@ -312,6 +312,8 @@ export const useAppStore = defineStore('app', () => {
|
||||
return {
|
||||
registration_enabled: false,
|
||||
email_verify_enabled: false,
|
||||
promo_code_enabled: true,
|
||||
password_reset_enabled: false,
|
||||
turnstile_enabled: false,
|
||||
turnstile_site_key: '',
|
||||
site_name: siteName.value,
|
||||
@@ -321,6 +323,9 @@ export const useAppStore = defineStore('app', () => {
|
||||
contact_info: contactInfo.value,
|
||||
doc_url: docUrl.value,
|
||||
home_content: '',
|
||||
hide_ccs_import_button: false,
|
||||
purchase_subscription_enabled: false,
|
||||
purchase_subscription_url: '',
|
||||
linuxdo_oauth_enabled: false,
|
||||
version: siteVersion.value
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, readonly } from 'vue'
|
||||
import { authAPI } from '@/api'
|
||||
import type { User, LoginRequest, RegisterRequest } from '@/types'
|
||||
import { authAPI, isTotp2FARequired, type LoginResponse } from '@/api'
|
||||
import type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types'
|
||||
|
||||
const AUTH_TOKEN_KEY = 'auth_token'
|
||||
const AUTH_USER_KEY = 'auth_user'
|
||||
@@ -91,32 +91,23 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
/**
|
||||
* User login
|
||||
* @param credentials - Login credentials (username and password)
|
||||
* @returns Promise resolving to the authenticated user
|
||||
* @param credentials - Login credentials (email and password)
|
||||
* @returns Promise resolving to the login response (may require 2FA)
|
||||
* @throws Error if login fails
|
||||
*/
|
||||
async function login(credentials: LoginRequest): Promise<User> {
|
||||
async function login(credentials: LoginRequest): Promise<LoginResponse> {
|
||||
try {
|
||||
const response = await authAPI.login(credentials)
|
||||
|
||||
// Store token and user
|
||||
token.value = response.access_token
|
||||
|
||||
// Extract run_mode if present
|
||||
if (response.user.run_mode) {
|
||||
runMode.value = response.user.run_mode
|
||||
// If 2FA is required, return the response without setting auth state
|
||||
if (isTotp2FARequired(response)) {
|
||||
return response
|
||||
}
|
||||
const { run_mode: _run_mode, ...userData } = response.user
|
||||
user.value = userData
|
||||
|
||||
// Persist to localStorage
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, response.access_token)
|
||||
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(userData))
|
||||
// Set auth state from the response
|
||||
setAuthFromResponse(response)
|
||||
|
||||
// Start auto-refresh interval
|
||||
startAutoRefresh()
|
||||
|
||||
return userData
|
||||
return response
|
||||
} catch (error) {
|
||||
// Clear any partial state on error
|
||||
clearAuth()
|
||||
@@ -124,6 +115,47 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete login with 2FA code
|
||||
* @param tempToken - Temporary token from initial login
|
||||
* @param totpCode - 6-digit TOTP code
|
||||
* @returns Promise resolving to the authenticated user
|
||||
* @throws Error if 2FA verification fails
|
||||
*/
|
||||
async function login2FA(tempToken: string, totpCode: string): Promise<User> {
|
||||
try {
|
||||
const response = await authAPI.login2FA({ temp_token: tempToken, totp_code: totpCode })
|
||||
setAuthFromResponse(response)
|
||||
return user.value!
|
||||
} catch (error) {
|
||||
clearAuth()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set auth state from an AuthResponse
|
||||
* Internal helper function
|
||||
*/
|
||||
function setAuthFromResponse(response: AuthResponse): void {
|
||||
// Store token and user
|
||||
token.value = response.access_token
|
||||
|
||||
// Extract run_mode if present
|
||||
if (response.user.run_mode) {
|
||||
runMode.value = response.user.run_mode
|
||||
}
|
||||
const { run_mode: _run_mode, ...userData } = response.user
|
||||
user.value = userData
|
||||
|
||||
// Persist to localStorage
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, response.access_token)
|
||||
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(userData))
|
||||
|
||||
// Start auto-refresh interval
|
||||
startAutoRefresh()
|
||||
}
|
||||
|
||||
/**
|
||||
* User registration
|
||||
* @param userData - Registration data (username, email, password)
|
||||
@@ -253,6 +285,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
// Actions
|
||||
login,
|
||||
login2FA,
|
||||
register,
|
||||
setToken,
|
||||
logout,
|
||||
|
||||
@@ -27,7 +27,6 @@ export interface FetchOptions {
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
notes: string
|
||||
email: string
|
||||
role: 'admin' | 'user' // User role for authorization
|
||||
balance: number // User balance for API usage
|
||||
@@ -39,6 +38,11 @@ export interface User {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AdminUser extends User {
|
||||
// 管理员备注(普通用户接口不返回)
|
||||
notes: string
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string
|
||||
password: string
|
||||
@@ -66,6 +70,8 @@ export interface SendVerifyCodeResponse {
|
||||
export interface PublicSettings {
|
||||
registration_enabled: boolean
|
||||
email_verify_enabled: boolean
|
||||
promo_code_enabled: boolean
|
||||
password_reset_enabled: boolean
|
||||
turnstile_enabled: boolean
|
||||
turnstile_site_key: string
|
||||
site_name: string
|
||||
@@ -75,6 +81,9 @@ export interface PublicSettings {
|
||||
contact_info: string
|
||||
doc_url: string
|
||||
home_content: string
|
||||
hide_ccs_import_button: boolean
|
||||
purchase_subscription_enabled: boolean
|
||||
purchase_subscription_url: string
|
||||
linuxdo_oauth_enabled: boolean
|
||||
version: string
|
||||
}
|
||||
@@ -120,6 +129,81 @@ export interface UpdateSubscriptionRequest {
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
// ==================== Announcement Types ====================
|
||||
|
||||
export type AnnouncementStatus = 'draft' | 'active' | 'archived'
|
||||
|
||||
export type AnnouncementConditionType = 'subscription' | 'balance'
|
||||
|
||||
export type AnnouncementOperator = 'in' | 'gt' | 'gte' | 'lt' | 'lte' | 'eq'
|
||||
|
||||
export interface AnnouncementCondition {
|
||||
type: AnnouncementConditionType
|
||||
operator: AnnouncementOperator
|
||||
group_ids?: number[]
|
||||
value?: number
|
||||
}
|
||||
|
||||
export interface AnnouncementConditionGroup {
|
||||
all_of?: AnnouncementCondition[]
|
||||
}
|
||||
|
||||
export interface AnnouncementTargeting {
|
||||
any_of?: AnnouncementConditionGroup[]
|
||||
}
|
||||
|
||||
export interface Announcement {
|
||||
id: number
|
||||
title: string
|
||||
content: string
|
||||
status: AnnouncementStatus
|
||||
targeting: AnnouncementTargeting
|
||||
starts_at?: string
|
||||
ends_at?: string
|
||||
created_by?: number
|
||||
updated_by?: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface UserAnnouncement {
|
||||
id: number
|
||||
title: string
|
||||
content: string
|
||||
starts_at?: string
|
||||
ends_at?: string
|
||||
read_at?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CreateAnnouncementRequest {
|
||||
title: string
|
||||
content: string
|
||||
status?: AnnouncementStatus
|
||||
targeting: AnnouncementTargeting
|
||||
starts_at?: number
|
||||
ends_at?: number
|
||||
}
|
||||
|
||||
export interface UpdateAnnouncementRequest {
|
||||
title?: string
|
||||
content?: string
|
||||
status?: AnnouncementStatus
|
||||
targeting?: AnnouncementTargeting
|
||||
starts_at?: number
|
||||
ends_at?: number
|
||||
}
|
||||
|
||||
export interface AnnouncementUserReadStatus {
|
||||
user_id: number
|
||||
email: string
|
||||
username: string
|
||||
balance: number
|
||||
eligible: boolean
|
||||
read_at?: string
|
||||
}
|
||||
|
||||
// ==================== Proxy Node Types ====================
|
||||
|
||||
export interface ProxyNode {
|
||||
@@ -270,14 +354,18 @@ export interface Group {
|
||||
claude_code_only: boolean
|
||||
fallback_group_id: number | null
|
||||
fallback_group_id_on_invalid_request: number | null
|
||||
// 模型路由配置(仅 anthropic 平台使用)
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AdminGroup extends Group {
|
||||
// 模型路由配置(仅管理员可见,内部信息)
|
||||
model_routing: Record<string, number[]> | null
|
||||
model_routing_enabled: boolean
|
||||
|
||||
// MCP XML 协议注入(仅 antigravity 平台使用)
|
||||
mcp_xml_inject: boolean
|
||||
account_count?: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ApiKey {
|
||||
@@ -488,6 +576,13 @@ export interface Account {
|
||||
max_sessions?: number | null
|
||||
session_idle_timeout_minutes?: number | null
|
||||
|
||||
// TLS指纹伪装(仅 Anthropic OAuth/SetupToken 账号有效)
|
||||
enable_tls_fingerprint?: boolean | null
|
||||
|
||||
// 会话ID伪装(仅 Anthropic OAuth/SetupToken 账号有效)
|
||||
// 启用后将在15分钟内固定 metadata.user_id 中的 session ID
|
||||
session_id_masking_enabled?: boolean | null
|
||||
|
||||
// 运行时状态(仅当启用对应限制时返回)
|
||||
current_window_cost?: number | null // 当前窗口费用
|
||||
active_sessions?: number | null // 当前活跃会话数
|
||||
@@ -637,7 +732,7 @@ export interface UsageLog {
|
||||
total_cost: number
|
||||
actual_cost: number
|
||||
rate_multiplier: number
|
||||
account_rate_multiplier?: number | null
|
||||
billing_type: number
|
||||
|
||||
stream: boolean
|
||||
duration_ms: number
|
||||
@@ -650,18 +745,57 @@ export interface UsageLog {
|
||||
// User-Agent
|
||||
user_agent: string | null
|
||||
|
||||
// IP 地址(仅管理员可见)
|
||||
ip_address: string | null
|
||||
|
||||
created_at: string
|
||||
|
||||
user?: User
|
||||
api_key?: ApiKey
|
||||
account?: Account
|
||||
group?: Group
|
||||
subscription?: UserSubscription
|
||||
}
|
||||
|
||||
export interface UsageLogAccountSummary {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface AdminUsageLog extends UsageLog {
|
||||
// 账号计费倍率(仅管理员可见)
|
||||
account_rate_multiplier?: number | null
|
||||
|
||||
// 用户请求 IP(仅管理员可见)
|
||||
ip_address?: string | null
|
||||
|
||||
// 最小账号信息(仅管理员接口返回)
|
||||
account?: UsageLogAccountSummary
|
||||
}
|
||||
|
||||
export interface UsageCleanupFilters {
|
||||
start_time: string
|
||||
end_time: string
|
||||
user_id?: number
|
||||
api_key_id?: number
|
||||
account_id?: number
|
||||
group_id?: number
|
||||
model?: string | null
|
||||
stream?: boolean | null
|
||||
billing_type?: number | null
|
||||
}
|
||||
|
||||
export interface UsageCleanupTask {
|
||||
id: number
|
||||
status: string
|
||||
filters: UsageCleanupFilters
|
||||
created_by: number
|
||||
deleted_rows: number
|
||||
error_message?: string | null
|
||||
canceled_by?: number | null
|
||||
canceled_at?: string | null
|
||||
started_at?: string | null
|
||||
finished_at?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface RedeemCode {
|
||||
id: number
|
||||
code: string
|
||||
@@ -885,6 +1019,7 @@ export interface UsageQueryParams {
|
||||
group_id?: number
|
||||
model?: string
|
||||
stream?: boolean
|
||||
billing_type?: number | null
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}
|
||||
@@ -1057,3 +1192,52 @@ export interface UpdatePromoCodeRequest {
|
||||
expires_at?: number | null
|
||||
notes?: string
|
||||
}
|
||||
|
||||
// ==================== TOTP (2FA) Types ====================
|
||||
|
||||
export interface TotpStatus {
|
||||
enabled: boolean
|
||||
enabled_at: number | null // Unix timestamp in seconds
|
||||
feature_enabled: boolean
|
||||
}
|
||||
|
||||
export interface TotpSetupRequest {
|
||||
email_code?: string
|
||||
password?: string
|
||||
}
|
||||
|
||||
export interface TotpSetupResponse {
|
||||
secret: string
|
||||
qr_code_url: string
|
||||
setup_token: string
|
||||
countdown: number
|
||||
}
|
||||
|
||||
export interface TotpEnableRequest {
|
||||
totp_code: string
|
||||
setup_token: string
|
||||
}
|
||||
|
||||
export interface TotpEnableResponse {
|
||||
success: boolean
|
||||
}
|
||||
|
||||
export interface TotpDisableRequest {
|
||||
email_code?: string
|
||||
password?: string
|
||||
}
|
||||
|
||||
export interface TotpVerificationMethod {
|
||||
method: 'email' | 'password'
|
||||
}
|
||||
|
||||
export interface TotpLoginResponse {
|
||||
requires_2fa: boolean
|
||||
temp_token?: string
|
||||
user_email_masked?: string
|
||||
}
|
||||
|
||||
export interface TotpLogin2FARequest {
|
||||
temp_token: string
|
||||
totp_code: string
|
||||
}
|
||||
|
||||
@@ -216,3 +216,67 @@ export function formatTokensK(tokens: number): string {
|
||||
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
|
||||
return tokens.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化倒计时(从现在到目标时间的剩余时间)
|
||||
* @param targetDate 目标日期字符串或 Date 对象
|
||||
* @returns 倒计时字符串,如 "2h 41m", "3d 5h", "15m"
|
||||
*/
|
||||
export function formatCountdown(targetDate: string | Date | null | undefined): string | null {
|
||||
if (!targetDate) return null
|
||||
|
||||
const now = new Date()
|
||||
const target = new Date(targetDate)
|
||||
const diffMs = target.getTime() - now.getTime()
|
||||
|
||||
// 如果目标时间已过或无效
|
||||
if (diffMs <= 0 || isNaN(diffMs)) return null
|
||||
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60))
|
||||
const diffHours = Math.floor(diffMins / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
const remainingHours = diffHours % 24
|
||||
const remainingMins = diffMins % 60
|
||||
|
||||
if (diffDays > 0) {
|
||||
// 超过1天:显示 "Xd Yh"
|
||||
return i18n.global.t('common.time.countdown.daysHours', { d: diffDays, h: remainingHours })
|
||||
}
|
||||
if (diffHours > 0) {
|
||||
// 小于1天:显示 "Xh Ym"
|
||||
return i18n.global.t('common.time.countdown.hoursMinutes', { h: diffHours, m: remainingMins })
|
||||
}
|
||||
// 小于1小时:显示 "Ym"
|
||||
return i18n.global.t('common.time.countdown.minutes', { m: diffMins })
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化倒计时并带后缀(如 "2h 41m 后解除")
|
||||
* @param targetDate 目标日期字符串或 Date 对象
|
||||
* @returns 完整的倒计时字符串,如 "2h 41m to lift", "2小时41分钟后解除"
|
||||
*/
|
||||
export function formatCountdownWithSuffix(targetDate: string | Date | null | undefined): string | null {
|
||||
const countdown = formatCountdown(targetDate)
|
||||
if (!countdown) return null
|
||||
return i18n.global.t('common.time.countdown.withSuffix', { time: countdown })
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化为相对时间 + 具体时间组合
|
||||
* @param date 日期字符串或 Date 对象
|
||||
* @returns 组合时间字符串,如 "5 天前 · 2026-01-27 15:25"
|
||||
*/
|
||||
export function formatRelativeWithDateTime(date: string | Date | null | undefined): string {
|
||||
if (!date) return ''
|
||||
|
||||
const relativeTime = formatRelativeTime(date)
|
||||
const dateTime = formatDateTime(date)
|
||||
|
||||
// 如果是 "从未" 或空字符串,只返回相对时间
|
||||
if (!dateTime || relativeTime === i18n.global.t('common.time.never')) {
|
||||
return relativeTime
|
||||
}
|
||||
|
||||
return `${relativeTime} · ${dateTime}`
|
||||
}
|
||||
|
||||
@@ -15,17 +15,115 @@
|
||||
@refresh="load"
|
||||
@sync="showSync = true"
|
||||
@create="showCreate = true"
|
||||
/>
|
||||
>
|
||||
<template #after>
|
||||
<!-- Auto Refresh Dropdown -->
|
||||
<div class="relative" ref="autoRefreshDropdownRef">
|
||||
<button
|
||||
@click="
|
||||
showAutoRefreshDropdown = !showAutoRefreshDropdown;
|
||||
showColumnDropdown = false
|
||||
"
|
||||
class="btn btn-secondary px-2 md:px-3"
|
||||
:title="t('admin.accounts.autoRefresh')"
|
||||
>
|
||||
<Icon name="refresh" size="sm" :class="[autoRefreshEnabled ? 'animate-spin' : '']" />
|
||||
<span class="hidden md:inline">
|
||||
{{
|
||||
autoRefreshEnabled
|
||||
? t('admin.accounts.autoRefreshCountdown', { seconds: autoRefreshCountdown })
|
||||
: t('admin.accounts.autoRefresh')
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="showAutoRefreshDropdown"
|
||||
class="absolute right-0 z-50 mt-2 w-56 origin-top-right rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div class="p-2">
|
||||
<button
|
||||
@click="setAutoRefreshEnabled(!autoRefreshEnabled)"
|
||||
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span>{{ t('admin.accounts.enableAutoRefresh') }}</span>
|
||||
<Icon v-if="autoRefreshEnabled" name="check" size="sm" class="text-primary-500" />
|
||||
</button>
|
||||
<div class="my-1 border-t border-gray-100 dark:border-gray-700"></div>
|
||||
<button
|
||||
v-for="sec in autoRefreshIntervals"
|
||||
:key="sec"
|
||||
@click="setAutoRefreshInterval(sec)"
|
||||
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span>{{ autoRefreshIntervalLabel(sec) }}</span>
|
||||
<Icon v-if="autoRefreshIntervalSeconds === sec" name="check" size="sm" class="text-primary-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column Settings Dropdown -->
|
||||
<div class="relative" ref="columnDropdownRef">
|
||||
<button
|
||||
@click="
|
||||
showColumnDropdown = !showColumnDropdown;
|
||||
showAutoRefreshDropdown = false
|
||||
"
|
||||
class="btn btn-secondary px-2 md:px-3"
|
||||
:title="t('admin.users.columnSettings')"
|
||||
>
|
||||
<svg class="h-4 w-4 md:mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 4.5v15m6-15v15m-10.875 0h15.75c.621 0 1.125-.504 1.125-1.125V5.625c0-.621-.504-1.125-1.125-1.125H4.125C3.504 4.5 3 5.004 3 5.625v12.75c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
<span class="hidden md:inline">{{ t('admin.users.columnSettings') }}</span>
|
||||
</button>
|
||||
<!-- Dropdown menu -->
|
||||
<div
|
||||
v-if="showColumnDropdown"
|
||||
class="absolute right-0 z-50 mt-2 w-48 origin-top-right rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div class="max-h-80 overflow-y-auto p-2">
|
||||
<button
|
||||
v-for="col in toggleableColumns"
|
||||
:key="col.key"
|
||||
@click="toggleColumn(col.key)"
|
||||
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span>{{ col.label }}</span>
|
||||
<Icon v-if="isColumnVisible(col.key)" name="check" size="sm" class="text-primary-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</AccountTableActions>
|
||||
</div>
|
||||
</template>
|
||||
<template #table>
|
||||
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @edit="showBulkEdit = true" @clear="selIds = []" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
|
||||
<DataTable :columns="cols" :data="accounts" :loading="loading" row-key="id">
|
||||
<DataTable
|
||||
:columns="cols"
|
||||
:data="accounts"
|
||||
:loading="loading"
|
||||
row-key="id"
|
||||
default-sort-key="name"
|
||||
default-sort-order="asc"
|
||||
:sort-storage-key="ACCOUNT_SORT_STORAGE_KEY"
|
||||
>
|
||||
<template #cell-select="{ row }">
|
||||
<input type="checkbox" :checked="selIds.includes(row.id)" @change="toggleSel(row.id)" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
|
||||
</template>
|
||||
<template #cell-name="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
<template #cell-name="{ row, value }">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
<span
|
||||
v-if="row.extra?.email_address"
|
||||
class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]"
|
||||
:title="row.extra.email_address"
|
||||
>
|
||||
{{ row.extra.email_address }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell-notes="{ value }">
|
||||
<span v-if="value" :title="value" class="block max-w-xs truncate text-sm text-gray-600 dark:text-gray-300">{{ value }}</span>
|
||||
@@ -54,6 +152,15 @@
|
||||
<template #cell-usage="{ row }">
|
||||
<AccountUsageCell :account="row" />
|
||||
</template>
|
||||
<template #cell-proxy="{ row }">
|
||||
<div v-if="row.proxy" class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ row.proxy.name }}</span>
|
||||
<span v-if="row.proxy.country_code" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
({{ row.proxy.country_code }})
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
|
||||
</template>
|
||||
<template #cell-rate_multiplier="{ row }">
|
||||
<span class="text-sm font-mono text-gray-700 dark:text-gray-300">
|
||||
{{ (row.rate_multiplier ?? 1).toFixed(2) }}x
|
||||
@@ -119,6 +226,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useIntervalFn } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -143,15 +251,16 @@ import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vu
|
||||
import AccountGroupsCell from '@/components/account/AccountGroupsCell.vue'
|
||||
import AccountCapacityCell from '@/components/account/AccountCapacityCell.vue'
|
||||
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { formatDateTime, formatRelativeTime } from '@/utils/format'
|
||||
import type { Account, Proxy, Group } from '@/types'
|
||||
import type { Account, Proxy, AdminGroup } from '@/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const proxies = ref<Proxy[]>([])
|
||||
const groups = ref<Group[]>([])
|
||||
const groups = ref<AdminGroup[]>([])
|
||||
const selIds = ref<number[]>([])
|
||||
const showCreate = ref(false)
|
||||
const showEdit = ref(false)
|
||||
@@ -171,12 +280,166 @@ const statsAcc = ref<Account | null>(null)
|
||||
const togglingSchedulable = ref<number | null>(null)
|
||||
const menu = reactive<{show:boolean, acc:Account|null, pos:{top:number, left:number}|null}>({ show: false, acc: null, pos: null })
|
||||
|
||||
// Column settings
|
||||
const showColumnDropdown = ref(false)
|
||||
const columnDropdownRef = ref<HTMLElement | null>(null)
|
||||
const hiddenColumns = reactive<Set<string>>(new Set())
|
||||
const DEFAULT_HIDDEN_COLUMNS = ['proxy', 'notes', 'priority', 'rate_multiplier']
|
||||
const HIDDEN_COLUMNS_KEY = 'account-hidden-columns'
|
||||
|
||||
// Sorting settings
|
||||
const ACCOUNT_SORT_STORAGE_KEY = 'account-table-sort'
|
||||
|
||||
// Auto refresh settings
|
||||
const showAutoRefreshDropdown = ref(false)
|
||||
const autoRefreshDropdownRef = ref<HTMLElement | null>(null)
|
||||
const AUTO_REFRESH_STORAGE_KEY = 'account-auto-refresh'
|
||||
const autoRefreshIntervals = [5, 10, 15, 30] as const
|
||||
const autoRefreshEnabled = ref(false)
|
||||
const autoRefreshIntervalSeconds = ref<(typeof autoRefreshIntervals)[number]>(30)
|
||||
const autoRefreshCountdown = ref(0)
|
||||
|
||||
const autoRefreshIntervalLabel = (sec: number) => {
|
||||
if (sec === 5) return t('admin.accounts.refreshInterval5s')
|
||||
if (sec === 10) return t('admin.accounts.refreshInterval10s')
|
||||
if (sec === 15) return t('admin.accounts.refreshInterval15s')
|
||||
if (sec === 30) return t('admin.accounts.refreshInterval30s')
|
||||
return `${sec}s`
|
||||
}
|
||||
|
||||
const loadSavedColumns = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY)
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved) as string[]
|
||||
parsed.forEach(key => hiddenColumns.add(key))
|
||||
} else {
|
||||
DEFAULT_HIDDEN_COLUMNS.forEach(key => hiddenColumns.add(key))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load saved columns:', e)
|
||||
DEFAULT_HIDDEN_COLUMNS.forEach(key => hiddenColumns.add(key))
|
||||
}
|
||||
}
|
||||
|
||||
const saveColumnsToStorage = () => {
|
||||
try {
|
||||
localStorage.setItem(HIDDEN_COLUMNS_KEY, JSON.stringify([...hiddenColumns]))
|
||||
} catch (e) {
|
||||
console.error('Failed to save columns:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadSavedAutoRefresh = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem(AUTO_REFRESH_STORAGE_KEY)
|
||||
if (!saved) return
|
||||
const parsed = JSON.parse(saved) as { enabled?: boolean; interval_seconds?: number }
|
||||
autoRefreshEnabled.value = parsed.enabled === true
|
||||
const interval = Number(parsed.interval_seconds)
|
||||
if (autoRefreshIntervals.includes(interval as any)) {
|
||||
autoRefreshIntervalSeconds.value = interval as any
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load saved auto refresh settings:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const saveAutoRefreshToStorage = () => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
AUTO_REFRESH_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
enabled: autoRefreshEnabled.value,
|
||||
interval_seconds: autoRefreshIntervalSeconds.value
|
||||
})
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('Failed to save auto refresh settings:', e)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
loadSavedColumns()
|
||||
loadSavedAutoRefresh()
|
||||
}
|
||||
|
||||
const setAutoRefreshEnabled = (enabled: boolean) => {
|
||||
autoRefreshEnabled.value = enabled
|
||||
saveAutoRefreshToStorage()
|
||||
if (enabled) {
|
||||
autoRefreshCountdown.value = autoRefreshIntervalSeconds.value
|
||||
resumeAutoRefresh()
|
||||
} else {
|
||||
pauseAutoRefresh()
|
||||
autoRefreshCountdown.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
const setAutoRefreshInterval = (seconds: (typeof autoRefreshIntervals)[number]) => {
|
||||
autoRefreshIntervalSeconds.value = seconds
|
||||
saveAutoRefreshToStorage()
|
||||
if (autoRefreshEnabled.value) {
|
||||
autoRefreshCountdown.value = seconds
|
||||
}
|
||||
}
|
||||
|
||||
const toggleColumn = (key: string) => {
|
||||
if (hiddenColumns.has(key)) {
|
||||
hiddenColumns.delete(key)
|
||||
} else {
|
||||
hiddenColumns.add(key)
|
||||
}
|
||||
saveColumnsToStorage()
|
||||
}
|
||||
|
||||
const isColumnVisible = (key: string) => !hiddenColumns.has(key)
|
||||
|
||||
const { items: accounts, loading, params, pagination, load, reload, debouncedReload, handlePageChange, handlePageSizeChange } = useTableLoader<Account, any>({
|
||||
fetchFn: adminAPI.accounts.list,
|
||||
initialParams: { platform: '', type: '', status: '', search: '' }
|
||||
})
|
||||
|
||||
const cols = computed(() => {
|
||||
const isAnyModalOpen = computed(() => {
|
||||
return (
|
||||
showCreate.value ||
|
||||
showEdit.value ||
|
||||
showSync.value ||
|
||||
showBulkEdit.value ||
|
||||
showTempUnsched.value ||
|
||||
showDeleteDialog.value ||
|
||||
showReAuth.value ||
|
||||
showTest.value ||
|
||||
showStats.value
|
||||
)
|
||||
})
|
||||
|
||||
const { pause: pauseAutoRefresh, resume: resumeAutoRefresh } = useIntervalFn(
|
||||
async () => {
|
||||
if (!autoRefreshEnabled.value) return
|
||||
if (document.hidden) return
|
||||
if (loading.value) return
|
||||
if (isAnyModalOpen.value) return
|
||||
if (menu.show) return
|
||||
|
||||
if (autoRefreshCountdown.value <= 0) {
|
||||
autoRefreshCountdown.value = autoRefreshIntervalSeconds.value
|
||||
try {
|
||||
await load()
|
||||
} catch (e) {
|
||||
console.error('Auto refresh failed:', e)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
autoRefreshCountdown.value -= 1
|
||||
},
|
||||
1000,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
// All available columns
|
||||
const allColumns = computed(() => {
|
||||
const c = [
|
||||
{ key: 'select', label: '', sortable: false },
|
||||
{ key: 'name', label: t('admin.accounts.columns.name'), sortable: true },
|
||||
@@ -189,11 +452,12 @@ const cols = computed(() => {
|
||||
if (!authStore.isSimpleMode) {
|
||||
c.push({ key: 'groups', label: t('admin.accounts.columns.groups'), sortable: false })
|
||||
}
|
||||
c.push(
|
||||
{ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false },
|
||||
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
|
||||
{ key: 'rate_multiplier', label: t('admin.accounts.columns.billingRateMultiplier'), sortable: true },
|
||||
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true },
|
||||
c.push(
|
||||
{ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false },
|
||||
{ key: 'proxy', label: t('admin.accounts.columns.proxy'), sortable: false },
|
||||
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
|
||||
{ key: 'rate_multiplier', label: t('admin.accounts.columns.billingRateMultiplier'), sortable: true },
|
||||
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true },
|
||||
{ key: 'expires_at', label: t('admin.accounts.columns.expiresAt'), sortable: true },
|
||||
{ key: 'notes', label: t('admin.accounts.columns.notes'), sortable: false },
|
||||
{ key: 'actions', label: t('admin.accounts.columns.actions'), sortable: false }
|
||||
@@ -201,6 +465,18 @@ const cols = computed(() => {
|
||||
return c
|
||||
})
|
||||
|
||||
// Columns that can be toggled (exclude select, name, and actions)
|
||||
const toggleableColumns = computed(() =>
|
||||
allColumns.value.filter(col => col.key !== 'select' && col.key !== 'name' && col.key !== 'actions')
|
||||
)
|
||||
|
||||
// Filtered columns based on visibility
|
||||
const cols = computed(() =>
|
||||
allColumns.value.filter(col =>
|
||||
col.key === 'select' || col.key === 'name' || col.key === 'actions' || !hiddenColumns.has(col.key)
|
||||
)
|
||||
)
|
||||
|
||||
const handleEdit = (a: Account) => { edAcc.value = a; showEdit.value = true }
|
||||
const openMenu = (a: Account, e: MouseEvent) => {
|
||||
menu.acc = a
|
||||
@@ -403,11 +679,22 @@ const isExpired = (value: number | null) => {
|
||||
return value * 1000 <= Date.now()
|
||||
}
|
||||
|
||||
// 滚动时关闭菜单
|
||||
// 滚动时关闭操作菜单(不关闭列设置下拉菜单)
|
||||
const handleScroll = () => {
|
||||
menu.show = false
|
||||
}
|
||||
|
||||
// 点击外部关闭列设置下拉菜单
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (columnDropdownRef.value && !columnDropdownRef.value.contains(target)) {
|
||||
showColumnDropdown.value = false
|
||||
}
|
||||
if (autoRefreshDropdownRef.value && !autoRefreshDropdownRef.value.contains(target)) {
|
||||
showAutoRefreshDropdown.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
load()
|
||||
try {
|
||||
@@ -418,9 +705,18 @@ onMounted(async () => {
|
||||
console.error('Failed to load proxies/groups:', error)
|
||||
}
|
||||
window.addEventListener('scroll', handleScroll, true)
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
|
||||
if (autoRefreshEnabled.value) {
|
||||
autoRefreshCountdown.value = autoRefreshIntervalSeconds.value
|
||||
resumeAutoRefresh()
|
||||
} else {
|
||||
pauseAutoRefresh()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
538
frontend/src/views/admin/AnnouncementsView.vue
Normal file
538
frontend/src/views/admin/AnnouncementsView.vue
Normal file
@@ -0,0 +1,538 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<TablePageLayout>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
@click="loadAnnouncements"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary"
|
||||
:title="t('common.refresh')"
|
||||
>
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
<button @click="openCreateDialog" class="btn btn-primary">
|
||||
<Icon name="plus" size="md" class="mr-1" />
|
||||
{{ t('admin.announcements.createAnnouncement') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #filters>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="max-w-md flex-1">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('admin.announcements.searchAnnouncements')"
|
||||
class="input"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Select
|
||||
v-model="filters.status"
|
||||
:options="statusFilterOptions"
|
||||
class="w-40"
|
||||
@change="handleStatusChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="announcements" :loading="loading">
|
||||
<template #cell-title="{ value, row }">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500 dark:text-dark-400">
|
||||
<span>#{{ row.id }}</span>
|
||||
<span class="text-gray-300 dark:text-dark-700">·</span>
|
||||
<span>{{ formatDateTime(row.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ value }">
|
||||
<span
|
||||
:class="[
|
||||
'badge',
|
||||
value === 'active'
|
||||
? 'badge-success'
|
||||
: value === 'draft'
|
||||
? 'badge-gray'
|
||||
: 'badge-warning'
|
||||
]"
|
||||
>
|
||||
{{ statusLabel(value) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-targeting="{ row }">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ targetingSummary(row.targeting) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-timeRange="{ row }">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
<div>
|
||||
<span class="font-medium">{{ t('admin.announcements.form.startsAt') }}:</span>
|
||||
<span class="ml-1">{{ row.starts_at ? formatDateTime(row.starts_at) : t('admin.announcements.timeImmediate') }}</span>
|
||||
</div>
|
||||
<div class="mt-0.5">
|
||||
<span class="font-medium">{{ t('admin.announcements.form.endsAt') }}:</span>
|
||||
<span class="ml-1">{{ row.ends_at ? formatDateTime(row.ends_at) : t('admin.announcements.timeNever') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-createdAt="{ value }">
|
||||
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center space-x-1">
|
||||
<button
|
||||
@click="openReadStatus(row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
||||
:title="t('admin.announcements.readStatus')"
|
||||
>
|
||||
<Icon name="eye" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
@click="openEditDialog(row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-dark-600 dark:hover:text-gray-300"
|
||||
:title="t('common.edit')"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
@click="handleDelete(row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||
:title="t('common.delete')"
|
||||
>
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
<EmptyState
|
||||
:title="t('empty.noData')"
|
||||
:description="t('admin.announcements.failedToLoad')"
|
||||
:action-text="t('admin.announcements.createAnnouncement')"
|
||||
@action="openCreateDialog"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
|
||||
<template #pagination>
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
@update:pageSize="handlePageSizeChange"
|
||||
/>
|
||||
</template>
|
||||
</TablePageLayout>
|
||||
|
||||
<!-- Create/Edit Dialog -->
|
||||
<BaseDialog
|
||||
:show="showEditDialog"
|
||||
:title="isEditing ? t('admin.announcements.editAnnouncement') : t('admin.announcements.createAnnouncement')"
|
||||
width="wide"
|
||||
@close="closeEdit"
|
||||
>
|
||||
<form id="announcement-form" @submit.prevent="handleSave" class="space-y-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.announcements.form.title') }}</label>
|
||||
<input v-model="form.title" type="text" class="input" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.announcements.form.content') }}</label>
|
||||
<textarea v-model="form.content" rows="6" class="input" required></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.announcements.form.status') }}</label>
|
||||
<Select v-model="form.status" :options="statusOptions" />
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.announcements.form.startsAt') }}</label>
|
||||
<input v-model="form.starts_at_str" type="datetime-local" class="input" />
|
||||
<p class="input-hint">{{ t('admin.announcements.form.startsAtHint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.announcements.form.endsAt') }}</label>
|
||||
<input v-model="form.ends_at_str" type="datetime-local" class="input" />
|
||||
<p class="input-hint">{{ t('admin.announcements.form.endsAtHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnnouncementTargetingEditor
|
||||
v-model="form.targeting"
|
||||
:groups="subscriptionGroups"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" @click="closeEdit" class="btn btn-secondary">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button type="submit" form="announcement-form" :disabled="saving" class="btn btn-primary">
|
||||
{{ saving ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Delete Confirmation -->
|
||||
<ConfirmDialog
|
||||
:show="showDeleteDialog"
|
||||
:title="t('admin.announcements.deleteAnnouncement')"
|
||||
:message="t('admin.announcements.deleteConfirm')"
|
||||
:confirm-text="t('common.delete')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
danger
|
||||
@confirm="confirmDelete"
|
||||
@cancel="showDeleteDialog = false"
|
||||
/>
|
||||
|
||||
<!-- Read Status Dialog -->
|
||||
<AnnouncementReadStatusDialog
|
||||
:show="showReadStatusDialog"
|
||||
:announcement-id="readStatusAnnouncementId"
|
||||
@close="showReadStatusDialog = false"
|
||||
/>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import { formatDateTime, formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
|
||||
import type { AdminGroup, Announcement, AnnouncementTargeting } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
import AnnouncementTargetingEditor from '@/components/admin/announcements/AnnouncementTargetingEditor.vue'
|
||||
import AnnouncementReadStatusDialog from '@/components/admin/announcements/AnnouncementReadStatusDialog.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const announcements = ref<Announcement[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const filters = reactive({
|
||||
status: '',
|
||||
})
|
||||
const searchQuery = ref('')
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
})
|
||||
|
||||
const statusFilterOptions = computed(() => [
|
||||
{ value: '', label: t('admin.announcements.allStatus') },
|
||||
{ value: 'draft', label: t('admin.announcements.statusLabels.draft') },
|
||||
{ value: 'active', label: t('admin.announcements.statusLabels.active') },
|
||||
{ value: 'archived', label: t('admin.announcements.statusLabels.archived') }
|
||||
])
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{ value: 'draft', label: t('admin.announcements.statusLabels.draft') },
|
||||
{ value: 'active', label: t('admin.announcements.statusLabels.active') },
|
||||
{ value: 'archived', label: t('admin.announcements.statusLabels.archived') }
|
||||
])
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'title', label: t('admin.announcements.columns.title') },
|
||||
{ key: 'status', label: t('admin.announcements.columns.status') },
|
||||
{ key: 'targeting', label: t('admin.announcements.columns.targeting') },
|
||||
{ key: 'timeRange', label: t('admin.announcements.columns.timeRange') },
|
||||
{ key: 'createdAt', label: t('admin.announcements.columns.createdAt') },
|
||||
{ key: 'actions', label: t('admin.announcements.columns.actions') }
|
||||
])
|
||||
|
||||
const statusLabel = (status: string) => {
|
||||
if (status === 'draft') return t('admin.announcements.statusLabels.draft')
|
||||
if (status === 'active') return t('admin.announcements.statusLabels.active')
|
||||
if (status === 'archived') return t('admin.announcements.statusLabels.archived')
|
||||
return status
|
||||
}
|
||||
|
||||
const targetingSummary = (targeting: AnnouncementTargeting) => {
|
||||
const anyOf = targeting?.any_of ?? []
|
||||
if (!anyOf || anyOf.length === 0) return t('admin.announcements.targetingSummaryAll')
|
||||
return t('admin.announcements.targetingSummaryCustom', { groups: anyOf.length })
|
||||
}
|
||||
|
||||
// ===== CRUD / list =====
|
||||
let currentController: AbortController | null = null
|
||||
|
||||
async function loadAnnouncements() {
|
||||
if (currentController) currentController.abort()
|
||||
currentController = new AbortController()
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await adminAPI.announcements.list(pagination.page, pagination.page_size, {
|
||||
status: filters.status || undefined,
|
||||
search: searchQuery.value || undefined
|
||||
})
|
||||
|
||||
announcements.value = res.items
|
||||
pagination.total = res.total
|
||||
pagination.pages = res.pages
|
||||
pagination.page = res.page
|
||||
pagination.page_size = res.page_size
|
||||
} catch (error: any) {
|
||||
if (currentController.signal.aborted || error?.name === 'AbortError') return
|
||||
console.error('Error loading announcements:', error)
|
||||
appStore.showError(error.response?.data?.detail || t('admin.announcements.failedToLoad'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
pagination.page = page
|
||||
loadAnnouncements()
|
||||
}
|
||||
|
||||
function handlePageSizeChange(pageSize: number) {
|
||||
pagination.page_size = pageSize
|
||||
pagination.page = 1
|
||||
loadAnnouncements()
|
||||
}
|
||||
|
||||
function handleStatusChange() {
|
||||
pagination.page = 1
|
||||
loadAnnouncements()
|
||||
}
|
||||
|
||||
let searchDebounceTimer: number | null = null
|
||||
function handleSearch() {
|
||||
if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = window.setTimeout(() => {
|
||||
pagination.page = 1
|
||||
loadAnnouncements()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// ===== Create/Edit dialog =====
|
||||
const showEditDialog = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingAnnouncement = ref<Announcement | null>(null)
|
||||
|
||||
const isEditing = computed(() => !!editingAnnouncement.value)
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
content: '',
|
||||
status: 'draft',
|
||||
starts_at_str: '',
|
||||
ends_at_str: '',
|
||||
targeting: { any_of: [] } as AnnouncementTargeting
|
||||
})
|
||||
|
||||
const subscriptionGroups = ref<AdminGroup[]>([])
|
||||
|
||||
async function loadSubscriptionGroups() {
|
||||
try {
|
||||
const all = await adminAPI.groups.getAll()
|
||||
subscriptionGroups.value = (all || []).filter((g) => g.subscription_type === 'subscription')
|
||||
} catch (error: any) {
|
||||
console.error('Error loading groups:', error)
|
||||
// not fatal
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.title = ''
|
||||
form.content = ''
|
||||
form.status = 'draft'
|
||||
form.starts_at_str = ''
|
||||
form.ends_at_str = ''
|
||||
form.targeting = { any_of: [] }
|
||||
}
|
||||
|
||||
function fillFormFromAnnouncement(a: Announcement) {
|
||||
form.title = a.title
|
||||
form.content = a.content
|
||||
form.status = a.status
|
||||
|
||||
// Backend returns RFC3339 strings
|
||||
form.starts_at_str = a.starts_at ? formatDateTimeLocalInput(Math.floor(new Date(a.starts_at).getTime() / 1000)) : ''
|
||||
form.ends_at_str = a.ends_at ? formatDateTimeLocalInput(Math.floor(new Date(a.ends_at).getTime() / 1000)) : ''
|
||||
|
||||
form.targeting = a.targeting ?? { any_of: [] }
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
editingAnnouncement.value = null
|
||||
resetForm()
|
||||
showEditDialog.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(row: Announcement) {
|
||||
editingAnnouncement.value = row
|
||||
fillFormFromAnnouncement(row)
|
||||
showEditDialog.value = true
|
||||
}
|
||||
|
||||
function closeEdit() {
|
||||
showEditDialog.value = false
|
||||
editingAnnouncement.value = null
|
||||
}
|
||||
|
||||
function buildCreatePayload() {
|
||||
const startsAt = parseDateTimeLocalInput(form.starts_at_str)
|
||||
const endsAt = parseDateTimeLocalInput(form.ends_at_str)
|
||||
|
||||
return {
|
||||
title: form.title,
|
||||
content: form.content,
|
||||
status: form.status as any,
|
||||
targeting: form.targeting,
|
||||
starts_at: startsAt ?? undefined,
|
||||
ends_at: endsAt ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
function buildUpdatePayload(original: Announcement) {
|
||||
const payload: any = {}
|
||||
|
||||
if (form.title !== original.title) payload.title = form.title
|
||||
if (form.content !== original.content) payload.content = form.content
|
||||
if (form.status !== original.status) payload.status = form.status
|
||||
|
||||
// starts_at / ends_at: distinguish unchanged vs clear(0) vs set
|
||||
const originalStarts = original.starts_at ? Math.floor(new Date(original.starts_at).getTime() / 1000) : null
|
||||
const originalEnds = original.ends_at ? Math.floor(new Date(original.ends_at).getTime() / 1000) : null
|
||||
|
||||
const newStarts = parseDateTimeLocalInput(form.starts_at_str)
|
||||
const newEnds = parseDateTimeLocalInput(form.ends_at_str)
|
||||
|
||||
if (newStarts !== originalStarts) {
|
||||
payload.starts_at = newStarts === null ? 0 : newStarts
|
||||
}
|
||||
if (newEnds !== originalEnds) {
|
||||
payload.ends_at = newEnds === null ? 0 : newEnds
|
||||
}
|
||||
|
||||
// targeting: do shallow compare by JSON
|
||||
if (JSON.stringify(form.targeting ?? {}) !== JSON.stringify(original.targeting ?? {})) {
|
||||
payload.targeting = form.targeting
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
// Frontend validation for targeting (to avoid ANNOUNCEMENT_INVALID_TARGET)
|
||||
const anyOf = form.targeting?.any_of ?? []
|
||||
if (anyOf.length > 50) {
|
||||
appStore.showError(t('admin.announcements.failedToCreate'))
|
||||
return
|
||||
}
|
||||
for (const g of anyOf) {
|
||||
const allOf = g?.all_of ?? []
|
||||
if (allOf.length > 50) {
|
||||
appStore.showError(t('admin.announcements.failedToCreate'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
if (!editingAnnouncement.value) {
|
||||
const payload = buildCreatePayload()
|
||||
await adminAPI.announcements.create(payload)
|
||||
appStore.showSuccess(t('common.success'))
|
||||
showEditDialog.value = false
|
||||
await loadAnnouncements()
|
||||
return
|
||||
}
|
||||
|
||||
const original = editingAnnouncement.value
|
||||
const payload = buildUpdatePayload(original)
|
||||
await adminAPI.announcements.update(original.id, payload)
|
||||
appStore.showSuccess(t('common.success'))
|
||||
showEditDialog.value = false
|
||||
editingAnnouncement.value = null
|
||||
await loadAnnouncements()
|
||||
} catch (error: any) {
|
||||
console.error('Failed to save announcement:', error)
|
||||
appStore.showError(error.response?.data?.detail || (editingAnnouncement.value ? t('admin.announcements.failedToUpdate') : t('admin.announcements.failedToCreate')))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Delete =====
|
||||
const showDeleteDialog = ref(false)
|
||||
const deletingAnnouncement = ref<Announcement | null>(null)
|
||||
|
||||
function handleDelete(row: Announcement) {
|
||||
deletingAnnouncement.value = row
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deletingAnnouncement.value) return
|
||||
|
||||
try {
|
||||
await adminAPI.announcements.delete(deletingAnnouncement.value.id)
|
||||
appStore.showSuccess(t('common.success'))
|
||||
showDeleteDialog.value = false
|
||||
deletingAnnouncement.value = null
|
||||
await loadAnnouncements()
|
||||
} catch (error: any) {
|
||||
console.error('Failed to delete announcement:', error)
|
||||
appStore.showError(error.response?.data?.detail || t('admin.announcements.failedToDelete'))
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Read status =====
|
||||
const showReadStatusDialog = ref(false)
|
||||
const readStatusAnnouncementId = ref<number | null>(null)
|
||||
|
||||
function openReadStatus(row: Announcement) {
|
||||
readStatusAnnouncementId.value = row.id
|
||||
showReadStatusDialog.value = true
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSubscriptionGroups()
|
||||
await loadAnnouncements()
|
||||
})
|
||||
</script>
|
||||
@@ -243,7 +243,7 @@
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.groups.platformHint') }}</p>
|
||||
</div>
|
||||
<div v-if="createForm.subscription_type !== 'subscription'">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.form.rateMultiplier') }}</label>
|
||||
<input
|
||||
v-model.number="createForm.rate_multiplier"
|
||||
@@ -739,7 +739,7 @@
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.groups.platformNotEditable') }}</p>
|
||||
</div>
|
||||
<div v-if="editForm.subscription_type !== 'subscription'">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.form.rateMultiplier') }}</label>
|
||||
<input
|
||||
v-model.number="editForm.rate_multiplier"
|
||||
@@ -1225,7 +1225,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useOnboardingStore } from '@/stores/onboarding'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Group, GroupPlatform, SubscriptionType } from '@/types'
|
||||
import type { AdminGroup, GroupPlatform, SubscriptionType } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
@@ -1358,7 +1358,7 @@ const invalidRequestFallbackOptionsForEdit = computed(() => {
|
||||
return options
|
||||
})
|
||||
|
||||
const groups = ref<Group[]>([])
|
||||
const groups = ref<AdminGroup[]>([])
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const filters = reactive({
|
||||
@@ -1379,8 +1379,8 @@ const showCreateModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const submitting = ref(false)
|
||||
const editingGroup = ref<Group | null>(null)
|
||||
const deletingGroup = ref<Group | null>(null)
|
||||
const editingGroup = ref<AdminGroup | null>(null)
|
||||
const deletingGroup = ref<AdminGroup | null>(null)
|
||||
|
||||
const createForm = reactive({
|
||||
name: '',
|
||||
@@ -1691,7 +1691,7 @@ const handleCreateGroup = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = async (group: Group) => {
|
||||
const handleEdit = async (group: AdminGroup) => {
|
||||
editingGroup.value = group
|
||||
editForm.name = group.name
|
||||
editForm.description = group.description || ''
|
||||
@@ -1753,7 +1753,7 @@ const handleUpdateGroup = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (group: Group) => {
|
||||
const handleDelete = (group: AdminGroup) => {
|
||||
deletingGroup.value = group
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
@@ -1773,12 +1773,11 @@ const confirmDelete = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 subscription_type 变化,订阅模式时重置 rate_multiplier 为 1,is_exclusive 为 true
|
||||
// 监听 subscription_type 变化,订阅模式时 is_exclusive 默认为 true
|
||||
watch(
|
||||
() => createForm.subscription_type,
|
||||
(newVal) => {
|
||||
if (newVal === 'subscription') {
|
||||
createForm.rate_multiplier = 1.0
|
||||
createForm.is_exclusive = true
|
||||
createForm.fallback_group_id_on_invalid_request = null
|
||||
}
|
||||
|
||||
@@ -238,7 +238,30 @@
|
||||
v-model="generateForm.group_id"
|
||||
:options="subscriptionGroupOptions"
|
||||
:placeholder="t('admin.redeem.selectGroupPlaceholder')"
|
||||
/>
|
||||
>
|
||||
<template #selected="{ option }">
|
||||
<GroupBadge
|
||||
v-if="option"
|
||||
:name="(option as unknown as GroupOption).label"
|
||||
:platform="(option as unknown as GroupOption).platform"
|
||||
:subscription-type="(option as unknown as GroupOption).subscriptionType"
|
||||
:rate-multiplier="(option as unknown as GroupOption).rate"
|
||||
/>
|
||||
<span v-else class="text-gray-400">{{
|
||||
t('admin.redeem.selectGroupPlaceholder')
|
||||
}}</span>
|
||||
</template>
|
||||
<template #option="{ option, selected }">
|
||||
<GroupOptionItem
|
||||
:name="(option as unknown as GroupOption).label"
|
||||
:platform="(option as unknown as GroupOption).platform"
|
||||
:subscription-type="(option as unknown as GroupOption).subscriptionType"
|
||||
:rate-multiplier="(option as unknown as GroupOption).rate"
|
||||
:description="(option as unknown as GroupOption).description"
|
||||
:selected="selected"
|
||||
/>
|
||||
</template>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.redeem.validityDays') }}</label>
|
||||
@@ -370,7 +393,7 @@ import { useAppStore } from '@/stores/app'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import type { RedeemCode, RedeemCodeType, Group } from '@/types'
|
||||
import type { RedeemCode, RedeemCodeType, Group, GroupPlatform, SubscriptionType } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
@@ -378,12 +401,23 @@ import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||
import GroupOptionItem from '@/components/common/GroupOptionItem.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const { copyToClipboard: clipboardCopy } = useClipboard()
|
||||
|
||||
interface GroupOption {
|
||||
value: number
|
||||
label: string
|
||||
description: string | null
|
||||
platform: GroupPlatform
|
||||
subscriptionType: SubscriptionType
|
||||
rate: number
|
||||
}
|
||||
|
||||
const showGenerateDialog = ref(false)
|
||||
const showResultDialog = ref(false)
|
||||
const generatedCodes = ref<RedeemCode[]>([])
|
||||
@@ -395,7 +429,11 @@ const subscriptionGroupOptions = computed(() => {
|
||||
.filter((g) => g.subscription_type === 'subscription')
|
||||
.map((g) => ({
|
||||
value: g.id,
|
||||
label: g.name
|
||||
label: g.name,
|
||||
description: g.description,
|
||||
platform: g.platform,
|
||||
subscriptionType: g.subscription_type,
|
||||
rate: g.rate_multiplier
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
@@ -323,6 +323,62 @@
|
||||
</div>
|
||||
<Toggle v-model="form.email_verify_enabled" />
|
||||
</div>
|
||||
|
||||
<!-- Promo Code -->
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||
>
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||
t('admin.settings.registration.promoCode')
|
||||
}}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.registration.promoCodeHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.promo_code_enabled" />
|
||||
</div>
|
||||
|
||||
<!-- Password Reset - Only show when email verification is enabled -->
|
||||
<div
|
||||
v-if="form.email_verify_enabled"
|
||||
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||
>
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||
t('admin.settings.registration.passwordReset')
|
||||
}}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.registration.passwordResetHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.password_reset_enabled" />
|
||||
</div>
|
||||
|
||||
<!-- TOTP 2FA -->
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||
>
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||
t('admin.settings.registration.totp')
|
||||
}}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.registration.totpHint') }}
|
||||
</p>
|
||||
<!-- Warning when encryption key not configured -->
|
||||
<p
|
||||
v-if="!form.totp_encryption_key_configured"
|
||||
class="mt-2 text-sm text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
{{ t('admin.settings.registration.totpKeyNotConfigured') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
v-model="form.totp_enabled"
|
||||
:disabled="!form.totp_encryption_key_configured"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -720,6 +776,21 @@
|
||||
{{ t('admin.settings.site.homeContentIframeWarning') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Hide CCS Import Button -->
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||
>
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||
t('admin.settings.site.hideCcsImportButton')
|
||||
}}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.site.hideCcsImportButtonHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.hide_ccs_import_button" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -864,6 +935,51 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Purchase Subscription Page -->
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.purchase.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.purchase.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-6 p-6">
|
||||
<!-- Enable Toggle -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||
t('admin.settings.purchase.enabled')
|
||||
}}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.purchase.enabledHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.purchase_subscription_enabled" />
|
||||
</div>
|
||||
|
||||
<!-- URL -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.purchase.url') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.purchase_subscription_url"
|
||||
type="url"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.purchase.urlPlaceholder')"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.purchase.urlHint') }}
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
{{ t('admin.settings.purchase.iframeWarning') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Send Test Email - Only show when email verification is enabled -->
|
||||
<div v-if="form.email_verify_enabled" class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
@@ -998,6 +1114,10 @@ type SettingsForm = SystemSettings & {
|
||||
const form = reactive<SettingsForm>({
|
||||
registration_enabled: true,
|
||||
email_verify_enabled: false,
|
||||
promo_code_enabled: true,
|
||||
password_reset_enabled: false,
|
||||
totp_enabled: false,
|
||||
totp_encryption_key_configured: false,
|
||||
default_balance: 0,
|
||||
default_concurrency: 1,
|
||||
site_name: 'Sub2API',
|
||||
@@ -1007,6 +1127,9 @@ const form = reactive<SettingsForm>({
|
||||
contact_info: '',
|
||||
doc_url: '',
|
||||
home_content: '',
|
||||
hide_ccs_import_button: false,
|
||||
purchase_subscription_enabled: false,
|
||||
purchase_subscription_url: '',
|
||||
smtp_host: '',
|
||||
smtp_port: 587,
|
||||
smtp_username: '',
|
||||
@@ -1119,6 +1242,9 @@ async function saveSettings() {
|
||||
const payload: UpdateSettingsRequest = {
|
||||
registration_enabled: form.registration_enabled,
|
||||
email_verify_enabled: form.email_verify_enabled,
|
||||
promo_code_enabled: form.promo_code_enabled,
|
||||
password_reset_enabled: form.password_reset_enabled,
|
||||
totp_enabled: form.totp_enabled,
|
||||
default_balance: form.default_balance,
|
||||
default_concurrency: form.default_concurrency,
|
||||
site_name: form.site_name,
|
||||
@@ -1128,6 +1254,9 @@ async function saveSettings() {
|
||||
contact_info: form.contact_info,
|
||||
doc_url: form.doc_url,
|
||||
home_content: form.home_content,
|
||||
hide_ccs_import_button: form.hide_ccs_import_button,
|
||||
purchase_subscription_enabled: form.purchase_subscription_enabled,
|
||||
purchase_subscription_url: form.purchase_subscription_url,
|
||||
smtp_host: form.smtp_host,
|
||||
smtp_port: form.smtp_port,
|
||||
smtp_username: form.smtp_username,
|
||||
|
||||
@@ -93,6 +93,57 @@
|
||||
>
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
<!-- Column Settings Dropdown -->
|
||||
<div class="relative" ref="columnDropdownRef">
|
||||
<button
|
||||
@click="showColumnDropdown = !showColumnDropdown"
|
||||
class="btn btn-secondary px-2 md:px-3"
|
||||
:title="t('admin.users.columnSettings')"
|
||||
>
|
||||
<svg class="h-4 w-4 md:mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 4.5v15m6-15v15m-10.875 0h15.75c.621 0 1.125-.504 1.125-1.125V5.625c0-.621-.504-1.125-1.125-1.125H4.125C3.504 4.5 3 5.004 3 5.625v12.75c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
<span class="hidden md:inline">{{ t('admin.users.columnSettings') }}</span>
|
||||
</button>
|
||||
<!-- Dropdown menu -->
|
||||
<div
|
||||
v-if="showColumnDropdown"
|
||||
class="absolute right-0 z-50 mt-2 w-48 origin-top-right rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div class="p-2">
|
||||
<!-- User column mode selection -->
|
||||
<div class="mb-2 border-b border-gray-200 pb-2 dark:border-gray-700">
|
||||
<div class="px-3 py-1 text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.subscriptions.columns.user') }}
|
||||
</div>
|
||||
<button
|
||||
@click="setUserColumnMode('email')"
|
||||
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span>{{ t('admin.users.columns.email') }}</span>
|
||||
<Icon v-if="userColumnMode === 'email'" name="check" size="sm" class="text-primary-500" />
|
||||
</button>
|
||||
<button
|
||||
@click="setUserColumnMode('username')"
|
||||
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span>{{ t('admin.users.columns.username') }}</span>
|
||||
<Icon v-if="userColumnMode === 'username'" name="check" size="sm" class="text-primary-500" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Other columns toggle -->
|
||||
<button
|
||||
v-for="col in toggleableColumns"
|
||||
:key="col.key"
|
||||
@click="toggleColumn(col.key)"
|
||||
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span>{{ col.label }}</span>
|
||||
<Icon v-if="isColumnVisible(col.key)" name="check" size="sm" class="text-primary-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="showAssignModal = true" class="btn btn-primary">
|
||||
<Icon name="plus" size="md" class="mr-2" />
|
||||
{{ t('admin.subscriptions.assignSubscription') }}
|
||||
@@ -103,19 +154,31 @@
|
||||
|
||||
<!-- Subscriptions Table -->
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="subscriptions" :loading="loading">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="subscriptions"
|
||||
:loading="loading"
|
||||
:server-side-sort="true"
|
||||
@sort="handleSort"
|
||||
>
|
||||
<template #cell-user="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30"
|
||||
>
|
||||
<span class="text-sm font-medium text-primary-700 dark:text-primary-300">
|
||||
{{ row.user?.email?.charAt(0).toUpperCase() || '?' }}
|
||||
{{ userColumnMode === 'email'
|
||||
? (row.user?.email?.charAt(0).toUpperCase() || '?')
|
||||
: (row.user?.username?.charAt(0).toUpperCase() || '?')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
||||
row.user?.email || t('admin.redeem.userPrefix', { id: row.user_id })
|
||||
}}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{{ userColumnMode === 'email'
|
||||
? (row.user?.email || t('admin.redeem.userPrefix', { id: row.user_id }))
|
||||
: (row.user?.username || '-')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -300,12 +363,12 @@
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-if="row.status === 'active'"
|
||||
v-if="row.status === 'active' || row.status === 'expired'"
|
||||
@click="handleExtend(row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
||||
>
|
||||
<Icon name="clock" size="sm" />
|
||||
<span class="text-xs">{{ t('admin.subscriptions.extend') }}</span>
|
||||
<Icon name="calendar" size="sm" />
|
||||
<span class="text-xs">{{ t('admin.subscriptions.adjust') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="row.status === 'active'"
|
||||
@@ -409,7 +472,28 @@
|
||||
v-model="assignForm.group_id"
|
||||
:options="subscriptionGroupOptions"
|
||||
:placeholder="t('admin.subscriptions.selectGroup')"
|
||||
/>
|
||||
>
|
||||
<template #selected="{ option }">
|
||||
<GroupBadge
|
||||
v-if="option"
|
||||
:name="(option as unknown as GroupOption).label"
|
||||
:platform="(option as unknown as GroupOption).platform"
|
||||
:subscription-type="(option as unknown as GroupOption).subscriptionType"
|
||||
:rate-multiplier="(option as unknown as GroupOption).rate"
|
||||
/>
|
||||
<span v-else class="text-gray-400">{{ t('admin.subscriptions.selectGroup') }}</span>
|
||||
</template>
|
||||
<template #option="{ option, selected }">
|
||||
<GroupOptionItem
|
||||
:name="(option as unknown as GroupOption).label"
|
||||
:platform="(option as unknown as GroupOption).platform"
|
||||
:subscription-type="(option as unknown as GroupOption).subscriptionType"
|
||||
:rate-multiplier="(option as unknown as GroupOption).rate"
|
||||
:description="(option as unknown as GroupOption).description"
|
||||
:selected="selected"
|
||||
/>
|
||||
</template>
|
||||
</Select>
|
||||
<p class="input-hint">{{ t('admin.subscriptions.groupHint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -455,10 +539,10 @@
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Extend Subscription Modal -->
|
||||
<!-- Adjust Subscription Modal -->
|
||||
<BaseDialog
|
||||
:show="showExtendModal"
|
||||
:title="t('admin.subscriptions.extendSubscription')"
|
||||
:title="t('admin.subscriptions.adjustSubscription')"
|
||||
width="narrow"
|
||||
@close="closeExtendModal"
|
||||
>
|
||||
@@ -470,7 +554,7 @@
|
||||
>
|
||||
<div class="rounded-lg bg-gray-50 p-4 dark:bg-dark-700">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.subscriptions.extendingFor') }}
|
||||
{{ t('admin.subscriptions.adjustingFor') }}
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
||||
extendingSubscription.user?.email
|
||||
}}</span>
|
||||
@@ -485,10 +569,25 @@
|
||||
}}
|
||||
</span>
|
||||
</p>
|
||||
<p v-if="extendingSubscription.expires_at" class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.subscriptions.remainingDays') }}:
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{{ getDaysRemaining(extendingSubscription.expires_at) ?? 0 }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.subscriptions.form.extendDays') }}</label>
|
||||
<input v-model.number="extendForm.days" type="number" min="1" required class="input" />
|
||||
<label class="input-label">{{ t('admin.subscriptions.form.adjustDays') }}</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model.number="extendForm.days"
|
||||
type="number"
|
||||
required
|
||||
class="input text-center"
|
||||
:placeholder="t('admin.subscriptions.adjustDaysPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<p class="input-hint">{{ t('admin.subscriptions.adjustHint') }}</p>
|
||||
</div>
|
||||
</form>
|
||||
<template #footer>
|
||||
@@ -502,7 +601,7 @@
|
||||
:disabled="submitting"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
{{ submitting ? t('admin.subscriptions.extending') : t('admin.subscriptions.extend') }}
|
||||
{{ submitting ? t('admin.subscriptions.adjusting') : t('admin.subscriptions.adjust') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -527,7 +626,7 @@ import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { UserSubscription, Group } from '@/types'
|
||||
import type { UserSubscription, Group, GroupPlatform, SubscriptionType } from '@/types'
|
||||
import type { SimpleUser } from '@/api/admin/usage'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import { formatDateOnly } from '@/utils/format'
|
||||
@@ -540,20 +639,128 @@ import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||
import GroupOptionItem from '@/components/common/GroupOptionItem.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'user', label: t('admin.subscriptions.columns.user'), sortable: true },
|
||||
{ key: 'group', label: t('admin.subscriptions.columns.group'), sortable: true },
|
||||
interface GroupOption {
|
||||
value: number
|
||||
label: string
|
||||
description: string | null
|
||||
platform: GroupPlatform
|
||||
subscriptionType: SubscriptionType
|
||||
rate: number
|
||||
}
|
||||
|
||||
// User column display mode: 'email' or 'username'
|
||||
const userColumnMode = ref<'email' | 'username'>('email')
|
||||
const USER_COLUMN_MODE_KEY = 'subscription-user-column-mode'
|
||||
|
||||
const loadUserColumnMode = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem(USER_COLUMN_MODE_KEY)
|
||||
if (saved === 'email' || saved === 'username') {
|
||||
userColumnMode.value = saved
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load user column mode:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const saveUserColumnMode = () => {
|
||||
try {
|
||||
localStorage.setItem(USER_COLUMN_MODE_KEY, userColumnMode.value)
|
||||
} catch (e) {
|
||||
console.error('Failed to save user column mode:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const setUserColumnMode = (mode: 'email' | 'username') => {
|
||||
userColumnMode.value = mode
|
||||
saveUserColumnMode()
|
||||
}
|
||||
|
||||
// All available columns
|
||||
const allColumns = computed<Column[]>(() => [
|
||||
{
|
||||
key: 'user',
|
||||
label: userColumnMode.value === 'email'
|
||||
? t('admin.subscriptions.columns.user')
|
||||
: t('admin.users.columns.username'),
|
||||
sortable: false
|
||||
},
|
||||
{ key: 'group', label: t('admin.subscriptions.columns.group'), sortable: false },
|
||||
{ key: 'usage', label: t('admin.subscriptions.columns.usage'), sortable: false },
|
||||
{ key: 'expires_at', label: t('admin.subscriptions.columns.expires'), sortable: true },
|
||||
{ key: 'status', label: t('admin.subscriptions.columns.status'), sortable: true },
|
||||
{ key: 'actions', label: t('admin.subscriptions.columns.actions'), sortable: false }
|
||||
])
|
||||
|
||||
// Columns that can be toggled (exclude user and actions which are always visible)
|
||||
const toggleableColumns = computed(() =>
|
||||
allColumns.value.filter(col => col.key !== 'user' && col.key !== 'actions')
|
||||
)
|
||||
|
||||
// Hidden columns set
|
||||
const hiddenColumns = reactive<Set<string>>(new Set())
|
||||
|
||||
// Default hidden columns
|
||||
const DEFAULT_HIDDEN_COLUMNS: string[] = []
|
||||
|
||||
// localStorage key
|
||||
const HIDDEN_COLUMNS_KEY = 'subscription-hidden-columns'
|
||||
|
||||
// Load saved column settings
|
||||
const loadSavedColumns = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY)
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved) as string[]
|
||||
parsed.forEach(key => hiddenColumns.add(key))
|
||||
} else {
|
||||
DEFAULT_HIDDEN_COLUMNS.forEach(key => hiddenColumns.add(key))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load saved columns:', e)
|
||||
DEFAULT_HIDDEN_COLUMNS.forEach(key => hiddenColumns.add(key))
|
||||
}
|
||||
}
|
||||
|
||||
// Save column settings to localStorage
|
||||
const saveColumnsToStorage = () => {
|
||||
try {
|
||||
localStorage.setItem(HIDDEN_COLUMNS_KEY, JSON.stringify([...hiddenColumns]))
|
||||
} catch (e) {
|
||||
console.error('Failed to save columns:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle column visibility
|
||||
const toggleColumn = (key: string) => {
|
||||
if (hiddenColumns.has(key)) {
|
||||
hiddenColumns.delete(key)
|
||||
} else {
|
||||
hiddenColumns.add(key)
|
||||
}
|
||||
saveColumnsToStorage()
|
||||
}
|
||||
|
||||
// Check if column is visible
|
||||
const isColumnVisible = (key: string) => !hiddenColumns.has(key)
|
||||
|
||||
// Filtered columns for display
|
||||
const columns = computed<Column[]>(() =>
|
||||
allColumns.value.filter(col =>
|
||||
col.key === 'user' || col.key === 'actions' || !hiddenColumns.has(col.key)
|
||||
)
|
||||
)
|
||||
|
||||
// Column dropdown state
|
||||
const showColumnDropdown = ref(false)
|
||||
const columnDropdownRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// Filter options
|
||||
const statusOptions = computed(() => [
|
||||
{ value: '', label: t('admin.subscriptions.allStatus') },
|
||||
@@ -584,10 +791,17 @@ const selectedUser = ref<SimpleUser | null>(null)
|
||||
let userSearchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const filters = reactive({
|
||||
status: '',
|
||||
status: 'active',
|
||||
group_id: '',
|
||||
user_id: null as number | null
|
||||
})
|
||||
|
||||
// Sorting state
|
||||
const sortState = reactive({
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc' as 'asc' | 'desc'
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
@@ -622,7 +836,14 @@ const groupOptions = computed(() => [
|
||||
const subscriptionGroupOptions = computed(() =>
|
||||
groups.value
|
||||
.filter((g) => g.subscription_type === 'subscription' && g.status === 'active')
|
||||
.map((g) => ({ value: g.id, label: g.name }))
|
||||
.map((g) => ({
|
||||
value: g.id,
|
||||
label: g.name,
|
||||
description: g.description,
|
||||
platform: g.platform,
|
||||
subscriptionType: g.subscription_type,
|
||||
rate: g.rate_multiplier
|
||||
}))
|
||||
)
|
||||
|
||||
const applyFilters = () => {
|
||||
@@ -646,7 +867,9 @@ const loadSubscriptions = async () => {
|
||||
{
|
||||
status: (filters.status as any) || undefined,
|
||||
group_id: filters.group_id ? parseInt(filters.group_id) : undefined,
|
||||
user_id: filters.user_id || undefined
|
||||
user_id: filters.user_id || undefined,
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
},
|
||||
{
|
||||
signal
|
||||
@@ -787,6 +1010,13 @@ const handlePageSizeChange = (pageSize: number) => {
|
||||
loadSubscriptions()
|
||||
}
|
||||
|
||||
const handleSort = (key: string, order: 'asc' | 'desc') => {
|
||||
sortState.sort_by = key
|
||||
sortState.sort_order = order
|
||||
pagination.page = 1
|
||||
loadSubscriptions()
|
||||
}
|
||||
|
||||
const closeAssignModal = () => {
|
||||
showAssignModal.value = false
|
||||
assignForm.user_id = null
|
||||
@@ -845,17 +1075,27 @@ const closeExtendModal = () => {
|
||||
const handleExtendSubscription = async () => {
|
||||
if (!extendingSubscription.value) return
|
||||
|
||||
// 前端验证:调整后的过期时间必须在未来
|
||||
if (extendingSubscription.value.expires_at) {
|
||||
const expiresAt = new Date(extendingSubscription.value.expires_at)
|
||||
const newExpiresAt = new Date(expiresAt.getTime() + extendForm.days * 24 * 60 * 60 * 1000)
|
||||
if (newExpiresAt <= new Date()) {
|
||||
appStore.showError(t('admin.subscriptions.adjustWouldExpire'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await adminAPI.subscriptions.extend(extendingSubscription.value.id, {
|
||||
days: extendForm.days
|
||||
})
|
||||
appStore.showSuccess(t('admin.subscriptions.subscriptionExtended'))
|
||||
appStore.showSuccess(t('admin.subscriptions.subscriptionAdjusted'))
|
||||
closeExtendModal()
|
||||
loadSubscriptions()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.subscriptions.failedToExtend'))
|
||||
console.error('Error extending subscription:', error)
|
||||
appStore.showError(error.response?.data?.detail || t('admin.subscriptions.failedToAdjust'))
|
||||
console.error('Error adjusting subscription:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
@@ -949,14 +1189,19 @@ const formatResetTime = (windowStart: string, period: 'daily' | 'weekly' | 'mont
|
||||
}
|
||||
}
|
||||
|
||||
// Handle click outside to close user dropdown
|
||||
// Handle click outside to close dropdowns
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (!target.closest('[data-assign-user-search]')) showUserDropdown.value = false
|
||||
if (!target.closest('[data-filter-user-search]')) showFilterUserDropdown.value = false
|
||||
if (columnDropdownRef.value && !columnDropdownRef.value.contains(target)) {
|
||||
showColumnDropdown.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUserColumnMode()
|
||||
loadSavedColumns()
|
||||
loadSubscriptions()
|
||||
loadGroups()
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
|
||||
@@ -17,12 +17,19 @@
|
||||
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
|
||||
</div>
|
||||
</div>
|
||||
<UsageFilters v-model="filters" v-model:startDate="startDate" v-model:endDate="endDate" :exporting="exporting" @change="applyFilters" @reset="resetFilters" @export="exportToExcel" />
|
||||
<UsageFilters v-model="filters" v-model:startDate="startDate" v-model:endDate="endDate" :exporting="exporting" @change="applyFilters" @reset="resetFilters" @cleanup="openCleanupDialog" @export="exportToExcel" />
|
||||
<UsageTable :data="usageLogs" :loading="loading" />
|
||||
<Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" />
|
||||
</div>
|
||||
</AppLayout>
|
||||
<UsageExportProgress :show="exportProgress.show" :progress="exportProgress.progress" :current="exportProgress.current" :total="exportProgress.total" :estimated-time="exportProgress.estimatedTime" @cancel="cancelExport" />
|
||||
<UsageCleanupDialog
|
||||
:show="cleanupDialogVisible"
|
||||
:filters="filters"
|
||||
:start-date="startDate"
|
||||
:end-date="endDate"
|
||||
@close="cleanupDialogVisible = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -33,15 +40,17 @@ import { useAppStore } from '@/stores/app'; import { adminAPI } from '@/api/admi
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'; import Pagination from '@/components/common/Pagination.vue'; import Select from '@/components/common/Select.vue'
|
||||
import UsageStatsCards from '@/components/admin/usage/UsageStatsCards.vue'; import UsageFilters from '@/components/admin/usage/UsageFilters.vue'
|
||||
import UsageTable from '@/components/admin/usage/UsageTable.vue'; import UsageExportProgress from '@/components/admin/usage/UsageExportProgress.vue'
|
||||
import UsageCleanupDialog from '@/components/admin/usage/UsageCleanupDialog.vue'
|
||||
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'; import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
|
||||
import type { UsageLog, TrendDataPoint, ModelStat } from '@/types'; import type { AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage'
|
||||
import type { AdminUsageLog, TrendDataPoint, ModelStat } from '@/types'; import type { AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const usageStats = ref<AdminUsageStatsResponse | null>(null); const usageLogs = ref<UsageLog[]>([]); const loading = ref(false); const exporting = ref(false)
|
||||
const usageStats = ref<AdminUsageStatsResponse | null>(null); const usageLogs = ref<AdminUsageLog[]>([]); const loading = ref(false); const exporting = ref(false)
|
||||
const trendData = ref<TrendDataPoint[]>([]); const modelStats = ref<ModelStat[]>([]); const chartsLoading = ref(false); const granularity = ref<'day' | 'hour'>('day')
|
||||
let abortController: AbortController | null = null; let exportAbortController: AbortController | null = null
|
||||
const exportProgress = reactive({ show: false, progress: 0, current: 0, total: 0, estimatedTime: '' })
|
||||
const cleanupDialogVisible = ref(false)
|
||||
|
||||
const granularityOptions = computed(() => [{ value: 'day', label: t('admin.dashboard.day') }, { value: 'hour', label: t('admin.dashboard.hour') }])
|
||||
// Use local timezone to avoid UTC timezone issues
|
||||
@@ -53,7 +62,7 @@ const formatLD = (d: Date) => {
|
||||
}
|
||||
const now = new Date(); const weekAgo = new Date(); weekAgo.setDate(weekAgo.getDate() - 6)
|
||||
const startDate = ref(formatLD(weekAgo)); const endDate = ref(formatLD(now))
|
||||
const filters = ref<AdminUsageQueryParams>({ user_id: undefined, model: undefined, group_id: undefined, start_date: startDate.value, end_date: endDate.value })
|
||||
const filters = ref<AdminUsageQueryParams>({ user_id: undefined, model: undefined, group_id: undefined, billing_type: null, start_date: startDate.value, end_date: endDate.value })
|
||||
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
|
||||
|
||||
const loadLogs = async () => {
|
||||
@@ -67,22 +76,23 @@ const loadStats = async () => { try { const s = await adminAPI.usage.getStats(fi
|
||||
const loadChartData = async () => {
|
||||
chartsLoading.value = true
|
||||
try {
|
||||
const params = { start_date: filters.value.start_date || startDate.value, end_date: filters.value.end_date || endDate.value, granularity: granularity.value, user_id: filters.value.user_id, model: filters.value.model, api_key_id: filters.value.api_key_id, account_id: filters.value.account_id, group_id: filters.value.group_id, stream: filters.value.stream }
|
||||
const [trendRes, modelRes] = await Promise.all([adminAPI.dashboard.getUsageTrend(params), adminAPI.dashboard.getModelStats({ start_date: params.start_date, end_date: params.end_date, user_id: params.user_id, model: params.model, api_key_id: params.api_key_id, account_id: params.account_id, group_id: params.group_id, stream: params.stream })])
|
||||
const params = { start_date: filters.value.start_date || startDate.value, end_date: filters.value.end_date || endDate.value, granularity: granularity.value, user_id: filters.value.user_id, model: filters.value.model, api_key_id: filters.value.api_key_id, account_id: filters.value.account_id, group_id: filters.value.group_id, stream: filters.value.stream, billing_type: filters.value.billing_type }
|
||||
const [trendRes, modelRes] = await Promise.all([adminAPI.dashboard.getUsageTrend(params), adminAPI.dashboard.getModelStats({ start_date: params.start_date, end_date: params.end_date, user_id: params.user_id, model: params.model, api_key_id: params.api_key_id, account_id: params.account_id, group_id: params.group_id, stream: params.stream, billing_type: params.billing_type })])
|
||||
trendData.value = trendRes.trend || []; modelStats.value = modelRes.models || []
|
||||
} catch (error) { console.error('Failed to load chart data:', error) } finally { chartsLoading.value = false }
|
||||
}
|
||||
const applyFilters = () => { pagination.page = 1; loadLogs(); loadStats(); loadChartData() }
|
||||
const resetFilters = () => { startDate.value = formatLD(weekAgo); endDate.value = formatLD(now); filters.value = { start_date: startDate.value, end_date: endDate.value }; granularity.value = 'day'; applyFilters() }
|
||||
const resetFilters = () => { startDate.value = formatLD(weekAgo); endDate.value = formatLD(now); filters.value = { start_date: startDate.value, end_date: endDate.value, billing_type: null }; granularity.value = 'day'; applyFilters() }
|
||||
const handlePageChange = (p: number) => { pagination.page = p; loadLogs() }
|
||||
const handlePageSizeChange = (s: number) => { pagination.page_size = s; pagination.page = 1; loadLogs() }
|
||||
const cancelExport = () => exportAbortController?.abort()
|
||||
const openCleanupDialog = () => { cleanupDialogVisible.value = true }
|
||||
|
||||
const exportToExcel = async () => {
|
||||
if (exporting.value) return; exporting.value = true; exportProgress.show = true
|
||||
const c = new AbortController(); exportAbortController = c
|
||||
try {
|
||||
const all: UsageLog[] = []; let p = 1; let total = pagination.total
|
||||
const all: AdminUsageLog[] = []; let p = 1; let total = pagination.total
|
||||
while (true) {
|
||||
const res = await adminUsageAPI.list({ page: p, page_size: 100, ...filters.value }, { signal: c.signal })
|
||||
if (c.signal.aborted) break; if (p === 1) { total = res.total; exportProgress.total = total }
|
||||
|
||||
@@ -492,7 +492,7 @@ import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { User, UserAttributeDefinition } from '@/types'
|
||||
import type { AdminUser, UserAttributeDefinition } from '@/types'
|
||||
import type { BatchUserUsageStats } from '@/api/admin/dashboard'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
@@ -637,7 +637,7 @@ const columns = computed<Column[]>(() =>
|
||||
)
|
||||
)
|
||||
|
||||
const users = ref<User[]>([])
|
||||
const users = ref<AdminUser[]>([])
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
|
||||
@@ -736,16 +736,16 @@ const showEditModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const showApiKeysModal = ref(false)
|
||||
const showAttributesModal = ref(false)
|
||||
const editingUser = ref<User | null>(null)
|
||||
const deletingUser = ref<User | null>(null)
|
||||
const viewingUser = ref<User | null>(null)
|
||||
const editingUser = ref<AdminUser | null>(null)
|
||||
const deletingUser = ref<AdminUser | null>(null)
|
||||
const viewingUser = ref<AdminUser | null>(null)
|
||||
let abortController: AbortController | null = null
|
||||
|
||||
// Action Menu State
|
||||
const activeMenuId = ref<number | null>(null)
|
||||
const menuPosition = ref<{ top: number; left: number } | null>(null)
|
||||
|
||||
const openActionMenu = (user: User, e: MouseEvent) => {
|
||||
const openActionMenu = (user: AdminUser, e: MouseEvent) => {
|
||||
if (activeMenuId.value === user.id) {
|
||||
closeActionMenu()
|
||||
} else {
|
||||
@@ -821,11 +821,11 @@ const handleClickOutside = (event: MouseEvent) => {
|
||||
|
||||
// Allowed groups modal state
|
||||
const showAllowedGroupsModal = ref(false)
|
||||
const allowedGroupsUser = ref<User | null>(null)
|
||||
const allowedGroupsUser = ref<AdminUser | null>(null)
|
||||
|
||||
// Balance (Deposit/Withdraw) modal state
|
||||
const showBalanceModal = ref(false)
|
||||
const balanceUser = ref<User | null>(null)
|
||||
const balanceUser = ref<AdminUser | null>(null)
|
||||
const balanceOperation = ref<'add' | 'subtract'>('add')
|
||||
|
||||
// 计算剩余天数
|
||||
@@ -998,7 +998,7 @@ const applyFilter = () => {
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
const handleEdit = (user: User) => {
|
||||
const handleEdit = (user: AdminUser) => {
|
||||
editingUser.value = user
|
||||
showEditModal.value = true
|
||||
}
|
||||
@@ -1008,7 +1008,7 @@ const closeEditModal = () => {
|
||||
editingUser.value = null
|
||||
}
|
||||
|
||||
const handleToggleStatus = async (user: User) => {
|
||||
const handleToggleStatus = async (user: AdminUser) => {
|
||||
const newStatus = user.status === 'active' ? 'disabled' : 'active'
|
||||
try {
|
||||
await adminAPI.users.toggleStatus(user.id, newStatus)
|
||||
@@ -1022,7 +1022,7 @@ const handleToggleStatus = async (user: User) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewApiKeys = (user: User) => {
|
||||
const handleViewApiKeys = (user: AdminUser) => {
|
||||
viewingUser.value = user
|
||||
showApiKeysModal.value = true
|
||||
}
|
||||
@@ -1032,7 +1032,7 @@ const closeApiKeysModal = () => {
|
||||
viewingUser.value = null
|
||||
}
|
||||
|
||||
const handleAllowedGroups = (user: User) => {
|
||||
const handleAllowedGroups = (user: AdminUser) => {
|
||||
allowedGroupsUser.value = user
|
||||
showAllowedGroupsModal.value = true
|
||||
}
|
||||
@@ -1042,7 +1042,7 @@ const closeAllowedGroupsModal = () => {
|
||||
allowedGroupsUser.value = null
|
||||
}
|
||||
|
||||
const handleDelete = (user: User) => {
|
||||
const handleDelete = (user: AdminUser) => {
|
||||
deletingUser.value = user
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
@@ -1061,13 +1061,13 @@ const confirmDelete = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeposit = (user: User) => {
|
||||
const handleDeposit = (user: AdminUser) => {
|
||||
balanceUser.value = user
|
||||
balanceOperation.value = 'add'
|
||||
showBalanceModal.value = true
|
||||
}
|
||||
|
||||
const handleWithdraw = (user: User) => {
|
||||
const handleWithdraw = (user: AdminUser) => {
|
||||
balanceUser.value = user
|
||||
balanceOperation.value = 'subtract'
|
||||
showBalanceModal.value = true
|
||||
|
||||
@@ -505,6 +505,16 @@ async function saveAllSettings() {
|
||||
</div>
|
||||
<Toggle v-model="advancedSettings.ignore_no_available_accounts" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.ignoreInvalidApiKeyErrors') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
{{ t('admin.ops.settings.ignoreInvalidApiKeyErrorsHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="advancedSettings.ignore_invalid_api_key_errors" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto Refresh -->
|
||||
|
||||
297
frontend/src/views/auth/ForgotPasswordView.vue
Normal file
297
frontend/src/views/auth/ForgotPasswordView.vue
Normal file
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<AuthLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Title -->
|
||||
<div class="text-center">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ t('auth.forgotPasswordTitle') }}
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ t('auth.forgotPasswordHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Success State -->
|
||||
<div v-if="isSubmitted" class="space-y-6">
|
||||
<div class="rounded-xl border border-green-200 bg-green-50 p-6 dark:border-green-800/50 dark:bg-green-900/20">
|
||||
<div class="flex flex-col items-center gap-4 text-center">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-800/50">
|
||||
<Icon name="checkCircle" size="lg" class="text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-green-800 dark:text-green-200">
|
||||
{{ t('auth.resetEmailSent') }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-green-700 dark:text-green-300">
|
||||
{{ t('auth.resetEmailSentHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<router-link
|
||||
to="/login"
|
||||
class="inline-flex items-center gap-2 font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
<Icon name="arrowLeft" size="sm" />
|
||||
{{ t('auth.backToLogin') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form State -->
|
||||
<form v-else @submit.prevent="handleSubmit" class="space-y-5">
|
||||
<!-- Email Input -->
|
||||
<div>
|
||||
<label for="email" class="input-label">
|
||||
{{ t('auth.emailLabel') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
|
||||
<Icon name="mail" size="md" class="text-gray-400 dark:text-dark-500" />
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
v-model="formData.email"
|
||||
type="email"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="email"
|
||||
:disabled="isLoading"
|
||||
class="input pl-11"
|
||||
:class="{ 'input-error': errors.email }"
|
||||
:placeholder="t('auth.emailPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="errors.email" class="input-error-text">
|
||||
{{ errors.email }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Turnstile Widget -->
|
||||
<div v-if="turnstileEnabled && turnstileSiteKey">
|
||||
<TurnstileWidget
|
||||
ref="turnstileRef"
|
||||
:site-key="turnstileSiteKey"
|
||||
@verify="onTurnstileVerify"
|
||||
@expire="onTurnstileExpire"
|
||||
@error="onTurnstileError"
|
||||
/>
|
||||
<p v-if="errors.turnstile" class="input-error-text mt-2 text-center">
|
||||
{{ errors.turnstile }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<Icon name="exclamationCircle" size="md" class="text-red-500" />
|
||||
</div>
|
||||
<p class="text-sm text-red-700 dark:text-red-400">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isLoading || (turnstileEnabled && !turnstileToken)"
|
||||
class="btn btn-primary w-full"
|
||||
>
|
||||
<svg
|
||||
v-if="isLoading"
|
||||
class="-ml-1 mr-2 h-4 w-4 animate-spin text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<Icon v-else name="mail" size="md" class="mr-2" />
|
||||
{{ isLoading ? t('auth.sendingResetLink') : t('auth.sendResetLink') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<template #footer>
|
||||
<p class="text-gray-500 dark:text-dark-400">
|
||||
{{ t('auth.rememberedPassword') }}
|
||||
<router-link
|
||||
to="/login"
|
||||
class="font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
{{ t('auth.signIn') }}
|
||||
</router-link>
|
||||
</p>
|
||||
</template>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { getPublicSettings, forgotPassword } from '@/api/auth'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// ==================== Stores ====================
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
// ==================== State ====================
|
||||
|
||||
const isLoading = ref<boolean>(false)
|
||||
const isSubmitted = ref<boolean>(false)
|
||||
const errorMessage = ref<string>('')
|
||||
|
||||
// Public settings
|
||||
const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
|
||||
// Turnstile
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||
const turnstileToken = ref<string>('')
|
||||
|
||||
const formData = reactive({
|
||||
email: ''
|
||||
})
|
||||
|
||||
const errors = reactive({
|
||||
email: '',
|
||||
turnstile: ''
|
||||
})
|
||||
|
||||
// ==================== Lifecycle ====================
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const settings = await getPublicSettings()
|
||||
turnstileEnabled.value = settings.turnstile_enabled
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings:', error)
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== Turnstile Handlers ====================
|
||||
|
||||
function onTurnstileVerify(token: string): void {
|
||||
turnstileToken.value = token
|
||||
errors.turnstile = ''
|
||||
}
|
||||
|
||||
function onTurnstileExpire(): void {
|
||||
turnstileToken.value = ''
|
||||
errors.turnstile = t('auth.turnstileExpired')
|
||||
}
|
||||
|
||||
function onTurnstileError(): void {
|
||||
turnstileToken.value = ''
|
||||
errors.turnstile = t('auth.turnstileFailed')
|
||||
}
|
||||
|
||||
// ==================== Validation ====================
|
||||
|
||||
function validateForm(): boolean {
|
||||
errors.email = ''
|
||||
errors.turnstile = ''
|
||||
|
||||
let isValid = true
|
||||
|
||||
// Email validation
|
||||
if (!formData.email.trim()) {
|
||||
errors.email = t('auth.emailRequired')
|
||||
isValid = false
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
errors.email = t('auth.invalidEmail')
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Turnstile validation
|
||||
if (turnstileEnabled.value && !turnstileToken.value) {
|
||||
errors.turnstile = t('auth.completeVerification')
|
||||
isValid = false
|
||||
}
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
// ==================== Form Handlers ====================
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
errorMessage.value = ''
|
||||
|
||||
if (!validateForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
await forgotPassword({
|
||||
email: formData.email,
|
||||
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined
|
||||
})
|
||||
|
||||
isSubmitted.value = true
|
||||
appStore.showSuccess(t('auth.resetEmailSent'))
|
||||
} catch (error: unknown) {
|
||||
// Reset Turnstile on error
|
||||
if (turnstileRef.value) {
|
||||
turnstileRef.value.reset()
|
||||
turnstileToken.value = ''
|
||||
}
|
||||
|
||||
const err = error as { message?: string; response?: { data?: { detail?: string } } }
|
||||
|
||||
if (err.response?.data?.detail) {
|
||||
errorMessage.value = err.response.data.detail
|
||||
} else if (err.message) {
|
||||
errorMessage.value = err.message
|
||||
} else {
|
||||
errorMessage.value = t('auth.sendResetLinkFailed')
|
||||
}
|
||||
|
||||
appStore.showError(errorMessage.value)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
@@ -72,9 +72,19 @@
|
||||
<Icon v-else name="eye" size="md" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="errors.password" class="input-error-text">
|
||||
{{ errors.password }}
|
||||
</p>
|
||||
<div class="mt-1 flex items-center justify-between">
|
||||
<p v-if="errors.password" class="input-error-text">
|
||||
{{ errors.password }}
|
||||
</p>
|
||||
<span v-else></span>
|
||||
<router-link
|
||||
v-if="passwordResetEnabled"
|
||||
to="/forgot-password"
|
||||
class="text-sm font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
{{ t('auth.forgotPassword') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Turnstile Widget -->
|
||||
@@ -153,6 +163,16 @@
|
||||
</p>
|
||||
</template>
|
||||
</AuthLayout>
|
||||
|
||||
<!-- 2FA Modal -->
|
||||
<TotpLoginModal
|
||||
v-if="show2FAModal"
|
||||
ref="totpModalRef"
|
||||
:temp-token="totpTempToken"
|
||||
:user-email-masked="totpUserEmailMasked"
|
||||
@verify="handle2FAVerify"
|
||||
@cancel="handle2FACancel"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -161,10 +181,12 @@ import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
|
||||
import TotpLoginModal from '@/components/auth/TotpLoginModal.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
import { getPublicSettings } from '@/api/auth'
|
||||
import { getPublicSettings, isTotp2FARequired } from '@/api/auth'
|
||||
import type { TotpLoginResponse } from '@/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -184,11 +206,18 @@ const showPassword = ref<boolean>(false)
|
||||
const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const linuxdoOAuthEnabled = ref<boolean>(false)
|
||||
const passwordResetEnabled = ref<boolean>(false)
|
||||
|
||||
// Turnstile
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||
const turnstileToken = ref<string>('')
|
||||
|
||||
// 2FA state
|
||||
const show2FAModal = ref<boolean>(false)
|
||||
const totpTempToken = ref<string>('')
|
||||
const totpUserEmailMasked = ref<string>('')
|
||||
const totpModalRef = ref<InstanceType<typeof TotpLoginModal> | null>(null)
|
||||
|
||||
const formData = reactive({
|
||||
email: '',
|
||||
password: ''
|
||||
@@ -216,6 +245,7 @@ onMounted(async () => {
|
||||
turnstileEnabled.value = settings.turnstile_enabled
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
|
||||
passwordResetEnabled.value = settings.password_reset_enabled
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings:', error)
|
||||
}
|
||||
@@ -290,12 +320,22 @@ async function handleLogin(): Promise<void> {
|
||||
|
||||
try {
|
||||
// Call auth store login
|
||||
await authStore.login({
|
||||
const response = await authStore.login({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined
|
||||
})
|
||||
|
||||
// Check if 2FA is required
|
||||
if (isTotp2FARequired(response)) {
|
||||
const totpResponse = response as TotpLoginResponse
|
||||
totpTempToken.value = totpResponse.temp_token || ''
|
||||
totpUserEmailMasked.value = totpResponse.user_email_masked || ''
|
||||
show2FAModal.value = true
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Show success toast
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
|
||||
@@ -326,6 +366,40 @@ async function handleLogin(): Promise<void> {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 2FA Handlers ====================
|
||||
|
||||
async function handle2FAVerify(code: string): Promise<void> {
|
||||
if (totpModalRef.value) {
|
||||
totpModalRef.value.setVerifying(true)
|
||||
}
|
||||
|
||||
try {
|
||||
await authStore.login2FA(totpTempToken.value, code)
|
||||
|
||||
// Close modal and show success
|
||||
show2FAModal.value = false
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
|
||||
// Redirect to dashboard or intended route
|
||||
const redirectTo = (router.currentRoute.value.query.redirect as string) || '/dashboard'
|
||||
await router.push(redirectTo)
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string; response?: { data?: { message?: string } } }
|
||||
const message = err.response?.data?.message || err.message || t('profile.totp.loginFailed')
|
||||
|
||||
if (totpModalRef.value) {
|
||||
totpModalRef.value.setError(message)
|
||||
totpModalRef.value.setVerifying(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handle2FACancel(): void {
|
||||
show2FAModal.value = false
|
||||
totpTempToken.value = ''
|
||||
totpUserEmailMasked.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Promo Code Input (Optional) -->
|
||||
<div>
|
||||
<div v-if="promoCodeEnabled">
|
||||
<label for="promo_code" class="input-label">
|
||||
{{ t('auth.promoCodeLabel') }}
|
||||
<span class="ml-1 text-xs font-normal text-gray-400 dark:text-dark-500">({{ t('common.optional') }})</span>
|
||||
@@ -260,6 +260,7 @@ const showPassword = ref<boolean>(false)
|
||||
// Public settings
|
||||
const registrationEnabled = ref<boolean>(true)
|
||||
const emailVerifyEnabled = ref<boolean>(false)
|
||||
const promoCodeEnabled = ref<boolean>(true)
|
||||
const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const siteName = ref<string>('Sub2API')
|
||||
@@ -294,22 +295,25 @@ const errors = reactive({
|
||||
// ==================== Lifecycle ====================
|
||||
|
||||
onMounted(async () => {
|
||||
// Read promo code from URL parameter
|
||||
const promoParam = route.query.promo as string
|
||||
if (promoParam) {
|
||||
formData.promo_code = promoParam
|
||||
// Validate the promo code from URL
|
||||
await validatePromoCodeDebounced(promoParam)
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await getPublicSettings()
|
||||
registrationEnabled.value = settings.registration_enabled
|
||||
emailVerifyEnabled.value = settings.email_verify_enabled
|
||||
promoCodeEnabled.value = settings.promo_code_enabled
|
||||
turnstileEnabled.value = settings.turnstile_enabled
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
siteName.value = settings.site_name || 'Sub2API'
|
||||
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
|
||||
|
||||
// Read promo code from URL parameter only if promo code is enabled
|
||||
if (promoCodeEnabled.value) {
|
||||
const promoParam = route.query.promo as string
|
||||
if (promoParam) {
|
||||
formData.promo_code = promoParam
|
||||
// Validate the promo code from URL
|
||||
await validatePromoCodeDebounced(promoParam)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings:', error)
|
||||
} finally {
|
||||
|
||||
355
frontend/src/views/auth/ResetPasswordView.vue
Normal file
355
frontend/src/views/auth/ResetPasswordView.vue
Normal file
@@ -0,0 +1,355 @@
|
||||
<template>
|
||||
<AuthLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Title -->
|
||||
<div class="text-center">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ t('auth.resetPasswordTitle') }}
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ t('auth.resetPasswordHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Invalid Link State -->
|
||||
<div v-if="isInvalidLink" class="space-y-6">
|
||||
<div class="rounded-xl border border-red-200 bg-red-50 p-6 dark:border-red-800/50 dark:bg-red-900/20">
|
||||
<div class="flex flex-col items-center gap-4 text-center">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-800/50">
|
||||
<Icon name="exclamationCircle" size="lg" class="text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-red-800 dark:text-red-200">
|
||||
{{ t('auth.invalidResetLink') }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-red-700 dark:text-red-300">
|
||||
{{ t('auth.invalidResetLinkHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<router-link
|
||||
to="/forgot-password"
|
||||
class="inline-flex items-center gap-2 font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
{{ t('auth.requestNewResetLink') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success State -->
|
||||
<div v-else-if="isSuccess" class="space-y-6">
|
||||
<div class="rounded-xl border border-green-200 bg-green-50 p-6 dark:border-green-800/50 dark:bg-green-900/20">
|
||||
<div class="flex flex-col items-center gap-4 text-center">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-800/50">
|
||||
<Icon name="checkCircle" size="lg" class="text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-green-800 dark:text-green-200">
|
||||
{{ t('auth.passwordResetSuccess') }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-green-700 dark:text-green-300">
|
||||
{{ t('auth.passwordResetSuccessHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<router-link
|
||||
to="/login"
|
||||
class="btn btn-primary inline-flex items-center gap-2"
|
||||
>
|
||||
<Icon name="login" size="md" />
|
||||
{{ t('auth.signIn') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form State -->
|
||||
<form v-else @submit.prevent="handleSubmit" class="space-y-5">
|
||||
<!-- Email (readonly) -->
|
||||
<div>
|
||||
<label for="email" class="input-label">
|
||||
{{ t('auth.emailLabel') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
|
||||
<Icon name="mail" size="md" class="text-gray-400 dark:text-dark-500" />
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
:value="email"
|
||||
type="email"
|
||||
readonly
|
||||
disabled
|
||||
class="input pl-11 bg-gray-50 dark:bg-dark-700"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Password Input -->
|
||||
<div>
|
||||
<label for="password" class="input-label">
|
||||
{{ t('auth.newPassword') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
|
||||
<Icon name="lock" size="md" class="text-gray-400 dark:text-dark-500" />
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
v-model="formData.password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
:disabled="isLoading"
|
||||
class="input pl-11 pr-11"
|
||||
:class="{ 'input-error': errors.password }"
|
||||
:placeholder="t('auth.newPasswordPlaceholder')"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3.5 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-dark-300"
|
||||
>
|
||||
<Icon v-if="showPassword" name="eyeOff" size="md" />
|
||||
<Icon v-else name="eye" size="md" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="errors.password" class="input-error-text">
|
||||
{{ errors.password }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password Input -->
|
||||
<div>
|
||||
<label for="confirmPassword" class="input-label">
|
||||
{{ t('auth.confirmPassword') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
|
||||
<Icon name="lock" size="md" class="text-gray-400 dark:text-dark-500" />
|
||||
</div>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
v-model="formData.confirmPassword"
|
||||
:type="showConfirmPassword ? 'text' : 'password'"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
:disabled="isLoading"
|
||||
class="input pl-11 pr-11"
|
||||
:class="{ 'input-error': errors.confirmPassword }"
|
||||
:placeholder="t('auth.confirmPasswordPlaceholder')"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="showConfirmPassword = !showConfirmPassword"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3.5 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-dark-300"
|
||||
>
|
||||
<Icon v-if="showConfirmPassword" name="eyeOff" size="md" />
|
||||
<Icon v-else name="eye" size="md" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="errors.confirmPassword" class="input-error-text">
|
||||
{{ errors.confirmPassword }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<Icon name="exclamationCircle" size="md" class="text-red-500" />
|
||||
</div>
|
||||
<p class="text-sm text-red-700 dark:text-red-400">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isLoading"
|
||||
class="btn btn-primary w-full"
|
||||
>
|
||||
<svg
|
||||
v-if="isLoading"
|
||||
class="-ml-1 mr-2 h-4 w-4 animate-spin text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<Icon v-else name="checkCircle" size="md" class="mr-2" />
|
||||
{{ isLoading ? t('auth.resettingPassword') : t('auth.resetPassword') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<template #footer>
|
||||
<p class="text-gray-500 dark:text-dark-400">
|
||||
{{ t('auth.rememberedPassword') }}
|
||||
<router-link
|
||||
to="/login"
|
||||
class="font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
{{ t('auth.signIn') }}
|
||||
</router-link>
|
||||
</p>
|
||||
</template>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { resetPassword } from '@/api/auth'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// ==================== Router & Stores ====================
|
||||
|
||||
const route = useRoute()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// ==================== State ====================
|
||||
|
||||
const isLoading = ref<boolean>(false)
|
||||
const isSuccess = ref<boolean>(false)
|
||||
const errorMessage = ref<string>('')
|
||||
const showPassword = ref<boolean>(false)
|
||||
const showConfirmPassword = ref<boolean>(false)
|
||||
|
||||
// URL parameters
|
||||
const email = ref<string>('')
|
||||
const token = ref<string>('')
|
||||
|
||||
const formData = reactive({
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
const errors = reactive({
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
// Check if the reset link is valid (has email and token)
|
||||
const isInvalidLink = computed(() => !email.value || !token.value)
|
||||
|
||||
// ==================== Lifecycle ====================
|
||||
|
||||
onMounted(() => {
|
||||
// Get email and token from URL query parameters
|
||||
email.value = (route.query.email as string) || ''
|
||||
token.value = (route.query.token as string) || ''
|
||||
})
|
||||
|
||||
// ==================== Validation ====================
|
||||
|
||||
function validateForm(): boolean {
|
||||
errors.password = ''
|
||||
errors.confirmPassword = ''
|
||||
|
||||
let isValid = true
|
||||
|
||||
// Password validation
|
||||
if (!formData.password) {
|
||||
errors.password = t('auth.passwordRequired')
|
||||
isValid = false
|
||||
} else if (formData.password.length < 6) {
|
||||
errors.password = t('auth.passwordMinLength')
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Confirm password validation
|
||||
if (!formData.confirmPassword) {
|
||||
errors.confirmPassword = t('auth.confirmPasswordRequired')
|
||||
isValid = false
|
||||
} else if (formData.password !== formData.confirmPassword) {
|
||||
errors.confirmPassword = t('auth.passwordsDoNotMatch')
|
||||
isValid = false
|
||||
}
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
// ==================== Form Handlers ====================
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
errorMessage.value = ''
|
||||
|
||||
if (!validateForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
await resetPassword({
|
||||
email: email.value,
|
||||
token: token.value,
|
||||
new_password: formData.password
|
||||
})
|
||||
|
||||
isSuccess.value = true
|
||||
appStore.showSuccess(t('auth.passwordResetSuccess'))
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string; response?: { data?: { detail?: string; code?: string } } }
|
||||
|
||||
// Check for invalid/expired token error
|
||||
if (err.response?.data?.code === 'INVALID_RESET_TOKEN') {
|
||||
errorMessage.value = t('auth.invalidOrExpiredToken')
|
||||
} else if (err.response?.data?.detail) {
|
||||
errorMessage.value = err.response.data.detail
|
||||
} else if (err.message) {
|
||||
errorMessage.value = err.message
|
||||
} else {
|
||||
errorMessage.value = t('auth.resetPasswordFailed')
|
||||
}
|
||||
|
||||
appStore.showError(errorMessage.value)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
@@ -91,6 +91,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-xl border border-gray-200 p-3 dark:border-dark-700">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t("setup.redis.enableTls") }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ t("setup.redis.enableTlsHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="formData.redis.enable_tls" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('setup.database.username') }}</label>
|
||||
@@ -517,7 +529,8 @@ const formData = reactive<InstallRequest>({
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
password: '',
|
||||
db: 0
|
||||
db: 0,
|
||||
enable_tls: false
|
||||
},
|
||||
admin: {
|
||||
email: '',
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
</button>
|
||||
<!-- Import to CC Switch Button -->
|
||||
<button
|
||||
v-if="!publicSettings?.hide_ccs_import_button"
|
||||
@click="importToCcswitch(row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
||||
>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
</div>
|
||||
<ProfileEditForm :initial-username="user?.username || ''" />
|
||||
<ProfilePasswordForm />
|
||||
<ProfileTotpCard />
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -27,6 +28,7 @@ import StatCard from '@/components/common/StatCard.vue'
|
||||
import ProfileInfoCard from '@/components/user/profile/ProfileInfoCard.vue'
|
||||
import ProfileEditForm from '@/components/user/profile/ProfileEditForm.vue'
|
||||
import ProfilePasswordForm from '@/components/user/profile/ProfilePasswordForm.vue'
|
||||
import ProfileTotpCard from '@/components/user/profile/ProfileTotpCard.vue'
|
||||
import { Icon } from '@/components/icons'
|
||||
|
||||
const { t } = useI18n(); const authStore = useAuthStore(); const user = computed(() => authStore.user)
|
||||
|
||||
121
frontend/src/views/user/PurchaseSubscriptionView.vue
Normal file
121
frontend/src/views/user/PurchaseSubscriptionView.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="purchase-page-layout">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('purchase.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ t('purchase.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<a
|
||||
v-if="isValidUrl"
|
||||
:href="purchaseUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
<Icon name="externalLink" size="sm" class="mr-1.5" :stroke-width="2" />
|
||||
{{ t('purchase.openInNewTab') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card flex-1 min-h-0 overflow-hidden">
|
||||
<div v-if="loading" class="flex h-full items-center justify-center py-12">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-2 border-primary-500 border-t-transparent"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!purchaseEnabled"
|
||||
class="flex h-full items-center justify-center p-10 text-center"
|
||||
>
|
||||
<div class="max-w-md">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700"
|
||||
>
|
||||
<Icon name="creditCard" size="lg" class="text-gray-400" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('purchase.notEnabledTitle') }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ t('purchase.notEnabledDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!isValidUrl"
|
||||
class="flex h-full items-center justify-center p-10 text-center"
|
||||
>
|
||||
<div class="max-w-md">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700"
|
||||
>
|
||||
<Icon name="link" size="lg" class="text-gray-400" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('purchase.notConfiguredTitle') }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ t('purchase.notConfiguredDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<iframe v-else :src="purchaseUrl" class="h-full w-full border-0" allowfullscreen></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const purchaseEnabled = computed(() => {
|
||||
return appStore.cachedPublicSettings?.purchase_subscription_enabled ?? false
|
||||
})
|
||||
|
||||
const purchaseUrl = computed(() => {
|
||||
return (appStore.cachedPublicSettings?.purchase_subscription_url || '').trim()
|
||||
})
|
||||
|
||||
const isValidUrl = computed(() => {
|
||||
const url = purchaseUrl.value
|
||||
return url.startsWith('http://') || url.startsWith('https://')
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (appStore.publicSettingsLoaded) return
|
||||
loading.value = true
|
||||
try {
|
||||
await appStore.fetchPublicSettings()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.purchase-page-layout {
|
||||
@apply flex flex-col gap-6;
|
||||
height: calc(100vh - 64px - 4rem); /* 减去 header + lg:p-8 的上下padding */
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -312,6 +312,14 @@
|
||||
<p v-else class="text-xs text-gray-400 dark:text-dark-500">
|
||||
{{ t('redeem.adminAdjustment') }}
|
||||
</p>
|
||||
<!-- Display notes for admin adjustments -->
|
||||
<p
|
||||
v-if="item.notes"
|
||||
class="mt-1 text-xs text-gray-500 dark:text-dark-400 italic max-w-[200px] truncate"
|
||||
:title="item.notes"
|
||||
>
|
||||
{{ item.notes }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,5 +21,6 @@
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/**", "src/**/*.spec.ts", "src/**/*.test.ts"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineConfig, Plugin } from 'vite'
|
||||
import { defineConfig, loadEnv, Plugin } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import checker from 'vite-plugin-checker'
|
||||
import { resolve } from 'path'
|
||||
@@ -7,9 +7,7 @@ import { resolve } from 'path'
|
||||
* Vite 插件:开发模式下注入公开配置到 index.html
|
||||
* 与生产模式的后端注入行为保持一致,消除闪烁
|
||||
*/
|
||||
function injectPublicSettings(): Plugin {
|
||||
const backendUrl = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:8080'
|
||||
|
||||
function injectPublicSettings(backendUrl: string): Plugin {
|
||||
return {
|
||||
name: 'inject-public-settings',
|
||||
transformIndexHtml: {
|
||||
@@ -35,15 +33,21 @@ function injectPublicSettings(): Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
checker({
|
||||
typescript: true,
|
||||
vueTsc: true
|
||||
}),
|
||||
injectPublicSettings()
|
||||
],
|
||||
export default defineConfig(({ mode }) => {
|
||||
// 加载环境变量
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const backendUrl = env.VITE_DEV_PROXY_TARGET || 'http://localhost:8080'
|
||||
const devPort = Number(env.VITE_DEV_PORT || 3000)
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
vue(),
|
||||
checker({
|
||||
typescript: true,
|
||||
vueTsc: true
|
||||
}),
|
||||
injectPublicSettings(backendUrl)
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
@@ -102,17 +106,18 @@ export default defineConfig({
|
||||
}
|
||||
}
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: Number(process.env.VITE_DEV_PORT || 3000),
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:8080',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/setup': {
|
||||
target: process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:8080',
|
||||
changeOrigin: true
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: devPort,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: backendUrl,
|
||||
changeOrigin: true
|
||||
},
|
||||
'/setup': {
|
||||
target: backendUrl,
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user