From fae27e7c20186b111b76b1874c9f3ec50041ec99 Mon Sep 17 00:00:00 2001 From: cat-shark <1716967236@qq.com> Date: Sun, 13 Sep 2026 22:11:23 +0800 Subject: [PATCH] =?UTF-8?q?style:=20=E9=A2=9C=E8=89=B2=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E4=B8=BACSS=E5=8F=98=E9=87=8F=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=A4=8D=E4=B9=A0=E5=8D=A1=E7=89=87=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 23 ++- e2e/review.spec.ts | 26 +++- .../integration/review-overview-flow.spec.ts | 143 ++++++++++++++++++ src/assets/base.css | 41 ----- src/assets/main.css | 108 ++++++++++++- src/components/MarkdownRenderer.vue | 4 +- src/components/Review.vue | 64 +++++--- src/components/ReviewDetail.vue | 10 +- src/components/ReviewRecall.vue | 18 +-- src/components/StartTask.vue | 10 +- src/components/Welcome.vue | 18 +-- src/main.ts | 7 +- 12 files changed, 372 insertions(+), 100 deletions(-) create mode 100644 src/__tests__/integration/review-overview-flow.spec.ts delete mode 100644 src/assets/base.css diff --git a/AGENTS.md b/AGENTS.md index be040e0..9631c7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,12 +85,27 @@ lpt-fe/src/ ### 按钮规范 -- 页面主操作 / 卡片 CTA:`size="large"`,如 Study 开始任务、TaskForm 保存、Welcome 快速入口。 -- 区块工具栏 / 页内操作:默认尺寸,如 Review 刷新、ReviewRecall 面板操作、历史记录。 -- 表格/列表行内操作:`size="small"`,主操作实底 `type="success"`,次操作 `text`;如 Review 卡片中「回忆复习」为主、「查看记录」为次。 +- 主操作 / 卡片 CTA:`size="large"`(实测 40px 高、15px 字),如 Study 开始任务、Welcome 快速入口。 +- 区块工具栏 / 页内操作:默认尺寸(32px 高、14px 字),如 Review 刷新、ReviewRecall 面板操作。 +- 表格/列表行内操作:`size="small"`(28px 高、13px 字;不得再调小)。 - 弹窗 footer:默认尺寸;确认按钮 `type="success"`,取消按钮默认;危险操作统一 `type="danger"`。 - 正向/保存语义统一 `success`,次级编辑语义可用 `primary`,不要在同一功能上混用。 -- 窄屏(`max-width: 768px`)操作区按钮纵向全宽,全局规则位于 `src/assets/main.css` 的 `.action-row / .edit-actions / .fragment-edit-actions / .task-actions`;`.task-actions` 中主操作在上、次操作在下(如 Review 的回忆复习/查看记录)。 +- 窄屏(`max-width: 768px`)操作区按钮纵向全宽,全局规则位于 `src/assets/main.css` 的 `.action-row / .edit-actions / .fragment-edit-actions / .task-actions`。 + +### 颜色与主题 + +- 禁止在组件内写颜色字面量:新增颜色先加到 `src/assets/base.css` 的 `:root`,组件只用 `var(--x)`。语义色已提供 `--accent-warning / --accent-warning-soft / --accent-warning-strong / --accent-success-soft / --accent-success-softer / --accent-success-strong / --accent-success-text`。 +- Element Plus 主题色在 `src/assets/main.css` 的 `:root` 覆盖(`--el-color-success` / `--el-color-primary` 指向 `--green-700`),全站按钮/标签/开关自动生效,不要在组件里逐个覆盖 EP 组件色。 +- `src/main.ts` 中自定义样式必须写在 `import 'element-plus/dist/index.css'` **之后**,否则主题覆盖会被 EP 覆盖。 +- 实底主色统一用满足 WCAG AA(白字对比度 ≥4.5:1)的 `--green-700`;`--green-600` 仅用于文字/链接。 +- 颜色字面量不得出现在 JS/模板表达式里(如 `:color="'#xxx'"` 引 CSS 变量会失效),需要动态色时保留字面量或改用 CSS 类。 + +### 卡片列表交互 + +- 卡片列表的卡片整体可点击进入详情:外层加 `role="link" tabindex="0"` 与 `aria-label`,并绑定 `@click` 及 `@keydown.enter/@keydown.space.prevent`。 +- 卡片内的附加按钮必须 `@click.stop`,避免被卡片点击吞掉(如 Review 的「回忆复习」)。 +- 卡片操作使用右对齐操作簇(`justify-content: flex-end`),**禁止用 `space-between` 把两个按钮拉到卡片两端**。 +- 加载失败必须区分于空数据:保留一个 `loadFailed` 状态,失败时给出可读提示 + 重试按钮,重试前先重置该状态;不要只靠 `finally` 关 loading 导致空白页。 ### 移动端适配 diff --git a/e2e/review.spec.ts b/e2e/review.spec.ts index a2ae0e6..62fa954 100644 --- a/e2e/review.spec.ts +++ b/e2e/review.spec.ts @@ -41,12 +41,14 @@ test.describe('Review page', () => { await page.waitForSelector('.review-page', { timeout: 10_000 }); }); - test('displays summary metrics and task table', async ({ page }) => { + test('displays summary metrics and task list', async ({ page }) => { await expect(page.locator('.metric-card')).toHaveCount(3); // Check metric values await expect(page.locator('.metric-card').first().locator('strong')).toContainText('2'); - // Check table rows - await expect(page.locator('.el-table__row')).toHaveCount(2); + // 任务以卡片列表展示(不再是 el-table) + await expect(page.locator('.task-card')).toHaveCount(2); + await expect(page.getByText('学习 Vue3')).toBeVisible(); + await expect(page.getByRole('button', { name: '回忆复习' })).toHaveCount(2); }); test('shows empty state when no tasks', async ({ page }) => { @@ -61,5 +63,23 @@ test.describe('Review page', () => { await page.goto('/review'); await page.waitForSelector('.review-page', { timeout: 10_000 }); await expect(page.locator('.metric-card').first().locator('strong')).toContainText('0'); + await expect(page.getByText('暂无复习任务')).toBeVisible(); + }); + + test('接口失败时提示加载失败而不是显示空列表', async ({ page }) => { + await page.route('**/api/review/tasks**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ code: 500, message: '服务异常', data: null }), + }), + ); + + await page.goto('/review'); + await page.waitForSelector('.review-page', { timeout: 10_000 }); + + await expect(page.getByText('复习数据加载失败,请稍后重试')).toBeVisible(); + await expect(page.getByRole('button', { name: '重新加载' })).toBeVisible(); + await expect(page.getByText('暂无复习任务')).toHaveCount(0); }); }); diff --git a/src/__tests__/integration/review-overview-flow.spec.ts b/src/__tests__/integration/review-overview-flow.spec.ts new file mode 100644 index 0000000..742e9df --- /dev/null +++ b/src/__tests__/integration/review-overview-flow.spec.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import ElementPlus from 'element-plus' +import { createMemoryHistory, createRouter } from 'vue-router' +import Review from '@/components/Review.vue' +import { getReviewTaskStats } from '@/api/review' + +vi.mock('@/api/review', () => ({ + getReviewTaskStats: vi.fn(), + getReviewFeed: vi.fn(), + getTaskReview: vi.fn(), +})) + +const tasks = [ + { + taskNum: 'T001', + taskName: '学习 Vue3 组合式 API', + reportCount: 3, + fragmentCount: 5, + effectiveTime: 9000, + }, + { + taskNum: 'T002', + taskName: 'TypeScript 类型体操', + reportCount: 1, + fragmentCount: 2, + effectiveTime: 4500, + }, +] + +/** + * 交互式集成测试:真实挂载 Review 总览页, + * 覆盖卡片进入详情、回忆复习跳转、加载失败提示三条用户可见路径。 + */ +describe('Review 交互流程(集成)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const mountReview = async () => { + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/review', name: 'review', component: Review }, + { path: '/review/detail/task/:taskNum', name: 'review-detail', component: { template: '
' } }, + { path: '/review/recall/:taskNum', name: 'review-recall', component: { template: '' } }, + ], + }) + await router.push('/review') + await router.isReady() + + const wrapper = mount(Review, { + global: { + plugins: [ElementPlus, router], + // el-tag 在 jsdom + VTU 下 vnode mounted 钩子崩溃(EP 2.8 已知问题),纯视觉组件,stub 掉 + stubs: { ElTag: true }, + }, + }) + await flushPromises() + return { wrapper, router } + } + + it('点击卡片空白处进入该任务的复习详情', async () => { + vi.mocked(getReviewTaskStats).mockResolvedValue({ code: 200, data: tasks }) + const { wrapper, router } = await mountReview() + + expect(wrapper.findAll('.task-card')).toHaveLength(2) + await wrapper.findAll('.task-card')[1].trigger('click') + await flushPromises() + + expect(router.currentRoute.value.path).toBe('/review/detail/task/T002') + }) + + it('键盘 Enter 与 Space 也能进入详情', async () => { + vi.mocked(getReviewTaskStats).mockResolvedValue({ code: 200, data: tasks }) + const { wrapper, router } = await mountReview() + + await wrapper.findAll('.task-card')[0].trigger('keydown', { key: 'Enter' }) + await flushPromises() + expect(router.currentRoute.value.path).toBe('/review/detail/task/T001') + + await router.push('/review') + await flushPromises() + await wrapper.findAll('.task-card')[0].trigger('keydown', { key: ' ' }) + await flushPromises() + expect(router.currentRoute.value.path).toBe('/review/detail/task/T001') + }) + + it('点击「回忆复习」进入回忆页,且不被卡片点击覆盖', async () => { + vi.mocked(getReviewTaskStats).mockResolvedValue({ code: 200, data: tasks }) + const { wrapper, router } = await mountReview() + + await wrapper.find('.recall-button').trigger('click') + await flushPromises() + + expect(router.currentRoute.value.path).toBe('/review/recall/T001') + }) + + it('卡片暴露 role 与 aria-label,具备可访问语义', async () => { + vi.mocked(getReviewTaskStats).mockResolvedValue({ code: 200, data: tasks }) + const { wrapper } = await mountReview() + + const card = wrapper.findAll('.task-card')[0] + expect(card.attributes('role')).toBe('link') + expect(card.attributes('tabindex')).toBe('0') + expect(card.attributes('aria-label')).toContain('学习 Vue3 组合式 API') + }) + + it('接口失败时提示加载失败并提供重试,而不是显示空列表', async () => { + vi.mocked(getReviewTaskStats).mockRejectedValue({ code: 500, message: '服务异常' }) + const { wrapper } = await mountReview() + + expect(wrapper.text()).toContain('复习数据加载失败,请稍后重试') + expect(wrapper.text()).not.toContain('暂无复习任务') + expect(wrapper.find('button').exists()).toBe(true) + }) + + it('重试按钮会重新请求接口,成功后展示卡片列表', async () => { + vi.mocked(getReviewTaskStats) + .mockRejectedValueOnce({ code: 500, message: '服务异常' }) + .mockResolvedValueOnce({ code: 200, data: tasks }) + const { wrapper } = await mountReview() + + expect(wrapper.text()).toContain('复习数据加载失败,请稍后重试') + + const retry = wrapper.findAll('button').find(b => b.text().includes('重新加载')) + expect(retry).toBeTruthy() + await retry!.trigger('click') + await flushPromises() + + expect(getReviewTaskStats).toHaveBeenCalledTimes(2) + expect(wrapper.findAll('.task-card')).toHaveLength(2) + expect(wrapper.text()).not.toContain('复习数据加载失败') + }) + + it('无任务时展示空列表文案,不显示失败提示', async () => { + vi.mocked(getReviewTaskStats).mockResolvedValue({ code: 200, data: [] }) + const { wrapper } = await mountReview() + + expect(wrapper.text()).toContain('暂无复习任务') + expect(wrapper.text()).not.toContain('复习数据加载失败') + }) +}) diff --git a/src/assets/base.css b/src/assets/base.css deleted file mode 100644 index 5709bab..0000000 --- a/src/assets/base.css +++ /dev/null @@ -1,41 +0,0 @@ -:root { - --green-900: #123d2d; - --green-800: #1a5a42; - --green-700: #237556; - --green-600: #2f8f68; - --green-500: #43a879; - --green-200: #d9f1e5; - --green-100: #eef9f3; - --surface: #f7fbf8; - --surface-strong: #ffffff; - --text-primary: #1b2a23; - --text-secondary: #4b6156; - --border-soft: #d6e6dc; - --shadow-soft: 0 14px 30px rgba(19, 61, 45, 0.09); - --shadow-strong: 0 20px 45px rgba(19, 61, 45, 0.16); -} - -*, -*::before, -*::after { - box-sizing: border-box; -} - -html, -body, -#app { - min-height: 100%; -} - -body { - margin: 0; - color: var(--text-primary); - background: - radial-gradient(circle at 12% 18%, rgba(67, 168, 121, 0.2), transparent 32%), - radial-gradient(circle at 85% 0%, rgba(35, 117, 86, 0.14), transparent 28%), - linear-gradient(165deg, #eef9f3 0%, #f8fcfa 50%, #edf7f1 100%); - font-family: "Source Han Sans SC", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif; - line-height: 1.6; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} diff --git a/src/assets/main.css b/src/assets/main.css index fede951..ccbef44 100644 --- a/src/assets/main.css +++ b/src/assets/main.css @@ -1,4 +1,84 @@ -@import "./base.css"; +:root { + --green-900: #123d2d; + --green-800: #1a5a42; + --green-700: #237556; + --green-600: #2f8f68; + --green-500: #43a879; + --green-100: #eef9f3; + --surface: #f7fbf8; + --surface-strong: #ffffff; + --text-primary: #1b2a23; + --text-secondary: #4b6156; + --border-soft: #d6e6dc; + --shadow-soft: 0 14px 30px rgba(19, 61, 45, 0.09); + --shadow-strong: 0 20px 45px rgba(19, 61, 45, 0.16); + + /* 残片/提醒语义色(对应 EP warning 色系,避免各组件硬编码) */ + --accent-warning: #e6a23c; + --accent-warning-soft: #fff8e1; + --accent-warning-strong: #b8860b; + /* 成功/命中语义的浅底与深字(统计卡片、标签共用) */ + --accent-success-soft: #e8f5e9; + --accent-success-softer: #f1f8e9; + --accent-success-strong: #2e7d32; + --accent-success-text: #856404; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body, +#app { + min-height: 100%; +} + +body { + margin: 0; + color: var(--text-primary); + background: + radial-gradient(circle at 12% 18%, rgba(67, 168, 121, 0.2), transparent 32%), + radial-gradient(circle at 85% 0%, rgba(35, 117, 86, 0.14), transparent 28%), + linear-gradient(165deg, #eef9f3 0%, #f8fcfa 50%, #edf7f1 100%); + font-family: "Source Han Sans SC", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif; + line-height: 1.6; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + + +/* ------------------------------------------------------------------ + * Element Plus 主题对齐 + * + * EP 默认 success 是黄绿 #67c23a,与本站墨绿主题色相不一致, + * 且白字压在其上对比度仅 2.24:1(低于 WCAG AA 4.5:1)。 + * 这里统一改写 EP 的 success / primary 色阶,使全站按钮、标签、 + * 开关等组件自动使用主题绿。 + * + * 注意:本文件在 main.ts 中必须导入在 element-plus 样式之后, + * 否则同特异度下会被 EP 默认值覆盖。 + * ------------------------------------------------------------------ */ +:root { + /* primary 与 success 同源:本站正向语义统一走 success,primary 仅次级编辑 */ + --el-color-primary: var(--green-700); + --el-color-primary-light-3: #4a8f74; + --el-color-primary-light-5: #6ba98f; + --el-color-primary-light-7: #9cc7b5; + --el-color-primary-light-8: #bcd9cc; + --el-color-primary-light-9: #eef9f3; + --el-color-primary-dark-2: var(--green-800); + + --el-color-success: var(--green-700); + --el-color-success-light-3: #4a8f74; + --el-color-success-light-5: #6ba98f; + --el-color-success-light-7: #9cc7b5; + --el-color-success-light-8: #bcd9cc; + --el-color-success-light-9: #eef9f3; + --el-color-success-dark-2: var(--green-800); +} #app { width: 100%; @@ -35,6 +115,32 @@ a:hover { box-shadow: var(--shadow-soft); } +/* ------------------------------------------------------------------ + * 按钮尺寸收敛 + * + * EP 默认 large / default / small 三档实测高度为 32 / 32 / 24px: + * large 与 default 在视觉上无法区分,行内 small 的 24px 又低于触屏 + * 舒适点击区。这里保持两个视觉层级: + * 主操作 large → 40px 高 + * 常规/行内 default、small → 至少 28px 高(保留 small 的紧凑感) + * 字号统一 13px 上限,避免行内按钮字号过小(原为 12px)。 + * ------------------------------------------------------------------ */ +.el-button--large { + --el-button-size: 40px; + padding: 0 20px; + font-size: 15px; +} + +.el-button--small { + --el-button-size: 28px; + font-size: 13px; +} + +.el-button--small.is-text, +.el-button--small.is-link { + padding: 0 8px; +} + /* 按钮操作区统一规范:窄屏下操作按钮纵向全宽 */ .action-row .el-button, .edit-actions .el-button, diff --git a/src/components/MarkdownRenderer.vue b/src/components/MarkdownRenderer.vue index 988906b..523e87d 100644 --- a/src/components/MarkdownRenderer.vue +++ b/src/components/MarkdownRenderer.vue @@ -82,7 +82,7 @@ const rendered = computed(() => { margin: 0.5em 0; padding: 0.5em 1em; border-left: 3px solid var(--green-600, #2f8f68); - background: #f1f8e9; + background: var(--accent-success-softer); color: #555; } @@ -121,7 +121,7 @@ const rendered = computed(() => { } .markdown-body :deep(th) { - background: #e8f5e9; + background: var(--accent-success-soft); font-weight: 600; } diff --git a/src/components/Review.vue b/src/components/Review.vue index b61222c..d21674b 100644 --- a/src/components/Review.vue +++ b/src/components/Review.vue @@ -9,6 +9,7 @@ import { const router = useRouter(); const loading = ref(false); +const loadFailed = ref(false); const tasks = ref