Compare commits

..
12 Commits
Author SHA1 Message Date
wc24 546d95c926 fix(filter): 单独清空按钮改禁用态切换,常驻不显隐(避免布局闪动) 2026-06-30 17:34:59 +08:00
wc24 6b0a6118e0 feat(filter): 筛选抽屉每个字段可单独清空
每行加 CircleClose 清空按钮(有值才出现),只摘掉该 key、其余条件不动。
比依赖各字段组件自身 clearable 更通用(range/relation/必填字段筛选时也能清)。
2026-06-30 17:30:16 +08:00
wc24 09ad43007a feat(tab): 类型可清空(clearable=!required)+ 空状态
FieldTab: 类型 select 非必填可清空 → type=null、整个 tab 置空、分支消失。去掉 innerValue/
initValue/onMounted 那套强制默认(空状态由 isEmpty 配 required 决定)。配合后端递归 isEmpty。
2026-06-30 16:52:09 +08:00
wc24 e789f8a1f7 feat(filter): filterableFields 递归收集嵌套 filterable,key=点路径
ShrimpTable 把 filterableFields 从'顶层 filter'改成'递归收集 object/tab 嵌套叶子',
生成 key=点路径的合成字段。抽屉/buildFilter/URL 同步全按 field.key 走即可,无需改。
版本与 shrimp-server 对齐 0.7.5。
2026-06-30 16:01:23 +08:00
wc24 d906603ce3 fix(add): array/object 字段新增初值用 []/{}(原落成 '')
handleAdd 里 array/object 类型字段未特判,初值落到 '';虽 FieldArray/FieldObject 能容错,
但语义错。补 array→[]、object→{}。版本与 shrimp-server 对齐 0.7.1。
2026-06-30 14:06:42 +08:00
wc24 c49663e16e feat!: object/array 字段组件 + 去掉 FieldJson + 版本统一 0.7.0
- 新增 FieldObject.vue(嵌套字段组,display/edit 递归渲染)、FieldArray.vue
  (元素列表,增删 + 每项递归渲染 item;第一版从简:无排序拖拽)。靠 glob 自动注册成 object/array。
- 删 FieldJson.vue(json 类型退场,object 取代)。
- 版本与 shrimp-server 统一为 0.7.0。
2026-06-29 22:25:59 +08:00
wc24 c44f2a159e feat(export): 前端生成 xlsx 前把对象/数组单元格 JSON 序列化
后端 export 改为只发原始对象后,前端在 json_to_sheet 前通用地 stringify 非标量单元格
(覆盖 tab 等),避免 [object Object];import 时由后端 reqFilter 还原。
2026-06-29 21:47:10 +08:00
wc24 35abfd831c feat(tab): 显示搬前端,递归渲染子字段组件(替代后端 _label 串)
FieldTab.display 不再吃 displayLabel 字符串,改为渲染选中分支的子字段(各自 display 组件):
relation 子→查 refs 出可点名、number→数字、嵌套 tab→再递归。配 shrimp-server v0.6.9
的 refs 下钻,嵌套关联名自动有。ShrimpTable displayLabel 绑定去掉 tab。
2026-06-29 21:30:48 +08:00
wc24 3b0887b126 fix(edit): 清空字段能真正提交(undefined→null,避免 JSON 丢键)
清空 relation/select 时 el-* 常 emit undefined,进 submitData 后 JSON.stringify 丢键 →
后端收不到、看似'提交无效'。修:
- handleSave 编辑 diff 把值归一成 null(??null),清空 → null → 提交 → 后端置空
- FieldRelation 清空归一成 null(单)/[](多),避免 ObjectId 存 '' cast 报错
2026-06-29 21:17:38 +08:00
wc24 bba08dadcc feat(fields): 可选字段(非 required)编辑态可清空,统一 :clearable=!required
之前 Select/Relation 编辑态根本没 clearable(可选也删不掉),String/Textarea/Date 又恒清。
现统一 String/Textarea/Select/Relation/Date 的 edit 控件 clearable=!required:可选给清空、必填不给。
Relation 清空归一成 null(el-select 单选清空发 '',ObjectId 存 '' 会 cast 报错)。
已知例外:Number 用 el-input-number 无 clearable 属性,只能手动清空输入框、无按钮。
2026-06-29 21:11:58 +08:00
wc24 701ed957ef feat(role): 跳转带的 ?filter={_id} 在角色列表里自动选中那条
role 是自定义 master-detail 页(非 ShrimpTable、不做服务端过滤),原 onMounted 默认选第一个,
无视 relation 跳转带的 _id。现全量加载后按 URL filter._id 客户端选中目标角色,解析不出走默认。
2026-06-29 21:00:07 +08:00
wc24 0b75f8c6f2 fix(filter): 用户搜索/改筛选时清掉 relation 跳转的隐形 _id pin
跳转用 ?filter={_id} 锁单条,但 _id 不在筛选抽屉里、且抽屉 confirm 会把它原样带回,
导致用户再搜索/筛选时被看不见的 _id 一直 AND 着、结果莫名为空。
onSearch + handleFilterConfirm 现在都剥掉 _id(重置本就清空)。
2026-06-29 20:54:19 +08:00
13 changed files with 344 additions and 122 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "shrimp-admin", "name": "shrimp-admin",
"version": "0.3.4", "version": "0.7.9",
"private": true, "private": true,
"description": "Shrimp 框架前端 — 消费 ShrimpServer + ShrimpTable 契约的经典 Vue3 + Element Plus 实现", "description": "Shrimp 框架前端 — 消费 ShrimpServer + ShrimpTable 契约的经典 Vue3 + Element Plus 实现",
"main": "src/index.ts", "main": "src/index.ts",
+47 -5
View File
@@ -4,11 +4,18 @@
<el-form :model="localFilterData"> <el-form :model="localFilterData">
<template v-for="field in filterableFields" :key="field.key"> <template v-for="field in filterableFields" :key="field.key">
<el-form-item :label="vueAdmin.t(field.label)"> <el-form-item :label="vueAdmin.t(field.label)">
<slot :name="`filter-${field.key}`" :field="field" :data="localFilterData"> <div class="filter-field-row">
<component :is="vueAdmin.getFieldComponent(moduleKey, field.type, field.key)" :field="field" <div class="filter-field-control">
:model-value="localFilterData[field.key]" <slot :name="`filter-${field.key}`" :field="field" :data="localFilterData">
@update:model-value="onFieldChange(field.key, $event)" mode="filter" /> <component :is="vueAdmin.getFieldComponent(moduleKey, field.type, field.key)"
</slot> :field="field" :model-value="localFilterData[field.key]"
@update:model-value="onFieldChange(field.key, $event)" mode="filter" />
</slot>
</div>
<!-- 单独清空该字段:常驻、无值时禁用(不 v-if,避免按钮显隐导致布局闪动) -->
<el-button link :icon="CircleClose" class="filter-clear"
:disabled="!hasValue(field.key)" @click="clearField(field.key)" />
</div>
</el-form-item> </el-form-item>
</template> </template>
</el-form> </el-form>
@@ -21,6 +28,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { CircleClose } from '@element-plus/icons-vue'
import { IFieldConfig } from '../IShrimpType' import { IFieldConfig } from '../IShrimpType'
import { vueAdmin } from '../VueAdmin' import { vueAdmin } from '../VueAdmin'
@@ -68,6 +76,21 @@ function onFieldChange(key: string, value: any) {
localFilterData.value = { ...localFilterData.value, [key]: value } localFilterData.value = { ...localFilterData.value, [key]: value }
} }
// 该字段当前是否有筛选值(决定单独清空按钮是否出现);范围 {gte,lte}/数组按内容判空
function hasValue(key: string): boolean {
const v = localFilterData.value[key]
if (v == null || v === '') return false
if (Array.isArray(v)) return v.length > 0
if (typeof v === 'object') return Object.keys(v).length > 0
return true
}
// 单独清空某个字段:只摘掉这一个 key,其余筛选条件不动
function clearField(key: string) {
const { [key]: _, ...rest } = localFilterData.value
localFilterData.value = rest
}
function handleReset() { function handleReset() {
closedByReset.value = true closedByReset.value = true
localFilterData.value = {} localFilterData.value = {}
@@ -101,4 +124,23 @@ function handleConfirm() {
.shrimp-drawer .el-drawer__body .el-form-item { .shrimp-drawer .el-drawer__body .el-form-item {
margin-bottom: 20px; margin-bottom: 20px;
} }
.filter-field-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.filter-field-control {
flex: 1;
min-width: 0;
}
.filter-clear {
flex: none;
padding: 0;
font-size: 16px;
color: var(--el-text-color-secondary);
}
</style> </style>
+46 -7
View File
@@ -29,7 +29,7 @@
<slot :name="`column-${field.key}`" :row="row" :field="field"> <slot :name="`column-${field.key}`" :row="row" :field="field">
<component :is="vueAdmin.getFieldComponent(moduleKey, field.type, field.key)" :field="field" <component :is="vueAdmin.getFieldComponent(moduleKey, field.type, field.key)" :field="field"
:model-value="row[field.key]" mode="display" :model-value="row[field.key]" mode="display"
v-bind="field.type === 'textarea' || field.type === 'tab' ? { displayLabel: row[field.key + '_label'] } : {}" /> v-bind="field.type === 'textarea' ? { displayLabel: row[field.key + '_label'] } : {}" />
</slot> </slot>
</template> </template>
</el-table-column> </el-table-column>
@@ -132,8 +132,18 @@ const {
const searchVisible = ref(false) const searchVisible = ref(false)
// relation 跳转用 ?filter={_id} 锁定单条;_id 是隐形 pin(不在筛选抽屉里),
// 用户一旦搜索/改筛选就清掉它,避免被看不见的 _id 一直 AND 着导致结果莫名为空
function stripJumpPin() {
if ('_id' in filterData.value) {
const { _id, ...rest } = filterData.value
filterData.value = rest
}
}
function onSearch() { function onSearch() {
searchKeyword.value = keyword.value searchKeyword.value = keyword.value
stripJumpPin()
currentPage.value = 1 currentPage.value = 1
refresh() refresh()
} }
@@ -151,7 +161,8 @@ const hasFilter = computed(() => {
}) })
async function handleFilterConfirm(newFilterData: Record<string, any>) { async function handleFilterConfirm(newFilterData: Record<string, any>) {
filterData.value = newFilterData const { _id, ...rest } = newFilterData // 确认筛选 → 丢弃跳转 pin
filterData.value = rest
currentPage.value = 1 currentPage.value = 1
await refresh() await refresh()
} }
@@ -201,9 +212,24 @@ const formFields = computed(() => {
return list.filter(f => f.editable !== 'readonly') return list.filter(f => f.editable !== 'readonly')
}) })
const searchableFields = computed(() => fields.value.filter(f => f.searchable)) const searchableFields = computed(() => fields.value.filter(f => f.searchable))
// 递归收集 filterable 字段(含 object/tab 嵌套叶子),生成 key=点路径的"合成字段"。
// 这样筛选抽屉/buildFilter/URL 同步全按 field.key 走即可——key 就是 condition.level 这种路径。
function collectFilterablePaths(list: IFieldConfig[], base: string[], out: any[]) {
for (const f of list) {
const path = [...base, f.key]
if (f.filterable) out.push({ ...f, key: path.join('.') })
if (f.type === 'tab' && Array.isArray((f as any).tabs)) {
for (const t of (f as any).tabs) collectFilterablePaths(t.fields, path, out)
} else if (f.type === 'object' && Array.isArray((f as any).fields)) {
collectFilterablePaths((f as any).fields, path, out)
}
}
}
const filterableFields = computed(() => { const filterableFields = computed(() => {
const lockedKeys = new Set(Object.keys(props.baseFilter || {})) const lockedKeys = new Set(Object.keys(props.baseFilter || {}))
return fields.value.filter(f => f.filterable && !lockedKeys.has(f.key)) const out: any[] = []
collectFilterablePaths(fields.value, [], out)
return out.filter(f => !lockedKeys.has(f.key))
}) })
const formSlotNames = computed(() => Object.keys(slots).filter(name => name.startsWith('form-'))) const formSlotNames = computed(() => Object.keys(slots).filter(name => name.startsWith('form-')))
@@ -296,8 +322,10 @@ async function handleAdd() {
formData.value[field.key] = false formData.value[field.key] = false
} else if (field.type === 'number') { } else if (field.type === 'number') {
formData.value[field.key] = 0 formData.value[field.key] = 0
} else if (field.multiple) { } else if (field.type === 'array' || field.multiple) {
formData.value[field.key] = [] formData.value[field.key] = []
} else if (field.type === 'object') {
formData.value[field.key] = {}
} else { } else {
formData.value[field.key] = '' formData.value[field.key] = ''
} }
@@ -323,8 +351,10 @@ async function handleSave() {
formFields.value.forEach(field => { formFields.value.forEach(field => {
if (field.editable === 'readonly') return if (field.editable === 'readonly') return
if (field.editable === 'addOnly') return if (field.editable === 'addOnly') return
const newValue = formData.value[field.key] // 清空字段值为 undefined 时,JSON.stringify 会丢键 → 后端收不到、看似"提交无效"。
const oldValue = originalData.value[field.key] // 归一成 null:既保证清空能提交(后端置空),又让"两边都空"不误判为有改动。
const newValue = formData.value[field.key] ?? null
const oldValue = originalData.value[field.key] ?? null
if (newValue === oldValue) return if (newValue === oldValue) return
submitData[field.key] = newValue submitData[field.key] = newValue
}) })
@@ -429,7 +459,16 @@ async function handleExport() {
} }
if (ownerId.value) query.ownerId = ownerId.value if (ownerId.value) query.ownerId = ownerId.value
const list = await api.export(query) const list = await api.export(query)
const ws = XLSX.utils.json_to_sheet(list) // 后端只发原始对象;xlsx 单元格放不下对象/数组(会变 [object Object]),这里通用地 JSON 序列化
// (覆盖 tab 等结构化字段)。import 时这些字符串由后端各类型 reqFilter 还原。
const rows = list.map((row: Record<string, any>) => {
const out: Record<string, any> = {}
for (const [k, v] of Object.entries(row)) {
out[k] = (v !== null && typeof v === 'object') ? JSON.stringify(v) : v
}
return out
})
const ws = XLSX.utils.json_to_sheet(rows)
const wb = XLSX.utils.book_new() const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, props.moduleKey) XLSX.utils.book_append_sheet(wb, ws, props.moduleKey)
XLSX.writeFile(wb, `${props.moduleKey}_export_${Date.now()}.xlsx`) XLSX.writeFile(wb, `${props.moduleKey}_export_${Date.now()}.xlsx`)
+107
View File
@@ -0,0 +1,107 @@
<template>
<!-- 显示:逐元素递归渲染 item 的 display 组件 -->
<template v-if="mode === 'display'">
<span v-if="!items.length">-</span>
<span v-else class="arr-display">
<span v-for="(el, i) in items" :key="i" class="arr-chip">
<component :is="vueAdmin.getFieldComponent(moduleKey, field.item.type, '')"
:field="field.item" :model-value="el" mode="display" />
</span>
</span>
</template>
<!-- 编辑:每项一行(标题 + item 编辑组件 + 删除),底部添加。第一版从简:无排序拖拽 -->
<div v-else class="field-array">
<div v-for="(el, i) in items" :key="i" class="arr-row">
<div class="arr-head">
<span class="arr-idx">{{ itemTitle }} #{{ i + 1 }}</span>
<el-button type="danger" text size="small" @click="removeAt(i)">删除</el-button>
</div>
<component :is="vueAdmin.getFieldComponent(moduleKey, field.item.type, '')"
:field="field.item" :model-value="el"
@update:model-value="setAt(i, $event)" mode="edit" :disabled="disabled" />
</div>
<el-button type="primary" text @click="addOne" :disabled="disabled">+ 添加{{ itemTitle }}</el-button>
</div>
</template>
<script lang="ts" setup>
import { computed, inject } from 'vue'
import { vueAdmin } from '../../VueAdmin'
const props = defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit' | 'filter'
disabled?: boolean
}>()
const emit = defineEmits<{ 'update:modelValue': [value: any] }>()
const moduleKey = inject('shrimpModuleKey', '')
const items = computed<any[]>(() => Array.isArray(props.modelValue) ? props.modelValue : [])
const itemTitle = computed(() => vueAdmin.t(props.field.item?.label) || '项')
function setAt(i: number, value: any) {
const arr = items.value.slice()
arr[i] = value
emit('update:modelValue', arr)
}
function removeAt(i: number) {
const arr = items.value.slice()
arr.splice(i, 1)
emit('update:modelValue', arr)
}
function addOne() {
emit('update:modelValue', [...items.value, defaultOf(props.field.item)])
}
/** 按 item schema 递归生成一个默认元素 */
function defaultOf(schema: any): any {
if (!schema) return null
if (schema.defaultValue !== undefined) return schema.defaultValue
if (schema.type === 'object') {
const obj: any = {}
for (const f of schema.fields || []) obj[f.key] = defaultOf(f)
return obj
}
if (schema.type === 'array') return []
if (schema.type === 'number') return 0
if (schema.type === 'boolean') return false
return null
}
</script>
<style scoped>
.arr-display {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 12px;
}
.field-array {
display: flex;
flex-direction: column;
gap: 8px;
}
.arr-row {
padding: 8px 10px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
}
.arr-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.arr-idx {
color: var(--el-text-color-secondary);
font-size: 12px;
font-weight: 600;
}
</style>
+1 -1
View File
@@ -42,7 +42,7 @@
style="width: 100%" style="width: 100%"
/> />
<el-date-picker v-else :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" :disabled="disabled" <el-date-picker v-else :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" :disabled="disabled"
type="datetime" style="width: 100%" /> type="datetime" :clearable="!field.required" style="width: 100%" />
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
-48
View File
@@ -1,48 +0,0 @@
<template>
<template v-if="mode === 'display'">
<el-tooltip v-if="modelValue != null" :content="displayValue" placement="top">
<span class="json-display">{{ displayValue }}</span>
</el-tooltip>
<span v-else>-</span>
</template>
<el-input
v-else
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
type="textarea"
:rows="4"
:disabled="disabled"
placeholder="请输入 JSON"
/>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
const props = defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit' | 'filter'
disabled?: boolean
}>()
defineEmits<{
'update:modelValue': [value: any]
}>()
const displayValue = computed(() => {
if (props.modelValue == null) return ''
if (typeof props.modelValue === 'string') return props.modelValue
return JSON.stringify(props.modelValue, null, 2)
})
</script>
<style scoped>
.json-display {
display: inline-block;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+69
View File
@@ -0,0 +1,69 @@
<template>
<!-- 显示:递归渲染各子字段的 display 组件(relation 子查 refs) -->
<template v-if="mode === 'display'">
<span v-if="!modelValue || typeof modelValue !== 'object'">-</span>
<span v-else class="obj-display">
<span v-for="f in field.fields || []" :key="f.key" class="obj-child">
<span class="obj-label">{{ vueAdmin.t(f.label) }}:</span>
<component :is="vueAdmin.getFieldComponent(moduleKey, f.type, f.key)"
:field="f" :model-value="(modelValue || {})[f.key]" mode="display" />
</span>
</span>
</template>
<!-- 编辑:每个子字段一行,递归渲染 edit 组件 -->
<div v-else class="field-object">
<el-form-item v-for="f in field.fields || []" :key="f.key" :label="vueAdmin.t(f.label)" :required="f.required">
<component :is="vueAdmin.getFieldComponent(moduleKey, f.type, f.key)"
:field="f" :model-value="(modelValue || {})[f.key]"
@update:model-value="onChild(f.key, $event)" mode="edit" :disabled="disabled" />
</el-form-item>
</div>
</template>
<script lang="ts" setup>
import { inject } from 'vue'
import { vueAdmin } from '../../VueAdmin'
const props = defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit' | 'filter'
disabled?: boolean
}>()
const emit = defineEmits<{ 'update:modelValue': [value: any] }>()
const moduleKey = inject('shrimpModuleKey', '')
function onChild(key: string, value: any) {
emit('update:modelValue', { ...(props.modelValue || {}), [key]: value })
}
</script>
<style scoped>
.obj-display {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 10px;
}
.obj-child {
display: inline-flex;
align-items: center;
gap: 4px;
}
.obj-label {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.field-object {
display: flex;
flex-direction: column;
gap: 8px;
padding-left: 12px;
border-left: 2px solid var(--el-border-color-lighter);
}
</style>
+8 -2
View File
@@ -18,7 +18,7 @@
> >
<el-option v-for="opt in options" :key="opt.value" :label="opt.label" :value="opt.value" /> <el-option v-for="opt in options" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select> </el-select>
<el-select v-else :model-value="stringValue" @update:model-value="$emit('update:modelValue', $event)" :disabled="disabled" :multiple="field.multiple" filterable remote :remote-method="onSearch" :loading="loading" style="width: 100%"> <el-select v-else :model-value="stringValue" @update:model-value="onEdit" :disabled="disabled" :multiple="field.multiple" :clearable="!field.required" filterable remote :remote-method="onSearch" :loading="loading" style="width: 100%">
<el-option v-for="opt in options" :key="opt.value" :label="opt.label" :value="opt.value" /> <el-option v-for="opt in options" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select> </el-select>
</template> </template>
@@ -35,10 +35,16 @@ const props = defineProps<{
disabled?: boolean disabled?: boolean
}>() }>()
defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: any] 'update:modelValue': [value: any]
}>() }>()
// 编辑态清空:el-select 单选清空发 '' 或 undefined,relation 是 ObjectId、且 undefined 会被
// JSON.stringify 丢键 → 都归一成 null(取消关联)。多选清空是 [],原样。
function onEdit(v: any) {
emit('update:modelValue', v === '' || v == null ? (props.field.multiple ? [] : null) : v)
}
const options = ref<{ label: string; value: string }[]>([]) const options = ref<{ label: string; value: string }[]>([])
const loading = ref(false) const loading = ref(false)
+1
View File
@@ -21,6 +21,7 @@
@update:model-value="$emit('update:modelValue', $event)" @update:model-value="$emit('update:modelValue', $event)"
:disabled="disabled" :disabled="disabled"
:multiple="field.multiple" :multiple="field.multiple"
:clearable="!field.required"
style="width: 100%" style="width: 100%"
> >
<el-option v-for="opt in resolvedOptions" :key="opt.value" :label="vueAdmin.t(opt.label)" :value="opt.value" /> <el-option v-for="opt in resolvedOptions" :key="opt.value" :label="vueAdmin.t(opt.label)" :value="opt.value" />
+1 -1
View File
@@ -9,7 +9,7 @@
</el-tag> </el-tag>
<el-input v-model="inputValue" @keyup.enter="addTag" @blur="addTag" :placeholder="$t('base.pressEnter')" /> <el-input v-model="inputValue" @keyup.enter="addTag" @blur="addTag" :placeholder="$t('base.pressEnter')" />
</div> </div>
<el-input v-else :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" :disabled="disabled" clearable /> <el-input v-else :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" :disabled="disabled" :clearable="!field.required" />
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
+45 -55
View File
@@ -1,17 +1,21 @@
<template> <template>
<template v-if="mode === 'display'"> <template v-if="mode === 'display'">
<!-- 表格列展示后端算好的 stringify 文本(displayLabel=${key}_label);本体仍是对象,供编辑/逻辑用 --> <!-- 显示=前端递归渲染:分支标签 + 选中分支子字段。没选 type(清空) → 显示 - -->
<el-tooltip v-if="displayLabel" :content="String(displayLabel)" placement="top"> <span v-if="!modelValue || typeof modelValue !== 'object' || modelValue.type == null">-</span>
<span class="tab-display">{{ displayLabel }}</span> <span v-else class="tab-display">
</el-tooltip> <span class="tab-branch">{{ branchLabel }}</span>
<span v-else-if="!modelValue || typeof modelValue !== 'object'">-</span> <span v-for="childField in selectedTab?.fields || []" :key="childField.key" class="tab-child">
<el-tooltip v-else :content="JSON.stringify(modelValue, null, 2)" placement="top"> <span class="tab-child-label">{{ vueAdmin.t(childField.label) }}:</span>
<span class="tab-display">{{ displayText }}</span> <component :is="vueAdmin.getFieldComponent(moduleKey, childField.type, childField.key)"
</el-tooltip> :field="childField" :model-value="(modelValue || {})[childField.key]" mode="display" />
</span>
</span>
</template> </template>
<div v-else class="field-tab"> <div v-else class="field-tab">
<el-form-item :label="vueAdmin.t(field.label) + ' - 类型'" :required="field.required"> <el-form-item :label="vueAdmin.t(field.label) + ' - 类型'" :required="field.required">
<el-select :model-value="innerValue.type" @update:model-value="onTypeChange" :disabled="disabled"> <!-- 非必填可清空 → 清空后 type=null、整个 tab 置空、下面分支消失 -->
<el-select :model-value="modelValue?.type ?? undefined" @update:model-value="onTypeChange"
:clearable="!field.required" :disabled="disabled">
<el-option v-for="tab in field.tabs" :key="tab.key" :label="vueAdmin.t(tab.label)" :value="tab.key" /> <el-option v-for="tab in field.tabs" :key="tab.key" :label="vueAdmin.t(tab.label)" :value="tab.key" />
</el-select> </el-select>
</el-form-item> </el-form-item>
@@ -20,7 +24,7 @@
<el-form-item :label="vueAdmin.t(childField.label)" :required="childField.required"> <el-form-item :label="vueAdmin.t(childField.label)" :required="childField.required">
<component :is="vueAdmin.getFieldComponent(moduleKey, childField.type, childField.key)" <component :is="vueAdmin.getFieldComponent(moduleKey, childField.type, childField.key)"
:field="childField" :field="childField"
:model-value="innerValue[childField.key]" :model-value="(modelValue || {})[childField.key]"
@update:model-value="onChildChange(childField.key, $event)" @update:model-value="onChildChange(childField.key, $event)"
mode="edit" mode="edit"
:disabled="disabled" /> :disabled="disabled" />
@@ -31,7 +35,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, inject, onMounted } from 'vue' import { computed, inject } from 'vue'
import { vueAdmin } from '../../VueAdmin' import { vueAdmin } from '../../VueAdmin'
const props = defineProps<{ const props = defineProps<{
@@ -39,7 +43,6 @@ const props = defineProps<{
modelValue: any modelValue: any
mode: 'display' | 'edit' | 'filter' mode: 'display' | 'edit' | 'filter'
disabled?: boolean disabled?: boolean
displayLabel?: string
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -48,39 +51,18 @@ const emit = defineEmits<{
const moduleKey = inject('shrimpModuleKey', '') const moduleKey = inject('shrimpModuleKey', '')
const displayText = computed(() => { const selectedTab = computed(() =>
if (!props.modelValue || typeof props.modelValue !== 'object') return '-' props.field.tabs?.find((t: any) => t.key === props.modelValue?.type)
const type = props.modelValue.type )
const tab = props.field.tabs?.find((t: any) => t.key === type)
return tab ? vueAdmin.t(tab.label) : String(type)
})
const selectedTab = computed(() => { const branchLabel = computed(() => selectedTab.value ? vueAdmin.t(selectedTab.value.label) : '')
const type = props.modelValue?.type
return props.field.tabs?.find((t: any) => t.key === type)
})
const innerValue = computed(() => {
const val = props.modelValue
if (val != null && typeof val === 'object' && Object.keys(val).length > 0) {
return val
}
return initValue()
})
function initValue() {
const firstTab = props.field.tabs?.[0]
if (!firstTab) return {}
const obj: any = { type: firstTab.key }
firstTab.fields?.forEach((f: any) => {
if (f.defaultValue !== undefined) {
obj[f.key] = typeof f.defaultValue === 'function' ? f.defaultValue() : f.defaultValue
}
})
return obj
}
function onTypeChange(type: any) { function onTypeChange(type: any) {
// 清空(非必填) → 整个 tab 置空:type=null、空状态、分支消失
if (type == null || type === '') {
emit('update:modelValue', null)
return
}
const tab = props.field.tabs?.find((t: any) => t.key === type) const tab = props.field.tabs?.find((t: any) => t.key === type)
const obj: any = { type } const obj: any = { type }
tab?.fields?.forEach((f: any) => { tab?.fields?.forEach((f: any) => {
@@ -92,24 +74,32 @@ function onTypeChange(type: any) {
} }
function onChildChange(key: string, value: any) { function onChildChange(key: string, value: any) {
emit('update:modelValue', { ...innerValue.value, [key]: value }) emit('update:modelValue', { ...(props.modelValue || {}), [key]: value })
} }
onMounted(() => {
const val = props.modelValue
if (val == null || val === '' || (typeof val === 'object' && Object.keys(val).length === 0)) {
emit('update:modelValue', initValue())
}
})
</script> </script>
<style scoped> <style scoped>
.tab-display { .tab-display {
display: inline-block; display: inline-flex;
max-width: 200px; flex-wrap: wrap;
overflow: hidden; align-items: center;
text-overflow: ellipsis; gap: 4px 10px;
white-space: nowrap; }
.tab-branch {
font-weight: 600;
color: var(--el-color-primary);
}
.tab-child {
display: inline-flex;
align-items: center;
gap: 4px;
}
.tab-child-label {
color: var(--el-text-color-secondary);
font-size: 12px;
} }
.field-tab { .field-tab {
+1 -1
View File
@@ -19,7 +19,7 @@
:model-value="modelValue" :model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)" @update:model-value="$emit('update:modelValue', $event)"
:disabled="disabled" :disabled="disabled"
clearable :clearable="!field.required"
/> />
</template> </template>
+17 -1
View File
@@ -50,6 +50,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { vueAdmin } from '../VueAdmin' import { vueAdmin } from '../VueAdmin'
import { IRole, IRoleProto, WithId, ILabel } from '../IShrimpType' import { IRole, IRoleProto, WithId, ILabel } from '../IShrimpType'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
@@ -61,12 +62,24 @@ interface IPowerItem {
} }
const { t } = useI18n() const { t } = useI18n()
const route = useRoute()
const props = defineProps<{ const props = defineProps<{
moduleKey: string moduleKey: string
events?: string[] events?: string[]
}>() }>()
// relation 跳转落到 role 页时带 ?filter={"_id":...}。role 是自定义 master-detail 页(非 ShrimpTable、
// 不做服务端过滤),全量加载后按这个 _id 在列表里选中那条;解析不出就走默认(选第一个)。
function jumpTargetId(): string {
try {
const f = route.query.filter ? JSON.parse(String(route.query.filter)) : null
return f && typeof f === 'object' ? String(f._id || '') : ''
} catch {
return ''
}
}
const roleApi = vueAdmin.getModule<IRoleProto>('role') const roleApi = vueAdmin.getModule<IRoleProto>('role')
const roleList = ref<WithId<IRole>[]>([]) const roleList = ref<WithId<IRole>[]>([])
@@ -138,7 +151,10 @@ async function handleDelete() {
onMounted(async () => { onMounted(async () => {
allPowers.value = await roleApi.allPower() allPowers.value = await roleApi.allPower()
await loadRoles() await loadRoles()
if (roleList.value.length > 0) { const jumpId = jumpTargetId()
if (jumpId && roleList.value.some(r => r._id === jumpId)) {
onSelect(jumpId)
} else if (roleList.value.length > 0) {
onSelect(roleList.value[0]._id) onSelect(roleList.value[0]._id)
} }
}) })