rebuild(stage-4): deliver shared shell and account interfaces
This commit is contained in:
@@ -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当作全部移动页面验收。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
@@ -4,6 +4,17 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<link rel="icon" href="data:," />
|
||||
<script>
|
||||
(() => {
|
||||
const stored = localStorage.getItem("xiaobai-theme");
|
||||
const theme = stored === "light" || stored === "dark"
|
||||
? stored
|
||||
: matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
})();
|
||||
</script>
|
||||
<title>小白复盘</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,3 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
|
||||
import { useSessionStore } from "../shared/stores/session";
|
||||
import AppShell from "./shell/AppShell.vue";
|
||||
import AuthView from "./views/AuthView.vue";
|
||||
|
||||
const session = useSessionStore();
|
||||
const restoreFailure = ref("");
|
||||
|
||||
async function restore(): Promise<void> {
|
||||
restoreFailure.value = "";
|
||||
try {
|
||||
await session.restore();
|
||||
} catch (error) {
|
||||
restoreFailure.value = error instanceof Error ? error.message : "服务连接失败";
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(restore);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<main v-if="!session.initialized || restoreFailure" class="app-loading" aria-live="polite">
|
||||
<div class="loading-stack">
|
||||
<p>{{ restoreFailure || "正在载入复盘工作台" }}</p>
|
||||
<button v-if="restoreFailure" class="btn" type="button" @click="restore">重试</button>
|
||||
</div>
|
||||
</main>
|
||||
<AppShell v-else-if="session.account" />
|
||||
<AuthView v-else />
|
||||
</template>
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted } from "vue";
|
||||
|
||||
import DialogHost from "../../shared/components/DialogHost.vue";
|
||||
import ToastHost from "../../shared/components/ToastHost.vue";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
import DesktopSidebar from "./DesktopSidebar.vue";
|
||||
import MarketStrip from "./MarketStrip.vue";
|
||||
import MobileNav from "./MobileNav.vue";
|
||||
import StatusBar from "./StatusBar.vue";
|
||||
import TopBar from "./TopBar.vue";
|
||||
|
||||
const ui = useUiStore();
|
||||
|
||||
function globalShortcut(event: KeyboardEvent): void {
|
||||
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "k") return;
|
||||
if (event.defaultPrevented) {
|
||||
ui.showToast("Ctrl+K 已被其他功能占用");
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
ui.openDialog("search");
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener("keydown", globalShortcut));
|
||||
onBeforeUnmount(() => window.removeEventListener("keydown", globalShortcut));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<DesktopSidebar />
|
||||
<div class="shell-main">
|
||||
<TopBar />
|
||||
<MarketStrip />
|
||||
<RouterView />
|
||||
</div>
|
||||
<StatusBar />
|
||||
<MobileNav />
|
||||
<DialogHost />
|
||||
<ToastHost />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { workspaceGroups, workspaces } from "../workspaceRegistry";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<span class="brand-mark">复</span>
|
||||
<span class="brand-name">小白复盘</span>
|
||||
</div>
|
||||
<nav class="sidebar-nav" aria-label="主导航">
|
||||
<template v-for="group in workspaceGroups" :key="group">
|
||||
<div class="nav-group-label">{{ group }}</div>
|
||||
<RouterLink
|
||||
v-for="workspace in workspaces.filter((item) => item.group === group)"
|
||||
:key="workspace.key"
|
||||
class="nav-item"
|
||||
:to="`/workspace/${workspace.key}`"
|
||||
>
|
||||
<span class="nav-mark" aria-hidden="true">{{ workspace.mark }}</span>
|
||||
<span>{{ workspace.title }}</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
|
||||
const expanded = ref(false);
|
||||
const cells = ["市场情绪", "涨停", "跌停", "炸板", "封板率", "两市成交", "数据日期"];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="market-strip" aria-label="市场摘要">
|
||||
<div class="market-strip-row">
|
||||
<span class="market-emotion"><span class="emotion-dot" aria-hidden="true"></span>市场情绪</span>
|
||||
<span>涨停</span>
|
||||
<span>跌停</span>
|
||||
<span>炸板</span>
|
||||
<span>封板率</span>
|
||||
<span>两市成交</span>
|
||||
<span class="faint">等待最新行情</span>
|
||||
<button class="market-toggle" type="button" :aria-expanded="expanded" @click="expanded = !expanded">
|
||||
{{ expanded ? "收起详情" : "展开详情" }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="expanded" class="market-details">
|
||||
<div v-for="cell in cells" :key="cell" class="market-cell">
|
||||
<div class="market-cell-label">{{ cell }}</div>
|
||||
<div class="market-cell-value"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<nav class="mobile-nav" aria-label="移动端主导航">
|
||||
<RouterLink class="mobile-nav-item" to="/workspace/emotion">行情</RouterLink>
|
||||
<RouterLink class="mobile-nav-item" to="/workspace/screener">选股</RouterLink>
|
||||
<RouterLink class="mobile-nav-item" to="/workspace/mentor">问师</RouterLink>
|
||||
<RouterLink class="mobile-nav-item" to="/workspace/heaven">问天</RouterLink>
|
||||
<RouterLink class="mobile-nav-item" to="/workspace/review">复盘</RouterLink>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
import { findWorkspace } from "../workspaceRegistry";
|
||||
|
||||
const route = useRoute();
|
||||
const title = computed(() => findWorkspace(String(route.params.workspace ?? ""))?.title ?? "系统管理");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="statusbar">
|
||||
<span>{{ title }}</span>
|
||||
<span class="statusbar-center">股市有风险,投资需谨慎</span>
|
||||
<span class="statusbar-right">等待行情数据</span>
|
||||
</footer>
|
||||
</template>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
import { useMarketStore } from "../../shared/stores/market";
|
||||
import { useSessionStore } from "../../shared/stores/session";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
import { findWorkspace } from "../workspaceRegistry";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const market = useMarketStore();
|
||||
const session = useSessionStore();
|
||||
const ui = useUiStore();
|
||||
const menuOpen = ref(false);
|
||||
const menuRoot = ref<HTMLElement | null>(null);
|
||||
|
||||
const title = computed(() => {
|
||||
if (route.name === "system") return "系统管理";
|
||||
return findWorkspace(String(route.params.workspace ?? "emotion"))?.title ?? "小白复盘";
|
||||
});
|
||||
|
||||
function closeOnOutside(event: MouseEvent): void {
|
||||
if (!menuRoot.value?.contains(event.target as Node)) menuOpen.value = false;
|
||||
}
|
||||
|
||||
function openAccountDialog(name: "profile" | "membership" | "password"): void {
|
||||
menuOpen.value = false;
|
||||
ui.openDialog(name);
|
||||
}
|
||||
|
||||
function changeDate(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const value = input.value.trim();
|
||||
const parsed = new Date(`${value}T12:00:00+08:00`);
|
||||
if (
|
||||
/^\d{4}-\d{2}-\d{2}$/.test(value) &&
|
||||
!Number.isNaN(parsed.valueOf()) &&
|
||||
parsed.toISOString().slice(0, 10) === value
|
||||
) {
|
||||
market.selectedDate = value;
|
||||
return;
|
||||
}
|
||||
input.value = market.selectedDate;
|
||||
ui.showToast("请输入 YYYY-MM-DD 格式的日期");
|
||||
}
|
||||
|
||||
async function endSession(action: "logout" | "switch-account"): Promise<void> {
|
||||
menuOpen.value = false;
|
||||
try {
|
||||
await session.endSession(action);
|
||||
await router.replace("/workspace/emotion");
|
||||
} catch (error) {
|
||||
ui.showToast(error instanceof Error ? error.message : "操作失败,请稍后重试。");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener("mousedown", closeOnOutside));
|
||||
onBeforeUnmount(() => document.removeEventListener("mousedown", closeOnOutside));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="topbar">
|
||||
<span class="topbar-title">{{ title }}</span>
|
||||
<div class="topbar-spacer"></div>
|
||||
<div class="date-control desktop-only">
|
||||
<button class="icon-button" type="button" aria-label="前一日" title="前一日" @click="market.moveDate(-1)">‹</button>
|
||||
<input :value="market.selectedDate" class="date-input" type="text" inputmode="numeric" pattern="\d{4}-\d{2}-\d{2}" maxlength="10" aria-label="交易日期" @change="changeDate" />
|
||||
<button class="icon-button" type="button" aria-label="后一日" title="后一日" @click="market.moveDate(1)">›</button>
|
||||
</div>
|
||||
<button class="icon-button" type="button" aria-label="全局搜索" title="全局搜索(Ctrl+K)" @click="ui.openDialog('search')">⌕</button>
|
||||
<button class="icon-button desktop-only" type="button" aria-label="提醒中心" title="提醒中心" @click="ui.showToast('暂无新提醒')">!</button>
|
||||
<button class="btn btn-small" type="button" :title="ui.theme === 'light' ? '切换夜间模式' : '切换日间模式'" @click="ui.toggleTheme">
|
||||
{{ ui.theme === "light" ? "夜间" : "日间" }}
|
||||
</button>
|
||||
<button v-if="session.isAdmin" class="btn btn-small desktop-only" type="button" @click="ui.showToast('行情刷新任务暂不可用')">后台刷新</button>
|
||||
<button v-if="session.isAdmin" class="btn btn-small desktop-only" type="button" @click="router.push('/system')">系统管理</button>
|
||||
<div class="identity-cluster">
|
||||
<button v-if="session.isMember" class="tag tag-vip" type="button" title="查看会员状态" @click="ui.openDialog('membership')">V 会员</button>
|
||||
<span v-if="session.isAdmin" class="tag tag-admin">管理员</span>
|
||||
<button v-if="!session.isMember" class="tag desktop-only" type="button" @click="ui.openDialog('membership')">普通用户</button>
|
||||
</div>
|
||||
<div ref="menuRoot" class="account-menu-wrap">
|
||||
<button class="btn btn-small account-trigger" type="button" :aria-expanded="menuOpen" @click="menuOpen = !menuOpen">
|
||||
<span class="account-name">{{ session.account?.username }}</span><span class="mobile-account-label">账户</span><span aria-hidden="true">⌄</span>
|
||||
</button>
|
||||
<div v-if="menuOpen" class="account-menu">
|
||||
<button type="button" @click="openAccountDialog('profile')">个人资料</button>
|
||||
<button type="button" @click="openAccountDialog('membership')">会员状态</button>
|
||||
<button type="button" @click="openAccountDialog('password')">修改密码</button>
|
||||
<button type="button" @click="endSession('switch-account')">切换账号</button>
|
||||
<button type="button" @click="endSession('logout')">退出</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
|
||||
import { api } from "../../shared/api/client";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
|
||||
type CredentialStatus = { name: string; configured: boolean; updated_at: string | null };
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
tushare_token: "Tushare Token",
|
||||
ifind_refresh_token: "iFinD Refresh Token",
|
||||
ifind_access_token: "iFinD Access Token",
|
||||
};
|
||||
const ui = useUiStore();
|
||||
const statuses = ref<CredentialStatus[]>([]);
|
||||
const values = reactive<Record<string, string>>({});
|
||||
const loading = ref(true);
|
||||
const errorMessage = ref("");
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
statuses.value = await api.get<CredentialStatus[]>("/admin/system/credentials");
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "行情凭据读取失败。";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(name: string): Promise<void> {
|
||||
const value = values[name]?.trim();
|
||||
if (!value) {
|
||||
errorMessage.value = "请输入需要保存的凭据。";
|
||||
return;
|
||||
}
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
await api.put(`/admin/system/credentials/${name}`, { value });
|
||||
values[name] = "";
|
||||
await load();
|
||||
ui.showToast("行情凭据已保存");
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "保存失败,请稍后重试。";
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="system-stack">
|
||||
<section class="card">
|
||||
<header class="card-header"><h2>平台行情凭据</h2><span class="faint">保存后不再返回明文</span></header>
|
||||
<div class="card-body credential-list">
|
||||
<p v-if="loading" class="muted">正在读取行情配置</p>
|
||||
<div v-for="status in statuses" v-else :key="status.name" class="credential-row">
|
||||
<div class="credential-meta">
|
||||
<strong>{{ labels[status.name] ?? status.name }}</strong>
|
||||
<span :class="status.configured ? 'up' : 'faint'">{{ status.configured ? "已配置" : "未配置" }}</span>
|
||||
</div>
|
||||
<input v-model="values[status.name]" class="input" type="password" autocomplete="off" placeholder="输入新值后保存" />
|
||||
<button class="btn" type="button" @click="save(status.name)">保存</button>
|
||||
</div>
|
||||
<p v-if="errorMessage" class="field-error" role="alert">{{ errorMessage }}</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<header class="card-header"><h2>历史数据回补</h2><span class="tag">暂不可用</span></header>
|
||||
<div class="card-body disabled-row">
|
||||
<p class="muted">历史数据回补将在行情数据迁移完成后开放。</p>
|
||||
<button class="btn" type="button" disabled>开始回补</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
|
||||
import { api } from "../../shared/api/client";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
|
||||
type Membership = {
|
||||
status: string;
|
||||
active: boolean;
|
||||
is_permanent: boolean;
|
||||
expires_at: string | null;
|
||||
daily_limit: number;
|
||||
};
|
||||
type AccountMembership = { user_id: number; username: string; is_admin: boolean; membership: Membership };
|
||||
|
||||
const ui = useUiStore();
|
||||
const accounts = ref<AccountMembership[]>([]);
|
||||
const selectedId = ref<number | null>(null);
|
||||
const duration = ref("1_month");
|
||||
const dailyLimit = ref(50);
|
||||
const errorMessage = ref("");
|
||||
const confirmDisable = ref(false);
|
||||
const selected = computed(() => accounts.value.find((item) => item.user_id === selectedId.value) ?? null);
|
||||
const limitDescending = ref(false);
|
||||
const sortedAccounts = computed(() =>
|
||||
[...accounts.value].sort((left, right) => {
|
||||
const difference = left.membership.daily_limit - right.membership.daily_limit;
|
||||
return limitDescending.value ? -difference : difference;
|
||||
}),
|
||||
);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
accounts.value = await api.get<AccountMembership[]>("/admin/memberships");
|
||||
if (selectedId.value === null && accounts.value[0]) select(accounts.value[0]);
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "会员列表读取失败。";
|
||||
}
|
||||
}
|
||||
|
||||
function select(account: AccountMembership): void {
|
||||
selectedId.value = account.user_id;
|
||||
dailyLimit.value = account.membership.daily_limit;
|
||||
confirmDisable.value = false;
|
||||
}
|
||||
|
||||
async function disable(): Promise<void> {
|
||||
if (!confirmDisable.value) {
|
||||
confirmDisable.value = true;
|
||||
return;
|
||||
}
|
||||
await update("disable");
|
||||
confirmDisable.value = false;
|
||||
}
|
||||
|
||||
async function update(action: "activate" | "disable" | "set_limit"): Promise<void> {
|
||||
if (!selected.value) return;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
await api.patch(`/admin/memberships/${selected.value.user_id}`, {
|
||||
action,
|
||||
...(action === "activate" ? { duration: duration.value } : {}),
|
||||
daily_limit: dailyLimit.value,
|
||||
});
|
||||
await load();
|
||||
ui.showToast(action === "disable" ? "会员已停用" : "会员设置已保存");
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "会员设置保存失败。";
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="system-grid member-grid">
|
||||
<section class="card">
|
||||
<header class="card-header"><h2>账号会员状态</h2><span class="tag">{{ accounts.length }} 个账号</span></header>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>账号</th><th>身份</th><th>会员状态</th><th>到期时间</th><th class="num" :aria-sort="limitDescending ? 'descending' : 'ascending'"><button class="sort-button" type="button" @click="limitDescending = !limitDescending">每日上限(次) <span aria-hidden="true">{{ limitDescending ? "↓" : "↑" }}</span></button></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="account in sortedAccounts" :key="account.user_id" :class="{ selected: selectedId === account.user_id }" @click="select(account)">
|
||||
<td class="name-cell">{{ account.username }}</td><td>{{ account.is_admin ? "管理员" : "用户" }}</td><td>{{ account.membership.active ? "有效" : account.membership.status === "disabled" ? "停用" : "未开通" }}</td><td class="numeric">{{ account.membership.is_permanent ? "永久" : account.membership.expires_at?.slice(0, 10) || "" }}</td><td class="num numeric">{{ account.membership.daily_limit }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card member-editor">
|
||||
<header class="card-header"><h2>会员设置</h2></header>
|
||||
<div v-if="selected" class="card-body form-grid">
|
||||
<div class="selected-account"><strong>{{ selected.username }}</strong><span class="tag">{{ selected.membership.active ? "有效" : "未开通" }}</span></div>
|
||||
<div class="field"><label for="membership-duration">开通或续期时长</label><select id="membership-duration" v-model="duration" class="select"><option value="1_month">1个月</option><option value="3_months">3个月</option><option value="12_months">12个月</option><option value="3_years">3年</option><option value="permanent">永久</option></select></div>
|
||||
<div class="field"><label for="daily-limit">每日智能分析上限</label><input id="daily-limit" v-model.number="dailyLimit" class="input numeric" type="number" min="1" max="1000" /></div>
|
||||
<p v-if="errorMessage" class="field-error" role="alert">{{ errorMessage }}</p>
|
||||
<button class="btn btn-primary" type="button" @click="update('activate')">开通 / 续期</button>
|
||||
<button class="btn" type="button" @click="update('set_limit')">保存每日上限</button>
|
||||
<button class="btn" type="button" :disabled="!selected.membership.active" @click="disable">{{ confirmDisable ? "确认停用" : "停用会员" }}</button>
|
||||
</div>
|
||||
<p v-else class="panel-loading muted">请选择账号</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
|
||||
import { api } from "../../shared/api/client";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
|
||||
type ModelItem = {
|
||||
id: number;
|
||||
display_name: string;
|
||||
base_url: string;
|
||||
model_identifier: string;
|
||||
has_api_key: boolean;
|
||||
is_primary: boolean;
|
||||
is_fallback: boolean;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
const ui = useUiStore();
|
||||
const models = ref<ModelItem[]>([]);
|
||||
const editingId = ref<number | null>(null);
|
||||
const primaryId = ref<number | null>(null);
|
||||
const fallbackId = ref<number | null>(null);
|
||||
const errorMessage = ref("");
|
||||
const deleteConfirmation = ref<number | null>(null);
|
||||
const form = reactive({ display_name: "", base_url: "", model_identifier: "", api_key: "" });
|
||||
const editing = computed(() => models.value.find((item) => item.id === editingId.value) ?? null);
|
||||
|
||||
function resetForm(): void {
|
||||
editingId.value = null;
|
||||
Object.assign(form, { display_name: "", base_url: "", model_identifier: "", api_key: "" });
|
||||
}
|
||||
|
||||
function edit(model: ModelItem): void {
|
||||
deleteConfirmation.value = null;
|
||||
editingId.value = model.id;
|
||||
Object.assign(form, {
|
||||
display_name: model.display_name,
|
||||
base_url: model.base_url,
|
||||
model_identifier: model.model_identifier,
|
||||
api_key: "",
|
||||
});
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
models.value = await api.get<ModelItem[]>("/admin/models");
|
||||
primaryId.value = models.value.find((item) => item.is_primary)?.id ?? null;
|
||||
fallbackId.value = models.value.find((item) => item.is_fallback)?.id ?? null;
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "模型池读取失败。";
|
||||
}
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
errorMessage.value = "";
|
||||
const wasEditing = editingId.value !== null;
|
||||
try {
|
||||
const payload = {
|
||||
display_name: form.display_name,
|
||||
base_url: form.base_url,
|
||||
model_identifier: form.model_identifier,
|
||||
...(form.api_key ? { api_key: form.api_key } : {}),
|
||||
};
|
||||
if (editingId.value) {
|
||||
await api.put(`/admin/models/${editingId.value}`, payload);
|
||||
} else {
|
||||
if (!form.api_key) {
|
||||
errorMessage.value = "新增模型时必须填写密钥。";
|
||||
return;
|
||||
}
|
||||
await api.post("/admin/models", payload);
|
||||
}
|
||||
resetForm();
|
||||
await load();
|
||||
ui.showToast(wasEditing ? "模型已更新" : "模型已添加");
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "模型保存失败。";
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSelection(): Promise<void> {
|
||||
if (!primaryId.value) {
|
||||
errorMessage.value = "请选择主模型。";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.put("/admin/models/selection", {
|
||||
primary_model_id: primaryId.value,
|
||||
fallback_model_id: fallbackId.value,
|
||||
});
|
||||
await load();
|
||||
ui.showToast("主模型和辅助模型已更新");
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "模型选择保存失败。";
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(model: ModelItem): Promise<void> {
|
||||
if (deleteConfirmation.value !== model.id) {
|
||||
deleteConfirmation.value = model.id;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.delete(`/admin/models/${model.id}`);
|
||||
if (editingId.value === model.id) resetForm();
|
||||
await load();
|
||||
deleteConfirmation.value = null;
|
||||
ui.showToast("模型已删除");
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "模型删除失败。";
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="system-grid model-grid">
|
||||
<section class="card">
|
||||
<header class="card-header"><h2>模型池</h2><span class="tag">{{ models.length }} / 20</span></header>
|
||||
<div class="model-list">
|
||||
<button v-for="model in models" :key="model.id" class="model-row" :class="{ active: editingId === model.id }" type="button" @click="edit(model)">
|
||||
<span><strong>{{ model.display_name }}</strong><small>{{ model.model_identifier }}</small></span>
|
||||
<span class="model-tags"><span v-if="model.is_primary" class="tag">主模型</span><span v-if="model.is_fallback" class="tag">辅助</span></span>
|
||||
</button>
|
||||
<p v-if="!models.length" class="panel-loading muted">尚未添加模型</p>
|
||||
</div>
|
||||
</section>
|
||||
<div class="system-stack">
|
||||
<section class="card">
|
||||
<header class="card-header"><h2>{{ editing ? "编辑模型" : "添加模型" }}</h2><button v-if="editing" class="btn btn-small" type="button" @click="resetForm">取消编辑</button></header>
|
||||
<form class="card-body form-grid" @submit.prevent="save">
|
||||
<div class="field"><label for="model-name">显示名称</label><input id="model-name" v-model="form.display_name" class="input" required /></div>
|
||||
<div class="field"><label for="model-url">服务地址</label><input id="model-url" v-model="form.base_url" class="input" type="url" required /></div>
|
||||
<div class="field"><label for="model-identifier">模型标识</label><input id="model-identifier" v-model="form.model_identifier" class="input" required /></div>
|
||||
<div class="field"><label for="model-key">密钥</label><input id="model-key" v-model="form.api_key" class="input" type="password" autocomplete="off" :placeholder="editing ? '留空则保持原密钥' : '请输入密钥'" /></div>
|
||||
<p v-if="errorMessage" class="field-error" role="alert">{{ errorMessage }}</p>
|
||||
<div class="form-actions"><button v-if="editing && !editing.is_primary && !editing.is_fallback" class="btn" type="button" @click="remove(editing)">{{ deleteConfirmation === editing.id ? "确认删除" : "删除" }}</button><button class="btn btn-primary" type="submit">{{ editing ? "保存修改" : "添加模型" }}</button></div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="card">
|
||||
<header class="card-header"><h2>调用顺序</h2></header>
|
||||
<form class="card-body selection-grid" @submit.prevent="saveSelection">
|
||||
<div class="field"><label for="primary-model">主模型</label><select id="primary-model" v-model="primaryId" class="select" required><option :value="null" disabled>请选择</option><option v-for="model in models" :key="model.id" :value="model.id">{{ model.display_name }}</option></select></div>
|
||||
<div class="field"><label for="fallback-model">辅助模型</label><select id="fallback-model" v-model="fallbackId" class="select"><option :value="null">关闭</option><option v-for="model in models" :key="model.id" :value="model.id">{{ model.display_name }}</option></select></div>
|
||||
<button class="btn" type="submit" :disabled="!models.length">保存选择</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
import { useSessionStore } from "../../shared/stores/session";
|
||||
|
||||
const router = useRouter();
|
||||
const session = useSessionStore();
|
||||
const mode = ref<"login" | "register">("login");
|
||||
const username = ref("");
|
||||
const password = ref("");
|
||||
const errorMessage = ref("");
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
await session.authenticate(mode.value, username.value, password.value);
|
||||
await router.replace("/workspace/emotion");
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "登录失败,请稍后重试。";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="auth-view">
|
||||
<section class="auth-panel card">
|
||||
<header class="auth-brand">
|
||||
<span class="brand-mark">复</span>
|
||||
<div><h1>小白复盘</h1><p>进入你的复盘工作台</p></div>
|
||||
</header>
|
||||
<div class="auth-tabs" role="tablist">
|
||||
<button type="button" :class="{ active: mode === 'login' }" role="tab" @click="mode = 'login'">登录</button>
|
||||
<button type="button" :class="{ active: mode === 'register' }" role="tab" @click="mode = 'register'">注册</button>
|
||||
</div>
|
||||
<form class="auth-form" @submit.prevent="submit">
|
||||
<div class="field">
|
||||
<label for="username">账号名</label>
|
||||
<input id="username" v-model="username" class="input" autocomplete="username" placeholder="请输入账号名" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">密码</label>
|
||||
<input id="password" v-model="password" class="input" type="password" :autocomplete="mode === 'login' ? 'current-password' : 'new-password'" placeholder="请输入密码" required />
|
||||
</div>
|
||||
<p v-if="errorMessage" class="field-error" role="alert">{{ errorMessage }}</p>
|
||||
<button class="btn btn-primary auth-submit" type="submit" :disabled="session.busy">
|
||||
{{ session.busy ? "请稍候" : mode === "login" ? "登录" : "注册并登录" }}
|
||||
</button>
|
||||
</form>
|
||||
<p class="auth-risk">数据仅供复盘研究,不构成投资建议<br />股市有风险,投资需谨慎</p>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -1,64 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
|
||||
import { api } from "../../shared/api/client";
|
||||
|
||||
type Health = {
|
||||
status: string;
|
||||
environment: string;
|
||||
};
|
||||
|
||||
const health = ref<Health | null>(null);
|
||||
const failure = ref("");
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
health.value = await api.get<Health>("/health");
|
||||
} catch (error) {
|
||||
failure.value = error instanceof Error ? error.message : "服务连接失败";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="bootstrap-view">
|
||||
<section class="bootstrap-panel" aria-live="polite">
|
||||
<h1>小白复盘</h1>
|
||||
<p v-if="health">新系统基础服务已连接({{ health.environment }})</p>
|
||||
<p v-else-if="failure" class="failure">{{ failure }}</p>
|
||||
<p v-else>正在连接基础服务</p>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bootstrap-view {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--space-16);
|
||||
}
|
||||
|
||||
.bootstrap-panel {
|
||||
width: min(100%, 420px);
|
||||
padding: var(--space-22);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 var(--space-8);
|
||||
font-size: var(--font-page-title);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.failure {
|
||||
color: var(--warning);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
import { useSessionStore } from "../../shared/stores/session";
|
||||
import MarketSettingsPanel from "../system/MarketSettingsPanel.vue";
|
||||
import MembershipAdminPanel from "../system/MembershipAdminPanel.vue";
|
||||
import ModelPoolPanel from "../system/ModelPoolPanel.vue";
|
||||
|
||||
const router = useRouter();
|
||||
const session = useSessionStore();
|
||||
const section = ref<"market" | "models" | "members">("market");
|
||||
|
||||
onMounted(() => {
|
||||
if (!session.isAdmin) router.replace("/workspace/emotion");
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main v-if="session.isAdmin" class="page-frame">
|
||||
<header class="page-header system-header">
|
||||
<div><h1>系统管理</h1><p class="page-subtitle">平台行情、模型池与会员配置</p></div>
|
||||
<div class="system-tabs" role="tablist">
|
||||
<button type="button" :class="{ active: section === 'market' }" @click="section = 'market'">行情管理</button>
|
||||
<button type="button" :class="{ active: section === 'models' }" @click="section = 'models'">模型池</button>
|
||||
<button type="button" :class="{ active: section === 'members' }" @click="section = 'members'">会员管理</button>
|
||||
</div>
|
||||
</header>
|
||||
<MarketSettingsPanel v-if="section === 'market'" />
|
||||
<ModelPoolPanel v-else-if="section === 'models'" />
|
||||
<MembershipAdminPanel v-else />
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
import EmptyState from "../../shared/components/EmptyState.vue";
|
||||
import { useMarketStore } from "../../shared/stores/market";
|
||||
import { useSessionStore } from "../../shared/stores/session";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
import { findWorkspace } from "../workspaceRegistry";
|
||||
|
||||
const route = useRoute();
|
||||
const market = useMarketStore();
|
||||
const session = useSessionStore();
|
||||
const ui = useUiStore();
|
||||
const workspace = computed(() => findWorkspace(String(route.params.workspace)) ?? findWorkspace("emotion")!);
|
||||
const locked = computed(
|
||||
() => ["screener", "mentor", "heaven"].includes(workspace.value.key) && !session.account?.smart_access,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="page-frame">
|
||||
<header class="page-header">
|
||||
<h1>{{ workspace.title }}</h1>
|
||||
<p class="page-subtitle">{{ workspace.description }} · 数据日期 {{ market.selectedDate }}</p>
|
||||
</header>
|
||||
<div v-if="locked" class="notice notice-warning membership-lock">
|
||||
<span><strong>{{ workspace.title }}仅对会员开放</strong>,开通会员后可使用完整智能功能。</span>
|
||||
<button class="btn btn-small" type="button" @click="ui.openDialog('membership')">查看会员状态</button>
|
||||
</div>
|
||||
<section class="card" :class="{ 'locked-content': locked }" :aria-disabled="locked">
|
||||
<EmptyState title="暂无可显示数据" description="当前日期尚未载入该页面的有效行情数据。" />
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
|
||||
import { api } from "../api/client";
|
||||
|
||||
type Membership = {
|
||||
status: string;
|
||||
active: boolean;
|
||||
is_permanent: boolean;
|
||||
expires_at: string | null;
|
||||
remaining_days: number | null;
|
||||
daily_limit: number;
|
||||
used_today: number;
|
||||
remaining_today: number | null;
|
||||
quota_exempt: boolean;
|
||||
smart_access: boolean;
|
||||
description: string;
|
||||
usage_package_available: boolean;
|
||||
};
|
||||
|
||||
const membership = ref<Membership | null>(null);
|
||||
const errorMessage = ref("");
|
||||
const statusLabel = computed(() => {
|
||||
if (!membership.value) return "";
|
||||
if (membership.value.status === "disabled") return "已停用";
|
||||
return membership.value.active ? "已开通" : "未开通";
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
membership.value = await api.get<Membership>("/account/membership");
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "会员状态读取失败。";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p v-if="errorMessage" class="field-error" role="alert">{{ errorMessage }}</p>
|
||||
<p v-else-if="!membership" class="muted panel-loading">正在读取会员状态</p>
|
||||
<div v-else class="membership-panel">
|
||||
<p class="notice">{{ membership.description }}</p>
|
||||
<dl class="status-grid">
|
||||
<div><dt>开通状态</dt><dd>{{ statusLabel }}</dd></div>
|
||||
<div><dt>到期时间</dt><dd class="numeric">{{ membership.is_permanent ? "永久" : membership.expires_at?.slice(0, 10) || "" }}</dd></div>
|
||||
<div><dt>剩余时长</dt><dd class="numeric">{{ membership.is_permanent ? "永久" : membership.remaining_days === null ? "" : `${membership.remaining_days} 天` }}</dd></div>
|
||||
<div><dt>今日用量</dt><dd class="numeric">{{ membership.quota_exempt ? `${membership.used_today} · 不限额` : `${membership.used_today} / ${membership.daily_limit}` }}</dd></div>
|
||||
<div><dt>今日剩余</dt><dd class="numeric">{{ membership.quota_exempt ? "不限额" : membership.remaining_today }}</dd></div>
|
||||
</dl>
|
||||
<div class="comparison-table" role="table" aria-label="会员功能对比">
|
||||
<div class="comparison-row comparison-head" role="row"><span>功能</span><span>普通用户</span><span>会员用户</span></div>
|
||||
<div class="comparison-row" role="row"><span>行情与复盘</span><span>可用</span><span>可用</span></div>
|
||||
<div class="comparison-row" role="row"><span>智能选股</span><span>锁定</span><span>可用</span></div>
|
||||
<div class="comparison-row" role="row"><span>问师、问天</span><span>锁定</span><span>可用</span></div>
|
||||
<div class="comparison-row" role="row"><span>复盘助手</span><span>锁定</span><span>可用</span></div>
|
||||
</div>
|
||||
<div class="usage-package">
|
||||
<div><strong>智能分析加油包</strong><p>每日用量不足时可补充调用额度。</p></div>
|
||||
<button class="btn" type="button" disabled>暂不可用</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
|
||||
const ui = useUiStore();
|
||||
const currentPassword = ref("");
|
||||
const newPassword = ref("");
|
||||
const confirmation = ref("");
|
||||
const saving = ref(false);
|
||||
const errorMessage = ref("");
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
saving.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
await api.patch("/account/password", {
|
||||
current_password: currentPassword.value,
|
||||
new_password: newPassword.value,
|
||||
confirmation: confirmation.value,
|
||||
});
|
||||
currentPassword.value = "";
|
||||
newPassword.value = "";
|
||||
confirmation.value = "";
|
||||
ui.closeDialog();
|
||||
ui.showToast("密码已修改");
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "密码修改失败。";
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="form-grid" @submit.prevent="submit">
|
||||
<div class="field"><label for="current-password">当前密码</label><input id="current-password" v-model="currentPassword" class="input" type="password" autocomplete="current-password" required /></div>
|
||||
<div class="field"><label for="new-password">新密码</label><input id="new-password" v-model="newPassword" class="input" type="password" autocomplete="new-password" required /></div>
|
||||
<div class="field"><label for="password-confirmation">确认新密码</label><input id="password-confirmation" v-model="confirmation" class="input" type="password" autocomplete="new-password" required /></div>
|
||||
<p class="faint password-hint">密码长度8至128位,至少包含字母、数字、符号中的两类。</p>
|
||||
<p v-if="errorMessage" class="field-error" role="alert">{{ errorMessage }}</p>
|
||||
<div class="form-actions"><button class="btn btn-primary" type="submit" :disabled="saving">{{ saving ? "保存中" : "修改密码" }}</button></div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
|
||||
type Profile = {
|
||||
configured: boolean;
|
||||
birth_date: string | null;
|
||||
birth_time: string | null;
|
||||
gender: "male" | "female" | null;
|
||||
privacy_notice: string;
|
||||
};
|
||||
|
||||
const ui = useUiStore();
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const privacyNotice = ref("原始出生资料加密保存,仅当前账号可见。");
|
||||
const birthDate = ref("");
|
||||
const birthTime = ref("");
|
||||
const gender = ref<"male" | "female">("male");
|
||||
const confirmDelete = ref(false);
|
||||
|
||||
function applyProfile(profile: Profile): void {
|
||||
birthDate.value = profile.birth_date ?? "";
|
||||
birthTime.value = profile.birth_time?.slice(0, 5) ?? "";
|
||||
gender.value = profile.gender ?? "male";
|
||||
privacyNotice.value = profile.privacy_notice;
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
applyProfile(await api.get<Profile>("/account/profile"));
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "个人资料读取失败。";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
saving.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
applyProfile(
|
||||
await api.put<Profile>("/account/profile", {
|
||||
birth_date: birthDate.value,
|
||||
birth_time: birthTime.value,
|
||||
gender: gender.value,
|
||||
}),
|
||||
);
|
||||
ui.showToast("个人资料已保存");
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "保存失败,请稍后重试。";
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(): Promise<void> {
|
||||
if (!confirmDelete.value) {
|
||||
confirmDelete.value = true;
|
||||
return;
|
||||
}
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
await api.delete("/account/profile");
|
||||
birthDate.value = "";
|
||||
birthTime.value = "";
|
||||
gender.value = "male";
|
||||
confirmDelete.value = false;
|
||||
ui.showToast("个人出生资料已删除");
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "删除失败,请稍后重试。";
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p class="notice">{{ privacyNotice }}</p>
|
||||
<p v-if="loading" class="muted panel-loading">正在读取个人资料</p>
|
||||
<form v-else class="form-grid panel-form" @submit.prevent="save">
|
||||
<div class="field">
|
||||
<label for="birth-date">出生日期</label>
|
||||
<input id="birth-date" v-model="birthDate" class="input" type="date" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="birth-time">出生时间</label>
|
||||
<input id="birth-time" v-model="birthTime" class="input" type="time" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="gender">性别</label>
|
||||
<select id="gender" v-model="gender" class="select">
|
||||
<option value="male">男</option>
|
||||
<option value="female">女</option>
|
||||
</select>
|
||||
</div>
|
||||
<p v-if="errorMessage" class="field-error" role="alert">{{ errorMessage }}</p>
|
||||
<div class="form-actions">
|
||||
<button class="btn" type="button" :disabled="!birthDate" @click="remove">{{ confirmDelete ? "确认删除" : "删除资料" }}</button>
|
||||
<button class="btn btn-primary" type="submit" :disabled="saving">{{ saving ? "保存中" : "保存" }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
|
||||
const query = ref("");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="search-panel">
|
||||
<label class="sr-only" for="global-search">搜索股票、板块、题材或指数</label>
|
||||
<input id="global-search" v-model="query" class="input search-input" autofocus placeholder="搜索股票、板块、题材或指数" />
|
||||
<div class="search-categories" aria-label="搜索结果分类">
|
||||
<span>股票</span><span>板块</span><span>题材</span><span>指数</span>
|
||||
</div>
|
||||
<div class="search-empty">
|
||||
<p>{{ query ? "当前没有匹配结果" : "输入代码或名称开始搜索" }}</p>
|
||||
<span>Ctrl+K</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,10 +18,19 @@ export class ApiError extends Error {
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
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<T>(path: string): Promise<T> {
|
||||
return request<T>(path);
|
||||
},
|
||||
post<T>(path: string, body?: unknown): Promise<T> {
|
||||
return request<T>(path, json("POST", body));
|
||||
},
|
||||
put<T>(path: string, body?: unknown): Promise<T> {
|
||||
return request<T>(path, json("PUT", body));
|
||||
},
|
||||
patch<T>(path: string, body?: unknown): Promise<T> {
|
||||
return request<T>(path, json("PATCH", body));
|
||||
},
|
||||
delete<T>(path: string): Promise<T> {
|
||||
return request<T>(path, json("DELETE"));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
|
||||
const props = withDefaults(defineProps<{ title: string; wide?: boolean; closeOnBackdrop?: boolean }>(), {
|
||||
wide: false,
|
||||
closeOnBackdrop: true,
|
||||
});
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
const panel = ref<HTMLElement | null>(null);
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
|
||||
function focusableElements(): HTMLElement[] {
|
||||
if (!panel.value) return [];
|
||||
return Array.from(
|
||||
panel.value.querySelectorAll<HTMLElement>(
|
||||
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === "Escape") {
|
||||
emit("close");
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = focusableElements();
|
||||
const first = focusable[0];
|
||||
const last = focusable.at(-1);
|
||||
if (!first || !last) return;
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function backdrop(event: MouseEvent): void {
|
||||
if (props.closeOnBackdrop && event.target === event.currentTarget) emit("close");
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
await nextTick();
|
||||
focusableElements()[0]?.focus();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("keydown", handleKeydown);
|
||||
previouslyFocused?.focus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="dialog-backdrop" @mousedown="backdrop">
|
||||
<section ref="panel" class="dialog" :class="{ 'dialog-wide': wide }" role="dialog" aria-modal="true" :aria-label="title">
|
||||
<header class="dialog-header">
|
||||
<h2 class="dialog-title">{{ title }}</h2>
|
||||
<button class="icon-button dialog-close" type="button" aria-label="关闭" title="关闭" @click="emit('close')">×</button>
|
||||
</header>
|
||||
<div class="dialog-body"><slot /></div>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import ProfilePanel from "../account/ProfilePanel.vue";
|
||||
import MembershipPanel from "../account/MembershipPanel.vue";
|
||||
import PasswordPanel from "../account/PasswordPanel.vue";
|
||||
import SearchPanel from "../account/SearchPanel.vue";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
import BaseDialog from "./BaseDialog.vue";
|
||||
|
||||
const ui = useUiStore();
|
||||
const titles = { profile: "个人资料", membership: "会员状态", password: "修改密码", search: "全局搜索" } as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseDialog v-if="ui.dialog" :title="titles[ui.dialog]" :wide="ui.dialog === 'membership' || ui.dialog === 'search'" @close="ui.closeDialog">
|
||||
<ProfilePanel v-if="ui.dialog === 'profile'" />
|
||||
<MembershipPanel v-else-if="ui.dialog === 'membership'" />
|
||||
<PasswordPanel v-else-if="ui.dialog === 'password'" />
|
||||
<SearchPanel v-else />
|
||||
</BaseDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ title: string; description: string }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="empty-state">
|
||||
<span class="empty-mark" aria-hidden="true">·</span>
|
||||
<h2>{{ title }}</h2>
|
||||
<p>{{ description }}</p>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { useUiStore } from "../stores/ui";
|
||||
|
||||
const ui = useUiStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="ui.toast" class="toast" role="status" aria-live="polite">{{ ui.toast }}</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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<AccountIdentity | null>(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<void> {
|
||||
try {
|
||||
account.value = await api.get<AccountIdentity>("/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<AuthResponse>(`/auth/${mode}`, { username, password });
|
||||
account.value = response.account;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function endSession(action: "logout" | "switch-account"): Promise<void> {
|
||||
await api.post(`/auth/${action}`);
|
||||
account.value = null;
|
||||
}
|
||||
|
||||
async function refreshIdentity(): Promise<void> {
|
||||
account.value = await api.get<AccountIdentity>("/auth/session");
|
||||
}
|
||||
|
||||
return {
|
||||
account,
|
||||
initialized,
|
||||
busy,
|
||||
isAdmin,
|
||||
isMember,
|
||||
restore,
|
||||
authenticate,
|
||||
endSession,
|
||||
refreshIdentity,
|
||||
};
|
||||
});
|
||||
@@ -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<Theme>(preferredTheme());
|
||||
const dialog = ref<DialogName>(null);
|
||||
const toast = ref("");
|
||||
let toastTimer: ReturnType<typeof setTimeout> | 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<DialogName, null>): 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 };
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user