From 326c5238c1d1c1b2028de6e19485bc6d135fdeb5 Mon Sep 17 00:00:00 2001 From: ulleo Date: Wed, 28 Oct 2026 12:01:50 +0800 Subject: [PATCH] fix: sync default welcome text with display language --- .../tests/test_assistant_welcome_defaults.py | 90 ++++++++++++ docs/agents/i18n.md | 15 ++ frontend/src/i18n/ko-KR.json | 2 +- frontend/src/i18n/welcome.ts | 42 ++++++ frontend/src/i18n/zh-TW.json | 4 +- frontend/src/views/chat/index.vue | 14 +- frontend/src/views/embedded/index.vue | 40 ++---- frontend/src/views/embedded/page.vue | 31 ++-- frontend/src/views/login/index.vue | 6 +- .../views/system/appearance/LoginPreview.vue | 7 +- .../src/views/system/appearance/index.vue | 47 +++--- frontend/src/views/system/embedded/SetUi.vue | 40 ++++-- tests/browser/README.md | 17 +++ .../fixtures/welcome-language/bridge.mjs | 38 +++++ .../fixtures/welcome-language/index.html | 1 + .../fixtures/welcome-language/main.mjs | 57 ++++++++ .../fixtures/welcome-language/server.mjs | 72 ++++++++++ tests/browser/specs/welcome-language.spec.mjs | 134 ++++++++++++++++++ tests/browser/welcome-language.config.mjs | 14 ++ 19 files changed, 574 insertions(+), 97 deletions(-) create mode 100644 backend/tests/test_assistant_welcome_defaults.py create mode 100644 frontend/src/i18n/welcome.ts create mode 100644 tests/browser/fixtures/welcome-language/bridge.mjs create mode 100644 tests/browser/fixtures/welcome-language/index.html create mode 100644 tests/browser/fixtures/welcome-language/main.mjs create mode 100644 tests/browser/fixtures/welcome-language/server.mjs create mode 100644 tests/browser/specs/welcome-language.spec.mjs create mode 100644 tests/browser/welcome-language.config.mjs diff --git a/backend/tests/test_assistant_welcome_defaults.py b/backend/tests/test_assistant_welcome_defaults.py new file mode 100644 index 000000000..ce47dfe9a --- /dev/null +++ b/backend/tests/test_assistant_welcome_defaults.py @@ -0,0 +1,90 @@ +"""Exercise the actual UI schema and save handler with an isolated session. + +Empty welcome strings must overwrite old custom text; omitted fields must not. +This does not exercise FastAPI authentication, file uploads or a real database. +""" + +import ast +import asyncio +import json +from pathlib import Path +from types import SimpleNamespace +from typing import Optional +from unittest.mock import AsyncMock, Mock + +import pytest +from pydantic import BaseModel, Field + +ROOT = Path(__file__).resolve().parents[1] + + +def load_node(path, name, namespace): + node = next( + n for n in ast.parse(path.read_text()).body if getattr(n, "name", None) == name + ) + if isinstance(node, ast.AsyncFunctionDef): + node.decorator_list = [] + exec( + compile(ast.Module(body=[node], type_ignores=[]), str(path), "exec"), namespace + ) + + +@pytest.mark.parametrize( + "fields,expected", + [ + ({"welcome": "", "welcome_desc": ""}, {"welcome": "", "welcome_desc": ""}), + ( + {"welcome": "New custom title"}, + {"welcome": "New custom title", "welcome_desc": "Custom description"}, + ), + ( + {"theme": "#123456"}, + {"welcome": "Custom title", "welcome_desc": "Custom description"}, + ), + ], +) +def test_save_welcome_defaults(fields, expected): + model = SimpleNamespace( + id=42, + configuration=json.dumps( + { + "welcome": "Custom title", + "welcome_desc": "Custom description", + "auto_ds": True, + } + ), + ) + namespace = { + "BaseModel": BaseModel, + "Field": Field, + "Optional": Optional, + "List": list, + "json": json, + "SessionDep": object, + "CurrentUser": object, + "Trans": object, + "UploadFile": object, + "Form": lambda: None, + "get_ws_assistant": Mock(return_value=model), + "clear_ui_cache": AsyncMock(), + } + load_node(ROOT / "common/core/schemas.py", "BaseCreatorDTO", namespace) + load_node( + ROOT / "apps/system/schemas/system_schema.py", "AssistantUiSchema", namespace + ) + load_node(ROOT / "apps/system/api/assistant.py", "ui", namespace) + session, user, trans = Mock(), object(), object() + result = asyncio.run( + namespace["ui"]( + session, user, trans, json.dumps({"id": 42, **fields}), files=[] + ) + ) + stored = json.loads(result.configuration) + assert {key: stored[key] for key in expected} == expected + assert stored["auto_ds"] is True + if "theme" in fields: + assert stored["theme"] == fields["theme"] + namespace["get_ws_assistant"].assert_called_once_with(session, 42, user, trans) + session.add.assert_called_once_with(model) + session.commit.assert_called_once() + namespace["clear_ui_cache"].assert_awaited_once_with(42) diff --git a/docs/agents/i18n.md b/docs/agents/i18n.md index 03d464904..d0d18b6a5 100644 --- a/docs/agents/i18n.md +++ b/docs/agents/i18n.md @@ -16,6 +16,21 @@ 可以用脚本比较键路径,但不要为了普通文案变更引入新依赖。 +## 欢迎语与恢复默认 + +- 助手的 `welcome` / `welcome_desc` 和全局外观的 `pc_welcome` / `pc_welcome_desc` / `slogan` + 使用空字符串表示默认文案。保存接口保留空字符串;不要改为省略字段或传 `null`, + 助手 PATCH 会忽略这两种输入,无法覆盖旧配置。 +- 显示和编辑入口共用 `frontend/src/i18n/welcome.ts`。默认文案按当前 locale 解析; + 编辑框用翻译后的默认文案作为 placeholder,预览显示解析结果,恢复默认后保存空字符串。 +- 兼容旧编辑器保存的默认文本:仅当内容与五种语言的系统默认文案完全一致时, + 按默认模式处理,包括两种历史说明文案。其余自定义文本原样保留,不 trim 或自动翻译。 + 历史数据没有来源标记,因此手工输入且恰好等于系统默认文案的内容也按默认处理。 +- 普通问数首页和页面嵌入共用全局外观文案;基础、高级助手的浮窗和全屏共用助手文案。 + 修改时覆盖这些入口及恢复默认、保存、重新打开、切换语言的链路。 +- 外观设置的登录区域与平台区域分别恢复各自的默认值。登录欢迎语 `slogan` 的预览和 + 实际登录页也按上述规则解析;是否显示仍由 `showSlogan` 开关控制。 + ## 新增语言 新增语言不是只添加 JSON 文件。以提交 `21961e2d`(Thai / `th-TH`)为完整示例,逐项检查: diff --git a/frontend/src/i18n/ko-KR.json b/frontend/src/i18n/ko-KR.json index 0130d208e..394df75f0 100644 --- a/frontend/src/i18n/ko-KR.json +++ b/frontend/src/i18n/ko-KR.json @@ -142,7 +142,7 @@ "invalid_embedding_parameter": "유사도는 0~1 사이의 유한한 수여야 하며 후보 수는 양의 정수여야 합니다." }, "prompt": { - "default_password:": "기본 비밀번호:{msg}", + "default_password": "기본 비밀번호:{msg}", "no_sql_sample": "현재 사용 가능한 SQL 샘플 라이브러리가 없습니다", "add_sql_sample": "SQL 샘플 라이브러리를 추가하세요", "no_prompt_words": "프롬프트가 없습니다", diff --git a/frontend/src/i18n/welcome.ts b/frontend/src/i18n/welcome.ts new file mode 100644 index 000000000..9a3ce0e42 --- /dev/null +++ b/frontend/src/i18n/welcome.ts @@ -0,0 +1,42 @@ +import en from './en.json' +import zhCN from './zh-CN.json' +import zhTW from './zh-TW.json' +import koKR from './ko-KR.json' +import thTH from './th-TH.json' + +type WelcomeKey = + | 'embedded.i_am_sqlbot' + | 'embedded.data_analysis_now' + | 'qa.hint_description' + | 'common.intelligent_questioning_platform' + +const messages = [en, zhCN, zhTW, koKR, thTH] +const defaultTitles = new Set(messages.map((message) => message.embedded.i_am_sqlbot)) +const defaultDescriptions = new Set( + messages.flatMap((message) => [message.embedded.data_analysis_now, message.qa.hint_description]) +) +const defaultSlogans = new Set( + messages.map((message) => message.common.intelligent_questioning_platform) +) +const defaultsByKey: Record> = { + 'embedded.i_am_sqlbot': defaultTitles, + 'embedded.data_analysis_now': defaultDescriptions, + 'qa.hint_description': defaultDescriptions, + 'common.intelligent_questioning_platform': defaultSlogans, +} + +// An empty string means "follow the display language" in both existing save APIs. +// Older editors persisted translated defaults. Match only exact stock strings; +// never trim, translate or otherwise rewrite a user's custom text. +export function normalizeWelcome(value: string | null | undefined, key: WelcomeKey): string { + const defaults = defaultsByKey[key] + return value == null || defaults.has(value) ? '' : value +} + +export function resolveWelcome( + value: string | null | undefined, + key: WelcomeKey, + translate: (key: WelcomeKey) => string +): string { + return normalizeWelcome(value, key) || translate(key) +} diff --git a/frontend/src/i18n/zh-TW.json b/frontend/src/i18n/zh-TW.json index 20e37100f..7ee90759c 100644 --- a/frontend/src/i18n/zh-TW.json +++ b/frontend/src/i18n/zh-TW.json @@ -883,8 +883,8 @@ "version_num": "版本號", "standard": "社區版", "enterprise": "企業版", - "professional": "專業版", - "embedded": "嵌入式版", + "Professional": "專業版", + "Embedded": "嵌入式版", "support": "取得技術支援", "update_success": "更新成功,請重新登入", "serial_no": "序號", diff --git a/frontend/src/views/chat/index.vue b/frontend/src/views/chat/index.vue index b09d21c0d..b054e2759 100644 --- a/frontend/src/views/chat/index.vue +++ b/frontend/src/views/chat/index.vue @@ -120,13 +120,10 @@ > - {{ appearanceStore.pc_welcome ?? '你好,我是 SQLBot' }} + {{ pageWelcome }}
- {{ - appearanceStore.pc_welcome_desc ?? - '我可以查询数据、生成图表、检测数据异常、预测数据等赶快开启智能问数吧~' - }} + {{ pageWelcomeDesc }}
@@ -467,6 +464,7 @@ import ErrorInfo from './ErrorInfo.vue' import ChatToolBar from './ChatToolBar.vue' import { dsTypeWithImg } from '@/views/ds/js/ds-type' import { useI18n } from 'vue-i18n' +import { resolveWelcome } from '@/i18n/welcome' import { find, forEach } from 'lodash-es' import custom_small from '@/assets/svg/logo-custom_small.svg' import LOGO_fold from '@/assets/LOGO-fold.svg' @@ -519,6 +517,12 @@ const customName = computed(() => { return '' }) const { t } = useI18n() +const pageWelcome = computed(() => + resolveWelcome(appearanceStore.pc_welcome, 'embedded.i_am_sqlbot', t) +) +const pageWelcomeDesc = computed(() => + resolveWelcome(appearanceStore.pc_welcome_desc, 'qa.hint_description', t) +) const chatConfig = useChatConfigStore() diff --git a/frontend/src/views/embedded/index.vue b/frontend/src/views/embedded/index.vue index 28d3c6e11..261d45466 100644 --- a/frontend/src/views/embedded/index.vue +++ b/frontend/src/views/embedded/index.vue @@ -22,8 +22,8 @@ @@ -31,16 +31,7 @@ ')[0] + const ast = ts.createSourceFile('welcome.ts', script, ts.ScriptTarget.Latest, true) + return ast.statements.filter(n => + n.declarationList?.declarations.some(d => names.includes(d.name.getText(ast))) || + (ts.isImportDeclaration(n) && n.moduleSpecifier.text === '@/i18n/welcome') + ).map(n => n.getText(ast)).join('\n') +} +const server = await createServer({ + configFile: false, root, cacheDir: '../../output/welcome-language-vite', + plugins: [ + { + name: 'isolate-chat-welcome', enforce: 'pre', + transform(source, id) { + if (id.endsWith('/views/login/index.vue')) { + const start = source.indexOf('\n
+import { computed } from 'vue' +import { useI18n } from 'vue-i18n' +import { useAssistantStore } from '@/stores/assistant' +import { useAppearanceStoreWithOut } from '@/stores/appearance' +defineProps(['welcome','welcomeDesc','logoAssistant','pageEmbedded','appName']) +const assistantStore = useAssistantStore() +const appearanceStore = useAppearanceStoreWithOut() +const { t } = useI18n() +const loginBg = null, selectAssistantDs = true, currentChatId = undefined +const createNewChatSimple = () => {} +${declarations} +` + } + }, vue(), svg() + ], + optimizeDeps: { noDiscovery: true, include: ['vue', 'vue-router', 'vue-i18n', 'element-plus-secondary', 'lodash-es', 'element-resize-detector', 'dompurify'] }, + resolve: { alias: [ + ...['@/stores/assistant', '@/stores/user', '@/stores/appearance', '@/utils/utils', '@/utils/useCache', '@/utils/request'].map(find => ({ find, replacement: bridge })), + { find: '@', replacement: path.join(frontend, 'src') }, + ...['vue', 'vue-router', 'vue-i18n', 'pinia', 'element-plus-secondary', 'lodash-es', 'element-resize-detector', 'dompurify'].map(name => ({ find: name, replacement: path.join(frontend, 'node_modules', name) })) + ] }, + server: { host: '127.0.0.1', port: 4218, strictPort: true, fs: { allow: [root, frontend] } } +}) +await server.listen() +for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, async () => { await server.close(); process.exit() }) diff --git a/tests/browser/specs/welcome-language.spec.mjs b/tests/browser/specs/welcome-language.spec.mjs new file mode 100644 index 000000000..6a2fb69c0 --- /dev/null +++ b/tests/browser/specs/welcome-language.spec.mjs @@ -0,0 +1,134 @@ +import { readFileSync } from 'node:fs' +import { test, expect } from '@playwright/test' + +const locales = ['zh-CN', 'zh-TW', 'en', 'ko-KR', 'th-TH'] +const messages = Object.fromEntries(locales.map(locale => [locale, + JSON.parse(readFileSync(new URL(`../../../frontend/src/i18n/${locale}.json`, import.meta.url), 'utf8')) +])) +const text = (locale, key) => key.split('.').reduce((value, part) => value[part], messages[locale]) +const titleKey = 'embedded.i_am_sqlbot' +const sloganKey = 'common.intelligent_questioning_platform' + +test.beforeEach(async ({ page }) => { + page.on('pageerror', error => { throw error }) + await page.goto('/') + await page.waitForFunction(() => window.welcomeFixture) +}) + +test('login appearance: restore saves default mode and preserves the other section', async ({ page }) => { + await page.evaluate(() => window.welcomeFixture.editor('appearance', { + slogan: 'Custom login slogan', showSlogan: '1', + pc_welcome: 'Keep platform title', pc_welcome_desc: 'Keep platform description' + })) + await page.getByRole('button', { name: text('zh-CN', 'system.restore_default'), exact: true }).first().click() + await page.getByRole('button', { name: text('zh-CN', 'system.save_and_apply'), exact: true }).click() + await expect.poll(() => page.evaluate(() => window.welcomeFixture.saved.length)).toBe(1) + const saved = await page.evaluate(() => Object.fromEntries(window.welcomeFixture.saved[0].data.map(row => [row.pkey, row.pval]))) + expect(saved.slogan).toBe('') + expect(saved.showSlogan).toBe('0') + expect(saved.pc_welcome).toBe('Keep platform title') + expect(saved.pc_welcome_desc).toBe('Keep platform description') + await page.evaluate(() => window.welcomeFixture.reopen()) + const sloganInput = locale => page.getByPlaceholder(text(locale, sloganKey), { exact: true }) + for (const locale of locales) { + await page.evaluate(locale => window.welcomeFixture.language(locale), locale) + await expect(sloganInput(locale)).toHaveValue('') + await expect(page.locator('.login-welcome')).toHaveText(text(locale, sloganKey)) + } + await sloganInput('th-TH').fill('New custom login slogan') + await page.getByRole('button', { name: text('th-TH', 'system.save_and_apply'), exact: true }).click() + await expect.poll(() => page.evaluate(() => window.welcomeFixture.saved.length)).toBe(2) + const custom = await page.evaluate(() => Object.fromEntries(window.welcomeFixture.saved[1].data.map(row => [row.pkey, row.pval]))) + expect(custom.slogan).toBe('New custom login slogan') + await page.evaluate(() => window.welcomeFixture.reopen()) + await page.evaluate(() => window.welcomeFixture.language('en')) + await expect(sloganInput('en')).toHaveValue('New custom login slogan') + await expect(page.locator('.login-welcome')).toHaveText('New custom login slogan') + await page.evaluate(config => window.welcomeFixture.render('login', 0, config), custom) + await expect(page.locator('.welcome')).toHaveText('New custom login slogan') + await page.evaluate(config => window.welcomeFixture.render('login', 0, config), saved) + await expect(page.locator('.welcome')).toHaveText(text('en', sloganKey)) +}) + +test('login: defaults follow language and the display switch still hides custom text', async ({ page }) => { + for (const old of [...locales, undefined, '', null]) { + const config = { slogan: locales.includes(old) ? text(old, sloganKey) : old, showSlogan: '0' } + await page.evaluate(config => window.welcomeFixture.render('login', 0, config), config) + for (const locale of locales) { + await page.evaluate(locale => window.welcomeFixture.language(locale), locale) + await expect(page.locator('.welcome')).toHaveText(text(locale, sloganKey)) + } + } + const custom = text('zh-CN', sloganKey) + '!' + await page.evaluate(config => window.welcomeFixture.render('login', 0, config), { slogan: custom, showSlogan: '0' }) + await page.evaluate(() => window.welcomeFixture.language('en')) + await expect(page.locator('.welcome')).toHaveText(custom) + await page.evaluate(config => window.welcomeFixture.render('login', 0, config), { slogan: custom, showSlogan: '1' }) + await expect(page.locator('.welcome')).toHaveText('') +}) + +for (const [kind, type] of [['float', 0], ['float', 1], ['page', 0], ['page', 1], ['page', 4], ['chat', 0]]) { + test(`${kind}/${type}: missing, empty and legacy defaults follow language; custom text survives`, async ({ page }) => { + const full = type === 4 || kind === 'chat' + const title = full ? 'pc_welcome' : 'welcome' + const description = full ? 'pc_welcome_desc' : 'welcome_desc' + const descKey = full ? 'qa.hint_description' : 'embedded.data_analysis_now' + const root = page.locator('.welcome-content') + for (const old of [...locales, undefined, '']) { + const config = old === undefined ? {} : { + [title]: old === '' ? '' : text(old, titleKey), + [description]: old === '' ? '' : text(old, descKey) + } + await page.evaluate(args => window.welcomeFixture.render(...args), [kind, type, config]) + for (const locale of locales) { + await page.evaluate(locale => window.welcomeFixture.language(locale), locale) + await expect(root).toContainText(text(locale, titleKey)) + await expect(root).toContainText(text(locale, descKey)) + } + } + await page.evaluate(args => window.welcomeFixture.render(...args), [kind, type, { + [title]: 'Custom welcome', [description]: '我的专属说明' + }]) + for (const locale of locales) { + await page.evaluate(locale => window.welcomeFixture.language(locale), locale) + await expect(root).toContainText('Custom welcome') + await expect(root).toContainText('我的专属说明') + await expect(root).toContainText(text(locale, 'qa.start_sqlbot')) + } + }) +} + +for (const kind of ['settings', 'appearance']) { + test(`${kind}: restore, save, reopen, switch language and edit again`, async ({ page }) => { + const title = kind === 'settings' ? 'welcome' : 'pc_welcome' + const description = kind === 'settings' ? 'welcome_desc' : 'pc_welcome_desc' + await page.evaluate(args => window.welcomeFixture.editor(...args), [kind, { + [title]: 'Custom title', [description]: 'Custom description' + }]) + const restore = page.getByRole('button', { name: text('zh-CN', 'system.restore_default'), exact: true }) + await restore.nth(kind === 'settings' ? 0 : 1).click() + await page.evaluate(() => window.welcomeFixture.language('th-TH')) + const welcomeInput = locale => page.getByPlaceholder(text(locale, titleKey), { exact: true }) + await expect(welcomeInput('th-TH')).toHaveValue('') + await page.getByRole('button', { name: text('th-TH', kind === 'settings' ? 'common.save' : 'system.save_and_apply'), exact: true }).click() + await expect.poll(() => page.evaluate(() => window.welcomeFixture.saved.length)).toBe(1) + const saved = await page.evaluate(() => window.welcomeFixture.saved[0].data) + const values = kind === 'settings' ? saved : Object.fromEntries(saved.map(x => [x.pkey, x.pval])) + expect(values[title]).toBe('') + expect(values[description]).toBe('') + await page.evaluate(() => window.welcomeFixture.reopen()) + await page.evaluate(() => window.welcomeFixture.language('en')) + await expect(welcomeInput('en')).toHaveValue('') + await welcomeInput('en').fill('Temporary custom text') + await welcomeInput('en').fill('') + await expect(welcomeInput('en')).toHaveValue('') + await welcomeInput('en').fill('Custom after reset') + await page.getByRole('button', { name: text('en', kind === 'settings' ? 'common.save' : 'system.save_and_apply'), exact: true }).click() + await expect.poll(() => page.evaluate(() => window.welcomeFixture.saved.length)).toBe(2) + const again = await page.evaluate(() => window.welcomeFixture.saved[1].data) + expect(kind === 'settings' ? again[title] : again.find(x => x.pkey === title).pval).toBe('Custom after reset') + await page.evaluate(() => window.welcomeFixture.reopen()) + await page.evaluate(() => window.welcomeFixture.language('ko-KR')) + await expect(welcomeInput('ko-KR')).toHaveValue('Custom after reset') + }) +} diff --git a/tests/browser/welcome-language.config.mjs b/tests/browser/welcome-language.config.mjs new file mode 100644 index 000000000..34ed372ba --- /dev/null +++ b/tests/browser/welcome-language.config.mjs @@ -0,0 +1,14 @@ +import { defineConfig } from '@playwright/test' +import base from './playwright.config.mjs' + +export default defineConfig({ + ...base, + webServer: { + command: 'node fixtures/welcome-language/server.mjs', + url: 'http://127.0.0.1:4218', + reuseExistingServer: false, + timeout: 30000 + }, + projects: [{ name: 'welcome-language', testMatch: 'welcome-language.spec.mjs', use: { baseURL: 'http://127.0.0.1:4218' } }], + outputDir: 'output/welcome-language-results' +})