From 530aa0954b0d92954240ea248dbf8d883fbfcbca Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Tue, 16 Jun 2026 02:27:40 -0600 Subject: [PATCH] style: unify app controls --- .../2026-06-16-unified-app-controls-style.md | 21 ++- src/components/AdminPage.test.ts | 40 +++++ src/components/AdminPage.vue | 113 ++++++-------- src/components/BookListPage.test.ts | 19 +++ src/components/BookListPage.vue | 70 +++++---- src/components/LoginPage.test.ts | 23 +++ src/components/LoginPage.vue | 67 +++----- src/style.css | 145 +++++++++++++++--- 8 files changed, 328 insertions(+), 170 deletions(-) create mode 100644 src/components/AdminPage.test.ts create mode 100644 src/components/LoginPage.test.ts diff --git a/docs/superpowers/plans/2026-06-16-unified-app-controls-style.md b/docs/superpowers/plans/2026-06-16-unified-app-controls-style.md index ec871d0..9081227 100644 --- a/docs/superpowers/plans/2026-06-16-unified-app-controls-style.md +++ b/docs/superpowers/plans/2026-06-16-unified-app-controls-style.md @@ -61,7 +61,9 @@ import { mount } from '@vue/test-utils' import { describe, expect, it, vi } from 'vitest' import LoginPage from './LoginPage.vue' -const login = vi.fn() +const { login } = vi.hoisted(() => ({ + login: vi.fn(), +})) vi.mock('../composables/useAuth', () => ({ useAuth: () => ({ login }), @@ -89,8 +91,10 @@ import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import AdminPage from './AdminPage.vue' -const authedFetch = vi.fn() -const logout = vi.fn() +const { authedFetch, logout } = vi.hoisted(() => ({ + authedFetch: vi.fn(), + logout: vi.fn(), +})) vi.mock('../composables/useAuth', () => ({ authedFetch: (...args: unknown[]) => authedFetch(...args), @@ -286,6 +290,7 @@ Expected: Still FAIL because component templates have not been updated yet. - Modify: `src/components/LoginPage.vue` - Modify: `src/components/BookListPage.vue` - Modify: `src/components/AdminPage.vue` +- Modify: `src/style.css` - [ ] **Step 1: Update LoginPage template classes** @@ -356,7 +361,11 @@ In `src/components/BookListPage.vue`: Delete the scoped style block from `src/components/BookListPage.vue` because `.app-page-header` and `.app-page-actions` replace its rules. -- [ ] **Step 5: Update AdminPage template classes** +- [ ] **Step 5: Remove old BookListPage global control rules** + +In `src/style.css`, remove the `.book-list-page` block and remove `.book-list-create input`, `.book-list-item input`, `.book-list-create button`, and `.book-list-item button` from the global book-list selectors. Keep the `.dialog input` rule and keep the book-list layout rules such as `.book-list`, `.book-list-item`, `.book-list-name`, and `.book-list-meta`. + +- [ ] **Step 6: Update AdminPage template classes** In `src/components/AdminPage.vue`: @@ -372,7 +381,7 @@ In `src/components/AdminPage.vue`: - Add `data-testid="delete-user-u1"` pattern by rendering ``:data-testid="`delete-user-${u.id}`"`` on each delete button. - Add `class="ui-button ui-button--danger"` to each delete button. -- [ ] **Step 6: Replace AdminPage scoped styles** +- [ ] **Step 7: Replace AdminPage scoped styles** Replace the scoped style block in `src/components/AdminPage.vue` with: @@ -402,7 +411,7 @@ Replace the scoped style block in `src/components/AdminPage.vue` with: ``` -- [ ] **Step 7: Run focused tests** +- [ ] **Step 8: Run focused tests** Run: diff --git a/src/components/AdminPage.test.ts b/src/components/AdminPage.test.ts new file mode 100644 index 0000000..fe0ba11 --- /dev/null +++ b/src/components/AdminPage.test.ts @@ -0,0 +1,40 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import AdminPage from './AdminPage.vue' + +const { authedFetch, logout } = vi.hoisted(() => ({ + authedFetch: vi.fn(), + logout: vi.fn(), +})) + +vi.mock('../composables/useAuth', () => ({ + authedFetch: (...args: unknown[]) => authedFetch(...args), + useAuth: () => ({ logout }), +})) + +describe('AdminPage', () => { + beforeEach(() => { + vi.clearAllMocks() + authedFetch.mockResolvedValue([ + { id: 'u1', username: 'teacher', role: 'user', createdAt: '2026-01-01T00:00:00.000Z' }, + ]) + }) + + it('uses shared app control classes', async () => { + const wrapper = mount(AdminPage) + await flushPromises() + + expect(wrapper.get('.admin-page').classes()).toContain('app-page') + expect(wrapper.get('header').classes()).toContain('app-page-header') + expect(wrapper.get('input[placeholder="用户名"]').classes()).toContain('ui-field') + expect(wrapper.get('input[placeholder="密码"]').classes()).toContain('ui-field') + expect(wrapper.get('select').classes()).toContain('ui-select') + expect(wrapper.get('button[type="submit"]').classes()).toEqual( + expect.arrayContaining(['ui-button', 'ui-button--primary']), + ) + expect(wrapper.get('table').classes()).toContain('ui-table') + expect(wrapper.get('button[data-testid="delete-user-u1"]').classes()).toEqual( + expect.arrayContaining(['ui-button', 'ui-button--danger']), + ) + }) +}) diff --git a/src/components/AdminPage.vue b/src/components/AdminPage.vue index a83a1f3..94209a0 100644 --- a/src/components/AdminPage.vue +++ b/src/components/AdminPage.vue @@ -63,30 +63,44 @@ onMounted(loadUsers) diff --git a/src/components/BookListPage.test.ts b/src/components/BookListPage.test.ts index 45fe3a0..27744bb 100644 --- a/src/components/BookListPage.test.ts +++ b/src/components/BookListPage.test.ts @@ -127,4 +127,23 @@ describe('BookListPage', () => { expect(booksApi.deleteBook).not.toHaveBeenCalled() expect(wrapper.text()).toContain('Web 前端开发') }) + + it('uses shared app control classes for actions', async () => { + vi.mocked(booksApi.listBooks).mockResolvedValue([ + { id: 'b1', name: 'Web 前端开发', updatedAt: '2026-01-01T00:00:00.000Z', lessonCount: 0 }, + ]) + + const wrapper = mount(BookListPage) + await flushPromises() + + expect(wrapper.get('form.book-list-create input').classes()).toContain('ui-field') + expect(wrapper.get('form.book-list-create button[type="submit"]').classes()).toEqual( + expect.arrayContaining(['ui-button', 'ui-button--primary']), + ) + expect(wrapper.get('button[data-testid="open-b1"]').classes()).toContain('ui-button') + expect(wrapper.get('button[data-testid="rename-b1"]').classes()).toContain('ui-button') + expect(wrapper.get('button[data-testid="delete-b1"]').classes()).toEqual( + expect.arrayContaining(['ui-button', 'ui-button--danger']), + ) + }) }) diff --git a/src/components/BookListPage.vue b/src/components/BookListPage.vue index a4b3e49..0e70342 100644 --- a/src/components/BookListPage.vue +++ b/src/components/BookListPage.vue @@ -103,18 +103,26 @@ async function removeBook(book: BookSummary): Promise { - - diff --git a/src/components/LoginPage.test.ts b/src/components/LoginPage.test.ts new file mode 100644 index 0000000..1149244 --- /dev/null +++ b/src/components/LoginPage.test.ts @@ -0,0 +1,23 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import LoginPage from './LoginPage.vue' + +const { login } = vi.hoisted(() => ({ + login: vi.fn(), +})) + +vi.mock('../composables/useAuth', () => ({ + useAuth: () => ({ login }), +})) + +describe('LoginPage', () => { + it('uses shared form field and primary button classes', () => { + const wrapper = mount(LoginPage) + + expect(wrapper.get('#username').classes()).toContain('ui-field') + expect(wrapper.get('#password').classes()).toContain('ui-field') + expect(wrapper.get('button[type="submit"]').classes()).toEqual( + expect.arrayContaining(['ui-button', 'ui-button--primary']), + ) + }) +}) diff --git a/src/components/LoginPage.vue b/src/components/LoginPage.vue index b1b980c..489ae8c 100644 --- a/src/components/LoginPage.vue +++ b/src/components/LoginPage.vue @@ -33,6 +33,7 @@ async function handleSubmit(): Promise { { -

{{ error }}

- @@ -63,66 +69,37 @@ async function handleSubmit(): Promise { align-items: center; justify-content: center; min-height: 100vh; - background: #f5f5f5; + background: #edf0f2; + padding: 24px; } .login-form { - background: white; - padding: 2rem; - border-radius: 8px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - width: 320px; + width: min(100%, 340px); display: flex; flex-direction: column; - gap: 1rem; + gap: 16px; + background: #fff; + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: 0 4px 18px rgba(32, 42, 51, 0.12); + padding: 24px; } .login-form h1 { margin: 0; - font-size: 1.5rem; + color: var(--green-700); + font-size: 24px; text-align: center; } .field { display: flex; flex-direction: column; - gap: 0.25rem; + gap: 6px; } .field label { - font-size: 0.875rem; - color: #555; -} - -.field input { - padding: 0.5rem; - border: 1px solid #ccc; - border-radius: 4px; - font-size: 1rem; -} - -.field input:disabled { - background: #f5f5f5; -} - -.error { - color: #c0392b; - font-size: 0.875rem; - margin: 0; -} - -button[type='submit'] { - padding: 0.6rem; - background: #2c3e50; - color: white; - border: none; - border-radius: 4px; - font-size: 1rem; - cursor: pointer; -} - -button[type='submit']:disabled { - opacity: 0.5; - cursor: not-allowed; + color: var(--muted); + font-size: 14px; } diff --git a/src/style.css b/src/style.css index 08bbf4c..5eb5c97 100644 --- a/src/style.css +++ b/src/style.css @@ -38,6 +38,130 @@ input { min-height: 100vh; } +/* Shared app controls */ +.app-page { + max-width: 880px; + margin: 0 auto; + padding: 32px 16px; +} + +.app-page-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; +} + +.app-page-header h1 { + margin: 0; + color: var(--green-700); + font-size: 24px; +} + +.app-page-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.ui-button { + border: 1px solid var(--line); + background: #fff; + border-radius: 6px; + padding: 6px 14px; + color: var(--green-700); + cursor: pointer; + white-space: nowrap; +} + +.ui-button:hover:not(:disabled) { + background: var(--green-100); + border-color: var(--green-600); +} + +.ui-button:disabled { + color: var(--muted); + border-color: var(--line); + cursor: not-allowed; + opacity: 0.6; +} + +.ui-button--primary { + border-color: var(--green-600); + background: var(--green-600); + color: #fff; +} + +.ui-button--primary:hover:not(:disabled) { + background: var(--green-700); + border-color: var(--green-700); +} + +.ui-button--danger { + color: #c0392b; +} + +.ui-button--danger:hover:not(:disabled) { + background: #fdecea; + border-color: #c0392b; +} + +.ui-field, +.ui-select { + border: 1px solid var(--line); + border-radius: 6px; + padding: 8px 12px; + background: #fff; + color: #202a33; +} + +.ui-field:focus, +.ui-select:focus { + outline: none; + border-color: var(--green-600); + box-shadow: 0 0 0 2px rgba(45, 122, 88, 0.16); +} + +.ui-field:disabled, +.ui-select:disabled { + background: #f4f6f7; + color: var(--muted); + cursor: not-allowed; +} + +.ui-table { + width: 100%; + border-collapse: collapse; + margin-top: 8px; + background: #fff; + border: 1px solid var(--line); +} + +.ui-table th, +.ui-table td { + text-align: left; + padding: 8px 10px; + border-bottom: 1px solid var(--line); +} + +.ui-table th { + background: var(--green-100); + color: var(--green-700); + font-weight: 600; +} + +.ui-table tr:last-child td { + border-bottom: none; +} + +.ui-error { + color: #c0392b; + font-size: 14px; + margin: 8px 0 0; +} + /* Toolbar */ .workspace-toolbar { display: flex; @@ -592,38 +716,19 @@ table { } /* Book list */ -.book-list-page { - max-width: 720px; - margin: 0 auto; - padding: 32px 16px; -} - .book-list-create { display: flex; gap: 8px; margin-bottom: 16px; } -.dialog input, -.book-list-create input, -.book-list-item input { +.dialog input { flex: 1 1 auto; border: 1px solid var(--line); border-radius: 6px; padding: 8px 12px; } -.book-list-create button, -.book-list-item button { - border: 1px solid var(--line); - background: #fff; - border-radius: 6px; - padding: 6px 14px; - color: var(--green-700); - cursor: pointer; - white-space: nowrap; -} - .book-list { list-style: none; margin: 0;