|
|
@@ -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>
|