Files

372 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {useState, useRef, useEffect, useCallback} from "react";
import {
RefreshCw,
CheckCircle2,
AlertCircle,
Loader2,
Terminal,
Calendar,
FileText,
} from "lucide-react";
import {Button} from "@/components/ui/button";
interface VersionFile {
version: string;
release_date: string;
changelog: string;
}
interface VersionInfo {
current: VersionFile;
latest: VersionFile | null;
hasUpdate: boolean;
currentCommit?: string;
latestCommit?: string;
message?: string;
}
type CheckState = "idle" | "checking" | "done" | "error";
type UpdateState = "idle" | "updating" | "done" | "error";
export default function UpdatePage() {
const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null);
const [checkState, setCheckState] = useState<CheckState>("idle");
const [updateState, setUpdateState] = useState<UpdateState>("idle");
const [logs, setLogs] = useState<string[]>([]);
const [errorMsg, setErrorMsg] = useState<string>("");
const logEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
logEndRef.current?.scrollIntoView({behavior: "smooth"});
}, [logs]);
// 页面加载时自动检查更新
useEffect(() => {
handleCheckUpdate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 检查更新
const handleCheckUpdate = useCallback(async () => {
setCheckState("checking");
setErrorMsg("");
setUpdateState("idle");
try {
const response = await fetch("/api/update/check");
const result = await response.json();
if (result.success) {
setVersionInfo(result.data);
setCheckState("done");
} else {
throw new Error(result.error || "检查更新失败");
}
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : "检查更新失败");
setCheckState("error");
}
}, []);
// 执行更新
const handleExecuteUpdate = useCallback(async () => {
setUpdateState("updating");
setLogs([]);
setErrorMsg("");
try {
const response = await fetch("/api/update/execute", {
method: "POST",
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("无法读取响应流");
}
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const {done, value} = await reader.read();
if (done) break;
buffer += decoder.decode(value, {stream: true});
const lines = buffer.split("\n\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6);
try {
const parsed = JSON.parse(data);
if (parsed.log) {
setLogs((prev) => [...prev, parsed.log]);
}
if (parsed.done) {
setUpdateState(parsed.success ? "done" : "error");
if (!parsed.success) {
setErrorMsg(parsed.message || "更新失败");
}
}
} catch {
// ignore
}
}
}
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : "更新执行失败");
setUpdateState("error");
}
}, []);
// 解析 changelog 为列表项显示
const parseChangelog = (text: string): string[] => {
if (!text) return [];
// 支持逗号、分号、换行分隔,也支持 "xxx1xxx2" 中文逗号
return text
.split(/[,;,;\n]/)
.map((s) => s.trim())
.filter(Boolean);
};
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold">版本更新</h2>
<p className="text-sm text-muted-foreground">检查并更新到最新版本</p>
</div>
{/* 版本信息卡片 */}
<div className="rounded-lg border bg-card p-6">
<div className="flex items-start justify-between flex-wrap gap-4">
<div className="space-y-4 min-w-0 flex-1">
{/* 当前版本 */}
<div>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">当前版本</span>
</div>
<div className="flex items-center gap-3 flex-wrap">
<code className="rounded-lg bg-primary/10 px-3 py-1 text-lg font-mono font-bold text-primary">
{versionInfo?.current.version || "-"}
</code>
{versionInfo?.current.release_date && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Calendar className="h-3 w-3"/>
{versionInfo.current.release_date}
</span>
)}
</div>
{/* 当前版本更新内容(无最新版本时展示) */}
{versionInfo?.current.changelog && !versionInfo?.latest?.changelog && (
<div className="mt-3 border-t pt-3">
<div className="flex items-center gap-2 mb-2">
<FileText className="h-3.5 w-3.5 text-muted-foreground"/>
<span className="text-xs font-medium text-muted-foreground">当前版本更新内容</span>
</div>
<ul className="space-y-1">
{parseChangelog(versionInfo.current.changelog).map((item, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/40"/>
<span className="text-muted-foreground">{item}</span>
</li>
))}
</ul>
</div>
)}
</div>
{/* 最新版本 */}
{versionInfo && (
<div className="border-t pt-3">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">最新版本</span>
{versionInfo.hasUpdate && (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700 ring-1 ring-amber-600/20">
<AlertCircle className="h-3 w-3"/>
有新版本
</span>
)}
{checkState === "done" && !versionInfo.hasUpdate && (
<span className="inline-flex items-center gap-1 rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700 ring-1 ring-green-600/20">
<CheckCircle2 className="h-3 w-3"/>
已是最新
</span>
)}
</div>
{versionInfo.latest ? (
<>
<div className="flex items-center gap-3 flex-wrap">
<code className="rounded-lg bg-muted px-3 py-1 text-lg font-mono">
{versionInfo.latest.version}
</code>
{versionInfo.latest.release_date && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Calendar className="h-3 w-3"/>
{versionInfo.latest.release_date}
</span>
)}
</div>
{/* 最新版本更新内容 */}
{versionInfo.latest.changelog && (
<div className="mt-3 border-t pt-3">
<div className="flex items-center gap-2 mb-2">
<FileText className="h-3.5 w-3.5 text-muted-foreground"/>
<span className="text-xs font-medium text-muted-foreground">更新内容</span>
</div>
<ul className="space-y-1">
{parseChangelog(versionInfo.latest.changelog).map((item, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary/60"/>
<span>{item}</span>
</li>
))}
</ul>
</div>
)}
</>
) : versionInfo.message ? (
<div className="rounded-lg bg-amber-50 border border-amber-200 p-3 text-sm text-amber-800">
<pre className="whitespace-pre-wrap font-sans text-xs leading-relaxed">{versionInfo.message}</pre>
</div>
) : (
<p className="text-sm text-muted-foreground">
{checkState === "checking" ? "检查中..." : "无法获取远程版本信息"}
</p>
)}
</div>
)}
</div>
{/* 操作按钮 */}
<div className="flex items-center gap-3 shrink-0">
{checkState === "checking" ? (
<Button disabled variant="outline" size="sm">
<Loader2 className="mr-2 h-4 w-4 animate-spin"/>
检查中...
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={handleCheckUpdate}
disabled={updateState === "updating"}
>
<RefreshCw className="mr-2 h-4 w-4"/>
检查更新
</Button>
)}
<Button
size="sm"
onClick={handleExecuteUpdate}
disabled={
updateState === "updating" ||
checkState === "checking" ||
(checkState === "done" && versionInfo && !versionInfo.hasUpdate) ||
checkState === "idle"
}
>
{updateState === "updating" ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin"/>
更新中...
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4"/>
立即更新
</>
)}
</Button>
</div>
</div>
</div>
{/* 错误提示 */}
{errorMsg && (
<div className="flex items-center gap-2 rounded-lg border border-destructive/50 bg-destructive/5 p-4 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0"/>
<span>{errorMsg}</span>
</div>
)}
{/* 状态提示 */}
{checkState === "idle" && !versionInfo && (
<div className="rounded-lg border bg-muted/30 p-8 text-center text-sm text-muted-foreground">
<RefreshCw className="mx-auto mb-2 h-8 w-8 opacity-50"/>
<p>点击"检查更新"查看是否有新版本可用</p>
<p className="mt-1 text-xs">
更新功能需要在 Docker 容器中运行,且已配置
<code className="mx-1 rounded bg-muted px-1 font-mono">ONEDEV_ACCESS_TOKEN</code>
</p>
</div>
)}
{/* 更新执行日志 */}
{(updateState !== "idle" || logs.length > 0) && (
<div className="rounded-lg border bg-card">
<div className="flex w-full items-center justify-between p-4 text-sm font-medium">
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4"/>
执行日志
</div>
</div>
<div className="border-t p-4">
<pre className="max-h-80 overflow-auto rounded-lg bg-black p-4 text-xs text-green-400 font-mono leading-relaxed">
{logs.map((log, i) => (
<div key={i}>{log}</div>
))}
{updateState === "updating" && (
<div className="flex items-center gap-1 mt-1">
<span className="animate-pulse"></span>
执行中...
</div>
)}
{updateState === "done" && logs.length > 0 && (
<div className="text-green-300 mt-1"> 更新完成</div>
)}
{updateState === "error" && logs.length > 0 && (
<div className="text-red-400 mt-1"> 更新失败</div>
)}
<div ref={logEndRef}/>
</pre>
</div>
</div>
)}
{/* 完成状态 */}
{updateState === "done" && (
<div className="flex items-center gap-2 rounded-lg border border-green-500/50 bg-green-50 p-4 text-sm text-green-700">
<CheckCircle2 className="h-5 w-5 shrink-0"/>
<div>
<p className="font-medium">更新完成</p>
<p className="text-green-600">服务已重启,请刷新页面以加载最新版本</p>
</div>
<Button
variant="outline"
size="sm"
className="ml-auto shrink-0"
onClick={() => window.location.reload()}
>
刷新页面
</Button>
</div>
)}
</div>
);
}