|
|
@@ -0,0 +1,355 @@
|
|
|
+<script setup lang="tsx">
|
|
|
+import { computed, nextTick, ref } from 'vue';
|
|
|
+import type { DataTableColumn } from 'naive-ui';
|
|
|
+import { NButton, NDivider, NForm, NFormItem, NImage, NInput, NTag } from 'naive-ui';
|
|
|
+import {
|
|
|
+ fetchDeleteOpinion,
|
|
|
+ fetchOpinionList,
|
|
|
+ fetchOpinionType,
|
|
|
+ fetchReplyOpinion
|
|
|
+} from '@/service/api/operation/opinion-feedback';
|
|
|
+import { useModal } from '@/components/zt/Modal/hooks/useModal';
|
|
|
+import { useTable } from '@/components/zt/Table/hooks/useTable';
|
|
|
+
|
|
|
+/** 解析图片字段:兼容 JSON 数组字符串、逗号分隔 URL、单个 URL */
|
|
|
+function parseImgUrls(raw: unknown): string[] {
|
|
|
+ if (!raw) return [];
|
|
|
+ if (Array.isArray(raw)) return raw.filter(Boolean);
|
|
|
+ if (typeof raw === 'string') {
|
|
|
+ try {
|
|
|
+ const parsed = JSON.parse(raw);
|
|
|
+ return Array.isArray(parsed) ? parsed.filter(Boolean) : [parsed];
|
|
|
+ } catch {
|
|
|
+ return raw
|
|
|
+ .split(',')
|
|
|
+ .map(s => s.trim())
|
|
|
+ .filter(Boolean);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return [String(raw)];
|
|
|
+}
|
|
|
+
|
|
|
+// ========== 回复弹窗状态 ==========
|
|
|
+const replyFormRef = ref<InstanceType<typeof NForm> | null>(null);
|
|
|
+const replyFormData = ref({ replyContent: '' });
|
|
|
+const currentReplyRow = ref<any>(null);
|
|
|
+
|
|
|
+const currentImages = computed(() => parseImgUrls(currentReplyRow.value?.img));
|
|
|
+
|
|
|
+// ========== 表格列定义 ==========
|
|
|
+const columns: DataTableColumn<any>[] = [
|
|
|
+ {
|
|
|
+ title: '序号',
|
|
|
+ key: 'index',
|
|
|
+ align: 'center',
|
|
|
+ width: 64,
|
|
|
+ render(_, index) {
|
|
|
+ return index + 1;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: '反馈类型',
|
|
|
+ key: 'opinionType',
|
|
|
+ align: 'center',
|
|
|
+ width: 100
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: '反馈描述',
|
|
|
+ key: 'feedbackDesc',
|
|
|
+ align: 'center',
|
|
|
+ ellipsis: { tooltip: true },
|
|
|
+ minWidth: 160
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: '图片',
|
|
|
+ key: 'img',
|
|
|
+ align: 'center',
|
|
|
+ width: 140,
|
|
|
+ render: row => {
|
|
|
+ const imgs = parseImgUrls(row.img);
|
|
|
+ if (!imgs.length) return '--';
|
|
|
+ return (
|
|
|
+ <div class="flex-center gap-6px">
|
|
|
+ {imgs.slice(0, 3).map((url: string) => (
|
|
|
+ <NImage
|
|
|
+ src={url}
|
|
|
+ width={44}
|
|
|
+ height={44}
|
|
|
+ style="border-radius: 4px; object-fit: cover; cursor: pointer; border: 1px solid #f0f0f0;"
|
|
|
+ />
|
|
|
+ ))}
|
|
|
+ {imgs.length > 3 && <span class="text-12px text-#999">+{imgs.length - 3}</span>}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: '反馈用户',
|
|
|
+ key: 'mobile',
|
|
|
+ align: 'center',
|
|
|
+ width: 100,
|
|
|
+ ellipsis: { tooltip: true }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: '联系电话',
|
|
|
+ key: 'contact',
|
|
|
+ align: 'center',
|
|
|
+ width: 130
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: '回复内容',
|
|
|
+ key: 'replyContent',
|
|
|
+ align: 'center',
|
|
|
+ ellipsis: { tooltip: true },
|
|
|
+ minWidth: 140
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: '回复时间',
|
|
|
+ key: 'replyTime',
|
|
|
+ align: 'center',
|
|
|
+ width: 170
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: '回复状态',
|
|
|
+ key: 'replyStatus',
|
|
|
+ align: 'center',
|
|
|
+ width: 100,
|
|
|
+ render: row => {
|
|
|
+ if (row.replyStatus === 1) {
|
|
|
+ return <NTag type="success">已回复</NTag>;
|
|
|
+ }
|
|
|
+ return <NTag type="error">未回复</NTag>;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: '创建时间',
|
|
|
+ key: 'createTime',
|
|
|
+ align: 'center',
|
|
|
+ width: 170
|
|
|
+ }
|
|
|
+];
|
|
|
+
|
|
|
+// ========== 表格 ==========
|
|
|
+const [registerTable, { refresh }] = useTable({
|
|
|
+ searchFormConfig: {
|
|
|
+ schemas: [
|
|
|
+ {
|
|
|
+ field: 'opinionType',
|
|
|
+ label: '反馈类型',
|
|
|
+ component: 'ApiSelect',
|
|
|
+ componentProps: {
|
|
|
+ api: fetchOpinionType,
|
|
|
+ labelFeild: 'name',
|
|
|
+ valueFeild: 'name'
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ field: 'mobile',
|
|
|
+ label: '反馈用户',
|
|
|
+ component: 'NInput',
|
|
|
+ componentProps: {
|
|
|
+ placeholder: '请输入反馈用户',
|
|
|
+ clearable: true
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ field: 'contact',
|
|
|
+ label: '联系电话',
|
|
|
+ component: 'NInput',
|
|
|
+ componentProps: {
|
|
|
+ placeholder: '请输入联系电话',
|
|
|
+ clearable: true
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ field: 'createTime',
|
|
|
+ label: '反馈时间',
|
|
|
+ component: 'NDatePicker',
|
|
|
+ componentProps: {
|
|
|
+ type: 'datetimerange',
|
|
|
+ defaultTime: ['00:00:00', '23:59:59']
|
|
|
+ }
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ inline: false,
|
|
|
+ size: 'small',
|
|
|
+ labelPlacement: 'left',
|
|
|
+ isFull: false
|
|
|
+ },
|
|
|
+ tableConfig: {
|
|
|
+ keyField: 'id',
|
|
|
+ title: '意见反馈列表',
|
|
|
+ scrollX: 1500,
|
|
|
+ showAddButton: false,
|
|
|
+ opWdith: 150,
|
|
|
+ fieldMapToTime: [['createTime', ['startTime', 'endTime']]]
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+// ========== 回复弹窗 ==========
|
|
|
+const [registerReplyModal, { openModal: openReplyModal, closeModal: closeReplyModal, setSubLoading }] = useModal({
|
|
|
+ title: '回复',
|
|
|
+ width: 600,
|
|
|
+ showFooter: true,
|
|
|
+ subBtuText: '确定'
|
|
|
+});
|
|
|
+
|
|
|
+function handleReply(row: any) {
|
|
|
+ currentReplyRow.value = row;
|
|
|
+ replyFormData.value = { replyContent: '' };
|
|
|
+ nextTick(() => {
|
|
|
+ openReplyModal();
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+async function handleReplySubmit() {
|
|
|
+ if (!replyFormData.value.replyContent.trim()) {
|
|
|
+ window.$message?.error('请输入回复内容');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ setSubLoading(true);
|
|
|
+ const { error } = await fetchReplyOpinion({
|
|
|
+ id: currentReplyRow.value.id,
|
|
|
+ replyContent: replyFormData.value.replyContent
|
|
|
+ });
|
|
|
+ setSubLoading(false);
|
|
|
+ if (!error) {
|
|
|
+ window.$message?.success('回复成功');
|
|
|
+ closeReplyModal();
|
|
|
+ refresh();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function handleDelete(row: any) {
|
|
|
+ window.$dialog?.info({
|
|
|
+ title: '删除确认',
|
|
|
+ content: '确定要删除该反馈吗?',
|
|
|
+ positiveText: '确定',
|
|
|
+ negativeText: '取消',
|
|
|
+ onPositiveClick: async () => {
|
|
|
+ const { error } = await fetchDeleteOpinion(row.id);
|
|
|
+ if (!error) {
|
|
|
+ window.$message?.success('删除成功');
|
|
|
+ refresh();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <LayoutTable>
|
|
|
+ <ZTable :columns="columns" :api="fetchOpinionList" @register="registerTable">
|
|
|
+ <template #op="{ row }">
|
|
|
+ <template v-if="row.replyStatus === 1">
|
|
|
+ <NButton size="small" type="error" ghost @click="handleDelete(row)">删除</NButton>
|
|
|
+ </template>
|
|
|
+ <template v-else>
|
|
|
+ <NButton size="small" type="primary" ghost @click="handleReply(row)">回复</NButton>
|
|
|
+ <NButton size="small" type="error" ghost @click="handleDelete(row)">删除</NButton>
|
|
|
+ </template>
|
|
|
+ </template>
|
|
|
+ </ZTable>
|
|
|
+
|
|
|
+ <BasicModal @register="registerReplyModal" @ok="handleReplySubmit">
|
|
|
+ <template #default>
|
|
|
+ <div v-if="currentReplyRow" class="reply-content">
|
|
|
+ <!-- 只读信息展示 -->
|
|
|
+ <div class="info-grid">
|
|
|
+ <div class="info-item">
|
|
|
+ <span class="info-label">反馈类型</span>
|
|
|
+ <span class="info-value">{{ currentReplyRow.opinionType }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="info-item">
|
|
|
+ <span class="info-label">反馈描述</span>
|
|
|
+ <span class="info-value">{{ currentReplyRow.feedbackDesc }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="info-item">
|
|
|
+ <span class="info-label">图片</span>
|
|
|
+ <div class="info-value">
|
|
|
+ <template v-if="currentImages.length">
|
|
|
+ <NImage
|
|
|
+ v-for="(url, idx) in currentImages"
|
|
|
+ :key="idx"
|
|
|
+ :src="url"
|
|
|
+ width="72"
|
|
|
+ height="72"
|
|
|
+ class="preview-img"
|
|
|
+ />
|
|
|
+ </template>
|
|
|
+ <span v-else class="text-#999">暂无图片</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <NDivider />
|
|
|
+
|
|
|
+ <!-- 回复输入 -->
|
|
|
+ <NForm ref="replyFormRef" :model="replyFormData" label-placement="top">
|
|
|
+ <NFormItem
|
|
|
+ label="反馈回复"
|
|
|
+ path="replyContent"
|
|
|
+ :rule="{
|
|
|
+ required: true,
|
|
|
+ message: '请输入回复内容',
|
|
|
+ trigger: 'blur'
|
|
|
+ }"
|
|
|
+ >
|
|
|
+ <NInput
|
|
|
+ v-model:value="replyFormData.replyContent"
|
|
|
+ type="textarea"
|
|
|
+ placeholder="请输入回复"
|
|
|
+ :rows="4"
|
|
|
+ :maxlength="500"
|
|
|
+ show-count
|
|
|
+ />
|
|
|
+ </NFormItem>
|
|
|
+ </NForm>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </BasicModal>
|
|
|
+ </LayoutTable>
|
|
|
+</template>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.reply-content {
|
|
|
+ padding: 0 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.info-grid {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 14px;
|
|
|
+}
|
|
|
+
|
|
|
+.info-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: flex-start;
|
|
|
+ gap: 12px;
|
|
|
+ font-size: 14px;
|
|
|
+ line-height: 1.6;
|
|
|
+}
|
|
|
+
|
|
|
+.info-label {
|
|
|
+ flex-shrink: 0;
|
|
|
+ min-width: 76px;
|
|
|
+ font-weight: 500;
|
|
|
+ color: #333;
|
|
|
+ text-align: right;
|
|
|
+}
|
|
|
+
|
|
|
+.info-value {
|
|
|
+ flex: 1;
|
|
|
+ color: #666;
|
|
|
+ word-break: break-all;
|
|
|
+}
|
|
|
+
|
|
|
+.preview-img {
|
|
|
+ border-radius: 4px;
|
|
|
+ object-fit: cover;
|
|
|
+ cursor: pointer;
|
|
|
+ border: 1px solid #f0f0f0;
|
|
|
+ margin-right: 8px;
|
|
|
+ margin-bottom: 8px;
|
|
|
+}
|
|
|
+</style>
|