lk 2 недель назад
Сommit
7f95a7a9a4
100 измененных файлов с 6520 добавлено и 0 удалено
  1. 8 0
      .idea/.gitignore
  2. 6 0
      .idea/misc.xml
  3. 8 0
      .idea/modules.xml
  4. 6 0
      .idea/vcs.xml
  5. 9 0
      .idea/ylrz_saas_pharmacy.iml
  6. 140 0
      01_系统概述与架构.md
  7. 194 0
      02_多级管理体系.md
  8. 211 0
      03_各端口功能模块.md
  9. 232 0
      04_消费分账与计费.md
  10. 188 0
      05_数据架构与部署.md
  11. 248 0
      06_代码工程与编译.md
  12. 5 0
      adminui/.dockerignore
  13. 22 0
      adminui/.editorconfig
  14. 28 0
      adminui/.env.development
  15. 27 0
      adminui/.env.production
  16. 10 0
      adminui/.eslintignore
  17. 199 0
      adminui/.eslintrc.js
  18. 30 0
      adminui/.gitignore
  19. 10 0
      adminui/Dockerfile
  20. 36 0
      adminui/README.en.md
  21. 37 0
      adminui/README.md
  22. 17 0
      adminui/babel.config.js
  23. 12 0
      adminui/bin/build.bat
  24. 12 0
      adminui/bin/package.bat
  25. 12 0
      adminui/bin/run-web.bat
  26. 35 0
      adminui/build/index.js
  27. 15 0
      adminui/jsconfig.json
  28. 36 0
      adminui/nginx.conf
  29. 0 0
      adminui/notepad.txt
  30. 113 0
      adminui/package.json
  31. 646 0
      adminui/public/chat-aggregate.html
  32. 21 0
      adminui/public/html/ie.html
  33. 209 0
      adminui/public/index.html
  34. 2 0
      adminui/public/robots.txt
  35. 12 0
      adminui/public/service.html
  36. 70 0
      adminui/scripts/apply-execution-config-dedup-columns.js
  37. 34 0
      adminui/scripts/apply-instance-scope-columns.js
  38. 63 0
      adminui/scripts/apply-lobster-execution-config-cleanup.js
  39. 116 0
      adminui/scripts/apply-lobster-execution-quality-gate.js
  40. 32 0
      adminui/scripts/apply-qdrant-menu-master.js
  41. 104 0
      adminui/scripts/apply-sales-corpus-task-migration.js
  42. 71 0
      adminui/scripts/aws-sdk-webpack-aliases.js
  43. 23 0
      adminui/scripts/bind-lobster-sys-role.js
  44. 15 0
      adminui/scripts/check-api-registry-schema.js
  45. 19 0
      adminui/scripts/check-lobster-schema.js
  46. 37 0
      adminui/scripts/check-tenant-db.js
  47. 98 0
      adminui/scripts/drop-lobster-dedup-config-menu.js
  48. 111 0
      adminui/scripts/drop-lobster-execution-config-menu.js
  49. 86 0
      adminui/scripts/drop-lobster-model-route-menu.js
  50. 139 0
      adminui/scripts/drop-lobster-summary-config.js
  51. 84 0
      adminui/scripts/ensure-lobster-channel-table.js
  52. 39 0
      adminui/scripts/ensure-message-dedup-menu.js
  53. 29 0
      adminui/scripts/lobster-menu-definitions.js
  54. 67 0
      adminui/scripts/move-list-search-actions-to-end.js
  55. 104 0
      adminui/scripts/repair-lobster-api-registry-schema.js
  56. 88 0
      adminui/scripts/repair-lobster-profile-schema.js
  57. 52 0
      adminui/scripts/repair-lobster-segment-schema.js
  58. 137 0
      adminui/scripts/seed-lobster-channel-data.js
  59. 116 0
      adminui/scripts/sync-lobster-menus-master.js
  60. 129 0
      adminui/scripts/sync-lobster-menus-tenant.js
  61. 37 0
      adminui/scripts/verify-session-fixes-db.js
  62. 197 0
      adminui/scripts/write-channel-manage.js
  63. 19 0
      adminui/src/App.vue
  64. 83 0
      adminui/src/api/admin/aiChatQuality.js
  65. 84 0
      adminui/src/api/admin/aiModel.js
  66. 71 0
      adminui/src/api/admin/appCidConfig.js
  67. 62 0
      adminui/src/api/admin/article.js
  68. 63 0
      adminui/src/api/admin/cidServerManage.js
  69. 31 0
      adminui/src/api/admin/commGatewayLog.js
  70. 27 0
      adminui/src/api/admin/commissionRecord.js
  71. 27 0
      adminui/src/api/admin/consumeRecord.js
  72. 10 0
      adminui/src/api/admin/consumeReport.js
  73. 39 0
      adminui/src/api/admin/course.js
  74. 46 0
      adminui/src/api/admin/dbConfig.js
  75. 53 0
      adminui/src/api/admin/deviceCardManage.js
  76. 95 0
      adminui/src/api/admin/ipadServer.js
  77. 39 0
      adminui/src/api/admin/live.js
  78. 10 0
      adminui/src/api/admin/liveVideo.js
  79. 130 0
      adminui/src/api/admin/lobster.js
  80. 23 0
      adminui/src/api/admin/lobsterChannel.js
  81. 19 0
      adminui/src/api/admin/lobsterExecutionConfig.js
  82. 26 0
      adminui/src/api/admin/lobsterSemanticKeywords.js
  83. 29 0
      adminui/src/api/admin/moduleUsage.js
  84. 56 0
      adminui/src/api/admin/product.js
  85. 36 0
      adminui/src/api/admin/proxy.js
  86. 15 0
      adminui/src/api/admin/proxyOperLog.js
  87. 27 0
      adminui/src/api/admin/rechargeRecord.js
  88. 34 0
      adminui/src/api/admin/serviceCost.js
  89. 9 0
      adminui/src/api/admin/storeOrder.js
  90. 133 0
      adminui/src/api/admin/sysCompany.js
  91. 45 0
      adminui/src/api/admin/sysUser.js
  92. 34 0
      adminui/src/api/admin/tenantAiModel.js
  93. 52 0
      adminui/src/api/admin/tenantGateway.js
  94. 34 0
      adminui/src/api/admin/tenantNodeType.js
  95. 44 0
      adminui/src/api/admin/trafficPricing.js
  96. 10 0
      adminui/src/api/admin/videoResource.js
  97. 10 0
      adminui/src/api/admin/voiceNumber.js
  98. 20 0
      adminui/src/api/admin/voiceOrder.js
  99. 27 0
      adminui/src/api/admin/voicePackage.js
  100. 55 0
      adminui/src/api/admin/voiceSeat.js

+ 8 - 0
.idea/.gitignore

@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml

+ 6 - 0
.idea/misc.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK">
+    <output url="file://$PROJECT_DIR$/out" />
+  </component>
+</project>

+ 8 - 0
.idea/modules.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="ProjectModuleManager">
+    <modules>
+      <module fileurl="file://$PROJECT_DIR$/.idea/ylrz_saas_pharmacy.iml" filepath="$PROJECT_DIR$/.idea/ylrz_saas_pharmacy.iml" />
+    </modules>
+  </component>
+</project>

+ 6 - 0
.idea/vcs.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="VcsDirectoryMappings">
+    <mapping directory="" vcs="Git" />
+  </component>
+</project>

+ 9 - 0
.idea/ylrz_saas_pharmacy.iml

@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<module type="JAVA_MODULE" version="4">
+  <component name="NewModuleRootManager" inherit-compiler-output="true">
+    <exclude-output />
+    <content url="file://$MODULE_DIR$" />
+    <orderEntry type="inheritedJdk" />
+    <orderEntry type="sourceFolder" forTests="false" />
+  </component>
+</module>

+ 140 - 0
01_系统概述与架构.md

@@ -0,0 +1,140 @@
+# 药店诊所 SAAS 私域运营系统 —— 系统概述与架构
+
+> **版本**: V3.0 | **日期**: 2026-05-05 | **项目代号**: XDD-SAAS
+> **代码库**: `ylrz_saas_his_scrm`(原 `fs-*` 系列迁移重构为 `ylrz-*`)
+
+---
+
+## 一、项目背景与定位
+
+本项目是一个**药店诊所 SAAS 私域运营系统**,致力于帮助药店、诊所实现从引流到售后的一站式数字化运营闭环:
+
+| 运营环节 | 实现手段 | 核心能力 |
+|----------|----------|----------|
+| **引流** | 九合一设备线下检测、促销活动、企微添加 | 线下+线上双通道获客 |
+| **留存** | SCRM 客户管理、客户分层、标签画像 | 客户数字资产沉淀 |
+| **触达** | 企微消息、短信、电话、AI 自动聊天 | 多渠道精准触达 |
+| **激活** | 优惠活动、直播、健康课程讲座、积分体系 | 提升用户活跃度 |
+| **转化** | 直播小黄车、课程小黄车、商城下单、到店自提 | 多场景交易转化 |
+| **售后** | 用药提醒、健康关怀、IM 在线咨询 | 长期客户关怀 |
+| **慢病管理** | 九合一设备检测、随访计划、健康报告 | 慢性病长期管理 |
+
+---
+
+## 二、系统端分类
+
+| 端 | 角色 | 载体 | 说明 |
+|----|------|------|------|
+| **后台管理端** | 平台超级管理员 | PC | 全局管理、资源配置、财务结算 |
+| **代理端** | 代理商 | PC/APP | 渠道拓展、下属租户管理 |
+| **租户管理端** | 租户(药店/诊所)管理员 | PC/APP | 租户级业务运营管理 |
+| **租户门店端** | 门店店长/店员 | PC/APP | 门店日常运营、客户接待 |
+| **租户医生端** | 医生 | PC/APP | 在线问诊、直播授课 |
+| **租户药师端** | 药师 | PC/APP | 用药咨询、处方审核 |
+| **商城小程序** | C端用户/患者 | 微信小程序 | 线上购物、健康管理、咨询 |
+
+---
+
+## 三、系统架构图
+
+```
++-----------------------------------------------------------------------------+
+|                           药店诊所 SAAS 私域运营系统                           |
++-----------------------------------------------------------------------------+
+|                                                                             |
+|   +----------+     +----------+     +-----------------------------------+   |
+|   | 后台管理端 | --> |  代理端   | --> |        租户管理端 (PC/APP)        |   |
+|   |(SuperAdmin)|    |  (Agent) |     |                                   |   |
+|   |           |     |          |     |  +---------+ +---------+ +------+  |   |
+|   |-资源库管理|     |-租户管理 |     |  |分销模式  | |进货模式  | |SCRM |  |   |
+|   |-商品库管理|     |-分成查询 |     |  |资源选用  | |选品进货  | |活动  |  |   |
+|   |-分账结算  |     |-提现管理 |     |  |分成查询  | |商品管理  | |门店  |  |   |
+|   |-支付配置  |     |-数据统计 |     |  |提现管理  | |订单管理  | |医生  |  |   |
+|   |-供应商管理|     |          |     |  |AI知识库  | |结算管理  | |小程序|  |   |
+|   |-设备管理  |     |          |     |  +---------+ +---------+ +------+  |   |
+|   |-代理管理  |     |          |     |                                   |   |
+|   |-套餐管理  |     |          |     |                                   |   |
+|   +-----+-----+     +----+----+     +-----------------+------------------+   |
+|          |                |                            |                      |
+|          +----------------+----------------------------+                      |
+|                           |                                                   |
+|                           v                                                   |
+|              +-----------------------+                                        |
+|              |       小程序层        |                                        |
+|              |    (双轨制)           |                                        |
+|              +-----------+----------+                                         |
+|                          |                                                    |
+|      +-------------------+--------------------+                               |
+|      v                                        v                               |
+|  +-------------------+              +-------------------+                     |
+|  |   统一小程序       |              |  租户独立小程序     |                     |
+|  |  (平台提供)       |              |  (租户自有)        |                     |
+|  | -直播小黄车       |              | -自有商品          |                     |
+|  | -课程小黄车       |              | -进货商品          |                     |
+|  | -平台商品         |              | -IM 咨询           |                     |
+|  | -平台收款         |              | -健康管理          |                     |
+|  | -一键代发         |              | -租户收款          |                     |
+|  +-------------------+              +-------------------+                     |
+|                                                                             |
+|   +---------------------------------------------------------------------+   |
+|   |                              基础设施层                               |   |
+|   |  Nginx | SpringBoot | MySQL 8.0 | Redis | RabbitMQ | OSS/COS        |   |
+|   +---------------------------------------------------------------------+   |
++-----------------------------------------------------------------------------+
+```
+
+---
+
+## 四、技术栈选型
+
+| 层级 | 技术选型 | 版本 |
+|------|----------|------|
+| **前端框架(管理端)** | Vue.js + Element UI | Vue 2.6.x / Element 2.15.x |
+| **小程序框架** | 微信原生小程序 | 最新版 |
+| **后端框架** | Spring Boot | 2.7.x |
+| **数据库** | MySQL | 8.0+ |
+| **缓存** | Redis | 6.0+ |
+| **消息队列** | RabbitMQ | 3.9+ |
+| **对象存储** | 阿里云 OSS / 腾讯云 COS | - |
+| **支付通道** | 微信支付 + 支付宝 | - |
+| **实时通讯** | WebSocket / 腾讯云 IM | - |
+| **AI 引擎** | 大模型 API(外部插件集成) | - |
+
+---
+
+## 五、两种商业模式对比
+
+| 对比维度 | **分销模式(平台收款)** | **进货模式(租户收款)** |
+|----------|--------------------------|---------------------------|
+| **所属小程序** | 总后台统一提供的小程序 | 租户自己的独立小程序 |
+| **商品来源** | 总后台商品库 | 租户自有 + 从平台选品进货 |
+| **收款方** | 平台收款 | 租户收款 |
+| **资金流向** | 用户 -> 平台 -> 分账给代理+租户 | 用户 -> 租户 -> 结算平台进货费 -> 分给代理 |
+| **商品定价** | 平台定价,租户可自定义加价 | 租户自行定价 |
+| **发货方式** | 一键代发(平台通知供应商) | 租户自行发货 |
+| **售后处理** | 平台统一售后规则 | 租户自行设定 |
+| **结算方式** | 超售后保障期后自动分账 | 租户向平台支付进货成本 |
+
+### 分销模式流程
+
+```
+供应商 --> 总后台商品库 --> 授权给租户 --> 租户选用 --> 统一小程序展示
+    --> 用户下单 --> 平台收款 --> 供应商一键代发 --> 7天售后保障期
+    --> 自动分账: 平台(10%) + 代理(5%) + 租户(85%) --> 提现
+```
+
+### 进货模式流程
+
+```
+总后台商品库 --> 租户选品进货(按进货价) --> 租户自有小程序上架(自定义售价)
+    --> 用户下单 --> 租户收款 --> 租户发货 --> 租户向平台结算进货费
+    --> 平台分给代理
+```
+
+### 三种资金流模型
+
+| 模型 | 收款方 | 说明 |
+|------|--------|------|
+| **分销** | 平台收款 | 平台收全款 -> 分给代理+租户 |
+| **进货** | 租户收款 | 租户收全款 -> 结进货费给平台 -> 平台分给代理 |
+| **SaaS计费** | 平台收款 | 租户按用量后付费/预付费给平台 |

+ 194 - 0
02_多级管理体系.md

@@ -0,0 +1,194 @@
+# 药店诊所 SAAS 私域运营系统 —— 多级管理体系
+
+> **版本**: V3.0 | **日期**: 2026-05-05
+
+---
+
+## 一、五级管理体系
+
+```
+                        总后台 (SuperAdmin / Platform)
+                      L0: 平台超级管理员
+                              |
+               +--------------+--------------+
+               v              v              v
+           代理A           代理B           代理C       L1: 代理商
+               |              |              |
+        +------+------+       |        +-----+-----+
+        v      v      v       v        v           v
+      租户1  租户2  租户3   租户4    租户5       租户6     L2: 租户(药店/诊所)
+        |              |               |
+   +----+----+    +----+----+    +----+----+
+   v    v    v    v    v    v    v    v    v
+ 门店 医生 药师  门店 医生 药师  门店 医生 药师   L3: 门店
+   |                                     L4: 医生/药师
+   v
+ 店员(Staff)                                    L4: 店员
+```
+
+---
+
+## 二、各级职责与权限
+
+| 层级 | 角色 | 核心职责 | 核心功能模块 |
+|------|------|----------|------------|
+| **L0** | 总后台(SuperAdmin) | 平台运营、全局管控 | 代理管理、套餐管理、资源库管理、商品库管理、分账结算、供应商管理、设备类型管理、系统配置、数据大屏 |
+| **L1** | 代理(Agent) | 渠道拓展、下级管理 | 创建租户、查看下属租户数据、分成查询、提现管理、数据统计 |
+| **L2** | 租户(Tenant) | 业务经营、全链条管理 | SCRM、企微运营、AI知识库、活动管理、门店管理、员工管理、医生管理、药师管理、分销/进货、小程序配置、营销自动化、供应链、财务、会员 |
+| **L3** | 门店(Store) | 线下服务交付 | 客户接待、设备管理、库存管理、活动执行、预存款操作、业绩查看 |
+| **L4** | 医生(Doctor) | 在线诊疗 | IM问诊、视频通话、电子处方、直播授课、患者管理 |
+| **L4** | 药师(Pharmacist) | 用药服务 | 用药咨询、处方审核、药品管理 |
+
+---
+
+## 三、各层级关系详解
+
+### 3.1 总后台 <-> 代理
+
+| 维度 | 关系说明 |
+|------|---------|
+| **创建** | 总后台创建代理账号,设置代理信息(名称/联系人/手机号/邮箱) |
+| **授权** | 总后台为代理分配功能权限、设置可创建租户数量上限 (`max_tenant_count`) |
+| **分级** | 代理支持多级 (`agent_level`),可配置不同等级代理的分成比例、配额 |
+| **配额调整** | 总后台可随时调整代理的租户配额 |
+| **数据可见** | 总后台 -> 所有代理数据;代理 -> 仅自身及下属租户数据 |
+| **账户管理** | 每个代理有独立余额账户,总后台可查看代理余额与流水 |
+
+### 3.2 总后台 <-> 租户
+
+| 维度 | 关系说明 |
+|------|---------|
+| **创建** | 总后台创建租户(或通过代理创建),绑定代理关系 |
+| **套餐** | 总后台为租户分配功能套餐,决定可用功能模块和有效期 |
+| **资源授权** | 总后台将平台课程/直播/商品授权给租户使用 |
+| **计费** | 租户按套餐计费(后付费/预付费),系统自动日结或月结 |
+| **分账** | 分销模式下总后台按比例将收入分给代理和租户 |
+| **提现审批** | 租户提现申请需总后台审核后执行发放 |
+
+### 3.3 代理 <-> 租户
+
+| 维度 | 关系说明 |
+|------|---------|
+| **创建** | 代理可在自己配额范围内创建租户 |
+| **管理** | 代理可查看/编辑下属租户基本信息 |
+| **分成** | 分销模式下代理获得下属租户销售收入的代理分成 |
+| **数据** | 代理可查看下属租户的业绩统计、活跃度数据 |
+
+### 3.4 租户 <-> 门店
+
+| 维度 | 关系说明 |
+|------|---------|
+| **数量** | 一个租户可拥有多个门店 |
+| **管理** | 租户管理员创建门店、分配设备、设定营业时间 |
+| **权限** | 门店店长/店员只能操作本门店数据 |
+| **设备** | 九合一检测仪等设备绑定到具体门店 |
+| **库存** | 门店有独立库存,支持门店间调拨 |
+| **业绩** | 租户可查看各门店业绩排行 |
+
+### 3.5 租户 <-> 医生
+
+| 维度 | 关系说明 |
+|------|---------|
+| **资质** | 租户录入医生信息后需提交资质审核(执业医师证) |
+| **分配** | 医生可分配到一个或多个门店坐诊 |
+| **排班** | 医生设置坐诊时间,患者可预约 |
+| **业务** | 医生进行在线问诊、开具电子处方、参与直播授课 |
+| **业绩** | 租户可查看医生问诊量、处方量等业绩数据 |
+
+### 3.6 租户 <-> 药师
+
+| 维度 | 关系说明 |
+|------|---------|
+| **资质** | 租户录入药师信息后需提交资质审核(执业药师证) |
+| **分配** | 药师分配到一个或多个门店值班 |
+| **排班** | 药师设置值班时间 |
+| **业务** | 药师进行用药咨询、处方审核、药品管理 |
+| **业绩** | 租户可查看药师咨询量、审方量等业绩数据 |
+
+---
+
+## 四、数据权限模型
+
+| 层级 | 可见数据范围 |
+|------|------------|
+| 总后台 | 全平台所有数据 |
+| 代理 | 自身下属所有租户数据 |
+| 租户 | 自身及下属门店/员工/医生/药师数据 |
+| 门店 | 自身门店数据 |
+| 医生/药师 | 自身相关数据 |
+
+### 数据可见性矩阵
+
+| 操作 | 总后台 | 代理 | 租户 | 门店 | 医生/药师 |
+|------|--------|------|------|------|-----------|
+| 查看全平台数据 | Y | N | N | N | N |
+| 查看下属租户数据 | Y | Y | - | - | - |
+| 查看本租户全量数据 | Y | Y | Y | N | N |
+| 查看本门店数据 | - | - | Y | Y | N |
+| 查看本人数据 | - | - | Y | Y | Y |
+| 创建代理 | Y | N | N | N | N |
+| 创建租户 | Y | Y | N | N | N |
+| 创建门店/员工 | Y | N | Y | N | N |
+| 管理套餐 | Y | N | N | N | N |
+| 提现操作 | Y(审核) | Y(发起) | Y(发起) | N | N |
+| 分账配置 | Y | N | N | N | N |
+
+---
+
+## 五、租户信息核心字段
+
+| 字段 | 说明 | 示例 |
+|------|------|------|
+| `id` | 租户唯一ID | 1001 |
+| `agent_id` | 所属代理ID | 5 |
+| `tenant_name` | 租户名称 | XX大药房 |
+| `tenant_code` | 租户编码(唯一) | xx_pharmacy |
+| `tenant_type` | 租户类型 | AI / NON_AI |
+| `db_url` | 租户独立数据库地址 | jdbc:mysql://host:3306/tenant_1001 |
+| `db_account` | 数据库账号 | tenant_1001 |
+| `db_pwd` | 数据库密码(加密) | **** |
+| `package_id` | 绑定套餐ID | 3 |
+| `billing_mode` | 计费模式 | PREPAID / POSTPAID |
+| `fee_plan_code` | 费用方案编码 | STANDARD |
+| `balance` | 租户余额(BigDecimal) | 5000.00 |
+| `status` | 状态 | 1-正常/0-禁用 |
+| `expire_at` | 到期时间 | 2026-12-31 |
+
+---
+
+## 六、核心数据库表(管理域)
+
+### 系统管理 (sys_*)
+
+| 表名 | 说明 | 关键字段 |
+|------|------|----------|
+| `sys_user` | 系统用户 | id, username, password(BCrypt), real_name, phone, email, avatar, status |
+| `sys_role` | 角色 | id, name, code, sort, status, remark |
+| `sys_menu` | 菜单 | id, parent_id, name, path, component, icon, sort, visible |
+| `sys_role_menu` | 角色菜单关联 | role_id, menu_id |
+| `sys_user_role` | 用户角色关联 | user_id, role_id |
+| `sys_dept` | 部门 | id, parent_id, name, sort, status |
+| `sys_dict_type` | 字典类型 | id, name, type, status |
+| `sys_dict_data` | 字典数据 | id, type_id, label, value, sort, status |
+| `sys_config` | 系统配置 | id, name, config_key, config_value(TEXT) |
+| `sys_notice` | 公告 | id, title, content, type, status |
+
+### 代理/租户
+
+| 表名 | 说明 | 关键字段 |
+|------|------|----------|
+| `agent` | 代理信息 | id, name, contact_person, phone, email, status, max_tenant_count, balance |
+| `agent_user` | 代理用户 | id, agent_id, username, password, real_name, phone, status |
+| `tenant_info` | 租户信息 | id, agent_id, name, tenant_code, db_url, package_id, billing_mode, status, balance |
+| `tenant_package` | 租户套餐 | id, name, price, duration, modules(JSON) |
+| `tenant_config` | 租户配置 | id, tenant_id(UNIQUE), payment_config(JSON) |
+
+### 门店/员工/医生/药师
+
+| 表名 | 说明 | 关键字段 |
+|------|------|----------|
+| `store` | 门店信息 | id, tenant_id, name, address, phone, status |
+| `store_staff` | 门店员工 | id, store_id, tenant_id, username, real_name, phone, position |
+| `doctor` | 医生 | id, tenant_id, store_id, username, real_name, phone, title, specialty, cert_url, audit_status |
+| `pharmacist` | 药师 | id, tenant_id, store_id, username, real_name, phone, cert_no, cert_url, audit_status |
+| `schedule` | 排班表 | id, user_type, user_id, date, time_slot, status |

+ 211 - 0
03_各端口功能模块.md

@@ -0,0 +1,211 @@
+# 药店诊所 SAAS 私域运营系统 —— 各端口功能模块
+
+> **版本**: V3.0 | **日期**: 2026-05-05
+
+---
+
+## 一、功能覆盖总览
+
+| 功能领域 | 已覆盖功能 |
+|----------|-----------|
+| **多级管理** | 总后台->代理->租户->门店,完整权限体系 |
+| **SCRM** | 客户档案、标签、画像、分层、生命周期、客户转移/继承 |
+| **企微集成** | 服务商模式、客户关联(UnionID+企微联系人)、AI客服、知识库、敏感词过滤、转人工、AI自动获取标签画像 |
+| **设备管理** | 设备类型、绑定、状态监控、数据同步(四级管理) |
+| **慢病管理** | 九合一检测设备(血糖/血压/尿酸/总胆固醇/三油甘脂/心电/面诊/舌诊/心率)、随访计划、用药提醒 |
+| **在线咨询** | 药师/医生 IM、视频通话、AI 辅助 |
+| **商城系统** | 商品管理、在线下单、订单管理、预存款、配送+到店自提 |
+| **活动管理** | 直播/录播、健康课程、促销活动(大转盘/抢购/拼团)、小黄车 |
+| **会员积分** | 积分规则、积分获取、积分兑换、会员等级 |
+| **营销自动化** | 智能触达、智能推荐、自动化流程、A/B 测试 |
+| **供应链管理** | 采购管理、库存调拨、供应商管理、库存盘点 |
+| **财务对账** | 财务报表、对账功能、发票管理、费用管理 |
+| **数据大屏** | 实时数据展示、可视化分析、预警提醒 |
+| **资源共享** | 总后台资源库(课程/直播/商品)、租户选用、资源自定义 |
+| **分成结算** | 分销/进货双模式、分成比例设置、自动结算、提现管理 |
+| **电子处方** | 处方开具、处方审核、处方流转 |
+| **多语言** | 语言切换、本地化内容 |
+| **第三方集成** | 微信支付、支付宝、物流对接 |
+
+---
+
+## 二、后台管理端(总后台 SuperAdmin)
+
+| 模块 | 子模块 | 功能点 |
+|------|--------|--------|
+| **系统管理** | 用户管理 | 创建/编辑/删除用户、角色分配、重置密码 |
+| | 角色权限 | 角色创建、菜单权限分配、数据权限配置 |
+| | 菜单管理 | 菜单树管理、按钮权限配置 |
+| | 部门管理 | 部门树管理 |
+| | 字典管理 | 字典类型、字典数据维护 |
+| | 系统配置 | 全局参数配置 |
+| | 公告管理 | 系统公告发布 |
+| **代理管理** | 代理信息 | 创建/编辑/删除代理、代理状态管理 |
+| | 代理授权 | 代理功能权限、可创建租户数量上限 |
+| | 代理账户 | 代理账户余额、流水 |
+| | 代理统计 | 下属租户数、业绩统计 |
+| **租户套餐** | 套餐定义 | 创建套餐、功能模块勾选 |
+| | 价格设置 | 套餐价格、续费规则 |
+| | 套餐分配 | 给租户分配套餐 |
+| **资源库管理** | 课程资源库 | 创建课程、分类、审核、授权给租户 |
+| | 直播资源库 | 创建直播、审核、授权给租户 |
+| | 商品库 | 创建商品、分类、审核 |
+| | 供应商管理 | 供应商入驻、信息管理 |
+| | 一键代发管理 | 代发订单管理、物流追踪 |
+| | 资源授权 | 授权租户使用资源、设置授权期限 |
+| **结算管理** | 分销分成设置 | 设置平台/代理/租户三方分成比例 |
+| | 进货结算设置 | 设置进货价格策略 |
+| | 自动结算配置 | 售后保障期、结算周期 |
+| | 提现审批 | 审核代理/租户提现申请 |
+| | 分账记录查询 | 分销分账明细、进货分账明细 |
+| | 对账报表 | 日/周/月对账报表 |
+| | 财务总账 | 平台收入/支出、各代理/租户账户余额 |
+| **支付配置** | 支付通道配置 | 微信支付、支付宝参数配置 |
+| **设备管理** | 设备类型管理 | 九合一等类型定义 |
+| | 设备厂商管理 | 厂商信息维护 |
+| | 设备授权 | 设备接入平台授权 |
+| | 设备统计 | 全局设备统计 |
+| **数据大屏** | 平台级大屏 | 全平台核心指标实时看板 |
+| **系统监控** | 操作日志 | 全平台操作审计日志 |
+| | 登录日志 | 安全登录监控 |
+| | 在线用户 | 实时在线用户 |
+| | 服务监控 | 服务器性能/数据库/缓存监控 |
+| | 定时任务 | 任务调度管理 |
+
+---
+
+## 三、代理端
+
+| 模块 | 功能点 |
+|------|--------|
+| **租户管理** | 创建租户、编辑信息、套餐分配、状态管理 |
+| **分销分成查询** | 查看分销模式下各租户的分成明细和汇总 |
+| **进货分成查询** | 查看进货模式下平台分配给代理的分成明细 |
+| **结算管理** | 分成总数、已结算金额、待结算金额 |
+| **提现管理** | 提交提现申请、提现记录查看、到账状态跟踪 |
+| **数据统计** | 下属租户分布、各租户业绩排行、活跃租户统计 |
+| **消息通知** | 系统公告、租户注册通知、续费提醒 |
+
+---
+
+## 四、租户管理端(PC/APP)——核心端
+
+| 模块 | 子模块 | 功能点 |
+|------|--------|--------|
+| **分销模式** | 资源选用 | 浏览平台课程/直播/商品、选用加入、自定义价格 |
+| | 分销订单 | 查看分销订单、订单状态 |
+| | 分成查询 | 分销分成收入明细、结算批次记录 |
+| | 提现管理 | 提交提现申请、提现记录 |
+| **进货模式** | 选品进货 | 浏览平台商品库、将商品加入租户商品库 |
+| | 租户商品库 | 自有商品+进货商品统一管理、自定义售价、上下架 |
+| | 销售订单 | 租户小程序产生的订单管理、发货操作 |
+| | 进货结算 | 向平台结算进货费用 |
+| **SCRM 管理** | 客户档案 | 客户基本信息、联系方式、就诊/消费记录 |
+| | 客户标签 | 手动标签设置、自动标签规则 |
+| | 客户画像 | 基于标签+行为数据的客户画像展示 |
+| | 客户分层 | VIP/普通/潜在客户分层管理 |
+| | 生命周期管理 | 新客->活跃->沉睡->流失 全生命周期追踪 |
+| **企微服务商** | 服务商配置 | CorpID/Secret/Token/AESKey 配置 |
+| | 客户关联管理 | UnionID与企微外部联系人关联规则、关联日志 |
+| | 客户转移 | 手动选择、批量转移、离职继承规则 |
+| | 转移记录 | 客户转移历史审计追溯 |
+| **AI 知识库** | 知识库 | 条目CRUD、分类、批量导入导出 |
+| | 角色提示词 | 为医生/药师/店员创建专属提示词 |
+| | 敏感词过滤 | 屏蔽词/敏感词维护、自动过滤 |
+| | 转人工设置 | 转人工条件配置 |
+| | AI 对话日志 | AI与客户对话记录查看 |
+| | AI 自动标签 | AI聊天中自动识别意图并打标签、更新画像 |
+| **活动管理** | 直播管理 | 创建直播、小黄车商品关联、回放 |
+| | 录播管理 | 视频上传、转码、封面设置 |
+| | 课程管理 | 课程资源上传、分类、存储桶链接配置 |
+| | 课程小黄车 | 关联商品、推荐规则 |
+| | 促销活动 | 大转盘、抢购、拼团、优惠券配置 |
+| **门店管理** | 门店信息 | 创建门店、地址、营业时间、地图定位 |
+| | 门店设备 | 设备绑定、状态监控 |
+| **员工管理** | 员工档案 | 员工信息、职位 |
+| | 员工权限 | 功能权限、数据权限 |
+| | 员工业绩 | 个人销售业绩、提成计算 |
+| **医生管理** | 医生档案 | 医生信息、职称、专业领域 |
+| | 资质审核 | 医师资质审核 |
+| | 医生业绩 | 问诊数量、服务质量统计 |
+| **药师管理** | 药师档案 | 药师信息、资质证书 |
+| | 资质审核 | 药师资质审核 |
+| **业绩管理** | 门店/员工/医生/药师业绩 | 销售额、客流量、问诊量、咨询量统计 |
+| **营销自动化** | 智能触达 | 触发条件配置(生日/复购周期/流失预警) |
+| | A/B测试 | 策略分组、效果对比 |
+| **供应链管理** | 采购管理 | 采购订单、进度跟踪 |
+| | 库存管理 | 库存查看、预警、调拨 |
+| **财务对账** | 财务报表 | 收入/支出/利润报表 |
+| | 对账管理 | 订单对账、支付对账 |
+| **会员管理** | 积分管理 | 积分规则、获取/兑换/清零 |
+| | 会员等级 | 等级定义、权益、升级规则 |
+| **小程序管理** | 独立小程序开关 | 是否开启独立小程序 |
+| | 支付配置 | 微信/支付宝支付参数配置 |
+
+---
+
+## 五、租户门店端(PC/APP)
+
+| 模块 | 功能点 |
+|------|--------|
+| **设备管理** | 设备日常开关机、检测数据查看、故障上报 |
+| **库存管理** | 门店库存查看、预警提醒、调拨申请 |
+| **门店运营** | 考勤打卡、耗材管理、交接班记录 |
+| **客户接待** | 到店登记、检测预约、服务记录、会员卡办理 |
+| **SCRM 操作** | 客户信息录入、标签添加 |
+| **活动执行** | 活动推广、客户邀约、签到核销 |
+| **预存款操作** | 客户预存款充值、消费记录查询 |
+| **业绩查看** | 门店业绩、个人业绩排行 |
+
+---
+
+## 六、租户医生端(PC/APP)
+
+| 模块 | 功能点 |
+|------|--------|
+| **在线问诊** | IM消息沟通、视频通话、语音通话、AI辅助诊断 |
+| **电子处方** | 处方开具、模板选择、处方记录 |
+| **问诊记录** | 问诊历史、病历管理、患者健康档案 |
+| **直播参与** | 医生直播授课、在线答疑、小黄车商品推荐 |
+| **排班管理** | 坐诊时间设置、预约管理、请假申请 |
+| **患者管理** | 患者列表、随访提醒发送、健康报告查看 |
+
+---
+
+## 七、租户药师端(PC/APP)
+
+| 模块 | 功能点 |
+|------|--------|
+| **用药咨询** | IM消息沟通、用药指导、AI辅助用药建议 |
+| **电子处方** | 处方审核、处方调剂 |
+| **咨询记录** | 咨询历史、用药建议记录 |
+| **药品管理** | 药品查询、库存查看、用药提醒配置 |
+| **排班管理** | 值班时间、请假申请 |
+
+---
+
+## 八、小程序层(双轨制)
+
+### 统一小程序(分销模式 - 平台提供)
+
+| 模块 | 功能点 |
+|------|--------|
+| **首页** | 轮播图、活动入口、热门推荐 |
+| **直播中心** | 直播列表、观看、弹幕互动、小黄车边看边买 |
+| **课程中心** | 课程列表、详情、小黄车关联商品购买 |
+| **商品商城** | 商品分类、搜索、详情、购物车、下单 |
+| **订单中心** | 订单列表、详情、物流跟踪、售后申请 |
+| **个人中心** | 会员信息、订单记录、积分查看 |
+
+### 租户独立小程序(进货模式 - 租户自有)
+
+| 模块 | 功能点 |
+|------|--------|
+| **首页** | 租户品牌展示、活动入口 |
+| **商品商城** | 自有+进货商品展示、购物车、下单 |
+| **健康管理** | 九合一检测记录、健康报告、用药提醒、慢病随访 |
+| **IM在线咨询** | 药师/医生咨询入口、AI智能客服 |
+| **活动中心** | 直播(含小黄车)、课程(含小黄车)、促销活动 |
+| **会员中心** | 等级展示、积分查询/兑换、权益 |
+| **预存款** | 充值、余额查询、消费记录 |
+| **个人中心** | 优惠券管理、常用地址、多语言切换 |

+ 232 - 0
04_消费分账与计费.md

@@ -0,0 +1,232 @@
+# 药店诊所 SAAS 私域运营系统 —— 消费分账与SaaS计费
+
+> **版本**: V3.0 | **日期**: 2026-05-05
+
+---
+
+## 一、三种资金流模型
+
+### 模型1:分销模式(平台收款 -> 三方分账)
+
+```
+用户在小程序下单支付
+         |
+         v
+    +---------+
+    | 平台收款 |  资金进入平台微信/支付宝商户账户
+    +----+----+
+         |
+    +----v----+
+    | 供应商   |  平台通知供应商一键代发
+    | 代发货   |
+    +----+----+
+         |
+    +----v---------------------+
+    | 售后保障期(默认7天)      |
+    | 期间可退款/退货            |
+    +----+----------------------+
+         | 保障期过后
+    +----v----+
+    |自动分账  |  按比例将资金拆分
+    +----+----+
+         |
+   +-----+-----+
+   v     v     v
+ 平台   代理   租户
+(10%)  (5%)   (85%)
+   |     |     |
+   +-----+-----+
+         v
+   支持提现到银行卡
+```
+
+**分账公式**:
+```
+订单实付金额 = 商品金额 - 优惠券 - 积分抵扣
+平台收入 = 订单实付金额 x 平台分成比例
+代理收入 = 订单实付金额 x 代理分成比例
+租户收入 = 订单实付金额 x 租户分成比例 - 支付手续费
+```
+
+### 模型2:进货模式(租户收款 -> 结算进货成本 -> 代理分润)
+
+```
+平台商品库
+    |
+    v
+租户选品进货(按平台进货价 x 数量)
+    |
+    v
+租户自有小程序上架(自定义售价)
+    |
+    v
+用户下单 -> 租户收款(资金进租户微信商户账户)
+    |
+    v
+租户自行发货
+    |
+    v
+租户向平台结算进货费用
+    |
+    v
+平台收到进货费 -> 按比例分给代理
+```
+
+### 模型3:SaaS计费模型(租户按用量付费给平台)
+
+```
+租户使用平台服务
+    |
+    +-- AI外呼通话(按分钟)
+    +-- AI回复Token(按万Token)
+    +-- SOP Token(按用量)
+    +-- 加微次数(按次)
+    +-- 流量消耗(阶梯计价)
+    |
+    v
+每日01:00定时结算(TenantBillTask)
+    |
+    +-- 查询前一天用量
+    +-- 按租户绑定方案计算费用
+    +-- 预付费: 直接从钱包扣款
+    +-- 后付费: 记明细,生成账单后结算
+    +-- 写入 tenant_wallet_txn 流水
+```
+
+---
+
+## 二、分成比例配置
+
+### 分销模式默认比例
+
+| 角色 | 默认比例 | 说明 |
+|------|----------|------|
+| 平台 | 10% | 平台服务费+支付手续费 |
+| 代理 | 5% | 代理佣金 |
+| 租户 | 85% | 租户实际收入 |
+
+### 进货模式结算
+
+| 环节 | 说明 | 示例 |
+|------|------|------|
+| 平台进货价 | 平台给租户的成本价 | 50元 |
+| 租户售价 | 租户对客户的售价(自行定价) | 100元 |
+| 租户毛利 | 租户收入 | 50元 |
+| 平台分代理 | 平台收到50元进货费后分给代理的比例 | 80% (40元) |
+| 平台自留 | 平台自留 | 20% (10元) |
+
+---
+
+## 三、SaaS计费项体系
+
+| 计费编码 | 名称 | 计费单位 | 计费规则 |
+|----------|------|----------|----------|
+| `FLOW_POSTPAID` | 后付费流量 | MB/GB | 按日统计流量消耗 |
+| `CALL_OUT` | 外呼通话 | 分钟 | 不足1分钟按1分钟;AI外呼叠加AI附加费 |
+| `CALL_IN` | 呼入通话 | 分钟 | 不足1分钟按1分钟 |
+| `AI_CALL` | AI外呼附加 | 次 | AI外呼在通话费基础上叠加 |
+| `TOKEN_SOP` | SOP Token | 万Token | 按消息Token消耗计费 |
+| `TOKEN_AI_REPLY` | AI回复Token | 万Token | 按AI回复消耗Token计费 |
+| `ADD_WECHAT` | 加微次数 | 次 | 每成功添加一个企微好友计费一次 |
+| `OPEN_ACCOUNT_AI` | AI租户开户费 | 次 | 一次性 |
+| `OPEN_ACCOUNT_NON_AI` | 非AI租户开户费 | 次 | 一次性 |
+
+---
+
+## 四、核心计费表结构
+
+| 表名 | 说明 | 关键字段 |
+|------|------|----------|
+| `fee_plan` | 计费方案 | plan_code, plan_name, version, status(DRAFT/PUBLISHED) |
+| `fee_plan_item` | 收费项配置 | plan_id, item_code, unit_price, unit, tier_config(JSON) |
+| `tenant_fee_binding` | 租户方案绑定 | tenant_id, plan_code, plan_version, billing_mode |
+| `usage_event` | 用量事件 | event_id(幂等键), tenant_id, event_type, sub_type, usage_value, usage_unit |
+| `billing_detail` | 计费明细 | tenant_id, event_id, item_code, amount, statement_id(NULL=未出账) |
+| `billing_statement` | 账单 | statement_no, tenant_id, start_time, end_time, total_amount |
+| `tenant_wallet` | 租户钱包 | tenant_id, balance_amount, total_recharge, total_cost |
+| `tenant_wallet_txn` | 钱包流水 | tenant_id, txn_type(RECHARGE/DEDUCT/REFUND), amount, balance_after, biz_no |
+
+---
+
+## 五、计费核心规则
+
+### 5.1 方案规则
+- 方案唯一键:`planCode + version`
+- 仅 `PUBLISHED` 状态方案可用于计费
+- 支持多版本并行,租户绑定到具体版本
+
+### 5.2 幂等规则
+- `usage_event.event_id` 作为幂等键
+- 重复事件上报不重复计费
+
+### 5.3 钱包规则
+- 预付费:实时扣减余额并写流水
+- 后付费:仅记录明细,后续账单结算
+
+### 5.4 账单规则
+- 仅聚合未入账明细(`statement_id is null`)
+- 账单项按 `event_type` 聚合
+- 生成后回填明细 `statement_id`,避免重复出账
+
+---
+
+## 六、端到端计费流程
+
+### 6.1 平台配置流程
+1. 创建方案草稿
+2. 配置收费项
+3. 配置流量阶梯
+4. 发布方案
+5. 绑定租户
+
+### 6.2 日常计费流程
+1. 业务侧上报 `usage_event`
+2. 幂等校验(`eventId`)
+3. 加载租户绑定与方案配置
+4. 计算金额并写 `billing_detail`
+5. 若预付费则扣钱包并写 `tenant_wallet_txn`
+6. 返回计费结果
+
+### 6.3 出账流程
+1. 按时间区间汇总未出账明细
+2. 生成账单
+3. 明细标记已入账
+
+---
+
+## 七、SaaS计费核心API
+
+| 接口 | 说明 |
+|------|------|
+| `POST /api/fee/plan/create` | 创建计费方案 |
+| `POST /api/fee/plan/item/save` | 保存收费项 |
+| `POST /api/fee/plan/publish` | 发布方案 |
+| `POST /api/fee/usage/report` | 上报用量事件(幂等:eventId) |
+| `GET /api/fee/billing/detail/list` | 平台端查询全租户明细 |
+| `GET /api/fee/billing/detail/my` | 租户查询自己明细 |
+| `POST /api/fee/statement/generate` | 生成账单 |
+| `GET /api/fee/wallet/{tenantId}` | 查询钱包余额 |
+| `POST /api/fee/wallet/recharge` | 钱包充值 |
+
+---
+
+## 八、结算定时任务
+
+| 任务 | 执行频率 | 说明 |
+|------|---------|------|
+| `TenantBillTask.tenantFeeSettlementTask` | 每日 01:00 | 遍历所有有效租户,按用量事件计费,扣减钱包 |
+| 分销自动分账 | 每日 02:00 | 对分销订单超售后保障期的自动触发分账 |
+| `cleanIdleDataSources` | 每10分钟 | 清理30分钟未使用的租户数据源连接池 |
+
+---
+
+## 九、财务结算相关表
+
+| 表名 | 说明 | 关键字段 |
+|------|------|----------|
+| `settlement_ratio` | 分成比例 | id, tenant_id, model_type, platform_ratio, agent_ratio, tenant_ratio |
+| `distribution_order_split` | 分销分账明细 | id, order_id, platform_amount, agent_amount, tenant_amount, status |
+| `settlement_batch` | 结算批次 | id, batch_no, model_type, start_time, end_time, total_amount |
+| `withdraw_apply` | 提现申请 | id, applicant_type, applicant_id, amount, fee, status, bank_info(JSON) |
+| `financial_ledger` | 财务总账 | id, order_type, order_id, from_account, to_account, amount |
+| `reconciliation_report` | 对账报表 | id, report_type, period, data(JSON) |

+ 188 - 0
05_数据架构与部署.md

@@ -0,0 +1,188 @@
+# 药店诊所 SAAS 私域运营系统 —— 数据架构与多租户方案
+
+> **版本**: V3.0 | **日期**: 2026-05-05
+
+---
+
+## 一、多租户数据隔离方案 ——「按库隔离」
+
+本系统采用 **每租户独立 MySQL 数据库** 的隔离方案:
+
+```
++--------------------------------------------------------------+
+|                      MySQL 数据库集群                          |
+|                                                               |
+|  +------------------+   +-------------------+                 |
+|  |  platform(主库)   |   |  tenant_1 租户库   |                |
+|  |  -sys_user       |   |  -store           |                |
+|  |  -agent          |   |  -customer        |                |
+|  |  -tenant_info    |   |  -order           |                |
+|  |  -tenant_package |   |  -product         |                |
+|  |  -platform_*     |   |  -sys_config(租户) |                |
+|  |  -settlement_*   |   |  -qw_company      |                |
+|  |  -billing_*      |   |  -...各业务表      |                |
+|  |  -fee_*          |   |                   |                |
+|  +------------------+   +-------------------+                |
+|                                                               |
+|  +------------------+   +-------------------+                |
+|  |  tenant_2 租户库  |   |  tenant_N 租户库   |  ...最多N个   |
+|  +------------------+   +-------------------+                |
++--------------------------------------------------------------+
+```
+
+### 数据分布原则
+
+| 存储位置 | 数据内容 | 说明 |
+|----------|---------|------|
+| **主库 (platform)** | 平台级数据 | 代理信息、租户注册信息、套餐定义、平台资源库、分账配置、计费方案 |
+| **租户库 (tenant_N)** | 租户私有数据 | 门店、员工、客户、商品、订单、SCRM标签、AI知识库、活动、财务、配置 |
+| **Redis** | 缓存+会话 | `TenantKeyRedisSerializer` 自动给 Key 加租户前缀实现隔离 |
+
+---
+
+## 二、数据源动态路由机制
+
+### 2.1 请求路由流程
+
+```
+请求到达 -> X-Tenant-Code Header -> TenantFilter 拦截
+    |
+    +-- 解析 tenant_code
+    |
+    +-- 查询主库 tenant_info 获取:
+    |     - tenant_id
+    |     - db_url (租户数据库连接地址)
+    |     - db_account / db_pwd
+    |
+    +-- TenantDataSourceManager 管理:
+    |     - LRU缓存(最多100个数据源)
+    |     - 30分钟空闲自动释放
+    |     - Druid连接池(初始5/最小5/最大20)
+    |
+    +-- DynamicDataSourceContextHolder.set(tenantKey)
+    |     - ThreadLocal 存储当前数据源标识
+    |
+    +-- 后续 MyBatis 操作自动路由到租户库
+    |
+    +-- Request完成后 -> finally清除 ThreadLocal
+```
+
+### 2.2 核心实现类
+
+| 类名 | 位置 | 职责 |
+|------|------|------|
+| `TenantDataSourceManager` | ylrz-framework/datasource | 数据源创建、缓存、切换、LRU淘汰 |
+| `DynamicDataSourceContextHolder` | ylrz-framework/datasource | ThreadLocal 数据源标识管理 |
+| `TenantConfigContext` | ylrz-framework/config | 租户级配置上下文 |
+| `@TenantDataSource` | ylrz-service/annotation | AOP自动切换租户数据源注解 |
+| `TenantDataSourceAspect` | ylrz-service/aop | 注解切面实现 |
+| `TenantKeyRedisSerializer` | ylrz-framework/redis | Redis Key自动加租户前缀 |
+| `DynamicDataSource` | ylrz-framework/datasource | 动态数据源路由核心 |
+
+---
+
+## 三、核心数据库表分类
+
+| 分类 | 表前缀 | 代表表 | 存储位置 | 说明 |
+|------|--------|--------|---------|------|
+| 系统管理 | `sys_` | sys_user, sys_role, sys_menu, sys_dict_type, sys_config | 主库 | 平台级管理表 |
+| 代理/租户 | `agent_`, `tenant_` | agent, agent_user, tenant_info, tenant_package, tenant_config | 主库 | 代理与租户管理 |
+| 门店/员工/医生/药师 | `store_`, `doctor_`, `pharmacist_` | store, store_staff, doctor, pharmacist, schedule | 租户库 | 门店及人员管理 |
+| 客户管理 | `customer_` | customer, customer_tag, customer_tag_rel, customer_portrait, customer_lifecycle | 租户库 | SCRM核心 |
+| 企微对接 | `wechat_` | wechat_provider_config, customer_wechat_relation, wechat_customer_transfer | 租户库 | 企业微信集成 |
+| AI知识库 | `ai_` | ai_knowledge_base, ai_prompt, ai_chat_log, ai_chat_tag_extract_log | 租户库 | AI自动服务 |
+| 平台资源 | `platform_` | platform_course, platform_live, platform_product, resource_auth, supplier | 主库 | 平台级资源共享 |
+| 商品/订单 | `product_`, `order_` | product, tenant_product_import, order, order_item, cart, shipment | 租户库 | 电商核心 |
+| 直播/课程/活动 | `live_`, `course_`, `promotion_` | live, course, promotion, coupon | 租户库 | 活动营销 |
+| 财务/结算 | `settlement_`, `finance_` | settlement_ratio, distribution_order_split, withdraw_apply, financial_ledger | 主库 | 分账结算 |
+| SaaS计费 | `fee_`, `billing_` | fee_plan, usage_event, billing_detail, tenant_wallet | 主库 | 用量计费 |
+| 设备/慢病/处方 | `device_`, `chronic_`, `prescription_` | device, health_check_record, chronic_follow_plan, prescription | 租户库 | 慢病管理 |
+| 日志/通知 | `log_`, `notify_` | sys_oper_log, sys_login_log, notify_record | 主库+租户库 | 审计日志 |
+
+---
+
+## 四、各端口对应的SpringBoot应用
+
+| 端口 | 对应模块 | 用户角色 | 部署形态 |
+|------|---------|---------|---------|
+| **后台管理端** | `ylrz-admin` | 平台超级管理员 | 独立SpringBoot服务 |
+| **代理端** | `ylrz-admin` (agent路径) | 代理商 | 与总后台共用服务 |
+| **租户管理端PC** | `ylrz-company` | 租户管理员 | 独立SpringBoot服务 |
+| **租户管理APP** | `ylrz-company-app` | 租户管理员 | 独立SpringBoot服务 |
+| **C端用户APP** | `ylrz-user-app` | C端患者/消费者 | 独立SpringBoot服务(小程序后端) |
+| **医生端APP** | `ylrz-doctor-app` | 医生 | 独立SpringBoot服务 |
+| **药师端APP** | `ylrz-pharmacist-app` | 药师 | 独立SpringBoot服务 |
+| **门店端APP** | `ylrz-store-app` | 店长/店员 | 独立SpringBoot服务 |
+| **直播服务** | `ylrz-live-app` | 跨端 | 独立SpringBoot服务 |
+| **AI聊天** | `ylrz-ai-chat` | 跨端 | 独立SpringBoot服务 |
+| **AI外呼** | `ylrz-ai-call-task` | 跨端 | 独立SpringBoot服务 |
+| **企微服务** | `ylrz-qw-*` 系列(7个模块) | 跨端 | 独立SpringBoot服务集群 |
+
+---
+
+## 五、部署架构
+
+```
+                         Nginx (80/443)
+                              |
+              +---------------+---------------+
+              v               v               v
+        ylrz-admin    ylrz-company    ylrz-user-app
+        (总后台)       (租户管理)      (C端小程序)
+              |               |               |
+              +---------------+---------------+ 
+                              |
+                    +---------+---------+
+                    v                   v
+              MySQL 8.0:3306     Redis 7:6379
+              (主库+N个租户库)    (Session+缓存)
+                    |
+              RabbitMQ 3.12:5672
+              (消息队列+企微回调)
+```
+
+### 前端工程对应
+
+| 前端工程目录 | 对应后端 | 技术栈 |
+|-------------|---------|--------|
+| `ylrz_saas_his_scrm_adminUI` | `ylrz-admin` | Vue.js 2.x + Element UI |
+| `ylrz_saas_his_scrm_agentUI` | `ylrz-admin` | Vue.js 2.x + Element UI |
+| 租户端UI | `ylrz-company` | Vue.js 2.x + Element UI |
+| 小程序 | `ylrz-user-app` | 微信原生小程序 |
+
+---
+
+## 六、平台默认配置
+
+| 配置项 | 默认值 | 说明 |
+|--------|--------|------|
+| 售后保障期 | 7天 | 分销订单超此期限后自动分账 |
+| 自动结算时间 | 00:00 | 每日结算时间 |
+| 提现最低金额 | 100元 | 低于此金额不可提现 |
+| 提现手续费率 | 0.6% | 提现金额的0.6% |
+| 平台分成比例 | 10% | 分销模式 |
+| 代理分成比例 | 5% | 分销模式 |
+| 租户分成比例 | 85% | 分销模式 |
+| 登录失败锁定次数 | 5次 | 超过锁定30分钟 |
+| Token过期时间 | 120分钟 | JWT Token有效期 |
+
+### 默认角色
+
+| 角色编码 | 角色名称 | 说明 |
+|---------|---------|------|
+| `super_admin` | 超级管理员 | 平台超级管理员 |
+| `agent_admin` | 代理管理员 | 代理端管理员 |
+| `tenant_admin` | 租户管理员 | 租户端管理员 |
+| `store_manager` | 门店店长 | 门店管理者 |
+| `store_staff` | 门店店员 | 门店普通员工 |
+| `doctor` | 医生 | 问诊医生 |
+| `pharmacist` | 药师 | 执业药师 |
+
+### 默认套餐
+
+| 套餐 | 月费 | 包含功能 |
+|------|------|---------|
+| 基础版 | 299元 | SCRM+企微+门店+员工 |
+| 标准版 | 599元 | 基础版+商城+直播+课程+营销 |
+| 专业版 | 999元 | 标准版+供应链+电子处方 |
+| 旗舰版 | 1999元 | 全部功能 |

+ 248 - 0
06_代码工程与编译.md

@@ -0,0 +1,248 @@
+# 药店诊所 SAAS 私域运营系统 —— 代码工程与编译指南
+
+> **版本**: V3.0 | **日期**: 2026-05-05
+
+---
+
+## 一、工程迁移背景
+
+本项目原以 `fs-*` 为模块前缀(`com.fs` 包名),后整体迁移重构为 `ylrz-*` 模块前缀(`com.ylrz` 包名):
+
+| 迁移项 | 原名称 | 新名称 |
+|--------|--------|--------|
+| 通用模块 | `fs-common` | `ylrz-common` |
+| 框架核心 | `fs-framework` | `ylrz-framework` |
+| 业务服务层 | `fs-service` | `ylrz-service` |
+| 总后台管理 | `fs-admin` | `ylrz-admin` |
+| 租户管理 | `fs-company` | `ylrz-company` |
+| 租户端APP | `fs-company-app` | `ylrz-company-app` |
+| 用户端APP | `fs-user-app` | `ylrz-user-app` |
+| 医生端APP | `fs-doctor-app` | `ylrz-doctor-app` |
+| 药师端APP | `fs-pharmacist-app` | `ylrz-pharmacist-app` |
+| 门店端APP | `fs-store-app` | `ylrz-store-app` |
+
+**迁移要点**:`com.fs.` -> `com.ylrz.` | `fs-` -> `ylrz-` | 父POM `com.fs:fs:1.1.0` -> `com.ylrz:ylrz-saas:2.0.0`
+
+---
+
+## 二、完整模块清单(36个子模块)
+
+```
+ylrz_saas_his_scrm/
++-- ylrz-common          # [公共层] 通用工具类、注解、枚举、基础实体、安全工具
+|   +-- com.ylrz.common.*
++-- ylrz-framework       # [框架层] 安全认证、数据源路由、拦截器、切面、基础服务
+|   +-- com.ylrz.framework.*
++-- ylrz-service         # [业务服务层] 核心业务逻辑(6357+源文件)
+|   +-- com.ylrz.{company,erp,qw,statis,course,his,hisStore,
+|        live,newAdv,crm,tag,wx,watch,pay,im,sop,store...}.*
++-- ylrz-redis           # Redis 独立模块
++-- ylrz-quartz          # 定时任务模块
++-- ylrz-generator       # 代码生成器
++-- ylrz-admin           # [总后台/代理后台] Web管理端
+|   +-- com.ylrz.{web,admin,tenant,billing,ad,chat,qw,stats...}.*
++-- ylrz-company         # [租户管理端] PC端内部服务
++-- ylrz-company-app     # [租户管理APP] 租户端移动API
++-- ylrz-user-app        # [用户端APP] C端用户API(小程序后端)
++-- ylrz-doctor-app      # [医生端APP] 医生端API
++-- ylrz-pharmacist-app  # [药师端APP] 药师端API
++-- ylrz-store-app       # [门店端APP] 门店端API
++-- ylrz-live-app        # [直播应用] 直播独立服务
++-- ylrz-ai-api          # [AI接口] AI开放API
++-- ylrz-ai-chat         # [AI聊天] AI聊天服务
++-- ylrz-ai-call-task    # [AI外呼] AI自动外呼任务
++-- ylrz-wx-api          # [微信API] 微信公众号接口
++-- ylrz-wx-task         # [微信任务] 微信定时任务
++-- ylrz-qw-api          # [企微API] 企业微信接口
++-- ylrz-qw-api-msg      # [企微消息API]
++-- ylrz-qw-task         # [企微任务]
++-- ylrz-qw-mq           # [企微消息队列]
++-- ylrz-qwhook          # [企微回调Hook]
++-- ylrz-qwhook-msg      # [企微回调消息]
++-- ylrz-qwhook-sop      # [企微SOP回调]
++-- ylrz-qw-voice        # [企微语音]
++-- ylrz-ad-api          # [广告API] 广告投放接口
++-- ylrz-ad-new-api      # [新广告API] 新版投放接口
++-- ylrz-repeat-api      # [复购API]
++-- ylrz-watch           # [手表/可穿戴设备]
++-- ylrz-websocket       # [WebSocket] 实时通讯
++-- ylrz-ipad-task       # [iPad任务]
++-- ylrz-cid-workflow    # [CID工作流]
++-- ylrz-common-api      # [公共API]
++-- common-ecall-sdk     # [外呼SDK]
+```
+
+---
+
+## 三、模块分层架构
+
+```
++--------------------------------------------------------------+
+|                      前端层 (Frontend)                        |
+|  ylrz_saas_his_scrm_adminUI  |  ylrz_saas_his_scrm_agentUI   |
+|  (总后台管理界面)            |  (代理管理界面)               |
+|  Vue.js + Element UI         |  Vue.js + Element UI           |
++--------------------------------------------------------------+
+|                      BFF/网关层 (API Gateway)                  |
+|  +----------+ +-----------+ +----------+ +-----------+        |
+|  |user-app  | |doctor-app | |pharm-app | |store-app  |         |
+|  |(C端用户) | |(医生端)   | |(药师端)  | |(门店端)   |         |
+|  +----------+ +-----------+ +----------+ +-----------+        |
+|  +----------+ +-----------+                                   |
+|  |company   | |company-app|                                    |
+|  |(租户PC)  | |(租户移动) |                                     |
+|  +----------+ +-----------+                                   |
++--------------------------------------------------------------+
+|                   业务服务层 (Business Service)                |
+|  +--------------------------------------------------------+   |
+|  |                  ylrz-service (核心)                     |   |
+|  |  6357个源文件,46个业务子包                              |   |
+|  |  company|qw|erp|course|statis|his|hisStore|live|         |   |
+|  |  newAdv|crm|tag|wx|watch|pay|im|sop|store|user|...      |   |
+|  +--------------------------------------------------------+   |
++--------------------------------------------------------------+
+|                   框架层 (Framework)                          |
+|  +--------------------+  +---------------------------+        |
+|  |   ylrz-framework   |  |     ylrz-common           |        |
+|  |   -安全认证        |  |   -通用注解(@Excel等)     |        |
+|  |   -数据源路由      |  |   -通用枚举                |        |
+|  |   -多租户管理器    |  |   -工具类(SecurityUtils等) |        |
+|  |   -切面/拦截器     |  |   -基础实体/域模型         |        |
+|  +--------------------+  +---------------------------+        |
++--------------------------------------------------------------+
+|                   基础设施层 (Infrastructure)                  |
+|  ylrz-redis | ylrz-quartz | ylrz-websocket                    |
+|  ylrz-qw-mq(RabbitMQ) | common-ecall-sdk(外呼)               |
++--------------------------------------------------------------+
+```
+
+---
+
+## 四、本地开发环境
+
+| 组件 | 版本/位置 | 状态 |
+|------|----------|------|
+| JDK | `D:\AICALL\jdk-17.0.12+7` (JDK 17) | 已配置 |
+| Maven | `C:\Users\周洋\maven\apache-maven-3.9.6` | 已配置 |
+| MySQL | 8.0, 端口3306 | 运行中 |
+| Redis | 端口6379 | 运行中 |
+| 数据库 | `yLrz_saas_his_scrm` (UTF8MB4) | 已初始化 |
+| 本地Maven仓库 | `~/.m2/repository` + `D:\AICALL\maven-repository` 合并 | 5923+ JAR |
+
+---
+
+## 五、编译问题与解决记录
+
+| # | 问题 | 解决方式 |
+|---|------|---------|
+| 1 | 8363个Java文件含UTF-8 BOM头 (`\ufeff`) | 批量清除 BOM 字节 |
+| 2 | pom中硬编码版本号导致依赖传递失败 | 改用 `${变量}` 引用父POM版本 |
+| 3 | `DataSourceType` 枚举缺 `EASYCALL` 值 | 添加枚举常量 |
+| 4 | JDK 17 中 `sun.misc.BASE64Decoder` 被移除 | 替换为 `java.util.Base64` |
+| 5 | `org.apache.commons.lang.exception` (commons-lang v2) 不存在 | 替换为 `commons-lang3`,`NestableRuntimeException` -> `RuntimeException` |
+| 6 | `com.baidu.dev2.thirdparty.jackson/swagger` 私有依赖 | 安装 baiduads-sdk-2023.1.0.jar |
+| 7 | `com.tzbank:tzbClient` 台州银行支付SDK | 安装 tzbClient-1.0-SNAPSHOT.jar |
+| 8 | `com.hc:openapi` 和诚IM SDK | 安装 openapi-1.0.jar |
+| 9 | `com.nuonuo:open-sdk` 诺诺开放平台 | 安装 open-sdk-1.0.5.2.jar |
+| 10 | 6357文件编译内存不足 (OOM) | MAVEN_OPTS=`-Xmx1536m -Xms256m` |
+| 11 | Lombok 1.18.24 在JDK17下注解处理器失效 | 升级到 1.18.30 |
+| 12 | `@TableField(strategy=FieldStrategy.NOT_NULL)` 已废弃 | 移除 `strategy` 参数 |
+| 13 | `ExecutionResult` 的 `@Builder` 内部类找不到 | 重写为手动静态工厂方法 |
+
+---
+
+## 六、私有依赖来源
+
+| 依赖 | 来源 | 安装命令 |
+|------|------|---------|
+| baiduads-sdk | `d:\AICODE\xdd\yilai\baidu.zip` | 解压后 mvn install:install-file |
+| tzbClient | `d:\AICODE\xdd\yilai\tzbank.zip` | 解压后 mvn install:install-file |
+| hc:openapi | `d:\AICODE\xdd\yilai\hc.zip` | 解压后 mvn install:install-file |
+| nuonuo:open-sdk | `d:\AICODE\xdd\yilai\nuonuo.zip` | 解压后 mvn install:install-file |
+| 其他全部依赖 | `D:\AICALL\maven-repository` + 公网Maven | 合并+下载 |
+
+---
+
+## 七、编译命令
+
+```bash
+# 设置环境
+JAVA_HOME=D:\AICALL\jdk-17.0.12+7
+MAVEN_OPTS=-Xmx1536m -Xms256m
+
+# 安装公共模块
+mvn install -pl ylrz-common -DskipTests
+
+# 编译业务服务层(内存不足时去掉 -T)
+mvn compile -pl ylrz-service -DskipTests
+
+# 编译框架+总后台
+mvn compile -pl ylrz-framework,ylrz-admin -DskipTests
+
+# 全量编译(需要大内存)
+mvn install -DskipTests -T 1
+```
+
+---
+
+## 八、数据库初始化
+
+```sql
+-- 主库
+CREATE DATABASE yLrz_saas_his_scrm DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- 导入14个初始化SQL脚本(位于项目sql目录)
+-- sys_user, sys_role, sys_menu 等基础表在 ylrz-admin 的 sql/ 目录下
+-- 补充SQL在 old/ 对应模块中
+```
+
+---
+
+## 九、API接口规范
+
+| 规范项 | 说明 |
+|--------|------|
+| 基础路径 | `/api/` |
+| RESTful 风格 | GET(查询)/POST(创建)/PUT(修改)/DELETE(删除) |
+| 分页参数 | `page`(页码), `size`(每页条数) |
+| 统一响应格式 | `{ code: 200, msg: "success", data: {} }` |
+
+### 核心接口模块统计
+
+| 模块 | 路径前缀 | 接口数(约) |
+|------|---------|-----------|
+| 总后台管理 | `/api/platform/` | 20+ |
+| 代理端 | `/api/platform/agent/` | 8+ |
+| 租户端 | `/api/tenant/` | 15+ |
+| SaaS计费 | `/api/fee/` | 15+ |
+| 企微服务商 | `/api/wechat/` | 15+ |
+| 小程序(C端) | `/api/mini/` | 20+ |
+| **合计** | | **260+** |
+
+---
+
+## 十、安全防护设计
+
+| 安全层面 | 措施 |
+|---------|------|
+| **认证** | Spring Security + JWT Token(120分钟过期) |
+| **授权** | RBAC角色权限 + 数据权限(五级隔离) |
+| **密码** | BCrypt加密存储 |
+| **防重放** | 登录失败锁定(5次锁定30分钟) |
+| **SQL注入** | MyBatis参数化查询 + 输入校验 |
+| **XSS** | 输出编码 + CSP头 |
+| **CSRF** | Token验证 |
+| **数据隔离** | 每租户独立数据库 + Redis Key租户前缀 |
+| **日志审计** | 全操作日志(sys_oper_log) + 登录日志(sys_login_log) |
+
+---
+
+## 十一、定时任务清单
+
+| 任务 | Cron | 说明 |
+|------|------|------|
+| `TenantBillTask.tenantFeeSettlementTask` | `0 0 1 * * ?` | 每日01:00自动结算租户SaaS用量费用 |
+| 分销自动分账 | `0 0 2 * * ?` | 每日02:00对超售后保障期的分销订单自动分账 |
+| `cleanIdleDataSources` | `0 */10 * * * ?` | 每10分钟清理30分钟未使用的租户数据源连接池 |
+| 数据源心跳检测 | `0 */5 * * * ?` | 每5分钟检测租户数据源连通性 |
+| 租户到期检查 | `0 0 0 * * ?` | 每日0点检查租户到期状态 |

+ 5 - 0
adminui/.dockerignore

@@ -0,0 +1,5 @@
+node_modules
+.git
+.gitignore
+*.log
+.idea

+ 22 - 0
adminui/.editorconfig

@@ -0,0 +1,22 @@
+# 告诉EditorConfig插件,这是根文件,不用继续往上查找
+root = true
+
+# 匹配全部文件
+[*]
+# 设置字符集
+charset = utf-8
+# 缩进风格,可选space、tab
+indent_style = space
+# 缩进的空格数
+indent_size = 2
+# 结尾换行符,可选lf、cr、crlf
+end_of_line = lf
+# 在文件结尾插入新行
+insert_final_newline = true
+# 删除一行中的前后空格
+trim_trailing_whitespace = true
+
+# 匹配md结尾的文件
+[*.md]
+insert_final_newline = false
+trim_trailing_whitespace = false

+ 28 - 0
adminui/.env.development

@@ -0,0 +1,28 @@
+#该文本用于打包测试平台
+# 页面标题
+VUE_APP_TITLE =总后台管理系统
+
+# 首页菜单标题
+VUE_APP_TITLE_INDEX =总后台
+# 公司名称
+VUE_APP_COMPANY_NAME =云联融智科技有限公司
+# ICP备案号
+VUE_APP_ICP_RECORD =
+# ICP网站访问地址
+VUE_APP_ICP_URL =https://beian.miit.gov.cn
+# 网站LOG
+VUE_APP_LOG_URL =@/assets/logo/ylrz.png
+
+# 开发环境配置
+ENV = 'production'
+
+# FS管理系统/开发环境
+VUE_APP_BASE_API = '/prod-api'
+
+# 路由懒加载
+VUE_CLI_BABEL_TRANSPILE_MODULES = true
+
+# 患者信息
+VUE_APP_PATIENT_INFO = '客户信息'
+# 添加病人
+VUE_APP_ADD_PATIENT = '添加信息'

+ 27 - 0
adminui/.env.production

@@ -0,0 +1,27 @@
+#该文本用于打包测试平台
+# 页面标题
+VUE_APP_TITLE =云联融智SAAS管理系统
+# 首页菜单标题
+VUE_APP_TITLE_INDEX =云联融智SAAS
+# 公司名称
+VUE_APP_COMPANY_NAME =云联融智科技有限公司
+# ICP备案号
+VUE_APP_ICP_RECORD =
+# ICP网站访问地址
+VUE_APP_ICP_URL =https://beian.miit.gov.cn
+# 网站LOG
+VUE_APP_LOG_URL =@/assets/logo/ylrz.png
+
+# 开发环境配置
+ENV = 'production'
+
+# FS管理系统/开发环境
+VUE_APP_BASE_API = '/prod-api'
+
+# 路由懒加载
+VUE_CLI_BABEL_TRANSPILE_MODULES = true
+
+# 患者信息
+VUE_APP_PATIENT_INFO = '客户信息'
+# 添加病人
+VUE_APP_ADD_PATIENT = '添加信息'

+ 10 - 0
adminui/.eslintignore

@@ -0,0 +1,10 @@
+# 忽略build目录下类型为js的文件的语法检查
+build/*.js
+# 忽略src/assets目录下文件的语法检查
+src/assets
+# 忽略public目录下文件的语法检查
+public
+# 忽略当前目录下为js的文件的语法检查
+*.js
+# 忽略当前目录下为vue的文件的语法检查
+*.vue

+ 199 - 0
adminui/.eslintrc.js

@@ -0,0 +1,199 @@
+// ESlint 检查配置
+module.exports = {
+  root: true,
+  parserOptions: {
+    parser: 'babel-eslint',
+    sourceType: 'module'
+  },
+  env: {
+    browser: true,
+    node: true,
+    es6: true,
+  },
+  extends: ['plugin:vue/recommended', 'eslint:recommended'],
+
+  // add your custom rules here
+  //it is base on https://github.com/vuejs/eslint-config-vue
+  rules: {
+    "vue/max-attributes-per-line": [2, {
+      "singleline": 10,
+      "multiline": {
+        "max": 1,
+        "allowFirstLine": false
+      }
+    }],
+    "vue/singleline-html-element-content-newline": "off",
+    "vue/multiline-html-element-content-newline":"off",
+    "vue/name-property-casing": ["error", "PascalCase"],
+    "vue/no-v-html": "off",
+    'accessor-pairs': 2,
+    'arrow-spacing': [2, {
+      'before': true,
+      'after': true
+    }],
+    'block-spacing': [2, 'always'],
+    'brace-style': [2, '1tbs', {
+      'allowSingleLine': true
+    }],
+    'camelcase': [0, {
+      'properties': 'always'
+    }],
+    'comma-dangle': [2, 'never'],
+    'comma-spacing': [2, {
+      'before': false,
+      'after': true
+    }],
+    'comma-style': [2, 'last'],
+    'constructor-super': 2,
+    'curly': [2, 'multi-line'],
+    'dot-location': [2, 'property'],
+    'eol-last': 2,
+    'eqeqeq': ["error", "always", {"null": "ignore"}],
+    'generator-star-spacing': [2, {
+      'before': true,
+      'after': true
+    }],
+    'handle-callback-err': [2, '^(err|error)$'],
+    'indent': [2, 2, {
+      'SwitchCase': 1
+    }],
+    'jsx-quotes': [2, 'prefer-single'],
+    'key-spacing': [2, {
+      'beforeColon': false,
+      'afterColon': true
+    }],
+    'keyword-spacing': [2, {
+      'before': true,
+      'after': true
+    }],
+    'new-cap': [2, {
+      'newIsCap': true,
+      'capIsNew': false
+    }],
+    'new-parens': 2,
+    'no-array-constructor': 2,
+    'no-caller': 2,
+    'no-console': 'off',
+    'no-class-assign': 2,
+    'no-cond-assign': 2,
+    'no-const-assign': 2,
+    'no-control-regex': 0,
+    'no-delete-var': 2,
+    'no-dupe-args': 2,
+    'no-dupe-class-members': 2,
+    'no-dupe-keys': 2,
+    'no-duplicate-case': 2,
+    'no-empty-character-class': 2,
+    'no-empty-pattern': 2,
+    'no-eval': 2,
+    'no-ex-assign': 2,
+    'no-extend-native': 2,
+    'no-extra-bind': 2,
+    'no-extra-boolean-cast': 2,
+    'no-extra-parens': [2, 'functions'],
+    'no-fallthrough': 2,
+    'no-floating-decimal': 2,
+    'no-func-assign': 2,
+    'no-implied-eval': 2,
+    'no-inner-declarations': [2, 'functions'],
+    'no-invalid-regexp': 2,
+    'no-irregular-whitespace': 2,
+    'no-iterator': 2,
+    'no-label-var': 2,
+    'no-labels': [2, {
+      'allowLoop': false,
+      'allowSwitch': false
+    }],
+    'no-lone-blocks': 2,
+    'no-mixed-spaces-and-tabs': 2,
+    'no-multi-spaces': 2,
+    'no-multi-str': 2,
+    'no-multiple-empty-lines': [2, {
+      'max': 1
+    }],
+    'no-native-reassign': 2,
+    'no-negated-in-lhs': 2,
+    'no-new-object': 2,
+    'no-new-require': 2,
+    'no-new-symbol': 2,
+    'no-new-wrappers': 2,
+    'no-obj-calls': 2,
+    'no-octal': 2,
+    'no-octal-escape': 2,
+    'no-path-concat': 2,
+    'no-proto': 2,
+    'no-redeclare': 2,
+    'no-regex-spaces': 2,
+    'no-return-assign': [2, 'except-parens'],
+    'no-self-assign': 2,
+    'no-self-compare': 2,
+    'no-sequences': 2,
+    'no-shadow-restricted-names': 2,
+    'no-spaced-func': 2,
+    'no-sparse-arrays': 2,
+    'no-this-before-super': 2,
+    'no-throw-literal': 2,
+    'no-trailing-spaces': 2,
+    'no-undef': 2,
+    'no-undef-init': 2,
+    'no-unexpected-multiline': 2,
+    'no-unmodified-loop-condition': 2,
+    'no-unneeded-ternary': [2, {
+      'defaultAssignment': false
+    }],
+    'no-unreachable': 2,
+    'no-unsafe-finally': 2,
+    'no-unused-vars': [2, {
+      'vars': 'all',
+      'args': 'none'
+    }],
+    'no-useless-call': 2,
+    'no-useless-computed-key': 2,
+    'no-useless-constructor': 2,
+    'no-useless-escape': 0,
+    'no-whitespace-before-property': 2,
+    'no-with': 2,
+    'one-var': [2, {
+      'initialized': 'never'
+    }],
+    'operator-linebreak': [2, 'after', {
+      'overrides': {
+        '?': 'before',
+        ':': 'before'
+      }
+    }],
+    'padded-blocks': [2, 'never'],
+    'quotes': [2, 'single', {
+      'avoidEscape': true,
+      'allowTemplateLiterals': true
+    }],
+    'semi': [2, 'never'],
+    'semi-spacing': [2, {
+      'before': false,
+      'after': true
+    }],
+    'space-before-blocks': [2, 'always'],
+    'space-before-function-paren': [2, 'never'],
+    'space-in-parens': [2, 'never'],
+    'space-infix-ops': 2,
+    'space-unary-ops': [2, {
+      'words': true,
+      'nonwords': false
+    }],
+    'spaced-comment': [2, 'always', {
+      'markers': ['global', 'globals', 'eslint', 'eslint-disable', '*package', '!', ',']
+    }],
+    'template-curly-spacing': [2, 'never'],
+    'use-isnan': 2,
+    'valid-typeof': 2,
+    'wrap-iife': [2, 'any'],
+    'yield-star-spacing': [2, 'both'],
+    'yoda': [2, 'never'],
+    'prefer-const': 2,
+    'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0,
+    'object-curly-spacing': [2, 'always', {
+      objectsInObjects: false
+    }],
+    'array-bracket-spacing': [2, 'never']
+  }
+}

+ 30 - 0
adminui/.gitignore

@@ -0,0 +1,30 @@
+.DS_Store
+node_modules
+/dist
+
+
+# local env files
+.env.local
+.env.*.local
+
+# Log files
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+
+# Editor directories and files
+.idea
+.vscode
+*.log
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+package-lock.json
+yarn.lock
+
+# AGENTS.md - 项目指导文档,不上传到远程仓库
+AGENTS.md

+ 10 - 0
adminui/Dockerfile

@@ -0,0 +1,10 @@
+# 基于 NGINX 部署(需先本地打包: npm run build:prod)
+# docker build -t adminui:1.0.20 .
+
+FROM docker.m.daocloud.io/library/nginx:alpine
+
+COPY ./dist /usr/share/nginx/html
+COPY ./nginx.conf /etc/nginx/nginx.conf
+
+EXPOSE 80
+CMD ["nginx", "-g", "daemon off;"]

+ 36 - 0
adminui/README.en.md

@@ -0,0 +1,36 @@
+# his_adminui
+
+#### Description
+adminui
+
+#### Software Architecture
+Software architecture description
+
+#### Installation
+
+1.  xxxx
+2.  xxxx
+3.  xxxx
+
+#### Instructions
+
+1.  xxxx
+2.  xxxx
+3.  xxxx
+
+#### Contribution
+
+1.  Fork the repository
+2.  Create Feat_xxx branch
+3.  Commit your code
+4.  Create Pull Request
+
+
+#### Gitee Feature
+
+1.  You can use Readme\_XXX.md to support different languages, such as Readme\_en.md, Readme\_zh.md
+2.  Gitee blog [blog.gitee.com](https://blog.gitee.com)
+3.  Explore open source project [https://gitee.com/explore](https://gitee.com/explore)
+4.  The most valuable open source project [GVP](https://gitee.com/gvp)
+5.  The manual of Gitee [https://gitee.com/help](https://gitee.com/help)
+6.  The most popular members  [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/)

+ 37 - 0
adminui/README.md

@@ -0,0 +1,37 @@
+# his_adminui
+
+#### 介绍
+adminui
+
+#### 软件架构
+软件架构说明
+
+
+#### 安装教程
+
+1.  xxxx
+2.  xxxx
+3.  xxxx
+
+#### 使用说明
+
+1.  xxxx
+2.  xxxx
+3.  xxxx
+
+#### 参与贡献
+
+1.  Fork 本仓库
+2.  新建 Feat_xxx 分支
+3.  提交代码
+4.  新建 Pull Request
+
+
+#### 特技
+
+1.  使用 Readme\_XXX.md 来支持不同的语言,例如 Readme\_en.md, Readme\_zh.md
+2.  Gitee 官方博客 [blog.gitee.com](https://blog.gitee.com)
+3.  你可以 [https://gitee.com/explore](https://gitee.com/explore) 这个地址来了解 Gitee 上的优秀开源项目
+4.  [GVP](https://gitee.com/gvp) 全称是 Gitee 最有价值开源项目,是综合评定出的优秀开源项目
+5.  Gitee 官方提供的使用手册 [https://gitee.com/help](https://gitee.com/help)
+6.  Gitee 封面人物是一档用来展示 Gitee 会员风采的栏目 [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/)

+ 17 - 0
adminui/babel.config.js

@@ -0,0 +1,17 @@
+module.exports = {
+  presets: [
+    // https://github.com/vuejs/vue-cli/tree/master/packages/@vue/babel-preset-app
+    '@vue/cli-plugin-babel/preset'
+  ],
+  'env': {
+    'development': {
+      // babel-plugin-dynamic-import-node plugin only does one thing by converting all import() to require().
+      // This plugin can significantly increase the speed of hot updates, when you have a large number of pages.
+      'plugins': ['dynamic-import-node']
+    }
+  },
+  plugins: [
+    '@babel/plugin-proposal-nullish-coalescing-operator',
+    '@babel/plugin-proposal-optional-chaining'
+  ]
+}

+ 12 - 0
adminui/bin/build.bat

@@ -0,0 +1,12 @@
+@echo off
+echo.
+echo [信息] 打包Web工程,生成dist文件。
+echo.
+
+%~d0
+cd %~dp0
+
+cd ..
+npm run build:prod
+
+pause

+ 12 - 0
adminui/bin/package.bat

@@ -0,0 +1,12 @@
+@echo off
+echo.
+echo [信息] 安装Web工程,生成node_modules文件。
+echo.
+
+%~d0
+cd %~dp0
+
+cd ..
+npm install --registry=https://registry.npm.taobao.org
+
+pause

+ 12 - 0
adminui/bin/run-web.bat

@@ -0,0 +1,12 @@
+@echo off
+echo.
+echo [信息] 使用 Vue CLI 命令运行 Web 工程。
+echo.
+
+%~d0
+cd %~dp0
+
+cd ..
+npm run dev
+
+pause

+ 35 - 0
adminui/build/index.js

@@ -0,0 +1,35 @@
+const { run } = require('runjs')
+const chalk = require('chalk')
+const config = require('../vue.config.js')
+const rawArgv = process.argv.slice(2)
+const args = rawArgv.join(' ')
+
+if (process.env.npm_config_preview || rawArgv.includes('--preview')) {
+  const report = rawArgv.includes('--report')
+
+  run(`vue-cli-service build ${args}`)
+
+  const port = 9526
+  const publicPath = config.publicPath
+
+  var connect = require('connect')
+  var serveStatic = require('serve-static')
+  const app = connect()
+
+  app.use(
+    publicPath,
+    serveStatic('./dist', {
+      index: ['index.html', '/']
+    })
+  )
+
+  app.listen(port, function () {
+    console.log(chalk.green(`> Preview at  http://localhost:${port}${publicPath}`))
+    if (report) {
+      console.log(chalk.green(`> Report at  http://localhost:${port}${publicPath}report.html`))
+    }
+
+  })
+} else {
+  run(`vue-cli-service build ${args}`)
+}

+ 15 - 0
adminui/jsconfig.json

@@ -0,0 +1,15 @@
+{
+  "compilerOptions": {
+    "baseUrl": ".",
+    "paths": {
+      "@/*": ["src/*"]
+    },
+    "target": "es5",
+    "module": "esnext",
+    "moduleResolution": "node",
+    "allowJs": true,
+    "checkJs": false
+  },
+  "include": ["src/**/*.js", "src/**/*.vue"],
+  "exclude": ["node_modules", "dist"]
+}

+ 36 - 0
adminui/nginx.conf

@@ -0,0 +1,36 @@
+# nginx.conf - adminui 总后台(fs-nginx-admin)
+# 与 vue.config.js 一致:全部 /prod-api/* → fs-admin:8003
+worker_processes  5;
+
+events {
+    worker_connections  1024;
+}
+
+http {
+    include       mime.types;
+    default_type  application/octet-stream;
+    client_max_body_size 200m;
+    sendfile        on;
+    keepalive_timeout  65;
+
+    server {
+        listen       80;
+        server_name  localhost;
+
+        root   /usr/share/nginx/html;
+        index  index.html;
+
+        location / {
+            try_files $uri $uri/ /index.html;
+        }
+
+        # 默认全部 API → fs-admin(内网 192.168.58.159:8003)
+        location /prod-api/ {
+            proxy_pass http://192.168.58.159:8003/;
+            proxy_set_header Host $host;
+            proxy_set_header X-Real-IP $remote_addr;
+            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+            proxy_set_header X-Forwarded-Proto $scheme;
+        }
+    }
+}

+ 0 - 0
adminui/notepad.txt


+ 113 - 0
adminui/package.json

@@ -0,0 +1,113 @@
+{
+  "name": "FS",
+  "version": "1.1.0",
+  "description": "FS管理系统",
+  "author": "YZX",
+  "license": "MIT",
+  "scripts": {
+    "dev": "set NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service serve",
+    "build:prod": "set NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service build",
+    "build:stage": "set NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service build --mode staging",
+    "preview": "node build/index.js --preview",
+    "lint": "eslint --ext .js,.vue src"
+  },
+  "husky": {
+    "hooks": {
+      "pre-commit": "lint-staged"
+    }
+  },
+  "lint-staged": {
+    "src/**/*.{js,vue}": [
+      "eslint --fix",
+      "git add"
+    ]
+  },
+  "keywords": [
+    "vue",
+    "admin",
+    "dashboard",
+    "element-ui",
+    "boilerplate",
+    "admin-template",
+    "management-system"
+  ],
+  "dependencies": {
+    "@amap/amap-jsapi-loader": "^1.0.1",
+    "@huaweicloud/huaweicloud-sdk-core": "^3.1.105",
+    "@huaweicloud/huaweicloud-sdk-vod": "^3.1.105",
+    "@riophae/vue-treeselect": "0.4.0",
+    "axios": "0.21.0",
+    "chart.js": "^2.9.4",
+    "clipboard": "2.0.6",
+    "core-js": "3.8.1",
+    "cos-js-sdk-v5": "^1.8.3",
+    "dayjs": "^1.11.13",
+    "echarts": "^4.9.0",
+    "element-ui": "2.15.5",
+    "esdk-obs-browserjs": "^3.24.3",
+    "file-saver": "2.0.4",
+    "fuse.js": "6.4.3",
+    "highlight.js": "9.18.5",
+    "hls.js": "^1.6.14",
+    "image-conversion": "^2.1.1",
+    "js-beautify": "1.13.0",
+    "js-cookie": "2.2.1",
+    "js-yaml": "^4.1.1",
+    "jsencrypt": "3.0.0-rc.1",
+    "monaco-editor": "^0.55.1",
+    "nprogress": "0.2.0",
+    "qrcode": "^1.5.4",
+    "qrcodejs2": "0.0.2",
+    "quill": "1.3.7",
+    "screenfull": "5.0.2",
+    "sortablejs": "1.10.2",
+    "tt-uploader": "^1.5.5",
+    "vod-js-sdk-v6": "^1.7.0",
+    "vue": "2.6.12",
+    "vue-baidu-map": "^0.21.22",
+    "vue-count-to": "1.0.13",
+    "vue-cropper": "0.5.5",
+    "vue-full-calendar": "^2.8.1-0",
+    "vue-jsonp": "^2.0.0",
+    "vue-meta": "^2.4.0",
+    "vue-mobile-calendar": "^3.3.0",
+    "vue-monaco": "^1.2.2",
+    "vue-router": "3.4.9",
+    "vuedraggable": "^2.24.3",
+    "vuex": "3.6.0",
+    "wangeditor": "^4.7.5",
+    "xlsx": "^0.18.5"
+  },
+  "devDependencies": {
+    "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6",
+    "@babel/plugin-proposal-optional-chaining": "^7.21.0",
+    "@vue/cli-plugin-babel": "4.4.6",
+    "@vue/cli-plugin-eslint": "4.4.6",
+    "@vue/cli-service": "4.4.6",
+    "babel-eslint": "10.1.0",
+    "chalk": "4.1.0",
+    "connect": "3.6.6",
+    "eslint": "7.15.0",
+    "eslint-plugin-vue": "7.2.0",
+    "html-webpack-plugin": "^3.2.0",
+    "lint-staged": "10.5.3",
+    "runjs": "4.4.2",
+    "sass": "^1.77.0",
+    "sass-loader": "8.0.2",
+    "script-ext-html-webpack-plugin": "2.1.5",
+    "stylus": "^0.64.0",
+    "stylus-loader": "^3.0.2",
+    "svg-sprite-loader": "5.1.1",
+    "vue-template-compiler": "2.6.12",
+    "webpack": "^4.46.0",
+    "webpack-dev-server": "^3.11.3"
+  },
+  "engines": {
+    "node": ">=8.9",
+    "npm": ">= 3.0.0"
+  },
+  "browserslist": [
+    "> 1%",
+    "last 2 versions"
+  ]
+}

+ 646 - 0
adminui/public/chat-aggregate.html

@@ -0,0 +1,646 @@
+<!DOCTYPE html>
+<html lang="zh">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>数字员工 - 聚合聊天</title>
+<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
+<style>
+*{margin:0;padding:0;box-sizing:border-box}
+body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0a0a1a;color:#e0e0e0;height:100vh;overflow:hidden}
+.main{display:flex;height:100vh}
+
+/* 左侧账户列表 */
+.account-panel{width:80px;background:#0d0d1f;border-right:1px solid #1a1a3e;display:flex;flex-direction:column;padding:12px 0}
+.account-item{width:56px;height:56px;border-radius:50%;margin:0 auto 8px;cursor:pointer;position:relative;border:2px solid transparent;transition:.2s;display:flex;align-items:center;justify-content:center;font-size:24px}
+.account-item:hover{border-color:#e94560;transform:scale(1.05)}
+.account-item.active{border-color:#e94560;background:#e9456022}
+.account-item .badge{position:absolute;top:-2px;right:-2px;background:#e94560;color:#fff;border-radius:10px;padding:1px 5px;font-size:10px}
+.account-item.disabled{opacity:.4;cursor:not-allowed}
+
+/* 会话列表 */
+.session-panel{width:280px;background:#1a1a2e;border-right:1px solid #2a2a4a;display:flex;flex-direction:column}
+.session-header{padding:12px 16px;border-bottom:1px solid #2a2a4a}
+.session-header h3{font-size:14px;color:#e94560}
+.session-header .search{margin-top:8px}
+.session-header input{width:100%;padding:6px 10px;background:#0a0a1a;border:1px solid #2a2a4a;border-radius:4px;color:#e0e0e0;font-size:12px}
+.session-list{flex:1;overflow-y:auto;padding:8px}
+.session-item{display:flex;padding:10px;cursor:pointer;border-radius:6px;margin-bottom:4px;transition:.2s}
+.session-item:hover{background:#2a2a4a}
+.session-item.active{background:#0f3460}
+.session-avatar{width:40px;height:40px;border-radius:50%;background:linear-gradient(135deg,#e94560,#f59e0b);display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0}
+.session-info{flex:1;min-width:0;margin-left:10px}
+.session-info .name{font-size:13px;color:#e0e0e0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.session-info .msg{font-size:12px;color:#888;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:2px}
+.session-time{font-size:11px;color:#666;text-align:right}
+.session-item.unread .name{font-weight:600;color:#fff}
+.session-item.unread .badge{background:#e94560;color:#fff;border-radius:10px;padding:1px 5px;font-size:10px}
+
+/* 聊天区域 */
+.chat-panel{flex:1;display:flex;flex-direction:column;background:#0a0a1a}
+.chat-header{padding:12px 20px;background:#1a1a2e;border-bottom:1px solid #2a2a4a;display:flex;align-items:center;justify-content:space-between}
+.chat-title .name{font-size:15px;color:#e0e0e0}
+.chat-title .type{font-size:11px;color:#888;margin-left:8px}
+.chat-actions{display:flex;gap:8px}
+.chat-actions button{padding:6px 12px;background:#0f3460;border:none;border-radius:4px;color:#e0e0e0;font-size:12px;cursor:pointer}
+.chat-actions button:hover{background:#1a4a80}
+
+/* 消息列表 */
+.message-list{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column}
+.message-item{display:flex;margin-bottom:12px;max-width:80%}
+.message-item.sent{align-self:flex-end}
+.message-item.sent .msg-bubble{background:#e94560;color:#fff;border-radius:12px 12px 0 12px}
+.message-item.received{align-self:flex-start}
+.message-item.received .msg-bubble{background:#1a1a2e;color:#e0e0e0;border-radius:12px 12px 12px 0;border:1px solid #2a2a4a}
+.msg-avatar{width:32px;height:32px;border-radius:50%;background:linear-gradient(135deg,#3b82f6,#22c55e);display:flex;align-items:center;justify-content:center;font-size:12px;flex-shrink:0}
+.message-item.sent .msg-avatar{order:2;margin-left:8px}
+.message-item.received .msg-avatar{order:1;margin-right:8px}
+.msg-content{display:flex;flex-direction:column}
+.message-item.sent .msg-content{order:1}
+.message-item.received .msg-content{order:2}
+.msg-bubble{padding:10px 14px;max-width:max-content}
+.msg-text{font-size:13px;line-height:1.5}
+.msg-time{font-size:10px;color:#666;margin-top:4px;text-align:right}
+.message-item.sent .msg-time{color:#fff8}
+
+/* 输入区域 */
+.chat-input{padding:12px 20px;background:#1a1a2e;border-top:1px solid #2a2a4a}
+.input-row{display:flex;gap:10px}
+.input-row textarea{flex:1;padding:10px 14px;background:#0a0a1a;border:1px solid #2a2a4a;border-radius:8px;color:#e0e0e0;font-size:13px;resize:none;min-height:44px;max-height:120px;font-family:inherit}
+.input-row textarea:focus{outline:none;border-color:#e94560}
+.input-row button{padding:10px 24px;background:#e94560;color:#fff;border:none;border-radius:8px;cursor:pointer;font-size:13px;font-weight:500;flex-shrink:0}
+.input-row button:hover{background:#d63850}
+.input-row button:disabled{opacity:.5;cursor:not-allowed}
+
+/* 客户信息面板 */
+.customer-panel{width:280px;background:#1a1a2e;border-left:1px solid #2a2a4a;display:flex;flex-direction:column}
+.customer-header{padding:16px;border-bottom:1px solid #2a2a4a;text-align:center}
+.customer-avatar{width:64px;height:64px;border-radius:50%;background:linear-gradient(135deg,#e94560,#f59e0b);display:flex;align-items:center;justify-content:center;font-size:24px;margin:0 auto}
+.customer-name{font-size:15px;color:#e0e0e0;margin-top:8px}
+.customer-id{font-size:11px;color:#666}
+.customer-tabs{display:flex;border-bottom:1px solid #2a2a4a}
+.customer-tabs .tab{flex:1;padding:8px;text-align:center;font-size:12px;color:#888;cursor:pointer;border-bottom:2px solid transparent}
+.customer-tabs .tab.active{border-color:#e94560;color:#e94560}
+.customer-detail{flex:1;overflow-y:auto;padding:12px}
+.detail-section{margin-bottom:16px}
+.detail-section h4{font-size:12px;color:#888;margin-bottom:8px}
+.detail-row{display:flex;justify-content:space-between;padding:4px 0;font-size:12px}
+.detail-row .label{color:#888}
+.detail-row .value{color:#e0e0e0}
+.tag-list{display:flex;flex-wrap:gap;gap:4px}
+.tag{display:inline-block;padding:3px 8px;background:#0f3460;color:#ccc;border-radius:4px;font-size:11px}
+.record-item{padding:8px;border-bottom:1px solid #1a1a3e}
+.record-item .time{font-size:11px;color:#666}
+.record-item .desc{font-size:12px;color:#e0e0e0;margin-top:2px}
+
+/* 渠道图标 */
+.channel-qw{background:linear-gradient(135deg,#1890ff,#096dd9)}
+.channel-wx{background:linear-gradient(135deg,#07c160,#10b981)}
+.channel-im{background:linear-gradient(135deg,#6366f1,#8b5cf6)}
+.channel-whatsapp{background:linear-gradient(135deg,#25d366,#10b981)}
+.channel-other{background:linear-gradient(135deg,#6b7280,#9ca3af)}
+
+/* 滚动条 */
+::-webkit-scrollbar{width:6px}
+::-webkit-scrollbar-track{background:#0a0a1a}
+::-webkit-scrollbar-thumb{background:#2a2a4a;border-radius:3px}
+::-webkit-scrollbar-thumb:hover{background:#3a3a5a}
+</style>
+</head>
+<body>
+<div id="app" class="main">
+  <!-- 左侧账户列表 -->
+  <div class="account-panel">
+    <div v-for="acc in accounts" :key="acc.id" 
+         :class="['account-item', acc.active ? 'active' : '', acc.connected ? '' : 'disabled']"
+         @click="selectAccount(acc)" :title="acc.name">
+      <span>{{acc.icon}}</span>
+      <span v-if="acc.unread>0" class="badge">{{acc.unread}}</span>
+    </div>
+  </div>
+
+  <!-- 会话列表 -->
+  <div class="session-panel">
+    <div class="session-header">
+      <h3>🗨️ 聊天列表</h3>
+      <div class="search">
+        <input v-model="searchKey" placeholder="搜索联系人..." @keyup="filterSessions">
+      </div>
+    </div>
+    <div class="session-list">
+      <div v-for="sess in filteredSessions" :key="sess.sessionId" 
+           :class="['session-item', sess.sessionId === currentSession?.sessionId ? 'active' : '', sess.unread > 0 ? 'unread' : '']"
+           @click="selectSession(sess)">
+        <div class="session-avatar">{{sess.avatar||'?'}}</div>
+        <div class="session-info">
+          <div class="name">{{sess.name}}</div>
+          <div class="msg">{{sess.lastMsg||'暂无消息'}}</div>
+        </div>
+        <div style="text-align:right;margin-left:8px">
+          <div class="session-time">{{formatLobsterDateTime(sess.lastTime)}}</div>
+          <span v-if="sess.unread>0" class="badge">{{sess.unread}}</span>
+        </div>
+      </div>
+      <div v-if="filteredSessions.length===0" style="text-align:center;padding:40px;color:#666;font-size:13px">
+        暂无会话记录
+      </div>
+    </div>
+  </div>
+
+  <!-- 聊天区域 -->
+  <div class="chat-panel">
+    <div v-if="currentSession" class="chat-header">
+      <div class="chat-title">
+        <span class="name">{{currentSession.name}}</span>
+        <span class="type">{{channelName(currentSession.channelType)}}</span>
+      </div>
+      <div class="chat-actions">
+        <button @click="toggleControlMode">{{currentSession.controlMode === 'ai' ? '🤖 AI接管中' : '👤 人工接管'}}</button>
+        <button @click="showCustomerInfo=true">👤 客户信息</button>
+      </div>
+    </div>
+    <div v-else class="chat-header">
+      <div class="chat-title">
+        <span class="name">请选择一个会话</span>
+      </div>
+    </div>
+
+    <div class="message-list" ref="messageList">
+      <div v-for="(msg, idx) in messages" :key="idx" :class="['message-item', msg.sendType === 1 ? 'received' : 'sent']">
+        <div class="msg-avatar">{{msg.sendType === 1 ? '👤' : '🤖'}}</div>
+        <div class="msg-content">
+          <div class="msg-bubble">
+            <div class="msg-text">{{msg.content}}</div>
+          </div>
+          <div class="msg-time">{{formatLobsterDateTime(msg.time)}}</div>
+        </div>
+      </div>
+      <div v-if="messages.length===0" style="text-align:center;padding:60px;color:#666">
+        <div style="font-size:48px;margin-bottom:12px">💬</div>
+        <p>开始与客户聊天</p>
+      </div>
+    </div>
+
+    <div class="chat-input">
+      <div class="input-row">
+        <textarea v-model="inputMsg" placeholder="输入消息..." @keyup.enter="sendMessage"></textarea>
+        <button @click="sendMessage" :disabled="!inputMsg.trim()">发送</button>
+      </div>
+    </div>
+  </div>
+
+  <!-- 客户信息面板 -->
+  <div class="customer-panel" v-if="showCustomerInfo">
+    <div class="customer-header">
+      <div class="customer-avatar">{{currentSession?.avatar||'?'}}</div>
+      <div class="customer-name">{{currentSession?.name||'-'}}</div>
+      <div class="customer-id">{{currentSession?.channelSourceId||'-'}}</div>
+    </div>
+    <div class="customer-tabs">
+      <div :class="['tab', customerTab==='basic'?'active':'']" @click="customerTab='basic'">基本信息</div>
+      <div :class="['tab', customerTab==='tags'?'active':'']" @click="customerTab='tags'">标签</div>
+      <div :class="['tab', customerTab==='records'?'active':'']" @click="customerTab='records'">访问记录</div>
+    </div>
+    <div class="customer-detail">
+      <div v-if="customerTab==='basic'" class="detail-section">
+        <h4>📋 基本信息</h4>
+        <div class="detail-row"><span class="label">渠道</span><span class="value">{{channelName(currentSession?.channelType)}}</span></div>
+        <div class="detail-row"><span class="label">来源ID</span><span class="value">{{currentSession?.channelSourceId||'-'}}</span></div>
+        <div class="detail-row"><span class="label">联系人ID</span><span class="value">{{currentSession?.contactId||'-'}}</span></div>
+        <div class="detail-row"><span class="label">会话ID</span><span class="value">{{currentSession?.sessionId||'-'}}</span></div>
+        <div class="detail-row"><span class="label">创建时间</span><span class="value">{{formatLobsterDateTime(currentSession?.createTime)}}</span></div>
+      </div>
+      <div v-if="customerTab==='tags'" class="detail-section">
+        <h4>🏷️ 客户标签</h4>
+        <div class="tag-list">
+          <span v-for="tag in customerTags" :key="tag" class="tag">{{tag}}</span>
+        </div>
+        <div v-if="customerTags.length===0" style="color:#666;font-size:12px">暂无标签</div>
+      </div>
+      <div v-if="customerTab==='records'" class="detail-section">
+        <h4>📊 访问记录</h4>
+        <div v-for="record in visitRecords" :key="record.time" class="record-item">
+          <div class="time">{{formatLobsterDateTime(record.time)}}</div>
+          <div class="desc">{{record.desc}}</div>
+        </div>
+        <div v-if="visitRecords.length===0" style="color:#666;font-size:12px">暂无访问记录</div>
+      </div>
+    </div>
+  </div>
+</div>
+
+<script>
+const {createApp, ref, computed, watch, nextTick, onMounted} = Vue;
+    createApp({
+        setup(){
+            const searchKey = ref('');
+            const inputMsg = ref('');
+            const showCustomerInfo = ref(true);
+            const customerTab = ref('basic');
+            const messageList = ref(null);
+            const loading = ref(false);
+            
+            // ====== API配置 ======
+            // 从URL参数获取配置(iframe嵌入时由父窗口传递)
+            const getUrlParam = (name) => {
+                const params = new URLSearchParams(window.location.search);
+                return params.get(name) || '';
+            };
+            
+            // 获取API基路径:URL参数 > 父窗口webpack环境变量 > 默认值
+            const getBaseApi = () => {
+                const fromUrl = getUrlParam('baseApi');
+                if (fromUrl) return fromUrl;
+                try {
+                    if (window.parent && window.parent.process && window.parent.process.env) {
+                        return window.parent.process.env.VUE_APP_BASE_API || '/dev-api';
+                    }
+                } catch(e) {}
+                return '/dev-api';
+            };
+            const BASE_API = getBaseApi();
+            
+            // 获取前端类型:URL参数 > 默认值
+            const getFrontendType = () => {
+                return getUrlParam('frontendType') || 'company';
+            };
+            const FRONTEND_TYPE = getFrontendType();
+            
+            // 从Cookie获取Token
+            const getToken = () => {
+                const match = document.cookie.match(/(?:^|;\s*)Web-Token=([^;]*)/);
+                return match ? match[1] : null;
+            };
+            
+            // 获取租户编码:URL参数 > localStorage
+            const getTenantCode = () => {
+                const fromUrl = getUrlParam('tenantCode');
+                if (fromUrl) return fromUrl;
+                try {
+                    return localStorage.getItem('tenantCode') || '';
+                } catch(e) { return ''; }
+            };
+            
+            // 认证请求工具
+            const request = async (url, options = {}) => {
+                const token = getToken();
+                const tenantCode = getTenantCode();
+                const headers = {
+                    'Content-Type': 'application/json',
+                    'X-Frontend-Type': FRONTEND_TYPE,
+                };
+                if (token) {
+                    headers['Authorization'] = 'Bearer ' + token;
+                }
+                if (tenantCode) {
+                    headers['tenant-code'] = tenantCode;
+                }
+                // 合并自定义headers
+                if (options.headers) {
+                    Object.assign(headers, options.headers);
+                }
+                
+                const fullUrl = url.startsWith('http') ? url : (BASE_API + url);
+                const response = await fetch(fullUrl, {
+                    ...options,
+                    headers,
+                    credentials: 'include',
+                });
+                if (!response.ok) {
+                    const text = await response.text().catch(() => '');
+                    throw new Error(`HTTP ${response.status}: ${text || response.statusText}`);
+                }
+                const data = await response.json();
+                if (data.code === 401) {
+                    // Token过期,显示提示而非强制刷新父窗口(避免死循环)
+                    console.warn('[ChatAggregate] 登录已过期');
+                    throw new Error('登录已过期');
+                }
+                return data;
+            };
+
+            const formatLobsterDateTime = (time) => {
+                if (time === null || time === undefined || time === '') return '—';
+                const date = time instanceof Date ? time : new Date(time);
+                if (isNaN(date.getTime())) return '—';
+                const y = date.getFullYear();
+                const m = 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}-${m}-${d} ${h}:${mi}:${s}`;
+            };
+
+            // 账户列表
+            const accounts = ref([]);
+
+            // 会话列表
+            const sessions = ref([]);
+
+            // 当前会话
+            const currentSession = ref(null);
+
+            // 消息列表
+            const messages = ref([]);
+
+            // 客户标签
+            const customerTags = ref([]);
+
+            // 访问记录
+            const visitRecords = ref([]);
+
+            // 当前登录用户的企微账户
+            const currentAccount = ref(null);
+
+            // 过滤后的会话
+            const filteredSessions = computed(()=>{
+                if(!searchKey.value) return sessions.value;
+                const key = searchKey.value.toLowerCase();
+                return sessions.value.filter(s=>{
+                    const name = s.nickName || s.name || '';
+                    return name.toLowerCase().includes(key);
+                });
+            });
+
+            // 加载账户列表
+            const loadAccounts = async () => {
+                try {
+                    // 先获取当前登录用户绑定的企微账户
+                    const res = await request('/qw/user/getMyQwUserList');
+                    if (res.code === 200 || res.code === 0) {
+                        const qwAccounts = (res.data || []).map((acc, idx) => ({
+                            id: acc.id || `qw_${idx}`,
+                            name: acc.qwUserName || '企微账户',
+                            icon: '💼',
+                            active: idx === 0,
+                            connected: true,
+                            unread: 0,
+                            type: 'QW',
+                            corpId: acc.corpId,
+                            qwUserId: acc.qwUserId
+                        }));
+                        
+                        // 添加个微账户占位(后续扩展)
+                        const wxAccounts = []; // 暂时为空,后续实现个微账户绑定
+                        
+                        accounts.value = [...qwAccounts, ...wxAccounts];
+                        
+                        if (accounts.value.length > 0) {
+                            currentAccount.value = accounts.value[0];
+                        }
+                    }
+                } catch (error) {
+                    console.error('加载账户失败:', error);
+                    // 降级显示模拟账户
+                    accounts.value = [
+                        {id:'qw', name:'企业微信', icon:'💼', active:true, connected:true, unread:0, type:'QW'},
+                        {id:'wx', name:'个人微信', icon:'💬', active:false, connected:false, unread:0, type:'WX'},
+                    ];
+                    currentAccount.value = accounts.value[0];
+                }
+            };
+
+            // 加载会话列表
+            const loadSessions = async () => {
+                try {
+                    // 先尝试加载chat会话
+                    const chatRes = await request('/chat/chatSession/list?pageNum=1&pageSize=100');
+                    if (chatRes.code === 200 || chatRes.rows) {
+                        const chatSessions = (chatRes.rows || []).map(s => ({
+                            sessionId: s.sessionId,
+                            name: s.nickName || s.userName || '客户',
+                            avatar: (s.nickName || s.userName || '客').charAt(0),
+                            channelType: 'CHAT',
+                            channelSourceId: s.userId,
+                            contactId: s.userId,
+                            lastMsg: '',
+                            lastTime: s.createTime || '',
+                            unread: 0,
+                            createTime: s.createTime,
+                            status: s.status
+                        }));
+                        
+                        // 再尝试加载企微外部联系人作为会话
+                        const qwRes = await request('/qw/externalContact/list?pageNum=1&pageSize=100');
+                        if (qwRes.code === 200 || qwRes.rows) {
+                            const qwSessions = (qwRes.rows || []).map(c => ({
+                                sessionId: `qw_${c.id}`,
+                                name: c.name || c.remark || '企微客户',
+                                avatar: (c.name || c.remark || '客').charAt(0),
+                                channelType: 'QW',
+                                channelSourceId: c.externalUserId,
+                                contactId: c.id,
+                                lastMsg: '',
+                                lastTime: c.createTime || '',
+                                unread: 0,
+                                createTime: c.createTime,
+                                tagIds: c.tagIds,
+                                customerId: c.customerId,
+                                remarkMobiles: c.remarkMobiles
+                            }));
+                            sessions.value = [...qwSessions, ...chatSessions];
+                        } else {
+                            sessions.value = chatSessions;
+                        }
+                    }
+                } catch (error) {
+                    console.error('加载会话失败:', error);
+                    sessions.value = [];
+                }
+                
+                if (sessions.value.length > 0 && !currentSession.value) {
+                    selectSession(sessions.value[0]);
+                }
+            };
+
+            // 选择账户
+            const selectAccount = (acc)=>{
+                if(!acc.connected) return;
+                accounts.value.forEach(a=>a.active = false);
+                acc.active = true;
+                currentAccount.value = acc;
+                loadSessions();
+            };
+
+            // 选择会话
+            const selectSession = async (sess)=>{
+                currentSession.value = sess;
+                // 清除未读
+                sess.unread = 0;
+                // 加载消息
+                await loadMessages(sess);
+                // 加载客户标签和信息
+                await loadCustomerInfo(sess);
+            };
+
+            // 加载消息
+            const loadMessages = async (session) => {
+                messages.value = [];
+                try {
+                    if (session.channelType === 'CHAT') {
+                        // 加载chat会话消息
+                        const chatDetailRes = await request(`/chat/chatSession/${session.sessionId}`);
+                        if (chatDetailRes.code === 200 || chatDetailRes.data) {
+                            const msgRes = await request(`/chat/chatMsg/list?sessionId=${session.sessionId}&pageNum=1&pageSize=100`);
+                            if (msgRes.rows) {
+                                messages.value = msgRes.rows.map(m => ({
+                                    content: m.content,
+                                    sendType: m.sendType,
+                                    time: m.createTime || new Date()
+                                }));
+                            }
+                        }
+                    } else if (session.channelType === 'QW') {
+                        // TODO: 加载企微聊天记录
+                        messages.value = [];
+                    }
+                } catch (error) {
+                    console.error('加载消息失败:', error);
+                    messages.value = [];
+                }
+                
+                nextTick(()=>{
+                    if(messageList.value){
+                        messageList.value.scrollTop = messageList.value.scrollHeight;
+                    }
+                });
+            };
+
+            // 加载客户信息
+            const loadCustomerInfo = async (session) => {
+                customerTags.value = [];
+                visitRecords.value = [];
+                
+                try {
+                    if (session.tagIds && session.tagIds !== '[]') {
+                        // 解析标签ID并加载标签名称
+                        const tagIds = JSON.parse(session.tagIds);
+                        if (tagIds.length > 0) {
+                            const tagRes = await request(`/qw/tag/list?tagIds=${tagIds.join(',')}`);
+                            if (tagRes.rows) {
+                                customerTags.value = tagRes.rows.map(t => t.tagName || t.name);
+                            }
+                        }
+                    }
+                    
+                    // 加载CRM客户信息
+                    if (session.customerId) {
+                        const crmRes = await request(`/crm/customer/${session.customerId}`);
+                        if (crmRes.data) {
+                            // 补充客户基本信息
+                            if (crmRes.data.phone) {
+                                visitRecords.value.push({
+                                    time: new Date(),
+                                    desc: `手机号: ${crmRes.data.phone}`
+                                });
+                            }
+                            if (crmRes.data.email) {
+                                visitRecords.value.push({
+                                    time: new Date(),
+                                    desc: `邮箱: ${crmRes.data.email}`
+                                });
+                            }
+                        }
+                    }
+                    
+                    // 加载fastGpt聊天摘要
+                    try {
+                        const chatRes = await request('/fastGpt/fastGptChatSession/list?pageNum=1&pageSize=10');
+                        if (chatRes.rows) {
+                            const relatedChat = chatRes.rows.find(c => 
+                                c.externalUserId === session.channelSourceId || 
+                                c.userId === session.contactId
+                            );
+                            if (relatedChat) {
+                                visitRecords.value.push({
+                                    time: relatedChat.createTime || new Date(),
+                                    desc: `最近聊天: ${relatedChat.lastMsg || '暂无消息'}`
+                                });
+                            }
+                        }
+                    } catch (err) {
+                        console.error('加载聊天摘要失败:', err);
+                    }
+                } catch (error) {
+                    console.error('加载客户信息失败:', error);
+                }
+            };
+
+            // 发送消息
+            const sendMessage = async ()=>{
+                if(!inputMsg.value.trim() || !currentSession.value) return;
+                
+                const msg = {
+                    content: inputMsg.value, 
+                    sendType: 2, 
+                    time: new Date()
+                };
+                messages.value.push(msg);
+                const contentToSend = inputMsg.value;
+                inputMsg.value = '';
+                
+                nextTick(()=>{
+                    if(messageList.value){
+                        messageList.value.scrollTop = messageList.value.scrollHeight;
+                    }
+                });
+                
+                // 调用发送消息接口
+                try {
+                    if (currentSession.value.channelType === 'QW') {
+                        // 企微发送消息
+                        await request('/qw/msg/send', {
+                            method: 'POST',
+                            body: JSON.stringify({
+                                externalUserId: currentSession.value.channelSourceId,
+                                content: contentToSend,
+                                qwUserId: currentAccount.value?.qwUserId
+                            })
+                        });
+                    } else if (currentSession.value.channelType === 'CHAT') {
+                        // Chat会话发送消息
+                        await request('/chat/chatMsg', {
+                            method: 'POST',
+                            body: JSON.stringify({
+                                sessionId: currentSession.value.sessionId,
+                                content: contentToSend,
+                                sendType: 2
+                            })
+                        });
+                    }
+                } catch (error) {
+                    console.error('发送消息失败:', error);
+                }
+            };
+
+            // 切换控制模式
+            const toggleControlMode = ()=>{
+                if(currentSession.value){
+                    currentSession.value.controlMode = currentSession.value.controlMode === 'ai' ? 'human' : 'ai';
+                }
+            };
+
+            // 渠道名称
+            const channelName = (type)=>{
+                const names = {QW:'企业微信', WX:'个人微信', IM:'系统IM', WHATSAPP:'WhatsApp', OTHER:'其他渠道', CHAT:'在线咨询'};
+                return names[type] || type;
+            };
+
+            // 初始化
+            onMounted(async () => {
+                await loadAccounts();
+                await loadSessions();
+            });
+
+            return {
+                searchKey, inputMsg, showCustomerInfo, customerTab, messageList, loading,
+                accounts, sessions, currentSession, messages, customerTags, visitRecords,
+                filteredSessions,
+                selectAccount, selectSession, sendMessage, toggleControlMode, channelName, formatLobsterDateTime
+            };
+        }
+    }).mount('#app');
+</script>
+</body>
+</html>

Разница между файлами не показана из-за своего большого размера
+ 21 - 0
adminui/public/html/ie.html


+ 209 - 0
adminui/public/index.html

@@ -0,0 +1,209 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
+    <meta name="renderer" content="webkit">
+    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
+    <!-- <link rel="icon" href="<%= BASE_URL %>favicon.ico"> -->
+    <!-- <title><%= webpackConfig.name %></title> -->
+    <!--[if lt IE 11]><script>window.location.href='/html/ie.html';</script><![endif]-->
+    <!-- <script charset="utf-8" src="https://map.qq.com/api/gljs?v=1.exp&key=UZQBZ-SYQL3-LYF3K-Y7FAL-N3656-2DBJ4"></script> -->
+	  <style>
+    html,
+    body,
+    #app {
+      height: 100%;
+      margin: 0px;
+      padding: 0px;
+    }
+    .chromeframe {
+      margin: 0.2em 0;
+      background: #ccc;
+      color: #000;
+      padding: 0.2em 0;
+    }
+
+    #loader-wrapper {
+      position: fixed;
+      top: 0;
+      left: 0;
+      width: 100%;
+      height: 100%;
+      z-index: 999999;
+    }
+
+    #loader {
+      display: block;
+      position: relative;
+      left: 50%;
+      top: 50%;
+      width: 150px;
+      height: 150px;
+      margin: -75px 0 0 -75px;
+      border-radius: 50%;
+      border: 3px solid transparent;
+      border-top-color: #FFF;
+      -webkit-animation: spin 2s linear infinite;
+      -ms-animation: spin 2s linear infinite;
+      -moz-animation: spin 2s linear infinite;
+      -o-animation: spin 2s linear infinite;
+      animation: spin 2s linear infinite;
+      z-index: 1001;
+    }
+
+    #loader:before {
+      content: "";
+      position: absolute;
+      top: 5px;
+      left: 5px;
+      right: 5px;
+      bottom: 5px;
+      border-radius: 50%;
+      border: 3px solid transparent;
+      border-top-color: #FFF;
+      -webkit-animation: spin 3s linear infinite;
+      -moz-animation: spin 3s linear infinite;
+      -o-animation: spin 3s linear infinite;
+      -ms-animation: spin 3s linear infinite;
+      animation: spin 3s linear infinite;
+    }
+
+    #loader:after {
+      content: "";
+      position: absolute;
+      top: 15px;
+      left: 15px;
+      right: 15px;
+      bottom: 15px;
+      border-radius: 50%;
+      border: 3px solid transparent;
+      border-top-color: #FFF;
+      -moz-animation: spin 1.5s linear infinite;
+      -o-animation: spin 1.5s linear infinite;
+      -ms-animation: spin 1.5s linear infinite;
+      -webkit-animation: spin 1.5s linear infinite;
+      animation: spin 1.5s linear infinite;
+    }
+
+
+    @-webkit-keyframes spin {
+      0% {
+        -webkit-transform: rotate(0deg);
+        -ms-transform: rotate(0deg);
+        transform: rotate(0deg);
+      }
+      100% {
+        -webkit-transform: rotate(360deg);
+        -ms-transform: rotate(360deg);
+        transform: rotate(360deg);
+      }
+    }
+
+    @keyframes spin {
+      0% {
+        -webkit-transform: rotate(0deg);
+        -ms-transform: rotate(0deg);
+        transform: rotate(0deg);
+      }
+      100% {
+        -webkit-transform: rotate(360deg);
+        -ms-transform: rotate(360deg);
+        transform: rotate(360deg);
+      }
+    }
+
+
+    #loader-wrapper .loader-section {
+      position: fixed;
+      top: 0;
+      width: 51%;
+      height: 100%;
+      background: #7171C6;
+      z-index: 1000;
+      -webkit-transform: translateX(0);
+      -ms-transform: translateX(0);
+      transform: translateX(0);
+    }
+
+    #loader-wrapper .loader-section.section-left {
+      left: 0;
+    }
+
+    #loader-wrapper .loader-section.section-right {
+      right: 0;
+    }
+
+
+    .loaded #loader-wrapper .loader-section.section-left {
+      -webkit-transform: translateX(-100%);
+      -ms-transform: translateX(-100%);
+      transform: translateX(-100%);
+      -webkit-transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1.000);
+      transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1.000);
+    }
+
+    .loaded #loader-wrapper .loader-section.section-right {
+      -webkit-transform: translateX(100%);
+      -ms-transform: translateX(100%);
+      transform: translateX(100%);
+      -webkit-transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1.000);
+      transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1.000);
+    }
+
+    .loaded #loader {
+      opacity: 0;
+      -webkit-transition: all 0.3s ease-out;
+      transition: all 0.3s ease-out;
+    }
+
+    .loaded #loader-wrapper {
+      visibility: hidden;
+      -webkit-transform: translateY(-100%);
+      -ms-transform: translateY(-100%);
+      transform: translateY(-100%);
+      -webkit-transition: all 0.3s 1s ease-out;
+      transition: all 0.3s 1s ease-out;
+    }
+
+    .no-js #loader-wrapper {
+      display: none;
+    }
+
+    .no-js h1 {
+      color: #222222;
+    }
+
+    #loader-wrapper .load_title {
+      font-family: 'Open Sans';
+      color: #FFF;
+      font-size: 19px;
+      width: 100%;
+      text-align: center;
+      z-index: 9999999999999;
+      position: absolute;
+      top: 60%;
+      opacity: 1;
+      line-height: 30px;
+    }
+
+    #loader-wrapper .load_title span {
+      font-weight: normal;
+      font-style: italic;
+      font-size: 13px;
+      color: #FFF;
+      opacity: 0.5;
+    }
+  </style>
+  </head>
+  <body>
+    <div id="app">
+	    <div id="loader-wrapper">
+		    <div id="loader"></div>
+		    <div class="loader-section section-left"></div>
+		    <div class="loader-section section-right"></div>
+		    <div class="load_title">正在加载系统资源,请耐心等待</div>
+        </div>
+	</div>
+  </body>
+</html>

+ 2 - 0
adminui/public/robots.txt

@@ -0,0 +1,2 @@
+User-agent: *
+Disallow: /

+ 12 - 0
adminui/public/service.html

@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html>
+	<head>
+		<meta charset="utf-8">
+		<title>技术支持</title>
+	</head>
+	<body>
+		<h4>芳华未来技术支持</h4>
+		<p>联系电话:17749925835</p>
+		<p>联系邮箱:17749925835@163.com</p>
+	</body>
+</html>

+ 70 - 0
adminui/scripts/apply-execution-config-dedup-columns.js

@@ -0,0 +1,70 @@
+const mysql = require('mysql2/promise')
+
+const DB = process.argv[2] || 'fs_tenant_cs1'
+const cfg = {
+  host: 'cq-cdb-8fjmemkb.sql.tencentcdb.com',
+  port: 27220,
+  user: 'root',
+  password: 'Ylrz_1q2w3e4r5t6y',
+  database: DB,
+  charset: 'utf8mb4',
+  multipleStatements: true
+}
+
+const COLUMNS = [
+  {
+    name: 'message_dedup_enabled',
+    sql: "ADD COLUMN message_dedup_enabled tinyint NOT NULL DEFAULT 1 COMMENT 'message dedup enabled' AFTER quality_multiturn_min_avg_score_percent"
+  },
+  {
+    name: 'message_dedup_exact_window_size',
+    sql: "ADD COLUMN message_dedup_exact_window_size int NOT NULL DEFAULT 5 COMMENT 'exact dedup window size' AFTER message_dedup_enabled"
+  },
+  {
+    name: 'message_dedup_semantic_threshold',
+    sql: "ADD COLUMN message_dedup_semantic_threshold decimal(4,2) NOT NULL DEFAULT 0.85 COMMENT 'semantic threshold' AFTER message_dedup_exact_window_size"
+  },
+  {
+    name: 'message_dedup_window_duration_seconds',
+    sql: "ADD COLUMN message_dedup_window_duration_seconds int NOT NULL DEFAULT 300 COMMENT 'dedup window seconds' AFTER message_dedup_semantic_threshold"
+  }
+]
+
+async function colExists(c, col) {
+  const [r] = await c.query(
+    'SELECT COUNT(*) cnt FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND COLUMN_NAME=?',
+    ['lobster_execution_config', col]
+  )
+  return r[0].cnt > 0
+}
+
+async function tableExists(c) {
+  const [r] = await c.query(
+    "SELECT COUNT(*) cnt FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lobster_execution_config'"
+  )
+  return r[0].cnt > 0
+}
+
+async function main() {
+  const c = await mysql.createConnection(cfg)
+  console.log('Patch lobster_execution_config message_dedup columns on', DB)
+  if (!(await tableExists(c))) {
+    console.error('Table lobster_execution_config not found')
+    process.exit(1)
+  }
+  for (const col of COLUMNS) {
+    if (await colExists(c, col.name)) {
+      console.log('  skip', col.name, '(exists)')
+    } else {
+      await c.query(`ALTER TABLE lobster_execution_config ${col.sql}`)
+      console.log('  added', col.name)
+    }
+  }
+  const [cols] = await c.query(
+    "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lobster_execution_config' AND COLUMN_NAME LIKE 'message_dedup%' ORDER BY ORDINAL_POSITION"
+  )
+  console.log('message_dedup columns:', cols.map(r => r.COLUMN_NAME).join(', '))
+  await c.end()
+}
+
+main().catch(e => { console.error(e); process.exit(1) })

+ 34 - 0
adminui/scripts/apply-instance-scope-columns.js

@@ -0,0 +1,34 @@
+const mysql = require('mysql2/promise')
+const DB = process.argv[2] || 'fs_tenant_cs1'
+const cfg = {
+  host: 'cq-cdb-8fjmemkb.sql.tencentcdb.com', port: 27220,
+  user: 'root', password: 'Ylrz_1q2w3e4r5t6y', database: DB, charset: 'utf8mb4', multipleStatements: true
+}
+
+async function colExists(c, table, col) {
+  const [r] = await c.query(
+    'SELECT COUNT(*) cnt FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND COLUMN_NAME=?',
+    [table, col]
+  )
+  return r[0].cnt > 0
+}
+
+async function main() {
+  const c = await mysql.createConnection(cfg)
+  console.log('Patch instance scope columns on', DB)
+  const alters = [
+    ['dept_id', "ADD COLUMN dept_id BIGINT NULL AFTER channel_type"],
+    ['company_user_id', "ADD COLUMN company_user_id BIGINT NULL AFTER dept_id"],
+    ['qw_user_id', "ADD COLUMN qw_user_id BIGINT NULL AFTER company_user_id"],
+    ['wx_account_id', "ADD COLUMN wx_account_id BIGINT NULL AFTER qw_user_id"]
+  ]
+  for (const [col, sql] of alters) {
+    if (!(await colExists(c, 'lobster_workflow_instance', col))) {
+      await c.query(`ALTER TABLE lobster_workflow_instance ${sql}`)
+      console.log('  added', col)
+    }
+  }
+  await c.end()
+  console.log('Done')
+}
+main().catch(e => { console.error(e); process.exit(1) })

+ 63 - 0
adminui/scripts/apply-lobster-execution-config-cleanup.js

@@ -0,0 +1,63 @@
+const mysql = require('mysql2/promise')
+const fs = require('fs')
+const path = require('path')
+
+const DB_CFG = {
+  host: 'cq-cdb-8fjmemkb.sql.tencentcdb.com',
+  port: 27220,
+  user: 'root',
+  password: 'Ylrz_1q2w3e4r5t6y',
+  multipleStatements: true
+}
+
+const MIGRATION_SQL = fs.readFileSync(
+  path.join(__dirname, '../../java/fs-agent/src/main/resources/db/migration/tenant/V20260623_01__lobster_execution_config_drop_task_policy.sql'),
+  'utf8'
+)
+
+function dbNameFromUrl(url) {
+  if (!url) return null
+  const m = String(url).match(/\/([^/?]+)(?:\?|$)/)
+  return m ? m[1] : null
+}
+
+async function applyMigration(database) {
+  const conn = await mysql.createConnection({ ...DB_CFG, database })
+  await conn.query(MIGRATION_SQL)
+  const [cols] = await conn.query(
+    "SHOW COLUMNS FROM lobster_execution_config LIKE 'enable_%'"
+  )
+  await conn.end()
+  return { database, legacyCols: cols.length }
+}
+
+async function main() {
+  const masterConn = await mysql.createConnection({ ...DB_CFG, database: 'ylrz_saas' })
+  const dbs = new Set()
+  try {
+    const [tenants] = await masterConn.query(
+      'SELECT id, tenant_code, db_url FROM tenant_info WHERE db_url IS NOT NULL AND db_url != \'\''
+    )
+    for (const t of tenants) {
+      const name = dbNameFromUrl(t.db_url)
+      if (name) dbs.add(name)
+    }
+  } catch (e) {
+    console.warn('tenant_info query skipped:', e.message)
+  }
+  await masterConn.end()
+
+  for (const db of dbs) {
+    try {
+      const r = await applyMigration(db)
+      console.log(`${db}: legacy_task_policy_cols=${r.legacyCols} (0=OK)`)
+    } catch (e) {
+      console.error(`${db}: ERROR ${e.message}`)
+    }
+  }
+}
+
+main().catch(e => {
+  console.error(e)
+  process.exit(1)
+})

+ 116 - 0
adminui/scripts/apply-lobster-execution-quality-gate.js

@@ -0,0 +1,116 @@
+const mysql = require('mysql2/promise')
+
+const DB_CFG = {
+  host: process.env.DB_HOST || 'cq-cdb-8fjmemkb.sql.tencentcdb.com',
+  port: Number(process.env.DB_PORT || 27220),
+  user: process.env.DB_USER || 'root',
+  password: process.env.DB_PASSWORD || 'Ylrz_1q2w3e4r5t6y',
+  multipleStatements: true
+}
+
+const MASTER_DB = process.env.MASTER_DB || 'ylrz_saas'
+
+const ALTER_SQL = `
+SET @col_exists := (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lobster_execution_config' AND COLUMN_NAME = 'quality_gate_enabled');
+SET @ddl := IF(@col_exists = 0,
+    'ALTER TABLE lobster_execution_config ADD COLUMN quality_gate_enabled tinyint NOT NULL DEFAULT 1 COMMENT ''quality gate enabled'' AFTER auto_start_instance_on_tag',
+    'SELECT 1');
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @col_exists := (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lobster_execution_config' AND COLUMN_NAME = 'quality_pass_score_percent');
+SET @ddl := IF(@col_exists = 0,
+    'ALTER TABLE lobster_execution_config ADD COLUMN quality_pass_score_percent int NOT NULL DEFAULT 75 COMMENT ''runtime pass score percent'' AFTER quality_gate_enabled',
+    'SELECT 1');
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @col_exists := (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lobster_execution_config' AND COLUMN_NAME = 'quality_target_score_percent');
+SET @ddl := IF(@col_exists = 0,
+    'ALTER TABLE lobster_execution_config ADD COLUMN quality_target_score_percent int NOT NULL DEFAULT 75 COMMENT ''e2e target score percent'' AFTER quality_pass_score_percent',
+    'SELECT 1');
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @col_exists := (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lobster_execution_config' AND COLUMN_NAME = 'quality_min_node_score_percent');
+SET @ddl := IF(@col_exists = 0,
+    'ALTER TABLE lobster_execution_config ADD COLUMN quality_min_node_score_percent int NOT NULL DEFAULT 60 COMMENT ''e2e min node score percent'' AFTER quality_target_score_percent',
+    'SELECT 1');
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @col_exists := (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lobster_execution_config' AND COLUMN_NAME = 'quality_min_e2e_avg_score_percent');
+SET @ddl := IF(@col_exists = 0,
+    'ALTER TABLE lobster_execution_config ADD COLUMN quality_min_e2e_avg_score_percent int NOT NULL DEFAULT 75 COMMENT ''e2e min avg score percent'' AFTER quality_min_node_score_percent',
+    'SELECT 1');
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @col_exists := (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lobster_execution_config' AND COLUMN_NAME = 'quality_multiturn_min_avg_score_percent');
+SET @ddl := IF(@col_exists = 0,
+    'ALTER TABLE lobster_execution_config ADD COLUMN quality_multiturn_min_avg_score_percent int NOT NULL DEFAULT 45 COMMENT ''multiturn min avg score percent'' AFTER quality_min_e2e_avg_score_percent',
+    'SELECT 1');
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+`
+
+function dbNameFromUrl(url) {
+  if (!url) return null
+  const m = String(url).match(/\/([^/?]+)(?:\?|$)/)
+  return m ? m[1] : null
+}
+
+async function columnExists(conn, column) {
+  const [rows] = await conn.query(
+    `SELECT COUNT(*) AS c FROM information_schema.COLUMNS
+     WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lobster_execution_config' AND COLUMN_NAME = ?`,
+    [column]
+  )
+  return rows[0].c > 0
+}
+
+async function applyOnDb(conn, label) {
+  const [tables] = await conn.query("SHOW TABLES LIKE 'lobster_execution_config'")
+  if (!tables.length) {
+    console.log(`${label}: skip (no lobster_execution_config table)`)
+    return
+  }
+  await conn.query(ALTER_SQL)
+  const ok = await columnExists(conn, 'quality_gate_enabled')
+  console.log(`${label}: quality_gate_enabled=${ok ? 'OK' : 'MISSING'}`)
+}
+
+async function main() {
+  const dbs = new Set(process.argv.slice(2))
+  const masterConn = await mysql.createConnection({ ...DB_CFG, database: MASTER_DB })
+  if (!dbs.size) {
+    try {
+      const [tenants] = await masterConn.query(
+        'SELECT db_url FROM tenant_info WHERE db_url IS NOT NULL AND db_url != \'\''
+      )
+      for (const t of tenants) {
+        const name = dbNameFromUrl(t.db_url)
+        if (name) dbs.add(name)
+      }
+    } catch (e) {
+      console.warn('tenant_info skipped:', e.message)
+    }
+  }
+  await masterConn.end()
+
+  for (const db of dbs) {
+    const conn = await mysql.createConnection({ ...DB_CFG, database: db })
+    try {
+      await applyOnDb(conn, db)
+    } catch (e) {
+      console.error(`${db}: ${e.message}`)
+    } finally {
+      await conn.end()
+    }
+  }
+}
+
+main().catch(err => {
+  console.error(err)
+  process.exit(1)
+})

+ 32 - 0
adminui/scripts/apply-qdrant-menu-master.js

@@ -0,0 +1,32 @@
+const mysql = require('mysql2/promise')
+const fs = require('fs')
+const path = require('path')
+
+const DB_CFG = {
+  host: 'cq-cdb-8fjmemkb.sql.tencentcdb.com',
+  port: 27220,
+  user: 'root',
+  password: 'Ylrz_1q2w3e4r5t6y',
+  database: 'ylrz_saas',
+  multipleStatements: true,
+  charset: 'utf8mb4'
+}
+
+async function main() {
+  const sqlPath = path.join(__dirname, '../../java/sql/master/V20260701_01__lobster_qdrant_menu_restore.sql')
+  const sql = fs.readFileSync(sqlPath, 'utf8')
+  const conn = await mysql.createConnection(DB_CFG)
+  await conn.query(sql)
+  const [sysRows] = await conn.query('SELECT menu_id, menu_name, path FROM sys_menu WHERE menu_id = 29951')
+  const [tsmRows] = await conn.query('SELECT menu_id, menu_name, path FROM tenant_sys_menu WHERE menu_id = 29951')
+  const [tcmRows] = await conn.query('SELECT menu_id, menu_name, path FROM tenant_company_menu WHERE menu_id = 29951')
+  console.log('sys_menu:', sysRows)
+  console.log('tenant_sys_menu:', tsmRows)
+  console.log('tenant_company_menu:', tcmRows)
+  await conn.end()
+}
+
+main().catch(err => {
+  console.error(err)
+  process.exit(1)
+})

+ 104 - 0
adminui/scripts/apply-sales-corpus-task-migration.js

@@ -0,0 +1,104 @@
+const mysql = require('mysql2/promise')
+const fs = require('fs')
+const path = require('path')
+
+const DB_CFG = {
+  host: 'cq-cdb-8fjmemkb.sql.tencentcdb.com',
+  port: 27220,
+  user: 'root',
+  password: 'Ylrz_1q2w3e4r5t6y',
+  multipleStatements: true,
+  charset: 'utf8mb4'
+}
+
+const TARGET_DB = process.argv[2] || 'fs_tenant_cs1'
+
+async function columnExists(conn, table, column) {
+  const [rows] = await conn.query(
+    `SELECT COUNT(*) AS cnt FROM information_schema.COLUMNS
+     WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
+    [table, column]
+  )
+  return rows[0].cnt > 0
+}
+
+async function tableExists(conn, table) {
+  const [rows] = await conn.query(
+    `SELECT COUNT(*) AS cnt FROM information_schema.TABLES
+     WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`,
+    [table]
+  )
+  return rows[0].cnt > 0
+}
+
+async function main() {
+  const conn = await mysql.createConnection({ ...DB_CFG, database: TARGET_DB })
+  console.log('Applying sales corpus task migration on', TARGET_DB)
+
+  if (!(await tableExists(conn, 'lobster_sales_corpus_task'))) {
+    await conn.query(`
+      CREATE TABLE lobster_sales_corpus_task (
+        id BIGINT NOT NULL AUTO_INCREMENT,
+        company_id BIGINT NOT NULL,
+        batch_no VARCHAR(64) DEFAULT NULL,
+        task_name VARCHAR(200) DEFAULT NULL,
+        salesperson_name VARCHAR(100) DEFAULT NULL,
+        scenario VARCHAR(100) DEFAULT NULL,
+        total_count INT DEFAULT 0,
+        qualified_count INT DEFAULT 0,
+        optimizable_count INT DEFAULT 0,
+        optimized_count INT DEFAULT 0,
+        kb_written_count INT DEFAULT 0,
+        overall_score DECIMAL(5,2) DEFAULT NULL,
+        ai_summary TEXT,
+        status VARCHAR(32) DEFAULT 'imported',
+        create_by VARCHAR(64) DEFAULT '',
+        create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
+        update_by VARCHAR(64) DEFAULT '',
+        update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+        PRIMARY KEY (id),
+        KEY idx_company_status (company_id, status),
+        KEY idx_batch_no (batch_no)
+      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
+    `)
+    console.log('  created lobster_sales_corpus_task')
+  } else {
+    console.log('  lobster_sales_corpus_task already exists')
+  }
+
+  const alters = [
+    ['customer_question', 'ADD COLUMN customer_question TEXT NULL AFTER dialog_text'],
+    ['sales_answer', 'ADD COLUMN sales_answer TEXT NULL AFTER customer_question'],
+    ['task_id', 'ADD COLUMN task_id BIGINT NULL AFTER company_id'],
+    ['salesperson_name', 'ADD COLUMN salesperson_name VARCHAR(100) NULL AFTER task_id'],
+    ['status', 'ADD COLUMN status VARCHAR(32) NULL AFTER tags'],
+    ['quality_status', 'ADD COLUMN quality_status VARCHAR(32) NULL AFTER status'],
+    ['optimized_answer', 'ADD COLUMN optimized_answer TEXT NULL AFTER quality_status'],
+    ['ai_feedback', 'ADD COLUMN ai_feedback TEXT NULL AFTER optimized_answer'],
+    ['kb_written', 'ADD COLUMN kb_written TINYINT DEFAULT 0 AFTER ai_feedback']
+  ]
+
+  for (const [col, sql] of alters) {
+    if (!(await columnExists(conn, 'lobster_sales_corpus', col))) {
+      await conn.query(`ALTER TABLE lobster_sales_corpus ${sql}`)
+      console.log('  added column', col)
+    }
+  }
+
+  const [idxRows] = await conn.query(
+    `SELECT COUNT(*) AS cnt FROM information_schema.STATISTICS
+     WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lobster_sales_corpus' AND INDEX_NAME = 'idx_task_id'`
+  )
+  if (idxRows[0].cnt === 0) {
+    await conn.query('ALTER TABLE lobster_sales_corpus ADD INDEX idx_task_id (task_id)')
+    console.log('  added idx_task_id')
+  }
+
+  await conn.end()
+  console.log('Done.')
+}
+
+main().catch(err => {
+  console.error(err)
+  process.exit(1)
+})

+ 71 - 0
adminui/scripts/aws-sdk-webpack-aliases.js

@@ -0,0 +1,71 @@
+'use strict'
+
+const fs = require('fs')
+const path = require('path')
+
+/**
+ * Webpack 4 无法识别 package.json 的 exports 字段,@aws-sdk v3 大量使用 exports,
+ * 需将 @aws-sdk/<pkg>/<subpath> 映射到实际 dist 文件,避免 browser 端解析失败
+ */
+function pickExportTarget(exportConfig) {
+  if (!exportConfig || typeof exportConfig !== 'object') {
+    return null
+  }
+  const browser = exportConfig.browser
+  if (browser) {
+    if (typeof browser === 'string') {
+      return browser
+    }
+    return browser.import || browser.require || browser.module
+  }
+  return exportConfig.module || exportConfig.import || exportConfig.require || exportConfig.node
+}
+
+function buildAwsSdkAliases(projectRoot) {
+  const aliases = {}
+  const awsSdkDir = path.join(projectRoot, 'node_modules', '@aws-sdk')
+  if (!fs.existsSync(awsSdkDir)) {
+    return aliases
+  }
+
+  for (const pkgName of fs.readdirSync(awsSdkDir)) {
+    const pkgRoot = path.join(awsSdkDir, pkgName)
+    const pkgJsonPath = path.join(pkgRoot, 'package.json')
+    if (!fs.existsSync(pkgJsonPath)) {
+      continue
+    }
+
+    let pkgJson
+    try {
+      pkgJson = require(pkgJsonPath)
+    } catch (e) {
+      continue
+    }
+
+    const exportsField = pkgJson.exports
+    if (!exportsField || typeof exportsField !== 'object') {
+      continue
+    }
+
+    for (const [subpath, exportConfig] of Object.entries(exportsField)) {
+      if (subpath === '.' || subpath === './package.json') {
+        continue
+      }
+      if (!subpath.startsWith('./')) {
+        continue
+      }
+
+      const target = pickExportTarget(exportConfig)
+      if (!target || typeof target !== 'string') {
+        continue
+      }
+
+      const aliasKey = `@aws-sdk/${pkgName}/${subpath.slice(2)}`
+      aliases[aliasKey] = path.resolve(pkgRoot, target)
+    }
+  }
+
+  return aliases
+}
+
+module.exports = { buildAwsSdkAliases }

+ 23 - 0
adminui/scripts/bind-lobster-sys-role.js

@@ -0,0 +1,23 @@
+const mysql = require('mysql2/promise')
+const DB_CFG = {
+  host: 'cq-cdb-8fjmemkb.sql.tencentcdb.com',
+  port: 27220,
+  user: 'root',
+  password: 'Ylrz_1q2w3e4r5t6y',
+  charset: 'utf8mb4'
+}
+const db = process.argv[2] || 'fs_tenant_cs1'
+
+;(async () => {
+  const conn = await mysql.createConnection({ ...DB_CFG, database: db })
+  await conn.query(`
+    INSERT IGNORE INTO sys_role_menu (role_id, menu_id)
+    SELECT 1, menu_id FROM sys_menu WHERE parent_id = 29900 OR menu_id = 29900
+  `)
+  const [n] = await conn.query(`
+    SELECT COUNT(*) c FROM sys_role_menu
+    WHERE menu_id IN (SELECT menu_id FROM sys_menu WHERE parent_id = 29900 OR menu_id = 29900)
+  `)
+  console.log(db, 'sys_role_menu lobster bindings:', n[0].c)
+  await conn.end()
+})().catch(e => { console.error(e); process.exit(1) })

+ 15 - 0
adminui/scripts/check-api-registry-schema.js

@@ -0,0 +1,15 @@
+const mysql = require('mysql2/promise')
+const DB = { host: 'cq-cdb-8fjmemkb.sql.tencentcdb.com', port: 27220, user: 'root', password: 'Ylrz_1q2w3e4r5t6y', charset: 'utf8mb4' }
+async function main() {
+  const db = process.argv[2] || 'fs_tenant_cs1'
+  const conn = await mysql.createConnection({ ...DB, database: db })
+  const [tables] = await conn.query("SHOW TABLES LIKE 'lobster_api_registry'")
+  console.log('db', db, 'hasTable', tables.length > 0)
+  if (!tables.length) { await conn.end(); return }
+  const [cols] = await conn.query('SHOW COLUMNS FROM lobster_api_registry')
+  console.log('columns', cols.map(c => c.Field).join(', '))
+  const [rows] = await conn.query('SELECT api_key, api_name, category, enabled FROM lobster_api_registry LIMIT 20')
+  console.log('rows', rows.length, JSON.stringify(rows, null, 2))
+  await conn.end()
+}
+main().catch(e => { console.error(e); process.exit(1) })

+ 19 - 0
adminui/scripts/check-lobster-schema.js

@@ -0,0 +1,19 @@
+const mysql = require('mysql2/promise')
+const DB = process.argv[2] || 'fs_tenant_cs1'
+const cfg = {
+  host: 'cq-cdb-8fjmemkb.sql.tencentcdb.com', port: 27220,
+  user: 'root', password: 'Ylrz_1q2w3e4r5t6y', database: DB, charset: 'utf8mb4'
+}
+async function main() {
+  const c = await mysql.createConnection(cfg)
+  const [cols] = await c.query(
+    "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lobster_sales_corpus' ORDER BY ORDINAL_POSITION"
+  )
+  const [instCols] = await c.query(
+    "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lobster_workflow_instance' AND COLUMN_NAME IN ('dept_id','binding_id','channel_type','company_user_id')"
+  )
+  console.log('lobster_sales_corpus columns:', cols.map(r => r.COLUMN_NAME).join(', '))
+  console.log('lobster_workflow_instance scope cols:', instCols.map(r => r.COLUMN_NAME).join(', ') || '(none)')
+  await c.end()
+}
+main().catch(e => { console.error(e); process.exit(1) })

+ 37 - 0
adminui/scripts/check-tenant-db.js

@@ -0,0 +1,37 @@
+const mysql = require('mysql2/promise')
+
+const DB_CFG = {
+  host: 'cq-cdb-8fjmemkb.sql.tencentcdb.com',
+  port: 27220,
+  user: 'root',
+  password: 'Ylrz_1q2w3e4r5t6y',
+  database: 'ylrz_saas'
+}
+
+function dbNameFromUrl(url) {
+  if (!url) return null
+  const m = String(url).match(/\/([^/?]+)(?:\?|$)/)
+  return m ? m[1] : null
+}
+
+async function main() {
+  const conn = await mysql.createConnection(DB_CFG)
+  const tenantId = process.argv[2] || '32'
+  const [rows] = await conn.query(
+    'SELECT id, tenant_code, db_url FROM tenant_info WHERE id = ?',
+    [tenantId]
+  )
+  console.log('tenant:', rows[0])
+  const [tables] = await conn.query("SHOW TABLES LIKE 'lobster_channel%'")
+  console.log('ylrz_saas lobster tables:', tables)
+  const dbName = dbNameFromUrl(rows[0] && rows[0].db_url)
+  if (dbName) {
+    const tconn = await mysql.createConnection({ ...DB_CFG, database: dbName })
+    const [tt] = await tconn.query("SHOW TABLES LIKE 'lobster_channel%'")
+    console.log(dbName + ' lobster tables:', tt)
+    await tconn.end()
+  }
+  await conn.end()
+}
+
+main().catch(e => { console.error(e); process.exit(1) })

+ 98 - 0
adminui/scripts/drop-lobster-dedup-config-menu.js

@@ -0,0 +1,98 @@
+const mysql = require('mysql2/promise')
+
+const DB_CFG = {
+  host: process.env.DB_HOST || 'cq-cdb-8fjmemkb.sql.tencentcdb.com',
+  port: Number(process.env.DB_PORT || 27220),
+  user: process.env.DB_USER || 'root',
+  password: process.env.DB_PASSWORD || 'Ylrz_1q2w3e4r5t6y',
+  multipleStatements: true
+}
+
+const MASTER_DB = process.env.MASTER_DB || 'ylrz_saas'
+
+const CORE_SQL = `
+DELETE FROM sys_role_menu WHERE menu_id IN (3000, 29924, 2621);
+DELETE FROM sys_menu WHERE menu_id IN (3000, 29924) OR component = 'lobster/dedup-config/index' OR path IN ('lobster-dedup-config', 'dedup-config');
+INSERT IGNORE INTO sys_menu
+(menu_id, menu_name, parent_id, order_num, path, component, menu_type, icon, visible, status, is_frame, is_cache, perms, create_by, create_time, remark)
+VALUES
+(2621, '\u804a\u5929\u6d88\u606f\u53bb\u91cd', 2600, 15, 'messageDedup', 'admin/messageDedup/index', 'C', 'el-icon-filter', '0', '0', 0, 0, NULL, 'admin', NOW(), 'Lobster outbound message dedup policy');
+UPDATE sys_menu SET perms = NULL WHERE menu_id = 2621;
+INSERT INTO sys_role_menu (role_id, menu_id)
+SELECT DISTINCT rm.role_id, 2621
+FROM sys_role_menu rm
+INNER JOIN sys_menu m ON m.menu_id = rm.menu_id
+WHERE m.menu_id >= 2000 AND m.menu_id < 3000
+  AND NOT EXISTS (SELECT 1 FROM sys_role_menu x WHERE x.role_id = rm.role_id AND x.menu_id = 2621);
+`
+
+const TENANT_TEMPLATE_CLEANUP = [
+  ['tenant_sys_role_menu', 'menu_id IN (3000, 29924)'],
+  ['tenant_company_role_menu', 'menu_id IN (3000, 29924)'],
+  ['tenant_sys_menu', "menu_id IN (3000, 29924) OR component = 'lobster/dedup-config/index'"],
+  ['tenant_company_menu', "menu_id IN (3000, 29924) OR component = 'lobster/dedup-config/index' OR path = 'dedup-config'"]
+]
+
+const TENANT_SQL = `
+DELETE FROM sys_role_menu WHERE menu_id = 29924;
+DELETE FROM company_role_menu WHERE menu_id = 29924;
+DELETE FROM company_menu WHERE menu_id = 29924 OR path = 'dedup-config' OR component = 'lobster/dedup-config/index';
+DELETE FROM sys_menu WHERE menu_id = 29924 OR path = 'dedup-config' OR component = 'lobster/dedup-config/index';
+`
+
+function dbNameFromUrl(url) {
+  if (!url) return null
+  const m = String(url).match(/\/([^/?]+)(?:\?|$)/)
+  return m ? m[1] : null
+}
+
+async function tableExists(conn, table) {
+  const [rows] = await conn.query('SHOW TABLES LIKE ?', [table])
+  return rows.length > 0
+}
+
+async function main() {
+  const masterConn = await mysql.createConnection({ ...DB_CFG, database: MASTER_DB })
+  await masterConn.query(CORE_SQL)
+  for (const [table, where] of TENANT_TEMPLATE_CLEANUP) {
+    if (await tableExists(masterConn, table)) {
+      await masterConn.query(`DELETE FROM ${table} WHERE ${where}`)
+    }
+  }
+  const [menuRows] = await masterConn.query(
+    'SELECT menu_id, menu_name, parent_id, path, component FROM sys_menu WHERE menu_id = 2621'
+  )
+  console.log('master menu 2621:', menuRows[0] || 'NOT FOUND')
+
+  const dbs = new Set()
+  try {
+    const [tenants] = await masterConn.query(
+      'SELECT db_url FROM tenant_info WHERE db_url IS NOT NULL AND db_url != \'\''
+    )
+    for (const t of tenants) {
+      const name = dbNameFromUrl(t.db_url)
+      if (name) dbs.add(name)
+    }
+  } catch (e) {
+    console.warn('tenant_info skipped:', e.message)
+  }
+  await masterConn.end()
+
+  for (const db of dbs) {
+    const conn = await mysql.createConnection({ ...DB_CFG, database: db })
+    try {
+      if (!(await tableExists(conn, 'sys_menu'))) continue
+      await conn.query(TENANT_SQL)
+      console.log(`${db}: tenant dedup menu removed`)
+    } catch (e) {
+      console.error(`${db}: ${e.message}`)
+    } finally {
+      await conn.end()
+    }
+  }
+}
+
+main().catch(err => {
+  console.error(err)
+  process.exit(1)
+})

+ 111 - 0
adminui/scripts/drop-lobster-execution-config-menu.js

@@ -0,0 +1,111 @@
+const mysql = require('mysql2/promise')
+
+const DB_CFG = {
+  host: process.env.DB_HOST || 'cq-cdb-8fjmemkb.sql.tencentcdb.com',
+  port: Number(process.env.DB_PORT || 27220),
+  user: process.env.DB_USER || 'root',
+  password: process.env.DB_PASSWORD || 'Ylrz_1q2w3e4r5t6y',
+  multipleStatements: true
+}
+
+const MASTER_DB = process.env.MASTER_DB || 'ylrz_saas'
+
+function dbNameFromUrl(url) {
+  if (!url) return null
+  const m = String(url).match(/\/([^/?]+)(?:\?|$)/)
+  return m ? m[1] : null
+}
+
+async function runDrop(conn, label) {
+  const tables = ['sys_role_menu', 'company_role_menu', 'company_menu', 'sys_menu']
+  for (const table of tables) {
+    const [exists] = await conn.query('SHOW TABLES LIKE ?', [table])
+    if (!exists.length) continue
+    if (table.includes('role_menu')) {
+      await conn.query(`DELETE FROM ${table} WHERE menu_id = 29947`)
+    } else {
+      await conn.query(
+        `DELETE FROM ${table} WHERE menu_id = 29947 OR path = 'execution-config' OR component = 'lobster/execution-config/index'`
+      )
+    }
+  }
+  const count = async (table, where) => {
+    const [exists] = await conn.query('SHOW TABLES LIKE ?', [table])
+    if (!exists.length) return 'NA'
+    const [rows] = await conn.query(`SELECT COUNT(*) AS c FROM ${table} WHERE ${where}`)
+    return rows[0].c
+  }
+  console.log(
+    `${label}: sys_menu=${await count('sys_menu', "menu_id = 29947 OR path = 'execution-config'")}, ` +
+    `company_menu=${await count('company_menu', "menu_id = 29947 OR path = 'execution-config'")}`
+  )
+}
+
+async function runMasterTemplateDrop(conn) {
+  const tables = ['tenant_sys_role_menu', 'tenant_company_role_menu', 'tenant_company_menu', 'tenant_sys_menu']
+  for (const table of tables) {
+    const [exists] = await conn.query('SHOW TABLES LIKE ?', [table])
+    if (!exists.length) continue
+    if (table.includes('role_menu')) {
+      await conn.query(`DELETE FROM ${table} WHERE menu_id = 29947`)
+    } else {
+      await conn.query(
+        `DELETE FROM ${table} WHERE menu_id = 29947 OR path = 'execution-config' OR component = 'lobster/execution-config/index'`
+      )
+    }
+  }
+  const count = async (table, where) => {
+    const [exists] = await conn.query('SHOW TABLES LIKE ?', [table])
+    if (!exists.length) return 'NA'
+    const [rows] = await conn.query(`SELECT COUNT(*) AS c FROM ${table} WHERE ${where}`)
+    return rows[0].c
+  }
+  console.log(
+    `master templates: tenant_sys_menu=${await count('tenant_sys_menu', "menu_id = 29947 OR path = 'execution-config'")}, ` +
+    `tenant_company_menu=${await count('tenant_company_menu', "menu_id = 29947 OR path = 'execution-config'")}`
+  )
+}
+
+async function main() {
+  const masterConn = await mysql.createConnection({ ...DB_CFG, database: MASTER_DB })
+  const dbs = new Set([MASTER_DB])
+  try {
+    await runMasterTemplateDrop(masterConn)
+  } catch (e) {
+    console.warn(`master template drop skipped: ${e.message}`)
+  }
+  try {
+    const [tenants] = await masterConn.query(
+      'SELECT id, tenant_code, db_url FROM tenant_info WHERE db_url IS NOT NULL AND db_url != \'\''
+    )
+    for (const t of tenants) {
+      const name = dbNameFromUrl(t.db_url)
+      if (name) dbs.add(name)
+    }
+  } catch (e) {
+    console.warn('tenant_info query skipped:', e.message)
+  }
+  await masterConn.end()
+
+  for (const db of dbs) {
+    const conn = await mysql.createConnection({ ...DB_CFG, database: db })
+    try {
+      const [hasSys] = await conn.query("SHOW TABLES LIKE 'sys_menu'")
+      const [hasCom] = await conn.query("SHOW TABLES LIKE 'company_menu'")
+      if (!hasSys.length && !hasCom.length) {
+        console.log(`${db}: skip (no tenant menu tables)`)
+        continue
+      }
+      await runDrop(conn, db)
+    } catch (e) {
+      console.error(`${db}: ERROR ${e.message}`)
+    } finally {
+      await conn.end()
+    }
+  }
+}
+
+main().catch(err => {
+  console.error(err)
+  process.exit(1)
+})

+ 86 - 0
adminui/scripts/drop-lobster-model-route-menu.js

@@ -0,0 +1,86 @@
+/**
+ * 删除租户库「多模型路由配置」菜单(sys_menu + company_menu)
+ * Usage: node scripts/drop-lobster-model-route-menu.js [dbName|all]
+ */
+const mysql = require('mysql2/promise')
+
+const DB_CFG = {
+  host: 'cq-cdb-8fjmemkb.sql.tencentcdb.com',
+  port: 27220,
+  user: 'root',
+  password: 'Ylrz_1q2w3e4r5t6y',
+  multipleStatements: true,
+  charset: 'utf8mb4'
+}
+
+const MENU_IDS = [29946, 3005]
+
+async function dropMenus(conn) {
+  const [sysBefore] = await conn.query(
+    `SELECT menu_id, menu_name, path, component FROM sys_menu
+     WHERE menu_id IN (?) OR component = 'lobster/model-route/index'
+        OR path IN ('model-route', 'lobster-model-route')`,
+    [MENU_IDS]
+  )
+  const [cmBefore] = await conn.query(
+    `SELECT menu_id, menu_name, path, component FROM company_menu
+     WHERE menu_id IN (?) OR component = 'lobster/model-route/index'
+        OR path IN ('model-route', 'lobster-model-route')`,
+    [MENU_IDS]
+  )
+  console.log('sys_menu before:', sysBefore.length, sysBefore.map(r => `${r.menu_id}:${r.menu_name}`).join(', ') || '-')
+  console.log('company_menu before:', cmBefore.length, cmBefore.map(r => `${r.menu_id}:${r.menu_name}`).join(', ') || '-')
+
+  await conn.query('DELETE FROM sys_role_menu WHERE menu_id IN (?)', [MENU_IDS])
+  await conn.query('DELETE FROM company_role_menu WHERE menu_id IN (?)', [MENU_IDS])
+  await conn.query(
+    `DELETE FROM company_menu
+     WHERE menu_id IN (?)
+        OR path IN ('model-route', 'lobster-model-route')
+        OR component = 'lobster/model-route/index'`,
+    [MENU_IDS]
+  )
+  await conn.query(
+    `DELETE FROM sys_menu
+     WHERE menu_id IN (?)
+        OR path IN ('model-route', 'lobster-model-route')
+        OR component = 'lobster/model-route/index'`,
+    [MENU_IDS]
+  )
+
+  const [sysAfter] = await conn.query(
+    `SELECT COUNT(*) AS c FROM sys_menu WHERE component = 'lobster/model-route/index'`
+  )
+  const [cmAfter] = await conn.query(
+    `SELECT COUNT(*) AS c FROM company_menu WHERE component = 'lobster/model-route/index'`
+  )
+  console.log('sys_menu model-route left:', sysAfter[0].c)
+  console.log('company_menu model-route left:', cmAfter[0].c)
+}
+
+async function listTenantDbs(conn) {
+  const [rows] = await conn.query(
+    "SELECT SCHEMA_NAME AS dbName FROM information_schema.SCHEMATA WHERE SCHEMA_NAME LIKE 'fs\\_tenant\\_%' ESCAPE '\\\\' ORDER BY SCHEMA_NAME"
+  )
+  return rows.map(r => r.dbName)
+}
+
+async function main() {
+  const target = process.argv[2] || 'fs_tenant_cs1'
+  const root = await mysql.createConnection({ ...DB_CFG, database: 'mysql' })
+  const dbs = target === 'all' ? await listTenantDbs(root) : [target]
+  await root.end()
+
+  for (const db of dbs) {
+    console.log('\n===', db, '===')
+    const conn = await mysql.createConnection({ ...DB_CFG, database: db })
+    try {
+      await dropMenus(conn)
+    } catch (e) {
+      console.error('failed:', db, e.message)
+    }
+    await conn.end()
+  }
+}
+
+main().catch(e => { console.error(e); process.exit(1) })

+ 139 - 0
adminui/scripts/drop-lobster-summary-config.js

@@ -0,0 +1,139 @@
+/**
+ * Drop unused lobster summary config menus + company_lobster_summary_config table.
+ * Usage:
+ *   node scripts/drop-lobster-summary-config.js              # all tenants + master
+ *   node scripts/drop-lobster-summary-config.js fs_tenant_cs1  # single tenant only
+ */
+const fs = require('fs')
+const path = require('path')
+const mysql = require('mysql2/promise')
+
+const DB_CFG = {
+  host: process.env.DB_HOST || 'cq-cdb-8fjmemkb.sql.tencentcdb.com',
+  port: Number(process.env.DB_PORT || 27220),
+  user: process.env.DB_USER || 'root',
+  password: process.env.DB_PASSWORD || 'Ylrz_1q2w3e4r5t6y',
+  multipleStatements: true,
+  charset: 'utf8mb4'
+}
+
+const MASTER_DB = process.env.DB_NAME || 'ylrz_saas'
+const ROOT = path.join(__dirname, '../../java')
+const MASTER_SQL = fs.readFileSync(
+  path.join(ROOT, 'sql/master/V20260626_01__drop_lobster_summary_config.sql'),
+  'utf8'
+)
+const TENANT_SQL = fs.readFileSync(
+  path.join(ROOT, 'fs-agent/src/main/resources/db/migration/tenant/V20260626_01__drop_lobster_summary_config.sql'),
+  'utf8'
+)
+
+function dbNameFromUrl(url) {
+  if (!url) return null
+  const m = String(url).match(/\/([^/?]+)(?:\?|$)/)
+  return m ? m[1] : null
+}
+
+async function runSql(database, sql, label) {
+  const conn = await mysql.createConnection({ ...DB_CFG, database })
+  try {
+    const statements = sql
+      .split(';')
+      .map(s => s.replace(/--[^\n]*/g, '').trim())
+      .filter(Boolean)
+    let affected = 0
+    for (const stmt of statements) {
+      if (!(await canRun(conn, stmt))) continue
+      const [r] = await conn.query(stmt)
+      affected += r && r.affectedRows ? r.affectedRows : 0
+    }
+    const [menus] = await conn.query(
+      "SELECT COUNT(*) AS c FROM information_schema.tables WHERE table_schema = ? AND table_name IN ('sys_menu','company_menu','tenant_sys_menu','tenant_company_menu')",
+      [database]
+    )
+    let summaryMenu = 0
+    if (menus[0].c > 0) {
+      const checks = []
+      if (await tableExists(conn, 'sys_menu')) {
+        checks.push("SELECT COUNT(*) AS c FROM sys_menu WHERE menu_id IN (29923,2999) OR component='lobster/summary-config/index'")
+      }
+      if (await tableExists(conn, 'company_menu')) {
+        checks.push("SELECT COUNT(*) AS c FROM company_menu WHERE menu_id IN (29923,2999) OR component='lobster/summary-config/index'")
+      }
+      if (await tableExists(conn, 'tenant_sys_menu')) {
+        checks.push("SELECT COUNT(*) AS c FROM tenant_sys_menu WHERE menu_id IN (29923,2999) OR component='lobster/summary-config/index'")
+      }
+      if (await tableExists(conn, 'tenant_company_menu')) {
+        checks.push("SELECT COUNT(*) AS c FROM tenant_company_menu WHERE menu_id IN (29923,2999) OR component='lobster/summary-config/index'")
+      }
+      for (const q of checks) {
+        const [r] = await conn.query(q)
+        summaryMenu += Number(r[0].c || 0)
+      }
+    }
+    const [tbl] = await conn.query(
+      "SELECT COUNT(*) AS c FROM information_schema.tables WHERE table_schema = ? AND table_name = 'company_lobster_summary_config'",
+      [database]
+    )
+    console.log(`[${label}] ${database}: affected=${affected}, summary_menu_left=${summaryMenu}, summary_table=${Number(tbl[0].c)}`)
+  } finally {
+    await conn.end()
+  }
+}
+
+async function tableExists(conn, table) {
+  const [r] = await conn.query('SHOW TABLES LIKE ?', [table])
+  return r.length > 0
+}
+
+async function canRun(conn, stmt) {
+  const m = stmt.match(/^DELETE\s+FROM\s+([a-zA-Z0-9_]+)/i)
+  if (!m) return true
+  return tableExists(conn, m[1])
+}
+
+async function listTenantDbs() {
+  const conn = await mysql.createConnection({ ...DB_CFG, database: MASTER_DB })
+  try {
+    const [rows] = await conn.query(
+      "SELECT id, tenant_code, db_url FROM tenant_info WHERE db_url IS NOT NULL AND db_url <> ''"
+    )
+    const dbs = new Map()
+    for (const t of rows) {
+      const name = dbNameFromUrl(t.db_url)
+      if (name) dbs.set(name, `${t.id}:${t.tenant_code}`)
+    }
+    return dbs
+  } finally {
+    await conn.end()
+  }
+}
+
+async function main() {
+  const onlyTenant = process.argv[2]
+
+  if (!onlyTenant) {
+    console.log('=== master ===')
+    await runSql(MASTER_DB, MASTER_SQL, 'master')
+  }
+
+  const tenantDbs = await listTenantDbs()
+  const targets = onlyTenant
+    ? [[onlyTenant, onlyTenant]]
+    : [...tenantDbs.entries()]
+
+  console.log('=== tenants === count', targets.length)
+  for (const [db, label] of targets) {
+    try {
+      await runSql(db, TENANT_SQL, label)
+    } catch (e) {
+      console.error(`[FAIL] ${db}: ${e.message}`)
+    }
+  }
+  console.log('Done.')
+}
+
+main().catch(e => {
+  console.error(e)
+  process.exit(1)
+})

+ 84 - 0
adminui/scripts/ensure-lobster-channel-table.js

@@ -0,0 +1,84 @@
+const mysql = require('mysql2/promise')
+
+const PLUGIN_TABLE_SQL = `
+CREATE TABLE IF NOT EXISTS lobster_channel_plugin_config (
+  id            BIGINT AUTO_INCREMENT PRIMARY KEY,
+  company_id    BIGINT       NOT NULL COMMENT 'tenant-level config uses 0',
+  channel_type  VARCHAR(30)  NOT NULL,
+  enabled       TINYINT      DEFAULT 0,
+  config_json   TEXT,
+  create_time   DATETIME     DEFAULT CURRENT_TIMESTAMP,
+  update_time   DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  UNIQUE KEY uk_company_channel (company_id, channel_type)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Lobster channel plugin config'
+`
+
+const GRANT_TABLE_SQL = `
+CREATE TABLE IF NOT EXISTS lobster_tenant_channel_grant (
+  id            BIGINT AUTO_INCREMENT PRIMARY KEY,
+  tenant_id     BIGINT       NOT NULL,
+  channel_type  VARCHAR(30)  NOT NULL,
+  granted       TINYINT      DEFAULT 1,
+  create_by     VARCHAR(64)  DEFAULT '',
+  create_time   DATETIME     DEFAULT CURRENT_TIMESTAMP,
+  update_by     VARCHAR(64)  DEFAULT '',
+  update_time   DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  UNIQUE KEY uk_tenant_channel (tenant_id, channel_type),
+  KEY idx_tenant (tenant_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Lobster channel grants by platform admin'
+`
+
+const DB_CFG = {
+  host: 'cq-cdb-8fjmemkb.sql.tencentcdb.com',
+  port: 27220,
+  user: 'root',
+  password: 'Ylrz_1q2w3e4r5t6y',
+  multipleStatements: true
+}
+
+function dbNameFromUrl(url) {
+  if (!url) return null
+  const m = String(url).match(/\/([^/?]+)(?:\?|$)/)
+  return m ? m[1] : null
+}
+
+async function ensureTables(database) {
+  const conn = await mysql.createConnection({ ...DB_CFG, database })
+  await conn.query(PLUGIN_TABLE_SQL)
+  await conn.query(GRANT_TABLE_SQL)
+  const [p] = await conn.query("SHOW TABLES LIKE 'lobster_channel_plugin_config'")
+  const [g] = await conn.query("SHOW TABLES LIKE 'lobster_tenant_channel_grant'")
+  await conn.end()
+  return { database, plugin: p.length > 0, grant: g.length > 0 }
+}
+
+async function main() {
+  const masterConn = await mysql.createConnection({ ...DB_CFG, database: 'ylrz_saas' })
+  const dbs = new Set(['ylrz_saas'])
+  try {
+    const [tenants] = await masterConn.query(
+      'SELECT id, tenant_code, db_url FROM tenant_info WHERE db_url IS NOT NULL AND db_url != \'\''
+    )
+    for (const t of tenants) {
+      const name = dbNameFromUrl(t.db_url)
+      if (name) dbs.add(name)
+    }
+  } catch (e) {
+    console.warn('tenant_info query skipped:', e.message)
+  }
+  await masterConn.end()
+
+  for (const db of dbs) {
+    try {
+      const r = await ensureTables(db)
+      console.log(`${db}: plugin=${r.plugin ? 'OK' : 'MISSING'} grant=${r.grant ? 'OK' : 'MISSING'}`)
+    } catch (e) {
+      console.error(`${db}: ERROR ${e.message}`)
+    }
+  }
+}
+
+main().catch(e => {
+  console.error(e)
+  process.exit(1)
+})

+ 39 - 0
adminui/scripts/ensure-message-dedup-menu.js

@@ -0,0 +1,39 @@
+const mysql = require('mysql2/promise')
+const fs = require('fs')
+const path = require('path')
+
+const DB_CFG = {
+  host: 'cq-cdb-8fjmemkb.sql.tencentcdb.com',
+  port: 27220,
+  user: 'root',
+  password: 'Ylrz_1q2w3e4r5t6y',
+  database: 'ylrz_saas',
+  charset: 'utf8mb4',
+  multipleStatements: true
+}
+
+const SQL = fs.readFileSync(
+  path.join(__dirname, '../../java/sql/master/V20260623_02__lobster_message_dedup_system_menu.sql'),
+  'utf8'
+)
+
+async function main() {
+  const conn = await mysql.createConnection(DB_CFG)
+  await conn.query('SET NAMES utf8mb4')
+  const [rows] = await conn.query(SQL)
+  await conn.query('UPDATE sys_menu SET menu_name = ? WHERE menu_id = 2621', ['锟斤拷锟斤拷锟斤拷息去锟斤拷'])
+  const last = Array.isArray(rows) ? rows[rows.length - 1] : rows
+  if (Array.isArray(last) && last.length) {
+    console.log('menu:', last[0])
+  }
+  const [grants] = await conn.query(
+    'SELECT COUNT(*) AS cnt FROM sys_role_menu WHERE menu_id = 2621'
+  )
+  console.log('role_grants:', grants[0].cnt)
+  await conn.end()
+}
+
+main().catch(e => {
+  console.error(e.message)
+  process.exit(1)
+})

+ 29 - 0
adminui/scripts/lobster-menu-definitions.js

@@ -0,0 +1,29 @@
+/** Shared lobster menu definitions for tenant DB sync and master tenant_sys_menu template sync */
+module.exports.LOBSTER_MENUS = [
+  { id: 29900, name: '数字员工', parent: 0, order: 100, path: '/lobster', component: '', type: 'M', icon: 'el-icon-cpu', visible: '0', perms: null },
+  { id: 32481, name: '工作流模板库', parent: 29900, order: 1, path: 'workflowLobster', component: 'company/workflowLobster/index', type: 'C', icon: 'el-icon-share', visible: '0', perms: 'workflow:lobster:edit' },
+  { id: 29949, name: '工作流执行任务', parent: 29900, order: 3, path: 'workflow-execution', component: 'lobster/workflow-execution/index', type: 'C', icon: 'el-icon-guide', visible: '0', perms: 'workflow:lobster:edit' },
+  { id: 29905, name: '实例监控', parent: 29900, order: 5, path: 'instance', component: 'lobster/instance/index', type: 'C', icon: 'el-icon-monitor', visible: '0', perms: 'workflow:lobster:query' },
+  { id: 29907, name: '提示词管理', parent: 29900, order: 6, path: 'prompt', component: 'lobster/prompt/index', type: 'C', icon: 'el-icon-edit', visible: '0', perms: 'workflow:lobster:prompt' },
+  { id: 29925, name: '敏感词库', parent: 29900, order: 8, path: 'sensitive-words', component: 'lobster/sensitive-words/index', type: 'C', icon: 'el-icon-warning', visible: '0', perms: 'workflow:lobster:replace' },
+  { id: 29963, name: '违禁词库', parent: 29900, order: 9, path: 'forbidden-words', component: 'lobster/replace-words/index', type: 'C', icon: 'el-icon-warning', visible: '0', perms: 'workflow:lobster:replace' },
+  { id: 29964, name: '易错词库', parent: 29900, order: 10, path: 'typo-words', component: 'lobster/replace-words/index', type: 'C', icon: 'el-icon-edit', visible: '0', perms: 'workflow:lobster:replace' },
+  { id: 29948, name: '会话摘要', parent: 29900, order: 11, path: 'conversation-summary', component: 'lobster/conversation-summary/index', type: 'C', icon: 'el-icon-document-copy', visible: '1', perms: 'workflow:lobster:summary' },
+  { id: 29922, name: '用户画像配置', parent: 29900, order: 13, path: 'profile-config', component: 'lobster/profile-config/index', type: 'C', icon: 'el-icon-user', visible: '1', perms: 'workflow:lobster:profile' },
+  { id: 29950, name: '客户画像', parent: 29900, order: 14, path: 'profile', component: 'lobster/profile/hub', type: 'C', icon: 'el-icon-user', visible: '0', perms: 'workflow:lobster:profile' },
+  { id: 29944, name: '渠道配置', parent: 29900, order: 15, path: 'channel-config', component: 'lobster/channel-config/index', type: 'C', icon: 'el-icon-connection', visible: '0', perms: 'workflow:lobster:channel' },
+  { id: 29936, name: '渠道插件', parent: 29900, order: 16, path: 'channel-plugin', component: 'lobster/channel-plugin/index', type: 'C', icon: 'el-icon-connection', visible: '1', perms: 'workflow:lobster:channel' },
+  { id: 29934, name: '角色设定', parent: 29900, order: 18, path: 'identity-config', component: 'lobster/identity-config/index', type: 'C', icon: 'el-icon-view', visible: '0', perms: 'workflow:lobster:identity' },
+  { id: 29933, name: '语义关键词', parent: 29900, order: 19, path: 'semantic-keywords', component: 'lobster/semantic-keywords/index', type: 'C', icon: 'el-icon-key', visible: '0', perms: 'workflow:lobster:semantic' },
+  { id: 29909, name: '接口注册中心', parent: 29900, order: 22, path: 'api-registry', component: 'lobster/api-registry/index', type: 'C', icon: 'el-icon-nested', visible: '0', perms: 'workflow:lobster:api' },
+  { id: 29908, name: '销冠语料学习', parent: 29900, order: 23, path: 'sales-corpus', component: 'lobster/sales-corpus/index', type: 'C', icon: 'el-icon-star', visible: '0', perms: 'workflow:lobster:corpus' },
+  { id: 29912, name: '聚合聊天', parent: 29900, order: 24, path: 'chat-aggregate', component: 'lobster/chat-aggregate/index', type: 'C', icon: 'el-icon-chat-dot-round', visible: '0', perms: 'workflow:lobster:chat' },
+  { id: 29906, name: '话术优化审核', parent: 29900, order: 26, path: 'optimization', component: 'lobster/optimization/index', type: 'C', icon: 'el-icon-eye-open', visible: '0', perms: 'workflow:lobster:optimize' },
+  { id: 3008, name: '动态节点审批', parent: 29900, order: 27, path: 'dynamic-impl', component: 'lobster/dynamic-impl/index', type: 'C', icon: 'el-icon-connection', visible: '0', perms: 'workflow:lobster:audit' },
+  { id: 29951, name: '向量知识库', parent: 29900, order: 33, path: 'qdrant', component: 'lobster/qdrant/index', type: 'C', icon: 'el-icon-collection', visible: '0', perms: 'workflow:lobster:query' },
+  { id: 29920, name: '模拟聊天测试', parent: 29900, order: 32, path: 'chat-test', component: 'lobster/chat-test/index', type: 'C', icon: 'el-icon-chat-dot-square', visible: '0', perms: 'workflow:lobster:test' },
+  { id: 29926, name: '节点详情', parent: 29900, order: 99, path: 'node-detail', component: 'lobster/node-detail/index', type: 'C', icon: 'el-icon-document', visible: '1', perms: 'workflow:lobster:query' }
+]
+
+/** Sales-side template excludes admin-only menus */
+module.exports.TENANT_COMPANY_MENU_EXCLUDE = new Set([29933, 29936, 29938, 29944, 29947])

+ 67 - 0
adminui/scripts/move-list-search-actions-to-end.js

@@ -0,0 +1,67 @@
+/**
+ * 将 queryForm 内带 list-search-actions 的块移到 </el-form> 前,使搜索按钮靠右对齐。
+ * Usage: node scripts/move-list-search-actions-to-end.js [viewsDir]
+ */
+const fs = require('fs')
+const path = require('path')
+
+const viewsDir = process.argv[2]
+  ? path.resolve(process.argv[2])
+  : path.join(__dirname, '../src/views')
+
+function walk(dir, files = []) {
+  for (const name of fs.readdirSync(dir)) {
+    const full = path.join(dir, name)
+    if (fs.statSync(full).isDirectory()) walk(full, files)
+    else if (name.endsWith('.vue')) files.push(full)
+  }
+  return files
+}
+
+function normalizeActionsBlock(block) {
+  // el-col 改为 template,避免嵌套 el-row 时破坏 el-form-item 布局
+  return block
+    .replace(/<el-col\b([^>]*)>/g, '<template$1>')
+    .replace(/<\/el-col>/g, '</template>')
+}
+
+function moveSearchActionsToEnd(content) {
+  const queryIdx = content.indexOf('ref="queryForm"')
+  if (queryIdx < 0 || !content.includes('list-search-actions')) return content
+
+  const formOpenStart = content.lastIndexOf('<el-form', queryIdx)
+  const formCloseStart = content.indexOf('</el-form>', queryIdx)
+  if (formOpenStart < 0 || formCloseStart < 0) return content
+
+  const before = content.slice(0, formOpenStart)
+  const formTagEnd = content.indexOf('>', formOpenStart) + 1
+  const formOpenTag = content.slice(formOpenStart, formTagEnd)
+  let formBody = content.slice(formTagEnd, formCloseStart)
+  const after = content.slice(formCloseStart)
+
+  const actionsRe = /<el-form-item\b[^>]*\blist-search-actions\b[^>]*>[\s\S]*?<\/el-form-item>\s*/g
+  const actions = []
+  formBody = formBody.replace(actionsRe, (m) => {
+    actions.push(normalizeActionsBlock(m.replace(/\s+$/, '')))
+    return ''
+  })
+
+  if (actions.length === 0) return content
+
+  formBody = formBody.replace(/\n{3,}/g, '\n\n').replace(/\s+$/, '')
+  const actionsBlock = '\n' + actions.join('\n') + '\n'
+  return before + formOpenTag + formBody + actionsBlock + after
+}
+
+let changed = 0
+for (const file of walk(viewsDir)) {
+  const original = fs.readFileSync(file, 'utf8')
+  const next = moveSearchActionsToEnd(original)
+  if (next !== original) {
+    fs.writeFileSync(file, next, 'utf8')
+    changed++
+    console.log(path.relative(viewsDir, file))
+  }
+}
+
+console.log('\nMoved list-search-actions in ' + changed + ' files')

+ 104 - 0
adminui/scripts/repair-lobster-api-registry-schema.js

@@ -0,0 +1,104 @@
+/**
+ * Repair lobster_api_registry schema for tenant DBs (old api_url -> new base_url/provider/auth columns).
+ * Usage: node scripts/repair-lobster-api-registry-schema.js [dbName|all]
+ */
+const mysql = require('mysql2/promise')
+
+const DB_CFG = {
+  host: process.env.DB_HOST || 'cq-cdb-8fjmemkb.sql.tencentcdb.com',
+  port: Number(process.env.DB_PORT || 27220),
+  user: process.env.DB_USER || 'root',
+  password: process.env.DB_PASSWORD || 'Ylrz_1q2w3e4r5t6y',
+  multipleStatements: true,
+  charset: 'utf8mb4'
+}
+
+const MASTER_DB = process.env.DB_NAME || 'ylrz_saas'
+
+const ALTERS = [
+  "ALTER TABLE lobster_api_registry ADD COLUMN provider VARCHAR(64) DEFAULT NULL COMMENT 'provider' AFTER category",
+  "ALTER TABLE lobster_api_registry ADD COLUMN base_url VARCHAR(500) DEFAULT NULL COMMENT 'base url' AFTER provider",
+  "ALTER TABLE lobster_api_registry ADD COLUMN auth_type VARCHAR(32) DEFAULT 'none' COMMENT 'auth type' AFTER base_url",
+  "ALTER TABLE lobster_api_registry ADD COLUMN auth_config TEXT COMMENT 'auth config' AFTER auth_type",
+  "ALTER TABLE lobster_api_registry ADD COLUMN timeout INT DEFAULT 5000 COMMENT 'timeout ms' AFTER auth_config",
+  "ALTER TABLE lobster_api_registry ADD COLUMN priority INT DEFAULT 0 COMMENT 'priority' AFTER enabled",
+  "ALTER TABLE lobster_api_registry ADD COLUMN is_backup TINYINT DEFAULT 0 COMMENT 'backup' AFTER priority",
+  "ALTER TABLE lobster_api_registry ADD COLUMN description TEXT COMMENT 'description' AFTER is_backup"
+]
+
+async function columnExists(conn, table, column) {
+  const [rows] = await conn.query(
+    'SELECT COUNT(*) AS c FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?',
+    [table, column]
+  )
+  return Number(rows[0].c) > 0
+}
+
+async function tableExists(conn, table) {
+  const [rows] = await conn.query('SHOW TABLES LIKE ?', [table])
+  return rows.length > 0
+}
+
+async function repairDb(db) {
+  const conn = await mysql.createConnection({ ...DB_CFG, database: db })
+  try {
+    if (!(await tableExists(conn, 'lobster_api_registry'))) {
+      console.log(`[skip] ${db}: table missing`)
+      return
+    }
+    let added = 0
+    for (const sql of ALTERS) {
+      const col = sql.match(/ADD COLUMN (\w+)/)[1]
+      if (await columnExists(conn, 'lobster_api_registry', col)) continue
+      await conn.query(sql)
+      added++
+      console.log(`[${db}] added column ${col}`)
+    }
+    if (await columnExists(conn, 'lobster_api_registry', 'api_url')) {
+      await conn.query(
+        "UPDATE lobster_api_registry SET base_url = api_url WHERE (base_url IS NULL OR base_url = '') AND api_url IS NOT NULL AND api_url <> ''"
+      )
+    }
+    if (await columnExists(conn, 'lobster_api_registry', 'api_description')) {
+      await conn.query(
+        "UPDATE lobster_api_registry SET description = api_description WHERE (description IS NULL OR description = '') AND api_description IS NOT NULL AND api_description <> ''"
+      )
+    }
+    await conn.query("UPDATE lobster_api_registry SET auth_type = 'none' WHERE auth_type IS NULL OR auth_type = ''")
+    const [cols] = await conn.query('SHOW COLUMNS FROM lobster_api_registry')
+    const [cnt] = await conn.query('SELECT COUNT(*) AS c FROM lobster_api_registry')
+    console.log(`[ok] ${db}: columns=${cols.length}, rows=${cnt[0].c}, newCols=${added}`)
+  } finally {
+    await conn.end()
+  }
+}
+
+async function listTenantDbs() {
+  const conn = await mysql.createConnection({ ...DB_CFG, database: MASTER_DB })
+  const dbs = new Set()
+  try {
+    const [rows] = await conn.query("SELECT db_url FROM tenant_info WHERE db_url IS NOT NULL AND db_url <> ''")
+    for (const r of rows) {
+      const m = String(r.db_url).match(/\/([^/?]+)(?:\?|$)/)
+      if (m) dbs.add(m[1])
+    }
+  } finally {
+    await conn.end()
+  }
+  return [...dbs]
+}
+
+async function main() {
+  const arg = process.argv[2] || 'all'
+  const targets = arg === 'all' ? await listTenantDbs() : [arg]
+  for (const db of targets) {
+    try {
+      await repairDb(db)
+    } catch (e) {
+      console.error(`[fail] ${db}: ${e.message}`)
+    }
+  }
+  console.log('Done.')
+}
+
+main().catch(e => { console.error(e); process.exit(1) })

+ 88 - 0
adminui/scripts/repair-lobster-profile-schema.js

@@ -0,0 +1,88 @@
+/**
+ * Add lobster_user_profile.channel_type (and related index) if missing.
+ * Usage: node scripts/repair-lobster-profile-schema.js [dbName]
+ */
+const mysql = require('mysql2/promise')
+
+const DB_CFG = {
+  host: 'cq-cdb-8fjmemkb.sql.tencentcdb.com',
+  port: 27220,
+  user: 'root',
+  password: 'Ylrz_1q2w3e4r5t6y',
+  charset: 'utf8mb4',
+  multipleStatements: true
+}
+
+const TARGET_DB = process.argv[2] || 'fs_tenant_cs1'
+
+async function columnExists(conn, table, column) {
+  const [rows] = await conn.query(
+    `SELECT COUNT(*) c FROM INFORMATION_SCHEMA.COLUMNS
+     WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
+    [table, column]
+  )
+  return rows[0].c > 0
+}
+
+async function indexExists(conn, table, indexName) {
+  const [rows] = await conn.query(
+    `SELECT COUNT(*) c FROM INFORMATION_SCHEMA.STATISTICS
+     WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?`,
+    [table, indexName]
+  )
+  return rows[0].c > 0
+}
+
+async function repair(conn) {
+  const [tables] = await conn.query(`SHOW TABLES LIKE 'lobster_user_profile'`)
+  if (!tables.length) {
+    console.log('skip: lobster_user_profile table not found')
+    return
+  }
+
+  const hasChannel = await columnExists(conn, 'lobster_user_profile', 'channel_type')
+  if (!hasChannel) {
+    await conn.query(`
+      ALTER TABLE lobster_user_profile
+      ADD COLUMN channel_type varchar(20) NOT NULL DEFAULT 'QW' COMMENT 'channel type QW/WX/IM'
+      AFTER external_user_id
+    `)
+    console.log('added column channel_type')
+  } else {
+    console.log('column channel_type already exists')
+  }
+
+  if (await indexExists(conn, 'lobster_user_profile', 'uk_user')) {
+    const [idx] = await conn.query(`SHOW INDEX FROM lobster_user_profile WHERE Key_name = 'uk_user'`)
+    const cols = idx.map(r => r.Column_name).join(',')
+    if (cols === 'company_id,external_user_id' || cols === 'company_id,external_user_id,') {
+      await conn.query('ALTER TABLE lobster_user_profile DROP INDEX uk_user')
+      console.log('dropped old uk_user')
+    }
+  }
+
+  if (!(await indexExists(conn, 'lobster_user_profile', 'uk_user'))) {
+    await conn.query(`
+      ALTER TABLE lobster_user_profile
+      ADD UNIQUE KEY uk_user (company_id, channel_type, external_user_id)
+    `)
+    console.log('added uk_user (company_id, channel_type, external_user_id)')
+  } else {
+    console.log('index uk_user already ok')
+  }
+}
+
+async function main() {
+  const conn = await mysql.createConnection({ ...DB_CFG, database: TARGET_DB })
+  console.log('DB:', TARGET_DB)
+  await repair(conn)
+  const [cols] = await conn.query(
+    `SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
+     WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lobster_user_profile'
+     ORDER BY ORDINAL_POSITION`
+  )
+  console.log('columns:', cols.map(r => r.COLUMN_NAME).join(', '))
+  await conn.end()
+}
+
+main().catch(e => { console.error(e); process.exit(1) })

+ 52 - 0
adminui/scripts/repair-lobster-segment-schema.js

@@ -0,0 +1,52 @@
+/**
+ * Ensure lobster_user_segment table exists (tenant DB).
+ * Usage: node scripts/repair-lobster-segment-schema.js [dbName]
+ */
+const mysql = require('mysql2/promise')
+
+const DB_CFG = {
+  host: 'cq-cdb-8fjmemkb.sql.tencentcdb.com',
+  port: 27220,
+  user: 'root',
+  password: 'Ylrz_1q2w3e4r5t6y',
+  charset: 'utf8mb4'
+}
+
+const TARGET_DB = process.argv[2] || 'fs_tenant_cs1'
+
+const CREATE_SQL = `
+CREATE TABLE IF NOT EXISTS lobster_user_segment (
+    id bigint NOT NULL AUTO_INCREMENT,
+    company_id bigint NOT NULL COMMENT 'company id',
+    segment_code varchar(64) NOT NULL COMMENT 'segment code',
+    segment_name varchar(128) DEFAULT NULL COMMENT 'segment name',
+    description varchar(500) DEFAULT NULL COMMENT 'description',
+    strategy_config text COMMENT 'strategy config JSON',
+    deleted tinyint DEFAULT 0 COMMENT 'deleted flag',
+    create_time datetime DEFAULT CURRENT_TIMESTAMP,
+    update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (id),
+    UNIQUE KEY uk_company_segment (company_id, segment_code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='lobster user segment definitions'
+`
+
+async function main() {
+  const conn = await mysql.createConnection({ ...DB_CFG, database: TARGET_DB })
+  console.log('DB:', TARGET_DB)
+  const [before] = await conn.query(`SHOW TABLES LIKE 'lobster_user_segment'`)
+  if (before.length) {
+    console.log('lobster_user_segment already exists')
+  } else {
+    await conn.query(CREATE_SQL)
+    console.log('created lobster_user_segment')
+  }
+  const [cols] = await conn.query(
+    `SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
+     WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lobster_user_segment'
+     ORDER BY ORDINAL_POSITION`
+  )
+  console.log('columns:', cols.map(r => r.COLUMN_NAME).join(', '))
+  await conn.end()
+}
+
+main().catch(e => { console.error(e); process.exit(1) })

+ 137 - 0
adminui/scripts/seed-lobster-channel-data.js

@@ -0,0 +1,137 @@
+const mysql = require('mysql2/promise')
+
+const BUILTIN = ['QW', 'WX', 'IM']
+
+/** Keep in sync with ChannelTypeRegistry.registerDefaults() */
+const ALL_CHANNELS = [
+  'QW', 'WX', 'IM',
+  'WHATSAPP', 'LINE', 'TELEGRAM', 'APP_IM',
+  'TMALL', 'JD', 'DOUYIN_DM', 'KUAISHOU_DM', 'XIAOHONGSHU_DM', 'DOUYIN_EC', 'OTHER'
+]
+
+const DB_CFG = {
+  host: 'cq-cdb-8fjmemkb.sql.tencentcdb.com',
+  port: 27220,
+  user: 'root',
+  password: 'Ylrz_1q2w3e4r5t6y',
+  multipleStatements: true
+}
+
+function dbNameFromUrl(url) {
+  if (!url) return null
+  const m = String(url).match(/\/([^/?]+)(?:\?|$)/)
+  return m ? m[1] : null
+}
+
+async function seedMasterGrants(conn) {
+  const [result] = await conn.query(`
+    INSERT IGNORE INTO lobster_tenant_channel_grant (tenant_id, channel_type, granted, create_by)
+    SELECT t.id, ch.channel_type, 1, 'seed'
+    FROM tenant_info t
+    CROSS JOIN (
+      SELECT 'QW' AS channel_type
+      UNION ALL SELECT 'WX'
+      UNION ALL SELECT 'IM'
+    ) ch
+    WHERE t.id IS NOT NULL
+  `)
+  return result.affectedRows || 0
+}
+
+async function seedTenantPluginConfig(conn) {
+  let inserted = 0
+  for (const channelType of ALL_CHANNELS) {
+    const enabled = BUILTIN.includes(channelType) ? 1 : 0
+    const [result] = await conn.query(
+      `INSERT IGNORE INTO lobster_channel_plugin_config (company_id, channel_type, enabled, config_json)
+       VALUES (0, ?, ?, '{}')`,
+      [channelType, enabled]
+    )
+    inserted += result.affectedRows || 0
+  }
+  return inserted
+}
+
+async function ensureChannelAccountTable(conn) {
+  await conn.query(`
+    CREATE TABLE IF NOT EXISTS lobster_channel_account (
+      id            BIGINT AUTO_INCREMENT PRIMARY KEY,
+      company_id    BIGINT       NOT NULL COMMENT 'tenant-level uses 0',
+      channel_type  VARCHAR(30)  NOT NULL COMMENT 'WHATSAPP/TMALL/DOUYIN_DM/...',
+      account_code  VARCHAR(64)  NOT NULL COMMENT 'unique code within channel',
+      account_name  VARCHAR(128) DEFAULT NULL,
+      config_json   TEXT,
+      enabled       TINYINT      DEFAULT 1,
+      is_default    TINYINT      DEFAULT 0,
+      sort_order    INT          DEFAULT 0,
+      create_time   DATETIME     DEFAULT CURRENT_TIMESTAMP,
+      update_time   DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+      UNIQUE KEY uk_company_channel_account (company_id, channel_type, account_code),
+      KEY idx_company_channel (company_id, channel_type)
+    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
+  `)
+  const [result] = await conn.query(`
+    INSERT IGNORE INTO lobster_channel_account (
+      company_id, channel_type, account_code, account_name, config_json, enabled, is_default, sort_order
+    )
+    SELECT
+      p.company_id,
+      p.channel_type,
+      'default',
+      CONCAT(p.channel_type, '-default'),
+      COALESCE(NULLIF(p.config_json, ''), '{}'),
+      COALESCE(p.enabled, 0),
+      1,
+      0
+    FROM lobster_channel_plugin_config p
+    WHERE p.channel_type NOT IN ('QW', 'WX', 'IM')
+  `)
+  return result.affectedRows || 0
+}
+
+async function main() {
+  const masterConn = await mysql.createConnection({ ...DB_CFG, database: 'ylrz_saas' })
+  const tenantDbs = new Set()
+
+  try {
+    const [tenants] = await masterConn.query(
+      'SELECT id, tenant_code, db_url FROM tenant_info WHERE db_url IS NOT NULL AND db_url != \'\''
+    )
+    for (const t of tenants) {
+      const name = dbNameFromUrl(t.db_url)
+      if (name) tenantDbs.add(name)
+    }
+  } catch (e) {
+    console.warn('tenant_info query skipped:', e.message)
+  }
+
+  const grantInserted = await seedMasterGrants(masterConn)
+  const [grantCount] = await masterConn.query(
+    'SELECT COUNT(*) AS c FROM lobster_tenant_channel_grant WHERE granted = 1'
+  )
+  console.log(`master(ylrz_saas): grant inserted=${grantInserted}, total granted rows=${grantCount[0].c}`)
+  await masterConn.end()
+
+  for (const db of tenantDbs) {
+    try {
+      const conn = await mysql.createConnection({ ...DB_CFG, database: db })
+      const n = await seedTenantPluginConfig(conn)
+      const migrated = await ensureChannelAccountTable(conn)
+      const [rows] = await conn.query(
+        'SELECT COUNT(*) AS c FROM lobster_channel_plugin_config WHERE company_id = 0'
+      )
+      const [accRows] = await conn.query(
+        'SELECT COUNT(*) AS c FROM lobster_channel_account WHERE company_id = 0'
+      )
+      await conn.end()
+      console.log(`${db}: plugin inserted=${n}, account migrated=${migrated}, plugin rows=${rows[0].c}, account rows=${accRows[0].c}`)
+    } catch (e) {
+      console.error(`${db}: ERROR ${e.message}`)
+    }
+  }
+}
+
+main().catch(e => {
+  console.error(e)
+  process.exit(1)
+})

+ 116 - 0
adminui/scripts/sync-lobster-menus-master.js

@@ -0,0 +1,116 @@
+/**
+ * Sync lobster menus on master DB (ylrz_saas):
+ *   tenant_sys_menu  = tenant admin template (includes semantic-keywords 29933)
+ *   tenant_company_menu = sales template (excludes 29933)
+ * Usage: node scripts/sync-lobster-menus-master.js [audit|sync]
+ */
+const mysql = require('mysql2/promise')
+const { LOBSTER_MENUS, TENANT_COMPANY_MENU_EXCLUDE } = require('./lobster-menu-definitions')
+
+const DB_CFG = {
+  host: 'cq-cdb-8fjmemkb.sql.tencentcdb.com',
+  port: 27220,
+  user: 'root',
+  password: 'Ylrz_1q2w3e4r5t6y',
+  multipleStatements: true,
+  charset: 'utf8mb4',
+  database: 'ylrz_saas'
+}
+
+const TENANT_COMPANY_MENU_IDS = LOBSTER_MENUS.filter(m => !TENANT_COMPANY_MENU_EXCLUDE.has(m.id)).map(m => m.id)
+
+async function upsertTenantSysMenu(conn, m) {
+  const [ex] = await conn.query('SELECT menu_id FROM tenant_sys_menu WHERE menu_id = ?', [m.id])
+  if (!ex.length) {
+    await conn.query(
+      `INSERT INTO tenant_sys_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, icon, visible, status, is_frame, is_cache, perms, create_by, create_time, remark)
+       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, '0', 0, 0, ?, 'admin', NOW(), 'lobster master sync')`,
+      [m.id, m.name, m.parent, m.order, m.path, m.component, m.type, m.icon, m.visible, m.perms]
+    )
+    return 'insert'
+  }
+  await conn.query(
+    `UPDATE tenant_sys_menu SET menu_name=?, parent_id=?, order_num=?, path=?, component=?, menu_type=?, icon=?, visible=?, status='0', perms=?, update_time=NOW() WHERE menu_id=?`,
+    [m.name, m.parent, m.order, m.path, m.component, m.type, m.icon, m.visible, m.perms, m.id]
+  )
+  return 'update'
+}
+
+async function upsertTenantCompanyMenu(conn, m) {
+  const [ex] = await conn.query('SELECT menu_id FROM tenant_company_menu WHERE menu_id = ? LIMIT 1', [m.id])
+  if (!ex.length) {
+    await conn.query(
+      `INSERT INTO tenant_company_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, company_id, remark)
+       VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?, ?, '0', ?, ?, 'admin', NOW(), NULL, 'lobster master sync')`,
+      [m.id, m.name, m.parent, m.order, m.path, m.component, m.type, m.visible, m.perms, m.icon]
+    )
+    return 'insert'
+  }
+  await conn.query(
+    `UPDATE tenant_company_menu SET menu_name=?, parent_id=?, order_num=?, path=?, component=?, menu_type=?, icon=?, visible=?, status='0', perms=?, update_time=NOW() WHERE menu_id=?`,
+    [m.name, m.parent, m.order, m.path, m.component, m.type, m.icon, m.visible, m.perms, m.id]
+  )
+  return 'update'
+}
+
+async function audit(conn) {
+  const ids = LOBSTER_MENUS.map(m => m.id)
+  const [sysRows] = await conn.query(
+    `SELECT menu_id, menu_name, path, component, visible FROM tenant_sys_menu WHERE menu_id IN (${ids.join(',')}) ORDER BY order_num, menu_id`
+  )
+  const [cmRows] = await conn.query(
+    `SELECT menu_id, menu_name, path FROM tenant_company_menu WHERE menu_id IN (${TENANT_COMPANY_MENU_IDS.join(',')}) ORDER BY order_num, menu_id`
+  )
+  console.log('\n=== tenant_sys_menu === count', sysRows.length, '/', LOBSTER_MENUS.length)
+  sysRows.forEach(r => console.log(`  ${r.menu_id}\t${r.menu_name}\t${r.path}`))
+  const missSys = LOBSTER_MENUS.filter(m => !sysRows.find(r => r.menu_id === m.id))
+  if (missSys.length) console.log('missing tenant_sys_menu:', missSys.map(m => m.id + ':' + m.name).join(', '))
+
+  const [badCm] = await conn.query(`SELECT menu_id, menu_name FROM tenant_company_menu WHERE menu_id = 29933`)
+  if (badCm.length) console.log('WARN tenant_company_menu still has 29933:', badCm)
+
+  console.log('\n=== tenant_company_menu === count', cmRows.length, '/', TENANT_COMPANY_MENU_IDS.length)
+  const missCm = TENANT_COMPANY_MENU_IDS.filter(id => !cmRows.find(r => r.menu_id === id))
+  if (missCm.length) console.log('missing tenant_company_menu:', missCm.join(', '))
+}
+
+async function sync(conn) {
+  await conn.query('DELETE FROM tenant_company_menu WHERE menu_id = 29933 OR path = \'semantic-keywords\' OR component = \'lobster/semantic-keywords/index\'')
+
+  let sysIns = 0
+  let sysUpd = 0
+  let cmIns = 0
+  let cmUpd = 0
+  for (const m of LOBSTER_MENUS) {
+    const r = await upsertTenantSysMenu(conn, m)
+    if (r === 'insert') sysIns++
+    else sysUpd++
+  }
+  for (const id of TENANT_COMPANY_MENU_IDS) {
+    const m = LOBSTER_MENUS.find(x => x.id === id)
+    const companyMenu = id === 29934 ? { ...m, perms: 'workflow:lobster:query' } : m
+    const r = await upsertTenantCompanyMenu(conn, companyMenu)
+    if (r === 'insert') cmIns++
+    else cmUpd++
+  }
+  await conn.query(`UPDATE tenant_sys_menu SET visible='1' WHERE menu_id IN (29936,29922,29948,29926)`)
+  await conn.query(`UPDATE tenant_company_menu SET visible='1' WHERE menu_id IN (29922,29948,29926)`)
+  console.log(`\nSync: tenant_sys_menu +${sysIns}/~${sysUpd}, tenant_company_menu +${cmIns}/~${cmUpd}`)
+}
+
+async function main() {
+  const mode = process.argv[2] || 'sync'
+  const conn = await mysql.createConnection(DB_CFG)
+  console.log('DB: ylrz_saas mode:', mode)
+  await audit(conn)
+  if (mode === 'sync') {
+    await sync(conn)
+    await audit(conn)
+  }
+  await conn.end()
+}
+
+main().catch(e => {
+  console.error(e)
+  process.exit(1)
+})

+ 129 - 0
adminui/scripts/sync-lobster-menus-tenant.js

@@ -0,0 +1,129 @@
+/**
+ * Sync lobster menus for tenant DB (sys_menu + company_menu)
+ * Usage: node scripts/sync-lobster-menus-tenant.js [dbName] [audit|sync]
+ */
+const mysql = require('mysql2/promise')
+const { LOBSTER_MENUS, TENANT_COMPANY_MENU_EXCLUDE } = require('./lobster-menu-definitions')
+
+const DB_CFG = {
+  host: 'cq-cdb-8fjmemkb.sql.tencentcdb.com',
+  port: 27220,
+  user: 'root',
+  password: 'Ylrz_1q2w3e4r5t6y',
+  multipleStatements: true,
+  charset: 'utf8mb4'
+}
+
+const TARGET_DB = process.argv[2] || 'fs_tenant_cs1'
+
+const COMPANY_MENU_EXCLUDE = TENANT_COMPANY_MENU_EXCLUDE
+const COMPANY_MENU_IDS = LOBSTER_MENUS.filter(m => !COMPANY_MENU_EXCLUDE.has(m.id)).map(m => m.id)
+
+async function upsertSysMenu(conn, m) {
+  const [ex] = await conn.query('SELECT menu_id FROM sys_menu WHERE menu_id = ?', [m.id])
+  if (!ex.length) {
+    await conn.query(
+      `INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, menu_type, icon, visible, status, is_frame, is_cache, perms, create_by, create_time, remark)
+       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, '0', 0, 0, ?, 'admin', NOW(), 'lobster sync')`,
+      [m.id, m.name, m.parent, m.order, m.path, m.component, m.type, m.icon, m.visible, m.perms]
+    )
+    return 'insert'
+  }
+  await conn.query(
+    `UPDATE sys_menu SET menu_name=?, parent_id=?, order_num=?, path=?, component=?, menu_type=?, icon=?, visible=?, status='0', perms=? WHERE menu_id=?`,
+    [m.name, m.parent, m.order, m.path, m.component, m.type, m.icon, m.visible, m.perms, m.id]
+  )
+  return 'update'
+}
+
+async function upsertCompanyMenu(conn, m) {
+  const [ex] = await conn.query('SELECT menu_id FROM company_menu WHERE menu_id = ?', [m.id])
+  if (!ex.length) {
+    await conn.query(
+      `INSERT INTO company_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, company_id, remark)
+       VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?, ?, '0', ?, ?, 'admin', NOW(), NULL, 'lobster sync')`,
+      [m.id, m.name, m.parent, m.order, m.path, m.component, m.type, m.visible, m.perms, m.icon]
+    )
+    return 'insert'
+  }
+  await conn.query(
+    `UPDATE company_menu SET menu_name=?, parent_id=?, order_num=?, path=?, component=?, menu_type=?, icon=?, visible=?, status='0', perms=? WHERE menu_id=?`,
+    [m.name, m.parent, m.order, m.path, m.component, m.type, m.icon, m.visible, m.perms, m.id]
+  )
+  return 'update'
+}
+
+async function audit(conn) {
+  const ids = LOBSTER_MENUS.map(m => m.id)
+  const [sysRows] = await conn.query(
+    `SELECT menu_id, menu_name, path, component, visible FROM sys_menu WHERE menu_id IN (${ids.join(',')}) ORDER BY order_num, menu_id`
+  )
+  const [cmRows] = await conn.query(
+    `SELECT menu_id, menu_name, path, component, visible FROM company_menu WHERE menu_id IN (${COMPANY_MENU_IDS.join(',')}) ORDER BY order_num, menu_id`
+  )
+  console.log('\n=== sys_menu === count', sysRows.length, '/', LOBSTER_MENUS.length)
+  sysRows.forEach(r => console.log(`  ${r.menu_id}\t${r.menu_name}\t${r.path}\t${r.component}`))
+  const missSys = LOBSTER_MENUS.filter(m => !sysRows.find(r => r.menu_id === m.id))
+  if (missSys.length) console.log('missing sys:', missSys.map(m => m.id + ':' + m.name).join(', '))
+
+  console.log('\n=== company_menu === count', cmRows.length, '/', COMPANY_MENU_IDS.length)
+  cmRows.forEach(r => console.log(`  ${r.menu_id}\t${r.menu_name}\t${r.path}`))
+  const missCm = COMPANY_MENU_IDS.filter(id => !cmRows.find(r => r.menu_id === id))
+  if (missCm.length) console.log('missing company:', missCm.join(', '))
+}
+
+async function sync(conn) {
+  await conn.query('DELETE FROM sys_role_menu WHERE menu_id IN (29923, 2999, 29937, 29938, 29939, 29946, 29947, 3005)')
+  await conn.query('DELETE FROM company_role_menu WHERE menu_id IN (29923, 2999, 29933, 29937, 29938, 29939, 29946, 29947, 3005)')
+  await conn.query("DELETE FROM company_menu WHERE menu_id IN (29923, 2999, 29933, 29937, 29938, 29939, 29946, 29947, 3005) OR path IN ('execution-config', 'dashboard', 'evolution', 'token-stats') OR component IN ('lobster/summary-config/index', 'lobster/model-route/index', 'lobster/execution-config/index', 'lobster/dashboard/index', 'lobster/evolution/index', 'lobster/token-stats/index')")
+  await conn.query("DELETE FROM sys_menu WHERE menu_id IN (29923, 2999, 29937, 29938, 29939, 29946, 29947, 3005) OR path IN ('execution-config', 'dashboard', 'evolution', 'token-stats') OR component IN ('lobster/summary-config/index', 'lobster/model-route/index', 'lobster/execution-config/index', 'lobster/dashboard/index', 'lobster/evolution/index', 'lobster/token-stats/index')")
+  await conn.query('DROP TABLE IF EXISTS company_lobster_summary_config')
+
+  let sysIns = 0; let sysUpd = 0; let cmIns = 0; let cmUpd = 0
+  for (const m of LOBSTER_MENUS) {
+    const r = await upsertSysMenu(conn, m)
+    if (r === 'insert') sysIns++; else sysUpd++
+  }
+  for (const id of COMPANY_MENU_IDS) {
+    const m = LOBSTER_MENUS.find(x => x.id === id)
+    const companyMenu = id === 29934 ? { ...m, perms: 'workflow:lobster:query' } : m
+    const r = await upsertCompanyMenu(conn, companyMenu)
+    if (r === 'insert') cmIns++; else cmUpd++
+  }
+  await conn.query(`UPDATE sys_menu SET visible='1' WHERE menu_id IN (29936,29922,29948,29926)`)
+  await conn.query(`UPDATE company_menu SET visible='1' WHERE menu_id IN (29922,29948,29926)`)
+
+  await conn.query(`
+    INSERT IGNORE INTO sys_role_menu (role_id, menu_id)
+    SELECT DISTINCT srm.role_id, m.menu_id
+    FROM sys_role_menu srm
+    CROSS JOIN (SELECT menu_id FROM sys_menu WHERE parent_id = 29900 OR menu_id = 29900) m
+    WHERE srm.menu_id = 29900
+  `)
+  await conn.query(`
+    INSERT IGNORE INTO sys_role_menu (role_id, menu_id)
+    SELECT 1, menu_id FROM sys_menu WHERE parent_id = 29900 OR menu_id = 29900
+  `)
+  await conn.query(`
+    INSERT IGNORE INTO company_role_menu (role_id, menu_id)
+    SELECT DISTINCT crm.role_id, m.menu_id
+    FROM company_role_menu crm
+    CROSS JOIN (SELECT menu_id FROM company_menu WHERE parent_id = 29900 OR menu_id = 29900) m
+    WHERE crm.menu_id = 29900
+  `)
+  console.log(`\nSync: sys +${sysIns}/~${sysUpd}, company +${cmIns}/~${cmUpd}`)
+}
+
+async function main() {
+  const mode = process.argv[3] || 'sync'
+  const conn = await mysql.createConnection({ ...DB_CFG, database: TARGET_DB })
+  console.log('DB:', TARGET_DB, 'mode:', mode)
+  await audit(conn)
+  if (mode === 'sync') {
+    await sync(conn)
+    await audit(conn)
+  }
+  await conn.end()
+}
+
+main().catch(e => { console.error(e); process.exit(1) })

+ 37 - 0
adminui/scripts/verify-session-fixes-db.js

@@ -0,0 +1,37 @@
+const mysql = require('mysql2/promise')
+
+const DB = process.argv[2] || 'fs_tenant_cs1'
+const cfg = {
+  host: 'cq-cdb-8fjmemkb.sql.tencentcdb.com',
+  port: 27220,
+  user: 'root',
+  password: 'Ylrz_1q2w3e4r5t6y',
+  database: DB,
+  charset: 'utf8mb4'
+}
+
+async function main() {
+  const c = await mysql.createConnection(cfg)
+  const [qdrantSys] = await c.query('SELECT menu_id, menu_name, path, visible FROM sys_menu WHERE menu_id = 29951')
+  const [qdrantCm] = await c.query('SELECT menu_id, menu_name, path FROM company_menu WHERE menu_id = 29951')
+  const [srm] = await c.query('SELECT COUNT(*) cnt FROM sys_role_menu WHERE menu_id = 29951')
+  const [crm] = await c.query('SELECT COUNT(*) cnt FROM company_role_menu WHERE menu_id = 29951')
+  const [tbl] = await c.query(
+    "SELECT COUNT(*) cnt FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lobster_sales_corpus_task'"
+  )
+  const [col] = await c.query(
+    "SELECT COUNT(*) cnt FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lobster_sales_corpus' AND COLUMN_NAME = 'task_id'"
+  )
+  console.log(JSON.stringify({
+    db: DB,
+    qdrantSysMenu: qdrantSys[0] || null,
+    qdrantCompanyMenu: qdrantCm[0] || null,
+    qdrantSysRoleGrants: srm[0].cnt,
+    qdrantCompanyRoleGrants: crm[0].cnt,
+    salesCorpusTaskTable: tbl[0].cnt === 1,
+    salesCorpusTaskIdColumn: col[0].cnt === 1
+  }, null, 2))
+  await c.end()
+}
+
+main().catch(e => { console.error(e); process.exit(1) })

+ 197 - 0
adminui/scripts/write-channel-manage.js

@@ -0,0 +1,197 @@
+const fs = require('fs')
+const path = 'd:/ylrz_saas_new/adminui/src/views/saas/tenant/ChannelManage.vue'
+
+const content = `<template>
+  <div v-loading="loading">
+    <el-alert type="info" :closable="false" show-icon style="margin-bottom: 12px">
+      <template slot="title">{{ labels.alertTitle }}</template>
+    </el-alert>
+
+    <el-table :data="plugins" border size="small" max-height="420">
+      <el-table-column :label="labels.colGrant" width="70" align="center">
+        <template slot-scope="{ row }">
+          <el-checkbox v-model="row.granted" />
+        </template>
+      </el-table-column>
+      <el-table-column prop="channelType" :label="labels.colCode" width="110" />
+      <el-table-column :label="labels.colName" min-width="130">
+        <template slot-scope="{ row }">
+          <i :class="row.icon" style="margin-right: 4px" />{{ row.displayName }}
+        </template>
+      </el-table-column>
+      <el-table-column label="SDK" width="88" align="center">
+        <template slot-scope="{ row }">
+          <el-tag :type="row.hasMessageChannel ? 'success' : 'warning'" size="mini">
+            {{ row.hasMessageChannel ? labels.sdkReady : labels.sdkPending }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column :label="labels.colEnable" width="72" align="center">
+        <template slot-scope="{ row }">
+          <el-switch
+            v-model="row._enabled"
+            :disabled="!row.granted || !row.hasMessageChannel"
+          />
+        </template>
+      </el-table-column>
+      <el-table-column :label="labels.colCredential" min-width="260">
+        <template slot-scope="{ row }">
+          <template v-if="!row.granted">
+            <span class="text-muted">{{ labels.notGranted }}</span>
+          </template>
+          <template v-else-if="isBuiltin(row.channelType)">
+            <el-tag size="mini" type="info">{{ labels.builtinNoConfig }}</el-tag>
+          </template>
+          <template v-else-if="row.channelType === 'WHATSAPP'">
+            <div class="config-row">
+              <el-input v-model="row._waPhone" placeholder="Phone Number ID" size="mini" class="cfg-md" />
+              <el-input v-model="row._waToken" placeholder="Token" size="mini" class="cfg-md" type="password" show-password />
+            </div>
+          </template>
+          <template v-else>
+            <el-input v-model="row._apiKey" placeholder="API Key / Webhook URL" size="mini" class="cfg-lg" />
+          </template>
+        </template>
+      </el-table-column>
+      <el-table-column :label="labels.colAction" width="88" fixed="right" align="center">
+        <template slot-scope="{ row }">
+          <el-button
+            type="text"
+            size="mini"
+            :disabled="!row.granted || !row._enabled"
+            @click="testChannel(row)"
+          >{{ labels.test }}</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+  </div>
+</template>
+
+<script>
+import { getTenantLobsterChannels, saveTenantLobsterChannels, testTenantLobsterChannel } from '@/api/admin/lobsterChannel'
+
+const LABELS = {
+  alertTitle: '\\u6e20\\u9053\\u7531\\u603b\\u540e\\u53f0\\u7edf\\u4e00\\u5f00\\u901a\\u4e0e\\u914d\\u7f6e\\uff08\\u6d89\\u53ca\\u79df\\u6237\\u8ba1\\u8d39\\uff09\\u3002\\u52fe\\u9009\\u300c\\u5f00\\u901a\\u300d\\u6388\\u6743\\u6e20\\u9053\\uff1b\\u5bf9\\u5df2\\u5f00\\u901a\\u6e20\\u9053\\u53ef\\u542f\\u7528\\u5e76\\u586b\\u5199\\u7b2c\\u4e09\\u65b9\\u51ed\\u8bc1\\u3002',
+  colGrant: '\\u5f00\\u901a',
+  colCode: '\\u7f16\\u7801',
+  colName: '\\u6e20\\u9053',
+  colEnable: '\\u542f\\u7528',
+  colCredential: '\\u51ed\\u8bc1',
+  colAction: '\\u64cd\\u4f5c',
+  sdkReady: '\\u5df2\\u63a5\\u5165',
+  sdkPending: '\\u5f85\\u63a5\\u5165',
+  notGranted: '\\u672a\\u5f00\\u901a',
+  builtinNoConfig: '\\u5185\\u7f6e\\u514d\\u914d\\u7f6e',
+  test: '\\u6d4b\\u8bd5',
+  needOneChannel: '\\u8bf7\\u81f3\\u5c11\\u5f00\\u901a\\u4e00\\u4e2a\\u6e20\\u9053',
+  saveOk: '\\u6e20\\u9053\\u914d\\u7f6e\\u5df2\\u4fdd\\u5b58',
+  testOk: '\\u8fde\\u63a5\\u6b63\\u5e38',
+  testFail: '\\u8fde\\u63a5\\u5931\\u8d25'
+}
+
+export default {
+  name: 'TenantChannelManage',
+  props: {
+    tenantId: { type: [Number, String], required: true }
+  },
+  data() {
+    return {
+      loading: false,
+      plugins: [],
+      labels: LABELS
+    }
+  },
+  watch: {
+    tenantId: {
+      immediate: true,
+      handler(v) {
+        if (v) this.load()
+      }
+    }
+  },
+  methods: {
+    isBuiltin(type) {
+      return type === 'QW' || type === 'WX' || type === 'IM'
+    },
+    load() {
+      this.loading = true
+      getTenantLobsterChannels(this.tenantId).then(res => {
+        const list = res.data || []
+        this.plugins = list.map(row => this.decorateRow(row))
+      }).finally(() => { this.loading = false })
+    },
+    decorateRow(row) {
+      const cfg = this.parseConfig(row.configJson)
+      return {
+        ...row,
+        granted: row.granted === true || row.granted === 1,
+        _enabled: row.enabled === 1 || row.enabled === true,
+        _waPhone: cfg.phoneNumberId || '',
+        _waToken: cfg.token || '',
+        _apiKey: cfg.apiKey || ''
+      }
+    },
+    parseConfig(json) {
+      if (!json) return {}
+      try {
+        return typeof json === 'string' ? JSON.parse(json) : json
+      } catch (e) {
+        return {}
+      }
+    },
+    buildPayload() {
+      const channelTypes = this.plugins.filter(p => p.granted).map(p => p.channelType)
+      if (!channelTypes.length) {
+        this.$message.warning(LABELS.needOneChannel)
+        return null
+      }
+      const plugins = this.plugins.filter(p => p.granted).map(p => {
+        let config = {}
+        if (!this.isBuiltin(p.channelType)) {
+          if (p.channelType === 'WHATSAPP') {
+            config = { phoneNumberId: p._waPhone, token: p._waToken }
+          } else {
+            config = { apiKey: p._apiKey }
+          }
+        }
+        return {
+          channelType: p.channelType,
+          enabled: p._enabled,
+          config
+        }
+      })
+      return { channelTypes, plugins }
+    },
+    save() {
+      const payload = this.buildPayload()
+      if (!payload) return Promise.reject()
+      this.loading = true
+      return saveTenantLobsterChannels(this.tenantId, payload).then(() => {
+        this.$message.success(LABELS.saveOk)
+        this.load()
+        this.$emit('success')
+      }).finally(() => { this.loading = false })
+    },
+    testChannel(row) {
+      testTenantLobsterChannel(this.tenantId, row.channelType).then(res => {
+        const r = res.data || {}
+        this.$message({
+          type: r.ok ? 'success' : 'error',
+          message: r.reason || (r.ok ? LABELS.testOk : LABELS.testFail)
+        })
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.config-row { display: flex; gap: 8px; flex-wrap: wrap; }
+.cfg-md { width: 140px; }
+.cfg-lg { width: 200px; }
+.text-muted { color: #909399; font-size: 12px; }
+</style>
+`
+
+fs.writeFileSync(path, content, 'utf8')
+console.log('written', path)

+ 19 - 0
adminui/src/App.vue

@@ -0,0 +1,19 @@
+<template>
+  <div id="app">
+    <router-view />
+  </div>
+</template>
+
+<script>
+export default  {
+  name:  'App',
+    metaInfo() {
+        return {
+            title: this.$store.state.settings.dynamicTitle && this.$store.state.settings.title,
+            titleTemplate: title => {
+                return title ? `${title} - ${process.env.VUE_APP_TITLE}` : process.env.VUE_APP_TITLE
+            }
+        }
+    }
+}
+</script>

+ 83 - 0
adminui/src/api/admin/aiChatQuality.js

@@ -0,0 +1,83 @@
+import request from '@/utils/request'
+
+// 导出AI对话会话
+export function exportChatSessions(query) {
+  return request({ url: '/admin/aiChatQuality/export', method: 'get', params: query })
+}
+
+// 查询AI聊天会话列表
+export function listChatSessions(query) {
+  return request({
+    url: '/admin/aiChatQuality/sessions',
+    method: 'get',
+    params: query
+  })
+}
+
+// 根据租户ID查询会话列表
+export function listByCompany(companyId) {
+  return request({
+    url: '/admin/aiChatQuality/sessions',
+    method: 'get',
+    params: { companyId }
+  })
+}
+
+// 获取会话详情
+export function getSessionDetail(sessionId) {
+  return request({
+    url: '/admin/aiChatQuality/session/' + sessionId,
+    method: 'get'
+  })
+}
+
+// 获取会话消息列表
+export function getSessionMessages(sessionId) {
+  return request({
+    url: '/admin/aiChatQuality/messages/' + sessionId,
+    method: 'get'
+  })
+}
+
+// 添加质检记录
+export function addQualityRecord(data) {
+  return request({
+    url: '/admin/aiChatQuality/record',
+    method: 'post',
+    data: data
+  })
+}
+
+// 查询质检记录列表
+export function listQualityRecords(query) {
+  return request({
+    url: '/admin/aiChatQuality/records',
+    method: 'get',
+    params: query
+  })
+}
+
+// 获取质检记录详情
+export function getQualityRecord(id) {
+  return request({
+    url: '/admin/aiChatQuality/record/' + id,
+    method: 'get'
+  })
+}
+
+// 修改质检记录
+export function updateQualityRecord(data) {
+  return request({
+    url: '/admin/aiChatQuality/record',
+    method: 'put',
+    data: data
+  })
+}
+
+// 删除质检记录
+export function deleteQualityRecord(id) {
+  return request({
+    url: '/admin/aiChatQuality/record/' + id,
+    method: 'delete'
+  })
+}

+ 84 - 0
adminui/src/api/admin/aiModel.js

@@ -0,0 +1,84 @@
+import request from '@/utils/request'
+
+const BASE = '/admin/aiModel'
+const SCENE_BASE = '/admin/aiScene'
+
+// ─── 模型管理 ───
+
+export function listAiModel() {
+  return request({ url: BASE + '/list', method: 'get' })
+}
+
+export function getAiModel(id) {
+  return request({ url: BASE + '/' + id, method: 'get' })
+}
+
+export function addAiModel(data) {
+  return request({ url: BASE, method: 'post', data })
+}
+
+export function updateAiModel(id, data) {
+  return request({ url: BASE + '/' + id, method: 'put', data })
+}
+
+export function deleteAiModel(id) {
+  return request({ url: BASE + '/' + id, method: 'delete' })
+}
+
+export function batchSortAiModel(sortList) {
+  return request({ url: BASE + '/batchSort', method: 'put', data: sortList })
+}
+
+export function testAiModel(id) {
+  return request({ url: BASE + '/test/' + id, method: 'post' })
+}
+
+export function refreshAiModel() {
+  return request({ url: BASE + '/refresh', method: 'post' })
+}
+
+// ─── 场景管理 ───
+
+export function listAiScene() {
+  return request({ url: SCENE_BASE + '/list', method: 'get' })
+}
+
+export function getAiScene(sceneCode) {
+  return request({ url: SCENE_BASE + '/' + sceneCode, method: 'get' })
+}
+
+export function updateAiScene(sceneCode, data) {
+  return request({ url: SCENE_BASE + '/' + sceneCode, method: 'put', data })
+}
+
+export function updateSceneThreshold(sceneCode, qualityThreshold) {
+  return request({ url: SCENE_BASE + '/' + sceneCode + '/threshold', method: 'put', data: { qualityThreshold } })
+}
+
+export function getSceneModels(sceneCode) {
+  return request({ url: SCENE_BASE + '/' + sceneCode + '/models', method: 'get' })
+}
+
+export function getSceneEnabledModels(sceneCode) {
+  return request({ url: SCENE_BASE + '/' + sceneCode + '/enabledModels', method: 'get' })
+}
+
+export function addSceneModel(sceneCode, data) {
+  return request({ url: SCENE_BASE + '/' + sceneCode + '/models', method: 'post', data })
+}
+
+export function deleteSceneModel(sceneCode, id) {
+  return request({ url: SCENE_BASE + '/' + sceneCode + '/models/' + id, method: 'delete' })
+}
+
+export function clearSceneModels(sceneCode) {
+  return request({ url: SCENE_BASE + '/' + sceneCode + '/models', method: 'delete' })
+}
+
+export function updateSceneModel(sceneCode, id, data) {
+  return request({ url: SCENE_BASE + '/' + sceneCode + '/models/' + id, method: 'put', data })
+}
+
+export function refreshAiScene() {
+  return request({ url: SCENE_BASE + '/refresh', method: 'post' })
+}

+ 71 - 0
adminui/src/api/admin/appCidConfig.js

@@ -0,0 +1,71 @@
+import request from '@/utils/request'
+
+const VOICE_BASE = '/admin/appCidConfig/voice'
+
+export function listAppVoice(query) {
+  return request({
+    url: VOICE_BASE + '/list',
+    method: 'get',
+    params: query
+  })
+}
+
+export function getAppVoice(id) {
+  return request({
+    url: VOICE_BASE + '/' + id,
+    method: 'get'
+  })
+}
+
+export function addAppVoice(data) {
+  return request({
+    url: VOICE_BASE,
+    method: 'post',
+    data: data
+  })
+}
+
+export function updateAppVoice(data) {
+  return request({
+    url: VOICE_BASE,
+    method: 'put',
+    data: data
+  })
+}
+
+export function delAppVoice(ids) {
+  return request({
+    url: VOICE_BASE + '/' + ids,
+    method: 'delete'
+  })
+}
+
+export function getPublicVoiceSyncPreview() {
+  return request({
+    url: VOICE_BASE + '/sync/preview',
+    method: 'get'
+  })
+}
+
+export function syncPublicVoiceToTenants(data) {
+  return request({
+    url: VOICE_BASE + '/sync/tenants',
+    method: 'post',
+    data: data
+  })
+}
+
+export function getAppVoiceOptions() {
+  return request({
+    url: VOICE_BASE + '/options',
+    method: 'get'
+  })
+}
+
+export function syncCustomVoiceToTenants(data) {
+  return request({
+    url: VOICE_BASE + '/sync/custom',
+    method: 'post',
+    data: data
+  })
+}

+ 62 - 0
adminui/src/api/admin/article.js

@@ -0,0 +1,62 @@
+import request from '@/utils/request'
+
+// 查询所有租户文章列表
+export function listAllArticles(query) {
+  return request({
+    url: '/admin/article/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 查询待审计文章列表
+export function listPendingArticles() {
+  return request({
+    url: '/admin/article/pending',
+    method: 'get'
+  })
+}
+
+// 根据租户ID查询文章列表
+export function listByCompany(companyId) {
+  return request({
+    url: '/admin/article/byCompany/' + companyId,
+    method: 'get'
+  })
+}
+
+// 获取文章详情
+export function getArticleInfo(articleId) {
+  return request({
+    url: '/admin/article/' + articleId,
+    method: 'get'
+  })
+}
+
+// 审计文章
+export function auditArticle(articleId, status, auditRemark) {
+  return request({
+    url: '/admin/article/audit/' + articleId,
+    method: 'put',
+    params: { status, auditRemark }
+  })
+}
+
+// 删除文章
+export function deleteArticle(articleId) {
+  return request({
+    url: '/admin/article/' + articleId,
+    method: 'delete'
+  })
+}
+
+// 导出文章
+export function exportAllArticles(query) {
+  return request({ url: '/admin/article/export', method: 'get', params: query })
+}
+export function getArticleStatistics() {
+  return request({
+    url: '/admin/article/statistics',
+    method: 'get'
+  })
+}

+ 63 - 0
adminui/src/api/admin/cidServerManage.js

@@ -0,0 +1,63 @@
+import request from '@/utils/request'
+
+export function listServiceConfig(query) {
+  return request({
+    url: '/admin/cidServerManage/serviceConfig/list',
+    method: 'get',
+    params: query
+  })
+}
+
+export function getServiceConfig(id) {
+  return request({
+    url: '/admin/cidServerManage/serviceConfig/' + id,
+    method: 'get'
+  })
+}
+
+export function addServiceConfig(data) {
+  return request({
+    url: '/admin/cidServerManage/serviceConfig',
+    method: 'post',
+    data: data
+  })
+}
+
+export function updateServiceConfig(data) {
+  return request({
+    url: '/admin/cidServerManage/serviceConfig',
+    method: 'put',
+    data: data
+  })
+}
+
+export function delServiceConfig(ids) {
+  return request({
+    url: '/admin/cidServerManage/serviceConfig/' + ids,
+    method: 'delete'
+  })
+}
+
+export function listCidServerQuota(data) {
+  return request({
+    url: '/admin/cidServerManage/quota/list',
+    method: 'post',
+    data: data
+  })
+}
+
+export function allocateCidServerQuota(data) {
+  return request({
+    url: '/admin/cidServerManage/quota/allocate',
+    method: 'post',
+    data: data
+  })
+}
+
+export function adjustCidServerQuota(data) {
+  return request({
+    url: '/admin/cidServerManage/quota/adjust',
+    method: 'put',
+    data: data
+  })
+}

+ 31 - 0
adminui/src/api/admin/commGatewayLog.js

@@ -0,0 +1,31 @@
+import request from '@/utils/request'
+
+/** 通讯网关 API 调用日志(主库 comm_gateway_api_log) */
+export function listCommGatewayLog(query) {
+  return request({
+    url: '/admin/comm-gateway-log/list',
+    method: 'get',
+    params: query
+  })
+}
+
+export function getCommGatewayLog(logId) {
+  return request({
+    url: '/admin/comm-gateway-log/' + logId,
+    method: 'get'
+  })
+}
+
+export function queryCommGatewayLogPhone(logId) {
+  return request({
+    url: '/admin/comm-gateway-log/queryPhone/' + logId,
+    method: 'get'
+  })
+}
+
+export function traceCommGatewayLogCaller(logId) {
+  return request({
+    url: '/admin/comm-gateway-log/trace/' + logId,
+    method: 'get'
+  })
+}

+ 27 - 0
adminui/src/api/admin/commissionRecord.js

@@ -0,0 +1,27 @@
+import request from '@/utils/request'
+
+// 查询代理返佣记录列表
+export function listCommissionRecords(query) {
+  return request({
+    url: '/admin/commission-record/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 获取返佣记录详情
+export function getCommissionRecord(id) {
+  return request({
+    url: '/admin/commission-record/' + id,
+    method: 'get'
+  })
+}
+
+// 导出返佣记录
+export function exportCommissionRecord(query) {
+  return request({
+    url: '/admin/commission-record/export',
+    method: 'get',
+    params: query
+  })
+}

+ 27 - 0
adminui/src/api/admin/consumeRecord.js

@@ -0,0 +1,27 @@
+import request from '@/utils/request'
+
+// 查询租户消费扣款记录列表
+export function listConsumeRecords(query) {
+  return request({
+    url: '/admin/consume-record/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 获取消费记录详情
+export function getConsumeRecord(id) {
+  return request({
+    url: '/admin/consume-record/' + id,
+    method: 'get'
+  })
+}
+
+// 导出消费记录
+export function exportConsumeRecord(query) {
+  return request({
+    url: '/admin/consume-record/export',
+    method: 'get',
+    params: query
+  })
+}

+ 10 - 0
adminui/src/api/admin/consumeReport.js

@@ -0,0 +1,10 @@
+import request from '@/utils/request'
+
+// 获取模块消费统计报告(按租户+模块交叉汇总)
+export function getConsumptionReport(params) {
+  return request({
+    url: '/admin/module-consumption/report',
+    method: 'get',
+    params
+  })
+}

+ 39 - 0
adminui/src/api/admin/course.js

@@ -0,0 +1,39 @@
+import request from '@/utils/request'
+
+// 查询所有租户课程列表
+export function listAllCourses(query) {
+  return request({
+    url: '/admin/course/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 根据租户ID查询课程列表
+export function listByCompany(companyId) {
+  return request({
+    url: '/admin/course/byCompany/' + companyId,
+    method: 'get'
+  })
+}
+
+// 获取课程详情
+export function getCourseInfo(courseId) {
+  return request({
+    url: '/admin/course/' + courseId,
+    method: 'get'
+  })
+}
+
+// 导出课程
+export function exportAllCourses(query) {
+  return request({ url: '/admin/course/export', method: 'get', params: query })
+}
+
+// 获取课程统计信息
+export function getCourseStatistics() {
+  return request({
+    url: '/admin/course/statistics',
+    method: 'get'
+  })
+}

+ 46 - 0
adminui/src/api/admin/dbConfig.js

@@ -0,0 +1,46 @@
+import request from '@/utils/request'
+
+export function listDbConfig(query) {
+  return request({
+    url: '/admin/dbConfig/list',
+    method: 'get',
+    params: query
+  })
+}
+
+export function getDbConfig(id) {
+  return request({
+    url: '/admin/dbConfig/' + id,
+    method: 'get'
+  })
+}
+
+export function addDbConfig(data) {
+  return request({
+    url: '/admin/dbConfig',
+    method: 'post',
+    data: data
+  })
+}
+
+export function updateDbConfig(data) {
+  return request({
+    url: '/admin/dbConfig',
+    method: 'put',
+    data: data
+  })
+}
+
+export function delDbConfig(ids) {
+  return request({
+    url: '/admin/dbConfig/' + ids,
+    method: 'delete'
+  })
+}
+
+export function getAvailableDbConfig() {
+  return request({
+    url: '/admin/dbConfig/available',
+    method: 'get'
+  })
+}

+ 53 - 0
adminui/src/api/admin/deviceCardManage.js

@@ -0,0 +1,53 @@
+import request from '@/utils/request'
+
+/**
+ * 设备与手机卡统一管理 API
+ * 对应后端:DeviceCardManageController (/admin/deviceCardManage)
+ * 对应页面:views/admin/smsCard/index.vue
+ */
+
+/** 设备列表(附带卡槽1/卡槽2信息) */
+export function listDeviceWithCards(query) {
+  return request({ url: '/admin/deviceCardManage/list', method: 'get', params: query })
+}
+
+/** 设备下拉选项(新增卡时选择关联设备) */
+export function listDeviceOptions(query) {
+  return request({ url: '/admin/deviceCardManage/device/options', method: 'get', params: query })
+}
+
+/** 设备详情(含关联手机卡,返回 { device, cards }) */
+export function getDeviceDetail(deviceId) {
+  return request({ url: '/admin/deviceCardManage/device/' + deviceId, method: 'get' })
+}
+
+/** 未绑定设备的手机卡列表 */
+export function listUnboundCard(query) {
+  return request({ url: '/admin/deviceCardManage/card/unboundList', method: 'get', params: query })
+}
+
+/** 新增手机卡(主库写入后自动同步租户库) */
+export function addCard(data) {
+  return request({ url: '/admin/deviceCardManage/card', method: 'post', data })
+}
+
+/**
+ * 更新手机卡(主库写入后自动同步租户库)
+ * 列表快捷开关通常只传 { cardId, status }
+ */
+export function updateCard(data) {
+  return request({ url: '/admin/deviceCardManage/card', method: 'put', data })
+}
+
+/**
+ * 更新设备(主库写入后自动同步租户库)
+ * 列表快捷开关通常只传 { deviceId, status }
+ */
+export function updateDevice(data) {
+  return request({ url: '/admin/deviceCardManage/device', method: 'put', data })
+}
+
+/** 禁用设备及其下所有手机卡(status=2) */
+export function disableDeviceWithCards(deviceId) {
+  return request({ url: '/admin/deviceCardManage/device/disable/' + deviceId, method: 'put' })
+}

+ 95 - 0
adminui/src/api/admin/ipadServer.js

@@ -0,0 +1,95 @@
+import request from '@/utils/request'
+
+// 查询iPad服务器列表(含租户使用统计,支持 addressId 筛选)
+export function listIpadServers(query) {
+  return request({
+    url: '/admin/ipad-server-list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 获取iPad租户使用统计
+export function getIpadStats() {
+  return request({
+    url: '/admin/ipad-stats',
+    method: 'get'
+  })
+}
+
+// 总后台新增主机(同一IP+addressId 自动创建 8083/8084/8085 三个端口,各100坐席)
+export function batchCreateIpadHost(data) {
+  return request({
+    url: '/admin/ipad/host/batchCreate',
+    method: 'post',
+    data: data
+  })
+}
+
+// 列出有剩余容量的销售区域(addressId)
+export function listIpadAddressRemaining() {
+  return request({
+    url: '/admin/ipad/address/remaining',
+    method: 'get'
+  })
+}
+
+// 按 addressId 为指定租户分配 iPad(主库扣减 + 租户库插入/累加)
+export function allocateIpadByAddressId(data) {
+  return request({
+    url: '/admin/ipad/allocateByAddressId',
+    method: 'post',
+    data: data
+  })
+}
+
+// 查询租户在各销售区域的已分配数量
+export function listTenantIpadAllocations(tenantId) {
+  return request({
+    url: '/admin/ipad/tenant/' + tenantId + '/allocations',
+    method: 'get'
+  })
+}
+
+// 按 addressId 从指定租户回收 iPad(租户库扣减 + 主库归还)
+export function recycleIpadByAddressId(data) {
+  return request({
+    url: '/admin/ipad/recycleByAddressId',
+    method: 'post',
+    data: data
+  })
+}
+
+// 获取单个iPad服务器详情(用于编辑)
+export function getIpadServer(id) {
+  return request({
+    url: '/admin/ipad-server/' + id,
+    method: 'get'
+  })
+}
+
+// 修改iPad服务器(编辑单条)
+export function updateIpadServer(data) {
+  return request({
+    url: '/admin/ipad-server',
+    method: 'put',
+    data: data
+  })
+}
+
+// 删除iPad服务器
+export function delIpadServer(id) {
+  return request({
+    url: '/admin/ipad-server/' + id,
+    method: 'delete'
+  })
+}
+
+// 导出iPad服务器
+export function exportIpadServer(query) {
+  return request({
+    url: '/admin/ipad-server/export',
+    method: 'get',
+    params: query
+  })
+}

+ 39 - 0
adminui/src/api/admin/live.js

@@ -0,0 +1,39 @@
+import request from '@/utils/request'
+
+// 查询所有租户直播列表
+export function listAllLives(query) {
+  return request({
+    url: '/admin/live/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 根据租户ID查询直播列表
+export function listByCompany(companyId) {
+  return request({
+    url: '/admin/live/byCompany/' + companyId,
+    method: 'get'
+  })
+}
+
+// 获取直播详情
+export function getLiveInfo(liveId) {
+  return request({
+    url: '/admin/live/' + liveId,
+    method: 'get'
+  })
+}
+
+// 导出直播
+export function exportAllLives(query) {
+  return request({ url: '/admin/live/export', method: 'get', params: query })
+}
+
+// 获取直播统计信息
+export function getLiveStatistics() {
+  return request({
+    url: '/admin/live/statistics',
+    method: 'get'
+  })
+}

+ 10 - 0
adminui/src/api/admin/liveVideo.js

@@ -0,0 +1,10 @@
+import request from '@/utils/request'
+
+// 查询所有租户直播视频列表
+export function listAllLiveVideos(query) {
+  return request({
+    url: '/admin/liveVideo/list',
+    method: 'get',
+    params: query
+  })
+}

+ 130 - 0
adminui/src/api/admin/lobster.js

@@ -0,0 +1,130 @@
+import request from '@/utils/request'
+
+// ======== 全局工作流模板 ========
+
+export function listAllTemplates(params) {
+  return request({ url: '/workflow/lobster/template/list', method: 'get', params })
+}
+
+// ======== 全局工作流实例监控 ========
+
+// 查看所有租户的工作流实例列表
+export function listAllInstances(params) {
+  return request({ url: '/workflow/lobster/instance/list', method: 'get', params })
+}
+
+// 查看指定实例详情(跨租户)
+export function getInstanceDetail(instanceId) {
+  return request({ url: `/workflow/lobster/instance/${instanceId}`, method: 'get' })
+}
+
+// 查看指定实例的节点执行日志
+export function getInstanceNodeLogs(instanceId) {
+  return request({ url: `/workflow/lobster/instance/node-logs/${instanceId}`, method: 'get' })
+}
+
+// 强制终止指定实例(管理级操作)
+export function forceTerminateInstance(instanceId, reason) {
+  return request({ url: `/workflow/lobster/instance/terminate/${instanceId}`, method: 'post', params: { reason }})
+}
+
+// 全局工作流运行统计
+export function getInstanceStats(params) {
+  return request({ url: '/workflow/lobster/instance/stats', method: 'get', params })
+}
+
+// ======== 全局死信队列管理 ========
+
+export function listAllDeadLetters(params) {
+  return request({ url: '/workflow/lobster/dead-letter/list', method: 'get', params })
+}
+
+export function retryAllDeadLetters() {
+  return request({ url: '/workflow/lobster/dead-letter/retry-all', method: 'post' })
+}
+
+export function getDeadLetterStats() {
+  return request({ url: '/workflow/lobster/dead-letter/stats', method: 'get' })
+}
+
+// ======== 全局节点审核 ========
+
+export function listAllEventAudits(params) {
+  return request({ url: '/workflow/lobster/event-audit/list', method: 'get', params })
+}
+
+export function approveEventAudit(id) {
+  return request({ url: `/workflow/lobster/event-audit/approve/${id}`, method: 'post' })
+}
+
+export function rejectEventAudit(id, data) {
+  return request({ url: `/workflow/lobster/event-audit/reject/${id}`, method: 'post', data })
+}
+
+export function getEventAuditDetail(id) {
+  return request({ url: `/workflow/lobster/event-audit/${id}`, method: 'get' })
+}
+
+// ======== 全局优化建议 ========
+
+export function listAllOptimization(params) {
+  return request({ url: '/workflow/lobster/optimization/list', method: 'get', params })
+}
+
+export function batchAuditOptimization(data) {
+  return request({ url: '/workflow/lobster/optimization/batch-audit', method: 'post', data })
+}
+
+export function getOptimizationStats() {
+  return request({ url: '/workflow/lobster/optimization/stats', method: 'get' })
+}
+
+// ======== 全局Token计费统计 ========
+
+export function listAllBillingRecords(params) {
+  return request({ url: '/workflow/lobster/billing/records', method: 'get', params })
+}
+
+export function getBillingStats() {
+  return request({ url: '/workflow/lobster/billing/stats', method: 'get' })
+}
+
+export function getTokenCoefficient() {
+  return request({ url: '/workflow/lobster/billing/token-coefficient', method: 'get' })
+}
+
+export function updateTokenCoefficient(data) {
+  return request({ url: '/workflow/lobster/billing/token-coefficient', method: 'put', data })
+}
+
+// ======== 敏感词库 ========
+
+export function listSensitiveWords(params) {
+  return request({ url: '/workflow/lobster/sensitive-words/list', method: 'get', params })
+}
+
+export function saveSensitiveWord(data) {
+  return request({ url: '/workflow/lobster/sensitive-words/save', method: 'post', data })
+}
+
+export function deleteSensitiveWord(id, params) {
+  return request({ url: `/workflow/lobster/sensitive-words/${id}`, method: 'delete', params })
+}
+
+export function checkSensitiveWord(data) {
+  return request({ url: '/workflow/lobster/sensitive-words/check', method: 'post', data })
+}
+
+// ======== 替换词库(禁用词 + 易错词) ========
+
+export function listReplaceWords(params) {
+  return request({ url: '/workflow/lobster/replace-words/list', method: 'get', params })
+}
+
+export function saveReplaceWord(data) {
+  return request({ url: '/workflow/lobster/replace-words/save', method: 'post', data })
+}
+
+export function deleteReplaceWord(id, params) {
+  return request({ url: `/workflow/lobster/replace-words/${id}`, method: 'delete', params })
+}

+ 23 - 0
adminui/src/api/admin/lobsterChannel.js

@@ -0,0 +1,23 @@
+import request from '@/utils/request'
+
+export function getTenantLobsterChannels(tenantId) {
+  return request({
+    url: `/admin/company/lobster-channels/${tenantId}`,
+    method: 'get'
+  })
+}
+
+export function saveTenantLobsterChannels(tenantId, data) {
+  return request({
+    url: `/admin/company/lobster-channels/${tenantId}`,
+    method: 'put',
+    data
+  })
+}
+
+export function testTenantLobsterChannel(tenantId, channelType) {
+  return request({
+    url: `/admin/company/lobster-channels/${tenantId}/test/${channelType}`,
+    method: 'post'
+  })
+}

+ 19 - 0
adminui/src/api/admin/lobsterExecutionConfig.js

@@ -0,0 +1,19 @@
+import request from '@/utils/request'
+
+const BASE = '/workflow/lobster/execution-config/tenant'
+
+/** 总后台租户执行策略(走 fs-saas-admin /workflow 代理) */
+export function getTenantLobsterExecutionConfig(tenantId) {
+  return request({
+    url: `${BASE}/${tenantId}`,
+    method: 'get'
+  })
+}
+
+export function saveTenantLobsterExecutionConfig(tenantId, data) {
+  return request({
+    url: `${BASE}/${tenantId}`,
+    method: 'post',
+    data
+  })
+}

+ 26 - 0
adminui/src/api/admin/lobsterSemanticKeywords.js

@@ -0,0 +1,26 @@
+import request from '@/utils/request'
+
+const BASE = '/workflow/lobster/tenant-keywords/tenant'
+
+export function getTenantLobsterSemanticKeywords(tenantId) {
+  return request({
+    url: `${BASE}/${tenantId}`,
+    method: 'get'
+  })
+}
+
+export function saveTenantLobsterSemanticKeywords(tenantId, data) {
+  return request({
+    url: `${BASE}/${tenantId}`,
+    method: 'post',
+    data
+  })
+}
+
+export function syncTenantLobsterSemanticKeywords(tenantId, data) {
+  return request({
+    url: `${BASE}/${tenantId}/sync-all`,
+    method: 'post',
+    data: data || {}
+  })
+}

+ 29 - 0
adminui/src/api/admin/moduleUsage.js

@@ -0,0 +1,29 @@
+import request from '@/utils/request'
+
+export function listModuleUsage(query) {
+  return request({ url: '/admin/module-usage/list', method: 'get', params: query })
+}
+
+export function listModuleUsageSummary(query) {
+  return request({ url: '/admin/module-usage/summary', method: 'get', params: query })
+}
+
+export function getProxyDetail(proxyId, statDate) {
+  return request({ url: `/admin/module-usage/proxy/${proxyId}`, method: 'get', params: { statDate } })
+}
+
+export function getTenantDetail(tenantId) {
+  return request({ url: `/admin/module-usage/tenant/${tenantId}`, method: 'get' })
+}
+
+export function refreshStatistics() {
+  return request({ url: '/admin/module-usage/refresh', method: 'post' })
+}
+
+export function refreshTenantStatistics(tenantId) {
+  return request({ url: `/admin/module-usage/refresh/${tenantId}`, method: 'post' })
+}
+
+export function exportModuleUsage(query) {
+  return request({ url: '/admin/module-usage/export', method: 'get', params: query })
+}

+ 56 - 0
adminui/src/api/admin/product.js

@@ -0,0 +1,56 @@
+import request from '@/utils/request'
+
+// 查询所有租户商品列表
+export function listAllProducts(query) {
+  return request({
+    url: '/admin/product/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 查询待审核商品列表
+export function listPendingProducts() {
+  return request({
+    url: '/admin/product/pending',
+    method: 'get'
+  })
+}
+
+// 根据租户ID查询商品列表
+export function listByCompany(companyId) {
+  return request({
+    url: '/admin/product/byCompany/' + companyId,
+    method: 'get'
+  })
+}
+
+// 获取商品详情
+export function getProductInfo(productId) {
+  return request({
+    url: '/admin/product/' + productId,
+    method: 'get'
+  })
+}
+
+// 审核商品
+export function auditProduct(productId, status, auditRemark) {
+  return request({
+    url: '/admin/product/audit/' + productId,
+    method: 'put',
+    params: { status, auditRemark }
+  })
+}
+
+// 导出商品
+export function exportAllProducts(query) {
+  return request({ url: '/admin/product/export', method: 'get', params: query })
+}
+
+// 获取商品统计信息
+export function getProductStatistics() {
+  return request({
+    url: '/admin/product/statistics',
+    method: 'get'
+  })
+}

+ 36 - 0
adminui/src/api/admin/proxy.js

@@ -0,0 +1,36 @@
+import request from '@/utils/request'
+
+// ======== 代理 CRUD ========
+
+export function listProxy(query) {
+  return request({ url: '/admin/proxy/list', method: 'get', params: query })
+}
+
+export function getProxy(proxyId) {
+  return request({ url: `/admin/proxy/${proxyId}`, method: 'get' })
+}
+
+export function addProxy(data) {
+  return request({ url: '/admin/proxy', method: 'post', data })
+}
+
+export function updateProxy(data) {
+  return request({ url: '/admin/proxy', method: 'put', data })
+}
+
+export function delProxy(proxyIds) {
+  return request({ url: `/admin/proxy/${proxyIds}`, method: 'delete' })
+}
+
+export function changeProxyStatus(proxyId, status) {
+  return request({ url: `/admin/proxy/changeStatus/${proxyId}`, method: 'put', params: { status } })
+}
+
+export function allEnabledProxies() {
+  return request({ url: '/admin/proxy/allEnabled', method: 'get' })
+}
+
+// 重置代理密码
+export function resetProxyPwd(proxyId, password) {
+  return request({ url: `/admin/proxy/resetPwd/${proxyId}`, method: 'put', data: { password } })
+}

+ 15 - 0
adminui/src/api/admin/proxyOperLog.js

@@ -0,0 +1,15 @@
+import request from '@/utils/request'
+
+// 查询代理操作日志列表
+export function listProxyOperLog(params) {
+  return request({
+    url: '/admin/proxyOperLog/list',
+    method: 'get',
+    params: params
+  })
+}
+
+// 导出代理操作日志
+export function exportProxyOperLog(query) {
+  return request({ url: '/admin/proxyOperLog/export', method: 'get', params: query })
+}

+ 27 - 0
adminui/src/api/admin/rechargeRecord.js

@@ -0,0 +1,27 @@
+import request from '@/utils/request'
+
+// 查询租户充值记录列表
+export function listRechargeRecords(query) {
+  return request({
+    url: '/admin/recharge-record/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 获取充値记录详情
+export function getRechargeRecord(id) {
+  return request({
+    url: '/admin/recharge-record/' + id,
+    method: 'get'
+  })
+}
+
+// 导出充値记录
+export function exportRechargeRecord(query) {
+  return request({
+    url: '/admin/recharge-record/export',
+    method: 'get',
+    params: query
+  })
+}

+ 34 - 0
adminui/src/api/admin/serviceCost.js

@@ -0,0 +1,34 @@
+import request from '@/utils/request'
+
+// ======== 服务成本配置 ========
+
+export function listServiceCost() {
+  return request({ url: '/admin/serviceCost/list', method: 'get' })
+}
+
+export function getServiceCostByType(serviceType) {
+  return request({ url: `/admin/serviceCost/${serviceType}`, method: 'get' })
+}
+
+export function updateServiceCost(data) {
+  return request({ url: '/admin/serviceCost', method: 'put', data })
+}
+
+export function getEnabledServiceTypes() {
+  return request({ url: '/admin/serviceCost/enabled', method: 'get' })
+}
+
+// ======== 代理定价 ========
+
+export function listServicePrice(query) {
+  return request({ url: '/admin/servicePrice/list', method: 'get', params: query })
+}
+
+export function batchSaveServicePrice(data) {
+  return request({ url: '/admin/servicePrice/batch', method: 'post', data })
+}
+
+// 代理定价批量保存(代理端接口)
+export function batchSaveProxyServicePrice(data) {
+  return request({ url: '/proxy/servicePrice/batch', method: 'put', data })
+}

+ 9 - 0
adminui/src/api/admin/storeOrder.js

@@ -0,0 +1,9 @@
+import request from '@/utils/request'
+
+export function listStoreOrder(query) {
+  return request({ url: '/admin/storeOrder/list', method: 'get', params: query })
+}
+
+export function exportStoreOrder(query) {
+  return request({ url: '/admin/storeOrder/export', method: 'get', params: query })
+}

+ 133 - 0
adminui/src/api/admin/sysCompany.js

@@ -0,0 +1,133 @@
+import request from '@/utils/request'
+
+// 获取租户下拉选项(总后台)
+export function listCompanyOptions() {
+  return request({
+    url: '/admin/company/options',
+    method: 'get'
+  })
+}
+
+// 查询所有租户列表
+export function listAllCompanies(query) {
+  return request({
+    url: '/admin/company/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 租户下拉列表(分页:pageNum、pageSize;搜索:tenantName;筛选:status 等)
+export function listAdminTenantList(query) {
+  return request({
+    url: '/admin/company/tenantList',
+    method: 'get',
+    params: query
+  })
+}
+
+// 获取租户详情
+export function getCompanyInfo(companyId) {
+  return request({
+    url: '/admin/company/' + companyId,
+    method: 'get'
+  })
+}
+
+// 禁用租户(TenantInfo: status=0 禁用)
+export function disableCompany(companyId) {
+  return request({
+    url: '/admin/company/status/' + companyId,
+    method: 'put',
+    params: { status: 0 }
+  })
+}
+
+// 启用租户(TenantInfo: status=1 启用)
+export function enableCompany(companyId) {
+  return request({
+    url: '/admin/company/status/' + companyId,
+    method: 'put',
+    params: { status: 1 }
+  })
+}
+
+// 获取租户统计信息
+export function getStatistics() {
+  return request({
+    url: '/admin/company/statistics',
+    method: 'get'
+  })
+}
+
+// 新增租户
+export function addCompany(data) {
+  return request({
+    url: '/admin/company',
+    method: 'post',
+    data: data
+  })
+}
+
+// 更新租户信息
+export function updateTenant(data) {
+  return request({
+    url: '/admin/company',
+    method: 'put',
+    data: data
+  })
+}
+
+// 龙虾行业字典(租户归属行业多选)
+export function listLobsterIndustryCatalog() {
+  return request({
+    url: '/admin/company/industry-catalog',
+    method: 'get'
+  })
+}
+
+// 租户充値/扣款
+export function rechargeCompany(companyId, data) {
+  return request({
+    url: '/admin/company/' + companyId + '/recharge',
+    method: 'post',
+    data: data
+  })
+}
+
+// 导出租户列表
+export function exportCompany(query) {
+  return request({
+    url: '/admin/company/export',
+    method: 'get',
+    params: query,
+    responseType: 'blob'
+  })
+}
+
+// 获取租户菜单树(管理端/销售端)
+export function getTenantMenuTree(companyId, flag) {
+  return request({
+    url: '/admin/company/' + companyId + '/menu',
+    method: 'post',
+    data: { flag: flag }
+  })
+}
+
+// 编辑租户菜单(提交当前完整勾选 menuId 列表,后端全量替换)
+export function editTenantMenu(companyId, data) {
+  return request({
+    url: '/admin/company/' + companyId + '/menu/edit',
+    method: 'post',
+    data: data
+  })
+}
+
+// 重置租户管理员密码
+export function resetTenantPwd(companyId, password) {
+  return request({
+    url: '/admin/company/' + companyId + '/resetPwd',
+    method: 'put',
+    data: { password }
+  })
+}

+ 45 - 0
adminui/src/api/admin/sysUser.js

@@ -0,0 +1,45 @@
+import request from '@/utils/request'
+
+// 查询管理员列表(sys_user 中 company_id IS NULL)
+export function listAdminUsers(query) {
+  return request({
+    url: '/admin/companyUser/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 获取管理员详情
+export function getAdminUserInfo(userId) {
+  return request({
+    url: '/admin/companyUser/' + userId,
+    method: 'get'
+  })
+}
+
+// 禁用管理员账户(status=1 停用)
+export function disableAdminUser(userId) {
+  return request({
+    url: '/admin/companyUser/status/' + userId,
+    method: 'put',
+    params: { status: '1' }
+  })
+}
+
+// 启用管理员账户(status=0 正常)
+export function enableAdminUser(userId) {
+  return request({
+    url: '/admin/companyUser/status/' + userId,
+    method: 'put',
+    params: { status: '0' }
+  })
+}
+
+// 导出管理员列表
+export function exportAdminUser(query) {
+  return request({
+    url: '/admin/companyUser/export',
+    method: 'get',
+    params: query
+  })
+}

+ 34 - 0
adminui/src/api/admin/tenantAiModel.js

@@ -0,0 +1,34 @@
+import request from '@/utils/request'
+
+/** 获取 EASYCALL 全部大模型下拉列表 */
+export function getAiModelOptions() {
+  return request({
+    url: '/admin/tenantAiModel/modelOptions',
+    method: 'get'
+  })
+}
+
+/** 查询租户已分配的大模型(租户库) */
+export function getTenantAiModels(tenantId) {
+  return request({
+    url: '/admin/tenantAiModel/tenant/' + tenantId,
+    method: 'get'
+  })
+}
+
+/** 保存租户可用大模型(租户库) */
+export function saveTenantAiModels(data) {
+  return request({
+    url: '/admin/tenantAiModel/save',
+    method: 'put',
+    data: data
+  })
+}
+
+/** 清空租户可用大模型(租户库) */
+export function clearTenantAiModels(tenantId) {
+  return request({
+    url: '/admin/tenantAiModel/tenant/' + tenantId,
+    method: 'delete'
+  })
+}

+ 52 - 0
adminui/src/api/admin/tenantGateway.js

@@ -0,0 +1,52 @@
+import request from '@/utils/request'
+
+/** 查询租户网关绑定列表 */
+export function listTenantGateway(query) {
+  return request({
+    url: '/admin/tenantGateway/list',
+    method: 'get',
+    params: query
+  })
+}
+
+/** 查询租户网关绑定详情 */
+export function getTenantGateway(id) {
+  return request({
+    url: '/admin/tenantGateway/' + id,
+    method: 'get'
+  })
+}
+
+/** 新增租户网关绑定 */
+export function addTenantGateway(data) {
+  return request({
+    url: '/admin/tenantGateway',
+    method: 'post',
+    data: data
+  })
+}
+
+/** 修改租户网关绑定 */
+export function updateTenantGateway(data) {
+  return request({
+    url: '/admin/tenantGateway',
+    method: 'put',
+    data: data
+  })
+}
+
+/** 删除租户网关绑定 */
+export function delTenantGateway(ids) {
+  return request({
+    url: '/admin/tenantGateway/' + ids,
+    method: 'delete'
+  })
+}
+
+/** 获取外呼网关下拉列表 */
+export function getGatewayOptions() {
+  return request({
+    url: '/admin/easyCall/gateway/list',
+    method: 'get'
+  })
+}

+ 34 - 0
adminui/src/api/admin/tenantNodeType.js

@@ -0,0 +1,34 @@
+import request from '@/utils/request'
+
+/** 获取节点类型模板下拉列表(主库) */
+export function getNodeTypeTemplateOptions() {
+  return request({
+    url: '/admin/tenantNodeType/templateOptions',
+    method: 'get'
+  })
+}
+
+/** 查询租户已配置的节点类型(租户库) */
+export function getTenantNodeTypes(tenantId) {
+  return request({
+    url: '/admin/tenantNodeType/tenant/' + tenantId,
+    method: 'get'
+  })
+}
+
+/** 保存租户可用节点类型(租户库) */
+export function saveTenantNodeTypes(data) {
+  return request({
+    url: '/admin/tenantNodeType/save',
+    method: 'put',
+    data: data
+  })
+}
+
+/** 清空租户节点类型(租户库) */
+export function clearTenantNodeTypes(tenantId) {
+  return request({
+    url: '/admin/tenantNodeType/tenant/' + tenantId,
+    method: 'delete'
+  })
+}

+ 44 - 0
adminui/src/api/admin/trafficPricing.js

@@ -0,0 +1,44 @@
+import request from '@/utils/request'
+
+// 查询租户流量定价列表
+export function listTrafficPricing(query) {
+  return request({
+    url: '/admin/traffic-pricing/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 查询流量定价详情
+export function getTrafficPricing(id) {
+  return request({
+    url: '/admin/traffic-pricing/' + id,
+    method: 'get'
+  })
+}
+
+// 新增流量定价
+export function addTrafficPricing(data) {
+  return request({
+    url: '/admin/traffic-pricing',
+    method: 'post',
+    data: data
+  })
+}
+
+// 更新流量定价
+export function updateTrafficPricing(data) {
+  return request({
+    url: '/admin/traffic-pricing',
+    method: 'put',
+    data: data
+  })
+}
+
+// 删除流量定价
+export function delTrafficPricing(id) {
+  return request({
+    url: '/admin/traffic-pricing/' + id,
+    method: 'delete'
+  })
+}

+ 10 - 0
adminui/src/api/admin/videoResource.js

@@ -0,0 +1,10 @@
+import request from '@/utils/request'
+
+// 查询所有租户视频资源列表
+export function listAllVideoResources(query) {
+  return request({
+    url: '/admin/videoResource/list',
+    method: 'get',
+    params: query
+  })
+}

+ 10 - 0
adminui/src/api/admin/voiceNumber.js

@@ -0,0 +1,10 @@
+import request from '@/utils/request'
+
+// 查询CID主叫号码列表(admin专用,走 /admin/ 前缀,路由到 fs-admin 8004)
+export function listCidNumber(query) {
+  return request({
+    url: '/admin/voice-number/list',
+    method: 'get',
+    params: query
+  })
+}

+ 20 - 0
adminui/src/api/admin/voiceOrder.js

@@ -0,0 +1,20 @@
+import request from '@/utils/request'
+
+// 查询通话套餐订单列表(admin专用,走 /admin/ 前缀,路由到 fs-admin 8004)
+export function listVoiceOrder(query) {
+  return request({
+    url: '/admin/voice-order/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 导出通话套餐订单
+export function exportVoiceOrder(query) {
+  return request({
+    url: '/admin/voice-order/export',
+    method: 'get',
+    params: query,
+    responseType: 'blob'
+  })
+}

+ 27 - 0
adminui/src/api/admin/voicePackage.js

@@ -0,0 +1,27 @@
+import request from '@/utils/request'
+
+// 查询通话套餐列表(admin专用,走 /admin/ 前缀,路由到 fs-admin 8004)
+export function listVoicePackage(query) {
+  return request({
+    url: '/admin/voice-package/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 查询通话套餐详情
+export function getVoicePackage(id) {
+  return request({
+    url: '/admin/voice-package/' + id,
+    method: 'get'
+  })
+}
+
+// 新增通话套餐
+export function addVoicePackage(data) {
+  return request({
+    url: '/admin/voice-package',
+    method: 'post',
+    data
+  })
+}

+ 55 - 0
adminui/src/api/admin/voiceSeat.js

@@ -0,0 +1,55 @@
+import request from '@/utils/request'
+
+export function listVoiceSeat(query) {
+  return request({
+    url: '/admin/voiceSeat/list',
+    method: 'get',
+    params: query
+  })
+}
+
+export function getVoiceSeat(id) {
+  return request({
+    url: '/admin/voiceSeat/' + id,
+    method: 'get'
+  })
+}
+
+export function batchCreateVoiceSeat(data) {
+  return request({
+    url: '/admin/voiceSeat/batchCreate',
+    method: 'post',
+    data: data
+  })
+}
+
+export function updateVoiceSeatStatus(data) {
+  return request({
+    url: '/admin/voiceSeat/status',
+    method: 'put',
+    data: data
+  })
+}
+
+export function batchUpdateVoiceSeatStatus(data) {
+  return request({
+    url: '/admin/voiceSeat/batchStatus',
+    method: 'put',
+    data: data
+  })
+}
+
+export function delVoiceSeat(ids) {
+  return request({
+    url: '/admin/voiceSeat/' + ids,
+    method: 'delete'
+  })
+}
+
+export function exportVoiceSeat(query) {
+  return request({
+    url: '/admin/voiceSeat/export',
+    method: 'get',
+    params: query
+  })
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов