Ver Fonte

Merge remote-tracking branch 'origin/master'

xgb há 5 dias atrás
pai
commit
6ee2fc2d07

+ 0 - 9
src/api/workflow/lobster-admin.js

@@ -71,15 +71,6 @@ export function adminListOptimizations(params) {
   })
 }
 
-// 跨租户查询计费记录
-export function adminListBillingRecords(params) {
-  return request({
-    url: '/workflow/lobster-admin/billing-records',
-    method: 'get',
-    params
-  })
-}
-
 // 跨租户查询语料
 export function adminListSalesCorpus(params) {
   return request({

+ 58 - 0
src/api/workflow/lobster-e2e.js

@@ -0,0 +1,58 @@
+import request from '@/utils/request'
+
+// ======== E2E 测试 ========
+export function runE2e(data) {
+  return request({ url: '/workflow/lobster/e2e/run', method: 'post', data })
+}
+
+export function getE2eReport(runId) {
+  return request({ url: `/workflow/lobster/e2e/report/${runId}`, method: 'get' })
+}
+
+export function listE2eRuns(params) {
+  return request({ url: '/workflow/lobster/e2e/list', method: 'get', params })
+}
+
+export function stepNext(instanceId, data) {
+  return request({ url: `/workflow/lobster-exec/step-next/${instanceId}`, method: 'post', data })
+}
+
+export function multiTurn(data) {
+  return request({ url: '/workflow/lobster/chat/multi-turn', method: 'post', data })
+}
+
+// ======== 测试场景剧本 ========
+export function listScenarios(params) {
+  return request({ url: '/workflow/lobster/scenario/list', method: 'get', params })
+}
+
+export function getScenario(id) {
+  return request({ url: `/workflow/lobster/scenario/${id}`, method: 'get' })
+}
+
+export function saveScenario(data) {
+  return request({ url: '/workflow/lobster/scenario/save', method: 'post', data })
+}
+
+export function deleteScenario(id) {
+  return request({ url: `/workflow/lobster/scenario/${id}`, method: 'delete' })
+}
+
+export function runScenarioNow(id) {
+  return request({ url: `/workflow/lobster/scenario/${id}/run`, method: 'post' })
+}
+
+export function runAllScenarios() {
+  return request({ url: '/workflow/lobster/scenario/run-all', method: 'post' })
+}
+
+// ======== 动态节点学习产物审批 ========
+export function listDynamicImpls(status) {
+  return request({ url: '/workflow/lobster/dynamic-impl/list', method: 'get', params: { status } })
+}
+export function approveDynamicImpl(id) {
+  return request({ url: `/workflow/lobster/dynamic-impl/${id}/approve`, method: 'post' })
+}
+export function rejectDynamicImpl(id, reason) {
+  return request({ url: `/workflow/lobster/dynamic-impl/${id}/reject`, method: 'post', params: { reason } })
+}

+ 23 - 0
src/api/workflow/lobster-token.js

@@ -0,0 +1,23 @@
+import request from '@/utils/request'
+
+// ======== Token消耗统计(仅管理端) ========
+
+// 按日汇总
+export function getTokenDaily(params) {
+  return request({ url: '/workflow/lobster/token-stats/daily', method: 'get', params })
+}
+
+// 按模型汇总
+export function getTokenByModel(params) {
+  return request({ url: '/workflow/lobster/token-stats/model', method: 'get', params })
+}
+
+// 按实例汇总
+export function getTokenByInstance(params) {
+  return request({ url: '/workflow/lobster/token-stats/instance', method: 'get', params })
+}
+
+// Token消耗明细
+export function listTokenRecords(params) {
+  return request({ url: '/workflow/lobster/token-stats/records', method: 'get', params })
+}

+ 54 - 9
src/api/workflow/lobster.js

@@ -230,19 +230,64 @@ export function setOptimizationConfig(data) {
   return request({ url: '/workflow/lobster/optimization/config', method: 'post', data })
 }
 
-// ======== Token计费 ========
-export function getTokenCoefficient() {
-  return request({ url: '/workflow/lobster/billing/token-coefficient', method: 'get' })
+// ======== 模拟对话 ========
+export function simulateChat(data) {
+  return request({ url: '/workflow/simulate', method: 'post', data })
 }
 
-export function updateTokenCoefficient(data) {
-  return request({ url: '/workflow/lobster/billing/token-coefficient', method: 'put', data })
+// ======== AI回复质量评分 ========
+export function listQualityRecords(params) {
+  return request({ url: '/aiChatQuality/list', method: 'get', params })
 }
 
-export function listBillingRecords(params) {
-  return request({ url: '/workflow/lobster/billing/records', method: 'get', params })
+export function getQualityRecord(id) {
+  return request({ url: `/aiChatQuality/${id}`, method: 'get' })
 }
 
-export function getBillingTypes() {
-  return request({ url: '/workflow/lobster/billing/types', method: 'get' })
+export function submitQualityReview(data) {
+  return request({ url: '/aiChatQuality', method: 'post', data })
+}
+
+export function updateQualityReview(data) {
+  return request({ url: '/aiChatQuality', method: 'put', data })
+}
+
+export function deleteQualityRecord(id) {
+  return request({ url: `/aiChatQuality/${id}`, method: 'delete' })
+}
+
+export function getQualityStats(params) {
+  return request({ url: '/aiChatQuality/stats', method: 'get', params })
+}
+
+// ======== 工作流模板获取 ========
+export function getTemplateWithNodes(id) {
+  return request({ url: `/workflow/template/${id}`, method: 'get' })
+}
+
+export function listAllTemplates() {
+  return request({ url: '/workflow/lobster/template/list', method: 'get' })
+}
+
+// ======== 工作流模板与节点CRUD(画布编辑器) ========
+export function listWorkflowTemplates() {
+  return request({ url: '/workflow/lobster/template/list', method: 'get' })
+}
+
+export function getWorkflowNodes(workflowId) {
+  return request({ url: `/workflow/lobster/nodes/${workflowId}`, method: 'get' })
+}
+
+export function saveWorkflowNodes(data) {
+  return request({ url: '/workflow/lobster/nodes/save', method: 'post', data })
+}
+
+/* ========== 渠道聚合聊天 ========== */
+
+export function getChatAggregate(params) {
+  return request({ url: '/workflow/lobster/chat-aggregate', method: 'get', params })
+}
+
+export function getChatMessages(sessionId) {
+  return request({ url: `/workflow/lobster/chat-aggregate/messages/${sessionId}`, method: 'get' })
 }

+ 46 - 0
src/api/workflow/model-route.js

@@ -0,0 +1,46 @@
+import request from '@/utils/request'
+
+// ======== 多模型路由配置(管理端跨租户) ========
+
+// 获取所有模型路由配置(可传companyId筛选租户)
+export function listModelConfigs(params) {
+  return request({
+    url: '/workflow/lobster/model-config/list',
+    method: 'get',
+    params
+  })
+}
+
+// 获取配置类型列表
+export function getModelConfigTypes() {
+  return request({
+    url: '/workflow/lobster/model-config/types',
+    method: 'get'
+  })
+}
+
+// 新增模型路由配置
+export function addModelConfig(data) {
+  return request({
+    url: '/workflow/lobster/model-config',
+    method: 'post',
+    data
+  })
+}
+
+// 更新模型路由配置
+export function updateModelConfig(id, data) {
+  return request({
+    url: '/workflow/lobster/model-config/' + id,
+    method: 'put',
+    data
+  })
+}
+
+// 删除模型路由配置
+export function deleteModelConfig(id) {
+  return request({
+    url: '/workflow/lobster/model-config/' + id,
+    method: 'delete'
+  })
+}

+ 99 - 13
src/router/index.js

@@ -532,15 +532,16 @@ export const constantRoutes = [
       {
         path: 'production-workflow',
         component: ParentView,
-        redirect: '/lobster/production-workflow/canvas',
+        redirect: '/lobster/production-workflow/template',
         name: 'ProductionWorkflow',
         meta: { title: 'AI生产工作流', icon: 'component' },
         children: [
           {
+            // 工作流画布保留路由(编辑入口仍可用),不挂菜单
             path: 'canvas',
             component: () => import('@/views/lobster/workflow-canvas/index'),
             name: 'LobsterCanvas',
-            meta: { title: '工作流画布', icon: 'chart' }
+            meta: { title: '工作流画布', icon: 'chart', hidden: true }
           },
           {
             path: 'template',
@@ -551,16 +552,17 @@ export const constantRoutes = [
         ]
       },
       {
+        // AI 生成工作流保留路由(被模板库或画布跳转使用),不挂菜单
         path: 'workflow-generate',
         component: () => import('@/views/lobster/workflow-generate/index'),
         name: 'LobsterGenerate',
-        meta: { title: 'AI生成工作流', icon: 'build' }
+        meta: { title: 'AI生成工作流', icon: 'build', hidden: true }
       },
       {
         path: 'instance',
         component: () => import('@/views/lobster/instance/index'),
         name: 'LobsterInstance',
-        meta: { title: '实例监控', icon: 'monitor' }
+        meta: { title: '任务执行(实例监控', icon: 'monitor' }
       },
       {
         path: 'optimization',
@@ -581,6 +583,7 @@ export const constantRoutes = [
         meta: { title: '销冠语料学习', icon: 'star' }
       },
       {
+        // 接口注册中心 = 外部工作流(节点中调用的外部 API 注册),统一为「接口注册中心」一处
         path: 'api-registry',
         component: () => import('@/views/lobster/api-registry/index'),
         name: 'LobsterApiRegistry',
@@ -605,16 +608,99 @@ export const constantRoutes = [
         meta: { title: '聚合聊天', icon: 'message' }
       },
       {
-        path: 'model-config',
-        component: () => import('@/views/lobster/model-config/index'),
-        name: 'LobsterModelConfig',
-        meta: { title: '模型配置', icon: 'server' }
+        path: 'channel-plugin',
+        component: () => import('@/views/lobster/channel-plugin/index'),
+        name: 'ChannelPlugin',
+        meta: { title: '渠道插件', icon: 'connection' }
       },
       {
-        path: 'billing',
-        component: () => import('@/views/lobster/billing/index'),
-        name: 'LobsterBilling',
-        meta: { title: 'Token系数管理', icon: 'money' }
+        path: 'chat-test',
+        component: () => import('@/views/lobster/chat-test/index'),
+        name: 'LobsterChatTest',
+        meta: { title: '模拟聊天测试', icon: 'chat-dot-square' }
+      },
+      {
+        path: 'quality-verify',
+        component: () => import('@/views/lobster/quality-verify/index'),
+        name: 'LobsterQualityVerify',
+        meta: { title: '评分准确性验证', icon: 'data-analysis' }
+      },
+      {
+        path: 'test-scenario',
+        component: () => import('@/views/lobster/test-scenario/index'),
+        name: 'LobsterTestScenario',
+        meta: { title: '测试场景管理', icon: 'document-checked' }
+      },
+      {
+        path: 'e2e-history',
+        component: () => import('@/views/lobster/e2e-history/index'),
+        name: 'LobsterE2eHistory',
+        meta: { title: 'E2E运行历史', icon: 'time' }
+      },
+      {
+        path: 'dynamic-impl',
+        component: () => import('@/views/lobster/dynamic-impl/index'),
+        name: 'LobsterDynamicImpl',
+        meta: { title: '动态节点审批', icon: 'connection' }
+      },
+      {
+        path: 'model-route',
+        component: () => import('@/views/lobster/model-route/index'),
+        name: 'LobsterModelRoute',
+        meta: { title: '多模型路由配置', icon: 'guide' }
+      },
+      {
+        path: 'token-stats',
+        component: () => import('@/views/lobster/token-stats/index'),
+        name: 'LobsterTokenStats',
+        meta: { title: 'Token消耗统计', icon: 'coin' }
+      },
+      {
+        path: 'node-detail/:instanceId/:nodeId',
+        component: () => import('@/views/lobster/node-detail/index'),
+        name: 'LobsterNodeDetail',
+        meta: { title: '节点详情', icon: 'document' }
+      },
+      {
+        path: 'profile-config',
+        component: () => import('@/views/lobster/profile-config/index'),
+        name: 'LobsterProfileConfig',
+        meta: { title: '用户画像配置', icon: 'user' }
+      },
+      {
+        path: 'summary-config',
+        component: () => import('@/views/lobster/summary-config/index'),
+        name: 'LobsterSummaryConfig',
+        meta: { title: '摘要生成配置', icon: 'notebook' }
+      },
+      {
+        path: 'dedup-config',
+        component: () => import('@/views/lobster/dedup-config/index'),
+        name: 'LobsterDedupConfig',
+        meta: { title: '消息去重配置', icon: 'filter' }
+      },
+      {
+        path: 'sensitive-words',
+        component: () => import('@/views/lobster/sensitive-words/index'),
+        name: 'LobsterSensitiveWords',
+        meta: { title: '敏感词库', icon: 'warning' }
+      }
+    ]
+  },
+
+  // ======== AI聊天质量评分 ========
+  {
+    path: '/aiChatQuality',
+    component: Layout,
+    name: 'AiChatQuality',
+    meta: { title: 'AI回复质量评分', icon: 'star' },
+    redirect: '/aiChatQuality/index',
+    children: [
+      {
+        path: 'index',
+        component: () => import('@/views/aiChatQuality/index'),
+        name: 'AiChatQualityIndex',
+        meta: { title: '质量评分', icon: 'star' }
       }
     ]
   },
@@ -670,7 +756,7 @@ export const constantRoutes = [
       { path: 'companyMoneyLogs', component: () => import('@/views/company/companyMoneyLogs/index'), name: 'CompMoney', meta: { title: '资金流水', hidden: true } },
       { path: 'companyProfit', component: () => import('@/views/company/companyProfit/index'), name: 'CompProfit', meta: { title: '分账记录', hidden: true } },
       { path: 'aiWorkflow', component: () => import('@/views/company/aiWorkflow/index'), name: 'CompAiWorkflow', meta: { title: 'AI工作流' } },
-      { path: 'aiModel', component: () => import('@/views/company/aiModel/index'), name: 'CompAiModel', meta: { title: 'AI模型管理' } },
+      { path: 'aiModel', component: () => import('@/views/company/aiModel/index'), name: 'CompAiModel', meta: { title: 'AI模型管理', hidden: true } },
       { path: 'workflowLobster', component: () => import('@/views/company/workflowLobster/index'), name: 'CompLobster', meta: { title: '龙虾工作流', hidden: true } }
     ]
   },

+ 313 - 36
src/views/aiChatQuality/index.vue

@@ -1,71 +1,348 @@
 <template>
   <div class="app-container">
+    <!-- 搜索栏 -->
     <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="100px">
-      <el-form-item label="关键词" prop="keyword">
-        <el-input v-model="queryParams.keyword" placeholder="请输入关键词" clearable size="small"
-          @keyup.enter.native="handleQuery" />
+      <el-form-item label="会话ID" prop="sessionId">
+        <el-input v-model="queryParams.sessionId" placeholder="请输入会话ID" clearable size="small" @keyup.enter.native="handleQuery" />
+      </el-form-item>
+      <el-form-item label="评分状态" prop="status">
+        <el-select v-model="queryParams.status" placeholder="全部" clearable size="small">
+          <el-option label="待评分" value="pending" />
+          <el-option label="已评分" value="scored" />
+          <el-option label="已复核" value="reviewed" />
+          <el-option label="有争议" value="disputed" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="日期范围" prop="dateRange">
+        <el-date-picker v-model="queryParams.dateRange" type="daterange" range-separator="至"
+          start-placeholder="开始日期" end-placeholder="结束日期" value-format="yyyy-MM-dd" size="small"
+          style="width:240px" />
       </el-form-item>
       <el-form-item>
         <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
         <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
       </el-form-item>
     </el-form>
+
+    <!-- 统计卡片 -->
+    <el-row :gutter="16" class="mb8">
+      <el-col :span="4">
+        <el-card shadow="hover"><div class="stat-card">
+          <div class="stat-value">{{ stats.total || 0 }}</div><div class="stat-label">总记录</div>
+        </div></el-card>
+      </el-col>
+      <el-col :span="4">
+        <el-card shadow="hover"><div class="stat-card">
+          <div class="stat-value" style="color:#E6A23C">{{ stats.pending || 0 }}</div><div class="stat-label">待评分</div>
+        </div></el-card>
+      </el-col>
+      <el-col :span="4">
+        <el-card shadow="hover"><div class="stat-card">
+          <div class="stat-value" style="color:#67C23A">{{ stats.scored || 0 }}</div><div class="stat-label">已评分</div>
+        </div></el-card>
+      </el-col>
+      <el-col :span="4">
+        <el-card shadow="hover"><div class="stat-card">
+          <div class="stat-value" style="color:#409EFF">{{ stats.avgScore || '-' }}</div><div class="stat-label">平均评分</div>
+        </div></el-card>
+      </el-col>
+      <el-col :span="4">
+        <el-card shadow="hover"><div class="stat-card">
+          <div class="stat-value" style="color:#F56C6C">{{ stats.disputed || 0 }}</div><div class="stat-label">有争议</div>
+        </div></el-card>
+      </el-col>
+    </el-row>
+
+    <!-- 工具栏 -->
     <el-row :gutter="10" class="mb8">
-      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
+      <el-col :span="1.5">
+        <el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd">新增评分</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button type="warning" plain icon="el-icon-s-data" size="mini" @click="handleBatchScore">
+          批量评分
+        </el-button>
+      </el-col>
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
     </el-row>
-    <el-table v-loading="loading" :data="list">
+
+    <!-- 主表格 -->
+    <el-table v-loading="loading" :data="list" @selection-change="handleSelectionChange" border>
       <el-table-column type="selection" width="50" align="center" />
-      <el-table-column label="ID" align="center" prop="id" width="80" />
-      <el-table-column label="名称" align="center" prop="name" />
-      <el-table-column label="创建时间" align="center" prop="createTime" width="160" />
-      <el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="180">
+      <el-table-column label="ID" align="center" prop="id" width="70" />
+      <el-table-column label="会话ID" align="center" prop="sessionId" width="100" show-overflow-tooltip />
+      <el-table-column label="AI回复内容" align="center" prop="aiReply" min-width="200" show-overflow-tooltip />
+      <el-table-column label="准确性" align="center" width="90">
+        <template slot-scope="scope">
+          <el-rate v-model="scope.row.accuracyScore" disabled :max="5" show-score
+            text-color="#ff9900" score-template="{value}" />
+        </template>
+      </el-table-column>
+      <el-table-column label="相关性" align="center" width="90">
+        <template slot-scope="scope">
+          <el-rate v-model="scope.row.relevanceScore" disabled :max="5" show-score
+            text-color="#ff9900" score-template="{value}" />
+        </template>
+      </el-table-column>
+      <el-table-column label="合规性" align="center" width="90">
+        <template slot-scope="scope">
+          <el-rate v-model="scope.row.complianceScore" disabled :max="5" show-score
+            text-color="#ff9900" score-template="{value}" />
+        </template>
+      </el-table-column>
+      <el-table-column label="综合分" align="center" width="80">
+        <template slot-scope="scope">
+          <el-tag :type="getTotalScoreType(scope.row)">{{ formatTotalScore(scope.row) }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="评分状态" align="center" width="90">
+        <template slot-scope="scope">
+          <el-tag :type="scope.row.status==='pending'?'warning':scope.row.status==='scored'?'success':scope.row.status==='disputed'?'danger':'info'" size="small">
+            {{ scope.row.status==='pending'?'待评分':scope.row.status==='scored'?'已评分':scope.row.status==='disputed'?'有争议':'已复核' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="评分人" align="center" prop="reviewer" width="100" />
+      <el-table-column label="评分时间" align="center" prop="reviewTime" width="160" />
+      <el-table-column label="操作" align="center" width="220" fixed="right">
         <template slot-scope="scope">
           <el-button size="mini" type="text" icon="el-icon-view" @click="handleDetail(scope.row)">详情</el-button>
-          <el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)">修改</el-button>
-          <el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)">删除</el-button>
+          <el-button size="mini" type="text" icon="el-icon-edit"
+            @click="handleScore(scope.row)">{{ scope.row.status === 'pending' ? '评分' : '修改' }}</el-button>
+          <el-button size="mini" type="text" icon="el-icon-s-opportunity"
+            @click="handleAppeal(scope.row)" v-if="scope.row.status === 'scored'">申诉</el-button>
+          <el-button size="mini" type="text" icon="el-icon-delete"
+            style="color:#F56C6C" @click="handleDelete(scope.row)">删除</el-button>
         </template>
       </el-table-column>
     </el-table>
-    <pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
+
+    <pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNum"
+      :limit.sync="queryParams.pageSize" @pagination="getList" />
+
+    <!-- 详情弹窗 -->
+    <el-dialog title="评分详情" :visible.sync="detailVisible" width="800px" append-to-body>
+      <template v-if="detail">
+        <el-descriptions :column="2" border size="small">
+          <el-descriptions-item label="ID">{{ detail.id }}</el-descriptions-item>
+          <el-descriptions-item label="会话ID">{{ detail.sessionId }}</el-descriptions-item>
+          <el-descriptions-item label="客户消息" :span="2">
+            <div style="background:#f5f7fa;padding:8px;border-radius:4px">{{ detail.customerMsg }}</div>
+          </el-descriptions-item>
+          <el-descriptions-item label="AI回复" :span="2">
+            <div style="background:#ecf5ff;padding:8px;border-radius:4px">{{ detail.aiReply }}</div>
+          </el-descriptions-item>
+          <el-descriptions-item label="准确性评分">{{ detail.accuracyScore }}/5</el-descriptions-item>
+          <el-descriptions-item label="相关性评分">{{ detail.relevanceScore }}/5</el-descriptions-item>
+          <el-descriptions-item label="合规性评分">{{ detail.complianceScore }}/5</el-descriptions-item>
+          <el-descriptions-item label="综合评分">{{ formatTotalScore(detail) }}</el-descriptions-item>
+          <el-descriptions-item label="评分说明" :span="2">{{ detail.remark || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="评分人">{{ detail.reviewer || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="评分时间">{{ detail.reviewTime || '-' }}</el-descriptions-item>
+        </el-descriptions>
+      </template>
+    </el-dialog>
+
+    <!-- 评分弹窗 -->
+    <el-dialog :title="scoreForm.id ? '修改评分' : 'AI回复质量评分'" :visible.sync="scoreVisible" width="650px" append-to-body>
+      <el-form ref="scoreFormRef" :model="scoreForm" :rules="scoreRules" label-width="100px">
+        <el-form-item label="会话ID" prop="sessionId">
+          <el-input v-model="scoreForm.sessionId" placeholder="请输入会话ID" />
+        </el-form-item>
+        <el-form-item label="客户消息" prop="customerMsg">
+          <el-input v-model="scoreForm.customerMsg" type="textarea" :rows="3" placeholder="请输入客户原始消息" />
+        </el-form-item>
+        <el-form-item label="AI回复" prop="aiReply">
+          <el-input v-model="scoreForm.aiReply" type="textarea" :rows="4" placeholder="请输入AI回复内容" />
+        </el-form-item>
+        <el-divider content-position="left">评分维度</el-divider>
+        <el-form-item label="准确性" prop="accuracyScore">
+          <el-rate v-model="scoreForm.accuracyScore" :max="5" show-score text-color="#ff9900" />
+          <span class="score-desc">AI回复内容是否准确无误</span>
+        </el-form-item>
+        <el-form-item label="相关性" prop="relevanceScore">
+          <el-rate v-model="scoreForm.relevanceScore" :max="5" show-score text-color="#ff9900" />
+          <span class="score-desc">回复是否与客户问题相关</span>
+        </el-form-item>
+        <el-form-item label="合规性" prop="complianceScore">
+          <el-rate v-model="scoreForm.complianceScore" :max="5" show-score text-color="#ff9900" />
+          <span class="score-desc">回复是否符合行业合规要求</span>
+        </el-form-item>
+        <el-form-item label="语气语调">
+          <el-rate v-model="scoreForm.toneScore" :max="5" show-score text-color="#ff9900" />
+          <span class="score-desc">回复的语气是否友好得体</span>
+        </el-form-item>
+        <el-form-item label="评分说明" prop="remark">
+          <el-input v-model="scoreForm.remark" type="textarea" :rows="3" placeholder="评分理由及改进建议" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="scoreVisible = false">取消</el-button>
+        <el-button type="primary" @click="submitScore" :loading="submitting">提交评分</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 申诉弹窗 -->
+    <el-dialog title="评分申诉" :visible.sync="appealVisible" width="500px" append-to-body>
+      <el-form :model="appealForm" label-width="80px">
+        <el-form-item label="申诉原因">
+          <el-input v-model="appealForm.reason" type="textarea" :rows="4" placeholder="请说明申诉原因..." />
+        </el-form-item>
+        <el-form-item label="建议评分">
+          <el-rate v-model="appealForm.suggestedScore" :max="5" show-score text-color="#ff9900" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="appealVisible = false">取消</el-button>
+        <el-button type="primary" @click="submitAppeal">提交申诉</el-button>
+      </div>
+    </el-dialog>
   </div>
 </template>
 
 <script>
+import { listQualityRecords, getQualityRecord, submitQualityReview,
+  updateQualityReview, deleteQualityRecord, getQualityStats } from '@/api/workflow/lobster'
+
 export default {
   name: 'AiChatQuality',
   data() {
     return {
-      loading: false,
-      showSearch: true,
-      total: 0,
-      list: [],
-      queryParams: {
-        pageNum: 1,
-        pageSize: 10,
-        keyword: null
-      }
+      loading: false, showSearch: true, submitting: false,
+      total: 0, list: [], selectedIds: [],
+      stats: { total: 0, pending: 0, scored: 0, avgScore: 0, disputed: 0 },
+      queryParams: { pageNum: 1, pageSize: 10, sessionId: null, status: null, dateRange: null },
+      detailVisible: false, detail: null,
+      scoreVisible: false,
+      scoreForm: {
+        id: null, sessionId: '', customerMsg: '', aiReply: '',
+        accuracyScore: 0, relevanceScore: 0, complianceScore: 0,
+        toneScore: 0, remark: ''
+      },
+      scoreRules: {
+        sessionId: [{ required: true, message: '会话ID不能为空', trigger: 'blur' }],
+        customerMsg: [{ required: true, message: '客户消息不能为空', trigger: 'blur' }],
+        aiReply: [{ required: true, message: 'AI回复不能为空', trigger: 'blur' }],
+        accuracyScore: [{ required: true, message: '请评分', trigger: 'change' }],
+        relevanceScore: [{ required: true, message: '请评分', trigger: 'change' }],
+        complianceScore: [{ required: true, message: '请评分', trigger: 'change' }]
+      },
+      appealVisible: false, appealForm: { recordId: null, reason: '', suggestedScore: 0 }
     }
   },
-  created() {
-    this.getList()
-  },
+  created() { this.getList(); this.getStats() },
   methods: {
-    getList() {
+    async getList() {
       this.loading = true
-      // TODO: 接入实际API
-      this.loading = false
+      try {
+        const res = await listQualityRecords(this.queryParams)
+        const data = res.data || {}
+        this.list = data.rows || data.list || []
+        this.total = data.total || 0
+      } catch (e) {
+        this.list = []
+        this.total = 0
+      } finally { this.loading = false }
+    },
+    async getStats() {
+      try {
+        const res = await getQualityStats()
+        this.stats = res.data || {}
+      } catch (e) { /* 统计API暂未就绪时静默处理 */ }
     },
-    handleQuery() {
-      this.queryParams.pageNum = 1
-      this.getList()
+    handleQuery() { this.queryParams.pageNum = 1; this.getList() },
+    resetQuery() { this.$refs.queryForm.resetFields(); this.handleQuery() },
+    handleSelectionChange(rows) { this.selectedIds = rows.map(r => r.id) },
+
+    formatTotalScore(row) {
+      const scores = [row.accuracyScore, row.relevanceScore, row.complianceScore].filter(s => s > 0)
+      if (scores.length === 0) return '-'
+      return (scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(1)
+    },
+    getTotalScoreType(row) {
+      const s = parseFloat(this.formatTotalScore(row))
+      if (isNaN(s)) return 'info'
+      if (s >= 4) return 'success'
+      if (s >= 3) return 'warning'
+      return 'danger'
+    },
+
+    handleAdd() {
+      this.scoreForm = { id: null, sessionId: '', customerMsg: '', aiReply: '',
+        accuracyScore: 3, relevanceScore: 3, complianceScore: 3, toneScore: 3, remark: '' }
+      this.scoreVisible = true
+    },
+    handleScore(row) {
+      this.scoreForm = { ...row, id: row.id }
+      this.scoreVisible = true
     },
-    resetQuery() {
-      this.resetForm('queryForm')
-      this.handleQuery()
+    async submitScore() {
+      this.$refs.scoreFormRef.validate(async valid => {
+        if (!valid) return
+        this.submitting = true
+        try {
+          const data = { ...this.scoreForm }
+          if (this.scoreForm.id) {
+            await updateQualityReview(data)
+            this.$message.success('评分已更新')
+          } else {
+            await submitQualityReview(data)
+            this.$message.success('评分已提交')
+          }
+          this.scoreVisible = false
+          this.getList()
+          this.getStats()
+        } catch (e) {
+          this.$message.error('操作失败:' + (e.message || ''))
+        } finally { this.submitting = false }
+      })
     },
-    handleDetail(row) { this.$message.info('详情功能待实现') },
-    handleUpdate(row) { this.$message.info('修改功能待实现') },
-    handleDelete(row) { this.$message.confirm('确认删除?', '警告', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }).then(() => { this.$message.success('删除成功') }) }
+
+    async handleDetail(row) {
+      try {
+        const res = await getQualityRecord(row.id)
+        this.detail = res.data || res
+        this.detailVisible = true
+      } catch (e) {
+        this.$message.error('获取详情失败')
+      }
+    },
+
+    handleAppeal(row) {
+      this.appealForm.recordId = row.id
+      this.appealVisible = true
+    },
+    submitAppeal() {
+      this.$message.success('申诉已提交,等待审核')
+      this.appealVisible = false
+    },
+
+    handleDelete(row) {
+      this.$confirm('确认删除该评分记录?', '警告', { type: 'warning' }).then(async () => {
+        try {
+          await deleteQualityRecord(row.id)
+          this.$message.success('删除成功')
+          this.getList()
+          this.getStats()
+        } catch (e) { this.$message.error('删除失败') }
+      }).catch(() => {})
+    },
+
+    handleBatchScore() {
+      if (this.selectedIds.length === 0) {
+        this.$message.warning('请至少选择一条记录')
+        return
+      }
+      this.$message.info(`已选择 ${this.selectedIds.length} 条记录,请进入评分弹窗逐条处理`)
+      this.handleAdd()
+    }
   }
 }
 </script>
+
+<style scoped>
+.stat-card { text-align: center; padding: 10px 0; }
+.stat-value { font-size: 24px; font-weight: bold; color: #409EFF; }
+.stat-label { font-size: 12px; color: #909399; margin-top: 4px; }
+.score-desc { font-size: 12px; color: #909399; margin-left: 12px; }
+</style>

+ 206 - 107
src/views/company/workflowLobster/execMonitor.vue

@@ -1,81 +1,144 @@
 <template>
   <div class="workflow-exec">
+    <!-- 统计卡片 -->
+    <el-row :gutter="16" class="mb8">
+      <el-col :span="4">
+        <el-card shadow="hover"><div class="stat-card">
+          <div class="stat-value" style="color:#67C23A">{{ runStats.running }}</div>
+          <div class="stat-label">运行中</div>
+        </div></el-card>
+      </el-col>
+      <el-col :span="4">
+        <el-card shadow="hover"><div class="stat-card">
+          <div class="stat-value" style="color:#E6A23C">{{ runStats.paused }}</div>
+          <div class="stat-label">已暂停</div>
+        </div></el-card>
+      </el-col>
+      <el-col :span="4">
+        <el-card shadow="hover"><div class="stat-card">
+          <div class="stat-value">{{ runStats.completed }}</div>
+          <div class="stat-label">已完成</div>
+        </div></el-card>
+      </el-col>
+      <el-col :span="4">
+        <el-card shadow="hover"><div class="stat-card">
+          <div class="stat-value" style="color:#F56C6C">{{ runStats.terminated }}</div>
+          <div class="stat-label">已终止</div>
+        </div></el-card>
+      </el-col>
+    </el-row>
+
+    <!-- 实例列表 -->
     <el-card>
       <div slot="header" class="card-header">
-        <span>工作流执行监控</span>
+        <span><i class="el-icon-monitor"></i> 工作流实例监控</span>
         <div>
-          <el-select v-model="searchWorkflowId" placeholder="选择工作流" clearable style="width: 200px; margin-right: 10px" @change="getList">
+          <el-select v-model="searchWorkflowId" placeholder="选择工作流" clearable
+            style="width: 200px; margin-right: 10px" @change="getList">
             <el-option v-for="w in workflowList" :key="w.id" :label="w.templateName" :value="w.id" />
           </el-select>
-          <el-button type="primary" @click="getList">
-            <i class="el-icon-search"></i>查询
-          </el-button>
+          <el-button type="primary" @click="getList"><i class="el-icon-search"></i>查询</el-button>
         </div>
       </div>
 
-      <el-table :data="tableData" v-loading="loading" border>
-        <el-table-column type="index" label="序号" width="60" align="center" />
-        <el-table-column prop="instanceName" label="实例名称" min-width="180" show-overflow-tooltip />
-        <el-table-column prop="contactName" label="联系人" width="120" />
-        <el-table-column prop="status" label="状态" width="100" align="center">
+      <el-table :data="tableData" v-loading="loading" border @row-click="selectInstance">
+        <el-table-column type="index" label="#" width="50" align="center" />
+        <el-table-column prop="instanceName" label="实例名称" min-width="160" show-overflow-tooltip />
+        <el-table-column prop="contactName" label="联系人" width="100" />
+        <el-table-column prop="status" label="状态" width="90" align="center">
           <template slot-scope="{ row }">
             <el-tag :type="getStatusType(row.status)">{{ getStatusText(row.status) }}</el-tag>
           </template>
         </el-table-column>
-        <el-table-column prop="currentNodeName" label="当前节点" width="150" show-overflow-tooltip />
-        <el-table-column label="进度" width="120" align="center">
-          <template slot-scope="{ row }">
-            {{ row.completedNodes || 0 }}/{{ row.totalNodes || 0 }}
-          </template>
+        <el-table-column prop="currentNodeName" label="当前节点" width="120" show-overflow-tooltip />
+        <el-table-column label="进度" width="90" align="center">
+          <template slot-scope="{ row }">{{ row.completedNodes || 0 }}/{{ row.totalNodes || 0 }}</template>
         </el-table-column>
-        <el-table-column prop="startTime" label="开始时间" width="170" />
-        <el-table-column prop="lastActivityTime" label="最后活动" width="170" />
-        <el-table-column label="操作" width="280" fixed="right">
+        <el-table-column prop="startTime" label="开始时间" width="160" />
+        <el-table-column label="操作" width="320" fixed="right">
           <template slot-scope="{ row }">
-            <el-button type="primary" size="mini" @click="handleViewLogs(row)">日志</el-button>
-            <el-button v-if="row.status === 'running'" type="warning" size="mini" @click="handlePause(row)">暂停</el-button>
-            <el-button v-if="row.status === 'paused'" type="success" size="mini" @click="handleResume(row)">恢复</el-button>
-            <el-button v-if="row.status !== 'completed' && row.status !== 'terminated'" type="danger" size="mini" @click="handleTerminate(row)">终止</el-button>
+            <el-button type="primary" size="mini" @click.stop="handleViewLogs(row)">日志</el-button>
+            <el-button type="success" size="mini" @click.stop="openDebug(row)" icon="el-icon-edit">调试</el-button>
+            <el-button v-if="row.status==='running'" type="warning" size="mini" @click.stop="handlePause(row)">暂停</el-button>
+            <el-button v-if="row.status==='paused'" type="success" size="mini" @click.stop="handleResume(row)">恢复</el-button>
+            <el-button v-if="row.status!=='completed'&&row.status!=='terminated'" type="danger" size="mini" @click.stop="handleTerminate(row)">终止</el-button>
           </template>
         </el-table-column>
       </el-table>
     </el-card>
 
+    <!-- 日志弹窗 -->
     <el-dialog title="节点执行日志" :visible.sync="logDialogVisible" width="900px">
       <el-timeline>
-        <el-timeline-item
-          v-for="log in nodeLogs"
-          :key="log.id"
-          :timestamp="log.createTime"
-          :type="getLogTimelineType(log.status)"
-          placement="top"
-        >
+        <el-timeline-item v-for="log in nodeLogs" :key="log.id" :timestamp="log.createTime"
+          :type="getLogTimelineType(log.status)" placement="top">
           <el-card shadow="hover">
-            <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px">
-              <div>
-                <el-tag size="small">{{ log.nodeName }}</el-tag>
-                <el-tag size="small" type="info" style="margin-left: 5px">{{ log.nodeType }}</el-tag>
-                <el-tag size="small" :type="log.status === 'sent' ? 'success' : 'primary'" style="margin-left: 5px">
-                  {{ log.status === 'sent' ? '发送' : '接收' }}
-                </el-tag>
-              </div>
-              <span v-if="log.durationMs" style="color: #909399; font-size: 12px">{{ log.durationMs }}ms</span>
-            </div>
-            <div v-if="log.outputContent" style="margin-bottom: 5px">
-              <strong>发送内容:</strong>
-              <p style="margin: 5px 0; padding: 8px; background: #f5f7fa; border-radius: 4px; white-space: pre-wrap">{{ log.outputContent }}</p>
-            </div>
-            <div v-if="log.inputContent">
-              <strong>接收内容:</strong>
-              <p style="margin: 5px 0; padding: 8px; background: #ecf5ff; border-radius: 4px; white-space: pre-wrap">{{ log.inputContent }}</p>
-            </div>
-            <div v-if="log.errorMessage" style="color: #f56c6c; margin-top: 5px">
-              <strong>错误:</strong>{{ log.errorMessage }}
+            <div class="log-header-row">
+              <span><el-tag size="small">{{ log.nodeName }}</el-tag>
+                <el-tag size="small" type="info" style="margin-left:5px">{{ log.nodeType }}</el-tag>
+                <el-tag size="small" :type="log.status==='sent'?'success':'primary'" style="margin-left:5px">
+                  {{ log.status === 'sent' ? '发送' : '接收' }}</el-tag>
+              </span>
+              <span v-if="log.durationMs" style="color:#909399;font-size:12px">{{ log.durationMs }}ms</span>
             </div>
+            <div v-if="log.outputContent" class="log-section"><strong>发送:</strong>
+              <p class="log-box">{{ log.outputContent }}</p></div>
+            <div v-if="log.inputContent" class="log-section"><strong>接收:</strong>
+              <p class="log-box input">{{ log.inputContent }}</p></div>
+            <div v-if="log.errorMessage" style="color:#f56c6c;margin-top:5px"><strong>错误:</strong>{{ log.errorMessage }}</div>
           </el-card>
         </el-timeline-item>
       </el-timeline>
-      <div v-if="!nodeLogs.length" style="text-align: center; color: #909399; padding: 20px">暂无执行日志</div>
+      <div v-if="!nodeLogs.length" class="empty-tip">暂无执行日志</div>
+    </el-dialog>
+
+    <!-- 逐节点调试弹窗 -->
+    <el-dialog title="逐节点调试测试" :visible.sync="debugVisible" width="950px" append-to-body>
+      <template v-if="debugInstance">
+        <el-alert :title="'当前调试实例: ' + (debugInstance.instanceName || debugInstance.id)" type="info" :closable="false" show-icon />
+
+        <el-row :gutter="16" style="margin-top: 12px">
+          <!-- 左侧节点图 -->
+          <el-col :span="12">
+            <el-card shadow="never">
+              <div slot="header"><span><i class="el-icon-share"></i> 节点流转 ({{ debugNodeLogs.length }}个)</span></div>
+              <el-steps direction="vertical" :active="debugCurrentStep" finish-status="success" process-status="process">
+                <el-step v-for="(n, idx) in debugNodeLogs" :key="idx"
+                  :title="n.nodeName"
+                  :description="'状态: ' + (n.status || 'pending') + (n.duration ? ' (' + n.duration + 'ms)' : '')" />
+              </el-steps>
+              <el-empty v-if="debugNodeLogs.length === 0" description="加载节点日志中..." />
+            </el-card>
+          </el-col>
+
+          <!-- 右侧调试面板 -->
+          <el-col :span="12">
+            <el-card shadow="never" style="margin-bottom: 12px">
+              <div slot="header"><span><i class="el-icon-edit-outline"></i> 发送模拟消息</span></div>
+              <el-input v-model="debugInput" type="textarea" :rows="3"
+                placeholder="输入模拟客户回复消息,点击执行下一步..." />
+              <div style="margin-top: 8px; display: flex; gap: 8px">
+                <el-button type="primary" size="small" @click="executeDebugStep" :loading="debugStepLoading">
+                  <i class="el-icon-d-arrow-right"></i> 执行下一步
+                </el-button>
+                <el-button size="small" @click="refreshDebugLogs">
+                  <i class="el-icon-refresh"></i> 刷新日志
+                </el-button>
+              </div>
+            </el-card>
+
+            <el-card shadow="never">
+              <div slot="header"><span><i class="el-icon-info"></i> 执行结果</span></div>
+              <div v-if="debugOutput" class="debug-output">
+                <div class="debug-label">AI响应:</div>
+                <div class="debug-content">{{ debugOutput }}</div>
+              </div>
+              <div v-if="debugError" class="debug-error">{{ debugError }}</div>
+              <el-empty v-if="!debugOutput && !debugError" description="点击执行下一步查看结果" :image-size="60" />
+            </el-card>
+          </el-col>
+        </el-row>
+      </template>
     </el-dialog>
   </div>
 </template>
@@ -88,86 +151,110 @@ export default {
   name: 'WorkflowExecMonitor',
   data() {
     return {
-      loading: false,
-      tableData: [],
-      searchWorkflowId: null,
-      workflowList: [],
-      logDialogVisible: false,
-      nodeLogs: []
+      loading: false, tableData: [], searchWorkflowId: null, workflowList: [],
+      logDialogVisible: false, nodeLogs: [],
+      runStats: { running: 0, paused: 0, completed: 0, terminated: 0 },
+      // 逐节点调试
+      debugVisible: false, debugInstance: null, debugNodeLogs: [],
+      debugCurrentStep: 0, debugInput: '', debugOutput: '', debugError: '',
+      debugStepLoading: false
     }
   },
-  created() {
-    this.getWorkflowList()
-    this.getList()
-  },
+  created() { this.getWorkflowList(); this.getList() },
   methods: {
     async getWorkflowList() {
-      try {
-        const res = await listAllWorkflowTemplates()
-        this.workflowList = res.data || []
-      } catch (e) {
-        this.workflowList = []
-      }
+      try { const r = await listAllWorkflowTemplates(); this.workflowList = r.data || [] } catch (e) { this.workflowList = [] }
     },
     async getList() {
       this.loading = true
       try {
         const res = await workflowExecApi.listInstances(this.searchWorkflowId)
         this.tableData = res.data || []
-      } catch (error) {
-        this.$message.error('获取列表失败')
-      } finally {
-        this.loading = false
-      }
+        this.computeStats()
+      } catch (e) { this.$message.error('获取列表失败') }
+      finally { this.loading = false }
     },
+    computeStats() {
+      this.runStats = { running: 0, paused: 0, completed: 0, terminated: 0 }
+      this.tableData.forEach(r => {
+        if (r.status === 'running') this.runStats.running++
+        else if (r.status === 'paused') this.runStats.paused++
+        else if (r.status === 'completed') this.runStats.completed++
+        else if (r.status === 'terminated') this.runStats.terminated++
+      })
+    },
+    selectInstance(row) {},
     async handleViewLogs(row) {
-      try {
-        const res = await workflowExecApi.getNodeLogs(row.id)
-        this.nodeLogs = res.data || []
-        this.logDialogVisible = true
-      } catch (error) {
-        this.$message.error('获取日志失败')
-      }
+      try { const r = await workflowExecApi.getNodeLogs(row.id); this.nodeLogs = r.data || []; this.logDialogVisible = true }
+      catch (e) { this.$message.error('获取日志失败') }
     },
     async handlePause(row) {
-      try {
-        await workflowExecApi.pause(row.id)
-        this.$message.success('已暂停')
-        this.getList()
-      } catch (error) {
-        this.$message.error('操作失败')
-      }
+      try { await workflowExecApi.pause(row.id); this.$message.success('已暂停'); this.getList() }
+      catch (e) { this.$message.error('操作失败') }
     },
     async handleResume(row) {
-      try {
-        await workflowExecApi.resume(row.id)
-        this.$message.success('已恢复')
-        this.getList()
-      } catch (error) {
-        this.$message.error('操作失败')
-      }
+      try { await workflowExecApi.resume(row.id); this.$message.success('已恢复'); this.getList() }
+      catch (e) { this.$message.error('操作失败') }
     },
     async handleTerminate(row) {
       try {
-        await this.$confirm('确定终止该工作流实例?', '提示', { type: 'warning' })
+        await this.$confirm('确定终止该实例?', '提示', { type: 'warning' })
         await workflowExecApi.terminate(row.id, '手动终止')
-        this.$message.success('已终止')
-        this.getList()
-      } catch (error) {
-        if (error !== 'cancel') this.$message.error('操作失败')
-      }
+        this.$message.success('已终止'); this.getList()
+      } catch (e) { if (e !== 'cancel') this.$message.error('操作失败') }
     },
-    getStatusType(status) {
-      const map = { running: 'success', paused: 'warning', completed: 'info', terminated: 'danger', pending: '' }
-      return map[status] || ''
+
+    // ====== 逐节点调试 ======
+    async openDebug(row) {
+      this.debugInstance = row
+      this.debugVisible = true
+      this.debugCurrentStep = row.completedNodes || 0
+      this.debugOutput = ''
+      this.debugError = ''
+      this.debugInput = ''
+      await this.refreshDebugLogs()
     },
-    getStatusText(status) {
-      const map = { running: '运行中', paused: '已暂停', completed: '已完成', terminated: '已终止', pending: '待启动' }
-      return map[status] || status
+    async refreshDebugLogs() {
+      if (!this.debugInstance) return
+      try {
+        const r = await workflowExecApi.getNodeLogs(this.debugInstance.id)
+        this.debugNodeLogs = r.data || []
+        this.debugCurrentStep = (this.debugInstance.completedNodes || 0)
+      } catch (e) { this.debugNodeLogs = [] }
     },
-    getLogTimelineType(status) {
-      return status === 'sent' ? 'success' : 'primary'
-    }
+    async executeDebugStep() {
+      if (!this.debugInstance) return
+      this.debugStepLoading = true
+      this.debugOutput = ''
+      this.debugError = ''
+      try {
+        const res = await workflowExecApi.executeNextNode(this.debugInstance.id, this.debugInput || null)
+        const data = res.data || res
+        this.debugOutput = typeof data === 'string' ? data : (data.reply || data.output || JSON.stringify(data))
+        this.$message.success('节点执行完成')
+        await this.refreshDebugLogs()
+        // 更新实例状态
+        try {
+          const stateRes = await workflowExecApi.getInstanceState(this.debugInstance.id)
+          if (stateRes.data) {
+            this.debugInstance = { ...this.debugInstance, ...stateRes.data }
+          }
+        } catch (e) { /* 静默 */ }
+      } catch (e) {
+        this.debugError = '执行失败: ' + (e.message || '未知错误')
+        this.$message.error(this.debugError)
+      } finally { this.debugStepLoading = false }
+    },
+
+    getStatusType(s) {
+      const m = { running: 'success', paused: 'warning', completed: 'info', terminated: 'danger', pending: '' }
+      return m[s] || ''
+    },
+    getStatusText(s) {
+      const m = { running: '运行中', paused: '已暂停', completed: '已完成', terminated: '已终止', pending: '待启动' }
+      return m[s] || s
+    },
+    getLogTimelineType(s) { return s === 'sent' ? 'success' : 'primary' }
   }
 }
 </script>
@@ -175,4 +262,16 @@ export default {
 <style scoped>
 .card-header { display: flex; justify-content: space-between; align-items: center; }
 .card-header div { display: flex; gap: 10px; align-items: center; }
+.stat-card { text-align: center; padding: 6px 0; }
+.stat-value { font-size: 22px; font-weight: bold; color: #409EFF; }
+.stat-label { font-size: 11px; color: #909399; margin-top: 2px; }
+.log-header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
+.log-section { margin-bottom: 4px; }
+.log-box { margin: 4px 0; padding: 8px; background: #f5f7fa; border-radius: 4px; white-space: pre-wrap; font-size: 12px; }
+.log-box.input { background: #ecf5ff; }
+.empty-tip { text-align: center; color: #909399; padding: 20px; }
+.debug-output { margin-top: 8px; }
+.debug-label { font-size: 12px; color: #909399; margin-bottom: 4px; }
+.debug-content { padding: 10px; background: #ecf5ff; border-radius: 6px; white-space: pre-wrap; font-size: 13px; }
+.debug-error { padding: 10px; color: #F56C6C; background: #fef0f0; border-radius: 6px; margin-top: 6px; font-size: 12px; }
 </style>

+ 47 - 1
src/views/company/workflowLobster/visual.vue

@@ -401,6 +401,16 @@
           <el-form-item label="条件表达式" v-if="selectedNode.nodeType === 3">
             <el-input v-model="selectedNode.conditionExpr" type="textarea" :rows="3" :disabled="isPublished" />
           </el-form-item>
+          <el-form-item label="AI 场景" v-if="aiSceneOptions.length">
+            <el-select v-model="selectedNode.sceneCode" :disabled="isPublished" placeholder="按场景选模型" clearable size="small" style="width:100%">
+              <el-option v-for="s in aiSceneOptions" :key="s.sceneCode" :label="`${s.sceneName} (${s.sceneCode})`" :value="s.sceneCode" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="指定模型" v-if="aiModelOptions.length">
+            <el-select v-model="selectedNode.modelName" :disabled="isPublished" placeholder="覆盖场景,强制指定" clearable size="small" style="width:100%">
+              <el-option v-for="m in aiModelOptions" :key="m.modelName" :label="`${m.modelName} [${m.providerName||m.provider}]`" :value="m.modelName" />
+            </el-select>
+          </el-form-item>
           <el-form-item label="节点配置">
             <el-input v-model="selectedNode.nodeConfig" type="textarea" :rows="4" placeholder="JSON格式" :disabled="isPublished" />
           </el-form-item>
@@ -473,6 +483,7 @@
 
 <script>
 import { getWorkflowTemplateDetail, saveWorkflowCanvas } from '@/api/company/workflowLobster'
+import request from '@/utils/request'
 
 export default {
   name: 'WorkflowCanvasEditor',
@@ -518,6 +529,9 @@ export default {
       selectedNode: null,
       selectedEdge: null,
       routeStatus: null,
+      // AI 场景与模型列表(用于节点配置抽屉的选择器)
+      aiSceneOptions: [],
+      aiModelOptions: [],
       // 全屏状态
       isFullscreen: false,
       // 网格配置
@@ -563,7 +577,9 @@ export default {
           name: '客户节点',
           types: [
             { type: 11, label: '文档节点', icon: 'el-icon-document', color: '#6366f1' },
-            { type: 12, label: '用户节点', icon: 'el-icon-user-solid', color: '#8b5cf6' }
+            { type: 12, label: '用户节点', icon: 'el-icon-user-solid', color: '#8b5cf6' },
+            { type: 13, label: '复购节点', icon: 'el-icon-refresh-right', color: '#14b8a6' },
+            { type: 14, label: '智能API节点', icon: 'el-icon-magic-stick', color: '#3b82f6' }
           ]
         },
         {
@@ -605,6 +621,8 @@ export default {
     })
     document.addEventListener('fullscreenchange', this.onFullscreenChange)
     document.addEventListener('webkitfullscreenchange', this.onFullscreenChange)
+    // 加载 AI 场景与模型供节点配置选择
+    this.loadAiSceneAndModelOptions()
   },
   beforeDestroy() {
     // 清理事件监听器,防止内存泄漏
@@ -778,6 +796,32 @@ export default {
         document.body.classList.remove('is-visual-fullscreen')
       }
     },
+    // 加载 AI 场景与模型清单(用于节点配置抽屉的下拉选择器)
+    async loadAiSceneAndModelOptions() {
+      try {
+        const scenesResp = await request({ url: '/admin/aiScene/list', method: 'get', params: { pageNum: 1, pageSize: 100 } })
+        const sceneRows = (scenesResp && (scenesResp.rows || scenesResp.data || scenesResp.list)) || []
+        this.aiSceneOptions = sceneRows.map(s => ({
+          sceneCode: s.sceneCode || s.code,
+          sceneName: s.sceneName || s.name
+        })).filter(s => s.sceneCode)
+      } catch (e) {
+        console.warn('[visual.vue] 加载 AI 场景失败', e && e.message)
+        this.aiSceneOptions = []
+      }
+      try {
+        const modelsResp = await request({ url: '/admin/aiModel/list', method: 'get', params: { pageNum: 1, pageSize: 200, enabled: 1 } })
+        const modelRows = (modelsResp && (modelsResp.rows || modelsResp.data || modelsResp.list)) || []
+        this.aiModelOptions = modelRows.map(m => ({
+          modelName: m.modelName,
+          provider: m.provider,
+          providerName: m.providerName
+        })).filter(m => m.modelName)
+      } catch (e) {
+        console.warn('[visual.vue] 加载 AI 模型失败', e && e.message)
+        this.aiModelOptions = []
+      }
+    },
     // 时间线节点类型颜色
     getNodeTimelineType(type) {
       const map = {
@@ -943,6 +987,8 @@ export default {
         sendTime: '',
         conditionExpr: '',
         nextNodeCode: '',
+        sceneCode: '',
+        modelName: '',
         delFlag: 0
       }
       this.nodes.push(newNode)

+ 7 - 2
src/views/hisStore/storeProduct/index.vue

@@ -1727,10 +1727,13 @@ export default {
           if(this.form.specType === 1 && this.manyFormValidate.length===0){
             return this.$message.warning('请点击生成规格!');
           }
+          // 组装companyIds
+          if (this.form.companyIds != null && this.form.companyIds != undefined) {
+            this.form.companyIds = this.form.companyIds.join(',');
+          }
           // 小程序
           const params = {
             ...this.form,
-            companyIds: this.form.companyIds && this.form.companyIds.join(','),
             appIds: this.appIds.join(',') // 数组转字符串
           };
           addOrEdit(params).then(response => {
@@ -1744,7 +1747,9 @@ export default {
               this.open = false;
               this.getList();
             }
-          }).catch(() => {});
+          }).catch(error => {
+            this.$message.error('请求失败: ' + error.message)
+          });
         }
       });
     },

+ 0 - 51
src/views/lobster/billing/index.vue

@@ -1,51 +0,0 @@
-<template>
-  <div class="app-container">
-    <!-- Token系数设置 -->
-    <el-card shadow="hover">
-      <div slot="header"><span>Token系数设置</span></div>
-      <el-descriptions :column="2" border>
-        <el-descriptions-item label="当前系数">{{ coefficientInfo.tokenCoefficient || '1.00' }}</el-descriptions-item>
-        <el-descriptions-item label="说明">{{ coefficientInfo.description || '客户看到消耗量 = 实际Token数 × 系数' }}</el-descriptions-item>
-      </el-descriptions>
-      <el-divider></el-divider>
-      <el-form :model="coefForm" label-width="100px">
-        <el-form-item label="新系数值">
-          <el-input-number v-model="coefForm.coefficient" :min="0.01" :max="100" :step="0.1" :precision="2" />
-          <span style="color:#909399;margin-left:10px">范围: 0.01 ~ 100</span>
-        </el-form-item>
-        <el-form-item>
-          <el-button type="primary" @click="handleUpdateCoef" v-hasPermi="['workflow:lobster:edit']">更新系数</el-button>
-        </el-form-item>
-      </el-form>
-    </el-card>
-    <!-- 消费记录 -->
-    <el-card shadow="hover" style="margin-top:16px">
-      <div slot="header"><span>Token消费记录</span></div>
-      <el-table border v-loading="loading" :data="records">
-        <el-table-column label="ID" align="center" prop="id" width="60" />
-        <el-table-column label="消费类型" align="center" prop="consumeType" width="100" />
-        <el-table-column label="金额" align="center" prop="amount" width="100" />
-        <el-table-column label="备注" align="center" prop="remark" show-overflow-tooltip />
-        <el-table-column label="状态" align="center" prop="status" width="80" />
-        <el-table-column label="消费时间" align="center" prop="consumeTime" width="160" />
-      </el-table>
-      <pagination v-show="recordTotal>0" :total="recordTotal" :page.sync="recordParams.page" :limit.sync="recordParams.size" @pagination="getRecords" />
-    </el-card>
-  </div>
-</template>
-<script>
-import { getTokenCoefficient, updateTokenCoefficient, listBillingRecords } from '@/api/workflow/lobster'
-export default {
-  name: 'LobsterBilling',
-  data() {
-    return { loading: false, coefficientInfo: {}, coefForm: { coefficient: 1 }, records: [], recordTotal: 0,
-      recordParams: { page: 1, size: 10 } }
-  },
-  created() { this.getCoefInfo(); this.getRecords() },
-  methods: {
-    getCoefInfo() { getTokenCoefficient().then(res => { this.coefficientInfo = res.data || {}; this.coefForm.coefficient = parseFloat(this.coefficientInfo.tokenCoefficient || 1) }) },
-    getRecords() { this.loading = true; listBillingRecords(this.recordParams).then(res => { let d = res.data || {}; this.records = d.list || []; this.recordTotal = d.total || 0; this.loading = false }).catch(() => { this.loading = false }) },
-    handleUpdateCoef() { this.$confirm('确定更新Token系数为 ' + this.coefForm.coefficient + ' 吗?', '提示', { type: 'warning' }).then(() => { updateTokenCoefficient({ coefficient: this.coefForm.coefficient }).then(res => { this.$message.success(res.msg || '更新成功'); this.getCoefInfo() }) }) }
-  }
-}
-</script>

+ 106 - 0
src/views/lobster/channel-plugin/index.vue

@@ -0,0 +1,106 @@
+<template>
+  <div class="app-container channel-plugin-page">
+    <div class="page-header">
+      <h3>渠道插件管理</h3>
+      <span class="page-desc">龙虾引擎即插即用:启用渠道 + 填写API凭证即可接入新的IM平台</span>
+    </div>
+    <el-table :data="plugins" v-loading="loading" border stripe size="small">
+      <el-table-column prop="channelType" label="渠道编码" width="100" />
+      <el-table-column label="渠道名称" width="140">
+        <template #default="{row}">
+          <i :class="row.icon" style="margin-right:4px" /> {{ row.displayName }}
+        </template>
+      </el-table-column>
+      <el-table-column label="SDK状态" width="100">
+        <template #default="{row}">
+          <el-tag :type="row.hasMessageChannel ? 'success' : 'warning'" size="small">
+            {{ row.hasMessageChannel ? '已接入' : '待接入' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="启用" width="80">
+        <template #default="{row}">
+          <el-switch :value="row.enabled === 1 || row.enabled === true"
+            @change="v => toggle(row, v)" :disabled="!row.hasMessageChannel" size="small" />
+        </template>
+      </el-table-column>
+      <el-table-column label="凭证配置" min-width="200">
+        <template #default="{row}">
+          <template v-if="row.channelType === 'QW' || row.channelType === 'WX' || row.channelType === 'IM'">
+            <el-tag type="info" size="small">内置免配置</el-tag>
+          </template>
+          <template v-else>
+            <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
+              <template v-if="row.channelType === 'WHATSAPP'">
+                <el-input v-model="waConfig.phoneNumberId" placeholder="Phone Number ID" size="mini" style="width:150px" />
+                <el-input v-model="waConfig.token" placeholder="Token" size="mini" style="width:150px" type="password" show-password />
+              </template>
+              <template v-else>
+                <el-input v-model="otherConfig[row.channelType]" placeholder="API Key / Webhook URL" size="mini" style="width:200px" />
+              </template>
+              <el-button size="mini" type="primary" @click="saveConfig(row)">保存</el-button>
+            </div>
+          </template>
+        </template>
+      </el-table-column>
+      <el-table-column label="测试" width="100">
+        <template #default="{row}">
+          <el-button size="mini" :type="row.hasMessageChannel && (row.enabled===1||row.enabled===true) ? 'success' : 'info'"
+            @click="test(row)" :disabled="!(row.enabled===1||row.enabled===true)">测试连接</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+  </div>
+</template>
+
+<script>
+import request from '@/utils/request'
+
+export default {
+  name: 'ChannelPlugin',
+  data() {
+    return {
+      plugins: [],
+      loading: false,
+      waConfig: { phoneNumberId: '', token: '' },
+      otherConfig: {}
+    }
+  },
+  mounted() { this.loadPlugins() },
+  methods: {
+    loadPlugins() {
+      this.loading = true
+      request({ url: '/workflow/lobster/channel-plugin/list', method: 'get' }).then(res => {
+        this.plugins = res.data || []
+      }).finally(() => { this.loading = false })
+    },
+    toggle(row, v) {
+      request({ url: `/workflow/lobster/channel-plugin/enable/${row.channelType}`, method: 'post', params: { enabled: v } }).then(() => {
+        this.$message.success(v ? `${row.displayName} 已启用` : `${row.displayName} 已禁用`)
+        row.enabled = v ? 1 : 0
+      })
+    },
+    saveConfig(row) {
+      let config = {}
+      if (row.channelType === 'WHATSAPP') config = { ...this.waConfig }
+      else config = { apiKey: this.otherConfig[row.channelType] }
+      request({ url: `/workflow/lobster/channel-plugin/config/${row.channelType}`, method: 'post', data: config }).then(() => {
+        this.$message.success('配置已保存')
+      })
+    },
+    test(row) {
+      request({ url: `/workflow/lobster/channel-plugin/test/${row.channelType}`, method: 'post' }).then(res => {
+        const r = res.data
+        this.$message({ type: r.ok ? 'success' : 'error', message: r.reason || (r.ok ? '连接正常' : '连接失败') })
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.channel-plugin-page { padding: 0; }
+.page-header { margin-bottom: 16px; }
+.page-header h3 { margin: 0 0 4px; }
+.page-desc { font-size: 12px; color: #999; }
+</style>

+ 157 - 30
src/views/lobster/chat-aggregate/index.vue

@@ -1,46 +1,173 @@
 <template>
-  <div class="chat-aggregate-wrapper">
-    <iframe
-      v-if="chatUrl"
-      :src="chatUrl"
-      class="chat-iframe"
-      frameborder="0"
-    />
+  <div class="app-container lobster-chat-aggregate">
+    <!-- 渠道筛选 tabs -->
+    <el-tabs v-model="activeChannel" @tab-click="onTabClick" type="card">
+      <el-tab-pane label="全部" name="" />
+      <el-tab-pane v-for="ch in channels" :key="ch.channelType" :name="ch.channelType">
+        <span slot="label">{{ ch.displayName }} <el-badge :value="ch.sessionCount" :hidden="!ch.sessionCount" /></span>
+      </el-tab-pane>
+    </el-tabs>
+
+    <!-- 搜索 -->
+    <div class="filter-bar">
+      <el-input v-model="keyword" placeholder="搜索用户ID或消息内容" prefix-icon="el-icon-search" clearable
+        style="width:300px; margin-right:12px" @clear="loadSessions" @keyup.enter.native="loadSessions" size="small" />
+      <el-button type="primary" size="small" icon="el-icon-search" @click="loadSessions">搜索</el-button>
+      <el-button size="small" icon="el-icon-refresh" @click="loadSessions">刷新</el-button>
+    </div>
+
+    <el-row :gutter="12" style="margin-top:12px">
+      <!-- 会话列表 -->
+      <el-col :span="8">
+        <div class="session-list card-style">
+          <div class="list-header">会话列表 ({{ sessions.length }})</div>
+          <div v-loading="loading" class="list-body">
+            <div v-if="sessions.length === 0 && !loading" class="empty">暂无会话</div>
+            <div v-for="s in sessions" :key="s.session_id"
+              :class="['session-item', { active: currentSession && currentSession.session_id === s.session_id }]"
+              @click="selectSession(s)">
+              <div class="session-top">
+                <span :class="['channel-tag', s.channel_type || 'QW']">
+                  {{ channelLabel(s.channel_type) }}
+                </span>
+                <span class="time">{{ s.last_msg_time || s.create_time | fmtTime }}</span>
+              </div>
+              <div class="user-id">{{ s.external_user_id || s.channel_source_id || s.user_id || '-' }}</div>
+              <div class="last-msg">{{ s.last_msg || '-' | truncate(40) }}</div>
+            </div>
+          </div>
+        </div>
+      </el-col>
+
+      <!-- 消息详情 -->
+      <el-col :span="16">
+        <div v-if="!currentSession" class="card-style empty-chat">请选择左侧会话查看详情</div>
+        <div v-else class="card-style chat-detail">
+          <div class="detail-header">
+            <span>{{ channelLabel(currentSession.channel_type) }} 会话详情</span>
+            <span class="header-info">用户: {{ currentSession.external_user_id || currentSession.channel_source_id || currentSession.user_id }}</span>
+            <span class="header-info">Session: {{ currentSession.session_id }}</span>
+            <span class="header-status" :class="currentSession.status===1?'online':''">
+              {{ currentSession.status===1 ? '在线' : '已结束' }}
+            </span>
+          </div>
+          <div v-loading="msgLoading" class="msg-body">
+            <div v-if="messages.length === 0 && !msgLoading" class="empty">暂无消息记录</div>
+            <div v-for="m in messages" :key="m.msg_id"
+              :class="['msg-item', m.send_type === 1 ? 'from-customer' : 'from-ai']">
+              <div class="msg-meta">
+                <span class="msg-sender">{{ m.send_type === 1 ? '客户' : 'AI' }}</span>
+                <span v-if="m.channel_type" :class="['channel-tag-small', m.channel_type]">{{ m.channel_type }}</span>
+                <span class="msg-time">{{ m.create_time | fmtTime }}</span>
+              </div>
+              <div class="msg-content">{{ m.content }}</div>
+            </div>
+          </div>
+        </div>
+      </el-col>
+    </el-row>
   </div>
 </template>
 
 <script>
+import { getChatAggregate, getChatMessages } from '@/api/workflow/lobster'
+
+const CHANNEL_LABELS = {
+  QW: '企微', WX: '个微', IM: 'IM', WHATSAPP: 'WhatsApp',
+  LINE: 'Line', TELEGRAM: 'Telegram', APP_IM: 'APP',
+  TMALL: '天猫', JD: '京东', DOUYIN_DM: '抖音私信',
+  KUAISHOU_DM: '快手', XIAOHONGSHU_DM: '小红书', DOUYIN_EC: '抖音电商'
+}
+
 export default {
-  name: 'ChatAggregate',
+  name: 'LobsterChatAggregate',
+  filters: {
+    fmtTime(v) { return v ? v.replace('T',' ').substring(0,19) : '-' },
+    truncate(v, len) { return v && v.length > len ? v.substring(0,len)+'...' : (v||'-') }
+  },
   data() {
     return {
-      chatUrl: ''
+      activeChannel: '',
+      keyword: '',
+      channels: [],
+      sessions: [],
+      currentSession: null,
+      messages: [],
+      loading: false,
+      msgLoading: false
     }
   },
-  created() {
-    const tenantCode = localStorage.getItem('tenantCode') || '';
-    const baseApi = process.env.VUE_APP_BASE_API || '/prod-api';
-    const params = new URLSearchParams();
-    if (tenantCode) params.set('tenantCode', tenantCode);
-    params.set('baseApi', baseApi);
-    params.set('frontendType', 'admin');
-    this.chatUrl = `/chat-aggregate.html?${params.toString()}`;
+  mounted() { this.loadSessions() },
+  methods: {
+    channelLabel(type) { return CHANNEL_LABELS[type] || type || '未知' },
+    loadSessions() {
+      this.loading = true
+      const params = {}
+      if (this.activeChannel) params.channelType = this.activeChannel
+      if (this.keyword) params.keyword = this.keyword
+      getChatAggregate(params).then(res => {
+        this.sessions = (res.data && Array.isArray(res.data)) ? res.data : (res.data && res.data.records || [])
+        this.buildChannelTabs()
+      }).finally(() => { this.loading = false })
+    },
+    buildChannelTabs() {
+      const map = {}
+      this.sessions.forEach(s => {
+        const ct = s.channel_type || 'QW'
+        if (!map[ct]) map[ct] = { channelType: ct, displayName: this.channelLabel(ct), sessionCount: 0 }
+        map[ct].sessionCount++
+      })
+      this.channels = Object.values(map)
+    },
+    selectSession(s) {
+      this.currentSession = s
+      this.messages = []
+      this.msgLoading = true
+      getChatMessages(s.session_id).then(res => {
+        this.messages = res.data || []
+      }).finally(() => { this.msgLoading = false })
+    },
+    onTabClick() { this.loadSessions() }
   }
 }
 </script>
 
 <style scoped>
-.chat-aggregate-wrapper {
-  position: absolute;
-  top: 0;
-  left: 0;
-  right: 0;
-  bottom: 0;
-  overflow: hidden;
-}
-.chat-iframe {
-  width: 100%;
-  height: 100%;
-  border: none;
-}
+.lobster-chat-aggregate { padding:0; }
+.filter-bar { margin-bottom:8px; }
+.card-style { border:1px solid #ebeef5; border-radius:4px; background:#fff; }
+.session-list { height:calc(100vh - 200px); overflow:hidden; display:flex; flex-direction:column; }
+.list-header { padding:12px 16px; border-bottom:1px solid #ebeef5; font-weight:600; font-size:14px; background:#fafafa; }
+.list-body { flex:1; overflow-y:auto; padding:4px 0; }
+.session-item { padding:10px 16px; cursor:pointer; border-bottom:1px solid #f0f2f5; transition:background .2s; }
+.session-item:hover { background:#f5f7fa; }
+.session-item.active { background:#ecf5ff; border-left:3px solid #409eff; }
+.session-top { display:flex; justify-content:space-between; align-items:center; margin-bottom:4px; }
+.channel-tag { font-size:11px; padding:1px 6px; border-radius:2px; color:#fff; font-weight:500; }
+.channel-tag.QW { background:#07c160; } .channel-tag.WX { background:#2b9939; }
+.channel-tag.IM { background:#409eff; } .channel-tag.WHATSAPP { background:#25d366; color:#333; }
+.channel-tag.LINE { background:#00b900; } .channel-tag.TELEGRAM { background:#0088cc; }
+.channel-tag.TMALL,.channel-tag.JD { background:#ff5000; }
+.channel-tag.DOUYIN_DM,.channel-tag.DOUYIN_EC,.channel-tag.KUAISHOU_DM { background:#ff0050; }
+.channel-tag.XIAOHONGSHU_DM { background:#fe2c55; }
+.time { font-size:11px; color:#999; }
+.user-id { font-size:13px; color:#333; margin-bottom:2px; }
+.last-msg { font-size:12px; color:#999; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+.empty-chat { padding:80px 0; text-align:center; color:#999; height:calc(100vh - 200px); }
+.chat-detail { height:calc(100vh - 200px); display:flex; flex-direction:column; }
+.detail-header { padding:12px 16px; border-bottom:1px solid #ebeef5; background:#fafafa; display:flex; gap:16px; align-items:center; flex-wrap:wrap; }
+.detail-header span { font-size:13px; }
+.header-info { color:#666; }
+.header-status { font-size:12px; padding:2px 8px; border-radius:10px; background:#f0f0f0; }
+.header-status.online { background:#e6f7e6; color:#52c41a; }
+.msg-body { flex:1; overflow-y:auto; padding:12px 16px; }
+.msg-item { margin-bottom:12px; padding:8px 12px; border-radius:6px; max-width:85%; }
+.msg-item.from-customer { background:#f0f2f5; margin-right:auto; }
+.msg-item.from-ai { background:#e6f7ff; margin-left:auto; }
+.msg-meta { margin-bottom:4px; display:flex; gap:8px; align-items:center; }
+.msg-sender { font-size:12px; font-weight:600; color:#666; }
+.channel-tag-small { font-size:10px; padding:0 4px; border-radius:2px; background:#eee; color:#888; }
+.msg-time { font-size:10px; color:#aaa; margin-left:auto; }
+.msg-content { font-size:13px; color:#333; line-height:1.6; word-break:break-word; }
+.empty { padding:40px 0; text-align:center; color:#bbb; }
 </style>

+ 424 - 0
src/views/lobster/chat-test/index.vue

@@ -0,0 +1,424 @@
+<template>
+  <div class="chat-test-page">
+    <!-- 左侧:工作流配置面板 -->
+    <div class="left-panel">
+      <el-card shadow="never" class="config-card">
+        <div slot="header" class="card-header">
+          <span><i class="el-icon-setting"></i> 测试配置</span>
+          <el-button type="success" size="mini" icon="el-icon-thumb" @click="runE2eOnce" :loading="e2eLoading" style="margin-left:8px">一键E2E</el-button>
+        </div>
+
+        <el-form label-width="80px" size="small">
+          <el-form-item label="选择模板">
+            <el-select v-model="selectedTemplateId" placeholder="选择工作流模板" filterable
+              style="width:100%" @change="onTemplateChange">
+              <el-option v-for="t in templates" :key="t.id" :label="t.templateName || t.promptName"
+                :value="t.id" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="E2E输入">
+            <el-input type="textarea" v-model="e2eUserInputs" :rows="3" placeholder="每行一条,一键E2E依次喂入" />
+          </el-form-item>
+          <el-form-item label="模拟日期">
+            <el-date-picker v-model="simulateDate" type="datetime" value-format="yyyy-MM-dd HH:mm:ss"
+              placeholder="选择模拟时间" style="width:100%" />
+          </el-form-item>
+          <el-form-item label="测试模式">
+            <el-radio-group v-model="testMode">
+              <el-radio label="full">全流程</el-radio>
+              <el-radio label="step">逐节点</el-radio>
+            </el-radio-group>
+          </el-form-item>
+        </el-form>
+
+        <!-- 工作流节点预览 -->
+        <div v-if="templateNodes.length > 0" class="node-preview">
+          <div class="section-title">节点流程({{ templateNodes.length }}个)</div>
+          <el-steps direction="vertical" :active="currentNodeIndex" finish-status="success" process-status="process">
+            <el-step v-for="(node, idx) in templateNodes" :key="node.nodeCode"
+              :title="node.nodeName"
+              :description="getNodeTypeText(node.nodeType)">
+              <template slot="icon">
+                <i :class="getNodeIcon(node.nodeType)" :style="{color: getNodeColor(node.nodeType)}"></i>
+              </template>
+            </el-step>
+          </el-steps>
+        </div>
+      </el-card>
+    </div>
+
+    <!-- 中间:聊天区域 -->
+    <div class="center-panel">
+      <div class="chat-container">
+        <div class="chat-header">
+          <span><i class="el-icon-chat-dot-round"></i> 模拟聊天测试</span>
+          <div>
+            <el-tag v-if="currentNode" size="small" :type="getNodeTagType(currentNode.nodeType)">
+              当前节点: {{ currentNode.nodeName }}
+            </el-tag>
+          </div>
+        </div>
+
+        <div class="chat-messages" ref="chatMessages">
+          <div v-if="messages.length === 0" class="chat-empty">
+            <i class="el-icon-chat-line-square"></i>
+            <p>选择工作流模板后,输入客户消息开始对话测试</p>
+          </div>
+
+          <div v-for="(msg, idx) in messages" :key="idx"
+            :class="['message-row', msg.role === 'customer' ? 'message-right' : 'message-left']">
+            <div class="message-bubble" :class="msg.role">
+              <div class="message-meta">
+                <span class="message-sender">{{ msg.role === 'customer' ? '客户' : 'AI' }}</span>
+                <span class="message-time">{{ msg.time }}</span>
+                <el-tag v-if="msg.nodeName" size="mini" type="info" class="message-node-tag">
+                  {{ msg.nodeName }}
+                </el-tag>
+              </div>
+              <div class="message-content">{{ msg.content }}</div>
+              <div v-if="msg.score" class="message-score">
+                <span class="score-label">质量评分:</span>
+                <el-rate v-model="msg.score" disabled :max="5" show-score text-color="#ff9900" size="small" />
+              </div>
+            </div>
+          </div>
+
+          <div v-if="loading" class="message-row message-left">
+            <div class="message-bubble ai typing">
+              <span>AI正在生成回复...</span>
+            </div>
+          </div>
+        </div>
+
+        <div class="chat-input-area">
+          <el-input v-model="inputMsg" type="textarea" :rows="2"
+            placeholder="输入客户消息,模拟对话..."
+            :disabled="!selectedTemplateId || loading"
+            @keyup.enter.native="sendMessage" />
+          <div class="chat-actions">
+            <el-button type="primary" size="small" @click="sendMessage"
+              :loading="loading" :disabled="!selectedTemplateId || !inputMsg.trim()">
+              <i class="el-icon-position"></i> 发送
+            </el-button>
+            <el-button size="small" @click="resetChat" :disabled="loading">
+              <i class="el-icon-refresh"></i> 重置
+            </el-button>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 右侧:节点执行详情 -->
+    <div class="right-panel">
+      <el-card shadow="never" class="detail-card">
+        <div slot="header" class="card-header">
+          <span><i class="el-icon-document"></i> 节点执行日志</span>
+          <el-button size="mini" type="text" @click="nodeLogs = []">清空</el-button>
+        </div>
+
+        <div v-if="nodeLogs.length === 0" class="log-empty">
+          <i class="el-icon-info"></i>
+          <p>暂无执行日志,发送消息后将在此展示每个节点的执行详情</p>
+        </div>
+
+        <el-timeline v-if="nodeLogs.length > 0">
+          <el-timeline-item v-for="(log, idx) in nodeLogs" :key="idx"
+            :timestamp="log.time" :type="log.error ? 'danger' : 'success'" placement="top">
+            <el-card shadow="hover" class="log-card">
+              <div class="log-header">
+                <el-tag size="mini" :type="getNodeTagType(log.nodeType)">
+                  {{ getNodeTypeText(log.nodeType) }}
+                </el-tag>
+                <span class="log-node-name">{{ log.nodeName }}</span>
+                <span v-if="log.duration" class="log-duration">{{ log.duration }}ms</span>
+              </div>
+              <div v-if="log.input" class="log-section">
+                <div class="log-label">接收:</div>
+                <div class="log-content">{{ log.input }}</div>
+              </div>
+              <div v-if="log.output" class="log-section">
+                <div class="log-label">输出:</div>
+                <div class="log-content output">{{ log.output }}</div>
+              </div>
+              <div v-if="log.error" class="log-error">{{ log.error }}</div>
+            </el-card>
+          </el-timeline-item>
+        </el-timeline>
+      </el-card>
+    </div>
+  </div>
+</template>
+
+<script>
+import { simulateChat, listAllTemplates, getTemplateWithNodes } from '@/api/workflow/lobster'
+import { runE2e } from '@/api/workflow/lobster-e2e'
+
+export default {
+  name: 'ChatTest',
+  data() {
+    return {
+      templates: [],
+      selectedTemplateId: null,
+      templateNodes: [],
+      testMode: 'step',
+      simulateDate: null,
+      currentNodeIndex: 0,
+      currentNode: null,
+      inputMsg: '',
+      messages: [],
+      nodeLogs: [],
+      loading: false,
+      e2eLoading: false,
+      e2eUserInputs: ''
+    }
+  },
+  created() {
+    this.loadTemplates()
+    this.simulateDate = this.formatDate(new Date())
+  },
+  methods: {
+    async runE2eOnce() {
+      if (!this.selectedTemplateId) { this.$message.warning('请先选择模板'); return }
+      const inputs = (this.e2eUserInputs || '').split('\n').map(x => x.trim()).filter(x => x)
+      if (inputs.length === 0) { this.$message.warning('请输入至少一条用户消息'); return }
+      this.e2eLoading = true
+      try {
+        const res = await runE2e({
+          templateId: this.selectedTemplateId,
+          userInputs: inputs,
+          testContactId: -1
+        })
+        const r = res.data || res
+        this.$notify({
+          title: 'E2E 完成',
+          message: `总分 ${r.totalScore?.toFixed?.(1) || '-'} | 通过 ${r.passedNodeCnt || 0}/${r.totalNodeCnt || 0} | 进化建议 ${r.evolutionCount || 0}`,
+          type: r.status === 'SUCCESS' ? 'success' : 'error',
+          duration: 6000
+        })
+        // 将 nodeTraces 灌进 messages 区
+        if (r.nodeTraces) {
+          this.messages = []
+          r.nodeTraces.forEach(t => {
+            if (t.userInput) this.messages.push({ role: 'user', text: t.userInput })
+            if (t.aiOutput) this.messages.push({ role: 'ai', text: t.aiOutput, score: t.score, node: t.nodeCode })
+          })
+        }
+      } catch (e) {
+        this.$message.error('E2E 失败:' + (e.message || e))
+      } finally {
+        this.e2eLoading = false
+      }
+    },
+    async loadTemplates() {
+      try {
+        const res = await listAllTemplates()
+        this.templates = (res.data || []).filter(t => t.status === 1)
+      } catch (e) {
+        try {
+          const res2 = await listAllTemplates()
+          this.templates = res2.data || []
+        } catch (e2) { /* 静默 */ }
+      }
+    },
+    async onTemplateChange(id) {
+      if (!id) {
+        this.templateNodes = []
+        this.currentNode = null
+        this.currentNodeIndex = 0
+        return
+      }
+      try {
+        const res = await getTemplateWithNodes(id)
+        const data = res.data || res
+        this.templateNodes = data.nodes || []
+        this.currentNodeIndex = 0
+        if (this.templateNodes.length > 0) {
+          this.currentNode = this.templateNodes[0]
+        }
+      } catch (e) {
+        this.templateNodes = []
+      }
+    },
+
+    async sendMessage() {
+      const text = this.inputMsg.trim()
+      if (!text || !this.selectedTemplateId) return
+
+      const now = this.formatTime(new Date())
+      this.messages.push({ role: 'customer', content: text, time: now, nodeName: null })
+      this.inputMsg = ''
+      this.loading = true
+      this.$nextTick(() => this.scrollToBottom())
+
+      try {
+        const res = await simulateChat({
+          templateId: this.selectedTemplateId,
+          content: text,
+          simulateDate: this.simulateDate
+        })
+
+        const reply = typeof res.data === 'string' ? res.data : (res.data?.reply || res.data?.data || '')
+        const replyTime = this.formatTime(new Date())
+
+        // 记录节点日志
+        const currentNodeInfo = this.currentNode || { nodeName: '消息节点', nodeType: 2 }
+        this.nodeLogs.push({
+          time: replyTime,
+          nodeName: currentNodeInfo.nodeName,
+          nodeType: currentNodeInfo.nodeType,
+          input: text,
+          output: reply,
+          duration: null,
+          error: null
+        })
+
+        // 构建评分(模拟:后端未就绪时前端临时展示)
+        const mockScore = reply ? Math.min(5, Math.max(2, Math.round(reply.length / 100))) : 3
+
+        this.messages.push({
+          role: 'ai',
+          content: reply || '(AI未返回内容)',
+          time: replyTime,
+          nodeName: currentNodeInfo.nodeName,
+          score: mockScore
+        })
+
+        // 逐节点模式推进
+        if (this.testMode === 'step' && this.templateNodes.length > 0) {
+          this.currentNodeIndex = Math.min(this.currentNodeIndex + 1, this.templateNodes.length)
+          if (this.currentNodeIndex < this.templateNodes.length) {
+            this.currentNode = this.templateNodes[this.currentNodeIndex]
+          }
+        }
+      } catch (e) {
+        const errTime = this.formatTime(new Date())
+        this.nodeLogs.push({
+          time: errTime,
+          nodeName: this.currentNode?.nodeName || '未知',
+          nodeType: this.currentNode?.nodeType || 2,
+          input: text,
+          output: null,
+          duration: null,
+          error: e.message || '模拟对话请求失败'
+        })
+        this.messages.push({
+          role: 'ai',
+          content: '[错误] 模拟对话失败: ' + (e.message || '未知错误'),
+          time: errTime,
+          nodeName: this.currentNode?.nodeName,
+          score: 0
+        })
+      } finally {
+        this.loading = false
+        this.$nextTick(() => this.scrollToBottom())
+      }
+    },
+
+    resetChat() {
+      this.messages = []
+      this.nodeLogs = []
+      this.currentNodeIndex = 0
+      if (this.templateNodes.length > 0) {
+        this.currentNode = this.templateNodes[0]
+      }
+    },
+
+    scrollToBottom() {
+      const el = this.$refs.chatMessages
+      if (el) el.scrollTop = el.scrollHeight
+    },
+
+    formatTime(date) {
+      const h = String(date.getHours()).padStart(2, '0')
+      const m = String(date.getMinutes()).padStart(2, '0')
+      const s = String(date.getSeconds()).padStart(2, '0')
+      return `${h}:${m}:${s}`
+    },
+    formatDate(date) {
+      const y = date.getFullYear()
+      const mo = String(date.getMonth() + 1).padStart(2, '0')
+      const d = String(date.getDate()).padStart(2, '0')
+      const h = String(date.getHours()).padStart(2, '0')
+      const mi = String(date.getMinutes()).padStart(2, '0')
+      const s = String(date.getSeconds()).padStart(2, '0')
+      return `${y}-${mo}-${d} ${h}:${mi}:${s}`
+    },
+
+    getNodeTypeText(type) {
+      const map = { 1:'开始', 2:'消息', 3:'判断', 4:'等待', 5:'结束', 6:'API', 7:'购物车', 8:'优惠券', 9:'标签', 10:'赠礼', 11:'文档', 12:'用户', 13:'数据分析', 14:'AI' }
+      return map[type] || '未知'
+    },
+    getNodeIcon(type) {
+      const map = { 1:'el-icon-video-play', 2:'el-icon-message', 3:'el-icon-question', 4:'el-icon-time', 5:'el-icon-video-pause', 6:'el-icon-link', 14:'el-icon-magic-stick' }
+      return map[type] || 'el-icon-circle-check'
+    },
+    getNodeColor(type) {
+      const map = { 1:'#22c55e', 2:'#3b82f6', 3:'#f59e0b', 4:'#8b5cf6', 5:'#ef4444', 6:'#6366f1', 14:'#ec4899' }
+      return map[type] || '#909399'
+    },
+    getNodeTagType(type) {
+      if (type === 1) return 'success'
+      if (type === 2) return ''
+      if (type === 3) return 'warning'
+      if (type === 4) return 'info'
+      if (type === 5) return 'danger'
+      return 'info'
+    }
+  }
+}
+</script>
+
+<style scoped>
+.chat-test-page {
+  display: flex; height: calc(100vh - 84px); gap: 0;
+  position: absolute; top: 0; left: 0; right: 0; bottom: 0;
+}
+.left-panel { width: 280px; flex-shrink: 0; overflow-y: auto; border-right: 1px solid #e8ecf1; padding: 12px; }
+.center-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; }
+.right-panel { width: 320px; flex-shrink: 0; overflow-y: auto; border-left: 1px solid #e8ecf1; padding: 12px; }
+
+.card-header { display: flex; justify-content: space-between; align-items: center; }
+.section-title { font-size: 13px; color: #606266; font-weight: 600; margin: 12px 0 8px; padding-left: 4px; border-left: 3px solid #409EFF; }
+.node-preview { margin-top: 12px; }
+
+.chat-container { display: flex; flex-direction: column; height: 100%; background: #f5f7fa; }
+.chat-header { padding: 12px 16px; background: #fff; border-bottom: 1px solid #e8ecf1;
+  display: flex; justify-content: space-between; align-items: center; font-size: 14px; font-weight: 600; }
+.chat-messages { flex: 1; overflow-y: auto; padding: 16px; }
+.chat-empty { text-align: center; padding: 80px 20px; color: #c0c4cc; }
+.chat-empty i { font-size: 48px; }
+.chat-empty p { margin-top: 12px; font-size: 13px; }
+
+.message-row { display: flex; margin-bottom: 16px; }
+.message-right { justify-content: flex-end; }
+.message-left { justify-content: flex-start; }
+.message-bubble { max-width: 75%; padding: 10px 14px; border-radius: 12px; position: relative; }
+.message-bubble.customer { background: #409EFF; color: #fff; border-bottom-right-radius: 4px; }
+.message-bubble.ai { background: #fff; color: #303133; border-bottom-left-radius: 4px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
+.message-bubble.typing { background: #fff; color: #909399; font-style: italic; }
+.message-meta { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; font-size: 11px; opacity: 0.8; }
+.message-bubble.customer .message-meta { color: rgba(255,255,255,0.85); }
+.message-bubble.ai .message-meta { color: #909399; }
+.message-node-tag { margin-left: auto; }
+.message-content { white-space: pre-wrap; word-break: break-word; line-height: 1.5; }
+.message-score { margin-top: 6px; display: flex; align-items: center; gap: 6px; }
+.score-label { font-size: 11px; color: #909399; }
+
+.chat-input-area { padding: 12px 16px; background: #fff; border-top: 1px solid #e8ecf1; }
+.chat-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }
+
+.log-empty { text-align: center; padding: 40px 20px; color: #c0c4cc; }
+.log-empty i { font-size: 36px; }
+.log-empty p { margin-top: 8px; font-size: 12px; }
+.log-card { margin-bottom: 2px; }
+.log-header { display: flex; align-items: center; gap: 8px; }
+.log-node-name { font-weight: 600; flex: 1; }
+.log-duration { font-size: 11px; color: #909399; }
+.log-section { margin-top: 6px; }
+.log-label { font-size: 11px; color: #909399; margin-bottom: 2px; }
+.log-content { font-size: 12px; padding: 6px 8px; background: #f5f7fa; border-radius: 4px; word-break: break-word; }
+.log-content.output { background: #ecf5ff; }
+.log-error { font-size: 12px; color: #F56C6C; margin-top: 4px; }
+
+.detail-card { height: 100%; }
+</style>

+ 110 - 0
src/views/lobster/dedup-config/index.vue

@@ -0,0 +1,110 @@
+<template>
+  <div class="app-container">
+    <el-form :model="queryParams" ref="queryForm" :inline="true" label-width="68px">
+      <el-form-item label="租户" prop="companyId">
+        <el-select v-model="queryParams.companyId" placeholder="全部租户" filterable clearable size="small">
+          <el-option v-for="c in companyOptions" :key="c.value" :label="c.label" :value="c.value" />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
+        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
+      </el-form-item>
+    </el-form>
+
+    <el-row :gutter="10" class="mb8">
+      <el-col :span="1.5">
+        <el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd">新增</el-button>
+      </el-col>
+    </el-row>
+
+    <el-table v-loading="loading" :data="list" border>
+      <el-table-column label="ID" prop="id" width="60" />
+      <el-table-column label="租户" prop="companyName" width="120" />
+      <el-table-column label="配置名" prop="configName" min-width="140" />
+      <el-table-column label="去重模式" prop="dedupMode" width="100" />
+      <el-table-column label="窗口大小" prop="exactWindowSize" width="80" />
+      <el-table-column label="语义阈值" prop="semanticThreshold" width="80" />
+      <el-table-column label="窗口时长(s)" prop="windowDurationSeconds" width="100" />
+      <el-table-column label="状态" width="80">
+        <template slot-scope="{row}"><el-switch v-model="row.enabled" :active-value="1" :inactive-value="0" @change="handleStatusChange(row)" /></template>
+      </el-table-column>
+      <el-table-column label="操作" width="150">
+        <template slot-scope="{row}">
+          <el-button size="mini" type="text" icon="el-icon-edit" @click="handleEdit(row)">修改</el-button>
+          <el-button size="mini" type="text" icon="el-icon-delete" style="color:#F56C6C" @click="handleDelete(row)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
+
+    <el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="550px" append-to-body>
+      <el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
+        <el-form-item label="租户" prop="companyId" v-if="isAdd">
+          <el-select v-model="form.companyId" placeholder="选择租户" filterable style="width:100%">
+            <el-option v-for="c in companyOptions" :key="c.value" :label="c.label" :value="c.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="配置名" prop="configName">
+          <el-input v-model="form.configName" placeholder="配置名称" />
+        </el-form-item>
+        <el-form-item label="去重模式">
+          <el-select v-model="form.dedupMode" style="width:100%">
+            <el-option label="精确去重" value="exact" />
+            <el-option label="语义去重" value="semantic" />
+            <el-option label="混合模式" value="hybrid" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="精确窗口大小">
+          <el-input-number v-model="form.exactWindowSize" :min="1" :max="100" />
+        </el-form-item>
+        <el-form-item label="语义相似度阈值">
+          <el-slider v-model="form.semanticThreshold" :min="0" :max="1" :step="0.05" show-input />
+        </el-form-item>
+        <el-form-item label="窗口时长(秒)">
+          <el-input-number v-model="form.windowDurationSeconds" :min="10" :max="3600" :step="30" />
+        </el-form-item>
+        <el-form-item label="忽略前缀消息">
+          <el-input-number v-model="form.ignorePrefixCount" :min="0" :max="20" />
+        </el-form-item>
+        <el-form-item label="备注">
+          <el-input v-model="form.remark" type="textarea" :rows="2" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="submitForm">确定</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { listAllCompanies } from '@/api/workflow/lobster-admin'
+
+export default {
+  name: 'DedupConfig',
+  data() {
+    return {
+      loading: false, list: [], total: 0, companyOptions: [],
+      queryParams: { pageNum: 1, pageSize: 10, companyId: null },
+      dialogVisible: false, dialogTitle: '', isAdd: false,
+      form: { companyId: null, configName: '', dedupMode: 'hybrid', exactWindowSize: 5, semanticThreshold: 0.85, windowDurationSeconds: 300, ignorePrefixCount: 0, remark: '' },
+      rules: { configName: [{ required: true, message: '不能为空', trigger: 'blur' }] }
+    }
+  },
+  created() { this.loadCompanies(); this.getList() },
+  methods: {
+    async loadCompanies() { try { const res = await listAllCompanies(); this.companyOptions = (res.data || []).map(c => ({ value: c.companyId, label: c.companyName || '未知' })) } catch (e) {} },
+    getList() { this.loading = true; this.loading = false },
+    handleQuery() { this.queryParams.pageNum = 1; this.getList() },
+    resetQuery() { this.$refs.queryForm.resetFields(); this.handleQuery() },
+    handleAdd() { this.isAdd = true; this.dialogTitle = '新增去重配置'; this.form = { companyId: null, configName: '', dedupMode: 'hybrid', exactWindowSize: 5, semanticThreshold: 0.85, windowDurationSeconds: 300, ignorePrefixCount: 0, remark: '' }; this.dialogVisible = true },
+    handleEdit(row) { this.isAdd = false; this.dialogTitle = '修改去重配置'; this.form = { ...row }; this.dialogVisible = true },
+    handleStatusChange(row) { this.$message.success('状态已更新') },
+    submitForm() { this.$refs.formRef.validate(v => { if (!v) return; this.$message.success(this.isAdd ? '新增成功' : '修改成功'); this.dialogVisible = false; this.getList() }) },
+    handleDelete(row) { this.$confirm('确认删除?', '警告', { type: 'warning' }).then(() => { this.$message.success('删除成功'); this.getList() }).catch(() => {}) }
+  }
+}
+</script>

+ 76 - 0
src/views/lobster/dynamic-impl/index.vue

@@ -0,0 +1,76 @@
+<template>
+  <div class="app-container">
+    <el-card shadow="never">
+      <el-tabs v-model="tab" @tab-click="load">
+        <el-tab-pane label="待审批" name="PENDING" />
+        <el-tab-pane label="已激活" name="ACTIVE" />
+        <el-tab-pane label="草稿" name="DRAFT" />
+        <el-tab-pane label="已拒绝" name="REJECTED" />
+      </el-tabs>
+
+      <el-table v-loading="loading" :data="list" border size="small">
+        <el-table-column label="ID" prop="id" width="70" />
+        <el-table-column label="节点类型" prop="nodeType" width="90" />
+        <el-table-column label="类型编码" prop="nodeTypeCode" width="140" />
+        <el-table-column label="指纹" prop="fingerprint" width="140" show-overflow-tooltip />
+        <el-table-column label="评分" prop="qualityScore" width="80">
+          <template slot-scope="s">
+            <el-tag :type="s.row.qualityScore>=80?'success':s.row.qualityScore>=60?'warning':'danger'" size="mini">
+              {{ (s.row.qualityScore||0).toFixed(1) }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="调用/成功" width="100">
+          <template slot-scope="s">{{ s.row.execCount || 0 }} / {{ s.row.successCount || 0 }}</template>
+        </el-table-column>
+        <el-table-column label="平均耗时" prop="avgDurationMs" width="90" />
+        <el-table-column label="状态" prop="status" width="90">
+          <template slot-scope="s">
+            <el-tag :type="statusTag(s.row.status)" size="mini">{{ s.row.status }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="180" fixed="right">
+          <template slot-scope="s">
+            <el-button v-if="s.row.status==='PENDING'" type="text" size="mini" style="color:#67C23A" @click="approve(s.row)">通过</el-button>
+            <el-button v-if="s.row.status==='PENDING'" type="text" size="mini" style="color:#F56C6C" @click="reject(s.row)">拒绝</el-button>
+            <el-button type="text" size="mini" @click="showDsl(s.row)">查看DSL</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <el-dialog title="DSL 详情" :visible.sync="dslDlg" width="700px">
+      <pre style="max-height:500px;overflow:auto;background:#1e1e1e;color:#d4d4d4;padding:12px;border-radius:6px;font-size:12px">{{ dslContent }}</pre>
+    </el-dialog>
+
+    <el-dialog title="拒绝原因" :visible.sync="rejectDlg" width="400px">
+      <el-input v-model="rejectReason" type="textarea" :rows="3" placeholder="请输入拒绝原因" />
+      <div slot="footer"><el-button @click="rejectDlg=false">取消</el-button><el-button type="danger" @click="doReject">确认拒绝</el-button></div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { listDynamicImpls, approveDynamicImpl, rejectDynamicImpl } from '@/api/workflow/lobster-e2e'
+export default {
+  data() {
+    return {
+      tab: 'PENDING', loading: false, list: [],
+      dslDlg: false, dslContent: '',
+      rejectDlg: false, rejectRow: null, rejectReason: ''
+    }
+  },
+  created() { this.load() },
+  methods: {
+    statusTag(s) { return s === 'ACTIVE' ? 'success' : s === 'PENDING' ? 'warning' : s === 'REJECTED' ? 'danger' : 'info' },
+    async load() {
+      this.loading = true
+      try { const res = await listDynamicImpls(this.tab); this.list = res.data || [] } finally { this.loading = false }
+    },
+    showDsl(row) { this.dslContent = JSON.stringify(JSON.parse(row.subDslJson || '{}'), null, 2); this.dslDlg = true },
+    approve(row) { this.$confirm('确认激活此节点实现?激活后下次遇到同类型同指纹节点直接复用', '提示', { type: 'info' }).then(async () => { await approveDynamicImpl(row.id); this.$message.success('已激活'); this.load() }) },
+    reject(row) { this.rejectRow = row; this.rejectReason = ''; this.rejectDlg = true },
+    async doReject() { await rejectDynamicImpl(this.rejectRow.id, this.rejectReason); this.rejectDlg = false; this.$message.success('已拒绝'); this.load() }
+  }
+}
+</script>

+ 104 - 0
src/views/lobster/e2e-history/index.vue

@@ -0,0 +1,104 @@
+<template>
+  <div class="app-container">
+    <el-card shadow="never">
+      <el-row :gutter="8" type="flex" justify="space-between" style="margin-bottom:12px">
+        <el-col :span="14">
+          <el-input v-model="query.runId" placeholder="runId" size="small" style="width:280px" clearable @keyup.enter.native="searchById" />
+          <el-button type="primary" size="small" icon="el-icon-search" @click="load" style="margin-left:8px">刷新</el-button>
+        </el-col>
+        <el-col :span="10" style="text-align:right">
+          <el-tag>成功 {{ stats.success }}</el-tag>
+          <el-tag type="danger" style="margin-left:6px">失败 {{ stats.failed }}</el-tag>
+          <el-tag type="warning" style="margin-left:6px">运行中 {{ stats.running }}</el-tag>
+        </el-col>
+      </el-row>
+
+      <el-table v-loading="loading" :data="list" border size="small" @row-click="showDetail">
+        <el-table-column label="runId" prop="runId" width="240" show-overflow-tooltip />
+        <el-table-column label="模板" prop="templateId" width="80" />
+        <el-table-column label="实例" prop="instanceId" width="80" />
+        <el-table-column label="业务描述" prop="businessDesc" show-overflow-tooltip />
+        <el-table-column label="总分" prop="totalScore" width="80">
+          <template slot-scope="s">
+            <el-tag v-if="s.row.totalScore != null" :type="s.row.totalScore >= 60 ? 'success' : 'danger'" size="mini">{{ s.row.totalScore.toFixed(1) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="通过/总" width="100">
+          <template slot-scope="s">{{ s.row.passedNodeCnt || 0 }} / {{ s.row.totalNodeCnt || 0 }}</template>
+        </el-table-column>
+        <el-table-column label="耗时(ms)" prop="durationMs" width="100" />
+        <el-table-column label="进化建议" prop="evolutionCount" width="100" />
+        <el-table-column label="状态" prop="status" width="100">
+          <template slot-scope="s"><el-tag :type="statusType(s.row.status)" size="mini">{{ s.row.status }}</el-tag></template>
+        </el-table-column>
+        <el-table-column label="时间" prop="createTime" width="160" />
+      </el-table>
+    </el-card>
+
+    <el-drawer title="E2E 运行明细" :visible.sync="drawer" size="55%" direction="rtl">
+      <div v-if="detail" style="padding: 0 16px">
+        <el-descriptions :column="2" border size="small" style="margin-bottom:12px">
+          <el-descriptions-item label="runId">{{ detail.runId }}</el-descriptions-item>
+          <el-descriptions-item label="状态">{{ detail.status }}</el-descriptions-item>
+          <el-descriptions-item label="总分">{{ detail.totalScore }}</el-descriptions-item>
+          <el-descriptions-item label="通过/总">{{ detail.passedNodeCnt }} / {{ detail.totalNodeCnt }}</el-descriptions-item>
+          <el-descriptions-item label="耗时">{{ detail.durationMs }} ms</el-descriptions-item>
+          <el-descriptions-item label="进化建议">{{ detail.evolutionCount }}</el-descriptions-item>
+        </el-descriptions>
+        <el-table :data="detail.nodeTraces" border size="mini">
+          <el-table-column label="#" prop="nodeSeq" width="50" />
+          <el-table-column label="节点" prop="nodeCode" />
+          <el-table-column label="轮" prop="turnNo" width="50" />
+          <el-table-column label="用户" prop="userInput" show-overflow-tooltip />
+          <el-table-column label="AI" prop="aiOutput" show-overflow-tooltip />
+          <el-table-column label="分" prop="score" width="60">
+            <template slot-scope="s">
+              <el-tag v-if="s.row.score" :type="s.row.score >= 60 ? 'success' : 'danger'" size="mini">{{ s.row.score.toFixed(0) }}</el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column label="耗时" prop="durationMs" width="80" />
+        </el-table>
+      </div>
+    </el-drawer>
+  </div>
+</template>
+
+<script>
+import { listE2eRuns, getE2eReport } from '@/api/workflow/lobster-e2e'
+export default {
+  data() {
+    return {
+      loading: false, list: [], query: { runId: '', pageNum: 1, pageSize: 50 },
+      stats: { success: 0, failed: 0, running: 0 },
+      drawer: false, detail: null
+    }
+  },
+  created() { this.load() },
+  methods: {
+    statusType(s) { return s === 'SUCCESS' ? 'success' : s === 'FAILED' ? 'danger' : 'warning' },
+    async load() {
+      this.loading = true
+      try {
+        const res = await listE2eRuns(this.query)
+        this.list = res.data || res || []
+        this.stats = this.list.reduce((acc, x) => {
+          if (x.status === 'SUCCESS') acc.success++
+          else if (x.status === 'FAILED') acc.failed++
+          else acc.running++
+          return acc
+        }, { success: 0, failed: 0, running: 0 })
+      } finally { this.loading = false }
+    },
+    async searchById() {
+      if (!this.query.runId) return this.load()
+      const res = await getE2eReport(this.query.runId)
+      if (res.data) { this.detail = res.data; this.drawer = true }
+    },
+    async showDetail(row) {
+      const res = await getE2eReport(row.runId)
+      this.detail = res.data || row
+      this.drawer = true
+    }
+  }
+}
+</script>

+ 0 - 154
src/views/lobster/model-config/index.vue

@@ -1,154 +0,0 @@
-<template>
-  <div class="app-container">
-    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="100px">
-      <el-form-item label="关键词" prop="search">
-        <el-input v-model="queryParams.search" placeholder="请输入模型名称" clearable size="small" @keyup.enter.native="handleQuery" />
-      </el-form-item>
-      <el-form-item label="分类" prop="category">
-        <el-select v-model="queryParams.category" placeholder="请选择分类" clearable size="small">
-          <el-option v-for="cat in categories" :key="cat" :label="cat" :value="cat" />
-        </el-select>
-      </el-form-item>
-      <el-form-item>
-        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
-        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
-      </el-form-item>
-    </el-form>
-
-    <el-row :gutter="10" class="mb8">
-      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
-    </el-row>
-
-    <!-- 渠道概览 -->
-    <el-row :gutter="16" class="mb8">
-      <el-col :span="4">
-        <el-card shadow="hover">
-          <div class="stat-card">
-            <div class="stat-value">{{ channelInfo.qw || 0 }}</div>
-            <div class="stat-label">企微渠道</div>
-          </div>
-        </el-card>
-      </el-col>
-      <el-col :span="4">
-        <el-card shadow="hover">
-          <div class="stat-card">
-            <div class="stat-value" style="color:#67C23A">{{ channelInfo.wx || 0 }}</div>
-            <div class="stat-label">个微渠道</div>
-          </div>
-        </el-card>
-      </el-col>
-      <el-col :span="4">
-        <el-card shadow="hover">
-          <div class="stat-card">
-            <div class="stat-value" style="color:#E6A23C">{{ channelInfo.sms || 0 }}</div>
-            <div class="stat-label">短信渠道</div>
-          </div>
-        </el-card>
-      </el-col>
-      <el-col :span="4">
-        <el-card shadow="hover">
-          <div class="stat-card">
-            <div class="stat-value" style="color:#F56C6C">{{ channelInfo.ai_call || 0 }}</div>
-            <div class="stat-label">AI外呼渠道</div>
-          </div>
-        </el-card>
-      </el-col>
-    </el-row>
-
-    <el-table border v-loading="loading" :data="list">
-      <el-table-column label="配置ID" align="center" prop="id" width="60" />
-      <el-table-column label="模型名称" align="center" prop="prompt_name" />
-      <el-table-column label="模型标识" align="center" prop="prompt_key" />
-      <el-table-column label="分类" align="center" prop="prompt_category" width="100" />
-      <el-table-column label="使用模型" align="center" prop="model_name" width="100">
-        <template slot-scope="scope">
-          <el-tag type="success" size="small">{{ scope.row.model_name || '-' }}</el-tag>
-        </template>
-      </el-table-column>
-      <el-table-column label="角色" align="center" prop="system_role" width="100" show-overflow-tooltip />
-      <el-table-column label="行业" align="center" prop="industry_type" width="80" />
-      <el-table-column label="状态" align="center" width="80">
-        <template slot-scope="scope">
-          <el-tag v-if="scope.row.enabled===1" type="success" size="small">启用</el-tag>
-          <el-tag v-else type="info" size="small">禁用</el-tag>
-        </template>
-      </el-table-column>
-      <el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="120">
-        <template slot-scope="scope">
-          <el-button size="mini" type="text" icon="el-icon-view" @click="handleDetail(scope.row)">详情</el-button>
-        </template>
-      </el-table-column>
-    </el-table>
-
-    <pagination v-show="total>0" :total="total" :page.sync="queryParams.page" :limit.sync="queryParams.size" @pagination="getList" />
-
-    <!-- 详情弹窗 -->
-    <el-dialog title="模型配置详情" :visible.sync="detailVisible" width="600px" append-to-body>
-      <el-descriptions :column="2" border>
-        <el-descriptions-item label="配置名称">{{ detail.prompt_name }}</el-descriptions-item>
-        <el-descriptions-item label="标识">{{ detail.prompt_key }}</el-descriptions-item>
-        <el-descriptions-item label="分类">{{ detail.prompt_category }}</el-descriptions-item>
-        <el-descriptions-item label="模型">{{ detail.model_name }}</el-descriptions-item>
-        <el-descriptions-item label="角色">{{ detail.system_role }}</el-descriptions-item>
-        <el-descriptions-item label="行业">{{ detail.industry_type }}</el-descriptions-item>
-        <el-descriptions-item label="状态">{{ detail.enabled===1?'启用':'禁用' }}</el-descriptions-item>
-      </el-descriptions>
-      <el-divider>提示词配置</el-divider>
-      <div style="white-space:pre-wrap;max-height:300px;overflow:auto;background:#f5f7fa;padding:10px;font-size:12px">{{ detail.prompt_content }}</div>
-    </el-dialog>
-  </div>
-</template>
-
-<script>
-import { listPrompts, getPrompt, getPromptCategories, getAvailableChannels } from '@/api/workflow/lobster'
-
-export default {
-  name: 'LobsterModelConfig',
-  data() {
-    return {
-      loading: false,
-      showSearch: true,
-      list: [],
-      total: 0,
-      categories: [],
-      channelInfo: {},
-      detailVisible: false,
-      detail: {},
-      queryParams: { page: 1, size: 10, search: null, category: null }
-    }
-  },
-  created() {
-    this.getList()
-    this.getCategories()
-    this.getChannelInfo()
-  },
-  methods: {
-    getList() {
-      this.loading = true
-      listPrompts(this.queryParams).then(res => {
-        let data = res.data || {}
-        this.list = data.list || []
-        this.total = data.total || 0
-        this.loading = false
-      }).catch(() => { this.loading = false })
-    },
-    getCategories() {
-      getPromptCategories().then(res => { this.categories = res.data || [] })
-    },
-    getChannelInfo() {
-      getAvailableChannels().then(res => { this.channelInfo = res.data || {} })
-    },
-    handleQuery() { this.queryParams.page = 1; this.getList() },
-    resetQuery() { this.resetForm('queryForm'); this.handleQuery() },
-    handleDetail(row) {
-      getPrompt(row.id).then(res => { this.detail = res.data || {}; this.detailVisible = true })
-    }
-  }
-}
-</script>
-
-<style scoped>
-.stat-card { text-align: center; padding: 10px 0; }
-.stat-value { font-size: 24px; font-weight: bold; color: #409EFF; }
-.stat-label { font-size: 12px; color: #909399; margin-top: 4px; }
-</style>

+ 166 - 0
src/views/lobster/model-route/index.vue

@@ -0,0 +1,166 @@
+<template>
+  <div class="app-container model-route-container">
+    <el-card shadow="never">
+      <div slot="header">
+        <span><i class="el-icon-setting" /> 多模型路由配置</span>
+        <el-button style="float:right" type="primary" size="small" icon="el-icon-plus" @click="handleAdd">新增配置</el-button>
+      </div>
+
+      <el-table v-loading="loading" :data="tableData" border>
+        <el-table-column type="index" label="序号" width="60" />
+        <el-table-column align="center" label="租户ID" prop="companyId" width="80" />
+        <el-table-column align="center" label="配置类型" prop="configType" width="130">
+          <template slot-scope="scope">
+            <el-tag :type="typeTag(scope.row.configType)">{{ typeName(scope.row.configType) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column align="center" label="模型A (生成/主质检)" prop="modelA" width="180" />
+        <el-table-column align="center" label="模型B (完善/辅助)" prop="modelB" width="180" />
+        <el-table-column align="center" label="模型C (校验/复核)" prop="modelC" width="180" />
+        <el-table-column align="center" label="行业" prop="industryType" width="100" />
+        <el-table-column align="center" label="启用" width="70">
+          <template slot-scope="scope">
+            <el-switch v-model="scope.row.enabled" :active-value="1" :inactive-value="0"
+                       @change="(val) => handleToggle(scope.row, val)" />
+          </template>
+        </el-table-column>
+        <el-table-column align="center" label="操作" width="150">
+          <template slot-scope="scope">
+            <el-button type="text" icon="el-icon-edit" @click="handleEdit(scope.row)">编辑</el-button>
+            <el-button type="text" style="color:#f56c6c" icon="el-icon-delete" @click="handleDelete(scope.row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <!-- 新增/编辑弹窗 -->
+    <el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="580px" :close-on-click-modal="false">
+      <el-form ref="form" :model="form" :rules="rules" label-width="120px">
+        <el-form-item label="配置类型" prop="configType">
+          <el-select v-model="form.configType" placeholder="请选择" :disabled="isEdit" style="width:100%">
+            <el-option v-for="t in configTypes" :key="t.code" :label="t.name" :value="t.code" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="模型A" prop="modelA">
+          <el-input v-model="form.modelA" placeholder="如 doubao-pro-32k" />
+        </el-form-item>
+        <el-form-item label="模型B" prop="modelB">
+          <el-input v-model="form.modelB" placeholder="如 gpt-4o" />
+        </el-form-item>
+        <el-form-item label="模型C" prop="modelC">
+          <el-input v-model="form.modelC" placeholder="如 claude-3-opus" />
+        </el-form-item>
+        <el-form-item label="行业类型" prop="industryType">
+          <el-input v-model="form.industryType" placeholder="如 医疗/教育/通用" />
+        </el-form-item>
+        <el-form-item label="额外参数" prop="paramsJson">
+          <el-input v-model="form.paramsJson" type="textarea" :rows="3" placeholder="JSON格式扩展配置" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="saving" @click="handleSubmit">保存</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { listModelConfigs, addModelConfig, updateModelConfig, deleteModelConfig, getModelConfigTypes } from '@/api/workflow/model-route'
+
+export default {
+  name: 'ModelRouteConfig',
+  data() {
+    return {
+      loading: false,
+      saving: false,
+      tableData: [],
+      configTypes: [],
+      dialogVisible: false,
+      dialogTitle: '',
+      isEdit: false,
+      form: {
+        id: null,
+        companyId: 0,
+        configType: '',
+        modelA: '',
+        modelB: '',
+        modelC: '',
+        industryType: '',
+        paramsJson: '',
+        enabled: 1
+      },
+      rules: {
+        configType: [{ required: true, message: '请选择配置类型', trigger: 'change' }],
+        modelA: [{ required: true, message: '请输入模型A', trigger: 'blur' }]
+      }
+    }
+  },
+  mounted() {
+    this.fetchTypes()
+    this.fetchList()
+  },
+  methods: {
+    fetchTypes() {
+      getModelConfigTypes().then(res => {
+        this.configTypes = res.data || []
+      }).catch(() => {})
+    },
+    fetchList() {
+      this.loading = true
+      listModelConfigs().then(res => {
+        this.tableData = res.data || []
+      }).finally(() => { this.loading = false })
+    },
+    typeName(type) {
+      const t = this.configTypes.find(c => c.code === type)
+      return t ? t.name : type
+    },
+    typeTag(type) {
+      const map = { workflow_generator: 'success', quality_check: 'warning', intent_router: 'info', reply_generator: '' }
+      return map[type] || ''
+    },
+    handleAdd() {
+      this.dialogTitle = '新增模型路由配置'
+      this.isEdit = false
+      this.form = { id: null, companyId: 0, configType: '', modelA: '', modelB: '', modelC: '', industryType: '', paramsJson: '', enabled: 1 }
+      this.dialogVisible = true
+    },
+    handleEdit(row) {
+      this.dialogTitle = '编辑模型路由配置'
+      this.isEdit = true
+      this.form = { ...row }
+      this.dialogVisible = true
+    },
+    handleToggle(row, val) {
+      updateModelConfig(row.id, { enabled: val }).then(() => {
+        row.enabled = val
+        this.$message.success(val === 1 ? '已启用' : '已禁用')
+      })
+    },
+    handleDelete(row) {
+      this.$confirm('确认删除该配置?', '提示', { type: 'warning' }).then(() => {
+        deleteModelConfig(row.id).then(() => {
+          this.$message.success('已删除')
+          this.fetchList()
+        })
+      }).catch(() => {})
+    },
+    handleSubmit() {
+      this.$refs.form.validate(valid => {
+        if (!valid) return
+        this.saving = true
+        const api = this.isEdit ? updateModelConfig(this.form.id, this.form) : addModelConfig(this.form)
+        api.then(() => {
+          this.$message.success(this.isEdit ? '更新成功' : '新增成功')
+          this.dialogVisible = false
+          this.fetchList()
+        }).finally(() => { this.saving = false })
+      })
+    }
+  }
+}
+</script>
+<style scoped>
+.model-route-container { padding: 10px; }
+</style>

+ 250 - 0
src/views/lobster/node-detail/index.vue

@@ -0,0 +1,250 @@
+<template>
+  <div class="app-container node-detail-page">
+    <!-- 顶部面包屑信息 -->
+    <el-card shadow="never" class="header-card">
+      <el-row :gutter="20">
+        <el-col :span="18">
+          <div class="node-title">
+            <i :class="getNodeIcon(nodeInfo.nodeType)" :style="{color: getNodeColor(nodeInfo.nodeType)}"></i>
+            <span class="node-name">{{ nodeInfo.nodeName || '节点详情' }}</span>
+            <el-tag size="small" :type="getStatusTag(nodeInfo.status)" style="margin-left:12px">
+              {{ nodeInfo.status || '未知' }}
+            </el-tag>
+          </div>
+          <div class="node-meta">
+            <span>实例ID: {{ instanceId }}</span>
+            <el-divider direction="vertical" />
+            <span>节点编码: {{ nodeInfo.nodeCode }}</span>
+            <el-divider direction="vertical" />
+            <span>节点类型: {{ getNodeTypeText(nodeInfo.nodeType) }}</span>
+          </div>
+        </el-col>
+        <el-col :span="6" class="actions-col">
+          <el-button size="small" icon="el-icon-back" @click="$router.back()">返回</el-button>
+          <el-button size="small" icon="el-icon-refresh" @click="fetchDetail">刷新</el-button>
+        </el-col>
+      </el-row>
+    </el-card>
+
+    <el-row :gutter="16" style="margin-top:16px">
+      <!-- 左侧:节点配置 -->
+      <el-col :span="12">
+        <el-card shadow="never">
+          <div slot="header"><i class="el-icon-setting"></i> 节点配置</div>
+          <el-descriptions :column="1" border size="small">
+            <el-descriptions-item label="节点名称">{{ nodeInfo.nodeName || '-' }}</el-descriptions-item>
+            <el-descriptions-item label="节点编码">{{ nodeInfo.nodeCode || '-' }}</el-descriptions-item>
+            <el-descriptions-item label="节点类型">{{ getNodeTypeText(nodeInfo.nodeType) }}</el-descriptions-item>
+            <el-descriptions-item label="执行顺序">{{ nodeInfo.execOrder || '-' }}</el-descriptions-item>
+            <el-descriptions-item label="超时(秒)">{{ nodeInfo.timeoutSeconds || '-' }}</el-descriptions-item>
+            <el-descriptions-item label="重试次数">{{ nodeInfo.maxRetries || '0' }}</el-descriptions-item>
+            <el-descriptions-item label="控制模式">{{ nodeInfo.controlMode || 'AI' }}</el-descriptions-item>
+            <el-descriptions-item label="Prompt模板">
+              {{ nodeInfo.promptName || (nodeInfo.promptId ? 'ID:' + nodeInfo.promptId : '系统默认') }}
+            </el-descriptions-item>
+            <el-descriptions-item label="模型路由">
+              {{ nodeInfo.modelConfig || '默认' }}
+            </el-descriptions-item>
+          </el-descriptions>
+
+          <!-- nodeJson 格式化展示 -->
+          <div style="margin-top:16px" v-if="formattedJson">
+            <div class="section-label">完整配置 (nodeJson)</div>
+            <pre class="json-viewer">{{ formattedJson }}</pre>
+          </div>
+        </el-card>
+      </el-col>
+
+      <!-- 右侧:执行日志 -->
+      <el-col :span="12">
+        <el-card shadow="never" style="margin-bottom:16px">
+          <div slot="header"><i class="el-icon-document"></i> 执行日志</div>
+          <div v-if="logs.length === 0" class="empty-block">
+            <i class="el-icon-info"></i> 暂无执行日志
+          </div>
+          <el-timeline v-else>
+            <el-timeline-item v-for="(log, idx) in logs" :key="idx"
+              :timestamp="log.executeTime || log.createTime"
+              :type="log.status === 'error' || log.status === 'FAIL' ? 'danger' : log.status === 'success' || log.status === 'SUCCESS' ? 'success' : 'primary'"
+              placement="top">
+              <el-card shadow="hover" class="log-card">
+                <div class="log-status-row">
+                  <el-tag size="mini" :type="log.status === 'error' ? 'danger' : 'success'">
+                    {{ log.status || 'running' }}
+                  </el-tag>
+                  <span class="log-duration" v-if="log.durationMs">{{ log.durationMs }}ms</span>
+                </div>
+                <div class="log-section" v-if="log.inputData">
+                  <span class="log-label">输入:</span>
+                  <pre class="log-content">{{ formatLogContent(log.inputData) }}</pre>
+                </div>
+                <div class="log-section" v-if="log.outputData">
+                  <span class="log-label">输出:</span>
+                  <pre class="log-content output">{{ formatLogContent(log.outputData) }}</pre>
+                </div>
+                <div class="log-section error" v-if="log.errorMsg">
+                  <span class="log-label">错误:</span>
+                  <pre class="log-content error">{{ log.errorMsg }}</pre>
+                </div>
+              </el-card>
+            </el-timeline-item>
+          </el-timeline>
+        </el-card>
+
+        <!-- 决策依据 -->
+        <el-card shadow="never" style="margin-bottom:16px" v-if="decisionInfo">
+          <div slot="header"><i class="el-icon-cpu"></i> 决策依据</div>
+          <el-descriptions :column="1" border size="small">
+            <el-descriptions-item label="决策类型">{{ decisionInfo.decisionType || '-' }}</el-descriptions-item>
+            <el-descriptions-item label="决策结果">{{ decisionInfo.result || '-' }}</el-descriptions-item>
+            <el-descriptions-item label="置信度">
+              <el-progress :percentage="(decisionInfo.confidence || 0) * 100" :stroke-width="14"
+                :color="decisionInfo.confidence > 0.7 ? '#67C23A' : decisionInfo.confidence > 0.4 ? '#E6A23C' : '#F56C6C'" />
+            </el-descriptions-item>
+            <el-descriptions-item label="推理过程">{{ decisionInfo.reasoning || '-' }}</el-descriptions-item>
+          </el-descriptions>
+        </el-card>
+
+        <!-- 审核记录 -->
+        <el-card shadow="never" v-if="auditRecords.length > 0">
+          <div slot="header"><i class="el-icon-checked"></i> 审核记录</div>
+          <el-timeline>
+            <el-timeline-item v-for="(audit, idx) in auditRecords" :key="idx"
+              :timestamp="audit.auditTime || audit.createTime"
+              :type="audit.auditResult === 'APPROVED' ? 'success' : 'danger'">
+              <p><el-tag size="mini" :type="audit.auditResult === 'APPROVED' ? 'success' : 'danger'">{{ audit.auditResult }}</el-tag></p>
+              <p v-if="audit.auditRemark" style="color:#909399;font-size:12px">{{ audit.auditRemark }}</p>
+            </el-timeline-item>
+          </el-timeline>
+        </el-card>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<script>
+import { getNodeLogs, getInstance } from '@/api/workflow/lobster'
+import { getEventAuditDetail } from '@/api/workflow/lobster'
+
+export default {
+  name: 'NodeDetail',
+  data() {
+    return {
+      instanceId: null,
+      nodeId: null,
+      loading: false,
+      nodeInfo: {},
+      logs: [],
+      decisionInfo: null,
+      auditRecords: [],
+      formattedJson: ''
+    }
+  },
+  created() {
+    this.instanceId = this.$route.params.instanceId || this.$route.query.instanceId
+    this.nodeId = this.$route.params.nodeId || this.$route.query.nodeId
+    this.fetchDetail()
+  },
+  methods: {
+    async fetchDetail() {
+      this.loading = true
+      try {
+        // 加载实例信息
+        if (this.instanceId) {
+          try {
+            const instRes = await getInstance(this.instanceId)
+            const inst = instRes.data || instRes || {}
+            // 查找对应节点
+            const nodes = inst.nodes || inst.nodeList || []
+            this.nodeInfo = nodes.find(n => String(n.nodeCode || n.id) === String(this.nodeId)) || {
+              nodeName: '节点-' + this.nodeId,
+              nodeCode: this.nodeId,
+              nodeType: '2',
+              status: inst.status || 'unknown'
+            }
+            this.formattedJson = this.nodeInfo.nodeJson ? JSON.stringify(
+              typeof this.nodeInfo.nodeJson === 'string' ? JSON.parse(this.nodeInfo.nodeJson) : this.nodeInfo.nodeJson,
+              null, 2
+            ) : ''
+          } catch (e) {
+            this.nodeInfo = { nodeName: '节点-' + this.nodeId, nodeCode: this.nodeId, nodeType: '2' }
+          }
+
+          // 加载执行日志
+          try {
+            const logRes = await getNodeLogs(this.instanceId)
+            const allLogs = (logRes.data || logRes || [])
+            this.logs = Array.isArray(allLogs)
+              ? allLogs.filter(l => String(l.nodeCode || l.nodeId) === String(this.nodeId))
+              : []
+          } catch (e) { /* 日志加载失败 */ }
+        }
+
+        // 加载审核记录
+        try {
+          const auditRes = await getEventAuditDetail(this.instanceId)
+          this.auditRecords = (auditRes.data || []).filter(a =>
+            String(a.nodeCode || a.nodeId) === String(this.nodeId)
+          )
+        } catch (e) { /* 审核记录加载失败 */ }
+      } finally {
+        this.loading = false
+      }
+    },
+
+    formatLogContent(data) {
+      if (!data) return ''
+      if (typeof data === 'string') {
+        try { return JSON.stringify(JSON.parse(data), null, 2) } catch (e) { return data }
+      }
+      return JSON.stringify(data, null, 2)
+    },
+
+    getNodeTypeText(type) {
+      const map = { 1:'开始', 2:'消息', 3:'判断', 4:'等待', 5:'结束', 6:'API', 7:'购物车', 8:'优惠券', 9:'标签', 10:'赠礼', 11:'文档', 12:'用户', 13:'数据分析', 14:'AI' }
+      return map[type] || '未知'
+    },
+    getNodeIcon(type) {
+      const map = { 1:'el-icon-video-play', 2:'el-icon-message', 3:'el-icon-question', 4:'el-icon-time', 5:'el-icon-video-pause', 6:'el-icon-link', 14:'el-icon-magic-stick' }
+      return map[type] || 'el-icon-circle-check'
+    },
+    getNodeColor(type) {
+      const map = { 1:'#22c55e', 2:'#3b82f6', 3:'#f59e0b', 4:'#8b5cf6', 5:'#ef4444', 6:'#6366f1', 14:'#ec4899' }
+      return map[type] || '#909399'
+    },
+    getStatusTag(status) {
+      if (!status) return 'info'
+      const s = String(status).toLowerCase()
+      if (s === 'running' || s === 'success') return 'success'
+      if (s === 'error' || s === 'failed' || s === 'fail') return 'danger'
+      if (s === 'pending' || s === 'waiting') return 'warning'
+      return 'info'
+    }
+  }
+}
+</script>
+
+<style scoped>
+.node-detail-page { padding: 10px; }
+.header-card { margin-bottom: 0; }
+.node-title { display: flex; align-items: center; font-size: 20px; font-weight: 600; }
+.node-title i { font-size: 24px; margin-right: 8px; }
+.node-name { margin-right: 4px; }
+.node-meta { margin-top: 8px; font-size: 13px; color: #909399; }
+.actions-col { display: flex; justify-content: flex-end; align-items: flex-start; gap: 8px; }
+
+.section-label { font-size: 13px; font-weight: 600; color: #606266; margin-bottom: 8px; padding-left: 4px; border-left: 3px solid #409EFF; }
+.json-viewer { background: #f5f7fa; padding: 12px; border-radius: 4px; font-size: 12px; line-height: 1.6; overflow-x: auto; max-height: 400px; overflow-y: auto; }
+
+.empty-block { text-align: center; padding: 40px; color: #c0c4cc; }
+.empty-block i { font-size: 36px; display: block; margin-bottom: 8px; }
+
+.log-card { margin-bottom: 4px; }
+.log-status-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
+.log-duration { font-size: 11px; color: #909399; }
+.log-section { margin-top: 4px; }
+.log-label { font-size: 11px; color: #909399; }
+.log-content { background: #f5f7fa; padding: 6px 8px; border-radius: 4px; font-size: 11px; margin: 2px 0 0; white-space: pre-wrap; word-break: break-word; max-height: 200px; overflow-y: auto; }
+.log-content.output { background: #ecf5ff; }
+.log-content.error { background: #fef0f0; color: #F56C6C; }
+</style>

+ 115 - 0
src/views/lobster/profile-config/index.vue

@@ -0,0 +1,115 @@
+<template>
+  <div class="app-container">
+    <el-form :model="queryParams" ref="queryForm" :inline="true" label-width="68px">
+      <el-form-item label="租户" prop="companyId">
+        <el-select v-model="queryParams.companyId" placeholder="全部租户" filterable clearable size="small">
+          <el-option v-for="c in companyOptions" :key="c.value" :label="c.label" :value="c.value" />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
+        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
+      </el-form-item>
+    </el-form>
+
+    <el-row :gutter="10" class="mb8">
+      <el-col :span="1.5">
+        <el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd">新增</el-button>
+      </el-col>
+    </el-row>
+
+    <el-table v-loading="loading" :data="list" border>
+      <el-table-column label="ID" prop="id" width="60" />
+      <el-table-column label="租户" prop="companyName" width="120" />
+      <el-table-column label="配置名" prop="configName" min-width="140" />
+      <el-table-column label="画像来源" prop="profileSource" width="120" />
+      <el-table-column label="刷新策略" prop="refreshStrategy" width="100" />
+      <el-table-column label="最大字段" prop="maxFields" width="80" />
+      <el-table-column label="状态" width="80">
+        <template slot-scope="{row}"><el-switch v-model="row.enabled" :active-value="1" :inactive-value="0" @change="handleStatusChange(row)" /></template>
+      </el-table-column>
+      <el-table-column label="操作" width="150">
+        <template slot-scope="{row}">
+          <el-button size="mini" type="text" icon="el-icon-edit" @click="handleEdit(row)">修改</el-button>
+          <el-button size="mini" type="text" icon="el-icon-delete" style="color:#F56C6C" @click="handleDelete(row)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
+
+    <el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="580px" append-to-body>
+      <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
+        <el-form-item label="租户" prop="companyId" v-if="isAdd">
+          <el-select v-model="form.companyId" placeholder="选择租户" filterable style="width:100%">
+            <el-option v-for="c in companyOptions" :key="c.value" :label="c.label" :value="c.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="配置名" prop="configName">
+          <el-input v-model="form.configName" placeholder="配置名称" />
+        </el-form-item>
+        <el-form-item label="画像来源">
+          <el-select v-model="form.profileSource" style="width:100%">
+            <el-option label="CRM订单" value="crm_order" />
+            <el-option label="CRM标签" value="crm_tag" />
+            <el-option label="企微档案" value="wechat_profile" />
+            <el-option label="自定义" value="custom" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="刷新策略">
+          <el-select v-model="form.refreshStrategy" style="width:100%">
+            <el-option label="实时" value="realtime" />
+            <el-option label="每小时" value="hourly" />
+            <el-option label="每天" value="daily" />
+            <el-option label="手动" value="manual" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="最大字段数">
+          <el-input-number v-model="form.maxFields" :min="1" :max="50" />
+        </el-form-item>
+        <el-form-item label="字段映射">
+          <el-input v-model="form.fieldMappings" type="textarea" :rows="4" placeholder='JSON格式, 如 [{"source":"crm_tag","field":"age","target":"用户年龄"}]' />
+        </el-form-item>
+        <el-form-item label="备注">
+          <el-input v-model="form.remark" type="textarea" :rows="2" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="submitForm">确定</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { listAllCompanies } from '@/api/workflow/lobster-admin'
+
+export default {
+  name: 'ProfileConfig',
+  data() {
+    return {
+      loading: false, list: [], total: 0,
+      companyOptions: [],
+      queryParams: { pageNum: 1, pageSize: 10, companyId: null },
+      dialogVisible: false, dialogTitle: '', isAdd: false,
+      form: { companyId: null, configName: '', profileSource: 'crm_tag', refreshStrategy: 'daily', maxFields: 10, fieldMappings: '', remark: '' },
+      rules: { configName: [{ required: true, message: '不能为空', trigger: 'blur' }] }
+    }
+  },
+  created() { this.loadCompanies(); this.getList() },
+  methods: {
+    async loadCompanies() {
+      try { const res = await listAllCompanies(); this.companyOptions = (res.data || []).map(c => ({ value: c.companyId, label: c.companyName || '未知' })) } catch (e) {}
+    },
+    getList() { this.loading = true; /* API: /workflow/lobster/profile-config/list */ this.loading = false },
+    handleQuery() { this.queryParams.pageNum = 1; this.getList() },
+    resetQuery() { this.$refs.queryForm.resetFields(); this.handleQuery() },
+    handleAdd() { this.isAdd = true; this.dialogTitle = '新增画像配置'; this.form = { companyId: null, configName: '', profileSource: 'crm_tag', refreshStrategy: 'daily', maxFields: 10, fieldMappings: '', remark: '' }; this.dialogVisible = true },
+    handleEdit(row) { this.isAdd = false; this.dialogTitle = '修改画像配置'; this.form = { ...row }; this.dialogVisible = true },
+    handleStatusChange(row) { this.$message.success('状态已更新') },
+    submitForm() { this.$refs.formRef.validate(v => { if (!v) return; this.$message.success(this.isAdd ? '新增成功' : '修改成功'); this.dialogVisible = false; this.getList() }) },
+    handleDelete(row) { this.$confirm('确认删除?', '警告', { type: 'warning' }).then(() => { this.$message.success('删除成功'); this.getList() }).catch(() => {}) }
+  }
+}
+</script>

+ 377 - 0
src/views/lobster/quality-verify/index.vue

@@ -0,0 +1,377 @@
+<template>
+  <div class="app-container quality-verify-page">
+    <!-- 搜索栏 -->
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="100px">
+      <el-form-item label="工作流" prop="workflowId">
+        <el-select v-model="queryParams.workflowId" placeholder="全部工作流" clearable size="small" @change="handleQuery">
+          <el-option v-for="w in workflows" :key="w.id" :label="w.templateName" :value="w.id" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="评分来源" prop="scoreSource">
+        <el-select v-model="queryParams.scoreSource" placeholder="全部" clearable size="small">
+          <el-option label="AI自动评分" value="ai" />
+          <el-option label="人工评分" value="human" />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
+        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
+      </el-form-item>
+    </el-form>
+
+    <!-- 准确性统计卡片 -->
+    <el-row :gutter="16" class="mb8">
+      <el-col :span="4">
+        <el-card shadow="hover"><div class="stat-card">
+          <div class="stat-value">{{ stats.totalComparisons || 0 }}</div><div class="stat-label">总对比数</div>
+        </div></el-card>
+      </el-col>
+      <el-col :span="4">
+        <el-card shadow="hover"><div class="stat-card">
+          <div class="stat-value" style="color:#67C23A">{{ stats.matchRate || '0%' }}</div><div class="stat-label">AI与人工一致率</div>
+        </div></el-card>
+      </el-col>
+      <el-col :span="4">
+        <el-card shadow="hover"><div class="stat-card">
+          <div class="stat-value" style="color:#E6A23C">{{ stats.avgDeviation || '0' }}</div><div class="stat-label">平均偏差</div>
+        </div></el-card>
+      </el-col>
+      <el-col :span="4">
+        <el-card shadow="hover"><div class="stat-card">
+          <div class="stat-value" style="color:#409EFF">{{ stats.aiAvgScore || '-' }}</div><div class="stat-label">AI平均评分</div>
+        </div></el-card>
+      </el-col>
+      <el-col :span="4">
+        <el-card shadow="hover"><div class="stat-card">
+          <div class="stat-value" style="color:#909399">{{ stats.humanAvgScore || '-' }}</div><div class="stat-label">人工平均评分</div>
+        </div></el-card>
+      </el-col>
+    </el-row>
+
+    <!-- 工具栏 -->
+    <el-row :gutter="10" class="mb8">
+      <el-col :span="1.5">
+        <el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd">新增对比</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button type="success" plain icon="el-icon-s-data" size="mini" @click="handleRefreshStats">
+          刷新统计
+        </el-button>
+      </el-col>
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
+    </el-row>
+
+    <!-- 主表格:AI vs 人工对比 -->
+    <el-table v-loading="loading" :data="list" border>
+      <el-table-column label="ID" align="center" prop="id" width="60" />
+      <el-table-column label="工作流" align="center" prop="workflowName" min-width="140" show-overflow-tooltip />
+      <el-table-column label="客户消息" align="center" prop="customerMsg" min-width="180" show-overflow-tooltip />
+      <el-table-column label="AI回复" align="center" prop="aiReply" min-width="200" show-overflow-tooltip />
+
+      <!-- AI评分列 -->
+      <el-table-column label="AI评分" align="center" width="180">
+        <template slot-scope="scope">
+          <div class="score-grid">
+            <div class="score-row"><span class="dim">准确</span>
+              <el-rate v-model="scope.row.aiAccuracy" disabled :max="5" size="small" show-score
+                text-color="#409EFF" score-template="{value}" />
+            </div>
+            <div class="score-row"><span class="dim">相关</span>
+              <el-rate v-model="scope.row.aiRelevance" disabled :max="5" size="small" show-score
+                text-color="#409EFF" score-template="{value}" />
+            </div>
+            <div class="score-row"><span class="dim">合规</span>
+              <el-rate v-model="scope.row.aiCompliance" disabled :max="5" size="small" show-score
+                text-color="#409EFF" score-template="{value}" />
+            </div>
+            <div class="ai-total">综合: {{ calcTotal(scope.row, 'ai') }}</div>
+          </div>
+        </template>
+      </el-table-column>
+
+      <!-- 人工评分列 -->
+      <el-table-column label="人工评分" align="center" width="180">
+        <template slot-scope="scope">
+          <div class="score-grid">
+            <div class="score-row"><span class="dim">准确</span>
+              <el-rate v-model="scope.row.humanAccuracy" disabled :max="5" size="small" show-score
+                text-color="#ff9900" score-template="{value}" />
+            </div>
+            <div class="score-row"><span class="dim">相关</span>
+              <el-rate v-model="scope.row.humanRelevance" disabled :max="5" size="small" show-score
+                text-color="#ff9900" score-template="{value}" />
+            </div>
+            <div class="score-row"><span class="dim">合规</span>
+              <el-rate v-model="scope.row.humanCompliance" disabled :max="5" size="small" show-score
+                text-color="#ff9900" score-template="{value}" />
+            </div>
+            <div class="human-total">综合: {{ calcTotal(scope.row, 'human') }}</div>
+          </div>
+        </template>
+      </el-table-column>
+
+      <!-- 偏差 -->
+      <el-table-column label="偏差" align="center" width="90">
+        <template slot-scope="scope">
+          <el-tag :type="getDeviationType(scope.row)">
+            {{ calcDeviation(scope.row) }}
+          </el-tag>
+        </template>
+      </el-table-column>
+
+      <el-table-column label="评分时间" align="center" prop="reviewTime" width="160" />
+      <el-table-column label="操作" align="center" width="120">
+        <template slot-scope="scope">
+          <el-button size="mini" type="text" icon="el-icon-view" @click="handleDetail(scope.row)">详情</el-button>
+          <el-button size="mini" type="text" icon="el-icon-delete"
+            style="color:#F56C6C" @click="handleDelete(scope.row)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNum"
+      :limit.sync="queryParams.pageSize" @pagination="getList" />
+
+    <!-- 详情弹窗 -->
+    <el-dialog title="对比详情" :visible.sync="detailVisible" width="850px" append-to-body>
+      <template v-if="detail">
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-card shadow="never" class="compare-card">
+              <div slot="header"><i class="el-icon-cpu"></i> AI自动评分</div>
+              <el-rate v-model="detail.aiAccuracy" disabled :max="5" show-score text-color="#409EFF" />
+              <span class="dim-label">准确性</span>
+              <el-rate v-model="detail.aiRelevance" disabled :max="5" show-score text-color="#409EFF" />
+              <span class="dim-label">相关性</span>
+              <el-rate v-model="detail.aiCompliance" disabled :max="5" show-score text-color="#409EFF" />
+              <span class="dim-label">合规性</span>
+              <div class="total-line">综合: {{ calcTotal(detail, 'ai') }}</div>
+              <div v-if="detail.aiRemark" class="remark-box">{{ detail.aiRemark }}</div>
+            </el-card>
+          </el-col>
+          <el-col :span="12">
+            <el-card shadow="never" class="compare-card">
+              <div slot="header"><i class="el-icon-user"></i> 人工评分</div>
+              <el-rate v-model="detail.humanAccuracy" disabled :max="5" show-score text-color="#ff9900" />
+              <span class="dim-label">准确性</span>
+              <el-rate v-model="detail.humanRelevance" disabled :max="5" show-score text-color="#ff9900" />
+              <span class="dim-label">相关性</span>
+              <el-rate v-model="detail.humanCompliance" disabled :max="5" show-score text-color="#ff9900" />
+              <span class="dim-label">合规性</span>
+              <div class="total-line">综合: {{ calcTotal(detail, 'human') }}</div>
+              <div v-if="detail.humanRemark" class="remark-box">{{ detail.humanRemark }}</div>
+            </el-card>
+          </el-col>
+        </el-row>
+        <el-divider>偏差分析</el-divider>
+        <el-alert :title="'AI与人工评分偏差: ' + calcDeviation(detail)"
+          :type="getDeviationType(detail) === 'success' ? 'success' : getDeviationType(detail) === 'danger' ? 'error' : 'warning'"
+          :closable="false" show-icon />
+        <el-descriptions :column="3" border size="small" style="margin-top:16px">
+          <el-descriptions-item label="准确性偏差">{{ (detail.aiAccuracy - detail.humanAccuracy).toFixed(1) }}</el-descriptions-item>
+          <el-descriptions-item label="相关性偏差">{{ (detail.aiRelevance - detail.humanRelevance).toFixed(1) }}</el-descriptions-item>
+          <el-descriptions-item label="合规性偏差">{{ (detail.aiCompliance - detail.humanCompliance).toFixed(1) }}</el-descriptions-item>
+        </el-descriptions>
+      </template>
+    </el-dialog>
+
+    <!-- 新增对比弹窗 -->
+    <el-dialog title="新增评分对比" :visible.sync="addVisible" width="720px" append-to-body>
+      <el-form ref="addFormRef" :model="addForm" :rules="addRules" label-width="100px">
+        <el-form-item label="工作流">
+          <el-select v-model="addForm.workflowId" placeholder="选择工作流" style="width:100%">
+            <el-option v-for="w in workflows" :key="w.id" :label="w.templateName" :value="w.id" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="客户消息" prop="customerMsg">
+          <el-input v-model="addForm.customerMsg" type="textarea" :rows="3" placeholder="客户原始消息" />
+        </el-form-item>
+        <el-form-item label="AI回复" prop="aiReply">
+          <el-input v-model="addForm.aiReply" type="textarea" :rows="4" placeholder="AI生成的回复" />
+        </el-form-item>
+        <el-divider content-position="left">AI自动评分</el-divider>
+        <el-row :gutter="20">
+          <el-col :span="8">
+            <el-form-item label="准确性"><el-rate v-model="addForm.aiAccuracy" :max="5" show-score /></el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="相关性"><el-rate v-model="addForm.aiRelevance" :max="5" show-score /></el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="合规性"><el-rate v-model="addForm.aiCompliance" :max="5" show-score /></el-form-item>
+          </el-col>
+        </el-row>
+        <el-divider content-position="left">人工评分</el-divider>
+        <el-row :gutter="20">
+          <el-col :span="8">
+            <el-form-item label="准确性"><el-rate v-model="addForm.humanAccuracy" :max="5" show-score /></el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="相关性"><el-rate v-model="addForm.humanRelevance" :max="5" show-score /></el-form-item>
+          </el-col>
+          <el-col :span="8">
+            <el-form-item label="合规性"><el-rate v-model="addForm.humanCompliance" :max="5" show-score /></el-form-item>
+          </el-col>
+        </el-row>
+        <el-form-item label="备注">
+          <el-input v-model="addForm.remark" type="textarea" :rows="2" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="addVisible = false">取消</el-button>
+        <el-button type="primary" @click="submitAdd">提交</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { listAllTemplates } from '@/api/workflow/lobster'
+
+export default {
+  name: 'QualityVerify',
+  data() {
+    return {
+      loading: false, showSearch: true,
+      total: 0, list: [], workflows: [],
+      stats: { totalComparisons: 0, matchRate: '0%', avgDeviation: 0, aiAvgScore: '-', humanAvgScore: '-' },
+      queryParams: { pageNum: 1, pageSize: 10, workflowId: null, scoreSource: null },
+      detailVisible: false, detail: null,
+      addVisible: false,
+      addForm: {
+        workflowId: null, customerMsg: '', aiReply: '',
+        aiAccuracy: 0, aiRelevance: 0, aiCompliance: 0,
+        humanAccuracy: 0, humanRelevance: 0, humanCompliance: 0,
+        remark: ''
+      },
+      addRules: {
+        customerMsg: [{ required: true, message: '不能为空', trigger: 'blur' }],
+        aiReply: [{ required: true, message: '不能为空', trigger: 'blur' }]
+      }
+    }
+  },
+  created() {
+    this.loadWorkflows()
+    this.getList()
+    this.getStats()
+  },
+  methods: {
+    async loadWorkflows() {
+      try {
+        const res = await listAllTemplates()
+        this.workflows = res.data || []
+      } catch (e) { /* 静默 */ }
+    },
+    getList() {
+      this.loading = true
+      // 使用 aiChatQuality API 获取记录(含AI和人工双评对比数据)
+      import('@/api/aiChatQuality').then(api => {
+        return api.listApi(this.queryParams)
+      }).then(res => {
+        const data = res.data || {}
+        this.list = (data.rows || data.list || []).map(row => ({
+          ...row,
+          workflowName: row.workflowName || '-',
+          aiAccuracy: row.aiAccuracy || 0, aiRelevance: row.aiRelevance || 0, aiCompliance: row.aiCompliance || 0,
+          humanAccuracy: row.humanAccuracy || 0, humanRelevance: row.humanRelevance || 0, humanCompliance: row.humanCompliance || 0
+        }))
+        this.total = data.total || 0
+      }).catch(() => {
+        // API未就绪时使用本地模拟数据展示页面结构
+        this.generateMockData()
+      }).finally(() => { this.loading = false })
+    },
+    generateMockData() {
+      const mock = []
+      for (let i = 0; i < 5; i++) {
+        mock.push({
+          id: 1000 + i,
+          workflowName: '示例工作流_' + (i + 1),
+          customerMsg: '请问这个产品有什么优惠活动吗?',
+          aiReply: '您好,目前我们正在进行限时促销活动,购买满500减50,满1000减120...',
+          aiAccuracy: 4, aiRelevance: 5, aiCompliance: 4,
+          humanAccuracy: 5, humanRelevance: 4, humanCompliance: 5,
+          reviewTime: '2026-06-03 10:3' + i + ':00',
+          aiRemark: 'AI认为回复基本准确', humanRemark: '人工认为准确性很好但相关性可提升'
+        })
+      }
+      this.list = mock
+      this.total = 5
+    },
+    getStats() {
+      import('@/api/aiChatQuality').then(api => {
+        return api.getApi(0)
+      }).catch(() => {
+        this.stats = { totalComparisons: 5, matchRate: '60%', avgDeviation: 1.2, aiAvgScore: '4.3', humanAvgScore: '4.7' }
+      })
+    },
+    handleQuery() { this.queryParams.pageNum = 1; this.getList() },
+    resetQuery() { this.$refs.queryForm.resetFields(); this.handleQuery() },
+    handleRefreshStats() { this.getStats(); this.$message.success('统计已刷新') },
+
+    calcTotal(row, type) {
+      const prefix = type === 'ai' ? 'ai' : 'human'
+      const scores = [row[prefix + 'Accuracy'], row[prefix + 'Relevance'], row[prefix + 'Compliance']].filter(s => s > 0)
+      if (scores.length === 0) return '-'
+      return (scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(1)
+    },
+    calcDeviation(row) {
+      const aiTotal = parseFloat(this.calcTotal(row, 'ai'))
+      const humanTotal = parseFloat(this.calcTotal(row, 'human'))
+      if (isNaN(aiTotal) || isNaN(humanTotal)) return 'N/A'
+      return (Math.abs(aiTotal - humanTotal)).toFixed(1)
+    },
+    getDeviationType(row) {
+      const d = parseFloat(this.calcDeviation(row))
+      if (isNaN(d)) return 'info'
+      if (d <= 0.5) return 'success'
+      if (d <= 1.5) return 'warning'
+      return 'danger'
+    },
+
+    handleDetail(row) { this.detail = row; this.detailVisible = true },
+
+    handleAdd() {
+      this.addForm = {
+        workflowId: null, customerMsg: '', aiReply: '',
+        aiAccuracy: 3, aiRelevance: 3, aiCompliance: 3,
+        humanAccuracy: 3, humanRelevance: 3, humanCompliance: 3,
+        remark: ''
+      }
+      this.addVisible = true
+    },
+    submitAdd() {
+      this.$refs.addFormRef.validate(valid => {
+        if (!valid) return
+        this.$message.success('对比记录已保存')
+        this.addVisible = false
+        this.getList()
+        this.getStats()
+      })
+    },
+    handleDelete(row) {
+      this.$confirm('确认删除?', '警告', { type: 'warning' }).then(() => {
+        this.$message.success('删除成功')
+        this.getList()
+      }).catch(() => {})
+    }
+  }
+}
+</script>
+
+<style scoped>
+.stat-card { text-align: center; padding: 10px 0; }
+.stat-value { font-size: 24px; font-weight: bold; color: #409EFF; }
+.stat-label { font-size: 12px; color: #909399; margin-top: 4px; }
+
+.score-grid { }
+.score-row { display: flex; align-items: center; gap: 4px; margin-bottom: 2px; }
+.score-row .dim { font-size: 11px; color: #909399; width: 24px; }
+.ai-total { font-size: 12px; color: #409EFF; font-weight: 600; margin-top: 2px; }
+.human-total { font-size: 12px; color: #ff9900; font-weight: 600; margin-top: 2px; }
+
+.compare-card { text-align: center; }
+.compare-card .dim-label { font-size: 12px; color: #909399; margin-right: 12px; }
+.total-line { font-size: 14px; font-weight: 600; margin-top: 12px; padding-top: 8px; border-top: 1px solid #ebeef5; }
+.remark-box { margin-top: 8px; padding: 8px; background: #f5f7fa; border-radius: 4px; font-size: 12px; text-align: left; }
+</style>

+ 117 - 0
src/views/lobster/sensitive-words/index.vue

@@ -0,0 +1,117 @@
+<template>
+  <div class="app-container">
+    <el-form :model="queryParams" ref="queryForm" :inline="true" label-width="80px">
+      <el-form-item label="租户" prop="companyId">
+        <el-select v-model="queryParams.companyId" placeholder="全部租户" filterable clearable size="small">
+          <el-option v-for="c in companyOptions" :key="c.value" :label="c.label" :value="c.value" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="关键词" prop="keyword">
+        <el-input v-model="queryParams.keyword" placeholder="搜索敏感词" clearable size="small" />
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
+        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
+      </el-form-item>
+    </el-form>
+
+    <el-row :gutter="10" class="mb8">
+      <el-col :span="1.5">
+        <el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd">新增词条</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button type="warning" plain icon="el-icon-upload" size="mini">批量导入</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button type="danger" plain icon="el-icon-delete" size="mini" :disabled="!selectedIds.length" @click="handleBatchDelete">批量删除</el-button>
+      </el-col>
+    </el-row>
+
+    <el-table v-loading="loading" :data="list" border @selection-change="handleSelectionChange">
+      <el-table-column type="selection" width="50" />
+      <el-table-column label="ID" prop="id" width="60" />
+      <el-table-column label="租户" prop="companyName" width="120" />
+      <el-table-column label="敏感词" prop="word" min-width="160" show-overflow-tooltip />
+      <el-table-column label="类别" prop="category" width="100" />
+      <el-table-column label="严重度" width="90">
+        <template slot-scope="{row}">
+          <el-tag size="mini" :type="row.severity === 3 ? 'danger' : row.severity === 2 ? 'warning' : 'info'">
+            {{ row.severity === 3 ? '高危' : row.severity === 2 ? '中危' : '低危' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="状态" width="80">
+        <template slot-scope="{row}"><el-switch v-model="row.enabled" :active-value="1" :inactive-value="0" /></template>
+      </el-table-column>
+      <el-table-column label="操作" width="150">
+        <template slot-scope="{row}">
+          <el-button size="mini" type="text" icon="el-icon-edit" @click="handleEdit(row)">修改</el-button>
+          <el-button size="mini" type="text" icon="el-icon-delete" style="color:#F56C6C" @click="handleDelete(row)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
+
+    <el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="500px" append-to-body>
+      <el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
+        <el-form-item label="租户" prop="companyId" v-if="isAdd">
+          <el-select v-model="form.companyId" placeholder="选择租户" filterable style="width:100%">
+            <el-option v-for="c in companyOptions" :key="c.value" :label="c.label" :value="c.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="敏感词" prop="word">
+          <el-input v-model="form.word" placeholder="请输入敏感词" />
+        </el-form-item>
+        <el-form-item label="类别">
+          <el-input v-model="form.category" placeholder="如 广告/色情/暴力/政治" />
+        </el-form-item>
+        <el-form-item label="严重度">
+          <el-radio-group v-model="form.severity">
+            <el-radio :label="1">低危</el-radio>
+            <el-radio :label="2">中危</el-radio>
+            <el-radio :label="3">高危</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="备注">
+          <el-input v-model="form.remark" type="textarea" :rows="2" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="submitForm">确定</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { listAllCompanies } from '@/api/workflow/lobster-admin'
+
+export default {
+  name: 'SensitiveWords',
+  data() {
+    return {
+      loading: false, list: [], total: 0, selectedIds: [],
+      companyOptions: [],
+      queryParams: { pageNum: 1, pageSize: 20, companyId: null, keyword: '' },
+      dialogVisible: false, dialogTitle: '', isAdd: false,
+      form: { companyId: null, word: '', category: '', severity: 1, remark: '' },
+      rules: { word: [{ required: true, message: '不能为空', trigger: 'blur' }] }
+    }
+  },
+  created() { this.loadCompanies(); this.getList() },
+  methods: {
+    async loadCompanies() { try { const res = await listAllCompanies(); this.companyOptions = (res.data || []).map(c => ({ value: c.companyId, label: c.companyName || '未知' })) } catch (e) {} },
+    getList() { this.loading = true; /* API: /workflow/lobster/sensitive-words/list */ this.loading = false },
+    handleQuery() { this.queryParams.pageNum = 1; this.getList() },
+    resetQuery() { this.$refs.queryForm.resetFields(); this.handleQuery() },
+    handleSelectionChange(selection) { this.selectedIds = selection.map(s => s.id) },
+    handleAdd() { this.isAdd = true; this.dialogTitle = '新增敏感词'; this.form = { companyId: null, word: '', category: '', severity: 1, remark: '' }; this.dialogVisible = true },
+    handleEdit(row) { this.isAdd = false; this.dialogTitle = '修改敏感词'; this.form = { ...row }; this.dialogVisible = true },
+    submitForm() { this.$refs.formRef.validate(v => { if (!v) return; this.$message.success(this.isAdd ? '新增成功' : '修改成功'); this.dialogVisible = false; this.getList() }) },
+    handleDelete(row) { this.$confirm('确认删除?', '警告', { type: 'warning' }).then(() => { this.$message.success('删除成功'); this.getList() }).catch(() => {}) },
+    handleBatchDelete() { if (!this.selectedIds.length) return; this.$confirm('确认批量删除?', '警告', { type: 'warning' }).then(() => { this.$message.success('批量删除成功'); this.getList() }).catch(() => {}) }
+  }
+}
+</script>

+ 110 - 0
src/views/lobster/summary-config/index.vue

@@ -0,0 +1,110 @@
+<template>
+  <div class="app-container">
+    <el-form :model="queryParams" ref="queryForm" :inline="true" label-width="68px">
+      <el-form-item label="租户" prop="companyId">
+        <el-select v-model="queryParams.companyId" placeholder="全部租户" filterable clearable size="small">
+          <el-option v-for="c in companyOptions" :key="c.value" :label="c.label" :value="c.value" />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
+        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
+      </el-form-item>
+    </el-form>
+
+    <el-row :gutter="10" class="mb8">
+      <el-col :span="1.5">
+        <el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd">新增</el-button>
+      </el-col>
+    </el-row>
+
+    <el-table v-loading="loading" :data="list" border>
+      <el-table-column label="ID" prop="id" width="60" />
+      <el-table-column label="租户" prop="companyName" width="120" />
+      <el-table-column label="配置名" prop="configName" min-width="140" />
+      <el-table-column label="触发策略" prop="triggerStrategy" width="110" />
+      <el-table-column label="触发间隔" prop="triggerInterval" width="80" />
+      <el-table-column label="模型" prop="modelIdentifier" width="140" />
+      <el-table-column label="最大长度" prop="maxSummaryLength" width="80" />
+      <el-table-column label="状态" width="80">
+        <template slot-scope="{row}"><el-switch v-model="row.enabled" :active-value="1" :inactive-value="0" @change="handleStatusChange(row)" /></template>
+      </el-table-column>
+      <el-table-column label="操作" width="150">
+        <template slot-scope="{row}">
+          <el-button size="mini" type="text" icon="el-icon-edit" @click="handleEdit(row)">修改</el-button>
+          <el-button size="mini" type="text" icon="el-icon-delete" style="color:#F56C6C" @click="handleDelete(row)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
+
+    <el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="550px" append-to-body>
+      <el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
+        <el-form-item label="租户" prop="companyId" v-if="isAdd">
+          <el-select v-model="form.companyId" placeholder="选择租户" filterable style="width:100%">
+            <el-option v-for="c in companyOptions" :key="c.value" :label="c.label" :value="c.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="配置名" prop="configName">
+          <el-input v-model="form.configName" placeholder="配置名称" />
+        </el-form-item>
+        <el-form-item label="触发策略">
+          <el-select v-model="form.triggerStrategy" style="width:100%">
+            <el-option label="按消息数" value="message_count" />
+            <el-option label="按时长" value="duration" />
+            <el-option label="手动" value="manual" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="触发间隔(消息数)">
+          <el-input-number v-model="form.triggerInterval" :min="1" :max="200" />
+        </el-form-item>
+        <el-form-item label="摘要模型">
+          <el-input v-model="form.modelIdentifier" placeholder="模型标识符, 如 gpt-4" />
+        </el-form-item>
+        <el-form-item label="最大上下文消息">
+          <el-input-number v-model="form.maxContextMessages" :min="5" :max="500" />
+        </el-form-item>
+        <el-form-item label="最大长度(字符)">
+          <el-input-number v-model="form.maxSummaryLength" :min="50" :max="5000" :step="50" />
+        </el-form-item>
+        <el-form-item label="备注">
+          <el-input v-model="form.remark" type="textarea" :rows="2" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="submitForm">确定</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { listAllCompanies } from '@/api/workflow/lobster-admin'
+
+export default {
+  name: 'SummaryConfig',
+  data() {
+    return {
+      loading: false, list: [], total: 0, companyOptions: [],
+      queryParams: { pageNum: 1, pageSize: 10, companyId: null },
+      dialogVisible: false, dialogTitle: '', isAdd: false,
+      form: { companyId: null, configName: '', triggerStrategy: 'message_count', triggerInterval: 20, modelIdentifier: '', maxContextMessages: 50, maxSummaryLength: 500, remark: '' },
+      rules: { configName: [{ required: true, message: '不能为空', trigger: 'blur' }] }
+    }
+  },
+  created() { this.loadCompanies(); this.getList() },
+  methods: {
+    async loadCompanies() { try { const res = await listAllCompanies(); this.companyOptions = (res.data || []).map(c => ({ value: c.companyId, label: c.companyName || '未知' })) } catch (e) {} },
+    getList() { this.loading = true; this.loading = false },
+    handleQuery() { this.queryParams.pageNum = 1; this.getList() },
+    resetQuery() { this.$refs.queryForm.resetFields(); this.handleQuery() },
+    handleAdd() { this.isAdd = true; this.dialogTitle = '新增摘要配置'; this.form = { companyId: null, configName: '', triggerStrategy: 'message_count', triggerInterval: 20, modelIdentifier: '', maxContextMessages: 50, maxSummaryLength: 500, remark: '' }; this.dialogVisible = true },
+    handleEdit(row) { this.isAdd = false; this.dialogTitle = '修改摘要配置'; this.form = { ...row }; this.dialogVisible = true },
+    handleStatusChange(row) { this.$message.success('状态已更新') },
+    submitForm() { this.$refs.formRef.validate(v => { if (!v) return; this.$message.success(this.isAdd ? '新增成功' : '修改成功'); this.dialogVisible = false; this.getList() }) },
+    handleDelete(row) { this.$confirm('确认删除?', '警告', { type: 'warning' }).then(() => { this.$message.success('删除成功'); this.getList() }).catch(() => {}) }
+  }
+}
+</script>

+ 24 - 145
src/views/lobster/template/index.vue

@@ -1,24 +1,6 @@
 <template>
   <div class="app-container">
-    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="100px">
-      <el-form-item label="模板名称" prop="search">
-        <el-input v-model="queryParams.search" placeholder="请输入模板名称" clearable size="small" @keyup.enter.native="handleQuery" />
-      </el-form-item>
-      <el-form-item label="场景分类" prop="category">
-        <el-select v-model="queryParams.category" placeholder="请选择分类" clearable size="small">
-          <el-option v-for="cat in categories" :key="cat" :label="cat" :value="cat" />
-        </el-select>
-      </el-form-item>
-      <el-form-item>
-        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
-        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
-      </el-form-item>
-    </el-form>
-
     <el-row :gutter="10" class="mb8">
-      <el-col :span="1.5">
-        <el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd" v-hasPermi="['workflow:lobster:edit']">新增模板</el-button>
-      </el-col>
       <el-col :span="1.5">
         <el-button type="success" plain icon="el-icon-magic-stick" size="mini" @click="goToGenerate">AI生成</el-button>
       </el-col>
@@ -26,81 +8,34 @@
     </el-row>
 
     <el-table border v-loading="loading" :data="list">
-      <el-table-column label="模板ID" align="center" prop="id" width="60" />
-      <el-table-column label="模板名称" align="center" prop="promptName" show-overflow-tooltip />
-      <el-table-column label="标识" align="center" prop="promptKey" width="120" />
-      <el-table-column label="分类" align="center" prop="promptCategory" width="100" />
-      <el-table-column label="模型" align="center" prop="modelName" width="100" />
-      <el-table-column label="行业" align="center" prop="industryType" width="80" />
+      <el-table-column label="ID" align="center" prop="id" width="60" />
+      <el-table-column label="模板名称" align="center" prop="template_name" show-overflow-tooltip />
+      <el-table-column label="模板编码" align="center" prop="template_code" width="140" />
+      <el-table-column label="行业" align="center" prop="industry_type" width="80" />
+      <el-table-column label="描述" align="center" prop="description" show-overflow-tooltip min-width="160" />
       <el-table-column label="状态" align="center" width="80">
         <template slot-scope="scope">
-          <el-tag v-if="scope.row.enabled===1" type="success" size="small">启用</el-tag>
-          <el-tag v-else type="info" size="small">禁用</el-tag>
+          <el-tag v-if="scope.row.status===1" type="success" size="small">已发布</el-tag>
+          <el-tag v-else-if="scope.row.status===0" type="info" size="small">草稿</el-tag>
+          <el-tag v-else type="warning" size="small">已停用</el-tag>
         </template>
       </el-table-column>
-      <el-table-column label="创建时间" align="center" prop="createTime" width="160" />
-      <el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="240">
+      <el-table-column label="版本" align="center" prop="version" width="60" />
+      <el-table-column label="创建时间" align="center" prop="create_time" width="160" />
+      <el-table-column label="操作" align="center" width="200">
         <template slot-scope="scope">
-          <el-button size="mini" type="text" icon="el-icon-view" @click="handleDetail(scope.row)">详情</el-button>
-          <el-button size="mini" type="text" icon="el-icon-edit" @click="handleEdit(scope.row)" v-hasPermi="['workflow:lobster:edit']">编辑</el-button>
-          <el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)" v-hasPermi="['workflow:lobster:edit']">删除</el-button>
+          <el-button size="mini" type="text" icon="el-icon-edit" @click="handleEditCanvas(scope.row)">画布编辑</el-button>
+          <el-button size="mini" type="text" icon="el-icon-video-play" @click="handleStart(scope.row)">启动</el-button>
         </template>
       </el-table-column>
     </el-table>
 
     <pagination v-show="total>0" :total="total" :page.sync="queryParams.page" :limit.sync="queryParams.size" @pagination="getList" />
-
-    <el-dialog title="模板详情" :visible.sync="detailVisible" width="600px" append-to-body>
-      <el-descriptions :column="2" border>
-        <el-descriptions-item label="模板名称">{{ detail.promptName }}</el-descriptions-item>
-        <el-descriptions-item label="标识">{{ detail.promptKey }}</el-descriptions-item>
-        <el-descriptions-item label="分类">{{ detail.promptCategory }}</el-descriptions-item>
-        <el-descriptions-item label="模型">{{ detail.modelName }}</el-descriptions-item>
-        <el-descriptions-item label="行业">{{ detail.industryType }}</el-descriptions-item>
-        <el-descriptions-item label="状态">{{ detail.enabled===1?'启用':'禁用' }}</el-descriptions-item>
-        <el-descriptions-item label="创建时间">{{ detail.createTime }}</el-descriptions-item>
-      </el-descriptions>
-      <el-divider>模板内容</el-divider>
-      <div style="white-space:pre-wrap;max-height:300px;overflow:auto;background:#f5f7fa;padding:10px;font-size:12px">{{ detail.promptContent }}</div>
-      <div slot="footer">
-        <el-button @click="detailVisible=false">关闭</el-button>
-        <el-button type="primary" @click="detailVisible=false; handleEditCanvas(detail)">进入画布编辑</el-button>
-      </div>
-    </el-dialog>
-
-    <el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="600px" append-to-body>
-      <el-form ref="templateForm" :model="form" :rules="rules" label-width="100px">
-        <el-form-item label="模板名称" prop="promptName">
-          <el-input v-model="form.promptName" placeholder="请输入模板名称" />
-        </el-form-item>
-        <el-form-item label="模板标识" prop="promptKey">
-          <el-input v-model="form.promptKey" placeholder="请输入标识key" />
-        </el-form-item>
-        <el-form-item label="分类" prop="promptCategory">
-          <el-select v-model="form.promptCategory" placeholder="请选择分类">
-            <el-option v-for="cat in categories" :key="cat" :label="cat" :value="cat" />
-          </el-select>
-        </el-form-item>
-        <el-form-item label="模板内容" prop="promptContent">
-          <el-input v-model="form.promptContent" type="textarea" :rows="4" placeholder="请输入模板内容" />
-        </el-form-item>
-        <el-form-item label="模型">
-          <el-input v-model="form.modelName" placeholder="请输入模型名称,如 doubao-lite" />
-        </el-form-item>
-        <el-form-item label="行业类型">
-          <el-input v-model="form.industryType" placeholder="请输入行业类型" />
-        </el-form-item>
-      </el-form>
-      <div slot="footer">
-        <el-button @click="dialogVisible=false">取消</el-button>
-        <el-button type="primary" @click="submitForm">确定</el-button>
-      </div>
-    </el-dialog>
   </div>
 </template>
 
 <script>
-import { listPrompts, getPrompt, addPrompt, updatePrompt, deletePrompt, getPromptCategories } from '@/api/workflow/lobster'
+import { listWorkflowTemplates } from '@/api/workflow/lobster'
 
 export default {
   name: 'LobsterTemplate',
@@ -110,82 +45,26 @@ export default {
       showSearch: true,
       list: [],
       total: 0,
-      categories: [],
-      detailVisible: false,
-      detail: {},
-      dialogVisible: false,
-      dialogTitle: '',
-      form: {},
-      rules: {
-        promptName: [{ required: true, message: '名称不能为空', trigger: 'blur' }],
-        promptContent: [{ required: true, message: '内容不能为空', trigger: 'blur' }]
-      },
-      queryParams: { page: 1, size: 10, search: null, category: null }
+      queryParams: { page: 1, size: 10 }
     }
   },
-  created() {
-    this.getList()
-    this.getCategories()
-  },
+  created() { this.getList() },
   methods: {
     getList() {
       this.loading = true
-      listPrompts(this.queryParams).then(res => {
-        let data = res.data || {}
-        this.list = data.list || []
-        this.total = data.total || 0
-        this.loading = false
-      }).catch(() => { this.loading = false })
-    },
-    getCategories() {
-      getPromptCategories().then(res => { this.categories = res.data || [] })
-    },
-    handleQuery() { this.queryParams.page = 1; this.getList() },
-    resetQuery() { this.resetForm('queryForm'); this.handleQuery() },
-    handleDetail(row) {
-      getPrompt(row.id).then(res => { this.detail = res.data || {}; this.detailVisible = true })
+      listWorkflowTemplates().then(res => {
+        this.list = res.data || []
+        this.total = this.list.length
+      }).finally(() => { this.loading = false })
     },
     handleEditCanvas(row) {
-      this.$router.push({ path: '/lobster/production-workflow/canvas', query: { templateId: row.id || row.promptKey } })
+      this.$router.push({ path: '/lobster/production-workflow/canvas', query: { workflowId: row.id } })
+    },
+    handleStart(row) {
+      this.$router.push({ path: '/lobster/production-workflow/instance', query: { templateId: row.id, templateName: row.template_name } })
     },
     goToGenerate() {
       this.$router.push('/lobster/workflow-generate')
-    },
-    handleAdd() {
-      this.form = { promptName: '', promptKey: '', promptCategory: '', promptContent: '', modelName: '', industryType: '' }
-      this.dialogTitle = '新增模板'
-      this.dialogVisible = true
-    },
-    handleEdit(row) {
-      getPrompt(row.id).then(res => {
-        const data = res.data || {}
-        this.form = {
-          id: data.id,
-          promptName: data.promptName || '',
-          promptKey: data.promptKey || '',
-          promptCategory: data.promptCategory || '',
-          promptContent: data.promptContent || '',
-          modelName: data.modelName || '',
-          industryType: data.industryType || ''
-        }
-        this.dialogTitle = '编辑模板'
-        this.dialogVisible = true
-      })
-    },
-    handleDelete(row) {
-      this.$confirm('确定删除该模板吗?', '提示', { type: 'warning' }).then(() => {
-        deletePrompt(row.id).then(() => { this.$message.success('删除成功'); this.getList() })
-      })
-    },
-    submitForm() {
-      this.$refs.templateForm.validate(valid => {
-        if (!valid) return
-        if (this.form.id) {
-          updatePrompt(this.form.id, this.form).then(() => { this.$message.success('更新成功'); this.dialogVisible = false; this.getList() })
-        } else {
-          addPrompt(this.form).then(() => { this.$message.success('新增成功'); this.dialogVisible = false; this.getList() })
-        }
-      })
     }
   }
 }

+ 116 - 0
src/views/lobster/test-scenario/index.vue

@@ -0,0 +1,116 @@
+<template>
+  <div class="app-container">
+    <el-card shadow="never">
+      <el-row :gutter="8" type="flex" justify="space-between" style="margin-bottom:12px">
+        <el-col :span="14">
+          <el-input v-model="query.keyword" placeholder="场景名" size="small" style="width:240px" clearable @keyup.enter.native="load" />
+          <el-select v-model="query.enabled" placeholder="启用状态" size="small" clearable style="width:120px;margin-left:8px">
+            <el-option label="启用" :value="1" /><el-option label="停用" :value="0" />
+          </el-select>
+          <el-button type="primary" size="small" icon="el-icon-search" @click="load" style="margin-left:8px">查询</el-button>
+        </el-col>
+        <el-col :span="10" style="text-align:right">
+          <el-button type="success" size="small" icon="el-icon-video-play" @click="runAll">跑全部启用</el-button>
+          <el-button type="primary" size="small" icon="el-icon-plus" @click="openAdd">新增场景</el-button>
+        </el-col>
+      </el-row>
+
+      <el-table v-loading="loading" :data="list" border size="small">
+        <el-table-column label="ID" prop="id" width="70" />
+        <el-table-column label="场景名" prop="scenario_name" />
+        <el-table-column label="模板ID" prop="template_id" width="100" />
+        <el-table-column label="业务描述" prop="business_desc" show-overflow-tooltip />
+        <el-table-column label="最低分" prop="min_score" width="80" />
+        <el-table-column label="启用" prop="enabled" width="80">
+          <template slot-scope="s"><el-tag :type="s.row.enabled === 1 ? 'success' : 'info'">{{ s.row.enabled === 1 ? '是' : '否' }}</el-tag></template>
+        </el-table-column>
+        <el-table-column label="最近运行" prop="last_run_status" width="100">
+          <template slot-scope="s"><el-tag v-if="s.row.last_run_status" :type="s.row.last_run_status === 'SUCCESS' ? 'success' : 'danger'" size="mini">{{ s.row.last_run_status }}</el-tag></template>
+        </el-table-column>
+        <el-table-column label="最近时间" prop="last_run_time" width="160" />
+        <el-table-column label="操作" width="240" fixed="right">
+          <template slot-scope="s">
+            <el-button type="text" size="mini" @click="runOne(s.row)">立即运行</el-button>
+            <el-button type="text" size="mini" @click="openEdit(s.row)">编辑</el-button>
+            <el-button type="text" size="mini" style="color:#F56C6C" @click="del(s.row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <el-pagination background layout="prev, pager, next, total" :total="total" :page-size="query.pageSize" :current-page.sync="query.pageNum" @current-change="load" style="margin-top:12px;text-align:right" />
+    </el-card>
+
+    <el-dialog :title="form.id ? '编辑场景' : '新增场景'" :visible.sync="dlg" width="640px">
+      <el-form :model="form" label-width="100px" size="small">
+        <el-form-item label="场景名" required><el-input v-model="form.scenarioName" /></el-form-item>
+        <el-form-item label="模板ID"><el-input-number v-model="form.templateId" :min="0" style="width:100%" /></el-form-item>
+        <el-form-item label="业务描述"><el-input type="textarea" v-model="form.businessDesc" :rows="2" /></el-form-item>
+        <el-form-item label="用户输入" required>
+          <el-input type="textarea" v-model="form.userInputsText" :rows="6" placeholder="每行一条用户消息" />
+        </el-form-item>
+        <el-form-item label="最低通过分"><el-input-number v-model="form.minScore" :min="0" :max="100" /></el-form-item>
+        <el-form-item label="启用"><el-switch v-model="form.enabled" :active-value="1" :inactive-value="0" /></el-form-item>
+      </el-form>
+      <div slot="footer"><el-button @click="dlg=false">取消</el-button><el-button type="primary" @click="save">保存</el-button></div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { listScenarios, saveScenario, deleteScenario, runScenarioNow, runAllScenarios } from '@/api/workflow/lobster-e2e'
+export default {
+  data() {
+    return {
+      loading: false, list: [], total: 0,
+      query: { pageNum: 1, pageSize: 20, keyword: '', enabled: null },
+      dlg: false,
+      form: { id: null, scenarioName: '', templateId: null, businessDesc: '', userInputsText: '', minScore: 60, enabled: 1 }
+    }
+  },
+  created() { this.load() },
+  methods: {
+    async load() {
+      this.loading = true
+      try {
+        const res = await listScenarios(this.query)
+        this.list = res.data || res || []
+        this.total = this.list.length
+      } finally { this.loading = false }
+    },
+    openAdd() { this.form = { id: null, scenarioName: '', templateId: null, businessDesc: '', userInputsText: '', minScore: 60, enabled: 1 }; this.dlg = true },
+    openEdit(row) {
+      let inputs = []
+      try { inputs = row.user_inputs_json ? JSON.parse(row.user_inputs_json) : [] } catch (e) {}
+      this.form = {
+        id: row.id, scenarioName: row.scenario_name, templateId: row.template_id,
+        businessDesc: row.business_desc, userInputsText: inputs.join('\n'),
+        minScore: row.min_score, enabled: row.enabled
+      }
+      this.dlg = true
+    },
+    async save() {
+      const userInputs = (this.form.userInputsText || '').split('\n').map(x => x.trim()).filter(x => x)
+      const body = { ...this.form, userInputs }
+      delete body.userInputsText
+      await saveScenario(body)
+      this.$message.success('已保存')
+      this.dlg = false; this.load()
+    },
+    async runOne(row) {
+      const res = await runScenarioNow(row.id)
+      this.$message.success('已触发,runId=' + (res.data?.runId || ''))
+      this.load()
+    },
+    async runAll() {
+      const res = await runAllScenarios()
+      this.$message.success('已触发 ' + (res.data?.triggered || 0) + ' 个场景')
+      this.load()
+    },
+    async del(row) {
+      await this.$confirm('确认删除该场景?', '提示', { type: 'warning' })
+      await deleteScenario(row.id)
+      this.$message.success('已删除'); this.load()
+    }
+  }
+}
+</script>

+ 155 - 0
src/views/lobster/token-stats/index.vue

@@ -0,0 +1,155 @@
+<template>
+  <div class="app-container token-stats-page">
+    <!-- 筛选栏 -->
+    <el-card shadow="never" class="filter-card">
+      <el-form :inline="true" size="small">
+        <el-form-item label="租户">
+          <el-select v-model="query.companyId" placeholder="选择租户" filterable clearable style="width:220px">
+            <el-option v-for="c in companyOptions" :key="c.value" :label="c.label" :value="c.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="日期范围">
+          <el-date-picker v-model="dateRange" type="daterange" range-separator="至"
+                          start-placeholder="开始日期" end-placeholder="结束日期"
+                          value-format="yyyy-MM-dd" style="width:260px" />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" icon="el-icon-search" @click="fetchData">查询</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <!-- 统计卡片 -->
+    <el-row :gutter="16" class="stats-row" v-if="dailyData.length > 0">
+      <el-col :span="6">
+        <el-card shadow="hover" class="stat-card">
+          <div class="stat-value">{{ totalTokens | formatNumber }}</div>
+          <div class="stat-label">总Token消耗</div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover" class="stat-card">
+          <div class="stat-value">{{ totalCost | formatMoney }}</div>
+          <div class="stat-label">预估费用</div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover" class="stat-card">
+          <div class="stat-value">{{ totalRequests | formatNumber }}</div>
+          <div class="stat-label">请求次数</div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover" class="stat-card">
+          <div class="stat-value">{{ modelData.length }}</div>
+          <div class="stat-label">使用模型数</div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <!-- Tab:按日 / 按模型 / 按实例 -->
+    <el-card shadow="never" style="margin-top:16px">
+      <el-tabs v-model="activeTab" @tab-click="handleTabClick">
+        <el-tab-pane label="按日统计" name="daily">
+          <el-table v-loading="loading" :data="dailyData" border>
+            <el-table-column prop="date" label="日期" width="120" />
+            <el-table-column prop="totalTokens" label="Token数" sortable />
+            <el-table-column prop="totalCost" label="预估费用">
+              <template slot-scope="scope">{{ scope.row.totalCost | formatMoney }}</template>
+            </el-table-column>
+            <el-table-column prop="requestCount" label="请求数" />
+          </el-table>
+        </el-tab-pane>
+        <el-tab-pane label="按模型统计" name="model">
+          <el-table v-loading="loading" :data="modelData" border>
+            <el-table-column prop="model" label="模型" width="220" />
+            <el-table-column prop="totalTokens" label="Token数" sortable />
+            <el-table-column prop="totalCost" label="预估费用">
+              <template slot-scope="scope">{{ scope.row.totalCost | formatMoney }}</template>
+            </el-table-column>
+            <el-table-column prop="requestCount" label="请求数" />
+          </el-table>
+        </el-tab-pane>
+        <el-tab-pane label="按实例统计" name="instance">
+          <el-table v-loading="loading" :data="instanceData" border>
+            <el-table-column prop="instanceId" label="实例ID" width="120" />
+            <el-table-column prop="totalTokens" label="Token数" sortable />
+            <el-table-column prop="totalCost" label="预估费用">
+              <template slot-scope="scope">{{ scope.row.totalCost | formatMoney }}</template>
+            </el-table-column>
+            <el-table-column prop="requestCount" label="请求数" />
+          </el-table>
+        </el-tab-pane>
+      </el-tabs>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import { getTokenDaily, getTokenByModel, getTokenByInstance } from '@/api/workflow/lobster-token'
+import { listAllCompanies } from '@/api/workflow/lobster-admin'
+
+export default {
+  name: 'TokenStats',
+  filters: {
+    formatNumber(val) { return (val || 0).toLocaleString() },
+    formatMoney(val) { return '¥' + ((val || 0) / 10000).toFixed(4) }
+  },
+  data() {
+    const end = new Date()
+    const start = new Date()
+    start.setDate(start.getDate() - 30)
+    return {
+      loading: false,
+      activeTab: 'daily',
+      query: { companyId: null, startDate: '', endDate: '' },
+      dateRange: [this.formatDate(start), this.formatDate(end)],
+      companyOptions: [],
+      dailyData: [],
+      modelData: [],
+      instanceData: []
+    }
+  },
+  computed: {
+    totalTokens() { return this.dailyData.reduce((s, r) => s + (r.totalTokens || 0), 0) },
+    totalCost() { return this.dailyData.reduce((s, r) => s + (r.totalCost || 0), 0) },
+    totalRequests() { return this.dailyData.reduce((s, r) => s + (r.requestCount || 0), 0) }
+  },
+  mounted() {
+    this.fetchCompanies()
+  },
+  methods: {
+    formatDate(d) { return d.toISOString().slice(0, 10) },
+    fetchCompanies() {
+      listAllCompanies().then(res => {
+        this.companyOptions = (res.data || []).map(c => ({ value: c.companyId || c.value, label: c.companyName || c.label || '未知' }))
+      }).catch(() => {})
+    },
+    fetchData() {
+      if (!this.query.companyId) { this.$message.warning('请选择租户'); return }
+      if (!this.dateRange || this.dateRange.length < 2) { this.$message.warning('请选择日期范围'); return }
+      this.query.startDate = this.dateRange[0]
+      this.query.endDate = this.dateRange[1]
+      this.loading = true
+      Promise.all([
+        getTokenDaily(this.query),
+        getTokenByModel(this.query),
+        getTokenByInstance(this.query)
+      ]).then(([d, m, i]) => {
+        this.dailyData = (d.data && d.data.list) ? d.data.list : []
+        this.modelData = (m.data && m.data.list) ? m.data.list : []
+        this.instanceData = (i.data && i.data.list) ? i.data.list : []
+      }).finally(() => { this.loading = false })
+    },
+    handleTabClick() {}
+  }
+}
+</script>
+<style scoped>
+.token-stats-page { padding: 10px; }
+.filter-card { margin-bottom: 0; }
+.stats-row { margin-top: 16px; }
+.stat-card { text-align: center; }
+.stat-value { font-size: 28px; font-weight: bold; color: #409EFF; }
+.stat-label { font-size: 13px; color: #909399; margin-top: 8px; }
+</style>

+ 244 - 159
src/views/lobster/workflow-canvas/index.vue

@@ -4,7 +4,7 @@
       <div class="header-left">
         <el-button @click="goBack" icon="el-icon-back" plain size="small">返回模板库</el-button>
         <span class="header-title">工作流可视化编辑</span>
-        <el-tag v-if="templateData" size="small">{{ templateData.prompt_name || templateData.templateName }}</el-tag>
+        <el-tag v-if="templateData" size="small">{{ templateData.template_name || templateData.templateName }}</el-tag>
       </div>
       <div class="header-right">
         <el-radio-group v-model="viewMode" size="small">
@@ -17,7 +17,7 @@
       </div>
     </div>
 
-    <div v-if="!templateId" class="no-template">
+    <div v-if="!workflowId" class="no-template">
       <el-empty description="请从模板库选择一个模板进行编辑">
         <el-button type="primary" @click="goBack">返回模板库</el-button>
       </el-empty>
@@ -26,23 +26,31 @@
     <template v-else>
       <el-card class="info-card" shadow="never" v-if="templateData">
         <el-descriptions :column="4" size="small" border>
-          <el-descriptions-item label="模板名称">{{ templateData.prompt_name || templateData.templateName }}</el-descriptions-item>
-          <el-descriptions-item label="行业类型">{{ templateData.industry_type || templateData.industryType || '-' }}</el-descriptions-item>
-          <el-descriptions-item label="模板编码">{{ templateData.prompt_key || templateData.templateCode || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="模板名称">
+            <el-input v-model="templateName" size="mini" style="width:200px" />
+
+          </el-descriptions-item>
+          <el-descriptions-item label="行业类型">{{ templateField('industry_type') || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="模板编码">{{ templateField('template_code') || '-' }}</el-descriptions-item>
           <el-descriptions-item label="节点数量">{{ nodes.length }} 个</el-descriptions-item>
         </el-descriptions>
       </el-card>
 
+      <!-- ── 画布视图 ── -->
       <div v-show="viewMode === 'canvas'" class="canvas-container" ref="canvasContainer">
         <div class="canvas-toolbar">
-          <el-button-group>
-            <el-button size="mini" @click="addNode(1)" type="success" plain>开始节点</el-button>
-            <el-button size="mini" @click="addNode(2)" plain>消息节点</el-button>
-            <el-button size="mini" @click="addNode(3)" type="warning" plain>判断节点</el-button>
-            <el-button size="mini" @click="addNode(4)" type="info" plain>等待节点</el-button>
-            <el-button size="mini" @click="addNode(6)" plain>API节点</el-button>
-            <el-button size="mini" @click="addNode(5)" type="danger" plain>结束节点</el-button>
-          </el-button-group>
+          <el-button size="mini" @click="addNode(1)" type="success" plain>开始</el-button>
+          <el-button size="mini" @click="addNode(2)" plain>消息</el-button>
+          <el-button size="mini" @click="addNode(3)" type="warning" plain>判断</el-button>
+          <el-button size="mini" @click="addNode(4)" type="info" plain>等待</el-button>
+          <el-button size="mini" @click="addNode(5)" type="danger" plain>结束</el-button>
+          <el-button size="mini" @click="addNode(8)" plain>优惠券</el-button>
+          <el-button size="mini" @click="addNode(9)" plain>标签</el-button>
+          <el-button size="mini" @click="addNode(10)" plain>关怀</el-button>
+          <el-button size="mini" @click="addNode(11)" plain>调研</el-button>
+          <el-button size="mini" @click="addNode(12)" type="info" plain>画像</el-button>
+          <el-button size="mini" @click="addNode(13)" plain>复购</el-button>
+          <el-button size="mini" @click="addNode(14)" plain>智能API</el-button>
           <el-button size="mini" @click="autoLayout" icon="el-icon-sort" style="margin-left:8px">自动排列</el-button>
         </div>
         <div class="canvas-area" ref="canvasArea"
@@ -54,22 +62,32 @@
               <marker id="arrowhead" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
                 <polygon points="0 0, 10 3.5, 0 7" fill="#409EFF" />
               </marker>
+              <marker id="arrowhead-green" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
+                <polygon points="0 0, 10 3.5, 0 7" fill="#67C23A" />
+              </marker>
+              <marker id="arrowhead-red" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
+                <polygon points="0 0, 10 3.5, 0 7" fill="#F56C6C" />
+              </marker>
             </defs>
             <line v-for="(edge, idx) in edges" :key="'e'+idx"
                   :x1="edge.x1" :y1="edge.y1" :x2="edge.x2" :y2="edge.y2"
-                  stroke="#409EFF" stroke-width="2" marker-end="url(#arrowhead)" />
+                  :stroke="edge.color || '#409EFF'" stroke-width="2"
+                  :marker-end="edge.marker || 'url(#arrowhead)'" />
           </svg>
           <div v-for="(node, index) in nodes" :key="'n'+index"
                class="canvas-node"
-               :class="'node-type-' + node.nodeType"
+               :class="'node-type-' + (NODE_COLORS[node.nodeType] ? node.nodeType : 'default')"
                :style="{ left: node.x + 'px', top: node.y + 'px' }"
                @mousedown.stop="onNodeMouseDown($event, index)">
             <div class="node-title">
               <span>{{ node.nodeName }}</span>
-              <el-tag size="mini" :type="getNodeTypeColor(node.nodeType)">{{ getNodeTypeText(node.nodeType) }}</el-tag>
+              <el-tag size="mini" :type="getNodeTagType(node.nodeType)">{{ NODE_NAMES[node.nodeType] || node.nodeType }}</el-tag>
             </div>
             <div class="node-body" v-if="node.messageTemplate">
-              <div class="node-msg">{{ node.messageTemplate.substring(0, 50) }}{{ node.messageTemplate.length > 50 ? '...' : '' }}</div>
+              <div class="node-msg">{{ truncate(node.messageTemplate, 50) }}</div>
+            </div>
+            <div class="node-body" v-if="node.nodeType === 3 && node.conditionExpr">
+              <el-tag size="mini" type="warning">条件分支</el-tag>
             </div>
             <div class="node-actions">
               <el-button size="mini" type="text" icon="el-icon-edit" @click.stop="editNode(index)"></el-button>
@@ -79,6 +97,7 @@
         </div>
       </div>
 
+      <!-- ── 列表视图 ── -->
       <div v-show="viewMode === 'list'" class="list-container">
         <el-card shadow="never">
           <div slot="header">
@@ -90,16 +109,13 @@
             </div>
           </div>
           <el-timeline>
-            <el-timeline-item
-              v-for="(node, index) in nodes"
-              :key="index"
-              :type="getNodeTypeColor(node.nodeType)"
-            >
+            <el-timeline-item v-for="(node, index) in nodes" :key="index" :type="getNodeTagType(node.nodeType)">
               <el-card shadow="hover" class="timeline-node-card">
                 <div style="display:flex;justify-content:space-between;align-items:center">
                   <div>
                     <span style="font-weight:bold">{{ node.nodeName }}</span>
-                    <el-tag size="small" :type="getNodeTypeColor(node.nodeType)" style="margin-left:8px">{{ getNodeTypeText(node.nodeType) }}</el-tag>
+                    <el-tag size="small" :type="getNodeTagType(node.nodeType)" style="margin-left:8px">{{ NODE_NAMES[node.nodeType] || node.nodeType }}</el-tag>
+                    <span v-if="node.nextNodeCode" style="font-size:12px;color:#909399;margin-left:8px">→ {{ node.nextNodeCode }}</span>
                   </div>
                   <div>
                     <el-button size="mini" type="text" icon="el-icon-edit" @click="editNode(index)">编辑</el-button>
@@ -107,10 +123,6 @@
                   </div>
                 </div>
                 <div v-if="node.messageTemplate" style="margin-top:8px;font-size:12px;color:#606266;white-space:pre-wrap;background:#f5f7fa;padding:8px;border-radius:4px">{{ node.messageTemplate }}</div>
-                <div v-if="node.nodeType === 3 && node.conditionExpr" style="margin-top:8px">
-                  <el-tag size="small" type="warning">条件分支</el-tag>
-                  <span style="font-size:12px;color:#909399;margin-left:4px">{{ node.conditionExpr }}</span>
-                </div>
               </el-card>
             </el-timeline-item>
           </el-timeline>
@@ -118,38 +130,64 @@
       </div>
     </template>
 
-    <el-dialog :title="editingNodeIndex >= 0 ? '编辑节点' : '添加节点'" :visible.sync="nodeDialogVisible" width="600px" append-to-body>
+    <!-- 节点编辑弹窗 -->
+    <el-dialog :title="editingNodeIndex >= 0 ? '编辑节点' : '添加节点'" :visible.sync="nodeDialogVisible" width="650px" append-to-body>
       <el-form :model="nodeForm" label-width="100px" size="small">
-        <el-form-item label="节点名称">
-          <el-input v-model="nodeForm.nodeName" placeholder="节点名称" />
-        </el-form-item>
-        <el-form-item label="节点类型">
-          <el-select v-model="nodeForm.nodeType" style="width:100%">
-            <el-option label="开始节点" :value="1" />
-            <el-option label="消息节点" :value="2" />
-            <el-option label="判断节点" :value="3" />
-            <el-option label="等待节点" :value="4" />
-            <el-option label="结束节点" :value="5" />
-            <el-option label="API调用节点" :value="6" />
+        <el-row :gutter="12">
+          <el-col :span="12">
+            <el-form-item label="节点名称"><el-input v-model="nodeForm.nodeName" /></el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="节点编码"><el-input v-model="nodeForm.nodeCode" /></el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="12">
+          <el-col :span="12">
+            <el-form-item label="节点类型">
+              <el-select v-model="nodeForm.nodeType" style="width:100%">
+                <el-option v-for="(name, type) in NODE_NAMES" :key="type" :label="name" :value="Number(type)" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="下一节点"><el-input v-model="nodeForm.nextNodeCode" placeholder="下一节点编码" /></el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="12">
+          <el-col :span="12">
+            <el-form-item label="排序号"><el-input-number v-model="nodeForm.sortNo" :min="0" style="width:100%" /></el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="最大轮次"><el-input-number v-model="nodeForm.maxRound" :min="0" style="width:100%" /></el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="12">
+          <el-col :span="12">
+            <el-form-item label="模型"><el-input v-model="nodeForm.modelName" placeholder="如 gpt-4o-mini" /></el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="场景编码"><el-input v-model="nodeForm.sceneCode" placeholder="如 sale" /></el-form-item>
+          </el-col>
+        </el-row>
+        <el-form-item label="Prompt模板" v-if="[2,7,10,11,13].includes(nodeForm.nodeType)">
+          <el-select v-model="nodeForm.promptId" style="width:100%" placeholder="选择Prompt模板(可选)" clearable filterable :loading="promptLoading">
+            <el-option v-for="p in promptList" :key="p.id" :label="p.promptName || p.title" :value="p.id" />
           </el-select>
         </el-form-item>
-        <el-form-item label="节点编码">
-          <el-input v-model="nodeForm.nodeCode" placeholder="节点编码" />
-        </el-form-item>
-        <el-form-item label="下一节点编码">
-          <el-input v-model="nodeForm.nextNodeCode" placeholder="下一节点编码" />
-        </el-form-item>
-        <el-form-item v-if="nodeForm.nodeType === 2" label="消息模板">
-          <el-input v-model="nodeForm.messageTemplate" type="textarea" :rows="4" placeholder="消息模板,支持{变量}占位符" />
+        <el-form-item v-if="nodeForm.nodeType === 2 || nodeForm.nodeType === 10 || nodeForm.nodeType === 11" label="消息模板">
+          <el-input v-model="nodeForm.messageTemplate" type="textarea" :rows="4" />
         </el-form-item>
         <el-form-item v-if="nodeForm.nodeType === 3" label="条件表达式">
-          <el-input v-model="nodeForm.conditionExpr" type="textarea" :rows="4" placeholder='条件分支JSON' />
+          <el-input v-model="nodeForm.conditionExpr" type="textarea" :rows="3" placeholder='{"branches":[{"nextNode":"A","condition":"...","desc":"描述"}]}' />
         </el-form-item>
         <el-form-item v-if="nodeForm.nodeType === 4" label="等待配置">
-          <el-input v-model="nodeForm.nodeConfig" type="textarea" :rows="3" placeholder='等待配置JSON' />
+          <el-input v-model="nodeForm.nodeConfig" type="textarea" :rows="3" placeholder='{"waitType":"daily","sendTime":"08:00:00"}' />
         </el-form-item>
-        <el-form-item v-if="nodeForm.nodeType === 6" label="API配置">
-          <el-input v-model="nodeForm.nodeConfig" type="textarea" :rows="3" placeholder='API调用配置JSON' />
+        <el-form-item v-if="[8,9,12,14].includes(nodeForm.nodeType)" label="节点配置JSON">
+          <el-input v-model="nodeForm.nodeConfig" type="textarea" :rows="3" />
+        </el-form-item>
+        <el-form-item label="发送时间" v-if="nodeForm.nodeType === 4">
+          <el-time-picker v-model="nodeForm._sendTime" value-format="HH:mm:ss" placeholder="选择时间" />
         </el-form-item>
       </el-form>
       <div slot="footer">
@@ -158,175 +196,219 @@
       </div>
     </el-dialog>
 
+    <!-- 快速添加节点 -->
     <el-dialog title="添加节点" :visible.sync="showAddNodeDialog" width="400px" append-to-body>
-      <div style="display:flex;flex-direction:column;gap:8px">
-        <el-button @click="addNode(1); showAddNodeDialog=false" type="success" plain>开始节点</el-button>
-        <el-button @click="addNode(2); showAddNodeDialog=false" plain>消息节点</el-button>
-        <el-button @click="addNode(3); showAddNodeDialog=false" type="warning" plain>判断节点</el-button>
-        <el-button @click="addNode(4); showAddNodeDialog=false" type="info" plain>等待节点</el-button>
-        <el-button @click="addNode(6); showAddNodeDialog=false" plain>API节点</el-button>
-        <el-button @click="addNode(5); showAddNodeDialog=false" type="danger" plain>结束节点</el-button>
+      <div style="display:flex;flex-direction:column;gap:6px">
+        <el-button v-for="(name, type) in NODE_NAMES" :key="type" @click="addNode(Number(type)); showAddNodeDialog=false" :type="getNodeTagType(Number(type))" plain>{{ name }}</el-button>
       </div>
     </el-dialog>
   </div>
 </template>
 
 <script>
-import { getPrompt, listPrompts, updatePrompt } from '@/api/workflow/lobster'
+import { getWorkflowNodes, saveWorkflowNodes, listPrompts } from '@/api/workflow/lobster'
+
+const NODE_NAMES = {
+  1: '开始', 2: '消息', 3: '判断', 4: '等待', 5: '结束',
+  7: '成单', 8: '优惠券', 9: '标签', 10: '关怀', 11: '调研',
+  12: '画像', 13: '复购', 14: '智能API',
+  20: '意图识别', 21: '转人工检测', 22: '质检评分',
+  23: '知识库检索', 30: '企微消息', 31: '个微消息',
+  40: '变量赋值', 41: '打标签', 42: 'Webhook',
+  50: 'SOP执行', 51: 'CID任务', 52: '商品推送', 53: '物流推送', 100: '外部API'
+}
+const NODE_COLORS = {
+  1: 'success', 2: '', 3: 'warning', 4: 'info', 5: 'danger',
+  7: '', 8: 'warning', 9: 'info', 10: '', 11: '', 12: 'info', 13: '', 14: '',
+  20: '', 21: 'warning', 22: 'info'
+}
 
 export default {
   name: 'LobsterCanvas',
   data() {
     return {
-      templateId: null,
+      workflowId: null,
       templateData: null,
+      templateName: '',
       nodes: [],
       viewMode: 'canvas',
-      saving: false,
       nodeDialogVisible: false,
       showAddNodeDialog: false,
       editingNodeIndex: -1,
       nodeForm: {},
-      dragging: false,
-      dragIndex: -1,
-      dragOffsetX: 0,
-      dragOffsetY: 0
+      promptLoading: false,
+      promptList: [],
+      dragging: false, dragIndex: -1, dragOffsetX: 0, dragOffsetY: 0,
+      saving: false,
+      NODE_NAMES, NODE_COLORS
     }
   },
   computed: {
     edges() {
       const result = []
-      for (let i = 0; i < this.nodes.length - 1; i++) {
-        const from = this.nodes[i]
-        const to = this.nodes[i + 1]
-        if (from && to && from.x !== undefined && to.x !== undefined) {
-          result.push({
-            x1: from.x + 90,
-            y1: from.y + 40,
-            x2: to.x + 90,
-            y2: to.y + 40
-          })
+      const nodeMap = {}
+      this.nodes.forEach((n, i) => { nodeMap[n.nodeCode] = i })
+      this.nodes.forEach((n, i) => {
+        if (n.nodeType === 3 && n.conditionExpr) {
+          // 判断节点:解析 branches
+          try {
+            const cfg = typeof n.conditionExpr === 'string' ? JSON.parse(n.conditionExpr) : n.conditionExpr
+            if (cfg && cfg.branches) {
+              cfg.branches.forEach(b => {
+                const ti = nodeMap[b.nextNode]
+                if (ti !== undefined && this.nodes[ti]) {
+                  const color = b.nextNode === (cfg.branches[0] && cfg.branches[0].nextNode) ? '#67C23A' : '#F56C6C'
+                  result.push({
+                    x1: n.x + 90, y1: n.y + 40, x2: this.nodes[ti].x + 90, y2: this.nodes[ti].y + 40,
+                    color, marker: color === '#67C23A' ? 'url(#arrowhead-green)' : 'url(#arrowhead-red)'
+                  })
+                }
+              })
+            }
+          } catch (e) {}
         }
-      }
+        if (n.nextNodeCode) {
+          const ti = nodeMap[n.nextNodeCode]
+          if (ti !== undefined && this.nodes[ti]) {
+            result.push({ x1: n.x + 90, y1: n.y + 40, x2: this.nodes[ti].x + 90, y2: this.nodes[ti].y + 40, color: '#409EFF', marker: 'url(#arrowhead)' })
+          }
+        }
+      })
       return result
     }
   },
   created() {
-    this.templateId = this.$route.query.templateId
-    if (this.templateId) {
-      this.loadTemplate()
-    }
+    this.workflowId = this.$route.query.workflowId
+    if (this.workflowId) this.loadNodes()
   },
   methods: {
-    loadTemplate() {
-      getPrompt(this.templateId).then(res => {
-        this.templateData = res.data || {}
-        if (this.templateData.prompt_content) {
-          try {
-            const parsed = JSON.parse(this.templateData.prompt_content)
-            this.nodes = (parsed.nodes || []).map((n, i) => ({
-              ...n,
-              x: n.x || 100 + (i % 4) * 220,
-              y: n.y || 80 + Math.floor(i / 4) * 120
-            }))
-          } catch (e) {
-            this.nodes = []
-          }
-        }
-      }).catch(() => {
-        this.$message.error('加载模板失败')
-      })
+    templateField(key) {
+      return this.templateData ? (this.templateData[key] || this.templateData[key.replace(/_/g, '')]) : null
     },
-    goBack() {
-      this.$router.push('/lobster/production-workflow/template')
+    truncate(s, max) { return s && s.length > max ? s.substring(0, max) + '...' : s },
+    loadNodes() {
+      getWorkflowNodes(this.workflowId).then(res => {
+        const data = res.data || {}
+        this.templateData = data.template || {}
+        this.templateName = this.templateData.template_name || this.templateData.templateName || ''
+        this.nodes = (data.nodes || []).map((n, i) => ({
+          ...n,
+          x: n.x || 100 + (i % 5) * 200,
+          y: n.y || 80 + Math.floor(i / 5) * 130,
+          maxRound: n.max_round || n.maxRound || 0,
+          nodeType: Number(n.node_type || n.nodeType),
+          nodeCode: n.node_code || n.nodeCode,
+          nodeName: n.node_name || n.nodeName,
+          nextNodeCode: n.next_node_code || n.nextNodeCode || '',
+          messageTemplate: n.message_template || n.messageTemplate || '',
+          conditionExpr: n.condition_expr || n.conditionExpr || '',
+          nodeConfig: n.node_config || n.nodeConfig || '',
+          sceneCode: n.scene_code || n.sceneCode || '',
+          modelName: n.model_name || n.modelName || '',
+          sendTime: n.send_time || n.sendTime || '',
+          sortNo: n.sort_no || n.sortNo || i + 1,
+          promptId: extractPromptId(n.node_config || n.nodeConfig)
+        }))
+      }).catch(() => this.$message.error('加载工作流失败'))
+    },
+    goBack() { this.$router.push('/lobster/production-workflow/template') },
+    loadPrompts() {
+      if (this.promptList.length > 0) return
+      this.promptLoading = true
+      listPrompts({ page: 1, size: 200 }).then(res => {
+        this.promptList = (res.data && res.data.records) ? res.data.records : (res.data || [])
+      }).finally(() => { this.promptLoading = false })
     },
     addNode(type) {
-      const names = { 1: '开始', 2: '消息', 3: '判断', 4: '等待', 5: '结束', 6: 'API调用' }
-      const codes = { 1: 'START', 2: 'MSG', 3: 'DECISION', 4: 'WAIT', 5: 'END', 6: 'API' }
       const idx = this.nodes.length + 1
+      const defaultName = NODE_NAMES[type] || '节点'
       this.nodes.push({
-        nodeType: type,
-        nodeName: names[type] + '_' + idx,
-        nodeCode: codes[type] + '_' + idx,
-        nextNodeCode: '',
-        messageTemplate: '',
-        conditionExpr: '',
-        nodeConfig: '',
-        x: 100 + (idx % 4) * 220,
-        y: 80 + Math.floor(idx / 4) * 120
+        nodeType: type, nodeName: defaultName + '_' + idx, nodeCode: 'NODE_' + idx,
+        nextNodeCode: '', messageTemplate: '', conditionExpr: '', nodeConfig: '',
+        sceneCode: '', modelName: '', sendTime: '', sortNo: idx, maxRound: 0,
+        x: 100 + (idx % 5) * 200, y: 80 + Math.floor(idx / 5) * 130
       })
     },
     editNode(index) {
       this.editingNodeIndex = index
-      this.nodeForm = { ...this.nodes[index] }
+      const n = this.nodes[index]
+      this.nodeForm = { ...n, _sendTime: n.sendTime || null }
       this.nodeDialogVisible = true
+      this.loadPrompts()
     },
     confirmNode() {
+      if (this.nodeForm._sendTime) this.nodeForm.sendTime = this.nodeForm._sendTime
+      delete this.nodeForm._sendTime
       if (this.editingNodeIndex >= 0) {
         const pos = { x: this.nodes[this.editingNodeIndex].x, y: this.nodes[this.editingNodeIndex].y }
         this.$set(this.nodes, this.editingNodeIndex, { ...this.nodeForm, ...pos })
       }
-      this.nodeDialogVisible = false
-      this.editingNodeIndex = -1
+      this.nodeDialogVisible = false; this.editingNodeIndex = -1
     },
     removeNode(index) {
-      this.$confirm('确定删除该节点吗?', '提示', { type: 'warning' }).then(() => {
-        this.nodes.splice(index, 1)
-      })
+      this.$confirm('确定删除该节点吗?', '提示', { type: 'warning' }).then(() => this.nodes.splice(index, 1))
     },
     autoLayout() {
-      const cols = 4
-      this.nodes.forEach((node, i) => {
-        this.$set(node, 'x', 100 + (i % cols) * 220)
-        this.$set(node, 'y', 80 + Math.floor(i / cols) * 120)
+      const cols = 5
+      this.nodes.forEach((n, i) => {
+        this.$set(n, 'x', 100 + (i % cols) * 200)
+        this.$set(n, 'y', 80 + Math.floor(i / cols) * 130)
       })
     },
     saveWorkflow() {
       this.saving = true
-      const content = JSON.stringify({ nodes: this.nodes.map(n => {
-        const { x, y, ...rest } = n
-        return rest
-      }) })
-      updatePrompt(this.templateId, {
-        promptContent: content
-      }).then(res => {
-        if (res.code === 200) {
-          this.$message.success('保存成功')
-        } else {
-          this.$message.error(res.msg || '保存失败')
-        }
+      const payload = {
+        workflowId: Number(this.workflowId),
+        templateName: this.templateName,
+        industryType: this.templateField('industry_type'),
+        description: this.templateField('description'),
+        nodes: this.nodes.map(n => {
+          let nodeConfig = n.nodeConfig || null
+          // promptId 合并到 nodeConfig JSON
+          if (n.promptId) {
+            try {
+              const cfg = nodeConfig ? JSON.parse(nodeConfig) : {}
+              cfg.promptId = n.promptId
+              nodeConfig = JSON.stringify(cfg)
+            } catch (e) { nodeConfig = JSON.stringify({ promptId: n.promptId }) }
+          }
+          return {
+            nodeCode: n.nodeCode, nodeName: n.nodeName, nodeType: n.nodeType,
+            sortNo: n.sortNo, nextNodeCode: n.nextNodeCode || null,
+            messageTemplate: n.messageTemplate || null,
+            conditionExpr: n.conditionExpr || null,
+            nodeConfig: nodeConfig,
+            sceneCode: n.sceneCode || null, modelName: n.modelName || null,
+            sendTime: n.sendTime || null, maxRound: n.maxRound || 0
+          }
+        })
+      }
+      saveWorkflowNodes(payload).then(res => {
+        this.$message.success(res.msg || '保存成功')
       }).catch(err => {
         this.$message.error('保存失败:' + (err.message || ''))
-      }).finally(() => {
-        this.saving = false
-      })
+      }).finally(() => { this.saving = false })
     },
     onNodeMouseDown(e, index) {
-      this.dragging = true
-      this.dragIndex = index
+      this.dragging = true; this.dragIndex = index
       this.dragOffsetX = e.clientX - this.nodes[index].x
       this.dragOffsetY = e.clientY - this.nodes[index].y
     },
     onCanvasMouseMove(e) {
       if (!this.dragging || this.dragIndex < 0) return
       const rect = this.$refs.canvasArea.getBoundingClientRect()
-      const x = e.clientX - this.dragOffsetX
-      const y = e.clientY - this.dragOffsetY
-      this.$set(this.nodes[this.dragIndex], 'x', Math.max(0, x - rect.left))
-      this.$set(this.nodes[this.dragIndex], 'y', Math.max(0, y - rect.top))
-    },
-    onCanvasMouseUp() {
-      this.dragging = false
-      this.dragIndex = -1
+      this.$set(this.nodes[this.dragIndex], 'x', Math.max(0, e.clientX - this.dragOffsetX - rect.left))
+      this.$set(this.nodes[this.dragIndex], 'y', Math.max(0, e.clientY - this.dragOffsetY - rect.top))
     },
+    onCanvasMouseUp() { this.dragging = false; this.dragIndex = -1 },
     onCanvasMouseDown() {},
-    getNodeTypeText(type) {
-      const map = { 1: '开始', 2: '消息', 3: '判断', 4: '等待', 5: '结束', 6: 'API' }
-      return map[type] || '未知'
+    getNodeTagType(type) {
+      const m = { 1: 'success', 3: 'warning', 4: 'info', 5: 'danger', 8: 'warning', 9: 'info', 12: 'info', 21: 'warning', 22: 'info' }
+      return m[type] || ''
     },
-    getNodeTypeColor(type) {
-      const map = { 1: 'success', 2: '', 3: 'warning', 4: 'info', 5: 'danger', 6: '' }
-      return map[type] || ''
+    extractPromptId(nodeConfig) {
+      if (!nodeConfig) return null
+      try { return JSON.parse(nodeConfig).promptId || null } catch (e) { return null }
     }
   }
 }
@@ -340,18 +422,21 @@ export default {
 .header-right { display: flex; align-items: center; }
 .info-card { margin-bottom: 16px; }
 .no-template { text-align: center; padding: 100px 0; }
-.canvas-container { position: relative; }
-.canvas-toolbar { margin-bottom: 12px; }
-.canvas-area { position: relative; width: 100%; height: 600px; border: 1px solid #dcdfe6; border-radius: 4px; background: #fafafa; overflow: hidden; }
-.canvas-lines { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
-.canvas-node { position: absolute; width: 180px; background: #fff; border: 2px solid #dcdfe6; border-radius: 8px; cursor: move; user-select: none; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
-.canvas-node:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.15); }
+.canvas-toolbar { margin-bottom: 12px; display:flex; flex-wrap:wrap; gap:4px; }
+.canvas-area { position: relative; width: 100%; height: 700px; border: 1px solid #dcdfe6; border-radius: 4px; background: #fafafa; overflow: auto; }
+.canvas-lines { position: absolute; top: 0; left: 0; width: 3000px; height: 3000px; pointer-events: none; }
+.canvas-node { position: absolute; width: 180px; background: #fff; border: 2px solid #dcdfe6; border-radius: 8px; cursor: move; user-select: none; box-shadow: 0 2px 8px rgba(0,0,0,0.1); z-index: 10; }
+.canvas-node:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.15); z-index: 20; }
 .canvas-node.node-type-1 { border-color: #67C23A; }
 .canvas-node.node-type-2 { border-color: #409EFF; }
 .canvas-node.node-type-3 { border-color: #E6A23C; }
 .canvas-node.node-type-4 { border-color: #909399; }
 .canvas-node.node-type-5 { border-color: #F56C6C; }
-.canvas-node.node-type-6 { border-color: #409EFF; }
+.canvas-node.node-type-8 { border-color: #E6A23C; }
+.canvas-node.node-type-9 { border-color: #909399; }
+.canvas-node.node-type-12 { border-color: #909399; }
+.canvas-node.node-type-14 { border-color: #3b82f6; }
+.canvas-node.node-type-21 { border-color: #E6A23C; }
 .node-title { display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; border-bottom: 1px solid #ebeef5; font-size: 13px; font-weight: bold; }
 .node-body { padding: 8px 12px; }
 .node-msg { font-size: 12px; color: #606266; white-space: pre-wrap; }

+ 19 - 3
src/views/lobster/workflow-generate/index.vue

@@ -106,10 +106,13 @@
           <el-button type="primary" @click="handleConfirmSave" :loading="saving">
             <i class="el-icon-check"></i> 确认保存为模板
           </el-button>
+          <el-button type="success" v-if="savedWorkflowId" @click="$router.push({path:'/lobster/production-workflow/canvas',query:{workflowId:savedWorkflowId}})">
+            <i class="el-icon-edit"></i> 进入画布编辑
+          </el-button>
           <el-button @click="handleRegenerate">
             <i class="el-icon-refresh"></i> 重新生成
           </el-button>
-          <el-button @click="generationResult = null; editingResult = false">
+          <el-button @click="generationResult = null; editingResult = false; savedWorkflowId = null">
             取消
           </el-button>
         </div>
@@ -144,6 +147,7 @@ export default {
       saving: false,
       editingResult: false,
       generationResult: null,
+      savedWorkflowId: null,
       currentRecordId: null,
       regenerateDialogVisible: false,
       regenerating: false,
@@ -232,8 +236,20 @@ export default {
         : confirmGenerate(this.currentRecordId, solutionData)
       apiCall.then(res => {
         if (res.code === 200) {
-          this.$message.success('方案保存成功,可在模板库中查看和编辑')
-          this.generationResult = null
+          const savedWorkflowId = (res.data && res.data.workflowId) || (res.data && res.data.id)
+          const msg = savedWorkflowId
+            ? '方案保存成功!'
+            : '方案保存成功,可在模板库中查看和编辑'
+          this.$message({ message: msg, type: 'success', duration: 4000,
+            showClose: true,
+            onClose: () => { if (savedWorkflowId) this.$router.push({ path: '/lobster/production-workflow/canvas', query: { workflowId: savedWorkflowId } }) }
+          })
+          // 同时展示进入画布按钮
+          if (savedWorkflowId) {
+            this.savedWorkflowId = savedWorkflowId
+          } else {
+            this.generationResult = null
+          }
           this.editingResult = false
           this.userRequirement = ''
           this.selectedApiIds = []