index.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637
  1. <template>
  2. <div class="dict-tab-panel sync-panel">
  3. <el-row :gutter="16" class="mb16">
  4. <el-col :span="8">
  5. <div class="sync-mode-card merge">
  6. <div class="sync-mode-title">MERGE · 合并</div>
  7. <div class="sync-mode-desc">推荐。新增缺失项,更新平台管控字段,保留租户扩展</div>
  8. </div>
  9. </el-col>
  10. <el-col :span="8">
  11. <div class="sync-mode-card append">
  12. <div class="sync-mode-title">APPEND · 追加</div>
  13. <div class="sync-mode-desc">仅追加模板中不存在的数据,不修改已有项</div>
  14. </div>
  15. </el-col>
  16. <el-col :span="8">
  17. <div class="sync-mode-card overwrite">
  18. <div class="sync-mode-title">OVERWRITE · 覆盖</div>
  19. <div class="sync-mode-desc">覆盖各租户中「平台管控」的字典项,请谨慎使用</div>
  20. </div>
  21. </el-col>
  22. </el-row>
  23. <el-card shadow="never" class="mb16 sync-config-card">
  24. <div slot="header" class="card-header">
  25. <span><i class="el-icon-setting" /> 同步配置</span>
  26. </div>
  27. <el-form ref="syncForm" :model="syncForm" label-width="100px" size="small">
  28. <el-row :gutter="20">
  29. <el-col :span="8">
  30. <el-form-item label="同步模式" required>
  31. <el-select v-model="syncForm.syncMode" style="width: 100%">
  32. <el-option label="MERGE - 合并(推荐)" value="MERGE" />
  33. <el-option label="APPEND - 仅追加缺失项" value="APPEND" />
  34. <el-option label="OVERWRITE - 覆盖平台管控项" value="OVERWRITE" />
  35. </el-select>
  36. </el-form-item>
  37. </el-col>
  38. <el-col :span="8">
  39. <el-form-item label="租户范围" required>
  40. <el-select v-model="syncForm.scopeType" style="width: 100%">
  41. <el-option label="全部启用租户" value="ALL" />
  42. <el-option label="仅指定租户" value="INCLUDE" />
  43. <el-option label="排除指定租户" value="EXCLUDE" />
  44. </el-select>
  45. </el-form-item>
  46. </el-col>
  47. <el-col :span="8">
  48. <el-form-item label="并行执行">
  49. <div class="parallel-row">
  50. <el-switch v-model="syncForm.parallel" />
  51. <el-input-number v-if="syncForm.parallel" v-model="syncForm.threads" :min="1" :max="16" size="small" controls-position="right" />
  52. </div>
  53. </el-form-item>
  54. </el-col>
  55. </el-row>
  56. <el-row :gutter="20">
  57. <el-col :span="12">
  58. <el-form-item label="指定租户" v-if="syncForm.scopeType !== 'ALL'">
  59. <el-select v-model="syncForm.tenantIds" multiple filterable placeholder="选择租户" style="width: 100%">
  60. <el-option v-for="t in tenantOptions" :key="t.id" :label="t.tenantName + ' (' + t.tenantCode + ')'" :value="t.id" />
  61. </el-select>
  62. </el-form-item>
  63. </el-col>
  64. <el-col :span="12">
  65. <el-form-item label="字典类型">
  66. <el-select v-model="syncForm.dictTypes" multiple filterable clearable placeholder="空 = 全部模板类型" style="width: 100%">
  67. <el-option v-for="t in templateTypeOptions" :key="t.dictType" :label="t.dictName + ' / ' + t.dictType" :value="t.dictType" />
  68. </el-select>
  69. </el-form-item>
  70. </el-col>
  71. </el-row>
  72. <el-alert
  73. v-if="syncForm.syncMode === 'OVERWRITE'"
  74. type="warning"
  75. :closable="false"
  76. show-icon
  77. class="mb12"
  78. title="OVERWRITE 将覆盖各租户中「平台管控」的字典项,租户自有扩展项不受影响"
  79. />
  80. <el-form-item v-if="syncForm.syncMode === 'OVERWRITE'" class="overwrite-check">
  81. <el-checkbox v-model="syncForm.overwriteConfirm">我已了解上述风险,确认执行覆盖同步</el-checkbox>
  82. </el-form-item>
  83. <el-form-item class="action-row">
  84. <el-button type="info" plain icon="el-icon-view" @click="handleSync(true)" v-hasPermi="['tenant:dict:sync']">全量预览</el-button>
  85. <el-button type="warning" icon="el-icon-upload2" @click="handleSync(false)" v-hasPermi="['tenant:dict:sync']">开始同步</el-button>
  86. </el-form-item>
  87. </el-form>
  88. </el-card>
  89. <el-card shadow="never" v-if="previewList.length" class="mb16 result-card">
  90. <div slot="header" class="card-header">
  91. <span><i class="el-icon-data-analysis" /> 预览结果</span>
  92. <el-tag size="mini" type="info" effect="plain">未写入数据库</el-tag>
  93. </div>
  94. <el-table :data="previewList" border size="small" class="dict-table">
  95. <el-table-column label="租户" prop="tenantName" min-width="120" />
  96. <el-table-column label="编码" prop="tenantCode" width="110" />
  97. <el-table-column label="新增类型" prop="typeAdded" width="88" align="center">
  98. <template slot-scope="s"><span class="stat-num add">{{ s.row.typeAdded || 0 }}</span></template>
  99. </el-table-column>
  100. <el-table-column label="更新类型" prop="typeUpdated" width="88" align="center">
  101. <template slot-scope="s"><span class="stat-num update">{{ s.row.typeUpdated || 0 }}</span></template>
  102. </el-table-column>
  103. <el-table-column label="新增数据" prop="dataAdded" width="88" align="center">
  104. <template slot-scope="s"><span class="stat-num add">{{ s.row.dataAdded || 0 }}</span></template>
  105. </el-table-column>
  106. <el-table-column label="更新数据" prop="dataUpdated" width="88" align="center">
  107. <template slot-scope="s"><span class="stat-num update">{{ s.row.dataUpdated || 0 }}</span></template>
  108. </el-table-column>
  109. <el-table-column label="跳过" prop="dataSkipped" width="72" align="center" />
  110. <el-table-column label="移除" prop="dataRemoved" width="72" align="center" />
  111. </el-table>
  112. </el-card>
  113. <el-card shadow="never" v-if="taskNo" class="result-card">
  114. <div slot="header" class="card-header">
  115. <span><i class="el-icon-loading" v-if="taskInfo && taskInfo.status === 'RUNNING'" /><i class="el-icon-finished" v-else /> 同步任务 {{ taskNo }}</span>
  116. <el-button type="text" icon="el-icon-refresh" @click="pollTask">刷新状态</el-button>
  117. </div>
  118. <el-descriptions :column="4" border size="small" v-if="taskInfo" class="task-summary">
  119. <el-descriptions-item label="状态"><el-tag :type="taskStatusType" size="small">{{ taskInfo.status }}</el-tag></el-descriptions-item>
  120. <el-descriptions-item label="模式">{{ taskInfo.syncMode }}</el-descriptions-item>
  121. <el-descriptions-item label="成功 / 总数">{{ taskInfo.successTenants }} / {{ taskInfo.totalTenants }}</el-descriptions-item>
  122. <el-descriptions-item label="失败">
  123. <span :class="{ 'stat-fail': taskInfo.failedTenants > 0 }">{{ taskInfo.failedTenants }}</span>
  124. </el-descriptions-item>
  125. </el-descriptions>
  126. <el-table v-if="taskInfo && taskInfo.details && taskInfo.details.length" :data="taskInfo.details" border size="small" class="dict-table mt12">
  127. <el-table-column label="租户" prop="tenantName" min-width="120" />
  128. <el-table-column label="状态" prop="status" width="90" align="center">
  129. <template slot-scope="s">
  130. <el-tag size="mini" :type="s.row.status === 'SUCCESS' ? 'success' : (s.row.status === 'FAILED' ? 'danger' : 'warning')">{{ s.row.status }}</el-tag>
  131. </template>
  132. </el-table-column>
  133. <el-table-column label="新增类型" prop="typeAdded" width="88" align="center" />
  134. <el-table-column label="更新类型" prop="typeUpdated" width="88" align="center" />
  135. <el-table-column label="新增数据" prop="dataAdded" width="88" align="center" />
  136. <el-table-column label="更新数据" prop="dataUpdated" width="88" align="center" />
  137. <el-table-column label="跳过" prop="dataSkipped" width="72" align="center" />
  138. <el-table-column label="错误" prop="errorMsg" min-width="160" :show-overflow-tooltip="true" />
  139. </el-table>
  140. </el-card>
  141. <el-empty v-if="!previewList.length && !taskNo" description="配置同步参数后,可先「全量预览」查看差异,再「开始同步」" :image-size="100" />
  142. </div>
  143. </template>
  144. <script>
  145. import { listCompanyOptions } from '@/api/admin/sysCompany'
  146. import { runDictSync, getDictSyncTask, previewDictSync, templateDictTypeOptions } from '@/api/tenant/dict'
  147. export default {
  148. name: 'AdminTenantDictSync',
  149. data() {
  150. return {
  151. tenantOptions: [],
  152. templateTypeOptions: [],
  153. previewList: [],
  154. taskNo: '',
  155. taskInfo: null,
  156. pollTimer: null,
  157. syncForm: {
  158. syncMode: 'MERGE',
  159. scopeType: 'ALL',
  160. tenantIds: [],
  161. dictTypes: [],
  162. parallel: false,
  163. threads: 4,
  164. overwriteConfirm: false,
  165. dryRun: false
  166. }
  167. }
  168. },
  169. computed: {
  170. taskStatusType() {
  171. const s = this.taskInfo && this.taskInfo.status
  172. if (s === 'SUCCESS') return 'success'
  173. if (s === 'FAILED') return 'danger'
  174. if (s === 'PARTIAL') return 'warning'
  175. return 'info'
  176. }
  177. },
  178. created() {
  179. listCompanyOptions().then(r => { this.tenantOptions = r.data || [] })
  180. templateDictTypeOptions().then(r => {
  181. this.templateTypeOptions = r.data || []
  182. if (!this.templateTypeOptions.length) {
  183. this.$message.warning('平台模板为空,请先在「平台模板」Tab 维护字典模板后再同步')
  184. }
  185. })
  186. },
  187. beforeDestroy() {
  188. if (this.pollTimer) clearInterval(this.pollTimer)
  189. },
  190. methods: {
  191. buildPayload(dryRun) {
  192. return {
  193. syncMode: this.syncForm.syncMode,
  194. scopeType: this.syncForm.scopeType,
  195. tenantIds: this.syncForm.scopeType === 'ALL' ? [] : this.syncForm.tenantIds,
  196. dictTypes: this.syncForm.dictTypes,
  197. parallel: this.syncForm.parallel,
  198. threads: this.syncForm.threads,
  199. overwriteConfirm: this.syncForm.overwriteConfirm,
  200. dryRun: dryRun
  201. }
  202. },
  203. handlePreview() {
  204. if (this.syncForm.scopeType === 'INCLUDE' && (!this.syncForm.tenantIds || !this.syncForm.tenantIds.length)) {
  205. this.$message.warning('请选择要预览的租户')
  206. return
  207. }
  208. const tenantId = this.syncForm.scopeType === 'INCLUDE' ? this.syncForm.tenantIds[0] : (this.tenantOptions[0] && this.tenantOptions[0].id)
  209. if (!tenantId) {
  210. this.$message.warning('无可用租户')
  211. return
  212. }
  213. previewDictSync(tenantId, this.syncForm.dictTypes).then(r => {
  214. this.previewList = r.data || []
  215. this.$message.success('预览完成(仅首个租户示例,完整预览请用 dryRun 同步)')
  216. })
  217. },
  218. handleSync(dryRun) {
  219. if (this.syncForm.syncMode === 'OVERWRITE' && !this.syncForm.overwriteConfirm) {
  220. this.$message.error('OVERWRITE 模式请先勾选确认')
  221. return
  222. }
  223. if (this.syncForm.scopeType !== 'ALL' && (!this.syncForm.tenantIds || !this.syncForm.tenantIds.length)) {
  224. this.$message.warning('请选择租户')
  225. return
  226. }
  227. this.$confirm(dryRun ? '确认预览同步差异?' : '确认开始字典同步任务?', '提示', { type: 'warning' }).then(() => {
  228. return runDictSync(this.buildPayload(dryRun))
  229. }).then(r => {
  230. const data = r.data || {}
  231. if (data.dryRun) {
  232. this.previewList = data.preview || []
  233. if (!this.previewList.length) {
  234. this.$message.warning('预览完成:无差异(请确认平台模板中已有字典类型和数据)')
  235. } else {
  236. this.$message.success('全量预览完成,共 ' + this.previewList.length + ' 个租户')
  237. }
  238. return
  239. }
  240. if (!data.taskNo) {
  241. this.$message.error('同步任务创建失败,请检查后端日志')
  242. return
  243. }
  244. this.taskNo = data.taskNo
  245. this.taskInfo = { status: 'RUNNING', syncMode: data.syncMode, totalTenants: data.totalTenants, successTenants: 0, failedTenants: 0, details: [] }
  246. this.startPoll()
  247. this.$message.success('同步任务已启动:' + data.taskNo)
  248. }).catch(err => {
  249. const msg = (err && err.message) || (typeof err === 'string' ? err : '同步失败')
  250. if (msg && msg !== 'cancel') this.$message.error(msg)
  251. })
  252. },
  253. startPoll() {
  254. if (this.pollTimer) clearInterval(this.pollTimer)
  255. this.pollTask()
  256. this.pollTimer = setInterval(() => this.pollTask(), 3000)
  257. },
  258. pollTask() {
  259. if (!this.taskNo) return
  260. getDictSyncTask(this.taskNo).then(r => {
  261. this.taskInfo = r.data
  262. if (this.taskInfo && ['SUCCESS', 'FAILED', 'PARTIAL'].includes(this.taskInfo.status)) {
  263. clearInterval(this.pollTimer)
  264. this.pollTimer = null
  265. }
  266. })
  267. }
  268. }
  269. }
  270. </script>
  271. <style scoped>
  272. .sync-panel { padding-top: 4px; }
  273. .mb12 { margin-bottom: 12px; }
  274. .mb16 { margin-bottom: 16px; }
  275. .mt12 { margin-top: 12px; }
  276. .dict-table { width: 100%; }
  277. .card-header {
  278. display: flex;
  279. align-items: center;
  280. justify-content: space-between;
  281. font-weight: 600;
  282. color: #303133;
  283. }
  284. .card-header i { margin-right: 6px; color: #409eff; }
  285. .sync-mode-card {
  286. padding: 14px 16px;
  287. border-radius: 8px;
  288. border: 1px solid #ebeef5;
  289. background: #fff;
  290. height: 100%;
  291. transition: box-shadow 0.2s;
  292. }
  293. .sync-mode-card:hover { box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06); }
  294. .sync-mode-title { font-size: 14px; font-weight: 600; margin-bottom: 6px; }
  295. .sync-mode-desc { font-size: 12px; color: #909399; line-height: 1.5; }
  296. .sync-mode-card.merge { border-left: 3px solid #67c23a; }
  297. .sync-mode-card.merge .sync-mode-title { color: #67c23a; }
  298. .sync-mode-card.append { border-left: 3px solid #409eff; }
  299. .sync-mode-card.append .sync-mode-title { color: #409eff; }
  300. .sync-mode-card.overwrite { border-left: 3px solid #e6a23c; }
  301. .sync-mode-card.overwrite .sync-mode-title { color: #e6a23c; }
  302. .sync-config-card >>> .el-card__body { padding-top: 8px; }
  303. .parallel-row { display: flex; align-items: center; gap: 12px; }
  304. .overwrite-check { margin-bottom: 0; }
  305. .action-row { margin-bottom: 0; margin-top: 4px; }
  306. .stat-num { font-weight: 600; }
  307. .stat-num.add { color: #67c23a; }
  308. .stat-num.update { color: #409eff; }
  309. .stat-fail { color: #f56c6c; font-weight: 600; }
  310. .task-summary { margin-bottom: 0; }
  311. </style>