Browse Source

Merge branch 'saas_adminUi' of http://1.14.104.71:10880/txl/ylrz_saas_his_scrm_adminUI into saas_adminUi

云联一号 3 ngày trước cách đây
mục cha
commit
c24dfe3c83

+ 2 - 9
src/components/InlineTenantSelector/index.vue

@@ -56,15 +56,8 @@ export default {
       })
     },
     handleChange(val) {
-      this.$store.dispatch('tenant/setCompanyId', val || null)
-      const item = val ? this.tenantList.find(t => t.id === val) : null
-      this.$emit('change', val || null, item)
-    },
-    /** 供父组件「重置」时清空租户选择 */
-    reset() {
-      this.selectedId = null
-      this.$store.dispatch('tenant/setCompanyId', null)
-      this.$emit('change', null, null)
+      this.$store.dispatch('tenant/setTenantId', val)
+      this.$emit('change', val)
     }
   }
 }

+ 1 - 1
src/components/TenantSelector/index.vue

@@ -45,7 +45,7 @@ export default {
       })
     },
     handleTenantChange(val) {
-      this.$store.dispatch('tenant/setCompanyId', val)
+      this.$store.dispatch('tenant/setTenantId', val)
     }
   }
 }

+ 2 - 2
src/main.js

@@ -36,8 +36,8 @@ Vue.prototype.$runtimeConfig = {}
 // 全局方法挂载
 
 // 全局 $modal 插件(若依风格)
-import modal from '@/plugins/modal'
-Vue.use(modal)
+// import modal from '@/plugins/modal'
+// Vue.use(modal)
 
 import { VueJsonp } from 'vue-jsonp'
 Vue.use(VueJsonp)

+ 12 - 12
src/mixins/tenantMixin.js

@@ -1,31 +1,31 @@
 /**
  * 租户选择器全局混入
- * 在所有Vue组件中提供 this.$companyId 和 this.$setCompanyId
+ * 在所有Vue组件中提供 this.$tenantId 和 this.$setTenantId
  * 用法:
- *   - this.$companyId 获取当前选中的租户ID(可能为null)
- *   - this.$setCompanyId(id) 设置当前租户ID
- *   - 在API请求中: listData('/store/storeOrder', { companyId: this.$companyId, ...queryParams })
+ *   - this.$tenantId 获取当前选中的租户ID(可能为null)
+ *   - this.$setTenantId(id) 设置当前租户ID
+ *   - 在API请求中: listData('/store/storeOrder', { tenantId: this.$tenantId, ...queryParams })
  */
 export default {
   computed: {
-    $companyId() {
-      return this.$store.getters.selectedCompanyId
+    $tenantId() {
+      return this.$store.getters.selectedTenantId
     }
   },
   methods: {
-    $setCompanyId(companyId) {
-      this.$store.dispatch('tenant/setCompanyId', companyId)
+    $setTenantId(tenantId) {
+      this.$store.dispatch('tenant/setTenantId', tenantId)
     },
     /**
      * 构建带租户ID的查询参数
      * @param {Object} params 原始查询参数
-     * @returns {Object} 带companyId的查询参数(如果选中了租户)
+     * @returns {Object} 带tenantId的查询参数(如果选中了租户)
      */
     $withTenant(params = {}) {
-      if (this.$companyId) {
-        params.companyId = this.$companyId
+      if (this.$tenantId) {
+        params.tenantId = this.$tenantId
       }
       return params
     }
   }
-}
+}

+ 1 - 1
src/store/getters.js

@@ -14,6 +14,6 @@ const getters = {
   topbarRouters:state => state.permission.topbarRouters,
   defaultRoutes:state => state.permission.defaultRoutes,
   sidebarRouters:state => state.permission.sidebarRouters,
-  selectedCompanyId: state => state.tenant.selectedCompanyId,
+  selectedTenantId: state => state.tenant.selectedTenantId,
 }
 export default getters

+ 5 - 5
src/store/modules/tenant.js

@@ -1,16 +1,16 @@
 const state = {
-  selectedCompanyId: null
+  selectedTenantId: null
 }
 
 const mutations = {
-  SET_COMPANY_ID: (state, companyId) => {
-    state.selectedCompanyId = companyId
+  SET_TENANT_ID: (state, tenantId) => {
+    state.selectedTenantId = tenantId
   }
 }
 
 const actions = {
-  setCompanyId({ commit }, companyId) {
-    commit('SET_COMPANY_ID', companyId)
+  setTenantId({ commit }, tenantId) {
+    commit('SET_TENANT_ID', tenantId)
   }
 }
 

+ 6 - 0
src/utils/request.js

@@ -32,6 +32,12 @@ service.interceptors.request.use(config => {
   if (getToken() && !isToken) {
     config.headers['Authorization'] = 'Bearer ' + getToken() // 让每个请求携带自定义token 请根据实际情况自行修改
   }
+  // 内容审计模块: 当选择了租户时, 设置 datasource-type=saas 通知后端切库
+  const tenantId = store.getters.selectedTenantId
+  if (tenantId) {
+    config.headers['datasource-type'] = 'saas'
+    config.headers['tenant-id'] = tenantId
+  }
   // get请求映射params参数
   if (config.method === 'get' && config.params) {
     let url = config.url + '?';

+ 29 - 55
src/views/admin/article/index.vue

@@ -2,22 +2,8 @@
   <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">
-      <el-form-item label="租户名称" prop="companyId">
-        <el-select
-          v-model="queryParams.companyId"
-          placeholder="选择租户"
-          clearable
-          filterable
-          size="small"
-          style="width: 200px"
-        >
-          <el-option
-            v-for="item in companyList"
-            :key="item.companyId"
-            :label="item.companyName"
-            :value="item.companyId"
-          />
-        </el-select>
+      <el-form-item label="租户名称" prop="tenantId">
+        <inline-tenant-selector :key="tenantSelectorKey" @change="handleTenantChange" />
       </el-form-item>
       <el-form-item label="文章标题" prop="articleTitle">
         <el-input
@@ -139,7 +125,6 @@
       @pagination="getList"
     />
 
-    <!-- 详情弹窗 -->
     <el-dialog title="文章详情" :visible.sync="viewOpen" width="800px" append-to-body>
       <el-form :model="viewForm" label-width="100px">
         <el-form-item label="租户名称">
@@ -168,7 +153,6 @@
       </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="审计备注">
@@ -180,7 +164,6 @@
       </div>
     </el-dialog>
 
-    <!-- 统计弹窗 -->
     <el-dialog title="文章统计" :visible.sync="showStatistics" width="600px" append-to-body>
       <el-card>
         <el-row :gutter="20">
@@ -216,49 +199,39 @@
 
 <script>
 import { listAllArticles, listPendingArticles, getArticleInfo, auditArticle, deleteArticle, getArticleStatistics, exportAllArticles } from '@/api/admin/article'
-import { listAllCompanies } from '@/api/admin/sysCompany'
+import InlineTenantSelector from '@/components/InlineTenantSelector'
 
 export default {
   name: 'ArticleAdmin',
+  components: { InlineTenantSelector },
   data() {
     return {
-      // 遮罩层
       loading: true,
-      // 导出遮罩层
       exportLoading: false,
-      // 显示搜索条件
       showSearch: true,
-      // 总条数
       total: 0,
-      // 文章列表
       articleList: [],
-      // 租户列表
-      companyList: [],
-      // 统计数据
+      tenantSelectorKey: 0,
       statistics: {
         totalCount: 0,
         pendingCount: 0,
         approvedCount: 0,
         rejectedCount: 0
       },
-      // 状态选项
       statusOptions: [
         { value: 0, label: '待审计' },
         { value: 1, label: '已通过' },
         { value: 2, label: '已拒绝' }
       ],
-      // 查询参数
       queryParams: {
         pageNum: 1,
         pageSize: 10,
-        companyId: null,
+        tenantId: null,
         articleTitle: null,
         status: null
       },
-      // 详情弹窗
       viewOpen: false,
       viewForm: {},
-      // 审计弹窗
       auditOpen: false,
       auditTitle: '',
       auditForm: {
@@ -266,23 +239,20 @@ export default {
         status: null,
         auditRemark: ''
       },
-      // 统计弹窗
       showStatistics: false
     }
   },
   created() {
-    this.getCompanyList()
     this.getList()
   },
   methods: {
-    /** 获取租户列表 */
-    getCompanyList() {
-      listAllCompanies({ pageSize: 9999 }).then(response => {
-        this.companyList = response.rows || []
-      })
-    },
-    /** 查询列表 */
     getList() {
+      if (!this.queryParams.tenantId) {
+        this.articleList = []
+        this.total = 0
+        this.loading = false
+        return
+      }
       this.loading = true
       listAllArticles(this.queryParams).then(response => {
         this.articleList = response.rows
@@ -290,7 +260,6 @@ export default {
         this.loading = false
       })
     },
-    /** 查询待审计列表 */
     handlePending() {
       this.loading = true
       listPendingArticles().then(response => {
@@ -299,25 +268,34 @@ export default {
         this.loading = false
       })
     },
-    /** 搜索按钮操作 */
     handleQuery() {
+      if (!this.queryParams.tenantId) {
+        this.$message.warning('请先选择租户')
+        return
+      }
       this.queryParams.pageNum = 1
       this.getList()
     },
-    /** 重置按钮操作 */
+    handleTenantChange(val) {
+      if (val) {
+        this.queryParams.tenantId = val
+        this.handleQuery()
+      }
+    },
     resetQuery() {
       this.resetForm('queryForm')
-      this.queryParams.companyId = null
-      this.handleQuery()
+      this.articleList = []
+      this.total = 0
+      this.queryParams.tenantId = null
+      this.$store.dispatch('tenant/setTenantId', null)
+      this.tenantSelectorKey++
     },
-    /** 查看详情 */
     handleView(row) {
       getArticleInfo(row.articleId).then(response => {
         this.viewForm = response.data
         this.viewOpen = true
       })
     },
-    /** 审计文章 */
     handleAudit(row, status) {
       this.auditTitle = status === 1 ? '通过文章' : '拒绝文章'
       this.auditForm = {
@@ -327,7 +305,6 @@ export default {
       }
       this.auditOpen = true
     },
-    /** 提交审计 */
     submitAudit() {
       auditArticle(this.auditForm.articleId, this.auditForm.status, this.auditForm.auditRemark).then(response => {
         this.$message.success(this.auditForm.status === 1 ? '通过成功' : '拒绝成功')
@@ -335,7 +312,6 @@ export default {
         this.getList()
       })
     },
-    /** 删除文章 */
     handleDelete(row) {
       this.$confirm('确定删除文章 [' + row.articleTitle + '] 吗?', '提示', {
         confirmButtonText: '确定',
@@ -348,14 +324,12 @@ export default {
         })
       })
     },
-    /** 查看统计 */
     showStatisticsDialog() {
       getArticleStatistics().then(response => {
         this.statistics = response.data
         this.showStatistics = true
       })
     },
-    /** 导出按钒操作 */
     handleExport() {
       this.exportLoading = true
       exportAllArticles(this.queryParams).then(response => {
@@ -383,8 +357,8 @@ export default {
   display: block;
   margin-top: 5px;
   font-size: 14px;
-  color: #666;
+  color: #606266;
 }
 .mb16 { margin-bottom: 16px; }
 .filter-card { padding-bottom: 0; }
-</style>
+</style>

+ 18 - 11
src/views/admin/callRecord/index.vue

@@ -2,14 +2,8 @@
   <div class="app-container">
     <el-card shadow="never" class="mb16 filter-card">
     <el-form :model="queryParams" ref="queryForm" :inline="true" size="small">
-      <el-form-item label="租户名称" prop="companyName">
-        <el-input
-          v-model="queryParams.companyName"
-          placeholder="请输入租户名称"
-          clearable
-          size="small"
-          @keyup.enter.native="handleQuery"
-        />
+      <el-form-item label="租户名称" prop="tenantId">
+        <inline-tenant-selector @change="handleTenantChange" />
       </el-form-item>
       <el-form-item label="员工名称" prop="userName">
         <el-input
@@ -30,7 +24,6 @@
         />
       </el-form-item>
       <el-form-item>
-        <inline-tenant-selector @change="handleQuery" />
       <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>
@@ -299,7 +292,7 @@ export default {
       queryParams: {
         pageNum: 1,
         pageSize: 10,
-        companyName: null,
+        tenantId: null,
         userName: null,
         customerPhone: null
       },
@@ -331,6 +324,12 @@ export default {
   methods: {
     /** 查询列表 */
     getList() {
+      if (!this.queryParams.tenantId) {
+        this.callRecordList = []
+        this.total = 0
+        this.loading = false
+        return
+      }
       this.loading = true
       listAllCallRecords(this.queryParams).then(response => {
         this.callRecordList = response.rows
@@ -340,12 +339,20 @@ export default {
     },
     /** 搜索按钮操作 */
     handleQuery() {
+      if (!this.queryParams.tenantId) {
+        this.$message.warning('请先选择租户')
+        return
+      }
       this.queryParams.pageNum = 1
       this.getList()
     },
-    /** 重置按钮操作 */
+    handleTenantChange(val) {
+      this.queryParams.tenantId = val || null
+      this.handleQuery()
+    },
     resetQuery() {
       this.resetForm('queryForm')
+      this.queryParams.tenantId = null
       this.handleQuery()
     },
     /** 查看详情 */

+ 36 - 57
src/views/admin/live/index.vue

@@ -2,22 +2,8 @@
   <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">
-      <el-form-item label="租户名称" prop="companyId">
-        <el-select
-          v-model="queryParams.companyId"
-          placeholder="选择租户"
-          clearable
-          filterable
-          size="small"
-          style="width: 200px"
-        >
-          <el-option
-            v-for="item in companyList"
-            :key="item.companyId"
-            :label="item.companyName"
-            :value="item.companyId"
-          />
-        </el-select>
+      <el-form-item label="租户名称" prop="tenantId">
+        <inline-tenant-selector :key="tenantSelectorKey" @change="handleTenantChange" />
       </el-form-item>
       <el-form-item label="直播标题" prop="liveTitle">
         <el-input
@@ -88,8 +74,8 @@
       <el-table-column label="直播时长" align="center" prop="duration" min-width="90" />
       <el-table-column label="直播状态" align="center" prop="status" min-width="90">
         <template slot-scope="scope">
-          <el-tag v-if="scope.row.status == 0" type="success">直播中</el-tag>
-          <el-tag v-else-if="scope.row.status == 1" type="warning">未开始</el-tag>
+          <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>
         </template>
       </el-table-column>
@@ -139,8 +125,8 @@
           <span>{{ viewForm.duration }}</span>
         </el-form-item>
         <el-form-item label="直播状态">
-          <el-tag v-if="viewForm.status == 0" type="success">直播中</el-tag>
-          <el-tag v-else-if="viewForm.status == 1" type="warning">未开始</el-tag>
+          <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-form>
@@ -182,65 +168,50 @@
 
 <script>
 import { listAllLives, getLiveInfo, getLiveStatistics, exportAllLives } from '@/api/admin/live'
-import { listAllCompanies } from '@/api/admin/sysCompany'
+import InlineTenantSelector from '@/components/InlineTenantSelector'
 
 export default {
   name: 'LiveAdmin',
+  components: { InlineTenantSelector },
   data() {
     return {
-      // 遮罩层
-      loading: true,
-      // 导出遮罩层
+      loading: false,
       exportLoading: false,
-      // 显示搜索条件
       showSearch: true,
-      // 总条数
       total: 0,
-      // 直播列表
       liveList: [],
-      // 租户列表
-      companyList: [],
-      // 统计数据
       statistics: {
         totalCount: 0,
         totalViewerCount: 0,
         livingCount: 0,
         endedCount: 0
       },
-      // 状态选项
       statusOptions: [
-        { value: 0, label: '直播中' },
-        { value: 1, label: '未开始' },
-        { value: 2, label: '已结束' }
+        { value: 1, label: '待支付' },
+        { value: 2, label: '直播中' },
+        { value: 3, label: '已结束' }
       ],
-      // 查询参数
       queryParams: {
         pageNum: 1,
         pageSize: 10,
-        companyId: null,
+        tenantId: null,
         liveTitle: null,
         status: null
       },
-      // 详情弹窗
       viewOpen: false,
       viewForm: {},
-      // 统计弹窗
-      showStatistics: false
+      showStatistics: false,
+      tenantSelectorKey: 0
     }
   },
-  created() {
-    this.getCompanyList()
-    this.getList()
-  },
   methods: {
-    /** 获取租户列表 */
-    getCompanyList() {
-      listAllCompanies({ pageSize: 9999 }).then(response => {
-        this.companyList = response.rows || []
-      })
-    },
-    /** 查询列表 */
     getList() {
+      if (!this.queryParams.tenantId) {
+        this.liveList = []
+        this.total = 0
+        this.loading = false
+        return
+      }
       this.loading = true
       listAllLives(this.queryParams).then(response => {
         this.liveList = response.rows
@@ -248,32 +219,40 @@ export default {
         this.loading = false
       })
     },
-    /** 搜索按钮操作 */
+    handleTenantChange(val) {
+      this.queryParams.tenantId = val || null
+      if (val) {
+        this.handleQuery()
+      }
+    },
     handleQuery() {
+      if (!this.queryParams.tenantId) {
+        this.$message.warning('请先选择租户')
+        return
+      }
       this.queryParams.pageNum = 1
       this.getList()
     },
-    /** 重置按钮操作 */
     resetQuery() {
+      this.$store.dispatch('tenant/setTenantId', null)
       this.resetForm('queryForm')
-      this.queryParams.companyId = null
-      this.handleQuery()
+      this.queryParams.tenantId = null
+      this.liveList = []
+      this.total = 0
+      this.tenantSelectorKey++
     },
-    /** 查看详情 */
     handleView(row) {
       getLiveInfo(row.liveId).then(response => {
         this.viewForm = response.data
         this.viewOpen = true
       })
     },
-    /** 查看统计 */
     showStatisticsDialog() {
       getLiveStatistics().then(response => {
         this.statistics = response.data
         this.showStatistics = true
       })
     },
-    /** 导出按钒操作 */
     handleExport() {
       this.exportLoading = true
       exportAllLives(this.queryParams).then(response => {

+ 28 - 31
src/views/admin/liveVideo/index.vue

@@ -2,22 +2,8 @@
   <div class="app-container">
     <el-card shadow="never" class="mb16 filter-card">
       <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" size="small" label-width="100px">
-        <el-form-item label="租户名称" prop="companyId">
-          <el-select
-            v-model="queryParams.companyId"
-            placeholder="选择租户"
-            clearable
-            filterable
-            size="small"
-            style="width: 200px"
-          >
-            <el-option
-              v-for="item in companyList"
-              :key="item.companyId"
-              :label="item.companyName"
-              :value="item.companyId"
-            />
-          </el-select>
+        <el-form-item label="租户名称" prop="tenantId">
+          <inline-tenant-selector :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" />
@@ -51,36 +37,34 @@
 
 <script>
 import { listAllLiveVideos } from '@/api/admin/liveVideo'
-import { listAllCompanies } from '@/api/admin/sysCompany'
+import InlineTenantSelector from '@/components/InlineTenantSelector'
 
 export default {
   name: 'AdminLiveVideo',
+  components: { InlineTenantSelector },
   data() {
     return {
       loading: false,
       showSearch: true,
       list: [],
       total: 0,
-      companyList: [],
       queryParams: {
         pageNum: 1,
         pageSize: 10,
-        companyId: null,
+        tenantId: null,
         videoTitle: null
-      }
+      },
+      tenantSelectorKey: 0
     }
   },
-  created() {
-    this.getCompanyList()
-    this.getList()
-  },
   methods: {
-    getCompanyList() {
-      listAllCompanies({ pageSize: 9999 }).then(response => {
-        this.companyList = response.rows || []
-      })
-    },
     getList() {
+      if (!this.queryParams.tenantId) {
+        this.list = []
+        this.total = 0
+        this.loading = false
+        return
+      }
       this.loading = true
       listAllLiveVideos(this.queryParams).then(res => {
         this.list = res.rows
@@ -88,14 +72,27 @@ export default {
         this.loading = false
       }).catch(() => { this.loading = false })
     },
+    handleTenantChange(val) {
+      this.queryParams.tenantId = val || null
+      if (val) {
+        this.handleQuery()
+      }
+    },
     handleQuery() {
+      if (!this.queryParams.tenantId) {
+        this.$message.warning('请先选择租户')
+        return
+      }
       this.queryParams.pageNum = 1
       this.getList()
     },
     resetQuery() {
+      this.$store.dispatch('tenant/setTenantId', null)
       this.resetForm('queryForm')
-      this.queryParams.companyId = null
-      this.handleQuery()
+      this.queryParams.tenantId = null
+      this.list = []
+      this.total = 0
+      this.tenantSelectorKey++
     }
   }
 }

+ 2 - 0
src/views/admin/menu.js

@@ -15,6 +15,8 @@ const adminRoutes = {
     // 2. 租户管理
     { path: 'company', component: () => import('@/views/admin/sysCompany/index'), name: 'SysCompanyAdmin', meta: { title: '租户管理' } },
     { path: 'moduleUsage', component: () => import('@/views/admin/moduleUsage/index'), name: 'AdminModuleUsage', meta: { title: '租户模块使用统计' } },
+    { path: 'tenantMenu', component: () => import('@/views/admin/tenantMenu/index'), name: 'AdminTenantSysMenu', meta: { title: '租户管理端菜单' } },
+    { path: 'tenantCompany', component: () => import('@/views/admin/tenantCompany/index'), name: 'AdminTenantComMenu', meta: { title: '租户销售端菜单' } },
 
     // 3. 代理管理
     { path: 'proxy', component: () => import('@/views/admin/proxy/index'), name: 'AdminProxy', meta: { title: '代理管理' } },

+ 30 - 42
src/views/admin/product/index.vue

@@ -2,22 +2,8 @@
   <div class="app-container">
     <el-card shadow="never" class="mb16 filter-card">
       <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" size="small" label-width="100px">
-        <el-form-item label="租户名称" prop="companyId">
-          <el-select
-            v-model="queryParams.companyId"
-            placeholder="选择租户"
-            clearable
-            filterable
-            size="small"
-            style="width: 200px"
-          >
-            <el-option
-              v-for="item in companyList"
-              :key="item.companyId"
-              :label="item.companyName"
-              :value="item.companyId"
-            />
-          </el-select>
+        <el-form-item label="租户名称" prop="tenantId">
+          <inline-tenant-selector :key="tenantSelectorKey" @change="handleTenantChange" />
         </el-form-item>
         <el-form-item label="商品名称" prop="productName">
           <el-input
@@ -224,32 +210,24 @@
 
 <script>
 import { listAllProducts, listPendingProducts, getProductInfo, auditProduct, getProductStatistics, exportAllProducts } from '@/api/admin/product'
-import { listAllCompanies } from '@/api/admin/sysCompany'
+import InlineTenantSelector from '@/components/InlineTenantSelector'
 
 export default {
   name: 'ProductAdmin',
+  components: { InlineTenantSelector },
   data() {
     return {
-      // 遮罩层
-      loading: true,
-      // 导出遮罩层
+      loading: false,
       exportLoading: false,
-      // 显示搜索条件
       showSearch: true,
-      // 总条数
       total: 0,
-      // 商品列表
       productList: [],
-      // 租户列表
-      companyList: [],
-      // 统计数据
       statistics: {
         totalCount: 0,
         pendingCount: 0,
         onlineCount: 0,
         rejectedCount: 0
       },
-      // 状态选项
       statusOptions: [
         { value: 0, label: '待审核' },
         { value: 1, label: '已上架' },
@@ -259,7 +237,7 @@ export default {
       queryParams: {
         pageNum: 1,
         pageSize: 10,
-        companyId: null,
+        tenantId: null,
         productName: null,
         status: null
       },
@@ -275,22 +253,19 @@ export default {
         auditRemark: ''
       },
       // 统计弹窗
-      showStatistics: false
+      showStatistics: false,
+      // 租户选择器 key,用于重置时强制重建组件
+      tenantSelectorKey: 0
     }
   },
-  created() {
-    this.getCompanyList()
-    this.getList()
-  },
   methods: {
-    /** 获取租户列表 */
-    getCompanyList() {
-      listAllCompanies({ pageSize: 9999 }).then(response => {
-        this.companyList = response.rows || []
-      })
-    },
-    /** 查询列表 */
     getList() {
+      if (!this.queryParams.tenantId) {
+        this.productList = []
+        this.total = 0
+        this.loading = false
+        return
+      }
       this.loading = true
       listAllProducts(this.queryParams).then(response => {
         this.productList = response.rows
@@ -307,16 +282,29 @@ export default {
         this.loading = false
       })
     },
+    handleTenantChange(val) {
+      this.queryParams.tenantId = val || null
+      if (val) {
+        this.handleQuery()
+      }
+    },
     /** 搜索按钮操作 */
     handleQuery() {
+      if (!this.queryParams.tenantId) {
+        this.$message.warning('请先选择租户')
+        return
+      }
       this.queryParams.pageNum = 1
       this.getList()
     },
     /** 重置按钮操作 */
     resetQuery() {
+      this.$store.dispatch('tenant/setTenantId', null)
       this.resetForm('queryForm')
-      this.queryParams.companyId = null
-      this.handleQuery()
+      this.queryParams.tenantId = null
+      this.productList = []
+      this.total = 0
+      this.tenantSelectorKey++
     },
     /** 查看详情 */
     handleView(row) {

+ 25 - 3
src/views/admin/sms/index.vue

@@ -31,8 +31,8 @@
     <!-- 搜索栏 -->
     <el-card shadow="never" class="mb16 filter-card">
       <el-form :model="queryParams" ref="queryForm" :inline="true" size="small">
-        <el-form-item label="租户名称" prop="companyName">
-          <el-input v-model="queryParams.companyName" placeholder="请输入租户名称" clearable @keyup.enter.native="handleQuery" />
+        <el-form-item label="租户名称" prop="tenantId">
+          <inline-tenant-selector @change="handleTenantChange" />
         </el-form-item>
         <el-form-item>
           <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">查询</el-button>
@@ -110,9 +110,11 @@
 
 <script>
 import request from '@/utils/request'
+import InlineTenantSelector from '@/components/InlineTenantSelector'
 
 export default {
   name: 'AdminSms',
+  components: { InlineTenantSelector },
   data() {
     return {
       loading: false,
@@ -125,7 +127,8 @@ export default {
       queryParams: {
         pageNum: 1,
         pageSize: 10,
-        companyName: null
+        companyName: null,
+        tenantId: null
       },
       editVisible: false,
       editForm: {
@@ -149,6 +152,12 @@ export default {
   },
   methods: {
     getList() {
+      if (!this.queryParams.tenantId) {
+        this.smsList = []
+        this.total = 0
+        this.loading = false
+        return
+      }
       this.loading = true
       request({
         url: '/admin/sms-admin/list',
@@ -161,6 +170,10 @@ export default {
       }).catch(() => { this.loading = false })
     },
     getCount() {
+      if (!this.queryParams.tenantId) {
+        this.smsCount = {}
+        return
+      }
       request({
         url: '/admin/sms-admin/count',
         method: 'get'
@@ -169,11 +182,20 @@ export default {
       })
     },
     handleQuery() {
+      if (!this.queryParams.tenantId) {
+        this.$message.warning('请先选择租户')
+        return
+      }
       this.queryParams.pageNum = 1
       this.getList()
     },
+    handleTenantChange(val) {
+      this.queryParams.tenantId = val || null
+      this.handleQuery()
+    },
     resetQuery() {
       this.resetForm('queryForm')
+      this.queryParams.tenantId = null
       this.handleQuery()
     },
     handleEdit(row) {

+ 20 - 3
src/views/admin/smsOrder/index.vue

@@ -3,8 +3,8 @@
     <!-- 搜索栏 -->
     <el-card shadow="never" class="mb16 filter-card">
       <el-form :model="queryParams" ref="queryForm" :inline="true" size="small">
-        <el-form-item label="租户名称" prop="companyName">
-          <el-input v-model="queryParams.companyName" placeholder="请输入租户名称" clearable @keyup.enter.native="handleQuery" />
+        <el-form-item label="租户名称" prop="tenantId">
+          <inline-tenant-selector @change="handleTenantChange" />
         </el-form-item>
         <el-form-item>
           <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">查询</el-button>
@@ -78,9 +78,11 @@
 
 <script>
 import request from '@/utils/request'
+import InlineTenantSelector from '@/components/InlineTenantSelector'
 
 export default {
   name: 'AdminSmsOrder',
+  components: { InlineTenantSelector },
   data() {
     return {
       loading: false,
@@ -91,7 +93,7 @@ export default {
       queryParams: {
         pageNum: 1,
         pageSize: 10,
-        companyName: null
+        tenantId: null
       },
       detailVisible: false,
       detail: {}
@@ -102,6 +104,12 @@ export default {
   },
   methods: {
     getList() {
+      if (!this.queryParams.tenantId) {
+        this.orderList = []
+        this.total = 0
+        this.loading = false
+        return
+      }
       this.loading = true
       request({
         url: '/admin/sms-order/list',
@@ -114,11 +122,20 @@ export default {
       }).catch(() => { this.loading = false })
     },
     handleQuery() {
+      if (!this.queryParams.tenantId) {
+        this.$message.warning('请先选择租户')
+        return
+      }
       this.queryParams.pageNum = 1
       this.getList()
     },
+    handleTenantChange(val) {
+      this.queryParams.tenantId = val || null
+      this.handleQuery()
+    },
     resetQuery() {
       this.resetForm('queryForm')
+      this.queryParams.tenantId = null
       this.handleQuery()
     },
     handleDetail(row) {

+ 35 - 27
src/views/admin/storeOrder/index.vue

@@ -2,22 +2,8 @@
   <div class="app-container">
     <el-card shadow="never" class="mb16 filter-card">
       <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" size="small" label-width="100px">
-        <el-form-item label="租户名称" prop="companyId">
-          <el-select
-            v-model="queryParams.companyId"
-            placeholder="选择租户"
-            clearable
-            filterable
-            size="small"
-            style="width: 200px"
-          >
-            <el-option
-              v-for="item in companyList"
-              :key="item.companyId"
-              :label="item.companyName"
-              :value="item.companyId"
-            />
-          </el-select>
+        <el-form-item label="租户名称" prop="tenantId">
+          <inline-tenant-selector :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" />
@@ -51,25 +37,27 @@
 
 <script>
 import { listStoreOrder, exportStoreOrder } from '@/api/admin/storeOrder'
-import { listAllCompanies } from '@/api/admin/sysCompany'
+import InlineTenantSelector from '@/components/InlineTenantSelector'
 
 export default {
   name: 'AdminStoreOrder',
+  components: { InlineTenantSelector },
   data() {
     return {
       loading: false, exportLoading: false, showSearch: true,
-      list: [], total: 0, companyList: [],
-      queryParams: { pageNum: 1, pageSize: 10, orderNo: null, companyId: null }
+      list: [], total: 0,
+      tenantSelectorKey: 0,
+      queryParams: { pageNum: 1, pageSize: 10, orderNo: null, tenantId: null }
     }
   },
-  created() { this.getCompanyList(); this.getList() },
   methods: {
-    getCompanyList() {
-      listAllCompanies({ pageSize: 9999 }).then(response => {
-        this.companyList = response.rows || []
-      })
-    },
     getList() {
+      if (!this.queryParams.tenantId) {
+        this.list = []
+        this.total = 0
+        this.loading = false
+        return
+      }
       this.loading = true
       listStoreOrder(this.queryParams).then(res => {
         this.list = res.rows || []
@@ -77,8 +65,28 @@ export default {
         this.loading = false
       }).catch(() => { this.loading = false })
     },
-    handleQuery() { this.queryParams.pageNum = 1; this.getList() },
-    resetQuery() { this.resetForm('queryForm'); this.queryParams.companyId = null; this.handleQuery() },
+    handleTenantChange(val) {
+      if (val) {
+        this.queryParams.tenantId = val
+        this.handleQuery()
+      }
+    },
+    handleQuery() {
+      if (!this.queryParams.tenantId) {
+        this.$message.warning('请先选择租户')
+        return
+      }
+      this.queryParams.pageNum = 1
+      this.getList()
+    },
+    resetQuery() {
+      this.resetForm('queryForm')
+      this.list = []
+      this.total = 0
+      this.queryParams.tenantId = null
+      this.$store.dispatch('tenant/setTenantId', null)
+      this.tenantSelectorKey++
+    },
     handleExport() {
       this.exportLoading = true
       exportStoreOrder(this.queryParams).then(response => {

+ 17 - 13
src/views/admin/sysCompany/index.vue

@@ -244,7 +244,7 @@
       </div>
     </el-dialog>
 
-    <!-- ===== 菜单编辑弹窗 ===== -->
+    <!-- ===== 菜单编辑弹窗(租户列表:管理端菜单 / 销售菜单) ===== -->
     <el-dialog :title="menuDialog.title" :visible.sync="menuDialog.visible" width="560px" append-to-body destroy-on-close @opened="onMenuDialogOpened">
       <div v-loading="menuDialog.loading" class="menu-tree-scroll">
         <div v-if="menuDialog.treeReady && menuDialog.treeData.length" class="menu-tree-toolbar">
@@ -256,14 +256,17 @@
           ref="menuTree"
           :data="menuDialog.treeData"
           show-checkbox
-          node-key="id"
+          node-key="menuId"
           :props="menuTreeProps"
           :expand-on-click-node="false"
         >
           <span slot-scope="{ data }" class="custom-tree-node">
-            <span>{{ data.label }}</span>
-            <el-tag v-if="data.visible === '0'" type="success" size="mini" style="margin-left:8px">显示</el-tag>
-            <el-tag v-else type="info" size="mini" style="margin-left:8px">隐藏</el-tag>
+            <span>{{ data.menuName }}</span>
+            <el-tag v-if="data.menuType === 'M'" size="mini" style="margin-left:6px">目录</el-tag>
+            <el-tag v-else-if="data.menuType === 'C'" type="success" size="mini" style="margin-left:6px">菜单</el-tag>
+            <el-tag v-else-if="data.menuType === 'F'" type="warning" size="mini" style="margin-left:6px">按钮</el-tag>
+            <el-tag v-if="data.visible === '0'" type="success" size="mini" style="margin-left:6px">已分配</el-tag>
+            <el-tag v-else type="info" size="mini" style="margin-left:6px">未分配</el-tag>
           </span>
         </el-tree>
         <el-empty v-if="!menuDialog.loading && menuDialog.treeReady && !menuDialog.treeData.length" description="暂无菜单数据" />
@@ -379,7 +382,7 @@ export default {
         operateType: [{ required: true, message: '请选择操作类型', trigger: 'change' }],
         amount: [{ required: true, message: '请输入金额', trigger: 'blur' }]
       },
-      menuTreeProps: { label: 'label', children: 'children' },
+      menuTreeProps: { label: 'menuName', children: 'children' },
       // 菜单编辑弹窗
       menuDialog: {
         visible: false,
@@ -592,7 +595,7 @@ export default {
       const ids = []
       const walk = (list) => {
         (list || []).forEach(n => {
-          if (n.visible === '0') ids.push(n.id)
+          if (n.visible === '0') ids.push(n.menuId)
           if (n.children && n.children.length) walk(n.children)
         })
       }
@@ -618,7 +621,7 @@ export default {
       const tree = this.$refs.menuTree
       if (!tree) return
       this.walkMenuTreeNodes(this.menuDialog.treeData, n => {
-        const node = tree.getNode(n.id)
+        const node = tree.getNode(n.menuId)
         if (node) node.expanded = true
       })
     },
@@ -626,7 +629,7 @@ export default {
       const tree = this.$refs.menuTree
       if (!tree) return
       this.walkMenuTreeNodes(this.menuDialog.treeData, n => {
-        const node = tree.getNode(n.id)
+        const node = tree.getNode(n.menuId)
         if (node) node.expanded = false
       })
     },
@@ -645,12 +648,13 @@ export default {
         if (res.code !== 200) {
           return Promise.reject(new Error(res.msg || '加载菜单失败'))
         }
-        const menus = res.menus || []
-        // 延迟挂载树,先出弹窗再渲染,默认全部收起
-        d.treeData = menus
+        const flat = res.menus || []
+        // 与模板维护页相同:扁平数据 + handleTree(menuId) 建树,保证层级一致
+        const tree = this.handleTree(flat, 'menuId')
+        d.treeData = tree
         requestAnimationFrame(() => {
           d.treeReady = true
-          this.$nextTick(() => this.applyMenuTreeChecked(menus))
+          this.$nextTick(() => this.applyMenuTreeChecked(tree))
         })
       }).catch(err => {
         this.$message.error(err.message || '加载菜单失败')

+ 476 - 0
src/views/admin/tenantCompany/index.vue

@@ -0,0 +1,476 @@
+<template>
+  <div class="app-container">
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch">
+      <el-form-item label="菜单名称" prop="menuName">
+        <el-input
+          v-model="queryParams.menuName"
+          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="dict in statusOptions"
+            :key="dict.dictValue"
+            :label="dict.dictLabel"
+            :value="dict.dictValue"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
+        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
+      </el-form-item>
+    </el-form>
+
+    <el-row :gutter="10" class="mb8">
+      <el-col :span="1.5">
+        <el-button
+          type="primary"
+          plain
+          icon="el-icon-plus"
+          size="mini"
+          @click="handleAdd"
+          v-hasPermi="['system:menu:add']"
+        >新增</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button
+          type="info"
+          plain
+          icon="el-icon-sort"
+          size="mini"
+          @click="toggleExpandAll"
+        >展开/折叠</el-button>
+      </el-col>
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
+    </el-row>
+
+    <el-table
+      v-if="refreshTable"
+      v-loading="loading"
+      :data="menuList"
+      row-key="menuId"
+      :default-expand-all="isExpandAll"
+      :tree-props="{children: 'children', hasChildren: 'hasChildren'}"
+    >
+      <el-table-column prop="menuName" label="菜单名称" :show-overflow-tooltip="true" width="160"></el-table-column>
+      <el-table-column prop="menuType" label="类型" width="72" align="center">
+        <template slot-scope="scope">
+          <el-tag v-if="scope.row.menuType === 'M'" size="mini">目录</el-tag>
+          <el-tag v-else-if="scope.row.menuType === 'C'" type="success" size="mini">菜单</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column prop="icon" label="图标" align="center" width="100">
+        <template slot-scope="scope">
+          <svg-icon :icon-class="scope.row.icon" />
+        </template>
+      </el-table-column>
+      <el-table-column prop="orderNum" label="排序" width="60"></el-table-column>
+      <el-table-column prop="perms" label="权限标识" :show-overflow-tooltip="true"></el-table-column>
+      <el-table-column prop="component" label="组件路径" :show-overflow-tooltip="true"></el-table-column>
+      <el-table-column prop="status" label="状态" width="80">
+        <template slot-scope="scope">
+          <dict-tag :options="statusOptions" :value="scope.row.status"/>
+        </template>
+      </el-table-column>
+      <el-table-column label="创建时间" align="center" prop="createTime">
+        <template slot-scope="scope">
+          <span>{{ parseTime(scope.row.createTime) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
+        <template slot-scope="scope">
+          <el-button size="mini"
+                     type="text"
+                     icon="el-icon-edit"
+                     @click="handleUpdate(scope.row)"
+                     v-hasPermi="['system:menu:edit']"
+          >修改</el-button>
+          <el-button
+            size="mini"
+            type="text"
+            icon="el-icon-plus"
+            @click="handleAdd(scope.row)"
+            v-hasPermi="['system:menu:add']"
+          >新增</el-button>
+          <el-button
+            size="mini"
+            type="text"
+            icon="el-icon-delete"
+            @click="handleDelete(scope.row)"
+            v-hasPermi="['system:menu:remove']"
+          >删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <!-- 添加或修改菜单对话框 -->
+    <el-dialog :title="title" :visible.sync="open" width="680px" append-to-body>
+      <el-form ref="form" :model="form" :rules="rules" label-width="100px">
+        <el-row>
+          <el-col :span="24">
+            <el-form-item label="上级菜单">
+              <treeselect
+                v-model="form.parentId"
+                :options="menuOptions"
+                :normalizer="normalizer"
+                :show-count="true"
+                placeholder="选择上级菜单"
+              />
+            </el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item label="菜单类型" prop="menuType">
+              <el-radio-group v-model="form.menuType">
+                <el-radio label="M">目录</el-radio>
+                <el-radio label="C">菜单</el-radio>
+                <el-radio label="F">按钮</el-radio>
+              </el-radio-group>
+            </el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item v-if="form.menuType != 'F'" label="菜单图标">
+              <el-popover
+                placement="bottom-start"
+                width="460"
+                trigger="click"
+                @show="$refs['iconSelect'].reset()"
+              >
+                <IconSelect ref="iconSelect" @selected="selected" />
+                <el-input slot="reference" v-model="form.icon" placeholder="点击选择图标" readonly>
+                  <svg-icon
+                    v-if="form.icon"
+                    slot="prefix"
+                    :icon-class="form.icon"
+                    class="el-input__icon"
+                    style="height: 32px;width: 16px;"
+                  />
+                  <i v-else slot="prefix" class="el-icon-search el-input__icon" />
+                </el-input>
+              </el-popover>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="菜单名称" prop="menuName">
+              <el-input v-model="form.menuName" placeholder="请输入菜单名称" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="显示排序" prop="orderNum">
+              <el-input-number v-model="form.orderNum" controls-position="right" :min="0" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item v-if="form.menuType != 'F'">
+              <span slot="label">
+                <el-tooltip content="选择是外链则路由地址需要以`http(s)://`开头" placement="top">
+                <i class="el-icon-question"></i>
+                </el-tooltip>
+                是否外链
+              </span>
+              <el-radio-group v-model="form.isFrame">
+                <el-radio label="0">是</el-radio>
+                <el-radio label="1">否</el-radio>
+              </el-radio-group>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item v-if="form.menuType != 'F'" prop="path">
+              <span slot="label">
+                <el-tooltip content="访问的路由地址,如:`user`,如外网地址需内链访问则以`http(s)://`开头" placement="top">
+                <i class="el-icon-question"></i>
+                </el-tooltip>
+                路由地址
+              </span>
+              <el-input v-model="form.path" placeholder="请输入路由地址" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12" v-if="form.menuType == 'C'">
+            <el-form-item prop="component">
+              <span slot="label">
+                <el-tooltip content="访问的组件路径,如:`system/user/index`,默认在`views`目录下" placement="top">
+                <i class="el-icon-question"></i>
+                </el-tooltip>
+                组件路径
+              </span>
+              <el-input v-model="form.component" placeholder="请输入组件路径" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item v-if="form.menuType != 'M'">
+              <el-input v-model="form.perms" placeholder="请输入权限标识" maxlength="100" />
+              <span slot="label">
+                <el-tooltip content="控制器中定义的权限字符,如:@PreAuthorize(`@ss.hasPermi('system:user:list')`)" placement="top">
+                <i class="el-icon-question"></i>
+                </el-tooltip>
+                权限字符
+              </span>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item v-if="form.menuType == 'C'">
+              <el-input v-model="form.query" placeholder="请输入路由参数" maxlength="255" />
+              <span slot="label">
+                <el-tooltip content='访问路由的默认传递参数,如:`{"id": 1, "name": "ry"}`' placement="top">
+                <i class="el-icon-question"></i>
+                </el-tooltip>
+                路由参数
+              </span>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item v-if="form.menuType == 'C'">
+              <span slot="label">
+                <el-tooltip content="选择是则会被`keep-alive`缓存,需要匹配组件的`name`和地址保持一致" placement="top">
+                <i class="el-icon-question"></i>
+                </el-tooltip>
+                是否缓存
+              </span>
+              <el-radio-group v-model="form.isCache">
+                <el-radio label="0">缓存</el-radio>
+                <el-radio label="1">不缓存</el-radio>
+              </el-radio-group>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item v-if="form.menuType != 'F'">
+              <span slot="label">
+                <el-tooltip content="选择隐藏则路由将不会出现在侧边栏,但仍然可以访问" placement="top">
+                <i class="el-icon-question"></i>
+                </el-tooltip>
+                显示状态
+              </span>
+              <el-radio-group v-model="form.visible">
+                <el-radio
+                  v-for="dict in visibleOptions"
+                  :key="dict.dictValue"
+                  :label="dict.dictValue"
+                >{{dict.dictLabel}}</el-radio>
+              </el-radio-group>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item v-if="form.menuType != 'F'">
+              <span slot="label">
+                <el-tooltip content="选择停用则路由将不会出现在侧边栏,也不能被访问" placement="top">
+                <i class="el-icon-question"></i>
+                </el-tooltip>
+                菜单状态
+              </span>
+              <el-radio-group v-model="form.status">
+                <el-radio
+                  v-for="dict in statusOptions"
+                  :key="dict.dictValue"
+                  :label="dict.dictValue"
+                >{{dict.dictLabel}}</el-radio>
+              </el-radio-group>
+            </el-form-item>
+          </el-col>
+        </el-row>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" @click="submitForm">确 定</el-button>
+        <el-button @click="cancel">取 消</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { delTenantComMenu,tenantComMenu,addTenantComMenu,updateTenantComMenu,getTenantComMenu } from "@/api/system/menu";
+import Treeselect from "@riophae/vue-treeselect";
+import "@riophae/vue-treeselect/dist/vue-treeselect.css";
+import IconSelect from "@/components/IconSelect";
+
+/**
+ * 租户销售端菜单模板维护(主库 tenant_company_menu)
+ * 新建租户时从此模板复制到租户库 company_menu
+ */
+export default {
+  name: "AdminTenantComMenu",
+  components: { Treeselect, IconSelect },
+  data() {
+    return {
+      // 遮罩层
+      loading: true,
+      // 显示搜索条件
+      showSearch: true,
+      // 菜单表格树数据
+      menuList: [],
+      // 菜单树选项
+      menuOptions: [],
+      // 弹出层标题
+      title: "",
+      // 是否显示弹出层
+      open: false,
+      // 是否展开,默认全部折叠
+      isExpandAll: false,
+      // 重新渲染表格状态
+      refreshTable: true,
+      // 显示状态数据字典
+      visibleOptions: [],
+      // 菜单状态数据字典
+      statusOptions: [],
+      // 查询参数
+      queryParams: {
+        menuName: undefined,
+        visible: undefined
+      },
+      // 表单参数
+      form: {},
+      // 表单校验
+      rules: {
+        menuName: [
+          { required: true, message: "菜单名称不能为空", trigger: "blur" }
+        ],
+        orderNum: [
+          { required: true, message: "菜单顺序不能为空", trigger: "blur" }
+        ],
+        path: [
+          { required: true, message: "路由地址不能为空", trigger: "blur" }
+        ]
+      }
+    };
+  },
+  created() {
+    this.getList();
+    this.getDicts("sys_show_hide").then(response => {
+      this.visibleOptions = response.data;
+    });
+    this.getDicts("sys_normal_disable").then(response => {
+      this.statusOptions = response.data;
+    });
+  },
+  methods: {
+    // 选择图标
+    selected(name) {
+      this.form.icon = name;
+    },
+    /** 查询菜单列表 */
+    getList() {
+      this.loading = true;
+      tenantComMenu(this.queryParams).then(response => {
+        this.menuList = this.handleTree(response.data, "menuId");
+        this.loading = false;
+      });
+    },
+    /** 转换菜单数据结构 */
+    normalizer(node) {
+      if (node.children && !node.children.length) {
+        delete node.children;
+      }
+      return {
+        id: node.menuId,
+        label: node.menuName,
+        children: node.children
+      };
+    },
+    /** 查询菜单下拉树结构 */
+    getTreeselect() {
+      tenantComMenu().then(response => {
+        this.menuOptions = [];
+        const menu = { menuId: 0, menuName: '主类目', children: [] };
+        menu.children = this.handleTree(response.data, "menuId");
+        this.menuOptions.push(menu);
+      });
+    },
+    // 取消按钮
+    cancel() {
+      this.open = false;
+      this.reset();
+    },
+    // 表单重置
+    reset() {
+      this.form = {
+        menuId: undefined,
+        parentId: 0,
+        menuName: undefined,
+        icon: undefined,
+        menuType: "M",
+        orderNum: undefined,
+        isFrame: "1",
+        isCache: "0",
+        visible: "0",
+        status: "0"
+      };
+      this.resetForm("form");
+    },
+    /** 搜索按钮操作 */
+    handleQuery() {
+      this.getList();
+    },
+    /** 重置按钮操作 */
+    resetQuery() {
+      this.resetForm("queryForm");
+      this.handleQuery();
+    },
+    /** 新增按钮操作 */
+    handleAdd(row) {
+      this.reset();
+      this.getTreeselect();
+      if (row != null && row.menuId) {
+        this.form.parentId = row.menuId;
+      } else {
+        this.form.parentId = 0;
+      }
+      this.open = true;
+      this.title = "添加菜单";
+    },
+    /** 展开/折叠操作 */
+    toggleExpandAll() {
+      this.refreshTable = false;
+      this.isExpandAll = !this.isExpandAll;
+      this.$nextTick(() => {
+        this.refreshTable = true;
+      });
+    },
+    /** 修改按钮操作 */
+    handleUpdate(row) {
+      this.reset();
+      this.getTreeselect();
+      getTenantComMenu(row.menuId).then(response => {
+        this.form = response.data;
+        this.open = true;
+        this.title = "修改菜单";
+      });
+    },
+    /** 提交按钮 */
+    submitForm: function() {
+      this.$refs["form"].validate(valid => {
+        if (valid) {
+          if (this.form.menuId != undefined) {
+            updateTenantComMenu(this.form).then(response => {
+              this.msgSuccess("修改成功");
+              this.open = false;
+              this.getList();
+            });
+          } else {
+            addTenantComMenu(this.form).then(response => {
+              this.msgSuccess("新增成功");
+              this.open = false;
+              this.getList();
+            });
+          }
+        }
+      });
+    },
+    /** 删除按钮操作 */
+    handleDelete(row) {
+      this.$confirm('是否确认删除名称为"' + row.menuName + '"的数据项?', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(function() {
+        return delTenantComMenu(row.menuId);
+      }).then(() => {
+        this.getList();
+        this.msgSuccess("删除成功");
+      }).catch(() => {});
+    }
+  }
+};
+</script>

+ 637 - 319
src/views/admin/tenantDict/sync/index.vue

@@ -1,319 +1,637 @@
-<template>

-  <div class="dict-tab-panel sync-panel">

-    <el-row :gutter="16" class="mb16">

-      <el-col :span="8">

-        <div class="sync-mode-card merge">

-          <div class="sync-mode-title">MERGE · 合并</div>

-          <div class="sync-mode-desc">推荐。新增缺失项,更新平台管控字段,保留租户扩展</div>

-        </div>

-      </el-col>

-      <el-col :span="8">

-        <div class="sync-mode-card append">

-          <div class="sync-mode-title">APPEND · 追加</div>

-          <div class="sync-mode-desc">仅追加模板中不存在的数据,不修改已有项</div>

-        </div>

-      </el-col>

-      <el-col :span="8">

-        <div class="sync-mode-card overwrite">

-          <div class="sync-mode-title">OVERWRITE · 覆盖</div>

-          <div class="sync-mode-desc">覆盖各租户中「平台管控」的字典项,请谨慎使用</div>

-        </div>

-      </el-col>

-    </el-row>

-

-    <el-card shadow="never" class="mb16 sync-config-card">

-      <div slot="header" class="card-header">

-        <span><i class="el-icon-setting" /> 同步配置</span>

-      </div>

-      <el-form ref="syncForm" :model="syncForm" label-width="100px" size="small">

-        <el-row :gutter="20">

-          <el-col :span="8">

-            <el-form-item label="同步模式" required>

-              <el-select v-model="syncForm.syncMode" style="width: 100%">

-                <el-option label="MERGE - 合并(推荐)" value="MERGE" />

-                <el-option label="APPEND - 仅追加缺失项" value="APPEND" />

-                <el-option label="OVERWRITE - 覆盖平台管控项" value="OVERWRITE" />

-              </el-select>

-            </el-form-item>

-          </el-col>

-          <el-col :span="8">

-            <el-form-item label="租户范围" required>

-              <el-select v-model="syncForm.scopeType" style="width: 100%">

-                <el-option label="全部启用租户" value="ALL" />

-                <el-option label="仅指定租户" value="INCLUDE" />

-                <el-option label="排除指定租户" value="EXCLUDE" />

-              </el-select>

-            </el-form-item>

-          </el-col>

-          <el-col :span="8">

-            <el-form-item label="并行执行">

-              <div class="parallel-row">

-                <el-switch v-model="syncForm.parallel" />

-                <el-input-number v-if="syncForm.parallel" v-model="syncForm.threads" :min="1" :max="16" size="small" controls-position="right" />

-              </div>

-            </el-form-item>

-          </el-col>

-        </el-row>

-        <el-row :gutter="20">

-          <el-col :span="12">

-            <el-form-item label="指定租户" v-if="syncForm.scopeType !== 'ALL'">

-              <el-select v-model="syncForm.tenantIds" multiple filterable placeholder="选择租户" style="width: 100%">

-                <el-option v-for="t in tenantOptions" :key="t.id" :label="t.tenantName + ' (' + t.tenantCode + ')'" :value="t.id" />

-              </el-select>

-            </el-form-item>

-          </el-col>

-          <el-col :span="12">

-            <el-form-item label="字典类型">

-              <el-select v-model="syncForm.dictTypes" multiple filterable clearable placeholder="空 = 全部模板类型" style="width: 100%">

-                <el-option v-for="t in templateTypeOptions" :key="t.dictType" :label="t.dictName + ' / ' + t.dictType" :value="t.dictType" />

-              </el-select>

-            </el-form-item>

-          </el-col>

-        </el-row>

-        <el-alert

-          v-if="syncForm.syncMode === 'OVERWRITE'"

-          type="warning"

-          :closable="false"

-          show-icon

-          class="mb12"

-          title="OVERWRITE 将覆盖各租户中「平台管控」的字典项,租户自有扩展项不受影响"

-        />

-        <el-form-item v-if="syncForm.syncMode === 'OVERWRITE'" class="overwrite-check">

-          <el-checkbox v-model="syncForm.overwriteConfirm">我已了解上述风险,确认执行覆盖同步</el-checkbox>

-        </el-form-item>

-        <el-form-item class="action-row">

-          <el-button type="info" plain icon="el-icon-view" @click="handleSync(true)" v-hasPermi="['tenant:dict:sync']">全量预览</el-button>

-          <el-button type="warning" icon="el-icon-upload2" @click="handleSync(false)" v-hasPermi="['tenant:dict:sync']">开始同步</el-button>

-        </el-form-item>

-      </el-form>

-    </el-card>

-

-    <el-card shadow="never" v-if="previewList.length" class="mb16 result-card">

-      <div slot="header" class="card-header">

-        <span><i class="el-icon-data-analysis" /> 预览结果</span>

-        <el-tag size="mini" type="info" effect="plain">未写入数据库</el-tag>

-      </div>

-      <el-table :data="previewList" border size="small" class="dict-table">

-        <el-table-column label="租户" prop="tenantName" min-width="120" />

-        <el-table-column label="编码" prop="tenantCode" width="110" />

-        <el-table-column label="新增类型" prop="typeAdded" width="88" align="center">

-          <template slot-scope="s"><span class="stat-num add">{{ s.row.typeAdded || 0 }}</span></template>

-        </el-table-column>

-        <el-table-column label="更新类型" prop="typeUpdated" width="88" align="center">

-          <template slot-scope="s"><span class="stat-num update">{{ s.row.typeUpdated || 0 }}</span></template>

-        </el-table-column>

-        <el-table-column label="新增数据" prop="dataAdded" width="88" align="center">

-          <template slot-scope="s"><span class="stat-num add">{{ s.row.dataAdded || 0 }}</span></template>

-        </el-table-column>

-        <el-table-column label="更新数据" prop="dataUpdated" width="88" align="center">

-          <template slot-scope="s"><span class="stat-num update">{{ s.row.dataUpdated || 0 }}</span></template>

-        </el-table-column>

-        <el-table-column label="跳过" prop="dataSkipped" width="72" align="center" />

-        <el-table-column label="移除" prop="dataRemoved" width="72" align="center" />

-      </el-table>

-    </el-card>

-

-    <el-card shadow="never" v-if="taskNo" class="result-card">

-      <div slot="header" class="card-header">

-        <span><i class="el-icon-loading" v-if="taskInfo && taskInfo.status === 'RUNNING'" /><i class="el-icon-finished" v-else /> 同步任务 {{ taskNo }}</span>

-        <el-button type="text" icon="el-icon-refresh" @click="pollTask">刷新状态</el-button>

-      </div>

-      <el-descriptions :column="4" border size="small" v-if="taskInfo" class="task-summary">

-        <el-descriptions-item label="状态"><el-tag :type="taskStatusType" size="small">{{ taskInfo.status }}</el-tag></el-descriptions-item>

-        <el-descriptions-item label="模式">{{ taskInfo.syncMode }}</el-descriptions-item>

-        <el-descriptions-item label="成功 / 总数">{{ taskInfo.successTenants }} / {{ taskInfo.totalTenants }}</el-descriptions-item>

-        <el-descriptions-item label="失败">

-          <span :class="{ 'stat-fail': taskInfo.failedTenants > 0 }">{{ taskInfo.failedTenants }}</span>

-        </el-descriptions-item>

-      </el-descriptions>

-      <el-table v-if="taskInfo && taskInfo.details && taskInfo.details.length" :data="taskInfo.details" border size="small" class="dict-table mt12">

-        <el-table-column label="租户" prop="tenantName" min-width="120" />

-        <el-table-column label="状态" prop="status" width="90" align="center">

-          <template slot-scope="s">

-            <el-tag size="mini" :type="s.row.status === 'SUCCESS' ? 'success' : (s.row.status === 'FAILED' ? 'danger' : 'warning')">{{ s.row.status }}</el-tag>

-          </template>

-        </el-table-column>

-        <el-table-column label="新增类型" prop="typeAdded" width="88" align="center" />

-        <el-table-column label="更新类型" prop="typeUpdated" width="88" align="center" />

-        <el-table-column label="新增数据" prop="dataAdded" width="88" align="center" />

-        <el-table-column label="更新数据" prop="dataUpdated" width="88" align="center" />

-        <el-table-column label="跳过" prop="dataSkipped" width="72" align="center" />

-        <el-table-column label="错误" prop="errorMsg" min-width="160" :show-overflow-tooltip="true" />

-      </el-table>

-    </el-card>

-

-    <el-empty v-if="!previewList.length && !taskNo" description="配置同步参数后,可先「全量预览」查看差异,再「开始同步」" :image-size="100" />

-  </div>

-</template>

-

-<script>

-import { listCompanyOptions } from '@/api/admin/sysCompany'

-import { runDictSync, getDictSyncTask, previewDictSync, templateDictTypeOptions } from '@/api/tenant/dict'

-

-export default {

-  name: 'AdminTenantDictSync',

-  data() {

-    return {

-      tenantOptions: [],

-      templateTypeOptions: [],

-      previewList: [],

-      taskNo: '',

-      taskInfo: null,

-      pollTimer: null,

-      syncForm: {

-        syncMode: 'MERGE',

-        scopeType: 'ALL',

-        tenantIds: [],

-        dictTypes: [],

-        parallel: false,

-        threads: 4,

-        overwriteConfirm: false,

-        dryRun: false

-      }

-    }

-  },

-  computed: {

-    taskStatusType() {

-      const s = this.taskInfo && this.taskInfo.status

-      if (s === 'SUCCESS') return 'success'

-      if (s === 'FAILED') return 'danger'

-      if (s === 'PARTIAL') return 'warning'

-      return 'info'

-    }

-  },

-  created() {

-    listCompanyOptions().then(r => { this.tenantOptions = r.data || [] })

-    templateDictTypeOptions().then(r => {

-      this.templateTypeOptions = r.data || []

-      if (!this.templateTypeOptions.length) {

-        this.$message.warning('平台模板为空,请先在「平台模板」Tab 维护字典模板后再同步')

-      }

-    })

-  },

-  beforeDestroy() {

-    if (this.pollTimer) clearInterval(this.pollTimer)

-  },

-  methods: {

-    buildPayload(dryRun) {

-      return {

-        syncMode: this.syncForm.syncMode,

-        scopeType: this.syncForm.scopeType,

-        tenantIds: this.syncForm.scopeType === 'ALL' ? [] : this.syncForm.tenantIds,

-        dictTypes: this.syncForm.dictTypes,

-        parallel: this.syncForm.parallel,

-        threads: this.syncForm.threads,

-        overwriteConfirm: this.syncForm.overwriteConfirm,

-        dryRun: dryRun

-      }

-    },

-    handlePreview() {

-      if (this.syncForm.scopeType === 'INCLUDE' && (!this.syncForm.tenantIds || !this.syncForm.tenantIds.length)) {

-        this.$message.warning('请选择要预览的租户')

-        return

-      }

-      const tenantId = this.syncForm.scopeType === 'INCLUDE' ? this.syncForm.tenantIds[0] : (this.tenantOptions[0] && this.tenantOptions[0].id)

-      if (!tenantId) {

-        this.$message.warning('无可用租户')

-        return

-      }

-      previewDictSync(tenantId, this.syncForm.dictTypes).then(r => {

-        this.previewList = r.data || []

-        this.$message.success('预览完成(仅首个租户示例,完整预览请用 dryRun 同步)')

-      })

-    },

-    handleSync(dryRun) {

-      if (this.syncForm.syncMode === 'OVERWRITE' && !this.syncForm.overwriteConfirm) {

-        this.$message.error('OVERWRITE 模式请先勾选确认')

-        return

-      }

-      if (this.syncForm.scopeType !== 'ALL' && (!this.syncForm.tenantIds || !this.syncForm.tenantIds.length)) {

-        this.$message.warning('请选择租户')

-        return

-      }

-      this.$confirm(dryRun ? '确认预览同步差异?' : '确认开始字典同步任务?', '提示', { type: 'warning' }).then(() => {

-        return runDictSync(this.buildPayload(dryRun))

-      }).then(r => {

-        const data = r.data || {}

-        if (data.dryRun) {

-          this.previewList = data.preview || []

-          if (!this.previewList.length) {

-            this.$message.warning('预览完成:无差异(请确认平台模板中已有字典类型和数据)')

-          } else {

-            this.$message.success('全量预览完成,共 ' + this.previewList.length + ' 个租户')

-          }

-          return

-        }

-        if (!data.taskNo) {

-          this.$message.error('同步任务创建失败,请检查后端日志')

-          return

-        }

-        this.taskNo = data.taskNo

-        this.taskInfo = { status: 'RUNNING', syncMode: data.syncMode, totalTenants: data.totalTenants, successTenants: 0, failedTenants: 0, details: [] }

-        this.startPoll()

-        this.$message.success('同步任务已启动:' + data.taskNo)

-      }).catch(err => {

-        const msg = (err && err.message) || (typeof err === 'string' ? err : '同步失败')

-        if (msg && msg !== 'cancel') this.$message.error(msg)

-      })

-    },

-    startPoll() {

-      if (this.pollTimer) clearInterval(this.pollTimer)

-      this.pollTask()

-      this.pollTimer = setInterval(() => this.pollTask(), 3000)

-    },

-    pollTask() {

-      if (!this.taskNo) return

-      getDictSyncTask(this.taskNo).then(r => {

-        this.taskInfo = r.data

-        if (this.taskInfo && ['SUCCESS', 'FAILED', 'PARTIAL'].includes(this.taskInfo.status)) {

-          clearInterval(this.pollTimer)

-          this.pollTimer = null

-        }

-      })

-    }

-  }

-}

-</script>

-

-<style scoped>

-.sync-panel { padding-top: 4px; }

-.mb12 { margin-bottom: 12px; }

-.mb16 { margin-bottom: 16px; }

-.mt12 { margin-top: 12px; }

-.dict-table { width: 100%; }

-.card-header {

-  display: flex;

-  align-items: center;

-  justify-content: space-between;

-  font-weight: 600;

-  color: #303133;

-}

-.card-header i { margin-right: 6px; color: #409eff; }

-.sync-mode-card {

-  padding: 14px 16px;

-  border-radius: 8px;

-  border: 1px solid #ebeef5;

-  background: #fff;

-  height: 100%;

-  transition: box-shadow 0.2s;

-}

-.sync-mode-card:hover { box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06); }

-.sync-mode-title { font-size: 14px; font-weight: 600; margin-bottom: 6px; }

-.sync-mode-desc { font-size: 12px; color: #909399; line-height: 1.5; }

-.sync-mode-card.merge { border-left: 3px solid #67c23a; }

-.sync-mode-card.merge .sync-mode-title { color: #67c23a; }

-.sync-mode-card.append { border-left: 3px solid #409eff; }

-.sync-mode-card.append .sync-mode-title { color: #409eff; }

-.sync-mode-card.overwrite { border-left: 3px solid #e6a23c; }

-.sync-mode-card.overwrite .sync-mode-title { color: #e6a23c; }

-.sync-config-card >>> .el-card__body { padding-top: 8px; }

-.parallel-row { display: flex; align-items: center; gap: 12px; }

-.overwrite-check { margin-bottom: 0; }

-.action-row { margin-bottom: 0; margin-top: 4px; }

-.stat-num { font-weight: 600; }

-.stat-num.add { color: #67c23a; }

-.stat-num.update { color: #409eff; }

-.stat-fail { color: #f56c6c; font-weight: 600; }

-.task-summary { margin-bottom: 0; }

-</style>

-
+<template>
+
+  <div class="dict-tab-panel sync-panel">
+
+    <el-row :gutter="16" class="mb16">
+
+      <el-col :span="8">
+
+        <div class="sync-mode-card merge">
+
+          <div class="sync-mode-title">MERGE · 合并</div>
+
+          <div class="sync-mode-desc">推荐。新增缺失项,更新平台管控字段,保留租户扩展</div>
+
+        </div>
+
+      </el-col>
+
+      <el-col :span="8">
+
+        <div class="sync-mode-card append">
+
+          <div class="sync-mode-title">APPEND · 追加</div>
+
+          <div class="sync-mode-desc">仅追加模板中不存在的数据,不修改已有项</div>
+
+        </div>
+
+      </el-col>
+
+      <el-col :span="8">
+
+        <div class="sync-mode-card overwrite">
+
+          <div class="sync-mode-title">OVERWRITE · 覆盖</div>
+
+          <div class="sync-mode-desc">覆盖各租户中「平台管控」的字典项,请谨慎使用</div>
+
+        </div>
+
+      </el-col>
+
+    </el-row>
+
+
+
+    <el-card shadow="never" class="mb16 sync-config-card">
+
+      <div slot="header" class="card-header">
+
+        <span><i class="el-icon-setting" /> 同步配置</span>
+
+      </div>
+
+      <el-form ref="syncForm" :model="syncForm" label-width="100px" size="small">
+
+        <el-row :gutter="20">
+
+          <el-col :span="8">
+
+            <el-form-item label="同步模式" required>
+
+              <el-select v-model="syncForm.syncMode" style="width: 100%">
+
+                <el-option label="MERGE - 合并(推荐)" value="MERGE" />
+
+                <el-option label="APPEND - 仅追加缺失项" value="APPEND" />
+
+                <el-option label="OVERWRITE - 覆盖平台管控项" value="OVERWRITE" />
+
+              </el-select>
+
+            </el-form-item>
+
+          </el-col>
+
+          <el-col :span="8">
+
+            <el-form-item label="租户范围" required>
+
+              <el-select v-model="syncForm.scopeType" style="width: 100%">
+
+                <el-option label="全部启用租户" value="ALL" />
+
+                <el-option label="仅指定租户" value="INCLUDE" />
+
+                <el-option label="排除指定租户" value="EXCLUDE" />
+
+              </el-select>
+
+            </el-form-item>
+
+          </el-col>
+
+          <el-col :span="8">
+
+            <el-form-item label="并行执行">
+
+              <div class="parallel-row">
+
+                <el-switch v-model="syncForm.parallel" />
+
+                <el-input-number v-if="syncForm.parallel" v-model="syncForm.threads" :min="1" :max="16" size="small" controls-position="right" />
+
+              </div>
+
+            </el-form-item>
+
+          </el-col>
+
+        </el-row>
+
+        <el-row :gutter="20">
+
+          <el-col :span="12">
+
+            <el-form-item label="指定租户" v-if="syncForm.scopeType !== 'ALL'">
+
+              <el-select v-model="syncForm.tenantIds" multiple filterable placeholder="选择租户" style="width: 100%">
+
+                <el-option v-for="t in tenantOptions" :key="t.id" :label="t.tenantName + ' (' + t.tenantCode + ')'" :value="t.id" />
+
+              </el-select>
+
+            </el-form-item>
+
+          </el-col>
+
+          <el-col :span="12">
+
+            <el-form-item label="字典类型">
+
+              <el-select v-model="syncForm.dictTypes" multiple filterable clearable placeholder="空 = 全部模板类型" style="width: 100%">
+
+                <el-option v-for="t in templateTypeOptions" :key="t.dictType" :label="t.dictName + ' / ' + t.dictType" :value="t.dictType" />
+
+              </el-select>
+
+            </el-form-item>
+
+          </el-col>
+
+        </el-row>
+
+        <el-alert
+
+          v-if="syncForm.syncMode === 'OVERWRITE'"
+
+          type="warning"
+
+          :closable="false"
+
+          show-icon
+
+          class="mb12"
+
+          title="OVERWRITE 将覆盖各租户中「平台管控」的字典项,租户自有扩展项不受影响"
+
+        />
+
+        <el-form-item v-if="syncForm.syncMode === 'OVERWRITE'" class="overwrite-check">
+
+          <el-checkbox v-model="syncForm.overwriteConfirm">我已了解上述风险,确认执行覆盖同步</el-checkbox>
+
+        </el-form-item>
+
+        <el-form-item class="action-row">
+
+          <el-button type="info" plain icon="el-icon-view" @click="handleSync(true)" v-hasPermi="['tenant:dict:sync']">全量预览</el-button>
+
+          <el-button type="warning" icon="el-icon-upload2" @click="handleSync(false)" v-hasPermi="['tenant:dict:sync']">开始同步</el-button>
+
+        </el-form-item>
+
+      </el-form>
+
+    </el-card>
+
+
+
+    <el-card shadow="never" v-if="previewList.length" class="mb16 result-card">
+
+      <div slot="header" class="card-header">
+
+        <span><i class="el-icon-data-analysis" /> 预览结果</span>
+
+        <el-tag size="mini" type="info" effect="plain">未写入数据库</el-tag>
+
+      </div>
+
+      <el-table :data="previewList" border size="small" class="dict-table">
+
+        <el-table-column label="租户" prop="tenantName" min-width="120" />
+
+        <el-table-column label="编码" prop="tenantCode" width="110" />
+
+        <el-table-column label="新增类型" prop="typeAdded" width="88" align="center">
+
+          <template slot-scope="s"><span class="stat-num add">{{ s.row.typeAdded || 0 }}</span></template>
+
+        </el-table-column>
+
+        <el-table-column label="更新类型" prop="typeUpdated" width="88" align="center">
+
+          <template slot-scope="s"><span class="stat-num update">{{ s.row.typeUpdated || 0 }}</span></template>
+
+        </el-table-column>
+
+        <el-table-column label="新增数据" prop="dataAdded" width="88" align="center">
+
+          <template slot-scope="s"><span class="stat-num add">{{ s.row.dataAdded || 0 }}</span></template>
+
+        </el-table-column>
+
+        <el-table-column label="更新数据" prop="dataUpdated" width="88" align="center">
+
+          <template slot-scope="s"><span class="stat-num update">{{ s.row.dataUpdated || 0 }}</span></template>
+
+        </el-table-column>
+
+        <el-table-column label="跳过" prop="dataSkipped" width="72" align="center" />
+
+        <el-table-column label="移除" prop="dataRemoved" width="72" align="center" />
+
+      </el-table>
+
+    </el-card>
+
+
+
+    <el-card shadow="never" v-if="taskNo" class="result-card">
+
+      <div slot="header" class="card-header">
+
+        <span><i class="el-icon-loading" v-if="taskInfo && taskInfo.status === 'RUNNING'" /><i class="el-icon-finished" v-else /> 同步任务 {{ taskNo }}</span>
+
+        <el-button type="text" icon="el-icon-refresh" @click="pollTask">刷新状态</el-button>
+
+      </div>
+
+      <el-descriptions :column="4" border size="small" v-if="taskInfo" class="task-summary">
+
+        <el-descriptions-item label="状态"><el-tag :type="taskStatusType" size="small">{{ taskInfo.status }}</el-tag></el-descriptions-item>
+
+        <el-descriptions-item label="模式">{{ taskInfo.syncMode }}</el-descriptions-item>
+
+        <el-descriptions-item label="成功 / 总数">{{ taskInfo.successTenants }} / {{ taskInfo.totalTenants }}</el-descriptions-item>
+
+        <el-descriptions-item label="失败">
+
+          <span :class="{ 'stat-fail': taskInfo.failedTenants > 0 }">{{ taskInfo.failedTenants }}</span>
+
+        </el-descriptions-item>
+
+      </el-descriptions>
+
+      <el-table v-if="taskInfo && taskInfo.details && taskInfo.details.length" :data="taskInfo.details" border size="small" class="dict-table mt12">
+
+        <el-table-column label="租户" prop="tenantName" min-width="120" />
+
+        <el-table-column label="状态" prop="status" width="90" align="center">
+
+          <template slot-scope="s">
+
+            <el-tag size="mini" :type="s.row.status === 'SUCCESS' ? 'success' : (s.row.status === 'FAILED' ? 'danger' : 'warning')">{{ s.row.status }}</el-tag>
+
+          </template>
+
+        </el-table-column>
+
+        <el-table-column label="新增类型" prop="typeAdded" width="88" align="center" />
+
+        <el-table-column label="更新类型" prop="typeUpdated" width="88" align="center" />
+
+        <el-table-column label="新增数据" prop="dataAdded" width="88" align="center" />
+
+        <el-table-column label="更新数据" prop="dataUpdated" width="88" align="center" />
+
+        <el-table-column label="跳过" prop="dataSkipped" width="72" align="center" />
+
+        <el-table-column label="错误" prop="errorMsg" min-width="160" :show-overflow-tooltip="true" />
+
+      </el-table>
+
+    </el-card>
+
+
+
+    <el-empty v-if="!previewList.length && !taskNo" description="配置同步参数后,可先「全量预览」查看差异,再「开始同步」" :image-size="100" />
+
+  </div>
+
+</template>
+
+
+
+<script>
+
+import { listCompanyOptions } from '@/api/admin/sysCompany'
+
+import { runDictSync, getDictSyncTask, previewDictSync, templateDictTypeOptions } from '@/api/tenant/dict'
+
+
+
+export default {
+
+  name: 'AdminTenantDictSync',
+
+  data() {
+
+    return {
+
+      tenantOptions: [],
+
+      templateTypeOptions: [],
+
+      previewList: [],
+
+      taskNo: '',
+
+      taskInfo: null,
+
+      pollTimer: null,
+
+      syncForm: {
+
+        syncMode: 'MERGE',
+
+        scopeType: 'ALL',
+
+        tenantIds: [],
+
+        dictTypes: [],
+
+        parallel: false,
+
+        threads: 4,
+
+        overwriteConfirm: false,
+
+        dryRun: false
+
+      }
+
+    }
+
+  },
+
+  computed: {
+
+    taskStatusType() {
+
+      const s = this.taskInfo && this.taskInfo.status
+
+      if (s === 'SUCCESS') return 'success'
+
+      if (s === 'FAILED') return 'danger'
+
+      if (s === 'PARTIAL') return 'warning'
+
+      return 'info'
+
+    }
+
+  },
+
+  created() {
+
+    listCompanyOptions().then(r => { this.tenantOptions = r.data || [] })
+
+    templateDictTypeOptions().then(r => {
+
+      this.templateTypeOptions = r.data || []
+
+      if (!this.templateTypeOptions.length) {
+
+        this.$message.warning('平台模板为空,请先在「平台模板」Tab 维护字典模板后再同步')
+
+      }
+
+    })
+
+  },
+
+  beforeDestroy() {
+
+    if (this.pollTimer) clearInterval(this.pollTimer)
+
+  },
+
+  methods: {
+
+    buildPayload(dryRun) {
+
+      return {
+
+        syncMode: this.syncForm.syncMode,
+
+        scopeType: this.syncForm.scopeType,
+
+        tenantIds: this.syncForm.scopeType === 'ALL' ? [] : this.syncForm.tenantIds,
+
+        dictTypes: this.syncForm.dictTypes,
+
+        parallel: this.syncForm.parallel,
+
+        threads: this.syncForm.threads,
+
+        overwriteConfirm: this.syncForm.overwriteConfirm,
+
+        dryRun: dryRun
+
+      }
+
+    },
+
+    handlePreview() {
+
+      if (this.syncForm.scopeType === 'INCLUDE' && (!this.syncForm.tenantIds || !this.syncForm.tenantIds.length)) {
+
+        this.$message.warning('请选择要预览的租户')
+
+        return
+
+      }
+
+      const tenantId = this.syncForm.scopeType === 'INCLUDE' ? this.syncForm.tenantIds[0] : (this.tenantOptions[0] && this.tenantOptions[0].id)
+
+      if (!tenantId) {
+
+        this.$message.warning('无可用租户')
+
+        return
+
+      }
+
+      previewDictSync(tenantId, this.syncForm.dictTypes).then(r => {
+
+        this.previewList = r.data || []
+
+        this.$message.success('预览完成(仅首个租户示例,完整预览请用 dryRun 同步)')
+
+      })
+
+    },
+
+    handleSync(dryRun) {
+
+      if (this.syncForm.syncMode === 'OVERWRITE' && !this.syncForm.overwriteConfirm) {
+
+        this.$message.error('OVERWRITE 模式请先勾选确认')
+
+        return
+
+      }
+
+      if (this.syncForm.scopeType !== 'ALL' && (!this.syncForm.tenantIds || !this.syncForm.tenantIds.length)) {
+
+        this.$message.warning('请选择租户')
+
+        return
+
+      }
+
+      this.$confirm(dryRun ? '确认预览同步差异?' : '确认开始字典同步任务?', '提示', { type: 'warning' }).then(() => {
+
+        return runDictSync(this.buildPayload(dryRun))
+
+      }).then(r => {
+
+        const data = r.data || {}
+
+        if (data.dryRun) {
+
+          this.previewList = data.preview || []
+
+          if (!this.previewList.length) {
+
+            this.$message.warning('预览完成:无差异(请确认平台模板中已有字典类型和数据)')
+
+          } else {
+
+            this.$message.success('全量预览完成,共 ' + this.previewList.length + ' 个租户')
+
+          }
+
+          return
+
+        }
+
+        if (!data.taskNo) {
+
+          this.$message.error('同步任务创建失败,请检查后端日志')
+
+          return
+
+        }
+
+        this.taskNo = data.taskNo
+
+        this.taskInfo = { status: 'RUNNING', syncMode: data.syncMode, totalTenants: data.totalTenants, successTenants: 0, failedTenants: 0, details: [] }
+
+        this.startPoll()
+
+        this.$message.success('同步任务已启动:' + data.taskNo)
+
+      }).catch(err => {
+
+        const msg = (err && err.message) || (typeof err === 'string' ? err : '同步失败')
+
+        if (msg && msg !== 'cancel') this.$message.error(msg)
+
+      })
+
+    },
+
+    startPoll() {
+
+      if (this.pollTimer) clearInterval(this.pollTimer)
+
+      this.pollTask()
+
+      this.pollTimer = setInterval(() => this.pollTask(), 3000)
+
+    },
+
+    pollTask() {
+
+      if (!this.taskNo) return
+
+      getDictSyncTask(this.taskNo).then(r => {
+
+        this.taskInfo = r.data
+
+        if (this.taskInfo && ['SUCCESS', 'FAILED', 'PARTIAL'].includes(this.taskInfo.status)) {
+
+          clearInterval(this.pollTimer)
+
+          this.pollTimer = null
+
+        }
+
+      })
+
+    }
+
+  }
+
+}
+
+</script>
+
+
+
+<style scoped>
+
+.sync-panel { padding-top: 4px; }
+
+.mb12 { margin-bottom: 12px; }
+
+.mb16 { margin-bottom: 16px; }
+
+.mt12 { margin-top: 12px; }
+
+.dict-table { width: 100%; }
+
+.card-header {
+
+  display: flex;
+
+  align-items: center;
+
+  justify-content: space-between;
+
+  font-weight: 600;
+
+  color: #303133;
+
+}
+
+.card-header i { margin-right: 6px; color: #409eff; }
+
+.sync-mode-card {
+
+  padding: 14px 16px;
+
+  border-radius: 8px;
+
+  border: 1px solid #ebeef5;
+
+  background: #fff;
+
+  height: 100%;
+
+  transition: box-shadow 0.2s;
+
+}
+
+.sync-mode-card:hover { box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06); }
+
+.sync-mode-title { font-size: 14px; font-weight: 600; margin-bottom: 6px; }
+
+.sync-mode-desc { font-size: 12px; color: #909399; line-height: 1.5; }
+
+.sync-mode-card.merge { border-left: 3px solid #67c23a; }
+
+.sync-mode-card.merge .sync-mode-title { color: #67c23a; }
+
+.sync-mode-card.append { border-left: 3px solid #409eff; }
+
+.sync-mode-card.append .sync-mode-title { color: #409eff; }
+
+.sync-mode-card.overwrite { border-left: 3px solid #e6a23c; }
+
+.sync-mode-card.overwrite .sync-mode-title { color: #e6a23c; }
+
+.sync-config-card >>> .el-card__body { padding-top: 8px; }
+
+.parallel-row { display: flex; align-items: center; gap: 12px; }
+
+.overwrite-check { margin-bottom: 0; }
+
+.action-row { margin-bottom: 0; margin-top: 4px; }
+
+.stat-num { font-weight: 600; }
+
+.stat-num.add { color: #67c23a; }
+
+.stat-num.update { color: #409eff; }
+
+.stat-fail { color: #f56c6c; font-weight: 600; }
+
+.task-summary { margin-bottom: 0; }
+
+</style>
+
+

+ 152 - 152
src/views/admin/tenantDict/template/TemplateDataPanel.vue

@@ -1,152 +1,152 @@
-<template>
-  <div class="template-data-panel">
-    <el-row :gutter="10" class="mb8 toolbar-row">
-      <el-col :span="1.5">
-        <el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd" v-hasPermi="['tenant:dict:template:add']">新增</el-button>
-      </el-col>
-      <el-col :span="1.5">
-        <el-button type="success" plain icon="el-icon-edit" size="mini" :disabled="single" @click="handleUpdate" v-hasPermi="['tenant:dict:template:edit']">修改</el-button>
-      </el-col>
-      <el-col :span="1.5">
-        <el-button type="danger" plain icon="el-icon-delete" size="mini" :disabled="multiple" @click="handleDelete" v-hasPermi="['tenant:dict:template:remove']">删除</el-button>
-      </el-col>
-    </el-row>
-
-    <el-table v-loading="loading" :data="dataList" border size="small" @selection-change="handleSelectionChange" class="dict-table">
-      <el-table-column type="selection" width="45" align="center" />
-      <el-table-column label="标签" prop="dictLabel" align="center" min-width="120" :show-overflow-tooltip="true" />
-      <el-table-column label="键值" prop="dictValue" align="center" min-width="100" />
-      <el-table-column label="排序" prop="dictSort" width="70" align="center" />
-      <el-table-column label="平台管控" width="88" align="center">
-        <template slot-scope="scope">
-          <el-tag size="mini" :type="scope.row.isManaged === 1 ? 'danger' : 'info'">{{ scope.row.isManaged === 1 ? '是' : '否' }}</el-tag>
-        </template>
-      </el-table-column>
-      <el-table-column label="状态" width="76" align="center">
-        <template slot-scope="scope"><dict-tag :options="statusOptions" :value="scope.row.status"/></template>
-      </el-table-column>
-      <el-table-column label="操作" width="130" align="center" fixed="right" class-name="small-padding fixed-width">
-        <template slot-scope="scope">
-          <el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)" v-hasPermi="['tenant:dict:template:edit']">修改</el-button>
-          <el-button size="mini" type="text" icon="el-icon-delete" style="color:#f5222d" @click="handleDelete(scope.row)" v-hasPermi="['tenant:dict:template: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="title" :visible.sync="open" width="520px" append-to-body>
-      <el-form ref="form" :model="form" :rules="rules" label-width="100px">
-        <el-form-item label="字典类型"><el-input v-model="form.dictType" disabled /></el-form-item>
-        <el-form-item label="数据标签" prop="dictLabel"><el-input v-model="form.dictLabel" /></el-form-item>
-        <el-form-item label="数据键值" prop="dictValue"><el-input v-model="form.dictValue" :disabled="form.dictCode != null" /></el-form-item>
-        <el-form-item label="显示排序" prop="dictSort"><el-input-number v-model="form.dictSort" :min="0" controls-position="right" style="width: 100%" /></el-form-item>
-        <el-form-item label="平台管控">
-          <el-radio-group v-model="form.isManaged">
-            <el-radio :label="1">是</el-radio><el-radio :label="0">否</el-radio>
-          </el-radio-group>
-        </el-form-item>
-        <el-form-item label="状态">
-          <el-radio-group v-model="form.status">
-            <el-radio v-for="d in statusOptions" :key="d.dictValue" :label="d.dictValue">{{ d.dictLabel }}</el-radio>
-          </el-radio-group>
-        </el-form-item>
-        <el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" /></el-form-item>
-      </el-form>
-      <div slot="footer" class="dialog-footer">
-        <el-button type="primary" @click="submitForm">确 定</el-button>
-        <el-button @click="cancel">取 消</el-button>
-      </div>
-    </el-dialog>
-  </div>
-</template>
-
-<script>
-import {
-  listTemplateDictData, getTemplateDictData, addTemplateDictData,
-  updateTemplateDictData, delTemplateDictData
-} from '@/api/tenant/dict'
-
-export default {
-  name: 'TemplateDataPanel',
-  props: {
-    dictType: { type: String, required: true }
-  },
-  data() {
-    return {
-      loading: false,
-      ids: [],
-      single: true,
-      multiple: true,
-      total: 0,
-      dataList: [],
-      title: '',
-      open: false,
-      statusOptions: [],
-      queryParams: { pageNum: 1, pageSize: 10, dictType: undefined },
-      form: {},
-      rules: {
-        dictLabel: [{ required: true, message: '标签不能为空', trigger: 'blur' }],
-        dictValue: [{ required: true, message: '键值不能为空', trigger: 'blur' }],
-        dictSort: [{ required: true, message: '排序不能为空', trigger: 'blur' }]
-      }
-    }
-  },
-  watch: {
-    dictType: {
-      immediate: true,
-      handler(val) {
-        if (val) {
-          this.queryParams.dictType = val
-          this.getList()
-        }
-      }
-    }
-  },
-  created() {
-    this.getDicts('sys_normal_disable').then(r => { this.statusOptions = r.data })
-  },
-  methods: {
-    getList() {
-      if (!this.queryParams.dictType) return
-      this.loading = true
-      listTemplateDictData(this.queryParams).then(r => {
-        this.dataList = r.rows
-        this.total = r.total
-        this.loading = false
-      }).catch(() => { this.loading = false })
-    },
-    cancel() { this.open = false; this.reset() },
-    reset() {
-      this.form = { dictCode: undefined, dictLabel: undefined, dictValue: undefined, dictSort: 0, status: '0', isManaged: 0, remark: undefined }
-      this.resetForm('form')
-    },
-    handleAdd() { this.reset(); this.form.dictType = this.queryParams.dictType; this.open = true; this.title = '添加模板数据' },
-    handleSelectionChange(s) { this.ids = s.map(i => i.dictCode); this.single = s.length !== 1; this.multiple = !s.length },
-    handleUpdate(row) {
-      this.reset()
-      getTemplateDictData(row.dictCode || this.ids[0]).then(r => { this.form = r.data; this.open = true; this.title = '修改模板数据' })
-    },
-    submitForm() {
-      this.$refs['form'].validate(valid => {
-        if (!valid) return
-        const req = this.form.dictCode != null ? updateTemplateDictData(this.form) : addTemplateDictData(this.form)
-        req.then(() => { this.msgSuccess('保存成功'); this.open = false; this.getList() })
-      })
-    },
-    handleDelete(row) {
-      const ids = row.dictCode || this.ids.join(',')
-      this.$confirm('确认删除?', '警告', { type: 'warning' }).then(() => delTemplateDictData(ids))
-        .then(() => { this.getList(); this.msgSuccess('删除成功') }).catch(() => {})
-    }
-  }
-}
-</script>
-
-<style scoped>
-.template-data-panel { min-height: 200px; }
-.mb8 { margin-bottom: 8px; }
-.toolbar-row { display: flex; flex-wrap: wrap; align-items: center; }
-.dict-table { width: 100%; }
-</style>
-
+<template>
+  <div class="template-data-panel">
+    <el-row :gutter="10" class="mb8 toolbar-row">
+      <el-col :span="1.5">
+        <el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd" v-hasPermi="['tenant:dict:template:add']">新增</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button type="success" plain icon="el-icon-edit" size="mini" :disabled="single" @click="handleUpdate" v-hasPermi="['tenant:dict:template:edit']">修改</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button type="danger" plain icon="el-icon-delete" size="mini" :disabled="multiple" @click="handleDelete" v-hasPermi="['tenant:dict:template:remove']">删除</el-button>
+      </el-col>
+    </el-row>
+
+    <el-table v-loading="loading" :data="dataList" border size="small" @selection-change="handleSelectionChange" class="dict-table">
+      <el-table-column type="selection" width="45" align="center" />
+      <el-table-column label="标签" prop="dictLabel" align="center" min-width="120" :show-overflow-tooltip="true" />
+      <el-table-column label="键值" prop="dictValue" align="center" min-width="100" />
+      <el-table-column label="排序" prop="dictSort" width="70" align="center" />
+      <el-table-column label="平台管控" width="88" align="center">
+        <template slot-scope="scope">
+          <el-tag size="mini" :type="scope.row.isManaged === 1 ? 'danger' : 'info'">{{ scope.row.isManaged === 1 ? '是' : '否' }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="状态" width="76" align="center">
+        <template slot-scope="scope"><dict-tag :options="statusOptions" :value="scope.row.status"/></template>
+      </el-table-column>
+      <el-table-column label="操作" width="130" align="center" fixed="right" class-name="small-padding fixed-width">
+        <template slot-scope="scope">
+          <el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)" v-hasPermi="['tenant:dict:template:edit']">修改</el-button>
+          <el-button size="mini" type="text" icon="el-icon-delete" style="color:#f5222d" @click="handleDelete(scope.row)" v-hasPermi="['tenant:dict:template: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="title" :visible.sync="open" width="520px" append-to-body>
+      <el-form ref="form" :model="form" :rules="rules" label-width="100px">
+        <el-form-item label="字典类型"><el-input v-model="form.dictType" disabled /></el-form-item>
+        <el-form-item label="数据标签" prop="dictLabel"><el-input v-model="form.dictLabel" /></el-form-item>
+        <el-form-item label="数据键值" prop="dictValue"><el-input v-model="form.dictValue" :disabled="form.dictCode != null" /></el-form-item>
+        <el-form-item label="显示排序" prop="dictSort"><el-input-number v-model="form.dictSort" :min="0" controls-position="right" style="width: 100%" /></el-form-item>
+        <el-form-item label="平台管控">
+          <el-radio-group v-model="form.isManaged">
+            <el-radio :label="1">是</el-radio><el-radio :label="0">否</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-radio-group v-model="form.status">
+            <el-radio v-for="d in statusOptions" :key="d.dictValue" :label="d.dictValue">{{ d.dictLabel }}</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" /></el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" @click="submitForm">确 定</el-button>
+        <el-button @click="cancel">取 消</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import {
+  listTemplateDictData, getTemplateDictData, addTemplateDictData,
+  updateTemplateDictData, delTemplateDictData
+} from '@/api/tenant/dict'
+
+export default {
+  name: 'TemplateDataPanel',
+  props: {
+    dictType: { type: String, required: true }
+  },
+  data() {
+    return {
+      loading: false,
+      ids: [],
+      single: true,
+      multiple: true,
+      total: 0,
+      dataList: [],
+      title: '',
+      open: false,
+      statusOptions: [],
+      queryParams: { pageNum: 1, pageSize: 10, dictType: undefined },
+      form: {},
+      rules: {
+        dictLabel: [{ required: true, message: '标签不能为空', trigger: 'blur' }],
+        dictValue: [{ required: true, message: '键值不能为空', trigger: 'blur' }],
+        dictSort: [{ required: true, message: '排序不能为空', trigger: 'blur' }]
+      }
+    }
+  },
+  watch: {
+    dictType: {
+      immediate: true,
+      handler(val) {
+        if (val) {
+          this.queryParams.dictType = val
+          this.getList()
+        }
+      }
+    }
+  },
+  created() {
+    this.getDicts('sys_normal_disable').then(r => { this.statusOptions = r.data })
+  },
+  methods: {
+    getList() {
+      if (!this.queryParams.dictType) return
+      this.loading = true
+      listTemplateDictData(this.queryParams).then(r => {
+        this.dataList = r.rows
+        this.total = r.total
+        this.loading = false
+      }).catch(() => { this.loading = false })
+    },
+    cancel() { this.open = false; this.reset() },
+    reset() {
+      this.form = { dictCode: undefined, dictLabel: undefined, dictValue: undefined, dictSort: 0, status: '0', isManaged: 0, remark: undefined }
+      this.resetForm('form')
+    },
+    handleAdd() { this.reset(); this.form.dictType = this.queryParams.dictType; this.open = true; this.title = '添加模板数据' },
+    handleSelectionChange(s) { this.ids = s.map(i => i.dictCode); this.single = s.length !== 1; this.multiple = !s.length },
+    handleUpdate(row) {
+      this.reset()
+      getTemplateDictData(row.dictCode || this.ids[0]).then(r => { this.form = r.data; this.open = true; this.title = '修改模板数据' })
+    },
+    submitForm() {
+      this.$refs['form'].validate(valid => {
+        if (!valid) return
+        const req = this.form.dictCode != null ? updateTemplateDictData(this.form) : addTemplateDictData(this.form)
+        req.then(() => { this.msgSuccess('保存成功'); this.open = false; this.getList() })
+      })
+    },
+    handleDelete(row) {
+      const ids = row.dictCode || this.ids.join(',')
+      this.$confirm('确认删除?', '警告', { type: 'warning' }).then(() => delTemplateDictData(ids))
+        .then(() => { this.getList(); this.msgSuccess('删除成功') }).catch(() => {})
+    }
+  }
+}
+</script>
+
+<style scoped>
+.template-data-panel { min-height: 200px; }
+.mb8 { margin-bottom: 8px; }
+.toolbar-row { display: flex; flex-wrap: wrap; align-items: center; }
+.dict-table { width: 100%; }
+</style>
+

+ 342 - 342
src/views/admin/tenantDict/template/index.vue

@@ -1,342 +1,342 @@
-<template>
-  <div class="dict-tab-panel">
-    <div class="tip-banner">
-      <i class="el-icon-info" />
-      <span>可从「字典维护」勾选字典后点「导入到平台模板」,或在此从平台主库 / 租户库批量选取导入</span>
-    </div>
-
-    <el-card shadow="never" class="mb16 filter-card" v-show="showSearch">
-      <el-form :model="queryParams" ref="queryForm" :inline="true" size="small" label-width="72px">
-        <el-form-item label="字典名称" prop="dictName">
-          <el-input v-model="queryParams.dictName" placeholder="请输入字典名称" clearable @keyup.enter.native="handleQuery" />
-        </el-form-item>
-        <el-form-item label="字典类型" prop="dictType">
-          <el-input v-model="queryParams.dictType" 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 toolbar-row">
-      <el-col :span="1.5">
-        <el-button type="primary" plain icon="el-icon-download" size="mini" @click="openImportDialog" v-hasPermi="['tenant:dict:template:add']">从字典库导入</el-button>
-      </el-col>
-      <el-col :span="1.5">
-        <el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd" v-hasPermi="['tenant:dict:template:add']">手动新增</el-button>
-      </el-col>
-      <el-col :span="1.5">
-        <el-button type="success" plain icon="el-icon-edit" size="mini" :disabled="single" @click="handleUpdate" v-hasPermi="['tenant:dict:template:edit']">修改</el-button>
-      </el-col>
-      <el-col :span="1.5">
-        <el-button type="danger" plain icon="el-icon-delete" size="mini" :disabled="multiple" @click="handleDelete" v-hasPermi="['tenant:dict:template:remove']">删除</el-button>
-      </el-col>
-      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
-    </el-row>
-
-    <el-table v-loading="loading" :data="typeList" border size="small" @selection-change="handleSelectionChange" class="dict-table">
-      <el-table-column type="selection" width="48" align="center" />
-      <el-table-column label="编号" align="center" prop="dictId" width="76" />
-      <el-table-column label="字典名称" align="center" prop="dictName" min-width="120" :show-overflow-tooltip="true" />
-      <el-table-column label="字典类型" align="center" min-width="140">
-        <template slot-scope="scope">
-          <el-link type="primary" :underline="false" @click="openDataDrawer(scope.row)">{{ scope.row.dictType }}</el-link>
-        </template>
-      </el-table-column>
-      <el-table-column label="平台管控" align="center" width="88">
-        <template slot-scope="scope">
-          <el-tag size="mini" :type="scope.row.isManaged === 1 ? 'danger' : 'info'">{{ scope.row.isManaged === 1 ? '是' : '否' }}</el-tag>
-        </template>
-      </el-table-column>
-      <el-table-column label="默认同步模式" align="center" prop="syncMode" width="120">
-        <template slot-scope="scope">
-          <el-tag size="mini" type="info" effect="plain">{{ scope.row.syncMode }}</el-tag>
-        </template>
-      </el-table-column>
-      <el-table-column label="状态" align="center" width="76">
-        <template slot-scope="scope">
-          <dict-tag :options="statusOptions" :value="scope.row.status"/>
-        </template>
-      </el-table-column>
-      <el-table-column label="操作" align="center" width="200" fixed="right" class-name="small-padding fixed-width">
-        <template slot-scope="scope">
-          <el-button size="mini" type="text" icon="el-icon-view" @click="openDataDrawer(scope.row)">字典数据</el-button>
-          <el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)" v-hasPermi="['tenant:dict:template:edit']">修改</el-button>
-          <el-button size="mini" type="text" icon="el-icon-delete" style="color:#f5222d" @click="handleDelete(scope.row)" v-hasPermi="['tenant:dict:template: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-drawer
-      :title="'模板字典数据 · ' + (currentType.dictName || currentType.dictType || '')"
-      :visible.sync="dataDrawerVisible"
-      size="62%"
-      append-to-body
-      destroy-on-close
-      custom-class="dict-data-drawer"
-    >
-      <div class="drawer-body-wrap" v-if="dataDrawerVisible && currentType.dictType">
-        <div class="drawer-type-meta">
-          <el-tag size="small" type="primary" effect="plain">{{ currentType.dictType }}</el-tag>
-        </div>
-        <template-data-panel :dict-type="currentType.dictType" />
-      </div>
-    </el-drawer>
-
-    <el-dialog :title="title" :visible.sync="open" width="560px" append-to-body>
-      <el-form ref="form" :model="form" :rules="rules" label-width="110px">
-        <el-form-item label="字典名称" prop="dictName"><el-input v-model="form.dictName" /></el-form-item>
-        <el-form-item label="字典类型" prop="dictType"><el-input v-model="form.dictType" :disabled="form.dictId != null" /></el-form-item>
-        <el-form-item label="平台管控" prop="isManaged">
-          <el-radio-group v-model="form.isManaged">
-            <el-radio :label="1">是(同步时可覆盖)</el-radio>
-            <el-radio :label="0">否(同步时仅追加)</el-radio>
-          </el-radio-group>
-        </el-form-item>
-        <el-form-item label="默认同步模式" prop="syncMode">
-          <el-select v-model="form.syncMode" style="width: 100%">
-            <el-option label="MERGE - 合并更新" value="MERGE" />
-            <el-option label="APPEND - 仅追加" value="APPEND" />
-            <el-option label="OVERWRITE - 覆盖平台项" value="OVERWRITE" />
-          </el-select>
-        </el-form-item>
-        <el-form-item label="状态" prop="status">
-          <el-radio-group v-model="form.status">
-            <el-radio v-for="dict in statusOptions" :key="dict.dictValue" :label="dict.dictValue">{{ dict.dictLabel }}</el-radio>
-          </el-radio-group>
-        </el-form-item>
-        <el-form-item label="备注"><el-input v-model="form.remark" type="textarea" /></el-form-item>
-      </el-form>
-      <div slot="footer" class="dialog-footer">
-        <el-button type="primary" @click="submitForm">确 定</el-button>
-        <el-button @click="cancel">取 消</el-button>
-      </div>
-    </el-dialog>
-
-    <el-dialog title="从字典库导入到平台模板" :visible.sync="importOpen" width="800px" append-to-body class="import-dialog">
-      <el-tabs v-model="importTab" class="import-tabs">
-        <el-tab-pane label="平台主库字典" name="platform">
-          <el-table v-loading="importLoading" :data="platformDictList" @selection-change="s => importPlatformSelection = s" max-height="360" size="small" border>
-            <el-table-column type="selection" width="45" />
-            <el-table-column label="字典名称" prop="dictName" min-width="140" />
-            <el-table-column label="字典类型" prop="dictType" min-width="160" />
-            <el-table-column label="状态" prop="status" width="80" align="center" />
-          </el-table>
-        </el-tab-pane>
-        <el-tab-pane label="租户字典" name="tenant">
-          <el-card shadow="never" class="mb8 import-filter">
-            <el-form inline size="small">
-              <el-form-item label="租户">
-                <inline-tenant-selector mode="admin" @change="onImportTenantChange" />
-              </el-form-item>
-              <el-form-item>
-                <el-button type="primary" size="mini" icon="el-icon-search" @click="loadTenantDictList" :disabled="!importTenantId">加载字典</el-button>
-              </el-form-item>
-            </el-form>
-          </el-card>
-          <el-table v-loading="importLoading" :data="tenantDictList" @selection-change="s => importTenantSelection = s" max-height="300" size="small" border>
-            <el-table-column type="selection" width="45" />
-            <el-table-column label="字典名称" prop="dictName" min-width="140" />
-            <el-table-column label="字典类型" prop="dictType" min-width="160" />
-            <el-table-column label="来源" prop="dictSource" width="80" align="center" />
-          </el-table>
-        </el-tab-pane>
-      </el-tabs>
-      <div class="import-options">
-        <el-checkbox v-model="importOverwrite">已存在同类型时覆盖更新(否则仅追加缺失项)</el-checkbox>
-      </div>
-      <div slot="footer" class="dialog-footer">
-        <el-button type="primary" :loading="importSubmitting" icon="el-icon-upload2" @click="submitImport">导入选中</el-button>
-        <el-button @click="importOpen = false">取 消</el-button>
-      </div>
-    </el-dialog>
-  </div>
-</template>
-
-<script>
-import InlineTenantSelector from '@/components/InlineTenantSelector'
-import TemplateDataPanel from './TemplateDataPanel'
-import { listType } from '@/api/system/dict/type'
-import { listTenantDictType } from '@/api/tenant/dict'
-import {
-  listTemplateDictType, getTemplateDictType, addTemplateDictType,
-  updateTemplateDictType, delTemplateDictType,
-  importTemplateFromPlatform, importTemplateFromTenant
-} from '@/api/tenant/dict'
-
-export default {
-  name: 'TenantDictTemplate',
-  components: { InlineTenantSelector, TemplateDataPanel },
-  data() {
-    return {
-      loading: true, ids: [], single: true, multiple: true, showSearch: true, total: 0, typeList: [],
-      title: '', open: false, statusOptions: [],
-      queryParams: { pageNum: 1, pageSize: 10, dictName: undefined, dictType: undefined },
-      form: {},
-      rules: {
-        dictName: [{ required: true, message: '字典名称不能为空', trigger: 'blur' }],
-        dictType: [{ required: true, message: '字典类型不能为空', trigger: 'blur' }],
-        syncMode: [{ required: true, message: '请选择同步模式', trigger: 'change' }]
-      },
-      dataDrawerVisible: false,
-      currentType: {},
-      importOpen: false,
-      importTab: 'platform',
-      importLoading: false,
-      importSubmitting: false,
-      importOverwrite: false,
-      platformDictList: [],
-      importPlatformSelection: [],
-      importTenantId: null,
-      tenantDictList: [],
-      importTenantSelection: []
-    }
-  },
-  created() {
-    this.getList()
-    this.getDicts('sys_normal_disable').then(r => { this.statusOptions = r.data })
-  },
-  methods: {
-    getList() {
-      this.loading = true
-      listTemplateDictType(this.queryParams).then(r => {
-        this.typeList = r.rows; this.total = r.total; this.loading = false
-      }).catch(() => { this.loading = false })
-    },
-    openDataDrawer(row) {
-      this.currentType = { dictId: row.dictId, dictType: row.dictType, dictName: row.dictName }
-      this.dataDrawerVisible = true
-    },
-    openImportDialog() {
-      this.importOpen = true
-      this.importTab = 'platform'
-      this.loadPlatformDictList()
-    },
-    loadPlatformDictList() {
-      this.importLoading = true
-      listType({ pageNum: 1, pageSize: 500 }).then(r => {
-        this.platformDictList = r.rows || []
-        this.importLoading = false
-      }).catch(() => { this.importLoading = false })
-    },
-    onImportTenantChange(val) {
-      this.importTenantId = val || null
-      this.tenantDictList = []
-      this.importTenantSelection = []
-    },
-    loadTenantDictList() {
-      if (!this.importTenantId) return
-      this.importLoading = true
-      listTenantDictType(this.importTenantId, { pageNum: 1, pageSize: 500 }).then(r => {
-        this.tenantDictList = r.rows || []
-        this.importLoading = false
-      }).catch(() => { this.importLoading = false })
-    },
-    submitImport() {
-      const overwriteExisting = this.importOverwrite
-      if (this.importTab === 'platform') {
-        if (!this.importPlatformSelection.length) {
-          this.$message.warning('请选择要导入的平台字典')
-          return
-        }
-        this.importSubmitting = true
-        importTemplateFromPlatform({
-          dictIds: this.importPlatformSelection.map(i => i.dictId),
-          overwriteExisting
-        }).then(r => {
-          this.$message.success(r.data.message || '导入成功')
-          this.importOpen = false
-          this.getList()
-        }).finally(() => { this.importSubmitting = false })
-      } else {
-        if (!this.importTenantId || !this.importTenantSelection.length) {
-          this.$message.warning('请选择租户并勾选字典')
-          return
-        }
-        this.importSubmitting = true
-        importTemplateFromTenant({
-          tenantId: this.importTenantId,
-          dictTypes: this.importTenantSelection.map(i => i.dictType),
-          overwriteExisting
-        }).then(r => {
-          this.$message.success(r.data.message || '导入成功')
-          this.importOpen = false
-          this.getList()
-        }).finally(() => { this.importSubmitting = false })
-      }
-    },
-    cancel() { this.open = false; this.reset() },
-    reset() {
-      this.form = { dictId: undefined, dictName: undefined, dictType: undefined, status: '0', isManaged: 0, syncMode: 'MERGE', remark: undefined }
-      this.resetForm('form')
-    },
-    handleQuery() { this.queryParams.pageNum = 1; this.getList() },
-    resetQuery() { this.resetForm('queryForm'); this.handleQuery() },
-    handleAdd() { this.reset(); this.open = true; this.title = '添加模板字典类型' },
-    handleSelectionChange(s) { this.ids = s.map(i => i.dictId); this.single = s.length !== 1; this.multiple = !s.length },
-    handleUpdate(row) {
-      this.reset()
-      getTemplateDictType(row.dictId || this.ids[0]).then(r => { this.form = r.data; this.open = true; this.title = '修改模板字典类型' })
-    },
-    submitForm() {
-      this.$refs['form'].validate(valid => {
-        if (!valid) return
-        const req = this.form.dictId != null ? updateTemplateDictType(this.form) : addTemplateDictType(this.form)
-        req.then(() => { this.msgSuccess('保存成功'); this.open = false; this.getList() })
-      })
-    },
-    handleDelete(row) {
-      const ids = row.dictId || this.ids.join(',')
-      this.$confirm('确认删除?', '警告', { type: 'warning' }).then(() => delTemplateDictType(ids))
-        .then(() => { this.getList(); this.msgSuccess('删除成功') }).catch(() => {})
-    }
-  }
-}
-</script>
-
-<style scoped>
-.tip-banner {
-  display: flex;
-  align-items: flex-start;
-  gap: 8px;
-  padding: 10px 14px;
-  margin-bottom: 16px;
-  background: #fdf6ec;
-  border: 1px solid #faecd8;
-  border-radius: 8px;
-  font-size: 13px;
-  color: #e6a23c;
-  line-height: 1.5;
-}
-.tip-banner i { font-size: 16px; margin-top: 1px; }
-.mb8 { margin-bottom: 8px; }
-.mb16 { margin-bottom: 16px; }
-.filter-card { padding-bottom: 0; }
-.toolbar-row { display: flex; flex-wrap: wrap; align-items: center; }
-.dict-table { width: 100%; }
-.drawer-body-wrap { padding: 0 20px 20px; }
-.drawer-type-meta { margin-bottom: 12px; }
-.import-filter { padding-bottom: 0; background: #fafafa; }
-.import-options {
-  margin-top: 16px;
-  padding: 12px 14px;
-  background: #f5f7fa;
-  border-radius: 6px;
-}
-</style>
-
-<style>
-.dict-data-drawer .el-drawer__header {
-  margin-bottom: 8px;
-  padding-bottom: 16px;
-  border-bottom: 1px solid #ebeef5;
-  font-weight: 600;
-  color: #303133;
-}
-.dict-data-drawer .el-drawer__body { padding-top: 0; }
-</style>
-
+<template>
+  <div class="dict-tab-panel">
+    <div class="tip-banner">
+      <i class="el-icon-info" />
+      <span>可从「字典维护」勾选字典后点「导入到平台模板」,或在此从平台主库 / 租户库批量选取导入</span>
+    </div>
+
+    <el-card shadow="never" class="mb16 filter-card" v-show="showSearch">
+      <el-form :model="queryParams" ref="queryForm" :inline="true" size="small" label-width="72px">
+        <el-form-item label="字典名称" prop="dictName">
+          <el-input v-model="queryParams.dictName" placeholder="请输入字典名称" clearable @keyup.enter.native="handleQuery" />
+        </el-form-item>
+        <el-form-item label="字典类型" prop="dictType">
+          <el-input v-model="queryParams.dictType" 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 toolbar-row">
+      <el-col :span="1.5">
+        <el-button type="primary" plain icon="el-icon-download" size="mini" @click="openImportDialog" v-hasPermi="['tenant:dict:template:add']">从字典库导入</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd" v-hasPermi="['tenant:dict:template:add']">手动新增</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button type="success" plain icon="el-icon-edit" size="mini" :disabled="single" @click="handleUpdate" v-hasPermi="['tenant:dict:template:edit']">修改</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button type="danger" plain icon="el-icon-delete" size="mini" :disabled="multiple" @click="handleDelete" v-hasPermi="['tenant:dict:template:remove']">删除</el-button>
+      </el-col>
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
+    </el-row>
+
+    <el-table v-loading="loading" :data="typeList" border size="small" @selection-change="handleSelectionChange" class="dict-table">
+      <el-table-column type="selection" width="48" align="center" />
+      <el-table-column label="编号" align="center" prop="dictId" width="76" />
+      <el-table-column label="字典名称" align="center" prop="dictName" min-width="120" :show-overflow-tooltip="true" />
+      <el-table-column label="字典类型" align="center" min-width="140">
+        <template slot-scope="scope">
+          <el-link type="primary" :underline="false" @click="openDataDrawer(scope.row)">{{ scope.row.dictType }}</el-link>
+        </template>
+      </el-table-column>
+      <el-table-column label="平台管控" align="center" width="88">
+        <template slot-scope="scope">
+          <el-tag size="mini" :type="scope.row.isManaged === 1 ? 'danger' : 'info'">{{ scope.row.isManaged === 1 ? '是' : '否' }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="默认同步模式" align="center" prop="syncMode" width="120">
+        <template slot-scope="scope">
+          <el-tag size="mini" type="info" effect="plain">{{ scope.row.syncMode }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="状态" align="center" width="76">
+        <template slot-scope="scope">
+          <dict-tag :options="statusOptions" :value="scope.row.status"/>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" width="200" fixed="right" class-name="small-padding fixed-width">
+        <template slot-scope="scope">
+          <el-button size="mini" type="text" icon="el-icon-view" @click="openDataDrawer(scope.row)">字典数据</el-button>
+          <el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)" v-hasPermi="['tenant:dict:template:edit']">修改</el-button>
+          <el-button size="mini" type="text" icon="el-icon-delete" style="color:#f5222d" @click="handleDelete(scope.row)" v-hasPermi="['tenant:dict:template: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-drawer
+      :title="'模板字典数据 · ' + (currentType.dictName || currentType.dictType || '')"
+      :visible.sync="dataDrawerVisible"
+      size="62%"
+      append-to-body
+      destroy-on-close
+      custom-class="dict-data-drawer"
+    >
+      <div class="drawer-body-wrap" v-if="dataDrawerVisible && currentType.dictType">
+        <div class="drawer-type-meta">
+          <el-tag size="small" type="primary" effect="plain">{{ currentType.dictType }}</el-tag>
+        </div>
+        <template-data-panel :dict-type="currentType.dictType" />
+      </div>
+    </el-drawer>
+
+    <el-dialog :title="title" :visible.sync="open" width="560px" append-to-body>
+      <el-form ref="form" :model="form" :rules="rules" label-width="110px">
+        <el-form-item label="字典名称" prop="dictName"><el-input v-model="form.dictName" /></el-form-item>
+        <el-form-item label="字典类型" prop="dictType"><el-input v-model="form.dictType" :disabled="form.dictId != null" /></el-form-item>
+        <el-form-item label="平台管控" prop="isManaged">
+          <el-radio-group v-model="form.isManaged">
+            <el-radio :label="1">是(同步时可覆盖)</el-radio>
+            <el-radio :label="0">否(同步时仅追加)</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="默认同步模式" prop="syncMode">
+          <el-select v-model="form.syncMode" style="width: 100%">
+            <el-option label="MERGE - 合并更新" value="MERGE" />
+            <el-option label="APPEND - 仅追加" value="APPEND" />
+            <el-option label="OVERWRITE - 覆盖平台项" value="OVERWRITE" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="状态" prop="status">
+          <el-radio-group v-model="form.status">
+            <el-radio v-for="dict in statusOptions" :key="dict.dictValue" :label="dict.dictValue">{{ dict.dictLabel }}</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="备注"><el-input v-model="form.remark" type="textarea" /></el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" @click="submitForm">确 定</el-button>
+        <el-button @click="cancel">取 消</el-button>
+      </div>
+    </el-dialog>
+
+    <el-dialog title="从字典库导入到平台模板" :visible.sync="importOpen" width="800px" append-to-body class="import-dialog">
+      <el-tabs v-model="importTab" class="import-tabs">
+        <el-tab-pane label="平台主库字典" name="platform">
+          <el-table v-loading="importLoading" :data="platformDictList" @selection-change="s => importPlatformSelection = s" max-height="360" size="small" border>
+            <el-table-column type="selection" width="45" />
+            <el-table-column label="字典名称" prop="dictName" min-width="140" />
+            <el-table-column label="字典类型" prop="dictType" min-width="160" />
+            <el-table-column label="状态" prop="status" width="80" align="center" />
+          </el-table>
+        </el-tab-pane>
+        <el-tab-pane label="租户字典" name="tenant">
+          <el-card shadow="never" class="mb8 import-filter">
+            <el-form inline size="small">
+              <el-form-item label="租户">
+                <inline-tenant-selector mode="admin" @change="onImportTenantChange" />
+              </el-form-item>
+              <el-form-item>
+                <el-button type="primary" size="mini" icon="el-icon-search" @click="loadTenantDictList" :disabled="!importTenantId">加载字典</el-button>
+              </el-form-item>
+            </el-form>
+          </el-card>
+          <el-table v-loading="importLoading" :data="tenantDictList" @selection-change="s => importTenantSelection = s" max-height="300" size="small" border>
+            <el-table-column type="selection" width="45" />
+            <el-table-column label="字典名称" prop="dictName" min-width="140" />
+            <el-table-column label="字典类型" prop="dictType" min-width="160" />
+            <el-table-column label="来源" prop="dictSource" width="80" align="center" />
+          </el-table>
+        </el-tab-pane>
+      </el-tabs>
+      <div class="import-options">
+        <el-checkbox v-model="importOverwrite">已存在同类型时覆盖更新(否则仅追加缺失项)</el-checkbox>
+      </div>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" :loading="importSubmitting" icon="el-icon-upload2" @click="submitImport">导入选中</el-button>
+        <el-button @click="importOpen = false">取 消</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import InlineTenantSelector from '@/components/InlineTenantSelector'
+import TemplateDataPanel from './TemplateDataPanel'
+import { listType } from '@/api/system/dict/type'
+import { listTenantDictType } from '@/api/tenant/dict'
+import {
+  listTemplateDictType, getTemplateDictType, addTemplateDictType,
+  updateTemplateDictType, delTemplateDictType,
+  importTemplateFromPlatform, importTemplateFromTenant
+} from '@/api/tenant/dict'
+
+export default {
+  name: 'TenantDictTemplate',
+  components: { InlineTenantSelector, TemplateDataPanel },
+  data() {
+    return {
+      loading: true, ids: [], single: true, multiple: true, showSearch: true, total: 0, typeList: [],
+      title: '', open: false, statusOptions: [],
+      queryParams: { pageNum: 1, pageSize: 10, dictName: undefined, dictType: undefined },
+      form: {},
+      rules: {
+        dictName: [{ required: true, message: '字典名称不能为空', trigger: 'blur' }],
+        dictType: [{ required: true, message: '字典类型不能为空', trigger: 'blur' }],
+        syncMode: [{ required: true, message: '请选择同步模式', trigger: 'change' }]
+      },
+      dataDrawerVisible: false,
+      currentType: {},
+      importOpen: false,
+      importTab: 'platform',
+      importLoading: false,
+      importSubmitting: false,
+      importOverwrite: false,
+      platformDictList: [],
+      importPlatformSelection: [],
+      importTenantId: null,
+      tenantDictList: [],
+      importTenantSelection: []
+    }
+  },
+  created() {
+    this.getList()
+    this.getDicts('sys_normal_disable').then(r => { this.statusOptions = r.data })
+  },
+  methods: {
+    getList() {
+      this.loading = true
+      listTemplateDictType(this.queryParams).then(r => {
+        this.typeList = r.rows; this.total = r.total; this.loading = false
+      }).catch(() => { this.loading = false })
+    },
+    openDataDrawer(row) {
+      this.currentType = { dictId: row.dictId, dictType: row.dictType, dictName: row.dictName }
+      this.dataDrawerVisible = true
+    },
+    openImportDialog() {
+      this.importOpen = true
+      this.importTab = 'platform'
+      this.loadPlatformDictList()
+    },
+    loadPlatformDictList() {
+      this.importLoading = true
+      listType({ pageNum: 1, pageSize: 500 }).then(r => {
+        this.platformDictList = r.rows || []
+        this.importLoading = false
+      }).catch(() => { this.importLoading = false })
+    },
+    onImportTenantChange(val) {
+      this.importTenantId = val || null
+      this.tenantDictList = []
+      this.importTenantSelection = []
+    },
+    loadTenantDictList() {
+      if (!this.importTenantId) return
+      this.importLoading = true
+      listTenantDictType(this.importTenantId, { pageNum: 1, pageSize: 500 }).then(r => {
+        this.tenantDictList = r.rows || []
+        this.importLoading = false
+      }).catch(() => { this.importLoading = false })
+    },
+    submitImport() {
+      const overwriteExisting = this.importOverwrite
+      if (this.importTab === 'platform') {
+        if (!this.importPlatformSelection.length) {
+          this.$message.warning('请选择要导入的平台字典')
+          return
+        }
+        this.importSubmitting = true
+        importTemplateFromPlatform({
+          dictIds: this.importPlatformSelection.map(i => i.dictId),
+          overwriteExisting
+        }).then(r => {
+          this.$message.success(r.data.message || '导入成功')
+          this.importOpen = false
+          this.getList()
+        }).finally(() => { this.importSubmitting = false })
+      } else {
+        if (!this.importTenantId || !this.importTenantSelection.length) {
+          this.$message.warning('请选择租户并勾选字典')
+          return
+        }
+        this.importSubmitting = true
+        importTemplateFromTenant({
+          tenantId: this.importTenantId,
+          dictTypes: this.importTenantSelection.map(i => i.dictType),
+          overwriteExisting
+        }).then(r => {
+          this.$message.success(r.data.message || '导入成功')
+          this.importOpen = false
+          this.getList()
+        }).finally(() => { this.importSubmitting = false })
+      }
+    },
+    cancel() { this.open = false; this.reset() },
+    reset() {
+      this.form = { dictId: undefined, dictName: undefined, dictType: undefined, status: '0', isManaged: 0, syncMode: 'MERGE', remark: undefined }
+      this.resetForm('form')
+    },
+    handleQuery() { this.queryParams.pageNum = 1; this.getList() },
+    resetQuery() { this.resetForm('queryForm'); this.handleQuery() },
+    handleAdd() { this.reset(); this.open = true; this.title = '添加模板字典类型' },
+    handleSelectionChange(s) { this.ids = s.map(i => i.dictId); this.single = s.length !== 1; this.multiple = !s.length },
+    handleUpdate(row) {
+      this.reset()
+      getTemplateDictType(row.dictId || this.ids[0]).then(r => { this.form = r.data; this.open = true; this.title = '修改模板字典类型' })
+    },
+    submitForm() {
+      this.$refs['form'].validate(valid => {
+        if (!valid) return
+        const req = this.form.dictId != null ? updateTemplateDictType(this.form) : addTemplateDictType(this.form)
+        req.then(() => { this.msgSuccess('保存成功'); this.open = false; this.getList() })
+      })
+    },
+    handleDelete(row) {
+      const ids = row.dictId || this.ids.join(',')
+      this.$confirm('确认删除?', '警告', { type: 'warning' }).then(() => delTemplateDictType(ids))
+        .then(() => { this.getList(); this.msgSuccess('删除成功') }).catch(() => {})
+    }
+  }
+}
+</script>
+
+<style scoped>
+.tip-banner {
+  display: flex;
+  align-items: flex-start;
+  gap: 8px;
+  padding: 10px 14px;
+  margin-bottom: 16px;
+  background: #fdf6ec;
+  border: 1px solid #faecd8;
+  border-radius: 8px;
+  font-size: 13px;
+  color: #e6a23c;
+  line-height: 1.5;
+}
+.tip-banner i { font-size: 16px; margin-top: 1px; }
+.mb8 { margin-bottom: 8px; }
+.mb16 { margin-bottom: 16px; }
+.filter-card { padding-bottom: 0; }
+.toolbar-row { display: flex; flex-wrap: wrap; align-items: center; }
+.dict-table { width: 100%; }
+.drawer-body-wrap { padding: 0 20px 20px; }
+.drawer-type-meta { margin-bottom: 12px; }
+.import-filter { padding-bottom: 0; background: #fafafa; }
+.import-options {
+  margin-top: 16px;
+  padding: 12px 14px;
+  background: #f5f7fa;
+  border-radius: 6px;
+}
+</style>
+
+<style>
+.dict-data-drawer .el-drawer__header {
+  margin-bottom: 8px;
+  padding-bottom: 16px;
+  border-bottom: 1px solid #ebeef5;
+  font-weight: 600;
+  color: #303133;
+}
+.dict-data-drawer .el-drawer__body { padding-top: 0; }
+</style>
+

+ 476 - 0
src/views/admin/tenantMenu/index.vue

@@ -0,0 +1,476 @@
+<template>
+  <div class="app-container">
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch">
+      <el-form-item label="菜单名称" prop="menuName">
+        <el-input
+          v-model="queryParams.menuName"
+          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="dict in statusOptions"
+            :key="dict.dictValue"
+            :label="dict.dictLabel"
+            :value="dict.dictValue"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
+        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
+      </el-form-item>
+    </el-form>
+
+    <el-row :gutter="10" class="mb8">
+      <el-col :span="1.5">
+        <el-button
+          type="primary"
+          plain
+          icon="el-icon-plus"
+          size="mini"
+          @click="handleAdd"
+          v-hasPermi="['system:menu:add']"
+        >新增</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button
+          type="info"
+          plain
+          icon="el-icon-sort"
+          size="mini"
+          @click="toggleExpandAll"
+        >展开/折叠</el-button>
+      </el-col>
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
+    </el-row>
+
+    <el-table
+      v-if="refreshTable"
+      v-loading="loading"
+      :data="menuList"
+      row-key="menuId"
+      :default-expand-all="isExpandAll"
+      :tree-props="{children: 'children', hasChildren: 'hasChildren'}"
+    >
+      <el-table-column prop="menuName" label="菜单名称" :show-overflow-tooltip="true" width="160"></el-table-column>
+      <el-table-column prop="menuType" label="类型" width="72" align="center">
+        <template slot-scope="scope">
+          <el-tag v-if="scope.row.menuType === 'M'" size="mini">目录</el-tag>
+          <el-tag v-else-if="scope.row.menuType === 'C'" type="success" size="mini">菜单</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column prop="icon" label="图标" align="center" width="100">
+        <template slot-scope="scope">
+          <svg-icon :icon-class="scope.row.icon" />
+        </template>
+      </el-table-column>
+      <el-table-column prop="orderNum" label="排序" width="60"></el-table-column>
+      <el-table-column prop="perms" label="权限标识" :show-overflow-tooltip="true"></el-table-column>
+      <el-table-column prop="component" label="组件路径" :show-overflow-tooltip="true"></el-table-column>
+      <el-table-column prop="status" label="状态" width="80">
+        <template slot-scope="scope">
+          <dict-tag :options="statusOptions" :value="scope.row.status"/>
+        </template>
+      </el-table-column>
+      <el-table-column label="创建时间" align="center" prop="createTime">
+        <template slot-scope="scope">
+          <span>{{ parseTime(scope.row.createTime) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
+        <template slot-scope="scope">
+          <el-button size="mini"
+                     type="text"
+                     icon="el-icon-edit"
+                     @click="handleUpdate(scope.row)"
+                     v-hasPermi="['system:menu:edit']"
+          >修改</el-button>
+          <el-button
+            size="mini"
+            type="text"
+            icon="el-icon-plus"
+            @click="handleAdd(scope.row)"
+            v-hasPermi="['system:menu:add']"
+          >新增</el-button>
+          <el-button
+            size="mini"
+            type="text"
+            icon="el-icon-delete"
+            @click="handleDelete(scope.row)"
+            v-hasPermi="['system:menu:remove']"
+          >删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <!-- 添加或修改菜单对话框 -->
+    <el-dialog :title="title" :visible.sync="open" width="680px" append-to-body>
+      <el-form ref="form" :model="form" :rules="rules" label-width="100px">
+        <el-row>
+          <el-col :span="24">
+            <el-form-item label="上级菜单">
+              <treeselect
+                v-model="form.parentId"
+                :options="menuOptions"
+                :normalizer="normalizer"
+                :show-count="true"
+                placeholder="选择上级菜单"
+              />
+            </el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item label="菜单类型" prop="menuType">
+              <el-radio-group v-model="form.menuType">
+                <el-radio label="M">目录</el-radio>
+                <el-radio label="C">菜单</el-radio>
+                <el-radio label="F">按钮</el-radio>
+              </el-radio-group>
+            </el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item v-if="form.menuType != 'F'" label="菜单图标">
+              <el-popover
+                placement="bottom-start"
+                width="460"
+                trigger="click"
+                @show="$refs['iconSelect'].reset()"
+              >
+                <IconSelect ref="iconSelect" @selected="selected" />
+                <el-input slot="reference" v-model="form.icon" placeholder="点击选择图标" readonly>
+                  <svg-icon
+                    v-if="form.icon"
+                    slot="prefix"
+                    :icon-class="form.icon"
+                    class="el-input__icon"
+                    style="height: 32px;width: 16px;"
+                  />
+                  <i v-else slot="prefix" class="el-icon-search el-input__icon" />
+                </el-input>
+              </el-popover>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="菜单名称" prop="menuName">
+              <el-input v-model="form.menuName" placeholder="请输入菜单名称" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="显示排序" prop="orderNum">
+              <el-input-number v-model="form.orderNum" controls-position="right" :min="0" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item v-if="form.menuType != 'F'">
+              <span slot="label">
+                <el-tooltip content="选择是外链则路由地址需要以`http(s)://`开头" placement="top">
+                <i class="el-icon-question"></i>
+                </el-tooltip>
+                是否外链
+              </span>
+              <el-radio-group v-model="form.isFrame">
+                <el-radio label="0">是</el-radio>
+                <el-radio label="1">否</el-radio>
+              </el-radio-group>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item v-if="form.menuType != 'F'" prop="path">
+              <span slot="label">
+                <el-tooltip content="访问的路由地址,如:`user`,如外网地址需内链访问则以`http(s)://`开头" placement="top">
+                <i class="el-icon-question"></i>
+                </el-tooltip>
+                路由地址
+              </span>
+              <el-input v-model="form.path" placeholder="请输入路由地址" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12" v-if="form.menuType == 'C'">
+            <el-form-item prop="component">
+              <span slot="label">
+                <el-tooltip content="访问的组件路径,如:`system/user/index`,默认在`views`目录下" placement="top">
+                <i class="el-icon-question"></i>
+                </el-tooltip>
+                组件路径
+              </span>
+              <el-input v-model="form.component" placeholder="请输入组件路径" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item v-if="form.menuType != 'M'">
+              <el-input v-model="form.perms" placeholder="请输入权限标识" maxlength="100" />
+              <span slot="label">
+                <el-tooltip content="控制器中定义的权限字符,如:@PreAuthorize(`@ss.hasPermi('system:user:list')`)" placement="top">
+                <i class="el-icon-question"></i>
+                </el-tooltip>
+                权限字符
+              </span>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item v-if="form.menuType == 'C'">
+              <el-input v-model="form.query" placeholder="请输入路由参数" maxlength="255" />
+              <span slot="label">
+                <el-tooltip content='访问路由的默认传递参数,如:`{"id": 1, "name": "ry"}`' placement="top">
+                <i class="el-icon-question"></i>
+                </el-tooltip>
+                路由参数
+              </span>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item v-if="form.menuType == 'C'">
+              <span slot="label">
+                <el-tooltip content="选择是则会被`keep-alive`缓存,需要匹配组件的`name`和地址保持一致" placement="top">
+                <i class="el-icon-question"></i>
+                </el-tooltip>
+                是否缓存
+              </span>
+              <el-radio-group v-model="form.isCache">
+                <el-radio label="0">缓存</el-radio>
+                <el-radio label="1">不缓存</el-radio>
+              </el-radio-group>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item v-if="form.menuType != 'F'">
+              <span slot="label">
+                <el-tooltip content="选择隐藏则路由将不会出现在侧边栏,但仍然可以访问" placement="top">
+                <i class="el-icon-question"></i>
+                </el-tooltip>
+                显示状态
+              </span>
+              <el-radio-group v-model="form.visible">
+                <el-radio
+                  v-for="dict in visibleOptions"
+                  :key="dict.dictValue"
+                  :label="dict.dictValue"
+                >{{dict.dictLabel}}</el-radio>
+              </el-radio-group>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item v-if="form.menuType != 'F'">
+              <span slot="label">
+                <el-tooltip content="选择停用则路由将不会出现在侧边栏,也不能被访问" placement="top">
+                <i class="el-icon-question"></i>
+                </el-tooltip>
+                菜单状态
+              </span>
+              <el-radio-group v-model="form.status">
+                <el-radio
+                  v-for="dict in statusOptions"
+                  :key="dict.dictValue"
+                  :label="dict.dictValue"
+                >{{dict.dictLabel}}</el-radio>
+              </el-radio-group>
+            </el-form-item>
+          </el-col>
+        </el-row>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" @click="submitForm">确 定</el-button>
+        <el-button @click="cancel">取 消</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { tenantMenu,addTenantMenu,updateTenantMenu,getTenantMenu,delTenantMenu } from "@/api/system/menu";
+import Treeselect from "@riophae/vue-treeselect";
+import "@riophae/vue-treeselect/dist/vue-treeselect.css";
+import IconSelect from "@/components/IconSelect";
+
+/**
+ * 租户管理端菜单模板维护(主库 tenant_sys_menu)
+ * 新建租户时从此模板复制到租户库 sys_menu
+ */
+export default {
+  name: "AdminTenantSysMenu",
+  components: { Treeselect, IconSelect },
+  data() {
+    return {
+      // 遮罩层
+      loading: true,
+      // 显示搜索条件
+      showSearch: true,
+      // 菜单表格树数据
+      menuList: [],
+      // 菜单树选项
+      menuOptions: [],
+      // 弹出层标题
+      title: "",
+      // 是否显示弹出层
+      open: false,
+      // 是否展开,默认全部折叠
+      isExpandAll: false,
+      // 重新渲染表格状态
+      refreshTable: true,
+      // 显示状态数据字典
+      visibleOptions: [],
+      // 菜单状态数据字典
+      statusOptions: [],
+      // 查询参数
+      queryParams: {
+        menuName: undefined,
+        visible: undefined
+      },
+      // 表单参数
+      form: {},
+      // 表单校验
+      rules: {
+        menuName: [
+          { required: true, message: "菜单名称不能为空", trigger: "blur" }
+        ],
+        orderNum: [
+          { required: true, message: "菜单顺序不能为空", trigger: "blur" }
+        ],
+        path: [
+          { required: true, message: "路由地址不能为空", trigger: "blur" }
+        ]
+      }
+    };
+  },
+  created() {
+    this.getList();
+    this.getDicts("sys_show_hide").then(response => {
+      this.visibleOptions = response.data;
+    });
+    this.getDicts("sys_normal_disable").then(response => {
+      this.statusOptions = response.data;
+    });
+  },
+  methods: {
+    // 选择图标
+    selected(name) {
+      this.form.icon = name;
+    },
+    /** 查询菜单列表 */
+    getList() {
+      this.loading = true;
+      tenantMenu(this.queryParams).then(response => {
+        this.menuList = this.handleTree(response.data, "menuId");
+        this.loading = false;
+      });
+    },
+    /** 转换菜单数据结构 */
+    normalizer(node) {
+      if (node.children && !node.children.length) {
+        delete node.children;
+      }
+      return {
+        id: node.menuId,
+        label: node.menuName,
+        children: node.children
+      };
+    },
+    /** 查询菜单下拉树结构 */
+    getTreeselect() {
+      tenantMenu().then(response => {
+        this.menuOptions = [];
+        const menu = { menuId: 0, menuName: '主类目', children: [] };
+        menu.children = this.handleTree(response.data, "menuId");
+        this.menuOptions.push(menu);
+      });
+    },
+    // 取消按钮
+    cancel() {
+      this.open = false;
+      this.reset();
+    },
+    // 表单重置
+    reset() {
+      this.form = {
+        menuId: undefined,
+        parentId: 0,
+        menuName: undefined,
+        icon: undefined,
+        menuType: "M",
+        orderNum: undefined,
+        isFrame: "1",
+        isCache: "0",
+        visible: "0",
+        status: "0"
+      };
+      this.resetForm("form");
+    },
+    /** 搜索按钮操作 */
+    handleQuery() {
+      this.getList();
+    },
+    /** 重置按钮操作 */
+    resetQuery() {
+      this.resetForm("queryForm");
+      this.handleQuery();
+    },
+    /** 新增按钮操作 */
+    handleAdd(row) {
+      this.reset();
+      this.getTreeselect();
+      if (row != null && row.menuId) {
+        this.form.parentId = row.menuId;
+      } else {
+        this.form.parentId = 0;
+      }
+      this.open = true;
+      this.title = "添加菜单";
+    },
+    /** 展开/折叠操作 */
+    toggleExpandAll() {
+      this.refreshTable = false;
+      this.isExpandAll = !this.isExpandAll;
+      this.$nextTick(() => {
+        this.refreshTable = true;
+      });
+    },
+    /** 修改按钮操作 */
+    handleUpdate(row) {
+      this.reset();
+      this.getTreeselect();
+      getTenantMenu(row.menuId).then(response => {
+        this.form = response.data;
+        this.open = true;
+        this.title = "修改菜单";
+      });
+    },
+    /** 提交按钮 */
+    submitForm: function() {
+      this.$refs["form"].validate(valid => {
+        if (valid) {
+          if (this.form.menuId != undefined) {
+            updateTenantMenu(this.form).then(response => {
+              this.msgSuccess("修改成功");
+              this.open = false;
+              this.getList();
+            });
+          } else {
+            addTenantMenu(this.form).then(response => {
+              this.msgSuccess("新增成功");
+              this.open = false;
+              this.getList();
+            });
+          }
+        }
+      });
+    },
+    /** 删除按钮操作 */
+    handleDelete(row) {
+      this.$confirm('是否确认删除名称为"' + row.menuName + '"的数据项?', "警告", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(function() {
+        return delTenantMenu(row.menuId);
+      }).then(() => {
+        this.getList();
+        this.msgSuccess("删除成功");
+      }).catch(() => {});
+    }
+  }
+};
+</script>

+ 20 - 29
src/views/admin/videoResource/index.vue

@@ -2,22 +2,8 @@
   <div class="app-container">
     <el-card shadow="never" class="mb16 filter-card">
       <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" size="small" label-width="100px">
-        <el-form-item label="租户名称" prop="companyId">
-          <el-select
-            v-model="queryParams.companyId"
-            placeholder="选择租户"
-            clearable
-            filterable
-            size="small"
-            style="width: 200px"
-          >
-            <el-option
-              v-for="item in companyList"
-              :key="item.companyId"
-              :label="item.companyName"
-              :value="item.companyId"
-            />
-          </el-select>
+        <el-form-item label="租户名称" prop="tenantId">
+          <inline-tenant-selector @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" />
@@ -51,36 +37,33 @@
 
 <script>
 import { listAllVideoResources } from '@/api/admin/videoResource'
-import { listAllCompanies } from '@/api/admin/sysCompany'
+import InlineTenantSelector from '@/components/InlineTenantSelector'
 
 export default {
   name: 'AdminVideoResource',
+  components: { InlineTenantSelector },
   data() {
     return {
       loading: false,
       showSearch: true,
       list: [],
       total: 0,
-      companyList: [],
       queryParams: {
         pageNum: 1,
         pageSize: 10,
-        companyId: null,
+        tenantId: null,
         videoName: null
       }
     }
   },
-  created() {
-    this.getCompanyList()
-    this.getList()
-  },
   methods: {
-    getCompanyList() {
-      listAllCompanies({ pageSize: 9999 }).then(response => {
-        this.companyList = response.rows || []
-      })
-    },
     getList() {
+      if (!this.queryParams.tenantId) {
+        this.list = []
+        this.total = 0
+        this.loading = false
+        return
+      }
       this.loading = true
       listAllVideoResources(this.queryParams).then(res => {
         this.list = res.rows
@@ -88,13 +71,21 @@ export default {
         this.loading = false
       }).catch(() => { this.loading = false })
     },
+    handleTenantChange(val) {
+      this.queryParams.tenantId = val || null
+      this.handleQuery()
+    },
     handleQuery() {
+      if (!this.queryParams.tenantId) {
+        this.$message.warning('请先选择租户')
+        return
+      }
       this.queryParams.pageNum = 1
       this.getList()
     },
     resetQuery() {
       this.resetForm('queryForm')
-      this.queryParams.companyId = null
+      this.queryParams.tenantId = null
       this.handleQuery()
     }
   }

+ 25 - 9
src/views/admin/voice/index.vue

@@ -2,6 +2,9 @@
   <div class="app-container">
     <el-card shadow="never" class="mb16 filter-card">
       <el-form :model="queryParams" ref="queryForm" :inline="true" size="small" label-width="100px">
+        <el-form-item label="租户名称" prop="tenantId">
+          <inline-tenant-selector @change="handleTenantChange" />
+        </el-form-item>
         <el-form-item label="任务名称" prop="taskName">
           <el-input
             v-model="queryParams.taskName"
@@ -10,14 +13,6 @@
             @keyup.enter.native="handleQuery"
           />
         </el-form-item>
-        <el-form-item label="租户名称" prop="companyName">
-          <el-input
-            v-model="queryParams.companyName"
-            placeholder="请输入租户名称"
-            clearable
-            @keyup.enter.native="handleQuery"
-          />
-        </el-form-item>
         <el-form-item label="状态" prop="status">
           <el-select v-model="queryParams.status" placeholder="请选择状态" clearable>
             <el-option label="待启动" :value="0" />
@@ -132,6 +127,7 @@
 
 <script>
 import request from '@/utils/request'
+import InlineTenantSelector from '@/components/InlineTenantSelector'
 
 // admin专用:走 /admin/ 前缀,代理到 fs-admin 8004
 function listRobotic(query) {
@@ -146,6 +142,7 @@ function exportRobotic(query) {
 
 export default {
   name: 'AdminVoice',
+  components: { InlineTenantSelector },
   data() {
     return {
       loading: true,
@@ -157,7 +154,7 @@ export default {
         pageNum: 1,
         pageSize: 10,
         taskName: null,
-        companyName: null,
+        tenantId: null,
         status: null
       },
       detailOpen: false,
@@ -169,6 +166,12 @@ export default {
   },
   methods: {
     getList() {
+      if (!this.queryParams.tenantId) {
+        this.dataList = []
+        this.total = 0
+        this.loading = false
+        return
+      }
       this.loading = true
       listRobotic(this.queryParams).then(response => {
         this.dataList = response.rows
@@ -177,11 +180,20 @@ export default {
       })
     },
     handleQuery() {
+      if (!this.queryParams.tenantId) {
+        this.$message.warning('请先选择租户')
+        return
+      }
       this.queryParams.pageNum = 1
       this.getList()
     },
+    handleTenantChange(val) {
+      this.queryParams.tenantId = val || null
+      this.handleQuery()
+    },
     resetQuery() {
       this.resetForm('queryForm')
+      this.queryParams.tenantId = null
       this.handleQuery()
     },
     handleDetail(row) {
@@ -191,6 +203,10 @@ export default {
       })
     },
     handleExport() {
+      if (!this.queryParams.tenantId) {
+        this.$message.warning('请先选择租户')
+        return
+      }
       this.exportLoading = true
       exportRobotic(this.queryParams).then(response => {
         this.download(response.msg)

+ 6 - 1
src/views/saas/tenantCompany/index.vue

@@ -58,6 +58,12 @@
       :tree-props="{children: 'children', hasChildren: 'hasChildren'}"
     >
       <el-table-column prop="menuName" label="菜单名称" :show-overflow-tooltip="true" width="160"></el-table-column>
+      <el-table-column prop="menuType" label="类型" width="72" align="center">
+        <template slot-scope="scope">
+          <el-tag v-if="scope.row.menuType === 'M'" size="mini">目录</el-tag>
+          <el-tag v-else-if="scope.row.menuType === 'C'" type="success" size="mini">菜单</el-tag>
+        </template>
+      </el-table-column>
       <el-table-column prop="icon" label="图标" align="center" width="100">
         <template slot-scope="scope">
           <svg-icon :icon-class="scope.row.icon" />
@@ -340,7 +346,6 @@ export default {
     selected(name) {
       this.form.icon = name;
     },
-    /** 查询菜单列表 */
     getList() {
       this.loading = true;
       tenantComMenu(this.queryParams).then(response => {

+ 6 - 1
src/views/saas/tenantMenu/index.vue

@@ -58,6 +58,12 @@
       :tree-props="{children: 'children', hasChildren: 'hasChildren'}"
     >
       <el-table-column prop="menuName" label="菜单名称" :show-overflow-tooltip="true" width="160"></el-table-column>
+      <el-table-column prop="menuType" label="类型" width="72" align="center">
+        <template slot-scope="scope">
+          <el-tag v-if="scope.row.menuType === 'M'" size="mini">目录</el-tag>
+          <el-tag v-else-if="scope.row.menuType === 'C'" type="success" size="mini">菜单</el-tag>
+        </template>
+      </el-table-column>
       <el-table-column prop="icon" label="图标" align="center" width="100">
         <template slot-scope="scope">
           <svg-icon :icon-class="scope.row.icon" />
@@ -340,7 +346,6 @@ export default {
     selected(name) {
       this.form.icon = name;
     },
-    /** 查询菜单列表 */
     getList() {
       this.loading = true;
       tenantMenu(this.queryParams).then(response => {