lk hace 1 día
padre
commit
3091ec3a8e
Se han modificado 23 ficheros con 1318 adiciones y 3 borrados
  1. 9 0
      adminui/src/api/admin/commGatewayLog.js
  2. 165 0
      adminui/src/views/admin/courseResource/index.vue
  3. 43 1
      adminui/src/views/admin/dashboard/index.vue
  4. 130 0
      adminui/src/views/admin/deviceStats/index.vue
  5. 375 0
      adminui/src/views/admin/inventoryTransfer/index.vue
  6. 206 0
      adminui/src/views/admin/productLibrary/index.vue
  7. 1 1
      java/fs-admin/src/main/java/com/fs/admin/config/AdminWebMvcConfig.java
  8. 11 0
      java/fs-admin/src/main/java/com/fs/admin/controller/AdminCommGatewayLogController.java
  9. 33 0
      java/fs-admin/src/main/java/com/fs/admin/controller/device/DeviceStatsAdminController.java
  10. 68 0
      java/fs-admin/src/main/java/com/fs/admin/controller/platform/AdminCourseResourceController.java
  11. 53 0
      java/fs-admin/src/main/java/com/fs/admin/controller/platform/AdminProductLibraryController.java
  12. 107 0
      java/fs-admin/src/main/java/com/fs/admin/controller/supplychain/InventoryTransferAdminController.java
  13. 3 0
      java/fs-service/src/main/java/com/fs/comm/mapper/CommGatewayApiLogMapper.java
  14. 7 0
      java/fs-service/src/main/java/com/fs/comm/service/CommGatewayApiLogServiceImpl.java
  15. 3 0
      java/fs-service/src/main/java/com/fs/comm/service/ICommGatewayApiLogService.java
  16. 24 0
      java/fs-service/src/main/java/com/fs/comm/vo/CommGatewayTenantStatsVO.java
  17. 8 0
      java/fs-service/src/main/java/com/fs/course/service/IFsVideoResourceService.java
  18. 20 0
      java/fs-service/src/main/java/com/fs/course/service/impl/FsVideoResourceServiceImpl.java
  19. 8 0
      java/fs-service/src/main/java/com/fs/watch/mapper/WatchDeviceInfoMapper.java
  20. 7 0
      java/fs-service/src/main/java/com/fs/watch/service/WatchDeviceInfoService.java
  21. 5 0
      java/fs-service/src/main/java/com/fs/watch/service/impl/WatchDeviceInfoServiceImpl.java
  22. 13 0
      java/fs-service/src/main/resources/mapper/comm/CommGatewayApiLogMapper.xml
  23. 19 1
      java/fs-service/src/main/resources/mapper/watch/WatchDeviceInfoMapper.xml

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

@@ -29,3 +29,12 @@ export function traceCommGatewayLogCaller(logId) {
     method: 'get'
   })
 }
+
+/** 通讯网关 API 调用日志:按租户统计数量与金额(apiType=call/sms) */
+export function getCommGatewayLogStats(query) {
+  return request({
+    url: '/admin/comm-gateway-log/stats',
+    method: 'get',
+    params: query
+  })
+}

+ 165 - 0
adminui/src/views/admin/courseResource/index.vue

@@ -0,0 +1,165 @@
+<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-item label="资源名称" prop="resourceName">
+          <el-input v-model="queryParams.resourceName" placeholder="请输入资源名称" clearable @keyup.enter.native="handleQuery" />
+        </el-form-item>
+        <el-form-item label="文件名称" prop="fileName">
+          <el-input v-model="queryParams.fileName" placeholder="请输入文件名称" clearable @keyup.enter.native="handleQuery" />
+        </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>
+
+    <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)" v-hasPermi="['resource:courseResource:add']">新增资源</el-button>
+      </el-col>
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
+    </el-row>
+
+    <el-table border v-loading="loading" :data="list" size="small" style="width:100%">
+      <el-table-column label="ID" prop="id" min-width="60" align="center" />
+      <el-table-column label="资源名称" prop="resourceName" min-width="160" show-overflow-tooltip />
+      <el-table-column label="分类ID" prop="typeId" min-width="80" align="center" />
+      <el-table-column label="子分类ID" prop="typeSubId" min-width="90" align="center" />
+      <el-table-column label="文件名称" prop="fileName" min-width="160" show-overflow-tooltip />
+      <el-table-column label="时长(秒)" prop="duration" min-width="90" align="center" />
+      <el-table-column label="排序" prop="sort" min-width="70" align="center" />
+      <el-table-column label="创建时间" prop="createTime" min-width="150" align="center" />
+      <el-table-column label="操作" align="center" width="150" fixed="right" class-name="small-padding fixed-width">
+        <template slot-scope="scope">
+          <el-button size="mini" type="text" icon="el-icon-edit" @click="openDialog(scope.row)" v-hasPermi="['resource:courseResource:edit']">编辑</el-button>
+          <el-button size="mini" type="text" style="color:#f5222d" icon="el-icon-delete" @click="handleDelete(scope.row)" v-hasPermi="['resource:courseResource:remove']">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
+
+    <el-dialog :title="formTitle" :visible.sync="dialogVisible" width="560px" append-to-body @closed="reset">
+      <el-form ref="form" :model="form" :rules="rules" label-width="100px">
+        <el-form-item label="资源名称" prop="resourceName">
+          <el-input v-model="form.resourceName" placeholder="请输入资源名称" />
+        </el-form-item>
+        <el-form-item label="分类ID" prop="typeId">
+          <el-input-number v-model="form.typeId" :min="0" style="width:100%" placeholder="请输入分类ID" />
+        </el-form-item>
+        <el-form-item label="子分类ID" prop="typeSubId">
+          <el-input-number v-model="form.typeSubId" :min="0" style="width:100%" placeholder="请输入子分类ID" />
+        </el-form-item>
+        <el-form-item label="文件名称" prop="fileName">
+          <el-input v-model="form.fileName" placeholder="请输入文件名称" />
+        </el-form-item>
+        <el-form-item label="缩略图" prop="thumbnail">
+          <el-input v-model="form.thumbnail" placeholder="请输入缩略图URL" />
+        </el-form-item>
+        <el-form-item label="视频URL" prop="videoUrl">
+          <el-input v-model="form.videoUrl" placeholder="请输入视频URL" />
+        </el-form-item>
+        <el-form-item label="时长(秒)" prop="duration">
+          <el-input-number v-model="form.duration" :min="0" style="width:100%" placeholder="请输入时长" />
+        </el-form-item>
+        <el-form-item label="排序" prop="sort">
+          <el-input-number v-model="form.sort" :min="0" style="width:100%" placeholder="请输入排序" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="dialogVisible = false">取 消</el-button>
+        <el-button type="primary" :loading="submitting" @click="submitForm">确 定</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { listCourseResource, getCourseResource, addCourseResource, updateCourseResource, delCourseResource } from '@/api/admin/courseResource'
+
+export default {
+  name: 'AdminCourseResource',
+  data() {
+    return {
+      showSearch: true,
+      loading: false,
+      list: [],
+      total: 0,
+      queryParams: { pageNum: 1, pageSize: 10, resourceName: null, fileName: null },
+      dialogVisible: false,
+      formTitle: '',
+      submitting: false,
+      form: { id: null, resourceName: '', typeId: null, typeSubId: null, fileName: '', thumbnail: '', videoUrl: '', duration: 0, sort: 0 },
+      rules: {
+        resourceName: [{ required: true, message: '请输入资源名称', trigger: 'blur' }]
+      }
+    }
+  },
+  created() {
+    this.getList()
+  },
+  methods: {
+    getList() {
+      this.loading = true
+      listCourseResource(this.queryParams).then(r => {
+        this.list = r.rows || []
+        this.total = r.total || 0
+        this.loading = false
+      }).catch(() => { this.loading = false })
+    },
+    handleQuery() {
+      this.queryParams.pageNum = 1
+      this.getList()
+    },
+    resetQuery() {
+      this.resetForm('queryForm')
+      this.handleQuery()
+    },
+    openDialog(row) {
+      this.reset()
+      if (row) {
+        this.formTitle = '编辑课程资源'
+        getCourseResource(row.id).then(r => {
+          this.form = { ...this.form, ...r.data }
+          this.dialogVisible = true
+        })
+      } else {
+        this.formTitle = '新增课程资源'
+        this.dialogVisible = true
+      }
+    },
+    submitForm() {
+      this.$refs.form.validate(valid => {
+        if (!valid) return
+        this.submitting = true
+        const req = this.form.id ? updateCourseResource(this.form) : addCourseResource(this.form)
+        req.then(() => {
+          this.$message.success(this.form.id ? '修改成功' : '新增成功')
+          this.dialogVisible = false
+          this.getList()
+        }).finally(() => { this.submitting = false })
+      })
+    },
+    handleDelete(row) {
+      this.$confirm(`确认删除课程资源 "${row.resourceName}"?`, '提示', { type: 'warning' }).then(() => {
+        delCourseResource(row.id).then(() => {
+          this.$message.success('删除成功')
+          this.getList()
+        })
+      })
+    },
+    reset() {
+      this.form = { id: null, resourceName: '', typeId: null, typeSubId: null, fileName: '', thumbnail: '', videoUrl: '', duration: 0, sort: 0 }
+      if (this.$refs.form) this.$refs.form.resetFields()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.mb8 { margin-bottom: 8px; }
+.mb16 { margin-bottom: 16px; }
+.filter-card { padding-bottom: 0; }
+</style>

+ 43 - 1
adminui/src/views/admin/dashboard/index.vue

@@ -45,11 +45,33 @@
         </el-col>
       </el-row>
     </el-card>
+    <!-- 外呼日志统计(按租户) -->
+    <el-card shadow="hover" class="quick-card" style="margin-top: 16px; width: 850px;">
+      <div slot="header" class="card-header">
+        <div class="card-header-left">
+          <div class="card-header-icon" style="background: linear-gradient(135deg, #f59e0b, #fbbf24);">
+            <i class="el-icon-phone-outline" style="color:#fff;font-size:16px"></i>
+          </div>
+          <span class="card-header-title">外呼日志统计(按租户)</span>
+        </div>
+      </div>
+      <el-table v-loading="callStatsLoading" :data="callStats" border size="small">
+        <el-table-column label="租户名称" prop="tenantName" min-width="80" show-overflow-tooltip />
+        <el-table-column label="租户编码" prop="tenantCode" min-width="80" show-overflow-tooltip />
+        <el-table-column label="调用次数" prop="callCount" width="140" align="center" />
+        <el-table-column label="计费金额" prop="totalAmount" width="180" align="right">
+          <template slot-scope="scope">
+            <span>{{ formatMoney(scope.row.totalAmount) }}</span>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
   </div>
 </template>
 
 <script>
 import { getDashboardData } from '@/api/admin/dashboard'
+import { getCommGatewayLogStats } from '@/api/admin/commGatewayLog'
 
 export default {
   name: 'AdminDashboard',
@@ -70,13 +92,16 @@ export default {
         { 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' }
-      ]
+      ],
+      callStats: [],
+      callStatsLoading: false
     }
   },
   mounted() {
     this.updateTime()
     this.timeTimer = setInterval(this.updateTime, 1000)
     this.fetchDashboardData()
+    this.fetchCallStats()
   },
   beforeDestroy() {
     clearInterval(this.timeTimer)
@@ -104,6 +129,23 @@ export default {
         console.error('加载看板数据失败', e)
       }
     },
+    async fetchCallStats() {
+      this.callStatsLoading = true
+      try {
+        const res = await getCommGatewayLogStats({ apiType: 'call' })
+        this.callStats = res.data || []
+      } catch (e) {
+        console.error('加载外呼日志统计失败', e)
+      } finally {
+        this.callStatsLoading = false
+      }
+    },
+    formatMoney(value) {
+      if (value == null || value === '') {
+        return '¥0.00'
+      }
+      return '¥' + Number(value).toFixed(2)
+    },
     updateTime() {
       const now = new Date()
       this.currentTime = now.toLocaleString('zh-CN', {

+ 130 - 0
adminui/src/views/admin/deviceStats/index.vue

@@ -0,0 +1,130 @@
+<template>
+  <div>
+    <div class="page-header">
+      <div>
+        <h1 class="page-title">设备统计</h1>
+        <p class="page-desc">实时监控腕表设备总量、在线状态、生命周期与健康情况</p>
+      </div>
+      <div class="page-time">{{ currentTime }}</div>
+    </div>
+
+    <el-row :gutter="16" class="stat-row">
+      <el-col :xs="24" :sm="12" :md="8" :lg="4" v-for="(card, idx) in statCards" :key="idx">
+        <el-card shadow="hover" class="stat-card" :style="{ '--card-color': card.color }">
+          <div class="stat-card-inner">
+            <div class="stat-icon-wrap" :style="{ background: card.gradient }">
+              <i :class="card.icon" style="font-size:24px;color:#fff"></i>
+            </div>
+            <div class="stat-info">
+              <div class="stat-label">{{ card.label }}</div>
+              <div class="stat-value" :style="{ color: card.color }">{{ card.value }}</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <el-row :gutter="16">
+      <el-col :span="12">
+        <el-card shadow="hover" class="detail-card">
+          <div slot="header" class="card-header">
+            <span>设备状态分布</span>
+            <el-button size="mini" type="text" icon="el-icon-refresh" @click="fetchStats">刷新</el-button>
+          </div>
+          <div class="detail-item"><span class="detail-label">在线</span><span class="detail-value success">{{ stats.onlineCount || 0 }}</span></div>
+          <div class="detail-item"><span class="detail-label">离线</span><span class="detail-value info">{{ stats.offlineCount || 0 }}</span></div>
+          <div class="detail-item"><span class="detail-label">未激活</span><span class="detail-value warning">{{ stats.unactiveCount || 0 }}</span></div>
+          <div class="detail-item"><span class="detail-label">已禁用</span><span class="detail-value danger">{{ stats.disableCount || 0 }}</span></div>
+        </el-card>
+      </el-col>
+      <el-col :span="12">
+        <el-card shadow="hover" class="detail-card">
+          <div slot="header" class="card-header">
+            <span>生命周期与健康</span>
+          </div>
+          <div class="detail-item"><span class="detail-label">空置</span><span class="detail-value">{{ stats.lifeIdleCount || 0 }}</span></div>
+          <div class="detail-item"><span class="detail-label">已送出</span><span class="detail-value">{{ stats.lifeSentCount || 0 }}</span></div>
+          <div class="detail-item"><span class="detail-label">已激活</span><span class="detail-value">{{ stats.lifeActiveCount || 0 }}</span></div>
+          <div class="detail-item"><span class="detail-label">正常 / 异常</span><span class="detail-value">{{ stats.normalCount || 0 }} / {{ stats.abnormalCount || 0 }}</span></div>
+        </el-card>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<script>
+import { getDeviceStats } from '@/api/admin/deviceStats'
+
+export default {
+  name: 'AdminDeviceStats',
+  data() {
+    return {
+      currentTime: '',
+      timeTimer: null,
+      stats: {},
+      statCards: [
+        { label: '设备总量', key: 'totalCount', icon: 'el-icon-cpu', color: '#3b82f6', gradient: 'linear-gradient(135deg, #3b82f6, #60a5fa)' },
+        { label: '在线设备', key: 'onlineCount', icon: 'el-icon-connection', color: '#10b981', gradient: 'linear-gradient(135deg, #10b981, #34d399)' },
+        { label: '离线设备', key: 'offlineCount', icon: 'el-icon-turn-off', color: '#8b5cf6', gradient: 'linear-gradient(135deg, #8b5cf6, #a78bfa)' },
+        { label: '今日新增', key: 'todayNewCount', icon: 'el-icon-circle-plus', color: '#f59e0b', gradient: 'linear-gradient(135deg, #f59e0b, #fbbf24)' },
+        { label: '本月新增', key: 'monthNewCount', icon: 'el-icon-date', color: '#ef4444', gradient: 'linear-gradient(135deg, #ef4444, #f87171)' },
+        { label: '异常设备', key: 'abnormalCount', icon: 'el-icon-warning', color: '#f97316', gradient: 'linear-gradient(135deg, #f97316, #fb923c)' }
+      ]
+    }
+  },
+  mounted() {
+    this.updateTime()
+    this.timeTimer = setInterval(this.updateTime, 1000)
+    this.fetchStats()
+  },
+  beforeDestroy() {
+    clearInterval(this.timeTimer)
+  },
+  methods: {
+    async fetchStats() {
+      try {
+        const res = await getDeviceStats()
+        this.stats = res.data || {}
+        this.statCards.forEach(card => {
+          card.value = this.stats[card.key] != null ? this.stats[card.key] : '-'
+        })
+      } catch (e) {
+        console.error('加载设备统计失败', e)
+      }
+    },
+    updateTime() {
+      const now = new Date()
+      this.currentTime = now.toLocaleString('zh-CN', {
+        year: 'numeric', month: '2-digit', day: '2-digit',
+        hour: '2-digit', minute: '2-digit', second: '2-digit',
+        weekday: 'long'
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; }
+.page-title { font-size: 24px; font-weight: 700; color: #1e293b; margin: 0 0 4px 0; }
+.page-desc { font-size: 14px; color: #94a3b8; margin: 0; }
+.page-time { font-size: 13px; color: #94a3b8; background: #fff; padding: 8px 16px; border-radius: 10px; border: 1px solid #e2e8f0; }
+.stat-row { margin-bottom: 20px; }
+.stat-card { margin-bottom: 16px; border-radius: 16px; border: none; transition: all 0.3s ease; }
+.stat-card:hover { transform: translateY(-4px); box-shadow: 0 12px 40px rgba(0, 0, 0, 0.08); }
+.stat-card-inner { display: flex; align-items: center; gap: 16px; }
+.stat-icon-wrap { width: 52px; height: 52px; border-radius: 14px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1); }
+.stat-info { flex: 1; min-width: 0; }
+.stat-label { font-size: 13px; color: #94a3b8; margin-bottom: 6px; }
+.stat-value { font-size: 22px; font-weight: 700; line-height: 1.2; }
+.detail-card { border-radius: 16px; border: none; }
+.card-header { display: flex; justify-content: space-between; align-items: center; font-size: 15px; font-weight: 600; color: #1e293b; }
+.detail-item { display: flex; justify-content: space-between; align-items: center; padding: 12px 4px; border-bottom: 1px solid #f1f5f9; }
+.detail-item:last-child { border-bottom: none; }
+.detail-label { font-size: 14px; color: #64748b; }
+.detail-value { font-size: 15px; font-weight: 600; color: #1e293b; }
+.detail-value.success { color: #10b981; }
+.detail-value.info { color: #3b82f6; }
+.detail-value.warning { color: #f59e0b; }
+.detail-value.danger { color: #ef4444; }
+</style>

+ 375 - 0
adminui/src/views/admin/inventoryTransfer/index.vue

@@ -0,0 +1,375 @@
+<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-item label="调拨单号" prop="transferNo">
+          <el-input v-model="queryParams.transferNo" placeholder="请输入调拨单号" clearable @keyup.enter.native="handleQuery" />
+        </el-form-item>
+        <el-form-item label="状态" prop="status">
+          <el-select v-model="queryParams.status" placeholder="全部" clearable style="width:140px">
+            <el-option v-for="(v, k) in statusMap" :key="k" :label="v" :value="k" />
+          </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>
+
+    <el-row :gutter="10" class="mb8 list-toolbar">
+      <el-col :span="1.5">
+        <el-button type="primary" icon="el-icon-plus" size="mini" @click="openEdit(null)" v-hasPermi="['supplychain:inventoryTransfer:add']">新增调拨单</el-button>
+      </el-col>
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
+    </el-row>
+
+    <el-table border v-loading="loading" :data="list" size="small" style="width:100%">
+      <el-table-column label="调拨单号" prop="transferNo" min-width="160" show-overflow-tooltip />
+      <el-table-column label="调出门店" prop="fromStoreName" min-width="120" show-overflow-tooltip />
+      <el-table-column label="调入门店" prop="toStoreName" min-width="120" show-overflow-tooltip />
+      <el-table-column label="总数量" prop="totalQuantity" min-width="80" align="center" />
+      <el-table-column label="总金额" prop="totalAmount" min-width="90" align="center" />
+      <el-table-column label="状态" align="center" min-width="90">
+        <template slot-scope="scope">
+          <el-tag :type="statusTagType(scope.row.status)" size="mini">{{ scope.row.statusName || statusMap[scope.row.status] || scope.row.status }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="申请人" prop="applyUserName" min-width="90" align="center" />
+      <el-table-column label="申请时间" prop="applyTime" min-width="150" align="center" />
+      <el-table-column label="操作" align="center" width="280" fixed="right" class-name="small-padding fixed-width">
+        <template slot-scope="scope">
+          <el-button size="mini" type="text" icon="el-icon-view" @click="openDetail(scope.row)" v-hasPermi="['supplychain:inventoryTransfer:query']">详情</el-button>
+          <el-button v-if="scope.row.status === 'DRAFT'" size="mini" type="text" icon="el-icon-edit" @click="openEdit(scope.row)" v-hasPermi="['supplychain:inventoryTransfer:edit']">编辑</el-button>
+          <el-button v-if="scope.row.status === 'DRAFT'" size="mini" type="text" icon="el-icon-s-promotion" @click="handleSubmit(scope.row)" v-hasPermi="['supplychain:inventoryTransfer:submit']">提交</el-button>
+          <el-button v-if="scope.row.status === 'SUBMITTED'" size="mini" type="text" icon="el-icon-check" @click="openAudit(scope.row)" v-hasPermi="['supplychain:inventoryTransfer:audit']">审批</el-button>
+          <el-button v-if="scope.row.status === 'APPROVED'" size="mini" type="text" icon="el-icon-truck" @click="handleShip(scope.row)" v-hasPermi="['supplychain:inventoryTransfer:ship']">发货</el-button>
+          <el-button v-if="scope.row.status === 'IN_TRANSIT'" size="mini" type="text" icon="el-icon-box" @click="openReceive(scope.row)" v-hasPermi="['supplychain:inventoryTransfer:receive']">收货</el-button>
+          <el-button v-if="['DRAFT', 'SUBMITTED'].includes(scope.row.status)" size="mini" type="text" icon="el-icon-close" @click="handleCancel(scope.row)" v-hasPermi="['supplychain:inventoryTransfer:cancel']">取消</el-button>
+          <el-button v-if="scope.row.status === 'DRAFT'" size="mini" type="text" style="color:#f5222d" icon="el-icon-delete" @click="handleDelete(scope.row)" v-hasPermi="['supplychain:inventoryTransfer:remove']">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
+
+    <!-- 新增/编辑弹窗 -->
+    <el-dialog :title="editTitle" :visible.sync="editVisible" width="760px" append-to-body @closed="resetEdit">
+      <el-form ref="editForm" :model="editForm" :rules="editRules" label-width="100px">
+        <el-row :gutter="10">
+          <el-col :span="12">
+            <el-form-item label="调出门店ID" prop="fromStoreId">
+              <el-input-number v-model="editForm.fromStoreId" :min="1" style="width:100%" placeholder="请输入调出门店ID" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="调出门店名称" prop="fromStoreName">
+              <el-input v-model="editForm.fromStoreName" placeholder="请输入调出门店名称" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="调入门店ID" prop="toStoreId">
+              <el-input-number v-model="editForm.toStoreId" :min="1" style="width:100%" placeholder="请输入调入门店ID" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="调入门店名称" prop="toStoreName">
+              <el-input v-model="editForm.toStoreName" placeholder="请输入调入门店名称" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-form-item label="备注" prop="remark">
+          <el-input v-model="editForm.remark" type="textarea" :rows="2" placeholder="请输入备注" />
+        </el-form-item>
+        <el-divider content-position="left">调拨明细</el-divider>
+        <div v-for="(item, idx) in editForm.items" :key="idx" class="item-row">
+          <el-input-number v-model="item.productId" :min="1" size="small" placeholder="商品ID" style="width:100px" />
+          <el-input v-model="item.productName" size="small" placeholder="商品名称" style="width:160px" />
+          <el-input v-model="item.productSpec" size="small" placeholder="规格" style="width:110px" />
+          <el-input v-model="item.unit" size="small" placeholder="单位" style="width:70px" />
+          <el-input-number v-model="item.quantity" :min="1" size="small" placeholder="数量" style="width:90px" />
+          <el-input-number v-model="item.costPrice" :min="0" :precision="2" size="small" placeholder="成本价" style="width:110px" />
+          <el-button size="mini" type="text" style="color:#f5222d" icon="el-icon-delete" @click="removeItem(idx)" />
+        </div>
+        <el-button size="mini" type="primary" plain icon="el-icon-plus" @click="addItem">添加明细</el-button>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="editVisible = false">取 消</el-button>
+        <el-button type="primary" :loading="submitting" @click="submitEdit">确 定</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 详情弹窗 -->
+    <el-dialog title="调拨单详情" :visible.sync="detailVisible" width="860px" append-to-body>
+      <el-descriptions :column="3" border size="small">
+        <el-descriptions-item label="调拨单号">{{ detail.transferNo }}</el-descriptions-item>
+        <el-descriptions-item label="状态">{{ detail.statusName || statusMap[detail.status] || detail.status }}</el-descriptions-item>
+        <el-descriptions-item label="总金额">{{ detail.totalAmount }}</el-descriptions-item>
+        <el-descriptions-item label="调出门店">{{ detail.fromStoreName }}</el-descriptions-item>
+        <el-descriptions-item label="调入门店">{{ detail.toStoreName }}</el-descriptions-item>
+        <el-descriptions-item label="总数量">{{ detail.totalQuantity }}</el-descriptions-item>
+        <el-descriptions-item label="申请人">{{ detail.applyUserName }}</el-descriptions-item>
+        <el-descriptions-item label="申请时间">{{ detail.applyTime }}</el-descriptions-item>
+        <el-descriptions-item label="备注">{{ detail.remark }}</el-descriptions-item>
+      </el-descriptions>
+      <el-divider content-position="left">明细</el-divider>
+      <el-table border :data="detail.items || []" size="small">
+        <el-table-column label="商品ID" prop="productId" min-width="80" align="center" />
+        <el-table-column label="商品名称" prop="productName" min-width="150" show-overflow-tooltip />
+        <el-table-column label="规格" prop="productSpec" min-width="100" />
+        <el-table-column label="单位" prop="unit" min-width="60" align="center" />
+        <el-table-column label="数量" prop="quantity" min-width="70" align="center" />
+        <el-table-column label="成本价" prop="costPrice" min-width="80" align="center" />
+        <el-table-column label="小计" prop="totalAmount" min-width="80" align="center" />
+      </el-table>
+      <el-divider content-position="left">操作日志</el-divider>
+      <el-table border :data="logs" size="small" v-loading="logsLoading">
+        <el-table-column label="操作" prop="operationName" min-width="100" />
+        <el-table-column label="操作人" prop="operatorName" min-width="100" />
+        <el-table-column label="内容" prop="content" min-width="200" show-overflow-tooltip />
+        <el-table-column label="时间" prop="createTime" min-width="150" />
+      </el-table>
+    </el-dialog>
+
+    <!-- 审批弹窗 -->
+    <el-dialog title="审批调拨单" :visible.sync="auditVisible" width="460px" append-to-body>
+      <el-form ref="auditForm" :model="auditForm" :rules="auditRules" label-width="100px">
+        <el-form-item label="调拨单号">
+          <el-input :value="auditForm.transferNo" disabled />
+        </el-form-item>
+        <el-form-item label="审批结果" prop="approveResult">
+          <el-radio-group v-model="auditForm.approveResult">
+            <el-radio label="APPROVED">通过</el-radio>
+            <el-radio label="REJECTED">驳回</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="审批备注" prop="approveRemark">
+          <el-input v-model="auditForm.approveRemark" type="textarea" :rows="3" placeholder="请输入审批备注" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="auditVisible = false">取 消</el-button>
+        <el-button type="primary" :loading="submitting" @click="submitAudit">确 定</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 收货弹窗 -->
+    <el-dialog title="收货确认" :visible.sync="receiveVisible" width="460px" append-to-body>
+      <el-form ref="receiveForm" :model="receiveForm" label-width="100px">
+        <el-form-item label="调拨单号">
+          <el-input :value="receiveForm.transferNo" disabled />
+        </el-form-item>
+        <el-form-item label="收货备注" prop="receiveRemark">
+          <el-input v-model="receiveForm.receiveRemark" type="textarea" :rows="3" placeholder="请输入收货备注" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="receiveVisible = false">取 消</el-button>
+        <el-button type="primary" :loading="submitting" @click="submitReceive">确 定</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import {
+  listInventoryTransfer, getInventoryTransfer, addInventoryTransfer, updateInventoryTransfer,
+  delInventoryTransfer, submitInventoryTransfer, auditInventoryTransfer, shipInventoryTransfer,
+  receiveInventoryTransfer, cancelInventoryTransfer, listInventoryTransferLogs
+} from '@/api/admin/inventoryTransfer'
+
+export default {
+  name: 'AdminInventoryTransfer',
+  data() {
+    return {
+      showSearch: true,
+      loading: false,
+      list: [],
+      total: 0,
+      queryParams: { pageNum: 1, pageSize: 10, transferNo: null, status: null },
+      statusMap: {
+        DRAFT: '草稿', SUBMITTED: '待审核', APPROVED: '已审核', REJECTED: '已驳回',
+        IN_TRANSIT: '运输中', RECEIVED: '已收货', CANCELLED: '已取消'
+      },
+      editVisible: false,
+      editTitle: '',
+      submitting: false,
+      editForm: { id: null, transferNo: null, fromStoreId: null, fromStoreName: '', toStoreId: null, toStoreName: '', remark: '', items: [] },
+      editRules: {
+        fromStoreId: [{ required: true, message: '请输入调出门店ID', trigger: 'blur' }],
+        toStoreId: [{ required: true, message: '请输入调入门店ID', trigger: 'blur' }]
+      },
+      detailVisible: false,
+      detail: {},
+      logs: [],
+      logsLoading: false,
+      auditVisible: false,
+      auditForm: { transferNo: '', approveResult: 'APPROVED', approveRemark: '' },
+      auditRules: { approveResult: [{ required: true, message: '请选择审批结果', trigger: 'change' }] },
+      receiveVisible: false,
+      receiveForm: { transferNo: '', receiveRemark: '' }
+    }
+  },
+  created() {
+    this.getList()
+  },
+  methods: {
+    getList() {
+      this.loading = true
+      listInventoryTransfer(this.queryParams).then(r => {
+        this.list = r.rows || []
+        this.total = r.total || 0
+        this.loading = false
+      }).catch(() => { this.loading = false })
+    },
+    handleQuery() {
+      this.queryParams.pageNum = 1
+      this.getList()
+    },
+    resetQuery() {
+      this.resetForm('queryForm')
+      this.handleQuery()
+    },
+    statusTagType(status) {
+      const map = {
+        DRAFT: 'info', SUBMITTED: 'warning', APPROVED: 'success', REJECTED: 'danger',
+        IN_TRANSIT: 'primary', RECEIVED: 'success', CANCELLED: 'info'
+      }
+      return map[status] || 'info'
+    },
+    addItem() {
+      this.editForm.items.push({ productId: null, productName: '', productSpec: '', unit: '', quantity: 1, costPrice: 0 })
+    },
+    removeItem(idx) {
+      this.editForm.items.splice(idx, 1)
+    },
+    openEdit(row) {
+      this.resetEdit()
+      this.addItem()
+      if (row) {
+        this.editTitle = '编辑调拨单'
+        getInventoryTransfer(row.transferNo).then(r => {
+          const d = r.data || {}
+          this.editForm = {
+            id: d.id, transferNo: d.transferNo, fromStoreId: d.fromStoreId, fromStoreName: d.fromStoreName,
+            toStoreId: d.toStoreId, toStoreName: d.toStoreName, remark: d.remark,
+            items: (d.items || []).map(it => ({
+              productId: it.productId, productName: it.productName, productSpec: it.productSpec,
+              unit: it.unit, quantity: it.quantity, costPrice: it.costPrice
+            }))
+          }
+          if (this.editForm.items.length === 0) this.addItem()
+          this.editVisible = true
+        })
+      } else {
+        this.editTitle = '新增调拨单'
+        this.editVisible = true
+      }
+    },
+    submitEdit() {
+      this.$refs.editForm.validate(valid => {
+        if (!valid) return
+        const items = this.editForm.items.filter(it => it.productId)
+        if (items.length === 0) {
+          this.$message.warning('请至少添加一条调拨明细')
+          return
+        }
+        const payload = { ...this.editForm, items }
+        this.submitting = true
+        const req = this.editForm.id ? updateInventoryTransfer(payload) : addInventoryTransfer(payload)
+        req.then(() => {
+          this.$message.success(this.editForm.id ? '修改成功' : '新增成功')
+          this.editVisible = false
+          this.getList()
+        }).finally(() => { this.submitting = false })
+      })
+    },
+    resetEdit() {
+      this.editForm = { id: null, transferNo: null, fromStoreId: null, fromStoreName: '', toStoreId: null, toStoreName: '', remark: '', items: [] }
+      if (this.$refs.editForm) this.$refs.editForm.resetFields()
+    },
+    openDetail(row) {
+      this.detail = {}
+      this.logs = []
+      this.detailVisible = true
+      getInventoryTransfer(row.transferNo).then(r => {
+        this.detail = r.data || {}
+      })
+      this.loadLogs(row.transferNo)
+    },
+    loadLogs(transferNo) {
+      this.logsLoading = true
+      listInventoryTransferLogs(transferNo).then(r => {
+        this.logs = r.data || []
+        this.logsLoading = false
+      }).catch(() => { this.logsLoading = false })
+    },
+    handleSubmit(row) {
+      this.$confirm(`确认提交调拨单 "${row.transferNo}"?`, '提示', { type: 'warning' }).then(() => {
+        submitInventoryTransfer(row.transferNo).then(() => {
+          this.$message.success('提交成功')
+          this.getList()
+        })
+      })
+    },
+    openAudit(row) {
+      this.auditForm = { transferNo: row.transferNo, approveResult: 'APPROVED', approveRemark: '' }
+      this.auditVisible = true
+    },
+    submitAudit() {
+      this.$refs.auditForm.validate(valid => {
+        if (!valid) return
+        this.submitting = true
+        auditInventoryTransfer(this.auditForm).then(() => {
+          this.$message.success('审批完成')
+          this.auditVisible = false
+          this.getList()
+        }).finally(() => { this.submitting = false })
+      })
+    },
+    handleShip(row) {
+      this.$confirm(`确认对调拨单 "${row.transferNo}" 执行发货?`, '提示', { type: 'warning' }).then(() => {
+        shipInventoryTransfer(row.transferNo).then(() => {
+          this.$message.success('发货成功')
+          this.getList()
+        })
+      })
+    },
+    openReceive(row) {
+      this.receiveForm = { transferNo: row.transferNo, receiveRemark: '' }
+      this.receiveVisible = true
+    },
+    submitReceive() {
+      this.submitting = true
+      receiveInventoryTransfer(this.receiveForm).then(() => {
+        this.$message.success('收货成功')
+        this.receiveVisible = false
+        this.getList()
+      }).finally(() => { this.submitting = false })
+    },
+    handleCancel(row) {
+      this.$confirm(`确认取消调拨单 "${row.transferNo}"?`, '提示', { type: 'warning' }).then(() => {
+        cancelInventoryTransfer(row.transferNo).then(() => {
+          this.$message.success('取消成功')
+          this.getList()
+        })
+      })
+    },
+    handleDelete(row) {
+      this.$confirm(`确认删除调拨单 "${row.transferNo}"?`, '提示', { type: 'warning' }).then(() => {
+        delInventoryTransfer(row.transferNo).then(() => {
+          this.$message.success('删除成功')
+          this.getList()
+        })
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.mb8 { margin-bottom: 8px; }
+.mb16 { margin-bottom: 16px; }
+.filter-card { padding-bottom: 0; }
+.item-row { display: flex; gap: 6px; margin-bottom: 8px; align-items: center; }
+</style>

+ 206 - 0
adminui/src/views/admin/productLibrary/index.vue

@@ -0,0 +1,206 @@
+<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-item label="商品名称" prop="productName">
+          <el-input v-model="queryParams.productName" placeholder="请输入商品名称" clearable @keyup.enter.native="handleQuery" />
+        </el-form-item>
+        <el-form-item label="编码" prop="barCode">
+          <el-input v-model="queryParams.barCode" placeholder="请输入编码" clearable @keyup.enter.native="handleQuery" />
+        </el-form-item>
+        <el-form-item label="审核状态" prop="isAudit">
+          <el-select v-model="queryParams.isAudit" placeholder="全部" clearable style="width:120px">
+            <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" 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>
+
+    <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)" v-hasPermi="['resource:productLibrary:add']">新增商品</el-button>
+      </el-col>
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
+    </el-row>
+
+    <el-table border v-loading="loading" :data="list" size="small" style="width:100%">
+      <el-table-column label="商品ID" prop="productId" min-width="70" align="center" />
+      <el-table-column label="商品图片" align="center" width="80">
+        <template slot-scope="scope">
+          <el-image v-if="scope.row.image" :src="scope.row.image" style="width:40px;height:40px" fit="cover" />
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="商品名称" prop="productName" min-width="160" show-overflow-tooltip />
+      <el-table-column label="编码" prop="barCode" min-width="120" show-overflow-tooltip />
+      <el-table-column label="价格" prop="price" min-width="80" align="center" />
+      <el-table-column label="成本价" prop="cost" min-width="80" align="center" />
+      <el-table-column label="单位" prop="unitName" min-width="70" align="center" />
+      <el-table-column label="商品类型" align="center" min-width="90">
+        <template slot-scope="scope">
+          <el-tag v-if="scope.row.productType === 2" 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" min-width="80">
+        <template slot-scope="scope">
+          <el-tag v-if="scope.row.isAudit === 1" type="success" size="mini">已审核</el-tag>
+          <el-tag v-else type="danger" size="mini">未审核</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="创建时间" prop="createTime" min-width="150" align="center" />
+      <el-table-column label="操作" align="center" width="150" fixed="right" class-name="small-padding fixed-width">
+        <template slot-scope="scope">
+          <el-button size="mini" type="text" icon="el-icon-edit" @click="openDialog(scope.row)" v-hasPermi="['resource:productLibrary:edit']">编辑</el-button>
+          <el-button size="mini" type="text" style="color:#f5222d" icon="el-icon-delete" @click="handleDelete(scope.row)" v-hasPermi="['resource:productLibrary:remove']">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
+
+    <el-dialog :title="formTitle" :visible.sync="dialogVisible" width="620px" append-to-body @closed="reset">
+      <el-form ref="form" :model="form" :rules="rules" label-width="100px">
+        <el-form-item label="商品名称" prop="productName">
+          <el-input v-model="form.productName" placeholder="请输入商品名称" />
+        </el-form-item>
+        <el-form-item label="编码" prop="barCode">
+          <el-input v-model="form.barCode" placeholder="请输入编码" />
+        </el-form-item>
+        <el-form-item label="分类ID" prop="cateId">
+          <el-input-number v-model="form.cateId" :min="0" style="width:100%" placeholder="请输入分类ID" />
+        </el-form-item>
+        <el-form-item label="价格" prop="price">
+          <el-input-number v-model="form.price" :min="0" :precision="2" style="width:100%" placeholder="请输入价格" />
+        </el-form-item>
+        <el-form-item label="市场价" prop="otPrice">
+          <el-input-number v-model="form.otPrice" :min="0" :precision="2" style="width:100%" placeholder="请输入市场价" />
+        </el-form-item>
+        <el-form-item label="成本价" prop="cost">
+          <el-input-number v-model="form.cost" :min="0" :precision="2" style="width:100%" placeholder="请输入成本价" />
+        </el-form-item>
+        <el-form-item label="单位名" prop="unitName">
+          <el-input v-model="form.unitName" placeholder="请输入单位名" />
+        </el-form-item>
+        <el-form-item label="排序" prop="sort">
+          <el-input-number v-model="form.sort" :min="0" style="width:100%" placeholder="请输入排序" />
+        </el-form-item>
+        <el-form-item label="规格类型" prop="specType">
+          <el-radio-group v-model="form.specType">
+            <el-radio :label="0">单规格</el-radio>
+            <el-radio :label="1">多规格</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="商品类型" prop="productType">
+          <el-radio-group v-model="form.productType">
+            <el-radio :label="1">非处方</el-radio>
+            <el-radio :label="2">处方</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="商品图片" prop="image">
+          <el-input v-model="form.image" placeholder="请输入图片URL" />
+        </el-form-item>
+        <el-form-item label="商品简介" prop="productInfo">
+          <el-input v-model="form.productInfo" type="textarea" :rows="3" placeholder="请输入商品简介" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="dialogVisible = false">取 消</el-button>
+        <el-button type="primary" :loading="submitting" @click="submitForm">确 定</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { listProductLibrary, getProductLibrary, addProductLibrary, updateProductLibrary, delProductLibrary } from '@/api/admin/productLibrary'
+
+export default {
+  name: 'AdminProductLibrary',
+  data() {
+    return {
+      showSearch: true,
+      loading: false,
+      list: [],
+      total: 0,
+      queryParams: { pageNum: 1, pageSize: 10, productName: null, barCode: null, isAudit: null },
+      dialogVisible: false,
+      formTitle: '',
+      submitting: false,
+      form: { productId: null, productName: '', barCode: '', cateId: null, price: 0, otPrice: 0, cost: 0, unitName: '', sort: 0, specType: 0, productType: 1, image: '', productInfo: '' },
+      rules: {
+        productName: [{ required: true, message: '请输入商品名称', trigger: 'blur' }]
+      }
+    }
+  },
+  created() {
+    this.getList()
+  },
+  methods: {
+    getList() {
+      this.loading = true
+      listProductLibrary(this.queryParams).then(r => {
+        this.list = r.rows || []
+        this.total = r.total || 0
+        this.loading = false
+      }).catch(() => { this.loading = false })
+    },
+    handleQuery() {
+      this.queryParams.pageNum = 1
+      this.getList()
+    },
+    resetQuery() {
+      this.resetForm('queryForm')
+      this.handleQuery()
+    },
+    openDialog(row) {
+      this.reset()
+      if (row) {
+        this.formTitle = '编辑商品'
+        getProductLibrary(row.productId).then(r => {
+          this.form = { ...this.form, ...r.data }
+          this.dialogVisible = true
+        })
+      } else {
+        this.formTitle = '新增商品'
+        this.dialogVisible = true
+      }
+    },
+    submitForm() {
+      this.$refs.form.validate(valid => {
+        if (!valid) return
+        this.submitting = true
+        const req = this.form.productId ? updateProductLibrary(this.form) : addProductLibrary(this.form)
+        req.then(() => {
+          this.$message.success(this.form.productId ? '修改成功' : '新增成功')
+          this.dialogVisible = false
+          this.getList()
+        }).finally(() => { this.submitting = false })
+      })
+    },
+    handleDelete(row) {
+      this.$confirm(`确认删除商品 "${row.productName}"?`, '提示', { type: 'warning' }).then(() => {
+        delProductLibrary(row.productId).then(() => {
+          this.$message.success('删除成功')
+          this.getList()
+        })
+      })
+    },
+    reset() {
+      this.form = { productId: null, productName: '', barCode: '', cateId: null, price: 0, otPrice: 0, cost: 0, unitName: '', sort: 0, specType: 0, productType: 1, image: '', productInfo: '' }
+      if (this.$refs.form) this.$refs.form.resetFields()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.mb8 { margin-bottom: 8px; }
+.mb16 { margin-bottom: 16px; }
+.filter-card { padding-bottom: 0; }
+</style>

+ 1 - 1
java/fs-admin/src/main/java/com/fs/admin/config/AdminWebMvcConfig.java

@@ -15,6 +15,6 @@ public class AdminWebMvcConfig implements WebMvcConfigurer {
     @Override
     public void addInterceptors(InterceptorRegistry registry) {
         registry.addInterceptor(adminTenantDataSourceInterceptor)
-                .addPathPatterns("/admin/**");
+                .addPathPatterns("/admin/**", "/supplychain/**");
     }
 }

+ 11 - 0
java/fs-admin/src/main/java/com/fs/admin/controller/AdminCommGatewayLogController.java

@@ -46,6 +46,17 @@ public class AdminCommGatewayLogController extends BaseController {
         return getDataTable(list);
     }
 
+    @PreAuthorize("@ss.hasAnyPermi('platform:commGatewayCallLog:list,platform:commGatewaySmsLog:list')")
+    @GetMapping("/stats")
+    public AjaxResult stats(String apiType) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        if (commGatewayApiLogService == null) {
+            return AjaxResult.error("服务不可用");
+        }
+        String type = StringUtils.isBlank(apiType) ? CommGatewayApiLog.API_TYPE_CALL : apiType;
+        return AjaxResult.success(commGatewayApiLogService.selectCallTenantStats(type));
+    }
+
     @Log(title = "查看通讯网关日志手机号", businessType = BusinessType.GRANT)
     @PreAuthorize("@ss.hasAnyPermi('platform:commGatewayCallLog:query,platform:commGatewaySmsLog:query')")
     @GetMapping("/queryPhone/{logId}")

+ 33 - 0
java/fs-admin/src/main/java/com/fs/admin/controller/device/DeviceStatsAdminController.java

@@ -0,0 +1,33 @@
+package com.fs.admin.controller.device;
+
+import com.fs.admin.annotation.TenantDS;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.watch.service.WatchDeviceInfoService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+/**
+ * 设备统计
+ */
+@TenantDS
+@RestController
+@RequestMapping("/admin/deviceStats")
+public class DeviceStatsAdminController extends BaseController {
+
+    @Autowired
+    private WatchDeviceInfoService watchDeviceInfoService;
+
+    /**
+     * 设备总量统计
+     */
+    @PreAuthorize("@ss.hasPermi('device:deviceStats:list')")
+    @GetMapping("/overview")
+    public AjaxResult overview() {
+        Map<String, Object> stats = watchDeviceInfoService.selectDeviceStats();
+        return AjaxResult.success(stats);
+    }
+}

+ 68 - 0
java/fs-admin/src/main/java/com/fs/admin/controller/platform/AdminCourseResourceController.java

@@ -0,0 +1,68 @@
+package com.fs.admin.controller.platform;
+
+import com.fs.admin.annotation.TenantDS;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.course.domain.FsVideoResource;
+import com.fs.course.service.IFsVideoResourceService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 课程资源库管理
+ */
+@TenantDS
+@RestController
+@RequestMapping("/admin/courseResource")
+public class AdminCourseResourceController extends BaseController {
+
+    @Autowired
+    private IFsVideoResourceService videoResourceService;
+
+    @PreAuthorize("@ss.hasPermi('resource:courseResource:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(FsVideoResource resource) {
+        startPage();
+        return getDataTable(videoResourceService.selectCourseResourceList(resource));
+    }
+
+    @PreAuthorize("@ss.hasPermi('resource:courseResource:query')")
+    @GetMapping("/{id}")
+    public AjaxResult getInfo(@PathVariable Long id) {
+        return AjaxResult.success(videoResourceService.getById(id));
+    }
+
+    @PreAuthorize("@ss.hasPermi('resource:courseResource:add')")
+    @PostMapping
+    public AjaxResult add(@RequestBody FsVideoResource resource) {
+        resource.setId(null);
+        if (resource.getIsDel() == null) {
+            resource.setIsDel(0);
+        }
+        return toAjax(videoResourceService.save(resource));
+    }
+
+    @PreAuthorize("@ss.hasPermi('resource:courseResource:edit')")
+    @PutMapping
+    public AjaxResult edit(@RequestBody FsVideoResource resource) {
+        return toAjax(videoResourceService.updateById(resource));
+    }
+
+    @PreAuthorize("@ss.hasPermi('resource:courseResource:remove')")
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids) {
+        List<FsVideoResource> list = new ArrayList<>();
+        for (Long id : ids) {
+            FsVideoResource resource = new FsVideoResource();
+            resource.setId(id);
+            resource.setIsDel(1);
+            list.add(resource);
+        }
+        return toAjax(videoResourceService.updateBatchById(list));
+    }
+}

+ 53 - 0
java/fs-admin/src/main/java/com/fs/admin/controller/platform/AdminProductLibraryController.java

@@ -0,0 +1,53 @@
+package com.fs.admin.controller.platform;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.oms.domain.OmsProduct;
+import com.fs.oms.service.IOmsProductService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 商品库管理(平台商品库,进货模式选品来源)
+ */
+@RestController
+@RequestMapping("/admin/productLibrary")
+public class AdminProductLibraryController extends BaseController {
+
+    @Autowired
+    private IOmsProductService omsProductService;
+
+    @PreAuthorize("@ss.hasPermi('resource:productLibrary:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(OmsProduct product) {
+        startPage();
+        return getDataTable(omsProductService.selectOmsProductList(product));
+    }
+
+    @PreAuthorize("@ss.hasPermi('resource:productLibrary:query')")
+    @GetMapping("/{productId}")
+    public AjaxResult getInfo(@PathVariable Long productId) {
+        return AjaxResult.success(omsProductService.selectOmsProductById(productId));
+    }
+
+    @PreAuthorize("@ss.hasPermi('resource:productLibrary:add')")
+    @PostMapping
+    public AjaxResult add(@RequestBody OmsProduct product) {
+        product.setProductId(null);
+        return toAjax(omsProductService.insertOmsProduct(product));
+    }
+
+    @PreAuthorize("@ss.hasPermi('resource:productLibrary:edit')")
+    @PutMapping
+    public AjaxResult edit(@RequestBody OmsProduct product) {
+        return toAjax(omsProductService.updateOmsProduct(product));
+    }
+
+    @PreAuthorize("@ss.hasPermi('resource:productLibrary:remove')")
+    @DeleteMapping("/{productIds}")
+    public AjaxResult remove(@PathVariable Long[] productIds) {
+        return toAjax(omsProductService.deleteOmsProductByIds(productIds));
+    }
+}

+ 107 - 0
java/fs-admin/src/main/java/com/fs/admin/controller/supplychain/InventoryTransferAdminController.java

@@ -0,0 +1,107 @@
+package com.fs.admin.controller.supplychain;
+
+import com.fs.admin.annotation.TenantDS;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.his.param.InventoryTransferAuditParam;
+import com.fs.his.param.InventoryTransferParam;
+import com.fs.his.param.InventoryTransferQueryParam;
+import com.fs.his.param.InventoryTransferReceiveParam;
+import com.fs.his.service.IFsInventoryTransferService;
+import com.fs.his.vo.InventoryTransferLogVO;
+import com.fs.his.vo.InventoryTransferVO;
+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("/supplychain/inventoryTransfer")
+public class InventoryTransferAdminController extends BaseController {
+
+    @Autowired
+    private IFsInventoryTransferService inventoryTransferService;
+
+    @PreAuthorize("@ss.hasPermi('supplychain:inventoryTransfer:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(InventoryTransferQueryParam param) {
+        startPage();
+        List<InventoryTransferVO> list = inventoryTransferService.selectTransferList(param);
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('supplychain:inventoryTransfer:query')")
+    @GetMapping("/detail")
+    public AjaxResult detail(@RequestParam("transferNo") String transferNo) {
+        return AjaxResult.success(inventoryTransferService.selectTransferDetail(transferNo));
+    }
+
+    @PreAuthorize("@ss.hasPermi('supplychain:inventoryTransfer:add')")
+    @PostMapping
+    public AjaxResult create(@RequestBody InventoryTransferParam param) {
+        String transferNo = inventoryTransferService.createTransfer(param);
+        return AjaxResult.success("创建成功", transferNo);
+    }
+
+    @PreAuthorize("@ss.hasPermi('supplychain:inventoryTransfer:edit')")
+    @PutMapping
+    public AjaxResult update(@RequestBody InventoryTransferParam param) {
+        inventoryTransferService.updateTransfer(param);
+        return AjaxResult.success("修改成功");
+    }
+
+    @PreAuthorize("@ss.hasPermi('supplychain:inventoryTransfer:remove')")
+    @DeleteMapping("/{transferNo}")
+    public AjaxResult remove(@PathVariable String transferNo) {
+        inventoryTransferService.deleteTransfer(transferNo);
+        return AjaxResult.success("删除成功");
+    }
+
+    @PreAuthorize("@ss.hasPermi('supplychain:inventoryTransfer:submit')")
+    @PostMapping("/submit")
+    public AjaxResult submit(@RequestParam("transferNo") String transferNo) {
+        inventoryTransferService.submitTransfer(transferNo);
+        return AjaxResult.success("提交成功");
+    }
+
+    @PreAuthorize("@ss.hasPermi('supplychain:inventoryTransfer:audit')")
+    @PostMapping("/audit")
+    public AjaxResult audit(@RequestBody InventoryTransferAuditParam param) {
+        inventoryTransferService.auditTransfer(param);
+        return AjaxResult.success("审批完成");
+    }
+
+    @PreAuthorize("@ss.hasPermi('supplychain:inventoryTransfer:ship')")
+    @PostMapping("/ship")
+    public AjaxResult ship(@RequestParam("transferNo") String transferNo) {
+        inventoryTransferService.shipTransfer(transferNo);
+        return AjaxResult.success("发货成功");
+    }
+
+    @PreAuthorize("@ss.hasPermi('supplychain:inventoryTransfer:receive')")
+    @PostMapping("/receive")
+    public AjaxResult receive(@RequestBody InventoryTransferReceiveParam param) {
+        inventoryTransferService.receiveTransfer(param);
+        return AjaxResult.success("收货成功");
+    }
+
+    @PreAuthorize("@ss.hasPermi('supplychain:inventoryTransfer:cancel')")
+    @PostMapping("/cancel")
+    public AjaxResult cancel(@RequestParam("transferNo") String transferNo) {
+        inventoryTransferService.cancelTransfer(transferNo);
+        return AjaxResult.success("取消成功");
+    }
+
+    @PreAuthorize("@ss.hasPermi('supplychain:inventoryTransfer:query')")
+    @GetMapping("/logs")
+    public AjaxResult logs(@RequestParam("transferNo") String transferNo) {
+        List<InventoryTransferLogVO> logs = inventoryTransferService.selectTransferLogs(transferNo);
+        return AjaxResult.success(logs);
+    }
+}

+ 3 - 0
java/fs-service/src/main/java/com/fs/comm/mapper/CommGatewayApiLogMapper.java

@@ -1,6 +1,7 @@
 package com.fs.comm.mapper;
 
 import com.fs.comm.domain.CommGatewayApiLog;
+import com.fs.comm.vo.CommGatewayTenantStatsVO;
 import com.fs.common.annotation.DataSource;
 import com.fs.common.enums.DataSourceType;
 import org.apache.ibatis.annotations.Param;
@@ -30,4 +31,6 @@ public interface CommGatewayApiLogMapper {
     Integer countByCompanyCall(@Param("companyId") Long companyId,
                                @Param("apiType") String apiType,
                                @Param("type") Integer type);
+
+    List<CommGatewayTenantStatsVO> selectCallTenantStats(@Param("apiType") String apiType);
 }

+ 7 - 0
java/fs-service/src/main/java/com/fs/comm/service/CommGatewayApiLogServiceImpl.java

@@ -2,6 +2,7 @@ package com.fs.comm.service;
 
 import com.fs.comm.domain.CommGatewayApiLog;
 import com.fs.comm.mapper.CommGatewayApiLogMapper;
+import com.fs.comm.vo.CommGatewayTenantStatsVO;
 import com.fs.common.annotation.DataSource;
 import com.fs.common.enums.DataSourceType;
 import com.fs.common.utils.DateUtils;
@@ -73,4 +74,10 @@ public class CommGatewayApiLogServiceImpl implements ICommGatewayApiLogService {
     public int countByCallerPhone(Long companyId, String callerPhone, Integer type) {
         return commGatewayApiLogMapper.countByCallerPhone(companyId, callerPhone, type);
     }
+
+    @Override
+    @DataSource(DataSourceType.MASTER)
+    public List<CommGatewayTenantStatsVO> selectCallTenantStats(String apiType) {
+        return commGatewayApiLogMapper.selectCallTenantStats(apiType);
+    }
 }

+ 3 - 0
java/fs-service/src/main/java/com/fs/comm/service/ICommGatewayApiLogService.java

@@ -1,6 +1,7 @@
 package com.fs.comm.service;
 
 import com.fs.comm.domain.CommGatewayApiLog;
+import com.fs.comm.vo.CommGatewayTenantStatsVO;
 
 import java.util.List;
 
@@ -17,4 +18,6 @@ public interface ICommGatewayApiLogService {
     int countByCallerPhone(Long companyId, String callerPhone, Integer type);
 
     String getDecryptCalleePhone(Long logId);
+
+    List<CommGatewayTenantStatsVO> selectCallTenantStats(String apiType);
 }

+ 24 - 0
java/fs-service/src/main/java/com/fs/comm/vo/CommGatewayTenantStatsVO.java

@@ -0,0 +1,24 @@
+package com.fs.comm.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 通讯网关 API 调用日志:按租户统计(外呼/短信 数量与金额)
+ */
+@Data
+public class CommGatewayTenantStatsVO {
+
+    private Long tenantId;
+
+    private String tenantName;
+
+    private String tenantCode;
+
+    /** 调用次数 */
+    private Long callCount;
+
+    /** 计费金额合计 */
+    private BigDecimal totalAmount;
+}

+ 8 - 0
java/fs-service/src/main/java/com/fs/course/service/IFsVideoResourceService.java

@@ -24,4 +24,12 @@ public interface IFsVideoResourceService extends IService<FsVideoResource> {
      * @return
      */
     FsVideoResource selectByFileKey(String fileKey);
+
+    /**
+     * 查询课程资源库列表
+     *
+     * @param resource 查询条件
+     * @return 课程资源集合
+     */
+    List<FsVideoResource> selectCourseResourceList(FsVideoResource resource);
 }

+ 20 - 0
java/fs-service/src/main/java/com/fs/course/service/impl/FsVideoResourceServiceImpl.java

@@ -36,4 +36,24 @@ public class FsVideoResourceServiceImpl extends ServiceImpl<FsVideoResourceMappe
         FsVideoResource fsVideoResource = baseMapper.selectOne(new LambdaQueryWrapper<FsVideoResource>().eq(FsVideoResource::getFileKey, fileKey));
         return fsVideoResource;
     }
+
+    @Override
+    public List<FsVideoResource> selectCourseResourceList(FsVideoResource resource) {
+        LambdaQueryWrapper<FsVideoResource> qw = new LambdaQueryWrapper<>();
+        if (resource.getResourceName() != null && !resource.getResourceName().isEmpty()) {
+            qw.like(FsVideoResource::getResourceName, resource.getResourceName());
+        }
+        if (resource.getFileName() != null && !resource.getFileName().isEmpty()) {
+            qw.like(FsVideoResource::getFileName, resource.getFileName());
+        }
+        if (resource.getTypeId() != null) {
+            qw.eq(FsVideoResource::getTypeId, resource.getTypeId());
+        }
+        if (resource.getTypeSubId() != null) {
+            qw.eq(FsVideoResource::getTypeSubId, resource.getTypeSubId());
+        }
+        qw.eq(FsVideoResource::getIsDel, 0);
+        qw.orderByDesc(FsVideoResource::getCreateTime);
+        return this.list(qw);
+    }
 }

+ 8 - 0
java/fs-service/src/main/java/com/fs/watch/mapper/WatchDeviceInfoMapper.java

@@ -6,6 +6,7 @@ import com.fs.watch.param.WatchDeviceInfoQueryParam;
 import org.apache.ibatis.annotations.Param;
 
 import java.util.List;
+import java.util.Map;
 
 /**
 * @author Administrator
@@ -69,4 +70,11 @@ public interface WatchDeviceInfoMapper{
     List<WatchDeviceInfo> selectListByUserOrFamilyUser(@Param("userId") Long userId);
 
     void removeUserId(WatchDeviceInfo data);
+
+    /**
+     * 设备统计(总量、状态、生命状态、健康状态、新增)
+     *
+     * @return 统计结果 Map
+     */
+    Map<String, Object> selectDeviceStats();
 }

+ 7 - 0
java/fs-service/src/main/java/com/fs/watch/service/WatchDeviceInfoService.java

@@ -62,4 +62,11 @@ public interface WatchDeviceInfoService{
     void bindCompany(CompanyUserUser map);
 
     void removeUserId(String deviceId);
+
+    /**
+     * 设备统计
+     *
+     * @return 统计结果
+     */
+    Map<String, Object> selectDeviceStats();
 }

+ 5 - 0
java/fs-service/src/main/java/com/fs/watch/service/impl/WatchDeviceInfoServiceImpl.java

@@ -707,6 +707,11 @@ public class WatchDeviceInfoServiceImpl implements WatchDeviceInfoService {
         }
     }
 
+    @Override
+    public Map<String, Object> selectDeviceStats() {
+        return mapper.selectDeviceStats();
+    }
+
     /**
      * 查询有设备编号的家人信息
      * @param str

+ 13 - 0
java/fs-service/src/main/resources/mapper/comm/CommGatewayApiLogMapper.xml

@@ -121,4 +121,17 @@
         <if test="type != null and type == 5">and timestampdiff(MONTH, create_time, now()) &lt; 1</if>
     </select>
 
+    <select id="selectCallTenantStats" resultType="com.fs.comm.vo.CommGatewayTenantStatsVO">
+        select l.tenant_id as tenantId,
+               ti.tenant_name as tenantName,
+               ti.tenant_code as tenantCode,
+               count(1) as callCount,
+               ifnull(sum(l.billing_amount), 0) as totalAmount
+        from comm_gateway_api_log l
+        left join tenant_info ti on ti.id = l.tenant_id
+        where l.api_type = #{apiType}
+        group by l.tenant_id, ti.tenant_name, ti.tenant_code
+        order by callCount desc
+    </select>
+
 </mapper>

+ 19 - 1
java/fs-service/src/main/resources/mapper/watch/WatchDeviceInfoMapper.xml

@@ -1,4 +1,4 @@
-<?xml version="1.0" encoding="UTF-8"?>
+<?xml version="1.0" encoding="UTF-8"?>
 <!DOCTYPE mapper
         PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
@@ -289,4 +289,22 @@
         where i.is_del = 0 and
               (f.family_user_id = #{userId} or i.user_id = #{userId} )
     </select>
+
+    <select id="selectDeviceStats" resultType="java.util.HashMap">
+        SELECT
+            COUNT(*) AS totalCount,
+            IFNULL(SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END), 0) AS onlineCount,
+            IFNULL(SUM(CASE WHEN status = 0 THEN 1 ELSE 0 END), 0) AS offlineCount,
+            IFNULL(SUM(CASE WHEN status = 2 THEN 1 ELSE 0 END), 0) AS unactiveCount,
+            IFNULL(SUM(CASE WHEN status = 3 THEN 1 ELSE 0 END), 0) AS disableCount,
+            IFNULL(SUM(CASE WHEN life_status = 0 THEN 1 ELSE 0 END), 0) AS lifeIdleCount,
+            IFNULL(SUM(CASE WHEN life_status = 1 THEN 1 ELSE 0 END), 0) AS lifeSentCount,
+            IFNULL(SUM(CASE WHEN life_status = 2 THEN 1 ELSE 0 END), 0) AS lifeActiveCount,
+            IFNULL(SUM(CASE WHEN is_normal = 1 THEN 1 ELSE 0 END), 0) AS normalCount,
+            IFNULL(SUM(CASE WHEN is_normal = 0 THEN 1 ELSE 0 END), 0) AS abnormalCount,
+            IFNULL(SUM(CASE WHEN DATE(create_time) = CURDATE() THEN 1 ELSE 0 END), 0) AS todayNewCount,
+            IFNULL(SUM(CASE WHEN DATE_FORMAT(create_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m') THEN 1 ELSE 0 END), 0) AS monthNewCount
+        FROM `watch_device_info`
+        WHERE is_del = 0
+    </select>
 </mapper>