PageHomeConfig.vue 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534
  1. <template>
  2. <div class="page-home-config">
  3. <!-- 顶部操作栏 -->
  4. <div class="toolbar">
  5. <div class="toolbar-left">
  6. <h2 class="toolbar-title">权益介绍页配置</h2>
  7. <el-tag :type="pageStatusTag.type" size="small" effect="plain">{{ pageStatusTag.label }}</el-tag>
  8. </div>
  9. <div class="toolbar-right">
  10. <el-button size="small" @click="showLinkDialog">
  11. <el-icon><Link /></el-icon>获取链接
  12. </el-button>
  13. <el-button size="small" type="primary" plain @click="handleSave('draft')">保存草稿</el-button>
  14. <el-button v-if="canPublish" size="small" type="primary" @click="handleSave('publish')">发布上线</el-button>
  15. </div>
  16. </div>
  17. <!-- 客群Tab -->
  18. <el-tabs v-model="currentTier" @tab-click="onTierChange">
  19. <el-tab-pane
  20. v-for="tier in tiers"
  21. :key="tier.key"
  22. :label="configData[tier.key] ? configData[tier.key].tabName : tier.label"
  23. :name="tier.key"
  24. />
  25. </el-tabs>
  26. <!-- 配置表单 -->
  27. <div v-if="currentTierData" class="config-sections">
  28. <!-- Tab名称配置 -->
  29. <el-card class="section-card" shadow="hover">
  30. <template #header>
  31. <div class="section-header">
  32. <el-icon><FolderOpened /></el-icon> Tab名称配置
  33. </div>
  34. </template>
  35. <el-form label-width="80px">
  36. <el-form-item>
  37. <template #label>Tab名称 <span class="required-star">*</span></template>
  38. <el-input v-model="currentTierData.tabName" placeholder="例如:卓越理财" style="width: 50%" @input="markModified" />
  39. </el-form-item>
  40. </el-form>
  41. </el-card>
  42. <!-- 头部配置 -->
  43. <el-card class="section-card" shadow="hover">
  44. <template #header>
  45. <div class="section-header">
  46. <el-icon><PictureRounded /></el-icon> 头部配置
  47. </div>
  48. </template>
  49. <el-form class="header-config-form" label-width="130px">
  50. <el-form-item>
  51. <template #label>头图配图 <span class="required-star">*</span></template>
  52. <div class="image-upload-area">
  53. <div class="upload-box" @click="triggerUpload('header')">
  54. <img v-if="currentTierData.header.imageUrl" :src="currentTierData.header.imageUrl" class="preview-img" />
  55. <div v-else class="upload-placeholder">
  56. <el-icon><Picture /></el-icon>
  57. <span>未上传图片</span>
  58. </div>
  59. <div class="upload-overlay">
  60. <el-icon><Upload /></el-icon>
  61. <span>点击上传</span>
  62. </div>
  63. </div>
  64. <div class="upload-tip">建议图片尺寸:<b>686 × 296 px</b></div>
  65. </div>
  66. <input ref="headerUpload" type="file" accept="image/*" style="display:none" @change="onImageUpload($event, 'header')" />
  67. </el-form-item>
  68. <el-form-item>
  69. <template #label>主标题(粗体) <span class="required-star">*</span></template>
  70. <el-input v-model="currentTierData.header.mainTitle" @input="markModified" />
  71. </el-form-item>
  72. <el-form-item label="副标题(小字)">
  73. <el-input v-model="currentTierData.header.subTitle" @input="markModified" />
  74. </el-form-item>
  75. </el-form>
  76. </el-card>
  77. <!-- 按钮配置 -->
  78. <el-card class="section-card" shadow="hover">
  79. <template #header>
  80. <div class="section-header">
  81. <el-icon><Pointer /></el-icon> 按钮配置
  82. </div>
  83. </template>
  84. <el-form label-width="130px">
  85. <el-row :gutter="20">
  86. <el-col :span="12">
  87. <el-form-item>
  88. <template #label>已解锁按钮文案 <span class="required-star">*</span></template>
  89. <el-input v-model="currentTierData.buttons.unlocked" placeholder="例如:进入权益专区" @input="markModified" />
  90. </el-form-item>
  91. </el-col>
  92. <el-col :span="12">
  93. <el-form-item>
  94. <template #label>待解锁按钮文案 <span class="required-star">*</span></template>
  95. <el-input v-model="currentTierData.buttons.locked" placeholder="例如:待解锁" @input="markModified" />
  96. </el-form-item>
  97. </el-col>
  98. </el-row>
  99. </el-form>
  100. </el-card>
  101. <!-- 权益列表配置 -->
  102. <el-card class="section-card" shadow="hover">
  103. <template #header>
  104. <div class="section-header">
  105. <el-icon><Grid /></el-icon> 权益列表配置
  106. <div class="header-right">
  107. <el-checkbox v-model="currentTierData.privilegeImageEnabled" @change="onPrivilegeImageToggle">
  108. 展示权益图片
  109. </el-checkbox>
  110. <el-button type="text" @click="addPrivilege">
  111. <el-icon><CirclePlus /></el-icon>添加权益
  112. </el-button>
  113. </div>
  114. </div>
  115. </template>
  116. <div v-if="currentTierData.privileges.length === 0" class="empty-tip">
  117. 暂无权益项
  118. </div>
  119. <div v-for="(item, index) in currentTierData.privileges" :key="index" class="privilege-item">
  120. <div class="privilege-index">NO. {{ index + 1 }}</div>
  121. <div class="privilege-actions">
  122. <el-popover placement="bottom" trigger="click" width="180">
  123. <div>
  124. <div style="margin-bottom:8px;font-size:13px;color:#666">移动到序号:</div>
  125. <div style="display:flex;gap:8px">
  126. <el-input-number v-model="sortTarget" :min="1" :max="currentTierData.privileges.length" size="small" controls-position="right" style="width:100px" />
  127. <el-button size="small" type="primary" @click="movePrivilege(index, sortTarget - 1)">确定</el-button>
  128. </div>
  129. </div>
  130. <template #reference>
  131. <el-button size="small" circle @click="sortTarget = index + 1">
  132. <el-icon><Sort /></el-icon>
  133. </el-button>
  134. </template>
  135. </el-popover>
  136. <el-button size="small" type="danger" circle @click="removePrivilege(index)">
  137. <el-icon><Delete /></el-icon>
  138. </el-button>
  139. </div>
  140. <div class="privilege-content">
  141. <div v-if="currentTierData.privilegeImageEnabled" class="privilege-image-wrap">
  142. <div class="privilege-image" @click="triggerUpload('privilege', index)">
  143. <img v-if="item.imageUrl" :src="item.imageUrl" class="preview-img-sm" />
  144. <div v-else class="upload-placeholder-sm">
  145. <el-icon><Picture /></el-icon>
  146. <span>未上传</span>
  147. </div>
  148. <input :ref="'privilegeUpload' + index" type="file" accept="image/*" style="display:none" @change="onImageUpload($event, 'privilege', index)" />
  149. </div>
  150. <div class="privilege-text-tip">必填,建议尺寸:<br/>100 * 100 px</div>
  151. </div>
  152. <div class="privilege-fields">
  153. <el-form label-width="60px" inline>
  154. <el-form-item>
  155. <template #label>标题 <span class="required-star">*</span></template>
  156. <el-input v-model="item.title" style="width:160px" @input="markModified" />
  157. </el-form-item>
  158. <el-form-item label="描述">
  159. <el-input v-model="item.desc" style="width:300px" @input="markModified" />
  160. </el-form-item>
  161. </el-form>
  162. </div>
  163. </div>
  164. </div>
  165. </el-card>
  166. </div>
  167. <!-- 获取链接弹窗 -->
  168. <el-dialog title="获取页面链接" v-model="linkDialogVisible" width="680px">
  169. <el-form label-width="100px" inline>
  170. <el-form-item label="Campaign ID">
  171. <el-select v-model="linkForm.campaignId" clearable filterable placeholder="请选择 (无)" style="width:240px" @change="updateLinks">
  172. <el-option v-for="opt in campaignOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
  173. </el-select>
  174. </el-form-item>
  175. <el-form-item label="Channel ID">
  176. <el-select v-model="linkForm.channelId" clearable filterable placeholder="请选择 (无)" style="width:240px" @change="updateLinks">
  177. <el-option v-for="opt in channelOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
  178. </el-select>
  179. </el-form-item>
  180. </el-form>
  181. <div v-if="['published', 'modified'].includes(pageStatus)" class="link-section">
  182. <div class="link-label"><span class="dot dot-green"></span> 正式环境链接</div>
  183. <div class="link-row">
  184. <span class="link-type">H5链接</span>
  185. <el-input :value="prodH5Url" readonly size="small" />
  186. <el-button size="small" type="primary" plain @click="copyLink(prodH5Url)">复制</el-button>
  187. </div>
  188. <div class="link-row">
  189. <span class="link-type">小程序链接</span>
  190. <el-input :value="prodAppUrl" readonly size="small" />
  191. <el-button size="small" type="primary" plain @click="copyLink(prodAppUrl)">复制</el-button>
  192. </div>
  193. </div>
  194. <div class="link-section">
  195. <div class="link-label"><span class="dot dot-blue"></span> 草稿预览链接 (测试)</div>
  196. <div class="link-row">
  197. <el-input :value="testUrl" readonly size="small" />
  198. <el-button size="small" type="primary" plain @click="copyLink(testUrl)">复制</el-button>
  199. </div>
  200. </div>
  201. </el-dialog>
  202. </div>
  203. </template>
  204. <script>
  205. import { ElMessage, ElMessageBox } from 'element-plus'
  206. import request from '@/utils/request'
  207. import { fetchLinkOptions } from '@/utils/linkOptions'
  208. export default {
  209. name: 'PageHomeConfig',
  210. data() {
  211. return {
  212. currentTier: 'premier',
  213. tiers: [
  214. { key: 'premier', label: '卓越理财' },
  215. { key: 'elite', label: '卓越理财·尊尚' },
  216. { key: 'global', label: '环球私人银行' }
  217. ],
  218. configData: {},
  219. pageStatus: 'draft',
  220. isModified: false,
  221. sortTarget: 1,
  222. linkDialogVisible: false,
  223. linkForm: { campaignId: '', channelId: '' },
  224. campaignOptions: [],
  225. channelOptions: []
  226. }
  227. },
  228. computed: {
  229. currentTierData() {
  230. return this.configData[this.currentTier] || null
  231. },
  232. pageStatusTag() {
  233. if (this.isModified) return { type: 'warning', label: '有未保存修改' }
  234. if (this.pageStatus === 'published') return { type: 'success', label: '正式线上环境' }
  235. return { type: 'info', label: '草稿 (未发布)' }
  236. },
  237. canPublish() {
  238. return !this.isModified && ['draft', 'modified'].includes(this.pageStatus)
  239. },
  240. linkQueryStr() {
  241. const params = []
  242. if (this.linkForm.campaignId) params.push(`CampaignId=${this.linkForm.campaignId}`)
  243. if (this.linkForm.channelId) params.push(`Channelid=${this.linkForm.channelId}`)
  244. return params.length ? '?' + params.join('&') : ''
  245. },
  246. prodH5Url() {
  247. return `https://hsbc.com/home${this.linkQueryStr}`
  248. },
  249. prodAppUrl() {
  250. return `hsbc-app://home${this.linkQueryStr}`
  251. },
  252. testUrl() {
  253. const q = this.linkQueryStr
  254. return `https://test.hsbc.com/home${q ? q + '&' : '?'}draft=1`
  255. }
  256. },
  257. created() {
  258. this.fetchData()
  259. },
  260. beforeRouteLeave(to, from, next) {
  261. if (!this.isModified) {
  262. next()
  263. return
  264. }
  265. this.confirmLeaveUnsaved().then(() => next()).catch(() => next(false))
  266. },
  267. methods: {
  268. async fetchData() {
  269. const res = await request.get('/api/page-config/home')
  270. this.configData = this.normalizeHomeConfig(res.data.config || {})
  271. this.pageStatus = res.data.status
  272. this.isModified = false
  273. },
  274. normalizeHomeConfig(config) {
  275. Object.keys(config).forEach((key) => {
  276. const tier = config[key]
  277. if (!tier) return
  278. if (!Object.prototype.hasOwnProperty.call(tier, 'privilegeImageEnabled')) {
  279. tier.privilegeImageEnabled = false
  280. }
  281. if (!Array.isArray(tier.privileges)) {
  282. tier.privileges = []
  283. }
  284. })
  285. return config
  286. },
  287. async loadLinkOptions() {
  288. const { campaignOptions, channelOptions } = await fetchLinkOptions()
  289. this.campaignOptions = campaignOptions
  290. this.channelOptions = channelOptions
  291. },
  292. markModified() {
  293. this.isModified = true
  294. },
  295. isBlank(value) {
  296. return !(value || '').trim()
  297. },
  298. showSaveError(message) {
  299. ElMessage.error(`保存失败:${message}`)
  300. return false
  301. },
  302. getTierName(key, tier) {
  303. return (tier && tier.tabName) || (this.tiers.find(item => item.key === key)?.label) || '当前客群'
  304. },
  305. confirmLeaveUnsaved() {
  306. return ElMessageBox.confirm('当前有未保存修改,离开后修改内容将丢失,确定离开吗?', '未保存修改', {
  307. type: 'warning',
  308. confirmButtonText: '确定离开',
  309. cancelButtonText: '继续编辑'
  310. })
  311. },
  312. onTierChange() {},
  313. triggerUpload(type, index) {
  314. if (type === 'header') {
  315. this.$refs.headerUpload.click()
  316. } else {
  317. const ref = this.$refs['privilegeUpload' + index]
  318. if (ref) (Array.isArray(ref) ? ref[0] : ref).click()
  319. }
  320. },
  321. onImageUpload(event, type, index) {
  322. const file = event.target.files[0]
  323. if (!file) return
  324. const reader = new FileReader()
  325. reader.onload = (e) => {
  326. if (type === 'header') {
  327. this.currentTierData.header.imageUrl = e.target.result
  328. } else {
  329. this.currentTierData.privileges[index].imageUrl = e.target.result
  330. }
  331. this.markModified()
  332. }
  333. reader.readAsDataURL(file)
  334. event.target.value = ''
  335. },
  336. addPrivilege() {
  337. this.currentTierData.privileges.push({ imageUrl: '', title: '', desc: '' })
  338. this.markModified()
  339. },
  340. removePrivilege(index) {
  341. this.currentTierData.privileges.splice(index, 1)
  342. this.markModified()
  343. },
  344. movePrivilege(from, to) {
  345. if (to < 0 || to >= this.currentTierData.privileges.length || from === to) return
  346. const item = this.currentTierData.privileges.splice(from, 1)[0]
  347. this.currentTierData.privileges.splice(to, 0, item)
  348. this.markModified()
  349. },
  350. validateRequiredConfig() {
  351. const entries = Object.entries(this.configData || {})
  352. for (const [key, tier] of entries) {
  353. if (!tier) continue
  354. const tierName = this.getTierName(key, tier)
  355. if (this.isBlank(tier.tabName)) {
  356. this.currentTier = key
  357. return this.showSaveError(`${tierName}-Tab名称配置-Tab名称未填写`)
  358. }
  359. if (!tier.header?.imageUrl) {
  360. this.currentTier = key
  361. return this.showSaveError(`${tierName}-头部配置-头图配图未上传`)
  362. }
  363. if (this.isBlank(tier.header?.mainTitle)) {
  364. this.currentTier = key
  365. return this.showSaveError(`${tierName}-头部配置-主标题未填写`)
  366. }
  367. if (this.isBlank(tier.buttons?.unlocked)) {
  368. this.currentTier = key
  369. return this.showSaveError(`${tierName}-按钮配置-已解锁按钮文案未填写`)
  370. }
  371. if (this.isBlank(tier.buttons?.locked)) {
  372. this.currentTier = key
  373. return this.showSaveError(`${tierName}-按钮配置-待解锁按钮文案未填写`)
  374. }
  375. for (const [index, item] of (tier.privileges || []).entries()) {
  376. if (this.isBlank(item.title)) {
  377. this.currentTier = key
  378. return this.showSaveError(`${tierName}-权益列表配置-第 ${index + 1} 个权益-标题未填写`)
  379. }
  380. if (tier.privilegeImageEnabled && !item.imageUrl) {
  381. this.currentTier = key
  382. return this.showSaveError(`${tierName}-权益列表配置-第 ${index + 1} 个权益-图片未上传`)
  383. }
  384. }
  385. }
  386. return true
  387. },
  388. async handleSave(type) {
  389. if (!this.validateRequiredConfig()) return
  390. if (type === 'publish' && this.isModified) {
  391. ElMessage.warning('请先保存草稿后再发布上线')
  392. return
  393. }
  394. const status = type === 'publish' ? 'published' : (this.pageStatus === 'published' || this.pageStatus === 'modified' ? 'modified' : 'draft')
  395. await request.put('/api/page-config/home', { config: this.configData, status })
  396. this.pageStatus = status
  397. this.isModified = false
  398. ElMessage.success(type === 'publish' ? '已发布权益介绍页配置!' : '已保存权益介绍页配置草稿!')
  399. },
  400. onPrivilegeImageToggle(val) {
  401. this.markModified()
  402. if (!val) {
  403. this.currentTierData.privileges.forEach(p => { p.imageUrl = '' })
  404. }
  405. },
  406. async showLinkDialog() {
  407. if (this.isModified) {
  408. ElMessage.warning('当前链接基于上一次保存内容,最新修改需保存后生效')
  409. }
  410. await this.loadLinkOptions()
  411. this.linkForm = { campaignId: '', channelId: '' }
  412. this.linkDialogVisible = true
  413. },
  414. updateLinks() {},
  415. copyLink(text) {
  416. navigator.clipboard.writeText(text).then(() => {
  417. ElMessage.success('链接已复制到剪贴板')
  418. })
  419. }
  420. }
  421. }
  422. </script>
  423. <style scoped>
  424. .toolbar {
  425. display: flex;
  426. justify-content: space-between;
  427. align-items: center;
  428. margin-bottom: 16px;
  429. padding: 12px 16px;
  430. background: #fff;
  431. border-radius: 4px;
  432. box-shadow: 0 1px 4px rgba(0,0,0,.06);
  433. }
  434. .toolbar-left { display: flex; align-items: center; gap: 12px; }
  435. .toolbar-title { margin: 0; font-size: 18px; font-weight: 600; }
  436. .toolbar-right { display: flex; gap: 8px; }
  437. .required-star { color: #F56C6C; }
  438. .config-sections { margin-top: 16px; }
  439. .section-card { margin-bottom: 16px; }
  440. .section-header { display: flex; align-items: center; gap: 12px; font-size: 15px; font-weight: 600; color: #303133; }
  441. .section-header i { color: #909399; font-size: 18px; }
  442. .header-right { margin-left: auto; display: flex; align-items: center; gap: 12px; }
  443. .header-right :deep(.el-checkbox) { font-weight: normal; }
  444. .header-config-form .el-form-item__label { white-space: nowrap; }
  445. .image-upload-area { display: flex; align-items: center; gap: 16px; }
  446. .upload-box {
  447. width: 360px; height: 160px;
  448. background: #eef6fc; border: 1px solid #dcdfe6; border-radius: 4px;
  449. position: relative; overflow: hidden; cursor: pointer;
  450. display: flex; align-items: center; justify-content: center;
  451. }
  452. .upload-box:hover .upload-overlay { display: flex; }
  453. .preview-img { width: 100%; height: 100%; object-fit: cover; }
  454. .upload-placeholder {
  455. display: flex; flex-direction: column; align-items: center;
  456. color: #c0c4cc; font-size: 12px;
  457. }
  458. .upload-placeholder i { font-size: 28px; margin-bottom: 4px; }
  459. .upload-overlay {
  460. display: none; position: absolute; inset: 0;
  461. background: rgba(0,0,0,.5); color: #fff;
  462. flex-direction: column; align-items: center; justify-content: center;
  463. font-size: 14px;
  464. }
  465. .upload-overlay i { font-size: 28px; margin-bottom: 6px; }
  466. .upload-tip {
  467. font-size: 12px; color: #909399;
  468. background: #f5f7fa; padding: 8px 12px; border-radius: 4px; border: 1px solid #ebeef5;
  469. }
  470. .privilege-item {
  471. position: relative;
  472. border: 1px solid #ebeef5; border-radius: 8px;
  473. padding: 20px 16px 16px; margin-bottom: 12px;
  474. background: #fff;
  475. }
  476. .privilege-index {
  477. position: absolute; left: 0; top: 0;
  478. background: #409EFF; color: #fff;
  479. font-size: 11px; font-weight: 700;
  480. padding: 2px 10px; border-radius: 8px 0 8px 0;
  481. }
  482. .privilege-actions {
  483. position: absolute; right: 12px; top: 12px;
  484. display: flex; gap: 6px;
  485. }
  486. .privilege-content { display: flex; align-items: flex-start; gap: 12px; margin-top: 8px; }
  487. .privilege-image-wrap {
  488. width: 96px;
  489. flex-shrink: 0;
  490. display: flex;
  491. flex-direction: column;
  492. align-items: center;
  493. }
  494. .privilege-image {
  495. width: 56px; height: 56px; flex-shrink: 0;
  496. background: #eef6fc; border: 1px solid #dcdfe6; border-radius: 4px;
  497. display: flex; align-items: center; justify-content: center;
  498. cursor: pointer; overflow: hidden; position: relative;
  499. }
  500. .preview-img-sm { width: 100%; height: 100%; object-fit: cover; }
  501. .upload-placeholder-sm {
  502. display: flex; flex-direction: column; align-items: center;
  503. color: #c0c4cc; font-size: 10px;
  504. }
  505. .upload-placeholder-sm i { font-size: 18px; }
  506. .privilege-text-tip { font-size: 10px; color: #909399; text-align: center; line-height: 1.35; margin-top: 6px; }
  507. .privilege-fields { flex: 1; }
  508. .empty-tip {
  509. text-align: center; color: #c0c4cc; padding: 24px;
  510. border: 1px dashed #dcdfe6; border-radius: 4px;
  511. }
  512. .link-section { margin-top: 16px; padding: 12px; background: #fafafa; border-radius: 6px; border: 1px solid #ebeef5; }
  513. .link-section + .link-section { margin-top: 12px; }
  514. .link-label { font-weight: 600; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
  515. .link-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
  516. .link-type { font-size: 12px; color: #909399; width: 70px; flex-shrink: 0; }
  517. .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
  518. .dot-green { background: #67C23A; }
  519. .dot-blue { background: #409EFF; }
  520. </style>