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 217351703f..c59e492a5a 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 344cda7715..eada4909a0 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 c174c7986d..afebc6d496 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 8a65031538..0eb4f53aba 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 7c04ad7a64..88e1bf8274 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 244b529395..1e228d51a4 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 a39b4e19fa..129625eee0 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 9df5f48114..d36dccaf10 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 d973de6ea9..e88038ed58 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 1b4e2348f9..46591cccf3 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 1e7cb5a8fb..9466c15ec4 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({