Ver código fonte

Merge remote-tracking branch 'origin/saas_adminUi' into saas_adminUi

周洋 2 dias atrás
pai
commit
8c891eeed6

+ 18 - 1
src/api/admin/sysCompany.js

@@ -1,5 +1,13 @@
 import request from '@/utils/request'
 
+// 获取租户下拉选项(总后台)
+export function listCompanyOptions() {
+  return request({
+    url: '/admin/company/options',
+    method: 'get'
+  })
+}
+
 // 查询所有租户列表
 export function listAllCompanies(query) {
   return request({
@@ -9,6 +17,15 @@ export function listAllCompanies(query) {
   })
 }
 
+// 租户下拉列表(分页:pageNum、pageSize;搜索:tenantName;筛选:status 等)
+export function listAdminTenantList(query) {
+  return request({
+    url: '/admin/company/tenantList',
+    method: 'get',
+    params: query
+  })
+}
+
 // 获取租户详情
 export function getCompanyInfo(companyId) {
   return request({
@@ -105,4 +122,4 @@ export function resetTenantPwd(companyId, password) {
     method: 'put',
     data: { password }
   })
-}
+}

+ 13 - 3
src/api/system/config.js

@@ -17,10 +17,20 @@ export function getConfig(configId) {
   })
 }
 
-export function getConfigByKey(configKey) {
+/**
+ * 按 configKey 查询配置
+ * @param {string} configKey 配置键
+ * @param {string|number} [tenantId] 租户ID;传入则查该租户库,不传则查主库
+ */
+export function getConfigByKey(configKey, tenantId) {
+  const params = {}
+  if (tenantId !== undefined && tenantId !== null && tenantId !== '') {
+    params.tenantId = tenantId
+  }
   return request({
     url: '/system/config/getConfigByKey/' + configKey,
-    method: 'get'
+    method: 'get',
+    params
   })
 }
 // 根据参数键名查询参数值
@@ -97,4 +107,4 @@ export function getGatewayList(query) {
     method: 'get',
     params: query
   })
-}
+}

+ 221 - 0
src/api/tenant/dict.js

@@ -0,0 +1,221 @@
+import request from '@/utils/request'
+
+// ==================== 方案1:租户字典代管 ====================
+
+export function listTenantDictType(tenantId, query) {
+  return request({
+    url: '/tenant/dict/type/list',
+    method: 'get',
+    params: { tenantId, ...query }
+  })
+}
+
+export function getTenantDictType(tenantId, dictId) {
+  return request({
+    url: '/tenant/dict/type/' + dictId,
+    method: 'get',
+    params: { tenantId }
+  })
+}
+
+export function addTenantDictType(tenantId, data) {
+  return request({
+    url: '/tenant/dict/type',
+    method: 'post',
+    params: { tenantId },
+    data
+  })
+}
+
+export function updateTenantDictType(tenantId, data) {
+  return request({
+    url: '/tenant/dict/type',
+    method: 'put',
+    params: { tenantId },
+    data
+  })
+}
+
+export function delTenantDictType(tenantId, dictId) {
+  return request({
+    url: '/tenant/dict/type/' + dictId,
+    method: 'delete',
+    params: { tenantId }
+  })
+}
+
+export function listTenantDictData(tenantId, query) {
+  return request({
+    url: '/tenant/dict/data/list',
+    method: 'get',
+    params: { tenantId, ...query }
+  })
+}
+
+export function getTenantDictData(tenantId, dictCode) {
+  return request({
+    url: '/tenant/dict/data/' + dictCode,
+    method: 'get',
+    params: { tenantId }
+  })
+}
+
+export function addTenantDictData(tenantId, data) {
+  return request({
+    url: '/tenant/dict/data',
+    method: 'post',
+    params: { tenantId },
+    data
+  })
+}
+
+export function updateTenantDictData(tenantId, data) {
+  return request({
+    url: '/tenant/dict/data',
+    method: 'put',
+    params: { tenantId },
+    data
+  })
+}
+
+export function delTenantDictData(tenantId, dictCode) {
+  return request({
+    url: '/tenant/dict/data/' + dictCode,
+    method: 'delete',
+    params: { tenantId }
+  })
+}
+
+export function refreshTenantDictCache(tenantId) {
+  return request({
+    url: '/tenant/dict/cache/refresh',
+    method: 'delete',
+    params: { tenantId }
+  })
+}
+
+// ==================== 方案2:平台字典模板 ====================
+
+export function listTemplateDictType(query) {
+  return request({
+    url: '/tenant/dict/template/type/list',
+    method: 'get',
+    params: query
+  })
+}
+
+export function getTemplateDictType(dictId) {
+  return request({
+    url: '/tenant/dict/template/type/' + dictId,
+    method: 'get'
+  })
+}
+
+export function addTemplateDictType(data) {
+  return request({
+    url: '/tenant/dict/template/type',
+    method: 'post',
+    data
+  })
+}
+
+export function updateTemplateDictType(data) {
+  return request({
+    url: '/tenant/dict/template/type',
+    method: 'put',
+    data
+  })
+}
+
+export function delTemplateDictType(dictId) {
+  return request({
+    url: '/tenant/dict/template/type/' + dictId,
+    method: 'delete'
+  })
+}
+
+export function listTemplateDictData(query) {
+  return request({
+    url: '/tenant/dict/template/data/list',
+    method: 'get',
+    params: query
+  })
+}
+
+export function getTemplateDictData(dictCode) {
+  return request({
+    url: '/tenant/dict/template/data/' + dictCode,
+    method: 'get'
+  })
+}
+
+export function addTemplateDictData(data) {
+  return request({
+    url: '/tenant/dict/template/data',
+    method: 'post',
+    data
+  })
+}
+
+export function updateTemplateDictData(data) {
+  return request({
+    url: '/tenant/dict/template/data',
+    method: 'put',
+    data
+  })
+}
+
+export function delTemplateDictData(dictCode) {
+  return request({
+    url: '/tenant/dict/template/data/' + dictCode,
+    method: 'delete'
+  })
+}
+
+export function templateDictTypeOptions() {
+  return request({
+    url: '/tenant/dict/template/type/optionselect',
+    method: 'get'
+  })
+}
+
+export function importTemplateFromPlatform(data) {
+  return request({
+    url: '/tenant/dict/template/import/platform',
+    method: 'post',
+    data
+  })
+}
+
+export function importTemplateFromTenant(data) {
+  return request({
+    url: '/tenant/dict/template/import/tenant',
+    method: 'post',
+    data
+  })
+}
+
+// ==================== 同步下发 ====================
+
+export function runDictSync(data) {
+  return request({
+    url: '/tenant/dict/sync/run',
+    method: 'post',
+    data
+  })
+}
+
+export function getDictSyncTask(taskNo) {
+  return request({
+    url: '/tenant/dict/sync/task/' + taskNo,
+    method: 'get'
+  })
+}
+
+export function previewDictSync(tenantId, dictTypes) {
+  return request({
+    url: '/tenant/dict/sync/preview',
+    method: 'get',
+    params: { tenantId, dictTypes }
+  })
+}

+ 22 - 4
src/components/InlineTenantSelector/index.vue

@@ -19,9 +19,17 @@
 
 <script>
 import { tenantList } from '@/api/tenant/tenant'
+import { listCompanyOptions } from '@/api/admin/sysCompany'
 
 export default {
   name: 'InlineTenantSelector',
+  props: {
+    /** admin=总后台(/admin/company/options),saas=租户总后台(/tenant/tenant/tenantList) */
+    mode: {
+      type: String,
+      default: 'saas'
+    }
+  },
   data() {
     return {
       tenantList: [],
@@ -38,15 +46,25 @@ export default {
   },
   methods: {
     loadTenants() {
-      tenantList().then(response => {
-        this.tenantList = response.rows || response.data || []
+      const loader = this.mode === 'admin'
+        ? listCompanyOptions().then(response => response.data || [])
+        : tenantList().then(response => response.rows || response.data || [])
+      loader.then(list => {
+        this.tenantList = list
       }).catch(() => {
         this.tenantList = []
       })
     },
     handleChange(val) {
-      this.$store.dispatch('tenant/setCompanyId', val)
-      this.$emit('change', 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)
     }
   }
 }

+ 26 - 0
src/router/index.js

@@ -131,6 +131,32 @@ export const constantRoutes = [
       }
     ]
   },
+  {
+    path: '/admin/dict-data',
+    component: () => import('@/layout/AdminLayout'),
+    hidden: true,
+    children: [
+      {
+        path: 'index/:dictId(\\d+)',
+        component: () => import('@/views/system/dict/data'),
+        name: 'AdminDictData',
+        meta: { title: '字典数据', activeMenu: '/admin/dict' }
+      }
+    ]
+  },
+  {
+    path: '/admin/tenant-dict-template-data',
+    component: () => import('@/layout/AdminLayout'),
+    hidden: true,
+    children: [
+      {
+        path: 'index/:dictId(\\d+)',
+        component: () => import('@/views/admin/tenantDict/template/data'),
+        name: 'AdminTenantDictTemplateData',
+        meta: { title: '模板字典数据', activeMenu: '/admin/dict' }
+      }
+    ]
+  },
   {
     path: '/monitor/job-log',
     component: Layout,

+ 274 - 30
src/views/admin/frontConfig/index.vue

@@ -1,9 +1,63 @@
 <template>
   <div class="app-container">
     <el-card v-loading="loading" shadow="never">
-      <div slot="header">
-        <span>前端配置</span>
+      <div slot="header" class="page-header">
+        <span>点播线路配置</span>
+        <el-form :inline="true" size="small" class="tenant-form">
+          <el-form-item label="选择租户">
+            <el-select
+              ref="tenantSelect"
+              v-model="selectedTenantId"
+              placeholder="请输入租户名称搜索"
+              filterable
+              remote
+              clearable
+              :remote-method="handleTenantSearch"
+              :loading="tenantSelectLoading"
+              style="width: 280px"
+              @visible-change="handleTenantDropdownVisible"
+              @clear="handleTenantSelectClear"
+              @change="loadConfig"
+            >
+              <el-option
+                v-for="item in tenantList"
+                :key="item.id"
+                :label="formatTenantLabel(item)"
+                :value="item.id"
+              />
+              <el-option v-if="hasMoreTenants" key="tenant-load-more" disabled class="tenant-load-more-option">
+                <div class="load-more" @click.stop="loadMoreTenants">
+                  <span>加载更多</span>
+                  <i v-if="tenantLoadingMore" class="el-icon-loading" />
+                </div>
+              </el-option>
+            </el-select>
+          </el-form-item>
+        </el-form>
       </div>
+
+      <el-alert
+        v-if="!selectedTenantId"
+        title="当前为系统默认配置(主库),保存后将更新全局配置;选择租户后可单独配置该租户参数"
+        type="info"
+        :closable="false"
+        show-icon
+        class="tenant-tip"
+      />
+
+      <el-alert
+        v-if="selectedTenantId && tenantConfigEmpty"
+        title="该租户尚未保存过此配置,可直接填写后保存;如需参考系统默认配置,可点击下方按钮加载(不会自动保存)。"
+        type="warning"
+        :closable="false"
+        show-icon
+        class="tenant-tip"
+      >
+        <el-button type="text" size="small" :loading="fallbackLoading" @click="loadGlobalConfigAsFallback">
+          加载系统默认配置
+        </el-button>
+      </el-alert>
+
       <el-form ref="form" :model="form" label-width="200px">
         <!-- 存储桶配置 -->
         <el-card shadow="never" class="section-card">
@@ -125,8 +179,8 @@
             </el-col>
           </el-row>
         </el-card>
+        </el-form>
 
-      </el-form>
       <div style="text-align: center; margin-top: 20px; padding-bottom: 20px;">
         <el-button type="primary" size="medium" @click="submitForm" :loading="submitLoading">保存配置</el-button>
       </div>
@@ -136,6 +190,23 @@
 
 <script>
 import { getConfigByKey, updateConfigByKey } from '@/api/system/config'
+import { listAdminTenantList } from '@/api/admin/sysCompany'
+
+const defaultForm = () => ({
+  obsAccessKeyId: '',
+  obsSecretAccessKey: '',
+  obsServer: '',
+  obsBucket: '',
+  cosBucket: '',
+  cosRegion: '',
+  videoLinePrimary: '',
+  videoLineSecondary: '',
+  livePath: '',
+  volcanoVideoDomain: '',
+  volcanoVodSpace: '',
+  liveWebSocketUrl: '',
+  courseDefaultType: '1'
+})
 
 export default {
   name: 'AdminFrontConfig',
@@ -145,56 +216,193 @@ export default {
       submitLoading: false,
       configId: null,
       configKey: 'his.adminUi.config',
-      form: {
-        obsAccessKeyId: '',
-        obsSecretAccessKey: '',
-        obsServer: '',
-        obsBucket: '',
-        cosBucket: '',
-        cosRegion: '',
-        videoLinePrimary: '',
-        videoLineSecondary: '',
-        livePath: '',
-        volcanoVideoDomain: '',
-        volcanoVodSpace: '',
-        liveWebSocketUrl: '',
-        courseDefaultType: '1'
-      }
+      selectedTenantId: null,
+      tenantConfigEmpty: false,
+      fallbackLoading: false,
+      tenantList: [],
+      tenantTotal: 0,
+      hasMoreTenants: false,
+      tenantSelectLoading: false,
+      tenantLoadingMore: false,
+      tenantQueryParams: {
+        pageNum: 1,
+        pageSize: 20,
+        tenantName: '',
+        status: 1
+      },
+      form: defaultForm(),
+      tenantSearchTimer: null,
+      tenantSelectInputEl: null,
+      tenantSelectInputHandler: null
     }
   },
-  created() {
+  mounted() {
     this.loadConfig()
   },
+  beforeDestroy() {
+    this.unbindTenantSelectInputListener()
+  },
   methods: {
+    formatTenantLabel(item) {
+      if (item.tenantCode) {
+        return `${item.tenantName}(${item.tenantCode})`
+      }
+      return item.tenantName
+    },
+    handleTenantSearch(query) {
+      if (query === undefined) return
+      this.tenantQueryParams.tenantName = (query || '').trim()
+      this.tenantQueryParams.pageNum = 1
+      this.fetchTenantList()
+    },
+    /** 清空已选租户时,同时重置搜索条件并刷新列表 */
+    handleTenantSelectClear() {
+      this.handleTenantSearch('')
+    },
+    getTenantSelectInputEl() {
+      const root = this.$refs.tenantSelect && this.$refs.tenantSelect.$el
+      return root ? root.querySelector('input.el-input__inner') : null
+    },
+    /** Element UI remote 清空搜索框时不一定触发 remote-method,改监听原生 input */
+    bindTenantSelectInputListener() {
+      this.unbindTenantSelectInputListener()
+      const input = this.getTenantSelectInputEl()
+      if (!input) return
+      this.tenantSelectInputEl = input
+      this.tenantSelectInputHandler = () => {
+        const val = (input.value || '').trim()
+        if (!val && this.tenantQueryParams.tenantName) {
+          if (this.tenantSearchTimer) clearTimeout(this.tenantSearchTimer)
+          this.tenantSearchTimer = setTimeout(() => {
+            this.handleTenantSearch('')
+          }, 150)
+        }
+      }
+      input.addEventListener('input', this.tenantSelectInputHandler)
+    },
+    unbindTenantSelectInputListener() {
+      if (this.tenantSearchTimer) {
+        clearTimeout(this.tenantSearchTimer)
+        this.tenantSearchTimer = null
+      }
+      if (this.tenantSelectInputEl && this.tenantSelectInputHandler) {
+        this.tenantSelectInputEl.removeEventListener('input', this.tenantSelectInputHandler)
+      }
+      this.tenantSelectInputEl = null
+      this.tenantSelectInputHandler = null
+    },
+    syncTenantSearchFromInput() {
+      const input = this.getTenantSelectInputEl()
+      const inputVal = input ? (input.value || '').trim() : ''
+      if (!inputVal && this.tenantQueryParams.tenantName) {
+        this.handleTenantSearch('')
+      }
+    },
+    handleTenantDropdownVisible(visible) {
+      if (visible) {
+        if (this.tenantList.length === 0) {
+          this.handleTenantSearch('')
+        }
+        this.$nextTick(() => {
+          this.syncTenantSearchFromInput()
+          this.bindTenantSelectInputListener()
+        })
+      } else {
+        this.unbindTenantSelectInputListener()
+      }
+    },
+    fetchTenantList(isLoadMore = false) {
+      if (!isLoadMore) {
+        this.tenantSelectLoading = true
+      } else {
+        this.tenantLoadingMore = true
+      }
+      listAdminTenantList(this.tenantQueryParams).then(response => {
+        const rows = response.rows || []
+        if (isLoadMore) {
+          const existIds = new Set(this.tenantList.map(t => t.id))
+          const append = rows.filter(r => !existIds.has(r.id))
+          this.tenantList = this.tenantList.concat(append)
+        } else {
+          this.tenantList = rows
+        }
+        this.tenantTotal = response.total || 0
+        this.hasMoreTenants = this.tenantList.length < this.tenantTotal
+      }).finally(() => {
+        this.tenantSelectLoading = false
+        this.tenantLoadingMore = false
+      })
+    },
+    loadMoreTenants() {
+      if (this.tenantLoadingMore || !this.hasMoreTenants) return
+      this.tenantQueryParams.pageNum += 1
+      this.fetchTenantList(true)
+    },
+    resetForm() {
+      this.form = defaultForm()
+      this.configId = null
+      this.tenantConfigEmpty = false
+    },
     loadConfig() {
       this.loading = true
-      getConfigByKey(this.configKey).then(response => {
-        if (response.data) {
-          this.configId = response.data.configId
-          try {
-            const parsed = JSON.parse(response.data.configValue)
-            this.form = { ...this.form, ...parsed }
-          } catch (e) {
-            // 使用默认值
+      this.tenantConfigEmpty = false
+      const tenantId = this.selectedTenantId || undefined
+      getConfigByKey(this.configKey, tenantId).then(response => {
+        const data = response.data
+        if (data) {
+          this.configId = data.configId != null ? data.configId : null
+          if (data.configValue) {
+            this.tenantConfigEmpty = false
+            try {
+              this.form = { ...defaultForm(), ...JSON.parse(data.configValue) }
+            } catch (e) {
+              this.form = defaultForm()
+              this.tenantConfigEmpty = !!this.selectedTenantId
+            }
+          } else {
+            this.form = defaultForm()
+            this.tenantConfigEmpty = !!this.selectedTenantId
           }
         } else {
           this.configId = null
+          this.form = defaultForm()
+          this.tenantConfigEmpty = !!this.selectedTenantId
         }
       }).finally(() => {
         this.loading = false
       })
     },
+    /** 从主库加载默认配置填充表单,保留当前租户 configId 供保存使用 */
+    loadGlobalConfigAsFallback() {
+      this.fallbackLoading = true
+      getConfigByKey(this.configKey).then(response => {
+        if (!response.data || !response.data.configValue) {
+          this.msgWarning('系统默认配置为空')
+          return
+        }
+        try {
+          this.form = { ...defaultForm(), ...JSON.parse(response.data.configValue) }
+          this.msgSuccess('已加载系统默认配置,保存时将写入当前租户')
+        } catch (e) {
+          this.msgWarning('系统默认配置解析失败')
+        }
+      }).finally(() => {
+        this.fallbackLoading = false
+      })
+    },
     submitForm() {
       this.submitLoading = true
       const param = {
-        configId: this.configId,
-        configName: '前端配置',
+        configId: (this.configId != null && this.configId !== '') ? Number(this.configId) : null,
+        configName: '点播线路配置',
         configKey: this.configKey,
-        configValue: JSON.stringify(this.form)
+        configValue: JSON.stringify(this.form),
+        tenantId: this.selectedTenantId ? String(this.selectedTenantId) : null
       }
       updateConfigByKey(param).then(response => {
         if (response.code === 200) {
           this.msgSuccess('修改成功')
+          this.loadConfig()
         }
       }).finally(() => {
         this.submitLoading = false
@@ -209,4 +417,40 @@ export default {
   margin-bottom: 20px;
 }
 
+.page-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  flex-wrap: wrap;
+}
+
+.tenant-form {
+  margin-bottom: 0;
+}
+
+.tenant-form >>> .el-form-item {
+  margin-bottom: 0;
+}
+
+.tenant-tip {
+  margin-bottom: 16px;
+}
+
+.tenant-load-more-option {
+  text-align: center;
+  padding: 8px 0;
+}
+
+.load-more {
+  color: #409eff;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 5px;
+}
+
+.load-more:hover {
+  color: #66b1ff;
+}
 </style>

+ 10 - 10
src/views/admin/keywordManage/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="keywordName">
-          <el-input v-model="queryParams.keywordName" placeholder="请输入关键词" clearable @keyup.enter.native="loadList" />
+        <el-form-item label="关键词" prop="keyword">
+          <el-input v-model="queryParams.keyword" placeholder="请输入关键词" clearable @keyup.enter.native="loadList" />
         </el-form-item>
         <el-form-item>
           <el-button type="primary" icon="el-icon-search" size="mini" @click="loadList">查询</el-button>
@@ -27,7 +27,7 @@
     <!-- ===== 列表 ===== -->
     <el-table border v-loading="loading" :data="dataList" size="small" style="width:100%">
       <el-table-column label="关键词ID" prop="keywordId" min-width="70" align="center" />
-      <el-table-column label="关键词内容" prop="keywordContent" min-width="200" />
+      <el-table-column label="关键词内容" prop="keyword" min-width="200" />
       <el-table-column label="类型" prop="keywordType" min-width="100" align="center">
         <template slot-scope="s">
           <el-tag v-if="s.row.keywordType === 1" type="danger" size="mini">违禁词</el-tag>
@@ -49,8 +49,8 @@
     <!-- ===== 编辑弹窗 ===== -->
     <el-dialog :title="formTitle" :visible.sync="dialogVisible" width="500px" append-to-body>
       <el-form ref="form" :model="form" :rules="rules" label-width="100px">
-        <el-form-item label="关键词内容" prop="keywordContent">
-          <el-input v-model="form.keywordContent" placeholder="请输入关键词内容" />
+        <el-form-item label="关键词内容" prop="keyword">
+          <el-input v-model="form.keyword" placeholder="请输入关键词内容" />
         </el-form-item>
         <el-form-item label="类型" prop="keywordType">
           <el-select v-model="form.keywordType" placeholder="请选择类型" style="width:100%">
@@ -81,13 +81,13 @@ export default {
       loading: false,
       dataList: [],
       total: 0,
-      queryParams: { pageNum: 1, pageSize: 10, keywordName: null },
+      queryParams: { pageNum: 1, pageSize: 10, keyword: null },
       dialogVisible: false,
       formTitle: '',
       submitting: false,
-      form: { keywordId: null, keywordContent: '', keywordType: 1, remark: '' },
+      form: { keywordId: null, keyword: '', keywordType: 1, remark: '' },
       rules: {
-        keywordContent: [{ required: true, message: '请输入关键词内容', trigger: 'blur' }],
+        keyword: [{ required: true, message: '请输入关键词内容', trigger: 'blur' }],
         keywordType: [{ required: true, message: '请选择类型', trigger: 'change' }]
       }
     }
@@ -136,7 +136,7 @@ export default {
       })
     },
     handleDelete(row) {
-      this.$confirm(`确认删除关键词 "${row.keywordContent}"?`, '提示', { type: 'warning' }).then(() => {
+      this.$confirm(`确认删除关键词 "${row.keyword}"?`, '提示', { type: 'warning' }).then(() => {
         request({ url: `/system/keyword/${row.keywordId}`, method: 'delete' }).then(() => {
           this.$message.success('删除成功')
           this.loadList()
@@ -154,7 +154,7 @@ export default {
       })
     },
     reset() {
-      this.form = { keywordId: null, keywordContent: '', keywordType: 1, remark: '' }
+      this.form = { keywordId: null, keyword: '', keywordType: 1, remark: '' }
       if (this.$refs['form']) this.$refs['form'].resetFields()
     }
   }

+ 1 - 1
src/views/admin/menu.js

@@ -38,7 +38,7 @@ const adminRoutes = {
     { path: 'cidConfig', component: () => import('@/views/admin/cidConfig/index'), name: 'AdminCidConfig', meta: { title: 'CID配置' } },
     { path: 'wxConfig', component: () => import('@/views/admin/wxConfig/index'), name: 'AdminWxConfig', meta: { title: '个微配置' } },
     { path: 'ossConfig', component: () => import('@/views/admin/ossConfig/index'), name: 'AdminOssConfig', meta: { title: 'OSS配置' } },
-    { path: 'frontConfig', component: () => import('@/views/admin/frontConfig/index'), name: 'AdminFrontConfig', meta: { title: '前端配置' } },
+    { path: 'frontConfig', component: () => import('@/views/admin/frontConfig/index'), name: 'AdminFrontConfig', meta: { title: '点播线路配置' } },
     { path: 'dbConfig', component: () => import('@/views/admin/dbConfig/index'), name: 'AdminDbConfig', meta: { title: 'DB配置' } },
 
     // 7. 外呼管理

+ 208 - 0
src/views/admin/tenantDict/data.vue

@@ -0,0 +1,208 @@
+<template>
+  <div class="app-container">
+    <el-page-header @back="goBack" :content="'租户字典数据 - ' + (defaultDictType || '')" class="mb8" />
+
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="80px">
+      <el-form-item label="字典标签" prop="dictLabel">
+        <el-input v-model="queryParams.dictLabel" 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="['tenant:dict: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: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:remove']">删除</el-button>
+      </el-col>
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
+    </el-row>
+
+    <el-table v-loading="loading" :data="dataList" @selection-change="handleSelectionChange">
+      <el-table-column type="selection" width="55" align="center" />
+      <el-table-column label="字典编码" align="center" prop="dictCode" width="90" />
+      <el-table-column label="字典标签" align="center" prop="dictLabel">
+        <template slot-scope="scope">
+          <el-tag v-if="scope.row.listClass && scope.row.listClass !== 'default'" :type="scope.row.listClass === 'primary' ? '' : scope.row.listClass">{{ scope.row.dictLabel }}</el-tag>
+          <span v-else>{{ scope.row.dictLabel }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="字典键值" align="center" prop="dictValue" />
+      <el-table-column label="排序" align="center" prop="dictSort" width="70" />
+      <el-table-column label="来源" align="center" width="80">
+        <template slot-scope="scope">
+          <el-tag size="mini" :type="scope.row.dictSource === 'platform' ? 'warning' : 'success'">
+            {{ scope.row.dictSource === 'platform' ? '平台' : '租户' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="平台管控" align="center" width="90">
+        <template slot-scope="scope">
+          <el-tag size="mini" :type="scope.row.isPlatformManaged === 1 ? 'danger' : 'info'">{{ scope.row.isPlatformManaged === 1 ? '是' : '否' }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="状态" align="center" prop="status" width="80">
+        <template slot-scope="scope">
+          <dict-tag :options="statusOptions" :value="scope.row.status"/>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" width="140">
+        <template slot-scope="scope">
+          <el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)" v-hasPermi="['tenant:dict:edit']" :disabled="isPlatformLocked(scope.row)">修改</el-button>
+          <el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)" v-hasPermi="['tenant:dict:remove']" :disabled="isPlatformLocked(scope.row)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
+
+    <el-dialog :title="title" :visible.sync="open" width="520px" append-to-body>
+      <el-form ref="form" :model="form" :rules="rules" label-width="90px">
+        <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" controls-position="right" :min="0" /></el-form-item>
+        <el-form-item label="回显样式" prop="listClass">
+          <el-select v-model="form.listClass">
+            <el-option v-for="item in listClassOptions" :key="item.value" :label="item.label" :value="item.value" />
+          </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="备注" prop="remark"><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>
+  </div>
+</template>
+
+<script>
+import {
+  listTenantDictData, getTenantDictData, addTenantDictData,
+  updateTenantDictData, delTenantDictData, getTenantDictType
+} from '@/api/tenant/dict'
+
+export default {
+  name: 'TenantDictData',
+  data() {
+    return {
+      tenantId: null,
+      loading: true,
+      ids: [],
+      single: true,
+      multiple: true,
+      showSearch: true,
+      total: 0,
+      dataList: [],
+      defaultDictType: '',
+      title: '',
+      open: false,
+      listClassOptions: [
+        { value: 'default', label: '默认' }, { value: 'primary', label: '主要' },
+        { value: 'success', label: '成功' }, { value: 'info', label: '信息' },
+        { value: 'warning', label: '警告' }, { value: 'danger', label: '危险' }
+      ],
+      statusOptions: [],
+      queryParams: { pageNum: 1, pageSize: 10, dictLabel: undefined, dictType: undefined, status: undefined },
+      form: {},
+      rules: {
+        dictLabel: [{ required: true, message: '数据标签不能为空', trigger: 'blur' }],
+        dictValue: [{ required: true, message: '数据键值不能为空', trigger: 'blur' }],
+        dictSort: [{ required: true, message: '数据顺序不能为空', trigger: 'blur' }]
+      }
+    }
+  },
+  created() {
+    this.tenantId = Number(this.$route.query.tenantId)
+    const dictId = this.$route.params && this.$route.params.dictId
+    if (!this.tenantId) {
+      this.$message.error('缺少租户ID')
+      return
+    }
+    this.getType(dictId)
+    this.getDicts('sys_normal_disable').then(r => { this.statusOptions = r.data })
+  },
+  methods: {
+    goBack() {
+      this.$router.push({ path: '/admin/tenantDict', query: { tenantId: this.tenantId } })
+    },
+    isPlatformLocked(row) {
+      return row.dictSource === 'platform' || row.isPlatformManaged === 1
+    },
+    getType(dictId) {
+      getTenantDictType(this.tenantId, dictId).then(response => {
+        this.queryParams.dictType = response.data.dictType
+        this.defaultDictType = response.data.dictType
+        this.getList()
+      })
+    },
+    getList() {
+      this.loading = true
+      listTenantDictData(this.tenantId, this.queryParams).then(response => {
+        this.dataList = response.rows
+        this.total = response.total
+        this.loading = false
+      })
+    },
+    cancel() { this.open = false; this.reset() },
+    reset() {
+      this.form = { dictCode: undefined, dictLabel: undefined, dictValue: undefined, cssClass: undefined, listClass: 'default', dictSort: 0, status: '0', remark: undefined }
+      this.resetForm('form')
+    },
+    handleQuery() { this.queryParams.pageNum = 1; this.getList() },
+    resetQuery() { this.resetForm('queryForm'); this.queryParams.dictType = this.defaultDictType; this.handleQuery() },
+    handleAdd() { this.reset(); this.open = true; this.title = '添加字典数据'; this.form.dictType = this.queryParams.dictType },
+    handleSelectionChange(selection) {
+      this.ids = selection.map(item => item.dictCode)
+      this.single = selection.length !== 1
+      this.multiple = !selection.length
+    },
+    handleUpdate(row) {
+      this.reset()
+      const dictCode = row.dictCode || this.ids[0]
+      getTenantDictData(this.tenantId, dictCode).then(response => {
+        this.form = response.data
+        this.open = true
+        this.title = '修改字典数据'
+      })
+    },
+    submitForm() {
+      this.$refs['form'].validate(valid => {
+        if (!valid) return
+        const req = this.form.dictCode != null
+          ? updateTenantDictData(this.tenantId, this.form)
+          : addTenantDictData(this.tenantId, this.form)
+        req.then(() => { this.msgSuccess('保存成功'); this.open = false; this.getList() })
+      })
+    },
+    handleDelete(row) {
+      const dictCodes = row.dictCode || this.ids.join(',')
+      this.$confirm('是否确认删除?', '警告', { type: 'warning' }).then(() =>
+        delTenantDictData(this.tenantId, dictCodes)
+      ).then(() => { this.getList(); this.msgSuccess('删除成功') }).catch(() => {})
+    }
+  }
+}
+</script>
+
+<style scoped>
+.mb8 { margin-bottom: 12px; }
+</style>

+ 253 - 0
src/views/admin/tenantDict/index.vue

@@ -0,0 +1,253 @@
+<template>
+  <div class="app-container">
+    <el-alert
+      v-if="!tenantId"
+      title="请先在上方选择租户,再进行字典维护"
+      type="warning"
+      show-icon
+      :closable="false"
+      class="mb8"
+    />
+
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="80px">
+      <el-form-item label="租户" required>
+        <inline-tenant-selector mode="admin" @change="onTenantChange" />
+      </el-form-item>
+      <el-form-item label="字典名称" prop="dictName">
+        <el-input v-model="queryParams.dictName" placeholder="请输入字典名称" clearable size="small" style="width: 200px" @keyup.enter.native="handleQuery" />
+      </el-form-item>
+      <el-form-item label="字典类型" prop="dictType">
+        <el-input v-model="queryParams.dictType" placeholder="请输入字典类型" clearable size="small" style="width: 200px" @keyup.enter.native="handleQuery" />
+      </el-form-item>
+      <el-form-item label="状态" prop="status">
+        <el-select v-model="queryParams.status" placeholder="字典状态" clearable size="small" style="width: 140px">
+          <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" :disabled="!tenantId">搜索</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="['tenant:dict:add']" :disabled="!tenantId">新增</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button type="success" plain icon="el-icon-edit" size="mini" :disabled="single || !tenantId" @click="handleUpdate" v-hasPermi="['tenant:dict:edit']">修改</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button type="danger" plain icon="el-icon-delete" size="mini" :disabled="multiple || !tenantId" @click="handleDelete" v-hasPermi="['tenant:dict:remove']">删除</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button type="info" plain icon="el-icon-refresh" size="mini" @click="handleRefreshCache" v-hasPermi="['tenant:dict:edit']" :disabled="!tenantId">刷新缓存</el-button>
+      </el-col>
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
+    </el-row>
+
+    <el-table v-loading="loading" :data="typeList" @selection-change="handleSelectionChange">
+      <el-table-column type="selection" width="55" align="center" />
+      <el-table-column label="字典编号" align="center" prop="dictId" width="90" />
+      <el-table-column label="字典名称" align="center" prop="dictName" :show-overflow-tooltip="true" />
+      <el-table-column label="字典类型" align="center" :show-overflow-tooltip="true">
+        <template slot-scope="scope">
+          <router-link :to="{ path: '/admin/tenant-dict-data/index/' + scope.row.dictId, query: { tenantId: tenantId } }" class="link-type">
+            <span>{{ scope.row.dictType }}</span>
+          </router-link>
+        </template>
+      </el-table-column>
+      <el-table-column label="来源" align="center" prop="dictSource" width="90">
+        <template slot-scope="scope">
+          <el-tag size="mini" :type="scope.row.dictSource === 'platform' ? 'warning' : 'success'">
+            {{ scope.row.dictSource === 'platform' ? '平台' : '租户' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="平台管控" align="center" prop="isPlatformManaged" width="90">
+        <template slot-scope="scope">
+          <el-tag size="mini" :type="scope.row.isPlatformManaged === 1 ? 'danger' : 'info'">
+            {{ scope.row.isPlatformManaged === 1 ? '是' : '否' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="状态" align="center" prop="status" 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="remark" :show-overflow-tooltip="true" />
+      <el-table-column label="创建时间" align="center" prop="createTime" width="160">
+        <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" width="140">
+        <template slot-scope="scope">
+          <el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)" v-hasPermi="['tenant:dict:edit']" :disabled="isPlatformLocked(scope.row)">修改</el-button>
+          <el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)" v-hasPermi="['tenant:dict:remove']" :disabled="isPlatformLocked(scope.row)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
+
+    <el-dialog :title="title" :visible.sync="open" width="520px" append-to-body>
+      <el-form ref="form" :model="form" :rules="rules" label-width="90px">
+        <el-form-item label="字典名称" prop="dictName">
+          <el-input v-model="form.dictName" placeholder="请输入字典名称" />
+        </el-form-item>
+        <el-form-item label="字典类型" prop="dictType">
+          <el-input v-model="form.dictType" placeholder="请输入字典类型" :disabled="form.dictId != null" />
+        </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="备注" prop="remark">
+          <el-input v-model="form.remark" type="textarea" placeholder="请输入内容" />
+        </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 InlineTenantSelector from '@/components/InlineTenantSelector'
+import {
+  listTenantDictType, getTenantDictType, addTenantDictType,
+  updateTenantDictType, delTenantDictType, refreshTenantDictCache
+} from '@/api/tenant/dict'
+
+export default {
+  name: 'AdminTenantDict',
+  components: { InlineTenantSelector },
+  data() {
+    return {
+      loading: false,
+      tenantId: null,
+      ids: [],
+      single: true,
+      multiple: true,
+      showSearch: true,
+      total: 0,
+      typeList: [],
+      title: '',
+      open: false,
+      statusOptions: [],
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        dictName: undefined,
+        dictType: undefined,
+        status: undefined
+      },
+      form: {},
+      rules: {
+        dictName: [{ required: true, message: '字典名称不能为空', trigger: 'blur' }],
+        dictType: [{ required: true, message: '字典类型不能为空', trigger: 'blur' }]
+      }
+    }
+  },
+  created() {
+    this.getDicts('sys_normal_disable').then(response => {
+      this.statusOptions = response.data
+    })
+    if (this.$route.query.tenantId) {
+      this.tenantId = Number(this.$route.query.tenantId)
+      this.getList()
+    }
+  },
+  methods: {
+    onTenantChange(val) {
+      this.tenantId = val
+      this.handleQuery()
+    },
+    isPlatformLocked(row) {
+      return row.dictSource === 'platform' || row.isPlatformManaged === 1
+    },
+    getList() {
+      if (!this.tenantId) {
+        this.typeList = []
+        this.total = 0
+        return
+      }
+      this.loading = true
+      listTenantDictType(this.tenantId, this.queryParams).then(response => {
+        this.typeList = response.rows
+        this.total = response.total
+        this.loading = false
+      }).catch(() => { this.loading = false })
+    },
+    cancel() {
+      this.open = false
+      this.reset()
+    },
+    reset() {
+      this.form = { dictId: undefined, dictName: undefined, dictType: undefined, status: '0', 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(selection) {
+      this.ids = selection.map(item => item.dictId)
+      this.single = selection.length !== 1
+      this.multiple = !selection.length
+    },
+    handleUpdate(row) {
+      this.reset()
+      const dictId = row.dictId || this.ids[0]
+      getTenantDictType(this.tenantId, dictId).then(response => {
+        this.form = response.data
+        this.open = true
+        this.title = '修改租户字典类型'
+      })
+    },
+    submitForm() {
+      this.$refs['form'].validate(valid => {
+        if (!valid) return
+        const req = this.form.dictId != null
+          ? updateTenantDictType(this.tenantId, this.form)
+          : addTenantDictType(this.tenantId, this.form)
+        req.then(() => {
+          this.msgSuccess(this.form.dictId != null ? '修改成功' : '新增成功')
+          this.open = false
+          this.getList()
+        })
+      })
+    },
+    handleDelete(row) {
+      const dictIds = row.dictId || this.ids.join(',')
+      this.$confirm('是否确认删除字典编号为"' + dictIds + '"的数据项?', '警告', {
+        confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
+      }).then(() => delTenantDictType(this.tenantId, dictIds)).then(() => {
+        this.getList()
+        this.msgSuccess('删除成功')
+      }).catch(() => {})
+    },
+    handleRefreshCache() {
+      refreshTenantDictCache(this.tenantId).then(() => this.msgSuccess('缓存刷新成功'))
+    }
+  }
+}
+</script>
+
+<style scoped>
+.mb8 { margin-bottom: 8px; }
+</style>

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

@@ -0,0 +1,319 @@
+<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 - 0
src/views/admin/tenantDict/template/TemplateDataPanel.vue

@@ -0,0 +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>
+

+ 126 - 0
src/views/admin/tenantDict/template/data.vue

@@ -0,0 +1,126 @@
+<template>
+  <div class="app-container">
+    <el-page-header @back="goBack" :content="'模板字典数据 - ' + (defaultDictType || '')" class="mb8" />
+
+    <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="['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" @selection-change="handleSelectionChange">
+      <el-table-column type="selection" width="55" align="center" />
+      <el-table-column label="编码" prop="dictCode" width="80" align="center" />
+      <el-table-column label="标签" prop="dictLabel" align="center" />
+      <el-table-column label="键值" prop="dictValue" align="center" />
+      <el-table-column label="排序" prop="dictSort" width="70" align="center" />
+      <el-table-column label="平台管控" width="90" 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="80" align="center">
+        <template slot-scope="scope"><dict-tag :options="statusOptions" :value="scope.row.status"/></template>
+      </el-table-column>
+      <el-table-column label="操作" width="140" align="center">
+        <template slot-scope="scope">
+          <el-button size="mini" type="text" @click="handleUpdate(scope.row)" v-hasPermi="['tenant:dict:template:edit']">修改</el-button>
+          <el-button size="mini" type="text" @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" /></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" /></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, getTemplateDictType
+} from '@/api/tenant/dict'
+
+export default {
+  name: 'TenantDictTemplateData',
+  data() {
+    return {
+      loading: true, ids: [], single: true, multiple: true, total: 0, dataList: [], defaultDictType: '',
+      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' }]
+      }
+    }
+  },
+  created() {
+    const dictId = this.$route.params.dictId
+    getTemplateDictType(dictId).then(r => {
+      this.queryParams.dictType = r.data.dictType
+      this.defaultDictType = r.data.dictType
+      this.getList()
+    })
+    this.getDicts('sys_normal_disable').then(r => { this.statusOptions = r.data })
+  },
+  methods: {
+    goBack() { this.$router.push({ path: '/admin/dict', query: { tab: 'template' } }) },
+    getList() {
+      this.loading = true
+      listTemplateDictData(this.queryParams).then(r => { this.dataList = r.rows; this.total = r.total; 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>
+.mb8 { margin-bottom: 12px; }
+</style>

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

@@ -0,0 +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>
+

+ 11 - 11
src/views/calendar/myCalendar/index.vue

@@ -5,18 +5,18 @@
       <el-col :span="6">
          <div class="mydate">
            <div><span>我的日程</span></div>
-            <div class="mycate"> 
+            <div class="mycate">
               <el-checkbox-group v-model="checkedEvent" @change="handleCheckedChange">
                 <el-checkbox v-for="item in eventOptions" :label="item.dictValue" :key="item.dictValue">{{item.dictLabel}}</el-checkbox>
               </el-checkbox-group>
             </div>
-         
+
         </div>
       </el-col>
        <el-col :span="18">
          <div class="mycalendar">
             <full-calendar
-              @changeMonth="changeMonth"   
+              @changeMonth="changeMonth"
               :config="config"
               :events="events"
               ref="calendar"
@@ -45,7 +45,7 @@
               v-model="form.startTime"
               type="datetime"
               placeholder="选择开始时间">
-              
+
             </el-date-picker>
           </el-form-item>
           <el-form-item label="结束时间" prop="finishTime" >
@@ -89,21 +89,21 @@
             </el-select>
           </el-form-item>
           <el-form-item label="开始时间"  prop="startTime" >
-              <el-date-picker :disabled="true"  
+              <el-date-picker :disabled="true"
               v-model="event.startTime"
               type="datetime"
               placeholder="选择开始时间">
             </el-date-picker>
           </el-form-item>
           <el-form-item label="结束时间" prop="finishTime" >
-              <el-date-picker :disabled="true"  
+              <el-date-picker :disabled="true"
               v-model="event.finishTime"
               type="datetime"
               placeholder="选择结束时间">
             </el-date-picker>
           </el-form-item>
             <el-form-item label="提醒时间" prop="eventTime" >
-              <el-date-picker :disabled="true"  
+              <el-date-picker :disabled="true"
               v-model="event.eventTime"
               type="datetime"
               placeholder="选择提醒时间">
@@ -127,7 +127,7 @@
 import { getMyCrmEventList,addCrmEvent,delCrmEvent,doEvent } from "@/api/crm/event";
 import icon from '@/assets/icon/menu.png'
 import { FullCalendar } from 'vue-full-calendar' //单页
-import 'fullcalendar/dist/fullcalendar.css'
+// import 'fullcalendar/dist/fullcalendar.css'
 export default {
 name: 'Index',
 components: { FullCalendar },
@@ -304,7 +304,7 @@ methods: {
           //   title:'春游4',
           //   start: '2021-03-17 15:00:00',
           //   end: '2021-03-17 16:00:00',
-          //   color: '#ff0000' 
+          //   color: '#ff0000'
           // }
           for(var i=0;i<this.myEvents.length;i++){
               var item={};
@@ -322,7 +322,7 @@ methods: {
               this.events.push(item)
           }
         }
-        
+
         this.users = response.data;
       });
     },
@@ -375,7 +375,7 @@ methods: {
  height: 100%;
  background-color:#ffffff;
  padding: 15px;
- 
+
 }
 .fc th{
  font-weight: normal;

+ 208 - 0
src/views/saas/tenant/dict/data.vue

@@ -0,0 +1,208 @@
+<template>
+  <div class="app-container">
+    <el-page-header @back="goBack" :content="'租户字典数据 - ' + (defaultDictType || '')" class="mb8" />
+
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="80px">
+      <el-form-item label="字典标签" prop="dictLabel">
+        <el-input v-model="queryParams.dictLabel" 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="['tenant:dict: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: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:remove']">删除</el-button>
+      </el-col>
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
+    </el-row>
+
+    <el-table v-loading="loading" :data="dataList" @selection-change="handleSelectionChange">
+      <el-table-column type="selection" width="55" align="center" />
+      <el-table-column label="字典编码" align="center" prop="dictCode" width="90" />
+      <el-table-column label="字典标签" align="center" prop="dictLabel">
+        <template slot-scope="scope">
+          <el-tag v-if="scope.row.listClass && scope.row.listClass !== 'default'" :type="scope.row.listClass === 'primary' ? '' : scope.row.listClass">{{ scope.row.dictLabel }}</el-tag>
+          <span v-else>{{ scope.row.dictLabel }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="字典键值" align="center" prop="dictValue" />
+      <el-table-column label="排序" align="center" prop="dictSort" width="70" />
+      <el-table-column label="来源" align="center" width="80">
+        <template slot-scope="scope">
+          <el-tag size="mini" :type="scope.row.dictSource === 'platform' ? 'warning' : 'success'">
+            {{ scope.row.dictSource === 'platform' ? '平台' : '租户' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="平台管控" align="center" width="90">
+        <template slot-scope="scope">
+          <el-tag size="mini" :type="scope.row.isPlatformManaged === 1 ? 'danger' : 'info'">{{ scope.row.isPlatformManaged === 1 ? '是' : '否' }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="状态" align="center" prop="status" width="80">
+        <template slot-scope="scope">
+          <dict-tag :options="statusOptions" :value="scope.row.status"/>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" width="140">
+        <template slot-scope="scope">
+          <el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)" v-hasPermi="['tenant:dict:edit']" :disabled="isPlatformLocked(scope.row)">修改</el-button>
+          <el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)" v-hasPermi="['tenant:dict:remove']" :disabled="isPlatformLocked(scope.row)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
+
+    <el-dialog :title="title" :visible.sync="open" width="520px" append-to-body>
+      <el-form ref="form" :model="form" :rules="rules" label-width="90px">
+        <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" controls-position="right" :min="0" /></el-form-item>
+        <el-form-item label="回显样式" prop="listClass">
+          <el-select v-model="form.listClass">
+            <el-option v-for="item in listClassOptions" :key="item.value" :label="item.label" :value="item.value" />
+          </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="备注" prop="remark"><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>
+  </div>
+</template>
+
+<script>
+import {
+  listTenantDictData, getTenantDictData, addTenantDictData,
+  updateTenantDictData, delTenantDictData, getTenantDictType
+} from '@/api/tenant/dict'
+
+export default {
+  name: 'TenantDictData',
+  data() {
+    return {
+      tenantId: null,
+      loading: true,
+      ids: [],
+      single: true,
+      multiple: true,
+      showSearch: true,
+      total: 0,
+      dataList: [],
+      defaultDictType: '',
+      title: '',
+      open: false,
+      listClassOptions: [
+        { value: 'default', label: '默认' }, { value: 'primary', label: '主要' },
+        { value: 'success', label: '成功' }, { value: 'info', label: '信息' },
+        { value: 'warning', label: '警告' }, { value: 'danger', label: '危险' }
+      ],
+      statusOptions: [],
+      queryParams: { pageNum: 1, pageSize: 10, dictLabel: undefined, dictType: undefined, status: undefined },
+      form: {},
+      rules: {
+        dictLabel: [{ required: true, message: '数据标签不能为空', trigger: 'blur' }],
+        dictValue: [{ required: true, message: '数据键值不能为空', trigger: 'blur' }],
+        dictSort: [{ required: true, message: '数据顺序不能为空', trigger: 'blur' }]
+      }
+    }
+  },
+  created() {
+    this.tenantId = Number(this.$route.query.tenantId)
+    const dictId = this.$route.params && this.$route.params.dictId
+    if (!this.tenantId) {
+      this.$message.error('缺少租户ID')
+      return
+    }
+    this.getType(dictId)
+    this.getDicts('sys_normal_disable').then(r => { this.statusOptions = r.data })
+  },
+  methods: {
+    goBack() {
+      this.$router.push({ path: '/system/tenant/dict', query: { tenantId: this.tenantId } })
+    },
+    isPlatformLocked(row) {
+      return row.dictSource === 'platform' || row.isPlatformManaged === 1
+    },
+    getType(dictId) {
+      getTenantDictType(this.tenantId, dictId).then(response => {
+        this.queryParams.dictType = response.data.dictType
+        this.defaultDictType = response.data.dictType
+        this.getList()
+      })
+    },
+    getList() {
+      this.loading = true
+      listTenantDictData(this.tenantId, this.queryParams).then(response => {
+        this.dataList = response.rows
+        this.total = response.total
+        this.loading = false
+      })
+    },
+    cancel() { this.open = false; this.reset() },
+    reset() {
+      this.form = { dictCode: undefined, dictLabel: undefined, dictValue: undefined, cssClass: undefined, listClass: 'default', dictSort: 0, status: '0', remark: undefined }
+      this.resetForm('form')
+    },
+    handleQuery() { this.queryParams.pageNum = 1; this.getList() },
+    resetQuery() { this.resetForm('queryForm'); this.queryParams.dictType = this.defaultDictType; this.handleQuery() },
+    handleAdd() { this.reset(); this.open = true; this.title = '添加字典数据'; this.form.dictType = this.queryParams.dictType },
+    handleSelectionChange(selection) {
+      this.ids = selection.map(item => item.dictCode)
+      this.single = selection.length !== 1
+      this.multiple = !selection.length
+    },
+    handleUpdate(row) {
+      this.reset()
+      const dictCode = row.dictCode || this.ids[0]
+      getTenantDictData(this.tenantId, dictCode).then(response => {
+        this.form = response.data
+        this.open = true
+        this.title = '修改字典数据'
+      })
+    },
+    submitForm() {
+      this.$refs['form'].validate(valid => {
+        if (!valid) return
+        const req = this.form.dictCode != null
+          ? updateTenantDictData(this.tenantId, this.form)
+          : addTenantDictData(this.tenantId, this.form)
+        req.then(() => { this.msgSuccess('保存成功'); this.open = false; this.getList() })
+      })
+    },
+    handleDelete(row) {
+      const dictCodes = row.dictCode || this.ids.join(',')
+      this.$confirm('是否确认删除?', '警告', { type: 'warning' }).then(() =>
+        delTenantDictData(this.tenantId, dictCodes)
+      ).then(() => { this.getList(); this.msgSuccess('删除成功') }).catch(() => {})
+    }
+  }
+}
+</script>
+
+<style scoped>
+.mb8 { margin-bottom: 12px; }
+</style>

+ 253 - 0
src/views/saas/tenant/dict/index.vue

@@ -0,0 +1,253 @@
+<template>
+  <div class="app-container">
+    <el-alert
+      v-if="!tenantId"
+      title="请先在上方选择租户,再进行字典维护"
+      type="warning"
+      show-icon
+      :closable="false"
+      class="mb8"
+    />
+
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="80px">
+      <el-form-item label="租户" required>
+        <inline-tenant-selector @change="onTenantChange" />
+      </el-form-item>
+      <el-form-item label="字典名称" prop="dictName">
+        <el-input v-model="queryParams.dictName" placeholder="请输入字典名称" clearable size="small" style="width: 200px" @keyup.enter.native="handleQuery" />
+      </el-form-item>
+      <el-form-item label="字典类型" prop="dictType">
+        <el-input v-model="queryParams.dictType" placeholder="请输入字典类型" clearable size="small" style="width: 200px" @keyup.enter.native="handleQuery" />
+      </el-form-item>
+      <el-form-item label="状态" prop="status">
+        <el-select v-model="queryParams.status" placeholder="字典状态" clearable size="small" style="width: 140px">
+          <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" :disabled="!tenantId">搜索</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="['tenant:dict:add']" :disabled="!tenantId">新增</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button type="success" plain icon="el-icon-edit" size="mini" :disabled="single || !tenantId" @click="handleUpdate" v-hasPermi="['tenant:dict:edit']">修改</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button type="danger" plain icon="el-icon-delete" size="mini" :disabled="multiple || !tenantId" @click="handleDelete" v-hasPermi="['tenant:dict:remove']">删除</el-button>
+      </el-col>
+      <el-col :span="1.5">
+        <el-button type="info" plain icon="el-icon-refresh" size="mini" @click="handleRefreshCache" v-hasPermi="['tenant:dict:edit']" :disabled="!tenantId">刷新缓存</el-button>
+      </el-col>
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
+    </el-row>
+
+    <el-table v-loading="loading" :data="typeList" @selection-change="handleSelectionChange">
+      <el-table-column type="selection" width="55" align="center" />
+      <el-table-column label="字典编号" align="center" prop="dictId" width="90" />
+      <el-table-column label="字典名称" align="center" prop="dictName" :show-overflow-tooltip="true" />
+      <el-table-column label="字典类型" align="center" :show-overflow-tooltip="true">
+        <template slot-scope="scope">
+          <router-link :to="{ path: '/system/tenant/dict-data/index/' + scope.row.dictId, query: { tenantId: tenantId } }" class="link-type">
+            <span>{{ scope.row.dictType }}</span>
+          </router-link>
+        </template>
+      </el-table-column>
+      <el-table-column label="来源" align="center" prop="dictSource" width="90">
+        <template slot-scope="scope">
+          <el-tag size="mini" :type="scope.row.dictSource === 'platform' ? 'warning' : 'success'">
+            {{ scope.row.dictSource === 'platform' ? '平台' : '租户' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="平台管控" align="center" prop="isPlatformManaged" width="90">
+        <template slot-scope="scope">
+          <el-tag size="mini" :type="scope.row.isPlatformManaged === 1 ? 'danger' : 'info'">
+            {{ scope.row.isPlatformManaged === 1 ? '是' : '否' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="状态" align="center" prop="status" 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="remark" :show-overflow-tooltip="true" />
+      <el-table-column label="创建时间" align="center" prop="createTime" width="160">
+        <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" width="140">
+        <template slot-scope="scope">
+          <el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)" v-hasPermi="['tenant:dict:edit']" :disabled="isPlatformLocked(scope.row)">修改</el-button>
+          <el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)" v-hasPermi="['tenant:dict:remove']" :disabled="isPlatformLocked(scope.row)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
+
+    <el-dialog :title="title" :visible.sync="open" width="520px" append-to-body>
+      <el-form ref="form" :model="form" :rules="rules" label-width="90px">
+        <el-form-item label="字典名称" prop="dictName">
+          <el-input v-model="form.dictName" placeholder="请输入字典名称" />
+        </el-form-item>
+        <el-form-item label="字典类型" prop="dictType">
+          <el-input v-model="form.dictType" placeholder="请输入字典类型" :disabled="form.dictId != null" />
+        </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="备注" prop="remark">
+          <el-input v-model="form.remark" type="textarea" placeholder="请输入内容" />
+        </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 InlineTenantSelector from '@/components/InlineTenantSelector'
+import {
+  listTenantDictType, getTenantDictType, addTenantDictType,
+  updateTenantDictType, delTenantDictType, refreshTenantDictCache
+} from '@/api/tenant/dict'
+
+export default {
+  name: 'TenantDict',
+  components: { InlineTenantSelector },
+  data() {
+    return {
+      loading: false,
+      tenantId: null,
+      ids: [],
+      single: true,
+      multiple: true,
+      showSearch: true,
+      total: 0,
+      typeList: [],
+      title: '',
+      open: false,
+      statusOptions: [],
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        dictName: undefined,
+        dictType: undefined,
+        status: undefined
+      },
+      form: {},
+      rules: {
+        dictName: [{ required: true, message: '字典名称不能为空', trigger: 'blur' }],
+        dictType: [{ required: true, message: '字典类型不能为空', trigger: 'blur' }]
+      }
+    }
+  },
+  created() {
+    this.getDicts('sys_normal_disable').then(response => {
+      this.statusOptions = response.data
+    })
+    if (this.$route.query.tenantId) {
+      this.tenantId = Number(this.$route.query.tenantId)
+      this.getList()
+    }
+  },
+  methods: {
+    onTenantChange(val) {
+      this.tenantId = val
+      this.handleQuery()
+    },
+    isPlatformLocked(row) {
+      return row.dictSource === 'platform' || row.isPlatformManaged === 1
+    },
+    getList() {
+      if (!this.tenantId) {
+        this.typeList = []
+        this.total = 0
+        return
+      }
+      this.loading = true
+      listTenantDictType(this.tenantId, this.queryParams).then(response => {
+        this.typeList = response.rows
+        this.total = response.total
+        this.loading = false
+      }).catch(() => { this.loading = false })
+    },
+    cancel() {
+      this.open = false
+      this.reset()
+    },
+    reset() {
+      this.form = { dictId: undefined, dictName: undefined, dictType: undefined, status: '0', 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(selection) {
+      this.ids = selection.map(item => item.dictId)
+      this.single = selection.length !== 1
+      this.multiple = !selection.length
+    },
+    handleUpdate(row) {
+      this.reset()
+      const dictId = row.dictId || this.ids[0]
+      getTenantDictType(this.tenantId, dictId).then(response => {
+        this.form = response.data
+        this.open = true
+        this.title = '修改租户字典类型'
+      })
+    },
+    submitForm() {
+      this.$refs['form'].validate(valid => {
+        if (!valid) return
+        const req = this.form.dictId != null
+          ? updateTenantDictType(this.tenantId, this.form)
+          : addTenantDictType(this.tenantId, this.form)
+        req.then(() => {
+          this.msgSuccess(this.form.dictId != null ? '修改成功' : '新增成功')
+          this.open = false
+          this.getList()
+        })
+      })
+    },
+    handleDelete(row) {
+      const dictIds = row.dictId || this.ids.join(',')
+      this.$confirm('是否确认删除字典编号为"' + dictIds + '"的数据项?', '警告', {
+        confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
+      }).then(() => delTenantDictType(this.tenantId, dictIds)).then(() => {
+        this.getList()
+        this.msgSuccess('删除成功')
+      }).catch(() => {})
+    },
+    handleRefreshCache() {
+      refreshTenantDictCache(this.tenantId).then(() => this.msgSuccess('缓存刷新成功'))
+    }
+  }
+}
+</script>
+
+<style scoped>
+.mb8 { margin-bottom: 8px; }
+</style>

+ 213 - 0
src/views/saas/tenant/dict/sync/index.vue

@@ -0,0 +1,213 @@
+<template>
+  <div class="app-container">
+    <el-card shadow="never" class="mb8">
+      <div slot="header"><span>字典同步配置</span></div>
+      <el-form ref="syncForm" :model="syncForm" label-width="110px" size="small">
+        <el-row :gutter="16">
+          <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="并行执行">
+              <el-switch v-model="syncForm.parallel" />
+              <el-input-number v-if="syncForm.parallel" v-model="syncForm.threads" :min="1" :max="16" style="margin-left: 12px" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="16">
+          <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-form-item v-if="syncForm.syncMode === 'OVERWRITE'">
+          <el-checkbox v-model="syncForm.overwriteConfirm">我已了解 OVERWRITE 将覆盖各租户中「平台管控」的字典项,租户自有扩展项不受影响</el-checkbox>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="info" 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">
+      <div slot="header">预览结果(未写入)</div>
+      <el-table :data="previewList" border size="small">
+        <el-table-column label="租户" prop="tenantName" />
+        <el-table-column label="编码" prop="tenantCode" width="120" />
+        <el-table-column label="新增类型" prop="typeAdded" width="90" align="center" />
+        <el-table-column label="更新类型" prop="typeUpdated" width="90" align="center" />
+        <el-table-column label="新增数据" prop="dataAdded" width="90" align="center" />
+        <el-table-column label="更新数据" prop="dataUpdated" width="90" align="center" />
+        <el-table-column label="跳过" prop="dataSkipped" width="80" align="center" />
+        <el-table-column label="移除" prop="dataRemoved" width="80" align="center" />
+      </el-table>
+    </el-card>
+
+    <el-card shadow="never" class="mt8" v-if="taskNo">
+      <div slot="header">
+        <span>同步任务 {{ taskNo }}</span>
+        <el-button style="float: right" type="text" icon="el-icon-refresh" @click="pollTask">刷新</el-button>
+      </div>
+      <el-descriptions :column="4" border size="small" v-if="taskInfo">
+        <el-descriptions-item label="状态"><el-tag :type="taskStatusType">{{ 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="失败">{{ taskInfo.failedTenants }}</el-descriptions-item>
+      </el-descriptions>
+      <el-table v-if="taskInfo && taskInfo.details" :data="taskInfo.details" border size="small" class="mt8">
+        <el-table-column label="租户" prop="tenantName" />
+        <el-table-column label="状态" prop="status" width="90" />
+        <el-table-column label="新增类型" prop="typeAdded" width="90" align="center" />
+        <el-table-column label="更新类型" prop="typeUpdated" width="90" align="center" />
+        <el-table-column label="新增数据" prop="dataAdded" width="90" align="center" />
+        <el-table-column label="更新数据" prop="dataUpdated" width="90" align="center" />
+        <el-table-column label="跳过" prop="dataSkipped" width="80" align="center" />
+        <el-table-column label="错误" prop="errorMsg" :show-overflow-tooltip="true" />
+      </el-table>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import { tenantList } from '@/api/tenant/tenant'
+import { runDictSync, getDictSyncTask, previewDictSync, templateDictTypeOptions } from '@/api/tenant/dict'
+
+export default {
+  name: 'TenantDictSync',
+  data() {
+    return {
+      tenantOptions: [],
+      templateTypeOptions: [],
+      previewList: [],
+      taskNo: '',
+      taskInfo: null,
+      pollTimer: null,
+      syncForm: {
+        syncMode: 'MERGE',
+        scopeType: 'ALL',
+        tenantIds: [],
+        dictTypes: [],
+        parallel: true,
+        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() {
+    tenantList().then(r => { this.tenantOptions = r.rows || r.data || [] })
+    templateDictTypeOptions().then(r => { this.templateTypeOptions = r.data || [] })
+  },
+  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 || []
+          this.$message.success('全量预览完成')
+          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('同步任务已启动')
+      }).catch(() => {})
+    },
+    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>
+.mb8 { margin-bottom: 12px; }
+.mt8 { margin-top: 12px; }
+</style>

+ 126 - 0
src/views/saas/tenant/dict/template/data.vue

@@ -0,0 +1,126 @@
+<template>
+  <div class="app-container">
+    <el-page-header @back="goBack" :content="'模板字典数据 - ' + (defaultDictType || '')" class="mb8" />
+
+    <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="['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" @selection-change="handleSelectionChange">
+      <el-table-column type="selection" width="55" align="center" />
+      <el-table-column label="编码" prop="dictCode" width="80" align="center" />
+      <el-table-column label="标签" prop="dictLabel" align="center" />
+      <el-table-column label="键值" prop="dictValue" align="center" />
+      <el-table-column label="排序" prop="dictSort" width="70" align="center" />
+      <el-table-column label="平台管控" width="90" 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="80" align="center">
+        <template slot-scope="scope"><dict-tag :options="statusOptions" :value="scope.row.status"/></template>
+      </el-table-column>
+      <el-table-column label="操作" width="140" align="center">
+        <template slot-scope="scope">
+          <el-button size="mini" type="text" @click="handleUpdate(scope.row)" v-hasPermi="['tenant:dict:template:edit']">修改</el-button>
+          <el-button size="mini" type="text" @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" /></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" /></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, getTemplateDictType
+} from '@/api/tenant/dict'
+
+export default {
+  name: 'TenantDictTemplateData',
+  data() {
+    return {
+      loading: true, ids: [], single: true, multiple: true, total: 0, dataList: [], defaultDictType: '',
+      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' }]
+      }
+    }
+  },
+  created() {
+    const dictId = this.$route.params.dictId
+    getTemplateDictType(dictId).then(r => {
+      this.queryParams.dictType = r.data.dictType
+      this.defaultDictType = r.data.dictType
+      this.getList()
+    })
+    this.getDicts('sys_normal_disable').then(r => { this.statusOptions = r.data })
+  },
+  methods: {
+    goBack() { this.$router.push('/system/tenant/dict/template') },
+    getList() {
+      this.loading = true
+      listTemplateDictData(this.queryParams).then(r => { this.dataList = r.rows; this.total = r.total; 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>
+.mb8 { margin-bottom: 12px; }
+</style>

+ 158 - 0
src/views/saas/tenant/dict/template/index.vue

@@ -0,0 +1,158 @@
+<template>
+  <div class="app-container">
+    <el-alert title="平台字典模板:维护标准字典定义,可通过「字典同步」下发到各租户库" type="info" show-icon :closable="false" class="mb8" />
+
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="80px">
+      <el-form-item label="字典名称" prop="dictName">
+        <el-input v-model="queryParams.dictName" placeholder="请输入字典名称" clearable size="small" style="width: 200px" @keyup.enter.native="handleQuery" />
+      </el-form-item>
+      <el-form-item label="字典类型" prop="dictType">
+        <el-input v-model="queryParams.dictType" placeholder="请输入字典类型" clearable size="small" style="width: 200px" @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-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="['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"></right-toolbar>
+    </el-row>
+
+    <el-table v-loading="loading" :data="typeList" @selection-change="handleSelectionChange">
+      <el-table-column type="selection" width="55" align="center" />
+      <el-table-column label="编号" align="center" prop="dictId" width="80" />
+      <el-table-column label="字典名称" align="center" prop="dictName" />
+      <el-table-column label="字典类型" align="center">
+        <template slot-scope="scope">
+          <router-link :to="'/system/tenant/dict-template-data/index/' + scope.row.dictId" class="link-type">
+            <span>{{ scope.row.dictType }}</span>
+          </router-link>
+        </template>
+      </el-table-column>
+      <el-table-column label="平台管控" align="center" width="90">
+        <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="110" />
+      <el-table-column label="状态" align="center" width="80">
+        <template slot-scope="scope">
+          <dict-tag :options="statusOptions" :value="scope.row.status"/>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" width="140">
+        <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" @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="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>
+  </div>
+</template>
+
+<script>
+import {
+  listTemplateDictType, getTemplateDictType, addTemplateDictType,
+  updateTemplateDictType, delTemplateDictType
+} from '@/api/tenant/dict'
+
+export default {
+  name: 'TenantDictTemplate',
+  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' }]
+      }
+    }
+  },
+  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
+      })
+    },
+    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>
+.mb8 { margin-bottom: 8px; }
+</style>

+ 191 - 304
src/views/system/dict/data.vue

@@ -1,33 +1,20 @@
 <template>
-  <div class="app-container">
-    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="68px">
-      <el-form-item label="字典名称" prop="dictType">
+  <div class="app-container dict-data-page">
+    <el-page-header @back="goBack" :content="pageHeaderTitle" class="page-header-bar mb16" />
+
+    <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 v-if="!isTenantMode" label="字典名称" prop="dictType">
         <el-select v-model="queryParams.dictType" size="small">
-          <el-option
-            v-for="item in typeOptions"
-            :key="item.dictId"
-            :label="item.dictName"
-            :value="item.dictType"
-          />
+          <el-option v-for="item in typeOptions" :key="item.dictId" :label="item.dictName" :value="item.dictType" />
         </el-select>
       </el-form-item>
       <el-form-item label="字典标签" prop="dictLabel">
-        <el-input
-          v-model="queryParams.dictLabel"
-          placeholder="请输入字典标签"
-          clearable
-          size="small"
-          @keyup.enter.native="handleQuery"
-        />
+        <el-input v-model="queryParams.dictLabel" 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-option v-for="dict in statusOptions" :key="dict.dictValue" :label="dict.dictLabel" :value="dict.dictValue" />
         </el-select>
       </el-form-item>
       <el-form-item>
@@ -35,144 +22,76 @@
         <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">
-      <el-col :span="1.5">
-        <el-button
-          type="primary"
-          plain
-          icon="el-icon-plus"
-          size="mini"
-          @click="handleAdd"
-          v-hasPermi="['system:dict: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="['system:dict: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="['system:dict:remove']"
-        >删除</el-button>
-      </el-col>
-      <el-col :span="1.5">
-        <el-button
-          type="warning"
-          plain
-          icon="el-icon-download"
-          size="mini"
-          :loading="exportLoading"
-          @click="handleExport"
-          v-hasPermi="['system:dict:export']"
-        >导出</el-button>
+    <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="addPermi">新增</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="editPermi">修改</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="removePermi">删除</el-button></el-col>
+      <el-col :span="1.5" v-if="!isTenantMode">
+        <el-button type="warning" plain icon="el-icon-download" size="mini" :loading="exportLoading" @click="handleExport" v-hasPermi="['system:dict:export']">导出</el-button>
       </el-col>
-      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
     </el-row>
 
-    <el-table v-loading="loading" :data="dataList" @selection-change="handleSelectionChange">
+    <el-table v-loading="loading" :data="dataList" border size="small" class="dict-table" @selection-change="handleSelectionChange">
       <el-table-column type="selection" width="55" align="center" />
-      <el-table-column label="字典编码" align="center" prop="dictCode" />
+      <el-table-column label="字典编码" align="center" prop="dictCode" width="90" />
       <el-table-column label="字典标签" align="center" prop="dictLabel">
         <template slot-scope="scope">
-          <span v-if="scope.row.listClass == '' || scope.row.listClass == 'default'">{{scope.row.dictLabel}}</span>
-          <el-tag v-else :type="scope.row.listClass == 'primary' ? '' : scope.row.listClass">{{scope.row.dictLabel}}</el-tag>
+          <span v-if="!scope.row.listClass || scope.row.listClass === 'default'">{{ scope.row.dictLabel }}</span>
+          <el-tag v-else :type="scope.row.listClass === 'primary' ? '' : scope.row.listClass">{{ scope.row.dictLabel }}</el-tag>
         </template>
       </el-table-column>
       <el-table-column label="字典键值" align="center" prop="dictValue" />
-      <el-table-column label="字典排序" align="center" prop="dictSort" />
-      <el-table-column label="状态" align="center" prop="status">
+      <el-table-column label="字典排序" align="center" prop="dictSort" width="80" />
+      <el-table-column v-if="isTenantMode" label="来源" align="center" width="80">
         <template slot-scope="scope">
-          <dict-tag :options="statusOptions" :value="scope.row.status"/>
+          <el-tag size="mini" :type="scope.row.dictSource === 'platform' ? 'warning' : 'success'">
+            {{ scope.row.dictSource === 'platform' ? '平台' : '租户' }}
+          </el-tag>
         </template>
       </el-table-column>
-      <el-table-column label="备注" align="center" prop="remark" :show-overflow-tooltip="true" />
-      <el-table-column label="创建时间" align="center" prop="createTime" width="180">
+      <el-table-column v-if="isTenantMode" label="平台管控" align="center" width="90">
         <template slot-scope="scope">
-          <span>{{ parseTime(scope.row.createTime) }}</span>
+          <el-tag size="mini" :type="scope.row.isPlatformManaged === 1 ? 'danger' : 'info'">{{ scope.row.isPlatformManaged === 1 ? '是' : '否' }}</el-tag>
         </template>
       </el-table-column>
-      <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
+      <el-table-column label="状态" align="center" prop="status" 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="remark" :show-overflow-tooltip="true" />
+      <el-table-column label="创建时间" align="center" prop="createTime" width="160">
+        <template slot-scope="scope"><span>{{ parseTime(scope.row.createTime) }}</span></template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" width="130" 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="['system:dict:edit']"
-          >修改</el-button>
-          <el-button
-            size="mini"
-            type="text"
-            icon="el-icon-delete"
-            @click="handleDelete(scope.row)"
-            v-hasPermi="['system:dict:remove']"
-          >删除</el-button>
+          <el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)" v-hasPermi="editPermi">修改</el-button>
+          <el-button size="mini" type="text" icon="el-icon-delete" style="color:#f5222d" @click="handleDelete(scope.row)" v-hasPermi="removePermi">删除</el-button>
         </template>
       </el-table-column>
     </el-table>
 
-    <pagination
-      v-show="total>0"
-      :total="total"
-      :page.sync="queryParams.pageNum"
-      :limit.sync="queryParams.pageSize"
-      @pagination="getList"
-    />
+    <pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
 
-    <!-- 添加或修改参数配置对话框 -->
-    <el-dialog :title="title" :visible.sync="open" width="500px" append-to-body>
+    <el-dialog :title="title" :visible.sync="open" width="520px" append-to-body>
       <el-form ref="form" :model="form" :rules="rules" label-width="80px">
-        <el-form-item label="字典类型">
-          <el-input v-model="form.dictType" :disabled="true" />
-        </el-form-item>
-        <el-form-item label="数据标签" prop="dictLabel">
-          <el-input v-model="form.dictLabel" placeholder="请输入数据标签" />
-        </el-form-item>
-        <el-form-item label="数据键值" prop="dictValue">
-          <el-input v-model="form.dictValue" placeholder="请输入数据键值" />
-        </el-form-item>
-        <el-form-item label="样式属性" prop="cssClass">
-          <el-input v-model="form.cssClass" placeholder="请输入样式属性" />
-        </el-form-item>
-        <el-form-item label="显示排序" prop="dictSort">
-          <el-input-number v-model="form.dictSort" controls-position="right" :min="0" />
-        </el-form-item>
+        <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="cssClass"><el-input v-model="form.cssClass" /></el-form-item>
+        <el-form-item label="显示排序" prop="dictSort"><el-input-number v-model="form.dictSort" controls-position="right" :min="0" /></el-form-item>
         <el-form-item label="回显样式" prop="listClass">
           <el-select v-model="form.listClass">
-            <el-option
-              v-for="item in listClassOptions"
-              :key="item.value"
-              :label="item.label"
-              :value="item.value"
-            ></el-option>
+            <el-option v-for="item in listClassOptions" :key="item.value" :label="item.label" :value="item.value" />
           </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 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="备注" prop="remark">
-          <el-input v-model="form.remark" type="textarea" placeholder="请输入内容"></el-input>
-        </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>
@@ -183,224 +102,192 @@
 </template>
 
 <script>
-import { listData, getData, delData, addData, updateData, exportData } from "@/api/system/dict/data";
-import { listType, getType } from "@/api/system/dict/type";
+import { listData, getData, delData, addData, updateData, exportData } from '@/api/system/dict/data'
+import { listType, getType } from '@/api/system/dict/type'
+import {
+  listTenantDictData, getTenantDictData, addTenantDictData,
+  updateTenantDictData, delTenantDictData, getTenantDictType
+} from '@/api/tenant/dict'
 
 export default {
-  name: "Data",
+  name: 'Data',
   data() {
     return {
-      // 遮罩层
+      tenantId: null,
       loading: true,
-      // 导出遮罩层
       exportLoading: false,
-      // 选中数组
       ids: [],
-      // 非单个禁用
+      selectedRows: [],
       single: true,
-      // 非多个禁用
       multiple: true,
-      // 显示搜索条件
       showSearch: true,
-      // 总条数
       total: 0,
-      // 字典表格数据
       dataList: [],
-      // 默认字典类型
-      defaultDictType: "",
-      // 弹出层标题
-      title: "",
-      // 是否显示弹出层
+      defaultDictType: '',
+      title: '',
       open: false,
-      // 数据标签回显样式
       listClassOptions: [
-        {
-          value: "default",
-          label: "默认"
-        },
-        {
-          value: "primary",
-          label: "主要"
-        },
-        {
-          value: "success",
-          label: "成功"
-        },
-        {
-          value: "info",
-          label: "信息"
-        },
-        {
-          value: "warning",
-          label: "警告"
-        },
-        {
-          value: "danger",
-          label: "危险"
-        }
+        { value: 'default', label: '默认' }, { value: 'primary', label: '主要' },
+        { value: 'success', label: '成功' }, { value: 'info', label: '信息' },
+        { value: 'warning', label: '警告' }, { value: 'danger', label: '危险' }
       ],
-      // 状态数据字典
       statusOptions: [],
-      // 类型数据字典
       typeOptions: [],
-      // 查询参数
-      queryParams: {
-        pageNum: 1,
-        pageSize: 10,
-        dictName: undefined,
-        dictType: undefined,
-        status: undefined
-      },
-      // 表单参数
+      queryParams: { pageNum: 1, pageSize: 10, dictLabel: undefined, dictType: undefined, status: undefined },
       form: {},
-      // 表单校验
       rules: {
-        dictLabel: [
-          { required: true, message: "数据标签不能为空", trigger: "blur" }
-        ],
-        dictValue: [
-          { required: true, message: "数据键值不能为空", trigger: "blur" }
-        ],
-        dictSort: [
-          { required: true, message: "数据顺序不能为空", trigger: "blur" }
-        ]
+        dictLabel: [{ required: true, message: '数据标签不能为空', trigger: 'blur' }],
+        dictValue: [{ required: true, message: '数据键值不能为空', trigger: 'blur' }],
+        dictSort: [{ required: true, message: '数据顺序不能为空', trigger: 'blur' }]
       }
-    };
+    }
+  },
+  computed: {
+    isTenantMode() { return !!this.tenantId },
+    pageHeaderTitle() {
+      const type = this.defaultDictType || ''
+      return this.isTenantMode ? ('租户字典数据 - ' + type) : ('字典数据 - ' + type)
+    },
+    isAdminRoute() { return this.$route.path.indexOf('/admin') === 0 },
+    addPermi() { return this.isTenantMode ? ['tenant:dict:add'] : ['system:dict:add'] },
+    editPermi() { return this.isTenantMode ? ['tenant:dict:edit'] : ['system:dict:edit'] },
+    removePermi() { return this.isTenantMode ? ['tenant:dict:remove'] : ['system:dict:remove'] }
   },
   created() {
-    const dictId = this.$route.params && this.$route.params.dictId;
-    this.getType(dictId);
-    this.getTypeList();
-    this.getDicts("sys_normal_disable").then(response => {
-      this.statusOptions = response.data;
-    });
+    this.initTenantContext()
+    const dictId = this.$route.params && this.$route.params.dictId
+    this.loadDictType(dictId)
+    if (!this.isTenantMode) this.getTypeList()
+    this.getDicts('sys_normal_disable').then(r => { this.statusOptions = r.data })
   },
   methods: {
-    /** 查询字典类型详细 */
-    getType(dictId) {
-      getType(dictId).then(response => {
-        this.queryParams.dictType = response.data.dictType;
-        this.defaultDictType = response.data.dictType;
-        this.getList();
-      });
+    initTenantContext() {
+      this.tenantId = this.$route.query.tenantId ? Number(this.$route.query.tenantId) : null
+    },
+    goBack() {
+      const from = this.$route.query.from
+      if (from) {
+        this.$router.push(from)
+        return
+      }
+      if (window.history.length > 1) {
+        this.$router.go(-1)
+        return
+      }
+      const activeMenu = this.$route.meta && this.$route.meta.activeMenu
+      if (activeMenu) {
+        const query = this.tenantId ? { tenantId: this.tenantId } : {}
+        this.$router.push({ path: activeMenu, query })
+      }
+    },
+    isPlatformLocked(row) {
+      if (!row) return false
+      return Number(row.isPlatformManaged) === 1
+    },
+    assertNotPlatformLocked(rows) {
+      if (!this.isTenantMode) return true
+      const locked = (rows || []).filter(r => this.isPlatformLocked(r))
+      if (!locked.length) return true
+      this.$message.warning('「' + (locked[0].dictLabel || locked[0].dictName) + '」为平台管控字典,请通过「平台模板 + 同步下发」维护')
+      return false
+    },
+    loadDictType(dictId) {
+      const req = this.isTenantMode ? getTenantDictType(this.tenantId, dictId) : getType(dictId)
+      req.then(response => {
+        this.queryParams.dictType = response.data.dictType
+        this.defaultDictType = response.data.dictType
+        this.getList()
+      })
     },
-    /** 查询字典类型列表 */
     getTypeList() {
-      listType().then(response => {
-        this.typeOptions = response.rows;
-      });
+      listType().then(response => { this.typeOptions = response.rows })
     },
-    /** 查询字典数据列表 */
     getList() {
-      this.loading = true;
-      listData(this.queryParams).then(response => {
-        this.dataList = response.rows;
-        this.total = response.total;
-        this.loading = false;
-      });
-    },
-    // 取消按钮
-    cancel() {
-      this.open = false;
-      this.reset();
+      this.loading = true
+      const req = this.isTenantMode
+        ? listTenantDictData(this.tenantId, this.queryParams)
+        : listData(this.queryParams)
+      req.then(response => {
+        this.dataList = response.rows
+        this.total = response.total
+        this.loading = false
+      }).catch(() => { this.loading = false })
     },
-    // 表单重置
+    cancel() { this.open = false; this.reset() },
     reset() {
-      this.form = {
-        dictCode: undefined,
-        dictLabel: undefined,
-        dictValue: undefined,
-        cssClass: undefined,
-        listClass: 'default',
-        dictSort: 0,
-        status: "0",
-        remark: undefined
-      };
-      this.resetForm("form");
+      this.form = { dictCode: undefined, dictLabel: undefined, dictValue: undefined, cssClass: undefined, listClass: 'default', dictSort: 0, status: '0', remark: undefined }
+      this.resetForm('form')
     },
-    /** 搜索按钮操作 */
-    handleQuery() {
-      this.queryParams.pageNum = 1;
-      this.getList();
-    },
-    /** 重置按钮操作 */
+    handleQuery() { this.queryParams.pageNum = 1; this.getList() },
     resetQuery() {
-      this.resetForm("queryForm");
-      this.queryParams.dictType = this.defaultDictType;
-      this.handleQuery();
-    },
-    /** 新增按钮操作 */
-    handleAdd() {
-      this.reset();
-      this.open = true;
-      this.title = "添加字典数据";
-      this.form.dictType = this.queryParams.dictType;
+      this.resetForm('queryForm')
+      this.queryParams.dictType = this.defaultDictType
+      this.handleQuery()
     },
-    // 多选框选中数据
+    handleAdd() { this.reset(); this.open = true; this.title = '添加字典数据'; this.form.dictType = this.queryParams.dictType },
     handleSelectionChange(selection) {
+      this.selectedRows = selection
       this.ids = selection.map(item => item.dictCode)
-      this.single = selection.length!=1
+      this.single = selection.length !== 1
       this.multiple = !selection.length
     },
-    /** 修改按钮操作 */
     handleUpdate(row) {
-      this.reset();
-      const dictCode = row.dictCode || this.ids
-      getData(dictCode).then(response => {
-        this.form = response.data;
-        this.open = true;
-        this.title = "修改字典数据";
-      });
+      const target = row && row.dictCode ? row : this.selectedRows[0]
+      if (this.isTenantMode && target && !this.assertNotPlatformLocked([target])) return
+      this.reset()
+      const dictCode = (target && target.dictCode) || this.ids[0]
+      const req = this.isTenantMode ? getTenantDictData(this.tenantId, dictCode) : getData(dictCode)
+      req.then(response => { this.form = response.data; this.open = true; this.title = '修改字典数据' })
     },
-    /** 提交按钮 */
-    submitForm: function() {
-      this.$refs["form"].validate(valid => {
-        if (valid) {
-          if (this.form.dictCode != undefined) {
-            updateData(this.form).then(response => {
-              this.msgSuccess("修改成功");
-              this.open = false;
-              this.getList();
-            });
-          } else {
-            addData(this.form).then(response => {
-              this.msgSuccess("新增成功");
-              this.open = false;
-              this.getList();
-            });
-          }
+    submitForm() {
+      this.$refs['form'].validate(valid => {
+        if (!valid) return
+        let req
+        if (this.isTenantMode) {
+          const payload = { ...this.form }
+          delete payload.dictSource
+          delete payload.isPlatformManaged
+          req = this.form.dictCode != null
+            ? updateTenantDictData(this.tenantId, payload)
+            : addTenantDictData(this.tenantId, payload)
+        } else {
+          req = this.form.dictCode != null ? updateData(this.form) : addData(this.form)
         }
-      });
+        req.then(() => { this.msgSuccess('保存成功'); this.open = false; this.getList() })
+          .catch(err => { this.$message.error((err && err.message) || err || '保存失败') })
+      })
     },
-    /** 删除按钮操作 */
     handleDelete(row) {
-      const dictCodes = row.dictCode || this.ids;
-      this.$confirm('是否确认删除字典编码为"' + dictCodes + '"的数据项?', "警告", {
-          confirmButtonText: "确定",
-          cancelButtonText: "取消",
-          type: "warning"
-        }).then(function() {
-          return delData(dictCodes);
-        }).then(() => {
-          this.getList();
-          this.msgSuccess("删除成功");
-        }).catch(() => {});
+      const targets = row && row.dictCode ? [row] : this.selectedRows
+      if (this.isTenantMode && !this.assertNotPlatformLocked(targets)) return
+      const dictCodes = row.dictCode || this.ids.join(',')
+      this.$confirm('是否确认删除?', '警告', { type: 'warning' })
+        .then(() => this.isTenantMode ? delTenantDictData(this.tenantId, dictCodes) : delData(dictCodes))
+        .then(() => { this.getList(); this.msgSuccess('删除成功') })
+        .catch(err => {
+          if (err && err !== 'cancel') {
+            this.$message.error((err && err.message) || err || '删除失败')
+          }
+        })
     },
-    /** 导出按钮操作 */
     handleExport() {
-      const queryParams = this.queryParams;
-      this.$confirm('是否确认导出所有数据项?', "警告", {
-          confirmButtonText: "确定",
-          cancelButtonText: "取消",
-          type: "warning"
-        }).then(() => {
-          this.exportLoading = true;
-          return exportData(queryParams);
-        }).then(response => {
-          this.download(response.msg);
-          this.exportLoading = false;
-        }).catch(() => {});
+      this.$confirm('是否确认导出?', '警告', { type: 'warning' }).then(() => {
+        this.exportLoading = true
+        return exportData(this.queryParams)
+      }).then(response => {
+        this.download(response.msg)
+        this.exportLoading = false
+      }).catch(() => { this.exportLoading = false })
     }
   }
-};
-</script>
+}
+</script>
+
+<style scoped>
+.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%; }
+.page-header-bar >>> .el-page-header__content { font-size: 16px; font-weight: 600; }
+</style>

+ 785 - 321
src/views/system/dict/index.vue

@@ -1,370 +1,834 @@
 <template>
-  <div class="app-container">
-    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="68px">
-      <el-form-item label="字典名称" prop="dictName">
-        <el-input
-          v-model="queryParams.dictName"
-          placeholder="请输入字典名称"
-          clearable
-          size="small"
-          style="width: 240px"
-          @keyup.enter.native="handleQuery"
-        />
-      </el-form-item>
-      <el-form-item label="字典类型" prop="dictType">
-        <el-input
-          v-model="queryParams.dictType"
-          placeholder="请输入字典类型"
-          clearable
-          size="small"
-          style="width: 240px"
-          @keyup.enter.native="handleQuery"
-        />
-      </el-form-item>
-      <el-form-item label="状态" prop="status">
-        <el-select
-          v-model="queryParams.status"
-          placeholder="字典状态"
-          clearable
-          size="small"
-          style="width: 240px"
-        >
-          <el-option
-            v-for="dict in statusOptions"
-            :key="dict.dictValue"
-            :label="dict.dictLabel"
-            :value="dict.dictValue"
-          />
-        </el-select>
-      </el-form-item>
-      <el-form-item label="创建时间">
-        <el-date-picker
-          v-model="dateRange"
-          size="small"
-          style="width: 240px"
-          value-format="yyyy-MM-dd"
-          type="daterange"
-          range-separator="-"
-          start-placeholder="开始日期"
-          end-placeholder="结束日期"
-        ></el-date-picker>
-      </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:dict: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="['system:dict: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="['system:dict:remove']"
-        >删除</el-button>
-      </el-col>
-      <el-col :span="1.5">
-        <el-button
-          type="warning"
-          plain
-          icon="el-icon-download"
-          size="mini"
-          :loading="exportLoading"
-          @click="handleExport"
-          v-hasPermi="['system:dict:export']"
-        >导出</el-button>
-      </el-col>
-      <el-col :span="1.5">
-        <el-button
-          type="danger"
-          plain
-          icon="el-icon-refresh"
-          size="mini"
-          @click="handleRefreshCache"
-          v-hasPermi="['system:dict:remove']"
-        >刷新缓存</el-button>
-      </el-col>
-      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
-    </el-row>
-
-    <el-table v-loading="loading" :data="typeList" @selection-change="handleSelectionChange">
-      <el-table-column type="selection" width="55" align="center" />
-      <el-table-column label="字典编号" align="center" prop="dictId" />
-      <el-table-column label="字典名称" align="center" prop="dictName" :show-overflow-tooltip="true" />
-      <el-table-column label="字典类型" align="center" :show-overflow-tooltip="true">
-        <template slot-scope="scope">
-          <router-link :to="'/system/dict-data/index/' + scope.row.dictId" class="link-type">
-            <span>{{ scope.row.dictType }}</span>
-          </router-link>
-        </template>
-      </el-table-column>
-      <el-table-column label="状态" align="center" prop="status">
-        <template slot-scope="scope">
-          <dict-tag :options="statusOptions" :value="scope.row.status"/>
-        </template>
-      </el-table-column>
-      <el-table-column label="备注" align="center" prop="remark" :show-overflow-tooltip="true" />
-      <el-table-column label="创建时间" align="center" prop="createTime" width="180">
-        <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:dict:edit']"
-          >修改</el-button>
-          <el-button
-            size="mini"
-            type="text"
-            icon="el-icon-delete"
-            @click="handleDelete(scope.row)"
-            v-hasPermi="['system:dict: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="500px" append-to-body>
-      <el-form ref="form" :model="form" :rules="rules" label-width="80px">
-        <el-form-item label="字典名称" prop="dictName">
-          <el-input v-model="form.dictName" placeholder="请输入字典名称" />
-        </el-form-item>
-        <el-form-item label="字典类型" prop="dictType">
-          <el-input v-model="form.dictType" placeholder="请输入字典类型" />
-        </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="备注" prop="remark">
-          <el-input v-model="form.remark" type="textarea" placeholder="请输入内容"></el-input>
-        </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 class="app-container dict-page">
+
+    <div class="dict-page-header">
+
+      <div class="dict-page-title">字典管理</div>
+
+      <div class="dict-page-desc">维护平台/租户字典,导入平台模板后统一下发至各租户</div>
+
+    </div>
+
+
+
+    <el-steps :active="stepActive" finish-status="success" align-center class="dict-steps mb16" simple>
+
+      <el-step title="字典维护" icon="el-icon-edit-outline" />
+
+      <el-step title="平台模板" icon="el-icon-document-copy" />
+
+      <el-step title="同步下发" icon="el-icon-upload2" />
+
+    </el-steps>
+
+
+
+    <el-tabs v-model="activeTab" @tab-click="onTabChange" class="dict-main-tabs">
+
+      <!-- ========== Tab1:字典维护 ========== -->
+
+      <el-tab-pane label="字典维护" name="dict">
+
+        <div class="dict-tab-panel">
+
+          <div class="mode-banner" :class="tenantId ? 'is-tenant' : 'is-platform'">
+
+            <i :class="tenantId ? 'el-icon-office-building' : 'el-icon-s-platform'" />
+
+            <div class="mode-banner-text">
+
+              <strong>{{ tenantId ? '租户字典模式' : '平台主库模式' }}</strong>
+
+              <span>{{ tenantId ? '当前租户:' + (tenantName || tenantId) : '未选择租户时,维护平台主库字典(原有字典管理)' }}</span>
+
+            </div>
+
+          </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="租户">
+
+                <inline-tenant-selector ref="tenantSelector" mode="admin" @change="onTenantChange" />
+
+              </el-form-item>
+
+              <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 label="状态" prop="status">
+
+                <el-select v-model="queryParams.status" placeholder="字典状态" clearable style="width: 120px">
+
+                  <el-option v-for="dict in statusOptions" :key="dict.dictValue" :label="dict.dictLabel" :value="dict.dictValue" />
+
+                </el-select>
+
+              </el-form-item>
+
+              <el-form-item v-if="!isTenantMode" label="创建时间">
+
+                <el-date-picker v-model="dateRange" style="width: 240px" value-format="yyyy-MM-dd" type="daterange" range-separator="-" start-placeholder="开始" end-placeholder="结束" />
+
+              </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-plus" size="mini" @click="handleAdd" v-hasPermi="addPermi">新增</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="editPermi">修改</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="removePermi">删除</el-button>
+
+            </el-col>
+
+            <el-col :span="1.5" v-if="!isTenantMode">
+
+              <el-button type="warning" plain icon="el-icon-download" size="mini" :loading="exportLoading" @click="handleExport" v-hasPermi="['system:dict:export']">导出</el-button>
+
+            </el-col>
+
+            <el-col :span="1.5">
+
+              <el-button type="primary" plain icon="el-icon-upload2" size="mini" :disabled="multiple" @click="handleImportToTemplate" v-hasPermi="['tenant:dict:template:add']">导入到平台模板</el-button>
+
+            </el-col>
+
+            <el-col :span="1.5">
+
+              <el-button type="info" plain icon="el-icon-refresh" size="mini" @click="handleRefreshCache" v-hasPermi="editPermi">刷新缓存</el-button>
+
+            </el-col>
+
+            <el-col :span="1.5" v-if="isTenantMode">
+
+              <el-button type="warning" plain icon="el-icon-right" size="mini" @click="activeTab = 'sync'" v-hasPermi="['tenant:dict:sync']">去同步下发</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="88" />
+
+            <el-table-column label="字典名称" align="center" prop="dictName" min-width="120" :show-overflow-tooltip="true" />
+
+            <el-table-column label="字典类型" align="center" min-width="140" :show-overflow-tooltip="true">
+
+              <template slot-scope="scope">
+
+                <router-link :to="dictDataRoute(scope.row.dictId)" class="link-type">
+
+                  <span>{{ scope.row.dictType }}</span>
+
+                </router-link>
+
+              </template>
+
+            </el-table-column>
+
+            <el-table-column v-if="isTenantMode" label="来源" align="center" width="76">
+
+              <template slot-scope="scope">
+
+                <el-tag size="mini" :type="scope.row.dictSource === 'platform' ? 'warning' : 'success'">
+
+                  {{ scope.row.dictSource === 'platform' ? '平台' : '租户' }}
+
+                </el-tag>
+
+              </template>
+
+            </el-table-column>
+
+            <el-table-column v-if="isTenantMode" label="平台管控" align="center" width="88">
+
+              <template slot-scope="scope">
+
+                <el-tag size="mini" :type="scope.row.isPlatformManaged === 1 ? 'danger' : 'info'">
+
+                  {{ scope.row.isPlatformManaged === 1 ? '是' : '否' }}
+
+                </el-tag>
+
+              </template>
+
+            </el-table-column>
+
+            <el-table-column label="状态" align="center" prop="status" width="76">
+
+              <template slot-scope="scope">
+
+                <dict-tag :options="statusOptions" :value="scope.row.status"/>
+
+              </template>
+
+            </el-table-column>
+
+            <el-table-column label="备注" align="center" prop="remark" min-width="100" :show-overflow-tooltip="true" />
+
+            <el-table-column label="创建时间" align="center" prop="createTime" width="158">
+
+              <template slot-scope="scope">
+
+                <span>{{ parseTime(scope.row.createTime) }}</span>
+
+              </template>
+
+            </el-table-column>
+
+            <el-table-column label="操作" align="center" width="130" 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="editPermi">修改</el-button>
+
+                <el-button size="mini" type="text" icon="el-icon-delete" style="color:#f5222d" @click="handleDelete(scope.row)" v-hasPermi="removePermi">删除</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" />
+
+        </div>
+
+
+
+        <el-dialog :title="title" :visible.sync="open" width="520px" append-to-body>
+
+          <el-form ref="form" :model="form" :rules="rules" label-width="80px">
+
+            <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="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-tab-pane>
+
+
+
+      <el-tab-pane label="平台模板" name="template" lazy>
+
+        <tenant-dict-template />
+
+      </el-tab-pane>
+
+
+
+      <el-tab-pane label="同步下发" name="sync" lazy>
+
+        <tenant-dict-sync />
+
+      </el-tab-pane>
+
+    </el-tabs>
+
   </div>
+
 </template>
 
+
+
 <script>
-import { listType, getType, delType, addType, updateType, exportType, refreshCache } from "@/api/system/dict/type";
+
+import InlineTenantSelector from '@/components/InlineTenantSelector'
+
+import TenantDictTemplate from '@/views/admin/tenantDict/template/index'
+
+import TenantDictSync from '@/views/admin/tenantDict/sync/index'
+
+import { listType, getType, delType, addType, updateType, exportType, refreshCache } from '@/api/system/dict/type'
+
+import {
+
+  listTenantDictType, getTenantDictType, addTenantDictType,
+
+  updateTenantDictType, delTenantDictType, refreshTenantDictCache,
+
+  importTemplateFromPlatform, importTemplateFromTenant
+
+} from '@/api/tenant/dict'
+
+
 
 export default {
-  name: "Dict",
+
+  name: 'Dict',
+
+  components: { InlineTenantSelector, TenantDictTemplate, TenantDictSync },
+
   data() {
+
     return {
-      // 遮罩层
+
+      activeTab: 'dict',
+
+      tenantId: null,
+
+      tenantName: '',
+
       loading: true,
-      // 导出遮罩层
+
       exportLoading: false,
-      // 选中数组
+
       ids: [],
-      // 非单个禁用
+
+      selectedRows: [],
+
       single: true,
-      // 非多个禁用
+
       multiple: true,
-      // 显示搜索条件
+
       showSearch: true,
-      // 总条数
+
       total: 0,
-      // 字典表格数据
+
       typeList: [],
-      // 弹出层标题
-      title: "",
-      // 是否显示弹出层
+
+      title: '',
+
       open: false,
-      // 状态数据字典
+
       statusOptions: [],
-      // 日期范围
+
       dateRange: [],
-      // 查询参数
-      queryParams: {
-        pageNum: 1,
-        pageSize: 10,
-        dictName: undefined,
-        dictType: undefined,
-        status: undefined
-      },
-      // 表单参数
+
+      queryParams: { pageNum: 1, pageSize: 10, dictName: undefined, dictType: undefined, status: undefined },
+
       form: {},
-      // 表单校验
+
       rules: {
-        dictName: [
-          { required: true, message: "字典名称不能为空", trigger: "blur" }
-        ],
-        dictType: [
-          { required: true, message: "字典类型不能为空", trigger: "blur" }
-        ]
+
+        dictName: [{ required: true, message: '字典名称不能为空', trigger: 'blur' }],
+
+        dictType: [{ required: true, message: '字典类型不能为空', trigger: 'blur' }]
+
       }
-    };
+
+    }
+
   },
+
+  computed: {
+
+    isTenantMode() { return !!this.tenantId },
+
+    isAdminRoute() { return this.$route.path.indexOf('/admin') === 0 },
+
+    addPermi() { return this.isTenantMode ? ['tenant:dict:add'] : ['system:dict:add'] },
+
+    editPermi() { return this.isTenantMode ? ['tenant:dict:edit'] : ['system:dict:edit'] },
+
+    removePermi() { return this.isTenantMode ? ['tenant:dict:remove'] : ['system:dict:remove'] },
+
+    stepActive() {
+
+      const map = { dict: 0, template: 1, sync: 2 }
+
+      return map[this.activeTab] != null ? map[this.activeTab] : 0
+
+    }
+
+  },
+
   created() {
-    this.getList();
-    this.getDicts("sys_normal_disable").then(response => {
-      this.statusOptions = response.data;
-    });
+
+    if (this.$route.query.tab) this.activeTab = this.$route.query.tab
+
+    this.initTenantContext()
+
+    this.getDicts('sys_normal_disable').then(r => { this.statusOptions = r.data })
+
+    this.getList()
+
+  },
+
+  mounted() {
+
+    this.syncTenantSelector()
+
+  },
+
+  watch: {
+
+    '$route.query.tenantId'(val) {
+
+      this.initTenantContext(val ? Number(val) : null)
+
+      this.syncTenantSelector()
+
+      this.getList()
+
+    }
+
   },
+
   methods: {
-    /** 查询字典类型列表 */
-    getList() {
-      this.loading = true;
-      listType(this.addDateRange(this.queryParams, this.dateRange)).then(response => {
-          this.typeList = response.rows;
-          this.total = response.total;
-          this.loading = false;
+
+    onTabChange() {},
+
+    initTenantContext(routeTenantId) {
+
+      const fromQuery = routeTenantId !== undefined
+
+        ? routeTenantId
+
+        : (this.$route.query.tenantId ? Number(this.$route.query.tenantId) : null)
+
+      this.tenantId = fromQuery || null
+
+      if (this.tenantId) {
+
+        this.$store.dispatch('tenant/setCompanyId', this.tenantId)
+
+      }
+
+    },
+
+    syncTenantSelector() {
+
+      if (!this.$refs.tenantSelector) return
+
+      const sel = this.$refs.tenantSelector
+
+      if (this.tenantId) {
+
+        sel.selectedId = this.tenantId
+
+        const item = (sel.tenantList || []).find(t => t.id === this.tenantId)
+
+        this.tenantName = item && item.tenantName ? item.tenantName : this.tenantName
+
+        if (!item && sel.tenantList && !sel.tenantList.length) {
+
+          const unwatch = this.$watch(() => sel.tenantList.length, len => {
+
+            if (!len) return
+
+            const loaded = sel.tenantList.find(t => t.id === this.tenantId)
+
+            if (loaded) this.tenantName = loaded.tenantName
+
+            unwatch()
+
+          })
+
         }
-      );
+
+      } else {
+
+        sel.selectedId = null
+
+        this.tenantName = ''
+
+      }
+
     },
-    // 取消按钮
-    cancel() {
-      this.open = false;
-      this.reset();
+
+    syncTenantRouteQuery() {
+
+      const query = { ...this.$route.query }
+
+      if (this.tenantId) query.tenantId = this.tenantId
+
+      else delete query.tenantId
+
+      if (String(query.tenantId || '') === String(this.$route.query.tenantId || '')) return
+
+      this.$router.replace({ path: this.$route.path, query })
+
     },
-    // 表单重置
-    reset() {
-      this.form = {
-        dictId: undefined,
-        dictName: undefined,
-        dictType: undefined,
-        status: "0",
-        remark: undefined
-      };
-      this.resetForm("form");
+
+    onTenantChange(val, item) {
+
+      this.tenantId = val || null
+
+      this.tenantName = item && item.tenantName ? item.tenantName : ''
+
+      this.queryParams.pageNum = 1
+
+      if (val) {
+
+        this.$store.dispatch('tenant/setCompanyId', val)
+
+      }
+
+      this.syncTenantRouteQuery()
+
+      this.getList()
+
+    },
+
+    isPlatformLocked(row) {
+      if (!row) return false
+      return Number(row.isPlatformManaged) === 1
     },
-    /** 搜索按钮操作 */
-    handleQuery() {
-      this.queryParams.pageNum = 1;
-      this.getList();
+    assertNotPlatformLocked(rows) {
+      if (!this.isTenantMode) return true
+      const locked = (rows || []).filter(r => this.isPlatformLocked(r))
+      if (!locked.length) return true
+      this.$message.warning('「' + locked[0].dictName + '」为平台管控字典,请通过「平台模板 + 同步下发」维护')
+      return false
     },
-    /** 重置按钮操作 */
-    resetQuery() {
-      this.dateRange = [];
-      this.resetForm("queryForm");
-      this.handleQuery();
+
+    dictDataRoute(dictId) {
+
+      const base = this.isAdminRoute ? '/admin/dict-data/index/' : '/system/dict-data/index/'
+
+      const query = { from: this.$route.fullPath }
+
+      if (this.isTenantMode) query.tenantId = this.tenantId
+
+      return { path: base + dictId, query }
+
+    },
+
+    getList() {
+
+      this.loading = true
+
+      const params = this.isTenantMode ? this.queryParams : this.addDateRange(this.queryParams, this.dateRange)
+
+      const req = this.isTenantMode
+
+        ? listTenantDictType(this.tenantId, params)
+
+        : listType(params)
+
+      req.then(response => {
+
+        this.typeList = response.rows
+
+        this.total = response.total
+
+        this.loading = false
+
+      }).catch(() => { this.loading = false })
+
     },
-    /** 新增按钮操作 */
-    handleAdd() {
-      this.reset();
-      this.open = true;
-      this.title = "添加字典类型";
+
+    cancel() { this.open = false; this.reset() },
+
+    reset() {
+
+      this.form = { dictId: undefined, dictName: undefined, dictType: undefined, status: '0', remark: undefined }
+
+      this.resetForm('form')
+
     },
-    // 多选框选中数据
+
+    handleQuery() { this.queryParams.pageNum = 1; this.getList() },
+
+    resetQuery() {
+
+      this.dateRange = []
+
+      this.resetForm('queryForm')
+
+      if (this.$refs.tenantSelector) {
+
+        this.$refs.tenantSelector.reset()
+
+      } else {
+
+        this.tenantId = null
+
+        this.tenantName = ''
+
+      }
+
+      this.handleQuery()
+
+    },
+
+    handleAdd() { this.reset(); this.open = true; this.title = '添加字典类型' },
+
     handleSelectionChange(selection) {
+
+      this.selectedRows = selection
+
       this.ids = selection.map(item => item.dictId)
-      this.single = selection.length!=1
+
+      this.single = selection.length !== 1
+
       this.multiple = !selection.length
+
+    },
+
+    handleImportToTemplate() {
+
+      if (!this.selectedRows.length) {
+
+        this.$message.warning('请先勾选要导入的字典类型')
+
+        return
+
+      }
+
+      this.$confirm('将选中字典的类型及全部字典数据导入到「平台模板」,已存在项默认仅追加缺失数据。是否继续?', '导入确认', { type: 'info' })
+
+        .then(() => {
+
+          const req = this.isTenantMode
+
+            ? importTemplateFromTenant({
+
+              tenantId: this.tenantId,
+
+              dictTypes: this.selectedRows.map(r => r.dictType),
+
+              overwriteExisting: false
+
+            })
+
+            : importTemplateFromPlatform({
+
+              dictIds: this.selectedRows.map(r => r.dictId),
+
+              overwriteExisting: false
+
+            })
+
+          return req.then(r => {
+
+            this.$message.success(r.data.message || '已导入到平台模板')
+
+            this.activeTab = 'template'
+
+          })
+
+        }).catch(() => {})
+
     },
-    /** 修改按钮操作 */
+
     handleUpdate(row) {
-      this.reset();
-      const dictId = row.dictId || this.ids
-      getType(dictId).then(response => {
-        this.form = response.data;
-        this.open = true;
-        this.title = "修改字典类型";
-      });
+      const target = row && row.dictId ? row : this.selectedRows[0]
+      if (this.isTenantMode && target && !this.assertNotPlatformLocked([target])) return
+      this.reset()
+      const dictId = (target && target.dictId) || this.ids[0]
+      const req = this.isTenantMode ? getTenantDictType(this.tenantId, dictId) : getType(dictId)
+      req.then(response => { this.form = response.data; this.open = true; this.title = '修改字典类型' })
     },
-    /** 提交按钮 */
-    submitForm: function() {
-      this.$refs["form"].validate(valid => {
-        if (valid) {
-          if (this.form.dictId != undefined) {
-            updateType(this.form).then(response => {
-              this.msgSuccess("修改成功");
-              this.open = false;
-              this.getList();
-            });
-          } else {
-            addType(this.form).then(response => {
-              this.msgSuccess("新增成功");
-              this.open = false;
-              this.getList();
-            });
-          }
+
+    submitForm() {
+
+      this.$refs['form'].validate(valid => {
+
+        if (!valid) return
+
+        let req
+
+        if (this.isTenantMode) {
+
+          const payload = { ...this.form }
+
+          delete payload.dictSource
+
+          delete payload.isPlatformManaged
+
+          req = this.form.dictId != null
+
+            ? updateTenantDictType(this.tenantId, payload)
+
+            : addTenantDictType(this.tenantId, payload)
+
+        } else {
+
+          req = this.form.dictId != null ? updateType(this.form) : addType(this.form)
+
         }
-      });
+
+        req.then(() => {
+          this.msgSuccess(this.form.dictId != null ? '修改成功' : '新增成功')
+          this.open = false
+          this.getList()
+        }).catch(err => {
+          this.$message.error((err && err.message) || err || '保存失败')
+        })
+
+      })
+
     },
-    /** 删除按钮操作 */
+
     handleDelete(row) {
-      const dictIds = row.dictId || this.ids;
-      this.$confirm('是否确认删除字典编号为"' + dictIds + '"的数据项?', "警告", {
-          confirmButtonText: "确定",
-          cancelButtonText: "取消",
-          type: "warning"
-        }).then(function() {
-          return delType(dictIds);
-        }).then(() => {
-          this.getList();
-          this.msgSuccess("删除成功");
-        }).catch(() => {});
+      const targets = row && row.dictId ? [row] : this.selectedRows
+      if (this.isTenantMode && !this.assertNotPlatformLocked(targets)) return
+      const dictIds = row.dictId || this.ids.join(',')
+      this.$confirm('是否确认删除字典编号为"' + dictIds + '"的数据项?', '警告', { type: 'warning' })
+        .then(() => this.isTenantMode ? delTenantDictType(this.tenantId, dictIds) : delType(dictIds))
+        .then(() => { this.getList(); this.msgSuccess('删除成功') })
+        .catch(err => {
+          if (err && err !== 'cancel') {
+            this.$message.error((err && err.message) || err || '删除失败')
+          }
+        })
     },
-    /** 导出按钮操作 */
+
     handleExport() {
-      const queryParams = this.queryParams;
-      this.$confirm('是否确认导出所有类型数据项?', "警告", {
-          confirmButtonText: "确定",
-          cancelButtonText: "取消",
-          type: "warning"
-        }).then(() => {
-          this.exportLoading = true;
-          return exportType(queryParams);
-        }).then(response => {
-          this.download(response.msg);
-          this.exportLoading = false;
-        }).catch(() => {});
+
+      this.$confirm('是否确认导出所有类型数据项?', '警告', { type: 'warning' }).then(() => {
+
+        this.exportLoading = true
+
+        return exportType(this.queryParams)
+
+      }).then(response => {
+
+        this.download(response.msg)
+
+        this.exportLoading = false
+
+      }).catch(() => { this.exportLoading = false })
+
     },
-    /** 刷新缓存按钮操作 */
+
     handleRefreshCache() {
-      refreshCache().then(() => {
-        this.msgSuccess("刷新成功");
-      });
+
+      const req = this.isTenantMode ? refreshTenantDictCache(this.tenantId) : refreshCache()
+
+      req.then(() => this.msgSuccess('刷新成功'))
+
     }
+
   }
-};
-</script>
+
+}
+
+</script>
+
+
+
+<style scoped>
+
+.dict-page-header { margin-bottom: 12px; }
+
+.dict-page-title { font-size: 18px; font-weight: 600; color: #303133; line-height: 1.4; }
+
+.dict-page-desc { margin-top: 4px; font-size: 13px; color: #909399; }
+
+.dict-steps { background: #f5f7fa; border-radius: 8px; padding: 12px 16px; }
+
+.dict-main-tabs >>> .el-tabs__header { margin-bottom: 16px; }
+
+.dict-main-tabs >>> .el-tabs__item { font-size: 14px; height: 42px; line-height: 42px; }
+
+.dict-tab-panel { padding-top: 4px; }
+
+.mode-banner {
+
+  display: flex;
+
+  align-items: center;
+
+  gap: 12px;
+
+  padding: 12px 16px;
+
+  border-radius: 8px;
+
+  margin-bottom: 16px;
+
+  border: 1px solid transparent;
+
+}
+
+.mode-banner i { font-size: 22px; }
+
+.mode-banner-text { display: flex; flex-direction: column; gap: 2px; font-size: 13px; color: #606266; }
+
+.mode-banner-text strong { font-size: 14px; color: #303133; }
+
+.mode-banner.is-platform { background: #ecf5ff; border-color: #d9ecff; color: #409eff; }
+
+.mode-banner.is-platform i { color: #409eff; }
+
+.mode-banner.is-tenant { background: #f0f9eb; border-color: #e1f3d8; color: #67c23a; }
+
+.mode-banner.is-tenant i { color: #67c23a; }
+
+.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%; }
+
+</style>
+
+