feat: 站点媒体库与移动端底部导航,服务测试补强
- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面 - 移动端底部导航 MobileTabBar 替换 MobilePostBar - 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强 - site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
This commit is contained in:
@@ -88,11 +88,11 @@ export default function AdsAdmin({
|
||||
enabled: true,
|
||||
panel_title: "自助推广",
|
||||
durations: [
|
||||
{ days: 30, label: "1 个月", price_hint: "" },
|
||||
{ days: 60, label: "2 个月", price_hint: "" },
|
||||
{ days: 90, label: "3 个月", price_hint: "" },
|
||||
{ days: 180, label: "6 个月", price_hint: "" },
|
||||
{ days: 365, label: "12 个月", price_hint: "" },
|
||||
{ days: 30, label: "1 个月", price_hint: "", price_hint_text: "" },
|
||||
{ days: 60, label: "2 个月", price_hint: "", price_hint_text: "" },
|
||||
{ days: 90, label: "3 个月", price_hint: "", price_hint_text: "" },
|
||||
{ days: 180, label: "6 个月", price_hint: "", price_hint_text: "" },
|
||||
{ days: 365, label: "12 个月", price_hint: "", price_hint_text: "" },
|
||||
],
|
||||
payments: [],
|
||||
}
|
||||
@@ -489,7 +489,10 @@ export default function AdsAdmin({
|
||||
onClick={() =>
|
||||
setCfg({
|
||||
...cfg,
|
||||
durations: [...cfg.durations, { days: 14, label: "14 天", price_hint: "" }],
|
||||
durations: [
|
||||
...cfg.durations,
|
||||
{ days: 14, label: "14 天", price_hint: "", price_hint_text: "" },
|
||||
],
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -500,43 +503,90 @@ export default function AdsAdmin({
|
||||
{cfg.durations.length === 0 ? (
|
||||
<p className="meta text-[13px]">至少保留一个时长档位。</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{cfg.durations.map((d, i) => (
|
||||
<div key={i} className="j13-ads-row">
|
||||
<input
|
||||
type="number"
|
||||
className="field w-20"
|
||||
value={d.days}
|
||||
min={1}
|
||||
onChange={(e) => {
|
||||
const durations = [...cfg.durations];
|
||||
durations[i] = { ...d, days: Number(e.target.value) || 1 };
|
||||
setCfg({ ...cfg, durations });
|
||||
}}
|
||||
aria-label="天数"
|
||||
/>
|
||||
<input
|
||||
className="field min-w-0 flex-1"
|
||||
value={d.label}
|
||||
placeholder="标签,如 1 个月"
|
||||
onChange={(e) => {
|
||||
const durations = [...cfg.durations];
|
||||
durations[i] = { ...d, label: e.target.value };
|
||||
setCfg({ ...cfg, durations });
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
aria-label="删除档位"
|
||||
onClick={() =>
|
||||
setCfg({ ...cfg, durations: cfg.durations.filter((_, j) => j !== i) })
|
||||
}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="overflow-x-auto rounded-xl border" style={{ borderColor: "var(--line)" }}>
|
||||
<table className="w-full min-w-[600px] text-left text-[13px]">
|
||||
<thead>
|
||||
<tr className="meta text-[12px]" style={{ background: "var(--surface)" }}>
|
||||
<th className="px-3 py-2 font-medium">天数</th>
|
||||
<th className="px-3 py-2 font-medium">标签</th>
|
||||
<th className="px-3 py-2 font-medium">图片价格</th>
|
||||
<th className="px-3 py-2 font-medium">文字价格</th>
|
||||
<th className="px-3 py-2 text-right font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cfg.durations.map((d, i) => (
|
||||
<tr key={i} style={{ borderTop: "1px solid var(--line)" }}>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
type="number"
|
||||
className="field w-20"
|
||||
value={d.days}
|
||||
min={1}
|
||||
onChange={(e) => {
|
||||
const durations = [...cfg.durations];
|
||||
durations[i] = { ...d, days: Number(e.target.value) || 1 };
|
||||
setCfg({ ...cfg, durations });
|
||||
}}
|
||||
aria-label="天数"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
className="field w-full min-w-0"
|
||||
value={d.label}
|
||||
placeholder="如 1 个月"
|
||||
onChange={(e) => {
|
||||
const durations = [...cfg.durations];
|
||||
durations[i] = { ...d, label: e.target.value };
|
||||
setCfg({ ...cfg, durations });
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
className="field w-full min-w-0"
|
||||
value={d.price_hint}
|
||||
maxLength={32}
|
||||
placeholder="如 ¥299,可空"
|
||||
onChange={(e) => {
|
||||
const durations = [...cfg.durations];
|
||||
durations[i] = { ...d, price_hint: e.target.value };
|
||||
setCfg({ ...cfg, durations });
|
||||
}}
|
||||
aria-label="图片广告价格"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
className="field w-full min-w-0"
|
||||
value={d.price_hint_text}
|
||||
maxLength={32}
|
||||
placeholder="如 ¥99,可空"
|
||||
onChange={(e) => {
|
||||
const durations = [...cfg.durations];
|
||||
durations[i] = { ...d, price_hint_text: e.target.value };
|
||||
setCfg({ ...cfg, durations });
|
||||
}}
|
||||
aria-label="文字广告价格"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
aria-label="删除档位"
|
||||
onClick={() =>
|
||||
setCfg({ ...cfg, durations: cfg.durations.filter((_, j) => j !== i) })
|
||||
}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</SubBlock>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Pencil, Plus, Trash2, Users } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Award, ChevronRight, Plus, Trash2, Users } from "lucide-react";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import BadgeChip from "@/components/BadgeChip";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import Modal from "@/components/Modal";
|
||||
import AdminEmpty from "@/components/admin/AdminEmpty";
|
||||
import AdminPageHeader from "@/components/admin/AdminPageHeader";
|
||||
import { BADGE_ICONS } from "@/lib/badgeIcons";
|
||||
import { toast } from "@/lib/toast";
|
||||
import {
|
||||
@@ -32,8 +33,8 @@ export default function BadgesClient() {
|
||||
const [badges, setBadges] = useState<BadgeWithHolders[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 编辑/新建表单
|
||||
const [editing, setEditing] = useState<BadgeWithHolders | null>(null);
|
||||
// 主从布局:左侧列表选中项 / 右侧详情(编辑表单 + 持有者)
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [form, setForm] = useState<BadgeInput>({ name: "", icon: "medal", color: "accent" });
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -43,7 +44,6 @@ export default function BadgesClient() {
|
||||
const [deletingBusy, setDeletingBusy] = useState(false);
|
||||
|
||||
// 持有者面板
|
||||
const [holdersFor, setHoldersFor] = useState<BadgeWithHolders | null>(null);
|
||||
const [holders, setHolders] = useState<BadgeHolder[]>([]);
|
||||
const [holdersLoading, setHoldersLoading] = useState(false);
|
||||
const [userQuery, setUserQuery] = useState("");
|
||||
@@ -51,6 +51,8 @@ export default function BadgesClient() {
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [grantingId, setGrantingId] = useState<number | null>(null);
|
||||
|
||||
const detailRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiAdminListBadges();
|
||||
@@ -66,22 +68,85 @@ export default function BadgesClient() {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const selected = useMemo(
|
||||
() => badges.find((b) => b.id === selectedId) ?? null,
|
||||
[badges, selectedId],
|
||||
);
|
||||
|
||||
const openCreate = () => {
|
||||
setForm({ name: "", icon: "medal", color: "accent" });
|
||||
setEditing(null);
|
||||
setCreating(true);
|
||||
setSelectedId(null);
|
||||
};
|
||||
|
||||
const openEdit = (b: BadgeWithHolders) => {
|
||||
setForm({ name: b.name, icon: b.icon || "medal", color: b.color || "accent" });
|
||||
const selectBadge = (b: BadgeWithHolders, scroll = true) => {
|
||||
setCreating(false);
|
||||
setEditing(b);
|
||||
setSelectedId(b.id);
|
||||
// 窄屏单列时,点选后滚动到右侧详情
|
||||
if (scroll && window.matchMedia("(max-width: 1023px)").matches) {
|
||||
detailRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
};
|
||||
|
||||
const closeForm = () => {
|
||||
setCreating(false);
|
||||
setEditing(null);
|
||||
};
|
||||
// 首次加载完成后自动选中第一个徽章(删除刷新期间跳过,避免选中已删除项)
|
||||
useEffect(() => {
|
||||
if (loading || creating || deletingBusy || selectedId != null) return;
|
||||
if (badges.length > 0) selectBadge(badges[0], false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loading, badges, creating, deletingBusy, selectedId]);
|
||||
|
||||
// 选中项被删除时回落到第一个(或清空)
|
||||
useEffect(() => {
|
||||
if (creating || loading || selectedId == null) return;
|
||||
if (!badges.some((b) => b.id === selectedId)) {
|
||||
setSelectedId(badges.length > 0 ? badges[0].id : null);
|
||||
}
|
||||
}, [badges, selectedId, creating, loading]);
|
||||
|
||||
// 选中变化:同步表单草稿并重置搜索(badges 刻意不作为依赖,避免授予/撤销刷新时清掉搜索结果与未保存草稿)
|
||||
useEffect(() => {
|
||||
if (creating || selectedId == null) return;
|
||||
const sel = badges.find((b) => b.id === selectedId);
|
||||
if (!sel) return;
|
||||
setForm({ name: sel.name, icon: sel.icon || "medal", color: sel.color || "accent" });
|
||||
setUserQuery("");
|
||||
setSearchResults([]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedId, creating]);
|
||||
|
||||
// 持有者加载:选中变化或徽章列表刷新(授予/撤销后)重新拉取
|
||||
useEffect(() => {
|
||||
if (creating || selectedId == null) {
|
||||
setHolders([]);
|
||||
setHoldersLoading(false);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
setHoldersLoading(true);
|
||||
apiAdminBadgeHolders(selectedId)
|
||||
.then((res) => {
|
||||
if (alive) setHolders(res.holders ?? []);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) {
|
||||
toast(e instanceof Error ? e.message : "获取持有者失败");
|
||||
setHolders([]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setHoldersLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [selectedId, creating, badges]);
|
||||
|
||||
const formDirty =
|
||||
creating ||
|
||||
(selected != null &&
|
||||
(form.name !== selected.name ||
|
||||
form.icon !== (selected.icon || "medal") ||
|
||||
form.color !== (selected.color || "accent")));
|
||||
|
||||
const saveForm = async () => {
|
||||
if (saving) return;
|
||||
@@ -91,14 +156,15 @@ export default function BadgesClient() {
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
await apiAdminUpdateBadge(editing.id, form);
|
||||
toast("徽章已更新", "ok");
|
||||
} else {
|
||||
await apiAdminCreateBadge(form);
|
||||
if (creating) {
|
||||
const created = await apiAdminCreateBadge(form);
|
||||
toast("徽章已创建", "ok");
|
||||
setCreating(false);
|
||||
setSelectedId(created.id);
|
||||
} else if (selected) {
|
||||
await apiAdminUpdateBadge(selected.id, form);
|
||||
toast("徽章已更新", "ok");
|
||||
}
|
||||
closeForm();
|
||||
await reload();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
@@ -114,7 +180,7 @@ export default function BadgesClient() {
|
||||
await apiAdminDeleteBadge(deleting.id);
|
||||
toast("徽章已删除", "ok");
|
||||
setDeleting(null);
|
||||
if (holdersFor?.id === deleting.id) setHoldersFor(null);
|
||||
if (selectedId === deleting.id) setSelectedId(null);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "删除失败");
|
||||
@@ -123,22 +189,6 @@ export default function BadgesClient() {
|
||||
}
|
||||
};
|
||||
|
||||
const openHolders = async (b: BadgeWithHolders) => {
|
||||
setHoldersFor(b);
|
||||
setUserQuery("");
|
||||
setSearchResults([]);
|
||||
setHoldersLoading(true);
|
||||
try {
|
||||
const res = await apiAdminBadgeHolders(b.id);
|
||||
setHolders(res.holders ?? []);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "获取持有者失败");
|
||||
setHolders([]);
|
||||
} finally {
|
||||
setHoldersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const searchUsers = async () => {
|
||||
const q = userQuery.trim();
|
||||
if (!q || searching) return;
|
||||
@@ -161,12 +211,11 @@ export default function BadgesClient() {
|
||||
};
|
||||
|
||||
const grant = async (userId: number) => {
|
||||
if (!holdersFor || grantingId != null) return;
|
||||
if (!selected || grantingId != null) return;
|
||||
setGrantingId(userId);
|
||||
try {
|
||||
await apiAdminGrantBadge(holdersFor.id, userId);
|
||||
await apiAdminGrantBadge(selected.id, userId);
|
||||
toast("已授予", "ok");
|
||||
await openHolders(holdersFor);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "授予失败");
|
||||
@@ -176,254 +225,335 @@ export default function BadgesClient() {
|
||||
};
|
||||
|
||||
const revoke = async (userId: number) => {
|
||||
if (!holdersFor) return;
|
||||
if (!selected) return;
|
||||
try {
|
||||
await apiAdminRevokeBadge(holdersFor.id, userId);
|
||||
await apiAdminRevokeBadge(selected.id, userId);
|
||||
toast("已撤销", "ok");
|
||||
await openHolders(holdersFor);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "撤销失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 授予/撤销后 reload 刷新持有数;这里再同步持有者列表
|
||||
useEffect(() => {
|
||||
if (selectedId == null || creating) return;
|
||||
setHoldersLoading(true);
|
||||
apiAdminBadgeHolders(selectedId)
|
||||
.then((res) => setHolders(res.holders ?? []))
|
||||
.catch(() => setHolders([]))
|
||||
.finally(() => setHoldersLoading(false));
|
||||
}, [badges, selectedId, creating]);
|
||||
|
||||
const holdersHeldIds = useMemo(() => new Set(holders.map((h) => h.user_id)), [holders]);
|
||||
|
||||
const nameOk = runeCount(form.name.trim()) >= 1 && runeCount(form.name.trim()) <= MAX_NAME_RUNES;
|
||||
|
||||
return (
|
||||
<div className="admin-settings-stack">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold" style={{ color: "var(--ink)" }}>
|
||||
徽章管理
|
||||
</h2>
|
||||
<p className="meta text-sm mt-1">
|
||||
管理员自定义徽章:全站作者处展示前 2 枚,个人主页展示徽章墙;授予后用户会收到站内通知。
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={openCreate}>
|
||||
<Plus size={14} /> 新建徽章
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="meta px-1 py-6">加载徽章列表…</p>
|
||||
) : badges.length === 0 ? (
|
||||
<div className="panel text-center py-12">
|
||||
<p className="meta">还没有徽章,点击右上角“新建徽章”开始。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="panel p-0 overflow-hidden">
|
||||
{badges.map((b, i) => (
|
||||
<div
|
||||
key={b.id}
|
||||
className="flex items-center gap-3 px-4 py-3 flex-wrap"
|
||||
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
|
||||
>
|
||||
<BadgeChip badge={b} />
|
||||
<span className="meta text-xs">
|
||||
{b.icon || "medal"} · {b.color || "accent"} · {b.holders} 人持有
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
onClick={() => void openHolders(b)}
|
||||
>
|
||||
<Users size={13} /> 持有者
|
||||
</button>
|
||||
<button type="button" className="btn btn-line btn-sm" onClick={() => openEdit(b)}>
|
||||
<Pencil size={13} /> 编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
style={{ color: "var(--danger)" }}
|
||||
onClick={() => setDeleting(b)}
|
||||
>
|
||||
<Trash2 size={13} /> 删除
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 持有者面板 */}
|
||||
<Modal
|
||||
open={holdersFor != null}
|
||||
title={`持有者 · ${holdersFor?.name ?? ""}`}
|
||||
onClose={() => setHoldersFor(null)}
|
||||
maxWidthClass="max-w-lg"
|
||||
>
|
||||
<div className="flex gap-2 mb-4">
|
||||
<input
|
||||
value={userQuery}
|
||||
onChange={(e) => setUserQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void searchUsers();
|
||||
}}
|
||||
placeholder="输入用户名或昵称搜索"
|
||||
className="flex-1 min-w-0"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm shrink-0"
|
||||
disabled={searching || !userQuery.trim()}
|
||||
onClick={() => void searchUsers()}
|
||||
>
|
||||
{searching ? "搜索中…" : "搜索"}
|
||||
<AdminPageHeader
|
||||
icon={Award}
|
||||
title="徽章管理"
|
||||
description="管理员自定义徽章:全站作者处展示前 2 枚,个人主页展示徽章墙;授予后用户会收到站内通知。"
|
||||
actions={
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={openCreate}>
|
||||
<Plus size={14} /> 新建徽章
|
||||
</button>
|
||||
</div>
|
||||
{searchResults.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="meta text-xs mb-1.5">搜索结果</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{searchResults.map((u) => (
|
||||
<div key={u.id} className="flex items-center gap-2.5 px-2 py-1.5 rounded-lg" style={{ background: "var(--panel-2)" }}>
|
||||
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={26} />
|
||||
<span className="text-sm truncate" style={{ color: "var(--ink)" }}>
|
||||
{u.nickname || u.username}
|
||||
<span className="meta ml-1">@{u.username}</span>
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
{holdersHeldIds.has(u.id) ? (
|
||||
<span className="meta text-xs">已持有</span>
|
||||
) : (
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-5 items-start lg:grid-cols-2">
|
||||
{/* 左半屏:徽章列表 */}
|
||||
<section className="panel p-0 overflow-hidden" aria-label="徽章列表">
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 px-4 py-3"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
>
|
||||
<p className="text-[13.5px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||
全部徽章
|
||||
</p>
|
||||
{!loading && badges.length > 0 && (
|
||||
<span className="meta text-xs tabular-nums">{badges.length} 枚</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="meta px-4 py-6">加载徽章列表…</p>
|
||||
) : badges.length === 0 ? (
|
||||
<div className="px-4 py-12 text-center">
|
||||
<p className="meta">还没有徽章,点击右上角“新建徽章”开始。</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul>
|
||||
{badges.map((b, i) => {
|
||||
const active = !creating && b.id === selectedId;
|
||||
return (
|
||||
<li key={b.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={grantingId === u.id}
|
||||
onClick={() => void grant(u.id)}
|
||||
onClick={() => selectBadge(b)}
|
||||
aria-current={active ? "true" : undefined}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
|
||||
style={{
|
||||
borderTop: i === 0 ? "none" : "1px solid var(--line)",
|
||||
background: active ? "var(--accent-soft)" : undefined,
|
||||
}}
|
||||
>
|
||||
{grantingId === u.id ? "授予中…" : "授予"}
|
||||
<BadgeChip badge={b} />
|
||||
<span className="meta text-xs">{b.holders} 人持有</span>
|
||||
<span className="flex-1" />
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className="shrink-0 transition-transform duration-200"
|
||||
style={{
|
||||
color: active ? "var(--accent)" : "var(--ink-3)",
|
||||
transform: active ? "translateX(2px)" : "none",
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 右半屏:编辑表单 + 持有者 */}
|
||||
<div ref={detailRef} className="flex flex-col gap-5 min-w-0">
|
||||
{creating || selected ? (
|
||||
<>
|
||||
{/* 编辑/新建表单 */}
|
||||
<section className="panel overflow-hidden">
|
||||
<div
|
||||
className="flex items-center justify-between gap-3 px-4 py-3.5 sm:px-5"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
>
|
||||
<p className="text-[15px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||
{creating ? "新建徽章" : "编辑徽章"}
|
||||
</p>
|
||||
{!creating && selected && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
style={{ color: "var(--danger)" }}
|
||||
onClick={() => setDeleting(selected)}
|
||||
>
|
||||
<Trash2 size={13} /> 删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="meta text-xs mb-1.5">当前持有者({holders.length})</p>
|
||||
{holdersLoading ? (
|
||||
<p className="meta text-sm py-3">加载持有者…</p>
|
||||
) : holders.length === 0 ? (
|
||||
<p className="meta text-sm py-3">暂无人持有。</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 max-h-64 overflow-y-auto">
|
||||
{holders.map((h) => (
|
||||
<div key={h.user_id} className="flex items-center gap-2.5 px-2 py-1.5 rounded-lg" style={{ background: "var(--panel-2)" }}>
|
||||
<span className="text-sm truncate" style={{ color: "var(--ink)" }}>
|
||||
{h.nickname || h.username}
|
||||
<span className="meta ml-1">@{h.username}</span>
|
||||
</span>
|
||||
<span className="meta text-xs">{new Date(h.awarded_at).toLocaleDateString("zh-CN")} 授予</span>
|
||||
<span className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
style={{ color: "var(--danger)" }}
|
||||
onClick={() => void revoke(h.user_id)}
|
||||
<div className="flex flex-col gap-4 px-4 py-4 sm:px-5">
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1.5" style={{ color: "var(--ink)" }}>
|
||||
名称(1–{MAX_NAME_RUNES} 字)
|
||||
</label>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
maxLength={MAX_NAME_RUNES * 2}
|
||||
placeholder="如:元老、技术之星"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1.5" style={{ color: "var(--ink)" }}>
|
||||
图标
|
||||
</label>
|
||||
<div className="grid grid-cols-8 gap-1.5">
|
||||
{Object.keys(BADGE_ICONS).map((key) => {
|
||||
const Icon = BADGE_ICONS[key];
|
||||
const active = form.icon === key;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-label={key}
|
||||
title={key}
|
||||
onClick={() => setForm((f) => ({ ...f, icon: key }))}
|
||||
className="h-9 rounded-lg inline-flex items-center justify-center transition-colors"
|
||||
style={{
|
||||
background: active ? "var(--accent-soft)" : "var(--panel-2)",
|
||||
color: active ? "var(--accent)" : "var(--ink-2)",
|
||||
outline: active ? "1.5px solid var(--accent)" : "none",
|
||||
}}
|
||||
>
|
||||
<Icon size={16} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1.5" style={{ color: "var(--ink)" }}>
|
||||
颜色
|
||||
</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{BADGE_COLORS.map((c) => {
|
||||
const active = form.color === c;
|
||||
return (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
aria-label={c}
|
||||
title={c}
|
||||
onClick={() => setForm((f) => ({ ...f, color: c }))}
|
||||
className="px-3 h-8 rounded-full text-xs font-medium transition-colors"
|
||||
style={{
|
||||
background: `var(--${c}-soft)`,
|
||||
color: `var(--${c})`,
|
||||
outline: active ? "1.5px solid var(--ink)" : "1.5px solid transparent",
|
||||
}}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="meta text-xs mb-1.5">预览</p>
|
||||
<BadgeChip badge={{ name: form.name.trim() || "徽章名称", icon: form.icon, color: form.color }} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex justify-end gap-2 px-4 py-3 sm:px-5"
|
||||
style={{ borderTop: "1px solid var(--line)" }}
|
||||
>
|
||||
撤销
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
{creating ? (
|
||||
<button type="button" className="btn btn-line btn-sm" onClick={() => setCreating(false)}>
|
||||
取消
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={!formDirty || saving}
|
||||
onClick={() => selected && setForm({ name: selected.name, icon: selected.icon || "medal", color: selected.color || "accent" })}
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={saving || !nameOk || (!creating && !formDirty)}
|
||||
onClick={() => void saveForm()}
|
||||
>
|
||||
{saving ? "保存中…" : creating ? "创建" : "保存修改"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 新建/编辑表单 */}
|
||||
<Modal
|
||||
open={creating || editing != null}
|
||||
title={editing ? "编辑徽章" : "新建徽章"}
|
||||
onClose={closeForm}
|
||||
maxWidthClass="max-w-md"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1.5" style={{ color: "var(--ink)" }}>
|
||||
名称(1–{MAX_NAME_RUNES} 字)
|
||||
</label>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
maxLength={MAX_NAME_RUNES * 2}
|
||||
placeholder="如:元老、技术之星"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1.5" style={{ color: "var(--ink)" }}>
|
||||
图标
|
||||
</label>
|
||||
<div className="grid grid-cols-8 gap-1.5">
|
||||
{Object.keys(BADGE_ICONS).map((key) => {
|
||||
const Icon = BADGE_ICONS[key];
|
||||
const active = form.icon === key;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-label={key}
|
||||
title={key}
|
||||
onClick={() => setForm((f) => ({ ...f, icon: key }))}
|
||||
className="h-9 rounded-lg inline-flex items-center justify-center transition-colors"
|
||||
style={{
|
||||
background: active ? "var(--accent-soft)" : "var(--panel-2)",
|
||||
color: active ? "var(--accent)" : "var(--ink-2)",
|
||||
outline: active ? "1.5px solid var(--accent)" : "none",
|
||||
}}
|
||||
{/* 持有者管理 */}
|
||||
{!creating && selected && (
|
||||
<section className="panel overflow-hidden">
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-4 py-3.5 sm:px-5"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
>
|
||||
<Icon size={16} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1.5" style={{ color: "var(--ink)" }}>
|
||||
颜色
|
||||
</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{BADGE_COLORS.map((c) => {
|
||||
const active = form.color === c;
|
||||
return (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
aria-label={c}
|
||||
title={c}
|
||||
onClick={() => setForm((f) => ({ ...f, color: c }))}
|
||||
className="px-3 h-8 rounded-full text-xs font-medium transition-colors"
|
||||
style={{
|
||||
background: `var(--${c}-soft)`,
|
||||
color: `var(--${c})`,
|
||||
outline: active ? "1.5px solid var(--ink)" : "1.5px solid transparent",
|
||||
}}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="meta text-xs mb-1.5">预览</p>
|
||||
<BadgeChip badge={{ name: form.name.trim() || "徽章名称", icon: form.icon, color: form.color }} />
|
||||
</div>
|
||||
<span
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
aria-hidden
|
||||
>
|
||||
<Users size={16} />
|
||||
</span>
|
||||
<p className="text-[15px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||
持有者
|
||||
<span className="meta ml-2 text-[12px] font-normal tabular-nums">{holders.length} 人</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-4 py-4 sm:px-5">
|
||||
<div className="flex gap-2 mb-4">
|
||||
<input
|
||||
value={userQuery}
|
||||
onChange={(e) => setUserQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void searchUsers();
|
||||
}}
|
||||
placeholder="输入用户名或昵称搜索"
|
||||
className="flex-1 min-w-0"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm shrink-0"
|
||||
disabled={searching || !userQuery.trim()}
|
||||
onClick={() => void searchUsers()}
|
||||
>
|
||||
{searching ? "搜索中…" : "搜索"}
|
||||
</button>
|
||||
</div>
|
||||
{searchResults.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="meta text-xs mb-1.5">搜索结果</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{searchResults.map((u) => (
|
||||
<div key={u.id} className="flex items-center gap-2.5 px-2 py-1.5 rounded-lg" style={{ background: "var(--panel-2)" }}>
|
||||
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={26} />
|
||||
<span className="text-sm truncate" style={{ color: "var(--ink)" }}>
|
||||
{u.nickname || u.username}
|
||||
<span className="meta ml-1">@{u.username}</span>
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
{holdersHeldIds.has(u.id) ? (
|
||||
<span className="meta text-xs">已持有</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={grantingId === u.id}
|
||||
onClick={() => void grant(u.id)}
|
||||
>
|
||||
{grantingId === u.id ? "授予中…" : "授予"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="meta text-xs mb-1.5">当前持有者({holders.length})</p>
|
||||
{holdersLoading ? (
|
||||
<p className="meta text-sm py-3">加载持有者…</p>
|
||||
) : holders.length === 0 ? (
|
||||
<p className="meta text-sm py-3">暂无人持有。</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 max-h-72 overflow-y-auto">
|
||||
{holders.map((h) => (
|
||||
<div key={h.user_id} className="flex items-center gap-2.5 px-2 py-1.5 rounded-lg" style={{ background: "var(--panel-2)" }}>
|
||||
<span className="text-sm truncate" style={{ color: "var(--ink)" }}>
|
||||
{h.nickname || h.username}
|
||||
<span className="meta ml-1">@{h.username}</span>
|
||||
</span>
|
||||
<span className="meta text-xs">{new Date(h.awarded_at).toLocaleDateString("zh-CN")} 授予</span>
|
||||
<span className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
style={{ color: "var(--danger)" }}
|
||||
onClick={() => void revoke(h.user_id)}
|
||||
>
|
||||
撤销
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<section className="panel">
|
||||
<AdminEmpty
|
||||
icon={<Award size={22} />}
|
||||
title={loading ? "加载中…" : "从左侧选择一个徽章"}
|
||||
description={loading ? undefined : "选中后可在此编辑名称、图标与颜色,管理持有者"}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-5">
|
||||
<button type="button" className="btn btn-line btn-sm" onClick={closeForm}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={saving || runeCount(form.name.trim()) === 0}
|
||||
onClick={() => void saveForm()}
|
||||
>
|
||||
{saving ? "保存中…" : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
{/* 删除确认 */}
|
||||
<ConfirmDialog
|
||||
|
||||
274
frontend/app/admin/media/MediaLibrary.tsx
Normal file
274
frontend/app/admin/media/MediaLibrary.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Images, Copy, ExternalLink, Eye, Check } from "lucide-react";
|
||||
import Modal from "@/components/Modal";
|
||||
import AdminEmpty from "@/components/admin/AdminEmpty";
|
||||
import type { MediaLibraryItem } from "@/lib/api";
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function formatTime(iso?: string): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
return d.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function formatDims(item: MediaLibraryItem): string {
|
||||
if (item.width > 0 && item.height > 0) return `${item.width} × ${item.height}`;
|
||||
return "—";
|
||||
}
|
||||
|
||||
/** 网格缩略图:本地图走服务端缩略接口(磁盘缓存),远程对象直连原图(头像类本身小) */
|
||||
function thumbSrc(item: MediaLibraryItem): string {
|
||||
if (item.url.startsWith("/uploads/")) {
|
||||
return `/api/admin/media-library/thumb?u=${encodeURIComponent(item.url)}`;
|
||||
}
|
||||
return item.url;
|
||||
}
|
||||
|
||||
/** 管理后台媒体库:全站图片网格 + 分类筛选 + 详情弹窗(WebP 优先,可查看原图) */
|
||||
export default function MediaLibrary({
|
||||
items,
|
||||
counts,
|
||||
total,
|
||||
initError,
|
||||
}: {
|
||||
items: MediaLibraryItem[];
|
||||
counts: Record<string, number>;
|
||||
total: number;
|
||||
initError?: string;
|
||||
}) {
|
||||
const [category, setCategory] = useState<string>("all");
|
||||
const [active, setActive] = useState<MediaLibraryItem | null>(null);
|
||||
const [showOriginal, setShowOriginal] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const filtered = useMemo(
|
||||
() => (category === "all" ? items : items.filter((i) => i.category === category)),
|
||||
[items, category]
|
||||
);
|
||||
|
||||
const tabs = useMemo(() => {
|
||||
const list: { key: string; label: string; count: number }[] = [
|
||||
{ key: "all", label: "全部", count: total },
|
||||
];
|
||||
// 按现有条目实际分类生成 tab,保持稳定顺序
|
||||
const seen = new Map<string, string>();
|
||||
for (const i of items) seen.set(i.category, i.category_name);
|
||||
for (const [key, label] of seen) {
|
||||
list.push({ key, label, count: counts[key] ?? 0 });
|
||||
}
|
||||
return list;
|
||||
}, [items, counts, total]);
|
||||
|
||||
const openItem = (item: MediaLibraryItem) => {
|
||||
setActive(item);
|
||||
setShowOriginal(false);
|
||||
setCopied(false);
|
||||
};
|
||||
|
||||
const copyLink = async (item: MediaLibraryItem) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(`${window.location.origin}${item.url}`);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
/* 剪贴板不可用静默 */
|
||||
}
|
||||
};
|
||||
|
||||
if (initError) {
|
||||
return (
|
||||
<AdminEmpty
|
||||
icon={<Images size={20} />}
|
||||
title="媒体库加载失败"
|
||||
description={initError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<AdminEmpty
|
||||
icon={<Images size={20} />}
|
||||
title="暂无图片资源"
|
||||
description="站点内还没有任何上传图片"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 分类 tab */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap mb-4">
|
||||
{tabs.map((t) => {
|
||||
const on = category === t.key;
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setCategory(t.key)}
|
||||
aria-pressed={on}
|
||||
className="h-8 px-3 rounded-full text-[12.5px] font-medium inline-flex items-center gap-1.5 transition-colors"
|
||||
style={{
|
||||
border: `1px solid ${on ? "var(--accent)" : "var(--line)"}`,
|
||||
background: on ? "var(--accent-soft)" : "var(--panel)",
|
||||
color: on ? "var(--accent)" : "var(--ink-2)",
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
<span className="tabular-nums opacity-70">{t.count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 图片网格 */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-3">
|
||||
{filtered.map((item) => (
|
||||
<button
|
||||
key={item.url}
|
||||
type="button"
|
||||
onClick={() => openItem(item)}
|
||||
className="group text-left rounded-xl overflow-hidden transition-transform hover:-translate-y-0.5 focus-visible:-translate-y-0.5"
|
||||
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
|
||||
title={item.name}
|
||||
>
|
||||
<span className="block relative aspect-square overflow-hidden">
|
||||
<img
|
||||
src={thumbSrc(item)}
|
||||
alt={item.name}
|
||||
loading="lazy"
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
{item.original_url && (
|
||||
<span
|
||||
className="absolute top-1.5 left-1.5 px-1.5 py-0.5 rounded text-[10px] font-semibold"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
>
|
||||
WebP
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="block px-2.5 py-2">
|
||||
<span
|
||||
className="block text-[12px] font-medium truncate"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="block meta text-[11px] mt-0.5">
|
||||
{formatDims(item)} · {formatBytes(item.size)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
<Modal
|
||||
open={active !== null}
|
||||
onClose={() => setActive(null)}
|
||||
title={active?.name ?? ""}
|
||||
description={active ? `${active.category_name}${active.uploader ? ` · 上传者 ${active.uploader}` : ""}` : undefined}
|
||||
maxWidthClass="max-w-2xl"
|
||||
dismissOnOverlay
|
||||
bodyClassName=""
|
||||
>
|
||||
{active && (
|
||||
<div>
|
||||
<div
|
||||
className="rounded-xl overflow-hidden flex items-center justify-center"
|
||||
style={{
|
||||
background:
|
||||
"repeating-conic-gradient(var(--panel-2) 0% 25%, var(--panel) 0% 50%) 50% / 20px 20px",
|
||||
maxHeight: "52vh",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={showOriginal && active.original_url ? active.original_url : active.url}
|
||||
alt={active.name}
|
||||
className="max-w-full max-h-[52vh] w-auto h-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{active.original_url && (
|
||||
<p className="meta text-[12px] mt-2.5">
|
||||
{showOriginal
|
||||
? "当前显示原图(原格式,体积更大)"
|
||||
: "当前显示 WebP 压缩版;站点内容引用的均为 WebP"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<dl className="mt-4 grid grid-cols-2 gap-x-4 gap-y-2 text-[12.5px]">
|
||||
{[
|
||||
["分类", active.category_name],
|
||||
["尺寸", formatDims(active)],
|
||||
["大小", formatBytes(active.size)],
|
||||
["格式", active.mime],
|
||||
["上传者", active.uploader || "—"],
|
||||
["时间", formatTime(active.uploaded_at)],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="flex items-baseline gap-2 min-w-0">
|
||||
<dt className="meta shrink-0 w-11 text-right">{k}</dt>
|
||||
<dd className="truncate" style={{ color: "var(--ink)" }} title={v}>
|
||||
{v}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
{active.source_url && (
|
||||
<div className="flex items-baseline gap-2 min-w-0 col-span-2">
|
||||
<dt className="meta shrink-0 w-11 text-right">来源</dt>
|
||||
<dd className="truncate">
|
||||
<a
|
||||
href={active.source_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={active.source_label || active.source_url}
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
{active.source_label || active.source_url}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
<div className="mt-5 flex items-center justify-end gap-2 flex-wrap">
|
||||
{active.original_url && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => setShowOriginal((v) => !v)}
|
||||
>
|
||||
<Eye size={14} aria-hidden />
|
||||
{showOriginal ? "看 WebP" : "查看原图"}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-sm" onClick={() => copyLink(active)}>
|
||||
{copied ? <Check size={14} aria-hidden /> : <Copy size={14} aria-hidden />}
|
||||
{copied ? "已复制" : "复制链接"}
|
||||
</button>
|
||||
<a
|
||||
className="btn btn-sm"
|
||||
href={active.original_url && showOriginal ? active.original_url : active.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<ExternalLink size={14} aria-hidden />
|
||||
新窗口打开
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
frontend/app/admin/media/page.tsx
Normal file
49
frontend/app/admin/media/page.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import MediaLibrary from "./MediaLibrary";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { fetchAdminMediaLibrary, type MediaLibraryItem } from "@/lib/api";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { isSuperOrOwner } from "@/lib/roles";
|
||||
import Forbidden from "../Forbidden";
|
||||
import AdminPageHeader from "@/components/admin/AdminPageHeader";
|
||||
import { Images } from "lucide-react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "媒体库",
|
||||
};
|
||||
|
||||
export default async function AdminMediaPage() {
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
if (!me.user || !isSuperOrOwner(me.user.role)) {
|
||||
return <Forbidden text="媒体库仅超级管理员及以上角色可查看。" />;
|
||||
}
|
||||
|
||||
let items: MediaLibraryItem[] = [];
|
||||
let counts: Record<string, number> = {};
|
||||
let total = 0;
|
||||
let initError = "";
|
||||
try {
|
||||
const res = await fetchAdminMediaLibrary(cookie || undefined);
|
||||
items = res.items ?? [];
|
||||
counts = res.counts ?? {};
|
||||
total = res.total ?? 0;
|
||||
} catch (e) {
|
||||
initError = e instanceof Error ? e.message : "加载失败";
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminPageHeader
|
||||
icon={Images}
|
||||
title="媒体库"
|
||||
description={`站点内全部图片资源盘点(共 ${total} 张)。同名图片同时存在原图与 WebP 时仅展示 WebP,点开可查看原图。`}
|
||||
/>
|
||||
<Suspense fallback={<p className="meta px-1 py-6">加载中…</p>}>
|
||||
<MediaLibrary items={items} counts={counts} total={total} initError={initError} />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ClipboardCheck,
|
||||
Database,
|
||||
FileUp,
|
||||
Images,
|
||||
ListChecks,
|
||||
ListX,
|
||||
Loader2,
|
||||
@@ -22,9 +23,8 @@ import {
|
||||
} from "@/components/admin";
|
||||
import {
|
||||
apiAdminImportLegacy,
|
||||
type LegacyCommentReport,
|
||||
type LegacyImportReport,
|
||||
type LegacyPostReport,
|
||||
type LegacyUserPreview,
|
||||
} from "@/lib/api";
|
||||
|
||||
/** 数据源清单:enabled=false 的来源展示为「计划中」,为后续 WordPress/Typecho 适配预留 */
|
||||
@@ -34,27 +34,40 @@ const SOURCES = [
|
||||
{ key: "typecho", label: "Typecho", enabled: false },
|
||||
] as const;
|
||||
|
||||
/** 单个旧用户的导入选择:是否建号 + 内容归属 */
|
||||
type UserSel = { create: boolean; owner: "auto" | "operator" | "custom"; target: string };
|
||||
|
||||
/**
|
||||
* 站点设置 → 数据导入:
|
||||
* 上传旧站数据库(+可选头像包)→ 预检(dry-run,不写库)→ 确认正式导入。
|
||||
* 上传旧站数据库(+可选头像包 / 帖子图片包)→ 预检(dry-run,返回用户/帖子/评论清单)
|
||||
* → 勾选导入范围(是否建号、内容归属、排除帖子/评论)→ 确认正式导入。
|
||||
* 全程幂等:已导入对象自动跳过,可安全重复执行。
|
||||
*/
|
||||
export default function ImportPanel() {
|
||||
const [source, setSource] = useState<string>("jiang13");
|
||||
const [dbFile, setDbFile] = useState<File | null>(null);
|
||||
const [zipFile, setZipFile] = useState<File | null>(null);
|
||||
const [imagesZipFile, setImagesZipFile] = useState<File | null>(null);
|
||||
const [withContent, setWithContent] = useState(true);
|
||||
const [busy, setBusy] = useState<"check" | "import" | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [report, setReport] = useState<LegacyImportReport | null>(null);
|
||||
const [preview, setPreview] = useState<LegacyImportReport | null>(null); // 预检清单
|
||||
const [report, setReport] = useState<LegacyImportReport | null>(null); // 最近一次执行结果
|
||||
const dbInputRef = useRef<HTMLInputElement>(null);
|
||||
const zipInputRef = useRef<HTMLInputElement>(null);
|
||||
const imagesZipInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 勾选状态(预检后按清单初始化,默认全部导入)
|
||||
const [userSel, setUserSel] = useState<Record<number, UserSel>>({});
|
||||
const [postChecked, setPostChecked] = useState<Set<number>>(new Set());
|
||||
const [commentChecked, setCommentChecked] = useState<Set<number>>(new Set());
|
||||
|
||||
// 「确认导入」只有在当前配置下跑过预检后才可用;文件/选项变化即重置
|
||||
const [preflighted, setPreflighted] = useState(false);
|
||||
|
||||
const resetResult = () => {
|
||||
setPreflighted(false);
|
||||
setPreview(null);
|
||||
setReport(null);
|
||||
setError("");
|
||||
};
|
||||
@@ -67,22 +80,73 @@ export default function ImportPanel() {
|
||||
setZipFile(f);
|
||||
resetResult();
|
||||
};
|
||||
const pickImagesZip = (f: File | null) => {
|
||||
setImagesZipFile(f);
|
||||
resetResult();
|
||||
};
|
||||
const toggleContent = () => {
|
||||
setWithContent((v) => !v);
|
||||
resetResult();
|
||||
};
|
||||
|
||||
const initSelections = (rep: LegacyImportReport) => {
|
||||
const users: Record<number, UserSel> = {};
|
||||
(rep.user_list ?? []).forEach((u) => {
|
||||
users[u.id] = { create: true, owner: "auto", target: "" };
|
||||
});
|
||||
setUserSel(users);
|
||||
setPostChecked(new Set((rep.post_list ?? []).filter((p) => p.published && !p.poll).map((p) => p.id)));
|
||||
setCommentChecked(new Set((rep.comment_list ?? []).filter((c) => c.published).map((c) => c.id)));
|
||||
};
|
||||
|
||||
// 由勾选状态构造请求参数(相对预检清单求差集)
|
||||
const buildOptions = () => {
|
||||
const skipUsers: number[] = [];
|
||||
const operatorUsers: number[] = [];
|
||||
const userMap: { oldId: number; username: string }[] = [];
|
||||
Object.entries(userSel).forEach(([idStr, sel]) => {
|
||||
const id = Number(idStr);
|
||||
if (sel.owner === "operator") {
|
||||
skipUsers.push(id);
|
||||
operatorUsers.push(id);
|
||||
} else if (sel.owner === "custom" && sel.target.trim()) {
|
||||
skipUsers.push(id);
|
||||
userMap.push({ oldId: id, username: sel.target.trim() });
|
||||
} else if (!sel.create) {
|
||||
skipUsers.push(id);
|
||||
}
|
||||
});
|
||||
return {
|
||||
withContent,
|
||||
dryRun: false,
|
||||
skipUsers,
|
||||
operatorUsers,
|
||||
userMap,
|
||||
skipPosts: (preview?.post_list ?? [])
|
||||
.filter((p) => p.published && !p.poll && !postChecked.has(p.id))
|
||||
.map((p) => p.id),
|
||||
skipComments: (preview?.comment_list ?? [])
|
||||
.filter((c) => c.published && !commentChecked.has(c.id))
|
||||
.map((c) => c.id),
|
||||
};
|
||||
};
|
||||
|
||||
const run = async (dryRun: boolean) => {
|
||||
if (!dbFile || busy) return;
|
||||
setBusy(dryRun ? "check" : "import");
|
||||
setError("");
|
||||
try {
|
||||
const rep = await apiAdminImportLegacy(source, dbFile, zipFile, {
|
||||
withContent,
|
||||
const opts = dryRun ? { withContent, dryRun } : buildOptions();
|
||||
const rep = await apiAdminImportLegacy(source, dbFile, zipFile, imagesZipFile, {
|
||||
...opts,
|
||||
dryRun,
|
||||
});
|
||||
setReport(rep);
|
||||
if (dryRun) setPreflighted(true);
|
||||
if (dryRun) {
|
||||
setPreview(rep);
|
||||
initSelections(rep);
|
||||
setPreflighted(true);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "导入失败");
|
||||
} finally {
|
||||
@@ -90,6 +154,25 @@ export default function ImportPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const setUser = (id: number, patch: Partial<UserSel>) => {
|
||||
setUserSel((m) => {
|
||||
const cur = m[id] ?? { create: true, owner: "auto" as const, target: "" };
|
||||
const next = { ...cur, ...patch };
|
||||
// 归属指定已有账号 / 站长时,账号不再新建
|
||||
if (next.owner !== "auto") next.create = false;
|
||||
return { ...m, [id]: next };
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSet = (set: Set<number>, id: number) => {
|
||||
const next = new Set(set);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
};
|
||||
|
||||
const hasLists = withContent && !!preview?.post_list?.length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<AdminSettingsGroup
|
||||
@@ -145,12 +228,21 @@ export default function ImportPanel() {
|
||||
onPick={pickZip}
|
||||
busy={busy !== null}
|
||||
/>
|
||||
<FilePickRow
|
||||
label="帖子图片压缩包(可选)"
|
||||
hint="把旧站 data/uploads/posts 目录压缩为 zip,最大 128MB;帖子/评论正文里的旧站图片会迁移到新站并改写链接"
|
||||
fileName={imagesZipFile?.name}
|
||||
inputRef={imagesZipInputRef}
|
||||
accept=".zip,application/zip"
|
||||
onPick={pickImagesZip}
|
||||
busy={busy !== null}
|
||||
/>
|
||||
</div>
|
||||
</AdminSettingsGroup>
|
||||
|
||||
<AdminSettingsGroup
|
||||
title="导入范围"
|
||||
description="第二步:确认导入范围。板块按旧板块名自动创建(同名复用);帖子与评论保留原发布时间与浏览/点赞数;作者账号缺失时归到站长。投票帖暂不迁移。"
|
||||
description="第二步:确认是否导入内容。板块按旧板块名自动创建(同名复用);帖子与评论保留原发布时间与浏览/点赞数。投票帖暂不迁移。"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<AdminSwitch checked={withContent} onChange={toggleContent} label="同时导入帖子与评论" />
|
||||
@@ -162,7 +254,7 @@ export default function ImportPanel() {
|
||||
|
||||
<AdminSettingsGroup
|
||||
title="预检与导入"
|
||||
description="第三步:先预检确认将导入的内容,再正式写入。可重复执行,已导入的内容自动跳过。"
|
||||
description="第三步:先预检生成内容清单并勾选导入范围,再正式写入。可重复执行,已导入的内容自动跳过。"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
@@ -190,7 +282,7 @@ export default function ImportPanel() {
|
||||
? "请先选择站点数据库"
|
||||
: !preflighted
|
||||
? "请先运行预检"
|
||||
: "预检通过,可以正式导入"}
|
||||
: "已生成清单,确认勾选后可正式导入"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -201,6 +293,38 @@ export default function ImportPanel() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{preflighted && preview?.user_list && preview.user_list.length > 0 && (
|
||||
<UserSelectList list={preview.user_list} sel={userSel} onChange={setUser} />
|
||||
)}
|
||||
{hasLists && preview && (
|
||||
<PostSelectList
|
||||
list={preview.post_list ?? []}
|
||||
checked={postChecked}
|
||||
onToggle={(id) => setPostChecked((s) => toggleSet(s, id))}
|
||||
onAll={(on) =>
|
||||
setPostChecked(
|
||||
on
|
||||
? new Set((preview.post_list ?? []).filter((p) => p.published && !p.poll).map((p) => p.id))
|
||||
: new Set()
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{hasLists && preview && !!preview.comment_list?.length && (
|
||||
<CommentSelectList
|
||||
list={preview.comment_list}
|
||||
checked={commentChecked}
|
||||
onToggle={(id) => setCommentChecked((s) => toggleSet(s, id))}
|
||||
onAll={(on) =>
|
||||
setCommentChecked(
|
||||
on
|
||||
? new Set(preview.comment_list!.filter((c) => c.published).map((c) => c.id))
|
||||
: new Set()
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{report && <ReportView report={report} />}
|
||||
</AdminSettingsGroup>
|
||||
</div>
|
||||
@@ -258,6 +382,222 @@ function FilePickRow({
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 预检后勾选清单 =====
|
||||
|
||||
const fieldStyle = { background: "var(--panel)", border: "1px solid var(--line)" } as const;
|
||||
|
||||
function UserSelectList({
|
||||
list,
|
||||
sel,
|
||||
onChange,
|
||||
}: {
|
||||
list: LegacyUserPreview[];
|
||||
sel: Record<number, UserSel>;
|
||||
onChange: (id: number, patch: Partial<UserSel>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
<p className="flex items-center gap-1.5 text-[13px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||
<Users size={14} aria-hidden />
|
||||
用户({list.length})——取消勾选则不创建账号,其内容按右侧归属落位
|
||||
</p>
|
||||
<div className="max-h-80 overflow-y-auto flex flex-col gap-1.5 pr-1">
|
||||
{list.map((u) => {
|
||||
const s = sel[u.id] ?? { create: true, owner: "auto" as const, target: "" };
|
||||
return (
|
||||
<div
|
||||
key={u.id}
|
||||
className="rounded-lg px-3 py-2 flex items-center gap-2.5 flex-wrap"
|
||||
style={fieldStyle}
|
||||
>
|
||||
<label className="inline-flex items-center gap-2 text-[12.5px] shrink-0" style={{ color: "var(--ink)" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-[var(--accent)]"
|
||||
checked={s.create}
|
||||
disabled={s.owner !== "auto"}
|
||||
onChange={(e) => onChange(u.id, { create: e.target.checked })}
|
||||
/>
|
||||
建号
|
||||
</label>
|
||||
<div className="min-w-0 flex-1 flex items-baseline gap-1.5 flex-wrap">
|
||||
<span className="text-[13px] font-semibold truncate" style={{ color: "var(--ink)" }}>
|
||||
{u.username}
|
||||
</span>
|
||||
{u.nickname && u.nickname !== u.username && (
|
||||
<span className="meta text-[12px] truncate">{u.nickname}</span>
|
||||
)}
|
||||
{u.exists && (
|
||||
<span className="text-[11px] font-semibold" style={{ color: "var(--warn, #b45309)" }}>
|
||||
同名已存在·导入时跳过建号
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="meta text-[12px] shrink-0">
|
||||
帖 {u.posts} · 评 {u.comments}
|
||||
</span>
|
||||
<select
|
||||
value={s.owner}
|
||||
onChange={(e) => onChange(u.id, { owner: e.target.value as UserSel["owner"] })}
|
||||
className="rounded-lg px-2 py-1 text-[12.5px] shrink-0"
|
||||
style={fieldStyle}
|
||||
>
|
||||
<option value="auto">归属:跟随默认</option>
|
||||
<option value="operator">归到站长</option>
|
||||
<option value="custom">指定已有账号…</option>
|
||||
</select>
|
||||
{s.owner === "custom" && (
|
||||
<input
|
||||
value={s.target}
|
||||
placeholder="本站用户名"
|
||||
onChange={(e) => onChange(u.id, { target: e.target.value })}
|
||||
className="rounded-lg px-2 py-1 text-[12.5px] w-36 shrink-0"
|
||||
style={fieldStyle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BulkBar({ label, onAll }: { label: string; onAll: (on: boolean) => void }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-[12px]" style={{ color: "var(--ink-3)" }}>
|
||||
{label}
|
||||
<button type="button" className="btn btn-line btn-xs" onClick={() => onAll(true)}>
|
||||
全选
|
||||
</button>
|
||||
<button type="button" className="btn btn-line btn-xs" onClick={() => onAll(false)}>
|
||||
全不选
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PostSelectList({
|
||||
list,
|
||||
checked,
|
||||
onToggle,
|
||||
onAll,
|
||||
}: {
|
||||
list: { id: number; title: string; author: string; board: string; created_at: string; poll: boolean; published: boolean }[];
|
||||
checked: Set<number>;
|
||||
onToggle: (id: number) => void;
|
||||
onAll: (on: boolean) => void;
|
||||
}) {
|
||||
const selectable = list.filter((p) => p.published && !p.poll).length;
|
||||
return (
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<p className="flex items-center gap-1.5 text-[13px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||
<ListChecks size={14} aria-hidden />
|
||||
帖子({selectable}/{list.length} 可导入)——取消勾选即排除,其评论一并跳过
|
||||
</p>
|
||||
<BulkBar label="批量:" onAll={onAll} />
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto flex flex-col gap-1.5 pr-1">
|
||||
{list.map((p) => {
|
||||
const disabled = !p.published || p.poll;
|
||||
return (
|
||||
<label
|
||||
key={p.id}
|
||||
className={`rounded-lg px-3 py-2 flex items-center gap-2.5 ${disabled ? "opacity-55" : "cursor-pointer"}`}
|
||||
style={fieldStyle}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-[var(--accent)] shrink-0"
|
||||
checked={!disabled && checked.has(p.id)}
|
||||
disabled={disabled}
|
||||
onChange={() => onToggle(p.id)}
|
||||
/>
|
||||
<span className="text-[13px] truncate flex-1" style={{ color: "var(--ink)" }}>
|
||||
{p.title}
|
||||
</span>
|
||||
{p.poll && (
|
||||
<span className="text-[11px] font-semibold shrink-0" style={{ color: "var(--danger)" }}>
|
||||
投票帖·不可迁移
|
||||
</span>
|
||||
)}
|
||||
{!p.published && (
|
||||
<span className="text-[11px] font-semibold shrink-0" style={{ color: "var(--danger)" }}>
|
||||
非公开
|
||||
</span>
|
||||
)}
|
||||
<span className="meta text-[12px] shrink-0 truncate max-w-[180px]">
|
||||
{p.author} · {p.board} · {fmtDate(p.created_at)}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentSelectList({
|
||||
list,
|
||||
checked,
|
||||
onToggle,
|
||||
onAll,
|
||||
}: {
|
||||
list: { id: number; post_title: string; author: string; excerpt: string; created_at: string; published: boolean }[];
|
||||
checked: Set<number>;
|
||||
onToggle: (id: number) => void;
|
||||
onAll: (on: boolean) => void;
|
||||
}) {
|
||||
const selectable = list.filter((c) => c.published).length;
|
||||
return (
|
||||
<details className="mt-2 flex flex-col gap-2">
|
||||
<summary className="flex items-center gap-2 flex-wrap cursor-pointer select-none">
|
||||
<span className="flex items-center gap-1.5 text-[13px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||
<MessageSquare size={14} aria-hidden />
|
||||
评论({selectable}/{list.length} 可导入)
|
||||
</span>
|
||||
<BulkBar label="批量:" onAll={onAll} />
|
||||
</summary>
|
||||
<div className="max-h-80 overflow-y-auto flex flex-col gap-1.5 pr-1">
|
||||
{list.map((c) => (
|
||||
<label
|
||||
key={c.id}
|
||||
className={`rounded-lg px-3 py-2 flex items-center gap-2.5 ${!c.published ? "opacity-55" : "cursor-pointer"}`}
|
||||
style={fieldStyle}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-[var(--accent)] shrink-0"
|
||||
checked={c.published && checked.has(c.id)}
|
||||
disabled={!c.published}
|
||||
onChange={() => onToggle(c.id)}
|
||||
/>
|
||||
<span className="text-[12.5px] truncate flex-1" style={{ color: "var(--ink)" }}>
|
||||
{c.excerpt || "(无文字)"}
|
||||
</span>
|
||||
{!c.published && (
|
||||
<span className="text-[11px] font-semibold shrink-0" style={{ color: "var(--danger)" }}>
|
||||
非公开
|
||||
</span>
|
||||
)}
|
||||
<span className="meta text-[12px] shrink-0 truncate max-w-[240px]">
|
||||
《{c.post_title}》{c.author} · {fmtDate(c.created_at)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtDate(s: string) {
|
||||
const d = new Date(s);
|
||||
return Number.isNaN(d.getTime()) ? "" : d.toLocaleDateString("zh-CN");
|
||||
}
|
||||
|
||||
// ===== 结果报告 =====
|
||||
|
||||
function StatChip({ icon, label, value, ok }: { icon: React.ReactNode; label: string; value: number; ok?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
@@ -282,7 +622,7 @@ function ReportView({ report }: { report: LegacyImportReport }) {
|
||||
{isDry ? (
|
||||
<>
|
||||
<ScanLine size={15} aria-hidden />
|
||||
预检结果(未写入任何数据)——确认无误后点击「确认正式导入」
|
||||
预检结果(未写入任何数据)——请在上方勾选导入范围,确认后点击「确认正式导入」
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -296,6 +636,9 @@ function ReportView({ report }: { report: LegacyImportReport }) {
|
||||
<StatChip icon={<ListChecks size={13} aria-hidden />} label="旧库用户" value={report.users.total} />
|
||||
<StatChip icon={<CheckCircle2 size={13} aria-hidden />} label={isDry ? "将导入" : "已导入"} value={report.users.imported} />
|
||||
<StatChip icon={<ListX size={13} aria-hidden />} label="已存在跳过" value={report.users.skipped} ok={false} />
|
||||
{report.users.excluded > 0 && (
|
||||
<StatChip icon={<ListX size={13} aria-hidden />} label="不建号" value={report.users.excluded} ok={false} />
|
||||
)}
|
||||
{report.users.avatar_written > 0 && (
|
||||
<StatChip icon={<FileUp size={13} aria-hidden />} label="头像写入" value={report.users.avatar_written} />
|
||||
)}
|
||||
@@ -332,29 +675,45 @@ function ReportView({ report }: { report: LegacyImportReport }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PostsView({ report, isDry }: { report: LegacyPostReport; isDry: boolean }) {
|
||||
function PostsView({ report, isDry }: { report: NonNullable<LegacyImportReport["posts"]>; isDry: boolean }) {
|
||||
return (
|
||||
<ReportCard icon={<ListChecks size={14} aria-hidden />} title="帖子">
|
||||
<StatChip icon={<ListChecks size={13} aria-hidden />} label="旧帖" value={report.total} />
|
||||
<StatChip icon={<CheckCircle2 size={13} aria-hidden />} label={isDry ? "将导入" : "已导入"} value={report.imported} />
|
||||
<StatChip icon={<ListX size={13} aria-hidden />} label="已导入跳过" value={report.skipped} ok={false} />
|
||||
{report.excluded > 0 && (
|
||||
<StatChip icon={<ListX size={13} aria-hidden />} label="手动排除" value={report.excluded} ok={false} />
|
||||
)}
|
||||
{report.images_written > 0 && (
|
||||
<StatChip icon={<Images size={13} aria-hidden />} label={isDry ? "将迁移图片" : "图片迁移"} value={report.images_written} />
|
||||
)}
|
||||
{report.polls_skipped > 0 && (
|
||||
<StatChip icon={<XCircle size={13} aria-hidden />} label="投票帖未迁移" value={report.polls_skipped} ok={false} />
|
||||
)}
|
||||
<DetailList title="投票帖(未迁移)" items={report.poll_titles} color="var(--danger)" />
|
||||
<DetailList title="手动排除" items={report.excluded_detail} />
|
||||
<DetailList title="图片缺失(保留原链接)" items={report.images_missing} color="var(--danger)" />
|
||||
<DetailList title="作者缺失(归到站长)" items={report.owner_fallback} />
|
||||
<DetailList title="失败 / 非公开" items={report.failed} color="var(--danger)" />
|
||||
</ReportCard>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentsView({ report, isDry }: { report: LegacyCommentReport; isDry: boolean }) {
|
||||
function CommentsView({ report, isDry }: { report: NonNullable<LegacyImportReport["comments"]>; isDry: boolean }) {
|
||||
return (
|
||||
<ReportCard icon={<MessageSquare size={14} aria-hidden />} title="评论">
|
||||
<StatChip icon={<ListChecks size={13} aria-hidden />} label="旧评论" value={report.total} />
|
||||
<StatChip icon={<CheckCircle2 size={13} aria-hidden />} label={isDry ? "将导入" : "已导入"} value={report.imported} />
|
||||
<StatChip icon={<ListX size={13} aria-hidden />} label="已导入跳过" value={report.skipped} ok={false} />
|
||||
{report.excluded > 0 && (
|
||||
<StatChip icon={<ListX size={13} aria-hidden />} label="手动排除" value={report.excluded} ok={false} />
|
||||
)}
|
||||
{report.images_written > 0 && (
|
||||
<StatChip icon={<Images size={13} aria-hidden />} label={isDry ? "将迁移图片" : "图片迁移"} value={report.images_written} />
|
||||
)}
|
||||
<DetailList title="手动排除" items={report.excluded_detail} />
|
||||
<DetailList title="非公开 / 已导入跳过明细" items={report.skipped_detail} />
|
||||
<DetailList title="图片缺失(保留原链接)" items={report.images_missing} color="var(--danger)" />
|
||||
<DetailList title="作者缺失(归到站长)" items={report.owner_fallback} />
|
||||
<DetailList title="失败" items={report.failed} color="var(--danger)" />
|
||||
</ReportCard>
|
||||
|
||||
@@ -180,6 +180,16 @@ export default function SidebarAdmin({
|
||||
}
|
||||
};
|
||||
|
||||
// 放弃未保存修改:回到最近一次保存的布局
|
||||
const discardChanges = () => {
|
||||
if (busy) return;
|
||||
try {
|
||||
setConfig(JSON.parse(savedJsonRef.current));
|
||||
} catch {
|
||||
/* 忽略:快照损坏时保持现状 */
|
||||
}
|
||||
};
|
||||
|
||||
/* ---------- 自定义工具 ---------- */
|
||||
|
||||
const openCreate = () => {
|
||||
@@ -304,16 +314,41 @@ export default function SidebarAdmin({
|
||||
</nav>
|
||||
);
|
||||
|
||||
// 页头与浮动保存条共用的未保存提示 + 操作按钮
|
||||
const dirtyDot = (
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full animate-pulse shrink-0"
|
||||
style={{ background: "var(--gold)" }}
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
const saveControls = dirty ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="meta flex items-center gap-1.5 text-[12px]">
|
||||
{dirtyDot}
|
||||
布局有未保存的修改
|
||||
</span>
|
||||
<button type="button" className="btn btn-line btn-sm" disabled={busy} onClick={discardChanges}>
|
||||
撤销修改
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={busy} onClick={() => void saveConfig()}>
|
||||
{busy ? "保存中…" : "保存布局"}
|
||||
</button>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<AdminSettingsPage>
|
||||
<AdminPageHeader
|
||||
icon={PanelLeft}
|
||||
title="侧栏管理"
|
||||
description="配置各页面左右侧栏的显示、顺序与位置,管理自定义 HTML 工具"
|
||||
actions={saveControls}
|
||||
/>
|
||||
|
||||
<AdminSettingsWork nav={leftNav}>
|
||||
<div className="space-y-5 pb-24">
|
||||
<>
|
||||
<div className="space-y-5 pb-4">
|
||||
{SIDES.map(({ scope, icon, desc, sides }) => {
|
||||
const sc = config.scopes[scope] ?? {};
|
||||
const left = sc.left ?? [];
|
||||
@@ -413,27 +448,34 @@ export default function SidebarAdmin({
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</AdminSettingsWork>
|
||||
|
||||
{/* 吸底保存栏 */}
|
||||
{dirty && (
|
||||
<div
|
||||
className="fixed bottom-0 left-0 right-0 z-40 flex items-center justify-between gap-3 border-t px-4 py-3 sm:px-6"
|
||||
style={{
|
||||
background: "color-mix(in srgb, var(--panel) 92%, transparent)",
|
||||
backdropFilter: "blur(12px)",
|
||||
borderColor: "var(--line)",
|
||||
}}
|
||||
>
|
||||
<p className="meta text-[12px]">
|
||||
<span className="inline-block w-2 h-2 rounded-full mr-1.5" style={{ background: "var(--gold)" }} aria-hidden />
|
||||
布局有未保存的修改
|
||||
</p>
|
||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void saveConfig()}>
|
||||
{busy ? "保存中…" : "保存布局"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* 浮动保存条:吸在内容列底部居中,滚动全程可见,不再贴屏幕角落 */}
|
||||
{dirty && (
|
||||
<div className="sticky bottom-4 z-30 flex justify-center pointer-events-none">
|
||||
<div
|
||||
className="pointer-events-auto flex items-center gap-3 rounded-full px-4 py-2"
|
||||
style={{
|
||||
background: "color-mix(in srgb, var(--panel) 92%, transparent)",
|
||||
backdropFilter: "blur(12px)",
|
||||
border: "1px solid var(--line)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
}}
|
||||
>
|
||||
<span className="meta flex items-center gap-1.5 text-[12px]">
|
||||
{dirtyDot}
|
||||
布局有未保存的修改
|
||||
</span>
|
||||
<button type="button" className="btn btn-line btn-sm" disabled={busy} onClick={discardChanges}>
|
||||
撤销修改
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={busy} onClick={() => void saveConfig()}>
|
||||
{busy ? "保存中…" : "保存布局"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</AdminSettingsWork>
|
||||
|
||||
{/* 工具编辑弹层 */}
|
||||
<Modal
|
||||
|
||||
Reference in New Issue
Block a user