ソースを参照

feature: 营销活动

sgc 2 ヶ月 前
コミット
4ce2345e0e

+ 36 - 0
src/api/modules/operationManagement.ts

@@ -93,3 +93,39 @@ export const getActivityRuleOptions = (params?: any) => {
 export const generatePromotionImage = (params: { text: string }) => {
   return http_ai.post<any>(`/ai/life-manager/api/v1/promotion_image/generate`, params);
 };
+
+/**
+ * 个人案例列表(上传情况、所属活动等)
+ * @param params 请求参数
+ * @returns 个人案例列表
+ */
+export const getPersonCaseList = (params: any) => {
+  return http.post<ResPage<any>>(PORT_NONE + `/operationalActivity/personCase/list`, params);
+};
+
+/**
+ * 个人案例详情(成果展示等)
+ * @param params { id: string }
+ * @returns 个人案例详情
+ */
+export const getPersonCaseDetail = (params: { id: string }) => {
+  return http.get<any>(PORT_NONE + `/operationalActivity/personCase/detail`, params);
+};
+
+/**
+ * 审核通过报名人员
+ * @param params { id: string }
+ * @returns 审核结果
+ */
+export const approvePersonnel = (params: { id: string }) => {
+  return http.post<any>(PORT_NONE + `/operationalActivity/personCase/approve`, params);
+};
+
+/**
+ * 审核拒绝报名人员
+ * @param params { id: string, rejectReason?: string }
+ * @returns 审核结果
+ */
+export const rejectPersonnel = (params: { id: string; rejectReason?: string }) => {
+  return http.post<any>(PORT_NONE + `/operationalActivity/personCase/reject`, params);
+};

+ 60 - 0
src/assets/json/authMenuList.json

@@ -656,6 +656,66 @@
             "isAffix": false,
             "isKeepAlive": false
           }
+        },
+        {
+          "path": "/operationManagement/personnel",
+          "name": "personnel",
+          "component": "/operationManagement/personnel",
+          "meta": {
+            "icon": "Menu",
+            "title": "报名人员",
+            "activeMenu": "/operationManagement/personnel",
+            "isLink": "",
+            "isHide": false,
+            "isFull": false,
+            "isAffix": false,
+            "isKeepAlive": false
+          }
+        },
+        {
+          "path": "/operationManagement/personnelDetail",
+          "name": "personnelDetail",
+          "component": "/operationManagement/personnelDetail",
+          "meta": {
+            "icon": "Menu",
+            "title": "报名人员详情",
+            "activeMenu": "/operationManagement/personnel",
+            "isLink": "",
+            "isHide": true,
+            "isFull": false,
+            "isAffix": false,
+            "isKeepAlive": false
+          }
+        },
+        {
+          "path": "/operationManagement/cases",
+          "name": "cases",
+          "component": "/operationManagement/cases",
+          "meta": {
+            "icon": "Menu",
+            "title": "案例",
+            "activeMenu": "/operationManagement/cases",
+            "isLink": "",
+            "isHide": false,
+            "isFull": false,
+            "isAffix": false,
+            "isKeepAlive": false
+          }
+        },
+        {
+          "path": "/operationManagement/caseDetail",
+          "name": "caseDetail",
+          "component": "/operationManagement/caseDetail",
+          "meta": {
+            "icon": "Menu",
+            "title": "案例详情",
+            "activeMenu": "/operationManagement/cases",
+            "isLink": "",
+            "isHide": true,
+            "isFull": false,
+            "isAffix": false,
+            "isKeepAlive": false
+          }
         }
       ]
     },

+ 247 - 0
src/views/operationManagement/caseDetail.vue

@@ -0,0 +1,247 @@
+<template>
+  <div class="case-detail">
+    <div class="header">
+      <el-button @click="goBack"> 返回 </el-button>
+      <h2 class="title">案例详情</h2>
+    </div>
+    <el-card v-loading="loading">
+      <template v-if="detail">
+        <el-descriptions :column="2" border>
+          <el-descriptions-item label="所属活动">
+            {{ detail.activityName || detail.activityTitle || "-" }}
+          </el-descriptions-item>
+          <el-descriptions-item label="用户昵称">
+            {{ detail.nickname ?? "------" }}
+          </el-descriptions-item>
+          <el-descriptions-item label="姓名">
+            {{ detail.name ?? "------" }}
+          </el-descriptions-item>
+          <el-descriptions-item label="联系方式">
+            {{ detail.phone || "-" }}
+          </el-descriptions-item>
+          <el-descriptions-item label="报名时间" :span="2">
+            {{ formatTime(detail.registrationTime || detail.signUpTime) }}
+          </el-descriptions-item>
+        </el-descriptions>
+
+        <div class="result-section">
+          <div class="result-title">成果展示</div>
+          <el-descriptions :column="2" border>
+            <el-descriptions-item label="更新时间" :span="2">
+              {{ formatTime(detail.updateTime || detail.updatedAt) }}
+            </el-descriptions-item>
+            <el-descriptions-item label="成果描述" :span="2">
+              <div v-if="detail.resultDesc || detail.description" class="result-desc">
+                {{ detail.resultDesc || detail.description }}
+              </div>
+              <span v-else>-</span>
+            </el-descriptions-item>
+            <el-descriptions-item label="图片与视频" :span="2">
+              <div v-if="mediaList.length > 0" class="media-grid">
+                <template v-for="(item, index) in mediaList" :key="index">
+                  <div v-if="item.type === 'video'" class="media-item video-item" @click="playVideo(item.url)">
+                    <div class="media-placeholder">
+                      <el-icon class="play-icon">
+                        <VideoPlay />
+                      </el-icon>
+                    </div>
+                  </div>
+                  <div v-else class="media-item image-item" @click="previewImage(item.url, index)">
+                    <el-image
+                      :src="item.url"
+                      fit="cover"
+                      class="media-image"
+                      :preview-src-list="imageList"
+                      :initial-index="getImageIndex(item.url)"
+                    >
+                      <template #error>
+                        <div class="image-slot">
+                          <el-icon><Picture /></el-icon>
+                        </div>
+                      </template>
+                    </el-image>
+                  </div>
+                </template>
+              </div>
+              <span v-else>-</span>
+            </el-descriptions-item>
+          </el-descriptions>
+        </div>
+      </template>
+      <el-empty v-else-if="!loading" description="暂无数据" />
+    </el-card>
+
+    <el-dialog v-model="videoDialogVisible" title="视频预览" width="640px" destroy-on-close @close="previewVideo = ''">
+      <video v-if="previewVideo" :src="previewVideo" controls autoplay class="dialog-video" />
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts" name="caseDetail">
+import { ref, onMounted, computed } from "vue";
+import { useRoute, useRouter } from "vue-router";
+import { Picture, VideoPlay } from "@element-plus/icons-vue";
+import { getPersonCaseDetail } from "@/api/modules/operationManagement";
+
+const route = useRoute();
+const router = useRouter();
+const loading = ref(false);
+const detail = ref<any>(null);
+const videoDialogVisible = ref(false);
+const previewVideo = ref("");
+
+const id = computed(() => route.query.id as string);
+
+const goBack = () => {
+  router.push({ path: "/operationManagement/cases" });
+};
+
+const formatTime = (time: string | null | undefined) => {
+  if (!time) return "-";
+  try {
+    const date = new Date(time);
+    const y = date.getFullYear();
+    const m = String(date.getMonth() + 1).padStart(2, "0");
+    const d = String(date.getDate()).padStart(2, "0");
+    const h = String(date.getHours()).padStart(2, "0");
+    const min = String(date.getMinutes()).padStart(2, "0");
+    const s = String(date.getSeconds()).padStart(2, "0");
+    return `${y}/${m}/${d} ${h}:${min}:${s}`;
+  } catch {
+    return time;
+  }
+};
+
+type MediaItem = { type: "image" | "video"; url: string };
+
+const mediaList = computed<MediaItem[]>(() => {
+  if (!detail.value) return [];
+  const list: MediaItem[] = [];
+  const raw = detail.value.mediaList ?? detail.value.images ?? detail.value.videos ?? [];
+  const arr = Array.isArray(raw) ? raw : [raw];
+  for (const it of arr) {
+    if (typeof it === "string") list.push({ type: "image", url: it });
+    else if (it?.url) list.push({ type: it.type === "video" ? "video" : "image", url: it.url });
+  }
+  if (detail.value.resultImages && Array.isArray(detail.value.resultImages))
+    detail.value.resultImages.forEach((u: string) => list.push({ type: "image", url: u }));
+  if (detail.value.resultVideos && Array.isArray(detail.value.resultVideos))
+    detail.value.resultVideos.forEach((u: string) => list.push({ type: "video", url: u }));
+  return list.filter(Boolean);
+});
+
+const imageList = computed(() => mediaList.value.filter(m => m.type === "image").map(m => m.url));
+
+const getImageIndex = (url: string) => {
+  return imageList.value.indexOf(url);
+};
+
+const previewImage = (url: string, index: number) => {
+  // el-image 的 preview-src-list 会自动处理预览
+};
+
+const playVideo = (url: string) => {
+  previewVideo.value = url;
+  videoDialogVisible.value = true;
+};
+
+onMounted(async () => {
+  if (!id.value) return;
+  loading.value = true;
+  try {
+    const res = await getPersonCaseDetail({ id: id.value });
+    detail.value = res?.data ?? res ?? null;
+  } catch {
+    detail.value = null;
+  } finally {
+    loading.value = false;
+  }
+});
+</script>
+
+<style scoped lang="scss">
+.case-detail {
+  min-height: 100%;
+  padding: 16px;
+  background: #ffffff;
+}
+.header {
+  display: flex;
+  gap: 16px;
+  align-items: center;
+  margin-bottom: 16px;
+}
+.title {
+  margin: 0;
+  font-size: 18px;
+  font-weight: 600;
+}
+.result-section {
+  margin-top: 24px;
+}
+.result-title {
+  margin-bottom: 12px;
+  font-size: 16px;
+  font-weight: 600;
+}
+.result-desc {
+  line-height: 1.6;
+  word-break: break-all;
+  white-space: pre-wrap;
+}
+.media-grid {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 12px;
+  margin-top: 8px;
+}
+.media-item {
+  position: relative;
+  aspect-ratio: 1;
+  overflow: hidden;
+  cursor: pointer;
+  background: #f5f7fa;
+  border-radius: 8px;
+}
+.image-item {
+  width: 100%;
+  height: 100%;
+}
+.media-image {
+  display: block;
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+.video-item {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #000000;
+}
+.media-placeholder {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  height: 100%;
+  color: #909399;
+}
+.play-icon {
+  font-size: 40px;
+}
+.image-slot {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  height: 100%;
+  color: #909399;
+  background: #f5f7fa;
+}
+.dialog-video {
+  display: block;
+  width: 100%;
+  max-height: 70vh;
+}
+</style>

+ 61 - 0
src/views/operationManagement/cases.vue

@@ -0,0 +1,61 @@
+<template>
+  <div class="table-box button-table">
+    <ProTable ref="proTable" :columns="columns" :request-api="getTableList" :init-param="initParam" :data-callback="dataCallback">
+      <template #operation="scope">
+        <el-button type="primary" link @click="toDetail(scope.row)"> 查看详情 </el-button>
+      </template>
+    </ProTable>
+  </div>
+</template>
+
+<script setup lang="tsx" name="cases">
+import { reactive, ref } from "vue";
+import { useRouter } from "vue-router";
+import ProTable from "@/components/ProTable/index.vue";
+import { ColumnProps, ProTableInstance } from "@/components/ProTable/interface";
+import { getPersonCaseList } from "@/api/modules/operationManagement";
+import { localGet } from "@/utils";
+
+const router = useRouter();
+const proTable = ref<ProTableInstance>();
+
+const uploadStatusEnum = [
+  { label: "未上传", value: 0 },
+  { label: "已上传", value: 1 }
+];
+
+const columns = reactive<ColumnProps<any>[]>([
+  { prop: "name", label: "姓名", minWidth: 120 },
+  { prop: "phone", label: "联系方式", width: 130 },
+  {
+    prop: "activityName",
+    label: "所属活动",
+    minWidth: 140,
+    search: { el: "input", props: { placeholder: "请输入" }, order: 2 }
+  },
+  {
+    prop: "uploadStatus",
+    label: "上传情况",
+    width: 110,
+    enum: uploadStatusEnum,
+    fieldNames: { label: "label", value: "value" },
+    search: { el: "select", props: { placeholder: "请选择" }, order: 1 }
+  },
+  { prop: "operation", label: "操作", fixed: "right", width: 120 }
+]);
+
+const initParam = reactive({
+  storeId: localGet("createdId")
+});
+
+const dataCallback = (data: any) => ({
+  list: data?.records ?? [],
+  total: data?.total ?? 0
+});
+
+const getTableList = (params: any) => getPersonCaseList(params);
+
+const toDetail = (row: any) => {
+  router.push(`/operationManagement/caseDetail?id=${row.id}`);
+};
+</script>

+ 130 - 0
src/views/operationManagement/personnel.vue

@@ -0,0 +1,130 @@
+<template>
+  <div class="table-box button-table">
+    <ProTable ref="proTable" :columns="columns" :request-api="getTableList" :init-param="initParam" :data-callback="dataCallback">
+      <template #operation="scope">
+        <el-button type="primary" link @click="toDetail(scope.row)"> 查看详情 </el-button>
+        <!-- 待审核状态显示通过和拒绝按钮 -->
+        <template v-if="isPending(scope.row.auditStatus || scope.row.status)">
+          <el-button type="success" link @click="handleApprove(scope.row)"> 通过 </el-button>
+          <el-button type="danger" link @click="handleReject(scope.row)"> 拒绝 </el-button>
+        </template>
+      </template>
+    </ProTable>
+  </div>
+</template>
+
+<script setup lang="tsx" name="personnel">
+import { reactive, ref } from "vue";
+import { useRouter } from "vue-router";
+import { ElMessage, ElMessageBox } from "element-plus";
+import ProTable from "@/components/ProTable/index.vue";
+import { ColumnProps, ProTableInstance } from "@/components/ProTable/interface";
+import { getPersonCaseList, approvePersonnel, rejectPersonnel } from "@/api/modules/operationManagement";
+import { localGet } from "@/utils";
+
+const router = useRouter();
+const proTable = ref<ProTableInstance>();
+
+// 报名情况枚举:通过(1)、拒绝(2)、待审核(0或null)
+const registrationStatusEnum = [
+  { label: "通过", value: 1 },
+  { label: "拒绝", value: 2 },
+  { label: "待审核", value: 0 }
+];
+
+const columns = reactive<ColumnProps<any>[]>([
+  { prop: "name", label: "姓名", minWidth: 120 },
+  { prop: "phone", label: "联系方式", width: 130 },
+  {
+    prop: "activityName",
+    label: "所属活动",
+    minWidth: 140,
+    search: { el: "input", props: { placeholder: "请输入" }, order: 2 }
+  },
+  {
+    prop: "auditStatus",
+    label: "报名情况",
+    width: 110,
+    enum: registrationStatusEnum,
+    fieldNames: { label: "label", value: "value" },
+    search: { el: "select", props: { placeholder: "请选择" }, order: 1 },
+    render: (scope: any) => {
+      const status = scope.row.auditStatus ?? scope.row.status;
+      if (status === 1 || status === "1" || status === "通过") return "通过";
+      if (status === 2 || status === "2" || status === "拒绝") return "拒绝";
+      return "待审核";
+    }
+  },
+  { prop: "operation", label: "操作", fixed: "right", width: 200 }
+]);
+
+const initParam = reactive({
+  storeId: localGet("createdId")
+});
+
+const dataCallback = (data: any) => ({
+  list: data?.records ?? [],
+  total: data?.total ?? 0
+});
+
+const getTableList = (params: any) => getPersonCaseList(params);
+
+const toDetail = (row: any) => {
+  router.push(`/operationManagement/personnelDetail?id=${row.id}`);
+};
+
+// 判断是否为待审核状态
+const isPending = (status: number | string | null | undefined) => {
+  if (status === null || status === undefined) return true;
+  const statusNum = Number(status);
+  return statusNum === 0 || status === "0" || status === "待审核";
+};
+
+// 审核通过
+const handleApprove = async (row: any) => {
+  try {
+    await ElMessageBox.confirm("确定要通过该报名吗?", "提示", {
+      confirmButtonText: "确定",
+      cancelButtonText: "取消",
+      type: "warning"
+    });
+    const res = await approvePersonnel({ id: row.id });
+    if (res.code === 200 || res.code === "200") {
+      ElMessage.success("审核通过成功");
+      proTable.value?.getTableList();
+    } else {
+      ElMessage.error(res?.msg || res?.message || "审核通过失败");
+    }
+  } catch (error: any) {
+    if (error !== "cancel") {
+      ElMessage.error(error?.message || "审核通过失败");
+    }
+  }
+};
+
+// 审核拒绝
+const handleReject = async (row: any) => {
+  try {
+    const { value: rejectReason } = await ElMessageBox.prompt("请输入拒绝原因", "审核拒绝", {
+      confirmButtonText: "确定",
+      cancelButtonText: "取消",
+      inputType: "textarea",
+      inputPlaceholder: "请输入拒绝原因"
+    });
+    const res = await rejectPersonnel({
+      id: row.id,
+      rejectReason: rejectReason || ""
+    });
+    if (res.code === 200 || res.code === "200") {
+      ElMessage.success("审核拒绝成功");
+      proTable.value?.getTableList();
+    } else {
+      ElMessage.error(res?.msg || res?.message || "审核拒绝失败");
+    }
+  } catch (error: any) {
+    if (error !== "cancel") {
+      ElMessage.error(error?.message || "审核拒绝失败");
+    }
+  }
+};
+</script>

+ 153 - 0
src/views/operationManagement/personnelDetail.vue

@@ -0,0 +1,153 @@
+<template>
+  <div class="personnel-detail">
+    <div class="header">
+      <el-button @click="goBack"> 返回 </el-button>
+      <h2 class="title">报名人员详情</h2>
+    </div>
+    <el-card v-loading="loading">
+      <template v-if="detail">
+        <el-descriptions :column="2" border>
+          <el-descriptions-item label="所属活动">
+            {{ detail.activityName || detail.activityTitle || "-" }}
+          </el-descriptions-item>
+          <el-descriptions-item label="用户昵称">
+            {{ detail.nickname ?? "------" }}
+          </el-descriptions-item>
+          <el-descriptions-item label="姓名">
+            {{ detail.name ?? "------" }}
+          </el-descriptions-item>
+          <el-descriptions-item label="联系方式">
+            {{ detail.phone || "-" }}
+          </el-descriptions-item>
+          <el-descriptions-item label="报名时间" :span="2">
+            {{ formatTime(detail.registrationTime || detail.signUpTime) }}
+          </el-descriptions-item>
+        </el-descriptions>
+
+        <!-- 审核状态部分 -->
+        <div class="audit-section">
+          <el-descriptions :column="2" border>
+            <el-descriptions-item label="审核状态" :span="2">
+              <el-tag :type="getAuditStatusType(detail.auditStatus || detail.status)" size="default">
+                {{ getAuditStatusText(detail.auditStatus || detail.status) }}
+              </el-tag>
+            </el-descriptions-item>
+            <!-- 审核通过:显示审核时间 -->
+            <template v-if="isAuditApproved(detail.auditStatus || detail.status)">
+              <el-descriptions-item label="审核时间" :span="2">
+                {{ formatTime(detail.auditTime || detail.approvedTime) }}
+              </el-descriptions-item>
+            </template>
+            <!-- 审核拒绝:显示审核拒绝原因和审核时间 -->
+            <template v-if="isAuditRejected(detail.auditStatus || detail.status)">
+              <el-descriptions-item label="审核拒绝原因" :span="2">
+                {{ detail.rejectReason || detail.auditReason || "-" }}
+              </el-descriptions-item>
+              <el-descriptions-item label="审核时间" :span="2">
+                {{ formatTime(detail.auditTime || detail.rejectedTime) }}
+              </el-descriptions-item>
+            </template>
+          </el-descriptions>
+        </div>
+      </template>
+      <el-empty v-else-if="!loading" description="暂无数据" />
+    </el-card>
+  </div>
+</template>
+
+<script setup lang="ts" name="personnelDetail">
+import { ref, onMounted, computed } from "vue";
+import { useRoute, useRouter } from "vue-router";
+import { getPersonCaseDetail } from "@/api/modules/operationManagement";
+
+const route = useRoute();
+const router = useRouter();
+const loading = ref(false);
+const detail = ref<any>(null);
+
+const id = computed(() => route.query.id as string);
+
+const goBack = () => {
+  router.push({ path: "/operationManagement/personnel" });
+};
+
+const formatTime = (time: string | null | undefined) => {
+  if (!time) return "-";
+  try {
+    const date = new Date(time);
+    const y = date.getFullYear();
+    const m = String(date.getMonth() + 1).padStart(2, "0");
+    const d = String(date.getDate()).padStart(2, "0");
+    const h = String(date.getHours()).padStart(2, "0");
+    const min = String(date.getMinutes()).padStart(2, "0");
+    const s = String(date.getSeconds()).padStart(2, "0");
+    return `${y}/${m}/${d} ${h}:${min}:${s}`;
+  } catch {
+    return time;
+  }
+};
+
+// 审核状态相关函数
+const getAuditStatusText = (status: number | string | null | undefined) => {
+  if (status === null || status === undefined) return "待审核";
+  const statusNum = Number(status);
+  if (statusNum === 1 || status === "1" || status === "审核通过") return "审核通过";
+  if (statusNum === 2 || status === "2" || status === "审核拒绝") return "审核拒绝";
+  return "待审核";
+};
+
+const getAuditStatusType = (status: number | string | null | undefined) => {
+  if (status === null || status === undefined) return "info";
+  const statusNum = Number(status);
+  if (statusNum === 1 || status === "1" || status === "审核通过") return "success";
+  if (statusNum === 2 || status === "2" || status === "审核拒绝") return "danger";
+  return "info";
+};
+
+const isAuditApproved = (status: number | string | null | undefined) => {
+  if (status === null || status === undefined) return false;
+  const statusNum = Number(status);
+  return statusNum === 1 || status === "1" || status === "审核通过";
+};
+
+const isAuditRejected = (status: number | string | null | undefined) => {
+  if (status === null || status === undefined) return false;
+  const statusNum = Number(status);
+  return statusNum === 2 || status === "2" || status === "审核拒绝";
+};
+
+onMounted(async () => {
+  if (!id.value) return;
+  loading.value = true;
+  try {
+    const res = await getPersonCaseDetail({ id: id.value });
+    detail.value = res?.data ?? res ?? null;
+  } catch {
+    detail.value = null;
+  } finally {
+    loading.value = false;
+  }
+});
+</script>
+
+<style scoped lang="scss">
+.personnel-detail {
+  min-height: 100%;
+  padding: 16px;
+  background: #ffffff;
+}
+.header {
+  display: flex;
+  gap: 16px;
+  align-items: center;
+  margin-bottom: 16px;
+}
+.title {
+  margin: 0;
+  font-size: 18px;
+  font-weight: 600;
+}
+.audit-section {
+  margin-top: 24px;
+}
+</style>