From 2ea3a26353357658262297ae601a714582f10046 Mon Sep 17 00:00:00 2001
From: hzm <934585316@qq.com>
Date: Sun, 15 Mar 2026 20:47:43 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=8E=92=E5=BA=8F?=
=?UTF-8?q?=E4=B8=AA=E6=80=A7=E5=8C=96=E8=AE=BE=E7=BD=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/components/SortSettingModal.jsx | 519 ++++++++++++++++++++++++++++
app/page.jsx | 125 ++++++-
2 files changed, 632 insertions(+), 12 deletions(-)
create mode 100644 app/components/SortSettingModal.jsx
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) => (