Commit f27a8f3f authored by timel's avatar timel

feat: filter and order

parent 367ed2cb
......@@ -2,17 +2,19 @@ import { Database } from "sql.js";
import { CardData, CardMeta, CardText } from "@/api";
import { isNil } from "lodash-es";
import { constructCardMeta } from ".";
const TYPE_MONSTER = 0x1;
/** 过滤条件 */
export interface FtsConditions {
// 过滤条件
types?: number[]; // 卡片类型
levels?: number[]; // 星阶/刻度/link值
atk?: [number, number]; // 攻击力区间
def?: [number, number]; // 防御力区间
races?: number[]; // 种族
attributes?: number[]; // 属性
types: number[]; // 卡片类型
levels: number[]; // 星阶/刻度/link值
races: number[]; // 种族
attributes: number[]; // 属性
atk: { min: number | null; max: number | null }; // 攻击力区间
def: { min: number | null; max: number | null }; // 防御力区间
}
export interface FtsParams {
query: string; // 用于全文检索的query
......@@ -61,10 +63,16 @@ function getFtsCondtions(conditions: FtsConditions): string {
?.map((level) => `level = ${level}`)
.join(" OR ");
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;
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;
const raceCondition = races?.map((race) => `race = ${race}`).join(" OR ");
const attributeCondition = attributes
......@@ -85,3 +93,8 @@ function getFtsCondtions(conditions: FtsConditions): string {
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 {
type SelectProps,
Tooltip,
} from "antd";
import { useState } from "react";
import { useEffect, useState } from "react";
import { fetchStrings, Region } from "@/api";
import {
......@@ -17,18 +17,14 @@ import {
} from "@/common";
import { FtsConditions } from "@/middleware/sqlite/fts";
import styles from "./Filter.module.scss";
const options: SelectProps["options"] = [];
import { debounce } from "lodash-es";
const levels = Array.from({ length: 12 }).map((_, index) => index + 1);
import styles from "./Filter.module.scss";
for (let i = 10; i < 36; i++) {
options.push({
label: i.toString(36) + i,
value: i.toString(36) + i,
});
}
const levels = Array.from({ length: 12 }, (_, index) => ({
value: index + 1,
label: (index + 1).toString(),
}));
export const Filter: React.FC<{
conditions: FtsConditions;
......@@ -36,175 +32,89 @@ export const Filter: React.FC<{
onCancel: () => void;
}> = ({ conditions, onConfirm, onCancel }) => {
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 (
<>
<div className={styles.title}>卡片筛选</div>
<div className={styles.form}>
<div className={styles.item}>
<div className={styles["item-name"]}>属性</div>
<Select
mode="multiple"
allowClear
style={{ width: "100%" }}
placeholder="请选择"
options={Array.from(Attribute2StringCodeMap.entries()).map(
([key, value]) => ({
value: key,
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>
{T.map(([options, title, key]) => (
<Item title={title} key={key}>
<CustomSelect
options={options}
defaultValue={conditions[key]}
onChange={handleSelectChange(key)}
/>
</Item>
))}
<Item title="攻击" showTip>
<div className={styles.number}>
<CustomInputNumber
min={-1}
max={1000000}
step={100}
placeholder="最小值"
defaultValue={conditions.atk?.[0]}
onChange={(value) => {
setNewConditions((prev) => {
// TODO: 下面这些逻辑有时间可以去重一下
if (value === null) {
prev.atk = undefined;
} else {
if (prev.atk) {
prev.atk[0] = value;
} else {
prev.atk = [value, 9999];
}
}
return prev;
});
}}
onChange={handleInputNumberChange("atk", "min")}
value={newConditions.atk.min}
/>
<span className={styles.divider}>~</span>
<CustomInputNumber
min={-1}
max={1000000}
step={100}
placeholder="最大值"
defaultValue={conditions.atk?.[1]}
onChange={(value) => {
setNewConditions((prev) => {
if (value === null) {
prev.atk = undefined;
} else {
if (prev.atk) {
prev.atk[1] = value;
} else {
prev.atk = [0, value];
}
}
return prev;
});
}}
onChange={handleInputNumberChange("atk", "max")}
value={newConditions.atk.max}
/>
</div>
</div>
<div className={styles.item}>
<div className={styles["item-name"]}>守备</div>
</Item>
<Item title="守备" showTip>
<div className={styles.number}>
<CustomInputNumber
min={-1}
max={1000000}
step={100}
placeholder="最小值"
onChange={handleInputNumberChange("def", "min")}
value={newConditions.def.min}
/>
<span className={styles.divider}>~</span>
<CustomInputNumber
min={-1}
max={1000000}
step={100}
placeholder="最大值"
onChange={handleInputNumberChange("def", "max")}
value={newConditions.def.max}
/>
</div>
</div>
</Item>
</div>
<div className={styles.btns}>
<Button type="primary" onClick={() => onConfirm(newConditions)}>
<Button
type="primary"
onClick={() => {
onConfirm(newConditions);
console.log(newConditions);
}}
>
确定
</Button>
<Button type="text" onClick={onCancel}>
......@@ -216,26 +126,54 @@ export const Filter: React.FC<{
};
/** 只支持输入整数 */
const CustomInputNumber = (props: InputNumberProps) => {
const [value, setValue] = useState(props.defaultValue);
const onChange = (newValue: string | number | null) => {
if (Number.isInteger(newValue)) {
setValue(newValue as number);
if (props.onChange) {
props.onChange(newValue);
}
}
};
const CustomInputNumber = (props: InputNumberProps) => (
<InputNumber
{...props}
formatter={(value) => (value !== undefined ? String(value) : "")}
parser={(value = "") => {
const parsedValue = value.replace(/[^\d-]/g, ""); // 允许数字和负号
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 (
<InputNumber
{...props}
value={value}
formatter={(value) => (value !== undefined ? String(value) : "")}
parser={(value) => (value !== undefined ? value.replace(/\D/g, "") : "")}
<Select
mode="multiple"
allowClear
style={{ width: "100%" }}
placeholder="请选择"
options={options}
defaultValue={defaultValue}
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<{
const Search: React.FC = () => {
const { modal } = App.useApp();
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 sortRef = useRef<(a: CardMeta, b: CardMeta) => number>(
......@@ -218,34 +225,26 @@ const Search: React.FC = () => {
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"] = (
[
["从新到旧", () => setSortRef((a, b) => b.id - a.id)],
["从旧到新", () => setSortRef((a, b) => a.id - b.id)],
[
"攻击力从高到低",
() => setSortRef((a, b) => (b.data?.atk ?? 0) - (a.data?.atk ?? 0)),
],
[
"攻击力从低到高",
() => setSortRef((a, b) => (a.data?.atk ?? 0) - (b.data?.atk ?? 0)),
],
[
"守备力从高到低",
() => 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)),
],
["攻击力从高到低", genSort("atk")],
["攻击力从低到高", genSort("atk", -1)],
["守备力从高到低", genSort("def")],
["守备力从低到高", genSort("def", -1)],
["星/阶/刻/Link从高到低", genSort("level")],
["星/阶/刻/Link从低到高", genSort("level", -1)],
["灵摆刻度从高到低", genSort("lscale")],
["灵摆刻度从低到高", genSort("lscale", -1)],
] as const
).map(([label, onClick], key) => ({ key, label, onClick }));
......@@ -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 (
<div className={styles.container} ref={dropRef}>
<div className={styles.title}>
......@@ -292,25 +313,7 @@ const Search: React.FC = () => {
block
type="text"
icon={<FilterOutlined />}
onClick={() => {
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,
});
}}
onClick={showFilterModal}
>
筛选
{/* 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