a242e3e3ec
- add scheduled task plumbing across backend core, CLI, and frontend surfaces - add frontend notification UI and keep service pages using the repaired shared stylesheet - refactor code queue runtime and update baidu netdisk/service integration docs
567 lines
33 KiB
TypeScript
567 lines
33 KiB
TypeScript
import React from "react";
|
|
import { fmtClock, fmtDate } from "./time";
|
|
import { LoadingTitle } from "./loading-indicator";
|
|
import { errorMessage, requestJson as requestUniDeskJson } from "./unidesk-error";
|
|
import { UniDeskErrorBanner } from "./unidesk-error-banner";
|
|
import { useNotification } from "./notification-context";
|
|
|
|
type AnyRecord = Record<string, any>;
|
|
|
|
const h = React.createElement;
|
|
const { useEffect } = React;
|
|
const useState: any = React.useState;
|
|
|
|
function requestJson(path: string, options: AnyRecord = {}): Promise<any> {
|
|
return requestUniDeskJson(path, { failureFields: ["ok", "success"], ...options });
|
|
}
|
|
|
|
function baiduApi(apiBaseUrl: string, path: string): string {
|
|
return `${apiBaseUrl}/microservices/baidu-netdisk/proxy${path}`;
|
|
}
|
|
|
|
function numberText(value: any): string {
|
|
const number = Number(value);
|
|
return Number.isFinite(number) ? number.toLocaleString("zh-CN") : "--";
|
|
}
|
|
|
|
function fmtBytes(value: any): string {
|
|
const bytes = Number(value);
|
|
if (!Number.isFinite(bytes) || bytes <= 0) return "--";
|
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
let current = bytes;
|
|
let index = 0;
|
|
while (current >= 1024 && index < units.length - 1) {
|
|
current /= 1024;
|
|
index += 1;
|
|
}
|
|
return `${current.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
|
}
|
|
|
|
function StatusBadge({ status, children }: AnyRecord) {
|
|
const normalized = String(status || "unknown").toLowerCase();
|
|
return h("span", { className: `status-badge ${normalized}` }, children || status || "unknown");
|
|
}
|
|
|
|
function MetricCard({ label, value, hint, tone }: AnyRecord) {
|
|
return h("article", { className: `metric-card ${tone || ""}` },
|
|
h("div", { className: "metric-label" }, label),
|
|
h("div", { className: "metric-value" }, value),
|
|
h("div", { className: "metric-hint" }, hint),
|
|
);
|
|
}
|
|
|
|
function Panel({ title, eyebrow, actions, children, className, loading }: AnyRecord) {
|
|
return h("section", { className: `panel ${className || ""}` },
|
|
h("div", { className: "panel-head" },
|
|
h("div", null,
|
|
eyebrow ? h("p", { className: "panel-eyebrow" }, eyebrow) : null,
|
|
h(LoadingTitle, { title, loading }),
|
|
),
|
|
actions ? h("div", { className: "panel-actions" }, actions) : null,
|
|
),
|
|
h("div", { className: "panel-body" }, children),
|
|
);
|
|
}
|
|
|
|
function RawButton({ title, data, onOpen, testId }: AnyRecord) {
|
|
return h("button", {
|
|
type: "button",
|
|
className: "ghost-btn",
|
|
"data-testid": testId,
|
|
onClick: (event: any) => {
|
|
event?.stopPropagation?.();
|
|
onOpen(title, data);
|
|
},
|
|
}, "查看原始JSON");
|
|
}
|
|
|
|
function EmptyState({ title, text }: AnyRecord) {
|
|
return h("div", { className: "empty-state" }, h("strong", null, title), h("span", null, text));
|
|
}
|
|
|
|
function DocLinkCard({ title, text, href, badge, testId }: AnyRecord) {
|
|
return h("a", {
|
|
className: "doc-link-card",
|
|
href,
|
|
target: "_blank",
|
|
rel: "noreferrer",
|
|
"data-testid": testId,
|
|
},
|
|
h("span", null, badge || "DOC"),
|
|
h("strong", null, title),
|
|
h("p", null, text),
|
|
h("code", null, href),
|
|
);
|
|
}
|
|
|
|
function microserviceRuntime(service: any): AnyRecord {
|
|
return service?.runtime && typeof service.runtime === "object" && !Array.isArray(service.runtime) ? service.runtime : {};
|
|
}
|
|
|
|
function microserviceBackend(service: any): AnyRecord {
|
|
return service?.backend && typeof service.backend === "object" && !Array.isArray(service.backend) ? service.backend : {};
|
|
}
|
|
|
|
function microserviceRepository(service: any): AnyRecord {
|
|
return service?.repository && typeof service.repository === "object" && !Array.isArray(service.repository) ? service.repository : {};
|
|
}
|
|
|
|
function filesFromPayload(payload: any): any[] {
|
|
return Array.isArray(payload?.files) ? payload.files : [];
|
|
}
|
|
|
|
function jobsFromPayload(payload: any): any[] {
|
|
return Array.isArray(payload?.jobs) ? payload.jobs : [];
|
|
}
|
|
|
|
function pathParent(path: string, root: string): string {
|
|
if (!path || path === root) return root;
|
|
const withoutSlash = path.replace(/\/+$/u, "");
|
|
const parent = withoutSlash.slice(0, withoutSlash.lastIndexOf("/")) || root;
|
|
return parent.length < root.length ? root : parent;
|
|
}
|
|
|
|
function pathJoin(base: string, name: string): string {
|
|
const safeName = String(name || "").replace(/^\/+|\/+$/gu, "");
|
|
return `${base.replace(/\/+$/u, "")}/${safeName}`;
|
|
}
|
|
|
|
function rootDisplayName(root: string): string {
|
|
return root === "/" ? "/" : root.split("/").filter(Boolean).pop() || root;
|
|
}
|
|
|
|
function ProgressBar({ percent }: AnyRecord) {
|
|
const value = Math.max(0, Math.min(100, Number(percent) || 0));
|
|
return h("div", { className: "baidu-progress" }, h("span", { style: { width: `${value}%` } }), h("em", null, `${value.toFixed(1)}%`));
|
|
}
|
|
|
|
export function BaiduNetdiskPage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyRecord) {
|
|
const service = microservices.find((item: any) => item.id === "baidu-netdisk") || null;
|
|
const [state, setState] = useState({ loading: false, actionLoading: false, error: "", message: "", health: null, account: null, files: null, transfers: null, logs: null, selfTest: null, refreshedAt: null });
|
|
const [currentDir, setCurrentDir] = useState("/");
|
|
const [deviceSession, setDeviceSession] = useState(null);
|
|
const [folderName, setFolderName] = useState("");
|
|
const [uploadForm, setUploadForm] = useState({ localPath: "sample.txt", remotePath: "/sample.txt" });
|
|
const [downloadForm, setDownloadForm] = useState({ fsId: "", localPath: "downloads/" });
|
|
const { addNotification } = useNotification();
|
|
|
|
const appRoot = state.health?.baidu?.appRoot || state.account?.rootPath || "/";
|
|
|
|
useEffect(() => {
|
|
setUploadForm((prev: any) => {
|
|
const legacyDefaults = new Set(["/sample.txt", "/apps/UniDeskBaiduNetdisk/sample.txt"]);
|
|
if (prev.remotePath && !legacyDefaults.has(prev.remotePath)) return prev;
|
|
const remotePath = pathJoin(appRoot, "sample.txt");
|
|
return prev.remotePath === remotePath ? prev : { ...prev, remotePath };
|
|
});
|
|
}, [appRoot]);
|
|
|
|
async function loadFiles(dir = currentDir): Promise<void> {
|
|
const targetDir = dir || appRoot;
|
|
const files = await requestJson(baiduApi(apiBaseUrl, `/api/files?dir=${encodeURIComponent(targetDir)}&limit=100`));
|
|
setState((prev: any) => ({ ...prev, files }));
|
|
}
|
|
|
|
async function loadTransfers(): Promise<void> {
|
|
const transfers = await requestJson(baiduApi(apiBaseUrl, "/api/transfers?limit=80"));
|
|
setState((prev: any) => ({ ...prev, transfers }));
|
|
}
|
|
|
|
async function load(): Promise<void> {
|
|
if (!service) return;
|
|
setState((prev: any) => ({ ...prev, loading: true, error: "", message: "" }));
|
|
try {
|
|
const health = await requestJson(`${apiBaseUrl}/microservices/baidu-netdisk/health`);
|
|
const root = health?.baidu?.appRoot || appRoot;
|
|
let account: any = null;
|
|
let files: any = null;
|
|
if (health?.auth?.loggedIn) {
|
|
account = await requestJson(baiduApi(apiBaseUrl, "/api/account?refresh=1"));
|
|
const dir = currentDir && currentDir.startsWith(root) ? currentDir : root;
|
|
setCurrentDir(dir);
|
|
files = await requestJson(baiduApi(apiBaseUrl, `/api/files?dir=${encodeURIComponent(dir)}&limit=100`));
|
|
} else {
|
|
setCurrentDir(root);
|
|
}
|
|
const transfers = await requestJson(baiduApi(apiBaseUrl, "/api/transfers?limit=80"));
|
|
const logs = await requestJson(baiduApi(apiBaseUrl, "/logs?limit=60"));
|
|
setState((prev: any) => ({ ...prev, loading: false, health, account: account?.account || null, files, transfers, logs, refreshedAt: new Date() }));
|
|
} catch (err) {
|
|
setState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "百度网盘服务加载失败") }));
|
|
}
|
|
}
|
|
|
|
async function startLogin(): Promise<void> {
|
|
setState((prev: any) => ({ ...prev, actionLoading: true, error: "", message: "" }));
|
|
try {
|
|
const result = await requestJson(baiduApi(apiBaseUrl, "/api/auth/device/start"), { method: "POST", body: {} });
|
|
setDeviceSession(result.session || null);
|
|
setState((prev: any) => ({ ...prev, actionLoading: false, message: "设备码已生成,请扫码授权" }));
|
|
} catch (err) {
|
|
setState((prev: any) => ({ ...prev, actionLoading: false, error: errorMessage(err, "创建设备码失败") }));
|
|
}
|
|
}
|
|
|
|
async function pollLogin(manual = false): Promise<void> {
|
|
if (!deviceSession?.id) return;
|
|
if (manual) setState((prev: any) => ({ ...prev, actionLoading: true, error: "" }));
|
|
try {
|
|
const result = await requestJson(baiduApi(apiBaseUrl, `/api/auth/device/status?sessionId=${encodeURIComponent(deviceSession.id)}`));
|
|
setDeviceSession(result.session || null);
|
|
if (result.session?.status === "succeeded") {
|
|
setState((prev: any) => ({ ...prev, actionLoading: false, message: "授权成功,正在刷新账号与文件列表" }));
|
|
await load();
|
|
} else if (manual) {
|
|
setState((prev: any) => ({ ...prev, actionLoading: false }));
|
|
}
|
|
} catch (err) {
|
|
setState((prev: any) => ({ ...prev, actionLoading: false, error: errorMessage(err, "轮询登录状态失败") }));
|
|
}
|
|
}
|
|
|
|
async function logout(): Promise<void> {
|
|
setState((prev: any) => ({ ...prev, actionLoading: true, error: "", message: "" }));
|
|
try {
|
|
await requestJson(baiduApi(apiBaseUrl, "/api/auth/logout"), { method: "POST", body: {} });
|
|
setDeviceSession(null);
|
|
setState((prev: any) => ({ ...prev, actionLoading: false, account: null, files: null, message: "本地 token 已清除" }));
|
|
await load();
|
|
} catch (err) {
|
|
setState((prev: any) => ({ ...prev, actionLoading: false, error: errorMessage(err, "退出登录失败") }));
|
|
}
|
|
}
|
|
|
|
async function createFolder(event: any): Promise<void> {
|
|
event.preventDefault();
|
|
const name = folderName.trim();
|
|
if (!name) return;
|
|
setState((prev: any) => ({ ...prev, actionLoading: true, error: "", message: "" }));
|
|
try {
|
|
await requestJson(baiduApi(apiBaseUrl, "/api/folders"), { method: "POST", body: { path: pathJoin(currentDir, name) } });
|
|
setFolderName("");
|
|
setState((prev: any) => ({ ...prev, actionLoading: false, message: "文件夹已创建" }));
|
|
await loadFiles(currentDir);
|
|
} catch (err) {
|
|
setState((prev: any) => ({ ...prev, actionLoading: false, error: errorMessage(err, "创建文件夹失败") }));
|
|
}
|
|
}
|
|
|
|
async function deleteRemote(path: string): Promise<void> {
|
|
if (!path) return;
|
|
setState((prev: any) => ({ ...prev, actionLoading: true, error: "", message: "" }));
|
|
try {
|
|
await requestJson(baiduApi(apiBaseUrl, "/api/files/manage"), { method: "POST", body: { opera: "delete", filelist: [{ path }], async: 1 } });
|
|
setState((prev: any) => ({ ...prev, actionLoading: false, message: "删除任务已提交" }));
|
|
await loadFiles(currentDir);
|
|
} catch (err) {
|
|
setState((prev: any) => ({ ...prev, actionLoading: false, error: errorMessage(err, "删除失败") }));
|
|
}
|
|
}
|
|
|
|
async function submitUpload(event: any): Promise<void> {
|
|
event.preventDefault();
|
|
setState((prev: any) => ({ ...prev, actionLoading: true, error: "", message: "" }));
|
|
try {
|
|
await requestJson(baiduApi(apiBaseUrl, "/api/transfers/upload-from-path"), { method: "POST", body: uploadForm });
|
|
setState((prev: any) => ({ ...prev, actionLoading: false, message: "上传任务已入队" }));
|
|
await loadTransfers();
|
|
} catch (err) {
|
|
setState((prev: any) => ({ ...prev, actionLoading: false, error: errorMessage(err, "上传任务创建失败") }));
|
|
}
|
|
}
|
|
|
|
async function submitDownload(event: any): Promise<void> {
|
|
event.preventDefault();
|
|
setState((prev: any) => ({ ...prev, actionLoading: true, error: "", message: "" }));
|
|
try {
|
|
await requestJson(baiduApi(apiBaseUrl, "/api/transfers/download-to-path"), { method: "POST", body: downloadForm });
|
|
setState((prev: any) => ({ ...prev, actionLoading: false, message: "下载任务已入队" }));
|
|
await loadTransfers();
|
|
} catch (err) {
|
|
setState((prev: any) => ({ ...prev, actionLoading: false, error: errorMessage(err, "下载任务创建失败") }));
|
|
}
|
|
}
|
|
|
|
async function transferAction(id: string, action: "cancel" | "retry"): Promise<void> {
|
|
setState((prev: any) => ({ ...prev, actionLoading: true, error: "", message: "" }));
|
|
try {
|
|
await requestJson(baiduApi(apiBaseUrl, `/api/transfers/${encodeURIComponent(id)}/${action}`), { method: "POST", body: {} });
|
|
setState((prev: any) => ({ ...prev, actionLoading: false, message: action === "cancel" ? "已请求取消任务" : "任务已重新入队" }));
|
|
await loadTransfers();
|
|
} catch (err) {
|
|
setState((prev: any) => ({ ...prev, actionLoading: false, error: errorMessage(err, "任务操作失败") }));
|
|
}
|
|
}
|
|
|
|
async function runSelfTest(): Promise<void> {
|
|
setState((prev: any) => ({ ...prev, actionLoading: true, error: "", message: "正在运行上传/下载自测..." }));
|
|
try {
|
|
const selfTest = await requestJson(baiduApi(apiBaseUrl, "/api/self-test"), { method: "POST", body: {} });
|
|
setState((prev: any) => ({ ...prev, actionLoading: false, selfTest, message: `上传/下载自测通过:${selfTest.remotePath || ""}` }));
|
|
await loadFiles(currentDir);
|
|
await loadTransfers();
|
|
} catch (err) {
|
|
setState((prev: any) => ({ ...prev, actionLoading: false, error: errorMessage(err, "上传/下载自测失败") }));
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!service) return undefined;
|
|
load();
|
|
return undefined;
|
|
}, [service?.id, service?.runtime?.providerStatus]);
|
|
|
|
useEffect(() => {
|
|
if (!deviceSession?.id || deviceSession.status !== "pending") return undefined;
|
|
const timer = window.setInterval(() => void pollLogin(false), Math.max(5000, Number(deviceSession.pollIntervalSeconds || 5) * 1000));
|
|
return () => window.clearInterval(timer);
|
|
}, [deviceSession?.id, deviceSession?.status, deviceSession?.pollIntervalSeconds]);
|
|
|
|
useEffect(() => {
|
|
if (!service) return undefined;
|
|
const timer = window.setInterval(() => void loadTransfers(), 5000);
|
|
return () => window.clearInterval(timer);
|
|
}, [service?.id]);
|
|
|
|
if (!service) return h(EmptyState, { title: "Baidu Netdisk 未登记", text: "请在 config.json 的 microservices 中登记用户服务 id=baidu-netdisk" });
|
|
|
|
const runtime = microserviceRuntime(service);
|
|
const repository = microserviceRepository(service);
|
|
const backend = microserviceBackend(service);
|
|
const health = state.health || {};
|
|
const account = state.account || health.auth?.account || null;
|
|
const auth = health.auth || {};
|
|
const files = filesFromPayload(state.files);
|
|
const jobs = jobsFromPayload(state.transfers);
|
|
const quota = account?.quota || {};
|
|
const loggedIn = Boolean(auth.loggedIn || account);
|
|
const configured = Boolean(auth.configured);
|
|
|
|
return h("div", { className: "baidu-netdisk-page", "data-testid": "baidu-netdisk-page" },
|
|
h(Panel, {
|
|
title: "Baidu Netdisk 工作台",
|
|
eyebrow: "Containerized Storage Gateway",
|
|
loading: state.loading,
|
|
actions: h("div", { className: "panel-actions" },
|
|
h("a", { className: "ghost-btn", href: "/docs/issue/baidu-netdisk-env-setup.md", target: "_blank", rel: "noreferrer", "data-testid": "baidu-netdisk-config-doc-link" }, "配置文档"),
|
|
h("button", { type: "button", className: "ghost-btn", onClick: load, disabled: state.loading, "data-testid": "baidu-netdisk-refresh" }, state.loading ? "刷新中" : "刷新"),
|
|
h(RawButton, { title: "Baidu Netdisk 用户服务", data: service, onOpen: onRaw, testId: "raw-baidu-netdisk-service" }),
|
|
),
|
|
},
|
|
h("div", { className: "baidu-netdisk-hero" },
|
|
h("div", null,
|
|
h("div", { className: "node-version-line" },
|
|
h(StatusBadge, { status: runtime.providerStatus === "online" ? "online" : "warn" }, runtime.providerStatus || "unknown"),
|
|
h("span", null, service.providerId),
|
|
h(StatusBadge, { status: backend.public ? "warn" : "private" }, backend.public ? "公网暴露" : "仅 UniDesk frontend 代理访问"),
|
|
),
|
|
h("p", { className: "muted paragraph" }, service.description),
|
|
),
|
|
h("div", { className: "microservice-ref-card" },
|
|
h("span", null, "Repo"),
|
|
h("strong", null, repository.url || "--"),
|
|
h("code", null, repository.commitId || "--"),
|
|
),
|
|
h("div", { className: "microservice-ref-card" },
|
|
h("span", null, "Private Backend"),
|
|
h("strong", null, `${backend.nodeBindHost || "--"}:${backend.nodePort || "--"}`),
|
|
h("code", null, `${repository.composeFile || "--"} / ${repository.composeService || "--"}`),
|
|
),
|
|
),
|
|
h(UniDeskErrorBanner, { error: state.error, wide: true }),
|
|
),
|
|
h("div", { className: "metric-grid" },
|
|
h(MetricCard, { label: "Health", value: health.ok ? "OK" : "--", hint: health.storage?.postgres || "postgres", tone: health.ok ? "ok" : "warn" }),
|
|
h(MetricCard, { label: "OAuth", value: configured ? "已配置" : "待配置", hint: configured ? "client + secret + token key" : "需要设置 UNIDESK_BAIDU_NETDISK_*", tone: configured ? "ok" : "warn" }),
|
|
h(MetricCard, { label: "Login", value: loggedIn ? "已登录" : "未登录", hint: account?.username || "Device Code QR", tone: loggedIn ? "ok" : "warn" }),
|
|
h(MetricCard, { label: "Work Root", value: rootDisplayName(appRoot), hint: appRoot }),
|
|
h(MetricCard, { label: "Quota", value: fmtBytes(quota.used), hint: quota.total ? `${quota.usedPercent || 0}% / ${fmtBytes(quota.total)}` : "授权后刷新" }),
|
|
h(MetricCard, { label: "Transfers", value: numberText(jobs.length), hint: `running ${state.transfers?.counts?.running || 0} / failed ${state.transfers?.counts?.failed || 0}` }),
|
|
),
|
|
h("div", { className: "baidu-netdisk-grid" },
|
|
h(Panel, {
|
|
title: "配置与文档",
|
|
eyebrow: "Deployment References",
|
|
className: "baidu-docs-panel",
|
|
actions: h("div", { className: "panel-actions inline-actions" },
|
|
h("a", { className: "ghost-btn", href: "/docs/issue/baidu-netdisk-env-setup.md", target: "_blank", rel: "noreferrer" }, "打开环境配置"),
|
|
h("a", { className: "ghost-btn", href: "/docs/issue/baidu-netdisk-user-service.md", target: "_blank", rel: "noreferrer" }, "打开服务方案"),
|
|
),
|
|
},
|
|
h("p", { className: "muted paragraph" }, configured
|
|
? "OAuth 运行时变量已配置;如需轮换密钥、迁移部署或排查代理边界,可直接打开下面的项目内文档。"
|
|
: "首次使用请先按环境变量配置文档填入百度应用 client id / secret,然后重建 baidu-netdisk 服务并刷新本页。"),
|
|
h("div", { className: "baidu-doc-grid", "data-testid": "baidu-netdisk-doc-links" },
|
|
h(DocLinkCard, {
|
|
title: "环境变量配置",
|
|
text: "填写 UNIDESK_BAIDU_NETDISK_CLIENT_ID、CLIENT_SECRET、TOKEN_KEY,并执行重建与健康检查。",
|
|
href: "/docs/issue/baidu-netdisk-env-setup.md",
|
|
badge: "SETUP",
|
|
testId: "baidu-netdisk-env-doc-card",
|
|
}),
|
|
h(DocLinkCard, {
|
|
title: "服务方案与 API",
|
|
text: "说明 OAuth Device Code、根目录工作区、staging 上传下载任务和后端 API 设计。",
|
|
href: "/docs/issue/baidu-netdisk-user-service.md",
|
|
badge: "DESIGN",
|
|
}),
|
|
h(DocLinkCard, {
|
|
title: "用户服务安全边界",
|
|
text: "查看 UniDesk microservice 私有代理、允许路径、frontendOnly 和密钥边界规则。",
|
|
href: "/docs/reference/microservices.md",
|
|
badge: "REF",
|
|
}),
|
|
h(DocLinkCard, {
|
|
title: "部署与重建流程",
|
|
text: "查看 server rebuild、Compose 编排、健康检查和交付验证的长期规则。",
|
|
href: "/docs/reference/deployment.md",
|
|
badge: "DEPLOY",
|
|
}),
|
|
h(DocLinkCard, {
|
|
title: "CLI 验证命令",
|
|
text: "查看 microservice health/proxy、server rebuild、job status 等命令入口。",
|
|
href: "/docs/reference/cli.md",
|
|
badge: "CLI",
|
|
}),
|
|
h(DocLinkCard, {
|
|
title: "百度设备码模式",
|
|
text: "打开百度官方 OAuth Device Code 文档,对照扫码登录和轮询参数。",
|
|
href: "https://pan.baidu.com/union/doc/fl1x114ti",
|
|
badge: "OFFICIAL",
|
|
}),
|
|
),
|
|
),
|
|
h(Panel, {
|
|
title: "设备码登录",
|
|
eyebrow: "OAuth Device Code",
|
|
className: "baidu-login-panel",
|
|
loading: state.actionLoading,
|
|
actions: h("div", { className: "panel-actions inline-actions" },
|
|
h("button", { type: "button", className: "primary-btn", onClick: startLogin, disabled: state.actionLoading || !configured, "data-testid": "baidu-netdisk-start-login" }, "生成二维码"),
|
|
deviceSession?.id ? h("button", { type: "button", className: "ghost-btn", onClick: () => pollLogin(true), disabled: state.actionLoading }, "检查状态") : null,
|
|
loggedIn ? h("button", { type: "button", className: "ghost-btn", onClick: logout, disabled: state.actionLoading }, "清除本地登录") : null,
|
|
h(RawButton, { title: "Baidu Device Session", data: deviceSession || auth.latestSession, onOpen: onRaw, testId: "raw-baidu-device-session" }),
|
|
),
|
|
},
|
|
h("div", { className: "baidu-login-card", "data-testid": "baidu-netdisk-login-card" },
|
|
h("div", { className: "baidu-qr-frame" },
|
|
deviceSession?.qrcodeUrl
|
|
? h("img", { src: deviceSession.qrcodeUrl, alt: "百度网盘设备码授权二维码", "data-testid": "baidu-netdisk-qrcode" })
|
|
: h(EmptyState, { title: configured ? "等待二维码" : "OAuth 未配置", text: configured ? "点击生成二维码后使用百度网盘或百度 App 扫码" : "设置 client id、secret 和 token key 后重建服务" }),
|
|
),
|
|
h("div", { className: "claudeqq-login-copy" },
|
|
h("div", { className: "node-version-line" },
|
|
h(StatusBadge, { status: loggedIn ? "online" : deviceSession?.status === "pending" ? "warn" : "unknown" }, loggedIn ? "已登录" : deviceSession?.status || "未开始"),
|
|
h("span", null, deviceSession?.secondsRemaining !== undefined ? `${deviceSession.secondsRemaining}s` : "--"),
|
|
h("span", null, "scope basic,netdisk"),
|
|
),
|
|
h("p", { className: "muted paragraph" }, loggedIn
|
|
? "access token / refresh token 已加密保存到 PostgreSQL;前端只看到脱敏登录态。"
|
|
: "后端使用百度 OAuth Device Code 轮询换取 token;二维码过期后重新生成即可。"),
|
|
h("div", { className: "microservice-ref-card" }, h("span", null, "User Code"), h("strong", null, deviceSession?.userCode || "--"), h("code", null, deviceSession?.verificationUrl || "https://openapi.baidu.com/device")),
|
|
h("div", { className: "microservice-ref-card" }, h("span", null, "Expires"), h("strong", null, deviceSession?.expiresAt ? fmtDate(deviceSession.expiresAt) : "--"), h("code", null, deviceSession?.error || "no token exposed")),
|
|
),
|
|
),
|
|
),
|
|
h(Panel, { title: "账号与容量", eyebrow: state.refreshedAt ? `Updated ${fmtClock(state.refreshedAt)}` : "Account", loading: state.loading,
|
|
actions: h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "Baidu Account", data: account, onOpen: onRaw, testId: "raw-baidu-account" })),
|
|
},
|
|
account ? h("div", { className: "baidu-account-card" },
|
|
h("div", { className: "node-version-line" }, h(StatusBadge, { status: "online" }, "connected"), h("span", null, account.baiduUid || "--"), h("span", null, `VIP ${account.vipType ?? "--"}`)),
|
|
h("h3", null, account.username || "Baidu Netdisk"),
|
|
h("p", { className: "muted paragraph" }, `工作目录固定在 ${account.rootPath || appRoot};v1 上传/下载只读写容器 staging 目录,不把大文件字节流穿过 UniDesk proxy。`),
|
|
h("div", { className: "quota-bar" }, h("span", { style: { width: `${Math.max(0, Math.min(100, Number(quota.usedPercent || 0)))}%` } })),
|
|
h("div", { className: "microservice-ref-card" }, h("span", null, "Quota"), h("strong", null, `${fmtBytes(quota.used)} / ${fmtBytes(quota.total)}`), h("code", null, `${quota.usedPercent || 0}% used`)),
|
|
) : h(EmptyState, { title: "尚未登录", text: "扫码授权后这里会显示账号、UID、会员状态和容量" }),
|
|
),
|
|
h(Panel, { title: "文件浏览器", eyebrow: currentDir, className: "baidu-files-panel", loading: state.loading,
|
|
actions: h("div", { className: "panel-actions inline-actions" },
|
|
h("button", { type: "button", className: "ghost-btn", onClick: () => { const parent = pathParent(currentDir, appRoot); setCurrentDir(parent); void loadFiles(parent); }, disabled: !loggedIn || currentDir === appRoot }, "上级"),
|
|
h("button", { type: "button", className: "ghost-btn", onClick: () => loadFiles(currentDir), disabled: !loggedIn }, "刷新文件"),
|
|
h(RawButton, { title: "Baidu Files", data: state.files, onOpen: onRaw, testId: "raw-baidu-files" }),
|
|
),
|
|
},
|
|
h("form", { className: "baidu-pathbar", onSubmit: (event: any) => { event.preventDefault(); void loadFiles(currentDir); } },
|
|
h("input", { value: currentDir, onChange: (event: any) => setCurrentDir(event.target.value), disabled: !loggedIn }),
|
|
h("button", { type: "submit", className: "ghost-btn", disabled: !loggedIn }, "打开路径"),
|
|
),
|
|
h("form", { className: "baidu-pathbar", onSubmit: createFolder },
|
|
h("input", { value: folderName, onChange: (event: any) => setFolderName(event.target.value), placeholder: "新文件夹名称", disabled: !loggedIn }),
|
|
h("button", { type: "submit", className: "primary-btn", disabled: !loggedIn || !folderName.trim() }, "新建文件夹"),
|
|
),
|
|
!loggedIn ? h(EmptyState, { title: "等待授权", text: "登录后通过 /api/files 读取工作目录文件列表" }) : files.length === 0 ? h(EmptyState, { title: "目录为空", text: "可以从 staging 目录上传文件或新建文件夹" }) :
|
|
h("div", { className: "table-wrap", "data-testid": "baidu-netdisk-file-table" }, h("table", null,
|
|
h("thead", null, h("tr", null, h("th", null, "名称"), h("th", null, "类型"), h("th", null, "大小"), h("th", null, "修改时间"), h("th", null, "fs_id"), h("th", null, "操作"))),
|
|
h("tbody", null, files.map((file: any) => h("tr", { key: file.fsId || file.path },
|
|
h("td", null, h("strong", null, file.serverFilename || file.path), h("code", null, file.path || "--")),
|
|
h("td", null, h(StatusBadge, { status: file.isDir ? "queued" : "private" }, file.isDir ? "DIR" : "FILE")),
|
|
h("td", null, file.isDir ? "--" : fmtBytes(file.size)),
|
|
h("td", null, file.serverMtime ? fmtDate(file.serverMtime * 1000) : "--"),
|
|
h("td", null, h("code", null, file.fsId || "--")),
|
|
h("td", null, h("div", { className: "inline-actions" },
|
|
file.isDir ? h("button", { type: "button", className: "ghost-btn", onClick: () => { setCurrentDir(file.path); void loadFiles(file.path); } }, "打开") :
|
|
h("button", { type: "button", className: "ghost-btn", onClick: () => setDownloadForm((prev: any) => ({ ...prev, fsId: file.fsId })) }, "填入下载"),
|
|
h("button", { type: "button", className: "ghost-btn", onClick: () => deleteRemote(file.path), disabled: state.actionLoading }, "删除"),
|
|
)),
|
|
))),
|
|
)),
|
|
),
|
|
h(Panel, { title: "传输任务", eyebrow: "staging path jobs", className: "baidu-transfers-panel", loading: state.actionLoading,
|
|
actions: h("div", { className: "panel-actions inline-actions" },
|
|
h("button", { type: "button", className: "primary-btn", onClick: runSelfTest, disabled: !loggedIn || state.actionLoading, "data-testid": "baidu-netdisk-self-test" }, "运行自测"),
|
|
h("button", { type: "button", className: "ghost-btn", onClick: loadTransfers }, "刷新任务"),
|
|
h(RawButton, { title: "Baidu Transfers", data: state.transfers, onOpen: onRaw, testId: "raw-baidu-transfers" }),
|
|
),
|
|
},
|
|
h("div", { className: "baidu-transfer-forms" },
|
|
h("form", { className: "stack-form", onSubmit: submitUpload, "data-testid": "baidu-upload-form" },
|
|
h("label", null, "容器 staging 文件", h("input", { value: uploadForm.localPath, onChange: (event: any) => setUploadForm((prev: any) => ({ ...prev, localPath: event.target.value })), placeholder: "sample.txt" })),
|
|
h("label", null, "百度网盘目标路径", h("input", { value: uploadForm.remotePath, onChange: (event: any) => setUploadForm((prev: any) => ({ ...prev, remotePath: event.target.value })), placeholder: pathJoin(appRoot, "sample.txt") })),
|
|
h("button", { type: "submit", className: "primary-btn", disabled: !loggedIn || state.actionLoading }, "上传 staging 文件"),
|
|
),
|
|
h("form", { className: "stack-form", onSubmit: submitDownload, "data-testid": "baidu-download-form" },
|
|
h("label", null, "文件 fs_id", h("input", { value: downloadForm.fsId, onChange: (event: any) => setDownloadForm((prev: any) => ({ ...prev, fsId: event.target.value })), placeholder: "从文件表填入" })),
|
|
h("label", null, "保存到 staging 路径", h("input", { value: downloadForm.localPath, onChange: (event: any) => setDownloadForm((prev: any) => ({ ...prev, localPath: event.target.value })), placeholder: "downloads/" })),
|
|
h("button", { type: "submit", className: "primary-btn", disabled: !loggedIn || !downloadForm.fsId || state.actionLoading }, "下载到 staging"),
|
|
),
|
|
),
|
|
state.selfTest ? h("div", { className: "baidu-account-card", "data-testid": "baidu-netdisk-self-test-result" },
|
|
h("div", { className: "node-version-line" },
|
|
h(StatusBadge, { status: state.selfTest.ok ? "online" : "warn" }, state.selfTest.ok ? "self-test ok" : "self-test"),
|
|
h("span", null, fmtBytes(state.selfTest.sizeBytes)),
|
|
),
|
|
h("h3", null, state.selfTest.remotePath || "Baidu self-test"),
|
|
h("div", { className: "microservice-ref-card" }, h("span", null, "fs_id"), h("strong", null, state.selfTest.fsId || "--"), h("code", null, state.selfTest.downloadedPath || "--")),
|
|
h("div", { className: "microservice-ref-card" }, h("span", null, "MD5"), h("strong", null, state.selfTest.downloadedMd5 || "--"), h("code", null, state.selfTest.expectedMd5 || "--")),
|
|
h(RawButton, { title: "Baidu Self Test", data: state.selfTest, onOpen: onRaw, testId: "raw-baidu-self-test" }),
|
|
) : null,
|
|
jobs.length === 0 ? h(EmptyState, { title: "暂无传输任务", text: "上传/下载任务会在后端容器内执行,避免大文件穿过 UniDesk proxy" }) :
|
|
h("div", { className: "table-wrap", "data-testid": "baidu-transfer-table" }, h("table", null,
|
|
h("thead", null, h("tr", null, h("th", null, "状态"), h("th", null, "方向"), h("th", null, "路径"), h("th", null, "进度"), h("th", null, "时间"), h("th", null, "操作"))),
|
|
h("tbody", null, jobs.map((job: any) => h("tr", { key: job.id },
|
|
h("td", null, h(StatusBadge, { status: job.status }, job.status)),
|
|
h("td", null, job.direction),
|
|
h("td", null, h("strong", null, job.remotePath || job.fsId || "--"), h("code", null, job.localPath || "--"), job.error ? h("span", { className: "form-error" }, job.error) : null),
|
|
h("td", null, h(ProgressBar, { percent: job.progressPercent }), h("span", { className: "muted" }, `${fmtBytes(job.bytesDone)} / ${fmtBytes(job.sizeBytes)}`)),
|
|
h("td", null, fmtDate(job.updatedAt)),
|
|
h("td", null, h("div", { className: "inline-actions" },
|
|
["queued", "running"].includes(job.status) ? h("button", { type: "button", className: "ghost-btn", onClick: () => transferAction(job.id, "cancel") }, "取消") : null,
|
|
["failed", "canceled"].includes(job.status) ? h("button", { type: "button", className: "ghost-btn", onClick: () => transferAction(job.id, "retry") }, "重试") : null,
|
|
h(RawButton, { title: `Transfer ${job.id}`, data: job, onOpen: onRaw }),
|
|
)),
|
|
))),
|
|
)),
|
|
),
|
|
h(Panel, { title: "安全与日志", eyebrow: "redacted diagnostics", className: "baidu-wide-panel", loading: state.loading,
|
|
actions: h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "Baidu Health", data: health, onOpen: onRaw, testId: "raw-baidu-health" }), h(RawButton, { title: "Baidu Logs", data: state.logs, onOpen: onRaw, testId: "raw-baidu-logs" })),
|
|
},
|
|
h("div", { className: "policy-grid" },
|
|
h("article", null, h("b", null, "私有后端"), h("span", null, "4244 只在 Compose 网络 expose,浏览器经 UniDesk 同源代理访问")),
|
|
h("article", null, h("b", null, "Token 加密"), h("span", null, "access/refresh token 使用 BAIDU_NETDISK_TOKEN_KEY 加密后写入 PostgreSQL")),
|
|
h("article", null, h("b", null, "无浏览器大文件流"), h("span", null, "上传/下载以容器 staging 目录为边界,避免 proxy 文本通道传输大字节流")),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|