feat: 新增排序个性化设置
This commit is contained in:
519
app/components/SortSettingModal.jsx
Normal file
519
app/components/SortSettingModal.jsx
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
125
app/page.jsx
125
app/page.jsx
@@ -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 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user