refactor(decoration): 重构店铺装修组件配置并规范化环境变量

- 重构装修组件配置,新增 icon 与 group 分类字段
- 将组件"图片"重命名为"热区","图片轮播"重命名为"轮播图"
- 新增搜索、公告、视频组件配置项
- 统一为各图片项添加 model 字段标识跳转模式
- 将装修页面组件间 pageGap 间距由 10 调整为 0
- 移除 im 模块中 VUE_APP_COMMON 变量,统一使用 VUE_APP_BUYER
- 为 im 环境变量与配置文件添加详细注释说明
This commit is contained in:
pikachu1995@126.com
2026-10-21 15:17:05 +08:00
parent da95f17885
commit f21ce6e252
118 changed files with 12064 additions and 1275 deletions

33
im/.env
View File

@@ -1,10 +1,37 @@
# ==============================================================================
# lilishop-im 生产环境默认环境变量配置
# 执行 npm run build 进行生产打包时,默认会加载此文件中的环境变量
# 注意:以 VUE_APP_ 开头的变量会被 webpack.DefinePlugin 静态嵌入客户端代码中
# 变量修改后需要重新构建/运行项目方可生效
#
# 【协议提示】
# - 线上生产环境配置了 SSL/HTTPS 证书时,WebSocket 使用 wss:// 协议
# - 本地开发环境未配置 SSL 证书时,WebSocket 必须使用 ws:// 协议,否则会连接失败
# ==============================================================================
# 当前运行环境模式:production (生产环境)
NODE_ENV=production
# 是否开启预览模式 (true: 开启, false: 关闭)
VUE_APP_PREVIEW=false
# IM 服务的 HTTP 接口请求基准地址 (用于登录认证、好友、群组、聊天记录查询等)
VUE_APP_API_BASE_URL=https://im-api.pickmall.cn
# IM 服务的 WebSocket 实时通讯地址 (支持 ws:// 或 wss://,生产环境已配置 SSL 证书使用 wss://;本地无证书环境必须使用 ws://)
VUE_APP_WEB_SOCKET_URL=wss://im-api.pickmall.cn/lili/webSocket
VUE_APP_COMMON=https://buyer-api.pickmall.cn
# 买家端业务接口基准地址 (用于获取买家用户信息、商品信息、订单信息等)
VUE_APP_BUYER=https://buyer-api.pickmall.cn
VUE_APP_SELLER=https://store-api.pickmall.cn
# 商家端业务接口基准地址 (用于获取店铺资料、商家客服相关信息等)
VUE_APP_SELLER=https://admin-api.pickmall.cn
# IM 网站/系统标题名称 (显示在浏览器标题栏与系统头部)
VUE_APP_WEBSITE_NAME="LiLi IM"
# PC 端商城前台首页及商品详情跳转前缀地址 (点击聊天中的商品卡片时跳转)
VUE_APP_PC_URL=https://pc-b2b2c.pickmall.cn/
VUE_APP_PC_STORE=https://store-b2b2c.pickmall.cn/
# PC 端商家管理后台跳转前缀地址 (点击聊天中的订单信息时跳转商家端订单页面)
VUE_APP_PC_STORE=https://store-b2b2c.pickmall.cn/

View File

@@ -1,10 +1,44 @@
# ==============================================================================
# lilishop-im 本地开发环境配置文件
# 执行 npm run serve 启动本地开发服务时,会优先加载此文件并覆盖 .env 中的同名变量
# 注意:以 VUE_APP_ 开头的变量会被 webpack.DefinePlugin 静态嵌入客户端代码中
# 变量修改后需要重新启动开发服务方可生效
#
# 【重要提示】
# 1. 本地联调如果使用本地或局域网服务(如 127.0.0.1 / localhost),WebSocket 必须使用 ws:// 协议!
# 例如: ws://127.0.0.1:8885/lili/webSocket
# 切勿在无 SSL 证书的本地环境下使用 wss://,否则会导致 WebSocket 握手失败、聊天无法连通!
# 2. 只有在线上域名并配置了 SSL/HTTPS 证书的情况下才使用 wss:// 协议。
# ==============================================================================
# 当前运行环境模式:development (本地开发环境)
NODE_ENV=development
# 是否开启预览模式 (true: 开启, false: 关闭)
VUE_APP_PREVIEW=false
# IM 服务的 HTTP 接口请求基准地址
# 线上演示示例: https://im-api.pickmall.cn
# 本地联调示例: http://127.0.0.1:8885
VUE_APP_API_BASE_URL=https://im-api.pickmall.cn
# IM 服务的 WebSocket 实时通讯地址
# 【重要提示】本地联调未配置 SSL 证书时务必使用 ws:// 协议,切勿写成 wss://!
# 本地联调推荐配置: ws://127.0.0.1:8885/lili/webSocket
# 线上已配置证书时: wss://im-api.pickmall.cn/lili/webSocket
VUE_APP_WEB_SOCKET_URL=wss://im-api.pickmall.cn/lili/webSocket
VUE_APP_COMMON=https://buyer-api.pickmall.cn
# 买家端业务接口基准地址 (本地联调示例: http://127.0.0.1:8888)
VUE_APP_BUYER=https://buyer-api.pickmall.cn
VUE_APP_SELLER=https://store-api.pickmall.cn
# 商家端业务接口基准地址 (本地联调示例: http://127.0.0.1:8889)
VUE_APP_SELLER=https://admin-api.pickmall.cn
# IM 网站/系统标题名称 (显示在浏览器标题栏与系统头部)
VUE_APP_WEBSITE_NAME="LiLi IM"
# PC 端商城前台首页及商品详情跳转前缀地址 (点击聊天中的商品卡片时跳转)
VUE_APP_PC_URL=https://pc-b2b2c.pickmall.cn/
VUE_APP_PC_STORE=https://store-b2b2c.pickmall.cn/
# PC 端商家管理后台跳转前缀地址 (点击聊天中的订单信息时跳转商家端订单页面)
VUE_APP_PC_STORE=https://store-b2b2c.pickmall.cn/

View File

@@ -1,40 +1,58 @@
/**
* ==============================================================================
* Babel 转译器配置文件 (babel.config.js)
* ==============================================================================
*
* 主要功能:
* 1. 采用 Vue CLI 官方预设进行现代 JavaScript (ES6+) 向下兼容转译
* 2. 集成 PrismJS 插件,用于在 IM 聊天对话框中展示富文本代码块时的多语言语法高亮
*/
module.exports = {
// Vue CLI 官方 Babel 预设,自动包含常用语法转换与 polyfill 支持
presets: ["@vue/cli-plugin-babel/preset"],
plugins: [
/**
* PrismJS 语法高亮插件配置
* babel-plugin-prismjs: 按需加载 PrismJS 的语言包和样式主题,优化打包体积
*/
[
"prismjs",
{
// 需要按需引入并支持语法高亮的编程/标记语言列表
languages: [
"html",
"css",
"less",
"javascript",
"typescript",
"json",
"xml",
"bash",
"nginx",
"sql",
"docker",
"php",
"java",
"go",
"python",
"ruby",
"rust",
"objectivec",
"c",
"csharp",
"cpp",
"lua",
"shell",
"vim",
"yaml",
"yml",
"md",
"erlang",
"ini",
"html", // HTML 标记语言
"css", // CSS 样式表
"less", // Less 预处理样式
"javascript", // JavaScript 脚本语言
"typescript", // TypeScript
"json", // JSON 数据格式
"xml", // XML 文档
"bash", // Bash 脚本
"nginx", // Nginx 服务器配置文件
"sql", // 数据库 SQL 语句
"docker", // Dockerfile 配置
"php", // PHP 语言
"java", // Java 语言
"go", // Go 语言
"python", // Python 语言
"ruby", // Ruby 语言
"rust", // Rust 语言
"objectivec", // Objective-C 语言
"c", // C 语言
"csharp", // C# 语言
"cpp", // C++ 语言
"lua", // Lua 脚本
"shell", // Shell 命令行脚本
"vim", // Vim 配置与脚本
"yaml", // YAML 配置
"yml", // YML 配置
"md", // Markdown 文本
"erlang", // Erlang 语言
"ini", // INI 配置文件
],
// 代码高亮使用的默认主题 (采用深色风格的 okaidia 主题)
theme: "okaidia",
},
],

View File

@@ -1,18 +1,28 @@
{
"compilerOptions": {
"target": "es6",
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
}
},
"exclude": [
"node_modules",
"dist"
],
"include": [
"src/**/*"
]
}
// ==============================================================================
// JavaScript 项目语言服务配置文件 (jsconfig.json)
// 用于指导 VS Code 等 IDE 进行代码跳转、智能提示和路径别名映射解析
// ==============================================================================
"compilerOptions": {
// 指定编译后的 JavaScript 目标语言版本
"target": "es6",
// 解析非相对模块名称的基准目录(当前根目录)
"baseUrl": ".",
// 配置模块名到基于 baseUrl 的路径映射(与 vue.config.js 中的 webpack alias 保持一致)
"paths": {
// 将 @/* 映射至 src/*,以便在编辑器中编写 import ... from '@/...' 时支持快捷跳转
"@/*": [
"src/*"
]
}
},
// 排除在代码智能索引和语法检查之外的目录
"exclude": [
"node_modules",
"dist"
],
// 包含在智能提示与类型检查范围内的源文件匹配规则
"include": [
"src/**/*"
]
}

View File

@@ -1,30 +1,74 @@
/**
* 读取公共配置API路径。
* 工程会读取根目录下的 env 。
* 根目录下的.env文件是用来定义环境变量的。
* 它可以包含各种键值对,每个键代表一个环境变量。
* 这些环境变量可以在应用程序的整个生命周期内使用,例如在代码中访问、配置webpack或在构建部署过程中使用不同的值。
* env中的 ODE_ENV指定当前的环境。
* 可以分别创建.env.development、.env.production等文件。
* 默认.env 文件中的变量作为开发环境的环境变量。
* env文件修改的话需要重新编译项目。
* ==============================================================================
* 全局业务配置文件
* ==============================================================================
*
* 说明:
* 1. 本文件整合并导出了项目运行所需的全局配置参数,统一由环境变量 process.env 读取。
* 2. 环境变量定义在项目根目录下的 .env(生产)与 .env.development(开发)文件中。
* 3. 只有以 VUE_APP_ 开头的环境变量才会被 Vue CLI 编译并暴露给前端客户端代码。
* 4. 修改 .env 或 .env.development 文件后,必须重新启动开发服务或重新打包构建方可生效。
*
* 核心配置项包含:
* - 站点名称
* - 后端 IM 服务 API 接口与 WebSocket 实时通讯地址
* - 买家端/商家端跨系统接口基准地址
* - PC 端前台商城与商家端管理后台的外链跳转地址
*/
export default {
// 网站名称
/**
* 站点/系统标题名称
* 用于页面 document.title 设置及系统头部品牌展示
* 默认值: "LiLi IM"
*/
WEBSITE_NAME: process.env.VUE_APP_WEBSITE_NAME || "LiLi IM",
// 默认请求IM的API
BASE_API_URL: process.env.VUE_APP_API_BASE_URL || "",
// 默认请求的WS
BASE_WS_URL: process.env.VUE_APP_WEB_SOCKET_URL || "",
// 默认请求公有接口相关 API
BASE_COMMON: process.env.VUE_APP_COMMON || "",
// 默认请求用户相关API
BASE_BUYER: process.env.VUE_APP_BUYER || "",
// 默认请求卖家相关的API
/**
* IM 专属后端 HTTP API 接口基准请求地址
* 用于 Axios 请求实例 (如获取会话历史、发送单聊/群聊富文本、撤回消息、好友列表等)
* 对应环境变量: VUE_APP_API_BASE_URL
*/
BASE_API_URL: process.env.VUE_APP_API_BASE_URL || "",
/**
* IM 实时通讯 WebSocket 连接基准地址
* 用于前端与后端建立长连接,实时收发在线聊天消息、输入状态提示、系统通知等
*
* 【重要提示】协议选用原则:
* - 本地开发/联调环境(无 SSL 证书)必须使用 "ws://" 协议(如 ws://127.0.0.1:8885/lili/webSocket),切勿写成 wss:// 导致握手失败!
* - 生产/线上环境配置了 HTTPS/SSL 证书时,必须使用 "wss://" 协议(如 wss://im-api.pickmall.cn/lili/webSocket)
*
* 对应环境变量: VUE_APP_WEB_SOCKET_URL
*/
BASE_WS_URL: process.env.VUE_APP_WEB_SOCKET_URL || "",
/**
* 买家端 (Buyer) 接口基准地址
* 用于在聊天窗口中查询买家用户信息、买家端商品详情、买家订单卡片数据等
* 对应环境变量: VUE_APP_BUYER
*/
BASE_BUYER: process.env.VUE_APP_BUYER || "",
/**
* 商家端/卖家端 (Seller) 接口基准地址
* 用于获取当前店铺信息、客服坐席列表、商家端关联配置等
* 对应环境变量: VUE_APP_SELLER
*/
BASE_SELLER: process.env.VUE_APP_SELLER || "",
// 点击商品跳转到的地址
/**
* PC 买家端商城首页/商品详情页面跳转基准前缀
* 当用户在聊天窗口中点击商品链接或商品卡片时,在新窗口打开对应的商城商品详情页
* 默认值: "https://pc-b2b2c.pickmall.cn"
* 对应环境变量: VUE_APP_PC_URL
*/
PC_URL: process.env.VUE_APP_PC_URL || "https://pc-b2b2c.pickmall.cn",
// 订单跳转商家订单页面
PC_STORE: process.env.VUE_APP_PC_STORE || 'https://store-b2b2c.pickmall.cn',
/**
* PC 商家管理后台跳转基准前缀
* 当客服人员在聊天面板中点击买家咨询的订单卡片时,跳转至商家管理后台对应的订单详情页
* 默认值: "https://store-b2b2c.pickmall.cn"
* 对应环境变量: VUE_APP_PC_STORE
*/
PC_STORE: process.env.VUE_APP_PC_STORE || "https://store-b2b2c.pickmall.cn",
};

View File

@@ -154,7 +154,7 @@ export const del = (url, data = {}, options = {}) => {
*/
export const upload = (url, data = {}, options = {}) => {
return request({
url: config.BASE_COMMON + url,
url: config.BASE_BUYER + url,
method: "post",
data: data,
...options,

View File

@@ -1,58 +1,118 @@
/**
* ==============================================================================
* Vue CLI 配置文件 (vue.config.js)
* ==============================================================================
*
* 官方文档: https://cli.vuejs.org/zh/config/
* 主要配置功能:
* 1. 部署子路径与打包输出目录设置
* 2. 本地开发服务器 (devServer) 端口及代理跨域规则
* 3. Gzip 打包压缩插件 (CompressionWebpackPlugin) 配置
* 4. SVG 图标雪碧图 (svg-sprite-loader) 解析
* 5. 全局 Less 样式变量自动注入 (style-resources-loader)
* 6. 构建优化与 SourceMap 控制
*/
const path = require('path')
// compression-webpack-plugin,添加压缩文件类型
// 引入 Gzip 压缩插件,用于生产环境减小打包产物体积,提升加载速度
const CompressionWebpackPlugin = require('compression-webpack-plugin')
// 需要进行 Gzip 压缩的文件后缀类型
const productionGzipExtensions = ['js', 'css']
// 判断当前是否为生产构建环境
const isProd = process.env.NODE_ENV === 'production'
/**
* 路径转换辅助函数:将相对路径转换为绝对路径
* @param {string} dir 相对 src 或根目录的路径
* @returns {string} 绝对路径
*/
function resolve(dir) {
return path.join(__dirname, dir)
}
// vue.config.js
const vueConfig = {
// 公共路径(必须有的)
/**
* 部署应用包时的基本 URL (部署路径)
* 默认使用根路径 '/',若部署在二级子目录下(例如 '/im/'),可通过环境变量 VUE_APP_PUBLIC_PATH 指定
*/
publicPath: process.env.VUE_APP_PUBLIC_PATH || '/',
// 输出文件目录
/**
* 生产环境构建输出文件的目录名称 (执行 npm run build 后的打包产物目录)
*/
outputDir: 'dist',
// 静态资源存放的文件夹(相对于ouputDir)
/**
* 放置生成的静态资源 (js、css、img、fonts) 的相对于 outputDir 的目录
*/
assetsDir: 'static',
/**
* 是否使用包含运行时编译器的 Vue 构建版本
* 设置为 false 可以减少打包体积约 10KB
*/
runtimeCompiler: false,
/**
* 本地开发服务器配置
*/
devServer: {
// 本地开发运行端口号,默认为 8000
port: 8000,
/**
* 本地开发跨域代理配置 (Proxy)
* 若本地直接请求后端 API 遇到跨域 (CORS) 限制,可在此开启反向代理
*/
// proxy: {
// //配置跨域
// '/api': {
// target: 'http://api.xxxx.com', // 后台接口域名
// ws: false, //如果要代理 websockets,配置这个参数
// secure: false, // 如果是https接口,需要配置这个参数
// changeOrigin: true, //是否跨域
// target: 'http://127.0.0.1:8885', // 后台 IM 接口域名或 IP
// ws: true, // 是否代理 websockets
// secure: false, // 若为 https 自签名证书接口设为 false
// changeOrigin: true, // 是否允许跨域改变 host 来源
// pathRewrite: {
// '^/api': '/api',
// '^/api': '/api', // 路径重写规则
// },
// },
// },
},
/**
* Webpack 原生对象式配置
*/
configureWebpack: {
// webpack plugins
// Webpack 插件列表
plugins: [
// 配置大文件压缩相关参数
/**
* 配置 Gzip 压缩插件
* 对体积大于 10KB 的 JS/CSS 文件生成对应的 .gz 预压缩文件
* 需配合 Nginx 的 gzip_static on 指令直接下发,减少服务器 CPU 动态压缩消耗
*/
new CompressionWebpackPlugin({
filename: '[path][base].gz',
algorithm: 'gzip',
test: new RegExp('\\.(' + productionGzipExtensions.join('|') + ')$'),
threshold: 10240,
minRatio: 0.8,
filename: '[path][base].gz', // 压缩后的文件命名规则
algorithm: 'gzip', // 压缩算法
test: new RegExp('\\.(' + productionGzipExtensions.join('|') + ')$'), // 匹配要压缩的文件类型
threshold: 10240, // 仅对超过 10KB 的资源进行压缩 (单位: 字节)
minRatio: 0.8, // 只有压缩率好于 0.8 时才生成压缩文件
}),
],
},
/**
* Webpack 链式配置 (Chain Webpack),细粒度修改内部 loader 与规则
*/
chainWebpack: config => {
// 1. 配置路径别名:设置 '@' 指向 'src' 目录,便于在代码中快速引入组件和工具库
config.resolve.alias.set('@', resolve('src'))
// 2. 配置内置 svg 规则:排除 src/icons/svg 目录,避免与下面的 svg-sprite-loader 冲突
config.module.rule('svg').exclude.add(resolve('src/icons/svg')).end()
// 3. 配置自定义 svg-icon 规则:将 src/icons/svg 下的 SVG 文件打包为 SVG Sprite 雪碧图
// 可以在项目中使用 <svg-icon icon-class="icon-name" /> 统一渲染矢量图标
config.module
.rule('icons')
.test(/\.svg$/)
@@ -61,35 +121,54 @@ const vueConfig = {
.use('svg-sprite-loader')
.loader('svg-sprite-loader')
.options({
symbolId: 'icon-[name]',
symbolId: 'icon-[name]', // 生成的 symbol ID 命名规则
})
},
/**
* 第三方插件配置项
*/
pluginOptions: {
// 自动在所有单文件组件中引入 Less 全局变量,无需在每个 .vue 文件手动 @import
'style-resources-loader': {
preProcessor: 'less',
patterns: [
//全局加载 less 变量
// 全局 Less 主题变量文件绝对路径
path.resolve(__dirname, './src/assets/css/variable.less'),
],
},
},
/**
* CSS 预处理器相关配置
*/
css: {
loaderOptions: {
less: {
lessOptions: {
modifyVars: {},
javascriptEnabled: true,
modifyVars: {}, // Less 变量覆盖
javascriptEnabled: true, // 允许 Less 中执行 JavaScript 表达式 (Ant Design / 部分组件库必需)
},
},
},
},
// 是否为生产环境构建生成 source map?
/**
* 是否为生产环境构建生成 Source Map
* 设置为 false 可以大幅加速生产打包速度,并防止源码泄露
*/
productionSourceMap: false,
// lintOnSave: undefined,
/**
* 是否在保存代码时通过 eslint-loader 执行检查
* 设置为 false 关闭保存时的编译中断
*/
lintOnSave: false,
// babel-loader no-ignore node_modules/*
/**
* 默认情况下 babel-loader 会忽略所有 node_modules 中的文件
* 若依赖库使用了 ES6+ 特性需要被转译,可在此数组中显式配置该依赖名称
*/
transpileDependencies: [],
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -114,7 +114,7 @@ const DEFAULTS = {
paddingTop: 10,
paddingBottom: 10,
paddingX: 12,
pageGap: 10,
pageGap: 0,
contentGap: 0,
borderRadius: 0,
imgRadius: 0,

View File

@@ -98,7 +98,7 @@ const DEFAULTS = {
paddingBottom: 10,
contentGap: 10,
paddingX: 0,
pageGap: 10,
pageGap: 0,
borderRadius: 0,
};
const DEFAULT_ITEM = { title: "文章列表", categoryId: "", count: 6 };

View File

@@ -37,7 +37,7 @@ const DEFAULTS = {
bgColor2: "#f5f5f5",
height: 10,
paddingX: 0,
pageGap: 10,
pageGap: 0,
};
export default {

View File

@@ -89,7 +89,7 @@ const DEFAULTS = {
paddingBottom: 10,
contentGap: 5,
paddingX: 12,
pageGap: 10,
pageGap: 0,
borderRadius: 0,
};
const DEFAULT_ITEM = { title: "优惠券", count: 5 };

View File

@@ -108,7 +108,7 @@ const DEFAULTS = {
paddingTop: 10,
paddingBottom: 10,
paddingX: 12,
pageGap: 10,
pageGap: 0,
borderRadius: 0,
imgRadius: 0,
};

View File

@@ -339,7 +339,7 @@ const DEFAULTS = {
paddingBottom: 10,
paddingX: 12,
contentGap: 10,
pageGap: 10,
pageGap: 0,
borderRadius: 0,
contentRadius: 7,
};

View File

@@ -47,7 +47,7 @@ const DEFAULTS = {
lineStyle: "dashed",
lineHeight: 1,
paddingX: 0,
pageGap: 10,
pageGap: 0,
};
export default {

View File

@@ -179,7 +179,7 @@ const DEFAULTS = {
paddingBottom: 10,
paddingX: 12,
contentGap: 10,
pageGap: 10,
pageGap: 0,
borderRadius: 0,
imgRadius: 30,
};

View File

@@ -164,7 +164,7 @@ const DEFAULTS = {
paddingTop: 0,
paddingBottom: 0,
paddingX: 12,
pageGap: 10,
pageGap: 0,
borderRadius: 0,
itemColor: "#333333",
};

View File

@@ -231,7 +231,7 @@ const DEFAULTS = {
paddingBottom: 10,
contentGap: 10,
paddingX: 12,
pageGap: 10,
pageGap: 0,
borderRadius: 0,
imgRadius: 10,
showGoodsName: true,

View File

@@ -58,7 +58,7 @@ const DEFAULTS = {
bgColor1: "#FFFFFF",
bgColor2: "#FFFFFF",
paddingX: 12,
pageGap: 10,
pageGap: 0,
borderRadius: 0,
};

View File

@@ -228,7 +228,7 @@ const DEFAULTS = {
paddingTop: 10,
paddingBottom: 10,
paddingX: 0,
pageGap: 10,
pageGap: 0,
borderRadius: 0,
imgRadius: 7,
};

View File

@@ -157,7 +157,7 @@ const DEFAULTS = {
paddingTop: 10,
paddingBottom: 10,
paddingX: 12,
pageGap: 10,
pageGap: 0,
radiusTop: 0,
radiusBottom: 0,
color: "#282828",

View File

@@ -115,7 +115,7 @@ const DEFAULTS = {
paddingTop: 10,
paddingBottom: 10,
paddingX: 12,
pageGap: 10,
pageGap: 0,
borderRadius: 0,
imgRadius: 0,
};

View File

@@ -851,7 +851,7 @@ export const modelData = [
bgColor: "#f5f5f5",
height: 10,
paddingX: 0,
pageGap: 10
pageGap: 0
}
},
{
@@ -866,7 +866,7 @@ export const modelData = [
lineStyle: "dashed",
lineHeight: 1,
paddingX: 0,
pageGap: 10
pageGap: 0
}
},
{
@@ -879,7 +879,7 @@ export const modelData = [
content: "",
bgColor: "#FFFFFF",
paddingX: 12,
pageGap: 10,
pageGap: 0,
borderRadius: 0
}
},
@@ -988,7 +988,7 @@ export const modelData = [
list: [{ title: "文章列表", categoryId: "", count: 3 }],
bgColor: "#FFFFFF",
paddingX: 0,
pageGap: 10,
pageGap: 0,
borderRadius: 0
}
},
@@ -1002,7 +1002,7 @@ export const modelData = [
list: [{ title: "店铺列表", count: 4 }],
bgColor: "#FFFFFF",
paddingX: 0,
pageGap: 10,
pageGap: 0,
borderRadius: 0
}
},
@@ -1044,7 +1044,7 @@ export const modelData = [
paddingBottom: 10,
paddingX: 12,
contentGap: 10,
pageGap: 10,
pageGap: 0,
borderRadius: 0,
contentRadius: 7
}

View File

@@ -34,12 +34,13 @@
<el-button size="default" type="primary" @click="handleSpinShow">保存页面</el-button>
<el-dialog
title="保存中"
:title="progress ? '保存页面' : '保存中'"
v-model="saveDialog"
:show-close="true"
:close-on-click-modal="false"
append-to-body
destroy-on-close
@closed="handleDialogClosed"
>
<div v-if="progress">
<div class="model-item">
@@ -56,7 +57,7 @@
</div>
<div class="model-item">
将当前装修内容写入到本地缓存中,下次进入页面时可继续使用
<el-button type="small" @click="witeLocalStore">写入</el-button>
<el-button size="small" @click="witeLocalStore">写入</el-button>
</div>
<el-button type="primary" @click="save()">保存</el-button>
@@ -84,7 +85,7 @@ const appStore = useAppStore();
const hasCache = ref(false); // 是否有缓存
const progress = ref(true); // 展示进度
const num = ref(20); // 提交进度
const num = ref(0); // 提交进度
const saveDialog = ref(false); // 加载状态
const way = ref([
// 装修tab栏切换
@@ -119,10 +120,17 @@ function startEditName() {
const submitWay = reactive({
// 表单信息
pageShow: route.query.type || false,
pageShow: route.query.type === "true" || route.query.type === true || route.query.type === "OPEN",
pageClientType: "H5"
});
watch(
() => route.query.type,
(val) => {
submitWay.pageShow = val === "true" || val === true || val === "OPEN";
}
);
// 模板名称与「页面设置」面板共用 store 中的同一份数据,双向同步
const pageName = computed({
get() {
@@ -191,9 +199,16 @@ function clickBtn(val) {
* 加载,并保存模板
*/
function handleSpinShow() {
progress.value = true;
num.value = 0;
saveDialog.value = true;
}
function handleDialogClosed() {
progress.value = true;
num.value = 0;
}
// 填写是否发布,模板名称之后保存
function save() {
// 楼层装修页面(非开屏/弹窗广告)需校验页面设置的必填项
@@ -220,49 +235,57 @@ function save() {
return false;
}
submitWay?.pageShow === true
? (submitWay.pageShow = "OPEN")
: (submitWay.pageShow = "CLOSE");
submitWay.name = pageName.value;
submitWay.pageData = JSON.stringify(appStore.styleStore);
submitWay.pageType = "INDEX";
submitWay.pageType = route.query.pagetype || "INDEX";
// submitWay.pageType = props.pagetype;
const payload = {
...submitWay,
pageShow: submitWay.pageShow ? "OPEN" : "CLOSE",
name: pageName.value,
pageData: JSON.stringify(appStore.styleStore),
pageType: route.query.pagetype || "INDEX",
};
if (route.query.pagetype == 'ALERT' || route.query.pagetype == 'OPEN_SCREEN_ANIMATION') {
update(submitWay);
update(payload);
} else {
route.query.id ? updateHome() : submit(submitWay);
route.query.id ? updateHome(payload) : submit(payload);
}
// route.query.id ? update() : submit(submitWay);
}
// 更新
function updateHome() {
function updateHome(payload) {
progress.value = false;
API_Other.updateHome(route.query.id, {
num.value = 20;
const data = payload || {
pageData: JSON.stringify(appStore.styleStore),
name: pageName.value,
pageShow: submitWay.pageShow,
pageType: submitWay.pageType,
pageShow: submitWay.pageShow ? "OPEN" : "CLOSE",
pageType: submitWay.pageType || route.query.pagetype || "INDEX",
pageClientType: "H5",
})
};
API_Other.updateHome(route.query.id, data)
.then(res => {
num.value = 50;
num.value = 60;
if (res.success) {
num.value = 80;
num.value = 100;
/**制作保存成功动画¸ */
setTimeout(() => {
saveDialog.value = false;
progress.value = true;
num.value = 0;
Message.success("修改成功");
goBack();
}, 1000);
}, 800);
} else {
progress.value = true;
num.value = 0;
saveDialog.value = false;
Message.error("修改失败,请稍后重试");
}
})
.catch(() => {
progress.value = true;
num.value = 0;
saveDialog.value = false;
Message.error("修改失败,请稍后重试");
});
}
@@ -274,6 +297,7 @@ function update(submitWay) {
return false;
}
progress.value = false;
num.value = 20;
let id = route.query.id;
const pagetype = route.query.pagetype;
@@ -283,29 +307,40 @@ function update(submitWay) {
OPEN_SCREEN_ANIMATION: "OPEN_SCREEN_ANIMATION",
}[pagetype ? pagetype : "INDEX"];
const payload = { ...submitWay };
if (pagetype) {
submitWay.pageData = JSON.stringify(
payload.pageData = JSON.stringify(
appStore.openStyleStore[0]
);
submitWay.pageType = pageType;
payload.pageType = pageType;
id = appStore.openStoreId;
}
API_Other.updateHome(id, submitWay)
API_Other.updateHome(id, payload)
.then((res) => {
num.value = 50;
num.value = 60;
if (res.success) {
num.value = 80;
num.value = 100;
/**制作保存成功动画¸ */
setTimeout(() => {
saveDialog.value = false;
progress.value = true;
num.value = 0;
Message.success("修改成功");
goBack();
}, 1000);
}, 800);
} else {
progress.value = true;
num.value = 0;
saveDialog.value = false;
Message.error("修改失败,请稍后重试");
}
})
.catch(() => {
progress.value = true;
num.value = 0;
saveDialog.value = false;
Message.error("修改失败,请稍后重试");
});
}
@@ -313,28 +348,42 @@ function update(submitWay) {
// 返回查询数据页面
function goBack() {
router.go(-1);
if (window.history.state?.back) {
router.go(-1);
} else {
window.close();
}
}
// 保存
function submit(submitWay) {
progress.value = false;
num.value = 20;
API_Other.setHomeSetup(submitWay)
.then(res => {
num.value = 50;
num.value = 60;
if (res.success) {
num.value = 80;
num.value = 100;
/**制作保存成功动画¸ */
setTimeout(() => {
saveDialog.value = false;
progress.value = true;
num.value = 0;
Message.success("保存成功");
goBack();
}, 1000);
}, 800);
} else {
progress.value = true;
num.value = 0;
saveDialog.value = false;
Message.error("保存失败,请稍后重试");
}
})
.catch(() => {
progress.value = true;
num.value = 0;
saveDialog.value = false;
Message.error("保存失败,请稍后重试");
});
}
</script>

View File

@@ -6,7 +6,7 @@
}
.layout {
padding: 8px;
margin: 4px 0;
margin: 0;
background: #fff;
}
.layout,

View File

@@ -58,7 +58,7 @@ export default {
return {
background: this.res.bgColor || "#FFFFFF",
padding: `10px ${this.res.paddingX || 0}px`,
marginBottom: `${this.res.pageGap !== undefined ? this.res.pageGap : 10}px`,
marginBottom: `${this.res.pageGap !== undefined ? this.res.pageGap : 0}px`,
borderRadius: `${this.res.borderRadius || 0}px`,
overflow: "hidden",
};

View File

@@ -4,7 +4,7 @@
:style="{
background: blankBg,
height: px(res.height ?? 10),
margin: `0 ${px(res.paddingX ?? 0)} ${px(res.pageGap ?? 10)}`,
margin: `0 ${px(res.paddingX ?? 0)} ${px(res.pageGap ?? 0)}`,
}"
></div>
</template>

View File

@@ -145,7 +145,7 @@ export default {
return {
background: this.res.bgColor || "#FFFFFF",
padding: `10px ${this.res.paddingX || 0}px`,
marginBottom: `${this.res.pageGap !== undefined ? this.res.pageGap : 10}px`,
marginBottom: `${this.res.pageGap !== undefined ? this.res.pageGap : 0}px`,
borderRadius: `${this.res.borderRadius || 0}px`,
overflow: "hidden",
};

View File

@@ -9,11 +9,11 @@
/>
<div v-else class="integral-head-title" :style="headTitleStyle">{{ title }}</div>
<div class="integral-head-space"></div>
<div class="integral-head-more" :style="moreStyle">更多 &gt;</div>
<div class="integral-head-more" :style="moreStyle">更多 ></div>
</div>
<!-- 老数据:原始四列样式 -->
<div v-if="!styled" class="join-box">
<div class="join-item" v-for="item in 4" :key="item">
<div class="join-item" v-for="item in count" :key="item">
<div class="item-img-box">
<img
class="item-img"
@@ -71,6 +71,45 @@
</template>
<script setup>
import { computed } from "vue";
const INTEGRAL_DEMO = [
{
goodsName: "小米小爱音箱 Play 增强版 智能红外遥控",
points: 1200,
originalPrice: 149,
thumbnail: "/demo/xiaomi-phone.png",
},
{
goodsName: "Apple 20W USB-C 官方原装快充充电器",
points: 1500,
originalPrice: 149,
thumbnail: "/demo/apple-phone.png",
},
{
goodsName: "米家自动洗手机 套装 抑菌洗手液",
points: 800,
originalPrice: 79,
thumbnail: "/demo/xiaomi-phone.png",
},
{
goodsName: "多功能桌面风扇 超长续航三档风力",
points: 600,
originalPrice: 59,
thumbnail: "/demo/vivo-phone.png",
},
{
goodsName: "精品保温杯 316不锈钢 480ml",
points: 990,
originalPrice: 99,
thumbnail: "/demo/apple-phone.png",
},
{
goodsName: "无线双模鼠标 2.4G+蓝牙 轻音便携",
points: 750,
originalPrice: 69,
thumbnail: "/demo/vivo-phone.png",
},
];
const props = defineProps(["res"]);
@@ -79,9 +118,27 @@ const title = computed(
);
const firstItem = computed(() => (props.res.list && props.res.list[0]) || {});
const layoutStyle = computed(() => props.res.layoutStyle || "1");
const count = computed(() => {
const c = firstItem.value?.count ?? props.res?.count;
if (c !== undefined && c !== null && !isNaN(Number(c)) && Number(c) > 0) {
return Number(c);
}
return layoutStyle.value === "1" ? 4 : 6;
});
const displayData = computed(() => {
const data = (firstItem.value && firstItem.value.data) || [];
return layoutStyle.value === "1" ? data.slice(0, 4) : data.slice(0, 6);
const raw = (firstItem.value && firstItem.value.data) || [];
const data = raw.length ? raw : INTEGRAL_DEMO;
const targetCount = count.value;
if (data.length >= targetCount) {
return data.slice(0, targetCount);
}
// 如果设置的数量大于现有模拟数据长度,循环补齐,使预览数量与配置数量严格一致
const result = [...data];
while (result.length < targetCount) {
result.push({ ...data[result.length % data.length] });
}
return result.slice(0, targetCount);
});
// 面板保存过样式字段才启用新样式,老数据保持原样
@@ -94,7 +151,7 @@ const wrapperStyle = computed(() => {
return {
background: res.bgColor || "#FFFFFF",
padding: `0 ${res.paddingX || 0}px`,
marginBottom: `${res.pageGap !== undefined ? res.pageGap : 10}px`,
marginBottom: `${res.pageGap !== undefined ? res.pageGap : 0}px`,
borderRadius: `${res.borderRadius || 0}px`,
overflow: "hidden",
};

View File

@@ -4,7 +4,7 @@
:style="{
background: lineBg,
padding: `0 ${px(res.paddingX ?? 0)}`,
marginBottom: px(res.pageGap ?? 10),
marginBottom: px(res.pageGap ?? 0),
}"
>
<div

View File

@@ -23,7 +23,7 @@
<span class="pt-success">{{ firstItem.groupSuccess }}</span>
</template>
<span class="pt-space"></span>
<span class="pt-more" :style="moreStyle">更多 &gt;</span>
<span class="pt-more" :style="moreStyle">更多 ></span>
</div>
<div class="pt-body">
<!-- 样式一:纵向商品行 -->
@@ -154,7 +154,7 @@
<span v-else class="lv-head-title">{{ firstItem.title || "精彩直播" }}</span>
<span class="lv-head-sub" :style="titleStyle">{{ firstItem.title1 || "精彩抢先看" }}</span>
<span class="lv-space"></span>
<span class="lv-more" :style="moreStyle">更多 &gt;</span>
<span class="lv-more" :style="moreStyle">更多 ></span>
</div>
<div class="lv-head-en">Exciting live streaming</div>
<!-- 单行:横向滑动卡片 -->
@@ -201,7 +201,7 @@
<span class="sk-divider"></span>
<span class="sk-head-sub" :style="titleStyle">{{ firstItem.title1 || "邀请你的好友来参与" }}</span>
<span class="sk-space"></span>
<span class="sk-go">GO &gt;</span>
<span class="sk-go">GO ></span>
</div>
<!-- 样式一:横向滑动 -->
<div v-if="layoutStyle === '1'" class="sk-scroll" :style="gapStyle">
@@ -246,7 +246,7 @@
<span class="sk-divider"></span>
<span class="sk-head-sub" :style="titleStyle">{{ firstItem.title1 || "邀请好友来砍价" }}</span>
<span class="sk-space"></span>
<span class="sk-go">GO &gt;</span>
<span class="sk-go">GO ></span>
</div>
<!-- 样式一:横向滑动 -->
<div v-if="layoutStyle === '1'" class="sk-scroll" :style="gapStyle">
@@ -287,7 +287,7 @@
<div v-if="styled && (res.icon || res.moreUrl)" class="promo-head">
<img v-if="res.icon" class="promo-head-icon" :src="res.icon" alt="" />
<div class="promo-head-space"></div>
<div v-if="res.moreUrl" class="promo-more" :style="moreStyle">更多 &gt;</div>
<div v-if="res.moreUrl" class="promo-more" :style="moreStyle">更多 ></div>
</div>
<div class="join-list" :style="gapStyle">
<div
@@ -343,6 +343,155 @@
</template>
<script setup>
import { computed } from "vue";
const SECKILL_DEMO = [
{
goodsName: "iPhone 16 Pro 256GB 黑色钛金属",
price: 6999,
originalPrice: 8999,
thumbnail: "/demo/apple-phone.png",
},
{
goodsName: "Xiaomi 15 Pro 16GB+512GB 沉墨色",
price: 4699,
originalPrice: 5299,
thumbnail: "/demo/xiaomi-phone.png",
},
{
goodsName: "vivo X200 Pro 16GB+512GB 钛色",
price: 4899,
originalPrice: 5499,
thumbnail: "/demo/vivo-phone.png",
},
{
goodsName: "Apple Watch Series 10 46mm GPS款",
price: 2699,
originalPrice: 3199,
thumbnail: "/demo/apple-phone.png",
},
{
goodsName: "AirPods Pro (第二代) 配USB-C充电盒",
price: 1399,
originalPrice: 1899,
thumbnail: "/demo/apple-phone.png",
},
{
goodsName: "Xiaomi 平板 6S Pro 12.4英寸",
price: 2799,
originalPrice: 3299,
thumbnail: "/demo/xiaomi-phone.png",
},
];
const PINTUAN_DEMO = [
{
goodsName: "iPhone 16 Pro 5G智能手机 256GB 白色钛金属",
price: 7999,
originalPrice: 8999,
thumbnail: "/demo/apple-phone.png",
groupNum: 2,
joined: 128,
},
{
goodsName: "Xiaomi 15 5G智能手机 12GB+256GB 白色",
price: 3699,
originalPrice: 4299,
thumbnail: "/demo/xiaomi-phone.png",
groupNum: 2,
joined: 256,
},
{
goodsName: "vivo X200 5G智能手机 12GB+256GB 宝石蓝",
price: 3999,
originalPrice: 4599,
thumbnail: "/demo/vivo-phone.png",
groupNum: 3,
joined: 89,
},
{
goodsName: "Apple AirPods 4 主动降噪无线耳机",
price: 1099,
originalPrice: 1399,
thumbnail: "/demo/apple-phone.png",
groupNum: 2,
joined: 412,
},
{
goodsName: "小米手环 9 Pro 晨曦金 金属机身",
price: 349,
originalPrice: 399,
thumbnail: "/demo/xiaomi-phone.png",
groupNum: 2,
joined: 680,
},
{
goodsName: "vivo Pad3 Pro 13英寸 3.1K 超清大屏",
price: 2999,
originalPrice: 3399,
thumbnail: "/demo/vivo-phone.png",
groupNum: 2,
joined: 195,
},
];
const KANJIA_DEMO = [
{
goodsName: "米家智能空气炸锅 6.5L 嫩烤大容量",
purchasePrice: 0,
price: 0,
originalPrice: 299,
thumbnail: "/demo/xiaomi-phone.png",
},
{
goodsName: "电动声波牙刷 T300 高频震动洁齿",
purchasePrice: 0,
price: 0,
originalPrice: 129,
thumbnail: "/demo/vivo-phone.png",
},
{
goodsName: "小米随身手持小风扇 大风力轻便便携",
purchasePrice: 0,
price: 0,
originalPrice: 59,
thumbnail: "/demo/xiaomi-phone.png",
},
{
goodsName: "智能温湿度计 电子墨水屏高精度",
purchasePrice: 0,
price: 0,
originalPrice: 69,
thumbnail: "/demo/apple-phone.png",
},
];
const LIVE_DEMO = [
{
cover: "/demo/apple-phone.png",
title: "双11新品首发专场 抢先看",
anchorName: "官方品牌主播",
status: "LIVE",
tagText: "直播中",
tagClass: "lv-tag-live",
},
{
cover: "/demo/xiaomi-phone.png",
title: "米粉狂欢日 爆款好物直降",
anchorName: "好物推荐官",
status: "PREVIEW",
startTime: "10/22 19:00",
tagText: "预告",
tagClass: "lv-tag-preview",
},
{
cover: "/demo/vivo-phone.png",
title: "旗舰影像新机实测现场",
anchorName: "数码达人",
status: "REPLAY",
tagText: "回放",
tagClass: "lv-tag-replay",
},
];
const props = defineProps(["res"]);
@@ -354,7 +503,8 @@ const isLive = computed(() => firstItem.value.type === "LIVE");
const isSeckill = computed(() => firstItem.value.type === "SECKILL");
const isKanjia = computed(() => firstItem.value.type === "KANJIA");
const seckillData = computed(() => {
const data = (firstItem.value && firstItem.value.data) || [];
const raw = (firstItem.value && firstItem.value.data) || [];
const data = raw.length ? raw : (isKanjia.value ? KANJIA_DEMO : SECKILL_DEMO);
return layoutStyle.value === "2" ? data.slice(0, 4) : data.slice(0, 6);
});
// 老数据未保存过样式时,给个红→白渐变兜底背景
@@ -382,9 +532,12 @@ const fmtLiveTime = (t) => {
return s;
};
const liveData = computed(() => {
const data = (firstItem.value && firstItem.value.data) || [];
const raw = (firstItem.value && firstItem.value.data) || [];
if (!raw.length) {
return LIVE_DEMO;
}
const limit = layoutStyle.value === "2" ? 3 : 6;
return data.slice(0, limit).map((item) => {
return raw.slice(0, limit).map((item) => {
const tag = liveTagOf(item.status != null ? item.status : item.liveStatus);
return {
cover: item.cover || item.roomImg || item.shareImg || item.thumbnail || "",
@@ -398,11 +551,12 @@ const liveData = computed(() => {
});
});
const displayData = computed(() => {
const data = (firstItem.value && firstItem.value.data) || [];
const raw = (firstItem.value && firstItem.value.data) || [];
const data = raw.length ? raw : PINTUAN_DEMO;
if (layoutStyle.value === "3" || layoutStyle.value === "4") {
return data.slice(0, 6);
}
return data.slice(0, 2);
return data.slice(0, 4);
});
const headTitleImage = computed(() =>
props.res.headTitleType === "image" ? props.res.headTitleImage : ""

View File

@@ -4,7 +4,7 @@
:style="{
background: richBg,
padding: `0 ${px(res.paddingX ?? 12)}`,
marginBottom: px(res.pageGap ?? 10),
marginBottom: px(res.pageGap ?? 0),
borderRadius: px(res.borderRadius ?? 0),
overflow: 'hidden',
}"

View File

@@ -3,7 +3,7 @@
<div class="shop-head" v-if="headIcon || title || hasMore">
<img v-if="headIcon" class="shop-head-icon" :src="headIcon" alt="" />
<div class="shop-title" :style="titleStyle">{{ title }}</div>
<div v-if="hasMore" class="shop-more" :style="moreStyle">更多 &gt;</div>
<div v-if="hasMore" class="shop-more" :style="moreStyle">更多 ></div>
</div>
<!-- 老数据:两列网格 -->
@@ -14,7 +14,7 @@
</div>
</div>
<!-- 样式一:店铺卡片三列 -->
<!-- 样式一:店铺卡片三列(全景大片橱窗) -->
<div v-else-if="shopStyle === 'one'" class="shop-grid">
<div class="shop-card" v-for="(shop, i) in previewShops" :key="i">
<img
@@ -25,6 +25,7 @@
alt=""
/>
<div v-else class="shop-card-bg shop-card-bg-empty" :style="logoStyle"></div>
<div class="shop-card-badge" v-if="isSelf(shop)">自营</div>
<div class="shop-card-body">
<img v-if="showLogo" class="shop-card-logo" :src="shop.logo" alt="" />
<div v-if="showName" class="shop-card-name" :style="nameStyle">{{ shop.name }}</div>
@@ -32,19 +33,21 @@
</div>
</div>
<!-- 样式二:店铺卡片 + 商品列表 -->
<!-- 样式二:店铺卡片 + 商品列表(大牌名店街 + 精品橱窗) -->
<div v-else class="shop-street-list">
<div class="shop-street-card" v-for="(shop, i) in previewShops" :key="i">
<div class="shop-street-head">
<img v-if="showLogo" class="shop-street-logo" :src="shop.logo" alt="" />
<div v-if="showName" class="shop-street-name" :style="nameStyle">{{ shop.name }}</div>
<img
v-if="shop.goodsImage"
class="shop-street-banner"
:style="logoStyle"
:src="shop.goodsImage"
alt=""
/>
<div class="shop-street-info">
<img v-if="showLogo" class="shop-street-logo" :src="shop.logo" alt="" />
<div class="shop-street-meta">
<div class="shop-street-name-row">
<div v-if="showName" class="shop-street-name" :style="nameStyle">{{ shop.name }}</div>
<span class="shop-street-tag" v-if="isSelf(shop)">自营</span>
</div>
<div class="shop-street-desc">热卖好物甄选中</div>
</div>
</div>
<div class="shop-street-enter">进店逛逛 &gt;</div>
</div>
<div class="shop-street-goods">
<div
@@ -52,9 +55,13 @@
v-for="(goods, goodsIndex) in shop.goods"
:key="goodsIndex"
>
<img class="street-goods-img" :src="goods.img" alt="" />
<div class="street-goods-cover">
<img class="street-goods-img" :src="goods.img" alt="" />
</div>
<div class="street-goods-name">{{ goods.name }}</div>
<div class="street-goods-price">¥{{ $filters.unitPrice(goods.price) }}</div>
<div class="street-goods-price">
<span class="street-goods-symbol">¥</span>{{ $filters.unitPrice(goods.price) }}
</div>
</div>
</div>
</div>
@@ -138,8 +145,9 @@ export default {
id: s.id,
name: s.storeName,
logo: s.storeLogo,
selfOperated: s.selfOperated,
goods,
goodsImage: goods[0]?.img || s.goodsImage || s.storeBanner || "",
goodsImage: goods[0]?.img || s.goodsImage || s.storeBanner || s.storeLogo || "",
};
});
}
@@ -174,7 +182,7 @@ export default {
return {
background: this.res.bgColor || "#FFFFFF",
padding: `10px ${this.res.paddingX || 0}px`,
marginBottom: `${this.res.pageGap !== undefined ? this.res.pageGap : 10}px`,
marginBottom: `${this.res.pageGap !== undefined ? this.res.pageGap : 0}px`,
borderRadius: `${this.res.borderRadius || 0}px`,
overflow: "hidden",
};
@@ -205,6 +213,17 @@ export default {
this.loadGoodsImages();
},
methods: {
isSelf(shop) {
if (!shop) return false;
return Boolean(
shop.selfOperated === true ||
shop.selfOperated === 'true' ||
shop.selfOperated === 1 ||
shop.selfOperated === '1' ||
(shop.name && shop.name.includes('自营')) ||
(shop.storeName && shop.storeName.includes('自营'))
);
},
loadGoodsImages() {
if (this.first.dataMode !== "custom") return;
(this.first.shopList || []).forEach((shop) => {
@@ -282,132 +301,213 @@ export default {
color: #333;
margin-left: 8px;
}
/* 样式一 */
/* 样式一:全景大片橱窗 */
.shop-grid {
display: flex;
gap: 8px;
padding: 0 12px;
}
.shop-card {
position: relative;
flex: 1;
min-width: 0;
border-radius: 6px;
height: 112px;
border-radius: 8px;
overflow: hidden;
background: #f7f7f7;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.shop-card-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 56px;
height: 100%;
display: block;
object-fit: cover;
background: #eee;
}
.shop-card-bg-empty {
background: linear-gradient(135deg, #f5f5f5, #e8e8e8);
}
.shop-card-badge {
position: absolute;
top: 5px;
right: 5px;
z-index: 2;
font-size: 10px;
color: #fff;
background: #e1251b;
padding: 1px 5px;
border-radius: 4px;
line-height: 1.3;
font-weight: 500;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.shop-card-body {
padding: 4px;
text-align: center;
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 2;
padding: 16px 6px 6px;
display: flex;
align-items: center;
gap: 4px;
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.25) 35%, rgba(0, 0, 0, 0.72) 100%);
}
.shop-card-logo {
position: relative;
z-index: 1;
flex-shrink: 0;
display: block;
width: 60px;
height: 24px;
margin: -12px auto 0;
width: 20px;
height: 20px;
border-radius: 3px;
object-fit: contain;
background: #fff;
padding: 1px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
}
.shop-card-name {
font-size: 12px;
margin-top: 3px;
flex: 1;
min-width: 0;
font-size: 11px;
font-weight: 600;
color: #fff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
}
/* 样式二:店铺卡片 + 商品列表 */
/* 样式二:大牌名店街 + 精品橱窗 */
.shop-street-list {
padding: 0 12px;
display: flex;
flex-direction: column;
gap: 8px;
gap: 10px;
}
.shop-street-card {
border-radius: 6px;
border-radius: 10px;
overflow: hidden;
background: #f7f7f7;
background: #fbfbfb;
border: 1px solid #f0f0f0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
}
.shop-street-head {
position: relative;
display: flex;
align-items: center;
height: 34px;
padding: 0 8px;
box-sizing: border-box;
background: $theme_color;
overflow: hidden;
justify-content: space-between;
padding: 8px 10px;
background: #fff;
border-bottom: 1px solid #f5f5f5;
}
.shop-street-info {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.shop-street-logo {
flex-shrink: 0;
width: 44px;
height: 16px;
padding: 1px 3px;
width: 32px;
height: 32px;
padding: 2px;
box-sizing: border-box;
object-fit: contain;
background: #fff;
border-radius: 2px;
border-radius: 50%;
border: 1px solid #eee;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.shop-street-meta {
min-width: 0;
flex: 1;
}
.shop-street-name-row {
display: flex;
align-items: center;
gap: 5px;
}
.shop-street-name {
max-width: calc(100% - 110px);
margin-left: 6px;
color: #fff;
font-size: 13px;
font-weight: 600;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.shop-street-banner {
position: absolute;
top: 0;
right: 0;
width: 80px;
height: 34px;
object-fit: cover;
.shop-street-tag {
flex-shrink: 0;
font-size: 9px;
color: #fff;
background: #e1251b;
padding: 0 4px;
border-radius: 3px;
line-height: 14px;
font-weight: 500;
}
.shop-street-desc {
font-size: 10px;
color: #999;
margin-top: 2px;
}
.shop-street-enter {
flex-shrink: 0;
font-size: 10px;
font-weight: 500;
color: $theme_color;
background: #fff;
border: 1px solid rgba(255, 60, 42, 0.25);
padding: 3px 8px;
border-radius: 12px;
box-shadow: 0 1px 2px rgba(255, 60, 42, 0.05);
cursor: pointer;
margin-left: 8px;
}
.shop-street-goods {
display: flex;
gap: 6px;
padding: 6px;
gap: 8px;
padding: 8px;
box-sizing: border-box;
}
.street-goods-item {
flex: 1;
min-width: 0;
border-radius: 4px;
border-radius: 6px;
overflow: hidden;
background: #fff;
border: 1px solid #f2f2f2;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
}
.street-goods-cover {
width: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
background: #fafafa;
}
.street-goods-img {
width: 100%;
height: 58px;
height: 100%;
display: block;
object-fit: cover;
background: #eee;
}
.street-goods-name {
padding: 4px 4px 0;
color: #282828;
padding: 4px 6px 0;
color: #333;
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.street-goods-price {
padding: 2px 4px 5px;
padding: 2px 6px 6px;
color: $theme_color;
font-size: 11px;
font-weight: 600;
font-size: 12px;
font-weight: 700;
}
.street-goods-symbol {
font-size: 9px;
font-weight: normal;
margin-right: 1px;
}
</style>

View File

@@ -7,7 +7,7 @@
<span class="title-sub" :style="subStyle" v-if="item.title1">{{ item.title1 }}</span>
</div>
<div v-if="item.showMore" class="title-more" :style="moreStyle">
{{ item.moreText || "更多" }} &gt;
{{ item.moreText || "更多" }} >
</div>
</div>
<!-- 老数据:保持原样 -->

View File

@@ -24,6 +24,11 @@
{{ formatTime(row.createTime) }}
</template>
</el-table-column>
<el-table-column label="修改时间" width="180">
<template #default="{ row }">
{{ formatTime(row.updateTime) }}
</template>
</el-table-column>
<el-table-column label="状态" width="120" align="center">
<template #default="{ row }">
<el-switch

View File

@@ -0,0 +1,129 @@
<template>
<div class="layout">
<el-form ref="formValidateRef" label-width="200px" label-position="right" :model="formValidate" :rules="ruleValidate">
<el-form-item label="数据脱敏级别" prop="sensitiveLevel">
<el-radio-group v-model="formValidate.sensitiveLevel">
<el-radio :value="0">不做脱敏</el-radio>
<el-radio :value="1">管理端脱敏</el-radio>
<el-radio :value="2">全端脱敏</el-radio>
</el-radio-group>
<span class="desc">控制手机号、收货地址、身份证等敏感信息脱敏显示策略</span>
</el-form-item>
<el-form-item label="演示站点保护" prop="isDemoSite">
<el-switch v-model="formValidate.isDemoSite" active-text="开启" inactive-text="关闭" />
<span class="desc">开启后拦截核心管理端修改与删除请求,防止演示数据被篡改</span>
</el-form-item>
<el-form-item label="买家端Token有效期" prop="buyerTokenExpireTime">
<el-input type="number" v-model="formValidate.buyerTokenExpireTime">
<template #append>分钟</template>
</el-input>
<span class="desc">买家免密登录会话有效时长</span>
</el-form-item>
<el-form-item label="管理/商家端Token有效期" prop="managerTokenExpireTime">
<el-input type="number" v-model="formValidate.managerTokenExpireTime">
<template #append>分钟</template>
</el-input>
<span class="desc">管理后台及店铺端操作人员会话有效时长</span>
</el-form-item>
<div class="label-btns" style="margin-left: 200px;">
<el-button type="primary" @click="submit('formValidateRef')">保存设置</el-button>
</div>
</el-form>
</div>
</template>
<script setup>
import { getCurrentInstance, reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import { setSetting } from "@/api/index";
import { handleSubmit } from "./validate";
const props = defineProps(["res", "type"]);
const { proxy } = getCurrentInstance();
const formValidateRef = ref(null);
const formValidate = ref({
sensitiveLevel: 1,
isDemoSite: false,
buyerTokenExpireTime: 259200,
managerTokenExpireTime: 86400,
});
const ruleValidate = reactive({
buyerTokenExpireTime: [
{ required: true, message: "请输入买家端Token有效时间", trigger: "blur" }
],
managerTokenExpireTime: [
{ required: true, message: "请输入管理端Token有效时间", trigger: "blur" }
],
});
function submit(name) {
handleSubmit(proxy, name)
.then(() => {
setupSetting();
})
.catch(() => {});
}
function setupSetting() {
setSetting(props.type, formValidate.value).then((res) => {
if (res.success) {
ElMessage.success("保存成功!");
} else {
ElMessage.error(res.message || "保存失败!");
}
});
}
function init() {
if (props.res) {
try {
const parsed = typeof props.res === "string" ? JSON.parse(props.res) : props.res;
formValidate.value = {
sensitiveLevel: Number(parsed.sensitiveLevel ?? 1),
isDemoSite: Boolean(parsed.isDemoSite ?? false),
buyerTokenExpireTime: Number(parsed.buyerTokenExpireTime ?? 259200),
managerTokenExpireTime: Number(parsed.managerTokenExpireTime ?? 86400),
};
} catch (e) {
console.error("解析安全设置失败", e);
}
}
}
init();
</script>
<style lang="scss" scoped>
@import "./style.scss";
:deep(.el-form-item__label) {
white-space: nowrap !important;
}
:deep(.el-form-item__content) {
display: flex;
align-items: center;
flex-wrap: nowrap;
white-space: nowrap;
}
.el-radio-group {
display: inline-flex;
flex-direction: row;
align-items: center;
white-space: nowrap;
}
.desc {
margin-left: 12px;
color: #999;
font-size: 12px;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,128 @@
<template>
<div class="layout">
<el-form ref="formValidateRef" label-width="160px" label-position="right" :model="formValidate" :rules="ruleValidate">
<el-form-item label="有效时间(秒)" prop="effectiveTime">
<el-input-number v-model="formValidate.effectiveTime" :min="30" :max="3600" />
<span class="desc">包含滑块生成及校验结果缓存的有效时间(秒)</span>
</el-form-item>
<el-form-item label="水印文字" prop="watermark">
<el-input class="label-appkey" v-model="formValidate.watermark" placeholder="例如: LILI-SHOP" />
<span class="desc">滑块背景图水印文本</span>
</el-form-item>
<el-form-item label="干扰块数量" prop="interfereNum">
<el-input-number v-model="formValidate.interfereNum" :min="0" :max="2" />
<span class="desc">滑块干扰块数量,最大为 2</span>
</el-form-item>
<el-form-item label="容错像素" prop="faultTolerant">
<el-input-number v-model="formValidate.faultTolerant" :min="0" :max="20" />
<span class="desc">滑块比对横坐标容错像素范围</span>
</el-form-item>
<el-form-item label="登录免验证码" prop="loginBypassEnabled">
<el-switch v-model="formValidate.loginBypassEnabled" active-text="开启" inactive-text="关闭" />
<span class="desc">开启后登录流程跳过滑块校验,仅建议本地或测试验收环境开启</span>
</el-form-item>
<div class="label-btns">
<el-button type="primary" @click="submit('formValidateRef')">保存</el-button>
</div>
</el-form>
</div>
</template>
<script setup>
import { getCurrentInstance, reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import { setSetting } from "@/api/index";
import { handleSubmit } from "./validate";
const props = defineProps(["res", "type"]);
const { proxy } = getCurrentInstance();
const formValidateRef = ref(null);
const formValidate = ref({
effectiveTime: 300,
watermark: "LILI-SHOP",
interfereNum: 0,
faultTolerant: 3,
loginBypassEnabled: false,
});
const ruleValidate = reactive({
effectiveTime: [
{ required: true, message: "请输入有效时间", trigger: "blur" }
],
watermark: [
{ required: true, message: "请输入水印文字", trigger: "blur" }
],
interfereNum: [
{ required: true, message: "请输入干扰块数量", trigger: "blur" }
],
faultTolerant: [
{ required: true, message: "请输入容错像素", trigger: "blur" }
],
});
function submit(name) {
handleSubmit(proxy, name)
.then(() => {
setupSetting();
})
.catch(() => {});
}
function setupSetting() {
setSetting(props.type, formValidate.value).then((res) => {
if (res.success) {
ElMessage.success("保存成功!");
} else {
ElMessage.error(res.message || "保存失败!");
}
});
}
function init() {
if (props.res) {
try {
const parsed = typeof props.res === "string" ? JSON.parse(props.res) : props.res;
formValidate.value = {
effectiveTime: Number(parsed.effectiveTime ?? 300),
watermark: String(parsed.watermark ?? "LILI-SHOP"),
interfereNum: Number(parsed.interfereNum ?? 0),
faultTolerant: Number(parsed.faultTolerant ?? 3),
loginBypassEnabled: Boolean(parsed.loginBypassEnabled ?? false),
};
} catch (e) {
console.error("解析验证码设置失败", e);
}
}
}
init();
</script>
<style lang="scss" scoped>
@import "./style.scss";
:deep(.el-form-item__label) {
white-space: nowrap !important;
}
:deep(.el-form-item__content) {
display: flex;
align-items: center;
flex-wrap: nowrap;
white-space: nowrap;
padding-bottom: 5px;
}
.desc {
margin-left: 10px;
color: #999;
font-size: 12px;
white-space: nowrap;
}
</style>

View File

@@ -75,6 +75,14 @@ const setting = ref([
type: "LIVE_SETTING",
name: "直播设置",
},
{
type: "VERIFICATION_SETTING",
name: "验证码设置",
},
{
type: "SECURITY_SETTING",
name: "安全设置",
},
]);
const authLogin = ref([
// 登录设置

View File

@@ -9,6 +9,8 @@ import SMS_SETTING from "./setting/SMS_SETTING";
import IM_SETTING from "./setting/IM_SETTING";
import LIVE_SETTING from "./setting/LIVE_SETTING";
import WITHDRAWAL_SETTING from "./setting/WITHDRAWAL_SETTING";
import VERIFICATION_SETTING from "./setting/VERIFICATION_SETTING";
import SECURITY_SETTING from "./setting/SECURITY_SETTING";
import ALIPAY_PAYMENT from "./pay/ALIPAY_PAYMENT";
import WECHAT_PAYMENT from "./pay/WECHAT_PAYMENT";
import PAYMENT_SUPPORT from "./pay/PAYMENT_SUPPORT";
@@ -27,6 +29,8 @@ export default {
IM_SETTING: markRaw(IM_SETTING),
LIVE_SETTING: markRaw(LIVE_SETTING),
WITHDRAWAL_SETTING: markRaw(WITHDRAWAL_SETTING),
VERIFICATION_SETTING: markRaw(VERIFICATION_SETTING),
SECURITY_SETTING: markRaw(SECURITY_SETTING),
PAYMENT_SUPPORT: markRaw(PAYMENT_SUPPORT),
WECHAT_PAYMENT: markRaw(WECHAT_PAYMENT),
ALIPAY_PAYMENT: markRaw(ALIPAY_PAYMENT),

View File

@@ -0,0 +1,350 @@
<template>
<div class="panel-ad-cube">
<div class="panel-header">
<span class="panel-name">图片魔方</span>
<div class="panel-tabs">
<div
v-for="tab in tabs"
:key="tab.key"
class="panel-tab"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.title }}
</div>
</div>
</div>
<template v-if="activeTab === 'content'">
<div class="panel-card panel-card-fill">
<div class="section-title">图片设置</div>
<div class="section-tip">当前风格:{{ res.name }},请按布局区域配置对应图片</div>
<div class="img-item" v-for="(item, index) in res.options.list" :key="index">
<div class="img-item-head">
<span>图片 {{ index + 1 }}</span>
<span class="img-size" v-if="item.size">建议尺寸 {{ item.size }}</span>
</div>
<div class="img-preview" v-if="item.img">
<img :src="item.img" alt="" />
</div>
<div class="img-placeholder" v-else>尺寸 {{ item.size || "不限" }}</div>
<div class="field-row" v-if="item.title !== undefined">
<div class="field-label">文字</div>
<el-input v-model="item.title" maxlength="10" />
</div>
<div class="btn-row">
<el-button plain type="primary" @click="pickImage(item)">选择照片</el-button>
<el-button plain type="primary" @click="pickLink(item)">选择链接</el-button>
</div>
<div class="link-view" v-if="item.url && item.url.___type">
已选链接:{{ linkText(item.url) }}
</div>
</div>
</div>
</template>
<template v-else>
<div class="panel-card">
<div class="section-title">风格选择</div>
<div class="section-tip">切换风格将重置当前已配置的图片内容</div>
<div class="style-grid">
<div
v-for="style in styleList"
:key="style.type"
class="style-item"
:class="{ active: res.type === style.type }"
@click="changeStyle(style)"
>
{{ style.name }}
</div>
</div>
</div>
<div class="panel-card">
<div class="section-title">颜色设置</div>
<gradient-field
v-model:start="res.options.bgColor1"
v-model:end="res.options.bgColor2"
:default-start="defaults.bgColor1"
:default-end="defaults.bgColor2"
/>
</div>
<div class="panel-card">
<div class="section-title">边距设置</div>
<slider-field v-model="res.options.paddingTop" label="上边距" :min="0" :max="100" />
<slider-field v-model="res.options.paddingBottom" label="下边距" :min="0" :max="100" />
<slider-field v-model="res.options.paddingX" label="左右边距" :min="0" :max="40" />
<slider-field v-model="res.options.pageGap" label="页面间距" :min="0" :max="100" />
<slider-field v-model="res.options.contentGap" label="内容间距" :min="0" :max="20" />
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">圆角设置</div>
<slider-field v-model="res.options.borderRadius" label="背景圆角" :min="0" :max="30" />
<slider-field v-model="res.options.imgRadius" label="内容圆角" :min="0" :max="30" />
</div>
</template>
<liliDialog ref="liliDialog" @selectedLink="selectedLink"></liliDialog>
<el-dialog v-model="picModelFlag" width="1200px" append-to-body destroy-on-close>
<ossManage
@callback="callbackSelected"
:is-component="true"
:initialize="picModelFlag"
:max-select="1"
ref="ossManage"
/>
</el-dialog>
</div>
</template>
<script>
import ossManage from "@/views/sys/oss-manage/ossManage";
import ways from "@/views/lili-dialog/wap.js";
import { modelData } from "../config";
// import ColorField from "../controls/ColorField.vue";
import GradientField from "../controls/GradientField.vue";
import SliderField from "../controls/SliderField.vue";
const DEFAULTS = {
bgColor1: "#FFFFFF",
bgColor2: "#FFFFFF",
paddingTop: 10,
paddingBottom: 10,
paddingX: 12,
pageGap: 0,
contentGap: 0,
borderRadius: 0,
imgRadius: 0,
};
export default {
name: "PanelAdCube",
hideDecorateTitle: true,
components: { ossManage, GradientField, SliderField },
props: {
res: { type: Object, required: true },
},
data() {
return {
defaults: DEFAULTS,
activeTab: "content",
tabs: [
{ key: "content", title: "内容" },
{ key: "style", title: "样式" },
],
picModelFlag: false,
currentItem: null,
};
},
computed: {
// 图片魔方的可选风格(config 中 drawer 标记的条目)
styleList() {
return modelData.filter((m) => m.drawer);
},
},
created() {
this.res.options = Object.assign({}, DEFAULTS, this.res.options);
},
methods: {
linkText(url) {
const way = ways.find((e) => e.name === url.___type);
const typeTitle = way ? way.title : "发现";
const name =
url.name || url.goodsName || url.memberName || url.title || "";
return name ? `${typeTitle} - ${name}` : typeTitle;
},
changeStyle(style) {
if (style.type === this.res.type) return;
this.$emit("handleDrawer", style);
},
pickImage(item) {
this.currentItem = item;
this.picModelFlag = true;
this.$nextTick(() => {
const oss = this.$refs.ossManage;
if (!oss) return;
oss.selectImage = true;
oss.fileType = "pic";
oss.changeFileType();
});
},
callbackSelected(val) {
this.picModelFlag = false;
if (val && val.url && this.currentItem) {
this.currentItem.img = val.url;
}
},
pickLink(item) {
this.currentItem = item;
const dialog = this.$refs.liliDialog;
dialog.clearGoodsSelected();
dialog.goodsFlag = false;
dialog.flag = true;
},
selectedLink(val) {
if (!this.currentItem) return;
delete val.selected;
delete val.intro;
delete val.mobileIntro;
this.currentItem.url = val;
},
},
};
</script>
<style scoped lang="scss">
@import "../controls/panel-common.scss";
.panel-ad-cube {
background: #f5f7fa;
margin: 0 -20px;
padding: 0 12px 12px;
min-height: 100%;
box-sizing: border-box;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 50px;
margin-bottom: 12px;
padding: 0 14px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
box-sizing: border-box;
.panel-name {
font-size: 16px;
font-weight: bold;
color: #303133;
}
}
.panel-tabs {
display: inline-flex;
background: #f4f4f5;
border-radius: 8px;
padding: 3px;
.panel-tab {
padding: 5px 14px;
font-size: 13px;
border-radius: 6px;
cursor: pointer;
color: #606266;
transition: all 0.2s ease;
&.active {
background: #ffffff;
color: #409eff;
font-weight: 600;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
}
}
}
.panel-card {
background: #ffffff;
border-radius: 8px;
padding: 14px;
margin-bottom: 12px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin: 0 0 12px;
}
.section-tip {
font-size: 13px;
color: #909399;
margin-bottom: 10px;
}
.img-item {
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 10px;
margin-bottom: 10px;
.img-item-head {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
color: #303133;
margin-bottom: 8px;
.img-size {
font-size: 12px;
color: #909399;
}
}
}
.img-preview {
border: 1px dashed #dcdfe6;
border-radius: 6px;
padding: 6px;
margin-bottom: 10px;
img {
width: 100%;
display: block;
border-radius: 4px;
}
}
.img-placeholder {
height: 80px;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed #dcdfe6;
border-radius: 6px;
color: #909399;
font-size: 12px;
margin-bottom: 10px;
}
.field-row {
display: flex;
align-items: center;
margin-bottom: 10px;
.field-label {
font-size: 14px;
color: #606266;
margin-right: 10px;
flex-shrink: 0;
}
}
.btn-row {
display: flex;
gap: 10px;
}
.link-view {
margin-top: 8px;
font-size: 12px;
color: #909399;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.style-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
.style-item {
border: 1px solid #dcdfe6;
border-radius: 6px;
padding: 10px 4px;
text-align: center;
font-size: 12px;
color: #606266;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: #409eff;
color: #409eff;
}
&.active {
border-color: #409eff;
background: #ecf5ff;
color: #409eff;
font-weight: 600;
}
}
}
</style>

View File

@@ -0,0 +1,69 @@
<template>
<div class="panel-blank">
<div class="panel-header">
<span class="panel-name">{{ res.name }}</span>
</div>
<div class="panel-card">
<div class="section-title">颜色设置</div>
<gradient-field
v-model:start="res.options.bgColor1"
v-model:end="res.options.bgColor2"
:default-start="defaults.bgColor1"
:default-end="defaults.bgColor2"
/>
</div>
<div class="panel-card">
<div class="section-title">高度设置</div>
<slider-field v-model="res.options.height" label="空白高度" :min="1" :max="100" />
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">边距设置</div>
<slider-field v-model="res.options.paddingX" label="左右边距" :min="0" :max="40" />
<slider-field v-model="res.options.pageGap" label="页面间距" :min="0" :max="100" />
</div>
</div>
</template>
<script>
import GradientField from "../controls/GradientField.vue";
import SliderField from "../controls/SliderField.vue";
const DEFAULTS = {
bgColor: "#f5f5f5",
bgColor1: "#f5f5f5",
bgColor2: "#f5f5f5",
height: 10,
paddingX: 0,
pageGap: 0,
};
export default {
name: "PanelBlank",
hideDecorateTitle: true,
components: { GradientField, SliderField },
props: {
res: { type: Object, required: true },
},
data() {
return { defaults: DEFAULTS };
},
created() {
this.res.options = Object.assign({}, DEFAULTS, this.res.options);
},
};
</script>
<style scoped lang="scss">
@import "../controls/panel-common.scss";
.panel-blank {
background: #f5f7fa;
margin: 0 -20px;
padding: 0 12px 12px;
min-height: 100%;
box-sizing: border-box;
}
</style>

View File

@@ -0,0 +1,461 @@
<template>
<div class="panel-carousel">
<div class="panel-header">
<span class="panel-name">{{ res.name }}</span>
<div class="panel-tabs">
<div
v-for="tab in tabs"
:key="tab.key"
class="panel-tab"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.title }}
</div>
</div>
</div>
<template v-if="activeTab === 'content'">
<div class="panel-card panel-card-fill">
<div class="section-title">图片设置</div>
<div class="img-item" v-for="(item, index) in res.options.list" :key="index">
<div class="img-item-head">
<span>图片 {{ index + 1 }}</span>
<div class="img-item-ops">
<el-icon
:class="{ disabled: index === 0 }"
@click="moveItem(index, -1)"
><Top
/></el-icon>
<el-icon
:class="{ disabled: index === res.options.list.length - 1 }"
@click="moveItem(index, 1)"
><Bottom
/></el-icon>
<el-icon class="del" @click="removeItem(index)"><Delete /></el-icon>
</div>
</div>
<div class="img-item-body">
<div class="img-thumb" v-if="item.img">
<img :src="item.img" alt="" />
</div>
<div class="img-thumb placeholder" v-else>750*350</div>
<div class="img-item-fields">
<div class="btn-row">
<el-button plain type="primary" @click="pickImage(item)">选择照片</el-button>
<el-button plain type="primary" @click="pickLink(item)">选择链接</el-button>
</div>
<div class="link-view" v-if="item.url && item.url.___type">
已选链接:{{ linkText(item.url) }}
</div>
<div class="link-view empty" v-else>未设置链接</div>
</div>
</div>
</div>
<el-button
v-if="res.options.list.length < 10"
class="add-btn"
plain
type="primary"
@click="addItem"
>添加图片</el-button
>
</div>
</template>
<template v-else>
<div class="panel-card">
<div class="section-title">轮播风格</div>
<div class="ctrl-row">
<div class="ctrl-label">风格</div>
<div class="ctrl-body">
<div class="seg layout-seg">
<div
v-for="opt in swiperStyleOptions"
:key="opt.value"
class="seg-item"
:class="{ active: res.options.swiperStyle === opt.value }"
@click="res.options.swiperStyle = opt.value"
>
{{ opt.title }}
</div>
</div>
</div>
</div>
<div class="section-tip" v-if="res.options.swiperStyle === 'card'">
卡片风格下,两侧会露出前后图片的一部分
</div>
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">指示器设置</div>
<div class="ctrl-row">
<div class="ctrl-label">指示器样式</div>
<div class="ctrl-body">
<div class="seg layout-seg-3">
<div
v-for="opt in indicatorStyleOptions"
:key="opt.value"
class="seg-item"
:class="{ active: res.options.indicatorStyle === opt.value }"
@click="res.options.indicatorStyle = opt.value"
>
{{ opt.title }}
</div>
</div>
</div>
</div>
<div class="ctrl-row" v-if="res.options.indicatorStyle !== 'none'">
<div class="ctrl-label">指示器位置</div>
<div class="ctrl-body">
<div class="seg layout-seg-3">
<div
v-for="opt in indicatorPositionOptions"
:key="opt.value"
class="seg-item"
:class="{ active: res.options.indicatorPosition === opt.value }"
@click="res.options.indicatorPosition = opt.value"
>
{{ opt.title }}
</div>
</div>
</div>
</div>
<div class="ctrl-row" v-if="res.options.indicatorStyle !== 'none'">
<div class="ctrl-label">色调</div>
<div class="ctrl-body">
<el-radio-group v-model="res.options.indicatorColorMode">
<el-radio value="theme">跟随主题风格</el-radio>
<el-radio value="custom">自定义</el-radio>
</el-radio-group>
</div>
</div>
<template
v-if="
res.options.indicatorStyle !== 'none' &&
res.options.indicatorColorMode === 'custom'
"
>
<color-field
v-model="res.options.indicatorColor"
label="指示器颜色"
:default-value="defaults.indicatorColor"
/>
</template>
</div>
<div class="panel-card">
<div class="section-title">颜色设置</div>
<gradient-field
v-model:start="res.options.bgColor1"
v-model:end="res.options.bgColor2"
:default-start="defaults.bgColor1"
:default-end="defaults.bgColor2"
/>
</div>
<div class="panel-card">
<div class="section-title">边距设置</div>
<slider-field v-model="res.options.paddingTop" label="上边距" :min="0" :max="100" />
<slider-field v-model="res.options.paddingBottom" label="下边距" :min="0" :max="100" />
<slider-field v-model="res.options.paddingX" label="左右边距" :min="0" :max="40" />
<slider-field v-model="res.options.pageGap" label="页面间距" :min="0" :max="100" />
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">圆角设置</div>
<slider-field v-model="res.options.borderRadius" label="背景圆角" :min="0" :max="30" />
<slider-field v-model="res.options.imgRadius" label="内容圆角" :min="0" :max="30" />
</div>
</template>
<liliDialog ref="liliDialog" @selectedLink="selectedLink"></liliDialog>
<el-dialog v-model="picModelFlag" width="1200px" append-to-body destroy-on-close>
<ossManage
@callback="callbackSelected"
:is-component="true"
:initialize="picModelFlag"
:max-select="1"
ref="ossManage"
/>
</el-dialog>
</div>
</template>
<script>
import { Top, Bottom, Delete } from "@element-plus/icons-vue";
import ColorField from "../controls/ColorField.vue";
import GradientField from "../controls/GradientField.vue";
import SliderField from "../controls/SliderField.vue";
import ossManage from "@/views/sys/oss-manage/ossManage";
import ways from "@/views/lili-dialog/wap.js";
const DEFAULTS = {
swiperStyle: "full",
indicatorStyle: "round",
indicatorPosition: "center",
indicatorColorMode: "theme",
indicatorColor: "#409eff",
bgColor1: "#FFFFFF",
bgColor2: "#FFFFFF",
paddingTop: 10,
paddingBottom: 10,
paddingX: 12,
pageGap: 0,
borderRadius: 0,
imgRadius: 7,
};
export default {
name: "PanelCarousel",
hideDecorateTitle: true,
components: { ColorField, GradientField, SliderField, ossManage, Top, Bottom, Delete },
props: {
res: { type: Object, required: true },
},
data() {
return {
defaults: DEFAULTS,
activeTab: "content",
tabs: [
{ key: "content", title: "内容" },
{ key: "style", title: "样式" },
],
swiperStyleOptions: [
{ value: "full", title: "通栏" },
{ value: "card", title: "卡片" },
],
indicatorStyleOptions: [
{ value: "round", title: "圆形" },
{ value: "line", title: "长条" },
{ value: "none", title: "无" },
],
indicatorPositionOptions: [
{ value: "left", title: "居左" },
{ value: "center", title: "居中" },
{ value: "right", title: "居右" },
],
picModelFlag: false,
currentItem: null,
};
},
created() {
this.res.options = Object.assign({}, DEFAULTS, this.res.options);
if (!Array.isArray(this.res.options.list)) {
this.res.options.list = [];
}
},
methods: {
linkText(url) {
const way = ways.find((e) => e.name === url.___type);
const typeTitle = way ? way.title : "发现";
const name =
url.name || url.goodsName || url.memberName || url.title || "";
return name ? `${typeTitle} - ${name}` : typeTitle;
},
pickImage(item) {
this.currentItem = item;
this.picModelFlag = true;
this.$nextTick(() => {
const oss = this.$refs.ossManage;
if (!oss) return;
oss.selectImage = true;
oss.fileType = "pic";
oss.changeFileType();
});
},
callbackSelected(val) {
this.picModelFlag = false;
if (val && val.url && this.currentItem) {
this.currentItem.img = val.url;
}
},
pickLink(item) {
this.currentItem = item;
const dialog = this.$refs.liliDialog;
dialog.clearGoodsSelected();
dialog.goodsFlag = false;
dialog.flag = true;
},
selectedLink(val) {
if (!this.currentItem) return;
delete val.selected;
delete val.intro;
delete val.mobileIntro;
this.currentItem.url = val;
},
moveItem(index, dir) {
const list = this.res.options.list;
const target = index + dir;
if (target < 0 || target >= list.length) return;
list.splice(target, 1, ...list.splice(index, 1, list[target]));
},
removeItem(index) {
this.res.options.list.splice(index, 1);
},
addItem() {
this.res.options.list.push({
img: "",
url: "",
link: "",
size: "750*350",
model: "link",
});
},
},
};
</script>
<style scoped lang="scss">
@import "../controls/field-common.scss";
@import "../controls/panel-common.scss";
.panel-carousel {
background: #f5f7fa;
margin: 0 -20px;
padding: 0 12px 12px;
min-height: 100%;
box-sizing: border-box;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 50px;
margin-bottom: 12px;
padding: 0 14px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
box-sizing: border-box;
.panel-name {
font-size: 16px;
font-weight: bold;
color: #303133;
}
}
.panel-tabs {
display: inline-flex;
background: #f4f4f5;
border-radius: 8px;
padding: 3px;
.panel-tab {
padding: 5px 14px;
font-size: 13px;
border-radius: 6px;
cursor: pointer;
color: #606266;
transition: all 0.2s ease;
&.active {
background: #ffffff;
color: #409eff;
font-weight: 600;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
}
}
}
.panel-card {
background: #ffffff;
border-radius: 8px;
padding: 14px;
margin-bottom: 12px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin: 0 0 12px;
}
.section-tip {
font-size: 13px;
color: #909399;
margin-bottom: 10px;
}
.img-item {
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 10px;
margin-bottom: 10px;
.img-item-head {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
color: #303133;
margin-bottom: 8px;
.img-item-ops {
display: flex;
gap: 4px;
.el-icon {
padding: 4px;
cursor: pointer;
color: #606266;
&:hover {
color: #409eff;
}
&.del:hover {
color: #e1251b;
}
&.disabled {
color: #dcdfe6;
cursor: not-allowed;
}
}
}
}
}
.img-item-body {
display: flex;
gap: 10px;
}
.img-thumb {
width: 132px;
height: 62px;
border: 1px dashed #dcdfe6;
border-radius: 6px;
padding: 3px;
box-sizing: border-box;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
display: block;
border-radius: 4px;
object-fit: cover;
}
&.placeholder {
display: flex;
align-items: center;
justify-content: center;
color: #909399;
font-size: 12px;
}
}
.img-item-fields {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
.link-view {
margin-top: 0;
}
}
.btn-row {
display: flex;
gap: 10px;
}
.link-view {
margin-top: 8px;
font-size: 12px;
color: #909399;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.add-btn {
width: 100%;
}
</style>

View File

@@ -0,0 +1,145 @@
<template>
<div class="panel-coupon">
<div class="panel-header">
<span class="panel-name">{{ res.name }}</span>
<div class="panel-tabs">
<div
v-for="tab in tabs"
:key="tab.key"
class="panel-tab"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.title }}
</div>
</div>
</div>
<template v-if="activeTab === 'content'">
<div class="panel-card panel-card-fill">
<div class="section-title">展示设置</div>
<div class="ctrl-row">
<div class="ctrl-label">标题</div>
<div class="ctrl-body">
<el-input v-model="firstItem.title" />
</div>
</div>
<slider-field v-model="firstItem.count" label="展示数量" :min="1" :max="10" />
</div>
</template>
<template v-else>
<div class="panel-card">
<div class="section-title">布局设置</div>
<div class="ctrl-row">
<div class="ctrl-label">展示样式</div>
<div class="ctrl-body">
<div class="seg layout-seg">
<div
v-for="opt in layoutOptions"
:key="opt.value"
class="seg-item"
:class="{ active: (res.options.layoutStyle || '1') === opt.value }"
@click="res.options.layoutStyle = opt.value"
>
{{ opt.title }}
</div>
</div>
</div>
</div>
</div>
<div class="panel-card">
<div class="section-title">颜色设置</div>
<gradient-field
v-model:start="res.options.bgColor1"
v-model:end="res.options.bgColor2"
:default-start="defaults.bgColor1"
:default-end="defaults.bgColor2"
/>
</div>
<div class="panel-card">
<div class="section-title">边距设置</div>
<slider-field v-model="res.options.paddingTop" label="上边距" :min="0" :max="100" />
<slider-field v-model="res.options.paddingBottom" label="下边距" :min="0" :max="100" />
<slider-field v-model="res.options.contentGap" label="内容间距" :min="0" :max="20" />
<slider-field v-model="res.options.paddingX" label="左右边距" :min="0" :max="40" />
<slider-field v-model="res.options.pageGap" label="页面间距" :min="0" :max="100" />
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">圆角设置</div>
<slider-field v-model="res.options.borderRadius" label="背景圆角" :min="0" :max="30" />
</div>
</template>
</div>
</template>
<script>
import GradientField from "../controls/GradientField.vue";
import SliderField from "../controls/SliderField.vue";
const DEFAULTS = {
layoutStyle: "1",
bgColor: "#FFFFFF",
bgColor1: "#FFFFFF",
bgColor2: "#FFFFFF",
paddingTop: 10,
paddingBottom: 10,
contentGap: 5,
paddingX: 12,
pageGap: 0,
borderRadius: 0,
};
const DEFAULT_ITEM = { title: "优惠券", count: 5 };
export default {
name: "PanelCoupon",
hideDecorateTitle: true,
components: { GradientField, SliderField },
props: {
res: { type: Object, required: true },
},
data() {
return {
defaults: DEFAULTS,
activeTab: "content",
tabs: [
{ key: "content", title: "内容" },
{ key: "style", title: "样式" },
],
layoutOptions: [
{ value: "1", title: "样式一" },
{ value: "2", title: "样式二" },
{ value: "3", title: "样式三" },
{ value: "4", title: "样式四" },
],
};
},
computed: {
firstItem() {
return this.res.options.list[0];
},
},
created() {
this.res.options = Object.assign({}, DEFAULTS, this.res.options);
if (!Array.isArray(this.res.options.list) || !this.res.options.list[0]) {
this.res.options.list = [{ ...DEFAULT_ITEM }];
}
},
};
</script>
<style scoped lang="scss">
@import "../controls/field-common.scss";
@import "../controls/panel-common.scss";
.panel-coupon {
background: #f5f7fa;
margin: 0 -20px;
padding: 0 12px 12px;
min-height: 100%;
box-sizing: border-box;
}
</style>

View File

@@ -0,0 +1,268 @@
<template>
<div class="panel-flex-one">
<div class="panel-header">
<span class="panel-name">{{ res.name }}</span>
<div class="panel-tabs">
<div
v-for="tab in tabs"
:key="tab.key"
class="panel-tab"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.title }}
</div>
</div>
</div>
<template v-if="activeTab === 'content'">
<div class="panel-card panel-card-fill">
<div class="section-title">内容设置</div>
<div class="img-preview" v-if="item.img">
<img :src="item.img" alt="" />
</div>
<div class="img-placeholder" v-else>尺寸不限</div>
<div class="btn-row">
<el-button plain type="primary" @click="pickImage">选择照片</el-button>
<el-button plain type="primary" @click="openHotzone">绘制热区</el-button>
</div>
</div>
</template>
<template v-else>
<div class="panel-card">
<div class="section-title">颜色设置</div>
<color-field
v-model="res.options.bgColor"
label="背景颜色"
:default-value="defaults.bgColor"
/>
</div>
<div class="panel-card">
<div class="section-title">边距设置</div>
<slider-field
v-model="res.options.paddingTop"
label="上边距"
:min="0"
:max="100"
/>
<slider-field
v-model="res.options.paddingBottom"
label="下边距"
:min="0"
:max="100"
/>
<slider-field
v-model="res.options.paddingX"
label="左右边距"
:min="0"
:max="40"
/>
<slider-field
v-model="res.options.pageGap"
label="页面间距"
:min="0"
:max="100"
/>
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">圆角设置</div>
<slider-field
v-model="res.options.borderRadius"
label="背景圆角"
:min="0"
:max="30"
/>
<slider-field
v-model="res.options.imgRadius"
label="内容圆角"
:min="0"
:max="30"
/>
</div>
</template>
<hotzone ref="hotzone" @changeZone="changeZone"></hotzone>
<el-dialog v-model="picModelFlag" width="1200px" append-to-body destroy-on-close>
<ossManage
@callback="callbackSelected"
:is-component="true"
:initialize="picModelFlag"
:max-select="1"
ref="ossManage"
/>
</el-dialog>
</div>
</template>
<script>
import ColorField from "../controls/ColorField.vue";
import SliderField from "../controls/SliderField.vue";
import hotzone from "@/views/shop/hotzone";
import ossManage from "@/views/sys/oss-manage/ossManage";
import { ElMessage } from "element-plus";
const DEFAULTS = {
bgColor: "#FFFFFF",
paddingTop: 10,
paddingBottom: 10,
paddingX: 12,
pageGap: 0,
borderRadius: 0,
imgRadius: 0,
};
export default {
name: "PanelFlexOne",
hideDecorateTitle: true,
components: { ColorField, SliderField, hotzone, ossManage },
props: {
res: { type: Object, required: true },
},
data() {
return {
defaults: DEFAULTS,
activeTab: "content",
tabs: [
{ key: "content", title: "内容" },
{ key: "style", title: "样式" },
],
picModelFlag: false,
};
},
computed: {
item() {
return this.res.options.list[0];
},
},
created() {
this.res.options = Object.assign({}, DEFAULTS, this.res.options);
if (!Array.isArray(this.res.options.list) || !this.res.options.list.length) {
this.res.options.list = [
{ img: "", url: "", link: "", size: "750*280", model: "hotzone" },
];
}
},
methods: {
pickImage() {
this.picModelFlag = true;
this.$nextTick(() => {
const oss = this.$refs.ossManage;
if (!oss) return;
oss.selectImage = true;
oss.fileType = "pic";
oss.changeFileType();
});
},
callbackSelected(val) {
this.picModelFlag = false;
if (val && val.url) {
this.item.img = val.url;
}
},
openHotzone() {
if (!this.item.img) {
ElMessage.warning("请先选择照片");
return;
}
this.$refs.hotzone.open(this.item);
},
changeZone(zoneInfo) {
this.item.zoneInfo = zoneInfo;
},
},
};
</script>
<style scoped lang="scss">
@import "../controls/panel-common.scss";
.panel-flex-one {
background: #f5f7fa;
margin: 0 -20px;
padding: 0 12px 12px;
min-height: 100%;
box-sizing: border-box;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 50px;
margin-bottom: 12px;
padding: 0 14px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
box-sizing: border-box;
.panel-name {
font-size: 16px;
font-weight: bold;
color: #303133;
}
}
.panel-card {
background: #ffffff;
border-radius: 8px;
padding: 14px;
margin-bottom: 12px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}
.panel-tabs {
display: inline-flex;
background: #f4f4f5;
border-radius: 8px;
padding: 3px;
.panel-tab {
padding: 5px 14px;
font-size: 13px;
border-radius: 6px;
cursor: pointer;
color: #606266;
transition: all 0.2s ease;
&.active {
background: #ffffff;
color: #409eff;
font-weight: 600;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
}
}
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin: 0 0 12px;
}
.section-tip {
font-size: 13px;
color: #909399;
margin-bottom: 10px;
}
.img-preview {
border: 1px dashed #dcdfe6;
border-radius: 6px;
padding: 6px;
margin-bottom: 10px;
img {
width: 100%;
display: block;
border-radius: 4px;
}
}
.img-placeholder {
height: 120px;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed #dcdfe6;
border-radius: 6px;
color: #909399;
font-size: 12px;
margin-bottom: 10px;
}
.btn-row {
display: flex;
gap: 10px;
}
</style>

View File

@@ -0,0 +1,619 @@
<template>
<div class="panel-goods">
<div class="panel-header">
<span class="panel-name">{{ res.name }}</span>
<div class="panel-tabs">
<div
v-for="tab in tabs"
:key="tab.key"
class="panel-tab"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.title }}
</div>
</div>
</div>
<!-- 旧版数据提示:保留原渲染,手动升级后才按新版配置 -->
<div v-if="legacy" class="panel-card panel-card-fill">
<div class="section-title">商品列表</div>
<div class="section-tip">
该楼层为旧版商品列表(分类标签 + 绑定商品),当前仍按旧版展示。
升级后可使用展现形式、购物车按钮、商品筛选等新版功能,旧的分类与商品绑定将被清空。
</div>
<el-button type="primary" plain @click="upgradeLegacy">升级为新版</el-button>
</div>
<template v-else-if="activeTab === 'content'">
<div class="panel-card">
<div class="section-title">展现形式</div>
<div class="ctrl-row">
<div class="ctrl-label">展现形式</div>
<div class="ctrl-body">
<div class="seg layout-seg">
<div
v-for="opt in layoutOptions"
:key="opt.value"
class="seg-item seg-icon"
:class="{ active: firstItem.layout === opt.value }"
@click="changeLayout(opt.value)"
>
<span class="ly-ico" :class="'ly-' + opt.value"><i></i><i></i><i></i></span>
</div>
</div>
</div>
</div>
</div>
<div class="panel-card">
<div class="section-title">显示内容</div>
<div class="ctrl-row">
<div class="ctrl-label">展示信息</div>
<div class="ctrl-body">
<el-checkbox v-model="firstItem.showName">商品名称</el-checkbox>
<el-checkbox v-model="firstItem.showPrice">商品价格</el-checkbox>
<el-checkbox v-model="firstItem.showSales">已售数量</el-checkbox>
</div>
</div>
</div>
<div class="panel-card">
<div class="section-title">购物车按钮</div>
<div class="ctrl-row">
<div class="ctrl-label">是否显示</div>
<div class="ctrl-body">
<div class="seg layout-seg">
<div
class="seg-item"
:class="{ active: firstItem.cartShow !== false }"
@click="firstItem.cartShow = true"
>
显示
</div>
<div
class="seg-item"
:class="{ active: firstItem.cartShow === false }"
@click="firstItem.cartShow = false"
>
隐藏
</div>
</div>
</div>
</div>
<div class="ctrl-row" v-if="firstItem.cartShow !== false">
<div class="ctrl-label">按钮样式</div>
<div class="ctrl-body">
<div class="seg layout-seg-3">
<div
v-for="opt in cartStyleOptions"
:key="opt.value"
class="seg-item seg-icon"
:class="{ active: firstItem.cartStyle === opt.value }"
@click="firstItem.cartStyle = opt.value"
>
<span v-if="opt.value === 'text'" class="cart-text-ico">加购</span>
<span v-else-if="opt.value === 'plus'" class="cart-plus-ico">+</span>
<span v-else class="cart-cart-ico">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
<path
d="M7 4h-2l-1 2h2l2.4 9.6a2 2 0 0 0 1.9 1.4h7.9a2 2 0 0 0 1.9-1.5l1.6-6.5h-12l-.8-3h11.5l.5-2h-13.4l-.0-.0zM10 20a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm8 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z"
/>
</svg>
</span>
</div>
</div>
</div>
</div>
<div class="ctrl-row" v-if="firstItem.cartShow !== false">
<div class="ctrl-label">按钮效果</div>
<div class="ctrl-body">
<div class="seg layout-seg">
<div
class="seg-item"
:class="{ active: firstItem.cartAction !== 'add' }"
@click="firstItem.cartAction = 'detail'"
>
进入商品详情页
</div>
<div
class="seg-item"
:class="{ active: firstItem.cartAction === 'add' }"
@click="firstItem.cartAction = 'add'"
>
商品加购
</div>
</div>
</div>
</div>
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">商品设置</div>
<div class="ctrl-row">
<div class="ctrl-label">选择方式</div>
<div class="ctrl-body">
<el-radio-group v-model="firstItem.dataMode">
<el-radio value="category">店铺分类</el-radio>
<el-radio value="goods">指定商品</el-radio>
<el-radio value="brand">商品品牌</el-radio>
</el-radio-group>
</div>
</div>
<template v-if="firstItem.dataMode === 'category'">
<div class="ctrl-row">
<div class="ctrl-label">店铺分类</div>
<div class="ctrl-body">
<el-cascader
v-model="firstItem.categoryId"
:options="categoryTree"
:props="cascaderProps"
placeholder="全部店铺分类"
clearable
/>
</div>
</div>
<slider-field v-model="firstItem.count" label="商品数量" :min="1" :max="50" />
<div class="ctrl-row">
<div class="ctrl-label">商品排序</div>
<div class="ctrl-body">
<div class="seg layout-seg-3">
<div
v-for="opt in sortOptions"
:key="opt.value"
class="seg-item"
:class="{ active: firstItem.sortType === opt.value }"
@click="firstItem.sortType = opt.value"
>
{{ opt.label }}
</div>
</div>
</div>
</div>
</template>
<template v-else-if="firstItem.dataMode === 'brand'">
<div class="ctrl-row">
<div class="ctrl-label">商品品牌</div>
<div class="ctrl-body">
<el-select v-model="firstItem.brandId" placeholder="全部品牌" clearable filterable>
<el-option v-for="b in brandList" :key="b.id" :label="b.name" :value="b.id" />
</el-select>
</div>
</div>
<slider-field v-model="firstItem.count" label="商品数量" :min="1" :max="50" />
<div class="ctrl-row">
<div class="ctrl-label">商品排序</div>
<div class="ctrl-body">
<div class="seg layout-seg-3">
<div
v-for="opt in sortOptions"
:key="opt.value"
class="seg-item"
:class="{ active: firstItem.sortType === opt.value }"
@click="firstItem.sortType = opt.value"
>
{{ opt.label }}
</div>
</div>
</div>
</div>
</template>
<template v-else>
<div class="goods-wall">
<div class="goods-wall-item" v-for="(g, i) in firstItem.goodsList" :key="g.id">
<img :src="g.thumbnail" alt="" />
<div class="goods-wall-del" @click="firstItem.goodsList.splice(i, 1)">
<el-icon><Close /></el-icon>
</div>
</div>
<div class="goods-wall-add" @click="pickGoods">
<el-icon><Plus /></el-icon>
</div>
</div>
<div class="section-tip">最多选择 50 个商品,展示顺序与选择顺序一致</div>
</template>
</div>
</template>
<template v-else>
<div class="panel-card">
<div class="section-title">颜色设置</div>
<gradient-field
v-model:start="res.options.bgColor1"
v-model:end="res.options.bgColor2"
:default-start="defaults.bgColor1"
:default-end="defaults.bgColor2"
/>
<color-field
v-model="res.options.titleColor"
label="商品标题颜色"
:default-value="defaults.titleColor"
/>
<div class="ctrl-row">
<div class="ctrl-label">色调</div>
<div class="ctrl-body">
<div class="seg layout-seg">
<div
class="seg-item"
:class="{ active: res.options.toneMode !== 'custom' }"
@click="res.options.toneMode = 'theme'"
>
跟随主题风格
</div>
<div
class="seg-item"
:class="{ active: res.options.toneMode === 'custom' }"
@click="res.options.toneMode = 'custom'"
>
自定义
</div>
</div>
</div>
</div>
<color-field
v-if="res.options.toneMode === 'custom'"
v-model="res.options.priceColor"
label="价格颜色"
:default-value="defaults.priceColor"
/>
<color-field
v-model="res.options.salesColor"
label="已售数量颜色"
:default-value="defaults.salesColor"
/>
</div>
<div class="panel-card">
<div class="section-title">边距设置</div>
<slider-field v-model="res.options.paddingTop" label="上边距" :min="0" :max="100" />
<slider-field v-model="res.options.paddingBottom" label="下边距" :min="0" :max="100" />
<slider-field v-model="res.options.paddingX" label="左右边距" :min="0" :max="40" />
<slider-field v-model="res.options.contentGap" label="内容间距" :min="0" :max="50" />
<slider-field v-model="res.options.pageGap" label="页面间距" :min="0" :max="100" />
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">圆角设置</div>
<slider-field v-model="res.options.borderRadius" label="背景圆角" :min="0" :max="30" />
<slider-field v-model="res.options.contentRadius" label="内容圆角" :min="0" :max="30" />
</div>
</template>
<liliDialog ref="liliDialogRef" @selectedGoodsData="onGoodsSelected"></liliDialog>
</div>
</template>
<script>
import { Close, Plus } from "@element-plus/icons-vue";
import GradientField from "../controls/GradientField.vue";
import ColorField from "../controls/ColorField.vue";
import SliderField from "../controls/SliderField.vue";
import { getShopGoodsLabelListSeller, getBrandListData } from "@/api/goods";
const DEFAULTS = {
bgColor1: "#FFFFFF",
bgColor2: "#FFFFFF",
titleColor: "#282828",
toneMode: "theme",
priceColor: "#FF5C58",
salesColor: "#666666",
paddingTop: 10,
paddingBottom: 10,
paddingX: 12,
contentGap: 10,
pageGap: 0,
borderRadius: 0,
contentRadius: 7,
};
const DEFAULT_ITEM = {
layout: "list",
showName: true,
showPrice: true,
showSales: true,
cartShow: true,
cartStyle: "plus",
cartAction: "detail",
dataMode: "category",
categoryId: "",
brandId: "",
storeId: "",
goodsList: [],
count: 6,
sortType: "default",
};
export default {
name: "PanelGoods",
hideDecorateTitle: true,
components: { GradientField, ColorField, SliderField, Close, Plus },
props: {
res: { type: Object, required: true },
},
data() {
return {
defaults: DEFAULTS,
activeTab: "content",
tabs: [
{ key: "content", title: "内容" },
{ key: "style", title: "样式" },
],
legacy: false,
layoutOptions: [
{ value: "card", label: "卡片" },
{ value: "list", label: "列表" },
{ value: "three", label: "三列" },
{ value: "big", label: "大图" },
],
sortOptions: [
{ value: "default", label: "综合" },
{ value: "sales", label: "销量" },
{ value: "price", label: "价格" },
],
cascaderProps: {
label: "label",
value: "value",
children: "children",
checkStrictly: true,
emitPath: false,
},
categoryTree: [],
brandList: [],
};
},
computed: {
firstItem() {
return this.res.options.list[0];
},
// 卡片/大图支持文字按钮,两列/三列只有图标按钮
cartStyleOptions() {
const base = [
{ value: "plus", label: "圆形加号" },
{ value: "cart", label: "购物车" },
];
if (this.firstItem.layout === "card" || this.firstItem.layout === "big") {
return [{ value: "text", label: "加购文字" }, ...base];
}
return base;
},
},
created() {
this.res.options = Object.assign({}, DEFAULTS, this.res.options);
if (!Array.isArray(this.res.options.list) || !this.res.options.list[0]) {
this.res.options.list = [{ ...DEFAULT_ITEM }];
}
const item = this.firstItem;
if (item.layout === undefined && Array.isArray(item.titleWay)) {
this.legacy = true;
return;
}
this.fillItemDefaults(item);
this.loadCategories();
this.loadBrands();
},
methods: {
formatShopCategory(list) {
return (list || []).map((item) => ({
value: item.id,
label: item.labelName,
children: item.children && item.children.length ? this.formatShopCategory(item.children) : undefined,
}));
},
loadCategories() {
getShopGoodsLabelListSeller().then((res) => {
if (res && res.success) {
this.categoryTree = this.formatShopCategory(res.result || []);
}
});
},
loadBrands() {
getBrandListData().then((res) => {
this.brandList = Array.isArray(res) ? res : (res && res.result) || [];
});
},
fillItemDefaults(item) {
Object.keys(DEFAULT_ITEM).forEach((key) => {
if (item[key] === undefined) {
item[key] = Array.isArray(DEFAULT_ITEM[key])
? [...DEFAULT_ITEM[key]]
: DEFAULT_ITEM[key];
}
});
},
upgradeLegacy() {
const item = this.firstItem;
delete item.titleWay;
delete item.listWay;
this.fillItemDefaults(item);
this.legacy = false;
this.loadCategories();
this.loadBrands();
},
changeLayout(val) {
this.firstItem.layout = val;
// 两列/三列不支持文字按钮,切布局时兜底
if (
(val === "list" || val === "three") &&
this.firstItem.cartStyle === "text"
) {
this.firstItem.cartStyle = "plus";
}
},
pickGoods() {
const dialog = this.$refs.liliDialogRef;
dialog.clearGoodsSelected();
dialog.goodsFlag = true;
dialog.flag = true;
},
onGoodsSelected(val) {
if (!Array.isArray(val) || !val.length) return;
const list = val.map((item) => ({
id: item.id,
goodsId: item.goodsId,
goodsName: item.goodsName,
price: item.price,
thumbnail: item.thumbnail,
buyCount: item.buyCount,
}));
const exist = new Set(this.firstItem.goodsList.map((g) => g.id));
const merged = this.firstItem.goodsList.concat(
list.filter((g) => !exist.has(g.id))
);
this.firstItem.goodsList = merged.slice(0, 50);
},
},
};
</script>
<style scoped lang="scss">
@import "../controls/field-common.scss";
@import "../controls/panel-common.scss";
.seg-icon {
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
padding: 6px 10px;
}
// 展现形式小图标
.ly-ico {
display: inline-flex;
align-items: center;
gap: 2px;
height: 14px;
i {
display: block;
background: currentColor;
border-radius: 1px;
}
}
.ly-card {
i:first-child {
width: 8px;
height: 10px;
}
i:not(:first-child) {
width: 12px;
height: 2px;
}
flex-wrap: wrap;
width: 24px;
align-content: center;
}
.ly-list {
i {
width: 8px;
height: 10px;
&:nth-child(3) {
display: none;
}
}
}
.ly-three {
i {
width: 6px;
height: 10px;
}
}
.ly-big {
i:first-child {
width: 22px;
height: 10px;
}
i:not(:first-child) {
display: none;
}
}
// 购物车按钮样式小图标
.cart-text-ico {
display: inline-block;
padding: 1px 8px;
border-radius: 8px;
background: #c0c4cc;
color: #fff;
font-size: 11px;
line-height: 14px;
}
.cart-plus-ico {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
background: #c0c4cc;
color: #fff;
font-size: 12px;
line-height: 1;
}
.seg-item.active .cart-text-ico,
.seg-item.active .cart-plus-ico {
background: #409eff;
}
.cart-cart-ico {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
}
.goods-wall {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 10px;
}
.goods-wall-item {
position: relative;
width: 72px;
height: 72px;
border-radius: 6px;
overflow: hidden;
border: 1px solid #ebeef5;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.goods-wall-del {
position: absolute;
top: 2px;
right: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 12px;
}
}
.goods-wall-add {
width: 72px;
height: 72px;
border: 1px dashed #dcdfe6;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: #909399;
font-size: 22px;
cursor: pointer;
&:hover {
border-color: #409eff;
color: #409eff;
}
}
</style>

View File

@@ -0,0 +1,86 @@
<template>
<div class="panel-line">
<div class="panel-header">
<span class="panel-name">{{ res.name }}</span>
</div>
<div class="panel-card">
<div class="section-title">颜色设置</div>
<gradient-field
v-model:start="res.options.bgColor1"
v-model:end="res.options.bgColor2"
:default-start="defaults.bgColor1"
:default-end="defaults.bgColor2"
/>
<color-field
v-model="res.options.lineColor"
label="线条颜色"
:default-value="defaults.lineColor"
/>
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">样式设置</div>
<segment-field
v-model="res.options.lineStyle"
label="线条样式"
:options="lineStyleOptions"
/>
<slider-field v-model="res.options.lineHeight" label="线条高度" :min="1" :max="100" />
<slider-field v-model="res.options.paddingX" label="左右边距" :min="0" :max="40" />
<slider-field v-model="res.options.pageGap" label="页面间距" :min="0" :max="100" />
</div>
</div>
</template>
<script>
import ColorField from "../controls/ColorField.vue";
import GradientField from "../controls/GradientField.vue";
import SliderField from "../controls/SliderField.vue";
import SegmentField from "../controls/SegmentField.vue";
const DEFAULTS = {
bgColor: "#FFFFFF",
bgColor1: "#FFFFFF",
bgColor2: "#FFFFFF",
lineColor: "#979797",
lineStyle: "dashed",
lineHeight: 1,
paddingX: 0,
pageGap: 0,
};
export default {
name: "PanelLine",
hideDecorateTitle: true,
components: { ColorField, GradientField, SliderField, SegmentField },
props: {
res: { type: Object, required: true },
},
data() {
return {
defaults: DEFAULTS,
lineStyleOptions: [
{ label: "虚线", value: "dashed" },
{ label: "实线", value: "solid" },
{ label: "点状线", value: "dotted" },
],
};
},
created() {
this.res.options = Object.assign({}, DEFAULTS, this.res.options);
},
};
</script>
<style scoped lang="scss">
@import "../controls/panel-common.scss";
.panel-line {
background: #f5f7fa;
margin: 0 -20px;
padding: 0 12px 12px;
min-height: 100%;
box-sizing: border-box;
}
</style>

View File

@@ -0,0 +1,427 @@
<template>
<div class="panel-notice">
<div class="panel-header">
<span class="panel-name">{{ res.name }}</span>
<div class="panel-tabs">
<div
v-for="tab in tabs"
:key="tab.key"
class="panel-tab"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.title }}
</div>
</div>
</div>
<template v-if="activeTab === 'content'">
<div class="panel-card">
<div class="section-title">图标设置</div>
<div class="section-tip">支持上传 1 张图片,建议宽度 130*36px</div>
<div class="icon-row">
<div class="icon-preview" v-if="item.img">
<img :src="item.img" alt="" />
</div>
<div class="icon-placeholder" v-else>图标</div>
<el-button plain type="primary" @click="pickImage">上传图标</el-button>
</div>
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">公告设置</div>
<div class="section-tip">最多可添加 10 个版块</div>
<div class="notice-item" v-for="(notice, index) in item.title" :key="index">
<div class="notice-item-head">
<span>版块 {{ index + 1 }}</span>
<div class="img-item-ops">
<el-icon :class="{ disabled: index === 0 }" @click="moveItem(index, -1)"><Top /></el-icon>
<el-icon
:class="{ disabled: index === item.title.length - 1 }"
@click="moveItem(index, 1)"
><Bottom
/></el-icon>
<el-icon class="del" @click="removeItem(index)"><Delete /></el-icon>
</div>
</div>
<div class="field-row">
<div class="field-label">标题</div>
<el-input v-model="notice.context" maxlength="50" placeholder="请输入公告内容" />
</div>
<div class="field-row">
<div class="field-label">链接</div>
<div class="link-text" v-if="notice.url && notice.url.___type">{{ linkText(notice.url) }}</div>
<el-button plain type="primary" @click="pickLink(notice)">选择链接</el-button>
</div>
</div>
<el-button
v-if="item.title.length < 10"
class="add-btn"
plain
type="primary"
@click="addItem"
>+ 添加模块</el-button
>
</div>
</template>
<template v-else>
<div class="panel-card">
<div class="section-title">文字设置</div>
<div class="ctrl-row">
<div class="ctrl-label">滚动方向</div>
<div class="ctrl-body">
<div class="seg layout-seg">
<div
class="seg-item"
:class="{ active: item.direction !== 'horizontal' }"
@click="item.direction = 'vertical'"
>
上下
</div>
<div
class="seg-item"
:class="{ active: item.direction === 'horizontal' }"
@click="item.direction = 'horizontal'"
>
左右
</div>
</div>
</div>
</div>
<div class="ctrl-row">
<div class="ctrl-label">文本位置</div>
<div class="ctrl-body">
<div class="seg layout-seg-3">
<div
v-for="opt in alignOptions"
:key="opt.value"
class="seg-item"
:class="{ active: (item.textAlign || 'left') === opt.value }"
@click="item.textAlign = opt.value"
>
{{ opt.title }}
</div>
</div>
</div>
</div>
<color-field
v-model="item.color"
label="文字颜色"
:default-value="defaults.itemColor"
/>
</div>
<div class="panel-card">
<div class="section-title">颜色设置</div>
<gradient-field
v-model:start="res.options.bgColor1"
v-model:end="res.options.bgColor2"
:default-start="defaults.bgColor1"
:default-end="defaults.bgColor2"
/>
</div>
<div class="panel-card">
<div class="section-title">边距设置</div>
<slider-field v-model="res.options.paddingTop" label="上边距" :min="0" :max="100" />
<slider-field v-model="res.options.paddingBottom" label="下边距" :min="0" :max="100" />
<slider-field v-model="res.options.paddingX" label="左右边距" :min="0" :max="40" />
<slider-field v-model="res.options.pageGap" label="页面间距" :min="0" :max="100" />
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">圆角设置</div>
<slider-field v-model="res.options.borderRadius" label="背景圆角" :min="0" :max="30" />
</div>
</template>
<liliDialog ref="liliDialog" @selectedLink="selectedLink"></liliDialog>
<el-dialog v-model="picModelFlag" width="1200px" append-to-body destroy-on-close>
<ossManage
@callback="callbackSelected"
:is-component="true"
:initialize="picModelFlag"
:max-select="1"
ref="ossManage"
/>
</el-dialog>
</div>
</template>
<script>
import { Top, Bottom, Delete } from "@element-plus/icons-vue";
import ColorField from "../controls/ColorField.vue";
import GradientField from "../controls/GradientField.vue";
import SliderField from "../controls/SliderField.vue";
import ossManage from "@/views/sys/oss-manage/ossManage";
import ways from "@/views/lili-dialog/wap.js";
const DEFAULTS = {
bgColor1: "#FFFFFF",
bgColor2: "#FFFFFF",
paddingTop: 0,
paddingBottom: 0,
paddingX: 12,
pageGap: 0,
borderRadius: 0,
itemColor: "#333333",
};
export default {
name: "PanelNotice",
hideDecorateTitle: true,
components: { ColorField, GradientField, SliderField, ossManage, Top, Bottom, Delete },
props: {
res: { type: Object, required: true },
},
data() {
return {
defaults: DEFAULTS,
activeTab: "content",
tabs: [
{ key: "content", title: "内容" },
{ key: "style", title: "样式" },
],
alignOptions: [
{ value: "left", title: "居左" },
{ value: "center", title: "居中" },
{ value: "right", title: "居右" },
],
picModelFlag: false,
currentItem: null,
};
},
computed: {
item() {
return this.res.options.list[0];
},
},
created() {
this.res.options = Object.assign({}, DEFAULTS, this.res.options);
if (!Array.isArray(this.res.options.list) || !this.res.options.list.length) {
this.res.options.list = [{ title: [], color: "#333333", direction: "vertical", img: "" }];
}
const item = this.item;
if (!Array.isArray(item.title) || !item.title.length) {
item.title = [{ context: "这是一条公告", url: "" }];
}
},
methods: {
linkText(url) {
const way = ways.find((e) => e.name === url.___type);
const typeTitle = way ? way.title : "发现";
const name =
url.name || url.goodsName || url.memberName || url.title || "";
return name ? `${typeTitle} - ${name}` : typeTitle;
},
pickImage() {
this.picModelFlag = true;
this.$nextTick(() => {
const oss = this.$refs.ossManage;
if (!oss) return;
oss.selectImage = true;
oss.fileType = "pic";
oss.changeFileType();
});
},
callbackSelected(val) {
this.picModelFlag = false;
if (val && val.url) {
this.item.img = val.url;
}
},
pickLink(notice) {
this.currentItem = notice;
const dialog = this.$refs.liliDialog;
dialog.clearGoodsSelected();
dialog.goodsFlag = false;
dialog.flag = true;
},
selectedLink(val) {
if (!this.currentItem) return;
delete val.selected;
delete val.intro;
delete val.mobileIntro;
this.currentItem.url = val;
},
moveItem(index, dir) {
const list = this.item.title;
const target = index + dir;
if (target < 0 || target >= list.length) return;
list.splice(target, 1, ...list.splice(index, 1, list[target]));
},
removeItem(index) {
this.item.title.splice(index, 1);
},
addItem() {
this.item.title.push({ context: "", url: "" });
},
},
};
</script>
<style scoped lang="scss">
@import "../controls/field-common.scss";
@import "../controls/panel-common.scss";
.panel-notice {
background: #f5f7fa;
margin: 0 -20px;
padding: 0 12px 12px;
min-height: 100%;
box-sizing: border-box;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 50px;
margin-bottom: 12px;
padding: 0 14px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
box-sizing: border-box;
.panel-name {
font-size: 16px;
font-weight: bold;
color: #303133;
}
}
.panel-tabs {
display: inline-flex;
background: #f4f4f5;
border-radius: 8px;
padding: 3px;
.panel-tab {
padding: 5px 14px;
font-size: 13px;
border-radius: 6px;
cursor: pointer;
color: #606266;
transition: all 0.2s ease;
&.active {
background: #ffffff;
color: #409eff;
font-weight: 600;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
}
}
}
.panel-card {
background: #ffffff;
border-radius: 8px;
padding: 14px;
margin-bottom: 12px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin: 0 0 12px;
}
.section-tip {
font-size: 13px;
color: #909399;
margin-bottom: 10px;
}
.icon-row {
display: flex;
align-items: center;
gap: 10px;
}
.icon-preview {
width: 88px;
height: 32px;
border: 1px dashed #dcdfe6;
border-radius: 6px;
padding: 3px;
box-sizing: border-box;
img {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
}
}
.icon-placeholder {
width: 88px;
height: 32px;
border: 1px dashed #dcdfe6;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: #909399;
font-size: 12px;
}
.notice-item {
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 10px;
margin-bottom: 10px;
.notice-item-head {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
color: #303133;
margin-bottom: 8px;
}
}
.img-item-ops {
display: flex;
gap: 4px;
.el-icon {
padding: 4px;
cursor: pointer;
color: #606266;
&:hover {
color: #409eff;
}
&.del:hover {
color: #e1251b;
}
&.disabled {
color: #dcdfe6;
cursor: not-allowed;
}
}
}
.field-row {
display: flex;
align-items: center;
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
.field-label {
font-size: 14px;
color: #606266;
margin-right: 10px;
flex-shrink: 0;
width: 32px;
}
.el-input {
flex: 1;
min-width: 0;
}
.el-button {
flex-shrink: 0;
}
}
.link-text {
flex: 1;
min-width: 0;
font-size: 12px;
color: #909399;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.add-btn {
width: 100%;
}
</style>

View File

@@ -0,0 +1,213 @@
<template>
<div class="panel-page-setting">
<div class="panel-header">
<span class="panel-name">页面设置</span>
<div class="panel-tabs">
<div
v-for="tab in tabs"
:key="tab.key"
class="panel-tab"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.title }}
</div>
</div>
</div>
<template v-if="activeTab === 'content'">
<div class="panel-card panel-card-fill">
<div class="section-title">基础设置</div>
<div class="ctrl-row">
<div class="ctrl-label"><span class="required-star">*</span>模板名称</div>
<div class="ctrl-body">
<el-input v-model="res.name" maxlength="30" placeholder="请输入模板名称" />
</div>
</div>
<div class="ctrl-row">
<div class="ctrl-label"><span class="required-star">*</span>页面标题</div>
<div class="ctrl-body">
<el-input v-model="res.title" maxlength="30" placeholder="请输入页面标题" />
</div>
</div>
</div>
</template>
<template v-else>
<div class="panel-card panel-card-fill">
<div class="section-title">颜色设置</div>
<segment-field v-model="res.bgType" label="背景设置" :options="bgTypeOptions" />
<color-field
v-if="res.bgType === 'color'"
v-model="res.bgColor"
label="背景颜色"
:default-value="defaults.bgColor"
/>
<template v-else>
<div class="ctrl-row">
<div class="ctrl-label">背景图片</div>
<div class="ctrl-body bg-image-body">
<div class="bg-image-preview" v-if="res.bgImage">
<img :src="res.bgImage" alt="" />
<el-icon class="bg-image-clear" @click="clearImage"><Close /></el-icon>
</div>
<el-button plain type="primary" @click="pickImage">上传图片</el-button>
</div>
</div>
<div class="section-tip upload-tip">上传图片 建议:宽度750px</div>
<segment-field v-model="res.bgFill" label="填充方式" :options="bgFillOptions" />
</template>
<color-field
v-model="res.topColor"
label="顶部颜色"
:default-value="defaults.topColor"
/>
<segment-field v-model="res.titleColor" label="标题颜色" :options="titleColorOptions" />
</div>
</template>
<el-dialog v-model="picModelFlag" width="1200px" append-to-body destroy-on-close>
<ossManage
@callback="callbackSelected"
:is-component="true"
:initialize="picModelFlag"
:max-select="1"
ref="ossManage"
/>
</el-dialog>
</div>
</template>
<script>
import ColorField from "../controls/ColorField.vue";
import SegmentField from "../controls/SegmentField.vue";
import ossManage from "@/views/sys/oss-manage/ossManage";
import { Close } from "@element-plus/icons-vue";
const DEFAULTS = {
name: "",
title: "",
bgType: "color",
bgColor: "#F5F5F5",
bgImage: "",
bgFill: "stretch",
topColor: "#ffffff",
titleColor: "black",
};
export default {
name: "PanelPageSetting",
components: { ColorField, SegmentField, ossManage, Close },
props: {
res: { type: Object, required: true },
},
data() {
return {
defaults: DEFAULTS,
activeTab: "content",
tabs: [
{ key: "content", title: "内容" },
{ key: "style", title: "样式" },
],
bgTypeOptions: [
{ label: "背景色", value: "color" },
{ label: "背景图", value: "image" },
],
bgFillOptions: [
{ label: "拉伸", value: "stretch" },
{ label: "缩放", value: "contain" },
{ label: "填充", value: "cover" },
],
titleColorOptions: [
{ label: "白色", value: "white" },
{ label: "黑色", value: "black" },
],
picModelFlag: false,
};
},
created() {
Object.keys(DEFAULTS).forEach((key) => {
if (this.res[key] === undefined) {
this.res[key] = DEFAULTS[key];
}
});
},
methods: {
pickImage() {
this.picModelFlag = true;
this.$nextTick(() => {
const oss = this.$refs.ossManage;
if (!oss) return;
oss.selectImage = true;
oss.fileType = "pic";
oss.changeFileType();
});
},
callbackSelected(val) {
this.picModelFlag = false;
if (val && val.url) {
this.res.bgImage = val.url;
}
},
clearImage() {
this.res.bgImage = "";
},
},
};
</script>
<style scoped lang="scss">
@import "../controls/field-common.scss";
@import "../controls/panel-common.scss";
.panel-page-setting {
background: #f5f7fa;
margin: 0 -20px;
padding: 0 12px 12px;
min-height: 100%;
box-sizing: border-box;
}
.required-star {
color: #f56c6c;
margin-right: 2px;
}
.bg-image-body {
align-items: center;
}
.bg-image-preview {
position: relative;
width: 88px;
height: 48px;
border: 1px dashed #dcdfe6;
border-radius: 6px;
padding: 3px;
box-sizing: border-box;
img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.bg-image-clear {
position: absolute;
top: -8px;
right: -8px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #909399;
color: #ffffff;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: #f56c6c;
}
}
}
.upload-tip {
margin: -8px 0 12px 102px;
}
</style>

View File

@@ -0,0 +1,98 @@
<template>
<div class="panel-rich-text">
<div class="panel-header">
<span class="panel-name">{{ res.name }}</span>
<div class="panel-tabs">
<div
v-for="tab in tabs"
:key="tab.key"
class="panel-tab"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.title }}
</div>
</div>
</div>
<template v-if="activeTab === 'content'">
<div class="panel-card panel-card-fill">
<div class="section-title">富文本内容</div>
<editor v-model="res.options.content" height="300px" />
</div>
</template>
<template v-else>
<div class="panel-card">
<div class="section-title">颜色设置</div>
<gradient-field
v-model:start="res.options.bgColor1"
v-model:end="res.options.bgColor2"
:default-start="defaults.bgColor1"
:default-end="defaults.bgColor2"
/>
</div>
<div class="panel-card">
<div class="section-title">边距设置</div>
<slider-field v-model="res.options.paddingX" label="左右边距" :min="0" :max="40" />
<slider-field v-model="res.options.pageGap" label="页面间距" :min="0" :max="100" />
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">圆角设置</div>
<slider-field v-model="res.options.borderRadius" label="背景圆角" :min="0" :max="30" />
</div>
</template>
</div>
</template>
<script>
import editor from "@/views/lili-components/editor";
import GradientField from "../controls/GradientField.vue";
import SliderField from "../controls/SliderField.vue";
const DEFAULTS = {
content: "",
bgColor: "#FFFFFF",
bgColor1: "#FFFFFF",
bgColor2: "#FFFFFF",
paddingX: 12,
pageGap: 0,
borderRadius: 0,
};
export default {
name: "PanelRichText",
hideDecorateTitle: true,
components: { editor, GradientField, SliderField },
props: {
res: { type: Object, required: true },
},
data() {
return {
defaults: DEFAULTS,
activeTab: "content",
tabs: [
{ key: "content", title: "内容" },
{ key: "style", title: "样式" },
],
};
},
created() {
this.res.options = Object.assign({}, DEFAULTS, this.res.options);
},
};
</script>
<style scoped lang="scss">
@import "../controls/panel-common.scss";
.panel-rich-text {
background: #f5f7fa;
margin: 0 -20px;
padding: 0 12px 12px;
min-height: 100%;
box-sizing: border-box;
}
</style>

View File

@@ -0,0 +1,280 @@
<template>
<div class="panel-search">
<div class="panel-header">
<span class="panel-name">{{ res.name }}</span>
<div class="panel-tabs">
<div
v-for="tab in tabs"
:key="tab.key"
class="panel-tab"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.title }}
</div>
</div>
</div>
<template v-if="activeTab === 'content'">
<div class="panel-card">
<div class="section-title">提示文字设置</div>
<div class="field-row">
<div class="field-label">提示文字</div>
<el-input
v-model="res.options.list[0].title"
size="default"
maxlength="20"
placeholder="搜索商品名称"
/>
</div>
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">搜索热词</div>
<div class="hot-word" v-for="(word, index) in res.options.hotWords" :key="index">
<el-input
v-model="res.options.hotWords[index]"
size="default"
maxlength="10"
placeholder="选填,不超过十个字"
/>
<el-icon class="del" @click="removeWord(index)"><CircleClose /></el-icon>
</div>
<el-button class="add-btn" plain type="primary" @click="addWord">
+ 添加热词
</el-button>
<div class="field-row interval-row">
<div class="field-label">切换时间</div>
<el-input-number
class="interval-input"
v-model="res.options.hotInterval"
:min="1"
:max="60"
size="default"
controls-position="right"
/>
<span class="unit">秒</span>
</div>
</div>
</template>
<template v-else>
<div class="panel-card">
<div class="section-title">文本位置</div>
<div class="ctrl-row">
<div class="ctrl-label">文本位置</div>
<div class="ctrl-body">
<div class="seg layout-seg">
<div
class="seg-item"
:class="{ active: res.options.textAlign !== 'center' }"
@click="res.options.textAlign = 'left'"
>
居左
</div>
<div
class="seg-item"
:class="{ active: res.options.textAlign === 'center' }"
@click="res.options.textAlign = 'center'"
>
居中
</div>
</div>
</div>
</div>
</div>
<div class="panel-card">
<div class="section-title">颜色设置</div>
<gradient-field
v-model:start="res.options.bgColor1"
v-model:end="res.options.bgColor2"
:default-start="defaults.bgColor1"
:default-end="defaults.bgColor2"
/>
<color-field
v-model="res.options.boxColor"
label="框体颜色"
:default-value="defaults.boxColor"
/>
<color-field
v-model="res.options.textColor"
label="输入文字颜色"
:default-value="defaults.textColor"
/>
</div>
<div class="panel-card">
<div class="section-title">边距设置</div>
<slider-field v-model="res.options.paddingX" label="左右边距" :min="0" :max="20" />
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">圆角设置</div>
<slider-field v-model="res.options.borderRadius" label="背景圆角" :min="0" :max="30" />
<slider-field v-model="res.options.boxRadius" label="内容圆角" :min="0" :max="30" />
</div>
</template>
</div>
</template>
<script>
import ColorField from "../controls/ColorField.vue";
import GradientField from "../controls/GradientField.vue";
import SliderField from "../controls/SliderField.vue";
import { CircleClose } from "@element-plus/icons-vue";
const DEFAULTS = {
textAlign: "left",
bgColor1: "#FFFFFF",
bgColor2: "#FFFFFF",
boxColor: "#EDEDED",
textColor: "#999999",
paddingX: 12,
borderRadius: 0,
boxRadius: 15,
hotWords: [],
hotInterval: 3,
};
export default {
name: "PanelSearch",
hideDecorateTitle: true,
components: { ColorField, GradientField, SliderField, CircleClose },
props: {
res: { type: Object, required: true },
},
data() {
return {
defaults: DEFAULTS,
activeTab: "content",
tabs: [
{ key: "content", title: "内容" },
{ key: "style", title: "样式" },
],
};
},
created() {
this.res.options = Object.assign({}, DEFAULTS, this.res.options);
if (!Array.isArray(this.res.options.list) || !this.res.options.list.length) {
this.res.options.list = [{ title: "搜索" }];
}
if (!Array.isArray(this.res.options.hotWords)) {
this.res.options.hotWords = [];
}
},
methods: {
addWord() {
this.res.options.hotWords.push("");
},
removeWord(index) {
this.res.options.hotWords.splice(index, 1);
},
},
};
</script>
<style scoped lang="scss">
@import "../controls/field-common.scss";
@import "../controls/panel-common.scss";
.panel-search {
background: #f5f7fa;
margin: 0 -20px;
padding: 0 12px 12px;
min-height: 100%;
box-sizing: border-box;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 50px;
margin-bottom: 12px;
padding: 0 14px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
box-sizing: border-box;
.panel-name {
font-size: 16px;
font-weight: bold;
color: #303133;
}
}
.panel-tabs {
display: inline-flex;
background: #f4f4f5;
border-radius: 8px;
padding: 3px;
.panel-tab {
padding: 5px 14px;
font-size: 13px;
border-radius: 6px;
cursor: pointer;
color: #606266;
transition: all 0.2s ease;
&.active {
background: #ffffff;
color: #409eff;
font-weight: 600;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
}
}
}
.panel-card {
background: #ffffff;
border-radius: 8px;
padding: 14px;
margin-bottom: 12px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin: 0 0 12px;
}
.field-row {
display: flex;
align-items: center;
.field-label {
font-size: 14px;
color: #606266;
margin-right: 10px;
flex-shrink: 0;
}
}
.hot-word {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
.del {
cursor: pointer;
color: #909399;
font-size: 18px;
flex-shrink: 0;
&:hover {
color: #e1251b;
}
}
}
.add-btn {
width: 100%;
margin-bottom: 14px;
}
.interval-row {
.interval-input {
flex: 1;
min-width: 0;
:deep(.el-input__inner) {
text-align: center;
}
}
.unit {
margin-left: 8px;
font-size: 13px;
color: #909399;
}
}
</style>

View File

@@ -0,0 +1,297 @@
<template>
<div class="panel-title">
<div class="panel-header">
<span class="panel-name">{{ res.name }}</span>
<div class="panel-tabs">
<div
v-for="tab in tabs"
:key="tab.key"
class="panel-tab"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.title }}
</div>
</div>
</div>
<template v-if="activeTab === 'content'">
<div class="panel-card">
<div class="section-title">文本标题</div>
<div class="ctrl-row">
<div class="ctrl-label">标题名称</div>
<div class="ctrl-body">
<el-input v-model="item.title" maxlength="20" />
</div>
</div>
<div class="ctrl-row">
<div class="ctrl-label">副标题</div>
<div class="ctrl-body">
<el-input v-model="item.title1" maxlength="20" />
</div>
</div>
<div class="ctrl-row">
<div class="ctrl-label">右侧按钮</div>
<div class="ctrl-body">
<el-radio-group v-model="item.showMore">
<el-radio :value="true">显示</el-radio>
<el-radio :value="false">隐藏</el-radio>
</el-radio-group>
</div>
</div>
<template v-if="item.showMore">
<div class="ctrl-row">
<div class="ctrl-label">右侧文字</div>
<div class="ctrl-body">
<el-input v-model="item.moreText" maxlength="6" placeholder="更多" />
</div>
</div>
<div class="ctrl-row">
<div class="ctrl-label">链接</div>
<div class="ctrl-body">
<span class="link-text" v-if="item.moreUrl && item.moreUrl.___type">
{{ linkText(item.moreUrl) }}
</span>
<el-button plain type="primary" @click="pickLink">选择链接</el-button>
</div>
</div>
</template>
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">选择风格</div>
<div class="ctrl-row">
<div class="ctrl-label">风格</div>
<div class="ctrl-body">
<el-radio-group v-model="item.bgType">
<el-radio value="image">背景图片</el-radio>
<el-radio value="color">背景色</el-radio>
</el-radio-group>
</div>
</div>
<div class="ctrl-row" v-if="item.bgType === 'color'">
<div class="ctrl-label">背景色</div>
<div class="ctrl-body">
<el-color-picker v-model="item.bk_color" />
<el-input v-model="item.bk_color" class="ctrl-color-input" />
</div>
</div>
<div class="ctrl-row" v-else>
<div class="ctrl-label">上传背景图</div>
<div class="ctrl-body">
<div class="bg-preview" v-if="item.bgImage">
<img :src="item.bgImage" alt="" />
</div>
<el-button plain type="primary" @click="pickImage">上传背景图</el-button>
</div>
</div>
<div class="section-tip" v-if="item.bgType !== 'color'">建议 910px*86px</div>
</div>
</template>
<template v-else>
<div class="panel-card">
<div class="section-title">文字设置</div>
<color-field v-model="item.color" label="标题颜色" :default-value="defaults.color" />
<slider-field v-model="item.fontSize" label="标题文字" :min="12" :max="40" />
<div class="ctrl-row">
<div class="ctrl-label">文本样式</div>
<div class="ctrl-body">
<div class="seg layout-seg-3">
<div
v-for="opt in textStyleOptions"
:key="opt.value"
class="seg-item"
:class="{ active: (item.textStyle || 'normal') === opt.value }"
@click="item.textStyle = opt.value"
>
{{ opt.title }}
</div>
</div>
</div>
</div>
<color-field v-model="item.color1" label="副标题颜色" :default-value="defaults.color1" />
<slider-field v-model="item.subFontSize" label="副标题文字" :min="10" :max="30" />
<template v-if="item.showMore">
<color-field v-model="item.moreColor" label="按钮颜色" :default-value="defaults.moreColor" />
<slider-field v-model="item.moreFontSize" label="按钮文字" :min="10" :max="24" />
</template>
</div>
<div class="panel-card">
<div class="section-title">边距设置</div>
<slider-field v-model="res.options.paddingTop" label="上边距" :min="0" :max="100" />
<slider-field v-model="res.options.paddingBottom" label="下边距" :min="0" :max="100" />
<slider-field v-model="res.options.paddingX" label="左右边距" :min="0" :max="40" />
<slider-field v-model="res.options.pageGap" label="页面间距" :min="0" :max="100" />
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">圆角设置</div>
<slider-field v-model="res.options.radiusTop" label="上圆角" :min="0" :max="30" />
<slider-field v-model="res.options.radiusBottom" label="下圆角" :min="0" :max="30" />
</div>
</template>
<liliDialog ref="liliDialog" @selectedLink="selectedLink"></liliDialog>
<el-dialog v-model="picModelFlag" width="1200px" append-to-body destroy-on-close>
<ossManage
@callback="callbackSelected"
:is-component="true"
:initialize="picModelFlag"
:max-select="1"
ref="ossManage"
/>
</el-dialog>
</div>
</template>
<script>
import ColorField from "../controls/ColorField.vue";
import SliderField from "../controls/SliderField.vue";
import ossManage from "@/views/sys/oss-manage/ossManage";
import ways from "@/views/lili-dialog/wap.js";
const DEFAULTS = {
paddingTop: 10,
paddingBottom: 10,
paddingX: 12,
pageGap: 0,
radiusTop: 0,
radiusBottom: 0,
color: "#282828",
color1: "#282828",
moreColor: "#282828",
};
export default {
name: "PanelTitle",
hideDecorateTitle: true,
components: { ColorField, SliderField, ossManage },
props: {
res: { type: Object, required: true },
},
data() {
return {
defaults: DEFAULTS,
activeTab: "content",
tabs: [
{ key: "content", title: "内容" },
{ key: "style", title: "样式" },
],
textStyleOptions: [
{ value: "normal", title: "正常" },
{ value: "italic", title: "斜体" },
{ value: "bold", title: "加粗" },
],
picModelFlag: false,
};
},
computed: {
item() {
return this.res.options.list[0];
},
},
created() {
this.res.options = Object.assign({}, DEFAULTS, this.res.options);
if (!Array.isArray(this.res.options.list) || !this.res.options.list.length) {
this.res.options.list = [
{
title: "标题",
title1: "副标题",
color: "#282828",
color1: "#282828",
bk_color: "#FFFFFF",
textAlign: "left",
},
];
}
const item = this.item;
if (item.showMore === undefined) item.showMore = true;
if (!item.moreText) item.moreText = "更多";
if (!item.bgType) item.bgType = "color";
if (!item.fontSize) item.fontSize = 20;
if (!item.subFontSize) item.subFontSize = 14;
if (!item.moreFontSize) item.moreFontSize = 12;
if (!item.moreColor) item.moreColor = "#282828";
},
methods: {
linkText(url) {
const way = ways.find((e) => e.name === url.___type);
const typeTitle = way ? way.title : "发现";
const name =
url.name || url.goodsName || url.memberName || url.title || "";
return name ? `${typeTitle} - ${name}` : typeTitle;
},
pickImage() {
this.picModelFlag = true;
this.$nextTick(() => {
const oss = this.$refs.ossManage;
if (!oss) return;
oss.selectImage = true;
oss.fileType = "pic";
oss.changeFileType();
});
},
callbackSelected(val) {
this.picModelFlag = false;
if (val && val.url) {
this.item.bgImage = val.url;
}
},
pickLink() {
const dialog = this.$refs.liliDialog;
dialog.clearGoodsSelected();
dialog.goodsFlag = false;
dialog.flag = true;
},
selectedLink(val) {
delete val.selected;
delete val.intro;
delete val.mobileIntro;
this.item.moreUrl = val;
},
},
};
</script>
<style scoped lang="scss">
@import "../controls/field-common.scss";
@import "../controls/panel-common.scss";
.panel-title {
background: #f5f7fa;
margin: 0 -20px;
padding: 0 12px 12px;
min-height: 100%;
box-sizing: border-box;
}
.link-text {
flex: 1;
min-width: 0;
font-size: 12px;
color: #909399;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 10px;
}
.bg-preview {
width: 132px;
height: 30px;
border: 1px dashed #dcdfe6;
border-radius: 6px;
padding: 2px;
box-sizing: border-box;
margin-right: 10px;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
border-radius: 4px;
}
}
</style>

View File

@@ -0,0 +1,300 @@
<template>
<div class="panel-video">
<div class="panel-header">
<span class="panel-name">{{ res.name }}</span>
<div class="panel-tabs">
<div
v-for="tab in tabs"
:key="tab.key"
class="panel-tab"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.title }}
</div>
</div>
</div>
<template v-if="activeTab === 'content'">
<div class="panel-card panel-card-fill">
<div class="section-title">视频内容设置</div>
<div class="ctrl-row">
<div class="ctrl-label">上传视频</div>
<div class="ctrl-body">
<div class="upload-state" :class="{ filled: item.videoUrl }">
{{ item.videoUrl ? "已选择视频" : "未选择视频" }}
</div>
<el-button plain type="primary" @click="pickFile('videoUrl')">
{{ item.videoUrl ? "重新选择" : "上传视频" }}
</el-button>
</div>
</div>
<div class="ctrl-row">
<div class="ctrl-label">视频封面</div>
<div class="ctrl-body">
<div class="cover-preview" v-if="item.poster">
<img :src="item.poster" alt="" />
</div>
<div class="cover-placeholder" v-else>封面</div>
<el-button plain type="primary" @click="pickFile('poster')">
{{ item.poster ? "重新选择" : "上传封面" }}
</el-button>
</div>
</div>
<div class="ctrl-row">
<div class="ctrl-label">自动播放</div>
<div class="ctrl-body">
<el-switch v-model="item.autoplay" />
</div>
</div>
<div class="ctrl-row">
<div class="ctrl-label">画面比例</div>
<div class="ctrl-body">
<div class="seg layout-seg-3">
<div
v-for="opt in aspectOptions"
:key="opt"
class="seg-item"
:class="{ active: (item.aspectRatio || '16:9') === opt }"
@click="item.aspectRatio = opt"
>
{{ opt }}
</div>
</div>
</div>
</div>
</div>
</template>
<template v-else>
<div class="panel-card">
<div class="section-title">颜色设置</div>
<gradient-field
v-model:start="res.options.bgColor1"
v-model:end="res.options.bgColor2"
:default-start="defaults.bgColor1"
:default-end="defaults.bgColor2"
/>
</div>
<div class="panel-card">
<div class="section-title">边距设置</div>
<slider-field v-model="res.options.paddingTop" label="上边距" :min="0" :max="100" />
<slider-field v-model="res.options.paddingBottom" label="下边距" :min="0" :max="100" />
<slider-field v-model="res.options.paddingX" label="左右边距" :min="0" :max="40" />
<slider-field v-model="res.options.pageGap" label="页面间距" :min="0" :max="100" />
</div>
<div class="panel-card panel-card-fill">
<div class="section-title">圆角设置</div>
<slider-field v-model="res.options.borderRadius" label="背景圆角" :min="0" :max="30" />
<slider-field v-model="res.options.imgRadius" label="内容圆角" :min="0" :max="30" />
</div>
</template>
<el-dialog v-model="picModelFlag" width="1200px" append-to-body destroy-on-close>
<ossManage
@callback="callbackSelected"
:is-component="true"
:initialize="picModelFlag"
:max-select="1"
ref="ossManage"
/>
</el-dialog>
</div>
</template>
<script>
import GradientField from "../controls/GradientField.vue";
import SliderField from "../controls/SliderField.vue";
import ossManage from "@/views/sys/oss-manage/ossManage";
const DEFAULTS = {
bgColor1: "#FFFFFF",
bgColor2: "#FFFFFF",
paddingTop: 10,
paddingBottom: 10,
paddingX: 12,
pageGap: 0,
borderRadius: 0,
imgRadius: 0,
};
export default {
name: "PanelVideo",
hideDecorateTitle: true,
components: { GradientField, SliderField, ossManage },
props: {
res: { type: Object, required: true },
},
data() {
return {
defaults: DEFAULTS,
activeTab: "content",
tabs: [
{ key: "content", title: "内容" },
{ key: "style", title: "样式" },
],
aspectOptions: ["16:9", "4:3", "1:1"],
picModelFlag: false,
currentField: "videoUrl",
};
},
computed: {
item() {
return this.res.options.list[0];
},
},
created() {
this.res.options = Object.assign({}, DEFAULTS, this.res.options);
if (!Array.isArray(this.res.options.list) || !this.res.options.list.length) {
this.res.options.list = [
{ videoUrl: "", poster: "", autoplay: false, aspectRatio: "16:9" },
];
}
},
methods: {
pickFile(field) {
this.currentField = field;
this.picModelFlag = true;
this.$nextTick(() => {
const oss = this.$refs.ossManage;
if (!oss) return;
oss.selectImage = true;
oss.fileType = field === "videoUrl" ? "video" : "pic";
oss.changeFileType();
});
},
callbackSelected(val) {
this.picModelFlag = false;
if (val && val.url) {
this.item[this.currentField] = val.url;
}
},
},
};
</script>
<style scoped lang="scss">
@import "../controls/field-common.scss";
@import "../controls/panel-common.scss";
.panel-video {
background: #f5f7fa;
margin: 0 -20px;
padding: 0 12px 12px;
min-height: 100%;
box-sizing: border-box;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 50px;
margin-bottom: 12px;
padding: 0 14px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
box-sizing: border-box;
.panel-name {
font-size: 16px;
font-weight: bold;
color: #303133;
}
}
.panel-tabs {
display: inline-flex;
background: #f4f4f5;
border-radius: 8px;
padding: 3px;
.panel-tab {
padding: 5px 14px;
font-size: 13px;
border-radius: 6px;
cursor: pointer;
color: #606266;
transition: all 0.2s ease;
&.active {
background: #ffffff;
color: #409eff;
font-weight: 600;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
}
}
}
.panel-card {
background: #ffffff;
border-radius: 8px;
padding: 14px;
margin-bottom: 12px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin: 0 0 12px;
}
.upload-state {
flex: 1;
min-width: 0;
font-size: 12px;
color: #909399;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.filled {
color: #409eff;
}
}
.cover-preview {
width: 88px;
height: 44px;
border: 1px dashed #dcdfe6;
border-radius: 6px;
padding: 3px;
box-sizing: border-box;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
border-radius: 4px;
}
}
.cover-placeholder {
width: 88px;
height: 44px;
border: 1px dashed #dcdfe6;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: #909399;
font-size: 12px;
flex-shrink: 0;
}
.seg {
display: inline-flex;
border: 1px solid #dcdfe6;
border-radius: 6px;
overflow: hidden;
.seg-item {
padding: 6px 14px;
font-size: 13px;
color: #606266;
cursor: pointer;
border-left: 1px solid #dcdfe6;
transition: all 0.2s ease;
&:first-child {
border-left: none;
}
&.active {
background: #ecf5ff;
color: #409eff;
font-weight: 600;
}
}
}
</style>

View File

@@ -0,0 +1,43 @@
// 店铺端组件配置面板映射:type → 面板组件
// 面板契约:props res(整个楼层条目),直接修改 res.options.*,保存链路自动感知
import PanelBlank from "./PanelBlank.vue";
import PanelLine from "./PanelLine.vue";
import PanelRichText from "./PanelRichText.vue";
import PanelCoupon from "./PanelCoupon.vue";
import PanelFlexOne from "./PanelFlexOne.vue";
import PanelCarousel from "./PanelCarousel.vue";
import PanelAdCube from "./PanelAdCube.vue";
import PanelSearch from "./PanelSearch.vue";
import PanelNotice from "./PanelNotice.vue";
import PanelVideo from "./PanelVideo.vue";
import PanelTitle from "./PanelTitle.vue";
import PanelGoods from "./PanelGoods.vue";
// 图片魔方及其全部可选风格共用 PanelAdCube
const adCubeTypes = [
"tpl_ad_list",
"leftOneRightTwo",
"leftTwoRightOne",
"flexThree",
"flexTwo",
"topOneBottomTwo",
"topTwoBottomOne",
"flexFive",
"flexFour",
"textPicture",
];
export default {
blank: PanelBlank,
line: PanelLine,
richText: PanelRichText,
coupon: PanelCoupon,
flexOne: PanelFlexOne,
carousel: PanelCarousel,
search: PanelSearch,
notice: PanelNotice,
video: PanelVideo,
title: PanelTitle,
goods: PanelGoods,
...Object.fromEntries(adCubeTypes.map((t) => [t, PanelAdCube])),
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
<template>
<div class="ctrl-row">
<div class="ctrl-label">{{ label }}</div>
<div class="ctrl-body">
<el-color-picker
:model-value="modelValue"
@update:model-value="onInput"
/>
<el-input
:model-value="modelValue"
class="ctrl-color-input"
placeholder="#FFFFFF"
@update:model-value="onInput"
/>
<el-button link type="primary" @click="onReset">重置</el-button>
</div>
</div>
</template>
<script>
export default {
name: "ColorField",
props: {
modelValue: { type: String, default: "" },
defaultValue: { type: String, default: "" },
label: { type: String, default: "" },
},
emits: ["update:modelValue"],
methods: {
onInput(val) {
this.$emit("update:modelValue", val);
},
onReset() {
this.$emit("update:modelValue", this.defaultValue);
},
},
};
</script>
<style scoped lang="scss">
@import "./field-common.scss";
</style>

View File

@@ -0,0 +1,75 @@
<template>
<div class="ctrl-row">
<div class="ctrl-label">{{ label }}</div>
<div class="ctrl-body gradient-body">
<div class="gradient-row">
<el-color-picker
:model-value="start"
@update:model-value="$emit('update:start', $event)"
/>
<el-input
:model-value="start"
class="ctrl-color-input"
placeholder="#FFFFFF"
@update:model-value="$emit('update:start', $event)"
/>
<el-button link type="primary" @click="$emit('update:start', defaultStart)">重置</el-button>
</div>
<div class="gradient-row">
<el-color-picker
:model-value="end"
@update:model-value="$emit('update:end', $event)"
/>
<el-input
:model-value="end"
class="ctrl-color-input"
placeholder="#FFFFFF"
@update:model-value="$emit('update:end', $event)"
/>
<el-button link type="primary" @click="$emit('update:end', defaultEnd)">重置</el-button>
</div>
</div>
</div>
</template>
<script>
export default {
name: "GradientField",
props: {
start: { type: String, default: "" },
end: { type: String, default: "" },
defaultStart: { type: String, default: "" },
defaultEnd: { type: String, default: "" },
label: { type: String, default: "背景颜色" },
},
emits: ["update:start", "update:end"],
};
</script>
<style scoped lang="scss">
@import "./field-common.scss";
.gradient-body {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.gradient-row {
display: flex;
align-items: center;
gap: 8px;
position: relative;
// 两个色板之间的连接线(对齐色板中心)
& + .gradient-row::before {
content: "";
position: absolute;
left: 15px;
top: -14px;
width: 2px;
height: 10px;
background: #dcdfe6;
}
}
</style>

View File

@@ -0,0 +1,42 @@
<template>
<div class="ctrl-row">
<div class="ctrl-label">{{ label }}</div>
<div class="ctrl-body">
<el-radio-group
:model-value="modelValue"
@update:model-value="onInput"
>
<el-radio-button
v-for="opt in options"
:key="opt.value"
:value="opt.value"
>{{ opt.label }}</el-radio-button
>
</el-radio-group>
</div>
</div>
</template>
<script>
export default {
name: "SegmentField",
props: {
modelValue: { type: String, default: "" },
options: {
type: Array,
default: () => [],
},
label: { type: String, default: "" },
},
emits: ["update:modelValue"],
methods: {
onInput(val) {
this.$emit("update:modelValue", val);
},
},
};
</script>
<style scoped lang="scss">
@import "./field-common.scss";
</style>

View File

@@ -0,0 +1,57 @@
<template>
<div class="ctrl-row">
<div class="ctrl-label">{{ label }}</div>
<div class="ctrl-body">
<el-slider
class="ctrl-slider"
:model-value="modelValue"
:min="min"
:max="max"
@update:model-value="onInput"
/>
<el-input-number
class="ctrl-number"
:model-value="modelValue"
:min="min"
:max="max"
@update:model-value="onInput"
/>
</div>
</div>
</template>
<script>
export default {
name: "SliderField",
props: {
modelValue: { type: Number, default: 0 },
min: { type: Number, default: 0 },
max: { type: Number, default: 100 },
label: { type: String, default: "" },
},
emits: ["update:modelValue"],
methods: {
onInput(val) {
this.$emit("update:modelValue", val);
},
},
};
</script>
<style scoped lang="scss">
@import "./field-common.scss";
.ctrl-body {
flex-wrap: nowrap;
}
.ctrl-slider {
flex: 1;
min-width: 0;
}
.ctrl-number {
width: 130px;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,28 @@
.ctrl-row {
display: flex;
align-items: flex-start;
margin-bottom: 16px;
gap: 12px;
}
.ctrl-label {
width: 90px;
flex-shrink: 0;
line-height: 32px;
color: #606266;
font-size: 14px;
white-space: nowrap;
}
.ctrl-body {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.ctrl-color-input {
width: 110px;
}

View File

@@ -0,0 +1,179 @@
// 新配置面板共用的骨架样式(灰底 + 白色卡片 + 头部 tabs)
// 使用方式:面板根类名需与 .panel-root 一起用,或在面板 scoped 样式里 @import 后把根类加上
.panel-search,
.panel-promotion,
.panel-coupon,
.panel-title,
.panel-shop,
.panel-goods,
.panel-notice,
.panel-video,
.panel-article,
.panel-page-setting {
background: #f5f7fa;
margin: 0 -20px;
padding: 0 12px 12px;
min-height: 100%;
box-sizing: border-box;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 50px;
margin-bottom: 12px;
padding: 0 14px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
box-sizing: border-box;
.panel-name {
font-size: 16px;
font-weight: bold;
color: #303133;
}
}
.panel-tabs {
display: inline-flex;
background: #f4f4f5;
border-radius: 8px;
padding: 3px;
.panel-tab {
padding: 5px 14px;
font-size: 13px;
border-radius: 6px;
cursor: pointer;
color: #606266;
transition: all 0.2s ease;
&.active {
background: #ffffff;
color: #409eff;
font-weight: 600;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
}
}
}
.panel-card {
background: #ffffff;
border-radius: 8px;
padding: 14px;
margin-bottom: 12px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}
.panel-card-fill {
flex: 1;
margin-bottom: 0;
}
// 面板根容器改为纵向 flex,让最后一张卡片可撑满到底
.panel-flex-one,
.panel-carousel,
.panel-ad-cube,
.panel-menu,
.panel-search,
.panel-notice,
.panel-video,
.panel-title,
.panel-integral,
.panel-coupon,
.panel-article,
.panel-shop,
.panel-goods,
.panel-blank,
.panel-line,
.panel-rich-text,
.panel-promotion,
.panel-page-setting {
display: flex;
flex-direction: column;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin: 0 0 12px;
}
.section-tip {
font-size: 13px;
color: #909399;
margin-bottom: 10px;
}
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
margin: 14px 0;
.setting-label {
font-size: 14px;
color: #606266;
.setting-value {
margin-left: 8px;
color: #303133;
}
}
}
.seg {
display: inline-flex;
border: 1px solid #dcdfe6;
border-radius: 6px;
overflow: hidden;
.seg-item {
padding: 6px 14px;
font-size: 13px;
color: #606266;
cursor: pointer;
border-left: 1px solid #dcdfe6;
transition: all 0.2s ease;
&:first-child {
border-left: none;
}
&.active {
background: #ecf5ff;
color: #409eff;
font-weight: 600;
}
}
}
// 选项平铺选择器:一行两个,超出换行
.layout-seg {
display: flex;
flex-wrap: wrap;
width: 100%;
.seg-item {
flex: 1 0 40%;
padding: 6px 0;
text-align: center;
white-space: nowrap;
box-sizing: border-box;
&:nth-child(3) {
border-left: none;
}
&:nth-child(n + 3) {
border-top: 1px solid #dcdfe6;
}
}
&.layout-seg-3 {
flex-wrap: nowrap;
.seg-item {
flex: 1 1 0;
&:nth-child(3) {
border-left: 1px solid #dcdfe6;
}
&:nth-child(n + 3) {
border-top: none;
}
}
}
}
// 选项平铺选择器(独立用法):一行三个
.layout-seg-3 {
display: flex;
flex-wrap: nowrap;
width: 100%;
.seg-item {
flex: 1 1 0;
padding: 6px 0;
text-align: center;
white-space: nowrap;
box-sizing: border-box;
}
}

View File

@@ -1,13 +1,13 @@
:deep(.el-overlay) {
z-index: 2000;
}
.decorate-view-link{
font-size: 12px;
margin: 0 4px;
flex: 1;
min-width: 0;
color: #999;
.decorate-view-link {
font-size: 12px;
margin: 0 4px;
flex: 1;
min-width: 0;
color: #999;
}
.decorate-view-btn {
flex-shrink: 0;
@@ -22,111 +22,179 @@
margin: 0;
}
}
.decorate-view-style {
border: 1px solid #ededed;
background: #f7f7fa;
text-align: center;
width: 100%;
padding: 30px 0 0 0;
margin-bottom: 20px;
cursor: pointer;
> .select-style {
background: #ededed;
padding: 15px 0;
font-size: 15px;
}
}
.decorate-border {
border: 1px solid #ededed;
margin: 10px 0;
padding: 0 10px;
}
.drawer {
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 14px;
> .drawer-item {
cursor: pointer;
border: 1px solid #ededed;
background: #f9f0ff;
width: calc(50% - 7px);
box-sizing: border-box;
color: #9254de;
border-radius: 0.8em;
height: 60px;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
}
}
.hidden-input {
display: none;
}
.decorate {
padding: 0 20px;
height: calc(100vh - 120px);
overflow-y: auto;
padding-bottom: 120px;
}
.decorate-title {
height: 50px;
line-height: 50px;
font-weight: bold;
}
.decorate-list {
overflow: hidden;
}
.decorate-view {
display: flex;
margin: 20px 0;
align-items: center;
}
.decorate-item-box {
background: #fff;
padding: 10px;
border: 1px solid #ededed;
}
.decorate-item-title {
height: 40px;
display: flex;
align-items: center;
justify-content: space-between;
line-height: 40px;
padding: 0 10px;
}
.decorate-view-title {
margin-right: 10px;
}
.decorate-item {
border: 1px solid #ededed;
background: #f7f7fa;
text-align: center;
width: 100%;
padding: 30px 0 0 0;
margin-bottom: 20px;
cursor: pointer;
>.select-style {
background: #ededed;
border-top-left-radius: 0.4em;
margin-bottom: 20px;
border-top-right-radius: 0.4em;
padding: 15px 0;
font-size: 15px;
}
.show-image {
max-width: 50px;
}
.tips {
font-size: 12px;
color: #999;
> span {
color: $theme_color;
}
}
.selectBtn {
margin-left: 10px;
}
.bing-goods-list {
}
.decorate-border {
border: 1px solid #ededed;
margin: 10px 0;
padding: 0 10px;
}
.drawer {
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 14px;
>.drawer-item {
cursor: pointer;
border: 1px solid #ededed;
background: #f9f0ff;
width: calc(50% - 7px);
box-sizing: border-box;
color: #9254de;
border-radius: 0.8em;
height: 60px;
display: flex;
> .bing-goods-item {
width: 50px;
height: 50px;
> img {
width: 100%;
height: 100%;
display: block;
}
align-items: center;
flex-direction: column;
justify-content: center;
}
}
.hidden-input {
display: none;
}
.decorate {
padding: 0 20px;
height: calc(100vh - 96px);
overflow-y: auto;
background: #f5f7fa;
}
.decorate-title {
height: 50px;
line-height: 50px;
font-weight: bold;
}
.decorate-list {
overflow: hidden;
}
.decorate-view {
display: flex;
margin: 12px 0;
align-items: center;
}
.decorate-item-box {
background: #fff;
padding: 10px;
border: 1px solid #ededed;
}
.decorate-item-title {
height: 40px;
display: flex;
align-items: center;
justify-content: space-between;
line-height: 40px;
padding: 0 10px;
}
.decorate-view-title {
margin-right: 10px;
}
.decorate-item {
background: #ededed;
border-top-left-radius: 0.4em;
margin-bottom: 20px;
border-top-right-radius: 0.4em;
}
.show-image {
max-width: 50px;
}
.tips {
font-size: 12px;
color: #999;
>span {
color: $theme_color;
}
}
.selectBtn {
margin-left: 10px;
}
.bing-goods-list {
display: flex;
>.bing-goods-item {
width: 50px;
height: 50px;
>img {
width: 100%;
height: 100%;
display: block;
}
}
}
.align-text {
width: 30px;
height: 30px;
display: block;
}
.card-box {
display: flex;
}
.decorate-notice {
margin-top: 10px;
}
.card {}
.card:hover {
background: #ededed;
}
.active {
background: #ededed;
}
.title-item{
border-bottom: 1px solid #ededed;
padding:10px 10px 10px 0;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
>.title-btn{
display: flex;
}
}
.title-goodsName{
/* 适用于webkit内核和移动端 */
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.title-tooltip{
white-space: normal;
}

View File

@@ -1,6 +1,6 @@
<template>
<div class="decorate">
<div class="decorate-title">
<div class="decorate-title" v-if="!hidePanelTitle">
{{ res.name }}
<el-button
@@ -20,14 +20,24 @@
style="margin-left: 20px"
size="small"
plain
v-if="res.type == 'promotions' || res.drawerPromotions"
v-if="res.drawerPromotions"
type="primary"
@click="selectPromotions()"
>选择促销活动</el-button
>
</div>
<!-- 新组件独立配置面板(按 type 分发) -->
<component
v-if="panelComponent"
:is="panelComponent"
:res="res"
@handleDrawer="emit('handleDrawer', $event)"
/>
<!-- 老组件遗留配置分支 -->
<template v-else>
<el-alert
v-if="res.name == '商品分类'"
v-if="res.name == '商品列表'"
type="warning"
title="装修提示"
:closable="false"
@@ -95,9 +105,7 @@
<div
class="decorate-list"
v-if="
(res.type != 'tpl_ad_list' &&
res.type != 'tpl_activity_list' &&
res.type != 'promotions') ||
(res.type != 'tpl_ad_list' && res.type != 'tpl_activity_list') ||
res.drawer ||
res.drawerPromotions
"
@@ -107,7 +115,7 @@
v-for="(item, index) in res.options.list"
:key="index"
>
<div class="decorate-item-title">
<div class="decorate-item-title" v-if="res.type != 'video'">
<div>卡片</div>
<el-icon
v-if="res.close || res.type == 'promotionDetail'"
@@ -247,7 +255,7 @@
@click="changeTextAlign('left')"
>
<img
:src="alignTextLeftImg"
:src="alignTextLeft"
class="align-text"
alt=""
/>
@@ -258,7 +266,7 @@
@click="changeTextAlign('center')"
>
<img
:src="alignTextCenterImg"
:src="alignTextCenter"
class="align-text"
alt=""
/>
@@ -269,7 +277,7 @@
@click="changeTextAlign('right')"
>
<img
:src="alignTextRightImg"
:src="alignTextRight"
class="align-text"
alt=""
/>
@@ -373,21 +381,17 @@
</div>
</div>
<!-- 视频楼层配置 -->
<video-config
v-if="res.type == 'video'"
:item="item"
@select-file="(field) => handleClickFile(item, index, field)"
/>
<!-- 填写链接 -->
<div class="decorate-view" v-if="res.onlyImg">
<div class="decorate-view-title">选择模式</div>
<div>
<el-radio-group v-model="item.model">
<el-radio value="link">链接</el-radio>
<el-radio value="hotzone">热区</el-radio>
</el-radio-group>
</div>
</div>
<div class="decorate-view" v-if="!res.notLink">
<div class="decorate-view-title">选择链接</div>
<div class="decorate-view-title">{{ item.model === "hotzone" ? "热区" : "选择链接" }}</div>
<div
v-if="item.url && item.url.length != 0"
class="decorate-view-link"
@@ -493,7 +497,7 @@
>
<liliDialog
ref="liliDialog"
ref="liliDialogRef"
@selectedLink="selectedLink"
@selectedGoodsData="selectedGoodsData"
></liliDialog>
@@ -520,26 +524,36 @@
<hotzone ref="hotzoneRef" @changeZone="changeZone"></hotzone>
<el-dialog v-model="picModelFlag" width="1200px" append-to-body destroy-on-close>
<ossManage @callback="callbackSelected" :is-component="true" :initialize="picModelFlag" :max-select="1" ref="ossManageRef" />
<ossManage
@callback="callbackSelected"
:is-component="true"
:initialize="picModelFlag"
:max-select="1"
ref="ossManageRef"
/>
</el-dialog>
</template>
</div>
</template>
<script setup>
import { ref, watch, nextTick } from "vue";
import { ref, watch, nextTick, computed } from "vue";
import { CircleClose } from "@element-plus/icons-vue";
import { Message } from "@/utils/message";
import alignTextLeftImg from "@/assets/align-text-left.png";
import alignTextCenterImg from "@/assets/align-text-center.png";
import ossManage from "@/views/sys/oss-manage/ossManage";
import { modelData } from "./config";
import hotzone from "@/views/shop/hotzone";
import ways from "@/views/lili-dialog/wap.js";
import categoryTemplate from "@/views/lili-dialog/template/category";
import VideoConfig from "./video-config";
import panelMap from "./config-panel";
import alignTextLeft from "@/assets/align-text-left.png";
import alignTextCenter from "@/assets/align-text-center.png";
import alignTextRight from "@/assets/align-text-right.png";
const props = defineProps(["res"]);
const emit = defineEmits(["handleDrawer"]);
// alignTextLeftImg/alignTextCenterImg、ways(选择链接的类型)、modelData(装修数据) 为 import 常量,模板中直接使用
// alignTextLeft/alignTextCenter/alignTextRight、ways(选择链接的类型)、modelData(装修数据) 为 import 常量,模板中直接使用
const picModelFlag = ref(false); //图片选择器
const linkType = ref("goods"); // dialog弹窗口类型
const styleFlag = ref(false); //广告魔方开关
@@ -547,14 +561,26 @@ const textAlign = ref(
props.res.options.list[0] ? props.res.options.list[0].textAlign : "center"
); //文字对齐方式
const promotionsFlag = ref(false); //广告魔方开关
const selectedLinkIndex = ref(""); //选择链接的索引
const selectedGoods = ref(""); // 已选商品
const selectedLinks = ref(""); // 已选链接
const modelList = ref(""); // 装修列表
const enableSelectCategory = ref(false); //商品是否绑定分类
const pendingCategory = ref(null); // 选择分类弹窗草稿
const goodsSelectedIndex = ref(0); //绑定商品分类的索引
const pictureField = ref("img"); //图片选择器回写的字段
// 新组件按 type 分发到独立配置面板;老组件走下方遗留分支
const panelComponent = computed(
() => (props.res && panelMap[props.res.type]) || null
);
// 面板声明 hideDecorateTitle 时,标题行由面板自己渲染
const hidePanelTitle = computed(
() => !!(panelComponent.value && panelComponent.value.hideDecorateTitle)
);
// hotzoneRef/ossManageRef:模板 ref 与组件 import 同名,改名避免互相覆盖
const liliDialog = ref(null);
const liliDialogRef = ref(null);
const hotzoneRef = ref(null);
const ossManageRef = ref(null);
@@ -722,9 +748,9 @@ function clickDrawer(item, index) {
}
// 打开图片选择器
function liliDialogFlag(flag) {
liliDialog.value.clearGoodsSelected();
liliDialog.value.goodsFlag = flag;
liliDialog.value.flag = true;
liliDialogRef.value.clearGoodsSelected();
liliDialogRef.value.goodsFlag = flag;
liliDialogRef.value.flag = true;
}
function changeTextAlign(val) {
@@ -743,9 +769,24 @@ function clickLink(val, index, oval) {
liliDialogFlag(false);
}
}
function addZone(zoneInfo) {
selectedLinks.value.zoneInfo.push(zoneInfo);
}
function changeZone(zoneInfo) {
selectedLinks.value.zoneInfo = zoneInfo;
}
//点击图片解析成base64
function changeFile(item, index) {
const file = document.getElementById("files" + index).files[0];
if (file == void 0) return false;
const reader = new FileReader();
reader.readAsDataURL(file);
nextTick((res) => {
reader.onload = (e) => {
item.img = e.target.result;
};
});
}
//添加设置
function addDecorate(type, data) {
if (type === "notice") {
@@ -768,20 +809,43 @@ function addDecorate(type, data) {
props.res.options.list.push(way);
}
}
function addHotPoint() {
let way = {
img: "https://picsum.photos/id/264/200/200",
text: "标题",
link: "",
url: "",
};
props.res.options.list[0].points.push(way);
}
// 图片选择器回显
function callbackSelected(val) {
picModelFlag.value = false;
if (!val?.url) return;
selectedGoods.value.img = val.url;
if (pictureField.value === "bgImage") {
if (!selectedGoods.value.style) {
selectedGoods.value.style = {};
}
selectedGoods.value.style.bgImage = val.url;
return;
}
selectedGoods.value[pictureField.value] = val.url;
}
// 点击选择图片
function handleClickFile(item, index) {
function handleClickFile(item, index, field = "img") {
pictureField.value = field;
selectedGoods.value = item;
picModelFlag.value = true;
nextTick(() => {
if (ossManageRef.value) {
ossManageRef.value.selectImage = true;
const oss = ossManageRef.value;
if (!oss) return;
oss.selectImage = true;
if (field === "videoUrl") {
oss.fileType = "video";
} else {
oss.fileType = "pic";
}
oss.changeFileType();
});
}
function removeNotice(index) {

View File

@@ -3,37 +3,42 @@
<!-- 拖拽栏 ,展示栏 -->
<div class="model-view">
<div class="model-view-menu">
<draggable
class="model-view-menu-item"
:list="modelData"
:item-key="getModelKey"
:clone="cloneModel"
:move="handleMove"
v-bind="{
group: { name: 'model', pull: 'clone', put: false, animation: 150 },
sort: false,
ghostClass: 'ghost',
}"
<div
v-for="grp in modelGroups"
:key="grp.key"
class="model-group"
>
<template #item="{ element: model }">
<li
v-if="!model.drawer && !model.drawerPromotions"
class="model-item"
>
<img src alt />
<span>{{ model.name }}</span>
</li>
</template>
</draggable>
<div class="model-group-title">{{ grp.title }}</div>
<draggable
class="model-view-menu-item"
:list="grp.list"
:item-key="getModelKey"
:clone="cloneModel"
:move="handleMove"
:group="{ name: 'model', pull: 'clone', put: false, animation: 150 }"
:sort="false"
ghost-class="ghost"
>
<template #item="{ element: model }">
<li class="model-item">
<el-icon v-if="model.icon && iconMap[model.icon]" :size="22">
<component :is="iconMap[model.icon]" />
</el-icon>
<span>{{ model.name }}</span>
</li>
</template>
</draggable>
</div>
</div>
<div class="model-view-content">
<div class="content">
<div class="wap-title">首页</div>
<div class="page-setting-entry" @click="showPageSetting">页面设置</div>
<div class="content" :style="canvasStyle">
<div class="wap-title" :style="titleBarStyle">{{ contentData.title || "店铺首页" }}</div>
<draggable
class="draggable"
group="model"
ghostClass="ghost"
ghost-class="ghost"
@add="handleContentlAdd"
@end="handleContentlEnd"
v-model="contentData.list"
@@ -41,22 +46,40 @@
>
<template #item="{ element, index }">
<div class="list">
<component
<div class="floor-name">{{ element.name || element.type }}</div>
<div
v-if="templates[element.type]"
class="component"
:class="{ active: selected == index }"
@click="handleComponent(element, index)"
:is="templates[element.type]"
:res="element.options"
></component>
<el-icon
v-if="selected == index"
@click="closeComponent(index)"
class="close"
:size="25"
color="#e1251b"
>
<CircleClose />
</el-icon>
<component :is="templates[element.type]" :res="element.options"></component>
</div>
<div
v-else
class="component unknown-component"
@click="handleUnknownComponent(element, index)"
>
未知组件「{{ element.type }}」已下线,点击删除
</div>
<div v-if="element.hidden" class="floor-hidden-mask">已隐藏</div>
<div v-if="selected == index" class="floor-tools">
<el-icon
:class="{ disabled: index === 0 }"
@click="moveComponent(index, -1)"
><Top
/></el-icon>
<el-icon
:class="{ disabled: index === contentData.list.length - 1 }"
@click="moveComponent(index, 1)"
><Bottom
/></el-icon>
<el-icon @click="copyComponent(index)"><CopyDocument /></el-icon>
<el-icon @click="closeComponent(index)"><Delete /></el-icon>
<el-icon @click="toggleHide(index)"
><View v-if="!element.hidden" /><Hide v-else
/></el-icon>
</div>
</div>
</template>
</draggable>
@@ -70,30 +93,89 @@
v-if="decorateData"
:res="decorateData"
></decorate>
<PanelPageSetting v-else :res="contentData" />
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, onMounted, nextTick } from "vue";
import { useRoute } from "vue-router";
import { CircleClose } from "@element-plus/icons-vue";
import { ref, watch, nextTick, onMounted, computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
Picture,
PictureFilled,
Document,
Grid,
Search,
Goods,
Bell,
VideoCamera,
Ticket,
Collection,
Crop,
Minus,
EditPen,
Top,
Bottom,
CopyDocument,
Delete,
View,
Hide,
} from "@element-plus/icons-vue";
import templates from "./template/index";
import Draggable from "vuedraggable";
import { modelData } from "./config";
import decorate from "./decorate";
import PanelPageSetting from "./config-panel/PanelPageSetting.vue";
import * as API_Other from "@/api/other";
import { getStore } from "@/libs/storage";
import { Modal } from "@/utils/message";
import { Message, Modal } from "@/utils/message";
import { useAppStore } from "@/stores/app";
const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
// templates: 模板类型,modelData: 装修模型,均为 import 常量,模板中直接使用
// script setup 下 :is 无法按字符串解析局部导入,用映射表渲染组件图标
const iconMap = {
Picture,
PictureFilled,
Document,
Grid,
Search,
Goods,
Bell,
VideoCamera,
Ticket,
Collection,
Crop,
Minus,
EditPen,
};
const selected = ref(0); // 已选下标
// 页面设置默认值(老数据没有这些字段,加载时合并)
const PAGE_SETTING_DEFAULTS = {
name: "",
title: "",
bgType: "color", // color | image
bgColor: "#F5F5F5",
bgImage: "",
bgFill: "stretch", // stretch 拉伸 | contain 缩放 | cover 填充
topColor: "#ffffff",
titleColor: "black", // black | white
};
function mergePageData(data) {
const source = data && typeof data === "object" ? data : {};
return {
...PAGE_SETTING_DEFAULTS,
...source,
list: Array.isArray(source.list) ? source.list : [],
};
}
const contentData = ref({
// 总数据
...PAGE_SETTING_DEFAULTS,
list: [],
});
const decorateData = ref(""); // 装修数据
@@ -103,9 +185,52 @@ watch(
(val) => {
appStore.styleStore = val;
},
{ deep: true }
{ deep: true, immediate: true }
);
// 画布背景:背景色或背景图(拉伸/缩放/填充)
const canvasStyle = computed(() => {
const d = contentData.value;
if (d.bgType === "image" && d.bgImage) {
const size =
{ stretch: "100% 100%", contain: "contain", cover: "cover" }[d.bgFill] ||
"100% 100%";
return {
backgroundImage: `url(${d.bgImage})`,
backgroundSize: size,
backgroundRepeat: "no-repeat",
backgroundColor: d.bgColor || "#F5F5F5",
};
}
return { backgroundColor: d.bgColor || "#F5F5F5" };
});
// 画布顶部标题栏:背景用顶部颜色,文字色随标题颜色
const titleBarStyle = computed(() => {
const d = contentData.value;
return {
background: d.topColor || "#ffffff",
color: d.titleColor === "black" ? "#000" : "#fff",
};
});
// 清空楼层选中,右侧回到页面设置面板
function showPageSetting() {
selected.value = -1;
decorateData.value = "";
}
// 组件面板三类分组(drawer 标记的是图片魔方的可选风格,不进左侧组件列表)
const modelGroups = computed(() => {
const groups = [
{ key: "base", title: "基础组件" },
{ key: "marketing", title: "商品营销" },
{ key: "tools", title: "工具组件" },
];
return groups.map((g) => ({
...g,
list: modelData.filter((m) => m.group === g.key && !m.drawer),
}));
});
onMounted(() => {
init();
});
@@ -118,7 +243,12 @@ function getContentItemKey(element) {
}
function cloneModel(model) {
const key = Date.now() + "_" + Math.ceil(Math.random() * 99999);
const cloned = JSON.parse(JSON.stringify(model));
// 图片魔方拖入画布时默认使用「左一右二」风格
const source =
model.type === "tpl_ad_list"
? modelData.find((m) => m.type === "leftOneRightTwo") || model
: model;
const cloned = JSON.parse(JSON.stringify(source));
return {
...cloned,
key,
@@ -127,29 +257,72 @@ function cloneModel(model) {
}
// 初始化数据
function init() {
// 先读缓存,如果缓存有值则读缓存。
const cache = getStore('managerMobilePageCache')
if(cache){
Modal.confirm({
title: '提示',
content: '获取到本地有缓存数据,是否使用缓存数据?',
okText: '使用',
cancelText: '取消',
// 先读缓存,如果缓存有值则询问是否使用缓存
const cache = getStore("sellerMobilePageCache") || getStore("managerMobilePageCache");
if (cache) {
Modal.confirm({
title: "提示",
content: "获取到本地有缓存数据,是否使用缓存数据?",
okText: "使用",
cancelText: "取消",
onOk: () => {
const data = JSON.parse(cache);
contentData.value = data;
}
contentData.value = mergePageData(data);
},
});
}
if (!route.query.id) {
if (route.query.mode !== "add" && (!route.query.pagetype || route.query.pagetype === "INDEX" || route.query.pagetype === "STORE")) {
API_Other.getHomeList({
pageNumber: 1,
pageSize: 10,
pageClientType: "H5",
}).then((res) => {
const records = (res && res.result && res.result.records) || [];
const active = records.find((r) => r.pageShow === "OPEN" || r.pageShow === true) || records[0];
if (!active || !active.id) return;
router.replace({
path: route.path,
query: {
...route.query,
id: active.id,
name: active.name,
type: active.pageShow === "OPEN" || active.pageShow === true,
},
});
API_Other.getHomeData(active.id).then((detail) => {
const parsed = JSON.parse(detail.result.pageData);
contentData.value = mergePageData(parsed);
if (!contentData.value.name) {
contentData.value.name = active.name || "";
}
const first = contentData.value.list[0];
if (first && templates[first.type]) {
handleComponent(first, 0);
}
});
});
return false;
}
if (!route.query.id) return false;
// 新建页面:模板名称取路由参数
if (!contentData.value.name) {
contentData.value.name = route.query.name || "店铺首页模板";
}
return false;
}
API_Other.getHomeData(route.query.id).then((res) => {
contentData.value = JSON.parse(res.result.pageData);
handleComponent(contentData.value.list[0], 0);
const parsed = JSON.parse(res.result.pageData);
contentData.value = mergePageData(parsed);
if (!contentData.value.name) {
contentData.value.name = route.query.name || res.result.name || "";
}
const first = contentData.value.list[0];
if (first && templates[first.type]) {
handleComponent(first, 0);
}
});
}
// 中间组件拖动,右侧数据绑定不变
@@ -165,17 +338,15 @@ function closeComponent(index) {
// 如果当前楼层不为一
if (contentData.value.list.length > 1) {
// 如果当前最底层 给下一层赋值
if (index - 1 == -1) {
handleComponent(contentData.value.list[index], index);
} else {
// 如果不是最底层给上一层赋值
handleComponent(contentData.value.list[index - 1], index - 1);
}
contentData.value.list.splice(index, 1);
} else {
contentData.value.list.splice(index, 1);
selected.value = -1;
}
});
}
@@ -185,9 +356,37 @@ function handleComponent(val, index) {
selected.value = index;
decorateData.value = val;
}
// 右侧栏回调
// 已下线的未知组件:点击占位条直接删除
function handleUnknownComponent(element, index) {
console.warn(`[page-decoration] 未知组件类型: ${element.type}`);
closeComponent(index);
}
// 上移/下移楼层
function moveComponent(index, dir) {
const target = index + dir;
if (target < 0 || target >= contentData.value.list.length) return;
const list = contentData.value.list;
[list[index], list[target]] = [list[target], list[index]];
selected.value = target;
handleComponent(contentData.value.list[target], target);
}
// 复制楼层
function copyComponent(index) {
const cloned = JSON.parse(JSON.stringify(contentData.value.list[index]));
const key = Date.now() + "_" + Math.ceil(Math.random() * 99999);
cloned.key = key;
cloned.model = cloned.type + "_" + key;
contentData.value.list.splice(index + 1, 0, cloned);
handleComponent(contentData.value.list[index + 1], index + 1);
}
// 隐藏/显示楼层(渲染端跳过 hidden 楼层)
function toggleHide(index) {
const item = contentData.value.list[index];
item.hidden = !item.hidden;
}
// 右侧栏回调(广告魔方风格切换等)
function handleDrawer(val) {
const newIndex = selected.value;
let newIndex = selected.value;
decorateData.value = "";
contentData.value.list[newIndex] = {
...val,
@@ -196,11 +395,13 @@ function handleDrawer(val) {
},
model: val.type,
};
contentData.value = JSON.parse(JSON.stringify(contentData.value));
contentData.value.list = JSON.parse(
JSON.stringify(contentData.value.list)
);
decorateData.value = contentData.value.list[newIndex];
}
// 封装拖拽参数(原名 package,ESM 严格模式下为保留字,改名 packageModel)
// 封装拖拽参数
function packageModel(val, newIndex) {
contentData.value.list[newIndex] = "";
val = JSON.parse(JSON.stringify(val));
@@ -217,7 +418,6 @@ function packageModel(val, newIndex) {
// 拖动
function handleContentlAdd(evt) {
const { newIndex } = evt;
packageModel(contentData.value.list[newIndex], newIndex);
handleComponent(contentData.value.list[newIndex], newIndex);
}
@@ -227,4 +427,35 @@ function handleMove() {
</script>
<style scoped lang="scss">
@import "./style.scss";
// 页面设置:画布容器支持悬浮入口、画布背景可透出到拖拽区
.model-view-content {
position: relative;
}
.page-setting-entry {
position: absolute;
top: 12px;
right: 16px;
z-index: 200;
padding: 6px 14px;
font-size: 13px;
color: #409eff;
background: #ffffff;
border: 1px solid #dcdfe6;
border-radius: 6px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: #409eff;
color: #409eff;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.2);
}
}
.content > .draggable {
background: transparent;
}
.wap-title {
border-bottom: none;
}
</style>

View File

@@ -1,7 +1,26 @@
<template>
<!-- 预览保存 -->
<div class="model-title">
<div>店铺装修</div>
<div class="model-title-left">
<el-button size="default" @click="goBack">返回</el-button>
<div class="page-name">
<template v-if="!editingName">
<span class="page-name-label">当前页面:{{ pageName }}</span>
<el-icon class="page-name-edit" @click="startEditName"><Edit /></el-icon>
</template>
<el-input
v-else
ref="nameInput"
v-model="pageName"
size="small"
style="width: 200px"
maxlength="30"
placeholder="请输入模板名称"
@blur="editingName = false"
@keyup.enter="editingName = false"
/>
</div>
</div>
<div class="btns">
<el-button
@click="clickBtn(item)"
@@ -12,29 +31,22 @@
>{{ item.title }}</el-button>
</div>
<div class="model-title-view-btn">
<!-- TODO 后期会补全 目前版本暂无 -->
<!-- <Poptip placement="bottom" width="100">
<el-button size="default" @click="creatQrCode">预览模板</el-button>
<template #content><div class="default-view-content">
<div>临时预览</div>
<div ref="qrCodeUrl"></div>
</div>
</template></el-popconfirm>-->
<el-button size="default" type="default" v-if="hasCache" @click="clearCache">清空本地缓存</el-button>
<el-button size="default" type="primary" @click="handleSpinShow">保存模板</el-button>
<el-button size="default" type="primary" @click="handleSpinShow">保存页面</el-button>
<el-dialog
title="保存中"
:title="progress ? '保存页面' : '保存中'"
v-model="saveDialog"
:show-close="true"
:close-on-click-modal="false"
append-to-body
destroy-on-close
@closed="handleDialogClosed"
>
<div v-if="progress">
<div class="model-item">
模板名称
<el-input style="width: 200px" v-model="submitWay.name" />
<el-input style="width: 200px" v-model="pageName" placeholder="请输入模板名称" />
</div>
<div class="model-item">
是否立即发布
@@ -46,7 +58,7 @@
</div>
<div class="model-item">
将当前装修内容写入到本地缓存中,下次进入页面时可继续使用
<el-button type="small" @click="witeLocalStore">写入</el-button>
<el-button size="small" @click="witeLocalStore">写入</el-button>
</div>
<el-button type="primary" @click="save()">保存</el-button>
@@ -57,12 +69,13 @@
</div>
</template>
<script setup>
import { onMounted, reactive, ref, watch } from "vue";
import { reactive, ref, watch, onMounted, nextTick, computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useAppStore } from "@/stores/app";
import { Message } from "@/utils/message";
import { getStore, setStore } from "@/libs/storage";
import * as API_Other from "@/api/other.js";
import { Edit } from "@element-plus/icons-vue";
import { getStore, setStore } from "@/libs/storage";
import { Message } from "@/utils/message";
import { useAppStore } from "@/stores/app";
const props = defineProps(["pagetype"]);
const emit = defineEmits(["selected"]);
@@ -73,9 +86,10 @@ const appStore = useAppStore();
const hasCache = ref(false); // 是否有缓存
const progress = ref(true); // 展示进度
const num = ref(20); // 提交进度
const num = ref(0); // 提交进度
const saveDialog = ref(false); // 加载状态
const way = ref([
// 装修tab栏切换
{
title: "首页",
name: "index",
@@ -85,11 +99,48 @@ const way = ref([
const submitWay = reactive({
// 表单信息
pageShow: route.query.type || false,
name: route.query.name || "模板名称",
pageClientType: "H5"
pageShow: route.query.type === "true" || route.query.type === true || route.query.type === "OPEN",
name: route.query.name || "店铺首页模板",
pageClientType: "H5",
});
// 模板名称(优先读写 store 中的 name,便于与页面设置面板联动)
const editingName = ref(false);
const nameInput = ref(null);
const pageName = computed({
get() {
const fromStore = appStore.styleStore && appStore.styleStore.name;
return fromStore || submitWay.name;
},
set(val) {
submitWay.name = val;
if (appStore.styleStore && typeof appStore.styleStore === "object") {
appStore.styleStore.name = val;
}
},
});
function startEditName() {
editingName.value = true;
nextTick(() => {
nameInput.value?.focus();
});
}
onMounted(() => {
const store = appStore.styleStore;
if (store && typeof store === "object" && !store.name && route.query.name) {
store.name = route.query.name;
}
hasCache.value = !!(getStore("sellerMobilePageCache") || getStore("managerMobilePageCache"));
});
watch(
() => route.query.type,
(val) => {
submitWay.pageShow = val === "true" || val === true || val === "OPEN";
}
);
watch(
() => props.pagetype,
(val) => {
@@ -103,109 +154,144 @@ watch(
{ immediate: true }
);
onMounted(() => {
hasCache.value = getStore('managerMobilePageCache') ? true : false;
});
function clearCache() {
setStore('managerMobilePageCache','');
Message.success('清空成功')
setStore("sellerMobilePageCache", "");
setStore("managerMobilePageCache", "");
hasCache.value = false;
Message.success("清空成功");
}
// 将楼层装修的内容写入到本地缓存中
function witeLocalStore() {
setStore('managerMobilePageCache', appStore.styleStore)
Message.success('写入成功')
setStore("sellerMobilePageCache", appStore.styleStore);
hasCache.value = true;
Message.success("写入成功");
}
function clickBtn(val) {
way.value.forEach((item, index) => {
way.value.forEach((item) => {
item.selected = false;
});
val.selected = true;
emit("selected", val.name);
}
/**
* 加载,并保存模板
*/
function handleSpinShow() {
progress.value = true;
num.value = 0;
saveDialog.value = true;
}
// 填写是否发布,模板名称之后保存
function handleDialogClosed() {
progress.value = true;
num.value = 0;
}
function save() {
if (
appStore.styleStore == void 0 &&
route.query.pagetype != 'SPECIAL'
) {
if (!pageName.value || !pageName.value.trim()) {
Message.error("请填写模板名称");
return false;
}
const title = appStore.styleStore && appStore.styleStore.title;
if (!title || !String(title).trim()) {
Message.error("请填写页面标题");
return false;
}
if (appStore.styleStore == void 0 && route.query.pagetype != "SPECIAL") {
Message.error("请装修楼层");
return false;
}
submitWay?.pageShow === true
? (submitWay.pageShow = "OPEN")
: (submitWay.pageShow = "CLOSE");
const payload = {
...submitWay,
pageShow: submitWay.pageShow ? "OPEN" : "CLOSE",
name: pageName.value,
pageData: JSON.stringify(appStore.styleStore),
pageType: route.query.pagetype || "INDEX",
};
submitWay.pageData = JSON.stringify(appStore.styleStore);
submitWay.pageType = route.query.pagetype || "INDEX";
route.query.id ? updateHome() : submit(submitWay);
route.query.id ? updateHome(payload) : submit(payload);
}
// 更新
function updateHome() {
function updateHome(payload) {
progress.value = false;
API_Other.updateHome(route.query.id, {
num.value = 20;
const data = payload || {
pageData: JSON.stringify(appStore.styleStore),
name: submitWay.name,
pageShow: submitWay.pageShow,
pageType: submitWay.pageType,
name: pageName.value,
pageShow: submitWay.pageShow ? "OPEN" : "CLOSE",
pageType: submitWay.pageType || route.query.pagetype || "INDEX",
pageClientType: "H5",
})
.then(res => {
num.value = 50;
};
API_Other.updateHome(route.query.id, data)
.then((res) => {
num.value = 60;
if (res.success) {
num.value = 80;
/**制作保存成功动画¸ */
num.value = 100;
setTimeout(() => {
saveDialog.value = false;
progress.value = true;
num.value = 0;
Message.success("修改成功");
goBack();
}, 1000);
}, 800);
} else {
progress.value = true;
num.value = 0;
saveDialog.value = false;
Message.error("修改失败,请稍后重试");
}
})
.catch(error => {});
.catch(() => {
progress.value = true;
num.value = 0;
saveDialog.value = false;
Message.error("修改失败,请稍后重试");
});
}
// 返回查询数据页面
function goBack() {
router.go(-1);
if (window.history.state?.back) {
router.go(-1);
} else {
router.push("/wapList");
}
}
// 保存
function submit(submitWay) {
function submit(payload) {
progress.value = false;
API_Other.setHomeSetup(submitWay)
.then(res => {
num.value = 50;
num.value = 20;
const data = payload || {
...submitWay,
pageShow: submitWay.pageShow ? "OPEN" : "CLOSE",
name: pageName.value,
pageData: JSON.stringify(appStore.styleStore),
pageType: route.query.pagetype || "INDEX",
};
API_Other.setHomeSetup(data)
.then((res) => {
num.value = 60;
if (res.success) {
num.value = 80;
/**制作保存成功动画¸ */
num.value = 100;
setTimeout(() => {
saveDialog.value = false;
progress.value = true;
num.value = 0;
Message.success("保存成功");
goBack();
}, 1000);
}, 800);
} else {
progress.value = true;
num.value = 0;
saveDialog.value = false;
Message.error("保存失败,请稍后重试");
}
})
.catch(error => {});
.catch(() => {
progress.value = true;
num.value = 0;
saveDialog.value = false;
});
}
</script>
<style scoped lang="scss">
@@ -219,17 +305,44 @@ function submit(submitWay) {
}
}
.model-title {
height: 70px;
height: 60px;
display: flex;
justify-content: space-between;
align-items: center;
color: #333;
font-size: 14px;
> .model-title-view-btn {
> * {
margin: 0 10px;
background: #fff;
border-bottom: 1px solid #ebeef5;
padding: 0 16px;
margin-bottom: 12px;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
.model-title-left {
display: flex;
align-items: center;
gap: 12px;
}
.page-name {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: #303133;
font-weight: 500;
}
.page-name-edit {
cursor: pointer;
color: #909399;
font-size: 14px;
&:hover {
color: #409eff;
}
}
> .model-title-view-btn {
display: flex;
align-items: center;
gap: 10px;
}
}
.btns {
* {

View File

@@ -1,12 +1,42 @@
.model-view {
display: flex;
gap: 12px;
height: calc(100vh - 96px);
.model-view-menu {
flex: 1.5;
min-width: 250px;
> .model-view-menu-item {
overflow-y: auto;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
padding: 14px 12px;
box-sizing: border-box;
.model-view-menu-item {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
}
.model-group {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
}
.model-group-title {
display: flex;
align-items: center;
flex-wrap: wrap;
font-size: 15px;
font-weight: 400;
color: #303133;
margin: 0 0 12px;
&::before {
content: "";
width: 3px;
height: 12px;
border-radius: 2px;
background: #409eff;
margin-right: 6px;
}
}
}
.model-config {
@@ -16,17 +46,44 @@
flex: 6;
}
.model-item {
line-height: 1.75;
font-size: 13px;
margin: 0 5px 10px 5px;
border: 1px solid #ededed;
background: #f6f6f9;
width: 70px;
height: 70px;
line-height: 1.4;
font-size: 12px;
border: 1px solid #ebeef5;
background: #ffffff;
border-radius: 8px;
height: 78px;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 8px;
color: #606266;
cursor: grab;
transition: all 0.2s ease;
.el-icon {
width: 36px;
height: 36px;
border-radius: 8px;
background: #f0f5ff;
color: #409eff;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
&:hover {
border-color: #c6e2ff;
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.15);
transform: translateY(-2px);
color: #303133;
.el-icon {
background: #409eff;
color: #ffffff;
}
}
&:active {
cursor: grabbing;
}
}
}
.model-view-content {
@@ -36,6 +93,8 @@
justify-content: flex-start;
align-items: center;
flex-direction: column;
overflow-y: auto;
overflow-x: hidden;
}
.wap-title {
line-height: 44px;
@@ -48,12 +107,12 @@
.content {
box-sizing: border-box;
margin: 20px 0;
padding: 50px 13px;
width: 360px;
background: url("../../../assets/iPhoneX_model.png") no-repeat;
height: 780px;
background-size: 100% 100%;
overflow: hidden;
width: 375px;
background: #ebebeb;
min-height: calc(100vh - 200px);
border-radius: 8px;
overflow: visible;
flex-shrink: 0;
display: flex;
flex-direction: column;
@@ -61,8 +120,7 @@
flex: 1;
width: 100%;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
overflow: visible;
background: #ebebeb;
padding-bottom: 100px;
box-sizing: border-box;
@@ -77,11 +135,67 @@
.list {
position: relative;
}
.close {
.floor-name {
position: absolute;
right: 5px;
top: 10px;
z-index: 99;
right: calc(100% + 8px);
top: 8px;
z-index: 60;
background: #ffffff;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 2px 8px;
font-size: 14px;
color: #606266;
white-space: nowrap;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
pointer-events: none;
}
.floor-tools {
position: absolute;
right: -56px;
top: 50%;
transform: translateY(-50%);
z-index: 100;
display: flex;
flex-direction: column;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
padding: 4px 0;
.el-icon {
font-size: 20px;
padding: 9px 12px;
cursor: pointer;
color: #606266;
&:hover {
color: #409eff;
}
&.disabled {
color: #dcdfe6;
cursor: not-allowed;
}
}
}
.floor-hidden-mask {
position: absolute;
inset: 0;
z-index: 50;
background: rgba(0, 0, 0, 0.45);
color: #ffffff;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.unknown-component {
padding: 16px 10px;
background: #fff2f0;
border: 1px dashed #ffccc7;
color: #cf1322;
font-size: 12px;
text-align: center;
cursor: pointer;
}
.model-btn {
@@ -97,15 +211,16 @@
text-align: center;
font-weight: bold;
line-height: 2;
:deep(img) {
:deep(img){
margin: 0 auto;
}
}
.model-config {
overflow-y: auto;
background: #f5f7fa;
}
.active {
box-sizing: border-box;
border: 1px solid $theme_color;
border: 2px solid $theme_color;
}

View File

@@ -0,0 +1,28 @@
// 图片魔方各布局预览共用的样式字段(面板配置;老数据无字段时返回空对象,保持原样)
// 画布 375px ≈ 750rpx,预览按数值一半像素呈现
const px = (v) => `${(Number(v) || 0) / 2}px`;
export function cubeWrapperStyle(res) {
if (!res || res.paddingTop === undefined) return {};
const bg =
!res.bgColor2 || res.bgColor1 === res.bgColor2
? res.bgColor1
: `linear-gradient(180deg, ${res.bgColor1}, ${res.bgColor2})`;
return {
padding: `${px(res.paddingTop)} ${px(res.paddingX)} ${px(res.paddingBottom)}`,
background: bg,
borderRadius: px(res.borderRadius),
marginBottom: px(res.pageGap),
overflow: Number(res.borderRadius) > 0 ? "hidden" : "visible",
};
}
export function cubeImgStyle(res) {
if (!res || res.imgRadius === undefined) return {};
return { borderRadius: px(res.imgRadius), overflow: "hidden" };
}
export function cubeGapStyle(res) {
if (!res || res.contentGap === undefined) return {};
return { gap: px(res.contentGap) };
}

View File

@@ -12,20 +12,34 @@ import tpl_flex_four from "./tpl_flex_four";
import tpl_text_picture from "./tpl_text_picture";
import tpl_menu from "./tpl_menu";
import tpl_search from "./tpl_search";
import tpl_join_group from "./tpl_join_group";
import tpl_promotion_detail from "./tpl_promotion_detail";
import tpl_integral from "./tpl_integral";
import tpl_spike from "./tpl_spike";
import tpl_group from "./tpl_group";
import tpl_ad_list from "./tpl_view_list";
import tpl_activity_list from './tpl_view_list'
import tpl_activity_list from './tpl_view_list';
// import tpl_hot_area from './tpl_hot_area';
import tpl_video from "./tpl_video";
import tpl_goods from "./tpl_goods";
import tpl_notice from "./tpl_notice";
import tpl_promotions from "./tpl_promotions";
import tpl_blank from "./tpl_blank";
import tpl_line from "./tpl_line";
import tpl_rich_text from "./tpl_rich_text";
import tpl_coupon from "./tpl_coupon";
import tpl_article from "./tpl_article";
import tpl_shop from "./tpl_shop";
import tpl_home_comb from "./tpl_home_comb";
export default {
homeComb: tpl_home_comb,
notice: tpl_notice,
carousel: tpl_banner,
title: tpl_title,
leftOneRightTwo: tpl_left_one_right_two,
leftTwoRightOne: tpl_left_two_right_one,
topOneBottomTwo:tpl_top_one_bottom_two,
topTwoBottomOne:tpl_top_two_bottom_one,
topOneBottomTwo: tpl_top_one_bottom_two,
topTwoBottomOne: tpl_top_two_bottom_one,
flexThree: tpl_flex_three,
flexFive: tpl_flex_five,
flexFour: tpl_flex_four,
@@ -33,12 +47,21 @@ export default {
textPicture: tpl_text_picture,
menu: tpl_menu,
search: tpl_search,
joinGroup: tpl_join_group,
promotionDetail: tpl_promotion_detail,
flexOne: tpl_flex_one,
goods: tpl_goods,
integral:tpl_integral,
spike:tpl_spike,
group:tpl_group,
integral: tpl_integral,
spike: tpl_spike,
group: tpl_group,
// hotArea: tpl_hot_area,
video: tpl_video,
tpl_ad_list,
tpl_activity_list
promotions: tpl_promotions,
tpl_activity_list,
blank: tpl_blank,
line: tpl_line,
richText: tpl_rich_text,
coupon: tpl_coupon,
article: tpl_article,
shop: tpl_shop,
};

View File

@@ -6,7 +6,7 @@
}
.layout {
padding: 8px;
margin: 4px 0;
margin: 0;
background: #fff;
}
.layout,

View File

@@ -0,0 +1,154 @@
<template>
<div class="tpl-article" :class="{ styled: styled }" :style="wrapperStyle">
<div class="article-title" v-if="title">{{ title }}</div>
<template v-if="articleList.length">
<div class="article-item" v-for="item in articleList" :key="item.id">
<div class="article-text">
<div class="article-name" :style="nameStyle">{{ item.title }}</div>
<div class="article-time" v-if="item.createTime">{{ item.createTime }}</div>
</div>
</div>
</template>
<template v-else>
<div class="article-item" v-for="i in 3" :key="i">
<div class="article-text">
<div class="article-name" :style="nameStyle">文章标题示例文章标题示例</div>
</div>
<div class="article-thumb"></div>
</div>
</template>
</div>
</template>
<script>
import { getArticle } from "@/api/pages";
export default {
name: "tpl_article",
props: {
res: { type: Object, default: () => ({}) },
},
data() {
return { articleList: [] };
},
computed: {
first() {
return (this.res.list && this.res.list[0]) || {};
},
title() {
return this.first.title || "";
},
count() {
return this.first.count || 3;
},
categoryId() {
return this.first.categoryId || "";
},
// 面板保存过样式字段才启用自定义样式,老数据保持原样
styled() {
return this.res && this.res.paddingTop !== undefined;
},
nameStyle() {
return this.styled && this.res.titleColor
? { color: this.res.titleColor }
: {};
},
wrapperStyle() {
if (!this.styled) {
return {
background: this.res.bgColor || "#FFFFFF",
padding: `10px ${this.res.paddingX || 0}px`,
marginBottom: `${this.res.pageGap !== undefined ? this.res.pageGap : 0}px`,
borderRadius: `${this.res.borderRadius || 0}px`,
overflow: "hidden",
};
}
const res = this.res;
const bg =
!res.bgColor2 || res.bgColor1 === res.bgColor2
? res.bgColor1
: `linear-gradient(180deg, ${res.bgColor1}, ${res.bgColor2})`;
const px = (v) => `${(Number(v) || 0) / 2}px`;
return {
background: bg,
padding: `${px(res.paddingTop)} ${px(res.paddingX)} ${px(res.paddingBottom)}`,
marginBottom: px(res.pageGap),
borderRadius: px(res.borderRadius),
overflow: Number(res.borderRadius) > 0 ? "hidden" : "visible",
display: "flex",
flexDirection: "column",
rowGap: px(res.contentGap),
};
},
},
watch: {
categoryId() {
this.loadArticles();
},
count() {
this.loadArticles();
},
},
mounted() {
this.loadArticles();
},
methods: {
loadArticles() {
const params = { pageNumber: 1, pageSize: this.count };
if (this.categoryId) params.categoryId = this.categoryId;
getArticle(params)
.then((res) => {
const records = res && res.result && res.result.records;
this.articleList = Array.isArray(records)
? records.slice(0, this.count)
: [];
})
.catch(() => {
this.articleList = [];
});
},
},
};
</script>
<style scoped lang="scss">
.article-title {
font-size: 15px;
font-weight: 600;
padding: 4px 12px 10px;
}
.article-item {
display: flex;
align-items: center;
padding: 8px 12px;
border-top: 1px solid #f5f5f5;
}
.tpl-article.styled .article-item {
border-top: none;
padding: 0;
}
.article-text {
flex: 1;
min-width: 0;
}
.article-name {
font-size: 13px;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.article-time {
font-size: 11px;
color: #999;
margin-top: 4px;
}
.article-thumb {
width: 56px;
height: 42px;
background: #f0f0f0;
border-radius: 4px;
margin-left: 10px;
flex-shrink: 0;
}
</style>

View File

@@ -1,17 +1,151 @@
<template>
<div class="layout">
<el-carousel class="carousel" v-if="res" autoplay :interval="5000" height="150px">
<el-carousel-item v-for="(item, index) in res.list" :key="index">
<div>
<img alt="轮播图" class="image-mode" :src="item.img" />
<div class="layout" :style="wrapperStyle">
<div class="banner-box" :style="cardMode ? {} : boxStyle">
<el-carousel
class="carousel"
v-if="res && !cardMode"
autoplay
:interval="5000"
height="150px"
:indicator-position="customIndicator ? 'none' : ''"
@change="onChange"
>
<el-carousel-item v-for="(item, index) in res.list" :key="index">
<div class="slide-wrap">
<img alt="轮播图" class="image-mode" :style="boxStyle" :src="item.img" />
</div>
</el-carousel-item>
</el-carousel>
<!-- 卡片风格:当前图 82% 居中放大,两侧 9% 露出并缩小,循环切换 -->
<div
v-if="res && cardMode"
ref="cardPreview"
class="card-preview"
@mouseenter="stopAuto"
@mouseleave="startAuto"
>
<div
class="card-track"
:style="{ transform: `translateX(${trackOffset}px)` }"
>
<div
class="card-slide"
:class="{ active: index === current }"
v-for="(item, index) in res.list"
:key="index"
>
<img alt="轮播图" class="card-img" :style="boxStyle" :src="item.img" />
</div>
</div>
</el-carousel-item>
</el-carousel>
</div>
<div
v-if="customIndicator && res.indicatorStyle !== 'none' && res.list.length > 1"
class="indicator"
:class="'pos-' + (res.indicatorPosition || 'center')"
>
<div
v-for="(item, i) in res.list"
:key="i"
class="dot"
:class="[res.indicatorStyle || 'round', { active: i === current }]"
:style="i === current ? { background: indicatorColor } : {}"
></div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onBeforeUnmount } from "vue";
defineOptions({ title: "导航栏" });
defineProps(["res"]);
const props = defineProps(["res"]);
const current = ref(0);
const previewWidth = ref(0);
const cardPreview = ref(null);
let timer = null;
// 配置过样式字段才启用自定义样式/指示器,老数据保持原样
const customIndicator = computed(
() => props.res && props.res.indicatorStyle !== undefined
);
const cardMode = computed(() => props.res && props.res.swiperStyle === "card");
const indicatorColor = computed(() =>
props.res.indicatorColorMode === "custom" && props.res.indicatorColor
? props.res.indicatorColor
: "#409eff"
);
// 卡片宽 82%,两侧各露出 9%
const trackOffset = computed(
() => previewWidth.value * 0.09 - current.value * previewWidth.value * 0.82
);
const wrapperStyle = computed(() => {
const res = props.res || {};
if (res.paddingTop === undefined) return {};
const bg =
!res.bgColor2 || res.bgColor1 === res.bgColor2
? res.bgColor1
: `linear-gradient(180deg, ${res.bgColor1}, ${res.bgColor2})`;
// 画布 375px ≈ 750rpx,预览按一半像素呈现
const px = (v) => `${(Number(v) || 0) / 2}px`;
return {
padding: `${px(res.paddingTop)} ${px(res.paddingX)} ${px(res.paddingBottom)}`,
background: bg,
borderRadius: px(res.borderRadius),
marginBottom: px(res.pageGap),
};
});
const boxStyle = computed(() => {
const res = props.res || {};
if (res.imgRadius === undefined) return {};
return { borderRadius: `${(Number(res.imgRadius) || 0) / 2}px` };
});
function onChange(index) {
current.value = index;
}
function measure() {
if (cardPreview.value) {
previewWidth.value = cardPreview.value.offsetWidth;
}
}
function startAuto() {
stopAuto();
if (!cardMode.value || !props.res.list || props.res.list.length < 2) return;
timer = setInterval(() => {
current.value = (current.value + 1) % props.res.list.length;
}, 5000);
}
function stopAuto() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
watch(
cardMode,
(val) => {
stopAuto();
if (val) {
measure();
startAuto();
}
},
{ immediate: true, flush: "post" }
);
watch(
() => props.res.list && props.res.list.length,
(len) => {
if (current.value >= len) current.value = 0;
}
);
onBeforeUnmount(stopAuto);
</script>
<style lang="scss" scoped>
@import "./tpl.scss";
@@ -19,4 +153,79 @@ defineProps(["res"]);
.image-mode {
height: 150px;
}
</style>
.banner-box {
position: relative;
overflow: hidden;
}
.slide-wrap {
height: 100%;
padding: 0 3px;
box-sizing: border-box;
}
.image-mode {
width: 100%;
display: block;
overflow: hidden;
}
.card-preview {
overflow: hidden;
height: 150px;
display: flex;
align-items: center;
}
.card-track {
display: flex;
align-items: center;
transition: transform 0.5s ease;
width: 100%;
}
.card-slide {
flex: 0 0 82%;
padding: 0 3px;
box-sizing: border-box;
transform: scale(0.9);
transition: transform 0.4s ease;
&.active {
transform: scale(1);
}
}
.card-img {
width: 100%;
height: 150px;
display: block;
overflow: hidden;
}
.indicator {
position: absolute;
bottom: 8px;
display: flex;
align-items: center;
z-index: 2;
&.pos-left {
left: 12px;
}
&.pos-center {
left: 50%;
transform: translateX(-50%);
}
&.pos-right {
right: 12px;
}
.dot {
background: rgba(255, 255, 255, 0.7);
transition: background 0.3s ease;
&.round {
width: 6px;
height: 6px;
border-radius: 50%;
margin: 0 3px;
}
&.line {
width: 12px;
height: 3px;
border-radius: 2px;
margin: 0 2px;
}
}
}
</style>

View File

@@ -0,0 +1,35 @@
<template>
<div
class="tpl-blank"
:style="{
background: blankBg,
height: px(res.height ?? 10),
margin: `0 ${px(res.paddingX ?? 0)} ${px(res.pageGap ?? 0)}`,
}"
></div>
</template>
<script>
const px = (v) => `${(Number(v) || 0) / 2}px`;
export default {
name: "tpl_blank",
props: {
res: { type: Object, default: () => ({}) },
},
methods: {
px,
},
computed: {
blankBg() {
const res = this.res || {};
if (res.bgColor1 !== undefined) {
return !res.bgColor2 || res.bgColor1 === res.bgColor2
? res.bgColor1
: `linear-gradient(180deg, ${res.bgColor1}, ${res.bgColor2})`;
}
return res.bgColor || "#f5f5f5";
},
},
};
</script>

View File

@@ -0,0 +1,441 @@
<template>
<div class="tpl-coupon" :class="{ styled: styled }" :style="wrapperStyle">
<div class="coupon-title" v-if="title">{{ title }}</div>
<!-- 老数据保持原样 -->
<div v-if="!styled" class="coupon-list">
<div class="coupon-item" v-for="i in 2" :key="i">
<div class="coupon-left">
<div class="coupon-amount">
<span class="coupon-symbol">¥</span>10
</div>
<div class="coupon-threshold">满100可用</div>
</div>
<div class="coupon-right">
<div class="coupon-name">优惠券名称</div>
<div class="coupon-btn">立即领取</div>
</div>
</div>
</div>
<!-- 样式一:红色大卡横滑,右侧竖排领取 -->
<div v-else-if="layoutStyle === '1'" class="cp-scroll" :style="scrollGap">
<div class="cp1-item" v-for="(item, i) in displayData" :key="i">
<div class="cp1-left">
<div class="cp1-amount">
<template v-if="item.couponType == 'DISCOUNT'">
<span class="cp1-value">{{ item.couponDiscount }}</span
><span class="cp1-symbol">折</span>
</template>
<template v-else>
<span class="cp1-symbol">¥</span
><span class="cp1-value">{{ item.price }}</span>
</template>
</div>
<div class="cp1-threshold">{{ thresholdText(item) }}</div>
</div>
<div class="cp1-right">
<span>领</span><span>取</span>
</div>
<i class="cp-notch cp-notch-t"></i>
<i class="cp-notch cp-notch-b"></i>
</div>
</div>
<!-- 样式二:白底小卡,底部去领取按钮 -->
<div v-else-if="layoutStyle === '2'" class="cp-scroll" :style="scrollGap">
<div class="cp2-item" v-for="(item, i) in displayData" :key="i">
<div class="cp2-top">
<div class="cp2-amount">
<template v-if="item.couponType == 'DISCOUNT'">
<span class="cp2-value">{{ item.couponDiscount }}</span
><span class="cp2-symbol">折</span>
</template>
<template v-else>
<span class="cp2-symbol">¥</span
><span class="cp2-value">{{ item.price }}</span>
</template>
</div>
<div class="cp2-threshold">{{ thresholdText(item) }}</div>
</div>
<div class="cp2-btn">去领取</div>
</div>
</div>
<!-- 样式三:浅粉虚线卡,右侧竖排立即领取 -->
<div v-else-if="layoutStyle === '3'" class="cp-scroll" :style="scrollGap">
<div class="cp3-item" v-for="(item, i) in displayData" :key="i">
<div class="cp3-left">
<div class="cp3-amount">
<template v-if="item.couponType == 'DISCOUNT'">
<span class="cp3-value">{{ item.couponDiscount }}</span
><span class="cp3-symbol">折</span>
</template>
<template v-else>
<span class="cp3-symbol">¥</span
><span class="cp3-value">{{ item.price }}</span>
</template>
</div>
<div class="cp3-threshold">{{ thresholdText(item) }}</div>
</div>
<div class="cp3-right">
<span>立</span><span>即</span><span>领</span><span>取</span>
</div>
<i class="cp-notch cp-notch-t"></i>
<i class="cp-notch cp-notch-b"></i>
</div>
</div>
<!-- 样式四:浅粉渐变 + 右侧渐变竖条领取 -->
<div v-else class="cp-scroll" :style="scrollGap">
<div class="cp4-item" v-for="(item, i) in displayData" :key="i">
<div class="cp4-left">
<div class="cp4-amount">
<template v-if="item.couponType == 'DISCOUNT'">
<span class="cp4-value">{{ item.couponDiscount }}</span
><span class="cp4-symbol">折</span>
</template>
<template v-else>
<span class="cp4-symbol">¥</span
><span class="cp4-value">{{ item.price }}</span>
</template>
</div>
<div class="cp4-threshold">{{ thresholdText(item) }}</div>
</div>
<div class="cp4-right">
<span>领</span><span>取</span>
</div>
<i class="cp-notch cp-notch-t"></i>
<i class="cp-notch cp-notch-b"></i>
</div>
</div>
</div>
</template>
<script>
export default {
name: "tpl_coupon",
props: {
res: { type: Object, default: () => ({}) },
},
computed: {
firstItem() {
return (this.res.list && this.res.list[0]) || {};
},
title() {
return this.firstItem.title || "";
},
layoutStyle() {
return (this.res && this.res.layoutStyle) || "1";
},
displayData() {
const data = this.firstItem.data || [];
return data.slice(0, this.firstItem.count || 5);
},
// 面板保存过样式字段才启用自定义样式,老数据保持原样
styled() {
return this.res && this.res.paddingTop !== undefined;
},
scrollGap() {
if (!this.styled) return {};
return { columnGap: `${(Number(this.res.contentGap) || 0) / 2}px` };
},
wrapperStyle() {
if (!this.styled) {
return {
background: this.res.bgColor || "#FFFFFF",
padding: `10px ${this.res.paddingX || 0}px`,
marginBottom: `${this.res.pageGap !== undefined ? this.res.pageGap : 0}px`,
borderRadius: `${this.res.borderRadius || 0}px`,
overflow: "hidden",
};
}
const res = this.res;
const bg =
!res.bgColor2 || res.bgColor1 === res.bgColor2
? res.bgColor1
: `linear-gradient(180deg, ${res.bgColor1}, ${res.bgColor2})`;
const px = (v) => `${(Number(v) || 0) / 2}px`;
return {
background: bg,
padding: `${px(res.paddingTop)} ${px(res.paddingX)} ${px(res.paddingBottom)}`,
marginBottom: px(res.pageGap),
borderRadius: px(res.borderRadius),
overflow: "hidden",
};
},
},
methods: {
thresholdText(item) {
const t = Number(item.consumeThreshold);
return t > 0 ? `满${item.consumeThreshold}元可用` : "无门槛使用";
},
},
};
</script>
<style scoped lang="scss">
.tpl-coupon {
font-size: 13px;
}
.coupon-title {
font-size: 15px;
font-weight: 600;
padding: 4px 10px 10px;
}
.coupon-item {
display: flex;
margin: 0 10px 10px;
border-radius: 6px;
overflow: hidden;
background: linear-gradient(90deg, #ffece8, #fff5f4);
}
.coupon-left {
width: 90px;
padding: 12px 0;
text-align: center;
color: #e1251b;
border-right: 1px dashed #f0c0bb;
}
.coupon-amount {
font-size: 20px;
font-weight: 700;
}
.coupon-symbol {
font-size: 12px;
}
.coupon-threshold {
font-size: 11px;
}
.coupon-right {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
}
.coupon-name {
color: #333;
}
.coupon-btn {
background: #e1251b;
color: #fff;
font-size: 12px;
padding: 4px 10px;
border-radius: 12px;
}
.tpl-coupon.styled .coupon-item {
margin: 0;
}
.tpl-coupon.styled .coupon-list {
display: contents;
}
/* 参考 CRMEB 优惠券组件 */
.tpl-coupon.styled .coupon-title {
padding: 0 0 8px;
}
.cp-scroll {
display: flex;
column-gap: 4px;
overflow: hidden;
}
.cp-notch {
position: absolute;
right: 30px;
width: 8px;
height: 8px;
border-radius: 50%;
background: #fff;
}
.cp-notch-t {
top: -4px;
}
.cp-notch-b {
bottom: -4px;
}
/* 样式一:红色大卡 */
.cp1-item {
position: relative;
display: flex;
flex-shrink: 0;
width: 132px;
height: 66px;
border-radius: 6px;
background: linear-gradient(135deg, #ff5a36, #e1251b);
color: #fff;
}
.cp1-left {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
padding-left: 12px;
}
.cp1-value {
font-size: 20px;
font-weight: 700;
}
.cp1-symbol {
font-size: 11px;
}
.cp1-threshold {
font-size: 10px;
color: rgba(255, 255, 255, 0.85);
margin-top: 2px;
}
.cp1-right {
width: 30px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 12px;
border-left: 1px dashed rgba(255, 255, 255, 0.6);
}
.cp1-item .cp-notch {
right: 26px;
}
/* 样式二:白底小卡 */
.cp2-item {
display: flex;
flex-direction: column;
flex-shrink: 0;
width: 74px;
border-radius: 6px;
background: #fff;
border-left: 2px solid #f5483a;
border-right: 2px solid #f5483a;
overflow: hidden;
}
.cp2-top {
padding: 8px 0 6px;
text-align: center;
}
.cp2-value {
font-size: 17px;
font-weight: 700;
color: #e1251b;
}
.cp2-symbol {
font-size: 10px;
color: #e1251b;
}
.cp2-threshold {
font-size: 9px;
color: #999;
margin-top: 1px;
}
.cp2-btn {
background: linear-gradient(90deg, #ff6a3d, #e1251b);
color: #fff;
font-size: 10px;
text-align: center;
padding: 4px 0;
}
/* 样式三:浅粉虚线卡 */
.cp3-item {
position: relative;
display: flex;
flex-shrink: 0;
width: 138px;
height: 60px;
border-radius: 6px;
background: #fdf1ec;
border: 1px dashed #f5483a;
}
.cp3-left {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
padding-left: 12px;
}
.cp3-value {
font-size: 19px;
font-weight: 700;
color: #e1251b;
}
.cp3-symbol {
font-size: 10px;
color: #e1251b;
}
.cp3-threshold {
font-size: 10px;
color: #e1251b;
margin-top: 1px;
}
.cp3-right {
width: 32px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 11px;
color: #e1251b;
border-left: 1px dashed #f5483a;
}
.cp3-item .cp-notch {
right: 28px;
}
/* 样式四:浅粉渐变 + 渐变竖条 */
.cp4-item {
position: relative;
display: flex;
flex-shrink: 0;
width: 116px;
height: 54px;
border-radius: 6px;
overflow: hidden;
}
.cp4-left {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
padding-left: 10px;
background: linear-gradient(135deg, #fff3ec, #ffe0d3);
}
.cp4-value {
font-size: 17px;
font-weight: 700;
color: #e1251b;
}
.cp4-symbol {
font-size: 10px;
color: #e1251b;
}
.cp4-threshold {
font-size: 9px;
color: #e1251b;
margin-top: 1px;
}
.cp4-right {
width: 26px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 11px;
color: #fff;
background: linear-gradient(180deg, #ff6a3d, #e1251b);
}
.cp4-item .cp-notch {
right: 22px;
width: 6px;
height: 6px;
}
.cp4-item .cp-notch-t {
top: -3px;
}
.cp4-item .cp-notch-b {
bottom: -3px;
}
</style>

View File

@@ -1,17 +1,24 @@
<template>
<div class="layout">
<img class="image-mode" :src="res.list[0].img" alt="">
<img class="image-mode" :src="res.list[1].img" alt="">
<img class="image-mode" :src="res.list[2].img" alt="">
<img class="image-mode" :src="res.list[3].img" alt="">
<img class="image-mode" :src="res.list[4].img" alt="">
<div class="layout" :style="[wrapperStyle, gapStyle]">
<img class="image-mode" :style="imgStyle" :src="res.list[0].img" alt="">
<img class="image-mode" :style="imgStyle" :src="res.list[1].img" alt="">
<img class="image-mode" :style="imgStyle" :src="res.list[2].img" alt="">
<img class="image-mode" :style="imgStyle" :src="res.list[3].img" alt="">
<img class="image-mode" :style="imgStyle" :src="res.list[4].img" alt="">
</div>
</template>
<script setup>
import { computed } from "vue";
import { cubeWrapperStyle, cubeImgStyle, cubeGapStyle } from "./cubeStyle";
defineOptions({ title: "五列单行图片模块" });
defineProps(["res"]);
const props = defineProps(["res"]);
const wrapperStyle = computed(() => cubeWrapperStyle(props.res));
const imgStyle = computed(() => cubeImgStyle(props.res));
const gapStyle = computed(() => cubeGapStyle(props.res));
</script>
<style lang="scss" scoped>
@import "./tpl.scss";
@@ -23,6 +30,7 @@ defineProps(["res"]);
background-size: cover;
}
img {
width: 67px !important;
flex: 1;
min-width: 0;
}
</style>

View File

@@ -1,19 +1,23 @@
<template>
<div class="layout">
<img class="image-mode" :src="res.list[0].img" alt="">
<img class="image-mode" :src="res.list[1].img" alt="">
<img class="image-mode" :src="res.list[2].img" alt="">
<img class="image-mode" :src="res.list[3].img" alt="">
<div class="layout" :style="[wrapperStyle, gapStyle]">
<img class="image-mode" :style="imgStyle" :src="res.list[0].img" alt="">
<img class="image-mode" :style="imgStyle" :src="res.list[1].img" alt="">
<img class="image-mode" :style="imgStyle" :src="res.list[2].img" alt="">
<img class="image-mode" :style="imgStyle" :src="res.list[3].img" alt="">
</div>
</template>
<script setup>
defineOptions({
title: "四列单行图片模块",
});
import { computed } from "vue";
import { cubeWrapperStyle, cubeImgStyle, cubeGapStyle } from "./cubeStyle";
defineProps(["res"]);
defineOptions({ title: "四列单行图片模块" });
const props = defineProps(["res"]);
const wrapperStyle = computed(() => cubeWrapperStyle(props.res));
const imgStyle = computed(() => cubeImgStyle(props.res));
const gapStyle = computed(() => cubeGapStyle(props.res));
</script>
<style lang="scss" scoped>
@import "./tpl.scss";
@@ -28,6 +32,7 @@ defineProps(["res"]);
}
img{
width: 84px !important;
flex: 1;
min-width: 0;
}
</style>

View File

@@ -1,12 +1,32 @@
<template>
<div class="layout">
<div class="flex-one">
<div
class="layout"
:style="{
background: wrapperBg,
padding: `${px(res.paddingTop ?? 0)} ${px(res.paddingX ?? 0)} ${px(res.paddingBottom ?? 0)}`,
marginBottom: px(res.pageGap ?? 0),
borderRadius: px(res.borderRadius ?? 0),
overflow: (res.borderRadius ?? 0) > 0 ? 'hidden' : 'visible',
}"
>
<div class="flex-one" :style="{ borderRadius: px(res.imgRadius ?? 0) }">
<img :src="res.list[0].img" alt="">
</div>
</div>
</template>
<script setup>
defineProps(['res']);
import { computed } from 'vue'
const props = defineProps(['res'])
const px = (v) => `${(Number(v) || 0) / 2}px`
const wrapperBg = computed(() => {
const r = props.res || {}
if (r.bgColor1 !== undefined) {
return !r.bgColor2 || r.bgColor1 === r.bgColor2
? r.bgColor1
: `linear-gradient(180deg, ${r.bgColor1}, ${r.bgColor2})`
}
return r.bgColor ?? 'transparent'
})
</script>
<style lang="scss" scoped>
@import './tpl.scss';
@@ -19,4 +39,4 @@ defineProps(['res']);
height: 100%;
}
}
</style>
</style>

View File

@@ -1,18 +1,22 @@
<template>
<div class="layout">
<img class="image-mode" :src="res.list[0].img" alt="">
<img class="image-mode" :src="res.list[1].img" alt="">
<img class="image-mode" :src="res.list[2].img" alt="">
<div class="layout" :style="[wrapperStyle, gapStyle]">
<img class="image-mode" :style="imgStyle" :src="res.list[0].img" alt="">
<img class="image-mode" :style="imgStyle" :src="res.list[1].img" alt="">
<img class="image-mode" :style="imgStyle" :src="res.list[2].img" alt="">
</div>
</template>
<script setup>
defineOptions({
title: "三列单行图片模块",
});
import { computed } from "vue";
import { cubeWrapperStyle, cubeImgStyle, cubeGapStyle } from "./cubeStyle";
defineProps(["res"]);
defineOptions({ title: "三列单行图片模块" });
const props = defineProps(["res"]);
const wrapperStyle = computed(() => cubeWrapperStyle(props.res));
const imgStyle = computed(() => cubeImgStyle(props.res));
const gapStyle = computed(() => cubeGapStyle(props.res));
</script>
<style lang="scss" scoped>
@import "./tpl.scss";
@@ -25,6 +29,7 @@ defineProps(["res"]);
background-size: cover;
}
img{
width: 111px !important;
flex: 1;
min-width: 0;
}
</style>

View File

@@ -1,18 +1,25 @@
<template>
<div class="layout">
<div class="flex-two">
<div class="flex-item">
<div class="layout" :style="wrapperStyle">
<div class="flex-two" :style="gapStyle">
<div class="flex-item" :style="imgStyle">
<img :src="res.list[0].img" alt />
</div>
<div class="flex-item">
<div class="flex-item" :style="imgStyle">
<img :src="res.list[1].img" alt />
</div>
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
import { cubeWrapperStyle, cubeImgStyle, cubeGapStyle } from "./cubeStyle";
defineOptions({ title: "两张横图" });
defineProps(["res"]);
const props = defineProps(["res"]);
const wrapperStyle = computed(() => cubeWrapperStyle(props.res));
const imgStyle = computed(() => cubeImgStyle(props.res));
const gapStyle = computed(() => cubeGapStyle(props.res));
</script>
<style lang="scss" scoped>
@import "./tpl.scss";
@@ -23,10 +30,11 @@ defineProps(["res"]);
overflow: hidden;
}
.flex-item {
width: 50%;
flex: 1;
min-width: 0;
> img {
display: block;
max-width: 100%;
width: 100%;
height: 100%;
}
}

View File

@@ -1,97 +1,484 @@
<template>
<div v-if="goodsBlock" class="layout">
<div class="goods-cell-title">
<div
class="goods-item-title"
:class="{ selected: selected.index == index }"
@click="handleClickTitle(title, index)"
v-for="(title, index) in goodsBlock.titleWay"
:key="index"
>
<h4>{{ title.title }}</h4>
<div>{{ title.desc }}</div>
</div>
</div>
<div class="goods-list">
<template v-for="(item, item_index) in goodsBlock.listWay" :key="item_index">
<div v-if="isGoodsVisible(item)" class="goods-item">
<div class="goods-img">
<el-icon
:size="20"
color="#e1251b"
@click="closeGoods(item, item_index)"
class="goods-icon"
>
<CircleClose />
</el-icon>
<img :src="item.img" alt />
<div class="tpl-goods" :class="{ 'legacy-mode': !isNew }" :style="wrapperStyle">
<!-- 新版商品列表 -->
<template v-if="isNew">
<div v-if="displayGoods.length" class="g-list" :class="'mode-' + layout" :style="listStyle">
<div class="g-item" v-for="(g, i) in displayGoods" :key="i" :style="itemStyle">
<div class="g-imgbox">
<img :src="g.img" alt="" />
</div>
<div class="goods-desc">
<div class="goods-title">
{{ item.title }}
<div class="g-info">
<div v-if="showName" class="g-name" :style="nameStyle">{{ g.name }}</div>
<div v-if="showPrice" class="g-price" :style="priceStyle">
¥{{ $filters.unitPrice(g.price) }}
</div>
<div class="goods-bottom">
<div class="goods-price">¥{{ $filters.unitPrice(item.price) }}</div>
<div class="g-bottom">
<span
v-if="showSales && g.sales != null"
class="g-sales"
:style="salesStyle"
>已售 {{ g.sales }} 件</span
>
<span
v-if="cartShow"
class="g-cart"
:class="'cart-' + cartStyle"
:style="cartStyleObj"
>
<template v-if="cartStyle === 'text'">加购</template>
<template v-else-if="cartStyle === 'plus'">+</template>
<svg v-else viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
<path
d="M7 4h-2l-1 2h2l2.4 9.6a2 2 0 0 0 1.9 1.4h7.9a2 2 0 0 0 1.9-1.5l1.6-6.5h-12l-.8-3h11.5l.5-2h-13.4zM10 20a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm8 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z"
/>
</svg>
</span>
</div>
</div>
</div>
</template>
</div>
</div>
<div v-else class="g-empty">暂无商品</div>
</template>
<!-- 旧版:分类标签 + 绑定商品 -->
<template v-else-if="goodsBlock">
<div class="goods-cell-title">
<div
class="goods-item-title"
:class="{ selected: selected.index == index }"
@click="handleClickTitle(title, index)"
v-for="(title, index) in goodsBlock.titleWay"
:key="index"
>
<h4>{{ title.title }}</h4>
<div>{{ title.desc }}</div>
</div>
</div>
<div class="goods-list">
<template v-for="(item, item_index) in goodsBlock.listWay" :key="item_index">
<div v-if="isGoodsVisible(item)" class="goods-item">
<div class="goods-img">
<img :src="item.img" alt />
</div>
<div class="goods-desc">
<div class="goods-title">
{{ item.title }}
</div>
<div class="goods-bottom">
<div class="goods-price">¥{{ $filters.unitPrice(item.price) }}</div>
</div>
</div>
</div>
</template>
</div>
</template>
</div>
</template>
<script setup>
import { computed, reactive, watch } from "vue";
import { CircleClose } from "@element-plus/icons-vue";
const props = defineProps(["res"]);
<script>
import { getGoodsListData } from "@/api/goods";
const selected = reactive({
index: 0,
val: "",
});
const goodsBlock = computed(() => props.res?.list?.[0] || null);
watch(
goodsBlock,
(val) => {
if (!val) {
return;
}
const firstGoods = (val.listWay || []).find(Boolean);
if (firstGoods) {
selected.val = firstGoods.type;
} else if (val.titleWay?.[0]?.title) {
selected.val = val.titleWay[0].title;
export default {
name: "tpl_goods",
props: {
res: { type: Object, default: () => ({}) },
},
data() {
return {
selected: { index: 0, val: "" },
goodsData: [],
};
},
computed: {
first() {
return (this.res.list && this.res.list[0]) || {};
},
// 新版标记:面板写入过 layout 字段
isNew() {
return this.first.layout !== undefined;
},
layout() {
return this.first.layout || "list";
},
showName() {
return this.first.showName !== false;
},
showPrice() {
return this.first.showPrice !== false;
},
showSales() {
return this.first.showSales !== false;
},
cartShow() {
return this.first.cartShow !== false;
},
cartStyle() {
return this.first.cartStyle || "plus";
},
themePrice() {
return "#FF5C58";
},
priceColor() {
return this.res.toneMode === "custom" && this.res.priceColor
? this.res.priceColor
: this.themePrice;
},
priceStyle() {
return { color: this.priceColor };
},
nameStyle() {
return this.res.titleColor ? { color: this.res.titleColor } : {};
},
salesStyle() {
return this.res.salesColor ? { color: this.res.salesColor } : {};
},
cartStyleObj() {
return { background: this.priceColor };
},
px() {
return (v) => `${(Number(v) || 0) / 2}px`;
},
wrapperStyle() {
const res = this.res;
if (!this.isNew) {
return {};
}
const bg =
!res.bgColor2 || res.bgColor1 === res.bgColor2
? res.bgColor1
: `linear-gradient(180deg, ${res.bgColor1}, ${res.bgColor2})`;
return {
background: bg,
padding: `${this.px(res.paddingTop)} ${this.px(res.paddingX)} ${this.px(res.paddingBottom)}`,
marginBottom: this.px(res.pageGap),
borderRadius: this.px(res.borderRadius),
overflow: Number(res.borderRadius) > 0 ? "hidden" : "visible",
};
},
listStyle() {
const gap = this.px(this.res.contentGap ?? 10);
return { gap, "--g-gap": gap };
},
itemStyle() {
const r = this.px(this.res.contentRadius);
return { borderRadius: r, overflow: Number(this.res.contentRadius) > 0 ? "hidden" : "visible" };
},
displayGoods() {
if (this.first.dataMode === "goods") {
return (this.first.goodsList || []).map((g) => ({
name: g.goodsName,
price: g.price,
img: g.thumbnail,
sales: g.buyCount != null ? g.buyCount : null,
}));
}
return this.goodsData;
},
goodsBlock() {
return this.res && this.res.list && this.res.list[0] ? this.res.list[0] : null;
},
},
watch: {
"first.dataMode": "loadGoods",
"first.categoryId": "loadGoods",
"first.brandId": "loadGoods",
"first.storeId": "loadGoods",
"first.count": "loadGoods",
"first.sortType": "loadGoods",
},
created() {
if (this.isNew) {
this.loadGoods();
} else {
const block = this.goodsBlock;
if (block) {
const firstGoods = (block.listWay || []).find(Boolean);
if (firstGoods) {
this.selected.val = firstGoods.type;
} else if (block.titleWay && block.titleWay[0]) {
this.selected.val = block.titleWay[0].title;
}
}
}
},
{ immediate: true }
);
function isGoodsVisible(item) {
if (!item) {
return false;
}
if (item.___index != undefined) {
return selected.index == item.___index;
}
return selected.val == item.type;
}
function closeGoods(val, index) {
goodsBlock.value.listWay.splice(index, 1);
}
function handleClickTitle(val, index) {
selected.index = index;
selected.val = val.title;
}
methods: {
loadGoods() {
const item = this.first;
if (!this.isNew || item.dataMode === "goods") {
return;
}
const params = {
pageNumber: 1,
pageSize: item.count || 6,
goodsStatus: "UPPER",
};
if (item.dataMode === "category" && item.categoryId) {
// category_path 以逗号包裹,前后补逗号避免误匹配
params.categoryPath = `,${item.categoryId},`;
}
if (item.dataMode === "brand" && item.brandId) {
params.brandId = item.brandId;
}
if (item.dataMode === "store" && item.storeId) {
params.storeId = item.storeId;
}
if (item.sortType === "sales") {
params.sort = "buyCount";
params.order = "desc";
} else if (item.sortType === "price") {
params.sort = "price";
params.order = "asc";
}
getGoodsListData(params)
.then((res) => {
const records = (res && res.result && res.result.records) || [];
this.goodsData = records.map((g) => ({
name: g.goodsName,
price: g.price,
img: g.thumbnail || g.small || g.original,
sales: g.buyCount != null ? g.buyCount : null,
}));
})
.catch(() => {
this.goodsData = [];
});
},
isGoodsVisible(item) {
if (!item) {
return false;
}
if (item.___index != undefined) {
return this.selected.index == item.___index;
}
return this.selected.val == item.type;
},
handleClickTitle(val, index) {
this.selected.index = index;
this.selected.val = val.title;
},
},
};
</script>
<style lang="scss" scoped>
.layout {
padding: 8px 0;
background: #e8e8e8;
.tpl-goods {
background: #fff;
}
.legacy-mode {
background: #e8e8e8;
padding: 8px 0;
}
.g-empty {
padding: 30px 0;
text-align: center;
color: #999;
font-size: 12px;
}
.g-list {
display: flex;
flex-wrap: wrap;
}
.g-item {
background: #fff;
display: flex;
box-sizing: border-box;
}
.g-imgbox {
flex-shrink: 0;
background: #f5f5f5;
img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
}
.g-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.g-name {
font-size: 13px;
color: #282828;
line-height: 1.4;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.g-bottom {
display: flex;
align-items: center;
margin-top: auto;
}
.g-price {
color: $theme_color;
font-size: 15px;
font-weight: 600;
margin-bottom: 2px;
}
.g-sales {
flex: 1;
min-width: 0;
font-size: 11px;
color: #666;
}
.g-cart {
flex-shrink: 0;
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
&.cart-plus,
&.cart-cart {
width: 24px;
height: 24px;
border-radius: 50%;
font-size: 15px;
line-height: 1;
}
&.cart-text {
border-radius: 12px;
padding: 3px 10px;
font-size: 12px;
}
}
/* 卡片:左图右文(参考 CRMEB 详细列表,左侧 112x112 缩略图,右侧标题置顶、价格销量置底) */
.mode-card {
flex-direction: column;
.g-item {
flex-direction: row;
align-items: stretch;
padding: 10px;
}
.g-imgbox {
width: 112px;
height: 112px;
margin-right: 12px;
border-radius: 6px;
overflow: hidden;
}
.g-info {
min-height: 112px;
justify-content: space-between;
}
.g-name {
font-size: 14px;
font-weight: 500;
line-height: 20px;
margin-bottom: 6px;
}
.g-price {
font-size: 16px;
font-weight: 700;
margin-top: auto;
margin-bottom: 4px;
}
.g-bottom {
margin-top: 0;
}
.g-sales {
font-size: 12px;
}
}
/* 列表:两列网格 */
.mode-list {
.g-item {
width: calc((100% - var(--g-gap, 5px)) / 2);
flex-direction: column;
}
.g-imgbox {
width: 100%;
aspect-ratio: 1;
}
.g-info {
padding: 8px 10px 10px;
min-height: 64px;
}
.g-name {
margin-bottom: 6px;
}
}
/* 三列 */
.mode-three {
.g-item {
width: calc((100% - var(--g-gap, 5px) * 2) / 3);
flex-direction: column;
}
.g-imgbox {
width: 100%;
aspect-ratio: 1;
}
.g-info {
padding: 6px;
min-height: 48px;
}
.g-name {
-webkit-line-clamp: 1;
font-size: 11px;
margin-bottom: 2px;
}
.g-price {
font-size: 12px;
}
.g-sales {
font-size: 10px;
}
.g-cart {
&.cart-plus,
&.cart-cart {
width: 20px;
height: 20px;
font-size: 13px;
}
}
}
/* 大图:一行一个 */
.mode-big {
flex-direction: column;
.g-item {
width: 100%;
flex-direction: column;
}
.g-imgbox {
width: 100%;
aspect-ratio: 3 / 2;
}
.g-info {
padding: 10px 12px 12px;
min-height: 64px;
}
.g-name {
font-size: 14px;
font-weight: 500;
margin-bottom: 6px;
}
.g-price {
font-size: 16px;
font-weight: 700;
margin-bottom: 4px;
}
}
/* 旧版样式 */
.selected {
> h4 {
color: #000 !important;
@@ -105,7 +492,6 @@ function handleClickTitle(val, index) {
padding: 10px;
transition: 0.35s;
display: flex;
// cursor: pointer;
> .goods-item-title {
flex: 1;
text-align: center;
@@ -156,9 +542,7 @@ function handleClickTitle(val, index) {
display: -webkit-box;
font-weight: 500;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
> .goods-bottom {
@@ -169,9 +553,4 @@ function handleClickTitle(val, index) {
}
}
}
.goods-icon {
right: 5px;
top: 5px;
position: absolute;
}
</style>

View File

@@ -0,0 +1,564 @@
<template>
<div class="tpl-home-comb" :style="wrapperStyle">
<!-- 高斯模糊轮播图背景 + 底部渐变遮罩 -->
<div class="comb-bg">
<img v-if="currentBannerImg" :src="currentBannerImg" alt="" />
<div class="comb-mask"></div>
</div>
<!-- 1. 搜索栏(可选左侧 Logo + 搜索框 + 小程序胶囊) -->
<div class="comb-search-row">
<img
v-if="res.searType === 'logo' && res.logoUrl"
class="comb-logo"
:src="res.logoUrl"
alt="logo"
/>
<div class="comb-search-box" :style="searchBoxStyle">
<el-icon class="search-ico"><Search /></el-icon>
<span class="search-text">{{ displayHotWord }}</span>
</div>
<div class="capsule">
<span class="cap-icon">
<svg width="18" height="5" viewBox="0 0 18 5" fill="currentColor">
<circle cx="2.5" cy="2.5" r="2" />
<circle cx="9" cy="2.5" r="2" />
<circle cx="15.5" cy="2.5" r="2" />
</svg>
</span>
<span class="cap-divider"></span>
<span class="cap-icon">
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
<circle cx="7.5" cy="7.5" r="6" stroke="currentColor" stroke-width="1.6" />
<circle cx="7.5" cy="7.5" r="2.2" fill="currentColor" />
</svg>
</span>
</div>
</div>
<!-- 2. 分类选项卡 -->
<div v-if="res.showTabs !== false" class="comb-tabs">
<div class="comb-tabs-scroll">
<div
v-for="(tab, idx) in displayTabs"
:key="idx"
class="comb-tab-item"
:class="{ active: activeTabIndex === idx }"
:style="{
color:
activeTabIndex === idx
? res.tabActiveColor || '#E93323'
: res.tabColor || '#FFFFFF',
}"
@click.stop="activeTabIndex = idx"
>
{{ tab.title || "未命名" }}
<span
v-if="activeTabIndex === idx"
class="tab-line"
:style="{ background: res.tabActiveColor || '#E93323' }"
></span>
</div>
</div>
<div class="comb-tab-more" :style="{ color: res.tabColor || '#FFFFFF' }">
<el-icon><ArrowDown /></el-icon>
</div>
</div>
<!-- 3. 轮播图区域(样式1:卡片露边 / 样式2:通栏) -->
<div class="comb-banner" :class="{ 'no-tabs': res.showTabs === false, 'is-card': cardMode }">
<!-- 样式2:通栏轮播 -->
<el-carousel
v-if="!cardMode && bannerList.length"
class="comb-carousel"
autoplay
:interval="5000"
height="146px"
indicator-position="none"
@change="onSlideChange"
>
<el-carousel-item v-for="(item, index) in bannerList" :key="index">
<div class="slide-wrap">
<img
v-if="item.img"
class="slide-img"
:style="bannerImgStyle"
:src="item.img"
alt=""
/>
<div v-else class="slide-empty" :style="bannerImgStyle">750*332</div>
</div>
</el-carousel-item>
</el-carousel>
<!-- 样式1:卡片风格(两侧露出前后图片) -->
<div
v-else-if="cardMode && bannerList.length"
ref="cardPreview"
class="card-preview"
@mouseenter="stopBannerTimer"
@mouseleave="startBannerTimer"
>
<div
class="card-track"
:style="{ transform: `translateX(${trackOffset}px)` }"
>
<div
v-for="(item, index) in cardSlides"
:key="index"
class="card-slide"
:class="{ active: index === activeTrackIndex }"
>
<img
v-if="item.img"
class="card-img"
:style="bannerImgStyle"
:src="item.img"
alt=""
/>
<div v-else class="slide-empty" :style="bannerImgStyle">750*332</div>
</div>
</div>
</div>
<div v-else class="slide-empty" :style="bannerImgStyle">请添加轮播图</div>
<!-- 自定义指示器 -->
<div
v-if="res.indicatorStyle !== 'none' && bannerList.length > 1"
class="comb-indicator"
:class="'pos-' + (res.indicatorPosition || 'center')"
>
<div
v-for="(item, i) in bannerList"
:key="i"
class="dot"
:class="[res.indicatorStyle || 'round', { active: i === currentSlide }]"
:style="
i === currentSlide
? { background: res.indicatorColor || '#E93323' }
: {}
"
></div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
import { Search, ArrowDown } from "@element-plus/icons-vue";
defineOptions({ title: "头部组件" });
const props = defineProps(["res"]);
const px = (v) => `${(Number(v) || 0) / 2}px`;
// 搜索热词轮换
const hotIndex = ref(0);
let hotTimer = null;
const validHotWords = computed(() =>
(props.res?.hotWords || []).filter((w) => w && String(w).trim())
);
const displayHotWord = computed(() => {
if (validHotWords.value.length) {
return validHotWords.value[hotIndex.value % validHotWords.value.length];
}
return props.res?.placeWords || "搜索商品名称";
});
function stopHotTimer() {
if (hotTimer) {
clearInterval(hotTimer);
hotTimer = null;
}
}
watch(
() => [validHotWords.value.length, props.res?.hotInterval],
() => {
stopHotTimer();
hotIndex.value = 0;
if (validHotWords.value.length > 1) {
hotTimer = setInterval(() => {
hotIndex.value = (hotIndex.value + 1) % validHotWords.value.length;
}, (Number(props.res?.hotInterval) || 3) * 1000);
}
},
{ immediate: true }
);
// 选项卡
const activeTabIndex = ref(0);
const displayTabs = computed(() => {
const custom = Array.isArray(props.res?.tabList) ? props.res.tabList : [];
return [{ title: "首页", isHome: true }, ...custom];
});
// 轮播图
const currentSlide = ref(0);
const previewWidth = ref(375);
const cardPreview = ref(null);
let bannerTimer = null;
const bannerList = computed(() =>
Array.isArray(props.res?.list) ? props.res.list : []
);
const cardMode = computed(() => (props.res?.swiperStyle || "card") === "card");
const currentBannerImg = computed(() => {
const list = bannerList.value;
if (!list.length) return "";
const item = list[currentSlide.value % list.length] || list[0];
return item?.img || "";
});
const cardSlides = computed(() => {
const list = bannerList.value;
if (list.length <= 1) return list;
return [list[list.length - 1], ...list, list[0]];
});
const activeTrackIndex = computed(() =>
bannerList.value.length > 1 ? currentSlide.value + 1 : currentSlide.value
);
const trackOffset = computed(
() =>
previewWidth.value * 0.06 -
activeTrackIndex.value * previewWidth.value * 0.88
);
function measureCard() {
if (cardPreview.value) {
previewWidth.value = cardPreview.value.offsetWidth || 375;
}
}
function startBannerTimer() {
stopBannerTimer();
if (!cardMode.value || bannerList.value.length < 2) return;
bannerTimer = setInterval(() => {
currentSlide.value = (currentSlide.value + 1) % bannerList.value.length;
}, 4000);
}
function stopBannerTimer() {
if (bannerTimer) {
clearInterval(bannerTimer);
bannerTimer = null;
}
}
function onSlideChange(idx) {
currentSlide.value = idx;
}
watch(
() => [cardMode.value, bannerList.value.length],
() => {
if (currentSlide.value >= bannerList.value.length) {
currentSlide.value = 0;
}
stopBannerTimer();
if (cardMode.value) {
measureCard();
startBannerTimer();
}
},
{ immediate: true, flush: "post" }
);
onMounted(() => {
measureCard();
startBannerTimer();
});
onBeforeUnmount(() => {
stopHotTimer();
stopBannerTimer();
});
// 样式计算
const wrapperStyle = computed(() => ({
marginBottom: px(props.res?.pageGap ?? 0),
}));
const searchBoxStyle = computed(() => {
const r = props.res || {};
const align = r.textAlign || "left";
return {
background: r.boxColor || "#FFFFFF",
color: r.textColor || "#303133",
borderRadius: px(r.searchRadius ?? 30),
justifyContent:
align === "center"
? "center"
: align === "right"
? "flex-end"
: "flex-start",
};
});
const bannerImgStyle = computed(() => ({
borderRadius: px(props.res?.bannerRadius ?? 14),
}));
</script>
<style scoped lang="scss">
.tpl-home-comb {
position: relative;
overflow: hidden;
padding: 8px 10px 10px;
box-sizing: border-box;
}
.comb-bg {
position: absolute;
inset: 0;
z-index: 0;
overflow: hidden;
background: linear-gradient(180deg, #e93323 0%, #f5f5f5 100%);
img {
width: 100%;
height: 100%;
object-fit: cover;
transform: scale(1.25);
filter: blur(22px);
display: block;
}
.comb-mask {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 90px;
background: linear-gradient(180deg, rgba(245, 245, 245, 0) 0%, #f5f5f5 100%);
}
}
.comb-search-row {
position: relative;
z-index: 2;
display: flex;
align-items: center;
gap: 8px;
}
.comb-logo {
width: 69px;
height: 24px;
object-fit: contain;
flex-shrink: 0;
}
.comb-search-box {
flex: 1;
min-width: 0;
height: 30px;
padding: 0 10px;
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
box-sizing: border-box;
overflow: hidden;
.search-ico {
font-size: 13px;
flex-shrink: 0;
}
.search-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.capsule {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
height: 30px;
padding: 0 10px;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 999px;
background: rgba(255, 255, 255, 0.6);
box-sizing: border-box;
color: #303133;
.cap-icon {
display: flex;
align-items: center;
justify-content: center;
svg {
display: block;
}
}
.cap-divider {
width: 1px;
height: 14px;
background: rgba(0, 0, 0, 0.12);
margin: 0 8px;
}
}
.comb-tabs {
position: relative;
z-index: 2;
display: flex;
align-items: center;
height: 36px;
margin-top: 4px;
}
.comb-tabs-scroll {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 16px;
overflow-x: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.comb-tab-item {
position: relative;
flex-shrink: 0;
font-size: 13px;
line-height: 34px;
cursor: pointer;
white-space: nowrap;
&.active {
font-size: 14px;
font-weight: 600;
}
.tab-line {
position: absolute;
left: 50%;
bottom: 2px;
transform: translateX(-50%);
width: 18px;
height: 2px;
border-radius: 2px;
}
}
.comb-tab-more {
flex-shrink: 0;
padding-left: 8px;
display: flex;
align-items: center;
font-size: 14px;
}
.comb-banner {
position: relative;
z-index: 2;
margin-top: 6px;
height: 146px;
overflow: hidden;
&.is-card {
margin-left: -10px;
margin-right: -10px;
}
&.no-tabs {
margin-top: 10px;
}
}
.comb-carousel {
height: 146px;
}
.slide-wrap {
width: 100%;
height: 146px;
}
.slide-img {
width: 100%;
height: 146px;
object-fit: fill;
display: block;
}
.slide-empty {
width: 100%;
height: 146px;
background: rgba(255, 255, 255, 0.75);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #909399;
}
.card-preview {
width: 100%;
height: 146px;
overflow: hidden;
display: flex;
align-items: center;
}
.card-track {
display: flex;
align-items: center;
width: 100%;
transition: transform 0.45s ease;
}
.card-slide {
flex: 0 0 88%;
padding: 0 3px;
box-sizing: border-box;
transform: scale(0.92);
transition: transform 0.35s ease;
&.active {
transform: scale(1);
}
}
.card-img {
width: 100%;
height: 146px;
object-fit: fill;
display: block;
}
.comb-indicator {
position: absolute;
bottom: 8px;
display: flex;
align-items: center;
z-index: 3;
&.pos-left {
left: 16px;
}
&.pos-center {
left: 50%;
transform: translateX(-50%);
}
&.pos-right {
right: 16px;
}
.dot {
background: rgba(255, 255, 255, 0.65);
transition: all 0.25s ease;
&.round {
width: 6px;
height: 6px;
border-radius: 50%;
margin: 0 3px;
}
&.line {
width: 12px;
height: 3px;
border-radius: 2px;
margin: 0 2px;
}
}
}
</style>

View File

@@ -0,0 +1,29 @@
<template>
<div class="layout">
<div class="hot-area">
<!-- <img :src="res.list[0].img" alt="" /> -->
<hotzone @change="changeHotzone" :zonesInit="res.list[0].zoneInfo" :image="res.list[0].img"></hotzone>
</div>
</div>
</template>
<script setup>
import hotzone from "@/views/shop/hotzone";
const props = defineProps(["res"]);
function changeHotzone(info) {
props.res.list[0].zoneInfo = info;
}
</script>
<style lang="scss" scoped>
@import "./tpl.scss";
.hot-area {
width: 100%;
display: block;
overflow: hidden;
> img {
width: 300px;
height: 500px;
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More