Browse Source

✨ feat: 增加微信对话能力

Pchen. 1 day ago
parent
commit
f289e8cc84

+ 53 - 1
src/api/aiChat.js

@@ -5,8 +5,12 @@ const api = {
   Conversation: '/AIChat/Conversation',
   Conversation: '/AIChat/Conversation',
   MessageList: '/AIChat/Message/List',
   MessageList: '/AIChat/Message/List',
   Message: '/AIChat/Message',
   Message: '/AIChat/Message',
+  AssistantDashboard: '/AIChat/Assistant/Dashboard',
+  Quota: '/AIChat/Quota',
+  WeixinBinding: '/AIChat/Weixin/Binding',
   AdminConversationList: '/Admin/AIChat/Conversation/List',
   AdminConversationList: '/Admin/AIChat/Conversation/List',
-  AdminConversationDetail: '/Admin/AIChat/Conversation/Detail'
+  AdminConversationDetail: '/Admin/AIChat/Conversation/Detail',
+  AdminWeixinBindingList: '/Admin/AIChat/WeixinBinding/List'
 }
 }
 
 
 export function listAIChatConversations(parameter) {
 export function listAIChatConversations(parameter) {
@@ -41,6 +45,46 @@ export function sendAIChatMessage(parameter) {
   })
   })
 }
 }
 
 
+export function getAIChatAssistantDashboard(parameter) {
+  return request({
+    url: api.AssistantDashboard,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function getAIChatQuota(parameter) {
+  return request({
+    url: api.Quota,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function getWeixinBinding(parameter) {
+  return request({
+    url: api.WeixinBinding,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function createWeixinBinding(parameter) {
+  return request({
+    url: api.WeixinBinding,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function deleteWeixinBinding(parameter) {
+  return request({
+    url: api.WeixinBinding,
+    method: 'delete',
+    data: parameter
+  })
+}
+
 export function adminListAIChatConversations(parameter) {
 export function adminListAIChatConversations(parameter) {
   return request({
   return request({
     url: api.AdminConversationList,
     url: api.AdminConversationList,
@@ -56,3 +100,11 @@ export function adminGetAIChatConversationDetail(parameter) {
     params: parameter
     params: parameter
   })
   })
 }
 }
+
+export function adminListWeixinBindings(parameter) {
+  return request({
+    url: api.AdminWeixinBindingList,
+    method: 'get',
+    params: parameter
+  })
+}

+ 0 - 0
src/assets/VIP.svg → src/assets/img/VIP.svg


BIN
src/assets/img/kefu.png


File diff suppressed because it is too large
+ 0 - 0
src/assets/img/wechat.svg


+ 410 - 39
src/components/AIChat/index.vue

@@ -12,7 +12,7 @@
     <template #title>
     <template #title>
       <div class="ai-chat-title">
       <div class="ai-chat-title">
         <a-avatar :size="36" class="ai-chat-title__avatar">
         <a-avatar :size="36" class="ai-chat-title__avatar">
-          <img src="https://lepao-api.xxoo365.top/uploads/9aca0644-f1f8-4773-b25e-4cd5d268cf0e.png" alt="小妍助理" draggable="false" />
+          <img src="@/assets/img/kefu.png" alt="小妍助理" draggable="false" />
         </a-avatar>
         </a-avatar>
         <div class="ai-chat-title__copy">
         <div class="ai-chat-title__copy">
           <div class="ai-chat-title__name">小妍助理</div>
           <div class="ai-chat-title__name">小妍助理</div>
@@ -55,7 +55,10 @@
               @click="openConversation(item.id)"
               @click="openConversation(item.id)"
             >
             >
               <span class="conversation-item__top">
               <span class="conversation-item__top">
-                <span class="conversation-item__title">{{ item.title || '新对话' }}</span>
+                <span class="conversation-item__title">
+                  <span v-if="item.channel === 'wechat'" class="channel-badge channel-badge--wechat"><icon-wechat /></span>
+                  {{ item.title || '新对话' }}
+                </span>
                 <span v-if="unreadMap[item.id]" class="conversation-item__dot"></span>
                 <span v-if="unreadMap[item.id]" class="conversation-item__dot"></span>
               </span>
               </span>
               <span class="conversation-item__preview">{{ item.last_message_preview || '尚未开始' }}</span>
               <span class="conversation-item__preview">{{ item.last_message_preview || '尚未开始' }}</span>
@@ -98,11 +101,14 @@
 
 
           <div class="chat-toolbar__identity">
           <div class="chat-toolbar__identity">
             <a-avatar :size="34" class="chat-toolbar__avatar">
             <a-avatar :size="34" class="chat-toolbar__avatar">
-              <img src="@/assets/img/ai.svg" alt="小妍助理" draggable="false" />
+              <img src="@/assets/img/ai.svg" alt="小妍助理" draggable="false" v-if="currentConversation?.channel !== 'wechat'"/>
+              <img src="@/assets/img/wechat.svg" alt="微信聊天" draggable="false" v-else />
             </a-avatar>
             </a-avatar>
             <div class="chat-toolbar__text">
             <div class="chat-toolbar__text">
-              <strong>{{ currentConversation?.title || '新对话' }}</strong>
-              <!-- <span>{{ currentConversation?.conversation_no ? `NO.${currentConversation.conversation_no}` : '准备开始' }}</span>  -->
+              <strong>
+                {{ currentConversation?.title || '新对话' }}
+              </strong>
+              <span v-if="currentConversation?.readonly">微信会话仅可在网页查看,请在微信内回复</span>
             </div>
             </div>
           </div>
           </div>
 
 
@@ -123,12 +129,45 @@
 
 
           <template v-else>
           <template v-else>
             <div v-if="messagesLoading" class="message-loading">
             <div v-if="messagesLoading" class="message-loading">
-              <a-spin />
+              <div class="message-loading__hero">
+                <a-avatar :size="42" class="message-loading__avatar">
+                  <img src="@/assets/img/kefu.png" alt="小妍助理" draggable="false" />
+                </a-avatar>
+                <div class="message-loading__copy">
+                  <strong>正在切换会话</strong>
+                  <span>为你整理最近的消息</span>
+                </div>
+                <span class="message-loading__pulse"></span>
+              </div>
+              <div class="message-loading__thread" aria-hidden="true">
+                <div class="message-loading__row">
+                  <span class="message-loading__dot"></span>
+                  <div class="message-loading__bubble message-loading__bubble--assistant">
+                    <i></i>
+                    <i></i>
+                    <i></i>
+                  </div>
+                </div>
+                <div class="message-loading__row message-loading__row--user">
+                  <div class="message-loading__bubble message-loading__bubble--user">
+                    <i></i>
+                    <i></i>
+                  </div>
+                  <span class="message-loading__dot"></span>
+                </div>
+                <div class="message-loading__row">
+                  <span class="message-loading__dot"></span>
+                  <div class="message-loading__bubble message-loading__bubble--assistant message-loading__bubble--short">
+                    <i></i>
+                    <i></i>
+                  </div>
+                </div>
+              </div>
             </div>
             </div>
 
 
             <div v-if="!messagesLoading && !messages.length" class="welcome-card">
             <div v-if="!messagesLoading && !messages.length" class="welcome-card">
               <a-avatar :size="52" class="welcome-card__avatar">
               <a-avatar :size="52" class="welcome-card__avatar">
-                <img src="https://lepao-api.xxoo365.top/uploads/9aca0644-f1f8-4773-b25e-4cd5d268cf0e.png" alt="小妍助理" draggable="false" />
+                <img src="@/assets/img/kefu.png" alt="小妍助理" draggable="false" />
               </a-avatar>
               </a-avatar>
               <div>
               <div>
                 <div class="welcome-card__title">我是小妍助理</div>
                 <div class="welcome-card__title">我是小妍助理</div>
@@ -155,7 +194,7 @@
               :class="`message-row--${message.role}`"
               :class="`message-row--${message.role}`"
             >
             >
               <a-avatar :size="36" class="message-avatar">
               <a-avatar :size="36" class="message-avatar">
-                <img v-if="message.role === 'assistant'" src="https://lepao-api.xxoo365.top/uploads/9aca0644-f1f8-4773-b25e-4cd5d268cf0e.png" alt="小妍助理" draggable="false" />
+                <img v-if="message.role === 'assistant'" src="@/assets/img/kefu.png" alt="小妍助理" draggable="false" />
                 <img v-else-if="message.role === 'user' && avatar" :src="avatar" draggable="false" />
                 <img v-else-if="message.role === 'user' && avatar" :src="avatar" draggable="false" />
                 <icon-user v-else-if="message.role === 'user'" />
                 <icon-user v-else-if="message.role === 'user'" />
                 <icon-info-circle v-else />
                 <icon-info-circle v-else />
@@ -207,7 +246,12 @@
           </div>
           </div>
         </div>
         </div>
 
 
-        <div class="composer-wrap">
+        <div v-if="currentConversation?.readonly" class="readonly-bar">
+          <span class="channel-badge channel-badge--wechat"><icon-wechat /></span>
+          该会话来自微信,网页端仅支持查看
+        </div>
+
+        <div v-else class="composer-wrap">
           <div class="composer">
           <div class="composer">
             <input ref="fileInput" type="file" accept="image/png,image/jpeg,image/jpg" multiple hidden @change="handleFileInput" />
             <input ref="fileInput" type="file" accept="image/png,image/jpeg,image/jpg" multiple hidden @change="handleFileInput" />
             <a-textarea
             <a-textarea
@@ -257,6 +301,10 @@ const props = defineProps({
   visible: {
   visible: {
     type: Boolean,
     type: Boolean,
     default: false
     default: false
+  },
+  action: {
+    type: Object,
+    default: null
   }
   }
 })
 })
 const emit = defineEmits(['close', 'unread-change'])
 const emit = defineEmits(['close', 'unread-change'])
@@ -288,12 +336,14 @@ const fileInput = ref(null)
 const unreadMap = ref({})
 const unreadMap = ref({})
 let conversationTimer = null
 let conversationTimer = null
 let messageTimer = null
 let messageTimer = null
+let messagesRequestSeq = 0
 
 
 const visible = computed(() => props.visible)
 const visible = computed(() => props.visible)
 const drawerWidth = computed(() => (isMobile.value ? '100%' : '940px'))
 const drawerWidth = computed(() => (isMobile.value ? '100%' : '940px'))
 const currentConversation = computed(() => conversations.value.find(item => item.id === currentConversationId.value))
 const currentConversation = computed(() => conversations.value.find(item => item.id === currentConversationId.value))
 const canDeleteCurrentConversation = computed(() => canShowConversationActions(currentConversation.value))
 const canDeleteCurrentConversation = computed(() => canShowConversationActions(currentConversation.value))
-const canSend = computed(() => currentConversationId.value && (input.value.trim() || uploadingImages.value.length > 0))
+const currentReadonly = computed(() => Boolean(currentConversation.value?.readonly || currentConversation.value?.channel === 'wechat'))
+const canSend = computed(() => currentConversationId.value && !currentReadonly.value && (input.value.trim() || uploadingImages.value.length > 0))
 const unreadTotal = computed(() => Object.values(unreadMap.value).reduce((sum, value) => sum + Number(value || 0), 0))
 const unreadTotal = computed(() => Object.values(unreadMap.value).reduce((sum, value) => sum + Number(value || 0), 0))
 const seenKey = computed(() => `aiChatSeen:${userStore.uuid || 'guest'}`)
 const seenKey = computed(() => `aiChatSeen:${userStore.uuid || 'guest'}`)
 const avatar = computed(() => {
 const avatar = computed(() => {
@@ -407,29 +457,36 @@ const loadConversations = async () => {
   }
   }
 }
 }
 
 
-const loadMessages = async () => {
-  if (!currentConversationId.value) return
-  if (isLocalConversation(currentConversationId.value)) {
+const loadMessages = async (conversationId = currentConversationId.value) => {
+  if (!conversationId) return
+  const requestSeq = ++messagesRequestSeq
+  messages.value = []
+  if (isLocalConversation(conversationId)) {
     messages.value = []
     messages.value = []
     messagesLoading.value = false
     messagesLoading.value = false
     return
     return
   }
   }
   messagesLoading.value = true
   messagesLoading.value = true
   try {
   try {
-    const res = await listAIChatMessages({ conversation_id: currentConversationId.value, current: 1, pagesize: 80 })
+    const res = await listAIChatMessages({ conversation_id: conversationId, current: 1, pagesize: 80 })
+    if (requestSeq !== messagesRequestSeq || currentConversationId.value !== conversationId) return
     if (!res || res.code !== 0) return Message.error(res?.msg || '获取消息失败')
     if (!res || res.code !== 0) return Message.error(res?.msg || '获取消息失败')
     messages.value = res.data || []
     messages.value = res.data || []
     markCurrentSeen()
     markCurrentSeen()
     scrollToBottom()
     scrollToBottom()
   } finally {
   } finally {
-    messagesLoading.value = false
+    if (requestSeq === messagesRequestSeq && currentConversationId.value === conversationId) {
+      messagesLoading.value = false
+    }
   }
   }
 }
 }
 
 
 const pollNewMessages = async () => {
 const pollNewMessages = async () => {
   if (!currentConversationId.value || !visible.value || isLocalConversation(currentConversationId.value)) return
   if (!currentConversationId.value || !visible.value || isLocalConversation(currentConversationId.value)) return
+  const conversationId = currentConversationId.value
   const lastId = getLastRemoteMessageId()
   const lastId = getLastRemoteMessageId()
-  const res = await listAIChatMessages({ conversation_id: currentConversationId.value, after_id: lastId })
+  const res = await listAIChatMessages({ conversation_id: conversationId, after_id: lastId })
+  if (currentConversationId.value !== conversationId) return
   if (!res || res.code !== 0) return
   if (!res || res.code !== 0) return
   const incoming = res.data || []
   const incoming = res.data || []
   if (!incoming.length) return
   if (!incoming.length) return
@@ -445,13 +502,21 @@ const pollNewMessages = async () => {
 }
 }
 
 
 const openConversation = async (id) => {
 const openConversation = async (id) => {
+  if (!id) return
+  if (currentConversationId.value === id) {
+    showConversationPanel.value = false
+    return
+  }
   currentConversationId.value = id
   currentConversationId.value = id
+  messagesRequestSeq += 1
+  messages.value = []
+  messagesLoading.value = !isLocalConversation(id)
   showConversationPanel.value = false
   showConversationPanel.value = false
-  await loadMessages()
+  await loadMessages(id)
 }
 }
 
 
 const createConversationAndOpen = async () => {
 const createConversationAndOpen = async () => {
-  const blank = conversations.value.find(item => item.local || Number(item.last_message_time || 0) === 0)
+  const blank = conversations.value.find(item => (item.local || Number(item.last_message_time || 0) === 0) && item.channel !== 'wechat' && !item.readonly)
   if (blank) {
   if (blank) {
     await openConversation(blank.id)
     await openConversation(blank.id)
     return
     return
@@ -474,6 +539,15 @@ const createConversationAndOpen = async () => {
   scrollToBottom()
   scrollToBottom()
 }
 }
 
 
+const openDraftConversation = async (draft = '') => {
+  await createConversationAndOpen()
+  if (currentReadonly.value) await createConversationAndOpen()
+  input.value = String(draft || '')
+  uploadingImages.value = []
+  await nextTick()
+  scrollToBottom()
+}
+
 const clearCurrentConversationIfNeeded = (id) => {
 const clearCurrentConversationIfNeeded = (id) => {
   if (currentConversationId.value !== id) return
   if (currentConversationId.value !== id) return
   currentConversationId.value = null
   currentConversationId.value = null
@@ -554,6 +628,10 @@ const upsertConversation = (conversation, previousId = null) => {
 
 
 const sendMessage = async (overrideContent = '') => {
 const sendMessage = async (overrideContent = '') => {
   const presetContent = String(overrideContent || '').trim()
   const presetContent = String(overrideContent || '').trim()
+  if (currentReadonly.value) {
+    Message.info('微信会话仅支持查看,请在微信内继续对话')
+    return
+  }
   if ((!presetContent && !canSend.value) || sending.value) return
   if ((!presetContent && !canSend.value) || sending.value) return
   const previousConversationId = currentConversationId.value
   const previousConversationId = currentConversationId.value
   const content = presetContent || input.value.trim()
   const content = presetContent || input.value.trim()
@@ -672,6 +750,13 @@ watch(() => props.visible, async (value) => {
   }
   }
 })
 })
 
 
+watch(() => props.action, async (action) => {
+  if (!action) return
+  if (action.newConversation || action.draft) {
+    await openDraftConversation(action.draft || '')
+  }
+}, { deep: true })
+
 watch(unreadTotal, value => emit('unread-change', value))
 watch(unreadTotal, value => emit('unread-change', value))
 
 
 onMounted(async () => {
 onMounted(async () => {
@@ -761,7 +846,10 @@ onUnmounted(() => {
   min-height: 0;
   min-height: 0;
   overflow: hidden;
   overflow: hidden;
   margin: 0;
   margin: 0;
-  background: linear-gradient(135deg, #f7f9ff 0%, #ffffff 45%, #f6fbf8 100%);
+  background:
+    radial-gradient(circle at 18% 12%, rgba(43, 179, 169, 0.14), transparent 30%),
+    radial-gradient(circle at 90% 6%, rgba(116, 89, 255, 0.12), transparent 28%),
+    linear-gradient(135deg, #f7f9ff 0%, #ffffff 48%, #f3fbf8 100%);
   border-top: 1px solid rgba(29, 33, 41, 0.08);
   border-top: 1px solid rgba(29, 33, 41, 0.08);
 }
 }
 
 
@@ -780,7 +868,7 @@ onUnmounted(() => {
   min-height: 0;
   min-height: 0;
   overflow: hidden;
   overflow: hidden;
   border-right: 1px solid rgba(29, 33, 41, 0.08);
   border-right: 1px solid rgba(29, 33, 41, 0.08);
-  background: rgba(255, 255, 255, 0.78);
+  background: rgba(255, 255, 255, 0.84);
   backdrop-filter: blur(18px);
   backdrop-filter: blur(18px);
 }
 }
 
 
@@ -790,6 +878,7 @@ onUnmounted(() => {
   align-items: center;
   align-items: center;
   justify-content: space-between;
   justify-content: space-between;
   padding: 18px 16px 12px;
   padding: 18px 16px 12px;
+  border-bottom: 1px solid rgba(29, 33, 41, 0.05);
 }
 }
 
 
 .conversation-panel__label {
 .conversation-panel__label {
@@ -854,18 +943,35 @@ onUnmounted(() => {
   padding: 13px 14px;
   padding: 13px 14px;
   border: 1px solid rgba(29, 33, 41, 0.07);
   border: 1px solid rgba(29, 33, 41, 0.07);
   border-radius: 8px;
   border-radius: 8px;
-  background: rgba(255, 255, 255, 0.58);
+  background: rgba(255, 255, 255, 0.72);
   box-shadow: 0 10px 24px rgba(15, 23, 42, 0.04);
   box-shadow: 0 10px 24px rgba(15, 23, 42, 0.04);
   text-align: left;
   text-align: left;
   cursor: pointer;
   cursor: pointer;
   transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease, background 0.18s ease;
   transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease, background 0.18s ease;
 
 
+  &::before {
+    content: '';
+    position: absolute;
+    inset: 9px auto 9px 0;
+    width: 3px;
+    border-radius: 0 999px 999px 0;
+    background: linear-gradient(180deg, #2bb3a9, #7459ff);
+    opacity: 0;
+    transform: scaleY(0.45);
+    transition: opacity 0.18s ease, transform 0.18s ease;
+  }
+
   &:hover,
   &:hover,
   &--active {
   &--active {
     border-color: rgba(116, 89, 255, 0.36);
     border-color: rgba(116, 89, 255, 0.36);
     background: #ffffff;
     background: #ffffff;
     box-shadow: 0 12px 28px rgba(82, 71, 190, 0.12);
     box-shadow: 0 12px 28px rgba(82, 71, 190, 0.12);
     transform: translateY(-1px);
     transform: translateY(-1px);
+
+    &::before {
+      opacity: 1;
+      transform: scaleY(1);
+    }
   }
   }
 
 
   &--local {
   &--local {
@@ -889,6 +995,9 @@ onUnmounted(() => {
   }
   }
 
 
   &__title {
   &__title {
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
     min-width: 0;
     min-width: 0;
     color: #1d2129;
     color: #1d2129;
     font-weight: 650;
     font-weight: 650;
@@ -914,6 +1023,38 @@ onUnmounted(() => {
   }
   }
 }
 }
 
 
+.channel-badge {
+  display: inline-grid;
+  place-items: center;
+  width: 18px;
+  height: 18px;
+  flex: 0 0 auto;
+  border-radius: 6px;
+  color: #ffffff;
+  font-size: 11px;
+  font-weight: 800;
+  line-height: 1;
+
+  &--wechat {
+    background: linear-gradient(135deg, #12b76a, #039855);
+    box-shadow: 0 6px 14px rgba(18, 183, 106, 0.22);
+  }
+}
+
+.readonly-bar {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin: 0 18px 18px;
+  padding: 12px 14px;
+  border: 1px solid rgba(18, 183, 106, 0.22);
+  border-radius: 12px;
+  background: rgba(240, 253, 244, 0.92);
+  color: #027a48;
+  font-size: 13px;
+  box-shadow: 0 12px 28px rgba(3, 152, 85, 0.08);
+}
+
 .chat-main {
 .chat-main {
   position: relative;
   position: relative;
   display: grid;
   display: grid;
@@ -921,6 +1062,10 @@ onUnmounted(() => {
   min-width: 0;
   min-width: 0;
   min-height: 0;
   min-height: 0;
   overflow: hidden;
   overflow: hidden;
+  background:
+    linear-gradient(180deg, rgba(255, 255, 255, 0.32), rgba(255, 255, 255, 0.74)),
+    radial-gradient(circle at 24px 24px, rgba(116, 89, 255, 0.07) 0 1px, transparent 1px);
+  background-size: auto, 22px 22px;
 
 
   &--dragging::after {
   &--dragging::after {
     content: '释放上传图片';
     content: '释放上传图片';
@@ -946,7 +1091,7 @@ onUnmounted(() => {
   min-height: 56px;
   min-height: 56px;
   padding: 10px 16px;
   padding: 10px 16px;
   border-bottom: 1px solid rgba(29, 33, 41, 0.08);
   border-bottom: 1px solid rgba(29, 33, 41, 0.08);
-  background: rgba(255, 255, 255, 0.62);
+  background: rgba(255, 255, 255, 0.78);
   backdrop-filter: blur(16px);
   backdrop-filter: blur(16px);
 
 
   &__identity {
   &__identity {
@@ -959,7 +1104,8 @@ onUnmounted(() => {
 
 
   &__avatar {
   &__avatar {
     flex: 0 0 auto;
     flex: 0 0 auto;
-    background: #eef4ff;
+    background: linear-gradient(135deg, #eef7ff, #edfdf7);
+    box-shadow: 0 8px 20px rgba(43, 179, 169, 0.12);
   }
   }
 
 
   &__text {
   &__text {
@@ -1032,9 +1178,157 @@ onUnmounted(() => {
 }
 }
 
 
 .message-loading {
 .message-loading {
+  display: grid;
+  align-content: center;
+  gap: 22px;
+  min-height: 100%;
+  padding: 22px;
+}
+
+.message-loading__hero {
+  position: relative;
   display: flex;
   display: flex;
-  justify-content: center;
-  padding: 18px;
+  align-items: center;
+  gap: 12px;
+  width: min(430px, 100%);
+  margin: 0 auto;
+  padding: 14px 16px;
+  border: 1px solid rgba(116, 89, 255, 0.14);
+  border-radius: 16px;
+  background: rgba(255, 255, 255, 0.78);
+  box-shadow:
+    0 20px 46px rgba(36, 48, 94, 0.08),
+    inset 0 1px 0 rgba(255, 255, 255, 0.86);
+  overflow: hidden;
+}
+
+.message-loading__hero::before {
+  content: '';
+  position: absolute;
+  inset: 0;
+  background: linear-gradient(110deg, transparent 0%, rgba(255, 255, 255, 0.7) 42%, transparent 70%);
+  transform: translateX(-100%);
+  animation: ai-chat-shimmer 1.9s ease-in-out infinite;
+}
+
+.message-loading__avatar {
+  position: relative;
+  z-index: 1;
+  flex: 0 0 auto;
+  background: linear-gradient(135deg, #eaf2ff, #edfbf7);
+  box-shadow: 0 10px 24px rgba(43, 179, 169, 0.16);
+}
+
+.message-loading__copy {
+  position: relative;
+  z-index: 1;
+  display: grid;
+  gap: 3px;
+  min-width: 0;
+}
+
+.message-loading__copy strong {
+  color: #1d2129;
+  font-size: 15px;
+  font-weight: 720;
+}
+
+.message-loading__copy span {
+  color: #667085;
+  font-size: 12px;
+}
+
+.message-loading__pulse {
+  position: relative;
+  z-index: 1;
+  width: 10px;
+  height: 10px;
+  margin-left: auto;
+  border-radius: 50%;
+  background: #2bb3a9;
+  box-shadow: 0 0 0 0 rgba(43, 179, 169, 0.28);
+  animation: ai-chat-pulse 1.4s ease-out infinite;
+}
+
+.message-loading__thread {
+  display: grid;
+  gap: 16px;
+  width: min(560px, 100%);
+  margin: 0 auto;
+}
+
+.message-loading__row {
+  display: flex;
+  align-items: flex-start;
+  gap: 10px;
+}
+
+.message-loading__row--user {
+  justify-content: flex-end;
+}
+
+.message-loading__dot {
+  width: 34px;
+  height: 34px;
+  flex: 0 0 34px;
+  border-radius: 50%;
+  background:
+    linear-gradient(135deg, rgba(234, 242, 255, 0.95), rgba(237, 251, 247, 0.95));
+  box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
+}
+
+.message-loading__bubble {
+  display: grid;
+  gap: 9px;
+  width: min(72%, 390px);
+  padding: 14px;
+  border: 1px solid rgba(29, 33, 41, 0.06);
+  border-radius: 16px;
+  background: rgba(255, 255, 255, 0.76);
+  box-shadow: 0 14px 34px rgba(15, 23, 42, 0.06);
+}
+
+.message-loading__bubble--user {
+  width: min(58%, 310px);
+  background: linear-gradient(135deg, rgba(116, 89, 255, 0.9), rgba(43, 179, 169, 0.86));
+
+  i {
+    background: rgba(255, 255, 255, 0.46);
+  }
+}
+
+.message-loading__bubble--short {
+  width: min(52%, 280px);
+}
+
+.message-loading__bubble i {
+  display: block;
+  height: 10px;
+  border-radius: 999px;
+  background:
+    linear-gradient(90deg, rgba(226, 232, 240, 0.76), rgba(245, 247, 251, 0.96), rgba(226, 232, 240, 0.76));
+  background-size: 220% 100%;
+  animation: ai-chat-skeleton 1.45s ease-in-out infinite;
+
+  &:nth-child(1) {
+    width: 92%;
+  }
+
+  &:nth-child(2) {
+    width: 74%;
+    animation-delay: 0.08s;
+  }
+
+  &:nth-child(3) {
+    width: 48%;
+    animation-delay: 0.16s;
+  }
+}
+
+.message-loading__bubble--user i {
+  background:
+    linear-gradient(90deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0.62), rgba(255, 255, 255, 0.3));
+  background-size: 220% 100%;
 }
 }
 
 
 .empty-state,
 .empty-state,
@@ -1113,6 +1407,7 @@ onUnmounted(() => {
   align-items: flex-start;
   align-items: flex-start;
   gap: 11px;
   gap: 11px;
   margin-bottom: 18px;
   margin-bottom: 18px;
+  animation: ai-chat-message-in 0.24s ease-out both;
 
 
   &--user {
   &--user {
     flex-direction: row-reverse;
     flex-direction: row-reverse;
@@ -1127,8 +1422,9 @@ onUnmounted(() => {
 
 
     .message-bubble {
     .message-bubble {
       color: #ffffff;
       color: #ffffff;
-      background: linear-gradient(135deg, #6f63ff, #22a08f);
-      box-shadow: 0 12px 28px rgba(82, 71, 190, 0.18);
+      border-color: rgba(255, 255, 255, 0.2);
+      background: linear-gradient(135deg, #6757f5, #1aa99a);
+      box-shadow: 0 16px 32px rgba(43, 86, 132, 0.2);
     }
     }
 
 
     .message-content :deep(a) {
     .message-content :deep(a) {
@@ -1180,8 +1476,10 @@ onUnmounted(() => {
   padding: 12px 14px;
   padding: 12px 14px;
   border: 1px solid rgba(29, 33, 41, 0.07);
   border: 1px solid rgba(29, 33, 41, 0.07);
   border-radius: 14px;
   border-radius: 14px;
-  background: rgba(255, 255, 255, 0.86);
-  box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06);
+  background: rgba(255, 255, 255, 0.92);
+  box-shadow:
+    0 14px 30px rgba(15, 23, 42, 0.06),
+    inset 0 1px 0 rgba(255, 255, 255, 0.78);
   line-height: 1.72;
   line-height: 1.72;
   word-break: break-word;
   word-break: break-word;
 }
 }
@@ -1315,7 +1613,7 @@ onUnmounted(() => {
 
 
 .composer-wrap {
 .composer-wrap {
   padding: 12px 18px 20px;
   padding: 12px 18px 20px;
-  background: linear-gradient(180deg, rgba(255, 255, 255, 0), rgba(250, 252, 255, 0.96) 32%);
+  background: linear-gradient(180deg, rgba(255, 255, 255, 0), rgba(250, 252, 255, 0.98) 32%);
 }
 }
 
 
 .composer {
 .composer {
@@ -1324,19 +1622,19 @@ onUnmounted(() => {
   gap: 8px;
   gap: 8px;
   min-height: 98px;
   min-height: 98px;
   padding: 12px 14px 10px;
   padding: 12px 14px 10px;
-  border: 1px solid rgba(143, 118, 255, 0.42);
-  border-radius: 22px;
+  border: 1px solid rgba(103, 87, 245, 0.28);
+  border-radius: 18px;
   background: rgba(255, 255, 255, 0.96);
   background: rgba(255, 255, 255, 0.96);
   box-shadow:
   box-shadow:
-    0 18px 44px rgba(50, 42, 130, 0.1),
-    0 0 0 3px rgba(175, 147, 255, 0.08);
+    0 18px 44px rgba(35, 48, 86, 0.1),
+    0 0 0 3px rgba(43, 179, 169, 0.06);
   transition: border-color 0.18s ease, box-shadow 0.18s ease;
   transition: border-color 0.18s ease, box-shadow 0.18s ease;
 
 
   &:focus-within {
   &:focus-within {
-    border-color: rgba(143, 118, 255, 0.72);
+    border-color: rgba(43, 179, 169, 0.5);
     box-shadow:
     box-shadow:
-      0 20px 48px rgba(50, 42, 130, 0.13),
-      0 0 0 4px rgba(175, 147, 255, 0.12);
+      0 20px 48px rgba(35, 48, 86, 0.13),
+      0 0 0 4px rgba(43, 179, 169, 0.12);
   }
   }
 
 
   :deep(.arco-textarea-wrapper) {
   :deep(.arco-textarea-wrapper) {
@@ -1406,8 +1704,8 @@ onUnmounted(() => {
     width: 32px;
     width: 32px;
     height: 32px;
     height: 32px;
     border: 0;
     border: 0;
-    background: linear-gradient(135deg, #c9a8ff, #8f7cff);
-    box-shadow: 0 8px 18px rgba(143, 118, 255, 0.28);
+    background: linear-gradient(135deg, #6757f5, #20b7a7);
+    box-shadow: 0 10px 22px rgba(43, 179, 169, 0.28);
   }
   }
 }
 }
 
 
@@ -1435,6 +1733,56 @@ onUnmounted(() => {
   }
   }
 }
 }
 
 
+@keyframes ai-chat-shimmer {
+  0% {
+    transform: translateX(-100%);
+  }
+
+  58%,
+  100% {
+    transform: translateX(100%);
+  }
+}
+
+@keyframes ai-chat-skeleton {
+  0% {
+    background-position: 120% 0;
+  }
+
+  100% {
+    background-position: -120% 0;
+  }
+}
+
+@keyframes ai-chat-pulse {
+  0% {
+    box-shadow: 0 0 0 0 rgba(43, 179, 169, 0.3);
+    transform: scale(0.92);
+  }
+
+  70% {
+    box-shadow: 0 0 0 9px rgba(43, 179, 169, 0);
+    transform: scale(1);
+  }
+
+  100% {
+    box-shadow: 0 0 0 0 rgba(43, 179, 169, 0);
+    transform: scale(0.92);
+  }
+}
+
+@keyframes ai-chat-message-in {
+  from {
+    opacity: 0;
+    transform: translateY(5px);
+  }
+
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
 @media (max-width: 768px) {
 @media (max-width: 768px) {
   .ai-chat-shell {
   .ai-chat-shell {
     grid-template-columns: 1fr;
     grid-template-columns: 1fr;
@@ -1467,6 +1815,29 @@ onUnmounted(() => {
     padding: 16px 12px 12px;
     padding: 16px 12px 12px;
   }
   }
 
 
+  .message-loading {
+    gap: 18px;
+    padding: 14px 4px;
+  }
+
+  .message-loading__hero {
+    width: 100%;
+    padding: 12px;
+    border-radius: 14px;
+  }
+
+  .message-loading__thread {
+    gap: 13px;
+  }
+
+  .message-loading__bubble {
+    width: min(76%, 320px);
+  }
+
+  .message-loading__bubble--user {
+    width: min(68%, 280px);
+  }
+
   .welcome-card {
   .welcome-card {
     min-height: 260px;
     min-height: 260px;
     padding: 0 6px;
     padding: 0 6px;

+ 1 - 0
src/components/LepaoAccountCard/accountDetailCard.vue

@@ -213,6 +213,7 @@ const noticeLabel = computed(() => {
   if (!item) return '-'
   if (!item) return '-'
   if (item.notice_type === 'email') return `📧 ${item.email || '未绑定'}`
   if (item.notice_type === 'email') return `📧 ${item.email || '未绑定'}`
   if (item.notice_type === 'bot') return `🤖 ${item.bot_account || '未绑定'}`
   if (item.notice_type === 'bot') return `🤖 ${item.bot_account || '未绑定'}`
+  if (item.notice_type === 'wechat') return '微信通知'
   return '无通知'
   return '无通知'
 })
 })
 
 

+ 108 - 7
src/components/userCard/userCard.vue

@@ -3,12 +3,19 @@
     <div class="user-card__main">
     <div class="user-card__main">
       <div class="user-card__stat">
       <div class="user-card__stat">
         <span class="user-card__label">剩余乐跑次数</span>
         <span class="user-card__label">剩余乐跑次数</span>
-        <a-statistic
-          :value="userCount?.lepao_count ?? 0"
-          :value-style="{ fontSize: '2rem', fontWeight: 700, color: 'var(--stat-color)' }"
-          animation
-          show-group-separator
-        />
+        <div class="user-card__stat-row">
+          <a-statistic
+            :value="userCount?.lepao_count ?? 0"
+            :value-style="{ fontSize: '2rem', fontWeight: 700, color: 'var(--stat-color)' }"
+            animation
+            show-group-separator
+          />
+          <div class="vip-strip" :class="{ 'vip-strip--active': isVip }" @click="router.push('/ai/assistant')">
+            <span class="vip-strip__badge">VIP</span>
+            <span class="vip-strip__text">{{ vipText }}</span>
+            <icon-right class="vip-strip__arrow" />
+          </div>
+        </div>
       </div>
       </div>
       <div class="user-card__actions">
       <div class="user-card__actions">
         <a-button
         <a-button
@@ -74,7 +81,7 @@
 </template>
 </template>
 
 
 <script setup>
 <script setup>
-import { ref, reactive, onUnmounted, onMounted } from 'vue'
+import { ref, reactive, onUnmounted, onMounted, computed } from 'vue'
 import { useRouter } from 'vue-router'
 import { useRouter } from 'vue-router'
 import { getCount, sendCount } from '@/api/goods'
 import { getCount, sendCount } from '@/api/goods'
 import { Notification, Message } from '@arco-design/web-vue'
 import { Notification, Message } from '@arco-design/web-vue'
@@ -90,6 +97,18 @@ const visible = ref(false)
 const ok_loading = ref(false)
 const ok_loading = ref(false)
 const sendform = reactive({ username: '', count: 1 })
 const sendform = reactive({ username: '', count: 1 })
 
 
+const isVip = computed(() => {
+  const expire = Number(userCount.value?.vip_expire_time || 0)
+  return expire > Date.now() || (Number(userCount.value?.vip || 0) === 1 && expire === 0)
+})
+
+const vipText = computed(() => {
+  const expire = Number(userCount.value?.vip_expire_time || 0)
+  if (!isVip.value) return '开通会员'
+  if (!expire) return '永久会员'
+  return `VIP 至 ${new Date(expire).toLocaleDateString('zh-CN')}`
+})
+
 const handleBeforeOk = async (done) => {
 const handleBeforeOk = async (done) => {
   if (!hasPermission('action.goods.sendCount')) {
   if (!hasPermission('action.goods.sendCount')) {
     Notification.warning({ title: '无权限', content: '当前账号暂无赠送权限' })
     Notification.warning({ title: '无权限', content: '当前账号暂无赠送权限' })
@@ -197,6 +216,17 @@ onUnmounted(stopPolling)
     margin-bottom: 4px;
     margin-bottom: 4px;
   }
   }
 
 
+  &__stat-row {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: baseline;
+    gap: 10px;
+
+    :deep(.arco-statistic) {
+      flex: 0 0 auto;
+    }
+  }
+
   &__actions {
   &__actions {
     display: flex;
     display: flex;
     flex-wrap: wrap;
     flex-wrap: wrap;
@@ -204,6 +234,64 @@ onUnmounted(stopPolling)
   }
   }
 }
 }
 
 
+.vip-strip {
+  display: inline-flex;
+  align-items: center;
+  align-self: center;
+  gap: 6px;
+  min-height: 28px;
+  max-width: min(210px, 100%);
+  padding: 4px 7px 4px 5px;
+  border: 1px solid rgba(236, 133, 171, 0.18);
+  border-radius: 999px;
+  background: linear-gradient(135deg, rgba(255, 246, 250, 0.96), rgba(255, 250, 240, 0.92));
+  color: #94465f;
+  font-size: 12px;
+  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;
+
+  &:hover {
+    border-color: rgba(236, 133, 171, 0.34);
+    box-shadow: 0 10px 22px rgba(236, 133, 171, 0.12);
+    transform: translateY(-1px);
+  }
+
+  &--active {
+    border-color: rgba(249, 168, 212, 0.3);
+    background: linear-gradient(135deg, rgba(255, 245, 248, 0.98), rgba(255, 247, 237, 0.96));
+    color: #8f3f58;
+    box-shadow: 0 8px 18px rgba(249, 168, 212, 0.1);
+  }
+
+  &__badge {
+    display: inline-grid;
+    place-items: center;
+    height: 18px;
+    min-width: 30px;
+    padding: 0 6px;
+    border-radius: 999px;
+    background: linear-gradient(135deg, #f9a8d4, #8b5cf6);
+    color: #fff;
+    font-size: 10px;
+    font-weight: 900;
+    box-shadow: 0 6px 14px rgba(139, 92, 246, 0.18);
+  }
+
+  &__text {
+    min-width: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  &__arrow {
+    flex: 0 0 auto;
+    font-size: 11px;
+    opacity: 0.56;
+  }
+}
+
 .action-btn {
 .action-btn {
   border-radius: 999px;
   border-radius: 999px;
 
 
@@ -217,4 +305,17 @@ onUnmounted(stopPolling)
   margin-top: 4px;
   margin-top: 4px;
   text-align: center;
   text-align: center;
 }
 }
+
+@media (max-width: 640px) {
+  .user-card__stat-row {
+    align-items: flex-start;
+    gap: 8px;
+  }
+
+  .vip-strip {
+    width: auto;
+    max-width: 100%;
+    justify-content: flex-start;
+  }
+}
 </style>
 </style>

+ 17 - 3
src/layout/default-layout.vue

@@ -50,7 +50,13 @@
       </a-avatar>
       </a-avatar>
     </a-badge>
     </a-badge>
 
 
-    <AIChat v-if="aiAvailable" :visible="chat" @close="chat = false" @unread-change="aiUnread = $event" />
+    <AIChat
+      v-if="aiAvailable"
+      :visible="chat"
+      :action="aiAction"
+      @close="chat = false"
+      @unread-change="aiUnread = $event"
+    />
 
 
     <a-modal
     <a-modal
       v-model:visible="popupVisible"
       v-model:visible="popupVisible"
@@ -79,6 +85,7 @@ import Footer from '@/components/Footer/index.vue'
 import AIChat from '@/components/AIChat/index.vue'
 import AIChat from '@/components/AIChat/index.vue'
 
 
 const chat = ref(false)
 const chat = ref(false)
+const aiAction = ref(null)
 const aiUnread = ref(0)
 const aiUnread = ref(0)
 const popupVisible = ref(false)
 const popupVisible = ref(false)
 const popupData = ref({})
 const popupData = ref({})
@@ -92,6 +99,11 @@ let isDragging = false
 let offsetX = 0
 let offsetX = 0
 let offsetY = 0
 let offsetY = 0
 let button = null
 let button = null
+const closeAI = () => { chat.value = false }
+const openAI = (payload = {}) => {
+  aiAction.value = { ...(payload || {}), ts: Date.now() }
+  chat.value = true
+}
 
 
 function handleMouseDown(e) {
 function handleMouseDown(e) {
   isDragging = true
   isDragging = true
@@ -125,7 +137,8 @@ onMounted(() => {
   if (button) {
   if (button) {
     button.addEventListener('mousedown', handleMouseDown)
     button.addEventListener('mousedown', handleMouseDown)
   }
   }
-  eventBus.on('closeAI', () => { chat.value = false })
+  eventBus.on('closeAI', closeAI)
+  eventBus.on('openAI', openAI)
   const mobile = window.innerWidth <= 992
   const mobile = window.innerWidth <= 992
   isMobile.value = mobile
   isMobile.value = mobile
   if (mobile) {
   if (mobile) {
@@ -143,7 +156,8 @@ onBeforeUnmount(() => {
 })
 })
 
 
 onUnmounted(() => {
 onUnmounted(() => {
-  eventBus.off('closeAI', () => { chat.value = false })
+  eventBus.off('closeAI', closeAI)
+  eventBus.off('openAI', openAI)
 })
 })
 
 
 const getPopup = async () => {
 const getPopup = async () => {

+ 139 - 0
src/pages/admin/aiChat/weixinBindingList.vue

@@ -0,0 +1,139 @@
+<template>
+  <div class="admin-wechat-page">
+    <a-card :bordered="false">
+      <template #title>微信绑定管理</template>
+      <a-form :model="query" layout="inline" class="query-form">
+        <a-form-item label="用户UUID">
+          <a-input v-model="query.user_uuid" allow-clear placeholder="模糊搜索" />
+        </a-form-item>
+        <a-form-item label="用户名">
+          <a-input v-model="query.username" allow-clear placeholder="模糊搜索" />
+        </a-form-item>
+        <a-form-item label="状态">
+          <a-select v-model="query.state" allow-clear style="width: 140px">
+            <a-option :value="-1">全部</a-option>
+            <a-option :value="0">待扫码</a-option>
+            <a-option :value="1">已绑定</a-option>
+            <a-option :value="2">已解绑</a-option>
+            <a-option :value="3">已过期</a-option>
+          </a-select>
+        </a-form-item>
+        <a-form-item label="VIP">
+          <a-select v-model="query.vip" allow-clear style="width: 120px">
+            <a-option value="">全部</a-option>
+            <a-option value="1">VIP</a-option>
+            <a-option value="0">非VIP</a-option>
+          </a-select>
+        </a-form-item>
+        <a-space>
+          <a-button type="primary" @click="loadData">
+            <template #icon><icon-search /></template>
+            查询
+          </a-button>
+          <a-button @click="reset">重置</a-button>
+        </a-space>
+      </a-form>
+
+      <a-table
+        row-key="user_uuid"
+        :data="rows"
+        :loading="loading"
+        :pagination="pagination"
+        @page-change="page => { query.current = page; loadData() }"
+        @page-size-change="size => { query.pagesize = size; query.current = 1; loadData() }"
+      >
+        <template #columns>
+          <a-table-column title="用户" :width="180">
+            <template #cell="{ record }">
+              <div class="user-cell">
+                <strong>{{ record.username || '-' }}</strong>
+                <span>{{ record.user_uuid }}</span>
+              </div>
+            </template>
+          </a-table-column>
+          <a-table-column title="状态" :width="110">
+            <template #cell="{ record }">
+              <a-tag :color="statusColor(record.state)">{{ statusText(record.state) }}</a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="微信" :width="90">
+            <template #cell="{ record }">{{ record.wechat_last_user_id ? '已识别' : '-' }}</template>
+          </a-table-column>
+          <a-table-column title="VIP到期" :width="170">
+            <template #cell="{ record }">{{ formatTime(record.vip_expire_time) || (record.vip ? '永久' : '-') }}</template>
+          </a-table-column>
+          <a-table-column title="最近微信消息" :width="170">
+            <template #cell="{ record }">{{ formatTime(record.last_user_message_time) || '-' }}</template>
+          </a-table-column>
+          <a-table-column title="最后轮询" :width="170">
+            <template #cell="{ record }">{{ formatTime(record.last_poll_time) || '-' }}</template>
+          </a-table-column>
+          <a-table-column title="错误" data-index="last_error" ellipsis tooltip />
+        </template>
+      </a-table>
+    </a-card>
+  </div>
+</template>
+
+<script setup>
+import { onMounted, reactive, ref } from 'vue'
+import { Message } from '@arco-design/web-vue'
+import { adminListWeixinBindings } from '@/api/aiChat'
+
+const loading = ref(false)
+const rows = ref([])
+const pagination = reactive({ current: 1, pageSize: 20, total: 0, showTotal: true, showPageSize: true })
+const query = reactive({ current: 1, pagesize: 20, user_uuid: '', username: '', state: -1, vip: '' })
+
+const statusText = (state) => ({ 0: '待扫码', 1: '已绑定', 2: '已解绑', 3: '已过期' }[Number(state)] || '未知')
+const statusColor = (state) => ({ 0: 'orange', 1: 'green', 2: 'gray', 3: 'red' }[Number(state)] || 'gray')
+const formatTime = (value) => value ? new Date(Number(value)).toLocaleString('zh-CN', { hour12: false }) : ''
+
+const loadData = async () => {
+  loading.value = true
+  try {
+    const res = await adminListWeixinBindings(query)
+    if (!res || res.code !== 0) return Message.error(res?.msg || '加载失败')
+    rows.value = res.data || []
+    pagination.current = Number(res.pagination?.current || query.current)
+    pagination.pageSize = Number(res.pagination?.pagesize || query.pagesize)
+    pagination.total = Number(res.pagination?.total || 0)
+  } finally {
+    loading.value = false
+  }
+}
+
+const reset = () => {
+  query.current = 1
+  query.pagesize = 20
+  query.user_uuid = ''
+  query.username = ''
+  query.state = -1
+  query.vip = ''
+  loadData()
+}
+
+onMounted(loadData)
+</script>
+
+<style scoped lang="less">
+.admin-wechat-page {
+  padding: 16px;
+}
+
+.query-form {
+  margin-bottom: 16px;
+}
+
+.user-cell {
+  display: grid;
+  gap: 4px;
+}
+
+.user-cell span {
+  overflow: hidden;
+  color: var(--color-text-3);
+  font-size: 12px;
+  text-overflow: ellipsis;
+}
+</style>

+ 48 - 3
src/pages/admin/goods/addGoods.vue

@@ -44,6 +44,35 @@
               <a-input-number v-model="form.num" :step="1" :precision="0" :min="0" style="width: 100%" />
               <a-input-number v-model="form.num" :step="1" :precision="0" :min="0" style="width: 100%" />
             </a-form-item>
             </a-form-item>
           </a-col>
           </a-col>
+          <a-col :xs="24" :sm="8">
+            <a-form-item field="vip" label="提供 VIP">
+              <a-switch v-model="form.vip" :checked-value="1" :unchecked-value="0" />
+            </a-form-item>
+          </a-col>
+          <a-col :xs="24" :sm="8">
+            <a-form-item field="vip_validity_type" label="VIP 有效期类型">
+              <a-select v-model="form.vip_validity_type" :disabled="form.vip !== 1">
+                <a-option value="none">不设置</a-option>
+                <a-option value="days">按天数顺延</a-option>
+                <a-option value="fixed">固定日期</a-option>
+              </a-select>
+            </a-form-item>
+          </a-col>
+          <a-col v-if="form.vip === 1 && form.vip_validity_type === 'days'" :xs="24" :sm="8">
+            <a-form-item field="vip_valid_days" label="VIP 天数">
+              <a-input-number v-model="form.vip_valid_days" :step="1" :precision="0" :min="0" style="width: 100%" />
+            </a-form-item>
+          </a-col>
+          <a-col v-if="form.vip === 1 && form.vip_validity_type === 'fixed'" :xs="24" :sm="8">
+            <a-form-item field="vip_fixed_expire_time" label="固定到期日">
+              <a-date-picker v-model="vipFixedDate" style="width: 100%" />
+            </a-form-item>
+          </a-col>
+          <a-col :xs="24" :sm="8">
+            <a-form-item field="allow_refund" label="支持自助退款">
+              <a-switch v-model="form.allow_refund" :checked-value="1" :unchecked-value="0" />
+            </a-form-item>
+          </a-col>
           <a-col :xs="24">
           <a-col :xs="24">
             <a-form-item field="icon" label="商品图标(Emoji)">
             <a-form-item field="icon" label="商品图标(Emoji)">
               <div class="emoji-panel">
               <div class="emoji-panel">
@@ -130,7 +159,7 @@
 </template>
 </template>
 
 
 <script setup>
 <script setup>
-import { ref, reactive, computed, onMounted } from 'vue'
+import { ref, reactive, computed, onMounted, watch } from 'vue'
 import { adminGetGoods, addGoods } from '@/api/goods'
 import { adminGetGoods, addGoods } from '@/api/goods'
 import { useRoute } from 'vue-router'
 import { useRoute } from 'vue-router'
 import { Notification } from '@arco-design/web-vue'
 import { Notification } from '@arco-design/web-vue'
@@ -151,6 +180,7 @@ const isEdit = computed(() => Boolean(route.params.id))
 const featureItems = ref([''])
 const featureItems = ref([''])
 
 
 const displayEmoji = computed(() => getGoodsEmoji(form.icon))
 const displayEmoji = computed(() => getGoodsEmoji(form.icon))
+const vipFixedDate = ref(null)
 
 
 const rules = {
 const rules = {
   name: [{ required: true, message: '请输入商品名称' }],
   name: [{ required: true, message: '请输入商品名称' }],
@@ -169,7 +199,16 @@ const form = reactive({
   ic_count: 0,
   ic_count: 0,
   content: '',
   content: '',
   state: 1,
   state: 1,
-  icon: DEFAULT_GOODS_EMOJI
+  icon: DEFAULT_GOODS_EMOJI,
+  vip: 0,
+  vip_validity_type: 'none',
+  vip_valid_days: 0,
+  vip_fixed_expire_time: 0,
+  allow_refund: 1
+})
+
+watch(vipFixedDate, (value) => {
+  form.vip_fixed_expire_time = value ? new Date(value).getTime() : 0
 })
 })
 
 
 const addFeature = () => {
 const addFeature = () => {
@@ -227,7 +266,7 @@ const getGoodsDetail = async () => {
         content: res?.msg ?? '请稍后再试'
         content: res?.msg ?? '请稍后再试'
       })
       })
     }
     }
-    const { name, price, num, content, state, lepao_count, icon, description, features } = res.data
+    const { name, price, num, content, state, lepao_count, icon, description, features, vip, vip_validity_type, vip_valid_days, vip_fixed_expire_time, allow_refund } = res.data
     form.content = decodeGoodsContent(content)
     form.content = decodeGoodsContent(content)
     form.name = name
     form.name = name
     form.description = description || ''
     form.description = description || ''
@@ -236,6 +275,12 @@ const getGoodsDetail = async () => {
     form.num = num
     form.num = num
     form.state = state
     form.state = state
     form.icon = getGoodsEmoji(icon)
     form.icon = getGoodsEmoji(icon)
+    form.vip = Number(vip || 0)
+    form.vip_validity_type = vip_validity_type || 'none'
+    form.vip_valid_days = Number(vip_valid_days || 0)
+    form.vip_fixed_expire_time = Number(vip_fixed_expire_time || 0)
+    vipFixedDate.value = form.vip_fixed_expire_time ? new Date(form.vip_fixed_expire_time) : null
+    form.allow_refund = Number(allow_refund ?? 1)
     const parsed = parseFeatures(features)
     const parsed = parseFeatures(features)
     featureItems.value = parsed.length ? [...parsed] : ['']
     featureItems.value = parsed.length ? [...parsed] : ['']
   } catch (error) {
   } catch (error) {

+ 8 - 0
src/pages/admin/goods/goodsList.vue

@@ -72,6 +72,12 @@
             {{ record.state === 1 ? '上架' : '下架' }}
             {{ record.state === 1 ? '上架' : '下架' }}
           </a-tag>
           </a-tag>
         </template>
         </template>
+        <template #vip="{ record }">
+          <a-tag :color="record.vip === 1 ? 'gold' : 'gray'">{{ record.vip === 1 ? 'VIP' : '无' }}</a-tag>
+        </template>
+        <template #allow_refund="{ record }">
+          <a-tag :color="record.allow_refund === 0 ? 'red' : 'green'">{{ record.allow_refund === 0 ? '不可退' : '可退' }}</a-tag>
+        </template>
         <template #optional="{ record }">
         <template #optional="{ record }">
           <a-button type="text" size="small" @click="$router.push(`/goodsManage/goods/addGoods/${record.id}`)">
           <a-button type="text" size="small" @click="$router.push(`/goodsManage/goods/addGoods/${record.id}`)">
             编辑
             编辑
@@ -102,6 +108,8 @@ const columnDefs = [
   { title: '价格', slotName: 'price', width: 100 },
   { title: '价格', slotName: 'price', width: 100 },
   { title: '库存', dataIndex: 'num', width: 80 },
   { title: '库存', dataIndex: 'num', width: 80 },
   { title: '乐跑次数', dataIndex: 'lepao_count', width: 100 },
   { title: '乐跑次数', dataIndex: 'lepao_count', width: 100 },
+  { title: 'VIP', slotName: 'vip', width: 90 },
+  { title: '退款', slotName: 'allow_refund', width: 90 },
   { title: '浏览量', dataIndex: 'views', width: 90 },
   { title: '浏览量', dataIndex: 'views', width: 90 },
   { title: '状态', slotName: 'state', width: 90 },
   { title: '状态', slotName: 'state', width: 90 },
   { title: '创建时间', slotName: 'create_time', width: 170 },
   { title: '创建时间', slotName: 'create_time', width: 170 },

+ 7 - 1
src/pages/admin/lepaoAccount/accountList.vue

@@ -135,6 +135,7 @@
                     <span v-if="record.notice_type === 'email'"><icon-email /> {{ record.email ?? '未绑定' }}</span>
                     <span v-if="record.notice_type === 'email'"><icon-email /> {{ record.email ?? '未绑定' }}</span>
                     <span v-else-if="record.notice_type === 'bot'"><icon-robot /> {{ record.bot_account ?? '未绑定'
                     <span v-else-if="record.notice_type === 'bot'"><icon-robot /> {{ record.bot_account ?? '未绑定'
                     }}</span>
                     }}</span>
+                    <span v-else-if="record.notice_type === 'wechat'"><icon-wechat /> 微信通知</span>
                     <span v-else><icon-notification-close /> 无通知</span>
                     <span v-else><icon-notification-close /> 无通知</span>
                 </template>
                 </template>
                 <template #update_type="{ record }">
                 <template #update_type="{ record }">
@@ -215,10 +216,14 @@
             <a-form-item field="notice_type" label="通知方式">
             <a-form-item field="notice_type" label="通知方式">
                 <a-radio-group v-model="form.notice_type">
                 <a-radio-group v-model="form.notice_type">
                     <a-radio value="email"><icon-email /> 邮件</a-radio>
                     <a-radio value="email"><icon-email /> 邮件</a-radio>
+                    <a-radio value="wechat"><icon-wechat /> 微信</a-radio>
                     <a-radio value="bot"><icon-robot /> 智能机器人</a-radio>
                     <a-radio value="bot"><icon-robot /> 智能机器人</a-radio>
                     <a-radio value="none"><icon-notification-close /> 无通知</a-radio>
                     <a-radio value="none"><icon-notification-close /> 无通知</a-radio>
                 </a-radio-group>
                 </a-radio-group>
             </a-form-item>
             </a-form-item>
+            <a-alert v-if="form.notice_type === 'wechat'" type="info" style="margin-bottom: 12px;">
+                微信通知将发送到用户已绑定的小妍助理微信会话。
+            </a-alert>
             <a-form-item field="email" label="通知邮箱" v-if="form.notice_type === 'email'">
             <a-form-item field="email" label="通知邮箱" v-if="form.notice_type === 'email'">
                 <EmailAutoComplete v-model="form.email" placeholder="用于接收乐跑失败、登录失效的通知" allow-clear />
                 <EmailAutoComplete v-model="form.email" placeholder="用于接收乐跑失败、登录失效的通知" allow-clear />
             </a-form-item>
             </a-form-item>
@@ -581,6 +586,7 @@ const editAccount = (item) => {
         form.auto_time = 7
         form.auto_time = 7
         form.target_count = 30
         form.target_count = 30
         form.auto_day = [0, 1, 2, 3, 4, 5, 6]
         form.auto_day = [0, 1, 2, 3, 4, 5, 6]
+        form.notice_type = 'email'
         form.email = ''
         form.email = ''
         form.notes = ''
         form.notes = ''
     }
     }
@@ -930,4 +936,4 @@ const autoTimeLabel = (record) => {
         flex: 1;
         flex: 1;
     }
     }
 }
 }
-</style>
+</style>

+ 107 - 30
src/pages/admin/systemConfig/index.vue

@@ -226,7 +226,7 @@
           </a-grid>
           </a-grid>
         </div>
         </div>
 
 
-        <div v-if="form.key === 'proxyForwardServer'" class="form-section">
+	        <div v-if="form.key === 'proxyForwardServer'" class="form-section">
           <div class="section-title">代理转发服务</div>
           <div class="section-title">代理转发服务</div>
           <a-grid :cols="{ xs: 1, sm: 2 }" :col-gap="16">
           <a-grid :cols="{ xs: 1, sm: 2 }" :col-gap="16">
             <a-grid-item>
             <a-grid-item>
@@ -244,8 +244,43 @@
                 <a-input v-model="form.proxy.url" placeholder="http://127.0.0.1:9000" />
                 <a-input v-model="form.proxy.url" placeholder="http://127.0.0.1:9000" />
               </a-form-item>
               </a-form-item>
             </a-grid-item>
             </a-grid-item>
-          </a-grid>
-        </div>
+	          </a-grid>
+	        </div>
+	        <div v-if="form.key === 'aiChatQuota'" class="form-section">
+	          <div class="section-title">AI Chat Quota</div>
+	          <a-grid :cols="{ xs: 1, sm: 2 }" :col-gap="16">
+	            <a-grid-item>
+	              <a-form-item label="Enabled">
+	                <a-switch v-model="form.aiChatQuota.enabled" />
+	              </a-form-item>
+	            </a-grid-item>
+	            <a-grid-item>
+	              <a-form-item label="Limit Message">
+	                <a-input v-model="form.aiChatQuota.limit_message" />
+	              </a-form-item>
+	            </a-grid-item>
+	            <a-grid-item>
+	              <a-form-item label="Non-VIP Web Daily">
+	                <a-input-number v-model="form.aiChatQuota.non_vip.web_daily" :min="0" mode="button" />
+	              </a-form-item>
+	            </a-grid-item>
+	            <a-grid-item>
+	              <a-form-item label="Non-VIP WeChat Daily">
+	                <a-input-number v-model="form.aiChatQuota.non_vip.wechat_daily" :min="0" mode="button" />
+	              </a-form-item>
+	            </a-grid-item>
+	            <a-grid-item>
+	              <a-form-item label="VIP Web Daily">
+	                <a-input-number v-model="form.aiChatQuota.vip.web_daily" :min="0" mode="button" />
+	              </a-form-item>
+	            </a-grid-item>
+	            <a-grid-item>
+	              <a-form-item label="VIP WeChat Daily">
+	                <a-input-number v-model="form.aiChatQuota.vip.wechat_daily" :min="0" mode="button" />
+	              </a-form-item>
+	            </a-grid-item>
+	          </a-grid>
+	        </div>
       </a-form>
       </a-form>
     </a-modal>
     </a-modal>
   </div>
   </div>
@@ -260,7 +295,8 @@ const configTypes = [
   { key: 'pay', label: '支付配置' },
   { key: 'pay', label: '支付配置' },
   { key: 'email', label: '邮件配置' },
   { key: 'email', label: '邮件配置' },
   { key: 'unilogin', label: '聚合登录' },
   { key: 'unilogin', label: '聚合登录' },
-  { key: 'proxyForwardServer', label: '代理配置' }
+  { key: 'proxyForwardServer', label: '代理配置' },
+  { key: 'aiChatQuota', label: 'AI限额' }
 ]
 ]
 
 
 const activeKey = ref('pay')
 const activeKey = ref('pay')
@@ -278,7 +314,8 @@ const form = reactive({
   pay: emptyPayConfig(),
   pay: emptyPayConfig(),
   emails: [],
   emails: [],
   unilogin: emptyUniLoginConfig(),
   unilogin: emptyUniLoginConfig(),
-  proxy: emptyProxyConfig()
+  proxy: emptyProxyConfig(),
+  aiChatQuota: emptyAiChatQuotaConfig()
 })
 })
 
 
 const currentList = computed(() => allConfigs.value.filter(item => item.config_key === activeKey.value))
 const currentList = computed(() => allConfigs.value.filter(item => item.config_key === activeKey.value))
@@ -326,11 +363,21 @@ function emptyProxyConfig() {
   }
   }
 }
 }
 
 
+function emptyAiChatQuotaConfig() {
+  return {
+    enabled: true,
+    non_vip: { web_daily: 10, wechat_daily: 0 },
+    vip: { web_daily: 100, wechat_daily: 100 },
+    limit_message: '今日与小妍助理聊天次数已达上限,请明天再试'
+  }
+}
+
 function resetConfigValue() {
 function resetConfigValue() {
   form.pay = emptyPayConfig()
   form.pay = emptyPayConfig()
   form.emails = [emptyEmailConfig()]
   form.emails = [emptyEmailConfig()]
   form.unilogin = emptyUniLoginConfig()
   form.unilogin = emptyUniLoginConfig()
   form.proxy = emptyProxyConfig()
   form.proxy = emptyProxyConfig()
+  form.aiChatQuota = emptyAiChatQuotaConfig()
   originalValue.value = {}
   originalValue.value = {}
 }
 }
 
 
@@ -390,10 +437,18 @@ function fillFormValue(key, value) {
       : [emptyEmailConfig()]
       : [emptyEmailConfig()]
   } else if (key === 'unilogin') {
   } else if (key === 'unilogin') {
     form.unilogin = { ...emptyUniLoginConfig(), ...data }
     form.unilogin = { ...emptyUniLoginConfig(), ...data }
-  } else if (key === 'proxyForwardServer') {
-    form.proxy = { ...emptyProxyConfig(), ...data }
-  }
-}
+	  } else if (key === 'proxyForwardServer') {
+	    form.proxy = { ...emptyProxyConfig(), ...data }
+	  } else if (key === 'aiChatQuota') {
+	    const empty = emptyAiChatQuotaConfig()
+	    form.aiChatQuota = {
+	      ...empty,
+	      ...data,
+	      non_vip: { ...empty.non_vip, ...(data.non_vip || {}) },
+	      vip: { ...empty.vip, ...(data.vip || {}) }
+	    }
+	  }
+	}
 
 
 function cloneValue(value) {
 function cloneValue(value) {
   if (value == null) return {}
   if (value == null) return {}
@@ -448,17 +503,32 @@ function buildValue() {
       .sort((a, b) => a.priority - b.priority)
       .sort((a, b) => a.priority - b.priority)
   }
   }
 
 
-  if (form.key === 'unilogin') {
-    return {
-      ...(editing.value && typeof originalValue.value === 'object' ? originalValue.value : {}),
-      url: String(form.unilogin.url || '').trim(),
-      appid: String(form.unilogin.appid || '').trim(),
-      appkey: String(form.unilogin.appkey || '').trim(),
-      enabledTypes: form.unilogin.enabledTypes || []
-    }
-  }
-
-  return {
+	  if (form.key === 'unilogin') {
+	    return {
+	      ...(editing.value && typeof originalValue.value === 'object' ? originalValue.value : {}),
+	      url: String(form.unilogin.url || '').trim(),
+	      appid: String(form.unilogin.appid || '').trim(),
+	      appkey: String(form.unilogin.appkey || '').trim(),
+	      enabledTypes: form.unilogin.enabledTypes || []
+	    }
+	  }
+
+	  if (form.key === 'aiChatQuota') {
+	    return {
+	      enabled: form.aiChatQuota.enabled === true,
+	      non_vip: {
+	        web_daily: Number(form.aiChatQuota.non_vip.web_daily || 0),
+	        wechat_daily: Number(form.aiChatQuota.non_vip.wechat_daily || 0)
+	      },
+	      vip: {
+	        web_daily: Number(form.aiChatQuota.vip.web_daily || 0),
+	        wechat_daily: Number(form.aiChatQuota.vip.wechat_daily || 0)
+	      },
+	      limit_message: String(form.aiChatQuota.limit_message || '').trim() || '今日与小妍助理聊天次数已达上限,请明天再试'
+	    }
+	  }
+	
+	  return {
     ...(editing.value && typeof originalValue.value === 'object' ? originalValue.value : {}),
     ...(editing.value && typeof originalValue.value === 'object' ? originalValue.value : {}),
     enabled: !!form.proxy.enabled,
     enabled: !!form.proxy.enabled,
     url: String(form.proxy.url || '').trim(),
     url: String(form.proxy.url || '').trim(),
@@ -472,11 +542,15 @@ function validateValue(value) {
     if (!value.url || !value.pid || !value.key || !value.return_url) return '请完整填写支付平台配置'
     if (!value.url || !value.pid || !value.key || !value.return_url) return '请完整填写支付平台配置'
     if (!value.methods.length) return '请至少启用一种付款方式'
     if (!value.methods.length) return '请至少启用一种付款方式'
   }
   }
-  if (form.key === 'email' && !value.length) return '请至少配置一个可用邮箱账号'
-  if (form.key === 'unilogin' && (!value.url || !value.appid || !value.appkey)) return '请完整填写聚合登录配置'
-  if (form.key === 'proxyForwardServer' && value.enabled && !value.url) return '启用代理时必须填写代理服务地址'
-  return ''
-}
+	  if (form.key === 'email' && !value.length) return '请至少配置一个可用邮箱账号'
+	  if (form.key === 'unilogin' && (!value.url || !value.appid || !value.appkey)) return '请完整填写聚合登录配置'
+	  if (form.key === 'proxyForwardServer' && value.enabled && !value.url) return '启用代理时必须填写代理服务地址'
+	  if (form.key === 'aiChatQuota') {
+	    const values = [value.non_vip.web_daily, value.non_vip.wechat_daily, value.vip.web_daily, value.vip.wechat_daily]
+	    if (values.some(v => !Number.isFinite(Number(v)) || Number(v) < 0)) return '聊天限额必须为大于等于 0 的数字'
+	  }
+	  return ''
+	}
 
 
 async function save() {
 async function save() {
   const value = buildValue()
   const value = buildValue()
@@ -524,11 +598,14 @@ function formatSummary(record) {
   if (record.config_key === 'unilogin') {
   if (record.config_key === 'unilogin') {
     return `${value?.url || '未填写接口'},${(value?.enabledTypes || ['qq', 'wx']).join(' / ')}`
     return `${value?.url || '未填写接口'},${(value?.enabledTypes || ['qq', 'wx']).join(' / ')}`
   }
   }
-  if (record.config_key === 'proxyForwardServer') {
-    return `${value?.enabled === false ? '停用' : '启用'},${value?.url || '未填写地址'}`
-  }
-  return '-'
-}
+	  if (record.config_key === 'proxyForwardServer') {
+	    return `${value?.enabled === false ? '停用' : '启用'},${value?.url || '未填写地址'}`
+	  }
+	  if (record.config_key === 'aiChatQuota') {
+	    return `${value?.enabled === false ? '停用' : '启用'},nonVIP ${value?.non_vip?.web_daily || 0}/${value?.non_vip?.wechat_daily || 0},VIP ${value?.vip?.web_daily || 0}/${value?.vip?.wechat_daily || 0}`
+	  }
+	  return '-'
+	}
 
 
 onMounted(load)
 onMounted(load)
 </script>
 </script>

+ 368 - 0
src/pages/ai/assistant/WeixinBindingModal.vue

@@ -0,0 +1,368 @@
+<template>
+  <a-modal
+    v-model:visible="visible"
+    :footer="false"
+    :width="modalWidth"
+    modal-class="wechat-bind-modal"
+    title-align="start"
+    @close="handleClose"
+  >
+    <template #title>
+      <div class="modal-title">
+        <span class="modal-title__icon"><icon-wechat /></span>
+        <span>微信绑定</span>
+      </div>
+    </template>
+
+    <div class="bind-dialog">
+      <div class="bind-dialog__main">
+        <div class="bind-dialog__status">
+          <span class="pulse" :class="`pulse--${stateType}`" />
+          <div>
+            <strong>{{ stateTitle }}</strong>
+            <p>{{ stateDescription }}</p>
+          </div>
+        </div>
+
+        <div v-if="bindingQrSource && binding?.state === 0" class="qr-stage">
+          <div class="qr-frame">
+            <canvas v-if="!bindingQrIsImage" ref="qrcodeCanvas" class="qr-canvas" aria-label="wechat qrcode"></canvas>
+            <img v-else :src="bindingQrSource" alt="微信绑定二维码" />
+          </div>
+          <div class="qr-tip">请使用微信扫码确认绑定</div>
+        </div>
+
+        <div v-else class="empty-stage">
+          <div class="empty-stage__icon"><icon-scan /></div>
+          <div>{{ emptyText }}</div>
+        </div>
+      </div>
+
+      <div class="bind-dialog__side">
+        <div class="side-card">
+          <span>状态</span>
+          <a-tag :color="bindingColor">{{ bindingLabel }}</a-tag>
+        </div>
+        <div class="side-card">
+          <span>最近微信消息</span>
+          <strong>{{ lastActiveText }}</strong>
+        </div>
+        <div class="side-card">
+          <span>到期提醒</span>
+          <strong>24 小时内需保持一次对话</strong>
+        </div>
+      </div>
+    </div>
+
+    <div class="modal-actions">
+      <a-button type="primary" size="large" :loading="bindingLoading" @click="createBinding">
+        <template #icon><icon-scan /></template>
+        {{ binding?.state === 1 ? '重新绑定微信' : '生成绑定二维码' }}
+      </a-button>
+      <a-button v-if="binding" size="large" status="danger" :loading="bindingLoading" @click="unbind">
+        <template #icon><icon-delete /></template>
+        解除绑定
+      </a-button>
+      <!-- <a-button size="large" :loading="loading" @click="refreshBinding">
+        <template #icon><icon-refresh /></template>
+        刷新状态
+      </a-button> -->
+    </div>
+  </a-modal>
+</template>
+
+<script setup>
+import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
+import { Message } from '@arco-design/web-vue'
+import QRCode from 'qrcode'
+import { createWeixinBinding, deleteWeixinBinding, getWeixinBinding } from '@/api/aiChat'
+
+const emit = defineEmits(['updated'])
+
+const visible = ref(false)
+const loading = ref(false)
+const bindingLoading = ref(false)
+const binding = ref(null)
+const qrcodeCanvas = ref(null)
+let timer = null
+
+const modalWidth = computed(() => (window.innerWidth < 720 ? '94vw' : '720px'))
+const bindingQrSource = computed(() => binding.value?.qrcode_url || binding.value?.qrcode || '')
+const bindingQrIsImage = computed(() => /^data:image\//i.test(bindingQrSource.value))
+const bindingLabel = computed(() => ({ 0: '待扫码', 1: '已绑定', 2: '已解绑', 3: '已过期' }[binding.value?.state] || '未绑定'))
+const bindingColor = computed(() => ({ 0: 'orange', 1: 'green', 2: 'gray', 3: 'red' }[binding.value?.state] || 'gray'))
+const stateType = computed(() => ({ 0: 'pending', 1: 'active', 2: 'muted', 3: 'danger' }[binding.value?.state] || 'muted'))
+const stateTitle = computed(() => {
+  if (binding.value?.state === 1) return '微信已连接'
+  if (binding.value?.state === 0) return '等待扫码确认'
+  if (binding.value?.state === 3) return '登录已过期'
+  return '尚未绑定微信'
+})
+const stateDescription = computed(() => {
+  if (binding.value?.state === 1) return '你可以在微信中随时随地与小妍助理对话'
+  if (binding.value?.state === 0) return '扫码后请在微信中确认授权,绑定完成后二维码会自动收起'
+  if (binding.value?.state === 3) return '超过 24 小时未向小妍助理发送消息,需要重新扫码登录'
+  return '绑定后可在微信中接收乐跑通知,并与小妍助理对话'
+})
+const emptyText = computed(() => {
+  if (binding.value?.state === 1) return '当前微信已绑定,无需扫码'
+  if (binding.value?.state === 3) return '登录已过期,请重新生成二维码。'
+  return '点击下方按钮生成二维码。'
+})
+const lastActiveText = computed(() => {
+  const time = Number(binding.value?.last_user_message_time || 0)
+  if (!time) return '暂无'
+  return new Date(time).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
+})
+
+const drawQrLogo = () => {
+  const canvas = qrcodeCanvas.value
+  if (!canvas) return
+  const ctx = canvas.getContext('2d')
+  const logo = new Image()
+  logo.src = '/logo.svg'
+  logo.onload = () => {
+    const canvasSize = canvas.width
+    const logoSize = canvasSize * 0.15
+    const x = (canvasSize - logoSize) / 2
+    const y = (canvasSize - logoSize) / 2
+    ctx.drawImage(logo, x, y, logoSize, logoSize)
+  }
+}
+
+const renderQrCode = async () => {
+  await nextTick()
+  if (!qrcodeCanvas.value || bindingQrIsImage.value || !bindingQrSource.value || binding.value?.state !== 0) return
+  await QRCode.toCanvas(qrcodeCanvas.value, bindingQrSource.value, {
+    width: 220,
+    margin: 1,
+    color: { dark: '#111827', light: '#ffffff' }
+  })
+  drawQrLogo()
+}
+
+const syncBinding = (data) => {
+  binding.value = data || null
+  emit('updated', binding.value)
+}
+
+const refreshBinding = async () => {
+  loading.value = true
+  try {
+    const res = await getWeixinBinding()
+    if (res?.code === 0) {
+      syncBinding(res.data)
+      await renderQrCode()
+    }
+  } finally {
+    loading.value = false
+  }
+}
+
+const createBinding = async () => {
+  bindingLoading.value = true
+  try {
+    const res = await createWeixinBinding()
+    if (!res || res.code !== 0) return Message.warning(res?.msg || '创建绑定失败')
+    syncBinding(res.data)
+    await renderQrCode()
+    Message.success('请扫码确认绑定')
+  } finally {
+    bindingLoading.value = false
+  }
+}
+
+const unbind = async () => {
+  bindingLoading.value = true
+  try {
+    const res = await deleteWeixinBinding()
+    if (!res || res.code !== 0) return Message.warning(res?.msg || '解绑失败')
+    await refreshBinding()
+    Message.success('已解除绑定')
+  } finally {
+    bindingLoading.value = false
+  }
+}
+
+const open = async (initialBinding = null) => {
+  visible.value = true
+  if (initialBinding) syncBinding(initialBinding)
+  await refreshBinding()
+  timer = setInterval(refreshBinding, 5000)
+}
+
+const handleClose = () => {
+  if (timer) clearInterval(timer)
+  timer = null
+}
+
+onUnmounted(handleClose)
+watch(bindingQrSource, renderQrCode)
+
+defineExpose({ open })
+</script>
+
+<style scoped lang="less">
+.modal-title {
+  display: inline-flex;
+  align-items: center;
+  gap: 10px;
+  font-weight: 800;
+}
+
+.modal-title__icon {
+  display: grid;
+  place-items: center;
+  width: 34px;
+  height: 34px;
+  border-radius: 8px;
+  background: #ecfdf3;
+  color: #039855;
+}
+
+.bind-dialog {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) 220px;
+  gap: 16px;
+}
+
+.bind-dialog__main,
+.bind-dialog__side {
+  min-width: 0;
+}
+
+.bind-dialog__status {
+  display: flex;
+  gap: 12px;
+  align-items: flex-start;
+  padding: 16px;
+  border-radius: 8px;
+  background: #f8fafc;
+}
+
+.bind-dialog__status strong {
+  display: block;
+  color: #101828;
+  font-size: 17px;
+}
+
+.bind-dialog__status p {
+  margin: 5px 0 0;
+  color: #667085;
+  line-height: 1.6;
+}
+
+.pulse {
+  width: 12px;
+  height: 12px;
+  margin-top: 5px;
+  border-radius: 50%;
+  background: #98a2b3;
+  box-shadow: 0 0 0 6px rgba(152, 162, 179, 0.14);
+}
+
+.pulse--active {
+  background: #12b76a;
+  box-shadow: 0 0 0 6px rgba(18, 183, 106, 0.14);
+}
+
+.pulse--pending {
+  background: #f79009;
+  box-shadow: 0 0 0 6px rgba(247, 144, 9, 0.14);
+}
+
+.pulse--danger {
+  background: #f04438;
+  box-shadow: 0 0 0 6px rgba(240, 68, 56, 0.14);
+}
+
+.qr-stage,
+.empty-stage {
+  display: grid;
+  place-items: center;
+  min-height: 310px;
+  margin-top: 14px;
+  border: 1px solid #eaecf0;
+  border-radius: 8px;
+  background: linear-gradient(180deg, #ffffff, #f8fafc);
+}
+
+.qr-frame {
+  display: grid;
+  place-items: center;
+  width: 244px;
+  height: 244px;
+  border-radius: 8px;
+  background: #fff;
+  box-shadow: 0 18px 36px rgba(15, 23, 42, 0.12);
+}
+
+.qr-frame img,
+.qr-canvas {
+  width: 220px;
+  height: 220px;
+}
+
+.qr-tip,
+.empty-stage {
+  color: #667085;
+}
+
+.empty-stage__icon {
+  display: grid;
+  place-items: center;
+  width: 58px;
+  height: 58px;
+  margin-bottom: 10px;
+  border-radius: 8px;
+  background: #eef4ff;
+  color: #2970ff;
+  font-size: 24px;
+}
+
+.bind-dialog__side {
+  display: grid;
+  gap: 10px;
+  align-content: start;
+}
+
+.side-card {
+  display: grid;
+  gap: 8px;
+  padding: 14px;
+  border: 1px solid #eaecf0;
+  border-radius: 8px;
+  background: #fff;
+}
+
+.side-card span {
+  color: #667085;
+  font-size: 12px;
+}
+
+.side-card strong {
+  color: #101828;
+}
+
+.modal-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+  margin-top: 18px;
+}
+
+@media (max-width: 720px) {
+  .bind-dialog {
+    grid-template-columns: 1fr;
+  }
+
+  .qr-stage,
+  .empty-stage {
+    min-height: 280px;
+  }
+
+  .modal-actions .arco-btn {
+    flex: 1 1 100%;
+  }
+}
+</style>

+ 919 - 0
src/pages/ai/assistant/index.vue

@@ -0,0 +1,919 @@
+<template>
+  <div class="assistant-page" :class="pageThemeClass">
+    <section class="assistant-hero">
+      <div class="hero-main">
+        <div class="hero-label">
+          <span class="signal-dot"></span>
+          RunForge AI Console
+        </div>
+        <h1>小妍助理</h1>
+        <p>
+          集中查看账号状态、次数余额,快速处理日常事务
+        </p>
+        <div class="hero-actions">
+          <a-button type="primary" @click="openChat">
+            <template #icon><icon-message /></template>
+            开始对话
+          </a-button>
+        </div>
+      </div>
+
+      <div class="membership-card">
+        <div class="membership-card__top">
+          <span class="membership-chip">{{ isVip ? 'VIP ONLINE' : 'STANDARD' }}</span>
+          <a-tag :color="isVip ? 'gold' : 'arcoblue'">{{ isVip ? '权益生效' : '普通用户' }}</a-tag>
+        </div>
+        <strong>{{ vipTitle }}</strong>
+        <p>{{ vipDescription }}</p>
+        <div class="membership-card__actions">
+          <a-button v-if="isVip" type="primary" @click="openBindingModal">
+            <template #icon><icon-scan /></template>
+            {{ weixin?.bound ? '管理微信' : '绑定微信' }}
+          </a-button>
+          <a-button v-else type="primary" @click="$router.push('/store/goodsList')">
+            <template #icon><icon-thunderbolt /></template>
+            开通 VIP
+          </a-button>
+        </div>
+      </div>
+    </section>
+
+    <section class="metric-grid">
+      <article v-for="item in metrics" :key="item.label" class="metric-card">
+        <div class="metric-card__icon" :class="`metric-card__icon--${item.tone}`">
+          <component :is="item.icon" />
+        </div>
+        <div class="metric-card__body">
+          <span>{{ item.label }}</span>
+          <strong>{{ item.value }}</strong>
+          <p>{{ item.hint }}</p>
+        </div>
+      </article>
+    </section>
+
+    <section class="content-grid">
+      <article class="panel panel--wide">
+        <div class="panel-head">
+          <div>
+            <h2>乐跑账号状态</h2>
+            <p>{{ semesterLabel }} · 登录状态概览</p>
+          </div>
+          <a-button size="small" @click="$router.push('/lepao/accountList')">
+            <template #icon><icon-arrow-right /></template>
+            账号列表
+          </a-button>
+        </div>
+        <div class="chart-shell">
+          <Chart :options="accountChartOptions" height="220px" />
+        </div>
+      </article>
+
+      <article class="panel quota-panel">
+        <div class="panel-head">
+          <div>
+            <h2>聊天额度余量</h2>
+            <p>今日可用额度</p>
+          </div>
+        </div>
+        <div class="quota-list">
+          <div v-for="item in quotaItems" :key="item.label" class="quota-item">
+            <div class="quota-item__top">
+              <span>{{ item.label }}</span>
+              <strong>{{ item.text }}</strong>
+            </div>
+            <a-progress :percent="item.progress" :show-text="false" :color="item.color" />
+            <p>{{ item.hint }}</p>
+          </div>
+        </div>
+      </article>
+
+      <article class="panel weixin-panel">
+        <div class="panel-head">
+          <div>
+            <h2>微信接入</h2>
+            <span class="vip-mini-badge">VIP</span>
+            <p>{{ weixinStatusText }}</p>
+          </div>
+          <a-tag :color="weixinTagColor">{{ weixinTagText }}</a-tag>
+        </div>
+        <div class="weixin-status-card">
+          <div class="weixin-status-card__icon"><icon-wechat /></div>
+          <div>
+            <strong>{{ weixinTitle }}</strong>
+            <span>{{ weixinDescription }}</span>
+          </div>
+        </div>
+        <a-button class="weixin-action" type="primary" :disabled="!isVip" @click="openBindingModal">
+          <template #icon><icon-scan /></template>
+          {{ weixin?.bound ? '查看绑定' : '绑定微信' }}
+        </a-button>
+      </article>
+
+      <article class="panel panel--wide">
+        <div class="panel-head">
+          <div>
+            <h2>次数变动趋势</h2>
+            <p>近 14 天乐跑次数变化</p>
+          </div>
+        </div>
+        <div class="chart-shell">
+          <Chart :options="ledgerChartOptions" height="220px" />
+        </div>
+      </article>
+
+      <article class="panel panel--full ability-panel">
+        <div class="panel-head">
+          <div>
+            <h2>快捷事项</h2>
+            <p>选择常用场景,快速进入咨询</p>
+          </div>
+        </div>
+        <div class="ability-grid">
+          <button
+            v-for="item in abilities"
+            :key="item.title"
+            type="button"
+            class="ability-button"
+            @click="openAbility(item)"
+          >
+            <span class="ability-button__icon"><component :is="item.icon" /></span>
+            <span>
+              <strong>{{ item.title }}</strong>
+              <small>{{ item.desc }}</small>
+            </span>
+            <icon-arrow-right />
+          </button>
+        </div>
+      </article>
+    </section>
+
+    <WeixinBindingModal ref="bindingModalRef" @updated="handleBindingUpdated" />
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, onUnmounted, ref } from 'vue'
+import { Message } from '@arco-design/web-vue'
+import { eventBus } from '@/utils/eventBus'
+import { getAIChatAssistantDashboard } from '@/api/aiChat'
+import WeixinBindingModal from './WeixinBindingModal.vue'
+
+const loading = ref(false)
+const dashboard = ref(null)
+const bindingModalRef = ref(null)
+let timer = null
+
+const user = computed(() => dashboard.value?.user || {})
+const account = computed(() => dashboard.value?.account || {})
+const quota = computed(() => dashboard.value?.quota || {})
+const weixin = computed(() => dashboard.value?.weixin || null)
+const ledgerTrend = computed(() => dashboard.value?.ledger_trend || [])
+const isVip = computed(() => Boolean(user.value.vip || quota.value.vip))
+const pageThemeClass = computed(() => (isVip.value ? 'assistant-page--vip' : 'assistant-page--standard'))
+
+const theme = computed(() => isVip.value
+  ? {
+      primary: '#c58a2a',
+      secondary: '#0f9f7a',
+      chartText: '#667085',
+      splitLine: '#eef2f6',
+      soft: '#fff7e8',
+      line: 'rgba(197, 138, 42, 0.22)',
+      autoColor: '#c58a2a'
+    }
+  : {
+      primary: '#2563eb',
+      secondary: '#0ea5a8',
+      chartText: '#667085',
+      splitLine: '#eef2f6',
+      soft: '#eef6ff',
+      line: 'rgba(37, 99, 235, 0.22)',
+      autoColor: '#0ea5a8'
+    })
+
+const formatDate = (time) => {
+  if (!time) return ''
+  return new Date(Number(time)).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
+}
+
+const semesterLabel = computed(() => {
+  const semester = dashboard.value?.semester || {}
+  if (!semester.start || !semester.end) return '当前学期'
+  return `${formatDate(semester.start)} - ${formatDate(semester.end)}`
+})
+
+const vipTitle = computed(() => {
+  if (!isVip.value) return '基础服务'
+  const expire = Number(user.value.vip_expire_time || quota.value.vip_expire_time || 0)
+  return expire > 0 ? `VIP 至 ${formatDate(expire)}` : '永久 VIP'
+})
+
+const vipDescription = computed(() => {
+  if (isVip.value) return '已启用微信助手、微信通知与专属对话额度'
+  return '支持网页对话 - 开通 VIP 后可启用微信助手与专属额度'
+})
+
+const metrics = computed(() => [
+  {
+    label: '剩余乐跑次数',
+    value: Number(user.value.lepao_count || 0),
+    hint: '可用于乐跑任务',
+    icon: 'icon-thunderbolt',
+    tone: 'green'
+  },
+  {
+    label: '总账号数',
+    value: Number(account.value.total || 0),
+    hint: '本学期更新',
+    icon: 'icon-user-group',
+    tone: 'blue'
+  },
+  {
+    label: '需登录账号',
+    value: Number(account.value.need_login || 0),
+    hint: '建议尽快处理',
+    icon: 'icon-exclamation-circle',
+    tone: 'orange'
+  },
+  {
+    label: '账号正常率',
+    value: `${Number(account.value.normal_percent || 0)}%`,
+    hint: `${Number(account.value.normal || 0)} 个正常`,
+    icon: 'icon-check-circle',
+    tone: 'purple'
+  }
+])
+
+const toProgress = (percent) => Math.max(0, Math.min(1, Number(percent || 0) / 100))
+const quotaItems = computed(() => {
+  const build = (label, item, color) => {
+    const configured = Boolean(item?.configured)
+    const percent = Number(item?.percent || 0)
+    return {
+      label,
+      text: configured ? `${percent}%` : '未配置',
+      progress: toProgress(percent),
+      color
+    }
+  }
+  return [
+    build('网页对话', quota.value.web, theme.value.primary),
+    build('微信对话', quota.value.wechat, theme.value.secondary)
+  ]
+})
+
+const weixinTagText = computed(() => ({ 0: '待扫码', 1: '已绑定', 2: '已解绑', 3: '已过期' }[weixin.value?.state] || '未绑定'))
+const weixinTagColor = computed(() => ({ 0: 'orange', 1: 'green', 2: 'gray', 3: 'red' }[weixin.value?.state] || 'gray'))
+const weixinStatusText = computed(() => {
+  if (!isVip.value) return '绑定微信仅限 VIP 用户'
+  if (weixin.value?.bound) return '微信助手已连接'
+  if (weixin.value?.state === 0) return '等待扫码确认'
+  if (weixin.value?.state === 3) return '登录已过期,请重新扫码'
+  return '扫码后即可在微信中使用'
+})
+const weixinTitle = computed(() => {
+  if (!isVip.value) return 'VIP 专属接入'
+  if (weixin.value?.bound) return '微信通道运行中'
+  return '绑定微信账号'
+})
+const weixinDescription = computed(() => {
+  if (!isVip.value) return '开通 VIP 后可使用微信对话与乐跑通知。'
+  if (weixin.value?.bound) return '可在微信中随时随地处理事务'
+  return '在弹窗中扫码完成授权。'
+})
+
+const accountChartOptions = computed(() => ({
+  color: ['#10b981', '#f59e0b'],
+  tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
+  grid: { left: 18, right: 12, top: 26, bottom: 18, containLabel: true },
+  xAxis: {
+    type: 'category',
+    data: ['正常', '需登录'],
+    axisTick: { show: false },
+    axisLine: { lineStyle: { color: theme.value.splitLine } },
+    axisLabel: { color: theme.value.chartText }
+  },
+  yAxis: {
+    type: 'value',
+    minInterval: 1,
+    splitLine: { lineStyle: { color: theme.value.splitLine } },
+    axisLabel: { color: theme.value.chartText }
+  },
+  series: [{
+    type: 'bar',
+    barWidth: 42,
+    data: [
+      { value: Number(account.value.normal || 0), itemStyle: { color: '#10b981' } },
+      { value: Number(account.value.need_login || 0), itemStyle: { color: '#f59e0b' } }
+    ],
+    itemStyle: { borderRadius: [8, 8, 0, 0] }
+  }]
+}))
+
+const ledgerChartOptions = computed(() => ({
+  color: [theme.value.primary, theme.value.secondary],
+  tooltip: { trigger: 'axis' },
+  legend: { top: 0, right: 0, itemWidth: 10, itemHeight: 10, textStyle: { color: theme.value.chartText } },
+  grid: { left: 18, right: 12, top: 34, bottom: 18, containLabel: true },
+  xAxis: {
+    type: 'category',
+    boundaryGap: false,
+    data: ledgerTrend.value.map(item => item.date),
+    axisTick: { show: false },
+    axisLine: { lineStyle: { color: theme.value.splitLine } },
+    axisLabel: { color: theme.value.chartText }
+  },
+  yAxis: {
+    type: 'value',
+    minInterval: 1,
+    splitLine: { lineStyle: { color: theme.value.splitLine } },
+    axisLabel: { color: theme.value.chartText }
+  },
+  series: [
+    {
+      name: '增加',
+      type: 'line',
+      smooth: true,
+      symbol: 'circle',
+      symbolSize: 6,
+      data: ledgerTrend.value.map(item => Number(item.income || 0)),
+      areaStyle: { opacity: 0.08 }
+    },
+    {
+      name: '消耗',
+      type: 'line',
+      smooth: true,
+      symbol: 'circle',
+      symbolSize: 6,
+      data: ledgerTrend.value.map(item => Number(item.expense || 0)),
+      areaStyle: { opacity: 0.06 }
+    }
+  ]
+}))
+
+const abilities = [
+  {
+    title: '查询乐跑账号',
+    desc: '账号状态、跑区、自动乐跑',
+    icon: 'icon-user',
+    prompt: '帮我查询当前绑定的乐跑账号,重点看哪些账号需要重新登录。'
+  },
+  {
+    title: '修改乐跑配置',
+    desc: '修改跑区、自动乐跑等信息',
+    icon: 'icon-edit',
+    prompt: '我想修改乐跑账号的配置信息。'
+  },
+  {
+    title: '查询次数明细',
+    desc: '次数余额与变动记录',
+    icon: 'icon-bar-chart',
+    prompt: '帮我查询最近的乐跑次数变动明细。'
+  },
+  {
+    title: '查看订单状态',
+    desc: '订单进度与售后状态',
+    icon: 'icon-idcard',
+    prompt: '帮我查询我的订单列表和当前状态。'
+  },
+  {
+    title: '提交工单',
+    desc: '提交问题与处理诉求',
+    icon: 'icon-message',
+    prompt: '我需要提交一个工单,问题描述是:。'
+  },
+  {
+    title: '电费查询',
+    desc: '账单查询与任务设置',
+    icon: 'icon-dashboard',
+    prompt: '帮我查询电费账单,寝室为:。'
+  },
+  {
+    title: '趣选书书单',
+    desc: '查询可选书单',
+    icon: 'icon-book',
+    prompt: '帮我查询趣选书书单,账号密码为:。'
+  },
+  {
+    title: '商品与权益',
+    desc: '商品列表与会员权益',
+    icon: 'icon-thunderbolt',
+    prompt: '帮我查询当前商品列表。'
+  }
+]
+
+const openChat = () => eventBus.emit('openAI')
+const openAbility = (item) => {
+  eventBus.emit('openAI', { newConversation: true, draft: item.prompt })
+}
+const openBindingModal = () => bindingModalRef.value?.open(weixin.value)
+const handleBindingUpdated = (binding) => {
+  if (!dashboard.value) return
+  dashboard.value.weixin = binding ? {
+    state: Number(binding.state),
+    bound: Number(binding.state) === 1,
+    last_user_message_time: Number(binding.last_user_message_time || 0),
+    bind_time: Number(binding.bind_time || 0),
+    last_error: binding.last_error || ''
+  } : null
+}
+
+const refreshDashboard = async () => {
+  loading.value = true
+  try {
+    const res = await getAIChatAssistantDashboard()
+    if (!res || res.code !== 0) return Message.warning(res?.msg || '获取助手数据失败')
+    dashboard.value = res.data || {}
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(async () => {
+  await refreshDashboard()
+  timer = setInterval(refreshDashboard, 12000)
+})
+
+onUnmounted(() => {
+  if (timer) clearInterval(timer)
+})
+</script>
+
+<style scoped lang="less">
+.assistant-page {
+  --assistant-primary: #2563eb;
+  --assistant-secondary: #0ea5a8;
+  --assistant-accent: #7c3aed;
+  --assistant-soft: #eef6ff;
+  --assistant-text: #111827;
+  --assistant-muted: #667085;
+  --assistant-border: rgba(17, 24, 39, 0.08);
+  --assistant-surface: rgba(255, 255, 255, 0.88);
+  --assistant-surface-strong: rgba(255, 255, 255, 0.96);
+  --assistant-card-shadow: 0 16px 40px rgba(15, 23, 42, 0.07);
+  min-height: 100%;
+  padding: 22px;
+  color: var(--assistant-text);
+  background:
+    linear-gradient(rgba(148, 163, 184, 0.08) 1px, transparent 1px),
+    linear-gradient(90deg, rgba(148, 163, 184, 0.08) 1px, transparent 1px),
+    linear-gradient(135deg, #f7fbff 0%, #ffffff 46%, #f4fbf9 100%);
+  background-size: 28px 28px, 28px 28px, auto;
+}
+
+.assistant-page--vip {
+  --assistant-primary: #c58a2a;
+  --assistant-secondary: #0f9f7a;
+  --assistant-accent: #7857d8;
+  --assistant-soft: #fff7e8;
+  --assistant-text: #111827;
+  --assistant-muted: #667085;
+  --assistant-border: rgba(17, 24, 39, 0.08);
+  --assistant-surface: rgba(255, 255, 255, 0.9);
+  --assistant-surface-strong: rgba(255, 255, 255, 0.96);
+  --assistant-card-shadow: 0 16px 40px rgba(15, 23, 42, 0.07);
+  background:
+    linear-gradient(rgba(197, 138, 42, 0.07) 1px, transparent 1px),
+    linear-gradient(90deg, rgba(15, 159, 122, 0.07) 1px, transparent 1px),
+    linear-gradient(135deg, #fffaf1 0%, #ffffff 48%, #f2fbf7 100%);
+  background-size: 28px 28px, 28px 28px, auto;
+}
+
+.assistant-hero {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) 360px;
+  gap: 16px;
+  align-items: stretch;
+}
+
+.hero-main,
+.membership-card,
+.metric-card,
+.panel {
+  border: 1px solid var(--assistant-border);
+  border-radius: 8px;
+  background: var(--assistant-surface);
+  box-shadow: var(--assistant-card-shadow);
+  backdrop-filter: blur(14px);
+}
+
+.hero-main {
+  padding: 24px 26px;
+}
+
+.hero-label {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  color: var(--assistant-primary);
+  font-size: 12px;
+  font-weight: 800;
+}
+
+.signal-dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  background: var(--assistant-secondary);
+  box-shadow: 0 0 0 5px color-mix(in srgb, var(--assistant-secondary) 14%, transparent);
+}
+
+.hero-main h1 {
+  margin: 10px 0 8px;
+  font-size: 30px;
+  line-height: 1.15;
+  letter-spacing: 0;
+}
+
+.hero-main p {
+  max-width: 720px;
+  margin: 0;
+  color: var(--assistant-muted);
+  font-size: 14px;
+  line-height: 1.8;
+}
+
+.hero-actions,
+.membership-card__actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+  margin-top: 18px;
+}
+
+.membership-card {
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  padding: 22px;
+  background:
+    linear-gradient(135deg, color-mix(in srgb, var(--assistant-primary) 10%, white), var(--assistant-surface-strong));
+}
+
+.assistant-page--vip .membership-card {
+  border-color: rgba(197, 138, 42, 0.2);
+  background:
+    linear-gradient(135deg, rgba(255, 247, 232, 0.98), rgba(255, 255, 255, 0.96) 46%, rgba(241, 251, 247, 0.96));
+  box-shadow: 0 16px 40px rgba(197, 138, 42, 0.08);
+}
+
+.membership-card__top {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 10px;
+}
+
+.membership-chip {
+  display: inline-flex;
+  align-items: center;
+  min-height: 26px;
+  padding: 0 10px;
+  border: 1px solid color-mix(in srgb, var(--assistant-primary) 28%, transparent);
+  border-radius: 999px;
+  color: var(--assistant-primary);
+  background: rgba(255, 255, 255, 0.65);
+  font-size: 12px;
+  font-weight: 800;
+}
+
+.assistant-page--vip .membership-chip,
+.assistant-page--vip .vip-mini-badge {
+  border-color: rgba(197, 138, 42, 0.28);
+  color: #8a4b10;
+  background: linear-gradient(135deg, #fff7dc, #f6d58a);
+  box-shadow: 0 10px 24px rgba(197, 138, 42, 0.1);
+}
+
+.membership-card strong {
+  display: block;
+  margin-top: 20px;
+  font-size: 22px;
+}
+
+.membership-card p {
+  margin: 8px 0 0;
+  color: var(--assistant-muted);
+  line-height: 1.7;
+}
+
+.assistant-page--vip .membership-card strong {
+  color: #101828;
+}
+
+.assistant-page--vip .membership-card p {
+  color: #667085;
+}
+
+.metric-grid {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 12px;
+  margin-top: 12px;
+}
+
+.metric-card {
+  display: flex;
+  gap: 13px;
+  align-items: center;
+  min-width: 0;
+  padding: 15px 17px;
+}
+
+.metric-card__icon {
+  display: grid;
+  place-items: center;
+  width: 44px;
+  height: 44px;
+  flex: 0 0 auto;
+  border-radius: 8px;
+  font-size: 22px;
+}
+
+.metric-card__icon--green { color: #039855; background: #ecfdf3; }
+.metric-card__icon--blue { color: #2563eb; background: #eff6ff; }
+.metric-card__icon--orange { color: #d97706; background: #fff7ed; }
+.metric-card__icon--purple { color: #7c3aed; background: #f5f3ff; }
+
+.metric-card__body {
+  min-width: 0;
+}
+
+.metric-card span {
+  display: block;
+  color: var(--assistant-muted);
+  font-size: 12px;
+}
+
+.metric-card strong {
+  display: block;
+  margin-top: 4px;
+  font-size: 27px;
+  line-height: 1.1;
+}
+
+.metric-card p {
+  margin: 5px 0 0;
+  color: #98a2b3;
+  font-size: 12px;
+}
+
+.content-grid {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  align-items: start;
+  gap: 12px;
+  margin-top: 12px;
+}
+
+.panel {
+  min-width: 0;
+  padding: 18px;
+}
+
+.panel--wide {
+  grid-column: span 2;
+}
+
+.panel--full {
+  grid-column: 1 / -1;
+}
+
+.panel-head {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 12px;
+  margin-bottom: 15px;
+}
+
+.panel-head > div {
+  min-width: 0;
+}
+
+.panel-head h2 {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  margin: 0;
+  font-size: 17px;
+  letter-spacing: 0;
+}
+
+.vip-mini-badge {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  height: 18px;
+  min-width: 34px;
+  margin-left: 8px;
+  padding: 0 7px;
+  border: 1px solid rgba(213, 168, 78, 0.36);
+  border-radius: 999px;
+  color: #7a4a0c;
+  background: linear-gradient(135deg, #fff7dc, #f3c96b);
+  font-size: 10px;
+  font-weight: 900;
+  line-height: 1;
+  vertical-align: 2px;
+}
+
+.panel-head p {
+  margin: 5px 0 0;
+  color: var(--assistant-muted);
+  font-size: 12px;
+  line-height: 1.5;
+}
+
+.chart-shell {
+  min-height: 220px;
+}
+
+.quota-list {
+  display: grid;
+  gap: 18px;
+  padding-top: 5px;
+}
+
+.quota-item__top {
+  display: flex;
+  justify-content: space-between;
+  gap: 12px;
+  margin-bottom: 8px;
+}
+
+.quota-item__top span {
+  color: #475467;
+}
+
+.quota-item__top strong {
+  color: var(--assistant-primary);
+  font-size: 18px;
+}
+
+.quota-item p {
+  margin: 7px 0 0;
+  color: #98a2b3;
+  font-size: 12px;
+}
+
+.weixin-status-card {
+  display: flex;
+  align-items: flex-start;
+  gap: 13px;
+  padding: 15px;
+  border: 1px solid color-mix(in srgb, var(--assistant-secondary) 18%, transparent);
+  border-radius: 8px;
+  background: color-mix(in srgb, var(--assistant-secondary) 8%, white);
+}
+
+.weixin-status-card__icon {
+  display: grid;
+  place-items: center;
+  width: 44px;
+  height: 44px;
+  flex: 0 0 auto;
+  border-radius: 8px;
+  color: #fff;
+  background: linear-gradient(135deg, #18b566, #0f9f7a);
+  font-size: 24px;
+}
+
+.weixin-status-card strong {
+  display: block;
+  font-size: 15px;
+}
+
+.weixin-status-card span {
+  display: block;
+  margin-top: 5px;
+  color: var(--assistant-muted);
+  line-height: 1.6;
+}
+
+.weixin-action {
+  width: 100%;
+  margin-top: 16px;
+}
+
+.ability-grid {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 10px;
+}
+
+.ability-button {
+  display: grid;
+  grid-template-columns: 38px minmax(0, 1fr) 18px;
+  gap: 11px;
+  align-items: center;
+  width: 100%;
+  min-height: 78px;
+  padding: 13px;
+  border: 1px solid rgba(17, 24, 39, 0.08);
+  border-radius: 8px;
+  background: rgba(255, 255, 255, 0.78);
+  color: inherit;
+  text-align: left;
+  cursor: pointer;
+  transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease, background 0.18s ease;
+}
+
+.ability-button:hover {
+  border-color: color-mix(in srgb, var(--assistant-primary) 38%, transparent);
+  background: #fff;
+  box-shadow: 0 14px 28px rgba(15, 23, 42, 0.08);
+  transform: translateY(-1px);
+}
+
+.ability-button__icon {
+  display: grid;
+  place-items: center;
+  width: 38px;
+  height: 38px;
+  border-radius: 8px;
+  color: #fff;
+  background: linear-gradient(135deg, var(--assistant-primary), var(--assistant-secondary));
+  font-size: 20px;
+}
+
+.ability-button strong,
+.ability-button small {
+  display: block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.ability-button strong {
+  font-size: 14px;
+}
+
+.ability-button small {
+  margin-top: 4px;
+  color: var(--assistant-muted);
+  font-size: 12px;
+}
+
+.assistant-page :deep(.arco-btn-primary) {
+  border-color: transparent;
+  background: linear-gradient(135deg, var(--assistant-primary), var(--assistant-secondary));
+}
+
+
+@media (max-width: 1180px) {
+  .assistant-hero,
+  .content-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .panel--wide,
+  .panel--full {
+    grid-column: auto;
+  }
+
+  .metric-grid,
+  .ability-grid {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+}
+
+@media (max-width: 640px) {
+  .assistant-page {
+    padding: 12px;
+  }
+
+  .hero-main,
+  .membership-card,
+  .metric-card,
+  .panel {
+    padding: 15px;
+  }
+
+  .hero-main h1 {
+    font-size: 25px;
+  }
+
+  .hero-main p {
+    font-size: 13px;
+  }
+
+  .hero-actions .arco-btn,
+  .membership-card__actions .arco-btn {
+    flex: 1 1 100%;
+  }
+
+  .metric-grid,
+  .ability-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .metric-card strong {
+    font-size: 24px;
+  }
+
+  .ability-button {
+    min-height: 72px;
+  }
+}
+</style>

+ 47 - 1
src/pages/lepao/accountList/index.vue

@@ -202,6 +202,9 @@
             <template #cell="{ record }">
             <template #cell="{ record }">
               <span v-if="record.notice_type === 'email'"><icon-email /> {{ record.email ?? '未绑定' }}</span>
               <span v-if="record.notice_type === 'email'"><icon-email /> {{ record.email ?? '未绑定' }}</span>
               <span v-else-if="record.notice_type === 'bot'"><icon-robot /> {{ record.bot_account ?? '未绑定' }}</span>
               <span v-else-if="record.notice_type === 'bot'"><icon-robot /> {{ record.bot_account ?? '未绑定' }}</span>
+              <span v-else-if="record.notice_type === 'wechat'" class="notice-display">
+                <icon-wechat /> 微信通知 <img class="vip-inline-icon vip-inline-icon--mini" src="@/assets/img/vip.svg" alt="vip" />
+              </span>
               <span v-else><icon-notification-close /> 无通知</span>
               <span v-else><icon-notification-close /> 无通知</span>
             </template>
             </template>
           </a-table-column>
           </a-table-column>
@@ -330,9 +333,18 @@
       <a-form-item field="notice_type" label="通知方式">
       <a-form-item field="notice_type" label="通知方式">
         <a-radio-group v-model="form.notice_type">
         <a-radio-group v-model="form.notice_type">
           <a-radio value="email"><icon-email /> 邮件</a-radio>
           <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="bot"><icon-robot /> 智能机器人</a-radio> -->
           <!-- <a-radio value="bot"><icon-robot /> 智能机器人</a-radio> -->
           <a-radio value="none"><icon-notification-close /> 无通知</a-radio>
           <a-radio value="none"><icon-notification-close /> 无通知</a-radio>
         </a-radio-group>
         </a-radio-group>
+        <template #extra v-if="form.notice_type === 'wechat'">
+          <div>微信通知将发送到已绑定的小妍助理微信会话,请先在 AI 助理页面完成微信绑定</div>
+        </template>
       </a-form-item>
       </a-form-item>
       <a-form-item field="email" label="通知邮箱" v-if="form.notice_type === 'email'">
       <a-form-item field="email" label="通知邮箱" v-if="form.notice_type === 'email'">
         <EmailAutoComplete v-model="form.email" placeholder="用于接收乐跑失败、登录失效的通知" allow-clear />
         <EmailAutoComplete v-model="form.email" placeholder="用于接收乐跑失败、登录失效的通知" allow-clear />
@@ -343,7 +355,6 @@
           <a-option v-for="(item, index) in area" :key="index" :value="item">
           <a-option v-for="(item, index) in area" :key="index" :value="item">
             <span class="vipcontent">
             <span class="vipcontent">
               <span>{{ item }} </span>
               <span>{{ item }} </span>
-              <!-- <img src="@/assets/vip.svg" alt="vip" height="20"> -->
             </span>
             </span>
           </a-option>
           </a-option>
         </a-select>
         </a-select>
@@ -955,6 +966,41 @@ onUnmounted(() => {
   align-items: center;
   align-items: center;
 }
 }
 
 
+.notice-radio-option {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  line-height: 1;
+  vertical-align: middle;
+}
+
+.notice-display {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+}
+
+.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;
+  margin-left: 2px;
+  vertical-align: middle;
+  object-fit: contain;
+}
+
+.vip-inline-icon--mini {
+  height: 15px;
+  margin-left: 1px;
+}
+
 .auto-fill-tip {
 .auto-fill-tip {
   color: rgb(var(--gray-6));
   color: rgb(var(--gray-6));
 }
 }

+ 101 - 0
src/router/index.js

@@ -77,6 +77,26 @@ const routes = [
                 }
                 }
             }
             }
         ]
         ]
+    },
+        {
+        path: "/ai",
+        name: 'ai',
+        redirect: '/ai/assistant',
+        component: DEFAULT_LAYOUT,
+        meta: {
+            title: 'AI助手',
+            icon: 'icon-robot'
+        },
+        children: [
+            {
+                path: 'assistant',
+                name: 'ai.assistant',
+                component: () => import('../pages/ai/assistant/index.vue'),
+                meta: {
+                    title: '小妍助理'
+                }
+            }
+        ]
     },
     },
     {
     {
         path: "/store",
         path: "/store",
@@ -347,6 +367,87 @@ const routes = [
             }
             }
         ]
         ]
     },
     },
+    {
+        path: "/aiManage",
+        name: 'aiManage',
+        redirect: '/aiManage/workOrder/orderList',
+        component: DEFAULT_LAYOUT,
+        meta: {
+            title: 'AI管理',
+            icon: 'icon-robot',
+            permission: ['admin', 'service'],
+            permissionCodes: [
+                'page.admin.service.orderList',
+                'page.admin.aiChat'
+            ]
+        },
+        children: [
+            {
+                path: 'workOrder/orderList',
+                name: 'aiManage.workOrder.orderList',
+                component: () => import('../pages/admin/workOrder/orderList.vue'),
+                meta: {
+                    title: '工单处理',
+                    permission: ['admin', 'service'],
+                    permissionCode: 'page.admin.service.orderList'
+                }
+            },
+            {
+                path: 'workOrder/orderDetail/:id',
+                name: 'aiManage.workOrder.orderDetail',
+                component: () => import('../pages/admin/workOrder/orderDetail.vue'),
+                meta: {
+                    title: '工单详情',
+                    hideInMenu: true,
+                    parent: 'aiManage.workOrder.orderList',
+                    permission: ['admin', 'service'],
+                    permissionCode: 'page.admin.service.orderList'
+                }
+            },
+            {
+                path: 'aiChat',
+                name: 'aiManage.aiChat.list',
+                component: () => import('../pages/admin/aiChat/list.vue'),
+                meta: {
+                    title: 'AI聊天管理',
+                    permission: ['admin', 'service'],
+                    permissionCode: 'page.admin.aiChat'
+                }
+            },
+            {
+                path: 'aiChat/detail/:id',
+                name: 'aiManage.aiChat.detail',
+                component: () => import('../pages/admin/aiChat/detail.vue'),
+                meta: {
+                    title: 'AI聊天详情',
+                    hideInMenu: true,
+                    parent: 'aiManage.aiChat.list',
+                    permission: ['admin', 'service'],
+                    permissionCode: 'page.admin.aiChat'
+                }
+            },
+            {
+                path: 'weixinBinding',
+                name: 'aiManage.weixinBinding.list',
+                component: () => import('../pages/admin/aiChat/weixinBindingList.vue'),
+                meta: {
+                    title: '微信绑定管理',
+                    permission: ['admin', 'service'],
+                    permissionCode: 'page.admin.aiChat'
+                }
+            },
+            {
+                path: 'quota',
+                name: 'aiManage.quota',
+                component: () => import('../pages/admin/systemConfig/index.vue'),
+                meta: {
+                    title: 'AI限额配置',
+                    permission: ['admin'],
+                    permissionCode: 'page.admin.aiChat'
+                }
+            }
+        ]
+    },
     {
     {
         path: "/admin",
         path: "/admin",
         name: 'admin',
         name: 'admin',

Some files were not shown because too many files changed in this diff