Browse Source

优化若干内容

Pchen. 10 hours ago
parent
commit
ae79c09167

+ 10 - 1
src/api/user.js

@@ -9,6 +9,7 @@ const api = {
   AdminUserList: '/Admin/User/GetUserList',
   AdminUserList: '/Admin/User/GetUserList',
   AdminSetSendCountAutoApprove: '/Admin/User/SetSendCountAutoApprove',
   AdminSetSendCountAutoApprove: '/Admin/User/SetSendCountAutoApprove',
   AdminSetUserBan: '/Admin/User/SetUserBan',
   AdminSetUserBan: '/Admin/User/SetUserBan',
+  AdminSetUserVip: '/Admin/User/SetUserVip',
   AdminChangeLepaoCount: '/Admin/User/ChangeLepaoCount',
   AdminChangeLepaoCount: '/Admin/User/ChangeLepaoCount',
   AdminGetReqLog: '/Admin/User/GetReqLog',
   AdminGetReqLog: '/Admin/User/GetReqLog',
   AdminGetReqLogDetail: '/Admin/User/GetReqLogDetail'
   AdminGetReqLogDetail: '/Admin/User/GetReqLogDetail'
@@ -62,6 +63,14 @@ export function adminSetUserBan(parameter) {
   })
   })
 }
 }
 
 
+export function adminSetUserVip(parameter) {
+  return request({
+    url: api.AdminSetUserVip,
+    method: 'post',
+    data: parameter
+  })
+}
+
 export function BindEmail(parameter) {
 export function BindEmail(parameter) {
   return request({
   return request({
     url: api.BindEmail,
     url: api.BindEmail,
@@ -100,4 +109,4 @@ export function adminGetUserList(parameter) {
     method: 'get',
     method: 'get',
     params: parameter
     params: parameter
   })
   })
-}
+}

+ 2 - 2
src/components/AIChat/index.vue

@@ -414,13 +414,14 @@ const updateUnreadByConversations = (list) => {
   const seen = loadSeen()
   const seen = loadSeen()
   const next = {}
   const next = {}
   list.forEach((item) => {
   list.forEach((item) => {
+    if (item.channel === 'wechat') return
     if (item.id === currentConversationId.value && visible.value) return
     if (item.id === currentConversationId.value && visible.value) return
     if (Number(item.last_message_time || 0) > Number(seen[item.id] || 0)) next[item.id] = 1
     if (Number(item.last_message_time || 0) > Number(seen[item.id] || 0)) next[item.id] = 1
   })
   })
   unreadMap.value = next
   unreadMap.value = next
   emit('unread-change', unreadTotal.value)
   emit('unread-change', unreadTotal.value)
   if (!visible.value && unreadTotal.value > oldTotal) {
   if (!visible.value && unreadTotal.value > oldTotal) {
-    Notification.info({ title: '小妍助理有新消息', content: '点击右下角助手查看回复' })
+    Notification.info({ title: '小妍助理有新消息', content: '点击右下角AI助手查看回复' })
   }
   }
 }
 }
 
 
@@ -495,7 +496,6 @@ const pollNewMessages = async () => {
   if (!filteredIncoming.length) return
   if (!filteredIncoming.length) return
   const hasNewMessages = mergeIncomingMessages(filteredIncoming)
   const hasNewMessages = mergeIncomingMessages(filteredIncoming)
   if (!hasNewMessages) return
   if (!hasNewMessages) return
-  // if (hasAssistant) Notification.info({ title: '小妍助理有新回复', content: incoming[incoming.length - 1]?.content || '收到新消息' })
   markCurrentSeen()
   markCurrentSeen()
   scrollToBottom()
   scrollToBottom()
   await loadConversations()
   await loadConversations()

+ 66 - 17
src/components/userCard/userCard.vue

@@ -190,30 +190,51 @@ onUnmounted(stopPolling)
 
 
 .user-card {
 .user-card {
   --stat-color: rgb(var(--primary-6));
   --stat-color: rgb(var(--primary-6));
-  background: @store-card-bg;
-  border: 1px solid @store-card-border;
+  position: relative;
+  overflow: hidden;
+  background:
+    radial-gradient(circle at 92% 8%, rgba(82, 184, 120, 0.1), transparent 28%),
+    linear-gradient(135deg, rgba(255, 255, 255, 0.99) 0%, rgba(255, 255, 255, 0.96) 48%, rgba(250, 253, 251, 0.98) 100%);
+  border: 1px solid rgba(196, 218, 205, 0.86);
   border-radius: @store-radius;
   border-radius: @store-radius;
-  box-shadow: @store-shadow;
-  padding: 20px 24px;
+  box-shadow:
+    0 14px 36px rgba(27, 48, 34, 0.08),
+    0 1px 0 rgba(255, 255, 255, 0.9) inset;
+  padding: 22px 28px;
+
+  &::before {
+    content: '';
+    position: absolute;
+    inset: 1px;
+    border-radius: calc(@store-radius - 1px);
+    border: 1px solid rgba(255, 255, 255, 0.72);
+    pointer-events: none;
+  }
 
 
   &--goods {
   &--goods {
-    background: linear-gradient(135deg, #f4faf6 0%, #e8f5ec 100%);
+    background:
+      radial-gradient(circle at 92% 8%, rgba(82, 184, 120, 0.14), transparent 30%),
+      linear-gradient(135deg, rgba(255, 255, 255, 0.99) 0%, rgba(255, 255, 255, 0.96) 50%, rgba(248, 252, 250, 0.98) 100%);
+    border-color: rgba(183, 211, 194, 0.92);
     --stat-color: @store-primary;
     --stat-color: @store-primary;
   }
   }
 
 
   &__main {
   &__main {
+    position: relative;
+    z-index: 1;
     display: flex;
     display: flex;
     flex-wrap: wrap;
     flex-wrap: wrap;
     align-items: center;
     align-items: center;
     justify-content: space-between;
     justify-content: space-between;
-    gap: 20px;
+    gap: 22px;
   }
   }
 
 
   &__label {
   &__label {
     display: block;
     display: block;
     font-size: 0.85rem;
     font-size: 0.85rem;
     color: @store-text-muted;
     color: @store-text-muted;
-    margin-bottom: 4px;
+    margin-bottom: 6px;
+    font-weight: 500;
   }
   }
 
 
   &__stat-row {
   &__stat-row {
@@ -242,18 +263,24 @@ onUnmounted(stopPolling)
   min-height: 28px;
   min-height: 28px;
   max-width: min(210px, 100%);
   max-width: min(210px, 100%);
   padding: 4px 7px 4px 5px;
   padding: 4px 7px 4px 5px;
-  border: 1px solid rgba(236, 133, 171, 0.18);
+  border: 1px solid rgba(142, 161, 150, 0.24);
   border-radius: 999px;
   border-radius: 999px;
-  background: linear-gradient(135deg, rgba(255, 246, 250, 0.96), rgba(255, 250, 240, 0.92));
-  color: #94465f;
+  background: rgba(244, 248, 246, 0.82);
+  color: #66756d;
   font-size: 12px;
   font-size: 12px;
   cursor: pointer;
   cursor: pointer;
-  box-shadow: 0 8px 18px rgba(236, 133, 171, 0.08);
-  transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
+  box-shadow:
+    0 6px 16px rgba(27, 48, 34, 0.05),
+    0 1px 0 rgba(255, 255, 255, 0.78) inset;
+  backdrop-filter: blur(8px);
+  transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease, background 0.18s ease;
 
 
   &:hover {
   &:hover {
-    border-color: rgba(236, 133, 171, 0.34);
-    box-shadow: 0 10px 22px rgba(236, 133, 171, 0.12);
+    background: rgba(255, 255, 255, 0.9);
+    border-color: rgba(82, 184, 120, 0.28);
+    box-shadow:
+      0 9px 20px rgba(27, 48, 34, 0.07),
+      0 1px 0 rgba(255, 255, 255, 0.82) inset;
     transform: translateY(-1px);
     transform: translateY(-1px);
   }
   }
 
 
@@ -262,6 +289,17 @@ onUnmounted(stopPolling)
     background: linear-gradient(135deg, rgba(255, 245, 248, 0.98), rgba(255, 247, 237, 0.96));
     background: linear-gradient(135deg, rgba(255, 245, 248, 0.98), rgba(255, 247, 237, 0.96));
     color: #8f3f58;
     color: #8f3f58;
     box-shadow: 0 8px 18px rgba(249, 168, 212, 0.1);
     box-shadow: 0 8px 18px rgba(249, 168, 212, 0.1);
+
+    &:hover {
+      border-color: rgba(236, 133, 171, 0.34);
+      box-shadow: 0 10px 22px rgba(236, 133, 171, 0.12);
+    }
+
+    .vip-strip__badge {
+      background: linear-gradient(135deg, #f9a8d4, #8b5cf6);
+      color: #fff;
+      box-shadow: 0 6px 14px rgba(139, 92, 246, 0.18);
+    }
   }
   }
 
 
   &__badge {
   &__badge {
@@ -271,11 +309,11 @@ onUnmounted(stopPolling)
     min-width: 30px;
     min-width: 30px;
     padding: 0 6px;
     padding: 0 6px;
     border-radius: 999px;
     border-radius: 999px;
-    background: linear-gradient(135deg, #f9a8d4, #8b5cf6);
-    color: #fff;
+    background: linear-gradient(135deg, #edf3ef, #d4ded8);
+    color: #5e6f65;
     font-size: 10px;
     font-size: 10px;
     font-weight: 900;
     font-weight: 900;
-    box-shadow: 0 6px 14px rgba(139, 92, 246, 0.18);
+    box-shadow: 0 5px 12px rgba(27, 48, 34, 0.06);
   }
   }
 
 
   &__text {
   &__text {
@@ -306,7 +344,17 @@ onUnmounted(stopPolling)
   text-align: center;
   text-align: center;
 }
 }
 
 
+@media (min-width: 641px) {
+  .vip-strip {
+    margin-left: 14px;
+  }
+}
+
 @media (max-width: 640px) {
 @media (max-width: 640px) {
+  .user-card {
+    padding: 20px 24px;
+  }
+
   .user-card__stat-row {
   .user-card__stat-row {
     align-items: flex-start;
     align-items: flex-start;
     gap: 8px;
     gap: 8px;
@@ -315,6 +363,7 @@ onUnmounted(stopPolling)
   .vip-strip {
   .vip-strip {
     width: auto;
     width: auto;
     max-width: 100%;
     max-width: 100%;
+    margin-left: 0;
     justify-content: flex-start;
     justify-content: flex-start;
   }
   }
 }
 }

+ 58 - 20
src/layout/default-layout.vue

@@ -44,9 +44,16 @@
       />
       />
     </a-layout>
     </a-layout>
 
 
-    <a-badge v-if="aiAvailable" :count="aiUnread" :dot="aiUnread > 0" class="aiButtonBadge">
-      <a-avatar class="aiButton" @click="chat = !chat">
-        <img alt="AI助手" src="@/assets/img/ai.svg" />
+    <a-badge
+      v-if="aiAvailable"
+      :count="aiUnread"
+      :dot="aiUnread > 0"
+      class="aiButtonBadge"
+      @pointerdown="handlePointerDown"
+      @click="handleAiButtonClick"
+    >
+      <a-avatar class="aiButton">
+        <img alt="AI助手" src="@/assets/img/ai.svg" draggable="false"/>
       </a-avatar>
       </a-avatar>
     </a-badge>
     </a-badge>
 
 
@@ -72,7 +79,7 @@
 </template>
 </template>
 
 
 <script setup>
 <script setup>
-import { computed, onMounted, onBeforeUnmount, ref, onUnmounted } from 'vue'
+import { computed, nextTick, onMounted, onBeforeUnmount, ref, onUnmounted } from 'vue'
 import { eventBus } from '@/utils/eventBus'
 import { eventBus } from '@/utils/eventBus'
 import { Notification } from '@arco-design/web-vue'
 import { Notification } from '@arco-design/web-vue'
 import { getUnreadPopup, markPopupRead } from '@/api/public'
 import { getUnreadPopup, markPopupRead } from '@/api/public'
@@ -96,6 +103,10 @@ const aiAvailable = computed(() => !!(userStore.uuid && userStore.session))
 
 
 // 按钮拖动
 // 按钮拖动
 let isDragging = false
 let isDragging = false
+let hasDragged = false
+let suppressNextClick = false
+let dragStartX = 0
+let dragStartY = 0
 let offsetX = 0
 let offsetX = 0
 let offsetY = 0
 let offsetY = 0
 let button = null
 let button = null
@@ -105,20 +116,44 @@ const openAI = (payload = {}) => {
   chat.value = true
   chat.value = true
 }
 }
 
 
-function handleMouseDown(e) {
+function handleAiButtonClick(e) {
+  if (suppressNextClick) {
+    e.preventDefault()
+    e.stopPropagation()
+    suppressNextClick = false
+    return
+  }
+  chat.value = !chat.value
+}
+
+function handlePointerDown(e) {
+  if (e.pointerType === 'mouse' && e.button !== 0) return
+  button = e.currentTarget
+  if (!button) return
   isDragging = true
   isDragging = true
+  hasDragged = false
   const rect = button.getBoundingClientRect()
   const rect = button.getBoundingClientRect()
+  dragStartX = e.clientX
+  dragStartY = e.clientY
   offsetX = e.clientX - rect.left
   offsetX = e.clientX - rect.left
   offsetY = e.clientY - rect.top
   offsetY = e.clientY - rect.top
 
 
-  document.addEventListener('mousemove', handleMouseMove)
-  document.addEventListener('mouseup', handleMouseUp)
+  window.addEventListener('pointermove', handlePointerMove, { passive: false })
+  window.addEventListener('pointerup', handlePointerUp)
+  window.addEventListener('pointercancel', handlePointerUp)
 }
 }
 
 
-function handleMouseMove(e) {
+function handlePointerMove(e) {
   if (!isDragging) return
   if (!isDragging) return
-  const x = e.clientX - offsetX
-  const y = e.clientY - offsetY
+  e.preventDefault()
+  if (Math.abs(e.clientX - dragStartX) > 3 || Math.abs(e.clientY - dragStartY) > 3) {
+    hasDragged = true
+  }
+
+  const maxX = window.innerWidth - button.offsetWidth
+  const maxY = window.innerHeight - button.offsetHeight
+  const x = Math.min(Math.max(e.clientX - offsetX, 8), Math.max(8, maxX - 8))
+  const y = Math.min(Math.max(e.clientY - offsetY, 8), Math.max(8, maxY - 8))
 
 
   button.style.left = x + 'px'
   button.style.left = x + 'px'
   button.style.top = y + 'px'
   button.style.top = y + 'px'
@@ -126,17 +161,20 @@ function handleMouseMove(e) {
   button.style.bottom = 'auto'
   button.style.bottom = 'auto'
 }
 }
 
 
-function handleMouseUp() {
+function handlePointerUp() {
+  if (hasDragged) {
+    suppressNextClick = true
+    nextTick(() => {
+      suppressNextClick = false
+    })
+  }
   isDragging = false
   isDragging = false
-  document.removeEventListener('mousemove', handleMouseMove)
-  document.removeEventListener('mouseup', handleMouseUp)
+  window.removeEventListener('pointermove', handlePointerMove)
+  window.removeEventListener('pointerup', handlePointerUp)
+  window.removeEventListener('pointercancel', handlePointerUp)
 }
 }
 
 
 onMounted(() => {
 onMounted(() => {
-  button = document.querySelector('.aiButtonBadge')
-  if (button) {
-    button.addEventListener('mousedown', handleMouseDown)
-  }
   eventBus.on('closeAI', closeAI)
   eventBus.on('closeAI', closeAI)
   eventBus.on('openAI', openAI)
   eventBus.on('openAI', openAI)
   const mobile = window.innerWidth <= 992
   const mobile = window.innerWidth <= 992
@@ -149,9 +187,7 @@ onMounted(() => {
 })
 })
 
 
 onBeforeUnmount(() => {
 onBeforeUnmount(() => {
-  if (button) {
-    button.removeEventListener('mousedown', handleMouseDown)
-  }
+  handlePointerUp()
   window.removeEventListener('resize', syncSiderCollapsed)
   window.removeEventListener('resize', syncSiderCollapsed)
 })
 })
 
 
@@ -355,6 +391,8 @@ const toggleSider = () => {
   bottom: 100px;
   bottom: 100px;
   right: 60px;
   right: 60px;
   z-index: 200;
   z-index: 200;
+  touch-action: none;
+  user-select: none;
 }
 }
 
 
 .popup-html {
 .popup-html {

+ 8 - 8
src/pages/admin/aiChat/list.vue

@@ -8,12 +8,12 @@
             <a-form class="app-query-form" :model="queryData" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }" label-align="left">
             <a-form class="app-query-form" :model="queryData" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }" label-align="left">
               <a-row :gutter="16">
               <a-row :gutter="16">
                 <a-col :span="6">
                 <a-col :span="6">
-                  <a-form-item field="conversation_no" label="会话编号">
-                    <a-input v-model="queryData.conversation_no" allow-clear />
+                  <a-form-item field="channel" label="消息通道">
+                    <a-input v-model="queryData.channel" allow-clear />
                   </a-form-item>
                   </a-form-item>
                 </a-col>
                 </a-col>
                 <a-col :span="6">
                 <a-col :span="6">
-                  <a-form-item field="id" label="内部ID">
+                  <a-form-item field="id" label="会话编号">
                     <a-input-number v-model="queryData.id" :precision="0" allow-clear />
                     <a-input-number v-model="queryData.id" :precision="0" allow-clear />
                   </a-form-item>
                   </a-form-item>
                 </a-col>
                 </a-col>
@@ -88,7 +88,7 @@
           {{ formatTime(record.update_time) }}
           {{ formatTime(record.update_time) }}
         </template>
         </template>
         <template #optional="{ record }">
         <template #optional="{ record }">
-          <a-button @click="$router.push(`/admin/aiChat/detail/${record.id}`)">查看详情</a-button>
+          <a-button @click="$router.push(`/aiManage/aiChat/detail/${record.id}`)">查看详情</a-button>
         </template>
         </template>
       </a-table>
       </a-table>
     </a-card>
     </a-card>
@@ -102,13 +102,13 @@ import { adminListAIChatConversations } from '@/api/aiChat'
 import { useResponsiveTable } from '@/hooks/useResponsiveTable'
 import { useResponsiveTable } from '@/hooks/useResponsiveTable'
 
 
 const pagination = reactive({ current: 1, pagesize: 20, total: 0 })
 const pagination = reactive({ current: 1, pagesize: 20, total: 0 })
-const queryData = reactive({ id: '', conversation_no: '', username: '', create_user: '', state: -1, queryTime: [] })
+const queryData = reactive({ id: '', channel: '', username: '', create_user: '', state: -1, queryTime: [] })
 const data = ref([])
 const data = ref([])
 const loading = ref(false)
 const loading = ref(false)
 
 
 const columnDefs = [
 const columnDefs = [
-  { title: '会话编号', dataIndex: 'conversation_no', width: 150 },
-  { title: '内部ID', dataIndex: 'id', width: 100 },
+  { title: '消息来源', dataIndex: 'channel', width: 100 },
+  { title: '会话ID', dataIndex: 'id', width: 100 },
   { title: '标题', dataIndex: 'title', width: 180, ellipsis: true, tooltip: true },
   { title: '标题', dataIndex: 'title', width: 180, ellipsis: true, tooltip: true },
   { title: '用户', dataIndex: 'username', width: 140, ellipsis: true, tooltip: true },
   { title: '用户', dataIndex: 'username', width: 140, ellipsis: true, tooltip: true },
   { title: '最后消息', dataIndex: 'last_message_preview', width: 260, ellipsis: true, tooltip: true },
   { title: '最后消息', dataIndex: 'last_message_preview', width: 260, ellipsis: true, tooltip: true },
@@ -145,7 +145,7 @@ const search = () => {
 
 
 const reset = () => {
 const reset = () => {
   queryData.id = ''
   queryData.id = ''
-  queryData.conversation_no = ''
+  queryData.channel = ''
   queryData.username = ''
   queryData.username = ''
   queryData.create_user = ''
   queryData.create_user = ''
   queryData.state = -1
   queryData.state = -1

+ 140 - 2
src/pages/admin/user/userList.vue

@@ -70,6 +70,9 @@
                                 :src="record.avatar ?? 'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png'" />
                                 :src="record.avatar ?? 'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png'" />
                         </a-avatar>
                         </a-avatar>
                         <span class="user-cell__name">{{ record.username || '-' }}</span>
                         <span class="user-cell__name">{{ record.username || '-' }}</span>
+                        <a-tooltip v-if="isVipActive(record)" :content="formatVipStatus(record)">
+                            <img class="vip-inline-icon vip-inline-icon--mini" src="@/assets/img/VIP.svg" alt="VIP" />
+                        </a-tooltip>
                     </div>
                     </div>
                 </template>
                 </template>
                 <template #registTime="{ record }">
                 <template #registTime="{ record }">
@@ -115,6 +118,9 @@
                             <a-doption v-if="hasPermission('action.user.setSendCountAutoApprove')" @click="onSendCountAutoApproveChange(record, !isSendCountAutoApprove(record))">
                             <a-doption v-if="hasPermission('action.user.setSendCountAutoApprove')" @click="onSendCountAutoApproveChange(record, !isSendCountAutoApprove(record))">
                                 <icon-check-circle /> {{ isSendCountAutoApprove(record) ? '关闭赠送免审' : '开启赠送免审' }}
                                 <icon-check-circle /> {{ isSendCountAutoApprove(record) ? '关闭赠送免审' : '开启赠送免审' }}
                             </a-doption>
                             </a-doption>
+                            <a-doption v-if="hasPermission('action.user.setVip')" @click="openVipManage(record)">
+                                <icon-star /> VIP 状态
+                            </a-doption>
                             <a-doption v-if="hasPermission('action.user.ban')" @click="onBanChange(record, !isBanned(record))">
                             <a-doption v-if="hasPermission('action.user.ban')" @click="onBanChange(record, !isBanned(record))">
                                 <icon-minus-circle /> {{ isBanned(record) ? '解除封禁' : '封禁账户' }}
                                 <icon-minus-circle /> {{ isBanned(record) ? '解除封禁' : '封禁账户' }}
                             </a-doption>
                             </a-doption>
@@ -146,6 +152,32 @@
         </a-form>
         </a-form>
     </a-modal>
     </a-modal>
 
 
+    <a-modal v-model:visible="vipVisible" title="VIP 状态" @cancel="handleVipCancel" @before-ok="handleVipBeforeOk" draggable
+        :ok-loading="vipSaving" esc-to-close closable>
+        <a-form :model="vipForm">
+            <a-form-item field="userid" label="用户UUID">
+                <a-input v-model="vipForm.userid" disabled />
+            </a-form-item>
+            <a-form-item field="username" label="用户名">
+                <a-input v-model="vipForm.username" disabled />
+            </a-form-item>
+            <a-form-item field="vip" label="VIP">
+                <a-switch v-model="vipForm.vip" :checked-value="1" :unchecked-value="0" />
+            </a-form-item>
+            <template v-if="vipForm.vip === 1">
+                <a-form-item field="validity_type" label="有效期">
+                    <a-radio-group v-model="vipForm.validity_type">
+                        <a-radio value="permanent">永久</a-radio>
+                        <a-radio value="fixed">指定到期日</a-radio>
+                    </a-radio-group>
+                </a-form-item>
+                <a-form-item v-if="vipForm.validity_type === 'fixed'" field="vip_expire_time" label="到期日">
+                    <a-date-picker v-model="vipExpireDate" show-time style="width: 100%" />
+                </a-form-item>
+            </template>
+        </a-form>
+    </a-modal>
+
     <a-modal v-model:visible="permissionVisible" title="权限管理" width="860px" @before-ok="handlePermissionBeforeOk"
     <a-modal v-model:visible="permissionVisible" title="权限管理" width="860px" @before-ok="handlePermissionBeforeOk"
         :ok-loading="permissionSaving" :mask-closable="false" draggable esc-to-close closable>
         :ok-loading="permissionSaving" :mask-closable="false" draggable esc-to-close closable>
         <a-skeleton animation :loading="permissionLoading">
         <a-skeleton animation :loading="permissionLoading">
@@ -224,7 +256,7 @@
 <script setup>
 <script setup>
 import { ref, reactive, onMounted, computed } from 'vue'
 import { ref, reactive, onMounted, computed } from 'vue'
 import { useResponsiveTable } from '@/hooks/useResponsiveTable'
 import { useResponsiveTable } from '@/hooks/useResponsiveTable'
-import { adminGetUserList, adminChangeLepaoCount, adminSetSendCountAutoApprove, adminSetUserBan } from '@/api/user'
+import { adminGetUserList, adminChangeLepaoCount, adminSetSendCountAutoApprove, adminSetUserBan, adminSetUserVip } from '@/api/user'
 import { getPermissionPoints, getPermissionResources, getUserPermissions, setUserPermissions, updatePermissionResource } from '@/api/permission'
 import { getPermissionPoints, getPermissionResources, getUserPermissions, setUserPermissions, updatePermissionResource } from '@/api/permission'
 import { Notification, Message, Modal } from '@arco-design/web-vue'
 import { Notification, Message, Modal } from '@arco-design/web-vue'
 import {
 import {
@@ -242,6 +274,15 @@ const change_lepao_count = reactive({
     lepao_count: 0,
     lepao_count: 0,
     remark: ''
     remark: ''
 })
 })
+const vipVisible = ref(false)
+const vipSaving = ref(false)
+const vipExpireDate = ref(null)
+const vipForm = reactive({
+    userid: '',
+    username: '',
+    vip: 0,
+    validity_type: 'permanent'
+})
 
 
 const queryData = reactive({
 const queryData = reactive({
     user_uuid: '',
     user_uuid: '',
@@ -276,6 +317,7 @@ const hasAnyUserOperationPermission = computed(() => {
     return [
     return [
         'action.user.changeCount',
         'action.user.changeCount',
         'action.user.setSendCountAutoApprove',
         'action.user.setSendCountAutoApprove',
+        'action.user.setVip',
         'action.user.ban',
         'action.user.ban',
         'action.user.permissionManage'
         'action.user.permissionManage'
     ].some(code => hasPermission(code))
     ].some(code => hasPermission(code))
@@ -288,6 +330,89 @@ const filteredPermissionPoints = computed(() => {
 
 
 const isSendCountAutoApprove = (record) => Number(record?.send_count_auto_approve) === 1
 const isSendCountAutoApprove = (record) => Number(record?.send_count_auto_approve) === 1
 const isBanned = (record) => Number(record?.is_banned) === 1
 const isBanned = (record) => Number(record?.is_banned) === 1
+const isVipActive = (record) => {
+    const expire = Number(record?.vip_expire_time || 0)
+    if (Number(record?.vip_active) === 1) return true
+    return expire > Date.now() || (Number(record?.vip || 0) === 1 && expire === 0)
+}
+
+const formatVipStatus = (record) => {
+    if (!isVipActive(record)) return '非 VIP'
+    const expire = Number(record?.vip_expire_time || 0)
+    if (expire === 0) return '永久 VIP'
+    return `VIP 至 ${stramptoTime(expire)}`
+}
+
+const normalizeDateMs = (value) => {
+    if (!value) return 0
+    if (typeof value === 'number') return value
+    return new Date(value).getTime()
+}
+
+const openVipManage = (record) => {
+    if (!record?.uuid) return
+    const expire = Number(record.vip_expire_time || 0)
+    vipForm.userid = record.uuid
+    vipForm.username = record.username || ''
+    vipForm.vip = isVipActive(record) ? 1 : 0
+    vipForm.validity_type = expire > 0 ? 'fixed' : 'permanent'
+    vipExpireDate.value = expire > 0 ? new Date(expire) : null
+    vipVisible.value = true
+}
+
+const handleVipBeforeOk = async (done) => {
+    if (!vipForm.userid) return false
+
+    const expireTime = vipForm.vip === 1 && vipForm.validity_type === 'fixed'
+        ? normalizeDateMs(vipExpireDate.value)
+        : 0
+
+    if (vipForm.vip === 1 && vipForm.validity_type === 'fixed') {
+        if (!expireTime || expireTime <= Date.now()) {
+            Message.error('请选择晚于当前时间的 VIP 到期日')
+            return false
+        }
+    }
+
+    vipSaving.value = true
+    try {
+        const res = await adminSetUserVip({
+            userid: vipForm.userid,
+            vip: vipForm.vip,
+            vip_expire_time: expireTime
+        })
+
+        if (!res || res.code !== 0) {
+            Notification.error({
+                title: '保存 VIP 状态失败',
+                content: res?.msg ?? '请稍后再试'
+            })
+            return false
+        }
+
+        const row = data.value.find(item => item.uuid === vipForm.userid)
+        if (row) {
+            row.vip = vipForm.vip
+            row.vip_expire_time = vipForm.vip === 1 ? expireTime : 0
+            row.vip_active = vipForm.vip
+        }
+        Message.success(res.msg || 'VIP 状态已保存')
+        done()
+        return true
+    } catch (error) {
+        Notification.error({
+            title: '保存 VIP 状态失败',
+            content: error.message || '请稍后再试'
+        })
+        return false
+    } finally {
+        vipSaving.value = false
+    }
+}
+
+const handleVipCancel = () => {
+    vipVisible.value = false
+}
 
 
 const onSendCountAutoApproveChange = async (record, checked) => {
 const onSendCountAutoApproveChange = async (record, checked) => {
     if (!record?.uuid || whitelistLoadingUuid.value) return
     if (!record?.uuid || whitelistLoadingUuid.value) return
@@ -722,6 +847,19 @@ const formatLastLoginType = (type) => {
     white-space: nowrap;
     white-space: nowrap;
 }
 }
 
 
+.vip-inline-icon {
+    display: inline-block;
+    width: auto;
+    height: 18px;
+    vertical-align: middle;
+    object-fit: contain;
+    flex-shrink: 0;
+}
+
+.vip-inline-icon--mini {
+    height: 15px;
+}
+
 .bind-cell {
 .bind-cell {
     display: inline-flex;
     display: inline-flex;
     align-items: center;
     align-items: center;
@@ -801,4 +939,4 @@ const formatLastLoginType = (type) => {
         flex: 1;
         flex: 1;
     }
     }
 }
 }
-</style>
+</style>

+ 90 - 2
src/pages/admin/workOrder/orderDetail.vue

@@ -65,9 +65,10 @@
                         </div>
                         </div>
                     </div>
                     </div>
                 </div>
                 </div>
-                <div class="content" :style="{ whiteSpace: 'pre-wrap' }">
+                <div v-if="msg.type !== 'ai'" class="content">
                     {{ msg.content }}
                     {{ msg.content }}
                 </div>
                 </div>
+                <div v-else class="content content--markdown" v-html="renderMarkdown(msg.content)"></div>
                 <div class="filebox">
                 <div class="filebox">
                     <a-upload :default-file-list="msg.files" :show-upload-button="false" :show-remove-button="false">
                     <a-upload :default-file-list="msg.files" :show-upload-button="false" :show-remove-button="false">
                         <template #success-icon>
                         <template #success-icon>
@@ -105,6 +106,7 @@ import { ref, reactive, onMounted, onUnmounted } from 'vue'
 import { adminOrderDetail, closeOrder, adminReplyOrder } from '@/api/workOrder'
 import { adminOrderDetail, closeOrder, adminReplyOrder } from '@/api/workOrder'
 import { Notification } from '@arco-design/web-vue'
 import { Notification } from '@arco-design/web-vue'
 import { useRoute } from 'vue-router'
 import { useRoute } from 'vue-router'
+import MarkdownIt from 'markdown-it'
 
 
 const route = useRoute()
 const route = useRoute()
 
 
@@ -112,6 +114,8 @@ const loading = ref(false)
 const formLoading = ref(false)
 const formLoading = ref(false)
 const buttonLoading = ref(false)
 const buttonLoading = ref(false)
 const data = ref({})
 const data = ref({})
+const md = new MarkdownIt({ html: false, linkify: true, breaks: true })
+const renderMarkdown = (text) => md.render(text || '')
 
 
 const form = reactive({
 const form = reactive({
     content: '',
     content: '',
@@ -325,10 +329,94 @@ const stramptoTime = (time) => {
         border-radius: @store-radius-sm;
         border-radius: @store-radius-sm;
         background: @store-bg;
         background: @store-bg;
         line-height: 1.65;
         line-height: 1.65;
+        white-space: pre-wrap;
         word-break: break-word;
         word-break: break-word;
         color: @store-text-muted;
         color: @store-text-muted;
     }
     }
 
 
+    .content--markdown {
+        white-space: normal;
+
+        :deep(p) {
+            margin: 0 0 8px;
+        }
+
+        :deep(p:last-child),
+        :deep(ul:last-child),
+        :deep(ol:last-child),
+        :deep(pre:last-child),
+        :deep(blockquote:last-child) {
+            margin-bottom: 0;
+        }
+
+        :deep(ul),
+        :deep(ol) {
+            margin: 6px 0 8px 18px;
+            padding: 0;
+        }
+
+        :deep(li) {
+            margin: 2px 0;
+            padding-left: 2px;
+        }
+
+        :deep(li > p) {
+            margin: 0;
+        }
+
+        :deep(h1),
+        :deep(h2),
+        :deep(h3),
+        :deep(h4),
+        :deep(h5),
+        :deep(h6) {
+            margin: 10px 0 6px;
+            line-height: 1.35;
+            font-size: 1em;
+            font-weight: 600;
+            color: @store-primary;
+        }
+
+        :deep(strong) {
+            color: @store-primary;
+            font-weight: 600;
+        }
+
+        :deep(a) {
+            color: @store-primary;
+            text-decoration: none;
+            word-break: break-all;
+        }
+
+        :deep(code) {
+            padding: 1px 5px;
+            border-radius: 4px;
+            background: rgba(15, 23, 42, 0.06);
+            font-size: 0.92em;
+        }
+
+        :deep(pre) {
+            margin: 8px 0;
+            padding: 10px 12px;
+            border-radius: 6px;
+            background: rgba(15, 23, 42, 0.06);
+            overflow-x: auto;
+            white-space: pre;
+        }
+
+        :deep(pre code) {
+            padding: 0;
+            background: transparent;
+        }
+
+        :deep(blockquote) {
+            margin: 8px 0;
+            padding: 2px 0 2px 10px;
+            border-left: 3px solid #c7d7f2;
+            color: @store-text-muted;
+        }
+    }
+
     .filebox {
     .filebox {
         margin-top: 8px;
         margin-top: 8px;
         margin-left: 48px;
         margin-left: 48px;
@@ -354,4 +442,4 @@ const stramptoTime = (time) => {
     max-width: 640px;
     max-width: 640px;
     width: 100%;
     width: 100%;
 }
 }
-</style>
+</style>

+ 2 - 3
src/pages/admin/workOrder/orderList.vue

@@ -6,7 +6,7 @@
             <AppQueryFilter>
             <AppQueryFilter>
             <a-row class="query-row">
             <a-row class="query-row">
                 <a-col :flex="'1000px'" class="query-main">
                 <a-col :flex="'1000px'" class="query-main">
-                    <a-form class="queryForm app-query-form" :model="queryData" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }"
+                    <a-form class="app-query-form" :model="queryData" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }"
                         label-align="left">
                         label-align="left">
                         <a-row :gutter="16">
                         <a-row :gutter="16">
                             <a-col :span="8">
                             <a-col :span="8">
@@ -45,7 +45,6 @@
             </a-row>
             </a-row>
             </AppQueryFilter>
             </AppQueryFilter>
 
 
-
             <a-table :bordered="false" :data="data" stripe hoverable column-resizable class="table" :loading="loading" :columns="columns"
             <a-table :bordered="false" :data="data" stripe hoverable column-resizable class="table" :loading="loading" :columns="columns"
                 :scroll="{ x: 980 }"
                 :scroll="{ x: 980 }"
                 :pagination="{
                 :pagination="{
@@ -96,7 +95,7 @@
                     </a-tag>
                     </a-tag>
                 </template>
                 </template>
                 <template #optional="{ record }">
                 <template #optional="{ record }">
-                    <a-button @click="$router.push(`/admin/service/orderDetail/${record.id}`)">查看详情</a-button>
+                    <a-button @click="$router.push(`/aiManage/workOrder/orderDetail/${record.id}`)">查看详情</a-button>
                 </template>
                 </template>
             </a-table>
             </a-table>
         </a-card>
         </a-card>

+ 1 - 1
src/pages/ai/assistant/WeixinBindingModal.vue

@@ -185,7 +185,7 @@ const stateDescription = computed(() => {
   return '绑定后可在微信中接收乐跑通知,并继续使用小妍助理对话'
   return '绑定后可在微信中接收乐跑通知,并继续使用小妍助理对话'
 })
 })
 const emptyTitle = computed(() => {
 const emptyTitle = computed(() => {
-  if (isActive.value) return '连接运行中'
+  if (isActive.value) return '连接'
   if (isExpired.value) return '当前二维码不可用'
   if (isExpired.value) return '当前二维码不可用'
   return '准备生成绑定二维码'
   return '准备生成绑定二维码'
 })
 })

+ 11 - 5
src/pages/ai/assistant/index.vue

@@ -118,7 +118,7 @@
             <strong>{{ keepAliveText }}</strong>
             <strong>{{ keepAliveText }}</strong>
           </div>
           </div>
         </div>
         </div>
-        <a-button class="weixin-action" type="primary" :disabled="!isVip" @click="openBindingModal">
+        <a-button class="weixin-action" type="primary" @click="openBindingModal">
           <template #icon><icon-scan /></template>
           <template #icon><icon-scan /></template>
           {{ weixin?.bound ? '查看绑定' : '绑定微信' }}
           {{ weixin?.bound ? '查看绑定' : '绑定微信' }}
         </a-button>
         </a-button>
@@ -128,7 +128,7 @@
         <div class="panel-head">
         <div class="panel-head">
           <div>
           <div>
             <h2>次数变动趋势</h2>
             <h2>次数变动趋势</h2>
-            <p>近 14 天乐跑次数变化</p>
+            <p>近 14 天剩余乐跑次数变化</p>
           </div>
           </div>
         </div>
         </div>
         <div class="chart-shell">
         <div class="chart-shell">
@@ -225,7 +225,7 @@ const vipTitle = computed(() => {
 
 
 const vipDescription = computed(() => {
 const vipDescription = computed(() => {
   if (isVip.value) return '已启用微信助手、微信通知与专属对话额度'
   if (isVip.value) return '已启用微信助手、微信通知与专属对话额度'
-  return '支持网页对话 - 开通 VIP 可启用微信助手与专属额度'
+  return '开通 VIP 可启用微信助手与专属额度'
 })
 })
 
 
 const metrics = computed(() => [
 const metrics = computed(() => [
@@ -278,7 +278,7 @@ const quotaItems = computed(() => {
 })
 })
 const quotaSummaryText = computed(() => {
 const quotaSummaryText = computed(() => {
   if (isVip.value) return '网页与微信额度独立统计,VIP可享受更多额度'
   if (isVip.value) return '网页与微信额度独立统计,VIP可享受更多额度'
-  return '当前可使用网页对话额度,开通 VIP 后可启用微信专属额度'
+  return '当前可使用基础网页对话额度,开通 VIP 后可使用微信接入和专属额度'
 })
 })
 
 
 const weixinTagText = computed(() => ({ 0: '待扫码', 1: '已绑定', 2: '已解绑', 3: '已过期' }[weixin.value?.state] || '未绑定'))
 const weixinTagText = computed(() => ({ 0: '待扫码', 1: '已绑定', 2: '已解绑', 3: '已过期' }[weixin.value?.state] || '未绑定'))
@@ -431,7 +431,13 @@ const openChat = () => eventBus.emit('openAI')
 const openAbility = (item) => {
 const openAbility = (item) => {
   eventBus.emit('openAI', { newConversation: true, draft: item.prompt })
   eventBus.emit('openAI', { newConversation: true, draft: item.prompt })
 }
 }
-const openBindingModal = () => bindingModalRef.value?.open(weixin.value)
+const openBindingModal = () => {
+  if(!isVip.value) {
+    Message.warning('绑定微信仅限 VIP 用户')
+    return
+  }
+  bindingModalRef.value?.open(weixin.value)
+}
 const handleBindingUpdated = (binding) => {
 const handleBindingUpdated = (binding) => {
   if (!dashboard.value) return
   if (!dashboard.value) return
   dashboard.value.weixin = binding ? {
   dashboard.value.weixin = binding ? {

+ 102 - 104
src/pages/power/accountList.vue

@@ -24,91 +24,72 @@
           </a-empty>
           </a-empty>
 
 
           <div v-else class="account-list">
           <div v-else class="account-list">
-          <article
-            v-for="record in data"
-            :key="record.id"
-            class="account-card"
-            :class="{ 'account-card--low': isLowBalance(record) }"
-          >
-            <div class="account-card__head">
-              <div class="account-card__location">
-                <icon-home class="loc-icon" />
-                <span>{{ record.area }} · {{ record.building }} · {{ record.room }}</span>
-              </div>
-              <!-- <a-tag color="arcoblue" size="small">
+            <article v-for="record in data" :key="record.id" class="account-card"
+              :class="{ 'account-card--low': isLowBalance(record) }">
+              <div class="account-card__head">
+                <div class="account-card__location">
+                  <icon-home class="loc-icon" />
+                  <span>{{ record.area }} · {{ record.building }} · {{ record.room }}</span>
+                </div>
+                <!-- <a-tag color="arcoblue" size="small">
                 扣费时间 {{ record.koufei_date || '-' }}
                 扣费时间 {{ record.koufei_date || '-' }}
               </a-tag> -->
               </a-tag> -->
-            </div>
+              </div>
 
 
-            <div class="account-card__stats">
-              <div class="stat" :class="{ 'stat--low': isLowBalance(record) }">
-                <span class="stat__label">当前余额</span>
-                <span class="stat__value">¥{{ record.balance }}</span>
+              <div class="account-card__stats">
+                <div class="stat" :class="{ 'stat--low': isLowBalance(record) }">
+                  <span class="stat__label">当前余额</span>
+                  <span class="stat__value">¥{{ record.balance }}</span>
+                </div>
+                <div class="stat">
+                  <span class="stat__label">提醒阈值</span>
+                  <span class="stat__value">¥{{ record.lowest }}</span>
+                </div>
               </div>
               </div>
-              <div class="stat">
-                <span class="stat__label">提醒阈值</span>
-                <span class="stat__value">¥{{ record.lowest }}</span>
+
+              <div class="account-card__meta">
+                <span v-if="record.notice_type === 'wechat'"><icon-wechat /> 微信通知</span>
+                <span v-else-if="record.notice_type === 'none'">不通知</span>
+                <span v-else><icon-email /> {{ record.email || '-' }}</span>
+                <span><icon-clock-circle /> 更新时间:{{ formatTime(record.update_time) }}</span>
+                <span><icon-clock-circle /> 扣费时间:{{ record.koufei_date || '-' }}</span>
               </div>
               </div>
-            </div>
-
-            <div class="account-card__meta">
-              <span><icon-email /> 通知邮箱:{{ record.email }}</span>
-              <span><icon-clock-circle /> 更新时间:{{ formatTime(record.update_time) }}</span>
-              <span><icon-clock-circle /> 扣费时间:{{ record.koufei_date || '-' }}</span>
-            </div>
-
-            <p v-if="record.notes" class="account-card__notes">{{ record.notes }}</p>
-
-            <div class="account-card__actions">
-              <a-button size="small" type="outline" @click="GetChangeRecord(record.id)">
-                <template #icon><icon-select-all /></template>
-                账单记录
-              </a-button>
-              <a-dropdown :popup-max-height="false" trigger="hover">
-                <a-button size="small" type="text">
-                  更多
-                  <icon-down />
+
+              <p v-if="record.notes" class="account-card__notes">{{ record.notes }}</p>
+
+              <div class="account-card__actions">
+                <a-button size="small" type="outline" @click="GetChangeRecord(record.id)">
+                  <template #icon><icon-select-all /></template>
+                  账单记录
                 </a-button>
                 </a-button>
-                <template #content>
-                  <a-doption @click="editAccount(record)"><icon-edit /> 编辑任务</a-doption>
-                  <a-doption @click="DeleteAccount(record)"><icon-delete /> 删除任务</a-doption>
-                </template>
-              </a-dropdown>
-            </div>
-          </article>
+                <a-dropdown :popup-max-height="false" trigger="hover">
+                  <a-button size="small" type="text">
+                    更多
+                    <icon-down />
+                  </a-button>
+                  <template #content>
+                    <a-doption @click="editAccount(record)"><icon-edit /> 编辑任务</a-doption>
+                    <a-doption @click="DeleteAccount(record)"><icon-delete /> 删除任务</a-doption>
+                  </template>
+                </a-dropdown>
+              </div>
+            </article>
           </div>
           </div>
         </template>
         </template>
       </a-skeleton>
       </a-skeleton>
     </div>
     </div>
   </div>
   </div>
 
 
-  <a-modal
-    v-model:visible="visible"
-    :title="form.id ? '编辑电费提醒任务' : '添加电费提醒任务'"
-    class="power-modal"
-    @cancel="handleCancel"
-    @before-ok="handleBeforeOk"
-    draggable
-    :ok-loading="ok_loading"
-    esc-to-close
-    closable
-  >
+  <a-modal v-model:visible="visible" :title="form.id ? '编辑电费提醒任务' : '添加电费提醒任务'" class="power-modal"
+    @cancel="handleCancel" @before-ok="handleBeforeOk" draggable :ok-loading="ok_loading" esc-to-close closable>
     <a-form :model="form" layout="vertical" class="power-form">
     <a-form :model="form" layout="vertical" class="power-form">
       <a-form-item field="area" label="校区" :loading="selectLoading">
       <a-form-item field="area" label="校区" :loading="selectLoading">
-        <a-select
-          v-model="form.area"
-          placeholder="请选择所在校区"
-          :options="areas"
-          @change="GetPowerData('dylist', form.area)"
-        />
+        <a-select v-model="form.area" placeholder="请选择所在校区" :options="areas"
+          @change="GetPowerData('dylist', form.area)" />
       </a-form-item>
       </a-form-item>
       <a-form-item field="building" label="楼栋" :loading="selectLoading">
       <a-form-item field="building" label="楼栋" :loading="selectLoading">
-        <a-select
-          v-model="form.building"
-          placeholder="请选择所在楼栋"
-          :options="buildings"
-          @change="GetPowerData('mphlist', form.building)"
-        />
+        <a-select v-model="form.building" placeholder="请选择所在楼栋" :options="buildings"
+          @change="GetPowerData('mphlist', form.building)" />
       </a-form-item>
       </a-form-item>
       <a-form-item field="room" label="宿舍号" :loading="selectLoading">
       <a-form-item field="room" label="宿舍号" :loading="selectLoading">
         <a-select v-model="form.room" placeholder="请选择所在宿舍" :options="rooms" />
         <a-select v-model="form.room" placeholder="请选择所在宿舍" :options="rooms" />
@@ -116,51 +97,42 @@
       <a-form-item field="lowest" label="提醒阈值(元)">
       <a-form-item field="lowest" label="提醒阈值(元)">
         <a-input-number v-model="form.lowest" placeholder="余额低于该值时发送邮件" :step="1" :precision="2" />
         <a-input-number v-model="form.lowest" placeholder="余额低于该值时发送邮件" :step="1" :precision="2" />
       </a-form-item>
       </a-form-item>
-      <a-form-item field="email" label="通知邮箱">
+      <a-form-item field="notice_type" label="通知方式">
+        <a-radio-group v-model="form.notice_type">
+          <a-radio value="email"><icon-email /> 邮件</a-radio>
+          <a-radio value="wechat">
+            <span class="notice-radio-option">
+              <span class="notice-radio-option__label"><icon-wechat /> 微信</span>
+              <img class="vip-inline-icon" src="@/assets/img/vip.svg" alt="vip" />
+            </span>
+          </a-radio>
+          <a-radio value="none">不通知</a-radio>
+        </a-radio-group>
+        <template #extra v-if="form.notice_type === 'wechat'">
+          微信通知将发送到已绑定的小妍助理微信会话
+        </template>
+      </a-form-item>
+      <a-form-item v-if="form.notice_type === 'email'" field="email" label="通知邮箱">
         <EmailAutoComplete v-model="form.email" placeholder="用于接收电费变更通知" allow-clear />
         <EmailAutoComplete v-model="form.email" placeholder="用于接收电费变更通知" allow-clear />
       </a-form-item>
       </a-form-item>
       <a-form-item field="notes" label="备注">
       <a-form-item field="notes" label="备注">
-        <a-textarea
-          v-model="form.notes"
-          no-trim
-          placeholder="添加对账号的备注(非必填)"
-          :max-length="{ length: 50 }"
-          allow-clear
-          show-word-limit
-        />
+        <a-textarea v-model="form.notes" no-trim placeholder="添加对账号的备注(非必填)" :max-length="{ length: 50 }" allow-clear
+          show-word-limit />
       </a-form-item>
       </a-form-item>
     </a-form>
     </a-form>
   </a-modal>
   </a-modal>
 
 
-  <a-modal
-    v-model:visible="listVisible"
-    title="电费变更记录"
-    class="power-modal power-modal--wide"
-    esc-to-close
-    closable
-    width="auto"
-    hide-cancel
-  >
-    <a-table
-      :bordered="false"
-      :data="changeList"
-      :columns="listColumns"
-      stripe
-      hoverable
-      :loading="listLoading"
-      class="change-table"
-      :scroll="{ x: 720 }"
-      :pagination="{
+  <a-modal v-model:visible="listVisible" title="电费变更记录" class="power-modal power-modal--wide" esc-to-close closable
+    width="auto" hide-cancel>
+    <a-table :bordered="false" :data="changeList" :columns="listColumns" stripe hoverable :loading="listLoading"
+      class="change-table" :scroll="{ x: 720 }" :pagination="{
         showPageSize: true,
         showPageSize: true,
         showJumper: true,
         showJumper: true,
         showTotal: true,
         showTotal: true,
         pageSize: pagination.pagesize,
         pageSize: pagination.pagesize,
         current: pagination.current,
         current: pagination.current,
         total: pagination.total
         total: pagination.total
-      }"
-      @page-change="handlePageChange"
-      @page-size-change="handlePageSizeChange"
-    >
+      }" @page-change="handlePageChange" @page-size-change="handlePageSizeChange">
       <template #time="{ record }">
       <template #time="{ record }">
         {{ formatTime(record.time) }}
         {{ formatTime(record.time) }}
       </template>
       </template>
@@ -263,6 +235,7 @@ const form = reactive({
   building: '',
   building: '',
   room: '',
   room: '',
   email: '',
   email: '',
+  notice_type: 'email',
   notes: '',
   notes: '',
   lowest: 10.0
   lowest: 10.0
 })
 })
@@ -315,6 +288,7 @@ const editAccount = (item) => {
     form.building = item.building
     form.building = item.building
     form.room = item.room
     form.room = item.room
     form.email = item.email
     form.email = item.email
+    form.notice_type = item.notice_type || 'email'
     form.notes = item.notes
     form.notes = item.notes
     form.lowest = Number(item.lowest) ?? 10.0
     form.lowest = Number(item.lowest) ?? 10.0
     if (form.area) GetPowerData('dylist', form.area)
     if (form.area) GetPowerData('dylist', form.area)
@@ -325,6 +299,7 @@ const editAccount = (item) => {
     form.building = ''
     form.building = ''
     form.room = ''
     form.room = ''
     form.email = ''
     form.email = ''
+    form.notice_type = 'email'
     form.notes = ''
     form.notes = ''
     form.lowest = 10.0
     form.lowest = 10.0
   }
   }
@@ -334,14 +309,14 @@ const editAccount = (item) => {
 const handleBeforeOk = async (done) => {
 const handleBeforeOk = async (done) => {
   try {
   try {
     ok_loading.value = true
     ok_loading.value = true
-    const { area, building, room, email } = form
-    if (!area || !building || !room || !email) {
+    const { area, building, room, email, notice_type } = form
+    if (!area || !building || !room || (notice_type === 'email' && !email)) {
       Message.error('请填写完整的任务信息')
       Message.error('请填写完整的任务信息')
       return false
       return false
     }
     }
 
 
     const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
     const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
-    if (!emailRegex.test(email)) {
+    if (notice_type === 'email' && !emailRegex.test(email)) {
       Message.error('请检查邮箱格式是否正确')
       Message.error('请检查邮箱格式是否正确')
       return false
       return false
     }
     }
@@ -587,4 +562,27 @@ onMounted(() => {
     color: @store-primary;
     color: @store-primary;
   }
   }
 }
 }
+
+.notice-radio-option {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  line-height: 1;
+  vertical-align: middle;
+}
+
+.notice-radio-option__label {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  line-height: 20px;
+}
+
+.vip-inline-icon {
+  display: inline-block;
+  width: auto;
+  height: 18px;
+  vertical-align: middle;
+  object-fit: contain;
+}
 </style>
 </style>

+ 0 - 1
src/pages/qk/taskList.vue

@@ -578,7 +578,6 @@ onUnmounted(() => {
 
 
 .task-form {
 .task-form {
   max-height: 70vh;
   max-height: 70vh;
-  overflow-y: auto;
 }
 }
 
 
 @media (max-width: 768px) {
 @media (max-width: 768px) {

+ 54 - 28
src/pages/service/createOrder.vue

@@ -15,10 +15,7 @@
       </header>
       </header>
 
 
       <a-card :bordered="false" class="form-card">
       <a-card :bordered="false" class="form-card">
-        <WorkOrderTemplatePicker
-          v-model="selectedTemplateId"
-          @select="applyTemplate"
-        />
+        <WorkOrderTemplatePicker v-model="selectedTemplateId" @select="applyTemplate" />
 
 
         <a-form :model="form" :rules="rules" layout="vertical" @submit-success="handleSubmit">
         <a-form :model="form" :rules="rules" layout="vertical" @submit-success="handleSubmit">
           <a-form-item field="title" label="工单标题">
           <a-form-item field="title" label="工单标题">
@@ -26,27 +23,32 @@
           </a-form-item>
           </a-form-item>
 
 
           <a-form-item field="content" label="问题描述">
           <a-form-item field="content" label="问题描述">
-            <a-textarea
-              v-model="form.content"
-              placeholder="请详细说明您遇到的问题,信息越完整越便于我们快速处理"
-              :max-length="500"
-              :auto-size="{ minRows: 8, maxRows: 16 }"
-              allow-clear
-              show-word-limit
-            />
+            <a-textarea v-model="form.content" placeholder="请详细说明您遇到的问题,信息越完整越便于我们快速处理" :max-length="500"
+              :auto-size="{ minRows: 8, maxRows: 16 }" allow-clear show-word-limit />
           </a-form-item>
           </a-form-item>
 
 
           <a-form-item field="files" label="上传附件(选填)">
           <a-form-item field="files" label="上传附件(选填)">
             <a-upload action="/cloud/api.php" :file-list="form.files" @change="handleFileChange" />
             <a-upload action="/cloud/api.php" :file-list="form.files" @change="handleFileChange" />
           </a-form-item>
           </a-form-item>
 
 
-          <a-form-item field="email" label="通知邮箱">
-            <EmailAutoComplete
-              v-model="form.email"
-              :max-length="30"
-              allow-clear
-              placeholder="客服回复后将通过邮件通知您"
-            />
+          <a-form-item field="notice_type" label="通知方式">
+            <a-radio-group v-model="form.notice_type">
+              <a-radio value="email"><icon-email /> 邮件</a-radio>
+              <a-radio value="wechat">
+                <span class="notice-radio-option">
+                  <span class="notice-radio-option__label"><icon-wechat /> 微信</span>
+                  <img class="vip-inline-icon" src="@/assets/img/vip.svg" alt="vip" />
+                </span>
+              </a-radio>
+              <a-radio value="none">不通知</a-radio>
+            </a-radio-group>
+            <template #extra v-if="form.notice_type === 'wechat'">
+              微信通知将发送到已绑定的小妍助理微信会话
+            </template>
+          </a-form-item>
+
+          <a-form-item v-if="form.notice_type === 'email'" field="email" label="通知邮箱">
+            <EmailAutoComplete v-model="form.email" :max-length="30" allow-clear placeholder="客服回复后将通过邮件通知您" />
           </a-form-item>
           </a-form-item>
 
 
           <a-alert v-if="!hasPermission('action.service.createOrder')" type="warning" class="perm-alert">
           <a-alert v-if="!hasPermission('action.service.createOrder')" type="warning" class="perm-alert">
@@ -55,14 +57,8 @@
 
 
           <a-form-item>
           <a-form-item>
             <a-space>
             <a-space>
-              <a-button
-                type="primary"
-                html-type="submit"
-                size="large"
-                class="submit-btn"
-                :loading="loading"
-                :disabled="!hasPermission('action.service.createOrder')"
-              >
+              <a-button type="primary" html-type="submit" size="large" class="submit-btn" :loading="loading"
+                :disabled="!hasPermission('action.service.createOrder')">
                 提交工单
                 提交工单
               </a-button>
               </a-button>
               <a-button size="large" @click="clearForm">清空内容</a-button>
               <a-button size="large" @click="clearForm">清空内容</a-button>
@@ -89,13 +85,14 @@ const selectedTemplateId = ref('')
 const rules = {
 const rules = {
   title: [{ required: true, message: '请输入工单标题' }],
   title: [{ required: true, message: '请输入工单标题' }],
   content: [{ required: true, message: '请详细说明您遇到的问题' }],
   content: [{ required: true, message: '请详细说明您遇到的问题' }],
-  email: [{ type: 'email', required: true, message: '请填写正确的通知邮箱' }]
+  email: [{ type: 'email', required: false, message: '请填写正确的通知邮箱' }]
 }
 }
 
 
 const form = reactive({
 const form = reactive({
   title: '',
   title: '',
   content: '',
   content: '',
   email: '',
   email: '',
+  notice_type: 'email',
   files: []
   files: []
 })
 })
 
 
@@ -108,6 +105,8 @@ const clearForm = () => {
   selectedTemplateId.value = ''
   selectedTemplateId.value = ''
   form.title = ''
   form.title = ''
   form.content = ''
   form.content = ''
+  form.email = ''
+  form.notice_type = 'email'
   form.files = []
   form.files = []
 }
 }
 
 
@@ -116,6 +115,10 @@ const handleSubmit = async () => {
     Notification.warning({ title: '无权限', content: '当前账号暂无发起工单权限' })
     Notification.warning({ title: '无权限', content: '当前账号暂无发起工单权限' })
     return
     return
   }
   }
+  if (form.notice_type === 'email' && !form.email) {
+    Notification.warning({ title: '请填写通知邮箱' })
+    return
+  }
   try {
   try {
     loading.value = true
     loading.value = true
     const res = await createOrder(form)
     const res = await createOrder(form)
@@ -171,6 +174,29 @@ const handleFileChange = (fileList) => {
   border-radius: @store-radius-sm;
   border-radius: @store-radius-sm;
 }
 }
 
 
+.notice-radio-option {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  line-height: 1;
+  vertical-align: middle;
+}
+
+.notice-radio-option__label {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  line-height: 20px;
+}
+
+.vip-inline-icon {
+  display: inline-block;
+  width: auto;
+  height: 18px;
+  vertical-align: middle;
+  object-fit: contain;
+}
+
 .submit-btn {
 .submit-btn {
   border-radius: 999px;
   border-radius: 999px;
   background: @store-primary !important;
   background: @store-primary !important;

+ 98 - 2
src/pages/service/orderDetail.vue

@@ -16,7 +16,7 @@
               <WorkOrderStateTag :state="data.state" />
               <WorkOrderStateTag :state="data.state" />
             </div>
             </div>
             <a-descriptions :column="{ xs: 1, sm: 2 }" size="medium" class="info-desc">
             <a-descriptions :column="{ xs: 1, sm: 2 }" size="medium" class="info-desc">
-              <a-descriptions-item label="通知邮箱">{{ data.email }}</a-descriptions-item>
+              <a-descriptions-item label="通知方式">{{ noticeLabel(data) }}</a-descriptions-item>
               <a-descriptions-item label="最后更新">{{ formatTimeFull(data.update_time) }}</a-descriptions-item>
               <a-descriptions-item label="最后更新">{{ formatTimeFull(data.update_time) }}</a-descriptions-item>
             </a-descriptions>
             </a-descriptions>
             <div class="info-card__actions">
             <div class="info-card__actions">
@@ -57,7 +57,8 @@
                     <a-tag size="small" :color="tagColor(msg.type)">{{ tagLabel(msg.type) }}</a-tag>
                     <a-tag size="small" :color="tagColor(msg.type)">{{ tagLabel(msg.type) }}</a-tag>
                     <span class="chat-item__time">{{ formatTimeFull(msg.time) }}</span>
                     <span class="chat-item__time">{{ formatTimeFull(msg.time) }}</span>
                   </div>
                   </div>
-                  <div class="chat-item__content">{{ msg.content }}</div>
+                  <div class="chat-item__content" v-if="msg.type !== 'ai'">{{ msg.content }}</div>
+                  <div class="chat-item__content chat-item__content--markdown" v-html="renderMarkdown(msg.content)" v-else></div>
                   <div v-if="msg.files?.length" class="chat-item__files">
                   <div v-if="msg.files?.length" class="chat-item__files">
                     <a-upload
                     <a-upload
                       :default-file-list="msg.files"
                       :default-file-list="msg.files"
@@ -111,6 +112,7 @@ import { ref, reactive, onMounted, onUnmounted } from 'vue'
 import { orderDetail, closeOrder, createOrder } from '@/api/workOrder'
 import { orderDetail, closeOrder, createOrder } from '@/api/workOrder'
 import { Notification } from '@arco-design/web-vue'
 import { Notification } from '@arco-design/web-vue'
 import { useRoute } from 'vue-router'
 import { useRoute } from 'vue-router'
+import MarkdownIt from 'markdown-it'
 import { hasPermission } from '@/utils/permission'
 import { hasPermission } from '@/utils/permission'
 import { formatStoreTimeFull } from '@/utils/storeFormat'
 import { formatStoreTimeFull } from '@/utils/storeFormat'
 import WorkOrderStateTag from '@/components/service/WorkOrderStateTag.vue'
 import WorkOrderStateTag from '@/components/service/WorkOrderStateTag.vue'
@@ -121,6 +123,8 @@ const loading = ref(false)
 const formLoading = ref(false)
 const formLoading = ref(false)
 const buttonLoading = ref(false)
 const buttonLoading = ref(false)
 const data = ref({})
 const data = ref({})
+const md = new MarkdownIt({ html: false, linkify: true, breaks: true })
+const renderMarkdown = (text) => md.render(text || '')
 
 
 const form = reactive({ content: '', files: [] })
 const form = reactive({ content: '', files: [] })
 const rules = {
 const rules = {
@@ -128,6 +132,11 @@ const rules = {
 }
 }
 
 
 const formatTimeFull = (time) => formatStoreTimeFull(time)
 const formatTimeFull = (time) => formatStoreTimeFull(time)
+const noticeLabel = (record) => {
+  if (record?.notice_type === 'wechat') return '微信通知'
+  if (record?.notice_type === 'none') return '不通知'
+  return record?.email || '-'
+}
 
 
 const tagLabel = (type) => {
 const tagLabel = (type) => {
   const map = { system: '系统', user: '我', server: '客服', ai: 'AI' }
   const map = { system: '系统', user: '我', server: '客服', ai: 'AI' }
@@ -354,6 +363,93 @@ onUnmounted(stopPolling)
     color: @store-text-muted;
     color: @store-text-muted;
   }
   }
 
 
+  &__content--markdown {
+    white-space: normal;
+
+    :deep(.markdown-body) {
+      line-height: 1.65;
+    }
+
+    :deep(p) {
+      margin: 0 0 8px;
+    }
+
+    :deep(p:last-child),
+    :deep(ul:last-child),
+    :deep(ol:last-child),
+    :deep(pre:last-child),
+    :deep(blockquote:last-child) {
+      margin-bottom: 0;
+    }
+
+    :deep(ul),
+    :deep(ol) {
+      margin: 6px 0 8px 18px;
+      padding: 0;
+    }
+
+    :deep(li) {
+      margin: 2px 0;
+      padding-left: 2px;
+    }
+
+    :deep(li > p) {
+      margin: 0;
+    }
+
+    :deep(h1),
+    :deep(h2),
+    :deep(h3),
+    :deep(h4),
+    :deep(h5),
+    :deep(h6) {
+      margin: 10px 0 6px;
+      line-height: 1.35;
+      font-size: 1em;
+      font-weight: 600;
+      color: @store-primary;
+    }
+
+    :deep(strong) {
+      color: @store-primary;
+      font-weight: 600;
+    }
+
+    :deep(a) {
+      color: @store-primary;
+      text-decoration: none;
+      word-break: break-all;
+    }
+
+    :deep(code) {
+      padding: 1px 5px;
+      border-radius: 4px;
+      background: rgba(15, 23, 42, 0.06);
+      font-size: 0.92em;
+    }
+
+    :deep(pre) {
+      margin: 8px 0;
+      padding: 10px 12px;
+      border-radius: 6px;
+      background: rgba(15, 23, 42, 0.06);
+      overflow-x: auto;
+      white-space: pre;
+    }
+
+    :deep(pre code) {
+      padding: 0;
+      background: transparent;
+    }
+
+    :deep(blockquote) {
+      margin: 8px 0;
+      padding: 2px 0 2px 10px;
+      border-left: 3px solid #c7d7f2;
+      color: @store-text-muted;
+    }
+  }
+
   &__files {
   &__files {
     margin-top: 8px;
     margin-top: 8px;
     width: fit-content;
     width: fit-content;

+ 3 - 1
src/pages/service/orderList.vue

@@ -36,7 +36,9 @@
             </div>
             </div>
             <h3 class="ticket-card__title">{{ record.title }}</h3>
             <h3 class="ticket-card__title">{{ record.title }}</h3>
             <div class="ticket-card__meta">
             <div class="ticket-card__meta">
-              <span><icon-email /> {{ record.email }}</span>
+              <span v-if="record.notice_type === 'wechat'"><icon-wechat /> 微信通知</span>
+              <span v-else-if="record.notice_type === 'none'">不通知</span>
+              <span v-else><icon-email /> {{ record.email || '-' }}</span>
               <span><icon-clock-circle /> {{ formatTime(record.update_time) }}</span>
               <span><icon-clock-circle /> {{ formatTime(record.update_time) }}</span>
             </div>
             </div>
             <div class="ticket-card__action">
             <div class="ticket-card__action">

+ 1 - 44
src/router/index.js

@@ -451,7 +451,7 @@ const routes = [
     {
     {
         path: "/admin",
         path: "/admin",
         name: 'admin',
         name: 'admin',
-        redirect: '/admin/service/orderList',
+        redirect: '/admin/systemConfig',
         component: DEFAULT_LAYOUT,
         component: DEFAULT_LAYOUT,
         meta: {
         meta: {
             title: '网站管理',
             title: '网站管理',
@@ -541,49 +541,6 @@ const routes = [
                     permission: ['admin', 'service']
                     permission: ['admin', 'service']
                 }
                 }
             },
             },
-            {
-                path: 'service/orderList',
-                name: 'admin.service.orderList',
-                component: () => import('../pages/admin/workOrder/orderList.vue'),
-                meta: {
-                    title: '工单管理',
-                    permission: ['admin', 'service'],
-                    permissionCode: 'page.admin.service.orderList'
-                }
-            },
-            {
-                path: 'service/orderDetail/:id',
-                name: 'admin.service.orderDetail',
-                component: () => import('../pages/admin/workOrder/orderDetail.vue'),
-                meta: {
-                    title: '工单详情',
-                    hideInMenu: true,
-                    parent: 'admin.service.orderList',
-                    permission: ['admin', 'service']
-                }
-            },
-            {
-                path: 'aiChat',
-                name: 'admin.aiChat.list',
-                component: () => import('../pages/admin/aiChat/list.vue'),
-                meta: {
-                    title: 'AI对话管理',
-                    permission: ['admin', 'service'],
-                    permissionCode: 'page.admin.aiChat'
-                }
-            },
-            {
-                path: 'aiChat/detail/:id',
-                name: 'admin.aiChat.detail',
-                component: () => import('../pages/admin/aiChat/detail.vue'),
-                meta: {
-                    title: 'AI对话详情',
-                    hideInMenu: true,
-                    parent: 'admin.aiChat.list',
-                    permission: ['admin', 'service'],
-                    permissionCode: 'page.admin.aiChat'
-                }
-            },
             {
             {
                 path: 'mqQueue',
                 path: 'mqQueue',
                 name: 'admin.mq.queue',
                 name: 'admin.mq.queue',