mirror of
https://gitee.com/beijing_hongye_huicheng/lilishop-ui.git
synced 2026-11-10 02:49:03 +08:00
fix(buyer): 修复路由兼容与页面加载体验
- 为文章 API 添加 skipMessage 选项,由页面接管错误提示 - 添加旧路由重定向(UserInfo -> Profile, Msg -> MsgList) - 重构文章页面,增加分页、骨架屏、分类独立加载与重试机制 - 用户权益组件接入真实登录状态与购物车、优惠券、订单数量 - 修正装修组件中收藏、足迹、领券等服务的旧地址映射 - 删除已过时的 MIGRATION-VUE3.md 迁移文档
This commit is contained in:
@@ -1,201 +0,0 @@
|
||||
# Lilishop UI — Vue 3 迁移与上线前检查清单
|
||||
|
||||
四个子项目(buyer / seller / manager / im)已完成 **Vue 3 + Element Plus** 迁移,并完成依赖小版本对齐。本文档供上线前统一回归使用。
|
||||
|
||||
## 技术栈(四端一致)
|
||||
|
||||
| 类别 | 包 | 版本(package.json) |
|
||||
|------|-----|---------------------|
|
||||
| 框架 | vue | ^3.5.35 |
|
||||
| 编译 | @vue/compiler-sfc | ^3.5.35 |
|
||||
| 路由 | vue-router | ^4.6.4 |
|
||||
| 状态 | pinia | ^4.0.3(四端已全部从 vuex 迁移完成) |
|
||||
| 组件写法 | `<script setup>` | 四端 597 个组件已全部完成 Options API → 组合式 API 迁移;各端 eslint 已启用 `vue/component-api-style: ['warn', ['script-setup']]` 防止回退 |
|
||||
| UI | element-plus | ^2.14.1 |
|
||||
| 图标 | @element-plus/icons-vue | ^2.3.2 |
|
||||
| 请求 | axios | ^1.7.9 |
|
||||
| 兼容 | core-js | ^3.49.0 |
|
||||
| 构建 | @vue/cli-service | ^5.0.9 |
|
||||
|
||||
> 使用 `^` 范围,实际安装版本以各目录 `yarn.lock` 为准。
|
||||
> 要求 **Node >= 16**,包管理统一 **yarn**。
|
||||
|
||||
## 本地启动
|
||||
|
||||
```bash
|
||||
# 买家端
|
||||
cd buyer && yarn install && yarn dev # 端口 10000
|
||||
|
||||
# 商家端
|
||||
cd seller && yarn install && yarn dev # 端口 10002
|
||||
|
||||
# 管理端
|
||||
cd manager && yarn install && yarn dev # 端口 10003
|
||||
|
||||
# IM 客服
|
||||
cd im && yarn install && yarn dev # 端口 8000
|
||||
```
|
||||
|
||||
生产构建(上线前必跑):
|
||||
|
||||
```bash
|
||||
cd buyer && yarn build
|
||||
cd seller && yarn build
|
||||
cd manager && yarn build
|
||||
cd im && yarn build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 一、通用检查(四端都要过)
|
||||
|
||||
### 1. 认证与请求(axios 1.x 重点)
|
||||
|
||||
各端请求封装位置:
|
||||
|
||||
| 端 | 文件 |
|
||||
|----|------|
|
||||
| buyer | `buyer/src/plugins/request.js` |
|
||||
| seller | `seller/src/libs/axios.js` |
|
||||
| manager | `manager/src/libs/axios.js` |
|
||||
| im | `im/src/utils/request.js` |
|
||||
|
||||
- [ ] **登录**:账号密码登录成功,token 写入正常
|
||||
- [ ] **登出**:清除 token,跳转登录页
|
||||
- [ ] **401**:token 过期后跳转登录 / 刷新 token(buyer/manager/seller 有刷新逻辑)
|
||||
- [ ] **403**:无权限时提示文案正确、不白屏
|
||||
- [ ] **400 / 5xx**:接口报错能弹出 `Message` / `ElMessage` / `Notification`
|
||||
- [ ] **网络超时**:断网或超时后有错误提示,页面不卡死
|
||||
- [ ] **GET 带参**:列表筛选、分页参数序列化正常
|
||||
- [ ] **POST JSON**:`Content-Type: application/json` 请求体正确
|
||||
- [ ] **POST 表单**:`application/x-www-form-urlencoded` 提交正常(im 等)
|
||||
|
||||
### 2. Element Plus UI(2.6 → 2.14 重点)
|
||||
|
||||
- [ ] **表格** `el-table`:列展示、排序、空数据、加载态
|
||||
- [ ] **分页** `el-pagination`:翻页、每页条数切换
|
||||
- [ ] **表单** `el-form`:必填校验、错误提示位置
|
||||
- [ ] **弹窗** `el-dialog` / `ElMessageBox`:打开、确认、取消、关闭
|
||||
- [ ] **下拉** `el-select` / `el-dropdown`:选项、点击外部关闭
|
||||
- [ ] **消息** `ElMessage` / `$Message` 兼容层:success / error / warning
|
||||
- [ ] **加载** `ElLoading` / `v-loading`:请求期间遮罩正常消失
|
||||
- [ ] **日期** `el-date-picker`:选择、清空、范围选择
|
||||
- [ ] **上传** `el-upload`:选文件、进度、成功 / 失败回调
|
||||
|
||||
### 3. 路由与页面
|
||||
|
||||
- [ ] 浏览器 **刷新** 当前路由不 404(history 模式 + 服务端配置)
|
||||
- [ ] **动态路由** 加载正常(seller / manager 菜单权限路由)
|
||||
- [ ] 浏览器 **前进 / 后退** 正常
|
||||
- [ ] 各端 **404 页** 可回到首页
|
||||
|
||||
### 4. 构建与部署
|
||||
|
||||
- [ ] 四端 `yarn build` 无 error(warning 可记录,不阻断)
|
||||
- [ ] 生产环境 API 地址配置正确(`config` / 环境变量)
|
||||
- [ ] 静态资源 `publicPath` 与 Nginx / CDN 路径一致
|
||||
- [ ] gzip 压缩包(如有)可正常访问
|
||||
|
||||
---
|
||||
|
||||
## 二、分端专项检查
|
||||
|
||||
### Buyer 买家端(10000)
|
||||
|
||||
详见 [buyer/MIGRATION-VUE3.md](./buyer/MIGRATION-VUE3.md)
|
||||
|
||||
- [ ] 首页装修模块渲染(轮播、商品列表、秒杀区等)
|
||||
- [ ] 商品详情:规格选择、加购、收藏、`el-image` 预览
|
||||
- [ ] 购物车 → 结算 → 支付流程
|
||||
- [ ] 用户中心:订单列表、地址、优惠券、售后
|
||||
- [ ] 登录 / 注册 / 忘记密码
|
||||
- [ ] 店铺入驻、商户页
|
||||
- [ ] 地图(高德 `@amap/amap-jsapi-loader`)定位与选点
|
||||
- [ ] 侧边栏 `el-menu`、头部 `el-dropdown`、动态图标 `iconMap.js`
|
||||
|
||||
### Seller 商家端(10002)
|
||||
|
||||
详见 [seller/MIGRATION-VUE3.md](./seller/MIGRATION-VUE3.md)
|
||||
|
||||
- [ ] 商家登录、店铺信息
|
||||
- [ ] 商品发布三步流程(`goodsOperation` 步骤条)
|
||||
- [ ] 订单管理:发货、退款、详情
|
||||
- [ ] 促销 / 分销 / 统计图表(`@antv/g2`)
|
||||
- [ ] 页面装修(wap / modelList,从 manager 同步部分)
|
||||
- [ ] 图片懒加载 `vue-lazyload`
|
||||
- [ ] Excel 导出 `vue-json-excel`
|
||||
|
||||
### Manager 管理端(10003)
|
||||
|
||||
详见 [manager/MIGRATION-VUE3.md](./manager/MIGRATION-VUE3.md)
|
||||
|
||||
- [ ] 管理员登录、权限菜单、动态路由
|
||||
- [ ] 系统设置(`sys/setting-manage` 各 Tab)
|
||||
- [ ] 菜单 / 部门管理
|
||||
- [ ] 页面装修(PC + H5)
|
||||
- [ ] 文章管理
|
||||
- [ ] 商品 / 订单 / 会员 / 促销等核心列表 CRUD
|
||||
- [ ] 富文本 TinyMCE 编辑、图片上传
|
||||
- [ ] 多语言 `vue-i18n` 切换(如有启用)
|
||||
|
||||
### IM 客服端(8000)
|
||||
|
||||
详见 [im/MIGRATION-VUE3.md](./im/MIGRATION-VUE3.md)
|
||||
|
||||
- [ ] 带 token 参数进入会话页
|
||||
- [ ] 对话列表加载、切换会话、未读数
|
||||
- [ ] 发送文字、表情、图片消息
|
||||
- [ ] 商品 / 订单卡片链接跳转(`linkToGoods` / `linkToOrders`)
|
||||
- [ ] 右键菜单(复制、撤回、删除等)
|
||||
- [ ] WebSocket 连接 / 断线重连 / 新消息通知
|
||||
- [ ] 最近浏览、订单列表侧栏(商家场景)
|
||||
- [ ] 用户名片弹层 `$user()`
|
||||
|
||||
---
|
||||
|
||||
## 三、已知可忽略项
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| `vue-awesome-swiper` peer warning | 历史依赖,Vue 3 下仍有 peer 告警,功能正常可暂忽略 |
|
||||
| im 内 `<i class="el-icon-*">` | Element Plus 无字体图标,部分旧图标可能不显示,不影响核心功能 |
|
||||
| `vue-prism-editor@2.0.0-alpha.2` | im 代码块编辑器刻意锁定 alpha 版 |
|
||||
| 打包体积 warning | chunk-vendors 偏大,属 Element Plus 全量引入,可后续做按需优化 |
|
||||
|
||||
---
|
||||
|
||||
## 四、问题记录模板
|
||||
|
||||
上线测试时建议按端记录:
|
||||
|
||||
```markdown
|
||||
### [端名] 问题标题
|
||||
- 页面/路由:
|
||||
- 操作步骤:
|
||||
- 预期:
|
||||
- 实际:
|
||||
- 控制台报错:
|
||||
- 优先级:P0 / P1 / P2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、子项目文档索引
|
||||
|
||||
| 端 | 迁移说明 |
|
||||
|----|----------|
|
||||
| buyer | [buyer/MIGRATION-VUE3.md](./buyer/MIGRATION-VUE3.md) |
|
||||
| seller | [seller/MIGRATION-VUE3.md](./seller/MIGRATION-VUE3.md) |
|
||||
| manager | [manager/MIGRATION-VUE3.md](./manager/MIGRATION-VUE3.md) |
|
||||
| im | [im/MIGRATION-VUE3.md](./im/MIGRATION-VUE3.md) |
|
||||
|
||||
---
|
||||
|
||||
## 六、建议上线顺序
|
||||
|
||||
1. **manager**(后台配置源头)
|
||||
2. **seller**(商家经营)
|
||||
3. **buyer**(C 端流量最大)
|
||||
4. **im**(客服,依赖前后端 WebSocket)
|
||||
|
||||
每上一端,至少完成本文 **第一节通用检查** + 对应 **第二节分端检查** 后再切下一端。
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -320,10 +320,10 @@ if (props.options.couponUrl === undefined) props.options.couponUrl = '/member/co
|
||||
if (props.options.orderUrl === undefined) props.options.orderUrl = '/member/order';
|
||||
|
||||
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 ensureServices = computed(() => {
|
||||
|
||||
@@ -78,10 +78,10 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
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 = {
|
||||
|
||||
Reference in New Issue
Block a user