489 Commits
v4.3 ... v5.0.2

Author SHA1 Message Date
pikachu1995@126.com
2563f122fc feat(manager): 优化定时任务管理、敏感词场景配置与商品选择器多规格匹配 2026-10-29 17:06:21 +08:00
pikachu1995@126.com
8739811b04 feat(pc-decorate): PC端楼层装修组件体系规范默认行高与各楼层组件重构 2026-10-29 17:06:08 +08:00
pikachu1995@126.com
5973d43b9d feat(buyer): 统一全站页面顶部为现代TopBar与SearchHeader,支持商品/店铺双模搜索与占位词规范 2026-10-29 17:05:53 +08:00
pikachu1995@126.com
d113f5c553 feat(buyer): 新增PC端店铺列表页面并优化搜索与商品缩略图 2026-10-29 17:00:03 +08:00
pikachu1995@126.com
5c7b109be6 fix(manager): 修复 walletLog 和 recharge 页面缺少 onMounted 导入的问题 2026-10-29 14:17:54 +08:00
pikachu1995@126.com
97e611c072 refactor(im): 重构聊天面板UI组件并修复多处交互问题
- 将头像组件从 dicebear 外链改为本地首字+底色生成,新增 avatarColor 工具函数
- 重构订单卡片(footPrint.vue)布局,优化商品与订单信息展示结构
- 重构店铺详情卡片(storeDetail.vue),统一信息行与评分行样式
- 移除消息气泡中的昵称时间行,调整气泡背景色与头像内边距
- 修复 face 变量名与全局组件 <face> 冲突导致头像URL被当作标签渲染
- 将 unload 事件监听替换为 pagehide,兼容 Chrome 弃用策略
- 修复搜索关键词未转义正则元字符导致输入特殊字符时抛异常
- 修复 WebSocket onMessage 对无 text 字段的推送消息误调用 JSON.parse
- 将 setup 中同步调用全局 mixin 方法移至 onMounted,避免代理未就绪
- 商品/订单图片统一添加 onerror 默认图兜底
- 聊天列表消息预览支持 emoji 图片渲染(textReplaceEmoji)
- 搜索框增加 clearable 属性及"没有找到相关好友"空态提示
- 锁定 element-plus 版本为 2.14.1 防止自动升级引入兼容问题
- 日期格式化分隔符由斜杠改为横杠
- 提现设置页新增提现规则说明字段,满足微信审核公示要求
2026-10-29 10:56:28 +08:00
pikachu1995@126.com
68dfae9a62 refactor(indexDecorate): 将静态资源迁移至OSS并优化轮播组件样式
- 将所有组件中的静态图片路径从本地 /static 迁移至 OSS 远程地址
- 更新 ChannelFloor、BrandWall 等组件的默认图片资源路径
- 更新 page-decoration/pc/config.js 中所有默认配置的图片路径
- 优化 Carousel 组件:fitMode 默认改为 contain,添加圆角与阴影样式
- Carousel 新增 banners 字段兼容映射,支持多种数据格式
- Carousel 指示器颜色改为深色,布局改为 flex 居中展示
2026-10-29 09:36:01 +08:00
pikachu1995@126.com
bb8e9a0d3d feat(buyer,manager): 楼层装修组件体系与平台端页面优化
- buyer:楼层装修组件(RowWrapper、组件库、预设、注册表)及首页/商品列表适配
- manager:PC端楼层装修页面、店铺设置、OSS素材管理等页面样式优化
2026-10-28 17:41:03 +08:00
pikachu1995@126.com
f4fd1c1acd feat(seller): 营销页面体验优化与列表页样式统一
- 拼团:去除拼团规则字段、编辑页商品表补SKU编码/规格/图片、提交按钮失败卡死修复
- 秒杀:活动详情改描述列表、场次商品数徽标、当前场次提示、页签标题动态显示活动名称
- 满额:卡片分区布局、查看模式只读描述视图、去除活动描述、新建默认近七天时间
- 优惠券:查看模式只读描述视图、去除店铺承担比例与范围描述、指定商品表加SKU/规格/图片
- 商品选择器:首列改SKU编码、弹层z-index修复
- 分销订单:商品信息列重构、卡片间距、状态筛选Tab
- 库存管理:筛选与列表分区、行高压缩、SPU/SKU编码列
- 全局:所有列表操作列统一右对齐
2026-10-28 17:40:50 +08:00
pikachu1995@126.com
21abd26b1f feat(buyer): 商品视频内嵌相册展示(天猫风格)
- 视频作为相册第一项,进页静音自动循环播放,控制条可开声音
- 竖视频两侧黑边改为封面高斯模糊填充(覆盖 DPlayer 运行时注入的黑底)
- 缩略图显示视频第一帧,居中透明播放图标
- 悬停切换相册时自动暂停/续播,播放器容器常驻避免实例丢失
- 移除购买区观看视频按钮与弹窗
- isCollection 收藏状态查询补充 catch,避免未捕获报错
2026-10-28 15:09:52 +08:00
pikachu1995@126.com
a55c8356c8 fix(buyer): 修复商品详情页未登录时调用评价接口导致的登录弹窗与未捕获报错
- goodsComment/goodsCommentNum 改为 needToken,未登录时不再发起请求
- 评价/收藏状态等后台查询增加 skipAuthHandler+skipMessage,token 失效时静默失败
- ShowGoodsDetail 评价接口调用补充 catch 并增加登录守卫
2026-10-28 15:09:28 +08:00
pikachu1995@126.com
b025ab804e feat(seller): 评价管理列表增强(回复状态/有无图片筛选、评价分级 Tab、会员头像列)及多页面样式优化(商品列表、部门管理、OSS 素材、运费模板、页头页脚等) 2026-10-27 16:05:36 +08:00
pikachu1995@126.com
700c709afd style(after-sale): 重构售后详情页为左主右辅布局(顶部摘要条、售后进度时间线、退款信息卡、订单编号跳转) 2026-10-27 16:01:44 +08:00
pikachu1995@126.com
8d0788e09b style(order): 优化订单详情页样式(按钮分组右置、分组标题、金额汇总负号红字、优惠明细直出) 2026-10-27 16:01:44 +08:00
pikachu1995@126.com
55a431ddd5 fix(order): 区分订单操作按钮颜色、待支付不显示退款标签、列表移除售后/投诉入口、修复详情页商品列表被全局样式压缩 2026-10-26 17:01:44 +08:00
pikachu1995@126.com
b72c792ade style(member): 统一会员中心页面风格(卡片面板/状态标签/链接按钮/右对齐分页) 2026-10-26 17:01:30 +08:00
pikachu1995@126.com
1f872254cc style(member): 优化申诉详情、会员等级、优惠券、礼品卡页面样式 2026-10-26 17:01:08 +08:00
pikachu1995@126.com
99e7f6ed47 feat(home): 丰富PC买家中心主页,增加订单快捷入口与资产卡片 2026-10-26 17:00:57 +08:00
pikachu1995@126.com
06e02998aa feat(shopEntry): 优化店铺入驻页面视觉与流程
- 新增品牌头部与自定义步骤条,已完成步骤支持点击回跳修改
- 审核状态页改用 el-result 图标结果页(审核中/已通过/已拒绝/已关闭)
- 分区标题改为主题色竖线样式,表单加宽,地址选择改为输入框样式
- 上传组件改为虚线卡片,补充上传失败与业务失败的错误提示
- 第2、3步补齐 sessionStorage 草稿保存,表单校验失败自动滚动到错误项
- 地址选择弹窗去除横向滚动,超长名称省略并支持悬停显示
- 注册 ElResult 组件;公司电话/营业执照号/法人证件号放宽为仅必填
2026-10-26 16:47:35 +08:00
pikachu1995@126.com
1395f2ad24 refactor(goods): 重构分类页面装修,使用图片预览替代CSS示意图
- 将"货号"统一重命名为"SKU编码"
- 重构分类页面装修,使用图片预览替代CSS示意图
- 修复商家端主容器横向溢出隐藏问题
2026-10-22 16:37:54 +08:00
pikachu1995@126.com
67e8c86b5b feat: 新增页面装修-分类设置页(模板选择+商品列表字段开关) 2026-10-22 09:15:44 +08:00
pikachu1995@126.com
9fcd325df9 fix(goods): 修复商品发布校验失败后保存按钮一直处于loading的问题 2026-10-22 08:52:16 +08:00
pikachu1995@126.com
f21ce6e252 refactor(decoration): 重构店铺装修组件配置并规范化环境变量
- 重构装修组件配置,新增 icon 与 group 分类字段
- 将组件"图片"重命名为"热区","图片轮播"重命名为"轮播图"
- 新增搜索、公告、视频组件配置项
- 统一为各图片项添加 model 字段标识跳转模式
- 将装修页面组件间 pageGap 间距由 10 调整为 0
- 移除 im 模块中 VUE_APP_COMMON 变量,统一使用 VUE_APP_BUYER
- 为 im 环境变量与配置文件添加详细注释说明
2026-10-21 15:17:05 +08:00
pikachu1995@126.com
da95f17885 docs: 更新项目 README 与开源许可说明 2026-10-20 19:07:47 +08:00
pikachu1995@126.com
0f9593d5ed feat(page-decoration): 新增移动端头部综合组件并重构各楼层模板尺寸与排版 2026-10-20 19:07:43 +08:00
pikachu1995@126.com
088a2bb308 feat(oss): 优化素材库上传格式限制及组件模式下的 Tab 显示 2026-10-20 19:07:35 +08:00
pikachu1995@126.com
d29ad9c99b refactor(page-decoration): 重构装修页面组件与交互方式
- 统一 liliDialog ref 命名为 liliDialogRef,避免与组件 import 冲突
- 商品选择弹窗改为 el-table 表格形式展示
- 链接选择器从 el-dialog 改为 el-drawer 抽屉形式
- 为所有 img 标签添加 alt 属性提升可访问性
- 新增拼团、直播、秒杀、砍价、积分商城、优惠券等营销组件默认配置
- 删除商品分类组件,新增商品列表组件配置
- i18n 配置从 legacy 模式切换为 composition 模式
- 注册 PanelSearch、PanelNotice、PanelVideo 等新面板组件
- 优化画布预览区域样式并添加页面设置入口
2026-10-20 16:13:28 +08:00
pikachu1995@126.com
d87e0c5f6b fix(seller): 修正 serviceStaffList 中 getUserListData 的导入路径为 @/api/index 2026-10-20 15:48:39 +08:00
pikachu1995@126.com
5e74e356e0 refactor(manager): 重构商品/审核/品牌/分词/分销提现与等级等7个核心列表页为 useTable
- 重构商品管理(商品总览、商品审核)列表页为 useTable

- 重构商品维护(品牌管理、评价管理)列表页为 useTable

- 重构分销管理(分销提现、等级变动历史)与自定义分词列表页为 useTable

- 消除 150+ 行重复样板代码,统一竞态防护与分页查询规范
2026-10-20 14:56:06 +08:00
pikachu1995@126.com
cba624f480 refactor(seller): 重构预约服务、分销与商品库存等8个列表页为 useTable
- 重构预约服务模块(服务人员、系统表单、服务工单)列表页为 useTable

- 重构分销管理模块(分销商品、分销订单)列表页为 useTable

- 重构商品与库存模块(库存导出导入、库存预警管理、商品草稿模版)列表页为 useTable

- 消除 180+ 行模板重复代码,规范分页、防抖与请求竞态防护
2026-10-20 14:28:29 +08:00
pikachu1995@126.com
ba964ff478 refactor(manager): 重构统计报表、促销活动与工单审核等12个列表页为 useTable
- 重构统计报表(销售订单明细、商品销售汇总、店铺业绩)列表页为 useTable

- 重构营销活动(秒杀、第N件优惠、积分商品)列表页为 useTable

- 重构财务流水(钱包日志)、服务工单、分销导入、店铺审核、商品参数、订单投诉列表页

- 消除约 210+ 行分页/防抖/竞态重复样板代码,统一加载与查询状态规范
2026-10-20 14:20:36 +08:00
pikachu1995@126.com
45338c9d70 style(seller): 重构订单统计页转化漏斗与指标卡样式 2026-10-20 14:15:07 +08:00
pikachu1995@126.com
6b6dc2c4f3 refactor(manager): 引入 useTable 并重构采购/财务/会员等10个列表页
- 引入通用 useTable composable 实现分页与查询标准化
- 重构采购单/入库单/报损单/盘点单4个采购管理列表页
- 重构退款日志/预存款流水/会员充值/会员经验等财务与会员日志列表页
- 重构支付流水与退款流水列表页
- 统一竞态防护与 loading 状态管理,消除 150+ 行冗余样板代码
2026-10-20 14:02:03 +08:00
pikachu1995@126.com
8fd353a766 refactor(manager): 优化全局空状态、错误图片占位并升级遗留消息语法
- 增加表格与树组件全局空状态 (el-empty) 样式规范
- 补齐视图图片语义化 alt,并添加全局图片加载异常 SVG 占位兜底
- 将砍价/秒杀/自定义面板中的 $Message 兼容语法替换为 ElMessage
- 清理冗余依赖 vuex,将 sass-loader 调整至 devDependencies
2026-10-20 13:51:21 +08:00
pikachu1995@126.com
3cb60139d5 refactor(seller): 列表页抽离 useTable 重构与通用 UI 规范优化
- 抽离通用 useTable composable,并迁移 13 个典型列表页(采购/报损/订单/售后/统计/营销等)
- 增加表格与树组件全局空状态 (el-empty) 样式规范
- 补齐视图图片语义化 alt,并添加全局图片加载异常 SVG 占位兜底
- 替换遗留的 $Message 兼容语法为 ElMessage
- 将 WAP 店铺装修模板的 Options API 统一为 defineOptions
- 优化数据报表卡片视觉样式,并移除失效的品牌筛选选项
- 清理冗余依赖 vuex,将 sass-loader 调整至 devDependencies
2026-10-20 13:51:04 +08:00
pikachu1995@126.com
7d9bf776db feat(seller): 素材管理页重构布局并统一主色交互
- 双层卡片嵌套改为单面板,tabs 与操作按钮并入顶部工具栏
- 悬停/选中红色描边统一为 Element Plus 主色
- 复选框改为悬停/选中时显示,选中增加淡蓝遮罩
- 分组行编辑入口悬停显示,添加分组改为置顶虚线按钮
- 分页增加总数显示,空列表显示空状态
2026-10-20 13:42:46 +08:00
pikachu1995@126.com
0c814ec956 feat(seller): 系统消息页改为顶部 tab 筛选与卡片式列表
- 左侧菜单改为顶部 pill tab(未读带数量角标)
- 表格改为卡片列表:类型图标 + 标题加粗 + 内容摘要 + 时间操作
- 点击消息就地展开内容,去掉返回按钮
- 修复标为已读后错误刷新全量列表的问题
2026-10-20 13:42:37 +08:00
pikachu1995@126.com
322e357398 Merge branch 'feat/appointment-goods' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui into feat/appointment-goods 2026-10-15 10:04:53 +08:00
lifenlong
cc4a94da40 fix(seller): shopSetting tab 事件改为 @tab-click
上一笔只把判断改成了 pane.paneName,但 @click 对未声明该 emit 的 el-tabs
会退化为原生 DOM 点击(handler 收到 MouseEvent),仍不生效。
@tab-click 才是 Element Plus 的自定义事件,handler 能拿到 pane 对象。
2026-10-14 21:22:05 +08:00
lifenlong
36648f065b fix(seller): 清理直播商品页的悬空模板引用
liveGoods 是从 addLive 复制来的纯只读详情页(所有表单项均 disabled),
但模板残留了三个不存在的引用:date-picker 的 @change="handleChangeTime"、
el-form 的 :rules="liveRulesForm"、"保存"按钮的 @click="createLives()"
(点击会抛 is not a function)。只读页无需保存入口,移除上述引用及该按钮。
2026-10-14 21:14:07 +08:00
lifenlong
9893494cf1 fix(seller): 修复店员管理页部门筛选失效
clerkManage 中 department-choose 监听器写的是 @change,而组件实际 emit 的是
on-change(与同目录 department-tree-choose 的约定一致),导致搜索区的部门筛选
永不触发。改为 @on-change 后恢复联动。
2026-10-14 21:09:49 +08:00
lifenlong
906cda0385 fix(seller): 修复三处历史遗留的恒假条件
- coupon-receive: 路由监听中 couponId 判断取错了来源(实际应判断 query.couponId),
  导致优惠券列表页切换时永不刷新;changeSelect 中 getType 判断改为读表单值 searchForm.getType
- addLive: 添加直播商品时 roomId 取 route.query.roomId 恒为 undefined,改为页面实际参数 route.query.id
- shopSetting: tab 切换回调 v == "INFO" 恒为 false(Element Plus 传 pane 对象),
  改为读 pane.paneName,基本信息/退货地址 tab 的数据重载恢复正常
2026-10-14 21:06:44 +08:00
lifenlong
92dd179525 chore(seller): 清理死代码与失效机制,生产构建剥离 console
- 删除零引用组件 main-components/message-tip.vue
- 清理 113 处 no-unused-vars(重点 goodsOperationSec 20+ 项、ossManage 25 项,含死代码链级联删除;renovation 的 ModelForm 为 vue-eslint-parser 误报,保留并核实)
- stores/app.js 移除只写不读的死字段(currNavTitle/menuTheme/themeColor/isFullScreen/messageCount/currentPageName/currentPath)及失效的 cachePage/dontCache 缓存页机制(无 keep-alive 消费方),同步清理 util.js/App.vue/Main.vue/shrinkable-menu/tags-page-opened 的调用点
- 删除 30 处 console.log 调试残留;生产构建 terser 配置 drop_console 兜底
2026-10-14 20:31:25 +08:00
pikachu1995@126.com
fefb748864 merge: 合并远端 script setup + pinia 迁移,保留装修编辑器 CRMEB 化功能 2026-10-13 11:28:23 +08:00
pikachu1995@126.com
0b644a1722 feat(manager): 移动端装修编辑器 CRMEB 化迭代
- 轮播图:双 tab 面板(内容/样式)、轮播风格(通栏/卡片)、指示器样式/位置/色调、渐变背景、边距圆角
- 图片魔方:9 种风格收进样式 tab,拖入默认左一右二,内容 tab 固定槽位配置,新增颜色/边距/圆角设置
- 宫格导航:双 tab 面板,菜单管理 + 颜色/边距/圆角设置
- 编辑器整体:灰底卡片模块化、顶栏页面名称编辑、画布操作条/名称标签外置、组件列表三列通高卡片
- 预览模板同步应用各组件样式字段
2026-10-13 11:07:30 +08:00
lifenlong
2a2c3f8174 perf(seller): Element Plus 按需引入 + 移除未使用的 vue-lazyload
- plugins/element.js 不再全量注册,组件由 unplugin-vue-components 按模板自动按需解析;
  v-loading 指令与全局配置(zhCn/size)单独保留
- chunk-framework 344KB → 279KB(gzip,-19%);样式仍走全量 CSS 保证覆盖顺序不变
- 修复 unplugin-vue-components 与 minimatch 的版本兼容(锁定 0.27.5 + resolutions 嵌套 v9)
- 移除全局注册但零使用的 vue-lazyload
2026-10-13 07:39:35 +08:00
lifenlong
88fc1b0b2a refactor: 四端完成 Options API → script setup + vuex → pinia 迁移
- buyer(95) / seller(169) / im(53) / manager(280) 共 597 个组件全部从 Options API 转换为 <script setup>
- 四端 vuex 全部移除,状态管理统一迁移至 pinia 4(stores/ 目录)
- util.js 等基础设施去除 vm 首参依赖;全局属性($Message/$filters 等)在 script 内改为具名导入
- 被外部 ref 访问的公共组件均已补齐 defineExpose(清单见各端 MIGRATION-VUE3.md)
- 各端 eslint 启用 vue/component-api-style 防止新代码回退 Options API
- 四端 yarn build 全部通过;迁移约定与注意事项已更新至各端 MIGRATION-VUE3.md
2026-10-13 07:06:59 +08:00
pikachu1995@126.com
b45910a35f style(manager): 左侧组件列表合并为一张通高卡片 2026-10-12 16:29:15 +08:00
pikachu1995@126.com
758f09a291 style(manager): 楼层操作条图标调大 2026-10-12 16:28:15 +08:00
pikachu1995@126.com
fce063642d style(manager): 左侧组件列表按分组模块化卡片展示 2026-10-12 16:27:03 +08:00
pikachu1995@126.com
c832350dd3 style(manager): 装修页整体间距梳理,三列与顶栏留 12px 间隔 2026-10-12 16:25:17 +08:00
pikachu1995@126.com
5169d4ac43 style(manager): 装修页顶栏改为卡片模块,页面换浅灰底 2026-10-12 16:21:29 +08:00
pikachu1995@126.com
d80ead4d94 style(manager): 热区面板标题行加白色卡片底色 2026-10-12 16:19:12 +08:00
pikachu1995@126.com
62700162d5 feat(manager): 装修页左上角支持设置页面名称 2026-10-12 16:17:57 +08:00
pikachu1995@126.com
a568131b7c style(manager): 热区设置面板按模块卡片形式展示 2026-10-12 16:14:40 +08:00
pikachu1995@126.com
27ca53068b style(manager): 左侧组件列表改为一行三个 2026-10-12 16:10:47 +08:00
pikachu1995@126.com
1d946a9616 style(manager): 组件分组标题与楼层名称标签字号调大 2026-10-12 16:09:45 +08:00
pikachu1995@126.com
f31669cba6 style(manager): 楼层名称标签与操作条移至预览区外两侧 2026-10-12 16:07:18 +08:00
pikachu1995@126.com
005006adf7 style(manager): 画布移除 iPhone 边框背景,改为简洁屏幕容器 2026-10-12 16:02:07 +08:00
pikachu1995@126.com
2f097ace49 feat(manager): 画布楼层加名称标签与悬浮操作条
- 每楼层左上角显示组件名标签
- 选中楼层右侧竖排操作条:上移/下移/复制/删除/隐藏,替代原右上角关闭图标
- 隐藏楼层变暗并显示已隐藏蒙层,hidden 随 pageData 保存
2026-10-12 15:59:58 +08:00
pikachu1995@126.com
654839b247 style(manager): 选择照片/绘制热区按钮改默认尺寸、自然宽度 2026-10-12 15:49:52 +08:00
pikachu1995@126.com
8ed67847dd style(manager): 滑块旁数字输入框放大(去 small 尺寸,宽 130px) 2026-10-12 15:48:30 +08:00
pikachu1995@126.com
47974dddab chore(manager): 设置热区按钮改名绘制热区 2026-10-12 15:47:03 +08:00
pikachu1995@126.com
76cc10c6af style(manager): 热区面板标题与 tab 同行展示
- 面板契约新增 hideDecorateTitle 标记,面板可自行接管标题行
- tab 分段控件改为自然宽度,右对齐
2026-10-12 15:46:02 +08:00
pikachu1995@126.com
ae08a568bd style(manager): 保存页面按钮右间距调整为 2% 2026-10-12 15:43:02 +08:00
pikachu1995@126.com
2bb3a71460 style(manager): 热区面板 tab 切换改为分段控件样式 2026-10-12 15:41:59 +08:00
pikachu1995@126.com
ce03b13718 feat(manager): 热区组件配置面板重做为内容/样式双 tab
- 新增 PanelFlexOne:内容 tab(选图+设置热区,复用 ossManage 与 hotzone 编辑器),样式 tab(背景颜色/上下左右边距/页面间距/背景圆角/内容圆角)
- 注册进 panelMap 替换 flexOne 的通用老面板
- 画布预览 tpl_flex_one 应用相同样式字段,缺字段兜底保持原样
2026-10-12 15:38:25 +08:00
pikachu1995@126.com
0ad79157d3 feat(manager): 图片组件更名为热区 2026-10-12 15:26:19 +08:00
pikachu1995@126.com
bdae07e585 feat(manager): 图片组件选择模式只保留热区
- 移除链接/热区单选,新增图片组件默认 hotzone 直接进绘制热区
- 链接区标题随模式显示热区/选择链接
- 存量 link 数据不受影响(uniapp 端热区由 tpl_hot_zone 渲染;原生小程序暂无热区渲染,待补)
2026-10-12 15:25:14 +08:00
pikachu1995@126.com
7fa6c2d84c style(manager): 保存页面按钮右间距调整为 5% 2026-10-12 15:22:19 +08:00
pikachu1995@126.com
59bd1b8c78 style(manager): 保存模板改名保存页面并往左收 15% 2026-10-12 15:17:58 +08:00
pikachu1995@126.com
18eb79054f feat(manager): 移动端装修编辑器改为全屏独立页
- /floorList/main 移出 Main 布局注册为顶层路由,不再显示左侧菜单/header/标签栏
- 编辑器容器铺满全屏,右侧面板高度同步调整
- 顶栏左侧新增返回按钮(goBack 复用已有方法)
- 路径/name/query 不变,列表页 4 处跳转零改动
2026-10-12 15:13:48 +08:00
pikachu1995@126.com
b5047a7a7a chore(manager): 移除装修页顶栏的页面切换按钮
watcher 会按 pagetype 重置为单 tab,切换入口实际不可用,且 main.vue 已按路由直接定页面
2026-10-12 15:05:13 +08:00
pikachu1995@126.com
45b83d0fe4 style(manager): 优化组件面板卡片质感
- 白底圆角卡片 + 淡蓝图标底块,hover 浮起、图标反白、投影
- 组标题加蓝色竖条点缀
2026-10-12 15:03:00 +08:00
pikachu1995@126.com
a804120fa2 feat(manager): 组件面板改为图标+文字双列布局
- config.js 21 个面板条目新增 icon 字段(element-plus 图标)
- 面板 item 渲染 el-icon + 名称,替换死的 img 占位
- 面板容器改 grid 两列,卡片式样式含 hover 高亮
2026-10-12 14:59:47 +08:00
pikachu1995@126.com
ebbf42cb76 chore(manager): 移除装修页顶栏的店铺装修标题 2026-10-12 14:51:45 +08:00
pikachu1995@126.com
1e7e78a841 fix(manager): 修复楼层装修审查发现的问题
- PanelArticle 文章分类字段名改为后端的 articleCategoryName
- blank/line/richText 预览组件补默认值兜底,避免拼出 undefinedpx
- 画布未知组件渲染可点击删除的占位条并 console.warn
2026-10-12 11:01:19 +08:00
pikachu1995@126.com
350e79895d feat(manager): 新增店铺列表装修组件 2026-10-12 10:23:00 +08:00
pikachu1995@126.com
2bee689198 feat(manager): 新增文章列表装修组件 2026-10-12 10:21:40 +08:00
pikachu1995@126.com
de333c2d34 feat(manager): 新增优惠券装修组件 2026-10-12 10:19:52 +08:00
pikachu1995@126.com
0c7ecbe039 feat(manager): 新增积分商城装修组件 2026-10-12 10:18:18 +08:00
pikachu1995@126.com
7a1ae5d5da feat(manager): 新增富文本装修组件 2026-10-12 10:16:45 +08:00
pikachu1995@126.com
2bbec5b9e5 feat(manager): 新增辅助线装修组件 2026-10-12 10:15:08 +08:00
pikachu1995@126.com
b4d67859da feat(manager): 新增辅助空白装修组件 2026-10-12 10:13:59 +08:00
pikachu1995@126.com
15c229fa62 feat(manager): 装修右栏按type分发配置面板机制 2026-10-12 10:12:11 +08:00
pikachu1995@126.com
3278726a25 feat(manager): 装修组件面板三类分组,移除促销活动组件,画布对未知类型增加守卫 2026-10-12 10:10:22 +08:00
pikachu1995@126.com
5df1ab6993 feat(manager): 装修编辑器共享控件库 ColorField/SliderField/SegmentField 2026-10-12 10:05:11 +08:00
pikachu1995@126.com
870bacdd8c refactor(seller): 重构店铺详情页面并优化表格列宽度
- 调整售后订单和订单列表中会员ID列宽度从120到185
- 将主题色管理状态从待办改为已完成
- 重构店铺详情页面UI组件结构和样式
- 更新店铺详情页面为卡片式布局并添加标签页功能
- 优化店铺详情信息展示为描述列表形式
- 修改订单类型选项将赠品订单和拼团订单替换为电子卡券订单和预约服务订单
- 重命名退款退货相关组件和变量为售后相关命名
- 添加店员列表标签页展示店铺员工信息
- 实现空白值过滤工具函数处理空数据展示
- 更新页面默认激活标签页为基本信息页
2026-10-09 17:06:59 +08:00
pikachu1995@126.com
bbe336d902 fix(theme): 优化主题色设置功能并修复令牌刷新逻辑
- 移除楼层列表中的主题色设置按钮及路由
- 重构主题色设置页面,新增系统预设色系和自定义模式
- 添加移动端真实页面预览功能,支持商品详情和订单页面预览
- 优化商品详情页优惠券展示样式和布局
- 修复买家端API刷新令牌请求的拦截器递归死锁问题
- 添加无refreshToken时的刷新请求保护机制
2026-10-09 15:50:56 +08:00
pikachu1995@126.com
1bd50cee45 fix(buyer): 修复商品列表路由变化时重复请求
- $route watcher 中 handleSearch 与末尾兜底调用重叠,带 keyword 时同一请求发两次,改为互斥分支
- 修复从关键词搜索切换到分类/促销路由时 params.keyword 残留旧值的问题
2026-10-09 09:50:38 +08:00
pikachu1995@126.com
b639bc36eb fix(buyer): 结算页异常兜底与白屏防御
- 1 处裸 await 包 try/catch,12 条 .then 链全部补 catch(原仅 2 条)
- 结算数据加载失败给用户可见提示并保持页面可操作,不再白屏;错误提示与 request 层拦截器去重
- 约 20 处深链式访问加可选链/默认值(含模板渲染层)
- 修复无效商品跳回购物车后缺少 return 导致的 TypeError
2026-10-09 09:46:06 +08:00
pikachu1995@126.com
89711af0e6 chore(buyer): 落地 ESLint 9 并清理调试代码
- 新增 eslint.config.js(flat config, vue3-essential),移除废弃的 .eslintrc.js/.eslintignore(引用已废弃的 babel-eslint),package.json 增加 lint/lint:fix 脚本
- 清理 17 处调试 console.log;修复 24 处空 catch(15 处补 console.warn 业务提示,9 处加注释说明可安全忽略)
- 修复存量 lint error:9 处 prop 默认值改为函数形式、3 处未使用组件、1 处 v-if/v-for 同级、1 处空 else
- vue.config.js 生产环境 terser 开启 drop_console 兜底
2026-10-09 09:45:59 +08:00
pikachu1995@126.com
3ec3f11d5b chore(seller): yarn.lock 入库,锁定依赖版本 2026-10-08 15:24:26 +08:00
pikachu1995@126.com
5fcc26a866 refactor(seller): 商家端系统性优化与 Vue3 迁移遗留问题修复
- 清理:删除 4 个未使用依赖、约 2700 行死代码(shop/ossManage 等)、tree-table 死组件、37 处未使用 import
- ESLint 9 flat config 升级(.eslintrc.js -> eslint.config.js),修复迁移遗留的 34 处真实 bug
- axios 重构:拦截器不再吞错、refreshToken 单例共享 Promise、超时收敛、form 序列化抽公共函数、vue-router 4 失效 API 修正
- 安全:4 处 v-html 增加 xss 过滤;logout 改为精确删除 localStorage 键
- API 重复定义收敛(delSpec/insertOrUpdateSpec、getDictData、ws)
- 首屏瘦身:tinymce 懒加载(约省 7.4MB)、core-js 按需 polyfill、vendor chunk 合并、dplayer/@antv/g2 动态 import、3 张大图压缩
- 页面修复:发布商品表单布局(iview 遗留 margin 移除)、beforeVideoUpload 补定义、el-tag type 空值、lili-dialog emits 声明、消息中心布局塌陷修复与样式重构、department-choose 空值、affix-time 不再发多余店铺请求、goods/distributionGoods 去掉品牌筛选
2026-10-08 15:22:56 +08:00
pikachu1995@126.com
8484936e3d refactor(axios): 优化HTTP请求拦截器和错误处理机制
- 移除调试用的console.log语句
- 调整表格列对齐方式,将操作列从居中改为右对齐
- 添加搜索表单功能,包括账单号、店铺名称、生成时间等筛选条件
- 重构axios拦截器逻辑,简化错误处理流程
- 实现统一的token刷新机制,使用单例模式避免重复请求
- 优化防抖函数实现,提高性能表现
- 添加401和403状态码的特殊处理逻辑
- 统一错误提示信息的显示方式
- 优化表格列宽度设置,调整金额相关列的显示效果
- 删除未使用的组件文件和样式代码
- 移除调试相关的代码和注释
- 优化图标组件的导入方式,使用markRaw包装
- 优化热词统计图表的数据显示逻辑,添加空数据提示
2026-10-08 14:39:12 +08:00
pikachu1995@126.com
1b275cf592 perf(image): 优化图片加载性能
- 为所有商品图片添加 loading="lazy" 属性实现懒加载
- 为所有图片元素添加 decoding="async" 属性实现异步解码
- 优化购物车页面的商品缩略图加载方式
- 改进订单中心各页面的图片加载策略
- 提升评价系统中图片的加载性能
- 优化会员中心收藏和足迹功能的图片加载
- 改善促销活动页面的图片加载体验
- 更新首页装饰组件中的图片加载属性
- 提高地址管理页面的界面展示效果
2026-10-08 14:36:22 +08:00
pikachu1995@126.com
4c232284f8 feat(goods, order): 完善预约与订单详情功能
- 预约规则区域的说明中补充可预约时段
- 价格区域的标题和说明改为根据商品类型动态展示
- 优化订单详情中配送信息的展示,仅在实物订单中显示
- 核销记录新增搜索功能,支持按商品名称和核销人筛选
- 精简订单列表搜索表单,移除不必要的字段,界面更简洁
2026-10-02 15:28:25 +08:00
pikachu1995@126.com
326ccea1b4 feat(router, appointment, procurement): 完善功能并优化界面
- 新增采购订单列表路由
- 预约工单列表新增状态筛选,搜索更方便
- 首页新增未完成预约工单和待审核采购的提醒
- 优化多个组件的样式与布局,提升用户体验和全站一致性
2026-10-02 15:27:52 +08:00
pikachu1995@126.com
8410179885 feat(buyer): 增强搜索功能并提升用户体验
- 新增 `getStoreById` 接口方法,用于获取包含评分在内的店铺信息
- `Search.vue` 新增搜索建议面板,展示搜索历史和热词,优化搜索体验
- 新增 `SearchSuggest.vue` 组件,用于展示搜索建议和搜索历史
- `App.vue` 根据路由元信息按条件设置页面标题
- 优化多个组件的布局与样式,让整体风格更统一
- 重构多个组件的跳转逻辑,改用路由跳转(`router.push`)替代新开窗口(`window.open`),提升用户体验
2026-10-02 15:26:58 +08:00
pikachu1995@126.com
4777eb2dd6 feat(buyer): 新增功能并优化样式,提升用户体验
- 新增 `getMemberMessage` 接口方法,用于获取会员消息
- 调整全局及商品列表样式,提升可读性与一致性
- 精简 `Search.vue`,移除不必要的店铺搜索切换
- 放宽 `addressManage` 中地址别名的输入长度
- 优化 `ShowGoods.vue` 和 `ShowGoodsDetail.vue` 的布局与样式
- 更新 `Cart.vue` 中的购物车步骤展示,让流程导航更清晰
- 调整多个组件,统一字号并优化界面元素
2026-09-29 18:11:31 +08:00
pikachu1995@126.com
867b03ebfc refactor(buyer): 优化组件结构并提升用户体验
- Main.vue 路由逻辑改为使用当前路由信息
- 简化 Header.vue 中的购物车展示,优化布局与可访问性
- GoodsClassNav.vue 改为按条件渲染分类信息
- 完善 MyOrder.vue 的订单展示与交互,优化样式与结构
- 调整 message.js,更稳妥地处理 Promise 拒绝

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 10:48:44 +08:00
pikachu1995@126.com
a6063874c7 Merge branch 'master' into feat/appointment-goods 2026-09-28 14:25:59 +08:00
pikachu1995@126.com
037fd0caa0 refactor(goods): 重构商品管理模块的API接口和页面结构
- 移除manager端的预约改期日志相关API接口
- 移除seller端的预约改期相关API接口
- 更新seller端API请求方法,将putRequest替换为putRequestWithNoForm
- 修改服务人员更新接口,使用POST方式并传入ID参数
- 重构seller端商品搜索表单,增加SKU名称、SKU货号、店铺分类等搜索条件
- 新增商品创建时间范围选择器和库存状态筛选功能
- 优化商品表格列结构,调整销售模式与商品类型显示逻辑
- 添加商品复制功能按钮
- 重构manager端商品详情页面,实现新的详情展示布局
- 新增商品审核弹窗和时段可约数量抽屉组件
- 优化商品详情页的商品参数展示逻辑
- 添加预约商品相关的特殊处理逻辑
2026-09-28 14:24:24 +08:00
田香琪
ea897b154b Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2026-09-24 17:49:09 +08:00
田香琪
54c72239d4 refactor(page-decoration): 用 beforeRelease 替换发布开关逻辑
- 楼层与移动端装修列表改用 before-change,避免开关被直接切换
- 唯一页面禁止关闭,已开启页面提示改开其他页
- 按接口成功/失败再决定是否切换开关
2026-09-24 17:45:46 +08:00
pikachu1995@126.com
1bad9a14db feat(appointment): 预约商品发布改为可视化配置,并接入表单设计器
商家端补齐预约规则、时段库存与系统表单设计;运营端增加预约工单与订单设置。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 17:06:16 +08:00
pikachu1995@126.com
50ebc7d6df refactor(buyer): 移除 PC 商城分销推荐入口与相关接口
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 09:55:20 +08:00
田香琪
ff38cf52b4 feat(distribution): 添加自动申请模式提示信息 2026-09-18 17:50:34 +08:00
Ryan Ran
2fdab71d14 fix(manager): 钉住 webpack 5.108.4,修复生产构建 $filters 全部报错
webpack 5.108.0 回归(webpack/webpack#21278):生产构建在 mangleExports 下遇到 namespace import 整体逃逸(app.config.globalProperties.$filters = filters)时,会把混淆后的导出名当属性路径渲染(u6 -> .u[6]),导致 $filters.unitPrice 等全部报 Cannot read properties of undefined (reading '6');dev server 不启用 mangleExports,本地无法复现。

- manager:webpack 固定 5.108.4(devDependencies + resolutions),pnpm-lock 同步更新
- buyer:纳入 pnpm-lock.yaml(webpack 5.111.0)

Refs: https://github.com/webpack/webpack/issues/21278
2026-09-18 11:29:18 +08:00
田香琪
b51148f520 Merge branch 'fix/distribution-statistics-v2-empty-date' 2026-09-15 10:43:26 +08:00
田香琪
87db9dbb12 Merge branch 'fix/featured-goods-display-count-limit' 2026-09-15 10:24:45 +08:00
田香琪
fb979ea514 feat(distribution): 增强主推商品选择功能与同步逻辑
- 添加 row-key 属性以优化表格行选择
- 引入 featuredPickerDraft 用于临时存储选择的商品
- 实现同步选择逻辑,确保用户在选择商品时状态一致
- 更新确认逻辑,确保用户选择的商品数量不超过限制
- 优化用户交互体验,提升选择反馈
2026-09-15 10:24:12 +08:00
田香琪
8d18f05085 feat(login): 增强扫码登录功能与会话管理
- 登录接口增加 loading 与超时配置,改善请求体验
- 根据服务端返回处理二维码过期,并动态设置有效时长
- 重构二维码刷新逻辑,改用 setTimeout 控制过期
- 合并会话管理与错误处理,简化扫码登录流程
- 移除多余 watch,精简组件行为
2026-09-15 09:46:45 +08:00
田香琪
dfb790e713 refactor(distribution): 移除不必要的时间参数构建逻辑
- 删除 buildQueryParams 方法及相关的 startTime 和 endTime 参数,简化数据查询逻辑
- 更新 loadOverviewData 和 loadBehavior 方法,直接调用 getDistributionOverviewV2,无需构建额外的查询参数
- 优化代码结构,提高可读性和维护性
2026-09-13 17:06:41 +08:00
田香琪
62d55e2b92 feat(distribution): 添加主推商品数量设置与超限提示
- 新增主推商品数量选择功能,用户可选择显示5个或10个商品
- 增加超限提示,确保用户在自定义商品选择时不超过限制
- 更新相关描述信息,提升用户体验与交互反馈
2026-09-13 10:06:33 +08:00
田香琪
f4a50cd854 refactor(distribution): 更新分销资格提示与申诉逻辑
- 修改分销资格被清退的提示信息,明确只可通过后台恢复,不再提供申诉功能
- 移除申诉相关的逻辑与按钮,简化用户交互
- 调整状态管理,去除不再使用的申诉状态
2026-09-09 15:21:07 +08:00
田香琪
47a2c40ffd feat(distribution): 添加百分比与分数转换功能
- 新增 toPercent 和 toFraction 函数,分别用于将小数转换为百分比和将百分比转换为小数
- 更新 distributionGrade.vue 中的佣金率处理逻辑,确保数据在保存和展示时正确转换
- 修改 distributionGradeHistory.vue 中的百分比显示逻辑,确保格式一致性
2026-09-07 11:12:55 +08:00
田香琪
fddb82e680 refactor(GoodsClassNav): 优化品牌ID处理与选择逻辑
- 重构品牌ID的获取方式,使用新方法 appendBrandIdsByNames 简化逻辑
- 更新多选确认逻辑,避免重复选择相同类型
- 调整标签内容显示逻辑,确保品牌标签在选择时正确显示
- 修复商品列表页的分页参数,确保从第一页开始加载数据
2026-09-03 17:17:11 +08:00
田香琪
2d80a795a7 feat(goodsOperation): 完善商品发布流程与分类选择界面
- 引入商品类型选择对话框,增强用户体验
- 重新组织商品发布步骤,优化步骤指示器
- 更新商品分类选择逻辑,确保用户选择到三级分类
- 调整数据传递与状态管理,支持商品编辑与复制功能
- 优化样式与布局,提高界面可用性
2026-08-27 17:48:16 +08:00
田香琪
ffdae4a332 refactor(renovation): 重构页面数据处理逻辑
- 将页面数据处理逻辑提取到 applyPageData 方法中,简化 onOk 和 getTemplateItem 方法
- 增强数据解析和错误处理,确保在无数据或解析失败时正确初始化 modelForm
- 优化对 topAdvert 类型的处理逻辑,提高代码可读性和维护性
2026-08-25 10:46:44 +08:00
田香琪
3ca9aad9be feat(editor): 增强 TinyMCE 编辑器功能与内容同步
- 提交前同步 PC 与移动端编辑器内容
- 增加编辑器未初始化时的待写入内容处理
- 优化内容读写方法,保证数据绑定准确
- 重构事件处理,避免初始化时覆盖 v-model 值
2026-08-24 12:46:39 +08:00
田香琪
085674dfa7 fix: 修复入驻误校验与商品品牌显示 0,并优化分类、运费模板交互
- 入驻第三步改为先回填再渲染,避免空字段进页即提示
- 入驻协议无内容时按空字符串处理,避免读取 null
- 商品品牌空值展示占位符,提交时再转为 0
- 店内分类为空时横向展示「暂无分类」
- 运费模板选择地址默认折叠,操作文案改为「选择地址」
2026-08-20 16:55:05 +08:00
田香琪
9540a1b98b refactor(goodsOperationSec): 优化批发规则表单与预览展示
- 调整批发规则表单的样式与结构,增强可读性
- 更新批发规则预览逻辑,确保信息准确展示
- 增加样式类以改善用户体验,确保输入框与按钮布局合理
2026-08-17 12:57:58 +08:00
田香琪
b9d48fab05 为保持开发环境一致性,将管理端与商家端的 API 基础地址均从 localhost 修改为生产环境地址。
说明:
fix(config):配置类修复,保留 git commit 前缀规范
manager:管理端
seller:商家端
localhost:本地地址
2026-08-17 11:18:08 +08:00
田香琪
6552767823 Merge branch 'salesman' 2026-08-17 11:05:22 +08:00
田香琪
f42f5903d3 fix(userManage): 提交前对密码进行 MD5 加密
- 用户管理表单在注册或更新用户时,先将密码 MD5 加密后再提交服务端,提升密码传输安全性
2026-08-13 19:20:08 +08:00
田香琪
0c94b67ad9 Merge branch 'feature/order-delivery-fulfillment' 2026-08-13 17:19:53 +08:00
田香琪
3b665f6d8c feat(orderFulfill): 增强订单发货逻辑与界面
- 新增无需配送场景及发货模式相关工具函数
- 更新订单详情与订单管理页,支持整单发货、分包裹发货、无需配送
- 优化物流信息与订单日志展示
- 重构订单发货 API 以适配新逻辑
- 增强订单列表与详情页的发货选项与操作展示
2026-08-13 17:19:06 +08:00
田香琪
420e8a84e5 fix(goodsOperationSec): 修复商品编辑页店内分类无法回填的问题
- 初始化店内分类 ID 列表时过滤空值
- 分类树数据加载完成后调用 setCheckedKeys 回填勾选状态
- 使用 const 声明 shopCategories 提升可读性
2026-08-13 16:37:52 +08:00
pikachu1995@126.com
05b14ada18 fix(promotion): 打开商品选择器前先初始化已选 SKU 列表
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 15:35:34 +08:00
pikachu1995@126.com
aed9d86ada Merge branch 'master' into salesman 2026-08-11 15:34:46 +08:00
pikachu1995@126.com
6933fb79ec feat(distribution): 优化分销提现页面功能
fix(setting): 更新海报和邀请卡背景图尺寸说明
style(table): 统一全局表格表头样式
refactor(home): 优化首页待办事项点击跳转
refactor(main): 优化主布局侧边栏收起动画
chore(menu): 优化菜单导航结构
docs(readme): 更新项目文档信息
feat(task): 新增定时任务管理页面
2026-08-11 15:16:32 +08:00
pikachu1995@126.com
4be36d61c7 增加二级分销相关设置以及页面 2026-08-05 18:03:45 +08:00
田香琪
d4cd6e0936 feat(buyer): 增强商品详情与支付逻辑
- 引入 buyerLogin 工具,优化商品详情页的链接跳转逻辑,确保未登录用户能正确重定向到登录页面
- 更新商品 SKU 详情 API,支持未登录状态下的访问
- 修改支付 API,新增 skipMessage 参数以控制支付提示信息
- 改进商品列表与支付页面的用户体验,确保更流畅的操作流程
- 规范化商品类型与库存提示,提升用户体验
2026-08-05 14:17:39 +08:00
田香琪
c0b00999c5 refactor(goodsType): 删除冗余的 promotionPriceMin 函数定义
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:13:01 +08:00
田香琪
1accdf5826 feat(buyer): 更新电子卡券(E_COUPON)相关逻辑与展示
- 优化购物车与支付页面,确保电子卡券只能通过立即购买结算
- 增强商品详情页,添加限时折扣与第N件优惠展示
- 更新促销逻辑,支持拼团与积分购买选项
- 规范化商品类型与库存提示,提升用户体验
2026-08-02 13:55:19 +08:00
田香琪
1ae7ab3036 feat(buyer): 添加电子卡券(E_COUPON)支持
- 新增 E_COUPON 商品类型,支持电子卡券的展示与购买逻辑
- 优化商品详情页,调整促销、库存和购买按钮逻辑以适应电子卡券
- 增加卡密信息展示与管理功能,支持商家端卡池管理
- 更新相关页面以处理电子卡券的特殊逻辑,如免地址、隐藏优惠券等
2026-07-31 13:44:56 +08:00
田香琪
36878e6f7f Merge branch 'fix/promotion-datetime-format' 2026-07-28 10:30:44 +08:00
田香琪
09b735e82a feat(buyer): 增加短信验证码发送状态管理
- 新增 codeSent 状态以跟踪短信验证码是否已发送
- 在登录流程中添加验证,确保用户完成安全验证和获取验证码
- 优化验证码发送逻辑,确保用户体验
2026-07-28 10:29:14 +08:00
pikachu1995@126.com
24f829033f Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2026-07-23 09:30:26 +08:00
pikachu1995@126.com
a72ff640cc feat(ossManage): 进行商家 素材管理 功能改造与界面优化 2026-07-23 09:29:47 +08:00
田香琪
68a791a439 Merge branch 'fix/im-vue3-migration' 2026-07-22 18:10:04 +08:00
田香琪
c0ca9672a1 fix(buyer): 未登录联系客服时提示登录
- 将登录提示由 Message 改为 Modal
- 新增 promptLogin 方法处理登录跳转
- IMService 增加 accessToken 校验,未登录时提示登录
- 改进用户信息获取的错误处理
- 重构 IM 链接 URL 拼接,提升可读性
2026-07-22 18:08:42 +08:00
pikachu1995@126.com
6ee49ce9ac feat:优化店铺管理相关功能并进行界面改造 2026-07-22 17:52:11 +08:00
pikachu1995@126.com
c83d715234 feat (router): 新增采购详情路由,优化预警数量管理功能
在管理员模块与商家模块中新增采购详情页面路由。
升级预警数量管理组件,优化交互体验与功能逻辑。
2026-07-22 15:52:15 +08:00
田香琪
4bcaf14bad fix(im): 迁移 Element UI 图标并修复 Vue 3 兼容问题
- 新增 migrate-legacy-icons 脚本,批量将旧图标替换为 legacy-el-icon 组件
- 多个组件中将 <i class="el-icon-*"> 统一改为 <legacy-el-icon name="el-icon-*">
- 将 size="mini"/"medium" 调整为 Element Plus 的 small/default
- 新增 legacy-el-icon 全局组件及图标映射逻辑
2026-07-22 13:36:44 +08:00
pikachu1995@126.com
e91d783e22 Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2026-07-21 18:46:39 +08:00
pikachu1995@126.com
4bb3d33f89 fix: 复选框的值双向绑定,并补充 tbody 标签,保证表格结构规范统一 2026-07-21 18:46:33 +08:00
田香琪
12291ad4d5 feat(invoiceModal): 发票弹窗组件新增 change 事件
- 在发票弹窗中增加 change 事件 emit,便于父组件监听数据变化
- 优化 coupon-publish 组件的 selectedMember、selectedList 绑定,完善会员选择功能
2026-07-21 17:01:38 +08:00
田香琪
829d3e65e7 Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2026-07-21 13:29:47 +08:00
pikachu1995@126.com
3be9aa5c14 feat(statistics): 添加店铺业绩、商品同比环比、销售订单明细及商品销售汇总报表视图和相关 API 接口 2026-07-20 15:31:41 +08:00
pikachu1995@126.com
6c9a3820e8 feat(statistics): 添加统计模块及相关视图 2026-07-20 09:10:56 +08:00
田香琪
d172a1c9b8 fix(cart): 调整错误提示展示并优化已下架商品遮罩样式
简化 Cart.vue 中错误文案展示,移除多余 margin
优化 error-goods 样式:使用 inset 定位,并通过 box-sizing 管理 padding,改善布局与响应表现
2026-07-17 17:35:42 +08:00
田香琪
59c98d1289 chore(config): 更新开发环境的 API 基础地址
将 manager 与 seller 配置中的 API 基础地址由本地开发地址改为生产地址,以统一各环境配置。
2026-07-17 11:46:56 +08:00
田香琪
b7235f5644 refactor(orderDetail): 迁移 UI 组件并优化订单信息展示
将 Card、Button 等组件替换为 el-card、el-button,与 Element Plus 保持一致
优化订单信息展示,补充备注缺省值,并改进发票信息处理
根据订单类型与可操作权限调整按钮显示条件
2026-07-17 11:11:23 +08:00
田香琪
9dc4829f1f fix(buyer): 解决 Profile.vue 合并冲突标记导致的编译错误
保留 getMemberMsg 安全加载用户信息逻辑,并统一使用 buyerUrl 上传接口。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 21:13:06 +08:00
田香琪
843134c82a Merge branch 'vue3' 2026-07-16 16:56:45 +08:00
田香琪
7e87214b6d refactor: 移除未使用的商品规格相关 API 接口与组件,优化用户管理与物流功能
- 删除了与商品规格相关的 API 接口和组件,简化代码结构。
- 更新用户管理功能,确保表单字段与验证规则一致。
- 优化物流添加与编辑功能,确保请求参数的正确性与一致性。
2026-07-16 16:52:55 +08:00
田香琪
192a9ca53f fix(manager): 解决 util.js 合并冲突标记导致的编译错误 2026-07-15 17:07:46 +08:00
田香琪
1df821aa0c Merge branch 'vue3' 2026-07-15 16:50:18 +08:00
田香琪
3e3644f4a4 Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2026-07-15 16:40:44 +08:00
田香琪
b1f62d599b feat(manager): 增强运营后台表单校验与页面体验
- 富文本编辑器支持 Vue3 modelValue 双向绑定
- 补充 lazyLoading 路径别名,跳过已弃用的分销等级菜单
- 完善文章、优惠券活动、砍价活动、商品参数等表单校验
- 修复文章管理布局遮挡、店铺选会员弹窗及操作列分隔符显示问题

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 16:39:10 +08:00
chc
4e0ee8e899 移除直播中控台消息发送按钮 2026-07-15 13:48:30 +08:00
田香琪
7804d4eba6 refactor: 移除已弃用的商品规格相关 API 接口与菜单路由检查 2026-07-15 11:03:45 +08:00
田香琪
1fa3a91979 解决 Profile.vue 合并冲突,同步master代码 2026-07-14 16:45:10 +08:00
pikachu1995@126.com
29d5ba98ff Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2026-07-13 17:38:20 +08:00
pikachu1995@126.com
c25c16adf3 替换登录背景图 2026-07-13 17:38:17 +08:00
chc
de9bf1902a Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2026-07-13 17:37:22 +08:00
chc
f2ec4530ed 直播功能完善提交 2026-07-13 17:37:10 +08:00
pikachu1995@126.com
7bbcab7679 feat: 更新 API 路径与文档 2026-07-13 17:36:11 +08:00
pikachu1995@126.com
f646022926 Merge branch 'vue3' 2026-07-13 16:56:36 +08:00
pikachu1995@126.com
f6037dc18b refactor: 更新 README 文档与后端服务配置 2026-07-13 16:49:49 +08:00
pikachu1995@126.com
29acc7f0ee Merge branch 'vue3' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui into vue3 2026-07-13 16:48:47 +08:00
chc
5ef5adf9be Merge branch 'vue3' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2026-07-13 15:28:32 +08:00
chc
9647e7614b 直播功能补充提交 2026-07-13 15:26:56 +08:00
pikachu1995@126.com
fb7c0c9ca2 refactor: 更新文件上传路径配置
- 将文件上传路径从 commonUrl 更新为 sellerUrl,确保 API 请求的正确性。
2026-07-13 14:32:34 +08:00
pikachu1995@126.com
0002a39fc4 Merge branch 'vue3' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui into vue3 2026-07-13 14:32:20 +08:00
pikachu1995@126.com
e67ea56651 refactor: 更新 API 请求路径与配置 2026-07-13 14:30:09 +08:00
田香琪
4cd96980ba Merge branch 'fix-vue3-left-bug' 2026-07-10 13:32:53 +08:00
田香琪
d648855533 feat: 增强用户登录体验与组件逻辑优化
- 在多个组件中添加用户登录状态检查,确保用户在进行特定操作前已登录。
- 更新购物车、消息中心等组件的点击事件,未登录时提示用户登录。
- 优化优惠券领取逻辑,未登录时引导用户登录。
- 调整样式以提升组件一致性,确保用户界面友好。
2026-07-10 13:31:14 +08:00
田香琪
9bed373ec1 Merge branch 'fix-vue3-left-bug' 2026-07-09 17:59:19 +08:00
田香琪
c355194aba feat: 优化商品详情与购物车功能
- 更新 ShowGoods.vue 和 ShowGoodsDetail.vue 中的按钮样式,统一使用 goods-action-btn 类。
- 在 Cart.vue 中实现商品收藏状态的同步与处理,优化收藏功能的用户体验。
- 调整 GoodsDetail.vue 中的店铺收藏按钮样式,提升视觉一致性。
- 在多个评价组件中引入主题色,增强评分组件的视觉效果。
2026-07-09 17:58:01 +08:00
田香琪
15fa1a5e52 Merge branch 'vue3' 2026-07-09 11:03:16 +08:00
田香琪
ecf11ae2d2 feat: 添加直播间推荐功能与视频上传优化
- 在 promotion.js 中新增 whetherStar 函数,用于更新直播间推荐状态。
- 在 goodsOperationSec.vue 中优化视频上传组件的样式与逻辑,增加视频删除功能。
- 在 live.vue 中集成 whetherStar 函数,优化直播间推荐状态切换逻辑。
2026-07-09 10:49:55 +08:00
田香琪
fb2a9e7a9f feat: 添加图标映射功能
- 新增 iconMap.js 文件,集中管理应用中的图标资源。
- 提供 getIconUrl 函数以便根据名称获取对应图标的 URL,提升代码可维护性。
2026-07-08 18:37:20 +08:00
田香琪
106e059d1a refactor: 统一组件导入与消息提示方式
- 将多个组件中的 require 替换为 import,提升代码一致性。
- 更新消息提示方式,使用统一的 Message 和 Modal 组件,增强用户体验。
- 调整部分组件的样式和逻辑,确保界面一致性与可读性。
2026-07-08 18:36:06 +08:00
田香琪
f6ddcf4f99 Merge branch 'vue3' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui into vue3 2026-07-08 16:23:15 +08:00
田香琪
a4659ed1a2 refactor: 统一组件导入方式与样式调整
- 将多个组件中的 require 替换为 import,提升代码一致性。
- 更新上传组件中的回调函数,简化图片选择逻辑。
- 调整样式以适配 Element Plus 组件,确保界面一致性。
- 删除不再使用的样式,优化项目结构。
2026-07-08 16:22:44 +08:00
pikachu1995@126.com
c90559981e Merge branch 'vue3' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui into vue3 2026-07-08 09:59:08 +08:00
pikachu1995@126.com
b299507df9 feat: 更新登录页面与底部信息展示 2026-07-08 09:56:25 +08:00
田香琪
cf92d9d939 Merge branch 'vue3' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui into vue3 2026-07-07 19:51:17 +08:00
田香琪
6b61b86585 feat: 优化组件导入与样式调整
- 在多个组件中将 require 替换为 import,提升代码可读性和一致性。
- 在 App.vue 中添加分类树的 API 请求,优化分类数据的加载逻辑。
- 更新样式以适配 Element Plus 组件,确保界面一致性。
- 删除不再使用的样式文件,简化项目结构。
2026-07-07 19:50:07 +08:00
Ryan Ran
ae896ff42c Merge branch 'vue3' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui into vue3 2026-07-07 16:40:19 +08:00
Ryan Ran
709d114986 feat: 添加直播聊天功能与相关配置
- 在 LiveChatPanel.vue 中实现直播间聊天功能,支持发送和接收消息。
- 新增 useChat.js 组合式 API,管理聊天逻辑与状态。
- 更新 Live.vue 控制面板,集成聊天组件并优化直播状态显示。
- 更新 package.json 和 pnpm-lock.yaml,添加 @tencentcloud/chat 和 hls.js 依赖。
2026-07-07 16:40:09 +08:00
pikachu1995@126.com
cc86430ea4 Merge branch 'vue3' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui into vue3 2026-07-07 10:56:17 +08:00
pikachu1995@126.com
295aaf1a01 feat: 添加主题色设置功能 2026-07-07 10:56:14 +08:00
田香琪
548335b658 feat: 优化售后服务页面与上传组件
- 在 AfterSaleDetail.vue 中添加样式以增强商品信息展示的可读性。
- 在 ApplyAfterSale.vue 中更新选择原因的下拉框,确保与服务类型关联。
- 在多个上传组件中添加图片选择与确认功能,提升用户体验。
- 在订单投诉与退款页面中调整表格列宽,确保信息展示的清晰性。
2026-07-07 10:38:59 +08:00
pikachu1995@126.com
df35a26f9e Merge branch 'vue3' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui into vue3 2026-07-07 09:40:52 +08:00
pikachu1995@126.com
c277cf1121 feat: 新增视频组件及配置功能
- 在 config.js 中添加视频类型配置,支持视频上传和样式设置。
- 创建 video-config.vue 组件,提供视频内容和样式的配置选项。
- 更新 decorate.vue 以支持视频类型的渲染和配置。
2026-07-07 09:39:00 +08:00
田香琪
297bdd95dd feat: 更新 API 请求参数与组件样式优化
- 在 account.js 和 member.js 中添加 loading 和 skipMessage 参数,提升 API 调用的用户体验。
- 在 ShowGoods.vue 中优化商品详情展示样式,增强可读性和交互性。
- 在 Cart.vue 中重构购物车商品列表结构,提升布局一致性。
- 在多个页面中修复了提示信息的内容,确保用户操作的清晰反馈。
2026-07-06 16:33:21 +08:00
pikachu1995@126.com
e13ca6281d feat: 更新登录页面与API逻辑 2026-07-06 15:59:14 +08:00
chc
235362d5b0 Merge branch 'vue3' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui into vue3 2026-07-06 14:48:47 +08:00
chc
fc5fb3832d 直播功能菜单调整 2026-07-06 14:48:40 +08:00
田香琪
bd9a5f75b0 fix: 增强错误处理与组件逻辑优化
- 在 App.vue 中添加 JSON 解析错误处理,确保站点信息获取的稳定性。
- 更新多个 API 请求,添加 loading 状态管理,提升用户体验。
- 在 Search.vue 中优化热词列表的解析逻辑,增强容错性。
- 改进购物车、地址管理等 API 请求参数,确保一致性与可用性。
- 新增用户中心布局组件,提升用户界面的一致性与可读性。
2026-07-03 18:29:41 +08:00
田香琪
99de0d4722 style: 更新短信设置页面样式以增强可读性和一致性
- 将 .card 类名更改为 .sms-setting-card,以更好地描述其功能。
- 移除不必要的样式定义,简化代码结构。
2026-07-01 17:30:33 +08:00
田香琪
726d563e1a style: 更新首页样式以增强布局和响应式表现
- 调整了 .flow-list、.flow-item 和 .today-item 的样式,优化了布局和间距。
- 增加了 .flow-box 和 .today-box 的 padding 和 flex 属性,提升了视觉一致性。
- 更新了 .today-list 的布局,确保更好的响应式设计。
- 修复了 .today-item 的样式,确保内容在不同屏幕尺寸下的可读性。
2026-07-01 17:00:30 +08:00
田香琪
7ce1ea8fa4 fix: 修复 formatPrice 对空值和非数字价格的处理
避免表格调用 unitPrice 时因 price 为 undefined/null/非法字符串导致报错。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 15:09:05 +08:00
田香琪
2e5be29a43 feat: 新增消息模板聚合接口及通知类站内信邮箱状态更新功能
- 在 setting.js 中添加了消息模板聚合分页接口和更新通知类站内信邮箱渠道开关的功能。
- 在 axios.js 中重构了 token 刷新和登录重定向逻辑,避免循环依赖。
- 新增 router-holder.js 文件以延迟持有 router 实例,优化依赖管理。
- 更新 util.js 中的动态路由管理逻辑,确保在无 router 实例时不执行相关操作。
2026-07-01 11:41:49 +08:00
chc
ac77acd888 快递鸟电子面单不进入打印页面 2026-07-01 10:39:54 +08:00
田香琪
f4b17254ec Merge branch 'vue3' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui into vue3 2026-06-30 21:10:06 +08:00
田香琪
28a857249a refactor: 重构组件逻辑与样式优化
- 在 App.vue 中重构站点信息的获取与应用逻辑,提升代码可读性与维护性。
- 更新 Search.vue 组件,优化搜索框的样式与结构,增强用户体验。
- 在 element.scss 中注释掉不再使用的样式,并新增确认收货按钮的样式。
- 改进多个页面的布局与样式,确保响应式设计的一致性与美观性。
- 更新订单相关组件的按钮样式,提升视觉一致性与可用性。
2026-06-30 21:09:28 +08:00
chc
22f7b1a343 腾讯云直播 2026-06-29 11:31:29 +08:00
田香琪
ec948b5496 feat: 增强组件功能与样式优化
- 在多个组件中添加了新的属性和方法以提升功能性,如在卡片组件中引入 _ActiveTab 属性以管理活动标签。
- 优化了商品详情和商户页面的收藏逻辑,确保在未登录状态下引导用户登录。
- 更新了轮播组件的路由解析逻辑,增强了用户体验。
- 改进了投诉列表和收藏列表的样式,提升了响应式布局和可读性。
- 在全局配置中添加了对开发环境的错误处理,提升了调试体验。
2026-06-25 16:52:46 +08:00
田香琪
1b2e980fb6 fix: 更新 API 请求参数格式及优化组件样式
- 将 addEvaluation API 的参数从 params 修改为 data,以符合标准请求格式
- 在多个组件中调整样式,提升响应式布局和用户体验
- 优化商品详情页的倒计时显示,增强视觉效果
- 更新购物车操作按钮样式,提升一致性和可用性
2026-06-23 14:13:25 +08:00
田香琪
8a60e23214 feat: 新增全局布局样式并提升组件响应式表现
- 在 global-layout.scss 中引入全局布局样式,统一页面宽度与对齐方式
- 更新多个组件与页面以优化响应式,包括宽度、间距及 flex 布局等调整
- 在 API 请求中增加 loading 状态管理,改善用户体验
- 优化领券中心与商品详情页,提升功能与 UI 一致性
2026-06-23 10:17:35 +08:00
Chopper711
ff55a0fade 完善首页 All-In-One 说明 2026-06-14 15:51:33 +08:00
lifenlong
c1447b4376 feat(促销管理): 添加限时直降和第N件优惠功能 2026-06-07 17:17:19 +08:00
lifenlong
734825ba9b 优化财务相关功能 2026-06-07 16:57:23 +08:00
lifenlong
0da6ef99af 升级Vue3,iView替换ElementPlus
- 删除babel配置、更新依赖与入口初始化
- 全量替换UI组件、样式适配,新增迁移文档与标签/过滤器自动化替换脚本
2026-06-05 17:50:17 +08:00
lifenlong
832fda813b 升级Vue3,iView替换ElementPlus
- 删除babel配置、更新依赖与入口初始化
- 全量替换UI组件、样式适配,新增迁移文档与标签/过滤器自动化替换脚本
2026-06-05 17:49:43 +08:00
pikachu1995@126.com
615ee91511 manager 升级到vue3 2026-05-25 10:49:09 +08:00
pikachu1995@126.com
e7350899bf Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2026-05-22 11:48:29 +08:00
pikachu1995@126.com
ad0522335e feat(礼品卡管理): 添加礼品卡选择、绑定、激活及列表功能
- 在购物车API中新增选择礼品卡的接口
- 在促销API中添加获取、绑定和激活礼品卡的接口
- 在订单详情和支付页面中展示礼品卡抵扣信息
- 新增“我的礼品卡”页面,支持礼品卡的查看、绑定和激活功能
- 更新路由配置以支持礼品卡相关页面
2026-05-22 11:48:19 +08:00
pikachu1995@126.com
38da0d9f3a fix(member/advance/withdrawApply): 完善提现申请审核提交逻辑
- 调整加载状态为submitLoading,通过finally确保加载状态正确关闭
- 优化成功判断逻辑,兼容布尔值和带success字段的响应结果
- 提交成功后重置更多表单状态并关闭相关弹窗
2026-05-20 11:26:52 +08:00
田香琪
020b413e20 feat(提现管理): 更新提现状态显示和逻辑
- 修改提现状态标签为“提现状态”
- 新增“用户确认”状态选项
- 更新状态显示逻辑以支持新状态
2026-05-19 20:35:05 +08:00
Chopper711
1750f04d0b update readme 2026-05-17 19:54:58 +08:00
Chopper711
14d53fa48d Merge branch 'master' of gitee.com:beijing_hongye_huicheng/lilishop-ui 2026-05-17 19:54:19 +08:00
pikachu1995@126.com
aa4bc81da4 feat(会员管理): 添加第三方账户绑定和会员等级功能
- 在登录API中新增获取第三方账户绑定列表、绑定和解绑功能
- 在会员API中新增获取当前会员等级、等级规则和等级列表的接口
- 在会员中心页面中添加第三方账户绑定的UI和逻辑
- 新增会员等级页面,展示当前等级、经验值和经验值记录
2026-05-13 18:53:33 +08:00
田香琪
fe4e0ce75c feat(商品评价管理): 添加商品名称搜索功能和评论置顶功能
- 在商品评价页面中新增商品名称搜索框
- 在评论列表中添加是否置顶的显示和操作功能
- 实现评论置顶状态的更新逻辑
2026-04-16 17:55:16 +08:00
田香琪
052c6e153d fix(会员积分管理): 更新分页设置,调整默认每页显示条目数为20 2026-04-15 16:27:37 +08:00
田香琪
60479b44b1 feat(会员积分管理): 添加会员积分分页功能和总数显示 2026-04-15 16:15:54 +08:00
田香琪
8e0d9148b1 运营后台,积分记录展示会员剩余积分列表 2026-04-15 15:06:33 +08:00
田香琪
fc471a17a0 Merge branch 'invoice_view' 2026-04-14 18:06:59 +08:00
田香琪
b6a2dbc23a feat(发票管理): 新增发票详情功能和优化发票信息展示
- 在会员API中添加获取发票详情的接口
- 更新发票模态框,支持电子普通发票和增值税专用发票的详细信息展示
- 在订单详情页和支付页面优化发票信息的显示逻辑
- 增加发票信息的校验和格式化处理
2026-04-13 18:52:32 +08:00
田香琪
b854f40cc7 feat(虚拟销量管理): 新增单个及批量设置商品规格虚拟销量的API接口 2026-04-01 19:00:58 +08:00
田香琪
a2cd9aebe8 Merge branch 'virtual_sales_volume' 2026-04-01 17:30:10 +08:00
田香琪
2e8d257140 refactor(router): remove unused message template route 2026-04-01 17:29:19 +08:00
田香琪
379fed15b3 feat(虚拟销量管理): 新增虚拟销量设置页面 2026-04-01 17:14:08 +08:00
pikachu1995@126.com
afa3290f11 refactor(router): 移除消息模版路由配置 2026-03-31 16:02:11 +08:00
田香琪
5593ebe574 feat(商品分组): 新增商品分组管理功能
- 添加商品分组相关API接口,包括分页获取、详情查询、增删改操作
- 在商品管理页面支持批量设置商品分组功能
- 新增商品分组管理页面,支持分组的增删改查
- 配置路由以支持商品分组页面访问
2026-03-27 17:40:36 +08:00
pikachu1995@126.com
b18999900d feat(会员): 新增会员分组功能
- 添加会员分组管理页面,支持增删改查
- 在会员列表页新增分组筛选和批量设置分组功能
- 扩展会员管理 API,添加分组相关接口
- 配置路由以支持分组页面访问
2026-03-24 16:24:50 +08:00
pikachu1995@126.com
03ccdf2d43 feat: 添加微信视频号管理模块和商品定时上下架功能
- 新增微信视频号管理页面,包含概况、订单、退单、商品、分类和设置六个标签页
- 实现微信视频号相关API接口,包括类目、商品、订单、退单和概况数据查询
- 在商品管理页面添加批量定时上下架功能,支持选择状态和触发时间
- 优化批量操作下拉菜单,整合上架、下架、定时上下架、物流模板设置和删除功能
- 改进设置页面样式,增强按钮布局的响应式设计
2026-03-15 17:34:50 +08:00
misworga831
40dc2b5d2f feat(商品参数): 优化销售模式处理及商品参数验证逻辑 2026-01-08 09:29:39 +09:00
misworga831
50960ed899 fix(客服跳转): 修复IM链接中ID的获取逻辑 2026-01-07 17:34:34 +09:00
misworga831
ede1de664a feat(商品详情): 增加商品参数初始化和详细信息获取的参数检查 2026-01-07 16:52:18 +09:00
pikachu1995@126.com
6884a89fe5 feat(会员积分): 添加会员积分统计功能
- 新增查询会员积分统计的API接口
- 在积分页面添加积分统计卡片展示总发放积分和未使用积分
- 调整表格列宽样式
2025-12-26 14:04:29 +08:00
pikachu1995@126.com
676410a108 feat(会员管理): 新增会员余额和积分管理功能
- 添加会员余额增加和积分修改的API接口
- 在会员列表页增加余额显示列
- 新增余额增加和积分修改的弹窗及操作逻辑
- 优化会员列表操作列,使用下拉菜单整合更多操作
2025-12-26 12:37:58 +08:00
pikachu1995@126.com
b37e12b7a7 feat(商品参数): 新增参数管理功能并优化相关页面
- 新增参数编辑页面,支持参数名称、必填、索引等属性配置
- 优化参数列表页面,增加搜索和分页功能
- 重构商品发布页面的参数展示逻辑
- 移除商品模板相关功能
- 优化品牌管理页面的分类关联功能
- 调整商品详情页参数展示样式
2025-12-22 14:38:52 +08:00
pikachu1995@126.com
8a0f5ff120 fix(goods): 移除下载结算单接口URL中的多余斜杠 2025-12-09 16:44:18 +08:00
chc
c5fecda212 先注释掉未实际使用功能,后续优化密码修改内容 2025-12-01 18:55:46 +08:00
pikachu1995@126.com
e5d98d022e feat(manager): 调整分页组件默认页面大小为20
- 将所有页面的默认页面大小从10调整为20
- 更新分页选项数组,将最小值从10改为20
- 统一所有分页相关组件的pageSize默认值为20
- 修改pageSizeOpts选项为[20, 50, 100]
- 确保所有涉及分页的业务逻辑使用新的默认值
2025-11-24 13:09:11 +08:00
pikachu1995@126.com
799184fd21 style(components): 将按钮组件替换为链接样式
- 统一将页面中的 Button 组件替换为 a 标签,保持样式一致
- 添加统一的链接样式类 ops-link 和分隔符样式类 ops-sep
- 更新操作列中按钮的样式,使用颜色、光标和文本装饰属性替代原有 props 配置
- 在多个文件中调整了操作项之间的分隔符显示逻辑
- 优化表格操作列渲染函数,去除冗余的 Button 属性配置
- 保持功能不变的前提下提升界面视觉一致性
2025-11-24 11:33:54 +08:00
pikachu1995@126.com
756cb28daa fix(api): 修正更新隐私协议的API路径格式
feat(ui): 在顶部导航栏添加客服按钮及样式
添加客服按钮功能,包括获取IM链接和用户信息验证

refactor(order): 重构分销订单状态显示逻辑
统一订单状态显示为"未完成"、"完成"和"退款",并调整对应颜色
2025-11-23 16:30:41 +08:00
lifenlong
5f6eb6a26d feat(积分商品分类): 添加分页功能并支持按排序值排序
在积分商品分类页面添加分页组件,支持分页查询和排序
新增排序值字段展示,并对分类列表按排序值进行递归排序
2025-11-20 15:32:46 +08:00
pikachu1995@126.com
4efbec5741 refactor(分页): 统一调整分页大小选项为[20, 50, 100]并更新默认pageSize
style(售后订单): 优化商品图片样式,添加圆角和固定宽高
docs(售后订单): 更新售后状态显示文本,使其更清晰易懂
2025-10-27 18:34:01 +08:00
Ryan Ran
86dbc0d86e docs(README): 更新安装指导,新增yarn安装和启动步骤,移除npm相关内容以简化用户体验 2025-10-22 15:01:21 +08:00
pikachu1995@126.com
c32d011851 docs(readme): 更新交流方式和联系方式- 移除了旧的QQ群交流信息
- 新增微信交流群二维码
- 添加在线客服链接
- 精简了联系方式展示方式
- 统一了交流渠道描述格式
2025-10-20 14:52:33 +08:00
pikachu1995@126.com
311ca35afc feat(商品设置): 添加ES商品索引管理功能
新增三个API接口用于管理ES商品索引:
1. 删除ES中下架的商品
2. 删除不存在的索引
3. 生成所有商品的缓存
同时在商品设置页面添加对应操作按钮
2025-10-20 13:43:34 +08:00
Ryan Ran
1a3fae6501 fix(README & package.json): 更新 README 文档以反映 Node.js 版本兼容性,移除 NODE_OPTIONS 环境变量的依赖,确保项目在 Node.js 16 及以上版本下正常运行,优化用户安装指导。 2025-10-10 15:23:27 +08:00
Ryan Ran
3432530de7 fix(README.md): 更新文档以兼容更高的 Node.js 版本(20、18、16),并提供安装指导,确保用户能够顺利拉取最新代码和处理依赖问题。 2025-09-30 14:03:43 +08:00
Ryan Ran
af85493863 fix(buyer端升级node版本): 添加 .npmrc 和 .yarnrc 文件以支持引擎配置,更新 package.json 中的 NODE_OPTIONS 环境变量,替换多个组件中的 /deep/ 选择器为 ::v-deep,优化依赖管理,确保兼容性,兼容node16版本 2025-09-30 13:33:22 +08:00
Ryan Ran
ae85f0d612 fix(manager端升级node版本): 更新多个组件的样式,替换 /deep/ 选择器为 ::v-deep,替换 node-sass 为 sass,优化依赖管理和兼容性,兼容node16版本 2025-09-30 11:38:38 +08:00
Ryan Ran
16c4a78e29 fix(兼容性): 更新 seller 项目的 package.json 和 README.md,添加 NODE_OPTIONS 环境变量以支持 Node.js 版本 20 和 18 的运行,详细说明各版本的兼容性和安装要求,优化文档以提升用户体验。 2025-09-30 11:04:12 +08:00
Ryan Ran
8fb87aa64d feat(seller端升级node版本): 添加 .npmrc 和 .yarnrc 文件以支持引擎配置,更新 package.json 中的引擎要求并替换 node-sass 为 sass,优化依赖管理和兼容性,兼容node16版本 2025-09-29 18:26:52 +08:00
Ryan Ran
c5d675b6d2 fix(商品规格): 优化规格值处理逻辑,确保在规格值为空时移除对应行并更新表格数据,增强用户体验 2025-09-23 19:17:24 +08:00
Ryan Ran
f4d06b2d7b fix(商品审核): 更新审核功能,添加请求头以支持表单数据提交,提升接口兼容性 2025-09-23 14:14:43 +08:00
Ryan Ran
bb610a7cd8 fix(商品审核): 优化审核功能,使用FormData处理请求数据,提升代码可读性和维护性 2025-09-23 14:01:53 +08:00
misworga831
bb864e72b3 fix(商品规格): 优化规格值编辑逻辑,避免空值提交并清理错误提示 2025-09-15 21:45:47 +09:00
misworga831
c6c4797d87 fix(商品规格): 修复规格值过滤逻辑,确保移除空值并更新表格数据 2025-09-15 21:03:33 +09:00
misworga831
25fed42395 fix(商品规格): 添加内联错误提示,优化规格值验证逻辑 2025-09-15 16:04:48 +09:00
misworga831
66c4676493 fix(商品规格): 更新规格值提示信息,增加用户操作指导 2025-09-12 22:35:03 +09:00
misworga831
f8747b5d5d fix(商品操作): 优化模板和方法中的代码格式,提升可读性 2025-09-12 22:19:38 +09:00
misworga831
0eb58d4b8a fix(商品规格): 修复规格值编辑逻辑,避免对未定义索引的访问 2025-09-11 19:52:35 +09:00
pikachu1995@126.com
7b30ea343f feat(商品管理): 添加商品复制功能并优化批量操作
- 在商品操作页面添加复制商品功能,通过copyId参数区分复制操作
- 复制商品时自动清除原商品ID确保提交为新商品
- 优化批量操作按钮功能,包括上架、下架、删除和设置物流模板
- 移除未使用的批量规格更新功能
- 调整商品列表操作按钮布局,将复制功能加入操作列
2025-09-09 17:04:58 +08:00
pikachu1995@126.com
7db8484a7c feat(售后订单): 添加售后数量统计功能并显示在标签页
- 在manager和seller端添加获取售后数量统计的API接口
- 修改售后订单页面,显示各状态对应的数量统计
- 新增计算属性serviceStatusWithCount动态生成带数量的标签页
- 在初始化、搜索和状态切换时调用统计接口更新数据
2025-08-29 11:36:45 +08:00
pikachu1995@126.com
78b058009b feat(订单/商品): 添加数量统计功能并优化列表展示
- 在订单和商品管理页面添加数量统计功能
- 优化订单和商品列表的筛选和展示样式
- 统一API接口参数传递方式
- 移除重复代码和无用代码
2025-08-29 09:40:36 +08:00
pikachu1995@126.com
05abad3905 feat(会员管理): 新增会员ID搜索和头像显示功能
refactor(订单管理): 移除表格边框并优化分页选项

feat(售后订单): 添加关键字搜索字段并移除二维码功能

feat(商品管理): 新增批量操作和审核功能,优化商品列表展示

style: 移除多余的margin-top样式
2025-08-27 16:15:11 +08:00
pikachu1995@126.com
ad178b1806 Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2025-08-27 10:12:05 +08:00
pikachu1995@126.com
075c20ae41 feat(订单列表): 新增搜索字段和支付方式筛选功能
- 添加关键字、商品名称、收货人和支付方式搜索字段
- 增加支付方式显示列和筛选功能
- 调整默认分页大小为20并增加100条选项
- 优化订单状态筛选逻辑
2025-08-27 10:11:40 +08:00
Ryan Ran
56420bd4d0 feat: 添加pageClientType属性以支持页面类型设置
在`renovation.vue`文件中,新增`pageClientType`属性并设置为'PC',以便于在页面数据中区分客户端类型,提升页面功能的灵活性。
2025-08-26 10:40:19 +08:00
pikachu1995@126.com
a18fc729c0 style(oss-manage): 调整表单元素宽度并优化布局结构
将日期选择器和输入框的宽度从200px统一调整为240px,提升表单元素的一致性
拆分操作区域到单独的Card组件中,改善页面布局结构
2025-08-21 16:41:46 +08:00
pikachu1995@126.com
099941a54e style: 统一输入框和选择器宽度为240px并优化卡片布局
- 将多处输入框和选择器的宽度从200px调整为240px
- 优化卡片布局结构,添加分割卡片提升可读性
- 移除部分冗余的刷新按钮和样式代码
- 调整日期选择器等表单元素的宽度为240px
2025-08-21 16:41:27 +08:00
Ryan Ran
3867d51d92 fix: 移除管理端会员列表,回收站username字段,优化成员列表组件的属性结构 2025-08-13 18:18:41 +08:00
Ryan Ran
f1be18b7e3 添加vue-virtual-scroller依赖并在消息视图中实现虚拟滚动功能,以优化对话列表的渲染性能。同时,为manager和seller项目添加minimatch依赖项的解析版本以解决潜在的版本冲突。 2025-07-15 16:16:42 +08:00
Ryan Ran
ef46380f9e 添加minimatch依赖项的解析版本以解决潜在的版本冲突 2025-07-04 17:16:47 +08:00
chc
d711273cf0 商品发布参数项校验无法some问题 2025-03-12 10:28:59 +08:00
chc
5e7cf2d679 Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2025-03-11 19:09:39 +08:00
chc
11319ba21f 分类参数必填校验 2025-03-11 19:09:05 +08:00
Chopper711
9c51523352 refactor(coupon-activity): 注释掉邀新赠券选项 (master)
注释掉了"邀新赠券"的Radio按钮,以简化界面。
2025-02-21 18:44:21 +08:00
Chopper711
b4a706820e ### feat: 优化优惠折扣输入组件 (master)
- 将`Input`组件替换为`InputNumber`组件,以确保输入的优惠折扣
  在0.1到9.9之间,并支持一位小数。
- 在`full-discount-add.vue`和`full-discount-detail.vue`文件中,
  更新了`FormItem`组件的`优惠折扣`输入逻辑。
- 在`coupon-publish.vue`文件中,更新了描述文本以强调输入
  范围和格式。
- 在`coupon.vue`文件中,修复了优惠券类型判断逻辑,增加了
  "DISCOUNT"类型和未知类型的处理。
2025-02-21 17:34:58 +08:00
Chopper711
376a3223dc fix: 优化积分商品添加页面和对账单详情页面的功能 (master)
- 移除积分商品添加页面的冗余标题
- 将兑换时间选择器替换为活动时间范围选择器
- 简化验证规则,使用rangeTime替代单独的开始和结束时间
- 调整批量删除按钮和其他按钮的样式格式
- 在对账单详情页面添加删除消息提示框功能
2025-02-21 16:09:14 +08:00
Chopper711
a85ec69549 fix: 消息提示框以及消息点击查看后需要标记已读按钮优化 2025-02-20 15:48:19 +08:00
Chopper711
ebf4d6d3eb Merge branch 'master' of gitee.com:beijing_hongye_huicheng/lilishop-ui 2025-02-20 14:57:21 +08:00
Chopper711
2ccd8265b9 fix: 消息提示框以及消息点击查看后需要标记已读按钮优化 2025-02-20 14:52:53 +08:00
chc
fb1671d062 Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2025-02-17 13:41:42 +08:00
chc
95e37df2ec 电子面单物流选中错误问题 2025-02-17 13:41:05 +08:00
pikachu1995@126.com
cfde72f1ab Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2025-02-11 15:43:20 +08:00
pikachu1995@126.com
efc778acbc 微信支付配置,添加配置使用公钥还是证书 2025-02-11 15:43:09 +08:00
misworga831
37384ebadd fix: 优化商品编辑 2025-01-21 13:53:07 +08:00
misworga831
9a7aee7e97 fix: 优化商品编辑 2025-01-20 18:43:21 +08:00
chc
1a56534d28 提现设置增加校验 2025-01-20 18:12:41 +08:00
chc
79e7e5f087 Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2025-01-17 17:42:58 +08:00
chc
aea03cf975 支付设置选中状态错误问题 2025-01-17 17:42:49 +08:00
pikachu1995@126.com
8ef6f944dc 微信支付配置,添加微信支付公钥 2025-01-08 16:14:14 +08:00
pikachu1995@126.com
e18d243961 优化微信支付配置,不读取密钥文件,改成读配置文件 2024-12-27 11:01:53 +08:00
chc
765b633cea Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2024-12-25 10:35:50 +08:00
chc
9c402a3878 1.查看物流信息
2.直播图片选择器修改
3.管理端隐藏会员详情
2024-12-25 10:35:40 +08:00
Chopper711
86426b087a feat: 使用InputNumber组件替换折扣输入框 (master)
在coupon-publish.vue中,将折扣输入框从Input组件更改为
InputNumber组件,以便更好地控制折扣值的输入范围。
2024-12-23 11:51:05 +08:00
chc
c5aaa2f3e2 Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2024-12-02 18:05:51 +08:00
chc
cc59bb6094 售后实际退款金额显示错误问题 2024-12-02 18:05:43 +08:00
Ryan Ran
5662da1210 fix: 修改商家端分销商品列表点击链接商品不展示问题 2024-11-25 10:43:49 +08:00
Ryan Ran
863725d3ba 优化管理端活动楼层装修 2024-11-11 15:23:32 +08:00
Yer11214
b1acfbdd4b !40 修复了行政地区切换验证状态没更新的问题
Merge pull request !40 from 孟锴/feature/mengkai-master-branch
2024-10-14 07:34:03 +00:00
mengkai
001f9c3f30 修复了行政地区切换验证状态没更新的问题 2024-10-14 11:26:26 +08:00
Yer11214
8d4df4b66e Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2024-10-07 22:26:15 +08:00
Yer11214
604447508a fix: oss选择图片不展示报错bug 2024-10-07 22:26:13 +08:00
misworga831
6f30949620 fix: 移动端楼层装修促销活动移除优惠券.pc端楼层装修快捷导航外部链接无法填写问题 2024-09-23 21:18:27 +08:00
chc
10e8e82fe4 增加部分发货状态 2024-09-18 15:25:04 +08:00
Yer11214
5227e0ae13 优惠券列表样式修改 2024-09-08 23:10:23 +08:00
misworga831
cf193f4eab fix: 优化店铺oss分组删除问题 2024-09-05 14:29:10 +08:00
RyanRan
708ae53fe2 docs: seller 端 axios修改 timeout过期时间 2024-07-24 14:45:46 +08:00
Yer11214
aed57177dd update README.md.
Signed-off-by: Yer11214 <17633066053@163.com>
2024-07-19 02:12:28 +00:00
Yer11214
e916a03ff8 update README.md.
Signed-off-by: Yer11214 <17633066053@163.com>
2024-07-19 02:10:18 +00:00
pikachu1995@126.com
859d60c0bf Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2024-07-18 14:30:27 +08:00
pikachu1995@126.com
1eeaf38638 优化店铺资源管理页面 2024-07-18 14:30:14 +08:00
RyanRan
ca09bbe3fc Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2024-07-18 14:05:02 +08:00
RyanRan
57b42bdc66 删除.npmrc以及.yarnrc命令 2024-07-18 14:05:00 +08:00
misworga831
88c31d157e fix: 修复商品编辑规格第一个规格值无效问题 2024-07-18 09:44:46 +08:00
RyanRan
c738274423 Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2024-07-17 14:52:42 +08:00
RyanRan
1c62ee6efe docs: 删除package.json中无用的引用导致报错 2024-07-17 14:52:22 +08:00
Yer11214
4fcb24607d update README.md.
修改readme文档

Signed-off-by: Yer11214 <17633066053@163.com>
2024-07-12 06:26:28 +00:00
RyanRan
52ce1e795c fix: buyer端删除 package-lock.json 2024-07-10 13:15:29 +08:00
pikachu1995@126.com
39c4593d9e 优化支付、短信的配置内容 2024-07-07 14:04:19 +08:00
pikachu1995@126.com
6ae288cf01 Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2024-07-02 14:56:20 +08:00
pikachu1995@126.com
bab54570fe 优化结算单详情以及分销相关页面展示 2024-07-02 14:56:10 +08:00
Yer11214
2551c76d97 删除文件 buyer/yarn.lock 2024-07-02 03:15:18 +00:00
pikachu1995@126.com
e3a5583802 商家增加库存导出导入api 2024-06-28 10:44:53 +08:00
pikachu1995@126.com
b03adc1b1f 优化微信支付的参数配置 2024-06-28 09:09:01 +08:00
pikachu1995@126.com
6e9d725f6d 支持店铺通过excel批量更新商品库存功能 2024-06-27 17:09:58 +08:00
pikachu1995@126.com
92559ca9e7 优化订单导出功能 2024-06-26 11:51:22 +08:00
misworga831
3bfa99f578 fix: 修复优惠券使用范围中的部分商品分类 2024-06-14 11:30:53 +08:00
misworga831
30b85163b7 fix: 临时注释优惠券使用范围中的部分商品分类(后续改为部分店铺分类) 2024-06-13 16:11:00 +08:00
15386982806
ef528a2952 OSS资源修改分组 2024-05-27 16:41:34 +08:00
lele0521
907e9d35a7 fix: 增加订单备注 2024-05-15 17:56:06 +08:00
lele0521
d003e5da70 fix: 增加订单备注 2024-05-15 16:57:27 +08:00
lele0521
19530988ce Merge remote-tracking branch 'origin/master' 2024-05-15 15:57:00 +08:00
lele0521
4e9baebcf5 fix: 增加订单备注 2024-05-15 15:56:50 +08:00
RyanRan
3a1cbfe8bb fix: 🐛 修改昨天提交判断出错bug 2024-05-10 17:36:55 +08:00
RyanRan
c16b37c8bc feat: ✨ pc端新增专题功能 2024-05-09 18:38:22 +08:00
15386982806
0c6703f04d 修改促销活动展示问题 2024-04-23 11:28:44 +08:00
lele0521
3471caf00a fix: 商品导入后,导入动态样式未取消 2024-04-22 16:34:09 +08:00
lele0521
ad28e70a07 fix: 运营后台首页统计数据,处理默认数据显示 2024-03-29 11:46:34 +08:00
lele0521
47315963f1 fix: 订单支付超时跳转对应页面 2024-03-27 15:36:56 +08:00
15386982806
fb8fa9e787 修改OSS资源回显 2024-03-26 18:07:20 +08:00
lele0521
223fa7ca05 fix: 修复商品评价中商家回复信息的展示 2024-03-25 16:31:03 +08:00
Yer
b568c242e3 perf: 🐛 修改个人资料后应该直接刷新页面,获取最新数据 2024-03-24 09:02:15 +08:00
15386982806
e4283e1c53 修改编辑会员地址 2024-03-21 19:19:59 +08:00
15386982806
25f9cc1095 修改编辑店铺入参 2024-03-21 18:51:39 +08:00
15386982806
85729e3761 我的评价展示商家回复 2024-03-21 17:38:47 +08:00
15386982806
fa0a0cb1d8 优化订单统计展示、修改店铺校验 2024-03-21 17:12:16 +08:00
15386982806
e7ee256b80 会员回收站列表 修改器启用提示信息 2024-03-21 16:19:02 +08:00
lele0521
36190438f0 fix: OSS资源管理运营端增加按店铺名称搜索 2024-03-19 17:58:45 +08:00
chc
ce629a1fb6 PC端增加修改价格字段 2024-03-15 16:29:13 +08:00
15386982806
f989d1ef4e OSS资源回显问题 2024-03-15 10:35:25 +08:00
chc
99455dee58 Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2024-03-11 16:08:13 +08:00
chc
c7bd7223bc 多货物订单无售后按钮 2024-03-11 16:08:05 +08:00
chc
8166f90f0f 多SKU订单无申请售后按钮 2024-03-11 15:49:35 +08:00
15386982806
8b2587daa7 修改商家端素材管理样式 2024-03-07 19:32:25 +08:00
Yer
82e047c797 style: 2024-03-07 15:48:22 +08:00
misworga831
3cefb40ccb merge conflict 2024-02-28 09:14:38 +08:00
misworga831
8bba4a1e48 fix: 修复编辑商品sku规格项时,值为空鼠标移出后显示错误问题 2024-02-28 09:13:16 +08:00
Yer
a12f317aa8 fix: 🐛 修改部分页面点击回车刷新页面bug 2024-02-27 11:56:43 +08:00
Chopper711
1d133af38d fix: 淘宝镜像源地址https证书过期问题处理 2024-02-19 18:06:26 +08:00
chc
9cab354cbb 去除商家修改收货地址的长度限制 2024-02-19 15:04:05 +08:00
chc
c0385414ca 隐藏普通发货功能,使用分包裹发货 2024-02-18 16:12:26 +08:00
Yer
4f7846dc4e Merge branch 'master' of gitee.com:beijing_hongye_huicheng/lilishop-ui 2024-02-02 18:29:12 +08:00
Yer
01beac9265 fix: 🐛 优化管理端商品分类添加/修改/删除 之后原有的 层级关闭问题 2024-02-02 18:29:10 +08:00
15386982806
77ad146e24 商家端优化OSS资源管理 2024-02-02 18:09:29 +08:00
15386982806
6c1c47de1d 管理端优化OSS资源管理 2024-02-02 17:24:57 +08:00
lele0521
b3cac070df fix: 管理端-结算单流水去除(flowType: "PAY") 2024-01-30 09:39:37 +08:00
Yer
daf1b20f67 fix: 🐛 修改订单设置退单描述问题 2024-01-29 14:53:04 +08:00
Yer
2316fb8d11 fix: 🐛 修改订单配置中描述错误bug 2024-01-29 14:08:24 +08:00
lele0521
43159335f8 Revert "分销结构优化"
This reverts commit 63b22f4c86.
2024-01-26 13:35:47 +08:00
misworga831
0e56c2038d Merge branch 'master' of gitee.com:beijing_hongye_huicheng/lilishop-ui 2024-01-25 17:51:09 +08:00
misworga831
0840740e2c fix: 优化秒杀展示 2024-01-25 17:50:55 +08:00
15386982806
e2404f0f3b Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2024-01-25 13:20:57 +08:00
15386982806
5e9a258cc8 隐私协议右侧添加手机查看效果 2024-01-25 13:20:46 +08:00
misworga831
8cfc420085 fix: 买家端虚拟商品订单不显示地址信息 2024-01-25 09:36:04 +08:00
misworga831
83593bd015 fix: 优化虚拟商品不需要收货地址 2024-01-24 19:23:34 +08:00
pikachu1995@126.com
c1cdf97edb Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2024-01-24 14:58:11 +08:00
pikachu1995@126.com
eca8370c70 结算单详情调优 2024-01-24 14:58:01 +08:00
chc
54502b331f Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2024-01-24 11:33:21 +08:00
chc
0ec5955ea0 库存预警,分类搜索问题 2024-01-24 11:33:12 +08:00
pikachu1995@126.com
b70a9bf0c0 Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2024-01-24 09:47:11 +08:00
pikachu1995@126.com
fb20cb32da 结算单详情调优 2024-01-24 09:46:59 +08:00
chc
a314373e64 商品编辑数据可能被覆盖问题 2024-01-23 16:51:10 +08:00
pikachu1995@126.com
4976b87c4e Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2024-01-23 10:33:08 +08:00
misworga831
3b9e54375a fix: 修复删除sku图片无效问题 2024-01-23 10:06:36 +08:00
misworga831
8b15cfb0b1 fix: 兼容旧版本图片格式 2024-01-22 18:11:25 +08:00
pikachu1995@126.com
ff02cce191 Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2024-01-22 15:24:41 +08:00
misworga831
83beb81a3b fix: 兼容旧版本图片格式 2024-01-22 14:39:43 +08:00
misworga831
de89249d3e improved: 优化sku编辑 2024-01-22 14:09:00 +08:00
chc
77732e766a Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2024-01-18 11:46:30 +08:00
chc
21c91cfcf1 库存预警 2024-01-18 11:46:11 +08:00
Yer
ebaf5e34d8 fix: 🐛 修改商家端订单评论发现的bug 和后端字段不统一 2024-01-17 18:35:11 +08:00
15386982806
94e389ab23 编辑商品字符长度限制 2024-01-16 18:09:11 +08:00
15386982806
7ba94c7fe1 编辑商品详情 2024-01-16 17:54:28 +08:00
15386982806
c47394a162 Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2024-01-16 17:29:39 +08:00
15386982806
bf8afdc0f4 买家端订单状态 2024-01-16 17:29:19 +08:00
chc
3df17ed33f Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2024-01-16 17:28:33 +08:00
chc
dc6d3b3743 Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2024-01-16 17:28:25 +08:00
15386982806
6e626f6e11 买家端订单退款状态 2024-01-16 17:27:05 +08:00
chc
d20da31f2c 库存预警 2024-01-16 17:26:09 +08:00
15386982806
2182cd012a 商品详情展示此规格的价格 2024-01-16 16:33:43 +08:00
15386982806
71c629cb06 运费模板 品牌 计量单位刷新成功提醒 2024-01-16 11:12:07 +08:00
15386982806
185d675abf 库存预警还原 2024-01-16 10:45:03 +08:00
chc
6221b59b21 库存预警回退 2024-01-16 10:18:39 +08:00
15386982806
07f320ca2f 修改订单查询物流样式 2024-01-11 14:45:17 +08:00
15386982806
9908b83b18 商家端优化库存预警,管理端 商家端隐藏所有促销活动的删除按钮 2024-01-11 13:32:39 +08:00
15386982806
9ce1c966eb 订单支持分包裹发货 2024-01-10 19:15:24 +08:00
15386982806
c81eff58d3 上传图片采用素材管理 2024-01-09 17:09:23 +08:00
15386982806
92b61a203f 商家端图片上传采用素材管理上传 2024-01-09 16:39:52 +08:00
15386982806
b1f9c0adf2 商家端添加OSS资源素材管理 2024-01-08 17:59:49 +08:00
15386982806
c5d75a3570 运营后台商品详情展示此规格的价格 2024-01-05 16:28:36 +08:00
15386982806
42c64920b1 商品规格字符长度限制 2024-01-05 15:54:10 +08:00
15386982806
77e7d095a7 运费模板 品牌 计量单位 支持刷新功能 2024-01-05 15:10:48 +08:00
15386982806
560199944d PC端订单货物展示退款状态与实际退款金额 2024-01-05 14:00:22 +08:00
15386982806
21ae217369 订单取消状态改为已关闭、订单货物展示退款数量 2024-01-05 10:08:37 +08:00
mahe
4565297fe6 Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2023-12-22 17:41:30 +08:00
mahe
118e68b8ac fix: 修复楼层装修跳转错误 2023-12-22 17:41:19 +08:00
Yer
a03f908e96 style: 🐛 修改店铺入驻协议样式问题 2023-12-22 17:35:51 +08:00
Yer
0b6dcf17d1 fix: 🐛 修改 im cdn 2023-12-22 17:13:18 +08:00
Yer
5136f8b3dd fix: 🐛 修改商家端楼层装修bug 2023-12-22 14:31:39 +08:00
mahe
286295393d 发布商品增加局部刷新与去除成本价 2023-12-18 18:04:44 +08:00
mahe
195ea35798 修改图片超出内容问题 2023-12-18 15:35:20 +08:00
mahe
053279f60d 修改店铺分类未展示 2023-12-18 14:43:58 +08:00
Chopper711
9bfb7e8b69 Merge branch 'master' of gitee.com:beijing_hongye_huicheng/lilishop-ui 2023-12-11 07:58:28 +08:00
Chopper711
c82f8a6de3 fix: 部门搜索功能bug修复 2023-12-11 07:58:22 +08:00
Yer
749147cfad feat: ✨ 商家端富文本改为本地引入 2023-12-01 11:38:31 +08:00
Yer
4429227665 feat: ✨ 商家端富文本改为本地引入 2023-12-01 11:38:01 +08:00
Yer
3ec0e9d997 feat: ✨ 新增富文本本地引入 2023-12-01 10:47:33 +08:00
Chopper711
904046f02c Merge branch 'master' of gitee.com:beijing_hongye_huicheng/lilishop-ui 2023-11-30 18:50:01 +08:00
pikachu1995@126.com
5a29215b80 Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2023-10-26 14:34:49 +08:00
misworga831
508a00d103 fix: 优化编译脚本 2023-10-18 08:14:54 +08:00
misworga831
5385d10bc8 Merge branch 'master' of gitee.com:beijing_hongye_huicheng/lilishop-ui 2023-10-16 18:20:34 +08:00
misworga831
08b4a04791 resolve conflict 2023-10-16 18:20:29 +08:00
misworga831
218cee2aef fix: 优化售后订单搜索 2023-10-16 18:16:28 +08:00
pikachu1995@126.com
63b22f4c86 分销结构优化 2023-10-16 16:00:15 +08:00
Chopper711
709a3f71fd Merge branch 'master' of gitee.com:beijing_hongye_huicheng/lilishop-ui 2023-10-13 11:14:19 +08:00
Yer
f7589e42ab Merge branch 'master' of gitee.com:beijing_hongye_huicheng/lilishop-ui 2023-10-11 16:36:38 +08:00
Yer
0e7008862e feat: 新增弹出页以及广告页面开启关闭提示 2023-10-11 16:36:36 +08:00
misworga831
7e88ee930b resolve conflict 2023-10-11 10:11:11 +08:00
chc
e935f2a3e3 Merge branch 'master' of https://gitee.com/beijing_hongye_huicheng/lilishop-ui 2023-10-10 11:29:15 +08:00
chc
9d442a2aa1 订单统计切换分页问题处理 2023-10-10 11:26:50 +08:00
Yer
eed6ba43d9 合并提交 2023-10-09 13:30:56 +08:00
Yer
b2c286f0f5 fix: 🐛 修复在统计图中可能出现的Bug 2023-10-09 13:27:13 +08:00
misworga831
2aae5a4bff fix: 修复拼团批量删除,所选数量不正确问题 2023-09-30 17:48:35 +08:00
“Yer”
c88c1865e7 fix: 🐛 修改统计图已发现的问题 2023-09-30 16:18:27 +08:00
Chopper711
7152e339e5 fix: 前后端交互数据出现问题处理 2023-09-27 07:07:22 +08:00
pikachu1995@126.com
46143beb83 添加文件-文件夹 2023-09-26 18:48:55 +08:00
Yer
2c66eaae2b fix: 🐛 修复已知统计问题 2023-09-26 15:53:13 +08:00
Yer
b8c74ac84c Merge branch 'master' of gitee.com:beijing_hongye_huicheng/lilishop-ui 2023-09-26 15:02:20 +08:00
Yer
cb210916eb style: 调整投诉详情页面的样式 2023-09-26 15:02:17 +08:00
Chopper711
f968f3d20d refactor: 重构PC分享页面,优化布局展示,增加移动端二维码展示 2023-09-25 15:02:57 +08:00
Chopper711
4ef2496221 店铺菜单管api 补充提交 2023-09-25 12:13:07 +08:00
Chopper711
b2b56178a2 菜单权限url增加长度限制 2023-09-25 12:05:05 +08:00
Chopper711
525838468c refactor: 增加店铺菜单维护页面 2023-09-25 11:58:34 +08:00
Yer
ef92bcfe3a fix: 🐛 修改部分已知的Bug 2023-09-25 10:05:41 +08:00
Yer
ed72c2ffc2 style: 关闭文章列表在楼层中添加文章功能 2023-09-22 11:51:18 +08:00
Yer
9f94c2013a style: 调整商品选择器,店铺选择器代码 2023-09-22 10:49:11 +08:00
misworga831
8666254ffe Merge branch 'master' of gitee.com:beijing_hongye_huicheng/lilishop-ui 2023-09-19 09:37:04 +08:00
Yer
e13fc57ae7 fix: 🐛 修复已知的bug 2023-09-19 09:25:57 +08:00
misworga831
c6cf90fb4e fix: 优化统计数据文本显示 2023-09-18 16:59:23 +08:00
Yer
fff086a3a1 feat: 新增商品审核中多个规格以及库存展示 2023-09-18 16:45:30 +08:00
misworga831
b7a2a5af6d fix: 修复管理端商品分类,编辑时状态显示错误问题及佣金比例限制 2023-09-18 16:04:25 +08:00
Yer
1786dfeee2 fix: 🐛 修复几个已知的bug 2023-09-18 15:36:46 +08:00
Yer
2c32088f4c feat: 优化部分代码,新增审核商品视频 2023-09-15 19:43:41 +08:00
Yer
71f61461ce Merge branch 'master' of gitee.com:beijing_hongye_huicheng/lilishop-ui 2023-09-14 18:07:44 +08:00
Yer
54be87efea fix: 🐛 解决几个发现的重置bug 2023-09-14 18:07:42 +08:00
misworga831
76fc55fdd0 improve: 统一物流单号名词 2023-09-14 16:58:35 +08:00
misworga831
5d5a845523 fix: 完善文字性描述 2023-09-14 15:06:59 +08:00
Yer
4f915a32b6 fix: 🐛 解决几个已知的bug 2023-09-14 13:58:50 +08:00
Yer
78df54d153 fix: 🐛 修复几个已知bug 2023-09-14 09:11:25 +08:00
Yer
24554a0768 feat: ✨ 编辑会员新增默认头像展示 2023-09-13 17:25:35 +08:00
Yer
ffe03fd785 feat: ✨ 买家端查看商品详情新增商品参数功能,修复几个已知的bug 2023-09-13 16:06:42 +08:00
Yer
4b635735ae Merge branch 'master' of gitee.com:beijing_hongye_huicheng/lilishop-ui 2023-09-05 11:28:26 +08:00
Yer
79c1e2c5bb fix: 🐛 修复填写支付宝微信支付秘钥被截断问题 2023-09-05 11:28:23 +08:00
Yer11214
cf9f9fb7dd style: 💄 PC端收藏商品中收藏店铺添加跳转以及优化样式 2023-08-26 13:48:04 +08:00
Yer
2bcefc1306 fix: 🐛 解决商品编辑中重新选择分类、商品参数不更新问题 2023-08-24 16:40:28 +08:00
1438 changed files with 470883 additions and 116874 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@
node_modules/ node_modules/
yarn.lock yarn.lock
package-lock.json package-lock.json
!seller/yarn.lock

682
LICENSE
View File

@@ -1,661 +1,21 @@
GNU AFFERO GENERAL PUBLIC LICENSE MIT License
Version 3, 19 November 2007
Copyright (c) 2020-2026 Beijing Hongye Huicheng Technology Co., Ltd. (北京宏业汇成科技有限公司)
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies Permission is hereby granted, free of charge, to any person obtaining a copy
of this license document, but changing it is not allowed. of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
Preamble to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
The GNU Affero General Public License is a free, copyleft license for furnished to do so, subject to the following conditions:
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software. The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast, THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
our General Public Licenses are intended to guarantee your freedom to IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
share and change all versions of a program--to make sure it remains free FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
software for all its users. AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
When we speak of free software, we are referring to freedom, not OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
price. Our General Public Licenses are designed to make sure that you SOFTWARE.
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<http://www.gnu.org/licenses/>.

201
MIGRATION-VUE3.md Normal file
View File

@@ -0,0 +1,201 @@
# Lilishop UI — Vue 3 迁移与上线前检查清单
四个子项目(buyer / seller / manager / im)已完成 **Vue 3 + Element Plus** 迁移,并完成依赖小版本对齐。本文档供上线前统一回归使用。
## 技术栈(四端一致)
| 类别 | 包 | 版本(package.json) |
|------|-----|---------------------|
| 框架 | vue | ^3.5.35 |
| 编译 | @vue/compiler-sfc | ^3.5.35 |
| 路由 | vue-router | ^4.6.4 |
| 状态 | pinia | ^4.0.3(四端已全部从 vuex 迁移完成) |
| 组件写法 | `<script setup>` | 四端 597 个组件已全部完成 Options API → 组合式 API 迁移;各端 eslint 已启用 `vue/component-api-style: ['warn', ['script-setup']]` 防止回退 |
| UI | element-plus | ^2.14.1 |
| 图标 | @element-plus/icons-vue | ^2.3.2 |
| 请求 | axios | ^1.7.9 |
| 兼容 | core-js | ^3.49.0 |
| 构建 | @vue/cli-service | ^5.0.9 |
> 使用 `^` 范围,实际安装版本以各目录 `yarn.lock` 为准。
> 要求 **Node >= 16**,包管理统一 **yarn**。
## 本地启动
```bash
# 买家端
cd buyer && yarn install && yarn dev # 端口 10000
# 商家端
cd seller && yarn install && yarn dev # 端口 10002
# 管理端
cd manager && yarn install && yarn dev # 端口 10003
# IM 客服
cd im && yarn install && yarn dev # 端口 8000
```
生产构建(上线前必跑):
```bash
cd buyer && yarn build
cd seller && yarn build
cd manager && yarn build
cd im && yarn build
```
---
## 一、通用检查(四端都要过)
### 1. 认证与请求(axios 1.x 重点)
各端请求封装位置:
| 端 | 文件 |
|----|------|
| buyer | `buyer/src/plugins/request.js` |
| seller | `seller/src/libs/axios.js` |
| manager | `manager/src/libs/axios.js` |
| im | `im/src/utils/request.js` |
- [ ] **登录**:账号密码登录成功,token 写入正常
- [ ] **登出**:清除 token,跳转登录页
- [ ] **401**:token 过期后跳转登录 / 刷新 token(buyer/manager/seller 有刷新逻辑)
- [ ] **403**:无权限时提示文案正确、不白屏
- [ ] **400 / 5xx**:接口报错能弹出 `Message` / `ElMessage` / `Notification`
- [ ] **网络超时**:断网或超时后有错误提示,页面不卡死
- [ ] **GET 带参**:列表筛选、分页参数序列化正常
- [ ] **POST JSON**:`Content-Type: application/json` 请求体正确
- [ ] **POST 表单**:`application/x-www-form-urlencoded` 提交正常(im 等)
### 2. Element Plus UI(2.6 → 2.14 重点)
- [ ] **表格** `el-table`:列展示、排序、空数据、加载态
- [ ] **分页** `el-pagination`:翻页、每页条数切换
- [ ] **表单** `el-form`:必填校验、错误提示位置
- [ ] **弹窗** `el-dialog` / `ElMessageBox`:打开、确认、取消、关闭
- [ ] **下拉** `el-select` / `el-dropdown`:选项、点击外部关闭
- [ ] **消息** `ElMessage` / `$Message` 兼容层:success / error / warning
- [ ] **加载** `ElLoading` / `v-loading`:请求期间遮罩正常消失
- [ ] **日期** `el-date-picker`:选择、清空、范围选择
- [ ] **上传** `el-upload`:选文件、进度、成功 / 失败回调
### 3. 路由与页面
- [ ] 浏览器 **刷新** 当前路由不 404(history 模式 + 服务端配置)
- [ ] **动态路由** 加载正常(seller / manager 菜单权限路由)
- [ ] 浏览器 **前进 / 后退** 正常
- [ ] 各端 **404 页** 可回到首页
### 4. 构建与部署
- [ ] 四端 `yarn build` 无 error(warning 可记录,不阻断)
- [ ] 生产环境 API 地址配置正确(`config` / 环境变量)
- [ ] 静态资源 `publicPath` 与 Nginx / CDN 路径一致
- [ ] gzip 压缩包(如有)可正常访问
---
## 二、分端专项检查
### Buyer 买家端(10000)
详见 [buyer/MIGRATION-VUE3.md](./buyer/MIGRATION-VUE3.md)
- [ ] 首页装修模块渲染(轮播、商品列表、秒杀区等)
- [ ] 商品详情:规格选择、加购、收藏、`el-image` 预览
- [ ] 购物车 → 结算 → 支付流程
- [ ] 用户中心:订单列表、地址、优惠券、售后
- [ ] 登录 / 注册 / 忘记密码
- [ ] 店铺入驻、商户页
- [ ] 地图(高德 `@amap/amap-jsapi-loader`)定位与选点
- [ ] 侧边栏 `el-menu`、头部 `el-dropdown`、动态图标 `iconMap.js`
### Seller 商家端(10002)
详见 [seller/MIGRATION-VUE3.md](./seller/MIGRATION-VUE3.md)
- [ ] 商家登录、店铺信息
- [ ] 商品发布三步流程(`goodsOperation` 步骤条)
- [ ] 订单管理:发货、退款、详情
- [ ] 促销 / 分销 / 统计图表(`@antv/g2`)
- [ ] 页面装修(wap / modelList,从 manager 同步部分)
- [ ] 图片懒加载 `vue-lazyload`
- [ ] Excel 导出 `vue-json-excel`
### Manager 管理端(10003)
详见 [manager/MIGRATION-VUE3.md](./manager/MIGRATION-VUE3.md)
- [ ] 管理员登录、权限菜单、动态路由
- [ ] 系统设置(`sys/setting-manage` 各 Tab)
- [ ] 菜单 / 部门管理
- [ ] 页面装修(PC + H5)
- [ ] 文章管理
- [ ] 商品 / 订单 / 会员 / 促销等核心列表 CRUD
- [ ] 富文本 TinyMCE 编辑、图片上传
- [ ] 多语言 `vue-i18n` 切换(如有启用)
### IM 客服端(8000)
详见 [im/MIGRATION-VUE3.md](./im/MIGRATION-VUE3.md)
- [ ] 带 token 参数进入会话页
- [ ] 对话列表加载、切换会话、未读数
- [ ] 发送文字、表情、图片消息
- [ ] 商品 / 订单卡片链接跳转(`linkToGoods` / `linkToOrders`)
- [ ] 右键菜单(复制、撤回、删除等)
- [ ] WebSocket 连接 / 断线重连 / 新消息通知
- [ ] 最近浏览、订单列表侧栏(商家场景)
- [ ] 用户名片弹层 `$user()`
---
## 三、已知可忽略项
| 项 | 说明 |
|----|------|
| `vue-awesome-swiper` peer warning | 历史依赖,Vue 3 下仍有 peer 告警,功能正常可暂忽略 |
| im 内 `<i class="el-icon-*">` | Element Plus 无字体图标,部分旧图标可能不显示,不影响核心功能 |
| `vue-prism-editor@2.0.0-alpha.2` | im 代码块编辑器刻意锁定 alpha 版 |
| 打包体积 warning | chunk-vendors 偏大,属 Element Plus 全量引入,可后续做按需优化 |
---
## 四、问题记录模板
上线测试时建议按端记录:
```markdown
### [端名] 问题标题
- 页面/路由:
- 操作步骤:
- 预期:
- 实际:
- 控制台报错:
- 优先级:P0 / P1 / P2
```
---
## 五、子项目文档索引
| 端 | 迁移说明 |
|----|----------|
| buyer | [buyer/MIGRATION-VUE3.md](./buyer/MIGRATION-VUE3.md) |
| seller | [seller/MIGRATION-VUE3.md](./seller/MIGRATION-VUE3.md) |
| manager | [manager/MIGRATION-VUE3.md](./manager/MIGRATION-VUE3.md) |
| im | [im/MIGRATION-VUE3.md](./im/MIGRATION-VUE3.md) |
---
## 六、建议上线顺序
1. **manager**(后台配置源头)
2. **seller**(商家经营)
3. **buyer**(C 端流量最大)
4. **im**(客服,依赖前后端 WebSocket)
每上一端,至少完成本文 **第一节通用检查** + 对应 **第二节分端检查** 后再切下一端。

144
README.en.md Normal file
View File

@@ -0,0 +1,144 @@
<div align="center">
<img src="./manager/src/assets/logo.png" width="180" alt="Lilishop Logo" />
<h1>Lilishop(PickMall) - Web Frontend (Vue 3)</h1>
<p>
<a href="https://github.com/lilishop/lilishop"><img src="https://img.shields.io/github/stars/lilishop/lilishop.svg?style=social&logo=github" alt="GitHub Stars" /></a>
<a href="https://gitee.com/beijing_hongye_huicheng/lilishop"><img src="https://gitee.com/beijing_hongye_huicheng/lilishop/badge/star.svg?theme=dark" alt="Gitee Stars" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" /></a>
<a href="https://vuejs.org/"><img src="https://img.shields.io/badge/Vue-3.5-brightgreen.svg" alt="Vue 3" /></a>
<a href="https://element-plus.org/"><img src="https://img.shields.io/badge/Element%20Plus-2.x-409EFF.svg" alt="Element Plus" /></a>
<a href="https://pinia.vuejs.org/"><img src="https://img.shields.io/badge/Pinia-4.x-yellow.svg" alt="Pinia" /></a>
</p>
<p>
<a href="https://pickmall.cn">🏠 Official Website</a> ·
<a href="https://bdwx2tfwwt.feishu.cn/wiki/space/7675179368632617952?ccm_open_type=lark_wiki_spaceLink&open_tab_from=wiki_home">📖 Product Documentation</a> ·
<a href="#2-online-demo">💻 Online Demo</a> ·
<a href="https://github.com/lilishop/lilishop">💻 GitHub</a>
</p>
<p>
<a href="./README.md">简体中文</a> | <b>English</b>
</p>
</div>
> ⭐ **If you like this project, please click `Star` in the top-right corner to support us! Your Star is our greatest motivation for continuous open-source iteration!**
**LILISHOP** is an open-source e-commerce system built with Spring Boot, Vue 3, and uni-app. It supports B2B2C multi-vendor marketplaces, WeChat Mini Programs, H5, Android/iOS Apps, live-streaming e-commerce, affiliate distribution, and flash sales.
### ✨ Why Choose Lilishop
- 🟢 **True MIT License**: Free for commercial use and private secondary development without licensing concerns.
- 🚀 **Modern Vue 3 Stack**: Built on `Vue 3.5 + Pinia 4 + Vue Router 4 + Element Plus`, leaving legacy Vue 2 templates behind.
- 🧩 **4-in-1 Production Suite**: Covers **PC Buyer Mall (`buyer`)**, **Seller Portal (`seller`)**, **Platform Admin Panel (`manager`)**, and **IM Customer Service Web (`im`)**, complete with drag-and-drop visual storefront decoration and rich analytics.
---
## 1. Overview
This repository (`lilishop-ui`) is the **Web Frontend Suite** of Lilishop, containing four standalone **Vue 3** web applications: **PC Buyer Mall, Seller Panel, Platform Admin Panel, and IM Customer Service Web**.
- **Official Website**: <https://pickmall.cn>
- **Documentation**: [Product Documentation](https://bdwx2tfwwt.feishu.cn/wiki/space/7675179368632617952?ccm_open_type=lark_wiki_spaceLink&open_tab_from=wiki_home)
- **Gitee Repository**: <https://gitee.com/beijing_hongye_huicheng>
- **GitHub Main Repository**: <https://github.com/lilishop/lilishop>
---
## 2. Online Demo
> **Note**: The verification code for the demo environment is `111111`.
| Portal | URL | Demo Credentials |
| :--- | :--- | :--- |
| **Platform Admin Panel (`manager`)** | <https://admin-b2b2c.pickmall.cn> | `admin` / `123456` |
| **Seller / Store Panel (`seller`)** | <https://store-b2b2c.pickmall.cn> | `13011111111` / `111111` |
| **PC Buyer Mall (`buyer`)** | <https://pc-b2b2c.pickmall.cn> | SMS code: `111111` |
| **Mobile H5 (`uni-app`)** | <https://m-b2b2c.pickmall.cn> | SMS code: `111111` |
| WeChat Mini Program | Mobile H5 ([Visit Directly](https://m-b2b2c.pickmall.cn)) |
| :---: | :---: |
| <img src="https://static.pickmall.cn/images/h5-qrcode.png" width="180" alt="WeChat Mini Program QR Code" /> | <img src="https://lilishop-wechat.oss-cn-beijing.aliyuncs.com/h5-qrcode.png" width="180" alt="Mobile H5 QR Code" /> |
| Scan with WeChat | Scan or visit <https://m-b2b2c.pickmall.cn> |
---
## 3. Core Features & Tech Stack
### Core Features
- **Four-Portal Synergy**: Includes Platform Admin, Seller Panel, PC Buyer Mall, and IM Web Workbench with clear role boundaries.
- **Drag-and-Drop Visual Decoration**: Built-in visual floor editor (`vuedraggable`) for customizing both PC and mobile storefronts.
- **Multi-Vendor B2B2C Operations**: Merchant onboarding, product auditing, split-order fulfillment, promotions, and financial settlement ledgers.
- **Rich Business Analytics**: Powered by `@antv/g2` for sales overview, YoY/MoM product reports, member analytics, and distribution metrics.
- **IM Customer Service**: Integrated with Tencent Cloud IM / WebSocket customer service for real-time communication.
### Web Tech Stack
| Category | Technology | Description |
| :--- | :--- | :--- |
| Core Framework | `Vue 3.5` + `Vue Router 4` | Modern reactive frontend framework |
| State Management | `Pinia 4` | Official Vue 3 state management library |
| UI Library | `Element Plus 2.x` | Enterprise desktop UI component library |
| HTTP Client | `Axios 1.x` | Unified request/response interceptors & auth handling |
| Data Visualization | `@antv/g2` | Interactive charts and business analytics |
| Visual Decoration | `vuedraggable 4` | Drag-and-drop page floor builder |
| Messaging & IM | `@tencentcloud/chat` / `sockjs-client` | Real-time customer service messaging |
| Video Player | `dplayer` + `hls.js` | Product video and live streaming playback |
| Build & Styling | `Vue CLI 5` + `Webpack 5` + `Sass / Less` | Modular build pipeline and theme customization |
---
## 4. Directory Structure
```text
lilishop-ui
├── buyer/ # [PC Buyer Mall]
│ ├── public/ # Static assets & global config (config.js)
│ └── src/
│ ├── api/ # Buyer API modules (goods, cart, order, member, promotion)
│ ├── components/ # Shared components (floor renderer, navbar, goods card)
│ ├── pages/ # PC Mall pages (Home, Product Detail, Checkout, User Center)
│ ├── router/ # Route definitions & guards
│ ├── stores/ # Pinia stores
│ └── utils/ # Request interceptors & helpers
├── seller/ # [Seller / Store Management Portal]
│ ├── public/ # Static assets & global config
│ └── src/
│ ├── api/ # Seller API modules
│ ├── components/ # Seller shared & decoration components
│ ├── views/ # Seller views (goods, orders, after-sales, promotions, store settings)
│ ├── router/ # Dynamic permission routes
│ └── stores/ # Pinia stores
├── manager/ # [Platform Admin Portal]
│ ├── public/ # Static assets & global config
│ └── src/
│ ├── api/ # Admin API modules
│ ├── components/ # Admin components, rich-text editor, visual floor builder
│ ├── views/ # Admin views (member, goods, order, seller, marketing, finance, stats, system)
│ ├── router/ # Menu & permission routes
│ └── stores/ # Pinia stores
├── im/ # [IM Customer Service Web Client]
│ └── src/
│ ├── api/ # Chat session & message APIs
│ ├── components/ # Chat window, message list, product/order cards
│ ├── im-server/ # WebSocket / IM connection manager
│ └── views/ # Customer service workbench
├── build.sh # Unified build script
└── docker-build.sh # Docker image build script
```
---
## 5. Related Repositories
| Repository | Description | Links |
| :--- | :--- | :--- |
| **`lilishop`** | Backend Services (Spring Boot 4 / Java 21) | [Gitee](https://gitee.com/beijing_hongye_huicheng/lilishop) · [GitHub](https://github.com/lilishop/lilishop) |
| **`lilishop-ui`** | PC Mall / Seller / Admin / IM Web Frontend | [Gitee](https://gitee.com/beijing_hongye_huicheng/lilishop-ui) |
| **`lilishop-uniapp`** | Mobile Buyer Client (H5 / Mini Program / App) | [Gitee](https://gitee.com/beijing_hongye_huicheng/lilishop-uniapp) |
| **`docker`** | Docker Compose, Images & SQL Init Scripts | [Gitee](https://gitee.com/beijing_hongye_huicheng/docker) · [MySQL Scripts](https://gitee.com/beijing_hongye_huicheng/docker/tree/master/init/mysql) |
---
## 6. License
Lilishop is licensed under the [MIT](./LICENSE) License. Copyright © 2020-2026 **Beijing Hongye Huicheng Technology Co., Ltd.**

279
README.md
View File

@@ -1,176 +1,181 @@
## Lilishop B2B2C商城系统 <div align="center">
<img src="./manager/src/assets/logo.png" width="180" alt="Lilishop Logo" />
<h1>Lilishop(PickMall) 开源商城系统 - Web 前端 (Vue 3)</h1>
#### 欢迎交流需求,交流业务,交流技术(基础问题自行解决,其他问题先看文档后提问) <p>
<a href="https://github.com/lilishop/lilishop"><img src="https://img.shields.io/github/stars/lilishop/lilishop.svg?style=social&logo=github" alt="GitHub Stars" /></a>
<a href="https://gitee.com/beijing_hongye_huicheng/lilishop"><img src="https://gitee.com/beijing_hongye_huicheng/lilishop/badge/star.svg?theme=dark" alt="Gitee Stars" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" /></a>
<a href="https://vuejs.org/"><img src="https://img.shields.io/badge/Vue-3.5-brightgreen.svg" alt="Vue 3" /></a>
<a href="https://element-plus.org/"><img src="https://img.shields.io/badge/Element%20Plus-2.x-409EFF.svg" alt="Element Plus" /></a>
<a href="https://pinia.vuejs.org/"><img src="https://img.shields.io/badge/Pinia-4.x-yellow.svg" alt="Pinia" /></a>
</p>
#### 不用削尖脑袋往老群里加,老群活跃度较低,很多潜水党,新群相对而言活跃一些 :tw-1f606: :tw-1f606: :tw-1f606: :tw-1f606: :tw-1f606: :tw-1f606: <p>
<a href="https://pickmall.cn">🏠 官方网站</a> ·
<a href="https://bdwx2tfwwt.feishu.cn/wiki/space/7675179368632617952?ccm_open_type=lark_wiki_spaceLink&open_tab_from=wiki_home">📖 产品文档</a> ·
<a href="#2-在线演示">💻 在线演示</a> ·
<a href="#7-社区与支持">💬 微信交流群</a>
</p>
#### PS: **演示站点所有环境均部署master分支。如果有演示站点问题,可以反馈,如果演示站点没问题本地运行有问题,需自行处理** <p>
<b>简体中文</b> | <a href="./README.en.md">English</a>
</p>
</div>
##### 交流 qq 1群 961316482(已满) > ⭐ **如果觉得项目不错,请点击右上角 `Star` 收藏支持!您的 Star 是我们持续开源迭代的最大动力!**
##### 交流 qq 2群 875294241(已满)
##### 交流 qq 3群 263785057(已满)
##### 交流 qq 4群 674617534 (已满)
##### 交流 qq 5群 594675235
##### 体验 公众号/小程序/APP 体验,扫描二维码 **LILISHOP** 是基于 Spring Boot / Vue 3 / uni-app 开发的开源商城系统,支持 B2B2C 多商户商城、微信小程序商城、H5 商城、APP 商城、直播电商、分销返佣、秒杀活动。
![image-20210511171611793](https://static.pickmall.cn/images/h5-qrcode.png) ### ✨ 为什么选择 Lilishop
- 🟢 **真 MIT 协议**:支持免费商用与私有化闭源二开,无版权后顾之忧。
- 🚀 **Vue 3 全新栈**:全面基于 `Vue 3.5 + Pinia 4 + Vue Router 4 + Element Plus` 构建,告别老旧 Vue 2 架构。
- 🧩 **四端合一生产级闭环**:完整覆盖 **PC 买家端 (`buyer`)**、**商家端 (`seller`)**、**平台运营管理端 (`manager`)** 与 **在线客服端 (`im`)**,支持拖拽式可视化楼层装修与多维数据统计报表。
[![star](https://gitee.com/beijing_hongye_huicheng/lilishop/badge/star.svg?theme=dark)](https://gitee.com/beijing_hongye_huicheng/lilishop/stargazers) ---
&nbsp;&nbsp;![github](https://img.shields.io/github/stars/hongyehuicheng/lilishop.svg?style=social&logo=#181717)
## 1. 项目简介
### 商城介绍 **Lilishop** 是一款功能完善的 B2B2C 多商户商城系统,采用前后端分离架构,全端代码开源。
**官网**:https://pickmall.cn 后端基于 **Spring Boot 4**(Java 21)构建,具备高内聚、低耦合的特性,支持分布式部署;前端覆盖 PC、管理后台、H5、小程序和 APP,基于 **Vue 3** 和 **uni-app** 开发。
Lilishop商城系统支持商家入驻,后端基于SpringBoot 研发,前端使用 Vue、uniapp开发, **系统全端全部代码开源** 本仓库(`lilishop-ui`)为 **Web 前端源码仓库**,包含 **PC 商城端、商家后台、平台管理后台、IM 在线客服** 四个独立子工程。
前后端分离,支持分布式部署,支持Docker,各个API独立,并且有独立的消费者。 - **官方网站**: <https://pickmall.cn>
- **官方文档**: [产品文档](https://bdwx2tfwwt.feishu.cn/wiki/space/7675179368632617952?ccm_open_type=lark_wiki_spaceLink&open_tab_from=wiki_home)
- **Gitee 仓库**: <https://gitee.com/beijing_hongye_huicheng>
- **GitHub 主仓库**: <https://github.com/lilishop/lilishop>
### 商城 API/消费者 聚合版 ---
api不需要单独部署,只需启动一个jar包就可以正常运转 如有需要,可以点击跳转
https://gitee.com/beijing_hongye_huicheng/lilishop-simplify
### 开发/使用/常见问题 帮助文档 ## 2. 在线演示
https://docs.pickmall.cn > **注意**: 演示站手机验证码统一为 `111111`。演示环境部署于 `master` 分支。
### 项目地址 | 演示站点 | 访问地址 | 演示账号 |
| :--- | :--- | :--- |
| **平台运营管理端 (`manager`)** | <https://admin-b2b2c.pickmall.cn> | 账号:`admin` / 密码:`123456` |
| **商家店铺管理端 (`seller`)** | <https://store-b2b2c.pickmall.cn> | 账号:`13011111111` / 密码:`111111` |
| **商城 PC 买家端 (`buyer`)** | <https://pc-b2b2c.pickmall.cn> | 可使用手机号验证码 `111111` 登录 |
| **移动端 H5 (`uni-app`)** | <https://m-b2b2c.pickmall.cn> | 可使用手机号验证码 `111111` 登录 |
gitee : https://gitee.com/beijing_hongye_huicheng | 微信小程序 | 移动端 H5([点击直接访问](https://m-b2b2c.pickmall.cn)) |
| :---: | :---: |
| <img src="https://static.pickmall.cn/images/h5-qrcode.png" width="180" alt="微信小程序体验码" /> | <img src="https://lilishop-wechat.oss-cn-beijing.aliyuncs.com/h5-qrcode.png" width="180" alt="移动端 H5 体验二维码" /> |
| 微信扫码体验小程序 | 手机扫码或访问 <https://m-b2b2c.pickmall.cn> |
github 镜像: https://github.com/lilishop?tab=repositories ---
商城UI 项目下3个文件夹 ## 3. 核心特性与技术栈
buyer:买家PC端,seller:商家端,manager:后台管理端
### 核心特性
- **四端协同**: 涵盖平台管理端、商家管理端、PC 买家商城端、IM 客服端,角色边界清晰,开箱即用。
- **可视化拖拽装修**: 管理端与商家端内置可视化楼层编辑器(基于 `vuedraggable`),可同步配置 PC 端与移动端首页/专题页。
- **多商户入驻与结算**: 完整的商家入驻审核、商品审核、拆单发货、促销报名、财务对账与结算台账体系。
- **多维经营分析**: 集成 `@antv/g2` 图表引擎,提供营业概况、商品同比环比、会员分析、营销与分销数据统计。
- **即时通讯客服**: 集成腾讯云 IM / WebSocket 在线客服系统,支持买家、商家与平台实时沟通。
### 演示地址 ### Web 前端技术栈
PS:手机验证码为 ‘111111’ | 类别 | 技术选型 | 说明 |
| :--- | :--- | :--- |
| 核心框架 | `Vue 3.5` + `Vue Router 4` | 基于 Composition API 的现代响应式前端框架 |
| 状态管理 | `Pinia 4` | 轻量、类型友好的 Vue 3 官方状态管理库 |
| UI 组件库 | `Element Plus 2.x` | 企业级中后台与桌面端 UI 组件库 |
| 网络请求 | `Axios 1.x` | 统一请求/响应拦截、Token 刷新与异常提示 |
| 数据可视化 | `@antv/g2` | 运营端与商家端数据统计图表渲染 |
| 可视化拖拽 | `vuedraggable 4` | 商城首页及专题楼层可视化拖拽装修 |
| 即时通讯 | `@tencentcloud/chat` / `sockjs-client` | 在线客服消息收发与实时通知 |
| 音视频播放 | `dplayer` + `hls.js` | 商品视频与直播流播放支持 |
| 构建与样式 | `Vue CLI 5` + `Webpack 5` + `Sass / Less` | 模块化工程构建与全局主题样式定制 |
**平台管理端**:https://admin-b2b2c.pickmall.cn 账号:admin/123456 ---
**店铺管理端**:https://store-b2b2c.pickmall.cn 账号:13011111111/111111 ## 4. 目录结构
**商城PC页面**:https://pc-b2b2c.pickmall.cn ```text
lilishop-ui
**商城 小程序/公众号/APP**:扫描二维码 ├── buyer/ # 【PC 买家商城端】源码工程
│ ├── public/ # 静态资源与全局配置文件 (config.js)
![image-20210511171611793](https://static.pickmall.cn/images/h5-qrcode.png) │ └── src/
│ ├── api/ # 买家端接口定义(商品、购物车、订单、会员、促销等)
### 快速本地部署 │ ├── components/ # 公共业务组件(楼层渲染、导航栏、商品卡片等)
│ ├── pages/ # PC 商城各业务页面(首页、商品详情、结算、个人中心等)
[点击跳转](https://docs.pickmall.cn/deploy/%E8%BF%90%E8%A1%8C%E7%8E%AF%E5%A2%83%E5%87%86%E5%A4%87.html) │ ├── router/ # 路由配置与守卫
│ ├── stores/ # Pinia 状态管理
**商城数据库** │ └── utils/ # 请求封装与通用工具函数
使用docker-compose部署数据库,自动初始化数据库,不需要手动下载等操作 ├── seller/ # 【商家店铺管理端】源码工程
│ ├── public/ # 静态资源与全局配置
如果手动部署,才需要获取sql [点击跳转](https://gitee.com/beijing_hongye_huicheng/docker/tree/master/init/mysql) (PS:这里有与tag版本一致的sql,如果是历史版本,则docker项目也切换至历史版本获取sql即可,历史版本升级则根据java相聚的根目录DB目录下的升级sql,按需执行)。 │ └── src/
│ ├── api/ # 商家端接口定义
│ ├── components/ # 商家端公共组件与装修组件
│ ├── views/ # 商家端业务视图(商品管理、订单发货、售后、促销、店铺设置、统计等)
### 功能列表 │ ├── router/ # 动态权限路由
│ └── stores/ # Pinia 状态管理
├── manager/ # 【平台运营管理端】源码工程
│ ├── public/ # 静态资源与全局配置
#### 平台管理端功能 │ └── src/
│ ├── api/ # 运营端接口定义
![平台管理端功能](https://static.pickmall.cn/images/other/managerList1.jpg) │ ├── components/ # 平台公共组件、富文本、可视化楼层装修编辑器
│ ├── views/ # 运营端业务视图(会员、商品、订单、商家、营销、分销、财务、统计、系统设置)
│ ├── router/ # 菜单与权限路由
│ └── stores/ # Pinia 状态管理
#### 卖家功能 ├── im/ # 【IM 在线客服 Web 端】源码工程
│ └── src/
![商家端功能](https://static.pickmall.cn/images/other/storeList.jpg) │ ├── api/ # 客服会话与消息接口
│ ├── components/ # 聊天窗口、消息列表、商品/订单卡片组件
│ ├── im-server/ # WebSocket / IM 连接服务管理
### 商城前端功能展示 │ └── views/ # 客服工作台页面
├── build.sh # 多端统一构建脚本
#### 商城移动端 └── docker-build.sh # Docker 镜像构建脚本
<img src="https://static.pickmall.cn/images/other/app.gif" alt="移动端功能展示" style="zoom:50%;" />
#### 平台管理端
![管理端功能展示](https://static.pickmall.cn/images/other/manager.gif)
### 技术选型
#### 架构图
![技术选型](https://lili-system.oss-cn-beijing.aliyuncs.com/docs/%E6%9E%B6%E6%9E%84.png)
##### 后台技术选型
| 说明 | 框架 | 说明 | |
| -------------- | --------------- | -------------- | ------------- |
| 基础框架 | Spring Boot | MVC框架 | Spring MVC |
| 持久框架 | Mybatis-Plus | 程序构建 | Maven |
| 关系型数据库 | MySQL | 消息中间件AMQP | RocketMQ |
| 缓存 | Redis +MongoDB | 搜索引擎 | Elasticsearch |
| 安全框架 | Spring Security | 数据库连接池 | Druid |
| 数据库分库分表 | sharding | 定时任务 | xxl-job |
| 负载均衡 | Nginx | 静态资源 | 阿里云OSS |
| 短信 | 阿里云短信 | 认证 | JWT |
| 日志处理 | Log4j | 接口规范 | RESTful |
##### 前端-运营后台、店铺后台
| 说明 | 框架 | 说明 | 框架 |
| ---------- | ---------- | ---------- | ------- |
| 构建工具 | webpack | JS版本 | ES6 |
| 基础JS框架 | Vue.js | 视频播放器 | Dplayer |
| 路由管理 | Vue Router | 状态管理 | Vuex |
| 基础UI库 | iView | UI界面基于 | iView |
| 网络请求 | axios | | |
##### 前端-移动端
| 说明 | 架构 | 说明 | 架构 |
| --------- | ------- | -------- | ------- |
| 基础UI库 | uViewui | 基础框架 | uni-app |
| CSS预处理 | scss | 地图引擎 | amap |
### 版本升级
```
系统后续会提供多场景解决方案。
更多架构:微服务、Saas、中台等,都会支持。 支持差价升级商业授权
``` ```
### 商业授权 ---
商业版本与开源版本代码一致,没有区分
商业使用需要授权,授权方式可选择联系官网客服,或者qq群联系群主。 ## 5. 配套源码仓库
商业授权模式为永久授权,支持永久升级。 本仓库专注于 **PC / 商家端 / 运营端 / IM Web**(`lilishop-ui`)。完整系统由以下四个核心仓库组成:
商业案例由于涉及部分多层二开关系,如需了解可以咨询销售。 | 仓库名称 | 职责说明 | 仓库地址 |
| :--- | :--- | :--- |
| **`lilishop`** | 后端服务源码(Spring Boot 4 / Java 21) | [Gitee](https://gitee.com/beijing_hongye_huicheng/lilishop) · [GitHub](https://github.com/lilishop/lilishop) |
| **`lilishop-ui`** | PC 商城 / 商家端 / 运营管理端 / IM Web 前端 | [Gitee](https://gitee.com/beijing_hongye_huicheng/lilishop-ui) |
| **`lilishop-uniapp`** | 买家端 H5 / 微信小程序 / Android & iOS APP | [Gitee](https://gitee.com/beijing_hongye_huicheng/lilishop-uniapp) |
| **`docker`** | 容器化部署编排、镜像与数据库脚本 | [Gitee](https://gitee.com/beijing_hongye_huicheng/docker) · [MySQL 脚本](https://gitee.com/beijing_hongye_huicheng/docker/tree/master/init/mysql) |
---
### 开源须知 ## 6. 开源与授权
1.仅允许用于个人学习研究使用.
2.禁止将本开源的代码和资源进行任何形式任何名义的出售. 本项目遵循 [MIT](./LICENSE) 开源协议,版权归 **北京宏业汇成科技有限公司** 所有。允许个人及企业免费用于学习、研究、修改和商业用途(需保留原始版权和许可声明)。
3.软件受国家计算机软件著作权保护(登记号:2021SR0805085)。 ---
4.限制商用,如果需要商业使用请联系我们。QQ3409056806.或者加入qq群联系群主。 ## 7. 社区与支持
我们欢迎任何形式的交流与贡献。在提问前,请先查阅 [产品文档](https://bdwx2tfwwt.feishu.cn/wiki/space/7675179368632617952?ccm_open_type=lark_wiki_spaceLink&open_tab_from=wiki_home),并参考 [《提问的智慧》](https://github.com/ryanhanwu/How-To-Ask-Questions-The-Smart-Way/blob/master/README-zh_CN.md) 以便高效沟通。
- **[在线客服](https://work.weixin.qq.com/kfid/kfc4d8dc24a73c15f44)**
- **微信交流群**:
<img src="https://lilishop-wechat.oss-cn-beijing.aliyuncs.com/wechat.jpg" width="200" alt="微信群" />
### 交流群 ---
##### 交流 qq 1群 961316482(已满) ## 8. 升级计划
##### 交流 qq 2群 875294241(已满)
##### 交流 qq 3群 263785057(已满)
##### 交流 qq 4群 674617534(已满)
##### 交流 qq 5群 594675235
### 附录 ### ✅ 已完成特性
有人有自己的学习视频、学习记录文档、希望宣传关联开源项目等均可以私聊仓库所有者。 | 功能模块 | 功能模块 |
| :--- | :--- |
| ✅ 商品定时上下架 | ✅ 商品分组 / 会员分组 |
| ✅ 商品虚拟销量 | ✅ 专用发票(发票上传) |
| ✅ 置顶评论 | ✅ 客户等级体系 |
| ✅ 购物卡 | ✅ 腾讯云-云直播 |
| ✅ 卡密商品 | ✅ 二级分销 |
| ✅ 定时任务管理 | ✅ 预约服务商品 |
| ✅ 主题色管理 | ✅ 商家发货改造(整单/拆单/无需配送) |
| ✅ 多维统计(营业/商品/会员/积分/储值/营销/分销) | ✅ 经营报表(店铺业绩/商品同比环比/订单明细/销售汇总) |
类似: ### 🔜 近期规划中
- 🔜 店铺结算汇总 & 结算台账
清晨敲代码同学的分析: https://blog.csdn.net/vaevaevae233/category_12103567.html - 🔜 支付方式汇总 & 钱包流水 & 财务汇总
- 🔜 微信小店对接
- 🔜 抖店对接

View File

@@ -1,7 +1,6 @@
#代码目录 #代码目录
code_path=$PWD code_path=$PWD
git checkout master
git pull git pull
cd ${code_path}/manager cd ${code_path}/manager

View File

@@ -1,12 +0,0 @@
{
"presets": [
["env", {
"modules": false,
"targets": {
"browsers": ["> 1%", "last 2 versions", "not ie <= 8"]
}
}],
"stage-2"
],
"plugins": ["transform-vue-jsx", "transform-runtime"]
}

View File

@@ -1,4 +0,0 @@
/build/
/config/
/dist/
/*.js

View File

@@ -1,33 +0,0 @@
// https://eslint.org/docs/user-guide/configuring
module.exports = {
root: true,
parserOptions: {
parser: 'babel-eslint'
},
env: {
browser: true,
},
extends: [
// https://github.com/vuejs/eslint-plugin-vue#priority-a-essential-error-prevention
// consider switching to `plugin:vue/strongly-recommended` or `plugin:vue/recommended` for stricter rules.
'plugin:vue/essential',
// https://github.com/standard/standard/blob/master/docs/RULES-en.md
'standard'
],
// required to lint *.vue files
plugins: [
'vue'
],
// add your custom rules here
rules: {
"semi": [0, "never"],
// allow async-await
'generator-star-spacing': 'off',
// allow debugger during development
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
"vue/no-parsing-error": [2, {
"x-invalid-end-tag": false
}]
}
}

View File

@@ -1,5 +1 @@
# 默认依赖包指定淘宝源 engine-strict=false
registry=https://registry.npm.taobao.org/
sass_binary_site=https://npm.taobao.org/mirrors/node-sass/
phantomjs_cdnurl=https://npm.taobao.org/mirrors/phantomjs/
electron_mirror=https://npm.taobao.org/mirrors/electron/

View File

@@ -1,5 +1 @@
registry "https://registry.npmmirror.com" --ignore-engines true
sass_binary_site "https://npmmirror.com/mirrors/node-sass"
phantomjs_cdnurl "https://npmmirror.com/mirrors/phantomjs"
electron_mirror "https://npmmirror.com/mirrors/electron"

55
buyer/MIGRATION-VUE3.md Normal file
View File

@@ -0,0 +1,55 @@
# Buyer 买家端 Vue 3 迁移说明
## 技术栈
- Vue 3 + Vue Router 4 + Pinia(vuex 已移除,见下文"组合式 API 迁移")
- Element Plus(替代 view-design / iView)
- 保留 `$Message` / `$Modal` / `$Notice` 兼容层(`src/utils/message.js`)
## 启动
```bash
cd buyer
yarn install
yarn dev # 默认端口 10000
yarn build
```
## 迁移要点
1. **入口** `src/main.js` 使用 `createApp`,全局过滤器改为 `$filters`
2. **路由** `src/router/index.js` 使用 `createRouter` + 动态 `import()`
3. **请求** `src/plugins/request.js` 使用 Element Plus `ElLoading`
4. **iView 组件已全部替换**:轮播 `el-carousel`、下拉 `el-dropdown`、侧栏 `el-menu`、头像 `el-avatar` 等
5. **图标映射** `src/utils/iconMap.js` — 动态图标(如侧边栏菜单)通过 `resolveIcon()` 使用
6. **自定义字体图标** `icomoon` 保留为 `<i class="icomoon icon-*">`(购物车步骤条、钱包等)
7. **首页装修** `indexDecorate/modelList` 保留买家端展示逻辑(非 manager 编辑版)
8. **特殊替换**
- `vue-piczoom` → `el-image` 预览
- `mv-count-down` → 内置倒计时
- 秒杀轮播 → 横向滚动列表
## 组合式 API 迁移(2026-10 完成)
全部 95 个 `.vue` 组件已从 Options API 转换为 `<script setup>`,同时完成 vuex → pinia:
- **状态管理**:`src/stores/app.js`(`useAppStore`:navList / cartNum / unreadMsg / logoImg / siteName / hotWordsList / category + 对应 setXxx actions)。原 `src/vuex/` 已删除,package.json 不再依赖 vuex。
- **路由守卫**:`beforeRouteEnter` 无组合式等价物,5 个页面(`GoodsList` / `GoodsDetail` / `PointGoodsDetail` / `Cart` / `ShoppingCart`)保留普通 `<script>` 块存放 `export default { name, beforeRouteEnter }`——这是官方支持的合并写法,统计 Options API 残留时需排除。
- **defineExpose**:以下组件的方法/状态被父组件通过模板 ref 调用,已显式暴露:`verify`(init / close)、`multiple-map`(open)、`addressManage`(show / hide)、`invoiceModal`(invoiceAvailable)。新增此类父子调用时必须加 `defineExpose`。
- **模板 ref 与响应式变量同名会互相覆盖**:同名时模板 ref 统一改名 `xxxRef`(如 `formRef` / `verifyRef`)。
- **mixin 已消除**:原 `components/mixes/talkIm.vue` 的 IM 客服逻辑已内联进 `Merchant.vue` 和 `goodsDetail/StoreSide.vue`,文件已删除。
- **全局属性照旧**:模板中的 `$filters.*` / `linkTo` / `connectCs` / `$inputMaxLength` 是 `main.js` 注册的全局属性,`<script setup>` 下模板可直接使用;script 内使用 `$filters` 函数需从 `@/plugins/filters.js` 具名导入,`this.Cookies` 等同 `storage`(`@/plugins/storage`)。
- **其余三端(seller / manager / im)仍为 Options API + vuex**,迁移时参照本端模式。
## 批量脚本
- `scripts/migrate-iview-to-element.js` — iView → Element 模板替换
- `scripts/fix-filters.js` — Vue 2 过滤器语法修复
- `scripts/replace-page.js` — `<Page>` → `el-pagination`
- `scripts/replace-icon.js` — 静态 `<Icon type>` → `el-icon`
- `scripts/fix-el-icon-click.js` — 修复 `@click` 绑定
- `scripts/fix-broken-tags.js` — 闭合标签修复
## 上线前检查
四端统一清单(认证、axios、Element Plus、分端专项):[../MIGRATION-VUE3.md](../MIGRATION-VUE3.md)

View File

@@ -1,19 +0,0 @@
# new
## Project setup
```
npm install
```
### Compiles and hot-reloads for development
```
npm run serve
```
### Compiles and minifies for production
```
npm run build
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

36
buyer/eslint.config.js Normal file
View File

@@ -0,0 +1,36 @@
const globals = require('globals')
const pluginVue = require('eslint-plugin-vue')
module.exports = [
{
ignores: [
'dist/**',
'node_modules/**',
'build/**',
'config/**',
'src/scripts/**', // 一次性迁移脚本,不参与 lint
],
},
...pluginVue.configs['flat/essential'],
{
files: ['src/**/*.{js,vue}'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.browser,
},
},
rules: {
'no-console': ['warn', { allow: ['warn', 'error'] }],
'no-empty': ['error', { allowEmptyCatch: false }],
'vue/no-parsing-error': ['error', { 'x-invalid-end-tag': false }],
// 页面组件通过路由按文件路径引用,组件名不参与解析,存量单词组件名不再强制多词
'vue/multi-word-component-names': 'off',
// 存量代码大量使用与 HTML 保留名同名的组件(element-plus 图标 Search/View/Camera/Link 及全局注册的 empty/drawer 等),重命名成本高,暂不启用
'vue/no-reserved-component-names': 'off',
// 全端已完成 Options API → <script setup> 迁移,防止新代码回退(路由守卫的普通 script 块等有意保留的除外,故用 warn)
'vue/component-api-style': ['warn', ['script-setup']],
},
},
]

11525
buyer/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,42 +3,54 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vue-cli-service serve",
"serve": "vue-cli-service serve", "serve": "vue-cli-service serve",
"build": "vue-cli-service build" "build": "vue-cli-service build",
"dev": "vue-cli-service serve",
"lint": "eslint src",
"lint:fix": "eslint src --fix"
},
"engines": {
"node": ">=16"
}, },
"dependencies": { "dependencies": {
"@amap/amap-jsapi-loader": "0.0.7", "@amap/amap-jsapi-loader": "0.0.7",
"axios": "^0.19.2", "@element-plus/icons-vue": "^2.3.2",
"axios": "^1.7.9",
"core-js": "^3.49.0",
"dplayer": "^1.27.1", "dplayer": "^1.27.1",
"element-plus": "^2.14.1",
"js-cookie": "^2.2.1", "js-cookie": "^2.2.1",
"less": "^2.7.0", "nprogress": "^0.2.0",
"less-loader": "^5.0.0", "pinia": "^4.0.3",
"mv-count-down": "^0.1.15",
"node-sass": "^4.14.1",
"postcss-loader": "^7.0.1",
"psl": "^1.8.0", "psl": "^1.8.0",
"qs": "^6.9.4", "qs": "^6.9.4",
"sass-loader": "^7.3.1", "sass": "^1.63.6",
"sass-loader": "^10.4.1",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"view-design": "^4.3.2", "vue": "^3.5.35",
"vue": "^2.6.11", "vue-qr": "^5.0.3",
"vue-awesome-swiper": "^3.1.3", "vue-router": "^4.6.4"
"vue-piczoom": "^1.0.6",
"vue-qr": "^2.3.0",
"vue-router": "^3.0.1",
"vuex": "^3.0.1"
}, },
"devDependencies": { "devDependencies": {
"@vue/cli-service": "~4.5.0", "@vue/cli-plugin-babel": "^5.0.9",
"compression-webpack-plugin": "^5.0.0", "@vue/cli-service": "^5.0.9",
"sass-loader": "^7.3.1", "@vue/compiler-sfc": "^3.5.35",
"uglifyjs-webpack-plugin": "^2.2.0", "compression-webpack-plugin": "^10.0.0",
"vue-template-compiler": "^2.6.11" "eslint": "^9.39.4",
"eslint-plugin-vue": "^10.11.1",
"globals": "^17.13.0",
"less": "^4.2.0",
"less-loader": "^11.1.4",
"vue-eslint-parser": "^10.4.1",
"webpack": "^5.95.0"
}, },
"browserslist": [ "browserslist": [
"> 1%", "> 1%",
"last 2 versions", "last 2 versions",
"not dead" "not dead"
] ],
"resolutions": {
"minimatch": "^3.1.2",
"@achrinza/node-ipc": "9.2.2"
}
} }

8672
buyer/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,15 +3,9 @@ var BASE = {
* @description api请求基础路径 * @description api请求基础路径
*/ */
API_DEV: { API_DEV: {
common: "https://common-api.pickmall.cn",
buyer: "https://buyer-api.pickmall.cn", buyer: "https://buyer-api.pickmall.cn",
seller: "https://store-api.pickmall.cn",
manager: "https://admin-api.pickmall.cn"
}, },
API_PROD: { API_PROD: {
common: "https://common-api.pickmall.cn",
buyer: "https://buyer-api.pickmall.cn", buyer: "https://buyer-api.pickmall.cn",
seller: "https://store-api.pickmall.cn",
manager: "https://admin-api.pickmall.cn"
}, },
}; };

View File

@@ -7,20 +7,12 @@
<meta name="viewport" content="width=device-width,initial-scale=1.0"> <meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>logo.ico"> <link rel="icon" href="<%= BASE_URL %>logo.ico">
<title><%= htmlWebpackPlugin.options.title %></title> <title><%= htmlWebpackPlugin.options.title %></title>
<% for(var css of htmlWebpackPlugin.options.cdn.css) { %>
<link rel="stylesheet" href="<%=css%>" />
<% } %>
</head> </head>
<body> <body>
<% for(var js of htmlWebpackPlugin.options.cdn.js) { %> <script src="<%= BASE_URL %>config.js"></script>
<script src="<%=js%>"></script>
<% } %>
<script src="/config.js"></script>
<noscript> <noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong> <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript> </noscript>
<div id="app"></div> <div id="app"></div>
<!-- built files will be auto injected -->
</body> </body>
</html> </html>

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..");
function walkDir(dir, files = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walkDir(full, files);
else if (entry.name.endsWith(".vue")) files.push(full);
}
return files;
}
function fix(content) {
let c = content;
c = c.replace(/<\/Button>/g, "</el-button>");
c = c.replace(/<\/Button\b/g, "</el-button");
c = c.replace(/<\/Input>/g, "</el-input>");
c = c.replace(/<\/Input\b/g, "</el-input");
c = c.replace(/<\/Option>/g, "</el-option>");
c = c.replace(/<\/Option\b/g, "</el-option");
c = c.replace(/<\/Checkbox>/g, "</el-checkbox>");
c = c.replace(/<\/Checkbox\b/g, "</el-checkbox");
c = c.replace(/<\/Select>/g, "</el-select>");
c = c.replace(/<\/Select\b/g, "</el-select");
c = c.replace(/<\/Form>/g, "</el-form>");
c = c.replace(/<\/Modal>/g, "</el-dialog>");
c = c.replace(/@click\.native/g, "@click");
c = c.replace(/@on-search/g, "@keyup.enter");
c = c.replace(/\bsearch\b(?=\s+enter-button)/g, "");
c = c.replace(/\benter-button\b/g, "");
c = c.replace(/<BreadcrumbItem/g, "<el-breadcrumb-item");
c = c.replace(/<\/BreadcrumbItem>/g, "</el-breadcrumb-item>");
c = c.replace(/<Breadcrumb/g, "<el-breadcrumb");
c = c.replace(/<\/Breadcrumb>/g, "</el-breadcrumb>");
c = c.replace(/type="error"/g, 'type="danger"');
c = c.replace(/<i-col/g, "<div");
c = c.replace(/<\/i-col>/g, "</div>");
c = c.replace(/\{\{([^}|]+)\s*\|\s*secrecyMobile\s*\}\}/g, "{{ $filters.secrecyMobile($1) }}");
return c;
}
let count = 0;
for (const file of walkDir(path.join(ROOT, "src"))) {
const original = fs.readFileSync(file, "utf8");
const fixed = fix(original);
if (fixed !== original) {
fs.writeFileSync(file, fixed);
count++;
}
}
console.log(`Fixed tags in ${count} files`);

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..");
function walkDir(dir, files = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walkDir(full, files);
else if (entry.name.endsWith(".vue")) files.push(full);
}
return files;
}
function fix(content) {
let c = content;
c = c.replace(/<p #header>/g, "<template #header><p>");
c = c.replace(
/(<template #header><p>[\s\S]*?<\/p>)(\s*<div)/g,
"$1</template>$2"
);
c = c.replace(
/<template #footer><div([^>]*)>([\s\S]*?)<\/div><\/template><\/el-dialog>/g,
"<template #footer><div$1>$2</div></template>\n </el-dialog>"
);
c = c.replace(
/<template #default\s*>([\s\S]*?)<\/template\s*>/g,
(match, inner) => {
if (match.includes("el-alert")) {
return `<template #title>${inner}</template>`;
}
return match;
}
);
return c;
}
let count = 0;
for (const file of walkDir(path.join(ROOT, "src"))) {
const original = fs.readFileSync(file, "utf8");
const fixed = fix(original);
if (fixed !== original) {
fs.writeFileSync(file, fixed);
count++;
}
}
console.log(`Fixed dialog slots in ${count} files`);

View File

@@ -0,0 +1,26 @@
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..", "src");
function walk(dir, files = []) {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const f = path.join(dir, e.name);
if (e.isDirectory()) walk(f, files);
else if (e.name.endsWith(".vue")) files.push(f);
}
return files;
}
let n = 0;
for (const file of walk(ROOT)) {
let c = fs.readFileSync(file, "utf8");
const next = c.replace(/:size="(\d+)"\s+:size="\1"/g, ':size="$1"');
if (next !== c) {
fs.writeFileSync(file, next);
n++;
console.log(path.relative(ROOT, file));
}
}
console.log(`Fixed duplicate :size in ${n} files`);

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..", "src");
function walk(dir, files = []) {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const f = path.join(dir, e.name);
if (e.isDirectory()) walk(f, files);
else if (e.name.endsWith(".vue")) files.push(f);
}
return files;
}
let n = 0;
for (const file of walk(ROOT)) {
let c = fs.readFileSync(file, "utf8");
const next = c
.replace(/<el-icon([^>]*?):click=/g, "<el-icon$1@click=")
.replace(/:class="refresh"/g, 'class="refresh"')
.replace(/:size="20" @click="show = false" :size="20"/g, ':size="20" @click="show = false"');
if (next !== c) {
fs.writeFileSync(file, next);
n++;
}
}
console.log(`Fixed el-icon click in ${n} files`);

View File

@@ -0,0 +1,77 @@
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..");
function walkDir(dir, files = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walkDir(full, files);
else if (entry.name.endsWith(".vue")) files.push(full);
}
return files;
}
function fixFilters(content) {
let c = content;
// Fix broken: {{ expr {{ $filters.unitPrice(args) }}
c = c.replace(
/\{\{\s*([^}{]+?)\s*\{\{\s*\$filters\.unitPrice\(([^)]*)\)\s*\}\}/g,
"{{ $filters.unitPrice($1, $2) }}"
);
// Fix: {{ expr | unitPrice }} or {{ expr | unitPrice('¥') }}
c = c.replace(
/\{\{\s*([^}|]+?)\s*\|\s*unitPrice(?:\(([^)]*)\))?\s*\}\}/g,
(_, expr, args) => {
const e = expr.trim();
return args !== undefined
? `{{ $filters.unitPrice(${e}, ${args}) }}`
: `{{ $filters.unitPrice(${e}) }}`;
}
);
// Fix inline without braces: expr | unitPrice("¥")
c = c.replace(
/(?<!\{\{)\s*([a-zA-Z0-9_.?()[\]'"\s]+?)\s*\|\s*unitPrice\(([^)]*)\)/g,
(match, expr, args) => {
if (match.includes("{{")) return match;
return `{{ $filters.unitPrice(${expr.trim()}, ${args}) }}`;
}
);
// Fix inline without args: expr | unitPrice
c = c.replace(
/(?<!\{\{)\s*([a-zA-Z0-9_.?()[\]'"\s]+?)\s*\|\s*unitPrice(?!\()/g,
(match, expr) => {
if (match.includes("{{") || match.includes("$filters")) return match;
return `{{ $filters.unitPrice(${expr.trim()}) }}`;
}
);
// Fix unixToDate filter
c = c.replace(
/\{\{\s*([^}|]+?)\s*\|\s*unixToDate(?:\(([^)]*)\))?\s*\}\}/g,
(_, expr, args) => {
const e = expr.trim();
return args !== undefined
? `{{ $filters.unixToDate(${e}, ${args}) }}`
: `{{ $filters.unixToDate(${e}) }}`;
}
);
return c;
}
let count = 0;
for (const file of walkDir(path.join(ROOT, "src"))) {
const original = fs.readFileSync(file, "utf8");
const fixed = fixFilters(original);
if (fixed !== original) {
fs.writeFileSync(file, fixed);
count++;
}
}
console.log(`Fixed filters in ${count} files`);

View File

@@ -0,0 +1,55 @@
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..");
function walkDir(dir, files = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walkDir(full, files);
else if (entry.name.endsWith(".vue")) files.push(full);
}
return files;
}
function fix(content) {
let c = content;
c = c.replace(/<el-pagination\b/g, "<Page");
c = c.replace(/<\/el-pagination>/g, "</Page>");
c = c.replace(
/<Icon([^>]*)\s+#prepend\s*>\s*<\/Icon>/g,
"<template #prepend><Icon$1></Icon></template>"
);
c = c.replace(
/<Icon([^>]*)\s+#prepend\s*>\s*<\/Icon>/g,
"<template #prepend><Icon$1></Icon></template>"
);
c = c.replace(
/<template #prepend><Icon([^>]*)\s*\/>/g,
"<template #prepend><Icon$1></Icon></template>"
);
c = c.replace(/@keyup\.enter\.native/g, "@keyup.enter");
c = c.replace(/slot="append"/g, "#append");
c = c.replace(/(<div[^>]*)\s+#append/g, "<template #append>$1");
c = c.replace(/(<el-button[^>]*)\slong/g, '$1 style="width:100%"');
c = c.replace(/@on-select/g, "@select");
c = c.replace(/@on-click/g, "@click");
return c;
}
let count = 0;
for (const file of walkDir(path.join(ROOT, "src"))) {
const original = fs.readFileSync(file, "utf8");
const fixed = fix(original);
if (fixed !== original) {
fs.writeFileSync(file, fixed);
count++;
}
}
console.log(`Runtime compat fixes in ${count} files`);

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..");
function walkDir(dir, files = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walkDir(full, files);
else if (entry.name.endsWith(".vue")) files.push(full);
}
return files;
}
function fix(content) {
let c = content;
c = c.replace(
/<template #default="(\{[^"]+\})" #([a-zA-Z0-9_-]+)>/g,
'<template #$2="$1">'
);
c = c.replace(
/<div([^>]*) #footer([^>]*)>/g,
'<template #footer><div$1$2>'
);
c = c.replace(/<\/div>\s*(?=<\/el-dialog>)/g, "</div></template>");
c = c.replace(/<span #append>/g, "<template #append><span>");
c = c.replace(/<\/span><\/el-input/g, "</span></template></el-input");
c = c.replace(/><span #append>/g, "><template #append><span>");
c = c.replace(/<el-button #append/g, "<template #append><el-button");
c = c.replace(
/<\/el-button>(\s*<\/el-input>)/g,
"</el-button></template>$1"
);
c = c.replace(/<template #desc\b/g, "<template #default");
return c;
}
let count = 0;
for (const file of walkDir(path.join(ROOT, "src"))) {
const original = fs.readFileSync(file, "utf8");
const fixed = fix(original);
if (fixed !== original) {
fs.writeFileSync(file, fixed);
count++;
}
}
console.log(`Fixed slots in ${count} files`);

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..");
function walkDir(dir, files = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walkDir(full, files);
else if (entry.name.endsWith(".vue")) files.push(full);
}
return files;
}
function fix(content) {
let c = content;
c = c.replace(/<\/span\s*\n\s*>/g, "</span>");
c = c.replace(/<\/span\s+>/g, "</span>");
c = c.replace(/<i-input/g, "<el-input");
c = c.replace(/<\/i-input>/g, "</el-input>");
c = c.replace(
/<Icon([^>]*)\s+#prepend\s*\/>/g,
"<template #prepend><Icon$1 /></template>"
);
c = c.replace(
/<template #append><el-button([^>]*)>([\s\S]*?)<\/el-button>\s*<\/i-input>/g,
"<template #append><el-button$1>$2</el-button></template></el-input>"
);
c = c.replace(
/<template #append><el-button([^>]*)>([\s\S]*?)<\/el-button>\s*<\/el-input>/g,
"<template #append><el-button$1>$2</el-button></template></el-input>"
);
return c;
}
let count = 0;
for (const file of walkDir(path.join(ROOT, "src"))) {
const original = fs.readFileSync(file, "utf8");
const fixed = fix(original);
if (fixed !== original) {
fs.writeFileSync(file, fixed);
count++;
}
}
console.log(`Fixed span/input tags in ${count} files`);

View File

@@ -0,0 +1,55 @@
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..");
function walkDir(dir, files = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walkDir(full, files);
else if (entry.name.endsWith(".vue")) files.push(full);
}
return files;
}
function fix(content) {
let c = content;
c = c.replace(
/(<div[^>]*)\s+#content>/g,
"<template #content><div$1>"
);
c = c.replace(
/(<\/el-tooltip>)/g,
(match, _m, offset, str) => {
return match;
}
);
c = c.replace(
/(<div style="white-space:[^"]*"[^>]*>[\s\S]*?<\/div>)(\s*<\/el-tooltip>)/g,
"$1</template>$2"
);
c = c.replace(/<\/div><\/template><\/el-dialog>/g, (m, offset, str) => {
const before = str.slice(Math.max(0, offset - 200), offset);
if (before.includes("#footer")) return m;
return "</div></el-dialog>";
});
c = c.replace(/<i(\s[^>]*)\/>/g, "<i$1></i>");
return c;
}
let count = 0;
for (const file of walkDir(path.join(ROOT, "src"))) {
const original = fs.readFileSync(file, "utf8");
const fixed = fix(original);
if (fixed !== original) {
fs.writeFileSync(file, fixed);
count++;
}
}
console.log(`Fixed stray templates in ${count} files`);

View File

@@ -0,0 +1,227 @@
#!/usr/bin/env node
/**
* Migrate buyer Vue files from iView to Element Plus.
* Usage: node scripts/migrate-iview-to-element.js
*/
const fs = require("fs");
const path = require("path");
const BUYER_ROOT = path.join(__dirname, "..");
const MANAGER_ROOT = path.join(__dirname, "../../manager");
const MODEL_LIST = [
["Carousel.vue", "carousel.vue"],
["Carousel1.vue", "carousel1.vue"],
["Carousel2.vue", "carousel2.vue"],
["FirstPageAdvert.vue", "firstPageAdvert.vue"],
["NewGoodsSort.vue", "newGoodsSort.vue"],
["NotEnough.vue", "notEnough.vue"],
["Recommend.vue", "recommend.vue"],
["Seckill.vue", "seckill.vue"],
["forYour.vue", "forYour.vue"],
["goodsAndType.vue", "goodsAndType.vue"],
["mixModel.vue", "mixModel.vue"],
["oneRowThreeColumns.vue", "oneRowThreeColumns.vue"],
["onlyGoodsModel.vue", "onlyGoodsModel.vue"],
["mixs/mix-brand.vue", "mixs/mix-brand.vue"],
["mixs/mix-goods.vue", "mixs/mix-goods.vue"],
];
const DECORATE_COPY_MAP = [
[
"src/components/indexDecorate/ModelForm.vue",
"src/views/page-decoration/modelForm.vue",
],
[
"src/components/indexDecorate/ModelFormItem.vue",
"src/views/page-decoration/modelFormItem.vue",
],
];
MODEL_LIST.forEach(([buyerName, managerName]) => {
DECORATE_COPY_MAP.push([
`src/components/indexDecorate/modelList/${buyerName}`,
`src/views/page-decoration/modelList/${managerName}`,
]);
});
const TARGET_DIRS = ["src/pages", "src/components"];
function adaptBuyerContent(content) {
return content
.replace(/@\/views\/page-decoration\//g, "@/components/indexDecorate/")
.replace(/admin-setting/g, "buyer-setting")
.replace(/adminPCPageCache/g, "buyerPCPageCache")
.replace(/getStore\('adminPCPageCache'\)/g, "getStore('buyerPCPageCache')")
.replace(/setStore\('adminPCPageCache'/g, "setStore('buyerPCPageCache'");
}
function applyScriptTransforms(content) {
let c = content;
c = c.replace(/\$options\.filters\./g, "$filters.");
c = c.replace(/\|\s*unitPrice\(/g, "{{ $filters.unitPrice(");
c = c.replace(/\|\s*unixToDate\(/g, "{{ $filters.unixToDate(");
c = c.replace(/\bbeforeDestroy\b/g, "beforeUnmount");
c = c.replace(/this\.\$set\(([^,]+),\s*([^,]+),\s*([^)]+)\)/g, "$1[$2] = $3");
c = c.replace(/::v-deep/g, ":deep");
c = c.replace(/\/deep\//g, ":deep");
c = c.replace(/@on-change/g, "@change");
c = c.replace(/@on-click/g, "@click");
c = c.replace(/@on-ok/g, "@ok");
c = c.replace(/@on-cancel/g, "@cancel");
c = c.replace(/@on-clear/g, "@clear");
c = c.replace(/@on-open-change/g, "@visible-change");
c = c.replace(/@on-page-size-change/g, "@size-change");
c = c.replace(/@on-select/g, "@select");
c = c.replace(/@keydown\.enter\.native/g, "@keyup.enter");
c = c.replace(/slot-scope="([^"]+)"/g, '#default="$1"');
c = c.replace(/slot="([^"]+)"/g, '#$1');
c = c.replace(/from ['"]view-design['"]/g, 'from "@/utils/message"');
c = c.replace(/import\s*\{([^}]*)\}\s*from\s*['"]view-design['"]/g, 'import {$1} from "@/utils/message"');
return c;
}
function applyTemplateTransforms(content) {
let c = content;
const replacements = [
[/<\/?Card>/g, (m) => m.replace("Card", "el-card")],
[/<Form-item/g, "<el-form-item"],
[/<\/Form-item>/g, "</el-form-item>"],
[/<FormItem/g, "<el-form-item"],
[/<\/FormItem>/g, "</el-form-item>"],
[/<Form(\s|>)/g, "<el-form$1"],
[/<\/Form>/g, "</el-form>"],
[/<InputNumber/g, "<el-input-number"],
[/<\/InputNumber>/g, "</el-input-number>"],
[/<Input(\s|>)/g, "<el-input$1"],
[/<\/Input>/g, "</el-input>"],
[/<Button(\s|>)/g, "<el-button$1"],
[/<\/Button>/g, "</el-button>"],
[/<Select(\s|>)/g, "<el-select$1"],
[/<\/Select>/g, "</el-select>"],
[/<Option(\s)/g, "<el-option "],
[/<\/Option>/g, "</el-option>"],
[/<DatePicker/g, "<el-date-picker"],
[/<\/DatePicker>/g, "</el-date-picker>"],
[/<TimePicker/g, "<el-time-picker"],
[/<\/TimePicker>/g, "</el-time-picker>"],
[/<Modal/g, "<el-dialog"],
[/<\/Modal>/g, "</el-dialog>"],
[/<Page(\s)/g, "<el-pagination "],
[/<\/Page>/g, "</el-pagination>"],
[/<Tabs(\s|>)/g, "<el-tabs$1"],
[/<\/Tabs>/g, "</el-tabs>"],
[/<TabPane/g, "<el-tab-pane"],
[/<\/TabPane>/g, "</el-tab-pane>"],
[/<Row(\s|>)/g, "<div$1"],
[/<\/Row>/g, "</div>"],
[/<Col(\s|>)/g, "<div$1"],
[/<\/Col>/g, "</div>"],
[/<Tag(\s|>)/g, "<el-tag$1"],
[/<\/Tag>/g, "</el-tag>"],
[/<Switch(\s|>)/g, "<el-switch$1"],
[/<\/Switch>/g, "</el-switch>"],
[/<RadioGroup/g, "<el-radio-group"],
[/<\/RadioGroup>/g, "</el-radio-group>"],
[/<Radio(\s)/g, "<el-radio "],
[/<\/Radio>/g, "</el-radio>"],
[/<CheckboxGroup/g, "<el-checkbox-group"],
[/<\/CheckboxGroup>/g, "</el-checkbox-group>"],
[/<Checkbox/g, "<el-checkbox"],
[/<\/Checkbox>/g, "</el-checkbox>"],
[/<Poptip/g, "<el-popover"],
[/<\/Poptip>/g, "</el-popover>"],
[/<Tooltip/g, "<el-tooltip"],
[/<\/Tooltip>/g, "</el-tooltip>"],
[/<Drawer/g, "<el-drawer"],
[/<\/Drawer>/g, "</el-drawer>"],
[/<Alert/g, "<el-alert"],
[/<\/Alert>/g, "</el-alert>"],
[/<Badge/g, "<el-badge"],
[/<\/Badge>/g, "</el-badge>"],
[/<Divider/g, "<el-divider"],
[/<\/Divider>/g, "</el-divider>"],
[/<Rate/g, "<el-rate"],
[/<\/Rate>/g, "</el-rate>"],
[/<Steps/g, "<el-steps"],
[/<\/Steps>/g, "</el-steps>"],
[/<Step(\s)/g, "<el-step "],
[/<\/Step>/g, "</el-step>"],
[/<Upload/g, "<el-upload"],
[/<\/Upload>/g, "</el-upload>"],
[/<Cascader/g, "<el-cascader"],
[/<\/Cascader>/g, "</el-cascader>"],
[/<Affix/g, "<el-affix"],
[/<\/Affix>/g, "</el-affix>"],
[/<Spin/g, "<el-skeleton"],
[/<\/Spin>/g, "</el-skeleton>"],
[/:label-width="(\d+)"/g, 'label-width="$1px"'],
[/:mask-closable="false"/g, ':close-on-click-modal="false"'],
[/:width="(\d+)"/g, 'width="$1"'],
[/type="primary"/g, 'type="primary"'],
];
for (const [pattern, replacement] of replacements) {
c = c.replace(pattern, replacement);
}
return c;
}
function walkDir(dir, files = []) {
if (!fs.existsSync(dir)) return files;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkDir(full, files);
} else if (entry.name.endsWith(".vue")) {
files.push(full);
}
}
return files;
}
const copied = [];
const transformed = [];
const skipped = [];
for (const [buyerRel, managerRel] of DECORATE_COPY_MAP) {
const managerPath = path.join(MANAGER_ROOT, managerRel);
const buyerPath = path.join(BUYER_ROOT, buyerRel);
if (!fs.existsSync(managerPath)) {
skipped.push({ file: buyerRel, reason: "manager counterpart missing" });
continue;
}
let content = fs.readFileSync(managerPath, "utf8");
content = adaptBuyerContent(content);
fs.mkdirSync(path.dirname(buyerPath), { recursive: true });
fs.writeFileSync(buyerPath, content);
copied.push(buyerRel);
}
const copiedSet = new Set(copied);
for (const dir of TARGET_DIRS) {
const absDir = path.join(BUYER_ROOT, dir);
for (const filePath of walkDir(absDir)) {
const rel = path.relative(BUYER_ROOT, filePath).replace(/\\/g, "/");
if (copiedSet.has(rel)) continue;
let content = fs.readFileSync(filePath, "utf8");
const hasIView =
/<(Card|Form|Form-item|FormItem|Table|Button|Modal|Page|Tabs|TabPane|Input|Select|Option|DatePicker|Row|Poptip|Drawer|Upload|Checkbox|Radio|Switch|Tag|Alert|Rate|Steps|Step|Spin|Affix|TimePicker|Cascader|Divider|Badge|InputNumber)\b/.test(
content
);
if (!hasIView) {
skipped.push({ file: rel, reason: "no iView components" });
continue;
}
content = applyTemplateTransforms(content);
content = applyScriptTransforms(content);
fs.writeFileSync(filePath, content);
transformed.push(rel);
}
}
console.log("=== Buyer Migration Summary ===");
console.log(`Copied from manager: ${copied.length}`);
console.log(`Script-transformed: ${transformed.length}`);
console.log(`Skipped: ${skipped.length}`);

View File

@@ -0,0 +1,149 @@
#!/usr/bin/env node
/**
* 将静态 <Icon type="..." /> 替换为 <el-icon><Xxx /></el-icon>
* 动态 :type / custom 属性保留,由人工或 resolveIcon 处理。
*/
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..", "src");
const TYPE_TO_COMPONENT = {
"md-checkmark": "Check",
"md-close": "Close",
"md-trash": "Delete",
"md-add": "Plus",
"md-arrow-dropdown": "ArrowDown",
"md-arrow-dropup": "ArrowUp",
"md-person": "User",
"md-lock": "Lock",
"md-phone-portrait": "Iphone",
"md-key": "Key",
"md-link": "Link",
"md-refresh": "Refresh",
"md-qr-scanner": "Iphone",
"ios-arrow-down": "ArrowDown",
"ios-arrow-up": "ArrowUp",
"ios-arrow-forward": "ArrowRight",
"ios-arrow-back": "ArrowLeft",
"ios-arrow-round-down": "ArrowDown",
"ios-arrow-round-forward": "ArrowRight",
"ios-cart-outline": "ShoppingCart",
"ios-camera": "Camera",
"ios-eye-outline": "View",
"ios-trash-outline": "Delete",
"ios-alert-outline": "Warning",
"ios-help-circle-outline": "Help",
"ios-add-circle-outline": "Plus",
"ios-information-circle": "InfoFilled",
"ios-search": "Search",
"ios-text-outline": "Document",
"ios-alarm-outline": "AlarmClock",
"ios-heart": "StarFilled",
"md-close-circle": "CircleClose",
edit: "Edit",
"trash-a": "Delete",
location: "Location",
};
function walkDir(dir, files = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory() && entry.name !== "compat") walkDir(full, files);
else if (entry.name.endsWith(".vue")) files.push(full);
}
return files;
}
function parseAttrs(attrStr) {
const attrs = {};
const re = /(?::([\w-]+)|@([\w-]+)|([\w-]+))=(?:"([^"]*)"|'([^']*)')|(?::([\w-]+)|@([\w-]+)|([\w-]+))(?=\s|\/|>)/g;
let m;
while ((m = re.exec(attrStr)) !== null) {
const name = m[1] || m[2] || m[3] || m[6] || m[7] || m[8];
const value = m[4] ?? m[5] ?? true;
attrs[name] = value;
}
return attrs;
}
function attrsToString(attrs, exclude = []) {
return Object.entries(attrs)
.filter(([k]) => !exclude.includes(k))
.map(([k, v]) => {
if (v === true) return k;
if (k.startsWith("@")) return `${k}="${v}"`;
return `:${k}="${v}"`;
})
.join(" ");
}
function transform(content, filePath) {
if (filePath.includes("/compat/")) return content;
let c = content;
const used = new Set();
c = c.replace(/<Icon\b([^>]*?)\/>/g, (match, attrStr) => {
if (/\bcustom=/.test(attrStr) || /:type=/.test(attrStr)) return match;
const attrs = parseAttrs(attrStr);
const type = attrs.type;
if (!type || !TYPE_TO_COMPONENT[type]) return match;
const comp = TYPE_TO_COMPONENT[type];
used.add(comp);
const rest = attrsToString(attrs, ["type"]);
const size = attrs.size ? ` :size="${attrs.size}"` : "";
const open = rest ? `<el-icon ${rest}${size}>` : `<el-icon${size}>`;
return `${open}<${comp} /></el-icon>`;
});
c = c.replace(/<Icon\b([^>]*?)><\/Icon>/g, (match, attrStr) => {
if (/\bcustom=/.test(attrStr) || /:type=/.test(attrStr)) return match;
const attrs = parseAttrs(attrStr);
const type = attrs.type;
if (!type || !TYPE_TO_COMPONENT[type]) return match;
const comp = TYPE_TO_COMPONENT[type];
used.add(comp);
const rest = attrsToString(attrs, ["type"]);
const size = attrs.size ? ` :size="${attrs.size}"` : "";
const open = rest ? `<el-icon ${rest}${size}>` : `<el-icon${size}>`;
return `${open}<${comp} /></el-icon>`;
});
if (used.size && !c.includes("@element-plus/icons-vue")) {
const imports = [...used].sort().join(", ");
c = c.replace(
/(<script[^>]*>\s*\n)/,
`$1import { ${imports} } from '@element-plus/icons-vue';\n`
);
c = c.replace(/export default \{/, (m) => {
if (/components:\s*\{/.test(c)) {
return m;
}
return `${m}\n components: { ${imports} },`;
});
if (/components:\s*\{/.test(c) && !c.includes(`${[...used][0]},`)) {
c = c.replace(/components:\s*\{([^}]*)\}/, (m, inner) => {
const existing = inner.trim();
const add = [...used].filter((n) => !inner.includes(n)).join(", ");
if (!add) return m;
const merged = existing ? `${existing}, ${add}` : add;
return `components: { ${merged} }`;
});
}
}
return c;
}
let count = 0;
for (const file of walkDir(ROOT)) {
const original = fs.readFileSync(file, "utf8");
if (!/<Icon\b/.test(original)) continue;
const fixed = transform(original, file);
if (fixed !== original) {
fs.writeFileSync(file, fixed);
count++;
console.log(path.relative(ROOT, file));
}
}
console.log(`Icon replacements in ${count} files`);

View File

@@ -0,0 +1,63 @@
#!/usr/bin/env node
/**
* 将全局 <Page> 封装替换为原生 el-pagination
*/
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..", "src");
function walkDir(dir, files = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walkDir(full, files);
else if (entry.name.endsWith(".vue")) files.push(full);
}
return files;
}
function transformPage(content) {
return content.replace(
/<Page\b([^>]*?)(\/>|>(\s*)<\/Page>)/gs,
(match, attrs, close) => {
let a = attrs;
const hasTotal = /\bshow-total\b/.test(a);
const hasSizer = /\bshow-sizer\b/.test(a);
const hasElevator = /\bshow-elevator\b/.test(a);
a = a.replace(/\s+show-total\b/g, "");
a = a.replace(/\s+show-sizer\b/g, "");
a = a.replace(/\s+show-elevator\b/g, "");
a = a.replace(/:current=/g, ":current-page=");
a = a.replace(/:page-size-opts=/g, ":page-sizes=");
a = a.replace(/@change=/g, "@current-change=");
a = a.replace(/\ssize="small"/g, " small");
const layoutParts = [];
if (hasTotal) layoutParts.push("total");
if (hasSizer) layoutParts.push("sizes");
layoutParts.push("prev", "pager", "next");
if (hasElevator) layoutParts.push("jumper");
const layoutAttr = ` layout="${layoutParts.join(", ")}"`;
if (close.startsWith("/>")) {
return `<el-pagination${a}${layoutAttr} />`;
}
return `<el-pagination${a}${layoutAttr}></el-pagination>`;
}
);
}
let count = 0;
for (const file of walkDir(ROOT)) {
const original = fs.readFileSync(file, "utf8");
if (!/<Page\b/.test(original)) continue;
const fixed = transformPage(original);
if (fixed !== original) {
fs.writeFileSync(file, fixed);
count++;
console.log(path.relative(ROOT, file));
}
}
console.log(`Page → el-pagination in ${count} files`);

View File

@@ -4,60 +4,78 @@
</div> </div>
</template> </template>
<script> <script setup>
import { onMounted } from "vue";
import { useRoute } from "vue-router";
import storage from "@/plugins/storage"; import storage from "@/plugins/storage";
import { getBaseSite } from "@/api/common.js"; import { getBaseSite, getThemeSetting } from "@/api/common.js";
export default { import { fetchAndApplyTheme } from "@/utils/theme";
name: "App", import { useAppStore } from "@/stores/app";
mounted() {
this.init();
},
methods:{
init(){
if(!storage.getItem("siteName")||!storage.getItem("logoImg")||!storage.getItem("sitelogo_expiration_time")) {
this.getSite();
}else{
// 如果缓存过期,则获取最新的信息
if (new Date() > storage.getItem("sitelogo_expiration_time")) {
this.getSite();
return;
}else{
window.document.title = storage.getItem("siteName");
//动态获取icon
let link =document.querySelector("link[rel*='icon']") ||document.createElement("link");
link.type = "image/x-icon";
link.href = storage.getItem("siteIcon");
link.rel = "shortcut icon";
document.getElementsByTagName("head")[0].appendChild(link);
}
}
}, defineOptions({ name: "App" });
getSite(){
//获取基本站点信息 const route = useRoute();
getBaseSite().then((res) => { const appStore = useAppStore();
if (res.success && res.result.settingValue) {
let data = JSON.parse(res.result.settingValue); function applySiteInfo(data) {
// 过期时间 const expirationTime = new Date().setHours(new Date().getHours() + 1);
var expirationTime = new Date().setHours(new Date().getHours() + 1); storage.setItem("sitelogo_expiration_time", expirationTime);
// 存放过期时间 storage.setItem("siteName", data.siteName);
storage.setItem("sitelogo_expiration_time", expirationTime); storage.setItem("logoImg", data.buyerSideLogo);
// 存放信息 storage.setItem("siteIcon", data.buyerSideIcon);
storage.setItem('siteName', data.siteName); appStore.setSiteName(data.siteName);
storage.setItem('logoImg', data.buyerSideLogo); appStore.setLogoImg(data.buyerSideLogo);
storage.setItem("siteIcon",data.buyerSideIcon); if (route.meta.title === undefined) {
window.document.title = data.siteName; window.document.title = data.siteName;
//动态获取icon
let link =document.querySelector("link[rel*='icon']") ||document.createElement("link");
link.type = "image/x-icon";
link.href = data.buyerSideIcon;
link.rel = "shortcut icon";
document.getElementsByTagName("head")[0].appendChild(link);
}
});
}
} }
}; let link =
document.querySelector("link[rel*='icon']") ||
document.createElement("link");
link.type = "image/x-icon";
link.href = data.buyerSideIcon;
link.rel = "shortcut icon";
document.getElementsByTagName("head")[0].appendChild(link);
}
function init() {
const expirationTime = storage.getItem("sitelogo_expiration_time");
const cacheValid =
expirationTime && new Date() <= new Date(Number(expirationTime));
if (cacheValid) {
applySiteInfo({
siteName: storage.getItem("siteName"),
buyerSideLogo: storage.getItem("logoImg"),
buyerSideIcon: storage.getItem("siteIcon"),
});
}
getSite();
getTheme();
}
function getSite() {
getBaseSite().then((res) => {
if (res.success && res.result.settingValue) {
try {
const data = JSON.parse(res.result.settingValue);
applySiteInfo(data);
} catch {
// 站点配置解析失败时使用缓存
}
}
}).catch((err) => {
console.warn('获取站点配置失败', err);
});
}
function getTheme() {
fetchAndApplyTheme(getThemeSetting).catch((err) => {
console.warn('获取主题配置失败', err);
});
}
onMounted(() => {
init();
});
</script> </script>
<style lang="scss"> <style lang="scss">
#app { #app {

View File

@@ -44,7 +44,8 @@ export function editMemberInfo (params) {
url: '/buyer/passport/member/editOwn', url: '/buyer/passport/member/editOwn',
method: Method.PUT, method: Method.PUT,
needToken: true, needToken: true,
data: params data: params,
loading: false
}) })
} }
@@ -54,7 +55,9 @@ export function editPwd (params) {
url: `/buyer/passport/member/modifyPass`, url: `/buyer/passport/member/modifyPass`,
method: Method.PUT, method: Method.PUT,
needToken: true, needToken: true,
data: params data: params,
loading: false,
skipMessage: true
}) })
} }

View File

@@ -1,5 +1,5 @@
import request, { import request, {
Method, commonUrl Method, buyerUrl
} from '@/plugins/request.js'; } from '@/plugins/request.js';
// 会员收货地址列表 // 会员收货地址列表
@@ -17,7 +17,8 @@ export function newMemberAddress (params) {
url: '/buyer/member/address', url: '/buyer/member/address',
needToken: true, needToken: true,
method: Method.POST, method: Method.POST,
data: params data: params,
loading: false
}); });
} }
@@ -27,7 +28,8 @@ export function editMemberAddress (params) {
url: '/buyer/member/address', url: '/buyer/member/address',
needToken: true, needToken: true,
method: Method.PUT, method: Method.PUT,
params params,
loading: false
}); });
} }
@@ -36,7 +38,8 @@ export function delMemberAddress (id) {
return request({ return request({
url: `/buyer/member/address/delById/${id}`, url: `/buyer/member/address/delById/${id}`,
needToken: true, needToken: true,
method: Method.DELETE method: Method.DELETE,
loading: false
}); });
} }
@@ -52,7 +55,7 @@ export function getAddrDetail (id) {
// 传给后台citycode 获取城市街道等id // 传给后台citycode 获取城市街道等id
export function handleRegion (params) { export function handleRegion (params) {
return request({ return request({
url: `${commonUrl}/common/common/region/region`, url: `${buyerUrl}/buyer/common/region/region`,
needToken: true, needToken: true,
method: Method.GET, method: Method.GET,
params params

View File

@@ -20,7 +20,8 @@ export function cartGoodsAll () {
return request({ return request({
url: '/buyer/trade/carts/all', url: '/buyer/trade/carts/all',
method: Method.GET, method: Method.GET,
needToken: true needToken: true,
loading: false
}); });
} }
@@ -64,7 +65,7 @@ export function addCartGoods (params) {
/** /**
* 创建交易 * 创建交易
* @param client 客户端:H5/移动端 PC/PC端,WECHAT_MP/小程序端,APP/移动应用端 * @param client 客户端:H5/移动端 PC/PC端,WECHAT_MP/小程序端,APP/移动应用端
* @param way 购物车购买:CART/立即购买:BUY_NOW / 积分购买:POINT * @param way 购物车:CART / 立即购买:BUY_NOW / 积分:POINTS / 拼团:PINTUAN / 砍价:KANJIA
* @param remark 备注 非必填 * @param remark 备注 非必填
*/ */
export function createTrade (data) { export function createTrade (data) {
@@ -80,7 +81,7 @@ export function createTrade (data) {
/** /**
* 选择优惠券 * 选择优惠券
* @param memberCouponId 优惠券id * @param memberCouponId 优惠券id
* @param way 购物车购买:CART/立即购买:BUY_NOW/ 积分购买:POINT * @param way 购物车:CART / 立即购买:BUY_NOW / 积分:POINTS
* @param used 使用true 弃用 false * @param used 使用true 弃用 false
*/ */
export function selectCoupon (params) { export function selectCoupon (params) {
@@ -92,6 +93,21 @@ export function selectCoupon (params) {
}); });
} }
/**
* 选择礼品卡
* @param way 购物车:CART / 立即购买:BUY_NOW / 拼团:PINTUAN / 积分:POINTS / 砍价:KANJIA
* @param credentialId 礼品卡凭证 id;used=false 且不传则清空全部
* @param used 使用 true / 弃用 false
*/
export function selectGiftCard (params) {
return request({
url: '/buyer/trade/carts/select/giftCard',
method: Method.GET,
needToken: true,
params
});
}
/** /**
* 可用优惠券数量 * 可用优惠券数量
*/ */

View File

@@ -1,11 +1,11 @@
import request, {Method, commonUrl} from '@/plugins/request.js'; import request, {Method, buyerUrl} from '@/plugins/request.js';
/** /**
* 获取拼图验证 * 获取拼图验证
*/ */
export function getVerifyImg (verificationEnums) { export function getVerifyImg (verificationEnums) {
return request({ return request({
url: `${commonUrl}/common/common/slider/${verificationEnums}`, url: `${buyerUrl}/buyer/common/slider/${verificationEnums}`,
method: Method.GET, method: Method.GET,
needToken: false needToken: false
}); });
@@ -15,7 +15,7 @@ export function getVerifyImg (verificationEnums) {
*/ */
export function postVerifyImg (params) { export function postVerifyImg (params) {
return request({ return request({
url: `${commonUrl}/common/common/slider/${params.verificationEnums}`, url: `${buyerUrl}/buyer/common/slider/${params.verificationEnums}`,
method: Method.POST, method: Method.POST,
needToken: false, needToken: false,
params params
@@ -26,7 +26,7 @@ export function postVerifyImg (params) {
*/ */
export function sendSms (params) { export function sendSms (params) {
return request({ return request({
url: `${commonUrl}/common/common/sms/${params.verificationEnums}/${params.mobile}`, url: `${buyerUrl}/buyer/common/sms/${params.verificationEnums}/${params.mobile}`,
method: Method.GET, method: Method.GET,
needToken: false, needToken: false,
params params
@@ -36,9 +36,10 @@ export function sendSms (params) {
// 地区数据,用于三级联动 // 地区数据,用于三级联动
export function getRegion (id) { export function getRegion (id) {
return request({ return request({
url: `${commonUrl}/common/common/region/item/${id}`, url: `${buyerUrl}/buyer/common/region/item/${id}`,
needToken: true, needToken: true,
method: Method.GET method: Method.GET,
loading: false
}); });
} }
@@ -79,7 +80,7 @@ export function articleDetail (id) {
// 获取IM接口前缀 // 获取IM接口前缀
export function getIMDetail () { export function getIMDetail () {
return request({ return request({
url: `${commonUrl}/common/common/IM`, url: `${buyerUrl}/buyer/common/IM`,
method: Method.GET method: Method.GET
}); });
} }
@@ -88,8 +89,17 @@ export function getIMDetail () {
//获取图片logo //获取图片logo
export function getBaseSite(){ export function getBaseSite(){
return request ({ return request ({
url:`${commonUrl}/common/common/site`, url:`${buyerUrl}/buyer/common/site`,
method: Method.GET, method: Method.GET,
needToken: false needToken: false
}) })
} }
// 获取主题色配置
export function getThemeSetting() {
return request({
url: `${buyerUrl}/buyer/common/site/theme`,
method: Method.GET,
needToken: false,
});
}

View File

@@ -5,9 +5,10 @@ import request, {
// 商品列表 // 商品列表
export function goodsList (params) { export function goodsList (params) {
return request({ return request({
url: '/buyer/goods/goods/es', url: '/buyer/goods/goods/search',
method: Method.GET, method: Method.GET,
needToken: false, needToken: false,
optionalToken: true,
params params
}); });
} }
@@ -15,9 +16,10 @@ export function goodsList (params) {
// 商品分类 筛选条件 品牌, 尺寸等 // 商品分类 筛选条件 品牌, 尺寸等
export function filterList (params) { export function filterList (params) {
return request({ return request({
url: '/buyer/goods/goods/es/related', url: '/buyer/goods/goods/search/related',
method: Method.GET, method: Method.GET,
needToken: false, needToken: false,
optionalToken: true,
params params
}); });
} }
@@ -31,12 +33,13 @@ export function goodsDetail (goodsId) {
}); });
} }
// id获取商品Sku详情 // id获取商品Sku详情(支持未登录浏览,登录后可选携带 token)
export function goodsSkuDetail (params) { export function goodsSkuDetail (params) {
return request({ return request({
url: `/buyer/goods/goods/sku/${params.goodsId}/${params.skuId}`, url: `/buyer/goods/goods/sku/${params.goodsId}/${params.skuId}`,
method: Method.GET, method: Method.GET,
needToken: true, needToken: false,
optionalToken: true,
params params
}); });
} }

View File

@@ -1,5 +1,16 @@
import request, { Method } from "@/plugins/request.js"; import request, { Method } from "@/plugins/request.js";
/**
* 获取首页专题数据
*/
export function getTopicData(id) {
return request({
url: `/buyer/other/pageData/get/${id}`,
method: Method.GET,
});
}
// 获取首页楼层装修数据 // 获取首页楼层装修数据
export function indexData(params) { export function indexData(params) {
return request({ return request({
@@ -52,6 +63,9 @@ export function handleRefreshToken(token) {
url: `/buyer/passport/member/refresh/${token}`, url: `/buyer/passport/member/refresh/${token}`,
method: Method.GET, method: Method.GET,
needToken: false, needToken: false,
// 刷新令牌请求自身不再进入 401/403 刷新分支,避免拦截器递归死锁;失败也不弹全局错误提示
skipAuthHandler: true,
skipMessage: true,
}); });
} }

View File

@@ -69,6 +69,32 @@ export function loginCallback (uuid) {
}); });
} }
export function getThirdAccountBindList () {
return request({
url: '/buyer/passport/connect/bind/list',
method: Method.GET,
needToken: true
});
}
export function bindThirdAccount (data) {
return request({
url: '/buyer/passport/connect/bind',
method: Method.POST,
needToken: true,
data
});
}
export function unbindThirdAccount (data) {
return request({
url: '/buyer/passport/connect/bind/unbind',
method: Method.POST,
needToken: true,
data
});
}
/** /**
* 忘记密码 验证手机验证码 * 忘记密码 验证手机验证码
*/ */
@@ -98,6 +124,7 @@ export function getSCLoginCode(params) {
url: `/buyer/passport/member/pc_session`, url: `/buyer/passport/member/pc_session`,
method: Method.POST, method: Method.POST,
needToken: false, needToken: false,
loading: false,
params params
}); });
} }
@@ -106,6 +133,19 @@ export function sCLogin(token,params) {
url: `/buyer/passport/member/session_login/`+token, url: `/buyer/passport/member/session_login/`+token,
method: Method.POST, method: Method.POST,
needToken: false, needToken: false,
loading: false,
skipMessage: true,
// 长轮询后端最长挂起 20s,需大于该值,避免 axios 10s 默认超时把二维码请求掐断
timeout: 25000,
params params
}); });
} }
export function getWechatH5QrCode (params) {
return request({
url: '/buyer/other/wechat/h5/qrcode',
method: Method.GET,
needToken: true,
params
})
}

View File

@@ -95,21 +95,25 @@ export function cancelStoreCollect (type, id) {
}); });
} }
// 查看是否收藏商品 // 查看是否收藏商品(后台查询,token 失效时静默失败,不弹全局登录框)
export function isCollection (type, goodsId) { export function isCollection (type, goodsId) {
return request({ return request({
url: `/buyer/member/collection/isCollection/${type}/${goodsId}`, url: `/buyer/member/collection/isCollection/${type}/${goodsId}`,
method: Method.GET, method: Method.GET,
needToken: true needToken: true,
skipAuthHandler: true,
skipMessage: true
}); });
} }
// 查看是否收藏店铺 // 查看是否收藏店铺(后台查询,token 失效时静默失败,不弹全局登录框)
export function isStoreCollection (type, goodsId) { export function isStoreCollection (type, goodsId) {
return request({ return request({
url: `/buyer/member/storeCollection/isCollection/${type}/${goodsId}`, url: `/buyer/member/storeCollection/isCollection/${type}/${goodsId}`,
method: Method.GET, method: Method.GET,
needToken: true needToken: true,
skipAuthHandler: true,
skipMessage: true
}); });
} }
@@ -133,22 +137,26 @@ export function storeCollectList (params) {
}); });
} }
// 单个商品评价 // 单个商品评价(需登录;token 失效时静默失败,不弹全局登录框)
export function goodsComment (params) { export function goodsComment (params) {
return request({ return request({
url: `/buyer/member/evaluation/${params.goodsId}/goodsEvaluation`, url: `/buyer/member/evaluation/${params.goodsId}/goodsEvaluation`,
method: Method.GET, method: Method.GET,
needToken: false, needToken: true,
skipAuthHandler: true,
skipMessage: true,
params params
}); });
} }
// 商品各评价类别数量 // 商品各评价类别数量(需登录;token 失效时静默失败,不弹全局登录框)
export function goodsCommentNum (goodsId) { export function goodsCommentNum (goodsId) {
return request({ return request({
url: `/buyer/member/evaluation/${goodsId}/evaluationNumber`, url: `/buyer/member/evaluation/${goodsId}/evaluationNumber`,
method: Method.GET, method: Method.GET,
needToken: false needToken: true,
skipAuthHandler: true,
skipMessage: true
}); });
} }
@@ -158,7 +166,7 @@ export function addEvaluation (params) {
url: `/buyer/member/evaluation`, url: `/buyer/member/evaluation`,
method: Method.POST, method: Method.POST,
needToken: true, needToken: true,
params data: params
}); });
} }
@@ -180,6 +188,15 @@ export function receiptList () {
}); });
} }
// 发票详情
export function receiptDetail (id) {
return request({
url: `/buyer/trade/receipt/${id}`,
method: Method.GET,
needToken: true
});
}
// 保存发票信息 // 保存发票信息
export function saveReceipt (params) { export function saveReceipt (params) {
return request({ return request({
@@ -196,6 +213,7 @@ export function couponList (params) {
url: `/buyer/promotion/coupon`, url: `/buyer/promotion/coupon`,
method: Method.GET, method: Method.GET,
needToken: true, needToken: true,
loading: false,
params params
}); });
} }
@@ -288,7 +306,8 @@ export function cancelAfterSale (afterSaleSn) {
return request({ return request({
url: `/buyer/order/afterSale/cancel/${afterSaleSn}`, url: `/buyer/order/afterSale/cancel/${afterSaleSn}`,
method: Method.POST, method: Method.POST,
needToken: true needToken: true,
loading: false
}); });
} }
@@ -333,93 +352,6 @@ export function clearComplain (id) {
}); });
} }
/**
* 获取当前会员分销信息
*/
export function distribution () {
return request({
url: `/buyer/distribution/distribution`,
method: Method.GET,
needToken: true
});
}
/**
* 申请成为分销商
* @param idNumber 身份证号
* @param name 名字
*/
export function applyDistribution (params) {
return request({
url: `/buyer/distribution/distribution`,
method: Method.POST,
needToken: true,
params
});
}
/**
* 获取分销商订单列表
*/
export function getDistOrderList (params) {
return request({
url: `/buyer/distribution/order`,
method: Method.GET,
needToken: true,
params
});
}
/**
* 获取分销商商品列表
*/
export function getDistGoodsList (params) {
return request({
url: `/buyer/distribution/goods`,
method: Method.GET,
needToken: true,
params
});
}
/**
* 绑定、解绑分销商品
* @param distributionGoodsId 分销商品id
* @param checked 分销商品id,true为绑定,false为解绑
*/
export function selectDistGoods (params) {
return request({
url: `/buyer/distribution/goods/checked/${params.distributionGoodsId}`,
method: Method.GET,
needToken: true,
params
});
}
/**
* 分销商提现历史
*/
export function distCashHistory (params) {
return request({
url: `/buyer/distribution/cash`,
method: Method.GET,
needToken: true,
params
});
}
/**
* 分销商提现
*/
export function distCash (params) {
return request({
url: `/buyer/distribution/cash`,
method: Method.POST,
needToken: true,
params
});
}
/** /**
* 我的足迹 * 我的足迹
* @param {Number} pageNumber 页码 * @param {Number} pageNumber 页码
@@ -480,6 +412,31 @@ export function memberPointHistory (params) {
params params
}); });
} }
export function memberGradeCurrent (params) {
return request({
url: `/buyer/member/memberGrade/current`,
method: Method.GET,
needToken: true,
params
})
}
export function memberGradeRules () {
return request({
url: `/buyer/member/memberGrade/rules`,
method: Method.GET,
needToken: true
})
}
export function memberGradeList () {
return request({
url: `/buyer/member/memberGrade/list`,
method: Method.GET,
needToken: true
})
}
/** /**
* 分页获取会员站内信 * 分页获取会员站内信
* @param {Object} params 请求参数,包括pageNumber、pageSize、status * @param {Object} params 请求参数,包括pageNumber、pageSize、status
@@ -497,6 +454,15 @@ export function memberMsgList (params) {
* @param {String} messageId 消息id * @param {String} messageId 消息id
*/ */
export function getMemberMessage (id) {
return request({
url: `/buyer/message/member/${id}`,
method: Method.GET,
needToken: true,
skipMessage: true
});
}
export function readMemberMsg (id) { export function readMemberMsg (id) {
return request({ return request({
url: `/buyer/message/member/${id}`, url: `/buyer/message/member/${id}`,

View File

@@ -115,3 +115,21 @@ export function getLogisticsCompany () {
params: { pageNumber: 1, pageSize: 200, disabled: 'OPEN' } params: { pageNumber: 1, pageSize: 200, disabled: 'OPEN' }
}); });
} }
//查询包裹列表
export const getPackage = (sn) => {
return request({
url: `/buyer/order/order/getPackage/${sn}`,
method: Method.GET,
needToken: true,
})
}
//查询物流
export const getTracesList = (sn, params) => {
return request({
url: `/buyer/order/order/getTracesList/${sn}`,
method: Method.GET,
needToken: true,
})
};

View File

@@ -24,12 +24,13 @@ export function tradeDetail (params) {
* @param payClient 调起方式 可用值:APP,NATIVE,JSAPI,H5 * @param payClient 调起方式 可用值:APP,NATIVE,JSAPI,H5
* @param sn 订单编号 * @param sn 订单编号
*/ */
export function pay (params) { export function pay (params, config = {}) {
return request({ return request({
url: `/buyer/payment/cashier/pay/${params.paymentMethod}/${params.paymentClient}`, url: `/buyer/payment/cashier/pay/${params.paymentMethod}/${params.paymentClient}`,
needToken: true, needToken: true,
method: Method.GET, method: Method.GET,
params params,
skipMessage: !!config.skipMessage,
}); });
} }

View File

@@ -57,3 +57,50 @@ export function pointGoodsDetail (id) {
id id
}); });
} }
/**
* 分页查询当前客户礼品卡
*/
export function getGiftCardCashMemberCardPage (params) {
return request({
url: '/buyer/promotion/giftCardCash/memberCard',
method: Method.GET,
needToken: true,
params
});
}
/**
* 绑定礼品卡(卡号 + 兑换码/卡密)
*/
export function bindGiftCardCashMemberCard (data) {
return request({
url: '/buyer/promotion/giftCardCash/memberCard/bind',
method: Method.POST,
needToken: true,
headers: { 'Content-Type': 'application/json' },
data
});
}
/**
* 激活礼品卡
*/
export function activateGiftCardCashMemberCard (memberCardId) {
return request({
url: `/buyer/promotion/giftCardCash/memberCard/${memberCardId}/activate`,
method: Method.POST,
needToken: true
});
}
/**
* 下单页可用礼品卡列表
*/
export function getAvailableGiftCardCashMemberCards () {
return request({
url: '/buyer/promotion/giftCardCash/memberCard/available',
method: Method.GET,
needToken: true
});
}

View File

@@ -6,7 +6,8 @@ import request, {
export function shopList (params) { export function shopList (params) {
return request({ return request({
url: '/buyer/store/store', url: '/buyer/store/store',
needToken: true, needToken: false,
optionalToken: true,
method: Method.GET, method: Method.GET,
params params
}) })
@@ -42,11 +43,31 @@ export function applyThird (params) {
}) })
} }
// 店铺信息(含评分)
export function getStoreById (id) {
return request({
url: `/buyer/store/store/store/${id}`,
needToken: false,
method: Method.GET
})
}
// 店铺详情 // 店铺详情
// 获取店铺工商/营业执照信息
export function getLicencePhoto (id) {
return request({
url: `/buyer/store/store/get/licencePhoto/${id}`,
needToken: false,
optionalToken: true,
method: Method.GET
})
}
export function getDetailById (id) { export function getDetailById (id) {
return request({ return request({
url: `/buyer/store/store/get/detail/${id}`, url: `/buyer/store/store/get/detail/${id}`,
needToken: true, needToken: false,
optionalToken: true,
method: Method.GET method: Method.GET
}) })
} }
@@ -72,7 +93,8 @@ export function applyStatus () {
return request({ return request({
url: `/buyer/store/store/apply`, url: `/buyer/store/store/apply`,
needToken: true, needToken: true,
method: Method.GET method: Method.GET,
loading: false
}) })
} }

View File

@@ -0,0 +1,29 @@
import back from "./back.png";
import carts from "./carts.png";
import collage from "./collage.png";
import feedback from "./feedback.png";
import notice from "./notice.png";
import notification from "./notification.png";
import shop from "./shop.png";
import story from "./story.png";
import support from "./support.png";
import user from "./user.png";
const iconMap = {
back,
carts,
collage,
feedback,
notice,
notification,
shop,
story,
support,
user,
};
export function getIconUrl(name) {
return iconMap[name] || "";
}
export default iconMap;

View File

@@ -0,0 +1,40 @@
:root {
--el-color-primary: var(--theme-color, #ff3c2a);
--el-color-danger: var(--theme-color, #ff3c2a);
--el-font-size-base: 14px;
}
.el-table--border {
.el-table__cell {
border-right: none !important;
}
}
.link-text {
color: var(--theme-color, #ff3c2a);
cursor: pointer;
text-decoration: none;
&:hover {
opacity: 0.85;
}
}
.op-split {
margin: 0 8px;
color: #dcdfe6;
}
/* 次要强调按钮,使用高亮主题色(如「立即购买」) */
.confirm-receipt-btn {
background-color: var(--theme-light, #ff6b35) !important;
border-color: var(--theme-light, #ff6b35) !important;
color: #fff !important;
&:hover,
&:focus {
background-color: color-mix(in srgb, var(--theme-light, #ff6b35) 88%, black) !important;
border-color: color-mix(in srgb, var(--theme-light, #ff6b35) 88%, black) !important;
color: #fff !important;
}
}

View File

@@ -0,0 +1,25 @@
// 全局布局工具类(非 scoped,避免子组件继承 class 时宽度失效)
.width_1200_auto {
width: 1200px;
max-width: 1200px;
margin-left: auto;
margin-right: auto;
box-sizing: border-box;
}
// 首页主体:分类导航 + 楼层装修共用同一 1200px 容器,保证列对齐
.index-main {
width: 1200px;
max-width: 1200px;
margin-left: auto;
margin-right: auto;
box-sizing: border-box;
position: relative;
}
// 与 CateNav 商品分类栏、Carousel 左占位一致的列宽
$index-main-width: 1200px;
$index-side-col-width: 263.2px;
$index-col-gap: 10px;
// 1200 - 263.2*2 - 10*2 = 653.6px
$index-center-col-width: $index-main-width - ($index-side-col-width * 2) - ($index-col-gap * 2);

View File

@@ -10,7 +10,7 @@ $warning_color: #ff9900;
$error_color: #ed3f14; $error_color: #ed3f14;
$handle-btn-color: #438cde; $handle-btn-color: #438cde;
$theme_color: #F31947; $theme_color: var(--theme-color, #ff3c2a);
$border_color: #dddee1; $border_color: #dddee1;
$title_color: #8c8c8c; $title_color: #8c8c8c;
@@ -30,6 +30,7 @@ $dark_content_color: #d5d5d5;
display: -webkit-box; display: -webkit-box;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden; overflow: hidden;
} }
/***** 封装一些方法可用于 黑暗主题 ,明亮主题 *****/ /***** 封装一些方法可用于 黑暗主题 ,明亮主题 *****/
@@ -305,9 +306,11 @@ li {
} }
.width_1200_auto { .width_1200_auto {
width: 1184px; width: 1200px;
max-width: 1200px;
margin: 0 auto; margin: 0 auto;
box-sizing: border-box;
} }
@@ -325,18 +328,24 @@ li {
.goods-list { .goods-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
width: 1200px; width: 100%;
margin: 0 auto; margin: 0 auto;
column-gap: 10px;
} }
.goods-show-info { .goods-show-info {
width: 235px; width: calc(20% - 8px);
padding: 6px; padding: 6px;
margin: 10px 0px; margin: 10px 0;
margin-left: 5px;
position: relative; position: relative;
border: 1px solid #fff; border: 1px solid #fff;
cursor: pointer; cursor: pointer;
background-color: #fff; background-color: #fff;
box-sizing: border-box;
}
.goods-show-img img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
} }
.goods-show-info:hover { .goods-show-info:hover {
border: 1px solid #ccc; border: 1px solid #ccc;
@@ -346,16 +355,17 @@ li {
margin-top: 6px; margin-top: 6px;
} }
.goods-show-detail { .goods-show-detail {
font-size: 12px; font-size: 14px;
line-height: 21px;
margin: 6px 0px; margin: 6px 0px;
} }
.goods-show-num { .goods-show-num {
font-size: 12px; font-size: 14px;
margin-bottom: 6px; margin-bottom: 6px;
color: #666; color: #666;
} }
.goods-show-num { .goods-show-num {
font-size: 12px; font-size: 14px;
margin-bottom: 6px; margin-bottom: 6px;
color: #009688; color: #009688;
} }
@@ -364,11 +374,16 @@ li {
font-weight: bold; font-weight: bold;
} }
.goods-show-seller { .goods-show-seller {
font-size: 12px; font-size: 14px;
color: $theme_color; color: $theme_color;
} }
.goods-page { .goods-page {
margin:10px auto ; margin: 10px 0;
text-align: right; width: 100%;
width: 1200px; display: flex;
justify-content: flex-end;
:deep(.el-pagination) {
justify-content: flex-end;
}
} }

View File

@@ -9,18 +9,24 @@
.goods-list { .goods-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
width: 1200px; width: 100%;
margin: 0 auto; margin: 0 auto;
column-gap: 10px;
} }
.goods-show-info { .goods-show-info {
width: 235px; width: calc(20% - 8px);
padding: 6px; padding: 6px;
margin: 10px 0px; margin: 10px 0;
margin-left: 5px;
position: relative; position: relative;
border: 1px solid #fff; border: 1px solid #fff;
cursor: pointer; cursor: pointer;
background-color: #fff; background-color: #fff;
box-sizing: border-box;
}
.goods-show-img img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
} }
.goods-show-info:hover { .goods-show-info:hover {
border: 1px solid #ccc; border: 1px solid #ccc;
@@ -30,13 +36,15 @@
margin-top: 6px; margin-top: 6px;
} }
.goods-show-detail { .goods-show-detail {
height: 36px; height: 42px;
font-size: 12px; font-size: 14px;
line-height: 21px;
margin: 6px 0px; margin: 6px 0px;
overflow:hidden; overflow:hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
display:-webkit-box; display:-webkit-box;
-webkit-line-clamp:2; -webkit-line-clamp:2;
line-clamp: 2;
-webkit-box-orient:vertical; -webkit-box-orient:vertical;
color:#808080; color:#808080;
} }
@@ -44,12 +52,12 @@
color:#E23A3A; color:#E23A3A;
} }
.goods-show-num { .goods-show-num {
font-size: 12px; font-size: 14px;
margin-bottom: 6px; margin-bottom: 6px;
color: #666; color: #666;
} }
.goods-show-num { .goods-show-num {
font-size: 12px; font-size: 14px;
margin-bottom: 6px; margin-bottom: 6px;
color: #009688; color: #009688;
} }
@@ -58,11 +66,16 @@
font-weight: bold; font-weight: bold;
} }
.goods-show-seller { .goods-show-seller {
font-size: 12px; font-size: 14px;
color: $theme_color; color: $theme_color;
} }
.goods-page { .goods-page {
margin:10px auto ; margin: 10px 0;
text-align: right; width: 100%;
width: 1200px; display: flex;
justify-content: flex-end;
:deep(.el-pagination) {
justify-content: flex-end;
}
} }

View File

@@ -1,6 +1,4 @@
@import "~view-design/src/styles/index.less"; // Element Plus 主题变量(原 view-design less 已移除)
// iview 自定义样式
@primary-color: #ff5c58; @primary-color: #ff5c58;
@info-color: #fa6419; @info-color: #fa6419;
@success-color: #68cabe; @success-color: #68cabe;
@@ -11,9 +9,7 @@
@table-td-highlight-bg: #ededed; @table-td-highlight-bg: #ededed;
@font-size-base: 12px; @font-size-base: 12px;
.ivu-drawer, .el-drawer,
.drawer, .drawer {
.ivu-drawer-wrap {
z-index: 2600 !important; z-index: 2600 !important;
} }

View File

@@ -1,247 +1,472 @@
<template> <template>
<div class="navbar" :class="{'small-search-box':useClass == 'fixed-show'}"> <div class="navbar" ref="rootRef" :class="{'small-search-box': useClass == 'fixed-show'}">
<div class="container width_1200_auto flex flex-a-c"> <template v-if="useClass == 'fixed-show'">
<img <div class="search search-embedded">
:src="$store.state.logoImg" <el-input
v-if="showLogo"
class="logo-img"
alt=""
@click="$router.push('/')"
/>
<div :class="{'small-search-box':useClass == 'fixed-show'}" class="search-box">
<i-input
v-model="searchData" v-model="searchData"
size="large" size="large"
class="search " class="search-input"
placeholder="输入你想查找的商品" :placeholder="placeholderText"
@keyup.enter.native="search" @focus="openPanel"
> @input="queueSuggest"
@keyup.enter="search"
<div class="search-icon" slot="append" @click="search"> />
<Icon type="ios-search" size="21"/> <div class="search-btn" @click="search">
<el-icon :size="21"><SearchIcon /></el-icon>
</div>
<div v-if="showPanel" class="suggest-panel" @mousedown.prevent>
<SearchSuggest
:keyword="trimmedKeyword"
:history="history"
:hot-words="promotionTags"
:suggestions="suggestions"
:suggesting="suggesting"
@pick="selectTags"
@clear="clearHistory"
/>
</div>
</div>
</template>
<template v-else>
<div class="container width_1200_auto flex flex-a-c">
<img
:src="logoImg"
v-if="showLogo"
class="logo-img"
alt=""
@click="$router.push('/')"
/>
<div class="search-box">
<!-- 搜索类型切换:商品 / 店铺(店铺内搜索时不展示) -->
<div v-if="showTypeSwitch" class="search-type">
<span
v-for="item in searchTypes"
:key="item.value"
class="search-type-item"
:class="{ active: searchType === item.value }"
@click="switchType(item.value)"
>{{ item.label }}</span>
</div> </div>
</i-input> <div class="search">
<template v-if="showTag"> <el-input
<div class="only-store" v-if="storeId" @click="research()"> v-model="searchData"
切换为{{!onlyStore ? '店铺内' : '平台'}}搜索 size="large"
class="search-input"
</div> :placeholder="placeholderText"
<div v-if="promotionTags.length === 0"></div> @focus="openPanel"
<div v-else class="history-list flex"> @input="queueSuggest"
<div @keyup.enter="search"
v-for="(item, index) in promotionTags" />
:key="index" <div class="search-btn" @click="search">
class="mr_10" <el-icon :size="21"><SearchIcon /></el-icon>
> </div>
<span class="history-item" @click="selectTags(item)">{{ item }}</span> <div v-if="showPanel" class="suggest-panel" @mousedown.prevent>
<SearchSuggest
:keyword="trimmedKeyword"
:history="history"
:hot-words="promotionTags"
:suggestions="suggestions"
:suggesting="suggesting"
@pick="selectTags"
@clear="clearHistory"
/>
</div> </div>
</div> </div>
</template> <template v-if="showTag">
<div v-if="promotionTags.length === 0"></div>
<div v-else class="history-list flex">
<div
v-for="(item, index) in promotionTags"
:key="index"
class="mr_10"
>
<span class="history-item" @click="selectTags(item)">{{ item }}</span>
</div>
</div>
</template>
</div>
</div> </div>
</div> </template>
</div> </div>
</template> </template>
<script> <script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import storage from '@/plugins/storage.js' import storage from '@/plugins/storage.js'
import {hotWords} from '@/api/goods.js' import { goodsList, hotWords } from '@/api/goods.js'
import { Search as SearchIcon } from '@element-plus/icons-vue'
import SearchSuggest from '@/components/SearchSuggest.vue'
import { useAppStore } from '@/stores/app'
export default { defineOptions({ name: 'search' })
name: 'search',
props: {
showTag: { // 是否展示搜索栏下方热门搜索
type: Boolean,
default: true
},
showLogo: { // 是否展示左侧logo
type: Boolean,
default: true
},
storeId: { // 是否为店铺页面
type: String,
default: ""
},
hover: {
type: Boolean,
default: false
},
useClass:{
type:null,
default:''
}
},
watch:{
storeId(val){
this.onlyStore = val ? true : false
}
},
data() {
return {
searchData: '', // 搜索内容
onlyStore:false,
};
},
methods: {
selectTags(item) { // 选择热门标签
this.searchData = item;
this.search();
},
research(){
this.onlyStore = !this.onlyStore
},
search () { // 全平台搜索商品
const url = this.$route.path;
if(url == '/goodsList'){
this.$emit('search', this.searchData)
}else{
const pushData = {
path:'/goodsList',
query: { keyword: this.searchData },
}
if(this.storeId && this.onlyStore) pushData.query.storeId = this.storeId
const props = defineProps({
this.$router.push(pushData); showTag: {
} type: Boolean,
}, default: true
searchStore() { // 店铺搜索商品
this.$emit('search', this.searchData)
}
}, },
computed: { showLogo: {
promotionTags() { type: Boolean,
if (this.$store.state.hotWordsList) { default: true
return JSON.parse(this.$store.state.hotWordsList)
} else {
return []
}
}
}, },
created() { storeId: {
this.searchData = this.$route.query.keyword type: String,
default: ""
if (!this.hover) { // 首页顶部固定搜索栏不调用热词接口 },
// 搜索热词每5分钟请求一次 hover: {
const reloadTime = storage.getItem('hotWordsReloadTime') type: Boolean,
const time = new Date().getTime() - 5 * 60 * 1000 default: false
if (!reloadTime) { },
hotWords({count: 5}).then(res => { useClass: {
if (res.success && res.result) storage.setItem('hotWordsList', res.result) type: null,
}) default: ''
storage.setItem('hotWordsReloadTime', new Date().getTime())
} else if (reloadTime && time > reloadTime) {
hotWords({count: 5}).then(res => {
if (res.success && res.result) storage.setItem('hotWordsList', res.result)
})
storage.setItem('hotWordsReloadTime', new Date().getTime())
}
}
} }
}; })
const emit = defineEmits(['search'])
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const HISTORY_KEY = 'buyerSearchHistory'
const HISTORY_LIMIT = 10
function readHistory() {
try {
const raw = localStorage.getItem(HISTORY_KEY)
const list = raw ? JSON.parse(raw) : []
return Array.isArray(list) ? list.filter((item) => typeof item === 'string' && item.trim()) : []
} catch {
return []
}
}
function parseHotWords(raw) {
if (!raw) return []
if (Array.isArray(raw)) return raw.filter(Boolean)
if (typeof raw !== 'string') return []
try {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) return parsed.filter(Boolean)
} catch {
// 旧数据可能是逗号拼接
}
return raw.split(',').map((item) => item.trim()).filter(Boolean)
}
const rootRef = ref(null)
const searchData = ref('')
const history = ref([])
const panelVisible = ref(false)
const suggestions = ref([])
const suggesting = ref(false)
let suggestTimer = null
let suggestSeq = 0
const logoImg = computed(() => appStore.logoImg)
const trimmedKeyword = computed(() => {
return (searchData.value || '').trim()
})
const promotionTags = computed(() => {
return parseHotWords(appStore.hotWordsList)
})
// 搜索类型:goods 商品 / store 店铺
const searchTypes = [
{ label: '商品', value: 'goods' },
{ label: '店铺', value: 'store' },
]
const searchType = ref(route.path === '/storeList' ? 'store' : 'goods')
// 店铺内搜索(传了 storeId)只能搜本店商品,不展示切换
const showTypeSwitch = computed(() => !props.storeId && props.useClass !== 'fixed-show')
const isStoreMode = computed(() => searchType.value === 'store' && !props.storeId)
const placeholderText = computed(() => (isStoreMode.value ? '输入你想查找的店铺' : '输入你想查找的商品'))
function switchType(type) {
if (searchType.value === type) return
searchType.value = type
suggestions.value = []
suggesting.value = false
}
const showPanel = computed(() => {
// 店铺搜索暂无联想/历史,不展示下拉面板
if (isStoreMode.value) return false
if (!panelVisible.value) return false
if (trimmedKeyword.value) return true
return history.value.length > 0 || promotionTags.value.length > 0
})
watch(
() => route.query.keyword,
(value) => {
searchData.value = value || ''
}
)
function selectTags(item) {
// 热词/历史均为商品关键词,点击时按商品搜索
searchType.value = 'goods'
searchData.value = item
search()
}
function remember(keyword) {
const next = [keyword, ...history.value.filter((item) => item !== keyword)].slice(0, HISTORY_LIMIT)
history.value = next
localStorage.setItem(HISTORY_KEY, JSON.stringify(next))
}
function clearHistory() {
history.value = []
localStorage.removeItem(HISTORY_KEY)
}
function openPanel() {
panelVisible.value = true
if (trimmedKeyword.value) queueSuggest()
}
function closePanel() {
panelVisible.value = false
}
function onDocumentClick(event) {
if (rootRef.value && !rootRef.value.contains(event.target)) closePanel()
}
function queueSuggest() {
if (isStoreMode.value) return
panelVisible.value = true
clearTimeout(suggestTimer)
const keyword = trimmedKeyword.value
if (!keyword) {
suggestions.value = []
suggesting.value = false
return
}
suggesting.value = true
suggestTimer = setTimeout(() => fetchSuggest(keyword), 300)
}
function fetchSuggest(keyword) {
const seq = ++suggestSeq
const params = { keyword, pageNumber: 1, pageSize: 8 }
if (props.storeId) params.storeId = props.storeId
goodsList(params).then((res) => {
if (seq !== suggestSeq || keyword !== trimmedKeyword.value) return
const records = res && res.success ? (res.result.records || []) : []
const seen = new Set()
const names = []
promotionTags.value.forEach((word) => {
if (word && String(word).toLowerCase().includes(keyword.toLowerCase()) && !seen.has(word)) {
seen.add(word)
names.push(word)
}
})
records.forEach((item) => {
const name = item && item.goodsName
if (name && !seen.has(name)) {
seen.add(name)
names.push(name)
}
})
suggestions.value = names.slice(0, 8)
}).catch(() => {
if (seq === suggestSeq) suggestions.value = []
}).finally(() => {
if (seq === suggestSeq) suggesting.value = false
})
}
function search() {
const keyword = trimmedKeyword.value
searchData.value = keyword
closePanel()
if (isStoreMode.value) {
if (route.path === '/storeList') {
emit('search', keyword)
} else {
router.push({ path: '/storeList', query: keyword ? { keyword } : {} })
}
return
}
if (keyword) remember(keyword)
const url = route.path
if (url == '/goodsList') {
emit('search', keyword)
} else {
const pushData = {
path: '/goodsList',
query: keyword ? { keyword } : {},
}
if (props.storeId) pushData.query.storeId = props.storeId
router.push(pushData)
}
}
function searchStore() {
emit('search', trimmedKeyword.value)
}
function applyHotWords(result) {
const list = parseHotWords(result)
appStore.setHotWords(list)
storage.setItem('hotWordsList', JSON.stringify(list))
}
function loadHotWords() {
hotWords({ count: 5 }).then((res) => {
if (res.success && res.result) applyHotWords(res.result)
}).catch(() => {
// 热词为可选数据,失败时沿用缓存或默认热词
})
storage.setItem('hotWordsReloadTime', new Date().getTime())
}
// 初始化(原 created 钩子逻辑)
searchData.value = route.query.keyword || ''
history.value = readHistory()
if (!props.hover) {
const reloadTime = storage.getItem('hotWordsReloadTime')
const time = new Date().getTime() - 5 * 60 * 1000
if (!reloadTime || time > reloadTime) loadHotWords()
}
onMounted(() => {
document.addEventListener('click', onDocumentClick)
})
onUnmounted(() => {
clearTimeout(suggestTimer)
document.removeEventListener('click', onDocumentClick)
})
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.only-store{
text-align: right;
color:$theme_color;
cursor: pointer;
}
.navbar { .navbar {
height: 113px; height: 113px;
background: #fff; background: #fff;
} }
.search-icon{
width: 100%;
height: 100%;
}
.small-search-box{
height: 60px;
margin: 0 !important; .navbar.small-search-box {
height: 60px;
width: 100%;
margin: 0;
display: flex;
align-items: center;
background: transparent;
}
.search-embedded {
width: 100%;
margin: 0;
} }
.container { .container {
position: relative; position: relative;
height: 100%; height: 100%;
} }
.search { .search {
$btn-gap: 4px;
$btn-width: 60px;
$input-height: 37.8px;
position: relative;
width: 778.4px; width: 778.4px;
height: $input-height;
margin: 10px 0px 5px 0; margin: 10px 0px 5px 0;
border-radius: 18.9px; .search-input {
width: 100%;
:deep(.el-input__wrapper) {
/deep/ .ivu-input.ivu-input-large { border: 2px solid #409EFF;
border: 1.4px solid $theme_color; border-radius: 4px;
box-sizing: border-box; padding-left: 26px;
border-radius: 19.6px; padding-right: calc(#{$btn-gap * 2} + #{$btn-width});
position: relative; font-size: 14px;
padding-left: 26px; font-weight: normal;
font-size: 14px; height: $input-height;
font-weight: normal; color: #999;
height: 37.8px;
color: #999;
&:focus {
box-shadow: none; box-shadow: none;
} }
} }
/deep/ .ivu-input-group-append { .search-btn {
border-radius: 19.6px !important; position: absolute;
right: $btn-gap;
top: $btn-gap;
bottom: $btn-gap;
width: $btn-width;
border-radius: 0 3px 3px 0;
cursor: pointer; cursor: pointer;
box-sizing: border-box; box-sizing: border-box;
border: 1.4px solid $theme_color;
width: 67.2px;
height: 37.8px;
position: absolute;
right: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
top: 0; z-index: 1;
z-index: 99; background-color: #409EFF;
background-color: $theme_color;
color: #ffffff; color: #ffffff;
transition: background 0.2s;
button { &:hover {
font-size: 14px; background-color: #66b1ff;
font-weight: 600;
line-height: 1;
} }
} }
} }
.search-embedded.search {
width: 100%;
}
.search-box{ .search-box{
margin-left: 28px; margin-left: 28px;
} }
.search-type {
display: flex;
align-items: flex-end;
gap: 4px;
height: 22px;
padding-left: 18px;
margin-top: 4px;
}
.search-type-item {
padding: 2px 10px;
border-radius: 4px 4px 0 0;
color: #666;
font-size: 13px;
line-height: 18px;
cursor: pointer;
user-select: none;
&:hover {
color: $theme_color;
}
&.active {
color: #fff;
background: $theme_color;
}
}
.search-type + .search {
margin-top: 0;
}
.logo-img { .logo-img {
max-width: 150px; max-width: 150px;
cursor: pointer; cursor: pointer;
} }
.store-search {
width: 55.6px;
padding: 0 9px;
border-radius: 0;
border-radius: 3px;
&:nth-child(2) {
width: 55px;
margin-left: -2px;
border-radius: 3px;
}
}
.btn-div {
position: relative;
height: 0px;
top: -38px;
left: 352px;
}
.history-list { .history-list {
margin-left: 28px; margin-left: 28px;
} }
@@ -254,4 +479,18 @@ export default {
color: #666666; color: #666666;
cursor: pointer; cursor: pointer;
} }
.suggest-panel {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 8px);
z-index: 30;
padding: 8px 0;
background: #fff;
border: 1px solid #eee;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
}
</style> </style>

View File

@@ -0,0 +1,150 @@
<template>
<div>
<div v-if="keyword" class="suggest-section">
<div v-if="suggesting && !suggestions.length" class="suggest-empty">正在查找...</div>
<button
v-for="item in suggestions"
:key="item"
type="button"
class="suggest-item"
@click="$emit('pick', item)"
>
<span v-for="(part, index) in parts(item)" :key="index" :class="{ hit: part.hit }">{{ part.text }}</span>
</button>
<div v-if="!suggesting && !suggestions.length" class="suggest-empty">没有匹配的商品,回车查看全部结果</div>
</div>
<template v-else>
<div v-if="history.length" class="suggest-section">
<div class="suggest-head">
<span>搜索历史</span>
<button type="button" class="suggest-clear" @click="$emit('clear')">清空</button>
</div>
<div class="suggest-tags">
<button
v-for="item in history"
:key="item"
type="button"
class="suggest-tag"
@click="$emit('pick', item)"
>{{ item }}</button>
</div>
</div>
<div v-if="hotWords.length" class="suggest-section">
<div class="suggest-head"><span>热门搜索</span></div>
<div class="suggest-tags">
<button
v-for="item in hotWords"
:key="item"
type="button"
class="suggest-tag"
@click="$emit('pick', item)"
>{{ item }}</button>
</div>
</div>
</template>
</div>
</template>
<script setup>
defineOptions({ name: "SearchSuggest" });
const props = defineProps({
keyword: { type: String, default: "" },
history: { type: Array, default: () => [] },
hotWords: { type: Array, default: () => [] },
suggestions: { type: Array, default: () => [] },
suggesting: { type: Boolean, default: false },
});
defineEmits(["pick", "clear"]);
function parts(text) {
const value = String(text || "");
const key = props.keyword;
if (!key) return [{ text: value, hit: false }];
const index = value.toLowerCase().indexOf(key.toLowerCase());
if (index < 0) return [{ text: value, hit: false }];
return [
{ text: value.slice(0, index), hit: false },
{ text: value.slice(index, index + key.length), hit: true },
{ text: value.slice(index + key.length), hit: false },
].filter((part) => part.text);
}
</script>
<style scoped lang="scss">
.suggest-section + .suggest-section {
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid #f5f5f5;
}
.suggest-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 16px;
color: #999;
font-size: 13px;
}
.suggest-clear {
border: none;
background: transparent;
color: #999;
font-size: 13px;
cursor: pointer;
}
.suggest-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 0 16px 8px;
}
.suggest-tag {
max-width: 180px;
padding: 4px 10px;
border: none;
border-radius: 14px;
background: #f5f5f5;
color: #333;
font-size: 13px;
line-height: 20px;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.suggest-item {
display: block;
width: 100%;
padding: 8px 16px;
border: none;
background: transparent;
color: #333;
font-size: 14px;
line-height: 22px;
text-align: left;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
background: #fafafa;
}
.hit {
color: $theme_color;
}
}
.suggest-empty {
padding: 10px 16px;
color: #999;
font-size: 13px;
}
</style>

View File

@@ -1,172 +1,177 @@
<template> <template>
<div> <div>
<Modal v-model="showAddr" width="800" title="收件人地址"> <el-dialog v-model="showAddr" width="800" title="收件人地址">
<Form <el-form
:model="formData" :model="formData"
ref="form" ref="formRef"
label-position="left" label-position="left"
:label-width="100" label-width="100px"
:rules="ruleInline" :rules="ruleInline"
> >
<FormItem label="收件人" prop="name"> <el-form-item label="收件人" prop="name">
<i-input v-model="formData.name" style="width: 600px"></i-input> <el-input v-model="formData.name" style="width: 600px"></el-input>
</FormItem> </el-form-item>
<FormItem label="收件地区" prop="address"> <el-form-item label="收件地区" prop="address">
{{ formData.address || '暂无地址' }} {{ formData.address || '暂无地址' }}
<Button type="primary" style="margin-left: 10px;" size="small" @click="$refs.map.open()">选择</Button> <el-button type="primary" style="margin-left: 10px;" size="small" @click="mapRef.open()">选择</el-button>
</FormItem> </el-form-item>
<FormItem label="详细地址" prop="detail"> <el-form-item label="详细地址" prop="detail">
<i-input v-model="formData.detail" style="width: 600px"></i-input> <el-input v-model="formData.detail" style="width: 600px"></el-input>
</FormItem> </el-form-item>
<FormItem label="手机号码" prop="mobile"> <el-form-item label="手机号码" prop="mobile">
<i-input v-model="formData.mobile" style="width: 600px"></i-input> <el-input v-model="formData.mobile" style="width: 600px"></el-input>
</FormItem> </el-form-item>
<FormItem label="地址别名"> <el-form-item label="地址别名">
<i-input <el-input
v-model="formData.alias" v-model="formData.alias"
length :maxlength="5"
:maxlength="4" show-word-limit
placeholder="请输入地址别名,例如公司" placeholder="请输入地址别名,例如公司"
style="width: 600px" style="width: 600px"
></i-input> ></el-input>
</FormItem> </el-form-item>
<FormItem label="默认地址"> <el-form-item label="默认地址">
<i-switch v-model="formData.isDefault" /> <el-switch v-model="formData.isDefault" />
</FormItem> </el-form-item>
</Form> </el-form>
<div class="mt_20" slot="footer"> <template #footer><div class="mt_20">
<Button @click="hide">取消</Button> <el-button @click="hide">取消</el-button>
<Button type="primary" class="mr_10" :loading="loading" @click="save">保存收货地址</Button> <el-button type="primary" class="mr_10" :loading="loading" @click="save">保存收货地址</el-button>
</div> </div></template>
</Modal> </el-dialog>
<multipleMap ref="map" @callback="getAddress"></multipleMap> <multipleMap ref="mapRef" @callback="getAddress"></multipleMap>
</div> </div>
</template> </template>
<script> <script setup>
import { ref, watch } from "vue";
import { Message } from "@/utils/message";
import multipleMap from "@/components/map/multiple-map"; import multipleMap from "@/components/map/multiple-map";
import { import {
newMemberAddress, newMemberAddress,
editMemberAddress, editMemberAddress,
getAddrDetail getAddrDetail
} from '@/api/address'; } from '@/api/address';
export default {
name: 'addressManage',
props: {
id: { // 传入的地址id
defalut: '',
type: String
}
},
data () {
return {
showAddr: false, // 控制模态框显隐
formData: { // 表单数据
isDefault: false
},
ruleInline: { // 验证规则
name: [{ required: true, message: '请输入收件人姓名', trigger: 'blur' }],
address: [{ required: true, message: '请输入地址', trigger: 'change' }],
detail: [
{ required: true, message: '请输入详细地址', trigger: 'blur' }
],
mobile: [
{ required: true, message: '手机号不能为空', trigger: 'blur' },
{
type: 'string',
pattern: /^1[3|4|5|6|7|8][0-9]{9}$/,
message: '手机号格式出错',
trigger: 'blur'
}
]
},
loading: false, // 提交的加载状态
mapMsg: {} // 地图信息
};
},
methods: {
save () { // 保存地址
this.$refs.form.validate((valid) => {
if (valid) {
const params = JSON.parse(JSON.stringify(this.formData));
params.consigneeAddressPath = params.address.replace(/\s/g, ',');
delete params.address;
this.loading = true;
if (this.id) {
editMemberAddress(params).then((res) => {
this.loading = false;
if (res.success) {
this.$Message.success('编辑地址成功');
this.$emit('change', true);
this.hide();
}
}).catch(() => { this.loading = false; });
} else {
newMemberAddress(params).then((res) => {
this.loading = false;
if (res.success) {
this.$Message.success('新增地址成功');
this.$emit('change', true);
this.hide();
}
}).catch(() => { this.loading = false; });
}
}
});
},
getAddrById (id) {
// 获取地址详情
getAddrDetail(id).then((res) => {
if (res.success) {
console.log(res);
const data = res.result;
data.address = res.result.consigneeAddressPath.replace(/,/g, ' ');
this.formData = data;
}
});
},
getAddress (val) {
// 获取地图选择信息
if(val.type === 'select'){
const paths = val.data.map(item => item.name).join(',')
const ids = val.data.map(item => item.id).join(',')
this.$set(this.formData,'address',paths)
this.$set(this.formData,'consigneeAddressIdPath',ids)
const coord = val.data[val.data.length - 1].center.split(',')
this.formData.lat = coord[1]
this.formData.lon = coord[0]
}else{
this.$set(this.formData, "address", val.data.addr);
this.$set(this.formData, "consigneeAddressIdPath", val.data.addrId);
this.$set(this.formData, "detail", val.data.address);
this.formData.lat = val.data.position.lat;
this.formData.lon = val.data.position.lng;
}
}, defineOptions({ name: 'addressManage' });
show () { // 地址模态框显示
this.showAddr = true; const props = defineProps({
}, id: { // 传入的地址id
hide () { // 地址模态框隐藏 defalut: '',
this.showAddr = false; type: String
}
});
const emit = defineEmits(['change']);
const showAddr = ref(false); // 控制模态框显隐
const formData = ref({ // 表单数据
isDefault: false
});
const ruleInline = { // 验证规则
name: [{ required: true, message: '请输入收件人姓名', trigger: 'blur' }],
address: [{ required: true, message: '请输入地址', trigger: 'change' }],
detail: [
{ required: true, message: '请输入详细地址', trigger: 'blur' }
],
mobile: [
{ required: true, message: '手机号不能为空', trigger: 'blur' },
{
type: 'string',
pattern: /^1[3|4|5|6|7|8][0-9]{9}$/,
message: '手机号格式出错',
trigger: 'blur'
} }
}, ]
watch: { };
id: { // 传入的地址id const loading = ref(false); // 提交的加载状态
handler: function (v) { const mapMsg = ref({}); // 地图信息
if (v) {
this.getAddrById(v); const formRef = ref(null);
} else { const mapRef = ref(null);
this.formData = {isDefault: false}
this.$refs.form.resetFields(); function save () { // 保存地址
} formRef.value.validate((valid) => {
if (valid) {
const params = JSON.parse(JSON.stringify(formData.value));
params.consigneeAddressPath = params.address.replace(/\s/g, ',');
delete params.address;
loading.value = true;
if (props.id) {
editMemberAddress(params).then((res) => {
loading.value = false;
if (res.success) {
Message.success('编辑地址成功');
emit('change', true);
hide();
}
}).catch(() => { loading.value = false; });
} else {
newMemberAddress(params).then((res) => {
loading.value = false;
if (res.success) {
Message.success('新增地址成功');
emit('change', true);
hide();
}
}).catch(() => { loading.value = false; });
} }
} }
}, });
components: { }
multipleMap
function getAddrById (id) {
// 获取地址详情
getAddrDetail(id).then((res) => {
if (res.success) {
const data = res.result;
data.address = res.result.consigneeAddressPath.replace(/,/g, ' ');
formData.value = data;
}
});
}
function getAddress (val) {
// 获取地图选择信息
if(val.type === 'select'){
const paths = val.data.map(item => item.name).join(',')
const ids = val.data.map(item => item.id).join(',')
formData.value['address'] = paths
formData.value['consigneeAddressIdPath'] = ids
const coord = val.data[val.data.length - 1].center.split(',')
formData.value.lat = coord[1]
formData.value.lon = coord[0]
}else{
formData.value["address"] = val.data.addr;
formData.value["consigneeAddressIdPath"] = val.data.addrId;
formData.value["detail"] = val.data.address;
formData.value.lat = val.data.position.lat;
formData.value.lon = val.data.position.lng;
} }
};
}
function show () { // 地址模态框显示
showAddr.value = true;
}
function hide () { // 地址模态框隐藏
showAddr.value = false;
}
defineExpose({ show, hide });
watch(
() => props.id, // 传入的地址id
(v) => {
if (v) {
getAddrById(v);
} else {
formData.value = {isDefault: false}
formRef.value.resetFields();
}
}
);
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -1,24 +1,22 @@
<template> <template>
<!-- 头部广告 --> <!-- 头部广告 -->
<div class="advertising" v-if="show" :style="{'background-color': data.bgColor}"> <div class="advertising" v-if="show && data" :style="{'background-color': data.bgColor}">
<img :src="data.img" class="hover-pointer" @click="linkTo(data.url)"/> <img :src="data.img" class="hover-pointer" @click="linkTo(data.url)"/>
<Icon type="md-close-circle" size="20" @click="show = false" /> <el-icon :size="20" @click="show = false"><CircleClose /></el-icon>
</div> </div>
</template> </template>
<script> <script setup>
export default { import { ref } from 'vue';
props: { import { CircleClose } from '@element-plus/icons-vue';
data: { // 传入的广告信息
type: Object, defineProps({
default: null data: { // 传入的广告信息
} type: Object,
}, default: null
data () {
return {
show: true // 是否显示头部广告
}
} }
}; });
const show = ref(true); // 是否显示头部广告
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.advertising { .advertising {

View File

@@ -1,148 +1,159 @@
<template> <template>
<Card class="_Card" :bordered="false" :dis-hover="true"> <el-card class="_Card" shadow="never">
<div slot="title" class="cardTitle"> <template #header>
<span :style="{fontSize:`${_Size}px`}">{{_Title}}</span> <div class="card-header">
<div class="cardTitle">
<div v-if="_Tabs" class="cardTabs"> <span :style="{ fontSize: `${_Size}px` }">{{ _Title }}</span>
<div @click="tabsChange(index)" :class="{active:(isActive==index)}" class="cardTabsItem" :style="{fontSize:`${_Size-2}px`}" v-for="(item,index) in _Tabs" <div v-if="_Tabs" class="cardTabs">
:key="index"> <div
{{item}} @click="tabsChange(index)"
:class="{ active: isActive == index }"
class="cardTabsItem"
:style="{ fontSize: `${_Size - 2}px` }"
v-for="(item, index) in _Tabs"
:key="index"
>
{{ item }}
</div>
</div>
</div>
<div class="cardExtra" v-if="_More" @click="callBack()">
{{ _More }}
</div> </div>
</div> </div>
</div> </template>
<div slot="extra" class="cardExtra" v-if="_More" @click="callBack()"> <slot />
{{_More}} </el-card>
</div>
<div>
</div>
</Card>
</template> </template>
<script> <script setup>
export default { import { ref, watch } from "vue";
name: 'index', import { useRouter } from "vue-router";
props:
{ defineOptions({ name: "index" });
_Tabs: { // 可点击的tab栏
type: null, const props = defineProps({
default: '' _Tabs: {
}, type: null,
// 头部 default: "",
_Title: { // 标题
type: null,
default: '卡片头部'
},
// 右侧更多
_More: {
type: null,
default: false
},
_Size: { // 文字大小
type: Number,
default: 16
},
// 点击更多触发跳转
_Src: {
type: null,
default: function (val) {
if (this._More) {
return val;
} else {
return false;
}
}
}
},
data () {
return {
isActive: 0 // 已激活tab栏下标
};
}, },
methods: { _Title: {
// 点击右侧的回调 type: null,
callBack () { default: "卡片头部",
let _this = this; },
if (this._Src !== '' || this._Src != null) { _More: {
this.$router.push({ type: null,
path: _this._Src default: false,
}); },
} _Size: {
}, type: Number,
// 点击tab的回调 default: 16,
tabsChange (index) { },
// 处理并返回index _Src: {
this.isActive = index; type: null,
this.$emit('_Change', index); default: null,
} },
_ActiveTab: {
type: Number,
default: 0,
},
});
const emit = defineEmits(["_Change"]);
const router = useRouter();
const isActive = ref(props._ActiveTab);
watch(
() => props._ActiveTab,
(val) => {
isActive.value = val;
} }
}; );
function callBack() {
if (props._Src) {
router.push({ path: props._Src });
}
}
function tabsChange(index) {
isActive.value = index;
emit("_Change", index);
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.cardTitle { .card-header {
display: flex; display: flex;
cursor: pointer; align-items: center;
} justify-content: space-between;
.active{ }
color: $theme_color; .cardTitle {
position: relative; display: flex;
&::before{ cursor: pointer;
content: ''; }
position: absolute; .active {
width: 100%; color: $theme_color;
height: 3px; position: relative;
bottom: 0; &::before {
left: 0; content: "";
background: $theme_color; position: absolute;
} width: 100%;
height: 3px;
bottom: 0;
left: 0;
background: $theme_color;
} }
}
.cardTabs { .cardTabs {
display: flex; display: flex;
padding: 0 12px;
> .cardTabsItem {
padding: 0 12px; padding: 0 12px;
> .cardTabsItem {
padding: 0 12px;
}
> .cardTabsItem:hover {
color: $theme_color;
}
} }
/deep/ .ivu-card, .ivu-card-head, ._Card { > .cardTabsItem:hover {
margin-bottom: 20px;
@include white_background_color();
}
/deep/ .ivu-card-head {
position: relative;
padding: 0 14px;
height: 50px;
line-height: 50px;
&::before {
content: '';
width: 3px;
height: 50%;
top: 25%;
background: $theme_color;
position: absolute;
left: 0;
}
}
.cardExtra {
color: $theme_color; color: $theme_color;
cursor: pointer;
} }
}
/deep/ .ivu-card-body { :deep(._Card) {
padding: 0 !important; margin-bottom: 20px;
display: none; @include white_background_color();
}
:deep(._Card .el-card__header) {
position: relative;
padding: 0 14px;
height: 50px;
line-height: 50px;
&::before {
content: "";
width: 3px;
height: 50%;
top: 25%;
background: $theme_color;
position: absolute;
left: 0;
} }
}
.cardExtra {
color: $theme_color;
cursor: pointer;
}
:deep(._Card .el-card__body) {
padding: 0 !important;
min-height: 0;
}
:deep(._Card .el-card__body:empty) {
display: none;
}
</style> </style>

View File

@@ -4,16 +4,16 @@
<ul> <ul>
<li v-for="(goods,goodsIndex) in cartList" :key="goodsIndex"> <li v-for="(goods,goodsIndex) in cartList" :key="goodsIndex">
<div> <div>
<img :src="goods.goodsSku.thumbnail" width="90" height="90" alt=""> <img :src="goods.goodsSku.thumbnail" loading="lazy" decoding="async" width="90" height="90" alt="">
</div> </div>
<div> <div>
<p class="hover-color" @click="linkTo(`/goodsDetail?skuId=${goods.goodsSku.id}&goodsId=${goods.goodsSku.goodsId}`)">{{goods.goodsSku.goodsName}}</p> <p class="hover-color" @click="linkTo(`/goodsDetail?skuId=${goods.goodsSku.id}&goodsId=${goods.goodsSku.goodsId}`)">{{goods.goodsSku.goodsName}}</p>
<p class="price">{{goods.goodsSku.price | unitPrice('¥')}}<span>&nbsp; x{{goods.num}}</span></p> <p class="price">{{ $filters.unitPrice(goods.goodsSku.price, '¥') }}<span>&nbsp; x{{goods.num}}</span></p>
</div> </div>
<span class="del hover-color" @click="delGoods(goods.goodsSku.id)">删除</span> <span class="del hover-color" @click="delGoods(goods.goodsSku.id)">删除</span>
</li> </li>
</ul> </ul>
<Button size="large" class="mt_10" type="primary" @click="linkTo('/cart')" long>去购物车结算</Button> <el-button size="large" class="mt_10" type="primary" @click="linkTo('/cart')" style="width:100%">去购物车结算</el-button>
</div> </div>
<div v-else-if="title === '我的订单'" class="order-con"> <div v-else-if="title === '我的订单'" class="order-con">
@@ -21,15 +21,15 @@
<li v-for="(order,orderIndex) in orderList" :key="orderIndex"> <li v-for="(order,orderIndex) in orderList" :key="orderIndex">
<div class="order-status"><span>{{filterOrderStatus(order.orderStatus)}}</span><span>{{order.createTime}}</span></div> <div class="order-status"><span>{{filterOrderStatus(order.orderStatus)}}</span><span>{{order.createTime}}</span></div>
<div class="goods-img"> <div class="goods-img">
<img :src="img.image" <img :src="img.image" loading="lazy" decoding="async"
@click="linkTo(`/goodsDetail?skuId=${img.skuId}&goodsId=${img.goodsId}`)" @click="linkTo(`/goodsDetail?skuId=${img.skuId}&goodsId=${img.goodsId}`)"
v-for="(img,imgIndex) in order.orderItems" v-for="(img,imgIndex) in order.orderItems"
:key="imgIndex" width="40" height="40" alt=""> :key="imgIndex" width="40" height="40" alt="">
</div> </div>
<div class="order-handle"><span>{{ order.flowPrice | unitPrice("¥") }}</span><span class="hover-color" @click="linkTo(`home/OrderDetail?sn=${order.sn}`)">查看订单</span></div> <div class="order-handle"><span>{{ $filters.unitPrice(order.flowPrice, "¥") }}</span><span class="hover-color" @click="linkTo(`/home/OrderDetail?sn=${order.sn}`)">查看订单</span></div>
</li> </li>
</ul> </ul>
<Button type="primary" @click="linkTo('/home/MyOrder')" long>查看全部订单</Button> <el-button type="primary" @click="linkTo('/home/MyOrder')" style="width:100%">查看全部订单</el-button>
</div> </div>
<div v-else-if="title === '优惠券'" class="coupon-con"> <div v-else-if="title === '优惠券'" class="coupon-con">
@@ -37,7 +37,7 @@
<li v-for="(coupon, index) in couponList" class="coupon-item" :key="index"> <li v-for="(coupon, index) in couponList" class="coupon-item" :key="index">
<div class="c-left"> <div class="c-left">
<div> <div>
<span v-if="coupon.couponType === 'PRICE'" class="fontsize_12 global_color">¥<span class="price">{{coupon.price | unitPrice}}</span></span> <span v-if="coupon.couponType === 'PRICE'" class="fontsize_12 global_color">¥<span class="price">{{ $filters.unitPrice(coupon.price) }}</span></span>
<span v-if="coupon.couponType === 'DISCOUNT'" class="fontsize_12 global_color"><span class="price">{{coupon.couponDiscount}}</span>折</span> <span v-if="coupon.couponType === 'DISCOUNT'" class="fontsize_12 global_color"><span class="price">{{coupon.couponDiscount}}</span>折</span>
<span class="describe">满{{coupon.consumeThreshold}}元可用</span> <span class="describe">满{{coupon.consumeThreshold}}元可用</span>
</div> </div>
@@ -54,9 +54,9 @@
<div v-else-if="title === '我的足迹'" class="tracks-con"> <div v-else-if="title === '我的足迹'" class="tracks-con">
<ul> <ul>
<li v-for="(track,trackIndex) in tracksList" :key="trackIndex"> <li v-for="(track,trackIndex) in tracksList" :key="trackIndex">
<img :src="track.thumbnail" :alt="track.thumbnail" @click="linkTo(`/goodsDetail?skuId=${track.id}&goodsId=${track.goodsId}`)" width="100" height="100"> <img :src="track.thumbnail" loading="lazy" decoding="async" :alt="track.thumbnail" @click="linkTo(`/goodsDetail?skuId=${track.id}&goodsId=${track.goodsId}`)" width="100" height="100">
<div @click="addToCart(track.id)">加入购物车</div> <div @click="addToCart(track.id)">加入购物车</div>
<p class="global_color">{{track.price | unitPrice('¥')}}</p> <p class="global_color">{{ $filters.unitPrice(track.price, '¥') }}</p>
</li> </li>
</ul> </ul>
<div class="hover-color" style="text-align:center;" @click="linkTo('/home/MyTracks')">查看更多>></div> <div class="hover-color" style="text-align:center;" @click="linkTo('/home/MyTracks')">查看更多>></div>
@@ -64,238 +64,242 @@
<div v-else-if="title === '我的收藏'" class="collect-con"> <div v-else-if="title === '我的收藏'" class="collect-con">
<ul> <ul>
<li v-for="(collect,collectIndex) in collectList" :key="collectIndex"> <li v-for="(collect,collectIndex) in collectList" :key="collectIndex">
<img :src="collect.image" :alt="collect.image" @click="linkTo(`/goodsDetail?skuId=${collect.skuId}&goodsId=${collect.goodsId}`)" width="100" height="100"> <img :src="collect.image" loading="lazy" decoding="async" :alt="collect.image" @click="linkTo(`/goodsDetail?skuId=${collect.skuId}&goodsId=${collect.goodsId}`)" width="100" height="100">
<div @click="addToCart(collect.skuId)">加入购物车</div> <div @click="addToCart(collect.skuId)">加入购物车</div>
<span class="del-icon" @click.stop="cancelCollect(collect.skuId)"> <span class="del-icon" @click.stop="cancelCollect(collect.skuId)">
<Icon type="md-trash" /> <el-icon><Delete /></el-icon>
</span> </span>
<p class="global_color">{{collect.price | unitPrice('¥')}}</p> <p class="global_color">{{ $filters.unitPrice(collect.price, '¥') }}</p>
</li> </li>
</ul> </ul>
<div class="hover-color" style="text-align:center;" @click="linkTo('/home/Favorites')">查看更多>></div> <div class="hover-color" style="text-align:center;" @click="linkTo('/home/Favorites')">查看更多>></div>
</div> </div>
<Spin v-if="loading" fix></Spin> <el-skeleton v-if="loading" fix></el-skeleton>
</div> </div>
</template> </template>
<script> <script setup>
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Message, Modal } from "@/utils/message";
import { Delete } from '@element-plus/icons-vue';
import {cartGoodsAll, delCartGoods, addCartGoods, cartCount} from '@/api/cart.js' import {cartGoodsAll, delCartGoods, addCartGoods, cartCount} from '@/api/cart.js'
import { getOrderList } from '@/api/order'; import { getOrderList as fetchOrderList } from '@/api/order';
import {couponList, receiveCoupon, tracksList, collectList, cancelCollect} from '@/api/member.js' import {couponList as fetchCouponList, receiveCoupon, tracksList as fetchTracksList, collectList as fetchCollectList, cancelCollect as apiCancelCollect} from '@/api/member.js'
export default { import storage from '@/plugins/storage.js'
name: 'Drawer', import { useAppStore } from '@/stores/app'
props: {
title: { defineOptions({ name: 'Drawer' })
default: '',
type: String const props = defineProps({
} title: {
}, default: '',
watch: { type: String
title (val) {
switch (val) {
case '购物车':
this.getCartList()
break;
case '我的订单':
this.getOrderList()
break;
case '我的足迹':
this.getTracksList()
break;
case '优惠券':
this.getCouponList()
break;
case '我的收藏':
this.getCollectList()
break;
}
}
},
data () {
return {
loading: false, // 控制spin显隐
cartList: [], // 购物车列表
couponList: [], // 优惠券列表
orderList: [], // 订单列表
collectList: [], // 收藏列表
tracksList: [], // 足迹列表
orderStatusList: [ // 订单状态
{
name: '未付款',
status: 'UNPAID'
},
{
name: '已付款',
status: 'PAID'
},
{
name: '待发货',
status: 'UNDELIVERED'
},
{
name: '已发货',
status: 'DELIVERED'
},
{
name: '已完成',
status: 'COMPLETED'
},
{
name: '待核验',
status: 'TAKE'
},
{
name: '已取消',
status: 'CANCELLED'
}
]
};
},
methods: {
getCartList () { // 获取购物车列表
this.loading = true
cartGoodsAll().then(res => {
this.loading = false
this.cartList = res.result.skuList
})
},
// 删除商品
delGoods (id) {
delCartGoods({ skuIds: id }).then((res) => {
if (res.success) {
this.$Message.success('删除成功');
this.getCartList();
cartCount().then(res => {
this.$store.commit('SET_CARTNUM', res.result)
this.Cookies.setItem('cartNum', res.result)
})
} else {
this.$Message.error(res.message);
}
});
},
filterOrderStatus (status) { // 获取订单状态中文
const ob = this.orderStatusList.filter(e => { return e.status === status });
return ob[0].name
},
receive (item) { // 领取优惠券
receiveCoupon(item.id).then(res => {
if (res.success) {
this.$Modal.confirm({
title: '领取优惠券',
content: '<p>优惠券领取成功,可到我的优惠券页面查看</p>',
okText: '我的优惠券',
cancelText: '立即使用',
onOk: () => {
this.$router.push('/home/Coupons')
},
onCancel: () => {
if (item.storeId !== '0') {
this.$router.push({path: '/merchant', query: {id: item.storeId}})
} else {
if (item.scopeType === 'PORTION_GOODS_CATEGORY') {
this.$router.push({path: '/goodsList', query: {categoryId: item.scopeId}})
} else {
this.$router.push({path: '/goodsList'})
}
}
}
});
}
})
},
useScope (type, storeName) { // 判断优惠券使用范围
let shop = '平台';
let goods = '全部商品'
if (storeName !== 'platform') shop = storeName
switch (type) {
case 'ALL':
goods = '全部商品'
break;
case 'PORTION_GOODS':
goods = '部分商品'
break;
case 'PORTION_GOODS_CATEGORY':
goods = '部分分类商品'
break;
}
return `${shop}${goods}可用`
},
addToCart (id) { // 添加商品到购物车
const params = {
num: 1,
skuId: id
}
this.loading = true;
addCartGoods(params).then(res => {
this.loading = false;
if (res.success) {
this.$Message.success('商品已成功添加到购物车')
} else {
this.$Message.warning(res.message);
}
}).catch(() => { this.loading = false });
},
getCouponList () { // 获取优惠券列表
// this.loading = true;
const params = {
pageNumber: 1,
pageSize: 10
}
couponList(params).then(res => {
this.loading = false
if (res.success) {
this.couponList = res.result.records
}
}).catch(() => { this.loading = false })
},
getOrderList () { // 获取订单列表
this.loading = true
const params = {
pageNumber: 1,
pageSize: 10,
tag: 'ALL'
}
getOrderList(params).then(res => {
this.loading = false
if (res.success) {
this.orderList = res.result.records;
}
});
},
getCollectList () { // 获取收藏列表
const params = {
pageNumber: 1,
pageSize: 10,
type: 'GOODS'
}
this.loading = true
collectList(params).then(res => {
this.loading = false
this.collectList = res.result.records
})
},
cancelCollect (id) { // 取消商品收藏
cancelCollect('GOODS', id).then(res => {
if (res.success) {
this.$Message.success('取消收藏成功')
this.getCollectList();
}
})
},
getTracksList () { // 获取足迹列表
const params = {
pageNumber: 1,
pageSize: 20
}
this.loading = true
tracksList(params).then(res => {
this.tracksList = res.result.records
this.loading = false
}).catch(() => { this.loading = false })
}
} }
}; })
const router = useRouter()
const appStore = useAppStore()
const loading = ref(false) // 控制spin显隐
const cartList = ref([]) // 购物车列表
const couponList = ref([]) // 优惠券列表
const orderList = ref([]) // 订单列表
const collectList = ref([]) // 收藏列表
const tracksList = ref([]) // 足迹列表
const orderStatusList = ref([ // 订单状态
{
name: '未付款',
status: 'UNPAID'
},
{
name: '已付款',
status: 'PAID'
},
{
name: '待发货',
status: 'UNDELIVERED'
},
{
name: '已发货',
status: 'DELIVERED'
},
{
name: '已完成',
status: 'COMPLETED'
},
{
name: '待核验',
status: 'TAKE'
},
{
name: '已取消',
status: 'CANCELLED'
}
])
watch(() => props.title, (val) => {
switch (val) {
case '购物车':
getCartList()
break;
case '我的订单':
getOrderList()
break;
case '我的足迹':
getTracksList()
break;
case '优惠券':
getCouponList()
break;
case '我的收藏':
getCollectList()
break;
}
})
function getCartList () { // 获取购物车列表
loading.value = true
cartGoodsAll().then(res => {
loading.value = false
cartList.value = res.result.skuList
})
}
// 删除商品
function delGoods (id) {
delCartGoods({ skuIds: id }).then((res) => {
if (res.success) {
Message.success('删除成功');
getCartList();
cartCount().then(res => {
appStore.setCartNum(res.result)
storage.setItem('cartNum', res.result)
})
} else {
Message.error(res.message);
}
});
}
function filterOrderStatus (status) { // 获取订单状态中文
const ob = orderStatusList.value.filter(e => { return e.status === status });
return ob[0].name
}
function receive (item) { // 领取优惠券
receiveCoupon(item.id).then(res => {
if (res.success) {
Modal.confirm({
title: '领取优惠券',
content: '<p>优惠券领取成功,可到我的优惠券页面查看</p>',
okText: '我的优惠券',
cancelText: '立即使用',
onOk: () => {
router.push('/home/Coupons')
},
onCancel: () => {
if (item.storeId !== '0') {
router.push({path: '/merchant', query: {id: item.storeId}})
} else {
if (item.scopeType === 'PORTION_GOODS_CATEGORY') {
router.push({path: '/goodsList', query: {categoryId: item.scopeId}})
} else {
router.push({path: '/goodsList'})
}
}
}
});
}
})
}
function useScope (type, storeName) { // 判断优惠券使用范围
let shop = '平台';
let goods = '全部商品'
if (storeName !== 'platform') shop = storeName
switch (type) {
case 'ALL':
goods = '全部商品'
break;
case 'PORTION_GOODS':
goods = '部分商品'
break;
case 'PORTION_GOODS_CATEGORY':
goods = '部分分类商品'
break;
}
return `${shop}${goods}可用`
}
function addToCart (id) { // 添加商品到购物车
const params = {
num: 1,
skuId: id
}
loading.value = true;
addCartGoods(params).then(res => {
loading.value = false;
if (res.success) {
Message.success('商品已成功添加到购物车')
} else {
Message.warning(res.message);
}
}).catch(() => { loading.value = false });
}
function getCouponList () { // 获取优惠券列表
// loading.value = true;
const params = {
pageNumber: 1,
pageSize: 10
}
fetchCouponList(params).then(res => {
loading.value = false
if (res.success) {
couponList.value = res.result.records
}
}).catch(() => { loading.value = false })
}
function getOrderList () { // 获取订单列表
loading.value = true
const params = {
pageNumber: 1,
pageSize: 10,
tag: 'ALL'
}
fetchOrderList(params).then(res => {
loading.value = false
if (res.success) {
orderList.value = res.result.records;
}
});
}
function getCollectList () { // 获取收藏列表
const params = {
pageNumber: 1,
pageSize: 10,
type: 'GOODS'
}
loading.value = true
fetchCollectList(params).then(res => {
loading.value = false
collectList.value = res.result.records
})
}
function cancelCollect (id) { // 取消商品收藏
apiCancelCollect('GOODS', id).then(res => {
if (res.success) {
Message.success('取消收藏成功')
getCollectList();
}
})
}
function getTracksList () { // 获取足迹列表
const params = {
pageNumber: 1,
pageSize: 20
}
loading.value = true
fetchTracksList(params).then(res => {
tracksList.value = res.result.records
loading.value = false
}).catch(() => { loading.value = false })
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -3,102 +3,99 @@
<div class="wrapper" :style="{right:handleDrawer ? '300px' : '0px'}"> <div class="wrapper" :style="{right:handleDrawer ? '300px' : '0px'}">
<div class="barItem" @mouseenter="showCartNum(item)" @click="clickBar(item)" v-for="(item,index) in resetConfig.menuList" :key="index"> <div class="barItem" @mouseenter="showCartNum(item)" @click="clickBar(item)" v-for="(item,index) in resetConfig.menuList" :key="index">
<Tooltip placement="left" :content="item.title"> <el-tooltip placement="left" :content="item.title">
<Icon size="20" :type="item.icon"/> <el-icon :size="20"><component :is="resolveIcon(item.icon)" /></el-icon>
<p class="barTitle" v-if="item.titleShow"> {{item.title}}</p> <p class="barTitle" v-if="item.titleShow"> {{item.title}}</p>
<div class="circle" v-if="item.title === '购物车'"> <div class="circle" v-if="item.title === '购物车'">
{{cartNum < 100 ? cartNum : 99}} {{ Number(cartNum) > 99 ? '99+' : cartNum }}
</div> </div>
</Tooltip> </el-tooltip>
</div> </div>
</div> </div>
<Drawer width="300" class="popup" :title="drawerData.title" :mask="resetConfig.mask" :closable="resetConfig.closable" <el-drawer width="300" class="popup" :title="drawerData.title" :modal="resetConfig.mask" :show-close="resetConfig.closable"
v-model="handleDrawer"> v-model="handleDrawer">
<drawerPage :title="drawerData.title" /> <drawerPage :title="drawerData.title" />
</Drawer> </el-drawer>
</div> </div>
</template> </template>
<script> <script setup>
import Storage from '@/plugins/storage.js'; import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Modal } from "@/utils/message";
import storage from '@/plugins/storage.js';
import Configuration from './config'; import Configuration from './config';
import drawerPage from './Drawer' import drawerPage from './Drawer'
import {cartCount} from '@/api/cart.js' import {cartCount} from '@/api/cart.js'
export default { import { resolveIcon } from '@/utils/iconMap'
name: 'Main', import { useAppStore } from '@/stores/app'
data () {
return { defineOptions({ name: 'Main' })
resetConfig: Configuration, // 菜单项
handleDrawer: false, // 是否可展开 const route = useRoute()
drawerData: '' // 菜单基础数据 const router = useRouter()
} const appStore = useAppStore()
},
components: {drawerPage}, const resetConfig = ref(Configuration) // 菜单项
computed: { const handleDrawer = ref(false) // 是否可展开
// 用户信息 const drawerData = ref('') // 菜单基础数据
userInfo () {
return Storage.getItem('userInfo'); // 用户信息
}, const userInfo = computed(() => {
// 购物车商品数量 return storage.getItem('userInfo');
cartNum () { })
return this.$store.state.cartNum
} // 购物车商品数量
}, const cartNum = computed(() => appStore.cartNum)
methods: {
showCartNum (item) { // 获取购物车数量 function showCartNum (item) { // 获取购物车数量
if (this.userInfo && item.title === '购物车') { if (userInfo.value && item.title === '购物车') {
this.getCartList() getCartList()
} }
}, }
clickBar (val) { // tabbar点击操作
if (!this.userInfo) { function clickBar (val) { // tabbar点击操作
this.$Modal.confirm({ if (!userInfo.value) {
title: '请登录', Modal.confirm({
content: '<p>请登录后执行此操作</p>', title: '请登录',
okText: '立即登录', content: '请登录后执行此操作',
cancelText: '继续浏览', okText: '立即登录',
onOk: () => { cancelText: '继续浏览',
this.$router.push({ onOk: () => {
path: '/login', router.push({
query: { path: '/login',
rePath: this.$router.history.current.path, query: {
query: JSON.stringify(this.$router.history.current.query) rePath: route.path,
} query: JSON.stringify(route.query || {}),
}); },
}
}); });
} else {
if (val.display) {
this.handleDrawer = true
this.drawerData = val
} else {
this.handleDrawer = false
switch (val.title) {
case '会员中心':
this.openBlank('/home')
break;
case '我的资产':
this.openBlank('/home/MoneyManagement')
break;
}
}
} }
}, });
openBlank (path) { // 新页面打开地址 } else {
let routerUrl = this.$router.resolve({ if (val.display) {
path: path handleDrawer.value = true
}) drawerData.value = val
window.open(routerUrl.href, '_blank') } else {
}, handleDrawer.value = false
getCartList () { // 获取购物车列表 switch (val.title) {
cartCount().then(res => { case '会员中心':
this.$store.commit('SET_CARTNUM', res.result) router.push('/home')
this.Cookies.setItem('cartNum', res.result) break;
}) case '我的资产':
router.push('/home/MoneyManagement')
break;
}
} }
} }
} }
function getCartList () { // 获取购物车列表
cartCount().then(res => {
appStore.setCartNum(res.result)
storage.setItem('cartNum', res.result)
})
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -128,11 +125,15 @@ export default {
} }
.circle { .circle {
width: 20px; min-width: 20px;
height: 20px; height: 20px;
border-radius: 50%; padding: 0 4px;
border-radius: 10px;
color: #fff; color: #fff;
background: $theme_color; background: $theme_color;
font-size: 12px;
line-height: 20px;
box-sizing: border-box;
} }
.wrapper { .wrapper {
@@ -147,11 +148,11 @@ export default {
justify-content: center; justify-content: center;
flex-direction: column; flex-direction: column;
} }
/deep/.popup .ivu-drawer-body{ :deep(.popup .el-drawer__body) {
padding: 0!important; padding: 0 !important;
background-color: #eee; background-color: #eee;
} }
/deep/.popup .ivu-drawer-wrap{ :deep(.popup) {
z-index: 3001; z-index: 3001;
} }
</style> </style>

View File

@@ -1,42 +1,30 @@
# 右侧侧边栏组件 # 右侧侧边栏组件
> 本组件依赖于iview的组件基础上进行封装
>项目结构
>* Main -- 组件用于挂在右侧的横栏
>* drawer -- 右侧横栏的内容
>* config -- 用于设置大小
基于 Element Plus `el-drawer` 封装的右侧悬浮栏。
#### config设置 ## 项目结构
```
- `Main.vue` — 挂载右侧横栏的容器
- `Drawer.vue` — 横栏展开后的内容面板
- `config.js` — 尺寸与菜单配置
## config 设置
```js
/** /**
menuList // 组件的menu * menuList 菜单项列表
display //是否显示此menu * display 是否显示该项
badge //显示徽标数 * badge 徽标数字
titleShow //是否显示title * titleShow 是否显示标题
*/ */
width: 50,
//实例代码 menuList: [{ icon: '...' }]
width : 50, //bar的大小
menuList:[
{
icon
}
]
``` ```
## 功能模块
账户信息、购物车、我的订单、优惠券、我的资产、我的足迹、我的收藏、邮箱订阅
> 账户信息 ## 使用
> 购物车
> 我的订单
> 优惠券
> 我的资产
> 我的足迹
> 我的收藏
> 邮箱订阅
在页面中注册全局组件 `<drawer />` 即可(已在 `global.js` 中注册)。
## 如何使用
1.

View File

@@ -11,20 +11,19 @@
</div> </div>
</template> </template>
<script> <script setup>
export default { defineOptions({ name: 'Main' })
name: 'Main',
props: { defineProps({
_Title: { // 描述内容 _Title: { // 描述内容
type: null, type: null,
default: '暂无更多' default: '暂无更多'
}, },
_Size: { // 图片大小 _Size: { // 图片大小
type: Number, type: Number,
default: 150 default: 150
}
} }
}; })
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -1,81 +1,111 @@
<template> <template>
<div class="shadow-box"> <div class="shadow-box">
<div class="shadow-item flex flex-a-c flex-j-c" v-for="(item,index) in iconList" @click="handleClickIcon(item)" :key="index"> <div class="shadow-item flex flex-a-c flex-j-c" v-for="(item,index) in iconList" @click="handleClickIcon(item)" :key="index">
<img class="icon" :src="require(`@/assets/iconfont/${item.icon}.png`)"> <img class="icon" :src="getIconUrl(item.icon)">
<div class="shadow-label">{{item.label}}</div> <div class="shadow-label">{{item.label}}</div>
<div class="line" v-if="iconList.length-1 !=index"></div> <div class="line" v-if="iconList.length-1 !=index"></div>
</div> </div>
</div> </div>
</template> </template>
<script> <script setup>
export default { import { onMounted, ref } from "vue";
name: "fixed-index", import { useRoute, useRouter } from "vue-router";
data(){ import { getIconUrl } from "@/assets/iconfont/iconMap";
return{ import storage from "@/plugins/storage.js";
iconList:[
{ defineOptions({ name: "fixed-index" });
icon:"user",
label:"会员中心", const route = useRoute();
path:"/home" const router = useRouter();
},
{ const iconList = ref([
icon:"carts", {
label:"购物车", icon:"user",
path:"/cart" label:"会员中心",
}, path:"/home",
{ requireLogin: true,
icon:"notification", },
label:"消息", {
path:"/home/MsgList" icon:"carts",
}, label:"购物车",
{ path:"/cart",
icon:"collage", requireLogin: true,
label:"收藏", },
path:"/home/Favorites" {
}, icon:"notification",
{ label:"消息",
icon:"back", path:"/home/MsgList",
label:"顶部", requireLogin: true,
path:"back" },
}, {
], icon:"collage",
isScrolling:false, label:"收藏",
timeoutId:"", path:"/home/Favorites",
requireLogin: true,
},
{
icon:"back",
label:"顶部",
path:"back"
},
]);
let isScrolling = false;
let timeoutId = "";
onMounted(() => {
// 添加滚轮事件监听器
window.addEventListener('wheel', function() {
if (isScrolling) {
// 如果页面正在滚动,则取消定时器并设置 isScrolling 为 false
window.cancelAnimationFrame(timeoutId);
isScrolling = false;
} }
}, });
mounted() {
const vm = this
// 添加滚轮事件监听器
window.addEventListener('wheel', function() {
if (vm.isScrolling) {
// 如果页面正在滚动,则取消定时器并设置 isScrolling 为 false
window.cancelAnimationFrame(vm.timeoutId);
vm.isScrolling = false;
}
});
}, });
methods:{
scrollToTop() { function scrollToTop() {
const c = document.documentElement.scrollTop || document.body.scrollTop; const c = document.documentElement.scrollTop || document.body.scrollTop;
if (c > 0) { if (c > 0) {
this.timeoutId = window.requestAnimationFrame(this.scrollToTop); timeoutId = window.requestAnimationFrame(scrollToTop);
window.scrollTo(0, c - c / 4); window.scrollTo(0, c - c / 4);
this.isScrolling = true; isScrolling = true;
}else { }else {
this.isScrolling = false; isScrolling = false;
} }
}, }
handleClickIcon(val){ function isLoggedIn () {
if(val.path === 'back'){ const rawUserInfo = storage.getItem('userInfo');
this.scrollToTop() if (rawUserInfo) {
}else{ try {
this.$router.push(val.path) return !!JSON.parse(rawUserInfo).username;
} } catch {
return false;
} }
} }
return !!storage.getItem('accessToken');
}
function goLogin (rePath) {
router.push({
path: '/login',
query: {
rePath,
query: JSON.stringify(route.query || {}),
},
});
}
function handleClickIcon (val) {
if (val.path === 'back') {
scrollToTop();
return;
}
if (val.requireLogin && !isLoggedIn()) {
goLogin(val.path);
return;
}
router.push(val.path);
} }
</script> </script>

View File

@@ -68,34 +68,29 @@
</div> </div>
</template> </template>
<script> <script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import config from '@/config'
export default { defineOptions({ name: 'Footer' })
name: "Footer",
data() { const router = useRouter()
return {
config:require('@/config'), const guideArr = ref([
guideArr: [ // 导航链接
// 导航链接 ["购物指南", "购物流程", "会员介绍", "生活旅行", "常见问题"],
["购物指南", "购物流程", "会员介绍", "生活旅行", "常见问题"], ["配送方式", "上门自提", "配送查询", "收取标准", "物流规则"],
["配送方式", "上门自提", "配送查询", "收取标准", "物流规则"], ["支付方式", "在线支付", "公司转账", "余额支付", "积分支付"],
["支付方式", "在线支付", "公司转账", "余额支付", "积分支付"], ["售后服务", "售后政策", "退款说明", "返修/退货", "取消订单"],
["售后服务", "售后政策", "退款说明", "返修/退货", "取消订单"], ])
], const moreLink = ref(["关于我们", "联系我们", "联系客服", "商家帮助", "隐私政策"]) // 更多链接
moreLink: ["关于我们", "联系我们", "联系客服", "商家帮助", "隐私政策"], // 更多链接 const year = ref(new Date().getFullYear()) // 当前年份
year: new Date().getFullYear(), // 当前年份
}; function goArticle() {
}, // 跳转文章页
methods: { router.push("/article")
goArticle() { }
// 跳转文章页
let routeUrl = this.$router.resolve({
path: "/article",
});
window.open(routeUrl.href, "_blank");
},
},
};
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -1,54 +1,21 @@
// 全局组件配置 import empty from "./empty/Main.vue";
import drawer from "./drawer/Main.vue";
import Header from "@/components/header/Header.vue";
import FixedTopPage from "@/components/advertising/FixedTop.vue";
import Footer from "@/components/footer/Footer.vue";
import Search from "@/components/Search.vue";
import card from "@/components/card/index.vue";
import cateNav from "@/components/nav/CateNav.vue";
import UserCenterLayout from "@/components/userCenter/Layout.vue";
import empty from './empty/Main' // 空状态组件 export function registerGlobalComponents(app) {
import drawer from './drawer/Main' // 右侧bar app.component("empty", empty);
import Header from '@/components/header/Header'; // 头部组件 app.component("drawer", drawer);
import FixedTopPage from '@/components/advertising/FixedTop'; // 顶部广告 app.component("BaseHeader", Header);
import Footer from '@/components/footer/Footer'; // 底部栏 app.component("FixedTopPage", FixedTopPage);
import Search from '@/components/Search' // 搜索框 app.component("BaseFooter", Footer);
import card from '@/components/card' // 个人中心 卡片 app.component("Search", Search);
import cateNav from '@/components/nav/CateNav' // 个人中心 卡片 app.component("card", card);
app.component("UserCenterLayout", UserCenterLayout);
empty.install = function (Vue) { app.component("cateNav", cateNav);
Vue.component('empty', empty);
};
drawer.install = function (Vue) {
Vue.component('drawer', drawer);
};
Header.install = function (Vue) {
Vue.component('BaseHeader', Header);
};
FixedTopPage.install = function (Vue) {
Vue.component('FixedTopPage', FixedTopPage);
};
Footer.install = function (Vue) {
Vue.component('BaseFooter', Footer);
};
Search.install = function (Vue) {
Vue.component('Search', Search);
};
card.install = function (Vue) {
Vue.component('card', card)
}
cateNav.install = function (Vue) {
Vue.component('cateNav', cateNav)
}
// 引用本js中所有的组件
export function InstallAll (Vue) {
Vue.use(empty)
Vue.use(drawer)
Vue.use(Header)
Vue.use(FixedTopPage)
Vue.use(Footer)
Vue.use(Search)
Vue.use(card)
Vue.use(cateNav)
} }

View File

@@ -0,0 +1,146 @@
<template>
<div class="goods-thumb" ref="rootEl" :class="{ 'is-loaded': loaded, 'is-error': error }">
<img v-if="currentSrc" :src="currentSrc" :alt="alt" @load="loaded = true" @error="onError" />
<img v-else-if="error" :src="defaultImg" :alt="alt || '默认商品图片'" class="default-img" />
</div>
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import defaultImg from "@/assets/images/default.png";
defineOptions({ name: "GoodsThumb" });
const props = defineProps({
src: {
type: String,
default: "",
},
alt: {
type: String,
default: "",
},
});
const rootEl = ref(null);
const currentSrc = ref("");
const loaded = ref(false);
const error = ref(false);
let observer = null;
function isValidUrl(url) {
if (!url || typeof url !== "string") return false;
const s = url.trim().toLowerCase();
if (
!s ||
s === "null" ||
s === "undefined" ||
s.startsWith("null?") ||
s.startsWith("undefined?")
) {
return false;
}
return true;
}
watch(
() => props.src,
() => {
observe();
}
);
function disconnect() {
if (observer) {
observer.disconnect();
observer = null;
}
}
function observe() {
disconnect();
currentSrc.value = "";
loaded.value = false;
error.value = false;
if (!isValidUrl(props.src)) {
error.value = true;
return;
}
if (typeof IntersectionObserver === "undefined") {
currentSrc.value = props.src;
return;
}
observer = new IntersectionObserver(
(entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
currentSrc.value = props.src;
disconnect();
},
{ rootMargin: "160px 0px" }
);
observer.observe(rootEl.value);
}
function onError() {
error.value = true;
loaded.value = false;
currentSrc.value = "";
}
onMounted(() => {
observe();
});
onBeforeUnmount(() => {
disconnect();
});
</script>
<style scoped lang="scss">
.goods-thumb {
position: relative;
width: 100%;
aspect-ratio: 1;
overflow: hidden;
background: #f3f3f3;
}
.goods-thumb::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, #f3f3f3 25%, #e8e8e8 37%, #f3f3f3 63%);
background-size: 400% 100%;
animation: goods-thumb-loading 1.2s ease infinite;
}
.goods-thumb.is-loaded::before,
.goods-thumb.is-error::before {
display: none;
}
.goods-thumb img {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
&.default-img {
object-fit: contain;
padding: 14px;
box-sizing: border-box;
opacity: 0.55;
}
}
@keyframes goods-thumb-loading {
0% {
background-position: 100% 0;
}
100% {
background-position: 0 0;
}
}
</style>

View File

@@ -1,9 +1,14 @@
<template> <template>
<div> <div>
<div class="wrapper" v-if="type === 'goodsDetail'"> <div class="wrapper" v-if="type === 'goodsDetail'">
<div class="wr-l"><Icon size="23" type="ios-alarm-outline" /> 秒杀活动</div> <div class="wr-l"><el-icon :size="23"><AlarmClock /></el-icon> 秒杀活动</div>
<div class="count-down" v-if="end === ''"> <div class="count-down" v-if="end === ''">
<p>倒计时:</p><span>{{ hours }}</span><span>{{ minutes }}</span><span>{{ seconds }}</span> <span class="count-down-label">倒计时:</span>
<span class="count-down-num">{{ hours }}</span>
<span class="count-down-colon">:</span>
<span class="count-down-num">{{ minutes }}</span>
<span class="count-down-colon">:</span>
<span class="count-down-num">{{ seconds }}</span>
</div> </div>
<div v-else>{{end}}</div> <div v-else>{{end}}</div>
</div> </div>
@@ -13,58 +18,57 @@
</span> </span>
</div> </div>
</template> </template>
<script> <script setup>
export default { import { onMounted, ref } from 'vue';
props: { import { AlarmClock } from '@element-plus/icons-vue';
time: { // 传入的初始时间
default: 1718977559428 const props = defineProps({
}, time: { // 传入的初始时间
type: { // 区分是在详情还是购物车调用 default: 1718977559428
default: 'goodsDetail', // 设置两个值,goodsDetail和cart,样式不同
type: String
}
}, },
data () { type: { // 区分是在详情还是购物车调用
return { default: 'goodsDetail', // 设置两个值,goodsDetail和cart,样式不同
end: '', // 结束状态 type: String
hours: '', // 小时
minutes: '', // 分钟
seconds: '', // 秒
interval: '' // 定时器
};
},
mounted () {
this.init()
},
methods: {
countDown (val) { // 倒计时方法
function addZero (i) {
return i < 10 ? '0' + i : i + '';
}
var nowtime = new Date();
var endtime = new Date(val);
var lefttime = parseInt((endtime.getTime() - nowtime.getTime()) / 1000);
var h = parseInt((lefttime / (60 * 60)) % 24);
var m = parseInt((lefttime / 60) % 60);
var s = parseInt(lefttime % 60);
h = addZero(h);
m = addZero(m);
s = addZero(s);
this.hours = h;
this.minutes = m
this.seconds = s;
if (lefttime <= 0) {
this.end = `活动已结束`;
clearInterval(this.interval)
}
},
init () { // 初始化
this.interval = setInterval(() => {
this.countDown(this.time);
}, 1000);
}
} }
}; });
const end = ref(''); // 结束状态
const hours = ref(''); // 小时
const minutes = ref(''); // 分钟
const seconds = ref(''); // 秒
let interval = null; // 定时器
function countDown (val) { // 倒计时方法
function addZero (i) {
return i < 10 ? '0' + i : i + '';
}
var nowtime = new Date();
var endtime = new Date(val);
var lefttime = parseInt((endtime.getTime() - nowtime.getTime()) / 1000);
var h = parseInt((lefttime / (60 * 60)) % 24);
var m = parseInt((lefttime / 60) % 60);
var s = parseInt(lefttime % 60);
h = addZero(h);
m = addZero(m);
s = addZero(s);
hours.value = h;
minutes.value = m
seconds.value = s;
if (lefttime <= 0) {
end.value = `活动已结束`;
clearInterval(interval)
}
}
function init () { // 初始化
interval = setInterval(() => {
countDown(props.time);
}, 1000);
}
onMounted(() => {
init()
});
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.cart-promotion{ .cart-promotion{
@@ -87,36 +91,35 @@ export default {
font-size: 13px; font-size: 13px;
} }
.count-down { .count-down {
margin-right: -20px; display: flex;
p{ align-items: center;
float: left; gap: 4px;
line-height: 20px; font-size: 13px;
line-height: 1;
.count-down-label {
flex-shrink: 0;
white-space: nowrap;
} }
> span {
position: relative; .count-down-num {
float: left; display: inline-flex;
width: 20px; align-items: center;
justify-content: center;
min-width: 20px;
height: 20px; height: 20px;
text-align: center; padding: 0 2px;
background-color: #2f3430; background-color: #2f3430;
margin-right: 20px; color: #fff;
color: white;
font-size: 14px; font-size: 14px;
line-height: 20px; line-height: 1;
&::after { border-radius: 2px;
content: ":";
display: block;
position: absolute;
right: -20px;
font-weight: bolder;
font-size: 14px;
width: 20px;
height: 100%;
top: 0;
}
} }
> span:last-child::after {
content: ""; .count-down-colon {
font-weight: bold;
font-size: 14px;
line-height: 1;
} }
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,495 @@
<template>
<aside class="store-side" v-if="storeId">
<div class="store-card">
<div class="store-head" @click="goStore">
<img
v-if="store.storeLogo && !logoError"
class="store-logo"
:src="store.storeLogo"
loading="lazy"
decoding="async"
:alt="storeName"
@error="logoError = true"
/>
<span v-else class="store-logo store-logo-fallback">{{ storeInitial }}</span>
<div class="store-head-text">
<p class="store-name">
<span v-if="isSelfOperated" class="store-self">自营</span>
<span class="store-name-text">{{ storeName }}</span>
</p>
</div>
</div>
<div class="store-scores">
<div class="score-item">
<span class="score-label">描述</span>
<span class="score-value">{{ formatScore(store.descriptionScore) }}</span>
</div>
<div class="score-item">
<span class="score-label">服务</span>
<span class="score-value">{{ formatScore(store.serviceScore) }}</span>
</div>
<div class="score-item">
<span class="score-label">物流</span>
<span class="score-value">{{ formatScore(store.deliveryScore) }}</span>
</div>
</div>
<div class="store-actions">
<button type="button" class="store-btn" @click="contact">联系客服</button>
<button type="button" class="store-btn" :class="{ 'is-on': collected }" @click="toggleCollect">
{{ collected ? "已收藏" : "收藏店铺" }}
</button>
</div>
<button type="button" class="enter-store" @click="goStore">进店逛逛</button>
</div>
<div class="recommend-card" v-if="recommends.length">
<p class="recommend-title">店铺推荐</p>
<div
class="recommend-item"
v-for="item in recommends"
:key="item.id"
@click="goGoods(item)"
>
<img class="recommend-img" :src="item.thumbnail || item.small" loading="lazy" decoding="async" :alt="item.goodsName" />
<div class="recommend-info">
<p class="recommend-name">{{ item.goodsName }}</p>
<div class="recommend-meta">
<p class="recommend-price">{{ $filters.unitPrice(item.price, "¥") }}</p>
<span class="recommend-sales">销量 {{ item.buyCount || 0 }}</span>
</div>
</div>
</div>
</div>
</aside>
</template>
<script setup>
import { computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message, Modal } from "@/utils/message";
import { goodsList } from "@/api/goods";
import { getStoreById } from "@/api/shopentry";
import { cancelStoreCollect, collectStore, isStoreCollection } from "@/api/member";
import { getIMDetail } from "@/api/common";
import { getMemberMsg } from "@/api/login";
import storage from "@/plugins/storage.js";
defineOptions({ name: "StoreSide" });
const props = defineProps({
storeId: {
type: [String, Number],
default: "",
},
goodsId: {
type: [String, Number],
default: "",
},
skuId: {
type: [String, Number],
default: "",
},
storeNameFallback: {
type: String,
default: "",
},
});
const route = useRoute();
const router = useRouter();
const store = ref({});
const logoError = ref(false);
const collected = ref(false);
const recommends = ref([]);
// 原 talkIm mixin 的 IM 链接
const IMLink = ref("");
const storeName = computed(() => {
return store.value.storeName || props.storeNameFallback || "店铺";
});
const storeInitial = computed(() => {
return storeName.value.slice(0, 1);
});
// 自营标记后端可能是 true、1 或 "1"
const isSelfOperated = computed(() => {
const flag = store.value.selfOperated;
return flag === true || flag === 1 || flag === "1";
});
watch(
() => props.storeId,
(id) => {
if (!id) return;
logoError.value = false;
loadStore(id);
loadRecommend(id);
loadCollected(id);
},
{ immediate: true }
);
// 没有评分或小于等于 0 时按满分展示
function formatScore(value) {
const score = Number(value);
if (!Number.isFinite(score) || score <= 0) return "5.00";
return score.toFixed(2);
}
function loadStore(id) {
getStoreById(id).then((res) => {
if (res.success && res.result) store.value = res.result;
}).catch((err) => {
console.warn('获取店铺信息失败', err);
});
}
// 按销量取店内商品,去掉当前商品,最多 4 个
function loadRecommend(id) {
goodsList({
storeId: id,
pageNumber: 1,
pageSize: 8,
sort: "buyCount",
order: "desc",
}).then((res) => {
const records = (res.success && res.result && res.result.records) || [];
recommends.value = records
.filter((item) => String(item.goodsId) !== String(props.goodsId))
.slice(0, 4);
}).catch(() => {
// 推荐商品为可选数据,加载失败时静默
});
}
// 同时有 token 和用户信息才查收藏,避免残留 userInfo 触发登录跳转
function loadCollected(id) {
if (!storage.getItem("accessToken") || !storage.getItem("userInfo")) return;
isStoreCollection("STORE", id).then((res) => {
if (res.success && res.result) collected.value = true;
}).catch(() => {
// 收藏状态为可选数据,失败时静默
});
}
function goStore() {
router.push({ path: "/merchant", query: { id: props.storeId } });
}
// 推荐商品新开详情页,店铺页仍在当前页打开
function goGoods(item) {
const href = router.resolve({
path: "/goodsDetail",
query: { skuId: item.id, goodsId: item.goodsId },
}).href;
window.open(href, "_blank");
}
// 原 talkIm mixin 的登录提示
function promptLogin() {
Modal.confirm({
title: "温馨提示",
content: "请登录后执行此操作",
okText: "立即登录",
cancelText: "取消",
onOk: () => {
router.push({
path: "/login",
query: {
rePath: route.path,
query: JSON.stringify(route.query || {}),
},
});
},
});
}
// 获取im信息(原 talkIm mixin)
async function getIMDetailMethods() {
let res = await getIMDetail();
if (res.success) {
IMLink.value = res.result;
}
}
// 跳转im客服(原 talkIm mixin;本组件无 storeMsg,mixin 的 storeMsg?.storeId 兜底恒为 undefined,等价于直接用传入的 id)
async function IMService(id, goodsId, skuId) {
if (!storage.getItem("accessToken")) {
promptLogin();
return;
}
try {
await getIMDetailMethods();
const userInfo = await getMemberMsg();
if (!userInfo.success) {
promptLogin();
return;
}
const accessToken = storage.getItem("accessToken");
const storeId = id;
const baseUrl = `${IMLink.value}?token=${accessToken}&id=${storeId}`;
if (goodsId && skuId) {
window.open(`${baseUrl}&goodsId=${goodsId}&skuId=${skuId}`);
} else {
window.open(baseUrl);
}
} catch {
// token 过期等场景由 request 拦截器处理;未登录时避免未捕获异常
if (!storage.getItem("accessToken")) {
promptLogin();
}
}
}
function contact() {
IMService(props.storeId, props.goodsId, props.skuId);
}
// 未登录先去登录,并带回当前商品页
async function toggleCollect() {
if (!storage.getItem("accessToken") || !storage.getItem("userInfo")) {
router.push({
path: "/login",
query: {
rePath: route.path,
query: JSON.stringify(route.query || {}),
},
});
return;
}
if (collected.value) {
const cancel = await cancelStoreCollect("STORE", props.storeId);
if (cancel.success) {
collected.value = false;
Message.success("已取消收藏");
}
return;
}
const collect = await collectStore("STORE", props.storeId);
if (collect.code === 200) {
collected.value = true;
Message.success("收藏店铺成功,可以前往个人中心我的收藏查看");
}
}
</script>
<style scoped lang="scss">
.store-side {
width: 260px;
flex-shrink: 0;
position: sticky;
top: 16px;
}
.store-card,
.recommend-card {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 4px;
}
.store-card {
padding: 16px;
}
.store-head {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
cursor: pointer;
}
.store-logo {
width: 64px;
height: 64px;
border-radius: 4px;
object-fit: cover;
border: 1px solid #f0f0f0;
flex-shrink: 0;
background: #fafafa;
}
.store-logo-fallback {
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: $theme_color;
font-size: 18px;
font-weight: 600;
}
.store-head-text {
width: 100%;
text-align: center;
}
.store-name {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
margin: 0;
color: #171A26;
font-size: 15px;
font-weight: 600;
line-height: 22px;
}
.store-name-text {
min-width: 0;
}
.store-self {
flex-shrink: 0;
padding: 0 6px;
border-radius: 2px;
background: $theme_color;
color: #fff;
font-size: 12px;
font-weight: 400;
line-height: 18px;
}
.store-scores {
display: flex;
margin-top: 14px;
padding: 10px 0;
border-top: 1px solid #f5f5f5;
border-bottom: 1px solid #f5f5f5;
}
.score-item {
flex: 1;
text-align: center;
}
.score-label {
display: block;
color: #999;
font-size: 12px;
line-height: 18px;
}
.score-value {
display: block;
margin-top: 2px;
color: $theme_color;
font-size: 14px;
font-weight: 600;
line-height: 20px;
}
.store-actions {
display: flex;
gap: 8px;
margin-top: 14px;
}
.store-btn,
.enter-store {
border: 1px solid #eee;
background: #fff;
color: #171A26;
font-size: 13px;
line-height: 32px;
border-radius: 4px;
cursor: pointer;
&:hover {
color: $theme_color;
border-color: $theme_color;
}
}
.store-btn {
flex: 1;
}
.store-btn.is-on {
color: $theme_color;
border-color: $theme_color;
background: #fff5f4;
}
.enter-store {
width: 100%;
margin-top: 8px;
}
.recommend-card {
margin-top: 12px;
padding: 12px 12px 4px;
}
.recommend-title {
margin: 0 0 8px;
color: #171A26;
font-size: 18px;
font-weight: 600;
line-height: 26px;
}
.recommend-item {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0 12px;
cursor: pointer;
& + & {
border-top: 1px solid #f5f5f5;
}
}
.recommend-img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 4px;
background: #fafafa;
}
.recommend-info {
min-width: 0;
}
.recommend-name {
margin: 0;
color: #333;
font-size: 16px;
line-height: 24px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recommend-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 4px;
}
.recommend-price {
margin: 0;
color: $theme_color;
font-size: 18px;
font-weight: 600;
line-height: 26px;
}
.recommend-sales {
flex-shrink: 0;
color: #999;
font-size: 15px;
line-height: 22px;
}
</style>

View File

@@ -23,12 +23,13 @@
<li v-show="!!userInfo.username"> <li v-show="!!userInfo.username">
<div class="username-p"> <div class="username-p">
<div> <div>
<Avatar class="person-icon" :src="userInfo.face" icon="person" size="small"/> <el-avatar class="person-icon" :src="userInfo.face" :size="24" />
<span class="username">{{ <span class="username">{{ $filters.secrecyMobile(
userInfo.nickName ? userInfo.nickName : userInfo.username | secrecyMobile userInfo.nickName ? userInfo.nickName : userInfo.username ) }}</span>
}}</span>
</div> </div>
<transition name='fade'> <transition name='fade'>
<!-- 下拉菜单通过父级 hover 的 CSS 控制显隐,transition 内无需 v-if/v-show -->
<!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
<ul class="drop-items"> <ul class="drop-items">
<li @click="goUserCenter('/home')">我的主页</li> <li @click="goUserCenter('/home')">我的主页</li>
<li @click="goUserCenter('/home/Coupons')">优惠券</li> <li @click="goUserCenter('/home/Coupons')">优惠券</li>
@@ -40,46 +41,45 @@
</li> </li>
<li @click="goUserCenter('/home/MyOrder')"><span class="nav-item">我的订单</span></li> <li @click="goUserCenter('/home/MyOrder')"><span class="nav-item">我的订单</span></li>
<li @click="goUserCenter('/home/MyTracks')"><span class="nav-item">我的足迹</span></li> <li @click="goUserCenter('/home/MyTracks')"><span class="nav-item">我的足迹</span></li>
<li @click="goUserCenter('/home/MsgList')"><span class="nav-item">我的消息</span></li> <li @click="goUserCenter('/home/MsgList')">
<li v-if="$route.name !== 'Cart'" style="position:relative;"> <span class="nav-item">
<Dropdown placement="bottom-start"> 我的消息
<router-link to="/cart" target="_blank"> <i v-if="unreadMsg > 0" class="msg-badge">{{ unreadLabel }}</i>
<span class="nav-item" @mouseenter="getCartList"> </span>
<Icon size="18" type="ios-cart-outline"></Icon> </li>
购物车({{ cartNum < 100 ? cartNum : '99' }}) <li v-if="$route.name !== 'Cart'" class="cart-nav-item">
</span> <el-dropdown
</router-link> class="cart-nav-dropdown"
<DropdownMenu slot="list"> placement="bottom-start"
<div class="shopping-cart-null" style="width:200px" v-show="shoppingCart.length <= 0"> trigger="hover"
<Icon type="ios-cart-outline" class="cart-null-icon"></Icon> @visible-change="handleCartDropdownVisible"
>
<span class="nav-item" @click="goToPay">
<el-icon :size="18"><ShoppingCart /></el-icon>
购物车({{ Number(cartNum) > 99 ? '99+' : cartNum }})
</span>
<template #dropdown>
<div class="shopping-cart-null" v-show="shoppingCart.length <= 0">
<el-icon class="cart-null-icon"><ShoppingCart /></el-icon>
<span>你的购物车没有宝贝哦</span> <span>你的购物车没有宝贝哦</span>
<span>赶快去添加商品吧~</span> <span>赶快去添加商品吧~</span>
</div> </div>
<div class="shopping-cart-list" v-show="shoppingCart.length > 0"> <div class="shopping-cart-list" v-show="shoppingCart.length > 0">
<div class="shopping-cart-box" v-for="(item, index) in shoppingCart" @click="goToPay" :key="index"> <div class="shopping-cart-box" v-for="(item, index) in shoppingCart" @click="goToPay" :key="index">
<div class="shopping-cart-img"> <div class="shopping-cart-img">
<img :src="item.goodsSku.thumbnail" class="hover-pointer"/> <img :src="item.goodsSku.thumbnail" alt="" />
</div> </div>
<div class="shopping-cart-info"> <div class="shopping-cart-info">
<div class="shopping-cart-title "> <p class="goods-title">{{ item.goodsSku.goodsName }}</p>
<p class="hover-pointer goods-title ellipsis">{{ item.goodsSku.goodsName }}</p>
</div>
<div class="shopping-cart-detail"> <div class="shopping-cart-detail">
<div> <span>数量 {{ item.num }}</span>
数量: <span class="shopping-price">{{ $filters.unitPrice(item.purchasePrice, '¥') }}</span>
<span class="shopping-cart-text">{{ item.num }}</span>
</div>
<div class="shopping-price">
{{ item.purchasePrice | unitPrice('¥') }}
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</DropdownMenu> </template>
</Dropdown> </el-dropdown>
</li> </li>
<li> <li>
<span class="nav-item" @click="shopEntry">店铺入驻</span> <span class="nav-item" @click="shopEntry">店铺入驻</span>
@@ -90,145 +90,249 @@
</div> </div>
</template> </template>
<script> <script setup>
import { ref, computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Modal } from "@/utils/message";
import { ShoppingCart } from '@element-plus/icons-vue';
import storage from '@/plugins/storage.js'; import storage from '@/plugins/storage.js';
import {cartGoodsAll} from '@/api/cart.js'; import {cartGoodsAll} from '@/api/cart.js';
import {logout} from '@/api/account.js'; import {logout} from '@/api/account.js';
import config from '@/config';
import { refreshUnreadMessage } from '@/utils/unreadMessage';
import { useAppStore } from '@/stores/app';
defineOptions({ name: 'M-Header' });
export default { const route = useRoute();
name: 'M-Header', const router = useRouter();
created() { const appStore = useAppStore();
if (storage.getItem('userInfo')) {
this.userInfo = JSON.parse(storage.getItem('userInfo'));
}
},
data() { const userInfo = ref({}); // 用户信息
return { const shoppingCart = ref([]); // 购物车
config: require('@/config'), const cartLoading = ref(false);
userInfo: {}, // 用户信息 const cartLoadedAt = ref(0);
shoppingCart: [] // 购物车 const cartCacheTime = ref(5000);
};
}, // 购物车商品数量
computed: { const cartNum = computed(() => appStore.cartNum);
// 购物车商品数量 const unreadMsg = computed(() => appStore.unreadMsg);
cartNum() { const unreadLabel = computed(() => {
return this.$store.state.cartNum; return unreadMsg.value > 99 ? '99+' : String(unreadMsg.value);
} });
},
methods: { if (storage.getItem('userInfo')) {
goToPay() { // 跳转购物车 userInfo.value = JSON.parse(storage.getItem('userInfo'));
let url = this.$router.resolve({ refreshUnreadMessage();
path: '/cart' }
});
window.open(url.href, '_blank'); function goToPay() {
}, if (!userInfo.value.username) {
myInfo() { // 跳转会员中心 Modal.confirm({
let url = this.$router.resolve({ title: '温馨提示',
path: '/home' content: '请登录后执行此操作',
}); okText: '立即登录',
window.open(url.href, '_blank'); cancelText: '取消',
}, onOk: () => {
signOutFun() { // 退出登录 router.push({
logout().then(res => { path: '/login',
storage.removeItem('accessToken'); query: {
storage.removeItem('refreshToken'); rePath: '/cart',
storage.removeItem('userInfo'); query: JSON.stringify(route.query || {}),
storage.removeItem('cartNum'); },
this.$store.commit('SET_CARTNUM', 0)
this.$router.push('/login');
});
},
goUserCenter(path) {
// 跳转我的订单,我的足迹、收藏等
if (this.userInfo.username) {
this.$router.push({path: path});
} else {
this.$Modal.confirm({
title: '请登录',
content: '<p>请登录后执行此操作</p>',
okText: '立即登录',
cancelText: '继续浏览',
onOk: () => {
this.$router.push({
path: '/login',
query: {
rePath: this.$router.history.current.path,
query: JSON.stringify(this.$router.history.current.query)
}
});
}
}); });
} },
}, });
shopEntry() { return;
// 店铺入驻
if (storage.getItem('accessToken')) {
let routeUrl = this.$router.resolve({
path: '/shopEntry',
query: {id: 1}
});
window.open(routeUrl.href, '_blank');
} else {
this.$router.push('login');
}
},
getCartList() {
// 获取购物车列表
if (this.userInfo.username) {
cartGoodsAll().then((res) => {
this.shoppingCart = res.result.skuList;
this.$store.commit('SET_CARTNUM', this.shoppingCart.length);
this.Cookies.setItem('cartNum', this.shoppingCart.length);
});
}
}
} }
}; router.push('/cart');
}
function myInfo() { // 跳转会员中心
router.push('/home');
}
function signOutFun() { // 退出登录
logout().then(res => {
storage.removeItem('accessToken');
storage.removeItem('refreshToken');
storage.removeItem('userInfo');
storage.removeItem('cartNum');
appStore.setCartNum(0)
appStore.setUnreadMsg(0)
router.push('/login');
});
}
function goUserCenter(path) {
// 跳转我的订单,我的足迹、收藏等
if (userInfo.value.username) {
router.push({path: path});
} else {
Modal.confirm({
title: '请登录',
content: '请登录后执行此操作',
okText: '立即登录',
cancelText: '继续浏览',
onOk: () => {
router.push({
path: '/login',
query: {
rePath: route.path,
query: JSON.stringify(route.query || {}),
},
});
}
});
}
}
function shopEntry() {
if (storage.getItem('accessToken')) {
router.push({
path: '/shopEntry',
query: { id: 1 },
});
return;
}
Modal.confirm({
title: '温馨提示',
content: '请登录后执行此操作',
okText: '立即登录',
cancelText: '取消',
onOk: () => {
router.push({
path: '/login',
query: {
rePath: '/shopEntry',
query: JSON.stringify({ id: 1 }),
},
});
},
});
}
function handleCartDropdownVisible(visible) {
if (visible) {
getCartList();
}
}
function getCartList() {
// 获取购物车列表
if (!userInfo.value.username || cartLoading.value) {
return;
}
const now = Date.now();
if (cartLoadedAt.value && now - cartLoadedAt.value < cartCacheTime.value) {
return;
}
cartLoadedAt.value = now;
cartLoading.value = true;
cartGoodsAll().then((res) => {
if (res.success) {
shoppingCart.value = (res.result && res.result.skuList) || [];
appStore.setCartNum(shoppingCart.value.length);
storage.setItem('cartNum', shoppingCart.value.length);
}
}).finally(() => {
cartLoading.value = false;
});
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.box { .box {
width: 100%; width: 100%;
font-size: 12px !important; font-size: 12px;
height: 36px; height: 40px;
background: #333; background: #f8fafc;
color: #fff; border-bottom: 1px solid #e2e8f0;
color: #64748b;
display: flex;
align-items: center;
} }
.nav { .nav {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center;
justify-content: space-between; justify-content: space-between;
height: 100%;
} }
.nav ul { .nav > ul {
list-style: none; list-style: none;
margin: 0;
padding: 0;
display: flex;
align-items: center;
} }
.nav li { .nav > ul > li,
.location > ul.flex > li {
cursor: pointer; cursor: pointer;
float: left; float: none;
line-height: 36px; line-height: 40px;
margin-right: 15px; margin-right: 15px;
display: flex;
align-items: center;
} }
.nav a, .nav a,
.nav-item { .nav-item {
font-size: 13px; font-size: 12px;
font-weight: normal; font-weight: normal;
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
color: #fff; color: #64748b;
display: inline-flex;
align-items: center;
line-height: 1;
transition: color 0.15s ease;
&:hover {
color: #409EFF;
}
} }
.nav-item { .nav-item {
padding-left: 20px; padding-left: 20px;
gap: 4px;
}
.msg-badge {
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
background: $theme_color;
color: #fff;
font-size: 12px;
font-style: normal;
line-height: 16px;
text-align: center;
}
.cart-nav-item {
:deep(.cart-nav-dropdown),
:deep(.el-dropdown) {
display: inline-flex;
align-items: center;
margin: 0;
padding: 0;
vertical-align: middle;
line-height: 40px;
}
:deep(.el-tooltip__trigger),
:deep(.el-dropdown-selfdefine),
:deep([aria-expanded="true"]) {
outline: none !important;
border: none !important;
box-shadow: none !important;
}
} }
.location a { .location a {
@@ -261,30 +365,10 @@ export default {
} }
.shopping-cart-img {
margin-right: 15px;
width: 40px;
height: 40px;
}
.shopping-cart-img img {
width: 100%;
}
.shopping-cart-info {
display: flex;
flex-direction: column;
justify-content: space-between;
align-content: space-between;
width: 200px;
overflow: hidden;
font-size: 12px;
line-height: 20px;
}
.shopping-cart-null { .shopping-cart-null {
padding: 15px; width: 320px;
padding: 28px 16px;
box-sizing: border-box;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -292,20 +376,102 @@ export default {
} }
.cart-null-icon { .cart-null-icon {
font-size: 38px; font-size: 36px;
margin-bottom: 15px; margin-bottom: 12px;
color: #ccc;
} }
.shopping-cart-null span { .shopping-cart-null span {
font-size: 12px; font-size: 13px;
line-height: 16px; line-height: 1.6;
color: #999;
}
.shopping-cart-list {
width: 320px;
max-height: 320px;
overflow-y: auto;
padding: 4px 0;
box-sizing: border-box;
color: #333;
}
.shopping-cart-box {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
&:hover {
background: #fafafa;
}
& + .shopping-cart-box {
border-top: 1px solid #f2f2f2;
}
}
.shopping-cart-img {
width: 64px;
height: 64px;
flex-shrink: 0;
border-radius: 6px;
overflow: hidden;
background: #f5f5f5;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.shopping-cart-info {
flex: 1;
min-width: 0;
min-height: 64px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.shopping-cart-info .goods-title {
margin: 0;
font-size: 13px;
line-height: 1.4;
color: #333;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
white-space: normal;
}
.shopping-cart-detail {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
color: #999;
font-size: 14px;
}
.shopping-price {
color: $theme_color;
font-size: 14px;
font-weight: 600;
} }
.username-p { .username-p {
position: relative; position: relative;
z-index: 100;
div { div {
cursor: pointer; cursor: pointer;
display: flex;
align-items: center;
> span { > span {
margin-left: 5px; margin-left: 5px;
@@ -315,59 +481,71 @@ export default {
.drop-items { .drop-items {
position: absolute; position: absolute;
display: none; display: none;
top: 45px; flex-direction: column;
left: 0; top: calc(100% + 10px);
right: 0; left: 50%;
margin: 0 auto; transform: translateX(-50%);
padding: 5px 10px; margin: 0;
z-index: 20; padding: 4px 0;
height: 150px; z-index: 1000;
min-width: 100px;
width: max-content;
background-color: #fff; background-color: #fff;
width: 80px;
border: 1px solid #eee; border: 1px solid #eee;
box-shadow: 2px 2px 7px #999; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
list-style: none;
li { li {
color: rgb(107, 106, 106); display: block;
float: none;
width: 100%; width: 100%;
border-bottom: 1px solid rgb(207, 206, 206); margin: 0;
padding: 8px 16px;
line-height: 1.4;
color: #666;
font-size: 14px;
font-weight: normal; font-weight: normal;
text-align: center; text-align: center;
white-space: nowrap;
border-bottom: 1px solid #f0f0f0;
box-sizing: border-box;
&:last-child { &:last-child {
border: none; border-bottom: none;
} }
&:hover { &:hover {
cursor: pointer; cursor: pointer;
color: $theme_color; color: $theme_color;
background-color: #fff7f7;
} }
} }
&::before { &::before {
position: absolute; position: absolute;
top: -20px; top: -6px;
left: 30px; left: 50%;
transform: translateX(-50%);
content: ''; content: '';
width: 0; width: 0;
height: 0; height: 0;
border: 10px solid #999; border: 6px solid transparent;
border-color: transparent transparent #fff transparent; border-bottom-color: #fff;
} }
&::after { &::after {
content: ''; content: '';
position: absolute; position: absolute;
width: 80px; width: 100%;
height: 20px; height: 10px;
top: -20px; top: -10px;
left: 0; left: 0;
} }
} }
&:hover { &:hover {
.drop-items { .drop-items {
display: block; display: flex;
} }
} }
} }
@@ -404,7 +582,7 @@ export default {
} }
.my-info p { .my-info p {
font-size: 12px; font-size: 14px;
} }
.sign-out { .sign-out {
@@ -424,7 +602,7 @@ export default {
} }
.sign-out p { .sign-out p {
font-size: 12px; font-size: 14px;
} }
.goods-title:hover { .goods-title:hover {
@@ -446,34 +624,4 @@ export default {
z-index: 3; z-index: 3;
top: 3px; top: 3px;
} }
.shopping-cart-box {
padding: 8px 0;
cursor: pointer;
display: flex;
align-items: center;
border-bottom: 1px #ccc dotted;
}
.shopping-cart-list {
padding: 10px 10px;
box-sizing: border-box;
max-height: 300px;
overflow-y: scroll;
color: #333;
}
.shopping-cart-detail {
color: #999;
font-size: 12px;
}
.shopping-price {
color: $theme_color;
font-size: 14px;
font-weight: bold;
}
</style> </style>

View File

@@ -1,117 +1,251 @@
<template> <template>
<div class="scroll-show"> <div class="scroll-show">
<div class="content clearfix"> <div class="content">
<cateNav class="cate" :hover="true" :showNavBar="false" <!-- 1. 左侧区域:商城 Logo -->
useClass="fixed-show"></cateNav> <div class="left-section">
<Search <router-link to="/" class="logo-link">
class="search-con" <img v-if="logoSrc" :src="logoSrc" alt="Logo" class="site-logo" />
:hover="true" <span v-else class="text-logo">LiliShop</span>
ref="search" </router-link>
:showLogo="false" </div>
:showTag="false"
useClass="fixed-show" <!-- 2. 中间搜索区域:与 SearchHeader 样式完全一致(蓝底色、圆角4px、搜索按钮) -->
></Search> <div class="search-main">
<div class="flex flex-a-c cart"> <div class="search-box">
<Icon <input
type="ios-cart-outline" v-model="keyword"
@click="goCartList" type="text"
class="cart-icon" class="search-input"
@mouseenter.native="getCartList" :placeholder="placeholderText"
/> @keyup.enter="handleSearch"
<i class="cart-badge">{{ cartNum < 100 ? cartNum : "99" }}</i> />
<button type="button" class="search-btn" @click="handleSearch">
<el-icon><Search /></el-icon>
<span>搜索</span>
</button>
</div>
</div>
<!-- 3. 右侧“我的购物车”胶囊入口:与 SearchHeader 样式完全一致 -->
<div class="cart-entry">
<router-link to="/cart" class="cart-btn" @mouseenter="getCartList">
<el-icon class="cart-icon"><ShoppingCart /></el-icon>
<span class="cart-text">{{ options.cartText || '我的购物车' }}</span>
<span class="cart-count">{{ Number(cartNum) > 99 ? '99+' : cartNum }}</span>
</router-link>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script>
import { cartCount } from "@/api/cart.js";
import storage from "@/plugins/storage.js";
export default {
data() {
return {
userInfo: {}, // 用户信息
};
},
computed: {
cartNum() {
// 购物车商品数量
return this.$store.state.cartNum;
},
},
methods: {
goCartList() {
// 跳转购物车页面
let routerUrl = this.$router.resolve({
path: "/cart",
});
window.open(routerUrl.href, "_blank");
},
getCartList() {
// 获取购物车列表
if (storage.getItem("userInfo")) {
cartCount().then((res) => {
this.$store.commit("SET_CARTNUM", res.result);
this.Cookies.setItem("cartNum", res.result);
});
}
},
},
mounted() {
if (storage.getItem("userInfo")) { <script setup>
this.userInfo = JSON.parse(storage.getItem("userInfo")); import { computed, onMounted, ref, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { cartCount } from '@/api/cart.js';
import storage from '@/plugins/storage.js';
import { ShoppingCart, Search } from '@element-plus/icons-vue';
import { useAppStore } from '@/stores/app';
const props = defineProps({
options: {
type: Object,
default: () => ({})
}
});
const router = useRouter();
const route = useRoute();
const appStore = useAppStore();
const keyword = ref(route.query.keyword || '');
const cartNum = computed(() => appStore.cartNum || 0);
watch(() => route.query.keyword, (v) => {
keyword.value = v || '';
});
const logoSrc = computed(() => {
return props.options?.logoUrl || appStore.logoImg || '';
});
const placeholderText = computed(() => {
const ph = props.options?.placeholder;
if (!ph || ph === '搜索品牌或商品' || ph === '请输入商品名称、品牌或型号') {
return '请输入商品名称';
}
return ph;
});
function handleSearch() {
const kw = (keyword.value || '').trim();
if (route.path === '/goodsList') {
const nextQuery = { ...route.query };
if (kw) {
nextQuery.keyword = kw;
} else {
delete nextQuery.keyword;
} }
}, router.push({ path: '/goodsList', query: nextQuery });
}; } else {
router.push({ path: '/goodsList', query: kw ? { keyword: kw } : {} });
}
}
function getCartList() {
if (storage.getItem('userInfo')) {
cartCount().then((res) => {
appStore.setCartNum(res.result);
storage.setItem('cartNum', res.result);
});
}
}
onMounted(() => {
getCartList();
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.scroll-show {
height: 60px;
width: 100%;
background: #ffffff;
}
.content { .content {
width: 1200px; width: 1200px;
height: 40px; max-width: 100%;
margin: 0 auto;
position: relative;
}
.cate {
float: left;
width: 200px !important;
}
.search-con {
float: left;
width: 800px;
overflow: hidden;
margin-top: -27px;
}
.cart{
height: 60px; height: 60px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
padding: 0 10px;
} }
.cart-icon {
width: 30px;
float: left;
font-size: 25px;
color: $theme_color; .left-section {
z-index: 1; display: flex;
position: relative; align-items: center;
&:hover { flex-shrink: 0;
cursor: pointer;
.logo-link {
display: flex;
align-items: center;
text-decoration: none;
height: 40px;
.site-logo {
max-height: 36px;
max-width: 160px;
object-fit: contain;
display: block;
}
.text-logo {
font-size: 22px;
font-weight: 800;
color: #409EFF;
letter-spacing: -0.5px;
line-height: 1;
}
} }
} }
.cart-badge {
position: absolute; .search-main {
font-style: normal; width: 560px;
right: 165px; flex-shrink: 0;
display: block;
background-color: $theme_color; .search-box {
color: #fff; display: flex;
font-size: 12px; border: 2px solid #409EFF;
width: 17px; border-radius: 4px;
height: 17px; overflow: hidden;
border-radius: 10px; height: 38px;
line-height: 17px; box-sizing: border-box;
text-align: center; background: #ffffff;
z-index: 5;
top: 10px; .search-input {
flex: 1;
min-width: 0;
border: none;
padding: 0 14px;
font-size: 13px;
color: #334155;
outline: none;
background: #ffffff;
&::placeholder {
color: #94a3b8;
}
}
.search-btn {
background: #409EFF;
color: #ffffff;
border: none;
padding: 0 22px;
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
flex-shrink: 0;
&:hover {
background: #66b1ff;
}
}
}
}
.cart-entry {
flex-shrink: 0;
.cart-btn {
height: 38px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 16px;
border: 1px solid #e2e8f0;
border-radius: 4px;
font-size: 13px;
color: #334155;
background: #ffffff;
text-decoration: none;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
transition: all 0.15s ease;
box-sizing: border-box;
.cart-icon {
font-size: 16px;
color: #409EFF;
}
.cart-text {
font-weight: 500;
white-space: nowrap;
}
.cart-count {
background: #409EFF;
color: #ffffff;
font-size: 11px;
font-weight: 700;
padding: 0 6px;
height: 18px;
line-height: 18px;
border-radius: 9px;
}
&:hover {
border-color: #409EFF;
color: #409EFF;
}
}
} }
</style> </style>

View File

@@ -1,31 +1,82 @@
<template> <template>
<div class="model-form"> <div class="model-form" :style="pageContainerStyle">
<div class="model-content"> <!-- Version 2: 新万米行模型渲染 -->
<template v-for="(element, index) in data.list"> <div v-if="isVersion2" class="v2-rows-container">
<model-form-item <row-wrapper
v-if="element && element.key" v-for="row in v2Rows"
:key="element.key" :key="row.id"
:element="element" :row="row"
:index="index" />
:data="data" </div>
></model-form-item>
</template> <!-- 旧版降级渲染 -->
<div v-else class="model-content">
<model-form-item
v-for="(element, index) in renderList"
:key="element.key"
:element="element"
:index="index"
:data="data"
></model-form-item>
</div> </div>
</div> </div>
</template> </template>
<script>
<script setup>
import { computed } from 'vue';
import ModelFormItem from './ModelFormItem.vue'; import ModelFormItem from './ModelFormItem.vue';
export default { import RowWrapper from './RowWrapper.vue';
name: 'modelForm',
components: { defineOptions({ name: 'modelForm' });
ModelFormItem
}, const props = defineProps(['data']);
props: ['data']
}; // 是否为 v2 行模型数据
const isVersion2 = computed(() => {
return (props.data?.version === 2 || props.data?.version === '2.0') && Array.isArray(props.data?.list);
});
const v2Rows = computed(() => {
return props.data?.list || [];
});
const pageContainerStyle = computed(() => {
if (isVersion2.value && props.data?.pageStyle?.backgroundColor) {
return {
backgroundColor: props.data.pageStyle.backgroundColor
};
}
return {};
});
const renderList = computed(() => {
const list = props.data?.list || [];
return list.filter(
(el) => el && el.key && !isBuiltinModule(el.type)
);
});
function isBuiltinModule(type) {
return type === 'topAdvert' || type === 'navBar';
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.model-form {
position: relative;
z-index: 0;
width: 100%;
}
.v2-rows-container {
width: 100%;
min-height: 400px;
}
.model-content { .model-content {
width: 100%; width: 100%;
min-height: 1200px; min-height: 1200px;
box-sizing: border-box;
overflow-x: visible;
} }
</style> </style>

View File

@@ -2,10 +2,9 @@
<div class="model-item" v-if="element && element.key"> <div class="model-item" v-if="element && element.key">
<!-- 轮播图模块,包括个人信息,快捷导航模块 --> <!-- 轮播图模块,包括个人信息,快捷导航模块 -->
<template v-if="element.type == 'carousel'"> <template v-if="element.type == 'carousel'">
<model-carousel <div class="carousel-wrap mb_20">
:data="element" <model-carousel :key="element.key" :data="element"></model-carousel>
class="mb_20 width_1200_auto" </div>
></model-carousel>
</template> </template>
<template v-if="element.type == 'carousel1'"> <template v-if="element.type == 'carousel1'">
<model-carousel1 :data="element" class="mb_20"></model-carousel1> <model-carousel1 :data="element" class="mb_20"></model-carousel1>
@@ -18,62 +17,66 @@
</template> </template>
<!-- 热门广告 --> <!-- 热门广告 -->
<template v-if="element.type == 'hotAdvert'"> <template v-if="element.type == 'hotAdvert'">
<div class="mb_20 width_1200_auto"> <div class="hot-advert mb_20 width_1200_auto">
<img <div class="hot-advert-banner">
style="display: block" <img
class="hover-pointer"
:src="element.options.list[0].img"
@click="linkTo(element.options.list[0].url)"
width="1200"
alt=""
/>
</div>
<ul class="advert-list width_1200_auto">
<template v-for="(item, index) in element.options.list">
<li
v-if="index !== 0"
@click="linkTo(item.url)"
class="hover-pointer" class="hover-pointer"
:key="index" :src="element.options.list[0].img"
> @click="linkTo(element.options.list[0].url)"
<img :src="item.img" width="230" height="190" alt="" /> alt=""
</li> />
</template> </div>
</ul> <ul class="advert-list">
<template v-for="(item, index) in element.options.list" :key="index">
<li
v-if="index !== 0"
@click="linkTo(item.url)"
class="hover-pointer"
>
<img :src="item.img" alt="" />
</li>
</template>
</ul>
</div>
</template> </template>
<!-- 限时秒杀 待完善 --> <!-- 限时秒杀 待完善 -->
<template v-if="element.type == 'seckill' && element.options.list.length"> <template v-if="element.type == 'seckill' && element.options?.list?.length">
<seckill :data="element" class="mb_20 width_1200_auto"></seckill> <seckill :data="element" class="mb_20 width_1200_auto"></seckill>
</template> </template>
<!-- 折扣广告 --> <!-- 折扣广告 -->
<template v-if="element.type == 'discountAdvert'"> <template v-if="element.type == 'discountAdvert'">
<div <div class="discountAdvert-wrap width_1200_auto mb_20">
class="discountAdvert" <div
:style="{ class="discountAdvert"
backgroundImage: :style="{
'url(' + require('@/assets/images/decorate.png') + ')', backgroundImage: 'url(' + decorateImg + ')',
}" }"
> >
<img <div>
@click="linkTo(item.url)" <img
class="hover-pointer" @click="linkTo(item.url)"
v-for="(item, index) in element.options.classification" class="hover-pointer"
:key="index" v-for="(item, index) in element.options.classification"
:src="item.img" :key="index"
width="190" :src="item.img"
height="210" width="190"
alt="" height="210"
/> alt=""
<img />
@click="linkTo(item.url)" </div>
class="hover-pointer" <div>
v-for="(item, index) in element.options.brandList" <img
:key="'discount' + index" @click="linkTo(item.url)"
:src="item.img" class="hover-pointer"
width="240" v-for="(item, index) in element.options.brandList"
height="105" :key="'discount' + index"
alt="" :src="item.img"
/> width="240"
height="105"
alt=""
/>
</div>
</div>
</div> </div>
</template> </template>
@@ -117,15 +120,12 @@
></first-page-advert> ></first-page-advert>
</template> </template>
<!-- 横幅广告 --> <!-- 横幅广告 -->
<template v-if="element.type == 'bannerAdvert'"> <template v-if="element.type == 'bannerAdvert' && element.options.img">
<div style="width: 100%; text-align: center"> <div
<img class="horizontal-advert width_1200_auto mb_20 hover-pointer"
width="1200" @click="linkTo(element.options.url)"
class="hover-pointer mb_20 bannerAd" >
@click="linkTo(element.options.url)" <img class="bannerAd" :src="element.options.img" alt="" />
:src="element.options.img"
alt=""
/>
</div> </div>
</template> </template>
<template v-if="element.type == 'notEnough'" <template v-if="element.type == 'notEnough'"
@@ -138,7 +138,8 @@
</div> </div>
</template> </template>
<script> <script setup>
import { ref } from "vue";
import ModelCarousel from "./modelList/Carousel.vue"; import ModelCarousel from "./modelList/Carousel.vue";
import ModelCarousel1 from "./modelList/Carousel1.vue"; import ModelCarousel1 from "./modelList/Carousel1.vue";
import ModelCarousel2 from "./modelList/Carousel2.vue"; import ModelCarousel2 from "./modelList/Carousel2.vue";
@@ -152,53 +153,79 @@ import goodsAndType from "./modelList/goodsAndType.vue";
import onlyGoodsModel from "./modelList/onlyGoodsModel.vue"; import onlyGoodsModel from "./modelList/onlyGoodsModel.vue";
import mixModel from "./modelList/mixModel.vue"; import mixModel from "./modelList/mixModel.vue";
import forYour from "./modelList/forYour.vue"; import forYour from "./modelList/forYour.vue";
import decorateImgUrl from "@/assets/images/decorate.png";
defineOptions({ name: "modelFormItem" });
export default { defineProps(["element", "select", "index", "data"]);
name: "modelFormItem",
props: ["element", "select", "index", "data"], const decorateImg = ref(decorateImgUrl);
components: { const showModal = ref(false); // 控制模态框显隐
ModelCarousel, const selected = ref({}); // 已选数据
ModelCarousel1,
ModelCarousel2,
Recommend,
NewGoodsSort,
FirstPageAdvert,
NotEnough,
Seckill,
oneRowThreeColumns,
goodsAndType,
onlyGoodsModel,
mixModel,
forYour
},
data() {
return {
showModal: false, // 控制模态框显隐
selected: {}, // 已选数据
};
},
};
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.model-item { .model-item {
position: relative; position: relative;
margin-bottom: 10px; margin-bottom: 10px;
width: 100%;
box-sizing: border-box;
} }
.bannerAd{ .carousel-wrap {
width: 1183px; width: 100%;
height: 166.6px; max-width: 1200px;
margin: 0 auto;
box-sizing: border-box;
overflow: hidden;
}
/** 横幅广告 */
.horizontal-advert {
width: 100%;
max-width: 1200px;
overflow: hidden;
border-radius: 10px; border-radius: 10px;
line-height: 0;
}
.bannerAd {
display: block;
width: 100%;
height: auto;
max-height: 166px;
object-fit: cover;
} }
/** 热门广告 */ /** 热门广告 */
.advert-list { .hot-advert {
background: $theme_color; width: 1200px;
height: 200px; max-width: 100%;
display: flex; overflow: hidden;
justify-content: space-around;
padding: 3px 10px; .hot-advert-banner {
> li { line-height: 0;
img { img {
display: block;
width: 100%;
height: auto;
}
}
}
.advert-list {
width: 100%;
margin: 0;
padding: 10px;
list-style: none;
box-sizing: border-box;
background: $theme_color;
height: 210px;
display: flex;
gap: 10px;
align-items: center;
> li {
flex: 1 1 0;
min-width: 0;
img {
display: block;
width: 100%;
height: 190px;
cursor: pointer; cursor: pointer;
border-radius: 10px; border-radius: 10px;
transition: all 150ms ease-in-out; transition: all 150ms ease-in-out;
@@ -211,25 +238,43 @@ export default {
} }
/** 折扣广告 */ /** 折扣广告 */
.discountAdvert-wrap {
width: 1200px;
position: relative;
overflow: visible;
}
.discountAdvert { .discountAdvert {
width: 1300px; width: 1300px;
height: 566px; height: 585px;
margin: 0 auto; margin-left: -100px;
margin-bottom: 20px; box-sizing: border-box;
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: 1300px 585px;
background-position: left top;
position: relative; position: relative;
left: -47px; > div {
padding-left: 295px; padding-left: 295px;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: flex-start; box-sizing: border-box;
img { &:nth-child(1) img {
margin-top: 10px; margin: 10px 10px 0 0;
margin-right: 10px; transition: all 150ms ease-in-out;
transition: all 150ms ease-in-out; &:hover {
&:hover { box-shadow: 0 5px 12px 0 rgba(0, 0, 0, 0.4);
box-shadow: 0 5px 12px 0 rgba(0, 0, 0, 0.4); transform: translateY(-2px);
transform: translateY(-2px); }
}
&:nth-child(2) {
margin-top: 10px;
img {
margin: 0 10px 0 0;
transition: all 150ms ease-in-out;
&:hover {
box-shadow: 0 5px 12px 0 rgba(0, 0, 0, 0.4);
transform: translateY(-2px);
}
}
} }
} }
} }

View File

@@ -0,0 +1,368 @@
<template>
<div class="row-wrapper" :style="rowWrapperStyle">
<div class="row-inner-flex" :style="rowInnerFlexStyle">
<div
v-for="comp in row.components"
:key="comp.id"
class="component-cell"
:style="getComponentCellStyle(comp.componentStyle, row.rowStyle, row.components ? row.components.length : 1)"
>
<component
:is="resolveComponent(comp.type)"
v-if="resolveComponent(comp.type)"
:component="comp"
:options="comp.options || {}"
:component-style="comp.componentStyle || {}"
:row-style="row.rowStyle || {}"
/>
<div v-else class="unknown-component-placeholder">
未知组件: {{ comp.type }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { resolveComponent } from './registry.js';
const props = defineProps({
row: {
type: Object,
required: true
}
});
const rowWrapperStyle = computed(() => {
const s = props.row.rowStyle || {};
const style = {};
// 宽度 (width)
if (s.width !== undefined && s.width !== '') {
const w = String(s.width).trim();
if (w && w !== '100%') {
const num = parseInt(w, 10);
if (!isNaN(num) && num >= 1920 && !w.endsWith('%')) {
style.width = '100%';
} else {
style.width = /^\d+$/.test(w) ? `${w}px` : w;
style.maxWidth = '100%';
}
} else {
style.width = '100%';
}
} else {
style.width = '100%';
}
// 高度 (height)
if (s.height !== undefined && s.height !== '') {
const h = String(s.height).trim();
if (h && Number(h) > 0) {
const hVal = /^\d+$/.test(h) ? `${h}px` : h;
style.height = hVal;
style.minHeight = hVal;
}
}
// 外边距 (margin)
const isNarrow = style.width !== '100%';
if (Array.isArray(s.margin)) {
style.marginTop = `${s.margin[0] || 0}px`;
style.marginRight = s.margin[1] ? `${s.margin[1]}px` : (isNarrow ? 'auto' : '0px');
style.marginBottom = `${s.margin[2] || 0}px`;
style.marginLeft = s.margin[3] ? `${s.margin[3]}px` : (isNarrow ? 'auto' : '0px');
} else if (isNarrow) {
style.marginLeft = 'auto';
style.marginRight = 'auto';
}
// 内边距 (padding)
if (Array.isArray(s.padding)) {
style.paddingTop = `${s.padding[0] || 0}px`;
style.paddingRight = `${s.padding[1] || 0}px`;
style.paddingBottom = `${s.padding[2] || 0}px`;
style.paddingLeft = `${s.padding[3] || 0}px`;
}
// 圆角 (radius)
if (Array.isArray(s.radius)) {
style.borderTopLeftRadius = `${s.radius[0] || 0}px`;
style.borderTopRightRadius = `${s.radius[1] || 0}px`;
style.borderBottomRightRadius = `${s.radius[2] || 0}px`;
style.borderBottomLeftRadius = `${s.radius[3] || 0}px`;
}
// 行背景由各组件配置统一管理
return style;
});
const rowInnerFlexStyle = computed(() => {
const s = props.row.rowStyle || {};
const styleObj = {
display: 'flex',
flexDirection: 'row',
flexWrap: 'nowrap',
alignItems: s.alignItems || 'stretch',
justifyContent: s.justifyContent || 'flex-start',
width: '100%',
height: '100%',
gap: `${s.gap || 0}px`
};
if (s.height !== undefined && s.height !== '') {
const h = String(s.height).trim();
if (h && Number(h) > 0) {
const hVal = /^\d+$/.test(h) ? `${h}px` : h;
styleObj.height = hVal;
styleObj.minHeight = hVal;
}
}
return styleObj;
});
function getComponentCellStyle(style, rowStyle = {}, totalComps = 1) {
if (!style) return { flex: '1 1 0%' };
const s = {};
if (rowStyle?.height !== undefined && rowStyle?.height !== '') {
const rh = String(rowStyle.height).trim();
if (rh && Number(rh) > 0) {
const rhVal = /^\d+$/.test(rh) ? `${rh}px` : rh;
s.height = rhVal;
s.minHeight = rhVal;
}
}
const gap = Number(rowStyle?.gap) || 0;
const totalGap = (totalComps > 1 && gap > 0) ? gap * (totalComps - 1) : 0;
if (style.widthRatio && style.widthRatio !== 'auto') {
let w = style.widthRatio;
if (w === '16.67%' || w === '16.6%' || w === '16.7%') {
w = totalGap > 0 ? `calc((100% - ${totalGap}px) / 6)` : 'calc(100% / 6)';
} else if (w === '66.67%' || w === '66.6%' || w === '66.7%') {
w = totalGap > 0 ? `calc((100% - ${totalGap}px) * 2 / 3)` : 'calc(100% * 2 / 3)';
} else if (w === '33.33%' || w === '33.3%') {
w = totalGap > 0 ? `calc((100% - ${totalGap}px) / 3)` : 'calc(100% / 3)';
} else if (w === '50%') {
w = totalGap > 0 ? `calc((100% - ${totalGap}px) / 2)` : '50%';
}
s.flex = `0 0 ${w}`;
s.width = w;
s.maxWidth = w;
s.minWidth = '0';
} else {
s.flex = '1 1 0%';
s.minWidth = '0';
}
if (Array.isArray(style.padding)) {
s.paddingTop = `${style.padding[0] || 0}px`;
s.paddingRight = `${style.padding[1] || 0}px`;
s.paddingBottom = `${style.padding[2] || 0}px`;
s.paddingLeft = `${style.padding[3] || 0}px`;
}
if (Array.isArray(style.radius)) {
s.borderTopLeftRadius = `${style.radius[0] || 0}px`;
s.borderTopRightRadius = `${style.radius[1] || 0}px`;
s.borderBottomRightRadius = `${style.radius[2] || 0}px`;
s.borderBottomLeftRadius = `${style.radius[3] || 0}px`;
}
if (Array.isArray(style.margin)) {
s.marginTop = `${style.margin[0] || 0}px`;
s.marginRight = `${style.margin[1] || 0}px`;
s.marginBottom = `${style.margin[2] || 0}px`;
s.marginLeft = `${style.margin[3] || 0}px`;
}
if (style.backgroundColor && style.backgroundColor !== 'transparent') {
let bg = style.backgroundColor;
if (/^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*0\s*\)$/.test(bg.trim())) {
bg = bg.trim().replace(/,\s*0\s*\)$/, ', 1)');
}
s.backgroundColor = bg;
}
if (style.backgroundImage) {
s.backgroundImage = `url(${style.backgroundImage})`;
s.backgroundSize = 'cover';
s.backgroundRepeat = 'no-repeat';
s.backgroundPosition = 'center';
}
return s;
}
</script>
<style lang="scss" scoped>
.row-wrapper {
box-sizing: border-box;
display: flex;
flex-direction: column;
.row-inner-flex {
box-sizing: border-box;
flex: 1;
height: 100%;
min-height: 0;
.component-cell {
box-sizing: border-box;
min-width: 0;
display: flex;
flex-direction: column;
height: 100%;
> * {
flex: 1;
height: 100%;
width: 100%;
box-sizing: border-box;
}
}
}
}
.unknown-component-placeholder {
padding: 20px;
background: #fef2f2;
border: 1px dashed #f87171;
color: #dc2626;
text-align: center;
font-size: 13px;
border-radius: 6px;
}
</style>

View File

@@ -0,0 +1,202 @@
<template>
<div class="decor-article-content" :style="customStyle">
<!-- 文章顶部标题区 -->
<div class="article-header" v-if="options.showTitle !== false && options.title">
<h2 class="article-main-title">{{ options.title }}</h2>
<p class="article-sub-title" v-if="options.subtitle">{{ options.subtitle }}</p>
<div class="article-meta-bar" v-if="options.showMeta !== false">
<span class="meta-tag">{{ options.tag || '品牌专栏' }}</span>
<span class="meta-item author" v-if="options.author">作者:{{ options.author }}</span>
<span class="meta-item date" v-if="options.date">发布于 {{ options.date }}</span>
<span class="meta-item views">阅读 2.4k+</span>
</div>
</div>
<!-- 文章正文排版区 (Rich Text Article Body) -->
<div
class="article-body-typography"
:class="[`align-${options.textAlign || 'left'}`]"
:style="{ fontSize: `${options.fontSize || 15}px` }"
v-html="renderedContent"
></div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
component: {
type: Object,
default: () => ({})
},
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const defaultArticleHtml = `
<h3>关于我们 · 甄选美好生活</h3>
<p>我们始终坚信,一件好的产品不仅能改善日常生活的品质,更传递着对美好生活的执着追求。自创立以来,我们严选全球顶尖供应链,深耕每一道生产工艺与质检环节,力求将兼具美学价值与极致实用性的商品送达每一位用户手中。</p>
<blockquote>品质是我们的信仰,体验是我们的底线。从选材到交付,每一步都经得起时光检验。</blockquote>
<h4>四大核心服务保障</h4>
<ul>
<li><strong>100% 官方正品</strong>:源头品牌直采,全链路防伪溯源,假一赔十保障。</li>
<li><strong>极速顺丰冷链</strong>:全国多仓联动发货,核心城市支持当日达/次日达。</li>
<li><strong>7天无理由退换</strong>:专业客服团队 7×24 小时贴心守护,售后无忧。</li>
<li><strong>会员专享权益</strong>:尊享积分抵现、生日特权礼及专属品鉴沙龙。</li>
</ul>
<p>感谢一路相伴的每一位朋友,未来我们将持续探索更多优质生活场景,与您共同见证品质生活的美好绽放。</p>
`.trim();
const renderedContent = computed(() => {
return props.options?.content || defaultArticleHtml;
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || '#ffffff',
padding: Array.isArray(s.padding)
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
: '24px 28px',
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '12px'
};
});
</script>
<style lang="scss" scoped>
.decor-article-content {
width: 100%;
box-sizing: border-box;
.article-header {
padding-bottom: 16px;
margin-bottom: 18px;
border-bottom: 1px solid #f1f5f9;
.article-main-title {
font-size: 24px;
font-weight: 800;
color: #0f172a;
line-height: 1.35;
margin: 0 0 8px 0;
letter-spacing: -0.3px;
}
.article-sub-title {
font-size: 14px;
color: #64748b;
line-height: 1.5;
margin: 0 0 12px 0;
}
.article-meta-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
font-size: 12px;
color: #94a3b8;
.meta-tag {
font-size: 11px;
font-weight: 600;
background: #eff6ff;
color: #2563eb;
padding: 2px 8px;
border-radius: 4px;
}
.meta-item {
display: inline-flex;
align-items: center;
}
}
}
.article-body-typography {
color: #334155;
line-height: 1.8;
letter-spacing: 0.2px;
&.align-center {
text-align: center;
}
&.align-justify {
text-align: justify;
}
:deep(h2), :deep(h3), :deep(h4) {
color: #0f172a;
font-weight: 700;
line-height: 1.4;
margin-top: 20px;
margin-bottom: 10px;
}
:deep(h2) {
font-size: 20px;
border-left: 4px solid #2563eb;
padding-left: 10px;
}
:deep(h3) {
font-size: 18px;
color: #1e293b;
}
:deep(h4) {
font-size: 16px;
}
:deep(p) {
margin: 0 0 14px 0;
line-height: 1.8;
}
:deep(blockquote) {
margin: 16px 0;
padding: 12px 18px;
background: #f8fafc;
border-left: 4px solid #3b82f6;
border-radius: 0 8px 8px 0;
color: #475569;
font-style: italic;
line-height: 1.7;
}
:deep(ul), :deep(ol) {
margin: 12px 0 16px 20px;
padding: 0;
li {
margin-bottom: 6px;
line-height: 1.7;
}
}
:deep(img) {
max-width: 100%;
height: auto;
border-radius: 8px;
display: block;
margin: 16px auto;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06);
}
:deep(strong) {
color: #0f172a;
font-weight: 700;
}
}
}
</style>

View File

@@ -0,0 +1,142 @@
<template>
<div class="decor-brand-wall" :style="customStyle">
<div
class="brand-grid"
:style="{
gridTemplateColumns: `repeat(${actualColumns}, 1fr)`,
gap: `${options.gap ?? 12}px`
}"
>
<router-link
v-for="(item, idx) in brandList"
:key="idx"
:to="item.url || '/goods'"
class="brand-card"
>
<img
v-if="item.img"
:src="item.img"
:alt="item.title || 'brand'"
class="brand-img"
/>
<div v-else class="brand-placeholder">
<span>{{ item.title || item.name || '品牌' }}</span>
</div>
</router-link>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const brandList = computed(() => {
if (Array.isArray(props.options.items) && props.options.items.length > 0) {
return props.options.items;
}
return [
{ title: '数码潮玩 · 爆款立减500', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_1.png', url: '/goodsList?category=1' },
{ title: '美妆奢宠 · 每满300减50', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_2.png', url: '/goodsList?category=2' },
{ title: '运动潮品 · 爆款低至5折', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_3.png', url: '/goodsList?category=3' },
{ title: '品质生活 · 限时第2件半价', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_4.png', url: '/goodsList?category=4' }
];
});
const actualColumns = computed(() => {
const baseCols = Number(props.options?.columns) || 4;
const ratio = props.componentStyle?.widthRatio || '100%';
if (ratio === '33.33%' || ratio === '33.3%') {
return Math.min(baseCols, 2);
}
if (ratio === '50%') {
return Math.min(baseCols, 2);
}
if (ratio === '66.67%' || ratio === '66.6%' || ratio === '66.7%') {
return Math.min(baseCols, 3);
}
return baseCols;
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || 'transparent',
padding: Array.isArray(s.padding)
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
: '10px 0'
};
});
</script>
<style lang="scss" scoped>
.decor-brand-wall {
width: 100%;
box-sizing: border-box;
.brand-grid {
max-width: 1600px;
margin: 0 auto;
display: grid;
width: 100%;
.brand-card {
background: #ffffff;
border: 1px solid #f1f5f9;
border-radius: 8px;
overflow: hidden;
aspect-ratio: 2 / 1;
width: 100%;
height: auto;
min-height: 80px;
max-height: 180px;
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
transition: all 0.25s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
border-color: #e2e8f0;
.brand-img {
transform: scale(1.03);
}
}
.brand-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.25s ease;
}
.brand-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #f8fafc;
color: #64748b;
font-size: 14px;
font-weight: 500;
}
}
}
}
</style>

View File

@@ -0,0 +1,170 @@
<template>
<div
class="decor-carousel"
:class="[
isMini ? 'is-mini' : (isCompact ? 'is-compact' : '')
]"
:style="customStyle"
>
<el-carousel
height="100%"
:autoplay="options.autoPlay !== false"
:interval="Number(options.interval) || 4000"
:arrow="options.showArrow !== false ? 'hover' : 'never'"
:indicator-position="options.showIndicator !== false ? '' : 'none'"
class="custom-carousel"
>
<el-carousel-item v-for="(slide, idx) in slidesList" :key="idx">
<router-link :to="slide.url || '/'" class="slide-link">
<img
:src="slide.img"
:alt="slide.title || 'banner'"
class="slide-img"
/>
</router-link>
</el-carousel-item>
</el-carousel>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
},
rowStyle: {
type: Object,
default: () => ({})
}
});
const isMini = computed(() => props.componentStyle?.widthRatio === '16.67%');
const isCompact = computed(() => props.componentStyle?.widthRatio === '33.33%');
const slidesList = computed(() => {
if (Array.isArray(props.options.list) && props.options.list.length > 0) {
return props.options.list;
}
if (Array.isArray(props.options.banners) && props.options.banners.length > 0) {
return props.options.banners.map(b => ({
title: b.title,
img: b.image || b.img || b.url,
url: b.url || b.link
}));
}
return [
{ title: '潮流数码', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/carousel/banner_1.png', url: '/goodsList?category=1' },
{ title: '奢宠美妆', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/carousel/banner_2.png', url: '/goodsList?category=2' },
{ title: '运动潮流', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/carousel/banner_3.png', url: '/goodsList?category=3' },
{ title: '居家生活', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/carousel/banner_4.png', url: '/goodsList?category=4' }
];
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
const fit = props.options.fitMode || 'cover';
const hasRowHeight = Boolean(props.rowStyle?.height && Number(props.rowStyle.height) > 0);
const styleObj = {
borderRadius: Array.isArray(s.radius) && s.radius.some(r => r > 0)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '8px',
backgroundColor: (s.backgroundColor && s.backgroundColor !== 'transparent') ? s.backgroundColor : '#ffffff',
'--carousel-fit': fit,
height: '100%',
minHeight: hasRowHeight ? '0' : '420px'
};
if (props.options.aspectRatio === 'custom' && props.options.height) {
styleObj.height = `${props.options.height}px`;
styleObj.minHeight = `${props.options.height}px`;
}
return styleObj;
});
</script>
<style lang="scss" scoped>
.decor-carousel {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
box-sizing: border-box;
display: flex;
flex-direction: column;
.custom-carousel {
width: 100%;
height: 100%;
flex: 1;
:deep(.el-carousel__container) {
height: 100% !important;
position: relative;
}
:deep(.el-carousel__indicators--horizontal) {
bottom: 12px;
z-index: 10;
background: rgba(0, 0, 0, 0.18);
border-radius: 10px;
padding: 3px 8px;
backdrop-filter: blur(2px);
.el-carousel__indicator {
padding: 3px;
.el-carousel__button {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.6);
opacity: 1;
transition: all 0.25s;
}
&.is-active .el-carousel__button {
width: 16px;
border-radius: 3px;
background-color: #409EFF;
}
}
}
.slide-link {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background: #ffffff;
.slide-img {
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
object-fit: var(--carousel-fit, cover);
display: block;
}
}
}
&.is-mini {
:deep(.el-carousel__indicators--horizontal) {
bottom: 8px;
}
}
}
</style>

View File

@@ -0,0 +1,543 @@
<template>
<div
class="decor-category-mega-menu"
:class="{ 'is-hovering': panelVisible && activeChildren.length }"
:style="customStyle"
@mouseleave="handleMouseLeave"
>
<!-- 顶部标题:分类 / 商品分类 -->
<div class="menu-header">
<span class="header-title">{{ options.title || '商品分类' }}</span>
</div>
<!-- 垂直一级类目列表 -->
<div class="category-list">
<div
v-for="(cat, idx) in displayCategories"
:key="cat.id || idx"
class="cat-item"
:class="{ 'is-active': activeIndex === idx && panelVisible }"
:style="{
'--text-color': options.textColor || '#334155',
'--active-color': options.activeColor || '#409EFF',
'--font-size': `${options.fontSize || 13}px`
}"
@mouseenter="handleMouseEnter(idx, cat)"
@click="goGoodsList(cat.id)"
>
<span class="cat-name">{{ cat.name }}</span>
<el-icon class="arrow-icon"><ArrowRight /></el-icon>
</div>
</div>
<!-- 右侧悬浮超级大面板 (Mega Menu) -->
<transition name="fade-flyout">
<div
v-if="panelVisible && activeChildren.length"
class="mega-flyout-panel"
@mouseenter="handlePanelEnter"
@mouseleave="handleMouseLeave"
>
<!-- 顶部快捷推荐标签(展示前8个二级分类) -->
<div class="flyout-top-tags" v-if="activeChildren && activeChildren.length">
<span
v-for="(sub, sIdx) in activeChildren.slice(0, 8)"
:key="sub.id || sIdx"
class="top-tag-pill"
@click.stop="goGoodsList(sub.id, sub.parentId)"
>
{{ sub.name }}
<el-icon class="pill-arrow"><ArrowRight /></el-icon>
</span>
</div>
<!-- 详细分类二级与三级平铺网格 -->
<div class="flyout-content-scroll">
<div
v-for="(sub, sIdx) in activeChildren"
:key="sub.id || sIdx"
class="cat-row"
>
<!-- 二级分类标题 -->
<div
class="lvl2-title"
@click.stop="goGoodsList(sub.id, sub.parentId)"
>
<span class="lvl2-name">{{ sub.name }}</span>
<el-icon class="lvl2-arrow"><ArrowRight /></el-icon>
</div>
<!-- 三级分类细项 -->
<div class="lvl3-tags-wrap">
<span
v-for="(leaf, lIdx) in (sub.children || [])"
:key="leaf.id || lIdx"
class="lvl3-tag"
@click.stop="goGoodsList(leaf.id, sub.id, sub.parentId)"
>
{{ leaf.name }}
</span>
</div>
</div>
</div>
</div>
</transition>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ArrowRight } from '@element-plus/icons-vue';
import { getCategory } from '@/api/goods';
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
},
rowStyle: {
type: Object,
default: () => ({})
}
});
const router = useRouter();
// 默认真实类目备选数据(防止弱网下未加载出接口时空白)
const fallbackCategories = [
{
id: '1348576427264204941',
name: '电脑办公',
children: [
{ id: '1-1', name: '电脑整机', parentId: '1348576427264204941', children: [{ id: '1-1-1', name: '笔记本' }, { id: '1-1-2', name: '游戏本' }, { id: '1-1-3', name: '台式机' }, { id: '1-1-4', name: '平板电脑' }] },
{ id: '1-2', name: '电脑配件', parentId: '1348576427264204941', children: [{ id: '1-2-1', name: '显示器' }, { id: '1-2-2', name: '显卡' }, { id: '1-2-3', name: '机械键盘' }, { id: '1-2-4', name: '鼠标' }] },
{ id: '1-3', name: '手机通讯', parentId: '1348576427264204941', children: [{ id: '1-3-1', name: '5G智能手机' }, { id: '1-3-2', name: '拍照手机' }, { id: '1-3-3', name: '蓝牙耳机' }] }
]
},
{
id: '1348576427268399553',
name: '家用电器',
children: [
{ id: '2-1', name: '大家电', parentId: '1348576427268399553', children: [{ id: '2-1-1', name: '平板电视' }, { id: '2-1-2', name: '风冷冰箱' }, { id: '2-1-3', name: '滚筒洗衣机' }, { id: '2-1-4', name: '变频空调' }] },
{ id: '2-2', name: '生活电器', parentId: '1348576427268399553', children: [{ id: '2-2-1', name: '空气净化器' }, { id: '2-2-2', name: '扫地机器人' }, { id: '2-2-3', name: '吸尘器' }] },
{ id: '2-3', name: '厨房小电', parentId: '1348576427268399553', children: [{ id: '2-3-1', name: '电饭煲' }, { id: '2-3-2', name: '复古咖啡机' }, { id: '2-3-3', name: '破壁料理机' }] }
]
},
{
id: '1348576427260010496',
name: '服装鞋包',
children: [
{ id: '3-1', name: '女装流行', parentId: '1348576427260010496', children: [{ id: '3-1-1', name: '连衣裙' }, { id: '3-1-2', name: '羊绒大衣' }, { id: '3-1-3', name: '早秋衬衫' }, { id: '3-1-4', name: '休闲裤' }] },
{ id: '3-2', name: '男装精选', parentId: '1348576427260010496', children: [{ id: '3-2-1', name: '商务夹克' }, { id: '3-2-2', name: '休闲T恤' }, { id: '3-2-3', name: '免烫衬衫' }, { id: '3-2-4', name: '牛仔裤' }] }
]
},
{
id: '1348576427268399363',
name: '食品生鲜',
children: [
{ id: '4-1', name: '生鲜水果', parentId: '1348576427268399363', children: [{ id: '4-1-1', name: '进口车厘子' }, { id: '4-1-2', name: '红富士苹果' }, { id: '4-1-3', name: '佳沛奇异果' }] },
{ id: '4-2', name: '休闲零食', parentId: '1348576427268399363', children: [{ id: '4-2-1', name: '每日坚果' }, { id: '4-2-2', name: '烘焙饼干' }, { id: '4-2-3', name: '巧克力糖果' }] }
]
},
{
id: '1348576427268399172',
name: '精品箱包',
children: [
{ id: '5-1', name: '潮流女包', parentId: '1348576427268399172', children: [{ id: '5-1-1', name: '真皮单肩包' }, { id: '5-1-2', name: '托特大包' }, { id: '5-1-3', name: '斜挎小方包' }] },
{ id: '5-2', name: '商务男包', parentId: '1348576427268399172', children: [{ id: '5-2-1', name: '双肩电脑包' }, { id: '5-2-2', name: '公文手提包' }] }
]
},
{
id: '1348576427268399318',
name: '美妆个护',
children: [
{ id: '6-1', name: '面部护肤', parentId: '1348576427268399318', children: [{ id: '6-1-1', name: '修护精华' }, { id: '6-1-2', name: '补水面膜' }, { id: '6-1-3', name: '爽肤水' }, { id: '6-1-4', name: '眼霜' }] },
{ id: '6-2', name: '彩妆香水', parentId: '1348576427268399318', children: [{ id: '6-2-1', name: '大牌口红' }, { id: '6-2-2', name: '持久气垫' }, { id: '6-2-3', name: '沙龙香氛' }] }
]
},
{
id: '1348576427268399274',
name: '运动户外',
children: [
{ id: '7-1', name: '运动鞋服', parentId: '1348576427268399274', children: [{ id: '7-1-1', name: '缓震跑鞋' }, { id: '7-1-2', name: '专业篮球鞋' }, { id: '7-1-3', name: '运动速干衣' }] },
{ id: '7-2', name: '户外装备', parentId: '1348576427268399274', children: [{ id: '7-2-1', name: '露营帐篷' }, { id: '7-2-2', name: '登山包' }, { id: '7-2-3', name: '运动手表' }] }
]
},
{
id: '1348576427268399217',
name: '家居家装',
children: [
{ id: '8-1', name: '家纺布艺', parentId: '1348576427268399217', children: [{ id: '8-1-1', name: '四件套' }, { id: '8-1-2', name: '乳胶枕' }, { id: '8-1-3', name: '羽绒被' }] },
{ id: '8-2', name: '现代家具', parentId: '1348576427268399217', children: [{ id: '8-2-1', name: '人体工学椅' }, { id: '8-2-2', name: '升降电脑桌' }, { id: '8-2-3', name: '真皮沙发' }] }
]
}
];
const systemCategories = ref([]);
const activeIndex = ref(-1);
const activeCategory = ref(null);
const panelVisible = ref(false);
const isCustomSource = computed(() => {
// 1. 如果明确指定为系统分类,始终按系统商品分类处理
if (props.options?.source === 'system') return false;
// 2. 如果配置为 custom,但分类条目依然是初始未编辑的 4 个占位条目,依然展示真实系统分类
if (props.options?.source === 'custom') {
if (Array.isArray(props.options?.categories) && props.options.categories.length === 4) {
const names = props.options.categories.map(c => c.name || c.title || '');
const isPlaceholder = names.some(n => n.includes('精选')) &&
names.some(n => n.includes('新品')) &&
names.some(n => n.includes('企业')) &&
names.some(n => n.includes('领券'));
if (isPlaceholder) return false;
}
return true;
}
// 3. 其它默认情况均默认展示系统商品分类
return false;
});
const displayCategories = computed(() => {
if (isCustomSource.value && Array.isArray(props.options?.categories) && props.options.categories.length) {
return props.options.categories.map(c => ({
...c,
name: c.name || c.title || ''
}));
}
if (systemCategories.value && systemCategories.value.length) {
return systemCategories.value;
}
return fallbackCategories;
});
const activeChildren = computed(() => {
if (!activeCategory.value) return [];
if (Array.isArray(activeCategory.value.children) && activeCategory.value.children.length) {
return activeCategory.value.children;
}
if (Array.isArray(activeCategory.value.subCategories) && activeCategory.value.subCategories.length) {
return activeCategory.value.subCategories.map((s, idx) => ({
id: s.id || `sub_${idx}`,
name: s.name,
parentId: activeCategory.value.id,
children: (s.items || []).map((it, iIdx) => ({
id: `leaf_${idx}_${iIdx}`,
name: typeof it === 'string' ? it : it.name,
parentId: s.id
}))
}));
}
return [];
});
function handleMouseEnter(idx, cat) {
activeIndex.value = idx;
activeCategory.value = cat;
if (cat.children && cat.children.length) {
panelVisible.value = true;
} else if (cat.subCategories && cat.subCategories.length) {
panelVisible.value = true;
} else {
panelVisible.value = false;
}
}
function handlePanelEnter() {
panelVisible.value = true;
}
function handleMouseLeave() {
panelVisible.value = false;
activeIndex.value = -1;
activeCategory.value = null;
}
function goGoodsList(id, secondId, firstId) {
const arr = [firstId, secondId, id];
if (!arr[1]) arr.splice(0, 2);
if (!arr[0]) arr.shift();
router.push({
path: '/goodsList',
query: { categoryId: arr.filter(Boolean).join(',') }
});
}
function loadCategories() {
// 1. 优先读取 localStorage 缓存快速渲染
try {
const cached = localStorage.getItem('category');
const exp = localStorage.getItem('category_expiration_time');
if (cached && exp && new Date() <= Number(exp)) {
const parsed = JSON.parse(cached);
if (Array.isArray(parsed) && parsed.length) {
systemCategories.value = parsed;
}
}
} catch (e) {}
// 2. 调接口拉取最新分类
getCategory(0).then(res => {
if (res.success && Array.isArray(res.result) && res.result.length) {
systemCategories.value = res.result;
const expTime = new Date().setHours(new Date().getHours() + 1);
localStorage.setItem('category_expiration_time', expTime);
localStorage.setItem('category', JSON.stringify(res.result));
}
}).catch(err => {
console.warn('获取分类失败', err);
});
}
onMounted(() => {
loadCategories();
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
const hasRowHeight = Boolean(props.rowStyle?.height && Number(props.rowStyle.height) > 0);
return {
backgroundColor: (s.backgroundColor && s.backgroundColor !== 'transparent') ? s.backgroundColor : '#ffffff',
borderRadius: Array.isArray(s.radius) && s.radius.some(r => r > 0)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '8px',
height: '100%',
minHeight: hasRowHeight ? '0' : '420px'
};
});
</script>
<style lang="scss" scoped>
.decor-category-mega-menu {
width: 100%;
height: 100%;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
display: flex;
flex-direction: column;
position: relative;
overflow: visible;
&.is-hovering {
z-index: 1050;
}
.menu-header {
height: 42px;
padding: 0 16px;
display: flex;
align-items: center;
border-bottom: 1px solid #f1f5f9;
flex-shrink: 0;
.header-title {
font-size: 14px;
font-weight: 700;
color: #1e293b;
letter-spacing: 0.5px;
}
}
.category-list {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-evenly;
padding: 4px 0 8px;
box-sizing: border-box;
min-height: 0;
overflow-y: auto;
.cat-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 16px;
cursor: pointer;
text-decoration: none;
transition: all 0.15s ease;
.cat-name {
font-size: var(--font-size, 13px);
color: var(--text-color, #334155);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.arrow-icon {
font-size: 11px;
color: #94a3b8;
transition: transform 0.15s ease, color 0.15s ease;
flex-shrink: 0;
}
&:hover,
&.is-active {
background: #ecf5ff;
.cat-name {
color: var(--active-color, #409EFF);
font-weight: 600;
}
.arrow-icon {
color: var(--active-color, #409EFF);
transform: translateX(2px);
}
}
}
}
.mega-flyout-panel {
position: absolute;
top: 0;
left: 100%;
width: 780px;
min-height: 100%;
max-height: 520px;
background: #ffffff;
z-index: 1100;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.04);
border: 1px solid #e2e8f0;
border-left: none;
border-radius: 0 8px 8px 0;
padding: 16px 20px;
box-sizing: border-box;
display: flex;
flex-direction: column;
.flyout-top-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding-bottom: 12px;
border-bottom: 1px solid #f1f5f9;
margin-bottom: 12px;
flex-shrink: 0;
.top-tag-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: #f8fafc;
border-radius: 14px;
font-size: 12px;
font-weight: 600;
color: #334155;
cursor: pointer;
transition: all 0.15s;
.pill-arrow {
font-size: 10px;
color: #94a3b8;
}
&:hover {
background: #ecf5ff;
color: #409EFF;
.pill-arrow {
color: #409EFF;
}
}
}
}
.flyout-content-scroll {
flex: 1;
overflow-y: auto;
padding-right: 6px;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.cat-row {
display: flex;
align-items: flex-start;
padding: 10px 0;
border-bottom: 1px dashed #f1f5f9;
&:last-child {
border-bottom: none;
}
.lvl2-title {
width: 96px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 3px;
cursor: pointer;
color: #1e293b;
font-weight: 600;
font-size: 12px;
padding-top: 1px;
.lvl2-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lvl2-arrow {
font-size: 10px;
color: #94a3b8;
}
&:hover {
color: #409EFF;
.lvl2-arrow {
color: #409EFF;
}
}
}
.lvl3-tags-wrap {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 6px 14px;
.lvl3-tag {
font-size: 12px;
color: #64748b;
cursor: pointer;
transition: color 0.15s;
line-height: 1.6;
&:hover {
color: #409EFF;
}
}
}
}
}
}
}
.fade-flyout-enter-active,
.fade-flyout-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.fade-flyout-enter-from,
.fade-flyout-leave-to {
opacity: 0;
transform: translateX(-4px);
}
</style>

View File

@@ -0,0 +1,199 @@
<template>
<div class="decor-category-nav" :style="customStyle">
<div class="nav-inner">
<!-- 1. 全部分类入口 (当开启显示全部分类时) -->
<div
v-if="options.showAllCategory"
class="all-category-btn"
:style="{ backgroundColor: options.activeColor || '#e11d48' }"
>
<img
v-if="options.allCategoryIcon"
:src="options.allCategoryIcon"
class="all-icon-img"
alt="icon"
/>
<el-icon v-else class="all-icon-svg"><Menu /></el-icon>
<span class="all-text">{{ options.allCategoryText || '全部分类' }}</span>
<el-icon class="all-arrow"><ArrowDown /></el-icon>
</div>
<!-- 2. 分类导航项水平列表 -->
<ul class="nav-items-ul">
<li
v-for="(item, idx) in options.navItems"
:key="idx"
class="nav-li"
>
<router-link
:to="item.url || '/'"
class="nav-link"
:style="{
'--text-color': options.textColor || '#334155',
'--active-color': options.activeColor || '#e11d48'
}"
active-class="is-active"
>
<img v-if="item.img" :src="item.img" class="cat-icon" alt="cat-icon" />
<span class="cat-title">{{ item.title }}</span>
<span
class="badge"
v-if="item.badge"
:style="{ backgroundColor: options.activeColor || '#e11d48' }"
>
{{ item.badge }}
</span>
</router-link>
</li>
</ul>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { Menu, ArrowDown } from '@element-plus/icons-vue';
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || '#ffffff',
height: `${s.height || 42}px`
};
});
</script>
<style lang="scss" scoped>
.decor-category-nav {
width: 100%;
border-bottom: 1px solid #f1f5f9;
display: flex;
align-items: center;
.nav-inner {
max-width: 1600px;
width: 100%;
margin: 0 auto;
padding: 0 16px;
height: 100%;
display: flex;
align-items: center;
box-sizing: border-box;
// 全部分类按钮
.all-category-btn {
display: inline-flex;
align-items: center;
gap: 8px;
height: 100%;
padding: 0 18px;
color: #ffffff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
flex-shrink: 0;
margin-right: 20px;
user-select: none;
transition: opacity 0.2s;
&:hover {
opacity: 0.92;
}
.all-icon-img {
width: 18px;
height: 18px;
object-fit: contain;
}
.all-icon-svg {
font-size: 16px;
}
.all-text {
letter-spacing: 0.5px;
}
.all-arrow {
font-size: 12px;
margin-left: 2px;
}
}
.nav-items-ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
align-items: center;
gap: 28px;
height: 100%;
overflow-x: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
.nav-li {
height: 100%;
display: flex;
align-items: center;
.nav-link {
position: relative;
text-decoration: none;
font-size: 14px;
font-weight: 500;
color: var(--text-color, #334155);
display: inline-flex;
align-items: center;
gap: 6px;
height: 100%;
white-space: nowrap;
transition: color 0.2s;
.cat-icon {
width: 22px;
height: 22px;
object-fit: contain;
border-radius: 4px;
transition: transform 0.2s ease;
}
&:hover, &.is-active {
color: var(--active-color, #e11d48) !important;
.cat-icon {
transform: scale(1.12);
}
}
.badge {
position: absolute;
top: 4px;
right: -12px;
color: #ffffff;
font-size: 9px;
line-height: 1;
padding: 2px 4px;
border-radius: 8px;
font-weight: 600;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,572 @@
<template>
<div class="decor-channel-floor" :style="customStyle">
<!-- 1. 标题区 -->
<div class="channel-floor-header" v-if="options.showTitle !== false && options.title">
<div class="header-main-title">
<span class="title-pill"></span>
<h3 class="title-text">{{ options.title }}</h3>
<span class="subtitle-text" v-if="options.subtitle">{{ options.subtitle }}</span>
</div>
<router-link :to="options.heroHref || '/goods'" class="header-more">
探索更多场景 &gt;
</router-link>
</div>
<!-- 2. 场景频道组合布局:左侧主视觉+活动入口 + 右侧商品网格 -->
<div class="channel-floor-body" :class="{ 'no-hero': !showHero }">
<!-- 左侧主视觉卡片 (Hero Card) -->
<div
class="channel-hero-card"
:class="[`style-${options.heroStyle || 'gradient'}`]"
v-if="showHero"
>
<div class="hero-top-info">
<div class="hero-badge">SCENE CHANNEL</div>
<h3 class="hero-title">{{ options.heroTitle || '品质生活馆' }}</h3>
<p class="hero-sub">{{ options.heroSubtitle || '精选场景好物,一站购齐' }}</p>
</div>
<div class="hero-image-wrap">
<img :src="heroImg" alt="channel hero" class="hero-img" />
<router-link :to="options.heroHref || '/goods'" class="hero-cta-btn">
<span>{{ options.heroButtonText || '进入频道' }}</span>
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
<path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/>
</svg>
</router-link>
</div>
<!-- 专属活动入口 (Promos) -->
<div class="hero-promos-wrap" v-if="promosList.length">
<router-link
v-for="(p, pIdx) in promosList"
:key="pIdx"
:to="p.url || '/goods'"
class="hero-promo-item"
>
<span class="promo-tag" v-if="p.tag">{{ p.tag }}</span>
<div class="promo-text-box">
<span class="promo-title">{{ p.title }}</span>
<span class="promo-desc">{{ p.desc }}</span>
</div>
<span class="promo-arrow">&gt;</span>
</router-link>
</div>
</div>
<!-- 右侧商品网格 (Scene Goods Grid) -->
<div
class="channel-goods-grid"
:class="`cols-${rightColumns}`"
:style="{ gridTemplateColumns: `repeat(${rightColumns}, 1fr)` }"
>
<div
v-for="(g, idx) in displayGoods"
:key="g.id || idx"
class="scene-goods-card"
>
<router-link :to="`/goods/${g.id || 1}`" class="card-thumb-wrap">
<img :src="g.thumbnail || defaultThumb" :alt="g.goodsName" class="goods-img" />
<span class="scene-tag">场景精选</span>
</router-link>
<div class="card-info">
<h4 class="goods-name">
<router-link :to="`/goods/${g.id || 1}`">{{ g.goodsName }}</router-link>
</h4>
<div class="price-action-row">
<div class="price-box">
<span class="sym">¥</span>
<span class="val">{{ g.price }}</span>
<span class="orig" v-if="g.originalPrice">¥{{ g.originalPrice }}</span>
</div>
<button
type="button"
class="cart-btn"
title="立即加购"
@click="addToCart(g)"
>
+
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
const router = useRouter();
function handleNavigate(url) {
const target = url ? String(url).trim() : '/goods';
if (!target) return;
if (/^https?:\/\//i.test(target)) {
window.open(target, '_blank');
} else {
router.push(target);
}
}
const props = defineProps({
component: {
type: Object,
default: () => ({})
},
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const defaultThumb = 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png';
const heroImg = computed(() => {
return props.options?.heroImage || 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/banner-floor/floor_banner_1.png';
});
const showHero = computed(() => {
const ratio = props.componentStyle?.widthRatio || '100%';
if (ratio === '16.67%' || ratio === '16.6%' || ratio === '33.33%' || ratio === '33.3%') {
return false;
}
return true;
});
const rightColumns = computed(() => {
const ratio = props.componentStyle?.widthRatio || '100%';
if (ratio === '16.67%' || ratio === '16.6%') return 1;
if (ratio === '33.33%' || ratio === '33.3%') return 2;
if (ratio === '50%') return showHero.value ? 2 : 3;
if (ratio === '66.67%' || ratio === '66.6%') return showHero.value ? 3 : 4;
return 3;
});
const promosList = computed(() => {
if (Array.isArray(props.options?.promos) && props.options.promos.length > 0) {
return props.options.promos;
}
return [
{ title: '大牌补贴', desc: '领券最高减100', tag: '限时', url: '/goods' },
{ title: '新品驾到', desc: '官方正品首发', tag: '新品', url: '/goods' }
];
});
const defaultGoods = [
{ id: '1', goodsName: '旗舰 5G 智能手机 12GB+256GB 超光影影像系统', price: '3999.00', originalPrice: '4599.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png' },
{ id: '2', goodsName: '无线头戴式深度主动降噪蓝牙耳机 空间音频Hi-Res', price: '1099.00', originalPrice: '1499.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_2.png' },
{ id: '3', goodsName: '14英寸 全面屏轻薄商务笔记本电脑 酷睿i7 高刷防眩光', price: '5699.00', originalPrice: '6299.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_3.png' },
{ id: '4', goodsName: '运动健康专业监测智能手表 心率血氧长续航50米防水', price: '899.00', originalPrice: '1199.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_4.png' },
{ id: '5', goodsName: '意式半自动家用复古咖啡机 20Bar高压恒温萃取蒸汽奶泡', price: '899.00', originalPrice: '1280.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_6.png' },
{ id: '6', goodsName: '奢宠酵母焕活修护精华露 50ml 紧致淡纹抗皱滋养', price: '680.00', originalPrice: '890.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_5.png' }
];
const displayGoods = computed(() => {
if (Array.isArray(props.options?.goods) && props.options.goods.length > 0) {
return props.options.goods;
}
const count = props.options?.goodsCount || 6;
return defaultGoods.slice(0, count);
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || '#ffffff',
padding: Array.isArray(s.padding)
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
: '16px 20px',
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '12px'
};
});
function addToCart(item) {
ElMessage.success(`已将 "${item.goodsName?.slice(0, 10)}..." 加入购物车`);
}
</script>
<style lang="scss" scoped>
.decor-channel-floor {
width: 100%;
box-sizing: border-box;
.channel-floor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 12px;
margin-bottom: 14px;
border-bottom: 2px solid #f1f5f9;
.header-main-title {
display: flex;
align-items: center;
gap: 10px;
.title-pill {
width: 4px;
height: 18px;
background: linear-gradient(180deg, #6366f1, #4f46e5);
border-radius: 2px;
}
.title-text {
font-size: 20px;
font-weight: 700;
color: #1e293b;
margin: 0;
}
.subtitle-text {
font-size: 13px;
color: #94a3b8;
margin-left: 2px;
}
}
.header-more {
font-size: 13px;
color: #64748b;
text-decoration: none;
transition: color 0.2s;
&:hover {
color: #4f46e5;
}
}
}
.channel-floor-body {
display: flex;
gap: 16px;
align-items: stretch;
&.no-hero {
.channel-goods-grid {
width: 100%;
}
}
.channel-hero-card {
width: 320px;
flex-shrink: 0;
border-radius: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 20px 18px 16px;
box-sizing: border-box;
position: relative;
transition: all 0.3s ease;
&.style-gradient {
background: linear-gradient(145deg, #1e1b4b, #312e81 60%, #4338ca);
color: #ffffff;
}
&.style-plain {
background: #f8fafc;
border: 1px solid #e2e8f0;
color: #1e293b;
.hero-badge {
background: #e0e7ff;
color: #4338ca;
}
.hero-title {
color: #0f172a;
}
.hero-sub {
color: #64748b;
}
.hero-cta-btn {
background: #4f46e5;
color: #ffffff;
}
}
.hero-top-info {
margin-bottom: 12px;
.hero-badge {
display: inline-block;
font-size: 10px;
font-weight: 800;
letter-spacing: 1px;
padding: 2px 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(4px);
margin-bottom: 8px;
}
.hero-title {
font-size: 22px;
font-weight: 800;
margin: 0 0 4px 0;
line-height: 1.25;
}
.hero-sub {
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
margin: 0;
line-height: 1.4;
}
}
.hero-image-wrap {
position: relative;
width: 100%;
height: 220px;
border-radius: 8px;
overflow: hidden;
margin-bottom: 14px;
background: rgba(0, 0, 0, 0.1);
.hero-img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.4s ease;
}
&:hover .hero-img {
transform: scale(1.04);
}
.hero-cta-btn {
position: absolute;
bottom: 10px;
right: 10px;
background: #ffffff;
color: #312e81;
font-size: 12px;
font-weight: 700;
padding: 6px 14px;
border-radius: 20px;
display: flex;
align-items: center;
gap: 4px;
text-decoration: none;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.25);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #4f46e5;
color: #ffffff;
}
}
}
.hero-promos-wrap {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: auto;
.hero-promo-item {
display: flex;
align-items: center;
gap: 8px;
background: rgba(255, 255, 255, 0.12);
backdrop-filter: blur(6px);
padding: 8px 12px;
border-radius: 8px;
text-decoration: none;
color: inherit;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.22);
transform: translateX(3px);
}
.promo-tag {
font-size: 10px;
font-weight: 700;
padding: 2px 6px;
border-radius: 3px;
background: #ef4444;
color: #ffffff;
}
.promo-text-box {
display: flex;
flex-direction: column;
flex: 1;
.promo-title {
font-size: 12px;
font-weight: 700;
line-height: 1.2;
}
.promo-desc {
font-size: 11px;
opacity: 0.85;
}
}
.promo-arrow {
font-size: 13px;
opacity: 0.7;
}
}
}
}
.channel-goods-grid {
flex: 1;
display: grid;
gap: 12px;
.scene-goods-card {
background: #ffffff;
border: 1px solid #f1f5f9;
border-radius: 8px;
padding: 10px;
display: flex;
flex-direction: column;
transition: all 0.25s ease;
&:hover {
border-color: #cbd5e1;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.06);
transform: translateY(-2px);
}
.card-thumb-wrap {
display: block;
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
background: #f8fafc;
border-radius: 6px;
overflow: hidden;
margin-bottom: 8px;
.goods-img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
&:hover .goods-img {
transform: scale(1.05);
}
.scene-tag {
position: absolute;
top: 6px;
left: 6px;
background: #4f46e5;
color: #ffffff;
font-size: 9px;
font-weight: 700;
padding: 1px 5px;
border-radius: 3px;
}
}
.card-info {
display: flex;
flex-direction: column;
flex: 1;
.goods-name {
font-size: 14px;
font-weight: 500;
line-height: 1.4;
height: 40px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
margin: 0 0 6px 0;
a {
color: #1e293b;
text-decoration: none;
&:hover {
color: #4f46e5;
}
}
}
.price-action-row {
margin-top: auto;
display: flex;
align-items: center;
justify-content: space-between;
.price-box {
display: flex;
align-items: baseline;
gap: 1px;
.sym {
font-size: 11px;
font-weight: 700;
color: #e11d48;
}
.val {
font-size: 16px;
font-weight: 800;
color: #e11d48;
}
.orig {
font-size: 10px;
color: #94a3b8;
text-decoration: line-through;
margin-left: 3px;
}
}
.cart-btn {
width: 22px;
height: 22px;
border-radius: 50%;
border: none;
background: #f1f5f9;
color: #475569;
font-size: 14px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #4f46e5;
color: #ffffff;
}
}
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,277 @@
<template>
<div class="decor-channel-grid" :style="customStyle">
<div
class="grid-container"
:style="{
gridTemplateColumns: `repeat(${gridColumns}, 1fr)`,
gap: `${options.gap ?? 12}px`
}"
>
<router-link
v-for="(item, idx) in options.items"
:key="idx"
:to="item.url || '/goods'"
class="channel-card"
:class="[
`is-${layoutMode}`,
{ 'is-compact': isCompact }
]"
>
<!-- 图标/图片 -->
<div
class="card-thumb"
:style="{ borderRadius: `${options.imageRadius ?? 10}px` }"
>
<img
v-if="item.img"
:src="item.img"
:alt="item.title"
class="thumb-img"
/>
<div v-else class="thumb-placeholder">
<span>{{ (item.title || '频').charAt(0) }}</span>
</div>
</div>
<!-- 主标题与副标题 -->
<div class="card-info">
<div class="card-title">{{ item.title || '频道名称' }}</div>
<div class="card-sub">{{ item.subtitle || '副标题文案' }}</div>
</div>
</router-link>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
// 排版宽度比例 (整行: 100%, 三分之二: 66.67%, 一半: 50%, 三分之一: 33.33%, 六分之一: 16.67%)
const widthRatio = computed(() => {
return props.componentStyle?.widthRatio || '100%';
});
// 响应式列数:50%、33.33%、16.67% 为 2 列;100%、66.67% 为 4 列
const gridColumns = computed(() => {
const w = widthRatio.value;
if (w === '50%' || w === '33.33%' || w === '33.3%' || w === '16.67%' || w === '16.6%') {
return 2;
}
return 4;
});
// 卡片内部排布方式:
// 100% (整行)、50% (一半) -> 水平横向 (左图右文)
// 66.67% (三分之二)、33.33% (三分之一)、16.67% (六分之一) -> 垂直纵向 (上图下文居中)
const layoutMode = computed(() => {
const w = widthRatio.value;
if (w === '66.67%' || w === '66.6%' || w === '33.33%' || w === '33.3%' || w === '16.67%' || w === '16.6%') {
return 'vertical';
}
return 'horizontal';
});
// 六分之一紧凑模式
const isCompact = computed(() => {
const w = widthRatio.value;
return w === '16.67%' || w === '16.6%';
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || 'transparent',
padding: Array.isArray(s.padding)
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
: '10px 0'
};
});
</script>
<style lang="scss" scoped>
.decor-channel-grid {
width: 100%;
box-sizing: border-box;
.grid-container {
max-width: 1600px;
margin: 0 auto;
display: grid;
width: 100%;
box-sizing: border-box;
.channel-card {
background: #ffffff;
border: 1px solid #f1f5f9;
border-radius: 8px;
text-decoration: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
box-sizing: border-box;
transition: all 0.25s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.06);
border-color: #e2e8f0;
.card-thumb .thumb-img {
transform: scale(1.06);
}
.card-info .card-title {
color: #ea580c;
}
}
.card-thumb {
flex-shrink: 0;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: #f8fafc;
.thumb-img {
width: 100%;
height: 100%;
object-fit: contain;
transition: transform 0.2s ease;
}
.thumb-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #f97316, #ea580c);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 16px;
}
}
.card-info {
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
.card-title {
font-weight: 600;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color 0.2s;
}
.card-sub {
color: #94a3b8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
// 1. 水平横向排布 (整行 100%、一半 50%)
&.is-horizontal {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
padding: 12px 16px;
.card-thumb {
width: 44px;
height: 44px;
}
.card-info {
flex: 1;
gap: 3px;
text-align: left;
.card-title {
font-size: 14px;
line-height: 1.3;
}
.card-sub {
font-size: 12px;
line-height: 1.2;
}
}
}
// 2. 垂直纵向排布 (三分之二 66.67%、三分之一 33.33%、六分之一 16.67%)
&.is-vertical {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 8px;
padding: 14px 10px 12px 10px;
.card-thumb {
width: 44px;
height: 44px;
margin: 0 auto;
}
.card-info {
width: 100%;
gap: 2px;
text-align: center;
.card-title {
font-size: 13px;
line-height: 1.3;
}
.card-sub {
font-size: 12px;
line-height: 1.2;
}
}
}
// 3. 六分之一紧凑模式
&.is-compact {
padding: 10px 6px 8px 6px;
gap: 5px;
.card-thumb {
width: 36px;
height: 36px;
}
.card-info {
gap: 1px;
.card-title {
font-size: 12px;
}
.card-sub {
font-size: 11px;
transform: scale(0.95);
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,184 @@
<template>
<div class="decor-coupon-list" :class="`skin-${options.skin || 'red'}`" :style="customStyle">
<div
class="coupon-grid"
:style="{ gridTemplateColumns: `repeat(${actualColumns}, 1fr)` }"
>
<div
v-for="(cp, idx) in coupons"
:key="idx"
class="coupon-card"
>
<div class="card-left">
<div class="amount-wrap">
<span class="symbol">¥</span>
<span class="amount">{{ cp.price }}</span>
</div>
<span class="threshold">{{ cp.consumeThreshold ? `满 ${cp.consumeThreshold} 可用` : '无门槛' }}</span>
</div>
<div class="card-right">
<span class="scope">{{ cp.title }}</span>
<button type="button" class="claim-btn" @click="handleClaim(cp)">
立即领取
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { ElMessage } from 'element-plus';
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const defaultCoupons = [
{ id: '1', price: 20, consumeThreshold: 199, title: '全品类通用券', limitText: '全场通用 无门槛限制' },
{ id: '2', price: 50, consumeThreshold: 399, title: '数码家电专享券', limitText: '数码/手机/家电可用' },
{ id: '3', price: 100, consumeThreshold: 699, title: '大牌美妆特惠券', limitText: '美妆奢护专区可用' },
{ id: '4', price: 10, consumeThreshold: 99, title: '新客首单立减券', limitText: '首次下单专享福利' }
];
const actualColumns = computed(() => {
const baseCols = Number(props.options?.columns) || 4;
const ratio = props.componentStyle?.widthRatio || '100%';
if (ratio === '16.67%' || ratio === '16.6%' || ratio === '16.7%') {
return 1;
}
if (ratio === '33.33%' || ratio === '33.3%') {
return Math.min(baseCols, 2);
}
if (ratio === '50%') {
return Math.min(baseCols, 2);
}
if (ratio === '66.67%' || ratio === '66.6%' || ratio === '66.7%') {
return Math.min(baseCols, 3);
}
return baseCols;
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || 'transparent',
padding: Array.isArray(s.padding)
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
: '10px 0',
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '0'
};
});
const coupons = computed(() => {
if (Array.isArray(props.options?.coupons) && props.options.coupons.length > 0) {
return props.options.coupons;
}
const count = props.options?.columns || 4;
return defaultCoupons.slice(0, count);
});
function handleClaim(cp) {
ElMessage.success(`领取成功!¥${cp.price} 优惠券已放入您的卡券包`);
}
</script>
<style lang="scss" scoped>
.decor-coupon-list {
width: 100%;
.coupon-grid {
display: grid;
gap: 16px;
.coupon-card {
display: flex;
height: 90px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
transition: transform 0.2s;
&:hover {
transform: translateY(-2px);
}
.card-left {
width: 120px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #ffffff;
padding: 8px;
.amount-wrap {
display: flex;
align-items: baseline;
.symbol { font-size: 14px; font-weight: 700; }
.amount { font-size: 28px; font-weight: 800; }
}
.threshold {
font-size: 11px;
opacity: 0.9;
}
}
.card-right {
flex: 1;
background: #ffffff;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 12px 14px;
.scope {
font-size: 13px;
font-weight: 600;
color: #1e293b;
}
.claim-btn {
align-self: flex-start;
padding: 4px 12px;
border-radius: 14px;
border: none;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
&:hover { opacity: 0.85; }
}
}
}
}
&.skin-red {
.coupon-card .card-left { background: linear-gradient(135deg, #f43f5e, #e11d48); }
.claim-btn { background: #ffe4e6; color: #e11d48; }
}
&.skin-dark {
.coupon-card .card-left { background: linear-gradient(135deg, #334155, #0f172a); }
.claim-btn { background: #f1f5f9; color: #0f172a; }
}
&.skin-gradient {
.coupon-card .card-left { background: linear-gradient(135deg, #8b5cf6, #ec4899); }
.claim-btn { background: #fdf2f8; color: #db2777; }
}
}
</style>

View File

@@ -0,0 +1,165 @@
<template>
<div class="decor-footer-nav" :style="customStyle">
<div class="footer-inner">
<!-- 1. 服务保障条 -->
<div class="assurance-row" v-if="options.showAssurance && options.assurances?.length">
<div
v-for="(item, idx) in options.assurances"
:key="idx"
class="assurance-col"
>
<div class="code-badge">{{ item.code }}</div>
<div class="text-info">
<span class="main-t">{{ item.title }}</span>
<span class="sub-t">{{ item.desc }}</span>
</div>
</div>
</div>
<!-- 2. 服务指南与链接列 -->
<div class="guides-row" v-if="options.guideGroups?.length">
<div
v-for="(group, gIdx) in options.guideGroups"
:key="gIdx"
class="guide-col"
>
<h4 class="col-title">{{ group.title }}</h4>
<ul class="col-links">
<li v-for="(link, lIdx) in group.links" :key="lIdx">
<router-link :to="link.url || '/article'">{{ link.text }}</router-link>
</li>
</ul>
</div>
</div>
<!-- 3. 版权与备案 -->
<div class="copyright-row">
<p class="icp-text" v-if="options.icpText">{{ options.icpText }}</p>
<p class="copy-text">{{ options.copyright || 'Copyright © 2026 Lilishop. All Rights Reserved.' }}</p>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || '#f1f5f9'
};
});
</script>
<style lang="scss" scoped>
.decor-footer-nav {
width: 100%;
padding: 40px 0 30px;
border-top: 1px solid #e2e8f0;
.footer-inner {
max-width: 1600px;
margin: 0 auto;
padding: 0 20px;
.assurance-row {
display: flex;
justify-content: space-around;
align-items: center;
padding-bottom: 30px;
border-bottom: 1px solid #e2e8f0;
.assurance-col {
display: flex;
align-items: center;
gap: 12px;
.code-badge {
width: 44px;
height: 44px;
border-radius: 50%;
border: 2px solid #e11d48;
color: #e11d48;
font-size: 20px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.text-info {
display: flex;
flex-direction: column;
.main-t {
font-size: 16px;
font-weight: 600;
color: #1e293b;
}
.sub-t {
font-size: 13px;
color: #94a3b8;
}
}
}
}
.guides-row {
display: flex;
justify-content: space-around;
padding: 30px 0;
border-bottom: 1px solid #e2e8f0;
.guide-col {
.col-title {
font-size: 15px;
font-weight: 600;
color: #1e293b;
margin-bottom: 14px;
}
.col-links {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
li a {
color: #64748b;
font-size: 13px;
text-decoration: none;
transition: color 0.2s;
&:hover {
color: #e11d48;
}
}
}
}
}
.copyright-row {
text-align: center;
padding-top: 24px;
font-size: 12px;
color: #94a3b8;
line-height: 1.8;
}
}
}
</style>

View File

@@ -0,0 +1,60 @@
<template>
<div class="decor-free-layout" :style="customStyle">
<div
v-for="(el, idx) in options.elements"
:key="idx"
class="free-element"
:style="{
top: `${el.top || 0}px`,
left: `${el.left || 0}px`,
width: el.width ? `${el.width}px` : 'auto',
height: el.height ? `${el.height}px` : 'auto'
}"
>
<img v-if="el.type === 'image'" :src="el.src" alt="" style="width:100%;height:100%;object-fit:cover;" />
<span v-else-if="el.type === 'text'">{{ el.text }}</span>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
height: `${props.options?.height || 320}px`,
backgroundColor: s.backgroundColor || 'transparent',
padding: Array.isArray(s.padding)
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
: '0',
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '0'
};
});
</script>
<style lang="scss" scoped>
.decor-free-layout {
position: relative;
width: 100%;
overflow: hidden;
box-sizing: border-box;
.free-element {
position: absolute;
}
}
</style>

View File

@@ -0,0 +1,343 @@
<template>
<div class="decor-goods-grid" :style="customStyle">
<!-- 宫格顶部标题区 -->
<div class="grid-header" v-if="options.title">
<div class="grid-title-wrap">
<span class="grid-title-decor"></span>
<h3 class="grid-main-title">{{ options.title }}</h3>
<span class="grid-sub-title" v-if="options.subtitle">{{ options.subtitle }}</span>
</div>
<a href="javascript:;" class="grid-more" v-if="options.showMoreLink !== false" @click="handleNavigate(options.moreUrl || '/goods')">
{{ options.moreText || '查看全部' }} &gt;
</a>
</div>
<!-- 紧凑严谨的商品九宫格/矩阵网格 (Bordered Matrix Grid) -->
<div
class="matrix-grid-container"
:class="[`cols-${actualColumns}`, { 'with-grid-border': options.showBorder !== false }]"
:style="{ gridTemplateColumns: `repeat(${actualColumns}, 1fr)` }"
>
<div
v-for="(item, idx) in displayGoods"
:key="item.id || idx"
class="matrix-grid-cell"
>
<div class="cell-rank-tag" v-if="idx < 5">
<span class="rank-num">TOP {{ idx + 1 }}</span>
</div>
<a href="javascript:;" class="cell-thumb-box" @click="handleNavigate(item.url || `/goods/${item.id || 1}`)">
<img :src="item.thumbnail || defaultThumb" :alt="item.goodsName" class="cell-thumb" />
</a>
<div class="cell-body">
<h4 class="cell-name">
<a href="javascript:;" @click="handleNavigate(item.url || `/goods/${item.id || 1}`)">{{ item.goodsName }}</a>
</h4>
<div class="cell-meta">
<span class="badge-label">热卖好物</span>
</div>
<div class="cell-bottom-row">
<div class="price-box">
<span class="sym">¥</span>
<span class="val">{{ item.price }}</span>
<span class="del" v-if="item.originalPrice">¥{{ item.originalPrice }}</span>
</div>
<span class="action-add-pill" title="立即选购" @click="addToCart(item)">+</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
const props = defineProps({
component: {
type: Object,
default: () => ({})
},
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const defaultThumb = 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png';
const actualColumns = computed(() => {
const ratio = props.componentStyle?.widthRatio || '100%';
const base = Number(props.options?.columns) || 5;
if (ratio === '16.67%' || ratio === '16.6%') return 1;
if (ratio === '33.33%' || ratio === '33.3%') return 2;
if (ratio === '50%') return 3;
if (ratio === '66.67%' || ratio === '66.6%') return 4;
return base;
});
const defaultGridGoods = [
{ id: '1', goodsName: '旗舰 5G 智能手机 12GB+256GB 超光影影像系统', price: '3999.00', originalPrice: '4599.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png' },
{ id: '2', goodsName: '无线头戴式深度主动降噪蓝牙耳机 空间音频Hi-Res', price: '1099.00', originalPrice: '1499.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_2.png' },
{ id: '3', goodsName: '14英寸 全面屏轻薄商务笔记本电脑 酷睿i7 高刷防眩光', price: '5699.00', originalPrice: '6299.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_3.png' },
{ id: '4', goodsName: '运动健康专业监测智能手表 心率血氧长续航50米防水', price: '899.00', originalPrice: '1199.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_4.png' },
{ id: '5', goodsName: '奢宠酵母焕活修护精华露 50ml 紧致淡纹抗皱滋养', price: '680.00', originalPrice: '890.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_5.png' },
{ id: '6', goodsName: '意式半自动家用复古咖啡机 20Bar高压恒温萃取蒸汽奶泡', price: '899.00', originalPrice: '1280.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_6.png' },
{ id: '7', goodsName: '碳板专业竞速轻量缓震跑步鞋 透气防滑男女同款潮鞋', price: '459.00', originalPrice: '699.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_7.png' },
{ id: '8', goodsName: '极简静音无雾负离子香薰加湿器 大容量客厅卧室通用', price: '299.00', originalPrice: '399.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_8.png' },
{ id: '9', goodsName: '智能降噪AI翻译蓝牙墨镜 超轻防紫外线偏光太阳镜', price: '599.00', originalPrice: '799.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png' },
{ id: '10', goodsName: '15.6英寸 4K超高清便携式显示器 Type-C一线直连高色域', price: '1299.00', originalPrice: '1699.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_3.png' }
];
const displayGoods = computed(() => {
if (Array.isArray(props.options?.goods) && props.options.goods.length > 0) {
return props.options.goods;
}
const count = (actualColumns.value * 2) || 10;
return defaultGridGoods.slice(0, count);
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || '#ffffff',
padding: Array.isArray(s.padding)
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
: '18px 20px',
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '12px'
};
});
function addToCart(item) {
ElMessage.success(`已将 "${item.goodsName?.slice(0, 10)}..." 加入购物车`);
}
</script>
<style lang="scss" scoped>
.decor-goods-grid {
width: 100%;
box-sizing: border-box;
.grid-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 14px;
margin-bottom: 14px;
border-bottom: 1px solid #f1f5f9;
.grid-title-wrap {
display: flex;
align-items: center;
gap: 10px;
.grid-title-decor {
width: 4px;
height: 18px;
background: #f97316;
border-radius: 2px;
}
.grid-main-title {
font-size: 19px;
font-weight: 700;
color: #0f172a;
margin: 0;
}
.grid-sub-title {
font-size: 12px;
color: #64748b;
margin-left: 4px;
}
}
.grid-more {
font-size: 13px;
color: #64748b;
text-decoration: none;
transition: color 0.2s;
&:hover {
color: #f97316;
}
}
}
.matrix-grid-container {
display: grid;
background: #ffffff;
box-sizing: border-box;
&.with-grid-border {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
.matrix-grid-cell {
border-right: 1px solid #f1f5f9;
border-bottom: 1px solid #f1f5f9;
}
}
.matrix-grid-cell {
position: relative;
padding: 16px 14px 14px 14px;
display: flex;
flex-direction: column;
box-sizing: border-box;
background: #ffffff;
transition: all 0.2s ease;
&:hover {
background: #fafbfc;
z-index: 1;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.05);
.cell-thumb {
transform: scale(1.06);
}
.action-add-pill {
background: #f97316;
color: #ffffff;
}
}
.cell-rank-tag {
position: absolute;
top: 10px;
left: 10px;
z-index: 2;
.rank-num {
display: inline-block;
font-size: 10px;
font-weight: 800;
color: #f97316;
background: #fff7ed;
border: 1px solid #fed7aa;
padding: 1px 6px;
border-radius: 4px;
}
}
.cell-thumb-box {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
margin-bottom: 12px;
text-decoration: none;
.cell-thumb {
width: 85%;
height: 85%;
object-fit: contain;
transition: transform 0.3s ease;
}
}
.cell-body {
display: flex;
flex-direction: column;
flex: 1;
.cell-name {
font-size: 14px;
font-weight: 500;
line-height: 1.4;
height: 40px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
margin: 0 0 6px 0;
a {
color: #334155;
text-decoration: none;
&:hover {
color: #f97316;
}
}
}
.cell-meta {
display: flex;
margin-bottom: 8px;
.badge-label {
font-size: 10px;
color: #d97706;
background: #fef3c7;
padding: 1px 6px;
border-radius: 3px;
}
}
.cell-bottom-row {
margin-top: auto;
display: flex;
align-items: baseline;
justify-content: space-between;
.price-box {
display: flex;
align-items: baseline;
gap: 1px;
.sym {
font-size: 12px;
font-weight: 700;
color: #dc2626;
}
.val {
font-size: 18px;
font-weight: 800;
color: #dc2626;
}
.del {
font-size: 11px;
color: #94a3b8;
text-decoration: line-through;
margin-left: 4px;
}
}
.action-add-pill {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: #f1f5f9;
color: #64748b;
font-size: 15px;
font-weight: 600;
line-height: 1;
transition: all 0.2s;
cursor: pointer;
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,435 @@
<template>
<div class="decor-goods-list" :style="customStyle">
<!-- 标题栏 -->
<div class="floor-title-bar" v-if="options.showTitle !== false && options.title">
<div class="title-left">
<h3 class="main-title">{{ options.title }}</h3>
<span class="sub-title" v-if="options.subtitle">{{ options.subtitle }}</span>
</div>
<router-link
:to="options.moreUrl || (options.categoryId ? `/goodsList?categoryId=${options.categoryId}` : '/goodsList')"
class="more-link"
v-if="options.showMoreLink !== false"
>
{{ options.moreText || '查看更多' }} &gt;
</router-link>
</div>
<!-- 6 列商品列表网格 (整行展示 6 个商品) -->
<div
class="goods-flow-grid"
:class="`cols-${actualColumns}`"
:style="{ gridTemplateColumns: `repeat(${actualColumns}, 1fr)` }"
>
<div
v-for="(g, idx) in sampleGoods"
:key="g.id || idx"
class="goods-flow-card"
>
<router-link
:to="{ path: '/goodsDetail', query: { skuId: g.id, goodsId: g.goodsId || g.id } }"
class="card-thumb-wrap"
>
<img :src="g.thumbnail || defaultThumb" :alt="g.goodsName" class="thumb-img" />
<span class="self-tag" v-if="idx % 3 === 0">自营</span>
</router-link>
<div class="card-body">
<h4 class="goods-name" v-if="options.showTitle !== false">
<router-link :to="{ path: '/goodsDetail', query: { skuId: g.id, goodsId: g.goodsId || g.id } }">
{{ g.goodsName }}
</router-link>
</h4>
<div class="price-row">
<div class="price-box">
<span class="symbol">¥</span>
<span class="price-val" v-if="options.showPrice !== false">{{ g.price }}</span>
<span class="orig-price" v-if="options.showOriginalPrice && g.originalPrice">¥{{ g.originalPrice }}</span>
</div>
<button
type="button"
class="add-cart-circle"
v-if="options.showCartBtn !== false"
title="加入购物车"
@click="addToCart(g)"
>
+
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import { goodsList } from '@/api/goods';
const router = useRouter();
function handleNavigateGoods(g) {
if (g.url) {
const url = String(g.url).trim();
if (/^https?:\/\//i.test(url)) {
window.open(url, '_blank');
return;
}
router.push(url);
return;
}
router.push({
path: '/goodsDetail',
query: { skuId: g.id, goodsId: g.goodsId || g.id }
});
}
function handleNavigateMore() {
const url = props.options?.moreUrl || (props.options?.categoryId ? `/goodsList?categoryId=${props.options.categoryId}` : '/goodsList');
if (/^https?:\/\//i.test(url)) {
window.open(url, '_blank');
} else {
router.push(url);
}
}
const props = defineProps({
component: {
type: Object,
default: () => ({})
},
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const defaultThumb = 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png';
const defaultMockGoods = [
{ id: '1', goodsName: '旗舰 5G 智能手机 12GB+256GB 超光影影像系统', price: '3999.00', originalPrice: '4599.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png' },
{ id: '2', goodsName: '无线头戴式深度主动降噪蓝牙耳机 空间音频Hi-Res', price: '1099.00', originalPrice: '1499.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_2.png' },
{ id: '3', goodsName: '14英寸 全面屏轻薄商务笔记本电脑 酷睿i7 高刷防眩光', price: '5699.00', originalPrice: '6299.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_3.png' },
{ id: '4', goodsName: '运动健康专业监测智能手表 心率血氧长续航50米防水', price: '899.00', originalPrice: '1199.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_4.png' },
{ id: '5', goodsName: '奢宠酵母焕活修护精华露 50ml 紧致淡纹抗皱滋养', price: '680.00', originalPrice: '890.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_5.png' },
{ id: '6', goodsName: '意式半自动家用复古咖啡机 20Bar高压恒温萃取蒸汽奶泡', price: '899.00', originalPrice: '1280.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_6.png' },
{ id: '7', goodsName: '碳板专业竞速轻量缓震跑步鞋 透气防滑男女同款潮鞋', price: '459.00', originalPrice: '699.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_7.png' },
{ id: '8', goodsName: '极简静音无雾负离子香薰加湿器 大容量客厅卧室通用', price: '299.00', originalPrice: '399.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_8.png' },
{ id: '9', goodsName: '折叠屏旗舰手机 典藏陶瓷白 悬停双旋水滴铰链', price: '6999.00', originalPrice: '7999.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png' },
{ id: '10', goodsName: '真无线触控降噪蓝牙耳机 超低延迟游戏降噪', price: '599.00', originalPrice: '799.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_2.png' },
{ id: '11', goodsName: '国AA级减蓝光润眼长臂台灯 智能感光多档色温无极调光', price: '249.00', originalPrice: '359.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_4.png' },
{ id: '12', goodsName: '航空级铝镁合金静音万向轮拉杆登机箱 20英寸防刮耐磨', price: '499.00', originalPrice: '699.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_7.png' }
];
const categoryFetchedGoods = ref([]);
const isFetching = ref(false);
async function fetchGoodsByCategory() {
if (props.options.dataSource !== 'category') return;
const count = Number(props.options.goodsCount) || (props.component?.type === 'goodsCard' ? 1 : 6);
const params = {
pageNumber: 1,
pageSize: count
};
if (props.options.categoryId) {
const rawCat = String(props.options.categoryId);
const catParts = rawCat.split(',');
params.categoryId = catParts[catParts.length - 1];
}
// 排序参数映射
if (props.options.sortType === 'sales') {
params.sort = 'buyCount';
params.order = 'desc';
} else if (props.options.sortType === 'price_asc') {
params.sort = 'price';
params.order = 'asc';
} else if (props.options.sortType === 'price_desc') {
params.sort = 'price';
params.order = 'desc';
} else if (props.options.sortType === 'new') {
params.sort = 'releaseTime';
params.order = 'desc';
}
try {
isFetching.value = true;
const res = await goodsList(params);
const records = res?.data?.result?.records || res?.result?.records || res?.data?.records || [];
if (Array.isArray(records) && records.length > 0) {
categoryFetchedGoods.value = records.map(item => ({
id: item.id || item.goodsId || item.sn,
goodsId: item.goodsId || item.id,
goodsName: item.goodsName || item.name,
price: item.price,
originalPrice: item.originalPrice || item.price,
thumbnail: item.thumbnail || item.pic || defaultThumb
}));
}
} catch (err) {
console.warn('Fetch goods by category failed:', err);
} finally {
isFetching.value = false;
}
}
onMounted(() => {
fetchGoodsByCategory();
});
watch(
() => [props.options.dataSource, props.options.categoryId, props.options.goodsCount, props.options.sortType],
() => {
fetchGoodsByCategory();
}
);
const isGoodsCard = computed(() => {
return props.component?.type === 'goodsCard' || props.options?.type === 'goodsCard';
});
const actualColumns = computed(() => {
let baseCols = Number(props.options?.columns) || 6;
const ratio = props.componentStyle?.widthRatio || '100%';
if (ratio === '16.67%' || ratio === '16.6%' || ratio === '16.7%') {
return 1;
}
if (ratio === '33.33%' || ratio === '33.3%') {
return Math.min(baseCols, 2);
}
if (ratio === '50%') {
return Math.min(baseCols, 3);
}
if (ratio === '66.67%' || ratio === '66.6%' || ratio === '66.7%') {
return Math.min(baseCols, 4);
}
return baseCols; // 100% 默认 6 列
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || 'transparent',
padding: Array.isArray(s.padding)
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
: '14px 0',
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '0'
};
});
const sampleGoods = computed(() => {
if (props.options.dataSource === 'category' && categoryFetchedGoods.value.length > 0) {
const targetCount = Number(props.options.goodsCount) || (props.component?.type === 'goodsCard' ? 1 : 6);
return categoryFetchedGoods.value.slice(0, targetCount);
}
if (Array.isArray(props.options.goods) && props.options.goods.length > 0) {
return props.options.goods;
}
const count = props.options.goodsCount || (actualColumns.value * 2) || 12;
return defaultMockGoods.slice(0, count);
});
function addToCart(item) {
ElMessage.success(`已将 "${item.goodsName?.slice(0, 10)}..." 加入购物车`);
}
</script>
<style lang="scss" scoped>
.decor-goods-list {
width: 100%;
box-sizing: border-box;
.floor-title-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
padding: 0 4px;
.title-left {
display: flex;
align-items: baseline;
gap: 10px;
.main-title {
font-size: 20px;
font-weight: 700;
color: #1e293b;
margin: 0;
}
.sub-title {
font-size: 13px;
color: #94a3b8;
}
}
.more-link {
cursor: pointer;
font-size: 13px;
color: #64748b;
text-decoration: none;
transition: color 0.2s;
&:hover {
color: #e11d48;
}
}
}
.goods-flow-grid {
display: grid;
gap: 12px;
.goods-flow-card {
background: #ffffff;
border-radius: 8px;
border: 1px solid #f1f5f9;
padding: 10px;
display: flex;
flex-direction: column;
transition: all 0.25s ease;
&:hover {
transform: translateY(-3px);
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.08);
border-color: #e2e8f0;
.thumb-img {
transform: scale(1.05);
}
.add-cart-circle {
background: #e11d48;
color: #ffffff;
}
}
.card-thumb-wrap {
cursor: pointer;
display: block;
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
background: #f8fafc;
border-radius: 6px;
overflow: hidden;
margin-bottom: 8px;
.thumb-img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.self-tag {
position: absolute;
top: 6px;
left: 6px;
background: #e11d48;
color: #ffffff;
font-size: 9px;
font-weight: 700;
padding: 1px 5px;
border-radius: 3px;
}
}
.card-body {
display: flex;
flex-direction: column;
flex: 1;
.goods-name {
font-size: 14px;
font-weight: 500;
line-height: 1.4;
height: 40px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
margin: 0 0 4px 0;
a {
color: #1e293b;
text-decoration: none;
&:hover {
color: #e11d48;
}
}
}
.meta-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
color: #94a3b8;
margin-bottom: 6px;
}
.price-row {
margin-top: auto;
display: flex;
align-items: center;
justify-content: space-between;
.price-box {
display: flex;
align-items: baseline;
.symbol {
font-size: 11px;
font-weight: 700;
color: #e11d48;
}
.price-val {
font-size: 16px;
font-weight: 800;
color: #e11d48;
}
.orig-price {
font-size: 10px;
color: #94a3b8;
text-decoration: line-through;
margin-left: 3px;
}
}
.add-cart-circle {
width: 22px;
height: 22px;
border-radius: 50%;
border: none;
background: #f1f5f9;
color: #64748b;
font-size: 14px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,576 @@
<template>
<div class="decor-grid-floor" :style="customStyle">
<!-- 楼层大标题栏 (Floor Title Bar) -->
<div class="floor-title-header" v-if="options.showFloorTitle !== false">
<div class="floor-main-heading">
<span class="floor-badge">{{ options.floorNum || '1F' }}</span>
<h3 class="floor-title-text">{{ options.title || '潮流数码' }}</h3>
<span class="floor-subtitle-text" v-if="options.subtitle">{{ options.subtitle }}</span>
</div>
<div class="floor-header-right">
<!-- 子分类快捷标签 -->
<div class="sub-category-tags" v-if="subCategories.length">
<router-link
v-for="(tag, idx) in subCategories"
:key="idx"
to="/goods"
class="tag-item"
:class="{ active: activeTagIdx === idx }"
@click.prevent="activeTagIdx = idx"
>
{{ tag }}
</router-link>
</div>
<a href="javascript:;" class="more-link" @click="handleNavigate(options.moreUrl || '/goods')">
查看更多 &gt;
</a>
</div>
</div>
<!-- 楼层主体:左侧竖向营销海报 + 右侧商品网格 -->
<div class="floor-main-body" :class="{ 'no-poster': !showPoster }">
<!-- 左侧营销大促海报 -->
<div class="floor-left-poster" v-if="showPoster">
<div class="poster-link" @click="handleNavigate(options.bannerUrl || options.posterUrl || '/goods')">
<img :src="posterImg" alt="floor poster" class="poster-img" />
<div class="poster-overlay">
<div class="poster-top-badge">{{ options.floorNum || '1F' }} 甄选</div>
<h4 class="poster-headline">{{ options.posterTitle || '数码潮电 酷玩季' }}</h4>
<p class="poster-subline">{{ options.posterSub || '领券最高立减 600 元' }}</p>
<div class="poster-pill-tags" v-if="posterTags.length">
<span v-for="(pt, pIdx) in posterTags" :key="pIdx" class="pill-tag">{{ pt }}</span>
</div>
<div class="poster-action-btn">
<span>立即抢购 &gt;</span>
</div>
</div>
</div>
</div>
<!-- 右侧商品网格 (2行 x N列) -->
<div
class="floor-right-goods"
:class="`cols-${rightColumns}`"
:style="{ gridTemplateColumns: `repeat(${rightColumns}, 1fr)` }"
>
<div
v-for="(g, idx) in displayGoods"
:key="g.id || idx"
class="floor-goods-card"
>
<a href="javascript:;" class="card-thumb-wrap" @click="handleNavigate(g.url || `/goods/${g.id || 1}`)">
<img :src="g.thumbnail || defaultThumb" :alt="g.goodsName" class="goods-img" />
<span class="corner-badge" v-if="idx < 3">HOT</span>
</a>
<div class="card-detail">
<h4 class="goods-name-line">
<a href="javascript:;" @click="handleNavigate(g.url || `/goods/${g.id || 1}`)">{{ g.goodsName }}</a>
</h4>
<div class="goods-tag-row">
<span class="mini-tag">官方直营</span>
<span class="mini-tag light">免息</span>
</div>
<div class="price-action-row">
<div class="price-group">
<span class="currency">¥</span>
<span class="amount">{{ g.price }}</span>
<span class="orig-amount" v-if="g.originalPrice">¥{{ g.originalPrice }}</span>
</div>
<button
type="button"
class="buy-action-btn"
title="加入购物车"
@click="addToCart(g)"
>
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
<path d="M11 9h2V6h3V4h-3V1h-2v3H8v2h3v3zm-4 9c-1.1 0-1.99.9-1.99 2S5.9 22 7 22s2-.9 2-2-.9-2-2-2zm10 0c-1.1 0-1.99.9-1.99 2s.89 2 1.99 2 2-.9 2-2-.9-2-2-2zm-9.83-3.25l.03-.12.9-1.63h7.45c.75 0 1.41-.41 1.75-1.03l3.86-7.01L19.42 4h-.01l-1.1 2-2.76 5H8.53l-.13-.27L6.16 6l-.95-2-.94-2H1v2h2l3.6 7.59-1.35 2.45c-.16.28-.25.61-.25.96 0 1.1.9 2 2 2h12v-2H7.42c-.13 0-.25-.11-.25-.25z"/>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
const router = useRouter();
function handleNavigate(url) {
const target = url ? String(url).trim() : '/goods';
if (!target) return;
if (/^https?:\/\//i.test(target)) {
window.open(target, '_blank');
} else {
router.push(target);
}
}
function handleNavigateGoods(g) {
if (g.url) {
handleNavigate(g.url);
return;
}
router.push(`/goods/${g.id || 1}`);
}
const props = defineProps({
component: {
type: Object,
default: () => ({})
},
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const defaultThumb = 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png';
const activeTagIdx = ref(0);
const subCategories = computed(() => {
if (Array.isArray(props.options?.subCategories) && props.options.subCategories.length > 0) {
return props.options.subCategories;
}
return ['旗舰手机', '轻薄笔电', '降噪耳机', '智能穿戴', '数码配件'];
});
const posterTags = computed(() => {
if (Array.isArray(props.options?.posterTags) && props.options.posterTags.length > 0) {
return props.options.posterTags;
}
return ['爆款低至5折', '新品首发', '分期免息'];
});
const posterImg = computed(() => {
return props.options?.posterImg || 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/banner-floor/floor_banner_1.png';
});
const showPoster = computed(() => {
const ratio = props.componentStyle?.widthRatio || '100%';
if (ratio === '16.67%' || ratio === '16.6%' || ratio === '33.33%' || ratio === '33.3%') {
return false;
}
return props.options?.showPoster !== false;
});
const rightColumns = computed(() => {
const ratio = props.componentStyle?.widthRatio || '100%';
let baseCols = Number(props.options?.columns) || 4;
if (ratio === '16.67%' || ratio === '16.6%') return 1;
if (ratio === '33.33%' || ratio === '33.3%') return 2;
if (ratio === '50%') return showPoster.value ? 2 : 3;
if (ratio === '66.67%' || ratio === '66.6%') return showPoster.value ? 3 : 4;
return baseCols;
});
const defaultFloorGoods = [
{ id: '101', goodsName: '旗舰 5G 智能手机 12GB+256GB 超光影影像系统', price: '3999.00', originalPrice: '4599.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png' },
{ id: '102', goodsName: '无线头戴式深度主动降噪蓝牙耳机 空间音频Hi-Res', price: '1099.00', originalPrice: '1499.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_2.png' },
{ id: '103', goodsName: '14英寸 全面屏轻薄商务笔记本电脑 酷睿i7 高刷防眩光', price: '5699.00', originalPrice: '6299.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_3.png' },
{ id: '104', goodsName: '运动健康专业监测智能手表 心率血氧长续航50米防水', price: '899.00', originalPrice: '1199.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_4.png' },
{ id: '105', goodsName: '奢宠酵母焕活修护精华露 50ml 紧致淡纹抗皱滋养', price: '680.00', originalPrice: '890.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_5.png' },
{ id: '106', goodsName: '意式半自动家用复古咖啡机 20Bar高压恒温萃取蒸汽奶泡', price: '899.00', originalPrice: '1280.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_6.png' },
{ id: '107', goodsName: '碳板专业竞速轻量缓震跑步鞋 透气防滑男女同款潮鞋', price: '459.00', originalPrice: '699.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_7.png' },
{ id: '108', goodsName: '极简静音无雾负离子香薰加湿器 大容量客厅卧室通用', price: '299.00', originalPrice: '399.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_8.png' }
];
const displayGoods = computed(() => {
if (Array.isArray(props.options?.goods) && props.options.goods.length > 0) {
return props.options.goods;
}
const count = (rightColumns.value * 2) || 8;
return defaultFloorGoods.slice(0, count);
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || '#ffffff',
padding: Array.isArray(s.padding)
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
: '16px 20px',
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '12px'
};
});
function addToCart(item) {
ElMessage.success(`已将 "${item.goodsName?.slice(0, 10)}..." 加入购物车`);
}
</script>
<style lang="scss" scoped>
.decor-grid-floor {
width: 100%;
box-sizing: border-box;
.floor-title-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 14px;
border-bottom: 2px solid #f1f5f9;
margin-bottom: 16px;
.floor-main-heading {
display: flex;
align-items: center;
gap: 10px;
.floor-badge {
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #2563eb, #1d4ed8);
color: #ffffff;
font-weight: 800;
font-size: 13px;
padding: 3px 10px;
border-radius: 4px;
letter-spacing: 0.5px;
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.25);
}
.floor-title-text {
font-size: 20px;
font-weight: 700;
color: #1e293b;
letter-spacing: -0.2px;
margin: 0;
}
.floor-subtitle-text {
font-size: 12px;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 1px;
margin-left: 4px;
}
}
.floor-header-right {
display: flex;
align-items: center;
gap: 16px;
.sub-category-tags {
display: flex;
align-items: center;
gap: 8px;
.tag-item {
font-size: 13px;
color: #64748b;
padding: 4px 10px;
border-radius: 14px;
text-decoration: none;
transition: all 0.2s;
&:hover, &.active {
color: #2563eb;
background: #eff6ff;
font-weight: 600;
}
}
}
.more-link {
cursor: pointer;
font-size: 13px;
color: #64748b;
text-decoration: none;
display: flex;
align-items: center;
gap: 2px;
transition: color 0.2s;
&:hover {
color: #2563eb;
}
}
}
}
.floor-main-body {
display: flex;
gap: 16px;
align-items: stretch;
&.no-poster {
.floor-right-goods {
width: 100%;
}
}
.floor-left-poster {
width: 240px;
flex-shrink: 0;
border-radius: 10px;
overflow: hidden;
position: relative;
background: #1e293b;
.poster-link {
cursor: pointer;
display: block;
position: relative;
width: 100%;
height: 100%;
min-height: 520px;
text-decoration: none;
.poster-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
filter: brightness(0.85);
transition: transform 0.4s ease;
}
&:hover .poster-img {
transform: scale(1.03);
}
.poster-overlay {
position: absolute;
inset: 0;
padding: 24px 18px;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, rgba(15, 23, 42, 0.75) 0%, rgba(15, 23, 42, 0.2) 40%, rgba(15, 23, 42, 0.85) 100%);
color: #ffffff;
.poster-top-badge {
align-self: flex-start;
font-size: 11px;
font-weight: 700;
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(4px);
padding: 3px 8px;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
.poster-headline {
font-size: 22px;
font-weight: 800;
line-height: 1.3;
margin: 0 0 6px 0;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
}
.poster-subline {
font-size: 13px;
color: rgba(255, 255, 255, 0.85);
margin: 0 0 16px 0;
line-height: 1.4;
}
.poster-pill-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: auto;
.pill-tag {
font-size: 11px;
padding: 3px 8px;
background: rgba(255, 255, 255, 0.18);
border-radius: 10px;
backdrop-filter: blur(4px);
}
}
.poster-action-btn {
align-self: flex-start;
margin-top: auto;
background: #ffffff;
color: #0f172a;
font-size: 13px;
font-weight: 700;
padding: 8px 16px;
border-radius: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
transition: all 0.2s;
&:hover {
background: #2563eb;
color: #ffffff;
}
}
}
}
}
.floor-right-goods {
flex: 1;
display: grid;
gap: 12px;
.floor-goods-card {
background: #ffffff;
border: 1px solid #f1f5f9;
border-radius: 8px;
padding: 12px;
display: flex;
flex-direction: column;
transition: all 0.25s ease;
&:hover {
border-color: #cbd5e1;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.06);
transform: translateY(-2px);
}
.card-thumb-wrap {
display: block;
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
background: #f8fafc;
border-radius: 6px;
overflow: hidden;
margin-bottom: 10px;
.goods-img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
&:hover .goods-img {
transform: scale(1.05);
}
.corner-badge {
position: absolute;
top: 6px;
left: 6px;
background: linear-gradient(135deg, #ef4444, #dc2626);
color: #ffffff;
font-size: 10px;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
}
}
.card-detail {
display: flex;
flex-direction: column;
flex: 1;
.goods-name-line {
font-size: 14px;
font-weight: 500;
line-height: 1.4;
height: 40px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
margin: 0 0 6px 0;
a {
color: #1e293b;
text-decoration: none;
&:hover {
color: #2563eb;
}
}
}
.goods-tag-row {
display: flex;
gap: 4px;
margin-bottom: 8px;
.mini-tag {
font-size: 10px;
padding: 1px 5px;
border-radius: 3px;
background: #fee2e2;
color: #dc2626;
&.light {
background: #eff6ff;
color: #2563eb;
}
}
}
.price-action-row {
margin-top: auto;
display: flex;
align-items: center;
justify-content: space-between;
.price-group {
display: flex;
align-items: baseline;
gap: 2px;
.currency {
font-size: 12px;
font-weight: 700;
color: #e11d48;
}
.amount {
font-size: 17px;
font-weight: 800;
color: #e11d48;
}
.orig-amount {
font-size: 11px;
color: #94a3b8;
text-decoration: line-through;
margin-left: 4px;
}
}
.buy-action-btn {
width: 26px;
height: 26px;
border-radius: 50%;
border: none;
background: #f1f5f9;
color: #475569;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #2563eb;
color: #ffffff;
}
}
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,116 @@
<template>
<div class="decor-hot-zone" :style="customStyle">
<div class="hot-zone-wrap" v-if="displayImageUrl">
<img :src="displayImageUrl" alt="hotzone" class="base-image" />
<div
v-for="(zone, idx) in displayZones"
:key="idx"
class="zone-link"
:style="{
top: `${zone.top || 0}%`,
left: `${zone.left || 0}%`,
width: `${zone.width || 10}%`,
height: `${zone.height || 10}%`
}"
:title="zone.title"
@click="handleClickZone(zone)"
/>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const defaultZones = [
{ title: '大促主会场 · 领券中心', url: '/couponCenter', left: 3.5, top: 12, width: 28.5, height: 76 },
{ title: '数码潮电馆 · 爆款直降', url: '/goodsList?category=1', left: 35.5, top: 12, width: 28.5, height: 76 },
{ title: '美妆奢品馆 · 5折特惠', url: '/goodsList?category=2', left: 67.5, top: 12, width: 29, height: 76 }
];
const displayImageUrl = computed(() => {
return props.options?.imageUrl || 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/hot-zone/hotzone_default.png';
});
const displayZones = computed(() => {
if (Array.isArray(props.options?.zones) && props.options.zones.length > 0) {
return props.options.zones;
}
return defaultZones;
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || 'transparent',
padding: Array.isArray(s.padding)
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
: '10px 0',
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '8px',
overflow: 'hidden'
};
});
function handleClickZone(zone) {
const url = zone?.url ? String(zone.url).trim() : '';
if (!url) return;
if (/^https?:\/\//i.test(url)) {
window.open(url, '_blank');
} else {
router.push(url);
}
}
</script>
<style lang="scss" scoped>
.decor-hot-zone {
width: 100%;
box-sizing: border-box;
.hot-zone-wrap {
position: relative;
width: 100%;
max-width: 1600px;
margin: 0 auto;
border-radius: inherit;
overflow: hidden;
.base-image {
display: block;
width: 100%;
height: auto;
user-select: none;
pointer-events: none;
}
.zone-link {
position: absolute;
display: block;
cursor: pointer;
z-index: 2;
transition: background 0.2s, box-shadow 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.12);
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.4);
}
}
}
}
</style>

View File

@@ -0,0 +1,336 @@
<template>
<div
class="decor-notice-bar"
:class="[
isMini ? 'is-mini' : (isCompact ? 'is-compact' : '')
]"
:style="customStyle"
@mouseenter="stopTimer"
@mouseleave="startTimer"
>
<!-- 左侧快讯徽章 -->
<div class="notice-badge-box">
<img
v-if="options.badgeImg || options.icon"
:src="options.badgeImg || options.icon"
class="badge-custom-img"
alt="icon"
/>
<div v-else class="badge-icon">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 12h-2v-2h2v2zm0-4h-2V6h2v4z"/>
</svg>
</div>
<span class="badge-title">{{ options.title || '公告快讯' }}</span>
<span class="badge-divider"></span>
</div>
<!-- 中间动态滚动快讯区 (Vertical Carousel) -->
<div class="notice-content-viewport">
<div
class="notice-items-strip"
:style="{ transform: `translateY(-${currentIndex * 40}px)` }"
>
<div
v-for="(item, idx) in noticeList"
:key="idx"
class="notice-strip-item"
>
<span class="notice-item-tag" :class="getTagClass(item.tag)">
{{ item.tag || '最新' }}
</span>
<a
href="javascript:;"
class="notice-item-link"
@click="handleNavigate(item.url || item.href || '/article')"
>
{{ item.title || item.text }}
</a>
<span class="notice-item-time" v-if="item.time && !isMini && !isCompact">
{{ item.time }}
</span>
</div>
</div>
</div>
<!-- 右侧更多链接 -->
<div class="notice-more-action" v-if="options.showMore !== false && !isMini">
<a
href="javascript:;"
class="more-link"
@click="handleNavigate(options.moreUrl || '/article')"
>
{{ options.moreText || '更多' }} &gt;
</a>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const props = defineProps({
component: {
type: Object,
default: () => ({})
},
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
function handleNavigate(url) {
if (!url) return;
if (/^https?:\/\//i.test(url)) {
window.open(url, '_blank');
} else {
router.push(url);
}
}
const isMini = computed(() => {
const r = props.componentStyle?.widthRatio;
return r === '16.67%' || r === '16.6%' || r === '16.7%';
});
const isCompact = computed(() => {
const r = props.componentStyle?.widthRatio;
return r === '33.33%' || r === '33.3%';
});
const defaultItems = [
{ tag: '最新', title: '商城秋季大促重磅开启,全场满300立减50元!', time: '10-28', url: '/article' },
{ tag: '活动', title: '会员专享积分商城全新上线,精选好礼0元兑换', time: '10-27', url: '/article' },
{ tag: '通知', title: '关于商城系统例行优化维护及物流发货保障通知', time: '10-26', url: '/article' },
{ tag: '热议', title: '品质生活馆场景专区火热来袭,领券最高减100', time: '10-25', url: '/article' }
];
const noticeList = computed(() => {
const list = props.options?.items || props.options?.notices;
if (Array.isArray(list) && list.length > 0) {
return list;
}
return defaultItems;
});
const currentIndex = ref(0);
let timer = null;
function startTimer() {
if (timer) clearInterval(timer);
if (noticeList.value.length <= 1) return;
timer = setInterval(() => {
currentIndex.value = (currentIndex.value + 1) % noticeList.value.length;
}, 3500);
}
function stopTimer() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
onMounted(() => {
startTimer();
});
onUnmounted(() => {
stopTimer();
});
function getTagClass(tag) {
if (tag === '活动') return 'tag-promo';
if (tag === '通知') return 'tag-notice';
if (tag === '热议') return 'tag-hot';
return 'tag-new';
}
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || '#ffffff',
padding: Array.isArray(s.padding)
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
: '0 16px',
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '8px'
};
});
</script>
<style lang="scss" scoped>
.decor-notice-bar {
width: 100%;
height: 44px;
display: flex;
align-items: center;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.02);
gap: 12px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
overflow: hidden;
&.is-mini {
gap: 6px;
padding: 0 8px !important;
.notice-badge-box {
.badge-title, .badge-divider {
display: none;
}
}
}
&.is-compact {
gap: 8px;
padding: 0 10px !important;
.notice-badge-box {
.badge-divider {
display: none;
}
}
}
.notice-badge-box {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
.badge-icon {
color: #f97316;
display: flex;
align-items: center;
justify-content: center;
}
.badge-custom-img {
width: 18px;
height: 18px;
object-fit: contain;
flex-shrink: 0;
}
.badge-title {
font-size: 13px;
font-weight: 700;
color: #0f172a;
white-space: nowrap;
}
.badge-divider {
width: 1px;
height: 14px;
background: #cbd5e1;
margin-left: 4px;
}
}
.notice-content-viewport {
flex: 1;
min-width: 0;
height: 40px;
overflow: hidden;
position: relative;
.notice-items-strip {
transition: transform 0.5s cubic-bezier(0.25, 1, 0.5, 1);
display: flex;
flex-direction: column;
.notice-strip-item {
height: 40px;
display: flex;
align-items: center;
gap: 8px;
box-sizing: border-box;
.notice-item-tag {
font-size: 11px;
padding: 1px 6px;
border-radius: 4px;
font-weight: 600;
flex-shrink: 0;
&.tag-new {
background: #eff6ff;
color: #2563eb;
border: 1px solid #bfdbfe;
}
&.tag-promo {
background: #fef2f2;
color: #ef4444;
border: 1px solid #fecaca;
}
&.tag-notice {
background: #fffbeb;
color: #d97706;
border: 1px solid #fde68a;
}
&.tag-hot {
background: #fdf2f8;
color: #db2777;
border: 1px solid #fbcfe8;
}
}
.notice-item-link {
flex: 1;
min-width: 0;
font-size: 13px;
color: #334155;
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color 0.15s ease;
cursor: pointer;
&:hover {
color: #f97316;
}
}
.notice-item-time {
font-size: 11px;
color: #94a3b8;
flex-shrink: 0;
}
}
}
}
.notice-more-action {
flex-shrink: 0;
.more-link {
font-size: 12px;
color: #64748b;
text-decoration: none;
transition: color 0.15s ease;
cursor: pointer;
&:hover {
color: #f97316;
}
}
}
}
</style>

View File

@@ -0,0 +1,154 @@
<template>
<div class="decor-promo-group" :style="customStyle">
<div
class="promo-grid"
:style="{ gridTemplateColumns: `repeat(${actualColumns}, 1fr)` }"
>
<div
v-for="(card, idx) in cardsList"
:key="idx"
class="promo-card"
:style="card.bgImg ? { backgroundImage: `url(${card.bgImg})` } : {}"
@click="handleClickCard(card)"
>
<div class="card-inner">
<h3 class="card-title">{{ card.title }}</h3>
<p class="card-sub">{{ card.subtitle }}</p>
<span class="action-btn">立即查看 &gt;</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const defaultCards = [
{ title: '限时秒杀', subtitle: '抢券立减50元 · 爆款直降', bgImg: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/promo-group/promo_1.png', url: '/goodsList?seckill=1' },
{ title: '品牌特惠', subtitle: '大牌买一送一 · 领百元神券', bgImg: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/promo-group/promo_2.png', url: '/goodsList?brand=1' },
{ title: '新品首发', subtitle: '首单专享特惠 · 1元起尝鲜', bgImg: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/promo-group/promo_3.png', url: '/goodsList?new=1' }
];
const cardsList = computed(() => {
if (Array.isArray(props.options?.cards) && props.options.cards.length > 0) {
return props.options.cards;
}
return defaultCards;
});
const actualColumns = computed(() => {
const baseCols = Number(props.options?.columns) || 3;
const ratio = props.componentStyle?.widthRatio || '100%';
if (ratio === '16.67%' || ratio === '16.6%' || ratio === '16.7%') {
return 1;
}
if (ratio === '33.33%' || ratio === '33.3%') {
return 1;
}
if (ratio === '50%') {
return Math.min(baseCols, 2);
}
if (ratio === '66.67%' || ratio === '66.6%' || ratio === '66.7%') {
return Math.min(baseCols, 2);
}
return baseCols;
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || 'transparent',
padding: Array.isArray(s.padding)
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
: '10px 0'
};
});
function handleClickCard(card) {
const url = card?.url ? String(card.url).trim() : '';
if (!url) return;
if (/^https?:\/\//i.test(url)) {
window.open(url, '_blank');
} else {
router.push(url);
}
}
</script>
<style lang="scss" scoped>
.decor-promo-group {
width: 100%;
box-sizing: border-box;
.promo-grid {
display: grid;
gap: 16px;
.promo-card {
min-height: 160px;
padding: 24px;
background-color: #1e293b;
background-size: cover;
background-position: center;
border-radius: 12px;
text-decoration: none;
display: flex;
flex-direction: column;
justify-content: center;
color: #ffffff;
cursor: pointer;
transition: all 0.25s ease;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08);
&:hover {
transform: translateY(-3px);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.15);
}
.card-inner {
.card-title {
font-size: 22px;
font-weight: 800;
color: #ffffff;
margin: 0;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
}
.card-sub {
font-size: 13px;
color: rgba(255, 255, 255, 0.9);
margin: 6px 0 14px 0;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.action-btn {
display: inline-block;
padding: 4px 12px;
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 14px;
font-size: 12px;
font-weight: 600;
color: #ffffff;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,688 @@
<template>
<div class="decor-recommend-feed" :style="customStyle">
<!-- 1. 推荐流居中标题栏 -->
<div class="recommend-feed-header">
<div class="header-decor-line left"></div>
<div class="header-center-title">
<svg class="heart-icon" viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
<h3 class="main-title">{{ options.title || '猜你喜欢' }}</h3>
<span class="sub-title" v-if="options.subtitle">{{ options.subtitle }}</span>
</div>
<div class="header-decor-line right"></div>
</div>
<!-- 2. 推荐流布局:精选大卡+网格 (featured) 或 标准网格 (grid) -->
<!-- A. 精选大卡+网格 (featured 布局) -->
<div
v-if="(options.layoutStyle || 'featured') === 'featured'"
class="recommend-featured-layout"
>
<!-- 左侧精选特大卡片 (Featured Hero Card) -->
<div class="featured-big-card" v-if="featuredGoods">
<div class="big-card-badge">为你特别甄选</div>
<router-link :to="{ path: '/goodsDetail', query: { skuId: featuredGoods.id, goodsId: featuredGoods.goodsId || featuredGoods.id } }" class="big-card-thumb">
<img :src="featuredGoods.thumbnail || defaultThumb" :alt="featuredGoods.goodsName" />
</router-link>
<div class="big-card-info">
<h4 class="big-title">
<router-link :to="{ path: '/goodsDetail', query: { skuId: featuredGoods.id, goodsId: featuredGoods.goodsId || featuredGoods.id } }">
{{ featuredGoods.goodsName }}
</router-link>
</h4>
<div class="big-reason">官方甄选 · 猜你对这款高人气商品感兴趣</div>
<!-- 核心特色标签 -->
<div class="big-tags-row">
<span class="feat-tag highlight">🔥 镇店爆品</span>
<span class="feat-tag">🛡️ 官方正品</span>
<span class="feat-tag">⚡ 极速发货</span>
<span class="feat-tag">🏷️ 售后无忧</span>
</div>
<!-- 权威热度指标 -->
<div class="big-stats-bar">
<div class="stat-cell">
<span class="stat-val">100%</span>
<span class="stat-lbl">品质正品</span>
</div>
<div class="stat-line"></div>
<div class="stat-cell">
<span class="stat-val">极速达</span>
<span class="stat-lbl">顺丰速运</span>
</div>
<div class="stat-line"></div>
<div class="stat-cell">
<span class="stat-val">TOP 1</span>
<span class="stat-lbl">品类热榜</span>
</div>
</div>
<!-- 服务保障权益 -->
<div class="big-guarantees">
<div class="guar-item"><span class="guar-icon">✓</span> 7天无理由退换货</div>
<div class="guar-item"><span class="guar-icon">✓</span> 赠运费险 · 破损包赔</div>
</div>
<div class="big-price-row">
<div class="big-price">
<span class="sym">¥</span>
<span class="val">{{ featuredGoods.price }}</span>
<span class="del" v-if="featuredGoods.originalPrice">¥{{ featuredGoods.originalPrice }}</span>
</div>
<button type="button" class="big-buy-btn" @click="addToCart(featuredGoods)">立即入手 &gt;</button>
</div>
</div>
</div>
<!-- 右侧与后续标准卡片网格 -->
<div class="featured-sub-grid">
<div
v-for="(item, idx) in regularGoods"
:key="item.id || idx"
class="recommend-card"
>
<router-link :to="{ path: '/goodsDetail', query: { skuId: item.id, goodsId: item.goodsId || item.id } }" class="card-thumb">
<img :src="item.thumbnail || defaultThumb" :alt="item.goodsName" />
<span class="rec-tag">推荐</span>
</router-link>
<div class="card-body">
<h4 class="goods-title">
<router-link :to="{ path: '/goodsDetail', query: { skuId: item.id, goodsId: item.goodsId || item.id } }">{{ item.goodsName }}</router-link>
</h4>
<div class="recommend-meta">
<span class="reason-tag">同品类热度榜</span>
</div>
<div class="price-row">
<div class="price-box">
<span class="sym">¥</span>
<span class="val">{{ item.price }}</span>
</div>
<button type="button" class="cart-pill-btn" @click="addToCart(item)">+</button>
</div>
</div>
</div>
</div>
</div>
<!-- B. 标准网格流 (grid 布局) -->
<div
v-else
class="recommend-standard-grid"
:class="`cols-${actualCols}`"
:style="{ gridTemplateColumns: `repeat(${actualCols}, 1fr)` }"
>
<div
v-for="(item, idx) in displayGoods"
:key="item.id || idx"
class="recommend-card"
>
<router-link :to="{ path: '/goodsDetail', query: { skuId: item.id, goodsId: item.goodsId || item.id } }" class="card-thumb">
<img :src="item.thumbnail || defaultThumb" :alt="item.goodsName" />
<span class="rec-tag">为你推荐</span>
</router-link>
<div class="card-body">
<h4 class="goods-title">
<router-link :to="{ path: '/goodsDetail', query: { skuId: item.id, goodsId: item.goodsId || item.id } }">{{ item.goodsName }}</router-link>
</h4>
<div class="recommend-meta">
<span class="reason-tag">甄选推荐</span>
</div>
<div class="price-row">
<div class="price-box">
<span class="sym">¥</span>
<span class="val">{{ item.price }}</span>
</div>
<button type="button" class="cart-pill-btn" @click="addToCart(item)">+</button>
</div>
</div>
</div>
</div>
<!-- 底部换一批 / 探索更多推荐 -->
<div class="recommend-footer-action">
<button type="button" class="refresh-rec-btn" @click="handleRefresh">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
<path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/>
</svg>
<span>换一批推荐好物</span>
</button>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { ElMessage } from 'element-plus';
const props = defineProps({
component: {
type: Object,
default: () => ({})
},
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const defaultThumb = 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png';
const defaultPool = [
{ id: '1', goodsName: '旗舰 5G 智能手机 12GB+256GB 超光影影像系统', price: '3999.00', originalPrice: '4599.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png' },
{ id: '2', goodsName: '无线头戴式深度主动降噪蓝牙耳机 空间音频Hi-Res', price: '1099.00', originalPrice: '1499.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_2.png' },
{ id: '3', goodsName: '14英寸 全面屏轻薄商务笔记本电脑 酷睿i7 高刷防眩光', price: '5699.00', originalPrice: '6299.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_3.png' },
{ id: '4', goodsName: '运动健康专业监测智能手表 心率血氧长续航50米防水', price: '899.00', originalPrice: '1199.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_4.png' },
{ id: '5', goodsName: '奢宠酵母焕活修护精华露 50ml 紧致淡纹抗皱滋养', price: '680.00', originalPrice: '890.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_5.png' },
{ id: '6', goodsName: '意式半自动家用复古咖啡机 20Bar高压恒温萃取蒸汽奶泡', price: '899.00', originalPrice: '1280.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_6.png' },
{ id: '7', goodsName: '碳板专业竞速轻量缓震跑步鞋 透气防滑男女同款潮鞋', price: '459.00', originalPrice: '699.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_7.png' },
{ id: '8', goodsName: '极简静音无雾负离子香薰加湿器 大容量客厅卧室通用', price: '299.00', originalPrice: '399.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_8.png' },
{ id: '9', goodsName: '智能降噪AI翻译蓝牙墨镜 超轻防紫外线偏光太阳镜', price: '599.00', originalPrice: '799.00', thumbnail: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/goods/goods_1.png' }
];
const displayGoods = computed(() => {
if (Array.isArray(props.options?.goods) && props.options.goods.length > 0) {
return props.options.goods;
}
const count = props.options?.pageSize || 8;
return defaultPool.slice(0, count);
});
const featuredGoods = computed(() => {
return displayGoods.value[0] || defaultPool[0];
});
const regularGoods = computed(() => {
return displayGoods.value.slice(1, 7);
});
const actualCols = computed(() => {
const ratio = props.componentStyle?.widthRatio || '100%';
if (ratio === '16.67%' || ratio === '16.6%') return 1;
if (ratio === '33.33%' || ratio === '33.3%') return 2;
if (ratio === '50%') return 3;
if (ratio === '66.67%' || ratio === '66.6%') return 4;
return 5;
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
const bg = props.options?.backgroundColor || s.backgroundColor || '#f8fafc';
return {
backgroundColor: bg,
padding: Array.isArray(s.padding)
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
: '24px 20px',
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '12px'
};
});
function addToCart(item) {
ElMessage.success(`已将 "${item.goodsName?.slice(0, 10)}..." 加入购物车`);
}
function handleRefresh() {
ElMessage.info('已刷新为您推荐的专属商品');
}
</script>
<style lang="scss" scoped>
.decor-recommend-feed {
width: 100%;
box-sizing: border-box;
.recommend-feed-header {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
gap: 16px;
.header-decor-line {
flex: 1;
max-width: 120px;
height: 1px;
background: linear-gradient(90deg, transparent, #cbd5e1);
&.right {
background: linear-gradient(90deg, #cbd5e1, transparent);
}
}
.header-center-title {
display: flex;
align-items: center;
gap: 8px;
.heart-icon {
color: #ef4444;
}
.main-title {
font-size: 22px;
font-weight: 800;
color: #0f172a;
margin: 0;
}
.sub-title {
font-size: 13px;
color: #64748b;
margin-left: 6px;
}
}
}
.recommend-featured-layout {
display: flex;
gap: 14px;
align-items: stretch;
.featured-big-card {
width: 320px;
flex-shrink: 0;
background: #ffffff;
border-radius: 10px;
padding: 16px;
box-sizing: border-box;
display: flex;
flex-direction: column;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.05);
border: 1px solid #f1f5f9;
position: relative;
transition: all 0.3s ease;
&:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
}
.big-card-badge {
position: absolute;
top: 12px;
left: 12px;
background: linear-gradient(135deg, #ef4444, #f97316);
color: #ffffff;
font-size: 10px;
font-weight: 700;
padding: 3px 8px;
border-radius: 4px;
z-index: 2;
}
.big-card-thumb {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
border-radius: 6px;
margin-bottom: 12px;
background: #f8fafc;
text-decoration: none;
img {
width: 85%;
height: 85%;
object-fit: contain;
transition: transform 0.3s ease;
}
&:hover img {
transform: scale(1.05);
}
}
.big-card-info {
display: flex;
flex-direction: column;
flex: 1;
.big-title {
font-size: 14px;
font-weight: 600;
line-height: 1.4;
margin: 0 0 6px 0;
a {
color: #0f172a;
text-decoration: none;
&:hover {
color: #ef4444;
}
}
}
.big-reason {
font-size: 12px;
color: #f97316;
background: #fff7ed;
padding: 4px 8px;
border-radius: 4px;
margin-bottom: 10px;
line-height: 1.3;
}
.big-tags-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
.feat-tag {
font-size: 11px;
color: #475569;
background: #f1f5f9;
padding: 2px 7px;
border-radius: 4px;
font-weight: 500;
&.highlight {
color: #dc2626;
background: #fee2e2;
font-weight: 600;
}
}
}
.big-stats-bar {
display: flex;
align-items: center;
justify-content: space-around;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 8px 6px;
margin-bottom: 10px;
.stat-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
.stat-val {
font-size: 13px;
font-weight: 700;
color: #0f172a;
}
.stat-lbl {
font-size: 10px;
color: #64748b;
}
}
.stat-line {
width: 1px;
height: 20px;
background: #cbd5e1;
}
}
.big-guarantees {
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px 10px;
background: #fbfbfb;
border-left: 3px solid #ef4444;
border-radius: 0 4px 4px 0;
margin-bottom: 12px;
.guar-item {
font-size: 11px;
color: #475569;
display: flex;
align-items: center;
gap: 4px;
.guar-icon {
color: #16a34a;
font-weight: 700;
font-size: 11px;
}
}
}
.big-price-row {
margin-top: auto;
display: flex;
align-items: center;
justify-content: space-between;
.big-price {
display: flex;
align-items: baseline;
.sym {
font-size: 13px;
font-weight: 700;
color: #dc2626;
}
.val {
font-size: 20px;
font-weight: 800;
color: #dc2626;
}
.del {
font-size: 11px;
color: #94a3b8;
text-decoration: line-through;
margin-left: 4px;
}
}
.big-buy-btn {
background: linear-gradient(135deg, #ef4444, #dc2626);
color: #ffffff;
font-size: 12px;
font-weight: 700;
padding: 6px 14px;
border-radius: 16px;
border: none;
cursor: pointer;
transition: all 0.2s;
&:hover {
opacity: 0.9;
transform: scale(1.03);
}
}
}
}
}
.featured-sub-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
}
.recommend-standard-grid {
display: grid;
gap: 12px;
}
.recommend-card {
background: #ffffff;
border-radius: 8px;
padding: 10px;
display: flex;
flex-direction: column;
border: 1px solid #f1f5f9;
transition: all 0.25s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.06);
.card-thumb img {
transform: scale(1.05);
}
.cart-pill-btn {
background: #ef4444;
color: #ffffff;
}
}
.card-thumb {
display: block;
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
background: #f8fafc;
border-radius: 6px;
overflow: hidden;
margin-bottom: 8px;
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.rec-tag {
position: absolute;
top: 6px;
left: 6px;
background: #ef4444;
color: #ffffff;
font-size: 9px;
font-weight: 700;
padding: 1px 5px;
border-radius: 3px;
}
}
.card-body {
display: flex;
flex-direction: column;
flex: 1;
.goods-title {
font-size: 14px;
line-height: 1.4;
height: 40px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
margin: 0 0 4px 0;
a {
color: #1e293b;
text-decoration: none;
&:hover {
color: #ef4444;
}
}
}
.recommend-meta {
font-size: 10px;
color: #f97316;
margin-bottom: 6px;
.reason-tag {
background: #fff7ed;
padding: 1px 5px;
border-radius: 3px;
}
.sales-num {
color: #94a3b8;
margin-right: 6px;
}
.rate-num {
color: #10b981;
}
}
.price-row {
margin-top: auto;
display: flex;
align-items: center;
justify-content: space-between;
.price-box {
display: flex;
align-items: baseline;
.sym {
font-size: 11px;
font-weight: 700;
color: #dc2626;
}
.val {
font-size: 16px;
font-weight: 800;
color: #dc2626;
}
}
.cart-pill-btn {
width: 22px;
height: 22px;
border-radius: 50%;
border: none;
background: #f1f5f9;
color: #64748b;
font-size: 14px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
}
}
}
.recommend-footer-action {
display: flex;
justify-content: center;
margin-top: 20px;
.refresh-rec-btn {
display: flex;
align-items: center;
gap: 6px;
background: #ffffff;
border: 1px solid #e2e8f0;
color: #475569;
font-size: 13px;
font-weight: 500;
padding: 8px 24px;
border-radius: 20px;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
transition: all 0.2s;
&:hover {
border-color: #ef4444;
color: #ef4444;
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.12);
}
}
}
}
</style>

View File

@@ -0,0 +1,421 @@
<template>
<div class="decor-search-header" :style="customStyle">
<div class="header-inner">
<!-- 1. 商城 Logo 区域 -->
<div class="logo-area">
<router-link to="/">
<img
v-if="logoSrc"
:src="logoSrc"
alt="Logo"
class="site-logo"
/>
<span v-else class="text-logo">LiliShop</span>
</router-link>
</div>
<!-- 2. 搜索核心区(紧凑居中,含商品/店铺 Tab、搜索框和热门推荐词) -->
<div class="search-main">
<!-- 搜索类型切换:商品 / 店铺 -->
<div class="search-tabs" v-if="options.showSearchTypes !== false">
<span
class="tab-item"
:class="{ active: currentSearchType === 'goods' }"
@click="switchSearchType('goods')"
>
商品
</span>
<span
class="tab-item"
:class="{ active: currentSearchType === 'store' }"
@click="switchSearchType('store')"
>
店铺
</span>
</div>
<!-- 搜索输入框与按钮 -->
<div class="search-box" :class="{ 'tab-store-active': currentSearchType === 'store' }">
<input
v-model="keyword"
type="text"
class="search-input"
:placeholder="placeholderText"
@keyup.enter="handleSearch"
/>
<button type="button" class="search-btn" @click="handleSearch">
<el-icon><Search /></el-icon>
<span>搜索</span>
</button>
</div>
<!-- 热门推荐词 -->
<div class="hot-words" v-if="options.showHotWords !== false && currentSearchType === 'goods' && hotWordsList.length">
<span
v-for="(word, idx) in hotWordsList"
:key="idx"
class="hot-w"
@click="quickSearch(word)"
>
{{ word }}
</span>
</div>
</div>
<!-- 3. 购物车入口(紧凑跟随在搜索框右侧,带数量徽标) -->
<div class="cart-entry" v-if="options.showCart !== false">
<router-link to="/cart" class="cart-btn" :style="cartBtnStyle">
<el-icon class="cart-icon"><ShoppingCart /></el-icon>
<span class="cart-text">{{ options.cartText || '购物车' }}</span>
<span class="cart-count">{{ cartCount }}</span>
</router-link>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { Search, ShoppingCart } from '@element-plus/icons-vue';
import { useAppStore } from '@/stores/app.js';
import { cartCount as fetchCartCount } from '@/api/cart.js';
import storage from '@/plugins/storage.js';
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const emit = defineEmits(['search']);
const router = useRouter();
const route = useRoute();
const appStore = useAppStore();
const currentSearchType = ref(
route.path === '/storeList'
? 'store'
: (props.options?.defaultSearchType || 'goods')
);
watch(
() => route.path,
(p) => {
if (p === '/storeList') {
currentSearchType.value = 'store';
} else if (p === '/goodsList') {
currentSearchType.value = 'goods';
}
}
);
function switchSearchType(type) {
currentSearchType.value = type;
}
const keyword = ref(route.query?.keyword || '');
watch(() => route.query?.keyword, (v) => {
keyword.value = v || '';
});
const logoSrc = computed(() => {
return props.options?.logoUrl || appStore.logoImg || '';
});
const placeholderText = computed(() => {
if (currentSearchType.value === 'store') {
const sph = props.options?.storePlaceholder;
if (!sph || sph === '请输入店铺名称、品牌或经营范围') return '请输入店铺名称';
return sph;
}
const ph = props.options?.placeholder;
if (!ph || ph === '搜索品牌或商品' || ph === '请输入商品名称、品牌或型号') {
return '请输入商品名称';
}
return ph;
});
const hotWordsList = computed(() => {
if (Array.isArray(props.options?.hotWords) && props.options.hotWords.length) {
return props.options.hotWords;
}
return ['iPhone 16 Pro', '小棕瓶', '戴森吹风机', '华为Mate 70', '五粮液', '跑鞋'];
});
const cartCount = computed(() => {
return appStore.cartNum || 0;
});
function handleSearch() {
const kw = (keyword.value || '').trim();
emit('search', kw);
if (currentSearchType.value === 'store') {
if (route.path === '/storeList') {
const nextQuery = { ...route.query };
if (kw) {
nextQuery.keyword = kw;
} else {
delete nextQuery.keyword;
}
router.push({ path: '/storeList', query: nextQuery });
} else {
router.push({ path: '/storeList', query: kw ? { keyword: kw } : {} });
}
} else {
if (route.path === '/goodsList') {
const nextQuery = { ...route.query };
if (kw) {
nextQuery.keyword = kw;
} else {
delete nextQuery.keyword;
}
router.push({ path: '/goodsList', query: nextQuery });
} else {
router.push({ path: '/goodsList', query: kw ? { keyword: kw } : {} });
}
}
}
function quickSearch(word) {
keyword.value = word;
handleSearch();
}
function getCartList() {
if (storage.getItem('userInfo')) {
fetchCartCount().then((res) => {
appStore.setCartNum(res.result);
storage.setItem('cartNum', res.result);
}).catch(() => {});
}
}
onMounted(() => {
getCartList();
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || '#ffffff',
minHeight: `${s.height || 120}px`
};
});
const cartBtnStyle = computed(() => {
const o = props.options || {};
return {
backgroundColor: o.cartBgColor || '#ffffff',
borderColor: o.cartBorderColor || '#e2e8f0',
color: o.cartTextColor || '#334155'
};
});
</script>
<style lang="scss" scoped>
.decor-search-header {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #ffffff;
border-bottom: 1px solid #f1f5f9;
.header-inner {
width: 1200px;
max-width: 100%;
margin: 0 auto;
padding: 16px 0 14px 0;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
.logo-area {
height: 40px;
display: flex;
align-items: center;
flex-shrink: 0;
a {
display: flex;
align-items: center;
text-decoration: none;
}
.site-logo {
max-height: 40px;
max-width: 180px;
object-fit: contain;
}
.text-logo {
font-size: 26px;
font-weight: 800;
color: #409EFF;
letter-spacing: -0.5px;
}
}
.search-main {
width: 580px;
max-width: 50%;
display: flex;
flex-direction: column;
.search-tabs {
display: flex;
align-items: flex-end;
gap: 4px;
margin-bottom: 0px;
padding-left: 2px;
z-index: 2;
.tab-item {
padding: 4px 16px;
font-size: 13px;
font-weight: 500;
color: #64748b;
cursor: pointer;
border-radius: 4px 4px 0 0;
transition: all 0.15s ease;
user-select: none;
&:hover {
color: #409EFF;
}
&.active {
color: #ffffff;
background: #409EFF;
font-weight: 600;
}
}
}
.search-box {
display: flex;
border: 2px solid #409EFF;
border-radius: 0 4px 4px 4px;
overflow: hidden;
height: 40px;
background: #ffffff;
box-sizing: border-box;
&.tab-store-active {
border-radius: 4px;
}
.search-input {
flex: 1;
border: none;
padding: 0 14px;
font-size: 14px;
outline: none;
color: #334155;
background: #ffffff;
&::placeholder {
color: #94a3b8;
}
}
.search-btn {
background: #409EFF;
color: #ffffff;
border: none;
padding: 0 24px;
display: flex;
align-items: center;
gap: 4px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
flex-shrink: 0;
&:hover {
background: #66b1ff;
}
}
}
.hot-words {
margin-top: 6px;
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 12px;
color: #94a3b8;
line-height: 1.4;
.hot-w {
cursor: pointer;
transition: color 0.15s;
&:hover {
color: #409EFF;
}
}
}
}
.cart-entry {
flex-shrink: 0;
.cart-btn {
height: 40px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 18px;
border: 1px solid #e2e8f0;
border-radius: 4px;
font-size: 13px;
color: #334155;
background: #ffffff;
text-decoration: none;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
transition: all 0.2s ease;
box-sizing: border-box;
.cart-icon {
font-size: 18px;
color: #409EFF;
}
.cart-text {
font-weight: 500;
white-space: nowrap;
}
.cart-count {
background: #409EFF;
color: #ffffff;
font-size: 11px;
font-weight: 700;
padding: 0 6px;
height: 18px;
line-height: 18px;
border-radius: 9px;
}
&:hover {
border-color: #409EFF;
color: #409EFF;
box-shadow: 0 2px 6px rgba(64, 158, 255, 0.15);
}
}
}
}
}
</style>

View File

@@ -0,0 +1,204 @@
<template>
<div
class="decor-title-bar"
:class="[
`align-${options.align || 'left'}`,
isMini ? 'is-mini' : (isCompact ? 'is-compact' : '')
]"
>
<div class="title-wrap" :class="{ 'with-icon': options.titleImg || options.icon }">
<img
v-if="options.titleImg || options.icon"
:src="options.titleImg || options.icon"
class="title-custom-icon"
alt="icon"
/>
<div class="title-text-group">
<h2
class="main-title"
:class="{ 'clickable': Boolean(options.titleUrl) }"
@click="options.titleUrl && handleNavigate(options.titleUrl)"
>
{{ options.mainTitle || '楼层标题' }}
</h2>
<p class="subtitle" v-if="options.subtitle">{{ options.subtitle }}</p>
</div>
</div>
<a
href="javascript:;"
class="more-link"
v-if="options.showMore"
@click="handleNavigate(options.moreUrl || '/goods')"
>
{{ options.moreText || '查看更多 >' }}
</a>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
function handleNavigate(url) {
if (!url) return;
if (/^https?:\/\//i.test(url)) {
window.open(url, '_blank');
} else {
router.push(url);
}
}
const isMini = computed(() => props.componentStyle?.widthRatio === '16.67%');
const isCompact = computed(() => props.componentStyle?.widthRatio === '33.33%');
</script>
<style lang="scss" scoped>
.decor-title-bar {
width: 100%;
display: flex;
align-items: flex-end;
justify-content: space-between;
box-sizing: border-box;
gap: 12px;
&.align-center {
justify-content: center;
text-align: center;
position: relative;
.more-link {
position: absolute;
right: 0;
bottom: 0;
}
}
.title-wrap {
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
.title-custom-icon {
width: 28px;
height: 28px;
object-fit: contain;
flex-shrink: 0;
}
.title-text-group {
min-width: 0;
.main-title {
font-size: 26px;
font-weight: 800;
color: #0f172a;
margin: 0;
line-height: 1.2;
word-break: break-word;
&.clickable {
cursor: pointer;
transition: color 0.15s ease;
&:hover {
color: #f97316;
}
}
}
.subtitle {
font-size: 14px;
color: #94a3b8;
margin: 6px 0 0 0;
word-break: break-word;
}
}
}
.more-link {
font-size: 13px;
color: #64748b;
text-decoration: none;
transition: color 0.2s;
white-space: nowrap;
flex-shrink: 0;
cursor: pointer;
&:hover {
color: #e11d48;
}
}
&.is-compact {
.title-wrap {
.title-custom-icon {
width: 22px;
height: 22px;
}
.title-text-group {
.main-title {
font-size: 20px;
}
.subtitle {
font-size: 13px;
margin-top: 4px;
}
}
}
.more-link {
font-size: 12px;
}
}
&.is-mini {
flex-direction: column;
align-items: flex-start;
gap: 6px;
&.align-center {
align-items: center;
text-align: center;
.more-link {
position: static;
margin-top: 4px;
}
}
.title-wrap {
.title-custom-icon {
width: 18px;
height: 18px;
}
.title-text-group {
.main-title {
font-size: 16px;
}
.subtitle {
font-size: 12px;
margin-top: 2px;
}
}
}
.more-link {
font-size: 12px;
}
}
}
</style>

View File

@@ -0,0 +1,181 @@
<template>
<div class="decor-top-bar" :style="customStyle">
<div class="top-bar-inner">
<div class="bar-left">
<span class="welcome-txt">{{ options.welcomeText || '欢迎来到商城' }}</span>
<span class="auth-links" v-if="!username">
<router-link :to="options.loginUrl || '/login'" class="auth-item">
[{{ options.loginText || '登录' }}]
</router-link>
<router-link :to="options.registerUrl || '/signUp'" class="auth-item">
[{{ options.registerText || '免费注册' }}]
</router-link>
</span>
<span class="auth-links user-logged" v-else>
<router-link :to="options.userCenterUrl || '/home/MyOrder'" class="user-name">
欢迎,{{ username }}
</router-link>
<span class="sep">|</span>
<a class="auth-item" @click="handleLogout">{{ options.logoutText || '退出' }}</a>
</span>
</div>
<div class="bar-right">
<router-link
v-for="(menu, idx) in (options.rightMenus || defaultMenus)"
:key="idx"
:to="menu.url || '/'"
class="bar-link"
>
{{ menu.title }}
</router-link>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import Cookies from 'js-cookie';
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const defaultMenus = [
{ title: '基本信息', url: '/home/UserInfo' },
{ title: '我的订单', url: '/home/MyOrder' },
{ title: '账户安全', url: '/home/AccountSafe' },
{ title: '消息', url: '/home/Msg' }
];
const username = computed(() => {
const userInfo = Cookies.get('userInfo');
if (userInfo) {
try {
const u = JSON.parse(userInfo);
return u.nickName || u.username || '';
} catch (e) {
return '';
}
}
return '';
});
function handleLogout() {
Cookies.remove('userInfo');
Cookies.remove('accessToken');
window.location.reload();
}
const customStyle = computed(() => {
const s = props.componentStyle || {};
const opt = props.options || {};
const textColor = opt.textColor || s.textColor || '#4b5563';
const fontSize = opt.fontSize ? `${opt.fontSize}px` : '12px';
return {
backgroundColor: s.backgroundColor || '#f8fafc',
color: textColor,
fontSize: fontSize,
height: `${s.height || 40}px`,
lineHeight: `${s.height || 40}px`
};
});
</script>
<style lang="scss" scoped>
.decor-top-bar {
width: 100%;
max-width: 100%;
box-sizing: border-box;
font-size: 12px;
overflow: hidden;
background-color: #f8fafc;
border-bottom: 1px solid #e2e8f0;
*, *::before, *::after {
box-sizing: border-box;
}
.top-bar-inner {
width: 1200px;
max-width: 100%;
margin: 0 auto;
padding: 0 10px;
box-sizing: border-box;
display: flex;
justify-content: space-between;
align-items: center;
height: 100%;
.bar-left {
display: flex;
align-items: center;
gap: 10px;
white-space: nowrap;
.welcome-txt {
white-space: nowrap;
}
.auth-links {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
.auth-item {
color: #409EFF;
text-decoration: none;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.sep {
margin: 0 4px;
color: #dcdfe6;
}
.user-name {
color: #333333;
font-weight: 500;
text-decoration: none;
&:hover {
color: #409EFF;
}
}
}
}
.bar-right {
display: flex;
align-items: center;
gap: 18px;
white-space: nowrap;
.bar-link {
color: inherit;
text-decoration: none;
transition: color 0.2s;
white-space: nowrap;
&:hover {
color: #409EFF;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,330 @@
<template>
<div class="decor-user-benefits" :style="customStyle">
<!-- 1. 用户头像与信息区 -->
<div class="user-main-info">
<div class="avatar-box">
<img v-if="options.avatar" :src="options.avatar" alt="avatar" />
<div v-else class="avatar-placeholder">
<svg viewBox="0 0 24 24" width="22" height="22" fill="#ffffff">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
</svg>
</div>
</div>
<div class="user-greeting">
<div class="badge-tag">{{ options.badgeText || '请登录' }}</div>
<div class="welcome-text" :title="options.welcomeText || '欢迎来到商城'">
{{ options.welcomeText || '欢迎来到商城' }}
</div>
<div class="sub-text" :title="options.subtitle || '登录后查看专属权益'">
{{ options.subtitle || '登录后查看专属权益' }}
</div>
</div>
</div>
<!-- 2. 登录 / 注册按钮组 -->
<div class="action-btn-row">
<a href="javascript:;" class="action-btn login-btn" @click="handleNavigate(options.loginUrl || '/login')">
{{ options.loginText || '登录' }}
</a>
<a href="javascript:;" class="action-btn register-btn" @click="handleNavigate(options.registerUrl || '/signUp')">
{{ options.registerText || '注册' }}
</a>
</div>
<!-- 3. 指标数据卡 (购物车 / 优惠券 / 订单) -->
<div class="metrics-cards-row">
<a href="javascript:;" class="metric-card" v-if="options.showCart !== false" @click="handleNavigate(options.cartUrl || '/cart')">
<div class="metric-val">0</div>
<div class="metric-label">{{ options.cartText || '购物车' }}</div>
</a>
<a href="javascript:;" class="metric-card" v-if="options.showCoupons !== false" @click="handleNavigate(options.couponUrl || '/member/coupon')">
<div class="metric-val">0</div>
<div class="metric-label">{{ options.couponText || '优惠券' }}</div>
</a>
<a href="javascript:;" class="metric-card" v-if="options.showOrders !== false" @click="handleNavigate(options.orderUrl || '/member/order')">
<div class="metric-val">0</div>
<div class="metric-label">{{ options.orderText || '订单' }}</div>
</a>
</div>
<!-- 4. 快捷便民服务入口(底部图标排) -->
<div class="quick-service-row">
<a
v-for="(srv, sIdx) in displayServices"
:key="sIdx"
href="javascript:;"
class="service-item"
@click="handleNavigate(srv.url)"
>
<el-icon class="service-icon"><component :is="getServiceIcon(srv.icon)" /></el-icon>
<span class="service-name">{{ srv.name }}</span>
</a>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { Star, Clock, Present, Service } from '@element-plus/icons-vue';
const router = useRouter();
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
},
rowStyle: {
type: Object,
default: () => ({})
}
});
function handleNavigate(url) {
if (!url) return;
if (/^https?:\/\//i.test(url)) {
window.open(url, '_blank');
} else {
router.push(url);
}
}
const defaultServices = [
{ name: '收藏', url: '/member/favorites', icon: 'Star' },
{ name: '足迹', url: '/member/footprint', icon: 'Clock' },
{ name: '红包', url: '/couponCenter', icon: 'Present' },
{ name: '客服', url: '/article', icon: 'Service' }
];
const iconMap = {
Star,
Clock,
Present,
Service
};
function getServiceIcon(iconName) {
return iconMap[iconName] || Service;
}
const displayServices = computed(() => {
if (Array.isArray(props.options?.services) && props.options.services.length > 0) {
return props.options.services;
}
return defaultServices;
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
const hasRowHeight = Boolean(props.rowStyle?.height && Number(props.rowStyle.height) > 0);
return {
backgroundColor: (s.backgroundColor && s.backgroundColor !== 'transparent') ? s.backgroundColor : '#ffffff',
borderRadius: Array.isArray(s.radius) && s.radius.some(r => r > 0)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '8px',
height: '100%',
minHeight: hasRowHeight ? '0' : '420px'
};
});
</script>
<style lang="scss" scoped>
.decor-user-benefits {
width: 100%;
height: 100%;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
padding: 24px 16px 20px;
gap: 12px;
display: flex;
flex-direction: column;
justify-content: space-between;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
overflow: hidden;
.user-main-info {
display: flex;
align-items: center;
gap: 8px;
.avatar-box {
width: 48px;
height: 48px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-placeholder {
width: 100%;
height: 100%;
background: #cbd5e1;
display: flex;
align-items: center;
justify-content: center;
}
}
.user-greeting {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
.badge-tag {
align-self: flex-start;
padding: 1px 6px;
background: #ecf5ff;
color: #409EFF;
font-size: 10px;
font-weight: 700;
border-radius: 8px;
line-height: 1.2;
margin-bottom: 2px;
}
.welcome-text {
font-size: 13px;
font-weight: 700;
color: #111827;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sub-text {
font-size: 11px;
color: #94a3b8;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
.action-btn-row {
display: flex;
gap: 8px;
margin-top: 8px;
.action-btn {
flex: 1;
height: 32px;
line-height: 32px;
font-size: 12px;
font-weight: 500;
text-align: center;
border-radius: 14px;
cursor: pointer;
text-decoration: none;
box-sizing: border-box;
transition: all 0.15s ease;
&.login-btn {
background: #409EFF;
color: #ffffff;
box-shadow: 0 2px 6px rgba(64, 158, 255, 0.25);
&:hover {
background: #66b1ff;
}
}
&.register-btn {
background: #ffffff;
color: #409EFF;
border: 1px solid #409EFF;
&:hover {
background: #ecf5ff;
}
}
}
}
.metrics-cards-row {
display: flex;
align-items: center;
justify-content: space-around;
padding: 12px 0;
margin-top: 6px;
background: #f8fafc;
border-radius: 6px;
.metric-card {
display: flex;
flex-direction: column;
align-items: center;
text-decoration: none;
cursor: pointer;
.metric-val {
font-size: 15px;
font-weight: 700;
color: #409EFF;
line-height: 1.2;
}
.metric-label {
font-size: 11px;
color: #64748b;
margin-top: 2px;
}
}
}
.quick-service-row {
display: flex;
justify-content: space-around;
align-items: center;
padding-top: 14px;
border-top: 1px dashed #f1f5f9;
margin-top: 6px;
.service-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
cursor: pointer;
color: #64748b;
text-decoration: none;
transition: color 0.15s ease;
.service-icon {
font-size: 20px;
transition: transform 0.15s ease;
}
.service-name {
font-size: 11px;
}
&:hover {
color: #409EFF;
.service-icon {
transform: translateY(-2px);
}
}
}
}
}
</style>

View File

@@ -0,0 +1,213 @@
<template>
<div
class="decor-video-player"
:class="{ 'has-link': !!options.url }"
:style="customStyle"
@click="handleClickPlaceholder"
>
<video
v-if="options.videoUrl"
:src="options.videoUrl"
:poster="options.coverUrl"
:autoplay="options.autoPlay"
:loop="options.loop"
:controls="options.controls !== false"
:muted="options.autoPlay"
:style="{ height: `${options.height || 450}px` }"
class="video-element"
/>
<div
v-else
class="video-placeholder"
:style="{
height: `${options.height || 450}px`,
backgroundImage: `url(${options.coverUrl || 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/video/video_cover.png'})`
}"
>
<div class="video-play-hint">
<span class="play-icon">▶</span>
<span class="hint-text">{{ options.title || '官方品牌宣传片' }}</span>
</div>
</div>
<!-- 跳转行动点徽标(如果配置了链接) -->
<div
v-if="options.url"
class="video-cta-badge"
@click.stop="handleNavigate"
title="点击查看详情"
>
<span class="cta-dot"></span>
<span class="cta-text">查看活动详情</span>
<span class="cta-arrow">&gt;</span>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const props = defineProps({
options: {
type: Object,
default: () => ({})
},
componentStyle: {
type: Object,
default: () => ({})
}
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`
: '8px'
};
});
function handleNavigate() {
const url = props.options?.url ? String(props.options.url).trim() : '';
if (!url) return;
if (/^https?:\/\//i.test(url)) {
window.open(url, '_blank');
} else {
router.push(url);
}
}
function handleClickPlaceholder() {
if (!props.options?.videoUrl && props.options?.url) {
handleNavigate();
}
}
</script>
<style lang="scss" scoped>
.decor-video-player {
width: 100%;
position: relative;
overflow: hidden;
box-sizing: border-box;
&.has-link {
.video-placeholder {
cursor: pointer;
}
}
.video-element {
width: 100%;
object-fit: cover;
display: block;
border-radius: inherit;
}
.video-placeholder {
width: 100%;
background: #0f172a;
background-size: cover;
background-position: center;
border-radius: inherit;
display: flex;
align-items: center;
justify-content: center;
color: #64748b;
font-size: 14px;
position: relative;
.video-play-hint {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 16px 24px;
background: rgba(15, 23, 42, 0.7);
border-radius: 12px;
backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.15);
transition: transform 0.2s ease, background 0.2s ease;
.play-icon {
width: 48px;
height: 48px;
background: #e11d48;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 20px;
padding-left: 3px;
box-shadow: 0 4px 14px rgba(225, 29, 72, 0.4);
}
.hint-text {
color: #ffffff;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.5px;
}
}
&:hover .video-play-hint {
transform: scale(1.04);
background: rgba(15, 23, 42, 0.85);
}
}
.video-cta-badge {
position: absolute;
top: 16px;
right: 16px;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: rgba(15, 23, 42, 0.8);
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 20px;
backdrop-filter: blur(8px);
color: #ffffff;
font-size: 12px;
font-weight: 500;
cursor: pointer;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
transition: all 0.2s ease;
z-index: 10;
.cta-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #e11d48;
box-shadow: 0 0 8px #e11d48;
}
.cta-text {
letter-spacing: 0.3px;
}
.cta-arrow {
font-size: 11px;
opacity: 0.8;
transition: transform 0.2s ease;
}
&:hover {
background: rgba(225, 29, 72, 0.9);
border-color: rgba(255, 255, 255, 0.4);
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(225, 29, 72, 0.35);
.cta-arrow {
transform: translateX(2px);
}
}
}
}
</style>

View File

@@ -1,22 +1,30 @@
<template> <template>
<div class="model-carousel"> <div class="model-carousel">
<div class="nav-body clearfix"> <div class="nav-body">
<!-- 侧边导航占位 --> <!-- 侧边导航占位(与 CateNav 分类栏同宽,勿与 CateNav .nav-side 混用类名) -->
<div class="nav-side"></div> <div class="carousel-side-spacer"></div>
<div class="nav-content"> <div class="nav-content">
<!-- 轮播图 --> <!-- 轮播图:无有效图片时不挂载 el-carousel;勿对 el-carousel 使用随图片变化的 key,否则 Vue3 patch 会报 emitsOptions 错误 -->
<!-- autoplay autoplay-speed="10000"--> <el-carousel
<Carousel :height="333.9" autoplay :autoplay-speed="5000"> v-if="displaySlides.length"
<CarouselItem v-for="(item, index) in data.options.list" :key="index"> height="334px"
<div class="swiper-img" > :interval="5000"
:loop="displaySlides.length > 1"
>
<el-carousel-item
v-for="(item, index) in displaySlides"
:key="carouselItemKey(item, index)"
>
<div class="swiper-img">
<img <img
:src="item.img" :src="item.img"
@click="linkTo(item.url)" @click="linkTo(item.url)"
class="hover-pointer" class="hover-pointer"
/> />
</div> </div>
</CarouselItem> </el-carousel-item>
</Carousel> </el-carousel>
<div v-else class="swiper-img carousel-placeholder"></div>
</div> </div>
<div class="nav-right"> <div class="nav-right">
<div class="person-msg"> <div class="person-msg">
@@ -24,7 +32,7 @@
<img class="user-face" :src="userInfo.face || defaultAvatar" alt /> <img class="user-face" :src="userInfo.face || defaultAvatar" alt />
<div class="welcome"> <div class="welcome">
Hi, {{ Hi, {{
userInfo.nickName || `欢迎来到${config.title}` | secrecyMobile $filters.secrecyMobile(userInfo.nickName || `欢迎来到${config.title}`)
}} }}
</div> </div>
</div> </div>
@@ -43,18 +51,14 @@
</div> --> </div> -->
</div> </div>
<div v-else class="flex flex-a-c "> <div v-else class="flex flex-a-c ">
<div class="btns" @click="$router.push('login')" shape="circle" <div class="btns" @click="goLogin">登录</div>
>登录</div <div class="btns sign-up" @click="$router.push('signUp')">注册</div>
>
<div class="btns sign-up" @click="$router.push('signUp')" shape="circle"
>注册</div
>
</div> </div>
<div class="gray-line"></div> <div class="gray-line"></div>
<div class="icon-list flex flex-j-sb" > <div class="icon-list flex flex-j-sb" >
<div class="icon-item" @click="entryControl(item)" :key="index" v-for="(item, index) in entranceList"> <div class="icon-item" @click="entryControl(item)" :key="index" v-for="(item, index) in entranceList">
<img class="icon" :src="require(`@/assets/iconfont/${item.icon}.png`)"> <img class="icon" :src="getIconUrl(item.icon)">
<div> <div>
{{ item.label }} {{ item.label }}
</div> </div>
@@ -62,7 +66,7 @@
</div> </div>
<div class="icon-list flex flex-j-sb" > <div class="icon-list flex flex-j-sb" >
<div class="icon-item" :key="index" @click="entryControl(item)" v-for="(item, index) in appendList"> <div class="icon-item" :key="index" @click="entryControl(item)" v-for="(item, index) in appendList">
<img class="icon" :src="require(`@/assets/iconfont/${item.icon}.png`)"> <img class="icon" :src="getIconUrl(item.icon)">
<div> <div>
{{ item.label }} {{ item.label }}
</div> </div>
@@ -78,95 +82,153 @@
</div> </div>
</template> </template>
<script> <script setup>
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { Modal } from "@/utils/message";
import storage from "@/plugins/storage"; import storage from "@/plugins/storage";
export default { import config from "@/config";
name: "modelCarousel", import defaultAvatar from "@/assets/images/default.png";
props: ["data"], import { getIconUrl } from "@/assets/iconfont/iconMap";
data() {
return {
config: require("@/config"),
defaultAvatar: require("@/assets/images/default.png"),
userInfo: {}, // 用户信息
entranceList: [
{
icon: "collage",
label: "宝贝收藏",
path: "/home/Favorites"
},
{
icon: "shop",
label: "收藏店铺",
path: "/home/Favorites?type=STORE"
},
{
icon: "carts",
label: "购物车",
path: "/cart"
},
{
icon: "story",
label: "我的足迹",
path: "/home/MyTracks"
},
],
appendList: [
{
icon: "support",
label: "帮助中心",
path: "/article"
},
{
icon: "feedback",
label: "我的评论",
path: "/home/CommentList"
},
{
icon: "notice",
label: "收货地址",
path: "/home/MyAddress"
},
{
icon: "notification",
label: "我的消息",
path: "/home/MsgList"
},
],
recentList: [
{
value: "0",
label: "购物车"
},
{
value: "0",
label: "待收货"
},
{
value: "0",
label: "待发货"
},
{
value: "0",
label: "代付款"
},
],
};
},
methods: {
// 快捷跳转中心
entryControl(val) {
console.log("val",val)
let url = this.$router.resolve(val.path);
window.open(url.href, '_blank');
},
},
mounted() {
if (storage.getItem("userInfo"))
this.userInfo = JSON.parse(storage.getItem("userInfo"));
defineOptions({ name: "modelCarousel" });
const props = defineProps(["data"]);
const router = useRouter();
const carouselList = computed(() => {
return props.data?.options?.list || [];
});
const displaySlides = computed(() => {
return carouselList.value.filter((item) => item && item.img);
});
const userInfo = ref({}); // 用户信息
const entranceList = ref([
{
icon: "collage",
label: "宝贝收藏",
path: "/home/Favorites"
}, },
}; {
icon: "shop",
label: "收藏店铺",
path: "/home/Favorites?type=STORE"
},
{
icon: "carts",
label: "购物车",
path: "/cart"
},
{
icon: "story",
label: "我的足迹",
path: "/home/MyTracks"
},
]);
const appendList = ref([
{
icon: "support",
label: "帮助中心",
path: "/article"
},
{
icon: "feedback",
label: "我的评论",
path: "/home/CommentList"
},
{
icon: "notice",
label: "收货地址",
path: "/home/MyAddress"
},
{
icon: "notification",
label: "我的消息",
path: "/home/MsgList"
},
]);
const recentList = ref([
{
value: "0",
label: "购物车"
},
{
value: "0",
label: "待收货"
},
{
value: "0",
label: "待发货"
},
{
value: "0",
label: "待付款"
},
]);
function carouselItemKey(item, index) {
return item?.img || item?.url || `slide-${index}`;
}
function resolveRoute(pathStr) {
if (!pathStr) return { path: "/" };
const [path, search] = pathStr.split("?");
if (!search) return { path };
return {
path,
query: Object.fromEntries(new URLSearchParams(search)),
};
}
function isMemberRoute(location) {
const path = location.path && location.path.charAt(0) === "/" ? location.path : `/${location.path || ""}`;
return path === "/home" || path.startsWith("/home/") || path === "/cart";
}
function normalizeLocation(location) {
if (!location.path || location.path.charAt(0) === "/") return location;
return { ...location, path: `/${location.path}` };
}
function promptLoginThenNavigate(location) {
Modal.confirm({
title: "温馨提示",
content: "请登录后执行此操作",
okText: "立即登录",
cancelText: "取消",
onOk: () => {
router.push({
path: "/login",
query: {
rePath: location.path,
query: JSON.stringify(location.query || {}),
},
});
},
});
}
function goLogin() {
router.push("/login");
}
// 快捷跳转中心
function entryControl(val) {
const location = normalizeLocation(resolveRoute(val.path));
if (isMemberRoute(location) && !storage.getItem("userInfo")) {
promptLoginThenNavigate(location);
return;
}
router.push(location);
}
onMounted(() => {
if (storage.getItem("userInfo"))
userInfo.value = JSON.parse(storage.getItem("userInfo"));
});
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -182,10 +244,15 @@ export default {
.swiper-img { .swiper-img {
overflow: hidden; overflow: hidden;
width: 637px; width: 100%;
height: 329.9px; height: 329.9px;
} }
.carousel-placeholder {
border-radius: 10px;
background: #ececec;
}
.icon-list { .icon-list {
width: 216px width: 216px
} }
@@ -265,21 +332,21 @@ export default {
} }
.model-carousel { .model-carousel {
width: 1200px; width: 100%;
max-width: 1200px;
height: 340px; height: 340px;
margin: 0 auto;
overflow: hidden; overflow: hidden;
box-sizing: border-box;
position: relative;
z-index: 0;
} }
.hover-pointer { .hover-pointer {
//display: block; width: 100%;
//width: 100%;
//height: 100%;
width: 637px;
height: 329.9px; height: 329.9px;
object-fit: cover; object-fit: cover;
border-radius: 10px; border-radius: 10px;
} }
.welcome { .welcome {
@@ -334,44 +401,62 @@ export default {
} }
/* 导航主体 */ /* 导航主体:263.2 + 10 + 1fr + 10 + 263.2 = 1200px,与下方楼层右缘对齐 */
.nav-body { .nav-body {
width: 1200px; width: 100%;
max-width: 1200px;
height: 340px; height: 340px;
margin: 0px auto; margin: 0 auto;
display: grid;
grid-template-columns: 263.2px minmax(0, 1fr) 263.2px;
column-gap: 10px;
align-items: start;
box-sizing: border-box;
} }
.nav-side { .carousel-side-spacer {
height: 334px; grid-column: 1;
width: 263.2px; width: 263.2px;
height: 334px;
flex-shrink: 0;
border-bottom-left-radius: 10px; border-bottom-left-radius: 10px;
border-bottom-right-radius: 10px; border-bottom-right-radius: 10px;
float: left; pointer-events: none;
padding: 0px;
color: #fff;
// background-color: #6e6568;
} }
/*导航内容*/ /*导航内容*/
.nav-content { .nav-content {
width: 637px; grid-column: 2;
margin-left: 10.8px; width: 100%;
min-width: 0;
margin-top: 10px; margin-top: 10px;
height: 333.9px; height: 333.9px;
float: left;
position: relative; position: relative;
overflow: hidden;
:deep(.el-carousel) {
width: 100%;
height: 100%;
border-radius: 10px;
overflow: hidden;
}
:deep(.el-carousel__container) {
height: 334px;
}
:deep(.el-carousel__item) {
width: 100%;
}
} }
.nav-right { .nav-right {
float: left; grid-column: 3;
margin-top: 10px;
width: 263.2px; width: 263.2px;
margin-left: 10px; min-width: 263.2px;
max-width: 263.2px;
margin-top: 10px;
border-radius: 10px; border-radius: 10px;
background: #FFFFFF; background: #FFFFFF;

View File

@@ -5,40 +5,38 @@
<div class="nav-side"></div> <div class="nav-side"></div>
<div class="nav-content"> <div class="nav-content">
<!-- 轮播图 --> <!-- 轮播图 -->
<Carousel autoplay @on-change="autoChange"> <el-carousel height="470px" :interval="5000" @change="autoChange">
<CarouselItem v-for="(item, index) in data.options.list" :key="index" > <el-carousel-item v-for="(item, index) in data.options.list" :key="index">
<div style="overflow: hidden"> <div style="overflow: hidden">
<img :src="item.img" width="1200" height="470" /> <img :src="item.img" width="1200" height="470" />
</div> </div>
</CarouselItem> </el-carousel-item>
</Carousel> </el-carousel>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script> <script setup>
export default { import { onMounted, ref } from 'vue'
name: 'modelCarousel1',
props: ['data'], defineOptions({ name: 'modelCarousel1' })
data () {
return { const props = defineProps(['data'])
showModal: false, // modal显隐
selected: null, // 已选数据 const showModal = ref(false) // modal显隐
picModelFlag: false, // 选择图片modal const selected = ref(null) // 已选数据
bgColor: '#fff' // 轮播背景色 const picModelFlag = ref(false) // 选择图片modal
}; const bgColor = ref('#fff') // 轮播背景色
},
mounted () { onMounted(() => {
this.bgColor = this.data.options.list[0].bgColor bgColor.value = props.data.options.list[0].bgColor
}, })
methods: {
// 自动切换时改变背景色 // 自动切换时改变背景色
autoChange (oVal, val) { function autoChange(index) {
this.bgColor = this.data.options.list[val].bgColor bgColor.value = props.data.options.list[index].bgColor
} }
}
};
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -5,18 +5,17 @@
<div class="nav-side"></div> <div class="nav-side"></div>
<div class="nav-content"> <div class="nav-content">
<!-- 轮播图 --> <!-- 轮播图 -->
<Carousel autoplay> <el-carousel height="470px" :interval="5000">
<CarouselItem v-for="(item, index) in data.options.list" :key="index"> <el-carousel-item v-for="(item, index) in data.options.list" :key="index">
<div style="overflow: hidden"> <div style="overflow: hidden">
<img :src="item.img" width="590" height="470" /> <img :src="item.img" width="590" height="470" />
</div> </div>
</CarouselItem> </el-carousel-item>
</Carousel> </el-carousel>
</div> </div>
<div class="nav-content1"> <div class="nav-content1">
<!-- 轮播图 --> <el-carousel height="470px" :interval="5000">
<Carousel autoplay :autoplay-speed="5000"> <el-carousel-item v-for="(item, index) in data.options.listRight" :key="index">
<CarouselItem v-for="(item, index) in data.options.listRight" :key="index">
<div class="mb_10"> <div class="mb_10">
<img :src="item[0].img" width="190" height="150" /> <img :src="item[0].img" width="190" height="150" />
</div> </div>
@@ -26,19 +25,19 @@
<div> <div>
<img :src="item[2].img" width="190" height="150" /> <img :src="item[2].img" width="190" height="150" />
</div> </div>
</CarouselItem> </el-carousel-item>
</Carousel> </el-carousel>
</div> </div>
<div class="nav-right"> <div class="nav-right">
<div class="person-msg"> <div class="person-msg">
<img :src="userInfo.face" v-if="userInfo.face" alt /> <img :src="userInfo.face" v-if="userInfo.face" alt />
<Avatar icon="ios-person" class="mb_10" v-else size="80" /> <el-avatar v-else :size="80" class="mb_10"><el-icon><User /></el-icon></el-avatar>
<div>Hi,{{ userInfo.nickName || `欢迎来到${config.title}` | secrecyMobile }}</div> <div>Hi,{{ $filters.secrecyMobile(userInfo.nickName || `欢迎来到${config.title}`) }}</div>
<div v-if="userInfo.id"> <div v-if="userInfo.id">
<Button type="error" shape="circle">会员中心</Button> <el-button type="danger">会员中心</el-button>
</div> </div>
<div v-else> <div v-else>
<Button type="error" shape="circle">请登录</Button> <el-button type="danger">请登录</el-button>
</div> </div>
</div> </div>
<div class="shop-msg"> <div class="shop-msg">
@@ -56,47 +55,48 @@
</div> </div>
</template> </template>
<script> <script setup>
import { onMounted, reactive, ref } from 'vue'
import {articleList} from '@/api/common.js' import { useRouter } from 'vue-router'
import { articleList as getArticleListApi } from '@/api/common.js'
import storage from '@/plugins/storage'; import storage from '@/plugins/storage';
export default { import { User } from '@element-plus/icons-vue';
name: 'modelCarousel2', import config from '@/config';
props: ['data'],
data () { defineOptions({ name: 'modelCarousel2' })
return {
config:require('@/config'), defineProps(['data'])
userInfo: {}, // 用户信息
articleList: [], // 常见问题 const router = useRouter()
params: { // 请求常见问题参数
pageNumber: 1, const userInfo = ref({}) // 用户信息
pageSize: 10, const articleList = ref([]) // 常见问题
type: 'ANNOUNCEMENT', const params = reactive({ // 请求常见问题参数
sort: 'sort' pageNumber: 1,
} pageSize: 10,
}; type: 'ANNOUNCEMENT',
}, sort: 'sort'
methods: { })
getArticleList () { // 获取常见问题列表
articleList(this.params).then(res => { function getArticleList () { // 获取常见问题列表
if (res.success) { getArticleListApi(params).then(res => {
this.articleList = res.result.records if (res.success) {
} articleList.value = res.result.records
})
},
goArticle (id) { // 跳转文章详情
let routeUrl = this.$router.resolve({
path: '/article',
query: {id}
});
window.open(routeUrl.href, '_blank');
} }
}, })
mounted () { }
if (storage.getItem('userInfo')) this.userInfo = JSON.parse(storage.getItem('userInfo'));
this.getArticleList() function goArticle (id) { // 跳转文章详情
} router.push({
}; path: '/article',
query: {id}
});
}
onMounted(() => {
if (storage.getItem('userInfo')) userInfo.value = JSON.parse(storage.getItem('userInfo'));
getArticleList()
})
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -163,7 +163,7 @@ export default {
margin-top: 10px; margin-top: 10px;
} }
.ivu-btn-default { .el-button--default {
color: $theme_color; color: $theme_color;
border-color: $theme_color; border-color: $theme_color;
} }

View File

@@ -15,24 +15,21 @@
<span class="line btm-line"></span> <span class="line btm-line"></span>
<p>{{ item.describe }}</p> <p>{{ item.describe }}</p>
</div> </div>
<img :src="item.img" width="170" height="170" alt="" /> <img :src="item.img" loading="lazy" decoding="async" width="170" height="170" alt="" />
</div> </div>
</div> </div>
</template> </template>
<script> <script setup>
export default { import { ref } from 'vue';
props: {
data: { const props = defineProps({
type: Object, data: {
default: null type: Object,
} default: null
},
data () {
return {
options: this.data.options // 装修数据
};
} }
}; });
const options = ref(props.data.options); // 装修数据
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.first-page-advert { .first-page-advert {

View File

@@ -4,8 +4,7 @@
<div class="top-header" :style="{ background: options.left.bgColor }"> <div class="top-header" :style="{ background: options.left.bgColor }">
<span>{{ options.left.title }}</span> <span>{{ options.left.title }}</span>
<span @click="linkTo(options.left.url)" class="hover-pointer" <span @click="linkTo(options.left.url)" class="hover-pointer"
>{{ options.left.secondTitle }} &gt;</span >{{ options.left.secondTitle }} &gt;</span>
>
</div> </div>
<div class="content"> <div class="content">
<div <div
@@ -18,7 +17,7 @@
<p>{{ item.name }}</p> <p>{{ item.name }}</p>
<p class="describe">{{ item.describe }}</p> <p class="describe">{{ item.describe }}</p>
</div> </div>
<img :src="item.img" alt="" /> <img :src="item.img" loading="lazy" decoding="async" alt="" />
</div> </div>
</div> </div>
</div> </div>
@@ -27,8 +26,7 @@
<div class="top-header" :style="{ background: options.middle.bgColor }"> <div class="top-header" :style="{ background: options.middle.bgColor }">
<span>{{ options.middle.title }}</span> <span>{{ options.middle.title }}</span>
<span class="hover-pointer" @click="linkTo(options.middle.url)" <span class="hover-pointer" @click="linkTo(options.middle.url)"
>{{ options.middle.secondTitle }} &gt;</span >{{ options.middle.secondTitle }} &gt;</span>
>
</div> </div>
<div class="content"> <div class="content">
<div <div
@@ -41,7 +39,7 @@
<p>{{ item.name }}</p> <p>{{ item.name }}</p>
<p class="describe">{{ item.describe }}</p> <p class="describe">{{ item.describe }}</p>
</div> </div>
<img :src="item.img" alt="" /> <img :src="item.img" loading="lazy" decoding="async" alt="" />
</div> </div>
</div> </div>
</div> </div>
@@ -50,8 +48,7 @@
<div class="top-header" :style="{ background: options.right.bgColor }"> <div class="top-header" :style="{ background: options.right.bgColor }">
<span>{{ options.right.title }}</span> <span>{{ options.right.title }}</span>
<span @click="linkTo(options.right.url)" class="hover-pointer" <span @click="linkTo(options.right.url)" class="hover-pointer"
>{{ options.right.secondTitle }} &gt;</span >{{ options.right.secondTitle }} &gt;</span>
>
</div> </div>
<div class="content"> <div class="content">
<div <div
@@ -60,134 +57,236 @@
class="hover-pointer" class="hover-pointer"
@click="linkTo(item.url)" @click="linkTo(item.url)"
> >
<img :src="item.img" alt="" /> <div class="img-wrap">
<p>{{ item.name }}</p> <img :src="item.img" loading="lazy" decoding="async" alt="" />
<p>{{ item.price | unitPrice("¥") }}</p> <div class="jiaobiao" :class="'jiaobiao' + (index + 1)">
<div class="jiaobiao" :class="'jiaobiao' + (index + 1)"> {{ index + 1 }}
{{ index + 1 }} </div>
</div> </div>
<p>{{ item.name }}</p>
<p>{{ $filters.unitPrice(item.price, "¥") }}</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script> <script setup>
export default { import { ref } from 'vue'
props: {
data: { const props = defineProps({
type: Object, data: {
default: null type: Object,
} default: null
}, }
data () { })
return {
options: this.data.options // 装修数据 const options = ref(props.data.options) // 装修数据
};
},
methods: {}
};
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.new-goods { .new-goods {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: stretch;
> div { > div {
width: 393px; width: 393px;
height: 440px; height: 440px;
overflow: hidden;
} }
.left > .content { .left > .content {
> div:nth-child(1) { display: grid;
height: 240px; grid-template-columns: repeat(2, 1fr);
flex-direction: column; grid-template-rows: repeat(3, 1fr);
border: 1px solid #eee; flex-direction: unset;
border-top: none; overflow: hidden;
border-left: none;
justify-content: space-between;
img {
width: 160px;
height: 160px;
}
.describe {
margin-top: 10px;
}
}
> div:nth-child(2) {
border-right: 1px solid #eee;
}
> div:nth-child(3),
> div:nth-child(4) {
border-bottom: 1px solid #eee;
}
}
.middle > .content {
> div { > div {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
box-sizing: border-box;
display: flex;
padding: 6px 4px 4px 6px;
border-style: solid; border-style: solid;
border-color: #eee; border-color: #eee;
border-width: 0; border-width: 0;
border-bottom-width: 1px; border-bottom-width: 1px;
border-right-width: 1px;
img {
width: 64px;
height: 64px;
margin-top: 2px;
flex-shrink: 0;
object-fit: cover;
}
> div {
flex: 1;
min-width: 0;
p {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
line-height: 1.3;
}
.describe {
margin-top: 4px;
font-size: 11px;
}
}
}
> div:nth-child(1) {
grid-row: span 2;
flex-direction: column;
justify-content: space-between;
align-items: center;
text-align: center;
padding: 8px 6px;
img {
width: 120px;
height: 120px;
margin-top: 6px;
}
> div {
flex: unset;
width: 100%;
p {
white-space: normal;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.describe {
margin-top: 6px;
white-space: normal;
}
}
} }
> div:nth-child(1),
> div:nth-child(2), > div:nth-child(2),
> div:nth-child(3) { > div:nth-child(3),
> div:nth-child(5) {
border-right-width: 0;
}
> div:nth-child(4),
> div:nth-child(5) {
border-bottom-width: 0;
}
}
.middle > .content {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(3, 1fr);
flex-direction: unset;
> div {
width: 100%;
height: 100%;
min-width: 0;
overflow: hidden;
box-sizing: border-box;
border-style: solid;
border-color: #eee;
border-width: 0;
border-bottom-width: 1px;
border-right-width: 1px; border-right-width: 1px;
} }
> div:nth-child(6), > div:nth-child(2n) {
> div:nth-child(3) { border-right-width: 0;
}
> div:nth-child(n + 5) {
border-bottom-width: 0; border-bottom-width: 0;
} }
} }
.right > .content { .right > .content {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: repeat(3, 1fr);
flex-direction: row; grid-template-rows: repeat(2, 1fr);
flex-direction: unset;
flex-wrap: unset;
font-size: 12px; font-size: 12px;
overflow: hidden;
> div { > div {
position: relative; position: relative;
width: 120px; width: 100%;
padding: 5px 10px 0 10px; height: 100%;
img { min-height: 0;
width: 100px; box-sizing: border-box;
height: 100px; padding: 4px 4px 2px;
} display: flex;
flex-direction: column;
align-items: center;
overflow: hidden;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
:nth-child(2) { border-right: 1px solid #eee;
height: 38px; .img-wrap {
position: relative;
width: 80px;
height: 80px;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.jiaobiao {
position: absolute;
width: 23px;
height: 23px;
top: 0;
right: 0;
margin: 0;
padding: 0;
max-height: none;
overflow: visible;
display: block;
background: url(../../../assets/images/festival_icon.png) no-repeat;
color: #fff;
text-align: center;
line-height: 23px;
font-size: 12px;
-webkit-line-clamp: unset;
-webkit-box-orient: unset;
}
.jiaobiao1,
.jiaobiao4 {
background-position: -2px -30px;
}
.jiaobiao2,
.jiaobiao5 {
background-position: -31px -30px;
}
.jiaobiao3,
.jiaobiao6 {
background-position: -60px -30px;
}
}
> p:nth-child(2) {
width: 100%;
height: auto;
max-height: 32px;
line-height: 16px;
margin-top: 4px;
overflow: hidden; overflow: hidden;
} display: -webkit-box;
:nth-child(3) { -webkit-line-clamp: 2;
color: $theme_color; -webkit-box-orient: vertical;
margin-top: 5px;
}
.jiaobiao {
position: absolute;
width: 23px;
height: 23px;
top: 10px;
right: 16px;
background: url(../../../assets/images/festival_icon.png);
color: #fff;
text-align: center; text-align: center;
} }
.jiaobiao1, > p:nth-child(3) {
.jiaobiao4 { color: $theme_color;
background-position: -2px -30px; margin-top: 2px;
} line-height: 1.2;
.jiaobiao2, flex-shrink: 0;
.jiaobiao5 {
background-position: -31px -30px;
}
.jiaobiao3,
.jiaobiao6 {
background-position: -60px -30px;
} }
} }
> div:nth-child(4), > div:nth-child(3n) {
> div:nth-child(5), border-right: none;
> div:nth-child(6) { }
border: none; > div:nth-child(n + 4) {
border-bottom: none;
} }
} }
@@ -212,6 +311,8 @@ export default {
flex-wrap: wrap; flex-wrap: wrap;
flex-direction: column; flex-direction: column;
height: 370px; height: 370px;
box-sizing: border-box;
overflow: hidden;
} }
.con-item { .con-item {
width: 185px; width: 185px;
@@ -225,6 +326,42 @@ export default {
margin-top: 10px; margin-top: 10px;
} }
} }
.middle > .content .con-item {
width: 100%;
max-width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
box-sizing: border-box;
padding: 6px 4px 4px 6px;
img {
width: 64px;
height: 64px;
margin-top: 2px;
flex-shrink: 0;
}
> div {
flex: 1;
min-width: 0;
p {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
line-height: 1.3;
}
.describe {
margin-top: 4px;
font-size: 11px;
}
}
}
.left > .content .con-item {
width: 100%;
max-width: 100%;
height: 100%;
}
.describe { .describe {
color: #999; color: #999;
font-size: 12px; font-size: 12px;

View File

@@ -1,18 +1,20 @@
<template> <template>
<div class="not-enough" ref="obtain" id="demo"> <div class="not-enough" ref="obtain" id="demo">
<Affix :offset-top="62" @on-change="change"> <el-affix :offset-top="62" @change="change" class="nav-affix">
<ul class="nav-bar" v-show="topSearchShow"> <div class="nav-bar" v-show="topSearchShow">
<li <ul class="nav-list">
v-for="(item, index) in conData.options.navList" <li
:class="currentIndex === index ? 'curr' : ''" v-for="(item, index) in conData.options.navList"
@click="changeCurr(index)" :class="currentIndex === index ? 'curr' : ''"
:key="index" @click="changeCurr(index)"
> :key="index"
<p @click="gotoDemo">{{ item.title }}</p> >
<p @click="gotoDemo">{{ item.desc }}</p> <p @click="gotoDemo">{{ item.title }}</p>
</li> <p @click="gotoDemo">{{ item.desc }}</p>
</ul> </li>
</Affix> </ul>
</div>
</el-affix>
<div class="content" v-if="showContent"> <div class="content" v-if="showContent">
<div <div
v-for="(item, index) in conData.options.list[currentIndex]" v-for="(item, index) in conData.options.list[currentIndex]"
@@ -20,93 +22,119 @@
class="hover-pointer" class="hover-pointer"
@click="linkTo(item.url)" @click="linkTo(item.url)"
> >
<img :src="item.img" width="210" height="210" :alt="item.name" /> <img :src="item.img" loading="lazy" decoding="async" width="210" height="210" :alt="item.name" />
<p>{{ item.name }}</p> <p>{{ item.name }}</p>
<p> <p>
<span>{{ Number(item.price) | unitPrice("¥") }}</span> <span>{{ $filters.unitPrice(Number(item.price), "¥") }}</span>
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script> <script setup>
export default { import { onMounted, ref, watch } from 'vue';
mounted() { import { useRouter } from 'vue-router';
window.addEventListener('scroll',this.handleScrollx,true)
const props = defineProps({
data: {
type: Object,
default: null,
}, },
props: { });
data: {
type: Object, const emit = defineEmits(['content']);
default: null,
}, const router = useRouter();
},
data() { const obtain = ref(null);
return { const screenHeight = ref(document.body.clientHeight);
screenHeight:document.body.clientHeight, const scrollHieght = ref(0);
scrollHieght:0, const topSearchShow = ref(true); //展示 导航条
topSearchShow: true, //展示 导航条 const topIndex = ref(0); // 当前滚动条位置 (取整)
topIndex: 0, // 当前滚动条位置 (取整) const scrollTops = ref(0);
scrollTops: 0, const open = ref('');
open:'', const currentIndex = ref(0); // 当前分类下标
currentIndex: 0, // 当前分类下标 const conData = ref(props.data); // 装修数据
conData: this.data, // 装修数据 const showContent = ref(true); // 是否展示内容
showContent: true, // 是否展示内容
}; watch(
}, () => props.data,
watch: { (val) => {
data: function (val) { conData.value = val;
this.conData = val; }
}, );
conData: function (val) {
this.$emit("content", val); watch(conData, (val) => {
}, emit('content', val);
}, });
methods: {
handleScrollx(){ function handleScrollx() {
// console.log('滚动高度',window.pageYOffset) // 获取滚动条的高度 // console.log('滚动高度',window.pageYOffset) // 获取滚动条的高度
// console.log(this.$refs.obtain.getBoundingClientRect().top) //获取到距离顶部的距离 // console.log(obtain.value.getBoundingClientRect().top) //获取到距离顶部的距离
// this.scrollHieght = Number(window.pageYOffset);//获取到距离顶部的距离 // scrollHieght.value = Number(window.pageYOffset);//获取到距离顶部的距离
// this.scrollTops = Number(this.$refs.obtain.getBoundingClientRect().top); // 获取到距离顶部的距离 // scrollTops.value = Number(obtain.value.getBoundingClientRect().top); // 获取到距离顶部的距离
// this.topSearchShow = true; // 展示图钉 // topSearchShow.value = true; // 展示图钉
// if(this.scrollTops < -660){ // 超过隐藏 // if(scrollTops.value < -660){ // 超过隐藏
// this.topSearchShow = false; // topSearchShow.value = false;
// } // }
}, }
toguid(path,id){
var path =path; function toguid(path, id) {
var Id = id; var path = path;
localStorage.setItem('toId',Id); var Id = id;
this.$router.push(path); localStorage.setItem('toId', Id);
}, router.push(path);
change(status){ //获取是否获取到图钉 }
this.open = status
}, function change(status) { //获取是否获取到图钉
gotoDemo(){ // 跳转到demo的位置 open.value = status
if(this.open){ // 获取到图钉之后在跳转当前位置 }
document.querySelector("#demo").scrollIntoView(true);
} function gotoDemo() { // 跳转到demo的位置
//scrollIntoView()可以在所有的HTML元素上调用,通过滚动浏览器窗口或某个容器元素 if (open.value) { // 获取到图钉之后在跳转当前位置
}, document.querySelector("#demo").scrollIntoView(true);
changeCurr(index) { }
// 选择分类 //scrollIntoView()可以在所有的HTML元素上调用,通过滚动浏览器窗口或某个容器元素
this.currentIndex = index; }
},
}, function changeCurr(index) {
}; // 选择分类
currentIndex.value = index;
}
onMounted(() => {
window.addEventListener('scroll', handleScrollx, true)
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.not-enough {
width: 100%;
box-sizing: border-box;
}
:deep(.nav-affix.el-affix) {
width: 100%;
}
.nav-bar { .nav-bar {
display: flex;
justify-content: center;
width: 100%; width: 100%;
margin-bottom: 10px; margin-bottom: 10px;
background-color: #f8f8f8; background-color: #f8f8f8;
height: 60px; height: 60px;
box-sizing: border-box;
}
.nav-list {
display: flex;
justify-content: center;
align-items: center; align-items: center;
position: relative; width: 100%;
position: sticky; height: 100%;
position: -webkit-sticky; margin: 0;
top: 0; padding: 0;
list-style: none;
box-sizing: border-box;
li { li {
padding: 0 30px; padding: 0 30px;
text-align: center; text-align: center;
@@ -114,11 +142,15 @@ export default {
font-size: 16px; font-size: 16px;
border-radius: 50px; border-radius: 50px;
padding: 0 7px; padding: 0 7px;
margin: 0;
line-height: 1.2;
} }
p:nth-child(2) { p:nth-child(2) {
font-size: 14px; font-size: 14px;
color: #999; color: #999;
margin: 2px 0 0;
line-height: 1.2;
} }
&:hover { &:hover {
@@ -129,6 +161,7 @@ export default {
} }
border-right: 1px solid #eee; border-right: 1px solid #eee;
} }
li:last-of-type { li:last-of-type {
border: none; border: none;
} }
@@ -136,7 +169,6 @@ export default {
.curr { .curr {
p:nth-child(1) { p:nth-child(1) {
background-color: $theme_color; background-color: $theme_color;
color: #fff; color: #fff;
} }
p:nth-child(2) { p:nth-child(2) {
@@ -146,6 +178,8 @@ export default {
} }
.content { .content {
width: 100%;
box-sizing: border-box;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: space-between; justify-content: space-between;

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