fix(admin-ui): 右上角Toast通知与弹窗遮罩修复
This commit is contained in:
1 parent
f6f98491b3
commit
0c9804f283
14 files changed
+1779
-1476
No files matched your search
@@ -4,6 +4,9 @@
|
||||
|
||||
### 变更
|
||||
|
||||
- **[admin-ui/dashboard]**: Dashboard 样板页引入 Apple 风主题(bento grid 卡片/表格/按钮重绘)
|
||||
- 方案: [202603152243_apple-ui-sample-dashboard](archive/2026-03/202603152243_apple-ui-sample-dashboard/)
|
||||
- 文件: apps/ui/src/features/DashboardView.tsx, apps/ui/src/styles.css, .helloagents/modules/admin-ui.md, .helloagents/modules/dashboard.md
|
||||
- **[sites]**: 站点类型接口字段由 claude 改为 Anthropic,并新增数据迁移脚本
|
||||
- 类型: 变更(无方案包)
|
||||
- 文件: apps/worker/src/services/site-metadata.ts, apps/worker/src/routes/sites.ts, apps/worker/src/services/channel-metadata.ts, apps/worker/src/services/provider-transform.ts, apps/worker/src/routes/proxy.ts, apps/worker/migrations/0003_rename_claude_anthropic.sql, apps/ui/src/core/types.ts, apps/ui/src/core/sites.ts, apps/ui/src/features/SitesView.tsx, apps/ui/src/App.tsx, tests/worker/provider-transform.test.ts, tests/worker/channel-metadata.test.ts, helloagents/modules/admin-ui.md, helloagents/modules/sites.md, helloagents/modules/channels.md, helloagents/modules/proxy.md
|
||||
@@ -171,6 +174,24 @@
|
||||
- 类型: 微调(无方案包)
|
||||
- 文件: apps/worker/src/routes/proxy.ts
|
||||
|
||||
### 修复
|
||||
|
||||
- **[admin-ui]**: Toast 通知固定右上并带进度条,弹窗改为全屏遮罩,筛选标题横排 — by lsy
|
||||
- 方案: [202603160049_modal-toast-search-fixes](archive/2026-03/202603160049_modal-toast-search-fixes/)
|
||||
- 决策: modal-toast-search-fixes#D001(通知统一为右上 Toast)
|
||||
|
||||
## [0.9.6] - 2026-03-15
|
||||
|
||||
### 修复
|
||||
- **[opencode-config]**: 配置 Gemini + Claude 模型并补充鉴权头 — by lsy
|
||||
- 方案: [202603152216_opencode-models-gemini-claude](archive/2026-03/202603152216_opencode-models-gemini-claude/)
|
||||
|
||||
## [0.9.5] - 2026-03-15
|
||||
|
||||
### 修复
|
||||
- **[opencode-config]**: 更新 OpenCode 模型列表与默认模型 — by lsy
|
||||
- 方案: [202603152138_opencode-model-config](archive/2026-03/202603152138_opencode-model-config/)
|
||||
|
||||
### 快速修改
|
||||
|
||||
- **[admin-ui]**: 修复模型广场渲染语法错误 — by lsy
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
- 入口文件为 `apps/ui/src/App.tsx`(`main.tsx` 已移除)
|
||||
- 按 `core/`(类型/常量/API)、`features/`(业务视图 + 布局/登录)组织前端模块
|
||||
- 样式基于 Tailwind v4(结合全局主题变量)
|
||||
- Dashboard 样板页引入 `apple-demo` 主题类(Apple 风格、bento grid)
|
||||
- 前端状态集中在单页
|
||||
- 站点管理采用列表视图,支持分页(默认 10 条),新增/编辑使用弹窗,保留启禁/连通测试操作
|
||||
- 站点管理支持名称/URL 搜索与列头排序,搜索/排序后再分页
|
||||
@@ -37,10 +38,10 @@
|
||||
- 移动端使用顶部栏 + 抽屉导航,侧边栏在桌面端保持固定
|
||||
- 渠道/令牌列表在移动端以卡片形式展示,操作区改为网格按钮排列
|
||||
- 数据面板、模型广场、使用日志表格在移动端支持横向滚动与字号收紧
|
||||
- 全局通知支持成功/提示/错误/信息分级展示,可关闭并自动消退
|
||||
- 全局通知采用右上角 Toast 展示,支持关闭与进度条自动消退
|
||||
- 删除类操作必须经过确认弹窗
|
||||
- 主要异步操作提供 loading 与按钮禁用态
|
||||
- 弹窗支持遮罩/ESC 关闭并自动聚焦首字段
|
||||
- 弹窗支持全屏遮罩/ESC 关闭,不强制自动聚焦首字段
|
||||
- 空状态提供清晰 CTA 引导下一步
|
||||
- 全局字体通过外部字体源加载,背景包含暖调渐变与轻纹理
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
## 行为规范
|
||||
- 以 `usage_logs` 为统计来源
|
||||
- 默认返回最近 30 条趋势数据
|
||||
- 前端 DashboardView 使用 `apple-demo` 样板主题(Apple 风格)
|
||||
|
||||
## 依赖关系
|
||||
- `usage_logs` 表
|
||||
|
||||
+192
-174
@@ -35,11 +35,15 @@ import type {
|
||||
SiteType,
|
||||
TabId,
|
||||
Token,
|
||||
TokenForm,
|
||||
UsageQuery,
|
||||
UsageResponse,
|
||||
TokenForm,
|
||||
} from "./core/types";
|
||||
import { toChinaDateTimeInput, toChinaIsoFromInput, toggleStatus } from "./core/utils";
|
||||
import {
|
||||
toChinaDateTimeInput,
|
||||
toChinaIsoFromInput,
|
||||
toggleStatus,
|
||||
} from "./core/utils";
|
||||
import { AppLayout } from "./features/AppLayout";
|
||||
import { DashboardView } from "./features/DashboardView";
|
||||
import { LoginView } from "./features/LoginView";
|
||||
@@ -136,15 +140,13 @@ const App = () => {
|
||||
const [tokenPage, setTokenPage] = useState(1);
|
||||
const [tokenPageSize, setTokenPageSize] = useState(10);
|
||||
const [editingToken, setEditingToken] = useState<Token | null>(null);
|
||||
const [tokenForm, setTokenForm] =
|
||||
useState<TokenForm>(initialTokenForm);
|
||||
const [tokenForm, setTokenForm] = useState<TokenForm>(initialTokenForm);
|
||||
const [usagePage, setUsagePage] = useState(1);
|
||||
const [usagePageSize, setUsagePageSize] = useState(50);
|
||||
const [usageTotal, setUsageTotal] = useState(0);
|
||||
const [usageFilters, setUsageFilters] =
|
||||
useState<UsageQuery>(initialUsageQuery);
|
||||
const [usageQuery, setUsageQuery] =
|
||||
useState<UsageQuery>(initialUsageQuery);
|
||||
const [usageQuery, setUsageQuery] = useState<UsageQuery>(initialUsageQuery);
|
||||
const [editingSite, setEditingSite] = useState<Site | null>(null);
|
||||
const [siteForm, setSiteForm] = useState<SiteForm>(() => ({
|
||||
...initialSiteForm,
|
||||
@@ -169,9 +171,12 @@ const App = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const pushNotice = useCallback((tone: NoticeTone, message: string) => {
|
||||
setNotice({ tone, message, id: Date.now() });
|
||||
}, []);
|
||||
const pushNotice = useCallback(
|
||||
(tone: NoticeTone, message: string, durationMs?: number) => {
|
||||
setNotice({ tone, message, id: Date.now(), durationMs });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const dismissNotice = useCallback(() => {
|
||||
setNotice(null);
|
||||
@@ -181,9 +186,10 @@ const App = () => {
|
||||
if (!notice) {
|
||||
return;
|
||||
}
|
||||
const durationMs = notice.durationMs ?? 4500;
|
||||
const timer = window.setTimeout(() => {
|
||||
setNotice(null);
|
||||
}, 4500);
|
||||
}, durationMs);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [notice]);
|
||||
|
||||
@@ -524,14 +530,7 @@ const App = () => {
|
||||
endAction(actionKey);
|
||||
}
|
||||
},
|
||||
[
|
||||
endAction,
|
||||
isActionPending,
|
||||
loadUsage,
|
||||
pushNotice,
|
||||
startAction,
|
||||
usagePage,
|
||||
],
|
||||
[endAction, isActionPending, loadUsage, pushNotice, startAction, usagePage],
|
||||
);
|
||||
|
||||
const handleUsagePageSizeChange = useCallback(
|
||||
@@ -554,12 +553,9 @@ const App = () => {
|
||||
[endAction, isActionPending, loadUsage, pushNotice, startAction],
|
||||
);
|
||||
|
||||
const handleUsageFiltersChange = useCallback(
|
||||
(patch: Partial<UsageQuery>) => {
|
||||
setUsageFilters((prev) => ({ ...prev, ...patch }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handleUsageFiltersChange = useCallback((patch: Partial<UsageQuery>) => {
|
||||
setUsageFilters((prev) => ({ ...prev, ...patch }));
|
||||
}, []);
|
||||
|
||||
const handleUsageSearch = useCallback(async () => {
|
||||
const actionKey = buildActionKey("usage:load");
|
||||
@@ -613,15 +609,18 @@ const App = () => {
|
||||
}
|
||||
}, [endAction, isActionPending, loadUsage, pushNotice, startAction]);
|
||||
|
||||
const handleTabChange = useCallback((tabId: TabId) => {
|
||||
const nextPath = tabToPath[tabId];
|
||||
const normalized = normalizePath(window.location.pathname);
|
||||
if (normalized !== nextPath) {
|
||||
history.pushState(null, "", nextPath);
|
||||
}
|
||||
dismissNotice();
|
||||
setActiveTab(tabId);
|
||||
}, [dismissNotice]);
|
||||
const handleTabChange = useCallback(
|
||||
(tabId: TabId) => {
|
||||
const nextPath = tabToPath[tabId];
|
||||
const normalized = normalizePath(window.location.pathname);
|
||||
if (normalized !== nextPath) {
|
||||
history.pushState(null, "", nextPath);
|
||||
}
|
||||
dismissNotice();
|
||||
setActiveTab(tabId);
|
||||
},
|
||||
[dismissNotice],
|
||||
);
|
||||
|
||||
const closeSiteModal = useCallback(() => {
|
||||
setEditingSite(null);
|
||||
@@ -643,48 +642,51 @@ const App = () => {
|
||||
dismissNotice();
|
||||
}, [dismissNotice]);
|
||||
|
||||
const startSiteEdit = useCallback((site: Site) => {
|
||||
setEditingSite(site);
|
||||
const callTokens =
|
||||
site.call_tokens && site.call_tokens.length > 0
|
||||
? site.call_tokens
|
||||
: site.api_key
|
||||
? [
|
||||
const startSiteEdit = useCallback(
|
||||
(site: Site) => {
|
||||
setEditingSite(site);
|
||||
const callTokens =
|
||||
site.call_tokens && site.call_tokens.length > 0
|
||||
? site.call_tokens
|
||||
: site.api_key
|
||||
? [
|
||||
{
|
||||
id: "",
|
||||
name: "主调用令牌",
|
||||
api_key: site.api_key,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const tokenForms =
|
||||
callTokens.length > 0
|
||||
? callTokens.map((token) => ({
|
||||
id: token.id,
|
||||
name: token.name,
|
||||
api_key: token.api_key,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
id: "",
|
||||
name: "主调用令牌",
|
||||
api_key: site.api_key,
|
||||
api_key: "",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const tokenForms =
|
||||
callTokens.length > 0
|
||||
? callTokens.map((token) => ({
|
||||
id: token.id,
|
||||
name: token.name,
|
||||
api_key: token.api_key,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
name: "主调用令牌",
|
||||
api_key: "",
|
||||
},
|
||||
];
|
||||
setSiteForm({
|
||||
name: site.name ?? "",
|
||||
base_url: site.base_url ?? "",
|
||||
weight: site.weight ?? 1,
|
||||
status: site.status ?? "active",
|
||||
site_type: site.site_type ?? "new-api",
|
||||
checkin_url: site.checkin_url ?? "",
|
||||
system_token: site.system_token ?? "",
|
||||
system_userid: site.system_userid ?? "",
|
||||
checkin_enabled: Boolean(site.checkin_enabled ?? false),
|
||||
call_tokens: tokenForms,
|
||||
});
|
||||
setSiteModalOpen(true);
|
||||
dismissNotice();
|
||||
}, [dismissNotice]);
|
||||
];
|
||||
setSiteForm({
|
||||
name: site.name ?? "",
|
||||
base_url: site.base_url ?? "",
|
||||
weight: site.weight ?? 1,
|
||||
status: site.status ?? "active",
|
||||
site_type: site.site_type ?? "new-api",
|
||||
checkin_url: site.checkin_url ?? "",
|
||||
system_token: site.system_token ?? "",
|
||||
system_userid: site.system_userid ?? "",
|
||||
checkin_enabled: Boolean(site.checkin_enabled ?? false),
|
||||
call_tokens: tokenForms,
|
||||
});
|
||||
setSiteModalOpen(true);
|
||||
dismissNotice();
|
||||
},
|
||||
[dismissNotice],
|
||||
);
|
||||
|
||||
const closeTokenModal = useCallback(() => {
|
||||
setTokenModalOpen(false);
|
||||
@@ -692,21 +694,24 @@ const App = () => {
|
||||
setTokenForm({ ...initialTokenForm });
|
||||
}, []);
|
||||
|
||||
const openTokenEdit = useCallback((tokenItem: Token) => {
|
||||
setEditingToken(tokenItem);
|
||||
setTokenForm({
|
||||
name: tokenItem.name ?? "",
|
||||
quota_total:
|
||||
tokenItem.quota_total === null || tokenItem.quota_total === undefined
|
||||
? ""
|
||||
: String(tokenItem.quota_total),
|
||||
status: tokenItem.status ?? "active",
|
||||
expires_at: toChinaDateTimeInput(tokenItem.expires_at ?? null),
|
||||
allowed_channels: tokenItem.allowed_channels ?? [],
|
||||
});
|
||||
setTokenModalOpen(true);
|
||||
dismissNotice();
|
||||
}, [dismissNotice]);
|
||||
const openTokenEdit = useCallback(
|
||||
(tokenItem: Token) => {
|
||||
setEditingToken(tokenItem);
|
||||
setTokenForm({
|
||||
name: tokenItem.name ?? "",
|
||||
quota_total:
|
||||
tokenItem.quota_total === null || tokenItem.quota_total === undefined
|
||||
? ""
|
||||
: String(tokenItem.quota_total),
|
||||
status: tokenItem.status ?? "active",
|
||||
expires_at: toChinaDateTimeInput(tokenItem.expires_at ?? null),
|
||||
allowed_channels: tokenItem.allowed_channels ?? [],
|
||||
});
|
||||
setTokenModalOpen(true);
|
||||
dismissNotice();
|
||||
},
|
||||
[dismissNotice],
|
||||
);
|
||||
|
||||
const handleSiteTest = useCallback(
|
||||
async (id: string) => {
|
||||
@@ -775,7 +780,10 @@ const App = () => {
|
||||
const summary = summarizeSiteTests(results);
|
||||
const testedTotal = summary.success + summary.failed;
|
||||
if (testedTotal === 0) {
|
||||
pushNotice("info", `已跳过 ${summary.skipped} 个禁用站点,暂无可测试站点。`);
|
||||
pushNotice(
|
||||
"info",
|
||||
`已跳过 ${summary.skipped} 个禁用站点,暂无可测试站点。`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let message =
|
||||
@@ -789,7 +797,15 @@ const App = () => {
|
||||
} finally {
|
||||
endAction(actionKey);
|
||||
}
|
||||
}, [apiFetch, data.sites, endAction, isActionPending, loadSites, pushNotice, startAction]);
|
||||
}, [
|
||||
apiFetch,
|
||||
data.sites,
|
||||
endAction,
|
||||
isActionPending,
|
||||
loadSites,
|
||||
pushNotice,
|
||||
startAction,
|
||||
]);
|
||||
|
||||
const handleSiteSubmit = useCallback(
|
||||
async (event: Event) => {
|
||||
@@ -906,8 +922,7 @@ const App = () => {
|
||||
return;
|
||||
}
|
||||
const quotaInput = tokenForm.quota_total.trim();
|
||||
const quotaTotal =
|
||||
quotaInput.length === 0 ? null : Number(quotaInput);
|
||||
const quotaTotal = quotaInput.length === 0 ? null : Number(quotaInput);
|
||||
if (quotaInput.length > 0 && Number.isNaN(quotaTotal)) {
|
||||
pushNotice("warning", "额度需为数字");
|
||||
return;
|
||||
@@ -965,23 +980,23 @@ const App = () => {
|
||||
} finally {
|
||||
endAction(actionKey);
|
||||
}
|
||||
},
|
||||
[
|
||||
apiFetch,
|
||||
editingToken,
|
||||
endAction,
|
||||
initialTokenForm,
|
||||
isActionPending,
|
||||
loadTokens,
|
||||
pushNotice,
|
||||
startAction,
|
||||
tokenForm.expires_at,
|
||||
tokenForm.allowed_channels,
|
||||
tokenForm.name,
|
||||
tokenForm.quota_total,
|
||||
tokenForm.status,
|
||||
],
|
||||
);
|
||||
},
|
||||
[
|
||||
apiFetch,
|
||||
editingToken,
|
||||
endAction,
|
||||
initialTokenForm,
|
||||
isActionPending,
|
||||
loadTokens,
|
||||
pushNotice,
|
||||
startAction,
|
||||
tokenForm.expires_at,
|
||||
tokenForm.allowed_channels,
|
||||
tokenForm.name,
|
||||
tokenForm.quota_total,
|
||||
tokenForm.status,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSettingsSubmit = useCallback(
|
||||
async (event: Event) => {
|
||||
@@ -1004,10 +1019,7 @@ const App = () => {
|
||||
pushNotice("warning", "请填写有效的保留天数与会话时长");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
Number.isNaN(failureCooldownMinutes) ||
|
||||
failureCooldownMinutes < 1
|
||||
) {
|
||||
if (Number.isNaN(failureCooldownMinutes) || failureCooldownMinutes < 1) {
|
||||
pushNotice("warning", "失败冷却需为正整数");
|
||||
return;
|
||||
}
|
||||
@@ -1238,7 +1250,14 @@ const App = () => {
|
||||
} finally {
|
||||
endAction(actionKey);
|
||||
}
|
||||
}, [apiFetch, endAction, isActionPending, loadSites, pushNotice, startAction]);
|
||||
}, [
|
||||
apiFetch,
|
||||
endAction,
|
||||
isActionPending,
|
||||
loadSites,
|
||||
pushNotice,
|
||||
startAction,
|
||||
]);
|
||||
|
||||
const handleUsageRefresh = useCallback(async () => {
|
||||
const actionKey = buildActionKey("usage:refresh");
|
||||
@@ -1311,15 +1330,15 @@ const App = () => {
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div class="animate-fade-up rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<div class="flex items-center gap-3 text-sm text-stone-500">
|
||||
<span class="h-2.5 w-2.5 animate-pulse rounded-full bg-amber-400" />
|
||||
<div class="app-card animate-fade-up p-5">
|
||||
<div class="flex items-center gap-3 text-sm text-[color:var(--app-ink-muted)]">
|
||||
<span class="h-2.5 w-2.5 animate-pulse rounded-full bg-[color:var(--app-accent)]" />
|
||||
正在加载数据...
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div class="h-20 rounded-xl bg-stone-100/80" />
|
||||
<div class="h-20 rounded-xl bg-stone-100/60" />
|
||||
<div class="h-20 rounded-xl bg-stone-100/70" />
|
||||
<div class="h-20 rounded-xl bg-white/70" />
|
||||
<div class="h-20 rounded-xl bg-white/60" />
|
||||
<div class="h-20 rounded-xl bg-white/80" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1427,15 +1446,11 @@ const App = () => {
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
未知模块
|
||||
</div>
|
||||
);
|
||||
return <div class="app-card p-5">未知模块</div>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="relative min-h-screen font-['IBM_Plex_Sans'] text-stone-900 antialiased">
|
||||
<div class="app-shell relative min-h-screen antialiased">
|
||||
<div aria-hidden="true" class="app-background" />
|
||||
{token ? (
|
||||
<AppLayout
|
||||
@@ -1458,57 +1473,60 @@ const App = () => {
|
||||
/>
|
||||
)}
|
||||
{confirmState && (
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-stone-900/50 px-4 py-8"
|
||||
onClick={closeConfirm}
|
||||
>
|
||||
<div
|
||||
aria-labelledby="confirm-title"
|
||||
aria-modal="true"
|
||||
class="w-full max-w-md rounded-2xl border border-stone-200 bg-white p-6 shadow-2xl"
|
||||
role="dialog"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3
|
||||
class="font-['Space_Grotesk'] text-lg tracking-tight text-stone-900"
|
||||
id="confirm-title"
|
||||
<div class="fixed inset-0 z-50">
|
||||
<button
|
||||
aria-label="关闭弹窗"
|
||||
class="absolute inset-0 bg-slate-950/40"
|
||||
type="button"
|
||||
onClick={closeConfirm}
|
||||
/>
|
||||
<div class="relative z-10 flex min-h-screen items-center justify-center px-4 py-8">
|
||||
<div
|
||||
aria-labelledby="confirm-title"
|
||||
aria-modal="true"
|
||||
class="app-card w-full max-w-md p-6"
|
||||
role="dialog"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="app-title text-lg" id="confirm-title">
|
||||
{confirmState.title}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-[color:var(--app-ink-muted)]">
|
||||
{confirmState.message}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="app-button app-focus"
|
||||
type="button"
|
||||
onClick={closeConfirm}
|
||||
>
|
||||
{confirmState.title}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-stone-600">
|
||||
{confirmState.message}
|
||||
</p>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-6 flex flex-wrap items-center justify-end gap-2">
|
||||
<button
|
||||
class="app-button app-focus"
|
||||
type="button"
|
||||
onClick={closeConfirm}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class={`app-button app-focus ${
|
||||
confirmState.tone === "error"
|
||||
? "app-button-danger"
|
||||
: "app-button-primary"
|
||||
}`}
|
||||
type="button"
|
||||
disabled={confirmPending}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{confirmPending
|
||||
? "处理中..."
|
||||
: (confirmState.confirmLabel ?? "确认")}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="h-8 rounded-full border border-stone-200 bg-stone-50 px-3 text-xs font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
type="button"
|
||||
onClick={closeConfirm}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-6 flex flex-wrap items-center justify-end gap-2">
|
||||
<button
|
||||
class="h-10 rounded-full border border-stone-200 bg-white px-4 text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
type="button"
|
||||
onClick={closeConfirm}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class={`h-10 rounded-full px-5 text-xs font-semibold text-white transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60 ${
|
||||
confirmState.tone === "error"
|
||||
? "bg-rose-600 hover:-translate-y-0.5 hover:shadow-lg"
|
||||
: "bg-stone-900 hover:-translate-y-0.5 hover:shadow-lg"
|
||||
}`}
|
||||
type="button"
|
||||
disabled={confirmPending}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{confirmPending ? "处理中..." : confirmState.confirmLabel ?? "确认"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -169,9 +169,7 @@ export const buildUsageStatusDetail = (log: UsageLog): UsageStatusDetail => {
|
||||
const statusCode = log.upstream_status ?? null;
|
||||
const isOk = log.status === "ok";
|
||||
const label =
|
||||
statusCode !== null && statusCode !== undefined
|
||||
? String(statusCode)
|
||||
: "-";
|
||||
statusCode !== null && statusCode !== undefined ? String(statusCode) : "-";
|
||||
return {
|
||||
label,
|
||||
tone: isOk ? "success" : "error",
|
||||
|
||||
@@ -33,10 +33,10 @@ export const AppLayout = ({
|
||||
children,
|
||||
}: AppLayoutProps) => {
|
||||
const noticeToneStyles: Record<NoticeMessage["tone"], string> = {
|
||||
success: "border-emerald-200 bg-emerald-50 text-emerald-800",
|
||||
warning: "border-amber-200 bg-amber-50 text-amber-800",
|
||||
error: "border-rose-200 bg-rose-50 text-rose-700",
|
||||
info: "border-sky-200 bg-sky-50 text-sky-800",
|
||||
success: "app-notice app-notice--success",
|
||||
warning: "app-notice app-notice--warning",
|
||||
error: "app-notice app-notice--error",
|
||||
info: "app-notice app-notice--info",
|
||||
};
|
||||
const noticeToneLabel: Record<NoticeMessage["tone"], string> = {
|
||||
success: "成功",
|
||||
@@ -44,6 +44,7 @@ export const AppLayout = ({
|
||||
error: "错误",
|
||||
info: "信息",
|
||||
};
|
||||
const noticeDuration = notice?.durationMs ?? 4500;
|
||||
const closeMobileNav = () => {
|
||||
const toggle = document.querySelector<HTMLInputElement>("#app-nav-toggle");
|
||||
if (toggle) {
|
||||
@@ -60,18 +61,18 @@ export const AppLayout = ({
|
||||
return (
|
||||
<div class="relative flex min-h-screen flex-col lg:grid lg:grid-cols-[260px_1fr]">
|
||||
<input class="peer hidden" id="app-nav-toggle" type="checkbox" />
|
||||
<header class="flex items-center justify-between border-b border-stone-200 bg-white px-4 py-4 lg:hidden">
|
||||
<header class="app-bar flex items-center justify-between px-4 py-4 lg:hidden">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
aria-controls="app-nav-toggle"
|
||||
aria-label="打开导航"
|
||||
class="inline-flex h-10 items-center gap-2 rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-700 shadow-sm transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
class="app-button app-focus inline-flex h-10 items-center gap-2 px-3 text-xs"
|
||||
type="button"
|
||||
onClick={toggleMobileNav}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 text-stone-500"
|
||||
class="h-4 w-4 text-[color:var(--app-ink-muted)]"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -96,18 +97,20 @@ export const AppLayout = ({
|
||||
菜单
|
||||
</button>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-semibold text-stone-900">
|
||||
<span class="text-sm font-semibold text-[color:var(--app-ink)]">
|
||||
api-workers
|
||||
</span>
|
||||
<span class="text-xs text-stone-500">{activeLabel}</span>
|
||||
<span class="text-xs text-[color:var(--app-ink-muted)]">
|
||||
{activeLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="rounded-full bg-stone-100 px-2.5 py-1 text-[10px] uppercase tracking-widest text-stone-500">
|
||||
<span class="app-badge text-[10px] uppercase tracking-widest">
|
||||
{token ? "已登录" : "未登录"}
|
||||
</span>
|
||||
<button
|
||||
class="h-9 rounded-full border border-stone-200 bg-transparent px-3 text-xs font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
class="app-button app-button-ghost app-focus h-9 px-3 text-xs"
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
>
|
||||
@@ -115,22 +118,18 @@ export const AppLayout = ({
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<aside class="fixed inset-y-0 left-0 z-40 flex w-72 -translate-x-full flex-col overflow-y-auto border-r border-stone-200 bg-white px-5 py-8 shadow-xl transition-transform duration-300 ease-in-out peer-checked:translate-x-0 lg:sticky lg:top-0 lg:z-auto lg:h-screen lg:w-auto lg:translate-x-0 lg:shadow-none">
|
||||
<aside class="app-sidebar fixed inset-y-0 left-0 z-40 flex w-72 -translate-x-full flex-col overflow-y-auto px-5 py-8 shadow-xl transition-transform duration-300 ease-in-out peer-checked:translate-x-0 lg:sticky lg:top-0 lg:z-auto lg:h-screen lg:w-auto lg:translate-x-0 lg:shadow-none">
|
||||
<div class="mb-8 flex flex-col gap-1.5">
|
||||
<h2 class="font-['Space_Grotesk'] text-lg font-semibold tracking-tight text-stone-900">
|
||||
api-workers
|
||||
</h2>
|
||||
<span class="text-xs uppercase tracking-widest text-stone-500">
|
||||
<h2 class="app-title text-lg">api-workers</h2>
|
||||
<span class="text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]">
|
||||
console
|
||||
</span>
|
||||
</div>
|
||||
<nav class="flex flex-col gap-2.5">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
class={`flex h-11 w-full items-center rounded-xl px-3.5 py-2.5 text-left text-sm font-medium transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white ${
|
||||
activeTab === tab.id
|
||||
? "bg-stone-100 text-stone-900"
|
||||
: "text-stone-500 hover:bg-stone-100 hover:text-stone-900"
|
||||
class={`app-nav-button app-focus h-11 w-full text-left text-sm ${
|
||||
activeTab === tab.id ? "app-nav-button--active" : ""
|
||||
}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -146,19 +145,15 @@ export const AppLayout = ({
|
||||
<main class="px-4 pt-5 pb-16 sm:px-10 sm:pt-8">
|
||||
<div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="font-['Space_Grotesk'] text-2xl tracking-tight text-stone-900">
|
||||
{activeLabel}
|
||||
</h1>
|
||||
<p class="text-sm text-stone-500">
|
||||
<h1 class="app-title text-2xl">{activeLabel}</h1>
|
||||
<p class="text-sm text-[color:var(--app-ink-muted)]">
|
||||
集中管理渠道、模型、令牌与使用情况。
|
||||
</p>
|
||||
</div>
|
||||
<div class="hidden items-center gap-3 lg:flex">
|
||||
<span class="rounded-full bg-stone-100 px-2.5 py-1 text-xs text-stone-500">
|
||||
{token ? "已登录" : "未登录"}
|
||||
</span>
|
||||
<span class="app-badge text-xs">{token ? "已登录" : "未登录"}</span>
|
||||
<button
|
||||
class="h-11 rounded-lg border border-stone-200 bg-transparent px-4 py-2.5 text-sm font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
class="app-button app-button-ghost app-focus h-11 px-4 text-sm"
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
>
|
||||
@@ -166,34 +161,39 @@ export const AppLayout = ({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{notice && (
|
||||
<div
|
||||
class={`animate-fade-up mt-4 rounded-xl border px-4 py-3 text-sm ${noticeToneStyles[notice.tone]}`}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
{notice && (
|
||||
<output
|
||||
aria-live="polite"
|
||||
class="app-toast"
|
||||
style={`--toast-duration: ${noticeDuration}ms`}
|
||||
>
|
||||
<div class={`app-toast-card ${noticeToneStyles[notice.tone]}`}>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<span class="text-[10px] font-semibold uppercase tracking-widest text-stone-500">
|
||||
<span class="app-chip text-[10px]">
|
||||
{noticeToneLabel[notice.tone]}
|
||||
</span>
|
||||
<div class="mt-1 text-sm font-semibold text-stone-900">
|
||||
<div class="mt-1 text-sm font-semibold text-[color:var(--app-ink)]">
|
||||
{notice.message}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="h-8 rounded-full border border-stone-200 bg-white px-3 text-[11px] font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
class="app-button app-focus h-8 px-3 text-[11px]"
|
||||
type="button"
|
||||
onClick={onDismissNotice}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<span aria-hidden="true" class="app-toast-progress" />
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</main>
|
||||
</output>
|
||||
)}
|
||||
<button
|
||||
aria-label="关闭导航"
|
||||
class="fixed inset-0 z-30 bg-stone-900/40 opacity-0 transition-opacity duration-300 ease-in-out peer-checked:pointer-events-auto peer-checked:opacity-100 lg:hidden pointer-events-none"
|
||||
class="fixed inset-0 z-30 bg-slate-950/40 opacity-0 transition-opacity duration-300 ease-in-out peer-checked:pointer-events-auto peer-checked:opacity-100 lg:hidden pointer-events-none"
|
||||
type="button"
|
||||
onClick={closeMobileNav}
|
||||
/>
|
||||
|
||||
@@ -22,18 +22,14 @@ export const DashboardView = ({
|
||||
}: DashboardViewProps) => {
|
||||
if (!dashboard) {
|
||||
return (
|
||||
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="animate-fade-up space-y-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="mb-1 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
|
||||
数据面板
|
||||
</h3>
|
||||
<p class="text-xs text-stone-500">
|
||||
查看请求量、消耗与性能趋势。
|
||||
</p>
|
||||
<h3 class="app-title">数据面板</h3>
|
||||
<p class="app-subtitle">查看请求量、消耗与性能趋势。</p>
|
||||
</div>
|
||||
<button
|
||||
class="h-9 rounded-full border border-stone-200 bg-stone-100 px-4 text-xs font-semibold text-stone-700 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
class="app-button app-focus"
|
||||
type="button"
|
||||
disabled={isRefreshing}
|
||||
onClick={onRefresh}
|
||||
@@ -41,11 +37,14 @@ export const DashboardView = ({
|
||||
{isRefreshing ? "刷新中..." : "刷新"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-6 rounded-xl border border-dashed border-stone-200 bg-stone-50 px-4 py-8 text-center text-sm text-stone-500">
|
||||
暂无数据,请先产生调用或刷新面板。
|
||||
<div class="app-card mt-6 text-center">
|
||||
<div class="app-chip app-chip--accent">空状态</div>
|
||||
<p class="mt-3 text-sm text-[color:var(--app-ink-muted)]">
|
||||
暂无数据,请先产生调用或刷新面板。
|
||||
</p>
|
||||
<div class="mt-4 flex justify-center">
|
||||
<button
|
||||
class="h-9 rounded-full bg-stone-900 px-4 text-xs font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
class="app-button app-button-primary app-focus"
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
@@ -66,24 +65,23 @@ export const DashboardView = ({
|
||||
)
|
||||
: 0;
|
||||
const successRate = totalRequests
|
||||
? Math.max(0, Math.round(((totalRequests - totalErrors) / totalRequests) * 100))
|
||||
? Math.max(
|
||||
0,
|
||||
Math.round(((totalRequests - totalErrors) / totalRequests) * 100),
|
||||
)
|
||||
: 0;
|
||||
const avgTokensPerRequest = totalRequests
|
||||
? Math.round(dashboard.summary.total_tokens / totalRequests)
|
||||
: 0;
|
||||
return (
|
||||
<div class="animate-fade-up space-y-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="mb-1 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
|
||||
数据面板
|
||||
</h3>
|
||||
<p class="text-xs text-stone-500">
|
||||
快速掌握请求表现、性能与消耗概况。
|
||||
</p>
|
||||
<h3 class="app-title">数据面板</h3>
|
||||
<p class="app-subtitle">快速掌握请求表现、性能与消耗概况。</p>
|
||||
</div>
|
||||
<button
|
||||
class="h-9 rounded-full border border-stone-200 bg-stone-100 px-4 text-xs font-semibold text-stone-700 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
class="app-button app-focus"
|
||||
type="button"
|
||||
disabled={isRefreshing}
|
||||
onClick={onRefresh}
|
||||
@@ -91,125 +89,75 @@ export const DashboardView = ({
|
||||
{isRefreshing ? "刷新中..." : "刷新"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-5 lg:grid-cols-4">
|
||||
<div class="flex flex-col gap-1.5 rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<span class="rounded-full bg-stone-100 px-2.5 py-1 text-xs text-stone-500">
|
||||
总请求
|
||||
</span>
|
||||
<div class="text-2xl font-semibold text-stone-900">
|
||||
{dashboard.summary.total_requests}
|
||||
</div>
|
||||
<span class="font-['Space_Grotesk'] text-xs text-stone-500">
|
||||
最近窗口
|
||||
</span>
|
||||
<div class="app-grid app-grid--kpi">
|
||||
<div class="app-card app-card--compact">
|
||||
<span class="app-chip">总请求</span>
|
||||
<div class="app-kpi-value">{dashboard.summary.total_requests}</div>
|
||||
<span class="app-kpi-meta">最近窗口</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5 rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<span class="rounded-full bg-stone-100 px-2.5 py-1 text-xs text-stone-500">
|
||||
总 Tokens
|
||||
</span>
|
||||
<div class="text-2xl font-semibold text-stone-900">
|
||||
{dashboard.summary.total_tokens}
|
||||
</div>
|
||||
<span class="font-['Space_Grotesk'] text-xs text-stone-500">
|
||||
累计消耗
|
||||
</span>
|
||||
<div class="app-card app-card--compact">
|
||||
<span class="app-chip">总 Tokens</span>
|
||||
<div class="app-kpi-value">{dashboard.summary.total_tokens}</div>
|
||||
<span class="app-kpi-meta">累计消耗</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5 rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<span class="rounded-full bg-stone-100 px-2.5 py-1 text-xs text-stone-500">
|
||||
成功率
|
||||
</span>
|
||||
<div class="text-2xl font-semibold text-stone-900">
|
||||
{successRate}%
|
||||
</div>
|
||||
<span class="font-['Space_Grotesk'] text-xs text-stone-500">
|
||||
错误率 {errorRate}%
|
||||
</span>
|
||||
<div class="app-card app-card--compact">
|
||||
<span class="app-chip">成功率</span>
|
||||
<div class="app-kpi-value">{successRate}%</div>
|
||||
<span class="app-kpi-meta">错误率 {errorRate}%</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5 rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<span class="rounded-full bg-stone-100 px-2.5 py-1 text-xs text-stone-500">
|
||||
单次消耗
|
||||
</span>
|
||||
<div class="text-2xl font-semibold text-stone-900">
|
||||
{avgTokensPerRequest}
|
||||
</div>
|
||||
<span class="font-['Space_Grotesk'] text-xs text-stone-500">
|
||||
<div class="app-card app-card--compact">
|
||||
<span class="app-chip">单次消耗</span>
|
||||
<div class="app-kpi-value">{avgTokensPerRequest}</div>
|
||||
<span class="app-kpi-meta">
|
||||
平均 {Math.round(dashboard.summary.avg_latency)}ms 延迟
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||||
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<div class="app-grid app-grid--split">
|
||||
<div class="app-card">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="mb-0 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
|
||||
按日趋势
|
||||
</h3>
|
||||
<h3 class="app-title">按日趋势</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-130 w-full border-collapse text-xs sm:text-sm">
|
||||
<table class="app-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
日期
|
||||
</th>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
请求
|
||||
</th>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
Tokens
|
||||
</th>
|
||||
<th>日期</th>
|
||||
<th>请求</th>
|
||||
<th>Tokens</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dashboard.byDay.map((row) => (
|
||||
<tr class="hover:bg-stone-50" key={row.day}>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
|
||||
{row.day}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
|
||||
{row.requests}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
|
||||
{row.tokens}
|
||||
</td>
|
||||
<tr key={row.day}>
|
||||
<td>{row.day}</td>
|
||||
<td>{row.requests}</td>
|
||||
<td>{row.tokens}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<div class="app-card">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="mb-0 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
|
||||
模型排行
|
||||
</h3>
|
||||
<h3 class="app-title">模型排行</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-130 w-full border-collapse text-xs sm:text-sm">
|
||||
<table class="app-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
模型
|
||||
</th>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
请求
|
||||
</th>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
Tokens
|
||||
</th>
|
||||
<th>模型</th>
|
||||
<th>请求</th>
|
||||
<th>Tokens</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dashboard.byModel.map((row) => (
|
||||
<tr class="hover:bg-stone-50" key={row.model}>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
|
||||
{row.model ?? "-"}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
|
||||
{row.requests}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
|
||||
{row.tokens}
|
||||
</td>
|
||||
<tr key={row.model}>
|
||||
<td>{row.model ?? "-"}</td>
|
||||
<td>{row.requests}</td>
|
||||
<td>{row.tokens}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -217,85 +165,51 @@ export const DashboardView = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||||
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<div class="app-grid app-grid--split">
|
||||
<div class="app-card">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="mb-0 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
|
||||
渠道贡献
|
||||
</h3>
|
||||
<h3 class="app-title">渠道贡献</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-130 w-full border-collapse text-xs sm:text-sm">
|
||||
<table class="app-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
渠道
|
||||
</th>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
请求
|
||||
</th>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
Tokens
|
||||
</th>
|
||||
<th>渠道</th>
|
||||
<th>请求</th>
|
||||
<th>Tokens</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dashboard.byChannel.map((row) => (
|
||||
<tr
|
||||
class="hover:bg-stone-50"
|
||||
key={row.channel_name ?? "unknown"}
|
||||
>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
|
||||
{row.channel_name ?? "-"}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
|
||||
{row.requests}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
|
||||
{row.tokens}
|
||||
</td>
|
||||
<tr key={row.channel_name ?? "unknown"}>
|
||||
<td>{row.channel_name ?? "-"}</td>
|
||||
<td>{row.requests}</td>
|
||||
<td>{row.tokens}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<div class="app-card">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="mb-0 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
|
||||
令牌贡献
|
||||
</h3>
|
||||
<h3 class="app-title">令牌贡献</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-130 w-full border-collapse text-xs sm:text-sm">
|
||||
<table class="app-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
令牌
|
||||
</th>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
请求
|
||||
</th>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
Tokens
|
||||
</th>
|
||||
<th>令牌</th>
|
||||
<th>请求</th>
|
||||
<th>Tokens</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dashboard.byToken.map((row) => (
|
||||
<tr
|
||||
class="hover:bg-stone-50"
|
||||
key={row.token_name ?? "unknown"}
|
||||
>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
|
||||
{row.token_name ?? "-"}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
|
||||
{row.requests}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
|
||||
{row.tokens}
|
||||
</td>
|
||||
<tr key={row.token_name ?? "unknown"}>
|
||||
<td>{row.token_name ?? "-"}</td>
|
||||
<td>{row.requests}</td>
|
||||
<td>{row.tokens}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -15,51 +15,52 @@ type LoginViewProps = {
|
||||
* Returns:
|
||||
* Login JSX element.
|
||||
*/
|
||||
export const LoginView = ({ notice, isSubmitting, onSubmit }: LoginViewProps) => {
|
||||
export const LoginView = ({
|
||||
notice,
|
||||
isSubmitting,
|
||||
onSubmit,
|
||||
}: LoginViewProps) => {
|
||||
const toneStyles: Record<NoticeMessage["tone"], string> = {
|
||||
success: "border-emerald-200 bg-emerald-50 text-emerald-800",
|
||||
warning: "border-amber-200 bg-amber-50 text-amber-800",
|
||||
error: "border-rose-200 bg-rose-50 text-rose-700",
|
||||
info: "border-sky-200 bg-sky-50 text-sky-800",
|
||||
success: "app-notice app-notice--success",
|
||||
warning: "app-notice app-notice--warning",
|
||||
error: "app-notice app-notice--error",
|
||||
info: "app-notice app-notice--info",
|
||||
};
|
||||
return (
|
||||
<div class="animate-fade-up mx-auto mt-24 max-w-md rounded-2xl border border-stone-200 bg-white p-8 shadow-lg">
|
||||
<h1 class="mb-2 font-['Space_Grotesk'] text-2xl tracking-tight text-stone-900">
|
||||
api-workers
|
||||
</h1>
|
||||
<p class="text-sm text-stone-500">请输入管理员密码登录管理台。</p>
|
||||
<div class="app-card animate-fade-up mx-auto mt-24 max-w-md p-8">
|
||||
<h1 class="app-title mb-2 text-2xl">api-workers</h1>
|
||||
<p class="text-sm text-[color:var(--app-ink-muted)]">
|
||||
请输入管理员密码登录管理台。
|
||||
</p>
|
||||
<form class="mt-6 grid gap-4" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
|
||||
for="password"
|
||||
>
|
||||
管理员密码
|
||||
</label>
|
||||
<input
|
||||
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
|
||||
class="app-input app-focus"
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="h-11 rounded-lg bg-stone-900 px-4 py-2.5 text-sm font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
class="app-button app-button-primary app-focus h-11 px-4 text-sm"
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "登录中..." : "登录"}
|
||||
</button>
|
||||
</form>
|
||||
<p class="mt-3 text-xs text-stone-500">按回车键可快速提交。</p>
|
||||
<p class="mt-3 text-xs text-[color:var(--app-ink-muted)]">
|
||||
按回车键可快速提交。
|
||||
</p>
|
||||
{notice && (
|
||||
<div
|
||||
class={`mt-4 rounded-xl border px-4 py-3 text-sm ${toneStyles[notice.tone]}`}
|
||||
>
|
||||
{notice.message}
|
||||
</div>
|
||||
<div class={`mt-4 ${toneStyles[notice.tone]}`}>{notice.message}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,49 +18,35 @@ export const ModelsView = ({ models }: ModelsViewProps) => {
|
||||
models.flatMap((model) => model.channels.map((channel) => channel.id)),
|
||||
).size;
|
||||
return (
|
||||
<div class="animate-fade-up rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<div class="app-card animate-fade-up p-5">
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="mb-1 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
|
||||
模型广场
|
||||
</h3>
|
||||
<p class="text-xs text-stone-500">
|
||||
当前聚合的模型与所属渠道清单。
|
||||
</p>
|
||||
<h3 class="app-title text-lg">模型广场</h3>
|
||||
<p class="app-subtitle">当前聚合的模型与所属渠道清单。</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-stone-500">
|
||||
<span class="rounded-full bg-stone-100 px-2.5 py-1">
|
||||
{models.length} 个模型
|
||||
</span>
|
||||
<span class="rounded-full bg-stone-100 px-2.5 py-1">
|
||||
{channelCount} 个渠道
|
||||
</span>
|
||||
<div class="flex items-center gap-2 text-xs text-[color:var(--app-ink-muted)]">
|
||||
<span class="app-chip">{models.length} 个模型</span>
|
||||
<span class="app-chip">{channelCount} 个渠道</span>
|
||||
</div>
|
||||
</div>
|
||||
{models.length === 0 ? (
|
||||
<div class="rounded-xl border border-dashed border-stone-200 bg-stone-50 px-4 py-8 text-center text-sm text-stone-500">
|
||||
<div class="app-card text-center text-sm text-[color:var(--app-ink-muted)]">
|
||||
暂无模型,请先在站点管理配置可用渠道。
|
||||
</div>
|
||||
) : (
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-105 w-full border-collapse text-xs sm:text-sm">
|
||||
<table class="app-table min-w-105 w-full text-xs sm:text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
模型
|
||||
</th>
|
||||
<th class="border-b border-stone-200 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
渠道
|
||||
</th>
|
||||
<th>模型</th>
|
||||
<th>渠道</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{models.map((model) => (
|
||||
<tr class="hover:bg-stone-50" key={model.id}>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
|
||||
{model.id}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
|
||||
<tr key={model.id}>
|
||||
<td>{model.id}</td>
|
||||
<td>
|
||||
{model.channels.map((channel) => channel.name).join(" / ")}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -24,22 +24,20 @@ export const SettingsView = ({
|
||||
onSubmit,
|
||||
onFormChange,
|
||||
}: SettingsViewProps) => (
|
||||
<div class="animate-fade-up rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<div class="app-card animate-fade-up p-5">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="mb-0 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
|
||||
系统设置
|
||||
</h3>
|
||||
<h3 class="app-title text-lg">系统设置</h3>
|
||||
</div>
|
||||
<form class="grid gap-3.5 lg:grid-cols-2" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
|
||||
for="retention"
|
||||
>
|
||||
日志保留天数
|
||||
</label>
|
||||
<input
|
||||
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
|
||||
class="app-input app-focus"
|
||||
id="retention"
|
||||
name="log_retention_days"
|
||||
type="number"
|
||||
@@ -55,13 +53,13 @@ export const SettingsView = ({
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
|
||||
for="session-ttl"
|
||||
>
|
||||
会话时长(小时)
|
||||
</label>
|
||||
<input
|
||||
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
|
||||
class="app-input app-focus"
|
||||
id="session-ttl"
|
||||
name="session_ttl_hours"
|
||||
type="number"
|
||||
@@ -77,13 +75,13 @@ export const SettingsView = ({
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
|
||||
for="checkin-schedule-time"
|
||||
>
|
||||
签到时间(中国时间)
|
||||
</label>
|
||||
<input
|
||||
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
|
||||
class="app-input app-focus"
|
||||
id="checkin-schedule-time"
|
||||
name="checkin_schedule_time"
|
||||
type="time"
|
||||
@@ -98,13 +96,13 @@ export const SettingsView = ({
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
|
||||
for="failure-cooldown"
|
||||
>
|
||||
失败冷却(分钟)
|
||||
</label>
|
||||
<input
|
||||
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
|
||||
class="app-input app-focus"
|
||||
id="failure-cooldown"
|
||||
name="model_failure_cooldown_minutes"
|
||||
type="number"
|
||||
@@ -117,19 +115,19 @@ export const SettingsView = ({
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<p class="mt-1 text-xs text-stone-500">
|
||||
<p class="mt-1 text-xs text-[color:var(--app-ink-muted)]">
|
||||
同一模型失败后在该时间内跳过对应渠道。
|
||||
</p>
|
||||
</div>
|
||||
<div class="lg:col-span-2">
|
||||
<label
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
|
||||
for="admin-password"
|
||||
>
|
||||
管理员密码
|
||||
</label>
|
||||
<input
|
||||
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
|
||||
class="app-input app-focus"
|
||||
id="admin-password"
|
||||
name="admin_password"
|
||||
type="password"
|
||||
@@ -146,13 +144,13 @@ export const SettingsView = ({
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<p class="mt-1 text-xs text-stone-500">
|
||||
<p class="mt-1 text-xs text-[color:var(--app-ink-muted)]">
|
||||
密码状态:{adminPasswordSet ? "已设置" : "未设置"}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-end lg:col-span-2">
|
||||
<button
|
||||
class="h-11 rounded-lg bg-stone-900 px-4 py-2.5 text-sm font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
class="app-button app-button-primary app-focus h-11 px-4 text-sm"
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
>
|
||||
|
||||
+426
-433
File diff suppressed because it is too large.
Load diff
+229
-245
@@ -96,20 +96,16 @@ export const TokensView = ({
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isTokenModalOpen, onCloseModal]);
|
||||
return (
|
||||
<div class="animate-fade-up space-y-5">
|
||||
<div class="rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<div class="space-y-5">
|
||||
<div class="app-card animate-fade-up p-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="mb-1 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
|
||||
令牌列表
|
||||
</h3>
|
||||
<p class="text-xs text-stone-500">
|
||||
统一管理令牌状态、额度与操作入口。
|
||||
</p>
|
||||
<h3 class="app-title text-lg">令牌列表</h3>
|
||||
<p class="app-subtitle">统一管理令牌状态、额度与操作入口。</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
class="h-9 rounded-full bg-stone-900 px-4 text-xs font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
class="app-button app-button-primary app-focus h-9 px-4 text-xs"
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
>
|
||||
@@ -120,10 +116,10 @@ export const TokensView = ({
|
||||
<div class="mt-4">
|
||||
<div class="space-y-3 md:hidden">
|
||||
{pagedTokens.length === 0 ? (
|
||||
<div class="rounded-xl border border-dashed border-stone-200 bg-stone-50 px-4 py-10 text-center text-sm text-stone-500">
|
||||
<div class="app-card text-center text-sm text-[color:var(--app-ink-muted)]">
|
||||
<p>暂无令牌,请先创建。</p>
|
||||
<button
|
||||
class="mt-4 h-9 rounded-full bg-stone-900 px-4 text-xs font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
class="app-button app-button-primary app-focus mt-4 h-9 px-4 text-xs"
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
>
|
||||
@@ -143,54 +139,49 @@ export const TokensView = ({
|
||||
`token:delete:${tokenItem.id}`,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
class="rounded-xl border border-stone-200 bg-white p-4 shadow-sm"
|
||||
key={tokenItem.id}
|
||||
>
|
||||
<div class="app-card p-4" key={tokenItem.id}>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold text-stone-900">
|
||||
<p class="truncate text-sm font-semibold text-[color:var(--app-ink)]">
|
||||
{tokenItem.name}
|
||||
</p>
|
||||
<p class="text-xs text-stone-500">
|
||||
<p class="text-xs text-[color:var(--app-ink-muted)]">
|
||||
前缀 {tokenItem.key_prefix ?? "-"}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class={`inline-flex items-center rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-widest ${
|
||||
isActive
|
||||
? "border-emerald-100 bg-emerald-50 text-emerald-600"
|
||||
: "border-stone-200 bg-stone-100 text-stone-500"
|
||||
class={`app-chip text-[10px] uppercase tracking-widest ${
|
||||
isActive ? "app-chip--success" : "app-chip--muted"
|
||||
}`}
|
||||
>
|
||||
{isActive ? "启用" : "禁用"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-2 gap-2 text-xs text-stone-500">
|
||||
<div class="rounded-lg border border-stone-200 bg-stone-50 px-3 py-2">
|
||||
<div class="mt-3 grid grid-cols-2 gap-2 text-xs text-[color:var(--app-ink-muted)]">
|
||||
<div class="app-card app-card--compact">
|
||||
<p>已用/额度</p>
|
||||
<p class="mt-1 font-semibold text-stone-700">
|
||||
<p class="mt-1 font-semibold text-[color:var(--app-ink)]">
|
||||
{tokenItem.quota_used} /{" "}
|
||||
{tokenItem.quota_total ?? "∞"}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-stone-200 bg-stone-50 px-3 py-2">
|
||||
<div class="app-card app-card--compact">
|
||||
<p>创建时间</p>
|
||||
<p class="mt-1 font-semibold text-stone-700">
|
||||
<p class="mt-1 font-semibold text-[color:var(--app-ink)]">
|
||||
{formatDateTime(tokenItem.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-stone-200 bg-stone-50 px-3 py-2">
|
||||
<div class="app-card app-card--compact">
|
||||
<p>过期时间</p>
|
||||
<p class="mt-1 font-semibold text-stone-700">
|
||||
<p class="mt-1 font-semibold text-[color:var(--app-ink)]">
|
||||
{tokenItem.expires_at
|
||||
? formatChinaDateTime(tokenItem.expires_at)
|
||||
: "永不过期"}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-stone-200 bg-stone-50 px-3 py-2">
|
||||
<div class="app-card app-card--compact">
|
||||
<p>渠道限制</p>
|
||||
<p class="mt-1 font-semibold text-stone-700">
|
||||
<p class="mt-1 font-semibold text-[color:var(--app-ink)]">
|
||||
{tokenItem.allowed_channels &&
|
||||
tokenItem.allowed_channels.length > 0
|
||||
? `${tokenItem.allowed_channels.length} 个`
|
||||
@@ -200,14 +191,14 @@ export const TokensView = ({
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
class="col-span-2 h-9 w-full rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
class="app-button app-focus col-span-2 h-9 w-full px-3 text-xs"
|
||||
type="button"
|
||||
onClick={() => onEdit(tokenItem)}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
class="h-9 w-full rounded-full border border-stone-200 bg-stone-100 px-3 text-xs font-semibold text-stone-900 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
class="app-button app-focus h-9 w-full px-3 text-xs"
|
||||
type="button"
|
||||
disabled={revealPending}
|
||||
onClick={() => onReveal(tokenItem.id)}
|
||||
@@ -215,7 +206,7 @@ export const TokensView = ({
|
||||
{revealPending ? "查看中..." : "查看"}
|
||||
</button>
|
||||
<button
|
||||
class="h-9 w-full rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
class="app-button app-focus h-9 w-full px-3 text-xs"
|
||||
type="button"
|
||||
disabled={togglePending}
|
||||
onClick={() => onToggle(tokenItem.id, tokenItem.status)}
|
||||
@@ -223,7 +214,7 @@ export const TokensView = ({
|
||||
{togglePending ? "处理中..." : "切换"}
|
||||
</button>
|
||||
<button
|
||||
class="col-span-2 h-9 w-full rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
class="app-button app-button-ghost app-focus col-span-2 h-9 w-full px-3 text-xs"
|
||||
type="button"
|
||||
disabled={deletePending}
|
||||
onClick={() => onDelete(tokenItem)}
|
||||
@@ -236,8 +227,8 @@ export const TokensView = ({
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div class="hidden overflow-hidden rounded-xl border border-stone-200 md:block">
|
||||
<div class="grid grid-cols-[minmax(0,1.2fr)_minmax(0,0.6fr)_minmax(0,0.9fr)_minmax(0,0.6fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,0.7fr)_minmax(0,1.3fr)] gap-3 bg-stone-50 px-4 py-3 text-xs uppercase tracking-widest text-stone-500">
|
||||
<div class="app-surface hidden overflow-hidden md:block">
|
||||
<div class="grid grid-cols-[minmax(0,1.2fr)_minmax(0,0.6fr)_minmax(0,0.9fr)_minmax(0,0.6fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,0.7fr)_minmax(0,1.3fr)] gap-3 bg-white/60 px-4 py-3 text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]">
|
||||
<div>名称</div>
|
||||
<div>状态</div>
|
||||
<div>已用/额度</div>
|
||||
@@ -248,10 +239,10 @@ export const TokensView = ({
|
||||
<div>操作</div>
|
||||
</div>
|
||||
{pagedTokens.length === 0 ? (
|
||||
<div class="px-4 py-10 text-center text-sm text-stone-500">
|
||||
<div class="px-4 py-10 text-center text-sm text-[color:var(--app-ink-muted)]">
|
||||
<p>暂无令牌,请先创建。</p>
|
||||
<button
|
||||
class="mt-4 h-9 rounded-full bg-stone-900 px-4 text-xs font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
class="app-button app-button-primary app-focus mt-4 h-9 px-4 text-xs"
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
>
|
||||
@@ -259,7 +250,7 @@ export const TokensView = ({
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div class="divide-y divide-stone-100">
|
||||
<div class="divide-y divide-white/60">
|
||||
{pagedTokens.map((tokenItem) => {
|
||||
const isActive = tokenItem.status === "active";
|
||||
const revealPending = isActionPending(
|
||||
@@ -277,36 +268,34 @@ export const TokensView = ({
|
||||
key={tokenItem.id}
|
||||
>
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<span class="truncate font-semibold text-stone-900">
|
||||
<span class="truncate font-semibold text-[color:var(--app-ink)]">
|
||||
{tokenItem.name}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
class={`inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-semibold ${
|
||||
isActive
|
||||
? "border-emerald-100 bg-emerald-50 text-emerald-600"
|
||||
: "border-stone-200 bg-stone-100 text-stone-500"
|
||||
class={`app-chip text-xs ${
|
||||
isActive ? "app-chip--success" : "app-chip--muted"
|
||||
}`}
|
||||
>
|
||||
{isActive ? "启用" : "禁用"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm font-semibold text-stone-700">
|
||||
<div class="text-sm font-semibold text-[color:var(--app-ink)]">
|
||||
{tokenItem.quota_used} / {tokenItem.quota_total ?? "∞"}
|
||||
</div>
|
||||
<div class="text-sm text-stone-700">
|
||||
<div class="text-sm text-[color:var(--app-ink)]">
|
||||
{tokenItem.key_prefix ?? "-"}
|
||||
</div>
|
||||
<div class="text-sm text-stone-700">
|
||||
<div class="text-sm text-[color:var(--app-ink)]">
|
||||
{formatDateTime(tokenItem.created_at)}
|
||||
</div>
|
||||
<div class="text-sm text-stone-700">
|
||||
<div class="text-sm text-[color:var(--app-ink)]">
|
||||
{tokenItem.expires_at
|
||||
? formatChinaDateTime(tokenItem.expires_at)
|
||||
: "永不过期"}
|
||||
</div>
|
||||
<div class="text-sm text-stone-700">
|
||||
<div class="text-sm text-[color:var(--app-ink)]">
|
||||
{tokenItem.allowed_channels &&
|
||||
tokenItem.allowed_channels.length > 0
|
||||
? `${tokenItem.allowed_channels.length} 个`
|
||||
@@ -314,14 +303,14 @@ export const TokensView = ({
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
class="h-9 rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
class="app-button app-focus h-9 px-3 text-xs"
|
||||
type="button"
|
||||
onClick={() => onEdit(tokenItem)}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
class="h-9 rounded-full border border-stone-200 bg-stone-100 px-3 text-xs font-semibold text-stone-900 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
class="app-button app-focus h-9 px-3 text-xs"
|
||||
type="button"
|
||||
disabled={revealPending}
|
||||
onClick={() => onReveal(tokenItem.id)}
|
||||
@@ -329,7 +318,7 @@ export const TokensView = ({
|
||||
{revealPending ? "查看中..." : "查看"}
|
||||
</button>
|
||||
<button
|
||||
class="h-9 rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
class="app-button app-focus h-9 px-3 text-xs"
|
||||
type="button"
|
||||
disabled={togglePending}
|
||||
onClick={() =>
|
||||
@@ -339,7 +328,7 @@ export const TokensView = ({
|
||||
{togglePending ? "处理中..." : "切换"}
|
||||
</button>
|
||||
<button
|
||||
class="h-9 rounded-full border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
class="app-button app-button-ghost app-focus h-9 px-3 text-xs"
|
||||
type="button"
|
||||
disabled={deletePending}
|
||||
onClick={() => onDelete(tokenItem)}
|
||||
@@ -354,13 +343,13 @@ export const TokensView = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-col gap-3 text-xs text-stone-500 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="mt-4 flex flex-col gap-3 text-xs text-[color:var(--app-ink-muted)] sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-xs text-stone-500">
|
||||
<span class="text-xs text-[color:var(--app-ink-muted)]">
|
||||
共 {tokenTotal} 条 · {tokenTotalPages} 页
|
||||
</span>
|
||||
<button
|
||||
class="h-8 w-8 rounded-full border border-stone-200 bg-white text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
class="app-button app-focus h-8 w-8 text-xs"
|
||||
type="button"
|
||||
disabled={tokenPage <= 1}
|
||||
onClick={() => onPageChange(Math.max(1, tokenPage - 1))}
|
||||
@@ -369,15 +358,16 @@ export const TokensView = ({
|
||||
</button>
|
||||
{pageItems.map((item, index) =>
|
||||
item === "ellipsis" ? (
|
||||
<span class="px-2 text-xs text-stone-400" key={`e-${index}`}>
|
||||
<span
|
||||
class="px-2 text-xs text-[color:var(--app-ink-muted)]"
|
||||
key={`e-${index}`}
|
||||
>
|
||||
...
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
class={`h-8 min-w-8 rounded-full border px-3 text-xs font-semibold transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white ${
|
||||
item === tokenPage
|
||||
? "border-stone-900 bg-stone-900 text-white shadow-md"
|
||||
: "border-stone-200 bg-white text-stone-600 hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md"
|
||||
class={`app-button app-focus h-8 min-w-8 px-3 text-xs ${
|
||||
item === tokenPage ? "app-button-primary" : ""
|
||||
}`}
|
||||
type="button"
|
||||
key={item}
|
||||
@@ -388,7 +378,7 @@ export const TokensView = ({
|
||||
),
|
||||
)}
|
||||
<button
|
||||
class="h-8 w-8 rounded-full border border-stone-200 bg-white text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
class="app-button app-focus h-8 w-8 text-xs"
|
||||
type="button"
|
||||
disabled={tokenPage >= tokenTotalPages}
|
||||
onClick={() =>
|
||||
@@ -398,10 +388,10 @@ export const TokensView = ({
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 rounded-full border border-stone-200 bg-stone-50 px-3 py-1 text-xs text-stone-500">
|
||||
<label class="app-chip app-chip--muted flex items-center gap-2 px-3 py-1 text-xs">
|
||||
每页条数
|
||||
<select
|
||||
class="rounded-full border border-stone-200 bg-white px-2 py-0.5 text-xs text-stone-700 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
|
||||
class="app-input app-input--pill app-focus w-auto text-xs"
|
||||
value={tokenPageSize}
|
||||
onChange={(event) => {
|
||||
onPageSizeChange(
|
||||
@@ -419,194 +409,188 @@ export const TokensView = ({
|
||||
</div>
|
||||
</div>
|
||||
{isTokenModalOpen && (
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-stone-900/40 px-4 py-8"
|
||||
onClick={onCloseModal}
|
||||
>
|
||||
<div
|
||||
aria-labelledby="token-modal-title"
|
||||
aria-modal="true"
|
||||
class="w-full max-w-xl rounded-2xl border border-stone-200 bg-white p-6 shadow-2xl"
|
||||
role="dialog"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3
|
||||
class="mb-1 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900"
|
||||
id="token-modal-title"
|
||||
>
|
||||
{modalTitle}
|
||||
</h3>
|
||||
<p class="text-xs text-stone-500">
|
||||
{modalDescription}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="h-9 rounded-full border border-stone-200 bg-stone-50 px-3 text-xs font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
type="button"
|
||||
onClick={onCloseModal}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<form class="mt-4 grid gap-3.5" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
|
||||
for="token-name"
|
||||
>
|
||||
名称
|
||||
</label>
|
||||
<input
|
||||
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
|
||||
id="token-name"
|
||||
name="name"
|
||||
required
|
||||
autoFocus
|
||||
value={tokenForm.name}
|
||||
onInput={(event) =>
|
||||
onFormChange({
|
||||
name: (event.currentTarget as HTMLInputElement).value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
|
||||
for="token-quota"
|
||||
>
|
||||
额度(可选)
|
||||
</label>
|
||||
<input
|
||||
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
|
||||
id="token-quota"
|
||||
name="quota_total"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="留空表示无限"
|
||||
value={tokenForm.quota_total}
|
||||
onInput={(event) =>
|
||||
onFormChange({
|
||||
quota_total: (
|
||||
event.currentTarget as HTMLInputElement
|
||||
).value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
|
||||
for="token-status"
|
||||
>
|
||||
状态
|
||||
</label>
|
||||
<select
|
||||
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
|
||||
id="token-status"
|
||||
name="status"
|
||||
value={tokenForm.status}
|
||||
onChange={(event) =>
|
||||
onFormChange({
|
||||
status: (
|
||||
event.currentTarget as HTMLSelectElement
|
||||
).value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="active">启用</option>
|
||||
<option value="disabled">禁用</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500"
|
||||
for="token-expires"
|
||||
>
|
||||
过期时间(北京时间)
|
||||
</label>
|
||||
<input
|
||||
class="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
|
||||
id="token-expires"
|
||||
name="expires_at"
|
||||
type="datetime-local"
|
||||
step="60"
|
||||
placeholder="留空表示不过期"
|
||||
value={tokenForm.expires_at}
|
||||
onInput={(event) =>
|
||||
onFormChange({
|
||||
expires_at: (
|
||||
event.currentTarget as HTMLInputElement
|
||||
).value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p class="mt-1 text-xs text-stone-400">
|
||||
留空表示不过期。
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="mb-1.5 block text-xs uppercase tracking-widest text-stone-500">
|
||||
允许渠道
|
||||
</label>
|
||||
<button
|
||||
class="text-xs font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:text-stone-900"
|
||||
type="button"
|
||||
onClick={clearChannels}
|
||||
>
|
||||
全开
|
||||
</button>
|
||||
<div class="fixed inset-0 z-50">
|
||||
<button
|
||||
aria-label="关闭弹窗"
|
||||
class="absolute inset-0 bg-slate-950/40"
|
||||
type="button"
|
||||
onClick={onCloseModal}
|
||||
/>
|
||||
<div class="relative z-10 flex min-h-screen items-center justify-center px-4 py-8">
|
||||
<div
|
||||
aria-labelledby="token-modal-title"
|
||||
aria-modal="true"
|
||||
class="app-card w-full max-w-xl p-6"
|
||||
role="dialog"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="app-title mb-1 text-lg" id="token-modal-title">
|
||||
{modalTitle}
|
||||
</h3>
|
||||
<p class="text-xs text-[color:var(--app-ink-muted)]">
|
||||
{modalDescription}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-xs text-stone-400">
|
||||
未选择表示全开。
|
||||
</p>
|
||||
<div class="mt-2 max-h-36 space-y-2 overflow-auto rounded-lg border border-stone-200 bg-stone-50 p-3 text-xs text-stone-600">
|
||||
{sites.length === 0 ? (
|
||||
<p class="text-stone-400">暂无渠道,请先创建。</p>
|
||||
) : (
|
||||
sites.map((site) => (
|
||||
<label
|
||||
class="flex items-center gap-2"
|
||||
key={site.id}
|
||||
>
|
||||
<input
|
||||
class="h-3 w-3 rounded border-stone-300 text-stone-900 focus:ring-amber-300"
|
||||
type="checkbox"
|
||||
checked={selectedChannels.has(site.id)}
|
||||
onChange={() => toggleChannel(site.id)}
|
||||
/>
|
||||
<span class="truncate">
|
||||
{site.name ?? site.id}
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-end gap-2 pt-2">
|
||||
<button
|
||||
class="h-10 rounded-full border border-stone-200 bg-stone-50 px-4 text-xs font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
class="app-button app-focus h-9 px-3 text-xs"
|
||||
type="button"
|
||||
onClick={onCloseModal}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="h-10 rounded-full bg-stone-900 px-5 text-xs font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting
|
||||
? isEditing
|
||||
? "保存中..."
|
||||
: "生成中..."
|
||||
: submitLabel}
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<form class="mt-4 grid gap-3.5" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
|
||||
for="token-name"
|
||||
>
|
||||
名称
|
||||
</label>
|
||||
<input
|
||||
class="app-input app-focus"
|
||||
id="token-name"
|
||||
name="name"
|
||||
required
|
||||
value={tokenForm.name}
|
||||
onInput={(event) =>
|
||||
onFormChange({
|
||||
name: (event.currentTarget as HTMLInputElement).value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
|
||||
for="token-quota"
|
||||
>
|
||||
额度(可选)
|
||||
</label>
|
||||
<input
|
||||
class="app-input app-focus"
|
||||
id="token-quota"
|
||||
name="quota_total"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="留空表示无限"
|
||||
value={tokenForm.quota_total}
|
||||
onInput={(event) =>
|
||||
onFormChange({
|
||||
quota_total: (event.currentTarget as HTMLInputElement)
|
||||
.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
|
||||
for="token-status"
|
||||
>
|
||||
状态
|
||||
</label>
|
||||
<select
|
||||
class="app-input app-focus"
|
||||
id="token-status"
|
||||
name="status"
|
||||
value={tokenForm.status}
|
||||
onChange={(event) =>
|
||||
onFormChange({
|
||||
status: (event.currentTarget as HTMLSelectElement)
|
||||
.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="active">启用</option>
|
||||
<option value="disabled">禁用</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]"
|
||||
for="token-expires"
|
||||
>
|
||||
过期时间(北京时间)
|
||||
</label>
|
||||
<input
|
||||
class="app-input app-focus"
|
||||
id="token-expires"
|
||||
name="expires_at"
|
||||
type="datetime-local"
|
||||
step="60"
|
||||
placeholder="留空表示不过期"
|
||||
value={tokenForm.expires_at}
|
||||
onInput={(event) =>
|
||||
onFormChange({
|
||||
expires_at: (event.currentTarget as HTMLInputElement)
|
||||
.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p class="mt-1 text-xs text-[color:var(--app-ink-muted)]">
|
||||
留空表示不过期。
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="mb-1.5 block text-xs uppercase tracking-widest text-[color:var(--app-ink-muted)]">
|
||||
允许渠道
|
||||
</div>
|
||||
<button
|
||||
class="text-xs font-semibold text-[color:var(--app-ink-muted)] transition-all duration-200 ease-in-out hover:text-[color:var(--app-ink)]"
|
||||
type="button"
|
||||
onClick={clearChannels}
|
||||
>
|
||||
全开
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-[color:var(--app-ink-muted)]">
|
||||
未选择表示全开。
|
||||
</p>
|
||||
<div class="app-card app-card--compact mt-2 max-h-36 space-y-2 overflow-auto text-xs text-[color:var(--app-ink-muted)]">
|
||||
{sites.length === 0 ? (
|
||||
<p class="text-[color:var(--app-ink-muted)]">
|
||||
暂无渠道,请先创建。
|
||||
</p>
|
||||
) : (
|
||||
sites.map((site) => (
|
||||
<label class="flex items-center gap-2" key={site.id}>
|
||||
<input
|
||||
class="h-3 w-3 rounded border-[color:var(--app-border)] text-[color:var(--app-ink)] focus:ring-[color:var(--app-accent-soft)]"
|
||||
type="checkbox"
|
||||
checked={selectedChannels.has(site.id)}
|
||||
onChange={() => toggleChannel(site.id)}
|
||||
/>
|
||||
<span class="truncate">{site.name ?? site.id}</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-end gap-2 pt-2">
|
||||
<button
|
||||
class="app-button app-focus h-10 px-4 text-xs"
|
||||
type="button"
|
||||
onClick={onCloseModal}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="app-button app-button-primary app-focus h-10 px-5 text-xs"
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting
|
||||
? isEditing
|
||||
? "保存中..."
|
||||
: "生成中..."
|
||||
: submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+318
-340
@@ -106,364 +106,342 @@ export const UsageView = ({
|
||||
}, [activeErrorLog]);
|
||||
|
||||
return (
|
||||
<div class="animate-fade-up rounded-2xl border border-stone-200 bg-white p-5 shadow-lg">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="mb-1 font-['Space_Grotesk'] text-lg tracking-tight text-stone-900">
|
||||
使用日志
|
||||
</h3>
|
||||
<p class="text-xs text-stone-500">
|
||||
追踪每次调用的令牌与关键性能指标。
|
||||
</p>
|
||||
<div class="space-y-4">
|
||||
<div class="app-card animate-fade-up p-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="app-title text-lg">使用日志</h3>
|
||||
<p class="app-subtitle">追踪每次调用的令牌与关键性能指标。</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
class="app-button app-focus h-9 px-4 text-xs"
|
||||
type="button"
|
||||
disabled={isRefreshing}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
{isRefreshing ? "刷新中..." : "刷新"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
class="h-9 rounded-full border border-stone-200 bg-stone-100 px-4 text-xs font-semibold text-stone-900 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
disabled={isRefreshing}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
{isRefreshing ? "刷新中..." : "刷新"}
|
||||
</button>
|
||||
<div class="app-card app-card--compact mt-4 flex flex-wrap items-center gap-2 p-3">
|
||||
<label class="app-chip app-chip--muted app-filter-group">
|
||||
<span class="app-filter-label">渠道</span>
|
||||
<input
|
||||
class="app-filter-input"
|
||||
placeholder="渠道名称"
|
||||
value={filters.channel}
|
||||
onInput={(event) =>
|
||||
onFiltersChange({
|
||||
channel: (event.currentTarget as HTMLInputElement).value,
|
||||
})
|
||||
}
|
||||
onKeyDown={handleSearchKey}
|
||||
/>
|
||||
</label>
|
||||
<label class="app-chip app-chip--muted app-filter-group">
|
||||
<span class="app-filter-label">令牌</span>
|
||||
<input
|
||||
class="app-filter-input"
|
||||
placeholder="令牌名称"
|
||||
value={filters.token}
|
||||
onInput={(event) =>
|
||||
onFiltersChange({
|
||||
token: (event.currentTarget as HTMLInputElement).value,
|
||||
})
|
||||
}
|
||||
onKeyDown={handleSearchKey}
|
||||
/>
|
||||
</label>
|
||||
<label class="app-chip app-chip--muted app-filter-group">
|
||||
<span class="app-filter-label">模型</span>
|
||||
<input
|
||||
class="app-filter-input"
|
||||
placeholder="模型名称"
|
||||
value={filters.model}
|
||||
onInput={(event) =>
|
||||
onFiltersChange({
|
||||
model: (event.currentTarget as HTMLInputElement).value,
|
||||
})
|
||||
}
|
||||
onKeyDown={handleSearchKey}
|
||||
/>
|
||||
</label>
|
||||
<label class="app-chip app-chip--muted app-filter-group">
|
||||
<span class="app-filter-label">状态码</span>
|
||||
<input
|
||||
class="app-filter-input app-filter-input--tight"
|
||||
placeholder="200/503"
|
||||
inputMode="numeric"
|
||||
value={filters.status}
|
||||
onInput={(event) =>
|
||||
onFiltersChange({
|
||||
status: (event.currentTarget as HTMLInputElement).value,
|
||||
})
|
||||
}
|
||||
onKeyDown={handleSearchKey}
|
||||
/>
|
||||
</label>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
class="app-button app-button-primary app-focus h-8 px-4 text-[11px]"
|
||||
type="button"
|
||||
disabled={isRefreshing}
|
||||
onClick={onSearch}
|
||||
>
|
||||
搜索
|
||||
</button>
|
||||
<button
|
||||
class="app-button app-button-ghost app-focus h-8 px-4 text-[11px]"
|
||||
type="button"
|
||||
disabled={isRefreshing || !hasFilters}
|
||||
onClick={onClear}
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2 rounded-2xl border border-stone-200 bg-stone-50 p-3">
|
||||
<label class="flex items-center gap-2 rounded-full border border-stone-200 bg-white px-3 py-1 text-[11px] uppercase tracking-widest text-stone-400">
|
||||
渠道
|
||||
<input
|
||||
class="w-36 bg-transparent text-xs text-stone-700 placeholder:text-stone-400 focus:outline-none"
|
||||
placeholder="渠道名称"
|
||||
value={filters.channel}
|
||||
onInput={(event) =>
|
||||
onFiltersChange({
|
||||
channel: (event.currentTarget as HTMLInputElement).value,
|
||||
})
|
||||
}
|
||||
onKeyDown={handleSearchKey}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 rounded-full border border-stone-200 bg-white px-3 py-1 text-[11px] uppercase tracking-widest text-stone-400">
|
||||
令牌
|
||||
<input
|
||||
class="w-36 bg-transparent text-xs text-stone-700 placeholder:text-stone-400 focus:outline-none"
|
||||
placeholder="令牌名称"
|
||||
value={filters.token}
|
||||
onInput={(event) =>
|
||||
onFiltersChange({
|
||||
token: (event.currentTarget as HTMLInputElement).value,
|
||||
})
|
||||
}
|
||||
onKeyDown={handleSearchKey}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 rounded-full border border-stone-200 bg-white px-3 py-1 text-[11px] uppercase tracking-widest text-stone-400">
|
||||
模型
|
||||
<input
|
||||
class="w-36 bg-transparent text-xs text-stone-700 placeholder:text-stone-400 focus:outline-none"
|
||||
placeholder="模型名称"
|
||||
value={filters.model}
|
||||
onInput={(event) =>
|
||||
onFiltersChange({
|
||||
model: (event.currentTarget as HTMLInputElement).value,
|
||||
})
|
||||
}
|
||||
onKeyDown={handleSearchKey}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 rounded-full border border-stone-200 bg-white px-3 py-1 text-[11px] uppercase tracking-widest text-stone-400">
|
||||
状态码
|
||||
<input
|
||||
class="w-28 bg-transparent text-xs text-stone-700 placeholder:text-stone-400 focus:outline-none"
|
||||
placeholder="200/503"
|
||||
inputMode="numeric"
|
||||
value={filters.status}
|
||||
onInput={(event) =>
|
||||
onFiltersChange({
|
||||
status: (event.currentTarget as HTMLInputElement).value,
|
||||
})
|
||||
}
|
||||
onKeyDown={handleSearchKey}
|
||||
/>
|
||||
</label>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
class="h-8 rounded-full bg-stone-900 px-4 text-[11px] font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
disabled={isRefreshing}
|
||||
onClick={onSearch}
|
||||
>
|
||||
搜索
|
||||
</button>
|
||||
<button
|
||||
class="h-8 rounded-full border border-stone-200 bg-white px-4 text-[11px] font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
disabled={isRefreshing || !hasFilters}
|
||||
onClick={onClear}
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 overflow-hidden rounded-xl border border-stone-200">
|
||||
<div class="h-[420px] overflow-auto sm:h-[520px]">
|
||||
<table class="min-w-[960px] w-full border-collapse text-xs sm:text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sticky top-0 border-b border-stone-200 bg-stone-50 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
时间
|
||||
</th>
|
||||
<th class="sticky top-0 border-b border-stone-200 bg-stone-50 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
模型
|
||||
</th>
|
||||
<th class="sticky top-0 border-b border-stone-200 bg-stone-50 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
渠道
|
||||
</th>
|
||||
<th class="sticky top-0 border-b border-stone-200 bg-stone-50 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
令牌
|
||||
</th>
|
||||
<th class="sticky top-0 border-b border-stone-200 bg-stone-50 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
输入 Tokens
|
||||
</th>
|
||||
<th class="sticky top-0 border-b border-stone-200 bg-stone-50 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
输出 Tokens
|
||||
</th>
|
||||
<th class="sticky top-0 border-b border-stone-200 bg-stone-50 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
用时 (s)
|
||||
</th>
|
||||
<th class="sticky top-0 border-b border-stone-200 bg-stone-50 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
首 token 延迟 (s)
|
||||
</th>
|
||||
<th class="sticky top-0 border-b border-stone-200 bg-stone-50 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
流式
|
||||
</th>
|
||||
<th class="sticky top-0 border-b border-stone-200 bg-stone-50 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
推理强度
|
||||
</th>
|
||||
<th class="sticky top-0 border-b border-stone-200 bg-stone-50 px-3 py-2.5 text-left text-[10px] uppercase tracking-widest text-stone-500 sm:text-xs">
|
||||
状态码
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{usage.length === 0 ? (
|
||||
<div class="app-surface mt-4 overflow-hidden">
|
||||
<div class="h-[420px] overflow-auto sm:h-[520px]">
|
||||
<table class="app-table min-w-[960px] w-full text-xs sm:text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<td
|
||||
class="px-3 py-10 text-center text-sm text-stone-500"
|
||||
colSpan={11}
|
||||
>
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<span>暂无日志,先完成一次调用吧。</span>
|
||||
<button
|
||||
class="h-8 rounded-full bg-stone-900 px-4 text-[11px] font-semibold text-white transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
立即刷新
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<th class="sticky top-0 bg-white/70">时间</th>
|
||||
<th class="sticky top-0 bg-white/70">模型</th>
|
||||
<th class="sticky top-0 bg-white/70">渠道</th>
|
||||
<th class="sticky top-0 bg-white/70">令牌</th>
|
||||
<th class="sticky top-0 bg-white/70">输入 Tokens</th>
|
||||
<th class="sticky top-0 bg-white/70">输出 Tokens</th>
|
||||
<th class="sticky top-0 bg-white/70">用时 (s)</th>
|
||||
<th class="sticky top-0 bg-white/70">首 token 延迟 (s)</th>
|
||||
<th class="sticky top-0 bg-white/70">流式</th>
|
||||
<th class="sticky top-0 bg-white/70">推理强度</th>
|
||||
<th class="sticky top-0 bg-white/70">状态码</th>
|
||||
</tr>
|
||||
) : (
|
||||
usage.map((log) => {
|
||||
const statusDetail = buildUsageStatusDetail(log);
|
||||
const hasDetail = Boolean(
|
||||
log.error_message || log.error_code,
|
||||
);
|
||||
return (
|
||||
<tr class="hover:bg-stone-50" key={log.id}>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
|
||||
{formatDateTime(log.created_at)}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
|
||||
{log.model ?? "-"}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
|
||||
{log.channel_name ?? log.channel_id ?? "-"}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
|
||||
{log.token_name ?? log.token_id ?? "-"}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
|
||||
{formatTokens(log.prompt_tokens)}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
|
||||
{formatTokens(log.completion_tokens)}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
|
||||
{formatSeconds(log.latency_ms)}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
|
||||
{formatSeconds(log.first_token_latency_ms)}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
|
||||
{formatStream(log.stream)}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
|
||||
{log.reasoning_effort ?? "-"}
|
||||
</td>
|
||||
<td class="border-b border-stone-200 px-3 py-2.5 text-left text-xs text-stone-700 sm:text-sm">
|
||||
<div
|
||||
class={
|
||||
statusDetail.tone === "success"
|
||||
? "font-semibold text-emerald-700"
|
||||
: "font-semibold text-rose-700"
|
||||
}
|
||||
>
|
||||
{statusDetail.label}
|
||||
</div>
|
||||
{hasDetail ? (
|
||||
</thead>
|
||||
<tbody>
|
||||
{usage.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
class="px-3 py-10 text-center text-sm text-[color:var(--app-ink-muted)]"
|
||||
colSpan={11}
|
||||
>
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<span>暂无日志,先完成一次调用吧。</span>
|
||||
<button
|
||||
class="mt-2 rounded-full border border-stone-200 bg-white px-2 py-0.5 text-[10px] font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md"
|
||||
class="app-button app-button-primary app-focus h-8 px-4 text-[11px]"
|
||||
type="button"
|
||||
onClick={() => setActiveErrorLog(log)}
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
查看详情
|
||||
立即刷新
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
usage.map((log) => {
|
||||
const statusDetail = buildUsageStatusDetail(log);
|
||||
const hasDetail = Boolean(
|
||||
log.error_message || log.error_code,
|
||||
);
|
||||
return (
|
||||
<tr key={log.id}>
|
||||
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
|
||||
{formatDateTime(log.created_at)}
|
||||
</td>
|
||||
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
|
||||
{log.model ?? "-"}
|
||||
</td>
|
||||
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
|
||||
{log.channel_name ?? log.channel_id ?? "-"}
|
||||
</td>
|
||||
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
|
||||
{log.token_name ?? log.token_id ?? "-"}
|
||||
</td>
|
||||
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
|
||||
{formatTokens(log.prompt_tokens)}
|
||||
</td>
|
||||
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
|
||||
{formatTokens(log.completion_tokens)}
|
||||
</td>
|
||||
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
|
||||
{formatSeconds(log.latency_ms)}
|
||||
</td>
|
||||
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
|
||||
{formatSeconds(log.first_token_latency_ms)}
|
||||
</td>
|
||||
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
|
||||
{formatStream(log.stream)}
|
||||
</td>
|
||||
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
|
||||
{log.reasoning_effort ?? "-"}
|
||||
</td>
|
||||
<td class="px-3 py-2.5 text-left text-xs text-[color:var(--app-ink)] sm:text-sm">
|
||||
<span
|
||||
class={`app-chip text-[10px] ${
|
||||
statusDetail.tone === "success"
|
||||
? "app-chip--success"
|
||||
: "app-chip--muted"
|
||||
}`}
|
||||
>
|
||||
{statusDetail.label}
|
||||
</span>
|
||||
{hasDetail ? (
|
||||
<button
|
||||
class="app-button app-button-ghost app-focus mt-2 h-7 px-2 text-[10px]"
|
||||
type="button"
|
||||
onClick={() => setActiveErrorLog(log)}
|
||||
>
|
||||
查看详情
|
||||
</button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-col gap-3 text-xs text-stone-500 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-xs text-stone-500">
|
||||
共 {totalPages} 页 · {total} 条
|
||||
</span>
|
||||
<button
|
||||
class="h-8 w-8 rounded-full border border-stone-200 bg-white text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
disabled={page <= 1 || isRefreshing}
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
>
|
||||
<
|
||||
</button>
|
||||
{pageItems.map((item, index) =>
|
||||
item === "ellipsis" ? (
|
||||
<span class="px-2 text-xs text-stone-400" key={`e-${index}`}>
|
||||
...
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
class={`h-8 min-w-8 rounded-full border px-3 text-xs font-semibold transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white ${
|
||||
item === page
|
||||
? "border-stone-900 bg-stone-900 text-white shadow-md"
|
||||
: "border-stone-200 bg-white text-stone-600 hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md"
|
||||
}`}
|
||||
type="button"
|
||||
key={item}
|
||||
disabled={isRefreshing}
|
||||
onClick={() => onPageChange(item)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
<button
|
||||
class="h-8 w-8 rounded-full border border-stone-200 bg-white text-xs font-semibold text-stone-600 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
disabled={page >= totalPages || isRefreshing}
|
||||
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
|
||||
>
|
||||
>
|
||||
</button>
|
||||
<div class="mt-4 flex flex-col gap-3 text-xs text-[color:var(--app-ink-muted)] sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-xs text-[color:var(--app-ink-muted)]">
|
||||
共 {totalPages} 页 · {total} 条
|
||||
</span>
|
||||
<button
|
||||
class="app-button app-focus h-8 w-8 text-xs"
|
||||
type="button"
|
||||
disabled={page <= 1 || isRefreshing}
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
>
|
||||
<
|
||||
</button>
|
||||
{pageItems.map((item, index) =>
|
||||
item === "ellipsis" ? (
|
||||
<span
|
||||
class="px-2 text-xs text-[color:var(--app-ink-muted)]"
|
||||
key={`e-${index}`}
|
||||
>
|
||||
...
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
class={`app-button app-focus h-8 min-w-8 px-3 text-xs ${
|
||||
item === page ? "app-button-primary" : ""
|
||||
}`}
|
||||
type="button"
|
||||
key={item}
|
||||
disabled={isRefreshing}
|
||||
onClick={() => onPageChange(item)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
<button
|
||||
class="app-button app-focus h-8 w-8 text-xs"
|
||||
type="button"
|
||||
disabled={page >= totalPages || isRefreshing}
|
||||
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
|
||||
>
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
<label class="app-chip app-chip--muted flex items-center gap-2 px-3 py-1 text-xs">
|
||||
每页条数
|
||||
<select
|
||||
class="app-input app-input--pill app-focus w-auto text-xs"
|
||||
value={pageSize}
|
||||
disabled={isRefreshing}
|
||||
onChange={(event) =>
|
||||
onPageSizeChange(
|
||||
Number((event.currentTarget as HTMLSelectElement).value),
|
||||
)
|
||||
}
|
||||
>
|
||||
{pageSizeOptions.map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 rounded-full border border-stone-200 bg-stone-50 px-3 py-1 text-xs text-stone-500">
|
||||
每页条数
|
||||
<select
|
||||
class="rounded-full border border-stone-200 bg-white px-2 py-0.5 text-xs text-stone-700 focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-200"
|
||||
value={pageSize}
|
||||
disabled={isRefreshing}
|
||||
onChange={(event) =>
|
||||
onPageSizeChange(
|
||||
Number((event.currentTarget as HTMLSelectElement).value),
|
||||
)
|
||||
}
|
||||
>
|
||||
{pageSizeOptions.map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{activeErrorLog ? (
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-stone-900/50 px-4 py-8"
|
||||
onClick={closeErrorModal}
|
||||
>
|
||||
<div
|
||||
aria-labelledby="usage-error-title"
|
||||
aria-modal="true"
|
||||
class="w-full max-w-2xl rounded-2xl border border-stone-200 bg-white p-6 shadow-2xl"
|
||||
role="dialog"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3
|
||||
class="font-['Space_Grotesk'] text-lg tracking-tight text-stone-900"
|
||||
id="usage-error-title"
|
||||
>
|
||||
错误详情
|
||||
</h3>
|
||||
<p class="mt-2 text-xs text-stone-500">
|
||||
状态码 {activeErrorLog.upstream_status ?? "-"}
|
||||
</p>
|
||||
{activeErrorLog.error_code ? (
|
||||
<p class="mt-1 text-xs text-stone-500">
|
||||
错误码: {activeErrorLog.error_code}
|
||||
<div class="fixed inset-0 z-50">
|
||||
<button
|
||||
aria-label="关闭弹窗"
|
||||
class="absolute inset-0 bg-slate-950/40"
|
||||
type="button"
|
||||
onClick={closeErrorModal}
|
||||
/>
|
||||
<div class="relative z-10 flex min-h-screen items-center justify-center px-4 py-8">
|
||||
<div
|
||||
aria-labelledby="usage-error-title"
|
||||
aria-modal="true"
|
||||
class="app-card w-full max-w-2xl p-6"
|
||||
role="dialog"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="app-title text-lg" id="usage-error-title">
|
||||
错误详情
|
||||
</h3>
|
||||
<p class="mt-2 text-xs text-[color:var(--app-ink-muted)]">
|
||||
状态码 {activeErrorLog.upstream_status ?? "-"}
|
||||
</p>
|
||||
) : null}
|
||||
{activeErrorLog.error_code ? (
|
||||
<p class="mt-1 text-xs text-[color:var(--app-ink-muted)]">
|
||||
错误码: {activeErrorLog.error_code}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
class="app-button app-focus h-8 px-3 text-xs"
|
||||
type="button"
|
||||
onClick={closeErrorModal}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="h-8 rounded-full border border-stone-200 bg-stone-50 px-3 text-xs font-semibold text-stone-500 transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:text-stone-900 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
type="button"
|
||||
onClick={closeErrorModal}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-4 rounded-xl border border-stone-200 bg-stone-50 p-3 text-xs text-stone-700">
|
||||
<div class="grid gap-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-stone-400">时间</span>
|
||||
<span>{formatDateTime(activeErrorLog.created_at)}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-stone-400">模型</span>
|
||||
<span>{activeErrorLog.model ?? "-"}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-stone-400">渠道</span>
|
||||
<span>
|
||||
{activeErrorLog.channel_name ??
|
||||
activeErrorLog.channel_id ??
|
||||
"-"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-stone-400">令牌</span>
|
||||
<span>
|
||||
{activeErrorLog.token_name ??
|
||||
activeErrorLog.token_id ??
|
||||
"-"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-stone-400">耗时</span>
|
||||
<span>{formatSeconds(activeErrorLog.latency_ms)}</span>
|
||||
<div class="app-card app-card--compact mt-4 text-xs text-[color:var(--app-ink)]">
|
||||
<div class="grid gap-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-[color:var(--app-ink-muted)]">时间</span>
|
||||
<span>{formatDateTime(activeErrorLog.created_at)}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-[color:var(--app-ink-muted)]">模型</span>
|
||||
<span>{activeErrorLog.model ?? "-"}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-[color:var(--app-ink-muted)]">渠道</span>
|
||||
<span>
|
||||
{activeErrorLog.channel_name ??
|
||||
activeErrorLog.channel_id ??
|
||||
"-"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-[color:var(--app-ink-muted)]">令牌</span>
|
||||
<span>
|
||||
{activeErrorLog.token_name ??
|
||||
activeErrorLog.token_id ??
|
||||
"-"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-[color:var(--app-ink-muted)]">耗时</span>
|
||||
<span>{formatSeconds(activeErrorLog.latency_ms)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 text-[11px] text-[color:var(--app-ink-muted)]">
|
||||
错误片段已省略,请结合状态码与错误码排查。
|
||||
</p>
|
||||
</div>
|
||||
<p class="mt-3 text-[11px] text-stone-400">
|
||||
错误片段已省略,请结合状态码与错误码排查。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+422
-12
@@ -2,17 +2,42 @@
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--app-ink: #1c1917;
|
||||
--app-ink-muted: #78716c;
|
||||
--app-amber: #f59e0b;
|
||||
--app-amber-soft: #fde68a;
|
||||
--app-bg: #f8f5f0;
|
||||
--app-surface: #ffffff;
|
||||
--app-border: rgba(28, 25, 23, 0.12);
|
||||
--app-bg: #f4f5f7;
|
||||
--app-surface: rgba(255, 255, 255, 0.78);
|
||||
--app-surface-strong: #ffffff;
|
||||
--app-ink: #1d1d1f;
|
||||
--app-ink-muted: #6e6e73;
|
||||
--app-border: rgba(210, 210, 215, 0.7);
|
||||
--app-accent: #0a84ff;
|
||||
--app-accent-strong: #0071e3;
|
||||
--app-accent-soft: rgba(10, 132, 255, 0.16);
|
||||
--app-success: #34c759;
|
||||
--app-warning: #ff9f0a;
|
||||
--app-danger: #ff453a;
|
||||
--app-info: #0a84ff;
|
||||
--app-shadow-soft: 0 12px 32px rgba(15, 23, 42, 0.08);
|
||||
--app-shadow: 0 20px 48px rgba(15, 23, 42, 0.12);
|
||||
--app-radius: 22px;
|
||||
--app-radius-sm: 14px;
|
||||
--app-radius-xs: 10px;
|
||||
--app-gap: 20px;
|
||||
--app-blur: 18px;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--app-bg);
|
||||
color: var(--app-ink);
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display",
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
color: var(--app-ink);
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display",
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
.app-background {
|
||||
@@ -20,18 +45,29 @@ body {
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background:
|
||||
radial-gradient(circle at 8% 12%, rgba(245, 158, 11, 0.16), transparent 45%),
|
||||
radial-gradient(circle at 90% 8%, rgba(120, 113, 108, 0.18), transparent 40%),
|
||||
linear-gradient(180deg, #fff7ed 0%, #f8f5f0 38%, #f1f5f9 100%);
|
||||
radial-gradient(
|
||||
circle at 12% 12%,
|
||||
rgba(255, 255, 255, 0.9),
|
||||
transparent 45%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 88% 8%,
|
||||
rgba(180, 190, 210, 0.25),
|
||||
transparent 40%
|
||||
),
|
||||
linear-gradient(180deg, #f7f8fa 0%, #f4f5f7 50%, #eef0f4 100%);
|
||||
}
|
||||
|
||||
.app-background::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: radial-gradient(rgba(12, 10, 9, 0.06) 0.6px, transparent 0);
|
||||
background-image: radial-gradient(
|
||||
rgba(15, 23, 42, 0.05) 0.6px,
|
||||
transparent 0
|
||||
);
|
||||
background-size: 3px 3px;
|
||||
opacity: 0.18;
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -50,8 +86,382 @@ body {
|
||||
animation: fadeUp 0.35s ease-out both;
|
||||
}
|
||||
|
||||
@keyframes toastProgress {
|
||||
from {
|
||||
width: 100%;
|
||||
}
|
||||
to {
|
||||
width: 0%;
|
||||
}
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--app-ink);
|
||||
}
|
||||
|
||||
.app-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--app-ink-muted);
|
||||
}
|
||||
|
||||
.app-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
background: rgba(29, 29, 31, 0.06);
|
||||
color: var(--app-ink-muted);
|
||||
}
|
||||
|
||||
.app-chip--accent {
|
||||
background: var(--app-accent-soft);
|
||||
color: var(--app-accent-strong);
|
||||
}
|
||||
|
||||
.app-chip--success {
|
||||
background: rgba(52, 199, 89, 0.14);
|
||||
color: #1f7a3a;
|
||||
}
|
||||
|
||||
.app-chip--muted {
|
||||
background: rgba(29, 29, 31, 0.08);
|
||||
color: var(--app-ink-muted);
|
||||
}
|
||||
|
||||
.app-filter-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
text-transform: none;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.app-filter-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-grid {
|
||||
display: grid;
|
||||
gap: var(--app-gap);
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.app-grid--kpi {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.app-grid--kpi {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
.app-grid--split {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.app-surface {
|
||||
background: var(--app-surface);
|
||||
border: 1px solid var(--app-border);
|
||||
border-radius: var(--app-radius);
|
||||
box-shadow: var(--app-shadow-soft);
|
||||
backdrop-filter: blur(var(--app-blur)) saturate(1.15);
|
||||
}
|
||||
|
||||
.app-bar {
|
||||
background: var(--app-surface);
|
||||
border-bottom: 1px solid var(--app-border);
|
||||
backdrop-filter: blur(var(--app-blur)) saturate(1.15);
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
background: var(--app-surface);
|
||||
border-right: 1px solid var(--app-border);
|
||||
backdrop-filter: blur(var(--app-blur)) saturate(1.15);
|
||||
}
|
||||
|
||||
.app-card {
|
||||
background: var(--app-surface);
|
||||
border: 1px solid var(--app-border);
|
||||
border-radius: var(--app-radius);
|
||||
padding: 20px;
|
||||
box-shadow: var(--app-shadow-soft);
|
||||
backdrop-filter: blur(var(--app-blur)) saturate(1.15);
|
||||
transition:
|
||||
transform 180ms ease-out,
|
||||
box-shadow 180ms ease-out;
|
||||
}
|
||||
|
||||
.app-card--compact {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.app-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--app-shadow);
|
||||
}
|
||||
|
||||
.app-kpi-value {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--app-ink);
|
||||
}
|
||||
|
||||
.app-kpi-meta {
|
||||
font-size: 12px;
|
||||
color: var(--app-ink-muted);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.app-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--app-border);
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--app-ink);
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 180ms ease-out,
|
||||
box-shadow 180ms ease-out,
|
||||
border-color 180ms ease-out,
|
||||
background-color 180ms ease-out;
|
||||
}
|
||||
|
||||
.app-button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--app-shadow-soft);
|
||||
border-color: rgba(10, 132, 255, 0.35);
|
||||
}
|
||||
|
||||
.app-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.app-button-primary {
|
||||
background: var(--app-accent);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.app-button-primary:hover {
|
||||
background: var(--app-accent-strong);
|
||||
}
|
||||
|
||||
.app-button-danger {
|
||||
background: var(--app-danger);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.app-button-danger:hover {
|
||||
background: #ff3b30;
|
||||
}
|
||||
|
||||
.app-button-ghost {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.app-input {
|
||||
width: 100%;
|
||||
border-radius: var(--app-radius-sm);
|
||||
border: 1px solid var(--app-border);
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
color: var(--app-ink);
|
||||
transition:
|
||||
border-color 180ms ease-out,
|
||||
box-shadow 180ms ease-out,
|
||||
background-color 180ms ease-out;
|
||||
}
|
||||
|
||||
.app-input--pill {
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.app-input:focus {
|
||||
border-color: var(--app-accent);
|
||||
box-shadow: 0 0 0 3px var(--app-accent-soft);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.app-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.app-filter-input {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
color: var(--app-ink);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.app-filter-input--tight {
|
||||
min-width: 84px;
|
||||
}
|
||||
|
||||
.app-filter-input::placeholder {
|
||||
color: var(--app-ink-muted);
|
||||
}
|
||||
|
||||
.app-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.app-table th,
|
||||
.app-table td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(210, 210, 215, 0.6);
|
||||
}
|
||||
|
||||
.app-table th {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--app-ink-muted);
|
||||
}
|
||||
|
||||
.app-table tr:hover {
|
||||
background: rgba(10, 132, 255, 0.05);
|
||||
}
|
||||
|
||||
.app-focus:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--app-accent-soft);
|
||||
}
|
||||
|
||||
.app-nav-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 14px;
|
||||
padding: 10px 14px;
|
||||
font-weight: 600;
|
||||
color: var(--app-ink-muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 180ms ease-out,
|
||||
background-color 180ms ease-out,
|
||||
transform 180ms ease-out;
|
||||
}
|
||||
|
||||
.app-nav-button:hover {
|
||||
color: var(--app-ink);
|
||||
background: rgba(10, 132, 255, 0.08);
|
||||
}
|
||||
|
||||
.app-nav-button--active {
|
||||
color: var(--app-ink);
|
||||
background: rgba(10, 132, 255, 0.12);
|
||||
}
|
||||
|
||||
.app-badge {
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: rgba(29, 29, 31, 0.08);
|
||||
color: var(--app-ink-muted);
|
||||
}
|
||||
|
||||
.app-notice {
|
||||
border-radius: var(--app-radius-sm);
|
||||
border: 1px solid transparent;
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.app-notice--success {
|
||||
border-color: rgba(52, 199, 89, 0.3);
|
||||
background: rgba(52, 199, 89, 0.12);
|
||||
color: #1f7a3a;
|
||||
}
|
||||
|
||||
.app-notice--warning {
|
||||
border-color: rgba(255, 159, 10, 0.3);
|
||||
background: rgba(255, 159, 10, 0.12);
|
||||
color: #8a5200;
|
||||
}
|
||||
|
||||
.app-notice--error {
|
||||
border-color: rgba(255, 69, 58, 0.3);
|
||||
background: rgba(255, 69, 58, 0.12);
|
||||
color: #8a1d1a;
|
||||
}
|
||||
|
||||
.app-notice--info {
|
||||
border-color: rgba(10, 132, 255, 0.3);
|
||||
background: rgba(10, 132, 255, 0.12);
|
||||
color: #0b4aa6;
|
||||
}
|
||||
|
||||
.app-toast {
|
||||
position: fixed;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
z-index: 60;
|
||||
width: min(360px, calc(100% - 48px));
|
||||
}
|
||||
|
||||
.app-toast-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--app-shadow);
|
||||
}
|
||||
|
||||
.app-toast-progress {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
opacity: 0.35;
|
||||
animation: toastProgress var(--toast-duration, 4500ms) linear forwards;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-fade-up {
|
||||
animation: none;
|
||||
}
|
||||
.app-card,
|
||||
.app-button,
|
||||
.app-nav-button {
|
||||
transition: none;
|
||||
}
|
||||
.app-card:hover,
|
||||
.app-button:hover {
|
||||
transform: none;
|
||||
box-shadow: var(--app-shadow-soft);
|
||||
}
|
||||
.app-toast-progress {
|
||||
animation: none;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user