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
);
}

View File

@@ -60,6 +60,7 @@ import RefreshButton from "./components/RefreshButton";
import WeChatModal from "./components/WeChatModal"; import WeChatModal from "./components/WeChatModal";
import DcaModal from "./components/DcaModal"; import DcaModal from "./components/DcaModal";
import MarketIndexAccordion from "./components/MarketIndexAccordion"; import MarketIndexAccordion from "./components/MarketIndexAccordion";
import SortSettingModal from "./components/SortSettingModal";
import githubImg from "./assets/github.svg"; import githubImg from "./assets/github.svg";
import { supabase, isSupabaseConfigured } from './lib/supabase'; import { supabase, isSupabaseConfigured } from './lib/supabase';
import { toast as sonnerToast } from 'sonner'; import { toast as sonnerToast } from 'sonner';
@@ -162,10 +163,20 @@ export default function HomePage() {
const [groupManageOpen, setGroupManageOpen] = useState(false); const [groupManageOpen, setGroupManageOpen] = useState(false);
const [addFundToGroupOpen, setAddFundToGroupOpen] = useState(false); const [addFundToGroupOpen, setAddFundToGroupOpen] = useState(false);
const DEFAULT_SORT_RULES = [
{ id: 'default', label: '默认', enabled: true },
// 估值涨幅为原始名称,“涨跌幅”为别名
{ id: 'yield', label: '估值涨幅', alias: '涨跌幅', enabled: true },
{ id: 'holding', label: '持有收益', enabled: true },
{ id: 'name', label: '名称', enabled: true },
];
// 排序状态 // 排序状态
const [sortBy, setSortBy] = useState('default'); // default, name, yield, holding const [sortBy, setSortBy] = useState('default'); // default, name, yield, holding
const [sortOrder, setSortOrder] = useState('desc'); // asc | desc const [sortOrder, setSortOrder] = useState('desc'); // asc | desc
const [isSortLoaded, setIsSortLoaded] = useState(false); const [isSortLoaded, setIsSortLoaded] = useState(false);
const [sortRules, setSortRules] = useState(DEFAULT_SORT_RULES);
const [sortSettingOpen, setSortSettingOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@@ -173,6 +184,46 @@ export default function HomePage() {
const savedSortOrder = window.localStorage.getItem('localSortOrder'); const savedSortOrder = window.localStorage.getItem('localSortOrder');
if (savedSortBy) setSortBy(savedSortBy); if (savedSortBy) setSortBy(savedSortBy);
if (savedSortOrder) setSortOrder(savedSortOrder); if (savedSortOrder) setSortOrder(savedSortOrder);
// 1优先从 customSettings.localSortRules 读取
// 2兼容旧版独立 localSortRules 字段
let rulesFromSettings = null;
try {
const rawSettings = window.localStorage.getItem('customSettings');
if (rawSettings) {
const parsed = JSON.parse(rawSettings);
if (parsed && Array.isArray(parsed.localSortRules)) {
rulesFromSettings = parsed.localSortRules;
}
}
} catch {
// ignore
}
if (!rulesFromSettings) {
const legacy = window.localStorage.getItem('localSortRules');
if (legacy) {
try {
const parsed = JSON.parse(legacy);
if (Array.isArray(parsed)) {
rulesFromSettings = parsed;
}
} catch {
// ignore
}
}
}
if (rulesFromSettings && rulesFromSettings.length) {
const merged = DEFAULT_SORT_RULES.map((rule) => {
const found = rulesFromSettings.find((r) => r.id === rule.id);
return found
? { ...rule, enabled: found.enabled !== false }
: rule;
});
setSortRules(merged);
}
setIsSortLoaded(true); setIsSortLoaded(true);
} }
}, []); }, []);
@@ -181,8 +232,36 @@ export default function HomePage() {
if (typeof window !== 'undefined' && isSortLoaded) { if (typeof window !== 'undefined' && isSortLoaded) {
window.localStorage.setItem('localSortBy', sortBy); window.localStorage.setItem('localSortBy', sortBy);
window.localStorage.setItem('localSortOrder', sortOrder); window.localStorage.setItem('localSortOrder', sortOrder);
try {
const raw = window.localStorage.getItem('customSettings');
const parsed = raw ? JSON.parse(raw) : {};
const next = {
...(parsed && typeof parsed === 'object' ? parsed : {}),
localSortRules: sortRules,
};
window.localStorage.setItem('customSettings', JSON.stringify(next));
// 更新后标记 customSettings 脏并触发云端同步
triggerCustomSettingsSync();
} catch {
// ignore
}
} }
}, [sortBy, sortOrder, isSortLoaded]); }, [sortBy, sortOrder, sortRules, isSortLoaded]);
// 当用户关闭某个排序规则时,如果当前 sortBy 不再可用,则自动切换到第一个启用的规则
useEffect(() => {
const enabledRules = (sortRules || []).filter((r) => r.enabled);
const enabledIds = enabledRules.map((r) => r.id);
if (!enabledIds.length) {
// 至少保证默认存在
setSortRules(DEFAULT_SORT_RULES);
setSortBy('default');
return;
}
if (!enabledIds.includes(sortBy)) {
setSortBy(enabledIds[0]);
}
}, [sortRules, sortBy]);
// 视图模式 // 视图模式
const [viewMode, setViewMode] = useState('card'); // card, list const [viewMode, setViewMode] = useState('card'); // card, list
@@ -3912,17 +3991,29 @@ export default function HomePage() {
<div className="divider" style={{ width: '1px', height: '20px', background: 'var(--border)' }} /> <div className="divider" style={{ width: '1px', height: '20px', background: 'var(--border)' }} />
<div className="sort-items" style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div className="sort-items" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="muted" style={{ fontSize: '12px', display: 'flex', alignItems: 'center', gap: 4 }}> <button
<SortIcon width="14" height="14" /> type="button"
排序 className="icon-button"
</span> onClick={() => setSortSettingOpen(true)}
style={{
border: 'none',
background: 'transparent',
padding: 0,
display: 'flex',
alignItems: 'center',
gap: 4,
fontSize: '12px',
color: 'var(--muted-foreground)',
cursor: 'pointer',
width: '50px',
}}
title="排序个性化设置"
>
<span className="muted">排序</span>
<SettingsIcon width="14" height="14" />
</button>
<div className="chips"> <div className="chips">
{[ {sortRules.filter((s) => s.enabled).map((s) => (
{ id: 'default', label: '默认' },
{ id: 'yield', label: '涨跌幅' },
{ id: 'holding', label: '持有收益' },
{ id: 'name', label: '名称' },
].map((s) => (
<button <button
key={s.id} key={s.id}
className={`chip ${sortBy === s.id ? 'active' : ''}`} className={`chip ${sortBy === s.id ? 'active' : ''}`}
@@ -3938,7 +4029,7 @@ export default function HomePage() {
}} }}
style={{ height: '28px', fontSize: '12px', padding: '0 10px', display: 'flex', alignItems: 'center', gap: 4 }} style={{ height: '28px', fontSize: '12px', padding: '0 10px', display: 'flex', alignItems: 'center', gap: 4 }}
> >
<span>{s.label}</span> <span>{s.alias || s.label}</span>
{s.id !== 'default' && sortBy === s.id && ( {s.id !== 'default' && sortBy === s.id && (
<span <span
style={{ style={{
@@ -4617,6 +4708,16 @@ export default function HomePage() {
/> />
)} )}
{/* 排序个性化设置弹框 */}
<SortSettingModal
open={sortSettingOpen}
onClose={() => setSortSettingOpen(false)}
isMobile={isMobile}
rules={sortRules}
onChangeRules={setSortRules}
onResetRules={() => setSortRules(DEFAULT_SORT_RULES)}
/>
{/* 全局轻提示 Toast */} {/* 全局轻提示 Toast */}
<AnimatePresence> <AnimatePresence>
{toast.show && ( {toast.show && (