2026-05-21 00:27:35 +08:00
|
|
|
|
"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-8 py-8">
|
|
|
|
|
|
<div className="flex items-center justify-between mb-8">
|
|
|
|
|
|
<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 && (
|
2026-05-24 12:17:43 +08:00
|
|
|
|
<div className="mb-6 p-5 bg-card border border-line rounded-lg">
|
2026-05-21 00:27:35 +08:00
|
|
|
|
<form onSubmit={handleAdd} className="flex gap-3 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 ${
|
2026-05-24 12:17:43 +08:00
|
|
|
|
l.hidden ? "bg-surface/50 border-line/50 opacity-60" : "bg-card border-line"
|
2026-05-21 00:27:35 +08:00
|
|
|
|
} ${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 gap-2 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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|