From 66d359c6d476fe7aa0d3b64d36ba10d57d5050ea Mon Sep 17 00:00:00 2001
From: Malekith <40722617+420978011@users.noreply.github.com>
Date: Fri, 17 Jul 2026 12:19:55 +0800
Subject: [PATCH] feat: add bilingual interface
---
frontend/index.html | 4 +-
frontend/package.json | 3 +-
frontend/pnpm-lock.yaml | 70 +++++-
frontend/src/App.vue | 235 +++++++++++-------
.../components/ui/dialog/DialogContent.vue | 4 +-
.../ui/dialog/DialogScrollContent.vue | 4 +-
frontend/src/i18n/i18n.test.ts | 13 +
frontend/src/i18n/index.ts | 15 ++
frontend/src/i18n/locales/en-US.ts | 120 +++++++++
frontend/src/i18n/locales/zh-CN.ts | 120 +++++++++
frontend/src/main.ts | 2 +
frontend/src/store/settingsStore.test.ts | 57 +++++
frontend/src/store/settingsStore.ts | 34 +++
13 files changed, 571 insertions(+), 110 deletions(-)
create mode 100644 frontend/src/i18n/i18n.test.ts
create mode 100644 frontend/src/i18n/index.ts
create mode 100644 frontend/src/i18n/locales/en-US.ts
create mode 100644 frontend/src/i18n/locales/zh-CN.ts
create mode 100644 frontend/src/store/settingsStore.test.ts
create mode 100644 frontend/src/store/settingsStore.ts
diff --git a/frontend/index.html b/frontend/index.html
index 096d706..c9ffe9f 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1,10 +1,10 @@
-
+
- frontend
+ Modlab
diff --git a/frontend/package.json b/frontend/package.json
index 1ce509b..bf829d2 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -23,7 +23,8 @@
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.2",
"tw-animate-css": "^1.4.0",
- "vue": "^3.5.39"
+ "vue": "^3.5.39",
+ "vue-i18n": "^11.4.6"
},
"devDependencies": {
"@types/node": "^24.13.2",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index a8c5640..feff9c4 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -50,6 +50,9 @@ importers:
vue:
specifier: ^3.5.39
version: 3.5.39(typescript@6.0.3)
+ vue-i18n:
+ specifier: ^11.4.6
+ version: 11.4.6(vue@3.5.39(typescript@6.0.3))
devDependencies:
'@types/node':
specifier: ^24.13.2
@@ -299,6 +302,22 @@ packages:
'@internationalized/number@3.6.7':
resolution: {integrity: sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==}
+ '@intlify/core-base@11.4.6':
+ resolution: {integrity: sha512-EOeHO95XESK9IFHgHeZXunsM/WBAoCA0DlaWODvx14vKmetAuS97t+l6Xe9hTUqntPpF93vtVSjjUDafw3wXMw==}
+ engines: {node: '>= 22'}
+
+ '@intlify/devtools-types@11.4.6':
+ resolution: {integrity: sha512-wowQPpNem56b2d43IJmqbrzG2FeBKe5f/kUGlpNuBmXs6OSqncF8m1+1lxHuW8ISZJF0ma2RkW3iLkw0g0G4VA==}
+ engines: {node: '>= 22'}
+
+ '@intlify/message-compiler@11.4.6':
+ resolution: {integrity: sha512-5nj3jULqeTAC1WovwMs1LQWgatTa2pM/rXN9T3XW8rdOtXW9ZF6/GLSNFTKDQmPLwclhPdgUWLJ/4w3fMeeC/Q==}
+ engines: {node: '>= 22'}
+
+ '@intlify/shared@11.4.6':
+ resolution: {integrity: sha512-m1p1HHAMLhqSpTRH7VnXdrN0CQ4y+9vunFkpLkbD8soIuBsnQdawZXqMCgvwI2UVF9Ww7sVaw7g9tV2VO7shoA==}
+ engines: {node: '>= 22'}
+
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
@@ -676,6 +695,9 @@ packages:
'@vue/compiler-ssr@3.5.39':
resolution: {integrity: sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==}
+ '@vue/devtools-api@6.6.4':
+ resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
+
'@vue/devtools-api@7.7.10':
resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==}
@@ -2221,10 +2243,6 @@ packages:
resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==}
engines: {node: '>=18'}
- string-width@4.2.0:
- resolution: {integrity: sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==}
- engines: {node: '>=8'}
-
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -2488,6 +2506,12 @@ packages:
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ vue-i18n@11.4.6:
+ resolution: {integrity: sha512-l0gE7Rfy0phCa5ChKYkOq543Wgd39BCK6hkktfr1Ed4D99oRkgPK9ffShASZdeC8OJxGfdWmpYoAaAH6iLEuIg==}
+ engines: {node: '>= 22'}
+ peerDependencies:
+ vue: ^3.0.0
+
vue-metamorph@3.3.4:
resolution: {integrity: sha512-WZ1xzHrmYh9UiZ7OC9eG1ASzgSybEB10jhop+k5KzMY9I1JmRKdreqUYzbV3hOnOMvLhyDn7y6f62mLE2jHFSg==}
hasBin: true
@@ -2867,10 +2891,28 @@ snapshots:
dependencies:
'@swc/helpers': 0.5.23
+ '@intlify/core-base@11.4.6':
+ dependencies:
+ '@intlify/devtools-types': 11.4.6
+ '@intlify/message-compiler': 11.4.6
+ '@intlify/shared': 11.4.6
+
+ '@intlify/devtools-types@11.4.6':
+ dependencies:
+ '@intlify/core-base': 11.4.6
+ '@intlify/shared': 11.4.6
+
+ '@intlify/message-compiler@11.4.6':
+ dependencies:
+ '@intlify/shared': 11.4.6
+ source-map-js: 1.2.1
+
+ '@intlify/shared@11.4.6': {}
+
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
- string-width-cjs: string-width@4.2.0
+ string-width-cjs: string-width@4.2.3
strip-ansi: 7.2.0
strip-ansi-cjs: strip-ansi@6.0.1
wrap-ansi: 8.1.0
@@ -3226,6 +3268,8 @@ snapshots:
'@vue/compiler-dom': 3.5.39
'@vue/shared': 3.5.39
+ '@vue/devtools-api@6.6.4': {}
+
'@vue/devtools-api@7.7.10':
dependencies:
'@vue/devtools-kit': 7.7.10
@@ -4725,12 +4769,6 @@ snapshots:
stdin-discarder@0.3.2: {}
- string-width@4.2.0:
- dependencies:
- emoji-regex: 8.0.0
- is-fullwidth-code-point: 3.0.0
- strip-ansi: 6.0.1
-
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
@@ -4934,6 +4972,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ vue-i18n@11.4.6(vue@3.5.39(typescript@6.0.3)):
+ dependencies:
+ '@intlify/core-base': 11.4.6
+ '@intlify/devtools-types': 11.4.6
+ '@intlify/shared': 11.4.6
+ '@vue/devtools-api': 6.6.4
+ vue: 3.5.39(typescript@6.0.3)
+
vue-metamorph@3.3.4(eslint@10.6.0(jiti@2.7.0)):
dependencies:
'@babel/parser': 8.0.0-alpha.12
@@ -4996,7 +5042,7 @@ snapshots:
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
- string-width: 4.2.0
+ string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@8.1.0:
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 6a298ae..71328ab 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -1,6 +1,7 @@
@@ -45,7 +47,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
>
diff --git a/frontend/src/components/ui/dialog/DialogScrollContent.vue b/frontend/src/components/ui/dialog/DialogScrollContent.vue
index 65683fc..4fbff54 100644
--- a/frontend/src/components/ui/dialog/DialogScrollContent.vue
+++ b/frontend/src/components/ui/dialog/DialogScrollContent.vue
@@ -1,5 +1,6 @@
@@ -52,7 +54,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
>
- Close
+ {{ t('common.close') }}
diff --git a/frontend/src/i18n/i18n.test.ts b/frontend/src/i18n/i18n.test.ts
new file mode 100644
index 0000000..e21e681
--- /dev/null
+++ b/frontend/src/i18n/i18n.test.ts
@@ -0,0 +1,13 @@
+import { describe, expect, it } from 'vitest'
+import i18n from './index'
+
+describe('application translations', () => {
+ it('renders dynamic status messages in Chinese and English', () => {
+ i18n.global.locale.value = 'zh-CN'
+ expect(i18n.global.t('status.autoReadSuccess', { name: '主站 1', count: 2 })).toContain('主站 1')
+
+ i18n.global.locale.value = 'en-US'
+ expect(i18n.global.t('status.autoReadSuccess', { name: 'Master 1', count: 2 })).toContain('Master 1')
+ expect(i18n.global.t('app.copyright')).toContain('dote27@163.com')
+ })
+})
diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts
new file mode 100644
index 0000000..fa3ef49
--- /dev/null
+++ b/frontend/src/i18n/index.ts
@@ -0,0 +1,15 @@
+import { createI18n } from 'vue-i18n'
+import enUS from './locales/en-US'
+import zhCN from './locales/zh-CN'
+
+export const i18n = createI18n({
+ legacy: false,
+ locale: 'zh-CN',
+ fallbackLocale: 'en-US',
+ messages: {
+ 'zh-CN': zhCN,
+ 'en-US': enUS,
+ },
+})
+
+export default i18n
diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts
new file mode 100644
index 0000000..21651f2
--- /dev/null
+++ b/frontend/src/i18n/locales/en-US.ts
@@ -0,0 +1,120 @@
+export default {
+ app: {
+ title: 'Modlab',
+ copyright: "© Modlab · dote27{'@'}163.com",
+ },
+ common: {
+ cancel: 'Cancel',
+ save: 'Save',
+ close: 'Close',
+ settings: 'Settings',
+ language: 'Language',
+ address: 'Address',
+ port: 'Port',
+ },
+ connection: {
+ master: 'Master',
+ slave: 'Slave',
+ connect: 'Connect',
+ disconnect: 'Disconnect',
+ settings: 'Connection Settings',
+ tcp: 'Modbus TCP',
+ rtu: 'Modbus RTU',
+ ip: 'IP',
+ baudRate: 'Baud Rate',
+ dataBits: 'Data Bits',
+ parity: 'Parity',
+ stopBits: 'Stop Bits',
+ none: 'None',
+ even: 'Even',
+ odd: 'Odd',
+ },
+ actions: {
+ addConnection: 'Add Connection',
+ random: 'Random',
+ increment: 'Increment',
+ stopIncrement: 'Stop Increment',
+ startPoll: 'Start Poll',
+ stopPoll: 'Stop Poll',
+ read: 'Read',
+ write: 'Write',
+ },
+ fields: {
+ unitId: 'Unit ID',
+ function: 'Function',
+ start: 'Start Address',
+ count: 'Count',
+ dataType: 'Data Type',
+ format: 'Format',
+ byteOrder: 'Byte Order',
+ interval: 'Interval',
+ raw: 'Raw Data',
+ },
+ functionCodes: {
+ '01': 'Coils (0x01)',
+ '02': 'Discrete Inputs (0x02)',
+ '03': 'Holding Registers (0x03)',
+ '04': 'Input Registers (0x04)',
+ },
+ data: {
+ decimal: 'Decimal',
+ hexadecimal: 'Hexadecimal',
+ binary: 'Binary',
+ current: 'Current',
+ writeAddress: 'Write address',
+ },
+ dialogs: {
+ addConnection: 'Add Connection',
+ chooseRole: 'Choose a role.',
+ name: 'Name',
+ optionalName: 'Name (optional)',
+ role: 'Role',
+ connection: 'Connection',
+ connectionDescription: 'Set TCP or serial parameters.',
+ write: 'Write Value',
+ writeAddress: 'Address: {address}',
+ settings: 'Application Settings',
+ settingsDescription: 'Adjust application display preferences.',
+ language: 'Interface Language',
+ chinese: '中文',
+ english: 'English',
+ },
+ placeholders: {
+ optionalName: 'e.g. Pump Station 1',
+ noSerialPorts: 'No serial ports',
+ selectPort: 'Select port',
+ },
+ messages: {
+ noSerialPorts: 'No serial ports available.',
+ noSerialPortsHint: 'No serial ports found. Plug in a device and wait.',
+ duplicateName: 'A connection with this name already exists.',
+ readOnly: 'Function codes 02 and 04 are read-only.',
+ },
+ status: {
+ ready: 'System Ready.',
+ autoReadSuccess: '[{name}] Auto Read: {count} registers successfully.',
+ autoReadError: '[{name}] Auto Read error: {error}. Reconnecting...',
+ autoReadStarted: '[{name}] Auto Read started.',
+ autoReadStopped: '[{name}] Auto Read stopped.',
+ readSuccess: '[{name}] Read {count} registers successfully.',
+ readError: '[{name}] Read error: {error}',
+ memoryRefreshed: '[{name}] Memory refreshed.',
+ connectedTcp: '[{name}] Connected to TCP {address}',
+ connectedRtu: '[{name}] Connected to RTU {address}',
+ listeningTcp: '[{name}] Listening on TCP {address}',
+ listeningRtu: '[{name}] Listening on RTU {address}',
+ disconnected: '[{name}] Disconnected.',
+ disconnectFailed: '[{name}] Disconnect failed: {error}',
+ connectionFailed: '[{name}] Connection failed: {error}',
+ connectBeforeWrite: '[{name}] Connect before writing.',
+ invalidInput: '[{name}] Invalid input: {error}',
+ updatedMemory: '[{name}] Updated local memory at {address}.',
+ writeFailed: '[{name}] Write failed: {error}',
+ randomized: '[{name}] Randomized {count} values based on {dataType}.',
+ randomizeError: '[{name}] Randomize error: {error}',
+ incrementStarted: '[{name}] Auto Increment started.',
+ incrementStopped: '[{name}] Auto Increment stopped.',
+ incrementError: '[{name}] Increment error: {error}',
+ externalWrite: '[{name}] External Write to addr {address}.',
+ },
+} as const
diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts
new file mode 100644
index 0000000..491b943
--- /dev/null
+++ b/frontend/src/i18n/locales/zh-CN.ts
@@ -0,0 +1,120 @@
+export default {
+ app: {
+ title: 'Modlab',
+ copyright: "© Modlab · dote27{'@'}163.com",
+ },
+ common: {
+ cancel: '取消',
+ save: '保存',
+ close: '关闭',
+ settings: '设置',
+ language: '语言',
+ address: '地址',
+ port: '端口',
+ },
+ connection: {
+ master: '主站',
+ slave: '从站',
+ connect: '连接',
+ disconnect: '断开',
+ settings: '连接设置',
+ tcp: 'Modbus TCP',
+ rtu: 'Modbus RTU',
+ ip: 'IP',
+ baudRate: '波特率',
+ dataBits: '数据位',
+ parity: '校验位',
+ stopBits: '停止位',
+ none: '无',
+ even: '偶校验',
+ odd: '奇校验',
+ },
+ actions: {
+ addConnection: '添加连接',
+ random: '随机生成',
+ increment: '递增',
+ stopIncrement: '停止递增',
+ startPoll: '开始轮询',
+ stopPoll: '停止轮询',
+ read: '读取',
+ write: '写入',
+ },
+ fields: {
+ unitId: '从站地址',
+ function: '功能码',
+ start: '起始地址',
+ count: '数量',
+ dataType: '数据类型',
+ format: '格式',
+ byteOrder: '字节序',
+ interval: '间隔',
+ raw: '原始数据',
+ },
+ functionCodes: {
+ '01': '线圈 (0x01)',
+ '02': '离散输入 (0x02)',
+ '03': '保持寄存器 (0x03)',
+ '04': '输入寄存器 (0x04)',
+ },
+ data: {
+ decimal: '十进制',
+ hexadecimal: '十六进制',
+ binary: '二进制',
+ current: '当前值',
+ writeAddress: '写入地址',
+ },
+ dialogs: {
+ addConnection: '添加连接',
+ chooseRole: '选择连接角色。',
+ name: '名称',
+ optionalName: '名称(可选)',
+ role: '角色',
+ connection: '连接参数',
+ connectionDescription: '设置 TCP 或串口参数。',
+ write: '写入数值',
+ writeAddress: '地址:{address}',
+ settings: '应用设置',
+ settingsDescription: '调整应用的显示偏好。',
+ language: '界面语言',
+ chinese: '中文',
+ english: 'English',
+ },
+ placeholders: {
+ optionalName: '例如:一号泵站',
+ noSerialPorts: '没有可用串口',
+ selectPort: '选择串口',
+ },
+ messages: {
+ noSerialPorts: '没有可用的串口。',
+ noSerialPortsHint: '未找到串口。请连接设备后稍候。',
+ duplicateName: '已存在同名连接。',
+ readOnly: '功能码 02 和 04 为只读区域。',
+ },
+ status: {
+ ready: '系统就绪。',
+ autoReadSuccess: '[{name}] 自动读取成功:{count} 个寄存器。',
+ autoReadError: '[{name}] 自动读取失败:{error}。正在重连...',
+ autoReadStarted: '[{name}] 已开始自动读取。',
+ autoReadStopped: '[{name}] 已停止自动读取。',
+ readSuccess: '[{name}] 成功读取 {count} 个寄存器。',
+ readError: '[{name}] 读取失败:{error}',
+ memoryRefreshed: '[{name}] 从站内存已刷新。',
+ connectedTcp: '[{name}] 已连接到 TCP {address}',
+ connectedRtu: '[{name}] 已连接到 RTU {address}',
+ listeningTcp: '[{name}] 正在监听 TCP {address}',
+ listeningRtu: '[{name}] 正在监听 RTU {address}',
+ disconnected: '[{name}] 已断开连接。',
+ disconnectFailed: '[{name}] 断开连接失败:{error}',
+ connectionFailed: '[{name}] 连接失败:{error}',
+ connectBeforeWrite: '[{name}] 请先连接后再写入。',
+ invalidInput: '[{name}] 输入无效:{error}',
+ updatedMemory: '[{name}] 已更新地址 {address} 的本地内存。',
+ writeFailed: '[{name}] 写入失败:{error}',
+ randomized: '[{name}] 已根据 {dataType} 随机生成 {count} 个值。',
+ randomizeError: '[{name}] 随机生成失败:{error}',
+ incrementStarted: '[{name}] 已开始自动递增。',
+ incrementStopped: '[{name}] 已停止自动递增。',
+ incrementError: '[{name}] 递增失败:{error}',
+ externalWrite: '[{name}] 外部写入地址 {address}。',
+ },
+} as const
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index 0f70684..29f81c8 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -3,10 +3,12 @@ import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import './style.css'
import App from './App.vue'
+import i18n from './i18n'
const app = createApp(App)
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
+app.use(i18n)
app.mount('#app')
diff --git a/frontend/src/store/settingsStore.test.ts b/frontend/src/store/settingsStore.test.ts
new file mode 100644
index 0000000..153080d
--- /dev/null
+++ b/frontend/src/store/settingsStore.test.ts
@@ -0,0 +1,57 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { createPinia, setActivePinia } from 'pinia'
+import { useSettingsStore } from './settingsStore'
+
+const storage = new Map()
+
+const localStorageMock: Storage = {
+ getItem: (key) => storage.get(key) ?? null,
+ setItem: (key, value) => storage.set(key, value),
+ removeItem: (key) => storage.delete(key),
+ clear: () => storage.clear(),
+ key: (index) => Array.from(storage.keys())[index] ?? null,
+ get length() {
+ return storage.size
+ },
+}
+
+const createConfiguredPinia = () => {
+ const pinia = createPinia()
+ setActivePinia(pinia)
+ return pinia
+}
+
+describe('settingsStore', () => {
+ beforeEach(() => {
+ storage.clear()
+ vi.stubGlobal('localStorage', localStorageMock)
+ vi.stubGlobal('window', { localStorage: localStorageMock })
+ })
+
+ it('defaults to Chinese', () => {
+ createConfiguredPinia()
+
+ const store = useSettingsStore()
+
+ expect(store.locale).toBe('zh-CN')
+ })
+
+ it('persists a selected language and restores it in a new Pinia instance', () => {
+ createConfiguredPinia()
+ useSettingsStore().setLocale('en-US')
+
+ createConfiguredPinia()
+ const restoredStore = useSettingsStore()
+
+ expect(restoredStore.locale).toBe('en-US')
+ })
+
+ it('falls back to Chinese for an unsupported persisted language', () => {
+ storage.set('modlab_settings', JSON.stringify({ locale: 'ja-JP' }))
+ createConfiguredPinia()
+
+ const store = useSettingsStore()
+
+ expect(store.locale).toBe('zh-CN')
+ })
+})
diff --git a/frontend/src/store/settingsStore.ts b/frontend/src/store/settingsStore.ts
new file mode 100644
index 0000000..c2dd898
--- /dev/null
+++ b/frontend/src/store/settingsStore.ts
@@ -0,0 +1,34 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+
+export type AppLocale = 'zh-CN' | 'en-US'
+const SETTINGS_STORAGE_KEY = 'modlab_settings'
+
+const isAppLocale = (value: unknown): value is AppLocale => value === 'zh-CN' || value === 'en-US'
+
+const loadLocale = (): AppLocale => {
+ if (typeof window === 'undefined') return 'zh-CN'
+
+ try {
+ const saved = JSON.parse(window.localStorage.getItem(SETTINGS_STORAGE_KEY) || 'null') as { locale?: unknown } | null
+ return isAppLocale(saved?.locale) ? saved.locale : 'zh-CN'
+ } catch {
+ return 'zh-CN'
+ }
+}
+
+const persistLocale = (locale: AppLocale) => {
+ if (typeof window === 'undefined') return
+ window.localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify({ locale }))
+}
+
+export const useSettingsStore = defineStore('settings', () => {
+ const locale = ref(loadLocale())
+
+ const setLocale = (nextLocale: AppLocale) => {
+ locale.value = nextLocale
+ persistLocale(nextLocale)
+ }
+
+ return { locale, setLocale }
+})