refactor(frontend): CrudTable 拆分——抽取 CrudFormPanel 动态表单与 useDraftAutosave 草稿 composable
This commit is contained in:
1 parent
ce7a17d170
commit
36afde19da
3 files changed
+352
-215
No files matched your search
@@ -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>
|
||||||
+24
-215
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
Reference in new issue
Block a user