官网 初版

This commit is contained in:
rucky
2026-03-18 17:13:27 +08:00
parent 879c4bdfc8
commit 241a76caeb
95 changed files with 8889 additions and 113 deletions

View File

@@ -0,0 +1,420 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "sonner";
import { Pencil, Trash2, Upload, X, Check } from "lucide-react";
interface SoftwareVersion {
id: string;
version: string;
versionCode: number;
changelog: string;
downloadType: string;
filePath: string | null;
externalUrl: string | null;
fileSize: number;
downloadCount: number;
isLatest: boolean;
forceUpdate: boolean;
minVersion: string | null;
createdAt: Date;
}
interface SoftwareEditFormProps {
software: {
id: string;
name: string;
slug: string;
description: string;
versions: SoftwareVersion[];
};
}
export function SoftwareEditForm({ software }: SoftwareEditFormProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
const fd = new FormData(e.currentTarget);
const res = await fetch(`/api/software/${software.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: fd.get("name"),
slug: fd.get("slug"),
description: fd.get("description"),
}),
});
if (res.ok) {
toast.success("更新成功");
router.refresh();
} else {
const err = await res.json();
toast.error(err.error || "更新失败");
}
setLoading(false);
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Input
id="name"
name="name"
defaultValue={software.name}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="slug">Slug</Label>
<Input
id="slug"
name="slug"
defaultValue={software.slug}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
name="description"
defaultValue={software.description}
rows={3}
/>
</div>
<Button type="submit" disabled={loading}>
{loading ? "保存中..." : "保存"}
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
{software.versions.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<div className="space-y-3">
{software.versions.map((v) => (
<VersionItem key={v.id} version={v} />
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}
function VersionItem({ version: v }: { version: SoftwareVersion }) {
const router = useRouter();
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [version, setVersion] = useState(v.version);
const [versionCode, setVersionCode] = useState(v.versionCode.toString());
const [changelog, setChangelog] = useState(v.changelog);
const [downloadType, setDownloadType] = useState(v.downloadType);
const [externalUrl, setExternalUrl] = useState(v.externalUrl || "");
const [forceUpdate, setForceUpdate] = useState(v.forceUpdate);
const [minVersion, setMinVersion] = useState(v.minVersion || "");
const [isLatest, setIsLatest] = useState(v.isLatest);
const [filePath, setFilePath] = useState(v.filePath || "");
const [fileSize, setFileSize] = useState(v.fileSize);
const [uploading, setUploading] = useState(false);
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
const formData = new FormData();
formData.append("file", file);
const res = await fetch("/api/upload", { method: "POST", body: formData });
if (res.ok) {
const data = await res.json();
setFilePath(data.filePath);
setFileSize(data.size);
toast.success(`文件 ${data.originalName} 上传成功`);
} else {
toast.error("文件上传失败");
}
setUploading(false);
}
async function handleSave() {
setSaving(true);
if (downloadType === "local" && !filePath) {
toast.error("请先上传文件");
setSaving(false);
return;
}
const res = await fetch(`/api/software/versions/${v.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
version,
versionCode: Number(versionCode),
changelog,
downloadType,
filePath: downloadType === "local" ? filePath : null,
externalUrl: downloadType === "url" ? externalUrl : null,
fileSize,
forceUpdate,
minVersion: minVersion || null,
isLatest,
}),
});
if (res.ok) {
toast.success("版本更新成功");
setEditing(false);
router.refresh();
} else {
const err = await res.json();
toast.error(err.error || "更新失败");
}
setSaving(false);
}
async function handleDelete() {
if (!confirm(`确定要删除版本 v${v.version} 吗?此操作不可撤销。`)) return;
setDeleting(true);
const res = await fetch(`/api/software/versions/${v.id}`, {
method: "DELETE",
});
if (res.ok) {
toast.success("版本已删除");
router.refresh();
} else {
toast.error("删除失败");
}
setDeleting(false);
}
if (!editing) {
return (
<div className="rounded-lg border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-semibold">v{v.version}</span>
<span className="text-xs text-muted-foreground">
(code: {v.versionCode})
</span>
{v.isLatest && <Badge></Badge>}
{v.forceUpdate && (
<Badge variant="destructive"></Badge>
)}
<Badge variant="outline" className="text-xs">
{v.downloadType === "url" ? "外部链接" : "本地文件"}
</Badge>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setEditing(true)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={handleDelete}
disabled={deleting}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{v.downloadCount} ·{" "}
{new Date(v.createdAt).toLocaleDateString("zh-CN")}
</p>
{v.changelog && (
<p className="mt-2 whitespace-pre-line text-sm text-muted-foreground">
{v.changelog}
</p>
)}
</div>
);
}
return (
<div className="space-y-4 rounded-lg border border-primary/30 bg-muted/30 p-4">
<div className="flex items-center justify-between">
<span className="font-semibold"> v{v.version}</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setEditing(false)}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label></Label>
<Input
value={version}
onChange={(e) => setVersion(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
type="number"
value={versionCode}
onChange={(e) => setVersionCode(e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={minVersion}
onChange={(e) => setMinVersion(e.target.value)}
placeholder="可选"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Textarea
value={changelog}
onChange={(e) => setChangelog(e.target.value)}
rows={4}
/>
</div>
<div className="space-y-3">
<Label></Label>
<div className="flex gap-3">
<Button
type="button"
size="sm"
variant={downloadType === "local" ? "default" : "outline"}
onClick={() => setDownloadType("local")}
>
</Button>
<Button
type="button"
size="sm"
variant={downloadType === "url" ? "default" : "outline"}
onClick={() => setDownloadType("url")}
>
</Button>
</div>
{downloadType === "local" ? (
<div className="space-y-2">
<Label
htmlFor={`file-${v.id}`}
className="flex cursor-pointer items-center gap-2 rounded-lg border-2 border-dashed px-4 py-3 text-sm transition-colors hover:border-primary"
>
<Upload className="h-4 w-4" />
{uploading
? "上传中..."
: filePath
? "重新选择文件"
: "选择文件"}
</Label>
<Input
id={`file-${v.id}`}
type="file"
className="hidden"
onChange={handleFileUpload}
/>
{filePath && (
<p className="text-xs text-muted-foreground">
: {filePath} ({(fileSize / 1024).toFixed(1)} KB)
</p>
)}
</div>
) : (
<div className="space-y-2">
<Input
value={externalUrl}
onChange={(e) => setExternalUrl(e.target.value)}
placeholder="https://..."
/>
</div>
)}
</div>
<div className="flex flex-wrap gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={forceUpdate}
onChange={(e) => setForceUpdate(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={isLatest}
onChange={(e) => setIsLatest(e.target.checked)}
className="h-4 w-4 rounded border-input"
/>
</label>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={handleSave} disabled={saving}>
<Check className="mr-1.5 h-3.5 w-3.5" />
{saving ? "保存中..." : "保存"}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setEditing(false)}
>
</Button>
</div>
</div>
);
}