feat: 新增排序个性化设置

This commit is contained in:
hzm
2026-03-15 20:47:43 +08:00
parent 885a8fc782
commit 2ea3a26353
2 changed files with 632 additions and 12 deletions

View File

@@ -0,0 +1,519 @@
"use client";
import { useEffect, useState } from "react";
import { AnimatePresence, motion, Reorder } from "framer-motion";
import { createPortal } from "react-dom";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerClose,
} from "@/components/ui/drawer";
import { CloseIcon, DragIcon, ResetIcon, SettingsIcon } from "./Icons";
import ConfirmModal from "./ConfirmModal";
/**
* 排序个性化设置弹框
*
* - 移动端:使用 Drawer自底向上抽屉参考市场指数设置
* - PC 端:使用右侧侧弹框(样式参考 PcTableSettingModal
*
* @param {Object} props
* @param {boolean} props.open - 是否打开
* @param {() => void} props.onClose - 关闭回调
* @param {boolean} props.isMobile - 是否为移动端(由上层传入)
* @param {Array<{id: string, label: string, enabled: boolean}>} props.rules - 排序规则列表
* @param {(nextRules: Array<{id: string, label: string, enabled: boolean}>) => void} props.onChangeRules - 规则变更回调
*/
export default function SortSettingModal({
open,
onClose,
isMobile,
rules = [],
onChangeRules,
onResetRules,
}) {
const [localRules, setLocalRules] = useState(rules);
const [editingId, setEditingId] = useState(null);
const [editingAlias, setEditingAlias] = useState("");
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
useEffect(() => {
if (open) {
const defaultRule = (rules || []).find((item) => item.id === "default");
const otherRules = (rules || []).filter((item) => item.id !== "default");
const ordered = defaultRule ? [defaultRule, ...otherRules] : otherRules;
setLocalRules(ordered);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}
}, [open, rules]);
const handleReorder = (nextItems) => {
// 基于当前 localRules 计算新顺序(默认规则固定在首位)
const defaultRule = (localRules || []).find((item) => item.id === "default");
const combined = defaultRule ? [defaultRule, ...nextItems] : nextItems;
setLocalRules(combined);
if (onChangeRules) {
queueMicrotask(() => {
onChangeRules(combined);
});
}
};
const handleToggle = (id) => {
const next = (localRules || []).map((item) =>
item.id === id ? { ...item, enabled: !item.enabled } : item
);
setLocalRules(next);
if (onChangeRules) {
queueMicrotask(() => {
onChangeRules(next);
});
}
};
const startEditAlias = (item) => {
if (!item || item.id === "default") return;
setEditingId(item.id);
setEditingAlias(item.alias || "");
};
const commitAlias = () => {
if (!editingId) return;
let nextRules = null;
setLocalRules((prev) => {
const next = prev.map((item) =>
item.id === editingId
? { ...item, alias: editingAlias.trim() || undefined }
: item
);
nextRules = next;
return next;
});
if (nextRules) {
// 将父组件状态更新放到微任务中,避免在 SortSettingModal 渲染过程中直接更新 HomePage
queueMicrotask(() => {
onChangeRules?.(nextRules);
});
}
setEditingId(null);
setEditingAlias("");
};
const cancelAlias = () => {
setEditingId(null);
setEditingAlias("");
};
if (!open) return null;
const body = (
<div
className={
isMobile
? "mobile-setting-body flex flex-1 flex-col overflow-y-auto"
: "pc-table-setting-body"
}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 4,
marginBottom: 16,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
>
<h3
className="pc-table-setting-subtitle"
style={{ margin: 0, fontSize: 14 }}
>
排序规则
</h3>
{onResetRules && (
<button
type="button"
className="icon-button"
onClick={() => setResetConfirmOpen(true)}
title="重置排序规则"
style={{
border: "none",
width: 28,
height: 28,
backgroundColor: "transparent",
color: "var(--muted-foreground)",
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<ResetIcon width="16" height="16" />
</button>
)}
</div>
<p
className="muted"
style={{ fontSize: 12, margin: 0, color: "var(--muted-foreground)" }}
>
可拖拽调整优先级右侧开关控制是否启用该排序规则点击规则名称可编辑别名例如估值涨幅的别名为涨跌幅
</p>
</div>
{localRules.length === 0 ? (
<div
className="muted"
style={{
textAlign: "center",
padding: "24px 0",
fontSize: 14,
}}
>
暂无可配置的排序规则
</div>
) : (
<>
{/* 默认排序固定在顶部,且不可排序、不可关闭 */}
{localRules.find((item) => item.id === "default") && (
<div
className={
(isMobile ? "mobile-setting-item" : "pc-table-setting-item") +
" glass"
}
style={{
display: "flex",
alignItems: "center",
marginBottom: 8,
}}
>
<div
style={{
width: 18,
height: 18,
marginLeft: 4,
}}
/>
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
<span style={{ fontSize: 14 }}>
{localRules.find((item) => item.id === "default")?.label ||
"默认"}
</span>
</div>
</div>
)}
{/* 其他规则支持拖拽和开关 */}
<Reorder.Group
axis="y"
values={localRules.filter((item) => item.id !== "default")}
onReorder={handleReorder}
className={isMobile ? "mobile-setting-list" : "pc-table-setting-list"}
layoutScroll={isMobile}
style={isMobile ? { touchAction: "none" } : undefined}
>
<AnimatePresence mode="popLayout">
{localRules
.filter((item) => item.id !== "default")
.map((item) => (
<Reorder.Item
key={item.id}
value={item}
className={
(isMobile ? "mobile-setting-item" : "pc-table-setting-item") +
" glass"
}
layout
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{
type: "spring",
stiffness: 500,
damping: 35,
mass: 1,
layout: { duration: 0.2 },
}}
style={isMobile ? { touchAction: "none" } : undefined}
>
<div
className="drag-handle"
style={{
cursor: "grab",
display: "flex",
alignItems: "center",
padding: "0 8px",
color: "var(--muted)",
}}
>
<DragIcon width="18" height="18" />
</div>
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
{editingId === item.id ? (
<div style={{ display: "flex", gap: 6 }}>
<input
autoFocus
value={editingAlias}
onChange={(e) => setEditingAlias(e.target.value)}
onBlur={commitAlias}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commitAlias();
} else if (e.key === "Escape") {
e.preventDefault();
cancelAlias();
}
}}
placeholder="输入别名,如涨跌幅"
style={{
flex: 1,
// 使用 >=16px 的字号,避免移动端聚焦时页面放大
fontSize: 16,
padding: "4px 8px",
borderRadius: 6,
border: "1px solid var(--border)",
background: "transparent",
color: "var(--text)",
outline: "none",
}}
/>
</div>
) : (
<>
<button
type="button"
onClick={() => startEditAlias(item)}
style={{
padding: 0,
margin: 0,
border: "none",
background: "transparent",
textAlign: "left",
fontSize: 14,
color: "inherit",
cursor: "pointer",
}}
title="点击修改别名"
>
{item.label}
</button>
{item.alias && (
<span
className="muted"
style={{
fontSize: 12,
color: "var(--muted-foreground)",
}}
>
{item.alias}
</span>
)}
</>
)}
</div>
{item.id !== "default" && (
<button
type="button"
className={
isMobile ? "icon-button" : "icon-button pc-table-column-switch"
}
onClick={(e) => {
e.stopPropagation();
handleToggle(item.id);
}}
title={item.enabled ? "关闭" : "开启"}
style={
isMobile
? {
border: "none",
backgroundColor: "transparent",
cursor: "pointer",
flexShrink: 0,
display: "flex",
alignItems: "center",
}
: {
border: "none",
padding: "0 4px",
backgroundColor: "transparent",
cursor: "pointer",
flexShrink: 0,
display: "flex",
alignItems: "center",
}
}
>
<span
className={`dca-toggle-track ${
item.enabled ? "enabled" : ""
}`}
>
<span
className="dca-toggle-thumb"
style={{ left: item.enabled ? 16 : 2 }}
/>
</span>
</button>
)}
</Reorder.Item>
))}
</AnimatePresence>
</Reorder.Group>
</>
)}
</div>
);
const resetConfirm = (
<AnimatePresence>
{resetConfirmOpen && (
<ConfirmModal
key="reset-sort-rules-confirm"
title="重置排序规则"
message="是否将排序规则恢复为默认配置?这会重置顺序、开关状态以及别名设置。"
icon={
<ResetIcon
width="20"
height="20"
className="shrink-0 text-[var(--primary)]"
/>
}
confirmVariant="primary"
confirmText="恢复默认"
onConfirm={() => {
setResetConfirmOpen(false);
queueMicrotask(() => {
onResetRules?.();
});
}}
onCancel={() => setResetConfirmOpen(false)}
/>
)}
</AnimatePresence>
);
if (isMobile) {
return (
<Drawer
open={open}
onOpenChange={(v) => {
if (!v) onClose?.();
}}
direction="bottom"
>
<DrawerContent
className="glass"
defaultHeight="70vh"
minHeight="40vh"
maxHeight="90vh"
>
<DrawerHeader className="flex flex-row items-center justify-between gap-2 py-4">
<DrawerTitle className="flex items-center gap-2.5 text-left">
<SettingsIcon width="20" height="20" />
<span>排序个性化设置</span>
</DrawerTitle>
<DrawerClose
className="icon-button border-none bg-transparent p-1"
title="关闭"
style={{
borderColor: "transparent",
backgroundColor: "transparent",
}}
>
<CloseIcon width="20" height="20" />
</DrawerClose>
</DrawerHeader>
<div className="flex-1 overflow-y-auto">{body}</div>
</DrawerContent>
{resetConfirm}
</Drawer>
);
}
if (typeof document === "undefined") return null;
const content = (
<AnimatePresence>
{open && (
<motion.div
key="sort-setting-overlay"
className="pc-table-setting-overlay"
role="dialog"
aria-modal="true"
aria-label="排序个性化设置"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={onClose}
style={{ zIndex: 10001, alignItems: "stretch" }}
>
<motion.aside
className="pc-table-setting-drawer glass"
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ type: "spring", damping: 30, stiffness: 300 }}
onClick={(e) => e.stopPropagation()}
style={{
width: 420,
maxWidth: 480,
}}
>
<div className="pc-table-setting-header">
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<SettingsIcon width="20" height="20" />
<span>排序个性化设置</span>
</div>
<button
type="button"
className="icon-button"
onClick={onClose}
title="关闭"
style={{ border: "none", background: "transparent" }}
>
<CloseIcon width="20" height="20" />
</button>
</div>
{body}
</motion.aside>
</motion.div>
)}
</AnimatePresence>
);
return createPortal(
<>
{content}
{resetConfirm}
</>,
document.body
);
}