253 lines
7.3 KiB
Vue
253 lines
7.3 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||
import { FEEDBACK_TYPE_DICT_CODE } from '@/constants/dict';
|
||
import { fetchCreateFeedback, fetchUpdateFeedback } from '@/service/api';
|
||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
|
||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||
import BusinessFormSection from '@/components/custom/business-form-section.vue';
|
||
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
|
||
import DictSelect from '@/components/custom/dict-select.vue';
|
||
|
||
defineOptions({ name: 'FeedbackOperateDialog' });
|
||
|
||
type OperateMode = 'create' | 'edit';
|
||
|
||
interface Props {
|
||
mode: OperateMode;
|
||
rowData?: Api.Feedback.FeedbackItem | null;
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
rowData: null
|
||
});
|
||
|
||
const emit = defineEmits<{
|
||
submitted: [];
|
||
}>();
|
||
|
||
const visible = defineModel<boolean>('visible', { default: false });
|
||
|
||
const { formRef, validate } = useForm();
|
||
const { createRequiredRule } = useFormRules();
|
||
|
||
const submitting = ref(false);
|
||
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
|
||
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
|
||
|
||
interface FormModel {
|
||
/** DictSelect 选中后为字符串;提交时 Number() 转字典 Integer(非 ID,允许转 number) */
|
||
type: string | number | null;
|
||
title: string;
|
||
content: string;
|
||
contact: string;
|
||
attachments: Api.Project.AttachmentItem[];
|
||
}
|
||
|
||
const model = reactive<FormModel>({
|
||
type: null,
|
||
title: '',
|
||
content: '',
|
||
contact: '',
|
||
attachments: []
|
||
});
|
||
|
||
const dialogTitle = computed(() => (props.mode === 'create' ? '提交意见反馈' : '编辑意见反馈'));
|
||
|
||
/** 富文本是否为空:去标签去 后无文本,且无图片 */
|
||
function isEmptyRichText(html: string | null | undefined) {
|
||
if (!html) {
|
||
return true;
|
||
}
|
||
const text = html
|
||
.replace(/<[^>]+>/g, '')
|
||
.replace(/ /g, '')
|
||
.trim();
|
||
if (text) {
|
||
return false;
|
||
}
|
||
return !/<img\b/i.test(html);
|
||
}
|
||
|
||
const rules = computed(
|
||
() =>
|
||
({
|
||
type: [createRequiredRule('请选择反馈分类')],
|
||
title: [createRequiredRule('请输入标题')],
|
||
content: [
|
||
{
|
||
validator: (_rule, _value, callback) => {
|
||
if (isEmptyRichText(model.content)) {
|
||
callback(new Error('请输入详细描述'));
|
||
return;
|
||
}
|
||
callback();
|
||
},
|
||
trigger: 'blur'
|
||
}
|
||
]
|
||
}) satisfies Record<string, App.Global.FormRule[]>
|
||
);
|
||
|
||
function applyRowDataToModel() {
|
||
const row = props.rowData;
|
||
if (props.mode === 'edit' && row) {
|
||
// 字典选项 value 为字符串,回显需转字符串才能与下拉项严格匹配(否则显示原始数字)
|
||
model.type = row.type === null || row.type === undefined ? null : String(row.type);
|
||
model.title = row.title ?? '';
|
||
model.content = row.content ?? '';
|
||
model.contact = row.contact ?? '';
|
||
model.attachments = row.attachments ? [...row.attachments] : [];
|
||
return;
|
||
}
|
||
model.type = null;
|
||
model.title = '';
|
||
model.content = '';
|
||
model.contact = '';
|
||
model.attachments = [];
|
||
}
|
||
|
||
async function handleConfirm() {
|
||
await validate();
|
||
|
||
if (attachmentUploaderRef.value?.hasUploading) {
|
||
window.$message?.warning('附件还在上传中,请稍候');
|
||
return;
|
||
}
|
||
|
||
submitting.value = true;
|
||
|
||
const basePayload: Api.Feedback.FeedbackSubmitParams = {
|
||
type: Number(model.type),
|
||
title: model.title.trim(),
|
||
content: model.content,
|
||
contact: model.contact.trim() || undefined,
|
||
attachments: [...model.attachments]
|
||
};
|
||
|
||
const { error } =
|
||
props.mode === 'edit' && props.rowData
|
||
? await fetchUpdateFeedback({ id: props.rowData.id, ...basePayload })
|
||
: await fetchCreateFeedback(basePayload);
|
||
|
||
submitting.value = false;
|
||
|
||
if (error) {
|
||
return;
|
||
}
|
||
|
||
// 成功后先 commit,置 committed=true,避免 destroy-on-close 触发 rollback 误删已存文件/图片
|
||
await Promise.all([attachmentUploaderRef.value?.commit(), richTextEditorRef.value?.commit()]);
|
||
window.$message?.success(props.mode === 'edit' ? '保存成功' : '提交成功');
|
||
visible.value = false;
|
||
emit('submitted');
|
||
}
|
||
|
||
async function handleCancel() {
|
||
await Promise.all([attachmentUploaderRef.value?.rollback(), richTextEditorRef.value?.rollback()]);
|
||
}
|
||
|
||
watch(visible, async value => {
|
||
if (!value) {
|
||
return;
|
||
}
|
||
applyRowDataToModel();
|
||
await nextTick();
|
||
// 让附件 / 富文本组件把当前 model 视作 original,必须在 model 填充之后
|
||
attachmentUploaderRef.value?.initSession();
|
||
richTextEditorRef.value?.initSession();
|
||
formRef.value?.clearValidate();
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<BusinessFormDialog
|
||
v-model="visible"
|
||
:title="dialogTitle"
|
||
width="980px"
|
||
max-body-height="76vh"
|
||
:confirm-loading="submitting"
|
||
@confirm="handleConfirm"
|
||
@cancel="handleCancel"
|
||
>
|
||
<ElForm
|
||
ref="formRef"
|
||
:model="model"
|
||
:rules="rules"
|
||
label-position="top"
|
||
:validate-on-rule-change="false"
|
||
class="feedback-operate-dialog__form"
|
||
>
|
||
<div class="feedback-operate-dialog__grid">
|
||
<div class="feedback-operate-dialog__col-left">
|
||
<BusinessFormSection title="反馈信息">
|
||
<ElFormItem label="反馈分类" prop="type">
|
||
<DictSelect v-model="model.type" :dict-code="FEEDBACK_TYPE_DICT_CODE" placeholder="请选择反馈分类" />
|
||
</ElFormItem>
|
||
|
||
<ElFormItem label="标题" prop="title">
|
||
<ElInput v-model="model.title" maxlength="100" show-word-limit placeholder="请输入标题" />
|
||
</ElFormItem>
|
||
|
||
<ElFormItem label="联系方式">
|
||
<ElInput v-model="model.contact" maxlength="100" placeholder="选填,方便我们联系你" />
|
||
</ElFormItem>
|
||
</BusinessFormSection>
|
||
|
||
<BusinessFormSection title="附件">
|
||
<ElFormItem class="feedback-operate-dialog__attachment-item">
|
||
<BusinessAttachmentUploader
|
||
ref="attachmentUploaderRef"
|
||
v-model="model.attachments"
|
||
directory="feedback"
|
||
/>
|
||
</ElFormItem>
|
||
</BusinessFormSection>
|
||
</div>
|
||
|
||
<div class="feedback-operate-dialog__col-right">
|
||
<BusinessFormSection title="详细描述">
|
||
<ElFormItem prop="content" class="feedback-operate-dialog__desc-item">
|
||
<BusinessRichTextEditor
|
||
ref="richTextEditorRef"
|
||
v-model="model.content"
|
||
:height="380"
|
||
upload-directory="feedback"
|
||
placeholder="请输入详细描述"
|
||
/>
|
||
</ElFormItem>
|
||
</BusinessFormSection>
|
||
</div>
|
||
</div>
|
||
</ElForm>
|
||
</BusinessFormDialog>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.feedback-operate-dialog__grid {
|
||
display: grid;
|
||
grid-template-columns: 320px 1fr;
|
||
gap: 24px;
|
||
align-items: start;
|
||
}
|
||
|
||
.feedback-operate-dialog__col-left,
|
||
.feedback-operate-dialog__col-right {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.feedback-operate-dialog__desc-item,
|
||
.feedback-operate-dialog__attachment-item {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
@media (width <= 1024px) {
|
||
.feedback-operate-dialog__grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|