42 Commits

Author SHA1 Message Date
pikachu1995@126.com
2497f33cb4 feat(gift-card): 新增提货卡类型支持
- 列表页拆分为提货卡和现金卡两个独立创建入口
- 创建页支持提货卡兑换方式配置(任选一种/全部兑换/任选N种)
- 集成 SKU 商品选择器,支持最多50个商品兑换配置
- 批次凭证页增加"已提货"状态标签及提货卡面额展示适配
- 批次凭证页新增导出卡密功能
- 重构表单布局为响应式 grid 并优化有效期选项交互样式
2026-11-06 15:20:42 +08:00
pikachu1995@126.com
0f08807f00 feat(coupon): 重构优惠券类型为满减、满折、立减三种分类
- 新增立减优惠券类型,支持无门槛直接抵扣
- 优惠券列表页统一使用行对象判断类型并显示完整类型名称
- 发布页增加优惠券类型选择组件,切换时自动联动字段显隐
- 买家端购物车满减门槛为零时显示"无门槛使用"
- 卖家端发布页新增券面实时预览面板
- 卖家端发布页重构为分步布局并优化表单交互样式
- 面额与消费门槛校验增加大于零的数值约束
- 移除列表页冗余的标签颜色映射函数,简化展示逻辑
2026-11-05 22:36:21 +08:00
pikachu1995@126.com
29a6b00a21 fix(setting): 保留提现金额与开关的真实类型 2026-11-05 21:31:22 +08:00
pikachu1995@126.com
6be934d00b feat(member): 优化等级权益管理与买家展示
- 优化客户权益配置、编辑表单及独立发放明细页面
- 完善客户等级图标、背景配置提示与 PC 权益展示
- 复用图片选择器并补齐素材元数据回传
2026-11-05 21:27:37 +08:00
pikachu1995@126.com
560286b7f6 refactor: 移除停用促销、批发和短信管理界面 2026-11-05 15:20:38 +08:00
pikachu1995@126.com
961565149c feat(manager): 新增运营模块小程序订单管理
固定小程序来源及微信支付筛选,复用订单列表和详情,新增微信订单自动上报开关并更新升级计划。
2026-11-04 15:44:54 +08:00
pikachu1995@126.com
1a457a60f8 fix(buyer): 补齐领券中心页头并处理秒杀空数据 2026-11-04 14:51:13 +08:00
pikachu1995@126.com
d521587ad8 feat(coupon): 支持立减优惠券并统一营销管理页面 2026-11-04 14:50:33 +08:00
pikachu1995@126.com
cbaf89c835 feat(goods): 完善商品定时上下架功能
- 新增定时下架支持,商品列表同时展示上架和下架计划
- 添加定时计划的编辑和删除操作入口
- 新增取消定时上下架API接口
- 商品选择对话框支持商品模式,适配SPU维度选择
- 商品选择对话框增加高级筛选折叠和品牌联动逻辑
- 优化商品列表筛选布局为弹性自适应宽度
- 管理端定时计划页面增加定时下架标签页
- 商家端定时计划页面重构为完整CRUD抽屉交互
- 修改楼层列表新建逻辑改为路由跳转装修页
2026-11-04 11:07:37 +08:00
pikachu1995@126.com
f6f106e12b chore(license): 将项目许可证 更换为 Apache-2.0
- 将 LICENSE 文件内容替换为 Apache License 2.0 完整文本
2026-11-03 17:47:37 +08:00
pikachu1995@126.com
484b6a6dad feat: 优化商品促销页面并独立展示定时上架
- 新增平台、商家定时上架独立页面,平台列表仅供查看
- 虚拟销量支持状态筛选及右侧抽屉设置,修复编码显示问题
- 统一列表操作栏,优化商品选择器、砍价、积分及优惠券页面
- 两端新增活动优惠券入口,隐藏创建表单中的活动类型字段
- 移除限时直降、第 N 件优惠页面、路由、接口调用及缓存菜单入口

验证:26 个改动前端文件编译或语法检查通过,暂存差异格式检查通过。
2026-11-03 16:29:13 +08:00
pikachu1995@126.com
d64dd08b42 refactor(goods-setting): 移除图片尺寸配置并新增自营店商品审核开关
- 移除缩略图、小图、原图宽高配置项及相关校验规则
- 新增自营店商品审核独立开关(selfOperatedGoodsCheck)
- 统一商品审核开关值为字符串类型 true/false
- 更新商品审核描述文案为非自营店商品审核说明
- 清理README中已移除功能的描述条目
2026-11-03 11:16:06 +08:00
pikachu1995@126.com
5300988643 feat(seller): align PC decoration editor and remove legacy components 2026-11-03 11:11:57 +08:00
pikachu1995@126.com
fb99824a3a refactor(site-settings): 统一站点配置管理并清理废弃页面
- 新增 useSiteSettings composable 替代各端硬编码的配置引用
- 移除 memberRecycle 和 fictitiousOrderList 废弃页面并增加缓存过滤
- 版权信息改为通过接口动态获取,后台新增版权归属配置字段
- 全局禁止 textarea 拖拽缩放并统一三端样式规则
- 会员列表增加状态 tab 筛选及启用/禁用双向切换功能
- 删除未使用的 getSpecificationList API 方法
2026-11-03 09:14:05 +08:00
pikachu1995@126.com
22f3e9b5e9 refactor(after-sale): 重构售后单详情页面为现代化布局
- 将页面从简单的 dl/dd 结构重构为基于 Element Plus 的卡片式两栏布局
- 新增顶部导航栏与状态看板,集成可视化流程步骤条
- 使用 el-descriptions 和 el-form 替代原有手写表单结构
- 添加动态步骤计算逻辑,支持仅退款和退货退款两种流程
- 新增复制单号、卡号格式化、跳转关联订单等辅助功能
- 物流弹窗改用 el-timeline 展示轨迹,优化请求时机为数据加载后打开
- 完善表单校验与操作 loading 状态,增加异常捕获处理
- 样式改为 scoped 并全面升级视觉设计
2026-11-02 16:53:49 +08:00
pikachu1995@126.com
7db1ee4fb7 feat(manager): 表单弹窗统一改为右抽屉,文章添加/编辑改为独立页面,支付方式汇总显示中文名称 2026-11-02 16:29:19 +08:00
pikachu1995@126.com
4e33ab19b6 feat(manager): 短信模板配置弹窗补充变量命名约束提示 2026-11-02 15:49:01 +08:00
pikachu1995@126.com
ce11f50942 feat(pc-decorate): 优化PC楼层装修组件自适应高度、支持可视化热区图绘制及编辑器交互修复
- fix(editor): 修复点击返回页面列表 autoSaveTimer is not defined 运行时错误,优化回退逻辑
- feat(hotzone): PC 端热区图接入可视化组件,支持拖拽画框、拉伸缩放与百分比自适应
- fix(floor-height): 商品宫格、宫格楼层、横幅楼层、商品条等默认行高改为自适应撑开,防止商品溢出截断
- fix(video): 修正视频组件默认行高与播放器高度对齐为 450px,避免底部遮挡
- fix(freelayout): 统一自由排版默认高度与后台画布高度为 450px,消除底部空白
- feat(adaptive): 频道宫格、品牌墙、优惠券等集合类组件支持动态行高与 autoCompTypes 弹性包裹
- refactor(preset): 优化双端 double11Preset 预设模板,清理遗留固定行高
2026-10-30 09:27:21 +08:00
pikachu1995@126.com
ccf243f31a feat(order): 优化订单详情页布局并重构标准A4出库发货单 2026-10-30 07:22:16 +08:00
pikachu1995@126.com
c0cc8d8598 feat(manager): 优化列表操作列规范、会员分组统计、漏斗图重构与订单交互
1. 订单交互:移除 orderList 缓存机制修复默认带入订单号问题,支持 query 联动;

2. 会员模块:会员分组新增人数统计与联动,全量规范会员提现、充值、日志等操作列样式;

3. 商品与销量:虚拟销量优化首列缩略图与对齐方式,规范品牌与商品分组操作;

4. 数据统计:重构交易转化漏斗图为自适应 SVG 渐变图与指标卡片;

5. 系统与营销:统一菜单/部门操作列样式,优化 OSS 模块、营销活动与首页待办对齐。
2026-10-30 06:51:48 +08:00
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
424 changed files with 85057 additions and 23597 deletions

216
LICENSE
View File

@@ -1,21 +1,205 @@
MIT License Apache License, Version 2.0
Copyright (c) 2020-2026 Beijing Hongye Huicheng Technology Co., Ltd. (北京宏业汇成科技有限公司) Copyright (c) 2020-2026 Beijing Hongye Huicheng Technology Co., Ltd. (北京宏业汇成科技有限公司)
Permission is hereby granted, free of charge, to any person obtaining a copy Apache License
of this software and associated documentation files (the "Software"), to deal Version 2.0, January 2004
in the Software without restriction, including without limitation the rights http://www.apache.org/licenses/
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 1. Definitions.
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE "License" shall mean the terms and conditions for use, reproduction,
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER and distribution as defined by Sections 1 through 9 of this document.
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE "Licensor" shall mean the copyright owner or entity authorized by
SOFTWARE. the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -5,7 +5,7 @@
<p> <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://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="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="./LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-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://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://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> <a href="https://pinia.vuejs.org/"><img src="https://img.shields.io/badge/Pinia-4.x-yellow.svg" alt="Pinia" /></a>
@@ -28,7 +28,7 @@
**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. **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 ### ✨ Why Choose Lilishop
- 🟢 **True MIT License**: Free for commercial use and private secondary development without licensing concerns. - 🟢 **True Apache-2.0 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. - 🚀 **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. - 🧩 **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.
@@ -141,4 +141,4 @@ lilishop-ui
## 6. License ## 6. License
Lilishop is licensed under the [MIT](./LICENSE) License. Copyright © 2020-2026 **Beijing Hongye Huicheng Technology Co., Ltd.** Lilishop is licensed under the [Apache-2.0](./LICENSE) License. Copyright © 2020-2026 **Beijing Hongye Huicheng Technology Co., Ltd.**

View File

@@ -5,7 +5,7 @@
<p> <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://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="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="./LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-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://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://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> <a href="https://pinia.vuejs.org/"><img src="https://img.shields.io/badge/Pinia-4.x-yellow.svg" alt="Pinia" /></a>
@@ -28,7 +28,7 @@
**LILISHOP** 是基于 Spring Boot / Vue 3 / uni-app 开发的开源商城系统,支持 B2B2C 多商户商城、微信小程序商城、H5 商城、APP 商城、直播电商、分销返佣、秒杀活动。 **LILISHOP** 是基于 Spring Boot / Vue 3 / uni-app 开发的开源商城系统,支持 B2B2C 多商户商城、微信小程序商城、H5 商城、APP 商城、直播电商、分销返佣、秒杀活动。
### ✨ 为什么选择 Lilishop ### ✨ 为什么选择 Lilishop
- 🟢 **真 MIT 协议**:支持免费商用与私有化闭源二开,无版权后顾之忧。 - 🟢 **真 Apache-2.0 协议**:支持免费商用与私有化闭源二开,无版权后顾之忧。
- 🚀 **Vue 3 全新栈**:全面基于 `Vue 3.5 + Pinia 4 + Vue Router 4 + Element Plus` 构建,告别老旧 Vue 2 架构。 - 🚀 **Vue 3 全新栈**:全面基于 `Vue 3.5 + Pinia 4 + Vue Router 4 + Element Plus` 构建,告别老旧 Vue 2 架构。
- 🧩 **四端合一生产级闭环**:完整覆盖 **PC 买家端 (`buyer`)**、**商家端 (`seller`)**、**平台运营管理端 (`manager`)** 与 **在线客服端 (`im`)**,支持拖拽式可视化楼层装修与多维数据统计报表。 - 🧩 **四端合一生产级闭环**:完整覆盖 **PC 买家端 (`buyer`)**、**商家端 (`seller`)**、**平台运营管理端 (`manager`)** 与 **在线客服端 (`im`)**,支持拖拽式可视化楼层装修与多维数据统计报表。
@@ -146,7 +146,7 @@ lilishop-ui
## 6. 开源与授权 ## 6. 开源与授权
本项目遵循 [MIT](./LICENSE) 开源协议,版权归 **北京宏业汇成科技有限公司** 所有。允许个人及企业免费用于学习、研究、修改和商业用途(需保留原始版权和许可声明)。 本项目遵循 [Apache-2.0](./LICENSE) 开源协议,版权归 **北京宏业汇成科技有限公司** 所有。允许个人及企业免费用于学习、研究、修改和商业用途(需保留原始版权和许可声明)。
--- ---
@@ -163,19 +163,44 @@ lilishop-ui
## 8. 升级计划 ## 8. 升级计划
### ✅ 已完成特性 ### ✅ 已完成特性
| 功能模块 | 功能模块 |
| :--- | :--- | 按可核实的实现或完善时间倒序记录;日期表示代码完成或功能更新时间,不代表生产上线时间。
| ✅ 商品定时上下架 | ✅ 商品分组 / 会员分组 |
| ✅ 商品虚拟销量 | ✅ 专用发票(发票上传) | | 时间 | 功能模块 | 完成内容 |
| ✅ 置顶评论 | ✅ 客户等级体系 | | :--- | :--- | :--- |
| ✅ 购物卡 | ✅ 腾讯云-云直播 | | 2026-11-05 | ✅ 付费会员 | 单一付费会员、开通与续费、有效期、关联权益、商家与商品折扣、平台补贴及付费记录。 |
| ✅ 卡密商品 | ✅ 二级分销 | | 2026-11-04 | ✅ 客户等级体系 / 客户权益 | 等级配置、权益介绍、经验值任务与明细、权益发放及重试,提供默认等级与权益数据。 |
| ✅ 定时任务管理 | ✅ 预约服务商品 | | 2026-11-04 | ✅ 小程序订单管理 | 增加小程序订单查询入口及微信订单自动上报开关。 |
| ✅ 主题色管理 | ✅ 商家发货改造(整单/拆单/无需配送) | | 2026-11-04 | ✅ 立减优惠券 | 新增立减券,完善优惠券校验、领取与订单优惠计算。 |
| ✅ 多维统计(营业/商品/会员/积分/储值/营销/分销) | ✅ 经营报表(店铺业绩/商品同比环比/订单明细/销售汇总) | | 2026-11-04 | ✅ 商品定时上下架 | 完善定时上下架计划管理、独立列表及执行校验。 |
| 2026-11-04 | ✅ 微信小程序原生能力 | 接入微信地址导入、头像选择和昵称填写。 |
| 2026-11-02 | ✅ 消息模板 | 支持多渠道模板矩阵维护,统一站内信、短信、微信公众号及小程序订阅消息的场景配置和异步发送。 |
| 2026-10-30 | ✅ PC 页面装修 | 重构楼层组件,支持自适应高度、可视化热区绘制及配置交互优化。 |
| 2026-10-29 | ✅ 定时任务管理 | 重构为数据库配置驱动,支持运营后台维护任务。 |
| 2026-10-29 | ✅ 敏感词管理 | 支持按业务场景配置过滤开关,并优化词库加载。 |
| 2026-10-29 | ✅ PC 店铺搜索 | 新增店铺列表,支持商品与店铺双模式搜索。 |
| 2026-10-22 | ✅ 分类设置 | 支持四种分类页模板选择、商品列表展示字段开关及模板图片预览。 |
| 2026-10-20 | ✅ 移动端页面装修 | 完善可视化编辑器,新增首页组合组件及优惠券、店铺、文章等楼层。 |
| 2026-10-09 | ✅ 主题色管理 | 完善主题设置,支持自定义模式。 |
| 2026-10-02 | ✅ 预约服务商品 | 完善预约商品、预约交易及订单详情功能。 |
| 2026-08-05 | ✅ 二级分销 | 由一级分销扩展为二级分销,增加节点配置及分销员中心。 |
| 2026-08-03 | ✅ 卡密商品 | 完善电子卡券履约、订单状态及卡池管理。 |
| 2026-07-23 | ✅ 素材库管理 | 增加文件分类、目录管理及目录归属校验。 |
| 2026-07-20 | ✅ 经营统计报表 | 新增店铺业绩、商品同比环比、销售订单明细及商品销售汇总报表。 |
| 2026-07-13 | ✅ 腾讯云直播 | 接入并完善直播、聊天与直播商品相关功能。 |
| 2026-06-30 | ✅ 移动端技术升级 | 升级 Vue 3 与 uView Plus,适配组件和页面。 |
| 2026-06-29 | ✅ 电子面单发货 | 完善快递鸟电子面单,支持自动携带运单号发货。 |
| 2026-06-05 | ✅ 后端与管理端框架升级 | 5 月 25 日至 6 月 5 日完成 Spring Boot 4、管理端 Vue 3 与 Element Plus 升级。 |
| 2026-05-22 | ✅ 购物卡 | 新增礼品卡接口,以及选择、绑定、激活和列表管理。 |
| 2026-05-20 | ✅ 微信提现 | 完善转账状态查询、确认回调和重试处理,后续补充提现规则说明配置。 |
| 2026-04-16 | ✅ 置顶评论 | 支持评论置顶及商品评价管理。 |
| 2026-04-13 | ✅ 专用发票(发票上传) | 完善发票信息字段、发票详情及管理展示。 |
| 2026-04-01 | ✅ 商品虚拟销量 | 支持按商品规格设置虚拟销量,提供单个及批量设置入口。 |
| 2026-03-30 | ✅ 商品分组 | 新增商品分组管理及商品关联功能。 |
| 2026-03-24 | ✅ 会员分组 | 新增会员分组管理。 |
### 🔜 近期规划中 ### 🔜 近期规划中
- 🔜 店铺结算汇总 & 结算台账
- 🔜 支付方式汇总 & 钱包流水 & 财务汇总
- 🔜 微信小店对接 - 🔜 微信小店对接
- 🔜 抖店对接 - 🔜 抖店对接
- 🔜 微信小程序原生重构与体验对齐(持续完善;已接入预约交易、分类多模板与首页楼层装修)
- 🔜 OpenAPI 开放平台与开发者中心

40
buyer/pnpm-lock.yaml generated
View File

@@ -36,6 +36,9 @@ importers:
nprogress: nprogress:
specifier: ^0.2.0 specifier: ^0.2.0
version: 0.2.0 version: 0.2.0
pinia:
specifier: ^4.0.3
version: 4.1.0(@vue/devtools-api@6.6.4)(vue@3.5.43)
psl: psl:
specifier: ^1.8.0 specifier: ^1.8.0
version: 1.15.0 version: 1.15.0
@@ -60,9 +63,6 @@ importers:
vue-router: vue-router:
specifier: ^4.6.4 specifier: ^4.6.4
version: 4.6.4(vue@3.5.43) version: 4.6.4(vue@3.5.43)
vuex:
specifier: ^4.1.0
version: 4.1.0(vue@3.5.43)
devDependencies: devDependencies:
'@vue/cli-plugin-babel': '@vue/cli-plugin-babel':
specifier: ^5.0.9 specifier: ^5.0.9
@@ -91,6 +91,9 @@ importers:
less-loader: less-loader:
specifier: ^11.1.4 specifier: ^11.1.4
version: 11.1.4(less@4.9.1)(webpack@5.111.0(postcss@8.5.28)) version: 11.1.4(less@4.9.1)(webpack@5.111.0(postcss@8.5.28))
vue-eslint-parser:
specifier: ^10.4.1
version: 10.4.1(eslint@9.39.4)
webpack: webpack:
specifier: ^5.95.0 specifier: ^5.95.0
version: 5.111.0(cssnano@5.1.15(postcss@8.5.28))(postcss@8.5.28) version: 5.111.0(cssnano@5.1.15(postcss@8.5.28))(postcss@8.5.28)
@@ -2968,6 +2971,9 @@ packages:
normalize-wheel-es@1.2.0: normalize-wheel-es@1.2.0:
resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==} resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==}
nostics@1.3.0:
resolution: {integrity: sha512-wzRsrJwNlFbqIRQt8aCeL2WE8USip/uuvGSOZV1D02CxX8ABTMbw8IywKjH7yWHETMXbWJI4OBpihg9rmz2Vyg==}
npm-run-path@2.0.2: npm-run-path@2.0.2:
resolution: {integrity: sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==} resolution: {integrity: sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -3135,6 +3141,16 @@ packages:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'} engines: {node: '>=12'}
pinia@4.1.0:
resolution: {integrity: sha512-+5/HMuVjYQfKlCD5xQ7zfXn1xUzYFEa7pd0DQK5SaMfZCaFRTHlaH/c02N1FoGOdlzSiwf5EOPgJx//1QDw6gw==}
peerDependencies:
'@vue/devtools-api': ^8.2.1
typescript: '>=5.6.0'
vue: ^3.5.11
peerDependenciesMeta:
typescript:
optional: true
pkg-dir@4.2.0: pkg-dir@4.2.0:
resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -4006,11 +4022,6 @@ packages:
typescript: typescript:
optional: true optional: true
vuex@4.1.0:
resolution: {integrity: sha512-hmV6UerDrPcgbSy9ORAtNXDr9M4wlNP4pEFKye4ujJF8oqgFFuxDCdOLS3eNoRTtq5O3hoBDh9Doj1bQMYHRbQ==}
peerDependencies:
vue: ^3.2.0
watchpack@2.5.2: watchpack@2.5.2:
resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==} resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
@@ -7374,6 +7385,8 @@ snapshots:
normalize-wheel-es@1.2.0: {} normalize-wheel-es@1.2.0: {}
nostics@1.3.0: {}
npm-run-path@2.0.2: npm-run-path@2.0.2:
dependencies: dependencies:
path-key: 2.0.1 path-key: 2.0.1
@@ -7533,6 +7546,12 @@ snapshots:
picomatch@4.0.7: picomatch@4.0.7:
optional: true optional: true
pinia@4.1.0(@vue/devtools-api@6.6.4)(vue@3.5.43):
dependencies:
'@vue/devtools-api': 6.6.4
nostics: 1.3.0
vue: 3.5.43
pkg-dir@4.2.0: pkg-dir@4.2.0:
dependencies: dependencies:
find-up: 4.1.0 find-up: 4.1.0
@@ -8449,11 +8468,6 @@ snapshots:
'@vue/server-renderer': 3.5.43 '@vue/server-renderer': 3.5.43
'@vue/shared': 3.5.43 '@vue/shared': 3.5.43
vuex@4.1.0(vue@3.5.43):
dependencies:
'@vue/devtools-api': 6.6.4
vue: 3.5.43
watchpack@2.5.2: watchpack@2.5.2:
dependencies: dependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11

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
}); });
} }

View File

@@ -0,0 +1,13 @@
/**
* 提货卡选品、试算及一次兑换接口。
* @author Bulbasaur
* @since 2026-11-05
*/
import request, { Method } from '@/plugins/request.js';
/** 查询当前会员的卡规则及候选 SKU。 */
export const getPickupCard = id => request({ url: `/buyer/promotion/pickupCard/${encodeURIComponent(id)}`, method: Method.GET, needToken: true });
/** 服务端校验商品与地址,返回零现金试算。 */
export const previewPickupCard = data => request({ url: '/buyer/promotion/pickupCard/preview', method: Method.POST, needToken: true, headers: { 'Content-Type': 'application/json' }, data });
/** 一次兑换;重试返回同一卡已创建的交易。 */
export const redeemPickupCard = data => request({ url: '/buyer/promotion/pickupCard/redeem', method: Method.POST, needToken: true, headers: { 'Content-Type': 'application/json' }, data });

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
}) })
@@ -52,6 +53,16 @@ export function getStoreById (id) {
} }
// 店铺详情 // 店铺详情
// 获取店铺工商/营业执照信息
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}`,

View File

@@ -1,3 +1,8 @@
/* 全局禁止多行输入框拖动缩放,覆盖组件生成的内联 resize 样式。 */
.el-textarea__inner {
resize: none !important;
}
:root { :root {
--el-color-primary: var(--theme-color, #ff3c2a); --el-color-primary: var(--theme-color, #ff3c2a);
--el-color-danger: var(--theme-color, #ff3c2a); --el-color-danger: var(--theme-color, #ff3c2a);

View File

@@ -6,7 +6,7 @@
v-model="searchData" v-model="searchData"
size="large" size="large"
class="search-input" class="search-input"
placeholder="输入你想查找的商品" :placeholder="placeholderText"
@focus="openPanel" @focus="openPanel"
@input="queueSuggest" @input="queueSuggest"
@keyup.enter="search" @keyup.enter="search"
@@ -37,12 +37,22 @@
@click="$router.push('/')" @click="$router.push('/')"
/> />
<div class="search-box"> <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 class="search"> <div class="search">
<el-input <el-input
v-model="searchData" v-model="searchData"
size="large" size="large"
class="search-input" class="search-input"
placeholder="输入你想查找的商品" :placeholder="placeholderText"
@focus="openPanel" @focus="openPanel"
@input="queueSuggest" @input="queueSuggest"
@keyup.enter="search" @keyup.enter="search"
@@ -165,7 +175,27 @@ const promotionTags = computed(() => {
return parseHotWords(appStore.hotWordsList) 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(() => { const showPanel = computed(() => {
// 店铺搜索暂无联想/历史,不展示下拉面板
if (isStoreMode.value) return false
if (!panelVisible.value) return false if (!panelVisible.value) return false
if (trimmedKeyword.value) return true if (trimmedKeyword.value) return true
return history.value.length > 0 || promotionTags.value.length > 0 return history.value.length > 0 || promotionTags.value.length > 0
@@ -179,6 +209,8 @@ watch(
) )
function selectTags(item) { function selectTags(item) {
// 热词/历史均为商品关键词,点击时按商品搜索
searchType.value = 'goods'
searchData.value = item searchData.value = item
search() search()
} }
@@ -208,6 +240,7 @@ function onDocumentClick(event) {
} }
function queueSuggest() { function queueSuggest() {
if (isStoreMode.value) return
panelVisible.value = true panelVisible.value = true
clearTimeout(suggestTimer) clearTimeout(suggestTimer)
const keyword = trimmedKeyword.value const keyword = trimmedKeyword.value
@@ -254,6 +287,14 @@ function search() {
const keyword = trimmedKeyword.value const keyword = trimmedKeyword.value
searchData.value = keyword searchData.value = keyword
closePanel() closePanel()
if (isStoreMode.value) {
if (route.path === '/storeList') {
emit('search', keyword)
} else {
router.push({ path: '/storeList', query: keyword ? { keyword } : {} })
}
return
}
if (keyword) remember(keyword) if (keyword) remember(keyword)
const url = route.path const url = route.path
if (url == '/goodsList') { if (url == '/goodsList') {
@@ -261,7 +302,7 @@ function search() {
} else { } else {
const pushData = { const pushData = {
path: '/goodsList', path: '/goodsList',
query: { keyword }, query: keyword ? { keyword } : {},
} }
if (props.storeId) pushData.query.storeId = props.storeId if (props.storeId) pushData.query.storeId = props.storeId
@@ -346,9 +387,8 @@ onUnmounted(() => {
width: 100%; width: 100%;
:deep(.el-input__wrapper) { :deep(.el-input__wrapper) {
border: 1.4px solid $theme_color; border: 2px solid #409EFF;
box-sizing: border-box; border-radius: 4px;
border-radius: 19.6px;
padding-left: 26px; padding-left: 26px;
padding-right: calc(#{$btn-gap * 2} + #{$btn-width}); padding-right: calc(#{$btn-gap * 2} + #{$btn-width});
font-size: 14px; font-size: 14px;
@@ -365,15 +405,20 @@ onUnmounted(() => {
top: $btn-gap; top: $btn-gap;
bottom: $btn-gap; bottom: $btn-gap;
width: $btn-width; width: $btn-width;
border-radius: calc((#{$input-height} - #{$btn-gap * 2}) / 2); border-radius: 0 3px 3px 0;
cursor: pointer; cursor: pointer;
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1; z-index: 1;
background-color: $theme_color; background-color: #409EFF;
color: #ffffff; color: #ffffff;
transition: background 0.2s;
&:hover {
background-color: #66b1ff;
}
} }
} }
@@ -384,6 +429,38 @@ onUnmounted(() => {
.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;

View File

@@ -39,7 +39,7 @@
<div> <div>
<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 === '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 > 0 ? '满' + coupon.consumeThreshold + '元可用' : '无门槛使用' }}</span>
</div> </div>
<p>使用范围:{{useScope(coupon.scopeType, coupon.storeName)}}</p> <p>使用范围:{{useScope(coupon.scopeType, coupon.storeName)}}</p>
<p>有效期:{{coupon.endTime}}</p> <p>有效期:{{coupon.endTime}}</p>

View File

@@ -43,25 +43,20 @@
</div> </div>
<div class="information footer-bottmom"> <div class="information footer-bottmom">
<div class="icp"> <div class="icp">
<li v-if="config.icpCard"> <li v-if="icp" class="footer-bottmom">
<a :href='"https://tsm.miit.gov.cn/dxxzsp/xkz/xkzgl/resource/qiyesearch.jsp?num="+config.icpCard+"&type=xuke"' target="_blank">
{{config.icpCard}}
</a>
</li>
<li v-if="config.icpMessage" class="footer-bottmom">
<a href="https://beian.miit.gov.cn/" target="_blank"> <a href="https://beian.miit.gov.cn/" target="_blank">
{{config.icpMessage}} {{icp}}
</a> </a>
</li> </li>
</div> </div>
<a class="flex " :href="config.company.href"> <a v-if="companyName" class="flex " :href="companyUrl || undefined">
<img class="zhizhao" src="@/assets/images/zhizhao.jpg" mode="" />{{config.company.name}} <img class="zhizhao" src="@/assets/images/zhizhao.jpg" mode="" />{{companyName}}
</a> </a>
</div> </div>
<div class="clearfix"></div> <div class="clearfix"></div>
<div class="copyright"> <div class="copyright">
<p>Copyright © {{year}} {{config.title}}</p> <p>Copyright © {{year}} {{copyrightOwner}}</p>
</div> </div>
</div> </div>
</footer> </footer>
@@ -71,11 +66,12 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import config from '@/config' import { useSiteSettings } from '@/composables/useSiteSettings'
defineOptions({ name: 'Footer' }) defineOptions({ name: 'Footer' })
const router = useRouter() const router = useRouter()
const { companyName, companyUrl, icp, copyrightOwner } = useSiteSettings()
const guideArr = ref([ const guideArr = ref([
// 导航链接 // 导航链接

View File

@@ -1,11 +1,13 @@
<template> <template>
<div class="goods-thumb" ref="rootEl" :class="{ 'is-loaded': loaded, 'is-error': error }"> <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-if="currentSrc" :src="currentSrc" :alt="alt" @load="loaded = true" @error="onError" />
<img v-else-if="error" :src="defaultImg" :alt="alt || '默认商品图片'" class="default-img" />
</div> </div>
</template> </template>
<script setup> <script setup>
import { onBeforeUnmount, onMounted, ref, watch } from "vue"; import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import defaultImg from "@/assets/images/default.png";
defineOptions({ name: "GoodsThumb" }); defineOptions({ name: "GoodsThumb" });
@@ -26,6 +28,21 @@ const loaded = ref(false);
const error = ref(false); const error = ref(false);
let observer = null; 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( watch(
() => props.src, () => props.src,
() => { () => {
@@ -45,7 +62,7 @@ function observe() {
currentSrc.value = ""; currentSrc.value = "";
loaded.value = false; loaded.value = false;
error.value = false; error.value = false;
if (!props.src) { if (!isValidUrl(props.src)) {
error.value = true; error.value = true;
return; return;
} }
@@ -109,6 +126,13 @@ onBeforeUnmount(() => {
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
display: block; display: block;
&.default-img {
object-fit: contain;
padding: 14px;
box-sizing: border-box;
opacity: 0.55;
}
} }
@keyframes goods-thumb-loading { @keyframes goods-thumb-loading {

View File

@@ -3,27 +3,44 @@
<div class="item-detail-show"> <div class="item-detail-show">
<!-- 详情左侧展示数据、图片,收藏、举报 --> <!-- 详情左侧展示数据、图片,收藏、举报 -->
<div class="item-detail-left"> <div class="item-detail-left">
<!-- 大图、放大镜 --> <!-- 大图、放大镜(视频商品首项为视频) -->
<!-- <div id="dplayer"></div> --> <div class="item-detail-big-img" v-if="mediaList[mediaIndex]">
<div class="item-detail-big-img" v-if="imgList[imgIndex]">
<el-image <el-image
v-show="!isCurrentVideo"
:src="currentImg" :src="currentImg"
fit="cover" fit="cover"
style="width: 100%; height: 100%" style="width: 100%; height: 100%"
/> />
<div v-show="isCurrentVideo" class="goods-video-wrap">
<div class="goods-video-bg" :style="videoBgStyle"></div>
<div
v-show="hasVideo"
id="goodsVideoPlayer"
class="goods-video-player"
></div>
</div>
</div> </div>
<!-- <div id="dplayer"></div> -->
<div class="item-detail-img-row"> <div class="item-detail-img-row">
<div <div
class="item-detail-img-small" class="item-detail-img-small"
:class="{ 'is-active': imgIndex === index }" :class="{ 'is-active': mediaIndex === index }"
@mouseover="imgIndex = index" @mouseover="switchMedia(index)"
v-for="(item, index) in imgList" v-for="(item, index) in mediaList"
:key="index" :key="index"
> >
<img :src="item.url || item"/> <video
v-if="item.type === 'video'"
class="video-thumb"
:src="item.url"
muted
preload="metadata"
playsinline
></video>
<img v-else :src="mediaThumb(item)"/>
<span v-if="item.type === 'video'" class="video-badge">
<img :src="playIcon" alt="" />
</span>
</div> </div>
</div> </div>
@@ -70,35 +87,7 @@
</div> </div>
<!-- 商品原价 --> <!-- 商品原价 -->
<div class="item-price-row" v-else> <div class="item-price-row" v-else>
<!-- 批发价格 --> <span class="item-price">{{ $filters.unitPrice(skuDetail.price, "¥") }}</span>
<div v-if="!isECouponGoods && wholesaleNum && wholesaleNum.length">
<div class="flex">
<div class="item-price-title"></div>
<div
v-for="(item, index) in wholesalePrice"
:key="index"
class="item-price item-num"
>
{{ $filters.unitPrice(item, "¥") }}
</div>
</div>
<div class="flex">
<div class="item-price-title">起批量</div>
<div
v-for="(item, index) in wholesaleNum"
:key="index"
class="item-num item-price-num"
>
{{ item }}{{ skuDetail.goodsUnit }}
</div>
</div>
</div>
<!-- 普通价格 -->
<div v-else>
<span class="item-price">{{ $filters.unitPrice(skuDetail.price, "¥") }}</span>
</div>
</div> </div>
<!-- 优惠券展示 --> <!-- 优惠券展示 -->
<div class="item-price-coupon-row" v-if="promotionMap['COUPON'].length"> <div class="item-price-coupon-row" v-if="promotionMap['COUPON'].length">
@@ -111,7 +100,7 @@
@click="handleReceiveCoupon(item.id)" @click="handleReceiveCoupon(item.id)"
> >
<span v-if="item.couponType == 'PRICE'" <span v-if="item.couponType == 'PRICE'"
>满{{ item.consumeThreshold }}减{{ item.price }}</span> >{{ item.consumeThreshold > 0 ? '满' + item.consumeThreshold + '减' + item.price : '无门槛,立减' + item.price + '元' }}</span>
<span v-if="item.couponType == 'DISCOUNT'" <span v-if="item.couponType == 'DISCOUNT'"
>满{{ item.consumeThreshold }}打{{ >满{{ item.consumeThreshold }}打{{
item.couponDiscount item.couponDiscount
@@ -128,7 +117,7 @@
@click="handleReceiveCoupon(item.id)" @click="handleReceiveCoupon(item.id)"
> >
<span v-if="item.couponType == 'PRICE'" <span v-if="item.couponType == 'PRICE'"
>满{{ item.consumeThreshold }}减{{ item.price }}</span> >{{ item.consumeThreshold > 0 ? '满' + item.consumeThreshold + '减' + item.price : '无门槛,立减' + item.price + '元' }}</span>
<span v-if="item.couponType == 'DISCOUNT'" <span v-if="item.couponType == 'DISCOUNT'"
>满{{ item.consumeThreshold }}打{{ >满{{ item.consumeThreshold }}打{{
item.couponDiscount item.couponDiscount
@@ -138,26 +127,6 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 限时折扣 -->
<div class="item-price-row" v-if="promotionMap['DISCOUNT']">
<p>
<span class="item-price-title">促销</span>
<span class="item-promotion">限时折扣</span>
<span class="item-desc-pintuan" v-if="promotionMap['DISCOUNT'].discount">
享 {{ promotionMap["DISCOUNT"].discount }} 折
</span>
</p>
</div>
<!-- 第N件优惠 -->
<div class="item-price-row" v-if="promotionMap['NTH']">
<p>
<span class="item-price-title">促销</span>
<span class="item-promotion">第N件</span>
<span class="item-desc-pintuan">
第 {{ promotionMap["NTH"].nthNum || promotionMap["NTH"].num }} 件优惠
</span>
</p>
</div>
<!-- 拼团 --> <!-- 拼团 -->
<div class="item-price-row" v-if="promotionMap['PINTUAN']"> <div class="item-price-row" v-if="promotionMap['PINTUAN']">
<p> <p>
@@ -303,23 +272,17 @@
show-icon show-icon
:title="eCouponAfterSaleHint()" :title="eCouponAfterSaleHint()"
/> />
<el-tooltip content="观看视频" v-if="skuDetail.goodsVideo">
<img class="view-video" @click="showGoodsVideo = true" :src="playIcon" alt="">
</el-tooltip>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<el-dialog title="浏览视频" v-model="showGoodsVideo">
<div id="dplayer"></div>
</el-dialog>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, watch, onMounted } from "vue"; import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { StarFilled } from '@element-plus/icons-vue'; import { StarFilled } from '@element-plus/icons-vue';
@@ -362,13 +325,11 @@ const emit = defineEmits(["handleClickSku"]);
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const showGoodsVideo = ref(false);
const goodsVideo = ref("");
const wholesaleList = ref([]);
const count = ref(1); // 商品数量 const count = ref(1); // 商品数量
const imgIndex = ref(0); // 展示图片下标 const mediaIndex = ref(0); // 展示媒体下标(相册图片/视频)
const currentSelceted = ref([]); // 当前商品sku const currentSelceted = ref([]); // 当前商品sku
const imgList = ref([]); // 商品图片列表 const mediaList = ref([]); // 媒体列表:视频项在前,其后为图片字符串
let dp = null; // DPlayer 实例,有视频时进页即创建(静音自动播放)
const skuDetail = ref({ const skuDetail = ref({
specList: [], specList: [],
}); // sku详情 }); // sku详情
@@ -384,26 +345,25 @@ const skuList = ref([]); // formatSku 中赋值(原 data 未声明)
// startCheckout 通过字符串 key 操作对应 loading // startCheckout 通过字符串 key 操作对应 loading
const loadings = { loading, loading1, loadingPintuan }; const loadings = { loading, loading1, loadingPintuan };
const currentMedia = computed(() => mediaList.value[mediaIndex.value] || null);
const isCurrentVideo = computed(() => currentMedia.value?.type === "video");
const hasVideo = computed(() =>
mediaList.value.some((item) => item.type === "video")
);
// 视频两侧用封面的高斯模糊填充,避免竖视频出现黑边
const videoBgStyle = computed(() => {
const video = mediaList.value.find((item) => item.type === "video");
return video && video.poster
? { backgroundImage: `url("${video.poster}")` }
: {};
});
const currentImg = computed(() => { const currentImg = computed(() => {
const item = imgList.value[imgIndex.value]; const item = currentMedia.value;
return item ? item.url || item : ""; if (!item) return "";
return item.type === "video" ? item.poster : item.url || item;
}); });
const wholesalePrice = computed(() => {
return wholesaleList.value.length
? wholesaleList.value.map((item) => {
return item.price;
})
: [];
});
const wholesaleNum = computed(() => {
return wholesaleList.value.length
? wholesaleList.value.map((item) => {
return item.num;
})
: [];
});
/** 卡密商品:禁加购,仅立即购买;库存读 quantity(M-02 起支持促销) */ /** 卡密商品:禁加购,仅立即购买;库存读 quantity(M-02 起支持促销) */
const isECouponGoods = computed(() => { const isECouponGoods = computed(() => {
@@ -436,7 +396,6 @@ watch(
() => props.detail, () => props.detail,
(val) => { (val) => {
skuDetail.value = val.data; skuDetail.value = val.data;
wholesaleList.value = val.wholesaleList;
syncCountWithQuantity(); syncCountWithQuantity();
promotion(); promotion();
swiperGoodsImg(); swiperGoodsImg();
@@ -444,23 +403,46 @@ watch(
{ deep: true, immediate: true } { deep: true, immediate: true }
); );
watch(showGoodsVideo, (val) => { // 天猫/淘宝风格:进页即在相册首位静音循环播放,用户可通过控制条开声音
if (val) { function initVideoPlayer() {
initVideo(); const video = mediaList.value[0];
} if (!video || video.type !== "video") return;
}); nextTick(() => {
if (dp) return;
dp = new DPlayer({
container: document.getElementById("goodsVideoPlayer"),
autoplay: true,
loop: true,
muted: true,
video: {
url: video.url,
pic: video.poster,
},
});
});
}
// 初始化video // 悬停切换相册项:离开视频时暂停,回到视频时续播
function initVideo() { function switchMedia(index) {
if (!goodsVideo.value) { if (index === mediaIndex.value) return;
setTimeout(() => { const leavingVideo = isCurrentVideo.value;
goodsVideo.value = new DPlayer({ mediaIndex.value = index;
container: document.getElementById('dplayer'), if (leavingVideo && dp) {
video: { dp.pause();
url: skuDetail.value.goodsVideo, } else if (isCurrentVideo.value && dp) {
}, dp.play();
}); }
}, 100); }
// 缩略图:视频用封面,图片兼容字符串与 { url } 两种形态
function mediaThumb(item) {
return item.type === "video" ? item.poster : item.url || item;
}
function destroyVideo() {
if (dp) {
dp.destroy();
dp = null;
} }
} }
@@ -472,10 +454,6 @@ function syncCountWithQuantity() {
count.value = 1; count.value = 1;
return; return;
} }
if (!isECouponGoods.value && wholesaleList.value && wholesaleList.value.length > 0) {
count.value = Math.min(Math.max(wholesaleList.value[0].num, 1), qty);
return;
}
const max = isECouponGoods.value ? getECouponMaxBuyNum(skuDetail.value) : qty; const max = isECouponGoods.value ? getECouponMaxBuyNum(skuDetail.value) : qty;
if (count.value > max) { if (count.value > max) {
count.value = max; count.value = max;
@@ -484,14 +462,7 @@ function syncCountWithQuantity() {
} }
} }
function changeCount(val) { function changeCount(val) {}
if (wholesaleList.value && wholesaleList.value.length > 0) {
if (count.value <= wholesaleList.value[0].num) {
Message.warning("批发商品购买数量不能小于起批数量");
count.value = wholesaleList.value[0].num;
}
}
}
function select(index, value) { function select(index, value) {
// 选择规格 // 选择规格
@@ -702,18 +673,29 @@ function promotion() {
} }
} }
// 相册里会混进规格 JSON,只留图片地址;没有相册时用主图 // 相册里会混进规格 JSON,只留图片地址;没有相册时用主图。有视频时视频排在相册第一项
function swiperGoodsImg() { function swiperGoodsImg() {
destroyVideo();
mediaIndex.value = 0;
const source = skuDetail.value.goodsGalleryList || []; const source = skuDetail.value.goodsGalleryList || [];
const gallery = Array.from(source).filter((item) => { const gallery = Array.from(source).filter((item) => {
if (typeof item !== "string") return false; if (typeof item !== "string") return false;
return item.indexOf('"url":') === -1 && item.indexOf('"status":') === -1; return item.indexOf('"url":') === -1 && item.indexOf('"status":') === -1;
}); });
if (gallery.length) { const images = gallery.length
imgList.value = gallery; ? gallery
return; : skuDetail.value.original
? [skuDetail.value.original]
: [];
mediaList.value = [...images];
if (skuDetail.value.goodsVideo) {
mediaList.value.unshift({
type: "video",
url: skuDetail.value.goodsVideo,
poster: images[0] || "",
});
initVideoPlayer();
} }
imgList.value = skuDetail.value.original ? [skuDetail.value.original] : [];
} }
onMounted(() => { onMounted(() => {
@@ -723,6 +705,8 @@ onMounted(() => {
if (res.success && res.result) { if (res.success && res.result) {
isCollected.value = true; isCollected.value = true;
} }
}).catch(() => {
// 收藏状态为可选数据,失败时静默
}); });
} }
@@ -730,6 +714,10 @@ onMounted(() => {
// promotion(); // promotion();
document.title = skuDetail.value.goodsName; document.title = skuDetail.value.goodsName;
}); });
onBeforeUnmount(() => {
destroyVideo();
});
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -793,10 +781,58 @@ onMounted(() => {
object-fit: cover; object-fit: cover;
} }
} }
#dplayer{ .goods-video-wrap {
width: 100%; position: relative;
height: 100%; width: 100%;
height: 100%;
overflow: hidden;
}
.goods-video-bg {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
filter: blur(28px) brightness(0.85);
transform: scale(1.4);
}
.goods-video-player {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
/* 播放器默认黑底改为透明,露出毛玻璃背景 */
:deep(.dplayer),
:deep(.dplayer-video-wrap) {
background: transparent;
} }
}
.video-badge {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
img {
width: 22px;
height: 22px;
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
}
}
.video-thumb {
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
}
.item-detail-img-row { .item-detail-img-row {
margin-top: 0; margin-top: 0;
@@ -806,6 +842,7 @@ onMounted(() => {
} }
.item-detail-img-small { .item-detail-img-small {
position: relative;
width: 86px; width: 86px;
height: 86px; height: 86px;
border: 2px solid transparent; border: 2px solid transparent;
@@ -1236,8 +1273,5 @@ onMounted(() => {
font-size: 15px; font-size: 15px;
line-height: 22px; line-height: 22px;
} }
.view-video{
cursor: pointer;
}
/******************商品图片及购买详情结束******************/ /******************商品图片及购买详情结束******************/
</style> </style>

View File

@@ -100,7 +100,7 @@
v-model:current-page="commentParams.pageNumber" v-model:current-page="commentParams.pageNumber"
v-model:page-size="commentParams.pageSize" v-model:page-size="commentParams.pageSize"
:total="commentTotal" :total="commentTotal"
small size="small"
@current-change="changePageNum" @current-change="changePageNum"
@size-change="changePageSize" @size-change="changePageSize"
layout="prev, pager, next" layout="prev, pager, next"
@@ -136,6 +136,7 @@
import { computed, onMounted, onBeforeUnmount, reactive, ref } from 'vue'; import { computed, onMounted, onBeforeUnmount, reactive, ref } from 'vue';
import { Refresh } from '@element-plus/icons-vue'; import { Refresh } from '@element-plus/icons-vue';
import { goodsComment, goodsCommentNum } from '@/api/member.js'; import { goodsComment, goodsCommentNum } from '@/api/member.js';
import storage from '@/plugins/storage.js';
import StoreSide from './StoreSide.vue'; import StoreSide from './StoreSide.vue';
defineOptions({ name: 'ShowGoodsDetail' }); defineOptions({ name: 'ShowGoodsDetail' });
@@ -246,17 +247,23 @@ function changePageSize (val) {
getList(); getList();
} }
function getList () { function getList () {
// 评价接口需要登录,未登录时不发起请求
if (!storage.getItem('accessToken') || !storage.getItem('userInfo')) return;
commentParams.goodsId = skuDetail.value.goodsId; commentParams.goodsId = skuDetail.value.goodsId;
goodsComment(commentParams).then(res => { goodsComment(commentParams).then(res => {
if (res.success) { if (res.success) {
commentList.value = res.result.records; commentList.value = res.result.records;
commentTotal.value = res.result.total; commentTotal.value = res.result.total;
} }
}).catch(() => {
// 评价为可选数据,加载失败时静默
}); });
goodsCommentNum(skuDetail.value.goodsId).then(res => { goodsCommentNum(skuDetail.value.goodsId).then(res => {
if (res.success) { if (res.success) {
commentTypeNum.value = res.result; commentTypeNum.value = res.result;
} }
}).catch(() => {
// 评价统计为可选数据,加载失败时静默
}); });
} }
function viewByGrade (grade) { function viewByGrade (grade) {

View File

@@ -245,10 +245,11 @@ function getCartList() {
.box { .box {
width: 100%; width: 100%;
font-size: 14px; font-size: 12px;
height: 40px; height: 40px;
background: #333; background: #f8fafc;
color: #fff; border-bottom: 1px solid #e2e8f0;
color: #64748b;
display: flex; display: flex;
align-items: center; align-items: center;
} }
@@ -281,14 +282,19 @@ function getCartList() {
.nav a, .nav a,
.nav-item { .nav-item {
font-size: 14px; 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; display: inline-flex;
align-items: center; align-items: center;
line-height: 1; line-height: 1;
transition: color 0.15s ease;
&:hover {
color: #409EFF;
}
} }
.nav-item { .nav-item {

View File

@@ -1,131 +1,251 @@
<template> <template>
<div class="scroll-show"> <div class="scroll-show">
<div class="content"> <div class="content">
<cateNav <!-- 1. 左侧区域:商城 Logo -->
class="cate" <div class="left-section">
:hover="true" <router-link to="/" class="logo-link">
:showNavBar="false" <img v-if="logoSrc" :src="logoSrc" alt="Logo" class="site-logo" />
useClass="fixed-show" <span v-else class="text-logo">LiliShop</span>
/> </router-link>
<Search </div>
class="search-con"
:hover="true" <!-- 2. 中间搜索区域:与 SearchHeader 样式完全一致(蓝底色、圆角4px、搜索按钮) -->
ref="search" <div class="search-main">
:showLogo="false" <div class="search-box">
:showTag="false" <input
useClass="fixed-show" v-model="keyword"
/> type="text"
<div class="cart"> class="search-input"
<span @mouseenter="getCartList"> :placeholder="placeholderText"
<el-icon class="cart-icon" :size="20" @click="goCartList"><ShoppingCart /></el-icon> @keyup.enter="handleSearch"
</span> />
<i class="cart-badge">{{ Number(cartNum) > 99 ? "99+" : cartNum }}</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 setup> <script setup>
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router' import { useRouter, useRoute } from 'vue-router';
import Search from '@/components/Search.vue' import { cartCount } from '@/api/cart.js';
import cateNav from '@/components/nav/CateNav.vue' import storage from '@/plugins/storage.js';
import { cartCount } from '@/api/cart.js' import { ShoppingCart, Search } from '@element-plus/icons-vue';
import storage from '@/plugins/storage.js' import { useAppStore } from '@/stores/app';
import { ShoppingCart } from '@element-plus/icons-vue'
import { useAppStore } from '@/stores/app'
const router = useRouter() const props = defineProps({
const appStore = useAppStore() options: {
type: Object,
default: () => ({})
}
});
const userInfo = ref({}) const router = useRouter();
const cartNum = computed(() => appStore.cartNum) const route = useRoute();
const appStore = useAppStore();
function goCartList() { const keyword = ref(route.query.keyword || '');
router.push('/cart') 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() { function getCartList() {
if (storage.getItem('userInfo')) { if (storage.getItem('userInfo')) {
cartCount().then((res) => { cartCount().then((res) => {
appStore.setCartNum(res.result) appStore.setCartNum(res.result);
storage.setItem('cartNum', res.result) storage.setItem('cartNum', res.result);
}) });
} }
} }
onMounted(() => { onMounted(() => {
if (storage.getItem('userInfo')) { getCartList();
userInfo.value = JSON.parse(storage.getItem('userInfo')) });
}
})
</script> </script>
<style lang="scss" scoped>
$side-col-width: 263.2px;
$col-gap: 10px;
$cart-col-width: 40px;
<style lang="scss" scoped>
.scroll-show { .scroll-show {
height: 60px; height: 60px;
overflow: visible; width: 100%;
background: #ffffff;
} }
.content { .content {
width: 1200px; width: 1200px;
max-width: 100%;
height: 60px; height: 60px;
margin: 0 auto; margin: 0 auto;
display: grid;
grid-template-columns: $side-col-width 1fr $cart-col-width;
column-gap: $col-gap;
align-items: center;
}
.cate {
min-width: 0;
:deep(.cate-nav) {
width: 100%;
margin: 0;
}
:deep(.nav-con) {
height: 60px;
}
}
.search-con {
min-width: 0;
width: 100%;
}
.cart {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: space-between;
height: 60px; box-sizing: border-box;
position: relative; padding: 0 10px;
} }
.cart-icon { .left-section {
font-size: 25px; display: flex;
color: $theme_color; align-items: center;
cursor: pointer; flex-shrink: 0;
.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 { .search-main {
position: absolute; width: 560px;
font-style: normal; flex-shrink: 0;
top: 12px;
right: -2px; .search-box {
display: block; display: flex;
background-color: $theme_color; border: 2px solid #409EFF;
color: #fff; border-radius: 4px;
font-size: 12px; overflow: hidden;
min-width: 17px; height: 38px;
height: 17px; box-sizing: border-box;
padding: 0 4px; background: #ffffff;
border-radius: 10px;
line-height: 17px; .search-input {
text-align: center; flex: 1;
z-index: 5; 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,6 +1,16 @@
<template> <template>
<div class="model-form"> <div class="model-form" :style="pageContainerStyle">
<div class="model-content"> <!-- Version 2: 新万米行模型渲染 -->
<div v-if="isVersion2" class="v2-rows-container">
<row-wrapper
v-for="row in v2Rows"
:key="row.id"
:row="row"
/>
</div>
<!-- 旧版降级渲染 -->
<div v-else class="model-content">
<model-form-item <model-form-item
v-for="(element, index) in renderList" v-for="(element, index) in renderList"
:key="element.key" :key="element.key"
@@ -11,14 +21,34 @@
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import ModelFormItem from './ModelFormItem.vue'; import ModelFormItem from './ModelFormItem.vue';
import RowWrapper from './RowWrapper.vue';
defineOptions({ name: 'modelForm' }); defineOptions({ name: 'modelForm' });
const props = defineProps(['data']); const props = defineProps(['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 renderList = computed(() => {
const list = props.data?.list || []; const list = props.data?.list || [];
return list.filter( return list.filter(
@@ -27,21 +57,26 @@ const renderList = computed(() => {
}); });
function isBuiltinModule(type) { function isBuiltinModule(type) {
// 顶部广告、快捷导航由 Index 页单独渲染
return type === 'topAdvert' || type === 'navBar'; return type === 'topAdvert' || type === 'navBar';
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.model-form { .model-form {
position: relative; position: relative;
z-index: 0; z-index: 0;
width: 100%; 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; box-sizing: border-box;
/* 勿设 overflow-x: hidden,会裁切 discountAdvert 的 margin-left: -100px 左溢背景 */
overflow-x: visible; overflow-x: visible;
} }
</style> </style>

View File

@@ -0,0 +1,385 @@
<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, comp.type)"
>
<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 hasAutoComp = computed(() => {
return Array.isArray(props.row.components) && props.row.components.some(c => ['goodsGrid', 'gridFloor', 'bannerFloor', 'goodsBar', 'channelFloor', 'goodsList', 'recommendFeed', 'articleContent', 'brandWall', 'channelGrid', 'couponList'].includes(c?.type));
});
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;
if (hasAutoComp.value) {
style.minHeight = hVal;
} else {
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;
if (hasAutoComp.value) {
styleObj.minHeight = hVal;
} else {
styleObj.height = hVal;
styleObj.minHeight = hVal;
}
}
}
return styleObj;
});
function getComponentCellStyle(style, rowStyle = {}, totalComps = 1, compType = '') {
const isAutoHeightComp = compType && ['goodsGrid', 'gridFloor', 'bannerFloor', 'goodsBar', 'channelFloor', 'goodsList', 'recommendFeed', 'articleContent', 'brandWall', 'channelGrid', 'couponList'].includes(compType);
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;
if (isAutoHeightComp) {
s.minHeight = rhVal;
} else {
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: '品质生活 · 精选好物', 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 !== undefined ? zone.top : ((zone.topPer || 0) * 100)}%`,
left: `${zone.left !== undefined ? zone.left : ((zone.leftPer || 0) * 100)}%`,
width: `${zone.width !== undefined ? zone.width : ((zone.widthPer || 0.1) * 100)}%`,
height: `${zone.height !== undefined ? zone.height : ((zone.heightPer || 0.1) * 100)}%`
}"
: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 || zone?.link) ? String(zone.url || zone.link).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>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,73 @@
/**
* PC 前台装修楼层组件注册表
* 映射 6 大分类、24 个组件
*/
import TopBar from './components/TopBar.vue';
import SearchHeader from './components/SearchHeader.vue';
import FooterNav from './components/FooterNav.vue';
import CategoryNav from './components/CategoryNav.vue';
import ChannelGrid from './components/ChannelGrid.vue';
import CategoryMegaMenu from './components/CategoryMegaMenu.vue';
import Carousel from './components/Carousel.vue';
import BrandWall from './components/BrandWall.vue';
import HotZone from './components/HotZone.vue';
import FreeLayout from './components/FreeLayout.vue';
import PromoGroup from './components/PromoGroup.vue';
import VideoPlayer from './components/VideoPlayer.vue';
import GoodsList from './components/GoodsList.vue';
import GridFloor from './components/GridFloor.vue';
import GoodsGrid from './components/GoodsGrid.vue';
import ChannelFloor from './components/ChannelFloor.vue';
import RecommendFeed from './components/RecommendFeed.vue';
import CouponList from './components/CouponList.vue';
import UserBenefits from './components/UserBenefits.vue';
import TitleBar from './components/TitleBar.vue';
import ArticleContent from './components/ArticleContent.vue';
import NoticeBar from './components/NoticeBar.vue';
export const COMPONENT_MAP = {
// 1. 结构 (3)
topBar: TopBar,
searchHeader: SearchHeader,
footerNav: FooterNav,
// 2. 导航 (3)
categoryNav: CategoryNav,
channelGrid: ChannelGrid,
categoryMegaMenu: CategoryMegaMenu,
// 3. 广告 (6)
carousel: Carousel,
brandWall: BrandWall,
hotZone: HotZone,
freeLayout: FreeLayout,
promoGroup: PromoGroup,
video: VideoPlayer,
// 4. 商品 (8)
goodsList: GoodsList,
gridFloor: GridFloor,
goodsGrid: GoodsGrid,
goodsCard: GoodsList,
bannerFloor: GridFloor,
goodsBar: GoodsList,
channelFloor: ChannelFloor,
recommendFeed: RecommendFeed,
// 5. 营销 (1)
couponList: CouponList,
userBenefits: UserBenefits,
// 6. 内容 (3)
titleBar: TitleBar,
articleContent: ArticleContent,
noticeBar: NoticeBar
};
/**
* 根据组件 type 解析 Vue 组件
*/
export function resolveComponent(type) {
if (!type) return null;
return COMPONENT_MAP[type] || null;
}

View File

@@ -9,7 +9,7 @@
<div class="selector"> <div class="selector">
<div class="selector-item" v-for="(plant, plantIndex) in Object.keys(data)" :key="plantIndex"> <div class="selector-item" v-for="(plant, plantIndex) in Object.keys(data)" :key="plantIndex">
<div :class="{ 'active': chiosend[plantIndex].id == item.id }" v-for="(item, index) in data[plant]" <div :class="{ 'active': chiosend[plantIndex].id == item.id }" v-for="(item, index) in data[plant]"
:key="index" :key="index" :title="item.name"
@click="init(item, plantIndex != Object.keys(data).length - 1 ? Object.keys(data)[plantIndex + 1] : 0, plantIndex)" @click="init(item, plantIndex != Object.keys(data).length - 1 ? Object.keys(data)[plantIndex + 1] : 0, plantIndex)"
class="map-item"> class="map-item">
{{ item.name }} {{ item.name }}
@@ -125,7 +125,8 @@ function finished() {
.selector-item { .selector-item {
width: 100%; width: 100%;
flex: 1; flex: 1;
overflow: auto; overflow-y: auto;
overflow-x: hidden;
} }
.map-item { .map-item {
@@ -133,6 +134,10 @@ function finished() {
padding: 10px; padding: 10px;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
cursor: pointer; cursor: pointer;
box-sizing: border-box;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:hover { &:hover {
background: #eee; background: #eee;

View File

@@ -0,0 +1,33 @@
import { computed, onMounted, ref } from "vue";
import { getBaseSite } from "@/api/common.js";
import config from "@/config";
// 同页的页头、页脚共享请求;每次重新进入页面时刷新,避免旧缓存覆盖后台设置。
const settings = ref(null);
let pendingRequest;
export function loadSiteSettings() {
if (!pendingRequest) {
pendingRequest = getBaseSite()
.then((res) => {
if (res.success && res.result?.settingValue) {
settings.value = JSON.parse(res.result.settingValue);
}
return settings.value;
})
.catch(() => settings.value)
.finally(() => { pendingRequest = null; });
}
return pendingRequest;
}
export function useSiteSettings() {
onMounted(loadSiteSettings);
const siteName = computed(() => settings.value?.siteName || config.title);
const companyName = computed(() => settings.value ? settings.value.companyName || "" : config.company?.name || "");
const companyUrl = computed(() => settings.value ? settings.value.companyUrl || "" : config.company?.href || "");
const website = computed(() => settings.value ? settings.value.website || "" : config.website || "");
const icp = computed(() => settings.value ? settings.value.icp || "" : config.icpMessage || "");
const copyrightOwner = computed(() => settings.value?.copyrightOwner?.trim() || siteName.value);
return { settings, siteName, companyName, companyUrl, website, icp, copyrightOwner };
}

View File

@@ -14,7 +14,7 @@ export const E_COUPON_ORDER_TYPE = "E_COUPON";
/** 单次购买数量上限(O-09 默认:min(quantity, 999)) */ /** 单次购买数量上限(O-09 默认:min(quantity, 999)) */
export const E_COUPON_MAX_BUY_NUM = 999; export const E_COUPON_MAX_BUY_NUM = 999;
/** 常规立即购买(秒杀/满减/券/限时/第N件等) */ /** 常规立即购买(秒杀/满减/券等) */
export const E_COUPON_CART_BUY_NOW = "BUY_NOW"; export const E_COUPON_CART_BUY_NOW = "BUY_NOW";
export const E_COUPON_CART_PINTUAN = "PINTUAN"; export const E_COUPON_CART_PINTUAN = "PINTUAN";
export const E_COUPON_CART_POINTS = "POINTS"; export const E_COUPON_CART_POINTS = "POINTS";
@@ -72,8 +72,6 @@ export function createEmptyPromotionMap() {
SECKILL: null, SECKILL: null,
FULL_DISCOUNT: null, FULL_DISCOUNT: null,
COUPON: [], COUPON: [],
DISCOUNT: null,
NTH: null,
PINTUAN: null, PINTUAN: null,
KANJIA: null, KANJIA: null,
}; };

View File

@@ -75,7 +75,7 @@
>¥{{ item.price }}</span> >¥{{ item.price }}</span>
<span v-if="item.couponType === 'DISCOUNT'" <span v-if="item.couponType === 'DISCOUNT'"
>{{ item.couponDiscount }}折</span> >{{ item.couponDiscount }}折</span>
<span>满{{ item.consumeThreshold }}元可用</span> <span>{{ item.consumeThreshold > 0 ? '满' + item.consumeThreshold + '元可用' : '无门槛使用' }}</span>
<el-button <el-button
class="coupon-btn" class="coupon-btn"
size="small" size="small"
@@ -118,13 +118,6 @@
<p> <p>
{{ goods.goodsSku.goodsName }} {{ goods.goodsSku.goodsName }}
</p> </p>
<p><el-tag
v-if="goods.goodsSku.salesModel === 'WHOLESALE'"
class="goods-show-tag"
color="purple"
>
批发商品
</el-tag></p>
<template <template
v-for="(promotion, promotionIndex) in goods.promotions" v-for="(promotion, promotionIndex) in goods.promotions"
> >

View File

@@ -1,6 +1,7 @@
<template> <template>
<div class="coupon-center"> <div class="coupon-center">
<BaseHeader></BaseHeader> <BaseHeader></BaseHeader>
<SearchHeader></SearchHeader>
<div class="content"> <div class="content">
<h2 class="coupon-page-title">领券中心</h2> <h2 class="coupon-page-title">领券中心</h2>
@@ -48,6 +49,7 @@ import { onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { Modal } from "@/utils/message"; import { Modal } from "@/utils/message";
import storage from "@/plugins/storage"; import storage from "@/plugins/storage";
import SearchHeader from "@/components/indexDecorate/components/SearchHeader.vue";
import { couponList, receiveCoupon } from "@/api/member.js"; import { couponList, receiveCoupon } from "@/api/member.js";
const route = useRoute(); const route = useRoute();

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="goods-detail-page"> <div class="goods-detail-page">
<BaseHeader></BaseHeader> <TopBar></TopBar>
<Search></Search> <SearchHeader></SearchHeader>
<div class="shop-item-path"> <div class="shop-item-path">
<div class="shop-nav-container"> <div class="shop-nav-container">
<el-breadcrumb> <el-breadcrumb>
@@ -44,7 +44,8 @@ import { ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import storage from "@/plugins/storage.js"; import storage from "@/plugins/storage.js";
import Search from "@/components/Search"; import TopBar from '@/components/indexDecorate/components/TopBar.vue';
import SearchHeader from '@/components/indexDecorate/components/SearchHeader.vue';
import ShowGoods from "@/components/goodsDetail/ShowGoods"; import ShowGoods from "@/components/goodsDetail/ShowGoods";
import empty from "@/components/empty/Main"; import empty from "@/components/empty/Main";
import ShowGoodsDetail from "@/components/goodsDetail/ShowGoodsDetail"; import ShowGoodsDetail from "@/components/goodsDetail/ShowGoodsDetail";

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="wrapper"> <div class="wrapper">
<BaseHeader></BaseHeader> <TopBar></TopBar>
<Search @search="handleSearch"></Search> <SearchHeader @search="handleSearch"></SearchHeader>
<div class="container"> <div class="container">
<!-- 商品筛选栏 --> <!-- 商品筛选栏 -->
@@ -56,12 +56,6 @@
</span> </span>
</div> </div>
<div class="goods-show-detail"> <div class="goods-show-detail">
<el-tag
v-if="item.salesModel === 'WHOLESALE'"
class="goods-show-tag goods-show-tag-wholesale"
>
批发
</el-tag>
<span>{{ item.goodsName }}</span> <span>{{ item.goodsName }}</span>
</div> </div>
<div class="goods-show-num"> <div class="goods-show-num">
@@ -121,6 +115,8 @@ import { ArrowDown, ArrowUp } from '@element-plus/icons-vue';
import GoodsClassNav from "@/components/nav/GoodsClassNav"; import GoodsClassNav from "@/components/nav/GoodsClassNav";
import GoodsThumb from "@/components/goods/GoodsThumb.vue"; import GoodsThumb from "@/components/goods/GoodsThumb.vue";
import * as apiGoods from "@/api/goods"; import * as apiGoods from "@/api/goods";
import TopBar from '@/components/indexDecorate/components/TopBar.vue';
import SearchHeader from '@/components/indexDecorate/components/SearchHeader.vue';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -351,16 +347,6 @@ getGoodsList();
} }
} }
.goods-show-tag-wholesale {
margin-right: 5px;
background-color: #f9f0ff;
border-color: #d3adf7;
color: #9254de;
:deep(.el-tag__content) {
color: #9254de;
}
}
.goods-show-seller { .goods-show-seller {
// padding:3px 0; // padding:3px 0;

View File

@@ -11,7 +11,7 @@
<el-card shadow="never" v-for="(item, index) in autoCoupList" :key="index" style="margin: 10px 15px"> <el-card shadow="never" v-for="(item, index) in autoCoupList" :key="index" style="margin: 10px 15px">
<span v-if="item.couponType === 'PRICE'" class="fontsize_12 global_color" style="font-size: 15px;">¥<span class="price">{{ $filters.unitPrice(item.price) }}</span></span> <span v-if="item.couponType === 'PRICE'" class="fontsize_12 global_color" style="font-size: 15px;">¥<span class="price">{{ $filters.unitPrice(item.price) }}</span></span>
<span v-if="item.couponType === 'DISCOUNT'" class="fontsize_12 global_color" style="font-size: 15px;"><span class="price">{{item.discount}}</span>折</span> <span v-if="item.couponType === 'DISCOUNT'" class="fontsize_12 global_color" style="font-size: 15px;"><span class="price">{{item.discount}}</span>折</span>
<span class="describe" style="font-size: 15px;margin-left: 5px;">满{{item.consumeThreshold}}元可用</span> <span class="describe" style="font-size: 15px;margin-left: 5px;">{{ item.consumeThreshold > 0 ? '满' + item.consumeThreshold + '元可用' : '无门槛使用' }}</span>
<p style="font-size: 14px;">使用范围:{{useScope(item.scopeType, item.storeName)}}</p> <p style="font-size: 14px;">使用范围:{{useScope(item.scopeType, item.storeName)}}</p>
<p style="font-size: 14px;">有效期:{{item.endTime}}</p> <p style="font-size: 14px;">有效期:{{item.endTime}}</p>
</el-card> </el-card>
@@ -22,33 +22,39 @@
</el-dialog> </el-dialog>
<!-- 固定头部 --> <!-- 固定头部 -->
<hover-search class="hover-search" :class="{show: topSearchShow}"></hover-search> <hover-search class="hover-search" :class="{show: topSearchShow}" :options="searchHeaderOptions"></hover-search>
<!-- 顶部广告 --> <!-- 顶部广告 -->
<FixedTopPage :data="topAdvert"></FixedTopPage> <FixedTopPage :data="topAdvert" v-if="!isVersion2"></FixedTopPage>
<!-- 头部 包括登录,我的订单等 --> <!-- 头部 包括登录,我的订单等 -->
<BaseHeader></BaseHeader> <BaseHeader v-if="!hasComponentInV2('topBar')"></BaseHeader>
<!-- 搜索框、logo --> <!-- 搜索框、logo -->
<Search></Search> <Search v-if="!hasComponentInV2('searchHeader')"></Search>
<div class="index-main"> <!-- Version 2 页面装修:全宽自由响应容器,由各行独立配置 rowStyle.width(如 100%、1920、1400、1200)实现全宽或精准居中 -->
<!-- 商品分类 + 楼层装修(同一宽度容器,保证与分类栏左缘对齐) --> <div v-if="isVersion2" class="v2-index-main">
<model-form :data="modelFormData"></model-form>
</div>
<!-- Version 1 历史旧版:保持 1200px 传统固定容器与分类栏 -->
<div v-else class="index-main">
<cateNav :showAlways="true" v-if="showNav" :large="carouselLarge" :opacity="carouselOpacity"></cateNav> <cateNav :showAlways="true" v-if="showNav" :large="carouselLarge" :opacity="carouselOpacity"></cateNav>
<model-form :data="modelFormData"></model-form> <model-form :data="modelFormData"></model-form>
</div> </div>
<!-- 底部栏 --> <!-- 底部栏 -->
<BaseFooter></BaseFooter> <BaseFooter v-if="!hasComponentInV2('footerNav')"></BaseFooter>
<!-- 侧边栏 --> <!-- 侧边栏 -->
<fixedBar class="fixed-bar" :class="{'show-fixed': topSearchShow}"></fixedBar> <fixedBar class="fixed-bar" :class="{'show-fixed': topSearchShow}"></fixedBar>
</div> </div>
</template> </template>
<script setup> <script setup>
import { onMounted, onUnmounted, ref } from "vue"; import { onMounted, onUnmounted, ref, computed } from "vue";
import Search from '@/components/Search'; import Search from '@/components/Search';
import ModelForm from '@/components/indexDecorate/ModelForm'; import ModelForm from '@/components/indexDecorate/ModelForm';
import HoverSearch from '@/components/header/hoverSearch'; import HoverSearch from '@/components/header/hoverSearch';
import fixedBar from '@/components/fixed/index'; import fixedBar from '@/components/fixed/index';
import storage from "@/plugins/storage"; import storage from "@/plugins/storage";
import { indexData, getAutoCoup } from '@/api/index.js'; import { indexData, getAutoCoup } from '@/api/index.js';
import { DOUBLE11_PAGE_DOCUMENT } from '@/components/indexDecorate/presets/double11Preset.js';
import { seckillByDay } from '@/api/promotion'; import { seckillByDay } from '@/api/promotion';
defineOptions({ name: "Index" }); defineOptions({ name: "Index" });
@@ -69,6 +75,29 @@ const topSearchShow = ref(false); // 滚动后顶部搜索栏展示
const carouselLarge = ref(false); // 不同轮播分类尺寸 const carouselLarge = ref(false); // 不同轮播分类尺寸
const carouselOpacity = ref(false); // 不同轮播分类样式 const carouselOpacity = ref(false); // 不同轮播分类样式
const isVersion2 = computed(() => {
return modelFormData.value?.version === 2 || modelFormData.value?.version === '2.0';
});
const searchHeaderOptions = computed(() => {
if (!Array.isArray(modelFormData.value?.list)) return {};
for (const row of modelFormData.value.list) {
if (Array.isArray(row.components)) {
const target = row.components.find(c => c && c.type === 'searchHeader');
if (target?.options) {
return target.options;
}
}
}
return {};
});
const hasComponentInV2 = (type) => {
if (!isVersion2.value || !Array.isArray(modelFormData.value?.list)) return false;
return modelFormData.value.list.some(r => r.components && r.components.some(c => c.type === type));
};
// 优惠券可用范围 // 优惠券可用范围
function useScope(type, storeName) { function useScope(type, storeName) {
let shop = '平台'; let shop = '平台';
@@ -137,7 +166,7 @@ function handleReachBottom() {
function getIndexData() { function getIndexData() {
if (props.pageData) { if (props.pageData) {
parsePageData(JSON.stringify(props.pageData)).catch(() => { parsePageData(JSON.stringify(props.pageData)).catch(() => {
// 装修数据解析失败时保持默认空楼层 modelFormData.value = DOUBLE11_PAGE_DOCUMENT;
}); });
} else { } else {
// 获取首页装修数据 // 获取首页装修数据
@@ -146,11 +175,15 @@ function getIndexData() {
try { try {
await parsePageData(res.result.pageData); await parsePageData(res.result.pageData);
} catch { } catch {
// 装修数据解析失败时保持默认空楼层 modelFormData.value = DOUBLE11_PAGE_DOCUMENT;
} }
} else {
// 无服务端数据时默认展示双11平台首页
modelFormData.value = DOUBLE11_PAGE_DOCUMENT;
} }
}).catch((err) => { }).catch((err) => {
console.warn('获取首页装修数据失败', err); console.warn('获取首页装修数据失败,启用双11平台首页预设', err);
modelFormData.value = DOUBLE11_PAGE_DOCUMENT;
}); });
} }
} }
@@ -160,6 +193,10 @@ async function parsePageData(pageData) {
if (!dataJson?.list?.length) { if (!dataJson?.list?.length) {
return; return;
} }
if (dataJson.version === 2 || dataJson.version === '2.0') {
modelFormData.value = dataJson;
return;
}
// 秒杀活动不是装修的数据,需要调用接口判断是否有秒杀商品 // 秒杀活动不是装修的数据,需要调用接口判断是否有秒杀商品
// 轮播图根据不同轮播,样式不同 // 轮播图根据不同轮播,样式不同
for (let i = 0; i < dataJson.list.length; i++) { for (let i = 0; i < dataJson.list.length; i++) {
@@ -221,6 +258,11 @@ onUnmounted(() => {
.container { .container {
@include sub_background_color($light_background_color); @include sub_background_color($light_background_color);
} }
.v2-index-main {
width: 100%;
position: relative;
box-sizing: border-box;
}
</style> </style>
<style> <style>
.hover-search { .hover-search {
@@ -231,7 +273,8 @@ onUnmounted(() => {
position: fixed; position: fixed;
top: 0; top: 0;
z-index: 9999; z-index: 9999;
box-shadow:0 0 10px 2px rgb(90 90 90 / 60%); box-shadow: 0 4px 16px rgba(15, 23, 42, 0.08);
border-bottom: 1px solid #f1f5f9;
transition: 0.35s; transition: 0.35s;
} }
.show { .show {

View File

@@ -1,8 +1,8 @@
<template> <template>
<div class="merchant"> <div class="merchant">
<BaseHeader/> <TopBar></TopBar>
<!-- 搜索栏 --> <!-- 搜索栏 -->
<Search :storeId="storeMsg.storeId || $route.query.id" @search="search"></Search> <SearchHeader @search="search"></SearchHeader>
<div class="store-head"> <div class="store-head">
<div class="store-head-main"> <div class="store-head-main">
<div class="shop-info"> <div class="shop-info">
@@ -87,10 +87,6 @@
<span class="seckill-price text-danger">{{ $filters.unitPrice(item.price, "¥") }}</span> <span class="seckill-price text-danger">{{ $filters.unitPrice(item.price, "¥") }}</span>
</div> </div>
<div class="goods-show-detail"> <div class="goods-show-detail">
<el-tag
v-if="item.salesModel === 'WHOLESALE'"
class="goods-show-tag goods-show-tag-wholesale"
>批发</el-tag>
<span>{{ item.goodsName }}</span> <span>{{ item.goodsName }}</span>
</div> </div>
<div class="goods-show-num"> <div class="goods-show-num">
@@ -133,7 +129,8 @@ import {
isStoreCollection, isStoreCollection,
} from "@/api/member"; } from "@/api/member";
import {goodsList as goodsListApi} from "@/api/goods"; import {goodsList as goodsListApi} from "@/api/goods";
import Search from "@/components/Search"; import TopBar from '@/components/indexDecorate/components/TopBar.vue';
import SearchHeader from '@/components/indexDecorate/components/SearchHeader.vue';
import ModelForm from "@/components/indexDecorate/ModelForm"; import ModelForm from "@/components/indexDecorate/ModelForm";
import storage from "@/plugins/storage"; import storage from "@/plugins/storage";
import {getFloorStoreData} from "@/api/index.js"; import {getFloorStoreData} from "@/api/index.js";
@@ -209,7 +206,11 @@ function getIndexData() {
} }
async function parsePageData(pageData) { async function parsePageData(pageData) {
const dataJson = JSON.parse(pageData); const dataJson = typeof pageData === 'string' ? JSON.parse(pageData) : pageData;
if (dataJson.version === 2 || dataJson.version === '2.0') {
modelForm.value = dataJson;
return;
}
for (let i = 0; i < dataJson.list.length; i++) { for (let i = 0; i < dataJson.list.length; i++) {
const type = dataJson.list[i].type; const type = dataJson.list[i].type;
if (type === "carousel2") { if (type === "carousel2") {
@@ -719,15 +720,6 @@ window.onscroll = () => {
color: #fff; color: #fff;
} }
.goods-show-tag-wholesale {
--el-tag-bg-color: #f9f0ff;
--el-tag-border-color: #d3adf7;
--el-tag-text-color: #9254de;
background-color: #f9f0ff;
border-color: #d3adf7;
color: #9254de;
}
.goods-page { .goods-page {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="not-found-page"> <div class="not-found-page">
<BaseHeader></BaseHeader> <TopBar></TopBar>
<Search :show-tag="false"></Search> <SearchHeader :options="{ showHotWords: false }"></SearchHeader>
<div class="not-found"> <div class="not-found">
<h1>页面不存在</h1> <h1>页面不存在</h1>
<p>你访问的地址没有对应页面</p> <p>你访问的地址没有对应页面</p>
@@ -12,6 +12,9 @@
</template> </template>
<script setup> <script setup>
import TopBar from '@/components/indexDecorate/components/TopBar.vue';
import SearchHeader from '@/components/indexDecorate/components/SearchHeader.vue';
defineOptions({ name: "NotFound" }); defineOptions({ name: "NotFound" });
</script> </script>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<BaseHeader></BaseHeader> <TopBar></TopBar>
<Search></Search> <SearchHeader></SearchHeader>
<!-- 添加成功提示 --> <!-- 添加成功提示 -->
<div class="add-info-box-container"> <div class="add-info-box-container">
@@ -50,6 +50,8 @@ export default {
<script setup> <script setup>
import { onMounted, ref } from "vue"; import { onMounted, ref } from "vue";
import TopBar from '@/components/indexDecorate/components/TopBar.vue';
import SearchHeader from '@/components/indexDecorate/components/SearchHeader.vue';
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
const route = useRoute(); const route = useRoute();

View File

@@ -0,0 +1,878 @@
<template>
<div class="wrapper">
<TopBar></TopBar>
<SearchHeader @search="handleSearch"></SearchHeader>
<div class="container">
<!-- 工具栏:排序 + 筛选 -->
<div class="store-tool">
<div class="store-sort">
<span
v-for="(item, index) in sortTools"
:key="item.value"
class="store-sort-item"
:class="{ active: index === sortIndex }"
@click="changeSort(index)"
>
{{ item.title }}
<el-icon v-if="index === sortIndex && item.value"><ArrowDown /></el-icon>
</span>
</div>
<div class="store-tool-right">
<span class="store-total-text">
<template v-if="params.storeName">
“<span class="keyword">{{ params.storeName }}</span>”
</template>
共找到 <span class="num">{{ total }}</span> 家店铺
</span>
<el-checkbox v-model="selfOnly" @change="changeSelfOnly">只看自营</el-checkbox>
</div>
</div>
<!-- 店铺列表 -->
<div class="store-list" v-loading="loading">
<div v-if="searched && !loading && storeList.length === 0" class="store-empty">
<empty :_Title="emptyTitle" />
</div>
<div v-for="store in storeList" :key="store.id" class="store-item">
<!-- 店铺信息 -->
<div class="store-info">
<div class="store-logo" @click="goShopPage(store.id)">
<img
v-if="store.storeLogo"
:src="store.storeLogo"
:alt="store.storeName"
loading="lazy"
@error="$event.target.style.visibility = 'hidden'"
/>
<span v-else class="store-logo-text">{{ (store.storeName || '店').slice(0, 1) }}</span>
</div>
<div class="store-meta">
<div class="store-name" @click="goShopPage(store.id)">
<span class="store-name-text" :title="store.storeName" v-html="highlight(store.storeName)"></span>
<span v-if="store.selfOperated" class="store-tag-self">自营</span>
</div>
<!-- 结构化字段:商家名称 / 所在地区 / 工商信息 -->
<div class="store-fields">
<div class="field-row">
<span class="field-label">商家名称</span>
<span class="field-value" :title="store.storeName">{{ store.storeName || '-' }}</span>
</div>
<div class="field-row">
<span class="field-label">所在地区</span>
<span class="field-value" :title="formatRegion(store.storeAddressPath)">
{{ formatRegion(store.storeAddressPath) }}
</span>
</div>
<div class="field-row">
<span class="field-label">工商信息</span>
<span class="field-value">
<span class="licence-btn" @click.stop="openLicence(store)">[查看详情]</span>
</span>
</div>
</div>
<div class="store-ops">
<button class="btn-enter" @click="goShopPage(store.id)">
进入店铺<el-icon><ArrowRight /></el-icon>
</button>
<button
class="btn-collect"
:class="{ 'is-collected': collectedMap[store.id] }"
@click="toggleCollect(store)"
>
{{ collectedMap[store.id] ? '已收藏' : '+ 收藏' }}
</button>
</div>
</div>
</div>
<!-- 店铺热销商品 -->
<div class="store-goods">
<template v-if="goodsMap[store.id] && goodsMap[store.id].length">
<div
v-for="goods in goodsMap[store.id]"
:key="goods.id"
class="store-goods-item"
@click="goGoodsDetail(goods.id, goods.goodsId)"
>
<div class="store-goods-img">
<GoodsThumb :src="goods.thumbnail || goods.small" :alt="goods.goodsName" />
</div>
<div class="store-goods-name" :title="goods.goodsName">{{ goods.goodsName }}</div>
<div class="store-goods-price">{{ $filters.unitPrice(goods.price, '¥') }}</div>
</div>
</template>
<div v-else-if="goodsMap[store.id]" class="store-goods-empty">
店铺暂无在售商品,<span @click="goShopPage(store.id)">去店铺逛逛 ›</span>
</div>
<template v-else>
<div v-for="n in PREVIEW_GOODS_SIZE" :key="n" class="store-goods-item is-placeholder">
<div class="store-goods-img"><GoodsThumb src="" /></div>
</div>
</template>
</div>
</div>
</div>
<div class="store-page" v-if="total > 0">
<el-pagination
v-model:current-page="params.pageNumber"
v-model:page-size="params.pageSize"
:page-sizes="[10, 20, 30]"
:total="total"
layout="total, prev, pager, next, sizes"
@current-change="getStoreList"
@size-change="changePageSize"
></el-pagination>
</div>
</div>
<!-- 工商资质信息弹窗 -->
<el-dialog
v-model="licenceDialogVisible"
title="工商资质信息公示"
width="520px"
align-center
destroy-on-close
>
<div v-loading="licenceLoading" class="licence-modal">
<template v-if="licenceData">
<div class="licence-row">
<span class="l-label">店铺名称</span>
<span class="l-val">{{ currentStore?.storeName || '-' }}</span>
</div>
<div class="licence-row" v-if="licenceData.companyName">
<span class="l-label">企业名称</span>
<span class="l-val">{{ licenceData.companyName }}</span>
</div>
<div class="licence-row" v-if="licenceData.companyAddressPath || licenceData.companyAddress">
<span class="l-label">企业住所</span>
<span class="l-val">
{{ formatRegion(licenceData.companyAddressPath) }} {{ licenceData.companyAddress || '' }}
</span>
</div>
<div class="licence-row" v-if="licenceData.scope">
<span class="l-label">经营范围</span>
<span class="l-val">{{ licenceData.scope }}</span>
</div>
<div class="licence-photo-box">
<div class="photo-title">营业执照电子版</div>
<div v-if="licenceData.licencePhoto" class="photo-wrap">
<el-image
:src="licenceData.licencePhoto"
:preview-src-list="[licenceData.licencePhoto]"
fit="contain"
class="licence-img"
/>
<div class="photo-hint">点击图片可放大查看</div>
</div>
<div v-else class="photo-empty">暂未上传营业执照电子版</div>
</div>
</template>
<div v-else-if="!licenceLoading" class="licence-none">
暂未查询到该店铺的工商资质信息
</div>
</div>
</el-dialog>
<BaseFooter></BaseFooter>
</div>
</template>
<script>
export default {
name: "StoreList",
beforeRouteEnter(to, from, next) {
window.scrollTo(0, 0);
next();
},
};
</script>
<script setup>
import { computed, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ArrowDown, ArrowRight } from "@element-plus/icons-vue";
import { Message } from "@/utils/message";
import storage from "@/plugins/storage";
import GoodsThumb from "@/components/goods/GoodsThumb.vue";
import TopBar from '@/components/indexDecorate/components/TopBar.vue';
import SearchHeader from '@/components/indexDecorate/components/SearchHeader.vue';
import { shopList, getLicencePhoto } from "@/api/shopentry";
import { goodsList as goodsListApi } from "@/api/goods";
import { cancelStoreCollect, collectStore, isStoreCollection } from "@/api/member";
const route = useRoute();
const router = useRouter();
// 每个店铺预览的商品数量
const PREVIEW_GOODS_SIZE = 5;
// 排序项(字段由后端 camel2Underline 转为列名)
const sortTools = [
{ title: "综合", value: "" },
{ title: "商品数", value: "goodsNum" },
{ title: "收藏数", value: "collectionNum" },
{ title: "服务评分", value: "serviceScore" },
];
const sortIndex = ref(0);
const selfOnly = ref(false);
const params = reactive({
pageNumber: 1,
pageSize: 10,
storeName: route.query.keyword || "",
// 买家端只展示营业中的店铺
storeDisable: "OPEN",
sort: "",
order: "",
});
const storeList = ref([]);
const total = ref(0);
const loading = ref(false);
const searched = ref(false);
// 店铺预览商品:storeId -> goods[]
const goodsMap = reactive({});
// 收藏状态:storeId -> boolean
const collectedMap = reactive({});
let requestSeq = 0;
// 工商资质弹窗状态
const licenceDialogVisible = ref(false);
const licenceLoading = ref(false);
const currentStore = ref(null);
const licenceData = ref(null);
const emptyTitle = computed(() =>
params.storeName ? `没有找到与“${params.storeName}”相关的店铺` : "暂无店铺"
);
watch(
() => route.query.keyword,
(keyword) => {
if (route.path !== "/storeList") return;
params.storeName = keyword || "";
params.pageNumber = 1;
getStoreList();
}
);
// 格式化地区展示(如 "北京市,东城区" -> "北京市东城区")
function formatRegion(path) {
if (!path) return "暂未填写";
const parts = String(path).split(/[,,\s]+/).filter(Boolean);
if (!parts.length) return "暂未填写";
const deduped = [];
parts.forEach((p) => {
if (!deduped.length || deduped[deduped.length - 1] !== p) {
deduped.push(p);
}
});
return deduped.join("") || "暂未填写";
}
// 打开工商资质信息弹窗
function openLicence(store) {
currentStore.value = store;
licenceData.value = null;
licenceDialogVisible.value = true;
licenceLoading.value = true;
getLicencePhoto(store.id)
.then((res) => {
if (res && res.success && res.result) {
licenceData.value = res.result;
} else {
licenceData.value = null;
}
})
.catch(() => {
licenceData.value = null;
})
.finally(() => {
licenceLoading.value = false;
});
}
// 搜索框(店铺模式)回调:写入 URL,便于刷新 / 回退
function handleSearch(keyword) {
const value = keyword || "";
if (value === (route.query.keyword || "")) {
params.storeName = value;
params.pageNumber = 1;
getStoreList();
return;
}
router.push({ path: "/storeList", query: value ? { keyword: value } : {} });
}
function changeSort(index) {
sortIndex.value = index;
const { value } = sortTools[index];
params.sort = value;
params.order = value ? "desc" : "";
params.pageNumber = 1;
getStoreList();
}
function changeSelfOnly() {
params.pageNumber = 1;
getStoreList();
}
function changePageSize(size) {
params.pageSize = size;
params.pageNumber = 1;
getStoreList();
}
function getStoreList() {
const seq = ++requestSeq;
loading.value = true;
const query = { ...params };
if (!query.sort) {
delete query.sort;
delete query.order;
}
if (!query.storeName) delete query.storeName;
if (selfOnly.value) query.selfOperated = true;
shopList(query)
.then((res) => {
if (seq !== requestSeq) return;
if (res.success) {
storeList.value = res.result.records || [];
total.value = res.result.total || 0;
loadStoreExtras(storeList.value);
}
})
.catch(() => {
if (seq !== requestSeq) return;
storeList.value = [];
total.value = 0;
})
.finally(() => {
if (seq !== requestSeq) return;
loading.value = false;
searched.value = true;
window.scrollTo(0, 0);
});
}
// 加载每个店铺的预览商品与收藏状态(失败不影响列表展示)
function loadStoreExtras(list) {
const logged = !!storage.getItem("userInfo");
list.forEach((store) => {
const storeId = store.id;
if (!storeId) return;
if (!goodsMap[storeId]) {
goodsListApi({
storeId,
pageNumber: 1,
pageSize: PREVIEW_GOODS_SIZE,
sort: "buyCount",
order: "desc",
})
.then((res) => {
goodsMap[storeId] = res && res.success ? res.result.records || [] : [];
})
.catch(() => {
goodsMap[storeId] = [];
});
}
if (logged && collectedMap[storeId] === undefined) {
isStoreCollection("STORE", storeId)
.then((res) => {
collectedMap[storeId] = !!(res && res.success && res.result);
})
.catch(() => {
// 收藏状态查询失败静默处理
});
}
});
}
async function toggleCollect(store) {
const storeId = store.id;
if (!storage.getItem("userInfo")) {
Message.warning("请先登录");
router.push("/login");
return;
}
try {
if (collectedMap[storeId]) {
const res = await cancelStoreCollect("STORE", storeId);
if (res.success) {
collectedMap[storeId] = false;
store.collectionNum = Math.max((store.collectionNum || 1) - 1, 0);
Message.success("已取消收藏");
}
} else {
const res = await collectStore("STORE", storeId);
if (res.success || res.code === 200) {
collectedMap[storeId] = true;
store.collectionNum = (store.collectionNum || 0) + 1;
Message.success("收藏店铺成功,可前往我的收藏查看");
}
}
} catch (err) {
const msg = err?.message || err?.data?.message || "";
if (msg.includes("重复收藏")) {
collectedMap[storeId] = true;
}
}
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// 店铺名关键词高亮(先转义,避免 XSS)
function highlight(name) {
const text = escapeHtml(name || "");
const keyword = (params.storeName || "").trim();
if (!keyword) return text;
const escapedKeyword = escapeHtml(keyword).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return text.replace(new RegExp(escapedKeyword, "gi"), (m) => `<em class="hl">${m}</em>`);
}
function goShopPage(id) {
if (!id) {
Message.warning("店铺信息不存在");
return;
}
router.push({ path: "/merchant", query: { id } });
}
function goGoodsDetail(skuId, goodsId) {
const location = { path: "/goodsDetail", query: { skuId, goodsId } };
window.open(router.resolve(location).href, "_blank");
}
getStoreList();
</script>
<style scoped lang="scss">
$theme_light: color-mix(in srgb, #{$theme_color} 8%, #fff);
.container {
margin: 20px auto 32px;
width: 1200px;
min-width: 1000px;
position: relative;
}
/* ---------------工具栏------------------- */
.store-tool {
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
padding: 0 20px;
border-radius: 8px;
background: #fff;
}
.store-tool-right {
display: flex;
align-items: center;
gap: 20px;
}
.store-total-text {
color: #999;
font-size: 13px;
.keyword,
.num {
color: $theme_color;
}
}
.store-sort {
display: flex;
gap: 10px;
}
.store-sort-item {
display: inline-flex;
align-items: center;
gap: 2px;
height: 30px;
padding: 0 16px;
border-radius: 15px;
background: #f5f5f5;
color: #666;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
user-select: none;
&:hover {
color: $theme_color;
}
&.active {
background: $theme_color;
color: #fff;
}
}
/* ---------------店铺列表------------------- */
.store-list {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 240px;
margin-top: 12px;
}
.store-empty {
padding: 40px 0;
border-radius: 8px;
background: #fff;
}
.store-item {
display: flex;
align-items: center;
gap: 28px;
padding: 24px;
border-radius: 8px;
background: #fff;
transition: box-shadow 0.2s, transform 0.2s;
&:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
}
.store-info {
display: flex;
flex-shrink: 0;
gap: 16px;
width: 350px;
padding-right: 28px;
border-right: 1px dashed #e8e8e8;
box-sizing: border-box;
}
.store-logo {
flex-shrink: 0;
width: 84px;
height: 84px;
border: 2px solid #f2f2f2;
border-radius: 50%;
overflow: hidden;
background: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.store-logo-text {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 32px;
font-weight: 600;
background: $theme_color;
}
.store-meta {
flex: 1;
min-width: 0;
}
.store-name {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
.store-name-text {
overflow: hidden;
color: #171a26;
font-size: 17px;
font-weight: 600;
white-space: nowrap;
text-overflow: ellipsis;
}
&:hover .store-name-text {
color: $theme_color;
}
:deep(.hl) {
color: $theme_color;
font-style: normal;
}
}
.store-tag-self {
flex-shrink: 0;
padding: 0 5px;
border-radius: 3px;
background: $theme_color;
color: #fff;
font-size: 12px;
line-height: 18px;
}
/* 结构化属性 */
.store-fields {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
font-size: 13px;
line-height: 1.4;
}
.field-row {
display: flex;
align-items: flex-start;
gap: 12px;
.field-label {
flex-shrink: 0;
width: 56px;
color: #999;
text-align: justify;
text-align-last: justify;
}
.field-value {
flex: 1;
min-width: 0;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.licence-btn {
color: #2d8cf0;
cursor: pointer;
font-weight: 500;
user-select: none;
&:hover {
color: $theme_color;
text-decoration: underline;
}
}
}
.store-ops {
display: flex;
gap: 8px;
margin-top: 14px;
button {
display: inline-flex;
align-items: center;
gap: 2px;
height: 28px;
padding: 0 14px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: opacity 0.2s, background 0.2s;
}
.btn-enter {
border: none;
background: $theme_color;
color: #fff;
&:hover {
opacity: 0.88;
}
}
.btn-collect {
border: 1px solid $theme_color;
background: #fff;
color: $theme_color;
&:hover {
background: $theme_light;
}
&.is-collected {
border-color: #ddd;
color: #999;
}
}
}
/* ---------------热销商品------------------- */
.store-goods {
flex: 1;
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 16px;
min-width: 0;
}
.store-goods-item {
min-width: 0;
cursor: pointer;
.store-goods-img {
border-radius: 6px;
overflow: hidden;
transition: transform 0.2s;
}
&:hover .store-goods-img {
transform: translateY(-3px);
}
&:hover .store-goods-name {
color: $theme_color;
}
&.is-placeholder {
cursor: default;
.store-goods-img {
transform: none;
}
}
}
.store-goods-name {
margin-top: 8px;
overflow: hidden;
color: #555;
font-size: 12px;
line-height: 18px;
white-space: nowrap;
text-overflow: ellipsis;
}
.store-goods-price {
margin-top: 2px;
color: $theme_color;
font-size: 15px;
font-weight: 600;
}
.store-goods-empty {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
min-height: 120px;
border-radius: 6px;
background: #fafafa;
color: #bbb;
font-size: 13px;
span {
color: $theme_color;
cursor: pointer;
}
}
.store-page {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
/* ---------------工商资质弹窗------------------- */
.licence-modal {
min-height: 160px;
padding: 8px 12px 16px;
.licence-row {
display: flex;
gap: 12px;
margin-bottom: 12px;
font-size: 14px;
line-height: 20px;
.l-label {
flex-shrink: 0;
width: 70px;
color: #999;
font-weight: 500;
}
.l-val {
flex: 1;
color: #333;
word-break: break-all;
}
}
.licence-photo-box {
margin-top: 16px;
padding-top: 16px;
border-top: 1px dashed #eee;
.photo-title {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 10px;
}
.photo-wrap {
display: flex;
flex-direction: column;
align-items: center;
.licence-img {
max-width: 100%;
max-height: 260px;
border: 1px solid #eee;
border-radius: 4px;
background: #fafafa;
cursor: zoom-in;
}
.photo-hint {
margin-top: 6px;
font-size: 12px;
color: #999;
}
}
.photo-empty {
padding: 20px 0;
text-align: center;
color: #bbb;
font-size: 13px;
}
}
.licence-none {
padding: 40px 0;
text-align: center;
color: #999;
font-size: 14px;
}
}
</style>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<BaseHeader></BaseHeader> <TopBar></TopBar>
<Search></Search> <SearchHeader></SearchHeader>
<div class="container width_1200"> <div class="container width_1200">
<h1 class="page-title">文章帮助中心</h1> <h1 class="page-title">文章帮助中心</h1>
@@ -50,6 +50,8 @@
<script setup> <script setup>
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import TopBar from '@/components/indexDecorate/components/TopBar.vue';
import SearchHeader from '@/components/indexDecorate/components/SearchHeader.vue';
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { articleCateList, articleDetail, articleList as fetchArticleList } from '@/api/common.js' import { articleCateList, articleDetail, articleList as fetchArticleList } from '@/api/common.js'

View File

@@ -40,6 +40,105 @@
</div> </div>
<span class="asset-link">查看</span> <span class="asset-link">查看</span>
</button> </button>
<button type="button" class="asset-card" @click="go('Point')">
<div>
<div class="asset-label">我的积分</div>
<div class="asset-value">{{ point }}<span>积分</span></div>
</div>
<span class="asset-link">查看</span>
</button>
<button type="button" class="asset-card" @click="go('MemberGrade')">
<div>
<div class="asset-label">会员等级</div>
<div class="asset-value grade-value">{{ gradeName }}</div>
</div>
<span class="asset-link">查看</span>
</button>
</div>
<div class="panel">
<div class="panel-head">
<span class="panel-title">最近订单</span>
<button type="button" class="panel-more" @click="go('MyOrder')">全部订单 ›</button>
</div>
<div v-if="recentOrders.length" class="order-list">
<button
v-for="order in recentOrders"
:key="order.sn"
type="button"
class="order-row"
@click="goOrderDetail(order.sn)"
>
<div class="order-thumb">
<img
:src="firstItem(order).image"
:alt="firstItem(order).name"
loading="lazy"
decoding="async"
/>
</div>
<div class="order-info">
<div class="order-name">
{{ firstItem(order).name }}
<span v-if="order.orderItems.length > 1" class="order-more">
等{{ order.orderItems.length }}件
</span>
</div>
<div class="order-meta">{{ order.createTime }} · {{ order.storeName }}</div>
</div>
<div class="order-side">
<div class="order-amount">{{ $filters.unitPrice(order.flowPrice, "¥") }}</div>
<div class="order-status">{{ orderStatusText(order.orderStatus) }}</div>
</div>
</button>
</div>
<div v-else-if="ordersLoaded" class="panel-empty">暂无订单</div>
</div>
<div class="panel">
<div class="panel-head">
<span class="panel-title">我的足迹</span>
<button type="button" class="panel-more" @click="go('MyTracks')">查看更多 ›</button>
</div>
<div v-if="tracks.length" class="goods-strip">
<button
v-for="item in tracks"
:key="item.id"
type="button"
class="goods-card"
@click="openGoods(item.id, item.goodsId)"
>
<div class="goods-thumb">
<img :src="item.thumbnail" :alt="item.goodsName" loading="lazy" decoding="async" />
</div>
<div class="goods-name">{{ item.goodsName }}</div>
<div class="goods-price">{{ $filters.unitPrice(item.price, "¥") }}</div>
</button>
</div>
<div v-else-if="tracksLoaded" class="panel-empty">暂无足迹</div>
</div>
<div class="panel">
<div class="panel-head">
<span class="panel-title">我的收藏</span>
<button type="button" class="panel-more" @click="go('Favorites')">查看更多 ›</button>
</div>
<div v-if="favorites.length" class="goods-strip">
<button
v-for="item in favorites"
:key="item.skuId"
type="button"
class="goods-card"
@click="openGoods(item.skuId, item.goodsId)"
>
<div class="goods-thumb">
<img :src="item.image" :alt="item.goodsName" loading="lazy" decoding="async" />
</div>
<div class="goods-name">{{ item.goodsName }}</div>
<div class="goods-price">{{ $filters.unitPrice(item.price, "¥") }}</div>
</button>
</div>
<div v-else-if="favoritesLoaded" class="panel-empty">暂无收藏</div>
</div> </div>
</div> </div>
</template> </template>
@@ -50,9 +149,18 @@ import { useRouter } from "vue-router";
import { Bell } from "@element-plus/icons-vue"; import { Bell } from "@element-plus/icons-vue";
import storage from "@/plugins/storage"; import storage from "@/plugins/storage";
import { getOrderList } from "@/api/order"; import { getOrderList } from "@/api/order";
import { afterSaleList, getMembersWallet, memberCouponList } from "@/api/member"; import {
afterSaleList,
collectList,
getMembersWallet,
memberCouponList,
memberGradeCurrent,
memberPoint,
tracksList,
} from "@/api/member";
import { refreshUnreadMessage } from "@/utils/unreadMessage"; import { refreshUnreadMessage } from "@/utils/unreadMessage";
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
import { orderStatusList } from "./enumeration.js";
defineOptions({ name: "UserCenterHome" }); defineOptions({ name: "UserCenterHome" });
@@ -63,6 +171,12 @@ function totalOf(res) {
return res && res.success ? Number(res.result && res.result.total) || 0 : 0; return res && res.success ? Number(res.result && res.result.total) || 0 : 0;
} }
function recordsOf(res) {
return res && res.success && res.result && Array.isArray(res.result.records)
? res.result.records.filter((item) => item != null)
: [];
}
const displayName = ref("会员"); const displayName = ref("会员");
const waitPay = ref(0); const waitPay = ref(0);
const waitShip = ref(0); const waitShip = ref(0);
@@ -70,6 +184,14 @@ const waitReceive = ref(0);
const afterSale = ref(0); const afterSale = ref(0);
const couponCount = ref(0); const couponCount = ref(0);
const balance = ref(0); const balance = ref(0);
const point = ref(0);
const gradeName = ref("-");
const recentOrders = ref([]);
const tracks = ref([]);
const favorites = ref([]);
const ordersLoaded = ref(false);
const tracksLoaded = ref(false);
const favoritesLoaded = ref(false);
const unreadMsg = computed(() => appStore.unreadMsg); const unreadMsg = computed(() => appStore.unreadMsg);
const unreadLabel = computed(() => (unreadMsg.value > 99 ? "99+" : String(unreadMsg.value))); const unreadLabel = computed(() => (unreadMsg.value > 99 ? "99+" : String(unreadMsg.value)));
@@ -90,6 +212,15 @@ function readUser() {
} }
} }
function firstItem(order) {
return (order.orderItems && order.orderItems[0]) || {};
}
function orderStatusText(status) {
const hit = orderStatusList.find((e) => e.status === status);
return hit ? hit.name : status;
}
function loadSummary() { function loadSummary() {
const page = { pageNumber: 1, pageSize: 1 }; const page = { pageNumber: 1, pageSize: 1 };
Promise.all([ Promise.all([
@@ -100,14 +231,45 @@ function loadSummary() {
memberCouponList({ ...page, memberCouponStatus: "NEW" }).catch(() => null), memberCouponList({ ...page, memberCouponStatus: "NEW" }).catch(() => null),
getMembersWallet().catch(() => null), getMembersWallet().catch(() => null),
refreshUnreadMessage(), refreshUnreadMessage(),
]).then(([pay, ship, receive, afterSaleRes, coupon, wallet]) => { getOrderList({ pageNumber: 1, pageSize: 3 }).catch(() => null),
waitPay.value = totalOf(pay); tracksList({ pageNumber: 1, pageSize: 10, order: "desc", sort: "updateTime" }).catch(() => null),
waitShip.value = totalOf(ship); collectList({ pageNumber: 1, pageSize: 10, type: "GOODS" }).catch(() => null),
waitReceive.value = totalOf(receive); memberPoint().catch(() => null),
afterSale.value = totalOf(afterSaleRes); memberGradeCurrent({ pageNumber: 1, pageSize: 10 }).catch(() => null),
couponCount.value = totalOf(coupon); ]).then(
balance.value = wallet && wallet.success ? wallet.result.memberWallet || 0 : 0; ([
}); pay,
ship,
receive,
afterSaleRes,
coupon,
wallet,
,
recent,
tracksRes,
favoritesRes,
pointRes,
gradeRes,
]) => {
waitPay.value = totalOf(pay);
waitShip.value = totalOf(ship);
waitReceive.value = totalOf(receive);
afterSale.value = totalOf(afterSaleRes);
couponCount.value = totalOf(coupon);
balance.value = wallet && wallet.success ? wallet.result.memberWallet || 0 : 0;
recentOrders.value = recordsOf(recent).filter((order) => order && order.sn);
ordersLoaded.value = true;
tracks.value = recordsOf(tracksRes);
tracksLoaded.value = true;
favorites.value = recordsOf(favoritesRes);
favoritesLoaded.value = true;
point.value = pointRes && pointRes.success && pointRes.result ? pointRes.result.point || 0 : 0;
const grade = gradeRes && gradeRes.success && gradeRes.result ? gradeRes.result.grade : null;
gradeName.value = grade && grade.gradeName ? grade.gradeName : "暂无等级";
}
);
} }
function go(name) { function go(name) {
@@ -118,6 +280,15 @@ function goOrder(tag) {
router.push({ name: "MyOrder", query: { tag } }); router.push({ name: "MyOrder", query: { tag } });
} }
function goOrderDetail(sn) {
router.push({ name: "OrderDetail", query: { sn } });
}
function openGoods(skuId, goodsId) {
const routeUrl = router.resolve({ path: "/goodsDetail", query: { skuId, goodsId } });
window.open(routeUrl.href, "_blank");
}
onMounted(() => { onMounted(() => {
readUser(); readUser();
loadSummary(); loadSummary();
@@ -196,7 +367,7 @@ onMounted(() => {
} }
.asset-grid { .asset-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
} }
.stat-card, .stat-card,
@@ -236,7 +407,12 @@ onMounted(() => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px;
padding: 18px 20px; padding: 18px 20px;
> div {
min-width: 0;
}
} }
.asset-label { .asset-label {
@@ -251,6 +427,9 @@ onMounted(() => {
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 600;
line-height: 28px; line-height: 28px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
span { span {
margin-left: 4px; margin-left: 4px;
@@ -260,8 +439,201 @@ onMounted(() => {
} }
} }
.grade-value {
font-size: 16px;
}
.asset-link { .asset-link {
flex: 0 0 auto;
color: $theme_color; color: $theme_color;
font-size: 14px; font-size: 14px;
white-space: nowrap;
}
.panel {
border: 1px solid #eee;
border-radius: 8px;
background: #fff;
padding: 16px 20px 20px;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.panel-title {
color: #333;
font-size: 16px;
font-weight: 600;
line-height: 24px;
}
.panel-more {
border: none;
background: none;
color: #999;
font-size: 14px;
cursor: pointer;
padding: 0;
&:hover {
color: $theme_color;
}
}
.panel-empty {
padding: 32px 0;
color: #999;
font-size: 14px;
text-align: center;
}
.order-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.order-row {
display: flex;
align-items: center;
gap: 14px;
width: 100%;
padding: 10px 12px;
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #fff;
cursor: pointer;
text-align: left;
&:hover {
border-color: $theme_color;
}
}
.order-thumb {
flex: 0 0 56px;
width: 56px;
height: 56px;
border-radius: 6px;
background: #f5f5f5;
overflow: hidden;
img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
}
.order-info {
flex: 1;
min-width: 0;
}
.order-name {
color: #333;
font-size: 14px;
line-height: 22px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.order-more {
color: #999;
font-size: 13px;
}
.order-meta {
margin-top: 4px;
color: #999;
font-size: 13px;
line-height: 20px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.order-side {
flex: 0 0 auto;
text-align: right;
}
.order-amount {
color: #333;
font-size: 15px;
font-weight: 600;
line-height: 22px;
}
.order-status {
margin-top: 4px;
color: $theme_color;
font-size: 13px;
line-height: 20px;
}
.goods-strip {
display: flex;
gap: 12px;
overflow-x: auto;
padding-bottom: 4px;
}
.goods-card {
flex: 0 0 140px;
display: flex;
flex-direction: column;
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #fff;
overflow: hidden;
cursor: pointer;
text-align: left;
padding: 0;
&:hover {
border-color: $theme_color;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
}
.goods-thumb {
width: 100%;
aspect-ratio: 1;
background: #f5f5f5;
overflow: hidden;
img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
}
.goods-name {
margin: 8px 10px 0;
color: #333;
font-size: 13px;
line-height: 1.4;
height: 36px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.goods-price {
margin: 6px 10px 10px;
color: $theme_color;
font-size: 14px;
font-weight: 600;
line-height: 1.2;
} }
</style> </style>

View File

@@ -27,9 +27,9 @@
</div> </div>
<div class="connect-row"> <div class="connect-row">
<span class="connect-name">{{ wechatConnect.label }}</span> <span class="connect-name">{{ wechatConnect.label }}</span>
<span class="connect-status" :class="{ 'is-bound': isBound(wechatConnect.type) }"> <el-tag :type="isBound(wechatConnect.type) ? 'success' : 'info'" size="small">
{{ isBound(wechatConnect.type) ? "已绑定" : "未绑定" }} {{ isBound(wechatConnect.type) ? "已绑定" : "未绑定" }}
</span> </el-tag>
<el-skeleton v-if="connectLoading" animated class="connect-skeleton" /> <el-skeleton v-if="connectLoading" animated class="connect-skeleton" />
</div> </div>
</div> </div>
@@ -277,8 +277,9 @@ onMounted(() => {
.safe-card { .safe-card {
padding: 20px; padding: 20px;
border: 1px solid #f0f0f0; border: 1px solid #eee;
border-radius: 8px; border-radius: 8px;
background: #fff;
} }
.safe-card-connect { .safe-card-connect {
@@ -295,7 +296,7 @@ onMounted(() => {
width: 48px; width: 48px;
height: 48px; height: 48px;
border-radius: 8px; border-radius: 8px;
background: #fff5f4; background: color-mix(in srgb, #{$theme_color} 8%, #fff);
color: $theme_color; color: $theme_color;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -309,7 +310,7 @@ onMounted(() => {
.safe-title { .safe-title {
margin: 0; margin: 0;
color: #171A26; color: #333;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
line-height: 24px; line-height: 24px;
@@ -333,26 +334,11 @@ onMounted(() => {
} }
.connect-name { .connect-name {
color: #171A26; color: #333;
font-size: 14px; font-size: 14px;
line-height: 22px; line-height: 22px;
} }
.connect-status {
height: 22px;
padding: 0 8px;
border-radius: 4px;
background: #f5f5f5;
color: #999;
font-size: 12px;
line-height: 22px;
}
.connect-status.is-bound {
background: #f0faf3;
color: #1f9d55;
}
.connect-skeleton { .connect-skeleton {
width: 120px; width: 120px;
height: 22px; height: 22px;

View File

@@ -34,7 +34,7 @@
<div class="comment-cell">{{ item.grade === 'GOOD' ? '好评' : item.grade === 'WORSE' ? '差评' : '中评' }}</div> <div class="comment-cell">{{ item.grade === 'GOOD' ? '好评' : item.grade === 'WORSE' ? '差评' : '中评' }}</div>
<div class="comment-content">{{ item.content }}</div> <div class="comment-content">{{ item.content }}</div>
<div class="comment-ops"> <div class="comment-ops">
<button type="button" class="text-link" @click="evaluateDetail(item.id)">评价详情</button> <el-button link type="primary" @click="evaluateDetail(item.id)">评价详情</el-button>
</div> </div>
</div> </div>
</div> </div>
@@ -230,19 +230,4 @@ onMounted(() => {
display: flex; display: flex;
justify-content: center; justify-content: center;
} }
.text-link {
border: none;
background: none;
padding: 0;
color: #171A26;
font-family: inherit;
font-size: 14px;
line-height: 22px;
cursor: pointer;
&:hover {
color: $theme_color;
}
}
</style> </style>

View File

@@ -1,185 +1,224 @@
<template> <template>
<div class="add-eval"> <div class="complain-detail">
<UserCenterLayout> <UserCenterLayout>
<div class="title"> <div class="detail-head">
<p> <el-tag :type="statusTagType" effect="dark">{{ statusText }}</el-tag>
<span class="fontsize_16 global_color">{{ statusLabel[detail.complainStatus] }}</span> <span class="create-time">创建时间:{{ detail.createTime || "-" }}</span>
<span class="color999 ml_20">创建时间:</span><span>{{ detail.createTime }}</span>
<span class="color999 ml_20">{{ detail.createTime }}</span>
</p>
</div> </div>
<el-alert class="l_title" show-icon type="warning">我的申诉信息</el-alert> <section class="panel">
<table cellspacing="0" cellpadding='0' border="1"> <div class="panel-title">我的申诉信息</div>
<tbody> <div class="info-row">
<tr> <span class="info-label">投诉商品</span>
<td>投诉商品</td> <div class="info-value">
<td class="hover-color" @click="linkTo(`/goodsDetail?goodsId=${detail.goodsId}&skuId=${detail.skuId}`)"><img :src="detail.goodsImage" loading="lazy" decoding="async" width="60" alt=""> &nbsp;{{ detail.goodsName }}</td> <button
</tr> v-if="detail.goodsId"
<tr> type="button"
<td>投诉主题</td> class="goods-info"
<td>{{ detail.complainTopic }}</td> @click="goGoodsDetail(detail.skuId, detail.goodsId)"
</tr> >
<tr> <img :src="detail.goodsImage" loading="lazy" decoding="async" alt="" />
<td>投诉内容</td> <span class="goods-name">{{ detail.goodsName }}</span>
<td>{{ detail.content }}</td> </button>
</tr> <span v-else class="empty-text">暂无</span>
<tr> </div>
<td>补充内容</td> </div>
<td> <div class="info-row">
<div style="display:flex;align-items:center;"> <span class="info-label">投诉主题</span>
<template v-if="detail.images"> <span class="info-value">{{ detail.complainTopic || "暂无" }}</span>
<div class="demo-upload-list" v-for="(img, index) in detail.images.split(',')" :key="index"> </div>
<img :src="img" loading="lazy" decoding="async"> <div class="info-row">
<span class="info-label">投诉内容</span>
<span class="info-value">{{ detail.content || "暂无" }}</span>
</div>
<div class="info-row">
<span class="info-label">补充内容</span>
<div class="info-value">
<div v-if="images.length" class="img-list">
<div
v-for="(img, index) in images"
:key="index"
class="demo-upload-list"
@click="handleView(img)"
>
<img :src="img" loading="lazy" decoding="async" />
<div class="demo-upload-list-cover"> <div class="demo-upload-list-cover">
<el-icon @click="handleView(img)"><View /></el-icon> <el-icon><View /></el-icon>
</div> </div>
</div> </div>
</template> </div>
<div v-else>暂无</div> <span v-else class="empty-text">暂无</span>
</div> </div>
</td> </div>
</tr> </section>
</tbody>
</table>
<el-alert class="l_title" show-icon type="warning">商家申诉信息</el-alert> <section class="panel">
<table cellspacing="0" cellpadding='0' border="1"> <div class="panel-title">商家申诉信息</div>
<tbody> <div class="info-row">
<tr> <span class="info-label">申诉时间</span>
<td>申诉时间</td> <span class="info-value">{{ detail.appealTime || "暂无" }}</span>
<td>{{ detail.appealTime || '暂无' }}</td> </div>
</tr> <div class="info-row">
<tr> <span class="info-label">申诉内容</span>
<td>申诉内容</td> <span class="info-value">{{ detail.appealContent || "暂无" }}</span>
<td>{{ detail.appealContent || '暂无' }}</td> </div>
</tr> <div class="info-row">
<tr> <span class="info-label">申诉凭证</span>
<td>申诉凭证</td> <div class="info-value">
<td> <div v-if="appealImages.length" class="img-list">
<div
<div style="display:flex;align-items:center;"> v-for="(img, index) in appealImages"
<template v-if="detail.appealImages"> :key="index"
<div class="demo-upload-list" v-for="(img, index) in detail.appealImages.split(',')" :key="index"> class="demo-upload-list"
<img :src="img" loading="lazy" decoding="async"> @click="handleView(img)"
>
<img :src="img" loading="lazy" decoding="async" />
<div class="demo-upload-list-cover"> <div class="demo-upload-list-cover">
<el-icon @click="handleView(img)"><View /></el-icon> <el-icon><View /></el-icon>
</div> </div>
</div> </div>
</template> </div>
<div v-else>暂无</div> <span v-else class="empty-text">暂无</span>
</div> </div>
</td> </div>
</tr> </section>
</tbody>
</table>
<el-alert class="l_title" show-icon type="warning">平台仲裁</el-alert> <section class="panel">
<table cellspacing="0" cellpadding='0' border="1"> <div class="panel-title">平台仲裁</div>
<tbody> <div class="info-row">
<tr> <span class="info-label">仲裁意见</span>
<td>仲裁意见</td> <span class="info-value">{{ detail.arbitrationResult || "暂无" }}</span>
<td>{{ detail.arbitrationResult || '暂无' }}</td> </div>
</tr> </section>
</tbody>
</table>
<el-alert class="l_title" show-icon type="warning">对话详情</el-alert> <section class="panel">
<div class="speak-way" v-if="detail.orderComplaintCommunications && detail.orderComplaintCommunications.length"> <div class="panel-title">对话详情</div>
<div <div v-if="communications.length" class="chat-list">
class="speak-msg seller" <div
:class="{'speak-buyer': item.owner == 'BUYER', 'speak-platform': item.owner == 'PLATFORM', 'speak-seller': item.owner == 'STORE',}" v-for="(item, i) in communications"
:key="i" :key="i"
v-for="(item, i) in detail.orderComplaintCommunications" class="chat-item"
> :class="{
{{ 'is-buyer': item.owner === 'BUYER',
item.owner == "PLATFORM" 'is-platform': item.owner === 'PLATFORM',
? "平台" }"
: item.owner == "BUYER" >
? "买家" <div class="chat-meta">{{ ownerName(item.owner) }} · {{ item.createTime }}</div>
: "卖家" <div class="chat-bubble">{{ item.content }}</div>
}}[{{ item.createTime }}]: </div>
<span>{{ item.content }}</span> </div>
</div> <div v-else class="empty-text">暂无对话</div>
</div>
<div v-else>暂无对话</div> <div v-if="detail.complainStatus !== 'COMPLETE'" class="reply-box">
<table cellspacing="0" cellpadding='0' border="1" v-if="detail.complainStatus!='COMPLETE'"> <el-input
<tbody> v-model="params.content"
<tr> type="textarea"
<td>回复:</td> :rows="3"
<td><label> maxlength="200"
<input type="textarea" maxlength="200" :rows="4" clearable show-word-limit
style="width:260px" v-model="params.content" /> placeholder="请输入回复内容"
</label></td> />
</tr> <el-button
<tr> type="primary"
<td></td> :loading="submitLoading"
<td> class="reply-btn"
<el-button type="primary" :loading="submitLoading" @click="handleSubmit" style="margin-left: 5px"> @click="handleSubmit"
>
回复 回复
</el-button> </el-button>
</td> </div>
</tr> </section>
</tbody>
</table>
<el-dialog title="图片预览" v-model="visible"> <el-dialog title="图片预览" v-model="visible">
<img :src="previewImage" loading="lazy" decoding="async" v-if="visible" style="width: 100%"> <img :src="previewImage" loading="lazy" decoding="async" v-if="visible" style="width: 100%" />
</el-dialog> </el-dialog>
</UserCenterLayout> </UserCenterLayout>
</div> </div>
</template> </template>
<script setup> <script setup>
import { onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { View } from '@element-plus/icons-vue'; import { View } from "@element-plus/icons-vue";
import {getComplainDetail} from '@/api/member.js'; import { getComplainDetail } from "@/api/member.js";
import {communication} from '@/api/order'; import { communication } from "@/api/order";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const detail = ref({}); // 评价详情 const detail = ref({}); // 投诉详情
const visible = ref(false); // 图片预览 const visible = ref(false); // 图片预览
const previewImage = ref(''); // 预览图片地址 const previewImage = ref(""); // 预览图片地址
const loading = ref(false); // 加载状态
const submitLoading = ref(false); // 回复消息loading const submitLoading = ref(false); // 回复消息loading
// 状态 // 状态
const statusLabel = { const statusLabel = {
NO_APPLY: '未申请', NO_APPLY: "未申请",
APPLYING: '申请中', APPLYING: "申请中",
COMPLETE: '已完成', COMPLETE: "已完成",
EXPIRED: '已失效', EXPIRED: "已失效",
CANCEL: '已取消', CANCEL: "已取消",
NEW: '待审核' NEW: "待审核",
}; };
// 商家回复内容 // 商家回复内容
const params = reactive({ const params = reactive({
content: '', content: "",
complainId: '' complainId: "",
}); });
function getDetail () { // 获取投诉详情 const statusText = computed(
getComplainDetail(route.query.id).then(res => { () => statusLabel[detail.value.complainStatus] || detail.value.complainStatus || ""
if (res.success) detail.value = res.result );
}) const statusTagType = computed(() => {
const map = {
COMPLETE: "success",
APPLYING: "warning",
NEW: "warning",
CANCEL: "info",
EXPIRED: "info",
NO_APPLY: "info",
};
return map[detail.value.complainStatus] || "info";
});
const images = computed(() =>
detail.value.images ? detail.value.images.split(",").filter(Boolean) : []
);
const appealImages = computed(() =>
detail.value.appealImages ? detail.value.appealImages.split(",").filter(Boolean) : []
);
const communications = computed(() =>
Array.isArray(detail.value.orderComplaintCommunications)
? detail.value.orderComplaintCommunications
: []
);
function ownerName(owner) {
return owner === "PLATFORM" ? "平台" : owner === "BUYER" ? "买家" : "卖家";
} }
function goGoodsDetail (skuId, goodsId) { // 跳转商品详情
function getDetail() {
// 获取投诉详情
getComplainDetail(route.query.id).then((res) => {
if (res.success) detail.value = res.result;
});
}
function goGoodsDetail(skuId, goodsId) {
// 跳转商品详情
let routerUrl = router.resolve({ let routerUrl = router.resolve({
path: '/goodsDetail', path: "/goodsDetail",
query: {skuId, goodsId} query: { skuId, goodsId },
}) });
window.open(routerUrl.href, '_blank') window.open(routerUrl.href, "_blank");
} }
function handleView (name) { // 预览图片 function handleView(name) {
// 预览图片
previewImage.value = name; previewImage.value = name;
visible.value = true; visible.value = true;
} }
// 回复消息 // 回复消息
function handleSubmit () { function handleSubmit() {
if (params.content === '') { if (params.content === "") {
Message.error('请填写对话内容'); Message.error("请填写对话内容");
return; return;
} }
submitLoading.value = true; submitLoading.value = true;
@@ -187,64 +226,129 @@ function handleSubmit () {
communication(params).then((res) => { communication(params).then((res) => {
submitLoading.value = false; submitLoading.value = false;
if (res.success) { if (res.success) {
Message.success('对话成功'); Message.success("对话成功");
params.content = ''; params.content = "";
getDetail(); getDetail();
} }
}); });
} }
onMounted(() => { onMounted(() => {
getDetail() getDetail();
}) });
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.speak-way { .detail-head {
background-color: #fff; display: flex;
width: 100%; align-items: center;
border: 1px solid #999; gap: 12px;
padding-bottom: 4px;
}
.speak-seller { .create-time {
background-color: #fff2c6; color: #999;
} font-size: 14px;
}
.speak-platform { .panel {
background-color: #ffe2d1; margin-top: 16px;
} border: 1px solid #eee;
border-radius: 8px;
background: #fff;
padding: 16px 20px;
}
.speak-buyer { .panel-title {
background-color: #c1d6d5; color: #333;
font-size: 16px;
font-weight: 600;
line-height: 24px;
padding-bottom: 12px;
border-bottom: 1px solid #f5f5f5;
}
.info-row {
display: flex;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid #f5f5f5;
&:last-child {
border-bottom: none;
padding-bottom: 4px;
} }
} }
.speak-msg { .info-label {
height: 40px; flex: 0 0 80px;
line-height: 40px; color: #999;
padding: 0 5px; font-size: 14px;
border-radius: 5px; line-height: 22px;
margin: 5px; }
background-color: #eee;
.info-value {
flex: 1;
min-width: 0;
color: #333;
font-size: 14px;
line-height: 22px;
word-break: break-all;
}
.empty-text {
color: #999;
font-size: 14px;
}
.goods-info {
display: inline-flex;
align-items: center;
gap: 12px;
padding: 0;
border: none;
background: none;
cursor: pointer;
img {
width: 56px;
height: 56px;
border-radius: 6px;
background: #f5f5f5;
object-fit: cover;
}
.goods-name {
color: #333;
font-size: 14px;
}
&:hover .goods-name {
color: $theme_color;
}
}
.img-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
} }
.demo-upload-list { .demo-upload-list {
display: inline-block;
width: 100px; width: 100px;
height: 100px; height: 100px;
text-align: center; border-radius: 6px;
line-height: 100px;
border: 1px solid transparent;
border-radius: 4px;
overflow: hidden; overflow: hidden;
background: #fff; background: #f5f5f5;
position: relative; position: relative;
box-shadow: 0 1px 1px rgba(0, 0, 0, .2); box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
margin-right: 4px; cursor: pointer;
margin-top: 10px;
} }
.demo-upload-list img { .demo-upload-list img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover;
} }
.demo-upload-list-cover { .demo-upload-list-cover {
@@ -254,51 +358,70 @@ onMounted(() => {
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
background: rgba(0, 0, 0, .6); background: rgba(0, 0, 0, 0.6);
align-items: center;
justify-content: center;
color: #fff;
font-size: 24px;
} }
.demo-upload-list:hover .demo-upload-list-cover { .demo-upload-list:hover .demo-upload-list-cover {
display: block; display: flex;
} }
.demo-upload-list-cover i { .chat-list {
color: #fff; display: flex;
font-size: 30px; flex-direction: column;
cursor: pointer; gap: 12px;
margin: 0 2px; padding: 16px 0 4px;
} }
.icon-upload { .chat-item {
width: 58px; display: flex;
height: 58px; flex-direction: column;
line-height: 58px; align-items: flex-start;
text-align: center; max-width: 80%;
display: inline-block;
border: 1px dashed #999;
border-radius: 4px;
margin-top: 10px;
&:hover { &.is-buyer {
cursor: pointer; align-self: flex-end;
border-color: $theme_color; align-items: flex-end;
}
}
.l_title { .chat-bubble {
margin: 10px 0; background: $theme_color;
} color: #fff;
table {
width: 100%;
border-color: #eee;
td {
padding: 10px;
&:nth-child(1) {
width: 120px;
color: #999;
} }
} }
&.is-platform .chat-bubble {
background: #fdf3e4;
color: #8a5a00;
}
}
.chat-meta {
margin-bottom: 4px;
color: #999;
font-size: 12px;
line-height: 18px;
}
.chat-bubble {
padding: 8px 12px;
border-radius: 8px;
background: #f5f5f5;
color: #333;
font-size: 14px;
line-height: 22px;
word-break: break-all;
}
.reply-box {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #f5f5f5;
}
.reply-btn {
margin-top: 10px;
} }
</style> </style>

View File

@@ -32,17 +32,19 @@
{{ item.goodsName }} {{ item.goodsName }}
</div> </div>
</div> </div>
<div class="complain-cell">{{ statusLabel[item.complainStatus] || item.complainStatus }}</div> <div class="complain-cell">
<el-tag size="small" :type="statusTagType(item.complainStatus)">{{ statusLabel[item.complainStatus] || item.complainStatus }}</el-tag>
</div>
<div class="complain-topic">{{ item.complainTopic }}</div> <div class="complain-topic">{{ item.complainTopic }}</div>
<div class="complain-content">{{ item.content }}</div> <div class="complain-content">{{ item.content }}</div>
<div class="complain-ops"> <div class="complain-ops">
<button type="button" class="text-link" @click="goDetail(item.id)">投诉详情</button> <el-button link type="primary" @click="goDetail(item.id)">投诉详情</el-button>
<button <el-button
v-if="item.complainStatus === 'APPLYING' || item.complainStatus === 'NEW'" v-if="item.complainStatus === 'APPLYING' || item.complainStatus === 'NEW'"
type="button" link
class="text-link" type="danger"
@click="cancel(item.id)" @click="cancel(item.id)"
>取消投诉</button> >取消投诉</el-button>
</div> </div>
</div> </div>
</div> </div>
@@ -84,6 +86,18 @@ const params = reactive({ // 请求参数
pageSize: 20 pageSize: 20
}); });
function statusTagType(status) { // 投诉状态标签类型
const map = {
COMPLETE: 'success',
APPLYING: 'warning',
NEW: 'warning',
CANCEL: 'info',
EXPIRED: 'info',
NO_APPLY: 'info'
};
return map[status] || 'info';
}
function getList() { // 获取投诉列表 function getList() { // 获取投诉列表
complainList(params).then(res => { complainList(params).then(res => {
if (res.success) { if (res.success) {
@@ -268,20 +282,9 @@ onMounted(() => {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
}
.text-link { .el-button + .el-button {
border: none; margin-left: 0;
background: none;
padding: 0;
color: #171A26;
font-family: inherit;
font-size: 14px;
line-height: 22px;
cursor: pointer;
&:hover {
color: $theme_color;
} }
} }
</style> </style>

View File

@@ -33,10 +33,10 @@
</div> </div>
<div v-if="params.type === 'GOODS'" class="fav-price">{{ $filters.unitPrice(item.price, '¥') }}</div> <div v-if="params.type === 'GOODS'" class="fav-price">{{ $filters.unitPrice(item.price, '¥') }}</div>
<div class="fav-ops"> <div class="fav-ops">
<button v-if="params.type === 'GOODS'" type="button" class="op-btn" @click="buynow(item.skuId, item.goodsId)">立即购买</button> <el-button v-if="params.type === 'GOODS'" type="primary" size="small" @click="buynow(item.skuId, item.goodsId)">立即购买</el-button>
<button v-else type="button" class="op-btn" @click="goShop(item.id)">查看店铺</button> <el-button v-else type="primary" size="small" @click="goShop(item.id)">查看店铺</el-button>
<button v-if="params.type === 'GOODS'" type="button" class="text-link" @click="cancel(item.skuId)">取消收藏</button> <el-button v-if="params.type === 'GOODS'" link type="danger" @click="cancel(item.skuId)">取消收藏</el-button>
<button v-else type="button" class="text-link" @click="cancelStore(item.id)">取消收藏</button> <el-button v-else link type="danger" @click="cancelStore(item.id)">取消收藏</el-button>
</div> </div>
</div> </div>
<el-skeleton size="large" fix v-if="spinShow"></el-skeleton> <el-skeleton size="large" fix v-if="spinShow"></el-skeleton>
@@ -72,10 +72,10 @@
</div> </div>
<div v-if="params.type === 'GOODS'" class="fav-price">{{ $filters.unitPrice(item.price, '¥') }}</div> <div v-if="params.type === 'GOODS'" class="fav-price">{{ $filters.unitPrice(item.price, '¥') }}</div>
<div class="fav-ops"> <div class="fav-ops">
<button v-if="params.type === 'GOODS'" type="button" class="op-btn" @click="buynow(item.skuId, item.goodsId)">立即购买</button> <el-button v-if="params.type === 'GOODS'" type="primary" size="small" @click="buynow(item.skuId, item.goodsId)">立即购买</el-button>
<button v-else type="button" class="op-btn" @click="goShop(item.id)">查看店铺</button> <el-button v-else type="primary" size="small" @click="goShop(item.id)">查看店铺</el-button>
<button v-if="params.type === 'GOODS'" type="button" class="text-link" @click="cancel(item.skuId)">取消收藏</button> <el-button v-if="params.type === 'GOODS'" link type="danger" @click="cancel(item.skuId)">取消收藏</el-button>
<button v-else type="button" class="text-link" @click="cancelStore(item.id)">取消收藏</button> <el-button v-else link type="danger" @click="cancelStore(item.id)">取消收藏</el-button>
</div> </div>
</div> </div>
<el-skeleton size="large" fix v-if="spinShow"></el-skeleton> <el-skeleton size="large" fix v-if="spinShow"></el-skeleton>
@@ -316,7 +316,7 @@ onMounted(() => {
margin-top: 6px; margin-top: 6px;
padding: 0 6px; padding: 0 6px;
border-radius: 4px; border-radius: 4px;
background: #fff1f0; background: color-mix(in srgb, #{$theme_color} 8%, #fff);
color: $theme_color; color: $theme_color;
font-size: 12px; font-size: 12px;
line-height: 20px; line-height: 20px;
@@ -336,37 +336,4 @@ onMounted(() => {
justify-content: center; justify-content: center;
gap: 16px; gap: 16px;
} }
.op-btn {
min-width: 96px;
height: 32px;
padding: 0 12px;
border: 1px solid $theme_color;
border-radius: 4px;
background: $theme_color;
color: #fff;
font-family: inherit;
font-size: 14px;
line-height: 30px;
cursor: pointer;
&:hover {
opacity: 0.9;
}
}
.text-link {
border: none;
background: none;
padding: 0;
color: #171A26;
font-family: inherit;
font-size: 14px;
line-height: 22px;
cursor: pointer;
&:hover {
color: $theme_color;
}
}
</style> </style>

View File

@@ -0,0 +1,51 @@
<template>
<section class="benefits-panel">
<h3>会员权益</h3>
<div class="grade-options">
<button v-for="item in enabledGrades" :key="item.grade.id" :class="{active: selectedId === item.grade.id}" @click="selectedId = item.grade.id">
{{ item.grade.gradeName }}<small v-if="item.grade.id === currentGradeId">当前等级</small>
</button>
</div>
<p class="benefit-subtitle">{{ selected?.grade?.gradeName || '暂无等级' }} · 点击查看权益说明</p>
<el-empty v-if="!selected?.benefits?.length" description="暂无权益" />
<div v-else class="benefits-grid">
<button v-for="benefit in selected.benefits" :key="benefit.id" class="benefit-card" :class="{locked: !owned(benefit)}" @click="detail = benefit">
<img v-if="benefit.benefitLogo" :src="benefit.benefitLogo" alt="" />
<strong>{{ benefit.benefitName }}</strong>
<span>{{ owned(benefit) ? '已享有' : '该等级专享' }}</span>
</button>
</div>
<el-dialog :model-value="!!detail" :title="detail?.benefitName || '权益说明'" width="520px" @close="detail = null">
<template v-if="detail">
<p>{{ owned(detail) ? '当前等级已享有该权益' : '此权益属于所选等级,当前尚未享有' }}</p>
<p v-if="detail.benefitType === 'POINT_MULTIPLIER'">购物积分倍率:{{ config(detail).multiplier || 1 }} 倍,同类权益取最高值。</p>
<p v-if="detail.benefitType === 'GIFT_POINT'">首次达到等级赠送 {{ config(detail).giftPoint || 0 }} 积分。</p>
<div class="benefit-description" v-html="detail.benefitDesc || '暂无说明'" />
</template>
</el-dialog>
</section>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
const props = defineProps({gradeDetails:{type:Array,default:()=>[]},currentGradeId:{type:String,default:''}})
const selectedId = ref(''), detail = ref(null)
const enabledGrades = computed(()=>props.gradeDetails.filter(item=>item.grade?.gradeState === 'OPEN'))
const selected = computed(()=>enabledGrades.value.find(item=>item.grade.id === selectedId.value))
const current = computed(()=>enabledGrades.value.find(item=>item.grade.id === props.currentGradeId))
const owned = benefit => (current.value?.benefits || []).some(item=>item.id === benefit.id)
function config(benefit) { try { return JSON.parse(benefit.benefitConfig || '{}') } catch { return {} } }
watch([()=>props.currentGradeId,enabledGrades],()=>{
if(!selectedId.value || !enabledGrades.value.some(item=>item.grade.id===selectedId.value)) selectedId.value=props.currentGradeId || enabledGrades.value[0]?.grade.id || ''
},{immediate:true})
</script>
<style scoped>
.benefits-panel { margin:20px 0; padding:24px; background:#fff; border:1px solid #eee; border-radius:8px; }
h3 { margin:0 0 20px; font-size:18px; } .grade-options { display:flex; flex-wrap:wrap; gap:10px; }
.grade-options button { padding:10px 18px; border:1px solid #ddd; background:#fff; border-radius:6px; cursor:pointer; }
.grade-options button.active { background:#fff8eb; border-color:#c99a47; color:#866021; } small { margin-left:8px; }
.benefit-subtitle { color:#777; margin:20px 0; }.benefits-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(140px,1fr)); gap:16px; }
.benefit-card { padding:20px 10px; display:flex; align-items:center; flex-direction:column; gap:12px; border:1px solid #f0e3cb; background:#fffcf6; border-radius:8px; cursor:pointer; }
.benefit-card img { width:48px; height:48px; object-fit:contain; }.benefit-card span { font-size:12px; color:#986d2a; }
.benefit-card.locked { background:#fafafa; border-color:#eee; color:#888; }.locked img { filter:grayscale(1); opacity:.5; }
.benefit-description { line-height:1.8; overflow-wrap:anywhere; }.benefit-description :deep(img) { max-width:100%; }
</style>

View File

@@ -9,63 +9,76 @@
<span v-for="b in currentGradeBenefits" :key="b.id" class="benefit-tag">{{ b.benefitName || '-' }}</span> <span v-for="b in currentGradeBenefits" :key="b.id" class="benefit-tag">{{ b.benefitName || '-' }}</span>
</div> </div>
<div class="grade-exp">当前经验值:<span class="num">{{ currentExperience || 0 }}</span></div> <div class="grade-exp">当前经验值:<span class="num">{{ currentExperience || 0 }}</span></div>
<div class="grade-next" v-if="nextGrade"> <template v-if="nextGrade">
距 {{ nextGrade.gradeName }} 还差 <span class="num">{{ diffExperience }}</span> 经验值 <div class="grade-next">
</div> 距 {{ nextGrade.gradeName }} 还差 <span class="num">{{ diffExperience }}</span> 经验值
</div>
<el-progress
class="grade-progress"
:percentage="progressPercent"
:stroke-width="8"
:show-text="false"
color="#f0c46c"
/>
</template>
<div class="grade-next" v-else>已达最高等级</div> <div class="grade-next" v-else>已达最高等级</div>
</div> </div>
</div> </div>
<div class="grade-tabs"> <MemberBenefitPanel :grade-details="gradeDetails" :current-grade-id="String(currentGradeId)" />
<div class="grade-tab" :class="{ active: activeGradeTab === 'rules' }" @click="activeGradeTab = 'rules'">获取经验值方式</div>
<div class="grade-tab" :class="{ active: activeGradeTab === 'logs' }" @click="activeGradeTab = 'logs'">经验值记录</div>
</div>
<div v-show="activeGradeTab === 'rules'" v-loading="rulesLoading"> <div class="grade-panel">
<empty v-if="!rulesLoading && !ruleList.length" /> <div class="grade-tabs">
<div v-else class="rule-list"> <div class="grade-tab" :class="{ active: activeGradeTab === 'rules' }" @click="activeGradeTab = 'rules'">获取经验值方式</div>
<div class="rule-item" v-for="row in ruleList" :key="row.ruleKey || row.id"> <div class="grade-tab" :class="{ active: activeGradeTab === 'logs' }" @click="activeGradeTab = 'logs'">经验值记录</div>
<div class="rule-top">
<div class="rule-name">{{ row.ruleName || row.ruleKey || '-' }}</div>
<span class="rule-status" :class="{ done: row.completed === true }">{{ row.completed === true ? '已完成' : '未完成' }}</span>
</div>
<div class="rule-desc">{{ getRuleDesc(row.ruleKey) }}</div>
<div class="rule-meta">
<span>单次获得 <b>+{{ row.value == null ? 0 : row.value }}</b></span>
<span v-if="row.maxValue != null && Number(row.maxValue) > 0">限额 <b>{{ row.maxValue }}</b></span>
<span>已获得 <b>{{ row.gainedExperience == null ? 0 : row.gainedExperience }}</b></span>
</div>
</div>
</div> </div>
<div class="rules-desc" v-if="rulesDescription">{{ rulesDescription }}</div>
</div>
<div v-show="activeGradeTab === 'logs'" v-loading="logLoading"> <div v-show="activeGradeTab === 'rules'" v-loading="rulesLoading">
<empty v-if="!(experienceLogs.records || []).length" /> <empty v-if="!rulesLoading && !ruleList.length" />
<div v-else class="log-list"> <div v-else class="rule-list">
<div class="log-head"> <div class="rule-item" v-for="row in ruleList" :key="row.ruleKey || row.id">
<span>时间</span> <div class="rule-top">
<span>规则</span> <div class="rule-name">{{ row.ruleName || row.ruleKey || '-' }}</div>
<span>变动值</span> <span class="rule-status" :class="{ done: row.completed === true }">{{ row.completed === true ? '已完成' : '未完成' }}</span>
</div> </div>
<div class="log-row" v-for="(row, index) in experienceLogs.records" :key="row.id || index"> <div class="rule-desc">{{ getRuleDesc(row.ruleKey) }}</div>
<div class="log-time">{{ row.createTime }}</div> <div class="rule-meta">
<div class="log-rule">{{ getRuleName(row.ruleKey) }}</div> <span>单次获得 <b>+{{ row.value == null ? 0 : row.value }}</b></span>
<div class="log-point" :class="getLogDeltaSign(row) === '+' ? 'is-plus' : 'is-minus'"> <span v-if="row.maxValue != null && Number(row.maxValue) > 0">限额 <b>{{ row.maxValue }}</b></span>
{{ getLogDeltaSign(row) }}{{ getLogDeltaAbsValue(row) }} <span>已获得 <b>{{ row.gainedExperience == null ? 0 : row.gainedExperience }}</b></span>
</div>
</div> </div>
</div> </div>
<div class="rules-desc" v-if="rulesDescription">{{ rulesDescription }}</div>
</div> </div>
<div class="grade-pagination">
<el-pagination <div v-show="activeGradeTab === 'logs'" v-loading="logLoading">
v-model:current-page="params.pageNumber" <empty v-if="!(experienceLogs.records || []).length" />
v-model:page-size="params.pageSize" <div v-else class="log-list">
:total="experienceLogs.total || 0" <div class="log-head">
@current-change="changePage" <span>时间</span>
@size-change="changePageSize" <span>规则</span>
:page-sizes="[20, 50, 100]" <span>变动值</span>
layout="total, sizes, prev, pager, next, jumper" </div>
/> <div class="log-row" v-for="(row, index) in experienceLogs.records" :key="row.id || index">
<div class="log-time">{{ row.createTime }}</div>
<div class="log-rule">{{ getRuleName(row.ruleKey) }}</div>
<div class="log-point" :class="getLogDeltaSign(row) === '+' ? 'is-plus' : 'is-minus'">
{{ getLogDeltaSign(row) }}{{ getLogDeltaAbsValue(row) }}
</div>
</div>
</div>
<div class="grade-pagination">
<el-pagination
v-model:current-page="params.pageNumber"
v-model:page-size="params.pageSize"
:total="experienceLogs.total || 0"
@current-change="changePage"
@size-change="changePageSize"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
/>
</div>
</div> </div>
</div> </div>
</UserCenterLayout> </UserCenterLayout>
@@ -76,6 +89,7 @@
<script setup> <script setup>
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import MemberBenefitPanel from './MemberBenefitPanel.vue'
import { memberGradeCurrent, memberGradeList, memberGradeRules } from '@/api/member.js' import { memberGradeCurrent, memberGradeList, memberGradeRules } from '@/api/member.js'
defineOptions({ name: 'MemberGrade' }) defineOptions({ name: 'MemberGrade' })
@@ -92,7 +106,6 @@ const currentGradeId = ref('')
const selectedRuleKey = ref('') const selectedRuleKey = ref('')
const activeGradeTab = ref('rules') const activeGradeTab = ref('rules')
const logLoading = ref(false) const logLoading = ref(false)
const gradeLoading = ref(false)
const params = reactive({ const params = reactive({
pageNumber: 1, pageNumber: 1,
pageSize: 20 pageSize: 20
@@ -137,6 +150,15 @@ const diffExperience = computed(() => {
return diff > 0 ? diff : 0 return diff > 0 ? diff : 0
}) })
/** 当前经验占下一级所需经验的百分比(无下一级时视为 100%) */
const progressPercent = computed(() => {
if (!nextGrade.value || !nextGrade.value.requiredExperience) return 100
const required = Number(nextGrade.value.requiredExperience)
if (!required || required <= 0) return 100
const percent = Math.round((Number(currentExperience.value || 0) / required) * 100)
return Math.min(100, Math.max(0, percent))
})
/** 当前等级在列表详情中对应的启用权益(顺序与后台 benefits 一致) */ /** 当前等级在列表详情中对应的启用权益(顺序与后台 benefits 一致) */
const currentGradeBenefits = computed(() => { const currentGradeBenefits = computed(() => {
const id = currentGradeId.value const id = currentGradeId.value
@@ -174,7 +196,6 @@ function loadRules () {
} }
function loadGradeList () { function loadGradeList () {
gradeLoading.value = true
memberGradeList() memberGradeList()
.then(res => { .then(res => {
if (res && res.success) { if (res && res.success) {
@@ -197,9 +218,6 @@ function loadGradeList () {
gradeList.value = [] gradeList.value = []
} }
}) })
.finally(() => {
gradeLoading.value = false
})
} }
function loadCurrent () { function loadCurrent () {
@@ -223,12 +241,6 @@ function loadCurrent () {
}) })
} }
function selectRule (ruleKey) {
selectedRuleKey.value = ruleKey || ''
params.pageNumber = 1
loadCurrent()
}
function changePage (val) { function changePage (val) {
params.pageNumber = val params.pageNumber = val
loadCurrent() loadCurrent()
@@ -283,30 +295,31 @@ function getLogDeltaAbsValue (row) {
const num = getLogDeltaRaw(row) const num = getLogDeltaRaw(row)
return Math.abs(num) return Math.abs(num)
} }
function getLogDeltaColor (row) {
return getLogDeltaRaw(row) >= 0 ? 'green' : 'red'
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.grade-card { .grade-card {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 20px;
margin-bottom: 8px; padding: 24px 28px;
padding: 16px; border-radius: 12px;
background: #fafafa; background: linear-gradient(135deg, #3a4157 0%, #23262f 100%);
border-radius: 8px; box-shadow: 0 6px 20px rgba(35, 38, 47, 0.25);
} }
.grade-icon { .grade-icon {
width: 56px; width: 64px;
height: 56px; height: 64px;
object-fit: contain; object-fit: contain;
flex-shrink: 0; flex-shrink: 0;
} }
.grade-info {
flex: 1;
min-width: 0;
}
.grade-name-row { .grade-name-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -315,39 +328,57 @@ function getLogDeltaColor (row) {
} }
.grade-name { .grade-name {
color: #171A26; color: #f0c46c;
font-size: 18px; font-size: 20px;
font-weight: 600; font-weight: 600;
line-height: 1.4; line-height: 1.4;
letter-spacing: 1px;
} }
.benefit-tag { .benefit-tag {
padding: 0 8px; padding: 0 10px;
border-radius: 4px; border: 1px solid rgba(240, 196, 108, 0.4);
background: #fff; border-radius: 10px;
color: $theme_color; background: rgba(240, 196, 108, 0.12);
font-size: 14px; color: #f0d9a8;
line-height: 22px; font-size: 12px;
line-height: 20px;
} }
.grade-exp, .grade-exp,
.grade-next { .grade-next {
margin-top: 6px; margin-top: 8px;
color: #666; color: rgba(255, 255, 255, 0.75);
font-size: 14px; font-size: 14px;
line-height: 22px; line-height: 22px;
} }
.num { .num {
color: $theme_color; color: #f0c46c;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
} }
.grade-progress {
margin-top: 10px;
:deep(.el-progress-bar__outer) {
background: rgba(255, 255, 255, 0.15);
}
}
.grade-panel {
margin-top: 16px;
padding: 4px 20px 20px;
border: 1px solid #eee;
border-radius: 8px;
background: #fff;
}
.grade-tabs { .grade-tabs {
display: flex; display: flex;
gap: 24px; gap: 24px;
margin: 16px 0; margin-bottom: 16px;
border-bottom: 1px solid #f0f0f0; border-bottom: 1px solid #f0f0f0;
} }

View File

@@ -15,10 +15,10 @@
<template #append><span>输入验证码</span></template> <template #append><span>输入验证码</span></template>
</el-input> </el-input>
</el-form-item> </el-form-item>
<el-form-item>
<el-button type="primary" @click="submitRegister">下一步</el-button>
</el-form-item>
</el-form> </el-form>
<div v-if="(status == 2 || status == 3) && !showPwd" style="text-align: center;width: 50%">
<el-button type="primary" class="ml_10" @click="submitRegister">下一步</el-button>
</div>
<!-- 修改 --> <!-- 修改 -->
<el-form ref="formRegisterRef" :model="formRegister" :rules="ruleInline" label-width="80px" v-if="status == 1 && !showPwd"> <el-form ref="formRegisterRef" :model="formRegister" :rules="ruleInline" label-width="80px" v-if="status == 1 && !showPwd">
@@ -43,11 +43,10 @@
<template #prepend><el-icon><Lock /></el-icon></template> <template #prepend><el-icon><Lock /></el-icon></template>
</el-input> </el-input>
</el-form-item> </el-form-item>
<el-form-item>
<el-button type="primary" @click="handleRegist">修改</el-button>
</el-form-item>
</el-form> </el-form>
<div v-if="status == 1 && !showPwd" style="width: 50%;text-align: center">
<el-button type="primary" class="ml_10" @click="handleRegist">修改</el-button>
</div>
<!-- 设置&修改的第二种情况 --> <!-- 设置&修改的第二种情况 -->
<el-form ref="formDataUpdateRef" :model="formDataUpdate" label-position="left" label-width="100px" :rules="ruleIn" <el-form ref="formDataUpdateRef" :model="formDataUpdate" label-position="left" label-width="100px" :rules="ruleIn"
v-if="showPwd"> v-if="showPwd">
@@ -65,10 +64,10 @@
<template #prepend><el-icon><Lock /></el-icon></template> <template #prepend><el-icon><Lock /></el-icon></template>
</el-input> </el-input>
</el-form-item> </el-form-item>
<el-form-item>
<el-button type="primary" @click="setPassword">设置</el-button>
</el-form-item>
</el-form> </el-form>
<div v-if="showPwd" style="text-align: center;width: 50%">
<el-button type="primary" class="ml_10" @click="setPassword">设置</el-button>
</div>
</UserCenterLayout> </UserCenterLayout>
</div> </div>
</template> </template>
@@ -252,9 +251,6 @@ onMounted(() => {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.wrapper {
text-align: center;
}
.phone { .phone {
text-align: left; text-align: left;
} }

View File

@@ -87,10 +87,10 @@ onMounted(() => {
<style lang="scss" scoped> <style lang="scss" scoped>
h3 { h3 {
margin: 8px 0 12px; margin: 8px 0 12px;
font-size: 15px; font-size: 16px;
font-weight: 600; font-weight: 600;
color: #333; color: #333;
line-height: 1.4; line-height: 24px;
} }
.point-summary { .point-summary {
@@ -102,7 +102,8 @@ h3 {
.point-stat { .point-stat {
flex: 1; flex: 1;
padding: 16px 20px; padding: 16px 20px;
background: #fafafa; background: #fff;
border: 1px solid #eee;
border-radius: 8px; border-radius: 8px;
.label { .label {

View File

@@ -13,7 +13,7 @@
:action="action" :action="action"
:headers="accessToken" :headers="accessToken"
> >
<button type="button" class="ghost-btn">上传头像</button> <el-button size="small">上传头像</el-button>
</el-upload> </el-upload>
</div> </div>
</el-form-item> </el-form-item>
@@ -29,13 +29,13 @@
/> />
</el-form-item> </el-form-item>
<el-form-item label="性别"> <el-form-item label="性别">
<div class="sex-group"> <el-radio-group v-model="formItem.sex">
<button type="button" class="sex-btn" :class="{ active: formItem.sex === 1 }" @click="formItem.sex = 1">男</button> <el-radio-button :value="1">男</el-radio-button>
<button type="button" class="sex-btn" :class="{ active: formItem.sex === 0 }" @click="formItem.sex = 0">女</button> <el-radio-button :value="0">女</el-radio-button>
</div> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<button type="button" class="op-btn" @click="save">确认修改</button> <el-button type="primary" @click="save">确认修改</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</UserCenterLayout> </UserCenterLayout>
@@ -167,7 +167,7 @@ function handleSuccess (res, file) { // 上传成功
:deep(.el-form-item__label) { :deep(.el-form-item__label) {
height: auto; height: auto;
line-height: 22px; line-height: 22px;
color: #666; color: #999;
font-size: 14px; font-size: 14px;
} }
@@ -199,51 +199,5 @@ function handleSuccess (res, file) { // 上传成功
:deep(.profile-field.el-date-editor) { :deep(.profile-field.el-date-editor) {
width: 320px; width: 320px;
} }
.sex-group {
display: flex;
gap: 8px;
}
.sex-btn,
.ghost-btn,
.op-btn {
height: 32px;
padding: 0 16px;
border-radius: 4px;
font-family: inherit;
font-size: 14px;
line-height: 30px;
cursor: pointer;
}
.sex-btn,
.ghost-btn {
border: 1px solid #ddd;
background: #fff;
color: #171A26;
&:hover {
border-color: $theme_color;
color: $theme_color;
}
}
.sex-btn.active {
border-color: $theme_color;
background: #fff5f4;
color: $theme_color;
}
.op-btn {
min-width: 96px;
border: 1px solid $theme_color;
background: $theme_color;
color: #fff;
&:hover {
opacity: 0.9;
}
}
</style> </style>

View File

@@ -4,7 +4,7 @@
<div class="order-card status-card" v-if="orderGoods.goodsName || orderGoods.content"> <div class="order-card status-card" v-if="orderGoods.goodsName || orderGoods.content">
<div class="status-head"> <div class="status-head">
<div class="status-main"> <div class="status-main">
<p class="status-text">{{ gradeText }}</p> <el-tag :type="gradeTagType" effect="dark">{{ gradeText }}</el-tag>
<p class="status-sn">评价时间:{{ orderGoods.createTime }}</p> <p class="status-sn">评价时间:{{ orderGoods.createTime }}</p>
</div> </div>
</div> </div>
@@ -99,6 +99,10 @@ const gradeText = computed(() => {
const map = { GOOD: '好评', MODERATE: '中评', WORSE: '差评' } const map = { GOOD: '好评', MODERATE: '中评', WORSE: '差评' }
return map[orderGoods.value.grade] || '评价详情' return map[orderGoods.value.grade] || '评价详情'
}); });
const gradeTagType = computed(() => {
const map = { GOOD: 'success', MODERATE: 'warning', WORSE: 'info' }
return map[orderGoods.value.grade] || 'info'
});
function getDetail() { function getDetail() {
// 获取评价详情 // 获取评价详情
@@ -130,33 +134,23 @@ onMounted(() => {
} }
.order-card { .order-card {
padding: 4px 0 16px; margin-top: 16px;
margin-bottom: 8px; padding: 16px 20px;
border-bottom: 1px solid #f0f0f0; border: 1px solid #eee;
border-radius: 8px;
background: #fff;
h3 { h3 {
margin: 0 0 12px; margin: 0 0 12px;
font-size: 15px; font-size: 16px;
font-weight: 600; font-weight: 600;
color: #333; color: #333;
line-height: 1.4; line-height: 24px;
}
&:last-of-type {
border-bottom: none;
} }
} }
.status-card { .status-card {
padding-top: 8px; margin-top: 0;
}
.status-text {
margin: 0;
color: $theme_color;
font-size: 18px;
font-weight: 600;
line-height: 1.4;
} }
.status-sn { .status-sn {

View File

@@ -33,7 +33,9 @@
<div class="order-sn"> <div class="order-sn">
{{ order.createTime }}<span>售后单号 {{ order.sn }}</span><span class="order-store" @click="shopPage(order.storeId)">{{ order.storeName }}</span> {{ order.createTime }}<span>售后单号 {{ order.sn }}</span><span class="order-store" @click="shopPage(order.storeId)">{{ order.storeName }}</span>
</div> </div>
<div class="order-status">{{ filterOrderStatus(order.serviceStatus) }}</div> <div class="order-status">
<el-tag size="small" :type="statusTagType(order.serviceStatus)">{{ filterOrderStatus(order.serviceStatus) }}</el-tag>
</div>
</div> </div>
<div class="order-body"> <div class="order-body">
<div class="col-detail"> <div class="col-detail">
@@ -59,19 +61,19 @@
<div class="order-amount">{{ $filters.unitPrice(order.applyRefundPrice, "¥") }}</div> <div class="order-amount">{{ $filters.unitPrice(order.applyRefundPrice, "¥") }}</div>
</div> </div>
<div class="col-ops"> <div class="col-ops">
<button <el-button
v-if="order.serviceStatus == 'PASS' && order.serviceType != 'RETURN_MONEY'" v-if="order.serviceStatus == 'PASS' && order.serviceType != 'RETURN_MONEY'"
type="button" type="primary"
class="op-btn" size="small"
@click="openModal(order)" @click="openModal(order)"
>提交物流</button> >提交物流</el-button>
<button type="button" class="text-link" @click="goDetail(order.sn)">售后详情</button> <el-button link type="primary" @click="goDetail(order.sn)">售后详情</el-button>
<button <el-button
v-if="order.afterSaleAllowOperationVO && order.afterSaleAllowOperationVO.cancel" v-if="order.afterSaleAllowOperationVO && order.afterSaleAllowOperationVO.cancel"
type="button" link
class="text-link" type="danger"
@click="cancel(order.sn)" @click="cancel(order.sn)"
>取消售后</button> >取消售后</el-button>
</div> </div>
</div> </div>
</div> </div>
@@ -253,6 +255,14 @@ function filterOrderStatus(status) { // 获取订单状态中文
}); });
return ob && ob[0] ? ob[0].name : status return ob && ob[0] ? ob[0].name : status
} }
function statusTagType(status) { // 售后状态对应标签类型
const map = {
APPLY: 'warning', APPLYING: 'warning',
COMPLETE: 'success',
CANCEL: 'info', EXPIRED: 'info'
}
return map[status] || 'info'
}
// 获取物流公司列表 // 获取物流公司列表
function getCompany() { function getCompany() {
getLogisticsCompany().then(res => { getLogisticsCompany().then(res => {
@@ -372,7 +382,7 @@ onMounted(() => {
align-items: center; align-items: center;
min-width: 0; min-width: 0;
font-size: 14px; font-size: 14px;
color: #171A26; color: #333;
line-height: 1.5; line-height: 1.5;
span { span {
@@ -476,38 +486,9 @@ onMounted(() => {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
}
.op-btn { :deep(.el-button + .el-button) {
min-width: 96px; margin-left: 0;
height: 32px;
padding: 0 12px;
border: 1px solid $theme_color;
border-radius: 4px;
background: $theme_color;
color: #fff;
font-family: inherit;
font-size: 14px;
line-height: 30px;
cursor: pointer;
&:hover {
opacity: 0.9;
}
}
.text-link {
border: none;
background: none;
padding: 0;
color: #171A26;
font-family: inherit;
font-size: 14px;
line-height: 22px;
cursor: pointer;
&:hover {
color: $theme_color;
} }
} }
} }

View File

@@ -1,10 +1,10 @@
<template> <template>
<div class="order-detail"> <div class="order-detail">
<UserCenterLayout> <UserCenterLayout>
<div class="order-card status-card" v-if="afterSale.sn"> <div class="panel" v-if="afterSale.sn">
<div class="status-head"> <div class="status-head">
<div class="status-main"> <div class="status-main">
<p class="status-text">{{ afterSale.serviceName }}</p> <el-tag :type="statusTagType" effect="dark">{{ afterSale.serviceName }}</el-tag>
<p class="status-sn"> <p class="status-sn">
售后单号:{{ afterSale.sn }} 售后单号:{{ afterSale.sn }}
<span>订单号:{{ afterSale.orderSn }}</span> <span>订单号:{{ afterSale.orderSn }}</span>
@@ -13,8 +13,8 @@
<div class="status-side"> <div class="status-side">
<div class="operation-time">创建时间:{{ afterSale.createTime }}</div> <div class="operation-time">创建时间:{{ afterSale.createTime }}</div>
<div class="status-actions" v-if="canSubmitLogistics || canCancel"> <div class="status-actions" v-if="canSubmitLogistics || canCancel">
<button v-if="canSubmitLogistics" type="button" class="op-btn" @click="openModal">提交物流</button> <el-button v-if="canSubmitLogistics" type="primary" size="small" @click="openModal">提交物流</el-button>
<button v-if="canCancel" type="button" class="text-link" @click="cancel(afterSale.sn)">取消售后</button> <el-button v-if="canCancel" link type="primary" @click="cancel(afterSale.sn)">取消售后</el-button>
</div> </div>
</div> </div>
</div> </div>
@@ -28,7 +28,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="order-card" v-if="logList.length"> <div class="panel" v-if="logList.length">
<h3>售后进程</h3> <h3>售后进程</h3>
<el-steps <el-steps
class="progress" class="progress"
@@ -43,50 +43,48 @@
></el-step> ></el-step>
</el-steps> </el-steps>
</div> </div>
<div class="order-card"> <div class="panel">
<h3>服务单信息</h3> <h3>服务单信息</h3>
<div class="info-list"> <div class="info-row">
<span class="info-label">退款方式</span>
<span class="info-value">{{ afterSale.refundWay == 'ORIGINAL' ? '原路退回' : '账号退款' }}</span>
</div>
<div class="info-row">
<span class="info-label">申请退款金额</span>
<span class="info-value">{{ $filters.unitPrice(afterSale.applyRefundPrice, '¥') }}</span>
</div>
<div class="info-row" v-if="afterSale.actualRefundPrice">
<span class="info-label">实际退款金额</span>
<span class="info-value">{{ $filters.unitPrice(afterSale.actualRefundPrice, '¥') }}</span>
</div>
<template v-if="afterSale.refundWay === 'OFFLINE'">
<div class="info-row"> <div class="info-row">
<span class="label">退款方式</span> <span class="info-label">退款开户行</span>
<span>{{ afterSale.refundWay == 'ORIGINAL' ? '原路退回' : '账号退款' }}</span> <span class="info-value">{{ afterSale.bankDepositName }}</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="label">申请退款金额</span> <span class="info-label">退款开户名</span>
<span>{{ $filters.unitPrice(afterSale.applyRefundPrice, '¥') }}</span> <span class="info-value">{{ afterSale.bankAccountName }}</span>
</div>
<div class="info-row" v-if="afterSale.actualRefundPrice">
<span class="label">实际退款金额</span>
<span>{{ $filters.unitPrice(afterSale.actualRefundPrice, '¥') }}</span>
</div>
<template v-if="afterSale.refundWay === 'OFFLINE'">
<div class="info-row">
<span class="label">退款开户行</span>
<span>{{ afterSale.bankDepositName }}</span>
</div>
<div class="info-row">
<span class="label">退款开户名</span>
<span>{{ afterSale.bankAccountName }}</span>
</div>
<div class="info-row">
<span class="label">退款卡号</span>
<span>{{ afterSale.bankAccountNumber }}</span>
</div>
</template>
<div class="info-row">
<span class="label">商品处理方式</span>
<span>{{ afterSale.serviceType == 'RETURN_MONEY' ? '退款' : '退货' }}</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="label">退款原因</span> <span class="info-label">退款卡号</span>
<span>{{ afterSale.reasonName }}</span> <span class="info-value">{{ afterSale.bankAccountNumber }}</span>
</div>
<div class="info-row">
<span class="label">问题描述</span>
<span>{{ afterSale.problemDesc }}</span>
</div> </div>
</template>
<div class="info-row">
<span class="info-label">商品处理方式</span>
<span class="info-value">{{ afterSale.serviceType == 'RETURN_MONEY' ? '退款' : '退货' }}</span>
</div>
<div class="info-row">
<span class="info-label">退款原因</span>
<span class="info-value">{{ afterSale.reasonName }}</span>
</div>
<div class="info-row">
<span class="info-label">问题描述</span>
<span class="info-value">{{ afterSale.problemDesc }}</span>
</div> </div>
</div> </div>
<div class="order-card" v-if="afterSale.afterSaleImage"> <div class="panel" v-if="afterSale.afterSaleImage">
<h3>图片信息</h3> <h3>图片信息</h3>
<div class="image-list"> <div class="image-list">
<img <img
@@ -175,6 +173,22 @@ const canCancel = computed(() => {
return afterSale.value.afterSaleAllowOperationVO && afterSale.value.afterSaleAllowOperationVO.cancel return afterSale.value.afterSaleAllowOperationVO && afterSale.value.afterSaleAllowOperationVO.cancel
}); });
const statusTagType = computed(() => {
const map = {
APPLY: 'warning',
PASS: 'success',
REFUSE: 'info',
BUYER_RETURN: 'warning',
SELLER_CONFIRM: 'warning',
SELLER_TERMINATION: 'info',
BUYER_CONFIRM: 'warning',
BUYER_CANCEL: 'info',
COMPLETE: 'success',
WAIT_REFUND: 'warning'
};
return map[afterSale.value.serviceStatus] || 'info';
});
function getDetail () { // 售后详情 function getDetail () { // 售后详情
afterSaleDetail(route.query.sn).then(res => { afterSaleDetail(route.query.sn).then(res => {
if (res.success) { if (res.success) {
@@ -286,26 +300,22 @@ onMounted(() => {
color: #333; color: #333;
} }
.order-card { .panel {
padding: 4px 0 16px; margin-top: 16px;
margin-bottom: 8px; padding: 16px 20px;
border-bottom: 1px solid #f0f0f0; border: 1px solid #eee;
border-radius: 8px;
background: #fff;
h3 { h3 {
margin: 0 0 12px; margin: 0 0 12px;
font-size: 15px; padding-bottom: 12px;
border-bottom: 1px solid #f5f5f5;
font-size: 16px;
font-weight: 600; font-weight: 600;
color: #333; color: #333;
line-height: 1.4; line-height: 24px;
} }
&:last-child {
border-bottom: none;
}
}
.status-card {
padding-top: 8px;
} }
.status-head { .status-head {
@@ -315,17 +325,16 @@ onMounted(() => {
gap: 16px; gap: 16px;
} }
.status-text { .status-main {
margin: 0; display: flex;
color: $theme_color; align-items: center;
font-size: 18px; gap: 12px;
font-weight: 600; flex-wrap: wrap;
line-height: 1.4;
} }
.status-sn { .status-sn {
margin: 6px 0 0; margin: 0;
color: #171A26; color: #999;
font-size: 14px; font-size: 14px;
line-height: 22px; line-height: 22px;
@@ -340,7 +349,7 @@ onMounted(() => {
} }
.operation-time { .operation-time {
color: #171A26; color: #999;
font-size: 14px; font-size: 14px;
line-height: 22px; line-height: 22px;
} }
@@ -392,7 +401,7 @@ onMounted(() => {
span { span {
margin: 0 4px; margin: 0 4px;
color: #438cde; color: #333;
cursor: pointer; cursor: pointer;
&:hover { &:hover {
@@ -435,35 +444,30 @@ onMounted(() => {
} }
} }
.info-list {
border: 1px solid #f0f0f0;
border-radius: 8px;
overflow: hidden;
}
.info-row { .info-row {
display: grid; display: flex;
grid-template-columns: 140px minmax(0, 1fr); gap: 16px;
min-height: 44px; padding: 12px 0;
border-top: 1px solid #f0f0f0; border-bottom: 1px solid #f5f5f5;
font-size: 14px; font-size: 14px;
line-height: 22px; line-height: 22px;
&:first-child { &:last-child {
border-top: none; border-bottom: none;
padding-bottom: 4px;
} }
}
span { .info-label {
display: flex; flex: 0 0 96px;
align-items: center; color: #999;
padding: 10px 16px; }
color: #171A26;
}
.label { .info-value {
background: #fafafa; flex: 1;
color: #666; min-width: 0;
} color: #333;
word-break: break-all;
} }
.image-list { .image-list {
@@ -480,37 +484,4 @@ onMounted(() => {
cursor: pointer; cursor: pointer;
} }
} }
.op-btn {
min-width: 96px;
height: 32px;
padding: 0 12px;
border: 1px solid $theme_color;
border-radius: 4px;
background: $theme_color;
color: #fff;
font-family: inherit;
font-size: 14px;
line-height: 30px;
cursor: pointer;
&:hover {
opacity: 0.9;
}
}
.text-link {
border: none;
background: none;
padding: 0;
color: #171A26;
font-family: inherit;
font-size: 14px;
line-height: 22px;
cursor: pointer;
&:hover {
color: $theme_color;
}
}
</style> </style>

View File

@@ -1,26 +1,20 @@
<template> <template>
<div class="apply-after-sale"> <div class="apply-after-sale">
<UserCenterLayout> <UserCenterLayout>
<el-table :data="goodsData" border> <div class="goods-row" v-for="(goods, index) in goodsData" :key="index">
<el-table-column label="商品名称"> <img class="goods-img" :src="goods.image" loading="lazy" decoding="async" alt="">
<template #default="{ row }"> <div class="goods-info">
<div style="padding:5px;display:flex;"> <div class="goods-name">{{ goods.goodsName }}</div>
<img :src="row.image" loading="lazy" decoding="async" style="vertical-align: top;" width="60" height="60" alt=""> <div class="goods-meta">
<span class="ml_10">{{ row.goodsName }}</span> <span class="goods-price">{{ $filters.unitPrice(goods.applyRefundPrice, '¥') }}</span>
</div> <span class="goods-num">×{{ goods.num }}</span>
</template> </div>
</el-table-column> </div>
<el-table-column label="价格"> </div>
<template #default="{ row }">
<div>{{ $filters.unitPrice(row.applyRefundPrice, '¥') }}</div>
</template>
</el-table-column>
<el-table-column prop="num" label="购买数量" />
</el-table>
<div> <div>
<el-form :model="form" ref="formRef" class="mt_10" :rules="rules" label-width="80px"> <el-form :model="form" ref="formRef" class="mt_10" :rules="rules" label-width="80px">
<el-form-item label="售后类别"> <el-form-item label="售后类别">
<el-radio-group v-model="form.serviceType" @change="changeReason" type="button" button-style="solid"> <el-radio-group v-model="form.serviceType" @change="changeReason">
<el-radio v-if="info.returnGoods" label="RETURN_GOODS">退货</el-radio> <el-radio v-if="info.returnGoods" label="RETURN_GOODS">退货</el-radio>
<el-radio v-if="info.returnMoney" label="RETURN_MONEY">退款</el-radio> <el-radio v-if="info.returnMoney" label="RETURN_MONEY">退款</el-radio>
</el-radio-group> </el-radio-group>
@@ -224,6 +218,50 @@ onMounted(() => {
<style lang="scss" scoped> <style lang="scss" scoped>
/** 商品信息 */ /** 商品信息 */
.goods-row {
display: flex;
align-items: center;
padding: 12px 0;
}
.goods-img {
width: 56px;
height: 56px;
flex-shrink: 0;
border: 1px solid #f0f0f0;
border-radius: 4px;
object-fit: cover;
}
.goods-info {
min-width: 0;
margin-left: 12px;
}
.goods-name {
color: #333;
font-size: 14px;
line-height: 22px;
}
.goods-meta {
display: flex;
align-items: center;
gap: 12px;
margin-top: 4px;
}
.goods-price {
color: $theme_color;
font-size: 14px;
font-weight: 600;
}
.goods-num {
color: #999;
font-size: 14px;
}
.order-price { .order-price {
text-align: right; text-align: right;
margin-top: 30px; margin-top: 30px;

View File

@@ -164,17 +164,6 @@ onMounted(() => {
}) })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.delivery-rate {
display: flex;
align-items: center;
margin-top: 20px;
height: 50px;
border-bottom: 1px solid #eee;
>div:nth-child(1) {
width: 30%;
font-weight: bold;
}
}
.goods-eval{ .goods-eval{
display: flex; display: flex;
.goods-con { .goods-con {

View File

@@ -30,7 +30,7 @@
</div> </div>
</div> </div>
</div> </div>
<el-empty v-else description="暂无收货地址" /> <empty v-else _Title="暂无收货地址" />
</UserCenterLayout> </UserCenterLayout>
</div> </div>
</template> </template>
@@ -130,7 +130,7 @@ onMounted(() => {
.address-name { .address-name {
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
color: $light_title_color; color: #333;
} }
.address-mobile { .address-mobile {
@@ -183,13 +183,13 @@ onMounted(() => {
} }
.del-btn:hover { .del-btn:hover {
color: #f56c6c; color: $theme_color;
} }
} }
.default-address-tag { .default-address-tag {
margin-left: 10px; margin-left: 10px;
background-color: #fff3f2 !important; background-color: color-mix(in srgb, #{$theme_color} 8%, #fff) !important;
border: 1px solid $theme_color !important; border: 1px solid $theme_color !important;
color: $theme_color !important; color: $theme_color !important;
} }

View File

@@ -40,7 +40,11 @@
<div class="order-sn" v-else> <div class="order-sn" v-else>
{{ order.createTime }}<span>订单号 {{ order.sn }}</span><span class="order-store" @click="shopPage(order.storeId)">{{ order.storeName }}</span> {{ order.createTime }}<span>订单号 {{ order.sn }}</span><span class="order-store" @click="shopPage(order.storeId)">{{ order.storeName }}</span>
</div> </div>
<div class="order-status">{{ order.isAfterSale ? filterAfterSaleStatus(order.serviceStatus) : filterOrderStatus(order.orderStatus) }}</div> <div class="order-status">
<el-tag size="small" :type="statusTagType(order.isAfterSale ? order.serviceStatus : order.orderStatus)">
{{ order.isAfterSale ? filterAfterSaleStatus(order.serviceStatus) : filterOrderStatus(order.orderStatus) }}
</el-tag>
</div>
</div> </div>
<div class="order-body"> <div class="order-body">
<div class="col-detail"> <div class="col-detail">
@@ -64,12 +68,10 @@
<div class="goods-name hover-color" @click="goodsDetail(goods.skuId, goods.goodsId)">{{ goods.name }}</div> <div class="goods-name hover-color" @click="goodsDetail(goods.skuId, goods.goodsId)">{{ goods.name }}</div>
<div class="goods-num">共{{ goods.num }}件</div> <div class="goods-num">共{{ goods.num }}件</div>
<div class="goods-extra"> <div class="goods-extra">
<span v-if="refundPriceList(goods.isRefund)" class="refund-tag">{{ refundPriceList(goods.isRefund) }}</span> <el-tag v-if="refundPriceList(goods.isRefund) && !isUnpaid(order)" type="warning" size="small">{{ refundPriceList(goods.isRefund) }}</el-tag>
<button v-if="goods.complainStatus == 'NO_APPLY'" type="button" class="sub-btn" @click="complain(order.sn, goodsIndex)">投诉</button>
</div> </div>
</div> </div>
</div> </div>
<button v-if="canAfterSale(order)" type="button" class="sub-btn" @click="applyAfterSale(order.orderItems)">申请售后</button>
</div> </div>
<div class="col-amount"> <div class="col-amount">
<div class="order-amount">{{ $filters.unitPrice(order.flowPrice, "¥") }}</div> <div class="order-amount">{{ $filters.unitPrice(order.flowPrice, "¥") }}</div>
@@ -77,14 +79,14 @@
</div> </div>
<div class="col-ops"> <div class="col-ops">
<template v-if="order.isAfterSale"> <template v-if="order.isAfterSale">
<button type="button" class="text-link" @click="goAfterSaleDetail(order.serviceSn)">售后详情</button> <el-button link type="primary" @click="goAfterSaleDetail(order.serviceSn)">售后详情</el-button>
</template> </template>
<template v-else> <template v-else>
<button v-if="order.allowOperationVO && order.allowOperationVO.pay" type="button" class="op-btn" @click="goPay(order.sn)">去支付</button> <el-button v-if="order.allowOperationVO && order.allowOperationVO.pay" type="primary" size="small" @click="goPay(order.sn)">去支付</el-button>
<button v-if="order.allowOperationVO && order.allowOperationVO.rog" type="button" class="op-btn" @click="received(order.sn)">确认收货</button> <el-button v-if="order.allowOperationVO && order.allowOperationVO.rog" type="primary" size="small" @click="received(order.sn)">确认收货</el-button>
<button type="button" class="text-link" @click="orderDetail(order.sn)">订单详情</button> <el-button link type="primary" @click="orderDetail(order.sn)">订单详情</el-button>
<button v-if="order.allowOperationVO && order.allowOperationVO.cancel" type="button" class="text-link" @click="handleCancelOrder(order.sn)">取消订单</button> <el-button v-if="order.allowOperationVO && order.allowOperationVO.cancel" link type="info" @click="handleCancelOrder(order.sn)">取消订单</el-button>
<button v-if="order.orderStatus === 'COMPLETED'" type="button" class="text-link" @click="delOrder(order.sn)">删除订单</button> <el-button v-if="order.orderStatus === 'COMPLETED'" link type="info" @click="delOrder(order.sn)">删除订单</el-button>
</template> </template>
</div> </div>
</div> </div>
@@ -120,7 +122,11 @@
<div class="order-sn" v-else> <div class="order-sn" v-else>
{{ order.createTime }}<span>订单号 {{ order.sn }}</span><span class="order-store" @click="shopPage(order.storeId)">{{ order.storeName }}</span> {{ order.createTime }}<span>订单号 {{ order.sn }}</span><span class="order-store" @click="shopPage(order.storeId)">{{ order.storeName }}</span>
</div> </div>
<div class="order-status">{{ order.isAfterSale ? filterAfterSaleStatus(order.serviceStatus) : filterOrderStatus(order.orderStatus) }}</div> <div class="order-status">
<el-tag size="small" :type="statusTagType(order.isAfterSale ? order.serviceStatus : order.orderStatus)">
{{ order.isAfterSale ? filterAfterSaleStatus(order.serviceStatus) : filterOrderStatus(order.orderStatus) }}
</el-tag>
</div>
</div> </div>
<div class="order-body"> <div class="order-body">
<div class="col-detail"> <div class="col-detail">
@@ -144,12 +150,10 @@
<div class="goods-name hover-color" @click="goodsDetail(goods.skuId, goods.goodsId)">{{ goods.name }}</div> <div class="goods-name hover-color" @click="goodsDetail(goods.skuId, goods.goodsId)">{{ goods.name }}</div>
<div class="goods-num">共{{ goods.num }}件</div> <div class="goods-num">共{{ goods.num }}件</div>
<div class="goods-extra"> <div class="goods-extra">
<span v-if="refundPriceList(goods.isRefund)" class="refund-tag">{{ refundPriceList(goods.isRefund) }}</span> <el-tag v-if="refundPriceList(goods.isRefund) && !isUnpaid(order)" type="warning" size="small">{{ refundPriceList(goods.isRefund) }}</el-tag>
<button v-if="goods.complainStatus == 'NO_APPLY'" type="button" class="sub-btn" @click="complain(order.sn, goodsIndex)">投诉</button>
</div> </div>
</div> </div>
</div> </div>
<button v-if="canAfterSale(order)" type="button" class="sub-btn" @click="applyAfterSale(order.orderItems)">申请售后</button>
</div> </div>
<div class="col-amount"> <div class="col-amount">
<div class="order-amount">{{ $filters.unitPrice(order.flowPrice, "¥") }}</div> <div class="order-amount">{{ $filters.unitPrice(order.flowPrice, "¥") }}</div>
@@ -157,31 +161,22 @@
</div> </div>
<div class="col-ops"> <div class="col-ops">
<template v-if="order.isAfterSale"> <template v-if="order.isAfterSale">
<button type="button" class="text-link" @click="goAfterSaleDetail(order.serviceSn)">售后详情</button> <el-button link type="primary" @click="goAfterSaleDetail(order.serviceSn)">售后详情</el-button>
</template> </template>
<template v-else> <template v-else>
<button v-if="order.allowOperationVO && order.allowOperationVO.pay" type="button" class="op-btn" @click="goPay(order.sn)">去支付</button> <el-button v-if="order.allowOperationVO && order.allowOperationVO.pay" type="primary" size="small" @click="goPay(order.sn)">去支付</el-button>
<button v-if="order.allowOperationVO && order.allowOperationVO.rog" type="button" class="op-btn" @click="received(order.sn)">确认收货</button> <el-button v-if="order.allowOperationVO && order.allowOperationVO.rog" type="primary" size="small" @click="received(order.sn)">确认收货</el-button>
<button type="button" class="text-link" @click="orderDetail(order.sn)">订单详情</button> <el-button link type="primary" @click="orderDetail(order.sn)">订单详情</el-button>
<button v-if="order.allowOperationVO && order.allowOperationVO.cancel" type="button" class="text-link" @click="handleCancelOrder(order.sn)">取消订单</button> <el-button v-if="order.allowOperationVO && order.allowOperationVO.cancel" link type="info" @click="handleCancelOrder(order.sn)">取消订单</el-button>
<button v-if="order.orderStatus === 'COMPLETED'" type="button" class="text-link" @click="delOrder(order.sn)">删除订单</button> <el-button v-if="order.orderStatus === 'COMPLETED'" link type="info" @click="delOrder(order.sn)">删除订单</el-button>
</template> </template>
</div> </div>
</div> </div>
</div> </div>
<el-skeleton size="large" fix v-if="spinShow"></el-skeleton> <el-skeleton size="large" fix v-if="spinShow"></el-skeleton>
</div> </div>
<!-- 选择售后商品 -->
<el-dialog v-model="afterSaleModal" title="请选择申请售后的商品">
<div>
<el-table :data="afterSaleArr" border @row-click="afterSaleSelect">
<el-table-column prop="name" label="商品名称" />
<el-table-column prop="goodsPrice" label="价格" />
</el-table>
</div>
<template #footer><div></div></template>
</el-dialog>
<el-dialog v-model="cancelAvail" title="请选择取消订单原因"> <el-dialog v-model="cancelAvail" title="请选择取消订单原因">
<el-alert v-if="cancelIsPickup" title="此订单使用提货卡兑换,取消不退还提货权益,无法再次兑换。" type="warning" :closable="false" style="margin-bottom:16px" />
<el-radio-group v-model="cancelParams.reason"> <el-radio-group v-model="cancelParams.reason">
<el-radio :label="item.reason" v-for="item in cancelReason" :key="item.id"> <el-radio :label="item.reason" v-for="item in cancelReason" :key="item.id">
{{ item.reason }} {{ item.reason }}
@@ -233,8 +228,7 @@ const changeWay = ref(['全部', '待付款', '待发货', '待收货/使用']);
const activeOrderTab = ref(0); const activeOrderTab = ref(0);
const total = ref(0); // 数据总数 const total = ref(0); // 数据总数
const spinShow = ref(false); // 加载状态 const spinShow = ref(false); // 加载状态
const afterSaleModal = ref(false); // 选择售后商品模态框 const cancelIsPickup = ref(false);
const afterSaleArr = ref([]); // 售后商品列表
const cancelAvail = ref(false); // 取消订单modal控制 const cancelAvail = ref(false); // 取消订单modal控制
const cancelReason = ref([]); // 取消订单原因 const cancelReason = ref([]); // 取消订单原因
@@ -312,27 +306,6 @@ function received (sn) { // 确认收货
function goPay (sn) { // 去支付 function goPay (sn) { // 去支付
router.push({path: '/payment', query: {orderType: 'ORDER', sn}}); router.push({path: '/payment', query: {orderType: 'ORDER', sn}});
} }
function applyAfterSale (goodsItem) { // 申请售后
let arr = []
goodsItem.forEach(e => {
if (e.afterSaleStatus === 'NOT_APPLIED'|| e.afterSaleStatus === 'PART_AFTER_SALE') {
arr.push(e)
}
});
if (arr.length === 1) {
router.push({name: 'ApplyAfterSale', query: {sn: arr[0].sn}})
} else {
afterSaleArr.value = arr;
afterSaleModal.value = true
}
}
// 申请售后
function afterSaleSelect (item) {
router.push({name: 'ApplyAfterSale', query: {sn: item.sn}})
}
function complain (sn, goodsIndex) { // 投诉
router.push({name: 'Complain', query: {sn, index: goodsIndex}})
}
function delOrder (sn) { // 删除订单 function delOrder (sn) { // 删除订单
Modal.confirm({ Modal.confirm({
title: '删除订单', title: '删除订单',
@@ -394,6 +367,7 @@ function changePageSize (val) { // 修改页数
} }
function handleCancelOrder (sn) { function handleCancelOrder (sn) {
// 取消订单 // 取消订单
cancelIsPickup.value = !!orderList.value.find(order => order.sn === sn)?.pickupCardId;
cancelParams.orderSn = sn; cancelParams.orderSn = sn;
afterSaleReason('CANCEL').then(res => { afterSaleReason('CANCEL').then(res => {
if (res.success) { if (res.success) {
@@ -420,11 +394,16 @@ function filterAfterSaleStatus (status) {
const ob = afterSaleStatusList.filter(e => e.status === status); const ob = afterSaleStatusList.filter(e => e.status === status);
return ob && ob[0] ? ob[0].name : status return ob && ob[0] ? ob[0].name : status
} }
function canAfterSale (order) { function statusTagType (status) { // 订单/售后状态对应标签类型
if (order.isAfterSale || order.orderType === 'E_COUPON' || !order.groupAfterSaleStatus) { const map = {
return false UNPAID: 'warning', WAIT_PAY: 'warning', PAID: 'warning',
UNDELIVERED: 'warning', PARTS_DELIVERED: 'warning', DELIVERED: 'warning',
WAIT_SHIP: 'warning', WAIT_USE: 'warning', TAKE: 'warning', STAY_PICKED_UP: 'warning',
APPLY: 'warning', APPLYING: 'warning',
COMPLETED: 'success', COMPLETE: 'success',
CANCELLED: 'info', CANCEL: 'info'
} }
return order.groupAfterSaleStatus.includes('NOT_APPLIED') || order.groupAfterSaleStatus.includes('PART_AFTER_SALE') return map[status] || 'info'
} }
function paymentLabel (order) { function paymentLabel (order) {
const map = { const map = {
@@ -436,11 +415,14 @@ function paymentLabel (order) {
if (order.paymentMethod && map[order.paymentMethod]) { if (order.paymentMethod && map[order.paymentMethod]) {
return map[order.paymentMethod] return map[order.paymentMethod]
} }
if (order.payStatus === 'UNPAID' || order.orderStatus === 'UNPAID') { if (isUnpaid(order)) {
return '待支付' return '待支付'
} }
return '' return ''
} }
function isUnpaid (order) {
return order.payStatus === 'UNPAID' || order.orderStatus === 'UNPAID'
}
watch(() => route.query.tag, () => { watch(() => route.query.tag, () => {
if (props.homePage || route.name !== 'MyOrder') return; if (props.homePage || route.name !== 'MyOrder') return;
@@ -529,7 +511,7 @@ onMounted(() => {
align-items: center; align-items: center;
min-width: 0; min-width: 0;
font-size: 14px; font-size: 14px;
color: #171A26; color: #333;
line-height: 1.5; line-height: 1.5;
span { span {
@@ -630,37 +612,6 @@ onMounted(() => {
margin-top: 6px; margin-top: 6px;
} }
.refund-tag {
padding: 0 6px;
border-radius: 4px;
background: #fff7e8;
color: #ff9900;
font-size: 14px;
line-height: 22px;
}
.sub-btn {
height: 28px;
margin-top: 8px;
padding: 0 10px;
border: 1px solid #ddd;
border-radius: 4px;
background: #fff;
color: #666;
font-size: 14px;
line-height: 26px;
cursor: pointer;
&:hover {
border-color: $theme_color;
color: $theme_color;
}
}
.goods-extra .sub-btn {
margin-top: 0;
}
.col-amount, .col-amount,
.col-ops { .col-ops {
text-align: center; text-align: center;
@@ -685,38 +636,9 @@ onMounted(() => {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
}
.op-btn { :deep(.el-button + .el-button) {
min-width: 96px; margin-left: 0;
height: 32px;
padding: 0 12px;
border: 1px solid $theme_color;
border-radius: 4px;
background: #fff;
color: #171A26;
font-family: inherit;
font-size: 14px;
line-height: 30px;
cursor: pointer;
&:hover {
background: #fff5f4;
}
}
.text-link {
border: none;
background: none;
padding: 0;
color: #171A26;
font-family: inherit;
font-size: 14px;
line-height: 22px;
cursor: pointer;
&:hover {
color: $theme_color;
} }
} }
} }

View File

@@ -1,10 +1,10 @@
<template> <template>
<div class="order-detail" v-if="order.order"> <div class="order-detail" v-if="order.order">
<UserCenterLayout> <UserCenterLayout>
<div class="order-card status-card"> <div class="panel">
<div class="status-head"> <div class="status-head">
<div class="status-main"> <div class="status-main">
<p class="status-text">{{ order.orderStatusValue }}</p> <el-tag :type="statusTagType" effect="dark">{{ order.orderStatusValue }}</el-tag>
<p class="status-sn">订单号:{{ order.order.sn }}</p> <p class="status-sn">订单号:{{ order.order.sn }}</p>
</div> </div>
<div class="status-side"> <div class="status-side">
@@ -23,7 +23,7 @@
" "
> >
<el-button <el-button
type="success" type="primary"
@click="goPay(order.order.sn)" @click="goPay(order.order.sn)"
size="small" size="small"
v-if="order.allowOperationVO.pay" v-if="order.allowOperationVO.pay"
@@ -35,7 +35,6 @@
v-if="order.allowOperationVO.rog" v-if="order.allowOperationVO.rog"
>确认收货</el-button> >确认收货</el-button>
<el-button <el-button
type="danger"
@click="handleCancelOrder(order.order.sn)" @click="handleCancelOrder(order.order.sn)"
v-if="order.allowOperationVO.cancel" v-if="order.allowOperationVO.cancel"
size="small" size="small"
@@ -61,95 +60,152 @@
<p class="verificationCode" v-if="order.order.verificationCode && !isECouponOrder"> <p class="verificationCode" v-if="order.order.verificationCode && !isECouponOrder">
核验码:<span>{{ order.order.verificationCode }}</span> 核验码:<span>{{ order.order.verificationCode }}</span>
</p> </p>
<div class="order-card" v-if="order.order.deliveryMethod === 'LOGISTICS' && !isNonPhysicalOrder"> <div class="panel" v-if="order.order.deliveryMethod === 'LOGISTICS' && !isNonPhysicalOrder">
<h3>收货人信息</h3> <h3>收货人信息</h3>
<p>收货人:{{ order.order.consigneeName }}</p> <div class="info-row">
<p>手机号码:{{ $filters.secrecyMobile( order.order.consigneeMobile ) }}</p> <span class="info-label">收货人</span>
<p> <span class="info-value">{{ order.order.consigneeName }}</span>
收货地址:{{ $filters.unitAddress(order.order.consigneeAddressPath) }} </div>
{{ order.order.consigneeDetail }} <div class="info-row">
</p> <span class="info-label">手机号码</span>
<span class="info-value">{{ $filters.secrecyMobile( order.order.consigneeMobile ) }}</span>
</div>
<div class="info-row">
<span class="info-label">收货地址</span>
<span class="info-value">
{{ $filters.unitAddress(order.order.consigneeAddressPath) }}
{{ order.order.consigneeDetail }}
</span>
</div>
</div> </div>
<div class="order-card" v-if="order.order.deliveryMethod === 'SELF_PICK_UP'"> <div class="panel" v-if="order.order.deliveryMethod === 'SELF_PICK_UP'">
<h3>自提点信息</h3> <h3>自提点信息</h3>
<p>自提点名称:{{ order.order.storeAddressPath }}</p> <div class="info-row">
<p>联系方式:{{ order.order.storeAddressMobile }}</p> <span class="info-label">自提点名称</span>
<span class="info-value">{{ order.order.storeAddressPath }}</span>
</div>
<div class="info-row">
<span class="info-label">联系方式</span>
<span class="info-value">{{ order.order.storeAddressMobile }}</span>
</div>
</div> </div>
<div class="order-card"> <div class="panel">
<h3>付款信息</h3> <h3>付款信息</h3>
<p>支付方式:{{ order.paymentMethodValue }}</p> <div class="info-row">
<p>付款状态:{{ order.payStatusValue }}</p> <span class="info-label">支付方式</span>
<span class="info-value">{{ order.paymentMethodValue }}</span>
</div>
<div class="info-row">
<span class="info-label">付款状态</span>
<span class="info-value">{{ order.payStatusValue }}</span>
</div>
</div> </div>
<div class="order-card" v-if="!order.order.verificationCode && !isNonPhysicalOrder"> <div class="panel" v-if="!order.order.verificationCode && !isNonPhysicalOrder">
<h3>配送信息</h3> <h3>配送信息</h3>
<p>配送方式:{{ displayDeliveryMethodValue }}</p> <div class="info-row">
<p v-if="order.order.deliveryMethod === 'LOGISTICS' && !isNoDeliveryOrder">配送状态:{{ order.deliverStatusValue }}</p> <span class="info-label">配送方式</span>
<span class="info-value">{{ displayDeliveryMethodValue }}</span>
</div>
<div class="info-row" v-if="order.order.deliveryMethod === 'LOGISTICS' && !isNoDeliveryOrder">
<span class="info-label">配送状态</span>
<span class="info-value">{{ order.deliverStatusValue }}</span>
</div>
<template v-if="isNoDeliveryOrder"> <template v-if="isNoDeliveryOrder">
<p v-if="noDeliveryShipTime">发货时间:{{ noDeliveryShipTime }}</p> <div class="info-row" v-if="noDeliveryShipTime">
<p v-if="noDeliveryRemark">配送说明:{{ noDeliveryRemark }}</p> <span class="info-label">发货时间</span>
<span class="info-value">{{ noDeliveryShipTime }}</span>
</div>
<div class="info-row" v-if="noDeliveryRemark">
<span class="info-label">配送说明</span>
<span class="info-value">{{ noDeliveryRemark }}</span>
</div>
</template> </template>
<template v-else> <template v-else>
<p v-if="logistics"> <div class="info-row" v-if="logistics">
物流信息:{{ logistics.shipper || "暂无物流信息" }} <span class="info-label">物流信息</span>
</p> <span class="info-value">{{ logistics.shipper || "暂无物流信息" }}</span>
<p v-if="logistics"> </div>
物流单号:{{ logistics.logisticCode || "暂无物流单号" }} <div class="info-row" v-if="logistics">
</p> <span class="info-label">物流单号</span>
<div class="div-express-log" v-if="logistics"> <span class="info-value">{{ logistics.logisticCode || "暂无物流单号" }}</span>
<div class="express-log"> </div>
<p>订单日志:</p> <div class="info-row" v-if="logistics">
<div v-for="(item, index) in logistics.traces" :key="index"> <span class="info-label">订单日志</span>
<span class="time">{{ item.AcceptTime }}</span> <div class="info-value">
<span class="detail">{{ item.AcceptStation }}</span> <div class="div-express-log">
<div class="express-log">
<div v-for="(item, index) in logistics.traces" :key="index">
<span class="time">{{ item.AcceptTime }}</span>
<span class="detail">{{ item.AcceptStation }}</span>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
</div> </div>
<div class="order-card" v-if="order.order.payStatus === 'PAID'"> <div class="panel" v-if="order.order.payStatus === 'PAID'">
<h3>发票信息</h3> <h3>发票信息</h3>
<!-- 以 li_receipt 为准:needReceipt 可能为 null/false 导致有 receipt 仍显示「未开发票」 --> <!-- 以 li_receipt 为准:needReceipt 可能为 null/false 导致有 receipt 仍显示「未开发票」 -->
<template v-if="hasValidReceipt()"> <template v-if="hasValidReceipt()">
<p>发票类型:{{ formatReceiptType(order.receipt) }}</p> <div class="info-row">
<p>发票抬头:{{ formatReceiptHeaderType(order.receipt) }}</p> <span class="info-label">发票类型</span>
<p v-if="isPersonalReceipt(order.receipt) && order.receipt.personalName"> <span class="info-value">{{ formatReceiptType(order.receipt) }}</span>
个人名称:{{ order.receipt.personalName }} </div>
</p> <div class="info-row">
<p v-if="order.receipt.companyName"> <span class="info-label">发票抬头</span>
单位名称:{{ order.receipt.companyName }} <span class="info-value">{{ formatReceiptHeaderType(order.receipt) }}</span>
</p> </div>
<p v-if="order.receipt.taxpayerId"> <div class="info-row" v-if="isPersonalReceipt(order.receipt) && order.receipt.personalName">
纳税人识别号:{{ order.receipt.taxpayerId }} <span class="info-label">个人名称</span>
</p> <span class="info-value">{{ order.receipt.personalName }}</span>
<p v-if="order.receipt.companyAddress"> </div>
单位地址:{{ order.receipt.companyAddress }} <div class="info-row" v-if="order.receipt.companyName">
</p> <span class="info-label">单位名称</span>
<p v-if="order.receipt.companyPhone"> <span class="info-value">{{ order.receipt.companyName }}</span>
单位电话:{{ order.receipt.companyPhone }} </div>
</p> <div class="info-row" v-if="order.receipt.taxpayerId">
<p v-if="order.receipt.bankName"> <span class="info-label">纳税人识别号</span>
开户银行:{{ order.receipt.bankName }} <span class="info-value">{{ order.receipt.taxpayerId }}</span>
</p> </div>
<p v-if="order.receipt.bankAccount"> <div class="info-row" v-if="order.receipt.companyAddress">
银行账号:{{ order.receipt.bankAccount }} <span class="info-label">单位地址</span>
</p> <span class="info-value">{{ order.receipt.companyAddress }}</span>
<p>发票内容:{{ order.receipt.receiptContent }}</p> </div>
<p v-if="order.receipt.receiptPhone"> <div class="info-row" v-if="order.receipt.companyPhone">
收票人手机:{{ order.receipt.receiptPhone }} <span class="info-label">单位电话</span>
</p> <span class="info-value">{{ order.receipt.companyPhone }}</span>
<p v-if="order.receipt.receiptEmail"> </div>
收票人邮箱:{{ order.receipt.receiptEmail }} <div class="info-row" v-if="order.receipt.bankName">
</p> <span class="info-label">开户银行</span>
<span class="info-value">{{ order.receipt.bankName }}</span>
</div>
<div class="info-row" v-if="order.receipt.bankAccount">
<span class="info-label">银行账号</span>
<span class="info-value">{{ order.receipt.bankAccount }}</span>
</div>
<div class="info-row">
<span class="info-label">发票内容</span>
<span class="info-value">{{ order.receipt.receiptContent }}</span>
</div>
<div class="info-row" v-if="order.receipt.receiptPhone">
<span class="info-label">收票人手机</span>
<span class="info-value">{{ order.receipt.receiptPhone }}</span>
</div>
<div class="info-row" v-if="order.receipt.receiptEmail">
<span class="info-label">收票人邮箱</span>
<span class="info-value">{{ order.receipt.receiptEmail }}</span>
</div>
<div v-if="Number(order.receipt.receiptStatus) === 1" class="receipt-action"> <div v-if="Number(order.receipt.receiptStatus) === 1" class="receipt-action">
<el-button size="small" type="primary" ghost @click="viewReceiptInvoice(order.receipt)"> <el-button size="small" type="primary" @click="viewReceiptInvoice(order.receipt)">
查看发票 查看发票
</el-button> </el-button>
</div> </div>
</template> </template>
<div v-else style="color: #999; margin-left: 5px">未开发票</div> <div v-else class="empty-text">未开发票</div>
</div> </div>
<!-- 卡密信息(当前订单 orderItems;满赠赠品卡密在赠品子单详情查看) --> <!-- 卡密信息(当前订单 orderItems;满赠赠品卡密在赠品子单详情查看) -->
<div class="order-card ecoupon-card-keys" v-if="hasCardKeySection"> <div class="panel ecoupon-card-keys" v-if="hasCardKeySection">
<h3>卡密信息</h3> <h3>卡密信息</h3>
<div <div
v-for="line in cardKeyFulfillLines" v-for="line in cardKeyFulfillLines"
@@ -194,75 +250,62 @@
</div> </div>
<!-- 订单商品 --> <!-- 订单商品 -->
<div class="goods"> <div class="panel goods-panel">
<div class="shop-name"> <div class="shop-name">
<span @click="shopPage(order.order.storeId)">{{order.order.storeName}}</span> <span @click="shopPage(order.order.storeId)">{{order.order.storeName}}</span>
</div> </div>
<table> <div class="goods-list">
<thead> <div class="goods-row" v-for="(goods, goodsIndex) in order.orderItems" :key="goodsIndex">
<tr> <img
<th width="28%">商品</th> class="goods-thumb"
<th width="16%">货号</th> @click="goodsDetail(goods.skuId, goods.goodsId)"
<th width="10%">单价</th> :src="goods.image"
<th width="8%">数量</th> loading="lazy"
<th width="10%">小计</th> decoding="async"
<th width="8%">操作</th> alt=""
</tr> />
</thead> <div class="goods-info">
<tbody> <p
<tr v-for="(goods, goodsIndex) in order.orderItems" :key="goodsIndex"> @click="goodsDetail(goods.skuId, goods.goodsId)"
<td> class="hover-color goods-name"
<div class="goods-cell"> >
<img {{ goods.goodsName }}
@click="goodsDetail(goods.skuId, goods.goodsId)" </p>
:src="goods.image" <p class="goods-sn">货号:{{ goods.id }}</p>
loading="lazy" </div>
decoding="async" <div class="goods-price">
alt="" <span class="unit-price">
/> {{ $filters.unitPrice(goods.goodsPrice, "¥") }} × {{ goods.num }}
<p </span>
@click="goodsDetail(goods.skuId, goods.goodsId)" <span class="subtotal">{{ $filters.unitPrice((goods.goodsPrice * goods.num), "¥") }}</span>
class="hover-color goods-name" </div>
> <div class="goods-actions">
{{ goods.goodsName }} <button
</p> v-if="goods.commentStatus == 'UNFINISHED'"
</div> type="button"
</td> class="comment-text"
<td>{{ goods.id }}</td> @click="comment(order.order.sn, goodsIndex)"
<td>{{ $filters.unitPrice(goods.goodsPrice, "¥") }}</td> >评价</button>
<td>{{ goods.num }}</td> <el-button
<td>{{ $filters.unitPrice((goods.goodsPrice * goods.num), "¥") }}</td> v-if="goods.complainStatus == 'NO_APPLY'"
<td class="order-item-actions"> @click="complain(order.order.sn, goodsIndex)"
<button type="danger"
v-if="goods.commentStatus == 'UNFINISHED'" size="small"
type="button" >投诉</el-button
class="comment-text" >
@click="comment(order.order.sn, goodsIndex)" <el-button
>评价</button> v-if="
<el-button !isECouponOrder &&
v-if="goods.complainStatus == 'NO_APPLY'" (goods.afterSaleStatus.includes('NOT_APPLIED') ||
@click="complain(order.order.sn, goodsIndex)" goods.afterSaleStatus.includes('PART_AFTER_SALE'))
type="danger" "
class="order-item-action-btn mb_5" @click="applyAfterSale(goods.sn)"
size="small" size="small"
>投诉</el-button >申请售后</el-button
> >
<el-button </div>
v-if=" </div>
!isECouponOrder && </div>
(goods.afterSaleStatus.includes('NOT_APPLIED') ||
goods.afterSaleStatus.includes('PART_AFTER_SALE'))
"
@click="applyAfterSale(goods.sn)"
type="default"
size="small"
class="order-item-action-btn mb_5"
>申请售后</el-button
>
</td>
</tr>
</tbody>
</table>
<!-- 订单价格 --> <!-- 订单价格 -->
<div class="order-price"> <div class="order-price">
<div> <div>
@@ -298,6 +341,7 @@
v-model="cancelAvail" v-model="cancelAvail"
title="请选择取消订单原因" title="请选择取消订单原因"
> >
<el-alert v-if="order.order?.pickupCardId" title="此订单使用提货卡兑换,取消不退还提货权益,无法再次兑换。" type="warning" :closable="false" style="margin-bottom:16px" />
<el-radio-group <el-radio-group
v-model="cancelParams.reason" v-model="cancelParams.reason"
> >
@@ -488,6 +532,20 @@ const noDeliveryShipTime = computed(() => {
const pkg = noDeliveryPackages.value[0]; const pkg = noDeliveryPackages.value[0];
return pkg?.logisticsTime || ""; return pkg?.logisticsTime || "";
}); });
const statusTagType = computed(() => {
const map = {
UNPAID: "warning",
PAID: "warning",
UNDELIVERED: "warning",
PARTS_DELIVERED: "warning",
DELIVERED: "warning",
TAKE: "warning",
STAY_PICKED_UP: "warning",
COMPLETED: "success",
CANCELLED: "info",
};
return map[order.value?.order?.orderStatus] || "info";
});
function lineCardKeyRows(line) { function lineCardKeyRows(line) {
return line.cardKeys || []; return line.cardKeys || [];
@@ -720,36 +778,59 @@ onMounted(() => {
} }
} }
.mb_5 {
margin-bottom: 5px;
}
.order-detail { .order-detail {
color: #333; color: #333;
} }
.order-card { .panel {
padding: 4px 0 16px; margin-top: 16px;
margin-bottom: 8px; padding: 16px 20px;
border-bottom: 1px solid #f0f0f0; border: 1px solid #eee;
border-radius: 8px;
background: #fff;
h3 { h3 {
margin: 0 0 12px; margin: 0 0 12px;
font-size: 15px; padding-bottom: 12px;
border-bottom: 1px solid #f5f5f5;
font-size: 16px;
font-weight: 600; font-weight: 600;
color: #333; color: #333;
line-height: 1.4; line-height: 24px;
}
p {
margin: 0;
padding: 4px 0;
color: #666;
line-height: 22px;
} }
} }
.status-card { .info-row {
padding-top: 8px; display: flex;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid #f5f5f5;
&:last-child {
border-bottom: none;
padding-bottom: 4px;
}
}
.info-label {
flex: 0 0 80px;
color: #999;
font-size: 14px;
line-height: 22px;
}
.info-value {
flex: 1;
min-width: 0;
color: #333;
font-size: 14px;
line-height: 22px;
word-break: break-all;
}
.empty-text {
color: #999;
font-size: 14px;
} }
.status-head { .status-head {
@@ -759,16 +840,17 @@ onMounted(() => {
gap: 16px; gap: 16px;
} }
.status-text { .status-main {
color: $theme_color; display: flex;
font-size: 18px; align-items: center;
font-weight: 600; gap: 12px;
line-height: 1.4; flex-wrap: wrap;
} }
.status-sn { .status-sn {
margin-top: 6px; margin: 0;
color: #666; color: #999;
font-size: 14px;
} }
.status-side { .status-side {
@@ -828,78 +910,61 @@ onMounted(() => {
} }
} }
.goods { .goods-panel {
padding-top: 8px; .shop-name {
} margin: 0 0 12px;
padding-bottom: 12px;
border-bottom: 1px solid #f5f5f5;
font-size: 16px;
font-weight: 600;
.shop-name { span {
margin: 8px 0 12px; color: #333;
font-size: 14px; cursor: pointer;
font-weight: 600;
span { &:hover {
color: #438cde; color: $theme_color;
cursor: pointer; }
&:hover {
color: $theme_color;
} }
} }
} }
table { // 覆盖全局 .goods-list 的 flex 布局,避免订单商品行被压缩
border: 1px solid #eee; .goods-list {
color: #666; display: block;
border-collapse: collapse;
width: 100%;
tr {
border-top: 1px solid #eee;
}
thead > tr {
height: 40px;
background: #fafafa;
}
th {
padding: 0 8px;
font-size: 13px;
font-weight: 500;
color: #666;
}
td {
padding: 12px 8px;
text-align: center;
vertical-align: middle;
&:first-child {
text-align: left;
}
}
} }
.goods-cell { .goods-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 16px;
min-width: 0; padding: 12px 0;
border-bottom: 1px solid #f5f5f5;
img { &:last-child {
width: 64px; border-bottom: none;
height: 64px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
background: #f5f5f5;
cursor: pointer;
} }
} }
.goods-thumb {
width: 56px;
height: 56px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
background: #f5f5f5;
cursor: pointer;
}
.goods-info {
flex: 1;
min-width: 0;
}
.goods-name { .goods-name {
min-width: 0; margin: 0;
color: #333; color: #333;
font-size: 14px;
line-height: 20px; line-height: 20px;
display: -webkit-box; display: -webkit-box;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
@@ -909,12 +974,41 @@ table {
cursor: pointer; cursor: pointer;
} }
.order-item-actions { .goods-sn {
vertical-align: middle; margin: 4px 0 0;
color: #999;
font-size: 12px;
line-height: 18px;
}
.goods-price {
flex-shrink: 0;
text-align: right;
.unit-price {
display: block;
color: #999;
font-size: 13px;
line-height: 20px;
}
.subtotal {
display: block;
color: #333;
font-size: 14px;
font-weight: 600;
line-height: 22px;
}
}
.goods-actions {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
.comment-text { .comment-text {
display: block;
margin: 0 auto 6px;
padding: 0; padding: 0;
border: 0; border: 0;
background: transparent; background: transparent;
@@ -924,9 +1018,8 @@ table {
cursor: pointer; cursor: pointer;
} }
.order-item-action-btn { .el-button {
display: block; margin: 0;
margin: 0 auto 6px;
min-width: 88px; min-width: 88px;
} }
} }
@@ -958,13 +1051,12 @@ table {
} }
} }
.verificationCode { .verificationCode {
font-size: 20px; margin: 16px 0 0;
margin-bottom: 20px; font-size: 14px;
color: rgb(65, 63, 63); color: #333;
font-weight: bold;
text-align: center;
span { span {
color: $theme_color; font-weight: 600;
} }
} }
@@ -1086,34 +1178,4 @@ table {
} }
} }
} }
.layui-layer-wrap {
dl {
border-top: solid 1px #f5f5f5;
margin-top: -1px;
overflow: hidden;
dt {
font-size: 14px;
line-height: 28px;
display: inline-block;
padding: 8px 1% 8px 0;
color: #999;
}
dd {
font-size: 14px;
line-height: 28px;
display: inline-block;
padding: 8px 0 8px 8px;
border-left: solid 1px #f5f5f5;
.text-box {
line-height: 40px;
color: #333;
word-break: break-all;
}
}
}
}
</style> </style>

View File

@@ -7,7 +7,12 @@
> >
<empty v-if="list.length == 0" /> <empty v-if="list.length == 0" />
<ul class="coupon-list" v-else> <ul class="coupon-list" v-else>
<li v-for="(item, index) in list" class="coupon-item" :key="index"> <li
v-for="(item, index) in list"
class="coupon-item"
:class="{ 'is-disabled': params.memberCouponStatus !== 'NEW' }"
:key="index"
>
<div class="c-left"> <div class="c-left">
<div> <div>
<span <span
@@ -18,7 +23,7 @@
v-if="item.couponType === 'DISCOUNT'" v-if="item.couponType === 'DISCOUNT'"
class="global_color coupon-amount" class="global_color coupon-amount"
><span class="price">{{ item.discount }}</span>折</span> ><span class="price">{{ item.discount }}</span>折</span>
<span class="describe">满{{ item.consumeThreshold }}元可用</span> <span class="describe">{{ item.consumeThreshold > 0 ? '满' + item.consumeThreshold + '元可用' : '无门槛使用' }}</span>
</div> </div>
<p>使用范围:{{ useScope(item.scopeType, item.storeName) }}</p> <p>使用范围:{{ useScope(item.scopeType, item.storeName) }}</p>
<p class="coupon-expire">有效期:{{ formatValidity(item) }}</p> <p class="coupon-expire">有效期:{{ formatValidity(item) }}</p>
@@ -176,34 +181,61 @@ onMounted(() => {
<style scoped lang="scss"> <style scoped lang="scss">
@import "../../../assets/styles/coupon.scss"; @import "../../../assets/styles/coupon.scss";
.coupon-list {
gap: 16px;
}
.coupon-item { .coupon-item {
overflow: hidden; overflow: hidden;
height: auto; height: auto;
min-height: 168px; min-height: 140px;
margin-left: 0; margin: 0;
border-radius: 8px;
transition: box-shadow 0.2s ease, transform 0.2s ease;
$claim-width: 55px; $claim-width: 55px;
&:not(.is-disabled):hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1);
}
.c-left { .c-left {
width: calc(100% - #{$claim-width}); width: calc(100% - #{$claim-width});
box-sizing: border-box; box-sizing: border-box;
padding: 16px 16px 14px; padding: 16px 16px 14px;
background: #fff8f5;
.coupon-amount { .coupon-amount {
font-size: 14px; font-size: 14px;
.price {
font-size: 28px;
font-weight: 700;
}
} }
.describe { .describe {
font-size: 14px; font-size: 12px;
} }
p { p {
font-size: 14px; font-size: 13px;
overflow: visible; overflow: visible;
text-overflow: unset; text-overflow: unset;
white-space: normal; white-space: normal;
word-break: break-all; word-break: normal;
line-height: 20px; line-height: 20px;
} }
p:nth-of-type(1) {
font-weight: 500;
color: #333;
}
.coupon-expire {
font-size: 12px;
}
} }
b { b {
@@ -248,6 +280,7 @@ onMounted(() => {
right: calc(#{$claim-width} - 9px); right: calc(#{$claim-width} - 9px);
width: 18px; width: 18px;
height: 18px; height: 18px;
border-color: #eee;
} }
i.circle-top { i.circle-top {
@@ -257,6 +290,26 @@ onMounted(() => {
i.circle-bottom { i.circle-bottom {
bottom: -9px; bottom: -9px;
} }
&.is-disabled {
.c-left {
background: #fafafa;
.coupon-amount,
.price {
color: #999;
}
.describe {
background: #f0f0f0;
color: #999;
}
p {
color: #999 !important;
}
}
}
} }
.pageration { .pageration {

View File

@@ -1,6 +1,10 @@
<template> <template>
<div class="wrapper"> <div class="wrapper">
<UserCenterLayout> <UserCenterLayout
:tabs="walletTabs"
:active-tab="activeWalletTabIndex"
@tab-change="handleTabChange"
>
<div class="wallet-summary"> <div class="wallet-summary">
<div class="wallet-stat"> <div class="wallet-stat">
@@ -13,8 +17,8 @@
</div> </div>
</div> </div>
<div class="account-btns"> <div class="account-btns">
<button type="button" class="op-btn" @click="recharge">在线充值</button> <el-button type="primary" @click="recharge">在线充值</el-button>
<button type="button" class="ghost-btn" @click="withdrawalApply">申请提现</button> <el-button @click="withdrawalApply">申请提现</el-button>
</div> </div>
<el-dialog v-model="modal" width="530"> <el-dialog v-model="modal" width="530">
<template #header><p> <template #header><p>
@@ -36,7 +40,7 @@
</el-form> </el-form>
</div> </div>
<template #footer><div style="text-align: center"> <template #footer><div style="text-align: center">
<el-button size="large" type="success" @click="rechargePrice">充值</el-button> <el-button size="large" type="primary" @click="rechargePrice">充值</el-button>
</div></template> </div></template>
</el-dialog> </el-dialog>
<!-- 提现申请 --> <!-- 提现申请 -->
@@ -65,7 +69,7 @@
number number
size="large" size="large"
><template #append><span>元</span></template></el-input> ><template #append><span>元</span></template></el-input>
<span style="color: red">最低提现金额 {{ withdrawApplyFormData.minPrice }}元</span> <span class="form-tip">最低提现金额 {{ withdrawApplyFormData.minPrice }}元</span>
</el-form-item> </el-form-item>
<el-form-item v-if="withdrawApplyFormData.type === 'ALI'" label="真实姓名" prop="realName"> <el-form-item v-if="withdrawApplyFormData.type === 'ALI'" label="真实姓名" prop="realName">
<el-input <el-input
@@ -86,14 +90,9 @@
</el-form> </el-form>
</div> </div>
<template #footer><div style="text-align: center"> <template #footer><div style="text-align: center">
<el-button size="large" type="success" @click="withdrawal">提现</el-button> <el-button size="large" type="primary" @click="withdrawal">提现</el-button>
</div></template> </div></template>
</el-dialog> </el-dialog>
<div class="wallet-tabs">
<div class="wallet-tab" :class="{ active: activeWalletTab === 'log' }" @click="tabPaneChange('log')">余额日志</div>
<div class="wallet-tab" :class="{ active: activeWalletTab === 'recharge' }" @click="tabPaneChange('recharge')">充值记录</div>
<div class="wallet-tab" :class="{ active: activeWalletTab === 'withdrawApply' }" @click="tabPaneChange('withdrawApply')">提现记录</div>
</div>
<div v-show="activeWalletTab === 'log'"> <div v-show="activeWalletTab === 'log'">
<empty v-if="!(logColumnsData.records || []).length" /> <empty v-if="!(logColumnsData.records || []).length" />
@@ -181,7 +180,7 @@
</template> </template>
<script setup> <script setup>
import { onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import {getDepositLog, getMembersWallet, getRecharge, getWithdrawApply, recharge as apiRecharge, withdrawalApply as apiWithdrawalApply} from '@/api/member'; import {getDepositLog, getMembersWallet, getRecharge, getWithdrawApply, recharge as apiRecharge, withdrawalApply as apiWithdrawalApply} from '@/api/member';
@@ -196,6 +195,12 @@ const formDataRef = ref(null);
const withdrawApplyFormDataRef = ref(null); const withdrawApplyFormDataRef = ref(null);
const activeWalletTab = ref("log"); const activeWalletTab = ref("log");
const walletTabs = ["余额日志", "充值记录", "提现记录"]; // 钱包页签
const walletTabKeys = ["log", "recharge", "withdrawApply"]; // 页签对应标识
const activeWalletTabIndex = computed(() => {
const index = walletTabKeys.indexOf(activeWalletTab.value);
return index > -1 ? index : 0;
});
const frozenDeposit = ref(0); // 冻结余额 const frozenDeposit = ref(0); // 冻结余额
const memberDeposit = ref(0); // 余额 const memberDeposit = ref(0); // 余额
@@ -290,6 +295,10 @@ function tabPaneChange(v) {
getWithdrawApplyData(); getWithdrawApplyData();
} }
} }
// 页签切换
function handleTabChange(index) {
tabPaneChange(walletTabKeys[index]);
}
// 充值记录 // 充值记录
function getRechargeData() { function getRechargeData() {
getRecharge(rechargeForm).then((res) => { getRecharge(rechargeForm).then((res) => {
@@ -416,7 +425,8 @@ function withdrawal() {
.wallet-stat { .wallet-stat {
flex: 1; flex: 1;
padding: 16px 20px; padding: 16px 20px;
background: #fafafa; background: #fff;
border: 1px solid #eee;
border-radius: 8px; border-radius: 8px;
.label { .label {
@@ -445,67 +455,9 @@ function withdrawal() {
margin-bottom: 8px; margin-bottom: 8px;
} }
.op-btn, .form-tip {
.ghost-btn { color: #999;
height: 32px;
padding: 0 16px;
border-radius: 4px;
font-family: inherit;
font-size: 14px; font-size: 14px;
line-height: 30px;
cursor: pointer;
}
.op-btn {
border: 1px solid $theme_color;
background: $theme_color;
color: #fff;
&:hover {
opacity: 0.9;
}
}
.ghost-btn {
border: 1px solid #ddd;
background: #fff;
color: #171A26;
&:hover {
border-color: $theme_color;
color: $theme_color;
}
}
.wallet-tabs {
display: flex;
gap: 24px;
margin: 16px 0;
border-bottom: 1px solid #f0f0f0;
}
.wallet-tab {
position: relative;
padding: 10px 0;
color: #666;
font-size: 14px;
line-height: 22px;
cursor: pointer;
&.active {
color: $theme_color;
font-weight: 600;
&::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background: $theme_color;
}
}
} }
.wallet-list { .wallet-list {

View File

@@ -1,19 +1,20 @@
<template> <template>
<div class="wrapper my-gift-cards"> <div class="wrapper my-gift-cards">
<UserCenterLayout> <UserCenterLayout>
<div class="gift-page-heading">
<div><h2>我的礼品卡</h2><p>查看卡片权益,兑换你的心意好礼</p></div>
</div>
<div class="bind-section mb_20"> <div class="bind-section mb_20">
<div class="section-title">兑换礼品卡</div> <div class="section-title">兑换礼品卡</div>
<el-form ref="bindFormRef" :model="bindForm" :rules="bindRules" inline class="bind-form"> <el-form ref="bindFormRef" :model="bindForm" :rules="bindRules" class="bind-form">
<el-form-item label="卡号" prop="cardNo" label-width="60px"> <el-form-item label="卡号" prop="cardNo" label-width="60px">
<el-input v-model="bindForm.cardNo" placeholder="请输入卡号" maxlength="64" style="width: 220px" /> <el-input v-model="bindForm.cardNo" placeholder="请输入卡号" maxlength="64" />
</el-form-item> </el-form-item>
<el-form-item label="兑换码" prop="cardSecret" label-width="70px"> <el-form-item label="兑换码" prop="cardSecret" label-width="70px">
<el-input <el-input
v-model="bindForm.cardSecret" v-model="bindForm.cardSecret"
placeholder="请输入兑换码(卡密)" placeholder="请输入兑换码(卡密)"
maxlength="128" maxlength="128"
style="width: 220px"
/> />
</el-form-item> </el-form-item>
<el-form-item label-width="0px"> <el-form-item label-width="0px">
@@ -25,31 +26,41 @@
<el-tabs v-model="statusTab" class="status-tabs mb_16" @tab-click="(tab) => onStatusTab(tab.paneName)"> <el-tabs v-model="statusTab" class="status-tabs mb_16" @tab-click="(tab) => onStatusTab(tab.paneName)">
<el-tab-pane label="可用" name="AVAILABLE" /> <el-tab-pane label="可用" name="AVAILABLE" />
<el-tab-pane label="不可用" name="UNAVAILABLE" /> <el-tab-pane label="不可用" name="UNAVAILABLE" />
<el-tab-pane label="待激活" name="PENDING_ACTIVATION" /> <el-tab-pane name="PENDING_ACTIVATION">
<template #label>
<span class="pending-tab-label">待激活<span v-if="pendingCount !== null" class="pending-count">{{ pendingCount }}</span></span>
</template>
</el-tab-pane>
</el-tabs> </el-tabs>
<div class="card-list-wrap"> <div class="card-list-wrap">
<el-skeleton v-if="loading" fix /> <el-skeleton v-if="loading" :rows="5" animated />
<template v-if="!loading"> <template v-if="!loading">
<empty v-if="list.length === 0" /> <div v-if="loadFailed" class="gift-load-error"><p>卡片加载失败,请稍后重试</p><el-button @click="getList">重新加载</el-button></div>
<el-empty v-else-if="list.length === 0" :description="emptyDescription" :image-size="100" />
<div v-else class="gift-card-grid"> <div v-else class="gift-card-grid">
<div v-for="item in list" :key="item.id || item.cardNo" class="gcc-card-item"> <div v-for="item in list" :key="item.id || item.cardNo" class="gcc-card-item" :class="{ 'is-unavailable': statusTab === 'UNAVAILABLE', 'is-pickup': item.cardType === 'PICKUP' }">
<div class="gcc-card-header"> <div class="gcc-card-header">
<div class="gcc-header-pattern" aria-hidden="true" /> <div class="gcc-header-pattern" aria-hidden="true" />
<div class="gcc-header-inner"> <div class="gcc-header-inner">
<div class="gcc-header-left"> <div class="gcc-header-left">
<div class="gcc-title">{{ item.giftCardName || "礼品卡" }}</div> <div class="gcc-name-row">
<div class="gcc-face">面值{{ formatYuan(item.faceValue) }}元</div> <div class="gcc-title">{{ item.giftCardName || "礼品卡" }}</div>
<div class="gcc-cardno">{{ item.cardNo }}</div> <span class="gcc-status-chip">{{ statusChipLabel }}</span>
</div>
<div v-if="item.cardType === 'PICKUP'" class="gcc-face">兑换 <strong>{{ item.pickupCount }}</strong> 种商品</div>
<div v-else class="gcc-face"><span class="gcc-face-symbol">¥</span>{{ formatYuan(item.faceValue) }}</div>
<div class="gcc-cardno">卡号 {{ item.cardNo }}</div>
<div class="gcc-expire">{{ formatValidity(item) }}</div>
</div> </div>
<div class="gcc-header-right"> <div class="gcc-header-right">
<div class="gcc-type">现金卡</div> <div class="gcc-type">{{ item.cardType === 'PICKUP' ? '提货卡' : '现金卡' }}</div>
<div class="gcc-expire">{{ formatValidity(item) }}</div>
</div> </div>
</div> </div>
</div> </div>
<div class="gcc-card-body"> <div class="gcc-card-body">
<div class="gcc-row"> <div v-if="item.cardType === 'PICKUP'" class="gcc-row">每种一件 · 一次兑完 · 免邮</div>
<div v-else class="gcc-row">
当前余额:<span class="gcc-strong" v-if="item.balance != null">¥{{ $filters.unitPrice(item.balance) }}</span><span class="gcc-strong" v-else>—</span> 当前余额:<span class="gcc-strong" v-if="item.balance != null">¥{{ $filters.unitPrice(item.balance) }}</span><span class="gcc-strong" v-else>—</span>
</div> </div>
@@ -109,6 +120,10 @@ const router = useRouter();
const statusTab = ref("AVAILABLE"); const statusTab = ref("AVAILABLE");
const loading = ref(false); const loading = ref(false);
const total = ref(0); const total = ref(0);
const pendingCount = ref(null);
const loadFailed = ref(false);
let listRequestId = 0;
let pendingCountRequestId = 0;
const list = ref([]); const list = ref([]);
const params = reactive({ const params = reactive({
pageNumber: 1, pageNumber: 1,
@@ -127,7 +142,6 @@ const bindRules = {
cardSecret: [{ required: true, message: "请输入兑换码", trigger: "blur" }], cardSecret: [{ required: true, message: "请输入兑换码", trigger: "blur" }],
}; };
const noticeModal = ref(false); const noticeModal = ref(false);
const serviceHotline = ref("12221111");
const activatingId = ref(""); const activatingId = ref("");
const bindFormRef = ref(null); const bindFormRef = ref(null);
@@ -146,11 +160,34 @@ const primaryActionDisabled = computed(() => {
const pendingActivateLocked = computed(() => { const pendingActivateLocked = computed(() => {
return statusTab.value === "PENDING_ACTIVATION" && !!activatingId.value; return statusTab.value === "PENDING_ACTIVATION" && !!activatingId.value;
}); });
const statusChipLabel = computed(() => {
const map = { AVAILABLE: "可用", UNAVAILABLE: "不可用", PENDING_ACTIVATION: "待激活" };
return map[statusTab.value] || "";
});
const emptyDescription = computed(() => ({
AVAILABLE: "暂无可用礼品卡,可输入卡号和兑换码添加",
UNAVAILABLE: "暂无不可用礼品卡",
PENDING_ACTIVATION: "暂无待激活礼品卡",
}[statusTab.value]));
onMounted(() => { onMounted(() => {
getList(); getList();
refreshPendingCount();
}); });
/** 复用会员分页接口的 total 获取全部待激活数量,独立于当前标签和每页条数。 */
async function refreshPendingCount() {
const requestId = ++pendingCountRequestId;
try {
const res = await getGiftCardCashMemberCardPage({ memberCardStatus: "PENDING_ACTIVATION", pageNumber: 1, pageSize: 1 });
if (requestId === pendingCountRequestId && res.success && res.result) {
pendingCount.value = Number(res.result.total) || 0;
}
} catch {
// 统计失败时保留已知数量,不将未知数量显示成零。
}
}
function formatYuan(val) { function formatYuan(val) {
if (val == null || val === "") { if (val == null || val === "") {
return "—"; return "—";
@@ -188,21 +225,32 @@ function onStatusTab(name) {
} }
function getList() { function getList() {
const requestId = ++listRequestId;
loading.value = true; loading.value = true;
loadFailed.value = false;
const p = { ...params }; const p = { ...params };
if (p.memberCardStatus) { if (p.memberCardStatus) {
p.memberCardStatus = String(p.memberCardStatus); p.memberCardStatus = String(p.memberCardStatus);
} }
getGiftCardCashMemberCardPage(p) getGiftCardCashMemberCardPage(p)
.then((res) => { .then((res) => {
if (requestId !== listRequestId) return;
loading.value = false; loading.value = false;
if (res.success && res.result) { if (res.success && res.result) {
list.value = res.result.records || []; list.value = res.result.records || [];
total.value = res.result.total || 0; total.value = res.result.total || 0;
if (p.memberCardStatus === "PENDING_ACTIVATION") {
++pendingCountRequestId;
pendingCount.value = Number(total.value);
}
} else {
loadFailed.value = true;
} }
}) })
.catch(() => { .catch(() => {
if (requestId !== listRequestId) return;
loading.value = false; loading.value = false;
loadFailed.value = true;
}); });
} }
@@ -243,6 +291,7 @@ function submitBind() {
resetBindForm(); resetBindForm();
params.pageNumber = 1; params.pageNumber = 1;
getList(); getList();
refreshPendingCount();
} }
}) })
.catch(() => { .catch(() => {
@@ -267,6 +316,7 @@ function onCardPrimaryAction(item) {
if (res.success) { if (res.success) {
Message.success(res.message || "激活成功"); Message.success(res.message || "激活成功");
getList(); getList();
refreshPendingCount();
} }
}) })
.catch((err) => { .catch((err) => {
@@ -278,7 +328,7 @@ function onCardPrimaryAction(item) {
return; return;
} }
if (statusTab.value === "AVAILABLE") { if (statusTab.value === "AVAILABLE") {
router.push({ path: "/goodsList" }); router.push(item.cardType === "PICKUP" ? { name: "PickupCard", query: { id: resolveMemberCardId(item) } } : { path: "/goodsList" });
} }
} }
@@ -301,168 +351,125 @@ function isActivating(item) {
<style scoped lang="scss"> <style scoped lang="scss">
.my-gift-cards { .my-gift-cards {
.gift-page-heading {
padding: 10px 4px 22px;
h2 { margin: 0 0 8px; font-size: 22px; font-weight: 600; color: #252833; }
p { margin: 0; font-size: 13px; color: #909399; }
}
.bind-section { .bind-section {
padding: 16px 18px; padding: 20px 24px 24px;
border-radius: 4px; border-radius: 10px;
border: 1px solid #e8eaec; border: 1px solid #f3e7e8;
@include white_background_color(); background: #fff9f9;
}
.section-title {
font-size: 15px;
font-weight: 500;
margin-bottom: 14px;
@include title_color($light_title_color);
} }
.section-title { margin-bottom: 18px; font-size: 15px; font-weight: 600; color: #303133; }
.bind-form { .bind-form {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
align-items: flex-end; align-items: start;
gap: 4px 8px; gap: 16px;
:deep(.el-form-item) { margin: 0; min-width: 0; }
:deep(.el-form-item__content) { min-width: 0; }
:deep(.el-input__wrapper) { min-height: 36px; box-sizing: border-box; background: #fff; }
:deep(.el-button) { height: 36px; min-width: 88px; border-radius: 6px; }
} }
.status-tabs { .status-tabs {
:deep(.el-tabs__header) { margin-top: 28px;
margin-bottom: 0; :deep(.el-tabs__header) { margin-bottom: 0; }
} :deep(.el-tabs__item) { height: 48px; font-size: 15px; }
:deep(.el-tabs__nav-wrap::after) { height: 1px; background: #eceef2; }
} }
.card-list-wrap { .pending-tab-label { display: inline-flex; align-items: center; gap: 8px; }
position: relative; .pending-count {
min-height: 200px; display: inline-flex;
margin-top: 24px; align-items: center;
justify-content: center;
min-width: 22px;
padding: 0 6px;
height: 20px;
box-sizing: border-box;
border-radius: 10px;
color: $theme_color;
background: #fff0f1;
font-size: 12px;
font-weight: 500;
font-variant-numeric: tabular-nums;
} }
.card-list-wrap { position: relative; min-height: 290px; margin-top: 24px; }
.gift-load-error { padding: 64px 0; text-align: center; color: #909399; }
.gift-card-grid { .gift-card-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px 18px; gap: 24px;
align-items: start; align-items: start;
} }
.gcc-card-item { .gcc-card-item {
border-radius: 12px;
overflow: hidden; overflow: hidden;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08); border: 1px solid #f0e3e4;
@include white_background_color(); border-radius: 12px;
} background: #fff;
.gcc-card-header { transition: box-shadow .2s, border-color .2s;
position: relative; &:hover { border-color: #edc9cd; box-shadow: 0 6px 20px rgba(100, 33, 40, .06); }
min-height: 128px;
padding: 0;
background: linear-gradient(125deg, #ff9a4a 0%, #ff7729 42%, #ff8f3d 100%);
} }
.gcc-card-header { position: relative; overflow: hidden; background: linear-gradient(120deg, #fff3f3, #fff9f7); }
.gcc-header-pattern { .gcc-header-pattern {
position: absolute; position: absolute;
inset: 0; width: 180px;
opacity: 0.22; height: 180px;
right: -70px;
bottom: -110px;
border: 24px solid rgba(211, 134, 141, .07);
border-radius: 50%;
pointer-events: none; pointer-events: none;
background-image: repeating-linear-gradient(
-36deg,
transparent,
transparent 5px,
rgba(255, 255, 255, 0.45) 5px,
rgba(255, 255, 255, 0.45) 6px
);
}
.gcc-header-inner {
position: relative;
z-index: 1;
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 16px 14px 14px;
color: #fff;
}
.gcc-header-left {
flex: 1;
min-width: 0;
padding-right: 8px;
}
.gcc-title {
font-size: 17px;
font-weight: 700;
line-height: 1.3;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
}
.gcc-face {
margin-top: 8px;
font-size: 14px;
opacity: 0.95;
}
.gcc-cardno {
margin-top: 22px;
font-size: 14px;
letter-spacing: 0.02em;
opacity: 0.75;
word-break: break-all;
}
.gcc-header-right {
text-align: right;
flex-shrink: 0;
}
.gcc-type {
font-size: 18px;
font-weight: 700;
line-height: 1.2;
}
.gcc-expire {
margin-top: 10px;
font-size: 14px;
opacity: 0.9;
white-space: nowrap;
}
.gcc-card-body {
padding: 14px 14px 16px;
}
.gcc-row {
font-size: 13px;
line-height: 1.85;
@include title_color($light_title_color);
}
.gcc-muted {
color: #999;
}
.gcc-strong {
color: #ff6b22;
font-weight: 600;
}
.gcc-link {
color: $theme_color;
cursor: pointer;
}
.gcc-service {
color: #515a6e;
} }
.gcc-header-inner { position: relative; display: flex; gap: 12px; padding: 22px 20px 18px; }
.gcc-header-left { flex: 1; min-width: 0; }
.gcc-name-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.gcc-title { max-width: 100%; overflow-wrap: anywhere; font-size: 15px; font-weight: 600; line-height: 1.5; color: #523338; }
.gcc-status-chip { padding: 1px 7px; border-radius: 4px; background: rgba(255,255,255,.8); color: #a9757b; font-size: 11px; line-height: 18px; }
.gcc-face { margin-top: 18px; color: #a63e4a; font-size: 30px; font-weight: 600; line-height: 1.3; }
.gcc-face strong { font-size: 32px; font-weight: 600; }
.gcc-face-symbol { margin-right: 3px; font-size: 18px; }
.gcc-cardno { margin-top: 18px; color: #96777b; font-size: 12px; overflow-wrap: anywhere; letter-spacing: .03em; }
.gcc-header-right { flex-shrink: 0; text-align: right; }
.gcc-type { padding: 3px 7px; border: 1px solid #e9cdd0; border-radius: 4px; font-size: 12px; color: #a46f75; white-space: nowrap; }
.gcc-expire { margin-top: 8px; color: #a08a8d; font-size: 12px; }
.gcc-card-body { padding: 16px 20px 20px; }
.gcc-row { min-height: 22px; color: #777e89; font-size: 13px; line-height: 22px; }
.gcc-strong { color: #a63e4a; font-weight: 600; }
.gcc-action { .gcc-action {
display: block; display: block;
width: 100%; width: 100%;
margin-top: 14px; margin-top: 16px;
padding: 10px 16px; padding: 10px 16px;
border: none; border: none;
border-radius: 999px; border-radius: 6px;
font-size: 15px; background: $theme_color;
color: #fff;
font-size: 14px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
color: #e95b6c; transition: opacity .2s;
background: #faf4f5; &:hover:not(.is-disabled) { opacity: .85; }
transition: opacity 0.2s, background 0.2s; &:focus-visible { outline: 2px solid $theme_color; outline-offset: 3px; }
&.is-disabled { color: #aaa; background: #f3f4f6; cursor: not-allowed; }
} }
.gcc-action:hover:not(.is-disabled) { .is-pickup .gcc-face { font-size: 22px; }
background: #f5e8ea; .gcc-card-item.is-unavailable {
border-color: #e8eaed;
.gcc-card-header { background: #f5f6f8; }
.gcc-title, .gcc-face, .gcc-strong { color: #92969e; }
.gcc-cardno, .gcc-expire, .gcc-status-chip, .gcc-type { color: #a1a5ad; }
.gcc-type { border-color: #dddfe4; }
} }
.gcc-action.is-disabled { .page-size { display: flex; justify-content: flex-end; margin-top: 28px; padding-top: 20px; border-top: 1px solid #f0f1f4; }
color: #c5c8ce; @media (max-width: 900px) {
cursor: not-allowed; .bind-form { grid-template-columns: minmax(0, 1fr); gap: 22px; }
background: #f7f7f7; .bind-form :deep(.el-form-item__label) { width: 70px !important; }
} .bind-form :deep(.el-form-item:last-child .el-form-item__content) { padding-left: 70px; }
.page-size { .gift-card-grid { grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); }
display: flex;
justify-content: flex-end;
margin-top: 20px;
} }
} }
.gcc-notice-text { .gcc-notice-text { line-height: 1.7; color: #515a6e; margin-bottom: 16px; }
line-height: 1.7; .gcc-notice-footer { text-align: right; }
color: #515a6e;
margin-bottom: 16px;
}
.gcc-notice-footer {
text-align: right;
}
</style> </style>

View File

@@ -0,0 +1,194 @@
<!--
提货卡选品与收货地址确认,兑换成功后进入普通订单履约。
@author Bulbasaur
@since 2026-11-05
-->
<template>
<UserCenterLayout>
<div v-loading="loading" class="pickup-page">
<header class="pickup-heading">
<div><div class="pickup-eyebrow">提货卡兑换</div><h2>{{ detail.giftCardName || '选择你的好礼' }}</h2></div>
<span v-if="detail.credentialId" class="pickup-validity">提货权益</span>
</header>
<el-result v-if="error" icon="warning" :title="error"><template #extra><el-button @click="load">重新加载</el-button></template></el-result>
<template v-else-if="detail.credentialId">
<el-result v-if="completed || detail.tradeSn" icon="success" title="该提货卡已兑换"><template #extra><el-button type="primary" @click="router.push({name:'MyOrder'})">查看订单</el-button></template></el-result>
<template v-else>
<div class="pickup-rules"><strong>{{ ruleText }}</strong><span>每种一件</span><span>一次兑完</span><span>免邮</span></div>
<section class="pickup-products">
<div class="pickup-section-title"><h3>选择兑换商品</h3><span>共 {{ detail.goods?.length || 0 }} 种商品 · 已选 <b>{{ selected.length }}</b> / {{ detail.pickupCount }} 种</span></div>
<el-checkbox-group v-model="selected" :max="detail.pickupCount" :disabled="busy || detail.pickupMode === 'ALL'" aria-label="可兑换商品">
<div class="pickup-grid">
<el-checkbox v-for="goods in detail.goods" :key="goods.id" :value="goods.id" :disabled="!goods.available" class="pickup-item" :class="{'is-selected': selected.includes(goods.id), 'is-unavailable': !goods.available}">
<div class="pickup-image"><el-image :src="goods.thumbnail" fit="contain" :alt="goods.goodsName"><template #error><span class="pickup-image-fallback">暂无图片</span></template></el-image></div>
<div class="pickup-goods-info">
<strong class="pickup-goods-name" :title="goods.goodsName">{{ goods.goodsName }}</strong>
<div class="pickup-specs" :title="specsText(goods.specs)">{{ specsText(goods.specs) }}</div>
<div class="pickup-store">{{ goods.storeName }}</div>
<div v-if="!goods.available" class="pickup-goods-unavailable">暂不可兑换</div>
</div>
</el-checkbox>
</div>
</el-checkbox-group>
</section>
<section class="pickup-address">
<div class="pickup-section-title"><h3>收货地址</h3><div><el-button link :disabled="busy" @click="router.push({name:'MyAddress'})">管理地址</el-button><el-button link :disabled="busy" @click="loadAddresses">刷新</el-button></div></div>
<el-select v-model="addressId" placeholder="请选择收货地址" :disabled="busy" aria-label="收货地址" class="pickup-address-select">
<el-option v-for="address in addresses" :key="address.id" :value="address.id" :label="addressText(address)" />
</el-select>
<p v-if="!addresses.length" class="pickup-address-empty">还没有收货地址,请先添加地址后刷新。</p>
</section>
<footer class="pickup-checkout">
<div class="pickup-selection"><strong>已选 <b>{{ selected.length }}</b> / {{ detail.pickupCount }} 种商品</strong><span>{{ selectionHint }}</span></div>
<div class="pickup-submit"><div class="pickup-payable">实付 <strong>¥0<span>.00</span></strong><small>免运费</small></div><el-button type="primary" :loading="busy" :disabled="!canSubmit" @click="submit">确认兑换</el-button></div>
</footer>
<p class="pickup-terms">兑换成功后取消不退权益,不支持退款或退货。</p>
</template>
</template>
</div>
</UserCenterLayout>
</template>
<script setup>
import { computed, onMounted, onBeforeUnmount, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { memberAddress } from '@/api/address';
import storage from '@/plugins/storage';
import { getPickupCard, previewPickupCard, redeemPickupCard } from '@/api/pickupCard';
const route = useRoute(), router = useRouter();
const detail = ref({}), selected = ref([]), addresses = ref([]), addressId = ref('');
const loading = ref(false), busy = ref(false), error = ref(''), completed = ref(false);
let active = true, requestVersion = 0;
const owner = storage.getItem('userInfo') || '';
/** 账号切换后丢弃旧请求结果,避免展示上一个账号的卡和地址。 */
function sameOwner() { return active && owner === (storage.getItem('userInfo') || ''); }
const canSubmit = computed(() => selected.value.length === detail.value.pickupCount && !!addressId.value && !loading.value && !detail.value.goods?.some(g => selected.value.includes(g.id) && !g.available));
const ruleText = computed(() => detail.value.pickupMode === 'ALL' ? '兑换以下全部商品' : `从以下商品任选 ${detail.value.pickupCount || 1} 种`);
const selectionHint = computed(() => {
const remaining = (detail.value.pickupCount || 0) - selected.value.length;
if (remaining > 0) return `还需选择 ${remaining} 种商品`;
return addressId.value ? '商品已选齐,请核对收货地址' : '请选择收货地址';
});
/** 兼容键值及旧数组规格,过滤图片元数据,避免展示原始 JSON。 */
function specsText(value) {
try {
const specs = typeof value === 'string' ? JSON.parse(value || '{}') : value;
const values = Array.isArray(specs) ? specs.filter(s => s && s.specName !== 'images').map(s => s.specValue) : Object.entries(specs || {}).filter(([key]) => key !== 'images').map(([, val]) => val);
return values.filter(v => typeof v === 'string' || typeof v === 'number').join(' / ') || '默认规格';
} catch { return typeof value === 'string' && !/^[\s]*[\[{]/.test(value) ? value : '默认规格'; }
}
/** 地址仅用于展示,提交后仍由服务端校验当前会员归属。 */
function addressText(address) { return `${address.name} ${address.mobile} ${address.consigneeAddressPath || ''} ${address.detail || ''}`; }
/** 刷新地址,保留仍存在的选择。 */
async function loadAddresses() {
const response = await memberAddress();
if (!sameOwner()) return;
if (!response.success) throw new Error(response.message || '地址加载失败');
addresses.value = response.result?.records || response.result || [];
if (!addresses.value.some(a => a.id === addressId.value)) addressId.value = (addresses.value.find(a => a.isDefault) || addresses.value[0])?.id || '';
}
/** 加载卡详情;离页或新请求发起后丢弃旧响应。 */
async function load() {
const version = ++requestVersion;
loading.value = true; error.value = '';
try {
const response = await getPickupCard(route.query.id);
if (!sameOwner() || version !== requestVersion) return;
if (!response.success) throw new Error(response.message || '提货卡加载失败');
detail.value = response.result;
selected.value = detail.value.pickupMode === 'ALL' ? detail.value.goods.map(g => g.id) : [];
await loadAddresses();
} catch (e) { if (sameOwner() && version === requestVersion) error.value = e.message || '加载失败,请重试'; }
finally { if (sameOwner() && version === requestVersion) loading.value = false; }
}
/** 先服务端试算再确认核销,网络异常保留选项,允许幂等重试。 */
async function submit() {
if (busy.value || !canSubmit.value || !sameOwner()) return;
busy.value = true;
const payload = { credentialId: detail.value.credentialId, skuIds: [...selected.value], addressId: addressId.value };
try {
const preview = await previewPickupCard(payload);
if (!sameOwner()) return;
if (!preview.success) throw new Error(preview.message || '兑换校验失败');
await ElMessageBox.confirm(`本次兑换 ${selected.value.length} 种商品,每种一件,免邮且实付为零。兑换后取消不退权益,不支持退款或退货。`, '确认兑换', { confirmButtonText: '确认兑换', cancelButtonText: '返回检查' });
if (!sameOwner()) return;
const response = await redeemPickupCard(payload);
if (!sameOwner()) return;
if (!response.success) throw new Error(response.message || '兑换失败');
completed.value = true;
} catch (e) {
if (sameOwner() && e !== 'cancel' && e !== 'close') {
// 网络超时可能发生在成功提交后,查询原卡确认结果,不重复创建订单。
try { const status = await getPickupCard(payload.credentialId); if (sameOwner() && status.success && status.result?.tradeSn) { detail.value = status.result; completed.value = true; return; } } catch { /* 保留原错误供重试 */ }
ElMessage.error(e.message || '兑换结果待确认,请重试或查看订单');
}
}
finally { if (sameOwner()) busy.value = false; }
}
onMounted(load);
onBeforeUnmount(() => { active = false; requestVersion++; });
</script>
<style scoped lang="scss">
.pickup-page { padding: 10px 4px 0; color: #303133; }
.pickup-heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; margin-bottom: 20px; }
.pickup-eyebrow { margin-bottom: 8px; color: #909399; font-size: 12px; }
.pickup-heading h2 { margin: 0; color: #252833; font-size: 22px; font-weight: 600; overflow-wrap: anywhere; }
.pickup-validity { padding: 5px 10px; border: 1px solid #eee; border-radius: 4px; font-size: 12px; color: #777e89; white-space: nowrap; }
.pickup-rules { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 20px; padding: 16px 18px; border-radius: 6px; background: #f7f8fa; font-size: 13px; color: #858b95; }
.pickup-rules strong { color: #434953; font-weight: 500; }
.pickup-products { margin-top: 28px; }
.pickup-section-title { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 16px; }
.pickup-section-title h3 { margin: 0; font-size: 16px; font-weight: 600; }
.pickup-section-title > span { color: #909399; font-size: 13px; }
.pickup-section-title b { color: $theme_color; font-weight: 500; }
.pickup-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; max-height: 570px; overflow-y: auto; padding: 2px 4px 2px 2px; }
.pickup-item {
position: relative;
display: block;
width: 100%;
height: auto;
margin: 0;
padding: 0;
box-sizing: border-box;
border: 1px solid #e9ebef;
border-radius: 8px;
overflow: hidden;
background: #fff;
transition: border-color .2s, box-shadow .2s;
&:hover:not(.is-unavailable) { border-color: #e7acb1; }
&.is-selected { border-color: $theme_color; box-shadow: 0 0 0 1px $theme_color; }
&.is-unavailable { background: #f8f9fa; }
:deep(.el-checkbox__input) { position: absolute; z-index: 1; top: 12px; right: 12px; }
:deep(.el-checkbox__label) { display: block; padding: 0; width: 100%; white-space: normal; color: inherit; }
}
.pickup-image { display: flex; justify-content: center; align-items: center; height: 140px; margin: 14px 20px 0; background: #fff; }
.pickup-image :deep(.el-image) { width: 100%; height: 100%; }
.pickup-image-fallback { display: flex; height: 100%; align-items: center; justify-content: center; color: #a8abb2; background: #f7f8fa; font-size: 12px; }
.pickup-goods-info { padding: 14px; }
.pickup-goods-name { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; min-height: 40px; line-height: 20px; font-size: 14px; font-weight: 500; color: #303133; }
.pickup-specs { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 8px; color: #6b7280; font-size: 12px; line-height: 18px; }
.pickup-store { margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #a0a5ae; font-size: 12px; line-height: 18px; }
.pickup-goods-unavailable { margin-top: 8px; color: #909399; font-size: 12px; }
.is-unavailable .pickup-image { opacity: .5; }
.pickup-address { margin-top: 28px; padding-top: 24px; border-top: 1px solid #eee; }
.pickup-address-select { width: 100%; }
.pickup-address-select :deep(.el-select__wrapper) { min-height: 42px; }
.pickup-address-empty { color: #909399; font-size: 13px; }
.pickup-checkout { position: sticky; bottom: 0; z-index: 2; display: flex; justify-content: space-between; align-items: center; gap: 20px; margin-top: 24px; padding: 20px 0; border-top: 1px solid #eee; background: #fff; }
.pickup-selection { display: flex; flex-direction: column; gap: 8px; font-size: 14px; }
.pickup-selection strong { font-weight: 500; }
.pickup-selection b { color: $theme_color; font-weight: 600; }
.pickup-selection > span { color: #909399; font-size: 12px; }
.pickup-submit { display: flex; align-items: center; gap: 24px; }
.pickup-payable { font-size: 13px; color: #606266; }
.pickup-payable strong { margin-left: 6px; color: $theme_color; font-size: 26px; font-weight: 600; }
.pickup-payable strong span { font-size: 16px; }
.pickup-payable small { display: block; margin-top: 4px; text-align: right; font-size: 12px; color: #909399; }
.pickup-submit :deep(.el-button) { min-width: 136px; height: 42px; border-radius: 6px; font-size: 15px; }
.pickup-terms { margin: 0; padding-bottom: 6px; color: #a0a5ae; font-size: 12px; line-height: 1.6; }
@media (max-width: 900px) { .pickup-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 600px) { .pickup-grid { grid-template-columns: minmax(0, 1fr); } .pickup-checkout { flex-wrap: wrap; } .pickup-submit { width: 100%; justify-content: space-between; } .pickup-section-title { flex-wrap: wrap; } }
</style>

View File

@@ -202,7 +202,7 @@
<template v-else>{{ item.discount }}折</template> <template v-else>{{ item.discount }}折</template>
</div> </div>
<div class="pay-coupon-info"> <div class="pay-coupon-info">
<p>满{{ item.consumeThreshold }}元可用</p> <p>{{ item.consumeThreshold > 0 ? '满' + item.consumeThreshold + '元可用' : '无门槛使用' }}</p>
<p>使用范围:{{ useScope(item.scopeType, item.storeName) }}</p> <p>使用范围:{{ useScope(item.scopeType, item.storeName) }}</p>
<p>有效期:{{ item.endTime }}</p> <p>有效期:{{ item.endTime }}</p>
</div> </div>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div style="background:#fff;"> <div style="background:#fff;">
<BaseHeader></BaseHeader> <TopBar></TopBar>
<Search style="border-bottom:2px solid red;"></Search> <SearchHeader></SearchHeader>
<div class="base-width cate-container"> <div class="base-width cate-container">
<el-breadcrumb> <el-breadcrumb>
<el-breadcrumb-item to="/">首页</el-breadcrumb-item> <el-breadcrumb-item to="/">首页</el-breadcrumb-item>
@@ -106,7 +106,8 @@ export default {
import { ref, computed, onMounted, nextTick } from "vue"; import { ref, computed, onMounted, nextTick } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import Search from "@/components/Search"; import TopBar from '@/components/indexDecorate/components/TopBar.vue';
import SearchHeader from '@/components/indexDecorate/components/SearchHeader.vue';
import { addCartGoods } from "@/api/cart.js"; import { addCartGoods } from "@/api/cart.js";
import { pointGoodsDetail } from "@/api/promotion"; import { pointGoodsDetail } from "@/api/promotion";
import { import {

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="point-mall"> <div class="point-mall">
<BaseHeader></BaseHeader> <TopBar></TopBar>
<Search></Search> <SearchHeader></SearchHeader>
<cateNav></cateNav> <cateNav></cateNav>
<h3 class="promotion-decorate">积分商品</h3> <h3 class="promotion-decorate">积分商品</h3>
<el-select @select="selectCate" size="small" class="cate-select-con" v-model="cateId"> <el-select @select="selectCate" size="small" class="cate-select-con" v-model="cateId">
@@ -46,6 +46,8 @@
</template> </template>
<script setup> <script setup>
import { onMounted, reactive, ref } from "vue"; import { onMounted, reactive, ref } from "vue";
import TopBar from '@/components/indexDecorate/components/TopBar.vue';
import SearchHeader from '@/components/indexDecorate/components/SearchHeader.vue';
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { pointGoods, pointGoodsCategory } from "@/api/promotion.js"; import { pointGoods, pointGoodsCategory } from "@/api/promotion.js";

View File

@@ -81,7 +81,7 @@ watch(currIndex, (val) => {
interval = null; interval = null;
nowHour.value = new Date().getHours(); nowHour.value = new Date().getHours();
countDown(val); countDown(val);
goodsList.value = list.value[val].seckillGoodsList; goodsList.value = list.value[val]?.seckillGoodsList || [];
}); });
watch(diffSeconds, (val) => { watch(diffSeconds, (val) => {
@@ -108,11 +108,11 @@ watch(diffSeconds, (val) => {
}); });
function getListByDay () { function getListByDay () {
// 当天秒杀活动 // 当天秒杀活动;result 可能为空数组(当天无活动),需判空避免取下标 0 报错
seckillByDay().then((res) => { seckillByDay().then((res) => {
if (res.success) { if (res.success && res.result && res.result.length) {
list.value = res.result; list.value = res.result;
goodsList.value = list.value[0].seckillGoodsList; goodsList.value = list.value[0].seckillGoodsList || [];
countDown(currIndex.value); countDown(currIndex.value);
} }
}); });

View File

@@ -5,27 +5,33 @@
:model="form" :model="form"
:rules="rules" :rules="rules"
label-width="140px" label-width="140px"
scroll-to-error
@submit.prevent @submit.prevent
@keydown.enter.prevent @keydown.enter.prevent
@focusout="saveDraft" @focusout="saveDraft"
> >
<h4>基础信息</h4> <div class="section-title">基础信息</div>
<el-form-item prop="companyName" label="公司名称"> <el-form-item prop="companyName" label="公司名称">
<el-input <el-input
type="text" type="text"
v-model="form.companyName" v-model="form.companyName"
placeholder="请填写公司信息" placeholder="请填写公司名称"
/> />
</el-form-item> </el-form-item>
<el-form-item prop="companyAddressIdPath" label="公司所在地"> <el-form-item prop="companyAddressIdPath" label="公司所在地">
<span>{{ form.companyAddressPath || '暂无地址' }}</span> <div class="address-select" @click="openMap">
<el-button type="default" style="margin-left: 10px;" @click="$refs.map.open()">选择</el-button> <el-icon class="addr-icon"><Location /></el-icon>
<span class="addr-text" :class="{ placeholder: !form.companyAddressPath }">
{{ form.companyAddressPath || '请选择公司所在地' }}
</span>
<span class="addr-action">{{ form.companyAddressPath ? '重新选择' : '选择' }}</span>
</div>
</el-form-item> </el-form-item>
<el-form-item prop="companyAddress" label="公司详细地址"> <el-form-item prop="companyAddress" label="公司详细地址">
<el-input <el-input
type="text" type="text"
v-model="form.companyAddress" v-model="form.companyAddress"
placeholder="请填写公司详细信息" placeholder="请填写公司详细地址"
/> />
</el-form-item> </el-form-item>
<el-form-item prop="employeeNum" label="员工总数"> <el-form-item prop="employeeNum" label="员工总数">
@@ -71,7 +77,7 @@
/> />
</el-form-item> </el-form-item>
<h4>营业执照信息</h4> <div class="section-title">营业执照信息</div>
<el-form-item prop="licenseNum" label="营业执照号"> <el-form-item prop="licenseNum" label="营业执照号">
<el-input <el-input
type="text" type="text"
@@ -92,25 +98,23 @@
<el-form-item prop="licencePhoto" label="营业执照电子版"> <el-form-item prop="licencePhoto" label="营业执照电子版">
<div class="upload-wrap"> <div class="upload-wrap">
<el-upload <el-upload
ref="uploadLicence" v-if="form.licencePhoto.length < 1"
:show-file-list="false" :show-file-list="false"
:on-success="handleSuccess" :on-success="handleSuccess"
accept=".jpg,.jpeg,.png,.gif" accept=".jpg,.jpeg,.png,.gif"
:before-upload="beforeUpload" :before-upload="beforeUpload"
:on-error="uploadErr" :on-error="uploadErr"
:disabled="form.licencePhoto.length >= 1"
:action="action" :action="action"
:headers="accessToken" :headers="accessToken"
> >
<el-button <div class="upload-box">
type="info" <el-icon v-if="uploadLoading" class="is-loading"><Loading /></el-icon>
:loading="uploadLoading" <el-icon v-else><Plus /></el-icon>
:disabled="form.licencePhoto.length >= 1" <span>上传营业执照</span>
>证照上传</el-button </div>
>
</el-upload> </el-upload>
<div class="describe"> <div class="describe">
请压缩图片在2M以内,格式为gif,jpg,png,并确保文字清晰,以免上传或审核失败,仅可上传1张 请压缩图片在2M以内,格式为gif、jpg、png,并确保文字清晰,以免上传或审核失败,仅可上传1张
</div> </div>
<div class="img-list-wrap"> <div class="img-list-wrap">
<div <div
@@ -128,7 +132,7 @@
</div> </div>
</el-form-item> </el-form-item>
<h4>法人信息</h4> <div class="section-title">法人信息</div>
<el-form-item prop="legalName" label="法人姓名"> <el-form-item prop="legalName" label="法人姓名">
<el-input <el-input
type="text" type="text"
@@ -147,7 +151,7 @@
<el-form-item prop="legalPhoto" label="法人证件电子版"> <el-form-item prop="legalPhoto" label="法人证件电子版">
<div class="upload-wrap"> <div class="upload-wrap">
<el-upload <el-upload
ref="uploadLegal" v-if="form.legalPhoto.length < 2"
:show-file-list="false" :show-file-list="false"
:on-success="handleSuccess1" :on-success="handleSuccess1"
:before-upload="beforeUpload1" :before-upload="beforeUpload1"
@@ -157,7 +161,11 @@
:action="action" :action="action"
:headers="accessToken" :headers="accessToken"
> >
<el-button type="info" :loading="uploadLoading1">证照上传</el-button> <div class="upload-box">
<el-icon v-if="uploadLoading1" class="is-loading"><Loading /></el-icon>
<el-icon v-else><Plus /></el-icon>
<span>上传法人证件</span>
</div>
</el-upload> </el-upload>
<div class="describe"> <div class="describe">
请压缩图片在2M以内,身份证正反面两张照片,确保图片清晰无缺角 请压缩图片在2M以内,身份证正反面两张照片,确保图片清晰无缺角
@@ -178,9 +186,11 @@
</div> </div>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" :loading="loading" @click="next" <div class="form-actions">
>填写财务资质信息</el-button <el-button type="primary" size="large" :loading="loading" @click="next"
> >下一步:填写财务资质信息</el-button
>
</div>
</el-form-item> </el-form-item>
</el-form> </el-form>
<el-dialog title="图片预览" v-model="visible"> <el-dialog title="图片预览" v-model="visible">
@@ -192,8 +202,8 @@
</template> </template>
<script setup> <script setup>
import { onBeforeUnmount, onMounted, reactive, ref, watch } from "vue"; import { onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { Message, Notice } from "@/utils/message"; import { Message } from "@/utils/message";
import { Delete, View } from '@element-plus/icons-vue'; import { Delete, Loading, Location, Plus, View } from '@element-plus/icons-vue';
import { applyFirst } from '@/api/shopentry'; import { applyFirst } from '@/api/shopentry';
import * as RegExp from '@/plugins/RegExp.js'; import * as RegExp from '@/plugins/RegExp.js';
import multipleMap from "@/components/map/multiple-map"; import multipleMap from "@/components/map/multiple-map";
@@ -237,7 +247,7 @@ const form = ref({ // 表单数据
licencePhoto: [] licencePhoto: []
}); });
const rules = { // 验证规则 const rules = { // 验证规则
companyName: [{ required: true, message: '请填写公司信息' }], companyName: [{ required: true, message: '请填写公司名称' }],
companyAddressIdPath: [{ required: true, message: '请选择公司所在地' }], companyAddressIdPath: [{ required: true, message: '请选择公司所在地' }],
companyAddress: [{ required: true, message: '请填写公司详细地址' }], companyAddress: [{ required: true, message: '请填写公司详细地址' }],
employeeNum: [ employeeNum: [
@@ -254,30 +264,31 @@ const rules = { // 验证规则
{ pattern: RegExp.mobile, message: '请填写正确的号码' } { pattern: RegExp.mobile, message: '请填写正确的号码' }
], ],
companyPhone: [ companyPhone: [
{ required: true, message: '请填写公司电话' }, { required: true, message: '请填写公司电话' }
{ pattern: RegExp.mobile, message: '请填写正确的号码' }
], ],
companyEmail: [ companyEmail: [
{ required: true, message: '请填写电子邮箱' }, { required: true, message: '请填写电子邮箱' },
{ type: 'email', message: '请输入正确的邮箱' } { type: 'email', message: '请输入正确的邮箱' }
], ],
licenseNum: [ licenseNum: [
{ required: true, message: '请填写营业执照号' }, { required: true, message: '请填写营业执照号' }
{ pattern: RegExp.licenseNum, message: '请输入正确的营业执照号' }
], ],
scope: [{ required: true, message: '请填写营业执照所示经营范围' }], scope: [{ required: true, message: '请填写营业执照所示经营范围' }],
legalPhoto: [{ required: true, message: '请上传法人身份证照片' }], legalPhoto: [{ required: true, message: '请上传法人身份证照片' }],
licencePhoto: [{ required: true, message: '请上传营业执照' }], licencePhoto: [{ required: true, message: '请上传营业执照' }],
legalName: [{ required: true, message: '请输入法人姓名' }], legalName: [{ required: true, message: '请输入法人姓名' }],
legalId: [ legalId: [
{ required: true, message: '请输入法人证件号' }, { required: true, message: '请输入法人证件号' }
{ pattern: RegExp.IDCard, message: '请输入正确的证件号' }
] ]
}; };
const uploadLoading1 = ref(false); // 上传loading const uploadLoading1 = ref(false); // 上传loading
const uploadLoading = ref(false); // 上传loading const uploadLoading = ref(false); // 上传loading
const contentInitialized = ref(false); const contentInitialized = ref(false);
function openMap() {
map.value?.open();
}
// 获取店铺地址 // 获取店铺地址
function getAddress(val){ function getAddress(val){
if(val.type === 'select'){ if(val.type === 'select'){
@@ -352,38 +363,34 @@ function validateUploadFile (file) {
} }
return true; return true;
} }
// 上传成功回调 // 上传成功回调(el-upload 只认 HTTP 状态码,业务失败 success:false 也会进这里,需要判断)
function handleSuccess (res, file) { function handleSuccess (res, file) {
uploadLoading.value = false; uploadLoading.value = false;
form.value.licencePhoto = [res.result]; if (res && res.success && res.result) {
form.value.licencePhoto = [res.result];
} else {
Message.error(res && res.message ? res.message : '上传失败,请重试');
}
} }
// 上传成功回调 // 上传成功回调
function handleSuccess1 (res, file) { function handleSuccess1 (res, file) {
uploadLoading1.value = false; uploadLoading1.value = false;
form.value.legalPhoto.push(res.result); if (res && res.success && res.result) {
form.value.legalPhoto.push(res.result);
} else {
Message.error(res && res.message ? res.message : '上传失败,请重试');
}
} }
// 上传失败 // 上传失败(非 2xx:err.message 通常是响应体字符串,尝试解析出服务端提示)
function uploadErr () { function uploadErr (err) {
uploadLoading.value = false; uploadLoading.value = false;
uploadLoading1.value = false; uploadLoading1.value = false;
} let msg = '上传失败,请重试';
// 上传失败回调 try {
function handleFormatError (file) { const body = JSON.parse(err && err.message);
uploadLoading.value = false; if (body && body.message) msg = body.message;
uploadLoading1.value = false; } catch (e) { /* 非 JSON 响应,用默认提示 */ }
Notice.warning({ Message.error(msg);
title: 'The file format is incorrect',
desc: '上传文件格式不正确'
});
}
// 上传大小限制
function handleMaxSize (file) {
uploadLoading.value = false;
uploadLoading1.value = false;
Notice.warning({
title: 'Exceeding file size limit',
desc: '文件大小不能超过2M'
});
} }
// 图片查看 // 图片查看
function handleView (item) { function handleView (item) {
@@ -456,19 +463,76 @@ onBeforeUnmount(() => {
}); });
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
h4 { .section-title {
margin-bottom: 10px; display: flex;
padding: 0 10px; align-items: center;
border: 1px solid #ddd; margin: 24px 0 18px;
background-color: #f8f8f8; font-size: 16px;
font-weight: bold; font-weight: 600;
color: #333; color: #303133;
font-size: 14px;
line-height: 40px; &::before {
text-align: left; content: '';
width: 4px;
height: 18px;
border-radius: 2px;
background: $theme_color;
margin-right: 8px;
}
} }
.el-input { .el-input {
width: 300px; width: 360px;
}
.el-textarea {
width: 640px;
max-width: 100%;
}
.address-select {
display: flex;
align-items: center;
width: 360px;
height: 32px;
padding: 0 12px;
border: 1px solid #dcdfe6;
border-radius: 4px;
cursor: pointer;
transition: border-color 0.2s;
background: #fff;
&:hover {
border-color: $theme_color;
}
.addr-icon {
color: #909399;
margin-right: 6px;
flex-shrink: 0;
}
.addr-text {
flex: 1;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.placeholder {
color: #a8abb2;
}
}
.addr-action {
color: $theme_color;
margin-left: 8px;
flex-shrink: 0;
}
}
.form-actions {
margin-top: 8px;
.el-button {
min-width: 240px;
}
} }
.img-list-wrap { .img-list-wrap {
display: flex; display: flex;
@@ -486,6 +550,7 @@ h4 {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
border: 1px solid #eee; border: 1px solid #eee;
border-radius: 8px;
box-sizing: border-box; box-sizing: border-box;
background-color: #f5f5f5; background-color: #f5f5f5;
@@ -531,6 +596,32 @@ h4 {
} }
} }
.upload-box {
width: 120px;
height: 120px;
border: 1px dashed #c0c4cc;
border-radius: 8px;
background: #fafafa;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
color: #909399;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
.el-icon {
font-size: 26px;
}
&:hover {
border-color: $theme_color;
color: $theme_color;
}
}
.describe { .describe {
display: block; display: block;
width: 100%; width: 100%;

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="photo-msg"> <div class="photo-msg">
<el-form ref="secondForm" :model="form" :rules="rules" label-width="140px"> <el-form ref="secondForm" :model="form" :rules="rules" label-width="140px" scroll-to-error>
<h4>基础信息</h4> <div class="section-title">结算银行信息</div>
<el-form-item prop="settlementBankAccountName" label="银行开户名"> <el-form-item prop="settlementBankAccountName" label="银行开户名">
<el-input <el-input
type="text" type="text"
@@ -32,18 +32,22 @@
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button @click="$emit('change', 0)">返回</el-button> <div class="form-actions">
<el-button type="primary" :loading="loading" @click="next" <el-button size="large" @click="$emit('change', 0)">上一步</el-button>
>填写其他信息</el-button <el-button type="primary" size="large" :loading="loading" @click="next"
> >下一步:填写其他信息</el-button
>
</div>
</el-form-item> </el-form-item>
</el-form> </el-form>
</div> </div>
</template> </template>
<script setup> <script setup>
import { onMounted, reactive, ref } from 'vue'; import { onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { applySecond } from '@/api/shopentry'; import { applySecond } from '@/api/shopentry';
const SECOND_APPLY_DRAFT_KEY = 'shopEntrySecondDraft';
const emit = defineEmits(['change']); const emit = defineEmits(['change']);
const props = defineProps({ const props = defineProps({
content: { content: {
@@ -78,7 +82,10 @@ function next() {
applySecond(form) applySecond(form)
.then((res) => { .then((res) => {
loading.value = false; loading.value = false;
if (res.success) emit('change', 2); if (res.success) {
sessionStorage.removeItem(SECOND_APPLY_DRAFT_KEY);
emit('change', 2);
}
}) })
.catch(() => { .catch(() => {
loading.value = false; loading.value = false;
@@ -87,25 +94,62 @@ function next() {
}); });
} }
function restoreDraft() {
const draft = sessionStorage.getItem(SECOND_APPLY_DRAFT_KEY);
if (!draft) return;
try {
Object.assign(form, JSON.parse(draft));
} catch (e) {
sessionStorage.removeItem(SECOND_APPLY_DRAFT_KEY);
}
}
function saveDraft() {
sessionStorage.setItem(SECOND_APPLY_DRAFT_KEY, JSON.stringify(form));
}
onMounted(() => { onMounted(() => {
if (props.content && Object.keys(props.content).length) { if (props.content && Object.keys(props.content).length) {
Object.assign(form, JSON.parse(JSON.stringify(props.content))); Object.assign(form, JSON.parse(JSON.stringify(props.content)));
} else {
restoreDraft();
} }
}); });
onBeforeUnmount(() => {
saveDraft();
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
h4 { .section-title {
margin-bottom: 10px; display: flex;
padding: 0 10px; align-items: center;
border: 1px solid #ddd; margin: 24px 0 18px;
background-color: #f8f8f8; font-size: 16px;
font-weight: bold; font-weight: 600;
color: #333; color: #303133;
font-size: 14px;
line-height: 40px; &::before {
text-align: left; content: '';
width: 4px;
height: 18px;
border-radius: 2px;
background: $theme_color;
margin-right: 8px;
}
} }
.el-input { .el-input {
width: 300px; width: 360px;
}
.form-actions {
margin-top: 8px;
.el-button {
min-width: 120px;
}
.el-button--primary {
min-width: 240px;
}
} }
</style> </style>

View File

@@ -1,14 +1,38 @@
<template> <template>
<div class="shop-entry"> <div class="shop-entry">
<div style="height: 20px"></div>
<div class="content"> <div class="content">
<h1>店铺入驻</h1> <div class="page-header">
<el-steps :active="currentIndex" class="margin" finish-status="success"> <div class="header-icon">
<el-step title="企业资质信息"></el-step> <el-icon><Shop /></el-icon>
<el-step title="财务资质信息"></el-step> </div>
<el-step title="其他信息"></el-step> <div class="header-text">
<el-step title="提交审核"></el-step> <h1>店铺入驻</h1>
</el-steps> <p>完善企业资质信息,快速开启您的电商之旅</p>
</div>
</div>
<div class="steps-bar">
<div
v-for="(step, i) in steps"
:key="i"
class="step-item"
:class="{
active: currentIndex === i,
done: currentIndex > i,
clickable: canClickStep(i),
}"
@click="onStepClick(i)"
>
<div class="step-circle">
<el-icon v-if="currentIndex > i"><Check /></el-icon>
<span v-else>{{ i + 1 }}</span>
</div>
<div class="step-title">{{ step.title }}</div>
<div class="step-desc">{{ step.desc }}</div>
<div class="step-line" v-if="i < steps.length - 1"></div>
</div>
</div>
<div v-if="initLoading" class="step-loading" v-loading="true"></div> <div v-if="initLoading" class="step-loading" v-loading="true"></div>
<template v-else> <template v-else>
<first-apply <first-apply
@@ -30,34 +54,68 @@
></third-apply> ></third-apply>
</template> </template>
<div class="success-page" v-if="currentIndex == 3"> <div class="status-page" v-if="currentIndex === 3">
<span v-if="storeDisable == '' || storeDisable == 'APPLYING'">入驻申请提交成功,等待平台审核</span> <el-result
<span v-if="storeDisable == 'OPEN'">申请已通过,请联系管理员</span> v-if="storeDisable === '' || storeDisable === 'APPLYING'"
<span v-if="storeDisable == 'CLOSED'">店铺已关闭,重申请联系管理员</span> icon="info"
<span v-if="storeDisable == 'REFUSED'">审核未通过,请修改资质信息,如有疑问请联系管理员</span> title="入驻申请提交成功"
sub-title="您的入驻申请已提交,请耐心等待平台审核"
>
<template #extra>
<el-button type="primary" @click="$router.push('/')">返回首页</el-button>
</template>
</el-result>
<el-result
v-else-if="storeDisable === 'OPEN'"
icon="success"
title="申请已通过"
sub-title="您的店铺已开通,如有问题请联系管理员"
>
<template #extra>
<el-button type="primary" @click="$router.push('/')">返回首页</el-button>
</template>
</el-result>
<el-result
v-else-if="storeDisable === 'CLOSED'"
icon="warning"
title="店铺已关闭"
sub-title="如需重新入驻,请联系管理员"
>
<template #extra>
<el-button type="primary" @click="$router.push('/')">返回首页</el-button>
</template>
</el-result>
<el-result
v-else-if="storeDisable === 'REFUSED'"
icon="error"
title="审核未通过"
sub-title="请修改资质信息后重新提交,如有疑问请联系管理员"
>
<template #extra>
<el-button @click="$router.push('/')">返回首页</el-button>
<el-button type="primary" @click="currentIndex = 0">重新申请</el-button>
</template>
</el-result>
</div> </div>
<el-button v-if="currentIndex === 3" @click="$router.push('/')">返回</el-button>
&nbsp;
<el-button type="primary" @click='currentIndex = 0'
v-if="storeDisable === 'REFUSED' && currentIndex === 3">重新申请</el-button>
</div> </div>
<el-dialog title="店铺入驻协议" v-model="showAgreement" width="1200" :show-close="false" :close-on-click-modal="false"> <el-dialog title="店铺入驻协议" v-model="showAgreement" width="900px" :show-close="false" :close-on-click-modal="false">
<div class="agreement-scroll"> <div class="agreement-scroll">
<div class="agreeent-con" v-html="agreementCon"></div> <div class="agreeent-con" v-html="agreementCon"></div>
</div> </div>
<template #footer><div style="text-align: center"> <template #footer><div style="text-align: center">
<p> <p>
<el-checkbox v-model="checked">我已同意以上协议</el-checkbox> <el-checkbox v-model="checked">我已阅读并同意以上协议</el-checkbox>
</p> </p>
<el-button type="primary" :disabled="!checked" class="margin" @click="confirmAgreement">同意协议填写资质信息</el-button> <el-button type="primary" :disabled="!checked" class="margin" @click="confirmAgreement">同意协议,填写资质信息</el-button>
</div></template> </div></template>
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
import { onMounted, reactive, ref } from "vue"; import { onMounted, reactive, ref } from "vue";
import { Check, Shop } from "@element-plus/icons-vue";
import { agreement, applyStatus } from "@/api/shopentry"; import { agreement, applyStatus } from "@/api/shopentry";
import firstApply from "./FirstApply"; import firstApply from "./FirstApply";
import secondApply from "./SecondApply"; import secondApply from "./SecondApply";
@@ -74,6 +132,24 @@ const storeDisable = ref(""); // APPLY OPEN 开店中 CLOSED 关闭 REFUSED 拒
const initLoading = ref(true); const initLoading = ref(true);
const agreementAccepted = ref(false); // 是否已同意入驻协议 const agreementAccepted = ref(false); // 是否已同意入驻协议
const steps = [
{ title: "企业资质信息", desc: "填写企业与法人资料" },
{ title: "财务资质信息", desc: "填写结算银行信息" },
{ title: "其他信息", desc: "完善店铺资料" },
{ title: "提交审核", desc: "等待平台审核" },
];
// 已完成步骤可点击回跳;审核状态页仅在 REFUSED 时允许回跳修改
function canClickStep(i) {
if (!agreementAccepted.value || i >= currentIndex.value) return false;
if (currentIndex.value === 3) return storeDisable.value === "REFUSED";
return true;
}
function onStepClick(i) {
if (canClickStep(i)) nextPage(i);
}
function confirmAgreement() { function confirmAgreement() {
agreementAccepted.value = true; agreementAccepted.value = true;
showAgreement.value = false; showAgreement.value = false;
@@ -193,16 +269,126 @@ onMounted(() => {
min-height: 500px; min-height: 500px;
border-radius: 20px; border-radius: 20px;
background: #fff; background: #fff;
padding: 10px 20px; padding: 28px 48px 48px;
}
.page-header {
display: flex;
align-items: center;
gap: 14px;
padding-bottom: 20px;
border-bottom: 1px solid #f0f0f0;
.header-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background: $theme_color;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
flex-shrink: 0;
}
h1 { h1 {
margin: 0;
font-size: 22px;
color: #303133;
}
margin-top: 20px; p {
margin: 4px 0 0;
font-size: 13px;
color: #909399;
} }
} }
.steps-bar {
display: flex;
align-items: flex-start;
margin: 28px 0 36px;
}
.step-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
&.clickable {
cursor: pointer;
&:hover .step-title {
color: $theme_color;
}
}
&.active .step-circle {
border-color: $theme_color;
background: $theme_color;
color: #fff;
}
&.active .step-title {
color: $theme_color;
}
&.done .step-circle {
border-color: $theme_color;
color: $theme_color;
}
&.done .step-line {
background: $theme_color;
}
}
.step-circle {
width: 36px;
height: 36px;
border-radius: 50%;
border: 2px solid #dcdfe6;
background: #fff;
color: #909399;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 600;
position: relative;
z-index: 1;
transition: all 0.25s;
}
.step-title {
margin-top: 10px;
font-size: 14px;
font-weight: 600;
color: #303133;
transition: color 0.25s;
}
.step-desc {
margin-top: 2px;
font-size: 12px;
color: #909399;
}
.step-line {
position: absolute;
top: 18px;
left: calc(50% + 28px);
width: calc(100% - 56px);
height: 2px;
background: #e4e7ed;
transition: background 0.25s;
}
.margin { .margin {
margin: 30px 0; margin: 30px 0 0;
} }
.agreeent-con { .agreeent-con {
max-height: 500px; max-height: 500px;
@@ -218,12 +404,11 @@ onMounted(() => {
.step-loading { .step-loading {
min-height: 320px; min-height: 320px;
} }
.success-page { .status-page {
height: 500px; min-height: 420px;
width: 100%; display: flex;
line-height: 500px; align-items: center;
text-align: center; justify-content: center;
font-size: 20px;
} }
.shop-entry { .shop-entry {
min-height: 100vh; min-height: 100vh;

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="person-msg"> <div class="person-msg">
<el-form ref="thirdFormRef" :model="form" :rules="rules" label-width="140px"> <el-form ref="thirdFormRef" :model="form" :rules="rules" label-width="140px" scroll-to-error>
<h4>基础信息</h4> <div class="section-title">店铺信息</div>
<el-form-item prop="storeName" label="店铺名称"> <el-form-item prop="storeName" label="店铺名称">
<el-input <el-input
type="text" type="text"
@@ -13,19 +13,22 @@
<el-form-item prop="storeLogo" label="店铺logo"> <el-form-item prop="storeLogo" label="店铺logo">
<div class="upload-wrap"> <div class="upload-wrap">
<el-upload <el-upload
ref="uploadLogo" v-if="form.storeLogo.length < 1"
:show-file-list="false" :show-file-list="false"
:on-success="handleSuccess" :on-success="handleSuccess"
accept=".jpg,.jpeg,.png,.gif" accept=".jpg,.jpeg,.png,.gif"
:before-upload="beforeUpload" :before-upload="beforeUpload"
:on-error="uploadErr" :on-error="uploadErr"
:disabled="form.storeLogo.length >= 1"
:action="action" :action="action"
:headers="accessToken" :headers="accessToken"
> >
<el-button type="info" :loading="uploadLoading" :disabled="form.storeLogo.length >= 1">上传logo</el-button> <div class="upload-box">
<el-icon v-if="uploadLoading" class="is-loading"><Loading /></el-icon>
<el-icon v-else><Plus /></el-icon>
<span>上传logo</span>
</div>
</el-upload> </el-upload>
<div class="describe">请压缩图片在2M以内,格式为gif,jpg,png,仅可上传1张</div> <div class="describe">请压缩图片在2M以内,格式为gif、jpg、png,仅可上传1张</div>
<div class="img-list-wrap"> <div class="img-list-wrap">
<div <div
class="img-list" class="img-list"
@@ -45,8 +48,8 @@
<el-select <el-select
v-model="form.goodsManagementCategory" v-model="form.goodsManagementCategory"
multiple multiple
placeholder="请选择" placeholder="请选择店铺经营类目"
style="width: 300px" style="width: 360px"
> >
<el-option <el-option
v-for="item in categoryList" v-for="item in categoryList"
@@ -58,8 +61,13 @@
</el-form-item> </el-form-item>
<el-form-item prop="storeAddressIdPath" label="店铺所在地"> <el-form-item prop="storeAddressIdPath" label="店铺所在地">
<span>{{ form.storeAddressPath || '暂无地址' }}</span> <div class="address-select" @click="openMap">
<el-button type="default" style="margin-left: 10px;" @click="$refs.map.open()">选择</el-button> <el-icon class="addr-icon"><Location /></el-icon>
<span class="addr-text" :class="{ placeholder: !form.storeAddressPath }">
{{ form.storeAddressPath || '请选择店铺所在地' }}
</span>
<span class="addr-action">{{ form.storeAddressPath ? '重新选择' : '选择' }}</span>
</div>
</el-form-item> </el-form-item>
<el-form-item prop="storeAddressDetail" label="店铺详细地址"> <el-form-item prop="storeAddressDetail" label="店铺详细地址">
<el-input <el-input
@@ -80,10 +88,12 @@
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button @click="$emit('change', 1)">返回</el-button> <div class="form-actions">
<el-button type="primary" :loading="loading" @click="next" <el-button size="large" @click="$emit('change', 1)">上一步</el-button>
>提交平台审核</el-button <el-button type="primary" size="large" :loading="loading" @click="next"
> >提交平台审核</el-button
>
</div>
</el-form-item> </el-form-item>
</el-form> </el-form>
<el-dialog title="图片预览" v-model="visible"> <el-dialog title="图片预览" v-model="visible">
@@ -93,9 +103,9 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { onMounted, reactive, ref } from "vue"; import { onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { Delete, View } from '@element-plus/icons-vue'; import { Delete, Loading, Location, Plus, View } from '@element-plus/icons-vue';
import { applyThird } from '@/api/shopentry'; import { applyThird } from '@/api/shopentry';
import { getCategory } from '@/api/goods'; import { getCategory } from '@/api/goods';
@@ -105,6 +115,7 @@ import { buyerUrl } from '@/plugins/request.js';
import multipleMap from "@/components/map/multiple-map"; import multipleMap from "@/components/map/multiple-map";
const THIRD_APPLY_DRAFT_KEY = 'shopEntryThirdDraft';
const emit = defineEmits(['change']); const emit = defineEmits(['change']);
@@ -116,6 +127,7 @@ const props = defineProps({
}); });
const thirdFormRef = ref(null); const thirdFormRef = ref(null);
const map = ref(null);
const loading = ref(false); // 加载状态 const loading = ref(false); // 加载状态
const uploadLoading = ref(false); // 上传加载状态 const uploadLoading = ref(false); // 上传加载状态
@@ -147,6 +159,10 @@ const rules = { // 验证规则
}; };
const categoryList = ref([]); // 分类数据 const categoryList = ref([]); // 分类数据
function openMap() {
map.value?.open();
}
// 下一步 // 下一步
function next () { function next () {
thirdFormRef.value.validate((valid) => { thirdFormRef.value.validate((valid) => {
@@ -158,7 +174,10 @@ function next () {
applyThird(params) applyThird(params)
.then((res) => { .then((res) => {
loading.value = false; loading.value = false;
if (res.success) emit('change', 3); if (res.success) {
sessionStorage.removeItem(THIRD_APPLY_DRAFT_KEY);
emit('change', 3);
}
}) })
.catch(() => { .catch(() => {
loading.value = false; loading.value = false;
@@ -192,14 +211,24 @@ function validateUploadFile (file) {
} }
return true; return true;
} }
// 上传成功回调 // 上传成功回调(el-upload 只认 HTTP 状态码,业务失败 success:false 也会进这里,需要判断)
function handleSuccess (res, file) { function handleSuccess (res, file) {
uploadLoading.value = false; uploadLoading.value = false;
form.value.storeLogo = [res.result]; if (res && res.success && res.result) {
form.value.storeLogo = [res.result];
} else {
Message.error(res && res.message ? res.message : '上传失败,请重试');
}
} }
// 上传失败 // 上传失败(非 2xx:err.message 通常是响应体字符串,尝试解析出服务端提示)
function uploadErr () { function uploadErr (err) {
uploadLoading.value = false; uploadLoading.value = false;
let msg = '上传失败,请重试';
try {
const body = JSON.parse(err && err.message);
if (body && body.message) msg = body.message;
} catch (e) { /* 非 JSON 响应,用默认提示 */ }
Message.error(msg);
} }
// 查看图片 // 查看图片
function handleView (item) { function handleView (item) {
@@ -277,28 +306,117 @@ function initFormFromContent (content) {
if (form.value[key] == null) form.value[key] = ""; if (form.value[key] == null) form.value[key] = "";
}); });
} }
function restoreDraft () {
const draft = sessionStorage.getItem(THIRD_APPLY_DRAFT_KEY);
if (!draft) return;
try {
const parsed = JSON.parse(draft);
form.value = {
...createEmptyForm(),
...parsed,
storeLogo: Array.isArray(parsed.storeLogo) ? parsed.storeLogo : [],
goodsManagementCategory: Array.isArray(parsed.goodsManagementCategory)
? parsed.goodsManagementCategory
: [],
};
} catch (e) {
sessionStorage.removeItem(THIRD_APPLY_DRAFT_KEY);
}
}
function saveDraft () {
sessionStorage.setItem(THIRD_APPLY_DRAFT_KEY, JSON.stringify(form.value));
}
initFormFromContent(props.content); if (Object.keys(props.content).length) {
initFormFromContent(props.content);
} else {
restoreDraft();
}
onMounted(() => { onMounted(() => {
accessToken.accessToken = storage.getItem('accessToken'); accessToken.accessToken = storage.getItem('accessToken');
getCategoryList(); getCategoryList();
}); });
onBeforeUnmount(() => {
saveDraft();
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
h4 { .section-title {
margin-bottom: 10px; display: flex;
padding: 0 10px; align-items: center;
border: 1px solid #ddd; margin: 24px 0 18px;
background-color: #f8f8f8; font-size: 16px;
font-weight: bold; font-weight: 600;
color: #333; color: #303133;
font-size: 14px;
line-height: 40px; &::before {
text-align: left; content: '';
width: 4px;
height: 18px;
border-radius: 2px;
background: $theme_color;
margin-right: 8px;
}
} }
.el-input { .el-input {
width: 300px; width: 360px;
}
.el-textarea {
width: 640px;
max-width: 100%;
}
.address-select {
display: flex;
align-items: center;
width: 360px;
height: 32px;
padding: 0 12px;
border: 1px solid #dcdfe6;
border-radius: 4px;
cursor: pointer;
transition: border-color 0.2s;
background: #fff;
&:hover {
border-color: $theme_color;
}
.addr-icon {
color: #909399;
margin-right: 6px;
flex-shrink: 0;
}
.addr-text {
flex: 1;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.placeholder {
color: #a8abb2;
}
}
.addr-action {
color: $theme_color;
margin-left: 8px;
flex-shrink: 0;
}
}
.form-actions {
margin-top: 8px;
.el-button {
min-width: 120px;
}
.el-button--primary {
min-width: 240px;
}
} }
.img-list-wrap { .img-list-wrap {
display: flex; display: flex;
@@ -316,6 +434,7 @@ h4 {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
border: 1px solid #eee; border: 1px solid #eee;
border-radius: 8px;
box-sizing: border-box; box-sizing: border-box;
background-color: #f5f5f5; background-color: #f5f5f5;
@@ -362,6 +481,32 @@ h4 {
} }
} }
.upload-box {
width: 120px;
height: 120px;
border: 1px dashed #c0c4cc;
border-radius: 8px;
background: #fafafa;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
color: #909399;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
.el-icon {
font-size: 26px;
}
&:hover {
border-color: $theme_color;
color: $theme_color;
}
}
.describe { .describe {
display: block; display: block;
width: 100%; width: 100%;

View File

@@ -115,54 +115,6 @@ const member = [{
// display: true // display: true
// }] // }]
// 批发中心
// const wholesale = [
// {
// icon: '',
// title: '批发中心',
// menus: [
// {
// icon: '',
// title: '我的采购单',
// path: 'MyOrder',
// },
// {
// icon: '',
// title: '采购退货单',
// path: 'MyOrder',
// },
// {
// icon: '',
// title: '我的求购单',
// path: 'MyOrder',
// },
//
// ],
// display: true
// }
// ]
// 店铺
// const shop = [
// {
// icon: '',
// title: '店铺管理',
// menus: [
// {
// icon: '',
// title: '店铺后台',
// path: 'MyOrder',
// },
// {
// icon: '',
// title: '商家等级',
// path: 'MyOrder',
// },
// ],
// display: true
// }
// ]
// wholesale[0], shop[0]
let menuList = [] let menuList = []
menuList.push(order[0], member[0]) menuList.push(order[0], member[0])

View File

@@ -37,6 +37,7 @@ import "element-plus/es/components/radio/style/css";
import "element-plus/es/components/radio-button/style/css"; import "element-plus/es/components/radio-button/style/css";
import "element-plus/es/components/radio-group/style/css"; import "element-plus/es/components/radio-group/style/css";
import "element-plus/es/components/rate/style/css"; import "element-plus/es/components/rate/style/css";
import "element-plus/es/components/result/style/css";
import "element-plus/es/components/select/style/css"; import "element-plus/es/components/select/style/css";
import "element-plus/es/components/skeleton/style/css"; import "element-plus/es/components/skeleton/style/css";
import "element-plus/es/components/step/style/css"; import "element-plus/es/components/step/style/css";
@@ -94,6 +95,7 @@ import {
ElRadioButton, ElRadioButton,
ElRadioGroup, ElRadioGroup,
ElRate, ElRate,
ElResult,
ElSelect, ElSelect,
ElSkeleton, ElSkeleton,
ElStep, ElStep,
@@ -149,6 +151,7 @@ const components = [
ElRadioButton, ElRadioButton,
ElRadioGroup, ElRadioGroup,
ElRate, ElRate,
ElResult,
ElSelect, ElSelect,
ElSkeleton, ElSkeleton,
ElStep, ElStep,

View File

@@ -53,6 +53,7 @@ const MyGiftCards = () => import("@/pages/home/userCenter/MyGiftCards");
const Home = () => import("@/pages/user/Home"); const Home = () => import("@/pages/user/Home");
const UserCenter = () => import("@/pages/home/Main"); const UserCenter = () => import("@/pages/home/Main");
const Merchant = () => import("@/pages/Merchant"); const Merchant = () => import("@/pages/Merchant");
const StoreList = () => import("@/pages/StoreList");
const NotFound = () => import("@/pages/NotFound"); const NotFound = () => import("@/pages/NotFound");
const topic = () => import("@/pages/Topic"); const topic = () => import("@/pages/Topic");
const ShopEntry = () => import("@/pages/shopEntry/ShopEntry"); const ShopEntry = () => import("@/pages/shopEntry/ShopEntry");
@@ -199,6 +200,12 @@ export const router = createRouter({
component: MoneyManagement, component: MoneyManagement,
meta: { title: "我的余额" }, meta: { title: "我的余额" },
}, },
{
path: "PickupCard",
name: "PickupCard",
component: () => import("@/pages/home/userCenter/PickupCard.vue"),
meta: { title: "提货卡兑换" },
},
{ {
path: "MyGiftCards", path: "MyGiftCards",
name: "MyGiftCards", name: "MyGiftCards",
@@ -338,6 +345,12 @@ export const router = createRouter({
component: Merchant, component: Merchant,
meta: { title: "店铺" }, meta: { title: "店铺" },
}, },
{
path: "/storeList",
name: "StoreList",
component: StoreList,
meta: { title: "店铺列表" },
},
{ {
path: "/:pathMatch(.*)*", path: "/:pathMatch(.*)*",
name: "NotFound", name: "NotFound",

View File

@@ -1,21 +1,205 @@
MIT License Apache License, Version 2.0
Copyright (c) 2020 YuanDong Copyright (c) 2020 YuanDong
Permission is hereby granted, free of charge, to any person obtaining a copy Apache License
of this software and associated documentation files (the "Software"), to deal Version 2.0, January 2004
in the Software without restriction, including without limitation the rights http://www.apache.org/licenses/
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 1. Definitions.
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE "License" shall mean the terms and conditions for use, reproduction,
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER and distribution as defined by Sections 1 through 9 of this document.
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE "Licensor" shall mean the copyright owner or entity authorized by
SOFTWARE. the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -16,7 +16,7 @@
"axios": "^1.7.9", "axios": "^1.7.9",
"babel-plugin-prismjs": "^2.0.1", "babel-plugin-prismjs": "^2.0.1",
"core-js": "^3.49.0", "core-js": "^3.49.0",
"element-plus": "^2.14.1", "element-plus": "2.14.1",
"js-audio-recorder": "^1.0.6", "js-audio-recorder": "^1.0.6",
"js-base64": "^2.5.1", "js-base64": "^2.5.1",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",

View File

@@ -34,11 +34,12 @@ function unloadHandler (e) {
onMounted(() => { onMounted(() => {
setRefreshView(refreshView); setRefreshView(refreshView);
window.addEventListener('beforeunload', e => beforeunloadHandler(e)) window.addEventListener('beforeunload', beforeunloadHandler)
window.addEventListener('unload', e => unloadHandler(e)) // unload 事件已被 Chrome 弃用(Permissions-Policy 禁止),改用 pagehide
window.addEventListener('pagehide', unloadHandler)
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('beforeunload', e => beforeunloadHandler(e)) window.removeEventListener('beforeunload', beforeunloadHandler)
window.removeEventListener('unload', e => unloadHandler(e)) window.removeEventListener('pagehide', unloadHandler)
}); });
</script> </script>

View File

@@ -37,7 +37,7 @@ a {
} }
textarea { textarea {
resize: none; resize: none !important;
outline: 0; outline: 0;
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;

View File

@@ -1,5 +1,5 @@
<template> <template>
<el-tabs v-model="activeName" @tab-click="handleClick" type="card" :stretch=true> <el-tabs v-model="activeName" @tab-click="handleClick" :stretch=true>
<el-tab-pane :label="toUser.storeFlag ? '正在咨询' : '他的足迹'" name="history"> <el-tab-pane :label="toUser.storeFlag ? '正在咨询' : '他的足迹'" name="history">
<div> <div>
<GoodsLink :goodsDetail="goodsDetail" v-if="toUser.userId === goodsDetail.storeId" <GoodsLink :goodsDetail="goodsDetail" v-if="toUser.userId === goodsDetail.storeId"
@@ -306,4 +306,20 @@ function formatDateToString(date) {
:deep(.el-tabs__header) { :deep(.el-tabs__header) {
margin-bottom: 0; margin-bottom: 0;
} }
/* 高度链:tabs 撑满面板,pane 高度传递给内部滚动容器 */
:deep(.el-tabs) {
height: 100%;
display: flex;
flex-direction: column;
}
:deep(.el-tabs__content) {
flex: 1;
min-height: 0;
}
:deep(.el-tab-pane) {
height: 100%;
}
</style> </style>

View File

@@ -43,7 +43,7 @@
</aside> </aside>
<aside class="avatar-column"> <aside class="avatar-column">
<div class="avatar-box" v-if="item.float == 'right'"> <div class="avatar-box" v-if="item.float == 'right'">
<face :text="face" v-if="face"></face> <face :text="userFace" v-if="userFace"></face>
<face-null :text="name" v-else></face-null> <face-null :text="name" v-else></face-null>
</div> </div>
<div class="avatar-box" v-if="item.float == 'left'"> <div class="avatar-box" v-if="item.float == 'left'">
@@ -53,14 +53,10 @@
</aside> </aside>
<main class="main-column"> <main class="main-column">
<div class="talk-content"> <div class="talk-content">
<span class="nickname">
{{ item.float == "right" ? name : toUser.name }} |
{{ unixToDate(item.createTime, "MM月dd日 hh:mm") }}
</span>
<div class="flex flex-a-c"> <div class="flex flex-a-c">
<legacy-el-icon name="el-icon-refresh-left" class="again main-color" @click="againSendMessage(item)" v-if="item.webSocketStatus" /> <legacy-el-icon name="el-icon-refresh-left" class="again main-color" @click="againSendMessage(item)" v-if="item.webSocketStatus" />
<!-- 文本消息 --> <!-- 文本消息 -->
<div v-if="item.messageType == 'MESSAGE'" style="background-color: #d0e9ff;color: black;" <div v-if="item.messageType == 'MESSAGE'"
class="text-message" :class="{ class="text-message" :class="{
left: item.float == 'left', left: item.float == 'left',
right: item.float == 'right', right: item.float == 'right',
@@ -79,7 +75,7 @@
}"> }">
<div class="base" @click="linkToGoods(item.text.goodsId, item.text.id)"> <div class="base" @click="linkToGoods(item.text.goodsId, item.text.id)">
<div> <div>
<img :src="item.text.thumbnail" class="image" /> <img :src="item.text.thumbnail" :onerror="defaultAvatar" class="image" />
</div> </div>
<div> <div>
<div class="goods_name"> <div class="goods_name">
@@ -106,7 +102,7 @@
<div class="goods-shared-box"> <div class="goods-shared-box">
<div class="goods-item" v-for="(order,orderIndex) in item.text.orderItems" :key="orderIndex"> <div class="goods-item" v-for="(order,orderIndex) in item.text.orderItems" :key="orderIndex">
<img :src="order.image" style="height: 100px;width: 100px;" /> <img :src="order.image" :onerror="defaultAvatar" style="height: 100px;width: 100px;" />
<div> <div>
<span class="orderGoodsName">{{ order.name }}</span> <span class="orderGoodsName">{{ order.name }}</span>
<div class="goods-item-price"> <div class="goods-item-price">
@@ -212,6 +208,7 @@ import SocketInstance from "@/im-server/socket-instance";
import { SvgMentionDown } from "@/core/icons"; import { SvgMentionDown } from "@/core/icons";
import { formatTime, parseTime, copyTextToClipboard } from "@/utils/functions"; import { formatTime, parseTime, copyTextToClipboard } from "@/utils/functions";
import { useUserStore } from "@/stores/user"; import { useUserStore } from "@/stores/user";
import { useAppStore } from "@/stores/app";
import { useTalkStore } from "@/stores/talk"; import { useTalkStore } from "@/stores/talk";
import { useNotifyStore } from "@/stores/notify"; import { useNotifyStore } from "@/stores/notify";
import { useSettingsStore } from "@/stores/settings"; import { useSettingsStore } from "@/stores/settings";
@@ -259,6 +256,7 @@ const emit = defineEmits(["close-talk"]);
const { proxy } = getCurrentInstance(); const { proxy } = getCurrentInstance();
const userStore = useUserStore(); const userStore = useUserStore();
const appStore = useAppStore();
const talkStore = useTalkStore(); const talkStore = useTalkStore();
const notifyStore = useNotifyStore(); const notifyStore = useNotifyStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
@@ -306,13 +304,16 @@ const panelHeader = ref(null);
const talkItems = computed(() => talkStore.talkItems); const talkItems = computed(() => talkStore.talkItems);
const name = computed(() => userStore.name); const name = computed(() => userStore.name);
const face = computed(() => userStore.face); // 不能命名为 face:会遮蔽 <face> 组件解析,头像 URL 会被当作标签名渲染
const userFace = computed(() => userStore.face);
const unreadMessage = computed(() => talkStore.unreadMessage); const unreadMessage = computed(() => talkStore.unreadMessage);
const inputEvent = computed(() => notifyStore.inputEvent); const inputEvent = computed(() => notifyStore.inputEvent);
const id = computed(() => userStore.id); const id = computed(() => userStore.id);
const records = computed(() => dialogueStore.records); const records = computed(() => dialogueStore.records);
const index_name = computed(() => dialogueStore.index_name); const index_name = computed(() => dialogueStore.index_name);
const toUser = computed(() => userStore.toUser); const toUser = computed(() => userStore.toUser);
// 商品/订单图片加载失败时的默认图(onerror 属性值)
const defaultAvatar = computed(() => appStore.defaultAvatar);
const sendTime = formatTime; const sendTime = formatTime;
@@ -1147,7 +1148,7 @@ function talkPanelScrollBottom() {
flex-shrink: 0; flex-shrink: 0;
order: 1; order: 1;
user-select: none; user-select: none;
padding-top: 25px; padding-top: 8px;
i { i {
color: #ccc; color: #ccc;
@@ -1168,7 +1169,7 @@ function talkPanelScrollBottom() {
align-items: center; align-items: center;
order: 2; order: 2;
user-select: none; user-select: none;
padding-top: 22px; padding-top: 5px;
flex-direction: column; flex-direction: column;
} }
@@ -1206,13 +1207,6 @@ function talkPanelScrollBottom() {
align-items: flex-start; align-items: flex-start;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
.nickname {
font-size: 12px;
color: #a7a0a0;
margin: -4px 0 4px -8px;
transform: scale(0.9);
}
} }
&:hover { &:hover {
@@ -1250,8 +1244,8 @@ function talkPanelScrollBottom() {
} }
} }
@bg-left-color: #f5f5f5; @bg-left-color: #f2f2f2;
@bg-right-color: #ffffff; @bg-right-color: #409eff;
.text-message { .text-message {
position: relative; position: relative;

View File

@@ -1,14 +1,14 @@
<template> <template>
<div class="box" :style="{height:toUser.storeFlag ? '858px' : '815px'}" @scroll="scrollBottom"> <div class="box" @scroll="scrollBottom">
<div class="tab"> <div class="tab">
<el-tabs v-model="activeName" @tab-click="handleClick" :stretch=true> <el-tabs v-model="activeName" @tab-click="handleClick" type="card" :stretch=true>
<el-tab-pane label="最近浏览" name="goods"> <el-tab-pane label="最近浏览" name="goods">
<dl v-if='list.length' class='base-list'> <dl v-if='list.length' class='base-list'>
<dd :key="index" @click="linkToGoods(item.goodsId, item.id)" v-for="(item,index) in list"> <dd :key="index" @click="linkToGoods(item.goodsId, item.id)" v-for="(item,index) in list">
<div class="base"> <div class="base">
<div> <div>
<img style="width: 60px; height: 60px;margin-left:20px;box-sizing: border-box;" :src="item.thumbnail" <img style="width: 60px; height: 60px;margin-left:20px;box-sizing: border-box;" :src="item.thumbnail"
class="image" /> :onerror="defaultAvatar" class="image" />
</div> </div>
<div class="recent_views"> <div class="recent_views">
<a class="goods_name">{{ item.goodsName }}</a> <a class="goods_name">{{ item.goodsName }}</a>
@@ -35,24 +35,8 @@
<dl class='base-order-list' v-if='orderList.length'> <dl class='base-order-list' v-if='orderList.length'>
<dd v-for="(item, index) in orderList" :key="index"> <dd v-for="(item, index) in orderList" :key="index">
<div class="order-list"> <div class="order-list">
<div class="order-top order-padding"> <div class="order-head">
<span class="order-sn" @click="linkToOrders(item.sn)">订单号:{{ item.sn }}</span> <span class="order-sn" :title="item.sn" @click="linkToOrders(item.sn)">订单号:{{ item.sn }}</span>
</div>
<div class="order-section order-padding">
<div class="order-items" v-for="(order,orderIndex) in item.orderItems" :key="orderIndex">
<img :src="order.image" alt="">
<span class="order-goods-name" @click="linkToOrders(item.sn)"> {{ order.name }}</span>
<span class="price">{{ $filters.unitPrice(order.goodsPrice, "¥") }}</span>
</div>
<!-- <img :src="item.groupImages" alt=""> -->
<div class="order-btn ">
<el-button class="store-button" v-if="item.btnHide == 1 && toUser.storeFlag"
size="small" @click="submitSendOrderMessage(item, index)" plain>发送</el-button>
</div>
</div>
<div class="order-footer order-padding">
<span></span>
<el-tag size="small" :type="col[item.orderStatus]">{{ <el-tag size="small" :type="col[item.orderStatus]">{{
item.orderStatus == 'STAY_PICKED_UP' ? '待自提' item.orderStatus == 'STAY_PICKED_UP' ? '待自提'
: item.orderStatus == 'CANCELLED' ? '已取消' : item.orderStatus == 'UNPAID' ? '未付款' : item.orderStatus : item.orderStatus == 'CANCELLED' ? '已取消' : item.orderStatus == 'UNPAID' ? '未付款' : item.orderStatus
@@ -62,7 +46,16 @@
'' ''
}}</el-tag> }}</el-tag>
</div> </div>
<div class="order-items" v-for="(order,orderIndex) in item.orderItems" :key="orderIndex">
<img :src="order.image" :onerror="defaultAvatar" alt="">
<div class="order-item-info">
<span class="order-goods-name" :title="order.name" @click="linkToOrders(item.sn)">{{ order.name }}</span>
<span class="price">{{ $filters.unitPrice(order.goodsPrice, "¥") }}</span>
</div>
</div>
<div class="order-foot" v-if="item.btnHide == 1 && toUser.storeFlag">
<el-button class="store-button" size="small" @click="submitSendOrderMessage(item, index)" plain>发送</el-button>
</div>
</div> </div>
</dd> </dd>
@@ -80,9 +73,11 @@
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { useTalkStore } from "@/stores/talk"; import { useTalkStore } from "@/stores/talk";
import { useUserStore } from "@/stores/user"; import { useUserStore } from "@/stores/user";
import { useAppStore } from "@/stores/app";
const talkStore = useTalkStore(); const talkStore = useTalkStore();
const userStore = useUserStore(); const userStore = useUserStore();
const appStore = useAppStore();
const emit = defineEmits(["loadMore", "sendMessage"]); const emit = defineEmits(["loadMore", "sendMessage"]);
@@ -118,6 +113,8 @@ const col = ref({
const talkItems = computed(() => talkStore.talkItems); const talkItems = computed(() => talkStore.talkItems);
const id = computed(() => userStore.id); const id = computed(() => userStore.id);
const toUser = computed(() => userStore.toUser); const toUser = computed(() => userStore.toUser);
// 图片加载失败时的默认图(onerror 属性值)
const defaultAvatar = computed(() => appStore.defaultAvatar);
//跳转订单列表 //跳转订单列表
@@ -204,7 +201,9 @@ onMounted(() => {
} }
.box { .box {
max-width: 362px; width: 100%;
height: 100%;
box-sizing: border-box;
.top { .top {
text-align: center; text-align: center;
@@ -237,57 +236,51 @@ onMounted(() => {
} }
.order-list { .order-list {
padding: 9px; border: 1px solid #eee;
transition: 0.35s; border-radius: 6px;
cursor: pointer; padding: 10px;
margin-bottom: 10px; margin-bottom: 10px;
background: #fff; background: #fff;
color: #5a606b; transition: border-color .2s;
box-sizing: border-box;
&:hover {
border-color: #b3d3f9;
}
} }
.order-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding-bottom: 8px;
margin-bottom: 8px;
border-bottom: 1px solid #f5f5f5;
}
.order-sn {
flex: 1;
min-width: 0;
font-size: 12px;
color: #666;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
color: #409eff;
}
}
.box::-webkit-scrollbar { .box::-webkit-scrollbar {
width: 0px !important; width: 0px !important;
} }
.order-section {
margin: 10px 0;
font-size: 12px;
img {
width: 60px;
height: 60px;
}
}
.order-footer { .order-foot {
font-size: 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.order-goods-name {
width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
position: absolute;
margin-left: 10px;
font-weight: 500;
}
.orderSn {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap
}
.order-btn {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
position: relative; margin-top: 8px;
bottom: 55px;
} }
.goods_name { .goods_name {
@@ -300,7 +293,7 @@ onMounted(() => {
:deep(.el-tabs__header ) { :deep(.el-tabs__header ) {
position: absolute; position: absolute;
width: 362px; width: 100%;
height: 50px; height: 50px;
left: 0; left: 0;
z-index: 2; z-index: 2;
@@ -329,10 +322,6 @@ onMounted(() => {
// width: 350px; // width: 350px;
} }
.order-padding {
padding: 0 10px;
}
.base { .base {
padding: 8px; padding: 8px;
cursor: pointer; cursor: pointer;
@@ -354,7 +343,7 @@ onMounted(() => {
} }
} }
.base:hover,.order-list:hover{ .base:hover{
background: #ededed; background: #ededed;
padding-left: 14px; padding-left: 14px;
>.review{ >.review{
@@ -371,9 +360,6 @@ onMounted(() => {
.separate { .separate {
margin-top: 8px; margin-top: 8px;
} }
.order-sn{
font-size: 12px;
}
// .el-tabs--card { // .el-tabs--card {
// height: calc(100vh - 110px); // height: calc(100vh - 110px);
// overflow-y: auto; // overflow-y: auto;
@@ -383,17 +369,54 @@ onMounted(() => {
// overflow-y: auto; // overflow-y: auto;
// } // }
.base-list{ .base-list{
padding-bottom: 120px; padding: 10px 10px 12px;
} }
.base-order-list{ .base-order-list{
padding-bottom: 100px; padding: 10px 10px 12px;
}
.price{
color: red;
margin-left: 10px;
margin-bottom: 10px;
} }
.order-items{ .order-items{
margin-bottom: 10px; display: flex;
align-items: center;
margin-bottom: 8px;
&:last-of-type {
margin-bottom: 0;
}
img {
width: 48px;
height: 48px;
flex-shrink: 0;
border-radius: 4px;
object-fit: cover;
background: #fafafa;
}
.order-item-info {
flex: 1;
min-width: 0;
margin-left: 10px;
display: flex;
flex-direction: column;
}
.order-goods-name {
font-size: 12px;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
&:hover {
color: #409eff;
}
}
.price {
margin-top: 4px;
font-size: 13px;
color: #f23030;
}
} }
</style> </style>

View File

@@ -3,7 +3,7 @@
当前浏览 当前浏览
<div class="base"> <div class="base">
<div> <div>
<img :src="goodsDetail.thumbnail" class="image" /> <img :src="goodsDetail.thumbnail" :onerror="defaultAvatar" class="image" />
</div> </div>
<div style="margin-left: 13px"> <div style="margin-left: 13px">
<a @click="linkToGoods(goodsDetail.goodsId, goodsDetail.id)"> {{ goodsDetail.goodsName }} </a> <a @click="linkToGoods(goodsDetail.goodsId, goodsDetail.id)"> {{ goodsDetail.goodsName }} </a>
@@ -23,10 +23,12 @@
import { computed, onMounted, nextTick, ref } from "vue"; import { computed, onMounted, nextTick, ref } from "vue";
import SocketInstance from "@/im-server/socket-instance"; import SocketInstance from "@/im-server/socket-instance";
import { useUserStore } from "@/stores/user"; import { useUserStore } from "@/stores/user";
import { useAppStore } from "@/stores/app";
import { useDialogueStore } from "@/stores/dialogue"; import { useDialogueStore } from "@/stores/dialogue";
import { useTalkStore } from "@/stores/talk"; import { useTalkStore } from "@/stores/talk";
const userStore = useUserStore(); const userStore = useUserStore();
const appStore = useAppStore();
const dialogueStore = useDialogueStore(); const dialogueStore = useDialogueStore();
const talkStore = useTalkStore(); const talkStore = useTalkStore();
@@ -44,6 +46,8 @@ const talkItems = computed(() => talkStore.talkItems);
const id = computed(() => userStore.id); const id = computed(() => userStore.id);
const index_name = computed(() => dialogueStore.index_name); const index_name = computed(() => dialogueStore.index_name);
const toUser = computed(() => userStore.toUser); const toUser = computed(() => userStore.toUser);
// 图片加载失败时的默认图(onerror 属性值)
const defaultAvatar = computed(() => appStore.defaultAvatar);
onMounted(() => { onMounted(() => {
console.log(props.goodsDetail) console.log(props.goodsDetail)

View File

@@ -1,100 +1,157 @@
<template> <template>
<div style="width: 362px;"> <div class="store-card">
<div class="base"> <div class="base">
<div> <img :src="storeInfo.storeLogo" :onerror="onError" class="image" alt="" />
<img :src="storeInfo.storeLogo" class="image" />
</div>
<div class="store-detail"> <div class="store-detail">
<div> <div class="store-name">
<div class="div-zoom"> <span class="name-text" :title="storeInfo.storeName">{{ storeInfo.storeName }}</span>
<span class="title_left_name"> 店铺名称:</span> <span class="title_right_name">{{ storeInfo.storeName }}</span> <el-tag type="danger" v-if="storeInfo.selfOperated" size="small">自营</el-tag>
<el-tag style="margin-left: 10px;" type="danger" v-if="storeInfo.selfOperated" size="small">自营</el-tag>
</div> </div>
<div> <div class="store-actions">
<span class="title_left_name"> 联系方式:</span> <span class="title_right_name">{{ storeInfo.memberName }}</span> <el-button @click="linkToStore(storeInfo.id)" size="small" plain>进入店铺</el-button>
</div>
</div>
<div>
<el-button class="store-button" @click="linkToStore(storeInfo.id)" size="small"
plain>进入店铺</el-button>
</div> </div>
</div> </div>
</div> </div>
<div class="info-list">
<div class="info-row" v-for="row in infoRows" :key="row.label">
<span class="label">{{ row.label }}</span>
<span class="value" :title="row.value">{{ row.value || '—' }}</span>
</div>
</div>
<div class="wrapper"> <div class="wrapper">
<div class="separate">店铺评分: <el-rate v-model="storeInfo.serviceScore" disabled show-score text-color="#ff9900" <div class="rate-row" v-for="row in rates" :key="row.label">
score-template="{value}"> <span class="rate-label">{{ row.label }}</span>
</el-rate></div> <el-rate :model-value="row.score" disabled show-score text-color="#ff9900" score-template="{value}" />
<div class="separate">服务评分: <el-rate v-model="storeInfo.descriptionScore" disabled show-score text-color="#ff9900" </div>
score-template="{value}">
</el-rate></div>
<div class="separate">物流评分: <el-rate v-model="storeInfo.deliveryScore" disabled show-score text-color="#ff9900"
score-template="{value}">
</el-rate></div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref } from "vue"; import { computed } from "vue";
import { defaultAvatar } from "@/assets/images";
defineProps({ const props = defineProps({
storeInfo: { storeInfo: {
type: Object, type: Object,
default: null, default: () => ({}),
}, },
}); });
const value = ref(null); const onError = "this.src='" + defaultAvatar + "'";
const colors = ref(['#99A9BF', '#F7BA2A', '#FF9900']);
const infoRows = computed(() => [
{ label: "店铺地址", value: props.storeInfo.storeAddressPath },
{ label: "详细地址", value: props.storeInfo.storeAddressDetail },
{ label: "店铺简介", value: props.storeInfo.storeDesc },
]);
// 与买家端字段口径一致:店铺评分=描述相符 descriptionScore,服务评分=serviceScore
const rates = computed(() => [
{ label: "店铺评分", score: Number(props.storeInfo.descriptionScore) || 0 },
{ label: "服务评分", score: Number(props.storeInfo.serviceScore) || 0 },
{ label: "物流评分", score: Number(props.storeInfo.deliveryScore) || 0 },
]);
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
.wrapper{ .store-card {
width: 100%;
box-sizing: border-box;
}
.base {
padding: 16px; padding: 16px;
border-top: 1px solid #f5eeee; display: flex;
}
.title_left_name { .image {
font-size: 12px; height: 72px;
color: #999; width: 72px;
flex-shrink: 0;
border-radius: 6px;
object-fit: cover;
background: #fafafa;
}
} }
.title_right_name { .store-detail {
font-size: 12px; flex: 1;
color: #343036; min-width: 0;
}
.store-button {
background-color: white;
}
.store-detail{
margin-left: 13px; margin-left: 13px;
display:flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
} }
.base {
padding: 16px;
margin-top: 5px;
.store-name {
display: flex; display: flex;
.image { align-items: center;
height: 100px; gap: 8px;
width: 100px .name-text {
font-size: 14px;
font-weight: 600;
color: #343036;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.store-actions {
display: flex;
}
.info-list {
padding: 0 16px 12px;
}
.info-row {
display: flex;
align-items: flex-start;
margin-top: 8px;
.label {
width: 60px;
flex-shrink: 0;
font-size: 12px;
color: #999;
line-height: 20px;
} }
.value {
flex: 1;
min-width: 0;
font-size: 12px;
color: #343036;
line-height: 20px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
} }
.separate { .wrapper {
margin-top: 8px; padding: 12px 16px 16px;
// display: inline; border-top: 1px solid #f5f5f5;
display: flex; }
font-size: 12px;
align-items: center;
color: #a1a6af; .rate-row {
display: flex;
align-items: center;
margin-top: 8px;
&:first-child {
margin-top: 0;
}
.rate-label {
width: 60px;
flex-shrink: 0;
font-size: 12px;
color: #a1a6af;
}
} }
</style> </style>

View File

@@ -1,16 +1,32 @@
<template> <template>
<img :src="'https://avatars.dicebear.com/api/initials/'+text+'.svg?fontSize=38'" alt=""/> <i class="face-initial" :style="{ backgroundColor: color }">{{ initial }}</i>
</template> </template>
<script setup> <script setup>
defineProps({ import { computed } from "vue";
text:{ import { avatarColor } from "@/utils/functions";
type:null,
default:'' const props = defineProps({
} text: {
}) type: null,
default: ''
}
});
// 无头像时用「底色 + 首字」本地生成,替代原来的 dicebear 外链
const initial = computed(() => String(props.text || "").slice(0, 1) || "?");
const color = computed(() => avatarColor(String(props.text || "")));
</script> </script>
<style lang="scss" scoped> <style lang="less" scoped>
.face-initial {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #fff;
font-style: normal;
user-select: none;
}
</style> </style>

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