310 lines
15 KiB
TypeScript
310 lines
15 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useCallback } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { ArrowLeft, Plus, Trash2, Eye, EyeOff, Edit, Wifi, WifiOff, Loader2, X } from "lucide-react";
|
||
|
||
interface Link {
|
||
name: string;
|
||
url: string;
|
||
description?: string;
|
||
image?: string;
|
||
hidden?: boolean;
|
||
rss?: string;
|
||
addedAt?: string;
|
||
}
|
||
|
||
export default function LinksPage() {
|
||
const router = useRouter();
|
||
const [links, setLinks] = useState<Link[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [showAll, setShowAll] = useState(false);
|
||
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||
|
||
// Add form
|
||
const [showAdd, setShowAdd] = useState(false);
|
||
const [addForm, setAddForm] = useState({ name: "", url: "", description: "" });
|
||
const [adding, setAdding] = useState(false);
|
||
|
||
// Edit form
|
||
const [editTarget, setEditTarget] = useState<string | null>(null);
|
||
const [editForm, setEditForm] = useState({ name: "", url: "", description: "" });
|
||
const [editing, setEditing] = useState(false);
|
||
|
||
// Health
|
||
const [health, setHealth] = useState<Map<string, { ok: boolean; status?: number; latency?: number }>>(new Map());
|
||
const [checkingAll, setCheckingAll] = useState(false);
|
||
const [checkingMap, setCheckingMap] = useState<Map<string, boolean>>(new Map());
|
||
|
||
const fetchLinks = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const res = await fetch(`/api/rss/links?all=1`); // always get all for admin
|
||
const data = await res.json();
|
||
const all = data.links || [];
|
||
// Sort visible first, then hidden
|
||
all.sort((a: Link, b: Link) => (a.hidden ? 1 : 0) - (b.hidden ? 1 : 0));
|
||
setLinks(all);
|
||
} catch { setMessage({ type: "error", text: "加载失败" }); }
|
||
finally { setLoading(false); }
|
||
}, []);
|
||
|
||
useEffect(() => { fetchLinks(); }, [fetchLinks]);
|
||
|
||
const handleAdd = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!addForm.name || !addForm.url) return;
|
||
setAdding(true);
|
||
try {
|
||
const res = await fetch("/api/rss/links", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(addForm),
|
||
});
|
||
if (!res.ok) throw new Error("添加失败");
|
||
setShowAdd(false);
|
||
setAddForm({ name: "", url: "", description: "" });
|
||
fetchLinks();
|
||
} catch (err: unknown) {
|
||
setMessage({ type: "error", text: err instanceof Error ? err.message : "添加失败" });
|
||
} finally { setAdding(false); }
|
||
};
|
||
|
||
const startEdit = (l: Link) => {
|
||
setEditTarget(l.url);
|
||
setEditForm({ name: l.name, url: l.url, description: l.description || "" });
|
||
};
|
||
|
||
const handleEdit = async () => {
|
||
if (!editTarget) return;
|
||
setEditing(true);
|
||
try {
|
||
const body = {
|
||
oldUrl: editTarget,
|
||
name: editForm.name,
|
||
url: editForm.url,
|
||
description: editForm.description,
|
||
};
|
||
const res = await fetch("/api/rss/links", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (!res.ok) throw new Error("保存失败");
|
||
setEditTarget(null);
|
||
fetchLinks();
|
||
} catch (err: unknown) {
|
||
setMessage({ type: "error", text: err instanceof Error ? err.message : "保存失败" });
|
||
} finally { setEditing(false); }
|
||
};
|
||
|
||
const toggleHidden = async (l: Link) => {
|
||
try {
|
||
const res = await fetch("/api/rss/links", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
oldUrl: l.url,
|
||
name: l.name,
|
||
description: l.description || "",
|
||
hidden: !l.hidden,
|
||
}),
|
||
});
|
||
if (!res.ok) throw new Error("操作失败");
|
||
setLinks((prev) => prev.map((x) => x.url === l.url ? { ...x, hidden: !x.hidden } : x));
|
||
} catch (err: unknown) {
|
||
setMessage({ type: "error", text: err instanceof Error ? err.message : "操作失败" });
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (url: string, name: string) => {
|
||
if (!confirm(`确定删除「${name}」?`)) return;
|
||
try {
|
||
await fetch(`/api/rss/links?url=${encodeURIComponent(url)}`, { method: "DELETE" });
|
||
setLinks((prev) => prev.filter((l) => l.url !== url));
|
||
} catch { setMessage({ type: "error", text: "删除失败" }); }
|
||
};
|
||
|
||
const checkOne = async (url: string) => {
|
||
setCheckingMap((prev) => new Map(prev).set(url, true));
|
||
try {
|
||
const res = await fetch(`/api/rss/links?health=${encodeURIComponent(url)}`);
|
||
const data = await res.json();
|
||
setHealth((prev) => new Map(prev).set(url, data));
|
||
} catch {} finally {
|
||
setCheckingMap((prev) => { const m = new Map(prev); m.delete(url); return m; });
|
||
}
|
||
};
|
||
|
||
const checkAll = async () => {
|
||
setCheckingAll(true);
|
||
const visible = links.filter((l) => !l.hidden);
|
||
for (let i = 0; i < visible.length; i += 3) {
|
||
const batch = visible.slice(i, i + 3);
|
||
await Promise.all(batch.map((l) => checkOne(l.url)));
|
||
}
|
||
setCheckingAll(false);
|
||
};
|
||
|
||
// Counts for display
|
||
const visibleLinks = showAll ? links : links.filter((l) => !l.hidden);
|
||
const hiddenCount = links.filter((l) => l.hidden).length;
|
||
|
||
return (
|
||
<div className="px-4 py-6 md:px-8 md:py-8">
|
||
<div className="flex items-center justify-between mb-6 sm:mb-8 gap-3 flex-wrap">
|
||
<div className="flex items-center gap-3">
|
||
<button type="button" onClick={() => router.back()} className="p-1 -ml-1 rounded hover:bg-surface text-muted hover:text-text transition-colors">
|
||
<ArrowLeft size={16} />
|
||
</button>
|
||
<h2 className="text-lg font-bold text-text">友链管理</h2>
|
||
<span className="text-[13px] text-muted">{links.length} 个{hiddenCount > 0 && `(${hiddenCount} 隐藏)`}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button type="button" onClick={checkAll} disabled={checkingAll}
|
||
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md border border-line text-muted hover:text-accent transition-colors disabled:opacity-50">
|
||
{checkingAll ? <Loader2 size={13} className="animate-spin" /> : <Wifi size={13} />}
|
||
{checkingAll ? "检测中..." : "检测存活"}
|
||
</button>
|
||
<button type="button" onClick={() => setShowAll(!showAll)}
|
||
className={`flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md border transition-colors ${
|
||
showAll ? "bg-accent/5 border-accent/30 text-accent" : "border-line text-muted hover:text-accent"
|
||
}`}>
|
||
{showAll ? <Eye size={13} /> : <EyeOff size={13} />}
|
||
{showAll ? "全部" : "可见"}
|
||
</button>
|
||
<button type="button" onClick={() => setShowAdd(true)}
|
||
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-md bg-accent text-white hover:bg-accent-hover transition-colors">
|
||
<Plus size={13} /> 添加
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{message && (
|
||
<div className={`mb-4 px-4 py-2.5 rounded-md text-[13px] flex items-center justify-between ${
|
||
message.type === "success" ? "bg-[#d1fae5] text-[#065f46] border border-[#a7f3d0]" : "bg-[#fee2e2] text-[#991b1b] border border-[#fecaca]"
|
||
}`}>
|
||
{message.text}
|
||
<button type="button" onClick={() => setMessage(null)} className="underline">关闭</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Add form */}
|
||
{showAdd && (
|
||
<div className="mb-6 p-5 bg-card border border-line rounded-lg">
|
||
<form onSubmit={handleAdd} className="flex flex-col sm:flex-row gap-3 sm:items-end">
|
||
<div className="flex-1">
|
||
<label className="block text-[12px] font-medium text-text/70 mb-1.5">网站名称</label>
|
||
<input type="text" value={addForm.name} onChange={(e) => setAddForm({ ...addForm, name: e.target.value })}
|
||
className="w-full bg-bg border border-line rounded-md px-3 py-2 text-[13px] focus:outline-none focus:border-accent/30" required />
|
||
</div>
|
||
<div className="flex-[2]">
|
||
<label className="block text-[12px] font-medium text-text/70 mb-1.5">URL</label>
|
||
<input type="text" value={addForm.url} onChange={(e) => setAddForm({ ...addForm, url: e.target.value })}
|
||
className="w-full bg-bg border border-line rounded-md px-3 py-2 text-[13px] focus:outline-none focus:border-accent/30" required />
|
||
</div>
|
||
<div className="flex-1">
|
||
<label className="block text-[12px] font-medium text-text/70 mb-1.5">描述</label>
|
||
<input type="text" value={addForm.description} onChange={(e) => setAddForm({ ...addForm, description: e.target.value })}
|
||
className="w-full bg-bg border border-line rounded-md px-3 py-2 text-[13px] focus:outline-none focus:border-accent/30" />
|
||
</div>
|
||
<button type="submit" disabled={adding} className="px-4 py-2 text-[13px] font-medium rounded-md bg-accent text-white hover:bg-accent-hover disabled:opacity-50 shrink-0" title="添加">
|
||
{adding ? "..." : "添加"}
|
||
</button>
|
||
<button type="button" onClick={() => setShowAdd(false)} className="px-3 py-2 text-[13px] rounded-md border border-line text-muted hover:bg-surface shrink-0" title="取消">取消</button>
|
||
</form>
|
||
</div>
|
||
)}
|
||
|
||
{/* Loading */}
|
||
{loading ? (
|
||
<div className="text-center py-20 text-muted text-sm">加载中...</div>
|
||
) : visibleLinks.length === 0 ? (
|
||
<div className="text-center py-20 text-muted text-sm">暂无友链</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{visibleLinks.map((l) => {
|
||
const h = health.get(l.url);
|
||
const ck = checkingMap.get(l.url);
|
||
return (
|
||
<div key={l.url}
|
||
className={`flex items-center gap-4 px-5 py-3.5 border rounded-md group transition-all ${
|
||
l.hidden ? "bg-surface/50 border-line/50 opacity-60" : "bg-card border-line"
|
||
} ${editTarget === l.url ? "ring-2 ring-accent/20 border-accent" : ""}`}>
|
||
|
||
{/* Health indicator */}
|
||
{ck ? <Loader2 size={13} className="animate-spin text-muted shrink-0" />
|
||
: h ? (h.ok ? <span title={`${h.status} ${h.latency}ms`}><Wifi size={13} className="text-success shrink-0" /></span>
|
||
: <span title={h.status ? String(h.status) : "不可达"}><WifiOff size={13} className="text-danger shrink-0" /></span>)
|
||
: null}
|
||
|
||
{/* Favicon */}
|
||
{l.image && <img src={l.image} alt="" className="w-4 h-4 rounded shrink-0" onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />}
|
||
|
||
{/* Info or Edit form */}
|
||
{editTarget === l.url ? (
|
||
<div className="flex-1 flex flex-col sm:flex-row gap-2 sm:items-end">
|
||
<div className="flex-1">
|
||
<input type="text" value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||
className="w-full bg-bg border border-line rounded-md px-2 py-1.5 text-[13px] focus:outline-none focus:border-accent/30" title="名称" placeholder="名称" />
|
||
</div>
|
||
<div className="flex-[2]">
|
||
<input type="text" value={editForm.url} onChange={(e) => setEditForm({ ...editForm, url: e.target.value })}
|
||
className="w-full bg-bg border border-line rounded-md px-2 py-1.5 text-[13px] focus:outline-none focus:border-accent/30" title="URL" placeholder="URL" />
|
||
</div>
|
||
<div className="flex-1">
|
||
<input type="text" value={editForm.description} onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
|
||
className="w-full bg-bg border border-line rounded-md px-2 py-1.5 text-[13px] focus:outline-none focus:border-accent/30" placeholder="描述" title="描述" />
|
||
</div>
|
||
<button type="button" onClick={handleEdit} disabled={editing}
|
||
className="px-3 py-1.5 text-[12px] font-medium rounded-md bg-accent text-white hover:bg-accent-hover shrink-0" title="保存">{editing ? "..." : "保存"}</button>
|
||
<button type="button" onClick={() => setEditTarget(null)}
|
||
className="p-1.5 rounded text-muted hover:text-text" title="取消"><X size={14} /></button>
|
||
</div>
|
||
) : (
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-medium text-[14px] text-text">{l.name}</span>
|
||
{l.hidden && <span className="text-[10px] text-[#92400e] bg-[#fef3c7] px-1.5 py-px rounded font-medium">隐藏</span>}
|
||
{h && !h.ok && <span className="text-[10px] text-danger bg-danger/8 px-1.5 py-px rounded font-medium">失联</span>}
|
||
</div>
|
||
<div className="flex items-center gap-2 mt-0.5 text-[11px] text-muted">
|
||
<span className="truncate">{l.url}</span>
|
||
{l.description && <span className="text-line">·</span>}
|
||
{l.description && <span className="truncate">{l.description}</span>}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Actions */}
|
||
<div className="flex items-center gap-0.5 shrink-0">
|
||
{editTarget !== l.url && (
|
||
<>
|
||
<button type="button" onClick={() => checkOne(l.url)} className="p-1.5 rounded hover:bg-surface text-muted hover:text-accent transition-colors"
|
||
title="检测">
|
||
{checkingMap.get(l.url) ? <Loader2 size={13} className="animate-spin" /> : <Wifi size={13} />}
|
||
</button>
|
||
<button type="button" onClick={() => startEdit(l)} className="p-1.5 rounded hover:bg-surface text-muted hover:text-text transition-colors"
|
||
title="编辑">
|
||
<Edit size={13} />
|
||
</button>
|
||
<button type="button" onClick={() => toggleHidden(l)} className="p-1.5 rounded hover:bg-surface text-muted hover:text-text transition-colors"
|
||
title={l.hidden ? "显示" : "隐藏"}>
|
||
{l.hidden ? <Eye size={13} /> : <EyeOff size={13} />}
|
||
</button>
|
||
<button type="button" onClick={() => handleDelete(l.url, l.name)} className="p-1.5 rounded hover:bg-danger/8 text-muted hover:text-danger transition-colors"
|
||
title="删除">
|
||
<Trash2 size={13} />
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|