feat: 排序个性化新增排序形式切换

This commit is contained in:
hzm
2026-03-20 09:03:51 +08:00
parent 4f438d0dc5
commit 9f6d1bb768
4 changed files with 243 additions and 43 deletions

View File

@@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
import { Switch } from '@/components/ui/switch';
import ConfirmModal from './ConfirmModal'; import ConfirmModal from './ConfirmModal';
import { ResetIcon, SettingsIcon } from './Icons'; import { ResetIcon, SettingsIcon } from './Icons';
@@ -19,10 +20,13 @@ export default function SettingsModal({
containerWidth = 1200, containerWidth = 1200,
setContainerWidth, setContainerWidth,
onResetContainerWidth, onResetContainerWidth,
showMarketIndex = true,
setShowMarketIndex,
}) { }) {
const [sliderDragging, setSliderDragging] = useState(false); const [sliderDragging, setSliderDragging] = useState(false);
const [resetWidthConfirmOpen, setResetWidthConfirmOpen] = useState(false); const [resetWidthConfirmOpen, setResetWidthConfirmOpen] = useState(false);
const [localSeconds, setLocalSeconds] = useState(tempSeconds); const [localSeconds, setLocalSeconds] = useState(tempSeconds);
const [localShowMarketIndex, setLocalShowMarketIndex] = useState(showMarketIndex);
const pageWidthTrackRef = useRef(null); const pageWidthTrackRef = useRef(null);
const clampedWidth = Math.min(2000, Math.max(600, Number(containerWidth) || 1200)); const clampedWidth = Math.min(2000, Math.max(600, Number(containerWidth) || 1200));
@@ -55,6 +59,10 @@ export default function SettingsModal({
setLocalSeconds(tempSeconds); setLocalSeconds(tempSeconds);
}, [tempSeconds]); }, [tempSeconds]);
useEffect(() => {
setLocalShowMarketIndex(showMarketIndex);
}, [showMarketIndex]);
return ( return (
<Dialog <Dialog
open open
@@ -162,6 +170,22 @@ export default function SettingsModal({
</div> </div>
)} )}
<div hidden className="form-group" style={{ marginBottom: 16 }}>
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>显示大盘指数</div>
<div className="row" style={{ justifyContent: 'flex-start', alignItems: 'center' }}>
<Switch
checked={localShowMarketIndex}
className="ml-2 scale-125"
onCheckedChange={(checked) => {
const nextValue = Boolean(checked);
setLocalShowMarketIndex(nextValue);
setShowMarketIndex?.(nextValue);
}}
aria-label="显示大盘指数"
/>
</div>
</div>
<div className="form-group" style={{ marginBottom: 16 }}> <div className="form-group" style={{ marginBottom: 16 }}>
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>数据导出</div> <div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>数据导出</div>
<div className="row" style={{ gap: 8 }}> <div className="row" style={{ gap: 8 }}>
@@ -188,7 +212,7 @@ export default function SettingsModal({
<div className="row" style={{ justifyContent: 'flex-end', marginTop: 24 }}> <div className="row" style={{ justifyContent: 'flex-end', marginTop: 24 }}>
<button <button
className="button" className="button"
onClick={(e) => saveSettings(e, localSeconds)} onClick={(e) => saveSettings(e, localSeconds, localShowMarketIndex)}
disabled={localSeconds < 30} disabled={localSeconds < 30}
> >
保存并关闭 保存并关闭

View File

@@ -10,6 +10,7 @@ import {
DrawerTitle, DrawerTitle,
DrawerClose, DrawerClose,
} from "@/components/ui/drawer"; } from "@/components/ui/drawer";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { CloseIcon, DragIcon, ResetIcon, SettingsIcon } from "./Icons"; import { CloseIcon, DragIcon, ResetIcon, SettingsIcon } from "./Icons";
import ConfirmModal from "./ConfirmModal"; import ConfirmModal from "./ConfirmModal";
@@ -33,6 +34,8 @@ export default function SortSettingModal({
rules = [], rules = [],
onChangeRules, onChangeRules,
onResetRules, onResetRules,
sortDisplayMode = "buttons",
onChangeSortDisplayMode,
}) { }) {
const [localRules, setLocalRules] = useState(rules); const [localRules, setLocalRules] = useState(rules);
const [editingId, setEditingId] = useState(null); const [editingId, setEditingId] = useState(null);
@@ -120,6 +123,59 @@ export default function SortSettingModal({
: "pc-table-setting-body" : "pc-table-setting-body"
} }
> >
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
marginBottom: 16,
}}
>
<h3
className="pc-table-setting-subtitle"
style={{ margin: 0, fontSize: 14 }}
>
排序形式
</h3>
<div style={{ display: "flex", justifyContent: "flex-end", marginLeft: "auto" }}>
<RadioGroup
value={sortDisplayMode}
onValueChange={(value) => onChangeSortDisplayMode?.(value)}
className="flex flex-row items-center gap-4"
>
<label
htmlFor="sort-display-mode-buttons"
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: 13,
color: "var(--text)",
cursor: "pointer",
}}
>
<RadioGroupItem id="sort-display-mode-buttons" value="buttons" />
<span>按钮</span>
</label>
<label
htmlFor="sort-display-mode-dropdown"
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: 13,
color: "var(--text)",
cursor: "pointer",
}}
>
<RadioGroupItem id="sort-display-mode-dropdown" value="dropdown" />
<span>下拉单选</span>
</label>
</RadioGroup>
</div>
</div>
<div <div
style={{ style={{
display: "flex", display: "flex",

View File

@@ -71,6 +71,13 @@ import packageJson from '../package.json';
import PcFundTable from './components/PcFundTable'; import PcFundTable from './components/PcFundTable';
import MobileFundTable from './components/MobileFundTable'; import MobileFundTable from './components/MobileFundTable';
import { useFundFuzzyMatcher } from './hooks/useFundFuzzyMatcher'; import { useFundFuzzyMatcher } from './hooks/useFundFuzzyMatcher';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
@@ -129,6 +136,7 @@ export default function HomePage() {
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [tempSeconds, setTempSeconds] = useState(60); const [tempSeconds, setTempSeconds] = useState(60);
const [containerWidth, setContainerWidth] = useState(1200); const [containerWidth, setContainerWidth] = useState(1200);
const [showMarketIndex, setShowMarketIndex] = useState(true);
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
@@ -141,6 +149,9 @@ export default function HomePage() {
if (Number.isFinite(num)) { if (Number.isFinite(num)) {
setContainerWidth(Math.min(2000, Math.max(600, num))); setContainerWidth(Math.min(2000, Math.max(600, num)));
} }
if (typeof parsed?.showMarketIndex === 'boolean') {
setShowMarketIndex(parsed.showMarketIndex);
}
} catch { } } catch { }
}, []); }, []);
@@ -174,10 +185,12 @@ export default function HomePage() {
{ id: 'holding', label: '持有收益', enabled: true }, { id: 'holding', label: '持有收益', enabled: true },
{ id: 'name', label: '基金名称', alias: '名称', enabled: true }, { id: 'name', label: '基金名称', alias: '名称', enabled: true },
]; ];
const SORT_DISPLAY_MODES = new Set(['buttons', 'dropdown']);
// 排序状态 // 排序状态
const [sortBy, setSortBy] = useState('default'); // default, name, yield, yesterdayIncrease, holding, holdingAmount const [sortBy, setSortBy] = useState('default'); // default, name, yield, yesterdayIncrease, holding, holdingAmount
const [sortOrder, setSortOrder] = useState('desc'); // asc | desc const [sortOrder, setSortOrder] = useState('desc'); // asc | desc
const [sortDisplayMode, setSortDisplayMode] = useState('buttons'); // buttons | dropdown
const [isSortLoaded, setIsSortLoaded] = useState(false); const [isSortLoaded, setIsSortLoaded] = useState(false);
const [sortRules, setSortRules] = useState(DEFAULT_SORT_RULES); const [sortRules, setSortRules] = useState(DEFAULT_SORT_RULES);
const [sortSettingOpen, setSortSettingOpen] = useState(false); const [sortSettingOpen, setSortSettingOpen] = useState(false);
@@ -199,6 +212,13 @@ export default function HomePage() {
if (parsed && Array.isArray(parsed.localSortRules)) { if (parsed && Array.isArray(parsed.localSortRules)) {
rulesFromSettings = parsed.localSortRules; rulesFromSettings = parsed.localSortRules;
} }
if (
parsed &&
typeof parsed.localSortDisplayMode === 'string' &&
SORT_DISPLAY_MODES.has(parsed.localSortDisplayMode)
) {
setSortDisplayMode(parsed.localSortDisplayMode);
}
} }
} catch { } catch {
// ignore // ignore
@@ -267,6 +287,7 @@ export default function HomePage() {
const next = { const next = {
...(parsed && typeof parsed === 'object' ? parsed : {}), ...(parsed && typeof parsed === 'object' ? parsed : {}),
localSortRules: sortRules, localSortRules: sortRules,
localSortDisplayMode: sortDisplayMode,
}; };
window.localStorage.setItem('customSettings', JSON.stringify(next)); window.localStorage.setItem('customSettings', JSON.stringify(next));
// 更新后标记 customSettings 脏并触发云端同步 // 更新后标记 customSettings 脏并触发云端同步
@@ -275,7 +296,7 @@ export default function HomePage() {
// ignore // ignore
} }
} }
}, [sortBy, sortOrder, sortRules, isSortLoaded]); }, [sortBy, sortOrder, sortRules, sortDisplayMode, isSortLoaded]);
// 当用户关闭某个排序规则时,如果当前 sortBy 不再可用,则自动切换到第一个启用的规则 // 当用户关闭某个排序规则时,如果当前 sortBy 不再可用,则自动切换到第一个启用的规则
useEffect(() => { useEffect(() => {
@@ -2785,19 +2806,25 @@ export default function HomePage() {
await refreshAll(codes); await refreshAll(codes);
}; };
const saveSettings = (e, secondsOverride) => { const saveSettings = (e, secondsOverride, showMarketIndexOverride) => {
e?.preventDefault?.(); e?.preventDefault?.();
const seconds = secondsOverride ?? tempSeconds; const seconds = secondsOverride ?? tempSeconds;
const shouldShowMarketIndex = typeof showMarketIndexOverride === 'boolean' ? showMarketIndexOverride : showMarketIndex;
const ms = Math.max(30, Number(seconds)) * 1000; const ms = Math.max(30, Number(seconds)) * 1000;
setTempSeconds(Math.round(ms / 1000)); setTempSeconds(Math.round(ms / 1000));
setRefreshMs(ms); setRefreshMs(ms);
setShowMarketIndex(shouldShowMarketIndex);
storageHelper.setItem('refreshMs', String(ms)); storageHelper.setItem('refreshMs', String(ms));
const w = Math.min(2000, Math.max(600, Number(containerWidth) || 1200)); const w = Math.min(2000, Math.max(600, Number(containerWidth) || 1200));
setContainerWidth(w); setContainerWidth(w);
try { try {
const raw = window.localStorage.getItem('customSettings'); const raw = window.localStorage.getItem('customSettings');
const parsed = raw ? JSON.parse(raw) : {}; const parsed = raw ? JSON.parse(raw) : {};
window.localStorage.setItem('customSettings', JSON.stringify({ ...parsed, pcContainerWidth: w })); window.localStorage.setItem('customSettings', JSON.stringify({
...parsed,
pcContainerWidth: w,
showMarketIndex: shouldShowMarketIndex,
}));
triggerCustomSettingsSync(); triggerCustomSettingsSync();
} catch { } } catch { }
setSettingsOpen(false); setSettingsOpen(false);
@@ -3955,13 +3982,15 @@ export default function HomePage() {
</div> </div>
</div> </div>
</div> </div>
<MarketIndexAccordion {showMarketIndex && (
navbarHeight={navbarHeight} <MarketIndexAccordion
onHeightChange={setMarketIndexAccordionHeight} navbarHeight={navbarHeight}
isMobile={isMobile} onHeightChange={setMarketIndexAccordionHeight}
onCustomSettingsChange={triggerCustomSettingsSync} isMobile={isMobile}
refreshing={refreshing} onCustomSettingsChange={triggerCustomSettingsSync}
/> refreshing={refreshing}
/>
)}
<div className="grid"> <div className="grid">
<div className="col-12"> <div className="col-12">
<div ref={filterBarRef} className="filter-bar" style={{ top: navbarHeight + marketIndexAccordionHeight, marginTop: 0, marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}> <div ref={filterBarRef} className="filter-bar" style={{ top: navbarHeight + marketIndexAccordionHeight, marginTop: 0, marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
@@ -4085,40 +4114,81 @@ export default function HomePage() {
<span className="muted">排序</span> <span className="muted">排序</span>
<SettingsIcon width="14" height="14" /> <SettingsIcon width="14" height="14" />
</button> </button>
<div className="chips"> {sortDisplayMode === 'dropdown' ? (
{sortRules.filter((s) => s.enabled).map((s) => ( <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button <Select
key={s.id} value={sortBy}
className={`chip ${sortBy === s.id ? 'active' : ''}`} onValueChange={(nextSortBy) => {
onClick={() => { setSortBy(nextSortBy);
if (sortBy === s.id) { if (nextSortBy !== sortBy) setSortOrder('desc');
// 同一按钮重复点击,切换升序/降序
setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc'));
} else {
// 切换到新的排序字段,默认用降序
setSortBy(s.id);
setSortOrder('desc');
}
}} }}
style={{ height: '28px', fontSize: '12px', padding: '0 10px', display: 'flex', alignItems: 'center', gap: 4 }}
> >
<span>{s.alias || s.label}</span> <SelectTrigger
{s.id !== 'default' && sortBy === s.id && ( className="h-4 min-w-[110px] py-0 text-xs shadow-none"
<span style={{ background: 'var(--card-bg)', height: 36 }}
style={{ >
display: 'inline-flex', <SelectValue placeholder="选择排序规则" />
flexDirection: 'column', </SelectTrigger>
lineHeight: 1, <SelectContent>
fontSize: '8px', {sortRules.filter((s) => s.enabled).map((s) => (
}} <SelectItem key={s.id} value={s.id}>
> {s.alias || s.label}
<span style={{ opacity: sortOrder === 'asc' ? 1 : 0.3 }}></span> </SelectItem>
<span style={{ opacity: sortOrder === 'desc' ? 1 : 0.3 }}></span> ))}
</span> </SelectContent>
)} </Select>
</button> <Select
))} value={sortOrder}
</div> onValueChange={(value) => setSortOrder(value)}
>
<SelectTrigger
className="h-4 min-w-[84px] py-0 text-xs shadow-none"
style={{ background: 'var(--card-bg)', height: 36 }}
>
<SelectValue placeholder="排序方向" />
</SelectTrigger>
<SelectContent>
<SelectItem value="desc">降序</SelectItem>
<SelectItem value="asc">升序</SelectItem>
</SelectContent>
</Select>
</div>
) : (
<div className="chips">
{sortRules.filter((s) => s.enabled).map((s) => (
<button
key={s.id}
className={`chip ${sortBy === s.id ? 'active' : ''}`}
onClick={() => {
if (sortBy === s.id) {
// 同一按钮重复点击,切换升序/降序
setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc'));
} else {
// 切换到新的排序字段,默认用降序
setSortBy(s.id);
setSortOrder('desc');
}
}}
style={{ height: '28px', fontSize: '12px', padding: '0 10px', display: 'flex', alignItems: 'center', gap: 4 }}
>
<span>{s.alias || s.label}</span>
{s.id !== 'default' && sortBy === s.id && (
<span
style={{
display: 'inline-flex',
flexDirection: 'column',
lineHeight: 1,
fontSize: '8px',
}}
>
<span style={{ opacity: sortOrder === 'asc' ? 1 : 0.3 }}></span>
<span style={{ opacity: sortOrder === 'desc' ? 1 : 0.3 }}></span>
</span>
)}
</button>
))}
</div>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -4740,6 +4810,8 @@ export default function HomePage() {
containerWidth={containerWidth} containerWidth={containerWidth}
setContainerWidth={setContainerWidth} setContainerWidth={setContainerWidth}
onResetContainerWidth={handleResetContainerWidth} onResetContainerWidth={handleResetContainerWidth}
showMarketIndex={showMarketIndex}
setShowMarketIndex={setShowMarketIndex}
/> />
)} )}
@@ -4792,6 +4864,8 @@ export default function HomePage() {
isMobile={isMobile} isMobile={isMobile}
rules={sortRules} rules={sortRules}
onChangeRules={setSortRules} onChangeRules={setSortRules}
sortDisplayMode={sortDisplayMode}
onChangeSortDisplayMode={setSortDisplayMode}
onResetRules={() => setSortRules(DEFAULT_SORT_RULES)} onResetRules={() => setSortRules(DEFAULT_SORT_RULES)}
/> />

View File

@@ -0,0 +1,46 @@
"use client"
import * as React from "react"
import { CircleIcon } from "lucide-react"
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef(({ className, ...props }, ref) => (
<RadioGroupPrimitive.Root
ref={ref}
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
))
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef(({ className, ...props }, ref) => (
<RadioGroupPrimitive.Item
ref={ref}
data-slot="radio-group-item"
className={cn(
"group/radio aspect-square size-4 shrink-0 rounded-full border shadow-xs outline-none",
"border-[var(--border)] bg-[var(--input)] text-[var(--primary)]",
"transition-[color,box-shadow,border-color,background-color] duration-200 ease-out",
"hover:border-[var(--muted-foreground)]",
"data-[state=checked]:border-[var(--primary)] data-[state=checked]:bg-[var(--background)]",
"focus-visible:border-[var(--ring)] focus-visible:ring-[3px] focus-visible:ring-[var(--ring)] focus-visible:ring-opacity-50",
"disabled:cursor-not-allowed disabled:opacity-50",
"aria-invalid:border-[var(--destructive)] aria-invalid:ring-[3px] aria-invalid:ring-[var(--destructive)] aria-invalid:ring-opacity-20",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="size-2 fill-current text-[var(--primary)]" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
))
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }