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/.DS_Store vendored Normal file

Binary file not shown.

18
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

65
frontend/README.md Normal file
View File

@@ -0,0 +1,65 @@
# Frontend Scaffold
轻量前端脚手架,技术选型:
- `Vite`
- `Preact`
- `TypeScript`
- 手写 `CSS`
## 启动
```bash
cd /home/zhang/workspace/script/auto_pool_maintainer_duckMail
python3 api_server.py
```
另一个终端:
```bash
cd frontend
npm install
npm run dev
```
生产构建:
```bash
cd frontend
npm run build
```
## 一键管理三端
项目根目录提供了统一脚本:
```bash
./dev_services.sh fg
```
前台托管三个服务,按 `Ctrl+C` 会一起关闭。
```bash
./dev_services.sh bg
./dev_services.sh status
./dev_services.sh stop
```
后台启动、查看状态、停止服务都可以直接用上面三条命令。
后台日志和 PID 会写到 `logs/dev-services/`
## 结构
- `src/app.tsx`: 页面入口
- `src/components/`: 配置面板、监控台、日志终端、账号表格
- `src/mock/data.ts`: 当前假数据
- `src/services/`: 后续接后端 API / SSE 的位置
- `src/types/`: 前端状态类型定义
- `src/styles/`: 设计变量和页面样式
## 当前状态
- 已将现有静态 UI 迁为前端组件结构
- `config` 已接到真实后端接口:`GET/POST /api/config`
- `runtime status` 已接到真实后端接口:`GET /api/runtime/status`
- 账号表格仍使用本地假数据

BIN
frontend/dist/.DS_Store vendored Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

13
frontend/dist/index.html vendored Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Auto Pool Maintainer</title>
<script type="module" crossorigin src="/assets/index-DPSNYdMF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CraRoSIX.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Auto Pool Maintainer</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

20
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,20 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8318;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}

19
frontend/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "auto-pool-maintainer-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"preact": "^10.26.4"
},
"devDependencies": {
"@preact/preset-vite": "^2.10.2",
"typescript": "^5.8.2",
"vite": "^6.2.0"
}
}

1291
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true

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[];
};

24
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"jsxImportSource": "preact",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

16
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from "vite";
import preact from "@preact/preset-vite";
export default defineConfig({
plugins: [preact()],
server: {
host: "127.0.0.1",
port: 8173,
proxy: {
"/api": {
target: "http://127.0.0.1:8318",
changeOrigin: true,
},
},
},
});