diff --git a/app/components/SortSettingModal.jsx b/app/components/SortSettingModal.jsx new file mode 100644 index 0000000..0bc8ef7 --- /dev/null +++ b/app/components/SortSettingModal.jsx @@ -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 = ( +
+
+
+

+ 排序规则 +

+ {onResetRules && ( + + )} +
+

+ 可拖拽调整优先级,右侧开关控制是否启用该排序规则。点击规则名称可编辑别名(例如“估值涨幅”的别名为“涨跌幅”)。 +

+
+ + {localRules.length === 0 ? ( +
+ 暂无可配置的排序规则。 +
+ ) : ( + <> + {/* 默认排序固定在顶部,且不可排序、不可关闭 */} + {localRules.find((item) => item.id === "default") && ( +
+
+
+ + {localRules.find((item) => item.id === "default")?.label || + "默认"} + +
+
+ )} + + {/* 其他规则支持拖拽和开关 */} + item.id !== "default")} + onReorder={handleReorder} + className={isMobile ? "mobile-setting-list" : "pc-table-setting-list"} + layoutScroll={isMobile} + style={isMobile ? { touchAction: "none" } : undefined} + > + + {localRules + .filter((item) => item.id !== "default") + .map((item) => ( + +
+ +
+
+ {editingId === item.id ? ( +
+ 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", + }} + /> +
+ ) : ( + <> + + {item.alias && ( + + {item.alias} + + )} + + )} +
+ {item.id !== "default" && ( + + )} +
+ ))} +
+
+ + )} +
+ ); + + const resetConfirm = ( + + {resetConfirmOpen && ( + + } + confirmVariant="primary" + confirmText="恢复默认" + onConfirm={() => { + setResetConfirmOpen(false); + queueMicrotask(() => { + onResetRules?.(); + }); + }} + onCancel={() => setResetConfirmOpen(false)} + /> + )} + + ); + + if (isMobile) { + return ( + { + if (!v) onClose?.(); + }} + direction="bottom" + > + + + + + 排序个性化设置 + + + + + +
{body}
+
+ {resetConfirm} +
+ ); + } + + if (typeof document === "undefined") return null; + + const content = ( + + {open && ( + + e.stopPropagation()} + style={{ + width: 420, + maxWidth: 480, + }} + > +
+
+ + 排序个性化设置 +
+ +
+ + {body} +
+
+ )} +
+ ); + + return createPortal( + <> + {content} + {resetConfirm} + , + document.body + ); +} diff --git a/app/page.jsx b/app/page.jsx index 896db33..bf3745e 100644 --- a/app/page.jsx +++ b/app/page.jsx @@ -60,6 +60,7 @@ import RefreshButton from "./components/RefreshButton"; import WeChatModal from "./components/WeChatModal"; import DcaModal from "./components/DcaModal"; import MarketIndexAccordion from "./components/MarketIndexAccordion"; +import SortSettingModal from "./components/SortSettingModal"; import githubImg from "./assets/github.svg"; import { supabase, isSupabaseConfigured } from './lib/supabase'; import { toast as sonnerToast } from 'sonner'; @@ -162,10 +163,20 @@ export default function HomePage() { const [groupManageOpen, setGroupManageOpen] = 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 [sortOrder, setSortOrder] = useState('desc'); // asc | desc const [isSortLoaded, setIsSortLoaded] = useState(false); + const [sortRules, setSortRules] = useState(DEFAULT_SORT_RULES); + const [sortSettingOpen, setSortSettingOpen] = useState(false); useEffect(() => { if (typeof window !== 'undefined') { @@ -173,6 +184,46 @@ export default function HomePage() { const savedSortOrder = window.localStorage.getItem('localSortOrder'); if (savedSortBy) setSortBy(savedSortBy); 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); } }, []); @@ -181,8 +232,36 @@ export default function HomePage() { if (typeof window !== 'undefined' && isSortLoaded) { window.localStorage.setItem('localSortBy', sortBy); 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 @@ -3912,17 +3991,29 @@ export default function HomePage() {
- - - 排序 - +
- {[ - { id: 'default', label: '默认' }, - { id: 'yield', label: '涨跌幅' }, - { id: 'holding', label: '持有收益' }, - { id: 'name', label: '名称' }, - ].map((s) => ( + {sortRules.filter((s) => s.enabled).map((s) => (