index.vue 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. <script setup lang="tsx">
  2. import { computed, nextTick, ref } from 'vue';
  3. import type { DataTableColumn } from 'naive-ui';
  4. import { NButton, NDivider, NForm, NFormItem, NImage, NInput, NTag } from 'naive-ui';
  5. import {
  6. fetchDeleteOpinion,
  7. fetchOpinionList,
  8. fetchOpinionType,
  9. fetchReplyOpinion
  10. } from '@/service/api/operation/opinion-feedback';
  11. import { useModal } from '@/components/zt/Modal/hooks/useModal';
  12. import { useTable } from '@/components/zt/Table/hooks/useTable';
  13. /** 解析图片字段:兼容 JSON 数组字符串、逗号分隔 URL、单个 URL */
  14. function parseImgUrls(raw: unknown): string[] {
  15. if (!raw) return [];
  16. if (Array.isArray(raw)) return raw.filter(Boolean);
  17. if (typeof raw === 'string') {
  18. try {
  19. const parsed = JSON.parse(raw);
  20. return Array.isArray(parsed) ? parsed.filter(Boolean) : [parsed];
  21. } catch {
  22. return raw
  23. .split(',')
  24. .map(s => s.trim())
  25. .filter(Boolean);
  26. }
  27. }
  28. return [String(raw)];
  29. }
  30. // ========== 回复弹窗状态 ==========
  31. const replyFormRef = ref<InstanceType<typeof NForm> | null>(null);
  32. const replyFormData = ref({ replyContent: '' });
  33. const currentReplyRow = ref<any>(null);
  34. const currentImages = computed(() => parseImgUrls(currentReplyRow.value?.img));
  35. // ========== 表格列定义 ==========
  36. const columns: DataTableColumn<any>[] = [
  37. {
  38. title: '序号',
  39. key: 'index',
  40. align: 'center',
  41. width: 64,
  42. render(_, index) {
  43. return index + 1;
  44. }
  45. },
  46. {
  47. title: '反馈类型',
  48. key: 'opinionType',
  49. align: 'center',
  50. width: 100
  51. },
  52. {
  53. title: '反馈描述',
  54. key: 'feedbackDesc',
  55. align: 'center',
  56. ellipsis: { tooltip: true },
  57. minWidth: 160
  58. },
  59. {
  60. title: '图片',
  61. key: 'img',
  62. align: 'center',
  63. width: 140,
  64. render: row => {
  65. const imgs = parseImgUrls(row.img);
  66. if (!imgs.length) return '--';
  67. return (
  68. <div class="flex-center gap-6px">
  69. {imgs.slice(0, 3).map((url: string) => (
  70. <NImage
  71. src={url}
  72. width={44}
  73. height={44}
  74. style="border-radius: 4px; object-fit: cover; cursor: pointer; border: 1px solid #f0f0f0;"
  75. />
  76. ))}
  77. {imgs.length > 3 && <span class="text-12px text-#999">+{imgs.length - 3}</span>}
  78. </div>
  79. );
  80. }
  81. },
  82. {
  83. title: '反馈用户',
  84. key: 'mobile',
  85. align: 'center',
  86. width: 100,
  87. ellipsis: { tooltip: true }
  88. },
  89. {
  90. title: '联系电话',
  91. key: 'contact',
  92. align: 'center',
  93. width: 130
  94. },
  95. {
  96. title: '回复内容',
  97. key: 'replyContent',
  98. align: 'center',
  99. ellipsis: { tooltip: true },
  100. minWidth: 140
  101. },
  102. {
  103. title: '回复时间',
  104. key: 'replyTime',
  105. align: 'center',
  106. width: 170
  107. },
  108. {
  109. title: '回复状态',
  110. key: 'replyStatus',
  111. align: 'center',
  112. width: 100,
  113. render: row => {
  114. if (row.replyStatus === 1) {
  115. return <NTag type="success">已回复</NTag>;
  116. }
  117. return <NTag type="error">未回复</NTag>;
  118. }
  119. },
  120. {
  121. title: '创建时间',
  122. key: 'createTime',
  123. align: 'center',
  124. width: 170
  125. }
  126. ];
  127. // ========== 表格 ==========
  128. const [registerTable, { refresh }] = useTable({
  129. searchFormConfig: {
  130. schemas: [
  131. {
  132. field: 'opinionType',
  133. label: '反馈类型',
  134. component: 'ApiSelect',
  135. componentProps: {
  136. api: fetchOpinionType,
  137. labelFeild: 'name',
  138. valueFeild: 'name'
  139. }
  140. },
  141. {
  142. field: 'mobile',
  143. label: '反馈用户',
  144. component: 'NInput',
  145. componentProps: {
  146. placeholder: '请输入反馈用户',
  147. clearable: true
  148. }
  149. },
  150. {
  151. field: 'contact',
  152. label: '联系电话',
  153. component: 'NInput',
  154. componentProps: {
  155. placeholder: '请输入联系电话',
  156. clearable: true
  157. }
  158. },
  159. {
  160. field: 'createTime',
  161. label: '反馈时间',
  162. component: 'NDatePicker',
  163. componentProps: {
  164. type: 'datetimerange',
  165. defaultTime: ['00:00:00', '23:59:59']
  166. }
  167. }
  168. ],
  169. inline: false,
  170. size: 'small',
  171. labelPlacement: 'left',
  172. isFull: false
  173. },
  174. tableConfig: {
  175. keyField: 'id',
  176. title: '意见反馈列表',
  177. scrollX: 1500,
  178. showAddButton: false,
  179. opWdith: 150,
  180. fieldMapToTime: [['createTime', ['startTime', 'endTime']]]
  181. }
  182. });
  183. // ========== 回复弹窗 ==========
  184. const [registerReplyModal, { openModal: openReplyModal, closeModal: closeReplyModal, setSubLoading }] = useModal({
  185. title: '回复',
  186. width: 600,
  187. showFooter: true,
  188. subBtuText: '确定'
  189. });
  190. function handleReply(row: any) {
  191. currentReplyRow.value = row;
  192. replyFormData.value = { replyContent: '' };
  193. nextTick(() => {
  194. openReplyModal();
  195. });
  196. }
  197. async function handleReplySubmit() {
  198. if (!replyFormData.value.replyContent.trim()) {
  199. window.$message?.error('请输入回复内容');
  200. return;
  201. }
  202. setSubLoading(true);
  203. const { error } = await fetchReplyOpinion({
  204. id: currentReplyRow.value.id,
  205. replyContent: replyFormData.value.replyContent
  206. });
  207. setSubLoading(false);
  208. if (!error) {
  209. window.$message?.success('回复成功');
  210. closeReplyModal();
  211. refresh();
  212. }
  213. }
  214. function handleDelete(row: any) {
  215. window.$dialog?.info({
  216. title: '删除确认',
  217. content: '确定要删除该反馈吗?',
  218. positiveText: '确定',
  219. negativeText: '取消',
  220. onPositiveClick: async () => {
  221. const { error } = await fetchDeleteOpinion(row.id);
  222. if (!error) {
  223. window.$message?.success('删除成功');
  224. refresh();
  225. }
  226. }
  227. });
  228. }
  229. </script>
  230. <template>
  231. <LayoutTable>
  232. <ZTable :columns="columns" :api="fetchOpinionList" @register="registerTable">
  233. <template #op="{ row }">
  234. <template v-if="row.replyStatus === 1">
  235. <NButton size="small" type="error" ghost @click="handleDelete(row)">删除</NButton>
  236. </template>
  237. <template v-else>
  238. <NButton size="small" type="primary" ghost @click="handleReply(row)">回复</NButton>
  239. <NButton size="small" type="error" ghost @click="handleDelete(row)">删除</NButton>
  240. </template>
  241. </template>
  242. </ZTable>
  243. <BasicModal @register="registerReplyModal" @ok="handleReplySubmit">
  244. <template #default>
  245. <div v-if="currentReplyRow" class="reply-content">
  246. <!-- 只读信息展示 -->
  247. <div class="info-grid">
  248. <div class="info-item">
  249. <span class="info-label">反馈类型</span>
  250. <span class="info-value">{{ currentReplyRow.opinionType }}</span>
  251. </div>
  252. <div class="info-item">
  253. <span class="info-label">反馈描述</span>
  254. <span class="info-value">{{ currentReplyRow.feedbackDesc }}</span>
  255. </div>
  256. <div class="info-item">
  257. <span class="info-label">图片</span>
  258. <div class="info-value">
  259. <template v-if="currentImages.length">
  260. <NImage
  261. v-for="(url, idx) in currentImages"
  262. :key="idx"
  263. :src="url"
  264. width="72"
  265. height="72"
  266. class="preview-img"
  267. />
  268. </template>
  269. <span v-else class="text-#999">暂无图片</span>
  270. </div>
  271. </div>
  272. </div>
  273. <NDivider />
  274. <!-- 回复输入 -->
  275. <NForm ref="replyFormRef" :model="replyFormData" label-placement="top">
  276. <NFormItem
  277. label="反馈回复"
  278. path="replyContent"
  279. :rule="{
  280. required: true,
  281. message: '请输入回复内容',
  282. trigger: 'blur'
  283. }"
  284. >
  285. <NInput
  286. v-model:value="replyFormData.replyContent"
  287. type="textarea"
  288. placeholder="请输入回复"
  289. :rows="4"
  290. :maxlength="500"
  291. show-count
  292. />
  293. </NFormItem>
  294. </NForm>
  295. </div>
  296. </template>
  297. </BasicModal>
  298. </LayoutTable>
  299. </template>
  300. <style scoped>
  301. .reply-content {
  302. padding: 0 4px;
  303. }
  304. .info-grid {
  305. display: flex;
  306. flex-direction: column;
  307. gap: 14px;
  308. }
  309. .info-item {
  310. display: flex;
  311. align-items: flex-start;
  312. gap: 12px;
  313. font-size: 14px;
  314. line-height: 1.6;
  315. }
  316. .info-label {
  317. flex-shrink: 0;
  318. min-width: 76px;
  319. font-weight: 500;
  320. color: #333;
  321. text-align: right;
  322. }
  323. .info-value {
  324. flex: 1;
  325. color: #666;
  326. word-break: break-all;
  327. }
  328. .preview-img {
  329. border-radius: 4px;
  330. object-fit: cover;
  331. cursor: pointer;
  332. border: 1px solid #f0f0f0;
  333. margin-right: 8px;
  334. margin-bottom: 8px;
  335. }
  336. </style>