feat: 用户徽章与等级体系,积分经济管理重构
- 新增徽章系统:badge 服务与 handler、管理端配置页、BadgeChip/LevelBadge 展示组件 - 新增等级模型:model/level 及前端 levels 元数据 - 管理端经济模块拆分为 points/badges/levels 独立页面,EconomySettings 迁入设置 - 积分、签到、评论等服务适配等级与徽章逻辑
This commit is contained in:
441
frontend/app/admin/badges/BadgesClient.tsx
Normal file
441
frontend/app/admin/badges/BadgesClient.tsx
Normal file
@@ -0,0 +1,441 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Pencil, 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 { BADGE_ICONS } from "@/lib/badgeIcons";
|
||||
import { toast } from "@/lib/toast";
|
||||
import {
|
||||
apiAdminBadgeHolders,
|
||||
apiAdminCreateBadge,
|
||||
apiAdminDeleteBadge,
|
||||
apiAdminGrantBadge,
|
||||
apiAdminListBadges,
|
||||
apiAdminListUsers,
|
||||
apiAdminRevokeBadge,
|
||||
apiAdminUpdateBadge,
|
||||
type BadgeHolder,
|
||||
type BadgeInput,
|
||||
type BadgeWithHolders,
|
||||
} from "@/lib/api";
|
||||
|
||||
/** 与后端 model.BadgeColors 同步的色板(CSS token 名) */
|
||||
const BADGE_COLORS = ["gold", "accent", "ok", "danger", "info", "purple", "teal"] as const;
|
||||
const MAX_NAME_RUNES = 16;
|
||||
|
||||
const runeCount = (s: string) => [...s].length;
|
||||
|
||||
export default function BadgesClient() {
|
||||
const [badges, setBadges] = useState<BadgeWithHolders[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 编辑/新建表单
|
||||
const [editing, setEditing] = useState<BadgeWithHolders | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [form, setForm] = useState<BadgeInput>({ name: "", icon: "medal", color: "accent" });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 删除确认
|
||||
const [deleting, setDeleting] = useState<BadgeWithHolders | null>(null);
|
||||
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("");
|
||||
const [searchResults, setSearchResults] = useState<{ id: number; nickname: string; username: string; avatar: string }[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [grantingId, setGrantingId] = useState<number | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiAdminListBadges();
|
||||
setBadges(res.badges ?? []);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "获取徽章列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const openCreate = () => {
|
||||
setForm({ name: "", icon: "medal", color: "accent" });
|
||||
setEditing(null);
|
||||
setCreating(true);
|
||||
};
|
||||
|
||||
const openEdit = (b: BadgeWithHolders) => {
|
||||
setForm({ name: b.name, icon: b.icon || "medal", color: b.color || "accent" });
|
||||
setCreating(false);
|
||||
setEditing(b);
|
||||
};
|
||||
|
||||
const closeForm = () => {
|
||||
setCreating(false);
|
||||
setEditing(null);
|
||||
};
|
||||
|
||||
const saveForm = async () => {
|
||||
if (saving) return;
|
||||
if (runeCount(form.name.trim()) < 1 || runeCount(form.name.trim()) > MAX_NAME_RUNES) {
|
||||
toast(`名称需 1–${MAX_NAME_RUNES} 字`);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
await apiAdminUpdateBadge(editing.id, form);
|
||||
toast("徽章已更新", "ok");
|
||||
} else {
|
||||
await apiAdminCreateBadge(form);
|
||||
toast("徽章已创建", "ok");
|
||||
}
|
||||
closeForm();
|
||||
await reload();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!deleting || deletingBusy) return;
|
||||
setDeletingBusy(true);
|
||||
try {
|
||||
await apiAdminDeleteBadge(deleting.id);
|
||||
toast("徽章已删除", "ok");
|
||||
setDeleting(null);
|
||||
if (holdersFor?.id === deleting.id) setHoldersFor(null);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "删除失败");
|
||||
} finally {
|
||||
setDeletingBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
setSearching(true);
|
||||
try {
|
||||
const res = await apiAdminListUsers({ q, size: 8 });
|
||||
setSearchResults(
|
||||
(res.users ?? []).map((u) => ({
|
||||
id: u.id,
|
||||
nickname: u.nickname,
|
||||
username: u.username,
|
||||
avatar: u.avatar,
|
||||
})),
|
||||
);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "搜索用户失败");
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const grant = async (userId: number) => {
|
||||
if (!holdersFor || grantingId != null) return;
|
||||
setGrantingId(userId);
|
||||
try {
|
||||
await apiAdminGrantBadge(holdersFor.id, userId);
|
||||
toast("已授予", "ok");
|
||||
await openHolders(holdersFor);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "授予失败");
|
||||
} finally {
|
||||
setGrantingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const revoke = async (userId: number) => {
|
||||
if (!holdersFor) return;
|
||||
try {
|
||||
await apiAdminRevokeBadge(holdersFor.id, userId);
|
||||
toast("已撤销", "ok");
|
||||
await openHolders(holdersFor);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "撤销失败");
|
||||
}
|
||||
};
|
||||
|
||||
const holdersHeldIds = useMemo(() => new Set(holders.map((h) => h.user_id)), [holders]);
|
||||
|
||||
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-solid 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 ? "搜索中…" : "搜索"}
|
||||
</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-solid 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-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)}
|
||||
>
|
||||
撤销
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* 新建/编辑表单 */}
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
<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 mt-5">
|
||||
<button type="button" className="btn btn-line btn-sm" onClick={closeForm}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-solid btn-sm"
|
||||
disabled={saving || runeCount(form.name.trim()) === 0}
|
||||
onClick={() => void saveForm()}
|
||||
>
|
||||
{saving ? "保存中…" : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* 删除确认 */}
|
||||
<ConfirmDialog
|
||||
open={deleting != null}
|
||||
title="删除徽章"
|
||||
message={`确定删除徽章「${deleting?.name ?? ""}」?该徽章的全部授予记录(${deleting?.holders ?? 0} 人)会被一并删除,用户主页将不再显示。`}
|
||||
confirmLabel="删除"
|
||||
danger
|
||||
busy={deletingBusy}
|
||||
onCancel={() => setDeleting(null)}
|
||||
onConfirm={() => void doDelete()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
frontend/app/admin/badges/page.tsx
Normal file
22
frontend/app/admin/badges/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import BadgesClient from "./BadgesClient";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { isSuperOrOwner } from "@/lib/roles";
|
||||
import Forbidden from "../Forbidden";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "徽章管理",
|
||||
};
|
||||
|
||||
// 徽章管理仅超管/站长可用(与站点设置同权限;后端路由另有守卫)
|
||||
export default async function AdminBadgesPage() {
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
if (!me.user || !isSuperOrOwner(me.user.role)) {
|
||||
return <Forbidden text="徽章管理仅超管/站长可操作。" />;
|
||||
}
|
||||
|
||||
return <BadgesClient />;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { fetchAdminEconomyStats } from "@/lib/api";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { isAdminOrAbove } from "@/lib/roles";
|
||||
import Forbidden from "../Forbidden";
|
||||
import EconomyClient from "./EconomyClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "积分看板",
|
||||
};
|
||||
|
||||
export default async function AdminEconomyPage() {
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
||||
return <Forbidden text="积分看板仅管理员及以上可查看。" />;
|
||||
}
|
||||
|
||||
let stats: Awaited<ReturnType<typeof fetchAdminEconomyStats>> | null = null;
|
||||
try {
|
||||
stats = await fetchAdminEconomyStats(cookie);
|
||||
} catch {
|
||||
stats = null;
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className="panel p-8 text-center">
|
||||
<p className="meta">暂时无法加载积分数据</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <EconomyClient stats={stats} />;
|
||||
}
|
||||
309
frontend/app/admin/levels/LevelsAdmin.tsx
Normal file
309
frontend/app/admin/levels/LevelsAdmin.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Plus, RotateCcw, Trash2 } from "lucide-react";
|
||||
import { AdminStepper, AdminSwitch } from "@/components/admin";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
|
||||
import { apiGetSettings, apiUpdateSiteSettings } from "@/lib/api";
|
||||
import {
|
||||
DEFAULT_LEVEL_DEFS,
|
||||
LEVEL_BADGE_CLASS,
|
||||
LEVEL_COLOR_KEYS,
|
||||
LEVEL_COLOR_TOKENS,
|
||||
MAX_LEVEL_COUNT,
|
||||
splitBackground,
|
||||
type LevelDef,
|
||||
} from "@/lib/levels";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
const cloneDefs = (defs: LevelDef[]): LevelDef[] =>
|
||||
defs.map((d) => ({ threshold: d.threshold, color: d.color }));
|
||||
|
||||
const sameDefs = (a: LevelDef[] | null, b: LevelDef[] | null) => {
|
||||
if (a === b) return true;
|
||||
if (!a || !b || a.length !== b.length) return false;
|
||||
return a.every((d, i) => d.threshold === b[i].threshold && d.color === b[i].color);
|
||||
};
|
||||
|
||||
/** 等级管理:配置等级数量(2–12)、每级所需累计积分与前景/背景配色、特效开关 */
|
||||
export default function LevelsAdmin() {
|
||||
const [form, setForm] = useState<LevelDef[] | null>(null); // null = 加载中
|
||||
const [saved, setSaved] = useState<LevelDef[] | null>(null);
|
||||
const [fx, setFx] = useState(true);
|
||||
const [savedFx, setSavedFx] = useState(true);
|
||||
const [resetOpen, setResetOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const dirty = useMemo(
|
||||
() => !sameDefs(form, saved) || fx !== savedFx,
|
||||
[form, saved, fx, savedFx],
|
||||
);
|
||||
const leave = useUnsavedGuard(dirty);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
apiGetSettings()
|
||||
.then((s) => {
|
||||
if (!alive) return;
|
||||
const defs = cloneDefs(s.levels);
|
||||
setForm(defs);
|
||||
setSaved(defs);
|
||||
setFx(s.levels_fx);
|
||||
setSavedFx(s.levels_fx);
|
||||
})
|
||||
.catch((e) => {
|
||||
toast(e instanceof Error ? e.message : "获取等级配置失败");
|
||||
if (alive) setForm([]);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setThreshold = (i: number, v: number) =>
|
||||
setForm((f) => (f ? f.map((d, j) => (j === i ? { ...d, threshold: v } : d)) : f));
|
||||
const setColor = (i: number, color: string) =>
|
||||
setForm((f) => (f ? f.map((d, j) => (j === i ? { ...d, color } : d)) : f));
|
||||
const addLevel = () =>
|
||||
setForm((f) => {
|
||||
if (!f || f.length >= MAX_LEVEL_COUNT) return f;
|
||||
const last = f[f.length - 1];
|
||||
return [...f, { threshold: (last?.threshold ?? 0) + 100, color: "neutral" }];
|
||||
});
|
||||
const removeLast = () =>
|
||||
setForm((f) => (f && f.length > 2 ? f.slice(0, -1) : f));
|
||||
|
||||
const save = async () => {
|
||||
if (saving || !dirty || !form) return;
|
||||
if (form.length < 2) {
|
||||
toast("至少保留 2 个等级");
|
||||
return;
|
||||
}
|
||||
if (form[0].threshold !== 0) {
|
||||
toast("Lv.1 所需积分须为 0");
|
||||
return;
|
||||
}
|
||||
for (let i = 1; i < form.length; i++) {
|
||||
if (!(form[i].threshold > form[i - 1].threshold)) {
|
||||
toast(`Lv.${i + 1} 所需积分须大于上一级`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await apiUpdateSiteSettings({ levels: form, levels_fx: fx });
|
||||
const defs = cloneDefs(res.levels);
|
||||
setForm(defs);
|
||||
setSaved(defs);
|
||||
setSavedFx(fx);
|
||||
toast("等级配置已保存", "ok");
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 重置:仅回填默认等级表(2–12 约束内),需再点保存才生效
|
||||
const resetToDefault = () => {
|
||||
if (!form) return;
|
||||
setForm(cloneDefs(DEFAULT_LEVEL_DEFS));
|
||||
setResetOpen(false);
|
||||
toast("已恢复默认等级表,点击保存生效", "ok");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-settings-stack">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold" style={{ color: "var(--ink)" }}>
|
||||
等级管理
|
||||
</h2>
|
||||
<p className="meta text-sm mt-1">
|
||||
用户等级由累计获得积分决定,达到某级阈值即晋升。可配置等级数量(2–{MAX_LEVEL_COUNT}{" "}
|
||||
级)、每级所需积分与配色;等级徽章仅显示 Lv.n,不弹悬浮提示。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!form ? (
|
||||
<p className="meta px-1 py-6">加载等级配置…</p>
|
||||
) : form.length === 0 ? (
|
||||
<div className="panel text-center py-12">
|
||||
<p className="meta">等级配置加载失败,请刷新页面重试。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="panel p-0 overflow-hidden">
|
||||
{form.map((d, i) => {
|
||||
const n = i + 1;
|
||||
const tokens = LEVEL_COLOR_TOKENS[d.color] ?? LEVEL_COLOR_TOKENS.neutral;
|
||||
const textBg = splitBackground(tokens.text ?? "none");
|
||||
const isLast = i === form.length - 1;
|
||||
return (
|
||||
<div
|
||||
key={n}
|
||||
className="flex items-center gap-4 px-4 py-3 flex-wrap"
|
||||
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
|
||||
>
|
||||
{/* 预览:与 LevelBadge 同构(外层底色 + .lv-text 文字层),实时反映未保存的修改 */}
|
||||
<span className="w-14 shrink-0 flex">
|
||||
<span
|
||||
className={LEVEL_BADGE_CLASS}
|
||||
style={{
|
||||
background: tokens.bg,
|
||||
boxShadow: `inset 0 0 0 1px ${tokens.ring ?? "transparent"}`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="lv-text"
|
||||
style={{
|
||||
backgroundImage: textBg.image,
|
||||
backgroundPosition: textBg.position,
|
||||
backgroundSize: textBg.size,
|
||||
backgroundClip: "text",
|
||||
WebkitBackgroundClip: "text",
|
||||
color: tokens.fg,
|
||||
}}
|
||||
>
|
||||
Lv.{n}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<div className="shrink-0">
|
||||
<p className="text-xs mb-1" style={{ color: "var(--ink-2)" }}>
|
||||
所需累计积分
|
||||
</p>
|
||||
<AdminStepper
|
||||
value={d.threshold}
|
||||
onChange={(v) => setThreshold(i, v)}
|
||||
min={0}
|
||||
max={1000000}
|
||||
unit="分"
|
||||
ariaLabel={`Lv.${n} 所需积分`}
|
||||
disabled={saving || n === 1}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs mb-1.5" style={{ color: "var(--ink-2)" }}>
|
||||
配色(前景/背景)
|
||||
</p>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{LEVEL_COLOR_KEYS.map((c) => {
|
||||
const t = LEVEL_COLOR_TOKENS[c];
|
||||
const active = d.color === c;
|
||||
return (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
aria-label={c}
|
||||
title={c}
|
||||
disabled={saving}
|
||||
onClick={() => setColor(i, c)}
|
||||
className="w-6 h-6 rounded-full transition-transform"
|
||||
style={{
|
||||
background: t.bg,
|
||||
outline: active
|
||||
? "2px solid var(--ink)"
|
||||
: "1px solid var(--line)",
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<span className="flex-1" />
|
||||
{isLast && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm shrink-0"
|
||||
style={{ color: "var(--danger)" }}
|
||||
disabled={saving || form.length <= 2}
|
||||
onClick={removeLast}
|
||||
>
|
||||
<Trash2 size={13} /> 删除此级
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* 添加新等级:整宽虚线条目,与上方项同宽 */}
|
||||
{form.length < MAX_LEVEL_COUNT ? (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center justify-center gap-1.5 py-3.5 text-sm transition-colors hover:opacity-80"
|
||||
style={{
|
||||
borderTop: "1.5px dashed var(--line)",
|
||||
color: "var(--ink-2)",
|
||||
}}
|
||||
disabled={saving}
|
||||
onClick={addLevel}
|
||||
>
|
||||
<Plus size={15} /> 添加新等级
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
className="w-full flex items-center justify-center py-3.5 text-xs"
|
||||
style={{
|
||||
borderTop: "1.5px dashed var(--line)",
|
||||
color: "var(--ink-3)",
|
||||
}}
|
||||
>
|
||||
已达 {MAX_LEVEL_COUNT} 级上限
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={saving || !dirty}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{saving ? "保存中…" : "保存等级配置"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={saving || !form}
|
||||
onClick={() => setResetOpen(true)}
|
||||
>
|
||||
<RotateCcw size={13} /> 重置
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminSwitch
|
||||
checked={fx}
|
||||
disabled={saving}
|
||||
onChange={() => setFx((v) => !v)}
|
||||
label="特效动画"
|
||||
/>
|
||||
</div>
|
||||
<span className="admin-settings-instant">
|
||||
{dirty
|
||||
? "有未保存的修改"
|
||||
: "已保存的配色在页面刷新后生效,等级数值即时按新阈值重算"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={resetOpen}
|
||||
title="重置等级配置"
|
||||
message={`将恢复为默认 ${DEFAULT_LEVEL_DEFS.length} 级配置(当前修改不会立即生效,需再点击保存)。确定重置?`}
|
||||
confirmLabel="重置"
|
||||
danger
|
||||
onCancel={() => setResetOpen(false)}
|
||||
onConfirm={resetToDefault}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={leave.leaveOpen}
|
||||
title="有未保存的修改"
|
||||
message="离开后,当前尚未保存的等级配置修改会丢失。确定离开?"
|
||||
confirmLabel="离开"
|
||||
danger
|
||||
onCancel={leave.cancelLeave}
|
||||
onConfirm={leave.confirmLeave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
frontend/app/admin/levels/page.tsx
Normal file
22
frontend/app/admin/levels/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import LevelsAdmin from "./LevelsAdmin";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { isSuperOrOwner } from "@/lib/roles";
|
||||
import Forbidden from "../Forbidden";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "等级管理",
|
||||
};
|
||||
|
||||
// 等级管理仅超管/站长可用(与徽章管理同权限;后端路由另有守卫)
|
||||
export default async function AdminLevelsPage() {
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
if (!me.user || !isSuperOrOwner(me.user.role)) {
|
||||
return <Forbidden text="等级管理仅超管/站长可操作。" />;
|
||||
}
|
||||
|
||||
return <LevelsAdmin />;
|
||||
}
|
||||
@@ -154,9 +154,9 @@ export default async function AdminDashboardPage() {
|
||||
description={`${dateLabel} · 站点管理`}
|
||||
actions={
|
||||
canEconomy ? (
|
||||
<Link href="/admin/economy" className="btn btn-ghost text-[13px] gap-1.5">
|
||||
<Link href="/admin/points" className="btn btn-ghost text-[13px] gap-1.5">
|
||||
<Coins size={15} aria-hidden />
|
||||
积分看板
|
||||
积分管理
|
||||
</Link>
|
||||
) : undefined
|
||||
}
|
||||
|
||||
93
frontend/app/admin/points/PointsTabs.tsx
Normal file
93
frontend/app/admin/points/PointsTabs.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { AdminSegmented } from "@/components/admin";
|
||||
import type { EconomyStats, PublicSettings } from "@/lib/api";
|
||||
import EconomyOverview from "./EconomyOverview";
|
||||
import EconomySettings from "../settings/EconomySettings";
|
||||
import UserPointsTab from "./UserPointsTab";
|
||||
|
||||
type TabKey = "overview" | "rules" | "users";
|
||||
|
||||
const TAB_OPTIONS = [
|
||||
{ key: "overview", label: "经济总览" },
|
||||
{ key: "rules", label: "积分规则" },
|
||||
{ key: "users", label: "用户积分" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 积分管理页标签壳:经济总览(管理员及以上)/ 积分规则 + 用户积分(超管/站长)。
|
||||
* tab 记在 URL query(?tab=)便于刷新/分享保持;三个面板保持挂载、用 display 切换,
|
||||
* 避免切标签丢失规则的未保存修改与用户列表状态。
|
||||
*/
|
||||
export default function PointsTabs({
|
||||
stats,
|
||||
settings,
|
||||
canManage,
|
||||
}: {
|
||||
stats: EconomyStats | null;
|
||||
settings: PublicSettings | null;
|
||||
canManage: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const queryTab = searchParams.get("tab");
|
||||
const [tab, setTab] = useState<TabKey>(() => {
|
||||
const t = queryTab;
|
||||
if (canManage && (t === "rules" || t === "users")) return t;
|
||||
return "overview";
|
||||
});
|
||||
|
||||
const switchTab = useCallback(
|
||||
(next: string) => {
|
||||
setTab(next as TabKey);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (next === "overview") params.delete("tab");
|
||||
else params.set("tab", next);
|
||||
const qs = params.toString();
|
||||
router.replace(qs ? `?${qs}` : "?", { scroll: false });
|
||||
},
|
||||
[router, searchParams]
|
||||
);
|
||||
|
||||
const options = canManage
|
||||
? TAB_OPTIONS
|
||||
: TAB_OPTIONS.filter((o) => o.key === "overview");
|
||||
|
||||
return (
|
||||
<div>
|
||||
{canManage ? (
|
||||
<div className="mb-4">
|
||||
<AdminSegmented
|
||||
options={options}
|
||||
value={tab}
|
||||
onChange={(v) => switchTab(String(v))}
|
||||
ariaLabel="积分管理标签"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 各面板保持挂载(display 切换):规则未保存修改与用户列表状态不因切标签丢失 */}
|
||||
<div style={tab === "overview" ? undefined : { display: "none" }}>
|
||||
{stats ? (
|
||||
<EconomyOverview stats={stats} />
|
||||
) : (
|
||||
<div className="panel p-8 text-center">
|
||||
<p className="meta">暂时无法加载积分数据</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canManage ? (
|
||||
<>
|
||||
<div style={tab === "rules" ? undefined : { display: "none" }}>
|
||||
{settings ? <EconomySettings initial={settings} /> : null}
|
||||
</div>
|
||||
<div style={tab === "users" ? undefined : { display: "none" }}>
|
||||
<UserPointsTab />
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
477
frontend/app/admin/points/UserPointsTab.tsx
Normal file
477
frontend/app/admin/points/UserPointsTab.tsx
Normal file
@@ -0,0 +1,477 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ChevronDown, ChevronUp, ChevronsUpDown, History, SlidersHorizontal, Users as UsersIcon } from "lucide-react";
|
||||
import {
|
||||
apiAdminAdjustUserPoints,
|
||||
apiAdminListUsers,
|
||||
apiAdminUserPointLedger,
|
||||
type AdminUser,
|
||||
type PointLedgerItem,
|
||||
} from "@/lib/api";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import LevelBadge from "@/components/LevelBadge";
|
||||
import Modal from "@/components/Modal";
|
||||
import { AdminPageHeader, AdminPager, AdminSearchField, AdminStepper } from "@/components/admin";
|
||||
import { pointReasonLabel } from "@/lib/points";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const MAX_DELTA = 100000; // 与后端 adminAdjustMaxDelta 一致
|
||||
const fmt = (n?: number) => (Number(n) || 0).toLocaleString("zh-CN");
|
||||
|
||||
type PointsSortKey = "id" | "points" | "total_points";
|
||||
|
||||
/** 表头排序按钮:未激活显示双向箭头,激活显示单向箭头并高亮 */
|
||||
function SortHeader({
|
||||
label,
|
||||
active,
|
||||
dir,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
dir: "asc" | "desc";
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 transition-colors hover:text-[var(--accent)]"
|
||||
style={{ color: active ? "var(--accent)" : "inherit" }}
|
||||
onClick={onClick}
|
||||
aria-label={`按${label}排序`}
|
||||
>
|
||||
{label}
|
||||
{active ? (
|
||||
dir === "asc" ? (
|
||||
<ChevronUp size={13} />
|
||||
) : (
|
||||
<ChevronDown size={13} />
|
||||
)
|
||||
) : (
|
||||
<ChevronsUpDown size={13} className="opacity-50" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户积分标签:搜索用户 → 余额/累计/等级一览;行内「调整」弹窗(加分计累计可升级,
|
||||
* 扣分仅扣余额不降级,理由必填)与「流水」弹窗(分页查看该用户积分流水)。
|
||||
*/
|
||||
export default function UserPointsTab() {
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [appliedQ, setAppliedQ] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [sort, setSort] = useState<PointsSortKey>("id");
|
||||
const [dir, setDir] = useState<"asc" | "desc">("desc");
|
||||
|
||||
const [adjustTarget, setAdjustTarget] = useState<AdminUser | null>(null);
|
||||
const [ledgerTarget, setLedgerTarget] = useState<AdminUser | null>(null);
|
||||
|
||||
const seqRef = useRef(0);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// 排序走 ref:load 身份保持稳定,防抖搜索与切页自动沿用当前排序
|
||||
const sortRef = useRef<{ sort: PointsSortKey; dir: "asc" | "desc" }>({ sort: "id", dir: "desc" });
|
||||
|
||||
const load = useCallback(async (opts: { page: number; q: string }) => {
|
||||
const seq = ++seqRef.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiAdminListUsers({
|
||||
page: opts.page,
|
||||
size: PAGE_SIZE,
|
||||
q: opts.q || undefined,
|
||||
sort: sortRef.current.sort,
|
||||
order: sortRef.current.dir,
|
||||
});
|
||||
if (seq !== seqRef.current) return;
|
||||
setUsers(res.users);
|
||||
setTotal(res.total);
|
||||
setPage(opts.page);
|
||||
setAppliedQ(opts.q);
|
||||
setError("");
|
||||
setLoaded(true);
|
||||
} catch (e) {
|
||||
if (seq !== seqRef.current) return;
|
||||
setError(e instanceof Error ? e.message : "获取用户列表失败");
|
||||
} finally {
|
||||
if (seq === seqRef.current) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load({ page: 1, q: "" });
|
||||
}, [load]);
|
||||
|
||||
// 搜索 400ms 防抖(对齐 UsersAdmin)
|
||||
const onKeywordChange = useCallback(
|
||||
(v: string) => {
|
||||
setKeyword(v);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
void load({ page: 1, q: v.trim() });
|
||||
}, 400);
|
||||
},
|
||||
[load]
|
||||
);
|
||||
|
||||
const patchUser = useCallback((id: number, patch: Partial<AdminUser>) => {
|
||||
setUsers((prev) => prev.map((u) => (u.id === id ? { ...u, ...patch } : u)));
|
||||
}, []);
|
||||
|
||||
// 切换排序:点未激活列 → 该列降序;点已激活列 → 升降切换;始终回到第 1 页
|
||||
const toggleSort = useCallback(
|
||||
(key: PointsSortKey) => {
|
||||
const cur = sortRef.current;
|
||||
const next = cur.sort === key ? { sort: key, dir: cur.dir === "desc" ? ("asc" as const) : ("desc" as const) } : { sort: key, dir: "desc" as const };
|
||||
sortRef.current = next;
|
||||
setSort(next.sort);
|
||||
setDir(next.dir);
|
||||
void load({ page: 1, q: appliedQ });
|
||||
},
|
||||
[load, appliedQ]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminPageHeader
|
||||
icon={UsersIcon}
|
||||
title="用户积分"
|
||||
description="查看与管理用户积分:加分计入累计获得(可升级),扣分仅扣余额不降级"
|
||||
/>
|
||||
|
||||
<div className="mb-4 max-w-xs">
|
||||
<AdminSearchField
|
||||
value={keyword}
|
||||
onChange={onKeywordChange}
|
||||
placeholder="搜索用户名 / 昵称 / 邮箱"
|
||||
ariaLabel="搜索用户"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="panel p-0 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[13px]" style={{ borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid var(--line)" }}>
|
||||
<th className="px-4 py-2.5 text-left font-medium" style={{ color: "var(--ink-3)" }}>
|
||||
用户
|
||||
</th>
|
||||
<th className="px-4 py-2.5 text-right font-medium" style={{ color: "var(--ink-3)" }}>
|
||||
<SortHeader label="余额" active={sort === "points"} dir={dir} onClick={() => toggleSort("points")} />
|
||||
</th>
|
||||
<th className="px-4 py-2.5 text-right font-medium" style={{ color: "var(--ink-3)" }}>
|
||||
<SortHeader label="累计获得" active={sort === "total_points"} dir={dir} onClick={() => toggleSort("total_points")} />
|
||||
</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium" style={{ color: "var(--ink-3)" }}>
|
||||
等级
|
||||
</th>
|
||||
<th className="px-4 py-2.5 text-right font-medium" style={{ color: "var(--ink-3)" }}>
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} style={{ borderBottom: "1px solid var(--line)" }}>
|
||||
<td className="px-4 py-2.5">
|
||||
<Link href={`/u/${u.id}`} className="flex items-center gap-2.5 min-w-0 group">
|
||||
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={30} />
|
||||
<span className="min-w-0">
|
||||
<span
|
||||
className="block truncate text-[13.5px] font-medium group-hover:text-[var(--accent)] transition-colors"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{u.nickname || u.username}
|
||||
</span>
|
||||
<span className="block meta text-[11.5px] truncate">@{u.username}</span>
|
||||
</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums" style={{ color: "var(--ink)" }}>
|
||||
{fmt(u.points)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums" style={{ color: "var(--ink-2)" }}>
|
||||
{fmt(u.total_points)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<LevelBadge level={u.level} />
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
onClick={() => setLedgerTarget(u)}
|
||||
>
|
||||
<History size={13} /> 流水
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
onClick={() => setAdjustTarget(u)}
|
||||
>
|
||||
<SlidersHorizontal size={13} /> 调整
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{loading ? <p className="meta text-center py-4">加载中…</p> : null}
|
||||
{!loading && error ? <p className="text-center py-6 text-[13px]" style={{ color: "var(--danger)" }}>{error}</p> : null}
|
||||
{!loading && !error && loaded && users.length === 0 ? (
|
||||
<p className="meta text-center py-10">没有匹配的用户</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!error && users.length > 0 ? (
|
||||
<div className="mt-4">
|
||||
<AdminPager page={page} total={total} pageSize={PAGE_SIZE} loading={loading} onChange={(p) => void load({ page: p, q: appliedQ })} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{adjustTarget ? (
|
||||
<AdjustModal
|
||||
user={adjustTarget}
|
||||
onClose={() => setAdjustTarget(null)}
|
||||
onAdjusted={(balance, totalPoints, level) => {
|
||||
patchUser(adjustTarget.id, { points: balance, total_points: totalPoints, level });
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{ledgerTarget ? (
|
||||
<LedgerModal user={ledgerTarget} onClose={() => setLedgerTarget(null)} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 调整弹窗:方向(加/减)+ 数量 + 理由;成功后回填余额/累计/等级 */
|
||||
function AdjustModal({
|
||||
user,
|
||||
onClose,
|
||||
onAdjusted,
|
||||
}: {
|
||||
user: AdminUser;
|
||||
onClose: () => void;
|
||||
onAdjusted: (balance: number, totalPoints: number, level: number) => void;
|
||||
}) {
|
||||
const [dir, setDir] = useState<"add" | "sub">("add");
|
||||
const [amount, setAmount] = useState(10);
|
||||
const [note, setNote] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
if (saving) return;
|
||||
if (!Number.isInteger(amount) || amount < 1 || amount > MAX_DELTA) {
|
||||
toast(`积分数量须为 1–${MAX_DELTA} 的整数`);
|
||||
return;
|
||||
}
|
||||
const trimmed = note.trim();
|
||||
if (!trimmed) {
|
||||
toast("请填写调整理由");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const delta = dir === "add" ? amount : -amount;
|
||||
const res = await apiAdminAdjustUserPoints(user.id, delta, trimmed);
|
||||
onAdjusted(res.balance, res.total_points, res.level);
|
||||
toast(
|
||||
`已${dir === "add" ? "增加" : "扣减"} ${fmt(amount)} 积分:余额 ${fmt(res.balance)},当前 Lv.${res.level}`,
|
||||
"ok"
|
||||
);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "积分调整失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title={`调整积分 - ${user.nickname || user.username}`}
|
||||
description={`当前余额 ${fmt(user.points)} · 累计获得 ${fmt(user.total_points)} · Lv.${user.level ?? 1}`}
|
||||
onClose={onClose}
|
||||
busy={saving}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn btn-line btn-sm" onClick={onClose} disabled={saving}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={() => void submit()} disabled={saving}>
|
||||
{saving ? "提交中…" : "确认调整"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="text-xs mb-1.5" style={{ color: "var(--ink-2)" }}>
|
||||
方向
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDir("add")}
|
||||
className={`btn btn-sm flex-1 ${dir === "add" ? "btn-primary" : "btn-line"}`}
|
||||
>
|
||||
增加
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDir("sub")}
|
||||
className={`btn btn-sm flex-1 ${dir === "sub" ? "btn-danger" : "btn-line"}`}
|
||||
>
|
||||
扣减
|
||||
</button>
|
||||
</div>
|
||||
<p className="meta text-[11.5px] mt-1.5">
|
||||
{dir === "add"
|
||||
? "加分同时计入累计获得,可能触发升级"
|
||||
: "扣分仅扣减余额,不改变累计获得与等级"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs mb-1.5" style={{ color: "var(--ink-2)" }}>
|
||||
数量(1–{MAX_DELTA})
|
||||
</p>
|
||||
<AdminStepper
|
||||
value={amount}
|
||||
onChange={setAmount}
|
||||
min={1}
|
||||
max={MAX_DELTA}
|
||||
unit="分"
|
||||
ariaLabel="调整数量"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs mb-1.5" style={{ color: "var(--ink-2)" }}>
|
||||
调整理由(必填,写入积分流水)
|
||||
</p>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
maxLength={200}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-[13px] rounded-[var(--r-sm)] outline-none focus:border-[var(--accent)] transition-colors"
|
||||
style={{ background: "var(--panel-2)", border: "1px solid var(--line-2)", color: "var(--ink)" }}
|
||||
placeholder="例如:活动奖励补偿 / 违规扣减"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** 流水弹窗:懒加载分页查询该用户积分流水 */
|
||||
function LedgerModal({ user, onClose }: { user: AdminUser; onClose: () => void }) {
|
||||
const [items, setItems] = useState<PointLedgerItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = useCallback(async (p: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiAdminUserPointLedger(user.id, p);
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
setPage(p);
|
||||
setError("");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "获取积分流水失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [user.id]);
|
||||
|
||||
useEffect(() => {
|
||||
void load(1);
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title={`积分流水 - ${user.nickname || user.username}`}
|
||||
description={`余额 ${fmt(user.points)} · 累计获得 ${fmt(user.total_points)} · 共 ${fmt(total)} 条`}
|
||||
onClose={onClose}
|
||||
maxWidthClass="max-w-2xl"
|
||||
>
|
||||
{loading && items.length === 0 ? (
|
||||
<p className="meta text-center py-8">加载中…</p>
|
||||
) : error ? (
|
||||
<p className="text-center py-8 text-[13px]" style={{ color: "var(--danger)" }}>
|
||||
{error}
|
||||
</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="meta text-center py-8">暂无积分流水</p>
|
||||
) : (
|
||||
<>
|
||||
<ul className="flex flex-col max-h-[50vh] overflow-y-auto">
|
||||
{items.map((it) => {
|
||||
const delta = Number(it.delta) || 0;
|
||||
return (
|
||||
<li
|
||||
key={it.id}
|
||||
className="flex items-center gap-3 py-2.5"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium select-none"
|
||||
style={{
|
||||
background: delta > 0 ? "var(--ok-soft)" : "var(--danger-soft)",
|
||||
color: delta > 0 ? "var(--ok)" : "var(--danger)",
|
||||
}}
|
||||
>
|
||||
{delta > 0 ? `+${fmt(delta)}` : fmt(delta)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[13px] truncate" style={{ color: "var(--ink)" }}>
|
||||
{pointReasonLabel(it.reason)}
|
||||
{it.note ? (
|
||||
<span className="ml-1.5 text-[12px]" style={{ color: "var(--ink-3)" }}>
|
||||
{it.note}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="block meta text-[11.5px] mt-0.5">
|
||||
变动后余额 {fmt(it.balance)} · {formatRelative(it.created_at)}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="mt-4">
|
||||
<AdminPager
|
||||
page={page}
|
||||
total={total}
|
||||
pageSize={20}
|
||||
loading={loading}
|
||||
onChange={(p) => void load(p)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
44
frontend/app/admin/points/page.tsx
Normal file
44
frontend/app/admin/points/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { fetchAdminEconomyStats, type EconomyStats, type PublicSettings } from "@/lib/api";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||
import { isAdminOrAbove, isSuperOrOwner } from "@/lib/roles";
|
||||
import Forbidden from "../Forbidden";
|
||||
import PointsTabs from "./PointsTabs";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "积分管理",
|
||||
};
|
||||
|
||||
export default async function AdminPointsPage() {
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
||||
return <Forbidden text="积分管理仅管理员及以上可查看。" />;
|
||||
}
|
||||
|
||||
const canManage = isSuperOrOwner(me.user.role);
|
||||
|
||||
// 经济总览预取(管理员及以上可读);失败降级为空态,不阻塞规则/用户积分标签
|
||||
let stats: Awaited<ReturnType<typeof fetchAdminEconomyStats>> | null = null;
|
||||
try {
|
||||
stats = await fetchAdminEconomyStats(cookie);
|
||||
} catch {
|
||||
stats = null;
|
||||
}
|
||||
|
||||
// 积分规则初始值(公开设置缓存,超管/站长的规则标签用)
|
||||
let settings: PublicSettings | null = null;
|
||||
if (canManage) {
|
||||
settings = await getPublicSettingsCached();
|
||||
}
|
||||
|
||||
return (
|
||||
<PointsTabs
|
||||
stats={stats}
|
||||
settings={settings}
|
||||
canManage={canManage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
237
frontend/app/admin/settings/EconomySettings.tsx
Normal file
237
frontend/app/admin/settings/EconomySettings.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { CalendarCheck, Flame, MessageSquarePlus, PenLine, Star } from "lucide-react";
|
||||
import { AdminSettingsGroup, AdminSettingsRow, AdminStepper } from "@/components/admin";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
|
||||
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
/** 积分规则 8 键(0–100,后端按 0–100 裁剪) */
|
||||
type PointsKeys = Pick<
|
||||
PublicSettings,
|
||||
| "points_checkin_base"
|
||||
| "points_streak_every_days"
|
||||
| "points_streak_bonus"
|
||||
| "points_post_reward"
|
||||
| "points_post_daily_cap"
|
||||
| "points_reply_reward"
|
||||
| "points_reply_daily_cap"
|
||||
| "points_recommend_reward"
|
||||
>;
|
||||
|
||||
const POINTS_KEYS: (keyof PointsKeys)[] = [
|
||||
"points_checkin_base",
|
||||
"points_streak_every_days",
|
||||
"points_streak_bonus",
|
||||
"points_post_reward",
|
||||
"points_post_daily_cap",
|
||||
"points_reply_reward",
|
||||
"points_reply_daily_cap",
|
||||
"points_recommend_reward",
|
||||
];
|
||||
|
||||
/** 管理员可配置的积分增长规则:签到基础分 / 连续签到加成 / 发帖奖励 / 回复奖励(各带每日上限)/ 推荐奖励 */
|
||||
export default function EconomySettings({ initial }: { initial: PublicSettings }) {
|
||||
const pickInitial = (): PointsKeys =>
|
||||
Object.fromEntries(POINTS_KEYS.map((k) => [k, initial[k]])) as PointsKeys;
|
||||
|
||||
const [form, setForm] = useState<PointsKeys>(pickInitial);
|
||||
const [saved, setSaved] = useState<PointsKeys>(pickInitial);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const dirty = useMemo(() => POINTS_KEYS.some((k) => form[k] !== saved[k]), [form, saved]);
|
||||
const leave = useUnsavedGuard(dirty);
|
||||
|
||||
const set = (k: keyof PointsKeys, v: number) => setForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
const save = async () => {
|
||||
if (saving || !dirty) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = Object.fromEntries(POINTS_KEYS.map((k) => [k, form[k]]));
|
||||
const res = await apiUpdateSiteSettings(payload);
|
||||
const next = Object.fromEntries(POINTS_KEYS.map((k) => [k, res[k]])) as PointsKeys;
|
||||
setForm(next);
|
||||
setSaved(next);
|
||||
toast("积分规则已保存", "ok");
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-settings-stack">
|
||||
<AdminSettingsGroup title="签到">
|
||||
<AdminSettingsRow
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<CalendarCheck size={15} /> 签到基础分
|
||||
</span>
|
||||
}
|
||||
hintBelow="每日首次签到固定获得的积分(0=关闭,上限 100)。"
|
||||
>
|
||||
<AdminStepper
|
||||
value={form.points_checkin_base}
|
||||
onChange={(v) => set("points_checkin_base", v)}
|
||||
min={0}
|
||||
max={100}
|
||||
unit="分"
|
||||
ariaLabel="签到基础分"
|
||||
disabled={saving}
|
||||
/>
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Flame size={15} /> 连续签到触发天数
|
||||
</span>
|
||||
}
|
||||
hintBelow="连续签到每满 N 天额外发放一次加成(0=关闭,上限 100)。"
|
||||
>
|
||||
<AdminStepper
|
||||
value={form.points_streak_every_days}
|
||||
onChange={(v) => set("points_streak_every_days", v)}
|
||||
min={0}
|
||||
max={100}
|
||||
unit="天"
|
||||
ariaLabel="连续签到触发天数"
|
||||
disabled={saving}
|
||||
/>
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Flame size={15} /> 连续签到加成
|
||||
</span>
|
||||
}
|
||||
hintBelow="触发加成时额外获得的积分(0=关闭,上限 100);与触发天数配合使用。"
|
||||
>
|
||||
<AdminStepper
|
||||
value={form.points_streak_bonus}
|
||||
onChange={(v) => set("points_streak_bonus", v)}
|
||||
min={0}
|
||||
max={100}
|
||||
unit="分"
|
||||
ariaLabel="连续签到加成分值"
|
||||
disabled={saving}
|
||||
/>
|
||||
</AdminSettingsRow>
|
||||
</AdminSettingsGroup>
|
||||
|
||||
<AdminSettingsGroup title="发帖与回复">
|
||||
<AdminSettingsRow
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<PenLine size={15} /> 发帖奖励
|
||||
</span>
|
||||
}
|
||||
hintBelow="帖子发布成功后发放(0=关闭,上限 100);仅公开可见的帖子计奖,删除不回追。"
|
||||
>
|
||||
<AdminStepper
|
||||
value={form.points_post_reward}
|
||||
onChange={(v) => set("points_post_reward", v)}
|
||||
min={0}
|
||||
max={100}
|
||||
unit="分"
|
||||
ariaLabel="发帖奖励分值"
|
||||
disabled={saving}
|
||||
/>
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow
|
||||
label={<span className="inline-flex items-center gap-1.5">发帖奖励每日上限</span>}
|
||||
hintBelow="每人每天最多计奖的帖子数(0=不限,上限 100),防止刷分。"
|
||||
>
|
||||
<AdminStepper
|
||||
value={form.points_post_daily_cap}
|
||||
onChange={(v) => set("points_post_daily_cap", v)}
|
||||
min={0}
|
||||
max={100}
|
||||
unit="帖"
|
||||
ariaLabel="发帖奖励每日上限"
|
||||
disabled={saving}
|
||||
/>
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<MessageSquarePlus size={15} /> 回复奖励
|
||||
</span>
|
||||
}
|
||||
hintBelow="评论发布成功后发放(0=关闭,上限 100);仅公开可见的评论计奖。"
|
||||
>
|
||||
<AdminStepper
|
||||
value={form.points_reply_reward}
|
||||
onChange={(v) => set("points_reply_reward", v)}
|
||||
min={0}
|
||||
max={100}
|
||||
unit="分"
|
||||
ariaLabel="回复奖励分值"
|
||||
disabled={saving}
|
||||
/>
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow
|
||||
label={<span className="inline-flex items-center gap-1.5">回复奖励每日上限</span>}
|
||||
hintBelow="每人每天最多计奖的评论数(0=不限,上限 100),防止刷分。"
|
||||
>
|
||||
<AdminStepper
|
||||
value={form.points_reply_daily_cap}
|
||||
onChange={(v) => set("points_reply_daily_cap", v)}
|
||||
min={0}
|
||||
max={100}
|
||||
unit="条"
|
||||
ariaLabel="回复奖励每日上限"
|
||||
disabled={saving}
|
||||
/>
|
||||
</AdminSettingsRow>
|
||||
</AdminSettingsGroup>
|
||||
|
||||
<AdminSettingsGroup title="帖子推荐">
|
||||
<AdminSettingsRow
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Star size={15} /> 推荐奖励
|
||||
</span>
|
||||
}
|
||||
hintBelow="帖子被管理员推荐(加精)时,作者一次性获得该积分(0=关闭,上限 100);推荐人为作者本人时不发放,同一帖子只发放一次。"
|
||||
>
|
||||
<AdminStepper
|
||||
value={form.points_recommend_reward}
|
||||
onChange={(v) => set("points_recommend_reward", v)}
|
||||
min={0}
|
||||
max={100}
|
||||
unit="分"
|
||||
ariaLabel="推荐奖励分值"
|
||||
disabled={saving}
|
||||
/>
|
||||
</AdminSettingsRow>
|
||||
</AdminSettingsGroup>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-solid btn-sm"
|
||||
disabled={saving || !dirty}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{saving ? "保存中…" : "保存积分规则"}
|
||||
</button>
|
||||
<span className="admin-settings-instant">
|
||||
{dirty ? "有未保存的修改" : "已保存的规则对后续签到/发帖/回复/推荐生效"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={leave.leaveOpen}
|
||||
title="有未保存的修改"
|
||||
message="离开后,当前尚未保存的积分规则修改会丢失。确定离开?"
|
||||
confirmLabel="离开"
|
||||
danger
|
||||
onCancel={leave.cancelLeave}
|
||||
onConfirm={leave.confirmLeave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -49,6 +49,8 @@ import { canOverseeChat, isOwner as isSiteOwner } from "@/lib/roles";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { scrollBehavior } from "@/lib/motion";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import LevelBadge from "@/components/LevelBadge";
|
||||
import BadgeChip from "@/components/BadgeChip";
|
||||
import AutolinkText from "@/components/AutolinkText";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
|
||||
@@ -1420,6 +1422,14 @@ export default function ChatRoomClient({
|
||||
>
|
||||
{m.sender.nickname}
|
||||
</span>
|
||||
{!isDirect && (
|
||||
<>
|
||||
<LevelBadge level={m.sender.level} />
|
||||
{(m.sender.badges ?? []).slice(0, 1).map((ub) => (
|
||||
<BadgeChip key={ub.id} badge={ub.badge} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<span className="meta text-[10.5px]">
|
||||
{formatTime(m.created_at)}
|
||||
</span>
|
||||
|
||||
@@ -32,6 +32,14 @@
|
||||
--ok: #137333;
|
||||
--ok-soft: rgba(19, 115, 51, 0.1);
|
||||
|
||||
/* 等级徽章与自定义徽章扩展色板 */
|
||||
--info: #1c6ea8;
|
||||
--info-soft: rgba(28, 110, 168, 0.1);
|
||||
--purple: #6d3fc0;
|
||||
--purple-soft: rgba(109, 63, 192, 0.1);
|
||||
--teal: #0f7a6e;
|
||||
--teal-soft: rgba(15, 122, 110, 0.1);
|
||||
|
||||
/* 积分看板最近流水:金额与类型标签 */
|
||||
--ledger-in: #16a34a;
|
||||
--ledger-out: #dc2626;
|
||||
@@ -103,6 +111,18 @@
|
||||
--rail-top: calc(var(--header-h) + var(--main-pad-y));
|
||||
--rail-max-h: calc(100vh - var(--rail-top) - 1rem);
|
||||
|
||||
/* 等级徽章默认色兜底(layout.tsx 会按后台配置注入 --lv-N-* 覆盖此处;
|
||||
与 lib/levels.ts 色板保持同步:灰/蓝/青/绿/红牌/黑金/鎏金,
|
||||
特效档为含高光段的流动渐变,流光由高光段随 position 流动呈现) */
|
||||
--lv-1-fg: #9aa3b2; --lv-1-bg: rgba(148,158,176,0.16); --lv-1-ring: transparent;
|
||||
--lv-2-fg: #4d8dff; --lv-2-bg: rgba(59,118,240,0.15); --lv-2-ring: transparent;
|
||||
--lv-3-fg: #12a89b; --lv-3-bg: rgba(14,148,136,0.15); --lv-3-ring: transparent;
|
||||
--lv-4-fg: #27a94f; --lv-4-bg: rgba(31,156,71,0.15); --lv-4-ring: transparent;
|
||||
--lv-5-fg: #ffffff; --lv-5-bg: linear-gradient(90deg, #b91c1c, #ef4444 25%, #fecaca 38%, #dc2626 52%, #991b1b 78%, #b91c1c) 0% 0% / 200% 100%; --lv-5-ring: transparent;
|
||||
--lv-6-fg: #e9c46a; --lv-6-bg: linear-gradient(90deg, #16171c, #23252c 22%, #4a3d1c 36%, #23252c 52%, #16171c 78%) 0% 0% / 200% 100%; --lv-6-ring: rgba(233, 196, 106, 0.5);
|
||||
--lv-7-fg: transparent; --lv-7-bg: linear-gradient(180deg, #221b0f, #100d07); --lv-7-ring: rgba(240, 200, 120, 0.45);
|
||||
--lv-7-text-image: linear-gradient(90deg, #c9973f, #ffe9a8 30%, #fff6d8 46%, #f0c964 66%, #c9973f); --lv-7-text-position: 0% 0%; --lv-7-text-size: 200% 100%;
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
@@ -133,6 +153,14 @@
|
||||
--ok: #58d18a;
|
||||
--ok-soft: rgba(88, 209, 138, 0.15);
|
||||
|
||||
/* 等级徽章与自定义徽章扩展色板 */
|
||||
--info: #5db8f0;
|
||||
--info-soft: rgba(93, 184, 240, 0.16);
|
||||
--purple: #c39bf5;
|
||||
--purple-soft: rgba(195, 155, 245, 0.16);
|
||||
--teal: #4fd8c4;
|
||||
--teal-soft: rgba(79, 216, 196, 0.16);
|
||||
|
||||
--ledger-in: #4ade80;
|
||||
--ledger-out: #f87171;
|
||||
--ledger-checkin-bg: rgba(22, 163, 74, 0.22);
|
||||
@@ -279,6 +307,51 @@ html.j13-has-admin-bg[data-j13-chrome="admin"] body::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 等级徽章特效(LevelBadge / .lv-badge):底色/前景由 SSR 注入的 --lv-N-* 变量驱动。
|
||||
特效档背景为水平多段渐变(首尾同色)+ background-size 200%,
|
||||
lv-flow 位移 0→200% 恰好一个图块周期(百分比 position 非线性:偏移=(100%-size)×p,
|
||||
size=200% 时 p=200% 的偏移=-200% 即一个图块宽),循环首尾严格重合、无接缝。
|
||||
.lv-text 为文字渐变层(--lv-N-text 存在时经 background-clip:text 呈现鎏金字,
|
||||
同样走 lv-flow 流动;无渐变档 background 为 none,文字按 --lv-N-fg 正常着色) */
|
||||
@keyframes lv-flow {
|
||||
to {
|
||||
background-position: 200% 0%;
|
||||
}
|
||||
}
|
||||
.lv-badge {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
animation: lv-flow 6s linear infinite;
|
||||
}
|
||||
.lv-text {
|
||||
animation: lv-flow 6s linear infinite;
|
||||
}
|
||||
/* 站点特效总开关(levels_fx 设置,layout SSR 注入 html[data-lv-fx]):
|
||||
关闭时停用流光动画,徽章退化为静态配色 */
|
||||
html[data-lv-fx="off"] .lv-badge,
|
||||
html[data-lv-fx="off"] .lv-text {
|
||||
animation: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lv-badge,
|
||||
.lv-text {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 徽章类统一禁止鼠标选择:拖选/双击正文时避免把小胶囊文字带进选区 */
|
||||
.lv-badge,
|
||||
.cmt-badge,
|
||||
.badge-pending,
|
||||
.badge-type,
|
||||
.badge-access,
|
||||
.badge-status,
|
||||
.badge-accepted,
|
||||
.j13-board-tag {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* 全站悬停即显提示气泡(配合 TipHost + data-tip,替代原生 title 延迟) */
|
||||
.j13-tip-bubble {
|
||||
position: fixed;
|
||||
@@ -3079,6 +3152,9 @@ body:has([data-admin-viewport]) {
|
||||
height: 100%;
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: admin-drawer-in 0.18s ease-out;
|
||||
/* 覆盖 .admin-rail 的上下内边距并叠加刘海/手势区避让 */
|
||||
padding-top: calc(1rem + env(safe-area-inset-top, 0px));
|
||||
padding-bottom: calc(1.25rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.admin-rail-brand {
|
||||
@@ -3265,6 +3341,7 @@ body:has([data-admin-viewport]) {
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-drawer-backdrop {
|
||||
@@ -3273,7 +3350,8 @@ body:has([data-admin-viewport]) {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
background: color-mix(in srgb, var(--ink) 28%, transparent);
|
||||
background: color-mix(in srgb, var(--ink) 32%, transparent);
|
||||
animation: admin-fade-in 0.18s ease-out;
|
||||
}
|
||||
|
||||
@keyframes admin-drawer-in {
|
||||
@@ -3287,8 +3365,49 @@ body:has([data-admin-viewport]) {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes admin-drawer-out {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes admin-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes admin-fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 退场:面板滑出 + 背板淡出,结束后由组件卸载 */
|
||||
.admin-drawer.is-closing .admin-drawer-backdrop {
|
||||
animation: admin-fade-out 0.18s ease-in forwards;
|
||||
}
|
||||
|
||||
.admin-drawer.is-closing .admin-drawer-panel {
|
||||
animation: admin-drawer-out 0.18s ease-in forwards;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.admin-drawer-panel {
|
||||
.admin-drawer-panel,
|
||||
.admin-drawer-backdrop,
|
||||
.admin-drawer.is-closing .admin-drawer-panel,
|
||||
.admin-drawer.is-closing .admin-drawer-backdrop {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||
import { TOKEN_COOKIE, authCookieHeader } from "@/lib/cookies";
|
||||
import { accentStyleCSS } from "@/lib/theme";
|
||||
import { bgConfigFromSettings, bgStyleCSS } from "@/lib/bg";
|
||||
import { levelVarsCSS } from "@/lib/levels";
|
||||
import { siteDocumentTitle } from "@/lib/brand";
|
||||
import { motionConfigFromSettings } from "@/lib/motion";
|
||||
import { peekPublicMetaSnapshot, publishPublicMetaSnapshot } from "@/lib/publicMetaSnapshot";
|
||||
@@ -126,6 +127,7 @@ export default async function RootLayout({
|
||||
<html
|
||||
lang="zh-CN"
|
||||
data-j13-chrome={isAdmin ? "admin" : "site"}
|
||||
data-lv-fx={settings.levels_fx === false ? "off" : "on"}
|
||||
className={`${theme === "dark" ? "dark" : ""} ${motionHtmlClass} ${jakarta.variable} ${notoSC.variable} ${jetbrainsMono.variable}`.trim()}
|
||||
>
|
||||
<body className="min-h-screen flex flex-col">
|
||||
@@ -144,6 +146,12 @@ export default async function RootLayout({
|
||||
dangerouslySetInnerHTML={{ __html: bgCSS }}
|
||||
/>
|
||||
) : null}
|
||||
{/* 等级配色变量(--lv-N-fg/-bg,引用设计 token 随明暗主题切换);恒渲染兼作兜底 */}
|
||||
<style
|
||||
id="j13-level-vars"
|
||||
data-level-source="ssr"
|
||||
dangerouslySetInnerHTML={{ __html: levelVarsCSS(settings.levels) }}
|
||||
/>
|
||||
<a href="#main" className="skip-link">
|
||||
跳到主要内容
|
||||
</a>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Heart, MessageCircle, CheckCheck, Bell, CheckCircle2, XCircle, AtSign, ClipboardList } from "lucide-react";
|
||||
import { Heart, MessageCircle, CheckCheck, Bell, CheckCircle2, XCircle, AtSign, ClipboardList, Award } from "lucide-react";
|
||||
import { apiMarkRead, apiMarkAllRead, type NotificationsResponse, type NotificationItem } from "@/lib/api";
|
||||
|
||||
/**
|
||||
@@ -20,11 +20,13 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
|
||||
list.map((item) => (item.id === n.id ? { ...item, is_read: true } : item))
|
||||
);
|
||||
}
|
||||
// @提及通知跳到群聊房间;其余跳到帖子/评论
|
||||
// @提及通知跳到群聊房间;徽章通知无关联帖子(仅标已读);其余跳到帖子/评论
|
||||
if (n.type === "mention" && n.room_id) {
|
||||
router.push(`/chat/${n.room_id}`);
|
||||
} else if (n.type === "pending_review") {
|
||||
router.push(n.post_id ? `/post/${n.post_id}` : "/admin/content?status=pending");
|
||||
} else if (n.type === "badge") {
|
||||
return;
|
||||
} else {
|
||||
router.push(`/post/${n.post_id}`);
|
||||
}
|
||||
@@ -84,21 +86,27 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
|
||||
? <AtSign size={16} />
|
||||
: n.type === "pending_review"
|
||||
? <ClipboardList size={16} />
|
||||
: <MessageCircle size={16} />;
|
||||
: n.type === "badge"
|
||||
? <Award size={16} />
|
||||
: <MessageCircle size={16} />;
|
||||
const iconColor = n.type === "approved"
|
||||
? "var(--ok)"
|
||||
: n.type === "rejected" || n.type === "deleted"
|
||||
? "var(--danger)"
|
||||
: n.type === "like"
|
||||
? "var(--clay)"
|
||||
: "var(--accent)";
|
||||
: n.type === "badge"
|
||||
? "var(--gold)"
|
||||
: "var(--accent)";
|
||||
const iconBg = n.type === "approved"
|
||||
? "var(--ok-soft)"
|
||||
: n.type === "rejected" || n.type === "deleted"
|
||||
? "var(--danger-soft)"
|
||||
: n.type === "like"
|
||||
? "var(--clay-soft)"
|
||||
: "var(--accent-soft)";
|
||||
: n.type === "badge"
|
||||
? "var(--gold-soft)"
|
||||
: "var(--accent-soft)";
|
||||
return (
|
||||
<button
|
||||
key={n.id}
|
||||
@@ -145,7 +153,9 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
|
||||
? "有新内容待审核"
|
||||
: n.type === "deleted"
|
||||
? "删除了你的帖子"
|
||||
: "点赞了你的帖子"}
|
||||
: n.type === "badge"
|
||||
? "向你颁布了徽章"
|
||||
: "点赞了你的帖子"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -32,6 +32,8 @@ import { SiteDocBreadcrumb } from "@/components/site-doc";
|
||||
import MobilePostBar from "@/components/MobilePostBar";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import RoleBadge from "@/components/RoleBadge";
|
||||
import LevelBadge from "@/components/LevelBadge";
|
||||
import BadgeChip from "@/components/BadgeChip";
|
||||
import MarkdownBody from "@/components/MarkdownBody";
|
||||
import { publicMarkdownForSeo } from "@/lib/hideBlocks";
|
||||
import PostAttachments from "@/components/PostAttachments";
|
||||
@@ -185,6 +187,10 @@ function AuthorCardStacked({
|
||||
>
|
||||
{u.nickname}
|
||||
</Link>
|
||||
<LevelBadge level={u.level} />
|
||||
{(u.badges ?? []).slice(0, 2).map((ub) => (
|
||||
<BadgeChip key={ub.id} badge={ub.badge} />
|
||||
))}
|
||||
{u.role !== "user" && <RoleBadge role={u.role} boardIds={u.board_ids} />}
|
||||
</div>
|
||||
<p className="meta mt-1 flex items-center gap-1.5 flex-wrap">
|
||||
|
||||
@@ -17,6 +17,8 @@ import PasswordForm from "./PasswordForm";
|
||||
import LoginDevices from "./LoginDevices";
|
||||
import ProfileHeroAvatar from "./ProfileHeroAvatar";
|
||||
import RoleBadge from "@/components/RoleBadge";
|
||||
import LevelBadge from "@/components/LevelBadge";
|
||||
import BadgeChip from "@/components/BadgeChip";
|
||||
import { roleMeta } from "@/lib/roles";
|
||||
import StartDMButton from "./StartDMButton";
|
||||
import StaffUserMenu from "@/components/StaffUserMenu";
|
||||
@@ -253,6 +255,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
||||
>
|
||||
{u.nickname || u.username}
|
||||
</h1>
|
||||
<LevelBadge level={u.level} />
|
||||
{u.role !== "user" && (
|
||||
<RoleBadge role={u.role} boardIds={u.board_ids} />
|
||||
)}
|
||||
@@ -265,6 +268,18 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 徽章墙:管理员颁布的全部徽章,悬浮显示授予时间 */}
|
||||
{(u.badges ?? []).length > 0 && (
|
||||
<div className="mt-1.5 flex items-center gap-1.5 flex-wrap">
|
||||
{(u.badges ?? []).map((ub) => (
|
||||
<BadgeChip
|
||||
key={ub.id}
|
||||
badge={ub.badge}
|
||||
tip={`${ub.badge.name} · ${new Date(ub.awarded_at).toLocaleDateString("zh-CN")} 颁布`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-1.5 text-[14px] leading-snug flex flex-wrap items-center" style={{ color: "var(--ink-2)" }}>
|
||||
<span>
|
||||
<span className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>{data.stats.post_count}</span>
|
||||
|
||||
Reference in New Issue
Block a user