feat: 管理端板块管理与旧版数据导入,补充部署运营文档
- 新增管理端板块管理页面与后端接口(admin_board) - 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具 - 聊天用户卡片、板块图标等 UI 组件与界面优化 - 补充 about/公告/1Panel 部署等文档 - gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { ArrowLeft, Copy, ExternalLink, Loader2 } from "lucide-react";
|
||||
import { ArrowLeft, Copy, ExternalLink, Loader2, Megaphone } from "lucide-react";
|
||||
import {
|
||||
apiAdminCreateAnnouncement,
|
||||
apiAdminUpdateAnnouncement,
|
||||
@@ -246,34 +246,68 @@ export default function AnnouncementComposeClient({
|
||||
|
||||
return (
|
||||
<div className="admin-doc-page admin-doc-editor">
|
||||
<header className="admin-doc-head">
|
||||
<div className="min-w-0">
|
||||
<Link href={backHref} className="admin-doc-back">
|
||||
<ArrowLeft size={14} aria-hidden /> 返回列表
|
||||
</Link>
|
||||
<div className="admin-doc-head-title">
|
||||
<header className="admin-doc-sticky">
|
||||
<div className="admin-doc-sticky-inner">
|
||||
<div className="admin-doc-sticky-title">
|
||||
<Link href={backHref} className="admin-doc-back" aria-label="返回公告列表">
|
||||
<ArrowLeft size={15} aria-hidden />
|
||||
<span className="admin-doc-back-text">返回列表</span>
|
||||
</Link>
|
||||
<span className="admin-doc-sticky-icon" aria-hidden>
|
||||
<Megaphone size={15} />
|
||||
</span>
|
||||
<h1>{isEdit ? "编辑公告" : "新建公告"}</h1>
|
||||
<AdminStatusChip tone={savedPublished ? "ok" : savedPublished === false ? "warn" : "accent"}>
|
||||
{savedPublished ? "已发布" : savedPublished === false ? "草稿" : "尚未保存"}
|
||||
</AdminStatusChip>
|
||||
</div>
|
||||
<div className="admin-doc-sticky-actions">
|
||||
{canViewOnline ? (
|
||||
<a
|
||||
href={publicPath}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-line btn-sm"
|
||||
title="打开已发布内容。未保存的修改不会出现在这里。"
|
||||
>
|
||||
<ExternalLink size={14} aria-hidden /> 线上页面
|
||||
</a>
|
||||
) : null}
|
||||
<AdminSegmented<PublishKey>
|
||||
role="group"
|
||||
ariaLabel="保存后状态"
|
||||
size="sm"
|
||||
value={form.published ? "published" : "draft"}
|
||||
onChange={(key) => patchForm({ published: key === "published" })}
|
||||
options={[
|
||||
{ key: "draft", label: "草稿" },
|
||||
{ key: "published", label: "发布" },
|
||||
]}
|
||||
/>
|
||||
<button type="button" className="btn btn-line btn-sm" disabled={!dirty || saving} onClick={discard}>
|
||||
撤销修改
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={!dirty || saving}
|
||||
onClick={requestSave}
|
||||
aria-busy={saving || undefined}
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" aria-hidden /> : null}
|
||||
{saving ? "保存中…" : saveLabel}
|
||||
</button>
|
||||
<p className={`admin-doc-status${errors.form ? " is-error" : ""}`} aria-live="polite">
|
||||
{statusText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{canViewOnline ? (
|
||||
<a
|
||||
href={publicPath}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-line"
|
||||
title="打开已发布内容。未保存的修改不会出现在这里。"
|
||||
>
|
||||
<ExternalLink size={14} aria-hidden /> 查看线上页面
|
||||
</a>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<div className="admin-doc-split">
|
||||
<div className="admin-doc-main">
|
||||
<AdminField
|
||||
className="admin-doc-title"
|
||||
id="anno-title"
|
||||
label="标题"
|
||||
maxLength={200}
|
||||
@@ -328,7 +362,12 @@ export default function AnnouncementComposeClient({
|
||||
/>
|
||||
|
||||
<div className="admin-doc-field">
|
||||
<p className="admin-doc-label">标签颜色</p>
|
||||
<p className="admin-doc-label">
|
||||
标签颜色
|
||||
<span className="admin-doc-label-value">
|
||||
{ANNOUNCEMENT_COLORS.find((c) => c.value === form.tag_color)?.label}
|
||||
</span>
|
||||
</p>
|
||||
<div className="admin-doc-colors" role="radiogroup" aria-label="标签颜色">
|
||||
{ANNOUNCEMENT_COLORS.map((c) => (
|
||||
<button
|
||||
@@ -343,7 +382,12 @@ export default function AnnouncementComposeClient({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="admin-doc-hint">只影响标签颜色,不改正文。</p>
|
||||
{/* 与前台同一套 j13-anno-tag 标记,所见即所得 */}
|
||||
<div className="admin-doc-tagpreview" data-note="前台胶囊效果">
|
||||
<span className={`j13-anno-tag is-${form.tag_color}`}>
|
||||
{form.tag.trim() || "公告"}
|
||||
</span>
|
||||
</div>
|
||||
{errors.tag_color ? (
|
||||
<p className="admin-settings-row-error" role="alert">
|
||||
{errors.tag_color}
|
||||
@@ -366,65 +410,25 @@ export default function AnnouncementComposeClient({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isEdit && saved ? (
|
||||
<div className="admin-doc-field">
|
||||
<p className="admin-doc-label">访问地址</p>
|
||||
<p className="admin-doc-hint">
|
||||
{publicPath}
|
||||
{origin ? ` · ${origin.replace(/\/$/, "")}${publicPath}` : ""}
|
||||
</p>
|
||||
<p className="admin-doc-hint">地址按公告编号生成,不能改成自定义路径。</p>
|
||||
<button type="button" className="btn btn-line admin-doc-copy" onClick={() => void copyAddress()}>
|
||||
<Copy size={14} aria-hidden /> 复制地址
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="admin-doc-hint">保存后才会有公开地址 /announcement/编号。草稿没有公开入口。</p>
|
||||
)}
|
||||
|
||||
<div className="admin-doc-field">
|
||||
<p className="admin-doc-label">保存后状态</p>
|
||||
<AdminSegmented<PublishKey>
|
||||
role="group"
|
||||
ariaLabel="保存后状态"
|
||||
size="sm"
|
||||
value={form.published ? "published" : "draft"}
|
||||
onChange={(key) => patchForm({ published: key === "published" })}
|
||||
options={[
|
||||
{ key: "draft", label: "草稿" },
|
||||
{ key: "published", label: "已发布" },
|
||||
]}
|
||||
/>
|
||||
<p className="admin-doc-hint">点选只改变这次要保存的状态,不会立刻发布或下线。</p>
|
||||
<p className="admin-doc-label">访问地址</p>
|
||||
{isEdit && saved ? (
|
||||
<>
|
||||
<p className="admin-doc-hint">
|
||||
{publicPath}
|
||||
{origin ? ` · ${origin.replace(/\/$/, "")}${publicPath}` : ""}
|
||||
</p>
|
||||
<p className="admin-doc-hint">地址按公告编号生成,不能改成自定义路径。</p>
|
||||
<button type="button" className="btn btn-line admin-doc-copy" onClick={() => void copyAddress()}>
|
||||
<Copy size={14} aria-hidden /> 复制地址
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className="admin-doc-hint">保存后按公告编号生成 /announcement/编号;草稿没有公开入口。</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div className="admin-doc-actions" aria-busy={saving || undefined}>
|
||||
<div className="admin-doc-actions-btns">
|
||||
<button type="button" className="btn btn-line" disabled={!dirty || saving} onClick={discard}>
|
||||
撤销修改
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!dirty || saving}
|
||||
onClick={requestSave}
|
||||
>
|
||||
{saving ? <Loader2 size={15} className="animate-spin" aria-hidden /> : null}
|
||||
{saving ? "保存中…" : saveLabel}
|
||||
</button>
|
||||
<p className={`admin-doc-status${errors.form ? " is-error" : ""}`} aria-live="polite">
|
||||
{statusText}
|
||||
</p>
|
||||
</div>
|
||||
<p className="admin-doc-hint">一次保存会写入标题、正文和公告设置。Ctrl/⌘ + Enter 与这个按钮相同。</p>
|
||||
{canViewOnline ? (
|
||||
<p className="admin-doc-hint">查看线上页面打开的是已发布内容,未保存的修改不会出现在那里。</p>
|
||||
) : (
|
||||
<p className="admin-doc-hint">草稿没有公开地址。要看正文,用编辑器里的预览。</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
739
frontend/app/admin/boards/BoardsAdmin.tsx
Normal file
739
frontend/app/admin/boards/BoardsAdmin.tsx
Normal file
@@ -0,0 +1,739 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
FolderOpen,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Users as UsersIcon,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
EyeOff,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
apiAdminAddBoardModerator,
|
||||
apiAdminCreateBoard,
|
||||
apiAdminDeleteBoard,
|
||||
apiAdminListBoardModerators,
|
||||
apiAdminListBoards,
|
||||
apiAdminReorderBoards,
|
||||
apiAdminRemoveBoardModerator,
|
||||
apiAdminUpdateBoard,
|
||||
BOARD_POST_POLICY,
|
||||
type AdminBoard,
|
||||
type AdminBoardInput,
|
||||
type AdminBoardModerator,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
AdminEmpty,
|
||||
AdminPageHeader,
|
||||
AdminStatusChip,
|
||||
} from "@/components/admin";
|
||||
import Modal from "@/components/Modal";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import BoardIcon, {
|
||||
BOARD_ICONS,
|
||||
BOARD_COLOR_COUNT,
|
||||
boardColor,
|
||||
} from "@/components/BoardIcon";
|
||||
|
||||
const POLICY_LABEL: Record<string, string> = {
|
||||
[BOARD_POST_POLICY.ALL]: "全员可发",
|
||||
[BOARD_POST_POLICY.STAFF]: "仅管理员",
|
||||
};
|
||||
|
||||
const POST_POLICY_OPTIONS: { value: string; label: string; desc: string }[] = [
|
||||
{
|
||||
value: BOARD_POST_POLICY.ALL,
|
||||
label: "全员可发",
|
||||
desc: "所有登录用户均可在此板块发帖",
|
||||
},
|
||||
{
|
||||
value: BOARD_POST_POLICY.STAFF,
|
||||
label: "仅管理员及以上",
|
||||
desc: "仅版主、超级管理员、站长可发帖",
|
||||
},
|
||||
];
|
||||
|
||||
function emptyInput(): AdminBoardInput {
|
||||
return {
|
||||
name: "",
|
||||
description: "",
|
||||
icon: "message-circle",
|
||||
color_index: -1,
|
||||
sort_order: 0,
|
||||
post_policy: BOARD_POST_POLICY.ALL,
|
||||
visible: true,
|
||||
};
|
||||
}
|
||||
|
||||
function toInput(b: AdminBoard): AdminBoardInput {
|
||||
return {
|
||||
name: b.name,
|
||||
description: b.description,
|
||||
icon: b.icon,
|
||||
color_index: b.color_index,
|
||||
sort_order: b.sort_order,
|
||||
post_policy: b.post_policy,
|
||||
visible: b.visible,
|
||||
};
|
||||
}
|
||||
|
||||
function BoardEditModal({
|
||||
open,
|
||||
initial,
|
||||
title,
|
||||
busy,
|
||||
editTarget,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean;
|
||||
initial: AdminBoardInput;
|
||||
title: string;
|
||||
busy: boolean;
|
||||
/** 编辑中的板块(新建为 null);自动配色预览按其 ID 轮换 */
|
||||
editTarget: AdminBoard | null;
|
||||
onClose: () => void;
|
||||
onSubmit: (input: AdminBoardInput) => void;
|
||||
}) {
|
||||
const [form, setForm] = useState<AdminBoardInput>(initial);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setForm(initial);
|
||||
}, [open, initial]);
|
||||
|
||||
const update = (patch: Partial<AdminBoardInput>) => setForm((f) => ({ ...f, ...patch }));
|
||||
|
||||
// 预览配色:固定(>0)按存值取模;自动(-1/0)按已有板块 ID 轮换,新板块用 accent 占位
|
||||
const previewColor =
|
||||
(form.color_index ?? 0) > 0
|
||||
? (form.color_index ?? 0) % BOARD_COLOR_COUNT
|
||||
: editTarget
|
||||
? editTarget.id % BOARD_COLOR_COUNT
|
||||
: -1;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
busy={busy}
|
||||
maxWidthClass="max-w-lg"
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={busy}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => onSubmit(form)}
|
||||
disabled={busy || !form.name.trim()}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-[12.5px] font-medium" style={{ color: "var(--ink-2)" }}>
|
||||
板块名称 <span style={{ color: "var(--danger)" }}>*</span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
maxLength={64}
|
||||
onChange={(e) => update({ name: e.target.value })}
|
||||
className="field"
|
||||
placeholder="如:综合讨论"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* 外观:实时预览 + 图标 + 颜色,联动更新 */}
|
||||
<div
|
||||
className="flex flex-col gap-3.5 rounded-xl p-3.5"
|
||||
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`w-11 h-11 rounded-xl flex items-center justify-center shrink-0 ${
|
||||
previewColor >= 0 ? `av-${previewColor}` : ""
|
||||
}`}
|
||||
style={
|
||||
previewColor < 0
|
||||
? { background: "var(--accent-soft)", color: "var(--accent)" }
|
||||
: undefined
|
||||
}
|
||||
aria-hidden
|
||||
>
|
||||
<BoardIcon icon={form.icon} name={form.name || "板"} size={20} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13.5px] font-semibold truncate" style={{ color: "var(--ink)" }}>
|
||||
{form.name.trim() || "板块名称预览"}
|
||||
</p>
|
||||
<p className="meta text-[12px] truncate">
|
||||
{form.description?.trim() || "描述将显示在这里"}
|
||||
</p>
|
||||
</div>
|
||||
<span className="ml-auto meta text-[11px] shrink-0 tracking-wide">前台预览</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[12px] font-medium mb-1.5" style={{ color: "var(--ink-3)" }}>
|
||||
图标
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5" role="radiogroup" aria-label="板块图标">
|
||||
{BOARD_ICONS.map((o) => {
|
||||
const active = form.icon === o.value;
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
data-tip={o.label}
|
||||
aria-label={o.label}
|
||||
onClick={() => update({ icon: o.value })}
|
||||
className="w-9 h-9 rounded-[10px] flex items-center justify-center transition-colors"
|
||||
style={{
|
||||
background: "var(--panel)",
|
||||
border: `1.5px solid ${active ? "var(--accent)" : "var(--line)"}`,
|
||||
color: active ? "var(--accent)" : "var(--ink-2)",
|
||||
boxShadow: active ? "0 0 0 3px var(--accent-soft)" : undefined,
|
||||
}}
|
||||
>
|
||||
<BoardIcon icon={o.value} name="" size={16} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[12px] font-medium mb-1.5" style={{ color: "var(--ink-3)" }}>
|
||||
图标颜色
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5" role="radiogroup" aria-label="图标颜色">
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={(form.color_index ?? 0) <= 0}
|
||||
data-tip="自动:按板块编号轮换配色"
|
||||
aria-label="自动配色"
|
||||
onClick={() => update({ color_index: -1 })}
|
||||
className="h-9 px-2.5 rounded-[10px] flex items-center justify-center text-[11.5px] font-medium transition-colors"
|
||||
style={{
|
||||
background: "var(--panel)",
|
||||
border: `1.5px dashed ${(form.color_index ?? 0) <= 0 ? "var(--accent)" : "var(--line)"}`,
|
||||
color: (form.color_index ?? 0) <= 0 ? "var(--accent)" : "var(--ink-3)",
|
||||
boxShadow:
|
||||
(form.color_index ?? 0) <= 0 ? "0 0 0 3px var(--accent-soft)" : undefined,
|
||||
}}
|
||||
>
|
||||
自动
|
||||
</button>
|
||||
{/* 存值 1..16 → 渲染 av-1..av-15、av-0;0 与 -1 均视为自动 */}
|
||||
{Array.from({ length: BOARD_COLOR_COUNT }, (_, k) => k + 1).map((v) => {
|
||||
const active = (form.color_index ?? 0) === v;
|
||||
return (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
data-tip={`颜色 ${v}`}
|
||||
aria-label={`颜色 ${v}`}
|
||||
onClick={() => update({ color_index: v })}
|
||||
className={`av-${v % BOARD_COLOR_COUNT} w-9 h-9 rounded-[10px] flex items-center justify-center transition-colors`}
|
||||
style={{
|
||||
border: `1.5px solid ${active ? "currentColor" : "transparent"}`,
|
||||
boxShadow: active ? "0 0 0 3px var(--accent-soft)" : undefined,
|
||||
}}
|
||||
>
|
||||
{active && <Check size={14} strokeWidth={3} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="meta text-[11.5px] mt-1.5">
|
||||
{(form.color_index ?? 0) <= 0
|
||||
? "自动模式:前台按板块编号分配配色,各页面保持一致。"
|
||||
: "指定固定配色,前台所有位置保持一致。"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-[12.5px] font-medium" style={{ color: "var(--ink-2)" }}>
|
||||
描述
|
||||
</span>
|
||||
<textarea
|
||||
value={form.description}
|
||||
maxLength={512}
|
||||
rows={3}
|
||||
onChange={(e) => update({ description: e.target.value })}
|
||||
className="field"
|
||||
placeholder="板块简介,可选"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-[12.5px] font-medium" style={{ color: "var(--ink-2)" }}>
|
||||
排序权重
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={form.sort_order ?? 0}
|
||||
onChange={(e) => update({ sort_order: parseInt(e.target.value || "0", 10) })}
|
||||
className="field"
|
||||
placeholder="数字越小越靠前"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-[12.5px] font-medium" style={{ color: "var(--ink-2)" }}>
|
||||
发帖策略
|
||||
</span>
|
||||
<div
|
||||
className="grid grid-cols-2 gap-2"
|
||||
role="radiogroup"
|
||||
aria-label="发帖策略"
|
||||
>
|
||||
{POST_POLICY_OPTIONS.map((o) => {
|
||||
const active = (form.post_policy || BOARD_POST_POLICY.ALL) === o.value;
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => update({ post_policy: o.value })}
|
||||
className="flex flex-col gap-0.5 rounded-[10px] px-3 py-2.5 text-left transition-colors"
|
||||
style={{
|
||||
background: active ? "var(--accent-soft)" : "var(--panel-2)",
|
||||
border: `1.5px solid ${active ? "var(--accent)" : "var(--line)"}`,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-[13px] font-semibold"
|
||||
style={{ color: active ? "var(--accent)" : "var(--ink)" }}
|
||||
>
|
||||
{o.label}
|
||||
</span>
|
||||
<span
|
||||
className="text-[11.5px] leading-snug"
|
||||
style={{ color: active ? "var(--accent)" : "var(--ink-3)" }}
|
||||
>
|
||||
{o.desc}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<label className="inline-flex items-center gap-2 text-[13px]" style={{ color: "var(--ink)" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!form.visible}
|
||||
onChange={(e) => update({ visible: e.target.checked })}
|
||||
className="accent-[var(--accent)]"
|
||||
/>
|
||||
前台可见(关闭后仅管理团队可见入口)
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeratorsModal({
|
||||
open,
|
||||
board,
|
||||
busy,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
open: boolean;
|
||||
board: AdminBoard | null;
|
||||
busy: boolean;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [mods, setMods] = useState<AdminBoardModerator[]>([]);
|
||||
const [username, setUsername] = useState("");
|
||||
const [loadErr, setLoadErr] = useState("");
|
||||
const [actErr, setActErr] = useState("");
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!board) return;
|
||||
setLoadErr("");
|
||||
try {
|
||||
const r = await apiAdminListBoardModerators(board.id);
|
||||
setMods(r.moderators ?? []);
|
||||
} catch (e) {
|
||||
setLoadErr(e instanceof Error ? e.message : "获取版主失败");
|
||||
}
|
||||
}, [board]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && board) {
|
||||
setUsername("");
|
||||
setActErr("");
|
||||
reload();
|
||||
}
|
||||
}, [open, board, reload]);
|
||||
|
||||
const add = async () => {
|
||||
if (!board) return;
|
||||
const u = username.trim();
|
||||
if (!u) return;
|
||||
setActErr("");
|
||||
try {
|
||||
await apiAdminAddBoardModerator(board.id, u);
|
||||
setUsername("");
|
||||
await reload();
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setActErr(e instanceof Error ? e.message : "添加版主失败");
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (uid: number) => {
|
||||
if (!board) return;
|
||||
setActErr("");
|
||||
try {
|
||||
await apiAdminRemoveBoardModerator(board.id, uid);
|
||||
await reload();
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setActErr(e instanceof Error ? e.message : "移除版主失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={`版主管理 · ${board?.name ?? ""}`}
|
||||
description="版主需为 board_admin 角色。新加入用户若已非该角色将被自动升级并强制下线;移除最后一块授权时自动降级为普通用户。"
|
||||
onClose={onClose}
|
||||
busy={busy}
|
||||
maxWidthClass="max-w-lg"
|
||||
footer={
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={busy}>
|
||||
关闭
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="按用户名添加版主"
|
||||
className="field flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
add();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm shrink-0"
|
||||
onClick={add}
|
||||
disabled={busy || !username.trim()}
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
{actErr && <p className="text-[12.5px]" style={{ color: "var(--danger)" }}>{actErr}</p>}
|
||||
{loadErr ? (
|
||||
<p className="text-[12.5px]" style={{ color: "var(--danger)" }}>{loadErr}</p>
|
||||
) : mods.length === 0 ? (
|
||||
<p className="meta text-[12.5px] py-4 text-center">暂无版主</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{mods.map((m) => (
|
||||
<li
|
||||
key={m.id}
|
||||
className="flex items-center gap-3 px-2 py-2 rounded-[var(--r-sm)]"
|
||||
style={{ background: "var(--panel-2)" }}
|
||||
>
|
||||
<Avatar name={m.nickname || m.username} src={m.avatar || undefined} size={30} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13px] truncate" style={{ color: "var(--ink)" }}>
|
||||
{m.nickname || m.username}
|
||||
</p>
|
||||
<p className="meta text-[11.5px] truncate">@{m.username}</p>
|
||||
</div>
|
||||
{m.banned && (
|
||||
<span className="admin-status is-muted" style={{ color: "var(--danger)" }}>
|
||||
已封禁
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm shrink-0"
|
||||
onClick={() => remove(m.id)}
|
||||
disabled={busy}
|
||||
style={{ color: "var(--danger)" }}
|
||||
>
|
||||
<Trash2 size={13} /> 移除
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BoardsAdmin({
|
||||
initial,
|
||||
initError,
|
||||
}: {
|
||||
initial: AdminBoard[] | null;
|
||||
initError: string;
|
||||
}) {
|
||||
const [boards, setBoards] = useState<AdminBoard[]>(initial ?? []);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(initError);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<AdminBoard | null>(null);
|
||||
const [editInitial, setEditInitial] = useState<AdminBoardInput>(emptyInput());
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [modsOpen, setModsOpen] = useState(false);
|
||||
const [modsTarget, setModsTarget] = useState<AdminBoard | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const r = await apiAdminListBoards();
|
||||
setBoards(r.boards ?? []);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "获取板块列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditTarget(null);
|
||||
setEditInitial(emptyInput());
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (b: AdminBoard) => {
|
||||
setEditTarget(b);
|
||||
setEditInitial(toInput(b));
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const submitEdit = async (input: AdminBoardInput) => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (editTarget) {
|
||||
const r = await apiAdminUpdateBoard(editTarget.id, input);
|
||||
setBoards((prev) => prev.map((b) => (b.id === r.board.id ? { ...b, ...r.board } : b)));
|
||||
} else {
|
||||
const r = await apiAdminCreateBoard(input);
|
||||
setBoards((prev) => [...prev, r.board]);
|
||||
}
|
||||
setEditOpen(false);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (b: AdminBoard) => {
|
||||
if (!window.confirm(`确认删除板块「${b.name}」?该操作不可恢复。`)) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await apiAdminDeleteBoard(b.id);
|
||||
setBoards((prev) => prev.filter((x) => x.id !== b.id));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "删除失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const move = async (idx: number, dir: -1 | 1) => {
|
||||
const target = idx + dir;
|
||||
if (target < 0 || target >= boards.length) return;
|
||||
const next = [...boards];
|
||||
const tmp = next[idx];
|
||||
next[idx] = next[target];
|
||||
next[target] = tmp;
|
||||
// 乐观更新:失败回滚
|
||||
const prev = boards;
|
||||
setBoards(next);
|
||||
try {
|
||||
await apiAdminReorderBoards(next.map((b) => b.id));
|
||||
} catch (e) {
|
||||
setBoards(prev);
|
||||
setError(e instanceof Error ? e.message : "排序失败");
|
||||
}
|
||||
};
|
||||
|
||||
const openMods = (b: AdminBoard) => {
|
||||
setModsTarget(b);
|
||||
setModsOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminPageHeader
|
||||
icon={FolderOpen}
|
||||
title="板块管理"
|
||||
description="新建、编辑、删除论坛板块,配置发帖策略与可见性,并指派版主。仅站长可操作。"
|
||||
actions={
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={openCreate}>
|
||||
<Plus size={14} /> 新建板块
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className="mb-3 px-3 py-2 rounded-[var(--r-sm)] text-[12.5px]"
|
||||
style={{ background: "var(--danger-soft)", color: "var(--danger)" }}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{boards.length === 0 ? (
|
||||
<AdminEmpty
|
||||
icon={<FolderOpen size={22} />}
|
||||
title="暂无板块"
|
||||
description="点击右上角「新建板块」创建第一个板块。"
|
||||
/>
|
||||
) : (
|
||||
<div className="panel overflow-hidden boards-panel">
|
||||
<div className="boards-head">
|
||||
<span>排序</span>
|
||||
<span>板块</span>
|
||||
<span className="boards-actions">操作</span>
|
||||
</div>
|
||||
{boards.map((b, i) => (
|
||||
<div key={b.id} className="boards-row">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-icon-btn"
|
||||
onClick={() => move(i, -1)}
|
||||
disabled={i === 0 || busy}
|
||||
aria-label="上移"
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-icon-btn"
|
||||
onClick={() => move(i, 1)}
|
||||
disabled={i === boards.length - 1 || busy}
|
||||
aria-label="下移"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-w-0 flex items-center gap-2.5">
|
||||
<span
|
||||
className={`av-${boardColor(b)} w-8 h-8 rounded-[10px] flex items-center justify-center shrink-0`}
|
||||
aria-hidden
|
||||
>
|
||||
<BoardIcon icon={b.icon} name={b.name} size={15} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13.5px] font-semibold truncate" style={{ color: "var(--ink)" }}>
|
||||
{b.name}
|
||||
</p>
|
||||
{b.description && (
|
||||
<p className="meta text-[12px] truncate">{b.description}</p>
|
||||
)}
|
||||
{/* 低信息密度列表:策略/状态/帖数并入元信息行,替代三列 */}
|
||||
<div className="boards-meta">
|
||||
<AdminStatusChip tone="muted">
|
||||
{POLICY_LABEL[b.post_policy] ?? b.post_policy}
|
||||
</AdminStatusChip>
|
||||
{b.visible ? (
|
||||
<AdminStatusChip tone="ok">可见</AdminStatusChip>
|
||||
) : (
|
||||
<AdminStatusChip tone="muted">
|
||||
<EyeOff size={11} /> 隐藏
|
||||
</AdminStatusChip>
|
||||
)}
|
||||
<span className="boards-meta-posts">{b.post_count ?? 0} 帖</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap boards-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
onClick={() => openEdit(b)}
|
||||
disabled={busy}
|
||||
>
|
||||
<Pencil size={13} /> 编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
onClick={() => openMods(b)}
|
||||
disabled={busy}
|
||||
>
|
||||
<UsersIcon size={13} /> 版主
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
onClick={() => remove(b)}
|
||||
disabled={busy}
|
||||
style={{ color: "var(--danger)" }}
|
||||
>
|
||||
<Trash2 size={13} /> 删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<p className="meta text-[12px] mt-3 text-center">加载中…</p>
|
||||
)}
|
||||
|
||||
<BoardEditModal
|
||||
open={editOpen}
|
||||
initial={editInitial}
|
||||
title={editTarget ? `编辑板块 · ${editTarget.name}` : "新建板块"}
|
||||
busy={busy}
|
||||
editTarget={editTarget}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onSubmit={submitEdit}
|
||||
/>
|
||||
|
||||
<ModeratorsModal
|
||||
open={modsOpen}
|
||||
board={modsTarget}
|
||||
busy={busy}
|
||||
onClose={() => setModsOpen(false)}
|
||||
onChanged={reload}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
frontend/app/admin/boards/page.tsx
Normal file
33
frontend/app/admin/boards/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { fetchAdminBoards, type AdminBoard } from "@/lib/api";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { isOwner } from "@/lib/roles";
|
||||
import Forbidden from "../Forbidden";
|
||||
import BoardsAdmin from "./BoardsAdmin";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "板块管理",
|
||||
};
|
||||
|
||||
export default async function AdminBoardsPage() {
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
|
||||
// 仅站长可管理板块
|
||||
if (!me.user || !isOwner(me.user.role)) {
|
||||
return <Forbidden text="板块管理仅站长可操作。" />;
|
||||
}
|
||||
|
||||
let initial: AdminBoard[] | null = null;
|
||||
let initError = "";
|
||||
try {
|
||||
const r = await fetchAdminBoards(cookie || undefined);
|
||||
initial = r.boards ?? [];
|
||||
} catch (e) {
|
||||
initError = e instanceof Error ? e.message : "获取板块列表失败";
|
||||
}
|
||||
|
||||
return <BoardsAdmin initial={initial} initError={initError} />;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { ArrowLeft, Copy, ExternalLink, Loader2 } from "lucide-react";
|
||||
import { ArrowLeft, Copy, ExternalLink, FileText, Loader2 } from "lucide-react";
|
||||
import {
|
||||
apiAdminCreateSitePage,
|
||||
apiAdminUpdateSitePage,
|
||||
@@ -283,34 +283,68 @@ export default function PageComposeClient({
|
||||
|
||||
return (
|
||||
<div className="admin-doc-page admin-doc-editor">
|
||||
<header className="admin-doc-head">
|
||||
<div className="min-w-0">
|
||||
<Link href={backHref} className="admin-doc-back">
|
||||
<ArrowLeft size={14} aria-hidden /> 返回列表
|
||||
</Link>
|
||||
<div className="admin-doc-head-title">
|
||||
<header className="admin-doc-sticky">
|
||||
<div className="admin-doc-sticky-inner">
|
||||
<div className="admin-doc-sticky-title">
|
||||
<Link href={backHref} className="admin-doc-back" aria-label="返回单页列表">
|
||||
<ArrowLeft size={15} aria-hidden />
|
||||
<span className="admin-doc-back-text">返回列表</span>
|
||||
</Link>
|
||||
<span className="admin-doc-sticky-icon" aria-hidden>
|
||||
<FileText size={15} />
|
||||
</span>
|
||||
<h1>{isEdit ? "编辑单页" : "新建单页"}</h1>
|
||||
<AdminStatusChip tone={savedPublished ? "ok" : savedPublished === false ? "warn" : "accent"}>
|
||||
{savedPublished ? "已发布" : savedPublished === false ? "草稿" : "尚未保存"}
|
||||
</AdminStatusChip>
|
||||
</div>
|
||||
<div className="admin-doc-sticky-actions">
|
||||
{canViewOnline && savedPage ? (
|
||||
<a
|
||||
href={sitePagePublicPath(savedPage.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-line btn-sm"
|
||||
title="打开已发布内容。未保存的修改不会出现在这里。"
|
||||
>
|
||||
<ExternalLink size={14} aria-hidden /> 线上页面
|
||||
</a>
|
||||
) : null}
|
||||
<AdminSegmented<PublishKey>
|
||||
role="group"
|
||||
ariaLabel="保存后状态"
|
||||
size="sm"
|
||||
value={form.published ? "published" : "draft"}
|
||||
onChange={(key) => patchForm({ published: key === "published" })}
|
||||
options={[
|
||||
{ key: "draft", label: "草稿" },
|
||||
{ key: "published", label: "发布" },
|
||||
]}
|
||||
/>
|
||||
<button type="button" className="btn btn-line btn-sm" disabled={!dirty || saving} onClick={discard}>
|
||||
撤销修改
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={!dirty || saving}
|
||||
onClick={requestSave}
|
||||
aria-busy={saving || undefined}
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" aria-hidden /> : null}
|
||||
{saving ? "保存中…" : saveLabel}
|
||||
</button>
|
||||
<p className={`admin-doc-status${errors.form ? " is-error" : ""}`} aria-live="polite">
|
||||
{statusText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{canViewOnline && savedPage ? (
|
||||
<a
|
||||
href={sitePagePublicPath(savedPage.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-line"
|
||||
title="打开已发布内容。未保存的修改不会出现在这里。"
|
||||
>
|
||||
<ExternalLink size={14} aria-hidden /> 查看线上页面
|
||||
</a>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<div className="admin-doc-split">
|
||||
<div className="admin-doc-main">
|
||||
<AdminField
|
||||
className="admin-doc-title"
|
||||
id="page-title"
|
||||
label="标题"
|
||||
maxLength={200}
|
||||
@@ -437,50 +471,8 @@ export default function PageComposeClient({
|
||||
/>
|
||||
<p className="admin-doc-hint">数字越小越靠前,可以为负数。</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-doc-field">
|
||||
<p className="admin-doc-label">保存后状态</p>
|
||||
<AdminSegmented<PublishKey>
|
||||
role="group"
|
||||
ariaLabel="保存后状态"
|
||||
size="sm"
|
||||
value={form.published ? "published" : "draft"}
|
||||
onChange={(key) => patchForm({ published: key === "published" })}
|
||||
options={[
|
||||
{ key: "draft", label: "草稿" },
|
||||
{ key: "published", label: "已发布" },
|
||||
]}
|
||||
/>
|
||||
<p className="admin-doc-hint">点选只改变这次要保存的状态,不会立刻发布或下线。</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div className="admin-doc-actions" aria-busy={saving || undefined}>
|
||||
<div className="admin-doc-actions-btns">
|
||||
<button type="button" className="btn btn-line" disabled={!dirty || saving} onClick={discard}>
|
||||
撤销修改
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!dirty || saving}
|
||||
onClick={requestSave}
|
||||
>
|
||||
{saving ? <Loader2 size={15} className="animate-spin" aria-hidden /> : null}
|
||||
{saving ? "保存中…" : saveLabel}
|
||||
</button>
|
||||
<p className={`admin-doc-status${errors.form ? " is-error" : ""}`} aria-live="polite">
|
||||
{statusText}
|
||||
</p>
|
||||
</div>
|
||||
<p className="admin-doc-hint">一次保存会写入标题、正文和页面设置。Ctrl/⌘ + Enter 与这个按钮相同。</p>
|
||||
{canViewOnline ? (
|
||||
<p className="admin-doc-hint">查看线上页面打开的是已发布内容,未保存的修改不会出现在那里。</p>
|
||||
) : (
|
||||
<p className="admin-doc-hint">草稿没有公开地址。要看正文,用编辑器里的预览。</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink, LogIn, MessageSquare, MessagesSquare, ShieldCheck, Timer } from "lucide-react";
|
||||
import { ExternalLink, LogIn, MessageSquare, MessagesSquare, ShieldCheck, ShieldOff, Timer } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
AdminSettingsGroup,
|
||||
@@ -27,6 +27,7 @@ export default function AccessSettings({ initial }: { initial: PublicSettings })
|
||||
const [allowMessages, setAllowMessages] = useState(initial.allow_messages !== false);
|
||||
const [postLinkNewTab, setPostLinkNewTab] = useState(initial.post_link_new_tab !== false);
|
||||
const [trustOn, setTrustOn] = useState(initial.trust_reviewed_publish !== false);
|
||||
const [withoutReviewOn, setWithoutReviewOn] = useState(initial.publish_without_review === true);
|
||||
const [cooldown, setCooldown] = useState(initial.post_cooldown_hours);
|
||||
const [savedCooldown, setSavedCooldown] = useState(initial.post_cooldown_hours);
|
||||
const [togglingComments, setTogglingComments] = useState(false);
|
||||
@@ -34,6 +35,7 @@ export default function AccessSettings({ initial }: { initial: PublicSettings })
|
||||
const [togglingMessages, setTogglingMessages] = useState(false);
|
||||
const [togglingLink, setTogglingLink] = useState(false);
|
||||
const [togglingTrust, setTogglingTrust] = useState(false);
|
||||
const [togglingWithoutReview, setTogglingWithoutReview] = useState(false);
|
||||
const [savingCooldown, setSavingCooldown] = useState(false);
|
||||
const [moduleDirty, setModuleDirty] = useState(false);
|
||||
const leave = useUnsavedGuard(moduleDirty);
|
||||
@@ -103,6 +105,25 @@ export default function AccessSettings({ initial }: { initial: PublicSettings })
|
||||
}
|
||||
};
|
||||
|
||||
const toggleWithoutReview = async () => {
|
||||
if (togglingWithoutReview) return;
|
||||
setTogglingWithoutReview(true);
|
||||
try {
|
||||
const res = await apiUpdateSiteSettings({ publish_without_review: !withoutReviewOn });
|
||||
setWithoutReviewOn(res.publish_without_review === true);
|
||||
toast(
|
||||
res.publish_without_review
|
||||
? "已开启全站免审(发帖与评论直接公开,无需过审记录)"
|
||||
: "已关闭全站免审(将按过审免审规则决定是否待审)",
|
||||
"ok",
|
||||
);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setTogglingWithoutReview(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTrust = async () => {
|
||||
if (togglingTrust) return;
|
||||
setTogglingTrust(true);
|
||||
@@ -218,18 +239,40 @@ export default function AccessSettings({ initial }: { initial: PublicSettings })
|
||||
<InstantNote saving={togglingLink} />
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ShieldOff size={15} /> 全站免审
|
||||
</span>
|
||||
}
|
||||
hintBelow="所有人发帖与评论直接公开,无需有过审记录;开启后优先生效。"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminSwitch
|
||||
checked={withoutReviewOn}
|
||||
disabled={togglingWithoutReview}
|
||||
onChange={() => void toggleWithoutReview()}
|
||||
label="全站免审"
|
||||
/>
|
||||
<InstantNote saving={togglingWithoutReview} />
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ShieldCheck size={15} /> 过审免审
|
||||
</span>
|
||||
}
|
||||
hintBelow="用户曾有过审内容后,后续发帖与评论直接公开。"
|
||||
hintBelow={
|
||||
withoutReviewOn
|
||||
? "全站免审已开启,所有内容直接公开,本开关暂不参与判定。"
|
||||
: "用户曾有过审内容后,后续发帖与评论直接公开。"
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminSwitch
|
||||
checked={trustOn}
|
||||
disabled={togglingTrust}
|
||||
disabled={withoutReviewOn || togglingTrust}
|
||||
onChange={() => void toggleTrust()}
|
||||
label="过审免审"
|
||||
/>
|
||||
|
||||
@@ -16,6 +16,7 @@ const NAV: { href: string; label: string }[] = [
|
||||
{ href: "/admin/settings/mail", label: "邮件服务" },
|
||||
{ href: "/admin/settings/storage", label: "文件与存储" },
|
||||
{ href: "/admin/settings/filter", label: "内容过滤" },
|
||||
{ href: "/admin/settings/import", label: "数据导入" },
|
||||
{ href: "/admin/settings/maintenance", label: "维护与诊断" },
|
||||
];
|
||||
|
||||
|
||||
415
frontend/app/admin/settings/import/ImportPanel.tsx
Normal file
415
frontend/app/admin/settings/import/ImportPanel.tsx
Normal file
@@ -0,0 +1,415 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
ClipboardCheck,
|
||||
Database,
|
||||
FileUp,
|
||||
ListChecks,
|
||||
ListX,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
ScanLine,
|
||||
Upload,
|
||||
Users,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
AdminSettingsGroup,
|
||||
AdminSwitch,
|
||||
} from "@/components/admin";
|
||||
import {
|
||||
apiAdminImportLegacy,
|
||||
type LegacyCommentReport,
|
||||
type LegacyImportReport,
|
||||
type LegacyPostReport,
|
||||
} from "@/lib/api";
|
||||
|
||||
/** 数据源清单:enabled=false 的来源展示为「计划中」,为后续 WordPress/Typecho 适配预留 */
|
||||
const SOURCES = [
|
||||
{ key: "jiang13", label: "姜十三论坛(SQLite)", enabled: true },
|
||||
{ key: "wordpress", label: "WordPress", enabled: false },
|
||||
{ key: "typecho", label: "Typecho", enabled: false },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 站点设置 → 数据导入:
|
||||
* 上传旧站数据库(+可选头像包)→ 预检(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 [withContent, setWithContent] = useState(true);
|
||||
const [busy, setBusy] = useState<"check" | "import" | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [report, setReport] = useState<LegacyImportReport | null>(null);
|
||||
const dbInputRef = useRef<HTMLInputElement>(null);
|
||||
const zipInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 「确认导入」只有在当前配置下跑过预检后才可用;文件/选项变化即重置
|
||||
const [preflighted, setPreflighted] = useState(false);
|
||||
|
||||
const resetResult = () => {
|
||||
setPreflighted(false);
|
||||
setReport(null);
|
||||
setError("");
|
||||
};
|
||||
|
||||
const pickDb = (f: File | null) => {
|
||||
setDbFile(f);
|
||||
resetResult();
|
||||
};
|
||||
const pickZip = (f: File | null) => {
|
||||
setZipFile(f);
|
||||
resetResult();
|
||||
};
|
||||
const toggleContent = () => {
|
||||
setWithContent((v) => !v);
|
||||
resetResult();
|
||||
};
|
||||
|
||||
const run = async (dryRun: boolean) => {
|
||||
if (!dbFile || busy) return;
|
||||
setBusy(dryRun ? "check" : "import");
|
||||
setError("");
|
||||
try {
|
||||
const rep = await apiAdminImportLegacy(source, dbFile, zipFile, {
|
||||
withContent,
|
||||
dryRun,
|
||||
});
|
||||
setReport(rep);
|
||||
if (dryRun) setPreflighted(true);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "导入失败");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<AdminSettingsGroup
|
||||
title="数据源"
|
||||
description="选择要导入的站点程序类型;其他平台适配将陆续提供"
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SOURCES.map((s) => (
|
||||
<button
|
||||
key={s.key}
|
||||
type="button"
|
||||
disabled={!s.enabled}
|
||||
onClick={() => {
|
||||
setSource(s.key);
|
||||
resetResult();
|
||||
}}
|
||||
className={`btn btn-sm ${source === s.key && s.enabled ? "btn-primary" : "btn-line"} ${!s.enabled ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
aria-pressed={source === s.key}
|
||||
title={s.enabled ? undefined : "计划中,敬请期待"}
|
||||
>
|
||||
<Database size={14} aria-hidden />
|
||||
{s.label}
|
||||
{!s.enabled && (
|
||||
<span className="text-[11px] font-semibold" style={{ color: "var(--ink-3)" }}>
|
||||
计划中
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</AdminSettingsGroup>
|
||||
|
||||
<AdminSettingsGroup
|
||||
title="导入文件"
|
||||
description="第一步:从旧站服务器或本地备份中取得以下文件"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<FilePickRow
|
||||
label="站点数据库(必填)"
|
||||
hint="旧站 data 目录下的 jiang13.db,最大 64MB"
|
||||
fileName={dbFile?.name}
|
||||
inputRef={dbInputRef}
|
||||
accept=".db,.sqlite,.sqlite3,application/octet-stream"
|
||||
onPick={pickDb}
|
||||
busy={busy !== null}
|
||||
/>
|
||||
<FilePickRow
|
||||
label="头像压缩包(可选)"
|
||||
hint="把旧站 data/uploads/avatars 目录压缩为 zip(直接右键压缩该文件夹即可),最大 128MB"
|
||||
fileName={zipFile?.name}
|
||||
inputRef={zipInputRef}
|
||||
accept=".zip,application/zip"
|
||||
onPick={pickZip}
|
||||
busy={busy !== null}
|
||||
/>
|
||||
</div>
|
||||
</AdminSettingsGroup>
|
||||
|
||||
<AdminSettingsGroup
|
||||
title="导入范围"
|
||||
description="第二步:确认导入范围。板块按旧板块名自动创建(同名复用);帖子与评论保留原发布时间与浏览/点赞数;作者账号缺失时归到站长。投票帖暂不迁移。"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<AdminSwitch checked={withContent} onChange={toggleContent} label="同时导入帖子与评论" />
|
||||
<span className="text-[13px]" style={{ color: "var(--ink)" }}>
|
||||
同时导入帖子与评论
|
||||
</span>
|
||||
</div>
|
||||
</AdminSettingsGroup>
|
||||
|
||||
<AdminSettingsGroup
|
||||
title="预检与导入"
|
||||
description="第三步:先预检确认将导入的内容,再正式写入。可重复执行,已导入的内容自动跳过。"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={!dbFile || busy !== null}
|
||||
onClick={() => void run(true)}
|
||||
>
|
||||
{busy === "check" ? <Loader2 size={14} className="animate-spin" aria-hidden /> : <ScanLine size={14} aria-hidden />}
|
||||
{busy === "check" ? "预检中…" : "预检(不写入)"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={!dbFile || busy !== null || !preflighted}
|
||||
onClick={() => void run(false)}
|
||||
>
|
||||
{busy === "import" ? <Loader2 size={14} className="animate-spin" aria-hidden /> : <ClipboardCheck size={14} aria-hidden />}
|
||||
{busy === "import" ? "导入中…" : "确认正式导入"}
|
||||
</button>
|
||||
<span className="meta text-[12px]" aria-live="polite">
|
||||
{busy
|
||||
? null
|
||||
: !dbFile
|
||||
? "请先选择站点数据库"
|
||||
: !preflighted
|
||||
? "请先运行预检"
|
||||
: "预检通过,可以正式导入"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-3 flex items-center gap-1.5 text-[12.5px]" style={{ color: "var(--danger)" }} role="alert">
|
||||
<AlertTriangle size={14} aria-hidden />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{report && <ReportView report={report} />}
|
||||
</AdminSettingsGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilePickRow({
|
||||
label,
|
||||
hint,
|
||||
fileName,
|
||||
inputRef,
|
||||
accept,
|
||||
onPick,
|
||||
busy,
|
||||
}: {
|
||||
label: string;
|
||||
hint: string;
|
||||
fileName?: string;
|
||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||
accept: string;
|
||||
onPick: (f: File | null) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl px-3.5 py-3 flex items-center justify-between gap-3 flex-wrap"
|
||||
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||
{label}
|
||||
</p>
|
||||
<p className="meta text-[12px] mt-0.5 truncate">{fileName || hint}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm shrink-0"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={busy}
|
||||
>
|
||||
<Upload size={14} aria-hidden />
|
||||
选择文件
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
onPick(e.target.files?.[0] ?? null);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatChip({ icon, label, value, ok }: { icon: React.ReactNode; label: string; value: number; ok?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg px-2.5 py-1.5 inline-flex items-center gap-1.5 text-[12.5px]"
|
||||
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
|
||||
>
|
||||
<span style={{ color: ok === false ? "var(--ink-3)" : "var(--accent)" }}>{icon}</span>
|
||||
<span style={{ color: "var(--ink-3)" }}>{label}</span>
|
||||
<strong style={{ color: "var(--ink)" }}>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportView({ report }: { report: LegacyImportReport }) {
|
||||
const isDry = report.dry_run;
|
||||
return (
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
<p
|
||||
className="flex items-center gap-1.5 text-[13px] font-semibold"
|
||||
style={{ color: isDry ? "var(--ink-3)" : "var(--ok)" }}
|
||||
>
|
||||
{isDry ? (
|
||||
<>
|
||||
<ScanLine size={15} aria-hidden />
|
||||
预检结果(未写入任何数据)——确认无误后点击「确认正式导入」
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 size={15} aria-hidden />
|
||||
导入完成。重复执行时已导入的内容会自动跳过
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<ReportCard icon={<Users size={14} aria-hidden />} title="用户">
|
||||
<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.avatar_written > 0 && (
|
||||
<StatChip icon={<FileUp size={13} aria-hidden />} label="头像写入" value={report.users.avatar_written} />
|
||||
)}
|
||||
<DetailList title="已存在(跳过)" items={report.users.conflicts} />
|
||||
<DetailList title="头像源缺失(字段置空)" items={report.users.avatar_missing} color="var(--danger)" />
|
||||
<DetailList title="失败" items={report.users.failed} color="var(--danger)" />
|
||||
</ReportCard>
|
||||
|
||||
{report.boards && (
|
||||
<ReportCard icon={<Database size={14} aria-hidden />} title="板块">
|
||||
<StatChip icon={<CheckCircle2 size={13} aria-hidden />} label={isDry ? "将新建" : "已新建"} value={report.boards.created} />
|
||||
<StatChip icon={<ListX size={13} aria-hidden />} label="复用同名" value={report.boards.reused} ok={false} />
|
||||
<DetailList title={isDry ? "将新建板块" : "新建板块"} items={report.boards.names} />
|
||||
</ReportCard>
|
||||
)}
|
||||
|
||||
{report.posts && <PostsView report={report.posts} isDry={isDry} />}
|
||||
{report.comments && <CommentsView report={report.comments} isDry={isDry} />}
|
||||
|
||||
{report.notes && report.notes.length > 0 && (
|
||||
<div
|
||||
className="rounded-xl px-3.5 py-3 flex flex-col gap-1 text-[12.5px]"
|
||||
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
|
||||
>
|
||||
{report.notes.map((n, i) => (
|
||||
<p key={i} className="flex items-start gap-1.5" style={{ color: "var(--ink-3)" }}>
|
||||
<AlertTriangle size={13} className="mt-0.5 shrink-0" aria-hidden />
|
||||
{n}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostsView({ report, isDry }: { report: LegacyPostReport; 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.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.owner_fallback} />
|
||||
<DetailList title="失败 / 非公开" items={report.failed} color="var(--danger)" />
|
||||
</ReportCard>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentsView({ report, isDry }: { report: LegacyCommentReport; 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} />
|
||||
<DetailList title="非公开 / 已导入跳过明细" items={report.skipped_detail} />
|
||||
<DetailList title="作者缺失(归到站长)" items={report.owner_fallback} />
|
||||
<DetailList title="失败" items={report.failed} color="var(--danger)" />
|
||||
</ReportCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportCard({
|
||||
icon,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl px-3.5 py-3 flex flex-col gap-2"
|
||||
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
|
||||
>
|
||||
<p className="flex items-center gap-1.5 text-[13px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||
{icon}
|
||||
{title}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailList({
|
||||
title,
|
||||
items,
|
||||
color,
|
||||
}: {
|
||||
title: string;
|
||||
items?: string[];
|
||||
color?: string;
|
||||
}) {
|
||||
if (!items || items.length === 0) return null;
|
||||
return (
|
||||
<details className="w-full">
|
||||
<summary
|
||||
className="flex items-center gap-1.5 cursor-pointer select-none text-[12.5px]"
|
||||
style={{ color: color || "var(--ink-3)" }}
|
||||
>
|
||||
<ListX size={13} aria-hidden />
|
||||
{title}:{items.length}
|
||||
</summary>
|
||||
<ul className="mt-1 ml-5 list-disc max-h-40 overflow-y-auto text-[12.5px]" style={{ color: "var(--ink-3)" }}>
|
||||
{items.map((it, i) => (
|
||||
<li key={i} className="break-all">
|
||||
{it}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
10
frontend/app/admin/settings/import/page.tsx
Normal file
10
frontend/app/admin/settings/import/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import ImportPanel from "./ImportPanel";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "数据导入",
|
||||
};
|
||||
|
||||
export default function ImportSettingsPage() {
|
||||
return <ImportPanel />;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
Undo2,
|
||||
Reply,
|
||||
Copy,
|
||||
AtSign,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
apiListChatMessages,
|
||||
@@ -53,6 +52,7 @@ import Avatar from "@/components/Avatar";
|
||||
import AutolinkText from "@/components/AutolinkText";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
|
||||
import ChatUserCard, { type ChatUserCardTarget } from "@/components/ChatUserCard";
|
||||
|
||||
/** 同一房间已读:进行中去重 + 2s 内合并(滚动/新消息/Strict Mode 连打) */
|
||||
const MARK_READ_GAP_MS = 2_000;
|
||||
@@ -137,7 +137,6 @@ function RecallNotice({
|
||||
href={`/u/${uid}`}
|
||||
className="transition-opacity hover:opacity-80"
|
||||
style={nameStyle}
|
||||
data-tip={`查看 ${name} 的主页`}
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
@@ -206,10 +205,11 @@ function formatTime(dateStr: string) {
|
||||
return `${md} ${hm}`;
|
||||
}
|
||||
|
||||
function highlightMentions(content: string, members: ChatRoomMember[]) {
|
||||
const byUsername = new Map(
|
||||
members.map((m) => [m.user.username.toLowerCase(), m.user])
|
||||
);
|
||||
function highlightMentions(
|
||||
content: string,
|
||||
users: Map<string, User>,
|
||||
onOpenCard: (u: User) => void
|
||||
) {
|
||||
const parts: React.ReactNode[] = [];
|
||||
const re = /@([A-Za-z0-9_-]{3,32})/g;
|
||||
let last = 0;
|
||||
@@ -217,19 +217,21 @@ function highlightMentions(content: string, members: ChatRoomMember[]) {
|
||||
let i = 0;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
if (m.index > last) parts.push(content.slice(last, m.index));
|
||||
const user = byUsername.get(m[1].toLowerCase());
|
||||
const user = users.get(m[1].toLowerCase());
|
||||
parts.push(
|
||||
user ? (
|
||||
<Link
|
||||
key={`m-${i}`}
|
||||
href={`/u/${user.id}`}
|
||||
className="j13-mention-token"
|
||||
data-tip={`查看 ${user.nickname || user.username} 的主页`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onContextMenu={(e) => e.stopPropagation()}
|
||||
>
|
||||
@{m[1]}
|
||||
</Link>
|
||||
<button
|
||||
key={`m-${i}`}
|
||||
type="button"
|
||||
className="j13-mention-token"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenCard(user);
|
||||
}}
|
||||
onContextMenu={(e) => e.stopPropagation()}
|
||||
>
|
||||
@{user.nickname || user.username}
|
||||
</button>
|
||||
) : (
|
||||
<span key={`m-${i}`}>@{m[1]}</span>
|
||||
)
|
||||
@@ -242,67 +244,154 @@ function highlightMentions(content: string, members: ChatRoomMember[]) {
|
||||
}
|
||||
|
||||
/** 消息正文:URL 自动识别 + @提及;外链新开跟随站点策略 */
|
||||
function renderChatContent(content: string, members: ChatRoomMember[]) {
|
||||
function renderChatContent(
|
||||
content: string,
|
||||
users: Map<string, User>,
|
||||
onOpenCard: (u: User) => void
|
||||
) {
|
||||
return (
|
||||
<AutolinkText
|
||||
text={content}
|
||||
forceNewTab
|
||||
renderText={(seg) => <>{highlightMentions(seg, members)}</>}
|
||||
renderText={(seg) => <>{highlightMentions(seg, users, onOpenCard)}</>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** 输入区镜像层:已确认的 @ 显示为飞书风 token */
|
||||
function renderComposerMirror(content: string, tokens: Set<string>) {
|
||||
if (!content) return null;
|
||||
const parts: React.ReactNode[] = [];
|
||||
const re = /@([A-Za-z0-9_-]{3,32})/g;
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
let i = 0;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
if (m.index > last) parts.push(content.slice(last, m.index));
|
||||
const key = m[1].toLowerCase();
|
||||
parts.push(
|
||||
tokens.has(key) ? (
|
||||
<span key={`t-${i}`} className="j13-mention-token">
|
||||
@{m[1]}
|
||||
</span>
|
||||
) : (
|
||||
<span key={`t-${i}`}>@{m[1]}</span>
|
||||
)
|
||||
);
|
||||
last = m.index + m[0].length;
|
||||
i++;
|
||||
/**
|
||||
* 编辑框提及方案:内容直接存「@昵称」(宽度即昵称宽度,光标/选区天然对齐),
|
||||
* mentionTokens 记录 昵称→用户名 映射,发送时再翻译回 @用户名(后端按用户名解析提醒)。
|
||||
* 昵称可含空格/@ 等任意字符,因此按字面匹配扫描(最长优先 + 边界校验 + 占位去重)。
|
||||
*/
|
||||
function collectMentionSpans(
|
||||
text: string,
|
||||
tokens: Map<string, string>
|
||||
): { start: number; end: number; key: string }[] {
|
||||
const spans: { start: number; end: number; key: string }[] = [];
|
||||
if (!text || tokens.size === 0) return spans;
|
||||
const keys = [...tokens.keys()].sort((a, b) => b.length - a.length);
|
||||
const occupied = new Array<boolean>(text.length).fill(false);
|
||||
for (const key of keys) {
|
||||
if (!key) continue;
|
||||
const needle = `@${key}`;
|
||||
let from = 0;
|
||||
for (;;) {
|
||||
const idx = text.indexOf(needle, from);
|
||||
if (idx < 0) break;
|
||||
from = idx + 1;
|
||||
const end = idx + needle.length;
|
||||
// @ 前需为行首/空白,昵称后需为空白/行尾(与插入时的「@昵称 」格式一致)
|
||||
const prevCh = idx > 0 ? text[idx - 1] : "";
|
||||
if (prevCh && !/\s/.test(prevCh)) continue;
|
||||
const nextCh = end < text.length ? text[end] : "";
|
||||
if (nextCh && !/\s/.test(nextCh)) continue;
|
||||
let clash = false;
|
||||
for (let i = idx; i < end; i++) {
|
||||
if (occupied[i]) {
|
||||
clash = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (clash) continue;
|
||||
for (let i = idx; i < end; i++) occupied[i] = true;
|
||||
spans.push({ start: idx, end, key });
|
||||
}
|
||||
}
|
||||
return spans.sort((a, b) => a.start - b.start);
|
||||
}
|
||||
|
||||
/** 输入区镜像层:已确认的 @ 以蓝色昵称呈现(胶囊样式在发出后的气泡里) */
|
||||
function renderComposerMirror(content: string, tokens: Map<string, string>) {
|
||||
if (!content) return null;
|
||||
const spans = collectMentionSpans(content, tokens);
|
||||
const parts: React.ReactNode[] = [];
|
||||
let last = 0;
|
||||
spans.forEach((s, i) => {
|
||||
if (s.start > last) parts.push(content.slice(last, s.start));
|
||||
parts.push(
|
||||
<span key={`t-${i}`} className="j13-mention-token">
|
||||
@{s.key}
|
||||
</span>
|
||||
);
|
||||
last = s.end;
|
||||
});
|
||||
if (last < content.length) parts.push(content.slice(last));
|
||||
// 末尾换行在 pre-wrap 镜像里需要占位,否则高度会少一行
|
||||
if (content.endsWith("\n")) parts.push("\n");
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** 发送前翻译:@昵称 → @用户名(后端按用户名解析提及) */
|
||||
function translateMentionsOut(text: string, tokens: Map<string, string>): string {
|
||||
if (tokens.size === 0) return text;
|
||||
const spans = collectMentionSpans(text, tokens);
|
||||
if (spans.length === 0) return text;
|
||||
let out = "";
|
||||
let last = 0;
|
||||
for (const s of spans) {
|
||||
out += text.slice(last, s.start) + `@${tokens.get(s.key) || s.key}`;
|
||||
last = s.end;
|
||||
}
|
||||
return out + text.slice(last);
|
||||
}
|
||||
|
||||
/** 服务端文本(@用户名)→ 编辑框文本(@昵称),并为可识别的提及建立 token(重新编辑草稿用) */
|
||||
function hydrateMentionsIn(
|
||||
text: string,
|
||||
userIndex: Map<string, User>
|
||||
): { text: string; tokens: Map<string, string> } {
|
||||
const re = /@([A-Za-z0-9_-]{3,32})/g;
|
||||
const replaces: { start: number; end: number; display: string; username: string }[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const u = userIndex.get(m[1].toLowerCase());
|
||||
if (!u) continue;
|
||||
const display = u.nickname || u.username;
|
||||
if (!display || display === m[1]) continue;
|
||||
replaces.push({
|
||||
start: m.index,
|
||||
end: m.index + m[0].length,
|
||||
display,
|
||||
username: m[1],
|
||||
});
|
||||
}
|
||||
const tokens = new Map<string, string>();
|
||||
let out = text;
|
||||
for (let i = replaces.length - 1; i >= 0; i--) {
|
||||
const r = replaces[i];
|
||||
out = out.slice(0, r.start) + `@${r.display}` + out.slice(r.end);
|
||||
tokens.set(r.display, r.username);
|
||||
}
|
||||
return { text: out, tokens };
|
||||
}
|
||||
|
||||
/** 光标前是否紧贴一个已确认的 @token(含可选尾随空格) */
|
||||
function mentionTokenBefore(
|
||||
text: string,
|
||||
cursor: number,
|
||||
tokens: Set<string>
|
||||
tokens: Map<string, string>
|
||||
): { start: number; end: number } | null {
|
||||
const before = text.slice(0, cursor);
|
||||
const m = before.match(/@([A-Za-z0-9_-]{3,32})( ?)$/);
|
||||
if (!m || !tokens.has(m[1].toLowerCase())) return null;
|
||||
return { start: cursor - m[0].length, end: cursor };
|
||||
for (const s of collectMentionSpans(text, tokens)) {
|
||||
if (s.end === cursor) return { start: s.start, end: cursor };
|
||||
if (s.end + 1 === cursor && text[s.end] === " ") {
|
||||
return { start: s.start, end: cursor };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 光标后是否紧跟一个已确认的 @token(含可选尾随空格) */
|
||||
function mentionTokenAfter(
|
||||
text: string,
|
||||
cursor: number,
|
||||
tokens: Set<string>
|
||||
tokens: Map<string, string>
|
||||
): { start: number; end: number } | null {
|
||||
const after = text.slice(cursor);
|
||||
const m = after.match(/^@([A-Za-z0-9_-]{3,32})( ?)/);
|
||||
if (!m || !tokens.has(m[1].toLowerCase())) return null;
|
||||
return { start: cursor, end: cursor + m[0].length };
|
||||
for (const s of collectMentionSpans(text, tokens)) {
|
||||
if (s.start !== cursor) continue;
|
||||
const end = s.end < text.length && text[s.end] === " " ? s.end + 1 : s.end;
|
||||
return { start: cursor, end };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function ChatRoomClient({
|
||||
@@ -344,8 +433,12 @@ export default function ChatRoomClient({
|
||||
const [mentionOpen, setMentionOpen] = useState(false);
|
||||
const [mentionFilter, setMentionFilter] = useState("");
|
||||
const [mentionIndex, setMentionIndex] = useState(0);
|
||||
/** 从选择器确认插入的 @,在输入区以 token 整体展示/删除 */
|
||||
const [mentionTokens, setMentionTokens] = useState<Set<string>>(() => new Set());
|
||||
/** 从选择器/悬浮确认插入的 @:key=展示昵称,value=用户名(发送时翻译回) */
|
||||
const [mentionTokens, setMentionTokens] = useState<Map<string, string>>(
|
||||
() => new Map()
|
||||
);
|
||||
/** 用户资料卡(头像/@昵称点击弹出) */
|
||||
const [userCard, setUserCard] = useState<ChatUserCardTarget | null>(null);
|
||||
/** 本人撤回后的可重新编辑草稿(仅本端内存,不展示正文) */
|
||||
const [reEditDrafts, setReEditDrafts] = useState<
|
||||
Record<number, { text: string; until: number }>
|
||||
@@ -369,6 +462,25 @@ export default function ChatRoomClient({
|
||||
const canManageInvite = !isDirect && (isOwner || isRoomAdmin || oversee);
|
||||
const canKickMembers = isOwner || isRoomAdmin || oversee;
|
||||
|
||||
/** @解析索引:成员表 + 消息发送者(成员未加载/已退群仍能显示昵称) */
|
||||
const userIndex = useMemo(() => {
|
||||
const idx = new Map<string, User>();
|
||||
const add = (u?: User | null) => {
|
||||
if (u?.username) idx.set(u.username.toLowerCase(), u);
|
||||
};
|
||||
members.forEach((m) => add(m.user));
|
||||
messages.forEach((m) => add(m.sender));
|
||||
return idx;
|
||||
}, [members, messages]);
|
||||
|
||||
const openUserCard = useCallback(
|
||||
(u: User) => {
|
||||
const mem = members.find((m) => m.user_id === u.id);
|
||||
setUserCard({ user: u, roomRole: mem?.role, muted: mem?.muted });
|
||||
},
|
||||
[members]
|
||||
);
|
||||
|
||||
const rememberReEdit = useCallback((id: number, text: string) => {
|
||||
const t = text.trim();
|
||||
if (!t) return;
|
||||
@@ -578,12 +690,17 @@ export default function ChatRoomClient({
|
||||
const prevTokens = mentionTokens;
|
||||
const replyId = replyTo?.id || 0;
|
||||
setContent("");
|
||||
setMentionTokens(new Set());
|
||||
setMentionTokens(new Map());
|
||||
setReplyTo(null);
|
||||
setMentionOpen(false);
|
||||
requestAnimationFrame(() => syncComposerHeight());
|
||||
try {
|
||||
const { message } = await apiSendChatMessage(roomId, prevContent, replyId);
|
||||
// 编辑框内存「@昵称」,发出前翻译回 @用户名(后端按用户名解析提及)
|
||||
const { message } = await apiSendChatMessage(
|
||||
roomId,
|
||||
translateMentionsOut(prevContent, prevTokens),
|
||||
replyId
|
||||
);
|
||||
setMessages((prev) => {
|
||||
if (prev.some((m) => m.id === message.id)) return prev;
|
||||
return [...prev, message];
|
||||
@@ -624,42 +741,53 @@ export default function ChatRoomClient({
|
||||
.slice(0, 8);
|
||||
})();
|
||||
|
||||
const insertMention = (username: string) => {
|
||||
/**
|
||||
* 插入 @提及:
|
||||
* - replaceTrigger:替换光标前未完成的 "@xx" 触发串(选择器路径)
|
||||
* - 否则在光标处追加(昵称悬浮 @ 路径),必要时补前导空格
|
||||
*/
|
||||
const insertMention = (
|
||||
username: string,
|
||||
nickname: string,
|
||||
opts?: { replaceTrigger?: boolean }
|
||||
) => {
|
||||
const el = inputRef.current;
|
||||
const val = content;
|
||||
const cursor = el?.selectionStart ?? val.length;
|
||||
const before = val.slice(0, cursor);
|
||||
let before = val.slice(0, cursor);
|
||||
const after = val.slice(cursor);
|
||||
const at = before.lastIndexOf("@");
|
||||
const head = at >= 0 ? before.slice(0, at) : before;
|
||||
const next = `${head}@${username} ${after}`;
|
||||
if (opts?.replaceTrigger) {
|
||||
const at = before.lastIndexOf("@");
|
||||
if (at >= 0) before = before.slice(0, at);
|
||||
} else if (before && !/\s$/.test(before)) {
|
||||
before = `${before} `;
|
||||
}
|
||||
// 编辑框直接写入昵称(宽度即昵称宽度),映射表记录昵称→用户名
|
||||
const display = nickname || username;
|
||||
const next = `${before}@${display} ${after}`;
|
||||
setContent(next);
|
||||
setMentionTokens((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.add(username.toLowerCase());
|
||||
return n;
|
||||
});
|
||||
setMentionTokens((prev) => new Map(prev).set(display, username));
|
||||
setMentionOpen(false);
|
||||
setMentionFilter("");
|
||||
requestAnimationFrame(() => {
|
||||
const pos = head.length + username.length + 2;
|
||||
const pos = before.length + display.length + 2;
|
||||
el?.focus();
|
||||
el?.setSelectionRange(pos, pos);
|
||||
syncComposerHeight();
|
||||
});
|
||||
};
|
||||
|
||||
const pruneMentionTokens = (text: string, tokens: Set<string>) => {
|
||||
const pruneMentionTokens = (text: string, tokens: Map<string, string>) => {
|
||||
if (tokens.size === 0) return tokens;
|
||||
const present = new Set<string>();
|
||||
const re = /@([A-Za-z0-9_-]{3,32})/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const key = m[1].toLowerCase();
|
||||
if (tokens.has(key)) present.add(key);
|
||||
}
|
||||
const present = new Set(
|
||||
collectMentionSpans(text, tokens).map((s) => s.key)
|
||||
);
|
||||
if (present.size === tokens.size) return tokens;
|
||||
return present;
|
||||
const next: Map<string, string> = new Map();
|
||||
tokens.forEach((username, key) => {
|
||||
if (present.has(key)) next.set(key, username);
|
||||
});
|
||||
return next;
|
||||
};
|
||||
|
||||
const onComposerChange = (raw: string) => {
|
||||
@@ -668,7 +796,7 @@ export default function ChatRoomClient({
|
||||
const el = inputRef.current;
|
||||
const cursor = el?.selectionStart ?? raw.length;
|
||||
const before = raw.slice(0, cursor);
|
||||
const m = before.match(/@([A-Za-z0-9_-]{0,32})$/);
|
||||
const m = before.match(/@([^\s@]{0,32})$/);
|
||||
if (m && isRealMember && !isDirect) {
|
||||
setMentionOpen(true);
|
||||
setMentionFilter(m[1] || "");
|
||||
@@ -696,7 +824,13 @@ export default function ChatRoomClient({
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const pick = mentionCandidates[mentionIndex];
|
||||
if (pick) insertMention(pick.user.username);
|
||||
if (pick) {
|
||||
insertMention(
|
||||
pick.user.username,
|
||||
pick.user.nickname || pick.user.username,
|
||||
{ replaceTrigger: true }
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
@@ -762,6 +896,21 @@ export default function ChatRoomClient({
|
||||
}
|
||||
};
|
||||
|
||||
/** 光标落点在已确认 @token 内部时吸附到 token 末尾(token 原子化,飞书体验) */
|
||||
const onComposerSelect = () => {
|
||||
const el = inputRef.current;
|
||||
if (!el || mentionTokens.size === 0) return;
|
||||
const start = el.selectionStart;
|
||||
const end = el.selectionEnd;
|
||||
if (start !== end) return;
|
||||
for (const s of collectMentionSpans(content, mentionTokens)) {
|
||||
if (start > s.start && start < s.end) {
|
||||
el.setSelectionRange(s.end, s.end);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openMsgMenu = (m: ChatMessage, x: number, y: number) => {
|
||||
if (m.recalled_at) return;
|
||||
setCtxMenu({ x, y, message: m });
|
||||
@@ -928,13 +1077,16 @@ export default function ChatRoomClient({
|
||||
toast("重新编辑已过期");
|
||||
return;
|
||||
}
|
||||
// 草稿存的是服务端文本(@用户名),回填时翻译为 @昵称 并重建 token
|
||||
const { text: display, tokens } = hydrateMentionsIn(text, userIndex);
|
||||
setReplyTo(null);
|
||||
setContent(text);
|
||||
setContent(display);
|
||||
setMentionTokens(tokens);
|
||||
requestAnimationFrame(() => {
|
||||
const el = inputRef.current;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
el.setSelectionRange(text.length, text.length);
|
||||
el.setSelectionRange(display.length, display.length);
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
|
||||
});
|
||||
@@ -972,13 +1124,10 @@ export default function ChatRoomClient({
|
||||
];
|
||||
if (!isDirect && isRealMember && m.sender_id !== meId) {
|
||||
items.push({
|
||||
key: "at",
|
||||
label: `提及 @${m.sender.username}`,
|
||||
icon: <AtSign size={14} />,
|
||||
onClick: () => {
|
||||
setContent((c) => `${c}${c && !c.endsWith(" ") ? " " : ""}@${m.sender.username} `);
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
key: "profile",
|
||||
label: "查看资料卡",
|
||||
icon: <Users size={14} />,
|
||||
onClick: () => openUserCard(m.sender),
|
||||
});
|
||||
}
|
||||
if (canRecallMsg(m)) {
|
||||
@@ -1030,6 +1179,25 @@ export default function ChatRoomClient({
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{isDirect && peer && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full transition-opacity hover:opacity-85"
|
||||
onClick={() =>
|
||||
openUserCard({
|
||||
...peer,
|
||||
role: "",
|
||||
})
|
||||
}
|
||||
aria-label={`查看 ${peer.nickname || peer.username} 的资料卡`}
|
||||
>
|
||||
<Avatar
|
||||
name={peer.nickname || peer.username}
|
||||
src={peer.avatar || undefined}
|
||||
size={24}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<h1
|
||||
className="font-extrabold text-[16px] truncate"
|
||||
style={{ color: "var(--ink)" }}
|
||||
@@ -1222,11 +1390,20 @@ export default function ChatRoomClient({
|
||||
className={`flex gap-2.5 ${mine ? "flex-row-reverse" : ""}`}
|
||||
>
|
||||
<div className="w-8 shrink-0">
|
||||
<Avatar
|
||||
name={m.sender.nickname || m.sender.username}
|
||||
src={m.sender.avatar || undefined}
|
||||
size={32}
|
||||
/>
|
||||
{!grouped && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full transition-opacity hover:opacity-85"
|
||||
onClick={() => openUserCard(m.sender)}
|
||||
aria-label={`查看 ${m.sender.nickname || m.sender.username} 的资料卡`}
|
||||
>
|
||||
<Avatar
|
||||
name={m.sender.nickname || m.sender.username}
|
||||
src={m.sender.avatar || undefined}
|
||||
size={32}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`flex flex-col min-w-0 max-w-[78%] ${
|
||||
@@ -1267,7 +1444,7 @@ export default function ChatRoomClient({
|
||||
已撤回
|
||||
</div>
|
||||
<div style={{ color: "var(--ink-2)" }}>
|
||||
{renderChatContent(m.content, members)}
|
||||
{renderChatContent(m.content, userIndex, openUserCard)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1286,11 +1463,18 @@ export default function ChatRoomClient({
|
||||
>
|
||||
<div className="w-8 shrink-0">
|
||||
{!grouped && (
|
||||
<Avatar
|
||||
name={m.sender.nickname || m.sender.username}
|
||||
src={m.sender.avatar || undefined}
|
||||
size={32}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full transition-opacity hover:opacity-85"
|
||||
onClick={() => openUserCard(m.sender)}
|
||||
aria-label={`查看 ${m.sender.nickname || m.sender.username} 的资料卡`}
|
||||
>
|
||||
<Avatar
|
||||
name={m.sender.nickname || m.sender.username}
|
||||
src={m.sender.avatar || undefined}
|
||||
size={32}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
@@ -1303,12 +1487,34 @@ export default function ChatRoomClient({
|
||||
{!isDirect && m.sender_id === ownerId && (
|
||||
<Crown size={11} style={{ color: "var(--gold)" }} />
|
||||
)}
|
||||
<span
|
||||
className="text-[12px] truncate"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
<button
|
||||
type="button"
|
||||
className={`text-[12px] truncate transition-colors ${
|
||||
!isDirect && isRealMember
|
||||
? "group/nm cursor-pointer text-[color:var(--ink-2)] hover:text-[color:var(--accent)]"
|
||||
: "cursor-default text-[color:var(--ink-2)]"
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (!isDirect && isRealMember) {
|
||||
insertMention(
|
||||
m.sender.username,
|
||||
m.sender.nickname || m.sender.username
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={isDirect || !isRealMember}
|
||||
>
|
||||
{/* 悬浮时名字前出现 @,整个名字成为可 @ 点击对象 */}
|
||||
{!isDirect && isRealMember && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="hidden group-hover/nm:inline font-semibold text-[color:var(--accent)]"
|
||||
>
|
||||
@
|
||||
</span>
|
||||
)}
|
||||
{m.sender.nickname}
|
||||
</span>
|
||||
</button>
|
||||
<span className="meta text-[10.5px]">
|
||||
{formatTime(m.created_at)}
|
||||
</span>
|
||||
@@ -1362,7 +1568,7 @@ export default function ChatRoomClient({
|
||||
{m.reply_snap}
|
||||
</button>
|
||||
)}
|
||||
{renderChatContent(m.content, members)}
|
||||
{renderChatContent(m.content, userIndex, openUserCard)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1428,7 +1634,11 @@ export default function ChatRoomClient({
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
insertMention(m.user.username);
|
||||
insertMention(
|
||||
m.user.username,
|
||||
m.user.nickname || m.user.username,
|
||||
{ replaceTrigger: true }
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
@@ -1476,6 +1686,7 @@ export default function ChatRoomClient({
|
||||
value={content}
|
||||
onChange={(e) => onComposerChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onSelect={onComposerSelect}
|
||||
onScroll={(e) => {
|
||||
if (mirrorRef.current) {
|
||||
mirrorRef.current.scrollTop = e.currentTarget.scrollTop;
|
||||
@@ -1587,6 +1798,13 @@ export default function ChatRoomClient({
|
||||
onKick={handleKick}
|
||||
onMute={handleMute}
|
||||
onSetAdmin={handleSetAdmin}
|
||||
onOpenCard={(mem) =>
|
||||
setUserCard({
|
||||
user: mem.user,
|
||||
roomRole: mem.role,
|
||||
muted: mem.muted,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{rightPanel === "invite" && (
|
||||
@@ -1628,6 +1846,13 @@ export default function ChatRoomClient({
|
||||
onKick={handleKick}
|
||||
onMute={handleMute}
|
||||
onSetAdmin={handleSetAdmin}
|
||||
onOpenCard={(mem) =>
|
||||
setUserCard({
|
||||
user: mem.user,
|
||||
roomRole: mem.role,
|
||||
muted: mem.muted,
|
||||
})
|
||||
}
|
||||
showTitle
|
||||
/>
|
||||
</aside>
|
||||
@@ -1642,6 +1867,14 @@ export default function ChatRoomClient({
|
||||
/>
|
||||
)}
|
||||
|
||||
{userCard && (
|
||||
<ChatUserCard
|
||||
target={userCard}
|
||||
meId={meId}
|
||||
onClose={() => setUserCard(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirm}
|
||||
title={confirm?.title || ""}
|
||||
@@ -1700,6 +1933,7 @@ function MembersPanel({
|
||||
onKick,
|
||||
onMute,
|
||||
onSetAdmin,
|
||||
onOpenCard,
|
||||
showTitle = false,
|
||||
}: {
|
||||
members: ChatRoomMember[];
|
||||
@@ -1711,6 +1945,7 @@ function MembersPanel({
|
||||
onKick: (uid: number, nickname: string) => void;
|
||||
onMute: (uid: number, nickname: string, muted: boolean) => void;
|
||||
onSetAdmin: (uid: number, nickname: string, makeAdmin: boolean) => void;
|
||||
onOpenCard: (m: ChatRoomMember) => void;
|
||||
showTitle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
@@ -1756,7 +1991,14 @@ function MembersPanel({
|
||||
key={m.id}
|
||||
className="flex items-center gap-2.5 px-2 py-2 rounded-lg group"
|
||||
>
|
||||
<Avatar name={name} src={m.user.avatar || undefined} size={28} />
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full transition-opacity hover:opacity-85"
|
||||
onClick={() => onOpenCard(m)}
|
||||
aria-label={`查看 ${name} 的资料卡`}
|
||||
>
|
||||
<Avatar name={name} src={m.user.avatar || undefined} size={28} />
|
||||
</button>
|
||||
<Link
|
||||
href={`/u/${m.user.id}`}
|
||||
className="flex-1 min-w-0 truncate text-[13px] hover:text-[var(--accent)] transition-colors"
|
||||
|
||||
@@ -3167,6 +3167,7 @@ body:has([data-admin-viewport]) {
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0 1rem;
|
||||
/* 半透明 + 毛玻璃:后台画布镂空透出全站画布层,顶栏与画布融为一体 */
|
||||
background: color-mix(in srgb, var(--bg) 88%, transparent);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
@@ -3253,6 +3254,11 @@ body:has([data-admin-viewport]) {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
/* 公告/单页编辑器吸顶操作栏:同上,必须留在未分层区域才能压过 .admin-canvas 的媒体查询内边距 */
|
||||
.admin-canvas:has(.admin-doc-editor) {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
/* 移动端导航抽屉 */
|
||||
.admin-drawer {
|
||||
position: fixed;
|
||||
@@ -3441,6 +3447,12 @@ select:focus-visible {
|
||||
color: transparent;
|
||||
caret-color: var(--ink);
|
||||
}
|
||||
/* 框选时 ::selection 前景色会盖过透明字色,露出底层真实文本(@用户名);
|
||||
这里保持字形透明 + 半透明高亮,让镜像层的 @昵称 透出(宽度已补偿对齐) */
|
||||
.j13-chat-composer-input.has-value::selection {
|
||||
background: color-mix(in srgb, var(--accent) 24%, transparent);
|
||||
color: transparent;
|
||||
}
|
||||
.j13-chat-composer-input.has-value::placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
@@ -3525,20 +3537,18 @@ a.j13-mention-token:hover {
|
||||
background: var(--accent);
|
||||
color: var(--accent-on) !important;
|
||||
}
|
||||
/* 输入镜像层:不加粗/不加 padding,避免与透明 textarea 字宽错位 */
|
||||
/* 输入镜像层:token 仅蓝色文字(胶囊样式只在发出后的气泡里显示);
|
||||
基础胶囊色带 !important,此处需同权重覆盖 */
|
||||
.j13-chat-composer-mirror .j13-mention-token {
|
||||
display: inline;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
line-height: inherit;
|
||||
font-weight: inherit;
|
||||
border-radius: 999px;
|
||||
font-weight: 500;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: color-mix(in srgb, var(--accent) 82%, #000);
|
||||
}
|
||||
.dark .j13-chat-composer-mirror .j13-mention-token {
|
||||
background: var(--accent);
|
||||
color: var(--accent-on) !important;
|
||||
background: transparent !important;
|
||||
color: var(--accent) !important;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
@@ -3772,10 +3782,7 @@ a.j13-mention-token:hover {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
/* 站点设置:限宽左对齐,不写入后台公共主容器 */
|
||||
.admin-canvas:has(.admin-settings-page) {
|
||||
background: var(--bg);
|
||||
}
|
||||
/* 站点设置:限宽左对齐,不写入后台公共主容器(画布保持镂空,与其他管理页一致) */
|
||||
@media (min-width: 1920px) {
|
||||
.admin-canvas:has(.admin-settings-page) {
|
||||
padding-left: 3rem; /* 宽屏距主导航最多约 48px;1024+ 默认 2rem */
|
||||
@@ -3803,9 +3810,11 @@ a.j13-mention-token:hover {
|
||||
top: 0;
|
||||
align-self: start;
|
||||
z-index: 5;
|
||||
/* 滚动时盖住右侧内容,避免透出 */
|
||||
background: var(--bg);
|
||||
padding-block: 0.15rem 0.5rem;
|
||||
/* 画布镂空后导航改白色卡片:盖住滚动内容,并与右侧设置组卡片同语言 */
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r);
|
||||
padding: 0.5rem 0.4rem;
|
||||
margin-block: -0.15rem;
|
||||
}
|
||||
.admin-settings-nav {
|
||||
@@ -4773,10 +4782,7 @@ a.j13-mention-token:hover {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 单页管理:列表与编辑各自限宽,不套用设置页 1120px */
|
||||
.admin-canvas:has(.admin-doc-page) {
|
||||
background: var(--bg);
|
||||
}
|
||||
/* 单页/公告管理:限宽左对齐,不套用设置页 1120px;画布保持镂空,与其他管理页一致 */
|
||||
.admin-doc-page {
|
||||
width: 100%;
|
||||
margin-inline: 0;
|
||||
@@ -4785,56 +4791,126 @@ a.j13-mention-token:hover {
|
||||
.admin-doc-list {
|
||||
max-width: 1240px;
|
||||
}
|
||||
/* 编辑器本身通栏:吸顶栏背景/分隔线才能横贯整个画布,不在 1440px 处被切断 */
|
||||
.admin-doc-editor {
|
||||
max-width: 1440px;
|
||||
container-type: inline-size;
|
||||
container-name: doc-editor;
|
||||
}
|
||||
.admin-doc-head {
|
||||
/* 编辑器吸顶操作栏:返回/标题/状态 + 发布状态/撤销/保存,滚动时始终可见。
|
||||
画布镂空透出全站画布层,操作栏用白色块(同用户管理吸顶栏),滚动时干净盖住内容 */
|
||||
.admin-doc-sticky {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
margin-inline: -1rem;
|
||||
padding: 0.6rem 1rem;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.admin-doc-sticky {
|
||||
margin-inline: -1.5rem;
|
||||
padding-inline: 1.5rem;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.admin-doc-sticky {
|
||||
margin-inline: -2rem;
|
||||
padding-inline: 2rem;
|
||||
}
|
||||
}
|
||||
/* 栏内内容与下方 1440px 内容区对齐(编辑器已通栏) */
|
||||
.admin-doc-sticky-inner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
gap: 0.5rem 1rem;
|
||||
flex-wrap: wrap;
|
||||
max-width: 1440px;
|
||||
margin-right: auto;
|
||||
}
|
||||
.admin-doc-sticky-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.admin-doc-sticky-icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.65rem;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.admin-doc-sticky-title h1 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
line-height: 1.3;
|
||||
font-weight: 750;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.admin-doc-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
min-width: 2rem;
|
||||
min-height: 2rem;
|
||||
font-size: 13px;
|
||||
padding: 0 0.6rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--panel);
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-2);
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
transition: border-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.admin-doc-back:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.admin-doc-back:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
.admin-doc-head-title {
|
||||
@media (max-width: 420px) {
|
||||
.admin-doc-back-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.admin-doc-sticky-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
gap: 0.45rem 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
.admin-doc-head-title h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
line-height: 1.25;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--ink);
|
||||
.admin-doc-sticky-actions .btn {
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
.admin-doc-status {
|
||||
margin: 0 0 0 0.15rem;
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.admin-doc-status.is-error {
|
||||
color: var(--danger);
|
||||
}
|
||||
.admin-doc-split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 1.25rem;
|
||||
align-items: start;
|
||||
max-width: 1440px;
|
||||
}
|
||||
.admin-doc-main {
|
||||
min-width: 0;
|
||||
@@ -4850,6 +4926,18 @@ a.j13-mention-token:hover {
|
||||
line-height: 1.4;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.admin-doc-label-value {
|
||||
margin-left: 0.4rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
/* 主栏标题输入:正文中最大的输入项,突出「先起标题」 */
|
||||
.admin-doc-title .admin-field-input {
|
||||
font-size: 16.5px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.01em;
|
||||
min-height: 2.9rem;
|
||||
}
|
||||
.admin-doc-hint {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 12.5px;
|
||||
@@ -4857,6 +4945,75 @@ a.j13-mention-token:hover {
|
||||
color: var(--ink-3);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* 编辑页主栏整块铺纯白(含标题与正文编辑区):画布镂空后编辑区域需要干净的白底,
|
||||
右栏设置卡同为白卡,两卡之间露出纹饰画布 */
|
||||
.admin-doc-editor .admin-doc-main {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 1.1rem 1.1rem 1.2rem;
|
||||
}
|
||||
/* 编辑器自带白卡边框在纯白主栏内会形成双层描边,扁平化融入主卡 */
|
||||
.admin-doc-editor .admin-doc-main .j13-md-editor:not(.is-fullscreen) {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
/* 板块管理:低信息密度列表 —— 策略/状态/帖数并入板块元信息行,6 列并 3 列并限宽,
|
||||
避免宽屏下名称列吞掉全部富余空间而显得空旷 */
|
||||
.boards-panel {
|
||||
max-width: 60rem;
|
||||
}
|
||||
.boards-head,
|
||||
.boards-row {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr) auto;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
.boards-head {
|
||||
padding-block: 0.65rem;
|
||||
font-size: 11.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ink-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.boards-row {
|
||||
padding-block: 0.8rem;
|
||||
}
|
||||
.boards-row + .boards-row {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.boards-actions {
|
||||
justify-self: end;
|
||||
}
|
||||
.boards-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.boards-meta-posts {
|
||||
font-size: 11.5px;
|
||||
color: var(--ink-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.boards-head {
|
||||
display: none;
|
||||
}
|
||||
.boards-row {
|
||||
grid-template-columns: 40px minmax(0, 1fr);
|
||||
}
|
||||
.boards-actions {
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
.admin-doc-md .j13-md-editor:not(.is-fullscreen) {
|
||||
height: clamp(16rem, calc(100dvh - 22rem), 40rem);
|
||||
min-height: 0;
|
||||
@@ -4875,7 +5032,7 @@ a.j13-mention-token:hover {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--panel);
|
||||
padding: 0.85rem 0.9rem 1rem;
|
||||
padding: 0.9rem 1rem 1.05rem;
|
||||
}
|
||||
.admin-doc-rail > summary {
|
||||
cursor: pointer;
|
||||
@@ -4886,15 +5043,39 @@ a.j13-mention-token:hover {
|
||||
min-height: 2.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
padding-right: 1.5rem;
|
||||
user-select: none;
|
||||
}
|
||||
.admin-doc-rail > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
/* 折叠箭头:桌面双栏时 summary 隐藏,仅移动端单栏可见 */
|
||||
.admin-doc-rail > summary::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 0.35rem;
|
||||
top: 50%;
|
||||
width: 0.42rem;
|
||||
height: 0.42rem;
|
||||
border-right: 1.5px solid var(--ink-3);
|
||||
border-bottom: 1.5px solid var(--ink-3);
|
||||
transform: translateY(-70%) rotate(45deg);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.admin-doc-rail[open] > summary::after {
|
||||
transform: translateY(-30%) rotate(-135deg);
|
||||
}
|
||||
.admin-doc-rail-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding-top: 0.35rem;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
/* 设置项分组:细分隔线代替卡片套卡片 */
|
||||
.admin-doc-rail-body > * + * {
|
||||
margin-top: 1.05rem;
|
||||
padding-top: 1.05rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.admin-doc-field {
|
||||
min-width: 0;
|
||||
@@ -4949,6 +5130,21 @@ a.j13-mention-token:hover {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
/* 标签胶囊实时预览:色块下方一条安静的展示条,与前台同款 j13-anno-tag */
|
||||
.admin-doc-tagpreview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
margin-top: 0.7rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: 0.7rem;
|
||||
background: var(--bg);
|
||||
}
|
||||
.admin-doc-tagpreview::after {
|
||||
content: attr(data-note);
|
||||
font-size: 12px;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.admin-doc-colors .j13-anno-dot {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
@@ -4963,26 +5159,6 @@ a.j13-mention-token:hover {
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
}
|
||||
.admin-doc-actions {
|
||||
min-width: 0;
|
||||
}
|
||||
.admin-doc-actions-btns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem 0.75rem;
|
||||
}
|
||||
.admin-doc-actions .btn {
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
.admin-doc-status {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.admin-doc-status.is-error {
|
||||
color: var(--danger);
|
||||
}
|
||||
.admin-doc-table-wrap {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -5129,11 +5305,11 @@ a.j13-mention-token:hover {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@container doc-editor (min-width: 64rem) {
|
||||
/* 双栏阈值取 72rem:保证吸顶操作栏单行放下,右侧设置栏再入列 */
|
||||
@container doc-editor (min-width: 72rem) {
|
||||
.admin-doc-split {
|
||||
grid-template-columns: minmax(0, 1fr) 19rem;
|
||||
column-gap: 1.5rem;
|
||||
row-gap: 0.85rem;
|
||||
grid-template-columns: minmax(0, 1fr) 20rem;
|
||||
column-gap: 1.75rem;
|
||||
}
|
||||
.admin-doc-main {
|
||||
grid-column: 1;
|
||||
@@ -5141,10 +5317,12 @@ a.j13-mention-token:hover {
|
||||
}
|
||||
.admin-doc-rail {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
grid-row: 1;
|
||||
align-self: start;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
max-height: calc(100dvh - 6rem);
|
||||
/* 吸顶操作栏(约 3.7rem)之下留出呼吸空隙 */
|
||||
top: 4.5rem;
|
||||
max-height: calc(100dvh - 10rem);
|
||||
overflow: auto;
|
||||
}
|
||||
.admin-doc-rail > summary {
|
||||
@@ -5153,9 +5331,10 @@ a.j13-mention-token:hover {
|
||||
.admin-doc-rail:not([open]) > .admin-doc-rail-body {
|
||||
display: flex;
|
||||
}
|
||||
.admin-doc-actions {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
/* 现代浏览器(Chrome 131+)对关闭的 details 用 content-visibility 隐藏内容,
|
||||
上一条 display 规则救不回来;桌面双栏强制可见,防止移动端折叠后拉宽窗口设置凭空消失 */
|
||||
.admin-doc-rail:not([open])::details-content {
|
||||
content-visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6118,7 +6297,8 @@ a.j13-mention-token:hover {
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.j13-anno-dot:hover { transform: scale(1.12); }
|
||||
.j13-anno-dot[aria-pressed="true"] {
|
||||
.j13-anno-dot[aria-pressed="true"],
|
||||
.j13-anno-dot[aria-checked="true"] {
|
||||
border-color: var(--panel);
|
||||
box-shadow: 0 0 0 2px var(--accent);
|
||||
}
|
||||
@@ -8499,7 +8679,7 @@ html.j13-reduce-chrome .j13-att-card.is-fresh {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* 头像配色(浅/暗各自保证可读) */
|
||||
/* 头像/板块配色(浅/暗各自保证可读) */
|
||||
.av-0 { background: rgba(52, 81, 200, 0.12); color: #3451c8; }
|
||||
.av-1 { background: rgba(10, 99, 117, 0.12); color: #0a6375; }
|
||||
.av-2 { background: rgba(109, 71, 192, 0.12); color: #6d47c0; }
|
||||
@@ -8508,6 +8688,14 @@ html.j13-reduce-chrome .j13-att-card.is-fresh {
|
||||
.av-5 { background: rgba(171, 56, 93, 0.12); color: #ab385d; }
|
||||
.av-6 { background: rgba(133, 88, 0, 0.14); color: #855800; }
|
||||
.av-7 { background: rgba(82, 96, 122, 0.13); color: #52607a; }
|
||||
.av-8 { background: rgba(5, 150, 105, 0.13); color: #059669; }
|
||||
.av-9 { background: rgba(2, 132, 199, 0.13); color: #0284c7; }
|
||||
.av-10 { background: rgba(67, 56, 202, 0.13); color: #4338ca; }
|
||||
.av-11 { background: rgba(190, 24, 93, 0.13); color: #be185d; }
|
||||
.av-12 { background: rgba(180, 83, 9, 0.14); color: #b45309; }
|
||||
.av-13 { background: rgba(77, 124, 3, 0.14); color: #4d7c03; }
|
||||
.av-14 { background: rgba(157, 23, 158, 0.13); color: #9d179e; }
|
||||
.av-15 { background: rgba(159, 0, 51, 0.13); color: #9f0033; }
|
||||
.dark .av-0 { background: rgba(169, 184, 255, 0.18); color: #a9b8ff; }
|
||||
.dark .av-1 { background: rgba(92, 201, 224, 0.18); color: #5cc9e0; }
|
||||
.dark .av-2 { background: rgba(192, 168, 245, 0.18); color: #c0a8f5; }
|
||||
@@ -8516,6 +8704,14 @@ html.j13-reduce-chrome .j13-att-card.is-fresh {
|
||||
.dark .av-5 { background: rgba(240, 147, 181, 0.18); color: #f093b5; }
|
||||
.dark .av-6 { background: rgba(224, 178, 89, 0.18); color: #e0b259; }
|
||||
.dark .av-7 { background: rgba(170, 182, 207, 0.18); color: #aab6cf; }
|
||||
.dark .av-8 { background: rgba(110, 231, 183, 0.18); color: #6ee7b7; }
|
||||
.dark .av-9 { background: rgba(125, 211, 252, 0.18); color: #7dd3fc; }
|
||||
.dark .av-10 { background: rgba(165, 180, 252, 0.18); color: #a5b4fc; }
|
||||
.dark .av-11 { background: rgba(251, 113, 133, 0.18); color: #fb7185; }
|
||||
.dark .av-12 { background: rgba(252, 211, 77, 0.18); color: #fcd34d; }
|
||||
.dark .av-13 { background: rgba(190, 242, 100, 0.18); color: #bef264; }
|
||||
.dark .av-14 { background: rgba(232, 121, 249, 0.18); color: #e879f9; }
|
||||
.dark .av-15 { background: rgba(248, 113, 113, 0.18); color: #f87171; }
|
||||
|
||||
/* ============================================================
|
||||
微交互
|
||||
|
||||
@@ -38,6 +38,14 @@ export default function LoginPage() {
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [retryAfter, setRetryAfter] = useState(0);
|
||||
|
||||
// 登录限流倒计时:窗口期内禁用提交,避免无意义请求与提示条消失重现的闪烁
|
||||
useEffect(() => {
|
||||
if (retryAfter <= 0) return;
|
||||
const t = setTimeout(() => setRetryAfter((s) => s - 1), 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, [retryAfter]);
|
||||
|
||||
// 已登录用户访问登录页:直接回落到安全的跳转目标
|
||||
useEffect(() => {
|
||||
@@ -52,7 +60,7 @@ export default function LoginPage() {
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
// 提交不清空 error:提示条消失又重现会造成表单跳动(闪烁),输入时再清除
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiLogin(username, password);
|
||||
@@ -63,6 +71,9 @@ export default function LoginPage() {
|
||||
router.refresh();
|
||||
} else {
|
||||
setError(res.error || "登录失败");
|
||||
if (typeof res.retry_after === "number" && res.retry_after > 0) {
|
||||
setRetryAfter(res.retry_after);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -96,7 +107,10 @@ export default function LoginPage() {
|
||||
id="login-username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
@@ -110,15 +124,18 @@ export default function LoginPage() {
|
||||
id="login-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="field"
|
||||
placeholder="输入密码"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center mt-2">
|
||||
{loading ? "登录中..." : "登录"}
|
||||
<button type="submit" disabled={loading || retryAfter > 0} className="btn btn-primary w-full justify-center mt-2">
|
||||
{loading ? "登录中..." : retryAfter > 0 ? `${retryAfter} 秒后可重试` : "登录"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ type Props = {
|
||||
boardName: string;
|
||||
boardId: number;
|
||||
boardColorIndex?: number;
|
||||
boardIcon?: string;
|
||||
};
|
||||
|
||||
export default function EditPostClient({
|
||||
@@ -31,6 +32,7 @@ export default function EditPostClient({
|
||||
boardName,
|
||||
boardId,
|
||||
boardColorIndex = 0,
|
||||
boardIcon,
|
||||
}: Props) {
|
||||
return (
|
||||
<PostComposer
|
||||
@@ -47,6 +49,7 @@ export default function EditPostClient({
|
||||
boardName={boardName}
|
||||
boardId={boardId}
|
||||
boardColorIndex={boardColorIndex}
|
||||
boardIcon={boardIcon}
|
||||
cancelHref={`/post/${postId}`}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -47,6 +47,7 @@ export default async function EditPostPage({ params }: PageProps) {
|
||||
boardName={post.board?.name || "未知板块"}
|
||||
boardId={post.board_id}
|
||||
boardColorIndex={post.board?.color_index ?? 0}
|
||||
boardIcon={post.board?.icon}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type Board,
|
||||
type CommentsResponse,
|
||||
} from "@/lib/api";
|
||||
import BoardIcon, { boardColor } from "@/components/BoardIcon";
|
||||
import { postViewerCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||
import CommentSection from "@/components/CommentSection";
|
||||
@@ -109,7 +110,7 @@ function formatDateTime(dateStr: string) {
|
||||
============================================================ */
|
||||
|
||||
function BoardCard({ board }: { board: Board }) {
|
||||
const colorIdx = Math.max(0, board.color_index ?? 0) % 8;
|
||||
const colorIdx = boardColor(board);
|
||||
return (
|
||||
<section className="j13-module shrink-0" aria-labelledby="board-card-title">
|
||||
<p className="j13-module-title mb-3" id="board-card-title">
|
||||
@@ -117,9 +118,9 @@ function BoardCard({ board }: { board: Board }) {
|
||||
</p>
|
||||
<Link href={`/?board=${board.id}`} className="flex items-center gap-3 group">
|
||||
<span
|
||||
className={`av-${colorIdx} w-11 h-11 rounded-xl flex items-center justify-center text-lg font-extrabold shrink-0`}
|
||||
className={`av-${colorIdx} w-11 h-11 rounded-xl flex items-center justify-center shrink-0`}
|
||||
>
|
||||
{board.name.slice(0, 1)}
|
||||
<BoardIcon icon={board.icon} name={board.name} size={20} />
|
||||
</span>
|
||||
<span
|
||||
className="font-semibold text-[14.5px] truncate group-hover:text-[var(--accent)] transition-colors"
|
||||
|
||||
Reference in New Issue
Block a user