feat: shrimp-admin v0.1.0 — 从 hand-admin 抽离的框架前端

Shrimp 框架前端 — 一种消费 ShrimpServer + ShrimpTable 契约的经典 Vue3 +
Element Plus 实现。不是框架本身,只是按 shrimp-server 约定写的一份参考解法。

包含:
- ShrimpClient + VueAdmin(factory:createShrimpAdmin)
- 5 个 Shrimp* 组件 + 10 个内置 Field*.vue + PowerTree + LangSwitch
- 4 个内置模块:user / role / operationLog / FieldRelativeTime
- 完整后台壳 views(layout/login/sideM/topbar/userbar/navMenu/App/404/logo)
- composables/useTableUrlSync, locales(zh-cn/en 框架默认), style 全套

API 改造:
- createShrimpAdmin({ config, moduleSources, customFields, locales }) factory
- vueAdmin 改为 Proxy 单例,createShrimpAdmin 调用前访问会抛错
- import.meta.glob 业务字段/模块由消费侧传入(vite 静态扫描机制限制)
- 框架默认 locales 与消费侧 locales 在 i18n 初始化时合并

源码 ship 不打包,消费侧靠 vite 直接吃 .ts/.vue。
This commit is contained in:
2026-05-23 21:01:33 +08:00
parent b4ee2e4b43
commit 4cb3bb8d75
50 changed files with 6551 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
<template>
<el-dropdown v-if="vueAdmin.langCount > 1" trigger="click" @command="setLang">
<div class="lang-switch">
<el-icon><Lang /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="item in vueAdmin.langList" :key="item.lang" :command="item.lang"
:class="{ 'is-active': item.lang === vueAdmin.locale }">{{
item.name }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script lang="ts" setup>
import { vueAdmin } from "../VueAdmin";
import Lang from "../assets/icons/Lang.vue";
function setLang(lang: string) {
vueAdmin.locale = lang
}
</script>
<style scoped>
.lang-switch {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
width: 32px;
height: 32px;
border-radius: 4px;
color: #999;
}
.lang-switch .el-icon {
font-size: 16px;
}
</style>
<style>
.el-dropdown-menu .is-active {
color: var(--el-color-primary) !important;
font-weight: bold;
}
</style>
+122
View File
@@ -0,0 +1,122 @@
<template>
<div class="power-tree-wrap">
<el-tree ref="treeRef" :data="treeData" show-checkbox node-key="key" default-expand-all
:props="{ label: 'label', children: 'children' }" @check="onCheck" />
</div>
</template>
<script lang="ts" setup>
import { ref, computed, watch, nextTick } from 'vue'
import { vueAdmin } from '../VueAdmin'
import type { IMenuItem, ILabel } from 'shrimp-server/types'
interface IPowerItem {
key: string
label: ILabel
}
const props = defineProps<{
modelValue: string[]
allPowers: IPowerItem[]
menuTree: IMenuItem[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: string[]]
}>()
const treeRef = ref<any>(null)
interface PowerNode { key: string; label: string; children?: PowerNode[] }
const MENU_PREFIX = '_menu:'
const treeData = computed(() => {
function walkMenu(items: IMenuItem[]): PowerNode[] {
return items.reduce<PowerNode[]>((acc, item) => {
if (item.moduleKey) {
const modulePowers = props.allPowers.filter(p => p.key === item.moduleKey)
const children = props.allPowers
.filter(p => p.key.startsWith(item.moduleKey + '.'))
.map(p => ({ key: p.key, label: vueAdmin.t(p.label) }))
// 模块本身(view 权限)
if (modulePowers.length > 0) {
children.unshift({
key: item.moduleKey,
label: vueAdmin.t({ t: "power.view" }),
})
}
const node: PowerNode = {
key: MENU_PREFIX + item.moduleKey,
label: vueAdmin.t(item.label),
children: children.length ? children : undefined,
}
acc.push(node)
} else if (item.children?.length) {
const children = walkMenu(item.children)
if (children.length) {
acc.push({
key: MENU_PREFIX + (item.name || ''),
label: vueAdmin.t(item.label),
children,
})
}
}
return acc
}, [])
}
return walkMenu(props.menuTree)
})
function onCheck(data: any, state: any) {
const allKeys: string[] = state.checkedKeys
let keys = allKeys.filter((k: string) => !k.startsWith(MENU_PREFIX))
const toggledKey: string = data.key
// 菜单分组节点的子节点已由 el-tree 自动处理,直接返回
if (toggledKey.startsWith(MENU_PREFIX)) {
emit('update:modelValue', keys)
return
}
const checked = new Set(keys)
const isChecked = checked.has(toggledKey)
if (isChecked) {
// 勾选子权限时,自动勾选所有祖先
let ancestor = toggledKey
while (ancestor.includes('.')) {
ancestor = ancestor.substring(0, ancestor.lastIndexOf('.'))
checked.add(ancestor)
}
} else {
// 取消父权限时,自动取消所有后代
const prefix = toggledKey + '.'
keys = keys.filter(key => !key.startsWith(prefix))
emit('update:modelValue', keys)
return
}
emit('update:modelValue', [...checked])
}
watch(() => [props.modelValue, props.allPowers], () => {
nextTick(() => treeRef.value?.setCheckedKeys(props.modelValue || []))
}, { immediate: true })
</script>
<style scoped>
.power-tree-wrap {
border: 1px solid var(--el-border-color);
border-radius: 4px;
background: var(--el-bg-color);
padding: 8px 12px;
width: 100%;
}
.power-tree-wrap :deep(.el-tree) {
background: transparent;
}
</style>
+126
View File
@@ -0,0 +1,126 @@
<template>
<el-drawer class="shrimp-drawer" :model-value="visible" @update:model-value="$emit('update:visible', $event)" :title="title" size="400px">
<el-form ref="formRef" :model="formData" :rules="formRules" @submit.prevent>
<template v-for="field in fields" :key="field.key">
<el-form-item :label="vueAdmin.t(field.label)" :prop="field.key" :required="field.required">
<slot :name="`form-${field.key}`" :field="field" :data="formData" :isEdit="isEdit">
<component :is="vueAdmin.getFieldComponent(moduleKey, field.type, field.key)" :field="field"
:model-value="formData[field.key]"
@update:model-value="onFieldChange(field.key, $event)"
mode="edit"
:disabled="isEdit && (field.editable === 'readonly' || field.editable === 'addOnly')" />
</slot>
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="$emit('update:visible', false)">{{ $t('base.cancel') }}</el-button>
<el-button type="primary" :disabled="!canSave" @click="$emit('save')">{{ $t('base.confirm') }}</el-button>
</template>
</el-drawer>
</template>
<script lang="ts" setup>
import { provide, computed, ref } from 'vue'
import { IFieldConfig, IFieldRule } from 'shrimp-server/types'
import { vueAdmin } from '../VueAdmin'
import type { FormInstance, FormItemRule } from 'element-plus'
interface Props {
visible: boolean
title: string
moduleKey: string
fields: IFieldConfig[]
formData: Record<string, any>
isEdit: boolean
canSave?: boolean
}
const props = defineProps<Props>()
provide('shrimpModuleKey', props.moduleKey)
const emit = defineEmits<{
'update:visible': [value: boolean]
'update:formData': [value: Record<string, any>]
save: []
}>()
const formRef = ref<FormInstance>()
function buildRules(field: IFieldConfig): FormItemRule[] {
const rules: FormItemRule[] = []
if (field.required) {
rules.push({
required: true,
message: vueAdmin.t('shrimp.fieldRequired'),
trigger: 'change',
...(field.type === 'string' || field.type === 'textarea' || field.type === 'password' ? { trigger: 'blur' } : {}),
})
}
if (field.rules) {
for (const r of field.rules) {
const rule: FormItemRule = { trigger: 'change' }
if (r.min !== undefined) rule.min = r.min
if (r.max !== undefined) rule.max = r.max
if (r.pattern) rule.pattern = new RegExp(r.pattern)
if (r.message) rule.message = vueAdmin.t(r.message)
rules.push(rule)
}
}
return rules
}
const formRules = computed(() => {
const rules: Record<string, FormItemRule[]> = {}
for (const field of props.fields) {
const list = buildRules(field)
if (list.length > 0) {
rules[field.key] = list
}
}
return rules
})
function onFieldChange(key: string, value: any) {
emit('update:formData', { ...props.formData, [key]: value })
}
async function validate(): Promise<boolean> {
if (!formRef.value) return true
try {
await formRef.value.validate()
return true
} catch {
return false
}
}
defineExpose({ validate })
</script>
<style>
.shrimp-drawer .el-drawer__header {
margin-bottom: 0;
padding: 16px 20px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.shrimp-drawer .el-drawer__body {
padding: 20px;
}
.shrimp-drawer .el-drawer__footer {
padding: 12px 20px;
border-top: 1px solid var(--el-border-color-lighter);
display: flex;
justify-content: flex-end;
gap: 10px;
}
.shrimp-drawer .el-drawer__body .el-form-item {
margin-bottom: 20px;
}
</style>
+104
View File
@@ -0,0 +1,104 @@
<template>
<el-drawer class="shrimp-drawer" :model-value="visible" @update:model-value="$emit('update:visible', $event)"
:title="title" size="400px">
<el-form :model="localFilterData">
<template v-for="field in filterableFields" :key="field.key">
<el-form-item :label="vueAdmin.t(field.label)">
<slot :name="`filter-${field.key}`" :field="field" :data="localFilterData">
<component :is="vueAdmin.getFieldComponent(moduleKey, field.type, field.key)" :field="field"
:model-value="localFilterData[field.key]"
@update:model-value="onFieldChange(field.key, $event)" mode="filter" />
</slot>
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="handleReset">{{ $t('base.reset') }}</el-button>
<el-button type="primary" @click="handleConfirm">{{ $t('base.confirm') }}</el-button>
</template>
</el-drawer>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import { IFieldConfig } from 'shrimp-server/types'
import { vueAdmin } from '../VueAdmin'
interface Props {
visible: boolean
title: string
moduleKey: string
filterableFields: IFieldConfig[]
filterData: Record<string, any>
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:visible': [value: boolean]
confirm: [value: Record<string, any>]
reset: []
}>()
const localFilterData = ref<Record<string, any>>({})
const closedByReset = ref(false)
function hasChanged(): boolean {
return JSON.stringify(localFilterData.value) !== JSON.stringify(props.filterData)
}
watch(() => props.visible, (val, oldVal) => {
if (oldVal === false && val === true) {
closedByReset.value = false
localFilterData.value = { ...props.filterData }
}
if (oldVal === true && val === false) {
if (closedByReset.value) {
closedByReset.value = false
emit('reset')
return
}
if (hasChanged()) {
emit('confirm', { ...localFilterData.value })
}
}
})
function onFieldChange(key: string, value: any) {
localFilterData.value = { ...localFilterData.value, [key]: value }
}
function handleReset() {
closedByReset.value = true
localFilterData.value = {}
emit('update:visible', false)
}
function handleConfirm() {
emit('update:visible', false)
}
</script>
<style>
.shrimp-drawer .el-drawer__header {
margin-bottom: 0;
padding: 16px 20px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.shrimp-drawer .el-drawer__body {
padding: 20px;
}
.shrimp-drawer .el-drawer__footer {
padding: 12px 20px;
border-top: 1px solid var(--el-border-color-lighter);
display: flex;
justify-content: flex-end;
gap: 10px;
}
.shrimp-drawer .el-drawer__body .el-form-item {
margin-bottom: 20px;
}
</style>
+22
View File
@@ -0,0 +1,22 @@
<template>
<el-icon :size="18" style="cursor:pointer"
:style="{ color: active ? 'var(--el-color-primary)' : '' }" @click="$emit('click')">
<Filter />
</el-icon>
</template>
<script lang="ts" setup>
import { Filter } from '@element-plus/icons-vue'
interface Props {
active?: boolean
}
withDefaults(defineProps<Props>(), {
active: false
})
defineEmits<{
click: []
}>()
</script>
+88
View File
@@ -0,0 +1,88 @@
<template>
<div class="header-search" :class="{ show: visible }">
<el-icon :size="18" @click.stop="toggleSearch"
:style="{ color: active ? 'var(--el-color-primary)' : '' }"
><el-icon-search /></el-icon>
<el-input ref="searchInput" :model-value="keyword" class="header-search-input" clearable
@update:model-value="$emit('update:keyword', $event)"
@keyup.enter="onSearch" @blur="onSearchBlur" @clear="onSearch" />
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
interface Props {
keyword: string
visible: boolean
active?: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:keyword': [value: string]
'update:visible': [value: boolean]
search: []
}>()
const searchInput = ref()
function toggleSearch() {
const next = !props.visible
emit('update:visible', next)
if (next) {
setTimeout(() => searchInput.value?.focus(), 200)
}
}
function onSearchBlur() {
if (!props.keyword) {
emit('update:visible', false)
}
}
function onSearch() {
emit('search')
}
</script>
<style scoped>
.header-search {
font-size: 0 !important;
display: flex;
align-items: center;
}
.header-search .el-icon {
cursor: pointer;
font-size: 18px;
vertical-align: middle;
}
.header-search-input {
font-size: 14px;
transition: width 0.2s;
width: 0;
overflow: hidden;
background: transparent;
border-radius: 0;
display: inline-block;
vertical-align: middle;
}
.header-search-input :deep(.el-input__inner) {
border-radius: 0;
border: 0;
padding-left: 0;
padding-right: 0;
box-shadow: none !important;
border-bottom: 1px solid var(--el-border-color);
vertical-align: middle;
}
.header-search.show .header-search-input {
width: 200px;
margin-left: 10px;
}
</style>
+583
View File
@@ -0,0 +1,583 @@
<template>
<el-container class="shrimp-table" direction="vertical">
<!-- 顶部区域:左侧操作按钮 + 右侧搜索 -->
<div class="aside" v-if="hasActions || searchableFields.length > 0 || filterableFields.length > 0">
<div class="aside-left">
<el-button v-if="can('add')" type="primary" :icon="Plus" circle size="large" @click="handleAdd" />
<el-button v-if="can('clear')" :icon="DeleteIcon" circle size="large" @click="handleClear" />
<el-button v-if="can('export')" :icon="Download" circle size="large" @click="handleExport" />
<el-button v-if="can('import')" :icon="Upload" circle size="large" @click="handleImportClick" />
<input ref="fileInput" type="file" accept=".xlsx,.xls,.json" style="display: none" @change="handleFileChange" />
<slot name="actions"></slot>
</div>
<div class="aside-right">
<ShrimpSearchInput v-if="searchableFields.length > 0" v-model:keyword="keyword"
v-model:visible="searchVisible" :active="!!searchKeyword" @search="onSearch" />
<ShrimpFilterTrigger v-if="filterableFields.length > 0" :active="hasFilter"
@click="filterDrawerVisible = true" />
</div>
</div>
<!-- 表格主体(可滚动) -->
<el-main>
<el-table :data="data" :loading="loading" stripe border style="width: 100%" v-bind="$attrs"
@sort-change="onSortChange">
<template v-for="field in visibleFields" :key="field.key">
<el-table-column :prop="field.key" :label="vueAdmin.t(field.label)"
:sortable="field.sortable ? 'custom' : false">
<template #default="{ row }">
<slot :name="`column-${field.key}`" :row="row" :field="field">
<component :is="vueAdmin.getFieldComponent(moduleKey, field.type, field.key)" :field="field"
:model-value="row[field.key]" mode="display"
v-bind="field.type === 'relation' || field.type === 'textarea' ? { displayLabel: row[field.key + '_label'] } : {}" />
</slot>
</template>
</el-table-column>
</template>
<el-table-column :label="$t('base.operation')" :width="rowActionsWidth" v-if="hasRowActions">
<template #default="{ row }">
<el-button v-if="hasActiveField && can('edit')" link :type="row.active ? 'warning' : 'success'" :icon="row.active ? VideoPause : VideoPlay" size="large" @click="handleToggleActive(row)" />
<el-button v-if="can('edit')" link type="primary" :icon="Edit" size="large" @click="handleEdit(row)" />
<el-button v-if="can('delete')" link type="danger" :icon="DeleteIcon" size="large" @click="handleDelete(row)" />
<el-tooltip v-for="sub in subModules" :key="sub.moduleKey" :content="vueAdmin.t(sub.label)">
<el-button link type="primary" :icon="resolveIcon(sub.icon)" size="large"
@click="handleSubJump(sub, row)" />
</el-tooltip>
<slot name="row-actions" :row="row"></slot>
</template>
</el-table-column>
</el-table>
</el-main>
<!-- 底部区域:分页 + 扩展(固定在容器底部) -->
<el-footer height="auto" v-if="total > 0 || $slots.footer">
<el-pagination v-if="total > 0" v-model:current-page="currentPage" v-model:page-size="currentPageSize"
:total="total" @current-change="refresh" />
<slot name="footer"></slot>
</el-footer>
<!-- 新增/编辑抽屉 -->
<ShrimpEditDrawer ref="drawerRef" v-model:visible="dialogVisible" :title="isEdit ? $t('base.edit') : $t('base.add')"
:moduleKey="props.moduleKey" :fields="formFields" v-model:formData="formData" :isEdit="isEdit"
:canSave="canSave" @save="handleSave">
<template v-for="name in formSlotNames" :key="name" #[name]="slotProps">
<slot :name="name" v-bind="slotProps" />
</template>
</ShrimpEditDrawer>
<!-- 筛选抽屉 -->
<ShrimpFilterDrawer v-model:visible="filterDrawerVisible" :title="$t('base.filter')"
:moduleKey="props.moduleKey" :filterableFields="filterableFields" :filterData="filterData"
@confirm="handleFilterConfirm" @reset="handleFilterReset">
<template v-for="name in filterSlotNames" :key="name" #[name]="slotProps">
<slot :name="name" v-bind="slotProps" />
</template>
</ShrimpFilterDrawer>
</el-container>
</template>
<script lang="ts" setup>
import { ref, computed, useSlots, onMounted, type Component } from 'vue'
import { ITableCrudProto, IFieldConfig, IModuleInfo, IPageQuery } from 'shrimp-server/types'
import { vueAdmin } from '../VueAdmin'
import { Plus, Delete as DeleteIcon, Download, Upload, Edit, VideoPlay, VideoPause, Operation } from '@element-plus/icons-vue'
import * as ElIcons from '@element-plus/icons-vue'
import { ElMessageBox, ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useTableUrlSync } from '../composables/useTableUrlSync'
import ShrimpEditDrawer from './ShrimpEditDrawer.vue'
import ShrimpFilterDrawer from './ShrimpFilterDrawer.vue'
import ShrimpSearchInput from './ShrimpSearchInput.vue'
import ShrimpFilterTrigger from './ShrimpFilterTrigger.vue'
import * as XLSX from 'xlsx'
const { t } = useI18n()
interface Props {
moduleKey: string
pageSize?: number
/** 显式锁定的筛选条件(覆盖自动 ownerKey 推断);用户无法修改,会合并进 filter 并在筛选抽屉中隐藏 */
baseFilter?: Record<string, any>
}
const props = withDefaults(defineProps<Props>(), {
pageSize: 20,
baseFilter: () => ({})
})
const emit = defineEmits<{
export: []
import: []
}>()
const api = vueAdmin.getModule<ITableCrudProto<any>>(props.moduleKey)
const slots = useSlots()
// ---- URL 同步 ----
const {
currentPage,
currentPageSize,
keyword,
searchKeyword,
currentSortBy,
currentSortOrder,
filterData,
restore,
} = useTableUrlSync({ pageSize: props.pageSize })
// ---- 搜索 ----
const searchVisible = ref(false)
function onSearch() {
searchKeyword.value = keyword.value
currentPage.value = 1
refresh()
}
// ---- 筛选 ----
const filterDrawerVisible = ref(false)
const hasFilter = computed(() => {
return Object.values(filterData.value).some(val => {
if (val === '' || val === null || val === undefined) return false
if (Array.isArray(val) && val.length === 0) return false
return true
})
})
async function handleFilterConfirm(newFilterData: Record<string, any>) {
filterData.value = newFilterData
currentPage.value = 1
await refresh()
}
async function handleFilterReset() {
filterData.value = {}
currentPage.value = 1
await refresh()
}
// ---- 远程排序 ----
function onSortChange({ prop, order }: { prop: string; order: string | null }) {
if (order) {
currentSortBy.value = prop
currentSortOrder.value = order === 'ascending' ? 'asc' : 'desc'
} else {
currentSortBy.value = ''
}
refresh()
}
// ---- 字段配置 ----
const moduleInfo = computed<IModuleInfo | undefined>(() =>
vueAdmin.moduleInfo.find(m => m.moduleKey === props.moduleKey)
)
const fields = computed<IFieldConfig[]>(() => moduleInfo.value?.fields || [])
// ---- 子表 ownerId 推断 ----
// 子模块约定: moduleInfo.parent 存在 → 从路由 ?id= 取父记录 id,作为 ownerId 顶层参数发给后端
// 后端按自己的 ownerKey 配置翻译为 filter 注入(前端不构造 {ownerKey: id} 这种业务字段名 payload)
const route = useRoute()
const router = useRouter()
const ownerId = computed<string | undefined>(() => {
if (!moduleInfo.value?.parent) return undefined
return (route.query.id as string | undefined) || undefined
})
const visibleFields = computed(() => fields.value.filter(f => !f.hidden && !f.tableHide))
const formFields = computed(() => {
const list = fields.value.filter(f => !f.hidden)
if (isEdit.value) {
return list.filter(f => f.editable !== 'readonly')
}
return list.filter(f => f.editable !== 'readonly')
})
const searchableFields = computed(() => fields.value.filter(f => f.searchable))
const filterableFields = computed(() => {
const lockedKeys = new Set(Object.keys(props.baseFilter || {}))
return fields.value.filter(f => f.filterable && !lockedKeys.has(f.key))
})
const formSlotNames = computed(() => Object.keys(slots).filter(name => name.startsWith('form-')))
const filterSlotNames = computed(() => Object.keys(slots).filter(name => name.startsWith('filter-')))
// ---- 表格状态 ----
const data = ref<any[]>([])
const loading = ref(false)
const total = ref(0)
// ---- 对话框状态 ----
const dialogVisible = ref(false)
const isEdit = ref(false)
const currentId = ref('')
const formData = ref<Record<string, any>>({})
const originalData = ref<Record<string, any>>({})
const drawerRef = ref<any>(null)
// ---- 权限 ----
const can = vueAdmin.can(props.moduleKey)
const hasActions = computed(() => can('add') || can('clear') || can('export') || can('import') || !!slots.actions)
// ---- 是否有 active 字段 ----
const hasActiveField = computed(() => {
return fields.value.some(f => f.key === 'active')
})
// ---- 子模块跳转 ----
const subModules = computed<IModuleInfo[]>(() =>
vueAdmin.moduleInfo.filter(m => m.parent === props.moduleKey)
)
function resolveIcon(name?: string): Component {
if (name && (ElIcons as any)[name]) return (ElIcons as any)[name]
return Operation
}
function handleSubJump(sub: IModuleInfo, row: any) {
// 跳子模块: 把当前 ?id 追加进 pids 链, row._id 作为新的 ?id
// 这样多层嵌套下面包屑/返回回到上层时能恢复对应父记录 id
const currentPids = ((route.query.pids as string) || '').split(',').filter(Boolean)
const currentId = route.query.id as string | undefined
const newPids = currentId ? [...currentPids, currentId] : currentPids
const query: Record<string, string> = { id: row._id }
if (newPids.length > 0) query.pids = newPids.join(',')
router.push({ path: `${route.path}/${sub.moduleKey}`, query })
}
const hasRowActions = computed(() => can('edit') || can('delete') || hasActiveField.value || subModules.value.length > 0 || !!slots['row-actions'])
const rowActionsWidth = computed(() => {
let n = 0
if (hasActiveField.value && can('edit')) n++
if (can('edit')) n++
if (can('delete')) n++
n += subModules.value.length
return Math.max(120, 40 + n * 40)
})
// ---- 编辑时是否有改动 ----
const canSave = computed(() => {
if (!isEdit.value) return true
return formFields.value.some(field => {
if (field.editable === 'readonly') return false
if (field.editable === 'addOnly') return false
return formData.value[field.key] !== originalData.value[field.key]
})
})
// ---- CRUD ----
async function handleAdd() {
isEdit.value = false
currentId.value = ''
formData.value = {}
const baseKeys = new Set(Object.keys(props.baseFilter || {}))
formFields.value.forEach(field => {
if (baseKeys.has(field.key)) {
formData.value[field.key] = props.baseFilter![field.key]
} else if (field.defaultValue !== undefined) {
formData.value[field.key] = field.defaultValue
} else if (field.type === 'boolean') {
formData.value[field.key] = false
} else if (field.type === 'number') {
formData.value[field.key] = 0
} else if (field.multiple) {
formData.value[field.key] = []
} else {
formData.value[field.key] = ''
}
})
dialogVisible.value = true
}
async function handleEdit(row: any) {
isEdit.value = true
currentId.value = row._id
originalData.value = { ...row }
formData.value = { ...row }
dialogVisible.value = true
}
async function handleSave() {
const valid = await drawerRef.value?.validate()
if (!valid) return
try {
const submitData: Record<string, any> = {}
if (isEdit.value) {
// 编辑:只提交修改过的字段
formFields.value.forEach(field => {
if (field.editable === 'readonly') return
if (field.editable === 'addOnly') return
const newValue = formData.value[field.key]
const oldValue = originalData.value[field.key]
if (newValue === oldValue) return
submitData[field.key] = newValue
})
} else {
// 新增:提交所有非 readonly 字段
formFields.value.forEach(field => {
if (field.editable === 'readonly') return
submitData[field.key] = formData.value[field.key]
})
}
if (isEdit.value) {
if (Object.keys(submitData).length === 0) {
dialogVisible.value = false
return
}
await api.edit(currentId.value, submitData)
} else {
await api.add(submitData, ownerId.value)
}
dialogVisible.value = false
ElMessage.success(isEdit.value ? t('table.editSuccess') : t('table.addSuccess'))
await refresh()
} catch (e: any) {
console.warn(`[ShrimpTable]`, e.message)
}
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm(t('table.confirmDelete'), t('base.confirm'), {
confirmButtonText: t('base.confirm'),
cancelButtonText: t('base.cancel'),
type: 'warning',
})
await api.delete(row._id)
await refresh()
} catch {
// 用户取消
}
}
async function handleToggleActive(row: any) {
try {
await api.edit(row._id, { active: !row.active })
await refresh()
ElMessage.success(t('base.success'))
} catch (e: any) {
console.warn(`[ShrimpTable]`, e.message)
}
}
async function handleClear() {
try {
await ElMessageBox.confirm(t('table.confirmClear'), t('base.delete'), {
confirmButtonText: t('base.confirm'),
cancelButtonText: t('base.cancel'),
type: 'warning',
})
const query: Record<string, any> = {}
if (searchKeyword.value) query.keyword = searchKeyword.value
const filter = buildFilter()
if (filter) query.filter = filter
if (ownerId.value) query.ownerId = ownerId.value
await api.clear(query)
await refresh()
} catch {
// 用户取消
}
}
// ---- 导出 ----
function buildFilter(): Record<string, any> | undefined {
const filter: Record<string, any> = {}
for (const [key, val] of Object.entries(filterData.value)) {
if (val === '' || val === null || val === undefined) continue
if (Array.isArray(val) && val.length === 0) continue
if (typeof val === 'object' && !Array.isArray(val)) {
const range: Record<string, any> = {}
if (val.min !== undefined && val.min !== null) range.min = val.min
if (val.max !== undefined && val.max !== null) range.max = val.max
if (Object.keys(range).length > 0) filter[key] = range
} else {
filter[key] = val
}
}
// baseFilter 优先级最高,覆盖用户选择
Object.assign(filter, props.baseFilter || {})
return Object.keys(filter).length > 0 ? filter : undefined
}
async function handleExport() {
emit('export')
try {
const query: Record<string, any> = {}
if (searchKeyword.value) query.keyword = searchKeyword.value
const filter = buildFilter()
if (filter) query.filter = filter
if (currentSortBy.value) {
query.sortBy = currentSortBy.value
query.sortOrder = currentSortOrder.value
}
if (ownerId.value) query.ownerId = ownerId.value
const list = await api.export(query)
const ws = XLSX.utils.json_to_sheet(list)
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, props.moduleKey)
XLSX.writeFile(wb, `${props.moduleKey}_export_${Date.now()}.xlsx`)
ElMessage.success(t('base.success'))
} catch (e: any) {
console.warn(`[ShrimpTable] export failed`, e.message)
}
}
// ---- 导入 ----
const fileInput = ref<HTMLInputElement | null>(null)
function handleImportClick() {
emit('import')
fileInput.value?.click()
}
async function handleFileChange(e: Event) {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
try {
let data: any[]
if (file.name.endsWith('.json')) {
const text = await file.text()
data = JSON.parse(text)
} else {
const buf = await file.arrayBuffer()
const wb = XLSX.read(buf, { type: 'array' })
const ws = wb.Sheets[wb.SheetNames[0]]
data = XLSX.utils.sheet_to_json(ws)
}
await api.import(data, ownerId.value)
ElMessage.success(t('base.success'))
await refresh()
} catch (e: any) {
// if (!e.handled) {
// ElMessage.error(t('base.fail'))
// }
// console.warn(`[ShrimpTable] import failed`, e.message)
} finally {
target.value = ''
}
}
// ---- 数据加载 ----
async function refresh(query?: Record<string, any>) {
loading.value = true
try {
const pageQuery: IPageQuery & Record<string, any> = {
page: currentPage.value,
pageSize: currentPageSize.value,
}
if (searchKeyword.value) {
pageQuery.keyword = searchKeyword.value
}
if (currentSortBy.value) {
pageQuery.sortBy = currentSortBy.value
pageQuery.sortOrder = currentSortOrder.value
}
// 筛选条件:仅传有值的字段,放到 filter 对象中
const filter = buildFilter()
if (filter) {
pageQuery.filter = filter
}
// 子表场景: 顶层 ownerId 由后端按 ownerKey 配置翻译
if (ownerId.value) {
pageQuery.ownerId = ownerId.value
}
Object.assign(pageQuery, query)
const res = await api.list(pageQuery)
data.value = res.list || []
total.value = res.total
currentPage.value = res.page
currentPageSize.value = res.pageSize
} finally {
loading.value = false
}
}
// ---- 暴露 ----
defineExpose({
data,
loading,
refresh,
can,
api,
keyword,
})
onMounted(() => {
restore()
if (keyword.value) {
searchVisible.value = true
}
refresh()
})
</script>
<style scoped>
.shrimp-table {
width: 100%;
height: 100%;
}
/* 顶部区域:左右分布 */
.aside {
padding: 4px 10px;
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.aside-left {
display: flex;
gap: 10px;
align-items: center;
}
.aside-right {
display: flex;
gap: 10px;
align-items: center;
}
/* 表格主体:内部纵向滚动(分页固定在 footer,不参与滚动) */
.shrimp-table :deep(.el-main) {
padding: 0;
overflow: auto;
flex: 1;
min-height: 0;
}
/* 底部分页:固定在容器底,不随内容滚动 */
.shrimp-table :deep(.el-footer) {
padding: 12px 0;
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
/* 强制放大操作栏图标 */
.shrimp-table :deep(.el-button .el-icon) {
font-size: 20px;
width: 20px;
height: 20px;
}
</style>
+36
View File
@@ -0,0 +1,36 @@
<template>
<el-tag v-if="mode === 'display'" :type="modelValue ? 'success' : 'danger'" size="small">
{{ modelValue ? $t('table.yes') : $t('table.no') }}
</el-tag>
<el-select
v-else-if="mode === 'filter'"
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
clearable
:placeholder="$t('table.unlimited')"
style="width: 100%"
>
<el-option :label="$t('table.yes')" :value="true" />
<el-option :label="$t('table.no')" :value="false" />
</el-select>
<el-switch v-else :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" :disabled="disabled" />
</template>
<script lang="ts" setup>
import { watch } from 'vue'
const props = defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit' | 'filter'
disabled?: boolean
}>()
defineEmits<{
'update:modelValue': [value: any]
}>()
watch(() => props.field.multiple, (v) => {
if (v) console.warn('[FieldBoolean] multiple is not supported, fallback to single value')
}, { immediate: true })
</script>
+137
View File
@@ -0,0 +1,137 @@
<template>
<template v-if="mode === 'display'">
<template v-if="Array.isArray(modelValue)">
<span v-if="!modelValue.length">-</span>
<span v-else class="dt-group">
<template v-for="(p, i) in parsedArray" :key="i">
<el-tooltip v-if="p" :content="p.full" placement="top" :show-after="800">
<span class="dt">
<el-icon class="ico"><Calendar /></el-icon>
<span class="d">{{ p.day }}</span>
<span class="t">{{ p.time }}</span>
</span>
</el-tooltip>
<span v-else>-</span>
</template>
</span>
</template>
<el-tooltip v-else-if="parsed" :content="parsed.full" placement="top" :show-after="800">
<span class="dt">
<el-icon class="ico"><Calendar /></el-icon>
<span class="d">{{ parsed.day }}</span>
<span class="t">{{ parsed.time }}</span>
</span>
</el-tooltip>
<span v-else>-</span>
</template>
<el-date-picker
v-else-if="mode === 'filter'"
:model-value="filterModelValue"
@update:model-value="onFilterChange($event)"
type="datetimerange"
:start-placeholder="$t('table.startTime')"
:end-placeholder="$t('table.endTime')"
style="width: 100%"
/>
<el-date-picker
v-else-if="field.multiple"
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
:disabled="disabled"
type="dates"
style="width: 100%"
/>
<el-date-picker v-else :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" :disabled="disabled"
type="datetime" style="width: 100%" />
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { Calendar } from '@element-plus/icons-vue'
const props = defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit' | 'filter'
disabled?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
function pad(n: number) { return n < 10 ? '0' + n : String(n) }
function parse(v: any): { day: string; time: string; full: string } | null {
if (v == null || v === '') return null
const d = new Date(v)
if (isNaN(d.getTime())) return null
const y = d.getFullYear()
const mo = pad(d.getMonth() + 1)
const da = pad(d.getDate())
const h = pad(d.getHours())
const mi = pad(d.getMinutes())
const se = pad(d.getSeconds())
const day = da
const time = `${h}:${mi}`
const full = `${y}-${mo}-${da} ${h}:${mi}:${se}`
return { day, time, full }
}
const parsed = computed(() => parse(props.modelValue))
const parsedArray = computed(() => Array.isArray(props.modelValue) ? props.modelValue.map(parse) : [])
const filterModelValue = computed(() => {
if (!props.modelValue || typeof props.modelValue !== 'object') return null
const { min, max } = props.modelValue
if (min != null && max != null) {
return [new Date(min), new Date(max)]
}
return null
})
function onFilterChange(dates: Date[] | null) {
if (dates && dates.length === 2) {
emit('update:modelValue', { min: dates[0].getTime(), max: dates[1].getTime() })
} else {
emit('update:modelValue', null)
}
}
</script>
<style scoped>
.dt-group {
display: inline-flex;
flex-wrap: wrap;
gap: 4px;
}
.dt {
display: inline-flex;
align-items: flex-end;
width: 100px;
height: 24px;
box-sizing: border-box;
padding: 0 10px 3px;
background: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color);
border-radius: var(--el-border-radius-base);
font-variant-numeric: tabular-nums;
white-space: nowrap;
font-size: 13px;
line-height: 1;
color: var(--el-text-color-regular);
vertical-align: middle;
}
.ico {
color: var(--el-text-color-placeholder);
font-size: 16px;
margin-right: 4px;
}
.d {
color: var(--el-text-color-regular);
}
.t {
margin-left: auto;
color: var(--el-text-color-regular);
}
</style>
+48
View File
@@ -0,0 +1,48 @@
<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>
+82
View File
@@ -0,0 +1,82 @@
<template>
<template v-if="mode === 'display'">
<template v-if="Array.isArray(modelValue)">{{ modelValue.join(', ') || 0 }}</template>
<template v-else>{{ modelValue ?? 0 }}</template>
</template>
<div v-else-if="mode === 'filter'" style="display: flex; gap: 8px; align-items: center; width: 100%">
<el-input-number
:model-value="modelValue?.min"
@update:model-value="emitRange('min', $event)"
:placeholder="$t('table.min')"
controls-position="right"
style="flex: 1"
/>
<span style="color: #999">~</span>
<el-input-number
:model-value="modelValue?.max"
@update:model-value="emitRange('max', $event)"
:placeholder="$t('table.max')"
controls-position="right"
style="flex: 1"
/>
</div>
<div v-else-if="field.multiple" class="tags-input">
<el-tag v-for="(num, index) in (modelValue || [])" :key="num + '-' + index" closable @close="removeNum(index)">
{{ num }}
</el-tag>
<el-input-number
v-model="inputValue"
@keyup.enter="addNum"
@blur="addNum"
:placeholder="$t('table.pressEnterForNumber')"
:controls="false"
style="width: 120px; flex: 1; min-width: 120px"
/>
</div>
<el-input-number v-else :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" :disabled="disabled" style="width: 100%" />
</template>
<script lang="ts" setup>
import { ref } from 'vue'
const props = defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit' | 'filter'
disabled?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
const inputValue = ref<number | undefined>(undefined)
function emitRange(key: 'min' | 'max', val: number | undefined) {
const current = props.modelValue || {}
emit('update:modelValue', { ...current, [key]: val })
}
function addNum() {
if (inputValue.value == null) return
const current = Array.isArray(props.modelValue) ? props.modelValue : []
emit('update:modelValue', [...current, inputValue.value])
inputValue.value = undefined
}
function removeNum(index: number) {
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
current.splice(index, 1)
emit('update:modelValue', current)
}
</script>
<style scoped>
.tags-input {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
width: 100%;
}
</style>
+20
View File
@@ -0,0 +1,20 @@
<template>
<template v-if="mode === 'display'">******</template>
<template v-else-if="field.multiple">
<el-alert type="warning" :title="$t('table.passwordMultipleNotSupported')" :closable="false" />
</template>
<el-input v-else type="password" show-password :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" :disabled="disabled" />
</template>
<script lang="ts" setup>
defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit'
disabled?: boolean
}>()
defineEmits<{
'update:modelValue': [value: any]
}>()
</script>
+94
View File
@@ -0,0 +1,94 @@
<template>
<template v-if="mode === 'display'">
<template v-if="field.multiple && Array.isArray(displayLabel)">{{ displayLabel.filter(Boolean).join(', ') || '-' }}</template>
<template v-else>{{ displayLabel || modelValue || '-' }}</template>
</template>
<el-select
v-else-if="mode === 'filter'"
:model-value="stringValue"
@update:model-value="$emit('update:modelValue', $event)"
multiple clearable 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-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-option v-for="opt in options" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</template>
<script lang="ts" setup>
import { ref, onMounted, computed } from 'vue'
import { vueAdmin } from '../../VueAdmin'
import { ITableCrudProto } from 'shrimp-server/types'
const props = defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit' | 'filter'
disabled?: boolean
displayLabel?: string | string[]
}>()
defineEmits<{
'update:modelValue': [value: any]
}>()
const options = ref<{ label: string; value: string }[]>([])
const loading = ref(false)
/** 统一转为字符串值,解决 ObjectId 与 el-select 的匹配问题 */
const stringValue = computed(() => {
// filter 模式强制 multiple,始终返回数组
if (props.mode === 'filter' || props.field.multiple) {
return Array.isArray(props.modelValue) ? props.modelValue.map(String) : []
}
return props.modelValue != null ? String(props.modelValue) : undefined
})
async function onSearch(query: string) {
if (!props.field.relation) return
loading.value = true
try {
const api = vueAdmin.getModule<ITableCrudProto<any>>(props.field.relation.moduleKey)
const listQuery: any = { keyword: query, pageSize: 20 }
if (props.field.filter) {
listQuery.filter = props.field.filter
}
const res = await api.list(listQuery)
const list = res.list || res as any
options.value = list.map((item: any) => ({
label: item[props.field.relation.labelKey],
value: String(item._id),
}))
} catch (e) {
console.warn('[FieldRelation] search failed', e)
} finally {
loading.value = false
}
}
onMounted(async () => {
if ((props.mode === 'edit' || props.mode === 'filter') && props.field.relation) {
await onSearch('')
// 确保当前值在选项中
const currentValues = props.field.multiple
? (Array.isArray(props.modelValue) ? props.modelValue.map(String) : [])
: (props.modelValue ? [String(props.modelValue)] : [])
for (const val of currentValues) {
if (val && !options.value.some(o => o.value === val)) {
try {
const api = vueAdmin.getModule<ITableCrudProto<any>>(props.field.relation.moduleKey)
const current = await api.get(val)
if (current) {
options.value.unshift({
label: (current as any)[props.field.relation.labelKey],
value: String(val),
})
}
} catch { }
}
}
}
})
</script>
+65
View File
@@ -0,0 +1,65 @@
<template>
<template v-if="mode === 'display'">
<template v-if="Array.isArray(modelValue)"
>{{ displayLabels.join(', ') || '-' }}</template
>
<template v-else>{{ displayText }}</template>
</template>
<el-select
v-else-if="mode === 'filter'"
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
multiple
clearable
style="width: 100%"
>
<el-option v-for="opt in resolvedOptions" :key="opt.value" :label="vueAdmin.t(opt.label)" :value="opt.value" />
</el-select>
<el-select
v-else
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
:disabled="disabled"
:multiple="field.multiple"
style="width: 100%"
>
<el-option v-for="opt in resolvedOptions" :key="opt.value" :label="vueAdmin.t(opt.label)" :value="opt.value" />
</el-select>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { vueAdmin } from '../../VueAdmin'
const props = defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit' | 'filter'
disabled?: boolean
}>()
defineEmits<{
'update:modelValue': [value: any]
}>()
const resolvedOptions = computed(() => {
const opts = props.field.options
if (typeof opts === 'string') {
return vueAdmin.globalOptions[opts] || []
}
return opts || []
})
const displayText = computed(() => {
const label = resolvedOptions.value.find((o: any) => o.value === props.modelValue)?.label
return label ? vueAdmin.t(label) : (props.modelValue ?? '-')
})
const displayLabels = computed(() => {
if (!Array.isArray(props.modelValue)) return []
return props.modelValue.map((val: any) => {
const opt = resolvedOptions.value.find((o: any) => o.value === val)
return opt ? vueAdmin.t(opt.label) : val
})
})
</script>
+59
View File
@@ -0,0 +1,59 @@
<template>
<template v-if="mode === 'display'">
<template v-if="Array.isArray(modelValue)">{{ modelValue.join(', ') || '-' }}</template>
<template v-else>{{ modelValue ?? '-' }}</template>
</template>
<div v-else-if="field.multiple" class="tags-input">
<el-tag v-for="(tag, index) in (modelValue || [])" :key="tag + index" closable @close="removeTag(index)">
{{ tag }}
</el-tag>
<el-input v-model="inputValue" @keyup.enter="addTag" @blur="addTag" :placeholder="$t('base.pressEnter')" />
</div>
<el-input v-else :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" :disabled="disabled" clearable />
</template>
<script lang="ts" setup>
import { ref } from 'vue'
const props = defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit' | 'filter'
disabled?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
const inputValue = ref('')
function addTag() {
const val = inputValue.value.trim()
if (!val) return
const current = Array.isArray(props.modelValue) ? props.modelValue : []
emit('update:modelValue', [...current, val])
inputValue.value = ''
}
function removeTag(index: number) {
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
current.splice(index, 1)
emit('update:modelValue', current)
}
</script>
<style scoped>
.tags-input {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
width: 100%;
}
.tags-input .el-input {
width: 120px;
flex: 1;
min-width: 120px;
}
</style>
+120
View File
@@ -0,0 +1,120 @@
<template>
<template v-if="mode === 'display'">
<span v-if="!modelValue || typeof modelValue !== 'object'">-</span>
<el-tooltip v-else :content="JSON.stringify(modelValue, null, 2)" placement="top">
<span class="tab-display">{{ displayText }}</span>
</el-tooltip>
</template>
<div v-else class="field-tab">
<el-form-item :label="vueAdmin.t(field.label) + ' - 类型'" :required="field.required">
<el-select :model-value="innerValue.type" @update:model-value="onTypeChange" :disabled="disabled">
<el-option v-for="tab in field.tabs" :key="tab.key" :label="vueAdmin.t(tab.label)" :value="tab.key" />
</el-select>
</el-form-item>
<div v-if="selectedTab?.fields?.length" class="tab-fields">
<template v-for="childField in selectedTab.fields" :key="childField.key">
<el-form-item :label="vueAdmin.t(childField.label)" :required="childField.required">
<component :is="vueAdmin.getFieldComponent(moduleKey, childField.type, childField.key)"
:field="childField"
:model-value="innerValue[childField.key]"
@update:model-value="onChildChange(childField.key, $event)"
mode="edit"
:disabled="disabled" />
</el-form-item>
</template>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, inject, onMounted } 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 displayText = computed(() => {
if (!props.modelValue || typeof props.modelValue !== 'object') return '-'
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 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) {
const tab = props.field.tabs?.find((t: any) => t.key === type)
const obj: any = { type }
tab?.fields?.forEach((f: any) => {
if (f.defaultValue !== undefined) {
obj[f.key] = typeof f.defaultValue === 'function' ? f.defaultValue() : f.defaultValue
}
})
emit('update:modelValue', obj)
}
function onChildChange(key: string, value: any) {
emit('update:modelValue', { ...innerValue.value, [key]: value })
}
onMounted(() => {
const val = props.modelValue
if (val == null || val === '' || (typeof val === 'object' && Object.keys(val).length === 0)) {
emit('update:modelValue', initValue())
}
})
</script>
<style scoped>
.tab-display {
display: inline-block;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.field-tab {
display: flex;
flex-direction: column;
gap: 8px;
}
.tab-fields {
padding-left: 16px;
border-left: 2px solid var(--el-border-color-lighter);
}
</style>
+78
View File
@@ -0,0 +1,78 @@
<template>
<template v-if="mode === 'display'">
<template v-if="Array.isArray(modelValue)">{{ modelValue.join('\n') || '-' }}</template>
<template v-else-if="!isLong">{{ text }}</template>
<div v-else class="textarea-cell">
<span class="text" :class="{ collapsed: !expanded }">{{ text }}</span>
<el-link type="primary" underline="never" class="toggle" @click.stop="expanded = !expanded">
{{ expanded ? $t('base.collapse') : $t('base.expand') }}
</el-link>
</div>
</template>
<template v-else-if="field.multiple">
<el-alert type="warning" :title="$t('table.textareaMultipleNotSupported')" :closable="false" />
</template>
<el-input
v-else
:type="mode === 'filter' ? 'text' : 'textarea'"
:rows="mode === 'filter' ? undefined : 3"
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
:disabled="disabled"
clearable
/>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue'
const props = defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit' | 'filter'
disabled?: boolean
displayLabel?: string
}>()
defineEmits<{
'update:modelValue': [value: any]
}>()
const text = computed(() => {
const v = props.displayLabel ?? props.modelValue
return v == null || v === '' ? '-' : String(v)
})
const isLong = computed(() => {
const s = text.value
return s.length > 40 || s.includes('\n')
})
const expanded = ref(false)
</script>
<style scoped>
.textarea-cell {
display: flex;
align-items: flex-start;
gap: 6px;
}
.text {
flex: 1;
white-space: pre-wrap;
word-break: break-all;
min-width: 0;
}
.text.collapsed {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-all;
}
.toggle {
flex-shrink: 0;
font-size: 12px;
line-height: 1.5;
}
</style>