init biliup-next
This commit is contained in:
65
frontend/README.md
Normal file
65
frontend/README.md
Normal file
@ -0,0 +1,65 @@
|
||||
# Frontend
|
||||
|
||||
`frontend/` 是新的 React + Vite 控制台迁移骨架,目标是逐步替换当前 `src/biliup_next/app/static/` 下的原生前端。
|
||||
|
||||
当前已迁入:
|
||||
|
||||
- 基础导航:`Overview / Tasks / Settings / Logs`
|
||||
- `Overview` 第一版
|
||||
- `Tasks` 工作台第一版
|
||||
- `Logs` 工作台第一版
|
||||
- 任务表
|
||||
- 任务详情
|
||||
- 与现有 Python API 的直连
|
||||
|
||||
## 目录
|
||||
|
||||
- `src/App.jsx`
|
||||
- `src/components/`
|
||||
- `src/api/client.js`
|
||||
- `src/lib/format.js`
|
||||
- `src/styles.css`
|
||||
|
||||
## 启动
|
||||
|
||||
当前机器未安装 `npm` 或 `corepack`,所以这套前端骨架还没有在本机完成依赖安装。
|
||||
|
||||
有包管理器后,在 `frontend/` 下执行:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
开发服务器默认地址:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:5173/ui/
|
||||
```
|
||||
|
||||
默认会通过 `vite.config.mjs` 把这些路径代理到现有后端 `http://127.0.0.1:8787`:
|
||||
|
||||
- `/health`
|
||||
- `/doctor`
|
||||
- `/tasks`
|
||||
- `/settings`
|
||||
- `/runtime`
|
||||
- `/history`
|
||||
- `/logs`
|
||||
- `/modules`
|
||||
- `/scheduler`
|
||||
- `/worker`
|
||||
- `/stage`
|
||||
|
||||
生产构建完成后,把输出放到 `frontend/dist/`,当前 Python API 会自动在以下地址托管它:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8787/ui/
|
||||
```
|
||||
|
||||
## 下一步
|
||||
|
||||
- 迁移 `Settings`
|
||||
- 将任务表改为真正服务端驱动的分页/排序/筛选
|
||||
- 增加 React 路由和查询缓存
|
||||
- 最终替换当前 `src/biliup_next/app/static/` 入口
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal 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>biliup-next Frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1815
frontend/package-lock.json
generated
Normal file
1815
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
frontend/package.json
Normal file
19
frontend/package.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "biliup-next-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
}
|
||||
495
frontend/src/App.jsx
Normal file
495
frontend/src/App.jsx
Normal file
@ -0,0 +1,495 @@
|
||||
import { useEffect, useState, useDeferredValue, startTransition } from "react";
|
||||
|
||||
import { fetchJson, uploadFile } from "./api/client.js";
|
||||
import LogsPanel from "./components/LogsPanel.jsx";
|
||||
import OverviewPanel from "./components/OverviewPanel.jsx";
|
||||
import SettingsPanel from "./components/SettingsPanel.jsx";
|
||||
import TaskTable from "./components/TaskTable.jsx";
|
||||
import TaskDetailCard from "./components/TaskDetailCard.jsx";
|
||||
import { summarizeAttention, summarizeDelivery } from "./lib/format.js";
|
||||
|
||||
const NAV_ITEMS = ["Overview", "Tasks", "Settings", "Logs"];
|
||||
|
||||
function PlaceholderView({ title, description }) {
|
||||
return (
|
||||
<section className="placeholder-view">
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TasksView({
|
||||
tasks,
|
||||
selectedTaskId,
|
||||
onSelectTask,
|
||||
onRunTask,
|
||||
taskDetail,
|
||||
loading,
|
||||
detailLoading,
|
||||
selectedStepName,
|
||||
onSelectStep,
|
||||
onRetryStep,
|
||||
onResetStep,
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [attentionFilter, setAttentionFilter] = useState("");
|
||||
const [deliveryFilter, setDeliveryFilter] = useState("");
|
||||
const [sort, setSort] = useState("updated_desc");
|
||||
const deferredSearch = useDeferredValue(search);
|
||||
|
||||
const filtered = tasks
|
||||
.filter((task) => {
|
||||
const haystack = `${task.id} ${task.title}`.toLowerCase();
|
||||
if (deferredSearch && !haystack.includes(deferredSearch.toLowerCase())) return false;
|
||||
if (statusFilter && task.status !== statusFilter) return false;
|
||||
if (attentionFilter && summarizeAttention(task) !== attentionFilter) return false;
|
||||
if (deliveryFilter && summarizeDelivery(task.delivery_state) !== deliveryFilter) return false;
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (sort === "title_asc") return String(a.title).localeCompare(String(b.title), "zh-CN");
|
||||
if (sort === "title_desc") return String(b.title).localeCompare(String(a.title), "zh-CN");
|
||||
if (sort === "attention") return summarizeAttention(a).localeCompare(summarizeAttention(b), "zh-CN");
|
||||
return String(b.updated_at).localeCompare(String(a.updated_at), "zh-CN");
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="tasks-layout-react">
|
||||
<article className="panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<p className="eyebrow">Tasks Workspace</p>
|
||||
<h2>Task Table</h2>
|
||||
</div>
|
||||
<div className="panel-meta">{loading ? "syncing..." : `${filtered.length} visible`}</div>
|
||||
</div>
|
||||
<div className="toolbar-grid">
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="搜索任务标题或 task id"
|
||||
/>
|
||||
<select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="running">处理中</option>
|
||||
<option value="failed_retryable">待重试</option>
|
||||
<option value="failed_manual">待人工</option>
|
||||
<option value="published">待收尾</option>
|
||||
<option value="collection_synced">已完成</option>
|
||||
</select>
|
||||
<select value={attentionFilter} onChange={(event) => setAttentionFilter(event.target.value)}>
|
||||
<option value="">全部关注状态</option>
|
||||
<option value="manual_now">仅看需人工</option>
|
||||
<option value="retry_now">仅看到点重试</option>
|
||||
<option value="waiting_retry">仅看等待重试</option>
|
||||
</select>
|
||||
<select value={deliveryFilter} onChange={(event) => setDeliveryFilter(event.target.value)}>
|
||||
<option value="">全部交付状态</option>
|
||||
<option value="legacy_untracked">主视频评论未追踪</option>
|
||||
<option value="pending_comment">评论待完成</option>
|
||||
<option value="cleanup_removed">已清理视频</option>
|
||||
</select>
|
||||
<select value={sort} onChange={(event) => setSort(event.target.value)}>
|
||||
<option value="updated_desc">最近更新</option>
|
||||
<option value="title_asc">标题 A-Z</option>
|
||||
<option value="title_desc">标题 Z-A</option>
|
||||
<option value="attention">按关注状态</option>
|
||||
</select>
|
||||
</div>
|
||||
<TaskTable tasks={filtered} selectedTaskId={selectedTaskId} onSelectTask={onSelectTask} onRunTask={onRunTask} />
|
||||
</article>
|
||||
<TaskDetailCard
|
||||
payload={taskDetail}
|
||||
loading={detailLoading}
|
||||
selectedStepName={selectedStepName}
|
||||
onSelectStep={onSelectStep}
|
||||
onRetryStep={onRetryStep}
|
||||
onResetStep={onResetStep}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [view, setView] = useState("Tasks");
|
||||
const [health, setHealth] = useState(false);
|
||||
const [doctorOk, setDoctorOk] = useState(false);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [services, setServices] = useState({ items: [] });
|
||||
const [scheduler, setScheduler] = useState(null);
|
||||
const [history, setHistory] = useState({ items: [] });
|
||||
const [logs, setLogs] = useState({ items: [] });
|
||||
const [selectedLogName, setSelectedLogName] = useState("");
|
||||
const [logContent, setLogContent] = useState(null);
|
||||
const [filterCurrentTaskLogs, setFilterCurrentTaskLogs] = useState(false);
|
||||
const [autoRefreshLogs, setAutoRefreshLogs] = useState(false);
|
||||
const [settings, setSettings] = useState({});
|
||||
const [settingsSchema, setSettingsSchema] = useState(null);
|
||||
const [selectedTaskId, setSelectedTaskId] = useState("");
|
||||
const [selectedStepName, setSelectedStepName] = useState("");
|
||||
const [taskDetail, setTaskDetail] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [overviewLoading, setOverviewLoading] = useState(false);
|
||||
const [logLoading, setLogLoading] = useState(false);
|
||||
const [settingsLoading, setSettingsLoading] = useState(false);
|
||||
const [banner, setBanner] = useState(null);
|
||||
|
||||
async function loadOverviewPanels() {
|
||||
const [servicesPayload, schedulerPayload, historyPayload] = await Promise.all([
|
||||
fetchJson("/runtime/services"),
|
||||
fetchJson("/scheduler/preview"),
|
||||
fetchJson("/history?limit=20"),
|
||||
]);
|
||||
setServices(servicesPayload);
|
||||
setScheduler(schedulerPayload);
|
||||
setHistory(historyPayload);
|
||||
}
|
||||
|
||||
async function loadShell() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [healthPayload, doctorPayload, taskPayload] = await Promise.all([
|
||||
fetchJson("/health"),
|
||||
fetchJson("/doctor"),
|
||||
fetchJson("/tasks?limit=100"),
|
||||
]);
|
||||
setHealth(Boolean(healthPayload.ok));
|
||||
setDoctorOk(Boolean(doctorPayload.ok));
|
||||
setTasks(taskPayload.items || []);
|
||||
startTransition(() => {
|
||||
if (!selectedTaskId && taskPayload.items?.length) {
|
||||
setSelectedTaskId(taskPayload.items[0].id);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTaskDetail(taskId) {
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const [task, steps, artifacts, history, timeline] = await Promise.all([
|
||||
fetchJson(`/tasks/${encodeURIComponent(taskId)}`),
|
||||
fetchJson(`/tasks/${encodeURIComponent(taskId)}/steps`),
|
||||
fetchJson(`/tasks/${encodeURIComponent(taskId)}/artifacts`),
|
||||
fetchJson(`/tasks/${encodeURIComponent(taskId)}/history`),
|
||||
fetchJson(`/tasks/${encodeURIComponent(taskId)}/timeline`),
|
||||
]);
|
||||
setTaskDetail({ task, steps, artifacts, history, timeline });
|
||||
if (!selectedStepName) {
|
||||
const suggested = steps.items?.find((step) => ["failed_retryable", "failed_manual", "running"].includes(step.status))?.step_name
|
||||
|| steps.items?.find((step) => step.status !== "succeeded")?.step_name
|
||||
|| "";
|
||||
setSelectedStepName(suggested);
|
||||
}
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadShell().catch((error) => {
|
||||
if (!cancelled) setBanner({ kind: "hot", text: `初始化失败: ${error}` });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedTaskId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view !== "Overview") return;
|
||||
let cancelled = false;
|
||||
async function loadOverviewView() {
|
||||
setOverviewLoading(true);
|
||||
try {
|
||||
const [servicesPayload, schedulerPayload, historyPayload] = await Promise.all([
|
||||
fetchJson("/runtime/services"),
|
||||
fetchJson("/scheduler/preview"),
|
||||
fetchJson("/history?limit=20"),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setServices(servicesPayload);
|
||||
setScheduler(schedulerPayload);
|
||||
setHistory(historyPayload);
|
||||
} finally {
|
||||
if (!cancelled) setOverviewLoading(false);
|
||||
}
|
||||
}
|
||||
loadOverviewView();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [view]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTaskId) return;
|
||||
let cancelled = false;
|
||||
loadTaskDetail(selectedTaskId).catch((error) => {
|
||||
if (!cancelled) setBanner({ kind: "hot", text: `任务详情加载失败: ${error}` });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedTaskId]);
|
||||
|
||||
async function loadCurrentLogContent(logName = selectedLogName) {
|
||||
if (!logName) return;
|
||||
setLogLoading(true);
|
||||
try {
|
||||
const currentTask = tasks.find((item) => item.id === selectedTaskId);
|
||||
let url = `/logs?name=${encodeURIComponent(logName)}&lines=200`;
|
||||
if (filterCurrentTaskLogs && currentTask?.title) {
|
||||
url += `&contains=${encodeURIComponent(currentTask.title)}`;
|
||||
}
|
||||
const payload = await fetchJson(url);
|
||||
setLogContent(payload);
|
||||
} finally {
|
||||
setLogLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (view !== "Logs") return;
|
||||
let cancelled = false;
|
||||
async function loadLogsIndex() {
|
||||
setLogLoading(true);
|
||||
try {
|
||||
const logsPayload = await fetchJson("/logs");
|
||||
if (cancelled) return;
|
||||
setLogs(logsPayload);
|
||||
if (!selectedLogName && logsPayload.items?.length) {
|
||||
startTransition(() => setSelectedLogName(logsPayload.items[0].name));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLogLoading(false);
|
||||
}
|
||||
}
|
||||
loadLogsIndex();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [view]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view !== "Logs" || !selectedLogName) return;
|
||||
let cancelled = false;
|
||||
loadCurrentLogContent(selectedLogName).catch((error) => {
|
||||
if (!cancelled) setBanner({ kind: "hot", text: `日志加载失败: ${error}` });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [view, selectedLogName, filterCurrentTaskLogs, selectedTaskId, tasks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view !== "Logs" || !selectedLogName || !autoRefreshLogs) return;
|
||||
const timer = window.setInterval(() => {
|
||||
loadCurrentLogContent(selectedLogName).catch(() => {});
|
||||
}, 5000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [view, selectedLogName, autoRefreshLogs, filterCurrentTaskLogs, selectedTaskId, tasks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view !== "Settings") return;
|
||||
let cancelled = false;
|
||||
async function loadSettingsView() {
|
||||
setSettingsLoading(true);
|
||||
try {
|
||||
const [settingsPayload, schemaPayload] = await Promise.all([
|
||||
fetchJson("/settings"),
|
||||
fetchJson("/settings/schema"),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setSettings(settingsPayload);
|
||||
setSettingsSchema(schemaPayload);
|
||||
} finally {
|
||||
if (!cancelled) setSettingsLoading(false);
|
||||
}
|
||||
}
|
||||
loadSettingsView();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [view]);
|
||||
|
||||
const currentView = (() => {
|
||||
if (view === "Overview") {
|
||||
return (
|
||||
<OverviewPanel
|
||||
health={health}
|
||||
doctorOk={doctorOk}
|
||||
tasks={{ items: tasks }}
|
||||
services={services}
|
||||
scheduler={scheduler}
|
||||
history={history}
|
||||
loading={overviewLoading}
|
||||
onRefreshScheduler={async () => {
|
||||
const payload = await fetchJson("/scheduler/preview");
|
||||
setScheduler(payload);
|
||||
setBanner({ kind: "good", text: "Scheduler 已刷新" });
|
||||
}}
|
||||
onRefreshHistory={async () => {
|
||||
const payload = await fetchJson("/history?limit=20");
|
||||
setHistory(payload);
|
||||
setBanner({ kind: "good", text: "Recent Actions 已刷新" });
|
||||
}}
|
||||
onStageImport={async (sourcePath) => {
|
||||
const result = await fetchJson("/stage/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ source_path: sourcePath }),
|
||||
});
|
||||
await loadShell();
|
||||
setBanner({ kind: "good", text: `已导入到 stage: ${result.target_path}` });
|
||||
}}
|
||||
onStageUpload={async (file) => {
|
||||
const result = await uploadFile("/stage/upload", file);
|
||||
await loadShell();
|
||||
setBanner({ kind: "good", text: `已上传到 stage: ${result.target_path}` });
|
||||
}}
|
||||
onRunOnce={async () => {
|
||||
await fetchJson("/worker/run-once", { method: "POST" });
|
||||
await loadShell();
|
||||
setBanner({ kind: "good", text: "Worker 已执行一轮" });
|
||||
}}
|
||||
onServiceAction={async (serviceId, action) => {
|
||||
await fetchJson(`/runtime/services/${serviceId}/${action}`, { method: "POST" });
|
||||
await loadShell();
|
||||
if (view === "Overview") {
|
||||
await loadOverviewPanels();
|
||||
}
|
||||
setBanner({ kind: "good", text: `${serviceId} ${action} 完成` });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (view === "Tasks") {
|
||||
return (
|
||||
<TasksView
|
||||
tasks={tasks}
|
||||
selectedTaskId={selectedTaskId}
|
||||
onSelectTask={(taskId) => {
|
||||
startTransition(() => {
|
||||
setSelectedTaskId(taskId);
|
||||
setSelectedStepName("");
|
||||
});
|
||||
}}
|
||||
onRunTask={async (taskId) => {
|
||||
const result = await fetchJson(`/tasks/${encodeURIComponent(taskId)}/actions/run`, { method: "POST" });
|
||||
await loadShell();
|
||||
await loadTaskDetail(taskId);
|
||||
setBanner({ kind: "good", text: `任务已推进: ${taskId} / processed=${result.processed.length}` });
|
||||
}}
|
||||
taskDetail={taskDetail}
|
||||
loading={loading}
|
||||
detailLoading={detailLoading}
|
||||
selectedStepName={selectedStepName}
|
||||
onSelectStep={setSelectedStepName}
|
||||
onRetryStep={async (stepName) => {
|
||||
if (!selectedTaskId || !stepName) return;
|
||||
const result = await fetchJson(`/tasks/${encodeURIComponent(selectedTaskId)}/actions/retry-step`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ step_name: stepName }),
|
||||
});
|
||||
await loadShell();
|
||||
await loadTaskDetail(selectedTaskId);
|
||||
setBanner({ kind: "good", text: `已重试 ${stepName} / processed=${result.processed.length}` });
|
||||
}}
|
||||
onResetStep={async (stepName) => {
|
||||
if (!selectedTaskId || !stepName) return;
|
||||
if (!window.confirm(`确认重置到 step=${stepName} 并清理其后的产物吗?`)) return;
|
||||
const result = await fetchJson(`/tasks/${encodeURIComponent(selectedTaskId)}/actions/reset-to-step`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ step_name: stepName }),
|
||||
});
|
||||
await loadShell();
|
||||
await loadTaskDetail(selectedTaskId);
|
||||
setBanner({ kind: "good", text: `已重置到 ${stepName} / processed=${result.run.processed.length}` });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (view === "Settings") {
|
||||
return (
|
||||
<SettingsPanel
|
||||
settings={settings}
|
||||
schema={settingsSchema}
|
||||
loading={settingsLoading}
|
||||
onSave={async (payload) => {
|
||||
await fetchJson("/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const refreshed = await fetchJson("/settings");
|
||||
setSettings(refreshed);
|
||||
setBanner({ kind: "good", text: "Settings 已保存并刷新" });
|
||||
return refreshed;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<LogsPanel
|
||||
logs={logs.items || []}
|
||||
selectedLogName={selectedLogName}
|
||||
onSelectLog={(name) => startTransition(() => setSelectedLogName(name))}
|
||||
logContent={logContent}
|
||||
loading={logLoading}
|
||||
currentTaskTitle={tasks.find((item) => item.id === selectedTaskId)?.title || ""}
|
||||
filterCurrentTask={filterCurrentTaskLogs}
|
||||
onToggleFilterCurrentTask={setFilterCurrentTaskLogs}
|
||||
autoRefresh={autoRefreshLogs}
|
||||
onToggleAutoRefresh={setAutoRefreshLogs}
|
||||
onRefreshLog={async () => {
|
||||
if (!selectedLogName) return;
|
||||
await loadCurrentLogContent(selectedLogName);
|
||||
setBanner({ kind: "good", text: "日志已刷新" });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="react-shell">
|
||||
<aside className="react-sidebar">
|
||||
<p className="eyebrow">Biliup Next</p>
|
||||
<h1>Frontend</h1>
|
||||
<p className="sidebar-copy">React + Vite 控制台迁移骨架,先接管任务工作台。</p>
|
||||
<nav className="sidebar-nav-react">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
className={view === item ? "nav-btn active" : "nav-btn"}
|
||||
onClick={() => setView(item)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
<main className="react-main">
|
||||
<header className="react-topbar">
|
||||
<div>
|
||||
<p className="eyebrow">Migration Workspace</p>
|
||||
<h2>{view}</h2>
|
||||
</div>
|
||||
<div className="status-row">
|
||||
<span className={`status-badge ${health ? "good" : "hot"}`}>API {health ? "ok" : "down"}</span>
|
||||
<span className={`status-badge ${doctorOk ? "good" : "warn"}`}>Doctor {doctorOk ? "ready" : "warn"}</span>
|
||||
<span className="status-badge">{tasks.length} tasks</span>
|
||||
</div>
|
||||
</header>
|
||||
{banner ? <div className={`status-banner ${banner.kind}`}>{banner.text}</div> : null}
|
||||
{currentView}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
frontend/src/api/client.js
Normal file
29
frontend/src/api/client.js
Normal file
@ -0,0 +1,29 @@
|
||||
export async function fetchJson(url, options = {}) {
|
||||
const token = localStorage.getItem("biliup_next_token") || "";
|
||||
const headers = { ...(options.headers || {}) };
|
||||
if (token) headers["X-Biliup-Token"] = token;
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.message || payload.error || JSON.stringify(payload));
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function uploadFile(url, file) {
|
||||
const token = localStorage.getItem("biliup_next_token") || "";
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const headers = {};
|
||||
if (token) headers["X-Biliup-Token"] = token;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: form,
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.message || payload.error || JSON.stringify(payload));
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
87
frontend/src/components/LogsPanel.jsx
Normal file
87
frontend/src/components/LogsPanel.jsx
Normal file
@ -0,0 +1,87 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
function buildFilteredLines(lines, query) {
|
||||
if (!query) return lines;
|
||||
const needle = query.toLowerCase();
|
||||
return lines.filter((line) => String(line).toLowerCase().includes(needle));
|
||||
}
|
||||
|
||||
export default function LogsPanel({
|
||||
logs,
|
||||
selectedLogName,
|
||||
onSelectLog,
|
||||
logContent,
|
||||
loading,
|
||||
onRefreshLog,
|
||||
currentTaskTitle,
|
||||
filterCurrentTask,
|
||||
onToggleFilterCurrentTask,
|
||||
autoRefresh,
|
||||
onToggleAutoRefresh,
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [lineFilter, setLineFilter] = useState("");
|
||||
|
||||
const visibleLogs = useMemo(() => {
|
||||
if (!search) return logs;
|
||||
const needle = search.toLowerCase();
|
||||
return logs.filter((item) => `${item.name} ${item.path}`.toLowerCase().includes(needle));
|
||||
}, [logs, search]);
|
||||
|
||||
const filteredLines = useMemo(
|
||||
() => buildFilteredLines(logContent?.lines || [], lineFilter),
|
||||
[logContent?.lines, lineFilter],
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="logs-layout-react">
|
||||
<article className="panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<p className="eyebrow">Logs Workspace</p>
|
||||
<h2>Log Index</h2>
|
||||
</div>
|
||||
<div className="panel-meta">{visibleLogs.length} logs</div>
|
||||
</div>
|
||||
<div className="toolbar-grid compact-grid">
|
||||
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索日志文件" />
|
||||
</div>
|
||||
<div className="log-index-list">
|
||||
{visibleLogs.map((item) => (
|
||||
<button
|
||||
key={item.name}
|
||||
className={selectedLogName === item.name ? "log-index-item active" : "log-index-item"}
|
||||
onClick={() => onSelectLog(item.name)}
|
||||
>
|
||||
<strong>{item.name}</strong>
|
||||
<span>{item.path}</span>
|
||||
</button>
|
||||
))}
|
||||
{!visibleLogs.length ? <p className="muted">{loading ? "loading..." : "暂无日志文件"}</p> : null}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="panel detail-panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<p className="eyebrow">Log Detail</p>
|
||||
<h2>{selectedLogName || "选择一个日志"}</h2>
|
||||
</div>
|
||||
<button className="nav-btn" onClick={onRefreshLog}>刷新</button>
|
||||
</div>
|
||||
<div className="toolbar-grid compact-grid">
|
||||
<input value={lineFilter} onChange={(event) => setLineFilter(event.target.value)} placeholder="过滤日志行内容" />
|
||||
<label className="toggle-row">
|
||||
<input type="checkbox" checked={filterCurrentTask} onChange={(event) => onToggleFilterCurrentTask?.(event.target.checked)} />
|
||||
<span>按当前任务过滤{currentTaskTitle ? ` · ${currentTaskTitle}` : ""}</span>
|
||||
</label>
|
||||
<label className="toggle-row">
|
||||
<input type="checkbox" checked={autoRefresh} onChange={(event) => onToggleAutoRefresh?.(event.target.checked)} />
|
||||
<span>自动刷新</span>
|
||||
</label>
|
||||
</div>
|
||||
<pre className="log-pre">{filteredLines.join("\n") || (loading ? "loading..." : "暂无日志内容")}</pre>
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
166
frontend/src/components/OverviewPanel.jsx
Normal file
166
frontend/src/components/OverviewPanel.jsx
Normal file
@ -0,0 +1,166 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import StatusBadge from "./StatusBadge.jsx";
|
||||
import { attentionLabel, summarizeAttention } from "../lib/format.js";
|
||||
|
||||
function SummaryCard({ label, value, tone = "" }) {
|
||||
return (
|
||||
<article className="summary-card">
|
||||
<span className="eyebrow">{label}</span>
|
||||
<strong>{value}</strong>
|
||||
{tone ? <StatusBadge tone={tone}>{tone}</StatusBadge> : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OverviewPanel({
|
||||
health,
|
||||
doctorOk,
|
||||
tasks,
|
||||
services,
|
||||
scheduler,
|
||||
history,
|
||||
loading,
|
||||
onRefreshScheduler,
|
||||
onRefreshHistory,
|
||||
onRunOnce,
|
||||
onServiceAction,
|
||||
onStageImport,
|
||||
onStageUpload,
|
||||
}) {
|
||||
const [stageSourcePath, setStageSourcePath] = useState("");
|
||||
const [stageFile, setStageFile] = useState(null);
|
||||
const taskItems = tasks?.items || [];
|
||||
const serviceItems = services?.items || [];
|
||||
const actionItems = history?.items || [];
|
||||
const scheduled = scheduler?.scheduled || [];
|
||||
const deferred = scheduler?.deferred || [];
|
||||
const attentionCounts = taskItems.reduce(
|
||||
(acc, task) => {
|
||||
const key = summarizeAttention(task);
|
||||
acc[key] = (acc[key] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="overview-stack-react">
|
||||
<div className="overview-grid">
|
||||
<SummaryCard label="Health" value={health ? "ok" : "down"} tone={health ? "good" : "hot"} />
|
||||
<SummaryCard label="Doctor" value={doctorOk ? "ready" : "warn"} tone={doctorOk ? "good" : "warn"} />
|
||||
<SummaryCard label="Tasks" value={String(taskItems.length)} />
|
||||
</div>
|
||||
|
||||
<div className="detail-grid">
|
||||
<article className="detail-card">
|
||||
<div className="card-head-inline">
|
||||
<h3>Import To Stage</h3>
|
||||
</div>
|
||||
<div className="stage-input-grid">
|
||||
<input
|
||||
value={stageSourcePath}
|
||||
onChange={(event) => setStageSourcePath(event.target.value)}
|
||||
placeholder="/absolute/path/to/video.mp4"
|
||||
/>
|
||||
<button
|
||||
className="nav-btn compact-btn"
|
||||
onClick={async () => {
|
||||
if (!stageSourcePath.trim()) return;
|
||||
await onStageImport?.(stageSourcePath.trim());
|
||||
setStageSourcePath("");
|
||||
}}
|
||||
>
|
||||
复制到隔离 Stage
|
||||
</button>
|
||||
</div>
|
||||
<div className="stage-input-grid upload-grid-react">
|
||||
<input
|
||||
type="file"
|
||||
onChange={(event) => setStageFile(event.target.files?.[0] || null)}
|
||||
/>
|
||||
<button
|
||||
className="nav-btn compact-btn strong-btn"
|
||||
onClick={async () => {
|
||||
if (!stageFile) return;
|
||||
await onStageUpload?.(stageFile);
|
||||
setStageFile(null);
|
||||
}}
|
||||
>
|
||||
上传到隔离 Stage
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted">只会导入到 `biliup-next/data/workspace/stage/`,不会移动原文件。</p>
|
||||
</article>
|
||||
|
||||
<article className="detail-card">
|
||||
<div className="card-head-inline">
|
||||
<h3>Runtime Services</h3>
|
||||
<button className="nav-btn compact-btn strong-btn" onClick={onRunOnce}>执行一轮 Worker</button>
|
||||
</div>
|
||||
<div className="list-stack">
|
||||
{serviceItems.map((service) => (
|
||||
<div className="list-row" key={service.id}>
|
||||
<div>
|
||||
<strong>{service.id}</strong>
|
||||
<div className="muted">{service.description}</div>
|
||||
</div>
|
||||
<div className="service-actions">
|
||||
<StatusBadge tone={service.active_state === "active" ? "good" : "hot"}>{service.active_state}</StatusBadge>
|
||||
<button className="nav-btn compact-btn" onClick={() => onServiceAction?.(service.id, "start")}>start</button>
|
||||
<button className="nav-btn compact-btn" onClick={() => onServiceAction?.(service.id, "restart")}>restart</button>
|
||||
<button className="nav-btn compact-btn" onClick={() => onServiceAction?.(service.id, "stop")}>stop</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!serviceItems.length ? <p className="muted">{loading ? "loading..." : "暂无服务数据"}</p> : null}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="detail-card">
|
||||
<div className="card-head-inline">
|
||||
<h3>Scheduler Queue</h3>
|
||||
<button className="nav-btn compact-btn" onClick={onRefreshScheduler}>刷新</button>
|
||||
</div>
|
||||
<div className="list-stack">
|
||||
<div className="list-row"><span>scheduled</span><strong>{scheduled.length}</strong></div>
|
||||
<div className="list-row"><span>deferred</span><strong>{deferred.length}</strong></div>
|
||||
<div className="list-row"><span>scanned</span><strong>{scheduler?.summary?.scanned_count ?? 0}</strong></div>
|
||||
<div className="list-row"><span>truncated</span><strong>{scheduler?.summary?.truncated_count ?? 0}</strong></div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div className="detail-grid">
|
||||
<article className="detail-card">
|
||||
<h3>Attention Summary</h3>
|
||||
<div className="list-stack">
|
||||
{Object.entries(attentionCounts).map(([key, count]) => (
|
||||
<div className="list-row" key={key}>
|
||||
<span>{attentionLabel(key)}</span>
|
||||
<strong>{count}</strong>
|
||||
</div>
|
||||
))}
|
||||
{!Object.keys(attentionCounts).length ? <p className="muted">暂无任务摘要</p> : null}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="detail-card">
|
||||
<div className="card-head-inline">
|
||||
<h3>Recent Actions</h3>
|
||||
<button className="nav-btn compact-btn" onClick={onRefreshHistory}>刷新</button>
|
||||
</div>
|
||||
<div className="list-stack">
|
||||
{actionItems.slice(0, 8).map((item) => (
|
||||
<div className="list-row" key={`${item.created_at}:${item.action_name}`}>
|
||||
<span>{item.action_name}</span>
|
||||
<StatusBadge tone={item.status === "error" ? "hot" : item.status === "warn" ? "warn" : "good"}>{item.status}</StatusBadge>
|
||||
</div>
|
||||
))}
|
||||
{!actionItems.length ? <p className="muted">{loading ? "loading..." : "暂无动作记录"}</p> : null}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
310
frontend/src/components/SettingsPanel.jsx
Normal file
310
frontend/src/components/SettingsPanel.jsx
Normal file
@ -0,0 +1,310 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
const SECRET_PLACEHOLDER = "__BILIUP_NEXT_SECRET__";
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function fieldKey(groupName, fieldName) {
|
||||
return `${groupName}.${fieldName}`;
|
||||
}
|
||||
|
||||
function compareEntries(a, b) {
|
||||
const orderA = Number(a[1].ui_order || 9999);
|
||||
const orderB = Number(b[1].ui_order || 9999);
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return String(a[0]).localeCompare(String(b[0]), "zh-CN");
|
||||
}
|
||||
|
||||
function stableStringify(value) {
|
||||
return JSON.stringify(value ?? {}, null, 2);
|
||||
}
|
||||
|
||||
function FieldInput({ groupName, fieldName, schema, value, onChange }) {
|
||||
if (schema.type === "boolean") {
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(value)}
|
||||
onChange={(event) => onChange(groupName, fieldName, event.target.checked)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (Array.isArray(schema.enum)) {
|
||||
return (
|
||||
<select value={String(value ?? "")} onChange={(event) => onChange(groupName, fieldName, event.target.value)}>
|
||||
{schema.enum.map((optionValue) => (
|
||||
<option key={String(optionValue)} value={String(optionValue)}>
|
||||
{String(optionValue)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
if (schema.type === "array") {
|
||||
return (
|
||||
<textarea
|
||||
value={JSON.stringify(value ?? [], null, 2)}
|
||||
onChange={(event) => onChange(groupName, fieldName, event.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<input
|
||||
type={schema.sensitive ? "password" : schema.type === "integer" ? "number" : "text"}
|
||||
value={value ?? ""}
|
||||
min={schema.type === "integer" && typeof schema.minimum === "number" ? schema.minimum : undefined}
|
||||
step={schema.type === "integer" ? 1 : undefined}
|
||||
placeholder={schema.ui_placeholder || ""}
|
||||
onChange={(event) => onChange(groupName, fieldName, event.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeFieldValue(schema, rawValue) {
|
||||
if (schema.type === "boolean") return Boolean(rawValue);
|
||||
if (schema.type === "integer") {
|
||||
if (rawValue === "" || Number.isNaN(Number(rawValue))) return { error: "必须填写整数" };
|
||||
const value = Number(rawValue);
|
||||
if (typeof schema.minimum === "number" && value < schema.minimum) {
|
||||
return { error: `最小值为 ${schema.minimum}` };
|
||||
}
|
||||
return { value };
|
||||
}
|
||||
if (schema.type === "array") {
|
||||
try {
|
||||
const value = typeof rawValue === "string" ? JSON.parse(rawValue || "[]") : rawValue;
|
||||
if (!Array.isArray(value)) return { error: "必须是 JSON 数组" };
|
||||
return { value };
|
||||
} catch {
|
||||
return { error: "必须是 JSON 数组" };
|
||||
}
|
||||
}
|
||||
return { value: rawValue };
|
||||
}
|
||||
|
||||
export default function SettingsPanel({ settings, schema, onSave, loading }) {
|
||||
const [draft, setDraft] = useState({});
|
||||
const [rawDraft, setRawDraft] = useState("{}");
|
||||
const [search, setSearch] = useState("");
|
||||
const [errors, setErrors] = useState({});
|
||||
const [dirty, setDirty] = useState({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveMessage, setSaveMessage] = useState("");
|
||||
const [jsonError, setJsonError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const nextDraft = clone(settings || {});
|
||||
setDraft(nextDraft);
|
||||
setRawDraft(stableStringify(nextDraft));
|
||||
setErrors({});
|
||||
setDirty({});
|
||||
setJsonError("");
|
||||
setSaveMessage("");
|
||||
}, [settings]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const entries = Object.entries(schema?.groups || {}).sort((a, b) => {
|
||||
const orderA = Number(schema?.group_ui?.[a[0]]?.order || 9999);
|
||||
const orderB = Number(schema?.group_ui?.[b[0]]?.order || 9999);
|
||||
return orderA - orderB;
|
||||
});
|
||||
const needle = search.trim().toLowerCase();
|
||||
return entries
|
||||
.map(([groupName, fields]) => {
|
||||
const featured = [];
|
||||
const advanced = [];
|
||||
Object.entries(fields)
|
||||
.sort(compareEntries)
|
||||
.forEach(([fieldName, fieldSchema]) => {
|
||||
const haystack = `${groupName}.${fieldName} ${fieldSchema.title || ""} ${fieldSchema.description || ""}`.toLowerCase();
|
||||
if (needle && !haystack.includes(needle)) return;
|
||||
(fieldSchema.ui_featured ? featured : advanced).push([fieldName, fieldSchema]);
|
||||
});
|
||||
return [groupName, featured, advanced];
|
||||
})
|
||||
.filter(([, featured, advanced]) => featured.length || advanced.length);
|
||||
}, [schema, search]);
|
||||
|
||||
function handleFieldChange(groupName, fieldName, rawValue) {
|
||||
const fieldSchema = schema.groups[groupName][fieldName];
|
||||
const result = normalizeFieldValue(fieldSchema, rawValue);
|
||||
setDraft((current) => {
|
||||
const next = clone(current);
|
||||
next[groupName] ??= {};
|
||||
next[groupName][fieldName] = result.value ?? rawValue;
|
||||
setRawDraft(stableStringify(next));
|
||||
return next;
|
||||
});
|
||||
const key = fieldKey(groupName, fieldName);
|
||||
setDirty((current) => ({ ...current, [key]: true }));
|
||||
setSaveMessage("");
|
||||
setErrors((current) => {
|
||||
const next = { ...current };
|
||||
if (result.error) next[key] = result.error;
|
||||
else delete next[key];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleRevertField(groupName, fieldName) {
|
||||
const key = fieldKey(groupName, fieldName);
|
||||
const originalValue = settings?.[groupName]?.[fieldName];
|
||||
const fieldSchema = schema.groups[groupName][fieldName];
|
||||
const rawValue = fieldSchema.type === "array" ? clone(originalValue ?? []) : originalValue ?? "";
|
||||
setDraft((current) => {
|
||||
const next = clone(current);
|
||||
next[groupName] ??= {};
|
||||
next[groupName][fieldName] = rawValue;
|
||||
setRawDraft(stableStringify(next));
|
||||
return next;
|
||||
});
|
||||
setDirty((current) => {
|
||||
const next = { ...current };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
setErrors((current) => {
|
||||
const next = { ...current };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
setSaveMessage("");
|
||||
}
|
||||
|
||||
function syncJsonToForm() {
|
||||
try {
|
||||
const parsed = JSON.parse(rawDraft || "{}");
|
||||
setDraft(parsed);
|
||||
setJsonError("");
|
||||
setErrors({});
|
||||
setDirty({});
|
||||
setSaveMessage("JSON 已同步到表单");
|
||||
} catch {
|
||||
setJsonError("JSON 格式无效,无法同步到表单");
|
||||
}
|
||||
}
|
||||
|
||||
function syncFormToJson() {
|
||||
setRawDraft(stableStringify(draft));
|
||||
setJsonError("");
|
||||
setSaveMessage("表单已同步到 JSON");
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (Object.keys(errors).length || jsonError) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const saved = await onSave(draft);
|
||||
const nextDraft = clone(saved || draft);
|
||||
setDraft(nextDraft);
|
||||
setRawDraft(stableStringify(nextDraft));
|
||||
setDirty({});
|
||||
setErrors({});
|
||||
setSaveMessage("Settings 已保存");
|
||||
setJsonError("");
|
||||
} catch (error) {
|
||||
setSaveMessage(`保存失败: ${error}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="settings-layout-react">
|
||||
<article className="panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<p className="eyebrow">Settings Workspace</p>
|
||||
<h2>Schema Form</h2>
|
||||
</div>
|
||||
<button className="nav-btn" onClick={handleSave} disabled={saving || Boolean(Object.keys(errors).length)}>
|
||||
{saving ? "保存中..." : "保存 Settings"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="toolbar-grid compact-grid">
|
||||
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索配置项" />
|
||||
</div>
|
||||
<div className="settings-note-stack">
|
||||
<p className="muted">
|
||||
敏感字段显示为 <code>{SECRET_PLACEHOLDER}</code>。保留占位符表示不改原值,改成空字符串表示清空。
|
||||
</p>
|
||||
{saveMessage ? <div className="status-inline-note">{saveMessage}</div> : null}
|
||||
{jsonError ? <div className="status-inline-note error">{jsonError}</div> : null}
|
||||
</div>
|
||||
<div className="settings-react-groups">
|
||||
{groups.map(([groupName, featured, advanced]) => (
|
||||
<section className="detail-card" key={groupName}>
|
||||
<div className="settings-group-head">
|
||||
<div>
|
||||
<p className="eyebrow">{groupName}</p>
|
||||
<h3>{schema.group_ui?.[groupName]?.title || groupName}</h3>
|
||||
</div>
|
||||
{schema.group_ui?.[groupName]?.description ? (
|
||||
<p className="muted">{schema.group_ui[groupName].description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="settings-field-grid">
|
||||
{[...featured, ...advanced].map(([fieldName, fieldSchema]) => {
|
||||
const key = fieldKey(groupName, fieldName);
|
||||
return (
|
||||
<label className={`settings-field-card ${dirty[key] ? "dirty" : ""} ${errors[key] ? "error" : ""}`} key={key}>
|
||||
<div className="settings-field-head">
|
||||
<strong>{fieldSchema.title || key}</strong>
|
||||
<div className="status-row">
|
||||
{fieldSchema.ui_widget ? <span className="status-badge">{fieldSchema.ui_widget}</span> : null}
|
||||
{fieldSchema.ui_featured ? <span className="status-badge warn">featured</span> : null}
|
||||
{dirty[key] ? (
|
||||
<button
|
||||
type="button"
|
||||
className="nav-btn compact-btn"
|
||||
onClick={() => handleRevertField(groupName, fieldName)}
|
||||
>
|
||||
撤销
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{fieldSchema.description ? <span className="muted">{fieldSchema.description}</span> : null}
|
||||
<FieldInput
|
||||
groupName={groupName}
|
||||
fieldName={fieldName}
|
||||
schema={fieldSchema}
|
||||
value={draft[groupName]?.[fieldName]}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
{errors[key] ? <span className="field-error-react">{errors[key]}</span> : null}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{!groups.length ? <article className="detail-card"><p className="muted">{loading ? "loading..." : "没有匹配配置项"}</p></article> : null}
|
||||
</div>
|
||||
<section className="detail-card">
|
||||
<div className="card-head-inline">
|
||||
<div>
|
||||
<p className="eyebrow">Advanced</p>
|
||||
<h3>Raw JSON</h3>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
<button type="button" className="nav-btn compact-btn" onClick={syncFormToJson}>表单同步到 JSON</button>
|
||||
<button type="button" className="nav-btn compact-btn" onClick={syncJsonToForm}>JSON 重绘表单</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
className="settings-json-editor"
|
||||
value={rawDraft}
|
||||
onChange={(event) => {
|
||||
setRawDraft(event.target.value);
|
||||
setJsonError("");
|
||||
setSaveMessage("");
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
6
frontend/src/components/StatusBadge.jsx
Normal file
6
frontend/src/components/StatusBadge.jsx
Normal file
@ -0,0 +1,6 @@
|
||||
import { statusClass } from "../lib/format.js";
|
||||
|
||||
export default function StatusBadge({ children, tone }) {
|
||||
const klass = tone || statusClass(String(children));
|
||||
return <span className={`status-badge ${klass}`.trim()}>{children}</span>;
|
||||
}
|
||||
142
frontend/src/components/TaskDetailCard.jsx
Normal file
142
frontend/src/components/TaskDetailCard.jsx
Normal file
@ -0,0 +1,142 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import StatusBadge from "./StatusBadge.jsx";
|
||||
import { attentionLabel, deliveryLabel, formatDate, summarizeAttention, summarizeDelivery } from "../lib/format.js";
|
||||
|
||||
function SummaryRow({ label, value }) {
|
||||
return (
|
||||
<div className="detail-row">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function suggestedStepName(steps) {
|
||||
const items = steps?.items || [];
|
||||
const retryable = items.find((step) => ["failed_retryable", "failed_manual", "running"].includes(step.status));
|
||||
return retryable?.step_name || items.find((step) => step.status !== "succeeded")?.step_name || "";
|
||||
}
|
||||
|
||||
export default function TaskDetailCard({
|
||||
payload,
|
||||
loading,
|
||||
selectedStepName,
|
||||
onSelectStep,
|
||||
onRetryStep,
|
||||
onResetStep,
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<article className="panel detail-panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<p className="eyebrow">Task Detail</p>
|
||||
<h2>Loading...</h2>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
if (!payload?.task) {
|
||||
return (
|
||||
<article className="panel detail-panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<p className="eyebrow">Task Detail</p>
|
||||
<h2>选择一个任务</h2>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
const { task, steps, artifacts, history } = payload;
|
||||
const delivery = task.delivery_state || {};
|
||||
const latestAction = history?.items?.[0];
|
||||
const activeStepName = useMemo(
|
||||
() => selectedStepName || suggestedStepName(steps),
|
||||
[selectedStepName, steps],
|
||||
);
|
||||
|
||||
return (
|
||||
<article className="panel detail-panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<p className="eyebrow">Task Detail</p>
|
||||
<h2>{task.title}</h2>
|
||||
</div>
|
||||
<div className="status-row">
|
||||
<StatusBadge>{task.status}</StatusBadge>
|
||||
<StatusBadge>{attentionLabel(summarizeAttention(task))}</StatusBadge>
|
||||
<button className="nav-btn compact-btn" onClick={() => onRetryStep?.(activeStepName)} disabled={!activeStepName}>
|
||||
Retry Step
|
||||
</button>
|
||||
<button className="nav-btn compact-btn strong-btn" onClick={() => onResetStep?.(activeStepName)} disabled={!activeStepName}>
|
||||
Reset To Step
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-grid">
|
||||
<section className="detail-card">
|
||||
<h3>Current State</h3>
|
||||
<SummaryRow label="Task ID" value={task.id} />
|
||||
<SummaryRow label="Updated" value={formatDate(task.updated_at)} />
|
||||
<SummaryRow label="Next Retry" value={formatDate(task.retry_state?.next_retry_at)} />
|
||||
<SummaryRow label="Split Comment" value={deliveryLabel(delivery.split_comment || "pending")} />
|
||||
<SummaryRow label="Full Timeline" value={deliveryLabel(delivery.full_video_timeline_comment || "pending")} />
|
||||
<SummaryRow label="Cleanup" value={deliveryLabel(summarizeDelivery(delivery) === "cleanup_removed" ? "cleanup_removed" : "present")} />
|
||||
</section>
|
||||
<section className="detail-card">
|
||||
<h3>Latest Action</h3>
|
||||
{latestAction ? (
|
||||
<>
|
||||
<SummaryRow label="Action" value={latestAction.action_name} />
|
||||
<SummaryRow label="Status" value={latestAction.status} />
|
||||
<SummaryRow label="Summary" value={latestAction.summary || "-"} />
|
||||
</>
|
||||
) : (
|
||||
<p className="muted">暂无动作记录</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="detail-grid">
|
||||
<section className="detail-card">
|
||||
<h3>Steps</h3>
|
||||
<div className="list-stack">
|
||||
{steps?.items?.map((step) => (
|
||||
<button
|
||||
type="button"
|
||||
key={step.step_name}
|
||||
className={activeStepName === step.step_name ? "list-row selectable active" : "list-row selectable"}
|
||||
onClick={() => onSelectStep?.(step.step_name)}
|
||||
>
|
||||
<span>{step.step_name}</span>
|
||||
<StatusBadge>{step.status}</StatusBadge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{activeStepName ? (
|
||||
<div className="selected-step-note">
|
||||
当前选中 step: <strong>{activeStepName}</strong>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
<section className="detail-card">
|
||||
<h3>Artifacts</h3>
|
||||
<div className="list-stack">
|
||||
{artifacts?.items?.slice(0, 8).map((artifact) => (
|
||||
<div key={`${artifact.artifact_type}:${artifact.path}`} className="list-row">
|
||||
<span>{artifact.artifact_type}</span>
|
||||
<span className="muted">{artifact.path}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
84
frontend/src/components/TaskTable.jsx
Normal file
84
frontend/src/components/TaskTable.jsx
Normal file
@ -0,0 +1,84 @@
|
||||
import StatusBadge from "./StatusBadge.jsx";
|
||||
import { attentionLabel, deliveryLabel, formatDate, summarizeAttention, summarizeDelivery } from "../lib/format.js";
|
||||
|
||||
function deliveryStateLabel(task) {
|
||||
const delivery = task.delivery_state || {};
|
||||
return {
|
||||
splitComment: deliveryLabel(delivery.split_comment || "pending"),
|
||||
fullComment: deliveryLabel(delivery.full_video_timeline_comment || "pending"),
|
||||
cleanup: deliveryLabel(summarizeDelivery(delivery) === "cleanup_removed" ? "cleanup_removed" : "present"),
|
||||
};
|
||||
}
|
||||
|
||||
export default function TaskTable({ tasks, selectedTaskId, onSelectTask, onRunTask }) {
|
||||
return (
|
||||
<div className="table-wrap-react">
|
||||
<table className="task-table-react">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>任务</th>
|
||||
<th>状态</th>
|
||||
<th>关注</th>
|
||||
<th>纯享评论</th>
|
||||
<th>主视频评论</th>
|
||||
<th>清理</th>
|
||||
<th>下次重试</th>
|
||||
<th>更新时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map((task) => {
|
||||
const delivery = deliveryStateLabel(task);
|
||||
return (
|
||||
<tr
|
||||
key={task.id}
|
||||
className={selectedTaskId === task.id ? "active" : ""}
|
||||
onClick={() => onSelectTask(task.id)}
|
||||
>
|
||||
<td>
|
||||
<div className="task-title">{task.title}</div>
|
||||
<div className="task-subtitle">{task.id}</div>
|
||||
</td>
|
||||
<td><StatusBadge>{task.status}</StatusBadge></td>
|
||||
<td><StatusBadge>{attentionLabel(summarizeAttention(task))}</StatusBadge></td>
|
||||
<td><StatusBadge>{delivery.splitComment}</StatusBadge></td>
|
||||
<td><StatusBadge>{delivery.fullComment}</StatusBadge></td>
|
||||
<td><StatusBadge>{delivery.cleanup}</StatusBadge></td>
|
||||
<td>
|
||||
<div>{formatDate(task.retry_state?.next_retry_at)}</div>
|
||||
{task.retry_state?.retry_remaining_seconds != null ? (
|
||||
<div className="task-subtitle">{task.retry_state.retry_remaining_seconds}s</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td>{formatDate(task.updated_at)}</td>
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
<button
|
||||
className="nav-btn compact-btn"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelectTask(task.id);
|
||||
}}
|
||||
>
|
||||
打开
|
||||
</button>
|
||||
<button
|
||||
className="nav-btn compact-btn strong-btn"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRunTask?.(task.id);
|
||||
}}
|
||||
>
|
||||
执行
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
frontend/src/lib/format.js
Normal file
51
frontend/src/lib/format.js
Normal file
@ -0,0 +1,51 @@
|
||||
export function statusClass(status) {
|
||||
if (["collection_synced", "published", "done", "resolved", "present"].includes(status)) return "good";
|
||||
if (["failed_manual"].includes(status)) return "hot";
|
||||
if (["failed_retryable", "pending", "legacy_untracked", "running", "retry_now", "waiting_retry", "manual_now"].includes(status)) return "warn";
|
||||
return "";
|
||||
}
|
||||
|
||||
export function formatDate(value) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
export function summarizeAttention(task) {
|
||||
if (task.status === "failed_manual") return "manual_now";
|
||||
if (task.retry_state?.retry_due) return "retry_now";
|
||||
if (task.status === "failed_retryable" && task.retry_state?.next_retry_at) return "waiting_retry";
|
||||
if (task.status === "running") return "running";
|
||||
return "stable";
|
||||
}
|
||||
|
||||
export function attentionLabel(value) {
|
||||
return {
|
||||
manual_now: "需人工",
|
||||
retry_now: "立即重试",
|
||||
waiting_retry: "等待重试",
|
||||
running: "处理中",
|
||||
stable: "正常",
|
||||
}[value] || value;
|
||||
}
|
||||
|
||||
export function summarizeDelivery(delivery = {}) {
|
||||
if (delivery.full_video_timeline_comment === "legacy_untracked") return "legacy_untracked";
|
||||
if (delivery.split_comment === "pending" || delivery.full_video_timeline_comment === "pending") return "pending_comment";
|
||||
if (delivery.source_video_present === false || delivery.split_videos_present === false) return "cleanup_removed";
|
||||
return "stable";
|
||||
}
|
||||
|
||||
export function deliveryLabel(value) {
|
||||
return {
|
||||
done: "已发送",
|
||||
pending: "待处理",
|
||||
legacy_untracked: "历史未追踪",
|
||||
present: "保留",
|
||||
removed: "已清理",
|
||||
cleanup_removed: "已清理视频",
|
||||
pending_comment: "评论待完成",
|
||||
stable: "正常",
|
||||
}[value] || value;
|
||||
}
|
||||
11
frontend/src/main.jsx
Normal file
11
frontend/src/main.jsx
Normal file
@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
import App from "./App.jsx";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
553
frontend/src/styles.css
Normal file
553
frontend/src/styles.css
Normal file
@ -0,0 +1,553 @@
|
||||
:root {
|
||||
--bg: #f4efe7;
|
||||
--paper: rgba(255, 252, 247, 0.94);
|
||||
--paper-strong: rgba(255, 255, 255, 0.98);
|
||||
--ink: #1d1a16;
|
||||
--muted: #6b6159;
|
||||
--line: rgba(29, 26, 22, 0.12);
|
||||
--accent: #b24b1a;
|
||||
--accent-2: #0e6c62;
|
||||
--warn: #9a690f;
|
||||
--good-bg: rgba(14, 108, 98, 0.12);
|
||||
--warn-bg: rgba(154, 105, 15, 0.12);
|
||||
--hot-bg: rgba(178, 75, 26, 0.12);
|
||||
--shadow: 0 24px 70px rgba(57, 37, 16, 0.08);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-family: "IBM Plex Sans", "Noto Sans SC", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(178, 75, 26, 0.14), transparent 30%),
|
||||
radial-gradient(circle at top right, rgba(14, 108, 98, 0.14), transparent 28%),
|
||||
linear-gradient(180deg, #f7f2ea 0%, #efe7dc 100%);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.react-shell {
|
||||
width: min(1680px, calc(100vw - 28px));
|
||||
margin: 18px auto 32px;
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.react-sidebar,
|
||||
.panel,
|
||||
.react-topbar {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 26px;
|
||||
background: var(--paper);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.react-sidebar {
|
||||
padding: 22px;
|
||||
position: sticky;
|
||||
top: 18px;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.react-main {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.status-banner {
|
||||
border-radius: 18px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,0.86);
|
||||
}
|
||||
|
||||
.status-banner.good {
|
||||
background: var(--good-bg);
|
||||
color: var(--accent-2);
|
||||
}
|
||||
|
||||
.status-banner.warn {
|
||||
background: var(--warn-bg);
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.status-banner.hot {
|
||||
background: var(--hot-bg);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.react-topbar {
|
||||
padding: 18px 22px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.react-topbar h2,
|
||||
.panel h2,
|
||||
.panel h3,
|
||||
.react-sidebar h1 {
|
||||
margin: 0;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.react-sidebar h1 {
|
||||
font-size: 42px;
|
||||
line-height: 0.92;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 8px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.sidebar-copy,
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.sidebar-nav-react {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 11px 14px;
|
||||
background: rgba(255,255,255,0.84);
|
||||
color: var(--ink);
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-btn.active {
|
||||
background: linear-gradient(135deg, rgba(178, 75, 26, 0.12), rgba(255,255,255,0.95));
|
||||
border-color: rgba(178, 75, 26, 0.28);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.panel-meta {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(29, 26, 22, 0.07);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-badge.good { background: var(--good-bg); color: var(--accent-2); }
|
||||
.status-badge.warn { background: var(--warn-bg); color: var(--warn); }
|
||||
.status-badge.hot { background: var(--hot-bg); color: var(--accent); }
|
||||
|
||||
.compact-btn {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.strong-btn {
|
||||
background: var(--ink);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.overview-grid,
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.summary-card,
|
||||
.detail-card,
|
||||
.placeholder-view {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: rgba(255,255,255,0.78);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.summary-card strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.tasks-layout-react {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(360px, 0.8fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.overview-stack-react {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.logs-layout-react {
|
||||
display: grid;
|
||||
grid-template-columns: 340px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-layout-react {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.toolbar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.toolbar-grid.compact-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.toolbar-grid input,
|
||||
.toolbar-grid select {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 11px 12px;
|
||||
background: rgba(255,255,255,0.92);
|
||||
}
|
||||
|
||||
.table-wrap-react {
|
||||
max-height: calc(100vh - 280px);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background: rgba(255,255,255,0.84);
|
||||
}
|
||||
|
||||
.task-table-react {
|
||||
width: 100%;
|
||||
min-width: 980px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.task-table-react th,
|
||||
.task-table-react td {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.task-table-react th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: rgba(243, 239, 232, 0.96);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.task-table-react tbody tr {
|
||||
cursor: pointer;
|
||||
transition: background 140ms ease;
|
||||
}
|
||||
|
||||
.task-table-react tbody tr:hover {
|
||||
background: rgba(178, 75, 26, 0.06);
|
||||
}
|
||||
|
||||
.task-table-react tbody tr.active {
|
||||
background: linear-gradient(135deg, rgba(255, 248, 240, 0.98), rgba(249, 242, 234, 0.95));
|
||||
}
|
||||
|
||||
.task-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-subtitle {
|
||||
margin-top: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.detail-panel .detail-row,
|
||||
.list-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.list-row:last-child,
|
||||
.detail-panel .detail-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.list-stack {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.list-row.selectable {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 14px;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.list-row.selectable.active {
|
||||
border-color: rgba(178, 75, 26, 0.28);
|
||||
background: rgba(255, 248, 240, 0.92);
|
||||
}
|
||||
|
||||
.selected-step-note {
|
||||
margin-top: 12px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.row-actions,
|
||||
.service-actions,
|
||||
.card-head-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-head-inline {
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stage-input-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stage-input-grid input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 11px 12px;
|
||||
background: rgba(255,255,255,0.96);
|
||||
}
|
||||
|
||||
.upload-grid-react input[type="file"] {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.settings-react-groups {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-note-stack {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.status-inline-note {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255,255,255,0.84);
|
||||
color: var(--accent-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-inline-note.error {
|
||||
background: rgba(255, 243, 239, 0.92);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.settings-group-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.settings-field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.settings-field-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: rgba(255,255,255,0.74);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.settings-field-card.dirty {
|
||||
border-color: rgba(178, 75, 26, 0.28);
|
||||
}
|
||||
|
||||
.settings-field-card.error {
|
||||
border-color: rgba(178, 75, 26, 0.44);
|
||||
background: rgba(255, 243, 239, 0.9);
|
||||
}
|
||||
|
||||
.settings-field-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.settings-field-card input[type="text"],
|
||||
.settings-field-card input[type="password"],
|
||||
.settings-field-card input[type="number"],
|
||||
.settings-field-card select,
|
||||
.settings-field-card textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 11px 12px;
|
||||
background: rgba(255,255,255,0.96);
|
||||
}
|
||||
|
||||
.settings-field-card textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
font-family: "IBM Plex Mono", "Noto Sans Mono CJK SC", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings-json-editor {
|
||||
width: 100%;
|
||||
min-height: 320px;
|
||||
resize: vertical;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
padding: 14px;
|
||||
background: rgba(255,255,255,0.96);
|
||||
font-family: "IBM Plex Mono", "Noto Sans Mono CJK SC", monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.field-error-react {
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.log-index-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.log-index-item {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(255,255,255,0.78);
|
||||
color: var(--ink);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.log-index-item.active {
|
||||
border-color: rgba(178, 75, 26, 0.28);
|
||||
background: linear-gradient(135deg, rgba(255, 248, 240, 0.98), rgba(249, 242, 234, 0.95));
|
||||
}
|
||||
|
||||
.log-index-item span {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.log-pre {
|
||||
margin: 0;
|
||||
min-height: 520px;
|
||||
max-height: calc(100vh - 260px);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
background: rgba(32, 28, 23, 0.96);
|
||||
color: #f6eee2;
|
||||
font-family: "IBM Plex Mono", "Noto Sans Mono CJK SC", monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.react-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tasks-layout-react,
|
||||
.logs-layout-react,
|
||||
.detail-grid,
|
||||
.overview-grid,
|
||||
.settings-field-grid,
|
||||
.stage-input-grid,
|
||||
.toolbar-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
34
frontend/vite.config.mjs
Normal file
34
frontend/vite.config.mjs
Normal file
@ -0,0 +1,34 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
const proxiedPaths = [
|
||||
"/health",
|
||||
"/doctor",
|
||||
"/tasks",
|
||||
"/settings",
|
||||
"/runtime",
|
||||
"/history",
|
||||
"/logs",
|
||||
"/modules",
|
||||
"/scheduler",
|
||||
"/worker",
|
||||
"/stage",
|
||||
];
|
||||
|
||||
export default defineConfig({
|
||||
base: "/ui/",
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 5173,
|
||||
proxy: Object.fromEntries(
|
||||
proxiedPaths.map((path) => [
|
||||
path,
|
||||
{
|
||||
target: "http://127.0.0.1:8787",
|
||||
changeOrigin: false,
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user