2 Commits bd97a8b2c6 ... 842943c754

Auteur SHA1 Bericht Datum
  Huanyi 842943c754 修复服务项目的bug 1 week geleden
  Huanyi cba838824b 消息中心基本完成 1 week geleden

+ 12 - 0
src/api/fulfiller/anamaly/index.ts

@@ -13,6 +13,18 @@ export function getAnamalyList(query: AnamalyQuery) {
     });
 }
 
+/**
+ * 获取异常上报详情
+ * @param id 记录ID
+ */
+export function getAnamaly(id: number | string) {
+    return request({
+        url: `/fulfiller/anamaly/getInfo`,
+        method: 'get',
+        params: { id }
+    });
+}
+
 /**
  * 新增异常上报
  * @param data 表单数据

+ 25 - 0
src/api/system/notice/index.ts

@@ -43,3 +43,28 @@ export function delNotice(noticeId: string | number | Array<string | number>) {
     method: 'delete'
   });
 }
+
+// 获取我的消息列表
+export function listMyNotice(query: NoticeQuery): AxiosPromise<NoticeVO[]> {
+  return request({
+    url: '/system/notice/myList',
+    method: 'get',
+    params: query
+  });
+}
+
+// 标记消息为已读
+export function readNotice(noticeId: string | number) {
+  return request({
+    url: '/system/notice/read/' + noticeId,
+    method: 'put'
+  });
+}
+
+// 标记所有消息为已读
+export function readAllNotice() {
+  return request({
+    url: '/system/notice/readAll',
+    method: 'put'
+  });
+}

+ 21 - 4
src/api/system/notice/types.ts

@@ -6,13 +6,30 @@ export interface NoticeVO extends BaseEntity {
   status: string;
   remark: string;
   createByName: string;
+
+  // New fields from backend modification
+  id?: number | string;
+  title?: string;
+  type?: number | string;
+  content?: string;
+  senderType?: number;
+  sender?: number | string;
+  receiverType?: number;
+  receiver?: number | string;
+  readFlag?: boolean | string | number;
+  readTime?: string;
+  businessId?: number | string;
 }
 
 export interface NoticeQuery extends PageQuery {
-  noticeTitle: string;
-  createByName: string;
-  status: string;
-  noticeType: string;
+  noticeTitle?: string;
+  createByName?: string;
+  status?: string;
+  noticeType?: string;
+  // New fields from backend modification
+  title?: string;
+  type?: number | string;
+  readFlag?: boolean | string | number;
 }
 
 export interface NoticeForm {

+ 3 - 0
src/assets/icons/svg/message-center.svg

@@ -0,0 +1,3 @@
+<svg viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
+  <path d="M64 8c-20.2 0-36.5 16.3-36.5 36.5v28L16 84v8h96v-8l-11.5-11.5v-28C100.5 24.3 84.2 8 64 8zm0 112c7.7 0 14-6.3 14-14v-6H50v6c0 7.7 6.3 14 14 14z"/>
+</svg>

+ 17 - 3
src/components/PetDetailDrawer/index.vue

@@ -163,9 +163,19 @@ const historyOrders = ref([])
 const detailActiveTab = ref('info')
 const remarkDialogVisible = ref(false)
 const remarkContent = ref('')
+const internalServiceList = ref([])
 
-// 直接使用父组件传入的服务列表数据
-const serviceOptions = computed(() => props.serviceList || [])
+// 优先使用父组件传入的服务列表数据,若无则使用内部加载的数据
+const serviceOptions = computed(() => (props.serviceList && props.serviceList.length > 0) ? props.serviceList : internalServiceList.value)
+
+const getServiceList = () => {
+  if (props.serviceList && props.serviceList.length > 0) return;
+  import('@/api/service/list/index').then(api => {
+    api.listAllService().then((res) => {
+      internalServiceList.value = res.data || [];
+    });
+  });
+};
 
 const getServiceName = (serviceId) => {
   const item = serviceOptions.value.find((i) => i.id === serviceId)
@@ -199,6 +209,8 @@ const loadData = async (id) => {
   listSubOrderOnPet(id).then((res) => {
     historyOrders.value = res.data || []
   })
+
+  getServiceList()
 }
 
 const handleRemark = () => {
@@ -228,7 +240,9 @@ watch(() => props.visible, (val) => {
 })
 
 onMounted(() => {
-  // 服务列表数据由父组件传入,无需在这里请求
+  if (props.visible && props.petId) {
+    getServiceList()
+  }
 })
 </script>
 

+ 96 - 38
src/layout/components/Navbar.vue

@@ -6,20 +6,20 @@
 
     <div class="right-menu flex align-center">
       <template v-if="appStore.device !== 'mobile'">
-        <el-select
-          v-if="userId === 1 && tenantEnabled"
-          v-model="companyName"
-          class="min-w-244px"
-          clearable
-          filterable
-          reserve-keyword
-          :placeholder="proxy.$t('navbar.selectTenant')"
-          @change="dynamicTenantEvent"
-          @clear="dynamicClearEvent"
-        >
-          <el-option v-for="item in tenantList" :key="item.tenantId" :label="item.companyName" :value="item.tenantId"> </el-option>
-          <template #prefix><svg-icon icon-class="company" class="el-input__icon input-icon" /></template>
-        </el-select>
+<!--        <el-select-->
+<!--          v-if="userId === 1 && tenantEnabled"-->
+<!--          v-model="companyName"-->
+<!--          class="min-w-244px"-->
+<!--          clearable-->
+<!--          filterable-->
+<!--          reserve-keyword-->
+<!--          :placeholder="proxy.$t('navbar.selectTenant')"-->
+<!--          @change="dynamicTenantEvent"-->
+<!--          @clear="dynamicClearEvent"-->
+<!--        >-->
+<!--          <el-option v-for="item in tenantList" :key="item.tenantId" :label="item.companyName" :value="item.tenantId"> </el-option>-->
+<!--          <template #prefix><svg-icon icon-class="company" class="el-input__icon input-icon" /></template>-->
+<!--        </el-select>-->
 
         <search-menu ref="searchMenuRef" />
 <!--        <el-tooltip content="搜索" effect="dark" placement="bottom">-->
@@ -27,21 +27,23 @@
 <!--            <svg-icon class-name="search-icon" icon-class="search" />-->
 <!--          </div>-->
 <!--        </el-tooltip>-->
-<!--        &lt;!&ndash; 消息 &ndash;&gt;-->
-<!--        <el-tooltip :content="proxy.$t('navbar.message')" effect="dark" placement="bottom">-->
-<!--          <div>-->
-<!--            <el-popover placement="bottom" trigger="click" transition="el-zoom-in-top" :width="300" :persistent="false">-->
-<!--              <template #reference>-->
-<!--                <el-badge :value="newNotice > 0 ? newNotice : ''" :max="99">-->
-<!--                  <div class="right-menu-item hover-effect" style="display: block"><svg-icon icon-class="message" /></div>-->
-<!--                </el-badge>-->
-<!--              </template>-->
-<!--              <template #default>-->
-<!--                <notice></notice>-->
-<!--              </template>-->
-<!--            </el-popover>-->
-<!--          </div>-->
-<!--        </el-tooltip>-->
+        <!-- 消息 -->
+        <el-tooltip :content="proxy.$t('navbar.message')" effect="dark" placement="bottom">
+          <div class="message-wrapper">
+            <el-popover placement="bottom" trigger="click" transition="el-zoom-in-top" :width="350" :persistent="false" @before-enter="getNoticeList">
+              <template #reference>
+                <el-badge :value="newNotice > 0 ? newNotice : ''" :max="99" class="message-badge">
+                  <div class="right-menu-item hover-effect notice-btn">
+                    <svg-icon icon-class="message-center" style="width: 26px; height: 26px;" />
+                  </div>
+                </el-badge>
+              </template>
+              <template #default>
+                <notice ref="noticeRef" @read="updateNoticeCount"></notice>
+              </template>
+            </el-popover>
+          </div>
+        </el-tooltip>
         <!--        <el-tooltip content="Github" effect="dark" placement="bottom">-->
         <!--          <ruo-yi-git id="ruoyi-git" class="right-menu-item hover-effect" />-->
         <!--        </el-tooltip>-->
@@ -99,6 +101,7 @@ import { TenantVO } from '@/api/types';
 import notice from './notice/index.vue';
 import router from '@/router';
 import { ElMessageBoxOptions } from 'element-plus/es/components/message-box/src/message-box.type';
+import { listMyNotice } from '@/api/system/notice';
 
 const appStore = useAppStore();
 const userStore = useUserStore();
@@ -117,11 +120,50 @@ const dynamic = ref(false);
 const tenantEnabled = ref(true);
 // 搜索菜单
 const searchMenuRef = ref<InstanceType<typeof SearchMenu>>();
+const noticeRef = ref();
 
 const openSearchMenu = () => {
   searchMenuRef.value?.openSearch();
 };
 
+const getNoticeList = () => {
+  fetchUnreadCount();
+  noticeRef.value?.getList();
+};
+
+const updateNoticeCount = () => {
+  fetchUnreadCount();
+};
+
+const fetchUnreadCount = async () => {
+  try {
+    const res = await listMyNotice({
+      pageNum: 1,
+      pageSize: 1,
+      readFlag: false,
+      _t: new Date().getTime() // 增加随机字符串确保不走缓存
+    } as any);
+    newNotice.value = Number(res.total) || 0;
+  } catch (error) {
+    console.error('获取未读消息数量失败', error);
+  }
+};
+
+let pollingTimer: any = null;
+
+onMounted(() => {
+  fetchUnreadCount();
+  // 每10秒轮询一次后台获取未读消息,减少视觉频率偏差带来的困惑
+  pollingTimer = setInterval(fetchUnreadCount, 5000);
+});
+
+onUnmounted(() => {
+  if (pollingTimer) {
+    clearInterval(pollingTimer);
+    pollingTimer = null;
+  }
+});
+
 // 动态切换
 const dynamicTenantEvent = async (tenantId: string) => {
   if (companyName.value != null && companyName.value !== '') {
@@ -190,14 +232,14 @@ const handleCommand = (command: string) => {
     commandMap[command]();
   }
 };
-//用深度监听 消息
-watch(
-  () => noticeStore.state.value.notices,
-  (newVal) => {
-    newNotice.value = newVal.filter((item: any) => !item.read).length;
-  },
-  { deep: true }
-);
+// 已切换到 fetchUnreadCount 轮询模式,清理旧的隔离 store 监听
+// watch(
+//   () => noticeStore.state.value.notices,
+//   (newVal) => {
+//     newNotice.value = newVal.filter((item: any) => !item.read).length;
+//   },
+//   { deep: true }
+// );
 </script>
 
 <style lang="scss" scoped>
@@ -206,7 +248,9 @@ watch(
 }
 
 :deep(.el-badge__content.is-fixed) {
-  top: 12px;
+  top: 15px; // 稍微向下移动一点以适应更大的图标
+  right: 12px;
+  transform: translateY(-50%) translateX(50%) scale(0.9);
 }
 
 .flex {
@@ -217,6 +261,20 @@ watch(
   align-items: center;
 }
 
+.message-wrapper {
+  display: flex;
+  align-items: center;
+  height: 100%;
+}
+
+.notice-btn {
+  display: flex !important;
+  align-items: center;
+  justify-content: center;
+  height: 100%;
+  padding: 0 8px !important; // 恢复到接近标准按钮的间距
+}
+
 .navbar {
   height: 50px;
   overflow: hidden;

+ 240 - 68
src/layout/components/notice/index.vue

@@ -1,130 +1,302 @@
 <template>
-  <div v-loading="state.loading" class="layout-navbars-breadcrumb-user-news">
+  <div v-loading="loading" class="layout-notice">
     <div class="head-box">
-      <div class="head-box-title">通知公告</div>
-      <div class="head-box-btn" @click="readAll">全部已读</div>
+      <div class="head-box-title">消息通知</div>
+      <div class="head-box-btn" @click="handleReadAll">全部已读</div>
     </div>
-    <div v-loading="state.loading" class="content-box">
-      <template v-if="newsList.length > 0">
-        <div v-for="(v, k) in newsList" :key="k" class="content-box-item" @click="onNewsClick(k)">
-          <div class="item-conten">
-            <div>{{ v.message }}</div>
-            <div class="content-box-msg"></div>
-            <div class="content-box-time">{{ v.time }}</div>
+    <div class="content-box">
+      <template v-if="noticeList.length > 0">
+        <div v-for="item in noticeList" :key="item.id" class="content-box-item" @click="handleDetail(item)">
+          <div class="item-icon" :style="{ backgroundColor: getIconConfig(item).bgColor }">
+            <svg-icon :icon-class="getIconConfig(item).icon" />
           </div>
-          <!-- 已读/未读 -->
-          <span v-if="v.read" class="el-tag el-tag--success el-tag--mini read">已读</span>
-          <span v-else class="el-tag el-tag--danger el-tag--mini read">未读</span>
+          <div class="item-content">
+            <div class="item-title text-ellipsis">{{ item.title }}</div>
+            <div class="item-time">{{ parseTime(item.createTime, '{m}-{d} {h}:{i}') }}</div>
+          </div>
+          <div v-if="!item.readFlag" class="unread-dot"></div>
         </div>
       </template>
-      <el-empty v-else :description="'消息为空'"></el-empty>
+      <el-empty v-else :image-size="60" description="暂无未读消息"></el-empty>
     </div>
-    <div v-if="newsList.length > 0" class="foot-box" @click="onGoToGiteeClick">前往gitee</div>
+    <div class="foot-box" @click="handleViewAll">查看全部消息</div>
+
+    <!-- 消息详情对话框 -->
+    <el-dialog title="消息详情" v-model="detailDialog.open" width="500px" append-to-body destroy-on-close>
+      <div class="dialog-content-wrapper">
+        <div class="dialog-header">
+          <el-tag :type="detailDialog.tagType" effect="plain" size="small">{{ detailDialog.tagLabel }}</el-tag>
+          <span class="dialog-time">{{ detailDialog.createTime }}</span>
+        </div>
+        <div class="dialog-main-title">{{ detailDialog.title }}</div>
+        <div class="dialog-desc-block">
+          {{ detailDialog.content }}
+        </div>
+        <div class="dialog-action-link" @click="handleProcess">
+          <el-link type="primary" :underline="false">前往处理 >></el-link>
+        </div>
+      </div>
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button @click="detailDialog.open = false">关闭</el-button>
+        </div>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
-<script setup lang="ts" name="layoutBreadcrumbUserNews">
-import { useNoticeStore } from '@/store/modules/notice';
+<script setup lang="ts">
+// 此代码由AI生成
+import { listMyNotice, readNotice, readAllNotice } from '@/api/system/notice';
+import { NoticeVO } from '@/api/system/notice/types';
+import router from '@/router';
 
-const noticeStore = useNoticeStore();
-const { readAll } = useNoticeStore();
-
-// 定义变量内容
-const state = reactive({
-  loading: false
-});
-const newsList = ref([]) as any;
+const { proxy } = getCurrentInstance() as ComponentInternalInstance;
+const emit = defineEmits(['read']);
 
 /**
- * 初始化数据
- * @returns
+ * 此代码由AI生成
+ * 通知类型枚举(与后端监听器中的type值保持一致)
+ * 0 = 派单通知
+ * 1 = 拒单/取消订单通知
+ * 2 = 异常上报通知
  */
-const getTableData = async () => {
-  state.loading = true;
-  newsList.value = noticeStore.state.notices;
-  state.loading = false;
+const NOTICE_TYPE = {
+  DISPATCH: 0,
+  REJECT: 1,
+  ANOMALY: 2
 };
 
-//点击消息,写入已读
-const onNewsClick = (item: any) => {
-  newsList.value[item].read = true;
-  //并且写入pinia
-  noticeStore.state.notices = newsList.value;
+const noticeList = ref<NoticeVO[]>([]);
+const loading = ref(false);
+
+const detailDialog = reactive({
+  open: false,
+  title: '',
+  content: '',
+  createTime: '',
+  tagLabel: '',
+  tagType: '' as any,
+  type: undefined as number | string | undefined,
+  businessId: undefined as number | string | undefined
+});
+
+/** 此代码由AI生成 - 根据type枚举获取图标和颜色配置 */
+const getIconConfig = (item: any) => {
+  const type = item.type;
+  if (type === NOTICE_TYPE.ANOMALY) return { icon: 'bug', bgColor: '#f56c6c' };
+  if (type === NOTICE_TYPE.REJECT) return { icon: 'message-center', bgColor: '#f56c6c' };
+  if (type === NOTICE_TYPE.DISPATCH) return { icon: 'guide', bgColor: '#409eff' };
+  return { icon: 'message-center', bgColor: '#909399' };
 };
 
-// 前往通知中心点击
-const onGoToGiteeClick = () => {
-  window.open('https://gitee.com/dromara/RuoYi-Vue-Plus/tree/5.X/');
+/** 查询未读消息 */
+const getList = async () => {
+  loading.value = true;
+  try {
+    const res = await listMyNotice({
+      pageNum: 1,
+      pageSize: 5,
+      readFlag: false
+    } as any);
+    noticeList.value = res.rows;
+  } finally {
+    loading.value = false;
+  }
 };
 
-onMounted(() => {
-  nextTick(() => {
-    getTableData();
+/** 此代码由AI生成 - 消息详情(使用type枚举判断) */
+const handleDetail = (item: NoticeVO) => {
+  detailDialog.title = item.title || '';
+  detailDialog.content = item.content || '';
+  detailDialog.createTime = proxy?.parseTime(item.createTime, '{y}-{m}-{d} {h}:{i}') || '';
+  detailDialog.type = item.type;
+  detailDialog.businessId = item.businessId;
+
+  const type = item.type;
+  if (type === NOTICE_TYPE.ANOMALY) {
+    detailDialog.tagLabel = '异常上报';
+    detailDialog.tagType = 'danger';
+  } else if (type === NOTICE_TYPE.REJECT) {
+    detailDialog.tagLabel = '取消订单';
+    detailDialog.tagType = 'danger';
+  } else if (type === NOTICE_TYPE.DISPATCH) {
+    detailDialog.tagLabel = '新订单';
+    detailDialog.tagType = 'primary';
+  } else {
+    detailDialog.tagLabel = '系统消息';
+    detailDialog.tagType = 'info';
+  }
+
+  detailDialog.open = true;
+
+  if (!item.readFlag) {
+    readNotice(item.id as number).then(() => {
+      item.readFlag = true;
+      emit('read');
+      // 延迟一会重新获取列表或直接前端过滤
+      setTimeout(getList, 300);
+    });
+  }
+};
+
+/** 此代码由AI生成 - 前往处理(使用type枚举判断跳转路由) */
+const handleProcess = () => {
+  detailDialog.open = false;
+  const type = detailDialog.type;
+  if (type === NOTICE_TYPE.ANOMALY) {
+    router.push({ path: '/fulfiller/anamaly', query: { id: detailDialog.businessId } });
+  } else if (type === NOTICE_TYPE.REJECT || type === NOTICE_TYPE.DISPATCH) {
+    router.push({ path: '/order/orderList', query: { id: detailDialog.businessId } });
+  }
+};
+
+/** 全部已读 */
+const handleReadAll = () => {
+  proxy?.$modal.confirm('是否将全部未读消息标记为已读?').then(() => {
+    return readAllNotice();
+  }).then(() => {
+    proxy?.$modal.msgSuccess('全部已读操作成功');
+    emit('read');
+    getList();
   });
+};
+
+/** 查看全部 */
+const handleViewAll = () => {
+  router.push('/notice');
+};
+
+defineExpose({
+  getList
+});
+
+onMounted(() => {
+  getList();
 });
 </script>
 
 <style lang="scss" scoped>
-.layout-navbars-breadcrumb-user-news {
+.layout-notice {
   .head-box {
     display: flex;
     border-bottom: 1px solid var(--el-border-color-lighter);
     box-sizing: border-box;
     color: var(--el-text-color-primary);
     justify-content: space-between;
-    height: 35px;
+    height: 40px;
     align-items: center;
+    padding: 0 16px;
+    .head-box-title {
+      font-size: 14px;
+      font-weight: bold;
+    }
     .head-box-btn {
       color: var(--el-color-primary);
-      font-size: 13px;
+      font-size: 12px;
       cursor: pointer;
-      opacity: 0.8;
       &:hover {
-        opacity: 1;
+        text-decoration: underline;
       }
     }
   }
   .content-box {
-    height: 300px;
-    overflow: auto;
-    font-size: 13px;
+    max-height: 400px;
+    overflow-y: auto;
+    padding: 8px 0;
     .content-box-item {
-      padding-top: 12px;
       display: flex;
-      &:last-of-type {
-        padding-bottom: 12px;
+      align-items: center;
+      padding: 12px 16px;
+      cursor: pointer;
+      position: relative;
+      transition: background-color 0.2s;
+      &:hover {
+        background-color: var(--el-fill-color-light);
       }
-      .content-box-msg {
-        color: var(--el-text-color-secondary);
-        margin-top: 5px;
-        margin-bottom: 5px;
+      .item-icon {
+        width: 36px;
+        height: 36px;
+        border-radius: 4px;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        margin-right: 12px;
+        flex-shrink: 0;
+        .svg-icon {
+          color: #fff;
+          font-size: 18px;
+        }
       }
-      .content-box-time {
-        color: var(--el-text-color-secondary);
+      .item-content {
+        flex: 1;
+        overflow: hidden;
+        .item-title {
+          font-size: 14px;
+          color: var(--el-text-color-primary);
+          margin-bottom: 4px;
+        }
+        .item-time {
+          font-size: 12px;
+          color: var(--el-text-color-secondary);
+        }
       }
-      .item-conten {
-        width: 100%;
-        display: flex;
-        flex-direction: column;
+      .unread-dot {
+        width: 8px;
+        height: 8px;
+        background-color: var(--el-color-danger);
+        border-radius: 50%;
+        margin-left: 8px;
       }
     }
   }
   .foot-box {
-    height: 35px;
+    height: 40px;
     color: var(--el-color-primary);
     font-size: 13px;
     cursor: pointer;
-    opacity: 0.8;
     display: flex;
     align-items: center;
     justify-content: center;
     border-top: 1px solid var(--el-border-color-lighter);
     &:hover {
-      opacity: 1;
+      background-color: var(--el-fill-color-light);
     }
   }
-  :deep(.el-empty__description p) {
-    font-size: 13px;
+}
+
+.text-ellipsis {
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.dialog-content-wrapper {
+  .dialog-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 16px;
+    .dialog-time {
+      font-size: 12px;
+      color: #909399;
+    }
+  }
+  .dialog-main-title {
+    font-size: 16px;
+    font-weight: bold;
+    margin-bottom: 12px;
+  }
+  .dialog-desc-block {
+    background-color: #f8f8f8;
+    padding: 12px;
+    border-radius: 4px;
+    font-size: 14px;
+    line-height: 1.6;
+    margin-bottom: 16px;
+  }
+  .dialog-action-link {
+    text-align: right;
   }
 }
 </style>

+ 22 - 2
src/views/fulfiller/anamaly/index.vue

@@ -231,9 +231,10 @@
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, onMounted, getCurrentInstance, ComponentInternalInstance, toRefs } from 'vue';
+import { ref, reactive, onMounted, getCurrentInstance, ComponentInternalInstance, toRefs, watch } from 'vue';
+import { useRoute } from 'vue-router';
 import { ElMessage, ElMessageBox } from 'element-plus';
-import { getAnamalyList, addAnamaly, auditAnamaly, deleteAnamaly } from '@/api/fulfiller/anamaly';
+import { getAnamalyList, addAnamaly, auditAnamaly, deleteAnamaly, getAnamaly } from '@/api/fulfiller/anamaly';
 import type { AnamalyQuery, AnamalyVO, AnamalyForm } from '@/api/fulfiller/anamaly/types';
 import { listByNameAndPhoneNumber } from '@/api/fulfiller/fulfiller';
 import type { FulfillerSearchQuery } from '@/api/fulfiller/fulfiller/types';
@@ -243,6 +244,7 @@ import ImageUpload from '@/components/ImageUpload/index.vue';
 const { proxy } = getCurrentInstance() as ComponentInternalInstance;
 const { flf_anamaly_type } = toRefs<any>(proxy?.useDict('flf_anamaly_type'));
 
+const route = useRoute();
 const loading = ref(false);
 
 const queryParams = reactive<AnamalyQuery>({
@@ -422,6 +424,24 @@ const saveData = async () => {
 
 onMounted(() => {
   getList();
+  if (route.query.id) {
+    getAnamaly(route.query.id as any).then(res => {
+      if (res.data) {
+         handleOpenDrawer(res.data, res.data.status === 0 ? 'audit' : 'view');
+      }
+    });
+  }
+});
+
+// 监听路由参数变化,实现从消息直达详情
+watch(() => route.query.id, (newId) => {
+  if (newId) {
+    getAnamaly(newId as any).then(res => {
+      if (res.data) {
+         handleOpenDrawer(res.data, res.data.status === 0 ? 'audit' : 'view');
+      }
+    });
+  }
 });
 </script>
 

+ 384 - 0
src/views/notice/index.vue

@@ -0,0 +1,384 @@
+<template>
+  <div class="message-container">
+    <div class="message-header">
+      <div class="title">消息通知</div>
+      <el-button @click="handleReadAll">全部已读</el-button>
+    </div>
+
+    <!-- 顶部页签 -->
+    <el-tabs v-model="activeTab" @tab-click="handleTabClick">
+      <el-tab-pane label="全部消息" name="all"></el-tab-pane>
+      <el-tab-pane label="未读消息" name="unread"></el-tab-pane>
+      <el-tab-pane label="拒单/取消" name="reject"></el-tab-pane>
+      <el-tab-pane label="异常上报" name="anomaly"></el-tab-pane>
+    </el-tabs>
+
+    <!-- 消息列表 -->
+    <div class="message-list" v-loading="loading">
+      <template v-if="noticeList && noticeList.length > 0">
+        <div class="message-item" v-for="item in noticeList" :key="item.id"
+          :class="{ 'is-unread': !(item.readFlag || item.readFlag === '1' || item.readFlag === 1) }"
+          @click="handleDetail(item)">
+          <el-badge :is-dot="!(item.readFlag || item.readFlag === '1' || item.readFlag === 1)"
+            class="icon-badge top-left-dot">
+            <div class="item-icon" :style="{ backgroundColor: getIconConfig(item).bgColor }">
+              <svg-icon :icon-class="getIconConfig(item).icon" />
+            </div>
+          </el-badge>
+
+          <div class="item-content">
+            <div class="item-title">{{ item.title }}</div>
+            <div class="item-desc">{{ item.content }}</div>
+          </div>
+
+          <div class="item-time">
+            {{ parseTime(item.createTime, '{y}-{m}-{d} {h}:{i}') }}
+          </div>
+        </div>
+      </template>
+      <el-empty v-else description="暂无消息数据" />
+    </div>
+
+    <!-- 分页 -->
+    <div class="pagination-container" v-show="total > 0">
+      <el-pagination v-model:current-page="queryParams.pageNum" v-model:page-size="queryParams.pageSize"
+        layout="total, prev, pager, next, jumper" :total="total" @current-change="getList" />
+    </div>
+
+    <!-- 消息详情对话框 -->
+    <el-dialog title="消息详情" v-model="detailDialog.open" width="600px" append-to-body destroy-on-close>
+      <div class="dialog-content-wrapper">
+        <div class="dialog-header">
+          <el-tag :type="detailDialog.tagType" effect="plain" size="small">{{ detailDialog.tagLabel }}</el-tag>
+          <span class="dialog-time">{{ detailDialog.createTime }}</span>
+        </div>
+        <div class="dialog-main-title">{{ detailDialog.title }}</div>
+        <div class="dialog-desc-block">
+          {{ detailDialog.content }}
+        </div>
+        <div class="dialog-action-link" @click="handleProcess">
+          <el-link type="primary" :underline="false">前往处理 >></el-link>
+        </div>
+      </div>
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button @click="detailDialog.open = false">关闭</el-button>
+        </div>
+      </template>
+    </el-dialog>
+
+  </div>
+</template>
+
+<script setup lang="ts">
+// 此代码由AI生成
+import { ref, reactive, toRefs, getCurrentInstance, ComponentInternalInstance } from 'vue';
+import { useRouter } from 'vue-router';
+import { listMyNotice, readNotice, readAllNotice } from '@/api/system/notice';
+import { NoticeQuery, NoticeVO } from '@/api/system/notice/types';
+
+const router = useRouter();
+// eslint-disable-next-line camelcase
+const { proxy } = getCurrentInstance() as ComponentInternalInstance;
+
+/**
+ * 此代码由AI生成
+ * 通知类型枚举(与后端监听器中的type值保持一致)
+ * 0 = 派单通知
+ * 1 = 拒单/取消订单通知
+ * 2 = 异常上报通知
+ */
+const NOTICE_TYPE = {
+  DISPATCH: 0,
+  REJECT: 1,
+  ANOMALY: 2
+};
+
+const activeTab = ref('all');
+const noticeList = ref<NoticeVO[]>([]);
+const loading = ref(true);
+const total = ref(0);
+
+const detailDialog = reactive({
+  open: false,
+  title: '',
+  content: '',
+  createTime: '',
+  tagLabel: '',
+  tagType: '' as any,
+  type: undefined as number | string | undefined,
+  businessId: undefined as number | string | undefined
+});
+
+const data = reactive({
+  queryParams: {
+    pageNum: 1,
+    pageSize: 10,
+    title: undefined,
+    type: undefined,
+    readFlag: undefined
+  } as NoticeQuery
+});
+
+const { queryParams } = toRefs(data);
+
+/** 此代码由AI生成 - 根据type枚举获取图标和颜色配置 */
+const getIconConfig = (item: any) => {
+  const type = item.type;
+  if (type === NOTICE_TYPE.ANOMALY) return { icon: 'bug', bgColor: '#f56c6c' };
+  if (type === NOTICE_TYPE.REJECT) return { icon: 'message-center', bgColor: '#f56c6c' };
+  if (type === NOTICE_TYPE.DISPATCH) return { icon: 'guide', bgColor: '#409eff' };
+  return { icon: 'message-center', bgColor: '#909399' };
+};
+
+/** 查询我的消息列表 */
+function getList() {
+  loading.value = true;
+  listMyNotice(queryParams.value).then((response: any) => {
+    noticeList.value = response.rows;
+    total.value = response.total;
+    loading.value = false;
+  });
+}
+
+/** 此代码由AI生成 - 切换页签(使用type枚举过滤) */
+function handleTabClick(tab: any) {
+  queryParams.value.pageNum = 1;
+  queryParams.value.type = undefined;
+  queryParams.value.title = undefined;
+  queryParams.value.readFlag = undefined;
+
+  const paneName = tab.paneName as string;
+  if (paneName === 'unread') {
+    queryParams.value.readFlag = false as any;
+  } else if (paneName === 'reject') {
+    queryParams.value.type = NOTICE_TYPE.REJECT as any;
+  } else if (paneName === 'anomaly') {
+    queryParams.value.type = NOTICE_TYPE.ANOMALY as any;
+  }
+
+  getList();
+}
+
+/** 此代码由AI生成 - 消息详情点击与设为已读(使用type枚举判断) */
+function handleDetail(item: NoticeVO) {
+  detailDialog.title = item.title || '';
+  detailDialog.content = item.content || '';
+  detailDialog.createTime = proxy?.parseTime(item.createTime, '{y}-{m}-{d} {h}:{i}') || '';
+  detailDialog.type = item.type;
+  detailDialog.businessId = item.businessId;
+
+  const type = item.type;
+  if (type === NOTICE_TYPE.ANOMALY) {
+    detailDialog.tagLabel = '异常上报';
+    detailDialog.tagType = 'danger';
+  } else if (type === NOTICE_TYPE.REJECT) {
+    detailDialog.tagLabel = '取消订单';
+    detailDialog.tagType = 'danger';
+  } else if (type === NOTICE_TYPE.DISPATCH) {
+    detailDialog.tagLabel = '新订单';
+    detailDialog.tagType = 'primary';
+  } else {
+    detailDialog.tagLabel = '系统消息';
+    detailDialog.tagType = 'info';
+  }
+
+  detailDialog.open = true;
+
+  // 如果状态未读,发请求
+  if (!(item.readFlag || item.readFlag === '1' || item.readFlag === 1)) {
+    readNotice(item.id as number).then(() => {
+      item.readFlag = true as any; // 本地变更为已读
+    });
+  }
+}
+
+/** 此代码由AI生成 - 前往处理(使用type枚举判断跳转路由) */
+function handleProcess() {
+  detailDialog.open = false;
+  const type = detailDialog.type;
+  if (type === NOTICE_TYPE.ANOMALY) {
+    router.push({ path: '/fulfiller/anamaly', query: { id: detailDialog.businessId } });
+  } else if (type === NOTICE_TYPE.REJECT || type === NOTICE_TYPE.DISPATCH) {
+    router.push({ path: '/order/orderList', query: { id: detailDialog.businessId } });
+  }
+}
+
+/** 全部标为已读操作 */
+function handleReadAll() {
+  proxy?.$modal.confirm('是否确认将全部未读消息标记为已读?').then(function () {
+    return readAllNotice();
+  }).then(() => {
+    proxy?.$modal.msgSuccess('全部已读操作成功');
+    getList();
+  }).catch(() => { });
+}
+
+getList();
+</script>
+
+<style lang="scss" scoped>
+.message-container {
+  padding: 24px;
+  background-color: #fff;
+  margin: 20px;
+  border-radius: 4px;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
+
+  .message-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 24px;
+
+    .title {
+      font-size: 18px;
+      font-weight: 600;
+      color: #303133;
+    }
+  }
+
+  /* 隐藏页签下方的灰线,按照参考图页签非常简洁 */
+  :deep(.el-tabs__nav-wrap::after) {
+    background-color: #f0f0f0;
+    height: 1px;
+  }
+
+  .message-list {
+    min-height: 400px;
+  }
+
+  .message-item {
+    display: flex;
+    padding: 20px 24px;
+    border-bottom: 1px solid #f0f0f0;
+    cursor: pointer;
+    background-color: #fff;
+    align-items: flex-start;
+    transition: background-color 0.3s ease;
+
+    &.is-unread {
+      // 图中未读消息带有浅绿偏黄的背景色
+      background-color: #f3faee;
+    }
+
+    &:hover {
+      background-color: #f5f7fa;
+
+      &.is-unread {
+        background-color: #ecf3e5; // 微调悬停效果
+      }
+    }
+
+    // 取消最后一条的边框
+    &:last-child {
+      border-bottom: none;
+    }
+  }
+
+  .icon-badge.top-left-dot {
+    margin-right: 20px;
+    flex-shrink: 0;
+
+    :deep(.el-badge__content.is-fixed.is-dot) {
+      right: auto;
+      left: 0;
+      transform: translateY(-30%) translateX(-30%);
+    }
+  }
+
+  .item-icon {
+    width: 48px;
+    height: 48px;
+    border-radius: 8px;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+
+    .svg-icon {
+      font-size: 24px;
+      color: #fff;
+    }
+  }
+
+  .item-content {
+    flex: 1;
+    overflow: hidden;
+
+    .item-title {
+      font-size: 16px;
+      font-weight: bold;
+      color: #303133;
+      margin-bottom: 8px;
+      line-height: 1.4;
+    }
+
+    .item-desc {
+      font-size: 14px;
+      color: #606266;
+      line-height: 1.5;
+      // 单行截断展示如果太长可以放开注释
+      // white-space: nowrap;
+      // overflow: hidden;
+      // text-overflow: ellipsis;
+    }
+  }
+
+  .item-time {
+    width: 150px;
+    text-align: right;
+    font-size: 13px;
+    font-family: monospace;
+    color: #909399;
+    flex-shrink: 0;
+  }
+
+  .pagination-container {
+    margin-top: 24px;
+    display: flex;
+    justify-content: flex-end;
+  }
+}
+
+/* 消息详情对话框内部样式 */
+.dialog-content-wrapper {
+  padding: 0 10px;
+
+  .dialog-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 20px;
+
+    .dialog-time {
+      font-size: 13px;
+      color: #909399;
+    }
+  }
+
+  .dialog-main-title {
+    font-size: 18px;
+    font-weight: bold;
+    color: #303133;
+    margin-bottom: 16px;
+  }
+
+  .dialog-desc-block {
+    background-color: #f8f8f8;
+    padding: 16px;
+    border-radius: 4px;
+    font-size: 14px;
+    color: #606266;
+    line-height: 1.6;
+    margin-bottom: 16px;
+  }
+
+  .dialog-action-link {
+    text-align: right;
+
+    .el-link {
+      font-size: 14px;
+    }
+  }
+}
+</style>

+ 1 - 1
src/views/order/dispatch/components/DispatchDialog.vue

@@ -158,7 +158,7 @@
     </el-dialog>
 
     <CustomerDetailDrawer v-model:visible="customerDialogVisible" :customer-id="customerId" :service-list="serviceList" :area-station-list="areaStationList" />
-    <PetDetailDrawer v-model:visible="petDialogVisible" :pet-id="petId" />
+    <PetDetailDrawer v-model:visible="petDialogVisible" :pet-id="petId" :service-list="serviceOptions" />
 </template>
 
 <script setup>

+ 1 - 1
src/views/order/orderList/components/DispatchDialog.vue

@@ -158,7 +158,7 @@
     </el-dialog>
 
     <CustomerDetailDrawer v-model:visible="customerDialogVisible" :customer-id="customerId" :area-station-list="areaStationList" />
-    <PetDetailDrawer v-model:visible="petDialogVisible" :pet-id="petId" />
+    <PetDetailDrawer v-model:visible="petDialogVisible" :pet-id="petId" :service-list="serviceOptions" />
 </template>
 
 <script setup>

+ 18 - 5
src/views/order/orderList/index.vue

@@ -203,7 +203,7 @@
 
 <script setup lang="ts">
 import { ref, reactive, onMounted, onUnmounted, nextTick, getCurrentInstance } from 'vue';
-import { useRouter } from 'vue-router';
+import { useRouter, useRoute } from 'vue-router';
 import { ElMessage, ElMessageBox } from 'element-plus';
 import fulfillerEnums from '@/json/fulfiller.json';
 import OrderDetailDrawer from './components/OrderDetailDrawer.vue';
@@ -230,6 +230,7 @@ import { getPet } from '@/api/archieves/pet';
 import { getCustomer } from '@/api/archieves/customer';
 import { addComplaint } from '@/api/fulfiller/complaint';
 
+const route = useRoute();
 const loading = ref(false);
 
 const filters = reactive({
@@ -256,11 +257,23 @@ onMounted(() => {
   getServiceList();
   getAreaStationList();
   handleSearch();
+  
+  if (route.query.id) {
+    handleDetail({ id: route.query.id as any });
+  }
+
   timer = setInterval(() => {
     handleSearch(true);
   }, 5000);
 });
 
+// 监听路由参数变化,实现从消息直达详情
+watch(() => route.query.id, (newId) => {
+  if (newId) {
+    handleDetail({ id: newId as any });
+  }
+});
+
 onUnmounted(() => {
   if (timer) {
     clearInterval(timer);
@@ -385,10 +398,10 @@ const getServiceModeTag = (row) => {
 
 const getServiceOrderTypeTag = (row) => {
   const t = row?.type;
-  if (t === 0 || t === '0') return { label: '接', type: 'primary' };
-  if (t === 1 || t === '1') return { label: '送', type: 'success' };
-  if (t === 2 || t === '2') return { label: '单程接', type: 'primary' };
-  if (t === 3 || t === '3') return { label: '单程送', type: 'success' };
+  if (t === 0 || t === '0') return { label: '接', type: 'primary' as const };
+  if (t === 1 || t === '1') return { label: '送', type: 'success' as const };
+  if (t === 2 || t === '2') return { label: '单程接', type: 'primary' as const };
+  if (t === 3 || t === '3') return { label: '单程送', type: 'success' as const };
   return null;
 };
 

+ 6 - 0
src/views/order/purchase/components/TransportForm.vue

@@ -83,6 +83,10 @@
         </div>
       </div>
     </div>
+
+    <div class="remark-section">
+      <el-input v-model="transportData.other" type="textarea" :rows="4" placeholder="请添加接送备注 (如宠物性格、接送要求等)" />
+    </div>
   </div>
 </template>
 
@@ -123,4 +127,6 @@ const emit = defineEmits(['change'])
 .required-field { margin-bottom: 10px; }
 .required-field :deep(.el-input__wrapper) { box-shadow: 0 0 0 1px #f56c6c inset; }
 .required-field :deep(.el-input__wrapper):hover { box-shadow: 0 0 0 1px #f56c6c inset !important; }
+
+.remark-section { background: #fdfdfd; border: 1px dashed #dcdfe6; padding: 15px; border-radius: 6px; margin-top: 20px; }
 </style>

+ 13 - 10
src/views/order/purchase/index.vue

@@ -257,7 +257,8 @@ const form = reactive({
     dropPrice: 35,
     subType: 'round',
     pickStartRegion: [], pickStartDetail: '', pickEndRegion: [], pickEndDetail: '', pickContact: '', pickPhone: '', pickTime: '',
-    dropStartRegion: [], dropStartDetail: '', dropEndRegion: [], dropEndDetail: '', dropContact: '', dropPhone: '', dropTime: ''
+    dropStartRegion: [], dropStartDetail: '', dropEndRegion: [], dropEndDetail: '', dropContact: '', dropPhone: '', dropTime: '',
+    other: ''
   },
   feeding: {
     pkgId: '', price: 68,
@@ -573,7 +574,8 @@ const resetForm = () => {
     dropPrice: 35,
     subType: 'round',
     pickStartRegion: [], pickStartDetail: '', pickEndRegion: [], pickEndDetail: '', pickContact: '', pickPhone: '', pickTime: '',
-    dropStartRegion: [], dropStartDetail: '', dropEndRegion: [], dropEndDetail: '', dropContact: '', dropPhone: '', dropTime: ''
+    dropStartRegion: [], dropStartDetail: '', dropEndRegion: [], dropEndDetail: '', dropContact: '', dropPhone: '', dropTime: '',
+    other: ''
   }
   form.feeding = {
     pkgId: '', price: 68,
@@ -761,19 +763,20 @@ onMounted(() => {
 
 .create-layout {
   display: flex;
-  gap: 20px;
   align-items: flex-start;
-  max-width: 1400px;
-  margin: 0 auto;
+  width: 100%;
+  margin: 0;
 }
 
 /* Left Content */
 .form-container {
-  flex: 1;
+  width: 70%;
+  padding-right: 20px;
   min-width: 0;
   display: flex;
   flex-direction: column;
   gap: 20px;
+  box-sizing: border-box;
 }
 
 .section-card {
@@ -1052,7 +1055,7 @@ onMounted(() => {
 
 /* Sidebar */
 .summary-sidebar {
-  width: 320px;
+  width: 30%;
   flex-shrink: 0;
 }
 
@@ -1061,9 +1064,9 @@ onMounted(() => {
   border-radius: 8px;
   box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
   position: fixed;
-  top: 150px;
-  right: 80px;
-  width: 320px;
+  top: 154px;
+  right: 10px;
+  width: 25%;
   z-index: 2000;
 }
 

+ 2 - 2
vite.config.ts

@@ -25,8 +25,8 @@ export default defineConfig(({ mode, command }) => {
       // open: true,
       proxy: {
         [env.VITE_APP_BASE_API]: {
-          target: 'http://127.0.0.1:8080',
-          // target: 'http://www.hoomeng.pet/api',
+          // target: 'http://127.0.0.1:8080',
+          target: 'http://www.hoomeng.pet/api',
           changeOrigin: true,
           ws: true,
           rewrite: (path) => path.replace(new RegExp('^' + env.VITE_APP_BASE_API), '')