diff --git a/code/fms/fms-api/config/migrations/001_add_i18n_type.sql b/code/fms/fms-api/config/migrations/001_add_i18n_type.sql index 58108c8b..7675c90e 100644 --- a/code/fms/fms-api/config/migrations/001_add_i18n_type.sql +++ b/code/fms/fms-api/config/migrations/001_add_i18n_type.sql @@ -1,5 +1,5 @@ /* - 新增「多语言类型」表 dbo.b_i18n_type,并注册独立模块 m_i18n_type 与其权限、管理员授权。 + 新增「多语言类型」表 dbo.s_i18n_type,并注册独立模块 m_i18n_type 与其权限、管理员授权。 用于维护系统支持的语言清单(简称、名称、是否默认、是否启用、排序),默认中文为内置默认语言。 本脚本可重复执行:表、模块、权限均通过存在性判断避免重复播种; @@ -24,22 +24,22 @@ BEGIN TRANSACTION; ); -- 1. 多语言类型表 - IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N'b_i18n_type') + IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = N's_i18n_type') BEGIN - CREATE TABLE dbo.b_i18n_type ( + CREATE TABLE dbo.s_i18n_type ( b_id bigint NOT NULL, b_code varchar(20) NOT NULL, b_name nvarchar(100) NOT NULL, - b_canuse int NOT NULL CONSTRAINT DF_b_i18n_type_canuse DEFAULT (1), - b_default int NOT NULL CONSTRAINT DF_b_i18n_type_default DEFAULT (0), - b_xh int NOT NULL CONSTRAINT DF_b_i18n_type_xh DEFAULT (0), - CONSTRAINT PK_b_i18n_type PRIMARY KEY (b_id), - CONSTRAINT UQ_b_i18n_type_code UNIQUE (b_code) + b_canuse int NOT NULL CONSTRAINT DF_s_i18n_type_canuse DEFAULT (1), + b_default int NOT NULL CONSTRAINT DF_s_i18n_type_default DEFAULT (0), + b_xh int NOT NULL CONSTRAINT DF_s_i18n_type_xh DEFAULT (0), + CONSTRAINT PK_s_i18n_type PRIMARY KEY (b_id), + CONSTRAINT UQ_s_i18n_type_code UNIQUE (b_code) ); - CREATE INDEX IX_b_i18n_type_xh ON dbo.b_i18n_type (b_xh, b_id); + CREATE INDEX IX_s_i18n_type_xh ON dbo.s_i18n_type (b_xh, b_id); -- 预置数据:默认中文不可删除,可选英文 - INSERT INTO dbo.b_i18n_type (b_id, b_code, b_name, b_canuse, b_default, b_xh) VALUES + INSERT INTO dbo.s_i18n_type (b_id, b_code, b_name, b_canuse, b_default, b_xh) VALUES (920000000000000001, N'zh-CN', N'简体中文', 1, 1, 0), (920000000000000002, N'en-US', N'English', 1, 0, 10); END diff --git a/code/fms/fms-api/config/migrations/100_initialize_fms_new.sql b/code/fms/fms-api/config/migrations/100_initialize_fms_new.sql index 48166242..1027327e 100644 --- a/code/fms/fms-api/config/migrations/100_initialize_fms_new.sql +++ b/code/fms/fms-api/config/migrations/100_initialize_fms_new.sql @@ -393,6 +393,25 @@ BEGIN CREATE INDEX IX_s_role_scope_role ON dbo.s_role_scope (b_role_id, b_module_id, b_operation, b_canuse); END; +IF OBJECT_ID(N'dbo.s_i18n_type', N'U') IS NULL +BEGIN + CREATE TABLE dbo.s_i18n_type ( + b_id bigint NOT NULL, + b_code varchar(20) NOT NULL, + b_name nvarchar(100) NOT NULL, + b_canuse int NOT NULL CONSTRAINT DF_s_i18n_type_canuse DEFAULT (1), + b_default int NOT NULL CONSTRAINT DF_s_i18n_type_default DEFAULT (0), + b_xh int NOT NULL CONSTRAINT DF_s_i18n_type_xh DEFAULT (0), + CONSTRAINT PK_s_i18n_type PRIMARY KEY (b_id), + CONSTRAINT UQ_s_i18n_type_code UNIQUE (b_code) + ); + CREATE INDEX IX_s_i18n_type_xh ON dbo.s_i18n_type (b_xh, b_id); + + INSERT INTO dbo.s_i18n_type (b_id, b_code, b_name, b_canuse, b_default, b_xh) VALUES + (920000000000000001, N'zh-CN', N'简体中文', 1, 1, 0), + (920000000000000002, N'en-US', N'English', 1, 0, 10); +END; + IF OBJECT_ID(N'dbo.s_i18n', N'U') IS NULL BEGIN CREATE TABLE dbo.s_i18n ( diff --git a/code/fms/fms-api/config/migrations/106_normalize_i18n_type_table.sql b/code/fms/fms-api/config/migrations/106_normalize_i18n_type_table.sql new file mode 100644 index 00000000..42470658 --- /dev/null +++ b/code/fms/fms-api/config/migrations/106_normalize_i18n_type_table.sql @@ -0,0 +1,45 @@ +/* + 将旧版 dbo.b_i18n_type 统一为系统表 dbo.s_i18n_type。 + 可重复执行,兼容新库未建表、旧库已有 b_i18n_type,以及两张表同时存在的情况。 +*/ +SET NOCOUNT ON; +SET XACT_ABORT ON; + +BEGIN TRANSACTION; + +IF OBJECT_ID(N'dbo.s_i18n_type', N'U') IS NULL +BEGIN + CREATE TABLE dbo.s_i18n_type ( + b_id bigint NOT NULL, + b_code varchar(20) NOT NULL, + b_name nvarchar(100) NOT NULL, + b_canuse int NOT NULL CONSTRAINT DF_s_i18n_type_canuse DEFAULT (1), + b_default int NOT NULL CONSTRAINT DF_s_i18n_type_default DEFAULT (0), + b_xh int NOT NULL CONSTRAINT DF_s_i18n_type_xh DEFAULT (0), + CONSTRAINT PK_s_i18n_type PRIMARY KEY (b_id), + CONSTRAINT UQ_s_i18n_type_code UNIQUE (b_code) + ); + CREATE INDEX IX_s_i18n_type_xh ON dbo.s_i18n_type (b_xh, b_id); +END; + +IF OBJECT_ID(N'dbo.b_i18n_type', N'U') IS NOT NULL +BEGIN + INSERT INTO dbo.s_i18n_type (b_id, b_code, b_name, b_canuse, b_default, b_xh) + SELECT old.b_id, old.b_code, old.b_name, old.b_canuse, old.b_default, old.b_xh + FROM dbo.b_i18n_type AS old + WHERE NOT EXISTS ( + SELECT 1 FROM dbo.s_i18n_type AS current_row + WHERE current_row.b_code = old.b_code + ); + + DROP TABLE dbo.b_i18n_type; +END; + +IF NOT EXISTS (SELECT 1 FROM dbo.s_i18n_type) +BEGIN + INSERT INTO dbo.s_i18n_type (b_id, b_code, b_name, b_canuse, b_default, b_xh) VALUES + (920000000000000001, N'zh-CN', N'简体中文', 1, 1, 0), + (920000000000000002, N'en-US', N'English', 1, 0, 10); +END; + +COMMIT TRANSACTION; diff --git a/code/fms/fms-vue/src/components/fms-module-edit/FmsModuleEditModal.vue b/code/fms/fms-vue/src/components/fms-module-edit/FmsModuleEditModal.vue new file mode 100644 index 00000000..27421432 --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-module-edit/FmsModuleEditModal.vue @@ -0,0 +1,454 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/fms-module-edit/index.js b/code/fms/fms-vue/src/components/fms-module-edit/index.js new file mode 100644 index 00000000..4373d244 --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-module-edit/index.js @@ -0,0 +1,3 @@ +import FmsModuleEditModal from './FmsModuleEditModal.vue' + +export { FmsModuleEditModal } \ No newline at end of file diff --git a/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue b/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue new file mode 100644 index 00000000..4eae4cf0 --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue @@ -0,0 +1,927 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/fms-module-list/FmsModuleTable.vue b/code/fms/fms-vue/src/components/fms-module-list/FmsModuleTable.vue new file mode 100644 index 00000000..c222639e --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-module-list/FmsModuleTable.vue @@ -0,0 +1,412 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/fms-module-list/FmsQueryControl.vue b/code/fms/fms-vue/src/components/fms-module-list/FmsQueryControl.vue new file mode 100644 index 00000000..2fa12b76 --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-module-list/FmsQueryControl.vue @@ -0,0 +1,166 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/fms-module-list/FmsQueryToolbar.vue b/code/fms/fms-vue/src/components/fms-module-list/FmsQueryToolbar.vue new file mode 100644 index 00000000..f68f2733 --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-module-list/FmsQueryToolbar.vue @@ -0,0 +1,260 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/fms-module-list/index.js b/code/fms/fms-vue/src/components/fms-module-list/index.js new file mode 100644 index 00000000..24c73ffe --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-module-list/index.js @@ -0,0 +1,7 @@ +import FmsModuleListPage from './FmsModuleListPage.vue' +import FmsModuleTable from './FmsModuleTable.vue' +import FmsQueryControl from './FmsQueryControl.vue' +import FmsQueryToolbar from './FmsQueryToolbar.vue' + +export { FmsModuleListPage, FmsModuleTable, FmsQueryControl, FmsQueryToolbar } +export * from './moduleListUtils.js' diff --git a/code/fms/fms-vue/src/components/fms-module-list/moduleListUtils.js b/code/fms/fms-vue/src/components/fms-module-list/moduleListUtils.js new file mode 100644 index 00000000..d372d834 --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-module-list/moduleListUtils.js @@ -0,0 +1,177 @@ +// 数据驱动列表的共享工具函数,避免在各组件间重复实现查询/格式化逻辑。 + +export const identifierRe = /^[A-Za-z_][A-Za-z0-9_]*$/ + +const padDatePart = (number) => String(number).padStart(2, '0') + +export function escapeSqlText(value) { + return String(value).replaceAll("'", "''") +} + +export function requireModuleId(value) { + const id = String(value ?? '') + if (!/^\d+$/.test(id)) throw new Error('模块 ID 格式不正确') + return id +} + +export function isEmptyValue(value) { + return value === undefined || value === null || value === '' +} + +export function parseOptions(field) { + if (!field?.b_options) return { optionConfig: {}, options: [] } + try { + const parsed = JSON.parse(field.b_options) + let optionConfig = {} + let source = [] + if (Array.isArray(parsed)) { + source = parsed + } else if (parsed && typeof parsed === 'object') { + optionConfig = parsed + if (Array.isArray(parsed.options)) { + source = parsed.options + } else if (Array.isArray(parsed.items)) { + source = parsed.items + } + } + const options = source.map((option) => { + if (option && typeof option === 'object') { + const value = option.value ?? option.key ?? option.id + return { ...option, value, label: String(option.label ?? option.name ?? value ?? '') } + } + return { value: option, label: String(option ?? '') } + }) + return { optionConfig, options } + } catch { + return { optionConfig: {}, options: [] } + } +} + +export function isRangeQuery(item) { + return ( + item.config.b_operator === 'between' || + item.config.b_component === 'number-range' || + item.field.b_type === 'datetime' + ) +} + +export function isNumericField(item) { + return item.field.b_type === 'number' || item.field.b_type === 'money' +} + +export function isDateQuery(item) { + return ( + item.config.b_component === 'date-picker' || + item.field.b_type === 'date' || + item.field.b_type === 'datetime' + ) +} + +export function emptyQueryValue(item) { + if (item.field.b_type === 'checkbox') return '' + if (isRangeQuery(item) || item.config.b_operator === 'in') return [] + return undefined +} + +export function defaultQueryValue(item) { + const value = item.config.b_default_value + if (value === null || value === undefined || value === '') return emptyQueryValue(item) + if (isRangeQuery(item) || item.config.b_operator === 'in') { + try { + const parsed = JSON.parse(value) + if (Array.isArray(parsed)) return parsed + } catch { + // 非 JSON 默认值继续按逗号分隔处理。 + } + return String(value) + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + } + if (isNumericField(item)) { + const number = Number(value) + return Number.isFinite(number) ? number : undefined + } + return value +} + +export function sqlLiteral(value, field, endOfRange = false) { + if (isEmptyValue(value)) return null + + if (field.b_type === 'number' || field.b_type === 'money') { + const number = Number(value) + return Number.isFinite(number) ? String(number) : null + } + + if (field.b_type === 'checkbox') { + return value === true || Number(value) === 1 ? '1' : '0' + } + + let normalized = String(value) + const isDate = field.b_type === 'date' || field.b_type === 'datetime' + if (isDate && value && typeof value.format === 'function') { + normalized = value.format('YYYY-MM-DD') + } else if (isDate) { + normalized = String(value).slice(0, 10) + } + + if (field.b_type === 'datetime') { + normalized += endOfRange ? ' 23:59:59' : ' 00:00:00' + } + + return `N'${escapeSqlText(normalized)}'` +} + +export function buildQueryCondition(item, value) { + const fieldName = item.field.b_field + if (!identifierRe.test(fieldName)) return '' + + const field = `[${fieldName}]` + if (isRangeQuery(item)) { + const values = Array.isArray(value) ? value : [] + const start = sqlLiteral(values[0], item.field) + const end = sqlLiteral(values[1], item.field, true) + if (start && end) return `${field} BETWEEN ${start} AND ${end}` + if (start) return `${field} >= ${start}` + if (end) return `${field} <= ${end}` + return '' + } + + if (item.config.b_operator === 'in') { + let values = value + if (!Array.isArray(values)) { + values = String(value ?? '') + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + } + const literals = [] + for (const entry of values) { + const literal = sqlLiteral(entry, item.field) + if (literal) literals.push(literal) + } + return literals.length ? `${field} IN (${literals.join(', ')})` : '' + } + + const literal = sqlLiteral(value, item.field) + if (!literal) return '' + if (item.config.b_operator === 'like') { + return `${field} LIKE N'%${escapeSqlText(String(value))}%'` + } + return `${field} = ${literal}` +} + +export function formatDateValue(value, withTime = false) { + if (isEmptyValue(value)) return '' + if (value && typeof value.format === 'function') { + return value.format(withTime ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD') + } + const text = String(value).replace('T', ' ') + if (/^\d{4}-\d{2}-\d{2}/.test(text)) return withTime ? text.slice(0, 19) : text.slice(0, 10) + const date = new Date(value) + if (Number.isNaN(date.getTime())) return text + const datePart = `${date.getFullYear()}-${padDatePart(date.getMonth() + 1)}-${padDatePart(date.getDate())}` + return withTime + ? `${datePart} ${padDatePart(date.getHours())}:${padDatePart(date.getMinutes())}:${padDatePart(date.getSeconds())}` + : datePart +} diff --git a/code/fms/fms-vue/src/layouts/components/AppSidebar.vue b/code/fms/fms-vue/src/layouts/components/AppSidebar.vue index 83f865c2..9b6626f0 100644 --- a/code/fms/fms-vue/src/layouts/components/AppSidebar.vue +++ b/code/fms/fms-vue/src/layouts/components/AppSidebar.vue @@ -55,8 +55,9 @@ const navMain = computed(() => { title: '系统管理', icon: Settings, items: [ - { title: '模块管理', url: '/system/module-management' }, - { title: '菜单管理', url: '/system/menu-management' }, + { title: '模块管理', url: '/module/module-management' }, + { title: '菜单管理', url: '/module/menu-management' }, + { title: '多语言类型', url: '/module/i18n-type' }, ], }) diff --git a/code/fms/fms-vue/src/router/index.js b/code/fms/fms-vue/src/router/index.js index 4e4a3593..af7b469c 100644 --- a/code/fms/fms-vue/src/router/index.js +++ b/code/fms/fms-vue/src/router/index.js @@ -4,6 +4,7 @@ import 'nprogress/nprogress.css' import DefaultLayout from '@/layouts/DefaultLayout.vue' import { useAuthStore } from '@/stores/auth' import { usePermissionStore } from '@/stores/permissions' +import { useLanguagesStore } from '@/stores/languages' NProgress.configure({ showSpinner: false }) @@ -96,18 +97,25 @@ const router = createRouter({ }, // 模块管理:元数据驱动的模块配置中心(先静态路由,权限/动态菜单后续接入) { - path: '/system/module-management', - name: 'system-module-management', - component: () => import('@/views/system/module-management/index.vue'), + path: '/module/module-management', + name: 'module-module-management', + component: () => import('@/views/module/module-management/index.vue'), meta: { title: '模块管理', componentName: 'ModuleManagement' }, }, // 菜单管理:占位页面,待实现 { - path: '/system/menu-management', - name: 'system-menu-management', - component: () => import('@/views/system/menu-management/index.vue'), + path: '/module/menu-management', + name: 'module-menu-management', + component: () => import('@/views/module/menu-management/index.vue'), meta: { title: '菜单管理', componentName: 'MenuManagement' }, }, + // 多语言类型:系统支持的语言清单维护(s_i18n_type) + { + path: '/module/i18n-type', + name: 'module-i18n-type', + component: () => import('@/views/module/i18n-type/index.vue'), + meta: { title: '多语言类型', componentName: 'I18nTypeManagement' }, + }, // 业务页面后续在此追加,配置 meta.moduleId 后自动走权限校验 ], }, @@ -139,6 +147,11 @@ router.beforeEach(async (to) => { const permissionStore = usePermissionStore() await permissionStore.load().catch(() => {}) + // 业务数据多语言配置(s_i18n_type 语言清单):随权限一并加载, + // 供菜单/模块等多语言编辑读取;内部去重、失败不阻断页面 + const languagesStore = useLanguagesStore() + await languagesStore.loadLanguages().catch(() => {}) + const moduleId = to.meta.moduleId if (moduleId && !permissionStore.canAccess(moduleId)) { return '/dashboard' diff --git a/code/fms/fms-vue/src/stores/app.js b/code/fms/fms-vue/src/stores/app.js index 4fedbdcb..e608a02a 100644 --- a/code/fms/fms-vue/src/stores/app.js +++ b/code/fms/fms-vue/src/stores/app.js @@ -2,6 +2,7 @@ import { defineStore } from 'pinia' import { watch } from 'vue' import { DEFAULT_PRIMARY } from '@/theme/presets' import { applyPrimaryColor, applyDarkMode } from '@/theme/index' +import { setLocale as setRuntimeLocale, translate } from '@/utils/translate' export const useAppStore = defineStore('app', { state: () => ({ @@ -18,6 +19,11 @@ export const useAppStore = defineStore('app', { }), getters: { + // 翻译目录由 utils/translate 维护,store 仅提供旧调用方兼容门面。 + t: + () => + (key, fallback = '') => + translate(key, fallback), /** keep-alive 的 include 列表:当前打开页签对应的组件名(去重)。 * 页签关闭时该集合随之缩小,keep-alive 据此销毁对应缓存实例, * 实现"关闭页签即销毁缓存" */ @@ -45,6 +51,7 @@ export const useAppStore = defineStore('app', { }, toggleLocale() { this.locale = this.locale === 'zh-CN' ? 'en-US' : 'zh-CN' + setRuntimeLocale(this.locale) }, addVisitedTab(tab) { if (this.visitedTabs.some((t) => t.key === tab.key)) return @@ -106,6 +113,8 @@ export const useAppStore = defineStore('app', { export function initThemeSync(pinia) { const store = useAppStore(pinia) + setRuntimeLocale(store.locale) + watch( () => store.darkMode, (val) => applyDarkMode(val), diff --git a/code/fms/fms-vue/src/stores/languages.js b/code/fms/fms-vue/src/stores/languages.js new file mode 100644 index 00000000..409d7244 --- /dev/null +++ b/code/fms/fms-vue/src/stores/languages.js @@ -0,0 +1,94 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' +import { loadDataApi } from '@/services/api' + +/** + * 业务数据多语言配置 store(s_i18n_type 驱动)。 + * + * 管理「业务数据要翻译成哪些语言」的语言清单(如菜单名 / 模块名等存 s_i18n 的 + * 目标语言),与界面文案翻译(utils/translate 的 t(key, fallback))无关: + * - 界面文案默认走 t() 的 fallback 参数(中文),本 store 不参与; + * - 本 store 只提供业务语言清单,完全以数据库 s_i18n_type 为准,无内置回退。 + * + * 用法: + * const langStore = useLanguagesStore() + * await langStore.loadLanguages() // 启动时调用一次 + * langStore.languages // 启用的语言(b_canuse=1,按 b_xh 排序) + * langStore.defaultLanguage // b_default=1 那条 + * langStore.nonDefaultLanguages // 其余语言(需要存 s_i18n 表) + * langStore.langName('en-US') // 语言编码 → 名称 + */ +export const useLanguagesStore = defineStore('languages', () => { + /** 语言清单(s_i18n_type,仅 b_canuse=1,按 b_xh 排序) */ + const languages = ref([]) + const loading = ref(false) + const loaded = ref(false) + const error = ref('') + + let loadingPromise = null + + /** 默认语言(b_default=1);无则返回 null */ + const defaultLanguage = computed( + () => languages.value.find((l) => Number(l.b_default) === 1) || null, + ) + /** 非默认语言(需要走 s_i18n 表的语言) */ + const nonDefaultLanguages = computed(() => + languages.value.filter((l) => Number(l.b_default) !== 1), + ) + /** code → 名称;未知编码返回编码本身 */ + const langName = computed( + () => (code) => { + const lang = languages.value.find((l) => String(l.b_code) === String(code)) + return lang?.b_name || String(code) + }, + ) + + /** + * 加载语言配置。并发调用共享同一请求;加载成功后再次调用直接返回。 + * 失败时置 error 且不清空已有列表(保留上次成功数据),由调用方决定重试。 + */ + async function loadLanguages(force = false) { + if (!force && loaded.value) return + if (loadingPromise && !force) return loadingPromise + + loading.value = true + error.value = '' + loadingPromise = loadDataApi('s_i18n_type', 'b_canuse = 1', 'b_xh ASC, b_id ASC') + .then((res) => { + languages.value = (res.data || []).map((row) => ({ + b_id: row.b_id, + b_code: row.b_code, + b_name: row.b_name, + b_canuse: Number(row.b_canuse), + b_default: Number(row.b_default), + b_xh: Number(row.b_xh) || 0, + })) + loaded.value = true + }) + .catch((e) => { + error.value = e?.message || String(e) + }) + .finally(() => { + loading.value = false + loadingPromise = null + }) + return loadingPromise + } + + /** 强制重新加载(如 i18n-type 管理页保存后调用,让消费端同步) */ + function refresh() { + return loadLanguages(true) + } + + return { + languages, + loading, + loaded, + error, + defaultLanguage, + nonDefaultLanguages, + langName, + loadLanguages, + refresh, + } +}) diff --git a/code/fms/fms-vue/src/theme/tokens.css b/code/fms/fms-vue/src/theme/tokens.css index 422d6ed2..25bd4e07 100644 --- a/code/fms/fms-vue/src/theme/tokens.css +++ b/code/fms/fms-vue/src/theme/tokens.css @@ -87,11 +87,11 @@ --fms-popover-shadow: 0 3px 8px rgba(0, 0, 0, 0.12); --fms-z-popover: 1000; - /* 轻提示 / 通知(message / notification) */ + /* 轻提示 / 通知(message / notification):须浮于弹窗之上(弹窗内操作的成功/失败提示可见) */ --fms-feedback-icon-size: 18px; --fms-feedback-offset: 24px; - --fms-z-message: 2000; - --fms-z-notification: 2000; + --fms-z-message: 4000; + --fms-z-notification: 4000; /* 弹窗(modal / drawer) */ --fms-mask-bg: rgba(0, 0, 0, 0.45); diff --git a/code/fms/fms-vue/src/utils/translate.js b/code/fms/fms-vue/src/utils/translate.js new file mode 100644 index 00000000..b1b19a7f --- /dev/null +++ b/code/fms/fms-vue/src/utils/translate.js @@ -0,0 +1,37 @@ +import { computed, readonly, ref } from 'vue' + +// 翻译目录独立于 Pinia:组件、服务和非组件代码都可以复用,切换目录时保持响应式。 +const localeRef = ref('zh-CN') +const messagesRef = ref({}) + +export function translate(key, fallback = '') { + const normalizedKey = String(key || '') + const value = messagesRef.value[normalizedKey] + if (value !== undefined && value !== null && String(value) !== '') return String(value) + return fallback || normalizedKey +} + +export function setLocale(locale) { + localeRef.value = String(locale || '') +} + +export function setMessages(messages, locale = undefined) { + messagesRef.value = messages && typeof messages === 'object' ? { ...messages } : {} + if (locale !== undefined) setLocale(locale) +} + +export function clearMessages() { + messagesRef.value = {} +} + +export function useI18n() { + return { + locale: readonly(localeRef), + messages: readonly(messagesRef), + t: translate, + setLocale, + setMessages, + clearMessages, + localeValue: computed(() => localeRef.value), + } +} diff --git a/code/fms/fms-vue/src/views/dashboard/index.vue b/code/fms/fms-vue/src/views/dashboard/index.vue index e47f1969..2d97e7fe 100644 --- a/code/fms/fms-vue/src/views/dashboard/index.vue +++ b/code/fms/fms-vue/src/views/dashboard/index.vue @@ -1,15 +1,6 @@ - - - - - - -
-
- - -
-
- - -
-
- - -
-
- -
@@ -220,7 +285,7 @@ function onAdvancedReset() { color: var(--fms-text); } -/* ---- demo 卡片:工具栏 + 高级搜索 + 表格 + 分页(灰底画布上的白卡,靠阴影分层,无边框线) ---- */ +/* ---- 列表卡:操作工具栏 + 表格 + 分页(灰底画布上的白卡,靠阴影分层,无边框线) ---- */ .demo-card { display: flex; flex-direction: column; @@ -232,80 +297,102 @@ function onAdvancedReset() { box-shadow: var(--fms-shadow); } -/* ---- 工具栏:左侧搜索 + 高级搜索,右侧更多操作 + 新增 ---- */ +/* ---- 操作工具栏:主操作靠左,更多操作靠右(白底,靠 padding 与上下分区) ---- */ .demo-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; + /* 换行时整组掉下去,避免左右两组元素交错堆叠 */ flex-wrap: wrap; padding: 10px 12px; - border-bottom: 1px solid var(--fms-border); } +/* 组内不换行:窄屏由外层整组换行兜底 */ .toolbar-left, .toolbar-right { display: flex; align-items: center; gap: 8px; - flex-wrap: wrap; } -.toolbar-search { - width: 240px; -} - -/* 高级搜索按钮:展开面板时高亮 */ -.toolbar-toggle-active { - color: var(--fms-primary); - background: color-mix(in srgb, var(--fms-primary) 10%, var(--fms-card)); -} - -/* ---- 高级搜索面板:label 在上、控件等宽网格。 - * 多语言下标签长度不一,label 置顶 + 控件铺满网格单元即可保证各列对齐; - * 查询/重置独立一行、靠右。 ---- */ -.demo-advanced { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); - gap: 12px 16px; - padding: 12px; - border-bottom: 1px solid var(--fms-border); - background: color-mix(in srgb, var(--fms-background) 30%, var(--fms-card)); -} - -.advanced-field { +/* ---- 搜索卡(独立白卡):行堆叠,每行固定 4 个等宽字段格。 + * 格内 label 行内居左、控件占满剩余宽度。 + * 回车提交:表单 submit + 首字段尾部的内嵌查询按钮。 ---- */ +.search-card { display: flex; flex-direction: column; - gap: 6px; + gap: 10px; + padding: 12px; + border-radius: 8px; + background: var(--fms-card); + box-shadow: var(--fms-shadow); +} + +.search-row { + display: flex; + flex-wrap: wrap; + align-items: center; + column-gap: 16px; + row-gap: 10px; +} + +.search-field { + display: flex; + flex-direction: column; + gap: 4px; + /* 控件自适应内容宽度,不撑满整行 */ + flex: 0 1 auto; min-width: 0; } -.advanced-label { +.search-label { font-size: 12px; color: var(--fms-text-secondary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + /* 多语言下 label 可能很长:不强制单行,允许换行,防溢出 */ + white-space: normal; + word-break: break-word; + line-height: 1.4; } -/* 控件统一铺满所在网格单元(Input / Select / RangePicker 根节点) */ -.advanced-field :deep(.input-wrapper), -.advanced-field :deep(.select), -.advanced-field :deep(.fms-range-picker) { - width: 100%; - min-width: 0; +/* 控件按内容自适应宽度(Input / Select / RangePicker 根节点),带保底宽度 */ +.search-field :deep(.input-wrapper), +.search-field :deep(.select), +.search-field :deep(.fms-range-picker) { + width: auto; + min-width: 160px; + max-width: 260px; } -.advanced-actions { - grid-column: 1 / -1; +/* 内嵌在首字段尾部的小查询按钮(原生 button,避免触发表单内嵌套问题) */ +.search-submit { + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + background: none; + padding: 2px; + color: var(--fms-text-secondary); + cursor: pointer; +} + +.search-submit:hover { + color: var(--fms-primary); +} + +.search-actions { display: flex; - justify-content: flex-end; + align-items: center; gap: 8px; + /* 右对齐:按钮落在表单末行右下角,与字段的阅读终点衔接 */ + margin-left: auto; } .demo-table { flex: 1; min-height: 0; + /* 表头文字对齐 12px 轴线:4px 容器 padding + 表格单元格自带 8px 水平 padding */ + padding: 0 4px; /* 行级特殊样式:rowClassName 返回的 class 落在 stk-table 的 tr 上, 这里通过 :deep() 跨过组件边界设置字体颜色与背景色 */ @@ -334,7 +421,8 @@ function onAdvancedReset() { } } -/* ---- 底部分页条:总数靠左,分页靠右 ---- */ +/* ---- 底部分页条:白底,靠 padding 与表格分区(列表卡整体纯白); + * 总数靠左,分页靠右 ---- */ .demo-pagination { display: flex; align-items: center; diff --git a/code/fms/fms-vue/src/views/module/i18n-type/index.vue b/code/fms/fms-vue/src/views/module/i18n-type/index.vue new file mode 100644 index 00000000..0ae35ada --- /dev/null +++ b/code/fms/fms-vue/src/views/module/i18n-type/index.vue @@ -0,0 +1,214 @@ + + + + + diff --git a/code/fms/fms-vue/src/views/system/menu-management/index.vue b/code/fms/fms-vue/src/views/module/menu-management/index.vue similarity index 95% rename from code/fms/fms-vue/src/views/system/menu-management/index.vue rename to code/fms/fms-vue/src/views/module/menu-management/index.vue index c94f707f..4f2baa0a 100644 --- a/code/fms/fms-vue/src/views/system/menu-management/index.vue +++ b/code/fms/fms-vue/src/views/module/menu-management/index.vue @@ -49,14 +49,38 @@ import { cloneData } from '@/utils/dataChanges' import { createTempId } from '@/utils/tempId' import UnsavedChangesModal from '@/components/UnsavedChangesModal.vue' import I18nNameModal from '@/components/I18nNameModal.vue' +import { useLanguagesStore } from '@/stores/languages' -/* ================= 多语言配置 ================= */ -// 菜单多语言:默认语言(简体中文)直接存 b_name;其他语言存 s_i18n(b_key=menu.编码) -const I18N_LANGUAGES = [ - { code: 'zh-CN', name: '简体中文', default: true }, - { code: 'en-US', name: 'English', default: false }, -] -const nonDefaultLanguages = I18N_LANGUAGES.filter((l) => !l.default) +/* ================= 多语言配置(s_i18n_type 驱动) ================= */ +// 菜单多语言:默认语言(b_default=1)名称直接存 b_name;其余语言存 s_i18n(b_key=menu.编码)。 +// 语言清单来自数据库(stores/languages),不再写死 zh-CN/en-US。 +const langStore = useLanguagesStore() + +/** 默认语言 code(b_default=1;数据异常时回退第一个启用语言,均为数据库配置) */ +const defaultLocale = computed(() => { + const def = langStore.defaultLanguage?.b_code + if (def) return def + return langStore.languages[0]?.b_code || '' +}) + +/** 给 I18nNameModal 的语言列表({ code, name, default }) */ +const I18N_LANGUAGES = computed(() => + langStore.languages.map((l) => ({ + code: l.b_code, + name: l.b_name, + default: Number(l.b_default) === 1, + })), +) + +/** 非默认语言(走 s_i18n 表,{ code, name }) */ +const nonDefaultLanguages = computed(() => + langStore.nonDefaultLanguages.map((l) => ({ code: l.b_code, name: l.b_name })), +) + +/** 默认语言名称(hint 文案用,动态取自语言配置) */ +const defaultLocaleName = computed( + () => langStore.defaultLanguage?.b_name || defaultLocale.value || '', +) /* ================= 类型映射 ================= */ const menuTypeOptions = [ @@ -65,12 +89,6 @@ const menuTypeOptions = [ { label: '外链', value: 'external' }, ] -const menuTypeLabel = { - directory: '目录', - page: '页面', - external: '外链', -} - const menuTypeIconMap = { directory: Folder, page: FileText, @@ -106,11 +124,6 @@ const selectedMenu = computed(() => const busy = computed(() => configLoading.value || configSaving.value) -const parentMenuName = computed(() => { - if (!selectedMenu.value?.b_parent_id) return '' - return findTreeNode(menuTreeData.value, selectedMenu.value.b_parent_id)?.b_name || '' -}) - const menuType = computed(() => selectedMenu.value?.b_menu_type || null) const isDirectory = computed(() => menuType.value === 'directory') const isPage = computed(() => menuType.value === 'page') @@ -196,7 +209,7 @@ const toolbarAddOptions = [ { key: 'external', label: '新增外链', icon: ExternalLink }, ] -function onToolbarAdd(key) { +function onToolbarAdd({ key }) { const parentId = selectedMenu.value ? String(selectedMenu.value.b_id) : null void addMenu(parentId, key) } @@ -348,8 +361,8 @@ async function addMenu(parentId, menuTypeValue = 'page') { await nextTick() drafts.value = cloneData({ menu: newMenu }) originals.value = cloneData(drafts.value) - i18nValues.value = { 'zh-CN': '' } - originalI18nValues.value = { 'zh-CN': '' } + i18nValues.value = { [defaultLocale.value]: '' } + originalI18nValues.value = { [defaultLocale.value]: '' } powers.value = [] powersBaseline.value = '[]' loadedMenuId.value = newId @@ -416,11 +429,12 @@ async function loadMenuConfig(menuId) { originals.value = { menu: menu ? { ...menu } : null } drafts.value = cloneData(originals.value) // 多语言:默认语言复用 b_name,其余读 s_i18n(按 b_module_id 关联) - const i18nValuesInit = { 'zh-CN': menu?.b_name || '' } + const i18nValuesInit = { [defaultLocale.value]: menu?.b_name || '' } if (menu) { const i18nRes = await loadDataApi('s_i18n', `b_module_id = ${id}`, 'b_key ASC, b_locale ASC') for (const row of i18nRes.data || []) { - if (row.b_locale && row.b_locale !== 'zh-CN') i18nValuesInit[row.b_locale] = row.b_value + if (row.b_locale && row.b_locale !== defaultLocale.value) + i18nValuesInit[row.b_locale] = row.b_value } } i18nValues.value = i18nValuesInit @@ -485,7 +499,7 @@ watch(menuSelectedId, (id) => { } drafts.value = cloneData({ menu: menuRowOf(node) }) originals.value = cloneData(drafts.value) - i18nValues.value = { 'zh-CN': node.b_name || '' } + i18nValues.value = { [defaultLocale.value]: node.b_name || '' } originalI18nValues.value = { ...i18nValues.value } powers.value = [] powersBaseline.value = '[]' @@ -506,7 +520,9 @@ function hasUnsavedChanges() { if (JSON.stringify(drafts.value.menu) !== JSON.stringify(originals.value.menu)) return true // 多语言非默认语言变化视为未保存 if ( - nonDefaultLanguages.some((l) => i18nValues.value[l.code] !== originalI18nValues.value[l.code]) + nonDefaultLanguages.value.some( + (l) => i18nValues.value[l.code] !== originalI18nValues.value[l.code], + ) ) return true // 操作/按钮权限点变化视为未保存 @@ -607,10 +623,7 @@ async function saveMenu() { ) return false } - if (typeRule.module && !menuData.b_module_id) { - Message.warning('页面菜单必须绑定模块') - return false - } + // 绑定模块非必填:页面菜单可不绑定业务模块,留空即保存为空 if (!typeRule.route && menuData.b_route) menuData.b_route = null if (!typeRule.module && menuData.b_module_id) menuData.b_module_id = null @@ -639,7 +652,7 @@ async function saveMenu() { // 多语言:先删后插(同表请求内按 del→upd→ins 顺序执行) const i18nRows = [] - for (const lang of nonDefaultLanguages) { + for (const lang of nonDefaultLanguages.value) { const value = (i18nValues.value[lang.code] || '').trim() if (!value) continue i18nRows.push({ @@ -825,7 +838,7 @@ async function saveMenu() { /* ================= 表单字段更新 ================= */ function setField(field, value) { if (!drafts.value?.menu) return - drafts.value.menu[field] = value === '' ? null : value + drafts.value.menu[field] = value === '' || value == null ? null : value } /** 设置非默认语言的多语言名称 */ @@ -836,15 +849,16 @@ function setI18nValue(locale, value) { /** 多语言弹窗值对象(默认语言=菜单名称,其余=各语言) */ const i18nModalModel = computed(() => { const menu = drafts.value?.menu - const values = { 'zh-CN': menu?.b_name || '' } - for (const lang of nonDefaultLanguages) values[lang.code] = i18nValues.value[lang.code] || '' + const values = { [defaultLocale.value]: menu?.b_name || '' } + for (const lang of nonDefaultLanguages.value) + values[lang.code] = i18nValues.value[lang.code] || '' return values }) /** 多语言弹窗确定时写回草稿与 i18nValues */ function onI18nModalChange(values) { - if (drafts.value?.menu) setField('b_name', values['zh-CN'] ?? '') - for (const lang of nonDefaultLanguages) setI18nValue(lang.code, values[lang.code] || '') + if (drafts.value?.menu) setField('b_name', values[defaultLocale.value] ?? '') + for (const lang of nonDefaultLanguages.value) setI18nValue(lang.code, values[lang.code] || '') } function onMenuTypeChange(v) { @@ -1162,9 +1176,6 @@ loadPowerLibrary() > {{ node.b_name || node.b_code || '(未命名)' }} - - {{ menuTypeLabel[node.b_menu_type] || '' }} - - - - - - + +