20260903173038
This commit is contained in:
1 parent
c8940f6e03
commit
0faf44cffc
55 files changed
+3278
-208
No files matched your search
@@ -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>
|
||||
+56
-57
@@ -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;
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+14
@@ -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,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' },
|
||||
|
||||
Reference in new issue
Block a user