feat(projects): 增加意见反馈

This commit is contained in:
2026-06-27 08:55:33 +08:00
parent 570f284230
commit a4884035cd
25 changed files with 1536 additions and 7 deletions

View File

@@ -0,0 +1,252 @@
<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' ? '提交意见反馈' : '编辑意见反馈'));
/** 富文本是否为空:去标签去 &nbsp; 后无文本,且无图片 */
function isEmptyRichText(html: string | null | undefined) {
if (!html) {
return true;
}
const text = html
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/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>