From e9b1b1827c74a29487c15e6d831a2eb65c6edf22 Mon Sep 17 00:00:00 2001 From: qian Date: Sat, 13 Jun 2026 10:51:53 +0800 Subject: [PATCH 01/10] =?UTF-8?q?fix:=E8=A1=A5=E5=85=85=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/composables/useAnnouncementRead.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 web/tenant/src/composables/useAnnouncementRead.ts diff --git a/web/tenant/src/composables/useAnnouncementRead.ts b/web/tenant/src/composables/useAnnouncementRead.ts new file mode 100644 index 0000000..5c9cd44 --- /dev/null +++ b/web/tenant/src/composables/useAnnouncementRead.ts @@ -0,0 +1,53 @@ +import { ref, computed } from 'vue' + +const STORAGE_KEY = 'tenant_read_announcements' + +function loadReadIds(): Set { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (raw) return new Set(JSON.parse(raw)) + } catch { /* ignore */ } + return new Set() +} + +function saveReadIds(ids: Set) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify([...ids])) + } catch { /* ignore */ } +} + +const readIds = ref>(loadReadIds()) + +export function useAnnouncementRead(announcements: { value: { id: number }[] }) { + const unreadCount = computed(() => { + return announcements.value.filter(a => !readIds.value.has(a.id)).length + }) + + const unreadItems = computed(() => { + return announcements.value.filter(a => !readIds.value.has(a.id)) + }) + + function markAsRead(id: number) { + readIds.value.add(id) + saveReadIds(readIds.value) + } + + function markAllRead() { + for (const a of announcements.value) { + readIds.value.add(a.id) + } + saveReadIds(readIds.value) + } + + function isRead(id: number): boolean { + return readIds.value.has(id) + } + + return { + unreadCount, + unreadItems, + markAsRead, + markAllRead, + isRead, + } +} From cea265a1dd51f0219eeb5667e550499b1db5f619 Mon Sep 17 00:00:00 2001 From: qian Date: Sat, 13 Jun 2026 14:36:58 +0800 Subject: [PATCH 02/10] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8DTurnstile=E7=9C=9F?= =?UTF-8?q?=E4=BA=BA=E9=AA=8C=E8=AF=81=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/logic/common/config.go | 11 +++- internal/logic/common/settings_registry.go | 4 +- internal/logic/tenant/auth.go | 35 ++++++----- migrations/000009_sync_options_is_public.sql | 27 +++++++++ web/tenant/src/stores/tenant-auth.ts | 3 +- web/tenant/src/views/auth/LoginPage.vue | 63 +++++++++++++++----- 6 files changed, 108 insertions(+), 35 deletions(-) create mode 100644 migrations/000009_sync_options_is_public.sql diff --git a/internal/logic/common/config.go b/internal/logic/common/config.go index e31ff68..b55d87e 100644 --- a/internal/logic/common/config.go +++ b/internal/logic/common/config.go @@ -197,9 +197,18 @@ func (s *ConfigService) SetOption(ctx context.Context, key, value string) error } if count > 0 { + data := do.SysOptions{Value: value} + // Sync metadata from registry so that changes to IsPublic/Category/Description + // are reflected in the DB row on the next write (e.g. a setting newly marked + // as IsPublic will become visible to GetPublicOptions). + if def := GetSettingDef(key); def != nil { + data.Category = def.Category + data.Description = def.Label + data.IsPublic = def.IsPublic + } _, err = dao.SysOptions.Ctx(ctx). Where("key", key). - Data(do.SysOptions{Value: value}). + Data(data). Update() } else { def := GetSettingDef(key) diff --git a/internal/logic/common/settings_registry.go b/internal/logic/common/settings_registry.go index 84dd689..8aa471e 100644 --- a/internal/logic/common/settings_registry.go +++ b/internal/logic/common/settings_registry.go @@ -120,9 +120,9 @@ var Registry = []SettingDef{ {Key: "password_min_length", Type: SettingTypeInt, Default: "8", Category: "security", Label: "密码最小长度", Validation: "min:6,max:32"}, {Key: "turnstile_enabled", Type: SettingTypeBool, Default: "false", Category: "security", - Label: "启用 Turnstile 人机验证"}, + Label: "启用 Turnstile 人机验证", IsPublic: true}, {Key: "turnstile_site_key", Type: SettingTypeString, Default: "", Category: "security", - Label: "Turnstile Site Key", Sensitive: false}, + Label: "Turnstile Site Key", Sensitive: false, IsPublic: true}, {Key: "turnstile_secret_key", Type: SettingTypeString, Default: "", Category: "security", Label: "Turnstile Secret Key", Sensitive: true}, {Key: "captcha_expire_seconds", Type: SettingTypeInt, Default: "300", Category: "security", diff --git a/internal/logic/tenant/auth.go b/internal/logic/tenant/auth.go index 4fb20ab..7fc2c3d 100644 --- a/internal/logic/tenant/auth.go +++ b/internal/logic/tenant/auth.go @@ -70,14 +70,16 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1. return nil, err } } else { - // 人机验证:优先 Turnstile,否则滑块验证 - if err := common.CheckTurnstileRequired(ctx, req.TurnstileToken); err != nil { - return nil, err - } - - // Turnstile 未启用时,使用滑块验证 - if err := common.CheckCaptchaRequired(ctx, "tenant_register", req.CaptchaKey, req.CaptchaX); err != nil { - return nil, err + // 人机验证:Turnstile 启用时只验 Turnstile,否则用滑块验证 + turnstileEnabled := common.Config().GetBool(ctx, "turnstile_enabled") + if turnstileEnabled { + if err := common.CheckTurnstileRequired(ctx, req.TurnstileToken); err != nil { + return nil, err + } + } else { + if err := common.CheckCaptchaRequired(ctx, "tenant_register", req.CaptchaKey, req.CaptchaX); err != nil { + return nil, err + } } } @@ -240,13 +242,16 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1. func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.TenantLoginRes, error) { account := strings.TrimSpace(req.Account) - // Check captcha if required - if err := common.CheckTurnstileRequired(ctx, req.TurnstileToken); err != nil { - return nil, err - } - - if err := common.CheckCaptchaRequired(ctx, "tenant_login", req.CaptchaKey, req.CaptchaX); err != nil { - return nil, err + // Check captcha: Turnstile takes priority; slider captcha is fallback + turnstileEnabled := common.Config().GetBool(ctx, "turnstile_enabled") + if turnstileEnabled { + if err := common.CheckTurnstileRequired(ctx, req.TurnstileToken); err != nil { + return nil, err + } + } else { + if err := common.CheckCaptchaRequired(ctx, "tenant_login", req.CaptchaKey, req.CaptchaX); err != nil { + return nil, err + } } // Get client info early for login history recording diff --git a/migrations/000009_sync_options_is_public.sql b/migrations/000009_sync_options_is_public.sql new file mode 100644 index 0000000..5d93dcb --- /dev/null +++ b/migrations/000009_sync_options_is_public.sql @@ -0,0 +1,27 @@ +-- +goose Up +-- Sync is_public flag from code registry to DB rows. +-- Settings like turnstile_enabled/turnstile_site_key were marked IsPublic in the +-- registry but the DB rows still had is_public=false, causing GetPublicOptions +-- (WHERE is_public=true) to skip them and return stale defaults to the tenant frontend. + +UPDATE sys_options SET is_public = true, updated_at = NOW() +WHERE key IN ( + 'site_name', + 'site_description', + 'register_enabled', + 'register_email_verification', + 'maintenance_message', + 'maintenance_duration', + 'api_maintenance_enabled', + 'oauth_auto_register', + 'oauth_github_enabled', + 'oauth_google_enabled', + 'turnstile_enabled', + 'turnstile_site_key', + 'agreement_enabled' +) AND is_public = false; + +-- +goose Down +-- No-op: reverting is_public to false would break public settings again, +-- so we leave the corrected values in place. +SELECT 1; diff --git a/web/tenant/src/stores/tenant-auth.ts b/web/tenant/src/stores/tenant-auth.ts index 8f5cb61..568c00d 100644 --- a/web/tenant/src/stores/tenant-auth.ts +++ b/web/tenant/src/stores/tenant-auth.ts @@ -114,13 +114,14 @@ export const useTenantAuthStore = defineStore('tenant-auth', () => { persist() } - async function login(account: string, password: string, type: 'ram' | 'admin', captcha?: { captchaKey: string; captchaX: number }): Promise { + async function login(account: string, password: string, type: 'ram' | 'admin', captcha?: { captchaKey: string; captchaX: number; turnstileToken?: string }): Promise { const { data } = await request.post('/tenant/auth/login', { account, password, type, captcha_key: captcha?.captchaKey, captcha_x: captcha?.captchaX, + turnstile_token: captcha?.turnstileToken, }) if (data.data?.totp_required) { return data.data diff --git a/web/tenant/src/views/auth/LoginPage.vue b/web/tenant/src/views/auth/LoginPage.vue index ac7018d..abf5540 100644 --- a/web/tenant/src/views/auth/LoginPage.vue +++ b/web/tenant/src/views/auth/LoginPage.vue @@ -36,10 +36,13 @@ const provisionalToken = ref('') const totpCode = ref('') const totpLoading = ref(false) -// Captcha state — always required +// Captcha state const captcha = reactive({ captchaKey: '', captchaX: 0 }) - const turnstileToken = ref('') +const turnstileToken = ref('') const captchaRef = ref | null>(null) +const turnstileRef = ref | null>(null) + +const useTurnstile = computed(() => settings.value['turnstile_enabled'] === true) const emailInput = ref(null) const accountInput = ref(null) @@ -124,15 +127,24 @@ function validateRam(): boolean { async function handleAdminLogin() { if (!validateAdmin()) return - if (!captcha.captchaKey || !captcha.captchaX) { - errorMsg.value = '请先完成滑块验证' - return + if (useTurnstile.value) { + if (!turnstileToken.value) { + errorMsg.value = '请先完成人机验证' + return + } + } else { + if (!captcha.captchaKey || !captcha.captchaX) { + errorMsg.value = '请先完成滑块验证' + return + } } loading.value = true errorMsg.value = '' try { - const captchaPayload = { captchaKey: captcha.captchaKey, captchaX: captcha.captchaX } + const captchaPayload = useTurnstile.value + ? { captchaKey: '', captchaX: 0, turnstileToken: turnstileToken.value } + : { captchaKey: captcha.captchaKey, captchaX: captcha.captchaX } const res = await authStore.login(adminForm.email, adminForm.password, 'admin', captchaPayload) if (res?.totp_required) { provisionalToken.value = res.provisional_token @@ -142,12 +154,16 @@ async function handleAdminLogin() { proceedAfterLogin() } catch (err) { const apiErr = extractApiError(err) - if (apiErr?.code === 10058) { + if (useTurnstile.value) { + turnstileRef.value?.reset() + errorMsg.value = apiErr?.message || '登录失败,请检查邮箱和密码' + } else if (apiErr?.code === 10058) { errorMsg.value = '滑块验证失败,请重新拖动' + captchaRef.value?.resetCaptcha() } else { errorMsg.value = apiErr?.message || '登录失败,请检查邮箱和密码' + captchaRef.value?.resetCaptcha() } - captchaRef.value?.resetCaptcha() } finally { loading.value = false } @@ -155,15 +171,24 @@ async function handleAdminLogin() { async function handleRamLogin() { if (!validateRam()) return - if (!captcha.captchaKey || !captcha.captchaX) { - errorMsg.value = '请先完成滑块验证' - return + if (useTurnstile.value) { + if (!turnstileToken.value) { + errorMsg.value = '请先完成人机验证' + return + } + } else { + if (!captcha.captchaKey || !captcha.captchaX) { + errorMsg.value = '请先完成滑块验证' + return + } } loading.value = true errorMsg.value = '' try { - const captchaPayload = { captchaKey: captcha.captchaKey, captchaX: captcha.captchaX } + const captchaPayload = useTurnstile.value + ? { captchaKey: '', captchaX: 0, turnstileToken: turnstileToken.value } + : { captchaKey: captcha.captchaKey, captchaX: captcha.captchaX } const res = await authStore.login(ramForm.account, ramForm.password, 'ram', captchaPayload) if (res?.totp_required) { provisionalToken.value = res.provisional_token @@ -173,12 +198,16 @@ async function handleRamLogin() { proceedAfterLogin() } catch (err) { const apiErr = extractApiError(err) - if (apiErr?.code === 10058) { + if (useTurnstile.value) { + turnstileRef.value?.reset() + errorMsg.value = apiErr?.message || '登录失败,请检查用户名和密码' + } else if (apiErr?.code === 10058) { errorMsg.value = '滑块验证失败,请重新拖动' + captchaRef.value?.resetCaptcha() } else { errorMsg.value = apiErr?.message || '登录失败,请检查用户名和密码' + captchaRef.value?.resetCaptcha() } - captchaRef.value?.resetCaptcha() } finally { loading.value = false } @@ -335,7 +364,8 @@ async function handleOAuthLogin(provider: string) { - + + @@ -432,7 +462,8 @@ async function handleOAuthLogin(provider: string) { - + + From bb7a37d28312eaf22561430c42fb4cd8a8ba80d8 Mon Sep 17 00:00:00 2001 From: qian Date: Sat, 13 Jun 2026 14:42:27 +0800 Subject: [PATCH 03/10] =?UTF-8?q?fix:=E6=B8=A0=E9=81=93=E5=86=85=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=8F=AA=E8=83=BD=E6=B5=8B=E8=AF=95=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E7=9A=84=E6=B8=A0=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/admin/src/views/ai/ChannelDetailPage.vue | 21 ++++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/web/admin/src/views/ai/ChannelDetailPage.vue b/web/admin/src/views/ai/ChannelDetailPage.vue index 27833f8..a676314 100644 --- a/web/admin/src/views/ai/ChannelDetailPage.vue +++ b/web/admin/src/views/ai/ChannelDetailPage.vue @@ -199,8 +199,10 @@ const availableModelOptions = computed(() => { }) const allModelOptions = computed(() => { - return modelsList.value - .map(m => ({ label: m.model_name ? `${m.model_name} (${m.model_id})` : m.model_id, value: m.model_id })) + // 测试模型下拉列表只显示该渠道已配置的能力模型 + return abilitiesData.value + .filter(a => a.enabled) + .map(a => ({ label: a.model_name, value: a.model_name })) }) function openAddAbilityModal() { @@ -343,7 +345,7 @@ onBeforeUnmount(() => { function onTabChange(key: string) { if (key === 'abilities' && abilitiesData.value.length === 0) fetchAbilities() - if (key === 'test' && modelsList.value.length === 0) fetchModels() + if (key === 'test' && abilitiesData.value.length === 0) fetchAbilities() if (key === 'health_trend' && trendData.value.length === 0) fetchHealthTrend() } @@ -517,13 +519,11 @@ function formatHeaders(headers: Record): string { @@ -698,13 +698,12 @@ function formatHeaders(headers: Record): string { From 4c6e59fbe56a7d591e582c0815e1de0ef205d5b7 Mon Sep 17 00:00:00 2001 From: qian Date: Sat, 13 Jun 2026 14:50:41 +0800 Subject: [PATCH 04/10] =?UTF-8?q?improve:=E4=BC=98=E5=8C=96=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E9=A1=B5=E9=94=99=E8=AF=AF=E6=8F=90=E7=A4=BA=EF=BC=8C?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=9C=A8=E5=9B=BA=E5=AE=9A=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/tenant/src/stores/tenant-auth.ts | 2 +- web/tenant/src/views/auth/LoginPage.vue | 40 ++++++++++++------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/web/tenant/src/stores/tenant-auth.ts b/web/tenant/src/stores/tenant-auth.ts index 568c00d..a674e24 100644 --- a/web/tenant/src/stores/tenant-auth.ts +++ b/web/tenant/src/stores/tenant-auth.ts @@ -122,7 +122,7 @@ export const useTenantAuthStore = defineStore('tenant-auth', () => { captcha_key: captcha?.captchaKey, captcha_x: captcha?.captchaX, turnstile_token: captcha?.turnstileToken, - }) + }, { _suppressErrorMsg: true } as any) if (data.data?.totp_required) { return data.data } diff --git a/web/tenant/src/views/auth/LoginPage.vue b/web/tenant/src/views/auth/LoginPage.vue index abf5540..501c554 100644 --- a/web/tenant/src/views/auth/LoginPage.vue +++ b/web/tenant/src/views/auth/LoginPage.vue @@ -367,16 +367,6 @@ async function handleOAuthLogin(provider: string) { - - -
-
- -

{{ errorMsg }}

-
-
-
-