From b302be30e3fd1e548d47a6add7ea2eedfd3ca6c0 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Mon, 25 May 2026 02:42:22 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20fix:=20v1=20interface?= =?UTF-8?q?=20feedback=20regressions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve verified V1 frontend feedback by improving channel workflows, auth behavior, API key interactions, user filtering, layout persistence, subscription quota handling, i18n text, pricing metadata, and stale frontend cache recovery. - Add a global frontend cache version cleanup to prevent old frontend localStorage from causing page errors after upgrades. - Fix channel copy refresh, model mapping input focus loss, create-channel fetch-model title state, upstream model update confirmation, and batch test toast behavior. - Respect password login settings and improve Turnstile, forgot-password, registration, and invite-link flows. - Make user role/status filtering server-side and preserve table page size in URLs. - Improve API key edit validation feedback and prefetch real keys for reliable copy actions. - Fix rankings access fail-open behavior, double scrollbars, subscription received amount conversion/display, token i18n wording, model deletion confirmation grammar, and Claude pricing context inference. - Add clearer Playground model/group loading errors. Validation: - bun run typecheck - bun run i18n:sync - gofmt on modified Go files - go test ./controller ./model -run '^$' --- controller/channel.go | 2 +- controller/misc.go | 1 + controller/user.go | 14 +- model/user.go | 31 ++- web/default/bun.lock | 11 +- web/default/package.json | 11 +- .../components/authenticated-layout.tsx | 1 + .../components/forgot-password-form.tsx | 13 +- .../auth/hooks/use-email-verification.ts | 3 + .../sign-in/components/user-auth-form.tsx | 201 ++++++++++-------- .../auth/sign-up/components/sign-up-form.tsx | 29 ++- web/default/src/features/auth/types.ts | 2 + .../components/channels-primary-buttons.tsx | 9 +- .../dialogs/channel-test-dialog.tsx | 48 ++++- .../dialogs/fetch-models-dialog.tsx | 33 +-- .../dialogs/upstream-update-dialog.tsx | 5 +- .../drawers/channel-mutate-drawer.tsx | 2 + .../components/model-mapping-editor.tsx | 42 +++- .../features/channels/lib/channel-actions.ts | 25 ++- .../keys/components/api-keys-cells.tsx | 16 +- .../components/api-keys-mutate-drawer.tsx | 2 +- .../components/data-table-row-actions.tsx | 26 ++- .../components/data-table-bulk-actions.tsx | 6 +- web/default/src/features/playground/index.tsx | 29 ++- .../src/features/playground/lib/storage.ts | 1 - .../features/pricing/lib/model-metadata.ts | 3 + .../dialogs/subscription-purchase-dialog.tsx | 5 +- .../components/subscriptions-columns.tsx | 7 +- .../subscriptions-mutate-drawer.tsx | 6 +- .../features/subscriptions/lib/plan-form.ts | 5 +- .../hooks/use-update-option.ts | 5 + .../models/model-ratio-visual-editor.tsx | 7 +- .../models/ratio-settings-card.tsx | 7 +- .../components/usage-logs-table.tsx | 2 +- web/default/src/features/users/api.ts | 20 +- .../features/users/components/users-table.tsx | 31 ++- web/default/src/features/users/types.ts | 2 + web/default/src/hooks/use-table-url-state.ts | 3 +- web/default/src/i18n/locales/en.json | 1 + web/default/src/i18n/locales/fr.json | 1 + web/default/src/i18n/locales/ja.json | 1 + web/default/src/i18n/locales/ru.json | 1 + web/default/src/i18n/locales/vi.json | 1 + web/default/src/i18n/locales/zh.json | 5 +- web/default/src/lib/frontend-cache.ts | 59 +++++ web/default/src/lib/nav-modules.ts | 2 +- web/default/src/main.tsx | 2 + web/default/src/routeTree.gen.ts | 21 ++ web/default/src/routes/(auth)/register.tsx | 29 +++ .../routes/_authenticated/channels/index.tsx | 2 +- .../src/routes/_authenticated/keys/index.tsx | 2 +- .../_authenticated/usage-logs/$section.tsx | 2 +- .../src/routes/_authenticated/users/index.tsx | 2 +- 53 files changed, 599 insertions(+), 198 deletions(-) create mode 100644 web/default/src/lib/frontend-cache.ts create mode 100644 web/default/src/routes/(auth)/register.tsx diff --git a/controller/channel.go b/controller/channel.go index 21735170..c59e492a 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -1218,7 +1218,7 @@ func CopyChannel(c *gin.Context) { } // insert - if err := model.BatchInsertChannels([]model.Channel{clone}); err != nil { + if err := clone.Insert(); err != nil { common.SysError("failed to clone channel: " + err.Error()) c.JSON(http.StatusOK, gin.H{"success": false, "message": "复制渠道失败,请稍后重试"}) return diff --git a/controller/misc.go b/controller/misc.go index 344cda77..eada4909 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -88,6 +88,7 @@ func GetStatus(c *gin.Context) { "demo_site_enabled": operation_setting.DemoSiteEnabled, "self_use_mode_enabled": operation_setting.SelfUseModeEnabled, "register_enabled": common.RegisterEnabled, + "password_login_enabled": common.PasswordLoginEnabled, "password_register_enabled": common.PasswordRegisterEnabled, "default_use_auto_group": setting.DefaultUseAutoGroup, diff --git a/controller/user.go b/controller/user.go index c174c798..afebc6d4 100644 --- a/controller/user.go +++ b/controller/user.go @@ -251,8 +251,20 @@ func GetAllUsers(c *gin.Context) { func SearchUsers(c *gin.Context) { keyword := c.Query("keyword") group := c.Query("group") + var role *int + if roleStr := c.Query("role"); roleStr != "" { + if parsed, err := strconv.Atoi(roleStr); err == nil { + role = &parsed + } + } + var status *int + if statusStr := c.Query("status"); statusStr != "" { + if parsed, err := strconv.Atoi(statusStr); err == nil { + status = &parsed + } + } pageInfo := common.GetPageQuery(c) - users, total, err := model.SearchUsers(keyword, group, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + users, total, err := model.SearchUsers(keyword, group, role, status, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) if err != nil { common.ApiError(c, err) return diff --git a/model/user.go b/model/user.go index 8a650315..0eb4f53a 100644 --- a/model/user.go +++ b/model/user.go @@ -225,7 +225,7 @@ func GetAllUsers(pageInfo *common.PageInfo) (users []*User, total int64, err err return users, total, nil } -func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User, int64, error) { +func SearchUsers(keyword string, group string, role *int, status *int, startIdx int, num int) ([]*User, int64, error) { var users []*User var total int64 var err error @@ -246,28 +246,25 @@ func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User, // 构建搜索条件 likeCondition := "username LIKE ? OR email LIKE ? OR display_name LIKE ?" + likeArgs := []interface{}{"%" + keyword + "%", "%" + keyword + "%", "%" + keyword + "%"} // 尝试将关键字转换为整数ID keywordInt, err := strconv.Atoi(keyword) if err == nil { // 如果是数字,同时搜索ID和其他字段 likeCondition = "id = ? OR " + likeCondition - if group != "" { - query = query.Where("("+likeCondition+") AND "+commonGroupCol+" = ?", - keywordInt, "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", group) - } else { - query = query.Where(likeCondition, - keywordInt, "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") - } - } else { - // 非数字关键字,只搜索字符串字段 - if group != "" { - query = query.Where("("+likeCondition+") AND "+commonGroupCol+" = ?", - "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", group) - } else { - query = query.Where(likeCondition, - "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") - } + likeArgs = append([]interface{}{keywordInt}, likeArgs...) + } + + query = query.Where("("+likeCondition+")", likeArgs...) + if group != "" { + query = query.Where(commonGroupCol+" = ?", group) + } + if role != nil { + query = query.Where("role = ?", *role) + } + if status != nil { + query = query.Where("status = ?", *status) } // 获取总数 diff --git a/web/default/bun.lock b/web/default/bun.lock index 7c04ad7a..88e1bf82 100644 --- a/web/default/bun.lock +++ b/web/default/bun.lock @@ -24,9 +24,9 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.3.0", - "dayjs": "^1.11.19", + "dayjs": "^1.11.20", "i18next": "^26.2.0", - "i18next-browser-languagedetector": "^8.2.0", + "i18next-browser-languagedetector": "^8.2.1", "input-otp": "^1.4.2", "lucide-react": "^1.16.0", "motion": "^12.40.0", @@ -38,7 +38,7 @@ "react-dom": "^19.2.6", "react-hook-form": "^7.76.1", "react-i18next": "^17.0.8", - "react-icons": "^5.5.0", + "react-icons": "^5.6.0", "react-markdown": "^10.1.0", "react-resizable-panels": "^4.11.2", "react-top-loading-bar": "^3.0.2", @@ -47,8 +47,8 @@ "remark-gfm": "^4.0.1", "shiki": "^4.1.0", "sonner": "^2.0.7", - "sse.js": "^2.7.2", - "streamdown": "^2.0.1", + "sse.js": "^2.8.0", + "streamdown": "^2.5.0", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.0", "tokenlens": "^1.3.1", @@ -94,6 +94,7 @@ "js-cookie": "3.0.7", "mermaid": "11.15.0", "minimist": "1.2.8", + "postcss": "8.5.15", "qs": "6.15.2", "uuid": "14.0.0", }, diff --git a/web/default/package.json b/web/default/package.json index 244b5293..1e228d51 100644 --- a/web/default/package.json +++ b/web/default/package.json @@ -38,9 +38,9 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.3.0", - "dayjs": "^1.11.19", + "dayjs": "^1.11.20", "i18next": "^26.2.0", - "i18next-browser-languagedetector": "^8.2.0", + "i18next-browser-languagedetector": "^8.2.1", "input-otp": "^1.4.2", "lucide-react": "^1.16.0", "motion": "^12.40.0", @@ -52,7 +52,7 @@ "react-dom": "^19.2.6", "react-hook-form": "^7.76.1", "react-i18next": "^17.0.8", - "react-icons": "^5.5.0", + "react-icons": "^5.6.0", "react-markdown": "^10.1.0", "react-resizable-panels": "^4.11.2", "react-top-loading-bar": "^3.0.2", @@ -61,8 +61,8 @@ "remark-gfm": "^4.0.1", "shiki": "^4.1.0", "sonner": "^2.0.7", - "sse.js": "^2.7.2", - "streamdown": "^2.0.1", + "sse.js": "^2.8.0", + "streamdown": "^2.5.0", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.0", "tokenlens": "^1.3.1", @@ -106,6 +106,7 @@ "js-cookie": "3.0.7", "mermaid": "11.15.0", "minimist": "1.2.8", + "postcss": "8.5.15", "qs": "6.15.2", "uuid": "14.0.0" } diff --git a/web/default/src/components/layout/components/authenticated-layout.tsx b/web/default/src/components/layout/components/authenticated-layout.tsx index a39b4e19..129625ee 100644 --- a/web/default/src/components/layout/components/authenticated-layout.tsx +++ b/web/default/src/components/layout/components/authenticated-layout.tsx @@ -45,6 +45,7 @@ export function AuthenticatedLayout(props: AuthenticatedLayoutProps) { className={cn( '@container/content', 'h-[calc(100svh-var(--app-header-height,0px))]', + 'min-h-0 overflow-hidden', 'peer-data-[variant=inset]:h-[calc(100svh-var(--app-header-height,0px)-(var(--spacing)*4))]' )} > diff --git a/web/default/src/features/auth/forgot-password/components/forgot-password-form.tsx b/web/default/src/features/auth/forgot-password/components/forgot-password-form.tsx index 9df5f481..d36dccaf 100644 --- a/web/default/src/features/auth/forgot-password/components/forgot-password-form.tsx +++ b/web/default/src/features/auth/forgot-password/components/forgot-password-form.tsx @@ -67,6 +67,7 @@ export function ForgotPasswordForm({ resolver: zodResolver(forgotPasswordFormSchema), defaultValues: { email: '' }, }) + const turnstileReady = !isTurnstileEnabled || Boolean(turnstileToken) async function onSubmit(data: z.infer) { if (!validateTurnstile()) return @@ -78,6 +79,8 @@ export function ForgotPasswordForm({ form.reset() startCountdown() toast.success(t('Reset email sent, please check your inbox')) + } else { + toast.error(res?.message || t('Failed to send reset email')) } } catch (_error) { // Errors are handled by global interceptor @@ -107,8 +110,14 @@ export function ForgotPasswordForm({ )} /> - diff --git a/web/default/src/features/auth/hooks/use-email-verification.ts b/web/default/src/features/auth/hooks/use-email-verification.ts index d973de6e..e88038ed 100644 --- a/web/default/src/features/auth/hooks/use-email-verification.ts +++ b/web/default/src/features/auth/hooks/use-email-verification.ts @@ -61,6 +61,9 @@ export function useEmailVerification(options?: UseEmailVerificationOptions) { toast.success(i18next.t('Verification email sent')) return true } + toast.error( + res?.message || i18next.t('Failed to send verification email') + ) return false } catch (_error) { // Errors are handled by global interceptor diff --git a/web/default/src/features/auth/sign-in/components/user-auth-form.tsx b/web/default/src/features/auth/sign-in/components/user-auth-form.tsx index 1b4e2348..46591ccc 100644 --- a/web/default/src/features/auth/sign-in/components/user-auth-form.tsx +++ b/web/default/src/features/auth/sign-in/components/user-auth-form.tsx @@ -81,6 +81,10 @@ export function UserAuthForm({ const passkeyLoginEnabled = Boolean( status?.passkey_login ?? status?.data?.passkey_login ) + const passwordLoginEnabled = + (status?.password_login_enabled ?? + status?.data?.password_login_enabled ?? + true) !== false const { isTurnstileEnabled, turnstileSiteKey, @@ -98,6 +102,16 @@ export function UserAuthForm({ !passkeySupported || (requiresLegalConsent && !agreedToLegal) const hasWeChatLogin = Boolean(status?.wechat_login) + const hasOAuthLogin = Boolean( + status?.github_oauth || + status?.discord_oauth || + status?.oidc_enabled || + status?.linuxdo_oauth || + status?.telegram_oauth || + (status?.custom_oauth_providers?.length ?? 0) > 0 + ) + const hasAlternativeLogin = + passkeyLoginEnabled || hasWeChatLogin || hasOAuthLogin useEffect(() => { if (requiresLegalConsent) { @@ -275,6 +289,42 @@ export function UserAuthForm({ } } + const alternativeLoginMethods = ( + <> + {passkeyLoginEnabled && ( +
+ + {!passkeySupported && ( +

+ {t('Passkey is not supported on this device.')} +

+ )} +
+ )} + + {/* OAuth Providers */} + + + ) + return (
- {/* Username Field */} - ( - - {t('Username or Email')} - - - - - - )} - /> + {hasAlternativeLogin && alternativeLoginMethods} - {/* Password Field */} - ( - - {t('Password')} - - - - - - {t('Forgot password?')} - - - )} - /> - - {/* Submit Button */} - - - {/* Turnstile */} - {isTurnstileEnabled && ( -
- + {/* Username Field */} + ( + + {t('Username or Email')} + + + + + + )} /> -
+ + {/* Password Field */} + ( + + {t('Password')} + + + + + + {t('Forgot password?')} + + + )} + /> + + {/* Submit Button */} + + + {/* Turnstile */} + {isTurnstileEnabled && ( +
+ +
+ )} + )} - {passkeyLoginEnabled && ( -
- - {!passkeySupported && ( -

- {t('Passkey is not supported on this device.')} -

- )} -
- )} - - {/* OAuth Providers */} - + {!hasAlternativeLogin && alternativeLoginMethods} {hasWeChatLogin && ( diff --git a/web/default/src/features/auth/sign-up/components/sign-up-form.tsx b/web/default/src/features/auth/sign-up/components/sign-up-form.tsx index 1e7cb5a8..9466c15e 100644 --- a/web/default/src/features/auth/sign-up/components/sign-up-form.tsx +++ b/web/default/src/features/auth/sign-up/components/sign-up-form.tsx @@ -53,7 +53,10 @@ import { registerFormSchema } from '@/features/auth/constants' import { useAuthRedirect } from '@/features/auth/hooks/use-auth-redirect' import { useEmailVerification } from '@/features/auth/hooks/use-email-verification' import { useTurnstile } from '@/features/auth/hooks/use-turnstile' -import { getAffiliateCode } from '@/features/auth/lib/storage' +import { + getAffiliateCode, + saveAffiliateCode, +} from '@/features/auth/lib/storage' export function SignUpForm({ className, @@ -107,6 +110,7 @@ export function SignUpForm({ status?.data?.oauth_register_enabled ?? true const hasWeChatLogin = Boolean(status?.wechat_login) + const turnstileReady = !isTurnstileEnabled || Boolean(turnstileToken) const wechatQrCodeUrl = useMemo(() => { return ( @@ -130,6 +134,13 @@ export function SignUpForm({ } }, [requiresLegalConsent]) + useEffect(() => { + const aff = new URLSearchParams(window.location.search).get('aff')?.trim() + if (aff) { + saveAffiliateCode(aff) + } + }, []) + async function onSubmit(data: z.infer) { if (requiresLegalConsent && !agreedToLegal) { toast.error(legalConsentErrorMessage) @@ -164,6 +175,8 @@ export function SignUpForm({ if (res?.success) { toast.success(t('Account created! Please sign in')) redirectToLogin() + } else { + toast.error(res?.message || t('Failed to create account')) } } catch (_error) { // Errors are handled by global interceptor @@ -307,7 +320,13 @@ export function SignUpForm({