From 589684b6eeb6bad9614ef7436999fd91f292bc9e Mon Sep 17 00:00:00 2001 From: root Date: Thu, 16 Jul 2026 09:36:40 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20ApiState=20?= =?UTF-8?q?=E5=A4=9A=E9=A1=B5=E9=9D=A2=E7=9B=91=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/sub2rank.yaml | 32 +- package.json | 4 +- scripts/{sub2rank-cli.ts => apistate-cli.ts} | 0 scripts/src/cli.ts | 36 ++- src/account-score-service.ts | 187 +++++++++++ src/admin-http-client.ts | 7 +- src/bootstrap.ts | 29 +- src/config.ts | 35 ++ src/http.ts | 87 ++++- src/server.ts | 4 +- src/web-auth.ts | 61 ++++ static/app.js | 322 +++++++++++-------- static/index.html | 87 ----- static/login.html | 30 ++ static/lottery.html | 27 ++ static/ranking.html | 19 ++ static/scores.html | 42 +++ static/styles.css | 263 ++++++++------- 18 files changed, 884 insertions(+), 388 deletions(-) rename scripts/{sub2rank-cli.ts => apistate-cli.ts} (100%) create mode 100644 src/account-score-service.ts create mode 100644 src/web-auth.ts delete mode 100644 static/index.html create mode 100644 static/login.html create mode 100644 static/lottery.html create mode 100644 static/ranking.html create mode 100644 static/scores.html diff --git a/config/sub2rank.yaml b/config/sub2rank.yaml index 8b48b55..d7fa310 100644 --- a/config/sub2rank.yaml +++ b/config/sub2rank.yaml @@ -2,9 +2,26 @@ apiVersion: sub2rank.pikapython.com/v1 kind: Sub2Rank metadata: - name: sub2rank + name: apistate owner: unidesk +monitor: + timezone: Asia/Shanghai + refreshIntervalMinutes: 10 + scoreWindow: 8h + target: PK01 + cli: + workDir: /opt/unidesk + executable: bun + entrypoint: scripts/cli.ts + mainServerHost: 152.53.229.148 + timeoutMs: 180000 + +webAuth: + username: admin + cookieName: apistate_session + sessionTtlSeconds: 43200 + sub2api: baseUrl: https://api.pikapython.com/api/v1 requestTimeoutMs: 20000 @@ -61,7 +78,7 @@ runtime: secretsRoot: /root/.unidesk/.state/secrets secretSourcePaths: platform-infra/sub2api.env: /root/.unidesk/.state/secrets/platform-infra/sub2api.env - platform-infra/sub2rank.env: /root/.unidesk/.state/secrets/platform-infra/sub2rank.env + platform-infra/apistate.env: /root/.unidesk/.state/secrets/platform-infra/apistate.env defaultCliTarget: local cliTargets: local: @@ -69,15 +86,18 @@ runtime: databasePath: .state/sub2rank.sqlite production: mode: http - baseUrl: https://rank.pikapython.com + baseUrl: https://apistate.hwpod.com adminToken: - sourceRef: platform-infra/sub2rank.env - sourceKey: SUB2RANK_ADMIN_TOKEN + sourceRef: platform-infra/apistate.env + sourceKey: APISTATE_API_KEY serverTargets: k8s: listenHost: 0.0.0.0 listenPort: 8080 databasePath: /var/lib/sub2rank/sub2rank.sqlite - adminTokenEnv: SUB2RANK_ADMIN_TOKEN + adminTokenEnv: APISTATE_API_KEY sub2apiAdminEmailEnv: SUB2API_ADMIN_EMAIL sub2apiAdminPasswordEnv: SUB2API_ADMIN_PASSWORD + webPasswordEnv: APISTATE_WEB_PASSWORD + apiKeyEnv: APISTATE_API_KEY + sessionSecretEnv: APISTATE_SESSION_SECRET diff --git a/package.json b/package.json index 5e1aa97..1a2bed0 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { - "name": "sub2rank", + "name": "apistate", "version": "0.1.0", "private": true, "type": "module", "scripts": { - "cli": "bun scripts/sub2rank-cli.ts --config config/sub2rank.yaml", + "cli": "bun scripts/apistate-cli.ts --config config/sub2rank.yaml", "server": "bun src/server.ts --config config/sub2rank.yaml --runtime k8s", "check": "bunx tsc --noEmit" }, diff --git a/scripts/sub2rank-cli.ts b/scripts/apistate-cli.ts similarity index 100% rename from scripts/sub2rank-cli.ts rename to scripts/apistate-cli.ts diff --git a/scripts/src/cli.ts b/scripts/src/cli.ts index 24e7be7..7bd1027 100644 --- a/scripts/src/cli.ts +++ b/scripts/src/cli.ts @@ -56,7 +56,7 @@ function parseArgs(args: string[]): Parsed { function help(): Record { return { ok: true, - usage: "bun scripts/sub2rank-cli.ts --config config/sub2rank.yaml [--target local|production] ", + usage: "bun scripts/apistate-cli.ts --config config/sub2rank.yaml [--target local|production] ", commands: [ "config validate", "backend check", @@ -66,6 +66,7 @@ function help(): Record { "records list [--limit N]", "records delete --id --confirm", "credit test [--confirm]", + "api smoke", ], }; } @@ -109,6 +110,27 @@ async function remote(parsed: Parsed, config: ReturnType, tar return parsed.confirm ? await client.deleteRecord(parsed.id) : { ok: true, mutation: false, action: "record-delete", id: parsed.id, hint: "add --confirm to execute" }; } if (group === "credit" && action === "test") return await client.creditTest(parsed.confirm); + if (group === "api" && action === "smoke") { + const [status, scores, ranking, lottery] = await Promise.all([ + client.serviceStatus(), + client.scores(), + client.ranking(), + client.lottery(), + ]); + const refreshed = await client.refreshScores(); + return { + ok: true, + action: "apistate-api-smoke", + checks: { + status: status.ok === true, + scores: scores.ok === true, + refresh: refreshed.ok === true, + ranking: ranking.ok === true, + lottery: lottery.ok === true, + }, + valuesPrinted: false, + }; + } throw new Error(`unknown command: ${parsed.command.join(" ")}`); } @@ -121,7 +143,17 @@ export async function runCli(args: string[]): Promise { const parsed = parseArgs(args); const config = loadConfig(parsed.configPath); if (parsed.command.join(" ") === "config validate") { - console.log(JSON.stringify({ ok: true, configPath: config.configPath, kind: config.kind, automaticCreditEnabled: config.lottery.automaticCredit.enabled, excludedIdentities: config.lottery.eligibility.excludedIdentities }, null, 2)); + console.log(JSON.stringify({ + ok: true, + configPath: config.configPath, + kind: config.kind, + service: config.metadata.name, + refreshIntervalMinutes: config.monitor.refreshIntervalMinutes, + scoreWindow: config.monitor.scoreWindow, + automaticCreditEnabled: config.lottery.automaticCredit.enabled, + excludedIdentities: config.lottery.eligibility.excludedIdentities, + valuesPrinted: false, + }, null, 2)); return; } const targetId = parsed.targetId ?? config.runtime.defaultCliTarget; diff --git a/src/account-score-service.ts b/src/account-score-service.ts new file mode 100644 index 0000000..0434717 --- /dev/null +++ b/src/account-score-service.ts @@ -0,0 +1,187 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import type { AppConfig } from "./config"; + +interface ScoreSnapshot { + ok: boolean; + status: "ready" | "refreshing" | "stale" | "unavailable"; + refreshedAt: string | null; + refreshStartedAt: string | null; + nextRefreshAt: string | null; + window: string; + groups: Array>; + accounts: Array>; + error: string | null; + source: string; +} + +function record(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; +} + +function records(value: unknown): Array> { + return Array.isArray(value) ? value.map(record).filter((item): item is Record => item !== null) : []; +} + +function nested(root: unknown, ...keys: string[]): unknown { + let value = root; + for (const key of keys) value = record(value)?.[key]; + return value; +} + +async function pooled(values: T[], concurrency: number, operation: (value: T) => Promise): Promise { + const results = new Array(values.length); + let cursor = 0; + await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, async () => { + for (;;) { + const index = cursor++; + if (index >= values.length) return; + results[index] = await operation(values[index]!); + } + })); + return results; +} + +export class AccountScoreService { + private timer: ReturnType | null = null; + private inFlight: Promise | null = null; + private snapshot: ScoreSnapshot; + + constructor(private readonly config: AppConfig, private readonly cachePath: string) { + this.snapshot = this.readCache(); + } + + start(): void { + void this.refresh().catch(() => undefined); + this.timer = setInterval(() => void this.refresh().catch(() => undefined), this.config.monitor.refreshIntervalMinutes * 60_000); + } + + close(): void { + if (this.timer) clearInterval(this.timer); + } + + state(): ScoreSnapshot { + return { ...this.snapshot, status: this.inFlight ? "refreshing" : this.snapshot.status }; + } + + async refresh(): Promise { + if (this.inFlight) return await this.inFlight; + this.inFlight = this.performRefresh(); + try { + return await this.inFlight; + } finally { + this.inFlight = null; + } + } + + private async performRefresh(): Promise { + const startedAt = new Date(); + this.snapshot = { ...this.snapshot, status: "refreshing", refreshStartedAt: startedAt.toISOString(), error: null }; + try { + const overview = await this.invoke(["--all-groups"]); + const groups = records(nested(overview, "data", "runtime", "allGroups", "groups")); + const details = await pooled(groups, 2, async (group) => { + const id = String(group.groupId ?? ""); + if (!id) return { group, accounts: [] as Array> }; + const payload = await this.invoke(["--group", id]); + return { + group, + accounts: records(nested(payload, "data", "runtime", "errors", "nativeOps", "accountQuality", "accounts")), + }; + }); + const refreshedAt = new Date(); + const accounts: Array> = details.flatMap(({ group, accounts }) => accounts.map((account): Record => ({ + groupId: group.groupId ?? null, + groupName: group.groupName ?? null, + platform: group.platform ?? null, + ...account, + }))).sort((left, right) => Number(right.score ?? -1) - Number(left.score ?? -1)); + this.snapshot = { + ok: true, + status: "ready", + refreshedAt: refreshedAt.toISOString(), + refreshStartedAt: startedAt.toISOString(), + nextRefreshAt: new Date(refreshedAt.getTime() + this.config.monitor.refreshIntervalMinutes * 60_000).toISOString(), + window: this.config.monitor.scoreWindow, + groups, + accounts, + error: null, + source: "unidesk-sub2api-runtime-errors", + }; + this.writeCache(this.snapshot); + return this.snapshot; + } catch (error) { + this.snapshot = { + ...this.snapshot, + ok: this.snapshot.refreshedAt !== null, + status: this.snapshot.refreshedAt === null ? "unavailable" : "stale", + refreshStartedAt: startedAt.toISOString(), + error: error instanceof Error ? error.message : String(error), + }; + return this.snapshot; + } + } + + private async invoke(scope: string[]): Promise> { + const args = [ + resolve(this.config.monitor.cli.workDir, this.config.monitor.cli.entrypoint), + "platform-infra", "sub2api", "codex-pool", "runtime", "errors", + "--target", this.config.monitor.target, + "--since", this.config.monitor.scoreWindow, + ...scope, + "--raw", + ]; + const process = Bun.spawn([this.config.monitor.cli.executable, ...args], { + cwd: this.config.monitor.cli.workDir, + stdout: "pipe", + stderr: "pipe", + env: { ...Bun.env, UNIDESK_MAIN_SERVER_IP: this.config.monitor.cli.mainServerHost }, + }); + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + process.kill(); + }, this.config.monitor.cli.timeoutMs); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(process.stdout).text(), + new Response(process.stderr).text(), + process.exited, + ]).finally(() => clearTimeout(timeout)); + if (timedOut) throw new Error(`评分 CLI 在 ${this.config.monitor.cli.timeoutMs}ms 后超时`); + if (exitCode !== 0) throw new Error(`评分 CLI 退出码 ${exitCode}: ${stderr.trim().slice(-600)}`); + const payload = record(JSON.parse(stdout)); + if (!payload || payload.ok !== true) throw new Error("评分 CLI 返回无效结果"); + if (nested(payload, "data", "outputTruncated")) throw new Error("评分 CLI 输出被截断"); + return payload; + } + + private readCache(): ScoreSnapshot { + if (existsSync(this.cachePath)) { + try { + const cached = record(JSON.parse(readFileSync(this.cachePath, "utf8"))) as ScoreSnapshot | null; + if (cached) return { ...cached, status: "stale" }; + } catch { + // Invalid cache is replaced by the next successful refresh. + } + } + return { + ok: false, + status: "unavailable", + refreshedAt: null, + refreshStartedAt: null, + nextRefreshAt: null, + window: this.config.monitor.scoreWindow, + groups: [], + accounts: [], + error: null, + source: "unidesk-sub2api-runtime-errors", + }; + } + + private writeCache(snapshot: ScoreSnapshot): void { + mkdirSync(dirname(this.cachePath), { recursive: true }); + const next = `${this.cachePath}.next`; + writeFileSync(next, `${JSON.stringify(snapshot, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + renameSync(next, this.cachePath); + } +} diff --git a/src/admin-http-client.ts b/src/admin-http-client.ts index 2dc91f1..5aa3465 100644 --- a/src/admin-http-client.ts +++ b/src/admin-http-client.ts @@ -18,7 +18,7 @@ export class AdminHttpClient { signal: AbortSignal.timeout(this.config.sub2api.requestTimeoutMs), }); const payload = await response.json().catch(() => null) as { ok?: boolean; error?: string } | null; - if (!response.ok || !payload) throw new Error(payload?.error ?? `Sub2Rank admin API failed with HTTP ${response.status}`); + if (!response.ok || !payload) throw new Error(payload?.error ?? `ApiState API failed with HTTP ${response.status}`); return payload as T; } @@ -31,4 +31,9 @@ export class AdminHttpClient { records(limit: number): Promise> { return this.request(`/api/admin/records?limit=${limit}`); } deleteRecord(id: string): Promise> { return this.request(`/api/admin/records/${encodeURIComponent(id)}`, { method: "DELETE" }); } creditTest(execute: boolean): Promise> { return this.request("/api/admin/credit-test", { method: "POST", body: JSON.stringify({ execute }) }); } + serviceStatus(): Promise> { return this.request("/api/status"); } + scores(): Promise> { return this.request("/api/scores"); } + refreshScores(): Promise> { return this.request("/api/scores/refresh", { method: "POST", body: "{}" }); } + ranking(): Promise> { return this.request("/api/ranking"); } + lottery(): Promise> { return this.request("/api/lottery"); } } diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 67d9301..e85bc6f 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -4,24 +4,47 @@ import { LotteryService } from "./lottery-service"; import { readSub2ApiCredentials } from "./secrets"; import { LotteryStore } from "./store"; import { Sub2ApiClient } from "./sub2api-client"; +import { AccountScoreService } from "./account-score-service"; +import type { WebAuthSecrets } from "./web-auth"; export interface AppContext { service: LotteryService; store: LotteryStore; + monitor: AccountScoreService; + auth: WebAuthSecrets; close(): void; } export function createEmbeddedContext(config: AppConfig, target: EmbeddedCliTarget): AppContext { const store = new LotteryStore(config, resolveDataPath(config, target.databasePath)); const client = new Sub2ApiClient(config, readSub2ApiCredentials(config)); - return { service: new LotteryService(config, store, client), store, close: () => store.close() }; + const monitor = new AccountScoreService(config, resolveDataPath(config, ".state/account-scores.json")); + return { + service: new LotteryService(config, store, client), + store, + monitor, + auth: { password: "embedded", apiKey: "embedded", sessionSecret: "embedded" }, + close: () => { monitor.close(); store.close(); }, + }; } export function createServerContext(config: AppConfig, target: ServerTarget): AppContext { const email = process.env[target.sub2apiAdminEmailEnv]; const password = process.env[target.sub2apiAdminPasswordEnv]; - if (!email || !password) throw new Error(`server target requires env ${target.sub2apiAdminEmailEnv} and ${target.sub2apiAdminPasswordEnv}`); + const webPassword = process.env[target.webPasswordEnv]; + const apiKey = process.env[target.apiKeyEnv]; + const sessionSecret = process.env[target.sessionSecretEnv]; + if (!email || !password || !webPassword || !apiKey || !sessionSecret) { + throw new Error("server target is missing one or more declared secret environment keys"); + } const store = new LotteryStore(config, resolveDataPath(config, target.databasePath)); const client = new Sub2ApiClient(config, { email, password }); - return { service: new LotteryService(config, store, client), store, close: () => store.close() }; + const monitor = new AccountScoreService(config, resolveDataPath(config, "account-scores.json")); + return { + service: new LotteryService(config, store, client), + store, + monitor, + auth: { password: webPassword, apiKey, sessionSecret }, + close: () => { monitor.close(); store.close(); }, + }; } diff --git a/src/config.ts b/src/config.ts index 618b267..be6a14c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -14,6 +14,14 @@ export interface AppConfig { apiVersion: string; kind: string; metadata: { name: string; owner: string }; + monitor: { + timezone: string; + refreshIntervalMinutes: number; + scoreWindow: string; + target: string; + cli: { workDir: string; executable: string; entrypoint: string; mainServerHost: string; timeoutMs: number }; + }; + webAuth: { username: string; cookieName: string; sessionTtlSeconds: number }; sub2api: { baseUrl: string; requestTimeoutMs: number; @@ -71,6 +79,9 @@ export interface ServerTarget { adminTokenEnv: string; sub2apiAdminEmailEnv: string; sub2apiAdminPasswordEnv: string; + webPasswordEnv: string; + apiKeyEnv: string; + sessionSecretEnv: string; } type ObjectValue = Record; @@ -134,6 +145,9 @@ export function loadConfig(path: string): AppConfig { const raw = object(parse(readFileSync(configPath, "utf8")), "config"); const metadata = object(raw.metadata, "metadata"); const sub2api = object(raw.sub2api, "sub2api"); + const monitor = object(raw.monitor, "monitor"); + const monitorCli = object(monitor.cli, "monitor.cli"); + const webAuth = object(raw.webAuth, "webAuth"); const adminCredentials = object(sub2api.adminCredentials, "sub2api.adminCredentials"); const lottery = object(raw.lottery, "lottery"); const dailyGrant = object(lottery.dailyGrant, "lottery.dailyGrant"); @@ -165,6 +179,9 @@ export function loadConfig(path: string): AppConfig { adminTokenEnv: stringValue(target, "adminTokenEnv", `runtime.serverTargets.${id}`), sub2apiAdminEmailEnv: stringValue(target, "sub2apiAdminEmailEnv", `runtime.serverTargets.${id}`), sub2apiAdminPasswordEnv: stringValue(target, "sub2apiAdminPasswordEnv", `runtime.serverTargets.${id}`), + webPasswordEnv: stringValue(target, "webPasswordEnv", `runtime.serverTargets.${id}`), + apiKeyEnv: stringValue(target, "apiKeyEnv", `runtime.serverTargets.${id}`), + sessionSecretEnv: stringValue(target, "sessionSecretEnv", `runtime.serverTargets.${id}`), }; } const automaticMode = stringValue(automaticCredit, "mode", "lottery.automaticCredit"); @@ -175,6 +192,24 @@ export function loadConfig(path: string): AppConfig { apiVersion: stringValue(raw, "apiVersion", "config"), kind: stringValue(raw, "kind", "config"), metadata: { name: stringValue(metadata, "name", "metadata"), owner: stringValue(metadata, "owner", "metadata") }, + monitor: { + timezone: timezoneValue(monitor, "timezone", "monitor"), + refreshIntervalMinutes: integerValue(monitor, "refreshIntervalMinutes", "monitor", 1, 1440), + scoreWindow: stringValue(monitor, "scoreWindow", "monitor"), + target: stringValue(monitor, "target", "monitor"), + cli: { + workDir: stringValue(monitorCli, "workDir", "monitor.cli"), + executable: stringValue(monitorCli, "executable", "monitor.cli"), + entrypoint: stringValue(monitorCli, "entrypoint", "monitor.cli"), + mainServerHost: stringValue(monitorCli, "mainServerHost", "monitor.cli"), + timeoutMs: integerValue(monitorCli, "timeoutMs", "monitor.cli", 1000), + }, + }, + webAuth: { + username: stringValue(webAuth, "username", "webAuth"), + cookieName: stringValue(webAuth, "cookieName", "webAuth"), + sessionTtlSeconds: integerValue(webAuth, "sessionTtlSeconds", "webAuth", 300), + }, sub2api: { baseUrl: stringValue(sub2api, "baseUrl", "sub2api").replace(/\/$/u, ""), requestTimeoutMs: integerValue(sub2api, "requestTimeoutMs", "sub2api", 1), diff --git a/src/http.ts b/src/http.ts index 42c5496..bf8cc48 100644 --- a/src/http.ts +++ b/src/http.ts @@ -1,41 +1,100 @@ -import type { LotteryService } from "./lottery-service"; import { resolve } from "node:path"; +import type { AccountScoreService } from "./account-score-service"; +import type { AppConfig } from "./config"; +import type { LotteryService } from "./lottery-service"; +import { + apiKeyAuthorized, + clearSessionCookie, + createSessionCookie, + sessionAuthorized, + validLogin, + type WebAuthSecrets, +} from "./web-auth"; const staticRoot = resolve(import.meta.dir, "../static"); -function json(data: unknown, status = 200): Response { - return Response.json(data, { status, headers: { "cache-control": "no-store" } }); +function json(data: unknown, status = 200, headers: HeadersInit = {}): Response { + return Response.json(data, { status, headers: { "cache-control": "no-store", ...headers } }); +} + +function redirect(location: string): Response { + return new Response(null, { status: 302, headers: { location, "cache-control": "no-store" } }); } async function body(request: Request): Promise> { return await request.json().catch(() => ({})) as Record; } -function errorResponse(error: unknown, publicRequest: boolean): Response { +function errorResponse(error: unknown): Response { const message = error instanceof Error ? error.message : String(error); const status = /does not exist|no draw chance|no eligible/u.test(message) ? 409 : 500; - if (status >= 500) console.error(JSON.stringify({ ok: false, path: publicRequest ? "public" : "admin", error: message })); - return json({ ok: false, error: publicRequest && status >= 500 ? "开奖服务暂时不可用,请稍后重试" : message }, status); + if (status >= 500) console.error(JSON.stringify({ ok: false, component: "http", error: message })); + return json({ ok: false, error: status >= 500 ? "服务暂时不可用,请稍后重试" : message }, status); } async function staticFile(name: string, contentType: string): Promise { const file = Bun.file(resolve(staticRoot, name)); if (!(await file.exists())) return json({ ok: false, error: "not found" }, 404); - return new Response(file, { headers: { "content-type": contentType, "cache-control": name === "index.html" ? "no-cache" : "public, max-age=300" } }); + return new Response(file, { + headers: { + "content-type": contentType, + "cache-control": name.endsWith(".html") ? "no-cache" : "public, max-age=300", + }, + }); } -export function createHandler(service: LotteryService, adminToken: string): (request: Request) => Promise { +export function createHandler( + service: LotteryService, + monitor: AccountScoreService, + config: AppConfig, + auth: WebAuthSecrets, + legacyAdminToken: string, +): (request: Request) => Promise { return async (request) => { const url = new URL(request.url); + const session = sessionAuthorized(request, config, auth); + const apiKey = apiKeyAuthorized(request, auth) || request.headers.get("authorization") === `Bearer ${legacyAdminToken}`; try { - if (request.method === "GET" && url.pathname === "/health") return json({ ok: true, service: "sub2rank" }); - if ((request.method === "GET" || request.method === "HEAD") && url.pathname === "/") return await staticFile("index.html", "text/html; charset=utf-8"); + if (request.method === "GET" && url.pathname === "/health") { + const scores = monitor.state(); + return json({ ok: true, service: "apistate", scoreStatus: scores.status, refreshedAt: scores.refreshedAt }); + } + if (request.method === "POST" && url.pathname === "/api/login") { + const input = await body(request); + if (!validLogin(input.username, input.password, config, auth)) return json({ ok: false, error: "用户名或密码错误" }, 401); + return json({ ok: true, username: config.webAuth.username }, 200, { "set-cookie": createSessionCookie(config, auth) }); + } + if (request.method === "POST" && url.pathname === "/api/logout") { + return json({ ok: true }, 200, { "set-cookie": clearSessionCookie(config) }); + } + if ((request.method === "GET" || request.method === "HEAD") && url.pathname === "/login") { + return session ? redirect("/scores") : await staticFile("login.html", "text/html; charset=utf-8"); + } if ((request.method === "GET" || request.method === "HEAD") && url.pathname === "/styles.css") return await staticFile("styles.css", "text/css; charset=utf-8"); if ((request.method === "GET" || request.method === "HEAD") && url.pathname === "/app.js") return await staticFile("app.js", "text/javascript; charset=utf-8"); - if (request.method === "GET" && url.pathname === "/api/public/state") return json(await service.publicState()); - if (request.method === "POST" && url.pathname === "/api/public/draw") return json({ ok: true, record: await service.publicDraw() }); + if (request.method === "GET" && url.pathname === "/") return redirect(session ? "/scores" : "/login"); + const page = ({ "/scores": "scores.html", "/ranking": "ranking.html", "/lottery": "lottery.html" } as Record)[url.pathname]; + if (page) return session ? await staticFile(page, "text/html; charset=utf-8") : redirect("/login"); + + if (url.pathname.startsWith("/api/") && !session && !apiKey) return json({ ok: false, error: "unauthorized" }, 401); + if (request.method === "GET" && url.pathname === "/api/status") { + const scores = monitor.state(); + return json({ ok: true, service: "apistate", scoreStatus: scores.status, refreshedAt: scores.refreshedAt, nextRefreshAt: scores.nextRefreshAt }); + } + if (request.method === "GET" && url.pathname === "/api/scores") { + const state = monitor.state(); + return json({ ...state, snapshotOk: state.ok, ok: true }); + } + if (request.method === "POST" && url.pathname === "/api/scores/refresh") { + const state = await monitor.refresh(); + return json({ ...state, snapshotOk: state.ok, ok: true }); + } + if (request.method === "GET" && url.pathname === "/api/ranking") return json({ ok: true, ranking: await service.ranking() }); + if (request.method === "GET" && url.pathname === "/api/lottery") return json(await service.publicState()); + if (request.method === "POST" && url.pathname === "/api/lottery/draw") return json({ ok: true, record: await service.publicDraw() }); + if (!url.pathname.startsWith("/api/admin/")) return json({ ok: false, error: "not found" }, 404); - if (request.headers.get("authorization") !== `Bearer ${adminToken}`) return json({ ok: false, error: "unauthorized" }, 401); + if (!apiKey) return json({ ok: false, error: "unauthorized" }, 401); if (request.method === "GET" && url.pathname === "/api/admin/status") return json(await service.status(false)); if (request.method === "GET" && url.pathname === "/api/admin/backend-check") return json(await service.status(true)); if (request.method === "POST" && url.pathname === "/api/admin/draw") return json({ ok: true, record: await service.draw() }); @@ -57,7 +116,7 @@ export function createHandler(service: LotteryService, adminToken: string): (req } return json({ ok: false, error: "not found" }, 404); } catch (error) { - return errorResponse(error, url.pathname.startsWith("/api/public/")); + return errorResponse(error); } }; } diff --git a/src/server.ts b/src/server.ts index 4a12bbf..e4ea006 100644 --- a/src/server.ts +++ b/src/server.ts @@ -19,9 +19,11 @@ const context = createServerContext(config, target); const server = Bun.serve({ hostname: target.listenHost, port: target.listenPort, - fetch: createHandler(context.service, adminToken), + fetch: createHandler(context.service, context.monitor, config, context.auth, adminToken), }); +context.monitor.start(); + console.log(JSON.stringify({ ok: true, service: config.metadata.name, runtime: runtimeId, listen: server.url.toString(), automaticCreditEnabled: config.lottery.automaticCredit.enabled })); for (const signal of ["SIGINT", "SIGTERM"] as const) { diff --git a/src/web-auth.ts b/src/web-auth.ts new file mode 100644 index 0000000..9593c0e --- /dev/null +++ b/src/web-auth.ts @@ -0,0 +1,61 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import type { AppConfig } from "./config"; + +export interface WebAuthSecrets { + password: string; + apiKey: string; + sessionSecret: string; +} + +function equal(left: string, right: string): boolean { + const a = Buffer.from(left); + const b = Buffer.from(right); + return a.length === b.length && timingSafeEqual(a, b); +} + +function signature(payload: string, secret: string): string { + return createHmac("sha256", secret).update(payload).digest("base64url"); +} + +function cookies(request: Request): Map { + return new Map((request.headers.get("cookie") ?? "").split(";").map((item) => { + const index = item.indexOf("="); + return index < 0 ? [item.trim(), ""] : [item.slice(0, index).trim(), item.slice(index + 1).trim()]; + })); +} + +export function apiKeyAuthorized(request: Request, secrets: WebAuthSecrets): boolean { + const value = request.headers.get("authorization") ?? ""; + return value.startsWith("Bearer ") && equal(value.slice(7), secrets.apiKey); +} + +export function sessionAuthorized(request: Request, config: AppConfig, secrets: WebAuthSecrets): boolean { + const token = cookies(request).get(config.webAuth.cookieName); + if (!token) return false; + const [payload, mac] = token.split(".", 2); + if (!payload || !mac || !equal(signature(payload, secrets.sessionSecret), mac)) return false; + try { + const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { user?: unknown; exp?: unknown }; + return parsed.user === config.webAuth.username && typeof parsed.exp === "number" && parsed.exp > Date.now(); + } catch { + return false; + } +} + +export function createSessionCookie(config: AppConfig, secrets: WebAuthSecrets): string { + const payload = Buffer.from(JSON.stringify({ + user: config.webAuth.username, + exp: Date.now() + config.webAuth.sessionTtlSeconds * 1000, + })).toString("base64url"); + const value = `${payload}.${signature(payload, secrets.sessionSecret)}`; + return `${config.webAuth.cookieName}=${value}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=${config.webAuth.sessionTtlSeconds}`; +} + +export function clearSessionCookie(config: AppConfig): string { + return `${config.webAuth.cookieName}=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=0`; +} + +export function validLogin(username: unknown, password: unknown, config: AppConfig, secrets: WebAuthSecrets): boolean { + return typeof username === "string" && typeof password === "string" + && equal(username, config.webAuth.username) && equal(password, secrets.password); +} diff --git a/static/app.js b/static/app.js index eb6982d..c14ba6b 100644 --- a/static/app.js +++ b/static/app.js @@ -1,164 +1,210 @@ +const page = document.body.dataset.page const $ = (selector) => document.querySelector(selector) -const fields = (name) => document.querySelectorAll(`[data-field="${name}"]`) -const setField = (name, value) => fields(name).forEach((node) => { node.textContent = String(value) }) -const button = $('#draw-button') -const stage = $('.draw-stage') -const statusLine = $('#draw-status') -const rankingBody = $('#ranking-body') -const recordList = $('#record-list') -const creditMode = $('#credit-mode') -const dialog = $('#winner-dialog') +function escapeHtml(value) { + const node = document.createElement('span') + node.textContent = String(value ?? '') + return node.innerHTML +} -let state = null -let drawing = false - -function money(value, digits = 2) { - return Number(value).toLocaleString('en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits }) +function number(value, digits = 0) { + if (value === null || value === undefined || Number.isNaN(Number(value))) return '—' + return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: digits, minimumFractionDigits: digits }) } function compact(value) { - return new Intl.NumberFormat('zh-CN', { notation: 'compact', maximumFractionDigits: 1 }).format(Number(value)) + if (value === null || value === undefined) return '—' + return new Intl.NumberFormat('zh-CN', { notation: 'compact', maximumFractionDigits: 2 }).format(Number(value)) } -function localTime(value) { - return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(value)) +function percent(value) { + return value === null || value === undefined ? '—' : `${number(Number(value) * 100, 1)}%` } -function renderRanking(ranking) { - const rows = ranking?.rows ?? [] - if (!rows.length) { - rankingBody.innerHTML = '当前窗口暂无用量' - return +function time(value) { + if (!value) return '—' + return new Intl.DateTimeFormat('zh-CN', { + timeZone: 'Asia/Shanghai', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, + }).format(new Date(value)) +} + +async function requestJson(url, options = {}, timeoutMs = 20000) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await fetch(url, { ...options, signal: controller.signal, headers: { 'content-type': 'application/json', ...(options.headers ?? {}) } }) + const data = await response.json().catch(() => null) + if (response.status === 401 && page !== 'login') { + location.assign('/login') + throw new Error('登录状态已失效') + } + if (!response.ok || !data?.ok) throw new Error(data?.error ?? `HTTP ${response.status}`) + return data + } finally { + clearTimeout(timer) } - rankingBody.innerHTML = rows.map((row) => ` - ${String(row.rank).padStart(2, '0')} - ${escapeHtml(row.displayName)} - $${money(row.actualCost, 3)} - ${compact(row.requests)} 次${compact(row.tokens)} tokens - `).join('') } -function renderRecords(records) { - if (!records?.length) { - recordList.innerHTML = '
  • 第一位幸运用户,会是谁?
  • ' - return - } - recordList.innerHTML = records.map((record, index) => `
  • - #${String(index + 1).padStart(2, '0')} - ${escapeHtml(record.winnerDisplayName)}${localTime(record.drawnAt)} · ${record.eligibleCount} 人等概率 - $${money(record.prizeAmountUsd, 0)}${creditLabel(record.creditStatus)} -
  • `).join('') +function shell() { + const mount = $('[data-shell]') + if (!mount) return + const links = [ + ['scores', '/scores', '账号评分'], + ['ranking', '/ranking', '用户用量'], + ['lottery', '/lottery', '额度抽奖'], + ] + mount.innerHTML = `
    + ASApiStateSub2API Operations + +
    PK01
    +
    ` + $('#logout').addEventListener('click', async () => { + await requestJson('/api/logout', { method: 'POST', body: '{}' }).catch(() => null) + location.assign('/login') + }) +} + +async function loginPage() { + const form = $('#login-form') + form.addEventListener('submit', async (event) => { + event.preventDefault() + const button = form.querySelector('button') + const error = $('#login-error') + button.disabled = true + error.textContent = '' + try { + await requestJson('/api/login', { + method: 'POST', + body: JSON.stringify({ username: $('#username').value, password: $('#password').value }), + }) + location.assign('/scores') + } catch (cause) { + error.textContent = cause instanceof Error ? cause.message : String(cause) + } finally { + button.disabled = false + } + }) +} + +let scoreRows = [] + +function gradeClass(value) { + const grade = String(value ?? '').toLowerCase() + if (grade === 'excellent' || grade === 'good') return 'grade-good' + if (grade === 'poor' || grade === 'critical' || grade === 'insufficient') return 'grade-risk' + return 'grade-mid' +} + +function renderScoreRows() { + const term = ($('#score-filter')?.value ?? '').trim().toLowerCase() + const rows = scoreRows.filter((row) => `${row.accountName} ${row.groupName}`.toLowerCase().includes(term)) + $('#score-body').innerHTML = rows.length ? rows.map((row) => { + const usage = row.usage ?? {} + return ` + ${escapeHtml(row.accountName)}#${escapeHtml(row.accountId)} + ${escapeHtml(row.groupName)} + ${number(row.priority)} + ${number(row.score)} + ${escapeHtml(row.grade ?? '—')} + ${escapeHtml(row.confidence ?? '—')} + ${number(row.observedAttempts)} + ${percent(row.failureRate)} + ${row.ttftP95Ms == null ? '—' : `${number(row.ttftP95Ms)} ms`} + ${compact(usage.requestCount)} + ${compact(usage.tokenCount)} + ${usage.apiAmountUsd == null ? '—' : `$${number(usage.apiAmountUsd, 3)}`} + ${number(row.failoverRecovered)} / ${number(row.failoverRequests)} + ${row.currentlyAvailable ? '可用' : '不可用'} + ` + }).join('') : '没有匹配的账号' +} + +function renderScores(data) { + scoreRows = data.accounts ?? [] + $('#metric-accounts').textContent = number(scoreRows.length) + $('#metric-groups').textContent = number((data.groups ?? []).length) + $('#metric-good').textContent = number(scoreRows.filter((row) => Number(row.score) >= 80).length) + $('#metric-risk').textContent = number(scoreRows.filter((row) => Number(row.score) < 60).length) + $('#metric-window').textContent = data.window ?? '—' + $('#score-state').textContent = ({ ready: '已更新', refreshing: '刷新中', stale: '使用旧快照', unavailable: '暂无快照' })[data.status] ?? data.status + $('#score-state').dataset.state = data.status + $('#score-updated').textContent = data.refreshedAt ? `北京时间 ${time(data.refreshedAt)} · 下次 ${time(data.nextRefreshAt)}` : (data.error ?? '尚无成功快照') + renderScoreRows() +} + +async function scoresPage() { + $('#score-filter').addEventListener('input', renderScoreRows) + $('#refresh-scores').addEventListener('click', async () => { + const button = $('#refresh-scores') + button.disabled = true + $('#score-state').textContent = '刷新中' + try { renderScores(await requestJson('/api/scores/refresh', { method: 'POST', body: '{}' }, 200000)) } + catch (error) { $('#score-updated').textContent = error instanceof Error ? error.message : String(error) } + finally { button.disabled = false } + }) + renderScores(await requestJson('/api/scores')) + setInterval(async () => { + if (!document.hidden) renderScores(await requestJson('/api/scores').catch(() => ({ ok: false, accounts: scoreRows }))) + }, 30000) +} + +async function rankingPage() { + const data = await requestJson('/api/ranking') + const ranking = data.ranking + $('#ranking-range').textContent = `${ranking.startDate} 至 ${ranking.endDate}` + $('#ranking-cost').textContent = `$${number(ranking.totals.actualCost, 3)}` + $('#ranking-requests').textContent = compact(ranking.totals.requests) + $('#ranking-tokens').textContent = compact(ranking.totals.tokens) + $('#ranking-body').innerHTML = ranking.rows.length ? ranking.rows.map((row) => `${String(row.rank).padStart(2, '0')}${escapeHtml(row.displayName)}$${number(row.actualCost, 3)}${compact(row.requests)}${compact(row.tokens)}`).join('') : '当前窗口暂无用量' } function creditLabel(status) { return ({ succeeded: '已充值', dry_run: '模拟充值', disabled: '充值未开启', pending: '充值待确认', failed: '充值失败' })[status] ?? status } -function escapeHtml(value) { - const node = document.createElement('span') - node.textContent = String(value) - return node.innerHTML +function renderLottery(data) { + $('#lottery-prize').textContent = number(data.prizeAmountUsd, 0) + $('#lottery-remaining').textContent = number(data.remainingDraws) + $('#lottery-eligible').textContent = number(data.eligibleUserCount) + $('#lottery-next').textContent = time(data.nextGrantAt) + $('#lottery-mode').textContent = data.automaticCredit?.enabled ? creditLabel(data.automaticCredit.mode) : '自动充值关闭' + $('#draw-button').disabled = Number(data.remainingDraws) < 1 || Number(data.eligibleUserCount) < 1 + $('#draw-status').textContent = Number(data.remainingDraws) < 1 ? '今天的机会已用完' : `${data.eligibleUserCount} 名候选用户已就绪` + $('#record-list').innerHTML = data.records?.length ? data.records.map((record) => `
  • ${escapeHtml(record.winnerDisplayName)}$${number(record.prizeAmountUsd, 0)} · ${creditLabel(record.creditStatus)}
  • `).join('') : '
  • 暂无开奖记录
  • ' } -function render(next) { - state = next - setField('active-hours', next.activeWithinHours) - setField('prize', money(next.prizeAmountUsd, 0)) - setField('remaining', next.remainingDraws) - setField('eligible', next.eligibleUserCount ?? '—') - setField('daily-grant', next.dailyGrantCount) - setField('next-grant', localTime(next.nextGrantAt)) - setField('ranking-label', next.rankingWindowDays === 1 ? '今日' : `近 ${next.rankingWindowDays} 日`) - setField('ranking-total', `$${money(next.ranking?.totals?.actualCost ?? 0)}`) - renderRanking(next.ranking) - renderRecords(next.records) - const autoEnabled = next.automaticCredit?.enabled === true - creditMode.textContent = autoEnabled ? (next.automaticCredit.mode === 'live' ? '自动充值已开启' : '自动充值模拟中') : '自动充值暂未开启' - button.disabled = drawing || Number(next.remainingDraws) < 1 || Number(next.eligibleUserCount) < 1 - statusLine.textContent = Number(next.remainingDraws) < 1 ? '今天的机会已用完,明早 06:00 再来' : `${next.eligibleUserCount} 名候选人已就位` +async function lotteryPage() { + let state = await requestJson('/api/lottery') + renderLottery(state) + $('#draw-button').addEventListener('click', async () => { + const button = $('#draw-button') + button.disabled = true + $('#draw-status').textContent = '正在抽取活跃用户...' + try { + const data = await requestJson('/api/lottery/draw', { method: 'POST', body: '{}' }) + $('#winner-name').textContent = data.record.winnerDisplayName + $('#winner-prize').textContent = number(data.record.prizeAmountUsd, 0) + $('#winner-meta').textContent = `${data.record.eligibleCount} 人等概率 · ${creditLabel(data.record.creditStatus)}` + $('#winner-dialog').showModal() + state = await requestJson('/api/lottery') + renderLottery(state) + } catch (error) { + $('#draw-status').textContent = error instanceof Error ? error.message : String(error) + button.disabled = false + } + }) + $('#winner-close').addEventListener('click', () => $('#winner-dialog').close()) } -async function requestJson(url, options = {}) { - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), 12000) - try { - const response = await fetch(url, { ...options, signal: controller.signal }) - const raw = await response.text() - let data = null - try { data = raw ? JSON.parse(raw) : null } catch { /* handled below */ } - if (!response.ok || !data?.ok) throw new Error(data?.error ?? `服务暂不可用(HTTP ${response.status})`) - return data - } catch (error) { - if (error instanceof DOMException && error.name === 'AbortError') throw new Error('请求超时,请稍后重试') - throw error - } finally { - clearTimeout(timeout) - } -} - -async function loadState() { - const data = await requestJson('/api/public/state', { cache: 'no-store' }) - render(data) -} - -function showStateError(error) { - const message = error instanceof Error ? error.message : String(error) - statusLine.textContent = `${message},正在重试…` - rankingBody.innerHTML = '服务暂时不可用,正在重试…' - creditMode.textContent = '连接中断' - button.disabled = true -} - -function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)) } - -async function draw() { - if (drawing) return - drawing = true - button.disabled = true - stage.classList.add('is-drawing') - const phrases = ['锁定活跃用户…', '打乱候选顺序…', '正在碰撞好运…', '马上揭晓!'] - let phraseIndex = 0 - const ticker = setInterval(() => { - statusLine.textContent = phrases[phraseIndex % phrases.length] - phraseIndex += 1 - }, 620) - try { - const request = requestJson('/api/public/draw', { method: 'POST', headers: { 'content-type': 'application/json' } }) - const [, data] = await Promise.all([sleep(2900), request]) - $('#winner-name').textContent = data.record.winnerDisplayName - $('#winner-prize').textContent = money(data.record.prizeAmountUsd, 0) - $('#winner-meta').textContent = `${data.record.eligibleCount} 人等概率 · ${creditLabel(data.record.creditStatus)}` - dialog.showModal() - await loadState() - } catch (error) { - statusLine.textContent = error instanceof Error ? error.message : String(error) - await loadState().catch((stateError) => { - state = null - showStateError(stateError) - setTimeout(boot, 5000) - }) - } finally { - clearInterval(ticker) - drawing = false - stage.classList.remove('is-drawing') - button.disabled = !state || Number(state.remainingDraws) < 1 || Number(state.eligibleUserCount) < 1 - } -} - -button.addEventListener('click', draw) -$('#winner-close').addEventListener('click', () => dialog.close()) -dialog.addEventListener('click', (event) => { if (event.target === dialog) dialog.close() }) - async function boot() { - try { await loadState() } catch (error) { - showStateError(error) - setTimeout(boot, 5000) - } + if (page === 'login') return await loginPage() + shell() + if (page === 'scores') return await scoresPage() + if (page === 'ranking') return await rankingPage() + if (page === 'lottery') return await lotteryPage() } -boot() - -setInterval(() => { - if (!drawing && !document.hidden) loadState().catch(showStateError) -}, 30000) +boot().catch((error) => { + const target = $('.workspace') ?? $('main') + if (target) target.insertAdjacentHTML('afterbegin', `
    ${escapeHtml(error instanceof Error ? error.message : String(error))}
    `) +}) diff --git a/static/index.html b/static/index.html deleted file mode 100644 index 31e9d6c..0000000 --- a/static/index.html +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - Sub2Rank 幸运额度站 - - - - - - - -
    - - S2 - SUB2RANK幸运额度站 - -
    PK01 LIVE
    -

    最近 24 小时活跃用户 · 等概率

    -
    - -
    -
    -
    -

    DAILY CREDIT DRAW / 每日额度抽奖

    -

    今天,
    好运值多少钱?

    -

    任何人都可以按下按钮。开奖瞬间从符合条件的活跃用户中等概率抽出一位。

    -
    -
    本期奖金
    $
    -
    剩余次数
    -
    候选人数
    -
    -
    - -
    - - -
    -
    ONE PRESSONE WINNER
    - -
    正在连接开奖台…
    -
    -

    下次增加 次:

    -
    -
    - -
    -
    -
    -

    01 / USAGE

    用量排行榜

    -
    今日$—
    -
    -
    - - - -
    名次用户用量请求 / Token
    正在载入排行榜…
    -
    -
    - -
    -
    -

    02 / ARCHIVE

    开奖记录

    - 充值状态读取中 -
    -
    1. 暂无开奖记录
    -
    -
    -
    - - - -

    TODAY'S LUCKY USER

    -

    恭喜

    -

    获得 $ 额度

    -

    - -
    - -
    SUB2RANK / PK01配置与排除名单由 YAML 控制
    - - diff --git a/static/login.html b/static/login.html new file mode 100644 index 0000000..c49fd96 --- /dev/null +++ b/static/login.html @@ -0,0 +1,30 @@ + + + + + + + 登录 | ApiState + + + + +
    + + +
    + + diff --git a/static/lottery.html b/static/lottery.html new file mode 100644 index 0000000..bcc9b3a --- /dev/null +++ b/static/lottery.html @@ -0,0 +1,27 @@ + + + + + + + 额度抽奖 | ApiState + + + + +
    +
    +

    DAILY CREDIT DRAW

    额度抽奖

    读取中
    +
    +
    +

    本期额度

    $ +
    剩余次数
    候选用户
    下次增加
    + +

    正在读取抽奖状态...

    +
    +

    RECENT RESULTS

    开奖记录

    1. 暂无记录
    +
    +
    +

    TODAY'S WINNER

    获得 $ 额度

    + + diff --git a/static/ranking.html b/static/ranking.html new file mode 100644 index 0000000..e8b8b15 --- /dev/null +++ b/static/ranking.html @@ -0,0 +1,19 @@ + + + + + + + 用户用量 | ApiState + + + + +
    +
    +

    USER FLOW / DAILY

    用户用量

    读取中
    +
    标准 API 用量
    请求
    Token
    +
    名次用户API USD请求Token
    正在读取用量...
    +
    + + diff --git a/static/scores.html b/static/scores.html new file mode 100644 index 0000000..ce70512 --- /dev/null +++ b/static/scores.html @@ -0,0 +1,42 @@ + + + + + + + 账号评分 | ApiState + + + + +
    +
    +
    +

    ACCOUNT QUALITY / ALL GROUPS

    上游账号评分

    +
    + 读取中 + +
    +
    +
    +
    账号
    +
    分组
    +
    优质
    +
    需关注
    +
    评分窗口
    +
    +
    +
    +
    筛选
    +

    尚无成功快照

    +
    +
    + + + +
    账号分组优先级评分等级置信度尝试失败率TTFT P95请求TokenAPI USD切号恢复可用
    正在读取评分快照...
    +
    +
    +
    + + diff --git a/static/styles.css b/static/styles.css index a6c4921..6c77985 100644 --- a/static/styles.css +++ b/static/styles.css @@ -1,146 +1,141 @@ :root { - --ink: #11110f; - --paper: #f0eadb; - --acid: #dfff00; - --signal: #ff4d2e; - --cyan: #60e8df; - --line: rgba(240, 234, 219, 0.19); - --muted: #a8a496; - --serif: "Bodoni 72", "Noto Serif SC", "Songti SC", Georgia, serif; - --sans: "Avenir Next Condensed", "DIN Condensed", "Noto Sans SC", sans-serif; + --ink: #111412; + --surface: #191d1a; + --surface-raised: #222722; + --paper: #f4f0e6; + --muted: #9ca49d; + --line: #343b35; + --signal: #b9e447; + --alert: #ff6b4a; + --info: #70b7d4; + --sans: "DIN 2014", "Noto Sans SC", "PingFang SC", sans-serif; + --mono: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; } * { box-sizing: border-box; } -html { background: var(--ink); color: var(--paper); scroll-behavior: smooth; } -body { margin: 0; min-width: 320px; font-family: var(--sans); background: - linear-gradient(90deg, transparent 49.9%, rgba(255,255,255,.026) 50%, transparent 50.1%) 0 0 / 7.5rem 7.5rem, - linear-gradient(rgba(255,255,255,.026) 49.9%, transparent 50.1%) 0 0 / 7.5rem 7.5rem, - radial-gradient(circle at 78% 24%, rgba(255,77,46,.11), transparent 31rem), var(--ink); } -.noise { position: fixed; inset: 0; opacity: .07; pointer-events: none; z-index: 20; background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.5'/%3E%3C/svg%3E"); } +html { background: var(--ink); color: var(--paper); } +body { margin: 0; min-width: 320px; min-height: 100vh; font-family: var(--sans); letter-spacing: 0; background-color: var(--ink); background-image: linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px); background-size: 32px 32px; } +button, input { font: inherit; letter-spacing: 0; } +button { cursor: pointer; } +button:disabled { cursor: wait; opacity: .48; } -.topbar { min-height: 5.6rem; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 1rem; padding: 1rem clamp(1.2rem, 4vw, 4.5rem); border-bottom: 1px solid var(--line); } -.brand { display: flex; align-items: center; gap: .75rem; color: inherit; text-decoration: none; width: fit-content; } -.brand-mark { display: grid; place-items: center; width: 2.55rem; aspect-ratio: 1; background: var(--acid); color: var(--ink); font: 900 1rem/1 var(--sans); clip-path: polygon(50% 0, 100% 20%, 90% 100%, 10% 100%, 0 20%); } -.brand b { display: block; letter-spacing: .16em; font-size: .86rem; } -.brand small { color: var(--muted); font-size: .72rem; } -.live-sign { border: 1px solid var(--line); border-radius: 99px; padding: .48rem .8rem; font-size: .68rem; letter-spacing: .16em; } -.live-sign i { display: inline-block; width: .46rem; height: .46rem; background: var(--signal); border-radius: 50%; margin-right: .42rem; box-shadow: 0 0 0 .25rem rgba(255,77,46,.15); animation: pulse 1.6s infinite; } -.rule-copy { justify-self: end; color: var(--muted); font-size: .78rem; margin: 0; } +.topbar { height: 64px; display: grid; grid-template-columns: 260px 1fr auto; align-items: center; border-bottom: 1px solid var(--line); background: rgba(17,20,18,.96); padding: 0 28px; position: sticky; top: 0; z-index: 10; } +.brand { display: flex; align-items: center; gap: 10px; color: inherit; text-decoration: none; } +.brand-mark { width: 34px; aspect-ratio: 1; display: grid; place-items: center; background: var(--signal); color: var(--ink); font: 800 12px/1 var(--mono); border-radius: 3px; } +.brand b, .brand small { display: block; } +.brand b { font-size: 15px; } +.brand small { color: var(--muted); font: 10px/1.2 var(--mono); text-transform: uppercase; } +.topbar nav { display: flex; height: 100%; align-items: stretch; } +.topbar nav a { min-width: 104px; display: grid; place-items: center; color: var(--muted); text-decoration: none; border-inline: 1px solid transparent; font-size: 13px; } +.topbar nav a:hover { color: var(--paper); background: var(--surface); } +.topbar nav a[aria-current="page"] { color: var(--paper); border-inline-color: var(--line); box-shadow: inset 0 -3px var(--signal); background: var(--surface); } +.topbar-actions { display: flex; gap: 18px; align-items: center; } +.live-sign { color: var(--muted); font: 11px/1 var(--mono); } +.live-sign i, .signal-line i { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--signal); margin-right: 7px; box-shadow: 0 0 0 4px rgba(185,228,71,.1); } +.text-command { border: 0; background: transparent; color: var(--paper); padding: 8px; } -main { width: min(1500px, 100%); margin: 0 auto; padding: 0 clamp(1.2rem, 4vw, 4.5rem) 4rem; } -.hero { min-height: 43rem; display: grid; grid-template-columns: minmax(0, 1.02fr) minmax(25rem, .98fr); align-items: center; gap: clamp(2rem, 7vw, 8rem); border-bottom: 1px solid var(--line); } -.eyebrow, .panel-index { color: var(--acid); letter-spacing: .22em; font-size: .7rem; font-weight: 800; } -h1 { max-width: 14ch; font: 400 clamp(3.8rem, 5.7vw, 7.1rem)/.88 var(--serif); letter-spacing: -.065em; margin: 1rem 0 1.8rem; } -h1 em { color: var(--acid); font-weight: 400; } -.hero-note { max-width: 37rem; color: var(--muted); font: 400 clamp(.9rem, 1.3vw, 1.05rem)/1.75 var(--sans); } -.stats-strip { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 3rem 0 0; border-block: 1px solid var(--line); } -.stats-strip div { padding: 1.2rem 1rem 1.2rem 0; } -.stats-strip div + div { border-left: 1px solid var(--line); padding-left: 1.2rem; } -.stats-strip dt { color: var(--muted); font-size: .7rem; letter-spacing: .1em; } -.stats-strip dd { margin: .35rem 0 0; font: 500 clamp(1.8rem, 3vw, 2.6rem)/1 var(--serif); } -.stats-strip small, .currency { font: 700 .75rem/1 var(--sans); color: var(--acid); } +.workspace { width: min(1760px, 100%); margin: 0 auto; padding: 34px 34px 52px; } +.page-head { min-height: 76px; display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; border-bottom: 1px solid var(--line); } +.eyebrow { margin: 0 0 8px; color: var(--signal); font: 11px/1 var(--mono); } +.page-head h1 { margin: 0; font-size: 28px; font-weight: 650; } +.page-actions { display: flex; align-items: center; gap: 10px; } +.state-badge { min-height: 32px; display: inline-flex; align-items: center; padding: 0 10px; border: 1px solid var(--line); border-radius: 3px; color: var(--muted); font: 11px/1 var(--mono); } +.state-badge[data-state="ready"] { color: var(--signal); border-color: rgba(185,228,71,.4); } +.state-badge[data-state="stale"], .state-badge[data-state="unavailable"] { color: var(--alert); border-color: rgba(255,107,74,.45); } +.icon-command { width: 34px; height: 34px; border: 1px solid var(--line); border-radius: 3px; background: var(--surface); color: var(--paper); font-size: 20px; line-height: 1; } +.icon-command:hover { border-color: var(--signal); color: var(--signal); } -.draw-stage { min-height: 36rem; display: grid; place-items: center; position: relative; isolation: isolate; overflow: clip; } -.draw-machine { width: clamp(18rem, 31vw, 29rem); aspect-ratio: 1; border-radius: 50%; display: grid; place-items: center; position: relative; background: radial-gradient(circle, #373732 0 45%, #171715 46% 58%, #292924 59% 60%, #121210 61%); box-shadow: inset 0 0 2rem #000, 0 3rem 6rem rgba(0,0,0,.55); } -.draw-machine::before { content: ""; position: absolute; inset: 5%; border-radius: 50%; border: 1px dashed rgba(223,255,0,.38); animation: rotate 24s linear infinite; } -.machine-label { position: absolute; inset: 1.5rem 0 auto; display: flex; justify-content: center; gap: 1.3rem; color: var(--muted); letter-spacing: .18em; font-size: .5rem; } -.draw-button { width: 50%; aspect-ratio: 1; border: 0; border-radius: 50%; position: relative; overflow: hidden; cursor: pointer; color: #fff5e9; background: radial-gradient(circle at 36% 26%, #ff9a70, var(--signal) 37%, #c52912 70%); box-shadow: 0 .8rem 0 #76180d, 0 1.4rem 3rem rgba(255,77,46,.32), inset 0 .25rem .35rem rgba(255,255,255,.45); transition: transform .18s, filter .18s, box-shadow .18s; z-index: 2; } -.draw-button:not(:disabled):hover { transform: translateY(-.25rem) scale(1.025); filter: saturate(1.2); } -.draw-button:focus-visible, .winner-dialog button:focus-visible, .brand:focus-visible { outline: .22rem solid var(--cyan); outline-offset: .3rem; } -.draw-button:not(:disabled):active { transform: translateY(.45rem) scale(.98); box-shadow: 0 .25rem 0 #76180d, 0 .6rem 1.6rem rgba(255,77,46,.28), inset 0 .25rem .35rem rgba(255,255,255,.35); } -.draw-button:disabled { cursor: not-allowed; filter: grayscale(.65) brightness(.65); } -.button-glare { position: absolute; inset: 7% 18% 56%; border-radius: 50%; background: rgba(255,255,255,.24); filter: blur(.35rem); } -.button-copy { position: relative; display: grid; gap: .25rem; } -.button-copy b { font-size: clamp(1.35rem, 2.4vw, 2.2rem); letter-spacing: .04em; } -.button-copy small { font-size: .52rem; letter-spacing: .18em; } -.machine-status { position: absolute; bottom: 18%; z-index: 3; width: 70%; text-align: center; color: var(--paper); font-size: .72rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.orbit { position: absolute; border-radius: 50%; pointer-events: none; } -.orbit-one { width: 88%; aspect-ratio: 1; border: 1px solid rgba(96,232,223,.25); transform: rotate(-14deg) scaleY(.38); } -.orbit-two { width: 104%; aspect-ratio: 1; border: 1px solid rgba(223,255,0,.2); transform: rotate(19deg) scaleY(.52); } -.next-grant { position: absolute; bottom: 1.1rem; color: var(--muted); font-size: .72rem; letter-spacing: .04em; } -.next-grant b { color: var(--acid); } -.draw-stage.is-drawing .draw-machine { animation: machineCharge 2.8s cubic-bezier(.32,.02,.26,1); } -.draw-stage.is-drawing .draw-button { animation: buttonCharge .38s alternate infinite; } -.draw-stage.is-drawing .orbit-one { animation: orbitDash .7s linear infinite; border-color: var(--acid); } -.draw-stage.is-drawing .orbit-two { animation: orbitDash .95s linear infinite reverse; border-color: var(--signal); } +.metric-strip { display: grid; grid-template-columns: repeat(5, minmax(120px, 1fr)); border-bottom: 1px solid var(--line); } +.metric-strip > div { min-height: 92px; padding: 20px 18px; border-right: 1px solid var(--line); } +.metric-strip > div:first-child { padding-left: 0; } +.metric-strip > div:last-child { border-right: 0; } +.metric-strip span { display: block; color: var(--muted); font-size: 11px; margin-bottom: 10px; } +.metric-strip strong { font: 600 25px/1 var(--mono); } -.board-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(22rem, .65fr); border-inline: 1px solid var(--line); } -.panel { min-width: 0; padding: clamp(1.3rem, 3vw, 2.4rem); } -.panel + .panel { border-left: 1px solid var(--line); } -.panel-head { min-height: 5.5rem; display: flex; justify-content: space-between; align-items: start; gap: 1rem; border-bottom: 1px solid var(--line); } -.panel-head h2 { margin: .3rem 0 1.2rem; font: 400 clamp(1.8rem, 3.2vw, 2.8rem)/1 var(--serif); } -.panel-index { margin: 0; } -.range-chip, .credit-mode { text-align: right; color: var(--muted); font-size: .67rem; letter-spacing: .08em; } -.range-chip span, .range-chip b { display: block; } -.range-chip b { color: var(--paper); font: 400 1.35rem/1.4 var(--serif); } -.credit-mode { border: 1px solid var(--line); padding: .5rem .65rem; } -.table-wrap { overflow-x: auto; } -table { width: 100%; border-collapse: collapse; } -th { color: var(--muted); font-size: .62rem; letter-spacing: .1em; text-align: left; font-weight: 500; padding: .9rem .5rem; } -td { border-top: 1px solid rgba(240,234,219,.1); padding: 1rem .5rem; font-size: .82rem; } -td:first-child { width: 4rem; color: var(--acid); font: 400 1.6rem/1 var(--serif); } -td:nth-child(3) { font: 400 1.1rem/1 var(--serif); } -.usage-detail { display: block; color: var(--muted); font-size: .63rem; margin-top: .3rem; } -tbody tr { transition: background .2s, transform .2s; } -tbody tr:hover { background: rgba(223,255,0,.04); transform: translateX(.2rem); } -.record-list { list-style: none; margin: 0; padding: 0; } -.record-list li { display: grid; grid-template-columns: 2.6rem 1fr auto; gap: .8rem; align-items: center; padding: 1rem 0; border-bottom: 1px solid rgba(240,234,219,.1); } -.record-badge { display: grid; place-items: center; width: 2.5rem; aspect-ratio: 1; border-radius: 50%; border: 1px solid var(--acid); color: var(--acid); font: 700 .6rem/1 var(--sans); } -.record-main b { display: block; font: 400 1.05rem/1.25 var(--serif); } -.record-main small, .record-prize small { color: var(--muted); font-size: .61rem; } -.record-prize { text-align: right; font: 400 1.25rem/1 var(--serif); } -.empty { color: var(--muted) !important; text-align: center !important; padding: 2.6rem 1rem !important; font: .75rem/1.5 var(--sans) !important; } -.record-list li.empty { display: block; } +.table-section { padding-top: 20px; } +.table-toolbar { min-height: 48px; display: flex; justify-content: space-between; align-items: center; gap: 18px; } +.table-toolbar p { color: var(--muted); font: 11px/1 var(--mono); } +.filter-field { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 12px; } +.filter-field input { width: 260px; height: 34px; border: 1px solid var(--line); border-radius: 3px; background: var(--surface); color: var(--paper); padding: 0 10px; outline: 0; } +.filter-field input:focus { border-color: var(--signal); } +.table-wrap { overflow: auto; border: 1px solid var(--line); } +.data-table { width: 100%; min-width: 1220px; border-collapse: collapse; font-size: 12px; } +.data-table th { height: 40px; padding: 0 12px; position: sticky; top: 0; z-index: 1; text-align: left; white-space: nowrap; background: var(--surface-raised); color: var(--muted); font: 10px/1 var(--mono); border-bottom: 1px solid var(--line); } +.data-table td { height: 52px; padding: 8px 12px; border-bottom: 1px solid var(--line); white-space: nowrap; } +.data-table tbody tr:hover { background: rgba(185,228,71,.045); } +.account-cell { min-width: 230px; } +.account-cell b { display: block; max-width: 360px; overflow: hidden; text-overflow: ellipsis; } +.account-cell small { display: block; color: var(--muted); margin-top: 4px; font: 10px/1 var(--mono); } +.score-value { display: inline-grid; place-items: center; width: 34px; height: 26px; border-radius: 3px; font: 700 12px/1 var(--mono); } +.grade-good { color: var(--signal); background: rgba(185,228,71,.1); } +.grade-mid { color: var(--info); background: rgba(112,183,212,.1); } +.grade-risk { color: var(--alert); background: rgba(255,107,74,.1); } +.availability { font-size: 11px; } +.availability::before { content: ""; display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 6px; background: currentColor; } +.is-up { color: var(--signal); } +.is-down { color: var(--alert); } +.empty { padding: 32px !important; text-align: center; color: var(--muted); } +.fatal-state { padding: 14px; border: 1px solid var(--alert); color: var(--alert); margin-bottom: 18px; } -.winner-dialog { width: min(38rem, calc(100% - 2rem)); border: 1px solid rgba(223,255,0,.5); color: var(--paper); background: #171714; padding: clamp(2rem, 6vw, 4.5rem); text-align: center; overflow: hidden; box-shadow: 0 2rem 8rem #000; } -.winner-dialog::backdrop { background: rgba(5,5,4,.82); backdrop-filter: blur(.7rem); } -.winner-dialog[open] { animation: reveal .65s cubic-bezier(.18,.85,.3,1.15); } -.winner-kicker { color: var(--acid); font-size: .68rem; letter-spacing: .2em; } -.winner-dialog h2 { font: 400 clamp(2.3rem, 7vw, 4rem)/1 var(--serif); margin: 1rem 0; } -.winner-dialog h2 span { display: block; color: var(--acid); font-size: clamp(1.65rem, 5vw, 3.3rem); overflow-wrap: anywhere; margin-top: .3rem; } -.winner-prize { color: var(--muted); } -.winner-prize b { display: block; color: var(--signal); font: 400 clamp(3.4rem, 10vw, 6rem)/1 var(--serif); margin-top: .5rem; } -.winner-meta { color: var(--muted); font-size: .72rem; min-height: 1em; } -.winner-dialog button { margin-top: 1.5rem; padding: .8rem 1.5rem; border: 0; background: var(--acid); color: var(--ink); font-weight: 800; cursor: pointer; } -.burst { position: absolute; inset: 50%; width: 1px; height: 1px; box-shadow: 0 -12rem 0 1px var(--acid), 8rem -8rem 0 1px var(--signal), 12rem 0 0 1px var(--cyan), 8rem 8rem 0 1px var(--acid), 0 12rem 0 1px var(--signal), -8rem 8rem 0 1px var(--cyan), -12rem 0 0 1px var(--acid), -8rem -8rem 0 1px var(--signal); animation: burst 1.4s ease-out both; } -footer { display: flex; justify-content: space-between; padding: 1.2rem clamp(1.2rem, 4vw, 4.5rem); border-top: 1px solid var(--line); color: var(--muted); font-size: .6rem; letter-spacing: .12em; } +.lottery-grid { display: grid; grid-template-columns: minmax(400px, .8fr) minmax(500px, 1.2fr); min-height: 600px; } +.draw-console { display: flex; flex-direction: column; justify-content: center; padding: 48px 56px 48px 0; border-right: 1px solid var(--line); } +.draw-console > p:first-child { color: var(--muted); } +.draw-console > strong { font: 600 76px/1 var(--mono); color: var(--signal); } +.draw-console dl { display: grid; grid-template-columns: repeat(3, 1fr); border-block: 1px solid var(--line); margin: 38px 0 28px; } +.draw-console dl div { padding: 18px 12px 18px 0; } +.draw-console dl div + div { padding-left: 14px; border-left: 1px solid var(--line); } +.draw-console dt { color: var(--muted); font-size: 11px; } +.draw-console dd { margin: 8px 0 0; font: 600 17px/1.2 var(--mono); } +.primary-command { min-height: 44px; border: 0; border-radius: 3px; padding: 0 20px; background: var(--signal); color: var(--ink); font-weight: 750; } +.primary-command:hover { background: var(--paper); } +.command-status { color: var(--muted); font-size: 12px; } +.records-section { padding: 48px 0 48px 48px; } +.records-section h2 { margin: 0; } +.record-list { list-style: none; padding: 0; margin: 24px 0 0; border-top: 1px solid var(--line); } +.record-list li { min-height: 68px; display: grid; grid-template-columns: 130px 1fr auto; align-items: center; gap: 16px; border-bottom: 1px solid var(--line); } +.record-list time, .record-list span { color: var(--muted); font: 11px/1.3 var(--mono); } +.winner-dialog { width: min(480px, calc(100% - 32px)); border: 1px solid var(--line); border-radius: 4px; background: var(--surface); color: var(--paper); padding: 36px; } +.winner-dialog::backdrop { background: rgba(0,0,0,.72); } +.winner-dialog h2 { font-size: 32px; } +.winner-dialog strong { color: var(--signal); } -@keyframes pulse { 50% { opacity: .45; } } -@keyframes rotate { to { transform: rotate(360deg); } } -@keyframes orbitDash { to { rotate: 360deg; } } -@keyframes buttonCharge { to { filter: brightness(1.35) saturate(1.4); transform: scale(1.035); } } -@keyframes machineCharge { 20% { transform: scale(.98); } 55% { transform: scale(1.035); filter: hue-rotate(20deg); } 100% { transform: scale(1); } } -@keyframes reveal { from { opacity: 0; transform: translateY(2rem) scale(.88) rotate(-1deg); } } -@keyframes burst { from { transform: scale(.2) rotate(0); opacity: 1; } to { transform: scale(1.4) rotate(50deg); opacity: 0; } } +.login-page { background-image: linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px); } +.login-layout { min-height: 100vh; display: grid; grid-template-columns: 1.25fr .75fr; } +.login-brand { display: flex; flex-direction: column; justify-content: center; padding: clamp(40px, 8vw, 120px); border-right: 1px solid var(--line); } +.login-brand .brand-mark { width: 54px; margin-bottom: 42px; } +.login-brand > p { color: var(--muted); font: 11px/1 var(--mono); } +.login-brand h1 { margin: 8px 0 54px; font-size: clamp(58px, 8vw, 116px); line-height: .9; } +.signal-line { color: var(--muted); font: 11px/1 var(--mono); } +.login-panel { display: grid; place-items: center; padding: 32px; background: rgba(25,29,26,.92); } +.login-panel form { width: min(360px, 100%); } +.login-panel h2 { margin: 0 0 32px; font-size: 28px; } +.login-panel label { display: block; color: var(--muted); font-size: 12px; margin-bottom: 18px; } +.login-panel input { width: 100%; height: 44px; margin-top: 8px; border: 1px solid var(--line); border-radius: 3px; background: var(--ink); color: var(--paper); padding: 0 12px; outline: 0; } +.login-panel input:focus { border-color: var(--signal); } +.login-panel .primary-command { width: 100%; } +.form-error { min-height: 22px; color: var(--alert); font-size: 12px; } -@media (max-width: 960px) { - .topbar { grid-template-columns: 1fr auto; } - .rule-copy { display: none; } - .hero { grid-template-columns: 1fr; padding-block: 4rem 2rem; } - .hero-copy { text-align: center; } - h1, .hero-note { margin-inline: auto; } - .stats-strip { text-align: left; } - .draw-stage { min-height: 31rem; } - .board-grid { grid-template-columns: 1fr; } - .panel + .panel { border-left: 0; border-top: 1px solid var(--line); } -} - -@media (max-width: 560px) { - .topbar { min-height: 4.6rem; padding: .8rem 1rem; } - .live-sign { font-size: .55rem; } - main { padding-inline: 1rem; } - h1 { font-size: 3.4rem; } - .stats-strip dd { font-size: 1.7rem; } - .draw-stage { min-height: 26rem; } - .draw-machine { width: min(21rem, 92vw); } - .board-grid { border-inline: 0; } - .panel { padding-inline: .5rem; } - th:last-child, td:last-child { display: none; } - .record-list li { grid-template-columns: 2.6rem 1fr; } - .record-prize { grid-column: 2; text-align: left; } - footer { gap: 1rem; } -} - -@media (prefers-reduced-motion: reduce) { - *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; } +@media (max-width: 900px) { + .topbar { height: auto; min-height: 58px; grid-template-columns: 1fr auto; padding: 10px 14px; } + .topbar nav { grid-column: 1 / -1; order: 3; overflow-x: auto; border-top: 1px solid var(--line); } + .topbar nav a { min-width: 92px; height: 42px; } + .live-sign { display: none; } + .workspace { padding: 24px 14px 40px; } + .page-head { min-height: 84px; } + .page-head h1 { font-size: 23px; } + .metric-strip { grid-template-columns: repeat(2, 1fr); overflow: hidden; } + .metric-strip > div { border-bottom: 1px solid var(--line); padding-left: 12px; } + .table-toolbar { align-items: stretch; flex-direction: column; padding-bottom: 12px; } + .filter-field input { flex: 1; width: auto; } + .lottery-grid { grid-template-columns: 1fr; } + .draw-console { border-right: 0; border-bottom: 1px solid var(--line); padding: 36px 0; } + .draw-console > strong { font-size: 56px; } + .records-section { padding: 34px 0; } + .record-list li { grid-template-columns: 100px 1fr; } + .record-list span { grid-column: 2; } + .login-layout { grid-template-columns: 1fr; } + .login-brand { min-height: 38vh; padding: 34px; border-right: 0; border-bottom: 1px solid var(--line); } + .login-brand .brand-mark { margin-bottom: 18px; } + .login-brand h1 { font-size: 58px; margin-bottom: 24px; } + .login-panel { min-height: 62vh; } } From 051d00c5ecaf82853a0439d1f600698bc1d65df9 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 16 Jul 2026 09:46:42 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E5=85=BC=E5=AE=B9=E8=AF=84=E5=88=86?= =?UTF-8?q?=E6=B8=90=E8=BF=9B=E6=8A=AB=E9=9C=B2=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/account-score-service.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/account-score-service.ts b/src/account-score-service.ts index 0434717..71e94c7 100644 --- a/src/account-score-service.ts +++ b/src/account-score-service.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import type { AppConfig } from "./config"; @@ -79,14 +79,14 @@ export class AccountScoreService { this.snapshot = { ...this.snapshot, status: "refreshing", refreshStartedAt: startedAt.toISOString(), error: null }; try { const overview = await this.invoke(["--all-groups"]); - const groups = records(nested(overview, "data", "runtime", "allGroups", "groups")); + const groups = records(nested(overview, "data", "parsed", "allGroups", "groups")); const details = await pooled(groups, 2, async (group) => { const id = String(group.groupId ?? ""); if (!id) return { group, accounts: [] as Array> }; const payload = await this.invoke(["--group", id]); return { group, - accounts: records(nested(payload, "data", "runtime", "errors", "nativeOps", "accountQuality", "accounts")), + accounts: records(nested(payload, "data", "parsed", "errors", "nativeOps", "accountQuality", "accounts")), }; }); const refreshedAt = new Date(); @@ -149,9 +149,20 @@ export class AccountScoreService { ]).finally(() => clearTimeout(timeout)); if (timedOut) throw new Error(`评分 CLI 在 ${this.config.monitor.cli.timeoutMs}ms 后超时`); if (exitCode !== 0) throw new Error(`评分 CLI 退出码 ${exitCode}: ${stderr.trim().slice(-600)}`); - const payload = record(JSON.parse(stdout)); + let payload = record(JSON.parse(stdout)); if (!payload || payload.ok !== true) throw new Error("评分 CLI 返回无效结果"); - if (nested(payload, "data", "outputTruncated")) throw new Error("评分 CLI 输出被截断"); + if (nested(payload, "data", "outputTruncated")) { + const dumpPath = nested(payload, "data", "dump", "path"); + if (typeof dumpPath !== "string" || !dumpPath.startsWith("/tmp/unidesk-cli-output/")) { + throw new Error("评分 CLI 渐进披露结果缺少受保护 dump"); + } + try { + payload = record(JSON.parse(readFileSync(dumpPath, "utf8"))); + } finally { + try { unlinkSync(dumpPath); } catch { /* The CLI may clean up its own temporary output. */ } + } + if (!payload || payload.ok !== true) throw new Error("评分 CLI dump 返回无效结果"); + } return payload; }