Browse Source

```
feat(router): 添加意见反馈路由配置

添加了意见反馈模块的路由配置,包括导入组件、路径映射和类型定义

feat(i18n): 添加意见反馈国际化支持

在中英文语言包中添加了意见反馈相关的国际化文本

feat(goods-center): 商品中心表格添加收藏量排序功能

在商品中心的商品表格中新增收藏量列,支持点击表头进行升序、降序和默认排序,
同时调整表格宽度以适应新增列

chore(config): 更新测试环境API基础URL配置

注释掉旧的测试服务器地址,启用新的测试服务器地址用于打包测试
```

wenjie 14 hours ago
parent
commit
2c14a33046

+ 2 - 2
.env.test

@@ -1,7 +1,7 @@
 # backend service base url, test environment
 
 # VITE_SERVICE_BASE_URL=https://522d2ea1.r39.cpolar.top #王
-VITE_SERVICE_BASE_URL=http://89561bkaq794.vicp.fun #张
+# VITE_SERVICE_BASE_URL=http://89561bkaq794.vicp.fun #张
  #VITE_SERVICE_BASE_URL=https://25740642.r3.cpolar.top #田
 # VITE_SERVICE_BASE_URL=https://749885d7.r16.cpolar.top #邓
 # VITE_SERVICE_BASE_URL=http://74949mkfh190.vicp.fun #付
@@ -10,7 +10,7 @@ VITE_SERVICE_BASE_URL=http://89561bkaq794.vicp.fun #张
 # VITE_SERVICE_BASE_URL=http://192.168.0.11:8081 #wzq
 # VITE_SERVICE_BASE_URL=http://89561bkaq794.vicp.fun:53846
 
-# VITE_SERVICE_BASE_URL=http://47.109.84.152:8081#打包测试本地服务器
+VITE_SERVICE_BASE_URL=http://47.109.84.152:8081#打包测试本地服务器
 # VITE_SERVICE_BASE_URL=https://smqjh.api.zswlgz.com #服务器
 
 # other backend service base url, test environment

+ 1 - 0
src/locales/langs/en-us.ts

@@ -294,6 +294,7 @@ const local: App.I18n.Schema = {
     'goods-center_scenic-goods': '',
     'operation_coupon-issuance': '',
     'operation_coupon-manage': '',
+    'operation_opinion-feedback': 'Opinion Feedback',
     'member-center': '',
     'member-center_edit-member-type': '',
     'member-center_member-list': '',

+ 1 - 0
src/locales/langs/zh-cn.ts

@@ -291,6 +291,7 @@ const local: App.I18n.Schema = {
     'goods-center_scenic-goods': '',
     'operation_coupon-issuance': '',
     'operation_coupon-manage': '',
+    'operation_opinion-feedback': '意见反馈',
     'member-center': '',
     'member-center_edit-member-type': '',
     'member-center_member-list': '',

+ 1 - 0
src/router/elegant/imports.ts

@@ -59,6 +59,7 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
   "operation_coupon-issuance": () => import("@/views/operation/coupon-issuance/index.vue"),
   "operation_coupon-manage": () => import("@/views/operation/coupon-manage/index.vue"),
   operation_coupon: () => import("@/views/operation/coupon/index.vue"),
+  "operation_opinion-feedback": () => import("@/views/operation/opinion-feedback/index.vue"),
   "order-manage_after-sales-order-detail": () => import("@/views/order-manage/after-sales-order-detail/index.vue"),
   "order-manage_after-sales-order": () => import("@/views/order-manage/after-sales-order/index.vue"),
   "order-manage_normal-order": () => import("@/views/order-manage/normal-order/index.vue"),

+ 9 - 0
src/router/elegant/routes.ts

@@ -530,6 +530,15 @@ export const generatedRoutes: GeneratedRoute[] = [
           title: 'operation_coupon-manage',
           i18nKey: 'route.operation_coupon-manage'
         }
+      },
+      {
+        name: 'operation_opinion-feedback',
+        path: '/operation/opinion-feedback',
+        component: 'view.operation_opinion-feedback',
+        meta: {
+          title: 'operation_opinion-feedback',
+          i18nKey: 'route.operation_opinion-feedback'
+        }
       }
     ]
   },

+ 1 - 0
src/router/elegant/transform.ts

@@ -232,6 +232,7 @@ const routeMap: RouteMap = {
   "operation_coupon": "/operation/coupon",
   "operation_coupon-issuance": "/operation/coupon-issuance",
   "operation_coupon-manage": "/operation/coupon-manage",
+  "operation_opinion-feedback": "/operation/opinion-feedback",
   "order-manage": "/order-manage",
   "order-manage_after-sales-order": "/order-manage/after-sales-order",
   "order-manage_after-sales-order-detail": "/order-manage/after-sales-order-detail",

+ 48 - 0
src/service/api/operation/opinion-feedback/index.ts

@@ -0,0 +1,48 @@
+import { request } from '@/service/request';
+
+/**
+ * 分页获取意见反馈列表
+ * @param params 查询参数
+ */
+export function fetchOpinionList(params: any) {
+  return request({
+    url: '/smqjh-system/api/v1/opinionFeedback/page',
+    method: 'get',
+    params
+  });
+}
+
+/**
+ * 获取意见反馈类型列表
+ * @param params 查询参数
+ */
+export function fetchOpinionType(params: any) {
+  return request({
+    url: '/smqjh-system/api/v1/opinionFeedback/opinionTypeList',
+    method: 'get',
+    params
+  });
+}
+
+/**
+ * 回复意见反馈
+ * @param data { id, replyContent }
+ */
+export function fetchReplyOpinion(data: { id: string; replyContent: string }) {
+  return request({
+    url: '/smqjh-system/api/v1/opinionFeedback/reply',
+    method: 'post',
+    data
+  });
+}
+
+/**
+ * 删除意见反馈
+ * @param id 反馈ID
+ */
+export function fetchDeleteOpinion(id: string) {
+  return request({
+    url: `/smqjh-system/api/v1/opinionFeedback/${id}`,
+    method: 'delete'
+  });
+}

+ 2 - 0
src/typings/elegant-router.d.ts

@@ -86,6 +86,7 @@ declare module "@elegant-router/types" {
     "operation_coupon": "/operation/coupon";
     "operation_coupon-issuance": "/operation/coupon-issuance";
     "operation_coupon-manage": "/operation/coupon-manage";
+    "operation_opinion-feedback": "/operation/opinion-feedback";
     "order-manage": "/order-manage";
     "order-manage_after-sales-order": "/order-manage/after-sales-order";
     "order-manage_after-sales-order-detail": "/order-manage/after-sales-order-detail";
@@ -239,6 +240,7 @@ declare module "@elegant-router/types" {
     | "operation_coupon-issuance"
     | "operation_coupon-manage"
     | "operation_coupon"
+    | "operation_opinion-feedback"
     | "order-manage_after-sales-order-detail"
     | "order-manage_after-sales-order"
     | "order-manage_normal-order"

+ 47 - 4
src/views/goods-center/store-goods/index.vue

@@ -29,6 +29,7 @@ import { useModal } from '@/components/zt/Modal/hooks/useModal';
 type Price = { channelId: number | undefined; channelProdPrice: number; id: number; channelName?: string };
 const importTemplateRef = useTemplateRef('importTemplateRef');
 const loading = ref(false);
+const collectSortOrder = ref<'ascend' | 'descend' | undefined>(undefined);
 
 const options = ref<Api.goods.Channel[]>([]);
 const typeOptions = ref([]);
@@ -224,6 +225,29 @@ const columns: NaiveUI.TableColumn<Api.goods.ShopSku>[] = [
       );
     }
   },
+  {
+    key: 'collect',
+    title: () => {
+      let sortIcon: string;
+      if (collectSortOrder.value === 'ascend') {
+        sortIcon = 'material-symbols:arrow-upward';
+      } else if (collectSortOrder.value === 'descend') {
+        sortIcon = 'material-symbols:arrow-downward';
+      } else {
+        sortIcon = 'material-symbols:unfold-more';
+      }
+      const iconStyle = collectSortOrder.value ? 'font-size:16px;color:var(--n-primary-color)' : 'font-size:16px';
+      return (
+        <div class="inline-flex cursor-pointer select-none items-center" onClick={toggleCollectSort}>
+          <span class="mr-1">收藏量</span>
+          <SvgIcon icon={sortIcon} style={iconStyle} />
+        </div>
+      );
+    },
+    align: 'center',
+    width: 130,
+    render: (row: any) => row.collect ?? 0
+  },
   {
     key: 'updateTime',
     title: '更新时间',
@@ -432,13 +456,32 @@ const [registerTable, { getTableCheckedRowKeys, refresh, getTableData, getSeachF
     keyField: 'skuId',
     title: '商品列表',
     showAddButton: false,
-    scrollX: 1800,
+    scrollX: 1950,
     fieldMapToTime: [
       ['price', ['minPrice', 'maxPrice']],
       ['createTime', ['startTime', 'endTime']]
     ]
   }
 });
+
+function toggleCollectSort() {
+  if (collectSortOrder.value === undefined) {
+    collectSortOrder.value = 'descend';
+  } else if (collectSortOrder.value === 'descend') {
+    collectSortOrder.value = 'ascend';
+  } else {
+    collectSortOrder.value = undefined;
+  }
+  nextTick(() => refresh());
+}
+
+function apiWithCollectSort(params: any) {
+  if (collectSortOrder.value !== undefined) {
+    params.isCollectSort = collectSortOrder.value === 'ascend';
+  }
+  return fetchProductList(params);
+}
+
 const [registerLogTable, { refresh: refreshLog, setTableLoading: setLogTableLoading }] = useTable({
   tableConfig: {
     keyField: 'skuId',
@@ -719,20 +762,20 @@ async function handleSubmit() {
 
 <template>
   <LayoutTable>
-    <ZTable :columns="columns" :api="fetchProductList" @register="registerTable">
+    <ZTable :columns="columns" :api="apiWithCollectSort" @register="registerTable">
       <template #op="{ row }">
         <NButton v-if="row.businessType !== 'JY'" size="small" ghost type="primary" @click="handleModalPrice(row)">
           设置渠道及价格
         </NButton>
       </template>
-      <template #prefix="{ loading }">
+      <template #prefix="{ loading: tableLoading }">
         <NSpace>
           <NButton v-if="useAuth().hasAuth('goods:reset-btn')" size="small" @click="openModalForm">
             渠道价格重置
           </NButton>
           <NButton size="small" @click="openImportLogModal">导入记录</NButton>
           <NButton size="small" @click="openImportModal">导入商品销售渠道及价格</NButton>
-          <NButton size="small" :disabled="tableData.length == 0" :loading="loading" @click="handleExport">
+          <NButton size="small" :disabled="tableData.length == 0" :loading="tableLoading" @click="handleExport">
             导出全部
           </NButton>
           <NButton size="small" :disabled="isDisabledExport">导出选中数据</NButton>

+ 2 - 2
src/views/member-center/member-list/index.vue

@@ -316,9 +316,9 @@ async function openRecordModal(id: string) {
 <template>
   <LayoutTable>
     <ZTable :columns="columns" :api="fetchMemberList" @register="registerTable">
-      <template #prefix="{ loading }">
+      <template #prefix="{ loading: tableLoading }">
         <NSpace>
-          <NButton size="small" :disabled="tableData.length == 0" :loading="loading" @click="handleExport">
+          <NButton size="small" :disabled="tableData.length == 0" :loading="tableLoading" @click="handleExport">
             导出
           </NButton>
           <NButton size="small" @click="openExportLogModal">导出记录</NButton>

+ 355 - 0
src/views/operation/opinion-feedback/index.vue

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