20260903173038

This commit is contained in:
oneao committed 2026-09-03 17:30:39 +08:00
1 parent c8940f6e03
commit 0faf44cffc
55 files changed
+3278 -208

No files matched your search

+196 -108
View File
@@ -1,15 +1,6 @@
<script setup>
import { ref } from 'vue'
import {
ChevronDown,
Download,
Plus,
RefreshCw,
Search,
SlidersHorizontal,
Trash2,
Upload,
} from '@lucide/vue'
import { computed, ref } from 'vue'
import { ChevronDown, ChevronUp, Download, Plus, RefreshCw, Search, Trash2, Upload } from '@lucide/vue'
import { Button, Dropdown, Input, Pagination, RangePicker, Select } from '@/components/ui'
import FmsTable from '@/components/fms-table/FmsTable.vue'
@@ -27,6 +18,21 @@ const STATUS_OPTIONS = [
{ value: 'disabled', label: '禁用' },
{ value: 'pending', label: '待审' },
]
// 低频筛选字段选项(模拟多字段场景)
const PRIORITY_OPTIONS = [
{ value: 'high', label: '高' },
{ value: 'mid', label: '中' },
{ value: 'low', label: '低' },
]
const SOURCE_OPTIONS = [
{ value: 'manual', label: '手工录入' },
{ value: 'import', label: '导入' },
{ value: 'api', label: 'API' },
]
const ENABLED_OPTIONS = [
{ value: '1', label: '是' },
{ value: '0', label: '否' },
]
// 额外可编辑文本列数量,与下方业务列合计 20 列
const EXTRA_FIELD_COUNT = 14
@@ -102,16 +108,48 @@ const DEFAULT_ROW_COUNT = 10000
const columns = ref(makeColumns())
const dataSource = ref(makeRows(DEFAULT_ROW_COUNT))
/* ---------- 工具栏 UI(仅界面骨架,搜索/查询逻辑后续接入) ---------- */
const keyword = ref('')
const advancedOpen = ref(false)
const advancedForm = ref({
/* ---------- 搜索表单 + 操作工具栏(仅界面骨架,查询/操作逻辑后续接入) ---------- */
const searchForm = ref({
name: '',
type: undefined,
status: undefined,
range: null,
owner: undefined,
priority: undefined,
source: undefined,
enabled: undefined,
minScore: null,
})
// 搜索字段描述:顺序即表单字段顺序(前 FIELDS_PER_ROW 个为常驻行)
const searchFields = [
{ key: 'name', label: '名称 / ID', type: 'input', placeholder: '模糊搜索', submitIcon: true },
{ key: 'type', label: '类型', type: 'select', options: TYPE_OPTIONS },
{ key: 'status', label: '状态', type: 'select', options: STATUS_OPTIONS },
{ key: 'range', label: '创建时间', type: 'range' },
{ key: 'owner', label: '负责人', type: 'input' },
{ key: 'priority', label: '优先级', type: 'select', options: PRIORITY_OPTIONS },
{ key: 'source', label: '来源', type: 'select', options: SOURCE_OPTIONS },
{ key: 'enabled', label: '启用', type: 'select', options: ENABLED_OPTIONS },
{ key: 'minScore', label: '最低评分', type: 'input-number' },
]
/* 每行固定 4 个字段格;字段更多时低频字段另起整行(展开/收起) */
const FIELDS_PER_ROW = 4
const searchExpanded = ref(false)
const fieldsCollapsible = computed(() => searchFields.length > FIELDS_PER_ROW)
const firstRowFields = computed(() => searchFields.slice(0, FIELDS_PER_ROW))
const moreRowFields = computed(() => searchFields.slice(FIELDS_PER_ROW))
const moreCount = computed(() => moreRowFields.value.length)
function toggleSearchExpanded() {
searchExpanded.value = !searchExpanded.value
if (!searchExpanded.value) {
// 收起时清空被隐藏的低频字段,保证「看到的条件 = 生效的条件」
for (const field of moreRowFields.value) searchForm.value[field.key] = null
}
}
const moreItems = [
{ key: 'export', label: '导出数据', icon: Download },
{ key: 'import', label: '导入数据', icon: Upload },
@@ -120,35 +158,94 @@ const moreItems = [
{ key: 'batch-delete', label: '批量删除', icon: Trash2, danger: true },
]
// TODO: 接入真实搜索 / 高级搜索 / 更多操作 / 新增逻辑
// TODO: 接入真实搜索 / 更多操作 / 新增逻辑
function onMoreAction() {}
function onAdvancedSearch() {}
function onAdvancedReset() {
advancedForm.value = { name: '', type: undefined, status: undefined, range: null }
function onSearch() {}
function onReset() {
searchForm.value = {
name: '',
type: undefined,
status: undefined,
range: null,
owner: undefined,
priority: undefined,
source: undefined,
enabled: undefined,
minScore: null,
}
}
</script>
<template>
<div class="dashboard-page">
<section class="demo-card">
<!-- 工具栏:搜索 + 高级搜索 | 更多操作 + 新增 -->
<div class="demo-toolbar">
<div class="toolbar-left">
<!-- 搜索卡(独立白卡):每行固定 4 个字段格;字段更多时低频字段另起整行(展开/收起),
操作区固定末行。「看到的条件 = 生效的条件」,收起时清空隐藏字段 -->
<form class="search-card" @submit.prevent="onSearch">
<div class="search-row">
<div v-for="field in firstRowFields" :key="field.key" class="search-field">
<label class="search-label">{{ field.label }}</label>
<Input
v-model="keyword"
class="toolbar-search"
placeholder="搜索名称 / ID"
v-if="field.type === 'input' || field.type === 'input-number'"
v-model="searchForm[field.key]"
:input-type="field.type === 'input-number' ? 'number' : 'text'"
:placeholder="field.placeholder"
allow-clear
>
<template #prefix><Search :size="14" aria-hidden="true" /></template>
<template v-if="field.submitIcon" #suffix>
<button type="submit" class="search-submit" aria-label="查询">
<Search :size="14" aria-hidden="true" />
</button>
</template>
</Input>
<Button
type="outline"
:class="{ 'toolbar-toggle-active': advancedOpen }"
@click="advancedOpen = !advancedOpen"
>
<SlidersHorizontal :size="14" aria-hidden="true" />
高级搜索
<Select
v-else-if="field.type === 'select'"
v-model="searchForm[field.key]"
:options="field.options"
clearable
/>
<RangePicker v-else-if="field.type === 'range'" v-model="searchForm[field.key]" />
</div>
</div>
<!-- 低频字段行:字段总数超过每行格数时按开关显隐 -->
<div v-if="fieldsCollapsible && searchExpanded" class="search-row">
<div v-for="field in moreRowFields" :key="field.key" class="search-field">
<label class="search-label">{{ field.label }}</label>
<Input
v-if="field.type === 'input' || field.type === 'input-number'"
v-model="searchForm[field.key]"
:input-type="field.type === 'input-number' ? 'number' : 'text'"
:placeholder="field.placeholder"
allow-clear
/>
<Select
v-else-if="field.type === 'select'"
v-model="searchForm[field.key]"
:options="field.options"
clearable
/>
<RangePicker v-else-if="field.type === 'range'" v-model="searchForm[field.key]" />
</div>
</div>
<div class="search-actions">
<Button type="primary" @click="onSearch">查询</Button>
<Button type="secondary" @click="onReset">重置</Button>
<Button v-if="fieldsCollapsible" type="text" @click="toggleSearchExpanded">
{{ searchExpanded ? '收起' : `展开 ${moreCount} 项` }}
<component :is="searchExpanded ? ChevronUp : ChevronDown" :size="14" aria-hidden="true" />
</Button>
</div>
</form>
<!-- 列表卡:操作工具栏 + 表格 + 分页 -->
<section class="demo-card">
<!-- 操作工具栏:主操作靠左,批量/更多操作靠右 -->
<div class="demo-toolbar">
<div class="toolbar-left">
<Button type="primary">
<Plus :size="14" aria-hidden="true" />
新增
</Button>
</div>
@@ -161,38 +258,6 @@ function onAdvancedReset() {
</Button>
</template>
</Dropdown>
<Button type="primary">
<Plus :size="14" aria-hidden="true" />
新增
</Button>
<Button type="primary">
<Plus :size="14" aria-hidden="true" />
Add New
</Button>
</div>
</div>
<!-- 高级搜索面板:label 在上、控件等宽网格(适配多语言标签长度),仅 UI 骨架 -->
<div v-show="advancedOpen" class="demo-advanced">
<div class="advanced-field">
<label class="advanced-label">名称 / ID</label>
<Input v-model="advancedForm.name" placeholder="模糊搜索" allow-clear />
</div>
<div class="advanced-field">
<label class="advanced-label">类型</label>
<Select v-model="advancedForm.type" :options="TYPE_OPTIONS" clearable />
</div>
<div class="advanced-field">
<label class="advanced-label">状态</label>
<Select v-model="advancedForm.status" :options="STATUS_OPTIONS" clearable />
</div>
<div class="advanced-field">
<label class="advanced-label">创建时间</label>
<RangePicker v-model="advancedForm.range" />
</div>
<div class="advanced-actions">
<Button type="primary" @click="onAdvancedSearch">查询</Button>
<Button type="secondary" @click="onAdvancedReset">重置</Button>
</div>
</div>
@@ -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;
@@ -0,0 +1,214 @@
<script setup>
/**
* 多语言类型管理(s_i18n_type)
* 基于 fms-module-list(列表)+ fms-module-edit(弹窗),全部元数据驱动:
* - 查询栏/表格/分页由 FmsModuleListPage 自动处理;
* - 新增/编辑由 FmsModuleEditModal 按 data-code 读取编辑配置自动渲染。
* 页面仅保留业务规则:
* - 默认语言(b_default=1)不可删除、不可取消默认、编码不可修改;
* - 任一行设为默认时,其他行的默认标记自动清除;
* - 表格「启用/默认」列支持行内点击切换并自动保存。
*/
import { computed, ref } from 'vue'
import { Plus } from '@lucide/vue'
import { Button, Message } from '@/components/ui'
import { FmsModuleListPage } from '@/components/fms-module-list'
import { FmsModuleEditModal } from '@/components/fms-module-edit'
import { loadDataApi, saveObjectApi } from '@/services/api'
import { useLanguagesStore } from '@/stores/languages'
/* ================= 组件引用 / 状态 ================= */
const listRef = ref(null)
const saving = ref(false)
/** 语言配置 store:页面增删改后 refresh,让菜单/模块等多语言消费端同步 */
const langStore = useLanguagesStore()
/* ================= 行多选(默认语言不可勾选) ================= */
const selectedRowKeys = ref([])
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
getCheckboxProps: (row) => ({ disabled: row.b_default === 1 }),
onChange: (keys) => {
selectedRowKeys.value = keys
},
}))
/* ================= 行操作:编辑 / 删除 ================= */
const rowActions = [
{
key: 'edit',
label: '编辑',
disabled: () => saving.value,
onClick: openEdit,
},
{
key: 'delete',
type: 'delete',
label: '删除',
confirm: { title: '删除语言', content: '确定删除该语言吗?' },
disabled: ({ record }) => saving.value || record.b_default === 1,
onClick: removeRow,
},
]
/* ================= 列扩展:checkbox 字段直接交互切换 ================= */
// columnExtensions 按字段声明单元格行为;checkbox 字段提供 onChange 后,
// FmsModuleTable 渲染可交互勾选框(不再需要 cell-* 插槽)。
const columnExtensions = {
b_canuse: {
onChange: (row, value) => saveToggle(row, 'b_canuse', value),
disabled: () => saving.value,
},
b_default: {
onChange: (row, value) => saveToggle(row, 'b_default', value),
// 默认语言不可取消默认:先设其他语言为默认,再改此项
disabled: (row) => saving.value || row.b_default === 1,
},
}
/* ================= 表格内联切换(启用/默认)自动保存 ================= */
// 默认语言保护:设为默认时需清除其他默认行(查库而非依赖当前页数据,兼容分页)。
async function saveToggle(row, field, value) {
if (saving.value) return
saving.value = true
try {
const updates = [{ b_id: row.b_id, [field]: value }]
if (field === 'b_default' && value === 1) {
const res = await loadDataApi('s_i18n_type', 'b_default = 1', 'b_id ASC')
for (const other of res.data || []) {
if (String(other.b_id) !== String(row.b_id)) {
updates.push({ b_id: other.b_id, b_default: 0 })
}
}
}
await saveObjectApi([{ table: 's_i18n_type', key_field: 'b_id', updates }])
Message.success('保存成功')
langStore.refresh()
await listRef.value?.reload()
} catch (e) {
Message.error('保存失败:' + (e?.message || e))
} finally {
saving.value = false
}
}
/* ================= 新增 / 编辑弹窗(FmsModuleEditModal 元数据驱动) ================= */
const modalOpen = ref(false)
const editingRow = ref(null) // null = 新增
function openCreate() {
editingRow.value = null
modalOpen.value = true
}
function openEdit(row) {
editingRow.value = row
modalOpen.value = true
}
/** 保存前钩子:设为默认语言时清除其他默认行(追加 updates,与 insert/update 同事务) */
async function beforeModalSave(rowData, ctx) {
if (Number(rowData.b_default) !== 1) return true
const res = await loadDataApi('s_i18n_type', 'b_default = 1', 'b_id ASC')
const extraUpdates = []
for (const other of res.data || []) {
if (String(other.b_id) !== String(rowData.b_id)) {
extraUpdates.push({ b_id: other.b_id, b_default: 0 })
}
}
return { extraUpdates }
}
/** 额外禁用规则:默认语言(编辑态)的编码与「默认」开关不可改 */
function modalFieldDisabled(fieldName, { isNew, record }) {
if (isNew) return false
if (Number(record?.b_default) !== 1) return false
return fieldName === 'b_code' || fieldName === 'b_default'
}
function onModalSaved() {
selectedRowKeys.value = []
langStore.refresh()
void listRef.value?.reload()
}
/* ================= 删除单行 ================= */
async function removeRow(row) {
if (saving.value || row.b_default === 1) return
saving.value = true
try {
await saveObjectApi([
{ table: 's_i18n_type', key_field: 'b_id', deletes: [{ b_id: row.b_id }] },
])
Message.success('删除成功')
langStore.refresh()
await listRef.value?.reload()
} catch (e) {
Message.error('删除失败:' + (e?.message || e))
} finally {
saving.value = false
}
}
/* ================= 批量删除(走组件方法) ================= */
async function batchDelete() {
const keys = selectedRowKeys.value
if (!keys.length) return
try {
await listRef.value?.deleteSelected(keys)
selectedRowKeys.value = []
langStore.refresh()
} catch (e) {
Message.error('删除失败:' + (e?.message || e))
}
}
</script>
<template>
<div class="i18n-type-page">
<FmsModuleListPage
ref="listRef"
data-code="s_i18n_type"
:row-selection="rowSelection"
:row-actions="rowActions"
:column-extensions="columnExtensions"
:inline-action-limit="2"
:row-draggable="true"
order-field="b_xh"
>
<template #actions="{ loading }">
<Button type="primary" :disabled="loading || saving" @click="openCreate">
<Plus :size="14" /> 新增
</Button>
<Button
type="danger"
:disabled="loading || saving || !selectedRowKeys.length"
@click="batchDelete"
>
批量删除{{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : '' }}
</Button>
</template>
</FmsModuleListPage>
<!-- 通用元数据驱动新增/编辑弹窗 -->
<!-- order-field:新增且序号未填时,弹窗自动取库中 MAX(b_xh)+10(页面如需覆盖可在 before-save 中改写) -->
<FmsModuleEditModal
v-model:open="modalOpen"
data-code="s_i18n_type"
:record="editingRow"
:before-save="beforeModalSave"
:field-disabled="modalFieldDisabled"
order-field="b_xh"
@saved="onModalSaved"
/>
</div>
</template>
<style scoped>
.i18n-type-page {
width: 100%;
height: 100%;
overflow: hidden;
}
</style>
@@ -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 || '(未命名)' }}
</span>
<span class="mm-tree-node__tag" v-if="node.b_menu_type !== 'directory'">
{{ menuTypeLabel[node.b_menu_type] || '' }}
</span>
<Checkbox
class="mm-tree-node__enable"
:model-value="Number(node.b_canuse) === 1"
@@ -1219,9 +1230,9 @@ loadPowerLibrary()
isPage || isExternal
? [{ required: true, message: '请填写路由地址' }]
: [],
b_module_id: isPage
? [{ required: true, message: '请选择绑定模块' }]
: [],
// 绑定模块非必填:页面菜单可不绑定业务模块(仅用于路由 + 数据来源),
// 留空时保存为空,后续需要再补选。
b_module_id: [],
}"
layout="vertical"
:gap="20"
@@ -1257,9 +1268,6 @@ loadPowerLibrary()
@update:model-value="onMenuTypeChange"
/>
</FormItem>
<FormItem label="上级菜单" name="b_parent_id">
<Input :model-value="parentMenuName || '根节点(顶级菜单)'" readonly />
</FormItem>
<!-- page/external: 路由地址 -->
<FormItem v-if="isPage" label="路由地址" name="b_route" required>
<Input
@@ -1273,11 +1281,12 @@ loadPowerLibrary()
@update:model-value="(v) => setField('b_route', v)"
/>
</FormItem>
<!-- page: 绑定模块 -->
<FormItem v-if="isPage" label="绑定模块" name="b_module_id" required>
<!-- page: 绑定模块(非必填,可不绑定业务模块) -->
<FormItem v-if="isPage" label="绑定模块" name="b_module_id">
<Select
:model-value="drafts.menu.b_module_id"
:options="moduleOptions"
clearable
@update:model-value="(v) => setField('b_module_id', v)"
/>
</FormItem>
@@ -1426,7 +1435,7 @@ loadPowerLibrary()
:open="i18nModalVisible"
:languages="I18N_LANGUAGES"
:model-value="i18nModalModel"
hint="简体中文即「菜单名称」;其余语言保存到多语言表(s_i18n,键 menu.编码)。"
:hint="`${defaultLocaleName}即「菜单名称」;其余语言保存到多语言表(s_i18n,键 menu.编码)。`"
@update:model-value="onI18nModalChange"
@update:open="(v) => (i18nModalVisible = v)"
/>
@@ -1579,16 +1588,6 @@ loadPowerLibrary()
}
}
.mm-tree-node__tag {
flex-shrink: 0;
padding: 0 6px;
border-radius: 3px;
background: var(--fms-secondary);
color: var(--fms-text-secondary);
font-size: 11px;
line-height: 18px;
}
/* 树节点启用勾选框:靠右、不随文字缩放 */
.mm-tree-node__enable {
flex-shrink: 0;
@@ -66,6 +66,7 @@ import ModuleEditPanel from './ModuleEditPanel.vue'
import ModuleQueryConfigPanel from './ModuleQueryConfigPanel.vue'
import ModuleAutoCodePanel from './ModuleAutoCodePanel.vue'
import ModuleI18nPanel from './ModuleI18nPanel.vue'
import { useLanguagesStore } from '@/stores/languages'
/* ================= 共享状态(index.vue 组装后传入各 composable) ================= */
const moduleTreeData = ref([])
@@ -90,6 +91,18 @@ const rawI18nRows = ref([])
// 新建模块时跳过 watch(moduleSelectedId) 的自动加载;非响应式 holder,跨 composable 共享
const skipModuleLoad = { value: false }
/* ================= 多语言配置(s_i18n_type 驱动) ================= */
// ModuleI18nPanel 的语言列表:由 languages store 提供,不再写死 zh-CN/en-US
const langStore = useLanguagesStore()
/** 语言列表({ code, name, default },供 ModuleI18nPanel) */
const i18nLangOptions = computed(() =>
langStore.languages.map((l) => ({
code: l.b_code,
name: l.b_name,
default: Number(l.b_default) === 1,
})),
)
/* ================= 派生状态(index.vue 组装) ================= */
const selectedModule = computed(() =>
moduleSelectedId.value ? findTreeNode(moduleTreeData.value, moduleSelectedId.value) : null,
@@ -1186,6 +1199,7 @@ loadModuleTree()
:fields="drafts?.fields"
:groups="drafts?.groups"
:raw-rows="rawI18nRows"
:languages="i18nLangOptions"
:loading="busy"
/>
</div>
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup>
import { ref } from 'vue'
import ModuleTableEditPanel from '@/views/system/module-management/TableEditPanel.vue'
import ModuleTableEditPanel from '@/views/module/module-management/TableEditPanel.vue'
const fields = [
{ b_id: '1', b_name: '姓名', b_field: 'name', b_type: 'input' },