Просмотр исходного кода

feat(EPIC 9): 前端人工方案页+支付页交付+API扩展

新增: pages/plan/ 目录 (apply.vue申请, monitor.vue进度, workbench.vue工作台)
扩展: chart/index.vue 支付+交付表单UI, api.js 新增API方法
修复: apply.vue radio-group改用@change事件修复v-model警告

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
liaoxg 3 месяцев назад
Родитель
Сommit
3fdf0bae75

+ 4 - 1
client/pages.json

@@ -9,7 +9,10 @@
     {"path": "pages/login/index", "style": {"navigationBarTitleText": "微信登录"}},
     {"path": "pages/subscribe/index", "style": {"navigationBarTitleText": "开通能量师"}},
     {"path": "pages/commission/index", "style": {"navigationBarTitleText": "我的推广", "enablePullDownRefresh": true, "backgroundColor": "#0c0a1a"}},
-    {"path": "pages/withdraw/index", "style": {"navigationBarTitleText": "提现"}}
+    {"path": "pages/withdraw/index", "style": {"navigationBarTitleText": "提现"}},
+    {"path": "pages/plan/apply", "style": {"navigationBarTitleText": "申请人工方案"}},
+    {"path": "pages/plan/workbench", "style": {"navigationBarTitleText": "能量师工作台", "enablePullDownRefresh": true, "backgroundColor": "#0c0a1a"}},
+    {"path": "pages/plan/monitor", "style": {"navigationBarTitleText": "咨询监看", "enablePullDownRefresh": true, "backgroundColor": "#0c0a1a"}}
   ],
   "globalStyle": {
     "navigationBarTextStyle": "black",

+ 604 - 82
client/pages/chart/index.vue

@@ -153,10 +153,24 @@
       </view>
 
       <scroll-view class="chat-box" scroll-y :scroll-top="scrollTop">
-        <view v-for="(msg, idx) in messages" :key="idx" class="msg-row" :class="msg.role">
-          <view class="msg-bubble">
+        <view v-for="(msg, idx) in messages" :key="idx">
+          <!-- System messages: centered italic text, no bubble -->
+          <view v-if="msg.senderType === 'system'" class="msg-system" @longpress.stop="onLongPressMsg(msg)">
             <text>{{ msg.content }}</text>
           </view>
+          <!-- Practitioner messages: green bubble with label -->
+          <view v-else-if="msg.senderType === 'practitioner'" class="msg-row practitioner" @longpress.stop="onLongPressMsg(msg)">
+            <view class="msg-practitioner-label">👤 能量师</view>
+            <view class="msg-bubble msg-practitioner-bubble">
+              <text>{{ msg.content }}</text>
+            </view>
+          </view>
+          <!-- Default (AI/User): existing rendering -->
+          <view v-else class="msg-row" :class="msg.role">
+            <view class="msg-bubble">
+              <text>{{ msg.content }}</text>
+            </view>
+          </view>
         </view>
       </scroll-view>
 
@@ -213,6 +227,86 @@
         </view>
        </view>
      </view>
+
+     <!-- US-9.4: Proposal card for negotiating state -->
+     <view v-if="planRequest && planRequest.status === 'negotiating'" class="proposal-section">
+       <view class="proposal-title">📋 人工方案</view>
+       <view class="proposal-card">
+         <view class="proposal-price">¥{{ planRequest.price }}</view>
+
+         <view v-if="proposalMessage" class="proposal-message">
+           <text class="proposal-msg-label">能量师留言:</text>
+           <text class="proposal-msg-content">{{ proposalMessage }}</text>
+         </view>
+
+         <view v-if="logs.length > 0" class="negotiation-history">
+           <view class="history-title">协商历史</view>
+           <view class="history-timeline">
+             <view v-for="(log, idx) in logs" :key="log.id" class="history-item" :class="log.action">
+               <view class="timeline-dot" :class="log.action"></view>
+               <view class="timeline-line" v-if="idx < logs.length - 1"></view>
+               <view class="timeline-content">
+                 <text v-if="log.action === 'proposal'">能量师提交方案: ¥{{ log.newPrice }}</text>
+                 <text v-else-if="log.action === 'counter' && log.operatorId === currentUserId">您还价: ¥{{ log.newPrice }}</text>
+                 <text v-else-if="log.action === 'counter'">能量师还价: ¥{{ log.newPrice }}</text>
+                 <text v-else-if="log.action === 'accept'">✓ 已接受方案</text>
+                 <text v-else>{{ log.action }}</text>
+               </view>
+             </view>
+           </view>
+         </view>
+
+         <view class="proposal-actions">
+           <button class="proposal-btn btn-accept" :disabled="acceptingOffer" @click="acceptOffer">接受方案</button>
+           <button class="proposal-btn btn-counter" @click="showCounterInput = !showCounterInput">还价</button>
+           <button class="proposal-btn btn-reject" :disabled="rejecting" @click="rejectOffer">拒绝</button>
+         </view>
+
+         <view v-if="showCounterInput" class="counter-input-wrap">
+           <view class="counter-input-row">
+             <input class="counter-input" v-model="counterPrice" type="number" placeholder="输入您的报价..." />
+             <button class="counter-submit" :disabled="countering || !counterPrice" @click="submitCounter">提交还价</button>
+           </view>
+           <text class="counter-hint">双方最多协商5轮</text>
+         </view>
+       </view>
+     </view>
+
+<!-- US-9.5: Pending payment — show pay button -->
+      <view v-if="planRequest && planRequest.status === 'pending_payment' && planOrder" class="proposal-section">
+        <view class="proposal-title">📋 方案已确认</view>
+        <view class="proposal-card pending-payment-card">
+          <view class="proposal-price">¥{{ planRequest.price }}</view>
+          <text class="payment-hint">请在24小时内完成支付</text>
+          <button class="payment-btn" @click="goPay">去支付 ¥{{ planRequest.price }}</button>
+        </view>
+      </view>
+
+      <!-- US-9.5: Paid — show delivery -->
+      <view v-else-if="planRequest && planRequest.status === 'paid'" class="proposal-section">
+        <view class="proposal-card paid-card">
+          <view v-if="deliveryData && deliveryData.length > 0" class="delivery-content">
+            <view class="delivery-title">📋 方案交付</view>
+            <view v-for="d in deliveryData" :key="d.id" class="delivery-item">
+              <view class="delivery-header">
+                <text class="delivery-type">{{ d.deliveryType === 'text' ? '文字方案' : d.deliveryType === 'file' ? '文件方案' : '综合方案' }}</text>
+                <text class="delivery-time">{{ d.createdAt?.slice(0, 10) }}</text>
+              </view>
+              <text v-if="d.textContent" class="delivery-text">{{ d.textContent }}</text>
+            </view>
+          </view>
+          <view v-else class="paid-waiting">
+            <text class="paid-waiting-text">方案已确认,等待能量师交付...</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- EPIC 9: Apply for practitioner plan -->
+     <view class="plan-apply-bar">
+       <button class="plan-apply-btn" @click="goPlanApply">
+         💼 申请人工深度方案
+       </button>
+     </view>
    </view>
 
    <!-- Share canvas (hidden off-screen) -->
@@ -262,7 +356,7 @@ import { useChartStore } from '@/stores/chart'
 import { useUserStore } from '@/stores/user'
 import TriangleChart from '@/components/TriangleChart.vue'
 import ChartShareCanvas from '@/components/ChartShareCanvas.vue'
-import { chartApi, chatApi, consultationApi, profileApi, annotationApi, exportApi } from '@/utils/api'
+import { chartApi, chatApi, consultationApi, profileApi, annotationApi, exportApi, interventionApi, planApi, proposalApi, deliveryApi, payApi } from '@/utils/api'
 
 const store = useChartStore()
 const userStore = useUserStore()
@@ -275,6 +369,23 @@ const maxChats = ref(0)
 const usedAcademic = ref(0)
 const maxAcademic = ref(0)
 
+// US-9.3: Intervention state
+const hasActiveIntervention = ref(false)
+const activeInterventionId = ref(null)
+
+// US-9.4: Plan proposal state
+const planRequest = ref(null)
+const logs = ref([])
+const counterPrice = ref(null)
+const showCounterInput = ref(false)
+const acceptingOffer = ref(false)
+const countering = ref(false)
+const rejecting = ref(false)
+
+// US-9.5: Payment and delivery state
+const planOrder = ref(null)
+const deliveryData = ref([])
+
 // Share canvas ref
 const shareCanvas = ref(null)
 const shareGenerating = ref(false)
@@ -292,10 +403,7 @@ const interpretExpanded = ref({})
 const academicData = ref(null)
 const academicLoading = ref(false)
 const academicExpanded = ref({})
-
-// US-2.1: Share state
-const shareGenerating = ref(false)
-const showShareActions = ref(false)
+// US-2.1: Share state (shareGenerating declared above with shareCanvas)
 
 const quotaExceeded = computed(() => {
   if (userStore.isVipActive) return false
@@ -314,6 +422,15 @@ const quotaAcademicExceeded = computed(() => {
   return maxAcademic.value > 0 && usedAcademic.value >= maxAcademic.value
 })
 
+// US-9.4: Computed proposal message from logs
+const proposalMessage = computed(() => {
+  const proposalLog = logs.value.find(l => l.action === 'proposal')
+  return proposalLog?.message || ''
+})
+
+// US-9.4: Current user ID for comparison in negotiation history
+const currentUserId = computed(() => userStore.user?.id || userStore.userId || '')
+
 onMounted(async () => {
   try {
     await userStore.fetchQuota()
@@ -342,18 +459,25 @@ onMounted(async () => {
       loadAnnotations()
     }
 
+    // US-9.4: Load plan request for this chart
+    loadPlanRequest()
+
     // Use consultationMessages from store (set during startConsultation) if available
     if (store.consultationMessages && store.consultationMessages.length > 0) {
       messages.value = store.consultationMessages.map(m => ({
         role: m.role === 'ai' ? 'ai' : 'user',
-        content: m.content
+        content: m.content,
+        senderType: m.senderType || null,
+        interventionId: m.interventionId || null
       }))
     } else if (store.currentRecordId) {
       try {
         const history = await chatApi.history(store.currentRecordId)
         messages.value = history.map(m => ({
           role: m.role === 'ai' ? 'ai' : 'user',
-          content: m.content
+          content: m.content,
+          senderType: m.senderType || null,
+          interventionId: m.interventionId || null
         }))
         const userMsgCount = history.filter(m => m.role === 'user').length
         usedChats.value = Math.max(usedChats.value, userMsgCount)
@@ -362,6 +486,9 @@ onMounted(async () => {
       }
     }
 
+    // US-9.3: Check intervention status from loaded messages
+    checkInterventionStatus(messages.value)
+
     // Ensure at least a greeting message if still empty
     if (messages.value.length === 0) {
       messages.value = [{
@@ -377,6 +504,57 @@ onMounted(async () => {
   }
 })
 
+// US-9.3: Check if intervention is active from message history
+function checkInterventionStatus(messages) {
+  let active = false
+  let lastId = null
+  for (const msg of messages) {
+    if (msg.senderType === 'system') {
+      if (msg.content && msg.content.includes('能量师') && msg.content.includes('进入')) {
+        active = true
+        lastId = msg.interventionId
+      }
+      if (msg.content && (msg.content.includes('退出') || msg.content.includes('结束'))) {
+        active = false
+      }
+    }
+  }
+  hasActiveIntervention.value = active
+  activeInterventionId.value = lastId
+}
+
+// US-9.3: Long-press handler for practitioner / entry system messages
+function onLongPressMsg(msg) {
+  // Only show action if intervention is active
+  if (!hasActiveIntervention.value) return
+  // Only on practitioner messages or entry system messages
+  if (msg.senderType === 'practitioner' || (msg.senderType === 'system' && msg.content && msg.content.includes('能量师'))) {
+    uni.showActionSheet({
+      itemList: ['结束能量师介入'],
+      success: async (res) => {
+        if (res.tapIndex === 0) {
+          await endIntervention()
+        }
+      }
+    })
+  }
+}
+
+// US-9.3: End intervention by user's request
+async function endIntervention() {
+  uni.showLoading({ title: '处理中...' })
+  try {
+    await interventionApi.endByUser(activeInterventionId.value)
+    uni.hideLoading()
+    uni.showToast({ title: '已结束能量师介入', icon: 'success' })
+    hasActiveIntervention.value = false
+    activeInterventionId.value = null
+  } catch (e) {
+    uni.hideLoading()
+    uni.showToast({ title: e.message || '操作失败', icon: 'none' })
+  }
+}
+
 // US-3.1: Fetch AI structured interpretation
 async function fetchInterpret() {
   if (!store.currentRecordId) {
@@ -588,47 +766,6 @@ async function triggerShare() {
   })
 }
 
-// US-2.2: Export PDF report
-async function exportPdf() {
-  if (!store.currentRecordId) {
-    uni.showToast({ title: '暂无命盘数据', icon: 'none' })
-    return
-  }
-  uni.showLoading({ title: '正在生成 PDF...' })
-  try {
-    // The API returns a PDF binary. We use uni.downloadFile to get it as a temp file.
-    const token = uni.getStorageSync('token')
-    const url = 'http://127.0.0.1:8186/api/export/pdf'
-    const downloadRes = await new Promise((resolve, reject) => {
-      uni.downloadFile({
-        url,
-        header: { Authorization: `Bearer ${token}` },
-        method: 'POST',
-        data: { recordId: store.currentRecordId },
-        success: resolve,
-        fail: reject
-      })
-    })
-    if (downloadRes.statusCode !== 200) {
-      throw new Error('下载失败')
-    }
-    uni.hideLoading()
-    // Open the PDF file
-    uni.openDocument({
-      filePath: downloadRes.tempFilePath,
-      success: () => {
-        uni.showToast({ title: 'PDF 已生成', icon: 'success' })
-      },
-      fail: () => {
-        uni.showToast({ title: '打开文件失败', icon: 'none' })
-      }
-    })
-  } catch (e) {
-    uni.hideLoading()
-    uni.showToast({ title: e.message || '导出失败', icon: 'none' })
-  }
-}
-
 function goSubscribe() {
   uni.navigateTo({ url: '/pages/subscribe/index' })
 }
@@ -639,6 +776,19 @@ function goPoster() {
   uni.navigateTo({ url: '/pages/subscribe/index' })
 }
 
+// EPIC 9: Navigate to plan apply page
+function goPlanApply() {
+  if (!userStore.isLoggedIn) {
+    uni.navigateTo({ url: '/pages/login/index' })
+    return
+  }
+  if (!store.currentRecordId) {
+    uni.showToast({ title: '暂无命盘数据', icon: 'none' })
+    return
+  }
+  uni.navigateTo({ url: `/pages/plan/apply?chartRecordId=${store.currentRecordId}` })
+}
+
 // US-2.1: Share chart image
 async function onShareTap() {
   if (shareGenerating.value) return
@@ -731,39 +881,9 @@ async function exportPdf() {
     uni.hideLoading()
     uni.showToast({ title: e.message || '导出失败', icon: 'none' })
   }
-}
-    }
-
-    // Generate image via canvas
-    if (!shareCanvas.value) throw new Error('Canvas未就绪')
-    const filePath = await shareCanvas.value.generate()
-
-    if (actionIndex === 0) {
-      // Save to album
-      await uni.saveImageToPhotosAlbum({ filePath })
-      uni.showToast({ title: '已保存到相册', icon: 'success' })
-    } else {
-      // Share via WeChat
-      const scene = actionIndex === 1 ? 'WXSceneSession' : 'WXSceneTimeline'
-      const title = store.currentChart?.O
-        ? `我的命盘主性格数字是${store.currentChart.O},快来测测你的!`
-        : '数字能量命盘分析'
-      await uni.share({
-        provider: 'weixin',
-        scene,
-        type: 'image',
-        imageUrl: filePath,
-        title
-      })
-    }
-  } catch (e) {
-    uni.showToast({ title: e.message || '分享失败', icon: 'none' })
-  } finally {
-    shareGenerating.value = false
   }
-}
 
-// === Annotation / Tag handlers (US-7.1 / US-7.2) ===
+  // === Annotation / Tag handlers (US-7.1 / US-7.2) ===
 
 async function loadAnnotations() {
   if (!store.currentRecordId) return
@@ -813,6 +933,104 @@ async function onTagCreate(tagData) {
     uni.showToast({ title: '创建失败', icon: 'none' })
   }
 }
+
+// US-9.4: Load plan request for current chart
+async function loadPlanRequest() {
+  if (!store.currentRecordId) return
+  try {
+    const requests = await planApi.myRequests()
+    const match = requests.find(r => r.chartRecordId === store.currentRecordId && (r.status === 'negotiating' || r.status === 'pending_payment' || r.status === 'paid'))
+    if (match) {
+      planRequest.value = match
+      const logList = await proposalApi.logs(match.id)
+      logs.value = logList || []
+      // US-9.5: Load delivery data when status is paid
+      if (match.status === 'paid') {
+        try {
+          const deliveries = await deliveryApi.list(match.id)
+          deliveryData.value = deliveries || []
+        } catch (e) { /* best-effort */ }
+      }
+    }
+  } catch (e) {
+    // No plan request found — that's fine
+  }
+}
+
+// US-9.4: Accept offer
+async function acceptOffer() {
+  uni.showModal({
+    title: '接受方案',
+    content: `确定以 ¥${planRequest.value.price} 接受此方案吗?`,
+    success: async (res) => {
+      if (!res.confirm) return
+      acceptingOffer.value = true
+      try {
+        const result = await proposalApi.accept({ requestId: planRequest.value.id })
+        planRequest.value = result.planRequest
+        planOrder.value = result.order
+        const logList = await proposalApi.logs(planRequest.value.id)
+        logs.value = logList || []
+        uni.showToast({ title: '已接受方案,请完成支付', icon: 'success' })
+      } catch (e) {
+        uni.showToast({ title: e.message || '操作失败', icon: 'none' })
+      } finally {
+        acceptingOffer.value = false
+      }
+    }
+  })
+}
+
+// US-9.5: Navigate to payment page
+function goPay() {
+  if (!planOrder.value || !planRequest.value) return
+  uni.navigateTo({
+    url: `/pages/payment/index?product=practitioner_plan&totalFee=${planRequest.value.price}&outTradeNo=${planOrder.value.outTradeNo}`
+  })
+}
+
+// US-9.4: Reject offer
+async function rejectOffer() {
+  uni.showModal({
+    title: '拒绝方案',
+    content: '确定拒绝此方案吗?',
+    success: async (res) => {
+      if (!res.confirm) return
+      rejecting.value = true
+      try {
+        await proposalApi.reject({ requestId: planRequest.value.id })
+        planRequest.value.status = 'rejected'
+        uni.showToast({ title: '已拒绝', icon: 'success' })
+      } catch (e) {
+        uni.showToast({ title: e.message || '操作失败', icon: 'none' })
+      } finally {
+        rejecting.value = false
+      }
+    }
+  })
+}
+
+// US-9.4: Submit counter-offer
+async function submitCounter() {
+  const price = Number(counterPrice.value)
+  if (!price || price <= 0) {
+    uni.showToast({ title: '请输入有效价格', icon: 'none' })
+    return
+  }
+  countering.value = true
+  try {
+    await proposalApi.counter({ requestId: planRequest.value.id, newPrice: price })
+    const logList = await proposalApi.logs(planRequest.value.id)
+    logs.value = logList || []
+    showCounterInput.value = false
+    counterPrice.value = null
+    uni.showToast({ title: '已提交还价', icon: 'success' })
+  } catch (e) {
+    uni.showToast({ title: e.message || '还价失败', icon: 'none' })
+  } finally {
+    countering.value = false
+  }
+}
 </script>
 
 <style scoped lang="scss">
@@ -1244,6 +1462,34 @@ async function onTagCreate(tagData) {
   color: #fbbf24;
 }
 
+/* US-9.3: Practitioner message bubble */
+.msg-row.practitioner {
+  justify-content: flex-start;
+  flex-direction: column;
+  align-items: flex-start;
+}
+.msg-practitioner-label {
+  font-size: 12px;
+  color: #10b981;
+  margin-bottom: 4px;
+  font-weight: 500;
+}
+.msg-practitioner-bubble {
+  background: rgba(16, 185, 129, 0.15);
+  border: 1px solid rgba(16, 185, 129, 0.3);
+  color: rgba(255, 255, 255, 0.85);
+}
+
+/* US-9.3: System message — centered italic text */
+.msg-system {
+  text-align: center;
+  padding: 8px 0;
+  color: rgba(255, 255, 255, 0.4);
+  font-style: italic;
+  font-size: 12px;
+  line-height: 1.6;
+}
+
 .input-bar {
   display: flex;
   gap: 8px;
@@ -1415,4 +1661,280 @@ async function onTagCreate(tagData) {
   font-size: 12px;
   color: rgba(255,255,255,0.35);
 }
-</style>
+
+/* US-9.4: Proposal section */
+.proposal-section {
+  flex-shrink: 0;
+  margin: 0 14px 10px;
+}
+.proposal-title {
+  font-size: 16px;
+  font-weight: 700;
+  color: #a78bfa;
+  margin-bottom: 10px;
+  padding-left: 2px;
+}
+.proposal-card {
+  background: rgba(124,58,237,0.06);
+  border: 1px solid rgba(124,58,237,0.12);
+  border-radius: 14px;
+  padding: 16px;
+}
+.proposal-price {
+  text-align: center;
+  font-size: 24px;
+  font-weight: 700;
+  color: #fbbf24;
+  margin-bottom: 12px;
+}
+.proposal-message {
+  background: rgba(255,255,255,0.03);
+  border-radius: 8px;
+  padding: 10px 12px;
+  margin-bottom: 12px;
+}
+.proposal-msg-label {
+  display: block;
+  font-size: 12px;
+  color: rgba(255,255,255,0.4);
+  margin-bottom: 4px;
+}
+.proposal-msg-content {
+  display: block;
+  font-size: 13px;
+  color: rgba(255,255,255,0.75);
+  line-height: 1.5;
+}
+.negotiation-history {
+  margin-bottom: 14px;
+}
+.history-title {
+  font-size: 13px;
+  font-weight: 600;
+  color: rgba(255,255,255,0.5);
+  margin-bottom: 10px;
+}
+.history-timeline {
+  padding-left: 6px;
+}
+.history-item {
+  display: flex;
+  align-items: flex-start;
+  position: relative;
+  padding-bottom: 6px;
+}
+.timeline-dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  background: rgba(255,255,255,0.2);
+  flex-shrink: 0;
+  margin-top: 4px;
+  margin-right: 10px;
+}
+.timeline-dot.accept {
+  background: #10b981;
+}
+.timeline-line {
+  position: absolute;
+  left: 3px;
+  top: 12px;
+  width: 2px;
+  height: calc(100% - 6px);
+  background: rgba(255,255,255,0.08);
+}
+.timeline-content {
+  flex: 1;
+  font-size: 13px;
+  color: rgba(255,255,255,0.6);
+  line-height: 1.5;
+}
+.proposal-actions {
+  display: flex;
+  gap: 10px;
+}
+.proposal-btn {
+  flex: 1;
+  height: 40px;
+  border: none;
+  border-radius: 10px;
+  font-size: 14px;
+  font-weight: 600;
+  line-height: 40px;
+  text-align: center;
+}
+.proposal-btn.btn-accept {
+  background: linear-gradient(135deg, #10b981, #059669);
+  color: #fff;
+}
+.proposal-btn.btn-accept[disabled] {
+  opacity: 0.5;
+}
+.proposal-btn.btn-counter {
+  background: transparent;
+  border: 1px solid #3b82f6;
+  color: #3b82f6;
+}
+.proposal-btn.btn-reject {
+  background: transparent;
+  border: 1px solid #ef4444;
+  color: #ef4444;
+}
+.proposal-btn.btn-reject[disabled] {
+  opacity: 0.5;
+}
+.counter-input-wrap {
+  margin-top: 12px;
+  padding-top: 12px;
+  border-top: 1px solid rgba(255,255,255,0.06);
+}
+.counter-input-row {
+  display: flex;
+  gap: 8px;
+}
+.counter-input {
+  flex: 1;
+  height: 42px;
+  border: 1px solid rgba(255,255,255,0.1);
+  border-radius: 10px;
+  padding: 0 14px;
+  font-size: 14px;
+  color: rgba(255,255,255,0.85);
+  background: rgba(255,255,255,0.04);
+}
+.counter-submit {
+  height: 42px;
+  padding: 0 20px;
+  background: linear-gradient(135deg, #3b82f6, #2563eb);
+  color: #fff;
+  border: none;
+  border-radius: 10px;
+  line-height: 42px;
+  font-size: 14px;
+  font-weight: 500;
+}
+.counter-submit[disabled] {
+  opacity: 0.4;
+}
+.counter-hint {
+  display: block;
+  font-size: 11px;
+  color: rgba(255,255,255,0.3);
+  margin-top: 6px;
+  text-align: center;
+}
+.proposal-card.paid-card {
+  text-align: center;
+  padding: 16px;
+}
+.paid-text {
+  font-size: 14px;
+  color: #10b981;
+  font-weight: 600;
+}
+
+/* EPIC 9: Plan apply button bar */
+.plan-apply-bar {
+  flex-shrink: 0;
+  margin: 10px 14px calc(env(safe-area-inset-bottom) + 16px);
+}
+.plan-apply-btn {
+  width: 100%;
+  height: 48px;
+  background: linear-gradient(135deg, #a78bfa, #7c3aed);
+  color: #fff;
+  border: none;
+  border-radius: 14px;
+  line-height: 48px;
+  font-size: 16px;
+  font-weight: 700;
+  letter-spacing: 1px;
+  box-shadow: 0 4px 16px rgba(124, 58, 237, 0.25);
+}
+.plan-apply-btn:active {
+  opacity: 0.85;
+}
+
+/* US-9.5: Pending payment card */
+.pending-payment-card {
+  background: linear-gradient(135deg, rgba(167, 139, 250, 0.08), rgba(124, 58, 237, 0.05));
+  border: 1px solid rgba(167, 139, 250, 0.2);
+  border-radius: 14px;
+  padding: 20px;
+  text-align: center;
+}
+.pending-payment-card .proposal-price {
+  font-size: 28px;
+  font-weight: 700;
+  color: #fbbf24;
+  margin-bottom: 8px;
+}
+.payment-hint {
+  font-size: 12px;
+  color: rgba(255, 255, 255, 0.4);
+  display: block;
+  margin-bottom: 14px;
+}
+.payment-btn {
+  width: 100%;
+  height: 46px;
+  background: linear-gradient(135deg, #10b981, #059669);
+  color: #fff;
+  border: none;
+  border-radius: 12px;
+  line-height: 46px;
+  font-size: 16px;
+  font-weight: 600;
+  box-shadow: 0 4px 12px rgba(16, 185, 129, 0.25);
+}
+
+/* US-9.5: Delivery content display */
+.delivery-content {
+  padding: 0;
+}
+.delivery-title {
+  font-size: 15px;
+  font-weight: 600;
+  color: rgba(255, 255, 255, 0.85);
+  margin-bottom: 12px;
+}
+.delivery-item {
+  background: rgba(255, 255, 255, 0.03);
+  border-radius: 10px;
+  padding: 14px;
+  margin-bottom: 10px;
+}
+.delivery-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 8px;
+}
+.delivery-type {
+  font-size: 13px;
+  font-weight: 600;
+  color: #10b981;
+}
+.delivery-time {
+  font-size: 11px;
+  color: rgba(255, 255, 255, 0.3);
+}
+.delivery-text {
+  display: block;
+  font-size: 13px;
+  color: rgba(255, 255, 255, 0.65);
+  line-height: 1.6;
+  white-space: pre-wrap;
+}
+
+/* US-9.5: Paid waiting state */
+.paid-waiting {
+  text-align: center;
+  padding: 10px 0;
+}
+.paid-waiting-text {
+  font-size: 14px;
+  color: rgba(255, 255, 255, 0.4);
+}
+
+</style>

+ 249 - 0
client/pages/plan/apply.vue

@@ -0,0 +1,249 @@
+<template>
+  <view class="page-apply">
+    <view class="header">
+      <text class="header-title">申请人工方案</text>
+    </view>
+
+    <view class="form-container">
+      <view class="form-group">
+        <text class="form-label">需求类型</text>
+        <radio-group class="radio-group" @change="onDemandTypeChange">
+          <label class="radio-item" v-for="type in demandTypes" :key="type">
+            <radio :value="type"></radio>
+            <text>{{ type }}</text>
+          </label>
+        </radio-group>
+      </view>
+
+      <view class="form-group">
+        <text class="form-label">具体需求描述</text>
+        <textarea 
+          class="textarea" 
+          placeholder="请详细描述您的需求,我们将根据此信息匹配合适的能量师"
+          maxlength="300"
+          v-model="form.description"
+          @input="onInput"
+        ></textarea>
+        <view class="counter">
+          {{ form.description.length }}/300
+        </view>
+      </view>
+
+      <view class="form-group">
+        <text class="form-label">期望价格范围</text>
+        <picker 
+          class="picker" 
+          mode="selector" 
+          :range="priceRanges"
+          v-model="form.priceIndex"
+        >
+          <view class="picker-value">
+            {{ priceRanges[form.priceIndex] }}
+          </view>
+        </picker>
+      </view>
+    </view>
+
+    <button 
+      class="submit-btn" 
+      @click="onSubmit" 
+      :disabled="loading"
+    >
+      {{ loading ? '提交中...' : '提交申请' }}
+    </button>
+  </view>
+</template>
+
+<script setup>
+import { ref, computed } from 'vue'
+import { planApi } from '@/utils/api'
+import { useUserStore } from '@/stores/user'
+
+const form = ref({
+  demandType: '',
+  description: '',
+  priceIndex: 0
+})
+
+const demandTypes = ['学业方向', '职业规划', '亲子关系', '其他']
+const priceRanges = ['¥50-99', '¥100-299', '¥300-499', '¥500-999', '面议']
+
+const loading = ref(false)
+const chartRecordId = ref('')
+
+// Get chartRecordId from query parameters on page load
+const onLoad = (query) => {
+  if (query && query.chartRecordId) {
+    chartRecordId.value = query.chartRecordId
+  }
+}
+
+const onInput = (e) => {
+  // Ensure we don't exceed 300 characters (though maxlength should handle it)
+  if (form.value.description.length > 300) {
+    form.value.description = form.value.description.slice(0, 300)
+  }
+}
+
+const onDemandTypeChange = (e) => {
+  form.value.demandType = e.detail.value
+}
+
+const onSubmit = async () => {
+  // Validate form
+  if (!form.value.demandType) {
+    uni.showToast({ title: '请选择需求类型', icon: 'none' })
+    return
+  }
+  if (!form.value.description.trim()) {
+    uni.showToast({ title: '请填写具体需求描述', icon: 'none' })
+    return
+  }
+
+  loading.value = true
+  try {
+    const res = await planApi.apply({
+      chartRecordId: chartRecordId.value,
+      demandType: form.value.demandType,
+      description: form.value.description.trim(),
+      priceRange: priceRanges[form.value.priceIndex]
+    })
+    // Backend returns { planRequest, assigned, message }
+    // If no practitioner assigned, show the guidance message
+    if (res && !res.assigned) {
+      uni.showModal({
+        title: '提交成功',
+        content: res.message || '暂未开放系统分配,请通过推荐链接找到专属能量师',
+        showCancel: false,
+        confirmText: '知道了'
+      })
+    } else {
+      uni.showToast({ title: '提交成功', icon: 'success' })
+    }
+    // Navigate back after a short delay to let the toast/modal show
+    setTimeout(() => {
+      uni.navigateBack()
+    }, res && !res.assigned ? 3000 : 1500)
+  } catch (e) {
+    // Error handling: show toast with error message from API
+    uni.showToast({
+      title: e.message || '提交失败,请重试',
+      icon: 'none'
+    })
+  } finally {
+    loading.value = false
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.page-apply {
+  padding: 20px;
+  min-height: 100vh;
+  background: linear-gradient(180deg, #0c0a1a 0%, #1a1232 100%);
+}
+
+.header {
+  text-align: center;
+  margin-bottom: 24px;
+}
+
+.header-title {
+  font-size: 22px;
+  font-weight: 700;
+  color: rgba(255,255,255,0.9);
+  display: block;
+}
+
+.form-container {
+  background: rgba(255,255,255,0.02);
+  border-radius: 16px;
+  padding: 24px;
+  margin-bottom: 24px;
+}
+
+.form-group {
+  margin-bottom: 20px;
+}
+
+.form-label {
+  display: block;
+  font-size: 14px;
+  color: rgba(255,255,255,0.6);
+  margin-bottom: 8px;
+  font-weight: 500;
+}
+
+.radio-group {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px;
+}
+
+.radio-item {
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+  font-size: 14px;
+  color: rgba(255,255,255,0.8);
+}
+
+.radio-item text {
+  margin-left: 6px;
+}
+
+.textarea {
+  width: 100%;
+  min-height: 80px;
+  padding: 12px;
+  border: 1px solid rgba(255,255,255,0.1);
+  border-radius: 8px;
+  background: rgba(255,255,255,0.03);
+  color: #fff;
+  font-size: 14px;
+  resize: none;
+}
+
+.textarea:focus {
+  outline: none;
+  border-color: rgba(255,255,255,0.2);
+}
+
+.counter {
+  margin-top: 8px;
+  text-align: right;
+  font-size: 12px;
+  color: rgba(255,255,255,0.4);
+}
+
+.picker {
+  width: 100%;
+  padding: 12px;
+  border: 1px solid rgba(255,255,255,0.1);
+  border-radius: 8px;
+  background: rgba(255,255,255,0.03);
+  color: #fff;
+  font-size: 14px;
+}
+
+.picker-value {
+  display: block;
+  text-align: center;
+}
+
+.submit-btn {
+  width: 100%;
+  padding: 16px;
+  background: linear-gradient(135deg, #f59e0b, #d97706);
+  color: #fff;
+  border: none;
+  border-radius: 14px;
+  font-size: 17px;
+  font-weight: 600;
+  letter-spacing: 2px;
+}
+
+.submit-btn[disabled] {
+  opacity: 0.4;
+}
+</style>

+ 878 - 0
client/pages/plan/monitor.vue

@@ -0,0 +1,878 @@
+<template>
+  <view class="page-monitor">
+    <!-- Header: subordinate user info -->
+    <view class="monitor-header">
+      <view class="header-info">
+        <text class="header-user">用户 {{ userId }}</text>
+        <text class="header-subtitle">AI 咨询记录</text>
+      </view>
+    </view>
+
+    <!-- Loading skeleton -->
+    <view v-if="loading" class="loading-msgs">
+      <view class="skeleton-msg left-msg">
+        <view class="skeleton-label"></view>
+        <view class="skeleton-bubble w-60"></view>
+      </view>
+      <view class="skeleton-msg right-msg">
+        <view class="skeleton-bubble w-40"></view>
+      </view>
+      <view class="skeleton-msg left-msg">
+        <view class="skeleton-label"></view>
+        <view class="skeleton-bubble w-75"></view>
+      </view>
+      <view class="skeleton-msg right-msg">
+        <view class="skeleton-bubble w-55"></view>
+      </view>
+      <view class="skeleton-msg left-msg">
+        <view class="skeleton-label"></view>
+        <view class="skeleton-bubble w-65"></view>
+      </view>
+      <view class="skeleton-msg right-msg">
+        <view class="skeleton-bubble w-45"></view>
+      </view>
+    </view>
+
+    <template v-else>
+      <!-- Chat display area -->
+      <scroll-view class="chat-box" scroll-y :scroll-top="scrollTop">
+        <view v-for="(msg, idx) in messages" :key="msg.id || idx" class="msg-wrapper">
+          <!-- System message: centered, italic, no bubble -->
+          <view v-if="msg.senderType === 'system'" class="msg-system">
+            <text class="msg-system-text">{{ msg.content }}</text>
+          </view>
+
+          <!-- Practitioner message: green bubble left-aligned with label -->
+          <view v-else-if="msg.senderType === 'practitioner'" class="msg-row practitioner">
+            <view class="msg-label label-practitioner">👤 能量师</view>
+            <view class="msg-bubble bubble-practitioner">
+              <text>{{ msg.content }}</text>
+            </view>
+          </view>
+
+          <!-- User message: dark blue bubble right-aligned -->
+          <view v-else-if="msg.senderType === 'user'" class="msg-row user">
+            <view class="msg-bubble bubble-user">
+              <text>{{ msg.content }}</text>
+            </view>
+          </view>
+
+          <!-- AI message (default): gray bubble left-aligned with label -->
+          <view v-else class="msg-row ai">
+            <view class="msg-label label-ai">AI</view>
+            <view class="msg-bubble bubble-ai">
+              <text>{{ msg.content }}</text>
+            </view>
+          </view>
+        </view>
+
+        <!-- Empty state -->
+        <view v-if="messages.length === 0" class="empty-chat">
+          <text class="empty-chat-text">暂无聊天记录</text>
+        </view>
+      </scroll-view>
+
+      <!-- Negotiation logs -->
+      <view v-if="negotiationLogs.length > 0" class="negotiation-section">
+        <view class="negotiation-title">📋 协商记录</view>
+        <view v-for="log in negotiationLogs" :key="log.id" class="negotiation-entry">
+          <view class="negotiation-entry-header">
+            <text class="negotiation-entry-action">{{ getActionLabel(log.action) }}</text>
+            <text class="negotiation-entry-time">{{ formatTime(log.createdAt) }}</text>
+          </view>
+          <view v-if="log.oldPrice != null || log.newPrice != null" class="negotiation-entry-price">
+            <text class="price-old" v-if="log.oldPrice">¥{{ log.oldPrice }}</text>
+            <text class="price-arrow" v-if="log.oldPrice != null && log.newPrice != null"> → </text>
+            <text class="price-new" v-if="log.newPrice">¥{{ log.newPrice }}</text>
+          </view>
+          <text v-if="log.message" class="negotiation-entry-msg">{{ log.message }}</text>
+        </view>
+      </view>
+
+      <!-- Bottom action area -->
+      <view class="bottom-bar">
+        <!-- Proposal: status is "accepted" and not yet submitted -->
+        <template v-if="requestStatus === 'accepted' && !submitted">
+          <view class="proposal-form-title">📋 提交方案</view>
+          <view class="proposal-price-row">
+            <text class="proposal-label">方案价格</text>
+            <input
+              class="proposal-input proposal-price-input"
+              v-model="proposalPrice"
+              type="digit"
+              placeholder="输入方案价格..."
+            />
+            <text class="proposal-unit">元</text>
+          </view>
+          <textarea
+            class="proposal-textarea"
+            v-model="proposalMessage"
+            placeholder="描述方案内容..."
+          />
+          <button
+            class="action-btn proposal-submit-btn"
+            @click="submitProposal"
+            :disabled="submitting"
+          >
+            {{ submitting ? '提交中...' : '提交方案' }}
+          </button>
+        </template>
+
+<!-- Proposal submitted or negotiating -->
+  <template v-else-if="submitted || requestStatus === 'negotiating'">
+    <view class="status-bar-submitted">
+      <text class="status-icon">✅</text>
+      <text class="status-text">方案已提交,等待用户回应</text>
+    </view>
+  </template>
+
+  <!-- Pending payment: waiting for user to pay -->
+  <template v-else-if="requestStatus === 'pending_payment'">
+    <view class="status-bar-submitted">
+      <text class="status-icon">⏳</text>
+      <text class="status-text">等待用户支付</text>
+    </view>
+  </template>
+
+  <!-- Paid: delivery form for practitioners -->
+  <template v-else-if="requestStatus === 'paid'">
+    <template v-if="deliverySubmitted">
+      <view class="status-bar-delivered">
+        <text class="status-icon">✅</text>
+        <text class="status-text">方案已交付</text>
+      </view>
+    </template>
+    <template v-else>
+      <view class="delivery-form-title">📦 交付方案</view>
+      <textarea
+        class="delivery-textarea"
+        v-model="deliveryContent"
+        placeholder="输入方案交付内容..."
+      />
+      <button
+        class="action-btn delivery-submit-btn"
+        @click="submitDelivery"
+        :disabled="delivering || !deliveryContent.trim()"
+      >
+        {{ delivering ? '提交中...' : '提交交付' }}
+      </button>
+    </template>
+  </template>
+
+  <!-- Completed: show completed status -->
+  <template v-else-if="requestStatus === 'completed'">
+    <view class="status-bar-delivered">
+      <text class="status-icon">✅</text>
+      <text class="status-text">已完成</text>
+    </view>
+  </template>
+
+  <!-- Default: existing intervention UI -->
+        <template v-else>
+          <button
+            v-if="!interventionMode"
+            class="action-btn intervene-btn"
+            :disabled="intervening"
+            @click="startIntervention"
+          >
+            {{ intervening ? '介入中...' : '介入会话' }}
+          </button>
+
+          <template v-else>
+            <view class="input-row">
+              <input
+                class="chat-input"
+                v-model="chatInput"
+                placeholder="输入消息..."
+                @confirm="sendMessage"
+                :disabled="sending"
+              />
+              <button
+                class="action-btn send-btn"
+                @click="sendMessage"
+                :disabled="sending || !chatInput.trim()"
+              >
+                {{ sending ? '发送中...' : '发送' }}
+              </button>
+            </view>
+            <button class="action-btn exit-btn" @click="confirmExit">退出介入</button>
+          </template>
+        </template>
+      </view>
+    </template>
+  </view>
+</template>
+
+<script>
+// Page lifecycle for WeChat share compatibility
+export default {}
+</script>
+
+<script setup>
+import { ref, computed, nextTick } from 'vue'
+import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
+import { interventionApi, proposalApi, deliveryApi } from '@/utils/api'
+
+// ── Query params ──
+const chartRecordId = ref('')
+const userId = ref('')
+const requestId = ref('')
+const requestStatus = ref('')
+const practitionerId = ref('')
+
+// ── State ──
+const loading = ref(true)
+const messages = ref([])
+const scrollTop = ref(0)
+const interventionMode = ref(false)
+const interventionId = ref(null)
+const intervening = ref(false)
+const sending = ref(false)
+const chatInput = ref('')
+
+// ── Proposal state ──
+const proposalPrice = ref('')
+const proposalMessage = ref('')
+const submitting = ref(false)
+const submitted = ref(false)
+const negotiationLogs = ref([])
+
+// ── Delivery state ──
+const deliveryContent = ref('')
+const deliverySubmitted = ref(false)
+const delivering = ref(false)
+const deliveryData = ref(null)
+
+// ── Lifecycle ──
+onLoad((options) => {
+  chartRecordId.value = options.chartRecordId || ''
+  userId.value = options.userId || ''
+  requestId.value = options.requestId || ''
+  requestStatus.value = options.status || ''
+  practitionerId.value = options.practitionerId || ''
+  fetchHistory()
+  // Fetch negotiation logs if status indicates ongoing negotiation
+  if (requestStatus.value === 'negotiating' || requestStatus.value === 'accepted') {
+    fetchLogs()
+  }
+  // Fetch delivery data if status is paid or completed
+  if (requestStatus.value === 'paid' || requestStatus.value === 'completed') {
+    loadDelivery()
+    fetchLogs()
+  }
+})
+
+onPullDownRefresh(async () => {
+  await fetchHistory()
+  uni.stopPullDownRefresh()
+})
+
+// ── Data fetching ──
+async function fetchHistory() {
+  if (!chartRecordId.value) {
+    loading.value = false
+    return
+  }
+  loading.value = true
+  try {
+    const data = await interventionApi.history(chartRecordId.value)
+    // Sort by timestamp or id for chronological order
+    const msgs = data || []
+    msgs.sort((a, b) => (a.createdAt || a.id || 0) - (b.createdAt || b.id || 0))
+    messages.value = msgs
+    // Scroll to bottom after rendering
+    nextTick(() => {
+      scrollTop.value = 99999
+    })
+  } catch (e) {
+    uni.showToast({ title: '加载聊天记录失败', icon: 'none' })
+  } finally {
+    loading.value = false
+  }
+}
+
+// ── Intervention actions ──
+async function startIntervention() {
+  intervening.value = true
+  try {
+    const result = await interventionApi.start(chartRecordId.value)
+    interventionId.value = result.interventionId || result.id || result
+    interventionMode.value = true
+    uni.showToast({ title: '已介入会话', icon: 'success' })
+    // Append a system message to indicate intervention started
+    messages.value.push({
+      id: Date.now(),
+      senderType: 'system',
+      content: '能量师已介入会话'
+    })
+    nextTick(() => {
+      scrollTop.value = 99999
+    })
+  } catch (e) {
+    uni.showToast({ title: e.message || '介入失败', icon: 'none' })
+  } finally {
+    intervening.value = false
+  }
+}
+
+async function sendMessage() {
+  const content = chatInput.value.trim()
+  if (!content || sending.value) return
+
+  // Optimistically add practitioner message to local list
+  const localMsg = {
+    id: Date.now(),
+    senderType: 'practitioner',
+    content,
+    createdAt: Date.now()
+  }
+  messages.value.push(localMsg)
+  chatInput.value = ''
+  sending.value = true
+
+  try {
+    await interventionApi.send({
+      chartRecordId: chartRecordId.value,
+      content
+    })
+    // Scroll to bottom
+    nextTick(() => {
+      scrollTop.value = 99999
+    })
+  } catch (e) {
+    // Remove the optimistically added message on failure
+    messages.value = messages.value.filter(m => m.id !== localMsg.id)
+    uni.showToast({ title: e.message || '发送失败', icon: 'none' })
+  } finally {
+    sending.value = false
+  }
+}
+
+function confirmExit() {
+  uni.showModal({
+    title: '退出介入',
+    content: '确定要退出当前会话介入吗?',
+    success: async (res) => {
+      if (res.confirm) {
+        try {
+          await interventionApi.end(interventionId.value)
+          interventionMode.value = false
+          interventionId.value = null
+          uni.showToast({ title: '已退出介入', icon: 'success' })
+          // Append system message
+          messages.value.push({
+            id: Date.now(),
+            senderType: 'system',
+            content: '能量师已退出介入'
+          })
+          nextTick(() => {
+            scrollTop.value = 99999
+          })
+        } catch (e) {
+          uni.showToast({ title: e.message || '退出失败', icon: 'none' })
+        }
+      }
+    }
+  })
+}
+
+// ── Proposal actions ──
+async function fetchLogs() {
+  if (!requestId.value) return
+  try {
+    const logs = await proposalApi.logs(requestId.value)
+    negotiationLogs.value = logs || []
+  } catch (e) {
+    console.error('获取协商记录失败', e)
+  }
+}
+
+async function submitProposal() {
+  const price = Number(proposalPrice.value)
+  if (!price || price <= 0) {
+    uni.showToast({ title: '请输入有效的方案价格', icon: 'none' })
+    return
+  }
+  if (!proposalMessage.value.trim()) {
+    uni.showToast({ title: '请描述方案内容', icon: 'none' })
+    return
+  }
+  submitting.value = true
+  try {
+    await proposalApi.create({
+      requestId: requestId.value,
+      price,
+      message: proposalMessage.value.trim()
+    })
+    submitted.value = true
+    uni.showToast({ title: '方案已提交', icon: 'success' })
+  } catch (e) {
+    uni.showToast({ title: e.message || '提交失败', icon: 'none' })
+  } finally {
+    submitting.value = false
+  }
+}
+
+function getActionLabel(action) {
+  const labels = {
+    create: '出价',
+    counter: '还价',
+    accept: '接受',
+    reject: '拒绝'
+  }
+  return labels[action] || action
+}
+
+function formatTime(timestamp) {
+  if (!timestamp) return ''
+  const d = new Date(timestamp)
+  const pad = (n) => String(n).padStart(2, '0')
+  return `${d.getMonth() + 1}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
+}
+
+// ── Delivery actions ──
+async function loadDelivery() {
+  if (!requestId.value) return
+  try {
+    const data = await deliveryApi.list(requestId.value)
+    if (data && data.length > 0) {
+      deliveryData.value = data[0]
+      deliveryContent.value = data[0].textContent || ''
+      deliverySubmitted.value = true
+    }
+  } catch (e) {
+    // Delivery may not exist yet — that's fine
+  }
+}
+
+async function submitDelivery() {
+  const content = deliveryContent.value.trim()
+  if (!content || delivering.value) return
+  delivering.value = true
+  try {
+    await deliveryApi.create({
+      planRequestId: requestId.value,
+      textContent: content
+    })
+    deliverySubmitted.value = true
+    uni.showToast({ title: '交付成功', icon: 'success' })
+  } catch (e) {
+    uni.showToast({ title: e.message || '交付失败', icon: 'none' })
+  } finally {
+    delivering.value = false
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.page-monitor {
+  display: flex;
+  flex-direction: column;
+  height: 100vh;
+  overflow: hidden;
+  background: linear-gradient(180deg, #0c0a1a 0%, #1a1232 100%);
+}
+
+/* ── Header ── */
+.monitor-header {
+  flex-shrink: 0;
+  padding: 16px 16px 12px;
+}
+.header-info {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+.header-user {
+  font-size: 17px;
+  font-weight: 700;
+  color: rgba(255, 255, 255, 0.9);
+}
+.header-subtitle {
+  font-size: 12px;
+  color: rgba(255, 255, 255, 0.4);
+}
+
+/* ── Loading Skeleton ── */
+.loading-msgs {
+  flex: 1;
+  overflow-y: auto;
+  padding: 0 16px;
+}
+.skeleton-msg {
+  display: flex;
+  flex-direction: column;
+  margin-bottom: 18px;
+}
+.skeleton-msg.right-msg {
+  align-items: flex-end;
+}
+.skeleton-label {
+  width: 32px;
+  height: 12px;
+  background: rgba(255, 255, 255, 0.06);
+  border-radius: 4px;
+  margin-bottom: 6px;
+  animation: pulse 1.5s ease-in-out infinite;
+}
+.skeleton-bubble {
+  height: 40px;
+  background: rgba(255, 255, 255, 0.04);
+  border-radius: 12px;
+  animation: pulse 1.5s ease-in-out infinite;
+}
+.w-40 { width: 40%; }
+.w-45 { width: 45%; }
+.w-55 { width: 55%; }
+.w-60 { width: 60%; }
+.w-65 { width: 65%; }
+.w-75 { width: 75%; }
+
+@keyframes pulse {
+  0%, 100% { opacity: 0.3; }
+  50% { opacity: 0.6; }
+}
+
+/* ── Chat Box ── */
+.chat-box {
+  flex: 1;
+  overflow-y: auto;
+  margin: 0 16px;
+  background: rgba(255, 255, 255, 0.02);
+  border-radius: 16px;
+  padding: 14px;
+}
+
+/* ── Message Row ── */
+.msg-wrapper {
+  margin-bottom: 16px;
+}
+
+/* System message */
+.msg-system {
+  display: flex;
+  justify-content: center;
+  padding: 6px 0;
+}
+.msg-system-text {
+  font-size: 12px;
+  color: rgba(255, 255, 255, 0.4);
+  font-style: italic;
+  text-align: center;
+}
+
+/* Regular message (left by default) */
+.msg-row {
+  display: flex;
+  flex-direction: column;
+  max-width: 85%;
+}
+.msg-row.user {
+  align-items: flex-end;
+  margin-left: auto;
+}
+.msg-row.practitioner,
+.msg-row.ai {
+  align-items: flex-start;
+  margin-right: auto;
+}
+
+/* Message label */
+.msg-label {
+  font-size: 11px;
+  margin-bottom: 4px;
+  padding-left: 4px;
+}
+.label-ai {
+  color: rgba(255, 255, 255, 0.35);
+}
+.label-practitioner {
+  color: rgba(16, 185, 129, 0.7);
+}
+
+/* Message bubble */
+.msg-bubble {
+  padding: 10px 14px;
+  border-radius: 14px;
+  font-size: 14px;
+  line-height: 1.5;
+  word-break: break-word;
+}
+
+/* AI bubble: gray, left-aligned */
+.bubble-ai {
+  background: rgba(255, 255, 255, 0.08);
+  color: rgba(255, 255, 255, 0.85);
+}
+
+/* User bubble: dark blue, right-aligned */
+.bubble-user {
+  background: #1E3A5F;
+  color: rgba(255, 255, 255, 0.9);
+}
+
+/* Practitioner bubble: green, left-aligned */
+.bubble-practitioner {
+  background: rgba(16, 185, 129, 0.15);
+  border: 1px solid rgba(16, 185, 129, 0.3);
+  color: rgba(255, 255, 255, 0.9);
+}
+
+/* ── Empty state ── */
+.empty-chat {
+  display: flex;
+  justify-content: center;
+  padding: 60px 20px;
+}
+.empty-chat-text {
+  font-size: 14px;
+  color: rgba(255, 255, 255, 0.3);
+}
+
+/* ── Bottom bar ── */
+.bottom-bar {
+  flex-shrink: 0;
+  padding: 12px 16px calc(env(safe-area-inset-bottom) + 12px);
+  border-top: 1px solid rgba(255, 255, 255, 0.06);
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+/* Action buttons base */
+.action-btn {
+  height: 44px;
+  border: none;
+  border-radius: 12px;
+  font-size: 15px;
+  font-weight: 600;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  line-height: 1;
+}
+
+/* Intervene button: green gradient */
+.intervene-btn {
+  width: 100%;
+  background: linear-gradient(135deg, #10b981, #059669);
+  color: #fff;
+}
+.intervene-btn[disabled] {
+  opacity: 0.5;
+}
+
+/* Send button */
+.send-btn {
+  flex-shrink: 0;
+  padding: 0 24px;
+  background: linear-gradient(135deg, #10b981, #059669);
+  color: #fff;
+}
+.send-btn[disabled] {
+  opacity: 0.4;
+}
+
+/* Exit button: red outline */
+.exit-btn {
+  width: 100%;
+  background: transparent;
+  border: 1px solid #ef4444;
+  color: #ef4444;
+}
+
+/* Input row */
+.input-row {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+}
+.chat-input {
+  flex: 1;
+  height: 42px;
+  border: 1px solid rgba(255, 255, 255, 0.1);
+  border-radius: 10px;
+  padding: 0 14px;
+  font-size: 14px;
+  color: rgba(255, 255, 255, 0.85);
+  background: rgba(255, 255, 255, 0.04);
+}
+
+/* ── Negotiation Logs Section ── */
+.negotiation-section {
+  flex-shrink: 0;
+  margin: 0 16px 8px;
+  padding: 14px;
+  background: rgba(255, 255, 255, 0.02);
+  border-radius: 16px;
+  max-height: 200px;
+  overflow-y: auto;
+}
+.negotiation-title {
+  font-size: 14px;
+  font-weight: 600;
+  color: rgba(255, 255, 255, 0.8);
+  margin-bottom: 10px;
+}
+.negotiation-entry {
+  padding: 10px 0;
+  border-bottom: 1px solid rgba(255, 255, 255, 0.04);
+}
+.negotiation-entry:last-child {
+  border-bottom: none;
+}
+.negotiation-entry-header {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 4px;
+}
+.negotiation-entry-action {
+  font-size: 13px;
+  font-weight: 600;
+  color: rgba(255, 255, 255, 0.7);
+}
+.negotiation-entry-time {
+  font-size: 11px;
+  color: rgba(255, 255, 255, 0.3);
+}
+.negotiation-entry-price {
+  margin-bottom: 4px;
+}
+.price-old {
+  font-size: 14px;
+  color: rgba(255, 255, 255, 0.4);
+  text-decoration: line-through;
+}
+.price-arrow {
+  font-size: 14px;
+  color: rgba(255, 255, 255, 0.3);
+}
+.price-new {
+  font-size: 15px;
+  font-weight: 700;
+  color: #fbbf24;
+}
+.negotiation-entry-msg {
+  font-size: 12px;
+  color: rgba(255, 255, 255, 0.5);
+  display: block;
+}
+
+/* ── Proposal Form ── */
+.proposal-form-title {
+  font-size: 16px;
+  font-weight: 700;
+  color: rgba(255, 255, 255, 0.9);
+  margin-bottom: 12px;
+  text-align: center;
+}
+.proposal-price-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 10px;
+}
+.proposal-label {
+  font-size: 14px;
+  color: rgba(255, 255, 255, 0.6);
+  flex-shrink: 0;
+}
+.proposal-input {
+  flex: 1;
+  height: 42px;
+  border: 1px solid rgba(255, 255, 255, 0.1);
+  border-radius: 10px;
+  padding: 0 14px;
+  font-size: 14px;
+  color: rgba(255, 255, 255, 0.85);
+  background: rgba(255, 255, 255, 0.04);
+}
+.proposal-price-input {
+  text-align: right;
+}
+.proposal-unit {
+  font-size: 14px;
+  color: rgba(255, 255, 255, 0.5);
+  flex-shrink: 0;
+}
+.proposal-textarea {
+  width: 100%;
+  min-height: 80px;
+  border: 1px solid rgba(255, 255, 255, 0.1);
+  border-radius: 10px;
+  padding: 10px 14px;
+  font-size: 14px;
+  color: rgba(255, 255, 255, 0.85);
+  background: rgba(255, 255, 255, 0.04);
+  margin-bottom: 10px;
+  box-sizing: border-box;
+}
+.proposal-submit-btn {
+  width: 100%;
+  background: linear-gradient(135deg, #a78bfa, #7c3aed);
+  color: #fff;
+}
+.proposal-submit-btn[disabled] {
+  opacity: 0.5;
+}
+
+/* ── Submitted Status Bar ── */
+.status-bar-submitted {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  padding: 12px 16px;
+  background: rgba(16, 185, 129, 0.1);
+  border-radius: 12px;
+}
+.status-icon {
+  font-size: 16px;
+}
+.status-text {
+  font-size: 14px;
+  color: #10b981;
+  font-weight: 500;
+}
+
+/* ── Delivered Status Bar ── */
+.status-bar-delivered {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  padding: 12px 16px;
+  background: rgba(16, 185, 129, 0.1);
+  border-radius: 12px;
+}
+
+/* ── Delivery Form ── */
+.delivery-form-title {
+  font-size: 16px;
+  font-weight: 700;
+  color: rgba(255, 255, 255, 0.9);
+  margin-bottom: 12px;
+  text-align: center;
+}
+.delivery-textarea {
+  width: 100%;
+  min-height: 100px;
+  border: 1px solid rgba(255, 255, 255, 0.1);
+  border-radius: 10px;
+  padding: 10px 14px;
+  font-size: 14px;
+  color: rgba(255, 255, 255, 0.85);
+  background: rgba(255, 255, 255, 0.04);
+  margin-bottom: 10px;
+  box-sizing: border-box;
+}
+.delivery-submit-btn {
+  width: 100%;
+  background: linear-gradient(135deg, #f59e0b, #d97706);
+  color: #fff;
+}
+.delivery-submit-btn[disabled] {
+  opacity: 0.5;
+}
+</style>

+ 502 - 0
client/pages/plan/workbench.vue

@@ -0,0 +1,502 @@
+<template>
+  <view class="page-workbench">
+    <!-- Role gate: only practitioners -->
+    <view v-if="!isPractitioner" class="gate-state">
+      <text class="gate-icon">🔒</text>
+      <text class="gate-text">仅能量师可访问此页面</text>
+      <button class="gate-back-btn" @click="goBack">返回</button>
+    </view>
+
+    <template v-else>
+      <!-- Loading state: skeleton cards -->
+      <view v-if="loading" class="loading-container">
+        <view v-for="n in 4" :key="n" class="skeleton-card">
+          <view class="skeleton-row">
+            <view class="skeleton-line w-40"></view>
+            <view class="skeleton-line w-20"></view>
+          </view>
+          <view class="skeleton-line w-90"></view>
+          <view class="skeleton-line w-60"></view>
+          <view class="skeleton-row">
+            <view class="skeleton-line w-30"></view>
+            <view class="skeleton-line w-16"></view>
+          </view>
+        </view>
+      </view>
+
+      <template v-else>
+        <!-- Stats header: 2x2 grid -->
+        <view class="stats-grid">
+          <view class="stat-item pending-stat">
+            <text class="stat-value">{{ stats.pending }}</text>
+            <text class="stat-label">待处理</text>
+          </view>
+          <view class="stat-item accepted-stat">
+            <text class="stat-value">{{ stats.accepted }}</text>
+            <text class="stat-label">已接受</text>
+          </view>
+          <view class="stat-item negotiating-stat">
+            <text class="stat-value">{{ stats.negotiating }}</text>
+            <text class="stat-label">协商中</text>
+          </view>
+          <view class="stat-item completed-stat">
+            <text class="stat-value">{{ stats.completed }}</text>
+            <text class="stat-label">已完成</text>
+          </view>
+        </view>
+
+        <!-- Section title -->
+        <view class="section-header">
+          <text class="section-title">方案需求</text>
+        </view>
+
+        <!-- Empty state -->
+        <view v-if="requests.length === 0" class="empty-state">
+          <text class="empty-icon">📋</text>
+          <text class="empty-text">暂无方案需求</text>
+        </view>
+
+        <!-- Request list -->
+        <view v-for="(req, idx) in requests" :key="req.id || idx" class="request-card">
+          <!-- Card top: userId + requestType -->
+          <view class="card-header">
+            <text class="user-info">用户 {{ req.userId }}</text>
+            <text class="request-type-tag">{{ requestTypeLabel(req.requestType) }}</text>
+          </view>
+
+          <!-- Description (truncated 2 lines) -->
+          <text class="card-desc">{{ req.description }}</text>
+
+          <!-- Card bottom: budget + date + status -->
+          <view class="card-meta">
+            <view class="meta-left">
+              <text class="budget-range">{{ req.budgetRange }}</text>
+              <text class="date-text">{{ formatDate(req.createdAt) }}</text>
+            </view>
+            <text class="status-badge" :class="'badge-' + req.status">
+              {{ statusLabel(req.status) }}
+            </text>
+          </view>
+
+          <!-- Action buttons by status -->
+          <view v-if="req.status === 'pending'" class="action-row">
+            <button class="btn btn-accept" :disabled="acceptingId === req.id" @click="handleAccept(req)">
+              {{ acceptingId === req.id ? '处理中...' : '接受' }}
+            </button>
+            <button class="btn btn-reject" :disabled="rejectingId === req.id" @click="handleReject(req)">
+              {{ rejectingId === req.id ? '处理中...' : '拒绝' }}
+            </button>
+          </view>
+          <view v-else-if="req.status === 'accepted'" class="action-row">
+            <button class="btn btn-negotiate" disabled>去协商</button>
+          </view>
+        </view>
+      </template>
+    </template>
+  </view>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import { onPullDownRefresh } from '@dcloudio/uni-app'
+import { useUserStore } from '@/stores/user'
+import { workbenchApi } from '@/utils/api'
+
+const userStore = useUserStore()
+const requests = ref([])
+const loading = ref(true)
+const error = ref('')
+const acceptingId = ref(null)
+const rejectingId = ref(null)
+
+// Role gate: only practitioners (vipType === 'practitioner')
+const isPractitioner = computed(() => userStore.vipType === 'practitioner')
+
+// Derived stats from request list
+const stats = computed(() => {
+  const list = requests.value
+  return {
+    pending: list.filter(r => r.status === 'pending').length,
+    accepted: list.filter(r => r.status === 'accepted').length,
+    negotiating: list.filter(r => r.status === 'negotiating').length,
+    completed: list.filter(r => r.status === 'completed').length
+  }
+})
+
+// Request type labels
+const requestTypeLabels = {
+  academic: '学业方向',
+  career: '职业规划',
+  parent_child: '亲子关系',
+  other: '其他',
+  // Fallback for direct Chinese values
+  '学业方向': '学业方向',
+  '职业规划': '职业规划',
+  '亲子关系': '亲子关系',
+  '其他': '其他'
+}
+
+function requestTypeLabel(type) {
+  return requestTypeLabels[type] || type || '其他'
+}
+
+// Status labels (Chinese)
+const statusLabels = {
+  pending: '待处理',
+  accepted: '已接受',
+  negotiating: '协商中',
+  completed: '已完成',
+  cancelled: '已取消',
+  rejected: '已拒绝'
+}
+
+function statusLabel(status) {
+  return statusLabels[status] || status || '未知'
+}
+
+// Lifecycle: fetch on mount
+onMounted(async () => {
+  if (isPractitioner.value) {
+    await fetchData()
+  } else {
+    loading.value = false
+  }
+})
+
+// Pull-to-refresh
+onPullDownRefresh(async () => {
+  if (isPractitioner.value) {
+    await fetchData()
+  }
+  uni.stopPullDownRefresh()
+})
+
+async function fetchData() {
+  error.value = ''
+  loading.value = true
+  try {
+    const data = await workbenchApi.list()
+    requests.value = data || []
+  } catch (e) {
+    error.value = '网络错误,请下拉刷新重试'
+    uni.showToast({ title: '加载失败', icon: 'none' })
+  } finally {
+    loading.value = false
+  }
+}
+
+async function handleAccept(req) {
+  acceptingId.value = req.id
+  try {
+    await workbenchApi.accept(req.id)
+    uni.showToast({ title: '已接受', icon: 'success' })
+    await fetchData()
+  } catch (e) {
+    uni.showToast({ title: e.message || '操作失败', icon: 'none' })
+  } finally {
+    acceptingId.value = null
+  }
+}
+
+async function handleReject(req) {
+  uni.showModal({
+    title: '确认拒绝',
+    content: '确定要拒绝此方案需求吗?',
+    success: async (res) => {
+      if (res.confirm) {
+        rejectingId.value = req.id
+        try {
+          await workbenchApi.reject(req.id)
+          uni.showToast({ title: '已拒绝', icon: 'success' })
+          await fetchData()
+        } catch (e) {
+          uni.showToast({ title: e.message || '操作失败', icon: 'none' })
+        } finally {
+          rejectingId.value = null
+        }
+      }
+    }
+  })
+}
+
+function goBack() {
+  uni.navigateBack()
+}
+
+function formatDate(dateStr) {
+  if (!dateStr) return ''
+  const d = new Date(dateStr)
+  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
+}
+</script>
+
+<style scoped lang="scss">
+.page-workbench {
+  min-height: 100vh;
+  padding: 16px;
+  background: linear-gradient(180deg, #0c0a1a 0%, #1a1232 100%);
+}
+
+/* ===== Role Gate ===== */
+.gate-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 120px 20px;
+  text-align: center;
+}
+.gate-icon {
+  font-size: 48px;
+  display: block;
+  margin-bottom: 16px;
+}
+.gate-text {
+  font-size: 15px;
+  color: rgba(255,255,255,0.6);
+  margin-bottom: 24px;
+  display: block;
+}
+.gate-back-btn {
+  width: 160px;
+  padding: 12px 0;
+  background: linear-gradient(135deg, #f59e0b, #d97706);
+  color: #fff;
+  border: none;
+  border-radius: 12px;
+  font-size: 15px;
+  font-weight: 600;
+}
+
+/* ===== Loading / Skeleton ===== */
+.loading-container {
+  padding-top: 8px;
+}
+.skeleton-card {
+  background: rgba(255,255,255,0.02);
+  border-radius: 16px;
+  padding: 16px;
+  margin-bottom: 14px;
+}
+.skeleton-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 12px;
+}
+.skeleton-line {
+  height: 14px;
+  background: rgba(255,255,255,0.06);
+  border-radius: 6px;
+  margin-bottom: 10px;
+  animation: pulse 1.5s ease-in-out infinite;
+}
+.skeleton-line:last-child {
+  margin-bottom: 0;
+}
+.w-16 { width: 16%; }
+.w-20 { width: 20%; }
+.w-30 { width: 30%; }
+.w-40 { width: 40%; }
+.w-60 { width: 60%; }
+.w-80 { width: 80%; }
+.w-90 { width: 90%; }
+
+@keyframes pulse {
+  0%, 100% { opacity: 0.3; }
+  50% { opacity: 0.6; }
+}
+
+/* ===== Stats Grid ===== */
+.stats-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 10px;
+  margin-bottom: 20px;
+}
+.stat-item {
+  background: rgba(255,255,255,0.02);
+  border-radius: 14px;
+  padding: 16px 12px;
+  text-align: center;
+  border: 1px solid rgba(255,255,255,0.04);
+}
+.stat-value {
+  font-size: 28px;
+  font-weight: 700;
+  display: block;
+  margin-bottom: 4px;
+}
+.stat-label {
+  font-size: 12px;
+  color: rgba(255,255,255,0.45);
+  display: block;
+}
+.pending-stat .stat-value { color: #f59e0b; }
+.accepted-stat .stat-value { color: #10b981; }
+.negotiating-stat .stat-value { color: #3b82f6; }
+.completed-stat .stat-value { color: #6b7280; }
+
+/* ===== Section Header ===== */
+.section-header {
+  margin-bottom: 14px;
+}
+.section-title {
+  font-size: 18px;
+  font-weight: 700;
+  color: rgba(255,255,255,0.9);
+}
+
+/* ===== Empty State ===== */
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 60px 20px;
+  text-align: center;
+}
+.empty-icon {
+  font-size: 40px;
+  display: block;
+  margin-bottom: 12px;
+}
+.empty-text {
+  font-size: 14px;
+  color: rgba(255,255,255,0.4);
+  display: block;
+}
+
+/* ===== Request Card ===== */
+.request-card {
+  background: rgba(255,255,255,0.02);
+  border-radius: 16px;
+  padding: 16px;
+  margin-bottom: 14px;
+}
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 10px;
+}
+.user-info {
+  font-size: 14px;
+  font-weight: 600;
+  color: rgba(255,255,255,0.85);
+}
+.request-type-tag {
+  font-size: 11px;
+  font-weight: 500;
+  color: #f59e0b;
+  background: rgba(245, 158, 11, 0.12);
+  padding: 3px 10px;
+  border-radius: 999px;
+}
+.card-desc {
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  font-size: 14px;
+  line-height: 1.5;
+  color: rgba(255,255,255,0.6);
+  margin-bottom: 12px;
+}
+.card-meta {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.meta-left {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+.budget-range {
+  font-size: 13px;
+  font-weight: 500;
+  color: rgba(255,255,255,0.7);
+}
+.date-text {
+  font-size: 11px;
+  color: rgba(255,255,255,0.35);
+}
+
+/* ===== Status Badge ===== */
+.status-badge {
+  font-size: 11px;
+  font-weight: 500;
+  padding: 3px 12px;
+  border-radius: 999px;
+  flex-shrink: 0;
+}
+.badge-pending {
+  background: rgba(245, 158, 11, 0.15);
+  color: #f59e0b;
+}
+.badge-accepted {
+  background: rgba(16, 185, 129, 0.15);
+  color: #10b981;
+}
+.badge-negotiating {
+  background: rgba(59, 130, 246, 0.15);
+  color: #3b82f6;
+}
+.badge-completed {
+  background: rgba(107, 114, 128, 0.15);
+  color: #9ca3af;
+}
+.badge-cancelled {
+  background: rgba(239, 68, 68, 0.15);
+  color: #ef4444;
+}
+.badge-rejected {
+  background: rgba(107, 114, 128, 0.15);
+  color: #9ca3af;
+}
+
+/* ===== Action Buttons ===== */
+.action-row {
+  display: flex;
+  gap: 10px;
+  margin-top: 14px;
+  padding-top: 14px;
+  border-top: 1px solid rgba(255,255,255,0.04);
+}
+.btn {
+  flex: 1;
+  height: 40px;
+  border: none;
+  border-radius: 10px;
+  font-size: 14px;
+  font-weight: 600;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  line-height: 1;
+}
+.btn-accept {
+  background: #10b981;
+  color: #fff;
+}
+.btn-accept[disabled] {
+  opacity: 0.5;
+}
+.btn-reject {
+  background: transparent;
+  border: 1px solid #ef4444;
+  color: #ef4444;
+}
+.btn-reject[disabled] {
+  opacity: 0.5;
+}
+.btn-negotiate {
+  background: rgba(59, 130, 246, 0.1);
+  border: 1px solid rgba(59, 130, 246, 0.3);
+  color: #3b82f6;
+}
+.btn-negotiate[disabled] {
+  opacity: 0.4;
+}
+</style>

+ 32 - 0
client/utils/api.js

@@ -85,6 +85,25 @@ export const pricingApi = {
   upgrade: () => requestInstance.post('/pricing/upgrade', {})
 }
 
+export const planApi = {
+  apply: (data) => requestInstance.post('/plan/apply', data),
+  myRequests: () => requestInstance.post('/plan/requests/my', {})
+}
+
+export const workbenchApi = {
+  list: () => requestInstance.post('/plan/requests/incoming', {}),
+  accept: (requestId) => requestInstance.post('/plan/requests/accept', { requestId }),
+  reject: (requestId) => requestInstance.post('/plan/requests/reject', { requestId })
+}
+
+export const proposalApi = {
+  create: (data) => requestInstance.post('/plan/proposals/create', data),
+  counter: (data) => requestInstance.post('/plan/proposals/counter', data),
+  accept: (data) => requestInstance.post('/plan/proposals/accept', data),
+  reject: (data) => requestInstance.post('/plan/proposals/reject', data),
+  logs: (requestId) => requestInstance.post('/plan/proposals/logs', { requestId })
+}
+
 export const profileApi = {
   info: () => requestInstance.post('/profile/info', {}),
   vipStatus: () => requestInstance.post('/profile/vip-status', {}),
@@ -117,6 +136,19 @@ export const withdrawApi = {
   list: () => requestInstance.post('/withdraw/list', {}),
   balance: () => requestInstance.post('/withdraw/balance', {})
 }
+export const interventionApi = {
+  chatList: () => requestInstance.post('/intervention/chat-list', {}),
+  history: (chartRecordId) => requestInstance.post('/intervention/chat-history', { chartRecordId }),
+  start: (chartRecordId) => requestInstance.post('/intervention/start', { chartRecordId }),
+  end: (interventionId) => requestInstance.post('/intervention/end', { interventionId, endedBy: 'practitioner' }),
+  endByUser: (interventionId) => requestInstance.post('/intervention/end', { interventionId, endedBy: 'user' }),
+  send: (data) => requestInstance.post('/intervention/send', data)
+}
+
+export const deliveryApi = {
+  create: (data) => requestInstance.post('/plan/delivery/create', data),
+  list: (planRequestId) => requestInstance.post('/plan/delivery/list', { planRequestId })
+}
 
 export const exportApi = {
   pdf: (recordId) => {