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

feat: AI辅助诊断+诊所线下闭环模块+门店运营管理+药师/医生工作台增强

## AI辅助诊断
- 后端: DoctorWorkbenchController新增/aiDiagnosis端点,复用AiModelGateway统一LLM网关
- 自动组装患者上下文(症状+健康档案+检测数据+历史处方)作为prompt
- 前端: consultWorkbench新增AI辅助分析按钮和弹窗(saasdoctorui)

## 诊所线下闭环模块
- 挂号分诊: ClinicRegistration domain/mapper/controller + 前端页面(saasmgnui)
- 就诊叫号: 叫号大屏+下一位/重呼/过号/指定叫号
- 医生排班: DoctorSchedule domain/mapper/controller + 停诊/复诊/周复制
- 收费结算: ClinicBilling domain/mapper/controller + 汇总卡片+退款+多支付方式

## 门店运营管理(saasmgnui)
- 门店业绩落实到店员、交接班管理、到店接待、考勤打卡
- 企微客户看板、采购建议
- 处方流转闭环(发药确认)、效期库存管理

## 药师工作台增强(saaspharmacistui)
- IM图片消息传输、处方流转时间线、药品效期管理tab

## 医生工作台增强(saasdoctorui)
- 问诊图片传输、处方驳回告警、患者健康检测数据展示
- 用药反馈记录、患者健康档案

## SQL
- 菜单ID 31001-31018: 门店运营/药师/医生/诊所模块
- DDL: med_feedback/clinic_registration/doctor_schedule/clinic_billing
- 同步sys_menu/tenant_sys_menu/tenant_company_menu

## 编译验证: BUILD SUCCESS
云联一号 2 недель назад
Родитель
Сommit
a451e085a6
100 измененных файлов с 5032 добавлено и 1040 удалено
  1. 3 0
      .vscode/settings.json
  2. 2 2
      adminui/src/api/admin/aiModel.js
  3. 16 18
      adminui/src/api/admin/article.js
  4. 11 0
      adminui/src/api/admin/dashboard.js
  5. 6 4
      adminui/src/api/admin/live.js
  6. 2 2
      adminui/src/api/admin/lobsterExecutionConfig.js
  7. 12 16
      adminui/src/api/admin/product.js
  8. 10 0
      adminui/src/api/system/dataMigrate.js
  9. 8 0
      adminui/src/api/system/keyword.js
  10. 26 5
      adminui/src/components/InlineTenantSelector/index.vue
  11. 1 0
      adminui/src/store/getters.js
  12. 15 1
      adminui/src/store/modules/tenant.js
  13. 56 4
      adminui/src/views/admin/aiModel/index.vue
  14. 187 98
      adminui/src/views/admin/article/index.vue
  15. 511 28
      adminui/src/views/admin/cidConfig/index.vue
  16. 1 1
      adminui/src/views/admin/consumeRecord/index.vue
  17. 29 4
      adminui/src/views/admin/dashboard/index.vue
  18. 143 46
      adminui/src/views/admin/keywordManage/index.vue
  19. 295 100
      adminui/src/views/admin/live/index.vue
  20. 108 20
      adminui/src/views/admin/liveVideo/index.vue
  21. 193 103
      adminui/src/views/admin/product/index.vue
  22. 2 2
      adminui/src/views/admin/rechargeRecord/index.vue
  23. 47 5
      adminui/src/views/admin/smsCard/index.vue
  24. 141 38
      adminui/src/views/admin/storeOrder/index.vue
  25. 164 22
      adminui/src/views/admin/videoResource/index.vue
  26. 127 47
      adminui/src/views/admin/voice/index.vue
  27. 58 34
      adminui/src/views/admin/voiceApi/index.vue
  28. 63 3
      adminui/src/views/admin/voiceApiTenant/index.vue
  29. 1 0
      adminui/src/views/admin/withdrawalManage/index.vue
  30. 8 0
      adminui/src/views/saas/tenant/ExecutionConfigManage.vue
  31. 79 0
      adminui/src/views/system/dataMigrate/index.vue
  32. 5 77
      agentui/src/views/balance/recharge.vue
  33. 3 18
      agentui/src/views/profit/index.vue
  34. 1 14
      agentui/src/views/withdraw/index.vue
  35. 2 2
      java/fs-ad-api/src/main/java/com/fs/framework/config/FilterConfig.java
  36. 9 0
      java/fs-ad-api/src/main/resources/application.yml
  37. 2 2
      java/fs-ad-new-api/src/main/java/com/fs/framework/config/FilterConfig.java
  38. 2 1
      java/fs-admin/src/main/java/com/fs/admin/controller/AdminConsumeRecordController.java
  39. 120 0
      java/fs-admin/src/main/java/com/fs/admin/controller/AdminIndustryFieldSchemaController.java
  40. 3 2
      java/fs-admin/src/main/java/com/fs/admin/controller/AdminRechargeRecordController.java
  41. 3 2
      java/fs-admin/src/main/java/com/fs/admin/controller/AiModelAdminController.java
  42. 71 0
      java/fs-admin/src/main/java/com/fs/admin/controller/IndexController.java
  43. 5 5
      java/fs-admin/src/main/java/com/fs/admin/controller/audit/AdminLiveVideoController.java
  44. 12 11
      java/fs-admin/src/main/java/com/fs/admin/controller/audit/AdminStoreOrderAdminController.java
  45. 7 3
      java/fs-admin/src/main/java/com/fs/admin/controller/audit/AdminVideoResourceController.java
  46. 25 24
      java/fs-admin/src/main/java/com/fs/admin/controller/audit/ArticleAdminController.java
  47. 126 109
      java/fs-admin/src/main/java/com/fs/admin/controller/audit/LiveAdminController.java
  48. 19 18
      java/fs-admin/src/main/java/com/fs/admin/controller/audit/ProductAdminController.java
  49. 27 0
      java/fs-admin/src/main/java/com/fs/admin/vo/AdminDashboardVO.java
  50. 77 0
      java/fs-admin/src/main/java/com/fs/lobster/controller/LobsterAutoTagsController.java
  51. 55 0
      java/fs-admin/src/main/java/com/fs/lobster/controller/LobsterErpDeliveryController.java
  52. 116 0
      java/fs-admin/src/main/java/com/fs/lobster/controller/LobsterLiveCardController.java
  53. 7 8
      java/fs-admin/src/main/java/com/fs/web/controller/system/SysConfigController.java
  54. 89 0
      java/fs-admin/src/main/java/com/fs/web/controller/system/SysDataMigrateController.java
  55. 11 1
      java/fs-admin/src/main/java/com/fs/web/controller/system/SysKeywordController.java
  56. 8 6
      java/fs-admin/src/main/resources/logback.xml
  57. 64 0
      java/fs-ai-api/src/main/java/com/fs/ai/rag/controller/LlmController.java
  58. 4 0
      java/fs-ai-api/src/main/java/com/fs/ai/rag/service/LlmService.java
  59. 91 0
      java/fs-ai-api/src/main/java/com/fs/ai/rag/service/impl/LlmServiceImpl.java
  60. 2 2
      java/fs-ai-api/src/main/java/com/fs/framework/config/FilterConfig.java
  61. 1 1
      java/fs-ai-api/src/main/resources/application-common.yml
  62. 2 2
      java/fs-ai-call-task/src/main/java/com/fs/framework/config/FilterConfig.java
  63. 15 8
      java/fs-cid-workflow/src/main/java/com/fs/app/task/CidTask.java
  64. 29 14
      java/fs-cid-workflow/src/main/java/com/fs/app/task/TenantTaskRunner.java
  65. 2 2
      java/fs-cid-workflow/src/main/java/com/fs/framework/config/FilterConfig.java
  66. 18 0
      java/fs-comm-gateway/src/main/java/com/fs/comm/controller/ProbeHealthController.java
  67. 2 0
      java/fs-comm-gateway/src/main/java/com/fs/framework/config/SecurityConfig.java
  68. 7 3
      java/fs-comm-gateway/src/main/resources/application.yml
  69. 107 0
      java/fs-comm-gateway/src/main/resources/logback.xml
  70. 2 2
      java/fs-common-api/src/main/java/com/fs/framework/config/FilterConfig.java
  71. 75 0
      java/fs-common/src/main/java/com/fs/common/config/XssResponseAutoConfiguration.java
  72. 90 0
      java/fs-common/src/main/java/com/fs/common/filter/HttpRequestBodyUtils.java
  73. 11 13
      java/fs-common/src/main/java/com/fs/common/filter/RepeatableFilter.java
  74. 164 36
      java/fs-common/src/main/java/com/fs/common/filter/XssHttpServletRequestWrapper.java
  75. 214 0
      java/fs-common/src/main/java/com/fs/common/utils/html/HtmlXssUtil.java
  76. 119 0
      java/fs-common/src/main/java/com/fs/common/utils/html/RichTextXssFilter.java
  77. 74 0
      java/fs-common/src/main/java/com/fs/common/utils/html/XssValidateUtil.java
  78. 85 5
      java/fs-common/src/main/java/com/fs/common/utils/poi/ExcelUtil.java
  79. 211 0
      java/fs-common/src/main/java/com/fs/his/utils/PhoneUtil.java
  80. 2 0
      java/fs-common/src/main/resources/META-INF/spring.factories
  81. 98 0
      java/fs-company-app/src/main/java/com/fs/app/controller/FsUserCourseVideoController.java
  82. 5 7
      java/fs-company-app/src/main/java/com/fs/core/config/FilterConfig.java
  83. 169 0
      java/fs-doctor-app/src/main/java/com/fs/app/controller/DoctorWorkbenchController.java
  84. 2 2
      java/fs-doctor-app/src/main/java/com/fs/framework/config/FilterConfig.java
  85. 9 0
      java/fs-doctor-app/src/main/resources/application.yml
  86. 24 14
      java/fs-framework/src/main/java/com/fs/framework/aspectj/LogAspect.java
  87. 9 8
      java/fs-framework/src/main/java/com/fs/framework/config/FilterConfig.java
  88. 30 0
      java/fs-framework/src/main/java/com/fs/framework/datasource/TenantDataSourceContextHelper.java
  89. 2 2
      java/fs-ipad-task/src/main/java/com/fs/framework/config/FilterConfig.java
  90. 0 3
      java/fs-ipad-task/src/main/resources/application.yml
  91. 2 2
      java/fs-live-app/src/main/java/com/fs/framework/config/FilterConfig.java
  92. 90 0
      java/fs-pharmacist-app/src/main/java/com/fs/app/controller/PharmacistWorkbenchController.java
  93. 18 0
      java/fs-pharmacist-app/src/main/java/com/fs/app/controller/PrescribeAuditController.java
  94. 18 0
      java/fs-qw-api-msg/src/main/java/com/fs/app/controller/ProbeHealthController.java
  95. 18 0
      java/fs-qw-api/src/main/java/com/fs/app/controller/ProbeHealthController.java
  96. 2 2
      java/fs-qw-voice/src/main/java/com/fs/framework/config/FilterConfig.java
  97. 9 0
      java/fs-qw-voice/src/main/resources/application.yml
  98. 2 2
      java/fs-qwhook-msg/src/main/java/com/fs/framework/config/FilterConfig.java
  99. 2 2
      java/fs-qwhook-sop/src/main/java/com/fs/framework/config/FilterConfig.java
  100. 2 2
      java/fs-qwhook/src/main/java/com/fs/framework/config/FilterConfig.java

+ 3 - 0
.vscode/settings.json

@@ -0,0 +1,3 @@
+{
+    "java.compile.nullAnalysis.mode": "automatic"
+}

+ 2 - 2
adminui/src/api/admin/aiModel.js

@@ -5,8 +5,8 @@ const SCENE_BASE = '/admin/aiScene'
 
 // ─── 模型管理 ───
 
-export function listAiModel() {
-  return request({ url: BASE + '/list', method: 'get' })
+export function listAiModel(query) {
+  return request({ url: BASE + '/list', method: 'get', params: query })
 }
 
 export function getAiModel(id) {

+ 16 - 18
adminui/src/api/admin/article.js

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

+ 11 - 0
adminui/src/api/admin/dashboard.js

@@ -0,0 +1,11 @@
+import request from '@/utils/request'
+
+/**
+ * 获取总后台首页看板统计卡片数据
+ */
+export function getDashboardData() {
+  return request({
+    url: '/admin/index/dashboard',
+    method: 'get'
+  })
+}

+ 6 - 4
adminui/src/api/admin/live.js

@@ -18,10 +18,11 @@ export function listByCompany(companyId) {
 }
 
 // 获取直播详情
-export function getLiveInfo(liveId) {
+export function getLiveInfo(liveId, query) {
   return request({
     url: '/admin/live/' + liveId,
-    method: 'get'
+    method: 'get',
+    params: query
   })
 }
 
@@ -31,9 +32,10 @@ export function exportAllLives(query) {
 }
 
 // 获取直播统计信息
-export function getLiveStatistics() {
+export function getLiveStatistics(query) {
   return request({
     url: '/admin/live/statistics',
-    method: 'get'
+    method: 'get',
+    params: query
   })
 }

+ 2 - 2
adminui/src/api/admin/lobsterExecutionConfig.js

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

+ 12 - 16
adminui/src/api/admin/product.js

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

+ 10 - 0
adminui/src/api/system/dataMigrate.js

@@ -0,0 +1,10 @@
+import request from '@/utils/request'
+
+// 同步余额:流量、Token、红包 从DB到Redis
+export function syncBalance(data) {
+  return request({
+    url: '/system/datamigrate/syncBalance',
+    method: 'post',
+    data: data
+  })
+}

+ 8 - 0
adminui/src/api/system/keyword.js

@@ -50,4 +50,12 @@ export function exportKeyword(query) {
     method: 'get',
     params: query
   })
+}
+
+// 获取关键字页面元数据
+export function getKeywordMeta() {
+  return request({
+    url: '/system/keyword/meta',
+    method: 'get'
+  })
 }

+ 26 - 5
adminui/src/components/InlineTenantSelector/index.vue

@@ -28,6 +28,16 @@ export default {
     mode: {
       type: String,
       default: 'saas'
+    },
+    /** 无历史选中时是否默认选中第一个租户 */
+    autoSelectFirst: {
+      type: Boolean,
+      default: false
+    },
+    /** 页面作用域键(如路由 path),设置后各页面租户选择互不影响 */
+    scopeKey: {
+      type: String,
+      default: ''
     }
   },
   data() {
@@ -37,22 +47,33 @@ export default {
     }
   },
   created() {
-    // 从Vuex恢复上次选择
-    const saved = this.$store.getters.selectedTenantId
+    const saved = this.scopeKey
+      ? this.$store.getters.getPageTenantId(this.scopeKey)
+      : this.$store.getters.selectedTenantId
     if (saved) {
       this.selectedId = saved
     }
     this.loadTenants()
   },
   methods: {
+    syncTenantId(tenantId) {
+      if (this.scopeKey) {
+        this.$store.dispatch('tenant/setPageTenantId', { scopeKey: this.scopeKey, tenantId })
+      } else {
+        this.$store.dispatch('tenant/setTenantId', tenantId)
+      }
+    },
     loadTenants() {
       const loader = this.mode === 'admin'
         ? listCompanyOptions().then(response => response.data || [])
         : tenantList().then(response => response.rows || response.data || [])
       loader.then(list => {
         this.tenantList = list
+        if (!this.selectedId && this.autoSelectFirst && list.length > 0) {
+          this.selectedId = list[0].id
+        }
         if (this.selectedId) {
-          this.$store.dispatch('tenant/setTenantId', this.selectedId)
+          this.syncTenantId(this.selectedId)
           this.$emit('change', this.selectedId)
         }
       }).catch(() => {
@@ -60,12 +81,12 @@ export default {
       })
     },
     handleChange(val) {
-      this.$store.dispatch('tenant/setTenantId', val)
+      this.syncTenantId(val)
       this.$emit('change', val)
     },
     reset() {
       this.selectedId = null
-      this.$store.dispatch('tenant/setTenantId', null)
+      this.syncTenantId(null)
     }
   }
 }

+ 1 - 0
adminui/src/store/getters.js

@@ -15,5 +15,6 @@ const getters = {
   defaultRoutes:state => state.permission.defaultRoutes,
   sidebarRouters:state => state.permission.sidebarRouters,
   selectedTenantId: state => state.tenant.selectedTenantId,
+  getPageTenantId: state => scopeKey => (scopeKey ? state.tenant.pageTenantIds[scopeKey] : null) || null,
 }
 export default getters

+ 15 - 1
adminui/src/store/modules/tenant.js

@@ -1,16 +1,30 @@
 const state = {
-  selectedTenantId: null
+  selectedTenantId: null,
+  /** 按页面路径独立保存租户选择,避免内容审计等模块跨页联动 */
+  pageTenantIds: {}
 }
 
 const mutations = {
   SET_TENANT_ID: (state, tenantId) => {
     state.selectedTenantId = tenantId
+  },
+  SET_PAGE_TENANT_ID: (state, { scopeKey, tenantId }) => {
+    if (!scopeKey) return
+    if (tenantId == null || tenantId === '') {
+      delete state.pageTenantIds[scopeKey]
+    } else {
+      state.pageTenantIds[scopeKey] = tenantId
+    }
   }
 }
 
 const actions = {
   setTenantId({ commit }, tenantId) {
     commit('SET_TENANT_ID', tenantId)
+  },
+  setPageTenantId({ commit }, { scopeKey, tenantId }) {
+    commit('SET_PAGE_TENANT_ID', { scopeKey, tenantId })
+    commit('SET_TENANT_ID', tenantId)
   }
 }
 

+ 56 - 4
adminui/src/views/admin/aiModel/index.vue

@@ -22,6 +22,24 @@
       <el-tabs v-model="activeTab" @tab-click="handleTabClick">
         <!-- ===== 模型列表 Tab ===== -->
         <el-tab-pane label="模型列表" name="models">
+          <el-form :inline="true" size="small" class="model-query-form">
+            <el-form-item label="模型输出">
+              <el-select
+                v-model="modelQuery.modelType"
+                clearable
+                placeholder="全部"
+                style="width: 140px"
+                @change="loadModels"
+              >
+                <el-option label="通用" :value="0" />
+                <el-option label="生成视频" :value="1" />
+              </el-select>
+            </el-form-item>
+            <el-form-item>
+              <el-button type="primary" icon="el-icon-search" @click="loadModels">查询</el-button>
+              <el-button icon="el-icon-refresh" @click="resetModelQuery">重置</el-button>
+            </el-form-item>
+          </el-form>
           <el-table :data="modelList" v-loading="modelLoading" border size="small" row-key="id"
             style="width:100%">
             <el-table-column label="排序" width="70" align="center">
@@ -40,6 +58,13 @@
                 </el-tag>
               </template>
             </el-table-column>
+            <el-table-column label="模型输出" width="100" align="center">
+              <template slot-scope="{ row }">
+                <el-tag :type="Number(row.modelType) === 1 ? 'warning' : 'info'" size="small">
+                  {{ modelTypeLabel(row.modelType) }}
+                </el-tag>
+              </template>
+            </el-table-column>
             <el-table-column prop="apiEndpoint" label="API地址" min-width="200" show-overflow-tooltip />
             <el-table-column prop="maxTokens" label="MaxToken" width="90" align="center" />
             <el-table-column prop="temperature" label="温度" width="70" align="center" />
@@ -254,6 +279,12 @@
             文本场景需 text;image_recognition 场景需 vision
           </div>
         </el-form-item>
+        <el-form-item label="模型输出" prop="modelType">
+          <el-radio-group v-model="modelForm.modelType">
+            <el-radio :label="0">通用</el-radio>
+            <el-radio :label="1">生成视频</el-radio>
+          </el-radio-group>
+        </el-form-item>
         <el-form-item label="状态">
           <el-switch v-model="modelForm.status" :active-value="1" :inactive-value="0" />
         </el-form-item>
@@ -336,12 +367,16 @@ export default {
       // 模型
       modelLoading: false,
       modelList: [],
+      modelQuery: {
+        modelType: undefined
+      },
       modelDialogVisible: false,
       modelDialogTitle: '',
       modelForm: {
         id: null, modelName: '', providerCode: '', modelIdentifier: '',
         apiEndpoint: '', apiKey: '', maxTokens: 4096, temperature: 0.7,
-        sortOrder: 0, status: 1, inputModalities: 'text', inputModalitiesList: ['text']
+        sortOrder: 0, status: 1, inputModalities: 'text', inputModalitiesList: ['text'],
+        modelType: 0
       },
       modelRules: {
         modelName: [{ required: true, message: '请输入模型名称', trigger: 'blur' }],
@@ -401,7 +436,11 @@ export default {
     async loadModels() {
       this.modelLoading = true
       try {
-        const res = await listAiModel()
+        const params = {}
+        if (this.modelQuery.modelType !== undefined && this.modelQuery.modelType !== null && this.modelQuery.modelType !== '') {
+          params.modelType = this.modelQuery.modelType
+        }
+        const res = await listAiModel(params)
         this.modelList = this.sortByOrder(res.data || [], 'sortOrder')
       } catch (e) {
         this.$message.error('获取模型列表失败')
@@ -409,12 +448,17 @@ export default {
         this.modelLoading = false
       }
     },
+    resetModelQuery() {
+      this.modelQuery.modelType = undefined
+      this.loadModels()
+    },
     handleAddModel() {
       this.modelDialogTitle = '新增模型'
       this.modelForm = {
         id: null, modelName: '', providerCode: '', modelIdentifier: '',
         apiEndpoint: '', apiKey: '', maxTokens: 4096, temperature: 0.7,
-        sortOrder: 0, status: 1, inputModalities: 'text', inputModalitiesList: ['text']
+        sortOrder: 0, status: 1, inputModalities: 'text', inputModalitiesList: ['text'],
+        modelType: 0
       }
       this.modelDialogVisible = true
       this.$nextTick(() => { this.$refs.modelFormRef && this.$refs.modelFormRef.resetFields() })
@@ -427,6 +471,7 @@ export default {
         const data = { ...(res.data || row) }
         data.inputModalitiesList = this.modalityTags(data.inputModalities)
         if (!data.inputModalitiesList.length) data.inputModalitiesList = ['text']
+        if (data.modelType === undefined || data.modelType === null) data.modelType = 0
         this.modelForm = data
       } catch (e) {
         this.$message.error('获取模型详情失败')
@@ -438,6 +483,7 @@ export default {
         await this.$refs.modelFormRef.validate()
         const payload = { ...this.modelForm }
         payload.inputModalities = (payload.inputModalitiesList || ['text']).join(',')
+        if (payload.modelType === undefined || payload.modelType === null) payload.modelType = 0
         delete payload.inputModalitiesList
         if (this.modelForm.id) {
           await updateAiModel(this.modelForm.id, payload)
@@ -492,10 +538,15 @@ export default {
       this.modelForm = {
         id: null, modelName: '', providerCode: '', modelIdentifier: '',
         apiEndpoint: '', apiKey: '', maxTokens: 4096, temperature: 0.7,
-        sortOrder: 0, status: 1
+        sortOrder: 0, status: 1, inputModalities: 'text', inputModalitiesList: ['text'],
+        modelType: 0
       }
     },
 
+    modelTypeLabel(modelType) {
+      return Number(modelType) === 1 ? '生成视频' : '通用'
+    },
+
     // ─── 场景 ───
     async loadScenes() {
       this.sceneLoading = true
@@ -640,6 +691,7 @@ export default {
 
 <style scoped>
 .card-header { display: flex; justify-content: space-between; align-items: center; }
+.model-query-form { margin-bottom: 12px; }
 .sub-header { font-weight: 600; font-size: 14px; }
 .scene-list-card { max-height: 600px; overflow-y: auto; }
 .scene-item {

+ 187 - 98
adminui/src/views/admin/article/index.vue

@@ -2,32 +2,38 @@
   <div class="app-container">
     <el-card shadow="never" class="mb16 filter-card">
       <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="100px" size="small" class="list-search-form">
-      <el-form-item label="租户名称" prop="tenantId">
-        <inline-tenant-selector :key="tenantSelectorKey" @change="handleTenantChange" />
-      </el-form-item>
-      <el-form-item label="文章标题" prop="articleTitle">
-        <el-input
-          v-model="queryParams.articleTitle"
-          placeholder="请输入文章标题"
-          clearable
-          size="small"
-          @keyup.enter.native="handleQuery"
-        />
-      </el-form-item>
-      <el-form-item label="文章状态" prop="status">
-        <el-select v-model="queryParams.status" placeholder="请选择文章状态" clearable size="small">
-          <el-option
-            v-for="item in statusOptions"
-            :key="item.value"
-            :label="item.label"
-            :value="item.value">
-          </el-option>
-        </el-select>
-      </el-form-item>
-      <el-form-item>
-        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
-        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
-      </el-form-item>
+        <el-form-item label="租户名称" prop="tenantId">
+          <inline-tenant-selector
+            mode="admin"
+            auto-select-first
+            :scope-key="$route.path"
+            :key="tenantSelectorKey"
+            @change="handleTenantChange"
+          />
+        </el-form-item>
+        <el-form-item label="文章标题" prop="articleTitle">
+          <el-input
+            v-model="queryParams.articleTitle"
+            placeholder="请输入文章标题"
+            clearable
+            size="small"
+            @keyup.enter.native="handleQuery"
+          />
+        </el-form-item>
+        <el-form-item label="审计状态" prop="status">
+          <el-select v-model="queryParams.status" placeholder="请选择审计状态" clearable size="small">
+            <el-option
+              v-for="item in statusOptions"
+              :key="item.value"
+              :label="item.label"
+              :value="item.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
+          <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
+        </el-form-item>
       </el-form>
     </el-card>
 
@@ -58,7 +64,7 @@
           plain
           icon="el-icon-bar-chart"
           size="mini"
-          @click="showStatisticsDialog"
+          @click="handleStatistics"
         >文章统计</el-button>
       </el-col>
       <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
@@ -66,33 +72,43 @@
 
     <el-table border size="small" style="width:100%" v-loading="loading" :data="articleList">
       <el-table-column type="selection" width="55" align="center" />
-      <el-table-column label="租户名称" align="center" prop="companyName" min-width="120" />
-      <el-table-column label="文章标题" align="center" prop="articleTitle" min-width="160" show-overflow-tooltip />
-      <el-table-column label="文章分类" align="center" prop="categoryName" min-width="100" />
-      <el-table-column label="作者" align="center" prop="author" min-width="90" />
-      <el-table-column label="创建时间" align="center" prop="createTime" min-width="150" />
-      <el-table-column label="文章状态" align="center" prop="status" min-width="90">
+      <el-table-column label="所属租户" align="center" prop="companyName" min-width="120" show-overflow-tooltip />
+      <el-table-column label="文章标题" align="center" min-width="160" show-overflow-tooltip>
         <template slot-scope="scope">
-          <el-tag v-if="scope.row.status == 0" type="warning">待审计</el-tag>
-          <el-tag v-else-if="scope.row.status == 1" type="success">已通过</el-tag>
-          <el-tag v-else type="error">已拒绝</el-tag>
+          <span>{{ scope.row.title || '-' }}</span>
         </template>
       </el-table-column>
-      <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
+      <el-table-column label="文章分类" align="center" min-width="100" show-overflow-tooltip>
         <template slot-scope="scope">
-          <el-button
-            size="mini"
-            type="text"
-            icon="el-icon-search"
-            plain
-            @click="handleView(scope.row)"
-          >详情</el-button>
-          <template v-if="scope.row.status == 0">
+          <span>{{ scope.row.categoryName || '-' }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="浏览数" align="center" width="80">
+        <template slot-scope="scope">
+          <span>{{ scope.row.views != null ? scope.row.views : '-' }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="创建时间" align="center" min-width="150">
+        <template slot-scope="scope">
+          <span>{{ formatDateTime(scope.row.createTime) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="审计状态" align="center" width="100">
+        <template slot-scope="scope">
+          <el-tag v-if="toNum(scope.row.status) === 0" type="warning" size="mini">待审计</el-tag>
+          <el-tag v-else-if="toNum(scope.row.status) === 1" type="success" size="mini">已通过</el-tag>
+          <el-tag v-else-if="toNum(scope.row.status) === 2" type="danger" size="mini">已拒绝</el-tag>
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="200" fixed="right">
+        <template slot-scope="scope">
+          <el-button size="mini" type="text" icon="el-icon-view" @click="handleView(scope.row)">详情</el-button>
+          <template v-if="toNum(scope.row.status) === 0">
             <el-button
               size="mini"
               type="text"
               icon="el-icon-check"
-              plain
               @click="handleAudit(scope.row, 1)"
               v-hasPermi="['admin:article:audit']"
             >通过</el-button>
@@ -100,7 +116,6 @@
               size="mini"
               type="text"
               icon="el-icon-close"
-              plain
               @click="handleAudit(scope.row, 2)"
               v-hasPermi="['admin:article:audit']"
             >拒绝</el-button>
@@ -109,7 +124,6 @@
             size="mini"
             type="text"
             icon="el-icon-delete"
-            plain
             @click="handleDelete(scope.row)"
             v-hasPermi="['admin:article:remove']"
           >删除</el-button>
@@ -118,7 +132,7 @@
     </el-table>
 
     <pagination
-      v-show="total>0"
+      v-show="total > 0"
       :total="total"
       :page.sync="queryParams.pageNum"
       :limit.sync="queryParams.pageSize"
@@ -126,29 +140,40 @@
     />
 
     <el-dialog title="文章详情" :visible.sync="viewOpen" width="800px" append-to-body>
-      <el-form :model="viewForm" label-width="100px">
-        <el-form-item label="租户名称">
-          <span>{{ viewForm.companyName }}</span>
+      <el-form :model="viewForm" label-width="100px" size="small">
+        <el-form-item label="所属租户">
+          <span>{{ viewForm.companyName || '-' }}</span>
         </el-form-item>
         <el-form-item label="文章标题">
-          <span>{{ viewForm.articleTitle }}</span>
+          <span>{{ viewForm.title || '-' }}</span>
         </el-form-item>
         <el-form-item label="文章分类">
-          <span>{{ viewForm.categoryName }}</span>
+          <span>{{ viewForm.categoryName || '-' }}</span>
         </el-form-item>
-        <el-form-item label="作者">
-          <span>{{ viewForm.author }}</span>
+        <el-form-item label="封面图">
+          <el-image
+            v-if="viewForm.imageUrl"
+            style="width: 120px; height: 90px; border-radius: 6px;"
+            :src="viewForm.imageUrl"
+            fit="cover"
+          />
+          <span v-else>-</span>
         </el-form-item>
         <el-form-item label="文章内容">
-          <div v-html="viewForm.content"></div>
+          <div v-if="viewForm.contents" v-html="viewForm.contents"></div>
+          <span v-else>-</span>
+        </el-form-item>
+        <el-form-item label="浏览数">
+          <span>{{ viewForm.views != null ? viewForm.views : '-' }}</span>
         </el-form-item>
         <el-form-item label="创建时间">
-          <span>{{ viewForm.createTime }}</span>
+          <span>{{ formatDateTime(viewForm.createTime) }}</span>
         </el-form-item>
-        <el-form-item label="文章状态">
-          <el-tag v-if="viewForm.status == 0" type="warning">待审计</el-tag>
-          <el-tag v-else-if="viewForm.status == 1" type="success">已通过</el-tag>
-          <el-tag v-else type="error">已拒绝</el-tag>
+        <el-form-item label="审计状态">
+          <el-tag v-if="toNum(viewForm.status) === 0" type="warning" size="mini">待审计</el-tag>
+          <el-tag v-else-if="toNum(viewForm.status) === 1" type="success" size="mini">已通过</el-tag>
+          <el-tag v-else-if="toNum(viewForm.status) === 2" type="danger" size="mini">已拒绝</el-tag>
+          <span v-else>-</span>
         </el-form-item>
       </el-form>
     </el-dialog>
@@ -156,7 +181,7 @@
     <el-dialog :title="auditTitle" :visible.sync="auditOpen" width="400px" append-to-body>
       <el-form ref="auditForm" :model="auditForm" label-width="80px">
         <el-form-item label="审计备注">
-          <el-textarea v-model="auditForm.auditRemark" placeholder="请输入审计备注" :rows="3" />
+          <el-input v-model="auditForm.auditRemark" type="textarea" placeholder="请输入审计备注" :rows="3" />
         </el-form-item>
       </el-form>
       <div slot="footer" class="dialog-footer">
@@ -165,29 +190,29 @@
     </el-dialog>
 
     <el-dialog title="文章统计" :visible.sync="showStatistics" width="600px" append-to-body>
-      <el-card>
+      <el-card v-loading="statisticsLoading">
         <el-row :gutter="20">
           <el-col :span="12">
             <div class="stat-item">
-              <span class="stat-value">{{ statistics.totalCount }}</span>
+              <span class="stat-value">{{ statistics.totalCount || 0 }}</span>
               <span class="stat-label">文章总数</span>
             </div>
           </el-col>
           <el-col :span="12">
             <div class="stat-item">
-              <span class="stat-value">{{ statistics.pendingCount }}</span>
+              <span class="stat-value">{{ statistics.pendingCount || 0 }}</span>
               <span class="stat-label">待审计</span>
             </div>
           </el-col>
           <el-col :span="12">
             <div class="stat-item">
-              <span class="stat-value">{{ statistics.approvedCount }}</span>
+              <span class="stat-value">{{ statistics.approvedCount || 0 }}</span>
               <span class="stat-label">已通过</span>
             </div>
           </el-col>
           <el-col :span="12">
             <div class="stat-item">
-              <span class="stat-value">{{ statistics.rejectedCount }}</span>
+              <span class="stat-value">{{ statistics.rejectedCount || 0 }}</span>
               <span class="stat-label">已拒绝</span>
             </div>
           </el-col>
@@ -199,6 +224,7 @@
 
 <script>
 import { listAllArticles, listPendingArticles, getArticleInfo, auditArticle, deleteArticle, getArticleStatistics, exportAllArticles } from '@/api/admin/article'
+import { listCompanyOptions } from '@/api/admin/sysCompany'
 import InlineTenantSelector from '@/components/InlineTenantSelector'
 
 export default {
@@ -206,11 +232,13 @@ export default {
   components: { InlineTenantSelector },
   data() {
     return {
-      loading: true,
+      loading: false,
       exportLoading: false,
+      statisticsLoading: false,
       showSearch: true,
       total: 0,
       articleList: [],
+      tenantOptions: [],
       tenantSelectorKey: 0,
       statistics: {
         totalCount: 0,
@@ -243,37 +271,78 @@ export default {
     }
   },
   created() {
-    this.getList()
+    this.loadTenantOptions()
   },
   methods: {
+    toNum(value) {
+      if (value === null || value === undefined || value === '') return null
+      const num = Number(value)
+      return Number.isNaN(num) ? null : num
+    },
+    formatDateTime(value) {
+      if (!value) return '-'
+      if (typeof value === 'string') return value
+      if (Array.isArray(value) && value.length >= 3) {
+        const [year, month, day, hour = 0, minute = 0, second = 0] = value
+        const pad = n => String(n).padStart(2, '0')
+        return `${year}-${pad(month)}-${pad(day)} ${pad(hour)}:${pad(minute)}:${pad(second)}`
+      }
+      return String(value)
+    },
+    loadTenantOptions() {
+      listCompanyOptions().then(response => {
+        this.tenantOptions = response.data || []
+      })
+    },
+    resolveTenantName(tenantId) {
+      if (tenantId == null) return '-'
+      const tenant = this.tenantOptions.find(item => item.id === tenantId)
+      return tenant ? (tenant.tenantName || tenant.companyName || String(tenantId)) : String(tenantId)
+    },
+    mapRow(item) {
+      const tenantId = this.queryParams.tenantId || this.$tenantId
+      return {
+        ...item,
+        companyName: this.resolveTenantName(tenantId)
+      }
+    },
+    buildQueryParams() {
+      return this.$withTenant({ ...this.queryParams })
+    },
+    ensureTenant() {
+      if (!this.queryParams.tenantId && this.$tenantId) {
+        this.queryParams.tenantId = this.$tenantId
+      }
+      return !!(this.queryParams.tenantId || this.$tenantId)
+    },
     getList() {
-      if (!this.queryParams.tenantId) {
+      if (!this.ensureTenant()) {
         this.articleList = []
         this.total = 0
         this.loading = false
         return
       }
       this.loading = true
-      listAllArticles(this.queryParams).then(response => {
-        this.articleList = response.rows
-        this.total = response.total
+      listAllArticles(this.buildQueryParams()).then(response => {
+        this.articleList = (response.rows || []).map(item => this.mapRow(item))
+        this.total = response.total || 0
         this.loading = false
-      })
+      }).catch(() => { this.loading = false })
     },
     handlePending() {
-      if (!this.queryParams.tenantId) {
+      if (!this.ensureTenant()) {
         this.$message.warning('请先选择租户')
         return
       }
       this.loading = true
-      listPendingArticles().then(response => {
-        this.articleList = response.rows
-        this.total = response.total
+      listPendingArticles(this.buildQueryParams()).then(response => {
+        this.articleList = (response.rows || []).map(item => this.mapRow(item))
+        this.total = response.total || 0
         this.loading = false
-      })
+      }).catch(() => { this.loading = false })
     },
     handleQuery() {
-      if (!this.queryParams.tenantId) {
+      if (!this.ensureTenant()) {
         this.$message.warning('请先选择租户')
         return
       }
@@ -281,22 +350,33 @@ export default {
       this.getList()
     },
     handleTenantChange(val) {
+      this.queryParams.tenantId = val || null
+      this.queryParams.pageNum = 1
       if (val) {
-        this.queryParams.tenantId = val
-        this.handleQuery()
+        this.getList()
+      } else {
+        this.articleList = []
+        this.total = 0
       }
     },
     resetQuery() {
       this.resetForm('queryForm')
+      this.queryParams.articleTitle = null
+      this.queryParams.status = null
+      this.queryParams.pageNum = 1
       this.articleList = []
       this.total = 0
       this.queryParams.tenantId = null
-      this.$store.dispatch('tenant/setTenantId', null)
+      this.$store.dispatch('tenant/setPageTenantId', { scopeKey: this.$route.path, tenantId: null })
       this.tenantSelectorKey++
     },
     handleView(row) {
-      getArticleInfo(row.articleId).then(response => {
-        this.viewForm = response.data
+      getArticleInfo(row.articleId, this.buildQueryParams()).then(response => {
+        const data = response.data || {}
+        this.viewForm = {
+          ...data,
+          companyName: this.resolveTenantName(this.queryParams.tenantId || this.$tenantId)
+        }
         this.viewOpen = true
       })
     },
@@ -304,47 +384,54 @@ export default {
       this.auditTitle = status === 1 ? '通过文章' : '拒绝文章'
       this.auditForm = {
         articleId: row.articleId,
-        status: status,
+        status,
         auditRemark: ''
       }
       this.auditOpen = true
     },
     submitAudit() {
-      auditArticle(this.auditForm.articleId, this.auditForm.status, this.auditForm.auditRemark).then(response => {
+      auditArticle(
+        this.auditForm.articleId,
+        this.auditForm.status,
+        this.auditForm.auditRemark,
+        this.buildQueryParams()
+      ).then(() => {
         this.$message.success(this.auditForm.status === 1 ? '通过成功' : '拒绝成功')
         this.auditOpen = false
         this.getList()
       })
     },
     handleDelete(row) {
-      this.$confirm('确定删除文章 [' + row.articleTitle + '] 吗?', '提示', {
+      this.$confirm('确定删除文章 [' + (row.title || row.articleId) + '] 吗?', '提示', {
         confirmButtonText: '确定',
         cancelButtonText: '取消',
         type: 'warning'
       }).then(() => {
-        deleteArticle(row.articleId).then(response => {
+        deleteArticle(row.articleId, this.buildQueryParams()).then(() => {
           this.$message.success('删除成功')
           this.getList()
         })
       })
     },
-    showStatisticsDialog() {
-      if (!this.queryParams.tenantId) {
+    handleStatistics() {
+      if (!this.ensureTenant()) {
         this.$message.warning('请先选择租户')
         return
       }
-      getArticleStatistics().then(response => {
-        this.statistics = response.data
-        this.showStatistics = true
-      })
+      this.statisticsLoading = true
+      this.showStatistics = true
+      getArticleStatistics(this.buildQueryParams()).then(response => {
+        this.statistics = response.data || {}
+        this.statisticsLoading = false
+      }).catch(() => { this.statisticsLoading = false })
     },
     handleExport() {
-      if (!this.queryParams.tenantId) {
+      if (!this.ensureTenant()) {
         this.$message.warning('请先选择租户')
         return
       }
       this.exportLoading = true
-      exportAllArticles(this.queryParams).then(response => {
+      exportAllArticles(this.buildQueryParams()).then(response => {
         this.download(response.msg)
         this.exportLoading = false
       }).catch(() => { this.exportLoading = false })
@@ -359,6 +446,7 @@ export default {
   padding: 20px;
   background: #f5f7fa;
   border-radius: 8px;
+  margin-bottom: 16px;
 }
 .stat-value {
   font-size: 24px;
@@ -371,6 +459,7 @@ export default {
   font-size: 14px;
   color: #606266;
 }
+.mb8 { margin-bottom: 8px; }
 .mb16 { margin-bottom: 16px; }
 .filter-card { padding-bottom: 0; }
 </style>

+ 511 - 28
adminui/src/views/admin/cidConfig/index.vue

@@ -48,17 +48,142 @@
             <el-input v-model="form.modelName" placeholder="请输入DeepSeekChat模型名称"></el-input>
           </el-form-item>
         </el-card>
+
+        <el-card shadow="never" class="section-card">
+          <div slot="header"><span>EasyCall意向评级配置</span></div>
+          <el-form-item label="是否启用系统意向度评级" prop="enabled">
+            <el-switch v-model="intentForm.enabled"></el-switch>
+            <span class="tip-text">启用后由本系统配置的模型进行意向评级;失败时沿用回调现有重试与未知意向度兜底</span>
+          </el-form-item>
+          <template v-if="intentForm.enabled">
+            <el-form-item label="当前启用模型" prop="activeCode">
+              <el-select
+                v-model="intentForm.activeCode"
+                placeholder="请选择启用的评级模型"
+                clearable
+                style="width: 360px"
+              >
+                <el-option
+                  v-for="item in intentForm.models"
+                  :key="item.code"
+                  :label="item.name ? item.name + ' (' + item.code + ')' : item.code"
+                  :value="item.code"
+                />
+              </el-select>
+              <span class="tip-text">同时只能启用一个评级模型</span>
+            </el-form-item>
+          </template>
+          <el-form-item label="评级模型列表" class="model-list-item">
+            <el-alert
+              type="info"
+              :closable="false"
+              show-icon
+              class="intent-save-alert"
+              title="新增/编辑/删除模型后,需点击页面底部「保存配置」才会写入数据库并对外呼回调生效;弹窗「确定」仅更新下方列表。"
+            />
+            <div class="model-list-toolbar">
+              <el-button type="primary" size="mini" icon="el-icon-plus" @click="openModelDialog()">新增模型</el-button>
+            </div>
+            <el-table
+              :data="intentForm.models"
+              border
+              size="small"
+              class="intent-model-table"
+              empty-text="暂无评级模型,请点击上方新增"
+            >
+              <el-table-column label="启用" width="88" align="center" class-name="enable-col">
+                <template slot-scope="scope">
+                  <el-radio
+                    v-model="intentForm.activeCode"
+                    :label="scope.row.code"
+                    class="enable-radio-only"
+                  />
+                </template>
+              </el-table-column>
+              <el-table-column label="编码" prop="code" min-width="140" align="center" show-overflow-tooltip />
+              <el-table-column label="名称" prop="name" min-width="108" align="center" show-overflow-tooltip />
+              <el-table-column label="Provider类型" min-width="128" align="center" show-overflow-tooltip>
+                <template slot-scope="scope">
+                  {{ formatProviderType(scope.row.providerType) }}
+                </template>
+              </el-table-column>
+              <el-table-column label="服务地址" prop="serverUrl" min-width="200" align="center" show-overflow-tooltip />
+              <el-table-column label="模型名称" prop="modelName" min-width="140" align="center" show-overflow-tooltip />
+              <el-table-column label="超时(秒)" prop="timeoutSec" width="96" align="center" />
+              <el-table-column label="操作" width="112" align="center">
+                <template slot-scope="scope">
+                  <el-button type="text" size="mini" @click="openModelDialog(scope.row, scope.$index)">编辑</el-button>
+                  <el-button type="text" size="mini" class="danger-text" @click="removeModel(scope.$index)">删除</el-button>
+                </template>
+              </el-table-column>
+            </el-table>
+          </el-form-item>
+        </el-card>
       </el-form>
-      <div style="text-align: center; margin-top: 20px; padding-bottom: 20px;">
+      <div class="page-save-bar">
+        <p class="save-tip-text">修改本页任意配置(含评级模型、开关等)后,请点击「保存配置」才会生效。</p>
         <el-button type="primary" size="medium" @click="submitForm" :loading="submitLoading">保存配置</el-button>
       </div>
     </el-card>
+
+    <el-dialog :title="modelDialogTitle" :visible.sync="modelDialogVisible" width="640px" append-to-body @closed="resetModelForm">
+      <el-form ref="modelFormRef" :model="modelForm" :rules="modelRules" label-width="120px">
+        <el-form-item label="编码" prop="code">
+          <el-input v-model="modelForm.code" placeholder="唯一标识,如 DoubaoChat" :disabled="modelEditIndex >= 0" />
+        </el-form-item>
+        <el-form-item label="名称" prop="name">
+          <el-input v-model="modelForm.name" placeholder="展示名称,如 豆包意向评级" />
+        </el-form-item>
+        <el-form-item label="Provider类型" prop="providerType">
+          <el-select
+            v-model="modelForm.providerType"
+            placeholder="请选择"
+            style="width: 100%"
+            @change="onModelProviderTypeChange"
+          >
+            <el-option label="豆包 Responses API" value="doubao_responses" />
+            <el-option label="DeepSeek-V4 Responses API" value="deepseek_v4_responses" />
+            <!-- <el-option label="OpenAI 兼容 Chat" value="openai_chat" /> -->
+          </el-select>
+        </el-form-item>
+        <el-form-item label="服务地址" prop="serverUrl">
+          <el-input
+            v-model="modelForm.serverUrl"
+            :placeholder="isVolcesResponsesProvider(modelForm.providerType)
+              ? DEFAULT_VOLCES_RESPONSES_URL
+              : 'API 地址'"
+          />
+        </el-form-item>
+        <el-form-item label="API Key" prop="apiKey">
+          <el-input v-model="modelForm.apiKey" placeholder="API Key" show-password />
+        </el-form-item>
+        <el-form-item label="模型名称" prop="modelName">
+          <el-input v-model="modelForm.modelName" placeholder="模型名称或 Endpoint ID" />
+        </el-form-item>
+        <el-form-item label="超时(秒)" prop="timeoutSec">
+          <el-input-number v-model="modelForm.timeoutSec" :min="10" :max="600" :step="10" :precision="0" style="width: 100%" />
+          <span class="tip-text">HTTP 读超时;带 reasoning 的模型(豆包/DeepSeek-V4 等)建议 300 秒(5 分钟)以上,最大 600 秒</span>
+        </el-form-item>
+      </el-form>
+      <p class="dialog-save-tip">「确定」仅将模型写入下方列表;请点击页面底部「保存配置」后才会写入数据库并生效。</p>
+      <div slot="footer">
+        <el-button @click="modelDialogVisible = false">取 消</el-button>
+        <el-button type="primary" @click="submitModelForm">确 定</el-button>
+      </div>
+    </el-dialog>
   </div>
 </template>
 
 <script>
 import { getConfigByKey, updateConfigByKey } from '@/api/system/config'
 
+/** 豆包 / DeepSeek-V4 等火山方舟 Responses API 默认地址 */
+const DEFAULT_VOLCES_RESPONSES_URL = 'https://ark.cn-beijing.volces.com/api/v3/responses'
+
+function isVolcesResponsesProvider(type) {
+  return type === 'doubao_responses' || type === 'deepseek_v4_responses'
+}
+
 function defaultCidForm() {
   return {
     enablePhoneConfig: false,
@@ -82,15 +207,116 @@ function mergeCidForm(parsed) {
   return { ...defaults, ...parsed }
 }
 
+function defaultIntentForm() {
+  return {
+    enabled: false,
+    activeCode: '',
+    models: []
+  }
+}
+
+function toTimeoutSec(value) {
+  const sec = Number(value)
+  if (!Number.isFinite(sec) || sec <= 0) {
+    return null
+  }
+  return Math.round(sec)
+}
+
+/** 从接口/历史配置解析为秒:优先 timeoutSec,否则兼容 timeoutMs(毫秒) */
+function resolveModelTimeoutSec(model) {
+  if (!model) {
+    return 300
+  }
+  const explicitSec = toTimeoutSec(model.timeoutSec)
+  if (explicitSec != null) {
+    return explicitSec
+  }
+  const ms = Number(model.timeoutMs)
+  if (!Number.isFinite(ms) || ms <= 0) {
+    return 300
+  }
+  // 历史毫秒配置
+  if (ms >= 1000) {
+    return Math.round(ms / 1000)
+  }
+  // 异常小值按秒理解
+  return Math.round(ms)
+}
+
+function normalizeModelForSave(model, options) {
+  if (!model || typeof model !== 'object') {
+    return defaultModelForm()
+  }
+  const preferForm = options && options.preferFormSec
+  let timeoutSec = preferForm ? toTimeoutSec(model.timeoutSec) : null
+  if (timeoutSec == null) {
+    timeoutSec = resolveModelTimeoutSec(model)
+  }
+  return {
+    code: (model.code || '').trim(),
+    name: model.name || '',
+    providerType: model.providerType || 'doubao_responses',
+    serverUrl: model.serverUrl || '',
+    apiKey: model.apiKey || '',
+    modelName: model.modelName || '',
+    timeoutSec
+  }
+}
+
+function defaultModelForm() {
+  return {
+    code: '',
+    name: '',
+    providerType: 'doubao_responses',
+    serverUrl: DEFAULT_VOLCES_RESPONSES_URL,
+    apiKey: '',
+    modelName: '',
+    timeoutSec: 300
+  }
+}
+
+function mergeIntentForm(parsed) {
+  const defaults = defaultIntentForm()
+  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+    return defaults
+  }
+  const models = Array.isArray(parsed.models)
+    ? parsed.models.map(item => normalizeModelForSave(item))
+    : []
+  return {
+    enabled: !!parsed.enabled,
+    activeCode: parsed.activeCode || '',
+    models
+  }
+}
+
 export default {
   name: 'AdminCidConfig',
   data() {
     return {
+      DEFAULT_VOLCES_RESPONSES_URL,
       loading: false,
       submitLoading: false,
       configId: null,
       configKey: 'cId.config',
-      form: defaultCidForm()
+      intentConfigId: null,
+      intentConfigKey: 'easycall.intent.rating.config',
+      form: defaultCidForm(),
+      intentForm: defaultIntentForm(),
+      modelDialogVisible: false,
+      modelDialogTitle: '新增评级模型',
+      modelEditIndex: -1,
+      modelForm: defaultModelForm(),
+      modelRules: {
+        code: [{ required: true, message: '编码不能为空', trigger: 'blur' }],
+        name: [{ required: true, message: '名称不能为空', trigger: 'blur' }],
+        providerType: [{ required: true, message: '请选择 Provider 类型', trigger: 'change' }],
+        serverUrl: [{ required: true, message: '服务地址不能为空', trigger: 'blur' }],
+        apiKey: [{ required: true, message: 'API Key 不能为空', trigger: 'blur' }],
+        modelName: [{ required: true, message: '模型名称不能为空', trigger: 'blur' }],
+        timeoutSec: [{ required: true, message: '超时时间不能为空', trigger: 'change' }]
+      }
     }
   },
   created() {
@@ -99,35 +325,189 @@ export default {
   methods: {
     loadConfig() {
       this.loading = true
-      getConfigByKey(this.configKey).then(response => {
-        const data = response.data
-        if (data) {
-          this.configId = data.configId != null ? data.configId : null
-          const configValue = data.configValue
-          if (configValue !== null && configValue !== undefined && configValue !== '' && configValue !== 'null') {
-            try {
-              const parsed = JSON.parse(configValue)
-              const {
-                enableGateWayLimit,
-                showGatewayIds,
-                ...rest
-              } = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {}
-              this.form = mergeCidForm(rest)
-            } catch (e) {
-              this.form = defaultCidForm()
-            }
-          } else {
+      Promise.all([
+        getConfigByKey(this.configKey),
+        getConfigByKey(this.intentConfigKey)
+      ]).then(([cidRes, intentRes]) => {
+        this.applyCidConfig(cidRes)
+        this.applyIntentConfig(intentRes)
+      }).finally(() => {
+        this.loading = false
+      })
+    },
+    applyCidConfig(response) {
+      const data = response.data
+      if (data) {
+        this.configId = data.configId != null ? data.configId : null
+        const configValue = data.configValue
+        if (configValue !== null && configValue !== undefined && configValue !== '' && configValue !== 'null') {
+          try {
+            const parsed = JSON.parse(configValue)
+            const {
+              enableGateWayLimit,
+              showGatewayIds,
+              ...rest
+            } = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {}
+            this.form = mergeCidForm(rest)
+          } catch (e) {
             this.form = defaultCidForm()
           }
         } else {
-          this.configId = null
           this.form = defaultCidForm()
         }
-      }).finally(() => {
-        this.loading = false
+      } else {
+        this.configId = null
+        this.form = defaultCidForm()
+      }
+    },
+    applyIntentConfig(response) {
+      const data = response.data
+      if (data) {
+        this.intentConfigId = data.configId != null ? data.configId : null
+        const configValue = data.configValue
+        if (configValue !== null && configValue !== undefined && configValue !== '' && configValue !== 'null') {
+          try {
+            const parsed = JSON.parse(configValue)
+            this.intentForm = mergeIntentForm(parsed)
+          } catch (e) {
+            this.intentForm = defaultIntentForm()
+          }
+        } else {
+          this.intentForm = defaultIntentForm()
+        }
+      } else {
+        this.intentConfigId = null
+        this.intentForm = defaultIntentForm()
+      }
+    },
+    formatProviderType(type) {
+      const map = {
+        doubao_responses: '豆包 Responses',
+        deepseek_v4_responses: 'DeepSeek-V4',
+        openai_chat: 'OpenAI Chat'
+      }
+      return map[type] || type || '-'
+    },
+    isVolcesResponsesProvider(type) {
+      return isVolcesResponsesProvider(type)
+    },
+    onModelProviderTypeChange(type) {
+      if (isVolcesResponsesProvider(type) && !(this.modelForm.serverUrl || '').trim()) {
+        this.modelForm.serverUrl = DEFAULT_VOLCES_RESPONSES_URL
+      }
+    },
+    displayTimeoutSec(model) {
+      return resolveModelTimeoutSec(model)
+    },
+    /** 保存总配置前,将弹窗内未点「确定」的编辑同步到 models */
+    flushPendingModelDialog() {
+      if (!this.modelDialogVisible || !this.modelForm) {
+        return true
+      }
+      const code = (this.modelForm.code || '').trim()
+      if (!code) {
+        return true
+      }
+      const timeoutSec = toTimeoutSec(this.modelForm.timeoutSec)
+      if (timeoutSec == null) {
+        this.msgError('请先完善弹窗中的超时(秒)后再保存')
+        return false
+      }
+      const payload = normalizeModelForSave({ ...this.modelForm, code, timeoutSec }, { preferFormSec: true })
+      if (this.modelEditIndex >= 0) {
+        const duplicateIndex = this.intentForm.models.findIndex(item => item.code === code)
+        if (duplicateIndex >= 0 && duplicateIndex !== this.modelEditIndex) {
+          this.msgError('模型编码已存在')
+          return false
+        }
+        const oldCode = this.intentForm.models[this.modelEditIndex].code
+        this.$set(this.intentForm.models, this.modelEditIndex, payload)
+        if (this.intentForm.activeCode === oldCode) {
+          this.intentForm.activeCode = code
+        }
+      } else {
+        const duplicateIndex = this.intentForm.models.findIndex(item => item.code === code)
+        if (duplicateIndex >= 0) {
+          this.msgError('模型编码已存在,请先关闭弹窗或修改编码')
+          return false
+        }
+        this.intentForm.models.push(payload)
+        if (!this.intentForm.activeCode) {
+          this.intentForm.activeCode = code
+        }
+      }
+      this.modelDialogVisible = false
+      return true
+    },
+    openModelDialog(row, index) {
+      if (row && index >= 0) {
+        this.modelDialogTitle = '编辑评级模型'
+        this.modelEditIndex = index
+        this.modelForm = normalizeModelForSave(row)
+      } else {
+        this.modelDialogTitle = '新增评级模型'
+        this.modelEditIndex = -1
+        this.modelForm = defaultModelForm()
+      }
+      this.modelDialogVisible = true
+      this.$nextTick(() => {
+        if (this.$refs.modelFormRef) {
+          this.$refs.modelFormRef.clearValidate()
+        }
       })
     },
-    submitForm() {
+    resetModelForm() {
+      this.modelEditIndex = -1
+      this.modelForm = defaultModelForm()
+    },
+    submitModelForm() {
+      this.$refs.modelFormRef.validate(valid => {
+        if (!valid) {
+          return
+        }
+        const code = (this.modelForm.code || '').trim()
+        const duplicateIndex = this.intentForm.models.findIndex(item => item.code === code)
+        if (this.modelEditIndex < 0 && duplicateIndex >= 0) {
+          this.msgError('模型编码已存在')
+          return
+        }
+        if (this.modelEditIndex >= 0 && duplicateIndex >= 0 && duplicateIndex !== this.modelEditIndex) {
+          this.msgError('模型编码已存在')
+          return
+        }
+        const payload = normalizeModelForSave({ ...this.modelForm, code }, { preferFormSec: true })
+        if (this.modelEditIndex >= 0) {
+          const oldCode = this.intentForm.models[this.modelEditIndex].code
+          this.$set(this.intentForm.models, this.modelEditIndex, payload)
+          if (this.intentForm.activeCode === oldCode) {
+            this.intentForm.activeCode = code
+          }
+        } else {
+          this.intentForm.models.push(payload)
+          if (!this.intentForm.activeCode) {
+            this.intentForm.activeCode = code
+          }
+        }
+        this.modelDialogVisible = false
+        this.msgInfo('模型已更新到列表,请点击页面底部「保存配置」后才会生效')
+      })
+    },
+    removeModel(index) {
+      const row = this.intentForm.models[index]
+      if (!row) {
+        return
+      }
+      this.$confirm('确认删除评级模型「' + (row.name || row.code) + '」吗?', '提示', {
+        type: 'warning'
+      }).then(() => {
+        if (this.intentForm.activeCode === row.code) {
+          this.intentForm.activeCode = ''
+        }
+        this.intentForm.models.splice(index, 1)
+        this.msgInfo('模型已从列表移除,请点击页面底部「保存配置」后才会生效')
+      }).catch(() => {})
+    },
+    validateCidForm() {
       if (this.form.enablePhoneConfig) {
         if (!this.form.generateCount || this.form.generateCount < 1) {
           this.msgError('生成条数不能为空且不能小于1')
@@ -165,17 +545,63 @@ export default {
           return false
         }
       }
+      return true
+    },
+    validateIntentForm() {
+      if (!this.intentForm.enabled) {
+        return true
+      }
+      if (!this.intentForm.models.length) {
+        this.msgError('请至少添加一个评级模型')
+        return false
+      }
+      if (!this.intentForm.activeCode) {
+        this.msgError('请选择当前启用的评级模型')
+        return false
+      }
+      const activeModel = this.intentForm.models.find(item => item.code === this.intentForm.activeCode)
+      if (!activeModel) {
+        this.msgError('当前启用的模型不存在,请重新选择')
+        return false
+      }
+      return true
+    },
+    submitForm() {
+      if (!this.validateCidForm() || !this.validateIntentForm()) {
+        return
+      }
+      if (!this.flushPendingModelDialog()) {
+        return
+      }
       this.submitLoading = true
-      const param = {
+      const cidParam = {
         configId: (this.configId != null && this.configId !== '') ? Number(this.configId) : null,
         configName: 'CID配置',
         configKey: this.configKey,
         configValue: JSON.stringify(this.form)
       }
-      updateConfigByKey(param).then(response => {
-        if (response.code === 200) {
-          this.msgSuccess(this.configId ? '修改成功' : '保存成功')
+      const intentPayload = {
+        enabled: !!this.intentForm.enabled,
+        activeCode: this.intentForm.activeCode || '',
+        models: this.intentForm.models.map(item => normalizeModelForSave(item))
+      }
+      const intentParam = {
+        configId: (this.intentConfigId != null && this.intentConfigId !== '') ? Number(this.intentConfigId) : null,
+        configName: 'EasyCall意向评级配置',
+        configKey: this.intentConfigKey,
+        configValue: JSON.stringify(intentPayload)
+      }
+      Promise.all([
+        updateConfigByKey(cidParam),
+        updateConfigByKey(intentParam)
+      ]).then(([cidResponse, intentResponse]) => {
+        if (cidResponse.code === 200 && intentResponse.code === 200) {
+          this.msgSuccess('保存成功')
           this.loadConfig()
+        } else if (cidResponse.code !== 200) {
+          this.msgError(cidResponse.msg || 'CID配置保存失败')
+        } else {
+          this.msgError(intentResponse.msg || '意向评级配置保存失败')
         }
       }).finally(() => {
         this.submitLoading = false
@@ -194,4 +620,61 @@ export default {
   font-size: 12px;
   margin-left: 8px;
 }
+.danger-text {
+  color: #f56c6c;
+}
+.model-list-item >>> .el-form-item__label {
+  float: none;
+  display: block;
+  width: 100% !important;
+  text-align: left;
+  padding-bottom: 8px;
+}
+.model-list-item >>> .el-form-item__content {
+  margin-left: 0 !important;
+  width: 100%;
+}
+.model-list-toolbar {
+  margin-bottom: 12px;
+  margin-top: 12px;
+}
+.intent-save-alert {
+  margin-bottom: 0;
+}
+.page-save-bar {
+  text-align: center;
+  margin-top: 20px;
+  padding-bottom: 20px;
+}
+.save-tip-text {
+  color: #e6a23c;
+  font-size: 13px;
+  margin: 0 0 12px;
+  line-height: 1.5;
+}
+.dialog-save-tip {
+  color: #909399;
+  font-size: 12px;
+  line-height: 1.6;
+  margin: 0 0 4px;
+  padding: 0 4px;
+}
+.intent-model-table {
+  width: 100%;
+}
+.intent-model-table >>> .cell {
+  padding-left: 8px;
+  padding-right: 8px;
+}
+.intent-model-table >>> .enable-radio-only .el-radio__label {
+  display: none;
+}
+.intent-model-table >>> .enable-radio-only .el-radio__input {
+  margin-right: 0;
+}
+.intent-model-table >>> td.enable-col .cell {
+  overflow: visible;
+  padding-left: 4px;
+  padding-right: 4px;
+}
 </style>

+ 1 - 1
adminui/src/views/admin/consumeRecord/index.vue

@@ -87,7 +87,7 @@
     <pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
 
     <!-- ===== 详情弹窗 ===== -->
-    <el-dialog title="消费扣款详情" :visible.sync="detailVisible" width="550px" append-to-body>
+    <el-dialog title="消费扣款详情" :visible.sync="detailVisible" width="600px" append-to-body>
       <el-descriptions :column="2" border size="small">
         <el-descriptions-item label="租户ID">{{ detail.tenantId }}</el-descriptions-item>
         <el-descriptions-item label="租户名称">{{ detail.tenantName || '-' }}</el-descriptions-item>

+ 29 - 4
adminui/src/views/admin/dashboard/index.vue

@@ -49,6 +49,8 @@
 </template>
 
 <script>
+import { getDashboardData } from '@/api/admin/dashboard'
+
 export default {
   name: 'AdminDashboard',
   data() {
@@ -64,21 +66,44 @@ export default {
         { label: '总充值金额', value: '¥0.00', icon: 'el-icon-bank-card', color: '#06b6d4', gradient: 'linear-gradient(135deg, #06b6d4, #22d3ee)' }
       ],
       quickLinks: [
-        { title: '代理管理', path: '/admin/proxy', icon: 'el-icon-user', color: '#3b82f6' },
-        { title: '租户管理', path: '/admin/company', icon: 'el-icon-office-building', color: '#10b981' },
-        { title: 'AI模型配置', path: '/admin/aiModel', icon: 'el-icon-cpu', color: '#8b5cf6' },
-        { title: '消费记录', path: '/admin/consumeRecord', icon: 'el-icon-coin', color: '#f59e0b' }
+        { title: '代理管理', path: '/admin/tenantProxy/proxy', icon: 'el-icon-user', color: '#3b82f6' },
+        { title: '租户管理', path: '/admin/tenantProxy/company', icon: 'el-icon-office-building', color: '#10b981' },
+        { title: 'AI模型配置', path: '/admin/shezhi/aiModel', icon: 'el-icon-cpu', color: '#8b5cf6' },
+        { title: '消费记录', path: '/admin/finance/consumeRecord', icon: 'el-icon-coin', color: '#f59e0b' }
       ]
     }
   },
   mounted() {
     this.updateTime()
     this.timeTimer = setInterval(this.updateTime, 1000)
+    this.fetchDashboardData()
   },
   beforeDestroy() {
     clearInterval(this.timeTimer)
   },
   methods: {
+    async fetchDashboardData() {
+      try {
+        const res = await getDashboardData()
+        const data = res.data
+        if (!data) return
+
+        this.statCards[0].value = data.totalTenants != null ? data.totalTenants : '-'
+        // statCards[1] 今日调用 — 暂不处理,保持默认 '-'
+        this.statCards[2].value = data.todayConsumption != null
+          ? '¥' + Number(data.todayConsumption).toFixed(2)
+          : '¥0.00'
+        // statCards[3] 总调用次数 — 暂不处理,保持默认 '-'
+        this.statCards[4].value = data.totalConsumption != null
+          ? '¥' + Number(data.totalConsumption).toFixed(2)
+          : '¥0.00'
+        this.statCards[5].value = data.totalRecharge != null
+          ? '¥' + Number(data.totalRecharge).toFixed(2)
+          : '¥0.00'
+      } catch (e) {
+        console.error('加载看板数据失败', e)
+      }
+    },
     updateTime() {
       const now = new Date()
       this.currentTime = now.toLocaleString('zh-CN', {

+ 143 - 46
adminui/src/views/admin/keywordManage/index.vue

@@ -1,11 +1,20 @@
 <template>
   <div class="app-container">
-    <!-- ===== 搜索栏 ===== -->
     <el-card shadow="never" class="mb16 filter-card">
-      <el-form :model="queryParams" ref="queryForm" :inline="true" size="small" class="list-search-form">
+      <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" size="small" class="list-search-form">
         <el-form-item label="关键词" prop="keyword">
           <el-input v-model="queryParams.keyword" placeholder="请输入关键词" clearable @keyup.enter.native="loadList" />
         </el-form-item>
+        <el-form-item label="类型" prop="keywordType">
+          <el-select v-model="queryParams.keywordType" placeholder="全部类型" clearable style="width: 160px">
+            <el-option
+              v-for="item in typeOptions"
+              :key="item.dictValue"
+              :label="item.dictLabel"
+              :value="item.dictValue"
+            />
+          </el-select>
+        </el-form-item>
         <el-form-item>
           <el-button type="primary" icon="el-icon-search" size="mini" @click="loadList">查询</el-button>
           <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
@@ -13,7 +22,6 @@
       </el-form>
     </el-card>
 
-    <!-- ===== 操作栏 ===== -->
     <el-row :gutter="10" class="mb8 list-toolbar">
       <el-col :span="1.5">
         <el-button type="primary" icon="el-icon-plus" size="mini" @click="openDialog(null)">新增关键词</el-button>
@@ -24,38 +32,49 @@
       <right-toolbar :showSearch.sync="showSearch" @queryTable="loadList" />
     </el-row>
 
-    <!-- ===== 列表 ===== -->
     <el-table border v-loading="loading" :data="dataList" size="small" style="width:100%">
-      <el-table-column label="关键词ID" prop="keywordId" min-width="70" align="center" />
-      <el-table-column label="关键词内容" prop="keyword" min-width="200" />
-      <el-table-column label="类型" prop="keywordType" min-width="100" align="center">
-        <template slot-scope="s">
-          <el-tag v-if="s.row.keywordType == 1" type="danger" size="mini">{{ getTypeLabel(s.row.keywordType) }}</el-tag>
-          <el-tag v-else-if="s.row.keywordType == 2" type="warning" size="mini">{{ getTypeLabel(s.row.keywordType) }}</el-tag>
-          <el-tag v-else size="mini">{{ getTypeLabel(s.row.keywordType) || '-' }}</el-tag>
+      <el-table-column label="关键词ID" prop="keywordId" min-width="80" align="center" />
+      <el-table-column label="关键词内容" prop="keyword" min-width="200" show-overflow-tooltip />
+      <el-table-column label="类型" min-width="120" align="center">
+        <template slot-scope="scope">
+          <dict-tag
+            v-if="scope.row.keywordType != null && scope.row.keywordType !== ''"
+            :options="typeOptions"
+            :value="scope.row.keywordType"
+          />
+          <span v-else-if="scope.row.typeName">{{ scope.row.typeName }}</span>
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="备注" prop="remark" min-width="160" show-overflow-tooltip>
+        <template slot-scope="scope">
+          <span>{{ scope.row.remark || '-' }}</span>
         </template>
       </el-table-column>
       <el-table-column label="创建时间" prop="createTime" min-width="150" align="center" />
       <el-table-column label="操作" align="center" width="160" fixed="right" class-name="small-padding fixed-width">
-        <template slot-scope="s">
-          <el-button size="mini" type="text" icon="el-icon-edit" @click="openDialog(s.row)">编辑</el-button>
-          <el-button size="mini" type="text" style="color:#f5222d" icon="el-icon-delete" @click="handleDelete(s.row)">删除</el-button>
+        <template slot-scope="scope">
+          <el-button size="mini" type="text" icon="el-icon-edit" @click="openDialog(scope.row)">编辑</el-button>
+          <el-button size="mini" type="text" style="color:#f5222d" icon="el-icon-delete" @click="handleDelete(scope.row)">删除</el-button>
         </template>
       </el-table-column>
     </el-table>
 
-    <pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="loadList" />
+    <pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="loadList" />
 
-    <!-- ===== 编辑弹窗 ===== -->
-    <el-dialog :title="formTitle" :visible.sync="dialogVisible" width="500px" append-to-body>
+    <el-dialog :title="formTitle" :visible.sync="dialogVisible" width="500px" append-to-body @closed="reset">
       <el-form ref="form" :model="form" :rules="rules" label-width="100px">
         <el-form-item label="关键词内容" prop="keyword">
           <el-input v-model="form.keyword" placeholder="请输入关键词内容" />
         </el-form-item>
         <el-form-item label="类型" prop="keywordType">
           <el-select v-model="form.keywordType" placeholder="请选择类型" style="width:100%">
-            <el-option v-for="item in typeOptions" :key="item.dictValue"
-              :label="item.dictLabel" :value="item.dictValue" />
+            <el-option
+              v-for="item in typeOptions"
+              :key="item.dictValue"
+              :label="item.dictLabel"
+              :value="item.dictValue"
+            />
           </el-select>
         </el-form-item>
         <el-form-item label="备注" prop="remark">
@@ -71,24 +90,36 @@
 </template>
 
 <script>
-import request from '@/utils/request'
-import { exportKeyword } from '@/api/system/keyword'
+import { listKeyword, getKeyword, addKeyword, updateKeyword, delKeyword, exportKeyword, getKeywordMeta } from '@/api/system/keyword'
+import DictTag from '@/components/DictTag'
 
 export default {
   name: 'AdminKeywordManage',
+  components: { DictTag },
   data() {
     return {
       showSearch: true,
       loading: false,
       exportLoading: false,
       typeOptions: [],
+      defaultKeywordType: '1',
       dataList: [],
       total: 0,
-      queryParams: { pageNum: 1, pageSize: 10, keyword: null },
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        keyword: null,
+        keywordType: null
+      },
       dialogVisible: false,
       formTitle: '',
       submitting: false,
-      form: { keywordId: null, keyword: '', keywordType: 1, remark: '' },
+      form: {
+        keywordId: null,
+        keyword: '',
+        keywordType: '1',
+        remark: ''
+      },
       rules: {
         keyword: [{ required: true, message: '请输入关键词内容', trigger: 'blur' }],
         keywordType: [{ required: true, message: '请选择类型', trigger: 'change' }]
@@ -96,54 +127,114 @@ export default {
     }
   },
   created() {
-    this.getDicts('keyword_type').then(response => {
-      this.typeOptions = response.data
+    this.initMeta().then(() => {
+      this.loadList()
     })
-    this.loadList()
   },
   methods: {
+    async initMeta() {
+      try {
+        const [dictRes, metaRes] = await Promise.all([
+          this.getDicts('keyword_type'),
+          getKeywordMeta()
+        ])
+        const dictOptions = dictRes.data || []
+        const meta = metaRes.data || {}
+        this.typeOptions = dictOptions.length ? dictOptions : (meta.typeOptions || [])
+        this.defaultKeywordType = String(
+          meta.defaultKeywordType != null
+            ? meta.defaultKeywordType
+            : (this.typeOptions[0] && this.typeOptions[0].dictValue) || '1'
+        )
+        this.form.keywordType = this.defaultKeywordType
+      } catch (e) {
+        this.typeOptions = [{ dictValue: '1', dictLabel: '看课弹幕', listClass: 'primary' }]
+        this.defaultKeywordType = '1'
+      }
+    },
+    buildQueryParams() {
+      const params = { ...this.queryParams }
+      if (params.keywordType != null && params.keywordType !== '') {
+        params.keywordType = Number(params.keywordType)
+      } else {
+        params.keywordType = null
+      }
+      return params
+    },
+    normalizeForm(data) {
+      return {
+        keywordId: data.keywordId || null,
+        keyword: data.keyword || '',
+        keywordType: data.keywordType != null ? String(data.keywordType) : this.defaultKeywordType,
+        remark: data.remark || ''
+      }
+    },
+    buildSubmitData() {
+      return {
+        keywordId: this.form.keywordId,
+        keyword: (this.form.keyword || '').trim(),
+        keywordType: Number(this.form.keywordType),
+        remark: this.form.remark
+      }
+    },
     loadList() {
       this.loading = true
-      request({ url: '/system/keyword/list', method: 'get', params: this.queryParams }).then(r => {
-        this.dataList = r.rows
-        this.total = r.total
+      listKeyword(this.buildQueryParams()).then(r => {
+        this.dataList = r.rows || []
+        this.total = r.total || 0
+        this.loading = false
+      }).catch(() => {
         this.loading = false
       })
     },
     resetQuery() {
       this.resetForm('queryForm')
+      this.queryParams.keywordType = null
+      this.queryParams.pageNum = 1
       this.loadList()
     },
     openDialog(row) {
       this.reset()
       if (row) {
         this.formTitle = '编辑关键词'
-        request({ url: `/system/keyword/${row.keywordId}`, method: 'get' }).then(r => {
-          this.form = { ...this.form, ...r.data }
+        getKeyword(row.keywordId).then(r => {
+          this.form = this.normalizeForm(r.data || {})
           this.dialogVisible = true
+          this.$nextTick(() => {
+            if (this.$refs.form) {
+              this.$refs.form.clearValidate()
+            }
+          })
         })
       } else {
         this.formTitle = '新增关键词'
+        this.form.keywordType = this.defaultKeywordType
         this.dialogVisible = true
+        this.$nextTick(() => {
+          if (this.$refs.form) {
+            this.$refs.form.clearValidate()
+          }
+        })
       }
     },
     submitForm() {
-      this.$refs['form'].validate(valid => {
+      this.$refs.form.validate(valid => {
         if (!valid) return
         this.submitting = true
-        const req = this.form.keywordId
-          ? request({ url: '/system/keyword', method: 'put', data: this.form })
-          : request({ url: '/system/keyword', method: 'post', data: this.form })
+        const payload = this.buildSubmitData()
+        const req = payload.keywordId ? updateKeyword(payload) : addKeyword(payload)
         req.then(() => {
-          this.$message.success(this.form.keywordId ? '修改成功' : '新增成功')
+          this.$message.success(payload.keywordId ? '修改成功' : '新增成功')
           this.dialogVisible = false
           this.loadList()
-        }).finally(() => { this.submitting = false })
+        }).finally(() => {
+          this.submitting = false
+        })
       })
     },
     handleDelete(row) {
       this.$confirm(`确认删除关键词 "${row.keyword}"?`, '提示', { type: 'warning' }).then(() => {
-        request({ url: `/system/keyword/${row.keywordId}`, method: 'delete' }).then(() => {
+        delKeyword(row.keywordId).then(() => {
           this.$message.success('删除成功')
           this.loadList()
         })
@@ -151,18 +242,24 @@ export default {
     },
     handleExport() {
       this.exportLoading = true
-      exportKeyword(this.queryParams).then(response => {
+      exportKeyword(this.buildQueryParams()).then(response => {
         this.download(response.msg)
         this.exportLoading = false
-      }).catch(() => { this.exportLoading = false })
-    },
-    getTypeLabel(value) {
-      const item = this.typeOptions.find(o => o.dictValue == value)
-      return item ? item.dictLabel : null
+      }).catch(() => {
+        this.exportLoading = false
+      })
     },
     reset() {
-      this.form = { keywordId: null, keyword: '', keywordType: 1, remark: '' }
-      if (this.$refs['form']) this.$refs['form'].resetFields()
+      this.form = {
+        keywordId: null,
+        keyword: '',
+        keywordType: this.defaultKeywordType,
+        remark: ''
+      }
+      if (this.$refs.form) {
+        this.$refs.form.resetFields()
+        this.form.keywordType = this.defaultKeywordType
+      }
     }
   }
 }

+ 295 - 100
adminui/src/views/admin/live/index.vue

@@ -2,32 +2,38 @@
   <div class="app-container">
     <el-card shadow="never" class="mb16 filter-card">
       <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="100px" size="small" class="list-search-form">
-      <el-form-item label="租户名称" prop="tenantId">
-        <inline-tenant-selector :key="tenantSelectorKey" @change="handleTenantChange" />
-      </el-form-item>
-      <el-form-item label="直播标题" prop="liveTitle">
-        <el-input
-          v-model="queryParams.liveTitle"
-          placeholder="请输入直播标题"
-          clearable
-          size="small"
-          @keyup.enter.native="handleQuery"
-        />
-      </el-form-item>
-      <el-form-item label="直播状态" prop="status">
-        <el-select v-model="queryParams.status" placeholder="请选择直播状态" clearable size="small">
-          <el-option
-            v-for="item in statusOptions"
-            :key="item.value"
-            :label="item.label"
-            :value="item.value">
-          </el-option>
-        </el-select>
-      </el-form-item>
-      <el-form-item>
-        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
-        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
-      </el-form-item>
+        <el-form-item label="租户名称" prop="tenantId">
+          <inline-tenant-selector
+            mode="admin"
+            auto-select-first
+            :scope-key="$route.path"
+            :key="tenantSelectorKey"
+            @change="handleTenantChange"
+          />
+        </el-form-item>
+        <el-form-item label="直播名称" prop="liveTitle">
+          <el-input
+            v-model="queryParams.liveTitle"
+            placeholder="请输入直播名称"
+            clearable
+            size="small"
+            @keyup.enter.native="handleQuery"
+          />
+        </el-form-item>
+        <el-form-item label="直播状态" prop="status">
+          <el-select v-model="queryParams.status" placeholder="请选择直播状态" clearable size="small">
+            <el-option
+              v-for="item in statusOptions"
+              :key="item.value"
+              :label="item.label"
+              :value="item.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
+          <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
+        </el-form-item>
       </el-form>
     </el-card>
 
@@ -49,43 +55,90 @@
           plain
           icon="el-icon-bar-chart"
           size="mini"
-          @click="showStatistics = true"
+          @click="handleStatistics"
         >直播统计</el-button>
       </el-col>
       <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
     </el-row>
 
     <el-table border size="small" style="width:100%" v-loading="loading" :data="liveList">
-      <el-table-column type="selection" width="55" align="center" />
-      <el-table-column label="租户名称" align="center" prop="companyName" min-width="120" />
-      <el-table-column label="直播标题" align="center" prop="liveTitle" min-width="150" show-overflow-tooltip />
-      <el-table-column label="直播封面" align="center" prop="coverUrl" min-width="100">
+      <el-table-column label="直播封面" align="center" width="100">
         <template slot-scope="scope">
           <el-image
-            v-if="scope.row.coverUrl"
-            style="width: 80px; height: 60px"
-            :src="scope.row.coverUrl"
-            fit="contain" />
+            v-if="scope.row.liveImgUrl"
+            style="width: 56px; height: 56px; border-radius: 6px;"
+            :src="scope.row.liveImgUrl"
+            fit="cover"
+            :preview-src-list="[scope.row.liveImgUrl]"
+          />
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="所属租户" align="center" prop="companyName" min-width="120" show-overflow-tooltip />
+      <el-table-column label="直播名称" align="center" min-width="150" show-overflow-tooltip>
+        <template slot-scope="scope">
+          <span>{{ scope.row.liveName || '-' }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="直播类型" align="center" width="90">
+        <template slot-scope="scope">
+          <el-tag v-if="toNum(scope.row.liveType) === 1" type="danger" size="mini">直播</el-tag>
+          <el-tag v-else-if="toNum(scope.row.liveType) === 2" type="success" size="mini">录播</el-tag>
+          <el-tag v-else-if="toNum(scope.row.liveType) === 3" size="mini">直播回放</el-tag>
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="显示类型" align="center" width="90">
+        <template slot-scope="scope">
+          <span v-if="toNum(scope.row.showType) === 1">横屏</span>
+          <span v-else-if="toNum(scope.row.showType) === 2">竖屏</span>
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="直播状态" align="center" width="110">
+        <template slot-scope="scope">
+          <el-tag v-if="toNum(scope.row.status) === 1" size="mini">待直播</el-tag>
+          <el-tag v-else-if="toNum(scope.row.status) === 2" type="warning" size="mini">直播中</el-tag>
+          <el-tag v-else-if="toNum(scope.row.status) === 3" type="info" size="mini">已结束</el-tag>
+          <el-tag v-else-if="toNum(scope.row.status) === 4" type="success" size="mini">直播回放中</el-tag>
+          <el-tag v-else type="info" size="mini">未知</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="观看人数" align="center" width="90">
+        <template slot-scope="scope">
+          <span>{{ scope.row.viewerCount != null ? scope.row.viewerCount : '-' }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="开始时间" align="center" min-width="150">
+        <template slot-scope="scope">
+          <span>{{ formatDateTime(scope.row.startTime) }}</span>
         </template>
       </el-table-column>
-      <el-table-column label="主播名称" align="center" prop="anchorName" min-width="90" />
-      <el-table-column label="观看人数" align="center" prop="viewerCount" min-width="80" />
-      <el-table-column label="开始时间" align="center" prop="startTime" min-width="150" />
-      <el-table-column label="直播时长" align="center" prop="duration" min-width="90" />
-      <el-table-column label="直播状态" align="center" prop="status" min-width="90">
+      <el-table-column label="结束时间" align="center" min-width="150">
         <template slot-scope="scope">
-          <el-tag v-if="scope.row.status == 2" type="success">直播中</el-tag>
-          <el-tag v-else-if="scope.row.status == 1" type="warning">待支付</el-tag>
-          <el-tag v-else type="info">已结束</el-tag>
+          <span>{{ formatDateTime(scope.row.finishTime) }}</span>
         </template>
       </el-table-column>
-      <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
+      <el-table-column label="上下架" align="center" width="80">
+        <template slot-scope="scope">
+          <el-tag v-if="toNum(scope.row.isShow) === 1" type="success" size="mini">上架</el-tag>
+          <el-tag v-else-if="toNum(scope.row.isShow) === 2" type="danger" size="mini">下架</el-tag>
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="审核状态" align="center" width="100">
+        <template slot-scope="scope">
+          <el-tag v-if="toNum(scope.row.isAudit) === 0" type="danger" size="mini">未通过</el-tag>
+          <el-tag v-else-if="toNum(scope.row.isAudit) === 1" type="success" size="mini">已通过</el-tag>
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="100" fixed="right">
         <template slot-scope="scope">
           <el-button
             size="mini"
             type="text"
-            icon="el-icon-search"
-            plain
+            icon="el-icon-view"
             @click="handleView(scope.row)"
           >详情</el-button>
         </template>
@@ -93,7 +146,7 @@
     </el-table>
 
     <pagination
-      v-show="total>0"
+      v-show="total > 0"
       :total="total"
       :page.sync="queryParams.pageNum"
       :limit.sync="queryParams.pageSize"
@@ -101,62 +154,104 @@
     />
 
     <!-- 详情弹窗 -->
-    <el-dialog title="直播详情" :visible.sync="viewOpen" width="600px" append-to-body>
-      <el-form :model="viewForm" label-width="100px">
-        <el-form-item label="租户名称">
-          <span>{{ viewForm.companyName }}</span>
-        </el-form-item>
-        <el-form-item label="直播标题">
-          <span>{{ viewForm.liveTitle }}</span>
-        </el-form-item>
-        <el-form-item label="直播封面">
-          <el-image v-if="viewForm.coverUrl" style="width: 120px; height: 90px" :src="viewForm.coverUrl" fit="contain" />
-        </el-form-item>
-        <el-form-item label="主播名称">
-          <span>{{ viewForm.anchorName }}</span>
-        </el-form-item>
-        <el-form-item label="观看人数">
-          <span>{{ viewForm.viewerCount }}</span>
-        </el-form-item>
-        <el-form-item label="开始时间">
-          <span>{{ viewForm.startTime }}</span>
-        </el-form-item>
-        <el-form-item label="直播时长">
-          <span>{{ viewForm.duration }}</span>
-        </el-form-item>
-        <el-form-item label="直播状态">
-          <el-tag v-if="viewForm.status == 2" type="success">直播中</el-tag>
-          <el-tag v-else-if="viewForm.status == 1" type="warning">待支付</el-tag>
-          <el-tag v-else type="info">已结束</el-tag>
-        </el-form-item>
+    <el-dialog title="直播详情" :visible.sync="viewOpen" width="640px" append-to-body>
+      <el-form :model="viewForm" label-width="100px" size="small">
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="所属租户">
+              <span>{{ viewForm.companyName || '-' }}</span>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="直播名称">
+              <span>{{ viewForm.liveName || '-' }}</span>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="直播类型">
+              <span>{{ resolveLiveType(viewForm.liveType) }}</span>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="直播状态">
+              <span>{{ resolveLiveStatus(viewForm.status) }}</span>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="显示类型">
+              <span>{{ toNum(viewForm.showType) === 1 ? '横屏' : toNum(viewForm.showType) === 2 ? '竖屏' : '-' }}</span>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="观看人数">
+              <span>{{ viewForm.viewerCount != null ? viewForm.viewerCount : '-' }}</span>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="上下架">
+              <span>{{ toNum(viewForm.isShow) === 1 ? '上架' : toNum(viewForm.isShow) === 2 ? '下架' : '-' }}</span>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="审核状态">
+              <span>{{ toNum(viewForm.isAudit) === 1 ? '已通过' : toNum(viewForm.isAudit) === 0 ? '未通过' : '-' }}</span>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="开始时间">
+              <span>{{ formatDateTime(viewForm.startTime) }}</span>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="结束时间">
+              <span>{{ formatDateTime(viewForm.finishTime) }}</span>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="创建时间">
+              <span>{{ formatDateTime(viewForm.createTime) }}</span>
+            </el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item label="直播封面">
+              <el-image
+                v-if="viewForm.liveImgUrl"
+                style="width: 120px; height: 90px; border-radius: 6px;"
+                :src="viewForm.liveImgUrl"
+                fit="cover"
+              />
+              <span v-else>-</span>
+            </el-form-item>
+          </el-col>
+        </el-row>
       </el-form>
     </el-dialog>
 
     <!-- 统计弹窗 -->
     <el-dialog title="直播统计" :visible.sync="showStatistics" width="600px" append-to-body>
-      <el-card>
+      <el-card v-loading="statisticsLoading">
         <el-row :gutter="20">
           <el-col :span="12">
             <div class="stat-item">
-              <span class="stat-value">{{ statistics.totalCount }}</span>
+              <span class="stat-value">{{ statistics.totalCount || 0 }}</span>
               <span class="stat-label">直播总数</span>
             </div>
           </el-col>
           <el-col :span="12">
             <div class="stat-item">
-              <span class="stat-value">{{ statistics.totalViewerCount }}</span>
+              <span class="stat-value">{{ statistics.totalViewerCount || 0 }}</span>
               <span class="stat-label">总观看人数</span>
             </div>
           </el-col>
           <el-col :span="12">
             <div class="stat-item">
-              <span class="stat-value">{{ statistics.livingCount }}</span>
-              <span class="stat-label">直播中</span>
+              <span class="stat-value">{{ statistics.livingCount || 0 }}</span>
+              <span class="stat-label">进行中</span>
             </div>
           </el-col>
           <el-col :span="12">
             <div class="stat-item">
-              <span class="stat-value">{{ statistics.endedCount }}</span>
+              <span class="stat-value">{{ statistics.endedCount || 0 }}</span>
               <span class="stat-label">已结束</span>
             </div>
           </el-col>
@@ -168,6 +263,7 @@
 
 <script>
 import { listAllLives, getLiveInfo, getLiveStatistics, exportAllLives } from '@/api/admin/live'
+import { listCompanyOptions } from '@/api/admin/sysCompany'
 import InlineTenantSelector from '@/components/InlineTenantSelector'
 
 export default {
@@ -177,9 +273,11 @@ export default {
     return {
       loading: false,
       exportLoading: false,
+      statisticsLoading: false,
       showSearch: true,
       total: 0,
       liveList: [],
+      tenantOptions: [],
       statistics: {
         totalCount: 0,
         totalViewerCount: 0,
@@ -187,9 +285,10 @@ export default {
         endedCount: 0
       },
       statusOptions: [
-        { value: 1, label: '待支付' },
+        { value: 1, label: '待直播' },
         { value: 2, label: '直播中' },
-        { value: 3, label: '已结束' }
+        { value: 3, label: '已结束' },
+        { value: 4, label: '直播回放中' }
       ],
       queryParams: {
         pageNum: 1,
@@ -204,29 +303,99 @@ export default {
       tenantSelectorKey: 0
     }
   },
+  created() {
+    this.loadTenantOptions()
+  },
   methods: {
+    toNum(value) {
+      if (value === null || value === undefined || value === '') {
+        return null
+      }
+      const num = Number(value)
+      return Number.isNaN(num) ? null : num
+    },
+    formatDateTime(value) {
+      if (!value) {
+        return '-'
+      }
+      if (typeof value === 'string') {
+        return value
+      }
+      if (Array.isArray(value) && value.length >= 3) {
+        const [year, month, day, hour = 0, minute = 0, second = 0] = value
+        const pad = n => String(n).padStart(2, '0')
+        return `${year}-${pad(month)}-${pad(day)} ${pad(hour)}:${pad(minute)}:${pad(second)}`
+      }
+      return String(value)
+    },
+    loadTenantOptions() {
+      listCompanyOptions().then(response => {
+        this.tenantOptions = response.data || []
+      })
+    },
+    resolveTenantName(tenantId) {
+      if (tenantId == null) {
+        return '-'
+      }
+      const tenant = this.tenantOptions.find(item => item.id === tenantId)
+      return tenant ? (tenant.tenantName || tenant.companyName || String(tenantId)) : String(tenantId)
+    },
+    resolveLiveType(liveType) {
+      const type = this.toNum(liveType)
+      if (type === 1) return '直播'
+      if (type === 2) return '录播'
+      if (type === 3) return '直播回放'
+      return '-'
+    },
+    resolveLiveStatus(status) {
+      const val = this.toNum(status)
+      if (val === 1) return '待直播'
+      if (val === 2) return '直播中'
+      if (val === 3) return '已结束'
+      if (val === 4) return '直播回放中'
+      return '-'
+    },
+    mapRow(item) {
+      const tenantId = this.queryParams.tenantId || this.$tenantId
+      return {
+        ...item,
+        companyName: this.resolveTenantName(tenantId)
+      }
+    },
+    buildQueryParams() {
+      return this.$withTenant({ ...this.queryParams })
+    },
     getList() {
-      if (!this.queryParams.tenantId) {
+      if (!this.queryParams.tenantId && !this.$tenantId) {
         this.liveList = []
         this.total = 0
         this.loading = false
         return
       }
+      if (!this.queryParams.tenantId && this.$tenantId) {
+        this.queryParams.tenantId = this.$tenantId
+      }
       this.loading = true
-      listAllLives(this.queryParams).then(response => {
-        this.liveList = response.rows
-        this.total = response.total
+      listAllLives(this.buildQueryParams()).then(response => {
+        this.liveList = (response.rows || []).map(item => this.mapRow(item))
+        this.total = response.total || 0
+        this.loading = false
+      }).catch(() => {
         this.loading = false
       })
     },
     handleTenantChange(val) {
       this.queryParams.tenantId = val || null
+      this.queryParams.pageNum = 1
       if (val) {
-        this.handleQuery()
+        this.getList()
+      } else {
+        this.liveList = []
+        this.total = 0
       }
     },
     handleQuery() {
-      if (!this.queryParams.tenantId) {
+      if (!this.queryParams.tenantId && !this.$tenantId) {
         this.$message.warning('请先选择租户')
         return
       }
@@ -234,31 +403,55 @@ export default {
       this.getList()
     },
     resetQuery() {
-      this.$store.dispatch('tenant/setTenantId', null)
       this.resetForm('queryForm')
+      this.queryParams.liveTitle = null
+      this.queryParams.status = null
+      this.queryParams.pageNum = 1
+      this.$store.dispatch('tenant/setPageTenantId', { scopeKey: this.$route.path, tenantId: null })
       this.queryParams.tenantId = null
-      this.liveList = []
-      this.total = 0
       this.tenantSelectorKey++
     },
     handleView(row) {
-      getLiveInfo(row.liveId).then(response => {
-        this.viewForm = response.data
+      const liveId = row.liveId
+      if (!liveId) {
+        this.$message.warning('直播ID不存在')
+        return
+      }
+      getLiveInfo(liveId, this.buildQueryParams()).then(response => {
+        const data = response.data || {}
+        this.viewForm = {
+          ...data,
+          companyName: this.resolveTenantName(this.queryParams.tenantId || this.$tenantId)
+        }
         this.viewOpen = true
       })
     },
-    showStatisticsDialog() {
-      getLiveStatistics().then(response => {
-        this.statistics = response.data
-        this.showStatistics = true
+    handleStatistics() {
+      if (!this.queryParams.tenantId && !this.$tenantId) {
+        this.$message.warning('请先选择租户')
+        return
+      }
+      this.statisticsLoading = true
+      this.showStatistics = true
+      getLiveStatistics(this.buildQueryParams()).then(response => {
+        this.statistics = response.data || {}
+        this.statisticsLoading = false
+      }).catch(() => {
+        this.statisticsLoading = false
       })
     },
     handleExport() {
+      if (!this.queryParams.tenantId && !this.$tenantId) {
+        this.$message.warning('请先选择租户')
+        return
+      }
       this.exportLoading = true
-      exportAllLives(this.queryParams).then(response => {
+      exportAllLives(this.buildQueryParams()).then(response => {
         this.download(response.msg)
         this.exportLoading = false
-      }).catch(() => { this.exportLoading = false })
+      }).catch(() => {
+        this.exportLoading = false
+      })
     }
   }
 }
@@ -270,6 +463,7 @@ export default {
   padding: 20px;
   background: #f5f7fa;
   border-radius: 8px;
+  margin-bottom: 16px;
 }
 .stat-value {
   font-size: 24px;
@@ -282,6 +476,7 @@ export default {
   font-size: 14px;
   color: #666;
 }
+.mb8 { margin-bottom: 8px; }
 .mb16 { margin-bottom: 16px; }
 .filter-card { padding-bottom: 0; }
 </style>

+ 108 - 20
adminui/src/views/admin/liveVideo/index.vue

@@ -3,10 +3,16 @@
     <el-card shadow="never" class="mb16 filter-card">
       <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" size="small" label-width="100px" class="list-search-form">
         <el-form-item label="租户名称" prop="tenantId">
-          <inline-tenant-selector :key="tenantSelectorKey" @change="handleTenantChange" />
+          <inline-tenant-selector
+            mode="admin"
+            auto-select-first
+            :scope-key="$route.path"
+            :key="tenantSelectorKey"
+            @change="handleTenantChange"
+          />
         </el-form-item>
-        <el-form-item label="视频标题" prop="videoTitle">
-          <el-input v-model="queryParams.videoTitle" placeholder="请输入视频标题" clearable size="small" @keyup.enter.native="handleQuery" />
+        <el-form-item label="视频备注" prop="videoTitle">
+          <el-input v-model="queryParams.videoTitle" placeholder="请输入视频备注或直播名称" clearable size="small" @keyup.enter.native="handleQuery" />
         </el-form-item>
         <el-form-item>
           <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
@@ -16,19 +22,44 @@
     </el-card>
 
     <el-table border size="small" style="width:100%" v-loading="loading" :data="list">
-      <el-table-column label="租户名称" prop="companyName" min-width="120" />
-      <el-table-column label="视频标题" prop="title" :show-overflow-tooltip="true" />
-      <el-table-column label="直播ID" prop="liveId" min-width="100" />
-      <el-table-column label="播放地址" prop="playUrl" :show-overflow-tooltip="true" />
-      <el-table-column label="时长(秒)" prop="duration" min-width="80" />
-      <el-table-column label="状态" prop="status" min-width="80" align="center">
+      <el-table-column label="所属租户" align="center" prop="companyName" min-width="120" show-overflow-tooltip />
+      <el-table-column label="关联直播" align="center" min-width="140" show-overflow-tooltip>
         <template slot-scope="scope">
-          <el-tag :type="scope.row.status === 1 ? 'success' : 'info'" size="mini">
-            {{ scope.row.status === 1 ? '已生成' : '处理中' }}
-          </el-tag>
+          <span>{{ scope.row.liveName || '-' }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="视频备注" align="center" min-width="140" show-overflow-tooltip>
+        <template slot-scope="scope">
+          <span>{{ scope.row.remark || '-' }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="视频地址" align="center" min-width="180" show-overflow-tooltip>
+        <template slot-scope="scope">
+          <el-link v-if="scope.row.videoUrl" type="primary" :href="scope.row.videoUrl" target="_blank">查看</el-link>
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="时长(秒)" align="center" width="90">
+        <template slot-scope="scope">
+          <span>{{ scope.row.duration != null ? scope.row.duration : '-' }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="文件大小" align="center" width="100">
+        <template slot-scope="scope">
+          <span>{{ formatFileSize(scope.row.fileSize) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="转码状态" align="center" width="100">
+        <template slot-scope="scope">
+          <el-tag v-if="toNum(scope.row.finishStatus) === 1" type="success" size="mini">已完成</el-tag>
+          <el-tag v-else type="info" size="mini">处理中</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="创建时间" align="center" min-width="150">
+        <template slot-scope="scope">
+          <span>{{ formatDateTime(scope.row.createTime) }}</span>
         </template>
       </el-table-column>
-      <el-table-column label="创建时间" prop="createTime" min-width="150" align="center" />
     </el-table>
 
     <pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
@@ -37,6 +68,7 @@
 
 <script>
 import { listAllLiveVideos } from '@/api/admin/liveVideo'
+import { listCompanyOptions } from '@/api/admin/sysCompany'
 import InlineTenantSelector from '@/components/InlineTenantSelector'
 
 export default {
@@ -48,6 +80,7 @@ export default {
       showSearch: true,
       list: [],
       total: 0,
+      tenantOptions: [],
       queryParams: {
         pageNum: 1,
         pageSize: 10,
@@ -57,29 +90,82 @@ export default {
       tenantSelectorKey: 0
     }
   },
+  created() {
+    this.loadTenantOptions()
+  },
   methods: {
+    toNum(value) {
+      if (value === null || value === undefined || value === '') return null
+      const num = Number(value)
+      return Number.isNaN(num) ? null : num
+    },
+    formatDateTime(value) {
+      if (!value) return '-'
+      if (typeof value === 'string') return value
+      if (Array.isArray(value) && value.length >= 3) {
+        const [year, month, day, hour = 0, minute = 0, second = 0] = value
+        const pad = n => String(n).padStart(2, '0')
+        return `${year}-${pad(month)}-${pad(day)} ${pad(hour)}:${pad(minute)}:${pad(second)}`
+      }
+      return String(value)
+    },
+    formatFileSize(size) {
+      if (size == null || size === '') return '-'
+      const bytes = Number(size)
+      if (Number.isNaN(bytes) || bytes <= 0) return '-'
+      if (bytes < 1024) return bytes + ' B'
+      if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
+      return (bytes / 1024 / 1024).toFixed(2) + ' MB'
+    },
+    loadTenantOptions() {
+      listCompanyOptions().then(response => {
+        this.tenantOptions = response.data || []
+      })
+    },
+    resolveTenantName(tenantId) {
+      if (tenantId == null) return '-'
+      const tenant = this.tenantOptions.find(item => item.id === tenantId)
+      return tenant ? (tenant.tenantName || tenant.companyName || String(tenantId)) : String(tenantId)
+    },
+    mapRow(item) {
+      const tenantId = this.queryParams.tenantId || this.$tenantId
+      return {
+        ...item,
+        companyName: this.resolveTenantName(tenantId)
+      }
+    },
+    buildQueryParams() {
+      return this.$withTenant({ ...this.queryParams })
+    },
     getList() {
-      if (!this.queryParams.tenantId) {
+      if (!this.queryParams.tenantId && !this.$tenantId) {
         this.list = []
         this.total = 0
         this.loading = false
         return
       }
+      if (!this.queryParams.tenantId && this.$tenantId) {
+        this.queryParams.tenantId = this.$tenantId
+      }
       this.loading = true
-      listAllLiveVideos(this.queryParams).then(res => {
-        this.list = res.rows
-        this.total = res.total
+      listAllLiveVideos(this.buildQueryParams()).then(res => {
+        this.list = (res.rows || []).map(item => this.mapRow(item))
+        this.total = res.total || 0
         this.loading = false
       }).catch(() => { this.loading = false })
     },
     handleTenantChange(val) {
       this.queryParams.tenantId = val || null
+      this.queryParams.pageNum = 1
       if (val) {
-        this.handleQuery()
+        this.getList()
+      } else {
+        this.list = []
+        this.total = 0
       }
     },
     handleQuery() {
-      if (!this.queryParams.tenantId) {
+      if (!this.queryParams.tenantId && !this.$tenantId) {
         this.$message.warning('请先选择租户')
         return
       }
@@ -87,8 +173,10 @@ export default {
       this.getList()
     },
     resetQuery() {
-      this.$store.dispatch('tenant/setTenantId', null)
+      this.$store.dispatch('tenant/setPageTenantId', { scopeKey: this.$route.path, tenantId: null })
       this.resetForm('queryForm')
+      this.queryParams.videoTitle = null
+      this.queryParams.pageNum = 1
       this.queryParams.tenantId = null
       this.list = []
       this.total = 0

+ 193 - 103
adminui/src/views/admin/product/index.vue

@@ -3,7 +3,13 @@
     <el-card shadow="never" class="mb16 filter-card">
       <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" size="small" label-width="100px" class="list-search-form">
         <el-form-item label="租户名称" prop="tenantId">
-          <inline-tenant-selector :key="tenantSelectorKey" @change="handleTenantChange" />
+          <inline-tenant-selector
+            mode="admin"
+            auto-select-first
+            :scope-key="$route.path"
+            :key="tenantSelectorKey"
+            @change="handleTenantChange"
+          />
         </el-form-item>
         <el-form-item label="商品名称" prop="productName">
           <el-input
@@ -14,14 +20,14 @@
             @keyup.enter.native="handleQuery"
           />
         </el-form-item>
-        <el-form-item label="商品状态" prop="status">
-          <el-select v-model="queryParams.status" placeholder="请选择商品状态" clearable size="small">
+        <el-form-item label="审核状态" prop="status">
+          <el-select v-model="queryParams.status" placeholder="请选择审核状态" clearable size="small">
             <el-option
               v-for="item in statusOptions"
               :key="item.value"
               :label="item.label"
-              :value="item.value">
-            </el-option>
+              :value="item.value"
+            />
           </el-select>
         </el-form-item>
         <el-form-item>
@@ -58,7 +64,7 @@
           plain
           icon="el-icon-bar-chart"
           size="mini"
-          @click="showStatistics = true"
+          @click="handleStatistics"
         >商品统计</el-button>
       </el-col>
       <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
@@ -66,107 +72,128 @@
 
     <el-table border size="small" style="width:100%" v-loading="loading" :data="productList">
       <el-table-column type="selection" width="55" align="center" />
-      <el-table-column label="租户名称" align="center" prop="companyName" min-width="120" />
-      <el-table-column label="商品名称" align="center" prop="productName" min-width="150" show-overflow-tooltip />
-      <el-table-column label="商品图片" align="center" prop="productImage" min-width="100">
+      <el-table-column label="所属租户" align="center" prop="companyName" min-width="120" show-overflow-tooltip />
+      <el-table-column label="商品名称" align="center" min-width="150" show-overflow-tooltip>
+        <template slot-scope="scope">
+          <span>{{ scope.row.productName || '-' }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="商品图片" align="center" width="100">
         <template slot-scope="scope">
           <el-image
-            v-if="scope.row.productImage"
-            style="width: 80px; height: 60px"
-            :src="scope.row.productImage"
-            fit="contain" />
+            v-if="resolveProductImage(scope.row)"
+            style="width: 56px; height: 56px; border-radius: 4px;"
+            :src="resolveProductImage(scope.row)"
+            fit="cover"
+            :preview-src-list="[resolveProductImage(scope.row)]"
+          />
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="商品分类" align="center" min-width="100" show-overflow-tooltip>
+        <template slot-scope="scope">
+          <span>{{ scope.row.categoryName || '-' }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="商品价格" align="center" width="90">
+        <template slot-scope="scope">
+          <span>{{ scope.row.price != null ? scope.row.price : '-' }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="库存数量" align="center" width="80">
+        <template slot-scope="scope">
+          <span>{{ scope.row.stock != null ? scope.row.stock : '-' }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="审核状态" align="center" width="100">
+        <template slot-scope="scope">
+          <el-tag v-if="String(scope.row.isAudit) === '0'" type="warning" size="mini">待审核</el-tag>
+          <el-tag v-else-if="String(scope.row.isAudit) === '1'" type="success" size="mini">审核通过</el-tag>
+          <el-tag v-else-if="String(scope.row.isAudit) === '2'" type="danger" size="mini">审核退回</el-tag>
+          <span v-else>-</span>
         </template>
       </el-table-column>
-      <el-table-column label="商品分类" align="center" prop="categoryName" min-width="100" />
-      <el-table-column label="商品价格" align="center" prop="price" min-width="90" />
-      <el-table-column label="库存数量" align="center" prop="stock" min-width="80" />
-      <el-table-column label="创建时间" align="center" prop="createTime" min-width="150" />
-      <el-table-column label="商品状态" align="center" prop="status" min-width="90">
+      <el-table-column label="创建时间" align="center" min-width="150">
         <template slot-scope="scope">
-          <el-tag v-if="scope.row.status == 0" type="warning">待审核</el-tag>
-          <el-tag v-else-if="scope.row.status == 1" type="success">已上架</el-tag>
-          <el-tag v-else type="error">已拒绝</el-tag>
+          <span>{{ formatDateTime(scope.row.createTime) }}</span>
         </template>
       </el-table-column>
-      <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
+      <el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="180" fixed="right">
         <template slot-scope="scope">
-          <el-button
-            size="mini"
-            type="text"
-            icon="el-icon-search"
-            plain
-            @click="handleView(scope.row)"
-          >详情</el-button>
-          <template v-if="scope.row.status == 0">
+          <el-button size="mini" type="text" icon="el-icon-view" @click="handleView(scope.row)">详情</el-button>
+          <template v-if="String(scope.row.isAudit) === '0'">
             <el-button
               size="mini"
               type="text"
               icon="el-icon-check"
-              plain
-              @click="handleAudit(scope.row, 1)"
+              @click="handleAudit(scope.row, '1')"
               v-hasPermi="['admin:product:audit']"
-            >上架</el-button>
+            >通过</el-button>
             <el-button
               size="mini"
               type="text"
               icon="el-icon-close"
-              plain
-              @click="handleAudit(scope.row, 2)"
+              @click="handleAudit(scope.row, '2')"
               v-hasPermi="['admin:product:audit']"
-            >拒绝</el-button>
+            >退回</el-button>
           </template>
         </template>
       </el-table-column>
     </el-table>
 
     <pagination
-      v-show="total>0"
+      v-show="total > 0"
       :total="total"
       :page.sync="queryParams.pageNum"
       :limit.sync="queryParams.pageSize"
       @pagination="getList"
     />
 
-    <!-- 详情弹窗 -->
-    <el-dialog title="商品详情" :visible.sync="viewOpen" width="600px" append-to-body>
-      <el-form :model="viewForm" label-width="100px">
-        <el-form-item label="租户名称">
-          <span>{{ viewForm.companyName }}</span>
+    <el-dialog title="商品详情" :visible.sync="viewOpen" width="640px" append-to-body>
+      <el-form :model="viewForm" label-width="100px" size="small">
+        <el-form-item label="所属租户">
+          <span>{{ viewForm.companyName || '-' }}</span>
         </el-form-item>
         <el-form-item label="商品名称">
-          <span>{{ viewForm.productName }}</span>
+          <span>{{ viewForm.productName || '-' }}</span>
         </el-form-item>
         <el-form-item label="商品图片">
-          <el-image v-if="viewForm.productImage" style="width: 120px; height: 90px" :src="viewForm.productImage" fit="contain" />
+          <el-image
+            v-if="resolveProductImage(viewForm)"
+            style="width: 120px; height: 90px; border-radius: 6px;"
+            :src="resolveProductImage(viewForm)"
+            fit="cover"
+          />
+          <span v-else>-</span>
         </el-form-item>
         <el-form-item label="商品分类">
-          <span>{{ viewForm.categoryName }}</span>
+          <span>{{ viewForm.categoryName || '-' }}</span>
         </el-form-item>
         <el-form-item label="商品价格">
-          <span>{{ viewForm.price }}</span>
+          <span>{{ viewForm.price != null ? viewForm.price : '-' }}</span>
         </el-form-item>
         <el-form-item label="库存数量">
-          <span>{{ viewForm.stock }}</span>
+          <span>{{ viewForm.stock != null ? viewForm.stock : '-' }}</span>
         </el-form-item>
         <el-form-item label="商品描述">
-          <span>{{ viewForm.description }}</span>
+          <span>{{ viewForm.description || viewForm.productInfo || '-' }}</span>
         </el-form-item>
         <el-form-item label="创建时间">
-          <span>{{ viewForm.createTime }}</span>
+          <span>{{ formatDateTime(viewForm.createTime) }}</span>
         </el-form-item>
-        <el-form-item label="商品状态">
-          <el-tag v-if="viewForm.status == 0" type="warning">待审核</el-tag>
-          <el-tag v-else-if="viewForm.status == 1" type="success">已上架</el-tag>
-          <el-tag v-else type="error">已拒绝</el-tag>
+        <el-form-item label="审核状态">
+          <el-tag v-if="String(viewForm.isAudit) === '0'" type="warning" size="mini">待审核</el-tag>
+          <el-tag v-else-if="String(viewForm.isAudit) === '1'" type="success" size="mini">审核通过</el-tag>
+          <el-tag v-else-if="String(viewForm.isAudit) === '2'" type="danger" size="mini">审核退回</el-tag>
+          <span v-else>-</span>
         </el-form-item>
       </el-form>
     </el-dialog>
 
-    <!-- 审核弹窗 -->
     <el-dialog :title="auditTitle" :visible.sync="auditOpen" width="400px" append-to-body>
       <el-form ref="auditForm" :model="auditForm" label-width="80px">
         <el-form-item label="审核备注">
-          <el-textarea v-model="auditForm.auditRemark" placeholder="请输入审核备注" :rows="3" />
+          <el-input v-model="auditForm.auditRemark" type="textarea" placeholder="请输入审核备注" :rows="3" />
         </el-form-item>
       </el-form>
       <div slot="footer" class="dialog-footer">
@@ -174,32 +201,31 @@
       </div>
     </el-dialog>
 
-    <!-- 统计弹窗 -->
     <el-dialog title="商品统计" :visible.sync="showStatistics" width="600px" append-to-body>
-      <el-card>
+      <el-card v-loading="statisticsLoading">
         <el-row :gutter="20">
           <el-col :span="12">
             <div class="stat-item">
-              <span class="stat-value">{{ statistics.totalCount }}</span>
+              <span class="stat-value">{{ statistics.totalCount || 0 }}</span>
               <span class="stat-label">商品总数</span>
             </div>
           </el-col>
           <el-col :span="12">
             <div class="stat-item">
-              <span class="stat-value">{{ statistics.pendingCount }}</span>
+              <span class="stat-value">{{ statistics.pendingCount || 0 }}</span>
               <span class="stat-label">待审核</span>
             </div>
           </el-col>
           <el-col :span="12">
             <div class="stat-item">
-              <span class="stat-value">{{ statistics.onlineCount }}</span>
-              <span class="stat-label">已上架</span>
+              <span class="stat-value">{{ statistics.onlineCount || 0 }}</span>
+              <span class="stat-label">审核通过</span>
             </div>
           </el-col>
           <el-col :span="12">
             <div class="stat-item">
-              <span class="stat-value">{{ statistics.rejectedCount }}</span>
-              <span class="stat-label">已拒绝</span>
+              <span class="stat-value">{{ statistics.rejectedCount || 0 }}</span>
+              <span class="stat-label">审核退回</span>
             </div>
           </el-col>
         </el-row>
@@ -210,6 +236,7 @@
 
 <script>
 import { listAllProducts, listPendingProducts, getProductInfo, auditProduct, getProductStatistics, exportAllProducts } from '@/api/admin/product'
+import { listCompanyOptions } from '@/api/admin/sysCompany'
 import InlineTenantSelector from '@/components/InlineTenantSelector'
 
 export default {
@@ -219,9 +246,11 @@ export default {
     return {
       loading: false,
       exportLoading: false,
+      statisticsLoading: false,
       showSearch: true,
       total: 0,
       productList: [],
+      tenantOptions: [],
       statistics: {
         totalCount: 0,
         pendingCount: 0,
@@ -229,11 +258,10 @@ export default {
         rejectedCount: 0
       },
       statusOptions: [
-        { value: 0, label: '待审核' },
-        { value: 1, label: '已上架' },
-        { value: 2, label: '已拒绝' }
+        { value: '0', label: '待审核' },
+        { value: '1', label: '审核通过' },
+        { value: '2', label: '审核退回' }
       ],
-      // 查询参数
       queryParams: {
         pageNum: 1,
         pageSize: 10,
@@ -241,10 +269,8 @@ export default {
         productName: null,
         status: null
       },
-      // 详情弹窗
       viewOpen: false,
       viewForm: {},
-      // 审核弹窗
       auditOpen: false,
       auditTitle: '',
       auditForm: {
@@ -252,96 +278,158 @@ export default {
         status: null,
         auditRemark: ''
       },
-      // 统计弹窗
       showStatistics: false,
-      // 租户选择器 key,用于重置时强制重建组件
       tenantSelectorKey: 0
     }
   },
+  created() {
+    this.loadTenantOptions()
+  },
   methods: {
+    formatDateTime(value) {
+      if (!value) return '-'
+      if (typeof value === 'string') return value
+      if (Array.isArray(value) && value.length >= 3) {
+        const [year, month, day, hour = 0, minute = 0, second = 0] = value
+        const pad = n => String(n).padStart(2, '0')
+        return `${year}-${pad(month)}-${pad(day)} ${pad(hour)}:${pad(minute)}:${pad(second)}`
+      }
+      return String(value)
+    },
+    resolveProductImage(row) {
+      return row.image || row.sliderImage || row.video || ''
+    },
+    loadTenantOptions() {
+      listCompanyOptions().then(response => {
+        this.tenantOptions = response.data || []
+      })
+    },
+    resolveTenantName(tenantId) {
+      if (tenantId == null) return '-'
+      const tenant = this.tenantOptions.find(item => item.id === tenantId)
+      return tenant ? (tenant.tenantName || tenant.companyName || String(tenantId)) : String(tenantId)
+    },
+    mapRow(item) {
+      const tenantId = this.queryParams.tenantId || this.$tenantId
+      return {
+        ...item,
+        companyName: this.resolveTenantName(tenantId)
+      }
+    },
+    buildQueryParams() {
+      return this.$withTenant({ ...this.queryParams })
+    },
+    ensureTenant() {
+      if (!this.queryParams.tenantId && this.$tenantId) {
+        this.queryParams.tenantId = this.$tenantId
+      }
+      return !!(this.queryParams.tenantId || this.$tenantId)
+    },
     getList() {
-      if (!this.queryParams.tenantId) {
+      if (!this.ensureTenant()) {
         this.productList = []
         this.total = 0
         this.loading = false
         return
       }
       this.loading = true
-      listAllProducts(this.queryParams).then(response => {
-        this.productList = response.rows
-        this.total = response.total
+      listAllProducts(this.buildQueryParams()).then(response => {
+        this.productList = (response.rows || []).map(item => this.mapRow(item))
+        this.total = response.total || 0
         this.loading = false
-      })
+      }).catch(() => { this.loading = false })
     },
-    /** 查询待审核列表 */
     handlePending() {
+      if (!this.ensureTenant()) {
+        this.$message.warning('请先选择租户')
+        return
+      }
       this.loading = true
-      listPendingProducts().then(response => {
-        this.productList = response.rows
-        this.total = response.total
+      listPendingProducts(this.buildQueryParams()).then(response => {
+        this.productList = (response.rows || []).map(item => this.mapRow(item))
+        this.total = response.total || 0
         this.loading = false
-      })
+      }).catch(() => { this.loading = false })
     },
     handleTenantChange(val) {
       this.queryParams.tenantId = val || null
+      this.queryParams.pageNum = 1
       if (val) {
-        this.handleQuery()
+        this.getList()
+      } else {
+        this.productList = []
+        this.total = 0
       }
     },
-    /** 搜索按钮操作 */
     handleQuery() {
-      if (!this.queryParams.tenantId) {
+      if (!this.ensureTenant()) {
         this.$message.warning('请先选择租户')
         return
       }
       this.queryParams.pageNum = 1
       this.getList()
     },
-    /** 重置按钮操作 */
     resetQuery() {
-      this.$store.dispatch('tenant/setTenantId', null)
+      this.$store.dispatch('tenant/setPageTenantId', { scopeKey: this.$route.path, tenantId: null })
       this.resetForm('queryForm')
+      this.queryParams.productName = null
+      this.queryParams.status = null
+      this.queryParams.pageNum = 1
       this.queryParams.tenantId = null
       this.productList = []
       this.total = 0
       this.tenantSelectorKey++
     },
-    /** 查看详情 */
     handleView(row) {
-      getProductInfo(row.productId).then(response => {
-        this.viewForm = response.data
+      getProductInfo(row.productId, this.buildQueryParams()).then(response => {
+        const data = response.data || {}
+        this.viewForm = {
+          ...data,
+          companyName: this.resolveTenantName(this.queryParams.tenantId || this.$tenantId)
+        }
         this.viewOpen = true
       })
     },
-    /** 审核商品 */
     handleAudit(row, status) {
-      this.auditTitle = status === 1 ? '上架商品' : '拒绝商品'
+      this.auditTitle = status === '1' ? '审核通过' : '审核退回'
       this.auditForm = {
         productId: row.productId,
-        status: status,
+        status,
         auditRemark: ''
       }
       this.auditOpen = true
     },
-    /** 提交审核 */
     submitAudit() {
-      auditProduct(this.auditForm.productId, this.auditForm.status, this.auditForm.auditRemark).then(response => {
-        this.$message.success(this.auditForm.status === 1 ? '上架成功' : '拒绝成功')
+      auditProduct(
+        this.auditForm.productId,
+        this.auditForm.status,
+        this.auditForm.auditRemark,
+        this.buildQueryParams()
+      ).then(() => {
+        this.$message.success(this.auditForm.status === '1' ? '审核通过' : '已退回')
         this.auditOpen = false
         this.getList()
       })
     },
-    /** 查看统计 */
-    showStatisticsDialog() {
-      getProductStatistics().then(response => {
-        this.statistics = response.data
-        this.showStatistics = true
-      })
+    handleStatistics() {
+      if (!this.ensureTenant()) {
+        this.$message.warning('请先选择租户')
+        return
+      }
+      this.statisticsLoading = true
+      this.showStatistics = true
+      getProductStatistics(this.buildQueryParams()).then(response => {
+        this.statistics = response.data || {}
+        this.statisticsLoading = false
+      }).catch(() => { this.statisticsLoading = false })
     },
-    /** 导出按钒操作 */
     handleExport() {
+      if (!this.ensureTenant()) {
+        this.$message.warning('请先选择租户')
+        return
+      }
       this.exportLoading = true
-      exportAllProducts(this.queryParams).then(response => {
+      exportAllProducts(this.buildQueryParams()).then(response => {
         this.download(response.msg)
         this.exportLoading = false
       }).catch(() => { this.exportLoading = false })
@@ -356,6 +444,7 @@ export default {
   padding: 20px;
   background: #f5f7fa;
   border-radius: 8px;
+  margin-bottom: 16px;
 }
 .stat-value {
   font-size: 24px;
@@ -368,6 +457,7 @@ export default {
   font-size: 14px;
   color: #666;
 }
+.mb8 { margin-bottom: 8px; }
 .mb16 { margin-bottom: 16px; }
 .filter-card { padding-bottom: 0; }
 </style>

+ 2 - 2
adminui/src/views/admin/rechargeRecord/index.vue

@@ -30,7 +30,7 @@
           end-placeholder="结束日期"
           value-format="yyyy-MM-dd"
           size="small"
-         
+
         />
       </el-form-item>
       <el-form-item>
@@ -89,7 +89,7 @@
 
     <pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
 
-    <el-dialog :title="detail.operateType === 'deduct' ? '扣款记录详情' : '充值记录详情'" :visible.sync="detailVisible" width="500px" append-to-body>
+    <el-dialog :title="detail.operateType === 'deduct' ? '扣款记录详情' : '充值记录详情'" :visible.sync="detailVisible" width="600px" append-to-body>
       <el-descriptions :column="2" border>
         <el-descriptions-item label="租户ID">{{ detail.tenantId }}</el-descriptions-item>
         <el-descriptions-item label="租户名称">{{ detail.tenantName }}</el-descriptions-item>

+ 47 - 5
adminui/src/views/admin/smsCard/index.vue

@@ -326,6 +326,9 @@
       <el-table-column label="心跳URL" align="center" prop="heartbeatUrl" min-width="180" show-overflow-tooltip />
       <el-table-column label="重试次数" align="center" prop="maxRetry" width="80" />
       <el-table-column label="超时(秒)" align="center" prop="timeoutSeconds" width="80" />
+      <el-table-column label="最大负载" align="center" prop="maxLoad" width="90">
+        <template slot-scope="scope">{{ scope.row.maxLoad != null && scope.row.maxLoad !== '' ? scope.row.maxLoad : '不限' }}</template>
+      </el-table-column>
       <el-table-column label="状态" align="center" prop="status" width="70">
         <template slot-scope="scope">
           <el-tag :type="scope.row.status === 1 ? 'success' : 'danger'" size="mini">{{ scope.row.status === 1 ? '正常' : '禁用' }}</el-tag>
@@ -366,6 +369,17 @@
             </el-form-item>
           </el-col>
         </el-row>
+        <el-form-item label="最大负载" prop="maxLoad">
+          <el-input-number
+            v-model="mwForm.maxLoad"
+            :max="99999"
+            :precision="0"
+            controls-position="right"
+            placeholder="留空表示不限制"
+            style="width:100%"
+          />
+          <div class="form-tip">可绑定设备数上限,留空表示不限制</div>
+        </el-form-item>
         <el-form-item label="状态">
           <el-switch v-model="mwForm.status" :active-value="1" :inactive-value="0" active-text="正常" inactive-text="禁用" />
         </el-form-item>
@@ -424,7 +438,22 @@ export default {
       mwForm: {},
       mwRules: {
         middlewareName: [{ required: true, message: '名称不能为空', trigger: 'blur' }],
-        callbackUrl: [{ required: true, message: '回调URL不能为空', trigger: 'blur' }]
+        callbackUrl: [{ required: true, message: '回调URL不能为空', trigger: 'blur' }],
+        maxLoad: [{
+          validator: (rule, value, callback) => {
+            if (value === undefined || value === null || value === '') {
+              callback()
+              return
+            }
+            const num = Number(value)
+            if (!Number.isInteger(num) || num < 1) {
+              callback(new Error('须为正整数,留空表示不限制'))
+            } else {
+              callback()
+            }
+          },
+          trigger: 'blur'
+        }]
       }
     }
   },
@@ -562,7 +591,7 @@ export default {
     },
     // ========== 中间件 ==========
     resetMwForm() {
-      this.mwForm = { id: undefined, apiId: undefined, middlewareName: '', callbackUrl: '', heartbeatUrl: '', authToken: '', maxRetry: 3, timeoutSeconds: 30, status: 1 }
+      this.mwForm = { id: undefined, apiId: undefined, middlewareName: '', callbackUrl: '', heartbeatUrl: '', authToken: '', maxRetry: 3, maxLoad: undefined, timeoutSeconds: 30, status: 1 }
     },
     handleAddMw() {
       this.resetMwForm()
@@ -571,21 +600,28 @@ export default {
     },
     handleEditMw(row) {
       this.resetMwForm()
-      this.mwForm = { ...row }
+      this.mwForm = {
+        ...row,
+        maxLoad: row.maxLoad != null && row.maxLoad !== '' ? Number(row.maxLoad) : undefined
+      }
       this.mwDialogTitle = '修改中间件'
       this.mwDialogVisible = true
     },
     submitMwForm() {
       this.$refs.mwForm.validate(valid => {
         if (!valid) return
+        const payload = { ...this.mwForm }
+        if (payload.maxLoad === '' || payload.maxLoad === undefined || payload.maxLoad === null) {
+          payload.maxLoad = null
+        }
         if (this.mwForm.id) {
-          updateMiddleware(this.mwForm).then(() => {
+          updateMiddleware(payload).then(() => {
             this.$message.success('修改成功')
             this.mwDialogVisible = false
             this.loadMwList()
           })
         } else {
-          addMiddleware(this.mwForm).then(() => {
+          addMiddleware(payload).then(() => {
             this.$message.success('新增成功')
             this.mwDialogVisible = false
             this.loadMwList()
@@ -613,4 +649,10 @@ export default {
 .card-desc-block {
   margin-bottom: 12px;
 }
+.form-tip {
+  color: #909399;
+  font-size: 12px;
+  line-height: 1.4;
+  margin-top: 4px;
+}
 </style>

+ 141 - 38
adminui/src/views/admin/storeOrder/index.vue

@@ -3,7 +3,13 @@
     <el-card shadow="never" class="mb16 filter-card">
       <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" size="small" label-width="100px" class="list-search-form">
         <el-form-item label="租户名称" prop="tenantId">
-          <inline-tenant-selector :key="tenantSelectorKey" @change="handleTenantChange" />
+          <inline-tenant-selector
+            mode="admin"
+            auto-select-first
+            :scope-key="$route.path"
+            :key="tenantSelectorKey"
+            @change="handleTenantChange"
+          />
         </el-form-item>
         <el-form-item label="订单编号" prop="orderNo">
           <el-input v-model="queryParams.orderNo" placeholder="请输入订单编号" clearable size="small" @keyup.enter.native="handleQuery" />
@@ -23,32 +29,48 @@
     </el-row>
 
     <el-table border size="small" style="width:100%" v-loading="loading" :data="list">
-      <el-table-column label="租户名称" align="center" prop="companyName" min-width="150" />
-      <el-table-column label="订单编号" align="center" prop="orderNo" width="180" />
-      <el-table-column label="购买人" align="center" prop="userName" width="120" show-overflow-tooltip />
-      <el-table-column label="订单金额" align="center" prop="orderAmount" width="120">
+      <el-table-column label="所属租户" align="center" prop="companyName" min-width="120" show-overflow-tooltip />
+      <el-table-column label="订单编号" align="center" min-width="180" show-overflow-tooltip>
         <template slot-scope="scope">
-          <span style="color:#fa8c16;font-weight:bold">¥{{ scope.row.orderAmount ? Number(scope.row.orderAmount).toFixed(2) : '0.00' }}</span>
+          <span>{{ scope.row.orderCode || '-' }}</span>
         </template>
       </el-table-column>
-      <el-table-column label="支付方式" align="center" prop="payType" width="110">
-        <template slot-scope="scope">{{ payTypeText(scope.row.payType) }}</template>
+      <el-table-column label="收货人" align="center" width="100">
+        <template slot-scope="scope">
+          <span>{{ scope.row.realName || '-' }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="订单金额" align="center" width="100">
+        <template slot-scope="scope">
+          <span>{{ scope.row.payPrice != null ? scope.row.payPrice : (scope.row.totalPrice != null ? scope.row.totalPrice : '-') }}</span>
+        </template>
       </el-table-column>
-      <el-table-column label="订单状态" align="center" prop="orderStatus" width="100">
+      <el-table-column label="支付方式" align="center" width="100">
         <template slot-scope="scope">
-          <el-tag v-if="orderStatusMap[String(scope.row.orderStatus)]" :type="orderStatusMap[String(scope.row.orderStatus)].type" size="mini">{{ orderStatusMap[String(scope.row.orderStatus)].label }}</el-tag>
-          <el-tag v-else type="info" size="mini">{{ scope.row.orderStatus }}</el-tag>
+          <span>{{ resolvePayType(scope.row.payType) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="订单状态" align="center" width="100">
+        <template slot-scope="scope">
+          <el-tag :type="resolveOrderStatusType(scope.row.status)" size="mini">
+            {{ resolveOrderStatus(scope.row.status) }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="创建时间" align="center" min-width="150">
+        <template slot-scope="scope">
+          <span>{{ formatDateTime(scope.row.createTime) }}</span>
         </template>
       </el-table-column>
-      <el-table-column label="创建时间" align="center" prop="createTime" width="160" />
     </el-table>
 
-    <pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
+    <pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
   </div>
 </template>
 
 <script>
 import { listStoreOrder, exportStoreOrder } from '@/api/admin/storeOrder'
+import { listCompanyOptions } from '@/api/admin/sysCompany'
 import InlineTenantSelector from '@/components/InlineTenantSelector'
 
 export default {
@@ -56,50 +78,124 @@ export default {
   components: { InlineTenantSelector },
   data() {
     return {
-      loading: false, exportLoading: false, showSearch: true,
-      list: [], total: 0,
+      loading: false,
+      exportLoading: false,
+      showSearch: true,
+      list: [],
+      total: 0,
+      tenantOptions: [],
       tenantSelectorKey: 0,
-      payTypeMap: { '1': '全款支付', '2': '物流代收', '3': '货到付款', '4': '全款支付+物流代收' },
-      orderStatusMap: {
-        '-1': { label: '退款中', type: 'warning' },
-        '-2': { label: '退款成功', type: 'info' },
-        '-3': { label: '已取消', type: 'danger' },
-        '1': { label: '待支付', type: 'warning' },
-        '2': { label: '待发货', type: '' },
-        '3': { label: '待收货', type: '' },
-        '4': { label: '已完成', type: 'success' },
-        '5': { label: '待推送', type: 'info' }
-      },
-      queryParams: { pageNum: 1, pageSize: 10, orderNo: null, tenantId: null }
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        orderNo: null,
+        tenantId: null
+      }
     }
   },
+  created() {
+    this.loadTenantOptions()
+  },
   methods: {
-    payTypeText(val) {
-      if (val === null || val === undefined || val === '') return '-'
-      return this.payTypeMap[String(val)] || String(val)
+    toNum(value) {
+      if (value === null || value === undefined || value === '') return null
+      const num = Number(value)
+      return Number.isNaN(num) ? null : num
+    },
+    formatDateTime(value) {
+      if (!value) return '-'
+      if (typeof value === 'string') return value
+      if (Array.isArray(value) && value.length >= 3) {
+        const [year, month, day, hour = 0, minute = 0, second = 0] = value
+        const pad = n => String(n).padStart(2, '0')
+        return `${year}-${pad(month)}-${pad(day)} ${pad(hour)}:${pad(minute)}:${pad(second)}`
+      }
+      return String(value)
+    },
+    resolvePayType(payType) {
+      const val = String(payType == null ? '' : payType)
+      if (!val) return '-'
+      const map = {
+        '1': '微信',
+        '2': '支付宝',
+        '3': '余额',
+        'yue': '余额',
+        'weixin': '微信',
+        'alipay': '支付宝'
+      }
+      return map[val] || val
+    },
+    resolveOrderStatus(status) {
+      const val = this.toNum(status)
+      if (val === -1) return '申请退款'
+      if (val === -2) return '退款成功'
+      if (val === -3) return '已取消'
+      if (val === 0) return '待支付'
+      if (val === 1) return '待发货'
+      if (val === 2) return '待收货'
+      if (val === 3) return '已完成'
+      if (val === 4) return '待评价'
+      return val != null ? String(val) : '-'
+    },
+    resolveOrderStatusType(status) {
+      const val = this.toNum(status)
+      if (val === 3 || val === 4) return 'success'
+      if (val === -1 || val === -2 || val === -3) return 'danger'
+      if (val === 0 || val === 1) return 'warning'
+      return 'info'
+    },
+    loadTenantOptions() {
+      listCompanyOptions().then(response => {
+        this.tenantOptions = response.data || []
+      })
+    },
+    resolveTenantName(tenantId) {
+      if (tenantId == null) return '-'
+      const tenant = this.tenantOptions.find(item => item.id === tenantId)
+      return tenant ? (tenant.tenantName || tenant.companyName || String(tenantId)) : String(tenantId)
+    },
+    mapRow(item) {
+      const tenantId = this.queryParams.tenantId || this.$tenantId
+      return {
+        ...item,
+        companyName: this.resolveTenantName(tenantId)
+      }
+    },
+    buildQueryParams() {
+      return this.$withTenant({ ...this.queryParams })
+    },
+    ensureTenant() {
+      if (!this.queryParams.tenantId && this.$tenantId) {
+        this.queryParams.tenantId = this.$tenantId
+      }
+      return !!(this.queryParams.tenantId || this.$tenantId)
     },
     getList() {
-      if (!this.queryParams.tenantId) {
+      if (!this.ensureTenant()) {
         this.list = []
         this.total = 0
         this.loading = false
         return
       }
       this.loading = true
-      listStoreOrder(this.queryParams).then(res => {
-        this.list = res.rows || []
+      listStoreOrder(this.buildQueryParams()).then(res => {
+        this.list = (res.rows || []).map(item => this.mapRow(item))
         this.total = res.total || 0
         this.loading = false
       }).catch(() => { this.loading = false })
     },
     handleTenantChange(val) {
+      this.queryParams.tenantId = val || null
+      this.queryParams.pageNum = 1
       if (val) {
-        this.queryParams.tenantId = val
-        this.handleQuery()
+        this.getList()
+      } else {
+        this.list = []
+        this.total = 0
       }
     },
     handleQuery() {
-      if (!this.queryParams.tenantId) {
+      if (!this.ensureTenant()) {
         this.$message.warning('请先选择租户')
         return
       }
@@ -108,15 +204,21 @@ export default {
     },
     resetQuery() {
       this.resetForm('queryForm')
+      this.queryParams.orderNo = null
+      this.queryParams.pageNum = 1
       this.list = []
       this.total = 0
       this.queryParams.tenantId = null
-      this.$store.dispatch('tenant/setTenantId', null)
+      this.$store.dispatch('tenant/setPageTenantId', { scopeKey: this.$route.path, tenantId: null })
       this.tenantSelectorKey++
     },
     handleExport() {
+      if (!this.ensureTenant()) {
+        this.$message.warning('请先选择租户')
+        return
+      }
       this.exportLoading = true
-      exportStoreOrder(this.queryParams).then(response => {
+      exportStoreOrder(this.buildQueryParams()).then(response => {
         this.download(response.msg)
         this.exportLoading = false
       }).catch(() => { this.exportLoading = false })
@@ -126,6 +228,7 @@ export default {
 </script>
 
 <style scoped>
+.mb8 { margin-bottom: 8px; }
 .mb16 { margin-bottom: 16px; }
 .filter-card { padding-bottom: 0; }
 </style>

+ 164 - 22
adminui/src/views/admin/videoResource/index.vue

@@ -3,10 +3,31 @@
     <el-card shadow="never" class="mb16 filter-card">
       <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" size="small" label-width="100px" class="list-search-form">
         <el-form-item label="租户名称" prop="tenantId">
-          <inline-tenant-selector @change="handleTenantChange" />
+          <inline-tenant-selector
+            mode="admin"
+            auto-select-first
+            :scope-key="$route.path"
+            :key="tenantSelectorKey"
+            @change="handleTenantChange"
+          />
         </el-form-item>
-        <el-form-item label="视频名称" prop="videoName">
-          <el-input v-model="queryParams.videoName" placeholder="请输入视频名称" clearable size="small" @keyup.enter.native="handleQuery" />
+        <el-form-item label="素材名称" prop="resourceName">
+          <el-input
+            v-model="queryParams.resourceName"
+            placeholder="请输入素材名称"
+            clearable
+            size="small"
+            @keyup.enter.native="handleQuery"
+          />
+        </el-form-item>
+        <el-form-item label="文件名称" prop="fileName">
+          <el-input
+            v-model="queryParams.fileName"
+            placeholder="请输入文件名称"
+            clearable
+            size="small"
+            @keyup.enter.native="handleQuery"
+          />
         </el-form-item>
         <el-form-item label-width="0px">
           <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
@@ -16,27 +37,76 @@
     </el-card>
 
     <el-table border size="small" style="width:100%" v-loading="loading" :data="list">
-      <el-table-column label="租户名称" prop="companyName" width="160" />
-      <el-table-column label="视频名称" prop="videoName" :show-overflow-tooltip="true" />
-      <el-table-column label="视频类型" prop="videoType" width="100" />
-      <el-table-column label="播放地址" prop="playUrl" :show-overflow-tooltip="true" />
-      <el-table-column label="时长(秒)" prop="duration" width="100" />
-      <el-table-column label="状态" prop="status" width="80" align="center">
+      <el-table-column label="所属租户" align="center" prop="companyName" min-width="120" show-overflow-tooltip />
+      <el-table-column label="素材名称" align="center" min-width="140" show-overflow-tooltip>
+        <template slot-scope="scope">
+          <span>{{ scope.row.resourceName || '-' }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="文件名称" align="center" min-width="140" show-overflow-tooltip>
         <template slot-scope="scope">
-          <el-tag :type="scope.row.status === 1 ? 'success' : 'info'" size="mini">
-            {{ scope.row.status === 1 ? '正常' : '禁用' }}
-          </el-tag>
+          <span>{{ scope.row.fileName || '-' }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="时长" align="center" width="90">
+        <template slot-scope="scope">
+          <span>{{ formatDuration(scope.row.duration) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="文件大小" align="center" width="100">
+        <template slot-scope="scope">
+          <span>{{ formatFileSize(scope.row.fileSize) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="转码状态" align="center" width="90">
+        <template slot-scope="scope">
+          <el-tag v-if="scope.row.isTranscode === 1" type="success" size="mini">已转码</el-tag>
+          <el-tag v-else type="info" size="mini">未转码</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="关联题目" align="center" width="90">
+        <template slot-scope="scope">
+          <el-tag v-if="scope.row.projectIds" type="warning" size="mini">已关联</el-tag>
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="预览" align="center" width="80">
+        <template slot-scope="scope">
+          <el-button
+            v-if="scope.row.videoUrl"
+            type="text"
+            size="mini"
+            icon="el-icon-video-play"
+            @click="handlePreview(scope.row)"
+          >预览</el-button>
+          <span v-else>-</span>
         </template>
       </el-table-column>
       <el-table-column label="创建时间" prop="createTime" width="160" align="center" />
     </el-table>
 
-    <pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
+    <pagination
+      v-show="total > 0"
+      :total="total"
+      :page.sync="queryParams.pageNum"
+      :limit.sync="queryParams.pageSize"
+      @pagination="getList"
+    />
+
+    <el-dialog title="视频预览" :visible.sync="previewOpen" width="720px" append-to-body @closed="previewUrl = ''">
+      <video
+        v-if="previewUrl"
+        :src="previewUrl"
+        controls
+        style="width: 100%; max-height: 400px;"
+      />
+    </el-dialog>
   </div>
 </template>
 
 <script>
 import { listAllVideoResources } from '@/api/admin/videoResource'
+import { listCompanyOptions } from '@/api/admin/sysCompany'
 import InlineTenantSelector from '@/components/InlineTenantSelector'
 
 export default {
@@ -48,35 +118,99 @@ export default {
       showSearch: true,
       list: [],
       total: 0,
+      tenantOptions: [],
+      tenantSelectorKey: 0,
+      previewOpen: false,
+      previewUrl: '',
       queryParams: {
         pageNum: 1,
         pageSize: 10,
         tenantId: null,
-        videoName: null
+        resourceName: null,
+        fileName: null
       }
     }
   },
+  created() {
+    this.loadTenantOptions()
+  },
   methods: {
+    loadTenantOptions() {
+      listCompanyOptions().then(response => {
+        this.tenantOptions = response.data || []
+      })
+    },
+    resolveTenantName(tenantId) {
+      if (tenantId == null) {
+        return '-'
+      }
+      const tenant = this.tenantOptions.find(item => item.id === tenantId)
+      return tenant ? (tenant.tenantName || tenant.companyName || String(tenantId)) : String(tenantId)
+    },
+    mapRow(item) {
+      const tenantId = this.queryParams.tenantId || this.$tenantId
+      return {
+        ...item,
+        companyName: this.resolveTenantName(tenantId)
+      }
+    },
+    buildQueryParams() {
+      return this.$withTenant({ ...this.queryParams })
+    },
+    formatDuration(seconds) {
+      if (!seconds) {
+        return '00:00'
+      }
+      const minutes = Math.floor(seconds / 60)
+      const remainingSeconds = seconds % 60
+      return `${String(minutes).padStart(2, '0')}:${String(remainingSeconds).padStart(2, '0')}`
+    },
+    formatFileSize(size) {
+      if (!size) {
+        return '-'
+      }
+      if (size < 1024) {
+        return `${size} B`
+      }
+      if (size < 1024 * 1024) {
+        return `${(size / 1024).toFixed(1)} KB`
+      }
+      if (size < 1024 * 1024 * 1024) {
+        return `${(size / 1024 / 1024).toFixed(1)} MB`
+      }
+      return `${(size / 1024 / 1024 / 1024).toFixed(1)} GB`
+    },
     getList() {
-      if (!this.queryParams.tenantId) {
+      if (!this.queryParams.tenantId && !this.$tenantId) {
         this.list = []
         this.total = 0
         this.loading = false
         return
       }
+      if (!this.queryParams.tenantId && this.$tenantId) {
+        this.queryParams.tenantId = this.$tenantId
+      }
       this.loading = true
-      listAllVideoResources(this.queryParams).then(res => {
-        this.list = res.rows
-        this.total = res.total
+      listAllVideoResources(this.buildQueryParams()).then(res => {
+        this.list = (res.rows || []).map(item => this.mapRow(item))
+        this.total = res.total || 0
         this.loading = false
-      }).catch(() => { this.loading = false })
+      }).catch(() => {
+        this.loading = false
+      })
     },
     handleTenantChange(val) {
       this.queryParams.tenantId = val || null
-      this.handleQuery()
+      this.queryParams.pageNum = 1
+      if (val) {
+        this.getList()
+      } else {
+        this.list = []
+        this.total = 0
+      }
     },
     handleQuery() {
-      if (!this.queryParams.tenantId) {
+      if (!this.queryParams.tenantId && !this.$tenantId) {
         this.$message.warning('请先选择租户')
         return
       }
@@ -85,8 +219,16 @@ export default {
     },
     resetQuery() {
       this.resetForm('queryForm')
+      this.queryParams.resourceName = null
+      this.queryParams.fileName = null
+      this.queryParams.pageNum = 1
+      this.$store.dispatch('tenant/setPageTenantId', { scopeKey: this.$route.path, tenantId: null })
       this.queryParams.tenantId = null
-      this.handleQuery()
+      this.tenantSelectorKey++
+    },
+    handlePreview(row) {
+      this.previewUrl = row.videoUrl
+      this.previewOpen = true
     }
   }
 }

+ 127 - 47
adminui/src/views/admin/voice/index.vue

@@ -3,21 +3,27 @@
     <el-card shadow="never" class="mb16 filter-card">
       <el-form :model="queryParams" ref="queryForm" :inline="true" size="small" label-width="100px" class="list-search-form">
         <el-form-item label="租户名称" prop="tenantId">
-          <inline-tenant-selector @change="handleTenantChange" />
+          <inline-tenant-selector
+            mode="admin"
+            auto-select-first
+            :scope-key="$route.path"
+            :key="tenantSelectorKey"
+            @change="handleTenantChange"
+          />
         </el-form-item>
-        <el-form-item label="任务名称" prop="taskName">
+        <el-form-item label="任务名称" prop="name">
           <el-input
-            v-model="queryParams.taskName"
+            v-model="queryParams.name"
             placeholder="请输入任务名称"
             clearable
             @keyup.enter.native="handleQuery"
           />
         </el-form-item>
-        <el-form-item label="状态" prop="status">
-          <el-select v-model="queryParams.status" placeholder="请选择状态" clearable>
+        <el-form-item label="任务状态" prop="taskStatus">
+          <el-select v-model="queryParams.taskStatus" placeholder="请选择状态" clearable>
             <el-option label="待启动" :value="0" />
-            <el-option label="行中" :value="1" />
-            <el-option label="已停" :value="2" />
+            <el-option label="行中" :value="1" />
+            <el-option label="已停" :value="2" />
             <el-option label="已完成" :value="3" />
           </el-select>
         </el-form-item>
@@ -43,19 +49,33 @@
     </el-row>
 
     <el-table v-loading="loading" :data="dataList" border size="small" style="width:100%">
-      <el-table-column label="任务名称" align="center" prop="taskName" min-width="140" show-overflow-tooltip />
+      <el-table-column label="任务名称" align="center" min-width="140" show-overflow-tooltip>
+        <template slot-scope="scope">
+          <span>{{ scope.row.name || scope.row.taskName || '-' }}</span>
+        </template>
+      </el-table-column>
       <el-table-column label="所属租户" align="center" prop="companyName" min-width="120" show-overflow-tooltip />
-      <el-table-column label="状态" align="center" prop="status" width="100">
+      <el-table-column label="任务类型" align="center" width="110">
+        <template slot-scope="scope">
+          <el-tag v-if="scope.row.taskType === 1" size="mini">普通任务</el-tag>
+          <el-tag v-else-if="scope.row.taskType === 2" size="mini" type="warning">场景任务</el-tag>
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="外呼模式" align="center" width="100">
+        <template slot-scope="scope">
+          <span>{{ resolveCallModel(scope.row.callModel) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="任务状态" align="center" width="100">
         <template slot-scope="scope">
-          <el-tag v-if="scope.row.status === 0" type="info">待启动</el-tag>
-          <el-tag v-else-if="scope.row.status === 1" type="success">运行中</el-tag>
-          <el-tag v-else-if="scope.row.status === 2" type="danger">已停止</el-tag>
-          <el-tag v-else-if="scope.row.status === 3" type="warning">已完成</el-tag>
-          <el-tag v-else type="info">未知</el-tag>
+          <el-tag v-if="scope.row.taskStatus === 0" type="info" size="mini">待启动</el-tag>
+          <el-tag v-else-if="scope.row.taskStatus === 1" type="success" size="mini">执行中</el-tag>
+          <el-tag v-else-if="scope.row.taskStatus === 2" type="danger" size="mini">已停</el-tag>
+          <el-tag v-else-if="scope.row.taskStatus === 3" type="warning" size="mini">已完成</el-tag>
+          <el-tag v-else type="info" size="mini">未知</el-tag>
         </template>
       </el-table-column>
-      <el-table-column label="外呼总数" align="center" prop="callTotal" width="100" />
-      <el-table-column label="成功数" align="center" prop="callSuccess" width="100" />
       <el-table-column label="创建时间" align="center" prop="createTime" width="160" />
       <el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="120">
         <template slot-scope="scope">
@@ -83,40 +103,43 @@
         <el-row :gutter="20">
           <el-col :span="12">
             <el-form-item label="任务名称">
-              <span>{{ detailForm.taskName }}</span>
+              <span>{{ detailForm.name || detailForm.taskName || '-' }}</span>
             </el-form-item>
           </el-col>
           <el-col :span="12">
             <el-form-item label="所属租户">
-              <span>{{ detailForm.companyName }}</span>
+              <span>{{ detailForm.companyName || '-' }}</span>
             </el-form-item>
           </el-col>
           <el-col :span="12">
-            <el-form-item label="状态">
-              <el-tag v-if="detailForm.status === 0" type="info">待启动</el-tag>
-              <el-tag v-else-if="detailForm.status === 1" type="success">运行中</el-tag>
-              <el-tag v-else-if="detailForm.status === 2" type="danger">已停止</el-tag>
-              <el-tag v-else-if="detailForm.status === 3" type="warning">已完成</el-tag>
+            <el-form-item label="任务类型">
+              <span v-if="detailForm.taskType === 1">普通任务</span>
+              <span v-else-if="detailForm.taskType === 2">场景任务</span>
+              <span v-else>-</span>
             </el-form-item>
           </el-col>
           <el-col :span="12">
-            <el-form-item label="外呼总数">
-              <span>{{ detailForm.callTotal }}</span>
+            <el-form-item label="外呼模式">
+              <span>{{ resolveCallModel(detailForm.callModel) }}</span>
             </el-form-item>
           </el-col>
           <el-col :span="12">
-            <el-form-item label="成功数">
-              <span>{{ detailForm.callSuccess }}</span>
+            <el-form-item label="任务状态">
+              <el-tag v-if="detailForm.taskStatus === 0" type="info" size="mini">待启动</el-tag>
+              <el-tag v-else-if="detailForm.taskStatus === 1" type="success" size="mini">执行中</el-tag>
+              <el-tag v-else-if="detailForm.taskStatus === 2" type="danger" size="mini">已暂停</el-tag>
+              <el-tag v-else-if="detailForm.taskStatus === 3" type="warning" size="mini">已完成</el-tag>
+              <el-tag v-else type="info" size="mini">未知</el-tag>
             </el-form-item>
           </el-col>
           <el-col :span="12">
-            <el-form-item label="创建时间">
-              <span>{{ detailForm.createTime }}</span>
+            <el-form-item label="三方任务ID">
+              <span>{{ detailForm.taskId || '-' }}</span>
             </el-form-item>
           </el-col>
-          <el-col :span="24">
-            <el-form-item label="任务描述">
-              <span>{{ detailForm.remark || '-' }}</span>
+          <el-col :span="12">
+            <el-form-item label="创建时间">
+              <span>{{ detailForm.createTime || '-' }}</span>
             </el-form-item>
           </el-col>
         </el-row>
@@ -127,9 +150,9 @@
 
 <script>
 import request from '@/utils/request'
+import { listCompanyOptions } from '@/api/admin/sysCompany'
 import InlineTenantSelector from '@/components/InlineTenantSelector'
 
-// admin专用:走 /admin/ 前缀,代理到 fs-admin 8004
 function listRobotic(query) {
   return request({ url: '/admin/voice-robotic/list', method: 'get', params: query })
 }
@@ -140,47 +163,90 @@ function exportRobotic(query) {
   return request({ url: '/admin/voice-robotic/export', method: 'get', params: query })
 }
 
+const CALL_MODEL_MAP = {
+  1: '人工',
+  2: 'AI自动',
+  3: 'APP网页点呼',
+  4: 'APP自动',
+  5: 'APP-AI'
+}
+
 export default {
   name: 'AdminVoice',
   components: { InlineTenantSelector },
   data() {
     return {
-      loading: true,
+      loading: false,
       exportLoading: false,
       showSearch: true,
       total: 0,
       dataList: [],
+      tenantOptions: [],
+      tenantSelectorKey: 0,
       queryParams: {
         pageNum: 1,
         pageSize: 10,
-        taskName: null,
+        name: null,
         tenantId: null,
-        status: null
+        taskStatus: null
       },
       detailOpen: false,
       detailForm: {}
     }
   },
   created() {
-    this.getList()
+    this.loadTenantOptions()
   },
   methods: {
+    loadTenantOptions() {
+      listCompanyOptions().then(response => {
+        this.tenantOptions = response.data || []
+      })
+    },
+    resolveTenantName(tenantId) {
+      if (tenantId == null) {
+        return '-'
+      }
+      const tenant = this.tenantOptions.find(item => item.id === tenantId)
+      return tenant ? (tenant.tenantName || tenant.companyName || String(tenantId)) : String(tenantId)
+    },
+    resolveCallModel(callModel) {
+      if (callModel == null) {
+        return '-'
+      }
+      return CALL_MODEL_MAP[callModel] || String(callModel)
+    },
+    mapRow(item) {
+      const tenantId = this.queryParams.tenantId || this.$tenantId
+      return {
+        ...item,
+        companyName: this.resolveTenantName(tenantId)
+      }
+    },
+    buildQueryParams() {
+      return this.$withTenant({ ...this.queryParams })
+    },
     getList() {
-      if (!this.queryParams.tenantId) {
+      if (!this.queryParams.tenantId && !this.$tenantId) {
         this.dataList = []
         this.total = 0
         this.loading = false
         return
       }
+      if (!this.queryParams.tenantId && this.$tenantId) {
+        this.queryParams.tenantId = this.$tenantId
+      }
       this.loading = true
-      listRobotic(this.queryParams).then(response => {
-        this.dataList = response.rows
-        this.total = response.total
+      listRobotic(this.buildQueryParams()).then(response => {
+        this.dataList = (response.rows || []).map(item => this.mapRow(item))
+        this.total = response.total || 0
+        this.loading = false
+      }).catch(() => {
         this.loading = false
       })
     },
     handleQuery() {
-      if (!this.queryParams.tenantId) {
+      if (!this.queryParams.tenantId && !this.$tenantId) {
         this.$message.warning('请先选择租户')
         return
       }
@@ -189,26 +255,40 @@ export default {
     },
     handleTenantChange(val) {
       this.queryParams.tenantId = val || null
-      this.handleQuery()
+      this.queryParams.pageNum = 1
+      if (val) {
+        this.getList()
+      } else {
+        this.dataList = []
+        this.total = 0
+      }
     },
     resetQuery() {
       this.resetForm('queryForm')
+      this.queryParams.name = null
+      this.queryParams.taskStatus = null
+      this.queryParams.pageNum = 1
+      this.$store.dispatch('tenant/setPageTenantId', { scopeKey: this.$route.path, tenantId: null })
       this.queryParams.tenantId = null
-      this.handleQuery()
+      this.tenantSelectorKey++
     },
     handleDetail(row) {
       getRobotic(row.id).then(response => {
-        this.detailForm = response.data
+        const data = response.data || {}
+        this.detailForm = {
+          ...data,
+          companyName: this.resolveTenantName(this.queryParams.tenantId || this.$tenantId)
+        }
         this.detailOpen = true
       })
     },
     handleExport() {
-      if (!this.queryParams.tenantId) {
+      if (!this.queryParams.tenantId && !this.$tenantId) {
         this.$message.warning('请先选择租户')
         return
       }
       this.exportLoading = true
-      exportRobotic(this.queryParams).then(response => {
+      exportRobotic(this.buildQueryParams()).then(response => {
         this.download(response.msg)
         this.exportLoading = false
       }).catch(() => {

+ 58 - 34
adminui/src/views/admin/voiceApi/index.vue

@@ -173,6 +173,7 @@
             v-loading="assignLoading"
             empty-text="暂无已分配租户"
           >
+            <el-table-column label="租户ID" align="center" prop="tenantId" width="80" />
             <el-table-column label="租户编码" align="center" prop="tenantCode" min-width="100" show-overflow-tooltip />
             <el-table-column label="租户名称" align="center" prop="tenantName" min-width="120" show-overflow-tooltip />
             <el-table-column label="接口名称" align="center" prop="apiName" min-width="120" show-overflow-tooltip />
@@ -255,6 +256,7 @@
     <!-- 查看已分配租户弹窗 -->
     <el-dialog title="已分配租户" :visible.sync="tenantListOpen" width="600px" append-to-body>
       <el-table :data="viewTenants" border size="small" style="width:100%" max-height="420" v-loading="viewTenantsLoading">
+        <el-table-column label="租户ID" align="center" prop="tenantId" width="80" />
         <el-table-column label="租户编码" align="center" prop="tenantCode" min-width="100" show-overflow-tooltip />
         <el-table-column label="租户名称" align="center" prop="tenantName" min-width="120" show-overflow-tooltip />
         <el-table-column label="接口名称" align="center" prop="apiName" min-width="120" show-overflow-tooltip />
@@ -337,6 +339,8 @@ export default {
       },
       assignTenantScrollWrap: null,
       assignTenantScrollHandler: null,
+      /** 编辑弹窗打开时的原始状态,用于判断是否从启用改为禁用 */
+      apiFormOriginalStatus: null,
       // 查看租户列表
       tenantListOpen: false,
       viewTenants: [],
@@ -431,6 +435,7 @@ export default {
           status: String(data.status),
           remark: data.remark
         }
+        this.apiFormOriginalStatus = String(data.status)
         this.formTitle = '编辑通话接口'
         this.formOpen = true
       })
@@ -450,52 +455,71 @@ export default {
         status: '1',
         remark: null
       }
+      this.apiFormOriginalStatus = null
       this.$nextTick(() => {
         if (this.$refs.apiForm) this.$refs.apiForm.clearValidate()
       })
     },
+    buildApiSubmitData() {
+      const data = { ...this.apiForm }
+      data.apiType = data.apiType != null ? parseInt(data.apiType, 10) : null
+      data.status = data.status != null ? parseInt(data.status, 10) : null
+      if (data.apiType === 0) {
+        data.account = null
+        data.password = null
+        data.apiUrl = null
+        data.dialogUrl = null
+      } else if (data.apiType === 1) {
+        data.account = null
+        data.password = null
+        data.wsUrl = null
+        data.dialogUrl = null
+      } else if (data.apiType === 2) {
+        data.wsUrl = null
+      }
+      return data
+    },
+    doSubmitApiForm(data) {
+      this.formSubmitting = true
+      const request = data.apiId
+        ? updateCompanyVoiceApiV2(data)
+        : addCompanyVoiceApiV2(data)
+      request.then(() => {
+        this.$message.success(data.apiId ? '修改成功' : '新增成功')
+        this.formOpen = false
+        this.getList()
+      }).finally(() => { this.formSubmitting = false })
+    },
     submitApiForm() {
       this.$refs.apiForm.validate(valid => {
         if (!valid) return
-        this.formSubmitting = true
-        const data = { ...this.apiForm }
-        data.apiType = data.apiType != null ? parseInt(data.apiType, 10) : null
-        data.status = data.status != null ? parseInt(data.status, 10) : null
-        if (data.apiType === 0) {
-          data.account = null
-          data.password = null
-          data.apiUrl = null
-          data.dialogUrl = null
-        } else if (data.apiType === 1) {
-          data.account = null
-          data.password = null
-          data.wsUrl = null
-          data.dialogUrl = null
-        } else if (data.apiType === 2) {
-          data.wsUrl = null
-        }
-        if (data.apiId) {
-          updateCompanyVoiceApiV2(data).then(() => {
-            this.$message.success('修改成功')
-            this.formOpen = false
-            this.getList()
-          }).finally(() => { this.formSubmitting = false })
-        } else {
-          addCompanyVoiceApiV2(data).then(() => {
-            this.$message.success('新增成功')
-            this.formOpen = false
-            this.getList()
-          }).finally(() => { this.formSubmitting = false })
+        const data = this.buildApiSubmitData()
+        const isDisabling = data.apiId && data.status === 0 && this.apiFormOriginalStatus === '1'
+        if (isDisabling) {
+          const apiName = data.apiName || '该接口'
+          this.$confirm(
+            `禁用接口「${apiName}」后,已分配并使用该接口的所有租户将同步禁用。此操作会影响到已经在使用的所有租户,是否继续?`,
+            '警告',
+            { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
+          ).then(() => {
+            this.doSubmitApiForm(data)
+          }).catch(() => {})
+          return
         }
+        this.doSubmitApiForm(data)
       })
     },
     // 删除
     handleDelete(row) {
-      this.$confirm('是否确认删除接口"' + row.apiName + '"?', '警告', {
-        confirmButtonText: '确定',
-        cancelButtonText: '取消',
-        type: 'warning'
-      }).then(() => {
+      this.$confirm(
+        `是否确认删除接口「${row.apiName}」?此操作会影响到已经在使用的所有租户,已分配该接口的租户绑定将同步禁用。`,
+        '警告',
+        {
+          confirmButtonText: '确定',
+          cancelButtonText: '取消',
+          type: 'warning'
+        }
+      ).then(() => {
         return delCompanyVoiceApiV2(row.apiId)
       }).then(() => {
         this.$message.success('删除成功')

+ 63 - 3
adminui/src/views/admin/voiceApiTenant/index.vue

@@ -130,12 +130,15 @@
       </el-table-column>
       <el-table-column label="状态" align="center" prop="status" width="72" class-name="status-column">
         <template slot-scope="scope">
-          <el-tooltip :content="scope.row.status === 1 ? '启用' : '禁用'" placement="top">
+          <el-tooltip
+            :content="getStatusSwitchTooltip(scope.row)"
+            placement="top"
+          >
             <el-switch
               v-model="scope.row.status"
               :active-value="1"
               :inactive-value="0"
-              :disabled="statusUpdatingId === scope.row.id"
+              :disabled="statusUpdatingId === scope.row.id || isStatusSwitchLocked(scope.row)"
               @change="val => handleStatusChange(scope.row, val)"
             />
           </el-tooltip>
@@ -212,7 +215,15 @@
           <el-switch v-model="pricingForm.selectable" active-value="1" inactive-value="0" active-text="允许" inactive-text="禁止" />
         </el-form-item>
         <el-form-item label="状态">
-          <el-switch v-model="pricingForm.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
+          <el-switch
+            v-model="pricingForm.status"
+            :active-value="1"
+            :inactive-value="0"
+            :disabled="!isMasterApiEnabled(pricingForm)"
+            active-text="启用"
+            inactive-text="禁用"
+          />
+          <div v-if="!isMasterApiEnabled(pricingForm)" class="status-tip">外呼接口已在接口管理中禁用,无法启用租户绑定</div>
         </el-form-item>
       </el-form>
       <div slot="footer" class="dialog-footer">
@@ -364,6 +375,29 @@ export default {
     isSelectable(row) {
       return row.selectable === '1' || row.selectable === 1 || row.selectable === true
     },
+    isMasterApiEnabled(row) {
+      if (!row) return false
+      const apiStatus = row.apiStatus != null ? row.apiStatus : row.api_status
+      return apiStatus === 1 || apiStatus === '1'
+    },
+    isStatusSwitchLocked(row) {
+      return !this.isMasterApiEnabled(row) && row.status !== 1
+    },
+    getStatusSwitchTooltip(row) {
+      if (!this.isMasterApiEnabled(row)) {
+        return row.status === 1 ? '外呼接口已禁用,请先在接口管理中启用' : '外呼接口已禁用,无法启用租户绑定'
+      }
+      return row.status === 1 ? '启用' : '禁用'
+    },
+    validateEnableBinding(row, targetStatus) {
+      const enabling = targetStatus === 1 || targetStatus === '1'
+      if (!enabling || this.isMasterApiEnabled(row)) {
+        return true
+      }
+      const apiName = (row && row.apiName) || '该接口'
+      this.$message.warning(`外呼接口「${apiName}」已在接口管理中禁用,无法启用租户绑定`)
+      return false
+    },
     normalizeSelectable(value) {
       if (value === 1 || value === true || value === '1') return '1'
       return '0'
@@ -553,6 +587,7 @@ export default {
         id: row.id,
         tenantName: row.tenantName,
         apiName: row.apiName,
+        apiStatus: row.apiStatus != null ? row.apiStatus : row.api_status,
         costPrice: row.costPrice,
         salePrice: row.salePrice,
         priority: row.priority || 1,
@@ -560,12 +595,19 @@ export default {
         selectable: this.normalizeSelectable(row.selectable),
         status: row.status
       }
+      if (!this.isMasterApiEnabled(this.pricingForm) && this.pricingForm.status === 1) {
+        this.pricingForm.status = 0
+      }
       this.pricingOpen = true
       this.$nextTick(() => {
         if (this.$refs.pricingForm) this.$refs.pricingForm.clearValidate()
       })
     },
     submitPricing() {
+      if (!this.validateEnableBinding(this.pricingForm, this.pricingForm.status)) {
+        this.pricingForm.status = 0
+        return
+      }
       this.pricingSubmitting = true
       updateTenantPricingV2({
         id: this.pricingForm.id,
@@ -625,6 +667,14 @@ export default {
         this.$message.warning('请先选择要操作的记录')
         return
       }
+      if (status === 1) {
+        const blockedRows = this.selectedRows.filter(row => !this.isMasterApiEnabled(row))
+        if (blockedRows.length) {
+          const names = blockedRows.map(row => row.apiName || row.apiId).join('、')
+          this.$message.warning(`以下接口已在接口管理中禁用,无法批量启用:${names}`)
+          return
+        }
+      }
       const actionText = status === 1 ? '启用' : '禁用'
       this.$confirm('确认批量' + actionText + '已选 ' + this.selectedRows.length + ' 条记录?', '提示', {
         confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
@@ -640,6 +690,10 @@ export default {
     },
     handleStatusChange(row, status) {
       const previousStatus = status === 1 ? 0 : 1
+      if (!this.validateEnableBinding(row, status)) {
+        row.status = previousStatus
+        return
+      }
       this.statusUpdatingId = row.id
       updateTenantPricingV2({ id: row.id, status }).then(() => {
         this.$message.success(status === 1 ? '已启用' : '已禁用')
@@ -679,6 +733,12 @@ export default {
 .voice-api-tenant-table >>> .status-column .el-switch {
   vertical-align: middle;
 }
+.status-tip {
+  margin-top: 6px;
+  color: #e6a23c;
+  font-size: 12px;
+  line-height: 1.5;
+}
 </style>
 
 <style>

+ 1 - 0
adminui/src/views/admin/withdrawalManage/index.vue

@@ -41,6 +41,7 @@
 
     <el-table border v-loading="loading" :data="recordList" size="small" style="width:100%">
       <el-table-column type="index" label="序号" width="55" align="center" />
+      <el-table-column label="代理ID" align="center" prop="proxyId" width="70" />
       <el-table-column label="代理名称" align="center" prop="proxyName" min-width="120" />
       <el-table-column label="可提现金额" align="center" prop="availableAmount" width="120">
         <template slot-scope="scope">¥{{ scope.row.availableAmount || scope.row.balance || '0.00' }}</template>

+ 8 - 0
adminui/src/views/saas/tenant/ExecutionConfigManage.vue

@@ -128,6 +128,14 @@
         <el-input-number v-model="form.dailyTokenLimit" :min="0" :step="1000" :disabled="!form.tokenBudgetEnabled" />
         <span class="form-tip">{{ labels.dailyTokenLimitTip }}</span>
       </el-form-item>
+
+      <el-divider content-position="left">统一策略编排优先级(只读)</el-divider>
+      <el-alert type="info" :closable="false" style="margin-bottom:12px">
+        <template slot="title">数字员工统一策略编排优先级(从高到低)</template>
+        <div style="line-height:2;font-size:13px">
+          合规安全 &gt; 人工接管/暂停状态 &gt; 租户执行策略 &gt; 工作流节点目标 &gt; 客户画像状态 &gt; 客户突发变动事件 &gt; 知识库事实约束 &gt; 销冠语料表达增强 &gt; 行业语料专业增强 &gt; 普通提示词风格
+        </div>
+      </el-alert>
     </el-form>
   </div>
 </template>

+ 79 - 0
adminui/src/views/system/dataMigrate/index.vue

@@ -0,0 +1,79 @@
+<template>
+  <div class="app-container">
+    <el-card class="box-card">
+      <div slot="header" class="clearfix">
+        <span>数据迁移</span>
+      </div>
+      <el-alert
+        title="将数据库中的流量余额、Token余额、红包余额同步到Redis缓存。适用于Redis数据丢失或首次初始化场景。"
+        type="warning"
+        :closable="false"
+        show-icon
+        style="margin-bottom: 20px;"
+      />
+      <el-form ref="form" :model="form" :rules="rules" label-width="100px" size="medium">
+        <el-form-item label="操作秘钥" prop="secretKey">
+          <el-input v-model="form.secretKey" placeholder="请输入操作秘钥" type="password" show-password style="width: 400px;" />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" :loading="loading" @click="handleSync">开始同步</el-button>
+        </el-form-item>
+      </el-form>
+      <el-divider />
+      <el-alert
+        v-if="resultMsg"
+        :title="resultMsg"
+        :type="resultType"
+        :closable="false"
+        show-icon
+      />
+    </el-card>
+  </div>
+</template>
+
+<script>
+import { syncBalance } from '@/api/system/dataMigrate'
+
+export default {
+  name: 'DataMigrate',
+  data() {
+    return {
+      loading: false,
+      resultMsg: '',
+      resultType: 'success',
+      form: {
+        secretKey: ''
+      },
+      rules: {
+        secretKey: [
+          { required: true, message: '请输入操作秘钥', trigger: 'blur' }
+        ]
+      }
+    }
+  },
+  methods: {
+    handleSync() {
+      this.$refs.form.validate(valid => {
+        if (!valid) return
+        this.$confirm('确认要将数据库余额数据同步到Redis缓存吗?', '操作确认', {
+          confirmButtonText: '确定',
+          cancelButtonText: '取消',
+          type: 'warning'
+        }).then(() => {
+          this.loading = true
+          this.resultMsg = ''
+          syncBalance({ secretKey: this.form.secretKey }).then(res => {
+            this.resultType = 'success'
+            this.resultMsg = res.msg || '同步完成'
+            this.loading = false
+          }).catch(err => {
+            this.resultType = 'error'
+            this.resultMsg = err || '同步失败'
+            this.loading = false
+          })
+        }).catch(() => {})
+      })
+    }
+  }
+}
+</script>

+ 5 - 77
agentui/src/views/balance/recharge.vue

@@ -5,28 +5,10 @@
         <div class="form-card">
           <el-form :model="rechargeForm" label-width="120px" ref="rechargeForm">
             <el-form-item label="租户ID" prop="tenantId">
-              <el-select
-                v-model="rechargeForm.tenantId"
-                filterable
-                remote
-                reserve-keyword
-                clearable
-                placeholder="请输入租户名称搜索"
-                :remote-method="searchTenant"
-                :loading="tenantLoading"
-                @change="handleRechargeTenantChange"
-                style="width: 100%"
-              >
-                <el-option
-                  v-for="item in tenantOptions"
-                  :key="item.id"
-                  :label="item.tenantName"
-                  :value="item.id"
-                />
-              </el-select>
+              <el-input v-model="rechargeForm.tenantId" type="number" placeholder="请输入租户ID" />
             </el-form-item>
             <el-form-item label="租户名称" prop="tenantName">
-              <el-input v-model="rechargeForm.tenantName" placeholder="选中租户后自动回填" disabled />
+              <el-input v-model="rechargeForm.tenantName" placeholder="请输入租户名称" />
             </el-form-item>
             <el-form-item label="充值金额" prop="amount">
               <el-input v-model="rechargeForm.amount" type="number" placeholder="请输入充值金额" />
@@ -42,24 +24,7 @@
         <div class="form-card">
           <el-form :model="purchaseForm" label-width="120px" ref="purchaseForm">
             <el-form-item label="租户ID" prop="tenantId">
-              <el-select
-                v-model="purchaseForm.tenantId"
-                filterable
-                remote
-                reserve-keyword
-                clearable
-                placeholder="请输入租户名称搜索"
-                :remote-method="searchTenant"
-                :loading="tenantLoading"
-                style="width: 100%"
-              >
-                <el-option
-                  v-for="item in tenantOptions"
-                  :key="item.id"
-                  :label="item.tenantName"
-                  :value="item.id"
-                />
-              </el-select>
+              <el-input v-model="purchaseForm.tenantId" type="number" placeholder="请输入租户ID" />
             </el-form-item>
             <el-form-item label="服务类型" prop="serviceType">
               <el-select v-model="purchaseForm.serviceType" placeholder="请选择服务类型">
@@ -83,24 +48,7 @@
         <div class="form-card">
           <el-form :model="transferForm" label-width="120px" ref="transferForm">
             <el-form-item label="租户ID" prop="tenantId">
-              <el-select
-                v-model="transferForm.tenantId"
-                filterable
-                remote
-                reserve-keyword
-                clearable
-                placeholder="请输入租户名称搜索"
-                :remote-method="searchTenant"
-                :loading="tenantLoading"
-                style="width: 100%"
-              >
-                <el-option
-                  v-for="item in tenantOptions"
-                  :key="item.id"
-                  :label="item.tenantName"
-                  :value="item.id"
-                />
-              </el-select>
+              <el-input v-model="transferForm.tenantId" type="number" placeholder="请输入租户ID" />
             </el-form-item>
             <el-form-item label="服务类型" prop="serviceType">
               <el-select v-model="transferForm.serviceType" placeholder="请选择服务类型">
@@ -155,7 +103,6 @@
 
 <script>
 import { rechargeToTotal, purchaseService, transferToTotal, getConsumeRecords, getFeeConfigs } from '@/api/balance'
-import { getTenantList } from '@/api/tenant'
 
 export default {
   name: 'BalanceRecharge',
@@ -188,9 +135,7 @@ export default {
         total: 0
       },
       feeConfigs: [],
-      estimatedFee: 0,
-      tenantOptions: [],
-      tenantLoading: false
+      estimatedFee: 0
     }
   },
   mounted() {
@@ -206,23 +151,6 @@ export default {
     }
   },
   methods: {
-    async searchTenant(query) {
-      if (!query) {
-        this.tenantOptions = []
-        return
-      }
-      this.tenantLoading = true
-      try {
-        const response = await getTenantList({ tenantName: query, pageNum: 1, pageSize: 20 })
-        this.tenantOptions = response.rows || []
-      } finally {
-        this.tenantLoading = false
-      }
-    },
-    handleRechargeTenantChange(tenantId) {
-      const matched = this.tenantOptions.find(t => t.id === tenantId)
-      this.rechargeForm.tenantName = matched ? matched.tenantName : ''
-    },
     async loadFeeConfigs() {
       const response = await getFeeConfigs()
       this.feeConfigs = response.data || []

+ 3 - 18
agentui/src/views/profit/index.vue

@@ -12,14 +12,12 @@
       <el-table-column prop="tenantName" label="租户名称" />
       <el-table-column prop="month" label="月份" />
       <el-table-column prop="totalAmount" label="消费总额" />
-      <el-table-column prop="shareRatio" label="分账比例(%)" :formatter="formatShareRatio" />
+      <el-table-column prop="shareRatio" label="分账比例(%)" />
       <el-table-column prop="shareAmount" label="分账金额" />
-      <!-- TODO: 后端若返回 sourceModule 字段,启用下方注释列以展示来源模块 -->
-      <!-- <el-table-column prop="sourceModule" label="来源模块" /> -->
       <el-table-column prop="status" label="状态">
         <template slot-scope="scope">
-          <el-tag :type="getProfitStatusType(scope.row.status)">
-            {{ getProfitStatusText(scope.row.status) }}
+          <el-tag :type="scope.row.status === 1 ? 'success' : 'warning'">
+            {{ scope.row.status === 1 ? '已结算' : '待结算' }}
           </el-tag>
         </template>
       </el-table-column>
@@ -60,19 +58,6 @@ export default {
     this.loadProfitList()
   },
   methods: {
-    formatShareRatio(row) {
-      const ratio = row.shareRatio
-      if (ratio == null || ratio === '') return '-'
-      return ratio + '%'
-    },
-    getProfitStatusType(status) {
-      const types = { 0: 'warning', 1: 'success', 2: 'danger' }
-      return types[status] || 'info'
-    },
-    getProfitStatusText(status) {
-      const texts = { 0: '待结算', 1: '已结算', 2: '失败' }
-      return texts[status] || '未知'
-    },
     async loadProfitList() {
       this.loading = true
       try {

+ 1 - 14
agentui/src/views/withdraw/index.vue

@@ -14,7 +14,7 @@
     <el-table :data="withdrawList" border v-loading="loading">
       <el-table-column prop="tenantName" label="租户名称" />
       <el-table-column prop="amount" label="提现金额" />
-      <el-table-column prop="bankCard" label="银行卡号" :formatter="formatBankCard" />
+      <el-table-column prop="bankCard" label="银行卡号" />
       <el-table-column prop="bankName" label="开户银行" />
       <el-table-column prop="status" label="状态">
         <template slot-scope="scope">
@@ -25,14 +25,6 @@
       </el-table-column>
       <el-table-column prop="applyTime" label="申请时间" />
       <el-table-column prop="auditTime" label="审核时间" />
-      <el-table-column prop="rejectReason" label="拒绝理由">
-        <template slot-scope="scope">
-          <el-tooltip v-if="scope.row.status === 2 && scope.row.rejectReason" :content="scope.row.rejectReason" placement="top">
-            <span style="color:#f56c6c;cursor:pointer">查看理由</span>
-          </el-tooltip>
-          <span v-else>-</span>
-        </template>
-      </el-table-column>
       <el-table-column label="操作">
         <template slot-scope="scope">
           <template v-if="scope.row.status === 0">
@@ -149,11 +141,6 @@ export default {
       }
       return types[status] || 'info'
     },
-    formatBankCard(row) {
-      const card = String(row.bankCard || '')
-      if (card.length < 4) return card
-      return '**** **** **** 尾号 ' + card.slice(-4)
-    },
     getStatusText(status) {
       const texts = {
         0: '待审核',

+ 2 - 2
java/fs-ad-api/src/main/java/com/fs/framework/config/FilterConfig.java

@@ -37,7 +37,7 @@ public class FilterConfig
         registration.setFilter(new XssFilter());
         registration.addUrlPatterns(StringUtils.split(urlPatterns, ","));
         registration.setName("xssFilter");
-        registration.setOrder(FilterRegistrationBean.HIGHEST_PRECEDENCE);
+        registration.setOrder(2);
         Map<String, String> initParameters = new HashMap<String, String>();
         initParameters.put("excludes", excludes);
         registration.setInitParameters(initParameters);
@@ -52,7 +52,7 @@ public class FilterConfig
         registration.setFilter(new RepeatableFilter());
         registration.addUrlPatterns("/*");
         registration.setName("repeatableFilter");
-        registration.setOrder(FilterRegistrationBean.LOWEST_PRECEDENCE);
+        registration.setOrder(1);
         return registration;
     }
 

+ 9 - 0
java/fs-ad-api/src/main/resources/application.yml

@@ -1,6 +1,15 @@
 # 开发环境配置
 server:
   port: 8008
+# 防止XSS攻击
+xss:
+  # 过滤开关
+  enabled: true
+  # 排除链接(多个用逗号分隔)
+  excludes: /system/notice,/system/config/*
+  # 匹配链接
+  urlPatterns: /*
+
 # Spring配置
 spring:
   profiles:

+ 2 - 2
java/fs-ad-new-api/src/main/java/com/fs/framework/config/FilterConfig.java

@@ -37,7 +37,7 @@ public class FilterConfig
         registration.setFilter(new XssFilter());
         registration.addUrlPatterns(StringUtils.split(urlPatterns, ","));
         registration.setName("xssFilter");
-        registration.setOrder(FilterRegistrationBean.HIGHEST_PRECEDENCE);
+        registration.setOrder(2);
         Map<String, String> initParameters = new HashMap<String, String>();
         initParameters.put("excludes", excludes);
         registration.setInitParameters(initParameters);
@@ -52,7 +52,7 @@ public class FilterConfig
         registration.setFilter(new RepeatableFilter());
         registration.addUrlPatterns("/*");
         registration.setName("repeatableFilter");
-        registration.setOrder(FilterRegistrationBean.LOWEST_PRECEDENCE);
+        registration.setOrder(1);
         return registration;
     }
 

+ 2 - 1
java/fs-admin/src/main/java/com/fs/admin/controller/AdminConsumeRecordController.java

@@ -16,6 +16,7 @@ import com.fs.common.core.controller.BaseController;
 import com.fs.common.core.domain.AjaxResult;
 import com.fs.common.core.page.TableDataInfo;
 import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.DateUtils;
 import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.company.domain.Company;
 import com.fs.company.mapper.CompanyMapper;
@@ -170,7 +171,7 @@ public class AdminConsumeRecordController extends BaseController {
             row.put("billingMode", billingMode);
             row.put("billingModeName", BillingLabelUtils.billingModeLabel(billingMode));
             row.put("bizId", txn.getBizId());
-            row.put("occurredAt", txn.getCreateTime());
+            row.put("occurredAt", DateUtils.parseDateToStr(DateUtils.YYYY_MM_DD_HH_MM_SS, txn.getCreateTime()));
             row.put("remark", txn.getRemark());
             result.add(row);
         }

+ 120 - 0
java/fs-admin/src/main/java/com/fs/admin/controller/AdminIndustryFieldSchemaController.java

@@ -0,0 +1,120 @@
+package com.fs.admin.controller;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.enums.BusinessType;
+import com.fs.company.domain.LobsterIndustryFieldSchema;
+import com.fs.company.service.workflow.config.IndustryFieldSchemaService;
+import com.fs.company.service.workflow.config.LobsterIndustryCatalog;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 总后台-行业扩展字段默认模板管理
+ * 路径: /admin/industryField
+ *
+ * 管理 tenant_id=0 的平台默认模板;租户管理端会按租户行业复制成独立配置。
+ */
+@RestController
+@RequestMapping("/admin/industryField")
+public class AdminIndustryFieldSchemaController extends BaseController {
+
+    @Autowired
+    private IndustryFieldSchemaService schemaService;
+
+    /** 全部字段定义列表(含禁用) */
+    @PreAuthorize("@ss.hasPermi('admin:industryField:list')")
+    @GetMapping("/list")
+    public AjaxResult list(@RequestParam(required = false) String industryCode) {
+        List<LobsterIndustryFieldSchema> all = schemaService.listAllSchema();
+        if (industryCode != null && !industryCode.isEmpty()) {
+            all.removeIf(s -> !industryCode.equals(s.getIndustryCode()));
+        }
+        return AjaxResult.success(all);
+    }
+
+    /** 按行业取启用的字段定义(前端动态表单渲染用) */
+    @GetMapping("/public/{industryCode}")
+    public AjaxResult publicByIndustry(@PathVariable String industryCode) {
+        return AjaxResult.success(schemaService.getSchema(0L, industryCode));
+    }
+
+    /** 公开只读:全部行业列表(前端下拉选择用,对齐 LobsterIndustryCatalog 28个行业) */
+    @GetMapping("/public/industries")
+    public AjaxResult publicIndustries() {
+        List<Map<String, String>> list = new java.util.ArrayList<>();
+        for (LobsterIndustryCatalog.IndustryPack pack : LobsterIndustryCatalog.allPacks()) {
+            Map<String, String> item = new LinkedHashMap<>();
+            item.put("code", pack.code);
+            item.put("label", pack.label);
+            list.add(item);
+        }
+        return AjaxResult.success(list);
+    }
+
+    /** 新增/编辑字段定义 */
+    @PreAuthorize("@ss.hasPermi('admin:industryField:edit')")
+    @Log(title = "行业扩展字段定义", businessType = BusinessType.INSERT)
+    @PostMapping("/save")
+    public AjaxResult save(@RequestBody LobsterIndustryFieldSchema schema) {
+        try {
+            schema.setTenantId(0L);
+            schemaService.saveSchema(schema);
+            return AjaxResult.success();
+        } catch (IllegalArgumentException e) {
+            return AjaxResult.error(e.getMessage());
+        }
+    }
+
+    /** 启用/禁用字段 */
+    @PreAuthorize("@ss.hasPermi('admin:industryField:edit')")
+    @Log(title = "行业扩展字段定义", businessType = BusinessType.UPDATE)
+    @PostMapping("/toggle")
+    public AjaxResult toggle(@RequestBody Map<String, Object> body) {
+        Long id = body.get("id") != null ? Long.valueOf(body.get("id").toString()) : null;
+        Integer enabled = body.get("enabled") != null ? Integer.valueOf(body.get("enabled").toString()) : null;
+        if (id == null || enabled == null) return AjaxResult.error("参数缺失");
+        return toAjax(schemaService.toggleSchemaEnabled(id, enabled));
+    }
+
+    /** 删除字段定义 */
+    @PreAuthorize("@ss.hasPermi('admin:industryField:remove')")
+    @Log(title = "行业扩展字段定义", businessType = BusinessType.DELETE)
+    @PostMapping("/delete/{id}")
+    public AjaxResult delete(@PathVariable Long id) {
+        return toAjax(schemaService.deleteSchema(id));
+    }
+
+    /** 从 catalog 重置某行业字段(覆盖模式) */
+    @PreAuthorize("@ss.hasPermi('admin:industryField:edit')")
+    @Log(title = "行业扩展字段定义", businessType = BusinessType.UPDATE)
+    @PostMapping("/seed")
+    public AjaxResult seed(@RequestBody Map<String, Object> body) {
+        String industryCode = body.get("industryCode") != null ? body.get("industryCode").toString() : null;
+        Boolean overwrite = body.get("overwrite") != null ? Boolean.valueOf(body.get("overwrite").toString()) : true;
+        if (industryCode == null || industryCode.isEmpty()) return AjaxResult.error("industryCode 必填");
+        int count = schemaService.seedFromCatalog(0L, industryCode, overwrite);
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("seededCount", count);
+        return AjaxResult.success(data);
+    }
+
+    /** 全部行业重置(覆盖模式,初始化时用) */
+    @PreAuthorize("@ss.hasPermi('admin:industryField:edit')")
+    @Log(title = "行业扩展字段定义", businessType = BusinessType.UPDATE)
+    @PostMapping("/seedAll")
+    public AjaxResult seedAll(@RequestBody(required = false) Map<String, Object> body) {
+        Boolean overwrite = body != null && body.get("overwrite") != null
+                ? Boolean.valueOf(body.get("overwrite").toString()) : true;
+        int count = schemaService.seedAllFromCatalog(0L, overwrite);
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("seededCount", count);
+        return AjaxResult.success(data);
+    }
+}

+ 3 - 2
java/fs-admin/src/main/java/com/fs/admin/controller/AdminRechargeRecordController.java

@@ -11,6 +11,7 @@ import com.fs.common.core.controller.BaseController;
 import com.fs.common.core.domain.AjaxResult;
 import com.fs.common.core.page.TableDataInfo;
 import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.DateUtils;
 import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.company.domain.Company;
 import com.fs.company.mapper.CompanyMapper;
@@ -138,7 +139,7 @@ public class AdminRechargeRecordController extends BaseController {
             result.put("payMethod", txn.getBizType());
             result.put("txnNo", txn.getTxnNo());
             result.put("createBy", txn.getCreateBy());
-            result.put("createTime", txn.getCreateTime());
+            result.put("createTime", DateUtils.parseDateToStr(DateUtils.YYYY_MM_DD_HH_MM_SS, txn.getCreateTime()));
             result.put("remark", txn.getRemark());
             return AjaxResult.success(result);
         }
@@ -213,7 +214,7 @@ public class AdminRechargeRecordController extends BaseController {
             row.put("balanceAfter", txn.getBalanceAfter());
             row.put("payMethod", txn.getBizType());
             row.put("createBy", txn.getCreateBy());
-            row.put("createTime", txn.getCreateTime());
+            row.put("createTime", DateUtils.parseDateToStr(DateUtils.YYYY_MM_DD_HH_MM_SS, txn.getCreateTime()));
             row.put("remark", txn.getRemark());
             resultList.add(row);
         }

+ 3 - 2
java/fs-admin/src/main/java/com/fs/admin/controller/AiModelAdminController.java

@@ -33,8 +33,8 @@ public class AiModelAdminController extends BaseController {
     /** 模型列表 */
     @PreAuthorize("@ss.hasPermi('admin:aiModel:list')")
     @GetMapping("/list")
-    public AjaxResult list() {
-        List<AdminAiModel> list = modelService.listAll();
+    public AjaxResult list(@RequestParam(value = "modelType", required = false) Integer modelType) {
+        List<AdminAiModel> list = modelService.listAll(modelType);
         list.forEach(this::maskApiKey);
         return AjaxResult.success(list);
     }
@@ -63,6 +63,7 @@ public class AiModelAdminController extends BaseController {
         if (model.getSortOrder() == null) model.setSortOrder(0);
         if (model.getMaxTokens() == null) model.setMaxTokens(4096);
         if (model.getTemperature() == null) model.setTemperature(0.7);
+        if (model.getModelType() == null) model.setModelType(0);
         modelService.insert(model);
         return AjaxResult.success();
     }

+ 71 - 0
java/fs-admin/src/main/java/com/fs/admin/controller/IndexController.java

@@ -0,0 +1,71 @@
+package com.fs.admin.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.fs.admin.vo.AdminDashboardVO;
+import com.fs.billing.mapper.TenantWalletTxnMapper;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.tenant.mapper.TenantInfoMapper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+/**
+ * 总后台首页看板
+ *
+ * @author fs
+ */
+@RestController
+@RequestMapping("/admin/index")
+public class IndexController extends BaseController {
+
+    @Autowired
+    private TenantInfoMapper tenantInfoMapper;
+
+    @Autowired
+    private TenantWalletTxnMapper tenantWalletTxnMapper;
+
+    /**
+     * 首页看板统计卡片数据
+     * GET /admin/index/dashboard
+     */
+    @GetMapping("/dashboard")
+    public AjaxResult dashboard() {
+        LocalDateTime todayStart = LocalDate.now().atStartOfDay();
+        LocalDateTime todayEnd = LocalDate.now().plusDays(1).atStartOfDay();
+
+        // 总租户数
+        Integer totalTenants = tenantInfoMapper.selectCount(new LambdaQueryWrapper<>());
+
+        // 今日消费(CONSUME + REFUND 今日净额)
+        BigDecimal todayConsumption = tenantWalletTxnMapper.sumTodayConsumptionAll(todayStart, todayEnd);
+        if (todayConsumption == null) {
+            todayConsumption = BigDecimal.ZERO;
+        }
+
+        // 总消费金额(CONSUME + REFUND 全部历史净额)
+        BigDecimal totalConsumption = tenantWalletTxnMapper.sumTotalConsumptionAll();
+        if (totalConsumption == null) {
+            totalConsumption = BigDecimal.ZERO;
+        }
+
+        // 总充值金额(ADMIN_RECHARGE 累计)
+        BigDecimal totalRecharge = tenantWalletTxnMapper.sumTotalRechargeAll();
+        if (totalRecharge == null) {
+            totalRecharge = BigDecimal.ZERO;
+        }
+
+        AdminDashboardVO vo = new AdminDashboardVO();
+        vo.setTotalTenants(totalTenants);
+        vo.setTodayConsumption(todayConsumption);
+        vo.setTotalConsumption(totalConsumption);
+        vo.setTotalRecharge(totalRecharge);
+
+        return AjaxResult.success(vo);
+    }
+}

+ 5 - 5
java/fs-admin/src/main/java/com/fs/admin/controller/audit/AdminLiveVideoController.java

@@ -1,16 +1,16 @@
 package com.fs.admin.controller.audit;
 
-import java.util.List;
-import java.util.Map;
-
 import com.fs.admin.annotation.TenantDS;
 import com.fs.admin.service.IAdminLiveVideoService;
+import com.fs.admin.vo.AdminLiveVideoVO;
 import com.fs.common.core.controller.BaseController;
 import com.fs.common.core.page.TableDataInfo;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
+import java.util.List;
+
 @TenantDS
 @RestController
 @RequestMapping("/admin/liveVideo")
@@ -25,7 +25,7 @@ public class AdminLiveVideoController extends BaseController {
                               @RequestParam(required = false) String companyName,
                               @RequestParam(required = false) String videoTitle) {
         startPage();
-        List<Map<String, Object>> allList = adminLiveVideoService.selectLiveVideoList(companyId, companyName, videoTitle);
-        return getDataTable(allList);
+        List<AdminLiveVideoVO> list = adminLiveVideoService.selectLiveVideoList(videoTitle);
+        return getDataTable(list);
     }
 }

+ 12 - 11
java/fs-admin/src/main/java/com/fs/admin/controller/audit/AdminStoreOrderAdminController.java

@@ -4,6 +4,7 @@ import java.util.*;
 
 import com.fs.admin.annotation.TenantDS;
 import com.fs.admin.service.IAdminStoreOrderService;
+import com.fs.admin.vo.AdminStoreOrderVO;
 import com.fs.common.annotation.Excel;
 import com.fs.common.annotation.Log;
 import com.fs.common.core.controller.BaseController;
@@ -41,17 +42,17 @@ public class AdminStoreOrderAdminController extends BaseController {
     @PreAuthorize("@ss.hasPermi('admin:storeOrder:list')")
     @GetMapping("/export")
     public AjaxResult export(@RequestParam(required = false) String orderNo) {
-        List<Map<String, Object>> allList = adminStoreOrderService.selectStoreOrderListForExport(orderNo);
+        List<AdminStoreOrderVO> allList = adminStoreOrderService.selectStoreOrderListForExport(orderNo);
         List<StoreOrderExportVO> voList = new ArrayList<>();
-        for (Map<String, Object> m : allList) {
+        for (AdminStoreOrderVO item : allList) {
             StoreOrderExportVO vo = new StoreOrderExportVO();
-            vo.setCompanyName(str(m.get("companyName")));
-            vo.setOrderNo(str(m.get("orderNo")));
-            vo.setOrderAmount(str(m.get("orderAmount")));
-            vo.setPayType(str(m.get("payType")));
-            vo.setOrderStatus(str(m.get("orderStatus")));
-            vo.setUserName(str(m.get("userName")));
-            vo.setCreateTime(str(m.get("createTime")));
+            vo.setCompanyName(str(item.getCompanyName()));
+            vo.setOrderNo(str(item.getOrderCode()));
+            vo.setOrderAmount(str(item.getPayPrice() != null ? item.getPayPrice() : item.getTotalPrice()));
+            vo.setPayType(str(item.getPayType()));
+            vo.setOrderStatus(str(item.getStatus()));
+            vo.setUserName(str(item.getRealName()));
+            vo.setCreateTime(str(item.getCreateTime()));
             voList.add(vo);
         }
         ExcelUtil<StoreOrderExportVO> util = new ExcelUtil<>(StoreOrderExportVO.class);
@@ -62,7 +63,7 @@ public class AdminStoreOrderAdminController extends BaseController {
     @GetMapping("/list")
     public TableDataInfo list(@RequestParam(required = false) String orderNo) {
         startPage();
-        List<Map<String, Object>> allList = adminStoreOrderService.selectStoreOrderList(orderNo);
-        return getDataTable(allList);
+        List<AdminStoreOrderVO> list = adminStoreOrderService.selectStoreOrderList(orderNo);
+        return getDataTable(list);
     }
 }

+ 7 - 3
java/fs-admin/src/main/java/com/fs/admin/controller/audit/AdminVideoResourceController.java

@@ -7,13 +7,14 @@ import com.fs.admin.service.IAdminVideoResourceService;
 import com.fs.admin.vo.AdminVideoResourceVO;
 import com.fs.common.core.controller.BaseController;
 import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.utils.StringUtils;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
 /**
  * 总后台视频资源审计控制器
- * 通过拦截器切库后查询当前租户 fs_user_course_video 数据
+ * 通过拦截器切库后查询当前租户 fs_video_resource 数据
  */
 @TenantDS
 @RestController
@@ -24,14 +25,17 @@ public class AdminVideoResourceController extends BaseController {
     private IAdminVideoResourceService adminVideoResourceService;
 
     /**
-     * 查询指定租户的视频资源列表(由 AdminTenantDataSourceInterceptor 切库)
+     * 查询指定租户的视频素材库列表(由 AdminTenantDataSourceInterceptor 切库)
      */
     @PreAuthorize("@ss.hasPermi('admin:videoResource:list')")
     @GetMapping("/list")
     public TableDataInfo list(@RequestParam(required = false) Long tenantId,
+                              @RequestParam(required = false) String resourceName,
+                              @RequestParam(required = false) String fileName,
                               @RequestParam(required = false) String videoName) {
         startPage();
-        List<AdminVideoResourceVO> list = adminVideoResourceService.selectVideoResourceList(videoName);
+        String nameKeyword = StringUtils.isNotEmpty(resourceName) ? resourceName : videoName;
+        List<AdminVideoResourceVO> list = adminVideoResourceService.selectVideoResourceList(nameKeyword, fileName);
         return getDataTable(list);
     }
 }

+ 25 - 24
java/fs-admin/src/main/java/com/fs/admin/controller/audit/ArticleAdminController.java

@@ -4,6 +4,7 @@ import java.util.*;
 
 import com.fs.admin.annotation.TenantDS;
 import com.fs.admin.service.IAdminArticleService;
+import com.fs.admin.vo.AdminArticleVO;
 import com.fs.common.annotation.Excel;
 import com.fs.common.annotation.Log;
 import com.fs.common.core.controller.BaseController;
@@ -31,8 +32,8 @@ public class ArticleAdminController extends BaseController {
         @Excel(name = "租户名称") private String companyName;
         @Excel(name = "文章标题") private String articleTitle;
         @Excel(name = "分类") private String categoryName;
-        @Excel(name = "作者") private String author;
         @Excel(name = "状态") private String status;
+        @Excel(name = "浏览数") private String views;
         @Excel(name = "创建时间") private String createTime;
     }
 
@@ -41,16 +42,16 @@ public class ArticleAdminController extends BaseController {
     @GetMapping("/export")
     public AjaxResult export(@RequestParam(required = false) String articleTitle,
                              @RequestParam(required = false) Integer status) {
-        List<Map<String, Object>> allList = adminArticleService.selectArticleListForExport(articleTitle, status);
+        List<AdminArticleVO> allList = adminArticleService.selectArticleListForExport(articleTitle, status);
         List<ArticleExportVO> voList = new ArrayList<>();
-        for (Map<String, Object> m : allList) {
+        for (AdminArticleVO item : allList) {
             ArticleExportVO vo = new ArticleExportVO();
-            vo.setCompanyName(str(m.get("companyName")));
-            vo.setArticleTitle(str(m.get("articleTitle")));
-            vo.setCategoryName(str(m.get("categoryName")));
-            vo.setAuthor(str(m.get("author")));
-            vo.setStatus(statusText(str(m.get("status"))));
-            vo.setCreateTime(str(m.get("createTime")));
+            vo.setCompanyName(str(item.getCompanyName()));
+            vo.setArticleTitle(str(item.getTitle()));
+            vo.setCategoryName(str(item.getCategoryName()));
+            vo.setStatus(statusText(item.getStatus()));
+            vo.setViews(str(item.getViews()));
+            vo.setCreateTime(str(item.getCreateTime()));
             voList.add(vo);
         }
         ExcelUtil<ArticleExportVO> util = new ExcelUtil<>(ArticleExportVO.class);
@@ -62,22 +63,22 @@ public class ArticleAdminController extends BaseController {
     public TableDataInfo list(@RequestParam(required = false) String articleTitle,
                               @RequestParam(required = false) Integer status) {
         startPage();
-        List<Map<String, Object>> allList = adminArticleService.selectArticleList(articleTitle, status);
-        return getDataTable(allList);
+        List<AdminArticleVO> list = adminArticleService.selectArticleList(articleTitle, status);
+        return getDataTable(list);
     }
 
     @PreAuthorize("@ss.hasPermi('admin:article:list')")
     @GetMapping("/pending")
     public TableDataInfo pendingList() {
         startPage();
-        List<Map<String, Object>> allList = adminArticleService.selectPendingArticleList();
-        return getDataTable(allList);
+        List<AdminArticleVO> list = adminArticleService.selectPendingArticleList();
+        return getDataTable(list);
     }
 
     @PreAuthorize("@ss.hasPermi('admin:article:query')")
     @GetMapping("/{articleId}")
     public AjaxResult getInfo(@PathVariable Long articleId) {
-        Map<String, Object> data = adminArticleService.selectArticleById(articleId);
+        AdminArticleVO data = adminArticleService.selectArticleById(articleId);
         if (data == null) return AjaxResult.error("文章不存在");
         return AjaxResult.success(data);
     }
@@ -101,22 +102,22 @@ public class ArticleAdminController extends BaseController {
     @PreAuthorize("@ss.hasPermi('admin:article:query')")
     @GetMapping("/statistics")
     public AjaxResult statistics() {
-        List<Map<String, Object>> allList = adminArticleService.selectArticleListForStatistics();
+        List<AdminArticleVO> allList = adminArticleService.selectArticleListForStatistics();
         Map<String, Object> result = new HashMap<>();
         result.put("totalCount", allList.size());
-        result.put("pendingCount", allList.stream().filter(r -> "0".equals(str(r.get("status")))).count());
-        result.put("approvedCount", allList.stream().filter(r -> "1".equals(str(r.get("status")))).count());
-        result.put("rejectedCount", allList.stream().filter(r -> "2".equals(str(r.get("status")))).count());
+        result.put("pendingCount", allList.stream().filter(r -> r.getStatus() != null && r.getStatus() == 0).count());
+        result.put("approvedCount", allList.stream().filter(r -> r.getStatus() != null && r.getStatus() == 1).count());
+        result.put("rejectedCount", allList.stream().filter(r -> r.getStatus() != null && r.getStatus() == 2).count());
         return AjaxResult.success(result);
     }
 
-    private String statusText(String status) {
-        if (status == null || status.isEmpty()) return "";
+    private String statusText(Integer status) {
+        if (status == null) return "";
         switch (status) {
-            case "0": return "待审计";
-            case "1": return "已通过";
-            case "2": return "已拒绝";
-            default: return status;
+            case 0: return "待审计";
+            case 1: return "已通过";
+            case 2: return "已拒绝";
+            default: return String.valueOf(status);
         }
     }
 }

+ 126 - 109
java/fs-admin/src/main/java/com/fs/admin/controller/audit/LiveAdminController.java

@@ -1,109 +1,126 @@
-package com.fs.admin.controller.audit;
-
-import java.util.*;
-
-import com.fs.admin.annotation.TenantDS;
-import com.fs.admin.service.IAdminLiveService;
-import com.fs.common.annotation.Excel;
-import com.fs.common.annotation.Log;
-import com.fs.common.core.controller.BaseController;
-import com.fs.common.core.domain.AjaxResult;
-import com.fs.common.core.page.TableDataInfo;
-import com.fs.common.enums.BusinessType;
-import com.fs.common.utils.poi.ExcelUtil;
-import lombok.Data;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.web.bind.annotation.*;
-
-@TenantDS
-@RestController
-@RequestMapping("/admin/live")
-public class LiveAdminController extends BaseController {
-
-    @Autowired
-    private IAdminLiveService adminLiveService;
-
-    private String str(Object o) { return o == null ? "" : o.toString(); }
-
-    @Data
-    public static class LiveExportVO {
-        @Excel(name = "租户名称") private String companyName;
-        @Excel(name = "直播ID") private String liveId;
-        @Excel(name = "直播标题") private String liveName;
-        @Excel(name = "封面地址") private String coverUrl;
-        @Excel(name = "直播类型") private String liveType;
-        @Excel(name = "直播状态") private String liveStatus;
-        @Excel(name = "开始时间") private String startTime;
-        @Excel(name = "结束时间") private String finishTime;
-        @Excel(name = "创建时间") private String createTime;
-    }
-
-    private String statusText(Integer status) {
-        if (status == null) return "";
-        switch (status) {
-            case 1: return "待支付";
-            case 2: return "直播中";
-            case 3: return "已结束";
-            default: return String.valueOf(status);
-        }
-    }
-
-    @Log(title = "导出直播", businessType = BusinessType.EXPORT)
-    @PreAuthorize("@ss.hasPermi('admin:live:list')")
-    @GetMapping("/export")
-    public AjaxResult export(@RequestParam(required = false) String liveTitle,
-                             @RequestParam(required = false) Long companyId,
-                             @RequestParam(required = false) String companyName,
-                             @RequestParam(required = false) Integer status) {
-        List<Map<String, Object>> allList = adminLiveService.selectLiveListForExport(companyId, companyName, liveTitle, status);
-        List<LiveExportVO> voList = new ArrayList<>();
-        for (Map<String, Object> m : allList) {
-            LiveExportVO vo = new LiveExportVO();
-            vo.setCompanyName(str(m.get("companyName")));
-            vo.setLiveId(str(m.get("liveId")));
-            vo.setLiveName(str(m.get("liveName")));
-            vo.setCoverUrl(str(m.get("coverUrl")));
-            vo.setLiveType("1".equals(str(m.get("liveType"))) ? "直播" : "2".equals(str(m.get("liveType"))) ? "录播" : "");
-            vo.setLiveStatus(statusText((Integer) m.get("status")));
-            vo.setStartTime(str(m.get("startTime")));
-            vo.setFinishTime(str(m.get("finishTime")));
-            vo.setCreateTime(str(m.get("createTime")));
-            voList.add(vo);
-        }
-        ExcelUtil<LiveExportVO> util = new ExcelUtil<>(LiveExportVO.class);
-        return util.exportExcel(voList, "直播数据");
-    }
-
-    @PreAuthorize("@ss.hasPermi('admin:live:list')")
-    @GetMapping("/list")
-    public TableDataInfo list(@RequestParam(required = false) String liveTitle,
-                              @RequestParam(required = false) Long companyId,
-                              @RequestParam(required = false) String companyName,
-                              @RequestParam(required = false) Integer status) {
-        startPage();
-        List<Map<String, Object>> allList = adminLiveService.selectLiveList(companyId, companyName, liveTitle, status);
-        return getDataTable(allList);
-    }
-
-    @PreAuthorize("@ss.hasPermi('admin:live:query')")
-    @GetMapping("/{liveId}")
-    public AjaxResult getInfo(@PathVariable Long liveId) {
-        Map<String, Object> data = adminLiveService.selectLiveById(liveId);
-        if (data == null) return AjaxResult.error("直播不存在");
-        return AjaxResult.success(data);
-    }
-
-    @PreAuthorize("@ss.hasPermi('admin:live:query')")
-    @GetMapping("/statistics")
-    public AjaxResult statistics(@RequestParam(required = false) Long companyId,
-                                 @RequestParam(required = false) String companyName,
-                                 @RequestParam(required = false) Integer status) {
-        List<Map<String, Object>> allList = adminLiveService.selectLiveListForExport(companyId, companyName, null, status);
-        Map<String, Object> result = new HashMap<>();
-        result.put("totalCount", allList.size());
-        result.put("livingCount", allList.stream().filter(m -> Integer.valueOf(2).equals(m.get("status"))).count());
-        result.put("endedCount", allList.stream().filter(m -> Integer.valueOf(3).equals(m.get("status"))).count());
-        return AjaxResult.success(result);
-    }
-}
+package com.fs.admin.controller.audit;

+

+import java.util.*;

+

+import com.fs.admin.annotation.TenantDS;

+import com.fs.admin.service.IAdminLiveService;

+import com.fs.admin.vo.AdminLiveVO;

+import com.fs.common.annotation.Excel;

+import com.fs.common.annotation.Log;

+import com.fs.common.core.controller.BaseController;

+import com.fs.common.core.domain.AjaxResult;

+import com.fs.common.core.page.TableDataInfo;

+import com.fs.common.enums.BusinessType;

+import com.fs.common.utils.poi.ExcelUtil;

+import lombok.Data;

+import org.springframework.beans.factory.annotation.Autowired;

+import org.springframework.security.access.prepost.PreAuthorize;

+import org.springframework.web.bind.annotation.*;

+

+@TenantDS

+@RestController

+@RequestMapping("/admin/live")

+public class LiveAdminController extends BaseController {

+

+    @Autowired

+    private IAdminLiveService adminLiveService;

+

+    private String str(Object o) { return o == null ? "" : o.toString(); }

+

+    @Data

+    public static class LiveExportVO {

+        @Excel(name = "租户名称") private String companyName;

+        @Excel(name = "直播ID") private String liveId;

+        @Excel(name = "直播标题") private String liveName;

+        @Excel(name = "封面地址") private String coverUrl;

+        @Excel(name = "直播类型") private String liveType;

+        @Excel(name = "直播状态") private String liveStatus;

+        @Excel(name = "开始时间") private String startTime;

+        @Excel(name = "结束时间") private String finishTime;

+        @Excel(name = "创建时间") private String createTime;

+    }

+

+    private String statusText(Integer status) {

+        if (status == null) return "";

+        switch (status) {

+            case 1: return "待直播";

+            case 2: return "直播中";

+            case 3: return "已结束";

+            case 4: return "直播回放中";

+            default: return String.valueOf(status);

+        }

+    }

+

+    private String liveTypeText(Integer liveType) {

+        if (liveType == null) return "";

+        switch (liveType) {

+            case 1: return "直播";

+            case 2: return "录播";

+            case 3: return "直播回放";

+            default: return String.valueOf(liveType);

+        }

+    }

+

+    @Log(title = "导出直播", businessType = BusinessType.EXPORT)

+    @PreAuthorize("@ss.hasPermi('admin:live:list')")

+    @GetMapping("/export")

+    public AjaxResult export(@RequestParam(required = false) String liveTitle,

+                             @RequestParam(required = false) Long companyId,

+                             @RequestParam(required = false) String companyName,

+                             @RequestParam(required = false) Integer status) {

+        List<AdminLiveVO> allList = adminLiveService.selectLiveListForExport(liveTitle, status);

+        List<LiveExportVO> voList = new ArrayList<>();

+        for (AdminLiveVO item : allList) {

+            LiveExportVO vo = new LiveExportVO();

+            vo.setCompanyName(str(item.getCompanyName()));

+            vo.setLiveId(str(item.getLiveId()));

+            vo.setLiveName(str(item.getLiveName()));

+            vo.setCoverUrl(str(item.getLiveImgUrl()));

+            vo.setLiveType(liveTypeText(item.getLiveType()));

+            vo.setLiveStatus(statusText(item.getStatus()));

+            vo.setStartTime(str(item.getStartTime()));

+            vo.setFinishTime(str(item.getFinishTime()));

+            vo.setCreateTime(str(item.getCreateTime()));

+            voList.add(vo);

+        }

+        ExcelUtil<LiveExportVO> util = new ExcelUtil<>(LiveExportVO.class);

+        return util.exportExcel(voList, "直播数据");

+    }

+

+    @PreAuthorize("@ss.hasPermi('admin:live:list')")

+    @GetMapping("/list")

+    public TableDataInfo list(@RequestParam(required = false) String liveTitle,

+                              @RequestParam(required = false) Long companyId,

+                              @RequestParam(required = false) String companyName,

+                              @RequestParam(required = false) Integer status) {

+        startPage();

+        List<AdminLiveVO> list = adminLiveService.selectLiveList(liveTitle, status);

+        return getDataTable(list);

+    }

+

+    @PreAuthorize("@ss.hasPermi('admin:live:query')")

+    @GetMapping("/{liveId}")

+    public AjaxResult getInfo(@PathVariable Long liveId) {

+        AdminLiveVO data = adminLiveService.selectLiveById(liveId);

+        if (data == null) return AjaxResult.error("直播不存在");

+        return AjaxResult.success(data);

+    }

+

+    @PreAuthorize("@ss.hasPermi('admin:live:query')")

+    @GetMapping("/statistics")

+    public AjaxResult statistics(@RequestParam(required = false) Long companyId,

+                                 @RequestParam(required = false) String companyName,

+                                 @RequestParam(required = false) Integer status) {

+        List<AdminLiveVO> allList = adminLiveService.selectLiveListForExport(null, status);

+        Map<String, Object> result = new HashMap<>();

+        result.put("totalCount", allList.size());

+        result.put("livingCount", allList.stream().filter(item -> item.getStatus() != null && (item.getStatus() == 2 || item.getStatus() == 4)).count());

+        result.put("endedCount", allList.stream().filter(item -> item.getStatus() != null && item.getStatus() == 3).count());

+        result.put("totalViewerCount", allList.stream()

+                .map(AdminLiveVO::getViewerCount)

+                .filter(Objects::nonNull)

+                .mapToLong(Long::longValue)

+                .sum());

+        return AjaxResult.success(result);

+    }

+}


+ 19 - 18
java/fs-admin/src/main/java/com/fs/admin/controller/audit/ProductAdminController.java

@@ -4,6 +4,7 @@ import java.util.*;
 
 import com.fs.admin.annotation.TenantDS;
 import com.fs.admin.service.IAdminProductService;
+import com.fs.admin.vo.AdminProductVO;
 import com.fs.common.annotation.Excel;
 import com.fs.common.annotation.Log;
 import com.fs.common.core.controller.BaseController;
@@ -42,17 +43,17 @@ public class ProductAdminController extends BaseController {
     @GetMapping("/export")
     public AjaxResult export(@RequestParam(required = false) String productName,
                              @RequestParam(required = false) String status) {
-        List<Map<String, Object>> allList = adminProductService.selectProductListForExport(productName, status);
+        List<AdminProductVO> allList = adminProductService.selectProductListForExport(productName, status);
         List<ProductExportVO> voList = new ArrayList<>();
-        for (Map<String, Object> m : allList) {
+        for (AdminProductVO item : allList) {
             ProductExportVO vo = new ProductExportVO();
-            vo.setCompanyName(str(m.get("companyName")));
-            vo.setProductName(str(m.get("productName")));
-            vo.setCategoryName(str(m.get("categoryName")));
-            vo.setPrice(str(m.get("price")));
-            vo.setStock(str(m.get("stock")));
-            vo.setStatus(statusText(str(m.get("status"))));
-            vo.setCreateTime(str(m.get("createTime")));
+            vo.setCompanyName(str(item.getCompanyName()));
+            vo.setProductName(str(item.getProductName()));
+            vo.setCategoryName(str(item.getCategoryName()));
+            vo.setPrice(str(item.getPrice()));
+            vo.setStock(str(item.getStock()));
+            vo.setStatus(statusText(str(item.getIsAudit())));
+            vo.setCreateTime(str(item.getCreateTime()));
             voList.add(vo);
         }
         ExcelUtil<ProductExportVO> util = new ExcelUtil<>(ProductExportVO.class);
@@ -64,22 +65,22 @@ public class ProductAdminController extends BaseController {
     public TableDataInfo list(@RequestParam(required = false) String productName,
                               @RequestParam(required = false) String status) {
         startPage();
-        List<Map<String, Object>> allList = adminProductService.selectProductList(productName, status);
-        return getDataTable(allList);
+        List<AdminProductVO> list = adminProductService.selectProductList(productName, status);
+        return getDataTable(list);
     }
 
     @PreAuthorize("@ss.hasPermi('admin:product:list')")
     @GetMapping("/pending")
     public TableDataInfo pendingList() {
         startPage();
-        List<Map<String, Object>> allList = adminProductService.selectProductPendingList();
-        return getDataTable(allList);
+        List<AdminProductVO> list = adminProductService.selectProductPendingList();
+        return getDataTable(list);
     }
 
     @PreAuthorize("@ss.hasPermi('admin:product:query')")
     @GetMapping("/{productId}")
     public AjaxResult getInfo(@PathVariable Long productId) {
-        Map<String, Object> data = adminProductService.selectProductById(productId);
+        AdminProductVO data = adminProductService.selectProductById(productId);
         if (data == null) return AjaxResult.error("商品不存在");
         return AjaxResult.success(data);
     }
@@ -96,12 +97,12 @@ public class ProductAdminController extends BaseController {
     @PreAuthorize("@ss.hasPermi('admin:product:query')")
     @GetMapping("/statistics")
     public AjaxResult statistics() {
-        List<Map<String, Object>> allList = adminProductService.selectProductListForStatistics();
+        List<AdminProductVO> allList = adminProductService.selectProductListForStatistics();
         Map<String, Object> result = new HashMap<>();
         result.put("totalCount", allList.size());
-        result.put("pendingCount", allList.stream().filter(r -> "0".equals(str(r.get("status")))).count());
-        result.put("onlineCount", allList.stream().filter(r -> "1".equals(str(r.get("status")))).count());
-        result.put("rejectedCount", allList.stream().filter(r -> "2".equals(str(r.get("status")))).count());
+        result.put("pendingCount", allList.stream().filter(r -> "0".equals(str(r.getIsAudit()))).count());
+        result.put("onlineCount", allList.stream().filter(r -> "1".equals(str(r.getIsAudit()))).count());
+        result.put("rejectedCount", allList.stream().filter(r -> "2".equals(str(r.getIsAudit()))).count());
         return AjaxResult.success(result);
     }
 

+ 27 - 0
java/fs-admin/src/main/java/com/fs/admin/vo/AdminDashboardVO.java

@@ -0,0 +1,27 @@
+package com.fs.admin.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+/**
+ * 总后台首页看板统计卡片响应 VO
+ *
+ * @author fs
+ */
+@Data
+public class AdminDashboardVO implements Serializable {
+
+    /** 总租户数 */
+    private Integer totalTenants;
+
+    /** 今日消费金额 */
+    private BigDecimal todayConsumption;
+
+    /** 总消费金额(历史累计) */
+    private BigDecimal totalConsumption;
+
+    /** 总充值金额(ADMIN_RECHARGE 累计) */
+    private BigDecimal totalRecharge;
+}

+ 77 - 0
java/fs-admin/src/main/java/com/fs/lobster/controller/LobsterAutoTagsController.java

@@ -0,0 +1,77 @@
+package com.fs.lobster.controller;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.company.domain.LobsterAutoTags;
+import com.fs.company.service.ILobsterAutoTagsService;
+import com.fs.company.service.workflow.trigger.LobsterCustomerTagService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 数字员工自动打标签规则Controller
+ *
+ * @author fs
+ * @date 2025-06-26
+ */
+@RestController
+@RequestMapping("/workflow/lobster/autoTags")
+public class LobsterAutoTagsController extends BaseController {
+
+    @Autowired
+    private ILobsterAutoTagsService lobsterAutoTagsService;
+
+    @Autowired(required = false)
+    private LobsterCustomerTagService lobsterCustomerTagService;
+
+    /**
+     * 新增
+     */
+    @PostMapping
+    public AjaxResult add(@RequestBody LobsterAutoTags entity) {
+        entity.setCreateTime(new Date());
+        return toAjax(lobsterAutoTagsService.insert(entity));
+    }
+
+    /**
+     * 修改
+     */
+    @PutMapping
+    public AjaxResult edit(@RequestBody LobsterAutoTags entity) {
+        return toAjax(lobsterAutoTagsService.update(entity));
+    }
+
+    /**
+     * 删除
+     */
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids) {
+        int count = 0;
+        for (Long id : ids) {
+            count += lobsterAutoTagsService.deleteById(id);
+        }
+        return toAjax(count);
+    }
+
+    /**
+     * 给企微客户打标签
+     */
+    @PostMapping("/applyTags")
+    public AjaxResult applyTags(@RequestBody Map<String, Object> params) {
+        if (lobsterCustomerTagService == null) {
+            return AjaxResult.error("LobsterCustomerTagService未配置");
+        }
+        Long companyId = params.get("companyId") != null ? Long.valueOf(params.get("companyId").toString()) : null;
+        Long contactId = params.get("contactId") != null ? Long.valueOf(params.get("contactId").toString()) : null;
+        String externalUserId = params.get("externalUserId") != null ? params.get("externalUserId").toString() : null;
+        List<String> tagKeys = (List<String>) params.get("tagKeys");
+        String source = params.get("source") != null ? params.get("source").toString() : null;
+        String reason = params.get("reason") != null ? params.get("reason").toString() : null;
+        lobsterCustomerTagService.applyTags(companyId, contactId, externalUserId, tagKeys, source, reason);
+        return AjaxResult.success();
+    }
+}

+ 55 - 0
java/fs-admin/src/main/java/com/fs/lobster/controller/LobsterErpDeliveryController.java

@@ -0,0 +1,55 @@
+package com.fs.lobster.controller;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.erp.service.IErpOrderService;
+import com.fs.his.domain.FsStoreOrder;
+import com.fs.his.mapper.FsStoreOrderMapper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * ERP快递发货Controller
+ *
+ * @author fs
+ * @date 2025-07-09
+ */
+@RestController
+@RequestMapping("/workflow/lobster/erpDelivery")
+public class LobsterErpDeliveryController extends BaseController {
+
+    @Autowired(required = false)
+    @Qualifier("dfOrderServiceImpl")
+    private IErpOrderService dfOrderService;
+
+    @Autowired
+    private FsStoreOrderMapper fsStoreOrderMapper;
+
+    /**
+     * 查询ERP快递发货状态
+     */
+    @PostMapping("/getOrderDeliveryStatus")
+    public AjaxResult getOrderDeliveryStatus() {
+        IErpOrderService erpOrderService = dfOrderService;
+        List<FsStoreOrder> orders;
+        if (erpOrderService != null) {
+            orders = fsStoreOrderMapper.selectShippedOrder();
+            if (orders != null && !orders.isEmpty()) {
+                List<CompletableFuture<Void>> futures = new ArrayList<>();
+                for (FsStoreOrder order : orders) {
+                    CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
+                        erpOrderService.getOrderDeliveryStatus(order);
+                    });
+                    futures.add(future);
+                }
+                CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
+            }
+        }
+        return AjaxResult.success();
+    }
+}

+ 116 - 0
java/fs-admin/src/main/java/com/fs/lobster/controller/LobsterLiveCardController.java

@@ -0,0 +1,116 @@
+package com.fs.lobster.controller;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.course.domain.FsCoursePlaySourceConfig;
+import com.fs.course.service.IFsCoursePlaySourceConfigService;
+import com.fs.ipad.IpadSendUtils;
+import com.fs.ipad.vo.BaseVo;
+import com.fs.ipad.vo.MiniProgramVo;
+import com.fs.wxwork.dto.WxWorkResponseDTO;
+import com.fs.wxwork.dto.WxWorkSendAppMsgRespDTO;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+/**
+ * 直播卡片发送Controller
+ *
+ * @author fs
+ * @date 2025-07-09
+ */
+@RestController
+@RequestMapping("/workflow/lobster/liveCard")
+public class LobsterLiveCardController extends BaseController {
+
+    private static final Logger log = LoggerFactory.getLogger(LobsterLiveCardController.class);
+
+    @Autowired(required = false)
+    private IpadSendUtils ipadSendUtils;
+
+    @Autowired(required = false)
+    private IFsCoursePlaySourceConfigService playSourceConfigService;
+
+    /**
+     * 发送直播卡片(小程序卡片)
+     */
+    @PostMapping("/sendLiveMiniProgram")
+    public AjaxResult sendLiveMiniProgram(@RequestBody Map<String, Object> params) {
+        if (ipadSendUtils == null) {
+            return AjaxResult.error("IpadSendUtils未配置");
+        }
+
+        // 解析BaseVo参数
+        BaseVo vo = new BaseVo();
+        vo.setUuid(getString(params, "uuid"));
+        vo.setExId(getString(params, "exId"));
+        vo.setQwUserId(getLong(params, "qwUserId"));
+        vo.setRoom(Boolean.TRUE.equals(params.get("isRoom")));
+        if (params.get("serverId") != null) {
+            vo.setServerId(Long.valueOf(params.get("serverId").toString()));
+        }
+        vo.setCorpId(getString(params, "corpId"));
+        vo.setCorpCode(getString(params, "corpCode"));
+        if (params.get("id") != null) {
+            vo.setId(Long.valueOf(params.get("id").toString()));
+        }
+
+        // 解析小程序参数
+        String appid = getString(params, "appid");
+        String desc = getString(params, "desc");
+        String imgUrl = getString(params, "imgUrl");
+        String pagepath = getString(params, "pagepath");
+
+        if (appid == null || appid.isEmpty()) {
+            return AjaxResult.error("appid不能为空");
+        }
+
+        // 查询小程序配置获取title/weappIconUrl/username
+        FsCoursePlaySourceConfig courseMaConfig = null;
+        if (playSourceConfigService != null) {
+            courseMaConfig = playSourceConfigService.selectCoursePlaySourceConfigByAppId(appid);
+        }
+
+        String title = courseMaConfig != null ? courseMaConfig.getName() : getString(params, "title");
+        String weappIconUrl = courseMaConfig != null ? courseMaConfig.getImg() : getString(params, "weappIconUrl");
+        String username = courseMaConfig != null ? (courseMaConfig.getOriginalId() + "@app") : getString(params, "username");
+
+        // 处理desc:去掉 "-" 后面的部分
+        if (desc != null && desc.contains("-")) {
+            desc = desc.replaceFirst("-.*", "");
+        }
+
+        MiniProgramVo miniProgramVo = MiniProgramVo.builder()
+                .desc(desc)
+                .title(title)
+                .weappIconUrl(weappIconUrl)
+                .imgUrl(imgUrl)
+                .username(username)
+                .pagepath(pagepath)
+                .appid(appid)
+                .build();
+        miniProgramVo.setBase(vo);
+
+        WxWorkResponseDTO<WxWorkSendAppMsgRespDTO> resp = ipadSendUtils.sendMiniProgram(miniProgramVo);
+        if (resp.getErrcode() != 0) {
+            log.warn("发送直播卡片失败: {}", resp.getErrmsg());
+            return AjaxResult.error("发送失败: " + resp.getErrmsg());
+        }
+        return AjaxResult.success();
+    }
+
+    private String getString(Map<String, Object> params, String key) {
+        Object val = params.get(key);
+        return val != null ? val.toString() : null;
+    }
+
+    private Long getLong(Map<String, Object> params, String key) {
+        Object val = params.get(key);
+        if (val == null) return null;
+        if (val instanceof Number) return ((Number) val).longValue();
+        return Long.valueOf(val.toString());
+    }
+}

+ 7 - 8
java/fs-admin/src/main/java/com/fs/web/controller/system/SysConfigController.java

@@ -151,24 +151,23 @@ public class SysConfigController extends BaseController {
     public AjaxResult updateConfigByKey(@Validated @RequestBody SysConfig config) {
         config.setCreateBy(SecurityUtils.getUsername());
         Long tenantId = config.getTenantId();
-        if (tenantId == null){
+        AjaxResult result;
+        if (tenantId == null) {
             if (null != config.getConfigId()) {
-                return toAjax(configService.updateConfig(config));
+                result = toAjax(configService.updateConfig(config));
             } else {
-                return toAjax(configService.insertConfig(config));
+                result = toAjax(configService.insertConfig(config));
             }
         } else {
             if (null != config.getConfigId()) {
-                return toAjax(tenantContextHelper.executeInTenant(tenantId, () ->configService.updateConfig(config)));
+                result = toAjax(tenantContextHelper.executeInTenant(tenantId, () -> configService.updateConfig(config)));
             } else {
-                return toAjax(tenantContextHelper.executeInTenant(tenantId, () ->configService.insertConfig(config)));
+                result = toAjax(tenantContextHelper.executeInTenant(tenantId, () -> configService.insertConfig(config)));
             }
         }
-        //修复只能更新的BUG
-
+        return result;
     }
 
-
     /**
      * 启用-关闭小程序销售管理
      *

+ 89 - 0
java/fs-admin/src/main/java/com/fs/web/controller/system/SysDataMigrateController.java

@@ -0,0 +1,89 @@
+package com.fs.web.controller.system;
+
+import com.fs.common.constant.FsConstants;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.redis.RedisCache;
+import com.fs.company.domain.Company;
+import com.fs.company.mapper.CompanyMapper;
+import com.fs.framework.datasource.TenantDataSourceContextHelper;
+import com.fs.tenant.domain.TenantInfo;
+import com.fs.tenant.service.TenantInfoService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 数据迁移控制器 —— DB余额 → Redis缓存同步
+ * <p>
+ * 总后台遍历所有租户,将各租户下所有公司的流量余额、Token余额、红包余额从DB同步到Redis。
+ *
+ * @author fs
+ */
+@Slf4j
+@RestController
+@RequestMapping("/system/datamigrate")
+public class SysDataMigrateController extends BaseController {
+
+    private static final String SECRET_KEY = "ylrz147..";
+
+    @Autowired private TenantInfoService tenantInfoService;
+    @Autowired private TenantDataSourceContextHelper tenantContextHelper;
+    @Autowired private CompanyMapper companyMapper;
+    @Autowired private RedisCache redisCache;
+
+    @PreAuthorize("@ss.hasPermi('system:datamigrate:sync')")
+    @PostMapping("/syncBalance")
+    public AjaxResult syncBalance(@RequestBody Map<String, String> body) {
+        if (!SECRET_KEY.equals(body.get("secretKey"))) {
+            return AjaxResult.error("秘钥错误,操作被拒绝");
+        }
+
+        List<TenantInfo> tenants = tenantContextHelper.executeInMaster(() ->
+                tenantInfoService.selectTenantInfoList(new TenantInfo()));
+
+        for (TenantInfo tenant : tenants) {
+            if (tenant.getStatus() == null || tenant.getStatus() != 1) {
+                continue;
+            }
+            try {
+                tenantContextHelper.executeInTenant(tenant, () -> {
+                    List<Company> companies = companyMapper.selectCompanyAllList();
+                    if (companies == null) return null;
+                    for (Company company : companies) {
+                        syncCompany(company.getCompanyId());
+                    }
+                    return null;
+                });
+            } catch (Exception e) {
+                log.error("切到租户库失败, tenantId:{}", tenant.getId(), e);
+            }
+        }
+        return AjaxResult.success("同步完成");
+    }
+
+    private void syncCompany(Long companyId) {
+        try {
+            Map<String, Object> balances = companyMapper.selectCompanyBalancesByCompany(companyId);
+            if (balances == null) return;
+
+            Long traffic = ((Number) balances.getOrDefault("trafficBalance", 0L)).longValue();
+            BigDecimal redPkg = new BigDecimal(String.valueOf(balances.getOrDefault("redPackageBalance", BigDecimal.ZERO)));
+            Long token = ((Number) balances.getOrDefault("tokenBalance", 0L)).longValue();
+
+            redisCache.setCacheObject(FsConstants.COMPANY_TRAFFIC_KEY + companyId, String.valueOf(traffic));
+            redisCache.setCacheObject(FsConstants.COMPANY_MONEY_KEY + companyId, redPkg.toString());
+            redisCache.setCacheObject(FsConstants.COMPANY_TOKEN_KEY + companyId, String.valueOf(token));
+        } catch (Exception e) {
+            log.error("同步公司余额失败, companyId:{}", companyId, e);
+        }
+    }
+}

+ 11 - 1
java/fs-admin/src/main/java/com/fs/web/controller/system/SysKeywordController.java

@@ -61,11 +61,21 @@ public class SysKeywordController extends BaseController
         return util.exportExcel(list, "系统关键字数据");
     }
 
+    /**
+     * 获取关键字页面元数据(默认类型、类型选项)
+     */
+    @PreAuthorize("@ss.hasPermi('system:keyword:list')")
+    @GetMapping("/meta")
+    public AjaxResult meta()
+    {
+        return AjaxResult.success(sysKeywordService.keywordMeta());
+    }
+
     /**
      * 获取系统关键字详细信息
      */
     @PreAuthorize("@ss.hasPermi('system:keyword:query')")
-    @GetMapping(value = "/{keywordId}")
+    @GetMapping(value = "/{keywordId:\\d+}")
     public AjaxResult getInfo(@PathVariable("keywordId") Long keywordId)
     {
         return AjaxResult.success(sysKeywordService.selectSysKeywordById(keywordId));

+ 8 - 6
java/fs-admin/src/main/resources/logback.xml

@@ -67,13 +67,15 @@
 	<logger name="org.springframework" level="warn" />
 	<logger name="com.baomidou.mybatisplus" level="warn" />
 	<logger name="org.apache.ibatis" level="warn" />
-	<logger name="com.fs.his.mapper" level="debug" />
-	<logger name="com.fs.company.mapper" level="debug" />
-	<logger name="com.fs.admin.mapper" level="debug" />
-	<logger name="com.fs.tenant.mapper" level="debug" />
-	<logger name="com.fs.fee.mapper" level="debug" />
-	<logger name="com.fs.hisStore.mapper" level="debug" />
+	<logger name="com.fs.his.mapper" level="info" />
+	<logger name="com.fs.company.mapper" level="info" />
+	<logger name="com.fs.admin.mapper" level="info" />
+	<logger name="com.fs.tenant.mapper" level="info" />
+	<logger name="com.fs.fee.mapper" level="info" />
+	<logger name="com.fs.hisStore.mapper" level="info" />
+	<logger name="com.fs.course.mapper" level="info" />
 	<logger name="com.fs.framework.datasource" level="info" />
+	<logger name="org.springframework.web.servlet" level="warn" />
 
 
     <root level="info">

+ 64 - 0
java/fs-ai-api/src/main/java/com/fs/ai/rag/controller/LlmController.java

@@ -6,13 +6,18 @@ import com.fs.common.core.domain.R;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.MediaType;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
 
+import javax.annotation.Resource;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.Future;
 
 @Slf4j
 @RestController
@@ -25,6 +30,9 @@ public class LlmController {
 
     private final LlmService llmService;
 
+    @Resource(name = "threadPoolTaskExecutor")
+    private ThreadPoolTaskExecutor threadPoolTaskExecutor;
+
     @PostMapping("/chat")
     public R chat(@RequestBody Map<String, Object> request) {
         String modelName = (String) request.getOrDefault("model", defaultModel);
@@ -49,4 +57,60 @@ public class LlmController {
             return R.error().put("msg", "LLM 调用失败: " + e.getMessage());
         }
     }
+
+    @PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+    public SseEmitter chatStream(@RequestBody Map<String, Object> request) {
+        String modelName = (String) request.getOrDefault("model", defaultModel);
+        @SuppressWarnings("unchecked")
+        List<Map<String, String>> messages = (List<Map<String, String>>) request.get("messages");
+        double temperature = request.get("temperature") != null
+                ? ((Number) request.get("temperature")).doubleValue() : 0.7;
+        int maxTokens = request.get("max_tokens") != null
+                ? ((Number) request.get("max_tokens")).intValue() : 2048;
+
+        if (messages == null || messages.isEmpty()) {
+            SseEmitter err = new SseEmitter(0L);
+            err.completeWithError(new IllegalArgumentException("messages 不能为空"));
+            return err;
+        }
+
+        SseEmitter emitter = new SseEmitter(180000L);
+
+        Future<?> future = threadPoolTaskExecutor.submit(() -> {
+            try {
+                llmService.chatStream(modelName, messages, temperature, maxTokens, request, data -> {
+                    try {
+                        if ("[DONE]".equals(data)) {
+                            emitter.send(SseEmitter.event().name("done").data("[DONE]"));
+                        } else {
+                            emitter.send(SseEmitter.event().name("message").data(data));
+                        }
+                    } catch (Exception e) {
+                        throw new RuntimeException(e);
+                    }
+                });
+                emitter.complete();
+            } catch (Exception e) {
+                log.error("LLM 流式异常: {}", e.getMessage());
+                try {
+                    emitter.send(SseEmitter.event().name("error").data("{\"message\":\"" + e.getMessage() + "\"}"));
+                } catch (Exception ignored) {
+                }
+                emitter.complete();
+            }
+        });
+
+        emitter.onCompletion(() -> {
+            if (!future.isDone()) {
+                future.cancel(true);
+            }
+        });
+        emitter.onTimeout(() -> {
+            if (!future.isDone()) {
+                future.cancel(true);
+            }
+        });
+
+        return emitter;
+    }
 }

+ 4 - 0
java/fs-ai-api/src/main/java/com/fs/ai/rag/service/LlmService.java

@@ -2,6 +2,7 @@ package com.fs.ai.rag.service;
 
 import java.util.List;
 import java.util.Map;
+import java.util.function.Consumer;
 
 /**
  * LLM 对话服务
@@ -32,4 +33,7 @@ public interface LlmService {
      * @return 模型回复内容
      */
     String chat(String prompt, double temperature, int maxTokens);
+
+    void chatStream(String modelName, List<Map<String, String>> messages, double temperature,
+                    int maxTokens, Map<String, Object> request, Consumer<String> onData);
 }

+ 91 - 0
java/fs-ai-api/src/main/java/com/fs/ai/rag/service/impl/LlmServiceImpl.java

@@ -9,10 +9,18 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.function.Consumer;
 
 @Slf4j
 @Service
@@ -108,4 +116,87 @@ public class LlmServiceImpl implements LlmService {
             throw new IllegalStateException("LLM 调用失败: " + e.getMessage(), e);
         }
     }
+
+    @Override
+    public void chatStream(String modelName, List<Map<String, String>> messages, double temperature,
+                           int maxTokens, Map<String, Object> request, Consumer<String> onData) {
+        if (messages == null || messages.isEmpty()) {
+            throw new IllegalArgumentException("messages 不能为空");
+        }
+
+        JSONObject reqBody = buildRequestBody(modelName, messages, temperature, maxTokens, request);
+        reqBody.put("stream", true);
+        log.info("LLM 流式请求: model={}, messages.size={}", modelName, messages.size());
+
+        HttpURLConnection conn = null;
+        try {
+            URL url = new URI(llmUrl).toURL();
+            conn = (HttpURLConnection) url.openConnection();
+            conn.setRequestMethod("POST");
+            conn.setRequestProperty("Authorization", "Bearer " + apiKey);
+            conn.setRequestProperty("Content-Type", "application/json");
+            conn.setDoOutput(true);
+            conn.setConnectTimeout(5000);
+            conn.setReadTimeout(180000);
+
+            try (OutputStream os = conn.getOutputStream()) {
+                os.write(reqBody.toJSONString().getBytes(StandardCharsets.UTF_8));
+            }
+            try (BufferedReader reader = new BufferedReader(
+                    new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
+                String line;
+                while ((line = reader.readLine()) != null) {
+                    if (line.startsWith("data: ")) {
+                        onData.accept(line.substring(6).trim());
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.error("LLM 流式调用异常: {}", e.getMessage(), e);
+            throw new IllegalStateException("LLM 流式调用失败", e);
+        } finally {
+            if (conn != null) {
+                conn.disconnect();
+            }
+        }
+    }
+
+    private JSONObject buildRequestBody(String modelName, List<Map<String, String>> messages,
+                                         double temperature, int maxTokens,
+                                         Map<String, Object> additionalParams) {
+        JSONObject reqBody = new JSONObject();
+        reqBody.put("model", modelName);
+
+        JSONArray msgArray = new JSONArray();
+        for (Map<String, String> msg : messages) {
+            JSONObject msgObj = new JSONObject();
+            msgObj.put("role", msg.get("role"));
+            msgObj.put("content", msg.get("content"));
+            if (msg.containsKey("tool_calls")) {
+                String tc = msg.get("tool_calls");
+                msgObj.put("tool_calls", tc != null ? JSON.parse(tc) : null);
+            }
+            if (msg.containsKey("tool_call_id")) {
+                msgObj.put("tool_call_id", msg.get("tool_call_id"));
+            }
+            if (msg.containsKey("name")) {
+                msgObj.put("name", msg.get("name"));
+            }
+            msgArray.add(msgObj);
+        }
+        reqBody.put("messages", msgArray);
+        reqBody.put("temperature", temperature);
+        reqBody.put("max_tokens", maxTokens);
+
+        if (additionalParams != null) {
+            if (additionalParams.containsKey("tools")) {
+                reqBody.put("tools", additionalParams.get("tools"));
+            }
+            if (additionalParams.containsKey("tool_choice")) {
+                reqBody.put("tool_choice", additionalParams.get("tool_choice"));
+            }
+        }
+
+        return reqBody;
+    }
 }

+ 2 - 2
java/fs-ai-api/src/main/java/com/fs/framework/config/FilterConfig.java

@@ -37,7 +37,7 @@ public class FilterConfig
         registration.setFilter(new XssFilter());
         registration.addUrlPatterns(StringUtils.split(urlPatterns, ","));
         registration.setName("xssFilter");
-        registration.setOrder(FilterRegistrationBean.HIGHEST_PRECEDENCE);
+        registration.setOrder(2);
         Map<String, String> initParameters = new HashMap<String, String>();
         initParameters.put("excludes", excludes);
         registration.setInitParameters(initParameters);
@@ -52,7 +52,7 @@ public class FilterConfig
         registration.setFilter(new RepeatableFilter());
         registration.addUrlPatterns("/*");
         registration.setName("repeatableFilter");
-        registration.setOrder(FilterRegistrationBean.LOWEST_PRECEDENCE);
+        registration.setOrder(1);
         return registration;
     }
 

+ 1 - 1
java/fs-ai-api/src/main/resources/application-common.yml

@@ -132,7 +132,7 @@ xss:
   # 排除链接(多个用逗号分隔)
   excludes: /system/notice,/system/config/*
   # 匹配链接
-  urlPatterns: /system/*,/monitor/*,/tool/*
+  urlPatterns: /*
 zhyf:
   url: https://zhyf-testController.jingpai.com
 

+ 2 - 2
java/fs-ai-call-task/src/main/java/com/fs/framework/config/FilterConfig.java

@@ -37,7 +37,7 @@ public class FilterConfig
         registration.setFilter(new XssFilter());
         registration.addUrlPatterns(StringUtils.split(urlPatterns, ","));
         registration.setName("xssFilter");
-        registration.setOrder(FilterRegistrationBean.HIGHEST_PRECEDENCE);
+        registration.setOrder(2);
         Map<String, String> initParameters = new HashMap<String, String>();
         initParameters.put("excludes", excludes);
         registration.setInitParameters(initParameters);
@@ -52,7 +52,7 @@ public class FilterConfig
         registration.setFilter(new RepeatableFilter());
         registration.addUrlPatterns("/*");
         registration.setName("repeatableFilter");
-        registration.setOrder(FilterRegistrationBean.LOWEST_PRECEDENCE);
+        registration.setOrder(1);
         return registration;
     }
 

+ 15 - 8
java/fs-cid-workflow/src/main/java/com/fs/app/task/CidTask.java

@@ -38,9 +38,6 @@ public class CidTask {
     @Autowired
     private IAiCallCallbackCompensateService aiCallCallbackCompensateService;
 
-    @Value("${cid-group-no:0}")
-    private Integer cidGroupNo;
-
     /**
      * 扫描当前分组下就绪任务,并开启执行
      */
@@ -85,11 +82,21 @@ public class CidTask {
      */
     @Scheduled(cron = "30 0/1 * * * ?")
     public void compensateAiCallCallback() {
-        if (saasTaskEnabled) {
-            tenantTaskRunner.runForResponsibleTenant("aiCallCallbackCompensate",
-                    () -> aiCallCallbackCompensateService.compensatePendingCallbacks(cidGroupNo));
-        } else {
-            aiCallCallbackCompensateService.compensatePendingCallbacks(cidGroupNo);
+        long startMs = System.currentTimeMillis();
+        log.info("[aiCallCallbackCompensate] 定时任务触发, saasTaskEnabled={}, cidGroupNo配置见服务侧 ai-call 补偿实现",
+                saasTaskEnabled);
+        try {
+            if (saasTaskEnabled) {
+                tenantTaskRunner.runForResponsibleTenant("aiCallCallbackCompensate",
+                        aiCallCallbackCompensateService::compensatePendingCallbacks);
+            } else {
+                log.info("[aiCallCallbackCompensate] 私有化模式,直接执行 compensatePendingCallbacks");
+                aiCallCallbackCompensateService.compensatePendingCallbacks();
+            }
+        } catch (Exception e) {
+            log.error("[aiCallCallbackCompensate] 定时任务执行异常", e);
+        } finally {
+            log.info("[aiCallCallbackCompensate] 定时任务结束, 耗时={}ms", System.currentTimeMillis() - startMs);
         }
     }
 

+ 29 - 14
java/fs-cid-workflow/src/main/java/com/fs/app/task/TenantTaskRunner.java

@@ -220,22 +220,37 @@ public class TenantTaskRunner {
      * @param taskName 定时任务名称,会输出在「定时任务切换数据源...」后面
      */
     public void runForResponsibleTenant(String taskName, Runnable action) {
+        log.info("[SaaS Task] runForResponsibleTenant 开始, task={}, serviceMarker={}, serverType={}",
+                taskName, tenantServiceMarker, EnumServerType.CIDWORKFLOW.getValue());
         TenantServiceConfig tenantServiceConfig = tenantServiceConfigMapper.selectTenantServiceConfigByServiceMarkerAndType(tenantServiceMarker, EnumServerType.CIDWORKFLOW.getValue());
-        if(null != tenantServiceConfig) {
-            String tenantIds = tenantServiceConfig.getTenantIds();
-            if (StringUtils.isNotBlank(tenantIds)) {
-                List<Long> tenatIdList = Arrays.stream((tenantIds.split(","))).map(item -> Long.valueOf(item.trim())).collect(Collectors.toList());
-                List<TenantInfo> responsibleValidTenants = getResponsibleValidTenants(tenatIdList);
-                if (responsibleValidTenants == null || responsibleValidTenants.isEmpty()) {
-                    return;
-                }
-                if (parallelEnabled && tenantExecutor != null) {
-                    runForEachTenantParallel(responsibleValidTenants, taskName, t -> action.run());
-                } else {
-                    runForEachTenantSequential(responsibleValidTenants, taskName, t -> action.run());
-                }
-            }
+        if (tenantServiceConfig == null) {
+            log.warn("[SaaS Task] 未找到租户服务配置,跳过, task={}, serviceMarker={}, serverType={}",
+                    taskName, tenantServiceMarker, EnumServerType.CIDWORKFLOW.getValue());
+            return;
+        }
+        String tenantIds = tenantServiceConfig.getTenantIds();
+        if (StringUtils.isBlank(tenantIds)) {
+            log.warn("[SaaS Task] 租户服务配置 tenantIds 为空,跳过, task={}, serviceMarker={}",
+                    taskName, tenantServiceMarker);
+            return;
+        }
+        List<Long> tenatIdList = Arrays.stream((tenantIds.split(","))).map(item -> Long.valueOf(item.trim())).collect(Collectors.toList());
+        List<TenantInfo> responsibleValidTenants = getResponsibleValidTenants(tenatIdList);
+        if (responsibleValidTenants == null || responsibleValidTenants.isEmpty()) {
+            log.info("[SaaS Task] 无有效负责租户,跳过, task={}, 配置tenantIds={}", taskName, tenantIds);
+            return;
+        }
+        log.info("[SaaS Task] 开始按租户执行, task={}, tenantCount={}, tenantIds={}, parallelEnabled={}",
+                taskName, responsibleValidTenants.size(),
+                responsibleValidTenants.stream().map(TenantInfo::getId).collect(Collectors.toList()),
+                parallelEnabled);
+        if (parallelEnabled && tenantExecutor != null) {
+            runForEachTenantParallel(responsibleValidTenants, taskName, t -> action.run());
+        } else {
+            runForEachTenantSequential(responsibleValidTenants, taskName, t -> action.run());
         }
+        log.info("[SaaS Task] runForResponsibleTenant 完成, task={}, tenantCount={}",
+                taskName, responsibleValidTenants.size());
     }
 
     /**

+ 2 - 2
java/fs-cid-workflow/src/main/java/com/fs/framework/config/FilterConfig.java

@@ -37,7 +37,7 @@ public class FilterConfig
         registration.setFilter(new XssFilter());
         registration.addUrlPatterns(StringUtils.split(urlPatterns, ","));
         registration.setName("xssFilter");
-        registration.setOrder(FilterRegistrationBean.HIGHEST_PRECEDENCE);
+        registration.setOrder(2);
         Map<String, String> initParameters = new HashMap<String, String>();
         initParameters.put("excludes", excludes);
         registration.setInitParameters(initParameters);
@@ -52,7 +52,7 @@ public class FilterConfig
         registration.setFilter(new RepeatableFilter());
         registration.addUrlPatterns("/*");
         registration.setName("repeatableFilter");
-        registration.setOrder(FilterRegistrationBean.LOWEST_PRECEDENCE);
+        registration.setOrder(1);
         return registration;
     }
 

+ 18 - 0
java/fs-comm-gateway/src/main/java/com/fs/comm/controller/ProbeHealthController.java

@@ -0,0 +1,18 @@
+package com.fs.comm.controller;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * K8s / 负载均衡探针健康检查(无需登录、无需租户头)。
+ */
+@RestController
+public class ProbeHealthController {
+
+    @RequestMapping(value = {"/", "/health"}, method = {RequestMethod.GET, RequestMethod.HEAD})
+    public ResponseEntity<Void> probe() {
+        return ResponseEntity.ok().build();
+    }
+}

+ 2 - 0
java/fs-comm-gateway/src/main/java/com/fs/framework/config/SecurityConfig.java

@@ -2,6 +2,7 @@ package com.fs.framework.config;
 
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.http.HttpMethod;
 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
 import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
@@ -22,6 +23,7 @@ public class SecurityConfig {
         http.csrf().disable()
                 .authorizeRequests()
                 .antMatchers("/comm/callback/**", "/comm/auth/**", "/comm/softphone/ws").permitAll()
+                .antMatchers(HttpMethod.HEAD, "/", "/health").permitAll()
                 .antMatchers("/**").permitAll();
         return http.build();
     }

+ 7 - 3
java/fs-comm-gateway/src/main/resources/application.yml

@@ -28,10 +28,14 @@ spring:
   main:
     allow-bean-definition-overriding: true
   profiles:
-    active: ylrz
+    active: dev
     include: common,config-dev
 
 easycall:
   base-url: http://129.28.164.235:8899
-  callback-url: https://ylgateway.ylrzcloud.com/comm/callback/easycall
-#  callback-url: http://r8ddb963.natappfree.cc/comm/callback/easycall
+#  新saas回调地址
+#  callback-url: https://ylgateway.ylrzcloud.com/comm/callback/easycall
+#  k8s的saas回调地址
+#  callback-url: https://comgateway.ylrzcloud.com/comm/callback/easycall
+#  本地回调地址
+  callback-url: http://wbc2b8d6.natappfree.cc/comm/callback/easycall

+ 107 - 0
java/fs-comm-gateway/src/main/resources/logback.xml

@@ -0,0 +1,107 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<configuration>
+    <!-- 日志存放路径 -->
+	<property name="log.path" value="${LOG_PATH:-/home/fs-comm-gateway/logs}" />
+    <!-- 日志输出格式 -->
+	<property name="log.pattern" value="%d{HH:mm:ss.SSS} [%thread] %-5level %logger{20} - [%method,%line] - %msg%n" />
+
+	<!-- 控制台输出 -->
+	<appender name="console" class="ch.qos.logback.core.ConsoleAppender">
+		<encoder>
+			<pattern>${log.pattern}</pattern>
+		</encoder>
+	</appender>
+
+    <logger name="com.fs" level="info" />
+    <logger name="org.springframework" level="warn" />
+    <logger name="com.baomidou.mybatisplus" level="warn" />
+    <logger name="org.apache.ibatis" level="warn" />
+    <logger name="com.fs.his.mapper" level="info" />
+    <logger name="com.fs.company.mapper" level="info" />
+    <logger name="com.fs.admin.mapper" level="info" />
+    <logger name="com.fs.tenant.mapper" level="info" />
+    <logger name="com.fs.fee.mapper" level="info" />
+    <logger name="com.fs.hisStore.mapper" level="info" />
+    <logger name="com.fs.course.mapper" level="info" />
+    <logger name="com.fs.framework.datasource" level="info" />
+    <logger name="org.springframework.web.servlet" level="warn" />
+
+	<!-- 系统日志输出 -->
+	<appender name="file_info" class="ch.qos.logback.core.rolling.RollingFileAppender">
+	    <file>${log.path}/sys-info.log</file>
+        <!-- 循环政策:基于时间创建日志文件 -->
+		<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <!-- 日志文件名格式 -->
+			<fileNamePattern>${log.path}/sys-info.%d{yyyy-MM-dd}.log</fileNamePattern>
+			<!-- 日志最大的历史 30 -->
+			<maxHistory>30</maxHistory>
+		</rollingPolicy>
+		<encoder>
+			<pattern>${log.pattern}</pattern>
+		</encoder>
+		<filter class="ch.qos.logback.classic.filter.LevelFilter">
+            <!-- 过滤的级别 -->
+            <level>INFO</level>
+            <!-- 匹配时的操作:接收(记录) -->
+            <onMatch>ACCEPT</onMatch>
+            <!-- 不匹配时的操作:拒绝(不记录) -->
+            <onMismatch>DENY</onMismatch>
+        </filter>
+	</appender>
+
+	<appender name="file_error" class="ch.qos.logback.core.rolling.RollingFileAppender">
+	    <file>${log.path}/sys-error.log</file>
+        <!-- 循环政策:基于时间创建日志文件 -->
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <!-- 日志文件名格式 -->
+            <fileNamePattern>${log.path}/sys-error.%d{yyyy-MM-dd}.log</fileNamePattern>
+			<!-- 日志最大的历史 30 -->
+			<maxHistory>30</maxHistory>
+        </rollingPolicy>
+        <encoder>
+            <pattern>${log.pattern}</pattern>
+        </encoder>
+        <filter class="ch.qos.logback.classic.filter.LevelFilter">
+            <!-- 过滤的级别 -->
+            <level>ERROR</level>
+			<!-- 匹配时的操作:接收(记录) -->
+            <onMatch>ACCEPT</onMatch>
+			<!-- 不匹配时的操作:拒绝(不记录) -->
+            <onMismatch>DENY</onMismatch>
+        </filter>
+    </appender>
+
+	<!-- 用户访问日志输出  -->
+    <appender name="sys-user" class="ch.qos.logback.core.rolling.RollingFileAppender">
+		<file>${log.path}/sys-user.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <!-- 按天回滚 daily -->
+            <fileNamePattern>${log.path}/sys-user.%d{yyyy-MM-dd}.log</fileNamePattern>
+            <!-- 日志最大的历史 30 -->
+            <maxHistory>30</maxHistory>
+        </rollingPolicy>
+        <encoder>
+            <pattern>${log.pattern}</pattern>
+        </encoder>
+    </appender>
+
+	<!-- 系统模块日志级别控制  -->
+	<logger name="com.fs" level="info" />
+	<!-- Spring日志级别控制  -->
+	<logger name="org.springframework" level="warn" />
+
+	<root level="info">
+		<appender-ref ref="console" />
+	</root>
+
+	<!--系统操作日志-->
+    <root level="info">
+        <appender-ref ref="file_info" />
+        <appender-ref ref="file_error" />
+    </root>
+
+	<!--系统用户操作日志-->
+    <logger name="sys-user" level="info">
+        <appender-ref ref="sys-user"/>
+    </logger>
+</configuration>

+ 2 - 2
java/fs-common-api/src/main/java/com/fs/framework/config/FilterConfig.java

@@ -37,7 +37,7 @@ public class FilterConfig
         registration.setFilter(new XssFilter());
         registration.addUrlPatterns(StringUtils.split(urlPatterns, ","));
         registration.setName("xssFilter");
-        registration.setOrder(FilterRegistrationBean.HIGHEST_PRECEDENCE);
+        registration.setOrder(2);
         Map<String, String> initParameters = new HashMap<String, String>();
         initParameters.put("excludes", excludes);
         registration.setInitParameters(initParameters);
@@ -52,7 +52,7 @@ public class FilterConfig
         registration.setFilter(new RepeatableFilter());
         registration.addUrlPatterns("/*");
         registration.setName("repeatableFilter");
-        registration.setOrder(FilterRegistrationBean.LOWEST_PRECEDENCE);
+        registration.setOrder(1);
         return registration;
     }
 

+ 75 - 0
java/fs-common/src/main/java/com/fs/common/config/XssResponseAutoConfiguration.java

@@ -0,0 +1,75 @@
+package com.fs.common.config;
+
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.html.HtmlXssUtil;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.MethodParameter;
+import org.springframework.core.io.Resource;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.http.server.ServerHttpRequest;
+import org.springframework.http.server.ServerHttpResponse;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
+
+import java.io.InputStream;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * 全局响应 XSS 过滤自动配置(所有依赖 fs-common 的 Web 服务自动生效)
+ */
+@Configuration
+@ControllerAdvice
+@ConditionalOnWebApplication
+@ConditionalOnClass(ResponseBodyAdvice.class)
+@ConditionalOnProperty(value = "xss.response.enabled", havingValue = "true", matchIfMissing = true)
+public class XssResponseAutoConfiguration implements ResponseBodyAdvice<Object>
+{
+    @Value("${xss.response.excludes:}")
+    private String excludes;
+
+    @Override
+    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType)
+    {
+        Class<?> returnClass = returnType.getParameterType();
+        return !byte[].class.isAssignableFrom(returnClass)
+                && !InputStream.class.isAssignableFrom(returnClass)
+                && !Resource.class.isAssignableFrom(returnClass);
+    }
+
+    @Override
+    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
+            Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request,
+            ServerHttpResponse response)
+    {
+        if (body == null)
+        {
+            return body;
+        }
+        if (isExcluded(request.getURI().getPath()))
+        {
+            return body;
+        }
+        if (body instanceof String)
+        {
+            return HtmlXssUtil.filterXss((String) body);
+        }
+        HtmlXssUtil.filterObject(body);
+        return body;
+    }
+
+    private boolean isExcluded(String path)
+    {
+        if (StringUtils.isEmpty(excludes) || StringUtils.isEmpty(path))
+        {
+            return false;
+        }
+        List<String> patterns = Arrays.asList(excludes.split(","));
+        return StringUtils.matches(path, patterns);
+    }
+}

+ 90 - 0
java/fs-common/src/main/java/com/fs/common/filter/HttpRequestBodyUtils.java

@@ -0,0 +1,90 @@
+package com.fs.common.filter;
+
+import com.fs.common.utils.StringUtils;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * 请求体类型判断,避免 XSS Filter 读取/缓存二进制或 multipart 流导致上传失败。
+ */
+public final class HttpRequestBodyUtils
+{
+    private HttpRequestBodyUtils()
+    {
+    }
+
+    public static boolean isMultipartRequest(HttpServletRequest request)
+    {
+        String contentType = request.getHeader(HttpHeaders.CONTENT_TYPE);
+        return contentType != null && contentType.toLowerCase().contains(MediaType.MULTIPART_FORM_DATA_VALUE);
+    }
+
+    public static boolean isFormUrlEncodedRequest(HttpServletRequest request)
+    {
+        String contentType = request.getHeader(HttpHeaders.CONTENT_TYPE);
+        return StringUtils.startsWithIgnoreCase(contentType, MediaType.APPLICATION_FORM_URLENCODED_VALUE);
+    }
+
+    /**
+     * 二进制或文件上传类请求,不能按字符串读取 body。
+     */
+    public static boolean isBinaryOrUploadRequest(HttpServletRequest request)
+    {
+        if (isMultipartRequest(request) || isFormUrlEncodedRequest(request))
+        {
+            return true;
+        }
+        String contentType = request.getHeader(HttpHeaders.CONTENT_TYPE);
+        if (StringUtils.isEmpty(contentType))
+        {
+            return false;
+        }
+        String lower = contentType.toLowerCase();
+        return lower.contains(MediaType.APPLICATION_OCTET_STREAM_VALUE)
+                || lower.startsWith("image/")
+                || lower.startsWith("video/")
+                || lower.startsWith("audio/")
+                || lower.contains("application/pdf")
+                || lower.contains("application/zip")
+                || lower.contains("application/vnd.");
+    }
+
+    /**
+     * 仅对 JSON / 文本类 body 做缓存或 XSS 字符串校验。
+     * 未带 Content-Type 的 POST/PUT 等仍视为文本 API(小程序常见),否则防重复提交读不到 body。
+     */
+    public static boolean isTextRequestBody(HttpServletRequest request)
+    {
+        if (isBinaryOrUploadRequest(request))
+        {
+            return false;
+        }
+        String contentType = request.getHeader(HttpHeaders.CONTENT_TYPE);
+        if (StringUtils.isEmpty(contentType))
+        {
+            return mayHaveBody(request);
+        }
+        String lower = contentType.toLowerCase();
+        return lower.startsWith(MediaType.APPLICATION_JSON_VALUE)
+                || lower.startsWith("text/")
+                || lower.contains("application/xml")
+                || lower.contains("application/javascript")
+                || lower.contains("+json")
+                || lower.contains("+xml");
+    }
+
+    public static boolean mayHaveBody(HttpServletRequest request)
+    {
+        String method = request.getMethod();
+        if (method == null)
+        {
+            return false;
+        }
+        return "POST".equalsIgnoreCase(method)
+                || "PUT".equalsIgnoreCase(method)
+                || "PATCH".equalsIgnoreCase(method)
+                || "DELETE".equalsIgnoreCase(method);
+    }
+}

+ 11 - 13
java/fs-common/src/main/java/com/fs/common/filter/RepeatableFilter.java

@@ -1,20 +1,12 @@
 package com.fs.common.filter;
 
-import java.io.IOException;
-import javax.servlet.Filter;
-import javax.servlet.FilterChain;
-import javax.servlet.FilterConfig;
-import javax.servlet.ServletException;
-import javax.servlet.ServletRequest;
-import javax.servlet.ServletResponse;
+import javax.servlet.*;
 import javax.servlet.http.HttpServletRequest;
-import org.springframework.http.MediaType;
-import com.fs.common.utils.StringUtils;
+import java.io.IOException;
 
 /**
- * Repeatable 过滤器
- * 
-
+ * Repeatable 过滤器:仅缓存文本类请求体,供 XSS 校验及后续重复读取。
+ * multipart / 二进制上传不包装,避免 Stream closed。
  */
 public class RepeatableFilter implements Filter
 {
@@ -30,7 +22,7 @@ public class RepeatableFilter implements Filter
     {
         ServletRequest requestWrapper = null;
         if (request instanceof HttpServletRequest
-                && StringUtils.startsWithIgnoreCase(request.getContentType(), MediaType.APPLICATION_JSON_VALUE))
+                && shouldWrap((HttpServletRequest) request))
         {
             requestWrapper = new RepeatedlyRequestWrapper((HttpServletRequest) request, response);
         }
@@ -44,6 +36,12 @@ public class RepeatableFilter implements Filter
         }
     }
 
+    private boolean shouldWrap(HttpServletRequest request)
+    {
+        return HttpRequestBodyUtils.mayHaveBody(request)
+                && HttpRequestBodyUtils.isTextRequestBody(request);
+    }
+
     @Override
     public void destroy()
     {

+ 164 - 36
java/fs-common/src/main/java/com/fs/common/filter/XssHttpServletRequestWrapper.java

@@ -1,35 +1,58 @@
 package com.fs.common.filter;
 
-import java.io.ByteArrayInputStream;
-import java.io.IOException;
+import com.fs.common.exception.CustomException;
+import com.fs.common.utils.StringUtils;
+import com.fs.common.utils.html.XssValidateUtil;
+import org.apache.commons.io.IOUtils;
+import org.springframework.http.HttpHeaders;
+
 import javax.servlet.ReadListener;
 import javax.servlet.ServletInputStream;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletRequestWrapper;
-import org.apache.commons.io.IOUtils;
-import org.springframework.http.HttpHeaders;
-import org.springframework.http.MediaType;
-import com.fs.common.utils.StringUtils;
-import com.fs.common.utils.html.EscapeUtil;
+import java.io.BufferedReader;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
 
 /**
- * XSS过滤处理
- * 
-
+ * XSS 过滤:校验 URL 路径、Query 参数、Form 参数、Header、RequestBody。
  */
 public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper
 {
-    /**
-     * @param request
-     */
+    private byte[] body = new byte[0];
+
     public XssHttpServletRequestWrapper(HttpServletRequest request)
     {
         super(request);
+        validateRequestPath();
+        validateHeaders();
+        if (!HttpRequestBodyUtils.isBinaryOrUploadRequest(request))
+        {
+            validateParameters();
+        }
+        validateRequestBody();
+    }
+
+    @Override
+    public String getParameter(String name)
+    {
+        validateValue(name);
+        String value = super.getParameter(name);
+        if (value == null)
+        {
+            return null;
+        }
+        validateNoScript(value);
+        return value.trim();
     }
 
     @Override
     public String[] getParameterValues(String name)
     {
+        validateValue(name);
         String[] values = super.getParameterValues(name);
         if (values != null)
         {
@@ -37,40 +60,146 @@ public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper
             String[] escapseValues = new String[length];
             for (int i = 0; i < length; i++)
             {
-                // 防xss攻击和过滤前后空格
-                escapseValues[i] = EscapeUtil.clean(values[i]).trim();
+                validateNoScript(values[i]);
+                escapseValues[i] = values[i].trim();
             }
             return escapseValues;
         }
         return super.getParameterValues(name);
     }
 
+    @Override
+    public Map<String, String[]> getParameterMap()
+    {
+        Map<String, String[]> map = super.getParameterMap();
+        for (Map.Entry<String, String[]> entry : map.entrySet())
+        {
+            validateValue(entry.getKey());
+            String[] values = entry.getValue();
+            if (values == null)
+            {
+                continue;
+            }
+            for (String value : values)
+            {
+                validateValue(value);
+            }
+        }
+        return map;
+    }
+
+    @Override
+    public String getQueryString()
+    {
+        String queryString = super.getQueryString();
+        if (StringUtils.isNotEmpty(queryString))
+        {
+            validateValue(queryString);
+        }
+        return queryString;
+    }
+
+    @Override
+    public String getHeader(String name)
+    {
+        String value = super.getHeader(name);
+        if (StringUtils.isNotEmpty(value))
+        {
+            validateValue(value);
+        }
+        return value;
+    }
+
+    @Override
+    public BufferedReader getReader() throws IOException
+    {
+        if (HttpRequestBodyUtils.isBinaryOrUploadRequest((HttpServletRequest) getRequest()))
+        {
+            return super.getReader();
+        }
+        return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8));
+    }
+
     @Override
     public ServletInputStream getInputStream() throws IOException
     {
-        // 非json类型,直接返回
-        if (!isJsonRequest())
+        if (HttpRequestBodyUtils.isBinaryOrUploadRequest((HttpServletRequest) getRequest()))
         {
             return super.getInputStream();
         }
+        return buildBodyStream(body);
+    }
+
+    private void validateRequestPath()
+    {
+        validateValue(super.getRequestURI());
+        validateValue(super.getServletPath());
+        validateValue(super.getPathInfo());
+        validateValue(super.getContextPath());
+    }
 
-        // 为空,直接返回
-        String json = IOUtils.toString(super.getInputStream(), "utf-8");
-        if (StringUtils.isEmpty(json))
+    private void validateParameters()
+    {
+        Map<String, String[]> parameterMap = super.getParameterMap();
+        for (Map.Entry<String, String[]> entry : parameterMap.entrySet())
         {
-            return super.getInputStream();
+            validateValue(entry.getKey());
+            String[] values = entry.getValue();
+            if (values == null)
+            {
+                continue;
+            }
+            for (String value : values)
+            {
+                validateValue(value);
+            }
         }
+    }
 
-        // xss过滤
-        json = EscapeUtil.clean(json).trim();
-        byte[] jsonBytes = json.getBytes("utf-8");
-        final ByteArrayInputStream bis = new ByteArrayInputStream(jsonBytes);
+    /**
+     * 主动读取并校验文本类请求体,不依赖 Controller 是否声明 @RequestBody。
+     */
+    private void validateRequestBody()
+    {
+        if (!HttpRequestBodyUtils.isTextRequestBody((HttpServletRequest) getRequest()))
+        {
+            return;
+        }
+        try
+        {
+            String bodyStr = IOUtils.toString(super.getInputStream(), StandardCharsets.UTF_8);
+            if (StringUtils.isNotEmpty(bodyStr))
+            {
+                validateValue(bodyStr);
+            }
+            body = bodyStr.getBytes(StandardCharsets.UTF_8);
+        }
+        catch (IOException e)
+        {
+            throw new CustomException("读取请求体失败");
+        }
+    }
+
+    /**
+     * 校验常见可由客户端控制的 Header。
+     */
+    private void validateHeaders()
+    {
+        validateValue(super.getHeader(HttpHeaders.REFERER));
+        validateValue(super.getHeader(HttpHeaders.USER_AGENT));
+        validateValue(super.getHeader("X-Forwarded-For"));
+        validateValue(super.getHeader("X-Real-IP"));
+    }
+
+    private ServletInputStream buildBodyStream(byte[] bodyBytes)
+    {
+        final ByteArrayInputStream bis = new ByteArrayInputStream(bodyBytes);
         return new ServletInputStream()
         {
             @Override
             public boolean isFinished()
             {
-                return true;
+                return bis.available() == 0;
             }
 
             @Override
@@ -82,7 +211,7 @@ public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper
             @Override
             public int available() throws IOException
             {
-                return jsonBytes.length;
+                return bis.available();
             }
 
             @Override
@@ -98,14 +227,13 @@ public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper
         };
     }
 
-    /**
-     * 是否是Json请求
-     * 
-     * @param request
-     */
-    public boolean isJsonRequest()
+    private void validateValue(String value)
+    {
+        validateNoScript(value);
+    }
+
+    private void validateNoScript(String value)
     {
-        String header = super.getHeader(HttpHeaders.CONTENT_TYPE);
-        return StringUtils.startsWithIgnoreCase(header, MediaType.APPLICATION_JSON_VALUE);
+        XssValidateUtil.rejectIfContainsXss(value);
     }
-}
+}

+ 214 - 0
java/fs-common/src/main/java/com/fs/common/utils/html/HtmlXssUtil.java

@@ -0,0 +1,214 @@
+package com.fs.common.utils.html;
+
+import java.lang.reflect.Array;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.util.*;
+
+/**
+ * 响应数据 XSS 过滤工具
+ */
+public class HtmlXssUtil
+{
+    private HtmlXssUtil()
+    {
+    }
+
+    /**
+     * 富文本 XSS 过滤:保留 p/a/img 等安全标签,危险攻击内容替换为 ***
+     */
+    public static String filterXss(String str)
+    {
+        if (str == null || str.isEmpty())
+        {
+            return str;
+        }
+        return RichTextXssFilter.filter(str);
+    }
+
+    /**
+     * HTML 实体转义(保留文本,禁用 HTML 解析)
+     */
+    public static String escapeHtml(String str)
+    {
+        if (str == null || str.isEmpty())
+        {
+            return str;
+        }
+        StringBuilder sb = new StringBuilder(str.length() + 16);
+        for (char c : str.toCharArray())
+        {
+            switch (c)
+            {
+                case '&':
+                    sb.append("&amp;");
+                    break;
+                case '<':
+                    sb.append("&lt;");
+                    break;
+                case '>':
+                    sb.append("&gt;");
+                    break;
+                case '"':
+                    sb.append("&quot;");
+                    break;
+                case '\'':
+                    sb.append("&#39;");
+                    break;
+                default:
+                    sb.append(c);
+            }
+        }
+        return sb.toString();
+    }
+
+    /**
+     * 递归处理对象中所有字符串字段
+     */
+    public static void filterObject(Object obj)
+    {
+        if (obj == null)
+        {
+            return;
+        }
+        Set<Object> visited = Collections.newSetFromMap(new IdentityHashMap<>());
+        filterObject(obj, visited);
+    }
+
+    private static void filterObject(Object obj, Set<Object> visited)
+    {
+        if (obj == null || visited.contains(obj))
+        {
+            return;
+        }
+
+        if (obj instanceof String)
+        {
+            return;
+        }
+
+        if (obj instanceof Map)
+        {
+            visited.add(obj);
+            Map<Object, Object> map = (Map<Object, Object>) obj;
+            for (Map.Entry<Object, Object> entry : map.entrySet())
+            {
+                Object value = entry.getValue();
+                if (value instanceof String)
+                {
+                    try
+                    {
+                        entry.setValue(filterXss((String) value));
+                    }
+                    catch (UnsupportedOperationException e)
+                    {
+                        // 不可修改的Map(如Collections.unmodifiableMap),跳过XSS过滤
+                    }
+                }
+                else
+                {
+                    filterObject(value, visited);
+                }
+            }
+            return;
+        }
+
+        if (obj instanceof Collection)
+        {
+            visited.add(obj);
+            if (obj instanceof List)
+            {
+                List<Object> list = (List<Object>) obj;
+                for (int i = 0; i < list.size(); i++)
+                {
+                    Object item = list.get(i);
+                    if (item instanceof String)
+                    {
+                        list.set(i, filterXss((String) item));
+                    }
+                    else
+                    {
+                        filterObject(item, visited);
+                    }
+                }
+            }
+            else
+            {
+                for (Object item : (Collection<?>) obj)
+                {
+                    filterObject(item, visited);
+                }
+            }
+            return;
+        }
+
+        if (obj.getClass().isArray())
+        {
+            visited.add(obj);
+            int length = Array.getLength(obj);
+            for (int i = 0; i < length; i++)
+            {
+                Object item = Array.get(obj, i);
+                if (item instanceof String)
+                {
+                    Array.set(obj, i, filterXss((String) item));
+                }
+                else
+                {
+                    filterObject(item, visited);
+                }
+            }
+            return;
+        }
+
+        if (!isPojo(obj.getClass()))
+        {
+            return;
+        }
+
+        visited.add(obj);
+        Class<?> clazz = obj.getClass();
+        while (clazz != null && clazz != Object.class)
+        {
+            Field[] fields = clazz.getDeclaredFields();
+            for (Field field : fields)
+            {
+                if (Modifier.isStatic(field.getModifiers()))
+                {
+                    continue;
+                }
+                field.setAccessible(true);
+                try
+                {
+                    Object val = field.get(obj);
+                    if (val instanceof String)
+                    {
+                        field.set(obj, filterXss((String) val));
+                    }
+                    else
+                    {
+                        filterObject(val, visited);
+                    }
+                }
+                catch (Exception ignored)
+                {
+                    // 忽略反射异常
+                }
+            }
+            clazz = clazz.getSuperclass();
+        }
+    }
+
+    private static boolean isPojo(Class<?> cls)
+    {
+        if (cls.isPrimitive() || cls.isEnum() || cls.isArray())
+        {
+            return false;
+        }
+        String name = cls.getName();
+        return !name.startsWith("java.")
+                && !name.startsWith("javax.")
+                && !name.startsWith("sun.")
+                && !name.startsWith("com.sun.");
+    }
+}

+ 119 - 0
java/fs-common/src/main/java/com/fs/common/utils/html/RichTextXssFilter.java

@@ -0,0 +1,119 @@
+package com.fs.common.utils.html;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * 富文本 XSS 过滤:保留常见富文本标签,危险内容替换为 ***
+ */
+public final class RichTextXssFilter
+{
+    private RichTextXssFilter()
+    {
+    }
+
+    private static final String MASK = "***";
+
+    /** 允许的富文本标签 */
+    private static final Set<String> ALLOWED_TAGS = new HashSet<>(Arrays.asList(
+            "p", "a", "img", "br", "span", "div", "b", "strong", "i", "em", "u", "s", "strike",
+            "sub", "sup", "ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6",
+            "blockquote", "pre", "code", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "hr", "font"
+    ));
+
+    /** 高危标签(整段替换为 ***) */
+    private static final Set<String> DANGEROUS_TAGS = new HashSet<>(Arrays.asList(
+            "script", "iframe", "frame", "frameset", "object", "embed", "applet", "form", "input",
+            "button", "select", "textarea", "option", "meta", "link", "base", "style", "svg", "math",
+            "marquee", "blink", "body", "html", "head", "title", "xml", "import"
+    ));
+
+    private static final Pattern DANGEROUS_TAG_BLOCK = Pattern.compile(
+            "<\\s*(script|iframe|frame|frameset|object|embed|applet|form|style|svg|math|meta|link|base|marquee|blink|html|head|body|title|xml|import)\\b[^>]*>.*?</\\s*\\1\\s*>",
+            Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
+
+    private static final Pattern DANGEROUS_TAG_SINGLE = Pattern.compile(
+            "<\\s*(script|iframe|frame|object|embed|applet|input|button|meta|link|base|style|svg|math|marquee|blink|import)\\b[^>]*/?>",
+            Pattern.CASE_INSENSITIVE);
+
+    private static final Pattern EVENT_ATTR = Pattern.compile(
+            "\\s+(?i:on\\w+|formaction|xmlns|xlink:href)\\s*=\\s*(\"[^\"]*\"|'[^']*'|[^\\s>]+)");
+
+    private static final Pattern BAD_PROTOCOL = Pattern.compile(
+            "(?i)\\b(javascript|vbscript|data\\s*:)\\s*[^\"'\\s>]*");
+
+    private static final Pattern TAG_PAIR = Pattern.compile(
+            "<\\s*(\\w+)\\b[^>]*>(.*?)</\\s*\\1\\s*>",
+            Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
+
+    private static final Pattern TAG_ANY = Pattern.compile(
+            "<\\s*(/?)\\s*(\\w+)\\b([^>]*)>",
+            Pattern.CASE_INSENSITIVE);
+
+    private static final Pattern LOOSE_SCRIPT = Pattern.compile(
+            "(?i)<\\s*script\\b[^>]*>?", Pattern.DOTALL);
+
+    private static final Pattern LOOSE_IFRAME = Pattern.compile(
+            "(?i)<\\s*/?\\s*iframe\\b[^>]*>?", Pattern.DOTALL);
+
+    public static String filter(String content)
+    {
+        if (content == null || content.isEmpty())
+        {
+            return content;
+        }
+        String result = content;
+        result = DANGEROUS_TAG_BLOCK.matcher(result).replaceAll(MASK);
+        result = DANGEROUS_TAG_SINGLE.matcher(result).replaceAll(MASK);
+        result = LOOSE_SCRIPT.matcher(result).replaceAll(MASK);
+        result = LOOSE_IFRAME.matcher(result).replaceAll(MASK);
+        result = replaceDisallowedTagPairs(result);
+        result = replaceDisallowedTags(result);
+        result = EVENT_ATTR.matcher(result).replaceAll(" " + MASK);
+        result = BAD_PROTOCOL.matcher(result).replaceAll(MASK);
+        return result;
+    }
+
+    private static String replaceDisallowedTagPairs(String content)
+    {
+        Matcher matcher = TAG_PAIR.matcher(content);
+        StringBuffer sb = new StringBuffer();
+        while (matcher.find())
+        {
+            String tag = matcher.group(1).toLowerCase();
+            if (ALLOWED_TAGS.contains(tag) || DANGEROUS_TAGS.contains(tag))
+            {
+                matcher.appendReplacement(sb, Matcher.quoteReplacement(matcher.group(0)));
+            }
+            else
+            {
+                matcher.appendReplacement(sb, MASK);
+            }
+        }
+        matcher.appendTail(sb);
+        return sb.toString();
+    }
+
+    private static String replaceDisallowedTags(String content)
+    {
+        Matcher matcher = TAG_ANY.matcher(content);
+        StringBuffer sb = new StringBuffer();
+        while (matcher.find())
+        {
+            String tag = matcher.group(2).toLowerCase();
+            if (ALLOWED_TAGS.contains(tag))
+            {
+                matcher.appendReplacement(sb, Matcher.quoteReplacement(matcher.group(0)));
+            }
+            else
+            {
+                matcher.appendReplacement(sb, MASK);
+            }
+        }
+        matcher.appendTail(sb);
+        return sb.toString();
+    }
+}

+ 74 - 0
java/fs-common/src/main/java/com/fs/common/utils/html/XssValidateUtil.java

@@ -0,0 +1,74 @@
+package com.fs.common.utils.html;
+
+import com.fs.common.exception.CustomException;
+import com.fs.common.utils.StringUtils;
+
+import java.util.regex.Pattern;
+
+/**
+ * XSS 校验:允许富文本常用标签(p、img、a 等),拦截 script 及其他常见 XSS 攻击向量。
+ */
+public class XssValidateUtil
+{
+    private static final String BLOCKED_MESSAGE = "请求参数存在非法脚本内容,已被拦截";
+
+    /** 高危 HTML 标签(富文本常用 p、img、div、span、a、table 等不在此列) */
+    private static final Pattern BLOCKED_TAG_PATTERN = Pattern.compile(
+            "<\\s*/?(?:script|iframe|object|embed|applet|meta|link|base|form|frame|frameset|svg|style)\\b",
+            Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
+
+    /** 内联事件属性:onclick、onerror 等 */
+    private static final Pattern EVENT_HANDLER_PATTERN = Pattern.compile(
+            "\\bon[a-zA-Z0-9_]+\\s*=",
+            Pattern.CASE_INSENSITIVE);
+
+    /** javascript:/vbscript: 等危险协议 */
+    private static final Pattern DANGEROUS_PROTOCOL_PATTERN = Pattern.compile(
+            "(?:javascript|vbscript|data\\s*:\\s*text/html|data\\s*:\\s*application/(?:javascript|ecmascript|x-javascript))\\s*:",
+            Pattern.CASE_INSENSITIVE);
+
+    /** IE 旧版 CSS expression 攻击 */
+    private static final Pattern CSS_EXPRESSION_PATTERN = Pattern.compile(
+            "expression\\s*\\(",
+            Pattern.CASE_INSENSITIVE);
+
+    /** HTML 实体 / URL 编码绕过 script */
+    private static final Pattern ENCODED_SCRIPT_PATTERN = Pattern.compile(
+            "(?:&#(?:0*60|x3c)|%3c)\\s*script\\b",
+            Pattern.CASE_INSENSITIVE);
+
+    private XssValidateUtil()
+    {
+    }
+
+    /**
+     * 检测到 XSS 攻击特征则拒绝请求。
+     */
+    public static void rejectIfContainsXss(String value)
+    {
+        if (StringUtils.isEmpty(value))
+        {
+            return;
+        }
+        if (BLOCKED_TAG_PATTERN.matcher(value).find())
+        {
+            throw new CustomException(BLOCKED_MESSAGE);
+        }
+        if (EVENT_HANDLER_PATTERN.matcher(value).find())
+        {
+            throw new CustomException(BLOCKED_MESSAGE);
+        }
+        if (DANGEROUS_PROTOCOL_PATTERN.matcher(value).find())
+        {
+            throw new CustomException(BLOCKED_MESSAGE);
+        }
+        if (CSS_EXPRESSION_PATTERN.matcher(value).find())
+        {
+            throw new CustomException(BLOCKED_MESSAGE);
+        }
+        if (ENCODED_SCRIPT_PATTERN.matcher(value).find())
+        {
+            throw new CustomException(BLOCKED_MESSAGE);
+        }
+    }
+}

+ 85 - 5
java/fs-common/src/main/java/com/fs/common/utils/poi/ExcelUtil.java

@@ -117,6 +117,17 @@ public class ExcelUtil<T>
      */
     private Map<Integer, Double> statistics = new HashMap<Integer, Double>();
 
+    /**
+     * 自定义字典数据(导入模板下拉用,优先于 Redis 缓存)
+     */
+    private Map<String, List<SysDictData>> dictDataMap = new HashMap<>();
+
+    /** Excel 内联下拉选项字符上限 */
+    private static final int EXCEL_DROPDOWN_CHAR_LIMIT = 255;
+
+    /** Excel 内联下拉选项数量建议上限 */
+    private static final int EXCEL_DROPDOWN_COUNT_LIMIT = 15;
+
     /**
      * 数字格式
      */
@@ -132,6 +143,17 @@ public class ExcelUtil<T>
         this.clazz = clazz;
     }
 
+    /**
+     * 设置导入模板使用的字典数据
+     */
+    public void setDictData(String dictType, List<SysDictData> dictDatas)
+    {
+        if (StringUtils.isNotEmpty(dictType) && dictDatas != null)
+        {
+            this.dictDataMap.put(dictType, dictDatas);
+        }
+    }
+
     public void init(List<T> list, String sheetName, Type type)
     {
         if (list == null)
@@ -712,7 +734,11 @@ public class ExcelUtil<T>
         {
             try
             {
-                List<SysDictData> dictDatas = DictUtils.getDictCache(attr.dictType());
+                List<SysDictData> dictDatas = dictDataMap.get(attr.dictType());
+                if (dictDatas == null || dictDatas.isEmpty())
+                {
+                    dictDatas = DictUtils.getDictCache(attr.dictType());
+                }
                 if (dictDatas != null && !dictDatas.isEmpty())
                 {
                     // 提取字典标签列表
@@ -722,10 +748,14 @@ public class ExcelUtil<T>
                     // 这里默认设了2-101列只能选择不能输入.
                     setXSSFValidation(sheet, dictLabels, 1, 100, column, column);
                 }
+                else
+                {
+                    log.warn("导入模板字典数据为空,列[{}]无法生成下拉,dictType: {}", attr.name(), attr.dictType());
+                }
             }
             catch (Exception e)
             {
-                log.warn("设置字典下拉选项失败,dictType: {}, error: {}", attr.dictType(), e.getMessage());
+                log.error("设置字典下拉选项失败,dictType: {}", attr.dictType(), e);
             }
         }
     }
@@ -821,9 +851,51 @@ public class ExcelUtil<T>
      */
     public void setXSSFValidation(Sheet sheet, String[] textlist, int firstRow, int endRow, int firstCol, int endCol)
     {
+        if (textlist == null || textlist.length == 0)
+        {
+            return;
+        }
         DataValidationHelper helper = sheet.getDataValidationHelper();
-        // 加载下拉列表内容
-        DataValidationConstraint constraint = helper.createExplicitListConstraint(textlist);
+        DataValidationConstraint constraint;
+        String joined = String.join(",", textlist);
+        if (textlist.length > EXCEL_DROPDOWN_COUNT_LIMIT || joined.length() > EXCEL_DROPDOWN_CHAR_LIMIT)
+        {
+            // 客户来源等字典项较多时,内联列表会超限,改用隐藏 Sheet + 命名区域(RuoYi 通用做法)
+            String hideSheetName = "combo" + firstCol;
+            Sheet hideSheet = wb.getSheet(hideSheetName);
+            if (hideSheet == null)
+            {
+                hideSheet = wb.createSheet(hideSheetName);
+                wb.setSheetHidden(wb.getSheetIndex(hideSheet), true);
+            }
+            for (int i = 0; i < textlist.length; i++)
+            {
+                Row hideRow = hideSheet.getRow(i);
+                if (hideRow == null)
+                {
+                    hideRow = hideSheet.createRow(i);
+                }
+                Cell hideCell = hideRow.getCell(0);
+                if (hideCell == null)
+                {
+                    hideCell = hideRow.createCell(0);
+                }
+                hideCell.setCellValue(textlist[i]);
+            }
+            String rangeName = "comboRange" + firstCol;
+            Name range = wb.getName(rangeName);
+            if (range == null)
+            {
+                range = wb.createName();
+                range.setNameName(rangeName);
+            }
+            range.setRefersToFormula(hideSheetName + "!$A$1:$A$" + textlist.length);
+            constraint = helper.createFormulaListConstraint(rangeName);
+        }
+        else
+        {
+            constraint = helper.createExplicitListConstraint(textlist);
+        }
         // 设置数据有效性加载在哪个单元格上,四个参数分别是:起始行、终止行、起始列、终止列
         CellRangeAddressList regions = new CellRangeAddressList(firstRow, endRow, firstCol, endCol);
         // 数据有效性对象
@@ -1126,7 +1198,15 @@ public class ExcelUtil<T>
      */
     public void createWorkbook()
     {
-        this.wb = new SXSSFWorkbook(500);
+        // 导入模板含数据校验下拉,须用 XSSFWorkbook;SXSSF 流式写入会导致下拉失效
+        if (Type.IMPORT.equals(type))
+        {
+            this.wb = new XSSFWorkbook();
+        }
+        else
+        {
+            this.wb = new SXSSFWorkbook(500);
+        }
     }
 
     /**

+ 211 - 0
java/fs-common/src/main/java/com/fs/his/utils/PhoneUtil.java

@@ -0,0 +1,211 @@
+package com.fs.his.utils;
+
+import com.fs.common.utils.ParseUtils;
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.SecretKeySpec;
+import java.util.Base64;
+
+public class PhoneUtil {
+
+    public static String encryptPhone(String text) {
+        String encryptedText=null;
+        try {
+            SecretKeySpec secretKey = new SecretKeySpec("AESAabCdeREssREA".getBytes(), "AES");
+            Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
+            // Encryption
+            cipher.init(Cipher.ENCRYPT_MODE, secretKey);
+            byte[] encryptedBytes = cipher.doFinal(text.getBytes());
+            encryptedText = Base64.getEncoder().encodeToString(encryptedBytes);
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+        return encryptedText;
+    }
+
+    /**
+     */
+    public static String encryptPhoneIfPlain(String mobile) {
+        if (mobile == null || mobile.trim().isEmpty()) {
+            return mobile;
+        }
+        String text = mobile.trim();
+        if (isEncryptedMobile(text) || text.contains("*")) {
+            return text;
+        }
+        if (looksLikePlainPhone(text) || (text.length() == 11 && text.matches("\\d+"))) {
+            return encryptPhone(text);
+        }
+        return text;
+    }
+
+    /**
+     */
+    public static String maskMobile(String mobile) {
+        if (mobile == null || mobile.isEmpty()) {
+            return mobile;
+        }
+        String plain = decryptAutoPhone(mobile);
+        if (plain != null && !plain.isEmpty()) {
+            return plain.replaceAll("(\\d{3})\\d*(\\d{4})", "$1****$2");
+        }
+        return mobile.replaceAll("(\\d{3})\\d*(\\d{4})", "$1****$2");
+    }
+
+    /**
+     */
+    public static String encryptPlainMobileForQuery(String mobile) {
+        if (mobile == null || mobile.trim().isEmpty()) {
+            return mobile;
+        }
+        String text = mobile.trim();
+        if (isEncryptedMobile(text)) {
+            return text;
+        }
+        if (looksLikePlainPhone(text) || (text.length() == 11 && text.matches("\\d+"))) {
+            return encryptPhone(text);
+        }
+        return text;
+    }
+
+    private static boolean isEncryptedMobile(String text) {
+        return text != null && text.length() > 11 && decryptPhone(text) != null;
+    }
+
+    /**
+    */
+    public static String decryptPhone(String encryptedText) {
+        String text = null;
+        try {
+            SecretKeySpec secretKey = new SecretKeySpec("AESAabCdeREssREA".getBytes(), "AES");
+            Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
+            cipher.init(Cipher.DECRYPT_MODE, secretKey);
+            byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(encryptedText));
+            text = new String(decryptedBytes);
+        } catch (Exception ignored) {
+            text = encryptedText;
+        }
+        return text;
+    }
+
+    /**
+     * ���ܼ�*
+     */
+    public static String decryptPhoneMk(String encryptedText) {
+        String text = null;
+        try {
+            SecretKeySpec secretKey = new SecretKeySpec("AESAabCdeREssREA".getBytes(), "AES");
+            Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
+            cipher.init(Cipher.DECRYPT_MODE, secretKey);
+            byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(encryptedText));
+            text = new String(decryptedBytes);
+            text = text.replaceAll("(\\d{3})\\d*(\\d{4})", "$1****$2");
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+        return text;
+    }
+
+    /**
+     */
+    public static String decryptAutoPhone(String encryptedText) {
+        if (encryptedText == null || encryptedText.isEmpty()) {
+            return null;
+        }
+        String text = encryptedText.trim();
+        if (text.length() <= 11 && text.matches("\\d+")) {
+            return text;
+        }
+        if (looksLikePlainPhone(text)) {
+            return text;
+        }
+        String decrypted = decryptPhone(text);
+        if (decrypted != null && !decrypted.isEmpty()) {
+            return decrypted;
+        }
+        return looksLikePlainPhone(text) ? text : null;
+    }
+
+    /**
+     */
+    public static String normalizeOutboundPhone(String phone) {
+        String normalized = resolvePhoneForBlacklist(phone);
+        if (normalized == null || normalized.isEmpty()) {
+            return null;
+        }
+        if (!normalized.matches("\\d{11,15}")) {
+            return null;
+        }
+        return normalized;
+    }
+
+    public static boolean isValidOutboundPhone(String phone) {
+        return normalizeOutboundPhone(phone) != null;
+    }
+
+    /**
+     */
+    public static String resolvePhoneForBlacklist(String phone) {
+        String decrypted = decryptAutoPhone(phone);
+        if (decrypted == null || decrypted.isEmpty()) {
+            return null;
+        }
+        return decrypted.replace(" ", "")
+                .replace("-", "")
+                .replace("+86", "")
+                .replace("(", "")
+                .replace(")", "");
+    }
+
+    private static boolean looksLikePlainPhone(String text) {
+        if (text == null || text.isEmpty()) {
+            return false;
+        }
+        if (!text.matches("^[+\\d\\-\\s()]+$")) {
+            return false;
+        }
+        String digits = text.replaceAll("\\D", "");
+        return digits.length() >= 11 && digits.length() <= 15;
+    }
+
+    public static String decryptAutoPhoneMk(String encryptedText) {
+        String text=null;
+        if (encryptedText!=null&&encryptedText!="") {
+            if (encryptedText.length()>11){
+                try {
+                    SecretKeySpec secretKey = new SecretKeySpec("AESAabCdeREssREA".getBytes(), "AES");
+                    Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
+                    cipher.init(Cipher.DECRYPT_MODE, secretKey);
+                    byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(encryptedText));
+                    text = new String(decryptedBytes);
+                    text =text.replaceAll("(\\d{3})\\d*(\\d{4})", "$1****$2");
+                } catch (Exception e) {
+                    e.printStackTrace();
+                }
+            }else {
+                text =  ParseUtils.parsePhone(encryptedText);
+            }
+        }
+
+        return text;
+    }
+    /**
+     * @param text
+     * @return
+     */
+    private static String OLD_KEY = "2c8d1a7f4e9b3c6ae6d5c4b3a291f8c9";
+    public static String encryptPhoneOldKey(String text) {
+        String encryptedText=null;
+        try {
+            SecretKeySpec secretKey = new SecretKeySpec(OLD_KEY.getBytes(), "AES");
+            Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
+            // Encryption
+            cipher.init(Cipher.ENCRYPT_MODE, secretKey);
+            byte[] encryptedBytes = cipher.doFinal(text.getBytes());
+            encryptedText = Base64.getEncoder().encodeToString(encryptedBytes);
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+        return encryptedText;
+    }
+}

+ 2 - 0
java/fs-common/src/main/resources/META-INF/spring.factories

@@ -0,0 +1,2 @@
+org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
+  com.fs.common.config.XssResponseAutoConfiguration

+ 98 - 0
java/fs-company-app/src/main/java/com/fs/app/controller/FsUserCourseVideoController.java

@@ -16,8 +16,10 @@ import com.fs.company.service.ICompanyMiniappService;
 import com.fs.company.service.ICompanyService;
 import com.fs.company.service.ICompanyUserService;
 import com.fs.course.domain.FsUserCoursePeriod;
+import com.fs.course.domain.FsUserCourseVideo;
 import com.fs.course.dto.BatchSendCourseDTO;
 import com.fs.course.dto.BatchUrgeCourseDTO;
+import com.fs.course.mapper.FsUserCourseVideoMapper;
 import com.fs.course.param.FsCourseLinkCreateParam;
 import com.fs.course.param.FsCourseWatchLogListParam;
 import com.fs.course.param.FsWatchCourseTimeParam;
@@ -27,15 +29,22 @@ import com.fs.course.param.newfs.FsUserCourseListParam;
 import com.fs.course.param.newfs.UserCourseVideoPageParam;
 import com.fs.course.service.*;
 import com.fs.course.vo.FsCourseWatchLogListVO;
+import com.fs.course.vo.FsCourseWatchLogVO;
 import com.fs.course.vo.FsUserCourseParticipationRecordVO;
+import com.fs.course.vo.FsUserCourseVideoVO;
 import com.fs.course.vo.newfs.*;
+import com.fs.fastGpt.domain.FastGptChatSession;
 import com.fs.im.domain.FsImMsgSendLog;
 import com.fs.im.dto.OpenImResponseDTO;
 import com.fs.im.service.IFsImMsgSendDetailService;
 import com.fs.im.service.IFsImMsgSendLogService;
 import com.fs.im.service.OpenIMService;
 import com.fs.im.vo.FsImMsgSendLogVO;
+import com.fs.ipad.IpadSendUtils;
 import com.fs.live.service.ILiveService;
+import com.fs.qw.domain.QwUser;
+import com.fs.qw.mapper.QwUserMapper;
+import com.fs.wxwork.dto.WxLoginResp;
 import com.github.pagehelper.PageHelper;
 import com.github.pagehelper.PageInfo;
 import io.swagger.annotations.Api;
@@ -102,6 +111,95 @@ public class FsUserCourseVideoController extends AppBaseController {
         PageInfo<FsUserCourseVideoPageListVO> pageInfo = new PageInfo<>(list);
         return ResponseResult.ok(pageInfo);
     }
+
+    @GetMapping("/allList")
+    @ApiOperation("课程列表")
+    public ResponseResult<PageInfo<FsUserCourseVideoPageListVO>> allList(UserCourseVideoPageParam param) {
+        param.setCompanyId(getCompanyId());
+        param.setCompanyUserId(Long.parseLong(getUserId()));
+        List<FsUserCourseVideoPageListVO> list = fsUserCourseVideoService.pageListCourseVideo(param);
+        PageInfo<FsUserCourseVideoPageListVO> pageInfo = new PageInfo<>(list);
+        return ResponseResult.ok(pageInfo);
+    }
+
+    @Autowired
+    private FsUserCourseVideoMapper fsUserCourseVideoMapper;
+
+    @Autowired
+    private QwUserMapper qwUserMapper;
+
+    @Autowired
+    private IpadSendUtils ipadSendUtils;
+
+    @ApiOperation("发送课程视频(支持龙虾AI内部调用)")
+    @PostMapping("/sendFsUserCourseVideo")
+    public R sendFsUserCourseVideo(FsUserCourseVideoVO vo) {
+        // ── 参数校验 ──
+        if (vo.getVideoId() == null) {
+            return R.error("视频ID不能为空");
+        }
+        if (vo.getQwUserId() == null) {
+            return R.error("销售企微用户ID不能为空");
+        }
+        if (vo.getExternalContactId() == null) {
+            return R.error("外部联系人ID不能为空");
+        }
+
+        // ── 1. 查询视频信息 ──
+        FsUserCourseVideo fsUserCourseVideo = fsUserCourseVideoMapper
+                .selectFsUserCourseVideoByVideoStringId(String.valueOf(vo.getVideoId()));
+        if (fsUserCourseVideo == null) {
+            return R.error("视频不存在");
+        }
+        if (fsUserCourseVideo.getCourseId() == null) {
+            return R.error("课程ID不能为空");
+        }
+
+        // ── 2. 查询销售企微用户 ──
+        QwUser qwUser = qwUserMapper.selectQwUserById(vo.getQwUserId());
+        if (qwUser == null) {
+            return R.error("销售企微用户不存在");
+        }
+        if (qwUser.getUid() == null) {
+            return R.error("请先绑定iPad主机");
+        }
+        if (qwUser.getServerId() == null) {
+            return R.error("请先获取AI主机");
+        }
+
+        // ── 3. 检查iPad登录状态 ──
+        try {
+            WxLoginResp login = ipadSendUtils.isLogin(qwUser.getUid(), qwUser.getServerId());
+            if (login.getUser_info() == null || !login.getUser_info().isLogin()) {
+                return R.error("iPad未登录,无法发送课程视频");
+            }
+        } catch (Exception e) {
+            log.error("检查iPad登录状态失败: qwUserId={}, {}", vo.getQwUserId(), e.getMessage());
+            return R.error("iPad登录状态检查失败: " + e.getMessage());
+        }
+
+        // ── 4. 构建发送参数 ──
+        FastGptChatSession chatSession = new FastGptChatSession();
+        chatSession.setQwExtId(vo.getExternalContactId());
+
+        FsCourseWatchLogVO watchLogVO = new FsCourseWatchLogVO();
+        watchLogVO.setCourseId(fsUserCourseVideo.getCourseId());
+        watchLogVO.setVideoId(vo.getVideoId());
+        watchLogVO.setTitle(fsUserCourseVideo.getTitle());
+
+        // ── 5. 发送 ──
+        fsUserCourseVideoService.sendMiniAppCourseVideo(
+                qwUser.getCompanyUserId(),              // sendId: 销售ID
+                qwUser.getUid(),                        // uuid: iPad登录标识
+                qwUser,                                 // QwUser: 完整企微用户
+                chatSession,                            // FastGptChatSession
+                watchLogVO,                             // FsCourseWatchLogVO
+                String.valueOf(vo.getExternalContactId()) // qwExtId: 外部联系人ID
+        );
+        return R.ok();
+    }
+
+
     @Login
     @ApiOperation("获取看课使用小程序")
     @GetMapping("/getLoginMiniAppId/{appId}")

+ 5 - 7
java/fs-company-app/src/main/java/com/fs/core/config/FilterConfig.java

@@ -1,9 +1,10 @@
-package com.fs.core.config;
+package com.fs.framework.config;
 
 import com.fs.common.filter.RepeatableFilter;
 import com.fs.common.filter.XssFilter;
 import com.fs.common.utils.StringUtils;
 import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.boot.web.servlet.FilterRegistrationBean;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
@@ -18,11 +19,9 @@ import java.util.Map;
 
  */
 @Configuration
+@ConditionalOnProperty(value = "xss.enabled", havingValue = "true")
 public class FilterConfig
 {
-    @Value("${xss.enabled}")
-    private String enabled;
-
     @Value("${xss.excludes}")
     private String excludes;
 
@@ -38,10 +37,9 @@ public class FilterConfig
         registration.setFilter(new XssFilter());
         registration.addUrlPatterns(StringUtils.split(urlPatterns, ","));
         registration.setName("xssFilter");
-        registration.setOrder(FilterRegistrationBean.HIGHEST_PRECEDENCE);
+        registration.setOrder(2);
         Map<String, String> initParameters = new HashMap<String, String>();
         initParameters.put("excludes", excludes);
-        initParameters.put("enabled", enabled);
         registration.setInitParameters(initParameters);
         return registration;
     }
@@ -54,7 +52,7 @@ public class FilterConfig
         registration.setFilter(new RepeatableFilter());
         registration.addUrlPatterns("/*");
         registration.setName("repeatableFilter");
-        registration.setOrder(FilterRegistrationBean.LOWEST_PRECEDENCE);
+        registration.setOrder(1);
         return registration;
     }
 

+ 169 - 0
java/fs-doctor-app/src/main/java/com/fs/app/controller/DoctorWorkbenchController.java

@@ -0,0 +1,169 @@
+package com.fs.app.controller;
+
+import com.fs.common.core.domain.R;
+import com.fs.company.service.ai.AiModelGateway;
+import com.fs.his.mapper.DoctorWorkbenchMapper;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 医生工作台扩展Controller
+ * 患者健康档案、检测数据、随访、病历等
+ */
+@Api("医生工作台扩展")
+@RestController
+@RequestMapping("/app/doctor/workbench")
+public class DoctorWorkbenchController extends AppBaseController {
+
+    @Autowired
+    private DoctorWorkbenchMapper doctorWorkbenchMapper;
+
+    @Autowired
+    private AiModelGateway aiModelGateway;
+
+    @ApiOperation("患者健康档案汇总")
+    @GetMapping("/patientHealth/{patientId}")
+    public R patientHealthSummary(@PathVariable Long patientId) {
+        Map<String, Object> summary = doctorWorkbenchMapper.selectPatientHealthSummary(patientId);
+        if (summary == null) {
+            summary = new java.util.HashMap<>();
+        }
+        // 获取最近5条检测数据
+        List<Map<String, Object>> healthData = doctorWorkbenchMapper.selectPatientHealthData(patientId, 5);
+        // 获取最近5条处方记录
+        List<Map<String, Object>> prescribeHistory = doctorWorkbenchMapper.selectPatientPrescribeHistory(patientId, 5);
+        // 获取随访计划
+        List<Map<String, Object>> followPlans = doctorWorkbenchMapper.selectPatientFollowPlans(patientId);
+        return R.ok()
+                .put("summary", summary)
+                .put("healthData", healthData)
+                .put("prescribeHistory", prescribeHistory)
+                .put("followPlans", followPlans);
+    }
+
+    @ApiOperation("患者检测数据列表")
+    @GetMapping("/patientHealthData/{patientId}")
+    public R patientHealthData(@PathVariable Long patientId,
+                                @RequestParam(value = "limit", required = false, defaultValue = "20") Integer limit) {
+        List<Map<String, Object>> data = doctorWorkbenchMapper.selectPatientHealthData(patientId, limit);
+        return R.ok().put("data", data);
+    }
+
+    @ApiOperation("患者历史处方")
+    @GetMapping("/patientPrescribeHistory/{patientId}")
+    public R patientPrescribeHistory(@PathVariable Long patientId,
+                                     @RequestParam(value = "limit", required = false, defaultValue = "20") Integer limit) {
+        List<Map<String, Object>> data = doctorWorkbenchMapper.selectPatientPrescribeHistory(patientId, limit);
+        return R.ok().put("data", data);
+    }
+
+    @ApiOperation("患者用药反馈列表")
+    @GetMapping("/medFeedback/{patientId}")
+    public R medFeedbackList(@PathVariable Long patientId) {
+        List<Map<String, Object>> data = doctorWorkbenchMapper.selectMedFeedbackList(patientId);
+        return R.ok().put("list", data);
+    }
+
+    @ApiOperation("新增用药反馈")
+    @PostMapping("/medFeedback")
+    public R addMedFeedback(@RequestBody Map<String, Object> body) {
+        Long patientId = body.get("patientId") != null ? Long.valueOf(body.get("patientId").toString()) : null;
+        String drugName = (String) body.get("drugName");
+        Integer effectRating = body.get("effectRating") != null ? Integer.valueOf(body.get("effectRating").toString()) : 3;
+        Boolean hasAdverseReaction = body.get("hasAdverseReaction") != null ? Boolean.valueOf(body.get("hasAdverseReaction").toString()) : false;
+        String feedbackContent = (String) body.get("feedbackContent");
+        Long doctorId = Long.parseLong(getDoctorId());
+        doctorWorkbenchMapper.insertMedFeedback(patientId, drugName, effectRating, hasAdverseReaction, feedbackContent, doctorId);
+        return R.ok();
+    }
+
+    /**
+     * AI辅助诊断:基于患者健康数据+历史处方+当前症状,由LLM给出初步分析
+     */
+    @ApiOperation("AI辅助诊断分析")
+    @PostMapping("/aiDiagnosis")
+    public R aiDiagnosis(@RequestBody Map<String, Object> body) {
+        Long patientId = body.get("patientId") != null ? Long.valueOf(body.get("patientId").toString()) : null;
+        String symptom = (String) body.get("symptom");
+        if (patientId == null || symptom == null || symptom.trim().isEmpty()) {
+            return R.error("患者ID和症状描述不能为空");
+        }
+
+        // 1. 组装患者上下文
+        StringBuilder ctx = new StringBuilder();
+        ctx.append("## 患者症状描述\n").append(symptom).append("\n\n");
+
+        // 健康档案汇总
+        Map<String, Object> healthSummary = doctorWorkbenchMapper.selectPatientHealthSummary(patientId);
+        if (healthSummary != null && !healthSummary.isEmpty()) {
+            ctx.append("## 健康档案\n");
+            if (healthSummary.get("bloodSugar") != null) ctx.append("- 血糖: ").append(healthSummary.get("bloodSugar")).append("\n");
+            if (healthSummary.get("bloodPressureSystolic") != null) ctx.append("- 血压: ").append(healthSummary.get("bloodPressureSystolic"));
+            if (healthSummary.get("bloodPressureDiastolic") != null) ctx.append("/").append(healthSummary.get("bloodPressureDiastolic")).append("\n");
+            if (healthSummary.get("uricAcid") != null) ctx.append("- 尿酸: ").append(healthSummary.get("uricAcid")).append("\n");
+            if (healthSummary.get("cholesterol") != null) ctx.append("- 胆固醇: ").append(healthSummary.get("cholesterol")).append("\n");
+            if (healthSummary.get("allergyInfo") != null) ctx.append("- 过敏史: ").append(healthSummary.get("allergyInfo")).append("\n");
+            ctx.append("\n");
+        }
+
+        // 最近检测数据
+        List<Map<String, Object>> healthData = doctorWorkbenchMapper.selectPatientHealthData(patientId, 5);
+        if (healthData != null && !healthData.isEmpty()) {
+            ctx.append("## 最近检测数据\n");
+            for (Map<String, Object> d : healthData) {
+                ctx.append("- ").append(d.getOrDefault("dataName", "检测项"));
+                ctx.append(": ").append(d.getOrDefault("dataValue", "")).append("\n");
+            }
+            ctx.append("\n");
+        }
+
+        // 历史处方
+        List<Map<String, Object>> prescriptions = doctorWorkbenchMapper.selectPatientPrescribeHistory(patientId, 5);
+        if (prescriptions != null && !prescriptions.isEmpty()) {
+            ctx.append("## 历史处方\n");
+            for (Map<String, Object> p : prescriptions) {
+                ctx.append("- 诊断: ").append(p.getOrDefault("diagnose", "未知"));
+                ctx.append(" | 开方时间: ").append(p.getOrDefault("createTime", ""));
+                ctx.append(" | 状态: ").append(prescribeStatusText(p.get("status"))).append("\n");
+            }
+        }
+
+        // 2. 构建系统提示词
+        String systemPrompt = "你是一个专业的医疗AI助手。请根据患者的主诉症状、健康档案数据和历史处方信息," +
+                "给出以下分析:\n" +
+                "1. 可能的疾病方向(列出2-3种可能性,按概率排序)\n" +
+                "2. 建议的检查项目\n" +
+                "3. 用药建议参考(注意过敏史和现有用药)\n" +
+                "4. 生活注意事项\n\n" +
+                "重要提示:此分析仅供参考,不能替代医生诊断。请以简明的格式输出。";
+
+        // 3. 调用LLM
+        try {
+            String aiResult = aiModelGateway.chat(ctx.toString(), systemPrompt, null, "medical_diagnosis");
+            return R.ok().put("analysis", aiResult).put("context", ctx.toString());
+        } catch (Exception e) {
+            return R.error("AI分析暂时不可用:" + e.getMessage());
+        }
+    }
+
+    private String prescribeStatusText(Object status) {
+        if (status == null) return "未知";
+        int s = Integer.parseInt(status.toString());
+        switch (s) {
+            case 0: return "待提交";
+            case 1: return "待审核";
+            case 2: return "已审核";
+            case 3: return "调剂中";
+            case 4: return "已完成";
+            case 5: return "已发药";
+            case -1: return "已驳回";
+            default: return "未知";
+        }
+    }
+}

+ 2 - 2
java/fs-doctor-app/src/main/java/com/fs/framework/config/FilterConfig.java

@@ -37,7 +37,7 @@ public class FilterConfig
         registration.setFilter(new XssFilter());
         registration.addUrlPatterns(StringUtils.split(urlPatterns, ","));
         registration.setName("xssFilter");
-        registration.setOrder(FilterRegistrationBean.HIGHEST_PRECEDENCE);
+        registration.setOrder(2);
         Map<String, String> initParameters = new HashMap<String, String>();
         initParameters.put("excludes", excludes);
         registration.setInitParameters(initParameters);
@@ -52,7 +52,7 @@ public class FilterConfig
         registration.setFilter(new RepeatableFilter());
         registration.addUrlPatterns("/*");
         registration.setName("repeatableFilter");
-        registration.setOrder(FilterRegistrationBean.LOWEST_PRECEDENCE);
+        registration.setOrder(1);
         return registration;
     }
 

+ 9 - 0
java/fs-doctor-app/src/main/resources/application.yml

@@ -2,6 +2,15 @@ server:
   # 服务器的HTTP端口,默认为
   port: 8088
 
+# 防止XSS攻击
+xss:
+  # 过滤开关
+  enabled: true
+  # 排除链接(多个用逗号分隔)
+  excludes: /system/notice,/system/config/*
+  # 匹配链接
+  urlPatterns: /*
+
 # Spring配置
 spring:
   profiles:

+ 24 - 14
java/fs-framework/src/main/java/com/fs/framework/aspectj/LogAspect.java

@@ -13,8 +13,9 @@ import com.fs.common.enums.BusinessType;
 import com.fs.common.utils.bean.BeanUtils;
 import com.fs.framework.web.domain.server.Sys;
 import com.fs.hisStore.domain.SysOperLogScrm;
+import com.fs.statis.dto.ProductAuditDTO;
+import com.fs.statis.dto.StoreAuditDTO;
 import org.aspectj.lang.JoinPoint;
-import org.aspectj.lang.ProceedingJoinPoint;
 import org.aspectj.lang.Signature;
 import org.aspectj.lang.annotation.*;
 import org.aspectj.lang.reflect.MethodSignature;
@@ -129,19 +130,28 @@ public class LogAspect
             // 保存数据库
             Long tenantId = loginUser != null ? loginUser.getTenantId() : null;
             AsyncManager.me().execute(AsyncFactory.recordOper(tenantId, operLog));
-            if(controllerLog.isStoreLog() ){
-                SysOperLogScrm operLogScrm = new SysOperLogScrm();
-                //插入operId
-                String operId = MDC.get("operIds");
-                BeanUtils.copyProperties(operLog, operLogScrm);
-                Long[] operIds = new Long[operId.split(",").length];
-                String[] operIdStrs = operId.split(",");
-                for (int i = 0; i < operIdStrs.length; i++) {
-                    operIds[i] = Long.parseLong(operIdStrs[i]);
+            if (controllerLog.isStoreLog())
+            {
+                String operIdsStr = MDC.get("operIds");
+                if (StringUtils.isBlank(operIdsStr))
+                {
+                    log.warn("skip scrm oper log: isStoreLog=true but MDC operIds is empty, method={}.{}",
+                            joinPoint.getTarget().getClass().getSimpleName(), joinPoint.getSignature().getName());
+                }
+                else
+                {
+                    SysOperLogScrm operLogScrm = new SysOperLogScrm();
+                    BeanUtils.copyProperties(operLog, operLogScrm);
+                    String[] operIdStrs = operIdsStr.split(",");
+                    Long[] operIds = new Long[operIdStrs.length];
+                    for (int i = 0; i < operIdStrs.length; i++)
+                    {
+                        operIds[i] = Long.parseLong(operIdStrs[i].trim());
+                    }
+                    operLogScrm.setOperIds(operIds);
+                    resolveParam(joinPoint, operLogScrm);
+                    AsyncManager.me().execute(AsyncFactory.recordOperScrm(operLogScrm));
                 }
-                operLogScrm.setOperIds(operIds);
-                resolveParam((ProceedingJoinPoint)joinPoint,operLogScrm);
-                AsyncManager.me().execute(AsyncFactory.recordOperScrm(operLogScrm));
             }
         }
         catch (Exception exp)
@@ -272,7 +282,7 @@ public class LogAspect
 
     private final ExpressionParser parser = new SpelExpressionParser();
 
-    public void resolveParam(ProceedingJoinPoint joinPoint, SysOperLogScrm operLog) {
+    public void resolveParam(JoinPoint joinPoint, SysOperLogScrm operLog) {
         // 1. 获取注解实例
         Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
         Log annotation = method.getAnnotation(Log.class);

+ 9 - 8
java/fs-framework/src/main/java/com/fs/framework/config/FilterConfig.java

@@ -1,16 +1,17 @@
 package com.fs.framework.config;
 
-import java.util.HashMap;
-import java.util.Map;
-import javax.servlet.DispatcherType;
+import com.fs.common.filter.RepeatableFilter;
+import com.fs.common.filter.XssFilter;
+import com.fs.common.utils.StringUtils;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.boot.web.servlet.FilterRegistrationBean;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
-import com.fs.common.filter.RepeatableFilter;
-import com.fs.common.filter.XssFilter;
-import com.fs.common.utils.StringUtils;
+
+import javax.servlet.DispatcherType;
+import java.util.HashMap;
+import java.util.Map;
 
 /**
  * Filter配置
@@ -36,7 +37,7 @@ public class FilterConfig
         registration.setFilter(new XssFilter());
         registration.addUrlPatterns(StringUtils.split(urlPatterns, ","));
         registration.setName("xssFilter");
-        registration.setOrder(FilterRegistrationBean.HIGHEST_PRECEDENCE);
+        registration.setOrder(2);
         Map<String, String> initParameters = new HashMap<String, String>();
         initParameters.put("excludes", excludes);
         registration.setInitParameters(initParameters);
@@ -51,7 +52,7 @@ public class FilterConfig
         registration.setFilter(new RepeatableFilter());
         registration.addUrlPatterns("/*");
         registration.setName("repeatableFilter");
-        registration.setOrder(FilterRegistrationBean.LOWEST_PRECEDENCE);
+        registration.setOrder(1);
         return registration;
     }
 

+ 30 - 0
java/fs-framework/src/main/java/com/fs/framework/datasource/TenantDataSourceContextHelper.java

@@ -79,6 +79,36 @@ public class TenantDataSourceContextHelper {
         }
     }
 
+    /**
+     * 在主库上下文中执行(无返回值),执行前后保存并还原旧数据源
+     */
+    public void runInMasterV2(Runnable action) {
+        executeInMasterV2(() -> {
+            action.run();
+            return null;
+        });
+    }
+
+    /**
+     * 在主库上下文中执行(有返回值),执行前后保存并还原旧数据源。
+     * <p>
+     * 与 {@link #executeInMaster} 的区别:本方法在执行前保存当前数据源类型,
+     * 执行后还原,适用于租户库上下文中临时切主库查询的场景。
+     */
+    public <T> T executeInMasterV2(Supplier<T> action) {
+        String previous = DynamicDataSourceContextHolder.getDataSourceType();
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        try {
+            return action.get();
+        } finally {
+            if (previous != null) {
+                DynamicDataSourceContextHolder.setDataSourceType(previous);
+            } else {
+                DynamicDataSourceContextHolder.clearDataSourceType();
+            }
+        }
+    }
+
     /**
      * 校验并加载启用中的租户(查询走主库)
      */

+ 2 - 2
java/fs-ipad-task/src/main/java/com/fs/framework/config/FilterConfig.java

@@ -37,7 +37,7 @@ public class FilterConfig
         registration.setFilter(new XssFilter());
         registration.addUrlPatterns(StringUtils.split(urlPatterns, ","));
         registration.setName("xssFilter");
-        registration.setOrder(FilterRegistrationBean.HIGHEST_PRECEDENCE);
+        registration.setOrder(2);
         Map<String, String> initParameters = new HashMap<String, String>();
         initParameters.put("excludes", excludes);
         registration.setInitParameters(initParameters);
@@ -52,7 +52,7 @@ public class FilterConfig
         registration.setFilter(new RepeatableFilter());
         registration.addUrlPatterns("/*");
         registration.setName("repeatableFilter");
-        registration.setOrder(FilterRegistrationBean.LOWEST_PRECEDENCE);
+        registration.setOrder(1);
         return registration;
     }
 

+ 0 - 3
java/fs-ipad-task/src/main/resources/application.yml

@@ -6,9 +6,6 @@ spring:
   profiles:
     active: ylrz
     include: common,config-dev
-# 兼容旧启动参数(logback 等仍可读 group-no)
-group-no: 1
-tenant-id: 33
 # 多租户定时任务:格式 租户ID:分租号,多个用逗号分隔
 tenant-ids: 33:1
 saas.task.enabled: true

+ 2 - 2
java/fs-live-app/src/main/java/com/fs/framework/config/FilterConfig.java

@@ -37,7 +37,7 @@ public class FilterConfig
         registration.setFilter(new XssFilter());
         registration.addUrlPatterns(StringUtils.split(urlPatterns, ","));
         registration.setName("xssFilter");
-        registration.setOrder(FilterRegistrationBean.HIGHEST_PRECEDENCE);
+        registration.setOrder(2);
         Map<String, String> initParameters = new HashMap<String, String>();
         initParameters.put("excludes", excludes);
         registration.setInitParameters(initParameters);
@@ -52,7 +52,7 @@ public class FilterConfig
         registration.setFilter(new RepeatableFilter());
         registration.addUrlPatterns("/*");
         registration.setName("repeatableFilter");
-        registration.setOrder(FilterRegistrationBean.LOWEST_PRECEDENCE);
+        registration.setOrder(1);
         return registration;
     }
 

+ 90 - 0
java/fs-pharmacist-app/src/main/java/com/fs/app/controller/PharmacistWorkbenchController.java

@@ -0,0 +1,90 @@
+package com.fs.app.controller;
+
+import com.fs.common.core.domain.R;
+import com.fs.his.mapper.DoctorWorkbenchMapper;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 药师工作台扩展Controller
+ * 药品销售统计、慢病用药周期、药品效期管理
+ */
+@Api("药师工作台扩展")
+@RestController
+@RequestMapping("/app/pharmacist/workbench")
+public class PharmacistWorkbenchController extends AppBaseController {
+
+    @Autowired
+    private DoctorWorkbenchMapper doctorWorkbenchMapper;
+
+    @ApiOperation("药品销售统计")
+    @GetMapping("/drugStats")
+    public R drugStats(
+            @RequestParam(value = "storeId", required = false) Long storeId,
+            @RequestParam(value = "beginTime", required = false) String beginTime,
+            @RequestParam(value = "endTime", required = false) String endTime,
+            @RequestParam(value = "limit", required = false, defaultValue = "20") Integer limit) {
+        List<Map<String, Object>> ranking = doctorWorkbenchMapper.selectDrugSalesRanking(storeId, beginTime, endTime, limit);
+        // 计算汇总
+        double totalAmount = 0;
+        int totalQty = 0;
+        for (Map<String, Object> item : ranking) {
+            Object amountObj = item.get("totalAmount");
+            Object qtyObj = item.get("totalQty");
+            if (amountObj != null) totalAmount += Double.parseDouble(amountObj.toString());
+            if (qtyObj != null) totalQty += Integer.parseInt(qtyObj.toString());
+        }
+        return R.ok()
+                .put("ranking", ranking)
+                .put("totalAmount", totalAmount)
+                .put("totalQty", totalQty)
+                .put("drugCount", ranking.size());
+    }
+
+    @ApiOperation("药品效期与库存预警")
+    @GetMapping("/drugExpiry")
+    public R drugExpiry(
+            @RequestParam(value = "storeId", required = false) Long storeId,
+            @RequestParam(value = "warningDays", required = false, defaultValue = "30") Integer warningDays) {
+        List<Map<String, Object>> list = doctorWorkbenchMapper.selectDrugExpiryList(storeId, warningDays);
+        int outCount = 0, lowCount = 0, normalCount = 0;
+        for (Map<String, Object> item : list) {
+            String status = (String) item.get("stockStatus");
+            if ("out".equals(status)) outCount++;
+            else if ("low".equals(status)) lowCount++;
+            else normalCount++;
+        }
+        return R.ok()
+                .put("list", list)
+                .put("outCount", outCount)
+                .put("lowCount", lowCount)
+                .put("normalCount", normalCount)
+                .put("totalCount", list.size());
+    }
+
+    @ApiOperation("慢病用药周期看板")
+    @GetMapping("/drugCycle")
+    public R drugCycle(
+            @RequestParam(value = "storeId", required = false) Long storeId,
+            @RequestParam(value = "daysAhead", required = false, defaultValue = "7") Integer daysAhead) {
+        List<Map<String, Object>> list = doctorWorkbenchMapper.selectDrugCycleDashboard(storeId, daysAhead);
+        int expiredCount = 0, warningCount = 0, normalCount = 0;
+        for (Map<String, Object> item : list) {
+            String status = (String) item.get("cycleStatus");
+            if ("expired".equals(status)) expiredCount++;
+            else if ("warning".equals(status)) warningCount++;
+            else normalCount++;
+        }
+        return R.ok()
+                .put("list", list)
+                .put("expiredCount", expiredCount)
+                .put("warningCount", warningCount)
+                .put("normalCount", normalCount)
+                .put("totalCount", list.size());
+    }
+}

+ 18 - 0
java/fs-pharmacist-app/src/main/java/com/fs/app/controller/PrescribeAuditController.java

@@ -107,4 +107,22 @@ public class PrescribeAuditController extends AppBaseController {
         param.setDoctorId(Long.parseLong(getDoctorId()));
         return prescribeService.cancelPrescribe(param);
     }
+
+    /**
+     * 发药确认(status=5 已发药)
+     */
+    @Login
+    @ApiOperation("发药确认")
+    @PostMapping("/deliver")
+    public R deliver(@RequestBody java.util.Map<String, Object> body, HttpServletRequest request) {
+        Long prescribeId = body.get("prescribeId") != null ? Long.valueOf(body.get("prescribeId").toString()) : null;
+        if (prescribeId == null) {
+            return R.error("处方ID不能为空");
+        }
+        FsPrescribeDispenseParam param = new FsPrescribeDispenseParam();
+        param.setPrescribeId(prescribeId);
+        param.setDoctorId(Long.parseLong(getDoctorId()));
+        param.setStatus(5); // 已发药
+        return prescribeService.dispense(param);
+    }
 }

+ 18 - 0
java/fs-qw-api-msg/src/main/java/com/fs/app/controller/ProbeHealthController.java

@@ -0,0 +1,18 @@
+package com.fs.app.controller;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * K8s / 负载均衡探针健康检查(无需登录、无需租户头)。
+ */
+@RestController
+public class ProbeHealthController {
+
+    @RequestMapping(value = {"/", "/health"}, method = {RequestMethod.GET, RequestMethod.HEAD})
+    public ResponseEntity<Void> probe() {
+        return ResponseEntity.ok().build();
+    }
+}

+ 18 - 0
java/fs-qw-api/src/main/java/com/fs/app/controller/ProbeHealthController.java

@@ -0,0 +1,18 @@
+package com.fs.app.controller;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * K8s / 负载均衡探针健康检查(无需登录、无需租户头)。
+ */
+@RestController
+public class ProbeHealthController {
+
+    @RequestMapping(value = {"/", "/health"}, method = {RequestMethod.GET, RequestMethod.HEAD})
+    public ResponseEntity<Void> probe() {
+        return ResponseEntity.ok().build();
+    }
+}

+ 2 - 2
java/fs-qw-voice/src/main/java/com/fs/framework/config/FilterConfig.java

@@ -37,7 +37,7 @@ public class FilterConfig
         registration.setFilter(new XssFilter());
         registration.addUrlPatterns(StringUtils.split(urlPatterns, ","));
         registration.setName("xssFilter");
-        registration.setOrder(FilterRegistrationBean.HIGHEST_PRECEDENCE);
+        registration.setOrder(2);
         Map<String, String> initParameters = new HashMap<String, String>();
         initParameters.put("excludes", excludes);
         registration.setInitParameters(initParameters);
@@ -52,7 +52,7 @@ public class FilterConfig
         registration.setFilter(new RepeatableFilter());
         registration.addUrlPatterns("/*");
         registration.setName("repeatableFilter");
-        registration.setOrder(FilterRegistrationBean.LOWEST_PRECEDENCE);
+        registration.setOrder(1);
         return registration;
     }
 

+ 9 - 0
java/fs-qw-voice/src/main/resources/application.yml

@@ -3,6 +3,15 @@ server:
   # 服务器的HTTP端口,默认为8080
   port: 8009
 
+# 防止XSS攻击
+xss:
+  # 过滤开关
+  enabled: true
+  # 排除链接(多个用逗号分隔)
+  excludes: /system/notice,/system/config/*
+  # 匹配链接
+  urlPatterns: /*
+
 # Spring配置
 spring:
   profiles:

+ 2 - 2
java/fs-qwhook-msg/src/main/java/com/fs/framework/config/FilterConfig.java

@@ -37,7 +37,7 @@ public class FilterConfig
         registration.setFilter(new XssFilter());
         registration.addUrlPatterns(StringUtils.split(urlPatterns, ","));
         registration.setName("xssFilter");
-        registration.setOrder(FilterRegistrationBean.HIGHEST_PRECEDENCE);
+        registration.setOrder(2);
         Map<String, String> initParameters = new HashMap<String, String>();
         initParameters.put("excludes", excludes);
         registration.setInitParameters(initParameters);
@@ -52,7 +52,7 @@ public class FilterConfig
         registration.setFilter(new RepeatableFilter());
         registration.addUrlPatterns("/*");
         registration.setName("repeatableFilter");
-        registration.setOrder(FilterRegistrationBean.LOWEST_PRECEDENCE);
+        registration.setOrder(1);
         return registration;
     }
 

+ 2 - 2
java/fs-qwhook-sop/src/main/java/com/fs/framework/config/FilterConfig.java

@@ -37,7 +37,7 @@ public class FilterConfig
         registration.setFilter(new XssFilter());
         registration.addUrlPatterns(StringUtils.split(urlPatterns, ","));
         registration.setName("xssFilter");
-        registration.setOrder(FilterRegistrationBean.HIGHEST_PRECEDENCE);
+        registration.setOrder(2);
         Map<String, String> initParameters = new HashMap<String, String>();
         initParameters.put("excludes", excludes);
         registration.setInitParameters(initParameters);
@@ -52,7 +52,7 @@ public class FilterConfig
         registration.setFilter(new RepeatableFilter());
         registration.addUrlPatterns("/*");
         registration.setName("repeatableFilter");
-        registration.setOrder(FilterRegistrationBean.LOWEST_PRECEDENCE);
+        registration.setOrder(1);
         return registration;
     }
 

+ 2 - 2
java/fs-qwhook/src/main/java/com/fs/framework/config/FilterConfig.java

@@ -37,7 +37,7 @@ public class FilterConfig
         registration.setFilter(new XssFilter());
         registration.addUrlPatterns(StringUtils.split(urlPatterns, ","));
         registration.setName("xssFilter");
-        registration.setOrder(FilterRegistrationBean.HIGHEST_PRECEDENCE);
+        registration.setOrder(2);
         Map<String, String> initParameters = new HashMap<String, String>();
         initParameters.put("excludes", excludes);
         registration.setInitParameters(initParameters);
@@ -52,7 +52,7 @@ public class FilterConfig
         registration.setFilter(new RepeatableFilter());
         registration.addUrlPatterns("/*");
         registration.setName("repeatableFilter");
-        registration.setOrder(FilterRegistrationBean.LOWEST_PRECEDENCE);
+        registration.setOrder(1);
         return registration;
     }
 

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