diff --git a/next/docs/evidence/stage-4/README.md b/next/docs/evidence/stage-4/README.md new file mode 100644 index 0000000..863e772 --- /dev/null +++ b/next/docs/evidence/stage-4/README.md @@ -0,0 +1,78 @@ +# 阶段4验收记录 + +## 范围 + +本阶段只验收全局前端基础和阶段3已有业务的前端交互:认证、16项工作区注册表、PC/移动Shell、主题、日期、统一API、统一弹窗与Toast、账户菜单、会员状态、系统凭据、模型池和会员管理。 + +行情摘要真实数据、全局搜索结果、后台刷新任务和16个工作区的业务内容分别属于阶段5及后续阶段。本阶段只提供稳定入口和明确空态,不将这些页面报告为已迁移。 + +## 设计规范第12节自检 + +### 表格 + +- 会员管理表按内容类型分配列宽;每日上限为右对齐等宽数字。 +- 单位写在“每日上限(次)”表头,空到期时间保持为空。 +- 每日上限表头可排序,并显示当前升降方向。 +- 本阶段没有行情图表;图例规则留到实际图表切片验收,未用占位图假装完成。 + +### 间距 + +- Playwright实测PC内容区上内边距14px、左右内边距16px。 +- 侧栏200px、顶栏46px、摘要条收起36px、状态栏30px均来自唯一令牌。 +- 16个工作区共用同一Shell和动态路由,不存在页面专属骨架副本。 + +### 字体 + +- 页面标题17px/800、卡片标题14px/700、正文13px、辅助文字11至12px。 +- 数字使用`tabular-nums`;未使用随视口连续缩放字号或负字距。 + +### 布局 + +- Shell与页面内容分离;4K内容限制最大可读宽度,没有将卡片和字体放大两倍。 +- 1023px及以下进入独立移动Shell,底部保留“行情、选股、问师、问天、复盘”五个入口。 +- 1920×1080、3840×2160和390×844实测无页面横向溢出。 + +### 细节 + +- 顶栏日期固定为`YYYY-MM-DD`,不使用会按Windows区域设置显示斜杠的原生日期外观。 +- 市场数据未接入时保留空值和原因,不展示模拟数字或满屏`--`。 +- 状态栏固定于视口底部;短页面不会把风险提示上移。 +- 日间/夜间在Vue挂载前选择主题,弹窗、表单、表格和空态均使用语义令牌。 +- 弹窗位于视口中心,支持Escape、遮罩关闭、焦点循环和焦点回收;成功反馈只使用自适应Toast。 + +## 权限与交互 + +- 真实注册/登录通过;首个账号显示管理员,普通用户不显示后台刷新和系统管理。 +- 管理员兼会员时前端可同时显示两个标识;管理员未开会员时仍显示真实“普通用户”会员状态。 +- 账户菜单严格包含个人资料、会员状态、修改密码、切换账号、退出五项。 +- 浏览器实际保存并重读个人出生资料;会员状态显示开通状态、到期、剩余时长、今日用量、今日剩余和暂不可用加油包。 +- 普通用户访问智能选股、问师和问天时保留相同页面结构,顶部提示会员限制,内容不可操作。 +- 非管理员直接访问`/system`会被前端送回情绪周期,所有管理API仍由后端独立拒绝。 + +## 减法证据 + +- 16项工作区只在`workspaceRegistry.ts`登记一次,路由和两种导航共同读取。 +- 浏览器请求只经`shared/api/client.ts`,CSRF由该出口统一附加。 +- 全站只存在一个主题Store、一个当前会话Store、一个弹窗Host和一个Toast Host。 +- CSS按令牌、基础、组件、Shell、账户、系统和移动端职责拆分;最长CSS为332行,未复制原型整份样式或增加覆盖补丁层。 +- 组件CSS未出现令牌文件之外的色值;`1023px`仅作为CSS媒体查询不可变量化的语法边界。 + +## 自动验证 + +- Ruff:通过。 +- pytest:40项通过。 +- Vue类型检查:通过。 +- Vitest:2个文件、5项通过。 +- Vite生产构建:通过。 +- Playwright:2项通过,覆盖管理员与普通用户、账户真实写入、三类系统管理、Ctrl+K、主题持久化、弹窗居中、桌面/4K/移动视口、HTTP失败和控制台错误审计。 + +## 截图 + +- [日间Shell 1920×1080](shell-light-1920x1080.jpg) +- [夜间Shell 1920×1080](shell-dark-1920x1080.jpg) +- [夜间系统管理 3840×2160](system-dark-3840x2160.jpg) +- [夜间移动Shell 390×844](shell-dark-390x844.jpg) + +## 后续入口 + +阶段5建立唯一数据网关、交易日期真相、行情快照、搜索和图表基础后,替换当前市场摘要与工作区空态。移动端逐页业务信息重组仍在阶段13完成,不把本阶段的移动Shell当作全部移动页面验收。 diff --git a/next/docs/evidence/stage-4/shell-dark-1920x1080.jpg b/next/docs/evidence/stage-4/shell-dark-1920x1080.jpg new file mode 100644 index 0000000..967c40b Binary files /dev/null and b/next/docs/evidence/stage-4/shell-dark-1920x1080.jpg differ diff --git a/next/docs/evidence/stage-4/shell-dark-390x844.jpg b/next/docs/evidence/stage-4/shell-dark-390x844.jpg new file mode 100644 index 0000000..1748058 Binary files /dev/null and b/next/docs/evidence/stage-4/shell-dark-390x844.jpg differ diff --git a/next/docs/evidence/stage-4/shell-light-1920x1080.jpg b/next/docs/evidence/stage-4/shell-light-1920x1080.jpg new file mode 100644 index 0000000..1761c1b Binary files /dev/null and b/next/docs/evidence/stage-4/shell-light-1920x1080.jpg differ diff --git a/next/docs/evidence/stage-4/system-dark-3840x2160.jpg b/next/docs/evidence/stage-4/system-dark-3840x2160.jpg new file mode 100644 index 0000000..fd06459 Binary files /dev/null and b/next/docs/evidence/stage-4/system-dark-3840x2160.jpg differ diff --git a/next/frontend/index.html b/next/frontend/index.html index a2641b8..c0ef21b 100644 --- a/next/frontend/index.html +++ b/next/frontend/index.html @@ -4,6 +4,17 @@ + + 小白复盘 diff --git a/next/frontend/src/app/App.vue b/next/frontend/src/app/App.vue index 7c2aa3f..3baee9f 100644 --- a/next/frontend/src/app/App.vue +++ b/next/frontend/src/app/App.vue @@ -1,3 +1,32 @@ + + diff --git a/next/frontend/src/app/router.ts b/next/frontend/src/app/router.ts index d31e5c9..8571854 100644 --- a/next/frontend/src/app/router.ts +++ b/next/frontend/src/app/router.ts @@ -1,8 +1,20 @@ import { createRouter, createWebHistory } from "vue-router"; -import BootstrapView from "./views/BootstrapView.vue"; +import SystemManagementView from "./views/SystemManagementView.vue"; +import WorkspaceView from "./views/WorkspaceView.vue"; +import { findWorkspace } from "./workspaceRegistry"; export default createRouter({ history: createWebHistory(), - routes: [{ path: "/", name: "bootstrap", component: BootstrapView }], + routes: [ + { path: "/", redirect: "/workspace/emotion" }, + { + path: "/workspace/:workspace", + name: "workspace", + component: WorkspaceView, + beforeEnter: (to) => (findWorkspace(String(to.params.workspace)) ? true : "/workspace/emotion"), + }, + { path: "/system", name: "system", component: SystemManagementView }, + { path: "/:pathMatch(.*)*", redirect: "/workspace/emotion" }, + ], }); diff --git a/next/frontend/src/app/shell/AppShell.vue b/next/frontend/src/app/shell/AppShell.vue new file mode 100644 index 0000000..cac0a86 --- /dev/null +++ b/next/frontend/src/app/shell/AppShell.vue @@ -0,0 +1,42 @@ + + + diff --git a/next/frontend/src/app/shell/DesktopSidebar.vue b/next/frontend/src/app/shell/DesktopSidebar.vue new file mode 100644 index 0000000..11971a8 --- /dev/null +++ b/next/frontend/src/app/shell/DesktopSidebar.vue @@ -0,0 +1,26 @@ + + + diff --git a/next/frontend/src/app/shell/MarketStrip.vue b/next/frontend/src/app/shell/MarketStrip.vue new file mode 100644 index 0000000..ce47dc1 --- /dev/null +++ b/next/frontend/src/app/shell/MarketStrip.vue @@ -0,0 +1,29 @@ + + + diff --git a/next/frontend/src/app/shell/MobileNav.vue b/next/frontend/src/app/shell/MobileNav.vue new file mode 100644 index 0000000..de82f58 --- /dev/null +++ b/next/frontend/src/app/shell/MobileNav.vue @@ -0,0 +1,9 @@ + diff --git a/next/frontend/src/app/shell/StatusBar.vue b/next/frontend/src/app/shell/StatusBar.vue new file mode 100644 index 0000000..b37c3dd --- /dev/null +++ b/next/frontend/src/app/shell/StatusBar.vue @@ -0,0 +1,17 @@ + + + diff --git a/next/frontend/src/app/shell/TopBar.vue b/next/frontend/src/app/shell/TopBar.vue new file mode 100644 index 0000000..869b28c --- /dev/null +++ b/next/frontend/src/app/shell/TopBar.vue @@ -0,0 +1,96 @@ + + + diff --git a/next/frontend/src/app/system/MarketSettingsPanel.vue b/next/frontend/src/app/system/MarketSettingsPanel.vue new file mode 100644 index 0000000..cbc8f89 --- /dev/null +++ b/next/frontend/src/app/system/MarketSettingsPanel.vue @@ -0,0 +1,76 @@ + + + diff --git a/next/frontend/src/app/system/MembershipAdminPanel.vue b/next/frontend/src/app/system/MembershipAdminPanel.vue new file mode 100644 index 0000000..787bfb8 --- /dev/null +++ b/next/frontend/src/app/system/MembershipAdminPanel.vue @@ -0,0 +1,104 @@ + + + diff --git a/next/frontend/src/app/system/ModelPoolPanel.vue b/next/frontend/src/app/system/ModelPoolPanel.vue new file mode 100644 index 0000000..1246ff3 --- /dev/null +++ b/next/frontend/src/app/system/ModelPoolPanel.vue @@ -0,0 +1,151 @@ + + + diff --git a/next/frontend/src/app/views/AuthView.vue b/next/frontend/src/app/views/AuthView.vue new file mode 100644 index 0000000..6940fb5 --- /dev/null +++ b/next/frontend/src/app/views/AuthView.vue @@ -0,0 +1,53 @@ + + + diff --git a/next/frontend/src/app/views/BootstrapView.vue b/next/frontend/src/app/views/BootstrapView.vue deleted file mode 100644 index 3ef6162..0000000 --- a/next/frontend/src/app/views/BootstrapView.vue +++ /dev/null @@ -1,64 +0,0 @@ - - - - - diff --git a/next/frontend/src/app/views/SystemManagementView.vue b/next/frontend/src/app/views/SystemManagementView.vue new file mode 100644 index 0000000..beb5f47 --- /dev/null +++ b/next/frontend/src/app/views/SystemManagementView.vue @@ -0,0 +1,33 @@ + + + diff --git a/next/frontend/src/app/views/WorkspaceView.vue b/next/frontend/src/app/views/WorkspaceView.vue new file mode 100644 index 0000000..d4ebb95 --- /dev/null +++ b/next/frontend/src/app/views/WorkspaceView.vue @@ -0,0 +1,35 @@ + + + diff --git a/next/frontend/src/app/workspaceRegistry.test.ts b/next/frontend/src/app/workspaceRegistry.test.ts new file mode 100644 index 0000000..bd1b56e --- /dev/null +++ b/next/frontend/src/app/workspaceRegistry.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { findWorkspace, workspaceGroups, workspaces } from "./workspaceRegistry"; + +describe("workspace registry", () => { + it("contains the sixteen ordered product workspaces without duplicate routes", () => { + expect(workspaces).toHaveLength(16); + expect(workspaces[0]?.key).toBe("emotion"); + expect(workspaces.at(-1)?.key).toBe("review"); + expect(new Set(workspaces.map((item) => item.key)).size).toBe(16); + expect(workspaceGroups).toEqual(["市场复盘", "智能工具", "个人"]); + }); + + it("resolves registered workspaces only", () => { + expect(findWorkspace("heaven")?.title).toBe("问天"); + expect(findWorkspace("unknown")).toBeUndefined(); + }); +}); diff --git a/next/frontend/src/app/workspaceRegistry.ts b/next/frontend/src/app/workspaceRegistry.ts new file mode 100644 index 0000000..6fec275 --- /dev/null +++ b/next/frontend/src/app/workspaceRegistry.ts @@ -0,0 +1,34 @@ +export type WorkspaceGroup = "市场复盘" | "智能工具" | "个人"; + +export type Workspace = { + key: string; + title: string; + mark: string; + group: WorkspaceGroup; + description: string; +}; + +export const workspaces: readonly Workspace[] = [ + { key: "emotion", title: "情绪周期", mark: "情", group: "市场复盘", description: "全市场情绪温度与阶段" }, + { key: "pool", title: "涨停池", mark: "涨", group: "市场复盘", description: "当日封板股票与结构" }, + { key: "broken", title: "炸板池", mark: "炸", group: "市场复盘", description: "触板未封股票与原因" }, + { key: "limit-down", title: "跌停板", mark: "跌", group: "市场复盘", description: "跌停风险与聚集" }, + { key: "yesterday", title: "昨日涨停", mark: "昨", group: "市场复盘", description: "昨日涨停次日反馈" }, + { key: "performance", title: "涨停表现", mark: "表", group: "市场复盘", description: "晋级、兑现与断板" }, + { key: "ladder", title: "市场天梯", mark: "梯", group: "市场复盘", description: "连板梯队与市场高度" }, + { key: "rotation", title: "板块轮动", mark: "转", group: "市场复盘", description: "热点迁移与板块成分" }, + { key: "auction", title: "集合竞价", mark: "竞", group: "市场复盘", description: "题材承接与竞价异动" }, + { key: "themes", title: "题材库", mark: "题", group: "市场复盘", description: "题材排行与成分股" }, + { key: "popularity", title: "人气热榜", mark: "热", group: "市场复盘", description: "双平台人气与共识" }, + { key: "dragon-list", title: "龙虎榜", mark: "榜", group: "市场复盘", description: "上榜明细与活跃席位" }, + { key: "screener", title: "智能选股", mark: "选", group: "智能工具", description: "阶段、策略与自定义选股" }, + { key: "mentor", title: "问师", mark: "师", group: "智能工具", description: "思维模型复盘对话" }, + { key: "heaven", title: "问天", mark: "天", group: "智能工具", description: "观势、观气与观心" }, + { key: "review", title: "我的复盘", mark: "复", group: "个人", description: "自选、日志与每日复盘" }, +] as const; + +export const workspaceGroups: readonly WorkspaceGroup[] = ["市场复盘", "智能工具", "个人"]; + +export function findWorkspace(key: string): Workspace | undefined { + return workspaces.find((workspace) => workspace.key === key); +} diff --git a/next/frontend/src/main.ts b/next/frontend/src/main.ts index c1c1735..d79efdb 100644 --- a/next/frontend/src/main.ts +++ b/next/frontend/src/main.ts @@ -3,7 +3,15 @@ import { createApp } from "vue"; import App from "./app/App.vue"; import router from "./app/router"; +import { applyTheme, preferredTheme } from "./shared/stores/ui"; import "./shared/styles/tokens.css"; import "./shared/styles/base.css"; +import "./shared/styles/components.css"; +import "./shared/styles/shell.css"; +import "./shared/styles/auth.css"; +import "./shared/styles/account.css"; +import "./shared/styles/system.css"; +import "./shared/styles/mobile.css"; +applyTheme(preferredTheme()); createApp(App).use(createPinia()).use(router).mount("#app"); diff --git a/next/frontend/src/shared/account/MembershipPanel.vue b/next/frontend/src/shared/account/MembershipPanel.vue new file mode 100644 index 0000000..4b23076 --- /dev/null +++ b/next/frontend/src/shared/account/MembershipPanel.vue @@ -0,0 +1,62 @@ + + + diff --git a/next/frontend/src/shared/account/PasswordPanel.vue b/next/frontend/src/shared/account/PasswordPanel.vue new file mode 100644 index 0000000..9ffa8c9 --- /dev/null +++ b/next/frontend/src/shared/account/PasswordPanel.vue @@ -0,0 +1,45 @@ + + + diff --git a/next/frontend/src/shared/account/ProfilePanel.vue b/next/frontend/src/shared/account/ProfilePanel.vue new file mode 100644 index 0000000..9a22aee --- /dev/null +++ b/next/frontend/src/shared/account/ProfilePanel.vue @@ -0,0 +1,109 @@ + + + diff --git a/next/frontend/src/shared/account/SearchPanel.vue b/next/frontend/src/shared/account/SearchPanel.vue new file mode 100644 index 0000000..4628670 --- /dev/null +++ b/next/frontend/src/shared/account/SearchPanel.vue @@ -0,0 +1,19 @@ + + + diff --git a/next/frontend/src/shared/api/client.test.ts b/next/frontend/src/shared/api/client.test.ts index cb4b89b..2801bc5 100644 --- a/next/frontend/src/shared/api/client.test.ts +++ b/next/frontend/src/shared/api/client.test.ts @@ -45,4 +45,23 @@ describe("api client", () => { requestId: "abc", }); }); + + it("adds the readable CSRF cookie to write requests", async () => { + vi.stubGlobal("document", { cookie: "xiaobai_csrf=csrf-token-123" }); + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ message: "ok" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await api.patch("/account/password", { value: "changed" }); + + const request = fetchMock.mock.calls[0]?.[1] as RequestInit; + const headers = request.headers as Headers; + expect(request.method).toBe("PATCH"); + expect(headers.get("X-CSRF-Token")).toBe("csrf-token-123"); + expect(headers.get("Content-Type")).toBe("application/json"); + }); }); diff --git a/next/frontend/src/shared/api/client.ts b/next/frontend/src/shared/api/client.ts index b938504..971d9e8 100644 --- a/next/frontend/src/shared/api/client.ts +++ b/next/frontend/src/shared/api/client.ts @@ -18,10 +18,19 @@ export class ApiError extends Error { } async function request(path: string, init?: RequestInit): Promise { + const csrfToken = readCookie("xiaobai_csrf"); + const headers = new Headers(init?.headers); + headers.set("Accept", "application/json"); + if (init?.body) { + headers.set("Content-Type", "application/json"); + } + if (init?.method && init.method !== "GET" && csrfToken) { + headers.set("X-CSRF-Token", csrfToken); + } const response = await fetch(`/api${path}`, { credentials: "same-origin", - headers: { Accept: "application/json", ...init?.headers }, ...init, + headers, }); const payload = (await response.json().catch(() => ({}))) as T & ApiErrorPayload; if (!response.ok) { @@ -35,8 +44,31 @@ async function request(path: string, init?: RequestInit): Promise { return payload; } +function readCookie(name: string): string | undefined { + if (typeof document === "undefined") return undefined; + const prefix = `${encodeURIComponent(name)}=`; + const item = document.cookie.split("; ").find((value) => value.startsWith(prefix)); + return item ? decodeURIComponent(item.slice(prefix.length)) : undefined; +} + +function json(method: string, body?: unknown): RequestInit { + return { method, body: body === undefined ? undefined : JSON.stringify(body) }; +} + export const api = { get(path: string): Promise { return request(path); }, + post(path: string, body?: unknown): Promise { + return request(path, json("POST", body)); + }, + put(path: string, body?: unknown): Promise { + return request(path, json("PUT", body)); + }, + patch(path: string, body?: unknown): Promise { + return request(path, json("PATCH", body)); + }, + delete(path: string): Promise { + return request(path, json("DELETE")); + }, }; diff --git a/next/frontend/src/shared/components/BaseDialog.vue b/next/frontend/src/shared/components/BaseDialog.vue new file mode 100644 index 0000000..397d47b --- /dev/null +++ b/next/frontend/src/shared/components/BaseDialog.vue @@ -0,0 +1,69 @@ + + + diff --git a/next/frontend/src/shared/components/DialogHost.vue b/next/frontend/src/shared/components/DialogHost.vue new file mode 100644 index 0000000..06dc5b3 --- /dev/null +++ b/next/frontend/src/shared/components/DialogHost.vue @@ -0,0 +1,20 @@ + + + diff --git a/next/frontend/src/shared/components/EmptyState.vue b/next/frontend/src/shared/components/EmptyState.vue new file mode 100644 index 0000000..9f5d1ba --- /dev/null +++ b/next/frontend/src/shared/components/EmptyState.vue @@ -0,0 +1,12 @@ + + + diff --git a/next/frontend/src/shared/components/ToastHost.vue b/next/frontend/src/shared/components/ToastHost.vue new file mode 100644 index 0000000..1b38922 --- /dev/null +++ b/next/frontend/src/shared/components/ToastHost.vue @@ -0,0 +1,11 @@ + + + diff --git a/next/frontend/src/shared/stores/market.ts b/next/frontend/src/shared/stores/market.ts new file mode 100644 index 0000000..0d54eb1 --- /dev/null +++ b/next/frontend/src/shared/stores/market.ts @@ -0,0 +1,23 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; + +function shanghaiDate(): string { + return new Intl.DateTimeFormat("en-CA", { + timeZone: "Asia/Shanghai", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(new Date()); +} + +export const useMarketStore = defineStore("market", () => { + const selectedDate = ref(shanghaiDate()); + + function moveDate(offset: number): void { + const date = new Date(`${selectedDate.value}T12:00:00+08:00`); + date.setUTCDate(date.getUTCDate() + offset); + selectedDate.value = date.toISOString().slice(0, 10); + } + + return { selectedDate, moveDate }; +}); diff --git a/next/frontend/src/shared/stores/session.ts b/next/frontend/src/shared/stores/session.ts new file mode 100644 index 0000000..74aebf0 --- /dev/null +++ b/next/frontend/src/shared/stores/session.ts @@ -0,0 +1,72 @@ +import { defineStore } from "pinia"; +import { computed, ref } from "vue"; + +import { api, ApiError } from "../api/client"; + +export type AccountIdentity = { + id: number; + username: string; + is_admin: boolean; + membership_status: string; + membership_active: boolean; + smart_access: boolean; + badges: string[]; +}; + +type AuthResponse = { + account: AccountIdentity; + csrf_token: string; +}; + +export const useSessionStore = defineStore("session", () => { + const account = ref(null); + const initialized = ref(false); + const busy = ref(false); + + const isAdmin = computed(() => account.value?.is_admin === true); + const isMember = computed(() => account.value?.membership_active === true); + + async function restore(): Promise { + try { + account.value = await api.get("/auth/session"); + } catch (error) { + if (!(error instanceof ApiError) || error.status !== 401) { + throw error; + } + account.value = null; + } finally { + initialized.value = true; + } + } + + async function authenticate(mode: "login" | "register", username: string, password: string) { + busy.value = true; + try { + const response = await api.post(`/auth/${mode}`, { username, password }); + account.value = response.account; + } finally { + busy.value = false; + } + } + + async function endSession(action: "logout" | "switch-account"): Promise { + await api.post(`/auth/${action}`); + account.value = null; + } + + async function refreshIdentity(): Promise { + account.value = await api.get("/auth/session"); + } + + return { + account, + initialized, + busy, + isAdmin, + isMember, + restore, + authenticate, + endSession, + refreshIdentity, + }; +}); diff --git a/next/frontend/src/shared/stores/ui.ts b/next/frontend/src/shared/stores/ui.ts new file mode 100644 index 0000000..e0f75c5 --- /dev/null +++ b/next/frontend/src/shared/stores/ui.ts @@ -0,0 +1,53 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; + +export type DialogName = "profile" | "membership" | "password" | "search" | null; +export type Theme = "light" | "dark"; + +const THEME_KEY = "xiaobai-theme"; + +export function preferredTheme(): Theme { + const stored = localStorage.getItem(THEME_KEY); + if (stored === "light" || stored === "dark") return stored; + return matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} + +export function applyTheme(theme: Theme): void { + document.documentElement.dataset.theme = theme; + document.documentElement.style.colorScheme = theme; +} + +export const useUiStore = defineStore("ui", () => { + const theme = ref(preferredTheme()); + const dialog = ref(null); + const toast = ref(""); + let toastTimer: ReturnType | undefined; + + function setTheme(next: Theme): void { + theme.value = next; + localStorage.setItem(THEME_KEY, next); + applyTheme(next); + } + + function toggleTheme(): void { + setTheme(theme.value === "light" ? "dark" : "light"); + } + + function openDialog(name: Exclude): void { + dialog.value = name; + } + + function closeDialog(): void { + dialog.value = null; + } + + function showToast(message: string): void { + toast.value = message; + if (toastTimer) clearTimeout(toastTimer); + toastTimer = setTimeout(() => { + toast.value = ""; + }, 2200); + } + + return { theme, dialog, toast, setTheme, toggleTheme, openDialog, closeDialog, showToast }; +}); diff --git a/next/frontend/src/shared/styles/account.css b/next/frontend/src/shared/styles/account.css new file mode 100644 index 0000000..03eba08 --- /dev/null +++ b/next/frontend/src/shared/styles/account.css @@ -0,0 +1,137 @@ +.panel-loading { + padding: var(--s-22) 0; + text-align: center; +} + +.panel-form { + padding-top: var(--s-14); +} + +.membership-panel { + display: grid; + gap: var(--s-14); +} + +.status-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: var(--s-1); + margin: 0; + overflow: hidden; + border: var(--s-1) solid var(--color-border); + border-radius: var(--control-radius); + background: var(--color-divider); +} + +.status-grid > div { + padding: var(--s-10) var(--s-12); + background: var(--color-surface); +} + +.status-grid dt { + color: var(--color-text-faint); + font-size: var(--font-11); +} + +.status-grid dd { + margin: var(--s-4) 0 0; + font-size: var(--font-14); + font-weight: var(--weight-700); +} + +.comparison-table { + overflow: hidden; + border: var(--s-1) solid var(--color-border); + border-radius: var(--control-radius); +} + +.comparison-row { + display: grid; + grid-template-columns: minmax(0, 1fr) var(--s-200) var(--s-200); + border-bottom: var(--s-1) solid var(--color-divider); +} + +.comparison-row:last-child { + border-bottom: 0; +} + +.comparison-row span { + padding: var(--s-8) var(--s-12); + font-size: var(--font-12-5); +} + +.comparison-row span:not(:first-child) { + text-align: center; +} + +.comparison-head { + color: var(--color-text-secondary); + background: var(--color-surface-muted); + font-size: var(--font-12); + font-weight: var(--weight-600); +} + +.usage-package { + display: flex; + align-items: center; + gap: var(--s-12); + padding: var(--s-12); + border: var(--s-1) solid var(--color-border); + border-radius: var(--control-radius); +} + +.usage-package div { + flex: 1; +} + +.usage-package strong { + font-size: var(--font-13); +} + +.usage-package p, +.password-hint { + margin-top: var(--s-4); + font-size: var(--font-11-5); +} + +.search-panel { + display: grid; + gap: var(--s-12); +} + +.search-input { + min-height: var(--s-44); +} + +.search-categories { + display: flex; + gap: var(--s-8); + padding-bottom: var(--s-10); + border-bottom: var(--s-1) solid var(--color-divider); +} + +.search-categories span { + padding: var(--s-4) var(--s-9); + border-radius: var(--tag-radius); + color: var(--color-text-secondary); + background: var(--color-surface-muted); + font-size: var(--font-11); +} + +.search-empty { + min-height: var(--s-200); + display: grid; + align-content: center; + justify-items: center; + gap: var(--s-8); + color: var(--color-text-secondary); + font-size: var(--font-12); +} + +.search-empty span { + padding: var(--s-2) var(--s-6); + border: var(--s-1) solid var(--color-border); + border-radius: var(--radius-4); + color: var(--color-text-faint); + font-size: var(--font-11); +} diff --git a/next/frontend/src/shared/styles/auth.css b/next/frontend/src/shared/styles/auth.css new file mode 100644 index 0000000..dfe0baf --- /dev/null +++ b/next/frontend/src/shared/styles/auth.css @@ -0,0 +1,69 @@ +.auth-view { + min-height: 100vh; + display: grid; + place-items: center; + padding: var(--s-16); + background: var(--color-canvas); +} + +.auth-panel { + width: min(100%, var(--s-400)); + overflow: hidden; +} + +.auth-brand { + display: flex; + align-items: center; + gap: var(--s-12); + padding: var(--s-22) var(--s-26) var(--s-16); +} + +.auth-brand h1 { + font-size: var(--font-17); + font-weight: var(--weight-800); +} + +.auth-brand p { + margin-top: var(--s-4); + color: var(--color-text-secondary); + font-size: var(--font-12); +} + +.auth-tabs { + display: flex; + padding: 0 var(--s-26); + border-bottom: var(--s-1) solid var(--color-divider); +} + +.auth-tabs button { + flex: 1; + padding: var(--s-10) var(--s-14); + border-bottom: var(--s-2) solid var(--c-transparent); + color: var(--color-text-secondary); + background: var(--c-transparent); +} + +.auth-tabs button.active { + color: var(--color-primary); + border-bottom-color: var(--color-primary); + font-weight: var(--weight-600); +} + +.auth-form { + display: grid; + gap: var(--s-14); + padding: var(--s-22) var(--s-26) var(--s-16); +} + +.auth-submit { + width: 100%; + margin-top: var(--s-4); +} + +.auth-risk { + padding: 0 var(--s-26) var(--s-22); + color: var(--color-text-faint); + font-size: var(--font-11); + line-height: var(--s-18); + text-align: center; +} diff --git a/next/frontend/src/shared/styles/base.css b/next/frontend/src/shared/styles/base.css index da524e4..4633c04 100644 --- a/next/frontend/src/shared/styles/base.css +++ b/next/frontend/src/shared/styles/base.css @@ -5,20 +5,95 @@ html, body, #app { - min-width: 320px; + min-width: var(--s-320); min-height: 100%; margin: 0; } +html { + background: var(--color-canvas); +} + body { min-height: 100vh; - color: var(--text-primary); - background: var(--canvas); + overflow-x: hidden; + color: var(--color-text); + background: var(--color-canvas); + font-family: var(--font-sans); + font-size: var(--font-13); + font-weight: var(--weight-400); + letter-spacing: 0; } button, input, select, textarea { + color: inherit; font: inherit; + letter-spacing: 0; +} + +button, +a, +input, +select, +textarea { + outline: none; +} + +button:focus-visible, +a:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + box-shadow: var(--focus-ring); +} + +button { + border: 0; + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; +} + +a { + color: inherit; + text-decoration: none; +} + +h1, +h2, +h3, +p { + margin: 0; +} + +.sr-only { + position: absolute; + width: var(--s-1); + height: var(--s-1); + padding: 0; + margin: calc(var(--s-1) * -1); + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.numeric { + font-variant-numeric: tabular-nums; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: var(--s-1) !important; + animation-iteration-count: 1 !important; + transition-duration: var(--s-1) !important; + } } diff --git a/next/frontend/src/shared/styles/components.css b/next/frontend/src/shared/styles/components.css new file mode 100644 index 0000000..fade93f --- /dev/null +++ b/next/frontend/src/shared/styles/components.css @@ -0,0 +1,332 @@ +.btn { + min-height: var(--control-height); + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--s-6); + padding: var(--s-6) var(--s-13); + border: var(--s-1) solid var(--color-border); + border-radius: var(--control-radius); + color: var(--color-text); + background: var(--color-surface); + font-size: var(--font-13); + font-weight: var(--weight-500); + transition: color var(--duration-fast) var(--ease-standard), + background var(--duration-fast) var(--ease-standard), + border-color var(--duration-fast) var(--ease-standard); +} + +.btn:hover:not(:disabled) { + color: var(--color-primary); + border-color: var(--color-primary-border); +} + +.btn-primary { + color: var(--c-gray-050); + border-color: var(--color-primary); + background: var(--color-primary); +} + +.btn-primary:hover:not(:disabled) { + color: var(--c-gray-050); + border-color: var(--color-primary-hover); + background: var(--color-primary-hover); +} + +.btn-ghost { + border-color: var(--c-transparent); + background: var(--c-transparent); +} + +.btn-small { + min-height: var(--s-28); + padding: var(--s-4) var(--s-9); + font-size: var(--font-12); +} + +.btn:disabled, +.is-disabled { + opacity: var(--opacity-disabled); +} + +.icon-button { + width: var(--s-32); + height: var(--s-32); + display: inline-grid; + place-items: center; + padding: 0; + border: var(--s-1) solid var(--color-border); + border-radius: var(--control-radius); + color: var(--color-text-secondary); + background: var(--color-surface); + font-size: var(--font-14); + transition: color var(--duration-fast) var(--ease-standard), + background var(--duration-fast) var(--ease-standard), + border-color var(--duration-fast) var(--ease-standard); +} + +.icon-button:hover { + color: var(--color-primary); + border-color: var(--color-primary-border); + background: var(--color-primary-soft); +} + +.card { + border: var(--s-1) solid var(--color-border); + border-radius: var(--card-radius); + background: var(--color-surface); + box-shadow: var(--shadow-card); +} + +.card-header { + min-height: var(--s-40); + display: flex; + align-items: center; + gap: var(--s-8); + padding: var(--s-11) var(--s-14); + border-bottom: var(--s-1) solid var(--color-divider); +} + +.card-header h2, +.card-header h3 { + font-size: var(--font-14); + font-weight: var(--weight-700); +} + +.card-body { + padding: var(--s-14) var(--s-16); +} + +.muted { + color: var(--color-text-secondary); +} + +.faint { + color: var(--color-text-faint); +} + +.up { + color: var(--color-up); +} + +.down { + color: var(--color-down); +} + +.warning { + color: var(--color-warning); +} + +.tag { + display: inline-flex; + align-items: center; + min-height: var(--s-20); + padding: var(--s-1) var(--s-7); + border: var(--s-1) solid var(--color-border); + border-radius: var(--tag-radius); + color: var(--color-text-secondary); + background: var(--color-surface-muted); + font-size: var(--font-11); +} + +.tag-admin { + color: var(--color-warning); + border-color: var(--color-warning); + background: var(--color-warning-soft); +} + +.tag-vip { + color: var(--color-warning); + border-color: var(--color-warning); + background: var(--color-warning-soft); + font-weight: var(--weight-800); +} + +.field { + display: grid; + gap: var(--s-6); +} + +.field label, +.field-label { + color: var(--color-text-secondary); + font-size: var(--font-12); + font-weight: var(--weight-600); +} + +.input, +.select, +.textarea { + width: 100%; + min-height: var(--s-36); + padding: var(--s-8) var(--s-12); + border: var(--s-1) solid var(--color-border); + border-radius: var(--control-radius); + color: var(--color-text); + background: var(--color-surface); + font-size: var(--font-13); +} + +.textarea { + min-height: var(--s-64); + resize: vertical; +} + +.input::placeholder, +.textarea::placeholder { + color: var(--color-text-faint); +} + +.input:hover, +.select:hover, +.textarea:hover { + border-color: var(--color-primary-border); +} + +.form-grid { + display: grid; + gap: var(--s-12); +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: var(--s-8); + padding-top: var(--s-4); +} + +.field-error { + color: var(--color-up); + font-size: var(--font-11-5); +} + +.notice { + padding: var(--s-10) var(--s-12); + border: var(--s-1) solid var(--color-primary-border); + border-radius: var(--control-radius); + color: var(--color-text-secondary); + background: var(--color-primary-soft); + font-size: var(--font-12); + line-height: var(--s-18); +} + +.notice-warning { + color: var(--color-warning); + border-color: var(--color-warning); + background: var(--color-warning-soft); +} + +.membership-lock { + display: flex; + align-items: center; + gap: var(--s-12); + margin-bottom: var(--s-12); +} + +.membership-lock span { + flex: 1; +} + +.locked-content { + pointer-events: none; + opacity: var(--opacity-muted); +} + +.dialog-backdrop { + position: fixed; + inset: 0; + z-index: var(--z-dialog); + display: grid; + place-items: center; + padding: var(--s-16); + background: var(--color-overlay); +} + +.dialog { + width: min(100%, var(--s-dialog)); + max-height: calc(100vh - var(--s-34)); + display: grid; + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; + border: var(--s-1) solid var(--color-border); + border-radius: var(--card-radius); + background: var(--color-surface-raised); + box-shadow: var(--shadow-float); +} + +.dialog-wide { + width: min(100%, var(--s-dialog-wide)); +} + +.dialog-header { + min-height: var(--s-46); + display: flex; + align-items: center; + gap: var(--s-8); + padding: var(--s-10) var(--s-14); + border-bottom: var(--s-1) solid var(--color-divider); +} + +.dialog-title { + font-size: var(--font-14); + font-weight: var(--weight-700); +} + +.dialog-close { + margin-left: auto; +} + +.dialog-body { + overflow: auto; + padding: var(--s-14) var(--s-16) var(--s-16); +} + +.toast { + position: fixed; + left: 50%; + bottom: var(--s-64); + z-index: var(--z-toast); + max-width: min(var(--s-400), calc(100vw - var(--s-32))); + padding: var(--s-10) var(--s-14); + border: var(--s-1) solid var(--color-border); + border-radius: var(--control-radius); + color: var(--color-text); + background: var(--color-surface-raised); + box-shadow: var(--shadow-float); + font-size: var(--font-12-5); + transform: translateX(-50%); +} + +.empty-state { + min-height: var(--s-200); + display: grid; + align-content: center; + justify-items: center; + gap: var(--s-8); + padding: var(--s-44) var(--s-16); + text-align: center; +} + +.empty-mark { + width: var(--s-30); + height: var(--s-30); + display: grid; + place-items: center; + border: var(--s-1) solid var(--color-border); + border-radius: var(--radius-round); + color: var(--color-text-faint); + background: var(--color-surface-muted); +} + +.empty-state h2 { + color: var(--color-text-secondary); + font-size: var(--font-14); + font-weight: var(--weight-600); +} + +.empty-state p { + max-width: var(--s-400); + color: var(--color-text-secondary); + font-size: var(--font-12); + line-height: var(--s-18); +} diff --git a/next/frontend/src/shared/styles/mobile.css b/next/frontend/src/shared/styles/mobile.css new file mode 100644 index 0000000..4bf7057 --- /dev/null +++ b/next/frontend/src/shared/styles/mobile.css @@ -0,0 +1,152 @@ +@media (max-width: 1023px) { + .dialog-backdrop { + align-items: end; + padding: 0; + } + + .dialog, + .dialog-wide { + width: 100%; + max-height: calc(100vh - var(--s-44)); + border-radius: var(--card-radius) var(--card-radius) 0 0; + } + + .btn, + .icon-button { + min-height: var(--touch-height); + } + + .icon-button { + width: var(--touch-height); + } + + .membership-lock { + align-items: stretch; + flex-direction: column; + } + + .app-shell { + padding-left: 0; + padding-bottom: var(--shell-mobile-nav-height); + } + + .sidebar, + .statusbar, + .desktop-only, + .market-details { + display: none; + } + + .topbar { + gap: var(--s-8); + padding: 0 var(--s-10); + } + + .topbar-title { + font-size: var(--font-13); + } + + .identity-cluster .tag, + .account-name, + .date-control .icon-button { + display: none; + } + + .mobile-account-label { + display: inline; + } + + .market-strip-row { + gap: var(--s-12); + overflow-x: auto; + padding-right: var(--s-10); + padding-left: var(--s-10); + white-space: nowrap; + } + + .market-toggle { + display: none; + } + + .page-frame { + min-height: calc(100vh - var(--shell-topbar-height) - var(--shell-summary-height) - var(--shell-mobile-nav-height)); + padding: var(--s-12) var(--s-10); + } + + .page-header { + align-items: flex-start; + gap: var(--s-4); + flex-direction: column; + } + + .mobile-nav { + position: fixed; + right: 0; + bottom: 0; + left: 0; + z-index: var(--z-mobile-nav); + height: var(--shell-mobile-nav-height); + display: grid; + grid-template-columns: repeat(5, 1fr); + padding-bottom: env(safe-area-inset-bottom); + border-top: var(--s-1) solid var(--color-border); + background: var(--color-surface); + } + + .mobile-nav-item { + min-width: 0; + display: grid; + place-items: center; + color: var(--color-text-secondary); + font-size: var(--font-11); + } + + .mobile-nav-item.router-link-active { + color: var(--color-primary); + font-weight: var(--weight-600); + } + + .status-grid { + grid-template-columns: 1fr 1fr; + } + + .comparison-row { + grid-template-columns: minmax(0, 1fr) var(--s-64) var(--s-64); + } + + .auth-view { + align-items: start; + padding-top: var(--s-34); + } + + .auth-panel { + box-shadow: none; + } + + .system-header, + .system-header > div:first-child { + align-items: flex-start; + flex-direction: column; + } + + .system-tabs { + width: 100%; + margin-left: 0; + overflow-x: auto; + } + + .system-grid, + .model-grid { + grid-template-columns: 1fr; + } + + .credential-row, + .selection-grid { + grid-template-columns: 1fr; + align-items: stretch; + } + + .member-editor { + position: static; + } +} diff --git a/next/frontend/src/shared/styles/shell.css b/next/frontend/src/shared/styles/shell.css new file mode 100644 index 0000000..5cab0ac --- /dev/null +++ b/next/frontend/src/shared/styles/shell.css @@ -0,0 +1,327 @@ +.app-loading { + min-height: 100vh; + display: grid; + place-items: center; + color: var(--color-text-secondary); + background: var(--color-canvas); +} + +.loading-stack { + display: grid; + justify-items: center; + gap: var(--s-12); +} + +.app-shell { + min-height: 100vh; + padding-left: var(--shell-sidebar-width); + padding-bottom: var(--shell-status-height); + background: var(--color-canvas); +} + +.sidebar { + position: fixed; + inset: 0 auto 0 0; + z-index: var(--z-sidebar); + width: var(--shell-sidebar-width); + display: flex; + flex-direction: column; + border-right: var(--s-1) solid var(--color-border); + background: var(--color-surface); +} + +.brand { + height: var(--shell-topbar-height); + display: flex; + align-items: center; + gap: var(--s-8); + padding: 0 var(--s-16); + border-bottom: var(--s-1) solid var(--color-divider); +} + +.brand-mark { + width: var(--s-26); + height: var(--s-26); + display: grid; + place-items: center; + border-radius: var(--control-radius); + color: var(--c-gray-050); + background: var(--color-primary); + font-size: var(--font-14); + font-weight: var(--weight-700); +} + +.brand-name { + font-size: var(--font-15); + font-weight: var(--weight-700); +} + +.sidebar-nav { + flex: 1; + overflow-y: auto; + padding: var(--s-8); +} + +.nav-group-label { + padding: var(--s-12) var(--s-10) var(--s-4); + color: var(--color-text-faint); + font-size: var(--font-11); +} + +.nav-item { + min-height: var(--s-32); + display: flex; + align-items: center; + gap: var(--s-8); + margin-bottom: var(--s-1); + padding: var(--s-6) var(--s-10); + border-radius: var(--control-radius); + color: var(--color-text-secondary); + font-size: var(--font-13); + transition: color var(--duration-fast) var(--ease-standard), + background var(--duration-fast) var(--ease-standard); +} + +.nav-item:hover { + color: var(--color-text); + background: var(--color-surface-muted); +} + +.nav-item.router-link-active { + color: var(--color-primary); + background: var(--color-primary-soft); + font-weight: var(--weight-600); +} + +.nav-mark { + width: var(--s-20); + color: var(--color-text-faint); + font-size: var(--font-11); + text-align: center; +} + +.nav-item.router-link-active .nav-mark { + color: var(--color-primary); +} + +.shell-main { + min-width: 0; +} + +.topbar { + position: sticky; + top: 0; + z-index: var(--z-topbar); + height: var(--shell-topbar-height); + display: flex; + align-items: center; + gap: var(--s-10); + padding: 0 var(--s-16); + border-bottom: var(--s-1) solid var(--color-border); + background: var(--color-surface); +} + +.topbar-title { + min-width: 0; + font-size: var(--font-14); + font-weight: var(--weight-700); + white-space: nowrap; +} + +.topbar-spacer { + flex: 1; +} + +.date-control { + display: inline-flex; + align-items: center; + gap: var(--s-4); +} + +.date-input { + height: var(--s-32); + padding: var(--s-4) var(--s-8); + border: var(--s-1) solid var(--color-border); + border-radius: var(--control-radius); + color: var(--color-text); + background: var(--color-surface); + font-size: var(--font-12); + font-variant-numeric: tabular-nums; +} + +.identity-cluster { + display: inline-flex; + align-items: center; + gap: var(--s-6); +} + +.account-menu-wrap { + position: relative; +} + +.account-trigger { + max-width: var(--s-200); +} + +.account-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mobile-account-label { + display: none; +} + +.account-menu { + position: absolute; + top: calc(100% + var(--s-8)); + right: 0; + z-index: var(--z-popover); + width: var(--s-200); + padding: var(--s-6); + border: var(--s-1) solid var(--color-border); + border-radius: var(--control-radius); + background: var(--color-surface-raised); + box-shadow: var(--shadow-float); +} + +.account-menu button { + width: 100%; + min-height: var(--s-32); + padding: var(--s-6) var(--s-10); + border-radius: var(--radius-5); + color: var(--color-text-secondary); + background: var(--c-transparent); + text-align: left; +} + +.account-menu button:hover { + color: var(--color-text); + background: var(--color-surface-muted); +} + +.market-strip { + border-bottom: var(--s-1) solid var(--color-border); + background: var(--color-surface); +} + +.market-strip-row { + min-height: var(--shell-summary-height); + display: flex; + align-items: center; + gap: var(--s-18); + padding: var(--s-6) var(--s-16); + color: var(--color-text-secondary); + font-size: var(--font-12); +} + +.market-emotion { + display: inline-flex; + align-items: center; + gap: var(--s-6); + color: var(--color-text-secondary); +} + +.emotion-dot { + width: var(--s-8); + height: var(--s-8); + border-radius: var(--radius-round); + background: var(--color-text-faint); + animation: emotion-pulse 2.2s var(--ease-standard) infinite; +} + +@keyframes emotion-pulse { + 50% { + opacity: var(--opacity-muted); + transform: scale(var(--scale-pulse)); + } +} + +.market-toggle { + margin-left: auto; + color: var(--color-primary); + background: var(--c-transparent); + font-size: var(--font-12); +} + +.market-details { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + border-top: var(--s-1) solid var(--color-divider); + background: var(--color-divider); + gap: var(--s-1); +} + +.market-cell { + min-height: var(--s-64); + padding: var(--s-10) var(--s-16); + background: var(--color-surface); +} + +.market-cell-label { + color: var(--color-text-faint); + font-size: var(--font-11); +} + +.market-cell-value { + min-height: var(--s-24); + padding-top: var(--s-4); + font-size: var(--font-18); + font-weight: var(--weight-700); + font-variant-numeric: tabular-nums; +} + +.page-frame { + width: min(100%, var(--s-content-max)); + min-height: calc(100vh - var(--shell-topbar-height) - var(--shell-summary-height) - var(--shell-status-height)); + margin: 0 auto; + padding: var(--page-padding-y) var(--page-padding-x); +} + +.page-header { + display: flex; + align-items: baseline; + gap: var(--s-12); + margin-bottom: var(--s-12); +} + +.page-header h1 { + font-size: var(--font-17); + font-weight: var(--weight-800); +} + +.page-subtitle { + color: var(--color-text-faint); + font-size: var(--font-12); +} + +.statusbar { + position: fixed; + right: 0; + bottom: 0; + left: var(--shell-sidebar-width); + z-index: var(--z-status); + height: var(--shell-status-height); + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: var(--s-12); + padding: 0 var(--s-16); + border-top: var(--s-1) solid var(--color-border); + color: var(--color-text-faint); + background: var(--color-surface); + font-size: var(--font-11); +} + +.statusbar-center { + color: var(--color-text-secondary); +} + +.statusbar-right { + text-align: right; +} + +.mobile-nav { + display: none; +} diff --git a/next/frontend/src/shared/styles/system.css b/next/frontend/src/shared/styles/system.css new file mode 100644 index 0000000..adc90c3 --- /dev/null +++ b/next/frontend/src/shared/styles/system.css @@ -0,0 +1,206 @@ +.system-header { + align-items: center; +} + +.system-header > div:first-child { + display: flex; + align-items: baseline; + gap: var(--s-12); +} + +.system-tabs { + display: flex; + align-self: stretch; + margin-left: auto; + border-bottom: var(--s-1) solid var(--color-divider); +} + +.system-tabs button { + padding: var(--s-10) var(--s-14); + border-bottom: var(--s-2) solid var(--c-transparent); + color: var(--color-text-secondary); + background: var(--c-transparent); + font-size: var(--font-13); +} + +.system-tabs button.active { + color: var(--color-primary); + border-bottom-color: var(--color-primary); + font-weight: var(--weight-600); +} + +.system-stack { + display: grid; + gap: var(--layout-gap); +} + +.system-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) var(--s-360); + align-items: start; + gap: var(--layout-gap); +} + +.model-grid { + grid-template-columns: var(--s-360) minmax(0, 1fr); +} + +.credential-list { + display: grid; + gap: var(--s-12); +} + +.credential-row { + display: grid; + grid-template-columns: var(--s-200) minmax(0, 1fr) auto; + align-items: end; + gap: var(--s-10); +} + +.credential-meta { + display: grid; + gap: var(--s-4); +} + +.credential-meta strong { + font-size: var(--font-13); +} + +.credential-meta span { + font-size: var(--font-11); +} + +.disabled-row, +.selected-account { + display: flex; + align-items: center; + gap: var(--s-12); +} + +.disabled-row p, +.selected-account strong { + flex: 1; +} + +.model-list { + max-height: calc(100vh - var(--s-260)); + overflow: auto; + padding: var(--s-6); +} + +.model-row { + width: 100%; + min-height: var(--s-44); + display: flex; + align-items: center; + gap: var(--s-8); + padding: var(--s-8) var(--s-10); + border-radius: var(--control-radius); + color: var(--color-text); + background: var(--c-transparent); + text-align: left; +} + +.model-row:hover, +.model-row.active { + background: var(--color-primary-soft); +} + +.model-row > span:first-child { + min-width: 0; + display: grid; + gap: var(--s-2); + flex: 1; +} + +.model-row strong { + overflow: hidden; + font-size: var(--font-13); + text-overflow: ellipsis; + white-space: nowrap; +} + +.model-row small { + overflow: hidden; + color: var(--color-text-faint); + font-size: var(--font-11); + text-overflow: ellipsis; + white-space: nowrap; +} + +.model-tags { + display: flex; + gap: var(--s-4); +} + +.selection-grid { + display: grid; + grid-template-columns: 1fr 1fr auto; + align-items: end; + gap: var(--s-10); +} + +.table-wrap { + overflow: auto; +} + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: var(--font-12-5); +} + +.data-table th { + position: sticky; + top: 0; + z-index: var(--z-table); + padding: var(--s-8) var(--s-12); + border-bottom: var(--s-1) solid var(--color-border); + color: var(--color-text-secondary); + background: var(--color-surface-muted); + font-size: var(--font-12); + font-weight: var(--weight-600); + text-align: left; + white-space: nowrap; +} + +.data-table td { + padding: var(--s-9) var(--s-12); + border-bottom: var(--s-1) solid var(--color-divider); + white-space: nowrap; +} + +.data-table th.num, +.data-table td.num { + text-align: right; +} + +.sort-button { + padding: 0; + color: inherit; + background: var(--c-transparent); + font: inherit; +} + +.sort-button span { + color: var(--color-primary); +} + +.data-table tbody tr { + cursor: pointer; +} + +.data-table tbody tr:hover, +.data-table tbody tr.selected { + background: var(--color-primary-soft); +} + +.name-cell { + font-size: var(--font-13); + font-weight: var(--weight-700); +} + +.member-editor { + position: sticky; + top: calc(var(--shell-topbar-height) + var(--shell-summary-height) + var(--s-14)); +} diff --git a/next/frontend/src/shared/styles/tokens.css b/next/frontend/src/shared/styles/tokens.css index b8e08c1..31fecbb 100644 --- a/next/frontend/src/shared/styles/tokens.css +++ b/next/frontend/src/shared/styles/tokens.css @@ -1,37 +1,191 @@ :root { color-scheme: light; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; - font-size: 13px; - color: #1f2937; - background: #f4f5f7; - --canvas: #f4f5f7; - --surface: #ffffff; - --border: #e5e7eb; - --text-primary: #1f2937; - --text-secondary: #6b7280; - --primary: #2563eb; - --warning: #b45309; + /* Primitive color tokens */ + --c-blue-600: #2563eb; + --c-blue-700: #1d4ed8; + --c-blue-050: #eff4ff; + --c-blue-200: #c7d8fb; + --c-red-600: #e04536; + --c-red-050: #fdecea; + --c-green-600: #16a34a; + --c-green-050: #e9f7ee; + --c-amber-700: #b45309; + --c-amber-050: #fdf3e3; + --c-gray-950: #121416; + --c-gray-900: #1b1e21; + --c-gray-850: #202428; + --c-gray-800: #24282d; + --c-gray-700: #343a40; + --c-gray-650: #474f57; + --c-gray-100: #f4f5f7; + --c-gray-075: #f8fafc; + --c-gray-050: #ffffff; + --c-text-900: #1f2937; + --c-text-700: #374151; + --c-text-600: #4b5563; + --c-text-500: #6b7280; + --c-text-400: #9ca3af; + --c-line-200: #e5e7eb; + --c-line-100: #eef0f3; + --c-night-text-900: #e8eaed; + --c-night-text-600: #adb5bd; + --c-night-text-400: #7f8993; + --c-night-blue: #6ca8e8; + --c-night-blue-hover: #8bbcf0; + --c-night-blue-soft: #23364a; + --c-night-red: #f06d73; + --c-night-red-soft: #40262a; + --c-night-green: #43bc8a; + --c-night-green-soft: #1d382f; + --c-night-amber: #e2ad58; + --c-night-amber-soft: #3d3220; + --c-transparent: transparent; - --space-8: 8px; - --space-16: 16px; - --space-22: 22px; - --radius-card: 10px; - --font-page-title: 17px; - --shadow-card: 0 1px 2px rgba(16, 24, 40, 0.05); + /* Primitive dimensions */ + --s-1: 1px; + --s-2: 2px; + --s-4: 4px; + --s-6: 6px; + --s-7: 7px; + --s-8: 8px; + --s-9: 9px; + --s-10: 10px; + --s-11: 11px; + --s-12: 12px; + --s-13: 13px; + --s-14: 14px; + --s-16: 16px; + --s-18: 18px; + --s-20: 20px; + --s-22: 22px; + --s-24: 24px; + --s-26: 26px; + --s-28: 28px; + --s-30: 30px; + --s-32: 32px; + --s-34: 34px; + --s-36: 36px; + --s-40: 40px; + --s-44: 44px; + --s-46: 46px; + --s-56: 56px; + --s-64: 64px; + --s-200: 200px; + --s-260: 260px; + --s-320: 320px; + --s-360: 360px; + --s-400: 400px; + --s-dialog: 512px; + --s-dialog-wide: 768px; + --s-content-max: 2200px; + --s-field: 340px; + + /* Primitive typography */ + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + --font-serif: "Kaiti SC", "STKaiti", "KaiTi", "STSong", "SimSun", serif; + --font-10-5: 10.5px; + --font-11: 11px; + --font-11-5: 11.5px; + --font-12: 12px; + --font-12-5: 12.5px; + --font-13: 13px; + --font-14: 14px; + --font-15: 15px; + --font-17: 17px; + --font-18: 18px; + --weight-400: 400; + --weight-500: 500; + --weight-600: 600; + --weight-700: 700; + --weight-800: 800; + + /* Primitive effects */ + --radius-4: 4px; + --radius-5: 5px; + --radius-6: 6px; + --radius-7: 7px; + --radius-10: 10px; + --radius-round: 999px; + --shadow-card-light: 0 1px 2px rgba(16, 24, 40, 0.05); + --shadow-card-dark: 0 1px 2px rgba(0, 0, 0, 0.28); + --shadow-float: 0 12px 32px rgba(0, 0, 0, 0.18); + --duration-fast: 160ms; + --duration-normal: 220ms; + --ease-standard: ease; + --opacity-disabled: 0.52; + --opacity-muted: 0.72; + --scale-pulse: 0.84; + --z-table: 2; + --z-topbar: 50; + --z-status: 55; + --z-sidebar: 60; + --z-mobile-nav: 70; + --z-popover: 100; + --z-dialog: 200; + --z-toast: 240; + + /* Semantic tokens */ + --color-canvas: var(--c-gray-100); + --color-surface: var(--c-gray-050); + --color-surface-muted: var(--c-gray-075); + --color-surface-raised: var(--c-gray-050); + --color-text: var(--c-text-900); + --color-text-secondary: var(--c-text-500); + --color-text-faint: var(--c-text-400); + --color-border: var(--c-line-200); + --color-divider: var(--c-line-100); + --color-primary: var(--c-blue-600); + --color-primary-hover: var(--c-blue-700); + --color-primary-soft: var(--c-blue-050); + --color-primary-border: var(--c-blue-200); + --color-up: var(--c-red-600); + --color-up-soft: var(--c-red-050); + --color-down: var(--c-green-600); + --color-down-soft: var(--c-green-050); + --color-warning: var(--c-amber-700); + --color-warning-soft: var(--c-amber-050); + --color-overlay: rgba(18, 20, 22, 0.46); + --shadow-card: var(--shadow-card-light); + + /* Component tokens */ + --shell-sidebar-width: var(--s-200); + --shell-topbar-height: var(--s-46); + --shell-summary-height: var(--s-36); + --shell-status-height: var(--s-30); + --shell-mobile-nav-height: var(--s-56); + --page-padding-y: var(--s-14); + --page-padding-x: var(--s-16); + --layout-gap: var(--s-12); + --control-height: var(--s-32); + --touch-height: var(--s-44); + --card-radius: var(--radius-10); + --control-radius: var(--radius-7); + --tag-radius: var(--radius-5); + --focus-ring: 0 0 0 var(--s-2) var(--color-canvas), 0 0 0 var(--s-4) var(--color-primary); } :root[data-theme="dark"] { color-scheme: dark; - color: #e8eaed; - background: #121416; - - --canvas: #121416; - --surface: #1b1e21; - --border: #343a40; - --text-primary: #e8eaed; - --text-secondary: #adb5bd; - --primary: #6ca8e8; - --warning: #e2ad58; - --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.28); + --color-canvas: var(--c-gray-950); + --color-surface: var(--c-gray-900); + --color-surface-muted: var(--c-gray-850); + --color-surface-raised: var(--c-gray-800); + --color-text: var(--c-night-text-900); + --color-text-secondary: var(--c-night-text-600); + --color-text-faint: var(--c-night-text-400); + --color-border: var(--c-gray-700); + --color-divider: var(--c-gray-700); + --color-primary: var(--c-night-blue); + --color-primary-hover: var(--c-night-blue-hover); + --color-primary-soft: var(--c-night-blue-soft); + --color-primary-border: var(--c-gray-650); + --color-up: var(--c-night-red); + --color-up-soft: var(--c-night-red-soft); + --color-down: var(--c-night-green); + --color-down-soft: var(--c-night-green-soft); + --color-warning: var(--c-night-amber); + --color-warning-soft: var(--c-night-amber-soft); + --color-overlay: rgba(0, 0, 0, 0.64); + --shadow-card: var(--shadow-card-dark); } diff --git a/next/playwright.config.js b/next/playwright.config.js new file mode 100644 index 0000000..7abb2df --- /dev/null +++ b/next/playwright.config.js @@ -0,0 +1,16 @@ +const { defineConfig } = require("@playwright/test"); + +module.exports = defineConfig({ + testDir: "./tests/e2e", + outputDir: "./data/playwright", + timeout: 30_000, + fullyParallel: false, + reporter: "line", + use: { + baseURL: "http://127.0.0.1:5173", + channel: "msedge", + headless: true, + viewport: { width: 1920, height: 1080 }, + trace: "retain-on-failure", + }, +}); diff --git a/next/tests/e2e/stage4.spec.js b/next/tests/e2e/stage4.spec.js new file mode 100644 index 0000000..13f00f9 --- /dev/null +++ b/next/tests/e2e/stage4.spec.js @@ -0,0 +1,132 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const { expect, test } = require("@playwright/test"); + +const evidence = path.resolve(__dirname, "../../docs/evidence/stage-4"); + +test.beforeAll(() => fs.mkdirSync(evidence, { recursive: true })); + +async function authenticate(page, username, password) { + await page.goto("/"); + await expect(page.getByRole("heading", { name: "小白复盘" })).toBeVisible(); + await page.getByLabel("账号名").fill(username); + await page.getByLabel("密码").fill(password); + await page.getByRole("button", { name: "登录", exact: true }).click(); + await expect(page.locator(".sidebar, .field-error")).toBeVisible(); + if (!(await page.locator(".sidebar").isVisible())) { + await page.getByRole("tab", { name: "注册" }).click(); + await page.getByRole("button", { name: "注册并登录" }).click(); + } + await expect(page.getByRole("navigation", { name: "主导航", exact: true })).toBeVisible(); +} + +test("authentication, shell, dialogs, theme and responsive structure", async ({ page }) => { + const consoleErrors = []; + const unexpectedResponses = []; + page.on("console", (message) => { + if (message.type() === "error" && !message.text().includes("401 (Unauthorized)")) { + consoleErrors.push(message.text()); + } + }); + page.on("response", (response) => { + const expectedAnonymousProbe = response.status() === 401 && response.url().endsWith("/api/auth/session"); + if (response.status() >= 400 && !expectedAnonymousProbe) { + unexpectedResponses.push(`${response.status()} ${response.url()}`); + } + }); + + await authenticate(page, "stage4admin", "Stage4-pass-123!"); + await expect(page.getByText("管理员", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "系统管理" })).toBeVisible(); + await expect(page.locator(".nav-item")).toHaveCount(16); + + await page.getByRole("button", { name: /stage4admin/ }).click(); + const accountMenu = page.locator(".account-menu"); + await expect(accountMenu.getByRole("button")).toHaveCount(5); + await accountMenu.getByRole("button", { name: "个人资料" }).click(); + const profileDialog = page.getByRole("dialog", { name: "个人资料" }); + await expect(profileDialog).toBeVisible(); + await profileDialog.getByLabel("出生日期").fill("1990-03-08"); + await profileDialog.getByLabel("出生时间").fill("08:30"); + await profileDialog.getByLabel("性别").selectOption("male"); + await profileDialog.getByRole("button", { name: "保存", exact: true }).click(); + await expect(page.getByRole("status")).toContainText("个人资料已保存"); + await page.getByLabel("关闭").click(); + + await page.getByRole("button", { name: "普通用户" }).click(); + await expect(page.getByRole("dialog", { name: "会员状态" })).toContainText("智能选股、问师、问天、复盘助手"); + await page.getByLabel("关闭").click(); + + await page.getByRole("button", { name: /stage4admin/ }).click(); + await page.locator(".account-menu").getByRole("button", { name: "修改密码" }).click(); + const passwordDialog = page.getByRole("dialog", { name: "修改密码" }); + await expect(passwordDialog.getByLabel("当前密码")).toBeVisible(); + await expect(passwordDialog.getByLabel("确认新密码")).toBeVisible(); + await page.getByLabel("关闭").click(); + + await page.keyboard.press("Control+K"); + const searchDialog = page.getByRole("dialog", { name: "全局搜索" }); + await expect(searchDialog).toBeVisible(); + const dialogBox = await searchDialog.boundingBox(); + expect(dialogBox).not.toBeNull(); + expect(Math.abs(dialogBox.x + dialogBox.width / 2 - 960)).toBeLessThan(2); + await page.keyboard.press("Escape"); + + const desktopMetrics = await page.evaluate(() => ({ + topbar: document.querySelector(".topbar")?.getBoundingClientRect().height, + sidebar: document.querySelector(".sidebar")?.getBoundingClientRect().width, + paddingTop: getComputedStyle(document.querySelector(".page-frame")).paddingTop, + paddingLeft: getComputedStyle(document.querySelector(".page-frame")).paddingLeft, + overflow: document.documentElement.scrollWidth - window.innerWidth, + })); + expect(desktopMetrics).toEqual({ topbar: 46, sidebar: 200, paddingTop: "14px", paddingLeft: "16px", overflow: 0 }); + await page.screenshot({ path: path.join(evidence, "shell-light-1920x1080.jpg"), type: "jpeg", quality: 82 }); + + await page.getByRole("button", { name: "夜间" }).click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + await expect(page.getByRole("button", { name: "日间" })).toBeVisible(); + await page.screenshot({ path: path.join(evidence, "shell-dark-1920x1080.jpg"), type: "jpeg", quality: 82 }); + + await page.getByRole("button", { name: "系统管理" }).click(); + await expect(page.getByRole("heading", { name: "系统管理" })).toBeVisible(); + await expect(page.getByRole("tab")).toHaveCount(0); + await expect(page.getByRole("button", { name: "行情管理" })).toBeVisible(); + await expect(page.getByRole("button", { name: "模型池" })).toBeVisible(); + await expect(page.getByRole("button", { name: "会员管理" })).toBeVisible(); + await page.getByRole("button", { name: "模型池" }).click(); + await expect(page.getByRole("heading", { name: "添加模型" })).toBeVisible(); + await page.getByRole("button", { name: "会员管理" }).click(); + await expect(page.getByRole("heading", { name: "账号会员状态" })).toBeVisible(); + await page.getByRole("button", { name: "行情管理" }).click(); + + await page.setViewportSize({ width: 3840, height: 2160 }); + expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBe(0); + await page.screenshot({ path: path.join(evidence, "system-dark-3840x2160.jpg"), type: "jpeg", quality: 72 }); + + await page.goto("/workspace/emotion"); + await page.setViewportSize({ width: 390, height: 844 }); + await expect(page.getByRole("navigation", { name: "移动端主导航" })).toBeVisible(); + await expect(page.getByRole("navigation", { name: "主导航", exact: true })).toBeHidden(); + expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBe(0); + await page.screenshot({ path: path.join(evidence, "shell-dark-390x844.jpg"), type: "jpeg", quality: 82 }); + + expect(consoleErrors).toEqual([]); + expect(unexpectedResponses).toEqual([]); +}); + +test("regular users do not receive administrator controls and see intelligent locks", async ({ page }) => { + await authenticate(page, "stage4user", "Stage4-user-123!"); + await expect(page.getByText("管理员", { exact: true })).toHaveCount(0); + await expect(page.getByRole("button", { name: "系统管理" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "后台刷新" })).toHaveCount(0); + + await page.goto("/system"); + await expect(page).toHaveURL(/\/workspace\/emotion$/); + + await page.goto("/workspace/screener"); + await expect(page.getByText("智能选股仅对会员开放")).toBeVisible(); + await expect(page.getByRole("button", { name: "查看会员状态" })).toBeVisible(); +});