Przeglądaj źródła

新增页面管理(权益介绍页、权益首页、组装页)和渠道管理(Channel管理、Campaign管理)模块

整合外部HTML原型为Vue组件,包含完整的Mock API、路由配置和侧边栏菜单

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Fade 1 miesiąc temu
rodzic
commit
82d1316346

+ 21 - 3
pc/src/components/Layout.vue

@@ -21,9 +21,18 @@
           </template>
           <el-submenu index="customization-hsbc">
             <template slot="title">汇丰银行</template>
-            <el-menu-item index="/customization/hsbc/pages">
-              页面管理
-            </el-menu-item>
+            <el-submenu index="customization-hsbc-pages">
+              <template slot="title">页面管理</template>
+              <el-menu-item index="/customization/hsbc/pages/home">
+                权益介绍页
+              </el-menu-item>
+              <el-menu-item index="/customization/hsbc/pages/zone">
+                权益首页
+              </el-menu-item>
+              <el-menu-item index="/customization/hsbc/pages/assembly">
+                组装页
+              </el-menu-item>
+            </el-submenu>
             <el-submenu index="customization-hsbc-equity">
               <template slot="title">权益管理</template>
               <el-menu-item index="/customization/hsbc/equity-products">
@@ -45,6 +54,15 @@
                 发送记录查询
               </el-menu-item>
             </el-submenu>
+            <el-submenu index="customization-hsbc-channel">
+              <template slot="title">渠道管理</template>
+              <el-menu-item index="/customization/hsbc/channel">
+                Channel管理
+              </el-menu-item>
+              <el-menu-item index="/customization/hsbc/campaign">
+                Campaign管理
+              </el-menu-item>
+            </el-submenu>
             <el-submenu index="customization-hsbc-report">
               <template slot="title">报表中心</template>
               <el-menu-item index="/customization/hsbc/points-detail">

+ 308 - 3
pc/src/mock/index.js

@@ -1,7 +1,312 @@
 import Mock from 'mockjs'
 
-// 当前无通用接口 Mock;权益商品相关页面使用组件本地数据。
-// 如需新增接口,请按 `{ code: 200, data, msg }` 的格式注册:
-// Mock.mock(url, method, handler)
+// =============================================
+// 页面管理 - 权益介绍页配置
+// =============================================
+const homeConfigData = {
+  premier: {
+    tabName: '卓越理财',
+    buttons: { unlocked: '进入权益专区', locked: '待解锁' },
+    header: {
+      imageUrl: 'https://images.unsplash.com/photo-1518893494013-481c1d8ed3fd?w=800',
+      mainTitle: '卓越理财',
+      subTitle: '为您和您的家人提供尊崇专属礼遇和权益'
+    },
+    privileges: [
+      { imageUrl: '', title: '国际教育', desc: '享专属国际化学校优惠及背景提升项目' },
+      { imageUrl: '', title: '酒店礼遇', desc: '高端酒店及商场会籍匹配' }
+    ]
+  },
+  elite: {
+    tabName: '卓越理财·尊尚',
+    buttons: { unlocked: '进入权益专区', locked: '待解锁' },
+    header: {
+      imageUrl: 'https://images.unsplash.com/photo-1518893494013-481c1d8ed3fd?w=800',
+      mainTitle: '卓越理财·尊尚',
+      subTitle: '为您和您的家人提供尊崇专属礼遇和权益'
+    },
+    privileges: [
+      { imageUrl: '', title: '国际教育', desc: '享专属国际化学校优惠及背景提升项目' },
+      { imageUrl: '', title: '酒店礼遇', desc: '高端酒店及商场会籍匹配' }
+    ]
+  },
+  global: {
+    tabName: '环球私人银行',
+    buttons: { unlocked: '进入权益专区', locked: '待解锁' },
+    header: {
+      imageUrl: 'https://images.unsplash.com/photo-1518893494013-481c1d8ed3fd?w=800',
+      mainTitle: '环球私人银行',
+      subTitle: '为您和您的家人提供尊崇专属礼遇和权益'
+    },
+    privileges: [
+      { imageUrl: '', title: '国际教育', desc: '享专属国际化学校优惠及背景提升项目' },
+      { imageUrl: '', title: '酒店礼遇', desc: '高端酒店及商场会籍匹配' }
+    ]
+  }
+}
+
+let homeStatus = 'published'
+
+Mock.mock(/\/api\/page-config\/home/, 'get', () => {
+  return { code: 200, data: { config: homeConfigData, status: homeStatus }, msg: 'ok' }
+})
+
+Mock.mock(/\/api\/page-config\/home/, 'put', (options) => {
+  const body = JSON.parse(options.body)
+  Object.assign(homeConfigData, body.config || {})
+  homeStatus = body.status || homeStatus
+  return { code: 200, data: null, msg: '保存成功' }
+})
+
+// =============================================
+// 页面管理 - 权益首页配置
+// =============================================
+const zoneConfigData = {
+  adCarousel: [{
+    imageUrl: 'https://images.unsplash.com/photo-1556742049-0cfed4f6a45d?w=800',
+    linkH5: '', linkApp: '', wechatAppId: '', wechatPath: '',
+    title: '汇丰卓越理财新户达标礼',
+    subtitle: '报名参加汇丰活动,体验更多精彩礼遇',
+    tags: ['t1', 't2']
+  }],
+  exchangeConfig: {
+    items: [
+      { imageUrl: 'https://images.unsplash.com/photo-1436491865332-7a61a109cc05?w=200', title: '航空里程', link: '/flight' },
+      { imageUrl: 'https://images.unsplash.com/photo-1518893494013-481c1d8ed3fd?w=200', title: '贵宾厅', link: '/lounge' }
+    ]
+  },
+  ruleModal: {
+    title: '积分规则',
+    content: '<p>本积分使用规则为汇丰银行(中国)有限公司...</p>'
+  }
+}
+
+let zoneStatus = 'published'
+
+const availableTags = Array.from({ length: 8 }, (_, i) => ({ id: `t${i + 1}`, name: `人群标签${i + 1}` }))
+
+Mock.mock(/\/api\/page-config\/zone/, 'get', () => {
+  return { code: 200, data: { config: zoneConfigData, status: zoneStatus, availableTags }, msg: 'ok' }
+})
+
+Mock.mock(/\/api\/page-config\/zone/, 'put', (options) => {
+  const body = JSON.parse(options.body)
+  Object.assign(zoneConfigData, body.config || {})
+  zoneStatus = body.status || zoneStatus
+  return { code: 200, data: null, msg: '保存成功' }
+})
+
+// =============================================
+// 页面管理 - 组装页
+// =============================================
+let assemblyIdCounter = 3000
+
+const assemblyList = [
+  {
+    id: 'A7538', name: '双十一迎新专属活动页', status: 'published',
+    createTime: '2025-10-01 10:00:00', updateTime: '2025-10-15 14:30:00',
+    blocks: [
+      { type: 'image', id: 'b_init1', data: { imageUrl: 'https://images.unsplash.com/photo-1557683316-973673baf926?w=800' } },
+      { type: 'privilege', id: 'b_init2', data: { title: '专属权益', items: [{ id: '1', title: '机场贵宾厅服务', desc: '全年免费使用6次指定机场贵宾厅' }, { id: '2', title: '境内礼宾车', desc: '免费2次接送' }] } },
+      { type: 'richtext', id: 'b_init3', data: { content: '<p>欢迎参与双十一专属福利活动...</p>' } },
+      { type: 'buttonGroup', id: 'b_init4', data: { buttons: [{ text: '立即参与', link: '/join', style: 'primary', actionType: 'link' }] } }
+    ]
+  },
+  {
+    id: 'A2091', name: '私行高净值沙龙引导页', status: 'draft',
+    createTime: '2025-11-20 09:15:00', updateTime: '2025-11-20 09:15:00',
+    blocks: [
+      { type: 'image', id: 'b_init5', data: { imageUrl: 'https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?w=800' } },
+      { type: 'privilege', id: 'b_init6', data: { title: '尊享体验', items: [{ id: '1', title: '高尔夫权益', desc: 'VIP高尔夫俱乐部免会籍预约' }] } }
+    ]
+  }
+]
+
+Mock.mock(/\/api\/assembly\/list/, 'get', (options) => {
+  const url = new URL(options.url, 'http://localhost')
+  const id = url.searchParams.get('id') || ''
+  const name = url.searchParams.get('name') || ''
+  let filtered = assemblyList
+  if (id) filtered = filtered.filter(a => a.id.includes(id))
+  if (name) filtered = filtered.filter(a => a.name.includes(name))
+  return { code: 200, data: filtered, msg: 'ok' }
+})
+
+Mock.mock(/\/api\/assembly\/detail/, 'get', (options) => {
+  const url = new URL(options.url, 'http://localhost')
+  const id = url.searchParams.get('id')
+  const item = assemblyList.find(a => a.id === id)
+  return item
+    ? { code: 200, data: item, msg: 'ok' }
+    : { code: 404, data: null, msg: '未找到' }
+})
+
+Mock.mock(/\/api\/assembly\/save/, 'post', (options) => {
+  const body = JSON.parse(options.body)
+  const now = new Date().toLocaleString('zh-CN', { hour12: false })
+  if (body.id) {
+    const idx = assemblyList.findIndex(a => a.id === body.id)
+    if (idx >= 0) {
+      assemblyList[idx] = { ...assemblyList[idx], ...body, updateTime: now }
+    }
+  } else {
+    body.id = 'A' + (++assemblyIdCounter)
+    body.createTime = now
+    body.updateTime = now
+    assemblyList.unshift(body)
+  }
+  return { code: 200, data: { id: body.id }, msg: '保存成功' }
+})
+
+Mock.mock(/\/api\/assembly\/delete/, 'post', (options) => {
+  const body = JSON.parse(options.body)
+  const idx = assemblyList.findIndex(a => a.id === body.id)
+  if (idx >= 0) assemblyList.splice(idx, 1)
+  return { code: 200, data: null, msg: '删除成功' }
+})
+
+// =============================================
+// 渠道管理 - Channel
+// =============================================
+const channelDict = {
+  'Human-assisted': {
+    '汇丰中国客户服务号': ['汇丰中国客户服务号'],
+    'MGM': ['MGM - Mortgage', 'MGM - Premier'],
+    'CCSS': ['CCSS SMS'],
+    'Referral': ['GPB Referral', 'HK referral', 'Non WPB staff Referral', 'Pinnacle Referral', 'RBB Refferal'],
+    'New staff': ['New staff'],
+    'IBC': ['IBC referral staff code'],
+    'Group Acct': ['HTC', 'NIVIDA']
+  },
+  'Unassisted': {
+    '汇丰中国APP': ['Mobile X'],
+    '汇丰中国官网': ['PWS site'],
+    '汇丰中国小程序': ['汇丰中国小程序'],
+    '汇丰中国订阅号': ['汇丰中国订阅号', '汇丰中国订阅号推文', '推文尾部入口'],
+    'Chatbot': ['Chatbot'],
+    'SMS': ['SMS'],
+    'Paid Media': ['汇丰中国订阅号推文', 'others'],
+    'Others': ['others', 'River APP']
+  }
+}
+
+let channelIdCounter = 9
+const channelRows = [
+  { id: 'CH0001', type: 'Human-assisted', category: '汇丰中国客户服务号', name: '汇丰中国客户服务号', subName: '', owner: '', createTime: '2025-11-20 09:15:00', updateTime: '2025-11-20 09:15:00' },
+  { id: 'CH0002', type: 'Human-assisted', category: 'MGM', name: 'MGM - Mortgage', subName: '', owner: '', createTime: '2025-11-20 09:15:00', updateTime: '2025-11-20 09:15:00' },
+  { id: 'CH0003', type: 'Human-assisted', category: 'MGM', name: 'MGM - Premier', subName: '双十一特惠', owner: '张经理', createTime: '2025-11-20 09:15:00', updateTime: '2025-11-21 14:30:00' },
+  { id: 'CH0004', type: 'Human-assisted', category: 'CCSS', name: 'CCSS SMS', subName: '', owner: '', createTime: '2025-11-20 09:15:00', updateTime: '2025-11-20 09:15:00' },
+  { id: 'CH0005', type: 'Human-assisted', category: 'Referral', name: 'GPB Referral', subName: '', owner: '', createTime: '2025-11-20 09:15:00', updateTime: '2025-11-20 09:15:00' },
+  { id: 'CH0006', type: 'Unassisted', category: '汇丰中国APP', name: 'Mobile X', subName: '', owner: '', createTime: '2025-11-20 09:15:00', updateTime: '2025-11-20 09:15:00' },
+  { id: 'CH0007', type: 'Unassisted', category: '汇丰中国官网', name: 'PWS site', subName: '私行沙龙引导', owner: '', createTime: '2025-11-20 09:15:00', updateTime: '2025-11-20 09:15:00' },
+  { id: 'CH0008', type: 'Unassisted', category: '汇丰中国订阅号', name: '汇丰中国订阅号', subName: '', owner: '', createTime: '2025-11-20 09:15:00', updateTime: '2025-11-20 09:15:00' },
+  { id: 'CH0009', type: 'Unassisted', category: '汇丰中国订阅号', name: '推文尾部入口', subName: '', owner: '', createTime: '2025-11-20 09:15:00', updateTime: '2025-11-20 09:15:00' }
+]
+
+Mock.mock(/\/api\/channel\/dict/, 'get', () => {
+  return { code: 200, data: channelDict, msg: 'ok' }
+})
+
+Mock.mock(/\/api\/channel\/dict\/save/, 'post', (options) => {
+  const body = JSON.parse(options.body)
+  const { target, type, category, oldValue, newValue, mode } = body
+  if (target === 'type') {
+    if (mode === 'add') { channelDict[newValue] = {} }
+    else { channelDict[newValue] = channelDict[oldValue]; delete channelDict[oldValue]; channelRows.forEach(r => { if (r.type === oldValue) r.type = newValue }) }
+  } else if (target === 'category') {
+    if (mode === 'add') { channelDict[type][newValue] = [] }
+    else { channelDict[type][newValue] = channelDict[type][oldValue]; delete channelDict[type][oldValue]; channelRows.forEach(r => { if (r.type === type && r.category === oldValue) r.category = newValue }) }
+  } else if (target === 'name') {
+    const list = channelDict[type][category]
+    if (mode === 'add') { list.push(newValue) }
+    else { const idx = list.indexOf(oldValue); if (idx >= 0) list[idx] = newValue; channelRows.forEach(r => { if (r.type === type && r.category === category && r.name === oldValue) r.name = newValue }) }
+  }
+  return { code: 200, data: channelDict, msg: '保存成功' }
+})
+
+Mock.mock(/\/api\/channel\/list/, 'get', (options) => {
+  const url = new URL(options.url, 'http://localhost')
+  const sId = (url.searchParams.get('id') || '').toLowerCase()
+  const sType = url.searchParams.get('type') || ''
+  const sCategory = (url.searchParams.get('category') || '').toLowerCase()
+  const sName = (url.searchParams.get('name') || '').toLowerCase()
+  const sSubName = (url.searchParams.get('subName') || '').toLowerCase()
+  const sOwner = (url.searchParams.get('owner') || '').toLowerCase()
+  let filtered = channelRows
+  if (sId) filtered = filtered.filter(c => c.id.toLowerCase().includes(sId))
+  if (sType) filtered = filtered.filter(c => c.type === sType)
+  if (sCategory) filtered = filtered.filter(c => c.category.toLowerCase().includes(sCategory))
+  if (sName) filtered = filtered.filter(c => c.name.toLowerCase().includes(sName))
+  if (sSubName) filtered = filtered.filter(c => c.subName.toLowerCase().includes(sSubName))
+  if (sOwner) filtered = filtered.filter(c => c.owner.toLowerCase().includes(sOwner))
+  return { code: 200, data: filtered, msg: 'ok' }
+})
+
+Mock.mock(/\/api\/channel\/save/, 'post', (options) => {
+  const body = JSON.parse(options.body)
+  const now = new Date().toLocaleString('zh-CN', { hour12: false })
+  if (body.id) {
+    const idx = channelRows.findIndex(c => c.id === body.id)
+    if (idx >= 0) channelRows[idx] = { ...channelRows[idx], ...body, updateTime: now }
+  } else {
+    body.id = 'CH' + String(++channelIdCounter).padStart(4, '0')
+    body.createTime = now
+    body.updateTime = now
+    channelRows.unshift(body)
+  }
+  return { code: 200, data: { id: body.id }, msg: '保存成功' }
+})
+
+Mock.mock(/\/api\/channel\/delete/, 'post', (options) => {
+  const body = JSON.parse(options.body)
+  const idx = channelRows.findIndex(c => c.id === body.id)
+  if (idx >= 0) channelRows.splice(idx, 1)
+  return { code: 200, data: null, msg: '删除成功' }
+})
+
+// =============================================
+// 渠道管理 - Campaign
+// =============================================
+let campaignIdCounter = 9002
+const campaignRows = [
+  { id: 'CP9001', name: 'Q3 大客户回馈', owner: 'Market Team', staff: '王专员', createTime: '2025-10-01 10:00:00', updateTime: '2025-10-15 14:30:00' },
+  { id: 'CP9002', name: '新春开户活动', owner: 'Retail Team', staff: '李专员', createTime: '2025-11-20 09:15:00', updateTime: '2025-11-20 09:15:00' }
+]
+
+Mock.mock(/\/api\/campaign\/list/, 'get', (options) => {
+  const url = new URL(options.url, 'http://localhost')
+  const sId = (url.searchParams.get('id') || '').toLowerCase()
+  const sName = (url.searchParams.get('name') || '').toLowerCase()
+  const sOwner = (url.searchParams.get('owner') || '').toLowerCase()
+  const sStaff = (url.searchParams.get('staff') || '').toLowerCase()
+  let filtered = campaignRows
+  if (sId) filtered = filtered.filter(c => c.id.toLowerCase().includes(sId))
+  if (sName) filtered = filtered.filter(c => c.name.toLowerCase().includes(sName))
+  if (sOwner) filtered = filtered.filter(c => c.owner.toLowerCase().includes(sOwner))
+  if (sStaff) filtered = filtered.filter(c => c.staff.toLowerCase().includes(sStaff))
+  return { code: 200, data: filtered, msg: 'ok' }
+})
+
+Mock.mock(/\/api\/campaign\/save/, 'post', (options) => {
+  const body = JSON.parse(options.body)
+  const now = new Date().toLocaleString('zh-CN', { hour12: false })
+  if (body.id) {
+    const idx = campaignRows.findIndex(c => c.id === body.id)
+    if (idx >= 0) campaignRows[idx] = { ...campaignRows[idx], ...body, updateTime: now }
+  } else {
+    body.id = 'CP' + (++campaignIdCounter)
+    body.createTime = now
+    body.updateTime = now
+    campaignRows.unshift(body)
+  }
+  return { code: 200, data: { id: body.id }, msg: '保存成功' }
+})
+
+Mock.mock(/\/api\/campaign\/delete/, 'post', (options) => {
+  const body = JSON.parse(options.body)
+  const idx = campaignRows.findIndex(c => c.id === body.id)
+  if (idx >= 0) campaignRows.splice(idx, 1)
+  return { code: 200, data: null, msg: '删除成功' }
+})
 
 export default Mock

+ 32 - 3
pc/src/router/index.js

@@ -12,9 +12,26 @@ const routes = [
     children: [
       {
         path: 'customization/hsbc/pages',
-        name: 'PageManagement',
-        component: () => import('@/views/PageManagement.vue'),
-        meta: { title: '页面管理', icon: 'el-icon-document' }
+        redirect: 'customization/hsbc/pages/home',
+        meta: { title: '页面管理' }
+      },
+      {
+        path: 'customization/hsbc/pages/home',
+        name: 'PageHomeConfig',
+        component: () => import('@/views/PageHomeConfig.vue'),
+        meta: { title: '权益介绍页' }
+      },
+      {
+        path: 'customization/hsbc/pages/zone',
+        name: 'PageZoneConfig',
+        component: () => import('@/views/PageZoneConfig.vue'),
+        meta: { title: '权益首页' }
+      },
+      {
+        path: 'customization/hsbc/pages/assembly',
+        name: 'PageAssembly',
+        component: () => import('@/views/PageAssembly.vue'),
+        meta: { title: '组装页' }
       },
       {
         path: 'customization/hsbc/equity-products',
@@ -117,6 +134,18 @@ const routes = [
         name: 'PointsDetail',
         component: () => import('@/views/PointsDetail.vue'),
         meta: { title: '积分明细查询', icon: 'el-icon-s-order' }
+      },
+      {
+        path: 'customization/hsbc/channel',
+        name: 'ChannelManagement',
+        component: () => import('@/views/ChannelManagement.vue'),
+        meta: { title: 'Channel管理' }
+      },
+      {
+        path: 'customization/hsbc/campaign',
+        name: 'CampaignManagement',
+        component: () => import('@/views/CampaignManagement.vue'),
+        meta: { title: 'Campaign管理' }
       }
     ]
   },

+ 144 - 0
pc/src/views/CampaignManagement.vue

@@ -0,0 +1,144 @@
+<template>
+  <div class="campaign-management">
+    <el-card>
+      <div slot="header" class="search-section">
+        <div class="search-row">
+          <el-form :model="searchForm" label-width="130px" inline>
+            <el-form-item label="Campaign ID:">
+              <el-input v-model="searchForm.id" placeholder="支持精确查询" clearable style="width:150px" />
+            </el-form-item>
+            <el-form-item label="Campaign 名称:">
+              <el-input v-model="searchForm.name" placeholder="关键字" clearable style="width:150px" />
+            </el-form-item>
+            <el-form-item label="Campaign 负责人:">
+              <el-input v-model="searchForm.owner" placeholder="关键字" clearable style="width:150px" />
+            </el-form-item>
+            <el-form-item label="跟进员工:">
+              <el-input v-model="searchForm.staff" placeholder="员工姓名" clearable style="width:150px" />
+            </el-form-item>
+            <div class="button-group">
+              <el-button type="primary" @click="handleSearch">查询</el-button>
+              <el-button @click="handleReset">重置</el-button>
+            </div>
+          </el-form>
+        </div>
+        <div class="action-row">
+          <el-button type="primary" icon="el-icon-plus" @click="openModal('create')">新建 Campaign</el-button>
+        </div>
+      </div>
+
+      <el-table :data="tableData" stripe border style="width:100%">
+        <el-table-column label="操作" width="200" fixed="left">
+          <template slot-scope="scope">
+            <el-button type="text" size="small" @click="openModal('edit', scope.row)">编辑</el-button>
+            <el-button type="text" size="small" @click="openModal('view', scope.row)">查看</el-button>
+            <el-button type="text" size="small" style="color:#F56C6C" @click="handleDelete(scope.row)">删除</el-button>
+          </template>
+        </el-table-column>
+        <el-table-column prop="id" label="Campaign ID" width="120" align="center" />
+        <el-table-column prop="name" label="Campaign 名称" width="190" />
+        <el-table-column prop="owner" label="Campaign 负责人" width="160" align="center" />
+        <el-table-column label="跟进员工" width="120" align="center">
+          <template slot-scope="scope">
+            <span :class="{ 'empty-text': !scope.row.staff }">{{ scope.row.staff || '-' }}</span>
+          </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>
+    </el-card>
+
+    <!-- 新建/编辑/查看弹窗 -->
+    <el-dialog :title="modalTitle" :visible.sync="modalVisible" width="520px" :close-on-click-modal="false">
+      <el-form label-width="130px">
+        <el-form-item v-if="modalMode !== 'create'" label="Campaign ID">
+          <el-input :value="form.id" disabled />
+        </el-form-item>
+        <el-form-item label="Campaign 名称" required>
+          <el-input v-model="form.name" placeholder="请输入Campaign 名称" :disabled="isViewMode" />
+        </el-form-item>
+        <el-form-item label="Campaign 负责人" required>
+          <el-input v-model="form.owner" placeholder="请输入Campaign 负责人" :disabled="isViewMode" />
+        </el-form-item>
+        <el-form-item label="跟进员工">
+          <el-input v-model="form.staff" placeholder="请输入跟进员工姓名" :disabled="isViewMode" />
+        </el-form-item>
+      </el-form>
+      <span v-if="!isViewMode" slot="footer">
+        <el-button @click="modalVisible = false">取消</el-button>
+        <el-button type="primary" @click="submitModal">确定</el-button>
+      </span>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import request from '@/utils/request'
+
+export default {
+  name: 'CampaignManagement',
+  data() {
+    return {
+      searchForm: { id: '', name: '', owner: '', staff: '' },
+      tableData: [],
+      modalVisible: false,
+      modalMode: 'create',
+      form: { id: '', name: '', owner: '', staff: '' }
+    }
+  },
+  computed: {
+    modalTitle() {
+      return this.modalMode === 'create' ? '新建 Campaign' : this.modalMode === 'edit' ? '编辑 Campaign' : '查看 Campaign'
+    },
+    isViewMode() {
+      return this.modalMode === 'view'
+    }
+  },
+  created() {
+    this.fetchList()
+  },
+  methods: {
+    async fetchList() {
+      const res = await request.get('/api/campaign/list', { params: this.searchForm })
+      this.tableData = res.data
+    },
+    handleSearch() { this.fetchList() },
+    handleReset() {
+      this.searchForm = { id: '', name: '', owner: '', staff: '' }
+      this.fetchList()
+    },
+    async handleDelete(row) {
+      await this.$confirm('您确定要删除此条数据吗?删除后将无法恢复。', '确认删除', { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' })
+      await request.post('/api/campaign/delete', { id: row.id })
+      this.$message.success('删除成功')
+      this.fetchList()
+    },
+    openModal(mode, row) {
+      this.modalMode = mode
+      if (mode === 'create') {
+        this.form = { id: '', name: '', owner: '', staff: '' }
+      } else {
+        this.form = { ...row }
+      }
+      this.modalVisible = true
+    },
+    async submitModal() {
+      if (!this.form.name || !this.form.owner) {
+        this.$message.warning('请填写Campaign 名称和负责人')
+        return
+      }
+      await request.post('/api/campaign/save', this.form)
+      this.$message.success(this.modalMode === 'create' ? '新建成功' : '保存成功')
+      this.modalVisible = false
+      this.fetchList()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.search-section .search-row { margin-bottom: 0; }
+.search-section .action-row { margin-top: 15px; }
+.button-group { display: inline-block; margin-left: 20px; }
+.empty-text { color: #c0c4cc; }
+</style>

+ 293 - 0
pc/src/views/ChannelManagement.vue

@@ -0,0 +1,293 @@
+<template>
+  <div class="channel-management">
+    <el-card>
+      <div slot="header" class="search-section">
+        <el-form :model="searchForm" label-width="120px" inline class="search-form">
+          <div class="search-row">
+            <el-form-item label="Channel ID:">
+              <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-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-form-item>
+            <el-form-item label="Channel 名称:">
+              <el-input v-model="searchForm.name" placeholder="关键字" clearable style="width:160px" />
+            </el-form-item>
+          </div>
+          <div class="search-row">
+            <el-form-item label="Channel 子名称:">
+              <el-input v-model="searchForm.subName" placeholder="关键字" clearable style="width:160px" />
+            </el-form-item>
+            <el-form-item label="跟进员工:">
+              <el-input v-model="searchForm.owner" placeholder="员工姓名" clearable style="width:160px" />
+            </el-form-item>
+            <el-form-item label=" ">
+              <el-button type="primary" @click="handleSearch">查询</el-button>
+              <el-button @click="handleReset">重置</el-button>
+            </el-form-item>
+          </div>
+        </el-form>
+        <div class="action-row">
+          <el-button type="primary" icon="el-icon-plus" @click="openModal('create')">新建 Channel</el-button>
+        </div>
+      </div>
+
+      <el-table :data="tableData" stripe border style="width:100%">
+        <el-table-column label="操作" width="200" fixed="left">
+          <template slot-scope="scope">
+            <el-button type="text" size="small" @click="openModal('edit', scope.row)">编辑</el-button>
+            <el-button type="text" size="small" @click="openModal('view', scope.row)">查看</el-button>
+            <el-button type="text" size="small" style="color:#F56C6C" @click="handleDelete(scope.row)">删除</el-button>
+          </template>
+        </el-table-column>
+        <el-table-column prop="id" label="Channel ID" width="120" align="center" />
+        <el-table-column prop="type" label="类型" width="150" align="center" />
+        <el-table-column prop="category" label="类别" width="190" />
+        <el-table-column prop="name" label="名称" width="190" />
+        <el-table-column label="子名称" width="160">
+          <template slot-scope="scope">
+            <span :class="{ 'empty-text': !scope.row.subName }">{{ scope.row.subName || '-' }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="跟进员工" width="120" align="center">
+          <template slot-scope="scope">
+            <span :class="{ 'empty-text': !scope.row.owner }">{{ scope.row.owner || '-' }}</span>
+          </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>
+    </el-card>
+
+    <!-- 新建/编辑/查看弹窗 -->
+    <el-dialog :title="modalTitle" :visible.sync="modalVisible" width="560px" :close-on-click-modal="false">
+      <el-form label-width="130px">
+        <el-form-item v-if="modalMode !== 'create'" label="Channel ID">
+          <el-input :value="form.id" disabled />
+        </el-form-item>
+
+        <!-- Channel 类型 -->
+        <el-form-item label="Channel 类型" required>
+          <div class="inline-field">
+            <el-select v-model="form.type" placeholder="请选择类型" :disabled="isViewMode" @change="onTypeChange" style="flex:1">
+              <el-option v-for="t in typeOptions" :key="t" :label="t" :value="t" />
+            </el-select>
+            <template v-if="!isViewMode">
+              <el-button size="small" @click="openPanel('type', 'add')">新增</el-button>
+              <el-button size="small" @click="openPanel('type', 'edit')">编辑</el-button>
+            </template>
+          </div>
+          <div v-if="activePanel === 'type'" class="quick-panel">
+            <el-input v-model="panelInput" :placeholder="panelMode === 'edit' ? '请输入新的内容' : '请输入新的类型'" size="small" />
+            <el-button size="small" type="primary" @click="savePanel('type')">保存</el-button>
+            <el-button size="small" @click="activePanel = ''">取消</el-button>
+          </div>
+        </el-form-item>
+
+        <!-- Channel 类别 -->
+        <el-form-item label="Channel 类别" required>
+          <div class="inline-field">
+            <el-select v-model="form.category" placeholder="请先选择类型" :disabled="isViewMode || !form.type" @change="onCategoryChange" style="flex:1">
+              <el-option v-for="c in categoryOptions" :key="c" :label="c" :value="c" />
+            </el-select>
+            <template v-if="!isViewMode">
+              <el-button size="small" @click="openPanel('category', 'add')">新增</el-button>
+              <el-button size="small" @click="openPanel('category', 'edit')">编辑</el-button>
+            </template>
+          </div>
+          <div v-if="activePanel === 'category'" class="quick-panel">
+            <el-input v-model="panelInput" :placeholder="panelMode === 'edit' ? '请输入新的内容' : '请输入新的类别'" size="small" />
+            <el-button size="small" type="primary" @click="savePanel('category')">保存</el-button>
+            <el-button size="small" @click="activePanel = ''">取消</el-button>
+          </div>
+        </el-form-item>
+
+        <!-- Channel 名称 -->
+        <el-form-item label="Channel 名称" required>
+          <div class="inline-field">
+            <el-select v-model="form.name" placeholder="请先选择类别" :disabled="isViewMode || !form.category" style="flex:1">
+              <el-option v-for="n in nameOptions" :key="n" :label="n" :value="n" />
+            </el-select>
+            <template v-if="!isViewMode">
+              <el-button size="small" @click="openPanel('name', 'add')">新增</el-button>
+              <el-button size="small" @click="openPanel('name', 'edit')">编辑</el-button>
+            </template>
+          </div>
+          <div v-if="activePanel === 'name'" class="quick-panel">
+            <el-input v-model="panelInput" :placeholder="panelMode === 'edit' ? '请输入新的内容' : '请输入新的名称'" size="small" />
+            <el-button size="small" type="primary" @click="savePanel('name')">保存</el-button>
+            <el-button size="small" @click="activePanel = ''">取消</el-button>
+          </div>
+        </el-form-item>
+
+        <el-form-item label="Channel 子名称">
+          <el-input v-model="form.subName" placeholder="请输入Channel 子名称" :disabled="isViewMode" />
+        </el-form-item>
+        <el-form-item label="跟进员工">
+          <el-input v-model="form.owner" placeholder="请输入跟进员工姓名" :disabled="isViewMode" />
+        </el-form-item>
+      </el-form>
+      <span v-if="!isViewMode" slot="footer">
+        <el-button @click="modalVisible = false">取消</el-button>
+        <el-button type="primary" @click="submitModal">确定</el-button>
+      </span>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import request from '@/utils/request'
+
+export default {
+  name: 'ChannelManagement',
+  data() {
+    return {
+      searchForm: { id: '', type: '', category: '', name: '', subName: '', owner: '' },
+      tableData: [],
+      channelDict: {},
+      modalVisible: false,
+      modalMode: 'create',
+      form: { id: '', type: '', category: '', name: '', subName: '', owner: '' },
+      activePanel: '',
+      panelMode: 'add',
+      panelInput: ''
+    }
+  },
+  computed: {
+    modalTitle() {
+      return this.modalMode === 'create' ? '新建 Channel' : this.modalMode === 'edit' ? '编辑 Channel' : '查看 Channel'
+    },
+    isViewMode() {
+      return this.modalMode === 'view'
+    },
+    typeOptions() {
+      return Object.keys(this.channelDict)
+    },
+    categoryOptions() {
+      if (!this.form.type || !this.channelDict[this.form.type]) return []
+      return Object.keys(this.channelDict[this.form.type])
+    },
+    nameOptions() {
+      if (!this.form.type || !this.form.category) return []
+      return (this.channelDict[this.form.type] || {})[this.form.category] || []
+    }
+  },
+  created() {
+    this.fetchDict()
+    this.fetchList()
+  },
+  methods: {
+    async fetchDict() {
+      const res = await request.get('/api/channel/dict')
+      this.channelDict = res.data
+    },
+    async fetchList() {
+      const res = await request.get('/api/channel/list', { params: this.searchForm })
+      this.tableData = res.data
+    },
+    handleSearch() { this.fetchList() },
+    handleReset() {
+      this.searchForm = { id: '', type: '', category: '', name: '', subName: '', owner: '' }
+      this.fetchList()
+    },
+    async handleDelete(row) {
+      await this.$confirm('您确定要删除此条数据吗?删除后将无法恢复。', '确认删除', { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' })
+      await request.post('/api/channel/delete', { id: row.id })
+      this.$message.success('删除成功')
+      this.fetchList()
+    },
+    openModal(mode, row) {
+      this.modalMode = mode
+      this.activePanel = ''
+      this.panelInput = ''
+      if (mode === 'create') {
+        this.form = { id: '', type: '', category: '', name: '', subName: '', owner: '' }
+      } else {
+        this.form = { ...row }
+      }
+      this.modalVisible = true
+    },
+    onTypeChange() {
+      this.form.category = ''
+      this.form.name = ''
+    },
+    onCategoryChange() {
+      this.form.name = ''
+    },
+    openPanel(target, mode) {
+      if (target === 'category' && !this.form.type) {
+        this.$message.warning('请先选择 Channel 类型')
+        return
+      }
+      if (target === 'name' && (!this.form.type || !this.form.category)) {
+        this.$message.warning('请先选择 Channel 类型和类别')
+        return
+      }
+      if (mode === 'edit') {
+        if (target === 'type' && !this.form.type) { this.$message.warning('请先选择要编辑的类型'); return }
+        if (target === 'category' && !this.form.category) { this.$message.warning('请先选择要编辑的类别'); return }
+        if (target === 'name' && !this.form.name) { this.$message.warning('请先选择要编辑的名称'); return }
+      }
+      this.activePanel = target
+      this.panelMode = mode
+      this.panelInput = mode === 'edit'
+        ? (target === 'type' ? this.form.type : target === 'category' ? this.form.category : this.form.name)
+        : ''
+    },
+    async savePanel(target) {
+      const value = this.panelInput.trim()
+      if (!value) { this.$message.warning('请输入内容'); return }
+      const oldValue = target === 'type' ? this.form.type : target === 'category' ? this.form.category : this.form.name
+      const res = await request.post('/api/channel/dict/save', {
+        target,
+        type: this.form.type,
+        category: this.form.category,
+        oldValue,
+        newValue: value,
+        mode: this.panelMode
+      })
+      this.channelDict = res.data
+      if (this.panelMode === 'add') {
+        if (target === 'type') this.form.type = value
+        else if (target === 'category') this.form.category = value
+        else this.form.name = value
+      } else {
+        if (target === 'type') this.form.type = value
+        else if (target === 'category') this.form.category = value
+        else this.form.name = value
+      }
+      this.activePanel = ''
+      this.$message.success(this.panelMode === 'add' ? '新增成功' : '编辑成功')
+      this.fetchList()
+    },
+    async submitModal() {
+      if (!this.form.type || !this.form.category || !this.form.name) {
+        this.$message.warning('请完整选择类型、类别和名称')
+        return
+      }
+      await request.post('/api/channel/save', this.form)
+      this.$message.success(this.modalMode === 'create' ? '新建成功' : '保存成功')
+      this.modalVisible = false
+      this.fetchList()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.search-form .search-row { display: flex; flex-wrap: wrap; margin-bottom: 0; }
+.search-section .action-row { margin-top: 15px; }
+.empty-text { color: #c0c4cc; }
+
+.inline-field { display: flex; gap: 8px; align-items: center; }
+.quick-panel {
+  display: flex; gap: 8px; align-items: center;
+  margin-top: 8px; padding: 10px;
+  background: #f0f7ff; border: 1px solid #d9ecff; border-radius: 4px;
+}
+</style>

+ 650 - 0
pc/src/views/PageAssembly.vue

@@ -0,0 +1,650 @@
+<template>
+  <div class="page-assembly">
+    <!-- ==================== 列表视图 ==================== -->
+    <template v-if="viewState === 'list'">
+      <el-card>
+        <div slot="header" class="search-section">
+          <div class="search-row">
+            <el-form :model="searchForm" label-width="80px" inline>
+              <el-form-item label="页面ID:">
+                <el-input v-model="searchForm.id" placeholder="请输入" clearable style="width:160px" />
+              </el-form-item>
+              <el-form-item label="页面名称:">
+                <el-input v-model="searchForm.name" placeholder="请输入" clearable style="width:160px" />
+              </el-form-item>
+              <div class="button-group">
+                <el-button type="primary" @click="handleSearch">查询</el-button>
+                <el-button @click="handleReset">重置</el-button>
+              </div>
+            </el-form>
+          </div>
+          <div class="action-row">
+            <el-button type="primary" icon="el-icon-plus" @click="handleCreate">新建组装页</el-button>
+          </div>
+        </div>
+
+        <el-table :data="tableData" stripe border style="width:100%">
+          <el-table-column label="操作" width="200" fixed="left">
+            <template slot-scope="scope">
+              <el-button type="text" size="small" @click="handleEdit(scope.row)">编辑</el-button>
+              <el-button type="text" size="small" @click="showLinkDialog(scope.row.id)">链接</el-button>
+              <el-button type="text" size="small" style="color:#F56C6C" @click="handleDelete(scope.row)">删除</el-button>
+            </template>
+          </el-table-column>
+          <el-table-column prop="id" label="页面ID" width="120" align="center" />
+          <el-table-column prop="name" label="组装页名称" min-width="200" show-overflow-tooltip />
+          <el-table-column label="发布状态" width="100" align="center">
+            <template slot-scope="scope">
+              <el-tag :type="scope.row.status === 'published' ? 'success' : 'info'" size="small">
+                {{ scope.row.status === 'published' ? '已上架' : '草稿' }}
+              </el-tag>
+            </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>
+      </el-card>
+    </template>
+
+    <!-- ==================== 编辑视图 ==================== -->
+    <template v-else>
+      <!-- 操作栏 -->
+      <div class="toolbar">
+        <div class="toolbar-left">
+          <el-button size="small" icon="el-icon-arrow-left" @click="backToList">返回</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="isModified" type="warning" size="small" effect="plain">有未发布的修改</el-tag>
+          <el-tag v-else size="info" effect="plain">草稿 (未发布)</el-tag>
+        </div>
+        <div class="toolbar-right">
+          <el-button size="small" icon="el-icon-link" @click="showLinkDialog(editData.id)">获取链接</el-button>
+          <el-button size="small" type="primary" plain @click="handleSaveAssembly('draft')">保存草稿</el-button>
+          <el-button size="small" type="primary" @click="handleSaveAssembly('publish')">发布上线</el-button>
+        </div>
+      </div>
+
+      <!-- 基本信息 -->
+      <el-card class="section-card" shadow="hover">
+        <div class="basic-info">
+          <div class="info-item">
+            <span class="info-label">页面 ID:</span>
+            <el-tag size="small" effect="plain">{{ editData.id || '保存后生成' }}</el-tag>
+          </div>
+          <div class="info-item" style="flex:1">
+            <span class="info-label">组装页名称 <span style="color:#F56C6C">*</span></span>
+            <el-input v-model="editData.name" placeholder="请为该页面设置一个名称 (必填)" size="small" @input="isModified = true" />
+          </div>
+        </div>
+      </el-card>
+
+      <!-- 模块列表 -->
+      <div v-for="(block, bIdx) in editData.blocks" :key="block.id" class="block-card">
+        <!-- 模块操作栏 -->
+        <div class="block-actions">
+          <el-popover placement="bottom" trigger="click" width="180">
+            <div>
+              <div style="margin-bottom:8px;font-size:13px;color:#666">移动到序号:</div>
+              <div style="display:flex;gap:8px">
+                <el-input-number v-model="sortTarget" :min="1" :max="editData.blocks.length" size="mini" controls-position="right" style="width:100px" />
+                <el-button size="mini" type="primary" @click="moveBlock(bIdx, sortTarget - 1)">确定</el-button>
+              </div>
+            </div>
+            <el-button slot="reference" size="mini" icon="el-icon-sort" @click="sortTarget = bIdx + 1">排序</el-button>
+          </el-popover>
+          <el-button v-if="block.type !== 'privilege'" size="mini" icon="el-icon-copy-document" @click="duplicateBlock(bIdx)">复制</el-button>
+          <el-button size="mini" type="danger" icon="el-icon-delete" @click="removeBlock(bIdx)">删除</el-button>
+        </div>
+
+        <!-- 图片模块 -->
+        <template v-if="block.type === 'image'">
+          <div class="block-header"><i class="el-icon-picture-outline"></i> 图片模块</div>
+          <div class="block-body">
+            <div class="image-upload-area">
+              <div class="upload-box upload-box-wide" @click="triggerBlockUpload(bIdx)">
+                <img v-if="block.data.imageUrl" :src="block.data.imageUrl" class="preview-img" />
+                <div v-else class="upload-placeholder">
+                  <i class="el-icon-picture"></i><span>未上传图片</span>
+                </div>
+                <div class="upload-overlay"><i class="el-icon-upload2"></i><span>点击上传图片</span></div>
+              </div>
+              <div class="upload-tip">建议宽度:<b>750px</b><br/>高度不限,系统将按比例自适应展示</div>
+              <input :ref="'blockUpload' + bIdx" type="file" accept="image/*" style="display:none" @change="onBlockImageUpload($event, bIdx)" />
+            </div>
+          </div>
+        </template>
+
+        <!-- 权益商品模块 -->
+        <template v-else-if="block.type === 'privilege'">
+          <div class="block-header"><i class="el-icon-s-grid"></i> 权益商品模块 <span class="block-hint">(当前页面仅限添加一个)</span></div>
+          <div class="block-body">
+            <el-form label-width="100px" style="margin-bottom:16px;border-bottom:1px solid #ebeef5;padding-bottom:16px">
+              <el-form-item label="可配置大标题">
+                <el-input v-model="block.data.title" style="width:50%" @input="isModified = true" />
+              </el-form-item>
+            </el-form>
+            <div v-for="(pItem, pIdx) in block.data.items" :key="pIdx" class="privilege-row">
+              <el-tag size="mini" effect="plain" class="privilege-no">NO. {{ pIdx + 1 }}</el-tag>
+              <div class="privilege-icon"><i class="el-icon-document"></i></div>
+              <div class="privilege-info">
+                <div class="privilege-title">{{ pItem.title }}</div>
+                <div class="privilege-desc">{{ pItem.desc }}</div>
+              </div>
+              <el-popover placement="bottom" trigger="click" width="180">
+                <div>
+                  <div style="margin-bottom:8px;font-size:13px;color:#666">移动到序号:</div>
+                  <div style="display:flex;gap:8px">
+                    <el-input-number v-model="sortTarget" :min="1" :max="block.data.items.length" size="mini" controls-position="right" style="width:100px" />
+                    <el-button size="mini" type="primary" @click="movePrivilegeItem(bIdx, pIdx, sortTarget - 1)">确定</el-button>
+                  </div>
+                </div>
+                <el-button slot="reference" size="mini" icon="el-icon-sort" circle @click="sortTarget = pIdx + 1" />
+              </el-popover>
+            </div>
+          </div>
+        </template>
+
+        <!-- 图文模块 (富文本) -->
+        <template v-else-if="block.type === 'richtext'">
+          <div class="block-header"><i class="el-icon-notebook-2"></i> 图文模块</div>
+          <div class="block-body">
+            <quill-editor
+              v-model="block.data.content"
+              :options="quillOptions"
+              @change="onQuillChange"
+            />
+          </div>
+        </template>
+
+        <!-- 按钮模块 -->
+        <template v-else-if="block.type === 'buttonGroup'">
+          <div class="block-header">
+            <span><i class="el-icon-thumb"></i> 按钮模块</span>
+            <el-button type="text" icon="el-icon-circle-plus-outline" size="small" @click="addButton(bIdx)" style="float:right">添加按钮</el-button>
+          </div>
+          <div class="block-body">
+            <div v-if="block.data.buttons.length === 0" class="empty-tip">暂无按钮</div>
+            <div v-for="(btn, btnIdx) in block.data.buttons" :key="btnIdx" class="button-config-item">
+              <div class="btn-index">BTN {{ btnIdx + 1 }}</div>
+              <div class="btn-config-body">
+                <el-form label-width="80px">
+                  <el-row :gutter="16">
+                    <el-col :span="8">
+                      <el-form-item label="按钮文案">
+                        <el-input v-model="btn.text" size="small" @input="isModified = true" />
+                      </el-form-item>
+                    </el-col>
+                    <el-col :span="8">
+                      <el-form-item label="点击动作">
+                        <el-select v-model="btn.actionType" size="small" @change="isModified = true">
+                          <el-option label="跳转链接" value="link" />
+                          <el-option label="弹窗提示" value="dialog" />
+                        </el-select>
+                      </el-form-item>
+                    </el-col>
+                    <el-col :span="8">
+                      <el-form-item label="按钮样式">
+                        <el-select v-model="btn.style" size="small" @change="isModified = true">
+                          <el-option label="突出显示 (红色)" value="primary" />
+                          <el-option label="次要操作 (白色)" value="secondary" />
+                        </el-select>
+                      </el-form-item>
+                    </el-col>
+                  </el-row>
+                  <!-- 跳转链接 -->
+                  <el-form-item v-if="btn.actionType === 'link'" label="跳转链接">
+                    <el-input v-model="btn.link" size="small" placeholder="必填,输入跳转URL" @input="isModified = true">
+                      <template slot="append"><span style="color:#F56C6C">*</span></template>
+                    </el-input>
+                  </el-form-item>
+                  <!-- 弹窗配置 -->
+                  <div v-if="btn.actionType === 'dialog'" class="dialog-config">
+                    <div class="dialog-config-title"><i class="el-icon-monitor"></i> 弹窗内容配置 (包含取消、确认按钮)</div>
+                    <el-form-item label="弹窗标题">
+                      <el-input v-model="btn.dialog.title" size="small" placeholder="弹窗标题" @input="isModified = true" />
+                    </el-form-item>
+                    <el-form-item label="弹窗描述">
+                      <el-input v-model="btn.dialog.desc" type="textarea" :rows="2" size="small" placeholder="弹窗正文提示..." @input="isModified = true" />
+                    </el-form-item>
+                    <el-row :gutter="16">
+                      <el-col :span="12">
+                        <el-form-item label="确认文案">
+                          <el-input v-model="btn.dialog.confirmText" size="small" placeholder="例如:继续前往" @input="isModified = true" />
+                        </el-form-item>
+                      </el-col>
+                      <el-col :span="12">
+                        <el-form-item label="确认链接">
+                          <el-input v-model="btn.dialog.confirmLink" size="small" placeholder="选填,无链接则仅关闭弹窗" @input="isModified = true" />
+                        </el-form-item>
+                      </el-col>
+                    </el-row>
+                  </div>
+                </el-form>
+              </div>
+              <div class="btn-config-actions">
+                <el-popover placement="bottom" trigger="click" width="180">
+                  <div>
+                    <div style="margin-bottom:8px;font-size:13px;color:#666">移动到序号:</div>
+                    <div style="display:flex;gap:8px">
+                      <el-input-number v-model="sortTarget" :min="1" :max="block.data.buttons.length" size="mini" controls-position="right" style="width:100px" />
+                      <el-button size="mini" type="primary" @click="moveButton(bIdx, btnIdx, sortTarget - 1)">确定</el-button>
+                    </div>
+                  </div>
+                  <el-button slot="reference" size="mini" icon="el-icon-sort" circle @click="sortTarget = btnIdx + 1" />
+                </el-popover>
+                <el-button size="mini" type="danger" icon="el-icon-delete" circle @click="removeButton(bIdx, btnIdx)" />
+              </div>
+            </div>
+          </div>
+        </template>
+      </div>
+
+      <!-- 添加模块按钮 -->
+      <div class="add-block-bar">
+        <el-button type="text" icon="el-icon-picture-outline" @click="addBlock('image')">图片模块</el-button>
+        <el-button v-if="!hasPrivilegeBlock" type="text" icon="el-icon-s-grid" @click="addBlock('privilege')">权益商品模块</el-button>
+        <el-button type="text" icon="el-icon-notebook-2" @click="addBlock('richtext')">图文模块</el-button>
+        <el-button type="text" icon="el-icon-thumb" @click="addBlock('buttonGroup')">按钮模块</el-button>
+      </div>
+    </template>
+
+    <!-- 获取链接弹窗 -->
+    <el-dialog title="获取页面链接" :visible.sync="linkDialogVisible" width="680px">
+      <el-form label-width="100px" inline>
+        <el-form-item label="Campaign ID">
+          <el-select v-model="linkForm.campaignId" clearable placeholder="请选择 (无)">
+            <el-option label="CAMP202511" value="CAMP202511" />
+            <el-option label="CAMP_WINTER" value="CAMP_WINTER" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="Channel ID">
+          <el-select v-model="linkForm.channelId" clearable placeholder="请选择 (无)">
+            <el-option label="APP推送" value="CH_APP" />
+            <el-option label="微信公众号" value="CH_WECHAT" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <div v-if="linkTargetPublished" class="link-section">
+        <div class="link-label"><span class="dot dot-green"></span> 正式环境链接</div>
+        <div class="link-row">
+          <span class="link-type">H5 链接</span>
+          <el-input :value="assemblyProdH5" readonly size="small" />
+          <el-button size="small" type="primary" plain @click="copyLink(assemblyProdH5)">复制</el-button>
+        </div>
+        <div class="link-row">
+          <span class="link-type">小程序/APP</span>
+          <el-input :value="assemblyProdApp" readonly size="small" />
+          <el-button size="small" type="primary" plain @click="copyLink(assemblyProdApp)">复制</el-button>
+        </div>
+      </div>
+      <div class="link-section">
+        <div class="link-label"><span class="dot dot-blue"></span> 草稿预览链接 (测试)</div>
+        <div class="link-row">
+          <el-input :value="assemblyTestUrl" readonly size="small" />
+          <el-button size="small" type="primary" plain @click="copyLink(assemblyTestUrl)">复制</el-button>
+        </div>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import request from '@/utils/request'
+
+let blockIdCounter = 0
+function genBlockId() {
+  return 'b_' + (++blockIdCounter) + '_' + Math.random().toString(36).substr(2, 6)
+}
+
+export default {
+  name: 'PageAssembly',
+  data() {
+    return {
+      viewState: 'list',
+      tableData: [],
+      searchForm: { id: '', name: '' },
+      editData: this.createEmptyAssembly(),
+      isNew: true,
+      isModified: false,
+      quillReady: false,
+      sortTarget: 1,
+      linkDialogVisible: false,
+      linkForm: { campaignId: '', channelId: '' },
+      linkTargetId: '',
+      linkTargetPublished: false,
+      quillOptions: {
+        placeholder: '请输入图文内容...',
+        modules: {
+          toolbar: [
+            ['bold', 'italic', 'underline'],
+            [{ list: 'ordered' }, { list: 'bullet' }],
+            [{ header: [1, 2, 3, false] }],
+            ['link', 'image'],
+            ['clean']
+          ]
+        }
+      }
+    }
+  },
+  computed: {
+    hasPrivilegeBlock() {
+      return this.editData.blocks.some(b => b.type === 'privilege')
+    },
+    linkQueryStr() {
+      const params = []
+      if (this.linkForm.campaignId) params.push(`CampaignId=${this.linkForm.campaignId}`)
+      if (this.linkForm.channelId) params.push(`Channelid=${this.linkForm.channelId}`)
+      return params.length ? '&' + params.join('&') : ''
+    },
+    assemblyProdH5() {
+      return `https://hsbc.com/assembly?id=${this.linkTargetId}${this.linkQueryStr}`
+    },
+    assemblyProdApp() {
+      return `hsbc-app://assembly?id=${this.linkTargetId}${this.linkQueryStr}`
+    },
+    assemblyTestUrl() {
+      return `https://test.hsbc.com/assembly?id=${this.linkTargetId}&draft=1${this.linkQueryStr}`
+    }
+  },
+  created() {
+    this.fetchList()
+  },
+  methods: {
+    createEmptyAssembly() {
+      return {
+        id: '', name: '', status: 'draft',
+        blocks: [
+          { type: 'image', id: genBlockId(), data: { imageUrl: '' } },
+          { type: 'privilege', id: genBlockId(), data: { title: '权益标题', items: [{ id: '1', title: '权益名称', desc: '权益简介' }] } },
+          { type: 'richtext', id: genBlockId(), data: { content: '<p>组装页富文本区块内容...</p>' } },
+          { type: 'buttonGroup', id: genBlockId(), data: { buttons: [{ text: '点击配置', link: '', style: 'primary', actionType: 'link', dialog: { title: '', desc: '', confirmText: '确认', confirmLink: '' } }] } }
+        ]
+      }
+    },
+    async fetchList() {
+      const res = await request.get('/api/assembly/list', { params: this.searchForm })
+      this.tableData = res.data
+    },
+    handleSearch() {
+      this.fetchList()
+    },
+    handleReset() {
+      this.searchForm = { id: '', name: '' }
+      this.fetchList()
+    },
+    handleCreate() {
+      this.editData = this.createEmptyAssembly()
+      this.isNew = true
+      this.isModified = true
+      this.quillReady = false
+      this.viewState = 'edit'
+      this.$nextTick(() => { this.quillReady = true })
+    },
+    handleEdit(row) {
+      this.editData = JSON.parse(JSON.stringify(row))
+      this.isNew = false
+      this.isModified = false
+      this.quillReady = false
+      this.viewState = 'edit'
+      this.$nextTick(() => { this.quillReady = true })
+    },
+    async handleDelete(row) {
+      await this.$confirm('确认要删除该组装页吗?此操作不可恢复。', '提示', { type: 'warning' })
+      await request.post('/api/assembly/delete', { id: row.id })
+      this.$message.success('删除成功')
+      this.fetchList()
+    },
+    backToList() {
+      this.viewState = 'list'
+      this.fetchList()
+    },
+    async handleSaveAssembly(type) {
+      const data = this.editData
+      let hasError = false
+      data.blocks.forEach(block => {
+        if (block.type === 'buttonGroup') {
+          block.data.buttons.forEach(btn => {
+            if (btn.actionType === 'link' && !btn.link.trim()) hasError = true
+          })
+        }
+      })
+      if (hasError) {
+        this.$message.error('保存失败:按钮模块中存在未填写的跳转链接!')
+        return
+      }
+      if (!data.name.trim()) data.name = '未命名组装页'
+      data.status = type === 'publish' ? 'published' : 'draft'
+
+      const res = await request.post('/api/assembly/save', data)
+      if (!data.id) data.id = res.data.id
+      this.isModified = false
+      this.$message.success(type === 'publish' ? '已发布组装页配置!' : '已保存组装页配置草稿!')
+      if (type === 'publish') this.backToList()
+    },
+
+    onQuillChange() {
+      if (this.quillReady) this.isModified = true
+    },
+
+    // 模块操作
+    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: [{ text: '点击跳转', link: '', style: 'primary', actionType: 'link', dialog: { title: '', desc: '', confirmText: '确认', confirmLink: '' } }] }
+      this.editData.blocks.push(block)
+      this.isModified = true
+    },
+    duplicateBlock(index) {
+      const block = this.editData.blocks[index]
+      if (block.type === 'privilege') {
+        this.$message.warning('权益商品模块每页仅限配置一个,无法复制!')
+        return
+      }
+      const copy = JSON.parse(JSON.stringify(block))
+      copy.id = genBlockId()
+      this.editData.blocks.splice(index + 1, 0, copy)
+      this.isModified = true
+    },
+    removeBlock(index) {
+      this.$confirm('确定删除该模块吗?', '提示', { type: 'warning' }).then(() => {
+        this.editData.blocks.splice(index, 1)
+        this.isModified = true
+      }).catch(() => {})
+    },
+    moveBlock(from, to) {
+      if (to < 0 || to >= this.editData.blocks.length || from === to) return
+      const item = this.editData.blocks.splice(from, 1)[0]
+      this.editData.blocks.splice(to, 0, item)
+      this.isModified = true
+    },
+    movePrivilegeItem(bIdx, from, to) {
+      const items = this.editData.blocks[bIdx].data.items
+      if (to < 0 || to >= items.length || from === to) return
+      const item = items.splice(from, 1)[0]
+      items.splice(to, 0, item)
+      this.isModified = true
+    },
+
+    // 按钮操作
+    addButton(bIdx) {
+      this.editData.blocks[bIdx].data.buttons.push({
+        text: '新按钮', link: '', style: 'secondary', actionType: 'link',
+        dialog: { title: '', desc: '', confirmText: '确认', confirmLink: '' }
+      })
+      this.isModified = true
+    },
+    removeButton(bIdx, btnIdx) {
+      this.editData.blocks[bIdx].data.buttons.splice(btnIdx, 1)
+      this.isModified = true
+    },
+    moveButton(bIdx, from, to) {
+      const buttons = this.editData.blocks[bIdx].data.buttons
+      if (to < 0 || to >= buttons.length || from === to) return
+      const item = buttons.splice(from, 1)[0]
+      buttons.splice(to, 0, item)
+      this.isModified = true
+    },
+
+    // 图片上传
+    triggerBlockUpload(bIdx) {
+      const ref = this.$refs['blockUpload' + bIdx]
+      if (ref) (Array.isArray(ref) ? ref[0] : ref).click()
+    },
+    onBlockImageUpload(event, bIdx) {
+      const file = event.target.files[0]
+      if (!file) return
+      const reader = new FileReader()
+      reader.onload = (e) => {
+        this.$set(this.editData.blocks[bIdx].data, 'imageUrl', e.target.result)
+        this.isModified = true
+      }
+      reader.readAsDataURL(file)
+      event.target.value = ''
+    },
+
+    // 链接弹窗
+    showLinkDialog(id) {
+      if (!id) {
+        this.$message.warning('请先保存组装页再获取链接!')
+        return
+      }
+      this.linkTargetId = id
+      const item = this.tableData.find(a => a.id === id) || this.editData
+      this.linkTargetPublished = item && item.status === 'published'
+      this.linkForm = { campaignId: '', channelId: '' }
+      this.linkDialogVisible = true
+    },
+    copyLink(text) {
+      navigator.clipboard.writeText(text).then(() => {
+        this.$message.success('链接已复制到剪贴板')
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.search-section .search-row { margin-bottom: 0; }
+.search-section .action-row { margin-top: 15px; }
+.button-group { display: inline-block; margin-left: 20px; }
+
+.toolbar {
+  display: flex; justify-content: space-between; align-items: center;
+  margin-bottom: 16px; padding: 12px 16px;
+  background: #fff; border-radius: 4px; box-shadow: 0 1px 4px rgba(0,0,0,.06);
+}
+.toolbar-left { display: flex; align-items: center; gap: 12px; }
+.toolbar-title { margin: 0; font-size: 18px; font-weight: 600; }
+.toolbar-right { display: flex; gap: 8px; }
+
+.section-card { margin-bottom: 16px; }
+.basic-info { display: flex; align-items: center; gap: 24px; }
+.info-item { display: flex; align-items: center; gap: 8px; }
+.info-label { font-size: 13px; color: #909399; white-space: nowrap; }
+
+.block-card {
+  background: #fff; border-radius: 8px; border: 1px solid #ebeef5;
+  margin-bottom: 16px; overflow: hidden; position: relative;
+  box-shadow: 0 1px 4px rgba(0,0,0,.04);
+}
+.block-header {
+  background: #f5f7fa; padding: 10px 16px;
+  border-bottom: 1px solid #ebeef5; font-weight: 600; font-size: 14px;
+}
+.block-header i { margin-right: 6px; color: #909399; }
+.block-hint { font-size: 12px; color: #c0c4cc; font-weight: normal; margin-left: 8px; }
+.block-body { padding: 16px; }
+.block-actions {
+  position: absolute; right: 0; top: 0; z-index: 10;
+  display: flex; gap: 4px; background: #f5f7fa;
+  border-bottom: 1px solid #ebeef5; border-left: 1px solid #ebeef5;
+  padding: 6px 8px; border-radius: 0 0 0 8px;
+}
+
+.image-upload-area { display: flex; align-items: center; gap: 16px; }
+.upload-box {
+  width: 256px; height: 144px;
+  background: #eef6fc; border: 1px solid #dcdfe6; border-radius: 4px;
+  position: relative; overflow: hidden; cursor: pointer;
+  display: flex; align-items: center; justify-content: center; flex-shrink: 0;
+}
+.upload-box-wide { width: 360px; height: 160px; }
+.upload-box:hover .upload-overlay { display: flex; }
+.preview-img { width: 100%; height: 100%; object-fit: cover; }
+.upload-placeholder {
+  display: flex; flex-direction: column; align-items: center;
+  color: #c0c4cc; font-size: 12px;
+}
+.upload-placeholder i { font-size: 28px; margin-bottom: 4px; }
+.upload-overlay {
+  display: none; position: absolute; inset: 0;
+  background: rgba(0,0,0,.5); color: #fff;
+  flex-direction: column; align-items: center; justify-content: center; font-size: 14px;
+}
+.upload-overlay i { font-size: 24px; margin-bottom: 4px; }
+.upload-tip {
+  font-size: 12px; color: #909399; line-height: 1.6;
+  background: #f5f7fa; padding: 8px 12px; border-radius: 4px; border: 1px solid #ebeef5;
+}
+
+.privilege-row {
+  display: flex; align-items: center; gap: 12px;
+  padding: 10px 12px; border: 1px solid #ebeef5; border-radius: 6px;
+  background: #fafafa; margin-bottom: 8px;
+}
+.privilege-no { flex-shrink: 0; }
+.privilege-icon {
+  width: 40px; height: 40px; border-radius: 50%;
+  border: 1px solid #dcdfe6; background: #fff;
+  display: flex; align-items: center; justify-content: center;
+  color: #909399; font-size: 18px; flex-shrink: 0;
+}
+.privilege-info { flex: 1; opacity: .8; }
+.privilege-title { font-size: 13px; font-weight: 600; }
+.privilege-desc { font-size: 12px; color: #909399; margin-top: 2px; }
+
+.button-config-item {
+  position: relative; border: 1px solid #ebeef5; border-radius: 6px;
+  padding: 20px 12px 12px; margin-bottom: 12px; background: #fafafa;
+}
+.btn-index {
+  position: absolute; left: 0; top: 0;
+  background: #606266; color: #fff; font-size: 10px; font-weight: 700;
+  padding: 2px 8px; border-radius: 6px 0 6px 0;
+}
+.btn-config-body { flex: 1; }
+.btn-config-actions {
+  position: absolute; right: 12px; top: 12px;
+  display: flex; gap: 6px;
+}
+.dialog-config {
+  background: #fff; padding: 12px; border: 1px solid #ebeef5; border-radius: 6px;
+  margin-top: 4px;
+}
+.dialog-config-title {
+  font-size: 13px; font-weight: 600; color: #606266;
+  border-bottom: 1px solid #ebeef5; padding-bottom: 8px; margin-bottom: 12px;
+}
+
+.add-block-bar {
+  display: flex; gap: 16px; justify-content: center;
+  padding: 20px; border: 2px dashed #dcdfe6; border-radius: 8px;
+  background: #fff; margin-bottom: 16px;
+}
+
+.empty-tip {
+  text-align: center; color: #c0c4cc; padding: 16px;
+  border: 1px dashed #dcdfe6; border-radius: 4px;
+}
+
+.link-section { margin-top: 16px; padding: 12px; background: #fafafa; border-radius: 6px; border: 1px solid #ebeef5; }
+.link-section + .link-section { margin-top: 12px; }
+.link-label { font-weight: 600; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
+.link-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
+.link-type { font-size: 12px; color: #909399; width: 70px; flex-shrink: 0; }
+.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
+.dot-green { background: #67C23A; }
+.dot-blue { background: #409EFF; }
+</style>

+ 388 - 0
pc/src/views/PageHomeConfig.vue

@@ -0,0 +1,388 @@
+<template>
+  <div class="page-home-config">
+    <!-- 顶部操作栏 -->
+    <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>
+      </div>
+      <div class="toolbar-right">
+        <el-button size="small" icon="el-icon-link" @click="showLinkDialog">获取链接</el-button>
+        <el-button size="small" type="primary" plain @click="handleSave('draft')">保存草稿</el-button>
+        <el-button size="small" type="primary" @click="handleSave('publish')">发布上线</el-button>
+      </div>
+    </div>
+
+    <!-- 客群Tab -->
+    <el-tabs v-model="currentTier" @tab-click="onTierChange">
+      <el-tab-pane
+        v-for="tier in tiers"
+        :key="tier.key"
+        :label="configData[tier.key] ? configData[tier.key].tabName : tier.label"
+        :name="tier.key"
+      />
+    </el-tabs>
+
+    <!-- 配置表单 -->
+    <div v-if="currentTierData" class="config-sections">
+      <!-- Tab名称配置 -->
+      <el-card class="section-card" shadow="hover">
+        <div slot="header" class="section-header">
+          <i class="el-icon-folder-opened"></i> Tab名称配置
+        </div>
+        <el-form label-width="80px">
+          <el-form-item label="Tab名称">
+            <el-input v-model="currentTierData.tabName" placeholder="例如:卓越理财" style="width: 50%" @input="markModified" />
+          </el-form-item>
+        </el-form>
+      </el-card>
+
+      <!-- 头部配置 -->
+      <el-card class="section-card" shadow="hover">
+        <div slot="header" class="section-header">
+          <i class="el-icon-picture-outline"></i> 头部配置
+        </div>
+        <el-form label-width="100px">
+          <el-form-item label="头图配图">
+            <div class="image-upload-area">
+              <div class="upload-box" @click="triggerUpload('header')">
+                <img v-if="currentTierData.header.imageUrl" :src="currentTierData.header.imageUrl" class="preview-img" />
+                <div v-else class="upload-placeholder">
+                  <i class="el-icon-picture"></i>
+                  <span>未上传图片</span>
+                </div>
+                <div class="upload-overlay">
+                  <i class="el-icon-upload2"></i>
+                  <span>点击上传</span>
+                </div>
+              </div>
+              <div class="upload-tip">建议图片尺寸:<b>686 × 296 px</b></div>
+            </div>
+            <input ref="headerUpload" type="file" accept="image/*" style="display:none" @change="onImageUpload($event, 'header')" />
+          </el-form-item>
+          <el-form-item label="主标题(粗体)">
+            <el-input v-model="currentTierData.header.mainTitle" @input="markModified" />
+          </el-form-item>
+          <el-form-item label="副标题(小字)">
+            <el-input v-model="currentTierData.header.subTitle" @input="markModified" />
+          </el-form-item>
+        </el-form>
+      </el-card>
+
+      <!-- 按钮配置 -->
+      <el-card class="section-card" shadow="hover">
+        <div slot="header" class="section-header">
+          <i class="el-icon-thumb"></i> 按钮配置
+        </div>
+        <el-form label-width="130px">
+          <el-row :gutter="20">
+            <el-col :span="12">
+              <el-form-item label="已解锁按钮文案">
+                <el-input v-model="currentTierData.buttons.unlocked" placeholder="例如:进入权益专区" @input="markModified" />
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="待解锁按钮文案">
+                <el-input v-model="currentTierData.buttons.locked" placeholder="例如:待解锁" @input="markModified" />
+              </el-form-item>
+            </el-col>
+          </el-row>
+        </el-form>
+      </el-card>
+
+      <!-- 权益列表配置 -->
+      <el-card class="section-card" shadow="hover">
+        <div slot="header" class="section-header">
+          <i class="el-icon-s-grid"></i> 权益列表配置
+          <el-button type="text" icon="el-icon-circle-plus-outline" @click="addPrivilege" style="float:right">添加权益</el-button>
+        </div>
+        <div v-if="currentTierData.privileges.length === 0" class="empty-tip">
+          暂无权益项
+        </div>
+        <div v-for="(item, index) in currentTierData.privileges" :key="index" class="privilege-item">
+          <div class="privilege-index">NO. {{ index + 1 }}</div>
+          <div class="privilege-actions">
+            <el-popover placement="bottom" trigger="click" width="180">
+              <div>
+                <div style="margin-bottom:8px;font-size:13px;color:#666">移动到序号:</div>
+                <div style="display:flex;gap:8px">
+                  <el-input-number v-model="sortTarget" :min="1" :max="currentTierData.privileges.length" size="mini" controls-position="right" style="width:100px" />
+                  <el-button size="mini" type="primary" @click="movePrivilege(index, sortTarget - 1)">确定</el-button>
+                </div>
+              </div>
+              <el-button slot="reference" size="mini" icon="el-icon-sort" circle @click="sortTarget = index + 1" />
+            </el-popover>
+            <el-button size="mini" type="danger" icon="el-icon-delete" circle @click="removePrivilege(index)" />
+          </div>
+          <div class="privilege-content">
+            <div class="privilege-image" @click="triggerUpload('privilege', index)">
+              <img v-if="item.imageUrl" :src="item.imageUrl" class="preview-img-sm" />
+              <div v-else class="upload-placeholder-sm">
+                <i class="el-icon-picture"></i>
+                <span>未上传</span>
+              </div>
+              <input :ref="'privilegeUpload' + index" type="file" accept="image/*" style="display:none" @change="onImageUpload($event, 'privilege', index)" />
+            </div>
+            <div class="privilege-text-tip">非必填<br/>无图展示圆点</div>
+            <div class="privilege-fields">
+              <el-form label-width="60px" inline>
+                <el-form-item label="标题">
+                  <el-input v-model="item.title" style="width:160px" @input="markModified" />
+                </el-form-item>
+                <el-form-item label="描述">
+                  <el-input v-model="item.desc" style="width:300px" @input="markModified" />
+                </el-form-item>
+              </el-form>
+            </div>
+          </div>
+        </div>
+      </el-card>
+    </div>
+
+    <!-- 获取链接弹窗 -->
+    <el-dialog title="获取页面链接" :visible.sync="linkDialogVisible" width="680px">
+      <el-form label-width="100px" inline>
+        <el-form-item label="Campaign ID">
+          <el-select v-model="linkForm.campaignId" clearable placeholder="请选择 (无)" @change="updateLinks">
+            <el-option label="CAMP202511" value="CAMP202511" />
+            <el-option label="CAMP_WINTER" value="CAMP_WINTER" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="Channel ID">
+          <el-select v-model="linkForm.channelId" clearable placeholder="请选择 (无)" @change="updateLinks">
+            <el-option label="APP推送" value="CH_APP" />
+            <el-option label="微信公众号" value="CH_WECHAT" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <div v-if="pageStatus === 'published'" class="link-section">
+        <div class="link-label"><span class="dot dot-green"></span> 正式环境链接</div>
+        <div class="link-row">
+          <span class="link-type">H5 链接</span>
+          <el-input :value="prodH5Url" readonly size="small" />
+          <el-button size="small" type="primary" plain @click="copyLink(prodH5Url)">复制</el-button>
+        </div>
+        <div class="link-row">
+          <span class="link-type">小程序/APP</span>
+          <el-input :value="prodAppUrl" readonly size="small" />
+          <el-button size="small" type="primary" plain @click="copyLink(prodAppUrl)">复制</el-button>
+        </div>
+      </div>
+      <div class="link-section">
+        <div class="link-label"><span class="dot dot-blue"></span> 草稿预览链接 (测试)</div>
+        <div class="link-row">
+          <el-input :value="testUrl" readonly size="small" />
+          <el-button size="small" type="primary" plain @click="copyLink(testUrl)">复制</el-button>
+        </div>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import request from '@/utils/request'
+
+export default {
+  name: 'PageHomeConfig',
+  data() {
+    return {
+      currentTier: 'premier',
+      tiers: [
+        { key: 'premier', label: '卓越理财' },
+        { key: 'elite', label: '卓越理财·尊尚' },
+        { key: 'global', label: '环球私人银行' }
+      ],
+      configData: {},
+      pageStatus: 'draft',
+      sortTarget: 1,
+      linkDialogVisible: false,
+      linkForm: { campaignId: '', channelId: '' }
+    }
+  },
+  computed: {
+    currentTierData() {
+      return this.configData[this.currentTier] || null
+    },
+    linkQueryStr() {
+      const params = []
+      if (this.linkForm.campaignId) params.push(`CampaignId=${this.linkForm.campaignId}`)
+      if (this.linkForm.channelId) params.push(`Channelid=${this.linkForm.channelId}`)
+      return params.length ? '?' + params.join('&') : ''
+    },
+    prodH5Url() {
+      return `https://hsbc.com/home${this.linkQueryStr}`
+    },
+    prodAppUrl() {
+      return `hsbc-app://home${this.linkQueryStr}`
+    },
+    testUrl() {
+      const q = this.linkQueryStr
+      return `https://test.hsbc.com/home${q ? q + '&' : '?'}draft=1`
+    }
+  },
+  created() {
+    this.fetchData()
+  },
+  methods: {
+    async fetchData() {
+      const res = await request.get('/api/page-config/home')
+      this.configData = res.data.config
+      this.pageStatus = res.data.status
+    },
+    markModified() {
+      if (this.pageStatus === 'published') {
+        this.pageStatus = 'modified'
+      }
+    },
+    onTierChange() {},
+    triggerUpload(type, index) {
+      if (type === 'header') {
+        this.$refs.headerUpload.click()
+      } else {
+        const ref = this.$refs['privilegeUpload' + index]
+        if (ref) (Array.isArray(ref) ? ref[0] : ref).click()
+      }
+    },
+    onImageUpload(event, type, index) {
+      const file = event.target.files[0]
+      if (!file) return
+      const reader = new FileReader()
+      reader.onload = (e) => {
+        if (type === 'header') {
+          this.currentTierData.header.imageUrl = e.target.result
+        } else {
+          this.$set(this.currentTierData.privileges[index], 'imageUrl', e.target.result)
+        }
+        this.markModified()
+      }
+      reader.readAsDataURL(file)
+      event.target.value = ''
+    },
+    addPrivilege() {
+      this.currentTierData.privileges.push({ imageUrl: '', title: '新权益', desc: '描述...' })
+      this.markModified()
+    },
+    removePrivilege(index) {
+      this.currentTierData.privileges.splice(index, 1)
+      this.markModified()
+    },
+    movePrivilege(from, to) {
+      if (to < 0 || to >= this.currentTierData.privileges.length || from === to) return
+      const item = this.currentTierData.privileges.splice(from, 1)[0]
+      this.currentTierData.privileges.splice(to, 0, item)
+      this.markModified()
+    },
+    async handleSave(type) {
+      const status = type === 'publish' ? 'published' : 'draft'
+      await request.put('/api/page-config/home', { config: this.configData, status })
+      this.pageStatus = status
+      this.$message.success(type === 'publish' ? '已发布权益介绍页配置!' : '已保存权益介绍页配置草稿!')
+    },
+    showLinkDialog() {
+      this.linkForm = { campaignId: '', channelId: '' }
+      this.linkDialogVisible = true
+    },
+    updateLinks() {},
+    copyLink(text) {
+      navigator.clipboard.writeText(text).then(() => {
+        this.$message.success('链接已复制到剪贴板')
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.toolbar {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16px;
+  padding: 12px 16px;
+  background: #fff;
+  border-radius: 4px;
+  box-shadow: 0 1px 4px rgba(0,0,0,.06);
+}
+.toolbar-left { display: flex; align-items: center; gap: 12px; }
+.toolbar-title { margin: 0; font-size: 18px; font-weight: 600; }
+.toolbar-right { display: flex; gap: 8px; }
+
+.config-sections { margin-top: 16px; }
+.section-card { margin-bottom: 16px; }
+.section-header { font-weight: 600; }
+.section-header i { margin-right: 6px; color: #909399; }
+
+.image-upload-area { display: flex; align-items: center; gap: 16px; }
+.upload-box {
+  width: 360px; height: 160px;
+  background: #eef6fc; border: 1px solid #dcdfe6; border-radius: 4px;
+  position: relative; overflow: hidden; cursor: pointer;
+  display: flex; align-items: center; justify-content: center;
+}
+.upload-box:hover .upload-overlay { display: flex; }
+.preview-img { width: 100%; height: 100%; object-fit: cover; }
+.upload-placeholder {
+  display: flex; flex-direction: column; align-items: center;
+  color: #c0c4cc; font-size: 12px;
+}
+.upload-placeholder i { font-size: 28px; margin-bottom: 4px; }
+.upload-overlay {
+  display: none; position: absolute; inset: 0;
+  background: rgba(0,0,0,.5); color: #fff;
+  flex-direction: column; align-items: center; justify-content: center;
+  font-size: 14px;
+}
+.upload-overlay i { font-size: 28px; margin-bottom: 6px; }
+.upload-tip {
+  font-size: 12px; color: #909399;
+  background: #f5f7fa; padding: 8px 12px; border-radius: 4px; border: 1px solid #ebeef5;
+}
+
+.privilege-item {
+  position: relative;
+  border: 1px solid #ebeef5; border-radius: 8px;
+  padding: 20px 16px 16px; margin-bottom: 12px;
+  background: #fff;
+}
+.privilege-index {
+  position: absolute; left: 0; top: 0;
+  background: #409EFF; color: #fff;
+  font-size: 11px; font-weight: 700;
+  padding: 2px 10px; border-radius: 8px 0 8px 0;
+}
+.privilege-actions {
+  position: absolute; right: 12px; top: 12px;
+  display: flex; gap: 6px;
+}
+.privilege-content { display: flex; align-items: flex-start; gap: 12px; margin-top: 8px; }
+.privilege-image {
+  width: 56px; height: 56px; flex-shrink: 0;
+  background: #eef6fc; border: 1px solid #dcdfe6; border-radius: 4px;
+  display: flex; align-items: center; justify-content: center;
+  cursor: pointer; overflow: hidden; position: relative;
+}
+.preview-img-sm { width: 100%; height: 100%; object-fit: cover; }
+.upload-placeholder-sm {
+  display: flex; flex-direction: column; align-items: center;
+  color: #c0c4cc; font-size: 10px;
+}
+.upload-placeholder-sm i { font-size: 18px; }
+.privilege-text-tip { font-size: 10px; color: #c0c4cc; text-align: center; line-height: 1.3; flex-shrink: 0; margin-top: 4px; }
+.privilege-fields { flex: 1; }
+
+.empty-tip {
+  text-align: center; color: #c0c4cc; padding: 24px;
+  border: 1px dashed #dcdfe6; border-radius: 4px;
+}
+
+.link-section { margin-top: 16px; padding: 12px; background: #fafafa; border-radius: 6px; border: 1px solid #ebeef5; }
+.link-section + .link-section { margin-top: 12px; }
+.link-label { font-weight: 600; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
+.link-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
+.link-type { font-size: 12px; color: #909399; width: 70px; flex-shrink: 0; }
+.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
+.dot-green { background: #67C23A; }
+.dot-blue { background: #409EFF; }
+</style>

+ 429 - 0
pc/src/views/PageZoneConfig.vue

@@ -0,0 +1,429 @@
+<template>
+  <div class="page-zone-config">
+    <!-- 顶部操作栏 -->
+    <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>
+      </div>
+      <div class="toolbar-right">
+        <el-button size="small" icon="el-icon-link" @click="showLinkDialog">获取链接</el-button>
+        <el-button size="small" type="primary" plain @click="handleSave('draft')">保存草稿</el-button>
+        <el-button size="small" type="primary" @click="handleSave('publish')">发布上线</el-button>
+      </div>
+    </div>
+
+    <!-- 活动广告轮播图 -->
+    <el-card class="section-card" shadow="hover">
+      <div slot="header" class="section-header">
+        <span><i class="el-icon-data-board"></i> 活动广告轮播图</span>
+        <el-button type="text" icon="el-icon-circle-plus-outline" @click="addCarousel" style="float:right">添加轮播</el-button>
+      </div>
+      <div v-if="configData.adCarousel.length === 0" class="empty-tip">暂无广告轮播图</div>
+      <div v-for="(item, index) in configData.adCarousel" :key="'c' + index" class="carousel-item">
+        <div class="item-index">NO. {{ index + 1 }}</div>
+        <div class="item-actions">
+          <el-popover placement="bottom" trigger="click" width="180">
+            <div>
+              <div style="margin-bottom:8px;font-size:13px;color:#666">移动到序号:</div>
+              <div style="display:flex;gap:8px">
+                <el-input-number v-model="sortTarget" :min="1" :max="configData.adCarousel.length" size="mini" controls-position="right" style="width:100px" />
+                <el-button size="mini" type="primary" @click="moveItem('adCarousel', index, sortTarget - 1)">确定</el-button>
+              </div>
+            </div>
+            <el-button slot="reference" size="mini" icon="el-icon-sort" circle @click="sortTarget = index + 1" />
+          </el-popover>
+          <el-button size="mini" type="danger" icon="el-icon-delete" circle @click="removeCarousel(index)" />
+        </div>
+
+        <!-- 图片上传 -->
+        <div class="carousel-body">
+          <div class="image-upload-area">
+            <div class="upload-box" @click="triggerUpload('carousel', index)">
+              <img v-if="item.imageUrl" :src="item.imageUrl" class="preview-img" />
+              <div v-else class="upload-placeholder">
+                <i class="el-icon-picture"></i>
+                <span>未上传</span>
+              </div>
+              <div class="upload-overlay">
+                <i class="el-icon-upload2"></i>
+                <span>点击上传</span>
+              </div>
+            </div>
+            <div class="upload-tip">建议图片尺寸:<b>686 × 296 px</b></div>
+            <input :ref="'carouselUpload' + index" type="file" accept="image/*" style="display:none" @change="onImageUpload($event, 'carousel', index)" />
+          </div>
+
+          <!-- 标题 -->
+          <el-form label-width="80px" style="margin-top:12px">
+            <el-form-item label="主标题">
+              <el-input v-model="item.title" @input="markModified" />
+            </el-form-item>
+            <el-form-item label="副标题">
+              <el-input v-model="item.subtitle" @input="markModified" />
+            </el-form-item>
+          </el-form>
+
+          <!-- 可见人群标签 -->
+          <el-form label-width="110px" style="margin-top:4px">
+            <el-form-item label="可见人群标签">
+              <el-select v-model="item.tags" multiple placeholder="点击选择..." filterable @change="markModified" style="width:100%">
+                <el-option v-for="tag in availableTags" :key="tag.id" :label="tag.name" :value="tag.id" />
+              </el-select>
+            </el-form-item>
+          </el-form>
+
+          <!-- 跳转链接配置 -->
+          <div class="link-config-block">
+            <div class="link-config-title"><i class="el-icon-link"></i> 跳转链接配置</div>
+            <el-form label-width="140px">
+              <el-form-item label="H5 跳转链接">
+                <el-input v-model="item.linkH5" @input="markModified" />
+              </el-form-item>
+              <el-form-item label="APP 跳转链接">
+                <el-input v-model="item.linkApp" @input="markModified" />
+              </el-form-item>
+              <el-row :gutter="20">
+                <el-col :span="12">
+                  <el-form-item label="微信小程序 AppID">
+                    <el-input v-model="item.wechatAppId" placeholder="例如:wx1234567890abcdef" @input="markModified" />
+                  </el-form-item>
+                </el-col>
+                <el-col :span="12">
+                  <el-form-item label="微信小程序页面路径">
+                    <el-input v-model="item.wechatPath" placeholder="例如:pages/index/index" @input="markModified" />
+                  </el-form-item>
+                </el-col>
+              </el-row>
+            </el-form>
+          </div>
+        </div>
+      </div>
+    </el-card>
+
+    <!-- 常用兑换配置 -->
+    <el-card class="section-card" shadow="hover">
+      <div slot="header" class="section-header">
+        <span><i class="el-icon-s-grid"></i> 常用兑换配置</span>
+        <el-button type="text" icon="el-icon-circle-plus-outline" @click="addExchangeItem" style="float:right">添加兑换项</el-button>
+      </div>
+      <div v-if="configData.exchangeConfig.items.length === 0" class="empty-tip">暂无常用兑换项</div>
+      <div v-for="(item, index) in configData.exchangeConfig.items" :key="'e' + index" class="exchange-item">
+        <div class="item-index">NO. {{ index + 1 }}</div>
+        <div class="item-actions">
+          <el-popover placement="bottom" trigger="click" width="180">
+            <div>
+              <div style="margin-bottom:8px;font-size:13px;color:#666">移动到序号:</div>
+              <div style="display:flex;gap:8px">
+                <el-input-number v-model="sortTarget" :min="1" :max="configData.exchangeConfig.items.length" size="mini" controls-position="right" style="width:100px" />
+                <el-button size="mini" type="primary" @click="moveItem('exchangeItems', index, sortTarget - 1)">确定</el-button>
+              </div>
+            </div>
+            <el-button slot="reference" size="mini" icon="el-icon-sort" circle @click="sortTarget = index + 1" />
+          </el-popover>
+          <el-button size="mini" type="danger" icon="el-icon-delete" circle @click="removeExchangeItem(index)" />
+        </div>
+        <div class="exchange-body">
+          <div class="exchange-image" @click="triggerUpload('exchange', index)">
+            <img v-if="item.imageUrl" :src="item.imageUrl" class="preview-img-sq" />
+            <div v-else class="upload-placeholder-sm">
+              <i class="el-icon-picture"></i>
+              <span>未上传</span>
+            </div>
+            <input :ref="'exchangeUpload' + index" type="file" accept="image/*" style="display:none" @change="onImageUpload($event, 'exchange', index)" />
+          </div>
+          <el-form label-width="80px" inline class="exchange-fields">
+            <el-form-item label="展示文字">
+              <el-input v-model="item.title" style="width:200px" @input="markModified" />
+            </el-form-item>
+            <el-form-item label="跳转链接">
+              <el-input v-model="item.link" style="width:280px" @input="markModified" />
+            </el-form-item>
+          </el-form>
+        </div>
+      </div>
+    </el-card>
+
+    <!-- 积分规则弹窗配置 -->
+    <el-card class="section-card" shadow="hover">
+      <div slot="header" class="section-header">
+        <i class="el-icon-document"></i> 积分规则弹窗配置
+      </div>
+      <el-form label-width="120px">
+        <el-form-item label="弹窗顶部名称">
+          <el-input v-model="configData.ruleModal.title" style="width:50%" @input="markModified" />
+        </el-form-item>
+        <el-form-item label="规则内容">
+          <quill-editor
+            v-model="configData.ruleModal.content"
+            :options="quillOptions"
+            @change="markModified"
+          />
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <!-- 获取链接弹窗 -->
+    <el-dialog title="获取页面链接" :visible.sync="linkDialogVisible" width="680px">
+      <el-form label-width="100px" inline>
+        <el-form-item label="Campaign ID">
+          <el-select v-model="linkForm.campaignId" clearable placeholder="请选择 (无)">
+            <el-option label="CAMP202511" value="CAMP202511" />
+            <el-option label="CAMP_WINTER" value="CAMP_WINTER" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="Channel ID">
+          <el-select v-model="linkForm.channelId" clearable placeholder="请选择 (无)">
+            <el-option label="APP推送" value="CH_APP" />
+            <el-option label="微信公众号" value="CH_WECHAT" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <div v-if="pageStatus === 'published'" class="link-section">
+        <div class="link-label"><span class="dot dot-green"></span> 正式环境链接</div>
+        <div class="link-row">
+          <span class="link-type">H5 链接</span>
+          <el-input :value="prodH5Url" readonly size="small" />
+          <el-button size="small" type="primary" plain @click="copyLink(prodH5Url)">复制</el-button>
+        </div>
+        <div class="link-row">
+          <span class="link-type">小程序/APP</span>
+          <el-input :value="prodAppUrl" readonly size="small" />
+          <el-button size="small" type="primary" plain @click="copyLink(prodAppUrl)">复制</el-button>
+        </div>
+      </div>
+      <div class="link-section">
+        <div class="link-label"><span class="dot dot-blue"></span> 草稿预览链接 (测试)</div>
+        <div class="link-row">
+          <el-input :value="testUrl" readonly size="small" />
+          <el-button size="small" type="primary" plain @click="copyLink(testUrl)">复制</el-button>
+        </div>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import request from '@/utils/request'
+
+export default {
+  name: 'PageZoneConfig',
+  data() {
+    return {
+      configData: {
+        adCarousel: [],
+        exchangeConfig: { items: [] },
+        ruleModal: { title: '', content: '' }
+      },
+      availableTags: [],
+      pageStatus: 'draft',
+      dataLoaded: false,
+      sortTarget: 1,
+      linkDialogVisible: false,
+      linkForm: { campaignId: '', channelId: '' },
+      quillOptions: {
+        placeholder: '请输入积分规则内容...',
+        modules: {
+          toolbar: [
+            ['bold', 'italic', 'underline'],
+            [{ list: 'ordered' }, { list: 'bullet' }],
+            [{ header: [1, 2, 3, false] }],
+            ['link', 'image'],
+            ['clean']
+          ]
+        }
+      }
+    }
+  },
+  computed: {
+    linkQueryStr() {
+      const params = []
+      if (this.linkForm.campaignId) params.push(`CampaignId=${this.linkForm.campaignId}`)
+      if (this.linkForm.channelId) params.push(`Channelid=${this.linkForm.channelId}`)
+      return params.length ? '?' + params.join('&') : ''
+    },
+    prodH5Url() {
+      return `https://hsbc.com/zone${this.linkQueryStr}`
+    },
+    prodAppUrl() {
+      return `hsbc-app://zone${this.linkQueryStr}`
+    },
+    testUrl() {
+      const q = this.linkQueryStr
+      return `https://test.hsbc.com/zone${q ? q + '&' : '?'}draft=1`
+    }
+  },
+  created() {
+    this.fetchData()
+  },
+  methods: {
+    async fetchData() {
+      const res = await request.get('/api/page-config/zone')
+      this.configData = res.data.config
+      this.availableTags = res.data.availableTags
+      this.pageStatus = res.data.status
+      this.$nextTick(() => { this.dataLoaded = true })
+    },
+    markModified() {
+      if (!this.dataLoaded) return
+      if (this.pageStatus === 'published') {
+        this.pageStatus = 'modified'
+      }
+    },
+    triggerUpload(type, index) {
+      const ref = this.$refs[type + 'Upload' + index]
+      if (ref) (Array.isArray(ref) ? ref[0] : ref).click()
+    },
+    onImageUpload(event, type, index) {
+      const file = event.target.files[0]
+      if (!file) return
+      const reader = new FileReader()
+      reader.onload = (e) => {
+        if (type === 'carousel') {
+          this.$set(this.configData.adCarousel[index], 'imageUrl', e.target.result)
+        } else {
+          this.$set(this.configData.exchangeConfig.items[index], 'imageUrl', e.target.result)
+        }
+        this.markModified()
+      }
+      reader.readAsDataURL(file)
+      event.target.value = ''
+    },
+    addCarousel() {
+      this.configData.adCarousel.push({
+        imageUrl: '', linkH5: '', linkApp: '', wechatAppId: '', wechatPath: '',
+        title: '新广告', subtitle: '', tags: []
+      })
+      this.markModified()
+    },
+    removeCarousel(index) {
+      this.configData.adCarousel.splice(index, 1)
+      this.markModified()
+    },
+    addExchangeItem() {
+      this.configData.exchangeConfig.items.push({ imageUrl: '', title: '新兑换项', link: '' })
+      this.markModified()
+    },
+    removeExchangeItem(index) {
+      this.configData.exchangeConfig.items.splice(index, 1)
+      this.markModified()
+    },
+    moveItem(type, from, to) {
+      const arr = type === 'adCarousel' ? this.configData.adCarousel : this.configData.exchangeConfig.items
+      if (to < 0 || to >= arr.length || from === to) return
+      const item = arr.splice(from, 1)[0]
+      arr.splice(to, 0, item)
+      this.markModified()
+    },
+    async handleSave(type) {
+      const status = type === 'publish' ? 'published' : 'draft'
+      await request.put('/api/page-config/zone', { config: this.configData, status })
+      this.pageStatus = status
+      this.$message.success(type === 'publish' ? '已发布权益首页配置!' : '已保存权益首页配置草稿!')
+    },
+    showLinkDialog() {
+      this.linkForm = { campaignId: '', channelId: '' }
+      this.linkDialogVisible = true
+    },
+    copyLink(text) {
+      navigator.clipboard.writeText(text).then(() => {
+        this.$message.success('链接已复制到剪贴板')
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.toolbar {
+  display: flex; justify-content: space-between; align-items: center;
+  margin-bottom: 16px; padding: 12px 16px;
+  background: #fff; border-radius: 4px; box-shadow: 0 1px 4px rgba(0,0,0,.06);
+}
+.toolbar-left { display: flex; align-items: center; gap: 12px; }
+.toolbar-title { margin: 0; font-size: 18px; font-weight: 600; }
+.toolbar-right { display: flex; gap: 8px; }
+
+.section-card { margin-bottom: 16px; }
+.section-header { font-weight: 600; }
+.section-header i { margin-right: 6px; color: #909399; }
+
+.carousel-item, .exchange-item {
+  position: relative; border: 1px solid #ebeef5; border-radius: 8px;
+  padding: 24px 16px 16px; margin-bottom: 16px; background: #fff;
+}
+.item-index {
+  position: absolute; left: 0; top: 0;
+  background: #409EFF; color: #fff; font-size: 11px; font-weight: 700;
+  padding: 2px 10px; border-radius: 8px 0 8px 0;
+}
+.item-actions {
+  position: absolute; right: 12px; top: 12px;
+  display: flex; gap: 6px;
+}
+
+.image-upload-area { display: flex; align-items: center; gap: 16px; }
+.upload-box {
+  width: 256px; height: 144px;
+  background: #eef6fc; border: 1px solid #dcdfe6; border-radius: 4px;
+  position: relative; overflow: hidden; cursor: pointer;
+  display: flex; align-items: center; justify-content: center; flex-shrink: 0;
+}
+.upload-box:hover .upload-overlay { display: flex; }
+.preview-img { width: 100%; height: 100%; object-fit: cover; }
+.upload-placeholder {
+  display: flex; flex-direction: column; align-items: center;
+  color: #c0c4cc; font-size: 12px;
+}
+.upload-placeholder i { font-size: 28px; margin-bottom: 4px; }
+.upload-overlay {
+  display: none; position: absolute; inset: 0;
+  background: rgba(0,0,0,.5); color: #fff;
+  flex-direction: column; align-items: center; justify-content: center; font-size: 14px;
+}
+.upload-overlay i { font-size: 24px; margin-bottom: 4px; }
+.upload-tip {
+  font-size: 12px; color: #909399;
+  background: #f5f7fa; padding: 8px 12px; border-radius: 4px; border: 1px solid #ebeef5;
+}
+
+.link-config-block {
+  background: #f5f7fa; padding: 16px; border-radius: 6px;
+  border: 1px solid #ebeef5; margin-top: 8px;
+}
+.link-config-title {
+  font-size: 13px; font-weight: 600; color: #606266;
+  border-bottom: 1px solid #ebeef5; padding-bottom: 8px; margin-bottom: 12px;
+}
+
+.exchange-body { display: flex; align-items: flex-start; gap: 16px; margin-top: 8px; }
+.exchange-image {
+  width: 80px; height: 80px; flex-shrink: 0;
+  background: #eef6fc; border: 1px solid #dcdfe6; border-radius: 4px;
+  display: flex; align-items: center; justify-content: center;
+  cursor: pointer; overflow: hidden;
+}
+.preview-img-sq { width: 100%; height: 100%; object-fit: cover; }
+.upload-placeholder-sm {
+  display: flex; flex-direction: column; align-items: center;
+  color: #c0c4cc; font-size: 10px;
+}
+.upload-placeholder-sm i { font-size: 18px; }
+.exchange-fields { flex: 1; margin-top: 8px; }
+
+.empty-tip {
+  text-align: center; color: #c0c4cc; padding: 24px;
+  border: 1px dashed #dcdfe6; border-radius: 4px;
+}
+
+.link-section { margin-top: 16px; padding: 12px; background: #fafafa; border-radius: 6px; border: 1px solid #ebeef5; }
+.link-section + .link-section { margin-top: 12px; }
+.link-label { font-weight: 600; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
+.link-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
+.link-type { font-size: 12px; color: #909399; width: 70px; flex-shrink: 0; }
+.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
+.dot-green { background: #67C23A; }
+.dot-blue { background: #409EFF; }
+</style>