Sfoglia il codice sorgente

完善页面配置交互

csk 1 mese fa
parent
commit
c626e84715

+ 86 - 5
工作空间/产研/权益/汇丰全行/产品原型/pc-demo/src/views/ChannelManagement.vue

@@ -9,15 +9,19 @@
               <el-input v-model="searchForm.id" placeholder="支持精确查询" clearable style="width:160px" />
             </el-form-item>
             <el-form-item label="Channel 类型:">
-              <el-select v-model="searchForm.type" clearable placeholder="全部" style="width:160px">
+              <el-select v-model="searchForm.type" clearable placeholder="全部" style="width:160px" @change="onSearchTypeChange">
                 <el-option v-for="t in typeOptions" :key="t" :label="t" :value="t" />
               </el-select>
             </el-form-item>
             <el-form-item label="Channel 类别:">
-              <el-input v-model="searchForm.category" placeholder="关键字" clearable style="width:160px" />
+              <el-select v-model="searchForm.category" clearable filterable placeholder="全部" style="width:160px" @change="onSearchCategoryChange">
+                <el-option v-for="c in searchCategoryOptions" :key="c" :label="c" :value="c" />
+              </el-select>
             </el-form-item>
             <el-form-item label="Channel 名称:">
-              <el-input v-model="searchForm.name" placeholder="关键字" clearable style="width:160px" />
+              <el-select v-model="searchForm.name" clearable filterable placeholder="全部" style="width:160px">
+                <el-option v-for="n in searchNameOptions" :key="n" :label="n" :value="n" />
+              </el-select>
             </el-form-item>
           </div>
           <div class="search-row">
@@ -39,7 +43,7 @@
         </div>
       </template>
 
-      <el-table :data="tableData" stripe border style="width:100%">
+      <el-table :data="pagedTableData" stripe border style="width:100%">
         <el-table-column label="操作" width="200" fixed="left">
           <template #default="scope">
             <el-button type="text" size="small" @click="openModal('edit', scope.row)">编辑</el-button>
@@ -64,6 +68,18 @@
         <el-table-column prop="createTime" label="创建时间" width="180" align="center" />
         <el-table-column prop="updateTime" label="更新时间" width="180" align="center" />
       </el-table>
+      <div class="pagination-section">
+        <span class="total">Total {{ tableData.length }}</span>
+        <el-pagination
+          v-model:current-page="pagination.currentPage"
+          v-model:page-size="pagination.pageSize"
+          layout="prev, pager, next, sizes, jumper"
+          :page-sizes="[10, 20, 50]"
+          :total="tableData.length"
+          @size-change="handlePageSizeChange"
+          @current-change="handleCurrentPageChange"
+        />
+      </div>
     </el-card>
 
     <!-- 新建/编辑/查看弹窗 -->
@@ -154,6 +170,10 @@ export default {
     return {
       searchForm: { id: '', type: '', category: '', name: '', subName: '', owner: '' },
       tableData: [],
+      pagination: {
+        currentPage: 1,
+        pageSize: 10
+      },
       channelDict: {},
       modalVisible: false,
       modalMode: 'create',
@@ -180,6 +200,31 @@ export default {
     nameOptions() {
       if (!this.form.type || !this.form.category) return []
       return (this.channelDict[this.form.type] || {})[this.form.category] || []
+    },
+    searchCategoryOptions() {
+      const dict = this.channelDict || {}
+      if (this.searchForm.type && dict[this.searchForm.type]) {
+        return Object.keys(dict[this.searchForm.type])
+      }
+      const categories = Object.values(dict).flatMap(group => Object.keys(group || {}))
+      return [...new Set(categories)]
+    },
+    searchNameOptions() {
+      const dict = this.channelDict || {}
+      const types = this.searchForm.type ? [this.searchForm.type] : Object.keys(dict)
+      const names = []
+      types.forEach(type => {
+        const group = dict[type] || {}
+        const categories = this.searchForm.category ? [this.searchForm.category] : Object.keys(group)
+        categories.forEach(category => {
+          names.push(...(group[category] || []))
+        })
+      })
+      return [...new Set(names)]
+    },
+    pagedTableData() {
+      const start = (this.pagination.currentPage - 1) * this.pagination.pageSize
+      return this.tableData.slice(start, start + this.pagination.pageSize)
     }
   },
   created() {
@@ -194,12 +239,34 @@ export default {
     async fetchList() {
       const res = await request.get('/api/channel/list', { params: this.searchForm })
       this.tableData = res.data
+      this.syncCurrentPage()
+    },
+    handleSearch() {
+      this.pagination.currentPage = 1
+      this.fetchList()
     },
-    handleSearch() { this.fetchList() },
     handleReset() {
       this.searchForm = { id: '', type: '', category: '', name: '', subName: '', owner: '' }
+      this.pagination.currentPage = 1
       this.fetchList()
     },
+    onSearchTypeChange() {
+      this.searchForm.category = ''
+      this.searchForm.name = ''
+    },
+    onSearchCategoryChange() {
+      this.searchForm.name = ''
+    },
+    handlePageSizeChange() {
+      this.pagination.currentPage = 1
+    },
+    handleCurrentPageChange(page) {
+      this.pagination.currentPage = page
+    },
+    syncCurrentPage() {
+      const maxPage = Math.max(1, Math.ceil(this.tableData.length / this.pagination.pageSize))
+      if (this.pagination.currentPage > maxPage) this.pagination.currentPage = maxPage
+    },
     async handleDelete(row) {
       await ElMessageBox.confirm('您确定要删除此条数据吗?删除后将无法恢复。', '确认删除', { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' })
       await request.post('/api/channel/delete', { id: row.id })
@@ -298,6 +365,7 @@ export default {
       await request.post('/api/channel/save', this.form)
       ElMessage.success(this.modalMode === 'create' ? '新建成功' : '保存成功')
       this.modalVisible = false
+      if (this.modalMode === 'create') this.pagination.currentPage = 1
       this.fetchList()
     }
   }
@@ -309,6 +377,19 @@ export default {
 .search-section .action-row { margin-top: 15px; }
 .empty-text { color: #c0c4cc; }
 
+.pagination-section {
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+  gap: 20px;
+  margin-top: 20px;
+}
+
+.pagination-section .total {
+  color: #606266;
+  font-size: 13px;
+}
+
 .inline-field { display: flex; gap: 8px; align-items: center; width: 100%; }
 .inline-field :deep(.el-select) { flex: 1; min-width: 0; }
 .quick-panel {

+ 181 - 39
工作空间/产研/权益/汇丰全行/产品原型/pc-demo/src/views/PageAssembly.vue

@@ -27,7 +27,7 @@
           </div>
         </template>
 
-        <el-table :data="tableData" stripe border style="width:100%">
+        <el-table :data="pagedTableData" stripe border style="width:100%">
           <el-table-column label="操作" width="120" fixed="left" align="center">
             <template #default="scope">
               <div class="list-actions">
@@ -40,14 +40,28 @@
           <el-table-column prop="name" label="组装页名称" min-width="200" show-overflow-tooltip />
           <el-table-column label="发布状态" width="100" align="center">
             <template #default="scope">
-              <el-tag :type="scope.row.status === 'published' ? 'success' : 'info'" size="small">
-                {{ scope.row.status === 'published' ? '已上架' : '草稿' }}
-              </el-tag>
+              <el-tooltip :content="getAssemblyStatusTag(scope.row.status).label" placement="top">
+                <el-tag :type="getAssemblyStatusTag(scope.row.status).type" size="small">
+                  {{ getAssemblyListStatusTag(scope.row.status).label }}
+                </el-tag>
+              </el-tooltip>
             </template>
           </el-table-column>
           <el-table-column prop="createTime" label="创建时间" width="180" align="center" />
           <el-table-column prop="updateTime" label="更新时间" width="180" align="center" />
         </el-table>
+        <div class="pagination-section">
+          <span class="total">Total {{ tableData.length }}</span>
+          <el-pagination
+            v-model:current-page="pagination.currentPage"
+            v-model:page-size="pagination.pageSize"
+            layout="prev, pager, next, sizes, jumper"
+            :page-sizes="[10, 20, 50]"
+            :total="tableData.length"
+            @size-change="handlePageSizeChange"
+            @current-change="handleCurrentPageChange"
+          />
+        </div>
       </el-card>
     </template>
 
@@ -60,9 +74,7 @@
             <el-icon><ArrowLeft /></el-icon>返回
           </el-button>
           <h2 class="toolbar-title">{{ isNew ? '新建组装页' : '编辑组装页' }}</h2>
-          <el-tag v-if="editData.status === 'published' && !isModified" type="success" size="small" effect="plain">正式线上环境</el-tag>
-          <el-tag v-else-if="showModifiedStatus" type="warning" size="small" effect="plain">有未发布的修改</el-tag>
-          <el-tag v-else size="info" effect="plain">草稿 (未发布)</el-tag>
+          <el-tag :type="pageStatusTag.type" size="small" effect="plain">{{ pageStatusTag.label }}</el-tag>
         </div>
         <div class="toolbar-right">
           <el-button size="small" @click="showLinkDialog(editData.id)">
@@ -446,6 +458,10 @@ export default {
       viewState: 'list',
       tableData: [],
       searchForm: { id: '', name: '' },
+      pagination: {
+        currentPage: 1,
+        pageSize: 10
+      },
       editData: this.createEmptyAssembly(),
       isNew: true,
       isModified: false,
@@ -475,8 +491,14 @@ export default {
     hasPrivilegeBlock() {
       return this.editData.blocks.some(b => b.type === 'privilege')
     },
-    showModifiedStatus() {
-      return this.editData.status === 'modified' || (this.editData.status === 'published' && this.isModified)
+    pagedTableData() {
+      const start = (this.pagination.currentPage - 1) * this.pagination.pageSize
+      return this.tableData.slice(start, start + this.pagination.pageSize)
+    },
+    pageStatusTag() {
+      if (this.isModified && !this.isNew) return { type: 'warning', label: '有未保存修改' }
+      if (this.editData.status === 'published') return { type: 'success', label: '正式线上环境' }
+      return { type: 'info', label: '草稿 (未发布)' }
     },
     canPublish() {
       return !this.isModified && !!this.editData.id && ['draft', 'modified'].includes(this.editData.status)
@@ -500,6 +522,13 @@ export default {
   created() {
     this.fetchList()
   },
+  beforeRouteLeave(to, from, next) {
+    if (!this.isModified) {
+      next()
+      return
+    }
+    this.confirmLeaveUnsaved().then(() => next()).catch(() => next(false))
+  },
   methods: {
     createDefaultJumpConfig(overrides = {}) {
       return {
@@ -534,7 +563,7 @@ export default {
     },
     createDefaultButton(overrides = {}) {
       return {
-        text: '新按钮',
+        text: '',
         link: '',
         style: 'secondary',
         actionType: 'link',
@@ -556,6 +585,28 @@ export default {
       delete config.desc
       return config
     },
+    isBlank(value) {
+      return !(value || '').trim()
+    },
+    showSaveError(message) {
+      ElMessage.error(`保存失败:${message}`)
+      return false
+    },
+    confirmLeaveUnsaved() {
+      return ElMessageBox.confirm('当前有未保存修改,离开后修改内容将丢失,确定离开吗?', '未保存修改', {
+        type: 'warning',
+        confirmButtonText: '确定离开',
+        cancelButtonText: '继续编辑'
+      })
+    },
+    getAssemblyStatusTag(status) {
+      if (status === 'published') return { type: 'success', label: '正式线上环境' }
+      return { type: 'info', label: '草稿 (未发布)' }
+    },
+    getAssemblyListStatusTag(status) {
+      if (status === 'published') return { type: 'success', label: '正式' }
+      return { type: 'info', label: '草稿' }
+    },
     ensureReactiveField(target, key, value) {
       if (Object.prototype.hasOwnProperty.call(target, key)) {
         target[key] = value
@@ -693,6 +744,7 @@ export default {
     async fetchList() {
       const res = await request.get('/api/assembly/list', { params: this.searchForm })
       this.tableData = res.data.sort((a, b) => b.id.localeCompare(a.id))
+      this.syncCurrentPage()
     },
     async loadLinkOptions() {
       const { campaignOptions, channelOptions } = await fetchLinkOptions()
@@ -700,12 +752,24 @@ export default {
       this.channelOptions = channelOptions
     },
     handleSearch() {
+      this.pagination.currentPage = 1
       this.fetchList()
     },
     handleReset() {
       this.searchForm = { id: '', name: '' }
+      this.pagination.currentPage = 1
       this.fetchList()
     },
+    handlePageSizeChange() {
+      this.pagination.currentPage = 1
+    },
+    handleCurrentPageChange(page) {
+      this.pagination.currentPage = page
+    },
+    syncCurrentPage() {
+      const maxPage = Math.max(1, Math.ceil(this.tableData.length / this.pagination.pageSize))
+      if (this.pagination.currentPage > maxPage) this.pagination.currentPage = maxPage
+    },
     handleCreate() {
       this.editData = this.createEmptyAssembly()
       this.isNew = true
@@ -728,37 +792,21 @@ export default {
       ElMessage.success('删除成功')
       this.fetchList()
     },
-    backToList() {
+    async backToList() {
+      if (this.isModified) {
+        try {
+          await this.confirmLeaveUnsaved()
+        } catch (e) {
+          return
+        }
+      }
       this.viewState = 'list'
       this.fetchList()
     },
     async handleSaveAssembly(type) {
       const data = this.editData
       this.normalizeAssemblyData(data)
-      let hasError = false
-      data.blocks.forEach(block => {
-        if (block.type === 'buttonGroup') {
-          block.data.buttons.forEach(btn => {
-            if (btn.actionType === 'link' && !this.validateButtonJumpConfig(btn)) hasError = true
-            if (btn.actionType === 'dialog' && btn.dialog && !this.validateDialogConfig(btn.dialog)) hasError = true
-          })
-        }
-      })
-      if (hasError) {
-        ElMessage.error('保存失败:按钮模块中存在未填写完整的弹窗或跳转配置!')
-        return
-      }
-      if (data.shareConfig.enabled) {
-        if (!data.shareConfig.thumbnail) {
-          ElMessage.error('保存失败:分享缩略图为必填项!')
-          return
-        }
-        if (!data.shareConfig.title.trim()) {
-          ElMessage.error('保存失败:分享标题为必填项!')
-          return
-        }
-      }
-      if (!data.name.trim()) data.name = '未命名组装页'
+      if (!this.validateAssemblyConfig(data)) return
       if (type === 'publish' && this.isModified) {
         ElMessage.warning('请先保存草稿后再发布上线')
         return
@@ -798,9 +846,9 @@ export default {
     addBlock(type) {
       const block = { type, id: genBlockId(), data: {} }
       if (type === 'image') block.data = { imageUrl: '' }
-      if (type === 'privilege') block.data = { title: '新权益标题', items: [{ id: '1', title: '模拟权益', desc: '描述' }] }
-      if (type === 'richtext') block.data = { content: '<p>输入富文本内容...</p>' }
-      if (type === 'buttonGroup') block.data = { buttons: [this.createDefaultButton({ text: '点击跳转', style: 'primary' })] }
+      if (type === 'privilege') block.data = { title: '', items: [] }
+      if (type === 'richtext') block.data = { content: '<p></p>' }
+      if (type === 'buttonGroup') block.data = { buttons: [this.createDefaultButton({ style: 'primary' })] }
       this.editData.blocks.push(block)
       this.isModified = true
     },
@@ -870,6 +918,18 @@ export default {
       }
       return false
     },
+    getButtonJumpConfigError(btn) {
+      if (this.getPartnerFixedLink(btn.linkType)) return ''
+      if (btn.linkType === 'h5') {
+        return this.isBlank(btn.linkH5) ? '配置链接未填写' : ''
+      }
+      if (btn.linkType === 'miniProgram') {
+        if (this.isBlank(btn.miniAppId)) return 'AppID未填写'
+        if (this.isBlank(btn.miniAppPath)) return '页面路径未填写'
+        return ''
+      }
+      return '跳转方式未选择'
+    },
     isRichTextEmpty(value) {
       const text = String(value || '')
         .replace(/<[^>]*>/g, '')
@@ -877,6 +937,72 @@ export default {
         .trim()
       return !text
     },
+    getDialogJumpConfigError(dialog) {
+      if (this.getPartnerFixedLink(dialog.confirmLinkType)) return ''
+      if (dialog.confirmLinkType === 'h5') {
+        return this.isBlank(dialog.confirmLinkH5) ? '配置链接未填写' : ''
+      }
+      if (dialog.confirmLinkType === 'miniProgram') {
+        if (this.isBlank(dialog.confirmMiniAppId)) return 'AppID未填写'
+        if (this.isBlank(dialog.confirmMiniAppPath)) return '页面路径未填写'
+        return ''
+      }
+      return '跳转方式未选择'
+    },
+    getDialogConfigError(dialog) {
+      if (this.isBlank(dialog.title)) return '弹窗内容配置-弹窗标题未填写'
+      if (this.isRichTextEmpty(dialog.desc)) return '弹窗内容配置-弹窗描述未填写'
+      if (this.isBlank(dialog.cancelText)) return '弹窗内容配置-左侧取消按钮配置未填写'
+      if (dialog.showConfirm === false) return ''
+      if (this.isBlank(dialog.confirmText)) return '弹窗内容配置-右侧确认按钮配置未填写'
+      const jumpError = this.getDialogJumpConfigError(dialog)
+      return jumpError ? `确认按钮跳转链接配置-${jumpError}` : ''
+    },
+    validateAssemblyConfig(data) {
+      if (this.isBlank(data.name)) {
+        return this.showSaveError('基本信息-组装页名称未填写')
+      }
+      if (data.shareConfig.enabled) {
+        if (!data.shareConfig.thumbnail) {
+          return this.showSaveError('微信分享卡片配置-分享缩略图未上传')
+        }
+        if (this.isBlank(data.shareConfig.title)) {
+          return this.showSaveError('微信分享卡片配置-分享标题未填写')
+        }
+      }
+      let imageIndex = 0
+      let buttonModuleIndex = 0
+      for (const block of (data.blocks || [])) {
+        if (block.type === 'image') {
+          imageIndex += 1
+          if (!block.data?.imageUrl) {
+            return this.showSaveError(`图片模块-第 ${imageIndex} 个-图片未上传`)
+          }
+        }
+        if (block.type === 'buttonGroup') {
+          buttonModuleIndex += 1
+          const buttons = block.data?.buttons || []
+          if (!buttons.length) {
+            return this.showSaveError(`按钮模块-第 ${buttonModuleIndex} 个模块-至少配置一个按钮`)
+          }
+          for (const [buttonIndex, btn] of buttons.entries()) {
+            const prefix = `按钮模块-第 ${buttonModuleIndex} 个模块-第 ${buttonIndex + 1} 个按钮`
+            if (this.isBlank(btn.text)) {
+              return this.showSaveError(`${prefix}-按钮文案未填写`)
+            }
+            if (btn.actionType === 'link') {
+              const jumpError = this.getButtonJumpConfigError(btn)
+              if (jumpError) return this.showSaveError(`${prefix}-跳转链接配置-${jumpError}`)
+            }
+            if (btn.actionType === 'dialog' && btn.dialog) {
+              const dialogError = this.getDialogConfigError(btn.dialog)
+              if (dialogError) return this.showSaveError(`${prefix}-${dialogError}`)
+            }
+          }
+        }
+      }
+      return true
+    },
     validateDialogConfig(dialog) {
       if (!(dialog.title || '').trim()) return false
       if (this.isRichTextEmpty(dialog.desc)) return false
@@ -928,10 +1054,13 @@ export default {
         ElMessage.warning('请先保存组装页再获取链接!')
         return
       }
+      if (this.viewState === 'edit' && this.isModified && id === this.editData.id) {
+        ElMessage.warning('当前链接基于上一次保存内容,最新修改需保存后生效')
+      }
       await this.loadLinkOptions()
       this.linkTargetId = id
       const item = this.tableData.find(a => a.id === id) || this.editData
-      this.linkTargetPublished = item && item.status === 'published'
+      this.linkTargetPublished = item && ['published', 'modified'].includes(item.status)
       this.linkForm = { campaignId: '', channelId: '' }
       this.linkDialogVisible = true
     },
@@ -980,6 +1109,19 @@ export default {
   line-height: 30px;
 }
 
+.pagination-section {
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+  gap: 20px;
+  margin-top: 20px;
+}
+
+.pagination-section .total {
+  color: #606266;
+  font-size: 13px;
+}
+
 .required-star { color: #F56C6C; }
 .dialog-desc-editor {
   width: 100%;

+ 68 - 16
工作空间/产研/权益/汇丰全行/产品原型/pc-demo/src/views/PageHomeConfig.vue

@@ -4,9 +4,7 @@
     <div class="toolbar">
       <div class="toolbar-left">
         <h2 class="toolbar-title">权益介绍页配置</h2>
-        <el-tag v-if="pageStatus === 'published'" type="success" size="small" effect="plain">正式线上环境</el-tag>
-        <el-tag v-else-if="pageStatus === 'modified'" type="warning" size="small" effect="plain">有未发布的修改</el-tag>
-        <el-tag v-else size="small" effect="plain">草稿 (未发布)</el-tag>
+        <el-tag :type="pageStatusTag.type" size="small" effect="plain">{{ pageStatusTag.label }}</el-tag>
       </div>
       <div class="toolbar-right">
         <el-button size="small" @click="showLinkDialog">
@@ -186,7 +184,7 @@
           </el-select>
         </el-form-item>
       </el-form>
-      <div v-if="pageStatus === 'published'" class="link-section">
+      <div v-if="['published', 'modified'].includes(pageStatus)" class="link-section">
         <div class="link-label"><span class="dot dot-green"></span> 正式环境链接</div>
         <div class="link-row">
           <span class="link-type">H5链接</span>
@@ -211,7 +209,7 @@
 </template>
 
 <script>
-import { ElMessage } from 'element-plus'
+import { ElMessage, ElMessageBox } from 'element-plus'
 import request from '@/utils/request'
 import { fetchLinkOptions } from '@/utils/linkOptions'
 
@@ -239,6 +237,11 @@ export default {
     currentTierData() {
       return this.configData[this.currentTier] || null
     },
+    pageStatusTag() {
+      if (this.isModified) return { type: 'warning', label: '有未保存修改' }
+      if (this.pageStatus === 'published') return { type: 'success', label: '正式线上环境' }
+      return { type: 'info', label: '草稿 (未发布)' }
+    },
     canPublish() {
       return !this.isModified && ['draft', 'modified'].includes(this.pageStatus)
     },
@@ -262,6 +265,13 @@ export default {
   created() {
     this.fetchData()
   },
+  beforeRouteLeave(to, from, next) {
+    if (!this.isModified) {
+      next()
+      return
+    }
+    this.confirmLeaveUnsaved().then(() => next()).catch(() => next(false))
+  },
   methods: {
     async fetchData() {
       const res = await request.get('/api/page-config/home')
@@ -288,11 +298,25 @@ export default {
       this.channelOptions = channelOptions
     },
     markModified() {
-      if (this.pageStatus === 'published') {
-        this.pageStatus = 'modified'
-      }
       this.isModified = true
     },
+    isBlank(value) {
+      return !(value || '').trim()
+    },
+    showSaveError(message) {
+      ElMessage.error(`保存失败:${message}`)
+      return false
+    },
+    getTierName(key, tier) {
+      return (tier && tier.tabName) || (this.tiers.find(item => item.key === key)?.label) || '当前客群'
+    },
+    confirmLeaveUnsaved() {
+      return ElMessageBox.confirm('当前有未保存修改,离开后修改内容将丢失,确定离开吗?', '未保存修改', {
+        type: 'warning',
+        confirmButtonText: '确定离开',
+        cancelButtonText: '继续编辑'
+      })
+    },
     onTierChange() {},
     triggerUpload(type, index) {
       if (type === 'header') {
@@ -318,7 +342,7 @@ export default {
       event.target.value = ''
     },
     addPrivilege() {
-      this.currentTierData.privileges.push({ imageUrl: '', title: '新权益', desc: '描述...' })
+      this.currentTierData.privileges.push({ imageUrl: '', title: '', desc: '' })
       this.markModified()
     },
     removePrivilege(index) {
@@ -331,21 +355,46 @@ export default {
       this.currentTierData.privileges.splice(to, 0, item)
       this.markModified()
     },
-    validatePrivilegeImages() {
+    validateRequiredConfig() {
       const entries = Object.entries(this.configData || {})
       for (const [key, tier] of entries) {
-        if (!tier || !tier.privilegeImageEnabled) continue
-        const missingIndex = (tier.privileges || []).findIndex(item => !item.imageUrl)
-        if (missingIndex >= 0) {
+        if (!tier) continue
+        const tierName = this.getTierName(key, tier)
+        if (this.isBlank(tier.tabName)) {
           this.currentTier = key
-          ElMessage.error(`${tier.tabName || '当前客群'} 第 ${missingIndex + 1} 个权益未上传图片,请补充后再保存。`)
-          return false
+          return this.showSaveError(`${tierName}-Tab名称配置-Tab名称未填写`)
+        }
+        if (!tier.header?.imageUrl) {
+          this.currentTier = key
+          return this.showSaveError(`${tierName}-头部配置-头图配图未上传`)
+        }
+        if (this.isBlank(tier.header?.mainTitle)) {
+          this.currentTier = key
+          return this.showSaveError(`${tierName}-头部配置-主标题未填写`)
+        }
+        if (this.isBlank(tier.buttons?.unlocked)) {
+          this.currentTier = key
+          return this.showSaveError(`${tierName}-按钮配置-已解锁按钮文案未填写`)
+        }
+        if (this.isBlank(tier.buttons?.locked)) {
+          this.currentTier = key
+          return this.showSaveError(`${tierName}-按钮配置-待解锁按钮文案未填写`)
+        }
+        for (const [index, item] of (tier.privileges || []).entries()) {
+          if (this.isBlank(item.title)) {
+            this.currentTier = key
+            return this.showSaveError(`${tierName}-权益列表配置-第 ${index + 1} 个权益-标题未填写`)
+          }
+          if (tier.privilegeImageEnabled && !item.imageUrl) {
+            this.currentTier = key
+            return this.showSaveError(`${tierName}-权益列表配置-第 ${index + 1} 个权益-图片未上传`)
+          }
         }
       }
       return true
     },
     async handleSave(type) {
-      if (!this.validatePrivilegeImages()) return
+      if (!this.validateRequiredConfig()) return
       if (type === 'publish' && this.isModified) {
         ElMessage.warning('请先保存草稿后再发布上线')
         return
@@ -363,6 +412,9 @@ export default {
       }
     },
     async showLinkDialog() {
+      if (this.isModified) {
+        ElMessage.warning('当前链接基于上一次保存内容,最新修改需保存后生效')
+      }
       await this.loadLinkOptions()
       this.linkForm = { campaignId: '', channelId: '' }
       this.linkDialogVisible = true

+ 82 - 24
工作空间/产研/权益/汇丰全行/产品原型/pc-demo/src/views/PageZoneConfig.vue

@@ -4,9 +4,7 @@
     <div class="toolbar">
       <div class="toolbar-left">
         <h2 class="toolbar-title">权益首页配置</h2>
-        <el-tag v-if="pageStatus === 'published'" type="success" size="small" effect="plain">正式线上环境</el-tag>
-        <el-tag v-else-if="pageStatus === 'modified'" type="warning" size="small" effect="plain">有未发布的修改</el-tag>
-        <el-tag v-else size="info" effect="plain">草稿 (未发布)</el-tag>
+        <el-tag :type="pageStatusTag.type" size="small" effect="plain">{{ pageStatusTag.label }}</el-tag>
       </div>
       <div class="toolbar-right">
         <el-button size="small" @click="showLinkDialog">
@@ -296,7 +294,7 @@
           </el-select>
         </el-form-item>
       </el-form>
-      <div v-if="pageStatus === 'published'" class="link-section">
+      <div v-if="['published', 'modified'].includes(pageStatus)" class="link-section">
         <div class="link-label"><span class="dot dot-green"></span> 正式环境链接</div>
         <div class="link-row">
           <span class="link-type">H5链接</span>
@@ -321,7 +319,7 @@
 </template>
 
 <script>
-import { ElMessage } from 'element-plus'
+import { ElMessage, ElMessageBox } from 'element-plus'
 import request from '@/utils/request'
 import { fetchLinkOptions } from '@/utils/linkOptions'
 
@@ -365,6 +363,11 @@ export default {
     }
   },
   computed: {
+    pageStatusTag() {
+      if (this.isModified) return { type: 'warning', label: '有未保存修改' }
+      if (this.pageStatus === 'published') return { type: 'success', label: '正式线上环境' }
+      return { type: 'info', label: '草稿 (未发布)' }
+    },
     linkQueryStr() {
       const params = []
       if (this.linkForm.campaignId) params.push(`CampaignId=${this.linkForm.campaignId}`)
@@ -388,6 +391,13 @@ export default {
   created() {
     this.fetchData()
   },
+  beforeRouteLeave(to, from, next) {
+    if (!this.isModified) {
+      next()
+      return
+    }
+    this.confirmLeaveUnsaved().then(() => next()).catch(() => next(false))
+  },
   methods: {
     defaultIdentityModule() {
       const base = {
@@ -493,27 +503,81 @@ export default {
         .trim()
       return !text
     },
+    isBlank(value) {
+      return !(value || '').trim()
+    },
+    showSaveError(message) {
+      ElMessage.error(`保存失败:${message}`)
+      return false
+    },
+    confirmLeaveUnsaved() {
+      return ElMessageBox.confirm('当前有未保存修改,离开后修改内容将丢失,确定离开吗?', '未保存修改', {
+        type: 'warning',
+        confirmButtonText: '确定离开',
+        cancelButtonText: '继续编辑'
+      })
+    },
+    getJumpConfigError(item) {
+      if (this.getPartnerFixedLink(item.jumpType)) return ''
+      if (item.jumpType === 'h5') {
+        return this.isBlank(item.linkH5) ? '配置链接未填写' : ''
+      }
+      if (item.jumpType === 'miniProgram') {
+        if (this.isBlank(item.wechatAppId)) return 'AppID未填写'
+        if (this.isBlank(item.wechatPath)) return '页面路径未填写'
+        return ''
+      }
+      return '跳转方式未选择'
+    },
     validateIdentityModule() {
       const list = this.identityMscList || []
       for (const item of list) {
         const data = this.configData.identityModule?.[item.key]
         if (!data) continue
         if (!data.bgImage) {
-          ElMessage.error(`${item.label} 的客群背景图未上传!`)
-          return false
+          return this.showSaveError(`客户身份模块配置-${item.label}-客群背景图未上传`)
         }
         if (item.key === 'privateBank' || !data.showAvgBalance) continue
-        if (!(data.avgBalancePopupTitle || '').trim()) {
-          ElMessage.error(`${item.label} 的账户余额弹窗提示-弹窗顶部名称未填写!`)
-          return false
+        if (this.isBlank(data.avgBalanceName)) {
+          return this.showSaveError(`客户身份模块配置-${item.label}-账户月内日均余额名称未填写`)
+        }
+        if (this.isBlank(data.avgBalancePopupTitle)) {
+          return this.showSaveError(`客户身份模块配置-${item.label}-账户余额弹窗提示-弹窗顶部名称未填写`)
         }
         if (this.isRichTextEmpty(data.avgBalanceRule)) {
-          ElMessage.error(`${item.label} 的账户余额弹窗提示-账户余额说明未填写!`)
-          return false
+          return this.showSaveError(`客户身份模块配置-${item.label}-账户余额弹窗提示-账户余额说明未填写`)
         }
       }
       return true
     },
+    validatePageConfig() {
+      if (!this.validateIdentityModule()) return false
+      this.normalizeCarouselItems(this.configData.adCarousel || [])
+      for (const [index, item] of (this.configData.adCarousel || []).entries()) {
+        if (this.isBlank(item.title)) {
+          return this.showSaveError(`活动广告轮播图-第 ${index + 1} 个-主标题未填写`)
+        }
+        const jumpError = this.getJumpConfigError(item)
+        if (jumpError) {
+          return this.showSaveError(`活动广告轮播图-第 ${index + 1} 个-跳转链接配置-${jumpError}`)
+        }
+      }
+      for (const [index, item] of (this.configData.exchangeConfig?.items || []).entries()) {
+        if (this.isBlank(item.title)) {
+          return this.showSaveError(`常用兑换配置-第 ${index + 1} 个-展示文字未填写`)
+        }
+        if (this.isBlank(item.link)) {
+          return this.showSaveError(`常用兑换配置-第 ${index + 1} 个-跳转链接未填写`)
+        }
+      }
+      if (this.isBlank(this.configData.ruleModal?.title)) {
+        return this.showSaveError('积分规则弹窗配置-弹窗顶部名称未填写')
+      }
+      if (this.isRichTextEmpty(this.configData.ruleModal?.content)) {
+        return this.showSaveError('积分规则弹窗配置-规则内容未填写')
+      }
+      return true
+    },
     async fetchData() {
       const res = await request.get('/api/page-config/zone')
       const cfg = res.data.config || {}
@@ -548,9 +612,6 @@ export default {
     },
     markModified() {
       if (!this.dataLoaded) return
-      if (this.pageStatus === 'published') {
-        this.pageStatus = 'modified'
-      }
       this.isModified = true
     },
     triggerUpload(type, index) {
@@ -575,7 +636,7 @@ export default {
     addCarousel() {
       this.configData.adCarousel.push({
         imageUrl: '', ...this.createDefaultJumpConfig(),
-        title: '新广告', subtitle: '', tags: []
+        title: '', subtitle: '', tags: []
       })
       this.markModified()
     },
@@ -584,7 +645,7 @@ export default {
       this.markModified()
     },
     addExchangeItem() {
-      this.configData.exchangeConfig.items.push({ imageUrl: '', title: '新兑换项', link: '' })
+      this.configData.exchangeConfig.items.push({ imageUrl: '', title: '', link: '' })
       this.markModified()
     },
     removeExchangeItem(index) {
@@ -599,13 +660,7 @@ export default {
       this.markModified()
     },
     async handleSave(type) {
-      if (!this.validateIdentityModule()) return
-      this.normalizeCarouselItems(this.configData.adCarousel || [])
-      const invalidCarouselIndex = (this.configData.adCarousel || []).findIndex(item => !this.validateJumpConfig(item))
-      if (invalidCarouselIndex > -1) {
-        ElMessage.error(`保存失败:第 ${invalidCarouselIndex + 1} 个活动广告轮播图跳转链接配置未填写完整!`)
-        return
-      }
+      if (!this.validatePageConfig()) return
       if (type === 'publish' && this.isModified) {
         ElMessage.warning('请先保存草稿后再发布上线')
         return
@@ -617,6 +672,9 @@ export default {
       ElMessage.success(type === 'publish' ? '已发布权益首页配置!' : '已保存权益首页配置草稿!')
     },
     async showLinkDialog() {
+      if (this.isModified) {
+        ElMessage.warning('当前链接基于上一次保存内容,最新修改需保存后生效')
+      }
       await this.loadLinkOptions()
       this.linkForm = { campaignId: '', channelId: '' }
       this.linkDialogVisible = true