style: 颜色统一为CSS变量并优化复习卡片交互
This commit is contained in:
@@ -85,12 +85,27 @@ lpt-fe/src/
|
|||||||
|
|
||||||
### 按钮规范
|
### 按钮规范
|
||||||
|
|
||||||
- 页面主操作 / 卡片 CTA:`size="large"`,如 Study 开始任务、TaskForm 保存、Welcome 快速入口。
|
- 主操作 / 卡片 CTA:`size="large"`(实测 40px 高、15px 字),如 Study 开始任务、Welcome 快速入口。
|
||||||
- 区块工具栏 / 页内操作:默认尺寸,如 Review 刷新、ReviewRecall 面板操作、历史记录。
|
- 区块工具栏 / 页内操作:默认尺寸(32px 高、14px 字),如 Review 刷新、ReviewRecall 面板操作。
|
||||||
- 表格/列表行内操作:`size="small"`,主操作实底 `type="success"`,次操作 `text`;如 Review 卡片中「回忆复习」为主、「查看记录」为次。
|
- 表格/列表行内操作:`size="small"`(28px 高、13px 字;不得再调小)。
|
||||||
- 弹窗 footer:默认尺寸;确认按钮 `type="success"`,取消按钮默认;危险操作统一 `type="danger"`。
|
- 弹窗 footer:默认尺寸;确认按钮 `type="success"`,取消按钮默认;危险操作统一 `type="danger"`。
|
||||||
- 正向/保存语义统一 `success`,次级编辑语义可用 `primary`,不要在同一功能上混用。
|
- 正向/保存语义统一 `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 导致空白页。
|
||||||
|
|
||||||
### 移动端适配
|
### 移动端适配
|
||||||
|
|
||||||
|
|||||||
+23
-3
@@ -41,12 +41,14 @@ test.describe('Review page', () => {
|
|||||||
await page.waitForSelector('.review-page', { timeout: 10_000 });
|
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);
|
await expect(page.locator('.metric-card')).toHaveCount(3);
|
||||||
// Check metric values
|
// Check metric values
|
||||||
await expect(page.locator('.metric-card').first().locator('strong')).toContainText('2');
|
await expect(page.locator('.metric-card').first().locator('strong')).toContainText('2');
|
||||||
// Check table rows
|
// 任务以卡片列表展示(不再是 el-table)
|
||||||
await expect(page.locator('.el-table__row')).toHaveCount(2);
|
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 }) => {
|
test('shows empty state when no tasks', async ({ page }) => {
|
||||||
@@ -61,5 +63,23 @@ test.describe('Review page', () => {
|
|||||||
await page.goto('/review');
|
await page.goto('/review');
|
||||||
await page.waitForSelector('.review-page', { timeout: 10_000 });
|
await page.waitForSelector('.review-page', { timeout: 10_000 });
|
||||||
await expect(page.locator('.metric-card').first().locator('strong')).toContainText('0');
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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: '<div />' } },
|
||||||
|
{ path: '/review/recall/:taskNum', name: 'review-recall', component: { template: '<div />' } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
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('复习数据加载失败')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
+107
-1
@@ -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 {
|
#app {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -35,6 +115,32 @@ a:hover {
|
|||||||
box-shadow: var(--shadow-soft);
|
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,
|
.action-row .el-button,
|
||||||
.edit-actions .el-button,
|
.edit-actions .el-button,
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ const rendered = computed(() => {
|
|||||||
margin: 0.5em 0;
|
margin: 0.5em 0;
|
||||||
padding: 0.5em 1em;
|
padding: 0.5em 1em;
|
||||||
border-left: 3px solid var(--green-600, #2f8f68);
|
border-left: 3px solid var(--green-600, #2f8f68);
|
||||||
background: #f1f8e9;
|
background: var(--accent-success-softer);
|
||||||
color: #555;
|
color: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ const rendered = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.markdown-body :deep(th) {
|
.markdown-body :deep(th) {
|
||||||
background: #e8f5e9;
|
background: var(--accent-success-soft);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+46
-18
@@ -9,6 +9,7 @@ import {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const loadFailed = ref(false);
|
||||||
const tasks = ref<ReviewTaskStats[]>([]);
|
const tasks = ref<ReviewTaskStats[]>([]);
|
||||||
|
|
||||||
const summary = computed(() => ({
|
const summary = computed(() => ({
|
||||||
@@ -28,9 +29,13 @@ const formatEffectiveTime = (seconds: number | string): string => {
|
|||||||
|
|
||||||
const loadTasks = async () => {
|
const loadTasks = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
loadFailed.value = false;
|
||||||
try {
|
try {
|
||||||
const res = await getReviewTaskStats();
|
const res = await getReviewTaskStats();
|
||||||
tasks.value = res?.data || [];
|
tasks.value = res?.data || [];
|
||||||
|
} catch {
|
||||||
|
// 请求层已提示具体错误,这里只保证不把失败当作空列表展示
|
||||||
|
loadFailed.value = true;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -48,9 +53,8 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="review-page">
|
<section class="review-page">
|
||||||
<div class="table-toolbar">
|
<div class="page-toolbar">
|
||||||
<span class="toolbar-placeholder"></span>
|
<el-button :loading="loading" @click="loadTasks">刷新</el-button>
|
||||||
<el-button type="success" plain :loading="loading" @click="loadTasks">刷新</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="summary-grid">
|
<div class="summary-grid">
|
||||||
@@ -73,6 +77,12 @@ onMounted(() => {
|
|||||||
v-for="row in tasks"
|
v-for="row in tasks"
|
||||||
:key="row.taskNum"
|
:key="row.taskNum"
|
||||||
class="surface-card task-card"
|
class="surface-card task-card"
|
||||||
|
role="link"
|
||||||
|
tabindex="0"
|
||||||
|
:aria-label="`查看 ${row.taskName} 的学习记录`"
|
||||||
|
@click="openTaskReview(row)"
|
||||||
|
@keydown.enter.prevent="openTaskReview(row)"
|
||||||
|
@keydown.space.prevent="openTaskReview(row)"
|
||||||
>
|
>
|
||||||
<div class="task-head">
|
<div class="task-head">
|
||||||
<strong>{{ row.taskName }}</strong>
|
<strong>{{ row.taskName }}</strong>
|
||||||
@@ -84,27 +94,26 @@ onMounted(() => {
|
|||||||
<span>学习报告数量:{{ row.reportCount || 0 }}</span>
|
<span>学习报告数量:{{ row.reportCount || 0 }}</span>
|
||||||
<span>学习残片数量:{{ row.fragmentCount || 0 }}</span>
|
<span>学习残片数量:{{ row.fragmentCount || 0 }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 卡片整体已可进详情,这里只保留「回忆复习」作为明确的主操作 -->
|
||||||
<div class="task-actions">
|
<div class="task-actions">
|
||||||
<el-button
|
|
||||||
text
|
|
||||||
type="success"
|
|
||||||
size="small"
|
|
||||||
@click="openTaskReview(row)"
|
|
||||||
>
|
|
||||||
查看记录
|
|
||||||
</el-button>
|
|
||||||
<el-button
|
<el-button
|
||||||
class="recall-button"
|
class="recall-button"
|
||||||
type="success"
|
type="success"
|
||||||
size="small"
|
size="small"
|
||||||
@click="router.push(`/review/recall/${row.taskNum}`)"
|
@click.stop="router.push(`/review/recall/${row.taskNum}`)"
|
||||||
>
|
>
|
||||||
回忆复习
|
回忆复习
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<el-empty v-if="!loading && tasks.length === 0" description="暂无复习任务" />
|
<el-empty
|
||||||
|
v-if="!loading && loadFailed"
|
||||||
|
description="复习数据加载失败,请稍后重试"
|
||||||
|
>
|
||||||
|
<el-button type="success" plain @click="loadTasks">重新加载</el-button>
|
||||||
|
</el-empty>
|
||||||
|
<el-empty v-else-if="!loading && tasks.length === 0" description="暂无复习任务" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -115,7 +124,7 @@ onMounted(() => {
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-toolbar {
|
.page-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
@@ -154,6 +163,19 @@ onMounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: var(--green-500, #43a879);
|
||||||
|
box-shadow: var(--shadow-strong, var(--shadow-soft));
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card:focus-visible {
|
||||||
|
outline: 2px solid var(--green-700);
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-head strong {
|
.task-head strong {
|
||||||
@@ -174,9 +196,9 @@ onMounted(() => {
|
|||||||
|
|
||||||
.task-actions {
|
.task-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: flex-end;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
@@ -186,8 +208,14 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.task-actions .recall-button {
|
/* 窄屏只保留一个主操作,不需要纵向堆叠与顺序调整 */
|
||||||
order: -1;
|
.task-actions {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-actions .el-button {
|
||||||
|
width: auto;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -402,7 +402,7 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.history-session.is-current {
|
.history-session.is-current {
|
||||||
background: #f1f8e9;
|
background: var(--accent-success-softer);
|
||||||
margin: 0 -10px;
|
margin: 0 -10px;
|
||||||
padding: 14px 10px;
|
padding: 14px 10px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -456,7 +456,7 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.clickable-text.active {
|
.clickable-text.active {
|
||||||
background: #e8f5e9;
|
background: var(--accent-success-soft);
|
||||||
border-left: 3px solid var(--green-600);
|
border-left: 3px solid var(--green-600);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,13 +471,13 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.report-tag {
|
.report-tag {
|
||||||
background: #e8f5e9;
|
background: var(--accent-success-soft);
|
||||||
color: var(--green-700);
|
color: var(--green-700);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fragment-tag {
|
.fragment-tag {
|
||||||
background: #fff8e1;
|
background: var(--accent-warning-soft);
|
||||||
color: #b8860b;
|
color: var(--accent-warning-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-area {
|
.edit-area {
|
||||||
|
|||||||
@@ -657,7 +657,7 @@ onMounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
background: linear-gradient(135deg, #f1f8e9 0%, #e8f5e9 100%);
|
background: linear-gradient(135deg, var(--accent-success-softer) 0%, var(--accent-success-soft) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-item {
|
.stat-item {
|
||||||
@@ -676,8 +676,8 @@ onMounted(() => {
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.highlight { color: #2e7d32; }
|
.highlight { color: var(--accent-success-strong); }
|
||||||
.matched { color: #2e7d32; }
|
.matched { color: var(--accent-success-strong); }
|
||||||
.missed { color: #c62828; }
|
.missed { color: #c62828; }
|
||||||
.extra { color: #1565c0; }
|
.extra { color: #1565c0; }
|
||||||
|
|
||||||
@@ -710,7 +710,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.ai-waiting {
|
.ai-waiting {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #e6a23c;
|
color: var(--accent-warning);
|
||||||
margin: 4px 0 0;
|
margin: 4px 0 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -722,7 +722,7 @@ onMounted(() => {
|
|||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
border: 2px solid #f3d19e;
|
border: 2px solid #f3d19e;
|
||||||
border-top-color: #e6a23c;
|
border-top-color: var(--accent-warning);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
@@ -851,7 +851,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.history-item:hover {
|
.history-item:hover {
|
||||||
background: #f1f8e9;
|
background: var(--accent-success-softer);
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-time {
|
.history-time {
|
||||||
@@ -891,12 +891,12 @@ onMounted(() => {
|
|||||||
|
|
||||||
.start-guide {
|
.start-guide {
|
||||||
padding: 14px 22px;
|
padding: 14px 22px;
|
||||||
background: linear-gradient(135deg, #fff8e1 0%, #fff3cd 100%);
|
background: linear-gradient(135deg, var(--accent-warning-soft) 0%, #fff3cd 100%);
|
||||||
border-left: 4px solid #ffc107;
|
border-left: 4px solid #ffc107;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #856404;
|
color: var(--accent-success-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.start-guide p {
|
.start-guide p {
|
||||||
@@ -905,7 +905,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.select-hint {
|
.select-hint {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #856404;
|
color: var(--accent-success-text);
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
background: #fdf6ec;
|
background: #fdf6ec;
|
||||||
|
|||||||
@@ -904,7 +904,7 @@ defineExpose({
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--green-700);
|
color: var(--green-700);
|
||||||
background: #e8f5e9;
|
background: var(--accent-success-soft);
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
@@ -931,7 +931,7 @@ defineExpose({
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--green-700);
|
color: var(--green-700);
|
||||||
background: #e8f5e9;
|
background: var(--accent-success-soft);
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
@@ -968,7 +968,7 @@ defineExpose({
|
|||||||
|
|
||||||
.ai-waiting {
|
.ai-waiting {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #e6a23c;
|
color: var(--accent-warning);
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -980,7 +980,7 @@ defineExpose({
|
|||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
border: 2px solid #f3d19e;
|
border: 2px solid #f3d19e;
|
||||||
border-top-color: #e6a23c;
|
border-top-color: var(--accent-warning);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
@@ -1238,7 +1238,7 @@ defineExpose({
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--green-700);
|
color: var(--green-700);
|
||||||
background: #e8f5e9;
|
background: var(--accent-success-soft);
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -461,7 +461,7 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dot-fragment {
|
.dot-fragment {
|
||||||
background: #e6a23c;
|
background: var(--accent-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
.review-scroll-track {
|
.review-scroll-track {
|
||||||
@@ -485,7 +485,7 @@ onBeforeUnmount(() => {
|
|||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
background: var(--surface-strong);
|
background: var(--surface-strong);
|
||||||
border: 1px solid var(--border-soft);
|
border: 1px solid var(--border-soft);
|
||||||
border-left: 3px solid #e6a23c;
|
border-left: 3px solid var(--accent-warning);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s, box-shadow 0.2s;
|
transition: background 0.2s, box-shadow 0.2s;
|
||||||
@@ -495,7 +495,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
.review-chip:hover {
|
.review-chip:hover {
|
||||||
box-shadow: var(--shadow-soft);
|
box-shadow: var(--shadow-soft);
|
||||||
background: #fff8e1;
|
background: var(--accent-warning-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip-task {
|
.chip-task {
|
||||||
@@ -524,7 +524,7 @@ onBeforeUnmount(() => {
|
|||||||
margin: 0 0 14px;
|
margin: 0 0 14px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-left: 3px solid var(--green-600);
|
border-left: 3px solid var(--green-600);
|
||||||
background: #f1f8e9;
|
background: var(--accent-success-softer);
|
||||||
border-radius: 0 6px 6px 0;
|
border-radius: 0 6px 6px 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -558,8 +558,8 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.recall-full-item.is-clicked {
|
.recall-full-item.is-clicked {
|
||||||
background: #fff8e1;
|
background: var(--accent-warning-soft);
|
||||||
border-color: #e6a23c;
|
border-color: var(--accent-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
.recall-item-tag {
|
.recall-item-tag {
|
||||||
@@ -571,13 +571,13 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tag-report {
|
.tag-report {
|
||||||
background: #e8f5e9;
|
background: var(--accent-success-soft);
|
||||||
color: var(--green-700);
|
color: var(--green-700);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-fragment {
|
.tag-fragment {
|
||||||
background: #fff8e1;
|
background: var(--accent-warning-soft);
|
||||||
color: #b8860b;
|
color: var(--accent-warning-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.recall-item-content {
|
.recall-item-content {
|
||||||
|
|||||||
+4
-3
@@ -1,11 +1,12 @@
|
|||||||
import './assets/main.css'
|
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import ElementPlus from 'element-plus'
|
import ElementPlus from 'element-plus'
|
||||||
import 'element-plus/dist/index.css'
|
import 'element-plus/dist/index.css'
|
||||||
|
import 'normalize.css'
|
||||||
|
// 必须在 EP 样式之后:同特异度下靠加载顺序覆盖 EP 主题变量
|
||||||
|
import './assets/main.css'
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from "@/router"
|
import router from "@/router"
|
||||||
import 'normalize.css'
|
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
app.use(ElementPlus)
|
app.use(ElementPlus)
|
||||||
|
|||||||
Reference in New Issue
Block a user