34 Commits

Author SHA1 Message Date
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
390 changed files with 83015 additions and 21589 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. (北京宏业汇成科技有限公司)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
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:
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
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
SOFTWARE.
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
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>
<a href="https://github.com/lilishop/lilishop"><img src="https://img.shields.io/github/stars/lilishop/lilishop.svg?style=social&logo=github" alt="GitHub Stars" /></a>
<a href="https://gitee.com/beijing_hongye_huicheng/lilishop"><img src="https://gitee.com/beijing_hongye_huicheng/lilishop/badge/star.svg?theme=dark" alt="Gitee Stars" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" /></a>
<a href="./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://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>
@@ -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.
### ✨ 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.
- 🧩 **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
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>
<a href="https://github.com/lilishop/lilishop"><img src="https://img.shields.io/github/stars/lilishop/lilishop.svg?style=social&logo=github" alt="GitHub Stars" /></a>
<a href="https://gitee.com/beijing_hongye_huicheng/lilishop"><img src="https://gitee.com/beijing_hongye_huicheng/lilishop/badge/star.svg?theme=dark" alt="Gitee Stars" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" /></a>
<a href="./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://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>
@@ -28,7 +28,7 @@
**LILISHOP** 是基于 Spring Boot / Vue 3 / uni-app 开发的开源商城系统,支持 B2B2C 多商户商城、微信小程序商城、H5 商城、APP 商城、直播电商、分销返佣、秒杀活动。
### ✨ 为什么选择 Lilishop
- 🟢 **真 MIT 协议**:支持免费商用与私有化闭源二开,无版权后顾之忧。
- 🟢 **真 Apache-2.0 协议**:支持免费商用与私有化闭源二开,无版权后顾之忧。
- 🚀 **Vue 3 全新栈**:全面基于 `Vue 3.5 + Pinia 4 + Vue Router 4 + Element Plus` 构建,告别老旧 Vue 2 架构。
- 🧩 **四端合一生产级闭环**:完整覆盖 **PC 买家端 (`buyer`)**、**商家端 (`seller`)**、**平台运营管理端 (`manager`)** 与 **在线客服端 (`im`)**,支持拖拽式可视化楼层装修与多维数据统计报表。
@@ -146,7 +146,7 @@ lilishop-ui
## 6. 开源与授权
本项目遵循 [MIT](./LICENSE) 开源协议,版权归 **北京宏业汇成科技有限公司** 所有。允许个人及企业免费用于学习、研究、修改和商业用途(需保留原始版权和许可声明)。
本项目遵循 [Apache-2.0](./LICENSE) 开源协议,版权归 **北京宏业汇成科技有限公司** 所有。允许个人及企业免费用于学习、研究、修改和商业用途(需保留原始版权和许可声明)。
---
@@ -162,6 +162,8 @@ lilishop-ui
## 8. 升级计划
> 更新日期:2026-11-03,依据各端近期提交及当前代码实现同步。
### ✅ 已完成特性
| 功能模块 | 功能模块 |
| :--- | :--- |
@@ -170,12 +172,11 @@ lilishop-ui
| ✅ 置顶评论 | ✅ 客户等级体系 |
| ✅ 购物卡 | ✅ 腾讯云-云直播 |
| ✅ 卡密商品 | ✅ 二级分销 |
| ✅ 定时任务管理 | ✅ 预约服务商品 |
| ✅ 主题色管理 | ✅ 商家发货改造(整单/拆单/无需配送) |
| ✅ 多维统计(营业/商品/会员/积分/储值/营销/分销) | ✅ 经营报表(店铺业绩/商品同比环比/订单明细/销售汇总) |
| ✅ 定时任务管理(数据库配置驱动) | ✅ 预约服务商品 |
| ✅ 主题色管理 | |
### 🔜 近期规划中
- 🔜 店铺结算汇总 & 结算台账
- 🔜 支付方式汇总 & 钱包流水 & 财务汇总
- 🔜 微信小店对接
- 🔜 抖店对接
- 🔜 微信小程序原生重构与体验对齐(持续完善;已接入预约交易、分类多模板与首页楼层装修)
- 🔜 OpenAPI 开放平台与开发者中心

40
buyer/pnpm-lock.yaml generated
View File

@@ -36,6 +36,9 @@ importers:
nprogress:
specifier: ^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:
specifier: ^1.8.0
version: 1.15.0
@@ -60,9 +63,6 @@ importers:
vue-router:
specifier: ^4.6.4
version: 4.6.4(vue@3.5.43)
vuex:
specifier: ^4.1.0
version: 4.1.0(vue@3.5.43)
devDependencies:
'@vue/cli-plugin-babel':
specifier: ^5.0.9
@@ -91,6 +91,9 @@ importers:
less-loader:
specifier: ^11.1.4
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:
specifier: ^5.95.0
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:
resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==}
nostics@1.3.0:
resolution: {integrity: sha512-wzRsrJwNlFbqIRQt8aCeL2WE8USip/uuvGSOZV1D02CxX8ABTMbw8IywKjH7yWHETMXbWJI4OBpihg9rmz2Vyg==}
npm-run-path@2.0.2:
resolution: {integrity: sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==}
engines: {node: '>=4'}
@@ -3135,6 +3141,16 @@ packages:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
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:
resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==}
engines: {node: '>=8'}
@@ -4006,11 +4022,6 @@ packages:
typescript:
optional: true
vuex@4.1.0:
resolution: {integrity: sha512-hmV6UerDrPcgbSy9ORAtNXDr9M4wlNP4pEFKye4ujJF8oqgFFuxDCdOLS3eNoRTtq5O3hoBDh9Doj1bQMYHRbQ==}
peerDependencies:
vue: ^3.2.0
watchpack@2.5.2:
resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==}
engines: {node: '>=10.13.0'}
@@ -7374,6 +7385,8 @@ snapshots:
normalize-wheel-es@1.2.0: {}
nostics@1.3.0: {}
npm-run-path@2.0.2:
dependencies:
path-key: 2.0.1
@@ -7533,6 +7546,12 @@ snapshots:
picomatch@4.0.7:
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:
dependencies:
find-up: 4.1.0
@@ -8449,11 +8468,6 @@ snapshots:
'@vue/server-renderer': 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:
dependencies:
graceful-fs: 4.2.11

View File

@@ -95,21 +95,25 @@ export function cancelStoreCollect (type, id) {
});
}
// 查看是否收藏商品
// 查看是否收藏商品(后台查询,token 失效时静默失败,不弹全局登录框)
export function isCollection (type, goodsId) {
return request({
url: `/buyer/member/collection/isCollection/${type}/${goodsId}`,
method: Method.GET,
needToken: true
needToken: true,
skipAuthHandler: true,
skipMessage: true
});
}
// 查看是否收藏店铺
// 查看是否收藏店铺(后台查询,token 失效时静默失败,不弹全局登录框)
export function isStoreCollection (type, goodsId) {
return request({
url: `/buyer/member/storeCollection/isCollection/${type}/${goodsId}`,
method: Method.GET,
needToken: true
needToken: true,
skipAuthHandler: true,
skipMessage: true
});
}
@@ -133,22 +137,26 @@ export function storeCollectList (params) {
});
}
// 单个商品评价
// 单个商品评价(需登录;token 失效时静默失败,不弹全局登录框)
export function goodsComment (params) {
return request({
url: `/buyer/member/evaluation/${params.goodsId}/goodsEvaluation`,
method: Method.GET,
needToken: false,
needToken: true,
skipAuthHandler: true,
skipMessage: true,
params
});
}
// 商品各评价类别数量
// 商品各评价类别数量(需登录;token 失效时静默失败,不弹全局登录框)
export function goodsCommentNum (goodsId) {
return request({
url: `/buyer/member/evaluation/${goodsId}/evaluationNumber`,
method: Method.GET,
needToken: false
needToken: true,
skipAuthHandler: true,
skipMessage: true
});
}

View File

@@ -6,7 +6,8 @@ import request, {
export function shopList (params) {
return request({
url: '/buyer/store/store',
needToken: true,
needToken: false,
optionalToken: true,
method: Method.GET,
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) {
return request({
url: `/buyer/store/store/get/detail/${id}`,

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,27 +3,44 @@
<div class="item-detail-show">
<!-- 详情左侧展示数据、图片,收藏、举报 -->
<div class="item-detail-left">
<!-- 大图、放大镜 -->
<!-- <div id="dplayer"></div> -->
<div class="item-detail-big-img" v-if="imgList[imgIndex]">
<!-- 大图、放大镜(视频商品首项为视频) -->
<div class="item-detail-big-img" v-if="mediaList[mediaIndex]">
<el-image
v-show="!isCurrentVideo"
:src="currentImg"
fit="cover"
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 id="dplayer"></div> -->
<div class="item-detail-img-row">
<div
class="item-detail-img-small"
:class="{ 'is-active': imgIndex === index }"
@mouseover="imgIndex = index"
v-for="(item, index) in imgList"
:class="{ 'is-active': mediaIndex === index }"
@mouseover="switchMedia(index)"
v-for="(item, index) in mediaList"
: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>
@@ -70,35 +87,7 @@
</div>
<!-- 商品原价 -->
<div class="item-price-row" v-else>
<!-- 批发价格 -->
<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>
<span class="item-price">{{ $filters.unitPrice(skuDetail.price, "¥") }}</span>
</div>
<!-- 优惠券展示 -->
<div class="item-price-coupon-row" v-if="promotionMap['COUPON'].length">
@@ -303,23 +292,17 @@
show-icon
: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>
<el-dialog title="浏览视频" v-model="showGoodsVideo">
<div id="dplayer"></div>
</el-dialog>
</div>
</div>
</template>
<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 { Message } from "@/utils/message";
import { StarFilled } from '@element-plus/icons-vue';
@@ -362,13 +345,11 @@ const emit = defineEmits(["handleClickSku"]);
const route = useRoute();
const router = useRouter();
const showGoodsVideo = ref(false);
const goodsVideo = ref("");
const wholesaleList = ref([]);
const count = ref(1); // 商品数量
const imgIndex = ref(0); // 展示图片下标
const mediaIndex = ref(0); // 展示媒体下标(相册图片/视频)
const currentSelceted = ref([]); // 当前商品sku
const imgList = ref([]); // 商品图片列表
const mediaList = ref([]); // 媒体列表:视频项在前,其后为图片字符串
let dp = null; // DPlayer 实例,有视频时进页即创建(静音自动播放)
const skuDetail = ref({
specList: [],
}); // sku详情
@@ -384,26 +365,25 @@ const skuList = ref([]); // formatSku 中赋值(原 data 未声明)
// startCheckout 通过字符串 key 操作对应 loading
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 item = imgList.value[imgIndex.value];
return item ? item.url || item : "";
const item = currentMedia.value;
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 起支持促销) */
const isECouponGoods = computed(() => {
@@ -436,7 +416,6 @@ watch(
() => props.detail,
(val) => {
skuDetail.value = val.data;
wholesaleList.value = val.wholesaleList;
syncCountWithQuantity();
promotion();
swiperGoodsImg();
@@ -444,23 +423,46 @@ watch(
{ deep: true, immediate: true }
);
watch(showGoodsVideo, (val) => {
if (val) {
initVideo();
}
});
// 天猫/淘宝风格:进页即在相册首位静音循环播放,用户可通过控制条开声音
function initVideoPlayer() {
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() {
if (!goodsVideo.value) {
setTimeout(() => {
goodsVideo.value = new DPlayer({
container: document.getElementById('dplayer'),
video: {
url: skuDetail.value.goodsVideo,
},
});
}, 100);
// 悬停切换相册项:离开视频时暂停,回到视频时续播
function switchMedia(index) {
if (index === mediaIndex.value) return;
const leavingVideo = isCurrentVideo.value;
mediaIndex.value = index;
if (leavingVideo && dp) {
dp.pause();
} else if (isCurrentVideo.value && dp) {
dp.play();
}
}
// 缩略图:视频用封面,图片兼容字符串与 { url } 两种形态
function mediaThumb(item) {
return item.type === "video" ? item.poster : item.url || item;
}
function destroyVideo() {
if (dp) {
dp.destroy();
dp = null;
}
}
@@ -472,10 +474,6 @@ function syncCountWithQuantity() {
count.value = 1;
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;
if (count.value > max) {
count.value = max;
@@ -484,14 +482,7 @@ function syncCountWithQuantity() {
}
}
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 changeCount(val) {}
function select(index, value) {
// 选择规格
@@ -702,18 +693,29 @@ function promotion() {
}
}
// 相册里会混进规格 JSON,只留图片地址;没有相册时用主图
// 相册里会混进规格 JSON,只留图片地址;没有相册时用主图。有视频时视频排在相册第一项
function swiperGoodsImg() {
destroyVideo();
mediaIndex.value = 0;
const source = skuDetail.value.goodsGalleryList || [];
const gallery = Array.from(source).filter((item) => {
if (typeof item !== "string") return false;
return item.indexOf('"url":') === -1 && item.indexOf('"status":') === -1;
});
if (gallery.length) {
imgList.value = gallery;
return;
const images = gallery.length
? gallery
: 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(() => {
@@ -723,6 +725,8 @@ onMounted(() => {
if (res.success && res.result) {
isCollected.value = true;
}
}).catch(() => {
// 收藏状态为可选数据,失败时静默
});
}
@@ -730,6 +734,10 @@ onMounted(() => {
// promotion();
document.title = skuDetail.value.goodsName;
});
onBeforeUnmount(() => {
destroyVideo();
});
</script>
<style scoped lang="scss">
@@ -793,10 +801,58 @@ onMounted(() => {
object-fit: cover;
}
}
#dplayer{
width: 100%;
height: 100%;
.goods-video-wrap {
position: relative;
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 {
margin-top: 0;
@@ -806,6 +862,7 @@ onMounted(() => {
}
.item-detail-img-small {
position: relative;
width: 86px;
height: 86px;
border: 2px solid transparent;
@@ -1236,8 +1293,5 @@ onMounted(() => {
font-size: 15px;
line-height: 22px;
}
.view-video{
cursor: pointer;
}
/******************商品图片及购买详情结束******************/
</style>

View File

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

View File

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

View File

@@ -1,131 +1,251 @@
<template>
<div class="scroll-show">
<div class="content">
<cateNav
class="cate"
:hover="true"
:showNavBar="false"
useClass="fixed-show"
/>
<Search
class="search-con"
:hover="true"
ref="search"
:showLogo="false"
:showTag="false"
useClass="fixed-show"
/>
<div class="cart">
<span @mouseenter="getCartList">
<el-icon class="cart-icon" :size="20" @click="goCartList"><ShoppingCart /></el-icon>
</span>
<i class="cart-badge">{{ Number(cartNum) > 99 ? "99+" : cartNum }}</i>
<!-- 1. 左侧区域:商城 Logo -->
<div class="left-section">
<router-link to="/" class="logo-link">
<img v-if="logoSrc" :src="logoSrc" alt="Logo" class="site-logo" />
<span v-else class="text-logo">LiliShop</span>
</router-link>
</div>
<!-- 2. 中间搜索区域:与 SearchHeader 样式完全一致(蓝底色、圆角4px、搜索按钮) -->
<div class="search-main">
<div class="search-box">
<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>
<!-- 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>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import Search from '@/components/Search.vue'
import cateNav from '@/components/nav/CateNav.vue'
import { cartCount } from '@/api/cart.js'
import storage from '@/plugins/storage.js'
import { ShoppingCart } from '@element-plus/icons-vue'
import { useAppStore } from '@/stores/app'
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { cartCount } from '@/api/cart.js';
import storage from '@/plugins/storage.js';
import { ShoppingCart, Search } from '@element-plus/icons-vue';
import { useAppStore } from '@/stores/app';
const router = useRouter()
const appStore = useAppStore()
const props = defineProps({
options: {
type: Object,
default: () => ({})
}
});
const userInfo = ref({})
const cartNum = computed(() => appStore.cartNum)
const router = useRouter();
const route = useRoute();
const appStore = useAppStore();
function goCartList() {
router.push('/cart')
const keyword = ref(route.query.keyword || '');
const cartNum = computed(() => appStore.cartNum || 0);
watch(() => route.query.keyword, (v) => {
keyword.value = v || '';
});
const logoSrc = computed(() => {
return props.options?.logoUrl || appStore.logoImg || '';
});
const placeholderText = computed(() => {
const ph = props.options?.placeholder;
if (!ph || ph === '搜索品牌或商品' || ph === '请输入商品名称、品牌或型号') {
return '请输入商品名称';
}
return ph;
});
function handleSearch() {
const kw = (keyword.value || '').trim();
if (route.path === '/goodsList') {
const nextQuery = { ...route.query };
if (kw) {
nextQuery.keyword = kw;
} else {
delete nextQuery.keyword;
}
router.push({ path: '/goodsList', query: nextQuery });
} else {
router.push({ path: '/goodsList', query: kw ? { keyword: kw } : {} });
}
}
function getCartList() {
if (storage.getItem('userInfo')) {
cartCount().then((res) => {
appStore.setCartNum(res.result)
storage.setItem('cartNum', res.result)
})
appStore.setCartNum(res.result);
storage.setItem('cartNum', res.result);
});
}
}
onMounted(() => {
if (storage.getItem('userInfo')) {
userInfo.value = JSON.parse(storage.getItem('userInfo'))
}
})
getCartList();
});
</script>
<style lang="scss" scoped>
$side-col-width: 263.2px;
$col-gap: 10px;
$cart-col-width: 40px;
<style lang="scss" scoped>
.scroll-show {
height: 60px;
overflow: visible;
width: 100%;
background: #ffffff;
}
.content {
width: 1200px;
max-width: 100%;
height: 60px;
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;
align-items: center;
justify-content: center;
height: 60px;
position: relative;
justify-content: space-between;
box-sizing: border-box;
padding: 0 10px;
}
.cart-icon {
font-size: 25px;
color: $theme_color;
cursor: pointer;
.left-section {
display: flex;
align-items: center;
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 {
position: absolute;
font-style: normal;
top: 12px;
right: -2px;
display: block;
background-color: $theme_color;
color: #fff;
font-size: 12px;
min-width: 17px;
height: 17px;
padding: 0 4px;
border-radius: 10px;
line-height: 17px;
text-align: center;
z-index: 5;
.search-main {
width: 560px;
flex-shrink: 0;
.search-box {
display: flex;
border: 2px solid #409EFF;
border-radius: 4px;
overflow: hidden;
height: 38px;
box-sizing: border-box;
background: #ffffff;
.search-input {
flex: 1;
min-width: 0;
border: none;
padding: 0 14px;
font-size: 13px;
color: #334155;
outline: none;
background: #ffffff;
&::placeholder {
color: #94a3b8;
}
}
.search-btn {
background: #409EFF;
color: #ffffff;
border: none;
padding: 0 22px;
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
flex-shrink: 0;
&:hover {
background: #66b1ff;
}
}
}
}
.cart-entry {
flex-shrink: 0;
.cart-btn {
height: 38px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 16px;
border: 1px solid #e2e8f0;
border-radius: 4px;
font-size: 13px;
color: #334155;
background: #ffffff;
text-decoration: none;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
transition: all 0.15s ease;
box-sizing: border-box;
.cart-icon {
font-size: 16px;
color: #409EFF;
}
.cart-text {
font-weight: 500;
white-space: nowrap;
}
.cart-count {
background: #409EFF;
color: #ffffff;
font-size: 11px;
font-weight: 700;
padding: 0 6px;
height: 18px;
line-height: 18px;
border-radius: 9px;
}
&:hover {
border-color: #409EFF;
color: #409EFF;
}
}
}
</style>

View File

@@ -1,6 +1,16 @@
<template>
<div class="model-form">
<div class="model-content">
<div class="model-form" :style="pageContainerStyle">
<!-- 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
v-for="(element, index) in renderList"
:key="element.key"
@@ -11,14 +21,34 @@
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import ModelFormItem from './ModelFormItem.vue';
import RowWrapper from './RowWrapper.vue';
defineOptions({ name: 'modelForm' });
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 list = props.data?.list || [];
return list.filter(
@@ -27,21 +57,26 @@ const renderList = computed(() => {
});
function isBuiltinModule(type) {
// 顶部广告、快捷导航由 Index 页单独渲染
return type === 'topAdvert' || type === 'navBar';
}
</script>
<style lang="scss" scoped>
.model-form {
position: relative;
z-index: 0;
width: 100%;
}
.v2-rows-container {
width: 100%;
min-height: 400px;
}
.model-content {
width: 100%;
min-height: 1200px;
box-sizing: border-box;
/* 勿设 overflow-x: hidden,会裁切 discountAdvert 的 margin-left: -100px 左溢背景 */
overflow-x: visible;
}
</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: '品质生活 · 限时第2件半价', img: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/brand-wall/brand_4.png', url: '/goodsList?category=4' }
];
});
const actualColumns = computed(() => {
const baseCols = Number(props.options?.columns) || 4;
const ratio = props.componentStyle?.widthRatio || '100%';
if (ratio === '33.33%' || ratio === '33.3%') {
return Math.min(baseCols, 2);
}
if (ratio === '50%') {
return Math.min(baseCols, 2);
}
if (ratio === '66.67%' || ratio === '66.6%' || ratio === '66.7%') {
return Math.min(baseCols, 3);
}
return baseCols;
});
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
backgroundColor: s.backgroundColor || 'transparent',
padding: Array.isArray(s.padding)
? `${s.padding[0]}px ${s.padding[1]}px ${s.padding[2]}px ${s.padding[3]}px`
: '10px 0'
};
});
</script>
<style lang="scss" scoped>
.decor-brand-wall {
width: 100%;
box-sizing: border-box;
.brand-grid {
max-width: 1600px;
margin: 0 auto;
display: grid;
width: 100%;
.brand-card {
background: #ffffff;
border: 1px solid #f1f5f9;
border-radius: 8px;
overflow: hidden;
aspect-ratio: 2 / 1;
width: 100%;
height: auto;
min-height: 80px;
max-height: 180px;
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
transition: all 0.25s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
border-color: #e2e8f0;
.brand-img {
transform: scale(1.03);
}
}
.brand-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.25s ease;
}
.brand-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #f8fafc;
color: #64748b;
font-size: 14px;
font-weight: 500;
}
}
}
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,116 @@
<template>
<div class="decor-hot-zone" :style="customStyle">
<div class="hot-zone-wrap" v-if="displayImageUrl">
<img :src="displayImageUrl" alt="hotzone" class="base-image" />
<div
v-for="(zone, idx) in displayZones"
:key="idx"
class="zone-link"
:style="{
top: `${zone.top !== 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-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]"
:key="index"
:key="index" :title="item.name"
@click="init(item, plantIndex != Object.keys(data).length - 1 ? Object.keys(data)[plantIndex + 1] : 0, plantIndex)"
class="map-item">
{{ item.name }}
@@ -125,7 +125,8 @@ function finished() {
.selector-item {
width: 100%;
flex: 1;
overflow: auto;
overflow-y: auto;
overflow-x: hidden;
}
.map-item {
@@ -133,6 +134,10 @@ function finished() {
padding: 10px;
border-bottom: 1px solid #eee;
cursor: pointer;
box-sizing: border-box;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
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

@@ -118,13 +118,6 @@
<p>
{{ goods.goodsSku.goodsName }}
</p>
<p><el-tag
v-if="goods.goodsSku.salesModel === 'WHOLESALE'"
class="goods-show-tag"
color="purple"
>
批发商品
</el-tag></p>
<template
v-for="(promotion, promotionIndex) in goods.promotions"
>

View File

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

View File

@@ -1,7 +1,7 @@
<template>
<div class="wrapper">
<BaseHeader></BaseHeader>
<Search @search="handleSearch"></Search>
<TopBar></TopBar>
<SearchHeader @search="handleSearch"></SearchHeader>
<div class="container">
<!-- 商品筛选栏 -->
@@ -56,12 +56,6 @@
</span>
</div>
<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>
</div>
<div class="goods-show-num">
@@ -121,6 +115,8 @@ import { ArrowDown, ArrowUp } from '@element-plus/icons-vue';
import GoodsClassNav from "@/components/nav/GoodsClassNav";
import GoodsThumb from "@/components/goods/GoodsThumb.vue";
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 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 {
// padding:3px 0;

View File

@@ -22,33 +22,39 @@
</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 -->
<Search></Search>
<div class="index-main">
<!-- 商品分类 + 楼层装修(同一宽度容器,保证与分类栏左缘对齐) -->
<Search v-if="!hasComponentInV2('searchHeader')"></Search>
<!-- 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>
<model-form :data="modelFormData"></model-form>
</div>
<!-- 底部栏 -->
<BaseFooter></BaseFooter>
<BaseFooter v-if="!hasComponentInV2('footerNav')"></BaseFooter>
<!-- 侧边栏 -->
<fixedBar class="fixed-bar" :class="{'show-fixed': topSearchShow}"></fixedBar>
</div>
</template>
<script setup>
import { onMounted, onUnmounted, ref } from "vue";
import { onMounted, onUnmounted, ref, computed } from "vue";
import Search from '@/components/Search';
import ModelForm from '@/components/indexDecorate/ModelForm';
import HoverSearch from '@/components/header/hoverSearch';
import fixedBar from '@/components/fixed/index';
import storage from "@/plugins/storage";
import { indexData, getAutoCoup } from '@/api/index.js';
import { DOUBLE11_PAGE_DOCUMENT } from '@/components/indexDecorate/presets/double11Preset.js';
import { seckillByDay } from '@/api/promotion';
defineOptions({ name: "Index" });
@@ -69,6 +75,29 @@ const topSearchShow = ref(false); // 滚动后顶部搜索栏展示
const carouselLarge = 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) {
let shop = '平台';
@@ -137,7 +166,7 @@ function handleReachBottom() {
function getIndexData() {
if (props.pageData) {
parsePageData(JSON.stringify(props.pageData)).catch(() => {
// 装修数据解析失败时保持默认空楼层
modelFormData.value = DOUBLE11_PAGE_DOCUMENT;
});
} else {
// 获取首页装修数据
@@ -146,11 +175,15 @@ function getIndexData() {
try {
await parsePageData(res.result.pageData);
} catch {
// 装修数据解析失败时保持默认空楼层
modelFormData.value = DOUBLE11_PAGE_DOCUMENT;
}
} else {
// 无服务端数据时默认展示双11平台首页
modelFormData.value = DOUBLE11_PAGE_DOCUMENT;
}
}).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) {
return;
}
if (dataJson.version === 2 || dataJson.version === '2.0') {
modelFormData.value = dataJson;
return;
}
// 秒杀活动不是装修的数据,需要调用接口判断是否有秒杀商品
// 轮播图根据不同轮播,样式不同
for (let i = 0; i < dataJson.list.length; i++) {
@@ -221,6 +258,11 @@ onUnmounted(() => {
.container {
@include sub_background_color($light_background_color);
}
.v2-index-main {
width: 100%;
position: relative;
box-sizing: border-box;
}
</style>
<style>
.hover-search {
@@ -231,7 +273,8 @@ onUnmounted(() => {
position: fixed;
top: 0;
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;
}
.show {

View File

@@ -1,8 +1,8 @@
<template>
<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-main">
<div class="shop-info">
@@ -87,10 +87,6 @@
<span class="seckill-price text-danger">{{ $filters.unitPrice(item.price, "¥") }}</span>
</div>
<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>
</div>
<div class="goods-show-num">
@@ -133,7 +129,8 @@ import {
isStoreCollection,
} from "@/api/member";
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 storage from "@/plugins/storage";
import {getFloorStoreData} from "@/api/index.js";
@@ -209,7 +206,11 @@ function getIndexData() {
}
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++) {
const type = dataJson.list[i].type;
if (type === "carousel2") {
@@ -719,15 +720,6 @@ window.onscroll = () => {
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 {
display: flex;
justify-content: flex-end;

View File

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

View File

@@ -1,7 +1,7 @@
<template>
<div>
<BaseHeader></BaseHeader>
<Search></Search>
<TopBar></TopBar>
<SearchHeader></SearchHeader>
<!-- 添加成功提示 -->
<div class="add-info-box-container">
@@ -50,6 +50,8 @@ export default {
<script setup>
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";
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>
<div>
<BaseHeader></BaseHeader>
<Search></Search>
<TopBar></TopBar>
<SearchHeader></SearchHeader>
<div class="container width_1200">
<h1 class="page-title">文章帮助中心</h1>
@@ -50,6 +50,8 @@
<script setup>
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 { articleCateList, articleDetail, articleList as fetchArticleList } from '@/api/common.js'

View File

@@ -40,6 +40,105 @@
</div>
<span class="asset-link">查看</span>
</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>
</template>
@@ -50,9 +149,18 @@ import { useRouter } from "vue-router";
import { Bell } from "@element-plus/icons-vue";
import storage from "@/plugins/storage";
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 { useAppStore } from "@/stores/app";
import { orderStatusList } from "./enumeration.js";
defineOptions({ name: "UserCenterHome" });
@@ -63,6 +171,12 @@ function totalOf(res) {
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 waitPay = ref(0);
const waitShip = ref(0);
@@ -70,6 +184,14 @@ const waitReceive = ref(0);
const afterSale = ref(0);
const couponCount = 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 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() {
const page = { pageNumber: 1, pageSize: 1 };
Promise.all([
@@ -100,14 +231,45 @@ function loadSummary() {
memberCouponList({ ...page, memberCouponStatus: "NEW" }).catch(() => null),
getMembersWallet().catch(() => null),
refreshUnreadMessage(),
]).then(([pay, ship, receive, afterSaleRes, coupon, wallet]) => {
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;
});
getOrderList({ pageNumber: 1, pageSize: 3 }).catch(() => null),
tracksList({ pageNumber: 1, pageSize: 10, order: "desc", sort: "updateTime" }).catch(() => null),
collectList({ pageNumber: 1, pageSize: 10, type: "GOODS" }).catch(() => null),
memberPoint().catch(() => null),
memberGradeCurrent({ pageNumber: 1, pageSize: 10 }).catch(() => null),
]).then(
([
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) {
@@ -118,6 +280,15 @@ function goOrder(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(() => {
readUser();
loadSummary();
@@ -196,7 +367,7 @@ onMounted(() => {
}
.asset-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.stat-card,
@@ -236,7 +407,12 @@ onMounted(() => {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 18px 20px;
> div {
min-width: 0;
}
}
.asset-label {
@@ -251,6 +427,9 @@ onMounted(() => {
font-size: 20px;
font-weight: 600;
line-height: 28px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
span {
margin-left: 4px;
@@ -260,8 +439,201 @@ onMounted(() => {
}
}
.grade-value {
font-size: 16px;
}
.asset-link {
flex: 0 0 auto;
color: $theme_color;
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>

View File

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

View File

@@ -34,7 +34,7 @@
<div class="comment-cell">{{ item.grade === 'GOOD' ? '好评' : item.grade === 'WORSE' ? '差评' : '中评' }}</div>
<div class="comment-content">{{ item.content }}</div>
<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>
@@ -230,19 +230,4 @@ onMounted(() => {
display: flex;
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>

View File

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

View File

@@ -32,17 +32,19 @@
{{ item.goodsName }}
</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-content">{{ item.content }}</div>
<div class="complain-ops">
<button type="button" class="text-link" @click="goDetail(item.id)">投诉详情</button>
<button
<el-button link type="primary" @click="goDetail(item.id)">投诉详情</el-button>
<el-button
v-if="item.complainStatus === 'APPLYING' || item.complainStatus === 'NEW'"
type="button"
class="text-link"
link
type="danger"
@click="cancel(item.id)"
>取消投诉</button>
>取消投诉</el-button>
</div>
</div>
</div>
@@ -84,6 +86,18 @@ const params = reactive({ // 请求参数
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() { // 获取投诉列表
complainList(params).then(res => {
if (res.success) {
@@ -268,20 +282,9 @@ onMounted(() => {
flex-direction: column;
align-items: center;
gap: 8px;
}
.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;
.el-button + .el-button {
margin-left: 0;
}
}
</style>

View File

@@ -33,10 +33,10 @@
</div>
<div v-if="params.type === 'GOODS'" class="fav-price">{{ $filters.unitPrice(item.price, '¥') }}</div>
<div class="fav-ops">
<button v-if="params.type === 'GOODS'" type="button" class="op-btn" @click="buynow(item.skuId, item.goodsId)">立即购买</button>
<button v-else type="button" class="op-btn" @click="goShop(item.id)">查看店铺</button>
<button v-if="params.type === 'GOODS'" type="button" class="text-link" @click="cancel(item.skuId)">取消收藏</button>
<button v-else type="button" class="text-link" @click="cancelStore(item.id)">取消收藏</button>
<el-button v-if="params.type === 'GOODS'" type="primary" size="small" @click="buynow(item.skuId, item.goodsId)">立即购买</el-button>
<el-button v-else type="primary" size="small" @click="goShop(item.id)">查看店铺</el-button>
<el-button v-if="params.type === 'GOODS'" link type="danger" @click="cancel(item.skuId)">取消收藏</el-button>
<el-button v-else link type="danger" @click="cancelStore(item.id)">取消收藏</el-button>
</div>
</div>
<el-skeleton size="large" fix v-if="spinShow"></el-skeleton>
@@ -72,10 +72,10 @@
</div>
<div v-if="params.type === 'GOODS'" class="fav-price">{{ $filters.unitPrice(item.price, '¥') }}</div>
<div class="fav-ops">
<button v-if="params.type === 'GOODS'" type="button" class="op-btn" @click="buynow(item.skuId, item.goodsId)">立即购买</button>
<button v-else type="button" class="op-btn" @click="goShop(item.id)">查看店铺</button>
<button v-if="params.type === 'GOODS'" type="button" class="text-link" @click="cancel(item.skuId)">取消收藏</button>
<button v-else type="button" class="text-link" @click="cancelStore(item.id)">取消收藏</button>
<el-button v-if="params.type === 'GOODS'" type="primary" size="small" @click="buynow(item.skuId, item.goodsId)">立即购买</el-button>
<el-button v-else type="primary" size="small" @click="goShop(item.id)">查看店铺</el-button>
<el-button v-if="params.type === 'GOODS'" link type="danger" @click="cancel(item.skuId)">取消收藏</el-button>
<el-button v-else link type="danger" @click="cancelStore(item.id)">取消收藏</el-button>
</div>
</div>
<el-skeleton size="large" fix v-if="spinShow"></el-skeleton>
@@ -316,7 +316,7 @@ onMounted(() => {
margin-top: 6px;
padding: 0 6px;
border-radius: 4px;
background: #fff1f0;
background: color-mix(in srgb, #{$theme_color} 8%, #fff);
color: $theme_color;
font-size: 12px;
line-height: 20px;
@@ -336,37 +336,4 @@ onMounted(() => {
justify-content: center;
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>

View File

@@ -9,63 +9,74 @@
<span v-for="b in currentGradeBenefits" :key="b.id" class="benefit-tag">{{ b.benefitName || '-' }}</span>
</div>
<div class="grade-exp">当前经验值:<span class="num">{{ currentExperience || 0 }}</span></div>
<div class="grade-next" v-if="nextGrade">
距 {{ nextGrade.gradeName }} 还差 <span class="num">{{ diffExperience }}</span> 经验值
</div>
<template v-if="nextGrade">
<div class="grade-next">
距 {{ 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>
</div>
<div class="grade-tabs">
<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">
<empty v-if="!rulesLoading && !ruleList.length" />
<div v-else class="rule-list">
<div class="rule-item" v-for="row in ruleList" :key="row.ruleKey || row.id">
<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 class="grade-panel">
<div class="grade-tabs">
<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 class="rules-desc" v-if="rulesDescription">{{ rulesDescription }}</div>
</div>
<div v-show="activeGradeTab === 'logs'" v-loading="logLoading">
<empty v-if="!(experienceLogs.records || []).length" />
<div v-else class="log-list">
<div class="log-head">
<span>时间</span>
<span>规则</span>
<span>变动值</span>
</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 v-show="activeGradeTab === 'rules'" v-loading="rulesLoading">
<empty v-if="!rulesLoading && !ruleList.length" />
<div v-else class="rule-list">
<div class="rule-item" v-for="row in ruleList" :key="row.ruleKey || row.id">
<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 class="rules-desc" v-if="rulesDescription">{{ rulesDescription }}</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 v-show="activeGradeTab === 'logs'" v-loading="logLoading">
<empty v-if="!(experienceLogs.records || []).length" />
<div v-else class="log-list">
<div class="log-head">
<span>时间</span>
<span>规则</span>
<span>变动值</span>
</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>
</UserCenterLayout>
@@ -92,7 +103,6 @@ const currentGradeId = ref('')
const selectedRuleKey = ref('')
const activeGradeTab = ref('rules')
const logLoading = ref(false)
const gradeLoading = ref(false)
const params = reactive({
pageNumber: 1,
pageSize: 20
@@ -137,6 +147,15 @@ const diffExperience = computed(() => {
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 一致) */
const currentGradeBenefits = computed(() => {
const id = currentGradeId.value
@@ -174,7 +193,6 @@ function loadRules () {
}
function loadGradeList () {
gradeLoading.value = true
memberGradeList()
.then(res => {
if (res && res.success) {
@@ -197,9 +215,6 @@ function loadGradeList () {
gradeList.value = []
}
})
.finally(() => {
gradeLoading.value = false
})
}
function loadCurrent () {
@@ -223,12 +238,6 @@ function loadCurrent () {
})
}
function selectRule (ruleKey) {
selectedRuleKey.value = ruleKey || ''
params.pageNumber = 1
loadCurrent()
}
function changePage (val) {
params.pageNumber = val
loadCurrent()
@@ -283,30 +292,31 @@ function getLogDeltaAbsValue (row) {
const num = getLogDeltaRaw(row)
return Math.abs(num)
}
function getLogDeltaColor (row) {
return getLogDeltaRaw(row) >= 0 ? 'green' : 'red'
}
</script>
<style scoped lang="scss">
.grade-card {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 8px;
padding: 16px;
background: #fafafa;
border-radius: 8px;
gap: 20px;
padding: 24px 28px;
border-radius: 12px;
background: linear-gradient(135deg, #3a4157 0%, #23262f 100%);
box-shadow: 0 6px 20px rgba(35, 38, 47, 0.25);
}
.grade-icon {
width: 56px;
height: 56px;
width: 64px;
height: 64px;
object-fit: contain;
flex-shrink: 0;
}
.grade-info {
flex: 1;
min-width: 0;
}
.grade-name-row {
display: flex;
flex-wrap: wrap;
@@ -315,39 +325,57 @@ function getLogDeltaColor (row) {
}
.grade-name {
color: #171A26;
font-size: 18px;
color: #f0c46c;
font-size: 20px;
font-weight: 600;
line-height: 1.4;
letter-spacing: 1px;
}
.benefit-tag {
padding: 0 8px;
border-radius: 4px;
background: #fff;
color: $theme_color;
font-size: 14px;
line-height: 22px;
padding: 0 10px;
border: 1px solid rgba(240, 196, 108, 0.4);
border-radius: 10px;
background: rgba(240, 196, 108, 0.12);
color: #f0d9a8;
font-size: 12px;
line-height: 20px;
}
.grade-exp,
.grade-next {
margin-top: 6px;
color: #666;
margin-top: 8px;
color: rgba(255, 255, 255, 0.75);
font-size: 14px;
line-height: 22px;
}
.num {
color: $theme_color;
color: #f0c46c;
font-size: 16px;
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 {
display: flex;
gap: 24px;
margin: 16px 0;
margin-bottom: 16px;
border-bottom: 1px solid #f0f0f0;
}

View File

@@ -15,10 +15,10 @@
<template #append><span>输入验证码</span></template>
</el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitRegister">下一步</el-button>
</el-form-item>
</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">
@@ -43,11 +43,10 @@
<template #prepend><el-icon><Lock /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleRegist">修改</el-button>
</el-form-item>
</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"
v-if="showPwd">
@@ -65,10 +64,10 @@
<template #prepend><el-icon><Lock /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="setPassword">设置</el-button>
</el-form-item>
</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>
</div>
</template>
@@ -252,9 +251,6 @@ onMounted(() => {
</script>
<style scoped lang="scss">
.wrapper {
text-align: center;
}
.phone {
text-align: left;
}

View File

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

View File

@@ -13,7 +13,7 @@
:action="action"
:headers="accessToken"
>
<button type="button" class="ghost-btn">上传头像</button>
<el-button size="small">上传头像</el-button>
</el-upload>
</div>
</el-form-item>
@@ -29,13 +29,13 @@
/>
</el-form-item>
<el-form-item label="性别">
<div class="sex-group">
<button type="button" class="sex-btn" :class="{ active: formItem.sex === 1 }" @click="formItem.sex = 1">男</button>
<button type="button" class="sex-btn" :class="{ active: formItem.sex === 0 }" @click="formItem.sex = 0">女</button>
</div>
<el-radio-group v-model="formItem.sex">
<el-radio-button :value="1">男</el-radio-button>
<el-radio-button :value="0">女</el-radio-button>
</el-radio-group>
</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>
</UserCenterLayout>
@@ -167,7 +167,7 @@ function handleSuccess (res, file) { // 上传成功
:deep(.el-form-item__label) {
height: auto;
line-height: 22px;
color: #666;
color: #999;
font-size: 14px;
}
@@ -199,51 +199,5 @@ function handleSuccess (res, file) { // 上传成功
:deep(.profile-field.el-date-editor) {
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>

View File

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

View File

@@ -33,7 +33,9 @@
<div class="order-sn">
{{ order.createTime }}<span>售后单号 {{ order.sn }}</span><span class="order-store" @click="shopPage(order.storeId)">{{ order.storeName }}</span>
</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 class="order-body">
<div class="col-detail">
@@ -59,19 +61,19 @@
<div class="order-amount">{{ $filters.unitPrice(order.applyRefundPrice, "¥") }}</div>
</div>
<div class="col-ops">
<button
<el-button
v-if="order.serviceStatus == 'PASS' && order.serviceType != 'RETURN_MONEY'"
type="button"
class="op-btn"
type="primary"
size="small"
@click="openModal(order)"
>提交物流</button>
<button type="button" class="text-link" @click="goDetail(order.sn)">售后详情</button>
<button
>提交物流</el-button>
<el-button link type="primary" @click="goDetail(order.sn)">售后详情</el-button>
<el-button
v-if="order.afterSaleAllowOperationVO && order.afterSaleAllowOperationVO.cancel"
type="button"
class="text-link"
link
type="danger"
@click="cancel(order.sn)"
>取消售后</button>
>取消售后</el-button>
</div>
</div>
</div>
@@ -253,6 +255,14 @@ function filterOrderStatus(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() {
getLogisticsCompany().then(res => {
@@ -372,7 +382,7 @@ onMounted(() => {
align-items: center;
min-width: 0;
font-size: 14px;
color: #171A26;
color: #333;
line-height: 1.5;
span {
@@ -476,38 +486,9 @@ onMounted(() => {
flex-direction: column;
align-items: center;
gap: 8px;
}
.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;
:deep(.el-button + .el-button) {
margin-left: 0;
}
}
}

View File

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

View File

@@ -1,26 +1,20 @@
<template>
<div class="apply-after-sale">
<UserCenterLayout>
<el-table :data="goodsData" border>
<el-table-column label="商品名称">
<template #default="{ row }">
<div style="padding:5px;display:flex;">
<img :src="row.image" loading="lazy" decoding="async" style="vertical-align: top;" width="60" height="60" alt="">
<span class="ml_10">{{ row.goodsName }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="价格">
<template #default="{ row }">
<div>{{ $filters.unitPrice(row.applyRefundPrice, '¥') }}</div>
</template>
</el-table-column>
<el-table-column prop="num" label="购买数量" />
</el-table>
<div class="goods-row" v-for="(goods, index) in goodsData" :key="index">
<img class="goods-img" :src="goods.image" loading="lazy" decoding="async" alt="">
<div class="goods-info">
<div class="goods-name">{{ goods.goodsName }}</div>
<div class="goods-meta">
<span class="goods-price">{{ $filters.unitPrice(goods.applyRefundPrice, '¥') }}</span>
<span class="goods-num">×{{ goods.num }}</span>
</div>
</div>
</div>
<div>
<el-form :model="form" ref="formRef" class="mt_10" :rules="rules" label-width="80px">
<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.returnMoney" label="RETURN_MONEY">退款</el-radio>
</el-radio-group>
@@ -224,6 +218,50 @@ onMounted(() => {
<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 {
text-align: right;
margin-top: 30px;

View File

@@ -164,17 +164,6 @@ onMounted(() => {
})
</script>
<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{
display: flex;
.goods-con {

View File

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

View File

@@ -40,7 +40,11 @@
<div class="order-sn" v-else>
{{ order.createTime }}<span>订单号 {{ order.sn }}</span><span class="order-store" @click="shopPage(order.storeId)">{{ order.storeName }}</span>
</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 class="order-body">
<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-num">共{{ goods.num }}件</div>
<div class="goods-extra">
<span v-if="refundPriceList(goods.isRefund)" class="refund-tag">{{ refundPriceList(goods.isRefund) }}</span>
<button v-if="goods.complainStatus == 'NO_APPLY'" type="button" class="sub-btn" @click="complain(order.sn, goodsIndex)">投诉</button>
<el-tag v-if="refundPriceList(goods.isRefund) && !isUnpaid(order)" type="warning" size="small">{{ refundPriceList(goods.isRefund) }}</el-tag>
</div>
</div>
</div>
<button v-if="canAfterSale(order)" type="button" class="sub-btn" @click="applyAfterSale(order.orderItems)">申请售后</button>
</div>
<div class="col-amount">
<div class="order-amount">{{ $filters.unitPrice(order.flowPrice, "¥") }}</div>
@@ -77,14 +79,14 @@
</div>
<div class="col-ops">
<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 v-else>
<button v-if="order.allowOperationVO && order.allowOperationVO.pay" type="button" class="op-btn" @click="goPay(order.sn)">去支付</button>
<button v-if="order.allowOperationVO && order.allowOperationVO.rog" type="button" class="op-btn" @click="received(order.sn)">确认收货</button>
<button type="button" class="text-link" @click="orderDetail(order.sn)">订单详情</button>
<button v-if="order.allowOperationVO && order.allowOperationVO.cancel" type="button" class="text-link" @click="handleCancelOrder(order.sn)">取消订单</button>
<button v-if="order.orderStatus === 'COMPLETED'" type="button" class="text-link" @click="delOrder(order.sn)">删除订单</button>
<el-button v-if="order.allowOperationVO && order.allowOperationVO.pay" type="primary" size="small" @click="goPay(order.sn)">去支付</el-button>
<el-button v-if="order.allowOperationVO && order.allowOperationVO.rog" type="primary" size="small" @click="received(order.sn)">确认收货</el-button>
<el-button link type="primary" @click="orderDetail(order.sn)">订单详情</el-button>
<el-button v-if="order.allowOperationVO && order.allowOperationVO.cancel" link type="info" @click="handleCancelOrder(order.sn)">取消订单</el-button>
<el-button v-if="order.orderStatus === 'COMPLETED'" link type="info" @click="delOrder(order.sn)">删除订单</el-button>
</template>
</div>
</div>
@@ -120,7 +122,11 @@
<div class="order-sn" v-else>
{{ order.createTime }}<span>订单号 {{ order.sn }}</span><span class="order-store" @click="shopPage(order.storeId)">{{ order.storeName }}</span>
</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 class="order-body">
<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-num">共{{ goods.num }}件</div>
<div class="goods-extra">
<span v-if="refundPriceList(goods.isRefund)" class="refund-tag">{{ refundPriceList(goods.isRefund) }}</span>
<button v-if="goods.complainStatus == 'NO_APPLY'" type="button" class="sub-btn" @click="complain(order.sn, goodsIndex)">投诉</button>
<el-tag v-if="refundPriceList(goods.isRefund) && !isUnpaid(order)" type="warning" size="small">{{ refundPriceList(goods.isRefund) }}</el-tag>
</div>
</div>
</div>
<button v-if="canAfterSale(order)" type="button" class="sub-btn" @click="applyAfterSale(order.orderItems)">申请售后</button>
</div>
<div class="col-amount">
<div class="order-amount">{{ $filters.unitPrice(order.flowPrice, "¥") }}</div>
@@ -157,30 +161,20 @@
</div>
<div class="col-ops">
<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 v-else>
<button v-if="order.allowOperationVO && order.allowOperationVO.pay" type="button" class="op-btn" @click="goPay(order.sn)">去支付</button>
<button v-if="order.allowOperationVO && order.allowOperationVO.rog" type="button" class="op-btn" @click="received(order.sn)">确认收货</button>
<button type="button" class="text-link" @click="orderDetail(order.sn)">订单详情</button>
<button v-if="order.allowOperationVO && order.allowOperationVO.cancel" type="button" class="text-link" @click="handleCancelOrder(order.sn)">取消订单</button>
<button v-if="order.orderStatus === 'COMPLETED'" type="button" class="text-link" @click="delOrder(order.sn)">删除订单</button>
<el-button v-if="order.allowOperationVO && order.allowOperationVO.pay" type="primary" size="small" @click="goPay(order.sn)">去支付</el-button>
<el-button v-if="order.allowOperationVO && order.allowOperationVO.rog" type="primary" size="small" @click="received(order.sn)">确认收货</el-button>
<el-button link type="primary" @click="orderDetail(order.sn)">订单详情</el-button>
<el-button v-if="order.allowOperationVO && order.allowOperationVO.cancel" link type="info" @click="handleCancelOrder(order.sn)">取消订单</el-button>
<el-button v-if="order.orderStatus === 'COMPLETED'" link type="info" @click="delOrder(order.sn)">删除订单</el-button>
</template>
</div>
</div>
</div>
<el-skeleton size="large" fix v-if="spinShow"></el-skeleton>
</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-radio-group v-model="cancelParams.reason">
<el-radio :label="item.reason" v-for="item in cancelReason" :key="item.id">
@@ -233,8 +227,6 @@ const changeWay = ref(['全部', '待付款', '待发货', '待收货/使用']);
const activeOrderTab = ref(0);
const total = ref(0); // 数据总数
const spinShow = ref(false); // 加载状态
const afterSaleModal = ref(false); // 选择售后商品模态框
const afterSaleArr = ref([]); // 售后商品列表
const cancelAvail = ref(false); // 取消订单modal控制
const cancelReason = ref([]); // 取消订单原因
@@ -312,27 +304,6 @@ function received (sn) { // 确认收货
function goPay (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) { // 删除订单
Modal.confirm({
title: '删除订单',
@@ -420,11 +391,16 @@ function filterAfterSaleStatus (status) {
const ob = afterSaleStatusList.filter(e => e.status === status);
return ob && ob[0] ? ob[0].name : status
}
function canAfterSale (order) {
if (order.isAfterSale || order.orderType === 'E_COUPON' || !order.groupAfterSaleStatus) {
return false
function statusTagType (status) { // 订单/售后状态对应标签类型
const map = {
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) {
const map = {
@@ -436,11 +412,14 @@ function paymentLabel (order) {
if (order.paymentMethod && map[order.paymentMethod]) {
return map[order.paymentMethod]
}
if (order.payStatus === 'UNPAID' || order.orderStatus === 'UNPAID') {
if (isUnpaid(order)) {
return '待支付'
}
return ''
}
function isUnpaid (order) {
return order.payStatus === 'UNPAID' || order.orderStatus === 'UNPAID'
}
watch(() => route.query.tag, () => {
if (props.homePage || route.name !== 'MyOrder') return;
@@ -529,7 +508,7 @@ onMounted(() => {
align-items: center;
min-width: 0;
font-size: 14px;
color: #171A26;
color: #333;
line-height: 1.5;
span {
@@ -630,37 +609,6 @@ onMounted(() => {
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-ops {
text-align: center;
@@ -685,38 +633,9 @@ onMounted(() => {
flex-direction: column;
align-items: center;
gap: 8px;
}
.op-btn {
min-width: 96px;
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;
:deep(.el-button + .el-button) {
margin-left: 0;
}
}
}

View File

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

View File

@@ -7,7 +7,12 @@
>
<empty v-if="list.length == 0" />
<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>
<span
@@ -176,34 +181,61 @@ onMounted(() => {
<style scoped lang="scss">
@import "../../../assets/styles/coupon.scss";
.coupon-list {
gap: 16px;
}
.coupon-item {
overflow: hidden;
height: auto;
min-height: 168px;
margin-left: 0;
min-height: 140px;
margin: 0;
border-radius: 8px;
transition: box-shadow 0.2s ease, transform 0.2s ease;
$claim-width: 55px;
&:not(.is-disabled):hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1);
}
.c-left {
width: calc(100% - #{$claim-width});
box-sizing: border-box;
padding: 16px 16px 14px;
background: #fff8f5;
.coupon-amount {
font-size: 14px;
.price {
font-size: 28px;
font-weight: 700;
}
}
.describe {
font-size: 14px;
font-size: 12px;
}
p {
font-size: 14px;
font-size: 13px;
overflow: visible;
text-overflow: unset;
white-space: normal;
word-break: break-all;
word-break: normal;
line-height: 20px;
}
p:nth-of-type(1) {
font-weight: 500;
color: #333;
}
.coupon-expire {
font-size: 12px;
}
}
b {
@@ -248,6 +280,7 @@ onMounted(() => {
right: calc(#{$claim-width} - 9px);
width: 18px;
height: 18px;
border-color: #eee;
}
i.circle-top {
@@ -257,6 +290,26 @@ onMounted(() => {
i.circle-bottom {
bottom: -9px;
}
&.is-disabled {
.c-left {
background: #fafafa;
.coupon-amount,
.price {
color: #999;
}
.describe {
background: #f0f0f0;
color: #999;
}
p {
color: #999 !important;
}
}
}
}
.pageration {

View File

@@ -1,6 +1,10 @@
<template>
<div class="wrapper">
<UserCenterLayout>
<UserCenterLayout
:tabs="walletTabs"
:active-tab="activeWalletTabIndex"
@tab-change="handleTabChange"
>
<div class="wallet-summary">
<div class="wallet-stat">
@@ -13,8 +17,8 @@
</div>
</div>
<div class="account-btns">
<button type="button" class="op-btn" @click="recharge">在线充值</button>
<button type="button" class="ghost-btn" @click="withdrawalApply">申请提现</button>
<el-button type="primary" @click="recharge">在线充值</el-button>
<el-button @click="withdrawalApply">申请提现</el-button>
</div>
<el-dialog v-model="modal" width="530">
<template #header><p>
@@ -36,7 +40,7 @@
</el-form>
</div>
<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>
</el-dialog>
<!-- 提现申请 -->
@@ -65,7 +69,7 @@
number
size="large"
><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 v-if="withdrawApplyFormData.type === 'ALI'" label="真实姓名" prop="realName">
<el-input
@@ -86,14 +90,9 @@
</el-form>
</div>
<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>
</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'">
<empty v-if="!(logColumnsData.records || []).length" />
@@ -181,7 +180,7 @@
</template>
<script setup>
import { onMounted, reactive, ref } from "vue";
import { computed, onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { Message } from "@/utils/message";
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 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 memberDeposit = ref(0); // 余额
@@ -290,6 +295,10 @@ function tabPaneChange(v) {
getWithdrawApplyData();
}
}
// 页签切换
function handleTabChange(index) {
tabPaneChange(walletTabKeys[index]);
}
// 充值记录
function getRechargeData() {
getRecharge(rechargeForm).then((res) => {
@@ -416,7 +425,8 @@ function withdrawal() {
.wallet-stat {
flex: 1;
padding: 16px 20px;
background: #fafafa;
background: #fff;
border: 1px solid #eee;
border-radius: 8px;
.label {
@@ -445,67 +455,9 @@ function withdrawal() {
margin-bottom: 8px;
}
.op-btn,
.ghost-btn {
height: 32px;
padding: 0 16px;
border-radius: 4px;
font-family: inherit;
.form-tip {
color: #999;
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 {

View File

@@ -33,13 +33,16 @@
<template v-if="!loading">
<empty v-if="list.length === 0" />
<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' }">
<div class="gcc-card-header">
<div class="gcc-header-pattern" aria-hidden="true" />
<div class="gcc-header-inner">
<div class="gcc-header-left">
<div class="gcc-title">{{ item.giftCardName || "礼品卡" }}</div>
<div class="gcc-face">面值{{ formatYuan(item.faceValue) }}元</div>
<div class="gcc-name-row">
<div class="gcc-title">{{ item.giftCardName || "礼品卡" }}</div>
<span class="gcc-status-chip">{{ statusChipLabel }}</span>
</div>
<div class="gcc-face"><span class="gcc-face-symbol">¥</span>{{ formatYuan(item.faceValue) }}</div>
<div class="gcc-cardno">{{ item.cardNo }}</div>
</div>
<div class="gcc-header-right">
@@ -146,6 +149,10 @@ const primaryActionDisabled = computed(() => {
const pendingActivateLocked = computed(() => {
return statusTab.value === "PENDING_ACTIVATION" && !!activatingId.value;
});
const statusChipLabel = computed(() => {
const map = { AVAILABLE: "可用", UNAVAILABLE: "不可用", PENDING_ACTIVATION: "待激活" };
return map[statusTab.value] || "";
});
onMounted(() => {
getList();
@@ -303,8 +310,8 @@ function isActivating(item) {
.my-gift-cards {
.bind-section {
padding: 16px 18px;
border-radius: 4px;
border: 1px solid #e8eaec;
border-radius: 8px;
border: 1px solid #eee;
@include white_background_color();
}
.section-title {
@@ -331,8 +338,8 @@ function isActivating(item) {
}
.gift-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px 18px;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
align-items: start;
}
.gcc-card-item {
@@ -375,19 +382,38 @@ function isActivating(item) {
padding-right: 8px;
}
.gcc-title {
font-size: 17px;
font-weight: 700;
font-size: 14px;
font-weight: 600;
line-height: 1.3;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
}
.gcc-name-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.gcc-status-chip {
padding: 0 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.2);
font-size: 11px;
line-height: 18px;
}
.gcc-face {
margin-top: 8px;
font-size: 14px;
opacity: 0.95;
margin-top: 10px;
font-size: 26px;
font-weight: 700;
line-height: 1.2;
}
.gcc-face-symbol {
margin-right: 2px;
font-size: 15px;
font-weight: 500;
}
.gcc-cardno {
margin-top: 22px;
font-size: 14px;
margin-top: 14px;
font-size: 12px;
letter-spacing: 0.02em;
opacity: 0.75;
word-break: break-all;
@@ -433,24 +459,32 @@ function isActivating(item) {
display: block;
width: 100%;
margin-top: 14px;
padding: 10px 16px;
padding: 9px 16px;
border: none;
border-radius: 999px;
font-size: 15px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
color: #e95b6c;
background: #faf4f5;
color: #fff;
background: $theme_color;
transition: opacity 0.2s, background 0.2s;
}
.gcc-action:hover:not(.is-disabled) {
background: #f5e8ea;
opacity: 0.9;
}
.gcc-action.is-disabled {
color: #c5c8ce;
cursor: not-allowed;
background: #f7f7f7;
}
.gcc-card-item.is-unavailable {
.gcc-card-header {
background: linear-gradient(125deg, #c9c9c9 0%, #a8a8a8 100%);
}
.gcc-strong {
color: #999;
}
}
.page-size {
display: flex;
justify-content: flex-end;

View File

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

View File

@@ -1,7 +1,7 @@
<template>
<div class="point-mall">
<BaseHeader></BaseHeader>
<Search></Search>
<TopBar></TopBar>
<SearchHeader></SearchHeader>
<cateNav></cateNav>
<h3 class="promotion-decorate">积分商品</h3>
<el-select @select="selectCate" size="small" class="cate-select-con" v-model="cateId">
@@ -46,6 +46,8 @@
</template>
<script setup>
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 { pointGoods, pointGoodsCategory } from "@/api/promotion.js";

View File

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

View File

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

View File

@@ -1,14 +1,38 @@
<template>
<div class="shop-entry">
<div style="height: 20px"></div>
<div class="content">
<h1>店铺入驻</h1>
<el-steps :active="currentIndex" class="margin" finish-status="success">
<el-step title="企业资质信息"></el-step>
<el-step title="财务资质信息"></el-step>
<el-step title="其他信息"></el-step>
<el-step title="提交审核"></el-step>
</el-steps>
<div class="page-header">
<div class="header-icon">
<el-icon><Shop /></el-icon>
</div>
<div class="header-text">
<h1>店铺入驻</h1>
<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>
<template v-else>
<first-apply
@@ -30,34 +54,68 @@
></third-apply>
</template>
<div class="success-page" v-if="currentIndex == 3">
<span v-if="storeDisable == '' || storeDisable == 'APPLYING'">入驻申请提交成功,等待平台审核</span>
<span v-if="storeDisable == 'OPEN'">申请已通过,请联系管理员</span>
<span v-if="storeDisable == 'CLOSED'">店铺已关闭,重申请联系管理员</span>
<span v-if="storeDisable == 'REFUSED'">审核未通过,请修改资质信息,如有疑问请联系管理员</span>
<div class="status-page" v-if="currentIndex === 3">
<el-result
v-if="storeDisable === '' || storeDisable === 'APPLYING'"
icon="info"
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>
<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>
<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="agreeent-con" v-html="agreementCon"></div>
</div>
<template #footer><div style="text-align: center">
<p>
<el-checkbox v-model="checked">我已同意以上协议</el-checkbox>
<el-checkbox v-model="checked">我已阅读并同意以上协议</el-checkbox>
</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>
</el-dialog>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from "vue";
import { Check, Shop } from "@element-plus/icons-vue";
import { agreement, applyStatus } from "@/api/shopentry";
import firstApply from "./FirstApply";
import secondApply from "./SecondApply";
@@ -74,6 +132,24 @@ const storeDisable = ref(""); // APPLY OPEN 开店中 CLOSED 关闭 REFUSED 拒
const initLoading = ref(true);
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() {
agreementAccepted.value = true;
showAgreement.value = false;
@@ -193,16 +269,126 @@ onMounted(() => {
min-height: 500px;
border-radius: 20px;
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 {
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: 30px 0;
margin: 30px 0 0;
}
.agreeent-con {
max-height: 500px;
@@ -218,12 +404,11 @@ onMounted(() => {
.step-loading {
min-height: 320px;
}
.success-page {
height: 500px;
width: 100%;
line-height: 500px;
text-align: center;
font-size: 20px;
.status-page {
min-height: 420px;
display: flex;
align-items: center;
justify-content: center;
}
.shop-entry {
min-height: 100vh;

View File

@@ -1,7 +1,7 @@
<template>
<div class="person-msg">
<el-form ref="thirdFormRef" :model="form" :rules="rules" label-width="140px">
<h4>基础信息</h4>
<el-form ref="thirdFormRef" :model="form" :rules="rules" label-width="140px" scroll-to-error>
<div class="section-title">店铺信息</div>
<el-form-item prop="storeName" label="店铺名称">
<el-input
type="text"
@@ -13,19 +13,22 @@
<el-form-item prop="storeLogo" label="店铺logo">
<div class="upload-wrap">
<el-upload
ref="uploadLogo"
v-if="form.storeLogo.length < 1"
:show-file-list="false"
:on-success="handleSuccess"
accept=".jpg,.jpeg,.png,.gif"
:before-upload="beforeUpload"
:on-error="uploadErr"
:disabled="form.storeLogo.length >= 1"
:action="action"
: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>
<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"
@@ -45,8 +48,8 @@
<el-select
v-model="form.goodsManagementCategory"
multiple
placeholder="请选择"
style="width: 300px"
placeholder="请选择店铺经营类目"
style="width: 360px"
>
<el-option
v-for="item in categoryList"
@@ -58,8 +61,13 @@
</el-form-item>
<el-form-item prop="storeAddressIdPath" label="店铺所在地">
<span>{{ form.storeAddressPath || '暂无地址' }}</span>
<el-button type="default" style="margin-left: 10px;" @click="$refs.map.open()">选择</el-button>
<div class="address-select" @click="openMap">
<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 prop="storeAddressDetail" label="店铺详细地址">
<el-input
@@ -80,10 +88,12 @@
</el-form-item>
<el-form-item>
<el-button @click="$emit('change', 1)">返回</el-button>
<el-button type="primary" :loading="loading" @click="next"
>提交平台审核</el-button
>
<div class="form-actions">
<el-button size="large" @click="$emit('change', 1)">上一步</el-button>
<el-button type="primary" size="large" :loading="loading" @click="next"
>提交平台审核</el-button
>
</div>
</el-form-item>
</el-form>
<el-dialog title="图片预览" v-model="visible">
@@ -93,9 +103,9 @@
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from "vue";
import { onBeforeUnmount, onMounted, reactive, ref } from "vue";
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 { getCategory } from '@/api/goods';
@@ -105,6 +115,7 @@ import { buyerUrl } from '@/plugins/request.js';
import multipleMap from "@/components/map/multiple-map";
const THIRD_APPLY_DRAFT_KEY = 'shopEntryThirdDraft';
const emit = defineEmits(['change']);
@@ -116,6 +127,7 @@ const props = defineProps({
});
const thirdFormRef = ref(null);
const map = ref(null);
const loading = ref(false); // 加载状态
const uploadLoading = ref(false); // 上传加载状态
@@ -147,6 +159,10 @@ const rules = { // 验证规则
};
const categoryList = ref([]); // 分类数据
function openMap() {
map.value?.open();
}
// 下一步
function next () {
thirdFormRef.value.validate((valid) => {
@@ -158,7 +174,10 @@ function next () {
applyThird(params)
.then((res) => {
loading.value = false;
if (res.success) emit('change', 3);
if (res.success) {
sessionStorage.removeItem(THIRD_APPLY_DRAFT_KEY);
emit('change', 3);
}
})
.catch(() => {
loading.value = false;
@@ -192,14 +211,24 @@ function validateUploadFile (file) {
}
return true;
}
// 上传成功回调
// 上传成功回调(el-upload 只认 HTTP 状态码,业务失败 success:false 也会进这里,需要判断)
function handleSuccess (res, file) {
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 : '上传失败,请重试');
}
}
// 上传失败
function uploadErr () {
// 上传失败(非 2xx:err.message 通常是响应体字符串,尝试解析出服务端提示)
function uploadErr (err) {
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) {
@@ -277,28 +306,117 @@ function initFormFromContent (content) {
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(() => {
accessToken.accessToken = storage.getItem('accessToken');
getCategoryList();
});
onBeforeUnmount(() => {
saveDraft();
});
</script>
<style lang="scss" scoped>
h4 {
margin-bottom: 10px;
padding: 0 10px;
border: 1px solid #ddd;
background-color: #f8f8f8;
font-weight: bold;
color: #333;
font-size: 14px;
line-height: 40px;
text-align: left;
.section-title {
display: flex;
align-items: center;
margin: 24px 0 18px;
font-size: 16px;
font-weight: 600;
color: #303133;
&::before {
content: '';
width: 4px;
height: 18px;
border-radius: 2px;
background: $theme_color;
margin-right: 8px;
}
}
.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 {
display: flex;
@@ -316,6 +434,7 @@ h4 {
position: relative;
overflow: hidden;
border: 1px solid #eee;
border-radius: 8px;
box-sizing: border-box;
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 {
display: block;
width: 100%;

View File

@@ -115,54 +115,6 @@ const member = [{
// 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 = []
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-group/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/skeleton/style/css";
import "element-plus/es/components/step/style/css";
@@ -94,6 +95,7 @@ import {
ElRadioButton,
ElRadioGroup,
ElRate,
ElResult,
ElSelect,
ElSkeleton,
ElStep,
@@ -149,6 +151,7 @@ const components = [
ElRadioButton,
ElRadioGroup,
ElRate,
ElResult,
ElSelect,
ElSkeleton,
ElStep,

View File

@@ -53,6 +53,7 @@ const MyGiftCards = () => import("@/pages/home/userCenter/MyGiftCards");
const Home = () => import("@/pages/user/Home");
const UserCenter = () => import("@/pages/home/Main");
const Merchant = () => import("@/pages/Merchant");
const StoreList = () => import("@/pages/StoreList");
const NotFound = () => import("@/pages/NotFound");
const topic = () => import("@/pages/Topic");
const ShopEntry = () => import("@/pages/shopEntry/ShopEntry");
@@ -338,6 +339,12 @@ export const router = createRouter({
component: Merchant,
meta: { title: "店铺" },
},
{
path: "/storeList",
name: "StoreList",
component: StoreList,
meta: { title: "店铺列表" },
},
{
path: "/:pathMatch(.*)*",
name: "NotFound",

View File

@@ -1,21 +1,205 @@
MIT License
Apache License, Version 2.0
Copyright (c) 2020 YuanDong
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
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:
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
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
SOFTWARE.
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
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",
"babel-plugin-prismjs": "^2.0.1",
"core-js": "^3.49.0",
"element-plus": "^2.14.1",
"element-plus": "2.14.1",
"js-audio-recorder": "^1.0.6",
"js-base64": "^2.5.1",
"nprogress": "^0.2.0",

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<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">
<div>
<GoodsLink :goodsDetail="goodsDetail" v-if="toUser.userId === goodsDetail.storeId"
@@ -306,4 +306,20 @@ function formatDateToString(date) {
:deep(.el-tabs__header) {
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>

View File

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

View File

@@ -1,14 +1,14 @@
<template>
<div class="box" :style="{height:toUser.storeFlag ? '858px' : '815px'}" @scroll="scrollBottom">
<div class="box" @scroll="scrollBottom">
<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">
<dl v-if='list.length' class='base-list'>
<dd :key="index" @click="linkToGoods(item.goodsId, item.id)" v-for="(item,index) in list">
<div class="base">
<div>
<img style="width: 60px; height: 60px;margin-left:20px;box-sizing: border-box;" :src="item.thumbnail"
class="image" />
:onerror="defaultAvatar" class="image" />
</div>
<div class="recent_views">
<a class="goods_name">{{ item.goodsName }}</a>
@@ -35,24 +35,8 @@
<dl class='base-order-list' v-if='orderList.length'>
<dd v-for="(item, index) in orderList" :key="index">
<div class="order-list">
<div class="order-top order-padding">
<span class="order-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>
<div class="order-head">
<span class="order-sn" :title="item.sn" @click="linkToOrders(item.sn)">订单号:{{ item.sn }}</span>
<el-tag size="small" :type="col[item.orderStatus]">{{
item.orderStatus == 'STAY_PICKED_UP' ? '待自提'
: item.orderStatus == 'CANCELLED' ? '已取消' : item.orderStatus == 'UNPAID' ? '未付款' : item.orderStatus
@@ -62,9 +46,18 @@
''
}}</el-tag>
</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>
</dd>
</dl>
<div v-else class='no-more'>
@@ -80,9 +73,11 @@
import { computed, onMounted, ref } from "vue";
import { useTalkStore } from "@/stores/talk";
import { useUserStore } from "@/stores/user";
import { useAppStore } from "@/stores/app";
const talkStore = useTalkStore();
const userStore = useUserStore();
const appStore = useAppStore();
const emit = defineEmits(["loadMore", "sendMessage"]);
@@ -118,6 +113,8 @@ const col = ref({
const talkItems = computed(() => talkStore.talkItems);
const id = computed(() => userStore.id);
const toUser = computed(() => userStore.toUser);
// 图片加载失败时的默认图(onerror 属性值)
const defaultAvatar = computed(() => appStore.defaultAvatar);
//跳转订单列表
@@ -204,7 +201,9 @@ onMounted(() => {
}
.box {
max-width: 362px;
width: 100%;
height: 100%;
box-sizing: border-box;
.top {
text-align: center;
@@ -237,57 +236,51 @@ onMounted(() => {
}
.order-list {
padding: 9px;
transition: 0.35s;
cursor: pointer;
border: 1px solid #eee;
border-radius: 6px;
padding: 10px;
margin-bottom: 10px;
background: #fff;
color: #5a606b;
box-sizing: border-box;
transition: border-color .2s;
&: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 {
width: 0px !important;
}
.order-section {
margin: 10px 0;
font-size: 12px;
img {
width: 60px;
height: 60px;
}
}
.order-footer {
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 {
.order-foot {
display: flex;
justify-content: flex-end;
position: relative;
bottom: 55px;
margin-top: 8px;
}
.goods_name {
@@ -300,7 +293,7 @@ onMounted(() => {
:deep(.el-tabs__header ) {
position: absolute;
width: 362px;
width: 100%;
height: 50px;
left: 0;
z-index: 2;
@@ -329,10 +322,6 @@ onMounted(() => {
// width: 350px;
}
.order-padding {
padding: 0 10px;
}
.base {
padding: 8px;
cursor: pointer;
@@ -354,12 +343,12 @@ onMounted(() => {
}
}
.base:hover,.order-list:hover{
.base:hover{
background: #ededed;
padding-left: 14px;
>.review{
opacity: 1;
}
}
.goods-price{
@@ -371,9 +360,6 @@ onMounted(() => {
.separate {
margin-top: 8px;
}
.order-sn{
font-size: 12px;
}
// .el-tabs--card {
// height: calc(100vh - 110px);
// overflow-y: auto;
@@ -383,17 +369,54 @@ onMounted(() => {
// overflow-y: auto;
// }
.base-list{
padding-bottom: 120px;
padding: 10px 10px 12px;
}
.base-order-list{
padding-bottom: 100px;
}
.price{
color: red;
margin-left: 10px;
margin-bottom: 10px;
padding: 10px 10px 12px;
}
.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>

View File

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

View File

@@ -1,100 +1,157 @@
<template>
<div style="width: 362px;">
<div class="store-card">
<div class="base">
<div>
<img :src="storeInfo.storeLogo" class="image" />
</div>
<img :src="storeInfo.storeLogo" :onerror="onError" class="image" alt="" />
<div class="store-detail">
<div>
<div class="div-zoom">
<span class="title_left_name"> 店铺名称:</span> <span class="title_right_name">{{ storeInfo.storeName }}</span>
<el-tag style="margin-left: 10px;" type="danger" v-if="storeInfo.selfOperated" size="small">自营</el-tag>
<div class="store-name">
<span class="name-text" :title="storeInfo.storeName">{{ storeInfo.storeName }}</span>
<el-tag type="danger" v-if="storeInfo.selfOperated" size="small">自营</el-tag>
</div>
<div>
<span class="title_left_name"> 联系方式:</span> <span class="title_right_name">{{ storeInfo.memberName }}</span>
</div>
</div>
<div>
<el-button class="store-button" @click="linkToStore(storeInfo.id)" size="small"
plain>进入店铺</el-button>
<div class="store-actions">
<el-button @click="linkToStore(storeInfo.id)" size="small" plain>进入店铺</el-button>
</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="separate">店铺评分: <el-rate v-model="storeInfo.serviceScore" disabled show-score text-color="#ff9900"
score-template="{value}">
</el-rate></div>
<div class="separate">服务评分: <el-rate v-model="storeInfo.descriptionScore" disabled show-score text-color="#ff9900"
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 class="rate-row" v-for="row in rates" :key="row.label">
<span class="rate-label">{{ row.label }}</span>
<el-rate :model-value="row.score" disabled show-score text-color="#ff9900" score-template="{value}" />
</div>
</div>
</div>
</template>
<script setup>
import { ref } from "vue";
import { computed } from "vue";
import { defaultAvatar } from "@/assets/images";
defineProps({
const props = defineProps({
storeInfo: {
type: Object,
default: null,
default: () => ({}),
},
});
const value = ref(null);
const colors = ref(['#99A9BF', '#F7BA2A', '#FF9900']);
const onError = "this.src='" + defaultAvatar + "'";
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>
<style scoped lang="less">
.wrapper{
.store-card {
width: 100%;
box-sizing: border-box;
}
.base {
padding: 16px;
border-top: 1px solid #f5eeee;
}
.title_left_name {
font-size: 12px;
color: #999;
display: flex;
.image {
height: 72px;
width: 72px;
flex-shrink: 0;
border-radius: 6px;
object-fit: cover;
background: #fafafa;
}
}
.title_right_name {
font-size: 12px;
color: #343036;
}
.store-button {
background-color: white;
}
.store-detail{
.store-detail {
flex: 1;
min-width: 0;
margin-left: 13px;
display:flex;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.base {
padding: 16px;
margin-top: 5px;
.store-name {
display: flex;
.image {
height: 100px;
align-items: center;
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 {
margin-top: 8px;
// display: inline;
.wrapper {
padding: 12px 16px 16px;
border-top: 1px solid #f5f5f5;
}
.rate-row {
display: flex;
font-size: 12px;
align-items: center;
margin-top: 8px;
color: #a1a6af;
&: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>
<img :src="'https://avatars.dicebear.com/api/initials/'+text+'.svg?fontSize=38'" alt=""/>
<i class="face-initial" :style="{ backgroundColor: color }">{{ initial }}</i>
</template>
<script setup>
defineProps({
text:{
type:null,
default:''
}
})
import { computed } from "vue";
import { avatarColor } from "@/utils/functions";
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>
<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>

View File

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

View File

@@ -273,22 +273,17 @@ class WsSocket {
*/
onMessage(evt) {
let result = this.onParse(evt);
if (
this.onParse(evt).text.includes("goodsName") ||
this.onParse(evt).text.includes("groupName")
) {
let params = {
...this.onParse(evt),
text: JSON.parse(this.onParse(evt).text),
};
this.onCallBacks["event_talk"](params);
} else {
let params = {
...this.onParse(evt),
text: this.onParse(evt).text,
};
this.onCallBacks["event_talk"](params);
}
// 部分推送(在线状态、系统通知等)没有 text 字段,不做商品/群名解析直接透传
const text = result.text;
let params = {
...result,
text:
typeof text === "string" &&
(text.includes("goodsName") || text.includes("groupName"))
? JSON.parse(text)
: text,
};
this.onCallBacks["event_talk"](params);
// 指定推送消息
}
/**

View File

@@ -390,6 +390,19 @@ export function hidePhone (phone) {
return phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2");
}
/**
* 根据名字生成稳定的头像底色(本地首字头像用,替代 dicebear 外链)
*
* @param {String} name
*/
export function avatarColor (name = "") {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
}
return `hsl(${hash % 360}, 55%, 55%)`;
}
/**
* 人性化显示时间
*
@@ -409,15 +422,15 @@ export function beautifyTime (datetime = "") {
}
if (time.getTime() < outTime.getTime()) {
return parseTime(outTime, "{y}/{m}/{d}");
return parseTime(outTime, "{y}-{m}-{d}");
}
if (time.getFullYear() != outTime.getFullYear()) {
return parseTime(outTime, "{y}/{m}/{d}");
return parseTime(outTime, "{y}-{m}-{d}");
}
if (time.getMonth() != outTime.getMonth()) {
return parseTime(outTime, "{m}/{d}");
return parseTime(outTime, "{m}-{d}");
}
if (time.getDate() != outTime.getDate()) {

View File

@@ -6,7 +6,7 @@
<el-header height="118px" class="logo-header">
<div class="user-login" v-popover:usercard>
<div class="user-box">
<face :text="face"></face>
<face :text="userFace"></face>
</div>
</div>
<p class="user-status">
@@ -66,7 +66,8 @@ const userStore = useUserStore();
const notifyStore = useNotifyStore();
const settingsStore = useSettingsStore();
const face = computed(() => userStore.face);
// 不能命名为 face:会遮蔽 <face> 组件解析,头像 URL 会被当作标签名渲染
const userFace = computed(() => userStore.face);
const name = computed(() => userStore.name);
const id = computed(() => userStore.id);
const defaultAvatar = computed(() => appStore.defaultAvatar);
@@ -107,8 +108,8 @@ function setApplyNum () {
<style lang="less" >
.main-layout {
position: fixed;
width: 80%;
height: 80%;
width: 88%;
height: 92%;
top: 0;
right: 0;
bottom: 0;
@@ -119,8 +120,8 @@ function setApplyNum () {
border-radius: 5px;
&.full-mode {
width: 1200px;
height: 85%;
width: 92%;
height: 96%;
border-radius: 0;
}

View File

@@ -10,7 +10,7 @@
<el-header height="60px" class="header">
<div class="user-login">
<div class="user-box">
<face :text="face" :name="name" class="user-face"></face>
<face :text="userFace" :name="name" class="user-face"></face>
</div>
</div>
<p class="user-status">
@@ -20,7 +20,7 @@
</el-header>
<el-header height="60px" class="header">
<div class="from-search">
<el-input v-model="input" :prefix-icon="$legacyIcon('el-icon-search')" placeholder="搜索好友" size="small" />
<el-input v-model="input" :prefix-icon="$legacyIcon('el-icon-search')" placeholder="搜索好友" size="small" clearable />
</div>
</el-header>
<!-- <el-header height="118px" class="logo-header">
@@ -58,6 +58,10 @@
暂无聊天消息
</p>
<p v-show="loadStatus === 1 && talkNum > 0 && userTalkItem.length === 0" class="empty-data">
没有找到相关好友
</p>
<p v-show="loadStatus === 1 && talkNum > 0" class="main-menu">
<span class="title">对话记录({{ talkNum }})</span>
@@ -108,7 +112,7 @@
</div>
</div>
<div class="talk-message">
<span v-if="item.lastMessageType === 'MESSAGE'">{{ item.lastTalkMessage }}</span>
<span v-if="item.lastMessageType === 'MESSAGE'" v-html="textReplaceEmoji(item.lastTalkMessage)"></span>
<span v-if="item.lastMessageType === 'GOODS'">[商品链接]</span>
<span v-if="item.lastMessageType === 'ORDER'">[订单链接]</span>
</div>
@@ -156,6 +160,7 @@ import {
getCurrentInstance,
nextTick,
onBeforeUnmount,
onMounted,
ref,
watch,
} from "vue";
@@ -174,6 +179,7 @@ import {
} from "@/api/chat";
import { ServeDeleteContact, ServeEditContactRemark } from "@/api/contacts";
import { beautifyTime } from "@/utils/functions";
import { textReplaceEmoji } from "@/utils/emojis";
import { formatTalkItem, findTalkIndex, getCacheIndexName } from "@/utils/talk";
import { setToken } from "@/utils/auth";
import { syncImTokenFromRoute } from "@/utils/im-mode";
@@ -243,7 +249,9 @@ const unreadNum = computed(() => talkStore.unreadNum);
const talkNum = computed(() => talkStore.talkNum);
// 原 vuex mapState
const face = computed(() => userStore.face);
// 注意:不能命名为 face——会与全局注册的 <face> 组件绑定冲突,
// 模板里的 <face> 标签会被解析成这个 computed 的值(头像 base64 字符串)当作标签名渲染
const userFace = computed(() => userStore.face);
const name = computed(() => userStore.name);
const talks = computed(() => talkStore.items);
const id = computed(() => userStore.id);
@@ -655,10 +663,11 @@ watch(talkItems, (val) => {
});
// 搜索用户的时候 根据当前用户表进行模糊搜索
watch(input, (val, oldVal) => {
console.log(val, oldVal);
watch(input, (val) => {
if (val) {
let str = ["", ...val, ""].join(".*");
// 转义正则元字符,避免输入 [ 等字符时构造 RegExp 抛异常
const keyword = val.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
let str = ["", ...keyword, ""].join(".*");
let reg = new RegExp(str);
userTalkItem.value = talkItems.value.filter((item) =>
reg.test(item.name)
@@ -703,8 +712,10 @@ onBeforeRouteUpdate((to, from, next) => {
// 原 beforeCreate
syncImTokenFromRoute(route, setToken);
// 原 async created(保持 await 顺序,但用 IIFE 避免 setup 变为异步)
(async () => {
// 原 async created。不能在 setup 同步执行:全局 mixin 的方法(initialize/
// loadUserSetting)在 finishComponentSetup 的 applyOptions 阶段才挂到公共代理上,
// 即 setup() 返回之后。故放到 onMounted 里执行。
onMounted(async () => {
await proxy.initialize();
await proxy.loadUserSetting();
// 初始化商品参数
@@ -714,7 +725,7 @@ syncImTokenFromRoute(route, setToken);
* 所以创建会话 并请求用户列表
* 如果没有id说明当前商家登录 直接请求用户列表
*/
})();
});
onBeforeUnmount(() => {
document.title = title;
@@ -747,8 +758,20 @@ onBeforeUnmount(() => {
flex-shrink: 0;
height: 40px;
:deep(.el-input .el-input__inner ) {
border-radius: 20px;
:deep(.el-input__wrapper) {
border-radius: 16px;
background-color: #f5f6f7;
box-shadow: none;
transition: box-shadow 0.2s;
&.is-focus {
background-color: #fff;
box-shadow: 0 0 0 1px #409eff inset;
}
}
:deep(.el-input__inner) {
font-size: 12px;
}
}
@@ -1058,6 +1081,12 @@ onBeforeUnmount(() => {
.talk-message {
font-size: 12px;
color: #8f959e;
:deep(img) {
width: 16px;
height: 16px;
vertical-align: middle;
}
}
.user-face {

View File

@@ -138,3 +138,19 @@ export const getDistributionCash = (params) => {
export const auditDistributionCash = (id,params) => {
return postRequest(`/distribution/cash/audit/${id}`, params)
}
// 兼容别名导出
export const getDistributionCustomerPage = (params) => getCustomerRelations(params);
export const getDistributionBindLogPage = (params) => {
if (params && params.id) {
return getCustomerRelationLogs(params.id);
}
return getRequest('/distribution/customerRelation/logs', params);
};
export const rebindDistributionCustomer = (params) => {
return rebindCustomerRelation({
memberId: params.memberId,
distributionId: params.distributionId,
reason: params.reason || params.remark
});
};

View File

@@ -1,6 +1,10 @@
// 统一请求路径前缀在libs/axios.js中修改
import { getRequest, postRequest, putRequest, deleteRequest} from '@/libs/axios';
export const cancelGoodsMarketSchedule = (goodsId, status) => {
return deleteRequest(`/goods/goods/schedule/market/${goodsId}`, { status });
};
// 获取商品品牌分页列表
export const getManagerBrandPage = (params) => {
return getRequest('/goods/brand/getByPage', params)

View File

@@ -170,6 +170,10 @@ export const deleteMemberGroup = (id) => {
return deleteRequest(`/member/memberGroup/delete/${id}`);
};
export const getMemberGroupUsers = (groupId, params) => {
return getRequest(`/member/memberGroup/${groupId}/users`, params);
};
export const addMemberGroupUsers = (groupId, memberIds) => {
return postRequest(`/member/memberGroup/${groupId}/users`, {
memberIds: Array.isArray(memberIds) ? memberIds.join(",") : memberIds,

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