zyy 1 месяц назад
Родитель
Сommit
8c4f3c2641

+ 129 - 0
src/api/decoration/decoration.js

@@ -0,0 +1,129 @@
+import request from '@/utils/request'
+
+// ==================== 组件类型 ====================
+
+/**
+ * 查询组件类型下拉
+ */
+export function listComponentTypeOptions() {
+  return request({
+    url: '/decoration/componentType/options',
+    method: 'get'
+  })
+}
+
+// ==================== 组件管理 ====================
+
+/**
+ * 查询组件列表
+ */
+export function listDecorationComponent(query) {
+  return request({
+    url: '/decoration/component/list',
+    method: 'get',
+    params: query
+  })
+}
+
+/**
+ * 查询组件详情
+ */
+export function getDecorationComponent(id) {
+  return request({
+    url: '/decoration/component/' + id,
+    method: 'get'
+  })
+}
+
+/**
+ * 新增/修改组件
+ */
+export function saveDecorationComponent(data) {
+  return request({
+    url: '/decoration/component/save',
+    method: 'post',
+    data: data
+  })
+}
+
+/**
+ * 删除组件
+ */
+export function delDecorationComponent(id) {
+  return request({
+    url: '/decoration/component/' + id,
+    method: 'delete'
+  })
+}
+
+/**
+ * 修改组件状态
+ */
+export function changeDecorationComponentStatus(data) {
+  return request({
+    url: '/decoration/component/changeStatus',
+    method: 'post',
+    data: data
+  })
+}
+
+// ==================== 模板管理 ====================
+
+/**
+ * 查询模板列表
+ */
+export function listDecorationTemplate() {
+  return request({
+    url: '/decoration/template/list',
+    method: 'get'
+  })
+}
+
+/**
+ * 查询模板详情
+ */
+export function getDecorationTemplate(id) {
+  return request({
+    url: '/decoration/template/' + id,
+    method: 'get'
+  })
+}
+
+/**
+ * 新增/修改模板
+ */
+export function saveDecorationTemplate(data) {
+  return request({
+    url: '/decoration/template/save',
+    method: 'post',
+    data: data
+  })
+}
+
+/**
+ * 删除模板
+ */
+export function delDecorationTemplate(id) {
+  return request({
+    url: '/decoration/template/' + id,
+    method: 'delete'
+  })
+}
+
+// 当前公司使用某个模板
+export function useDecorationTemplate(data) {
+  return request({
+    url: '/decoration/companyTemplate/use',
+    method: 'post',
+    data: data
+  })
+}
+
+// 查询当前公司某个类型正在使用的模板
+export function getCurrentCompanyTemplate(query) {
+  return request({
+    url: '/decoration/companyTemplate/current',
+    method: 'get',
+    params: query
+  })
+}

+ 16 - 0
src/components/decoration/BannerPreview.vue

@@ -0,0 +1,16 @@
+<template>
+  <div class="banner-preview"><img src="https://picsum.photos/120/80?random=11"></div>
+</template>
+
+<style scoped>
+.banner-preview {
+  height: 80px;
+  background: linear-gradient(135deg, #d3eafd, #91caff);
+  border-radius: 8px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #1d39c4;
+  font-size: 14px;
+}
+</style>

+ 15 - 0
src/components/decoration/BottomCtaPreview.vue

@@ -0,0 +1,15 @@
+<template>
+  <div class="cta-preview">底部按钮</div>
+</template>
+
+<style scoped>
+.cta-preview {
+  height: 48px;
+  background: #111;
+  border-radius: 24px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #fff;
+}
+</style>

+ 15 - 0
src/components/decoration/BrandStoryPreview.vue

@@ -0,0 +1,15 @@
+<template>
+  <div class="brand-preview">品牌故事</div>
+</template>
+
+<style scoped>
+.brand-preview {
+  height: 80px;
+  background: #f9f0ff;
+  border-radius: 8px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #722ed1;
+}
+</style>

+ 16 - 0
src/components/decoration/CouponCardPreview.vue

@@ -0,0 +1,16 @@
+<template>
+  <div class="coupon-preview">优惠券卡片</div>
+</template>
+
+<style scoped>
+.coupon-preview {
+  height: 70px;
+  background: #fff1f0;
+  border: 1px dashed #ff7875;
+  border-radius: 8px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #cf1322;
+}
+</style>

+ 18 - 0
src/components/decoration/GoodsListPreview.vue

@@ -0,0 +1,18 @@
+<template>
+  <div class="goods-preview">
+    <div class="goods-item" v-for="i in 3" :key="i"></div>
+  </div>
+</template>
+
+<style scoped>
+.goods-preview {
+  display: flex;
+  gap: 6px;
+}
+.goods-item {
+  flex: 1;
+  height: 70px;
+  background: #f5f5f5;
+  border-radius: 6px;
+}
+</style>

+ 16 - 0
src/components/decoration/NoticeBarPreview.vue

@@ -0,0 +1,16 @@
+<template>
+  <div class="notice-preview">公告栏预览</div>
+</template>
+
+<style scoped>
+.notice-preview {
+  height: 40px;
+  background: #fff7e6;
+  border-radius: 20px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #d48806;
+  font-size: 13px;
+}
+</style>

+ 19 - 0
src/router/index.js

@@ -419,6 +419,25 @@ export const constantRoutes = [
                 }
             }
         ]
+    },
+    {
+        path: '/decoration/template',
+        component: Layout,
+        children: [
+            {
+                path: 'list',
+                component: () => import('@/views/decoration/templateList'),
+                name: 'DecorationTemplateList',
+                meta: { title: '模板管理' }
+            },
+            {
+                path: 'editor',
+                component: () => import('@/views/decoration/templateEditor'),
+                name: 'DecorationTemplateEditor',
+                meta: { title: '模板编辑' },
+                hidden: true
+            }
+        ]
     }
 
 ]

+ 272 - 0
src/views/decoration/componentList.vue

@@ -0,0 +1,272 @@
+<template>
+  <div class="app-container">
+    <!-- 查询区域 -->
+    <el-form :model="queryParams" size="small" inline>
+      <el-form-item label="组件名称">
+        <el-input v-model="queryParams.componentName" placeholder="请输入组件名称" clearable />
+      </el-form-item>
+
+      <el-form-item label="组件类型">
+        <el-select v-model="queryParams.componentTypeCode" placeholder="请选择组件类型" clearable>
+          <el-option
+            v-for="item in typeOptions"
+            :key="item.value"
+            :label="item.label"
+            :value="item.value"
+          />
+        </el-select>
+      </el-form-item>
+
+      <el-form-item label="状态">
+        <el-select v-model="queryParams.status" placeholder="请选择状态" clearable>
+          <el-option label="启用" :value="1" />
+          <el-option label="停用" :value="0" />
+        </el-select>
+      </el-form-item>
+
+      <el-form-item>
+        <el-button type="primary" size="mini" @click="getList">查询</el-button>
+        <el-button size="mini" @click="resetQuery">重置</el-button>
+        <el-button type="success" size="mini" @click="handleAdd">新增组件</el-button>
+      </el-form-item>
+    </el-form>
+
+    <!-- 表格区域 -->
+    <el-table :data="componentList" v-loading="loading" border>
+      <el-table-column label="ID" prop="id" width="70" />
+
+      <el-table-column label="缩略图" width="100">
+        <template slot-scope="scope">
+          <img
+            v-if="scope.row.previewImage"
+            :src="scope.row.previewImage"
+            style="width: 70px; height: 45px; object-fit: cover; border-radius: 4px;"
+          />
+          <i
+            v-else
+            :class="scope.row.icon || 'el-icon-picture'"
+            style="font-size: 22px;"
+          />
+        </template>
+      </el-table-column>
+
+      <el-table-column label="组件编码" prop="componentCode" />
+      <el-table-column label="组件名称" prop="componentName" />
+      <el-table-column label="组件类型" prop="componentTypeCode" />
+      <el-table-column label="状态" width="120">
+        <template slot-scope="scope">
+          <el-switch
+            :value="scope.row.status === 1"
+            @change="val => handleChangeStatus(scope.row, val)"
+          />
+        </template>
+      </el-table-column>
+      <el-table-column label="备注" prop="remark" />
+
+      <el-table-column label="操作" width="220">
+        <template slot-scope="scope">
+          <el-button type="text" size="mini" @click="handleEdit(scope.row)">修改</el-button>
+          <el-button type="text" size="mini" style="color:#F56C6C" @click="handleDelete(scope.row)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <!-- 新增/修改弹窗 -->
+    <el-dialog :title="title" :visible.sync="open" width="650px" append-to-body>
+      <el-form :model="form" label-width="100px">
+        <el-form-item label="组件编码">
+          <el-input v-model="form.componentCode" placeholder="请输入组件编码" />
+        </el-form-item>
+
+        <el-form-item label="组件名称">
+          <el-input v-model="form.componentName" placeholder="请输入组件名称" />
+        </el-form-item>
+
+        <el-form-item label="组件类型">
+          <el-select v-model="form.componentTypeCode" placeholder="请选择组件类型" style="width:100%">
+            <el-option
+              v-for="item in typeOptions"
+              :key="item.value"
+              :label="item.label"
+              :value="item.value"
+            />
+          </el-select>
+        </el-form-item>
+
+        <el-form-item label="图标">
+          <el-input v-model="form.icon" placeholder="请输入 icon" />
+        </el-form-item>
+
+        <el-form-item label="缩略图">
+          <el-input v-model="form.previewImage" placeholder="请输入缩略图地址" />
+        </el-form-item>
+
+        <el-form-item label="状态">
+          <el-radio-group v-model="form.status">
+            <el-radio :label="1">启用</el-radio>
+            <el-radio :label="0">停用</el-radio>
+          </el-radio-group>
+        </el-form-item>
+
+        <el-form-item label="备注">
+          <el-input v-model="form.remark" type="textarea" placeholder="请输入备注" />
+        </el-form-item>
+      </el-form>
+
+      <div slot="footer">
+        <el-button size="mini" type="primary" @click="submitForm">确 定</el-button>
+        <el-button size="mini" @click="open = false">取 消</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import {
+  listComponentTypeOptions,
+  listDecorationComponent,
+  getDecorationComponent,
+  saveDecorationComponent,
+  delDecorationComponent,
+  changeDecorationComponentStatus
+} from '@/api/decoration/decoration'
+
+export default {
+  name: 'DecorationComponentList',
+  data() {
+    return {
+      loading: false,
+      open: false,
+      title: '',
+      typeOptions: [],
+      componentList: [],
+      queryParams: {
+        componentName: '',
+        componentTypeCode: '',
+        status: null
+      },
+      form: {
+        id: null,
+        componentCode: '',
+        componentName: '',
+        componentTypeCode: '',
+        icon: '',
+        previewImage: '',
+        status: 1,
+        remark: ''
+      }
+    }
+  },
+  created() {
+    this.getTypeOptions()
+    this.getList()
+  },
+  methods: {
+    /**
+     * 加载组件类型下拉
+     */
+    getTypeOptions() {
+      listComponentTypeOptions().then(res => {
+        this.typeOptions = res.data || []
+      })
+    },
+
+    /**
+     * 查询组件列表
+     */
+    getList() {
+      this.loading = true
+      listDecorationComponent(this.queryParams).then(res => {
+        this.componentList = res.data || []
+        this.loading = false
+      }).catch(() => {
+        this.loading = false
+      })
+    },
+
+    /**
+     * 重置查询条件
+     */
+    resetQuery() {
+      this.queryParams = {
+        componentName: '',
+        componentTypeCode: '',
+        status: null
+      }
+      this.getList()
+    },
+
+    /**
+     * 重置表单
+     */
+    resetForm() {
+      this.form = {
+        id: null,
+        componentCode: '',
+        componentName: '',
+        componentTypeCode: '',
+        icon: '',
+        previewImage: '',
+        status: 1,
+        remark: ''
+      }
+    },
+
+    /**
+     * 新增组件
+     */
+    handleAdd() {
+      this.resetForm()
+      this.title = '新增组件'
+      this.open = true
+    },
+
+    /**
+     * 修改组件
+     */
+    handleEdit(row) {
+      getDecorationComponent(row.id).then(res => {
+        this.form = res.data
+        this.title = '修改组件'
+        this.open = true
+      })
+    },
+
+    /**
+     * 提交表单
+     */
+    submitForm() {
+      saveDecorationComponent(this.form).then(() => {
+        this.$message.success('保存成功')
+        this.open = false
+        this.getList()
+      })
+    },
+
+    /**
+     * 删除组件
+     */
+    handleDelete(row) {
+      this.$confirm('确认删除该组件吗?', '提示', { type: 'warning' }).then(() => {
+        return delDecorationComponent(row.id)
+      }).then(() => {
+        this.$message.success('删除成功')
+        this.getList()
+      })
+    },
+
+    /**
+     * 修改组件状态
+     */
+    handleChangeStatus(row, val) {
+      changeDecorationComponentStatus({
+        id: row.id,
+        status: val ? 1 : 0
+      }).then(() => {
+        this.$message.success('状态修改成功')
+        this.getList()
+      })
+    }
+  }
+}
+</script>

+ 810 - 0
src/views/decoration/templateEditor.vue

@@ -0,0 +1,810 @@
+<template>
+  <div class="app-container template-editor-page">
+    <!-- 顶部标题栏 -->
+    <div class="editor-page-header">
+      <div class="header-left">
+        <div class="page-title">{{ isEdit ? '编辑模板' : '新增模板' }}</div>
+        <div class="page-desc">左侧选择组件,中间预览,右侧操作组件顺序</div>
+      </div>
+
+      <div class="header-right">
+        <el-button size="mini" @click="goBack">返回</el-button>
+        <el-button size="mini" type="primary" @click="submitForm">保存模板</el-button>
+      </div>
+    </div>
+
+    <!-- 顶部表单区域 -->
+    <div class="template-base-form">
+      <el-form :model="form" label-width="90px" size="small">
+        <el-row :gutter="16">
+          <el-col :span="8">
+            <el-form-item label="模板名称">
+              <el-input v-model="form.templateName" placeholder="请输入模板名称" />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="8">
+            <el-form-item label="模板类型" prop="templateType">
+               <el-radio-group v-model="form.templateType">
+                   <el-radio label="home">首页模板</el-radio>
+                   <el-radio label="user">个人中心模板</el-radio>
+               </el-radio-group>
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="8">
+            <el-form-item label="模板封面">
+              <el-input v-model="form.coverUrl" placeholder="请输入模板封面地址" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <el-row :gutter="16">
+          <el-col :span="8">
+            <el-form-item label="状态">
+              <el-radio-group v-model="form.status">
+                <el-radio :label="1">启用</el-radio>
+                <el-radio :label="0">停用</el-radio>
+              </el-radio-group>
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="16">
+            <el-form-item label="备注">
+              <el-input v-model="form.remark" placeholder="请输入备注" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+      </el-form>
+    </div>
+
+    <!-- 编辑器主体 -->
+    <div class="editor-main">
+      <!-- 左侧组件库 -->
+      <div class="component-library">
+        <div class="panel-title">组件库</div>
+
+        <el-select
+          v-model="componentQuery.componentTypeCode"
+          placeholder="组件类型"
+          clearable
+          size="mini"
+          style="width: 100%; margin-bottom: 10px;"
+          @change="loadComponentList"
+        >
+          <el-option
+            v-for="item in typeOptions"
+            :key="item.value"
+            :label="item.label"
+            :value="item.value"
+          />
+        </el-select>
+
+        <div class="component-card-list">
+          <div
+            v-for="item in componentList"
+            :key="item.id"
+            class="component-card"
+            :class="{ disabled: usedComponentCodes.includes(item.componentCode) }"
+            @click="addComponent(item)"
+          >
+            <img
+              v-if="item.previewImage"
+              :src="item.previewImage"
+              class="component-thumb"
+            />
+            <div v-else class="component-thumb icon-thumb">
+              <i :class="item.icon || 'el-icon-picture'"></i>
+            </div>
+            <div class="component-name">{{ item.componentName }}</div>
+          </div>
+        </div>
+      </div>
+
+      <!-- 中间手机画布 -->
+      <div class="canvas-panel">
+        <div class="panel-title">页面画布(手机预览)</div>
+
+        <div class="phone-frame modern-phone">
+          <div class="phone-status-bar modern-status-bar">
+            <span class="status-time">9:41</span>
+            <div class="status-icons">
+              <span class="signal-bars">
+                <i></i><i></i><i></i><i></i>
+              </span>
+              <span class="wifi-icon">◜◝</span>
+              <span class="battery-icon">
+                <span class="battery-body"></span>
+                <span class="battery-cap"></span>
+              </span>
+            </div>
+          </div>
+
+          <div class="phone-screen modern-phone-screen">
+              <draggable
+                  v-model="form.components"
+                  class="canvas-list"
+                  animation="200"
+                  :move="checkMove"
+                  @end="normalizeComponentsOrder"
+              >
+              <transition-group>
+                <div
+                  v-for="(item, index) in form.components"
+                  :key="item.id"
+                  class="canvas-item"
+                  :class="{ active: selectedIndex === index }"
+                  @click.stop="selectComponent(index)"
+                >
+                  <component
+                    :is="getPreviewComponent(item.componentCode)"
+                    class="preview-runtime"
+                  />
+                </div>
+              </transition-group>
+            </draggable>
+
+            <div v-if="!form.components.length" class="empty-tip">
+              请从左侧点击组件添加到页面
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <!-- 右侧组件操作 -->
+      <div class="config-panel">
+        <div class="panel-title">组件操作</div>
+
+        <div v-if="selectedComponent" class="config-box">
+          <div class="config-row">
+            <span class="label">组件编码:</span>
+            <span class="value">{{ selectedComponent.componentCode }}</span>
+          </div>
+
+          <div class="config-row">
+            <span class="label">当前位置:</span>
+            <span class="value">第 {{ selectedIndex + 1 }} 个</span>
+          </div>
+
+          <div class="config-actions">
+              <el-button
+                  size="mini"
+                  @click="moveUp"
+                  :disabled="
+                    selectedIndex <= 0 ||
+                    selectedComponent.componentCode === 'Banner' ||
+                    selectedComponent.componentCode === 'RecommendedProducts'"
+              >
+                  上移
+              </el-button>
+              <el-button
+                  size="mini"
+                  @click="moveDown"
+                  :disabled="
+                    selectedIndex >= form.components.length - 1 ||
+                    selectedComponent.componentCode === 'Banner' ||
+                    selectedComponent.componentCode === 'RecommendedProducts'"
+              >
+                  下移
+              </el-button>
+
+            <el-button
+              size="mini"
+              type="danger"
+              @click="removeSelected"
+            >
+              删除组件
+            </el-button>
+          </div>
+        </div>
+
+        <div v-else class="config-empty">
+          请点击中间画布中的组件
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import draggable from 'vuedraggable'
+import {
+  getDecorationTemplate,
+  saveDecorationTemplate,
+  listDecorationComponent,
+  listComponentTypeOptions
+} from '@/api/decoration/decoration'
+
+import BannerPreview from '@/components/decoration/BannerPreview.vue'
+import NoticeBarPreview from '@/components/decoration/NoticeBarPreview.vue'
+import GoodsListPreview from '@/components/decoration/GoodsListPreview.vue'
+import CouponCardPreview from '@/components/decoration/CouponCardPreview.vue'
+import BrandStoryPreview from '@/components/decoration/BrandStoryPreview.vue'
+import BottomCtaPreview from '@/components/decoration/BottomCtaPreview.vue'
+
+export default {
+  name: 'DecorationTemplateEditor',
+  components: {
+    draggable
+  },
+  data() {
+    return {
+      selectedIndex: -1,
+      typeOptions: [],
+      componentList: [],
+      componentQuery: {
+        componentTypeCode: '',
+        status: 1
+      },
+      form: {
+        id: null,
+        templateName: '',
+        templateType: 'home',
+        coverUrl: '',
+        status: 1,
+        remark: '',
+        components: []
+      }
+    }
+  },
+  computed: {
+    isEdit() {
+        return !!this.$route.query.id
+    },
+    selectedComponent() {
+        if (this.selectedIndex < 0) return null
+        return this.form.components[this.selectedIndex] || null
+    },
+    usedComponentCodes() {
+        return this.form.components.map(item => item.componentCode)
+    }
+  },
+  created() {
+    this.loadTypeOptions()
+    this.loadComponentList()
+    this.loadDetail()
+  },
+  methods: {
+    goBack() {
+      this.$router.push('/decoration/template/list')
+    },
+
+    loadTypeOptions() {
+      listComponentTypeOptions().then(res => {
+        this.typeOptions = res.data || []
+      })
+    },
+
+    loadComponentList() {
+      listDecorationComponent(this.componentQuery).then(res => {
+        this.componentList = res.data || []
+      })
+    },
+
+    loadDetail() {
+      const id = this.$route.query.id
+      if (!id) return
+
+      getDecorationTemplate(id).then(res => {
+        const data = res.data || {}
+        const templateData = JSON.parse(data.templateData || '{}')
+
+        this.form = {
+          id: data.id,
+          templateName: data.templateName,
+          templateType: data.templateType,
+          coverUrl: data.coverUrl,
+          status: data.status,
+          remark: data.remark,
+          components: templateData.components || []
+        }
+      })
+    },
+
+    selectComponent(index) {
+      this.selectedIndex = index
+    },
+
+    removeSelected() {
+      if (this.selectedIndex < 0) return
+      this.form.components.splice(this.selectedIndex, 1)
+      this.selectedIndex = -1
+    },
+
+
+    getPreviewComponent(code) {
+      const map = {
+        Banner: BannerPreview,
+        MenuBar: NoticeBarPreview,
+        FlashSale: GoodsListPreview,
+        TodayGroupDeals: CouponCardPreview,
+        LimitedTimeDiscount: BrandStoryPreview,
+        TrendingList: BottomCtaPreview,
+        RecommendedProducts: GoodsListPreview
+      }
+      return map[code] || BannerPreview
+    },
+
+    isFixedTop(componentCode) {
+      return componentCode === 'Banner'
+    },
+
+    isFixedBottom(componentCode) {
+      return componentCode === 'RecommendedProducts'
+    },
+
+    addComponent(item) {
+      // 每个组件只能添加一次
+      if (this.usedComponentCodes.includes(item.componentCode)) {
+          this.$message.warning('该组件已经添加过了,不能重复添加')
+          return
+      }
+
+      this.form.components.push({
+          id: 'cmp_' + Date.now() + '_' + Math.floor(Math.random() * 10000),
+          componentCode: item.componentCode,
+          props: {}
+      })
+
+      this.normalizeComponentsOrder()
+    },
+
+    normalizeComponentsOrder() {
+      const oldSelected = this.selectedComponent ? this.selectedComponent.id : null
+
+      const list = this.form.components || []
+
+      const topList = []
+      const middleList = []
+      const bottomList = []
+
+      list.forEach(item => {
+          if (this.isFixedTop(item.componentCode)) {
+              topList.push(item)
+          } else if (this.isFixedBottom(item.componentCode)) {
+              bottomList.push(item)
+          } else {
+              middleList.push(item)
+          }
+      })
+
+      this.form.components = topList.concat(middleList, bottomList)
+
+      if (oldSelected) {
+          this.selectedIndex = this.form.components.findIndex(item => item.id === oldSelected)
+      }
+    },
+
+    checkMove(evt) {
+      const dragged = evt.draggedContext && evt.draggedContext.element
+      const related = evt.relatedContext && evt.relatedContext.element
+
+      if (!dragged) return true
+
+      // Banner 不允许被拖动到非顶部
+      if (this.isFixedTop(dragged.componentCode)) {
+          return false
+      }
+
+      // RecommendedProducts 不允许被拖动到非底部
+      if (this.isFixedBottom(dragged.componentCode)) {
+          return false
+      }
+
+      // 其他组件不能拖到 Banner 上面
+      if (related && this.isFixedTop(related.componentCode)) {
+          return false
+      }
+
+      // 其他组件不能拖到 RecommendedProducts 下面
+      if (related && this.isFixedBottom(related.componentCode)) {
+          return false
+      }
+
+      return true
+    },
+
+    moveUp() {
+      if (this.selectedIndex <= 0) return
+
+      const current = this.form.components[this.selectedIndex]
+      const prev = this.form.components[this.selectedIndex - 1]
+
+      if (this.isFixedTop(current.componentCode)) {
+          this.$message.warning('轮播图必须在最上面')
+          return
+      }
+
+      if (prev && this.isFixedTop(prev.componentCode)) {
+          this.$message.warning('不能移动到轮播图上方')
+          return
+      }
+
+      const arr = this.form.components
+      this.$set(arr, this.selectedIndex, prev)
+      this.$set(arr, this.selectedIndex - 1, current)
+      this.selectedIndex = this.selectedIndex - 1
+
+      this.normalizeComponentsOrder()
+    },
+
+    moveDown() {
+      if (this.selectedIndex < 0 || this.selectedIndex >= this.form.components.length - 1) return
+
+      const current = this.form.components[this.selectedIndex]
+      const next = this.form.components[this.selectedIndex + 1]
+
+      if (this.isFixedBottom(current.componentCode)) {
+          this.$message.warning('推荐商品必须在最下面')
+          return
+      }
+
+      if (next && this.isFixedBottom(next.componentCode)) {
+          this.$message.warning('不能移动到推荐商品下方')
+          return
+      }
+
+      const arr = this.form.components
+      this.$set(arr, this.selectedIndex, next)
+      this.$set(arr, this.selectedIndex + 1, current)
+      this.selectedIndex = this.selectedIndex + 1
+
+      this.normalizeComponentsOrder()
+    },
+
+    submitForm() {
+      // 保存前再兜底整理一次顺序
+      this.normalizeComponentsOrder()
+
+      const payload = {
+          id: this.form.id,
+          templateName: this.form.templateName,
+          templateType: this.form.templateType,
+          coverUrl: this.form.coverUrl,
+          status: this.form.status,
+          remark: this.form.remark,
+          templateData: JSON.stringify({
+              pageConfig: {
+                  templateName: this.form.templateName
+              },
+              components: this.form.components
+          })
+      }
+
+      saveDecorationTemplate(payload).then(() => {
+          this.$message.success('保存成功')
+          this.goBack()
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.template-editor-page {
+  height: calc(100vh - 84px);
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+
+/* 页面顶部 */
+.editor-page-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16px;
+  flex-shrink: 0;
+}
+
+.page-title {
+  font-size: 20px;
+  font-weight: 600;
+  color: #303133;
+}
+
+.page-desc {
+  font-size: 12px;
+  color: #909399;
+  margin-top: 4px;
+}
+
+/* 顶部表单 */
+.template-base-form {
+  background: #fff;
+  padding: 16px 16px 2px;
+  border-radius: 8px;
+  margin-bottom: 16px;
+  flex-shrink: 0;
+}
+
+/* 主体三栏 */
+.editor-main {
+  flex: 1;
+  display: flex;
+  gap: 16px;
+  min-height: 0;
+  overflow: hidden;
+}
+
+/* 左侧组件库 */
+.component-library {
+  width: 280px;
+  border: 1px solid #ebeef5;
+  padding: 12px;
+  overflow-y: auto;
+  background: #fff;
+  border-radius: 8px;
+  flex-shrink: 0;
+}
+
+/* 中间画布 */
+.canvas-panel {
+  flex: 1;
+  padding: 12px;
+  background: #f5f7fa;
+  overflow: auto;
+  min-width: 0;
+  border-radius: 8px;
+}
+
+/* 右侧操作区 */
+.config-panel {
+  width: 240px;
+  border: 1px solid #ebeef5;
+  padding: 12px;
+  background: #fff;
+  overflow-y: auto;
+  border-radius: 8px;
+  flex-shrink: 0;
+}
+
+.panel-title {
+  font-size: 15px;
+  font-weight: bold;
+  margin-bottom: 12px;
+}
+
+/* 左侧组件卡片 */
+.component-card-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+}
+
+.component-card {
+  width: calc(50% - 5px);
+  border: 1px solid #dcdfe6;
+  border-radius: 6px;
+  padding: 8px;
+  cursor: pointer;
+  background: #fff;
+  transition: all 0.2s;
+}
+
+.component-card:hover {
+  border-color: #409EFF;
+  box-shadow: 0 2px 8px rgba(64, 158, 255, 0.15);
+}
+
+.component-thumb {
+  width: 100%;
+  height: 70px;
+  object-fit: cover;
+  border-radius: 4px;
+}
+
+.icon-thumb {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 26px;
+  background: #f5f7fa;
+}
+
+.component-name {
+  margin-top: 8px;
+  text-align: center;
+  font-size: 13px;
+}
+
+/* 手机 */
+.phone-frame.modern-phone {
+  width: 370px;
+  height: 760px;
+  margin: 0 auto;
+  background: #0f0f10;
+  border-radius: 42px;
+  padding: 10px;
+  box-sizing: border-box;
+  box-shadow:
+    0 18px 40px rgba(0, 0, 0, 0.18),
+    0 2px 8px rgba(0, 0, 0, 0.08);
+  position: relative;
+  border: 2px solid #1b1b1d;
+}
+
+.modern-status-bar {
+  height: 28px;
+  padding: 0 16px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  color: #111;
+  font-size: 12px;
+  font-weight: 600;
+  background: #fff;
+  border-top-left-radius: 32px;
+  border-top-right-radius: 32px;
+  box-sizing: border-box;
+}
+
+.status-time {
+  color: #222;
+  font-size: 12px;
+  font-weight: 600;
+}
+
+.status-icons {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.signal-bars {
+  display: flex;
+  align-items: flex-end;
+  gap: 2px;
+  height: 12px;
+}
+
+.signal-bars i {
+  display: block;
+  width: 2px;
+  background: #222;
+  border-radius: 1px;
+}
+
+.signal-bars i:nth-child(1) { height: 4px; }
+.signal-bars i:nth-child(2) { height: 6px; }
+.signal-bars i:nth-child(3) { height: 8px; }
+.signal-bars i:nth-child(4) { height: 10px; }
+
+.wifi-icon {
+  font-size: 10px;
+  color: #222;
+  line-height: 1;
+}
+
+.battery-icon {
+  display: flex;
+  align-items: center;
+  gap: 2px;
+}
+
+.battery-body {
+  width: 18px;
+  height: 9px;
+  border: 1.5px solid #222;
+  border-radius: 2px;
+  position: relative;
+  display: inline-block;
+}
+
+.battery-body::after {
+  content: '';
+  position: absolute;
+  left: 1px;
+  top: 1px;
+  width: 11px;
+  height: 5px;
+  background: #9adf7f;
+  border-radius: 1px;
+}
+
+.battery-cap {
+  width: 2px;
+  height: 5px;
+  background: #222;
+  border-radius: 1px;
+  display: inline-block;
+}
+
+.modern-phone-screen {
+  height: calc(100% - 28px);
+  background: #fff;
+  border-bottom-left-radius: 32px;
+  border-bottom-right-radius: 32px;
+  overflow-y: auto;
+  padding: 10px 10px 14px;
+  box-sizing: border-box;
+}
+
+.modern-phone-screen::-webkit-scrollbar {
+  width: 4px;
+}
+
+.modern-phone-screen::-webkit-scrollbar-thumb {
+  background: rgba(0, 0, 0, 0.15);
+  border-radius: 2px;
+}
+
+/* 画布组件 */
+.canvas-list {
+  min-height: 200px;
+}
+
+.canvas-item {
+  background: #fff;
+  border: 1px solid #ebeef5;
+  border-radius: 12px;
+  padding: 10px;
+  margin-bottom: 10px;
+  cursor: move;
+  transition: all 0.2s;
+}
+
+.canvas-item:hover {
+  border-color: #409EFF;
+  box-shadow: 0 2px 10px rgba(64, 158, 255, 0.12);
+}
+
+.canvas-item.active {
+  border-color: #409EFF;
+  box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.12);
+}
+
+.preview-runtime {
+  width: 100%;
+}
+
+.empty-tip {
+  text-align: center;
+  color: #909399;
+  padding: 40px 0;
+}
+
+/* 右侧操作面板 */
+.config-box {
+  border: 1px solid #ebeef5;
+  border-radius: 8px;
+  padding: 12px;
+  background: #fafafa;
+}
+
+.config-row {
+  margin-bottom: 12px;
+  line-height: 20px;
+}
+
+.config-row .label {
+  color: #909399;
+}
+
+.config-row .value {
+  color: #303133;
+  word-break: break-all;
+}
+
+.config-actions {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.config-empty {
+  color: #909399;
+  text-align: center;
+  padding: 40px 10px;
+}
+
+.component-card.disabled {
+    opacity: 0.45;
+    cursor: not-allowed;
+}
+
+.component-card.disabled:hover {
+    border-color: #dcdfe6;
+    box-shadow: none;
+}
+</style>

+ 187 - 0
src/views/decoration/templateList.vue

@@ -0,0 +1,187 @@
+<template>
+  <div class="app-container">
+    <el-button
+      type="success"
+      size="mini"
+      @click="handleAdd"
+      style="margin-bottom: 10px;"
+    >
+      新增模板
+    </el-button>
+
+    <el-table :data="templateList" v-loading="loading" border>
+      <el-table-column label="ID" prop="id" width="70" />
+      <el-table-column label="模板名称" prop="templateName" />
+      <el-table-column label="模板类型">
+        <template slot-scope="scope">
+            <el-tag v-if="scope.row.templateType === 'home'">
+                首页模板
+            </el-tag>
+
+            <el-tag
+                type="success"
+                v-else-if="scope.row.templateType === 'user'"
+            >
+                个人中心
+            </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="状态" width="100">
+        <template slot-scope="scope">
+          {{ scope.row.status === 1 ? '启用' : '停用' }}
+        </template>
+      </el-table-column>
+      <el-table-column label="备注" prop="remark" />
+      <el-table-column label="操作" width="340">
+        <template slot-scope="scope">
+          <el-button type="text" size="mini" @click="handleEdit(scope.row)"> 编辑 </el-button>
+
+          <el-button type="text" size="mini" @click="handlePreview(scope.row)"> 查看JSON </el-button>
+
+          <el-tag v-if="currentTemplateMap[scope.row.templateType] === scope.row.id" type="success" size="mini"> 使用中 </el-tag>
+
+          <el-button v-else type="text" size="mini" @click="handleUseTemplate(scope.row)"> 使用当前模板 </el-button>
+
+          <el-button type="text" size="mini" style="color:#F56C6C" @click="handleDelete(scope.row)"> 删除 </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <!-- ✅ JSON预览弹窗(必须放在同一个根div里) -->
+    <el-dialog
+      title="模板JSON"
+      :visible.sync="jsonOpen"
+      width="80%"
+      top="5vh"
+    >
+      <pre class="json-preview">{{ currentJson }}</pre>
+
+      <div slot="footer">
+        <el-button size="mini" @click="jsonOpen = false">关 闭</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import {
+  listDecorationTemplate,
+  getDecorationTemplate,
+  delDecorationTemplate,
+  useDecorationTemplate,
+  getCurrentCompanyTemplate
+} from '@/api/decoration/decoration'
+
+export default {
+  name: 'DecorationTemplateList',
+  data() {
+    return {
+      loading: false,
+      templateList: [],
+      jsonOpen: false,
+      currentJson: '',
+      currentTemplateMap: {}
+    }
+  },
+  created() {
+    this.getList()
+    this.loadCurrentTemplates()
+  },
+  methods: {
+    getList() {
+      this.loading = true
+      listDecorationTemplate()
+        .then(res => {
+          this.templateList = res.data || []
+        })
+        .finally(() => {
+          this.loading = false
+        })
+    },
+
+    handleAdd() {
+      this.$router.push({
+        path: '/decoration/template/editor'
+      })
+    },
+
+    handleEdit(row) {
+      this.$router.push({
+        path: '/decoration/template/editor',
+        query: { id: row.id }
+      })
+    },
+
+    handlePreview(row) {
+      getDecorationTemplate(row.id).then(res => {
+        const data = res.data || {}
+
+        try {
+          this.currentJson = JSON.stringify(
+            JSON.parse(data.templateData || '{}'),
+            null,
+            2
+          )
+        } catch (e) {
+          this.currentJson = data.templateData || ''
+        }
+
+        this.jsonOpen = true
+      })
+    },
+
+    handleDelete(row) {
+      this.$confirm('确认删除该模板吗?', '提示', { type: 'warning' })
+        .then(() => delDecorationTemplate(row.id))
+        .then(() => {
+          this.$message.success('删除成功')
+          this.getList()
+        })
+    },
+
+    // 查询当前公司每种类型正在使用的模板
+    loadCurrentTemplates() {
+      const types = ['home', 'activity', 'user']
+
+      types.forEach(type => {
+        getCurrentCompanyTemplate({
+          templateType: type
+        }).then(res => {
+          this.$set(this.currentTemplateMap, type, res.data)
+        })
+      })
+    },
+
+// 使用当前模板
+    handleUseTemplate(row) {
+      this.$confirm('确认使用该模板吗?同类型模板会被替换。', '提示', {
+        type: 'warning'
+      }).then(() => {
+        return useDecorationTemplate({
+          templateId: row.id
+        })
+      }).then(() => {
+        this.$message.success('使用成功')
+        this.loadCurrentTemplates()
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.json-preview {
+  background: #1e1e1e;
+  color: #dcdcdc;
+  padding: 16px;
+  border-radius: 6px;
+  font-size: 13px;
+  line-height: 1.6;
+
+  max-height: 70vh;
+  overflow: auto;
+
+  white-space: pre-wrap;
+  word-break: break-all;
+}
+</style>

+ 1 - 0
src/views/taskStatistics/callLog/index.vue

@@ -132,6 +132,7 @@
             <el-table-column label="发送成功数量" align="center" prop="successCount" width="150"/>
             <el-table-column label="发送失败数量" align="center" prop="failCount" width="150"/>
             <el-table-column label="发送中数量" align="center" prop="runningCount" width="150"/>
+            <el-table-column label="接通成功率" align="center" prop="successRate" width="150"/>
             <!--      <el-table-column label="销售名称" align="center" prop="companyUserName" width="180" />-->
             <!--      <el-table-column label="花费金额" align="center" prop="cost" />-->
             <!--      <el-table-column label="手机号" align="center" prop="phone" />-->