first commit

This commit is contained in:
2026-04-05 10:23:02 +08:00
commit 8824aa1fb1
48 changed files with 12943 additions and 0 deletions

BIN
frontend/src/.DS_Store vendored Normal file

Binary file not shown.

284
frontend/src/app.tsx Normal file
View File

@@ -0,0 +1,284 @@
import { useEffect, useState } from "preact/hooks";
import { ConfigPanel } from "./components/config-panel";
import { LoginGate } from "./components/login-gate";
import { MonitorPanel } from "./components/monitor-panel";
import { initialConfigSections, initialMonitorState } from "./mock/data";
import {
clearAuthToken,
fetchConfig,
fetchMonitorState,
getStoredAuth,
isAuthError,
saveConfig,
startRuntime,
startRuntimeLoop,
stopRuntime,
storeAuthToken,
verifyAuthToken,
} from "./services/api";
import type { ConfigSection, MonitorState } from "./types/runtime";
export function App() {
const [sections, setSections] = useState<ConfigSection[]>(initialConfigSections);
const [monitor, setMonitor] = useState<MonitorState>(initialMonitorState);
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<string>("");
const [authenticated, setAuthenticated] = useState<boolean>(Boolean(getStoredAuth().token));
const [authError, setAuthError] = useState("");
const [hasStoredToken, setHasStoredToken] = useState<boolean>(Boolean(getStoredAuth().token));
const refreshMonitor = async () => {
const nextMonitor = await fetchMonitorState();
setMonitor(nextMonitor);
};
useEffect(() => {
let active = true;
fetchConfig()
.then((nextSections) => {
if (active) {
setSections(nextSections);
setAuthenticated(true);
}
})
.catch((error) => {
if (active) {
if (isAuthError(error)) {
clearAuthToken();
setHasStoredToken(false);
setAuthError("登录已失效,请重新输入管理令牌");
}
setAuthenticated(false);
}
});
refreshMonitor()
.then(() => {
if (active) {
setNotice("");
}
})
.catch((error) => {
if (active && isAuthError(error)) {
clearAuthToken();
setHasStoredToken(false);
setAuthenticated(false);
setAuthError("登录已失效,请重新输入管理令牌");
}
});
return () => {
active = false;
};
}, []);
useEffect(() => {
if (!authenticated) {
return;
}
let active = true;
const timer = window.setInterval(() => {
refreshMonitor()
.then(() => {
if (active) {
setNotice((current) => current);
}
})
.catch((error) => {
if (active && isAuthError(error)) {
clearAuthToken();
setHasStoredToken(false);
setAuthenticated(false);
setAuthError("登录已失效,请重新输入管理令牌");
}
});
}, 5000);
return () => {
active = false;
window.clearInterval(timer);
};
}, [authenticated]);
const handleLogin = async (token: string) => {
setBusy(true);
setAuthError("");
try {
await verifyAuthToken(token);
storeAuthToken(token);
setAuthenticated(true);
setHasStoredToken(true);
setNotice("登录成功");
const [nextSections, nextMonitor] = await Promise.all([fetchConfig(), fetchMonitorState()]);
setSections(nextSections);
setMonitor(nextMonitor);
} catch (error) {
console.error("登录失败", error);
clearAuthToken();
setAuthenticated(false);
setHasStoredToken(false);
setAuthError("管理令牌无效或服务暂不可用");
} finally {
setBusy(false);
}
};
const handleLogout = () => {
clearAuthToken();
setAuthenticated(false);
setHasStoredToken(false);
setNotice("已退出登录");
setAuthError("");
};
const updateFieldValue = (sectionKey: string, fieldKey: string, nextValue: string | number | boolean) => {
setSections((current) =>
current.map((section) => {
if (section.key !== sectionKey) {
return section;
}
return {
...section,
fields: section.fields.map((field) =>
field.key === fieldKey ? { ...field, value: nextValue } : field,
),
};
}),
);
};
const handleClearLogs = () => {
setMonitor((current) => ({
...current,
logs: [
{
id: "cleared",
prefix: "[系统] [00:00:00]",
timestamp: "[00:00:00]",
message: "日志已清空,等待任务输出...",
tone: "muted",
},
],
}));
};
const handleSaveConfig = async () => {
setBusy(true);
try {
const savedSections = await saveConfig(sections);
setSections(savedSections);
setNotice("配置已保存");
} catch (error) {
console.error("保存配置失败", error);
if (isAuthError(error)) {
clearAuthToken();
setHasStoredToken(false);
setAuthenticated(false);
setAuthError("登录已失效,请重新输入管理令牌");
} else {
setNotice("保存配置失败");
}
} finally {
setBusy(false);
}
};
const handleStartRuntime = async () => {
setBusy(true);
try {
const savedSections = await saveConfig(sections);
setSections(savedSections);
const result = await startRuntime();
setNotice(`配置已保存,${result.message}`);
await refreshMonitor();
} catch (error) {
console.error("保存配置或启动维护任务失败", error);
if (isAuthError(error)) {
clearAuthToken();
setHasStoredToken(false);
setAuthenticated(false);
setAuthError("登录已失效,请重新输入管理令牌");
} else {
setNotice("保存配置或启动维护任务失败");
}
} finally {
setBusy(false);
}
};
const handleStopRuntime = async () => {
setBusy(true);
try {
const result = await stopRuntime();
setNotice(result.message);
await refreshMonitor();
} catch (error) {
console.error("停止维护任务失败", error);
if (isAuthError(error)) {
clearAuthToken();
setHasStoredToken(false);
setAuthenticated(false);
setAuthError("登录已失效,请重新输入管理令牌");
} else {
setNotice("停止维护任务失败");
}
} finally {
setBusy(false);
}
};
const handleStartRuntimeLoop = async () => {
setBusy(true);
try {
const savedSections = await saveConfig(sections);
setSections(savedSections);
const result = await startRuntimeLoop();
setNotice(`配置已保存,${result.message}`);
await refreshMonitor();
} catch (error) {
console.error("保存配置或启动循环补号任务失败", error);
if (isAuthError(error)) {
clearAuthToken();
setHasStoredToken(false);
setAuthenticated(false);
setAuthError("登录已失效,请重新输入管理令牌");
} else {
setNotice("保存配置或启动循环补号任务失败");
}
} finally {
setBusy(false);
}
};
return (
<div class="page-shell">
{!authenticated ? <LoginGate busy={busy} error={authError} onSubmit={handleLogin} /> : null}
{authenticated ? (
<>
{notice ? <div class="page-notice">{notice}</div> : null}
<div class="page-grid">
<ConfigPanel
sections={sections}
onValueChange={updateFieldValue}
onSave={handleSaveConfig}
onStart={handleStartRuntime}
onStartLoop={handleStartRuntimeLoop}
onStop={handleStopRuntime}
onLogout={handleLogout}
busy={busy}
running={monitor.running}
loopRunning={Boolean(monitor.loopRunning)}
hasStoredToken={hasStoredToken}
/>
<div class="main-stack">
<MonitorPanel monitor={monitor} onClearLogs={handleClearLogs} />
</div>
</div>
</>
) : null}
</div>
);
}

View File

@@ -0,0 +1,322 @@
import { useEffect, useState } from "preact/hooks";
import type { ConfigField, ConfigSection } from "../types/runtime";
type ConfigPanelProps = {
sections: ConfigSection[];
onValueChange: (sectionKey: string, fieldKey: string, nextValue: string | number | boolean) => void;
onSave: () => void;
onStart: () => void;
onStartLoop: () => void;
onStop: () => void;
onLogout: () => void;
busy?: boolean;
running?: boolean;
loopRunning?: boolean;
hasStoredToken?: boolean;
};
type ConfigCategory = "common" | "mail" | "advanced";
function FieldControl(props: {
sectionKey: string;
field: ConfigField;
onValueChange: ConfigPanelProps["onValueChange"];
}) {
const { sectionKey, field, onValueChange } = props;
if (field.type === "select") {
return (
<select
value={String(field.value)}
onInput={(event) =>
onValueChange(sectionKey, field.key, (event.currentTarget as HTMLSelectElement).value)
}
>
{(field.options ?? []).map((option) => (
<option value={option.value} key={option.value}>
{option.label}
</option>
))}
</select>
);
}
if (field.type === "checkbox") {
return (
<label class="check-row">
<input
type="checkbox"
checked={Boolean(field.value)}
onInput={(event) =>
onValueChange(sectionKey, field.key, (event.currentTarget as HTMLInputElement).checked)
}
/>
<span>
{field.label} <span class="field-key">{field.key}</span>
</span>
</label>
);
}
if (field.type === "textarea") {
return (
<textarea
value={String(field.value)}
onInput={(event) =>
onValueChange(sectionKey, field.key, (event.currentTarget as HTMLTextAreaElement).value)
}
/>
);
}
return (
<input
type={field.type}
value={String(field.value)}
placeholder={field.sensitive && String(field.value) === "__MASKED__" ? "已保存,留空或保持不变将沿用原值" : ""}
onInput={(event) => {
const target = event.currentTarget as HTMLInputElement;
const nextValue = field.type === "number" ? Number(target.value) : target.value;
onValueChange(sectionKey, field.key, nextValue);
}}
/>
);
}
export function ConfigPanel(props: ConfigPanelProps) {
const {
sections,
onValueChange,
onSave,
onStart,
onStartLoop,
onStop,
onLogout,
busy = false,
running = false,
loopRunning = false,
hasStoredToken = false,
} = props;
const [activeCategory, setActiveCategory] = useState<ConfigCategory>("common");
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
priority: true,
clean: false,
mail: true,
cfmail: false,
self_hosted_mail_api: false,
duckmail: false,
tempmail_lol: false,
yyds_mail: false,
run: false,
registration: false,
flow: false,
oauth: false,
output: false,
});
const sectionCategoryMap: Record<string, ConfigCategory> = {
priority: "common",
clean: "common",
mail: "mail",
cfmail: "mail",
self_hosted_mail_api: "mail",
duckmail: "mail",
tempmail_lol: "mail",
yyds_mail: "mail",
run: "advanced",
registration: "advanced",
flow: "advanced",
oauth: "advanced",
output: "advanced",
};
const categoryLabelMap: Record<ConfigCategory, string> = {
common: "常用",
mail: "邮箱",
advanced: "高级",
};
const selectedProvider =
sections.find((section) => section.key === "mail")?.fields.find((field) => field.key === "provider")?.value ??
"self_hosted_mail_api";
const providerLabelMap: Record<string, string> = {
cfmail: "CF Mail",
self_hosted_mail_api: "自建 Mail API",
duckmail: "DuckMail",
tempmail_lol: "TempMail.lol",
yyds_mail: "YYDS Mail",
};
const visibleSections = sections.filter((section) => {
if (sectionCategoryMap[section.key] !== activeCategory) {
return false;
}
if (section.key === "self_hosted_mail_api") {
return selectedProvider === "self_hosted_mail_api";
}
if (section.key === "cfmail") {
return selectedProvider === "cfmail";
}
if (section.key === "duckmail") {
return selectedProvider === "duckmail";
}
if (section.key === "tempmail_lol") {
return selectedProvider === "tempmail_lol";
}
if (section.key === "yyds_mail") {
return selectedProvider === "yyds_mail";
}
return true;
});
const summaryItems = [
{
label: "当前邮箱",
value: providerLabelMap[String(selectedProvider)] ?? String(selectedProvider),
},
{
label: "维护目标",
value: String(
sections.find((section) => section.key === "priority")?.fields.find((field) => field.key === "min_candidates")
?.value ??
"",
),
},
{
label: "补号并发",
value: String(
sections.find((section) => section.key === "run")?.fields.find((field) => field.key === "workers")?.value ?? "",
),
},
];
const toggleSection = (sectionKey: string) => {
setExpandedSections((current) => ({
...current,
[sectionKey]: !(current[sectionKey] ?? false),
}));
};
useEffect(() => {
if (
selectedProvider === "cfmail" ||
selectedProvider === "self_hosted_mail_api" ||
selectedProvider === "duckmail" ||
selectedProvider === "tempmail_lol" ||
selectedProvider === "yyds_mail"
) {
setExpandedSections((current) => ({
...current,
[String(selectedProvider)]: true,
}));
}
}, [selectedProvider]);
useEffect(() => {
if (activeCategory === "mail") {
return;
}
if (
selectedProvider === "cfmail" ||
selectedProvider === "self_hosted_mail_api" ||
selectedProvider === "duckmail" ||
selectedProvider === "tempmail_lol" ||
selectedProvider === "yyds_mail"
) {
setExpandedSections((current) => ({
...current,
[String(selectedProvider)]: true,
}));
}
}, [activeCategory, selectedProvider]);
return (
<aside class="card settings-card">
<div class="card-head">
<div class="card-title">
<span class="title-icon">📝</span>
<span></span>
</div>
{hasStoredToken ? (
<button class="link-button" type="button" onClick={onLogout}>
退
</button>
) : null}
</div>
<div class="settings-body">
<div class="settings-summary">
{summaryItems.map((item) => (
<div class="summary-chip" key={item.label}>
<span class="summary-label">{item.label}</span>
<span class="summary-value">{item.value}</span>
</div>
))}
</div>
<div class="settings-tabs">
{(Object.keys(categoryLabelMap) as ConfigCategory[]).map((category) => (
<button
key={category}
type="button"
class={`settings-tab${activeCategory === category ? " active" : ""}`}
onClick={() => setActiveCategory(category)}
>
{categoryLabelMap[category]}
</button>
))}
</div>
{visibleSections.map((section) => {
const isExpanded = expandedSections[section.key] ?? false;
return (
<section class={`config-group${isExpanded ? " expanded" : ""}`} key={section.key}>
<button class="group-toggle" type="button" onClick={() => toggleSection(section.key)}>
<span class="group-title">
{section.label}
<span class="group-key">{section.key}</span>
</span>
<span class={`group-caret${isExpanded ? " open" : ""}`}></span>
</button>
{isExpanded ? (
<div class="group-content field-row single-col">
{section.fields.map((field) => (
<label class={`field${field.type === "checkbox" ? " checkbox-group compact" : ""}`} key={field.key}>
{field.type !== "checkbox" ? (
<span class="field-label">
{field.label}
<span class="field-key">{field.key}</span>
</span>
) : null}
<FieldControl sectionKey={section.key} field={field} onValueChange={onValueChange} />
{field.hint ? <span class="field-hint">{field.hint}</span> : null}
</label>
))}
</div>
) : null}
</section>
);
})}
<div class="settings-actions">
<button class="button primary" type="button" onClick={onStart} disabled={busy || running}>
</button>
<button class="button primary" type="button" onClick={onStartLoop} disabled={busy || running}>
{loopRunning ? "循环补号运行中" : "循环补号"}
</button>
<button class="button warning" type="button" onClick={onStop} disabled={busy || !running}>
</button>
<button class="button secondary" type="button" onClick={onSave} disabled={busy}>
</button>
</div>
</div>
</aside>
);
}

View File

@@ -0,0 +1,39 @@
import { useState } from "preact/hooks";
type LoginGateProps = {
busy?: boolean;
error?: string;
onSubmit: (token: string) => Promise<void>;
};
export function LoginGate(props: LoginGateProps) {
const { busy = false, error = "", onSubmit } = props;
const [token, setToken] = useState("");
const handleSubmit = async (event: Event) => {
event.preventDefault();
await onSubmit(token.trim());
};
return (
<div class="login-shell">
<form class="login-card" onSubmit={handleSubmit}>
<div class="login-title"></div>
<div class="login-subtitle">访</div>
<label class="field">
<span class="field-label">Admin Token</span>
<input
type="password"
value={token}
onInput={(event) => setToken((event.currentTarget as HTMLInputElement).value)}
placeholder="请输入 X-Admin-Token"
/>
</label>
{error ? <div class="login-error">{error}</div> : null}
<button class="button primary login-button" type="submit" disabled={busy || !token.trim()}>
{busy ? "验证中..." : "进入控制台"}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,110 @@
import { TerminalLog } from "./terminal-log";
import type { MonitorState } from "../types/runtime";
type MonitorPanelProps = {
monitor: MonitorState;
onClearLogs: () => void;
};
export function MonitorPanel(props: MonitorPanelProps) {
const { monitor, onClearLogs } = props;
const successCount = monitor.stats.find((item) => item.tone === "success")?.value ?? 0;
const failedCount = monitor.stats.find((item) => item.tone === "danger")?.value ?? 0;
const pendingFromStats = monitor.stats.find((item) => item.tone === "pending")?.value ?? 0;
const pendingCount = Math.max(0, Math.max(pendingFromStats, monitor.total - successCount));
const timing = monitor.singleAccountTiming;
const displaySeconds = (value: number | null): string => (typeof value === "number" ? `${value.toFixed(1)}s` : "--");
const loopRemain = monitor.loopNextCheckInSeconds;
const runtimeModeText = monitor.loopRunning ? "循环补号" : monitor.running ? "单次维护" : "未运行";
const loopRemainText = typeof loopRemain === "number" ? `${Math.max(0, loopRemain)}s` : "--";
return (
<section class="card monitor-card">
<div class="card-head">
<div class="card-title">
<span class="title-icon">💻</span>
<span></span>
</div>
<button class="link-button" type="button" onClick={onClearLogs}>
</button>
</div>
<div class="monitor-body">
<div class={`runtime-banner ${monitor.running ? "active" : ""}`}>
<span class="runtime-dot" />
<span>{monitor.message}</span>
</div>
<div class="runtime-mode-banner">
<span class="runtime-mode-label"></span>
<span class="runtime-mode-value">{runtimeModeText}</span>
{monitor.loopRunning ? (
<span class="runtime-mode-next">: {loopRemainText}</span>
) : null}
</div>
<div class="inventory-banner">
<span class="inventory-label">CPA </span>
<span class="inventory-value">
{monitor.availableCandidates === null ? "--" : monitor.availableCandidates}
</span>
</div>
<div class="progress-head">
<div class="progress-title"></div>
<div class="progress-meta">
<span>
{successCount} / {monitor.total}
</span>
<span>{monitor.percent}%</span>
</div>
</div>
<div class="progress-track">
<div class="progress-value" style={{ width: `${monitor.percent}%` }} />
</div>
<div class="stat-strip">
<div class="mini-stat success">
<span class="mini-stat-label"></span>
<span class="mini-stat-value">{successCount}</span>
</div>
<div class="mini-stat danger">
<span class="mini-stat-label"></span>
<span class="mini-stat-value">{failedCount}</span>
</div>
<div class="mini-stat pending">
<span class="mini-stat-label"></span>
<span class="mini-stat-value">{pendingCount}</span>
</div>
</div>
<div class="timing-strip">
<div class="timing-item">
<span class="timing-label"></span>
<span class="timing-value">{displaySeconds(timing.latestTotalSeconds)}</span>
</div>
<div class="timing-item">
<span class="timing-label">/OAuth</span>
<span class="timing-value">
{displaySeconds(timing.latestRegSeconds)} / {displaySeconds(timing.latestOauthSeconds)}
</span>
</div>
<div class="timing-item">
<span class="timing-label">{timing.windowSize}()</span>
<span class="timing-value">{displaySeconds(timing.recentAvgTotalSeconds)}</span>
</div>
<div class="timing-item">
<span class="timing-label">(100s)</span>
<span class="timing-value">
{timing.recentSlowCount} / {timing.sampleSize}
</span>
</div>
</div>
<TerminalLog lines={monitor.logs} />
</div>
</section>
);
}

View File

@@ -0,0 +1,39 @@
import { useEffect, useRef } from "preact/hooks";
import type { LogLine } from "../types/runtime";
type TerminalLogProps = {
lines: LogLine[];
};
export function TerminalLog(props: TerminalLogProps) {
const { lines } = props;
const terminalRef = useRef<HTMLDivElement>(null);
const shouldStickToBottomRef = useRef(true);
const handleScroll = () => {
const node = terminalRef.current;
if (!node) {
return;
}
const distanceToBottom = node.scrollHeight - node.scrollTop - node.clientHeight;
shouldStickToBottomRef.current = distanceToBottom < 32;
};
useEffect(() => {
if (terminalRef.current && shouldStickToBottomRef.current) {
terminalRef.current.scrollTop = terminalRef.current.scrollHeight;
}
}, [lines]);
return (
<div class="terminal" ref={terminalRef} onScroll={handleScroll}>
{lines.map((line) => (
<div class="log-row" key={line.id}>
<span class="log-dim">{line.prefix}</span>{" "}
<span class={`log-${line.tone}`}>{line.timestamp}</span>{" "}
<span>{line.message}</span>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,580 @@
import type { BackendConfig } from "../types/api";
import type { ConfigSection } from "../types/runtime";
export const defaultBackendConfig: BackendConfig = {
cfmail: {
api_base: "https://mail.example.com",
api_key: "",
domain: "",
domains: [],
},
clean: {
base_url: "CPA地址",
token: "CPA登录密码",
target_type: "codex",
workers: 20,
sample_size: 0,
delete_workers: 20,
timeout: 10,
retries: 1,
user_agent: "codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal",
used_percent_threshold: 95,
},
mail: {
provider: "tempmail_lol",
api_base: "https://your-worker.workers.dev",
api_key: "your-mail-api-key",
domain: "mail.example.com",
domains: [],
otp_timeout_seconds: 120,
poll_interval_seconds: 3,
},
duckmail: {
api_base: "https://api.duckmail.sbs",
bearer: "",
domain: "duckmail.sbs",
domains: [],
},
tempmail_lol: {
api_base: "https://api.tempmail.lol/v2",
},
yyds_mail: {
api_base: "https://maliapi.215.im/v1",
api_key: "",
domain: "",
domains: [],
},
maintainer: {
min_candidates: 50,
loop_interval_seconds: 60,
},
run: {
workers: 8,
proxy: "",
failure_threshold_for_cooldown: 5,
failure_cooldown_seconds: 45,
loop_jitter_min_seconds: 2,
loop_jitter_max_seconds: 6,
},
flow: {
step_retry_attempts: 2,
step_retry_delay_base: 0.2,
step_retry_delay_cap: 0.8,
outer_retry_attempts: 3,
oauth_local_retry_attempts: 3,
transient_markers:
"sentinel_,oauth_authorization_code_not_found,headers_failed,timeout,timed out,server disconnected,unexpected_eof_while_reading,transport,remoteprotocolerror,connection reset,temporarily unavailable,network,eof occurred,http_429,http_500,http_502,http_503,http_504",
register_otp_validate_order: "normal,sentinel",
oauth_otp_validate_order: "normal,sentinel",
oauth_password_phone_action: "warn_and_continue",
oauth_otp_phone_action: "warn_and_continue",
},
registration: {
entry_mode: "chatgpt_web",
entry_mode_fallback: true,
chatgpt_base: "https://chatgpt.com",
register_create_account_phone_action: "warn_and_continue",
phone_verification_markers: "add_phone,/add-phone,phone_verification,phone-verification,phone/verify",
},
oauth: {
issuer: "https://auth.openai.com",
client_id: "app_EMoamEEZ73f0CkXaXp7hrann",
redirect_uri: "http://localhost:1455/auth/callback",
retry_attempts: 3,
retry_backoff_base: 2,
retry_backoff_max: 15,
otp_timeout_seconds: 120,
otp_poll_interval_seconds: 2,
},
output: {
accounts_file: "accounts.txt",
csv_file: "registered_accounts.csv",
ak_file: "ak.txt",
rk_file: "rk.txt",
save_local: false,
},
};
function toNumber(value: unknown, fallback: number): number {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function toString(value: unknown, fallback: string): string {
return typeof value === "string" ? value : fallback;
}
function toBoolean(value: unknown, fallback: boolean): boolean {
return typeof value === "boolean" ? value : fallback;
}
function toStringArray(value: unknown, fallback: string[] = []): string[] {
if (!Array.isArray(value)) {
return fallback;
}
const normalized = value
.map((item) => (typeof item === "string" ? item.trim() : ""))
.filter(Boolean);
return normalized.length ? normalized : fallback;
}
function linesToArray(value: unknown): string[] {
return String(value ?? "")
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
}
function arrayToLines(values: string[]): string {
return values.join("\n");
}
export function normalizeBackendConfig(raw: Partial<BackendConfig> | Record<string, unknown>): BackendConfig {
const source = raw ?? {};
const cfmail = (source.cfmail ?? {}) as Partial<BackendConfig["cfmail"]>;
const clean = (source.clean ?? {}) as Partial<BackendConfig["clean"]>;
const mail = (source.mail ?? {}) as Partial<BackendConfig["mail"]>;
const duckmail = (source.duckmail ?? {}) as Partial<BackendConfig["duckmail"]>;
const tempmailLol = (source.tempmail_lol ?? {}) as Partial<BackendConfig["tempmail_lol"]>;
const yydsMail = (source.yyds_mail ?? {}) as Partial<BackendConfig["yyds_mail"]>;
const maintainer = (source.maintainer ?? {}) as Partial<BackendConfig["maintainer"]>;
const run = (source.run ?? {}) as Partial<BackendConfig["run"]>;
const flow = (source.flow ?? {}) as Partial<BackendConfig["flow"]>;
const registration = (source.registration ?? {}) as Partial<BackendConfig["registration"]>;
const oauth = (source.oauth ?? {}) as Partial<BackendConfig["oauth"]>;
const output = (source.output ?? {}) as Partial<BackendConfig["output"]>;
return {
cfmail: {
api_base: toString(cfmail.api_base, defaultBackendConfig.cfmail.api_base),
api_key: toString(cfmail.api_key, defaultBackendConfig.cfmail.api_key),
domain: toString(cfmail.domain, defaultBackendConfig.cfmail.domain),
domains: toStringArray(cfmail.domains, defaultBackendConfig.cfmail.domains),
},
clean: {
base_url: toString(clean.base_url, defaultBackendConfig.clean.base_url),
token: toString(clean.token, defaultBackendConfig.clean.token),
target_type: toString(clean.target_type, defaultBackendConfig.clean.target_type),
workers: toNumber(clean.workers, defaultBackendConfig.clean.workers),
sample_size: toNumber(clean.sample_size, defaultBackendConfig.clean.sample_size),
delete_workers: toNumber(clean.delete_workers, defaultBackendConfig.clean.delete_workers),
timeout: toNumber(clean.timeout, defaultBackendConfig.clean.timeout),
retries: toNumber(clean.retries, defaultBackendConfig.clean.retries),
user_agent: toString(clean.user_agent, defaultBackendConfig.clean.user_agent ?? ""),
used_percent_threshold: toNumber(clean.used_percent_threshold, defaultBackendConfig.clean.used_percent_threshold),
},
mail: {
provider: toString(mail.provider, defaultBackendConfig.mail.provider),
api_base: toString(mail.api_base, defaultBackendConfig.mail.api_base),
api_key: toString(mail.api_key, defaultBackendConfig.mail.api_key),
domain: toString(mail.domain, defaultBackendConfig.mail.domain),
domains: toStringArray(mail.domains, defaultBackendConfig.mail.domains),
otp_timeout_seconds: toNumber(mail.otp_timeout_seconds, defaultBackendConfig.mail.otp_timeout_seconds),
poll_interval_seconds: toNumber(mail.poll_interval_seconds, defaultBackendConfig.mail.poll_interval_seconds),
},
duckmail: {
api_base: toString(duckmail.api_base, defaultBackendConfig.duckmail.api_base),
bearer: toString(duckmail.bearer, defaultBackendConfig.duckmail.bearer),
domain: toString(duckmail.domain, defaultBackendConfig.duckmail.domain),
domains: toStringArray(duckmail.domains, defaultBackendConfig.duckmail.domains),
},
tempmail_lol: {
api_base: toString(tempmailLol.api_base, defaultBackendConfig.tempmail_lol.api_base),
},
yyds_mail: {
api_base: toString(yydsMail.api_base, defaultBackendConfig.yyds_mail.api_base),
api_key: toString(yydsMail.api_key, defaultBackendConfig.yyds_mail.api_key),
domain: toString(yydsMail.domain, defaultBackendConfig.yyds_mail.domain),
domains: toStringArray(yydsMail.domains, defaultBackendConfig.yyds_mail.domains),
},
maintainer: {
min_candidates: toNumber(maintainer.min_candidates, defaultBackendConfig.maintainer.min_candidates),
loop_interval_seconds: toNumber(
maintainer.loop_interval_seconds,
defaultBackendConfig.maintainer.loop_interval_seconds,
),
},
run: {
workers: toNumber(run.workers, defaultBackendConfig.run.workers),
proxy: toString(run.proxy, defaultBackendConfig.run.proxy),
failure_threshold_for_cooldown: toNumber(
run.failure_threshold_for_cooldown,
defaultBackendConfig.run.failure_threshold_for_cooldown,
),
failure_cooldown_seconds: toNumber(run.failure_cooldown_seconds, defaultBackendConfig.run.failure_cooldown_seconds),
loop_jitter_min_seconds: toNumber(run.loop_jitter_min_seconds, defaultBackendConfig.run.loop_jitter_min_seconds),
loop_jitter_max_seconds: toNumber(run.loop_jitter_max_seconds, defaultBackendConfig.run.loop_jitter_max_seconds),
},
flow: {
step_retry_attempts: toNumber(flow.step_retry_attempts, defaultBackendConfig.flow.step_retry_attempts),
step_retry_delay_base: toNumber(flow.step_retry_delay_base, defaultBackendConfig.flow.step_retry_delay_base),
step_retry_delay_cap: toNumber(flow.step_retry_delay_cap, defaultBackendConfig.flow.step_retry_delay_cap),
outer_retry_attempts: toNumber(flow.outer_retry_attempts, defaultBackendConfig.flow.outer_retry_attempts),
oauth_local_retry_attempts: toNumber(
flow.oauth_local_retry_attempts,
defaultBackendConfig.flow.oauth_local_retry_attempts,
),
transient_markers: toString(flow.transient_markers, defaultBackendConfig.flow.transient_markers),
register_otp_validate_order: toString(
flow.register_otp_validate_order,
defaultBackendConfig.flow.register_otp_validate_order,
),
oauth_otp_validate_order: toString(flow.oauth_otp_validate_order, defaultBackendConfig.flow.oauth_otp_validate_order),
oauth_password_phone_action: toString(
flow.oauth_password_phone_action,
defaultBackendConfig.flow.oauth_password_phone_action,
),
oauth_otp_phone_action: toString(flow.oauth_otp_phone_action, defaultBackendConfig.flow.oauth_otp_phone_action),
},
registration: {
entry_mode: toString(registration.entry_mode, defaultBackendConfig.registration.entry_mode),
entry_mode_fallback: toBoolean(registration.entry_mode_fallback, defaultBackendConfig.registration.entry_mode_fallback),
chatgpt_base: toString(registration.chatgpt_base, defaultBackendConfig.registration.chatgpt_base),
register_create_account_phone_action: toString(
registration.register_create_account_phone_action,
defaultBackendConfig.registration.register_create_account_phone_action,
),
phone_verification_markers: toString(
registration.phone_verification_markers,
defaultBackendConfig.registration.phone_verification_markers,
),
},
oauth: {
issuer: toString(oauth.issuer, defaultBackendConfig.oauth.issuer),
client_id: toString(oauth.client_id, defaultBackendConfig.oauth.client_id),
redirect_uri: toString(oauth.redirect_uri, defaultBackendConfig.oauth.redirect_uri),
retry_attempts: toNumber(oauth.retry_attempts, defaultBackendConfig.oauth.retry_attempts),
retry_backoff_base: toNumber(oauth.retry_backoff_base, defaultBackendConfig.oauth.retry_backoff_base),
retry_backoff_max: toNumber(oauth.retry_backoff_max, defaultBackendConfig.oauth.retry_backoff_max),
otp_timeout_seconds: toNumber(oauth.otp_timeout_seconds, defaultBackendConfig.oauth.otp_timeout_seconds),
otp_poll_interval_seconds: toNumber(
oauth.otp_poll_interval_seconds,
defaultBackendConfig.oauth.otp_poll_interval_seconds,
),
},
output: {
accounts_file: toString(output.accounts_file, defaultBackendConfig.output.accounts_file),
csv_file: toString(output.csv_file, defaultBackendConfig.output.csv_file),
ak_file: toString(output.ak_file, defaultBackendConfig.output.ak_file),
rk_file: toString(output.rk_file, defaultBackendConfig.output.rk_file),
save_local: toBoolean(output.save_local, defaultBackendConfig.output.save_local),
},
};
}
export function configToSections(config: BackendConfig): ConfigSection[] {
return [
{
key: "priority",
label: "核心配置",
fields: [
{ key: "base_url", label: "CPA 接口地址", type: "text", value: config.clean.base_url },
{ key: "token", label: "CPA 访问令牌", type: "password", value: config.clean.token, sensitive: true },
{
key: "min_candidates",
label: "最小候选账号数",
type: "number",
value: config.maintainer.min_candidates,
hint: "表示账号池希望长期保有的最低可用账号数。清理完成后若当前候选账号低于该值,系统会自动补号。",
},
{
key: "loop_interval_seconds",
label: "循环补号间隔(秒)",
type: "number",
value: config.maintainer.loop_interval_seconds,
hint: "点击“循环补号”按钮后,每轮检查完会休眠该秒数再重新检测。",
},
{
key: "proxy",
label: "代理地址",
type: "text",
value: config.run.proxy,
hint: "示例: http://127.0.0.1:7890 或 socks5://127.0.0.1:1080",
},
],
},
{
key: "clean",
label: "清理配置",
columns: 2,
fields: [
{ key: "target_type", label: "目标账号类型", type: "text", value: config.clean.target_type },
{ key: "timeout", label: "请求超时", type: "number", value: config.clean.timeout },
{ key: "workers", label: "探测并发", type: "number", value: config.clean.workers },
{
key: "sample_size",
label: "抽样数量",
type: "number",
value: config.clean.sample_size,
hint: "0 表示全量探测;大于 0 时,每轮仅随机抽取这部分账号做可用性探测。",
},
{ key: "delete_workers", label: "删除并发", type: "number", value: config.clean.delete_workers },
{ key: "retries", label: "重试次数", type: "number", value: config.clean.retries },
{
key: "used_percent_threshold",
label: "用量阈值",
type: "number",
value: config.clean.used_percent_threshold,
hint: "用于识别高消耗账号。若账号的 used_percent 大于等于该值,会在清理阶段优先禁用(不直接删除)。",
},
],
},
{
key: "mail",
label: "邮箱配置",
columns: 2,
fields: [
{
key: "provider",
label: "邮箱提供方",
type: "select",
value: config.mail.provider,
options: [
{ label: "cfmail", value: "cfmail" },
{ label: "self_hosted_mail_api", value: "self_hosted_mail_api" },
{ label: "duckmail", value: "duckmail" },
{ label: "tempmail_lol", value: "tempmail_lol" },
{ label: "yyds_mail", value: "yyds_mail" },
],
},
{ key: "otp_timeout_seconds", label: "验证码超时", type: "number", value: config.mail.otp_timeout_seconds },
{ key: "poll_interval_seconds", label: "轮询间隔", type: "number", value: config.mail.poll_interval_seconds },
],
},
{
key: "cfmail",
label: "CF Mail 配置",
columns: 2,
fields: [
{ key: "api_base", label: "接口地址", type: "text", value: config.cfmail.api_base },
{ key: "api_key", label: "接口密钥", type: "password", value: config.cfmail.api_key, sensitive: true },
{ key: "domain", label: "邮箱域名", type: "text", value: config.cfmail.domain },
{
key: "domains",
label: "邮箱域名列表",
type: "textarea",
value: arrayToLines(config.cfmail.domains),
hint: "每行一个域名;填写后优先于单个 domain。",
},
],
},
{
key: "self_hosted_mail_api",
label: "自建 Mail API 配置",
columns: 2,
fields: [
{ key: "api_base", label: "邮件 API 地址", type: "text", value: config.mail.api_base },
{ key: "domain", label: "邮箱域名", type: "text", value: config.mail.domain },
{
key: "domains",
label: "邮箱域名列表",
type: "textarea",
value: arrayToLines(config.mail.domains),
hint: "每行一个域名;填写后优先于单个 domain。",
},
{ key: "api_key", label: "邮件 API 密钥", type: "password", value: config.mail.api_key, sensitive: true },
],
},
{
key: "duckmail",
label: "DuckMail 配置",
columns: 2,
fields: [
{ key: "api_base", label: "接口地址", type: "text", value: config.duckmail.api_base },
{ key: "domain", label: "邮箱域名", type: "text", value: config.duckmail.domain },
{
key: "domains",
label: "邮箱域名列表",
type: "textarea",
value: arrayToLines(config.duckmail.domains),
hint: "每行一个域名;填写后优先于单个 domain。",
},
{ key: "bearer", label: "访问凭证", type: "password", value: config.duckmail.bearer, sensitive: true },
],
},
{
key: "tempmail_lol",
label: "TempMail.lol 配置",
fields: [{ key: "api_base", label: "接口地址", type: "text", value: config.tempmail_lol.api_base }],
},
{
key: "yyds_mail",
label: "YYDS Mail 配置",
columns: 2,
fields: [
{ key: "api_base", label: "接口地址", type: "text", value: config.yyds_mail.api_base },
{ key: "domain", label: "邮箱域名", type: "text", value: config.yyds_mail.domain },
{
key: "domains",
label: "邮箱域名列表",
type: "textarea",
value: arrayToLines(config.yyds_mail.domains),
hint: "每行一个域名;填写后优先于单个 domain。",
},
{ key: "api_key", label: "访问密钥", type: "password", value: config.yyds_mail.api_key, sensitive: true },
],
},
{
key: "run",
label: "运行参数",
columns: 2,
fields: [
{ key: "workers", label: "补号并发数", type: "number", value: config.run.workers },
{
key: "failure_threshold_for_cooldown",
label: "连续失败阈值",
type: "number",
value: config.run.failure_threshold_for_cooldown,
},
{
key: "failure_cooldown_seconds",
label: "冷却时长",
type: "number",
value: config.run.failure_cooldown_seconds,
},
{ key: "loop_jitter_min_seconds", label: "最小抖动秒数", type: "number", value: config.run.loop_jitter_min_seconds },
{ key: "loop_jitter_max_seconds", label: "最大抖动秒数", type: "number", value: config.run.loop_jitter_max_seconds },
],
},
{
key: "registration",
label: "注册流程策略",
columns: 2,
fields: [
{
key: "entry_mode",
label: "注册入口模式",
type: "select",
value: config.registration.entry_mode,
options: [
{ label: "chatgpt_web", value: "chatgpt_web" },
{ label: "direct_auth", value: "direct_auth" },
],
},
{ key: "entry_mode_fallback", label: "入口失败自动回退", type: "checkbox", value: config.registration.entry_mode_fallback },
{ key: "chatgpt_base", label: "ChatGPT 入口域名", type: "text", value: config.registration.chatgpt_base },
{
key: "register_create_account_phone_action",
label: "注册命中手机验证",
type: "select",
value: config.registration.register_create_account_phone_action,
options: [
{ label: "warn_and_continue", value: "warn_and_continue" },
{ label: "fail_fast", value: "fail_fast" },
],
},
{
key: "phone_verification_markers",
label: "手机验证识别关键词",
type: "text",
value: config.registration.phone_verification_markers,
},
],
},
{
key: "flow",
label: "流程重试策略",
columns: 2,
fields: [
{ key: "step_retry_attempts", label: "注册步骤局部重试", type: "number", value: config.flow.step_retry_attempts },
{ key: "step_retry_delay_base", label: "步骤重试基数", type: "number", value: config.flow.step_retry_delay_base },
{ key: "step_retry_delay_cap", label: "步骤重试上限", type: "number", value: config.flow.step_retry_delay_cap },
{ key: "outer_retry_attempts", label: "OAuth 外层重试", type: "number", value: config.flow.outer_retry_attempts },
{
key: "oauth_local_retry_attempts",
label: "OAuth 局部重试",
type: "number",
value: config.flow.oauth_local_retry_attempts,
},
{ key: "register_otp_validate_order", label: "注册 OTP 校验顺序", type: "text", value: config.flow.register_otp_validate_order },
{ key: "oauth_otp_validate_order", label: "OAuth OTP 校验顺序", type: "text", value: config.flow.oauth_otp_validate_order },
{
key: "oauth_password_phone_action",
label: "OAuth 密码阶段手机验证",
type: "select",
value: config.flow.oauth_password_phone_action,
options: [
{ label: "warn_and_continue", value: "warn_and_continue" },
{ label: "fail_fast", value: "fail_fast" },
],
},
{
key: "oauth_otp_phone_action",
label: "OAuth OTP阶段手机验证",
type: "select",
value: config.flow.oauth_otp_phone_action,
options: [
{ label: "warn_and_continue", value: "warn_and_continue" },
{ label: "fail_fast", value: "fail_fast" },
],
},
{ key: "transient_markers", label: "瞬时错误关键词", type: "text", value: config.flow.transient_markers },
],
},
{
key: "oauth",
label: "OAuth 配置",
columns: 2,
fields: [
{ key: "issuer", label: "认证服务地址", type: "text", value: config.oauth.issuer },
{ key: "client_id", label: "客户端 ID", type: "text", value: config.oauth.client_id },
{ key: "redirect_uri", label: "回调地址", type: "text", value: config.oauth.redirect_uri },
{ key: "retry_attempts", label: "重试次数", type: "number", value: config.oauth.retry_attempts },
{ key: "retry_backoff_base", label: "退避基数", type: "number", value: config.oauth.retry_backoff_base },
{ key: "retry_backoff_max", label: "最大退避", type: "number", value: config.oauth.retry_backoff_max },
{ key: "otp_timeout_seconds", label: "登录验证码超时", type: "number", value: config.oauth.otp_timeout_seconds },
{ key: "otp_poll_interval_seconds", label: "登录轮询间隔", type: "number", value: config.oauth.otp_poll_interval_seconds },
],
},
{
key: "output",
label: "输出配置",
columns: 2,
fields: [
{ key: "accounts_file", label: "账号文件", type: "text", value: config.output.accounts_file },
{ key: "csv_file", label: "CSV 文件", type: "text", value: config.output.csv_file },
{ key: "ak_file", label: "Access Token 文件", type: "text", value: config.output.ak_file },
{ key: "rk_file", label: "Refresh Token 文件", type: "text", value: config.output.rk_file },
{ key: "save_local", label: "本地保存", type: "checkbox", value: config.output.save_local },
],
},
];
}
export function sectionsToConfig(sections: ConfigSection[]): BackendConfig {
const config = structuredClone(defaultBackendConfig);
for (const section of sections) {
const targetKey = section.key === "self_hosted_mail_api" ? "mail" : section.key;
if (section.key === "priority") {
for (const field of section.fields) {
if (field.key === "base_url" || field.key === "token") {
(config.clean as Record<string, string | number | boolean>)[field.key] = field.value;
} else if (field.key === "min_candidates") {
config.maintainer.min_candidates = Number(field.value);
} else if (field.key === "loop_interval_seconds") {
config.maintainer.loop_interval_seconds = Number(field.value);
} else if (field.key === "proxy") {
config.run.proxy = String(field.value);
}
}
continue;
}
const target = config[targetKey as keyof BackendConfig] as Record<string, string | number | boolean | string[]>;
if (!target) {
continue;
}
for (const field of section.fields) {
if (field.key === "domains") {
target[field.key] = linesToArray(field.value);
continue;
}
target[field.key] = field.value;
}
}
return normalizeBackendConfig(config);
}

7
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,7 @@
import { render } from "preact";
import { App } from "./app";
import "./styles/tokens.css";
import "./styles/base.css";
import "./styles/layout.css";
render(<App />, document.getElementById("app") as HTMLElement);

51
frontend/src/mock/data.ts Normal file
View File

@@ -0,0 +1,51 @@
import { configToSections, defaultBackendConfig } from "../lib/config-schema";
import type { MonitorState } from "../types/runtime";
export const initialConfigSections = configToSections(defaultBackendConfig);
export const initialMonitorState: MonitorState = {
running: false,
runMode: "",
loopRunning: false,
loopNextCheckInSeconds: null,
phase: "idle",
message: "等待任务启动",
availableCandidates: null,
availableCandidatesError: "",
completed: 2,
total: 20,
percent: 10,
stats: [
{ label: "成功", value: 2, icon: "☑", tone: "success" },
{ label: "失败", value: 0, icon: "✕", tone: "danger" },
{ label: "剩余", value: 18, icon: "⏳", tone: "pending" },
],
singleAccountTiming: {
latestRegSeconds: 15.4,
latestOauthSeconds: 56.8,
latestTotalSeconds: 72.2,
recentAvgRegSeconds: 16.1,
recentAvgOauthSeconds: 54.3,
recentAvgTotalSeconds: 70.4,
recentSlowCount: 1,
sampleSize: 20,
windowSize: 20,
},
logs: [
{ id: "1", prefix: "[00:28:38] [任务3]", timestamp: "[00:28:38]", message: "提交密码状态: 200", tone: "info" },
{ id: "2", prefix: "[00:28:38] [任务3]", timestamp: "[00:28:38]", message: "9. 发送验证码...", tone: "info" },
{ id: "3", prefix: "[00:28:39] [任务3]", timestamp: "[00:28:39]", message: "验证码发送状态: 200", tone: "info" },
{ id: "4", prefix: "[00:28:39] [任务3]", timestamp: "[00:28:39]", message: "10. 等待验证码...", tone: "info" },
{
id: "5",
prefix: "[00:28:39] [任务3]",
timestamp: "[00:28:39]",
message: "正在等待邮箱 dictman3eb8a4@whf.hush2u.com 的验证码...",
tone: "info",
},
{ id: "6", prefix: "[00:28:39] [任务3]", timestamp: "[00:28:39]", message: "成功获取验证码: 963817", tone: "success" },
{ id: "7", prefix: "[00:28:40] [任务3]", timestamp: "[00:28:40]", message: "生成用户信息: Charlotte生日: 1996-01-27", tone: "info" },
{ id: "8", prefix: "[00:28:41] [任务3]", timestamp: "[00:28:41]", message: "Sentinel token 获取成功", tone: "success" },
{ id: "9", prefix: "[00:28:44] [任务3]", timestamp: "[00:28:44]", message: "OAuth 登录链路进入 consent 阶段", tone: "success" },
],
};

View File

@@ -0,0 +1,127 @@
import { configToSections, normalizeBackendConfig, sectionsToConfig } from "../lib/config-schema";
import type {
AuthState,
BackendConfig,
RuntimeStatusResponse,
StartRuntimeResponse,
StopRuntimeResponse,
} from "../types/api";
import type { ConfigSection, MonitorState } from "../types/runtime";
const AUTH_STORAGE_KEY = "apm_admin_token";
export function getStoredAuth(): AuthState {
return {
token: window.sessionStorage.getItem(AUTH_STORAGE_KEY) ?? "",
};
}
export function storeAuthToken(token: string): void {
window.sessionStorage.setItem(AUTH_STORAGE_KEY, token);
}
export function clearAuthToken(): void {
window.sessionStorage.removeItem(AUTH_STORAGE_KEY);
}
export class ApiRequestError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = "ApiRequestError";
this.status = status;
}
}
export function isAuthError(error: unknown): boolean {
return error instanceof ApiRequestError && (error.status === 401 || error.status === 403);
}
async function getJson<T>(path: string, init?: RequestInit, token?: string): Promise<T> {
const adminToken = token ?? getStoredAuth().token;
const response = await fetch(path, {
...init,
headers: {
"Content-Type": "application/json",
...(adminToken ? { "X-Admin-Token": adminToken } : {}),
...(init?.headers ?? {}),
},
});
if (!response.ok) {
throw new ApiRequestError(`${init?.method ?? "GET"} ${path} failed: ${response.status}`, response.status);
}
return (await response.json()) as T;
}
export async function verifyAuthToken(token: string): Promise<void> {
await getJson<{ ok: boolean; time: string }>("/api/health", undefined, token);
await getJson<Partial<BackendConfig>>("/api/config", undefined, token);
}
export async function fetchConfig(): Promise<ConfigSection[]> {
const config = await getJson<Partial<BackendConfig>>("/api/config");
return configToSections(normalizeBackendConfig(config));
}
export async function saveConfig(sections: ConfigSection[]): Promise<ConfigSection[]> {
const saved = await getJson<BackendConfig>("/api/config", {
method: "POST",
body: JSON.stringify(sectionsToConfig(sections)),
});
return configToSections(normalizeBackendConfig(saved));
}
export async function fetchMonitorState(): Promise<MonitorState> {
const status = await getJson<RuntimeStatusResponse>("/api/runtime/status");
const timing = status.single_account_timing;
return {
running: status.running,
runMode: status.run_mode ?? "",
loopRunning: status.loop_running ?? false,
loopNextCheckInSeconds: status.loop_next_check_in_seconds ?? null,
phase: status.phase,
message: status.message,
availableCandidates: status.available_candidates,
availableCandidatesError: status.available_candidates_error,
completed: status.completed,
total: status.total,
percent: status.percent,
stats: status.stats,
singleAccountTiming: {
latestRegSeconds: timing?.latest_reg_seconds ?? null,
latestOauthSeconds: timing?.latest_oauth_seconds ?? null,
latestTotalSeconds: timing?.latest_total_seconds ?? null,
recentAvgRegSeconds: timing?.recent_avg_reg_seconds ?? null,
recentAvgOauthSeconds: timing?.recent_avg_oauth_seconds ?? null,
recentAvgTotalSeconds: timing?.recent_avg_total_seconds ?? null,
recentSlowCount: timing?.recent_slow_count ?? 0,
sampleSize: timing?.sample_size ?? 0,
windowSize: timing?.window_size ?? 20,
},
logs: status.logs,
};
}
export async function startRuntime(): Promise<StartRuntimeResponse> {
return getJson<StartRuntimeResponse>("/api/runtime/start", {
method: "POST",
body: "{}",
});
}
export async function startRuntimeLoop(): Promise<StartRuntimeResponse> {
return getJson<StartRuntimeResponse>("/api/runtime/start-loop", {
method: "POST",
body: "{}",
});
}
export async function stopRuntime(): Promise<StopRuntimeResponse> {
return getJson<StopRuntimeResponse>("/api/runtime/stop", {
method: "POST",
body: "{}",
});
}

View File

@@ -0,0 +1,3 @@
export function connectLogStream(): EventSource {
return new EventSource("/api/logs");
}

View File

@@ -0,0 +1,29 @@
* {
box-sizing: border-box;
}
html,
body,
#app {
min-height: 100%;
}
html,
body {
margin: 0;
}
body {
padding: 28px 24px;
font-family: "IBM Plex Sans", "PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(177, 220, 255, 0.24), transparent 28%),
linear-gradient(180deg, #f9fcff 0%, var(--bg) 100%);
}
button,
input,
select {
font: inherit;
}

View File

@@ -0,0 +1,816 @@
.page-shell {
max-width: 1280px;
margin: 0 auto;
}
.login-shell {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-card {
width: min(420px, 100%);
padding: 28px;
border: 1px solid var(--line);
border-radius: 20px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(246, 250, 255, 0.96));
box-shadow: var(--shadow);
}
.login-title {
font-size: 24px;
font-weight: 700;
color: #153150;
}
.login-subtitle {
margin-top: 8px;
margin-bottom: 20px;
color: #6f87a4;
font-size: 14px;
}
.login-error {
margin-top: -4px;
margin-bottom: 14px;
color: #b24b52;
font-size: 13px;
}
.login-button {
width: 100%;
}
.page-notice {
margin-bottom: 16px;
padding: 12px 16px;
border: 1px solid #d7e4f2;
border-radius: 14px;
background: rgba(255, 255, 255, 0.92);
color: #335376;
box-shadow: var(--shadow);
}
.page-grid {
display: grid;
grid-template-columns: 430px minmax(0, 1fr);
gap: 24px;
align-items: start;
}
.main-stack {
display: grid;
align-content: start;
min-width: 0;
}
.card {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(251, 253, 255, 0.95));
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
backdrop-filter: blur(10px);
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px 18px;
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(247, 250, 255, 0.9));
}
.card-title {
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 16px;
font-weight: 700;
letter-spacing: 0.01em;
}
.title-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
font-size: 14px;
}
.settings-body {
padding: 24px;
max-height: calc(100vh - 160px);
overflow: auto;
}
.settings-card {
min-width: 0;
}
.settings-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-bottom: 16px;
}
.summary-chip {
padding: 10px 12px;
border: 1px solid #deebf8;
border-radius: 14px;
background: linear-gradient(180deg, #fdfefe, #eef5fb);
}
.summary-label {
display: block;
margin-bottom: 4px;
color: #6f87a4;
font-size: 12px;
}
.summary-value {
display: block;
color: #163151;
font-size: 14px;
font-weight: 700;
}
.settings-tabs {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-bottom: 16px;
padding: 6px;
border: 1px solid #deebf8;
border-radius: 16px;
background: linear-gradient(180deg, #f9fbfe, #eef4fb);
}
.settings-tab {
height: 38px;
border: 0;
border-radius: 11px;
background: transparent;
color: #6f87a4;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: background 140ms ease, color 140ms ease, transform 140ms ease;
}
.settings-tab:hover {
transform: translateY(-1px);
}
.settings-tab.active {
background: #ffffff;
color: #143150;
box-shadow: 0 8px 18px rgba(110, 139, 174, 0.14);
}
.config-group {
margin-bottom: 16px;
padding: 0;
border: 1px solid #e0eaf5;
border-radius: 14px;
background: linear-gradient(180deg, rgba(249, 252, 255, 0.96), rgba(244, 249, 254, 0.9));
}
.config-group:last-of-type {
margin-bottom: 0;
}
.group-title {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.03em;
color: #4f7196;
}
.group-toggle {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 15px 14px;
border: 0;
background: transparent;
cursor: pointer;
}
.group-toggle:hover {
background: rgba(234, 242, 251, 0.5);
}
.group-caret {
color: #6f87a4;
font-size: 18px;
line-height: 1;
transition: transform 160ms ease;
}
.group-caret.open {
transform: rotate(180deg);
}
.config-group.expanded .field-row,
.config-group.expanded .group-content,
.config-group.expanded > .field,
.config-group.expanded > .checkbox-group {
padding-left: 14px;
padding-right: 14px;
}
.config-group.expanded .field-row,
.config-group.expanded .group-content {
padding-bottom: 10px;
}
.group-key,
.field-key {
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: 6px;
padding: 2px 7px;
border-radius: 999px;
background: #eaf2fb;
border: 1px solid #d6e4f2;
color: #6483a8;
font-family: "IBM Plex Mono", "JetBrains Mono", monospace;
font-size: 11px;
font-weight: 600;
}
.field-key {
margin-left: 8px;
transform: translateY(-1px);
}
.field {
display: block;
margin-bottom: 14px;
}
.field-row {
display: grid;
gap: 12px;
}
.field-row.single-col {
grid-template-columns: 1fr;
}
.field-row.two-cols {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.field-row.three-cols {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.field-row .field {
margin-bottom: 8px;
}
.field-label {
display: block;
margin-bottom: 8px;
color: var(--muted-strong);
font-size: 13px;
line-height: 1.35;
}
.field-hint {
display: block;
margin-top: 8px;
color: #8da1bb;
font-size: 12px;
line-height: 1.45;
}
input[type="text"],
input[type="password"],
select,
input[type="number"] {
width: 100%;
height: 40px;
padding: 0 14px;
border: 1px solid #d6e2f0;
border-radius: 11px;
background: #fff;
color: var(--text);
font-size: 14px;
outline: none;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
input[type="text"]:focus,
input[type="password"]:focus,
select:focus,
input[type="number"]:focus {
border-color: #8eb8ef;
box-shadow: 0 0 0 3px rgba(115, 164, 224, 0.14);
}
.checkbox-group {
margin-top: 4px;
}
.checkbox-group.compact {
margin-bottom: 6px;
}
.check-row {
display: flex;
align-items: center;
gap: 10px;
color: var(--muted-strong);
font-size: 15px;
}
.check-row input {
width: 14px;
height: 14px;
accent-color: var(--accent);
}
.settings-actions {
display: flex;
gap: 10px;
margin-top: 18px;
position: sticky;
bottom: -24px;
padding-top: 12px;
background: linear-gradient(180deg, rgba(244, 248, 252, 0), rgba(244, 248, 252, 0.96) 36%, rgba(244, 248, 252, 1) 100%);
}
.button,
.tool-button,
.link-button {
border: 0;
border-radius: 11px;
font: inherit;
cursor: pointer;
transition: transform 140ms ease, box-shadow 140ms ease, background 140ms ease;
}
.button:hover,
.tool-button:hover,
.link-button:hover {
transform: translateY(-1px);
}
.button:disabled,
.tool-button:disabled,
.link-button:disabled {
cursor: not-allowed;
opacity: 0.65;
transform: none;
}
.button.primary {
padding: 11px 18px;
background: linear-gradient(135deg, #179774, #0f7f61);
color: #fff;
box-shadow: 0 12px 22px rgba(21, 145, 110, 0.25);
}
.button.secondary,
.button.tertiary,
.button.warning {
padding: 11px 18px;
background: #eef4fb;
color: var(--text);
border: 1px solid #d6e3f0;
}
.button.warning {
background: #fff2ee;
color: #a34b2c;
border-color: #f3c8ba;
}
.button.tertiary {
padding: 8px 14px;
font-size: 14px;
font-weight: 600;
}
.link-button {
background: transparent;
color: #4d6c92;
padding: 0;
font-size: 14px;
}
.monitor-body {
padding: 22px 16px 18px;
display: flex;
flex-direction: column;
min-height: 0;
}
.progress-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.progress-title {
color: #5b7391;
font-size: 13px;
font-weight: 700;
}
.runtime-banner {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
padding: 10px 12px;
border-radius: 12px;
background: #eef4fb;
color: #526e90;
font-size: 14px;
}
.runtime-banner.active {
background: #e3f5ee;
color: #1c6b53;
}
.inventory-banner {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid #dfe9f4;
background: linear-gradient(180deg, #ffffff, #f2f7fc);
}
.inventory-label {
color: #637b98;
font-size: 13px;
font-weight: 600;
}
.inventory-value {
color: #153150;
font-size: 18px;
font-weight: 700;
}
.runtime-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #8ca6c7;
box-shadow: 0 0 0 4px rgba(140, 166, 199, 0.14);
}
.runtime-banner.active .runtime-dot {
background: #35be7c;
box-shadow: 0 0 0 4px rgba(53, 190, 124, 0.14);
}
.runtime-mode-banner {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
padding: 9px 12px;
border-radius: 12px;
border: 1px solid #dbe6f2;
background: #f7fafd;
color: #4b6788;
font-size: 13px;
}
.runtime-mode-label {
color: #6f89a7;
font-weight: 600;
}
.runtime-mode-value {
color: #153150;
font-weight: 700;
}
.runtime-mode-next {
margin-left: auto;
color: #5a7290;
font-weight: 600;
}
.progress-meta {
display: flex;
align-items: center;
gap: 12px;
padding: 0 2px;
font-size: 15px;
font-weight: 700;
color: #0f2240;
}
.progress-meta span:last-child {
font-size: 13px;
font-weight: 700;
color: #5b7391;
}
.progress-track {
margin-top: 10px;
width: 100%;
height: 8px;
border-radius: 999px;
background: #deebf8;
overflow: hidden;
}
.progress-value {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #15916e, #1bb790);
}
.stat-strip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
padding: 14px 0 14px;
}
.mini-stat {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid #dfebf6;
border-radius: 14px;
background: linear-gradient(180deg, #fdfefe, #eef5fb);
}
.mini-stat-label {
color: #617a98;
font-size: 13px;
font-weight: 600;
}
.mini-stat-value {
font-size: 20px;
font-weight: 700;
color: #0f2140;
}
.mini-stat.success {
border-color: #d2eee3;
background: linear-gradient(180deg, #f9fffc, #eaf7f1);
}
.mini-stat.danger {
border-color: #f4d8df;
background: linear-gradient(180deg, #fffafb, #fcf0f3);
}
.mini-stat.pending {
border-color: #e7e2cf;
background: linear-gradient(180deg, #fffef9, #f6f2e4);
}
.timing-strip {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.timing-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
border: 1px dashed #d3e2f0;
border-radius: 10px;
background: #f7fbff;
}
.timing-label {
color: #5f7997;
font-size: 12px;
font-weight: 600;
}
.timing-value {
color: #112746;
font-size: 13px;
font-weight: 700;
}
.terminal {
height: clamp(320px, 52vh, 560px);
padding: 14px 16px;
border-radius: 13px;
background: linear-gradient(180deg, #1e2030, var(--terminal));
border: 1px solid #2b3044;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
overflow: auto;
}
.log-row {
font-family: "IBM Plex Mono", "JetBrains Mono", "SFMono-Regular", monospace;
font-size: 15px;
line-height: 1.9;
white-space: nowrap;
color: #d7e3ff;
}
.log-dim {
color: #91a3c8;
}
.log-info {
color: #b6c8ff;
}
.log-success {
color: #9fe870;
}
.log-warning {
color: #ffd575;
}
.log-danger {
color: #ff8ea6;
}
.card-tools {
display: flex;
align-items: center;
gap: 10px;
}
.tool-button {
padding: 6px 10px;
background: transparent;
color: #4d6c92;
font-size: 14px;
}
.table-wrap {
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
thead th {
padding: 10px 16px;
text-align: left;
font-size: 14px;
font-weight: 700;
color: #506b8a;
background: linear-gradient(180deg, #eef4fb, #e7eef7);
border-bottom: 1px solid #dde8f3;
}
tbody td {
padding: 12px 16px;
font-size: 15px;
border-bottom: 1px solid #edf3f8;
vertical-align: middle;
}
tbody tr:hover {
background: rgba(243, 248, 253, 0.88);
}
.col-id {
width: 48px;
}
.col-password {
width: 150px;
}
.col-status {
width: 82px;
}
.email-cell,
.password-cell {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.email-cell span:first-child,
.password-cell span:first-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.masked {
filter: blur(4px);
user-select: none;
}
.mini-icon {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid #d9e4ef;
background: #f7faff;
border-radius: 8px;
color: #6f87a4;
cursor: pointer;
flex: 0 0 auto;
}
.status-dot {
display: inline-block;
width: 13px;
height: 13px;
border-radius: 50%;
background: #bbcad9;
box-shadow: 0 0 0 4px rgba(187, 202, 217, 0.18);
}
.status-dot.active {
background: linear-gradient(180deg, #7fe098, #34c36c);
box-shadow: 0 0 0 4px rgba(59, 198, 108, 0.16);
}
@media (max-width: 1120px) {
.page-grid {
grid-template-columns: 1fr;
}
.monitor-body {
padding: 22px 18px 18px;
}
}
@media (max-width: 720px) {
body {
padding: 18px 14px;
}
.card-head,
.settings-body {
padding-left: 16px;
padding-right: 16px;
}
.field-row.two-cols,
.field-row.three-cols {
grid-template-columns: 1fr;
}
.settings-summary {
grid-template-columns: 1fr;
}
.settings-tabs {
grid-template-columns: 1fr;
}
.stat-strip {
grid-template-columns: 1fr;
}
.timing-strip {
grid-template-columns: 1fr;
}
.terminal {
height: 320px;
}
thead th,
tbody td {
padding-left: 12px;
padding-right: 12px;
font-size: 14px;
}
}

View File

@@ -0,0 +1,16 @@
:root {
--bg: #f4f8fc;
--panel: rgba(255, 255, 255, 0.92);
--panel-strong: #ffffff;
--line: #d7e4f2;
--line-soft: #e8f0f8;
--text: #10233f;
--muted: #6f87a4;
--muted-strong: #4f6987;
--accent: #15916e;
--danger: #ef5077;
--pending: #1e3152;
--terminal: #191b29;
--shadow: 0 14px 42px rgba(108, 135, 166, 0.12);
--radius: 18px;
}

142
frontend/src/types/api.ts Normal file
View File

@@ -0,0 +1,142 @@
import type { LogLine, StatItem } from "./runtime";
export type SingleAccountTimingResponse = {
latest_reg_seconds: number | null;
latest_oauth_seconds: number | null;
latest_total_seconds: number | null;
recent_avg_reg_seconds: number | null;
recent_avg_oauth_seconds: number | null;
recent_avg_total_seconds: number | null;
recent_slow_count: number;
sample_size: number;
window_size: number;
};
export type BackendConfig = {
cfmail: {
api_base: string;
api_key: string;
domain: string;
domains: string[];
};
clean: {
base_url: string;
token: string;
target_type: string;
workers: number;
sample_size: number;
delete_workers: number;
timeout: number;
retries: number;
user_agent?: string;
used_percent_threshold: number;
};
mail: {
provider: string;
api_base: string;
api_key: string;
domain: string;
domains: string[];
otp_timeout_seconds: number;
poll_interval_seconds: number;
};
duckmail: {
api_base: string;
bearer: string;
domain: string;
domains: string[];
};
tempmail_lol: {
api_base: string;
};
yyds_mail: {
api_base: string;
api_key: string;
domain: string;
domains: string[];
};
maintainer: {
min_candidates: number;
loop_interval_seconds: number;
};
run: {
workers: number;
proxy: string;
failure_threshold_for_cooldown: number;
failure_cooldown_seconds: number;
loop_jitter_min_seconds: number;
loop_jitter_max_seconds: number;
};
flow: {
step_retry_attempts: number;
step_retry_delay_base: number;
step_retry_delay_cap: number;
outer_retry_attempts: number;
oauth_local_retry_attempts: number;
transient_markers: string;
register_otp_validate_order: string;
oauth_otp_validate_order: string;
oauth_password_phone_action: string;
oauth_otp_phone_action: string;
};
registration: {
entry_mode: string;
entry_mode_fallback: boolean;
chatgpt_base: string;
register_create_account_phone_action: string;
phone_verification_markers: string;
};
oauth: {
issuer: string;
client_id: string;
redirect_uri: string;
retry_attempts: number;
retry_backoff_base: number;
retry_backoff_max: number;
otp_timeout_seconds: number;
otp_poll_interval_seconds: number;
};
output: {
accounts_file: string;
csv_file: string;
ak_file: string;
rk_file: string;
save_local: boolean;
};
};
export type RuntimeStatusResponse = {
running: boolean;
run_mode?: string;
loop_running?: boolean;
loop_next_check_in_seconds?: number | null;
phase: string;
message: string;
available_candidates: number | null;
available_candidates_error?: string;
completed: number;
total: number;
percent: number;
stats: StatItem[];
single_account_timing?: SingleAccountTimingResponse;
logs: LogLine[];
last_log_path?: string;
};
export type StartRuntimeResponse = {
ok: boolean;
started: boolean;
pid?: number;
mode?: string;
message: string;
};
export type StopRuntimeResponse = {
ok: boolean;
stopped: boolean;
message: string;
};
export type AuthState = {
token: string;
};

View File

@@ -0,0 +1,63 @@
export type SelectOption = {
label: string;
value: string;
};
export type ConfigField = {
key: string;
label: string;
type: "text" | "textarea" | "password" | "number" | "checkbox" | "select";
value: string | number | boolean;
sensitive?: boolean;
options?: SelectOption[];
hint?: string;
};
export type ConfigSection = {
key: string;
label: string;
columns?: 1 | 2 | 3;
fields: ConfigField[];
};
export type StatItem = {
label: string;
value: number;
icon: string;
tone: "success" | "danger" | "pending";
};
export type LogLine = {
id: string;
prefix: string;
timestamp: string;
message: string;
tone: "muted" | "info" | "success" | "warning" | "danger";
};
export type MonitorState = {
running: boolean;
runMode?: string;
loopRunning?: boolean;
loopNextCheckInSeconds?: number | null;
phase: string;
message: string;
availableCandidates: number | null;
availableCandidatesError?: string;
completed: number;
total: number;
percent: number;
stats: StatItem[];
singleAccountTiming: {
latestRegSeconds: number | null;
latestOauthSeconds: number | null;
latestTotalSeconds: number | null;
recentAvgRegSeconds: number | null;
recentAvgOauthSeconds: number | null;
recentAvgTotalSeconds: number | null;
recentSlowCount: number;
sampleSize: number;
windowSize: number;
};
logs: LogLine[];
};