Files
recipe_tool/app/settings/update/page.tsx
T
hanhan f7060b056d feat: 添加版本更新功能
- 新增 .version 文件(JSON 格式:version/release_date/changelog)
- 新增 lib/update.ts 更新逻辑(版本检查、git clone、docker compose)
- 新增 /api/update/check 版本检查 API
- 新增 /api/update/execute 执行更新 API(SSE 流式日志)
- 新增设置页「版本更新」界面(含 changelog 展示)
- Dockerfile 添加 git、docker CLI;compose 挂载 docker socket
- .env.docker 添加 ONEDEV_ACCESS_TOKEN 配置
2026-05-24 22:35:53 +08:00

381 lines
13 KiB
TypeScript
Raw 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,
ChevronDown,
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 [showLogs, setShowLogs] = useState(false);
const [errorMsg, setErrorMsg] = useState<string>("");
const logEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
logEndRef.current?.scrollIntoView({behavior: "smooth"});
}, [logs]);
// 检查更新
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([]);
setShowLogs(true);
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>
</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.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>
{/* 变更日志 - 有更新时展示 */}
{versionInfo?.latest?.changelog && (
<div className="rounded-lg border bg-card">
<div className="flex items-center gap-2 border-b px-4 py-3">
<FileText className="h-4 w-4 text-muted-foreground"/>
<span className="text-sm font-medium">更新内容</span>
</div>
<div className="px-4 py-3">
<ul className="space-y-1.5">
{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>
</div>
)}
{/* 当前版本自己的 changelog(本地) */}
{versionInfo?.current.changelog && !versionInfo?.latest?.changelog && (
<div className="rounded-lg border bg-card">
<div className="flex items-center gap-2 border-b px-4 py-3">
<FileText className="h-4 w-4 text-muted-foreground"/>
<span className="text-sm font-medium">当前版本更新内容</span>
</div>
<div className="px-4 py-3">
<ul className="space-y-1.5">
{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>
)}
{/* 错误提示 */}
{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>
)}
{/* 更新执行日志 */}
{showLogs && (
<div className="rounded-lg border bg-card">
<button
onClick={() => setShowLogs(!showLogs)}
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>
<ChevronDown
className={`h-4 w-4 transition-transform ${
showLogs ? "" : "-rotate-90"
}`}
/>
</button>
{logs.length > 0 && (
<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" && (
<div className="text-green-300 mt-1"> 更新完成</div>
)}
{updateState === "error" && (
<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>
);
}