fix(buyer): 修复路由兼容与页面加载体验

- 为文章 API 添加 skipMessage 选项,由页面接管错误提示
- 添加旧路由重定向(UserInfo -> Profile, Msg -> MsgList)
- 重构文章页面,增加分页、骨架屏、分类独立加载与重试机制
- 用户权益组件接入真实登录状态与购物车、优惠券、订单数量
- 修正装修组件中收藏、足迹、领券等服务的旧地址映射
- 删除已过时的 MIGRATION-VUE3.md 迁移文档
This commit is contained in:
pikachu1995@126.com
2026-11-09 11:08:37 +08:00
parent d799b37f0c
commit fd241bc36a
8 changed files with 305 additions and 308 deletions

View File

@@ -45,32 +45,36 @@ export function getRegion (id) {
/**
* 分页获取文章列表
* @param cateId 文章分类id
* @param params 分页及分类查询条件
* @param options 请求选项,skipMessage 为 true 时由页面展示错误
*/
export function articleList (params) {
export function articleList (params, options = {}) {
return request({
url: `/buyer/other/article`,
method: Method.GET,
params
params,
skipMessage: options.skipMessage
});
}
/**
* 获取帮助中心文章分类列表
* @param cateId 文章分类id
* @param options 请求选项,skipMessage 为 true 时由页面展示错误
*/
export function articleCateList () {
export function articleCateList (options = {}) {
return request({
url: `/buyer/other/article/articleCategory/list`,
method: Method.GET
method: Method.GET,
skipMessage: options.skipMessage
});
}
// 通过id获取文章
export function articleDetail (id) {
/** 通过文章 ID 获取正文;可由页面接管错误提示。 */
export function articleDetail (id, options = {}) {
return request({
url: `/buyer/other/article/get/${id}`,
method: Method.GET
method: Method.GET,
skipMessage: options.skipMessage
});
}

View File

@@ -50,10 +50,10 @@ const props = defineProps({
});
const defaultMenus = [
{ title: '基本信息', url: '/home/UserInfo' },
{ title: '基本信息', url: '/home/Profile' },
{ title: '我的订单', url: '/home/MyOrder' },
{ title: '账户安全', url: '/home/AccountSafe' },
{ title: '消息', url: '/home/Msg' }
{ title: '消息', url: '/home/MsgList' }
];
const username = computed(() => {

View File

@@ -3,7 +3,7 @@
<!-- 1. 用户头像与信息区 -->
<div class="user-main-info">
<div class="avatar-box">
<img v-if="options.avatar" :src="options.avatar" alt="avatar" />
<img v-if="displayAvatar" :src="displayAvatar" alt="用户头像" />
<div v-else class="avatar-placeholder">
<svg viewBox="0 0 24 24" width="22" height="22" fill="#ffffff">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
@@ -12,18 +12,22 @@
</div>
<div class="user-greeting">
<div class="badge-tag">{{ options.badgeText || '请登录' }}</div>
<div class="welcome-text" :title="options.welcomeText || '欢迎来到商城'">
{{ options.welcomeText || '欢迎来到商城' }}
<div class="badge-tag">{{ isLoggedIn ? '已登录' : (options.badgeText || '请登录') }}</div>
<div class="welcome-text" :title="welcomeText">
{{ welcomeText }}
</div>
<div class="sub-text" :title="options.subtitle || '登录后查看专属权益'">
{{ options.subtitle || '登录后查看专属权益' }}
<div class="sub-text" :title="subtitle">
{{ subtitle }}
</div>
</div>
</div>
<!-- 2. 登录 / 注册按钮组 -->
<div class="action-btn-row">
<!-- 已登录时提供会员入口,装修中的登录与注册文案仅用于游客。 -->
<div v-if="isLoggedIn" class="action-btn-row">
<router-link to="/home" class="action-btn login-btn">会员中心</router-link>
<router-link to="/home/MyOrder" class="action-btn register-btn">我的订单</router-link>
</div>
<div v-else class="action-btn-row">
<a href="javascript:;" class="action-btn login-btn" @click="handleNavigate(options.loginUrl || '/login')">
{{ options.loginText || '登录' }}
</a>
@@ -35,19 +39,23 @@
<!-- 3. 指标数据卡 (购物车 / 优惠券 / 订单) -->
<div class="metrics-cards-row">
<a href="javascript:;" class="metric-card" v-if="options.showCart !== false" @click="handleNavigate(options.cartUrl || '/cart')">
<div class="metric-val">0</div>
<div class="metric-val">{{ metricText('cart') }}</div>
<div class="metric-label">{{ options.cartText || '购物车' }}</div>
</a>
<a href="javascript:;" class="metric-card" v-if="options.showCoupons !== false" @click="handleNavigate(options.couponUrl || '/member/coupon')">
<div class="metric-val">0</div>
<a href="javascript:;" class="metric-card" v-if="options.showCoupons !== false" @click="handleNavigate(metricUrl('coupons'))">
<div class="metric-val">{{ metricText('coupons') }}</div>
<div class="metric-label">{{ options.couponText || '优惠券' }}</div>
</a>
<a href="javascript:;" class="metric-card" v-if="options.showOrders !== false" @click="handleNavigate(options.orderUrl || '/member/order')">
<div class="metric-val">0</div>
<a href="javascript:;" class="metric-card" v-if="options.showOrders !== false" @click="handleNavigate(metricUrl('orders'))">
<div class="metric-val">{{ metricText('orders') }}</div>
<div class="metric-label">{{ options.orderText || '订单' }}</div>
</a>
</div>
<button v-if="hasMetricError" type="button" class="metrics-retry" @click="loadMetrics">
数量加载失败,点击重试
</button>
<!-- 4. 快捷便民服务入口(底部图标排) -->
<div class="quick-service-row">
<a
@@ -65,11 +73,17 @@
</template>
<script setup>
import { computed } from 'vue';
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { Star, Clock, Present, Service } from '@element-plus/icons-vue';
import storage from '@/plugins/storage';
import { cartCount } from '@/api/cart';
import { memberCouponList } from '@/api/member';
import { getOrderList } from '@/api/order';
import { useAppStore } from '@/stores/app';
const router = useRouter();
const appStore = useAppStore();
const props = defineProps({
options: {
@@ -86,6 +100,113 @@ const props = defineProps({
}
});
const userInfo = ref({});
const isLoggedIn = ref(false);
/** 读取现有登录缓存;缺少令牌时按游客展示,损坏的资料缓存不阻断首页。 */
function syncLoginState() {
isLoggedIn.value = Boolean(storage.getItem('accessToken'));
userInfo.value = {};
if (!isLoggedIn.value) return;
try {
const cached = JSON.parse(storage.getItem('userInfo') || '{}');
userInfo.value = cached && typeof cached === 'object' ? cached : {};
} catch {
userInfo.value = {};
}
}
syncLoginState();
// 购物车沿用顶部购物车的有效 SKU 种数;优惠券统计未使用张数,订单统计全部订单。
const metrics = reactive({
cart: { status: 'idle', value: 0 },
coupons: { status: 'idle', value: 0 },
orders: { status: 'idle', value: 0 }
});
let metricRequestId = 0;
let metricsLoading = false;
const hasMetricError = computed(() => Object.values(metrics).some(item => item.status === 'error'));
/** 游客显示 0;已登录时区分加载、失败和真实的零值,购物车随公共状态更新。 */
function metricText(key) {
if (!isLoggedIn.value) return 0;
const metric = metrics[key];
if (metric.status === 'loading' || metric.status === 'idle') return '…';
if (metric.status === 'error') return '—';
return key === 'cart' ? appStore.cartNum : metric.value;
}
/** 将装修默认的历史地址映射到现有列表,保留商家配置的其他自定义链接。 */
function metricUrl(key) {
const isCoupon = key === 'coupons';
const configured = isCoupon ? props.options.couponUrl : props.options.orderUrl;
const legacy = isCoupon ? '/member/coupon' : '/member/order';
return !configured || configured === legacy
? (isCoupon ? '/home/Coupons' : '/home/MyOrder')
: configured;
}
/** 并行读取汇总;失败独立展示,离开页面后忽略旧请求,重入首页时刷新数量。 */
async function loadMetrics() {
if (metricsLoading) return;
syncLoginState();
const requestId = ++metricRequestId;
if (!isLoggedIn.value) {
Object.values(metrics).forEach(item => { item.status = 'idle'; item.value = 0; });
return;
}
metricsLoading = true;
const page = { pageNumber: 1, pageSize: 1 };
const queries = [
['cart', props.options.showCart !== false, () => cartCount()],
['coupons', props.options.showCoupons !== false, () => memberCouponList({ ...page, memberCouponStatus: 'NEW' })],
['orders', props.options.showOrders !== false, () => getOrderList({ ...page, tag: 'ALL' })]
];
await Promise.all(queries.filter(([, visible]) => visible).map(async ([key, , fetchCount]) => {
metrics[key].status = 'loading';
try {
const response = await fetchCount();
const raw = key === 'cart' ? response?.result : response?.result?.total;
const count = Number(raw);
if (!response?.success || raw == null || !Number.isSafeInteger(count) || count < 0) {
throw new Error('无效的数量响应');
}
if (requestId !== metricRequestId) return;
metrics[key].value = count;
metrics[key].status = 'ready';
if (key === 'cart') {
appStore.setCartNum(count);
storage.setItem('cartNum', count);
}
} catch {
if (requestId === metricRequestId) metrics[key].status = 'error';
}
}));
if (requestId === metricRequestId) metricsLoading = false;
}
/** 使离开首页前的异步响应失效,避免覆盖下一次进入后的数据。 */
function invalidateMetrics() {
metricRequestId += 1;
metricsLoading = false;
}
onMounted(loadMetrics);
onActivated(loadMetrics);
onDeactivated(invalidateMetrics);
onBeforeUnmount(invalidateMetrics);
const displayAvatar = computed(() => isLoggedIn.value
? userInfo.value.face
: props.options.avatar);
const welcomeText = computed(() => isLoggedIn.value
? `你好,${userInfo.value.nickName || userInfo.value.username || '会员'}`
: (props.options.welcomeText || '欢迎来到商城'));
const subtitle = computed(() => isLoggedIn.value
? '查看我的订单与专属权益'
: (props.options.subtitle || '登录后查看专属权益'));
function handleNavigate(url) {
if (!url) return;
if (/^https?:\/\//i.test(url)) {
@@ -96,10 +217,10 @@ function handleNavigate(url) {
}
const defaultServices = [
{ name: '收藏', url: '/member/favorites', icon: 'Star' },
{ name: '足迹', url: '/member/footprint', icon: 'Clock' },
{ name: '红包', url: '/couponCenter', icon: 'Present' },
{ name: '客服', url: '/article', icon: 'Service' }
{ name: '收藏', url: '/home/Favorites', icon: 'Star' },
{ name: '足迹', url: '/home/MyTracks', icon: 'Clock' },
{ name: '领券', url: '/coupon', icon: 'Present' },
{ name: '帮助', url: '/article', icon: 'Service' }
];
const iconMap = {
@@ -114,12 +235,26 @@ function getServiceIcon(iconName) {
}
const displayServices = computed(() => {
if (Array.isArray(props.options?.services) && props.options.services.length > 0) {
return props.options.services;
}
return defaultServices;
const services = Array.isArray(props.options?.services) && props.options.services.length > 0
? props.options.services
: defaultServices;
return services.map(normalizeService);
});
/** 兼容装修中保存的旧默认入口,仅修正已知地址,不覆盖其他自定义链接。 */
function normalizeService(service) {
const legacyRoutes = {
'/member/favorites': '/home/Favorites',
'/member/footprint': '/home/MyTracks',
'/couponCenter': '/coupon'
};
const url = legacyRoutes[service.url] || service.url;
let name = service.name;
if (name === '红包' && url === '/coupon') name = '领券';
if (name === '客服' && url === '/article') name = '帮助';
return { ...service, url, name };
}
const customStyle = computed(() => {
const s = props.componentStyle || {};
const hasRowHeight = Boolean(props.rowStyle?.height && Number(props.rowStyle.height) > 0);
@@ -135,6 +270,14 @@ const customStyle = computed(() => {
</script>
<style lang="scss" scoped>
.metrics-retry {
border: 0;
background: transparent;
color: #409eff;
font-size: 11px;
cursor: pointer;
}
.decor-user-benefits {
width: 100%;
height: 100%;

View File

@@ -8,6 +8,11 @@
<div class="layoutAll">
<el-aside class="side-bar" width="220px">
<div class="article-cate">文章分类</div>
<p v-if="categoryError" role="alert" class="load-message">
分类加载失败,可先浏览全部文章。
<el-button link type="primary" :loading="categoryLoading" @click="getCateList">重试分类</el-button>
</p>
<el-button link type="primary" @click="onSelect('')">全部文章</el-button>
<el-menu
class="side-menu"
:key="menuKey"
@@ -31,14 +36,22 @@
</el-menu>
</el-aside>
<el-main class="content">
<ul class="article-list" v-show="showList">
<div v-if="contentError" role="alert" class="load-message">
{{ contentError }}
<el-button link type="primary" @click="retryContent">重新加载</el-button>
</div>
<el-skeleton v-if="contentLoading" :rows="5" animated />
<ul class="article-list" v-show="showList && !contentLoading && !contentError">
<li v-if="!articleList.length" class="article-empty">暂无文章</li>
<li v-for="(article, index) in articleList" :key="article.id || index" @click="getDetail(article.id)">
{{ article.title }}
</li>
</ul>
<div v-show="!showList" class="article-detail">
<button type="button" class="back-btn" @click="showList = true">返回列表</button>
<el-pagination v-if="showList && !contentLoading && !contentError && total > params.pageSize"
:current-page="params.pageNumber" :page-size="params.pageSize" :total="total"
layout="prev, pager, next" @current-change="changePage" />
<div v-show="!showList && !contentLoading && !contentError" class="article-detail">
<button type="button" class="back-btn" @click="getList()">返回列表</button>
<h2>{{ detail.title }}</h2>
<div class="article-html" v-html="detail.content"></div>
</div>
@@ -60,82 +73,111 @@ defineOptions({ name: 'ArticleIndex' })
const route = useRoute()
const list = ref([])
const detail = ref('')
const detail = ref({})
const articleList = ref([])
const activeName = ref('1347456734864367616')
const activeName = ref('')
const openName = ref([])
const menuKey = ref(0)
const params = reactive({
pageNumber: 1,
pageSize: 100,
categoryId: '',
sort: 'sort'
})
const params = reactive({ pageNumber: 1, pageSize: 20, categoryId: '', sort: 'sort' })
const showList = ref(true)
const total = ref(0)
const categoryError = ref(false)
const categoryLoading = ref(false)
const contentError = ref('')
const contentLoading = ref(false)
const detailId = ref('')
let contentRequestId = 0
function onSelect (id) {
getList(id)
detail.value = ''
showList.value = true
/** 切换分类时重置分页,并使之前的列表或正文请求失效。 */
function onSelect(id) {
activeName.value = id
params.pageNumber = 1
return getList(id)
}
function getCateList () {
articleCateList().then(res => {
if (res.success) {
list.value = res.result || []
openName.value = []
if (route.query.id && detail.value && detail.value.categoryId) {
activeName.value = detail.value.categoryId
list.value.forEach(e => {
const children = e.children || []
const hitParent = String(e.id) === String(activeName.value)
const hitChild = children.some(i => i && String(i.id) === String(activeName.value))
if ((hitParent || hitChild) && children.length) {
openName.value.push(String(e.id))
}
})
} else if (list.value[0] && list.value[0].id) {
activeName.value = list.value[0].id
if (list.value[0].children && list.value[0].children.length) {
openName.value.push(String(list.value[0].id))
}
}
menuKey.value++
if (activeName.value) {
getList(activeName.value)
}
}
})
}
function getList (id) {
params.categoryId = id
fetchArticleList(params).then(res => {
if (res.success) {
articleList.value = res.result.records
}
})
}
async function getDetail (id) {
await articleDetail(id).then(res => {
if (res.success) {
detail.value = res.result
showList.value = false
}
})
}
onMounted(async () => {
const articleId = route.query.id
if (articleId) {
await getDetail(articleId)
/** 独立加载分类;分类服务不可用时保留全部文章入口,不阻断正文和列表。 */
async function getCateList() {
if (categoryLoading.value) return
categoryLoading.value = true
categoryError.value = false
try {
const res = await articleCateList({ skipMessage: true })
if (!res.success || !Array.isArray(res.result)) throw new Error('分类响应无效')
list.value = res.result
openName.value = list.value.filter(item => Array.isArray(item.children) && item.children.length)
.map(item => String(item.id))
menuKey.value++
} catch {
categoryError.value = true
} finally {
categoryLoading.value = false
}
}
/** 按分类分页查询公开文章;只接收最新一次请求结果,失败不冒充空列表。 */
async function getList(id = activeName.value) {
const requestId = ++contentRequestId
showList.value = true
contentLoading.value = true
contentError.value = ''
params.categoryId = id || ''
try {
const res = await fetchArticleList({ ...params }, { skipMessage: true })
if (!res.success || !Array.isArray(res.result?.records)) throw new Error('列表响应无效')
if (requestId !== contentRequestId) return
articleList.value = res.result.records
total.value = Number(res.result.total) || 0
} catch {
if (requestId === contentRequestId) contentError.value = '文章加载失败,请稍后重试。'
} finally {
if (requestId === contentRequestId) contentLoading.value = false
}
}
/** 加载指定文章;失败可原地重试,过时的正文响应不得覆盖用户新选择。 */
async function getDetail(id) {
const requestId = ++contentRequestId
detailId.value = id
showList.value = false
contentLoading.value = true
contentError.value = ''
try {
const res = await articleDetail(id, { skipMessage: true })
if (!res.success || !res.result) throw new Error('文章不存在')
if (requestId !== contentRequestId) return
detail.value = res.result
} catch {
if (requestId === contentRequestId) contentError.value = '文章详情加载失败或已不存在,请稍后重试。'
} finally {
if (requestId === contentRequestId) contentLoading.value = false
}
}
/** 重试当前列表或正文,保留分类和分页条件。 */
function retryContent() {
return showList.value ? getList() : getDetail(detailId.value)
}
/** 切换文章列表页码,沿用当前分类。 */
function changePage(page) {
params.pageNumber = page
return getList()
}
onMounted(() => {
getCateList()
if (route.query.id) getDetail(route.query.id)
else getList()
})
</script>
<style scoped lang="scss">
.load-message {
padding: 12px;
color: #64748b;
font-size: 13px;
}
.container {
margin: 0 auto;
padding: 20px 0 40px;

View File

@@ -279,6 +279,10 @@ export const router = createRouter({
component: MemberGrade,
meta: { title: "我的等级" },
},
{
path: "UserInfo",
redirect: { name: "Profile" },
},
{
path: "Profile",
name: "Profile",
@@ -307,6 +311,11 @@ export const router = createRouter({
name: "AddAddress",
component: AddAddress,
},
{
// 兼容已保存的装修顶部菜单中的旧消息地址,统一进入现有消息列表。
path: "Msg",
redirect: { name: "MsgList" },
},
{
path: "MsgList",
name: "MsgList",