22 Commits

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

40
buyer/pnpm-lock.yaml generated
View File

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

View File

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

View File

@@ -6,7 +6,8 @@ import request, {
export function shopList (params) { export function shopList (params) {
return request({ return request({
url: '/buyer/store/store', url: '/buyer/store/store',
needToken: true, needToken: false,
optionalToken: true,
method: Method.GET, method: Method.GET,
params params
}) })
@@ -52,6 +53,16 @@ export function getStoreById (id) {
} }
// 店铺详情 // 店铺详情
// 获取店铺工商/营业执照信息
export function getLicencePhoto (id) {
return request({
url: `/buyer/store/store/get/licencePhoto/${id}`,
needToken: false,
optionalToken: true,
method: Method.GET
})
}
export function getDetailById (id) { export function getDetailById (id) {
return request({ return request({
url: `/buyer/store/store/get/detail/${id}`, url: `/buyer/store/store/get/detail/${id}`,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,16 @@
<template> <template>
<div class="model-form"> <div class="model-form" :style="pageContainerStyle">
<div class="model-content"> <!-- Version 2: 新万米行模型渲染 -->
<div v-if="isVersion2" class="v2-rows-container">
<row-wrapper
v-for="row in v2Rows"
:key="row.id"
:row="row"
/>
</div>
<!-- 旧版降级渲染 -->
<div v-else class="model-content">
<model-form-item <model-form-item
v-for="(element, index) in renderList" v-for="(element, index) in renderList"
:key="element.key" :key="element.key"
@@ -11,14 +21,34 @@
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import ModelFormItem from './ModelFormItem.vue'; import ModelFormItem from './ModelFormItem.vue';
import RowWrapper from './RowWrapper.vue';
defineOptions({ name: 'modelForm' }); defineOptions({ name: 'modelForm' });
const props = defineProps(['data']); const props = defineProps(['data']);
// 是否为 v2 行模型数据
const isVersion2 = computed(() => {
return (props.data?.version === 2 || props.data?.version === '2.0') && Array.isArray(props.data?.list);
});
const v2Rows = computed(() => {
return props.data?.list || [];
});
const pageContainerStyle = computed(() => {
if (isVersion2.value && props.data?.pageStyle?.backgroundColor) {
return {
backgroundColor: props.data.pageStyle.backgroundColor
};
}
return {};
});
const renderList = computed(() => { const renderList = computed(() => {
const list = props.data?.list || []; const list = props.data?.list || [];
return list.filter( return list.filter(
@@ -27,21 +57,26 @@ const renderList = computed(() => {
}); });
function isBuiltinModule(type) { function isBuiltinModule(type) {
// 顶部广告、快捷导航由 Index 页单独渲染
return type === 'topAdvert' || type === 'navBar'; return type === 'topAdvert' || type === 'navBar';
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.model-form { .model-form {
position: relative; position: relative;
z-index: 0; z-index: 0;
width: 100%; width: 100%;
} }
.v2-rows-container {
width: 100%;
min-height: 400px;
}
.model-content { .model-content {
width: 100%; width: 100%;
min-height: 1200px; min-height: 1200px;
box-sizing: border-box; box-sizing: border-box;
/* 勿设 overflow-x: hidden,会裁切 discountAdvert 的 margin-left: -100px 左溢背景 */
overflow-x: visible; overflow-x: visible;
} }
</style> </style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
<template> <template>
<div class="merchant"> <div class="merchant">
<BaseHeader/> <TopBar></TopBar>
<!-- 搜索栏 --> <!-- 搜索栏 -->
<Search :storeId="storeMsg.storeId || $route.query.id" @search="search"></Search> <SearchHeader @search="search"></SearchHeader>
<div class="store-head"> <div class="store-head">
<div class="store-head-main"> <div class="store-head-main">
<div class="shop-info"> <div class="shop-info">
@@ -87,10 +87,6 @@
<span class="seckill-price text-danger">{{ $filters.unitPrice(item.price, "¥") }}</span> <span class="seckill-price text-danger">{{ $filters.unitPrice(item.price, "¥") }}</span>
</div> </div>
<div class="goods-show-detail"> <div class="goods-show-detail">
<el-tag
v-if="item.salesModel === 'WHOLESALE'"
class="goods-show-tag goods-show-tag-wholesale"
>批发</el-tag>
<span>{{ item.goodsName }}</span> <span>{{ item.goodsName }}</span>
</div> </div>
<div class="goods-show-num"> <div class="goods-show-num">
@@ -133,7 +129,8 @@ import {
isStoreCollection, isStoreCollection,
} from "@/api/member"; } from "@/api/member";
import {goodsList as goodsListApi} from "@/api/goods"; import {goodsList as goodsListApi} from "@/api/goods";
import Search from "@/components/Search"; import TopBar from '@/components/indexDecorate/components/TopBar.vue';
import SearchHeader from '@/components/indexDecorate/components/SearchHeader.vue';
import ModelForm from "@/components/indexDecorate/ModelForm"; import ModelForm from "@/components/indexDecorate/ModelForm";
import storage from "@/plugins/storage"; import storage from "@/plugins/storage";
import {getFloorStoreData} from "@/api/index.js"; import {getFloorStoreData} from "@/api/index.js";
@@ -719,15 +716,6 @@ window.onscroll = () => {
color: #fff; color: #fff;
} }
.goods-show-tag-wholesale {
--el-tag-bg-color: #f9f0ff;
--el-tag-border-color: #d3adf7;
--el-tag-text-color: #9254de;
background-color: #f9f0ff;
border-color: #d3adf7;
color: #9254de;
}
.goods-page { .goods-page {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,53 @@
<template> <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> </template>
<script setup> <script setup>
const props = defineProps({ import { computed } from "vue";
text: { import { avatarColor } from "@/utils/functions";
type: null, import { defaultAvatar } from "@/assets/images";
default: ''
}, const props = defineProps({
name: { text: {
type: null, type: null,
default: '' 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> </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) { onMessage(evt) {
let result = this.onParse(evt); let result = this.onParse(evt);
if ( // 部分推送(在线状态、系统通知等)没有 text 字段,不做商品/群名解析直接透传
this.onParse(evt).text.includes("goodsName") || const text = result.text;
this.onParse(evt).text.includes("groupName") let params = {
) { ...result,
let params = { text:
...this.onParse(evt), typeof text === "string" &&
text: JSON.parse(this.onParse(evt).text), (text.includes("goodsName") || text.includes("groupName"))
}; ? JSON.parse(text)
this.onCallBacks["event_talk"](params); : text,
} else { };
let params = { this.onCallBacks["event_talk"](params);
...this.onParse(evt),
text: this.onParse(evt).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"); 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()) { if (time.getTime() < outTime.getTime()) {
return parseTime(outTime, "{y}/{m}/{d}"); return parseTime(outTime, "{y}-{m}-{d}");
} }
if (time.getFullYear() != outTime.getFullYear()) { if (time.getFullYear() != outTime.getFullYear()) {
return parseTime(outTime, "{y}/{m}/{d}"); return parseTime(outTime, "{y}-{m}-{d}");
} }
if (time.getMonth() != outTime.getMonth()) { if (time.getMonth() != outTime.getMonth()) {
return parseTime(outTime, "{m}/{d}"); return parseTime(outTime, "{m}-{d}");
} }
if (time.getDate() != outTime.getDate()) { if (time.getDate() != outTime.getDate()) {

View File

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

View File

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

View File

@@ -138,3 +138,19 @@ export const getDistributionCash = (params) => {
export const auditDistributionCash = (id,params) => { export const auditDistributionCash = (id,params) => {
return postRequest(`/distribution/cash/audit/${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,11 +1,16 @@
import { managerApiUrl, getRequest, postRequest } from '@/libs/axios'; import { managerApiUrl, getRequest, postRequest, putRequestWithNoForm } from '@/libs/axios';
// 获取定时任务列表 // 获取定时任务列表
export const getTimedTaskList = () => { export const getTimedTaskList = () => {
return getRequest(`${managerApiUrl}/manager/setting/timed-task`); return getRequest(`${managerApiUrl}/manager/setting/timed-task`);
}; };
// 手动触发定时任务 // 修改定时任务(Cron表达式、启用/暂停状态)
export const triggerTimedTask = (type) => { export const updateTimedTask = (params) => {
return postRequest(`${managerApiUrl}/manager/setting/timed-task/trigger/${type}`); return putRequestWithNoForm(`${managerApiUrl}/manager/setting/timed-task`, params);
};
// 手动触发定时任务
export const triggerTimedTask = (taskId) => {
return postRequest(`${managerApiUrl}/manager/setting/timed-task/trigger/${taskId}`);
}; };

View File

@@ -189,7 +189,7 @@ function syncTableSelection() {
return; return;
} }
goodsData.value.forEach((item) => { goodsData.value.forEach((item) => {
const selected = selectedList.value.some((e) => e.id && e.id === item.id); const selected = selectedList.value.some((e) => (e.id || e.skuId) && String(e.id || e.skuId) === String(item.id || item.skuId));
table.toggleRowSelection(item, selected); table.toggleRowSelection(item, selected);
}); });
} }

View File

@@ -26,17 +26,16 @@
</template> </template>
</el-dialog> </el-dialog>
<el-drawer <el-dialog
v-else v-else
v-model="flag" v-model="flag"
class="link-selector-drawer" width="1160px"
direction="rtl" top="80px"
size="1160px"
:with-header="false"
:z-index="10000" :z-index="10000"
:close-on-click-modal="false" :close-on-click-modal="false"
append-to-body append-to-body
destroy-on-close destroy-on-close
class="link-selector-dialog"
@close="clickClose" @close="clickClose"
> >
<linkDialog <linkDialog
@@ -52,7 +51,7 @@
<el-button @click="clickClose">取消</el-button> <el-button @click="clickClose">取消</el-button>
<el-button type="primary" @click="clickOK">确定</el-button> <el-button type="primary" @click="clickOK">确定</el-button>
</template> </template>
</el-drawer> </el-dialog>
</template> </template>
<script setup> <script setup>
import { ref } from "vue"; import { ref } from "vue";

View File

@@ -51,6 +51,11 @@ import {
Star, Star,
Document, Document,
User, User,
UserFilled,
Service,
Ticket,
Bell,
OfficeBuilding,
Promotion, Promotion,
PriceTag, PriceTag,
Sunny, Sunny,
@@ -61,27 +66,33 @@ import {
ArrowRight, ArrowRight,
Check, Check,
} from "@element-plus/icons-vue"; } from "@element-plus/icons-vue";
import { ElMessageBox } from "element-plus";
const emit = defineEmits(["selected"]); const emit = defineEmits(["selected"]);
const route = useRoute(); const route = useRoute();
const linkList = ref([ const linkList = ref([
{ title: "首页", icon: markRaw(House), ___type: "home" }, { title: "首页", icon: markRaw(House), url: "/", ___type: "home" },
{ title: "购物车", icon: markRaw(ShoppingCart), ___type: "cart" }, { title: "购物车", icon: markRaw(ShoppingCart), url: "/cart", ___type: "cart" },
{ title: "收藏商品", icon: markRaw(Star), ___type: "collection" }, { title: "我的订单", icon: markRaw(Document), url: "/home/MyOrder", ___type: "order" },
{ title: "我的订单", icon: markRaw(Document), ___type: "order" }, { title: "个人中心", icon: markRaw(User), url: "/home", ___type: "user" },
{ title: "个人中心", icon: markRaw(User), ___type: "user" }, { title: "基本信息", icon: markRaw(UserFilled), url: "/home/Profile", ___type: "userInfo" },
{ title: "拼团频道", icon: markRaw(Promotion), ___type: "group" }, { title: "我的售后", icon: markRaw(Service), url: "/home/AfterSale", ___type: "afterSale" },
{ title: "秒杀频道", icon: markRaw(Promotion), ___type: "seckill" }, { title: "我的优惠券", icon: markRaw(Ticket), url: "/home/Coupons", ___type: "myCoupons" },
{ title: "领券中心", icon: markRaw(PriceTag), ___type: "coupon" }, { title: "收藏商品", icon: markRaw(Star), url: "/home/Favorites", ___type: "collection" },
{ title: "签到", icon: markRaw(Sunny), ___type: "sign" }, { title: "消息中心", icon: markRaw(Bell), url: "/home/MsgList", ___type: "msg" },
{ title: "小程序直播", icon: markRaw(VideoCamera), ___type: "live" }, { title: "商家入驻", icon: markRaw(OfficeBuilding), url: "/shopEntry", ___type: "merchantEntry" },
{ title: "砍价", icon: markRaw(Share), ___type: "kanjia" }, { title: "领券中心", icon: markRaw(PriceTag), url: "/coupon", ___type: "coupon" },
{ title: "积分商城", icon: markRaw(ShoppingBag), ___type: "point" }, { title: "秒杀频道", icon: markRaw(Promotion), url: "/seckill", ___type: "seckill" },
{ title: "拼团频道", icon: markRaw(Promotion), url: "/group", ___type: "group" },
{ title: "签到", icon: markRaw(Sunny), url: "/sign", ___type: "sign" },
{ title: "小程序直播", icon: markRaw(VideoCamera), url: "/live", ___type: "live" },
{ title: "砍价", icon: markRaw(Share), url: "/kanjia", ___type: "kanjia" },
{ title: "积分商城", icon: markRaw(ShoppingBag), url: "/point", ___type: "point" },
]); ]);
const linkItem = ref({ const linkItem = ref({
title: "外部链接", title: "自定义链接",
icon: markRaw(Link), icon: markRaw(Link),
___type: "link", ___type: "link",
url: "", url: "",
@@ -110,13 +121,37 @@ function showLinkItem(item) {
); );
} }
function handleLink(val, index) { function handleLink(val, index) {
val = { ...val, ___type: "other" }; if (val.title === "自定义链接" || val.title === "外部链接") {
ElMessageBox.prompt("请输入链接地址(支持站内相对路径如 /cart 或外部网址如 https://...):", "自定义链接", {
confirmButtonText: "确定",
cancelButtonText: "取消",
inputValue: val.url || "",
inputPlaceholder: "例如 /cart 或 https://...",
inputPattern: /^\S+$/,
inputErrorMessage: "链接地址不能为空",
})
.then(({ value }) => {
const itemData = {
...val,
title: "自定义链接",
url: value,
originalType: "link",
___type: "other",
};
selectedIndex.value = index;
emit("selected", [itemData]);
})
.catch(() => {});
return;
}
const itemData = {
...val,
originalType: val.___type,
___type: "other",
};
selectedIndex.value = index; selectedIndex.value = index;
emit("selected", [val]); emit("selected", [itemData]);
} }
// 无 defineExpose:grep manager/src 全量确认无外部 ref/$refs/$parent 访问本组件成员
// (link-dialog.vue 的 $refs["lili-component"][0].type 写入指向首个 tab 的 goods-dialog,与本组件无关)
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import "../style.scss"; @import "../style.scss";

View File

@@ -54,7 +54,9 @@
<el-dialog <el-dialog
v-model="picModalFlag" v-model="picModalFlag"
title="选择图片" title="选择图片"
width="1200px" width="1160px"
top="16px"
class="pc-image-picker-dialog"
append-to-body append-to-body
destroy-on-close destroy-on-close
@closed="resetPicModal" @closed="resetPicModal"

View File

@@ -21,6 +21,16 @@ export const floorDecorationRouter = {
component: () => import("@/views/page-decoration/wap/main.vue") component: () => import("@/views/page-decoration/wap/main.vue")
}; };
// PC端楼层装修编辑器:全屏独立页,不在 Main(左侧菜单)布局内
export const pcFloorDecorationRouter = {
path: "/floorList/renovation",
name: "renovation",
meta: {
title: "PC楼层装修"
},
component: () => import("@/views/page-decoration/pc/index.vue")
};
// 作为Main组件的子页面展示但是不在左侧菜单显示的路由写在otherRouter里 // 作为Main组件的子页面展示但是不在左侧菜单显示的路由写在otherRouter里
export const otherRouter = { export const otherRouter = {
path: "/", path: "/",
@@ -32,6 +42,7 @@ export const otherRouter = {
path: "home", path: "home",
title: '首页', title: '首页',
name: "home_index", name: "home_index",
meta: { firstRouterName: "home_index" },
component: () => import("@/views/home/home.vue") component: () => import("@/views/home/home.vue")
}, },
{ {
@@ -359,12 +370,7 @@ export const otherRouter = {
name: "seckill-goods", name: "seckill-goods",
component: () => import("@/views/promotions/seckill/seckill-goods.vue") component: () => import("@/views/promotions/seckill/seckill-goods.vue")
}, },
{
path: "/floorList/renovation",
title: "编辑模板",
name: "renovation",
component: () => import("@/views/page-decoration/renovation.vue")
},
{ {
path: "add-sms-sign", path: "add-sms-sign",
title: "短信签名", title: "短信签名",
@@ -549,4 +555,4 @@ export const externalLink = {
}; };
// 所有上面定义的路由都要写在下面的routers里 // 所有上面定义的路由都要写在下面的routers里
export const routers = [loginRouter, floorDecorationRouter, otherRouter, page500, page403, externalLink]; export const routers = [loginRouter, floorDecorationRouter, pcFloorDecorationRouter, otherRouter, page500, page403, externalLink];

View File

@@ -271,6 +271,10 @@ export function formatLinkType (item) {
break; break;
case 'other': case 'other':
if (item.url) {
url = item.url;
break;
}
switch (item.title) { switch (item.title) {
case '首页': case '首页':
url = '/'; url = '/';
@@ -281,13 +285,29 @@ export function formatLinkType (item) {
case '我的订单': case '我的订单':
url = '/home/MyOrder'; url = '/home/MyOrder';
break; break;
case '收藏商品':
url = '/home/Favorites';
break;
case '个人中心': case '个人中心':
url = '/home'; url = '/home';
break; break;
case '基本信息':
url = '/home/Profile';
break;
case '我的售后':
url = '/home/AfterSale';
break;
case '我的优惠券':
url = '/home/Coupons';
break;
case '收藏商品':
url = '/home/Favorites';
break;
case '消息中心':
url = '/home/MsgList';
break;
case '商家入驻':
url = '/shopEntry';
break;
case '外部链接': case '外部链接':
case '自定义链接':
url = item.url; url = item.url;
break; break;
case '秒杀频道': case '秒杀频道':

View File

@@ -84,3 +84,7 @@ export const orderStatusList = [
label:'提现完成' label:'提现完成'
} }
] ]
// 兼容别名导出
export const bindStatusList = relationStatusList;
export const bindChangeTypeList = customerRelationChangeTypeList;

View File

@@ -171,9 +171,12 @@
<script setup> <script setup>
import { ref, reactive, onMounted } from "vue"; import { ref, reactive, onMounted } from "vue";
import { import {
getCustomerRelations,
getCustomerRelationLogs,
getDistributionCustomerPage, getDistributionCustomerPage,
getDistributionBindLogPage, getDistributionBindLogPage,
getDistributionListData, getDistributionListData,
rebindCustomerRelation,
rebindDistributionCustomer, rebindDistributionCustomer,
} from "@/api/distribution"; } from "@/api/distribution";
import { bindStatusList, bindSourceList, bindChangeTypeList } from "./dataJson"; import { bindStatusList, bindSourceList, bindChangeTypeList } from "./dataJson";

View File

@@ -17,10 +17,10 @@
style="width: 240px" style="width: 240px"
/> />
</el-form-item> </el-form-item>
<el-form-item label="商品ID" prop="id"> <el-form-item label="SPU编码" prop="id">
<el-input <el-input
v-model="searchForm.id" v-model="searchForm.id"
placeholder="请输入商品ID" placeholder="请输入SPU编码"
clearable clearable
style="width: 240px" style="width: 240px"
/> />
@@ -33,17 +33,6 @@
style="width: 240px" style="width: 240px"
/> />
</el-form-item> </el-form-item>
<el-form-item label="销售模式" prop="salesModel">
<el-select
v-model="searchForm.salesModel"
placeholder="请选择"
clearable
style="width: 240px"
>
<el-option label="零售" value="RETAIL" />
<el-option label="批发" value="WHOLESALE" />
</el-select>
</el-form-item>
<el-form-item label="商品类型" prop="goodsType"> <el-form-item label="商品类型" prop="goodsType">
<el-select <el-select
v-model="searchForm.goodsType" v-model="searchForm.goodsType"
@@ -137,7 +126,7 @@
@selection-change="onSelectionChange" @selection-change="onSelectionChange"
> >
<el-table-column type="selection" width="100" align="center" /> <el-table-column type="selection" width="100" align="center" />
<el-table-column prop="id" label="商品ID" width="200" show-overflow-tooltip /> <el-table-column prop="id" label="SPU编码" width="200" show-overflow-tooltip />
<el-table-column label="商品图片" width="100" align="center"> <el-table-column label="商品图片" width="100" align="center">
<template #default="{ row }"> <template #default="{ row }">
<img alt="商品图片" <img alt="商品图片"
@@ -159,11 +148,6 @@
</el-table-column> </el-table-column>
<el-table-column prop="buyCount" label="销量" width="150" /> <el-table-column prop="buyCount" label="销量" width="150" />
<el-table-column prop="quantity" label="库存" width="150" /> <el-table-column prop="quantity" label="库存" width="150" />
<el-table-column label="销售模式" width="150">
<template #default="{ row }">
<span v-if="row">{{ salesModelText(row.salesModel) }}</span>
</template>
</el-table-column>
<el-table-column label="商品类型" width="150"> <el-table-column label="商品类型" width="150">
<template #default="{ row }"> <template #default="{ row }">
<span v-if="row">{{ goodsTypeText(row.goodsType) }}</span> <span v-if="row">{{ goodsTypeText(row.goodsType) }}</span>
@@ -344,7 +328,6 @@ const { loading, data, total, searchForm, getDataList, handleSearch, resetSearch
goodsName: "", goodsName: "",
id: "", id: "",
storeName: "", storeName: "",
salesModel: "",
goodsType: "", goodsType: "",
groupId: "", groupId: "",
}, },
@@ -403,11 +386,6 @@ function onStatusTabClick(tab) {
const status = tab.paneName ?? tab.props?.name; const status = tab.paneName ?? tab.props?.name;
goodsStatusClick(status); goodsStatusClick(status);
} }
function salesModelText(v) {
if (v === "RETAIL") return "零售";
if (v === "WHOLESALE") return "批发";
return "其他类型";
}
function goodsTypeText(v) { function goodsTypeText(v) {
if (v === "PHYSICAL_GOODS") return "实物商品"; if (v === "PHYSICAL_GOODS") return "实物商品";
if (v === "VIRTUAL_GOODS") return "虚拟商品"; if (v === "VIRTUAL_GOODS") return "虚拟商品";

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