Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
a161fcc8
Commit
a161fcc8
authored
Jan 26, 2026
by
cyhhao
Browse files
Merge branch 'main' of github.com:Wei-Shaw/sub2api
parents
65e69738
e32c5f53
Changes
119
Hide whitespace changes
Inline
Side-by-side
backend/internal/service/user_subscription_port.go
View file @
a161fcc8
...
...
@@ -18,7 +18,7 @@ type UserSubscriptionRepository interface {
ListByUserID
(
ctx
context
.
Context
,
userID
int64
)
([]
UserSubscription
,
error
)
ListActiveByUserID
(
ctx
context
.
Context
,
userID
int64
)
([]
UserSubscription
,
error
)
ListByGroupID
(
ctx
context
.
Context
,
groupID
int64
,
params
pagination
.
PaginationParams
)
([]
UserSubscription
,
*
pagination
.
PaginationResult
,
error
)
List
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
userID
,
groupID
*
int64
,
status
string
)
([]
UserSubscription
,
*
pagination
.
PaginationResult
,
error
)
List
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
userID
,
groupID
*
int64
,
status
,
sortBy
,
sortOrder
string
)
([]
UserSubscription
,
*
pagination
.
PaginationResult
,
error
)
ExistsByUserIDAndGroupID
(
ctx
context
.
Context
,
userID
,
groupID
int64
)
(
bool
,
error
)
ExtendExpiry
(
ctx
context
.
Context
,
subscriptionID
int64
,
newExpiresAt
time
.
Time
)
error
...
...
backend/internal/service/wire.go
View file @
a161fcc8
...
...
@@ -72,6 +72,13 @@ func ProvideAccountExpiryService(accountRepo AccountRepository) *AccountExpirySe
return
svc
}
// ProvideSubscriptionExpiryService creates and starts SubscriptionExpiryService.
func
ProvideSubscriptionExpiryService
(
userSubRepo
UserSubscriptionRepository
)
*
SubscriptionExpiryService
{
svc
:=
NewSubscriptionExpiryService
(
userSubRepo
,
time
.
Minute
)
svc
.
Start
()
return
svc
}
// ProvideTimingWheelService creates and starts TimingWheelService
func
ProvideTimingWheelService
()
(
*
TimingWheelService
,
error
)
{
svc
,
err
:=
NewTimingWheelService
()
...
...
@@ -256,6 +263,7 @@ var ProviderSet = wire.NewSet(
ProvideUpdateService
,
ProvideTokenRefreshService
,
ProvideAccountExpiryService
,
ProvideSubscriptionExpiryService
,
ProvideTimingWheelService
,
ProvideDashboardAggregationService
,
ProvideUsageCleanupService
,
...
...
@@ -263,4 +271,5 @@ var ProviderSet = wire.NewSet(
NewAntigravityQuotaFetcher
,
NewUserAttributeService
,
NewUsageCache
,
NewTotpService
,
)
backend/internal/util/urlvalidator/validator.go
View file @
a161fcc8
...
...
@@ -46,7 +46,7 @@ func ValidateURLFormat(raw string, allowInsecureHTTP bool) (string, error) {
}
}
return
trimmed
,
nil
return
strings
.
TrimRight
(
trimmed
,
"/"
),
nil
}
func
ValidateHTTPSURL
(
raw
string
,
opts
ValidationOptions
)
(
string
,
error
)
{
...
...
backend/internal/util/urlvalidator/validator_test.go
View file @
a161fcc8
...
...
@@ -21,4 +21,31 @@ func TestValidateURLFormat(t *testing.T) {
if
_
,
err
:=
ValidateURLFormat
(
"https://example.com:bad"
,
true
);
err
==
nil
{
t
.
Fatalf
(
"expected invalid port to fail"
)
}
// 验证末尾斜杠被移除
normalized
,
err
:=
ValidateURLFormat
(
"https://example.com/"
,
false
)
if
err
!=
nil
{
t
.
Fatalf
(
"expected trailing slash url to pass, got %v"
,
err
)
}
if
normalized
!=
"https://example.com"
{
t
.
Fatalf
(
"expected trailing slash to be removed, got %s"
,
normalized
)
}
// 验证多个末尾斜杠被移除
normalized
,
err
=
ValidateURLFormat
(
"https://example.com///"
,
false
)
if
err
!=
nil
{
t
.
Fatalf
(
"expected multiple trailing slashes to pass, got %v"
,
err
)
}
if
normalized
!=
"https://example.com"
{
t
.
Fatalf
(
"expected all trailing slashes to be removed, got %s"
,
normalized
)
}
// 验证带路径的 URL 末尾斜杠被移除
normalized
,
err
=
ValidateURLFormat
(
"https://example.com/api/v1/"
,
false
)
if
err
!=
nil
{
t
.
Fatalf
(
"expected trailing slash url with path to pass, got %v"
,
err
)
}
if
normalized
!=
"https://example.com/api/v1"
{
t
.
Fatalf
(
"expected trailing slash to be removed from path, got %s"
,
normalized
)
}
}
backend/migrations/044_add_user_totp.sql
0 → 100644
View file @
a161fcc8
-- 为 users 表添加 TOTP 双因素认证字段
ALTER
TABLE
users
ADD
COLUMN
IF
NOT
EXISTS
totp_secret_encrypted
TEXT
DEFAULT
NULL
,
ADD
COLUMN
IF
NOT
EXISTS
totp_enabled
BOOLEAN
NOT
NULL
DEFAULT
FALSE
,
ADD
COLUMN
IF
NOT
EXISTS
totp_enabled_at
TIMESTAMPTZ
DEFAULT
NULL
;
COMMENT
ON
COLUMN
users
.
totp_secret_encrypted
IS
'AES-256-GCM 加密的 TOTP 密钥'
;
COMMENT
ON
COLUMN
users
.
totp_enabled
IS
'是否启用 TOTP 双因素认证'
;
COMMENT
ON
COLUMN
users
.
totp_enabled_at
IS
'TOTP 启用时间'
;
-- 创建索引以支持快速查询启用 2FA 的用户
CREATE
INDEX
IF
NOT
EXISTS
idx_users_totp_enabled
ON
users
(
totp_enabled
)
WHERE
deleted_at
IS
NULL
AND
totp_enabled
=
true
;
deploy/.env.example
View file @
a161fcc8
...
...
@@ -61,6 +61,18 @@ ADMIN_PASSWORD=
JWT_SECRET=
JWT_EXPIRE_HOUR=24
# -----------------------------------------------------------------------------
# TOTP (2FA) Configuration
# TOTP(双因素认证)配置
# -----------------------------------------------------------------------------
# IMPORTANT: Set a fixed encryption key for TOTP secrets. If left empty, a
# random key will be generated on each startup, causing all existing TOTP
# configurations to become invalid (users won't be able to login with 2FA).
# Generate a secure key: openssl rand -hex 32
# 重要:设置固定的 TOTP 加密密钥。如果留空,每次启动将生成随机密钥,
# 导致现有的 TOTP 配置失效(用户无法使用双因素认证登录)。
TOTP_ENCRYPTION_KEY=
# -----------------------------------------------------------------------------
# Configuration File (Optional)
# -----------------------------------------------------------------------------
...
...
deploy/config.example.yaml
View file @
a161fcc8
...
...
@@ -403,6 +403,21 @@ jwt:
# 令牌过期时间(小时,最大 24)
expire_hour
:
24
# =============================================================================
# TOTP (2FA) Configuration
# TOTP 双因素认证配置
# =============================================================================
totp
:
# IMPORTANT: Set a fixed encryption key for TOTP secrets.
# 重要:设置固定的 TOTP 加密密钥。
# If left empty, a random key will be generated on each startup, causing all
# existing TOTP configurations to become invalid (users won't be able to
# login with 2FA).
# 如果留空,每次启动将生成随机密钥,导致现有的 TOTP 配置失效(用户无法使用
# 双因素认证登录)。
# Generate with / 生成命令: openssl rand -hex 32
encryption_key
:
"
"
# =============================================================================
# LinuxDo Connect OAuth Login (SSO)
# LinuxDo Connect OAuth 登录(用于 Sub2API 用户登录)
...
...
deploy/docker-compose.yml
View file @
a161fcc8
...
...
@@ -79,6 +79,16 @@ services:
-
JWT_SECRET=${JWT_SECRET:-}
-
JWT_EXPIRE_HOUR=${JWT_EXPIRE_HOUR:-24}
# =======================================================================
# TOTP (2FA) Configuration
# =======================================================================
# IMPORTANT: Set a fixed encryption key for TOTP secrets. If left empty,
# a random key will be generated on each startup, causing all existing
# TOTP configurations to become invalid (users won't be able to login
# with 2FA).
# Generate a secure key: openssl rand -hex 32
-
TOTP_ENCRYPTION_KEY=${TOTP_ENCRYPTION_KEY:-}
# =======================================================================
# Timezone Configuration
# This affects ALL time operations in the application:
...
...
frontend/package-lock.json
View file @
a161fcc8
...
...
@@ -15,6 +15,7 @@
"driver.js": "^1.4.0",
"file-saver": "^2.0.5",
"pinia": "^2.1.7",
"qrcode": "^1.5.4",
"vue": "^3.4.0",
"vue-chartjs": "^5.3.0",
"vue-i18n": "^9.14.5",
...
...
@@ -25,6 +26,7 @@
"@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",
...
...
@@ -1680,6 +1682,16 @@
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
"license": "MIT"
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmmirror.com/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.20",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
...
...
@@ -2354,7 +2366,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev"
:
true
,
"license": "MIT",
"engines": {
"node": ">=8"
...
...
@@ -2364,7 +2375,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev"
:
true
,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
...
...
@@ -2646,6 +2656,15 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/camelcase-css": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
...
...
@@ -2784,6 +2803,51 @@
"node": ">= 6"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/cliui/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/cliui/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
...
...
@@ -2806,7 +2870,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev"
:
true
,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
...
...
@@ -2819,7 +2882,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev"
:
true
,
"license": "MIT"
},
"node_modules/combined-stream": {
...
...
@@ -2989,6 +3051,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
...
...
@@ -3029,6 +3100,12 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
...
...
@@ -3759,6 +3836,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
...
...
@@ -4156,7 +4242,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev"
:
true
,
"license": "MIT",
"engines": {
"node": ">=8"
...
...
@@ -4883,6 +4968,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
...
...
@@ -4957,7 +5051,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev"
:
true
,
"license": "MIT",
"engines": {
"node": ">=8"
...
...
@@ -5093,6 +5186,15 @@
"node": ">= 6"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/polished": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz",
...
...
@@ -5313,6 +5415,23 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmmirror.com/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
...
...
@@ -5370,6 +5489,21 @@
"node": ">=8.10.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
...
...
@@ -5543,6 +5677,12 @@
"node": ">=10"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
...
...
@@ -5714,7 +5854,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev"
:
true
,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
...
...
@@ -6715,6 +6854,12 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
...
...
@@ -6928,6 +7073,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
...
...
@@ -6937,6 +7088,113 @@
"node": ">= 6"
}
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yargs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/yargs/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yargs/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
...
...
frontend/package.json
View file @
a161fcc8
...
...
@@ -22,6 +22,7 @@
"driver.js"
:
"^1.4.0"
,
"file-saver"
:
"^2.0.5"
,
"pinia"
:
"^2.1.7"
,
"qrcode"
:
"^1.5.4"
,
"vue"
:
"^3.4.0"
,
"vue-chartjs"
:
"^5.3.0"
,
"vue-i18n"
:
"^9.14.5"
,
...
...
@@ -32,6 +33,7 @@
"@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"
,
...
...
frontend/pnpm-lock.yaml
View file @
a161fcc8
...
...
@@ -29,6 +29,9 @@ importers:
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)
...
...
@@ -54,6 +57,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 +1245,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==
}
...
...
@@ -1479,6 +1496,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 +1852,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 +1919,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 +2191,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 +2229,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 +2458,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 +2526,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 +2898,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 +3285,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 +3399,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 +3483,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 +3731,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 +3813,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 +4340,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 +4365,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 +4408,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'
}
...
...
@@ -5838,6 +5933,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 +6420,8 @@ snapshots:
camelcase-css@2.0.1
:
{}
camelcase@5.3.1
:
{}
caniuse-lite@1.0.30001761
:
{}
ccount@2.0.1
:
{}
...
...
@@ -6395,6 +6496,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 +6775,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 +6803,8 @@ snapshots:
didyoumean@1.2.2
:
{}
dijkstrajs@1.0.3
:
{}
dir-glob@3.0.1
:
dependencies
:
path-type
:
4.0.0
...
...
@@ -6978,6 +7089,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 +7145,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 +7639,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 +8316,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 +8416,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 +8486,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 +8843,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 +8932,8 @@ snapshots:
semver@7.7.3
:
{}
set-blocking@2.0.0
:
{}
set-value@2.0.1
:
dependencies
:
extend-shallow
:
2.0.1
...
...
@@ -9298,6 +9444,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 +9461,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 +9499,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)
:
...
...
frontend/src/api/admin/settings.ts
View file @
a161fcc8
...
...
@@ -13,6 +13,9 @@ export interface SystemSettings {
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
...
...
@@ -66,6 +69,8 @@ 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
...
...
frontend/src/api/admin/subscriptions.ts
View file @
a161fcc8
...
...
@@ -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
...
...
frontend/src/api/auth.ts
View file @
a161fcc8
...
...
@@ -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
'
,
c
re
dentials
)
export
async
function
login
2FA
(
request
:
Totp
Login
2FA
Request
):
Promise
<
AuthResponse
>
{
const
{
data
}
=
await
apiClient
.
post
<
AuthResponse
>
(
'
/auth/login
/2fa
'
,
re
quest
)
// 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
frontend/src/api/index.ts
View file @
a161fcc8
...
...
@@ -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,7 @@ export { usageAPI } from './usage'
export
{
userAPI
}
from
'
./user
'
export
{
redeemAPI
,
type
RedeemHistoryItem
}
from
'
./redeem
'
export
{
userGroupsAPI
}
from
'
./groups
'
export
{
totpAPI
}
from
'
./totp
'
// Admin APIs
export
{
adminAPI
}
from
'
./admin
'
...
...
frontend/src/api/totp.ts
0 → 100644
View file @
a161fcc8
/**
* 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
frontend/src/components/account/AccountStatusIndicator.vue
View file @
a161fcc8
<
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,44 +56,6 @@
></div>
</div>
</div>
<!-- Rate Limit Indicator (429) -->
<div
v-if=
"isRateLimited"
class=
"group relative"
>
<span
class=
"inline-flex items-center gap-1 rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
>
<Icon
name=
"exclamationTriangle"
size=
"xs"
:stroke-width=
"2"
/>
429
</span>
<!-- Tooltip -->
<div
class=
"pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
{{
t
(
'
admin.accounts.status.rateLimitedUntil
'
,
{
time
:
formatTime
(
account
.
rate_limit_reset_at
)
}
)
}}
<
div
class
=
"
absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700
"
><
/div
>
<
/div
>
<
/div
>
<!--
Overload
Indicator
(
529
)
-->
<
div
v
-
if
=
"
isOverloaded
"
class
=
"
group relative
"
>
<
span
class
=
"
inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400
"
>
<
Icon
name
=
"
exclamationTriangle
"
size
=
"
xs
"
:
stroke
-
width
=
"
2
"
/>
529
<
/span
>
<!--
Tooltip
-->
<
div
class
=
"
pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700
"
>
{{
t
(
'
admin.accounts.status.overloadedUntil
'
,
{
time
:
formatTime
(
account
.
overload_until
)
}
)
}}
<
div
class
=
"
absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700
"
><
/div
>
<
/div
>
<
/div
>
</div>
</template>
...
...
@@ -87,8 +63,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
()
...
...
@@ -123,6 +98,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
)
{
...
...
@@ -131,7 +116,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
)
{
...
...
@@ -157,9 +142,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
}
`
)
})
...
...
@@ -167,5 +149,4 @@ const handleTempUnschedClick = () => {
if
(
!
isTempUnschedulable
.
value
)
return
emit
(
'
show-temp-unsched
'
,
props
.
account
)
}
</
script
>
frontend/src/components/admin/account/AccountActionMenu.vue
View file @
a161fcc8
<
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
>
frontend/src/components/auth/TotpLoginModal.vue
0 → 100644
View file @
a161fcc8
<
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
>
frontend/src/components/common/DataTable.vue
View file @
a161fcc8
...
...
@@ -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
'
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
=
newOrder
emit
(
'
sort
'
,
key
,
newOrder
)
}
else
{
// Client-side sort mode: just update internal state
sortKey
.
value
=
key
sortOrder
.
value
=
'
asc
'
sortOrder
.
value
=
newOrder
}
}
const
sortedData
=
computed
(()
=>
{
if
(
!
sortKey
.
value
||
!
props
.
data
)
return
props
.
data
return
[...
props
.
data
].
sort
((
a
,
b
)
=>
{
const
aVal
=
a
[
sortKey
.
value
]
const
bVal
=
b
[
sortKey
.
value
]
if
(
aVal
===
bVal
)
return
0
const
comparison
=
aVal
>
bVal
?
1
:
-
1
return
sortOrder
.
value
===
'
asc
'
?
comparison
:
-
comparison
})
// Server-side sort mode: return data as-is (server handles sorting)
if
(
props
.
serverSideSort
||
!
sortKey
.
value
||
!
props
.
data
)
return
props
.
data
const
key
=
sortKey
.
value
const
order
=
sortOrder
.
value
// 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
>
...
...
Prev
1
2
3
4
5
6
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment