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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user