feat: 类型帖互动、积分经济与发帖体验
补齐问答采纳/重开、投票匿名与有票禁编、悬赏过期退回、抽奖回帖开奖,并接入积分账本与发帖附件可见性。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
192
frontend/components/BoardPicker.tsx
Normal file
192
frontend/components/BoardPicker.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import type { Board } from "@/lib/api";
|
||||
|
||||
type BoardPickerProps = {
|
||||
boards: Board[];
|
||||
value: number;
|
||||
onChange: (id: number) => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
function boardColorIndex(b: Board, fallbackIndex: number) {
|
||||
return Math.max(0, b.color_index ?? fallbackIndex) % 8;
|
||||
}
|
||||
|
||||
export default function BoardPicker({
|
||||
boards,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
loading,
|
||||
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 label = loading
|
||||
? "加载板块…"
|
||||
: selected
|
||||
? selected.name
|
||||
: boards.length === 0
|
||||
? "暂无可用板块"
|
||||
: "选择板块";
|
||||
|
||||
const colorIdx = selected
|
||||
? boardColorIndex(selected, selectedIdx)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="j13-board-picker" ref={rootRef}>
|
||||
<button
|
||||
id={id}
|
||||
type="button"
|
||||
className="j13-board-trigger"
|
||||
disabled={disabled || loading || boards.length === 0}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onKeyDown={onTriggerKeyDown}
|
||||
>
|
||||
<span className={`j13-board-trigger-ico av-${colorIdx}`} aria-hidden>
|
||||
{(selected?.name || "板").slice(0, 1)}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 truncate font-semibold text-[14px]">
|
||||
{label}
|
||||
</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;
|
||||
const c = boardColorIndex(b, i);
|
||||
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-${c}`} aria-hidden>
|
||||
{b.name.slice(0, 1)}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 truncate">{b.name}</span>
|
||||
{selectedHere && <Check size={15} className="shrink-0" />}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 编辑态只读板块徽章(与触发器同高) */
|
||||
export function BoardBadge({
|
||||
name,
|
||||
colorIndex = 0,
|
||||
}: {
|
||||
name: string;
|
||||
colorIndex?: number;
|
||||
}) {
|
||||
const c = Math.max(0, colorIndex) % 8;
|
||||
return (
|
||||
<div className="j13-board-badge">
|
||||
<span className={`j13-board-trigger-ico av-${c}`} aria-hidden>
|
||||
{name.slice(0, 1)}
|
||||
</span>
|
||||
<span className="truncate">{name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user