From 28542af2288148a973ee8be448a65e8f35c2d92f Mon Sep 17 00:00:00 2001 From: l17728 Date: Tue, 19 May 2026 22:36:52 +0800 Subject: [PATCH 01/20] =?UTF-8?q?Web=20UI=20design=20=E2=80=94=20decomposi?= =?UTF-8?q?tion=20(5=20UI=20sub-projects)=20+=20UI-SP1=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI-SP1 = app shell + auth + useLiveResource foundation + Dashboard + Task List + Task Create. Frontend-only on the existing ~11-endpoint backend (zero backend/api/migration change). UI-SP2..SP5 (download- manager Task Detail, infra/governance, AI-Copilot, realtime upgrade) each need additive backend read-endpoints — deferred to their own spec/plan cycles. Grounded by 5-agent exploration of the v2.0 design, the real implemented API surface, the scaffold conventions, AI-Copilot feasibility, and an industry-leading UX synthesis. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-19-ui-sp1-shell-tasks-design.md | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-19-ui-sp1-shell-tasks-design.md diff --git a/docs/superpowers/specs/2026-05-19-ui-sp1-shell-tasks-design.md b/docs/superpowers/specs/2026-05-19-ui-sp1-shell-tasks-design.md new file mode 100644 index 0000000..7938641 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-ui-sp1-shell-tasks-design.md @@ -0,0 +1,90 @@ +# Web UI — Decomposition + UI-SP1 (App Shell + Tasks) Design + +**Status:** approved (self-approved under the project's autonomous-execution directive, Rule #1) +**Date:** 2026-05-19 +**Sources:** `docs/v2.0/10-frontend-wireframes.md` (9-page design), `docs/v2.0/12-ai-copilot.md` (conversational UI), the **implemented** backend (`src/dlw/api/*` + `api/openapi.yaml`), the existing `frontend/` scaffold. Grounded by a 5-agent exploration (design spec / real API surface / scaffold conventions / AI-Copilot feasibility / industry UX synthesis). + +> **Pre-execution review note:** the load-bearing constraint is *backend reality*. Only ~11 v1 endpoints exist. A truly feature-complete UI needs **additive backend read-endpoints** for several pages — those are scoped into later sub-projects, NOT UI-SP1. UI-SP1 is deliberately frontend-only on the existing surface. + +--- + +## 1. Why decompose + +The "complete, feature-complete UI" spans 9 pages + an AI-Copilot conversational panel + a download-manager realtime view. That is multiple independent subsystems and (for Copilot) an entire missing backend. Per the brainstorming skill's scope rule and this project's validated sub-project cadence (Phase-3 SP1–SP4 each ran its own spec→plan→2-reviewer→implement→milestone→final-review→PR cycle, all zero-CI-iteration), this is decomposed into **5 UI sub-projects**, built in dependency order. Each gets its own spec/plan/cycle. This document fully specifies the decomposition and **UI-SP1** (the first, highest-value MVP). + +## 2. Decomposition (dependency-ordered) + +| Sub-project | Scope | Backend dependency | +|---|---|---| +| **UI-SP1** (this doc) | App shell (sidebar + topbar + ⌘K command palette + tenant chip + dark mode + zh/en), auth, `useLiveResource` realtime foundation, Dashboard, Task List, **Task Create** | **None — existing endpoints only** | +| UI-SP2 | Download-manager Task Detail (aggregate ring → per-source segmented bar → virtualized chunk-segmented file table → executor swimlanes → event log) + task/file/chunk actions | Additive read endpoints: subtask-chunks, source-allocation, participating-executors, task-events | +| UI-SP3 | Infrastructure & Governance: Executors (host-grouped, drain/restart), Quota metering, Audit log, Settings | Additive: `GET /executors`, audit query endpoint | +| UI-SP4 | AI-Copilot conversational UI (right slide-over, SSE, tool-call/confirm cards, ⌘K integration) | Full AI backend (`/api/ai/chat` SSE, conversation persistence, LLM bridge, MCP→REST tool bridge) — large, v2.1 | +| UI-SP5 (optional) | Realtime upgrade: swap `useLiveResource` internals to SSE/WS, zero view changes | Backend SSE/WS | + +Sequence: **UI-SP1 → (UI-SP2 ∥ UI-SP3) → UI-SP4 → UI-SP5**. MVP value at UI-SP1; flagship differentiation at UI-SP2. + +## 3. UI-SP1 goal + +Turn the 3-page read-only scaffold into a usable application shell where a user can **create and monitor download tasks from the browser** — closing the "can't create tasks from the UI" gap. Frontend-only; **zero backend/API/migration/lint change** (lowest blast radius, per the project's additive lesson). + +## 4. Backend surface UI-SP1 uses (verified implemented) + +- `GET /api/v1/auth/login` (302 → OIDC) / `GET /api/v1/auth/callback` (→ system-JWT) / `GET /api/v1/auth/me` (principal). Token-paste login (existing) kept; an "OIDC login" button calls `/auth/login`. +- `POST /api/v1/tasks` (201 `TaskRead`; enumerates HF → subtasks; 409/422/503 errors; 429 quota). Requires `TaskCreate`: `repo_id, revision, storage_id, priority?, source_strategy?, source_blacklist?, trust_non_hf_sha256?, upgrade_from_revision?, path_template?`. +- `GET /api/v1/tasks` (`TaskList{items:TaskRead[], total}`; tenant-scoped; **no server-side filter** → client-side). +- `GET /api/v1/tasks/{id}` (`TaskDetail` = TaskRead + `subtasks[]`). +- `POST /api/v1/tasks/{id}/cancel` (202 → cancelling; 409 if terminal). +- `DELETE /api/v1/tasks/{id}` (204; terminal-only; 409 otherwise). +- `GET /api/v1/quota/current` (tenant quota snapshot). +- `GET /health/{live,ready,active}`. +- Auth header: `Authorization: Bearer `. **Must be a tenant-user JWT (`user_id` matching a real `users` row), not the system-admin service token** (admin → `user_id=0` → `download_tasks.owner_user_id` FK → 500 on create). UI-SP1 surfaces this as a pre-flight check + clear error. + +UI-SP1 does **not** depend on any missing endpoint (executors list, source-allocation, events, retry/upgrade, AI chat) — those are later sub-projects. + +## 5. Architecture & components (reuse scaffold conventions) + +Existing stack kept: Vue 3.5 + Composition/` + + +``` + +- [ ] **Step 4: Run** `pnpm test:unit -- tests/unit/DataBoundary.spec.ts` → PASS. `pnpm lint:fix && pnpm lint && pnpm typecheck`. + +- [ ] **Step 5: Commit** +```bash +git add frontend/src/components/DataBoundary.vue frontend/tests/unit/DataBoundary.spec.ts +git commit -m "feat(ui-sp1): DataBoundary state wrapper" +``` + +--- + +### Task 7: nav registry + role filtering + +**Files:** Create `src/nav/registry.ts`; Test `tests/unit/nav.spec.ts` + +- [ ] **Step 1: Write the failing test** — `frontend/tests/unit/nav.spec.ts`: +```ts +import { describe, expect, test } from 'vitest' +import { NAV_ITEMS, visibleNav } from '@/nav/registry' + +describe('nav registry', () => { + test('all items have route + labelKey', () => { + for (const i of NAV_ITEMS) { + expect(i.route).toBeTruthy() + expect(i.labelKey).toMatch(/^nav\./) + } + }) + test('visibleNav: no roles → visible to everyone', () => { + const names = visibleNav('guest').map((i) => i.route) + expect(names).toContain('taskList') + expect(names).toContain('dashboard') + }) + test('role-gated item hidden for wrong role', () => { + const gated = { route: 'x', labelKey: 'nav.x', icon: 'i', roles: ['system_admin'] } + expect(visibleNav('tenant_admin', [...NAV_ITEMS, gated]).map((i) => i.route)) + .not.toContain('x') + expect(visibleNav('system_admin', [...NAV_ITEMS, gated]).map((i) => i.route)) + .toContain('x') + }) +}) +``` + +- [ ] **Step 2: Run** `pnpm test:unit -- tests/unit/nav.spec.ts` → FAIL. + +- [ ] **Step 3: Implement** `frontend/src/nav/registry.ts`: +```ts +export interface NavItem { + route: string // route name + labelKey: string // i18n key under nav.* + icon: string // element-plus icon component name + roles?: string[] // if set, visible only to these roles +} + +export const NAV_ITEMS: NavItem[] = [ + { route: 'dashboard', labelKey: 'nav.dashboard', icon: 'Odometer' }, + { route: 'taskList', labelKey: 'nav.tasks', icon: 'List' }, + { route: 'taskCreate', labelKey: 'nav.createTask', icon: 'Plus' }, +] + +export function visibleNav(role: string, items: NavItem[] = NAV_ITEMS): NavItem[] { + return items.filter((i) => !i.roles || i.roles.includes(role)) +} +``` + +- [ ] **Step 4: Run** `pnpm test:unit -- tests/unit/nav.spec.ts` → PASS. `pnpm lint:fix && pnpm lint && pnpm typecheck`. + +- [ ] **Step 5: Commit** +```bash +git add frontend/src/nav/registry.ts frontend/tests/unit/nav.spec.ts +git commit -m "feat(ui-sp1): nav registry + role filtering" +``` + +--- + +# Milestone M2 — Shell + +### Task 8: AppShell (sidebar + topbar) + App.vue conditional layout + +**Files:** Create `src/components/shell/AppShell.vue`; Modify `src/App.vue`; Test `tests/unit/AppShell.spec.ts` + +- [ ] **Step 1: Write the failing test** — `frontend/tests/unit/AppShell.spec.ts`: +```ts +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import ElementPlus from 'element-plus' +import { createI18n } from 'vue-i18n' +import AppShell from '@/components/shell/AppShell.vue' +import zh from '@/locale/zh-CN.json' +import { useAuthStore } from '@/stores/auth' + +const i18n = createI18n({ legacy: false, locale: 'zh-CN', messages: { 'zh-CN': zh } }) +const push = vi.fn() +vi.mock('vue-router', () => ({ + useRouter: () => ({ push }), + useRoute: () => ({ name: 'taskList' }), + RouterView: { template: '
' }, +})) + +function mountShell() { + return mount(AppShell, { global: { plugins: [ElementPlus, i18n] } }) +} + +describe('AppShell', () => { + beforeEach(() => { setActivePinia(createPinia()); push.mockClear() }) + + test('authenticated → renders nav items', () => { + useAuthStore().login('h.' + btoa(JSON.stringify( + { sub: '1', tid: 1, role: 'tenant_admin', pids: [] })) + '.s') + const w = mountShell() + expect(w.text()).toContain(zh.nav.tasks) + expect(w.text()).toContain(zh.nav.dashboard) + }) + + test('logout calls auth.logout + redirects', async () => { + const auth = useAuthStore() + auth.login('h.' + btoa(JSON.stringify( + { sub: '1', tid: 1, role: 'tenant_admin', pids: [] })) + '.s') + const w = mountShell() + await w.find('[data-test=logout]').trigger('click') + expect(auth.isAuthenticated).toBe(false) + expect(push).toHaveBeenCalledWith('/login') + }) +}) +``` + +- [ ] **Step 2: Run** `pnpm test:unit -- tests/unit/AppShell.spec.ts` → FAIL. + +- [ ] **Step 3: Implement** `frontend/src/components/shell/AppShell.vue`: +```vue + + + + + +``` +Replace `frontend/src/App.vue`: +```vue + + + +``` +(NOTE: `CommandPalette.vue` is created in Task 9; until then `App.vue` won't typecheck. To keep Task 8 self-contained, **Task 8 Step 3 also creates a minimal placeholder** `frontend/src/components/CommandPalette.vue`: +```vue + + +``` +Task 9 replaces it with the real component.) + +- [ ] **Step 4: Run** `pnpm test:unit -- tests/unit/AppShell.spec.ts` → PASS. `pnpm lint:fix && pnpm lint && pnpm typecheck && pnpm build`. + +- [ ] **Step 5: Commit** +```bash +git add frontend/src/components/shell/AppShell.vue frontend/src/components/CommandPalette.vue frontend/src/App.vue frontend/tests/unit/AppShell.spec.ts +git commit -m "feat(ui-sp1): AppShell (sidebar+topbar) + conditional layout" +``` + +--- + +### Task 9: CommandPalette (⌘/Ctrl+K) + +**Files:** Modify `src/components/CommandPalette.vue` (replace placeholder); Test `tests/unit/palette.spec.ts` + +- [ ] **Step 1: Write the failing test** — `frontend/tests/unit/palette.spec.ts`: +```ts +import { describe, expect, test } from 'vitest' +import { buildCommands } from '@/components/palette' + +describe('buildCommands', () => { + const t = (k: string) => k + test('includes nav items + create + open-by-id', () => { + const cmds = buildCommands('tenant_admin', t) + const ids = cmds.map((c) => c.id) + expect(ids).toContain('nav:dashboard') + expect(ids).toContain('nav:taskList') + expect(ids).toContain('action:createTask') + expect(ids).toContain('action:openTaskById') + }) + test('role-gates nav', () => { + const cmds = buildCommands('guest', t) + expect(cmds.find((c) => c.id === 'nav:dashboard')).toBeTruthy() + }) +}) +``` + +- [ ] **Step 2: Run** `pnpm test:unit -- tests/unit/palette.spec.ts` → FAIL. + +- [ ] **Step 3: Implement.** Create `frontend/src/components/palette.ts`: +```ts +import { visibleNav } from '@/nav/registry' + +export interface Command { + id: string + label: string + kind: 'nav' | 'action' + routeName?: string + action?: 'createTask' | 'openTaskById' +} + +export function buildCommands(role: string, t: (k: string) => string): Command[] { + const nav: Command[] = visibleNav(role).map((i) => ({ + id: `nav:${i.route}`, label: t(i.labelKey), kind: 'nav', routeName: i.route, + })) + const actions: Command[] = [ + { id: 'action:createTask', label: t('palette.createTask'), + kind: 'action', action: 'createTask' }, + { id: 'action:openTaskById', label: t('palette.openTaskById'), + kind: 'action', action: 'openTaskById' }, + ] + return [...nav, ...actions] +} +``` +Replace `frontend/src/components/CommandPalette.vue`: +```vue + + + + + +``` + +- [ ] **Step 4: Run** `pnpm test:unit -- tests/unit/palette.spec.ts` → PASS. `pnpm lint:fix && pnpm lint && pnpm typecheck`. + +- [ ] **Step 5: Commit** +```bash +git add frontend/src/components/palette.ts frontend/src/components/CommandPalette.vue frontend/tests/unit/palette.spec.ts +git commit -m "feat(ui-sp1): command palette (Ctrl/Cmd+K)" +``` + +--- + +### Task 10: router — add Dashboard/TaskList/TaskCreate routes + +**Files:** Modify `src/router/index.ts`; Test `tests/unit/router.spec.ts` + +- [ ] **Step 1: Write the failing test** — `frontend/tests/unit/router.spec.ts`: +```ts +import { describe, expect, test } from 'vitest' +import { routes } from '@/router' + +describe('routes', () => { + test('has dashboard / taskList / taskCreate / taskDetail / login', () => { + const byName = Object.fromEntries( + routes.filter((r) => r.name).map((r) => [r.name, r])) + expect(byName.dashboard?.path).toBe('/') + expect(byName.taskList?.path).toBe('/tasks') + expect(byName.taskCreate?.path).toBe('/tasks/new') + expect(byName.taskDetail?.path).toBe('/tasks/:id') + expect(byName.login?.meta?.public).toBe(true) + }) +}) +``` + +- [ ] **Step 2: Run** `pnpm test:unit -- tests/unit/router.spec.ts` → FAIL. + +- [ ] **Step 3: Implement** `frontend/src/router/index.ts`: +```ts +import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' +import { useAuthStore } from '@/stores/auth' + +export const routes: RouteRecordRaw[] = [ + { + path: '/login', name: 'login', + component: () => import('@/pages/Login.vue'), + meta: { public: true }, + }, + { + path: '/', name: 'dashboard', + component: () => import('@/pages/Dashboard.vue'), + }, + { + path: '/tasks', name: 'taskList', + component: () => import('@/pages/TaskList.vue'), + }, + { + path: '/tasks/new', name: 'taskCreate', + component: () => import('@/pages/TaskCreate.vue'), + }, + { + path: '/tasks/:id', name: 'taskDetail', + component: () => import('@/pages/TaskDetail.vue'), + props: true, + }, + { path: '/:pathMatch(.*)*', redirect: '/' }, +] + +const router = createRouter({ history: createWebHistory(), routes }) + +router.beforeEach((to) => { + if (to.meta.public) return true + const auth = useAuthStore() + if (!auth.isAuthenticated) return { path: '/login' } + return true +}) + +export { router } +export default router +``` + +- [ ] **Step 2b: Run** `pnpm test:unit -- tests/unit/router.spec.ts` → still FAIL until Dashboard/TaskCreate pages exist (dynamic imports are lazy; the route-config test doesn't import them, so it PASSES now). Expected PASS. + +- [ ] **Step 4: Run** `pnpm test:unit -- tests/unit/router.spec.ts` → PASS. `pnpm lint:fix && pnpm lint && pnpm typecheck` (typecheck tolerates not-yet-created lazy page imports? No — `vue-tsc` resolves `import('@/pages/Dashboard.vue')`. **Therefore create empty placeholder pages now**: `frontend/src/pages/Dashboard.vue` and `frontend/src/pages/TaskCreate.vue` each: +```vue + + +``` +Tasks 11/14 replace them.) Re-run `pnpm typecheck` → green. + +- [ ] **Step 5: Commit** +```bash +git add frontend/src/router/index.ts frontend/src/pages/Dashboard.vue frontend/src/pages/TaskCreate.vue frontend/tests/unit/router.spec.ts +git commit -m "feat(ui-sp1): routes for dashboard/tasks/create (+ placeholders)" +``` + +--- + +# Milestone M3 — Pages + +### Task 11: Sparkline + Dashboard + +**Files:** Create `src/components/Sparkline.vue`, `src/composables/useQuota.ts`, `src/dashboard/aggregate.ts`; Modify `src/pages/Dashboard.vue`; Test `tests/unit/dashboard.spec.ts` + +- [ ] **Step 1: Write the failing test** — `frontend/tests/unit/dashboard.spec.ts`: +```ts +import { describe, expect, test } from 'vitest' +import { aggregateKpis, bucket24h } from '@/dashboard/aggregate' +import type { TaskRead } from '@/api/types' + +const mk = (status: TaskRead['status'], createdAt: string): TaskRead => ({ + id: Math.random().toString(36), repo_id: 'o/r', revision: 'a', + status, priority: 1, created_at: createdAt, completed_at: null, + error_message: null, +}) + +describe('dashboard aggregate', () => { + test('aggregateKpis counts by bucket', () => { + const k = aggregateKpis([ + mk('downloading', '2026-05-19T00:00:00Z'), + mk('scheduling', '2026-05-19T00:00:00Z'), + mk('succeeded', '2026-05-19T00:00:00Z'), + mk('failed', '2026-05-19T00:00:00Z'), + ]) + expect(k).toEqual({ inProgress: 2, completed: 1, failed: 1, total: 4 }) + }) + test('bucket24h returns 24 hourly counts within window', () => { + const now = new Date('2026-05-19T12:00:00Z') + const b = bucket24h([ + mk('succeeded', '2026-05-19T11:30:00Z'), + mk('succeeded', '2026-05-19T11:45:00Z'), + mk('succeeded', '2026-05-10T00:00:00Z'), // outside 24h → excluded + ], now) + expect(b).toHaveLength(24) + expect(b.reduce((a, c) => a + c, 0)).toBe(2) + }) +}) +``` + +- [ ] **Step 2: Run** `pnpm test:unit -- tests/unit/dashboard.spec.ts` → FAIL. + +- [ ] **Step 3: Implement.** Create `frontend/src/dashboard/aggregate.ts`: +```ts +import type { TaskRead, TaskStatus } from '@/api/types' + +const IN_PROGRESS: ReadonlySet = new Set([ + 'pending', 'queued', 'scheduling', 'downloading', +]) + +export function aggregateKpis(tasks: TaskRead[]) { + let inProgress = 0, completed = 0, failed = 0 + for (const t of tasks) { + if (IN_PROGRESS.has(t.status)) inProgress++ + else if (t.status === 'succeeded') completed++ + else if (t.status === 'failed') failed++ + } + return { inProgress, completed, failed, total: tasks.length } +} + +export function bucket24h(tasks: TaskRead[], now: Date = new Date()): number[] { + const buckets = new Array(24).fill(0) + const end = now.getTime() + const start = end - 24 * 3600_000 + for (const t of tasks) { + const ts = new Date(t.created_at).getTime() + if (ts >= start && ts <= end) { + const idx = Math.min(23, Math.floor((ts - start) / 3600_000)) + buckets[idx]++ + } + } + return buckets +} +``` +Create `frontend/src/composables/useQuota.ts`: +```ts +import { useLiveResource } from '@/composables/useLiveResource' +import { client } from '@/api/client' +import type { QuotaCurrent } from '@/api/types' + +export function useQuota() { + return useLiveResource( + ['quota'], + async () => (await client.get('/api/v1/quota/current')).data, + { baseIntervalMs: 30_000, staleTime: 30_000 }, + ) +} +``` +Create `frontend/src/components/Sparkline.vue`: +```vue + + + +``` +Replace `frontend/src/pages/Dashboard.vue`: +```vue + + + + + +``` + +- [ ] **Step 4: Run** `pnpm test:unit -- tests/unit/dashboard.spec.ts` → PASS. `pnpm lint:fix && pnpm lint && pnpm typecheck`. + +- [ ] **Step 5: Commit** +```bash +git add frontend/src/dashboard frontend/src/composables/useQuota.ts frontend/src/components/Sparkline.vue frontend/src/pages/Dashboard.vue frontend/tests/unit/dashboard.spec.ts +git commit -m "feat(ui-sp1): Dashboard (KPIs + 24h sparkline + quota + recent)" +``` + +--- + +### Task 12: task mutations (cancel/delete, optimistic) + +**Files:** Create `src/composables/useTaskMutations.ts`; Test `tests/unit/taskMutations.spec.ts` + +- [ ] **Step 1: Write the failing test** — `frontend/tests/unit/taskMutations.spec.ts`: +```ts +import { describe, expect, test } from 'vitest' +import { canCancel, canDelete } from '@/composables/useTaskMutations' + +describe('task action guards', () => { + test('canCancel: only non-terminal', () => { + expect(canCancel('downloading')).toBe(true) + expect(canCancel('pending')).toBe(true) + expect(canCancel('succeeded')).toBe(false) + expect(canCancel('failed')).toBe(false) + expect(canCancel('cancelled')).toBe(false) + }) + test('canDelete: only terminal', () => { + expect(canDelete('succeeded')).toBe(true) + expect(canDelete('failed')).toBe(true) + expect(canDelete('cancelled')).toBe(true) + expect(canDelete('downloading')).toBe(false) + }) +}) +``` + +- [ ] **Step 2: Run** `pnpm test:unit -- tests/unit/taskMutations.spec.ts` → FAIL. + +- [ ] **Step 3: Implement** `frontend/src/composables/useTaskMutations.ts`: +```ts +import { useMutation, useQueryClient } from '@tanstack/vue-query' +import { client } from '@/api/client' +import { TERMINAL_STATUSES, type TaskStatus } from '@/api/types' + +export function canCancel(status: TaskStatus): boolean { + return !TERMINAL_STATUSES.has(status) +} +export function canDelete(status: TaskStatus): boolean { + return TERMINAL_STATUSES.has(status) +} + +export function useTaskMutations() { + const qc = useQueryClient() + const invalidate = () => qc.invalidateQueries({ queryKey: ['tasks'] }) + + const cancel = useMutation({ + mutationFn: (id: string) => + client.post(`/api/v1/tasks/${id}/cancel`, {}), + onSettled: invalidate, + }) + const remove = useMutation({ + mutationFn: (id: string) => client.delete(`/api/v1/tasks/${id}`), + onSettled: invalidate, + }) + return { cancel, remove } +} +``` + +- [ ] **Step 4: Run** `pnpm test:unit -- tests/unit/taskMutations.spec.ts` → PASS. `pnpm lint:fix && pnpm lint && pnpm typecheck`. + +- [ ] **Step 5: Commit** +```bash +git add frontend/src/composables/useTaskMutations.ts frontend/tests/unit/taskMutations.spec.ts +git commit -m "feat(ui-sp1): task cancel/delete mutations + guards" +``` + +--- + +### Task 13: TaskList upgrade (filter + actions) + +**Files:** Create `src/tasks/filter.ts`; Modify `src/pages/TaskList.vue`; Test `tests/unit/taskFilter.spec.ts` + +- [ ] **Step 1: Write the failing test** — `frontend/tests/unit/taskFilter.spec.ts`: +```ts +import { describe, expect, test } from 'vitest' +import { filterTasks } from '@/tasks/filter' +import type { TaskRead } from '@/api/types' + +const mk = (id: string, repo: string, status: TaskRead['status']): TaskRead => ({ + id, repo_id: repo, revision: 'abc', status, priority: 1, + created_at: '2026-05-19T00:00:00Z', completed_at: null, error_message: null, +}) +const items = [ + mk('aaaa1111', 'org/alpha', 'downloading'), + mk('bbbb2222', 'org/beta', 'succeeded'), +] + +describe('filterTasks', () => { + test('no filter → all', () => { + expect(filterTasks(items, { status: '', q: '' })).toHaveLength(2) + }) + test('status filter', () => { + expect(filterTasks(items, { status: 'succeeded', q: '' }).map((t) => t.id)) + .toEqual(['bbbb2222']) + }) + test('q matches repo or id (case-insensitive)', () => { + expect(filterTasks(items, { status: '', q: 'ALPHA' }).map((t) => t.id)) + .toEqual(['aaaa1111']) + expect(filterTasks(items, { status: '', q: 'bbbb' }).map((t) => t.id)) + .toEqual(['bbbb2222']) + }) +}) +``` + +- [ ] **Step 2: Run** `pnpm test:unit -- tests/unit/taskFilter.spec.ts` → FAIL. + +- [ ] **Step 3: Implement.** Create `frontend/src/tasks/filter.ts`: +```ts +import type { TaskRead } from '@/api/types' + +export function filterTasks( + items: TaskRead[], f: { status: string; q: string }, +): TaskRead[] { + const q = f.q.trim().toLowerCase() + return items.filter((t) => { + if (f.status && t.status !== f.status) return false + if (q && !t.repo_id.toLowerCase().includes(q) && + !t.id.toLowerCase().includes(q)) return false + return true + }) +} +``` +Replace `frontend/src/pages/TaskList.vue`: +```vue + + + + + +``` + +- [ ] **Step 4: Run** `pnpm test:unit -- tests/unit/taskFilter.spec.ts` → PASS; `pnpm test:unit` (full) → all PASS. `pnpm lint:fix && pnpm lint && pnpm typecheck`. + +- [ ] **Step 5: Commit** +```bash +git add frontend/src/tasks/filter.ts frontend/src/pages/TaskList.vue frontend/tests/unit/taskFilter.spec.ts +git commit -m "feat(ui-sp1): TaskList filter + cancel/delete actions" +``` + +--- + +### Task 14: TaskCreate page (+ service-token guard) + +**Files:** Create `src/tasks/createValidation.ts`; Modify `src/pages/TaskCreate.vue`; Test `tests/unit/createValidation.spec.ts` + +- [ ] **Step 1: Write the failing test** — `frontend/tests/unit/createValidation.spec.ts`: +```ts +import { describe, expect, test } from 'vitest' +import { validateCreate, mapCreateError } from '@/tasks/createValidation' + +describe('validateCreate', () => { + test('valid', () => { + expect(validateCreate({ repo_id: 'org/m', revision: 'a'.repeat(40), + storage_id: 1 })).toEqual([]) + }) + test('errors', () => { + const e = validateCreate({ repo_id: 'bad', revision: 'xyz', storage_id: 0 }) + expect(e).toContain('repoPattern') + expect(e).toContain('revPattern') + expect(e).toContain('storageRequired') + }) +}) +describe('mapCreateError', () => { + test('http status → i18n key', () => { + expect(mapCreateError(409)).toBe('errors.conflict') + expect(mapCreateError(422)).toBe('errors.validation') + expect(mapCreateError(429)).toBe('errors.quota_exceeded') + expect(mapCreateError(503)).toBe('errors.service_unavailable') + expect(mapCreateError(500)).toBe('errors.service_unavailable') + }) +}) +``` + +- [ ] **Step 2: Run** `pnpm test:unit -- tests/unit/createValidation.spec.ts` → FAIL. + +- [ ] **Step 3: Implement.** Create `frontend/src/tasks/createValidation.ts`: +```ts +import type { TaskCreateBody } from '@/api/types' + +const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/ +const SHA_RE = /^[0-9a-f]{40}$/ + +export function validateCreate(b: Partial): string[] { + const e: string[] = [] + if (!b.repo_id) e.push('repoRequired') + else if (!REPO_RE.test(b.repo_id)) e.push('repoPattern') + if (!b.revision) e.push('revRequired') + else if (!SHA_RE.test(b.revision)) e.push('revPattern') + if (!b.storage_id || b.storage_id <= 0) e.push('storageRequired') + return e +} + +export function mapCreateError(status: number | undefined): string { + if (status === 409) return 'errors.conflict' + if (status === 422) return 'errors.validation' + if (status === 429) return 'errors.quota_exceeded' + return 'errors.service_unavailable' +} +``` +Replace `frontend/src/pages/TaskCreate.vue`: +```vue + + + +``` + +- [ ] **Step 4: Run** `pnpm test:unit -- tests/unit/createValidation.spec.ts` → PASS. `pnpm lint:fix && pnpm lint && pnpm typecheck`. + +- [ ] **Step 5: Commit** +```bash +git add frontend/src/tasks/createValidation.ts frontend/src/pages/TaskCreate.vue frontend/tests/unit/createValidation.spec.ts +git commit -m "feat(ui-sp1): TaskCreate form + service-token preflight guard" +``` + +--- + +### Task 15: Login + OIDC button + +**Files:** Modify `src/pages/Login.vue`; Test `tests/unit/oidc.spec.ts` + +- [ ] **Step 1: Write the failing test** — `frontend/tests/unit/oidc.spec.ts`: +```ts +import { describe, expect, test } from 'vitest' +import { oidcLoginUrl } from '@/pages/oidc' + +describe('oidcLoginUrl', () => { + test('uses VITE_API_BASE when set', () => { + expect(oidcLoginUrl('http://c:8001')).toBe('http://c:8001/api/v1/auth/login') + }) + test('relative when base empty (vite proxy)', () => { + expect(oidcLoginUrl('')).toBe('/api/v1/auth/login') + expect(oidcLoginUrl(undefined)).toBe('/api/v1/auth/login') + }) +}) +``` + +- [ ] **Step 2: Run** `pnpm test:unit -- tests/unit/oidc.spec.ts` → FAIL. + +- [ ] **Step 3: Implement.** Create `frontend/src/pages/oidc.ts`: +```ts +export function oidcLoginUrl(base: string | undefined): string { + return `${base ?? ''}/api/v1/auth/login` +} +``` +Modify `frontend/src/pages/Login.vue` — add OIDC button + handler (keep everything else; add to ` ``` -- [ ] **Step 4: Run** `pnpm test:unit -- tests/unit/createValidation.spec.ts` → PASS. `pnpm lint:fix && pnpm lint && pnpm typecheck`. +- [ ] **Step 4: Run** `pnpm test:unit -- tests/unit/createValidation.spec.ts tests/unit/TaskCreate.spec.ts` → PASS. `pnpm lint:fix && pnpm lint && pnpm typecheck`. - [ ] **Step 5: Commit** ```bash -git add frontend/src/tasks/createValidation.ts frontend/src/pages/TaskCreate.vue frontend/tests/unit/createValidation.spec.ts +git add frontend/src/tasks/createValidation.ts frontend/src/pages/TaskCreate.vue frontend/tests/unit/createValidation.spec.ts frontend/tests/unit/TaskCreate.spec.ts git commit -m "feat(ui-sp1): TaskCreate form + service-token preflight guard" ``` From f362a68e4054145b66384f0f45c05f4b2e355abb Mon Sep 17 00:00:00 2001 From: l17728 Date: Tue, 19 May 2026 22:57:23 +0800 Subject: [PATCH 04/20] feat(ui-sp1): en-US locale + zh parity --- frontend/src/locale/en-US.json | 61 +++++++++++++++++++ frontend/src/locale/zh-CN.json | 95 +++++++++++++++++------------- frontend/tests/unit/locale.spec.ts | 24 ++++++++ 3 files changed, 138 insertions(+), 42 deletions(-) create mode 100644 frontend/src/locale/en-US.json create mode 100644 frontend/tests/unit/locale.spec.ts diff --git a/frontend/src/locale/en-US.json b/frontend/src/locale/en-US.json new file mode 100644 index 0000000..3e92151 --- /dev/null +++ b/frontend/src/locale/en-US.json @@ -0,0 +1,61 @@ +{ + "app": { "title": "modelpull", "logout": "Sign out" }, + "login": { + "heading": "Sign in to modelpull", "tokenLabel": "Bearer Token", + "tokenPlaceholder": "Paste a tenant-user JWT", "submit": "Sign in", + "tokenRequired": "Token is required", "oidc": "Sign in with OIDC" + }, + "nav": { "dashboard": "Overview", "tasks": "Tasks", "createTask": "New task" }, + "shell": { + "theme": "Theme", "language": "Language", + "commandHint": "Command palette (Ctrl/⌘+K)", "tenant": "Tenant", "role": "Role" + }, + "palette": { + "placeholder": "Jump to or run an action…", "navGroup": "Navigate", + "actionGroup": "Actions", "createTask": "New task", + "openTaskById": "Open task by ID", "openTaskPrompt": "Enter task ID" + }, + "dashboard": { + "heading": "Overview", "inProgress": "In progress", "completed": "Completed", + "failed": "Failed", "total": "Total", "recent": "Recent tasks", + "quota": "Quota", "trend": "Created (last 24h)", "quotaBytes": "Bytes this month", + "quotaStorage": "Storage", "quotaConcurrent": "Concurrent tasks" + }, + "tasks": { + "listHeading": "Tasks", "empty": "No tasks yet — click \"New task\" to create one", + "create": "New task", "filterStatus": "Status", "filterAll": "All", + "search": "Search repo / id", "cancel": "Cancel", "delete": "Delete", + "cancelConfirm": "Cancel this task?", "deleteConfirm": "Delete this terminal task?", + "cancelled": "Cancellation requested", "deleted": "Deleted", + "columns": { "id": "ID", "repo": "Repo", "revision": "Revision", + "status": "Status", "createdAt": "Created", "actions": "Actions" }, + "view": "View", "detailHeading": "Task detail", "subtasksHeading": "Subtasks", + "polling": "Live refreshing…", "completed": "Stopped (terminal)", + "back": "Back to list", "notFound": "Task not found or deleted", + "subtaskColumns": { "filename": "File", "size": "Size", + "sha256": "SHA256", "status": "Status" } + }, + "create": { + "heading": "Create download task", "repo": "Repo (org/model)", + "revision": "Revision (40-hex sha)", "storageId": "Storage backend ID", + "priority": "Priority", "strategy": "Source strategy", + "upgradeFrom": "Upgrade-from revision", "trustNonHf": "Trust non-HF sha256", + "submit": "Create task", "repoRequired": "Repo is required", + "repoPattern": "Expected org/model", "revRequired": "Revision is required", + "revPattern": "Must be a 40-hex sha", "storageRequired": "Storage ID is required", + "serviceTokenWarn": "You are using the system-admin service token (user_id=0); task creation fails the owner FK. Sign in with a tenant-user JWT to create tasks.", + "success": "Task created" + }, + "status": { + "pending": "Pending", "queued": "Queued", "scheduling": "Scheduling", + "downloading": "Downloading", "succeeded": "Succeeded", "failed": "Failed", + "cancelled": "Cancelled", "assigned": "Assigned", "in_progress": "Downloading" + }, + "errors": { + "invalid_token": "Token invalid or expired — please sign in again", + "service_unavailable": "Service unavailable, retrying…", + "network": "Network error, check your connection", + "quota_exceeded": "Quota exceeded", "conflict": "State conflict (task not terminal or duplicate)", + "validation": "Invalid request parameters", "forbidden": "Forbidden" + } +} diff --git a/frontend/src/locale/zh-CN.json b/frontend/src/locale/zh-CN.json index d5f6340..5ce4995 100644 --- a/frontend/src/locale/zh-CN.json +++ b/frontend/src/locale/zh-CN.json @@ -1,54 +1,65 @@ { - "app": { - "title": "modelpull", - "logout": "退出登录" - }, + "app": { "title": "modelpull", "logout": "退出登录" }, "login": { - "heading": "登录 modelpull", - "tokenLabel": "Bearer Token", - "tokenPlaceholder": "粘贴 DLW_BEARER_TOKEN 的值", - "submit": "登录", - "tokenRequired": "请输入 token" + "heading": "登录 modelpull", "tokenLabel": "Bearer Token", + "tokenPlaceholder": "粘贴租户用户 JWT", "submit": "登录", + "tokenRequired": "请输入 token", "oidc": "使用 OIDC 登录" + }, + "nav": { + "dashboard": "概览", "tasks": "任务", "createTask": "新建任务" + }, + "shell": { + "theme": "主题", "language": "语言", "commandHint": "命令面板 (Ctrl/⌘+K)", + "tenant": "租户", "role": "角色" + }, + "palette": { + "placeholder": "跳转或执行操作…", "navGroup": "导航", + "actionGroup": "操作", "createTask": "新建任务", "openTaskById": "按 ID 打开任务", + "openTaskPrompt": "输入任务 ID" + }, + "dashboard": { + "heading": "概览", "inProgress": "进行中", "completed": "已完成", + "failed": "失败", "total": "总计", "recent": "最近任务", + "quota": "配额", "trend": "近 24h 新建", "quotaBytes": "本月流量", + "quotaStorage": "存储", "quotaConcurrent": "并发任务" }, "tasks": { - "listHeading": "任务列表", - "empty": "暂无任务,使用 curl POST /api/v1/tasks 创建一个", - "columns": { - "id": "ID", - "repo": "仓库", - "revision": "Revision", - "status": "状态", - "createdAt": "创建时间", - "actions": "操作" - }, - "view": "查看", - "detailHeading": "任务详情", - "subtasksHeading": "子任务", - "polling": "实时刷新中…", - "completed": "已停止刷新(终态)", - "back": "返回列表", - "notFound": "任务不存在或已删除", - "subtaskColumns": { - "filename": "文件名", - "size": "大小", - "sha256": "SHA256", - "status": "状态" - } + "listHeading": "任务列表", "empty": "暂无任务,点击「新建任务」创建一个", + "create": "新建任务", "filterStatus": "状态筛选", "filterAll": "全部", + "search": "搜索仓库/ID", "cancel": "取消", "delete": "删除", + "cancelConfirm": "确认取消该任务?", "deleteConfirm": "确认删除该终态任务?", + "cancelled": "已请求取消", "deleted": "已删除", + "columns": { "id": "ID", "repo": "仓库", "revision": "Revision", + "status": "状态", "createdAt": "创建时间", "actions": "操作" }, + "view": "查看", "detailHeading": "任务详情", "subtasksHeading": "子任务", + "polling": "实时刷新中…", "completed": "已停止刷新(终态)", + "back": "返回列表", "notFound": "任务不存在或已删除", + "subtaskColumns": { "filename": "文件名", "size": "大小", + "sha256": "SHA256", "status": "状态" } + }, + "create": { + "heading": "新建下载任务", "repo": "仓库 (org/model)", + "revision": "Revision (40 位 hex sha)", "storageId": "存储后端 ID", + "priority": "优先级", "strategy": "源策略", "upgradeFrom": "增量基线 revision", + "trustNonHf": "信任非 HF 源 sha256", "submit": "创建任务", + "repoRequired": "请输入仓库", "repoPattern": "格式应为 org/model", + "revRequired": "请输入 revision", "revPattern": "应为 40 位十六进制 sha", + "storageRequired": "请输入存储后端 ID", + "serviceTokenWarn": "当前是 system-admin 服务 token(user_id=0),无法创建任务(owner FK)。请用租户用户 JWT 登录后再创建。", + "success": "任务已创建" }, "status": { - "pending": "排队中", - "queued": "排队中", - "scheduling": "调度中", - "downloading": "下载中", - "succeeded": "成功", - "failed": "失败", - "cancelled": "已取消", - "assigned": "已分派", - "in_progress": "下载中" + "pending": "排队中", "queued": "排队中", "scheduling": "调度中", + "downloading": "下载中", "succeeded": "成功", "failed": "失败", + "cancelled": "已取消", "assigned": "已分派", "in_progress": "下载中" }, "errors": { "invalid_token": "Token 无效或已失效,请重新登录", "service_unavailable": "服务暂不可用,正在重试…", - "network": "网络错误,请检查连接" + "network": "网络错误,请检查连接", + "quota_exceeded": "配额已超限", + "conflict": "状态冲突(任务可能非终态或重复)", + "validation": "请求参数有误", + "forbidden": "无权限访问" } } diff --git a/frontend/tests/unit/locale.spec.ts b/frontend/tests/unit/locale.spec.ts new file mode 100644 index 0000000..d5f1ed4 --- /dev/null +++ b/frontend/tests/unit/locale.spec.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'vitest' +import zh from '@/locale/zh-CN.json' +import en from '@/locale/en-US.json' + +function keys(o: unknown, p = ''): string[] { + if (o && typeof o === 'object' && !Array.isArray(o)) { + return Object.entries(o as Record) + .flatMap(([k, v]) => keys(v, p ? `${p}.${k}` : k)) + } + return [p] +} + +describe('locale parity', () => { + test('en-US and zh-CN have identical key sets', () => { + expect(keys(en).sort()).toEqual(keys(zh).sort()) + }) + test('new keys present', () => { + for (const k of ['nav.dashboard', 'nav.tasks', 'tasks.create', + 'tasks.filterStatus', 'create.heading', 'create.serviceTokenWarn', + 'shell.theme', 'shell.language', 'palette.placeholder', + 'dashboard.heading', 'errors.quota_exceeded']) + expect(keys(zh)).toContain(k) + }) +}) From 64aaff36f0da505331eec6563b4284db7c4dffbb Mon Sep 17 00:00:00 2001 From: l17728 Date: Tue, 19 May 2026 22:59:32 +0800 Subject: [PATCH 05/20] feat(ui-sp1): design tokens + dark css-vars --- frontend/src/styles/main.scss | 8 +++++--- frontend/src/styles/tokens.scss | 23 +++++++++++++++++++++++ frontend/tests/unit/tokens.spec.ts | 22 ++++++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 frontend/src/styles/tokens.scss create mode 100644 frontend/tests/unit/tokens.spec.ts diff --git a/frontend/src/styles/main.scss b/frontend/src/styles/main.scss index 10cffdd..b402d41 100644 --- a/frontend/src/styles/main.scss +++ b/frontend/src/styles/main.scss @@ -1,3 +1,5 @@ +@use './tokens'; + *, *::before, *::after { @@ -14,8 +16,8 @@ body, } body { - background-color: #f5f7fa; - color: #303133; + background-color: var(--dlw-bg); + color: var(--dlw-text); } a { @@ -24,7 +26,7 @@ a { } .page-container { - padding: 24px; + padding: var(--dlw-space-4); max-width: 1280px; margin: 0 auto; } diff --git a/frontend/src/styles/tokens.scss b/frontend/src/styles/tokens.scss new file mode 100644 index 0000000..82d9a6f --- /dev/null +++ b/frontend/src/styles/tokens.scss @@ -0,0 +1,23 @@ +:root { + --dlw-space-1: 4px; --dlw-space-2: 8px; --dlw-space-3: 16px; + --dlw-space-4: 24px; --dlw-space-5: 32px; + --dlw-radius: 8px; + --dlw-bg: #f5f7fa; --dlw-surface: #ffffff; + --dlw-text: #303133; --dlw-text-soft: #909399; + --dlw-border: #ebeef5; + --dlw-status-pending: #909399; --dlw-status-queued: #909399; + --dlw-status-scheduling: #e6a23c; --dlw-status-downloading: #409eff; + --dlw-status-assigned: #409eff; --dlw-status-in_progress: #409eff; + --dlw-status-succeeded: #67c23a; --dlw-status-failed: #f56c6c; + --dlw-status-cancelled: #909399; +} +:root.dark { + --dlw-bg: #141414; --dlw-surface: #1d1e1f; + --dlw-text: #e5eaf3; --dlw-text-soft: #8d9095; + --dlw-border: #363637; + --dlw-status-pending: #6b6d71; --dlw-status-queued: #6b6d71; + --dlw-status-scheduling: #b88230; --dlw-status-downloading: #409eff; + --dlw-status-assigned: #409eff; --dlw-status-in_progress: #409eff; + --dlw-status-succeeded: #529b2e; --dlw-status-failed: #c45656; + --dlw-status-cancelled: #6b6d71; +} diff --git a/frontend/tests/unit/tokens.spec.ts b/frontend/tests/unit/tokens.spec.ts new file mode 100644 index 0000000..8302422 --- /dev/null +++ b/frontend/tests/unit/tokens.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from 'vitest' +import { readFileSync } from 'node:fs' + +// vitest runs with cwd = frontend/ (CI working-directory + no vitest root +// override), so a cwd-relative path is robust. Reading via import.meta.url + +// fileURLToPath fails under vitest/happy-dom (import.meta.url is http://). +const css = readFileSync('src/styles/tokens.scss', 'utf-8') + +describe('design tokens', () => { + test('defines status color tokens for all 9 task statuses', () => { + for (const s of ['pending', 'queued', 'scheduling', 'downloading', + 'succeeded', 'failed', 'cancelled', 'assigned', 'in_progress']) + expect(css).toContain(`--dlw-status-${s}`) + }) + test('defines a dark theme block', () => { + expect(css).toMatch(/(:root\.dark|html\.dark|\.dark)\s*\{/) + }) + test('defines spacing + radius tokens', () => { + expect(css).toContain('--dlw-space-') + expect(css).toContain('--dlw-radius') + }) +}) From 3afa54d4b8240359ff0ae95542d762f86f4bc8b4 Mon Sep 17 00:00:00 2001 From: l17728 Date: Tue, 19 May 2026 23:01:49 +0800 Subject: [PATCH 06/20] feat(ui-sp1): shared i18n + ui store (theme/sidebar/locale) --- frontend/src/i18n.ts | 16 +++++++++++++ frontend/src/main.ts | 26 ++++++++------------ frontend/src/stores/ui.ts | 42 +++++++++++++++++++++++++++++++++ frontend/tests/unit/ui.spec.ts | 43 ++++++++++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 16 deletions(-) create mode 100644 frontend/src/i18n.ts create mode 100644 frontend/src/stores/ui.ts create mode 100644 frontend/tests/unit/ui.spec.ts diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts new file mode 100644 index 0000000..5dee538 --- /dev/null +++ b/frontend/src/i18n.ts @@ -0,0 +1,16 @@ +import { createI18n } from 'vue-i18n' +import zhCN from './locale/zh-CN.json' +import enUS from './locale/en-US.json' + +export type LocaleCode = 'zh-CN' | 'en-US' + +export const i18n = createI18n({ + legacy: false, + locale: (localStorage.getItem('dlw_locale') as LocaleCode) ?? 'zh-CN', + fallbackLocale: 'zh-CN', + messages: { 'zh-CN': zhCN, 'en-US': enUS }, +}) + +export function setI18nLocale(l: LocaleCode): void { + i18n.global.locale.value = l +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 54b2131..6d2f883 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -4,34 +4,28 @@ import './styles/main.scss' import { createApp } from 'vue' import { createPinia } from 'pinia' import { VueQueryPlugin } from '@tanstack/vue-query' -import { createI18n } from 'vue-i18n' import ElementPlus from 'element-plus' import App from './App.vue' import router from './router' -import zhCN from './locale/zh-CN.json' - -const i18n = createI18n({ - legacy: false, - locale: 'zh-CN', - fallbackLocale: 'zh-CN', - messages: { 'zh-CN': zhCN }, -}) +import { i18n } from './i18n' +import { useUiStore } from './stores/ui' const app = createApp(App) -app.use(createPinia()) +const pinia = createPinia() +app.use(pinia) app.use(router) app.use(i18n) app.use(ElementPlus) app.use(VueQueryPlugin, { queryClientConfig: { defaultOptions: { - queries: { - retry: 3, - refetchOnWindowFocus: true, - staleTime: 5_000, - }, + queries: { retry: 3, refetchOnWindowFocus: true, staleTime: 5_000 }, }, }, }) -app.mount('#app') +useUiStore().hydrate() +// Pre-review fix B-I2: mount AFTER the initial route resolves so App.vue's +// route.name is defined on first render — otherwise an authenticated user +// briefly sees the bare (login) layout flash. +router.isReady().then(() => app.mount('#app')) diff --git a/frontend/src/stores/ui.ts b/frontend/src/stores/ui.ts new file mode 100644 index 0000000..d6a6aab --- /dev/null +++ b/frontend/src/stores/ui.ts @@ -0,0 +1,42 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' +import { setI18nLocale, type LocaleCode } from '@/i18n' + +type Theme = 'light' | 'dark' + +export const useUiStore = defineStore('ui', () => { + const theme = ref( + (localStorage.getItem('dlw_theme') as Theme) ?? + (window.matchMedia?.('(prefers-color-scheme: dark)').matches + ? 'dark' : 'light'), + ) + const sidebarCollapsed = ref(localStorage.getItem('dlw_sidebar') === 'true') + const locale = ref( + (localStorage.getItem('dlw_locale') as LocaleCode) ?? 'zh-CN', + ) + + function applyTheme(): void { + document.documentElement.classList.toggle('dark', theme.value === 'dark') + } + function toggleTheme(): void { + theme.value = theme.value === 'dark' ? 'light' : 'dark' + localStorage.setItem('dlw_theme', theme.value) + applyTheme() + } + function toggleSidebar(): void { + sidebarCollapsed.value = !sidebarCollapsed.value + localStorage.setItem('dlw_sidebar', String(sidebarCollapsed.value)) + } + function setLocale(l: LocaleCode): void { + locale.value = l + localStorage.setItem('dlw_locale', l) + setI18nLocale(l) + } + function hydrate(): void { + applyTheme() + setI18nLocale(locale.value) + } + + return { theme, sidebarCollapsed, locale, + toggleTheme, toggleSidebar, setLocale, hydrate } +}) diff --git a/frontend/tests/unit/ui.spec.ts b/frontend/tests/unit/ui.spec.ts new file mode 100644 index 0000000..20e3551 --- /dev/null +++ b/frontend/tests/unit/ui.spec.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' + +// vi.mock is hoisted — its factory must not reference an outer const +// (TDZ). Use vi.hoisted so the mock fn is created in the hoisted scope. +const { setLocaleMock } = vi.hoisted(() => ({ setLocaleMock: vi.fn() })) +vi.mock('@/i18n', () => ({ setI18nLocale: setLocaleMock })) + +import { useUiStore } from '@/stores/ui' + +describe('ui store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + document.documentElement.classList.remove('dark') + setLocaleMock.mockClear() + }) + + test('toggleTheme flips + persists + sets html.dark', () => { + const ui = useUiStore() + expect(ui.theme).toBe('light') + ui.toggleTheme() + expect(ui.theme).toBe('dark') + expect(document.documentElement.classList.contains('dark')).toBe(true) + expect(localStorage.getItem('dlw_theme')).toBe('dark') + }) + + test('setLocale persists + calls i18n setter', () => { + const ui = useUiStore() + ui.setLocale('en-US') + expect(ui.locale).toBe('en-US') + expect(localStorage.getItem('dlw_locale')).toBe('en-US') + expect(setLocaleMock).toHaveBeenCalledWith('en-US') + }) + + test('toggleSidebar persists', () => { + const ui = useUiStore() + const before = ui.sidebarCollapsed + ui.toggleSidebar() + expect(ui.sidebarCollapsed).toBe(!before) + expect(localStorage.getItem('dlw_sidebar')).toBe(String(!before)) + }) +}) From 6ae0a0948138fd762f42fedfb8da8b11be1b989d Mon Sep 17 00:00:00 2001 From: l17728 Date: Tue, 19 May 2026 23:03:11 +0800 Subject: [PATCH 07/20] feat(ui-sp1): session store (JWT principal + isServiceToken) --- frontend/src/api/types.ts | 29 +++++++++++++++++++ frontend/src/stores/session.ts | 43 +++++++++++++++++++++++++++++ frontend/tests/unit/session.spec.ts | 33 ++++++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 frontend/src/stores/session.ts create mode 100644 frontend/tests/unit/session.spec.ts diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index b4be69d..8e9e900 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -44,3 +44,32 @@ export const TERMINAL_STATUSES: ReadonlySet = new Set([ 'failed', 'cancelled', ]) + +export interface Principal { + userId: number + tenantId: number + role: string + projectIds: number[] + isServiceToken: boolean +} + +export interface QuotaCurrent { + tenant_id: number + bytes_used_month: number + bytes_quota_month: number + storage_gb_used: number + storage_gb_quota: number + concurrent_tasks: number + concurrent_quota: number +} + +export interface TaskCreateBody { + repo_id: string + revision: string + storage_id: number + priority?: number + source_strategy?: string + source_blacklist?: string[] + trust_non_hf_sha256?: boolean + upgrade_from_revision?: string +} diff --git a/frontend/src/stores/session.ts b/frontend/src/stores/session.ts new file mode 100644 index 0000000..c2a3041 --- /dev/null +++ b/frontend/src/stores/session.ts @@ -0,0 +1,43 @@ +import { computed } from 'vue' +import { defineStore } from 'pinia' +import { useAuthStore } from '@/stores/auth' +import type { Principal } from '@/api/types' + +export function decodePrincipal(token: string | null): Principal | null { + if (!token) return null + const parts = token.split('.') + if (parts.length !== 3) return null + const payload = parts[1] // bind (noUncheckedIndexedAccess) + if (!payload) return null + try { + const raw = payload.replace(/-/g, '+').replace(/_/g, '/') + // JWT base64url is unpadded — re-pad before atob (atob is + // length-dependent / throws on `len % 4 === 1` without padding). + const b64 = raw + '='.repeat((4 - (raw.length % 4)) % 4) + const json = decodeURIComponent( + atob(b64).split('').map( + (c) => '%' + c.charCodeAt(0).toString(16).padStart(2, '0')).join(''), + ) + const c = JSON.parse(json) as Record + const userId = Number(c.sub) + const role = String(c.role ?? '') + if (!Number.isFinite(userId)) return null + return { + userId, + tenantId: Number(c.tid ?? 0), + role, + projectIds: Array.isArray(c.pids) ? (c.pids as number[]) : [], + isServiceToken: userId === 0 || role === 'system_admin', + } + } catch { + return null + } +} + +export const useSessionStore = defineStore('session', () => { + const auth = useAuthStore() + const principal = computed(() => decodePrincipal(auth.accessToken)) + const role = computed(() => principal.value?.role ?? 'guest') + const isServiceToken = computed(() => principal.value?.isServiceToken ?? false) + return { principal, role, isServiceToken } +}) diff --git a/frontend/tests/unit/session.spec.ts b/frontend/tests/unit/session.spec.ts new file mode 100644 index 0000000..fc1e014 --- /dev/null +++ b/frontend/tests/unit/session.spec.ts @@ -0,0 +1,33 @@ +import { beforeEach, describe, expect, test } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { decodePrincipal } from '@/stores/session' + +// JWT = header..sig ; only payload matters. tok() strips +// padding (like real JWTs) — exercises decodePrincipal's re-pad path. +function tok(payload: Record): string { + const b64 = (o: unknown) => + btoa(JSON.stringify(o)).replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_') + return `${b64({ alg: 'HS256' })}.${b64(payload)}.sig` +} + +describe('decodePrincipal', () => { + beforeEach(() => setActivePinia(createPinia())) + test('tenant user', () => { + const p = decodePrincipal(tok({ sub: '1', tid: 1, role: 'tenant_admin', pids: [] })) + expect(p).toEqual({ userId: 1, tenantId: 1, role: 'tenant_admin', + projectIds: [], isServiceToken: false }) + }) + test('service token (sub=0) → isServiceToken', () => { + const p = decodePrincipal(tok({ sub: '0', tid: 1, role: 'system_admin', pids: [] })) + expect(p?.isServiceToken).toBe(true) + }) + test('role system_admin → isServiceToken even if sub != 0', () => { + const p = decodePrincipal(tok({ sub: '5', tid: 1, role: 'system_admin', pids: [] })) + expect(p?.isServiceToken).toBe(true) + }) + test('null / malformed → null', () => { + expect(decodePrincipal(null)).toBeNull() + expect(decodePrincipal('garbage')).toBeNull() + expect(decodePrincipal('a.notbase64!.c')).toBeNull() + }) +}) From 3005ca9b5144bacea8156baa319157f83ef40fc2 Mon Sep 17 00:00:00 2001 From: l17728 Date: Tue, 19 May 2026 23:05:33 +0800 Subject: [PATCH 08/20] feat(ui-sp1): useLiveResource seam + refactor task composables --- frontend/src/composables/useLiveResource.ts | 48 +++++++++++++++++++++ frontend/src/composables/useTaskDetail.ts | 34 +++------------ frontend/src/composables/useTaskList.ts | 17 +++----- frontend/tests/unit/useLiveResource.spec.ts | 21 +++++++++ frontend/tests/unit/useTaskDetail.spec.ts | 45 ++++--------------- 5 files changed, 89 insertions(+), 76 deletions(-) create mode 100644 frontend/src/composables/useLiveResource.ts create mode 100644 frontend/tests/unit/useLiveResource.spec.ts diff --git a/frontend/src/composables/useLiveResource.ts b/frontend/src/composables/useLiveResource.ts new file mode 100644 index 0000000..f3dec4d --- /dev/null +++ b/frontend/src/composables/useLiveResource.ts @@ -0,0 +1,48 @@ +import { useQuery, type QueryKey } from '@tanstack/vue-query' + +const ERROR_BACKOFF_MS = 5_000 +const HIDDEN_MULTIPLIER = 3 + +export function computeInterval(o: { + base: number; terminal: boolean; hidden: boolean; errored: boolean +}): number | false { + if (o.terminal) return false + if (o.errored) return ERROR_BACKOFF_MS + return o.hidden ? o.base * HIDDEN_MULTIPLIER : o.base +} + +export interface LiveOptions { + baseIntervalMs: number + isTerminal?: (data: T) => boolean + staleTime?: number +} + +/** + * Single realtime seam. Today: adaptive polling on vue-query. UI-SP5 swaps + * the internals to SSE/WS — consumers (views) never change. + * + * vue-query v5.59 does NOT accept a getter for `queryKey` — it must be a + * QueryKey (array). Reactivity comes from putting refs *inside* the array + * (v5 unwraps them), exactly like the scaffold's proven useTaskDetail. + */ +export function useLiveResource( + key: QueryKey, + fetcher: () => Promise, + opts: LiveOptions, +) { + return useQuery({ + queryKey: key, + queryFn: fetcher, + staleTime: opts.staleTime ?? 0, + refetchInterval: (query) => { + const data = query.state.data as T | undefined + const errored = query.state.status === 'error' + const terminal = data !== undefined && !!opts.isTerminal?.(data) + const hidden = + typeof document !== 'undefined' && document.visibilityState === 'hidden' + return computeInterval({ + base: opts.baseIntervalMs, terminal, hidden, errored, + }) + }, + }) +} diff --git a/frontend/src/composables/useTaskDetail.ts b/frontend/src/composables/useTaskDetail.ts index a1d0d23..24170d9 100644 --- a/frontend/src/composables/useTaskDetail.ts +++ b/frontend/src/composables/useTaskDetail.ts @@ -1,34 +1,12 @@ -import { useQuery } from '@tanstack/vue-query' import type { Ref } from 'vue' - +import { useLiveResource } from '@/composables/useLiveResource' import { client } from '@/api/client' import { TERMINAL_STATUSES, type TaskDetail } from '@/api/types' -const POLL_INTERVAL_MS = 1_000 -const ERROR_BACKOFF_MS = 5_000 - -type QueryStatus = 'pending' | 'error' | 'success' - -/** Pure helper — exported so tests don't need vue-query plumbing. */ -export function computeRefetchInterval( - data: TaskDetail | undefined, - status: QueryStatus, -): number | false { - if (!data) { - return status === 'error' ? ERROR_BACKOFF_MS : POLL_INTERVAL_MS - } - return TERMINAL_STATUSES.has(data.status) ? false : POLL_INTERVAL_MS -} - export function useTaskDetail(taskId: Ref) { - return useQuery({ - queryKey: ['task', taskId], - queryFn: async () => { - const r = await client.get(`/api/v1/tasks/${taskId.value}`) - return r.data - }, - refetchInterval: (query) => - computeRefetchInterval(query.state.data, query.state.status as QueryStatus), - staleTime: 0, - }) + return useLiveResource( + ['task', taskId], + async () => (await client.get(`/api/v1/tasks/${taskId.value}`)).data, + { baseIntervalMs: 1_000, isTerminal: (d) => TERMINAL_STATUSES.has(d.status) }, + ) } diff --git a/frontend/src/composables/useTaskList.ts b/frontend/src/composables/useTaskList.ts index d4cc133..5b537fd 100644 --- a/frontend/src/composables/useTaskList.ts +++ b/frontend/src/composables/useTaskList.ts @@ -1,16 +1,11 @@ -import { useQuery } from '@tanstack/vue-query' - +import { useLiveResource } from '@/composables/useLiveResource' import { client } from '@/api/client' import type { TaskListResponse } from '@/api/types' export function useTaskList() { - return useQuery({ - queryKey: ['tasks'], - queryFn: async () => { - const r = await client.get('/api/v1/tasks') - return r.data - }, - refetchInterval: 5_000, - staleTime: 5_000, - }) + return useLiveResource( + ['tasks'], + async () => (await client.get('/api/v1/tasks')).data, + { baseIntervalMs: 5_000, staleTime: 5_000 }, + ) } diff --git a/frontend/tests/unit/useLiveResource.spec.ts b/frontend/tests/unit/useLiveResource.spec.ts new file mode 100644 index 0000000..b6841f0 --- /dev/null +++ b/frontend/tests/unit/useLiveResource.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'vitest' +import { computeInterval } from '@/composables/useLiveResource' + +describe('computeInterval', () => { + const base = 2000 + test('active + visible → base', () => { + expect(computeInterval({ base, terminal: false, hidden: false, errored: false })).toBe(2000) + }) + test('terminal → false (stop)', () => { + expect(computeInterval({ base, terminal: true, hidden: false, errored: false })).toBe(false) + }) + test('hidden → base × 3', () => { + expect(computeInterval({ base, terminal: false, hidden: true, errored: false })).toBe(6000) + }) + test('errored (no data) → 5000 backoff', () => { + expect(computeInterval({ base, terminal: false, hidden: false, errored: true })).toBe(5000) + }) + test('terminal beats hidden/errored', () => { + expect(computeInterval({ base, terminal: true, hidden: true, errored: true })).toBe(false) + }) +}) diff --git a/frontend/tests/unit/useTaskDetail.spec.ts b/frontend/tests/unit/useTaskDetail.spec.ts index 9f0d4db..55a35c3 100644 --- a/frontend/tests/unit/useTaskDetail.spec.ts +++ b/frontend/tests/unit/useTaskDetail.spec.ts @@ -1,43 +1,14 @@ import { describe, expect, test } from 'vitest' +import { computeInterval } from '@/composables/useLiveResource' -import { computeRefetchInterval } from '@/composables/useTaskDetail' -import type { TaskDetail } from '@/api/types' - -function fakeTask(status: TaskDetail['status']): TaskDetail { - return { - id: 'x', - repo_id: 'o/r', - revision: 'a', - status, - priority: 1, - created_at: '2026-01-01T00:00:00Z', - completed_at: null, - error_message: null, - subtasks: [], - } -} - -describe('computeRefetchInterval', () => { - test('non-terminal status → 1000ms', () => { - expect(computeRefetchInterval(fakeTask('downloading'), 'success')).toBe(1000) - expect(computeRefetchInterval(fakeTask('queued'), 'success')).toBe(1000) - expect(computeRefetchInterval(fakeTask('scheduling'), 'success')).toBe(1000) - expect(computeRefetchInterval(fakeTask('pending'), 'success')).toBe(1000) - }) - - test('terminal status → false (stop polling)', () => { - expect(computeRefetchInterval(fakeTask('succeeded'), 'success')).toBe(false) - expect(computeRefetchInterval(fakeTask('failed'), 'success')).toBe(false) - expect(computeRefetchInterval(fakeTask('cancelled'), 'success')).toBe(false) +describe('task-detail polling via computeInterval', () => { + test('non-terminal → 1000', () => { + expect(computeInterval({ base: 1000, terminal: false, hidden: false, errored: false })).toBe(1000) }) - - test('undefined data + pending status (first fetch in flight) → 1000ms', () => { - expect(computeRefetchInterval(undefined, 'pending')).toBe(1000) + test('terminal → false', () => { + expect(computeInterval({ base: 1000, terminal: true, hidden: false, errored: false })).toBe(false) }) - - test('undefined data + error status → 5000ms backoff', () => { - // 5xx / network error during initial fetch — back off so we don't hammer - // a struggling backend at 1 req/sec. - expect(computeRefetchInterval(undefined, 'error')).toBe(5000) + test('errored first fetch → 5000', () => { + expect(computeInterval({ base: 1000, terminal: false, hidden: false, errored: true })).toBe(5000) }) }) From bd9c47c096714c53896169bb9ba5917d7e4ccd54 Mon Sep 17 00:00:00 2001 From: l17728 Date: Tue, 19 May 2026 23:07:19 +0800 Subject: [PATCH 09/20] feat(ui-sp1): DataBoundary state wrapper --- frontend/src/components/DataBoundary.vue | 40 ++++++++++++++++++++++++ frontend/tests/unit/DataBoundary.spec.ts | 38 ++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 frontend/src/components/DataBoundary.vue create mode 100644 frontend/tests/unit/DataBoundary.spec.ts diff --git a/frontend/src/components/DataBoundary.vue b/frontend/src/components/DataBoundary.vue new file mode 100644 index 0000000..c0c6edd --- /dev/null +++ b/frontend/src/components/DataBoundary.vue @@ -0,0 +1,40 @@ + + + diff --git a/frontend/tests/unit/DataBoundary.spec.ts b/frontend/tests/unit/DataBoundary.spec.ts new file mode 100644 index 0000000..813450e --- /dev/null +++ b/frontend/tests/unit/DataBoundary.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'vitest' +import { mount } from '@vue/test-utils' +import ElementPlus from 'element-plus' +import { createI18n } from 'vue-i18n' +import DataBoundary from '@/components/DataBoundary.vue' +import zh from '@/locale/zh-CN.json' + +const i18n = createI18n({ legacy: false, locale: 'zh-CN', messages: { 'zh-CN': zh } }) +const mountB = (props: Record) => + mount(DataBoundary, { + props, slots: { default: '
DATA
' }, + global: { plugins: [ElementPlus, i18n] }, + }) + +describe('DataBoundary', () => { + test('loading → skeleton, no content', () => { + const w = mountB({ loading: true }) + expect(w.findComponent({ name: 'ElSkeleton' }).exists()).toBe(true) + expect(w.find('.content').exists()).toBe(false) + }) + test('forbidden → forbidden message', () => { + const w = mountB({ loading: false, forbidden: true }) + expect(w.text()).toContain(zh.errors.forbidden) + expect(w.find('.content').exists()).toBe(false) + }) + test('error → alert', () => { + const w = mountB({ loading: false, error: true }) + expect(w.findComponent({ name: 'ElAlert' }).exists()).toBe(true) + }) + test('empty → EmptyState', () => { + const w = mountB({ loading: false, isEmpty: true }) + expect(w.findComponent({ name: 'EmptyState' }).exists()).toBe(true) + }) + test('ok → renders default slot', () => { + const w = mountB({ loading: false }) + expect(w.find('.content').text()).toBe('DATA') + }) +}) From 576d93f9886c3d724dadc1de3155cd29dc377d62 Mon Sep 17 00:00:00 2001 From: l17728 Date: Tue, 19 May 2026 23:08:32 +0800 Subject: [PATCH 10/20] feat(ui-sp1): nav registry + role filtering --- frontend/src/nav/registry.ts | 16 ++++++++++++++++ frontend/tests/unit/nav.spec.ts | 23 +++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 frontend/src/nav/registry.ts create mode 100644 frontend/tests/unit/nav.spec.ts diff --git a/frontend/src/nav/registry.ts b/frontend/src/nav/registry.ts new file mode 100644 index 0000000..4d604c3 --- /dev/null +++ b/frontend/src/nav/registry.ts @@ -0,0 +1,16 @@ +export interface NavItem { + route: string // route name + labelKey: string // i18n key under nav.* + icon: string // element-plus icon component name + roles?: string[] // if set, visible only to these roles +} + +export const NAV_ITEMS: NavItem[] = [ + { route: 'dashboard', labelKey: 'nav.dashboard', icon: 'Odometer' }, + { route: 'taskList', labelKey: 'nav.tasks', icon: 'List' }, + { route: 'taskCreate', labelKey: 'nav.createTask', icon: 'Plus' }, +] + +export function visibleNav(role: string, items: NavItem[] = NAV_ITEMS): NavItem[] { + return items.filter((i) => !i.roles || i.roles.includes(role)) +} diff --git a/frontend/tests/unit/nav.spec.ts b/frontend/tests/unit/nav.spec.ts new file mode 100644 index 0000000..03b3320 --- /dev/null +++ b/frontend/tests/unit/nav.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'vitest' +import { NAV_ITEMS, visibleNav } from '@/nav/registry' + +describe('nav registry', () => { + test('all items have route + labelKey', () => { + for (const i of NAV_ITEMS) { + expect(i.route).toBeTruthy() + expect(i.labelKey).toMatch(/^nav\./) + } + }) + test('visibleNav: no roles → visible to everyone', () => { + const names = visibleNav('guest').map((i) => i.route) + expect(names).toContain('taskList') + expect(names).toContain('dashboard') + }) + test('role-gated item hidden for wrong role', () => { + const gated = { route: 'x', labelKey: 'nav.x', icon: 'i', roles: ['system_admin'] } + expect(visibleNav('tenant_admin', [...NAV_ITEMS, gated]).map((i) => i.route)) + .not.toContain('x') + expect(visibleNav('system_admin', [...NAV_ITEMS, gated]).map((i) => i.route)) + .toContain('x') + }) +}) From 73698ed52a1338fb5d49022640a82aeddbecc429 Mon Sep 17 00:00:00 2001 From: l17728 Date: Tue, 19 May 2026 23:10:23 +0800 Subject: [PATCH 11/20] feat(ui-sp1): AppShell (sidebar+topbar) + conditional layout --- frontend/src/App.vue | 22 +++- frontend/src/components/CommandPalette.vue | 2 + frontend/src/components/shell/AppShell.vue | 123 +++++++++++++++++++++ frontend/tests/unit/AppShell.spec.ts | 42 +++++++ 4 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/CommandPalette.vue create mode 100644 frontend/src/components/shell/AppShell.vue create mode 100644 frontend/tests/unit/AppShell.spec.ts diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 8708d92..65bd04d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,9 +1,23 @@ diff --git a/frontend/src/components/CommandPalette.vue b/frontend/src/components/CommandPalette.vue new file mode 100644 index 0000000..04cb7a8 --- /dev/null +++ b/frontend/src/components/CommandPalette.vue @@ -0,0 +1,2 @@ + + diff --git a/frontend/src/components/shell/AppShell.vue b/frontend/src/components/shell/AppShell.vue new file mode 100644 index 0000000..70d7924 --- /dev/null +++ b/frontend/src/components/shell/AppShell.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/frontend/tests/unit/AppShell.spec.ts b/frontend/tests/unit/AppShell.spec.ts new file mode 100644 index 0000000..a010404 --- /dev/null +++ b/frontend/tests/unit/AppShell.spec.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import ElementPlus from 'element-plus' +import { createI18n } from 'vue-i18n' +import AppShell from '@/components/shell/AppShell.vue' +import zh from '@/locale/zh-CN.json' +import { useAuthStore } from '@/stores/auth' + +const i18n = createI18n({ legacy: false, locale: 'zh-CN', messages: { 'zh-CN': zh } }) +const push = vi.fn() +vi.mock('vue-router', () => ({ + useRouter: () => ({ push }), + useRoute: () => ({ name: 'taskList' }), + RouterView: { template: '
' }, +})) + +function mountShell() { + return mount(AppShell, { global: { plugins: [ElementPlus, i18n] } }) +} + +describe('AppShell', () => { + beforeEach(() => { setActivePinia(createPinia()); push.mockClear() }) + + test('authenticated → renders nav items', () => { + useAuthStore().login('h.' + btoa(JSON.stringify( + { sub: '1', tid: 1, role: 'tenant_admin', pids: [] })) + '.s') + const w = mountShell() + expect(w.text()).toContain(zh.nav.tasks) + expect(w.text()).toContain(zh.nav.dashboard) + }) + + test('logout calls auth.logout + redirects', async () => { + const auth = useAuthStore() + auth.login('h.' + btoa(JSON.stringify( + { sub: '1', tid: 1, role: 'tenant_admin', pids: [] })) + '.s') + const w = mountShell() + await w.find('[data-test=logout]').trigger('click') + expect(auth.isAuthenticated).toBe(false) + expect(push).toHaveBeenCalledWith('/login') + }) +}) From 6d054f2003a4f7fc84e48c0b0e794aff33520f2e Mon Sep 17 00:00:00 2001 From: l17728 Date: Tue, 19 May 2026 23:11:42 +0800 Subject: [PATCH 12/20] feat(ui-sp1): command palette (Ctrl/Cmd+K) --- frontend/src/components/CommandPalette.vue | 82 +++++++++++++++++++++- frontend/src/components/palette.ts | 22 ++++++ frontend/tests/unit/palette.spec.ts | 18 +++++ 3 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/palette.ts create mode 100644 frontend/tests/unit/palette.spec.ts diff --git a/frontend/src/components/CommandPalette.vue b/frontend/src/components/CommandPalette.vue index 04cb7a8..9b0108d 100644 --- a/frontend/src/components/CommandPalette.vue +++ b/frontend/src/components/CommandPalette.vue @@ -1,2 +1,80 @@ - - + + + + + diff --git a/frontend/src/components/palette.ts b/frontend/src/components/palette.ts new file mode 100644 index 0000000..bd5024e --- /dev/null +++ b/frontend/src/components/palette.ts @@ -0,0 +1,22 @@ +import { visibleNav } from '@/nav/registry' + +export interface Command { + id: string + label: string + kind: 'nav' | 'action' + routeName?: string + action?: 'createTask' | 'openTaskById' +} + +export function buildCommands(role: string, t: (k: string) => string): Command[] { + const nav: Command[] = visibleNav(role).map((i) => ({ + id: `nav:${i.route}`, label: t(i.labelKey), kind: 'nav', routeName: i.route, + })) + const actions: Command[] = [ + { id: 'action:createTask', label: t('palette.createTask'), + kind: 'action', action: 'createTask' }, + { id: 'action:openTaskById', label: t('palette.openTaskById'), + kind: 'action', action: 'openTaskById' }, + ] + return [...nav, ...actions] +} diff --git a/frontend/tests/unit/palette.spec.ts b/frontend/tests/unit/palette.spec.ts new file mode 100644 index 0000000..42658d6 --- /dev/null +++ b/frontend/tests/unit/palette.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from 'vitest' +import { buildCommands } from '@/components/palette' + +describe('buildCommands', () => { + const t = (k: string) => k + test('includes nav items + create + open-by-id', () => { + const cmds = buildCommands('tenant_admin', t) + const ids = cmds.map((c) => c.id) + expect(ids).toContain('nav:dashboard') + expect(ids).toContain('nav:taskList') + expect(ids).toContain('action:createTask') + expect(ids).toContain('action:openTaskById') + }) + test('role-gates nav', () => { + const cmds = buildCommands('guest', t) + expect(cmds.find((c) => c.id === 'nav:dashboard')).toBeTruthy() + }) +}) From 82c7f13aa111638a20dad2a892bbe07e3f0d1569 Mon Sep 17 00:00:00 2001 From: l17728 Date: Tue, 19 May 2026 23:13:42 +0800 Subject: [PATCH 13/20] feat(ui-sp1): routes for dashboard/tasks/create (+ placeholders) --- frontend/src/pages/Dashboard.vue | 2 ++ frontend/src/pages/TaskCreate.vue | 2 ++ frontend/src/router/index.ts | 28 ++++++++++++++-------------- frontend/tests/unit/router.spec.ts | 14 ++++++++++++++ 4 files changed, 32 insertions(+), 14 deletions(-) create mode 100644 frontend/src/pages/Dashboard.vue create mode 100644 frontend/src/pages/TaskCreate.vue create mode 100644 frontend/tests/unit/router.spec.ts diff --git a/frontend/src/pages/Dashboard.vue b/frontend/src/pages/Dashboard.vue new file mode 100644 index 0000000..9740eaf --- /dev/null +++ b/frontend/src/pages/Dashboard.vue @@ -0,0 +1,2 @@ + + diff --git a/frontend/src/pages/TaskCreate.vue b/frontend/src/pages/TaskCreate.vue new file mode 100644 index 0000000..9740eaf --- /dev/null +++ b/frontend/src/pages/TaskCreate.vue @@ -0,0 +1,2 @@ + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 4973869..8fa9774 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,38 +1,38 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' import { useAuthStore } from '@/stores/auth' -const routes: RouteRecordRaw[] = [ +export const routes: RouteRecordRaw[] = [ { - path: '/login', - name: 'login', + path: '/login', name: 'login', component: () => import('@/pages/Login.vue'), meta: { public: true }, }, { - path: '/', - name: 'taskList', + path: '/', name: 'dashboard', + component: () => import('@/pages/Dashboard.vue'), + }, + { + path: '/tasks', name: 'taskList', component: () => import('@/pages/TaskList.vue'), }, { - path: '/tasks/:id', - name: 'taskDetail', + path: '/tasks/new', name: 'taskCreate', + component: () => import('@/pages/TaskCreate.vue'), + }, + { + path: '/tasks/:id', name: 'taskDetail', component: () => import('@/pages/TaskDetail.vue'), props: true, }, { path: '/:pathMatch(.*)*', redirect: '/' }, ] -const router = createRouter({ - history: createWebHistory(), - routes, -}) +const router = createRouter({ history: createWebHistory(), routes }) router.beforeEach((to) => { if (to.meta.public) return true const auth = useAuthStore() - if (!auth.isAuthenticated) { - return { path: '/login' } - } + if (!auth.isAuthenticated) return { path: '/login' } return true }) diff --git a/frontend/tests/unit/router.spec.ts b/frontend/tests/unit/router.spec.ts new file mode 100644 index 0000000..398bea7 --- /dev/null +++ b/frontend/tests/unit/router.spec.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from 'vitest' +import { routes } from '@/router' + +describe('routes', () => { + test('has dashboard / taskList / taskCreate / taskDetail / login', () => { + const byName = Object.fromEntries( + routes.filter((r) => r.name).map((r) => [r.name, r])) + expect(byName.dashboard?.path).toBe('/') + expect(byName.taskList?.path).toBe('/tasks') + expect(byName.taskCreate?.path).toBe('/tasks/new') + expect(byName.taskDetail?.path).toBe('/tasks/:id') + expect(byName.login?.meta?.public).toBe(true) + }) +}) From b4c28289483bdcbc41944efe620a6f6cadb45939 Mon Sep 17 00:00:00 2001 From: l17728 Date: Tue, 19 May 2026 23:16:14 +0800 Subject: [PATCH 14/20] feat(ui-sp1): Dashboard (KPIs + 24h sparkline + quota + recent) --- frontend/src/components/Sparkline.vue | 29 +++++++ frontend/src/composables/useQuota.ts | 11 +++ frontend/src/dashboard/aggregate.ts | 29 +++++++ frontend/src/pages/Dashboard.vue | 106 +++++++++++++++++++++++++- frontend/tests/unit/dashboard.spec.ts | 31 ++++++++ 5 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/Sparkline.vue create mode 100644 frontend/src/composables/useQuota.ts create mode 100644 frontend/src/dashboard/aggregate.ts create mode 100644 frontend/tests/unit/dashboard.spec.ts diff --git a/frontend/src/components/Sparkline.vue b/frontend/src/components/Sparkline.vue new file mode 100644 index 0000000..8d78ec6 --- /dev/null +++ b/frontend/src/components/Sparkline.vue @@ -0,0 +1,29 @@ + + + diff --git a/frontend/src/composables/useQuota.ts b/frontend/src/composables/useQuota.ts new file mode 100644 index 0000000..0c6358e --- /dev/null +++ b/frontend/src/composables/useQuota.ts @@ -0,0 +1,11 @@ +import { useLiveResource } from '@/composables/useLiveResource' +import { client } from '@/api/client' +import type { QuotaCurrent } from '@/api/types' + +export function useQuota() { + return useLiveResource( + ['quota'], + async () => (await client.get('/api/v1/quota/current')).data, + { baseIntervalMs: 30_000, staleTime: 30_000 }, + ) +} diff --git a/frontend/src/dashboard/aggregate.ts b/frontend/src/dashboard/aggregate.ts new file mode 100644 index 0000000..f9dbf32 --- /dev/null +++ b/frontend/src/dashboard/aggregate.ts @@ -0,0 +1,29 @@ +import type { TaskRead, TaskStatus } from '@/api/types' + +const IN_PROGRESS: ReadonlySet = new Set([ + 'pending', 'queued', 'scheduling', 'downloading', +]) + +export function aggregateKpis(tasks: TaskRead[]) { + let inProgress = 0, completed = 0, failed = 0 + for (const t of tasks) { + if (IN_PROGRESS.has(t.status)) inProgress++ + else if (t.status === 'succeeded') completed++ + else if (t.status === 'failed') failed++ + } + return { inProgress, completed, failed, total: tasks.length } +} + +export function bucket24h(tasks: TaskRead[], now: Date = new Date()): number[] { + const buckets = new Array(24).fill(0) + const end = now.getTime() + const start = end - 24 * 3600_000 + for (const t of tasks) { + const ts = new Date(t.created_at).getTime() + if (ts >= start && ts <= end) { + const idx = Math.min(23, Math.floor((ts - start) / 3600_000)) + buckets[idx] = (buckets[idx] ?? 0) + 1 // noUncheckedIndexedAccess + } + } + return buckets +} diff --git a/frontend/src/pages/Dashboard.vue b/frontend/src/pages/Dashboard.vue index 9740eaf..dca64fb 100644 --- a/frontend/src/pages/Dashboard.vue +++ b/frontend/src/pages/Dashboard.vue @@ -1,2 +1,104 @@ - - + + + + + diff --git a/frontend/tests/unit/dashboard.spec.ts b/frontend/tests/unit/dashboard.spec.ts new file mode 100644 index 0000000..55dac93 --- /dev/null +++ b/frontend/tests/unit/dashboard.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'vitest' +import { aggregateKpis, bucket24h } from '@/dashboard/aggregate' +import type { TaskRead } from '@/api/types' + +const mk = (status: TaskRead['status'], createdAt: string): TaskRead => ({ + id: Math.random().toString(36), repo_id: 'o/r', revision: 'a', + status, priority: 1, created_at: createdAt, completed_at: null, + error_message: null, +}) + +describe('dashboard aggregate', () => { + test('aggregateKpis counts by bucket', () => { + const k = aggregateKpis([ + mk('downloading', '2026-05-19T00:00:00Z'), + mk('scheduling', '2026-05-19T00:00:00Z'), + mk('succeeded', '2026-05-19T00:00:00Z'), + mk('failed', '2026-05-19T00:00:00Z'), + ]) + expect(k).toEqual({ inProgress: 2, completed: 1, failed: 1, total: 4 }) + }) + test('bucket24h returns 24 hourly counts within window', () => { + const now = new Date('2026-05-19T12:00:00Z') + const b = bucket24h([ + mk('succeeded', '2026-05-19T11:30:00Z'), + mk('succeeded', '2026-05-19T11:45:00Z'), + mk('succeeded', '2026-05-10T00:00:00Z'), + ], now) + expect(b).toHaveLength(24) + expect(b.reduce((a, c) => a + c, 0)).toBe(2) + }) +}) From 76e623c1820b26cb60671aa31fc7607a3dfc9594 Mon Sep 17 00:00:00 2001 From: l17728 Date: Tue, 19 May 2026 23:17:13 +0800 Subject: [PATCH 15/20] feat(ui-sp1): task cancel/delete mutations + guards --- frontend/src/composables/useTaskMutations.ts | 25 ++++++++++++++++++++ frontend/tests/unit/taskMutations.spec.ts | 18 ++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 frontend/src/composables/useTaskMutations.ts create mode 100644 frontend/tests/unit/taskMutations.spec.ts diff --git a/frontend/src/composables/useTaskMutations.ts b/frontend/src/composables/useTaskMutations.ts new file mode 100644 index 0000000..c873310 --- /dev/null +++ b/frontend/src/composables/useTaskMutations.ts @@ -0,0 +1,25 @@ +import { useMutation, useQueryClient } from '@tanstack/vue-query' +import { client } from '@/api/client' +import { TERMINAL_STATUSES, type TaskStatus } from '@/api/types' + +export function canCancel(status: TaskStatus): boolean { + return !TERMINAL_STATUSES.has(status) +} +export function canDelete(status: TaskStatus): boolean { + return TERMINAL_STATUSES.has(status) +} + +export function useTaskMutations() { + const qc = useQueryClient() + const invalidate = () => qc.invalidateQueries({ queryKey: ['tasks'] }) + + const cancel = useMutation({ + mutationFn: (id: string) => client.post(`/api/v1/tasks/${id}/cancel`, {}), + onSettled: invalidate, + }) + const remove = useMutation({ + mutationFn: (id: string) => client.delete(`/api/v1/tasks/${id}`), + onSettled: invalidate, + }) + return { cancel, remove } +} diff --git a/frontend/tests/unit/taskMutations.spec.ts b/frontend/tests/unit/taskMutations.spec.ts new file mode 100644 index 0000000..6b7785b --- /dev/null +++ b/frontend/tests/unit/taskMutations.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from 'vitest' +import { canCancel, canDelete } from '@/composables/useTaskMutations' + +describe('task action guards', () => { + test('canCancel: only non-terminal', () => { + expect(canCancel('downloading')).toBe(true) + expect(canCancel('pending')).toBe(true) + expect(canCancel('succeeded')).toBe(false) + expect(canCancel('failed')).toBe(false) + expect(canCancel('cancelled')).toBe(false) + }) + test('canDelete: only terminal', () => { + expect(canDelete('succeeded')).toBe(true) + expect(canDelete('failed')).toBe(true) + expect(canDelete('cancelled')).toBe(true) + expect(canDelete('downloading')).toBe(false) + }) +}) From b5decd818d4b14f18be9b99c79226b6b8ef8b165 Mon Sep 17 00:00:00 2001 From: l17728 Date: Tue, 19 May 2026 23:18:46 +0800 Subject: [PATCH 16/20] feat(ui-sp1): TaskList filter + cancel/delete actions --- frontend/src/pages/TaskList.vue | 228 +++++++++++++++---------- frontend/src/tasks/filter.ts | 13 ++ frontend/tests/unit/taskFilter.spec.ts | 28 +++ 3 files changed, 181 insertions(+), 88 deletions(-) create mode 100644 frontend/src/tasks/filter.ts create mode 100644 frontend/tests/unit/taskFilter.spec.ts diff --git a/frontend/src/pages/TaskList.vue b/frontend/src/pages/TaskList.vue index 9f13d8e..d804039 100644 --- a/frontend/src/pages/TaskList.vue +++ b/frontend/src/pages/TaskList.vue @@ -1,116 +1,168 @@ + + diff --git a/frontend/src/tasks/filter.ts b/frontend/src/tasks/filter.ts new file mode 100644 index 0000000..e114471 --- /dev/null +++ b/frontend/src/tasks/filter.ts @@ -0,0 +1,13 @@ +import type { TaskRead } from '@/api/types' + +export function filterTasks( + items: TaskRead[], f: { status: string; q: string }, +): TaskRead[] { + const q = f.q.trim().toLowerCase() + return items.filter((t) => { + if (f.status && t.status !== f.status) return false + if (q && !t.repo_id.toLowerCase().includes(q) && + !t.id.toLowerCase().includes(q)) return false + return true + }) +} diff --git a/frontend/tests/unit/taskFilter.spec.ts b/frontend/tests/unit/taskFilter.spec.ts new file mode 100644 index 0000000..6173fe5 --- /dev/null +++ b/frontend/tests/unit/taskFilter.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'vitest' +import { filterTasks } from '@/tasks/filter' +import type { TaskRead } from '@/api/types' + +const mk = (id: string, repo: string, status: TaskRead['status']): TaskRead => ({ + id, repo_id: repo, revision: 'abc', status, priority: 1, + created_at: '2026-05-19T00:00:00Z', completed_at: null, error_message: null, +}) +const items = [ + mk('aaaa1111', 'org/alpha', 'downloading'), + mk('bbbb2222', 'org/beta', 'succeeded'), +] + +describe('filterTasks', () => { + test('no filter → all', () => { + expect(filterTasks(items, { status: '', q: '' })).toHaveLength(2) + }) + test('status filter', () => { + expect(filterTasks(items, { status: 'succeeded', q: '' }).map((t) => t.id)) + .toEqual(['bbbb2222']) + }) + test('q matches repo or id (case-insensitive)', () => { + expect(filterTasks(items, { status: '', q: 'ALPHA' }).map((t) => t.id)) + .toEqual(['aaaa1111']) + expect(filterTasks(items, { status: '', q: 'bbbb' }).map((t) => t.id)) + .toEqual(['bbbb2222']) + }) +}) From f80b6141f3df6be508528b48f88e8f2837a63173 Mon Sep 17 00:00:00 2001 From: l17728 Date: Tue, 19 May 2026 23:20:46 +0800 Subject: [PATCH 17/20] feat(ui-sp1): TaskCreate form + service-token preflight guard --- frontend/src/pages/TaskCreate.vue | 121 ++++++++++++++++++- frontend/src/tasks/createValidation.ts | 22 ++++ frontend/tests/unit/TaskCreate.spec.ts | 42 +++++++ frontend/tests/unit/createValidation.spec.ts | 25 ++++ 4 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 frontend/src/tasks/createValidation.ts create mode 100644 frontend/tests/unit/TaskCreate.spec.ts create mode 100644 frontend/tests/unit/createValidation.spec.ts diff --git a/frontend/src/pages/TaskCreate.vue b/frontend/src/pages/TaskCreate.vue index 9740eaf..d83dc6f 100644 --- a/frontend/src/pages/TaskCreate.vue +++ b/frontend/src/pages/TaskCreate.vue @@ -1,2 +1,119 @@ - - + + + diff --git a/frontend/src/tasks/createValidation.ts b/frontend/src/tasks/createValidation.ts new file mode 100644 index 0000000..36d8d76 --- /dev/null +++ b/frontend/src/tasks/createValidation.ts @@ -0,0 +1,22 @@ +import type { TaskCreateBody } from '@/api/types' + +const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/ +const SHA_RE = /^[0-9a-f]{40}$/ + +export function validateCreate(b: Partial): string[] { + const e: string[] = [] + if (!b.repo_id) e.push('repoRequired') + else if (!REPO_RE.test(b.repo_id)) e.push('repoPattern') + if (!b.revision) e.push('revRequired') + else if (!SHA_RE.test(b.revision)) e.push('revPattern') + if (!b.storage_id || b.storage_id <= 0) e.push('storageRequired') + return e +} + +export function mapCreateError(status: number | undefined): string { + if (status === 403) return 'errors.forbidden' + if (status === 409) return 'errors.conflict' + if (status === 422) return 'errors.validation' + if (status === 429) return 'errors.quota_exceeded' + return 'errors.service_unavailable' +} diff --git a/frontend/tests/unit/TaskCreate.spec.ts b/frontend/tests/unit/TaskCreate.spec.ts new file mode 100644 index 0000000..db2c2c1 --- /dev/null +++ b/frontend/tests/unit/TaskCreate.spec.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import ElementPlus, { ElMessage } from 'element-plus' +import { createI18n } from 'vue-i18n' +import zh from '@/locale/zh-CN.json' +import { useAuthStore } from '@/stores/auth' + +// vi.mock factories are hoisted — use vi.hoisted so they don't reference +// an outer const (TDZ). +const { post } = vi.hoisted(() => ({ post: vi.fn() })) +vi.mock('@/api/client', () => ({ client: { post } })) +vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })) + +const i18n = createI18n({ legacy: false, locale: 'zh-CN', messages: { 'zh-CN': zh } }) +function mountCreate() { + return import('@/pages/TaskCreate.vue').then((m) => + mount(m.default, { global: { plugins: [ElementPlus, i18n] } })) +} +const b64 = (o: unknown) => btoa(JSON.stringify(o)).replace(/=+$/, '') + +describe('TaskCreate wiring', () => { + beforeEach(() => { setActivePinia(createPinia()); post.mockReset() }) + + test('429 → quota_exceeded error message', async () => { + useAuthStore().login(`h.${b64({ sub: '1', tid: 1, role: 'tenant_admin', pids: [] })}.s`) + const spy = vi.spyOn(ElMessage, 'error') + post.mockRejectedValueOnce({ response: { status: 429 } }) + const w = await mountCreate() + ;(w.vm as unknown as { form: Record }).form.repo_id = 'o/m' + ;(w.vm as unknown as { form: Record }).form.revision = 'a'.repeat(40) + await (w.vm as unknown as { submit: () => Promise }).submit() + expect(spy).toHaveBeenCalledWith(zh.errors.quota_exceeded) + }) + + test('service token → submit disabled', async () => { + useAuthStore().login(`h.${b64({ sub: '0', tid: 1, role: 'system_admin', pids: [] })}.s`) + const w = await mountCreate() + const btn = w.findComponent({ name: 'ElButton' }) + expect(btn.props('disabled')).toBe(true) + }) +}) diff --git a/frontend/tests/unit/createValidation.spec.ts b/frontend/tests/unit/createValidation.spec.ts new file mode 100644 index 0000000..8855049 --- /dev/null +++ b/frontend/tests/unit/createValidation.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from 'vitest' +import { validateCreate, mapCreateError } from '@/tasks/createValidation' + +describe('validateCreate', () => { + test('valid', () => { + expect(validateCreate({ repo_id: 'org/m', revision: 'a'.repeat(40), + storage_id: 1 })).toEqual([]) + }) + test('errors', () => { + const e = validateCreate({ repo_id: 'bad', revision: 'xyz', storage_id: 0 }) + expect(e).toContain('repoPattern') + expect(e).toContain('revPattern') + expect(e).toContain('storageRequired') + }) +}) +describe('mapCreateError', () => { + test('http status → i18n key', () => { + expect(mapCreateError(409)).toBe('errors.conflict') + expect(mapCreateError(422)).toBe('errors.validation') + expect(mapCreateError(429)).toBe('errors.quota_exceeded') + expect(mapCreateError(403)).toBe('errors.forbidden') + expect(mapCreateError(503)).toBe('errors.service_unavailable') + expect(mapCreateError(500)).toBe('errors.service_unavailable') + }) +}) From 4d45532f99744721c89fabc9824c410fdb7e6d19 Mon Sep 17 00:00:00 2001 From: l17728 Date: Tue, 19 May 2026 23:22:12 +0800 Subject: [PATCH 18/20] feat(ui-sp1): Login OIDC button --- frontend/src/pages/Login.vue | 13 +++++++++++++ frontend/src/pages/oidc.ts | 3 +++ frontend/tests/unit/oidc.spec.ts | 12 ++++++++++++ 3 files changed, 28 insertions(+) create mode 100644 frontend/src/pages/oidc.ts create mode 100644 frontend/tests/unit/oidc.spec.ts diff --git a/frontend/src/pages/Login.vue b/frontend/src/pages/Login.vue index d6cb2b8..918758c 100644 --- a/frontend/src/pages/Login.vue +++ b/frontend/src/pages/Login.vue @@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n' import { ElMessage, type FormInstance, type FormRules } from 'element-plus' import { useAuthStore } from '@/stores/auth' +import { oidcLoginUrl } from '@/pages/oidc' const { t } = useI18n() const route = useRoute() @@ -33,6 +34,10 @@ async function onSubmit() { authStore.login(form.token.trim()) router.push('/') } + +function loginOidc() { + window.location.assign(oidcLoginUrl(import.meta.env.VITE_API_BASE)) +}