feat(buyer): 增强搜索功能并提升用户体验

- 新增 `getStoreById` 接口方法,用于获取包含评分在内的店铺信息
- `Search.vue` 新增搜索建议面板,展示搜索历史和热词,优化搜索体验
- 新增 `SearchSuggest.vue` 组件,用于展示搜索建议和搜索历史
- `App.vue` 根据路由元信息按条件设置页面标题
- 优化多个组件的布局与样式,让整体风格更统一
- 重构多个组件的跳转逻辑,改用路由跳转(`router.push`)替代新开窗口(`window.open`),提升用户体验
This commit is contained in:
pikachu1995@126.com
2026-10-02 15:26:58 +08:00
parent 4777eb2dd6
commit 8410179885
44 changed files with 2116 additions and 674 deletions

View File

@@ -23,7 +23,9 @@ export default {
storage.setItem("siteIcon", data.buyerSideIcon);
this.$store.commit("SET_SITENAME", data.siteName);
this.$store.commit("SET_LOGOIMG", data.buyerSideLogo);
window.document.title = data.siteName;
if (this.$route.meta.title === undefined) {
window.document.title = data.siteName;
}
let link =
document.querySelector("link[rel*='icon']") ||
document.createElement("link");

View File

@@ -42,6 +42,15 @@ export function applyThird (params) {
})
}
// 店铺信息(含评分)
export function getStoreById (id) {
return request({
url: `/buyer/store/store/store/${id}`,
needToken: false,
method: Method.GET
})
}
// 店铺详情
export function getDetailById (id) {
return request({

View File

@@ -7,11 +7,24 @@
size="large"
class="search-input"
placeholder="输入你想查找的商品"
@focus="openPanel"
@input="queueSuggest"
@keyup.enter="search"
/>
<div class="search-btn" @click="search">
<el-icon :size="21"><SearchIcon /></el-icon>
</div>
<div v-if="showPanel" class="suggest-panel" @mousedown.prevent>
<SearchSuggest
:keyword="trimmedKeyword"
:history="history"
:hot-words="promotionTags"
:suggestions="suggestions"
:suggesting="suggesting"
@pick="selectTags"
@clear="clearHistory"
/>
</div>
</div>
</template>
<template v-else>
@@ -30,11 +43,24 @@
size="large"
class="search-input"
placeholder="输入你想查找的商品"
@focus="openPanel"
@input="queueSuggest"
@keyup.enter="search"
/>
<div class="search-btn" @click="search">
<el-icon :size="21"><SearchIcon /></el-icon>
</div>
<div v-if="showPanel" class="suggest-panel" @mousedown.prevent>
<SearchSuggest
:keyword="trimmedKeyword"
:history="history"
:hot-words="promotionTags"
:suggestions="suggestions"
:suggesting="suggesting"
@pick="selectTags"
@clear="clearHistory"
/>
</div>
</div>
<template v-if="showTag">
<div v-if="promotionTags.length === 0"></div>
@@ -56,22 +82,49 @@
<script>
import storage from '@/plugins/storage.js'
import {hotWords} from '@/api/goods.js'
import { goodsList, hotWords } from '@/api/goods.js'
import { Search as SearchIcon } from '@element-plus/icons-vue'
import SearchSuggest from '@/components/SearchSuggest.vue'
const HISTORY_KEY = 'buyerSearchHistory'
const HISTORY_LIMIT = 10
function readHistory() {
try {
const raw = localStorage.getItem(HISTORY_KEY)
const list = raw ? JSON.parse(raw) : []
return Array.isArray(list) ? list.filter((item) => typeof item === 'string' && item.trim()) : []
} catch {
return []
}
}
function parseHotWords(raw) {
if (!raw) return []
if (Array.isArray(raw)) return raw.filter(Boolean)
if (typeof raw !== 'string') return []
try {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) return parsed.filter(Boolean)
} catch {
// 旧数据可能是逗号拼接
}
return raw.split(',').map((item) => item.trim()).filter(Boolean)
}
export default {
name: 'search',
components: { SearchIcon },
components: { SearchIcon, SearchSuggest },
props: {
showTag: { // 是否展示搜索栏下方热门搜索
showTag: {
type: Boolean,
default: true
},
showLogo: { // 是否展示左侧logo
showLogo: {
type: Boolean,
default: true
},
storeId: { // 是否为店铺页面
storeId: {
type: String,
default: ""
},
@@ -86,64 +139,148 @@ export default {
},
data() {
return {
searchData: '', // 搜索内容
searchData: '',
history: [],
panelVisible: false,
suggestions: [],
suggesting: false,
suggestTimer: null,
suggestSeq: 0,
};
},
methods: {
selectTags(item) { // 选择热门标签
selectTags(item) {
this.searchData = item;
this.search();
},
search () { // 全平台搜索商品
remember(keyword) {
const next = [keyword, ...this.history.filter((item) => item !== keyword)].slice(0, HISTORY_LIMIT)
this.history = next
localStorage.setItem(HISTORY_KEY, JSON.stringify(next))
},
clearHistory() {
this.history = []
localStorage.removeItem(HISTORY_KEY)
},
openPanel() {
this.panelVisible = true
if (this.trimmedKeyword) this.queueSuggest()
},
closePanel() {
this.panelVisible = false
},
onDocumentClick(event) {
if (this.$el && !this.$el.contains(event.target)) this.closePanel()
},
queueSuggest() {
this.panelVisible = true
clearTimeout(this.suggestTimer)
const keyword = this.trimmedKeyword
if (!keyword) {
this.suggestions = []
this.suggesting = false
return
}
this.suggesting = true
this.suggestTimer = setTimeout(() => this.fetchSuggest(keyword), 300)
},
fetchSuggest(keyword) {
const seq = ++this.suggestSeq
const params = { keyword, pageNumber: 1, pageSize: 8 }
if (this.storeId) params.storeId = this.storeId
goodsList(params).then((res) => {
if (seq !== this.suggestSeq || keyword !== this.trimmedKeyword) return
const records = res && res.success ? (res.result.records || []) : []
const seen = new Set()
const names = []
this.promotionTags.forEach((word) => {
if (word && String(word).toLowerCase().includes(keyword.toLowerCase()) && !seen.has(word)) {
seen.add(word)
names.push(word)
}
})
records.forEach((item) => {
const name = item && item.goodsName
if (name && !seen.has(name)) {
seen.add(name)
names.push(name)
}
})
this.suggestions = names.slice(0, 8)
}).catch(() => {
if (seq === this.suggestSeq) this.suggestions = []
}).finally(() => {
if (seq === this.suggestSeq) this.suggesting = false
})
},
search () {
const keyword = this.trimmedKeyword
this.searchData = keyword
this.closePanel()
if (keyword) this.remember(keyword)
const url = this.$route.path;
if(url == '/goodsList'){
this.$emit('search', this.searchData)
this.$emit('search', keyword)
}else{
const pushData = {
path:'/goodsList',
query: { keyword: this.searchData },
query: { keyword },
}
if(this.storeId) pushData.query.storeId = this.storeId
this.$router.push(pushData);
}
},
searchStore() { // 店铺搜索商品
this.$emit('search', this.searchData)
searchStore() {
this.$emit('search', this.trimmedKeyword)
},
applyHotWords(result) {
const list = parseHotWords(result)
this.$store.commit('SET_HOTWORDS', list)
storage.setItem('hotWordsList', JSON.stringify(list))
},
loadHotWords() {
hotWords({ count: 5 }).then((res) => {
if (res.success && res.result) this.applyHotWords(res.result)
}).catch(() => {})
storage.setItem('hotWordsReloadTime', new Date().getTime())
}
},
computed: {
trimmedKeyword() {
return (this.searchData || '').trim()
},
promotionTags() {
const raw = this.$store.state.hotWordsList;
if (!raw) {
return [];
}
try {
return typeof raw === "string" ? JSON.parse(raw) : raw;
} catch {
return [];
}
return parseHotWords(this.$store.state.hotWordsList)
},
showPanel() {
if (!this.panelVisible) return false
if (this.trimmedKeyword) return true
return this.history.length > 0 || this.promotionTags.length > 0
}
},
watch: {
'$route.query.keyword'(value) {
this.searchData = value || ''
}
},
created() {
this.searchData = this.$route.query.keyword
this.searchData = this.$route.query.keyword || ''
this.history = readHistory()
if (!this.hover) { // 首页顶部固定搜索栏不调用热词接口
// 搜索热词每5分钟请求一次
if (!this.hover) {
const reloadTime = storage.getItem('hotWordsReloadTime')
const time = new Date().getTime() - 5 * 60 * 1000
if (!reloadTime) {
hotWords({count: 5}).then(res => {
if (res.success && res.result) storage.setItem('hotWordsList', res.result)
}).catch(() => {})
storage.setItem('hotWordsReloadTime', new Date().getTime())
} else if (reloadTime && time > reloadTime) {
hotWords({count: 5}).then(res => {
if (res.success && res.result) storage.setItem('hotWordsList', res.result)
}).catch(() => {})
storage.setItem('hotWordsReloadTime', new Date().getTime())
}
if (!reloadTime || time > reloadTime) this.loadHotWords()
}
},
mounted() {
this.onDocumentClick = this.onDocumentClick.bind(this)
document.addEventListener('click', this.onDocumentClick)
},
beforeUnmount() {
clearTimeout(this.suggestTimer)
document.removeEventListener('click', this.onDocumentClick)
}
};
</script>
@@ -169,7 +306,6 @@ export default {
.container {
position: relative;
height: 100%;
}
@@ -217,6 +353,11 @@ export default {
color: #ffffff;
}
}
.search-embedded.search {
width: 100%;
}
.search-box{
margin-left: 28px;
}
@@ -225,28 +366,7 @@ export default {
cursor: pointer;
}
.store-search {
width: 55.6px;
padding: 0 9px;
border-radius: 0;
border-radius: 3px;
&:nth-child(2) {
width: 55px;
margin-left: -2px;
border-radius: 3px;
}
}
.btn-div {
position: relative;
height: 0px;
top: -38px;
left: 352px;
}
.history-list {
margin-left: 28px;
}
@@ -259,4 +379,18 @@ export default {
color: #666666;
cursor: pointer;
}
.suggest-panel {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 8px);
z-index: 30;
padding: 8px 0;
background: #fff;
border: 1px solid #eee;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
}
</style>

View File

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

View File

@@ -26,7 +26,7 @@
v-for="(img,imgIndex) in order.orderItems"
:key="imgIndex" width="40" height="40" alt="">
</div>
<div class="order-handle"><span>{{ $filters.unitPrice(order.flowPrice, "¥") }}</span><span class="hover-color" @click="linkTo(`home/OrderDetail?sn=${order.sn}`)">查看订单</span></div>
<div class="order-handle"><span>{{ $filters.unitPrice(order.flowPrice, "¥") }}</span><span class="hover-color" @click="linkTo(`/home/OrderDetail?sn=${order.sn}`)">查看订单</span></div>
</li>
</ul>
<el-button type="primary" @click="linkTo('/home/MyOrder')" style="width:100%">查看全部订单</el-button>

View File

@@ -7,7 +7,7 @@
<el-icon :size="20"><component :is="resolveIcon(item.icon)" /></el-icon>
<p class="barTitle" v-if="item.titleShow"> {{item.title}}</p>
<div class="circle" v-if="item.title === '购物车'">
{{cartNum < 100 ? cartNum : 99}}
{{ Number(cartNum) > 99 ? '99+' : cartNum }}
</div>
</el-tooltip>
</div>
@@ -79,21 +79,15 @@ export default {
this.handleDrawer = false
switch (val.title) {
case '会员中心':
this.openBlank('/home')
this.$router.push('/home')
break;
case '我的资产':
this.openBlank('/home/MoneyManagement')
this.$router.push('/home/MoneyManagement')
break;
}
}
}
},
openBlank (path) { // 新页面打开地址
let routerUrl = this.$router.resolve({
path: path
})
window.open(routerUrl.href, '_blank')
},
getCartList () { // 获取购物车列表
cartCount().then(res => {
this.$store.commit('SET_CARTNUM', res.result)
@@ -131,11 +125,15 @@ export default {
}
.circle {
width: 20px;
min-width: 20px;
height: 20px;
border-radius: 50%;
padding: 0 4px;
border-radius: 10px;
color: #fff;
background: $theme_color;
font-size: 12px;
line-height: 20px;
box-sizing: border-box;
}
.wrapper {

View File

@@ -90,10 +90,7 @@ export default {
methods: {
goArticle() {
// 跳转文章页
let routeUrl = this.$router.resolve({
path: "/article",
});
window.open(routeUrl.href, "_blank");
this.$router.push("/article");
},
},
};

View File

@@ -0,0 +1,118 @@
<template>
<div class="goods-thumb" :class="{ 'is-loaded': loaded, 'is-error': error }">
<img v-if="currentSrc" :src="currentSrc" :alt="alt" @load="loaded = true" @error="onError" />
</div>
</template>
<script>
export default {
name: "GoodsThumb",
props: {
src: {
type: String,
default: "",
},
alt: {
type: String,
default: "",
},
},
data() {
return {
currentSrc: "",
loaded: false,
error: false,
observer: null,
};
},
watch: {
src() {
this.observe();
},
},
mounted() {
this.observe();
},
beforeUnmount() {
this.disconnect();
},
methods: {
disconnect() {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
},
observe() {
this.disconnect();
this.currentSrc = "";
this.loaded = false;
this.error = false;
if (!this.src) {
this.error = true;
return;
}
if (typeof IntersectionObserver === "undefined") {
this.currentSrc = this.src;
return;
}
this.observer = new IntersectionObserver(
(entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
this.currentSrc = this.src;
this.disconnect();
},
{ rootMargin: "160px 0px" }
);
this.observer.observe(this.$el);
},
onError() {
this.error = true;
this.loaded = false;
this.currentSrc = "";
},
},
};
</script>
<style scoped lang="scss">
.goods-thumb {
position: relative;
width: 100%;
aspect-ratio: 1;
overflow: hidden;
background: #f3f3f3;
}
.goods-thumb::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, #f3f3f3 25%, #e8e8e8 37%, #f3f3f3 63%);
background-size: 400% 100%;
animation: goods-thumb-loading 1.2s ease infinite;
}
.goods-thumb.is-loaded::before,
.goods-thumb.is-error::before {
display: none;
}
.goods-thumb img {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
@keyframes goods-thumb-loading {
0% {
background-position: 100% 0;
}
100% {
background-position: 0 0;
}
}
</style>

View File

@@ -8,9 +8,8 @@
<div class="item-detail-big-img" v-if="imgList[imgIndex]">
<el-image
:src="currentImg"
:preview-src-list="previewList"
fit="contain"
style="width: 100%; height: 350px"
fit="cover"
style="width: 100%; height: 100%"
/>
</div>
@@ -44,27 +43,6 @@
<div class="item-detail-title">
<div class="item-detail-title-row">
<span class="item-detail-name">{{ skuDetail.goodsName }}</span>
<el-tag
v-if="isECouponGoods"
class="goods-type-tag"
size="small"
>
电子卡券
</el-tag>
<el-tag
v-else-if="skuDetail.goodsType !== 'VIRTUAL_GOODS'"
class="goods-type-tag"
size="small"
>
实物商品
</el-tag>
<el-tag
v-else-if="skuDetail.goodsType == 'VIRTUAL_GOODS'"
class="goods-type-tag"
size="small"
>
虚拟商品
</el-tag>
</div>
</div>
<div class="sell-point">
@@ -95,7 +73,7 @@
<!-- 批发价格 -->
<div v-if="!isECouponGoods && wholesaleNum && wholesaleNum.length">
<div class="flex">
<div class="item-price-title">价格</div>
<div class="item-price-title"></div>
<div
v-for="(item, index) in wholesalePrice"
@@ -119,7 +97,6 @@
<!-- 普通价格 -->
<div v-else>
<span class="item-price-title">价格</span>
<span class="item-price">{{ $filters.unitPrice(skuDetail.price, "¥") }}</span>
</div>
</div>
@@ -228,7 +205,7 @@
</div>
<!-- 选择颜色 -->
<div
class="item-select"
class="item-select item-spec"
v-for="(sku, index) in formatList"
:key="sku.name"
>
@@ -257,7 +234,7 @@
</div>
</div>
<div class="add-buy-car-box">
<div class="item-select">
<div class="item-select item-spec">
<div class="item-select-title">
<p>数量</p>
</div>
@@ -271,25 +248,10 @@
:precision="0"
@blur="changeCount"
></el-input-number>
<span class="inventory">&nbsp;&nbsp;库存{{ displayStock }}</span>
<span class="inventory">(库存:{{ displayStock }})</span>
</div>
</div>
</div>
<div
class="item-select"
v-if="
!isECouponGoods &&
skuDetail.goodsType !== 'VIRTUAL_GOODS' &&
skuDetail.weight !== 0
"
>
<div class="item-select-title">
<p>重量</p>
</div>
<div class="item-select-column">
<span class="inventory">{{ skuDetail.weight }}kg</span>
</div>
</div>
<div
class="item-select add-buy-car-row"
v-if="$route.query.way === 'POINT' && skuDetail.authFlag === 'PASS'"
@@ -439,9 +401,6 @@ export default {
const item = this.imgList[this.imgIndex];
return item ? item.url || item : "";
},
previewList() {
return this.imgList.map((item) => item.url || item);
},
wholesalePrice(key) {
return this.wholesaleList.length
? this.wholesaleList.map((item) => {
@@ -624,6 +583,16 @@ export default {
});
},
async collect() {
if (!this.Cookies.getItem("accessToken") || !this.Cookies.getItem("userInfo")) {
this.$router.push({
path: "/login",
query: {
rePath: this.$route.path,
query: JSON.stringify(this.$route.query || {}),
},
});
return;
}
// 收藏商品
if (this.isCollected) {
let cancel = await cancelCollect("GOODS", this.skuDetail.id);
@@ -717,16 +686,18 @@ export default {
}
}
},
// 相册里会混进规格 JSON,只留图片地址;没有相册时用主图
swiperGoodsImg() {
this.skuDetail.specList.forEach((e) => {
if (e.specName === "images") {
this.imgList = this.skuDetail.goodsGalleryList.filter(i => i.indexOf("\"url\":") === -1 && i.indexOf("\"status\":") === -1);
}
const source = this.skuDetail.goodsGalleryList || [];
const gallery = Array.from(source).filter((item) => {
if (typeof item !== "string") return false;
return item.indexOf('"url":') === -1 && item.indexOf('"status":') === -1;
});
if (!this.imgList) {
this.imgList = [this.skuDetail.original];
if (gallery.length) {
this.imgList = gallery;
return;
}
this.imgList = this.skuDetail.original ? [this.skuDetail.original] : [];
},
},
mounted() {
@@ -784,42 +755,50 @@ export default {
}
.item-detail-left {
width: 350px;
width: 500px;
margin-right: 32px;
flex-shrink: 0;
}
.item-detail-big-img {
width: 350px;
height: 350px;
border: 1px solid #eee;
border-radius: 4px;
width: 500px;
height: 500px;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
margin-bottom: 12px;
background: #fff;
margin-bottom: 16px;
background: #f5f5f5;
:deep(.el-image),
:deep(.el-image__inner) {
width: 100%;
height: 100%;
}
:deep(.el-image__inner) {
object-fit: cover;
}
}
#dplayer{
width: 100%;
height: 100%;
}
.item-detail-big-img img {
width: 100%;
}
.item-detail-img-row {
margin-top: 0;
display: flex;
gap: 8px;
flex-wrap: wrap;
gap: 12px;
}
.item-detail-img-small {
width: 64px;
height: 64px;
border: 1px solid #eee;
border-radius: 4px;
width: 86px;
height: 86px;
border: 2px solid transparent;
border-radius: 6px;
overflow: hidden;
cursor: pointer;
box-sizing: border-box;
background: #f5f5f5;
&.is-active {
border-color: $theme_color;
@@ -829,6 +808,7 @@ export default {
.item-detail-img-small img {
height: 100%;
width: 100%;
object-fit: cover;
}
/*商品选购详情*/
@@ -848,18 +828,11 @@ export default {
.item-detail-name {
color: #171A26;
font-weight: 600;
font-weight: 400;
font-size: 20px;
line-height: 28px;
}
.goods-type-tag {
flex-shrink: 0;
--el-tag-bg-color: #fafafa;
--el-tag-border-color: #eee;
--el-tag-text-color: #666;
}
.item-detail-express {
font-size: 14px;
padding: 2px 3px;
@@ -1026,6 +999,22 @@ export default {
margin-top: 15px;
}
.item-spec {
flex-direction: column;
align-items: flex-start;
width: 100%;
.item-select-title {
width: auto;
margin-right: 0;
margin-bottom: 16px;
}
.item-select-column {
width: 100%;
}
}
.item-select-title {
color: #666;
font-size: 14px;
@@ -1215,8 +1204,9 @@ export default {
.inventory {
padding-left: 8px;
color: #999;
font-size: 13px;
color: #666;
font-size: 15px;
line-height: 22px;
}
.view-video{
cursor: pointer;

View File

@@ -1,5 +1,8 @@
<template>
<div class="goods-detail-extra">
<div class="goods-detail-extra__main">
<div class="detail-layout">
<div class="detail-main">
<div class="detail-anchor" ref="tabNav" :class="{ 'is-stuck': anchorStuck }">
<button
v-for="tab in detailTabs"
@@ -117,15 +120,25 @@
<div class="detail-empty" v-else>暂无商品参数</div>
</section>
</div>
</div>
<StoreSide
:store-id="skuDetail.storeId"
:goods-id="skuDetail.goodsId"
:sku-id="skuDetail.id"
:store-name-fallback="skuDetail.storeName"
/>
</div>
</div>
</div>
</template>
<script>
import { Refresh } from '@element-plus/icons-vue';
import { goodsComment, goodsCommentNum } from '@/api/member.js';
import StoreSide from './StoreSide.vue';
export default {
components: { Refresh },
components: { Refresh, StoreSide },
name: 'ShowGoodsDetail',
props: {
detail: {
@@ -277,8 +290,25 @@ export default {
<style scoped lang="scss">
.goods-detail-extra {
width: 100%;
background: #fff;
}
.goods-detail-extra__main {
width: 1200px;
margin: 16px auto 24px;
margin: 0 auto;
padding-bottom: 16px;
}
.detail-layout {
display: flex;
align-items: flex-start;
gap: 16px;
}
.detail-main {
flex: 1;
min-width: 0;
}
.detail-anchor {
@@ -334,6 +364,7 @@ export default {
.detail-panel {
background: #fff;
border-radius: 0 0 4px 4px;
padding-bottom: 32px;
}
.detail-section + .detail-section {
@@ -381,7 +412,7 @@ export default {
.item-param-grid {
display: grid;
grid-template-columns: 1fr 1fr;
margin: 16px 24px 24px;
margin: 16px 24px 0;
border-top: 1px solid #f0f0f0;
border-left: 1px solid #f0f0f0;
border-radius: 4px;

View File

@@ -0,0 +1,419 @@
<template>
<aside class="store-side" v-if="storeId">
<div class="store-card">
<div class="store-head" @click="goStore">
<img
v-if="store.storeLogo && !logoError"
class="store-logo"
:src="store.storeLogo"
:alt="storeName"
@error="logoError = true"
/>
<span v-else class="store-logo store-logo-fallback">{{ storeInitial }}</span>
<div class="store-head-text">
<p class="store-name">
<span v-if="isSelfOperated" class="store-self">自营</span>
<span class="store-name-text">{{ storeName }}</span>
</p>
</div>
</div>
<div class="store-scores">
<div class="score-item">
<span class="score-label">描述</span>
<span class="score-value">{{ formatScore(store.descriptionScore) }}</span>
</div>
<div class="score-item">
<span class="score-label">服务</span>
<span class="score-value">{{ formatScore(store.serviceScore) }}</span>
</div>
<div class="score-item">
<span class="score-label">物流</span>
<span class="score-value">{{ formatScore(store.deliveryScore) }}</span>
</div>
</div>
<div class="store-actions">
<button type="button" class="store-btn" @click="contact">联系客服</button>
<button type="button" class="store-btn" :class="{ 'is-on': collected }" @click="toggleCollect">
{{ collected ? "已收藏" : "收藏店铺" }}
</button>
</div>
<button type="button" class="enter-store" @click="goStore">进店逛逛</button>
</div>
<div class="recommend-card" v-if="recommends.length">
<p class="recommend-title">店铺推荐</p>
<div
class="recommend-item"
v-for="item in recommends"
:key="item.id"
@click="goGoods(item)"
>
<img class="recommend-img" :src="item.thumbnail || item.small" :alt="item.goodsName" />
<div class="recommend-info">
<p class="recommend-name">{{ item.goodsName }}</p>
<div class="recommend-meta">
<p class="recommend-price">{{ $filters.unitPrice(item.price, "¥") }}</p>
<span class="recommend-sales">销量 {{ item.buyCount || 0 }}</span>
</div>
</div>
</div>
</div>
</aside>
</template>
<script>
import { Message } from "@/utils/message";
import { goodsList } from "@/api/goods";
import { getStoreById } from "@/api/shopentry";
import { cancelStoreCollect, collectStore, isStoreCollection } from "@/api/member";
import imTalk from "@/components/mixes/talkIm";
export default {
name: "StoreSide",
mixins: [imTalk],
props: {
storeId: {
type: [String, Number],
default: "",
},
goodsId: {
type: [String, Number],
default: "",
},
skuId: {
type: [String, Number],
default: "",
},
storeNameFallback: {
type: String,
default: "",
},
},
data() {
return {
store: {},
logoError: false,
collected: false,
recommends: [],
};
},
computed: {
storeName() {
return this.store.storeName || this.storeNameFallback || "店铺";
},
storeInitial() {
return this.storeName.slice(0, 1);
},
// 自营标记后端可能是 true、1 或 "1"
isSelfOperated() {
const flag = this.store.selfOperated;
return flag === true || flag === 1 || flag === "1";
},
},
watch: {
storeId: {
immediate: true,
handler(id) {
if (!id) return;
this.logoError = false;
this.loadStore(id);
this.loadRecommend(id);
this.loadCollected(id);
},
},
},
methods: {
// 没有评分或小于等于 0 时按满分展示
formatScore(value) {
const score = Number(value);
if (!Number.isFinite(score) || score <= 0) return "5.00";
return score.toFixed(2);
},
loadStore(id) {
getStoreById(id).then((res) => {
if (res.success && res.result) this.store = res.result;
}).catch(() => {});
},
// 按销量取店内商品,去掉当前商品,最多 4 个
loadRecommend(id) {
goodsList({
storeId: id,
pageNumber: 1,
pageSize: 8,
sort: "buyCount",
order: "desc",
}).then((res) => {
const records = (res.success && res.result && res.result.records) || [];
this.recommends = records
.filter((item) => String(item.goodsId) !== String(this.goodsId))
.slice(0, 4);
}).catch(() => {});
},
// 同时有 token 和用户信息才查收藏,避免残留 userInfo 触发登录跳转
loadCollected(id) {
if (!this.Cookies.getItem("accessToken") || !this.Cookies.getItem("userInfo")) return;
isStoreCollection("STORE", id).then((res) => {
if (res.success && res.result) this.collected = true;
}).catch(() => {});
},
goStore() {
this.$router.push({ path: "/merchant", query: { id: this.storeId } });
},
// 推荐商品新开详情页,店铺页仍在当前页打开
goGoods(item) {
const href = this.$router.resolve({
path: "/goodsDetail",
query: { skuId: item.id, goodsId: item.goodsId },
}).href;
window.open(href, "_blank");
},
contact() {
this.IMService(this.storeId, this.goodsId, this.skuId);
},
// 未登录先去登录,并带回当前商品页
async toggleCollect() {
if (!this.Cookies.getItem("accessToken") || !this.Cookies.getItem("userInfo")) {
this.$router.push({
path: "/login",
query: {
rePath: this.$route.path,
query: JSON.stringify(this.$route.query || {}),
},
});
return;
}
if (this.collected) {
const cancel = await cancelStoreCollect("STORE", this.storeId);
if (cancel.success) {
this.collected = false;
Message.success("已取消收藏");
}
return;
}
const collect = await collectStore("STORE", this.storeId);
if (collect.code === 200) {
this.collected = true;
Message.success("收藏店铺成功,可以前往个人中心我的收藏查看");
}
},
},
};
</script>
<style scoped lang="scss">
.store-side {
width: 260px;
flex-shrink: 0;
position: sticky;
top: 16px;
}
.store-card,
.recommend-card {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 4px;
}
.store-card {
padding: 16px;
}
.store-head {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
cursor: pointer;
}
.store-logo {
width: 64px;
height: 64px;
border-radius: 4px;
object-fit: cover;
border: 1px solid #f0f0f0;
flex-shrink: 0;
background: #fafafa;
}
.store-logo-fallback {
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: $theme_color;
font-size: 18px;
font-weight: 600;
}
.store-head-text {
width: 100%;
text-align: center;
}
.store-name {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
margin: 0;
color: #171A26;
font-size: 15px;
font-weight: 600;
line-height: 22px;
}
.store-name-text {
min-width: 0;
}
.store-self {
flex-shrink: 0;
padding: 0 6px;
border-radius: 2px;
background: $theme_color;
color: #fff;
font-size: 12px;
font-weight: 400;
line-height: 18px;
}
.store-scores {
display: flex;
margin-top: 14px;
padding: 10px 0;
border-top: 1px solid #f5f5f5;
border-bottom: 1px solid #f5f5f5;
}
.score-item {
flex: 1;
text-align: center;
}
.score-label {
display: block;
color: #999;
font-size: 12px;
line-height: 18px;
}
.score-value {
display: block;
margin-top: 2px;
color: $theme_color;
font-size: 14px;
font-weight: 600;
line-height: 20px;
}
.store-actions {
display: flex;
gap: 8px;
margin-top: 14px;
}
.store-btn,
.enter-store {
border: 1px solid #eee;
background: #fff;
color: #171A26;
font-size: 13px;
line-height: 32px;
border-radius: 4px;
cursor: pointer;
&:hover {
color: $theme_color;
border-color: $theme_color;
}
}
.store-btn {
flex: 1;
}
.store-btn.is-on {
color: $theme_color;
border-color: $theme_color;
background: #fff5f4;
}
.enter-store {
width: 100%;
margin-top: 8px;
}
.recommend-card {
margin-top: 12px;
padding: 12px 12px 4px;
}
.recommend-title {
margin: 0 0 8px;
color: #171A26;
font-size: 18px;
font-weight: 600;
line-height: 26px;
}
.recommend-item {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0 12px;
cursor: pointer;
& + & {
border-top: 1px solid #f5f5f5;
}
}
.recommend-img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 4px;
background: #fafafa;
}
.recommend-info {
min-width: 0;
}
.recommend-name {
margin: 0;
color: #333;
font-size: 16px;
line-height: 24px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recommend-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 4px;
}
.recommend-price {
margin: 0;
color: $theme_color;
font-size: 18px;
font-weight: 600;
line-height: 26px;
}
.recommend-sales {
flex-shrink: 0;
color: #999;
font-size: 15px;
line-height: 22px;
}
</style>

View File

@@ -29,7 +29,7 @@
</div>
<transition name='fade'>
<ul class="drop-items">
<li @click="goUserCenter('/home/MyOrder')">我的主页</li>
<li @click="goUserCenter('/home')">我的主页</li>
<li @click="goUserCenter('/home/Coupons')">优惠券</li>
<li @click="goUserCenter('/home/Favorites')">我的收藏</li>
<li @click="signOutFun">退出登录</li>
@@ -39,7 +39,12 @@
</li>
<li @click="goUserCenter('/home/MyOrder')"><span class="nav-item">我的订单</span></li>
<li @click="goUserCenter('/home/MyTracks')"><span class="nav-item">我的足迹</span></li>
<li @click="goUserCenter('/home/MsgList')"><span class="nav-item">我的消息</span></li>
<li @click="goUserCenter('/home/MsgList')">
<span class="nav-item">
我的消息
<i v-if="unreadMsg > 0" class="msg-badge">{{ unreadLabel }}</i>
</span>
</li>
<li v-if="$route.name !== 'Cart'" class="cart-nav-item">
<el-dropdown
class="cart-nav-dropdown"
@@ -49,7 +54,7 @@
>
<span class="nav-item" @click="goToPay">
<el-icon :size="18"><ShoppingCart /></el-icon>
购物车({{ cartNum < 100 ? cartNum : '99' }})
购物车({{ Number(cartNum) > 99 ? '99+' : cartNum }})
</span>
<template #dropdown>
<div class="shopping-cart-null" v-show="shoppingCart.length <= 0">
@@ -90,6 +95,7 @@ import storage from '@/plugins/storage.js';
import {cartGoodsAll} from '@/api/cart.js';
import {logout} from '@/api/account.js';
import config from '@/config';
import { refreshUnreadMessage } from '@/utils/unreadMessage';
export default {
components: { ShoppingCart },
@@ -97,6 +103,7 @@ export default {
created() {
if (storage.getItem('userInfo')) {
this.userInfo = JSON.parse(storage.getItem('userInfo'));
refreshUnreadMessage();
}
},
@@ -114,6 +121,12 @@ export default {
// 购物车商品数量
cartNum() {
return this.$store.state.cartNum;
},
unreadMsg() {
return Number(this.$store.state.unreadMsg) || 0;
},
unreadLabel() {
return this.unreadMsg > 99 ? '99+' : String(this.unreadMsg);
}
},
methods: {
@@ -136,16 +149,10 @@ export default {
});
return;
}
const url = this.$router.resolve({
path: '/cart',
});
window.open(url.href, '_blank');
this.$router.push('/cart');
},
myInfo() { // 跳转会员中心
let url = this.$router.resolve({
path: '/home/MyOrder'
});
window.open(url.href, '_blank');
this.$router.push('/home');
},
signOutFun() { // 退出登录
logout().then(res => {
@@ -154,6 +161,7 @@ export default {
storage.removeItem('userInfo');
storage.removeItem('cartNum');
this.$store.commit('SET_CARTNUM', 0)
this.$store.commit('SET_UNREAD_MSG', 0)
this.$router.push('/login');
});
},
@@ -181,11 +189,10 @@ export default {
},
shopEntry() {
if (storage.getItem('accessToken')) {
const routeUrl = this.$router.resolve({
this.$router.push({
path: '/shopEntry',
query: { id: 1 },
});
window.open(routeUrl.href, '_blank');
return;
}
Modal.confirm({
@@ -289,6 +296,19 @@ export default {
gap: 4px;
}
.msg-badge {
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
background: $theme_color;
color: #fff;
font-size: 12px;
font-style: normal;
line-height: 16px;
text-align: center;
}
.cart-nav-item {
:deep(.cart-nav-dropdown),
:deep(.el-dropdown) {

View File

@@ -19,7 +19,7 @@
<span @mouseenter="getCartList">
<el-icon class="cart-icon" :size="20" @click="goCartList"><ShoppingCart /></el-icon>
</span>
<i class="cart-badge">{{ cartNum < 100 ? cartNum : "99" }}</i>
<i class="cart-badge">{{ Number(cartNum) > 99 ? "99+" : cartNum }}</i>
</div>
</div>
</div>
@@ -45,8 +45,7 @@ export default {
},
methods: {
goCartList() {
const routerUrl = this.$router.resolve({ path: '/cart' })
window.open(routerUrl.href, '_blank')
this.$router.push('/cart')
},
getCartList() {
if (storage.getItem('userInfo')) {
@@ -71,7 +70,7 @@ $cart-col-width: 40px;
.scroll-show {
height: 60px;
overflow: hidden;
overflow: visible;
}
.content {
@@ -125,8 +124,9 @@ $cart-col-width: 40px;
background-color: $theme_color;
color: #fff;
font-size: 12px;
width: 17px;
min-width: 17px;
height: 17px;
padding: 0 4px;
border-radius: 10px;
line-height: 17px;
text-align: center;

View File

@@ -164,7 +164,7 @@ export default {
},
{
value: "0",
label: "代付款"
label: "待付款"
},
],
};
@@ -184,7 +184,12 @@ export default {
};
},
isMemberRoute(location) {
return location.path.startsWith("/home/") || location.path === "/cart";
const path = location.path && location.path.charAt(0) === "/" ? location.path : `/${location.path || ""}`;
return path === "/home" || path.startsWith("/home/") || path === "/cart";
},
normalizeLocation(location) {
if (!location.path || location.path.charAt(0) === "/") return location;
return { ...location, path: `/${location.path}` };
},
promptLoginThenNavigate(location) {
Modal.confirm({
@@ -208,13 +213,12 @@ export default {
},
// 快捷跳转中心
entryControl(val) {
const location = this.resolveRoute(val.path);
const location = this.normalizeLocation(this.resolveRoute(val.path));
if (this.isMemberRoute(location) && !storage.getItem("userInfo")) {
this.promptLoginThenNavigate(location);
return;
}
const url = this.$router.resolve(location);
window.open(url.href, "_blank");
this.$router.push(location);
},
},
mounted() {

View File

@@ -87,11 +87,10 @@ export default {
})
},
goArticle (id) { // 跳转文章详情
let routeUrl = this.$router.resolve({
this.$router.push({
path: '/article',
query: {id}
});
window.open(routeUrl.href, '_blank');
}
},
mounted () {

View File

@@ -80,8 +80,7 @@ export default {
},
methods: {
goToSeckill() {
const routeUrl = this.$router.resolve({ path: "/seckill" });
window.open(routeUrl.href, "_blank");
this.$router.push("/seckill");
},
countDown(currIndex) {
const zeroTime = new Date(new Date().toLocaleDateString()).getTime();

View File

@@ -198,11 +198,10 @@ export default {
if (!arr[0]) {
arr.shift();
}
let routerUrl = this.$router.resolve({
this.$router.push({
path: "/goodsList",
query: { categoryId: arr.toString() },
});
window.open(routerUrl.href, "_blank");
},
},
mounted() {

View File

@@ -281,11 +281,10 @@ export default {
},
// 跳转店铺首页
goShopPage(id) {
let routeUrl = this.$router.resolve({
this.$router.push({
path: "/merchant",
query: { id },
});
window.open(routeUrl.href, "_blank");
},
// 同步购物车商品收藏状态
async syncCartCollectionStatus() {
@@ -959,3 +958,6 @@ export default {
}
</style>

View File

@@ -2,55 +2,43 @@
<div class="coupon-center">
<BaseHeader></BaseHeader>
<div class="content">
<div>
<div class="coupon-title">
<router-link to="/">
<img src="../assets/images/logo.png" width="120" alt="" />
</router-link>
<p>领券中心</p>
<el-input
v-model="keyword"
style="width: 400px"
@keyup.enter="search"
placeholder="搜索优惠券"
/>
</div>
<h2 class="coupon-page-title">领券中心</h2>
<div class="fontsize_18 recommend">推荐好券</div>
<empty v-if="!loading && list.length === 0" />
<ul class="coupon-list" v-else-if="list.length > 0">
<li v-for="(item, index) in list" class="coupon-item" :key="index">
<div class="c-left">
<div>
<span
v-if="item.couponType === 'PRICE'"
class="fontsize_12 global_color"
>¥<span class="price">{{ $filters.unitPrice(item.price) }}</span></span>
<span
v-if="item.couponType === 'DISCOUNT'"
class="fontsize_12 global_color"
><span class="price">{{ item.couponDiscount || item.discount }}</span>折</span>
<span class="describe"
>满{{ item.consumeThreshold }}元可用</span>
</div>
<p>使用范围:{{ useScope(item.scopeType, item.storeName) }}</p>
<p>有效期:{{ item.endTime }}</p>
<empty v-if="!loading && list.length === 0" />
<ul class="coupon-list" v-else-if="list.length > 0">
<li v-for="item in list" class="coupon-item" :key="item.id">
<div class="c-left">
<div class="coupon-amount">
<span v-if="item.couponType === 'PRICE'" class="global_color">
¥<span class="price">{{ $filters.unitPrice(item.price) }}</span>
</span>
<span v-if="item.couponType === 'DISCOUNT'" class="global_color">
<span class="price">{{ item.couponDiscount || item.discount }}</span>折
</span>
<span class="describe">{{ thresholdText(item) }}</span>
</div>
<b></b>
<a class="c-right" @click="receive(item)">立即领取</a>
<i class="circle-top"></i>
<i class="circle-bottom"></i>
</li>
</ul>
<el-pagination v-model:current-page="params.pageNumber"
v-model:page-size="params.pageSize"
:total="total"
@current-change="changePageNum"
class="pageration"
@size-change="changePageSize"
layout="total, sizes, prev, pager, next"></el-pagination>
</div>
<p class="coupon-name">
<span class="scope-tag">{{ scopeTag(item.scopeType, item.storeName) }}</span>
<span>{{ item.couponName }}</span>
</p>
<p class="coupon-time">有效期:{{ item.endTime }}</p>
</div>
<a
class="c-right"
:class="{ 'is-finished': isFinished(item) }"
@click="receive(item)"
>{{ isFinished(item) ? "已领完" : "立即领取" }}</a>
</li>
</ul>
<el-pagination
v-model:current-page="params.pageNumber"
v-model:page-size="params.pageSize"
:total="total"
@current-change="changePageNum"
class="pageration"
@size-change="changePageSize"
layout="total, sizes, prev, pager, next"
></el-pagination>
</div>
<BaseFooter></BaseFooter>
</div>
@@ -65,7 +53,6 @@ export default {
list: [], // 优惠券列表
total: 0, // 优惠券总数
loading: false,
keyword: "",
params: {
// 请求参数
getType: "FREE",
@@ -75,13 +62,6 @@ export default {
};
},
methods: {
// 搜索优惠券
search() {
this.params.couponName = this.keyword.trim();
this.params.pageNumber = 1;
this.getList();
},
// 获取优惠券列表
getList() {
if (this.loading) return;
this.loading = true;
@@ -116,6 +96,7 @@ export default {
},
// 领取优惠券
receive(item) {
if (this.isFinished(item)) return;
const userInfo = storage.getItem("userInfo");
const isLogin = userInfo && JSON.parse(userInfo).username;
if (!isLogin) {
@@ -158,23 +139,22 @@ export default {
})
.catch(() => {});
},
// 优惠券可用范围
useScope(type, storeName) {
let shop = "平台";
let goods = "全部商品";
if (storeName !== "platform") shop = storeName;
switch (type) {
case "ALL":
goods = "全部商品";
break;
case "PORTION_GOODS":
goods = "部分商品";
break;
case "PORTION_GOODS_CATEGORY":
goods = "部分分类商品";
break;
}
return `${shop}${goods}可用`;
// 发行量已领完
isFinished(item) {
return Number(item.publishNum) > 0 && Number(item.receivedNum) >= Number(item.publishNum);
},
// 使用门槛文案
thresholdText(item) {
const threshold = Number(item.consumeThreshold);
if (!threshold) return "无使用门槛";
return `满${item.consumeThreshold}元可用`;
},
// 券范围。店铺名为 platform 时是平台通用券
scopeTag(type, storeName) {
if (type === "PORTION_GOODS") return "商品券";
if (type === "PORTION_GOODS_CATEGORY") return "分类券";
if (storeName && storeName !== "platform") return "店铺券";
return "通用券";
},
},
mounted() {
@@ -184,84 +164,156 @@ export default {
</script>
<style lang="scss" scoped>
@import "../assets/styles/coupon.scss";
.coupon-center {
background: #f5f5f5;
}
.content {
width: 100%;
background-color: #fff;
> div {
margin: 10px auto;
width: 1200px;
}
width: 1200px;
margin: 0 auto 48px;
background: transparent;
}
.coupon-title {
display: flex;
align-items: center;
p {
font-size: 18px;
margin-right: 500px;
}
border-bottom: 2px solid $theme_color;
}
.recommend {
margin: 20px auto;
font-weight: bold;
width: 200px;
.coupon-page-title {
margin: 28px 0 20px;
color: #171A26;
font-size: 28px;
font-weight: 600;
text-align: center;
}
.coupon-item {
overflow: hidden;
$claim-width: 55px;
.c-left {
width: calc(100% - #{$claim-width});
box-sizing: border-box;
.coupon-list {
gap: 16px;
margin: 0;
padding: 0;
list-style: none;
}
.coupon-item {
width: calc((100% - 32px) / 3);
height: auto;
min-height: 132px;
margin: 0;
display: flex;
align-items: stretch;
overflow: hidden;
border: none;
border-radius: 8px;
background: #fff;
b,
i.circle-top,
i.circle-bottom {
display: none;
}
b {
right: $claim-width;
width: 0;
background: none;
border-right: 1px dashed #e0e0e0;
.c-left {
flex: 1;
width: auto;
min-width: 0;
padding: 16px 16px 14px;
box-sizing: border-box;
.coupon-amount {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
.price {
font-size: 28px;
font-weight: 600;
line-height: 1;
}
.describe {
margin-left: 0;
padding: 0;
background: transparent;
font-size: 13px;
line-height: 20px;
}
.coupon-name {
display: flex;
align-items: center;
gap: 6px;
margin: 0 0 8px;
color: #171A26;
font-size: 14px;
font-weight: 400;
line-height: 20px;
}
.scope-tag {
flex-shrink: 0;
padding: 0 6px;
border-radius: 2px;
background: #fff5f4;
color: $theme_color;
font-size: 12px;
line-height: 20px;
}
.coupon-name span:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.coupon-time {
margin: 0;
color: #999;
font-size: 12px;
font-weight: 400;
line-height: 18px;
}
}
.c-right {
width: $claim-width;
position: relative;
right: auto;
top: auto;
flex-shrink: 0;
box-sizing: border-box;
width: 48px;
height: auto;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
writing-mode: vertical-rl;
text-orientation: mixed;
letter-spacing: 4px;
font-size: 16px;
white-space: nowrap;
font-size: 14px;
font-weight: 600;
line-height: 1;
cursor: pointer;
text-decoration: none;
user-select: none;
&:hover {
opacity: 0.9;
opacity: 0.92;
}
&.is-finished {
background: #c8c8c8;
cursor: default;
&:hover {
opacity: 1;
}
}
}
i.circle-top,
i.circle-bottom {
right: calc(#{$claim-width} - 9px);
width: 18px;
height: 18px;
}
i.circle-top {
top: -9px;
}
i.circle-bottom {
bottom: -9px;
}
}
.pageration {
text-align: right;
padding-bottom: 10px;
display: flex;
justify-content: flex-end;
margin-top: 20px;
padding-bottom: 0;
}
</style>

View File

@@ -6,27 +6,10 @@
<div class="shop-nav-container">
<el-breadcrumb>
<el-breadcrumb-item to="/">首页</el-breadcrumb-item>
<el-breadcrumb-item v-for="(item, index) in categoryBar" :to="goGoodsList(index)" target="_blank" :key="index">
<el-breadcrumb-item v-for="(item, index) in categoryBar" :to="goGoodsList(index)" :key="index">
{{ item.name }}
</el-breadcrumb-item>
</el-breadcrumb>
<div class="store-collect" v-if="!takeDownSale && goodsMsg.data">
<span class="mr_10" v-if="goodsMsg.data">
<router-link
class="store-name-link"
:to="{ path: '/merchant', query: { id: goodsMsg.data.storeId } }"
>{{ goodsMsg.data.storeName }}</router-link>
</span>
<span
class="store-collect-btn"
:class="{ 'is-collected': storeCollected }"
@click="collect"
>
<el-icon class="store-collect-icon"><StarFilled /></el-icon>
{{ storeCollected? "已收藏店铺": "收藏店铺" }}
</span>
<span class="ml_10 contact-service-btn" @click="IMService(goodsMsg.data.storeId,goodsMsg.data.goodsId,goodsMsg.data.id)">联系客服</span>
</div>
</div>
</div>
@@ -48,21 +31,13 @@
<script>
import { Message } from "@/utils/message";
import { StarFilled } from '@element-plus/icons-vue';
import Search from "@/components/Search";
import ShowGoods from "@/components/goodsDetail/ShowGoods";
import empty from "@/components/empty/Main";
import ShowGoodsDetail from "@/components/goodsDetail/ShowGoodsDetail";
import { goodsSkuDetail } from "@/api/goods";
import {
cancelStoreCollect,
collectStore,
isStoreCollection,
getGoodsDistribution,
} from "@/api/member";
import { getDetailById } from "@/api/shopentry";
import imTalk from '@/components/mixes/talkIm'
import { getGoodsDistribution } from "@/api/member";
export default {
name: "GoodsDetail",
beforeRouteEnter (to, from, next) {
@@ -72,14 +47,11 @@ export default {
created () {
this.getGoodsDetail();
},
mixins: [imTalk],
data () {
return {
goodsMsg: {}, // 商品信息
isLoading: false, // 加载状态
categoryBar: [], // 分类
storeCollected: false, // 商品收藏
storeMsg: {}, // 店铺信息
takeDownSale:false, // 是否下架
};
@@ -140,24 +112,6 @@ export default {
if (!this.goodsMsg.data.intro) {
this.goodsMsg.data.intro = ''
}
// 判断是否收藏(须同时有 token,避免未登录/ token 失效时误跳登录页)
if (this.Cookies.getItem("accessToken") && this.Cookies.getItem("userInfo")) {
isStoreCollection("STORE", this.goodsMsg.data.storeId).then((res) => {
if (res.success && res.result) {
this.storeCollected = true;
}
});
}
if (!this.storeMsg) {
// 获取店铺信息
getDetailById(this.goodsMsg.data.storeId).then((res) => {
if (res.success) {
this.storeMsg = res.result;
}
});
}
} else {
Message.error(res.message);
this.isLoading = false
@@ -183,37 +137,12 @@ export default {
query: { categoryId: arr.toString() },
};
},
async collect () {
// 收藏店铺
const storeId = this.goodsMsg.data && this.goodsMsg.data.storeId;
if (!storeId) {
return;
}
if (!this.Cookies.getItem("userInfo")) {
Message.warning("请先登录");
this.$router.push("/login");
return;
}
if (this.storeCollected) {
let cancel = await cancelStoreCollect("STORE", storeId);
if (cancel.success) {
Message.success("已取消收藏");
this.storeCollected = false;
}
} else {
let collect = await collectStore("STORE", storeId);
if (collect.code === 200) {
this.storeCollected = true;
Message.success("收藏店铺成功,可以前往个人中心我的收藏查看");
}
}
},
},
watch: {},
components: { Search,
ShowGoods,
ShowGoodsDetail,
empty, StarFilled },
empty },
};
</script>
<style scoped lang="scss">
@@ -248,52 +177,6 @@ export default {
:deep(.el-breadcrumb__item:last-child .el-breadcrumb__inner) {
color: #171A26;
}
.store-collect {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
gap: 16px;
color: #171A26;
font-size: 14px;
.store-collect-btn,
.contact-service-btn {
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
color: #171A26;
&:hover {
color: $theme_color;
}
}
.store-collect-btn {
.store-collect-icon {
color: #999;
font-size: 16px;
}
&.is-collected,
&.is-collected .store-collect-icon {
color: $theme_color;
}
}
.store-name-link {
color: #438cde;
text-decoration: none;
&:hover {
color: $theme_color;
}
}
}
}
.sale-btn{
margin:10px 0

View File

@@ -21,32 +21,34 @@
>
<span :class="{ 'goods-list-tool-active': index === sortIndex }">
{{ item.title }}
<el-icon><ArrowDown /></el-icon>
<el-icon v-if="index === sortIndex" class="sort-arrow"><ArrowDown /></el-icon>
</span>
</li>
<li @click="orderBy('price', 5, 'up')" class="price-sort">
<span :class="{ 'goods-list-tool-active': 5 === sortIndex }">
<li @click="orderBy('price', priceSortIndex)" class="price-sort">
<span :class="{ 'goods-list-tool-active': priceSortIndex === sortIndex }">
价格
<span class="price-arrows">
<el-icon :class="{ 'price-color': sortPriceIndex == 'desc' }"><ArrowUp /></el-icon>
<el-icon :class="{ 'price-color': sortPriceIndex == 'asc' }"><ArrowDown /></el-icon>
<el-icon :class="{ 'is-on': priceSortIndex === sortIndex && sortPriceIndex === 'asc' }"><ArrowUp /></el-icon>
<el-icon :class="{ 'is-on': priceSortIndex === sortIndex && sortPriceIndex === 'desc' }"><ArrowDown /></el-icon>
</span>
</span>
</li>
</ul>
</div>
<!-- 列表 -->
<div class="goods-list">
<empty v-if="goodsList.length === 0" />
<div v-if="searched && !loading && goodsList.length === 0" class="list-empty">
<empty :_Title="emptyTitle" />
<div v-if="recommendList.length" class="recommend-title">为你推荐</div>
</div>
<div class="goods-list" v-if="displayGoods.length">
<div
v-else
class="goods-show-info"
v-for="(item, index) in goodsList"
:key="index"
v-for="(item, index) in displayGoods"
:key="item.id || index"
@click="goGoodsDetail(item.id, item.goodsId)"
>
<div class="goods-show-img">
<img :src="item.thumbnail" />
<GoodsThumb :src="item.thumbnail" :alt="item.goodsName" />
</div>
<div class="goods-show-price">
<span>
@@ -85,7 +87,7 @@
</div>
</div>
</div>
<div class="goods-page">
<div class="goods-page" v-if="total > 0">
<el-pagination
v-model:current-page="params.pageNumber"
v-model:page-size="params.pageSize"
@@ -105,6 +107,7 @@
import { Message } from "@/utils/message";
import { ArrowDown, ArrowUp } from '@element-plus/icons-vue';
import GoodsClassNav from "@/components/nav/GoodsClassNav";
import GoodsThumb from "@/components/goods/GoodsThumb.vue";
import * as apiGoods from "@/api/goods";
export default {
name: "GoodsList",
@@ -115,7 +118,10 @@ export default {
data() {
return {
sortIndex: 0, // 排序状态
priceSortIndex: 4,
sortPriceIndex: false, // 判断价格升序还是降序
searched: false,
recommendList: [],
goodsTool: [
// 排序类型
{ title: "综合", en: "" },
@@ -154,6 +160,16 @@ export default {
this.getGoodsList();
},
},
computed: {
displayGoods() {
if (this.goodsList.length) return this.goodsList;
return this.searched && !this.loading ? this.recommendList : [];
},
emptyTitle() {
const keyword = this.params.keyword;
return keyword ? `没有找到与“${keyword}”相关的商品` : "暂无商品";
},
},
methods: {
// 搜索
handleSearch(key) {
@@ -204,11 +220,10 @@ export default {
Message.warning("店铺信息不存在");
return;
}
const routeUrl = this.$router.resolve({
this.$router.push({
path: "/merchant",
query: { id },
});
window.open(routeUrl.href, "_blank");
},
// 分页 修改页码
changePageNum(val) {
@@ -230,14 +245,28 @@ export default {
.then((res) => {
this.loading = false;
if (res.success) {
this.goodsList = res.result.records;
this.goodsList = res.result.records || [];
this.total = res.result.total;
if (!this.goodsList.length) this.loadRecommend();
}
})
.catch(() => {
this.goodsList = [];
})
.finally(() => {
this.loading = false;
this.searched = true;
});
},
loadRecommend() {
if (this.recommendList.length) return;
apiGoods
.goodsList({ pageNumber: 1, pageSize: 10, sort: "buyCount", order: "desc" })
.then((res) => {
if (res.success) this.recommendList = res.result.records || [];
})
.catch(() => {});
},
getParams(val) {
// 筛选条件回显
Object.assign(this.params, val);
@@ -256,6 +285,7 @@ export default {
},
components: {
GoodsClassNav,
GoodsThumb,
ArrowDown,
ArrowUp,
},
@@ -395,8 +425,12 @@ export default {
}
}
.price-color {
color: #b3b3b3;
.el-icon {
color: #c8c8c8;
}
.el-icon.is-on {
color: $theme_color;
}
}
}
@@ -433,9 +467,30 @@ export default {
background-color: transparent !important;
font-weight: 600;
.price-color {
color: $theme_color !important;
.sort-arrow {
color: $theme_color;
}
.price-arrows .el-icon {
color: #c8c8c8;
}
.price-arrows .el-icon.is-on {
color: $theme_color;
}
}
.list-empty {
background: #fff;
border: 1px solid #eee;
border-top: none;
}
.recommend-title {
padding: 4px 16px 12px;
color: #333;
font-size: 16px;
font-weight: 600;
}
.goods-list {

View File

@@ -92,9 +92,9 @@
</div>
<div class="foot">
<div type="flex" justify="space-around" class="help">
<router-link to="/article" class="item" target="_blank">帮助</router-link>
<router-link to="/article?id=1371779927900160000" class="item" target="_blank">隐私</router-link>
<router-link to="/article?id=1371992704333905920" class="item" target="_blank">条款</router-link>
<router-link to="/article" class="item">帮助</router-link>
<router-link to="/article?id=1371779927900160000" class="item">隐私</router-link>
<router-link to="/article?id=1371992704333905920" class="item">条款</router-link>
</div>
<div type="flex" justify="center" class="copyright">
Copyright © {{year}} - Present
@@ -601,3 +601,5 @@ export default {
}
}
</style>

View File

@@ -0,0 +1,44 @@
<template>
<div class="not-found-page">
<BaseHeader></BaseHeader>
<Search :show-tag="false"></Search>
<div class="not-found">
<h1>页面不存在</h1>
<p>你访问的地址没有对应页面</p>
<el-button type="primary" @click="$router.push('/')">返回首页</el-button>
</div>
<BaseFooter></BaseFooter>
</div>
</template>
<script>
export default {
name: "NotFound",
};
</script>
<style scoped lang="scss">
.not-found {
width: 1200px;
min-height: 420px;
margin: 24px auto 40px;
background: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.not-found h1 {
margin: 0;
color: #333;
font-size: 28px;
font-weight: 600;
}
.not-found p {
margin: 12px 0 24px;
color: #999;
font-size: 14px;
}
</style>

View File

@@ -66,7 +66,7 @@
<el-form-item>
<el-button class="signup-btn" @click="handleRegist">注册</el-button>
</el-form-item>
<p class="agreement">点击注册,表示您同意《<router-link to="/article?id=1371992704333905920" target="_blank">商城用户协议</router-link>》</p>
<p class="agreement">点击注册,表示您同意《<router-link to="/article?id=1371992704333905920">商城用户协议</router-link>》</p>
</el-form>
<Verify
ref="verify"
@@ -78,9 +78,9 @@
</div>
<div class="foot">
<div type="flex" justify="space-around" class="help">
<router-link to="/article" class="item" target="_blank">帮助</router-link>
<router-link to="/article?id=1371779927900160000" class="item" target="_blank">隐私</router-link>
<router-link to="/article?id=1371992704333905920" class="item" target="_blank">条款</router-link>
<router-link to="/article" class="item">帮助</router-link>
<router-link to="/article?id=1371779927900160000" class="item">隐私</router-link>
<router-link to="/article?id=1371992704333905920" class="item">条款</router-link>
</div>
<div type="flex" justify="center" class="copyright">
Copyright © {{year}} - Present

View File

@@ -1,35 +1,271 @@
<template>
<div>
<div class="userBox">
<div class="box">
<!-- 我的订单组件 -->
<myOrderPage :homePage="true" />
</div>
<div class="box">
<!-- 近期收藏 -->
<myFavorites :homePage="true" />
</div>
<div class="box">
<div class="member-home">
<div class="home-head">
<div>
<div class="home-hello">你好,{{ displayName }}</div>
<div class="home-sub">查看订单进度、优惠券和账户余额</div>
</div>
<button type="button" class="msg-entry" @click="go('MsgList')">
<el-icon :size="18"><Bell /></el-icon>
<span>我的消息</span>
<i v-if="unreadMsg > 0" class="msg-badge">{{ unreadLabel }}</i>
</button>
</div>
<div class="status-grid">
<button
v-for="item in orderStats"
:key="item.key"
type="button"
class="stat-card"
@click="item.action()"
>
<span class="stat-count">{{ item.count }}</span>
<span class="stat-label">{{ item.label }}</span>
</button>
</div>
<div class="asset-grid">
<button type="button" class="asset-card" @click="go('Coupons')">
<div>
<div class="asset-label">优惠券</div>
<div class="asset-value">{{ couponCount }}<span>张可用</span></div>
</div>
<span class="asset-link">查看</span>
</button>
<button type="button" class="asset-card" @click="go('MoneyManagement')">
<div>
<div class="asset-label">账户余额</div>
<div class="asset-value">¥{{ $filters.unitPrice(balance) }}</div>
</div>
<span class="asset-link">查看</span>
</button>
</div>
</div>
</template>
<script>
import myOrderPage from '@/pages/home/orderCenter/MyOrder'
import myFavorites from '@/pages/home/memberCenter/Favorites'
import { Bell } from "@element-plus/icons-vue";
import storage from "@/plugins/storage";
import { getOrderList } from "@/api/order";
import { afterSaleList, getMembersWallet, memberCouponList } from "@/api/member";
import { refreshUnreadMessage } from "@/utils/unreadMessage";
function totalOf(res) {
return res && res.success ? Number(res.result && res.result.total) || 0 : 0;
}
export default {
name: 'main',
components: {
myOrderPage,
myFavorites
}
}
name: "UserCenterHome",
components: { Bell },
data() {
return {
displayName: "会员",
waitPay: 0,
waitShip: 0,
waitReceive: 0,
afterSale: 0,
couponCount: 0,
balance: 0,
};
},
computed: {
unreadMsg() {
return Number(this.$store.state.unreadMsg) || 0;
},
unreadLabel() {
return this.unreadMsg > 99 ? "99+" : String(this.unreadMsg);
},
orderStats() {
return [
{ key: "pay", label: "待付款", count: this.waitPay, action: () => this.goOrder("WAIT_PAY") },
{ key: "ship", label: "待发货", count: this.waitShip, action: () => this.goOrder("WAIT_SHIP") },
{ key: "receive", label: "待收货", count: this.waitReceive, action: () => this.goOrder("WAIT_USE") },
{ key: "after", label: "售后", count: this.afterSale, action: () => this.go("AfterSale") },
];
},
},
mounted() {
this.readUser();
this.loadSummary();
},
methods: {
readUser() {
try {
const raw = storage.getItem("userInfo");
const user = raw ? JSON.parse(raw) : {};
this.displayName = user.nickName || user.username || "会员";
} catch {
this.displayName = "会员";
}
},
loadSummary() {
const page = { pageNumber: 1, pageSize: 1 };
Promise.all([
getOrderList({ ...page, tag: "WAIT_PAY" }).catch(() => null),
getOrderList({ ...page, tag: "WAIT_SHIP" }).catch(() => null),
getOrderList({ ...page, tag: "WAIT_USE" }).catch(() => null),
afterSaleList({ ...page, sort: "createTime", order: "desc" }).catch(() => null),
memberCouponList({ ...page, memberCouponStatus: "NEW" }).catch(() => null),
getMembersWallet().catch(() => null),
refreshUnreadMessage(),
]).then(([pay, ship, receive, afterSale, coupon, wallet]) => {
this.waitPay = totalOf(pay);
this.waitShip = totalOf(ship);
this.waitReceive = totalOf(receive);
this.afterSale = totalOf(afterSale);
this.couponCount = totalOf(coupon);
this.balance = wallet && wallet.success ? wallet.result.memberWallet || 0 : 0;
});
},
go(name) {
this.$router.push({ name });
},
goOrder(tag) {
this.$router.push({ name: "MyOrder", query: { tag } });
},
},
};
</script>
<style scoped lang="scss">
.userBox {
padding: 0 0 20px 0;
.member-home {
display: flex;
flex-direction: column;
gap: 16px;
}
.home-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 4px 4px 8px;
}
.home-hello {
color: #333;
font-size: 18px;
font-weight: 600;
line-height: 26px;
}
.home-sub {
margin-top: 4px;
color: #999;
font-size: 14px;
line-height: 22px;
}
.msg-entry {
display: inline-flex;
align-items: center;
gap: 6px;
height: 36px;
padding: 0 14px;
border: 1px solid #eee;
border-radius: 18px;
background: #fff;
color: #333;
font-size: 14px;
cursor: pointer;
&:hover {
color: $theme_color;
border-color: $theme_color;
}
}
.msg-badge {
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
background: $theme_color;
color: #fff;
font-size: 12px;
font-style: normal;
line-height: 16px;
text-align: center;
}
.status-grid,
.asset-grid {
display: grid;
gap: 12px;
}
.status-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.asset-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.stat-card,
.asset-card {
border: 1px solid #eee;
border-radius: 8px;
background: #fff;
cursor: pointer;
text-align: left;
&:hover {
border-color: $theme_color;
}
}
.stat-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 18px 16px;
}
.stat-count {
color: $theme_color;
font-size: 24px;
font-weight: 600;
line-height: 32px;
}
.stat-label {
color: #666;
font-size: 14px;
line-height: 22px;
}
.asset-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 20px;
}
.asset-label {
color: #999;
font-size: 14px;
line-height: 22px;
}
.asset-value {
margin-top: 4px;
color: #333;
font-size: 20px;
font-weight: 600;
line-height: 28px;
span {
margin-left: 4px;
color: #999;
font-size: 14px;
font-weight: 400;
}
}
.asset-link {
color: $theme_color;
font-size: 14px;
}
</style>

View File

@@ -1,44 +1,36 @@
<template>
<div class="wrapper">
<UserCenterLayout>
<div class="safeList">
<!-- 密码 -->
<div class="safeItem">
<div class="safeItem-icon">
<el-icon :size="40"><Key /></el-icon>
<div class="safe-list">
<div class="safe-card">
<div class="safe-icon">
<el-icon :size="22"><Key /></el-icon>
</div>
<div class="safeItem-content">
<div class="setDivItem">登录密码</div>
<div class="setDivItem theme">互联网账号存在被盗风险,建议您定期更改密码以保护账户安全。</div>
</div>
<div class="safeItem-action">
<el-button @click="modifyPwd">修改密码</el-button>
<div class="safe-body">
<p class="safe-title">登录密码</p>
<p class="safe-desc">互联网账号存在被盗风险,建议您定期更改密码以保护账户安全。</p>
</div>
<el-button @click="modifyPwd">修改密码</el-button>
</div>
<div class="safeItem safeItem-connect">
<div class="safeItem-main">
<div class="safeItem-icon">
<el-icon :size="40"><Link /></el-icon>
<div class="safe-card safe-card-connect">
<div class="safe-card-main">
<div class="safe-icon">
<el-icon :size="22"><Link /></el-icon>
</div>
<div class="safeItem-content">
<div class="setDivItem">第三方账户绑定</div>
<div class="setDivItem theme">绑定后可使用第三方账号快捷登录。</div>
</div>
<div class="safeItem-action">
<el-button v-if="!isBound(wechatConnect.type)" type="primary" @click="openBindModal(wechatConnect)">绑定</el-button>
<el-button v-else @click="confirmUnbind(wechatConnect)">解绑</el-button>
<div class="safe-body">
<p class="safe-title">第三方账户绑定</p>
<p class="safe-desc">绑定后可使用第三方账号快捷登录。</p>
</div>
<el-button v-if="!isBound(wechatConnect.type)" type="primary" @click="openBindModal(wechatConnect)">绑定</el-button>
<el-button v-else @click="confirmUnbind(wechatConnect)">解绑</el-button>
</div>
<div class="connectList">
<div class="connectRow">
<div class="connectRowLeft">
<span class="connectName">{{ wechatConnect.label }}</span>
<el-tag v-if="isBound(wechatConnect.type)" color="success">已绑定</el-tag>
<el-tag v-else>未绑定</el-tag>
</div>
</div>
<el-skeleton size="large" fix v-if="connectLoading"></el-skeleton>
<div class="connect-row">
<span class="connect-name">{{ wechatConnect.label }}</span>
<span class="connect-status" :class="{ 'is-bound': isBound(wechatConnect.type) }">
{{ isBound(wechatConnect.type) ? "已绑定" : "未绑定" }}
</span>
<el-skeleton v-if="connectLoading" animated class="connect-skeleton" />
</div>
</div>
</div>
@@ -273,73 +265,101 @@ export default {
</script>
<style scoped lang="scss">
.theme {
color: $theme_color;
.safe-list {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 4px;
}
.setDivItem {
line-height: 1.75;
}
.safeItem {
.safe-card,
.safe-card-main {
display: flex;
align-items: center;
gap: 16px;
border-bottom: 1px solid $border_color;
padding: 16px 0;
}
.safeItem-connect {
.safe-card {
padding: 20px;
border: 1px solid #f0f0f0;
border-radius: 8px;
}
.safe-card-connect {
display: block;
padding: 0;
}
.safeItem-main {
display: flex;
align-items: center;
gap: 16px;
.safe-card-main {
padding: 20px 20px 16px;
}
.safeItem-icon {
.safe-icon {
flex-shrink: 0;
width: 56px;
width: 48px;
height: 48px;
border-radius: 8px;
background: #fff5f4;
color: $theme_color;
display: flex;
align-items: center;
justify-content: center;
color: $theme_color;
}
.safeItem-content {
.safe-body {
flex: 1;
min-width: 0;
}
.safeItem-action {
flex-shrink: 0;
display: flex;
align-items: center;
.safe-title {
margin: 0;
color: #171A26;
font-size: 16px;
font-weight: 600;
line-height: 24px;
}
.connectList {
.safe-desc {
margin: 6px 0 0;
color: #999;
font-size: 13px;
line-height: 20px;
}
.connect-row {
position: relative;
margin-top: 8px;
margin-left: 72px;
}
.connectRow {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 0;
}
.connectRowLeft {
display: flex;
align-items: center;
gap: 10px;
margin: 0 20px 16px 84px;
padding-top: 12px;
border-top: 1px solid #f5f5f5;
}
.connectName {
color: $title_color;
.connect-name {
color: #171A26;
font-size: 14px;
line-height: 22px;
}
.connect-status {
height: 22px;
padding: 0 8px;
border-radius: 4px;
background: #f5f5f5;
color: #999;
font-size: 12px;
line-height: 22px;
}
.connect-status.is-bound {
background: #f0faf3;
color: #1f9d55;
}
.connect-skeleton {
width: 120px;
height: 22px;
}
.wechatQrBox {

View File

@@ -280,3 +280,9 @@ export default {
}
}
</style>

View File

@@ -200,11 +200,10 @@ export default {
},
goShop(id) { // 跳转店铺页面
let url = this.$router.resolve({
this.$router.push({
path: '/merchant',
query: { id }
})
window.open(url.href, '_blank')
}
},
mounted() {

View File

@@ -25,6 +25,7 @@
<script>
import { Message, Modal } from "@/utils/message";
import { delMemberMsg, getMemberMessage, readMemberMsg } from '@/api/member.js';
import { refreshUnreadMessage } from '@/utils/unreadMessage';
const CACHE_KEY = 'buyer-member-message';
@@ -71,7 +72,10 @@ export default {
markRead () {
if (!this.detail.id || this.detail.status !== 'UN_READY') return
readMemberMsg(this.detail.id).then(res => {
if (res && res.success) this.detail.status = 'ALREADY_READY'
if (res && res.success) {
this.detail.status = 'ALREADY_READY'
refreshUnreadMessage()
}
}).catch(() => {})
},
goBack () {
@@ -86,6 +90,7 @@ export default {
delMemberMsg(this.detail.id).then((res) => {
if (res.success) {
Message.success('消息已成功放入回收站')
refreshUnreadMessage()
this.goBack()
}
})

View File

@@ -94,6 +94,9 @@ export default {
memberMsgList(this.params).then(res => {
if (res.success) {
this.messageData = res.result;
if (this.params.status === 'UN_READY') {
this.$store.commit('SET_UNREAD_MSG', Number(res.result.total) || 0);
}
}
})
},

View File

@@ -212,11 +212,10 @@ export default {
},
// 跳转店铺首页
shopPage(id) {
let routeUrl = this.$router.resolve({
this.$router.push({
path: '/merchant',
query: {id: id}
});
window.open(routeUrl.href, '_blank');
},
change(index) {
this.activeTab = index

View File

@@ -211,11 +211,10 @@ export default {
},
shopPage (id) {
if (!id) return
const routeUrl = this.$router.resolve({
this.$router.push({
path: '/merchant',
query: { id }
})
window.open(routeUrl.href, '_blank')
},
filterOrderStatus (status) { // 获取订单状态中文
const ob = this.afterSaleStatusList.filter(e => { return e.status === status });

View File

@@ -223,7 +223,7 @@ export default {
// 状态数组
orderStatusList,
afterSaleStatusList,
changeWay: ['全部', '待支付', '待发货', '待收货/使用'],
changeWay: ['全部', '待付款', '待发货', '待收货/使用'],
activeOrderTab: 0,
total: 0, // 数据总数
spinShow: false, // 加载状态
@@ -235,8 +235,18 @@ export default {
},
mounted () {
if (this.homePage) this.params.pageSize = 5;
if (!this.homePage) this.applyRouteTag();
this.getList();
},
watch: {
'$route.query.tag' () {
if (this.homePage || this.$route.name !== 'MyOrder') return;
const next = this.$route.query.tag || 'ALL';
if (this.params.tag === next) return;
this.applyRouteTag();
this.getList();
}
},
methods: {
// 退款状态枚举
refundPriceList(status) {
@@ -261,22 +271,39 @@ export default {
});
window.open(routeUrl.href, '_blank');
},
orderTags () {
return ['ALL', 'WAIT_PAY', 'WAIT_SHIP', 'WAIT_USE'];
},
applyRouteTag () {
const tags = this.orderTags();
const tag = this.$route.query.tag;
const index = tags.indexOf(tag);
this.activeOrderTab = index > -1 ? index : 0;
this.params.tag = tags[this.activeOrderTab];
this.params.pageNumber = 1;
},
// 切换订单状态
change (index) {
this.activeOrderTab = index;
this.params.pageNumber = 1;
delete this.params.commentStatus;
const tags = ['ALL', 'WAIT_PAY', 'WAIT_SHIP', 'WAIT_USE'];
const tags = this.orderTags();
this.params.tag = tags[index] || 'ALL';
const query = { ...this.$route.query };
if (index === 0) delete query.tag;
else query.tag = this.params.tag;
const current = this.$route.query.tag || 'ALL';
if (current !== this.params.tag) {
this.$router.replace({ name: 'MyOrder', query });
}
this.getList()
},
// 跳转店铺首页
shopPage (id) {
let routeUrl = this.$router.resolve({
this.$router.push({
path: '/merchant',
query: { id: id }
});
window.open(routeUrl.href, '_blank');
},
orderDetail (sn) {
this.$router.push({ name: 'OrderDetail', query: {sn} });

View File

@@ -589,11 +589,10 @@ export default {
},
// 跳转店铺首页
shopPage(id) {
let routeUrl = this.$router.resolve({
this.$router.push({
path: "/merchant",
query: { id: id },
});
window.open(routeUrl.href, "_blank");
},
async getDetail() {
// 获取订单详情

View File

@@ -72,11 +72,10 @@ export default {
},
// 跳转店铺首页
shopPage(id) {
let routeUrl = this.$router.resolve({
this.$router.push({
path: "/merchant",
query: { id: id },
});
window.open(routeUrl.href, "_blank");
},
clearAll() {
// 清除全部足迹

View File

@@ -836,11 +836,10 @@ export default {
},
// 跳转店铺首页
goShopPage(id) {
let routeUrl = this.$router.resolve({
this.$router.push({
path: "/merchant",
query: { id: id },
});
window.open(routeUrl.href, "_blank");
},
useCoupon(id, used) {
// 使用优惠券

View File

@@ -29,46 +29,32 @@
<div v-else class="goods-show-info1" v-for="(item, index) in goodsList" :key="index"
@click="goGoodsDetail(item.skuId, item.goodsId)">
<div class="goods-show-img">
<img width="200" height="200" :src="item.goodsImage" />
<img :src="item.goodsImage" :alt="item.goodsName" />
</div>
<div class="goods-show-price">
<span>
<span class="seckill-price text-danger">{{ $filters.unitPrice(item.price, "¥") }}</span>
<span style="color: #999; text-decoration: line-through">{{ $filters.unitPrice(item.originalPrice, "¥") }}</span>
</span>
<span class="seckill-price text-danger">{{ $filters.unitPrice(item.price, "¥") }}</span>
<span class="origin-price">{{ $filters.unitPrice(item.originalPrice, "¥") }}</span>
</div>
<div class="goods-show-detail" style="margin-bottom: 20px">
<div class="goods-show-detail">
<span>{{ item.goodsName }}</span>
</div>
<div class="goods-seckill-btn" :class="{
'goods-seckill-btn-gray': nowHour < list[currIndex].timeLine,
}">
{{ nowHour >= list[currIndex].timeLine ? "立即抢购" : "即将开始" }}
</div>
<div v-if="nowHour >= list[currIndex].timeLine && item.quantity <= item.salesNum"
class="goods-seckill-btn goods-seckill-btn-gray">
已售罄
</div>
<div v-if="nowHour >= list[currIndex].timeLine" class="goods-show-num1">
<span>已售{{
(item.quantity && item.quantity > 0
? Math.ceil(
(item.salesNum / (item.quantity)) * 100
)
: 100) + "%"
}}</span><el-progress
<span>已售{{ salePercent(item) }}%</span>
<el-progress
:show-text="false"
color="#df0021"
style="width: 110px"
class="ml_10"
:percentage="item.quantity && item.quantity > 0
? Math.ceil((item.salesNum / item.quantity) * 100)
: 100"
:color="'var(--theme-color, #ff3c2a)'"
:percentage="salePercent(item)"
/>
</div>
<div class="goods-show-seller">
<span>{{ item.storeName }}</span>
</div>
<div
class="goods-seckill-btn"
:class="{ 'is-disabled': !canBuy(item) }"
>
{{ buyText(item) }}
</div>
</div>
</div>
</div>
@@ -142,6 +128,25 @@ export default {
});
window.open(routeUrl.href, "_blank");
},
// 已售占比。没有库存时按售罄展示
salePercent (item) {
if (!item.quantity || item.quantity <= 0) return 100;
return Math.min(100, Math.ceil((item.salesNum / item.quantity) * 100));
},
// 场次已开始,且销量达到库存
isSoldOut (item) {
return this.nowHour >= this.list[this.currIndex].timeLine && item.quantity <= item.salesNum;
},
// 场次已开始且未售罄才可抢
canBuy (item) {
return this.nowHour >= this.list[this.currIndex].timeLine && !this.isSoldOut(item);
},
// 按钮文案:未开始 / 已售罄 / 立即抢购
buyText (item) {
if (this.nowHour < this.list[this.currIndex].timeLine) return "即将开始";
if (this.isSoldOut(item)) return "已售罄";
return "立即抢购";
},
countDown (currIndex) {
// 倒计时
// 0点时间戳
@@ -175,73 +180,96 @@ export default {
<style lang="scss" scoped>
@import "../../assets/styles/goodsList.scss";
.goods-seckill-btn {
position: absolute;
right: 0;
bottom: 0;
width: 80px;
color: #fff;
height: 35px;
text-align: center;
line-height: 35px;
font-size: 14px;
background-color: $theme_color;
margin-bottom: 1px;
margin-right: 1px;
.seckill-list {
background: #f5f5f5;
}
.goods-seckill-btn-gray {
background-color: #666;
}
.promotion-decorate {
width: auto;
margin: 28px auto 16px;
color: #171A26;
font-size: 22px;
font-weight: 600;
.promotion-decorate::before,
.promotion-decorate::after {
background-image: url("/src/assets/images/sprite@2x.png");
&::before,
&::after {
display: none;
}
}
.time-line {
width: 1200px;
max-width: 1200px;
height: 60px;
height: auto;
margin: 0 auto;
background-color: #fff;
background: #fff;
display: flex;
box-sizing: border-box;
border-radius: 8px;
overflow: hidden;
li {
padding: 0 30px;
font-size: 16px;
font-weight: bold;
width: 240px;
height: 100%;
flex: 1;
width: auto;
min-height: 78px;
padding: 12px 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 4px;
font-size: 18px;
font-weight: 600;
color: #171A26;
box-sizing: border-box;
& + li {
box-shadow: inset 1px 0 0 #f2f2f2;
}
&:hover {
cursor: pointer;
background: #fafafa;
}
p {
margin: 0;
font-size: 13px;
font-weight: 400;
line-height: 18px;
}
.not-curr {
border: 1px solid #999;
border-radius: 20px;
padding: 3px 10px;
margin-left: 10px;
font-size: 12px;
font-weight: normal;
border: none;
border-radius: 0;
padding: 0;
margin: 0;
font-size: 13px;
font-weight: 400;
color: #999;
}
}
.currTimeline {
background-color: $theme_color;
background: $theme_color;
color: #fff;
>div:nth-child(1) {
font-size: 20px;
&:hover {
background: $theme_color;
}
>div:nth-child(2) {
font-size: 14px;
margin-left: 10px;
> div:nth-child(1) {
font-size: 22px;
line-height: 28px;
}
> div:nth-child(2) {
margin-left: 0;
}
.not-curr {
color: #fff;
}
}
}
@@ -249,55 +277,116 @@ export default {
.seckill-goods-wrap {
width: 1200px;
max-width: 1200px;
margin: 0 auto;
margin: 16px auto 40px;
padding: 16px;
box-sizing: border-box;
background: #fff;
border-radius: 8px;
min-height: 320px;
.goods-list {
width: 100%;
margin: 0;
column-gap: 10px;
column-gap: 16px;
}
:deep(.empty) {
margin: 48px 0;
color: #999;
}
}
.goods-show-info1 {
width: calc(25% - 8px);
width: calc(25% - 12px);
min-width: 0;
padding: 6px;
margin: 10px 0;
padding: 12px;
margin: 0 0 16px;
position: relative;
border: 1px solid #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
cursor: pointer;
background-color: #fff;
box-sizing: border-box;
}
display: flex;
flex-direction: column;
.goods-show-info1:hover {
border: 1px solid #ccc;
box-shadow: 0px 0px 15px #ccc;
&:hover {
border-color: #eee;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.06);
}
}
.goods-show-img {
display: flex;
justify-content: center;
border-radius: 6px;
overflow: hidden;
background: #fafafa;
img {
width: 100%;
max-width: 200px;
height: auto;
aspect-ratio: 1;
object-fit: cover;
}
}
.goods-show-price {
display: flex;
align-items: baseline;
gap: 8px;
margin-top: 10px;
}
.origin-price {
color: #999;
font-size: 13px;
text-decoration: line-through;
}
.goods-show-detail {
height: 40px;
margin: 8px 0;
color: #333;
font-size: 14px;
line-height: 20px;
}
.goods-show-num1 {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
margin-bottom: 6px;
margin-bottom: 8px;
color: #666;
span {
flex-shrink: 0;
}
:deep(.el-progress) {
flex: 1;
}
}
.el-progress-bar__inner {
background-color: #df0021;
.goods-show-seller {
margin-bottom: 10px;
color: #999;
font-size: 13px;
}
.goods-seckill-btn {
margin-top: auto;
height: 34px;
border-radius: 4px;
color: #fff;
text-align: center;
line-height: 34px;
font-size: 14px;
background-color: $theme_color;
&.is-disabled {
background: #c8c8c8;
}
}
</style>

View File

@@ -24,17 +24,25 @@
<p>{{ userInfo.nickName }}</p>
</div>
<el-menu-item index="UserCenter">
<el-icon><House /></el-icon>
<span>我的主页</span>
</el-menu-item>
<template v-for="(menu, index) in menuList" :key="index">
<el-sub-menu v-if="menu.display" :index="menu.title">
<template #title>
<el-icon><Location /></el-icon>
<el-icon><component :is="menuIcon(menu.icon)" /></el-icon>
<span>{{ menu.title }}</span>
</template>
<el-menu-item
v-for="(chlidren, i) in menu.menus"
:key="i"
:index="chlidren.path"
>{{ chlidren.title }}</el-menu-item>
>
<el-icon><component :is="menuIcon(chlidren.icon)" /></el-icon>
<span>{{ chlidren.title }}</span>
<span v-if="chlidren.path === 'MsgList' && unreadMsg > 0" class="menu-badge">{{ unreadLabel }}</span>
</el-menu-item>
</el-sub-menu>
</template>
</el-menu>
@@ -48,12 +56,51 @@
</template>
<script>
import { Location, User } from '@element-plus/icons-vue';
import {
Bell,
ChatDotRound,
Clock,
Coin,
Goods,
House,
List,
Location,
Lock,
Present,
RefreshLeft,
Star,
Ticket,
Trophy,
User,
Wallet,
Warning,
} from '@element-plus/icons-vue';
import menuList from "./menu";
import Storage from "@/plugins/storage.js";
import { refreshUnreadMessage } from "@/utils/unreadMessage";
const MENU_ICONS = {
Bell,
ChatDotRound,
Clock,
Coin,
Goods,
House,
List,
Location,
Lock,
Present,
RefreshLeft,
Star,
Ticket,
Trophy,
User,
Wallet,
Warning,
};
export default {
components: { Location, User },
components: { House, User },
name: "Home",
data() {
return {
@@ -67,14 +114,24 @@ export default {
}
return {};
},
unreadMsg() {
return Number(this.$store.state.unreadMsg) || 0;
},
unreadLabel() {
return this.unreadMsg > 99 ? "99+" : String(this.unreadMsg);
},
},
mounted() {
document.body.classList.add("buyer-user-center");
refreshUnreadMessage();
},
beforeUnmount() {
document.body.classList.remove("buyer-user-center");
},
methods: {
menuIcon(name) {
return MENU_ICONS[name] || Goods;
},
onSelect(name) {
this.$router.push({ name });
},
@@ -134,6 +191,38 @@ export default {
overflow: hidden;
}
.side-menu {
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
display: flex;
align-items: center;
}
:deep(.el-menu-item .el-icon),
:deep(.el-sub-menu__title .el-icon) {
margin-right: 6px;
}
}
.menu-badge {
margin-left: auto;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
background: $theme_color;
color: #fff;
font-size: 12px;
font-style: normal;
line-height: 16px;
text-align: center;
}
/* 选中项会把文字设成主题红。角标用 span,避免 i 继承成红底红字 */
.side-menu :deep(.el-menu-item.is-active) .menu-badge {
color: #fff;
}
.layout-footer-center {
padding: 0px 15px;
padding-bottom: 15px;

View File

@@ -6,20 +6,20 @@
// 订单中心
const order = [{
icon: '',
icon: 'List',
title: '订单中心',
menus: [{
icon: '',
icon: 'Goods',
title: '我的订单',
path: 'MyOrder'
},
{
icon: '',
icon: 'Location',
title: '收货地址',
path: 'MyAddress'
},
{
icon: '',
icon: 'RefreshLeft',
title: '售后订单',
path: 'AfterSale'
}
@@ -29,66 +29,66 @@ const order = [{
// 会员中心
const member = [{
icon: '',
icon: 'User',
title: '会员中心',
menus: [{
icon: '',
icon: 'User',
title: '用户信息',
path: 'Profile'
},
{
icon: '',
icon: 'Lock',
title: '账户安全',
path: 'AccountSafe'
},
{
icon: '',
icon: 'Bell',
title: '我的消息',
path: 'MsgList'
},
{
icon: '',
icon: 'Clock',
title: '我的足迹',
path: 'MyTracks'
},
{
icon: '',
icon: 'Star',
title: '我的收藏',
path: 'Favorites'
},
{
icon: '',
icon: 'ChatDotRound',
title: '我的评论',
path: 'CommentList'
},
{
icon: '',
icon: 'Warning',
title: '我的投诉',
path: 'ComplainList'
},
{
icon: '',
icon: 'Coin',
title: '我的积分',
path: 'Point'
},
{
icon: '',
icon: 'Trophy',
title: '我的等级',
path: 'MemberGrade'
},
{
icon: '',
icon: 'Ticket',
title: '我的优惠券',
path: 'Coupons'
},
{
icon: '',
icon: 'Wallet',
title: '我的余额',
path: 'MoneyManagement'
},
{
icon: '',
icon: 'Present',
title: '我的礼品卡',
path: 'MyGiftCards'
}

View File

@@ -51,7 +51,9 @@ const MoneyManagement = () => import("@/pages/home/userCenter/MoneyManagement");
const MyGiftCards = () => import("@/pages/home/userCenter/MyGiftCards");
const Home = () => import("@/pages/user/Home");
const UserCenter = () => import("@/pages/home/Main");
const Merchant = () => import("@/pages/Merchant");
const NotFound = () => import("@/pages/NotFound");
const topic = () => import("@/pages/Topic");
const ShopEntry = () => import("@/pages/shopEntry/ShopEntry");
@@ -178,8 +180,13 @@ export const router = createRouter({
{
path: "/home",
component: Home,
redirect: { name: "MyOrder" },
children: [
{
path: "",
name: "UserCenter",
component: UserCenter,
meta: { title: "个人中心" },
},
{
path: "MyTracks",
name: "MyTracks",
@@ -213,19 +220,19 @@ export const router = createRouter({
path: "CommentList",
name: "CommentList",
component: CommentList,
mate: { title: "评价列表" },
meta: { title: "评价列表" },
},
{
path: "AddEval",
name: "AddEval",
component: AddEval,
mate: { title: "添加评价" },
meta: { title: "添加评价" },
},
{
path: "EvalDetail",
name: "EvalDetail",
component: EvalDetail,
mate: { title: "评价详情" },
meta: { title: "评价详情" },
},
{
path: "ComplainList",
@@ -331,6 +338,12 @@ export const router = createRouter({
component: Merchant,
meta: { title: "店铺" },
},
{
path: "/:pathMatch(.*)*",
name: "NotFound",
component: NotFound,
meta: { title: "页面不存在" },
},
],
});

View File

@@ -1,16 +1,40 @@
/**
* 买家端内部链接跳转(首页装修、列表等共用)
*
* @author Mike
* @date 2026-08-05
* 商品详情新开标签,其余站内页面在当前页打开。外链仍新开。
*/
/** 内部链接新窗口打开,外链直接打开。浏览商品详情不要求登录。 */
const NEW_TAB_PATHS = ["/goodsDetail", "/pointGoodsDetail"];
function isExternalLink(url) {
return /^https?:\/\//i.test(url) || url.startsWith("//");
}
function normalizeInternal(url) {
return url.charAt(0) === "/" ? url : `/${url}`;
}
function pathnameOf(url) {
return normalizeInternal(url).split("?")[0].split("#")[0];
}
export function shouldOpenNewTab(url) {
if (!url) return false;
if (isExternalLink(url)) return true;
const path = pathnameOf(url);
return NEW_TAB_PATHS.indexOf(path) !== -1;
}
export function openLink(url, router) {
if (!url) return;
if (url.charAt(0) !== "/") {
if (isExternalLink(url)) {
window.open(url, "_blank");
return;
}
window.open(router.resolve(url).href, "_blank");
const internal = normalizeInternal(url);
if (shouldOpenNewTab(internal)) {
window.open(router.resolve(internal).href, "_blank");
return;
}
return router.push(internal);
}

View File

@@ -0,0 +1,24 @@
import store from "@/vuex/store";
import storage from "@/plugins/storage";
import { memberMsgList } from "@/api/member";
// 用未读消息总数刷新角标,只取 1 条记录
export function refreshUnreadMessage() {
if (!storage.getItem("userInfo")) {
store.commit("SET_UNREAD_MSG", 0);
return Promise.resolve(0);
}
return memberMsgList({
pageNumber: 1,
pageSize: 1,
status: "UN_READY",
})
.then((res) => {
const total = res && res.success ? Number(res.result && res.result.total) || 0 : 0;
store.commit("SET_UNREAD_MSG", total);
return total;
})
.catch(() => {
return Number(store.state.unreadMsg) || 0;
});
}

View File

@@ -9,6 +9,9 @@ export const SET_NAVLIST = (state, data) => {
export const SET_CARTNUM = (state, data) => {
state.cartNum = data
}
export const SET_UNREAD_MSG = (state, data) => {
state.unreadMsg = Number(data) || 0
}
export const SET_HOTWORDS = (state, data) => {
state.hotWordsList = data
}

View File

@@ -8,6 +8,7 @@ export default createStore({
state: {
navList: [],
cartNum: storage.getItem("cartNum") || 0,
unreadMsg: 0,
logoImg: storage.getItem("logoImg") || defaultLogo,
siteName: storage.getItem("siteName") || "lilishop",
hotWordsList: storage.getItem("hotWordsList"),