Commit f27a8f3f authored by timel's avatar timel

feat: filter and order

parent 367ed2cb
...@@ -2,17 +2,19 @@ import { Database } from "sql.js"; ...@@ -2,17 +2,19 @@ import { Database } from "sql.js";
import { CardData, CardMeta, CardText } from "@/api"; import { CardData, CardMeta, CardText } from "@/api";
import { isNil } from "lodash-es";
import { constructCardMeta } from "."; import { constructCardMeta } from ".";
const TYPE_MONSTER = 0x1; const TYPE_MONSTER = 0x1;
/** 过滤条件 */
export interface FtsConditions { export interface FtsConditions {
// 过滤条件 types: number[]; // 卡片类型
types?: number[]; // 卡片类型 levels: number[]; // 星阶/刻度/link值
levels?: number[]; // 星阶/刻度/link值 races: number[]; // 种族
atk?: [number, number]; // 攻击力区间 attributes: number[]; // 属性
def?: [number, number]; // 防御力区间 atk: { min: number | null; max: number | null }; // 攻击力区间
races?: number[]; // 种族 def: { min: number | null; max: number | null }; // 防御力区间
attributes?: number[]; // 属性
} }
export interface FtsParams { export interface FtsParams {
query: string; // 用于全文检索的query query: string; // 用于全文检索的query
...@@ -61,10 +63,16 @@ function getFtsCondtions(conditions: FtsConditions): string { ...@@ -61,10 +63,16 @@ function getFtsCondtions(conditions: FtsConditions): string {
?.map((level) => `level = ${level}`) ?.map((level) => `level = ${level}`)
.join(" OR "); .join(" OR ");
const atkCondition = atk const atkCondition = atk
? `atk BETWEEN ${atk[0]} AND ${atk[1]} AND ${assertMonster}` ? `atk BETWEEN ${handleFinite(atk.min, "min")} AND ${handleFinite(
atk.max,
"max"
)} AND ${assertMonster}`
: undefined; : undefined;
const defCondition = def const defCondition = def
? `def BETWEEN ${def[0]} AND ${def[1]} AND ${assertMonster}` ? `def BETWEEN ${handleFinite(def.min, "min")} AND ${handleFinite(
def.max,
"max"
)} AND ${assertMonster}`
: undefined; : undefined;
const raceCondition = races?.map((race) => `race = ${race}`).join(" OR "); const raceCondition = races?.map((race) => `race = ${race}`).join(" OR ");
const attributeCondition = attributes const attributeCondition = attributes
...@@ -85,3 +93,8 @@ function getFtsCondtions(conditions: FtsConditions): string { ...@@ -85,3 +93,8 @@ function getFtsCondtions(conditions: FtsConditions): string {
return merged; return merged;
} }
function handleFinite(value: number | null, type: "min" | "max"): number {
if (isNil(value)) return type === "min" ? -2 : 9999999;
return value;
}
...@@ -7,7 +7,7 @@ import { ...@@ -7,7 +7,7 @@ import {
type SelectProps, type SelectProps,
Tooltip, Tooltip,
} from "antd"; } from "antd";
import { useState } from "react"; import { useEffect, useState } from "react";
import { fetchStrings, Region } from "@/api"; import { fetchStrings, Region } from "@/api";
import { import {
...@@ -17,18 +17,14 @@ import { ...@@ -17,18 +17,14 @@ import {
} from "@/common"; } from "@/common";
import { FtsConditions } from "@/middleware/sqlite/fts"; import { FtsConditions } from "@/middleware/sqlite/fts";
import styles from "./Filter.module.scss"; import { debounce } from "lodash-es";
const options: SelectProps["options"] = [];
const levels = Array.from({ length: 12 }).map((_, index) => index + 1); import styles from "./Filter.module.scss";
for (let i = 10; i < 36; i++) { const levels = Array.from({ length: 12 }, (_, index) => ({
options.push({ value: index + 1,
label: i.toString(36) + i, label: (index + 1).toString(),
value: i.toString(36) + i, }));
});
}
export const Filter: React.FC<{ export const Filter: React.FC<{
conditions: FtsConditions; conditions: FtsConditions;
...@@ -36,175 +32,89 @@ export const Filter: React.FC<{ ...@@ -36,175 +32,89 @@ export const Filter: React.FC<{
onCancel: () => void; onCancel: () => void;
}> = ({ conditions, onConfirm, onCancel }) => { }> = ({ conditions, onConfirm, onCancel }) => {
const [newConditions, setNewConditions] = useState<FtsConditions>(conditions); const [newConditions, setNewConditions] = useState<FtsConditions>(conditions);
const handleSelectChange =
<T extends keyof FtsConditions>(key: T) =>
(value: FtsConditions[T]) => {
setNewConditions((prev) => ({
...prev,
[key]: value,
}));
};
const genOptions = (map: Map<number, number>) =>
Array.from(map.entries()).map(([key, value]) => ({
value: key,
label: fetchStrings(Region.System, value),
}));
const T = [
[genOptions(Attribute2StringCodeMap), "属性", "attributes"],
[genOptions(Race2StringCodeMap), "种族", "races"],
[genOptions(Type2StringCodeMap), "类型", "types"],
[levels, "星级", "levels"],
] as const;
const handleInputNumberChange =
(attibute: "atk" | "def", index: "min" | "max") => (value: any) => {
setNewConditions((prev) => ({
...prev,
[attibute]: {
...prev[attibute],
[index]: value,
},
}));
};
return ( return (
<> <>
<div className={styles.title}>卡片筛选</div> <div className={styles.title}>卡片筛选</div>
<div className={styles.form}> <div className={styles.form}>
<div className={styles.item}> {T.map(([options, title, key]) => (
<div className={styles["item-name"]}>属性</div> <Item title={title} key={key}>
<Select <CustomSelect
mode="multiple" options={options}
allowClear defaultValue={conditions[key]}
style={{ width: "100%" }} onChange={handleSelectChange(key)}
placeholder="请选择" />
options={Array.from(Attribute2StringCodeMap.entries()).map( </Item>
([key, value]) => ({ ))}
value: key, <Item title="攻击" showTip>
label: fetchStrings(Region.System, value),
})
)}
defaultValue={conditions.attributes ?? []}
onChange={(values) => {
// @ts-ignore
setNewConditions((prev) => {
prev.attributes = values;
return prev;
});
}}
/>
</div>
<div className={styles.item}>
<div className={styles["item-name"]}>星级</div>
<Select
mode="multiple"
allowClear
style={{ width: "100%" }}
placeholder="请选择"
options={levels.map((level) => ({
value: level,
label: level.toString(),
}))}
defaultValue={conditions.levels ?? []}
onChange={(values) => {
// @ts-ignore
setNewConditions((prev) => {
prev.levels = values;
return prev;
});
}}
/>
</div>
<div className={styles.item}>
<div className={styles["item-name"]}>种族</div>
<Select
mode="multiple"
allowClear
style={{ width: "100%" }}
placeholder="请选择"
options={Array.from(Race2StringCodeMap.entries()).map(
([key, value]) => ({
value: key,
label: fetchStrings(Region.System, value),
})
)}
defaultValue={conditions.races ?? []}
onChange={(values) => {
// @ts-ignore
setNewConditions((prev) => {
prev.races = values;
return prev;
});
}}
/>
</div>
<div className={styles.item}>
<div className={styles["item-name"]}>类型</div>
<Select
mode="multiple"
allowClear
style={{ width: "100%" }}
placeholder="请选择"
options={Array.from(Type2StringCodeMap.entries()).map(
([key, value]) => ({
value: key,
label: fetchStrings(Region.System, value),
})
)}
defaultValue={conditions.types ?? []}
onChange={(values) => {
// @ts-ignore
setNewConditions((prev) => {
prev.types = values;
return prev;
});
}}
/>
</div>
<div className={styles.item}>
<div className={styles["item-name"]}>
攻击
<Tooltip title="输入-1即等同于输入“?”">
<InfoCircleFilled style={{ fontSize: 10 }} />
</Tooltip>
</div>
<div className={styles.number}> <div className={styles.number}>
<CustomInputNumber <CustomInputNumber
min={-1}
max={1000000}
step={100}
placeholder="最小值" placeholder="最小值"
defaultValue={conditions.atk?.[0]} onChange={handleInputNumberChange("atk", "min")}
onChange={(value) => { value={newConditions.atk.min}
setNewConditions((prev) => {
// TODO: 下面这些逻辑有时间可以去重一下
if (value === null) {
prev.atk = undefined;
} else {
if (prev.atk) {
prev.atk[0] = value;
} else {
prev.atk = [value, 9999];
}
}
return prev;
});
}}
/> />
<span className={styles.divider}>~</span> <span className={styles.divider}>~</span>
<CustomInputNumber <CustomInputNumber
min={-1}
max={1000000}
step={100}
placeholder="最大值" placeholder="最大值"
defaultValue={conditions.atk?.[1]} onChange={handleInputNumberChange("atk", "max")}
onChange={(value) => { value={newConditions.atk.max}
setNewConditions((prev) => {
if (value === null) {
prev.atk = undefined;
} else {
if (prev.atk) {
prev.atk[1] = value;
} else {
prev.atk = [0, value];
}
}
return prev;
});
}}
/> />
</div> </div>
</div> </Item>
<div className={styles.item}> <Item title="守备" showTip>
<div className={styles["item-name"]}>守备</div>
<div className={styles.number}> <div className={styles.number}>
<CustomInputNumber <CustomInputNumber
min={-1}
max={1000000}
step={100}
placeholder="最小值" placeholder="最小值"
onChange={handleInputNumberChange("def", "min")}
value={newConditions.def.min}
/> />
<span className={styles.divider}>~</span> <span className={styles.divider}>~</span>
<CustomInputNumber <CustomInputNumber
min={-1}
max={1000000}
step={100}
placeholder="最大值" placeholder="最大值"
onChange={handleInputNumberChange("def", "max")}
value={newConditions.def.max}
/> />
</div> </div>
</div> </Item>
</div> </div>
<div className={styles.btns}> <div className={styles.btns}>
<Button type="primary" onClick={() => onConfirm(newConditions)}> <Button
type="primary"
onClick={() => {
onConfirm(newConditions);
console.log(newConditions);
}}
>
确定 确定
</Button> </Button>
<Button type="text" onClick={onCancel}> <Button type="text" onClick={onCancel}>
...@@ -216,26 +126,54 @@ export const Filter: React.FC<{ ...@@ -216,26 +126,54 @@ export const Filter: React.FC<{
}; };
/** 只支持输入整数 */ /** 只支持输入整数 */
const CustomInputNumber = (props: InputNumberProps) => { const CustomInputNumber = (props: InputNumberProps) => (
const [value, setValue] = useState(props.defaultValue); <InputNumber
const onChange = (newValue: string | number | null) => { {...props}
if (Number.isInteger(newValue)) { formatter={(value) => (value !== undefined ? String(value) : "")}
setValue(newValue as number); parser={(value = "") => {
if (props.onChange) { const parsedValue = value.replace(/[^\d-]/g, ""); // 允许数字和负号
props.onChange(newValue); if (parsedValue === "-") return "-"; // 单独的负号允许通过
} return parsedValue;
} }}
}; min={-2}
max={1000000}
step={100}
/>
);
const CustomSelect: React.FC<{
options: {
label: string;
value: number;
}[];
defaultValue: number[];
onChange: (values: number[]) => void;
}> = ({ options, defaultValue, onChange }) => {
return ( return (
<InputNumber <Select
{...props} mode="multiple"
value={value} allowClear
formatter={(value) => (value !== undefined ? String(value) : "")} style={{ width: "100%" }}
parser={(value) => (value !== undefined ? value.replace(/\D/g, "") : "")} placeholder="请选择"
options={options}
defaultValue={defaultValue}
onChange={onChange} onChange={onChange}
min={-1}
max={1000000}
step={100}
/> />
); );
}; };
const Item: React.FC<
React.PropsWithChildren<{ title: string; showTip?: boolean }>
> = ({ title, children, showTip = false }) => (
<div className={styles.item}>
<div className={styles["item-name"]}>
{title}
{showTip && (
<Tooltip title="输入-2即等同于输入“?”">
<InfoCircleFilled style={{ fontSize: 10 }} />
</Tooltip>
)}
</div>
{children}
</div>
);
...@@ -206,7 +206,14 @@ const DeckEditor: React.FC<{ ...@@ -206,7 +206,14 @@ const DeckEditor: React.FC<{
const Search: React.FC = () => { const Search: React.FC = () => {
const { modal } = App.useApp(); const { modal } = App.useApp();
const [searchWord, setSearchWord] = useState(""); const [searchWord, setSearchWord] = useState("");
const [searchConditions, setSearchConditions] = useState<FtsConditions>({}); const [searchConditions, setSearchConditions] = useState<FtsConditions>({
atk: { min: null, max: null },
def: { min: null, max: null },
levels: [],
races: [],
attributes: [],
types: [],
});
const [searchResult, setSearchResult] = useState<CardMeta[]>([]); const [searchResult, setSearchResult] = useState<CardMeta[]>([]);
const sortRef = useRef<(a: CardMeta, b: CardMeta) => number>( const sortRef = useRef<(a: CardMeta, b: CardMeta) => number>(
...@@ -218,34 +225,26 @@ const Search: React.FC = () => { ...@@ -218,34 +225,26 @@ const Search: React.FC = () => {
setSearchResult([...searchResult.sort(sortRef.current)]); setSearchResult([...searchResult.sort(sortRef.current)]);
}; };
const genSort = (key: keyof CardMeta["data"], scale: 1 | -1 = 1) => {
return () =>
setSortRef(
(a: CardMeta, b: CardMeta) =>
((a.data?.[key] ?? 0) - (b.data?.[key] ?? 0)) * scale
);
};
const dropdownOptions: MenuProps["items"] = ( const dropdownOptions: MenuProps["items"] = (
[ [
["从新到旧", () => setSortRef((a, b) => b.id - a.id)], ["从新到旧", () => setSortRef((a, b) => b.id - a.id)],
["从旧到新", () => setSortRef((a, b) => a.id - b.id)], ["从旧到新", () => setSortRef((a, b) => a.id - b.id)],
[ ["攻击力从高到低", genSort("atk")],
"攻击力从高到低", ["攻击力从低到高", genSort("atk", -1)],
() => setSortRef((a, b) => (b.data?.atk ?? 0) - (a.data?.atk ?? 0)), ["守备力从高到低", genSort("def")],
], ["守备力从低到高", genSort("def", -1)],
[ ["星/阶/刻/Link从高到低", genSort("level")],
"攻击力从低到高", ["星/阶/刻/Link从低到高", genSort("level", -1)],
() => setSortRef((a, b) => (a.data?.atk ?? 0) - (b.data?.atk ?? 0)), ["灵摆刻度从高到低", genSort("lscale")],
], ["灵摆刻度从低到高", genSort("lscale", -1)],
[
"守备力从高到低",
() => setSortRef((a, b) => (b.data?.def ?? 0) - (a.data?.def ?? 0)),
],
[
"守备力从低到高",
() => setSortRef((a, b) => (a.data?.def ?? 0) - (b.data?.def ?? 0)),
],
[
"星/阶/刻/Link从高到低",
() => setSortRef((a, b) => (a.data?.level ?? 0) - (b.data?.level ?? 0)),
],
[
"星/阶/刻/Link从低到高",
() => setSortRef((a, b) => (b.data?.level ?? 0) - (a.data?.level ?? 0)),
],
] as const ] as const
).map(([label, onClick], key) => ({ key, label, onClick })); ).map(([label, onClick], key) => ({ key, label, onClick }));
...@@ -268,6 +267,28 @@ const Search: React.FC = () => { ...@@ -268,6 +267,28 @@ const Search: React.FC = () => {
} }
}, },
}); });
const showFilterModal = () => {
const { destroy } = modal.info({
width: 500,
centered: true,
title: null,
icon: null,
content: (
<Filter
conditions={searchConditions}
onConfirm={(newConditions) => {
setSearchConditions(newConditions);
destroy();
setTimeout(handleSearch, 50); // 先收起再搜索
}}
onCancel={() => destroy()}
/>
),
footer: null,
});
};
return ( return (
<div className={styles.container} ref={dropRef}> <div className={styles.container} ref={dropRef}>
<div className={styles.title}> <div className={styles.title}>
...@@ -292,25 +313,7 @@ const Search: React.FC = () => { ...@@ -292,25 +313,7 @@ const Search: React.FC = () => {
block block
type="text" type="text"
icon={<FilterOutlined />} icon={<FilterOutlined />}
onClick={() => { onClick={showFilterModal}
const { destroy } = modal.info({
width: 500,
centered: true,
title: null,
icon: null,
content: (
<Filter
conditions={searchConditions}
onConfirm={(newConditions) => {
setSearchConditions(newConditions);
destroy();
}}
onCancel={() => destroy()}
/>
),
footer: null,
});
}}
> >
筛选 筛选
{/* TODO: 下面这个Badge应根据有无筛选规则而显示 */} {/* TODO: 下面这个Badge应根据有无筛选规则而显示 */}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment