lxr 2 ماه پیش
والد
کامیت
3a9183f6cb

+ 30 - 0
src/api/modules/feedback.ts

@@ -0,0 +1,30 @@
+import httpApi from "@/api/indexApi";
+/** 反馈类型映射(与商家端一致) */
+export const typeMap: Record<number, string> = {
+  0: "BUG反馈",
+  1: "优化反馈",
+  2: "功能反馈"
+};
+
+export const getTypeName = (type: number): string => typeMap[type] ?? "其他";
+
+/**
+ * 获取反馈详情
+ * GET /alienStore/feedback/detail?feedbackId=xxx
+ */
+export const getFeedbackDetail = (params: { feedbackId: number | string }) => {
+  return httpApi.get(`/alienStore/feedback/detail`, params, { loading: false });
+};
+
+/**
+ * 用户回复
+ * POST /alienStore/feedback/userReply
+ */
+
+export const userReply = (params: { feedbackId: number | string; content: string }) => {
+  return httpApi.post(`/alienStore/feedback/userReply`, {
+    ...params,
+    feedbackSource: 1,
+    fileUrlList: []
+  });
+};

+ 54 - 5
src/layouts/components/Header/components/NotificationDrawerContent.vue

@@ -83,7 +83,7 @@
                 class="message-card"
                 :class="{ unread: item.unread }"
               >
-                <div class="message-avatar">
+                <div class="message-avatar avatar-clickable" @click.stop="handleAvatarClick(item)">
                   <el-avatar :size="40" :src="item.userImage">
                     <el-icon><UserFilled /></el-icon>
                   </el-avatar>
@@ -180,7 +180,7 @@
         {{ currentDetail?.userName }} {{ currentDetail?.content }}
       </div>
       <div class="detail-dialog-content" v-else>
-        <div style=" font-size: 14px; font-weight: bold;text-align: left">
+        <div style="font-size: 14px; font-weight: bold; text-align: left">
           {{ currentDetail?.createTime }}
         </div>
         {{ currentDetail?.content }}
@@ -217,9 +217,14 @@ interface NoticeItem {
   unread: boolean;
   /** 原始创建时间(接口返回 createdTime) */
   createTime?: string;
-  /** 互动类可能有发送者与头像 */
+  /** 意见反馈回复通知:context 中的 feedbackId,用于跳转反馈详情 */
+  feedbackId?: string | number;
+  /** 互动类:发送者信息,用于跳转动态主页 */
   userName?: string;
   userImage?: string;
+  userId?: string | number;
+  phoneId?: string;
+  storeUserId?: string | number;
   /** 类型,用于 processContent 展示 */
   type?: string;
 }
@@ -542,6 +547,18 @@ function parseContext(context: string | undefined): string {
   }
 }
 
+/** 从 context 解析 feedbackId(意见反馈回复通知) */
+function parseFeedbackIdFromContext(context: string | undefined): string | number | undefined {
+  if (!context) return undefined;
+  try {
+    const parsed = typeof context === "string" ? JSON.parse(context) : context;
+    const id = parsed?.feedbackId;
+    return id != null ? id : undefined;
+  } catch {
+    return undefined;
+  }
+}
+
 // 互动类内容:商家端 context 可能为 "类型|split|内容1|split|内容2",取第一段或解析 JSON
 function getInteractionContent(context: string | undefined): string {
   if (!context) return "";
@@ -578,6 +595,7 @@ async function fetchNoticeList(catKey: string) {
       const content = isRelated ? getInteractionContent(rawContext) : parseContext(rawContext);
       const dateRaw = item.createdTime ?? "";
       const dateStr = dateRaw.includes(" ") ? dateRaw.split(" ")[0].replace(/-/g, "/") : dateRaw.replace(/-/g, "/");
+      const feedbackId = parseFeedbackIdFromContext(rawContext);
       return {
         id: String(item.id),
         title: item.title ?? "",
@@ -586,9 +604,13 @@ async function fetchNoticeList(catKey: string) {
         unread: !item.isRead,
         createTime: item.createdTime ?? "",
         type: item.type,
+        ...(feedbackId != null && { feedbackId }),
         ...(isRelated && {
           userName: item.userName ?? item.senderName ?? item.title ?? "",
-          userImage: item.userImage ?? item.storeImg ?? item.senderImg
+          userImage: item.userImage ?? item.storeImg ?? item.senderImg,
+          userId: item.userId ?? item.storeUserId,
+          phoneId: item.phoneId ?? item.senderId,
+          storeUserId: item.storeUserId ?? item.userId
         })
       };
     });
@@ -651,7 +673,6 @@ async function handleViewDetail(item: NoticeItem) {
     try {
       const res: any = await readNoticeById({ id: item.id });
       const ok = res?.code === 200 || res?.code === "200";
-      console.log(res, "res");
       if (ok) {
         const key = activeCategory.value;
         const list = listByCategory.value[key] ?? [];
@@ -664,10 +685,35 @@ async function handleViewDetail(item: NoticeItem) {
       // 仍打开详情
     }
   }
+
+  // 系统通知 - 意见反馈回复通知:跳转反馈详情页(与商家端 s-informList 一致)
+  if (activeCategory.value === "system" && item.feedbackId != null) {
+    emit("close");
+    router.push({ path: "/feedback/detail", query: { id: String(item.feedbackId) } });
+    return;
+  }
+
   currentDetail.value = item;
   detailVisible.value = true;
 }
 
+// 互动列表:点击头像跳转动态主页(与 dynamicManagement/index 的 handleViewUserProfile 一致)
+function handleAvatarClick(item: NoticeItem) {
+  console.log(item, "item");
+  const phoneId = item.phoneId ?? item.userId;
+  if (!phoneId) return;
+  emit("close");
+  router.push({
+    path: "/dynamicManagement/userDynamic",
+    query: {
+      userId: String(item.storeUserId ?? item.userId ?? ""),
+      phoneId: String(phoneId),
+      userName: item.userName ?? "",
+      userAvatar: item.userImage ?? ""
+    }
+  });
+}
+
 async function handleDelete(item: NoticeItem, index: number) {
   try {
     await ElMessageBox.confirm("确定要删除这条通知吗?", "提示", {
@@ -826,6 +872,9 @@ watch(activeTab, val => {
   .message-avatar {
     position: relative;
     flex-shrink: 0;
+    &.avatar-clickable {
+      cursor: pointer;
+    }
     .message-unread-dot {
       position: absolute;
       top: 0;

+ 9 - 0
src/routers/modules/staticRouter.ts

@@ -37,6 +37,15 @@ export const staticRouter: RouteRecordRaw[] = [
           isAffix: false,
           isKeepAlive: false
         }
+      },
+      {
+        path: "/feedback/detail",
+        name: "feedbackDetail",
+        component: () => import("@/views/feedback/detail.vue"),
+        meta: {
+          title: "反馈详情",
+          isHide: true
+        }
       }
     ]
   }

+ 286 - 0
src/views/feedback/detail.vue

@@ -0,0 +1,286 @@
+<template>
+  <div class="feedback-detail">
+    <div class="page-header">
+      <el-button @click="goBack"> 返回 </el-button>
+    </div>
+
+    <div v-if="loading" class="loading-wrap">
+      <el-icon class="is-loading" :size="32">
+        <Loading />
+      </el-icon>
+      <span>加载中...</span>
+    </div>
+
+    <template v-else-if="feedbackInfo.id">
+      <!-- 反馈内容卡片 -->
+      <div class="detail-card">
+        <div class="card-type">
+          {{ getTypeName(feedbackInfo.feedbackType) }}
+        </div>
+        <div class="card-meta">反馈时间:{{ feedbackInfo.feedbackTime }}</div>
+        <div class="card-meta" v-if="feedbackInfo.contactWay">联系方式:{{ feedbackInfo.contactWay }}</div>
+        <div class="card-content">
+          {{ feedbackInfo.content }}
+        </div>
+        <!-- 附件 -->
+        <div v-if="allAttachments.length > 0" class="card-attach">
+          <div class="attach-title">附件</div>
+          <div class="attach-grid">
+            <el-image
+              v-for="(url, idx) in feedbackInfo.imgUrlList"
+              :key="'img-' + idx"
+              :src="url"
+              fit="cover"
+              class="attach-img"
+              :preview-src-list="feedbackInfo.imgUrlList || []"
+              :initial-index="idx"
+            />
+            <div v-for="(url, idx) in feedbackInfo.videoUrlList" :key="'vid-' + idx" class="attach-video">
+              <video :src="url" controls class="video-player" />
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <!-- 回复区域 -->
+      <div v-if="replyList.length > 0" class="reply-wrapper">
+        <div
+          v-for="(reply, index) in replyList"
+          :key="'reply-' + index"
+          class="reply-section"
+          :class="reply.isPlatform ? 'platform' : 'user'"
+        >
+          <div class="section-title">
+            {{ reply.isPlatform ? "平台回复我" : "我的回复" }}
+          </div>
+          <div class="reply-card">
+            <div class="reply-content">
+              {{ reply.content }}
+            </div>
+            <div class="reply-time">
+              {{ reply.displayTime }}
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <!-- 回复输入 -->
+      <div v-if="hasPlatformReply" class="reply-bar">
+        <el-input v-model="replyContent" type="textarea" :rows="3" placeholder="回复平台" maxlength="500" show-word-limit />
+        <el-button type="primary" :disabled="!replyContent.trim()" @click="sendReply"> 发送 </el-button>
+      </div>
+    </template>
+
+    <div v-else-if="!loading" class="empty-tip">暂无数据</div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted } from "vue";
+import { useRoute, useRouter } from "vue-router";
+import { Loading } from "@element-plus/icons-vue";
+import { ElMessage } from "element-plus";
+import { getFeedbackDetail, userReply, getTypeName } from "@/api/modules/feedback";
+// 获取缓存的geeker-user
+import { localGet } from "@/utils/index";
+const route = useRoute();
+const router = useRouter();
+
+const feedbackId = ref<string>("");
+const loading = ref(true);
+const replyContent = ref("");
+
+const feedbackInfo = ref<{
+  id?: number;
+  feedbackType?: number;
+  content?: string;
+  feedbackTime?: string;
+  contactWay?: string;
+  imgUrlList?: string[];
+  videoUrlList?: string[];
+  platformReplies?: Array<{ content?: string; feedbackTime?: string; staffId?: number }>;
+}>({});
+
+const allAttachments = computed(() => {
+  const imgs = feedbackInfo.value.imgUrlList || [];
+  const videos = feedbackInfo.value.videoUrlList || [];
+  return [...imgs, ...videos];
+});
+
+const replyList = computed(() => {
+  const replies = feedbackInfo.value.platformReplies || [];
+  return replies.map(item => ({
+    ...item,
+    isPlatform: item.staffId != null,
+    displayTime: item.feedbackTime
+  }));
+});
+
+const hasPlatformReply = computed(() => {
+  const replies = feedbackInfo.value.platformReplies || [];
+  return replies.some(item => item.staffId != null);
+});
+
+async function fetchDetail(id: string) {
+  loading.value = true;
+  try {
+    const res: any = await getFeedbackDetail({ feedbackId: id });
+    if (res?.code === 200 || res?.code === "200") {
+      feedbackInfo.value = res?.data || res || {};
+    }
+  } catch (e) {
+    ElMessage.error("获取详情失败");
+  } finally {
+    loading.value = false;
+  }
+}
+
+function goBack() {
+  router.back();
+}
+
+async function sendReply() {
+  if (!replyContent.value.trim()) return;
+  try {
+    const res: any = await userReply({
+      feedbackId: feedbackId.value,
+      content: replyContent.value.trim(),
+      userId: localGet("geeker-user")?.userInfo?.id
+    });
+    if (res?.code === 200 || res?.code === "200") {
+      ElMessage.success("回复成功");
+      replyContent.value = "";
+      await fetchDetail(feedbackId.value);
+    } else {
+      ElMessage.error(res?.msg || "回复失败");
+    }
+  } catch (e) {
+    ElMessage.error("回复失败");
+  }
+}
+
+onMounted(() => {
+  const id = route.query.id || route.query.feedbackId;
+  if (id) {
+    feedbackId.value = String(id);
+    fetchDetail(feedbackId.value);
+  }
+});
+</script>
+
+<style scoped lang="scss">
+.feedback-detail {
+  min-height: 100vh;
+  padding: 20px;
+  background: #f4f6fb;
+}
+.page-header {
+  margin-bottom: 20px;
+}
+.loading-wrap {
+  display: flex;
+  gap: 12px;
+  align-items: center;
+  justify-content: center;
+  padding: 60px;
+  color: var(--el-text-color-secondary);
+}
+.detail-card {
+  padding: 24px;
+  margin-bottom: 16px;
+  background: #ffffff;
+  border-radius: 12px;
+  .card-type {
+    margin-bottom: 8px;
+    font-size: 16px;
+    font-weight: 600;
+    color: #333333;
+  }
+  .card-meta {
+    margin-bottom: 4px;
+    font-size: 13px;
+    color: #999999;
+  }
+  .card-content {
+    margin-top: 12px;
+    font-size: 14px;
+    line-height: 1.8;
+    color: #333333;
+  }
+  .card-attach {
+    margin-top: 24px;
+    .attach-title {
+      margin-bottom: 12px;
+      font-size: 14px;
+      font-weight: 600;
+    }
+    .attach-grid {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 12px;
+    }
+    .attach-img {
+      width: 100px;
+      height: 100px;
+      border-radius: 8px;
+    }
+    .attach-video {
+      width: 200px;
+      .video-player {
+        width: 100%;
+        border-radius: 8px;
+      }
+    }
+  }
+}
+.reply-wrapper {
+  padding: 24px;
+  margin-bottom: 16px;
+  background: #ffffff;
+  border-radius: 12px;
+}
+.reply-section {
+  margin-bottom: 20px;
+  &:last-child {
+    margin-bottom: 0;
+  }
+  .section-title {
+    margin-bottom: 8px;
+    font-size: 14px;
+    font-weight: 600;
+  }
+  &.user .reply-card {
+    background: rgb(108 143 248 / 15%);
+  }
+  &.platform .reply-card {
+    background: #f5f6fa;
+  }
+}
+.reply-card {
+  padding: 16px;
+  border-radius: 8px;
+  .reply-content {
+    margin-bottom: 8px;
+    font-size: 14px;
+    line-height: 1.6;
+    color: #333333;
+  }
+  .reply-time {
+    font-size: 12px;
+    color: #999999;
+  }
+}
+.reply-bar {
+  padding: 16px;
+  background: #ffffff;
+  border-radius: 12px;
+  .el-textarea {
+    margin-bottom: 12px;
+  }
+}
+.empty-tip {
+  padding: 60px;
+  color: var(--el-text-color-secondary);
+  text-align: center;
+}
+</style>