added:增加Linux-server
This commit is contained in:
1 parent
6fe4e9d23f
commit
d78f3f811b
90 files changed
+16784
-1
No files matched your search
@@ -0,0 +1,234 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft, Plus, Trash2, Loader2, Wifi, WifiOff, Edit, X } from "lucide-react";
|
||||
|
||||
interface Feed {
|
||||
url: string;
|
||||
feedTitle: string;
|
||||
siteUrl?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface FeedHealth {
|
||||
url: string;
|
||||
status: number;
|
||||
ok: boolean;
|
||||
latency?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function FeedsPage() {
|
||||
const router = useRouter();
|
||||
const [feeds, setFeeds] = useState<Feed[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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 [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
|
||||
// Add form
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [addForm, setAddForm] = useState({ url: "", feedTitle: "" });
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
// Edit
|
||||
const [editTarget, setEditTarget] = useState<string | null>(null);
|
||||
const [editForm, setEditForm] = useState({ url: "", feedTitle: "" });
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const fetchFeeds = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/rss/feeds");
|
||||
const data = await res.json();
|
||||
setFeeds(data.feeds || []);
|
||||
} catch { setMessage({ type: "error", text: "加载失败" }); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchFeeds(); }, [fetchFeeds]);
|
||||
|
||||
const checkOne = async (url: string) => {
|
||||
setCheckingMap((prev) => new Map(prev).set(url, true));
|
||||
try {
|
||||
const res = await fetch(`/api/rss/feeds?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);
|
||||
for (let i = 0; i < feeds.length; i += 3) {
|
||||
const batch = feeds.slice(i, i + 3);
|
||||
await Promise.all(batch.map((f) => checkOne(f.url)));
|
||||
}
|
||||
setCheckingAll(false);
|
||||
};
|
||||
|
||||
const handleAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!addForm.url) return;
|
||||
setAdding(true);
|
||||
try {
|
||||
const res = await fetch("/api/rss/feeds", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(addForm),
|
||||
});
|
||||
if (!res.ok) throw new Error("添加失败");
|
||||
setShowAdd(false);
|
||||
setAddForm({ url: "", feedTitle: "" });
|
||||
fetchFeeds();
|
||||
} catch (err: unknown) {
|
||||
setMessage({ type: "error", text: err instanceof Error ? err.message : "添加失败" });
|
||||
} finally { setAdding(false); }
|
||||
};
|
||||
|
||||
const startEdit = (f: Feed) => {
|
||||
setEditTarget(f.url);
|
||||
setEditForm({ url: f.url, feedTitle: f.feedTitle });
|
||||
};
|
||||
|
||||
const handleEdit = async () => {
|
||||
if (!editTarget) return;
|
||||
setEditing(true);
|
||||
try {
|
||||
const res = await fetch("/api/rss/feeds", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ oldUrl: editTarget, url: editForm.url, feedTitle: editForm.feedTitle }),
|
||||
});
|
||||
if (!res.ok) throw new Error("保存失败");
|
||||
setEditTarget(null);
|
||||
fetchFeeds();
|
||||
} catch (err: unknown) {
|
||||
setMessage({ type: "error", text: err instanceof Error ? err.message : "保存失败" });
|
||||
} finally { setEditing(false); }
|
||||
};
|
||||
|
||||
const handleDelete = async (url: string, title: string) => {
|
||||
if (!confirm(`确定删除「${title}」?`)) return;
|
||||
try {
|
||||
await fetch(`/api/rss/feeds?url=${encodeURIComponent(url)}`, { method: "DELETE" });
|
||||
setFeeds((prev) => prev.filter((f) => f.url !== url));
|
||||
} catch { setMessage({ type: "error", text: "删除失败" }); }
|
||||
};
|
||||
|
||||
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">{feeds.length} 个</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={() => 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>
|
||||
)}
|
||||
|
||||
{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.feedTitle} onChange={(e) => setAddForm({ ...addForm, feedTitle: 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"
|
||||
placeholder="例如:刘郎阁" />
|
||||
</div>
|
||||
<div className="flex-[2]">
|
||||
<label className="block text-[12px] font-medium text-text/70 mb-1.5">RSS/站点 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"
|
||||
placeholder="https://example.com" required />
|
||||
</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 ? (
|
||||
<div className="text-center py-20 text-muted text-sm">加载中...</div>
|
||||
) : feeds.length === 0 ? (
|
||||
<div className="text-center py-20 text-muted text-sm">暂无订阅源</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{feeds.map((f) => {
|
||||
const h = health.get(f.url);
|
||||
const ck = checkingMap.get(f.url);
|
||||
return (
|
||||
<div key={f.url} className={`flex items-center gap-4 px-5 py-3.5 bg-card border rounded-md group transition-all ${
|
||||
editTarget === f.url ? "ring-2 ring-accent/20 border-accent" : "border-line"
|
||||
}`}>
|
||||
{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}
|
||||
|
||||
{editTarget === f.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.feedTitle} onChange={(e) => setEditForm({ ...editForm, feedTitle: 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>
|
||||
<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="font-medium text-[14px] text-text truncate">{f.feedTitle || f.url}</div>
|
||||
<div className="text-[11px] text-muted truncate mt-0.5">{f.url}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editTarget !== f.url && (
|
||||
<div className="flex items-center gap-0.5 shrink-0 md:opacity-0 md:group-hover:opacity-100 transition-all">
|
||||
<button type="button" onClick={() => startEdit(f)} className="p-1.5 rounded hover:bg-surface text-muted hover:text-text transition-colors" title="编辑">
|
||||
<Edit size={13} />
|
||||
</button>
|
||||
<button type="button" onClick={() => handleDelete(f.url, f.feedTitle)} 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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user