Files
jiang13-bbs/frontend/components/BoardPicker.tsx
freefire 3e55b5d230 feat: 管理端板块管理与旧版数据导入,补充部署运营文档
- 新增管理端板块管理页面与后端接口(admin_board)
- 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具
- 聊天用户卡片、板块图标等 UI 组件与界面优化
- 补充 about/公告/1Panel 部署等文档
- gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
2026-09-24 03:37:44 +08:00

207 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { Check, ChevronDown } from "lucide-react";
import type { Board } from "@/lib/api";
import BoardIcon, { boardColor } from "@/components/BoardIcon";
type BoardPickerProps = {
boards: Board[];
value: number;
onChange: (id: number) => void;
disabled?: boolean;
loading?: boolean;
/** 提交校验失败后再为 true,用于红色错误态 */
invalid?: boolean;
id?: string;
};
export default function BoardPicker({
boards,
value,
onChange,
disabled,
loading,
invalid = false,
id,
}: BoardPickerProps) {
const listId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const selectedIdx = boards.findIndex((b) => b.id === value);
const selected = selectedIdx >= 0 ? boards[selectedIdx] : null;
useEffect(() => {
if (!open) return;
setActiveIndex(selectedIdx >= 0 ? selectedIdx : 0);
}, [open, selectedIdx]);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
setOpen(false);
}
};
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDoc);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const pick = (id: number) => {
onChange(id);
setOpen(false);
};
const onTriggerKeyDown = (e: React.KeyboardEvent) => {
if (disabled || loading) return;
if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpen(true);
}
};
const onMenuKeyDown = (e: React.KeyboardEvent) => {
if (!boards.length) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => (i + 1) % boards.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => (i - 1 + boards.length) % boards.length);
} else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
const b = boards[activeIndex];
if (b) pick(b.id);
} else if (e.key === "Home") {
e.preventDefault();
setActiveIndex(0);
} else if (e.key === "End") {
e.preventDefault();
setActiveIndex(boards.length - 1);
}
};
const empty = !loading && !selected;
const showError = empty && invalid;
const label = loading
? "加载板块…"
: selected
? selected.name
: boards.length === 0
? "暂无可用板块"
: "请选择板块";
const colorIdx = selected ? boardColor(selected) : 0;
return (
<div className="j13-board-picker" ref={rootRef}>
<button
id={id}
type="button"
className={`j13-board-trigger${showError ? " is-invalid" : ""}`}
disabled={disabled || loading || boards.length === 0}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listId}
aria-invalid={showError || undefined}
onClick={() => setOpen((v) => !v)}
onKeyDown={onTriggerKeyDown}
>
<span
className={`j13-board-trigger-ico${selected ? ` av-${colorIdx}` : " is-placeholder"}`}
aria-hidden
>
<BoardIcon icon={selected?.icon} name={selected?.name || "板"} size={16} />
</span>
<span className="j13-board-trigger-copy">
<span className="j13-board-trigger-name">{label}</span>
{showError ? (
<span className="j13-board-trigger-hint">请选择板块后再发布</span>
) : empty ? (
<span className="j13-board-trigger-hint">发布前必须选择</span>
) : loading ? (
<span className="j13-board-trigger-hint">正在加载</span>
) : null}
</span>
<ChevronDown
size={16}
className="shrink-0 opacity-60"
style={{
color: "var(--ink-3)",
transform: open ? "rotate(180deg)" : undefined,
transition: "transform 0.15s ease",
}}
/>
</button>
{open && (
<ul
id={listId}
role="listbox"
aria-label="选择板块"
className="j13-board-menu"
tabIndex={-1}
onKeyDown={onMenuKeyDown}
>
{boards.map((b, i) => {
const selectedHere = b.id === value;
return (
<li key={b.id} role="presentation">
<button
type="button"
role="option"
aria-selected={selectedHere}
className={`j13-board-option${
selectedHere ? " is-selected" : ""
}${i === activeIndex ? " is-active" : ""}`}
onMouseEnter={() => setActiveIndex(i)}
onClick={() => pick(b.id)}
>
<span className={`j13-board-trigger-ico av-${boardColor(b)}`} aria-hidden>
<BoardIcon icon={b.icon} name={b.name} size={16} />
</span>
<span className="flex-1 min-w-0 truncate">{b.name}</span>
{selectedHere && <Check size={15} className="shrink-0" />}
</button>
</li>
);
})}
</ul>
)}
</div>
);
}
/** 编辑态只读板块徽章(与触发器同高;boardId + 原始 color_index,内部按全站规则解析配色) */
export function BoardBadge({
name,
boardId,
colorIndex = 0,
icon,
}: {
name: string;
boardId: number;
colorIndex?: number;
icon?: string;
}) {
const c = boardColor({ id: boardId, color_index: colorIndex });
return (
<div className="j13-board-badge">
<span className={`j13-board-trigger-ico av-${c}`} aria-hidden>
<BoardIcon icon={icon} name={name} size={16} />
</span>
<span className="truncate">{name}</span>
</div>
);
}