diff --git a/docs/operator/web-ui.md b/docs/operator/web-ui.md new file mode 100644 index 0000000..934333a --- /dev/null +++ b/docs/operator/web-ui.md @@ -0,0 +1,86 @@ +# Web UI — Operator/User Guide (UI-SP1) + +> UI-SP1 ships the **app shell + auth + Dashboard + Task List + Task Create**. +> It is **frontend-only** (no backend/API change) and runs on the existing +> controller. The full 9-page vision is decomposed — see §5. +> Spec: `docs/superpowers/specs/2026-05-19-ui-sp1-shell-tasks-design.md`. +> Local deploy of the controller: `docs/operator/local-deployment.md`. + +--- + +## 1. What UI-SP1 delivers + +- **App shell**: collapsible sidebar + topbar, tenant/role chip (from JWT), + dark-mode toggle, zh/en locale toggle, **command palette (Ctrl/⌘+K)** for + nav + "create task" + "open task by id". +- **Auth**: paste a tenant-user JWT, or "Sign in with OIDC" button + (`/api/v1/auth/login`). 401 → auto sign-out. +- **Dashboard** (`/`): KPI cards (in-progress/completed/failed/total, + client-aggregated from the task list), a 24h created-count sparkline, + quota summary (`/api/v1/quota/current`), recent tasks. +- **Task List** (`/tasks`): client-side status filter + repo/id search, + per-row actions (view / cancel non-terminal / delete terminal) with + optimistic refresh. +- **Task Create** (`/tasks/new`): repo / revision (40-hex) / storage_id / + priority / source-strategy / upgrade-from / trust-non-hf, validation, + friendly error mapping (409/422/429/403/5xx), success → task detail. +- Realtime via a single `useLiveResource` seam (adaptive polling: faster + on detail, slower on lists, ×3 when the tab is hidden, stops at terminal, + backs off on error). UI-SP5 will swap this to SSE/WS with **zero view + changes**. + +## 2. Run it + +```bash +# controller (browser-friendly plain-HTTP instance) — see local-deployment.md +# → http://localhost:8001 +cd frontend && pnpm install && pnpm dev # → http://localhost:5173 +# Vite proxies /api,/health → DLW_API_PROXY (default http://localhost:8001) +``` + +Open `http://localhost:5173`, paste a **tenant-user JWT** on the login page. + +## 3. The token (important) + +Use a **tenant-user JWT** (`user_id` matching a real `users` row), **not +the system-admin service token**: the admin token is `user_id=0` and +`download_tasks.owner_user_id` has an FK to `users` — creating a task with +it fails (HTTP 500). The Task Create page detects a service token and +**disables submit** with a clear warning. Mint a tenant-user JWT: + +```bash +uv run python -c "from dlw.auth.principal import issue_system_jwt; \ + print(issue_system_jwt(secret='dev-system-jwt-change-me', user_id=1, \ + tenant_id=1, role='tenant_admin', project_ids=[], ttl_seconds=2592000))" +``` + +(30-day token for convenience during manual testing; production uses OIDC.) + +## 4. Keyboard / UX notes + +- **Ctrl/⌘+K** — command palette (navigate, create task, open task by id). +- Dark mode + locale persist (localStorage), default from + `prefers-color-scheme`. +- Every page has uniform loading / empty / error / forbidden states + (`DataBoundary`). + +## 5. Decomposition — what's deferred (and why) + +UI-SP1 is the first of 5 UI sub-projects (the full design needs additive +backend endpoints that don't exist yet): + +| Sub-project | Scope | Backend it needs | +|---|---|---| +| **UI-SP1** (this) | shell + auth + dashboard + list + create | none (existing API) | +| UI-SP2 | download-manager Task Detail (aggregate ring → per-source bar → virtualized chunk-segmented file table → executor swimlanes → event log) + task/file/chunk actions | new read endpoints: subtask-chunks, source-allocation, participating-executors, task-events | +| UI-SP3 | Executors (host-grouped, drain/restart), Quota metering, Audit log, Settings | `GET /executors`, audit query endpoint | +| UI-SP4 | AI-Copilot conversational UI (right slide-over, SSE, tool-call/confirm cards, ⌘K) | full AI backend (`/api/ai/chat` SSE, conversation persistence, LLM bridge, MCP→REST tool bridge) | +| UI-SP5 | realtime upgrade: `useLiveResource` → SSE/WS, zero view change | backend SSE/WS | + +**Known UI-SP1 scope limits:** Task Detail is still the simple scaffold view +(UI-SP2 makes it the download-manager view); no Executors/Search/Quota-mgmt/ +Audit/Settings/Copilot pages; Dashboard aggregates are client-side; tenant +chip is read-only (no tenant switcher); list filtering is client-side +(no server-side filter endpoint yet). + +Cross-ref: `docs/getting-started.md`, `docs/operator/cli-sdk.md`. diff --git a/docs/superpowers/plans/2026-05-19-ui-sp1-shell-tasks.md b/docs/superpowers/plans/2026-05-19-ui-sp1-shell-tasks.md new file mode 100644 index 0000000..1d78ed7 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-ui-sp1-shell-tasks.md @@ -0,0 +1,2275 @@ +# UI-SP1 — App Shell + Auth + Tasks Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development (project's validated variant: 2 opus pre-execution reviewers → implementer/controller per task → controller milestone E2E + frontend CI gates → opus final review → PR). Steps use `- [ ]`. + +**Goal:** Turn the 3-page read-only scaffold into a usable app shell where a user can create + monitor download tasks from the browser. + +**Architecture:** Frontend-only (`frontend/**` only — NO `src/dlw`, `api/openapi.yaml`, `alembic`, `tools`). Reuse the scaffold's conventions. New foundation (`useLiveResource`, `DataBoundary`, `ui`/`session` stores, nav registry, design tokens, en-US locale) → app shell (sidebar+topbar+command palette) → pages (Dashboard, TaskList upgrade, TaskCreate). All realtime via the single `useLiveResource` seam (SP5 swaps to SSE/WS, zero view change). + +**Tech Stack:** Vue 3.5 ` + + +``` + +- [ ] **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(403)).toBe('errors.forbidden') + expect(mapCreateError(503)).toBe('errors.service_unavailable') + expect(mapCreateError(500)).toBe('errors.service_unavailable') + }) +}) +``` + +Also add a **mounted wiring test** (pre-review fix B-M3 — the create-submit path + service-token guard are load-bearing; `TaskCreate` uses raw `client`, not vue-query, so no QueryClient needed) — `frontend/tests/unit/TaskCreate.spec.ts`: +```ts +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' + +const post = vi.fn() +vi.mock('@/api/client', () => ({ client: { post: (...a: unknown[]) => post(...a) } })) +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) + }) +}) +``` +(TaskCreate's `submit` + `form` must be exposed for the test — add `defineExpose({ submit, form })` at the end of its ` + + +``` + +- [ ] **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 frontend/tests/unit/TaskCreate.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 ` 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/components/AppLayout.vue b/frontend/src/components/AppLayout.vue deleted file mode 100644 index 5810402..0000000 --- a/frontend/src/components/AppLayout.vue +++ /dev/null @@ -1,70 +0,0 @@ - - - - - diff --git a/frontend/src/components/CommandPalette.vue b/frontend/src/components/CommandPalette.vue new file mode 100644 index 0000000..9b0108d --- /dev/null +++ b/frontend/src/components/CommandPalette.vue @@ -0,0 +1,80 @@ + + + + + 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/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/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/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/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/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/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/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/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/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/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/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/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/src/pages/Dashboard.vue b/frontend/src/pages/Dashboard.vue new file mode 100644 index 0000000..dca64fb --- /dev/null +++ b/frontend/src/pages/Dashboard.vue @@ -0,0 +1,104 @@ + + + + + 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)) +}