refactor(frontend): CrudTable 拆分——抽取 CrudFormPanel 动态表单与 useDraftAutosave 草稿 composable

This commit is contained in:
TermiNexus committed 2026-08-13 18:26:18 +08:00
1 parent ce7a17d170
commit 36afde19da
3 files changed
+353 -216

No files matched your search

+230
View File
@@ -0,0 +1,230 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import type { ColumnSchema } from '@/types/api'
import EntityPicker from './EntityPicker.vue'
import RichTextEditor from './RichTextEditor.vue'
/**
* 新增/编辑弹窗表单(从 CrudTable.vue 拆出的独立组件)
*
* 职责:按列定义(formColumns)动态渲染表单项 + required 校验;
* formModel 为父组件 editingRow 的引用,组件内直接读写其属性(受控表单)。
*/
const props = defineProps<{
/** 弹窗可见性(v-model) */
visible: boolean
/** 弹窗标题 */
title: string
/** 提交中(保存按钮 loading) */
submitting: boolean
/** 弹窗可见列(父组件已过滤 pk/hidden/继承注入等) */
columns: ColumnSchema[]
/** 表单数据对象(引用:直接改属性,父组件 editingRow) */
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 对齐 useCrudTable 的 editingRow 类型
formModel: Record<string, any>
}>()
const emit = defineEmits<{
'update:visible': [v: boolean]
/** 校验通过后请求保存 */
submit: []
/** EntityPicker 树面板"全部提交"(多选暂存批量新增) */
'picker-multi': [
field: string,
items: Array<{ value: string; label: string; secondary?: string; type?: number }>,
]
/** EntityPicker 回显标签同步 */
'update:label': [field: string, label: string]
}>()
const formRef = ref<FormInstance>()
// required 列自动生成校验规则(required 为样式标记 → 真实校验)
const rules = computed<FormRules>(() => {
const r: FormRules = {}
for (const col of props.columns) {
if (col.required) {
r[col.field] = [{ required: true, message: `请填写${col.title || col.field}`, trigger: 'blur' }]
}
}
return r
})
// tags 表单:逗号分隔字符串 ↔ 多选数组互转
function splitTags(value: unknown, separator = ','): string[] {
if (!value) return []
return String(value).split(separator).map((s) => s.trim()).filter(Boolean)
}
function joinTags(values: string[], separator = ','): string {
return (values || []).filter(Boolean).join(separator)
}
/** 保存:先校验,通过后交父组件提交 */
async function onSubmit(): Promise<void> {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
return // 校验未通过
}
emit('submit')
}
function onPickerLabel(field: string, label: string): void {
emit('update:label', field, label)
}
</script>
<template>
<el-dialog
:model-value="visible"
:title="title"
width="600px"
destroy-on-close
@update:model-value="(v: boolean) => emit('update:visible', v)"
>
<el-form
ref="formRef"
:model="formModel"
:rules="rules"
label-width="120px"
label-position="right"
>
<el-form-item
v-for="col in columns"
:key="col.field"
:label="col.title"
:prop="col.field"
>
<!-- entityPicker:EntityPicker 组件(支持级联:dependsOn 字段值 + dependCompany 公司值) -->
<EntityPicker
v-if="col.type === 'entityPicker' && col.entityPicker"
v-model="formModel[col.field]"
:config="col.entityPicker"
:existing-label="(formModel[`${col.field}_label`] as string) || (col.entityPicker.valueField === col.field ? (formModel[col.field] as string) : '')"
:placeholder="col.placeholder"
:disabled="col.disabled"
:depend-value="col.entityPicker.dependsOn ? (formModel[col.entityPicker.dependsOn] as string) : undefined"
:depend-company-value="col.entityPicker.dependCompany ? (formModel.CompanyUUID as string) : undefined"
@update:label="(label: string) => onPickerLabel(col.field, label)"
@submit-multi="(items) => emit('picker-multi', col.field, items)"
/>
<el-input
v-else-if="!col.type || col.type === 'text' || col.type === 'string'"
v-model="formModel[col.field]"
:placeholder="col.placeholder"
:maxlength="col.maxLength"
:disabled="col.disabled"
/>
<el-input-number
v-else-if="col.type === 'number'"
v-model="formModel[col.field]"
:min="col.min"
:max="col.max"
:step="col.step"
:disabled="col.disabled"
/>
<el-input
v-else-if="col.type === 'textarea'"
v-model="formModel[col.field]"
type="textarea"
:rows="col.rows || 3"
:disabled="col.disabled"
/>
<!-- richtext:富文本编辑器(tinymce 自托管) -->
<RichTextEditor
v-else-if="col.type === 'richtext'"
v-model="formModel[col.field]"
:placeholder="col.placeholder"
:rows="col.rows"
:disabled="col.disabled"
/>
<el-input
v-else-if="col.type === 'password'"
v-model="formModel[col.field]"
type="password"
show-password
:placeholder="col.placeholder"
/>
<el-select
v-else-if="col.type === 'select'"
v-model="formModel[col.field]"
:placeholder="col.placeholder"
:disabled="col.disabled"
style="width: 100%"
>
<el-option
v-for="opt in col.options"
:key="String(opt.value)"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<!-- tags:多选标签(预设可选项 + 允许自定义新增),逗号分隔存储 -->
<el-select
v-else-if="col.type === 'tags'"
:model-value="splitTags(formModel[col.field], col.tagsSeparator)"
@update:model-value="(v: string[]) => (formModel[col.field] = joinTags(v, col.tagsSeparator))"
:placeholder="col.placeholder"
:disabled="col.disabled"
multiple
filterable
allow-create
default-first-option
style="width: 100%"
>
<el-option
v-for="opt in col.options ?? []"
:key="String(opt.value)"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-radio-group
v-else-if="col.type === 'radio'"
v-model="formModel[col.field]"
:disabled="col.disabled"
>
<el-radio v-for="opt in col.options" :key="String(opt.value)" :value="opt.value">{{ opt.label }}</el-radio>
</el-radio-group>
<el-checkbox-group
v-else-if="col.type === 'checkbox'"
v-model="formModel[col.field]"
:disabled="col.disabled"
>
<el-checkbox v-for="opt in col.options" :key="String(opt.value)" :value="opt.value">{{ opt.label }}</el-checkbox>
</el-checkbox-group>
<el-date-picker
v-else-if="col.type === 'date' || col.type === 'datetime'"
v-model="formModel[col.field]"
:type="col.type as 'date' | 'datetime'"
:placeholder="col.placeholder"
style="width: 100%"
/>
<el-time-picker
v-else-if="col.type === 'time'"
v-model="formModel[col.field]"
:placeholder="col.placeholder"
style="width: 100%"
/>
<el-switch
v-else-if="col.type === 'switch' || col.type === 'boolean'"
v-model="formModel[col.field]"
:disabled="col.disabled"
/>
<el-input
v-else-if="col.type === 'readonly'"
:model-value="String(formModel[col.field] ?? '')"
readonly
/>
<!-- hidden/divider/html 不渲染表单项 -->
</el-form-item>
</el-form>
<template #footer>
<el-button @click="emit('update:visible', false)">取消</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">保存</el-button>
</template>
</el-dialog>
</template>
+25 -216
View File
@@ -1,11 +1,12 @@
<script setup lang="ts" generic="T extends Record<string, unknown>"> <script setup lang="ts" generic="T extends Record<string, unknown>">
import { ref, computed, watch, onBeforeUnmount } from 'vue' import { ref, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage } from 'element-plus'
import { useCrudTable, type UseCrudTableOptions } from '@/composables/useCrudTable' import { useCrudTable, type UseCrudTableOptions } from '@/composables/useCrudTable'
import { useDraftAutosave } from '@/composables/useDraftAutosave'
import type { ColumnSchema } from '@/types/api' import type { ColumnSchema } from '@/types/api'
import { formatUtcTime, nowUtcString } from '@/utils/dateTime' import { formatUtcTime, nowUtcString } from '@/utils/dateTime'
import { formApi } from '@/api/form' import { formApi } from '@/api/form'
import EntityPicker from './EntityPicker.vue' import CrudFormPanel from './CrudFormPanel.vue'
/** 自动时间戳字段(CreateTime 等):由服务器填写,前端自动预填当前时间且不要求输入 */ /** 自动时间戳字段(CreateTime 等):由服务器填写,前端自动预填当前时间且不要求输入 */
function isAutoTimestampField(field: string): boolean { function isAutoTimestampField(field: string): boolean {
@@ -176,62 +177,16 @@ const formColumns = computed(() => {
.map((c: ColumnSchema) => (isAutoTimestampField(c.field) ? { ...c, required: false } : c)) .map((c: ColumnSchema) => (isAutoTimestampField(c.field) ? { ...c, required: false } : c))
}) })
// ===== 新增表单自动草稿(防止误关闭/误刷新丢失录入内容) ===== // ===== 新增表单自动草稿(独立 composable:useDraftAutosave) =====
// 按表维度存 localStorage:录入内容每 600ms 防抖自动保存; // 防止误关闭/误刷新丢失录入内容;按表维度存 localStorage,
// 再次打开"新增"时提示恢复或放弃;提交成功后清除。 // 再次打开"新增"时提示恢复或放弃;提交成功后清除。
const draftKey = computed(() => `gerp-draft:add:${props.table}`) const draft = useDraftAutosave({
table: props.table,
interface FormDraft { editingRow: editingRow as Record<string, unknown>,
data: Record<string, unknown> dialogVisible: () => dialogVisible.value,
updatedAt: number isEdit: () => isEdit.value,
}
function readDraft(): FormDraft | null {
try {
const raw = localStorage.getItem(draftKey.value)
return raw ? (JSON.parse(raw) as FormDraft) : null
} catch {
return null
}
}
function clearDraft(): void {
localStorage.removeItem(draftKey.value)
}
let draftTimer: ReturnType<typeof setTimeout> | null = null
/** 打开新增弹窗时的表单基线(JSON),内容未改动时不写草稿,避免"空草稿"打扰 */
let draftBaseline: string | null = null
function writeDraft(): void {
if (!dialogVisible.value || isEdit.value) return
try {
const cur = JSON.stringify({ ...editingRow })
if (draftBaseline !== null && cur === draftBaseline) return // 未录入任何内容,不存
localStorage.setItem(
draftKey.value,
JSON.stringify({ data: { ...editingRow }, updatedAt: Date.now() } satisfies FormDraft),
)
} catch {
/* 隐私模式/配额满时静默忽略 */
}
}
onBeforeUnmount(() => {
if (draftTimer) clearTimeout(draftTimer)
}) })
// 弹窗内表单内容变化 → 防抖自动存草稿(仅新增态;基线未变不写)
watch(
() => ({ ...editingRow }),
() => {
if (!dialogVisible.value || isEdit.value) return
if (draftTimer) clearTimeout(draftTimer)
draftTimer = setTimeout(writeDraft, 600)
},
{ deep: true },
)
// 新增:先走 useCrudTable 初始化,再注入父级上下文关联字段; // 新增:先走 useCrudTable 初始化,再注入父级上下文关联字段;
// 自动时间戳字段(CreateTime 等)预填当前 UTC 时间——不要求用户输入(后端仍以服务器时间为准); // 自动时间戳字段(CreateTime 等)预填当前 UTC 时间——不要求用户输入(后端仍以服务器时间为准);
// 若存在未提交草稿,提示恢复或放弃 // 若存在未提交草稿,提示恢复或放弃
@@ -244,20 +199,8 @@ async function onHandleCreate(): Promise<void> {
} }
} }
// 记录表单基线:未录入内容时不落盘草稿 // 记录表单基线:未录入内容时不落盘草稿
draftBaseline = JSON.stringify({ ...editingRow }) draft.markBaseline()
const draft = readDraft() await draft.restoreDraftIfAny()
if (!draft || !draft.data || Object.keys(draft.data).length === 0) return
try {
await ElMessageBox.confirm(
`检测到 ${new Date(draft.updatedAt).toLocaleString()} 未提交的草稿,是否恢复继续编辑?`,
'恢复草稿',
{ type: 'warning', confirmButtonText: '恢复草稿', cancelButtonText: '放弃' },
)
Object.assign(editingRow, draft.data)
draftBaseline = null // 恢复后允许继续增量保存
} catch {
clearDraft() // 用户放弃 → 清掉旧草稿
}
} }
// 提交:保证父级上下文关联字段始终存在(编辑时用户可能误删); // 提交:保证父级上下文关联字段始终存在(编辑时用户可能误删);
@@ -266,7 +209,7 @@ async function onHandleSubmit(): Promise<void> {
Object.assign(editingRow, inheritedQueryFields.value) Object.assign(editingRow, inheritedQueryFields.value)
try { try {
await handleSubmit() await handleSubmit()
clearDraft() draft.clearDraft()
} catch { } catch {
// 失败:保留草稿供再次提交 // 失败:保留草稿供再次提交
} }
@@ -337,12 +280,6 @@ function renderTags(value: unknown, separator = ','): string[] {
} }
// tags 表单:逗号分隔字符串 ↔ 多选数组互转 // tags 表单:逗号分隔字符串 ↔ 多选数组互转
function splitTags(value: unknown, separator = ','): string[] {
return renderTags(value, separator)
}
function joinTags(values: string[], separator = ','): string {
return (values || []).filter(Boolean).join(separator)
}
// richtext 预览:去除 HTML 标签保留纯文本,超长截断 // richtext 预览:去除 HTML 标签保留纯文本,超长截断
function stripHtml(value: unknown): string { function stripHtml(value: unknown): string {
@@ -817,145 +754,17 @@ function isCardSelected(row: Record<string, unknown>): boolean {
</div> </div>
</el-card> </el-card>
<!-- 新增/编辑弹窗 --> <!-- 新增/编辑弹窗(独立组件 CrudFormPanel:动态表单 + required 校验) -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px" destroy-on-close> <CrudFormPanel
<el-form :model="editingRow" label-width="120px" label-position="right"> :visible="dialogVisible"
<el-form-item :title="dialogTitle"
v-for="col in formColumns" :submitting="submitting"
:key="col.field" :columns="formColumns"
:label="col.title" :form-model="editingRow"
:required="col.required" @update:visible="(v: boolean) => (dialogVisible = v)"
> @submit="onHandleSubmit"
<!-- entityPicker:EntityPicker 组件(支持级联:dependsOn 字段值 + dependCompany 公司值) --> @picker-multi="handlePickerSubmitMulti"
<EntityPicker />
v-if="col.type === 'entityPicker' && col.entityPicker"
v-model="editingRow[col.field]"
:config="col.entityPicker"
:existing-label="(editingRow[`${col.field}_label`] as string) || (col.entityPicker.valueField === col.field ? (editingRow[col.field] as string) : '')"
:placeholder="col.placeholder"
:disabled="col.disabled"
:depend-value="col.entityPicker.dependsOn ? (editingRow[col.entityPicker.dependsOn] as string) : undefined"
:depend-company-value="col.entityPicker.dependCompany ? (editingRow.CompanyUUID as string) : undefined"
@update:label="(label: string) => (editingRow[`${col.field}_label`] = label)"
@submit-multi="(items) => handlePickerSubmitMulti(col.field, items)"
/>
<el-input
v-else-if="!col.type || col.type === 'text' || col.type === 'string'"
v-model="editingRow[col.field]"
:placeholder="col.placeholder"
:maxlength="col.maxLength"
:disabled="col.disabled"
/>
<el-input-number
v-else-if="col.type === 'number'"
v-model="editingRow[col.field]"
:min="col.min"
:max="col.max"
:step="col.step"
:disabled="col.disabled"
/>
<el-input
v-else-if="col.type === 'textarea'"
v-model="editingRow[col.field]"
type="textarea"
:rows="col.rows || 3"
:disabled="col.disabled"
/>
<!-- richtext:富文本编辑器(tinymce 自托管) -->
<RichTextEditor
v-else-if="col.type === 'richtext'"
v-model="editingRow[col.field]"
:placeholder="col.placeholder"
:rows="col.rows"
:disabled="col.disabled"
/>
<el-input
v-else-if="col.type === 'password'"
v-model="editingRow[col.field]"
type="password"
show-password
:placeholder="col.placeholder"
/>
<el-select
v-else-if="col.type === 'select'"
v-model="editingRow[col.field]"
:placeholder="col.placeholder"
:disabled="col.disabled"
style="width: 100%"
>
<el-option
v-for="opt in col.options"
:key="String(opt.value)"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<!-- tags:多选标签(预设可选项 + 允许自定义新增),逗号分隔存储 -->
<el-select
v-else-if="col.type === 'tags'"
:model-value="splitTags(editingRow[col.field], col.tagsSeparator)"
@update:model-value="(v: string[]) => (editingRow[col.field] = joinTags(v, col.tagsSeparator))"
:placeholder="col.placeholder"
:disabled="col.disabled"
multiple
filterable
allow-create
default-first-option
style="width: 100%"
>
<el-option
v-for="opt in col.options ?? []"
:key="String(opt.value)"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-radio-group
v-else-if="col.type === 'radio'"
v-model="editingRow[col.field]"
:disabled="col.disabled"
>
<el-radio v-for="opt in col.options" :key="String(opt.value)" :value="opt.value">{{ opt.label }}</el-radio>
</el-radio-group>
<el-checkbox-group
v-else-if="col.type === 'checkbox'"
v-model="editingRow[col.field]"
:disabled="col.disabled"
>
<el-checkbox v-for="opt in col.options" :key="String(opt.value)" :value="opt.value">{{ opt.label }}</el-checkbox>
</el-checkbox-group>
<el-date-picker
v-else-if="col.type === 'date' || col.type === 'datetime'"
v-model="editingRow[col.field]"
:type="col.type as 'date' | 'datetime'"
:placeholder="col.placeholder"
style="width: 100%"
/>
<el-time-picker
v-else-if="col.type === 'time'"
v-model="editingRow[col.field]"
:placeholder="col.placeholder"
style="width: 100%"
/>
<el-switch
v-else-if="col.type === 'switch' || col.type === 'boolean'"
v-model="editingRow[col.field]"
:disabled="col.disabled"
/>
<el-input
v-else-if="col.type === 'readonly'"
:model-value="String(editingRow[col.field] ?? '')"
readonly
/>
<!-- hidden/divider/html 不渲染表单项 -->
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="onHandleSubmit">保存</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
+98
View File
@@ -0,0 +1,98 @@
import { watch, onBeforeUnmount } from 'vue'
import { ElMessageBox } from 'element-plus'
/** localStorage 草稿结构 */
export interface FormDraft {
data: Record<string, unknown>
updatedAt: number
}
/**
* 新增表单自动草稿(防止误关闭/误刷新丢失录入内容)
*
* 按表维度存 localStorage:录入内容每 600ms 防抖自动保存;
* 再次打开"新增"时提示恢复或放弃;提交成功后清除。
*
* 从 CrudTable.vue 抽出的独立 composable(原 179-273 行逻辑)。
*/
export function useDraftAutosave(options: {
/** 表名(草稿存储 key 维度) */
table: string
/** 表单数据对象(reactive 引用,直接读写) */
editingRow: Record<string, unknown>
/** 弹窗是否可见(函数取最新值) */
dialogVisible: () => boolean
/** 是否编辑态(编辑态不存草稿) */
isEdit: () => boolean
}) {
const draftKey = `gerp-draft:add:${options.table}`
/** 打开新增弹窗时的表单基线(JSON),内容未改动时不写草稿,避免"空草稿"打扰 */
let draftBaseline: string | null = null
let draftTimer: ReturnType<typeof setTimeout> | null = null
function readDraft(): FormDraft | null {
try {
const raw = localStorage.getItem(draftKey)
return raw ? (JSON.parse(raw) as FormDraft) : null
} catch {
return null
}
}
function clearDraft(): void {
localStorage.removeItem(draftKey)
}
function writeDraft(): void {
if (!options.dialogVisible() || options.isEdit()) return
try {
const cur = JSON.stringify({ ...options.editingRow })
if (draftBaseline !== null && cur === draftBaseline) return // 未录入任何内容,不存
localStorage.setItem(
draftKey,
JSON.stringify({ data: { ...options.editingRow }, updatedAt: Date.now() } satisfies FormDraft),
)
} catch {
/* 隐私模式/配额满时静默忽略 */
}
}
onBeforeUnmount(() => {
if (draftTimer) clearTimeout(draftTimer)
})
// 弹窗内表单内容变化 → 防抖自动存草稿(仅新增态;基线未变不写)
watch(
() => ({ ...options.editingRow }),
() => {
if (!options.dialogVisible() || options.isEdit()) return
if (draftTimer) clearTimeout(draftTimer)
draftTimer = setTimeout(writeDraft, 600)
},
{ deep: true },
)
/** 记录表单基线:未录入内容时不落盘草稿(在新增弹窗初始化后调用) */
function markBaseline(): void {
draftBaseline = JSON.stringify({ ...options.editingRow })
}
/** 检测并提示恢复/放弃草稿;恢复则写入表单(在新增弹窗初始化后调用) */
async function restoreDraftIfAny(): Promise<void> {
const draft = readDraft()
if (!draft || !draft.data || Object.keys(draft.data).length === 0) return
try {
await ElMessageBox.confirm(
`检测到 ${new Date(draft.updatedAt).toLocaleString()} 未提交的草稿,是否恢复继续编辑?`,
'恢复草稿',
{ type: 'warning', confirmButtonText: '恢复草稿', cancelButtonText: '放弃' },
)
Object.assign(options.editingRow, draft.data)
draftBaseline = null // 恢复后允许继续增量保存
} catch {
clearDraft() // 用户放弃 → 清掉旧草稿
}
}
return { markBaseline, restoreDraftIfAny, clearDraft }
}