diff --git a/agents.md b/agents.md index 6c96714..847cd25 100644 --- a/agents.md +++ b/agents.md @@ -657,3 +657,39 @@ cd backend && go run . # http://localhost:8080 - `mall/src/router/index.ts` - 路由守卫改造 - `mall/src/views/mall/ProductDetailView.vue` - 按钮登录检查 - `tests/mall.test.js` - 测试更新 + +--- + +### 2026-02-11: 商城页面补全 — 购物车 / 我的 + +**问题**: 购物车页和我的页关键交互缺失,影响日常使用。 + +**补全内容**: + +1. **购物车页** (`mall/src/views/mall/CartView.vue`) + - 新增顶部操作区:`删除已选`、`清空购物车` + - 删除/清空前增加确认弹窗,完成后成功提示 + - 操作中禁用按钮,避免重复点击导致重复请求 + +2. **购物车 Store** (`mall/src/stores/mall.ts`) + - 新增 `removeCartItems(ids)` 批量删除方法 + - 使用并发删除后统一刷新购物车,减少页面闪烁 + +3. **我的页** (`mall/src/views/mall/MemberView.vue`) + - 新增订单状态角标(待支付/待发货/待收货/已完成) + - 新增会员信息加载失败提示与重试按钮 + - 新增“退出登录”菜单项 + - 增加退出确认弹窗,退出后跳转本地登录页 + +4. **会员 Store** (`mall/src/stores/member.ts`) + - 修复积分“加载更多”逻辑:第2页开始追加而非覆盖 + - 增加重置状态方法 `resetMemberState()` + +5. **订单 Store** (`mall/src/stores/order.ts`) + - 兼容 `page_info` 缺失场景 + - 修复分页“加载更多”逻辑:第2页开始追加而非覆盖 + +**验证**: + +- `mall` 执行 `npx vue-tsc --noEmit` 通过 +- `tests/mall.test.js` 执行通过(62项,失败0) diff --git a/mall-design.md b/mall-design.md index 7ff4411..0eea8e0 100644 --- a/mall-design.md +++ b/mall-design.md @@ -1158,7 +1158,17 @@ storage: --- -> **文档版本**: v1.3 -> **更新日期**: 2026-02-07 +### v1.4 增量更新(2026-02-11) + +- 购物车页补全批量操作:新增“删除已选”“清空购物车”交互和确认流程。 +- 我的页补全账号操作:新增“退出登录”入口并接入本地登录页。 +- 我的页补全订单状态总览:新增待支付/待发货/待收货/已完成角标统计。 +- 会员信息请求失败时提供重试入口,提升弱网可恢复性。 +- 修复会员积分记录分页加载:第 2 页起采用追加模式,避免“加载更多”覆盖已加载数据。 + +--- + +> **文档版本**: v1.4 +> **更新日期**: 2026-02-11 > **创建日期**: 2026-02-01 > **关联项目**: 健康AI问询助手 diff --git a/mall/src/stores/mall.ts b/mall/src/stores/mall.ts index 114b114..8c85f1a 100644 --- a/mall/src/stores/mall.ts +++ b/mall/src/stores/mall.ts @@ -60,6 +60,12 @@ export const useMallStore = defineStore('mall', () => { await fetchCart() } + async function removeCartItems(ids: number[]) { + if (!ids.length) return + await Promise.all(ids.map((id) => deleteCartApi(id))) + await fetchCart() + } + async function toggleSelectAll(selected: boolean) { const ids = cartItems.value.map(item => item.id) if (ids.length > 0) { @@ -89,6 +95,14 @@ export const useMallStore = defineStore('mall', () => { } } + function resetMallState() { + cartItems.value = [] + cartTotalCount.value = 0 + cartSelectedCount.value = 0 + cartTotalAmount.value = 0 + cartLoading.value = false + } + return { // 购物车 cartItems, @@ -103,11 +117,13 @@ export const useMallStore = defineStore('mall', () => { addToCart, updateCartItem, removeCartItem, + removeCartItems, toggleSelectAll, clearCart, // 分类 categories, categoriesLoading, - fetchCategories + fetchCategories, + resetMallState } }) diff --git a/mall/src/stores/member.ts b/mall/src/stores/member.ts index 68fca35..cfc52af 100644 --- a/mall/src/stores/member.ts +++ b/mall/src/stores/member.ts @@ -9,6 +9,7 @@ export const useMemberStore = defineStore('member', () => { const pointsRecords = ref([]) const pageInfo = ref({ total: 0, page: 1, page_size: 10 }) const loading = ref(false) + const memberLoadFailed = ref(false) const levelInfo = computed(() => { const level = memberInfo.value?.level || 'normal' @@ -27,10 +28,14 @@ export const useMemberStore = defineStore('member', () => { async function fetchMemberInfo() { loading.value = true + memberLoadFailed.value = false try { memberInfo.value = await getMemberInfoApi() as unknown as MemberInfo + return true } catch { // 错误已在拦截器中处理 + memberLoadFailed.value = true + return false } finally { loading.value = false } @@ -42,9 +47,18 @@ export const useMemberStore = defineStore('member', () => { const data = await getPointsRecordsApi(params) as unknown as { records: PointsRecord[] page_info: PageInfo + total?: number + page?: number + page_size?: number + } + const page = params?.page || 1 + const records = data.records || [] + pointsRecords.value = page > 1 ? [...pointsRecords.value, ...records] : records + pageInfo.value = data.page_info || { + total: data.total || 0, + page: data.page || page, + page_size: data.page_size || (params?.page_size || 10) } - pointsRecords.value = data.records || [] - pageInfo.value = data.page_info } catch { // 错误已在拦截器中处理 } finally { @@ -52,14 +66,24 @@ export const useMemberStore = defineStore('member', () => { } } + function resetMemberState() { + memberInfo.value = null + pointsRecords.value = [] + pageInfo.value = { total: 0, page: 1, page_size: 10 } + memberLoadFailed.value = false + loading.value = false + } + return { memberInfo, pointsRecords, pageInfo, loading, + memberLoadFailed, levelInfo, upgradeProgress, fetchMemberInfo, - fetchPointsRecords + fetchPointsRecords, + resetMemberState } }) diff --git a/mall/src/stores/order.ts b/mall/src/stores/order.ts index 5851ee5..6178dd9 100644 --- a/mall/src/stores/order.ts +++ b/mall/src/stores/order.ts @@ -21,9 +21,21 @@ export const useOrderStore = defineStore('order', () => { async function fetchOrders(params?: { page?: number; page_size?: number; status?: string }) { loading.value = true try { - const data = await getOrdersApi(params) as unknown as { orders: Order[]; page_info: PageInfo } - orders.value = data.orders || [] - pageInfo.value = data.page_info + const data = await getOrdersApi(params) as unknown as { + orders: Order[] + page_info?: PageInfo + total?: number + page?: number + page_size?: number + } + const page = params?.page || 1 + const list = data.orders || [] + orders.value = page > 1 ? [...orders.value, ...list] : list + pageInfo.value = data.page_info || { + total: data.total || 0, + page: data.page || page, + page_size: data.page_size || (params?.page_size || 10) + } } catch { // 错误已在拦截器中处理 } finally { diff --git a/mall/src/views/mall/CartView.vue b/mall/src/views/mall/CartView.vue index a3a4da3..e4887e0 100644 --- a/mall/src/views/mall/CartView.vue +++ b/mall/src/views/mall/CartView.vue @@ -7,6 +7,13 @@ +
+ + 删除已选 + + 清空购物车 +
+
@@ -385,6 +451,22 @@ $radius: 12px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04); } +.load-failed-card { + text-align: center; + + .load-failed-title { + font-size: 16px; + font-weight: 600; + color: $text; + } + + .load-failed-desc { + margin: 6px 0 12px; + color: $text-hint; + font-size: 13px; + } +} + /* ===== 会员等级卡片 ===== */ .level-card { margin-top: -8px; @@ -523,6 +605,10 @@ $radius: 12px; } } +.tab-icon-wrap { + position: relative; +} + .tab-icon { font-size: 28px; line-height: 1; @@ -533,6 +619,12 @@ $radius: 12px; color: $text-secondary; } +.order-tab-badge { + position: absolute; + top: -8px; + right: -12px; +} + /* ===== 功能菜单 ===== */ .menu-section { padding: 6px 16px; diff --git a/start.bat b/start.bat index 05d9dff..9a9044e 100644 --- a/start.bat +++ b/start.bat @@ -5,27 +5,37 @@ echo ======================================== echo Health AI Assistant - Launcher echo ======================================== echo. -echo [1] Start Web (Vue 3) -echo [2] Start APP (React Native) -echo [3] Start Both (Web + APP) -echo [4] Exit +echo [1] Start Web (Vue 3 - 5173) +echo [2] Start Mall (Vue 3 - 5174) +echo [3] Start APP (React Native) +echo [4] Start Web + Mall +echo [5] Start Web + APP +echo [6] Exit echo. echo ---------------------------------------- echo Test Account: 13800138000 / 123456 echo ---------------------------------------- echo. -set /p choice=Select option [1-4]: +set /p choice=Select option [1-6]: if "%choice%"=="1" goto startweb -if "%choice%"=="2" goto startapp -if "%choice%"=="3" goto startall -if "%choice%"=="4" exit /b 0 +if "%choice%"=="2" goto startmall +if "%choice%"=="3" goto startapp +if "%choice%"=="4" goto startwebmall +if "%choice%"=="5" goto startwebapp +if "%choice%"=="6" exit /b 0 goto :eof :startweb echo Starting Web dev server... -cd /d "%~dp0web" -npm run dev +cd /d "%~dp0" +node scripts/dev.js web +goto :eof + +:startmall +echo Starting Mall dev server... +cd /d "%~dp0" +node scripts/dev.js mall goto :eof :startapp @@ -34,13 +44,18 @@ cd /d "%~dp0app" npx expo start goto :eof -:startall -echo Starting both servers... -start cmd /k "cd /d %~dp0web && npm run dev" +:startwebmall +echo Starting Web + Mall... +cd /d "%~dp0" +node scripts/dev.js +goto :eof + +:startwebapp +echo Starting Web + APP in new windows... +start cmd /k "cd /d %~dp0 && node scripts/dev.js web" timeout /t 2 /nobreak >nul start cmd /k "cd /d %~dp0app && npx expo start --web" echo. -echo Servers started in new windows! echo Web: http://localhost:5173 echo APP: http://localhost:8081 pause diff --git a/start.sh b/start.sh index c3a3298..7ce2454 100644 --- a/start.sh +++ b/start.sh @@ -8,28 +8,40 @@ echo "========================================" echo " Health AI Assistant - Launcher" echo "========================================" echo "" -echo " [1] Start Web (Vue 3)" -echo " [2] Start APP (React Native)" -echo " [3] Exit" +echo " [1] Start Web (Vue 3 - 5173)" +echo " [2] Start Mall (Vue 3 - 5174)" +echo " [3] Start APP (React Native)" +echo " [4] Start Web + Mall" +echo " [5] Exit" echo "" echo "----------------------------------------" echo "Test Account: 13800138000 / 123456" echo "----------------------------------------" echo "" -read -p "Select option [1-3]: " choice +read -p "Select option [1-5]: " choice case $choice in 1) echo "Starting Web dev server..." - cd "$SCRIPT_DIR/web" - npm run dev + cd "$SCRIPT_DIR" + node scripts/dev.js web ;; 2) + echo "Starting Mall dev server..." + cd "$SCRIPT_DIR" + node scripts/dev.js mall + ;; + 3) echo "Starting APP dev server..." cd "$SCRIPT_DIR/app" npx expo start ;; - 3) + 4) + echo "Starting Web + Mall..." + cd "$SCRIPT_DIR" + node scripts/dev.js + ;; + 5) exit 0 ;; *) diff --git a/tests/mall.test.js b/tests/mall.test.js index 238f8ac..d9204fc 100644 --- a/tests/mall.test.js +++ b/tests/mall.test.js @@ -171,10 +171,25 @@ async function setupApiMocks(page) { }); await page.route('**/api/mall/orders*', (route) => { + const url = new URL(route.request().url()); + const status = url.searchParams.get('status'); + const statusTotalMap = { + pending: 3, + paid: 2, + shipped: 1, + completed: 5 + }; + const total = status ? (statusTotalMap[status] || 0) : 0; route.fulfill({ status: 200, contentType: 'application/json', - body: JSON.stringify({ orders: [], total: 0, page: 1, page_size: 20 }) + body: JSON.stringify({ + orders: [], + page_info: { total, page: 1, page_size: 20 }, + total, + page: 1, + page_size: 20 + }) }); }); @@ -191,10 +206,30 @@ async function setupApiMocks(page) { }); await page.route('**/api/mall/member/points/records*', (route) => { + const url = new URL(route.request().url()); + const pageNo = Number(url.searchParams.get('page') || '1'); + const pageSize = Number(url.searchParams.get('page_size') || '10'); + const all = Array.from({ length: 12 }).map((_, idx) => ({ + id: idx + 1, + type: idx % 2 === 0 ? 'earn' : 'order', + points: idx % 2 === 0 ? 10 : -5, + balance: 100 + idx, + source: 'test', + remark: `积分记录${idx + 1}`, + created_at: '2026-02-01 10:00:00' + })); + const start = (pageNo - 1) * pageSize; + const records = all.slice(start, start + pageSize); route.fulfill({ status: 200, contentType: 'application/json', - body: JSON.stringify({ records: [], total: 0, page: 1, page_size: 20 }) + body: JSON.stringify({ + records, + page_info: { total: all.length, page: pageNo, page_size: pageSize }, + total: all.length, + page: pageNo, + page_size: pageSize + }) }); }); @@ -353,13 +388,13 @@ async function testHeaderComponents(page) { await page.goto(MALL_URL, { waitUntil: 'networkidle', timeout: 10000 }).catch(() => {}); await page.waitForTimeout(1000); - // 检查 header 购物车图标 - const headerCartBadge = page.locator('.cart-badge'); - const cartBadgeVisible = await headerCartBadge.isVisible().catch(() => false); - logTest('Header购物车图标', cartBadgeVisible); + // 检查 header 购物车图标(角标为空时不一定存在 .cart-badge) + const headerCartIcon = page.locator('.header-right .header-icon').nth(1); + const cartIconVisible = await headerCartIcon.isVisible().catch(() => false); + logTest('Header购物车图标', cartIconVisible); // 点击购物车图标应跳转到购物车 - await headerCartBadge.click(); + await headerCartIcon.click(); await page.waitForTimeout(1000); const urlAfterCart = page.url(); logTest('Header购物车跳转', urlAfterCart.includes('/cart'), `URL: ${urlAfterCart}`); @@ -432,6 +467,134 @@ async function testOrdersPage(page) { logTest('订单页返回按钮', backVisible); } +/** + * 测试 6B:我的页面增强功能(订单角标、积分分页、退出弹窗) + */ +async function testMemberEnhancements(page) { + console.log('\n【步骤6B】测试我的页面增强功能...'); + await page.goto(MALL_URL + '/member', { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {}); + await page.waitForTimeout(1500); + + // 订单状态角标(来自 /orders?status=xxx 统计) + const badgeCount = await page.locator('.order-tab-badge').count(); + logTest('我的页订单状态角标', badgeCount > 0, `角标数量: ${badgeCount}`); + + // 积分明细分页加载(第2页追加) + await page.getByText('积分明细').first().click().catch(() => {}); + await page.waitForTimeout(1200); + const beforeCount = await page.locator('.points-record').count(); + const loadMoreBtn = page.getByRole('button', { name: '加载更多' }); + const canLoadMore = await loadMoreBtn.isVisible().catch(() => false); + if (canLoadMore) { + await loadMoreBtn.click(); + await page.waitForTimeout(1200); + } + const afterCount = await page.locator('.points-record').count(); + logTest('积分明细加载更多追加', afterCount > beforeCount, `之前: ${beforeCount}, 现在: ${afterCount}`); + + // 关闭积分抽屉,避免遮罩影响后续点击 + await page.keyboard.press('Escape').catch(() => {}); + await page.waitForTimeout(300); + + // 退出登录入口:出现确认弹窗(取消即可,不影响后续) + await page.getByText('退出登录').first().click().catch(() => {}); + await page.waitForTimeout(500); + const logoutDialog = page.locator('.el-message-box'); + const logoutDialogVisible = await logoutDialog.isVisible().catch(() => false); + logTest('退出登录确认弹窗', logoutDialogVisible); + if (logoutDialogVisible) { + await page.getByRole('button', { name: '取消' }).click().catch(() => {}); + } +} + +/** + * 测试 6C:购物车增强功能(删除已选、清空购物车) + */ +async function testCartEnhancements(browser) { + console.log('\n【步骤6C】测试购物车增强功能...'); + const context = await browser.newContext({ + viewport: { width: 750, height: 1334 }, + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15' + }); + const page = await context.newPage(); + + await setupAuth(context, page); + await setupApiMocks(page); + + const state = { + items: [ + { id: 1, product_id: 1, sku_id: 0, product_name: '枸杞红枣养生茶', sku_name: '', image: '', price: 49.9, quantity: 1, selected: true, stock: 20 }, + { id: 2, product_id: 2, sku_id: 0, product_name: '黄芪片', sku_name: '', image: '', price: 39.9, quantity: 2, selected: false, stock: 20 } + ] + }; + + // 覆盖购物车相关 mock,模拟真实增删清流程 + await page.route('**/api/mall/cart/clear', async (route) => { + state.items = []; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) }); + }); + await page.route('**/api/mall/cart/*', async (route) => { + const req = route.request(); + const url = new URL(req.url()); + if (url.pathname.endsWith('/cart/clear') && req.method() === 'DELETE') { + state.items = []; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) }); + return; + } + const id = Number(url.pathname.split('/').pop()); + if (req.method() === 'DELETE') { + state.items = state.items.filter((i) => i.id !== id); + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) }); + return; + } + if (req.method() === 'PUT') { + const body = req.postDataJSON(); + state.items = state.items.map((i) => (i.id === id ? { ...i, ...body } : i)); + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) }); + return; + } + await route.continue(); + }); + await page.route('**/api/mall/cart', async (route) => { + const selectedItems = state.items.filter((i) => i.selected); + const totalAmount = selectedItems.reduce((sum, i) => sum + i.price * i.quantity, 0); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items: state.items, + total_count: state.items.length, + selected_count: selectedItems.length, + total_amount: Number(totalAmount.toFixed(2)) + }) + }); + }); + + try { + await page.goto(MALL_URL + '/cart', { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {}); + await page.waitForTimeout(1200); + + const deleteSelectedBtn = page.getByRole('button', { name: '删除已选' }); + const clearCartBtn = page.getByRole('button', { name: '清空购物车' }); + logTest('购物车增强-删除已选按钮', await deleteSelectedBtn.isVisible().catch(() => false)); + logTest('购物车增强-清空购物车按钮', await clearCartBtn.isVisible().catch(() => false)); + + await deleteSelectedBtn.click(); + await page.locator('.el-message-box__btns .el-button--primary').first().click().catch(() => {}); + await page.waitForTimeout(1000); + const itemCountAfterDelete = await page.locator('.cart-item').count(); + logTest('购物车增强-删除已选生效', itemCountAfterDelete === 1, `剩余: ${itemCountAfterDelete}`); + + await clearCartBtn.click(); + await page.locator('.el-message-box__btns .el-button--primary').first().click().catch(() => {}); + await page.waitForTimeout(1000); + const emptyVisible = await page.locator('.cart-empty').isVisible().catch(() => false); + logTest('购物车增强-清空购物车生效', emptyVisible); + } finally { + await context.close(); + } +} + /** * 测试 7A:未登录公开浏览(应能正常访问) */ @@ -630,6 +793,12 @@ async function runTests() { // 阶段 6:订单页 await testOrdersPage(page); + // 阶段 6B:我的页面增强 + await testMemberEnhancements(page); + + // 阶段 6C:购物车增强 + await testCartEnhancements(browser); + // 阶段 7A:未登录公开浏览 await testPublicBrowsing(browser); diff --git a/tests/screenshots/mall-address.png b/tests/screenshots/mall-address.png index e80c688..aaaae2d 100644 Binary files a/tests/screenshots/mall-address.png and b/tests/screenshots/mall-address.png differ diff --git a/tests/screenshots/mall-home.png b/tests/screenshots/mall-home.png index 434b258..7def4b1 100644 Binary files a/tests/screenshots/mall-home.png and b/tests/screenshots/mall-home.png differ diff --git a/tests/screenshots/mall-member.png b/tests/screenshots/mall-member.png index bd6fb28..b43d029 100644 Binary files a/tests/screenshots/mall-member.png and b/tests/screenshots/mall-member.png differ diff --git a/tests/screenshots/mall-mobile-375.png b/tests/screenshots/mall-mobile-375.png index 3434906..b0f428e 100644 Binary files a/tests/screenshots/mall-mobile-375.png and b/tests/screenshots/mall-mobile-375.png differ diff --git a/tests/screenshots/mall-orders.png b/tests/screenshots/mall-orders.png index 8c12390..13b91b8 100644 Binary files a/tests/screenshots/mall-orders.png and b/tests/screenshots/mall-orders.png differ diff --git a/tests/screenshots/mall-public-home.png b/tests/screenshots/mall-public-home.png index 7def4b1..434b258 100644 Binary files a/tests/screenshots/mall-public-home.png and b/tests/screenshots/mall-public-home.png differ