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>">
|
||||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useCrudTable, type UseCrudTableOptions } from '@/composables/useCrudTable'
|
||||
import { useDraftAutosave } from '@/composables/useDraftAutosave'
|
||||
import type { ColumnSchema } from '@/types/api'
|
||||
import { formatUtcTime, nowUtcString } from '@/utils/dateTime'
|
||||
import { formApi } from '@/api/form'
|
||||
import EntityPicker from './EntityPicker.vue'
|
||||
import CrudFormPanel from './CrudFormPanel.vue'
|
||||
|
||||
/** 自动时间戳字段(CreateTime 等):由服务器填写,前端自动预填当前时间且不要求输入 */
|
||||
function isAutoTimestampField(field: string): boolean {
|
||||
@@ -176,62 +177,16 @@ const formColumns = computed(() => {
|
||||
.map((c: ColumnSchema) => (isAutoTimestampField(c.field) ? { ...c, required: false } : c))
|
||||
})
|
||||
|
||||
// ===== 新增表单自动草稿(防止误关闭/误刷新丢失录入内容) =====
|
||||
// 按表维度存 localStorage:录入内容每 600ms 防抖自动保存;
|
||||
// ===== 新增表单自动草稿(独立 composable:useDraftAutosave) =====
|
||||
// 防止误关闭/误刷新丢失录入内容;按表维度存 localStorage,
|
||||
// 再次打开"新增"时提示恢复或放弃;提交成功后清除。
|
||||
const draftKey = computed(() => `gerp-draft:add:${props.table}`)
|
||||
|
||||
interface FormDraft {
|
||||
data: Record<string, unknown>
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
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)
|
||||
const draft = useDraftAutosave({
|
||||
table: props.table,
|
||||
editingRow: editingRow as Record<string, unknown>,
|
||||
dialogVisible: () => dialogVisible.value,
|
||||
isEdit: () => isEdit.value,
|
||||
})
|
||||
|
||||
// 弹窗内表单内容变化 → 防抖自动存草稿(仅新增态;基线未变不写)
|
||||
watch(
|
||||
() => ({ ...editingRow }),
|
||||
() => {
|
||||
if (!dialogVisible.value || isEdit.value) return
|
||||
if (draftTimer) clearTimeout(draftTimer)
|
||||
draftTimer = setTimeout(writeDraft, 600)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// 新增:先走 useCrudTable 初始化,再注入父级上下文关联字段;
|
||||
// 自动时间戳字段(CreateTime 等)预填当前 UTC 时间——不要求用户输入(后端仍以服务器时间为准);
|
||||
// 若存在未提交草稿,提示恢复或放弃
|
||||
@@ -244,20 +199,8 @@ async function onHandleCreate(): Promise<void> {
|
||||
}
|
||||
}
|
||||
// 记录表单基线:未录入内容时不落盘草稿
|
||||
draftBaseline = JSON.stringify({ ...editingRow })
|
||||
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(editingRow, draft.data)
|
||||
draftBaseline = null // 恢复后允许继续增量保存
|
||||
} catch {
|
||||
clearDraft() // 用户放弃 → 清掉旧草稿
|
||||
}
|
||||
draft.markBaseline()
|
||||
await draft.restoreDraftIfAny()
|
||||
}
|
||||
|
||||
// 提交:保证父级上下文关联字段始终存在(编辑时用户可能误删);
|
||||
@@ -266,7 +209,7 @@ async function onHandleSubmit(): Promise<void> {
|
||||
Object.assign(editingRow, inheritedQueryFields.value)
|
||||
try {
|
||||
await handleSubmit()
|
||||
clearDraft()
|
||||
draft.clearDraft()
|
||||
} catch {
|
||||
// 失败:保留草稿供再次提交
|
||||
}
|
||||
@@ -337,12 +280,6 @@ function renderTags(value: unknown, separator = ','): string[] {
|
||||
}
|
||||
|
||||
// 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 标签保留纯文本,超长截断
|
||||
function stripHtml(value: unknown): string {
|
||||
@@ -817,145 +754,17 @@ function isCardSelected(row: Record<string, unknown>): boolean {
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px" destroy-on-close>
|
||||
<el-form :model="editingRow" label-width="120px" label-position="right">
|
||||
<el-form-item
|
||||
v-for="col in formColumns"
|
||||
:key="col.field"
|
||||
:label="col.title"
|
||||
:required="col.required"
|
||||
>
|
||||
<!-- entityPicker:EntityPicker 组件(支持级联:dependsOn 字段值 + dependCompany 公司值) -->
|
||||
<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)"
|
||||
<!-- 新增/编辑弹窗(独立组件 CrudFormPanel:动态表单 + required 校验) -->
|
||||
<CrudFormPanel
|
||||
:visible="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
:submitting="submitting"
|
||||
:columns="formColumns"
|
||||
:form-model="editingRow"
|
||||
@update:visible="(v: boolean) => (dialogVisible = v)"
|
||||
@submit="onHandleSubmit"
|
||||
@picker-multi="handlePickerSubmitMulti"
|
||||
/>
|
||||
<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>
|
||||
</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