)}
-
- 能力预检:
- {resource.data?.model_capability_guard === undefined
- ? "未知"
- : resource.data.model_capability_guard
- ? "开启"
- : "关闭"}
-
+
+
{rows &&
@@ -698,7 +756,14 @@ export function Models() {
-
+
{m.custom && (
-
-
- {data.audit.degraded === true
- ? "存储异常"
- : data.audit.degraded === false
- ? "正常"
- : "状态未知"}
-
-
- 查看存储详情
-
-
-
{data.session.degraded === true && (
撤销未生效
diff --git a/web/src/pages/Storage.tsx b/web/src/pages/Storage.tsx
new file mode 100644
index 0000000..af167ac
--- /dev/null
+++ b/web/src/pages/Storage.tsx
@@ -0,0 +1,230 @@
+import { useState } from "react";
+import { api, errorMessage, number, object, text, useResource } from "../api";
+import { useT } from "../i18n";
+import {
+ Badge,
+ ClearLogs,
+ DrawerPresence,
+ Empty,
+ ErrorNotice,
+ Icon,
+ PageTitle,
+ Panel,
+ ResourceState,
+} from "../components";
+import s from "../ui.module.scss";
+
+function normalize(value: unknown) {
+ return object(value, "存储状态");
+}
+
+function bytes(value: unknown): string {
+ const n = number(value);
+ if (n === null) return "—";
+ if (n < 1024) return `${n} B`;
+ if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`;
+ if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`;
+ return `${(n / 1024 ** 3).toFixed(2)} GB`;
+}
+
+function ArchivePanel({ archives, reload }: { archives: unknown[]; reload: () => void }) {
+ const t = useT();
+ // Default shorter than the 30-day retention window so opening the page (which
+ // prunes expired rows) still leaves older in-retention logs to archive.
+ const [days, setDays] = useState(7);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const [result, setResult] = useState(null);
+ return (
+
+
+
+ {result && {result} }
+
+
+
+ {t("storage.archives")}
+ {archives.length}
+
+ {archives.length ? (
+
+ {archives.map((name) => (
+ - {text(name)}
+ ))}
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
+
+export function Storage() {
+ const t = useT();
+ const resource = useResource("/storage", normalize);
+ const [clearing, setClearing] = useState(false);
+ const d = resource.data;
+ const reclaimable = number(d?.reclaimable_bytes);
+ const archives = Array.isArray(d?.archives) ? d.archives : [];
+ const stateLabel =
+ d?.degraded === true
+ ? t("storage.stateDegraded")
+ : d?.degraded === false
+ ? t("dash.status.normal")
+ : t("storage.stateUnknown");
+ return (
+ <>
+
+ {d && (
+
+ {t("storage.status")} · {stateLabel}
+
+ )}
+
+
+ {t("dash.refresh")}
+
+ >
+ }
+ />
+
+ {d && (
+ <>
+
+
+
+ {t("storage.dbFile")}
+
+
+
+
+ {bytes(d.db_bytes)}
+
+ {t("storage.counts", {
+ requests: number(d.request_count) ?? "—",
+ events: number(d.event_count) ?? "—",
+ days: number(d.retention_days) ?? "—",
+ })}
+
+
+
+
+ {t("storage.walFile")}
+
+
+
+
+ {bytes(d.wal_bytes)}
+ SHM {bytes(d.shm_bytes)}
+
+
+
+ {t("storage.logical")}
+
+
+
+
+ {bytes(d.logical_bytes)}
+ {d.pending_cleanup === true ? t("storage.pendingCleanup") : stateLabel}
+
+
+
+ {t("storage.reclaimable")}
+
+
+
+
+ {bytes(d.reclaimable_bytes)}
+
+ {number(d.freelist_pages) ?? 0} / {number(d.page_count) ?? 0} pages
+
+
+
+
+
+
+
+
+ {t("storage.vacuumDesc", {
+ reclaimable:
+ reclaimable !== null && reclaimable > 0
+ ? t("storage.vacuumReclaim", { size: bytes(reclaimable) })
+ : "",
+ })}
+
+
+ {`${t("storage.step.stop")}
+docker compose down ${t("storage.step.stopLocal")}
+
+${t("storage.step.vacuum")}
+sqlite3 "\${CODEBUDDY_AUTH_DIR:-auth}/logs.sqlite3" \\
+ "PRAGMA wal_checkpoint(TRUNCATE); VACUUM;"
+
+${t("storage.step.restart")}
+docker compose up -d`}
+
+ {t("storage.vacuumNote")}
+
+
+ {t("storage.cleanup")}
+ {t("storage.cleanupDesc")}
+
+ setClearing(true)}>
+ {t("storage.cleanupOpen")}
+
+
+
+
+
+ >
+ )}
+
+ {clearing && (
+ setClearing(false)} onDone={() => resource.reload()} />
+ )}
+
+ >
+ );
+}
diff --git a/web/src/storage.test.tsx b/web/src/storage.test.tsx
new file mode 100644
index 0000000..fb39657
--- /dev/null
+++ b/web/src/storage.test.tsx
@@ -0,0 +1,74 @@
+// @vitest-environment jsdom
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vite-plus/test";
+import { api, useResource } from "./api";
+import { Storage } from "./pages/Storage";
+
+const storagePayload = {
+ degraded: false,
+ db_bytes: 5 * 1024 * 1024,
+ wal_bytes: 1024,
+ shm_bytes: 32,
+ logical_bytes: 2 * 1024 * 1024,
+ request_count: 120,
+ event_count: 8,
+ retention_days: 30,
+ page_count: 1300,
+ freelist_pages: 512,
+ reclaimable_bytes: 512 * 4096,
+ pending_cleanup: false,
+ archives: ["audit-20260901-000000-abcdef12.jsonl"],
+};
+
+afterEach(() => vi.restoreAllMocks());
+
+describe("storage page", () => {
+ it("shows sizes with reclaimable estimate and archives old logs on demand", async () => {
+ const get = vi.spyOn(api, "get").mockResolvedValue({ data: storagePayload });
+ const post = vi.spyOn(api, "post").mockResolvedValue({
+ data: { ok: true, archived_requests: 4, archived_events: 2, file: "audit-x.jsonl" },
+ });
+ render();
+ expect(await screen.findByText("可回收空间")).toBeTruthy();
+ expect(screen.getAllByText("2.0 MB").length).toBeGreaterThan(0);
+ expect(screen.getByText(/约可回收 2.0 MB/)).toBeTruthy();
+ expect(screen.getByText("audit-20260901-000000-abcdef12.jsonl")).toBeTruthy();
+ expect(get).toHaveBeenCalledTimes(1);
+ fireEvent.click(screen.getByRole("button", { name: "归档并删除" }));
+ await waitFor(() => expect(post).toHaveBeenCalledWith("/storage/archive", { days: 7 }));
+ expect(await screen.findByText(/已归档 6 条/)).toBeTruthy();
+ await waitFor(() => expect(get).toHaveBeenCalledTimes(2));
+ });
+});
+
+const asMarker = (value: unknown) => value as { marker: string };
+const CACHE_OPTS = { cache: true };
+
+describe("cached resources", () => {
+ function Probe({ path }: { path: string }) {
+ const { data, loading } = useResource(path, asMarker, CACHE_OPTS);
+ if (loading || !data) return 加载中;
+ return {data.marker};
+ }
+
+ it("renders the cached snapshot immediately on remount while refreshing", async () => {
+ let call = 0;
+ const get = vi
+ .spyOn(api, "get")
+ .mockImplementation(
+ (path: string) =>
+ new Promise((resolve) =>
+ setTimeout(() => resolve({ data: { marker: `fresh-${path}-${++call}` } }), 5),
+ ),
+ );
+ const first = render();
+ expect(await screen.findByText("fresh-/cache-probe-1")).toBeTruthy();
+ first.unmount();
+ // Second mount: cached value shows synchronously, then the fresh GET replaces it.
+ render();
+ expect(screen.queryByText("加载中")).toBeNull();
+ expect(screen.getByText("fresh-/cache-probe-1")).toBeTruthy();
+ expect(await screen.findByText("fresh-/cache-probe-2")).toBeTruthy();
+ expect(get).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/web/src/theme.scss b/web/src/theme.scss
index fd5e340..6a4b604 100644
--- a/web/src/theme.scss
+++ b/web/src/theme.scss
@@ -25,6 +25,9 @@
--good-bg: #e6f2e5;
--glow: hsl(var(--hue) 43% 78% / 0.72);
--glow-alt: hsl(calc(var(--hue) + 38) 35% 79% / 0.65);
+ --series-requests: #3f7fb8;
+ --series-tokens: #4e9e5f;
+ --series-credit: #cf8a2e;
}
:root[data-theme="light"][data-palette="blue"] {
--hue: 213;
@@ -63,6 +66,9 @@
--good-bg: #263e2f;
--glow: #35634b55;
--glow-alt: #284e4955;
+ --series-requests: #7fb2e0;
+ --series-tokens: #8fd0a0;
+ --series-credit: #e3b96f;
}
:root[data-theme="dark"] {
@include dark;
diff --git a/web/src/trial.test.tsx b/web/src/trial.test.tsx
index 7d61d8d..30958f8 100644
--- a/web/src/trial.test.tsx
+++ b/web/src/trial.test.tsx
@@ -1,9 +1,18 @@
-import { act, fireEvent, render, screen } from "@testing-library/react";
+import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { beforeEach, expect, it, vi } from "vite-plus/test";
import { Trial } from "./Trial";
import { Credentials } from "./pages/Credentials";
import { api, useResource } from "./api";
+function openRowMenu(credentialName: string) {
+ const row = screen
+ .getAllByText(credentialName)
+ .map((el) => el.closest("tr, article"))
+ .find((tr) => tr && within(tr).queryByRole("button", { name: /^更多/ }));
+ if (!row) throw new Error(`row for ${credentialName} not found`);
+ fireEvent.click(within(row).getByRole("button", { name: /^更多/ }));
+}
+
vi.mock("./api", async (original) => ({
...(await original()),
useResource: vi.fn(),
@@ -33,7 +42,9 @@ it("only opens a confirmation drawer for supported accounts and sends no claim o
});
const post = vi.spyOn(api, "post");
render();
+ openRowMenu("cn.info");
expect(screen.queryByRole("button", { name: "领取体验积分 cn.info" })).toBeNull();
+ openRowMenu("intl.info");
fireEvent.click(screen.getByRole("button", { name: "领取体验积分 intl.info" }));
expect(screen.getByRole("dialog")).toBeTruthy();
expect(screen.getByRole("button", { name: "确认领取" })).toHaveProperty("disabled", false);
diff --git a/web/src/ui.module.scss b/web/src/ui.module.scss
index 4f6f56b..5c95670 100644
--- a/web/src/ui.module.scss
+++ b/web/src/ui.module.scss
@@ -430,6 +430,733 @@ $white: var(--page);
font-size: 10px;
color: #96a08e;
}
+.kpiGrid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 14px;
+ margin-bottom: 23px;
+}
+.kpiCard {
+ @include glass(0.48, 18px);
+ border-radius: 20px;
+ padding: 16px 18px 12px;
+ min-width: 0;
+}
+.kpiCard > div {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ color: #798771;
+ font-size: 12px;
+}
+.kpiCard > div > span:last-child {
+ width: 28px;
+ height: 28px;
+ display: grid;
+ place-items: center;
+ background: #f1f6e9;
+ color: $green;
+ border-radius: 8px;
+}
+.kpiCard strong {
+ display: block;
+ font-size: 22px;
+ letter-spacing: -0.4px;
+ font-weight: 600;
+ margin: 6px 0 2px;
+ line-height: 1.2;
+ font-variant-numeric: tabular-nums;
+}
+.kpiCard small {
+ display: block;
+ min-height: 13px;
+ font-size: 10px;
+ color: #96a08e;
+}
+.spark {
+ width: 100%;
+ height: 26px;
+ margin-top: 6px;
+}
+.kpiValue {
+ position: relative;
+ cursor: default;
+}
+.kpiTip {
+ position: absolute;
+ left: 0;
+ bottom: calc(100% + 8px);
+ z-index: 20;
+ display: none;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 120px;
+ padding: 8px 12px;
+ background: var(--primary);
+ color: var(--on-primary);
+ border-radius: 12px;
+ font-size: 11px;
+ font-weight: 500;
+ letter-spacing: 0;
+ box-shadow: 0 8px 22px var(--shadow);
+ pointer-events: none;
+ white-space: nowrap;
+}
+.kpiTip b {
+ font-size: 10px;
+ opacity: 0.75;
+ font-weight: 500;
+}
+.kpiValue:hover .kpiTip,
+.kpiValue:focus-visible .kpiTip {
+ display: flex;
+}
+.zoomReset {
+ flex-shrink: 0;
+ font-size: 11px;
+ padding: 5px 14px;
+ border-radius: 999px;
+ border: 1px solid var(--line);
+ background: var(--surface);
+ color: var(--text);
+ cursor: pointer;
+ transition:
+ border-color 0.15s ease,
+ color 0.15s ease,
+ box-shadow 0.15s ease;
+}
+.zoomReset:hover {
+ color: var(--accent);
+ border-color: var(--accent);
+ box-shadow: 0 2px 10px var(--shadow);
+}
+.moreMenu {
+ position: relative;
+ display: inline-block;
+}
+.moreMenuList {
+ position: absolute;
+ right: 0;
+ top: calc(100% + 4px);
+ z-index: 40;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 128px;
+ padding: 6px;
+ background: var(--surface);
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ box-shadow: 0 10px 26px var(--shadow);
+}
+.moreMenuList button {
+ width: 100%;
+ text-align: left;
+ white-space: nowrap;
+}
+.channelGrid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 14px;
+ margin-bottom: 20px;
+}
+@media (max-width: 1200px) {
+ .channelGrid {
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ }
+}
+.channelCard {
+ @include glass(0.48, 18px);
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 14px 16px 12px;
+ border-radius: 18px;
+ text-align: left;
+ cursor: pointer;
+ transition:
+ border-color 0.15s ease,
+ box-shadow 0.15s ease,
+ transform 0.15s ease;
+}
+.channelCard:hover {
+ border-color: var(--accent);
+ transform: translateY(-1px);
+}
+.channelActive {
+ border-color: var(--accent);
+ box-shadow: 0 6px 20px var(--shadow);
+}
+.channelHead {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ width: 100%;
+ font-size: 12px;
+ color: var(--muted);
+}
+.channelHead span:last-child {
+ font-size: 10.5px;
+ padding: 2px 8px;
+ border-radius: 999px;
+ background: var(--card);
+ border: 1px solid var(--line);
+ font-variant-numeric: tabular-nums;
+}
+.channelCard strong {
+ font-size: 20px;
+ font-weight: 600;
+ letter-spacing: -0.3px;
+ color: var(--text);
+ font-variant-numeric: tabular-nums;
+}
+.channelCard small {
+ font-size: 10.5px;
+ color: var(--muted);
+}
+.channelBar {
+ width: 100%;
+ height: 4px;
+ margin-top: 2px;
+ border-radius: 999px;
+ background: var(--line);
+ overflow: hidden;
+}
+.channelBar > i {
+ display: block;
+ height: 100%;
+ border-radius: 999px;
+ background: var(--accent);
+ transition: width 0.2s ease;
+}
+.credFilterBar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+ padding: 4px 22px 14px;
+}
+.credFilterLeft {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+ flex: 1 1 320px;
+}
+.credSearch {
+ flex: 1 1 200px;
+ max-width: 280px;
+ min-width: 150px;
+}
+.statusPills {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px;
+ border-radius: 999px;
+ background: var(--card);
+ border: 1px solid var(--line);
+}
+.statusPills button,
+.viewSwitch button {
+ padding: 4px 11px;
+ font-size: 11px;
+ border-radius: 999px;
+ border: 1px solid transparent;
+ background: transparent;
+ color: var(--muted);
+}
+.statusPills button.pillActive,
+.viewSwitch button.viewActive {
+ background: var(--surface);
+ color: var(--text);
+ border-color: var(--line);
+ box-shadow: 0 2px 8px var(--shadow);
+}
+.viewSwitch {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px;
+ border-radius: 999px;
+ background: var(--card);
+ border: 1px solid var(--line);
+}
+.credCardGrid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
+ gap: 16px;
+ padding: 4px 22px 22px;
+}
+@media (max-width: 600px) {
+ .credCardGrid {
+ grid-template-columns: 1fr;
+ padding: 4px 14px 18px;
+ }
+}
+.credCard {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 16px 18px;
+ background: var(--card);
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ min-width: 0;
+}
+.credCardHead {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 10px;
+}
+.credCardTitle {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ min-width: 0;
+}
+.credCardTitle input {
+ margin-top: 3px;
+}
+.credCardTitle > div {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+.credCardTitle strong {
+ font-size: 14px;
+ word-break: break-all;
+}
+.credCardTitle small {
+ font-size: 11px;
+ color: var(--muted);
+}
+.credCardBadges {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ flex-shrink: 0;
+}
+.credCardStats {
+ display: grid;
+ grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.25fr);
+ gap: 8px;
+}
+.credCardStats > div {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ padding: 10px 12px;
+ border-radius: 12px;
+ background: var(--surface);
+ border: 1px solid var(--line);
+ min-width: 0;
+}
+.credStatHead {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 6px;
+}
+.credStatHead > span,
+.credCardStats > div > span {
+ font-size: 10px;
+ color: var(--muted);
+}
+.credCardStats > div > strong {
+ font-size: 15px;
+ font-variant-numeric: tabular-nums;
+}
+.credCardStats > div > small {
+ font-size: 10px;
+ color: var(--muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.credSyncTags {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+ margin-top: 2px;
+}
+.iconBtn {
+ width: 26px;
+ height: 26px;
+ padding: 0;
+ display: inline-grid;
+ place-items: center;
+ border-radius: 50%;
+ border: 1px solid var(--line);
+ background: var(--card);
+ color: var(--text);
+ flex-shrink: 0;
+}
+.iconBtn svg {
+ width: 13px;
+ height: 13px;
+}
+.credUsageBox {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 8px;
+ padding: 11px 12px;
+ border-radius: 14px;
+ background: var(--surface);
+ border: 1px solid var(--line);
+}
+.credUsageBox > div {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+.credUsageBox span {
+ font-size: 10px;
+ color: var(--muted);
+}
+.credUsageBox strong {
+ font-size: 13.5px;
+ font-variant-numeric: tabular-nums;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.credUsageBox small {
+ font-size: 10px;
+ color: var(--muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.tableUsage {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(92px, 1fr));
+ gap: 4px 12px;
+}
+.tableUsage > div {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 6px;
+ font-size: 11px;
+}
+.tableUsage span {
+ font-size: 10px;
+ color: var(--muted);
+}
+.tableUsage strong {
+ font-size: 11.5px;
+ font-variant-numeric: tabular-nums;
+}
+.credPager {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+ padding: 12px 22px 18px;
+ border-top: 1px solid var(--line);
+ font-size: 11.5px;
+ color: var(--muted);
+}
+.credPagerRight {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+.detailBar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ flex-wrap: wrap;
+ margin-bottom: 14px;
+}
+.detailKpis {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+ margin-bottom: 14px;
+}
+.detailKpis > div {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ padding: 12px 14px;
+ border-radius: 14px;
+ background: var(--card);
+ border: 1px solid var(--line);
+}
+.detailKpis span {
+ font-size: 10.5px;
+ color: var(--muted);
+}
+.detailKpis strong {
+ font-size: 18px;
+ font-variant-numeric: tabular-nums;
+}
+.detailKpis small {
+ font-size: 10.5px;
+ color: var(--muted);
+}
+.credCardTasks {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 10px 12px;
+ border-radius: 12px;
+ background: var(--surface);
+ border: 1px solid var(--line);
+}
+.credTaskRow {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ font-size: 11.5px;
+}
+.credTaskRow .check {
+ margin: 0;
+ flex-shrink: 0;
+}
+.enableSwitch {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 3px 10px 3px 5px;
+ border-radius: 999px;
+ border: 1px solid var(--line);
+ background: var(--surface);
+ font-size: 11px;
+ color: var(--text);
+ cursor: pointer;
+ user-select: none;
+}
+.enableSwitch input {
+ appearance: none;
+ width: 26px;
+ height: 15px;
+ margin: 0;
+ border-radius: 999px;
+ background: var(--line);
+ position: relative;
+ cursor: pointer;
+ transition: background 0.15s ease;
+}
+.enableSwitch input::after {
+ content: "";
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 11px;
+ height: 11px;
+ border-radius: 50%;
+ background: #fff;
+ transition: transform 0.15s ease;
+}
+.enableSwitch input:checked {
+ background: var(--accent);
+}
+.enableSwitch input:checked::after {
+ transform: translateX(11px);
+}
+.enableSwitch input:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+.credTaskRow small {
+ font-size: 10.5px;
+ color: var(--muted);
+ text-align: right;
+}
+.credCardFoot {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ flex-wrap: wrap;
+ margin-top: auto;
+ padding-top: 4px;
+}
+.zoomHint {
+ margin-left: auto;
+ font-size: 10px;
+ color: var(--muted);
+ opacity: 0.8;
+}
+.storageSplit {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 22px;
+ align-items: stretch;
+}
+@media (max-width: 980px) {
+ .storageSplit {
+ grid-template-columns: 1fr;
+ }
+}
+.panelBody {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ padding: 2px 22px 22px;
+}
+.storageLead {
+ margin: 0;
+ font-size: 12px;
+ line-height: 1.65;
+ color: var(--text);
+}
+.storageSubtle {
+ font-size: 11px;
+ line-height: 1.5;
+ color: var(--muted);
+}
+.archiveForm {
+ display: flex;
+ align-items: flex-end;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+.archiveForm .field {
+ flex: 1 1 160px;
+ max-width: 220px;
+ margin: 0;
+}
+.storageSection {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding-top: 14px;
+ border-top: 1px solid var(--line);
+}
+.storageSectionHead {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 12px;
+}
+.storageSectionHead span {
+ padding: 2px 8px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-variant-numeric: tabular-nums;
+ background: var(--card);
+ border: 1px solid var(--line);
+ color: var(--muted);
+}
+.archiveList {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ max-height: 210px;
+ overflow-y: auto;
+}
+.archiveList li {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ background: var(--card);
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ font-size: 12px;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ color: var(--text);
+}
+.cleanupBox {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 14px 16px;
+ margin-top: auto;
+ background: var(--card);
+ border: 1px solid var(--line);
+ border-radius: 16px;
+}
+.cleanupBox strong {
+ display: block;
+ font-size: 12px;
+ margin-bottom: 4px;
+}
+.cleanupBox p {
+ margin: 0;
+ font-size: 11px;
+ line-height: 1.5;
+ color: var(--muted);
+}
+.cleanupBox button {
+ flex-shrink: 0;
+}
+.resultNote {
+ font-size: 12px;
+ color: var(--accent);
+ margin: 0;
+}
+.codeBlock {
+ font-size: 11.5px;
+ line-height: 1.55;
+ background: var(--card);
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ padding: 12px 14px;
+ overflow-x: auto;
+ white-space: pre;
+ margin: 0;
+}
+.chartHead {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin: -4px 15px 2px;
+}
+.chartHead .zoomHint {
+ margin-left: 0;
+}
+.chartLegend {
+ display: flex;
+ gap: 18px;
+ justify-content: center;
+ padding: 4px 0 8px;
+ font-size: 11px;
+ color: $muted;
+}
+.legendDot {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ margin-right: 6px;
+}
+.panelScroll {
+ max-height: 340px;
+ overflow-y: auto;
+}
+.panelScroll thead th {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ background: var(--surface);
+ box-shadow: 0 1px 0 var(--line);
+}
+@media (max-width: 1100px) {
+ .kpiGrid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
.dashboardGrid {
display: grid;
grid-template-columns: minmax(0, 1.65fr) minmax(0, 1fr);
@@ -454,8 +1181,8 @@ $white: var(--page);
}
.chart {
width: 100%;
- min-height: 190px;
- padding: 0 15px;
+ height: 210px;
+ display: block;
}
.chart text {
font-size: 11px;
@@ -768,9 +1495,13 @@ $white: var(--page);
padding: 26px 28px 42px;
}
.drawerBody > .panel,
-.drawerBody form + .panel {
+.drawerBody form + .panel,
+.drawerBody section + section {
margin-top: 22px;
}
+.drawerBody .panel + .panel {
+ margin-top: 14px;
+}
.field {
display: flex;
flex-direction: column;
@@ -828,7 +1559,7 @@ $white: var(--page);
}
.details > div {
display: grid;
- grid-template-columns: minmax(100px, 0.85fr) minmax(0, 1.3fr);
+ grid-template-columns: 140px minmax(0, 1fr);
gap: 15px;
border-bottom: 1px solid #edf0e6;
padding: 10px 0;
@@ -1778,7 +2509,8 @@ $white: var(--page);
padding: 0 24px 20px;
}
.appearanceChoices,
-.paletteChoices {
+.paletteChoices,
+.langChoices {
border: 0;
padding: 0;
margin: 18px 0 24px;
@@ -1787,7 +2519,8 @@ $white: var(--page);
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.appearanceChoices legend,
-.paletteChoices legend {
+.paletteChoices legend,
+.langChoices legend {
margin-bottom: 12px;
font-size: 12px;
color: var(--muted);
@@ -1806,7 +2539,8 @@ $white: var(--page);
font-size: 11px;
}
.appearanceChoices input,
-.paletteChoices input {
+.paletteChoices input,
+.langChoices input {
position: absolute;
opacity: 0;
inset: 0;
@@ -1943,17 +2677,24 @@ $white: var(--page);
font-size: 11px;
}
.chartTooltip dl {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
+ display: flex;
+ flex-direction: column;
margin: 8px 0 0;
- gap: 12px;
+ gap: 6px;
+}
+.chartTooltip dl > div {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 16px;
}
.chartTooltip dt {
color: var(--muted);
+ white-space: nowrap;
}
.chartTooltip dd {
margin: 0;
- font-size: 15px;
+ font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@@ -2219,3 +2960,23 @@ $white: var(--page);
backdrop-filter: none;
}
}
+
+.langChoices label {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 64px;
+ height: 64px;
+ margin: 0 auto;
+ border: 1px solid var(--line);
+ border-radius: 50%;
+ cursor: pointer;
+ font-size: 12px;
+ position: relative;
+ color: var(--muted);
+}
+.langChoices label:has(:checked) {
+ border-color: var(--accent);
+ color: var(--accent);
+ background: var(--soft);
+}
|