first commit
This commit is contained in:
BIN
frontend/src/.DS_Store
vendored
Normal file
BIN
frontend/src/.DS_Store
vendored
Normal file
Binary file not shown.
284
frontend/src/app.tsx
Normal file
284
frontend/src/app.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
322
frontend/src/components/config-panel.tsx
Normal file
322
frontend/src/components/config-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
frontend/src/components/login-gate.tsx
Normal file
39
frontend/src/components/login-gate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
frontend/src/components/monitor-panel.tsx
Normal file
110
frontend/src/components/monitor-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
frontend/src/components/terminal-log.tsx
Normal file
39
frontend/src/components/terminal-log.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
580
frontend/src/lib/config-schema.ts
Normal file
580
frontend/src/lib/config-schema.ts
Normal 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
7
frontend/src/main.tsx
Normal 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
51
frontend/src/mock/data.ts
Normal 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" },
|
||||
],
|
||||
};
|
||||
127
frontend/src/services/api.ts
Normal file
127
frontend/src/services/api.ts
Normal 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: "{}",
|
||||
});
|
||||
}
|
||||
3
frontend/src/services/events.ts
Normal file
3
frontend/src/services/events.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function connectLogStream(): EventSource {
|
||||
return new EventSource("/api/logs");
|
||||
}
|
||||
29
frontend/src/styles/base.css
Normal file
29
frontend/src/styles/base.css
Normal 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;
|
||||
}
|
||||
816
frontend/src/styles/layout.css
Normal file
816
frontend/src/styles/layout.css
Normal 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;
|
||||
}
|
||||
}
|
||||
16
frontend/src/styles/tokens.css
Normal file
16
frontend/src/styles/tokens.css
Normal 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
142
frontend/src/types/api.ts
Normal 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;
|
||||
};
|
||||
63
frontend/src/types/runtime.ts
Normal file
63
frontend/src/types/runtime.ts
Normal 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[];
|
||||
};
|
||||
Reference in New Issue
Block a user