feat: 完成静态管理后台与用户端
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
__pycache__/
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# WOV Web
|
||||||
|
|
||||||
|
本仓库是 WOV 平台的静态前端,当前阶段不依赖构建工具,由 `wov-api` 直接挂载服务。
|
||||||
|
|
||||||
|
## 页面
|
||||||
|
|
||||||
|
- `index.html`:用户端应用中心。
|
||||||
|
- `admin.html`:节点注册、调用、实例管理。
|
||||||
|
- `workflow.html`:工作流编排入口,后端工作流 API 就绪后启用。
|
||||||
|
|
||||||
|
## 约束
|
||||||
|
|
||||||
|
- 页面不直接暴露 API 调用细节给普通用户。
|
||||||
|
- 前端只通过 `/api/...` 调用后端,不内联业务逻辑。
|
||||||
|
- 后续迁移到 React 时,页面行为应与当前静态页面保持一致。
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>WOV 管理后台</title>
|
||||||
|
<link rel="stylesheet" href="/assets/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<a class="brand" href="/">WOV</a>
|
||||||
|
<nav>
|
||||||
|
<a href="/">应用中心</a>
|
||||||
|
<a href="/admin.html">管理后台</a>
|
||||||
|
<a href="/workflow.html">工作流</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<h1>节点管理</h1>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>注册节点</h2>
|
||||||
|
<label for="manifestJson">节点 Manifest JSON</label>
|
||||||
|
<textarea id="manifestJson" rows="14"></textarea>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="registerNode" class="primary">注册节点</button>
|
||||||
|
<button id="loadEchoManifest" class="ghost">填入 Echo Manifest</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>已注册节点</h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>名称</th>
|
||||||
|
<th>能力</th>
|
||||||
|
<th>版本</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="nodeList"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>调用节点</h2>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>
|
||||||
|
节点
|
||||||
|
<select id="invokeNodeId"></select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Run ID
|
||||||
|
<input id="invokeRunId" type="text" value="run_web" />
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
inputs JSON
|
||||||
|
<textarea id="invokeInputs" rows="4">{"text":"hello wov"}</textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button id="invokeNode" class="primary">调用节点</button>
|
||||||
|
<pre id="invokeResult" class="result">等待调用...</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>节点实例</h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>实例 ID</th>
|
||||||
|
<th>节点</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>PID</th>
|
||||||
|
<th>地址</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="instanceList"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/assets/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+409
@@ -0,0 +1,409 @@
|
|||||||
|
const ECHO_MANIFEST = {
|
||||||
|
id: "echo",
|
||||||
|
name: "Echo Node",
|
||||||
|
version: "0.1.0",
|
||||||
|
capability: "echo",
|
||||||
|
repo_dir: "wov-node-echo",
|
||||||
|
command: ["python", "-m", "wov_node_echo"],
|
||||||
|
env: { WOV_NODE_PORT: "0" },
|
||||||
|
input_schema: { text: "string" },
|
||||||
|
output_schema: { text: "string", file_uri: "file" },
|
||||||
|
max_concurrency: 1,
|
||||||
|
idle_ttl_seconds: 15,
|
||||||
|
health_timeout_seconds: 10,
|
||||||
|
keep_warm: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEMO_WORKFLOW = {
|
||||||
|
name: "视频字幕生成",
|
||||||
|
version: 1,
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "extract",
|
||||||
|
node_type: "ffmpeg-extract",
|
||||||
|
params: { sample_rate: 16000, channels: 1 },
|
||||||
|
inputs: { video_uri: "input.video_uri" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "asr",
|
||||||
|
node_type: "faster-whisper",
|
||||||
|
params: { language: "ja" },
|
||||||
|
inputs: { audio_uri: "extract.audio_uri" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "translate",
|
||||||
|
node_type: "llm-translate",
|
||||||
|
params: { target_language: "zh-CN" },
|
||||||
|
inputs: { srt_uri: "asr.srt_uri" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ass",
|
||||||
|
node_type: "srt-to-dual-eye-ass",
|
||||||
|
params: { resolution: "3840x1920" },
|
||||||
|
inputs: { cn_srt_uri: "translate.cn_srt_uri" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: "extract", to: "asr" },
|
||||||
|
{ from: "asr", to: "translate" },
|
||||||
|
{ from: "translate", to: "ass" },
|
||||||
|
],
|
||||||
|
entry_inputs: { video_uri: "file" },
|
||||||
|
final_outputs: {
|
||||||
|
cn_srt: "translate.cn_srt_uri",
|
||||||
|
ass: "ass.ass_uri",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = data && data.detail ? JSON.stringify(data.detail) : response.statusText;
|
||||||
|
throw new Error(`${response.status} ${detail}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function badge(status) {
|
||||||
|
const className =
|
||||||
|
status === "ready" || status === "completed" || status === "ok"
|
||||||
|
? "ok"
|
||||||
|
: status === "stopped" || status === "error"
|
||||||
|
? "error"
|
||||||
|
: "warn";
|
||||||
|
return `<span class="badge ${className}">${escapeHtml(status)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHealth() {
|
||||||
|
try {
|
||||||
|
await api("/health");
|
||||||
|
} catch (error) {
|
||||||
|
const banner = document.createElement("div");
|
||||||
|
banner.className = "result";
|
||||||
|
banner.textContent = `后端不可用:${error.message}`;
|
||||||
|
document.body.prepend(banner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNodes() {
|
||||||
|
const nodes = await api("/api/admin/nodes");
|
||||||
|
const tbody = document.getElementById("nodeList");
|
||||||
|
const select = document.getElementById("invokeNodeId");
|
||||||
|
tbody.innerHTML = nodes.length
|
||||||
|
? nodes
|
||||||
|
.map(
|
||||||
|
(node) => `
|
||||||
|
<tr>
|
||||||
|
<td>${escapeHtml(node.id)}</td>
|
||||||
|
<td>${escapeHtml(node.name)}</td>
|
||||||
|
<td>${escapeHtml(node.capability)}</td>
|
||||||
|
<td>${escapeHtml(node.version)}</td>
|
||||||
|
<td><button class="danger" data-delete-node="${escapeHtml(node.id)}">删除</button></td>
|
||||||
|
</tr>`,
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
: '<tr><td colspan="5">暂无节点</td></tr>';
|
||||||
|
select.innerHTML = nodes
|
||||||
|
.map((node) => `<option value="${escapeHtml(node.id)}">${escapeHtml(node.name)}</option>`)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInstances() {
|
||||||
|
const instances = await api("/api/admin/node-instances");
|
||||||
|
const tbody = document.getElementById("instanceList");
|
||||||
|
tbody.innerHTML = instances.length
|
||||||
|
? instances
|
||||||
|
.map(
|
||||||
|
(instance) => `
|
||||||
|
<tr>
|
||||||
|
<td>${escapeHtml(instance.id)}</td>
|
||||||
|
<td>${escapeHtml(instance.node_id)}</td>
|
||||||
|
<td>${badge(instance.status)}</td>
|
||||||
|
<td>${escapeHtml(instance.pid ?? "")}</td>
|
||||||
|
<td>${escapeHtml(instance.address ?? "")}</td>
|
||||||
|
<td><button class="danger" data-stop-instance="${escapeHtml(instance.id)}">停止</button></td>
|
||||||
|
</tr>`,
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
: '<tr><td colspan="6">暂无实例</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
await Promise.all([loadNodes(), loadInstances()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerNode() {
|
||||||
|
const raw = document.getElementById("manifestJson").value.trim();
|
||||||
|
try {
|
||||||
|
const manifest = JSON.parse(raw);
|
||||||
|
await api("/api/admin/nodes", { method: "POST", body: raw });
|
||||||
|
alert("节点注册成功");
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
alert(`注册失败:${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invokeNode() {
|
||||||
|
const nodeId = document.getElementById("invokeNodeId").value;
|
||||||
|
const runId = document.getElementById("invokeRunId").value.trim() || "run_web";
|
||||||
|
const resultBox = document.getElementById("invokeResult");
|
||||||
|
resultBox.textContent = "调用中...";
|
||||||
|
try {
|
||||||
|
const inputs = JSON.parse(document.getElementById("invokeInputs").value);
|
||||||
|
const result = await api(`/api/admin/nodes/${nodeId}/invoke`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ run_id: runId, inputs, params: {} }),
|
||||||
|
});
|
||||||
|
resultBox.textContent = JSON.stringify(result, null, 2);
|
||||||
|
await loadInstances();
|
||||||
|
} catch (error) {
|
||||||
|
resultBox.textContent = `调用失败:${error.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteNode(nodeId) {
|
||||||
|
try {
|
||||||
|
await api(`/api/admin/nodes/${nodeId}`, { method: "DELETE" });
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
alert(`删除失败:${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopInstance(instanceId) {
|
||||||
|
try {
|
||||||
|
await api(`/api/admin/node-instances/${instanceId}/stop`, { method: "POST" });
|
||||||
|
await loadInstances();
|
||||||
|
} catch (error) {
|
||||||
|
alert(`停止失败:${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runEcho() {
|
||||||
|
const text = document.getElementById("echoText").value;
|
||||||
|
const resultBox = document.getElementById("echoResult");
|
||||||
|
resultBox.textContent = "运行中...";
|
||||||
|
try {
|
||||||
|
const result = await api("/api/admin/nodes/echo/invoke", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
run_id: `web_${Date.now()}`,
|
||||||
|
inputs: { text },
|
||||||
|
params: {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
resultBox.textContent = JSON.stringify(result, null, 2);
|
||||||
|
} catch (error) {
|
||||||
|
resultBox.textContent = `运行失败:${error.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWorkflows() {
|
||||||
|
const workflows = await api("/api/admin/workflows");
|
||||||
|
const tbody = document.getElementById("workflowList");
|
||||||
|
if (!tbody) return;
|
||||||
|
tbody.innerHTML = workflows.length
|
||||||
|
? workflows
|
||||||
|
.map(
|
||||||
|
(workflow) => `
|
||||||
|
<tr>
|
||||||
|
<td>${escapeHtml(workflow.id)}</td>
|
||||||
|
<td>${escapeHtml(workflow.name)}</td>
|
||||||
|
<td>${escapeHtml(workflow.latest_version)}</td>
|
||||||
|
<td>${workflow.published ? badge("ok") : badge("draft")}</td>
|
||||||
|
<td>
|
||||||
|
<button class="danger" data-delete-workflow="${escapeHtml(workflow.id)}">删除</button>
|
||||||
|
${workflow.published ? "" : `<button data-publish-workflow="${escapeHtml(workflow.id)}">发布</button>`}
|
||||||
|
</td>
|
||||||
|
</tr>`,
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
: '<tr><td colspan="5">暂无工作流</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createWorkflow() {
|
||||||
|
const workflowId = document.getElementById("workflowId").value.trim();
|
||||||
|
const name = document.getElementById("workflowName").value.trim();
|
||||||
|
const description = document.getElementById("workflowDescription").value.trim();
|
||||||
|
let definition;
|
||||||
|
try {
|
||||||
|
definition = JSON.parse(document.getElementById("workflowDefinition").value);
|
||||||
|
} catch (error) {
|
||||||
|
alert(`DAG JSON 无效:${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api("/api/admin/workflows", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: workflowId || undefined,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
definition,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
alert("工作流已保存");
|
||||||
|
await loadWorkflows();
|
||||||
|
} catch (error) {
|
||||||
|
alert(`保存失败:${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishWorkflow(workflowId) {
|
||||||
|
try {
|
||||||
|
await api(`/api/admin/workflows/${workflowId}/publish`, { method: "POST" });
|
||||||
|
await loadWorkflows();
|
||||||
|
} catch (error) {
|
||||||
|
alert(`发布失败:${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteWorkflow(workflowId) {
|
||||||
|
try {
|
||||||
|
await api(`/api/admin/workflows/${workflowId}`, { method: "DELETE" });
|
||||||
|
await loadWorkflows();
|
||||||
|
} catch (error) {
|
||||||
|
alert(`删除失败:${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadVideo() {
|
||||||
|
const fileInput = document.getElementById("videoFile");
|
||||||
|
const progress = document.getElementById("runProgress");
|
||||||
|
const downloads = document.getElementById("downloads");
|
||||||
|
downloads.innerHTML = "";
|
||||||
|
if (!fileInput.files.length) {
|
||||||
|
progress.textContent = "请先选择视频文件";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", fileInput.files[0]);
|
||||||
|
progress.textContent = "上传中...";
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/apps/demo/runs", { method: "POST", body: form });
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data && data.detail ? JSON.stringify(data.detail) : response.statusText);
|
||||||
|
}
|
||||||
|
await pollRun(data.id, progress, downloads);
|
||||||
|
} catch (error) {
|
||||||
|
progress.textContent = `运行失败:${error.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollRun(runId, progress, downloads) {
|
||||||
|
for (let i = 0; i < 600; i += 1) {
|
||||||
|
const run = await api(`/api/runs/${runId}`);
|
||||||
|
const percent = Math.round((run.progress || 0) * 100);
|
||||||
|
progress.textContent = `状态:${run.status} | 当前节点:${run.current_node_id || "-"} | 进度:${percent}%`;
|
||||||
|
if (run.status === "COMPLETED") {
|
||||||
|
renderArtifacts(runId, run.artifacts, downloads);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (run.status === "FAILED") {
|
||||||
|
progress.textContent = `运行失败:${run.error || "未知错误"}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
}
|
||||||
|
progress.textContent = "轮询超时,请到 /api/runs 查看任务状态";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderArtifacts(runId, artifacts, container) {
|
||||||
|
container.innerHTML = artifacts
|
||||||
|
.filter((item) => ["cn_srt", "ass"].includes(item.name))
|
||||||
|
.map(
|
||||||
|
(item) => `
|
||||||
|
<a class="download-link" href="/api/runs/${encodeURIComponent(runId)}/artifacts/${encodeURIComponent(item.name)}">
|
||||||
|
下载 ${escapeHtml(item.name)}
|
||||||
|
</a>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
const deleteNodeButton = event.target.closest("[data-delete-node]");
|
||||||
|
if (deleteNodeButton) {
|
||||||
|
deleteNode(deleteNodeButton.dataset.deleteNode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stopButton = event.target.closest("[data-stop-instance]");
|
||||||
|
if (stopButton) {
|
||||||
|
stopInstance(stopButton.dataset.stopInstance);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const deleteWorkflowButton = event.target.closest("[data-delete-workflow]");
|
||||||
|
if (deleteWorkflowButton) {
|
||||||
|
deleteWorkflow(deleteWorkflowButton.dataset.deleteWorkflow);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const publishWorkflowButton = event.target.closest("[data-publish-workflow]");
|
||||||
|
if (publishWorkflowButton) {
|
||||||
|
publishWorkflow(publishWorkflowButton.dataset.publishWorkflow);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", async () => {
|
||||||
|
const manifestBox = document.getElementById("manifestJson");
|
||||||
|
if (manifestBox) {
|
||||||
|
manifestBox.value = JSON.stringify(ECHO_MANIFEST, null, 2);
|
||||||
|
}
|
||||||
|
const loadEchoButton = document.getElementById("loadEchoManifest");
|
||||||
|
if (loadEchoButton) {
|
||||||
|
loadEchoButton.addEventListener("click", () => {
|
||||||
|
manifestBox.value = JSON.stringify(ECHO_MANIFEST, null, 2);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const registerButton = document.getElementById("registerNode");
|
||||||
|
if (registerButton) {
|
||||||
|
registerButton.addEventListener("click", registerNode);
|
||||||
|
}
|
||||||
|
const invokeButton = document.getElementById("invokeNode");
|
||||||
|
if (invokeButton) {
|
||||||
|
invokeButton.addEventListener("click", invokeNode);
|
||||||
|
}
|
||||||
|
const echoButton = document.getElementById("runEcho");
|
||||||
|
if (echoButton) {
|
||||||
|
echoButton.addEventListener("click", runEcho);
|
||||||
|
}
|
||||||
|
const workflowDefinition = document.getElementById("workflowDefinition");
|
||||||
|
if (workflowDefinition) {
|
||||||
|
workflowDefinition.value = JSON.stringify(DEMO_WORKFLOW, null, 2);
|
||||||
|
}
|
||||||
|
const createWorkflowButton = document.getElementById("createWorkflow");
|
||||||
|
if (createWorkflowButton) {
|
||||||
|
createWorkflowButton.addEventListener("click", createWorkflow);
|
||||||
|
}
|
||||||
|
const publishWorkflowButton = document.getElementById("publishWorkflow");
|
||||||
|
if (publishWorkflowButton) {
|
||||||
|
publishWorkflowButton.addEventListener("click", () => {
|
||||||
|
publishWorkflow(document.getElementById("workflowId").value.trim());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const uploadButton = document.getElementById("uploadVideo");
|
||||||
|
if (uploadButton) {
|
||||||
|
uploadButton.addEventListener("click", uploadVideo);
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadHealth();
|
||||||
|
if (document.getElementById("nodeList")) {
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
if (document.getElementById("workflowList")) {
|
||||||
|
await loadWorkflows();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f5f7fa;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--border: #d7dde6;
|
||||||
|
--text: #1c2733;
|
||||||
|
--muted: #66748a;
|
||||||
|
--primary: #1769aa;
|
||||||
|
--danger: #b42318;
|
||||||
|
--ok: #177245;
|
||||||
|
--radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 28px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1080px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 28px 20px 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card,
|
||||||
|
.panel {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
margin-top: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin: 10px 0 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 11px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font: inherit;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 9px 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.ghost {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions,
|
||||||
|
.form-grid {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid .wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 10px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result {
|
||||||
|
margin: 16px 0 0;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #0f1720;
|
||||||
|
color: #d7e5f3;
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-link {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-link:hover {
|
||||||
|
background: #e8f1f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.ok {
|
||||||
|
background: #e2f4ea;
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.warn {
|
||||||
|
background: #fff2d9;
|
||||||
|
color: #8a5a00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.error {
|
||||||
|
background: #fde8e6;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>WOV 应用中心</title>
|
||||||
|
<link rel="stylesheet" href="/assets/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<a class="brand" href="/">WOV</a>
|
||||||
|
<nav>
|
||||||
|
<a href="/">应用中心</a>
|
||||||
|
<a href="/admin.html">管理后台</a>
|
||||||
|
<a href="/workflow.html">工作流</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<h1>WOV 应用中心</h1>
|
||||||
|
<p class="muted">选择一个已发布的应用,上传或输入内容后由后台自动执行。</p>
|
||||||
|
|
||||||
|
<div class="app-grid">
|
||||||
|
<article class="card">
|
||||||
|
<h2>Echo 演示</h2>
|
||||||
|
<p>调用已注册的 echo 节点,验证前端到后端再到节点进程的完整链路。</p>
|
||||||
|
<label for="echoText">输入文本</label>
|
||||||
|
<input id="echoText" type="text" value="hello wov" />
|
||||||
|
<button id="runEcho" class="primary">运行</button>
|
||||||
|
<pre id="echoResult" class="result">等待运行...</pre>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
<h2>视频字幕生成</h2>
|
||||||
|
<p>上传视频后自动执行提音、转写、翻译和 VR 双眼 ASS 生成。</p>
|
||||||
|
<label for="videoFile">选择视频</label>
|
||||||
|
<input id="videoFile" type="file" accept="video/*,.mp4,.mkv,.avi,.mov,.m4a,.mp3" />
|
||||||
|
<button id="uploadVideo" class="primary">上传并生成</button>
|
||||||
|
<pre id="runProgress" class="result">等待上传...</pre>
|
||||||
|
<div id="downloads" class="downloads"></div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/assets/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>WOV 工作流</title>
|
||||||
|
<link rel="stylesheet" href="/assets/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<a class="brand" href="/">WOV</a>
|
||||||
|
<nav>
|
||||||
|
<a href="/">应用中心</a>
|
||||||
|
<a href="/admin.html">管理后台</a>
|
||||||
|
<a href="/workflow.html">工作流</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<h1>工作流编排</h1>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>工作流定义</h2>
|
||||||
|
<label for="workflowId">工作流 ID</label>
|
||||||
|
<input id="workflowId" type="text" value="demo" />
|
||||||
|
<label for="workflowName">名称</label>
|
||||||
|
<input id="workflowName" type="text" value="视频字幕生成" />
|
||||||
|
<label for="workflowDescription">描述</label>
|
||||||
|
<input id="workflowDescription" type="text" value="上传视频,自动生成中文字幕和 VR 双眼 ASS。" />
|
||||||
|
<label for="workflowDefinition">DAG JSON</label>
|
||||||
|
<textarea id="workflowDefinition" rows="22"></textarea>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="createWorkflow" class="primary">创建/更新工作流</button>
|
||||||
|
<button id="publishWorkflow" class="ghost">发布</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>已发布工作流</h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>名称</th>
|
||||||
|
<th>版本</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="workflowList"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/assets/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user