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

@@ -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>