From 3a8dbf5a9957eb7bc6ca8d54e4b40e64c5f29743 Mon Sep 17 00:00:00 2001 From: yangjianbo Date: Sat, 27 Dec 2025 10:57:53 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20golang=201.24->=201.25=20node=202?= =?UTF-8?q?0=20->=20node=2024=20=E5=85=B7=E4=BD=93=E6=8F=90=E5=8D=87?= =?UTF-8?q?=E8=AF=B7=E6=9F=A5=E7=9C=8B=E5=AE=98=E6=96=B9=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 098d4e3a..6bdcd94f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ # ----------------------------------------------------------------------------- # Stage 1: Frontend Builder # ----------------------------------------------------------------------------- -FROM node:20-alpine AS frontend-builder +FROM node:24-alpine AS frontend-builder WORKDIR /app/frontend @@ -24,7 +24,7 @@ RUN npm run build # ----------------------------------------------------------------------------- # Stage 2: Backend Builder # ----------------------------------------------------------------------------- -FROM golang:1.24-alpine AS backend-builder +FROM golang:1.25-alpine AS backend-builder # Build arguments for version info (set by CI) ARG VERSION=docker From 3252c378aa497fe59ab5b8aa600bc50df095f547 Mon Sep 17 00:00:00 2001 From: yangjianbo Date: Sat, 27 Dec 2025 21:30:14 +0800 Subject: [PATCH 02/10] =?UTF-8?q?feat=20=E5=A2=9E=E5=8A=A0=20caddy=20?= =?UTF-8?q?=E7=A4=BA=E4=BE=8B=E5=AE=89=E5=85=A8=E5=8F=8D=E5=90=91=E4=BB=A3?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/Caddyfile | 184 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 deploy/Caddyfile diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 00000000..eaba462b --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,184 @@ +# ============================================================================= +# Sub2API Caddy Reverse Proxy Configuration (宿主机部署) +# ============================================================================= +# 使用方法: +# 1. 安装 Caddy: https://caddyserver.com/docs/install +# 2. 修改下方 example.com 为你的域名 +# 3. 确保域名 DNS 已指向服务器 +# 4. 复制配置: sudo cp Caddyfile /etc/caddy/Caddyfile +# 5. 重载配置: sudo systemctl reload caddy +# +# Caddy 会自动申请和续期 Let's Encrypt SSL 证书 +# ============================================================================= + +# 全局配置 +{ + # Let's Encrypt 邮箱通知 + email admin@example.com + + # 服务器配置 + servers { + # 启用 HTTP/2 和 HTTP/3 + protocols h1 h2 h3 + + # 超时配置 + timeouts { + read_body 30s + read_header 10s + write 60s + idle 120s + } + } +} + +# 修改为你的域名 +example.com { + # ========================================================================= + # TLS 安全配置 + # ========================================================================= + tls { + # 仅使用 TLS 1.2 和 1.3 + protocols tls1.2 tls1.3 + + # 优先使用的加密套件 + ciphers TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 + } + + # ========================================================================= + # 反向代理配置 + # ========================================================================= + reverse_proxy localhost:8080 { + # 健康检查 + health_uri /health + health_interval 30s + health_timeout 10s + health_status 200 + + # 负载均衡策略(单节点可忽略,多节点时有用) + lb_policy round_robin + lb_try_duration 5s + lb_try_interval 250ms + + # 传递真实客户端信息 + # 兼容 Cloudflare 和直连:后端应优先读取 CF-Connecting-IP,其次 X-Real-IP + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + header_up X-Forwarded-Host {host} + # 保留 Cloudflare 原始头(如果存在) + # 后端获取 IP 的优先级建议: CF-Connecting-IP → X-Real-IP → X-Forwarded-For + header_up CF-Connecting-IP {http.request.header.CF-Connecting-IP} + + # 连接池优化 + transport http { + keepalive 120s + keepalive_idle_conns 256 + read_buffer 16KB + write_buffer 16KB + compression off + } + + # 故障转移 + fail_duration 30s + max_fails 3 + unhealthy_status 500 502 503 504 + } + + # ========================================================================= + # 压缩配置 + # ========================================================================= + encode { + zstd + gzip 6 + minimum_length 256 + match { + header Content-Type text/* + header Content-Type application/json* + header Content-Type application/javascript* + header Content-Type application/xml* + header Content-Type application/rss+xml* + header Content-Type image/svg+xml* + } + } + + # ========================================================================= + # 速率限制 (需要 caddy-ratelimit 插件) + # 如未安装插件,请注释掉此段 + # ========================================================================= + # rate_limit { + # zone api { + # key {remote_host} + # events 100 + # window 1m + # } + # } + + # ========================================================================= + # 安全响应头 + # ========================================================================= + header { + # 防止点击劫持 + X-Frame-Options "SAMEORIGIN" + + # XSS 保护 + X-XSS-Protection "1; mode=block" + + # 防止 MIME 类型嗅探 + X-Content-Type-Options "nosniff" + + # 引用策略 + Referrer-Policy "strict-origin-when-cross-origin" + + # HSTS - 强制 HTTPS (max-age=1年) + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + + # 内容安全策略 (根据需要调整) + # Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:;" + + # 权限策略 + Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()" + + # 跨域资源策略 + Cross-Origin-Opener-Policy "same-origin" + Cross-Origin-Embedder-Policy "require-corp" + Cross-Origin-Resource-Policy "same-origin" + + # 移除敏感头 + -Server + -X-Powered-By + } + + # ========================================================================= + # 请求大小限制 (防止大文件攻击) + # ========================================================================= + request_body { + max_size 100MB + } + + # ========================================================================= + # 日志配置 + # ========================================================================= + log { + output file /var/log/caddy/sub2api.log { + roll_size 50mb + roll_keep 10 + roll_keep_for 720h + } + format json + level INFO + } + + # ========================================================================= + # 错误处理 + # ========================================================================= + handle_errors { + respond "{err.status_code} {err.status_text}" + } +} + +# ============================================================================= +# HTTP 重定向到 HTTPS (Caddy 默认自动处理,此处显式声明) +# ============================================================================= +; http://example.com { +; redir https://{host}{uri} permanent +; } From 97ab649d16f9f85fc2167bf2deb237596053251f Mon Sep 17 00:00:00 2001 From: yangjianbo Date: Sat, 27 Dec 2025 23:08:38 +0800 Subject: [PATCH 03/10] =?UTF-8?q?fix(=E4=BB=AA=E8=A1=A8=E7=9B=98):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9C=80=E8=BF=91=E7=94=A8=E9=87=8F=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E6=97=A5=E6=9C=9F=E5=8F=82=E6=95=B0=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:仪表盘“最近用量”调用 /usage 时传入完整 ISO 时间戳(含时分秒/时区),后端 start_date/end_date 仅接受 YYYY-MM-DD,导致请求参数校验失败,页面无法正常展示最近用量。 解决: - loadRecentUsage 改为传入 YYYY-MM-DD(从 toISOString() 取日期部分),与后端参数格式约定保持一致 - 补充注释说明:后端会将 end_date 扩展到当日结束时间,以及 toISOString() 为 UTC 可能带来的统计口径差异 - 同步修正 usageAPI.getByDateRange 的参数注释,避免后续误用 验证:npm -C frontend run build --- frontend/src/api/usage.ts | 4 ++-- frontend/src/views/user/DashboardView.vue | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/frontend/src/api/usage.ts b/frontend/src/api/usage.ts index 20581603..caf763de 100644 --- a/frontend/src/api/usage.ts +++ b/frontend/src/api/usage.ts @@ -148,8 +148,8 @@ export async function getStatsByDateRange( /** * Get usage by date range - * @param startDate - Start date (ISO format) - * @param endDate - End date (ISO format) + * @param startDate - Start date (YYYY-MM-DD format) + * @param endDate - End date (YYYY-MM-DD format) * @param apiKeyId - Optional API key ID filter * @returns Usage logs within date range */ diff --git a/frontend/src/views/user/DashboardView.vue b/frontend/src/views/user/DashboardView.vue index f214c98a..199a0efc 100644 --- a/frontend/src/views/user/DashboardView.vue +++ b/frontend/src/views/user/DashboardView.vue @@ -987,8 +987,13 @@ const loadChartData = async () => { const loadRecentUsage = async () => { loadingUsage.value = true try { - const endDate = new Date().toISOString() - const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString() + // 后端 /usage 查询参数 start_date/end_date 仅接受 YYYY-MM-DD(见 backend usage handler 的校验逻辑)。 + // 同时后端会将 end_date 自动扩展到当天 23:59:59.999...,因此前端只需要传「日期」即可。 + // 注意:toISOString() 生成的是 UTC 日期字符串;如果需要按本地/服务端时区对齐统计口径, + // 请改用时区感知的日期格式化方法(例如 Intl.DateTimeFormat 指定 timeZone)。 + const now = new Date() + const endDate = now.toISOString().split('T')[0] + const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] const usageResponse = await usageAPI.getByDateRange(startDate, endDate) recentUsage.value = usageResponse.items.slice(0, 5) } catch (error) { From fd51ff697092bf1948c987de97b4d1be1c1bf591 Mon Sep 17 00:00:00 2001 From: yangjianbo Date: Sun, 28 Dec 2025 14:34:05 +0800 Subject: [PATCH 04/10] =?UTF-8?q?fix:=20=E4=BB=A3=E7=A0=81=E7=9A=84?= =?UTF-8?q?=E6=A0=B8=E5=BF=83=E9=97=AE=E9=A2=98=E6=98=AF=E5=88=A4=E9=94=99?= =?UTF-8?q?=E6=9D=A1=E4=BB=B6=E7=94=A8=E9=94=99=E4=BA=86=E5=B1=82=E7=BA=A7?= =?UTF-8?q?=EF=BC=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - apiKeyService.GetByKey(...) 返回的“找不到 API key”在这个项目里通常会被翻译成业务错误(比如 service.ErrApiKeyNotFound 这类 ApplicationError),而不是直接把 gorm.ErrRecordNotFound 透传到中 间件层。 - 因此你在中间件里用 errors.Is(err, gorm.ErrRecordNotFound) 去判断“无效 key”,很容易匹配不到(尤其 是:后面加 Redis 缓存、换存储实现、或测试里用 stub repo 时,根本不会出现 gorm 的错误)。 - 匹配不到时就会走到 500 Failed to validate API key,导致无效 API key 被错误地当成服务端故障返回 500(应该是 401)。 修复思路:中间件不要依赖 gorm 的错误,改成判断业务层错误,例如: if errors.Is(err, service.ErrApiKeyNotFound) { abortWithGoogleError(c, 401, "Invalid API key") return } 如果你把 GetByKey 的“not found”统一封装成业务错误,这样才不会被底层实现(gorm/redis/mock)影响。 --- backend/internal/server/middleware/api_key_auth_google.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/internal/server/middleware/api_key_auth_google.go b/backend/internal/server/middleware/api_key_auth_google.go index e37b389e..199aca82 100644 --- a/backend/internal/server/middleware/api_key_auth_google.go +++ b/backend/internal/server/middleware/api_key_auth_google.go @@ -8,7 +8,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" - "gorm.io/gorm" ) // ApiKeyAuthGoogle is a Google-style error wrapper for API key auth. @@ -30,7 +29,7 @@ func ApiKeyAuthWithSubscriptionGoogle(apiKeyService *service.ApiKeyService, subs apiKey, err := apiKeyService.GetByKey(c.Request.Context(), apiKeyString) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { + if errors.Is(err, service.ErrApiKeyNotFound) { abortWithGoogleError(c, 401, "Invalid API key") return } From 506cb21cb1d1aa7b94a6586e7da52c44104a1510 Mon Sep 17 00:00:00 2001 From: IanShaw027 <131567472+IanShaw027@users.noreply.github.com> Date: Sun, 28 Dec 2025 01:00:06 +0800 Subject: [PATCH 05/10] =?UTF-8?q?refactor(frontend):=20UI/UX=E6=94=B9?= =?UTF-8?q?=E8=BF=9B=E5=92=8C=E7=BB=84=E4=BB=B6=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DataTable组件操作列自适应 - 优化各种Modal弹窗 - 统一API调用方式(AbortSignal) - 添加全局订阅状态管理 - 优化各管理视图的交互和布局 - 修复国际化翻译问题 --- backend/internal/pkg/gemini/models.go | 4 +- backend/internal/pkg/geminicli/models.go | 6 +- frontend/src/App.vue | 22 +- frontend/src/api/admin/accounts.ts | 6 +- frontend/src/api/admin/groups.ts | 6 +- frontend/src/api/admin/proxies.ts | 6 +- frontend/src/api/admin/redeem.ts | 6 +- frontend/src/api/admin/subscriptions.ts | 6 +- frontend/src/api/admin/usage.ts | 8 +- frontend/src/api/admin/users.ts | 7 +- frontend/src/api/keys.ts | 9 +- frontend/src/api/usage.ts | 15 +- .../components/account/AccountStatsModal.vue | 11 +- .../components/account/AccountTestModal.vue | 8 +- .../account/BulkEditAccountModal.vue | 162 +++++-- .../components/account/CreateAccountModal.vue | 76 +-- .../components/account/EditAccountModal.vue | 32 +- .../account/OAuthAuthorizationFlow.vue | 2 +- .../components/account/ReAuthAccountModal.vue | 29 +- .../components/account/SyncFromCrsModal.vue | 21 +- .../src/components/common/ConfirmDialog.vue | 6 +- frontend/src/components/common/DataTable.vue | 10 +- frontend/src/components/common/Pagination.vue | 4 - frontend/src/components/common/Select.vue | 55 ++- .../common/SubscriptionProgressMini.vue | 39 +- frontend/src/components/common/index.ts | 1 + frontend/src/components/keys/UseKeyModal.vue | 8 +- frontend/src/i18n/locales/en.ts | 5 + frontend/src/i18n/locales/zh.ts | 5 + frontend/src/stores/index.ts | 1 + frontend/src/stores/subscriptions.ts | 135 ++++++ frontend/src/style.css | 29 ++ frontend/src/views/admin/AccountsView.vue | 257 +++++----- frontend/src/views/admin/GroupsView.vue | 74 ++- frontend/src/views/admin/ProxiesView.vue | 141 ++++-- frontend/src/views/admin/RedeemView.vue | 48 +- .../src/views/admin/SubscriptionsView.vue | 77 ++- frontend/src/views/admin/UsageView.vue | 92 +++- frontend/src/views/admin/UsersView.vue | 437 +++++++++++------- frontend/src/views/auth/LoginView.vue | 1 + frontend/src/views/auth/RegisterView.vue | 1 + frontend/src/views/setup/SetupWizardView.vue | 41 +- frontend/src/views/user/DashboardView.vue | 38 +- frontend/src/views/user/KeysView.vue | 57 ++- frontend/src/views/user/ProfileView.vue | 12 + frontend/src/views/user/UsageView.vue | 210 +++++++-- 46 files changed, 1582 insertions(+), 644 deletions(-) create mode 100644 frontend/src/stores/subscriptions.ts diff --git a/backend/internal/pkg/gemini/models.go b/backend/internal/pkg/gemini/models.go index 0af6003d..2be13c44 100644 --- a/backend/internal/pkg/gemini/models.go +++ b/backend/internal/pkg/gemini/models.go @@ -18,8 +18,10 @@ func DefaultModels() []Model { methods := []string{"generateContent", "streamGenerateContent"} return []Model{ {Name: "models/gemini-3-pro-preview", SupportedGenerationMethods: methods}, + {Name: "models/gemini-3-flash-preview", SupportedGenerationMethods: methods}, + {Name: "models/gemini-2.5-pro", SupportedGenerationMethods: methods}, + {Name: "models/gemini-2.5-flash", SupportedGenerationMethods: methods}, {Name: "models/gemini-2.0-flash", SupportedGenerationMethods: methods}, - {Name: "models/gemini-2.0-flash-lite", SupportedGenerationMethods: methods}, {Name: "models/gemini-1.5-pro", SupportedGenerationMethods: methods}, {Name: "models/gemini-1.5-flash", SupportedGenerationMethods: methods}, {Name: "models/gemini-1.5-flash-8b", SupportedGenerationMethods: methods}, diff --git a/backend/internal/pkg/geminicli/models.go b/backend/internal/pkg/geminicli/models.go index 065c7a10..f09bef90 100644 --- a/backend/internal/pkg/geminicli/models.go +++ b/backend/internal/pkg/geminicli/models.go @@ -11,11 +11,11 @@ type Model struct { // DefaultModels is the curated Gemini model list used by the admin UI "test account" flow. var DefaultModels = []Model{ - {ID: "gemini-3-pro", Type: "model", DisplayName: "Gemini 3 Pro", CreatedAt: ""}, - {ID: "gemini-3-flash", Type: "model", DisplayName: "Gemini 3 Flash", CreatedAt: ""}, + {ID: "gemini-3-pro-preview", Type: "model", DisplayName: "Gemini 3 Pro Preview", CreatedAt: ""}, + {ID: "gemini-3-flash-preview", Type: "model", DisplayName: "Gemini 3 Flash Preview", CreatedAt: ""}, {ID: "gemini-2.5-pro", Type: "model", DisplayName: "Gemini 2.5 Pro", CreatedAt: ""}, {ID: "gemini-2.5-flash", Type: "model", DisplayName: "Gemini 2.5 Flash", CreatedAt: ""}, } // DefaultTestModel is the default model to preselect in test flows. -const DefaultTestModel = "gemini-2.5-pro" +const DefaultTestModel = "gemini-3-pro-preview" diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 89aa91bc..8bae7b74 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -2,12 +2,14 @@ import { RouterView, useRouter, useRoute } from 'vue-router' import { onMounted, watch } from 'vue' import Toast from '@/components/common/Toast.vue' -import { useAppStore } from '@/stores' +import { useAppStore, useAuthStore, useSubscriptionStore } from '@/stores' import { getSetupStatus } from '@/api/setup' const router = useRouter() const route = useRoute() const appStore = useAppStore() +const authStore = useAuthStore() +const subscriptionStore = useSubscriptionStore() /** * Update favicon dynamically @@ -46,6 +48,24 @@ watch( { immediate: true } ) +// Watch for authentication state and manage subscription data +watch( + () => authStore.isAuthenticated, + (isAuthenticated) => { + if (isAuthenticated) { + // User logged in: preload subscriptions and start polling + subscriptionStore.fetchActiveSubscriptions().catch((error) => { + console.error('Failed to preload subscriptions:', error) + }) + subscriptionStore.startPolling() + } else { + // User logged out: clear data and stop polling + subscriptionStore.clear() + } + }, + { immediate: true } +) + onMounted(async () => { // Check if setup is needed try { diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index cac50232..dbd4ff15 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -30,6 +30,9 @@ export async function list( type?: string status?: string search?: string + }, + options?: { + signal?: AbortSignal } ): Promise> { const { data } = await apiClient.get>('/admin/accounts', { @@ -37,7 +40,8 @@ export async function list( page, page_size: pageSize, ...filters - } + }, + signal: options?.signal }) return data } diff --git a/frontend/src/api/admin/groups.ts b/frontend/src/api/admin/groups.ts index d48792e7..23db9104 100644 --- a/frontend/src/api/admin/groups.ts +++ b/frontend/src/api/admin/groups.ts @@ -26,6 +26,9 @@ export async function list( platform?: GroupPlatform status?: 'active' | 'inactive' is_exclusive?: boolean + }, + options?: { + signal?: AbortSignal } ): Promise> { const { data } = await apiClient.get>('/admin/groups', { @@ -33,7 +36,8 @@ export async function list( page, page_size: pageSize, ...filters - } + }, + signal: options?.signal }) return data } diff --git a/frontend/src/api/admin/proxies.ts b/frontend/src/api/admin/proxies.ts index 273e1f8a..fe20a205 100644 --- a/frontend/src/api/admin/proxies.ts +++ b/frontend/src/api/admin/proxies.ts @@ -20,6 +20,9 @@ export async function list( protocol?: string status?: 'active' | 'inactive' search?: string + }, + options?: { + signal?: AbortSignal } ): Promise> { const { data } = await apiClient.get>('/admin/proxies', { @@ -27,7 +30,8 @@ export async function list( page, page_size: pageSize, ...filters - } + }, + signal: options?.signal }) return data } diff --git a/frontend/src/api/admin/redeem.ts b/frontend/src/api/admin/redeem.ts index 738b1519..a53c3566 100644 --- a/frontend/src/api/admin/redeem.ts +++ b/frontend/src/api/admin/redeem.ts @@ -25,6 +25,9 @@ export async function list( type?: RedeemCodeType status?: 'active' | 'used' | 'expired' | 'unused' search?: string + }, + options?: { + signal?: AbortSignal } ): Promise> { const { data } = await apiClient.get>('/admin/redeem-codes', { @@ -32,7 +35,8 @@ export async function list( page, page_size: pageSize, ...filters - } + }, + signal: options?.signal }) return data } diff --git a/frontend/src/api/admin/subscriptions.ts b/frontend/src/api/admin/subscriptions.ts index ceabd4ee..54b448e2 100644 --- a/frontend/src/api/admin/subscriptions.ts +++ b/frontend/src/api/admin/subscriptions.ts @@ -27,6 +27,9 @@ export async function list( status?: 'active' | 'expired' | 'revoked' user_id?: number group_id?: number + }, + options?: { + signal?: AbortSignal } ): Promise> { const { data } = await apiClient.get>( @@ -36,7 +39,8 @@ export async function list( page, page_size: pageSize, ...filters - } + }, + signal: options?.signal } ) return data diff --git a/frontend/src/api/admin/usage.ts b/frontend/src/api/admin/usage.ts index 5d4896d3..42c23a87 100644 --- a/frontend/src/api/admin/usage.ts +++ b/frontend/src/api/admin/usage.ts @@ -41,9 +41,13 @@ export interface AdminUsageQueryParams extends UsageQueryParams { * @param params - Query parameters for filtering and pagination * @returns Paginated list of usage logs */ -export async function list(params: AdminUsageQueryParams): Promise> { +export async function list( + params: AdminUsageQueryParams, + options?: { signal?: AbortSignal } +): Promise> { const { data } = await apiClient.get>('/admin/usage', { - params + params, + signal: options?.signal }) return data } diff --git a/frontend/src/api/admin/users.ts b/frontend/src/api/admin/users.ts index 9ba58e8b..2901f4ce 100644 --- a/frontend/src/api/admin/users.ts +++ b/frontend/src/api/admin/users.ts @@ -11,6 +11,7 @@ import type { User, UpdateUserRequest, PaginatedResponse } from '@/types' * @param page - Page number (default: 1) * @param pageSize - Items per page (default: 20) * @param filters - Optional filters (status, role, search) + * @param options - Optional request options (signal) * @returns Paginated list of users */ export async function list( @@ -20,6 +21,9 @@ export async function list( status?: 'active' | 'disabled' role?: 'admin' | 'user' search?: string + }, + options?: { + signal?: AbortSignal } ): Promise> { const { data } = await apiClient.get>('/admin/users', { @@ -27,7 +31,8 @@ export async function list( page, page_size: pageSize, ...filters - } + }, + signal: options?.signal }) return data } diff --git a/frontend/src/api/keys.ts b/frontend/src/api/keys.ts index 5bedbf2c..caa339e4 100644 --- a/frontend/src/api/keys.ts +++ b/frontend/src/api/keys.ts @@ -10,14 +10,19 @@ import type { ApiKey, CreateApiKeyRequest, UpdateApiKeyRequest, PaginatedRespons * List all API keys for current user * @param page - Page number (default: 1) * @param pageSize - Items per page (default: 10) + * @param options - Optional request options * @returns Paginated list of API keys */ export async function list( page: number = 1, - pageSize: number = 10 + pageSize: number = 10, + options?: { + signal?: AbortSignal + } ): Promise> { const { data } = await apiClient.get>('/keys', { - params: { page, page_size: pageSize } + params: { page, page_size: pageSize }, + signal: options?.signal }) return data } diff --git a/frontend/src/api/usage.ts b/frontend/src/api/usage.ts index 20581603..f0aec5fb 100644 --- a/frontend/src/api/usage.ts +++ b/frontend/src/api/usage.ts @@ -90,8 +90,12 @@ export async function list( * @param params - Query parameters for filtering and pagination * @returns Paginated list of usage logs */ -export async function query(params: UsageQueryParams): Promise> { +export async function query( + params: UsageQueryParams, + config: { signal?: AbortSignal } = {} +): Promise> { const { data } = await apiClient.get>('/usage', { + ...config, params }) return data @@ -232,15 +236,22 @@ export interface BatchApiKeysUsageResponse { /** * Get batch usage stats for user's own API keys * @param apiKeyIds - Array of API key IDs + * @param options - Optional request options * @returns Usage stats map keyed by API key ID */ export async function getDashboardApiKeysUsage( - apiKeyIds: number[] + apiKeyIds: number[], + options?: { + signal?: AbortSignal + } ): Promise { const { data } = await apiClient.post( '/usage/dashboard/api-keys-usage', { api_key_ids: apiKeyIds + }, + { + signal: options?.signal } ) return data diff --git a/frontend/src/components/account/AccountStatsModal.vue b/frontend/src/components/account/AccountStatsModal.vue index a82bbfb2..93f38a83 100644 --- a/frontend/src/components/account/AccountStatsModal.vue +++ b/frontend/src/components/account/AccountStatsModal.vue @@ -1,5 +1,10 @@ - + diff --git a/frontend/src/components/common/Select.vue b/frontend/src/components/common/Select.vue index d0e52541..71a41431 100644 --- a/frontend/src/components/common/Select.vue +++ b/frontend/src/components/common/Select.vue @@ -30,7 +30,11 @@ -
+
+ + + + { }) const loadGroups = async () => { + if (abortController) { + abortController.abort() + } + const currentController = new AbortController() + abortController = currentController + const { signal } = currentController loading.value = true try { const response = await adminAPI.groups.list(pagination.page, pagination.page_size, { platform: (filters.platform as GroupPlatform) || undefined, status: filters.status as any, is_exclusive: filters.is_exclusive ? filters.is_exclusive === 'true' : undefined - }) + }, { signal }) + if (signal.aborted) return groups.value = response.items pagination.total = response.total pagination.pages = response.pages - } catch (error) { + } catch (error: any) { + if (signal.aborted || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') { + return + } appStore.showError(t('admin.groups.failedToLoad')) console.error('Error loading groups:', error) } finally { - loading.value = false + if (abortController === currentController && !signal.aborted) { + loading.value = false + } } } @@ -683,6 +719,12 @@ const handlePageChange = (page: number) => { loadGroups() } +const handlePageSizeChange = (pageSize: number) => { + pagination.page_size = pageSize + pagination.page = 1 + loadGroups() +} + const closeCreateModal = () => { showCreateModal.value = false createForm.name = '' diff --git a/frontend/src/views/admin/ProxiesView.vue b/frontend/src/views/admin/ProxiesView.vue index f5d39ef2..a5df9bd0 100644 --- a/frontend/src/views/admin/ProxiesView.vue +++ b/frontend/src/views/admin/ProxiesView.vue @@ -209,15 +209,16 @@ :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" + @update:pageSize="handlePageSizeChange" /> - @@ -271,7 +272,12 @@
-
+
-
- - -
@@ -435,11 +413,44 @@
-
+
+ + + - -
+
@@ -526,11 +542,20 @@

{{ t('admin.subscriptions.validityHint') }}

- -
+ + + -
@@ -417,17 +429,23 @@
- -
+ + + ([]) const groups = ref([]) const users = ref([]) const loading = ref(false) +let abortController: AbortController | null = null const filters = reactive({ status: '', group_id: '' @@ -530,20 +549,36 @@ const subscriptionGroupOptions = computed(() => const userOptions = computed(() => users.value.map((u) => ({ value: u.id, label: u.email }))) const loadSubscriptions = async () => { + if (abortController) { + abortController.abort() + } + const requestController = new AbortController() + abortController = requestController + const { signal } = requestController + loading.value = true try { const response = await adminAPI.subscriptions.list(pagination.page, pagination.page_size, { status: (filters.status as any) || undefined, group_id: filters.group_id ? parseInt(filters.group_id) : undefined + }, { + signal }) + if (signal.aborted || abortController !== requestController) return subscriptions.value = response.items pagination.total = response.total pagination.pages = response.pages - } catch (error) { + } catch (error: any) { + if (signal.aborted || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') { + return + } appStore.showError(t('admin.subscriptions.failedToLoad')) console.error('Error loading subscriptions:', error) } finally { - loading.value = false + if (abortController === requestController) { + loading.value = false + abortController = null + } } } @@ -569,6 +604,12 @@ const handlePageChange = (page: number) => { loadSubscriptions() } +const handlePageSizeChange = (pageSize: number) => { + pagination.page_size = pageSize + pagination.page = 1 + loadSubscriptions() +} + const closeAssignModal = () => { showAssignModal.value = false assignForm.user_id = null diff --git a/frontend/src/views/admin/UsageView.vue b/frontend/src/views/admin/UsageView.vue index bdd9f68f..2165bf2a 100644 --- a/frontend/src/views/admin/UsageView.vue +++ b/frontend/src/views/admin/UsageView.vue @@ -224,7 +224,7 @@ v-model="filters.api_key_id" :options="apiKeyOptions" :placeholder="t('usage.allApiKeys')" - :disabled="!selectedUser && apiKeys.length === 0" + searchable @change="applyFilters" />
@@ -236,6 +236,7 @@ v-model="filters.model" :options="modelOptions" :placeholder="t('admin.usage.allModels')" + searchable @change="applyFilters" /> @@ -534,6 +535,7 @@ :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" + @update:pageSize="handlePageSizeChange" /> @@ -666,6 +668,7 @@ const models = ref([]) const accounts = ref([]) const groups = ref([]) const loading = ref(false) +let abortController: AbortController | null = null // User search state const userSearchKeyword = ref('') @@ -675,7 +678,7 @@ const showUserDropdown = ref(false) const selectedUser = ref(null) let searchTimeout: ReturnType | null = null -// API Key options computed from selected user's keys +// API Key options computed from loaded keys const apiKeyOptions = computed(() => { return [ { value: null, label: t('usage.allApiKeys') }, @@ -796,7 +799,7 @@ const selectUser = async (user: SimpleUser) => { filters.value.api_key_id = undefined // Load API keys for selected user - await loadApiKeysForUser(user.id) + await loadApiKeys(user.id) applyFilters() } @@ -807,10 +810,11 @@ const clearUserFilter = () => { filters.value.user_id = undefined filters.value.api_key_id = undefined apiKeys.value = [] + loadApiKeys() applyFilters() } -const loadApiKeysForUser = async (userId: number) => { +const loadApiKeys = async (userId?: number) => { try { apiKeys.value = await adminAPI.usage.searchApiKeys(userId) } catch (error) { @@ -863,7 +867,24 @@ const formatCacheTokens = (value: number): string => { return value.toLocaleString() } +const isAbortError = (error: unknown): boolean => { + if (error instanceof DOMException && error.name === 'AbortError') { + return true + } + if (typeof error === 'object' && error !== null) { + const maybeError = error as { code?: string; name?: string } + return maybeError.code === 'ERR_CANCELED' || maybeError.name === 'CanceledError' + } + return false +} + const loadUsageLogs = async () => { + if (abortController) { + abortController.abort() + } + const controller = new AbortController() + abortController = controller + const { signal } = controller loading.value = true try { const params: AdminUsageQueryParams = { @@ -872,17 +893,23 @@ const loadUsageLogs = async () => { ...filters.value } - const response = await adminAPI.usage.list(params) + const response = await adminAPI.usage.list(params, { signal }) + if (signal.aborted) { + return + } usageLogs.value = response.items pagination.value.total = response.total pagination.value.pages = response.pages - // Extract models from loaded logs for filter options - extractModelsFromLogs() } catch (error) { + if (signal.aborted || isAbortError(error)) { + return + } appStore.showError(t('usage.failedToLoad')) } finally { - loading.value = false + if (!signal.aborted && abortController === controller) { + loading.value = false + } } } @@ -944,27 +971,37 @@ const applyFilters = () => { // Load filter options const loadFilterOptions = async () => { try { - // Load accounts - const accountsResponse = await adminAPI.accounts.list(1, 1000) + const [accountsResponse, groupsResponse] = await Promise.all([ + adminAPI.accounts.list(1, 1000), + adminAPI.groups.list(1, 1000) + ]) accounts.value = accountsResponse.items || [] - - // Load groups - const groupsResponse = await adminAPI.groups.list(1, 1000) groups.value = groupsResponse.items || [] } catch (error) { console.error('Failed to load filter options:', error) } + await loadModelOptions() } -// Extract unique models from usage logs -const extractModelsFromLogs = () => { - const uniqueModels = new Set() - usageLogs.value.forEach(log => { - if (log.model) { - uniqueModels.add(log.model) - } - }) - models.value = Array.from(uniqueModels).sort() +const loadModelOptions = async () => { + try { + const endDate = new Date() + const startDateRange = new Date(endDate) + startDateRange.setDate(startDateRange.getDate() - 29) + const response = await adminAPI.dashboard.getModelStats({ + start_date: startDateRange.toISOString().split('T')[0], + end_date: endDate.toISOString().split('T')[0] + }) + const uniqueModels = new Set() + response.models?.forEach((stat) => { + if (stat.model) { + uniqueModels.add(stat.model) + } + }) + models.value = Array.from(uniqueModels).sort() + } catch (error) { + console.error('Failed to load model options:', error) + } } const resetFilters = () => { @@ -987,6 +1024,7 @@ const resetFilters = () => { // Reset date range to default (last 7 days) initializeDateRange() pagination.value.page = 1 + loadApiKeys() loadUsageLogs() loadUsageStats() loadChartData() @@ -997,6 +1035,12 @@ const handlePageChange = (page: number) => { loadUsageLogs() } +const handlePageSizeChange = (pageSize: number) => { + pagination.value.page_size = pageSize + pagination.value.page = 1 + loadUsageLogs() +} + const exportToCSV = () => { if (usageLogs.value.length === 0) { appStore.showWarning(t('usage.noDataToExport')) @@ -1072,6 +1116,7 @@ const hideTooltip = () => { onMounted(() => { initializeDateRange() loadFilterOptions() + loadApiKeys() loadUsageLogs() loadUsageStats() loadChartData() @@ -1083,5 +1128,8 @@ onUnmounted(() => { if (searchTimeout) { clearTimeout(searchTimeout) } + if (abortController) { + abortController.abort() + } }) diff --git a/frontend/src/views/admin/UsersView.vue b/frontend/src/views/admin/UsersView.vue index d9ce2036..9288650d 100644 --- a/frontend/src/views/admin/UsersView.vue +++ b/frontend/src/views/admin/UsersView.vue @@ -198,12 +198,13 @@ {{ formatDateTime(value) }} - + + +
+
+ +
+
+
+ - -
+
+
-
+ + - -
+
@@ -664,11 +664,19 @@
-
+ + + + -
@@ -828,13 +836,13 @@
-
+ -
@@ -994,16 +1002,21 @@
-
+ - -
+
-
+ + + - -
+
- -
+ + + (null) const dropdownRef = ref(null) const dropdownPosition = ref<{ top: number; left: number } | null>(null) const groupButtonRefs = ref>(new Map()) +let abortController: AbortController | null = null // Get the currently selected key for group change const selectedKeyForGroup = computed(() => { @@ -623,14 +627,27 @@ const copyToClipboard = async (text: string, keyId: number) => { copiedKeyId.value = keyId setTimeout(() => { copiedKeyId.value = null - }, 2000) + }, 800) } } +const isAbortError = (error: unknown) => { + if (!error || typeof error !== 'object') return false + const { name, code } = error as { name?: string; code?: string } + return name === 'AbortError' || code === 'ERR_CANCELED' +} + const loadApiKeys = async () => { + abortController?.abort() + const controller = new AbortController() + abortController = controller + const { signal } = controller loading.value = true try { - const response = await keysAPI.list(pagination.value.page, pagination.value.page_size) + const response = await keysAPI.list(pagination.value.page, pagination.value.page_size, { + signal + }) + if (signal.aborted) return apiKeys.value = response.items pagination.value.total = response.total pagination.value.pages = response.pages @@ -639,16 +656,24 @@ const loadApiKeys = async () => { if (response.items.length > 0) { const keyIds = response.items.map((k) => k.id) try { - const usageResponse = await usageAPI.getDashboardApiKeysUsage(keyIds) + const usageResponse = await usageAPI.getDashboardApiKeysUsage(keyIds, { signal }) + if (signal.aborted) return usageStats.value = usageResponse.stats } catch (e) { - console.error('Failed to load usage stats:', e) + if (!isAbortError(e)) { + console.error('Failed to load usage stats:', e) + } } } } catch (error) { + if (isAbortError(error)) { + return + } appStore.showError(t('keys.failedToLoad')) } finally { - loading.value = false + if (abortController === controller) { + loading.value = false + } } } @@ -683,6 +708,12 @@ const handlePageChange = (page: number) => { loadApiKeys() } +const handlePageSizeChange = (pageSize: number) => { + pagination.value.page_size = pageSize + pagination.value.page = 1 + loadApiKeys() +} + const editKey = (key: ApiKey) => { selectedKey.value = key formData.value = { diff --git a/frontend/src/views/user/ProfileView.vue b/frontend/src/views/user/ProfileView.vue index ebb079f6..e1b72380 100644 --- a/frontend/src/views/user/ProfileView.vue +++ b/frontend/src/views/user/ProfileView.vue @@ -244,6 +244,12 @@ autocomplete="new-password" class="input" /> +

+ {{ t('profile.passwordsNotMatch') }} +

@@ -392,6 +398,12 @@ const handleChangePassword = async () => { } const handleUpdateProfile = async () => { + // Basic validation + if (!profileForm.value.username.trim()) { + appStore.showError(t('profile.usernameRequired')) + return + } + updatingProfile.value = true try { const updatedUser = await userAPI.updateProfile({ diff --git a/frontend/src/views/user/UsageView.vue b/frontend/src/views/user/UsageView.vue index f9a628e8..b326b4c5 100644 --- a/frontend/src/views/user/UsageView.vue +++ b/frontend/src/views/user/UsageView.vue @@ -164,8 +164,28 @@ -
@@ -366,6 +386,7 @@ :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" + @update:pageSize="handlePageSizeChange" /> @@ -412,7 +433,7 @@ From 4e3499c0d7b66edaf099f8881e4c570b376ca32f Mon Sep 17 00:00:00 2001 From: IanShaw027 <131567472+IanShaw027@users.noreply.github.com> Date: Sun, 28 Dec 2025 10:32:04 +0800 Subject: [PATCH 07/10] =?UTF-8?q?fix(frontend):=20=E6=94=B9=E8=BF=9B?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E7=8A=B6=E6=80=81=E5=AE=9E=E6=97=B6=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 Dashboard 页面加载时强制刷新订阅状态 - 在兑换订阅卡密后立即刷新订阅状态 - 清理订阅轮询相关注释 --- frontend/src/stores/subscriptions.ts | 4 ++-- frontend/src/views/user/DashboardView.vue | 7 +++++++ frontend/src/views/user/RedeemView.vue | 7 +++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/frontend/src/stores/subscriptions.ts b/frontend/src/stores/subscriptions.ts index e63707f7..2bda1e1a 100644 --- a/frontend/src/stores/subscriptions.ts +++ b/frontend/src/stores/subscriptions.ts @@ -79,7 +79,7 @@ export const useSubscriptionStore = defineStore('subscriptions', () => { } /** - * Start auto-refresh polling (every 5 minutes) + * Start auto-refresh polling */ function startPolling() { if (pollerInterval) return @@ -88,7 +88,7 @@ export const useSubscriptionStore = defineStore('subscriptions', () => { fetchActiveSubscriptions(true).catch((error) => { console.error('Subscription polling failed:', error) }) - }, 5 * 60 * 1000) // 5 minutes + }, 5 * 60 * 1000) } /** diff --git a/frontend/src/views/user/DashboardView.vue b/frontend/src/views/user/DashboardView.vue index f9d5ec42..1288ac48 100644 --- a/frontend/src/views/user/DashboardView.vue +++ b/frontend/src/views/user/DashboardView.vue @@ -661,6 +661,7 @@ import { ref, computed, onMounted, watch } from 'vue' import { useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' import { useAuthStore } from '@/stores/auth' +import { useSubscriptionStore } from '@/stores/subscriptions' import { formatDateTime } from '@/utils/format' const { t } = useI18n() @@ -701,6 +702,7 @@ ChartJS.register( const router = useRouter() const authStore = useAuthStore() +const subscriptionStore = useSubscriptionStore() const user = computed(() => authStore.user) const stats = ref(null) @@ -1018,6 +1020,11 @@ onMounted(async () => { // Load critical data first await loadDashboardStats() + // Force refresh subscription status when entering dashboard (bypass cache) + subscriptionStore.fetchActiveSubscriptions(true).catch((error) => { + console.error('Failed to refresh subscription status:', error) + }) + // Initialize date range (synchronous) initializeDateRange() diff --git a/frontend/src/views/user/RedeemView.vue b/frontend/src/views/user/RedeemView.vue index 6e6c1600..7e35916d 100644 --- a/frontend/src/views/user/RedeemView.vue +++ b/frontend/src/views/user/RedeemView.vue @@ -445,6 +445,7 @@ import { ref, computed, onMounted } from 'vue' import { useI18n } from 'vue-i18n' import { useAuthStore } from '@/stores/auth' import { useAppStore } from '@/stores/app' +import { useSubscriptionStore } from '@/stores/subscriptions' import { redeemAPI, authAPI, type RedeemHistoryItem } from '@/api' import AppLayout from '@/components/layout/AppLayout.vue' import { formatDateTime } from '@/utils/format' @@ -452,6 +453,7 @@ import { formatDateTime } from '@/utils/format' const { t } = useI18n() const authStore = useAuthStore() const appStore = useAppStore() +const subscriptionStore = useSubscriptionStore() const user = computed(() => authStore.user) @@ -544,6 +546,11 @@ const handleRedeem = async () => { // Refresh user data to get updated balance/concurrency await authStore.refreshUser() + // If subscription type, immediately refresh subscription status + if (result.type === 'subscription') { + await subscriptionStore.fetchActiveSubscriptions(true) // force refresh + } + // Clear the input redeemCode.value = '' From 5f2d81d154478884fa723ae381bc202fd71d17b9 Mon Sep 17 00:00:00 2001 From: IanShaw027 <131567472+IanShaw027@users.noreply.github.com> Date: Sun, 28 Dec 2025 13:20:30 +0800 Subject: [PATCH 08/10] =?UTF-8?q?fix(frontend):=20=E4=BF=AE=E5=A4=8DUI?= =?UTF-8?q?=E6=94=B9=E8=BF=9B=E5=88=86=E6=94=AF=E4=B8=AD=E7=9A=84=E5=85=B3?= =?UTF-8?q?=E9=94=AE=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复RedeemView订阅刷新失败导致流程中断的问题 将订阅刷新隔离到独立try/catch,失败时仅显示警告 - 修复DataTable resize事件监听器泄漏问题 确保添加和移除使用同一个回调引用 - 修复订阅状态缓存导致强制刷新失效的问题 force=true时绕过activePromise缓存,clear()清空缓存 - 修复图表主题切换后颜色不更新的问题 添加图表ref并在主题切换时调用update()方法 --- frontend/src/components/common/DataTable.vue | 10 +++++++--- frontend/src/i18n/locales/en.ts | 3 ++- frontend/src/i18n/locales/zh.ts | 3 ++- frontend/src/stores/subscriptions.ts | 13 +++++++++---- frontend/src/views/user/DashboardView.vue | 19 ++++++++++++++++--- frontend/src/views/user/RedeemView.vue | 7 ++++++- 6 files changed, 42 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/common/DataTable.vue b/frontend/src/components/common/DataTable.vue index 8abeee0c..9c250bc2 100644 --- a/frontend/src/components/common/DataTable.vue +++ b/frontend/src/components/common/DataTable.vue @@ -211,6 +211,7 @@ const checkActionsColumnWidth = () => { // 监听尺寸变化 let resizeObserver: ResizeObserver | null = null +let resizeHandler: (() => void) | null = null onMounted(() => { checkScrollable() @@ -223,17 +224,20 @@ onMounted(() => { resizeObserver.observe(tableWrapperRef.value) } else { // 降级方案:不支持 ResizeObserver 时使用 window resize - const handleResize = () => { + resizeHandler = () => { checkScrollable() checkActionsColumnWidth() } - window.addEventListener('resize', handleResize) + window.addEventListener('resize', resizeHandler) } }) onUnmounted(() => { resizeObserver?.disconnect() - window.removeEventListener('resize', checkScrollable) + if (resizeHandler) { + window.removeEventListener('resize', resizeHandler) + resizeHandler = null + } }) interface Props { diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 3eb2fef3..285bb199 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -410,7 +410,8 @@ export default { subscriptionDays: '{days} days', days: ' days', codeRedeemSuccess: 'Code redeemed successfully!', - failedToRedeem: 'Failed to redeem code. Please check the code and try again.' + failedToRedeem: 'Failed to redeem code. Please check the code and try again.', + subscriptionRefreshFailed: 'Redeemed successfully, but failed to refresh subscription status.' }, // Profile diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index ca4ea7ac..1231ce54 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -406,7 +406,8 @@ export default { subscriptionDays: '{days} 天', days: '天', codeRedeemSuccess: '兑换成功!', - failedToRedeem: '兑换失败,请检查兑换码后重试。' + failedToRedeem: '兑换失败,请检查兑换码后重试。', + subscriptionRefreshFailed: '兑换成功,但订阅状态刷新失败。' }, // Profile diff --git a/frontend/src/stores/subscriptions.ts b/frontend/src/stores/subscriptions.ts index 2bda1e1a..58965914 100644 --- a/frontend/src/stores/subscriptions.ts +++ b/frontend/src/stores/subscriptions.ts @@ -48,7 +48,7 @@ export const useSubscriptionStore = defineStore('subscriptions', () => { } // Return in-flight request if exists (deduplication) - if (activePromise) { + if (activePromise && !force) { return activePromise } @@ -56,7 +56,7 @@ export const useSubscriptionStore = defineStore('subscriptions', () => { // Start new request loading.value = true - activePromise = subscriptionsAPI + const requestPromise = subscriptionsAPI .getActiveSubscriptions() .then((data) => { if (currentGeneration === requestGeneration) { @@ -71,10 +71,14 @@ export const useSubscriptionStore = defineStore('subscriptions', () => { throw error }) .finally(() => { - loading.value = false - activePromise = null + if (activePromise === requestPromise) { + loading.value = false + activePromise = null + } }) + activePromise = requestPromise + return activePromise } @@ -106,6 +110,7 @@ export const useSubscriptionStore = defineStore('subscriptions', () => { */ function clear() { requestGeneration++ + activePromise = null activeSubscriptions.value = [] loaded.value = false lastFetchedAt.value = null diff --git a/frontend/src/views/user/DashboardView.vue b/frontend/src/views/user/DashboardView.vue index 1288ac48..d660e1a0 100644 --- a/frontend/src/views/user/DashboardView.vue +++ b/frontend/src/views/user/DashboardView.vue @@ -336,6 +336,7 @@
@@ -400,7 +401,12 @@ {{ t('dashboard.tokenUsageTrend') }}
- +
diff --git a/frontend/src/views/user/RedeemView.vue b/frontend/src/views/user/RedeemView.vue index 7e35916d..6fa29c5b 100644 --- a/frontend/src/views/user/RedeemView.vue +++ b/frontend/src/views/user/RedeemView.vue @@ -548,7 +548,12 @@ const handleRedeem = async () => { // If subscription type, immediately refresh subscription status if (result.type === 'subscription') { - await subscriptionStore.fetchActiveSubscriptions(true) // force refresh + try { + await subscriptionStore.fetchActiveSubscriptions(true) // force refresh + } catch (error) { + console.error('Failed to refresh subscriptions after redeem:', error) + appStore.showWarning(t('redeem.subscriptionRefreshFailed')) + } } // Clear the input From d895a2c46952dc3856ec83fa56774f14dcf79103 Mon Sep 17 00:00:00 2001 From: IanShaw027 <131567472+IanShaw027@users.noreply.github.com> Date: Sun, 28 Dec 2025 14:45:40 +0800 Subject: [PATCH 09/10] =?UTF-8?q?refactor(frontend):=20=E7=A7=BB=E9=99=A4D?= =?UTF-8?q?ataTable=E8=A1=A8=E5=A4=B4=E4=B8=AD=E5=BA=9F=E5=BC=83=E7=9A=84?= =?UTF-8?q?=E5=B1=95=E5=BC=80/=E6=8A=98=E5=8F=A0=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除操作列表头的展开/折叠按钮和图标 - 该功能已被操作列内的'更多'按钮替代 - 保留底层的展开/收起逻辑供'更多'按钮使用 --- frontend/src/components/common/DataTable.vue | 31 -------------------- 1 file changed, 31 deletions(-) diff --git a/frontend/src/components/common/DataTable.vue b/frontend/src/components/common/DataTable.vue index 9c250bc2..27eb61cc 100644 --- a/frontend/src/components/common/DataTable.vue +++ b/frontend/src/components/common/DataTable.vue @@ -24,37 +24,6 @@ >
{{ column.label }} - - Date: Sun, 28 Dec 2025 14:47:55 +0800 Subject: [PATCH 10/10] =?UTF-8?q?fix(frontend):=20=E7=A7=BB=E9=99=A4DataTa?= =?UTF-8?q?ble=E4=B8=AD=E6=9C=AA=E4=BD=BF=E7=94=A8=E7=9A=84=E5=87=BD?= =?UTF-8?q?=E6=95=B0=E5=92=8C=E5=8F=98=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除未使用的 hasExpandableActions 计算属性 - 移除未使用的 toggleActionsExpanded 函数 - 修复 TypeScript 类型检查错误 --- frontend/src/components/common/DataTable.vue | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/frontend/src/components/common/DataTable.vue b/frontend/src/components/common/DataTable.vue index 27eb61cc..c160da26 100644 --- a/frontend/src/components/common/DataTable.vue +++ b/frontend/src/components/common/DataTable.vue @@ -271,26 +271,6 @@ const sortedData = computed(() => { }) }) -// 检查是否有可展开的操作列 -const hasExpandableActions = computed(() => { - // 如果明确指定了actionsCount,使用它来判断 - if (props.actionsCount !== undefined) { - return props.expandableActions && props.columns.some((col) => col.key === 'actions') && props.actionsCount > 2 - } - - // 否则使用原来的检测逻辑 - return ( - props.expandableActions && - props.columns.some((col) => col.key === 'actions') && - actionsColumnNeedsExpanding.value - ) -}) - -// 切换操作列展开/折叠状态 -const toggleActionsExpanded = () => { - actionsExpanded.value = !actionsExpanded.value -} - // 检查第一列是否为勾选列 const hasSelectColumn = computed(() => { return props.columns.length > 0 && props.columns[0].key === 'select'