| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355 |
- <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>
|