优化移动端筛选为可折叠样式,并修复标签 key 重复

筛选默认收起、展开后单行横滑;chip 使用唯一 key 消除 React 警告。
This commit is contained in:
yml2213
2026-07-16 23:50:10 +08:00
parent f856473d0d
commit b36ec48edd
3 changed files with 443 additions and 147 deletions
+207 -130
View File
@@ -1,6 +1,7 @@
import CopyOutlined from "@ant-design/icons/es/icons/CopyOutlined";
import CustomerServiceOutlined from "@ant-design/icons/es/icons/CustomerServiceOutlined";
import DownOutlined from "@ant-design/icons/es/icons/DownOutlined";
import FilterOutlined from "@ant-design/icons/es/icons/FilterOutlined";
import ReloadOutlined from "@ant-design/icons/es/icons/ReloadOutlined";
import SearchOutlined from "@ant-design/icons/es/icons/SearchOutlined";
import Alert from "antd/es/alert";
@@ -160,6 +161,7 @@ function Showcase() {
const [filters, setFilters] = useState<Filters>(cloneFilters(emptyFilters));
const [appliedFilters, setAppliedFilters] = useState<Filters>(cloneFilters(emptyFilters));
const [activeSort, setActiveSort] = useState("recommended");
const [filterOpen, setFilterOpen] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [copiedCode, setCopiedCode] = useState("");
const [copiedInfoCode, setCopiedInfoCode] = useState("");
@@ -196,6 +198,8 @@ function Showcase() {
}, [publishOptions.skin_groups]);
const noticeText = announcements[0]?.trim() || "";
const activeFilterCount = useMemo(() => countActiveFilters(filters), [filters]);
const appliedFilterCount = useMemo(() => countActiveFilters(appliedFilters), [appliedFilters]);
const displayListings = useMemo(() => {
const items = [...listings];
@@ -262,6 +266,7 @@ function Showcase() {
function applyFilters(nextFilters = filters) {
setAppliedFilters(cloneFilters(nextFilters));
setPage(1);
setFilterOpen(false);
}
function resetFilters() {
@@ -270,6 +275,8 @@ function Showcase() {
setAppliedFilters(cloneFilters(nextFilters));
setActiveSort("recommended");
setPage(1);
setFilterOpen(false);
setAdvancedOpen(false);
}
function selectRange(minKey: "minCoin" | "minPrice", maxKey: "maxCoin" | "maxPrice", min: string, max: string) {
@@ -381,141 +388,173 @@ function Showcase() {
</div>
) : null}
<section className="filter-board">
<div className="section-title">
<i />
</div>
<div className="filter-grid">
<FilterRow
active={(item) => filters.minCoin === item.min && filters.maxCoin === item.max}
label="哈夫币(M)"
options={coinRanges}
render={(item) => item.label}
onSelect={(item) => selectRange("minCoin", "maxCoin", item.min, item.max)}
/>
<FilterRow
active={(item) => filters.minPrice === item.min && filters.maxPrice === item.max}
label="租金"
options={priceRanges}
render={(item) => item.label}
onSelect={(item) => selectRange("minPrice", "maxPrice", item.min, item.max)}
/>
<FilterRow
active={(item) => filters.insurance.includes(item)}
label="保险"
options={insuranceOptions}
render={insuranceLabel}
onSelect={(item) => toggleFilterList("insurance", item)}
/>
<FilterRow
active={(item) => filters.stamina.includes(item)}
label="体力"
options={levelOptions}
render={(item) => item}
onSelect={(item) => toggleFilterList("stamina", item)}
/>
<FilterRow
active={(item) => filters.load.includes(item)}
label="负重"
options={levelOptions}
render={(item) => item}
onSelect={(item) => toggleFilterList("load", item)}
/>
<div className="filter-row compact-select">
<span>IP地址</span>
<Select
allowClear
mode="multiple"
maxTagCount="responsive"
options={zoneOptions.map((item) => ({ label: item, value: item }))}
placeholder="全部"
value={filters.zones}
onChange={(value) => updateFilter("zones", value)}
/>
<section className={filterOpen ? "filter-board is-open" : "filter-board"}>
<button
type="button"
className="filter-toggle"
aria-expanded={filterOpen}
onClick={() => setFilterOpen((open) => !open)}
>
<div className="section-title">
<i />
<FilterOutlined className="filter-title-icon" />
{appliedFilterCount > 0 ? <em className="filter-count">{appliedFilterCount}</em> : null}
</div>
<div className="filter-row compact-select">
<span></span>
<Select
allowClear
mode="multiple"
maxTagCount="responsive"
options={loginOptions.map((item) => ({ label: item, value: item }))}
placeholder="不限"
value={filters.loginMethods}
onChange={(value) => updateFilter("loginMethods", value)}
/>
</div>
<div className="filter-row compact-select">
<span></span>
<Select
allowClear
mode="multiple"
maxTagCount="responsive"
options={rankOptions.map((item) => ({ label: item, value: item }))}
placeholder="不限"
value={filters.ranks}
onChange={(value) => updateFilter("ranks", value)}
/>
</div>
</div>
<span className="filter-toggle-meta">
{appliedFilterCount > 0 ? `${appliedFilterCount} 项已选` : filterOpen ? "收起" : "展开"}
<DownOutlined rotate={filterOpen ? 180 : 0} />
</span>
</button>
<div className={advancedOpen ? "advanced-panel open" : "advanced-panel"}>
<div className="range-fields">
<label>
<span></span>
<div className="range-inputs">
<Input
inputMode="numeric"
placeholder="最小"
value={filters.minCoin}
onChange={(event) => updateFilter("minCoin", event.target.value)}
/>
<em></em>
<Input
inputMode="numeric"
placeholder="最大"
value={filters.maxCoin}
onChange={(event) => updateFilter("maxCoin", event.target.value)}
/>
</div>
</label>
<label>
<span>KD范围</span>
<div className="range-inputs">
<Input
inputMode="decimal"
placeholder="最小"
value={filters.minKd}
onChange={(event) => updateFilter("minKd", event.target.value)}
/>
<em></em>
<Input
inputMode="decimal"
placeholder="最大"
value={filters.maxKd}
onChange={(event) => updateFilter("maxKd", event.target.value)}
/>
</div>
</label>
{appliedFilterCount > 0 && !filterOpen ? (
<div className="filter-summary" aria-label="已选筛选">
{buildFilterSummary(appliedFilters, insuranceLabel).map((item) => (
<span key={item} className="filter-summary-chip">
{item}
</span>
))}
</div>
{skinSections.map((section) => (
) : null}
<div className="filter-body">
<div className="filter-grid">
<FilterRow
key={section.key}
active={(item) => filters.skinNames.includes(item)}
label={section.title}
options={section.options.slice(0, 18)}
render={(item) => item}
onSelect={(item) => toggleFilterList("skinNames", item)}
active={(item) => filters.minCoin === item.min && filters.maxCoin === item.max}
label="哈夫币"
options={coinRanges}
render={(item) => item.label}
onSelect={(item) => selectRange("minCoin", "maxCoin", item.min, item.max)}
/>
))}
</div>
<FilterRow
active={(item) => filters.minPrice === item.min && filters.maxPrice === item.max}
label="租金"
options={priceRanges}
render={(item) => item.label}
onSelect={(item) => selectRange("minPrice", "maxPrice", item.min, item.max)}
/>
<FilterRow
active={(item) => filters.insurance.includes(item)}
label="保险"
options={insuranceOptions}
render={insuranceLabel}
onSelect={(item) => toggleFilterList("insurance", item)}
/>
<FilterRow
active={(item) => filters.stamina.includes(item)}
label="体力"
options={levelOptions}
render={(item) => item}
onSelect={(item) => toggleFilterList("stamina", item)}
/>
<FilterRow
active={(item) => filters.load.includes(item)}
label="负重"
options={levelOptions}
render={(item) => item}
onSelect={(item) => toggleFilterList("load", item)}
/>
<div className="filter-row compact-select">
<span className="filter-label">IP地址</span>
<Select
allowClear
mode="multiple"
maxTagCount="responsive"
options={zoneOptions.map((item) => ({ label: item, value: item }))}
placeholder="全部"
value={filters.zones}
onChange={(value) => updateFilter("zones", value)}
/>
</div>
<div className="filter-row compact-select">
<span className="filter-label"></span>
<Select
allowClear
mode="multiple"
maxTagCount="responsive"
options={loginOptions.map((item) => ({ label: item, value: item }))}
placeholder="不限"
value={filters.loginMethods}
onChange={(value) => updateFilter("loginMethods", value)}
/>
</div>
<div className="filter-row compact-select">
<span className="filter-label"></span>
<Select
allowClear
mode="multiple"
maxTagCount="responsive"
options={rankOptions.map((item) => ({ label: item, value: item }))}
placeholder="不限"
value={filters.ranks}
onChange={(value) => updateFilter("ranks", value)}
/>
</div>
</div>
<div className="filter-footer">
<button className="advanced-toggle" type="button" onClick={() => setAdvancedOpen((open) => !open)}>
{advancedOpen ? "收起高级筛选" : "展开高级筛选"}
<DownOutlined rotate={advancedOpen ? 180 : 0} />
</button>
<div className={advancedOpen ? "advanced-panel open" : "advanced-panel"}>
<div className="range-fields">
<label>
<span></span>
<div className="range-inputs">
<Input
inputMode="numeric"
placeholder="最小"
value={filters.minCoin}
onChange={(event) => updateFilter("minCoin", event.target.value)}
/>
<em></em>
<Input
inputMode="numeric"
placeholder="最大"
value={filters.maxCoin}
onChange={(event) => updateFilter("maxCoin", event.target.value)}
/>
</div>
</label>
<label>
<span>KD范围</span>
<div className="range-inputs">
<Input
inputMode="decimal"
placeholder="最小"
value={filters.minKd}
onChange={(event) => updateFilter("minKd", event.target.value)}
/>
<em></em>
<Input
inputMode="decimal"
placeholder="最大"
value={filters.maxKd}
onChange={(event) => updateFilter("maxKd", event.target.value)}
/>
</div>
</label>
</div>
{skinSections.map((section) => (
<FilterRow
key={section.key}
active={(item) => filters.skinNames.includes(item)}
label={section.title}
options={section.options.slice(0, 18)}
render={(item) => item}
onSelect={(item) => toggleFilterList("skinNames", item)}
/>
))}
</div>
<div className="filter-footer">
<button className="advanced-toggle" type="button" onClick={() => setAdvancedOpen((open) => !open)}>
{advancedOpen ? "收起高级筛选" : "展开高级筛选"}
<DownOutlined rotate={advancedOpen ? 180 : 0} />
</button>
<div className="filter-mobile-actions">
<Button onClick={resetFilters}></Button>
<Button type="primary" icon={<SearchOutlined />} onClick={() => applyFilters()}>
{activeFilterCount > 0 ? ` (${activeFilterCount})` : ""}
</Button>
</div>
</div>
</div>
</section>
@@ -706,7 +745,7 @@ function ListingRow({ codeCopied, infoCopied, item, onCopyCode, onCopyInfo }: Li
{getMainChips(item)
.slice(0, 5)
.map((chip) => (
<Tag key={`${code}-${chip.label}`} className={`mini-tag tag-${chip.tone}`}>
<Tag key={`${code}-${chip.key}`} className={`mini-tag tag-${chip.tone}`}>
{chip.label}
</Tag>
))}
@@ -872,6 +911,44 @@ function insuranceLabel(value: string) {
return value;
}
function countActiveFilters(filters: Filters) {
let count = 0;
if (filters.keyword.trim()) count += 1;
if (filters.minCoin || filters.maxCoin) count += 1;
if (filters.minPrice || filters.maxPrice) count += 1;
if (filters.minKd || filters.maxKd) count += 1;
count += filters.stamina.length;
count += filters.load.length;
count += filters.insurance.length;
count += filters.skinNames.length;
count += filters.loginMethods.length;
count += filters.ranks.length;
count += filters.zones.length;
return count;
}
function buildFilterSummary(filters: Filters, formatInsurance: (value: string) => string) {
const items: string[] = [];
if (filters.minCoin || filters.maxCoin) {
items.push(`哈夫币 ${filters.minCoin || "0"}-${filters.maxCoin || "∞"}`);
}
if (filters.minPrice || filters.maxPrice) {
items.push(`租金 ${filters.minPrice || "0"}-${filters.maxPrice || "∞"}`);
}
for (const value of filters.insurance) items.push(`保险 ${formatInsurance(value)}`);
for (const value of filters.stamina) items.push(`体力 ${value}`);
for (const value of filters.load) items.push(`负重 ${value}`);
for (const value of filters.ranks) items.push(value);
for (const value of filters.loginMethods) items.push(value);
for (const value of filters.zones) items.push(value);
for (const value of filters.skinNames) items.push(value);
if (filters.minKd || filters.maxKd) {
items.push(`KD ${filters.minKd || "0"}-${filters.maxKd || "∞"}`);
}
if (filters.keyword.trim()) items.push(`编号 ${filters.keyword.trim()}`);
return items.slice(0, 6);
}
function getAssetText(item: Listing, key: string) {
const value = item.asset_summary?.[key];
return typeof value === "string" ? value : "";
+14 -10
View File
@@ -200,16 +200,20 @@ export function getOnlineTimeText(item: Listing) {
export function getMainChips(item: Listing) {
return [
{ label: readAssetString(item, "stamina_level") || "体力--", tone: "green" },
{ label: readAssetString(item, "load_level") || "负重--", tone: "green" },
{ label: readAssetString(item, "season_insurance") || "保险--", tone: "red" },
{ label: item.rank_level || "段位--", tone: "red" },
{ label: `${getResourceQuantity(item, "armor6")}`, tone: "red" },
{ label: `${getResourceQuantity(item, "helmet6")}`, tone: "red" },
{ label: `AWM子弹:${getResourceQuantity(item, "awmAmmo")}`, tone: "red" },
{ label: `KD:${readAssetNumber(item, "secret_kd") || "--"}`, tone: "red" },
...getRegions(item).slice(0, 2).map((region) => ({ label: region, tone: "purple" })),
...getSkinNames(item).slice(0, 2).map((skin) => ({ label: skin, tone: "gold" })),
{ key: "stamina", label: readAssetString(item, "stamina_level") || "体力--", tone: "green" },
{ key: "load", label: readAssetString(item, "load_level") || "负重--", tone: "green" },
{ key: "insurance", label: readAssetString(item, "season_insurance") || "保险--", tone: "red" },
{ key: "rank", label: item.rank_level || "段位--", tone: "red" },
{ key: "armor6", label: `${getResourceQuantity(item, "armor6")}`, tone: "red" },
{ key: "helmet6", label: `${getResourceQuantity(item, "helmet6")}`, tone: "red" },
{ key: "awm", label: `AWM子弹:${getResourceQuantity(item, "awmAmmo")}`, tone: "red" },
{ key: "kd", label: `KD:${readAssetNumber(item, "secret_kd") || "--"}`, tone: "red" },
...getRegions(item)
.slice(0, 2)
.map((region, index) => ({ key: `region-${index}`, label: region, tone: "purple" })),
...getSkinNames(item)
.slice(0, 2)
.map((skin, index) => ({ key: `skin-${index}`, label: skin, tone: "gold" })),
].filter((chip) => chip.label && !chip.label.includes(":0发") && chip.label !== "0甲" && chip.label !== "0头");
}
+222 -7
View File
@@ -317,6 +317,69 @@ input {
padding: 18px 22px 12px;
}
.filter-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
margin: 0 0 14px;
padding: 0;
border: none;
background: transparent;
color: inherit;
text-align: left;
cursor: default;
}
.filter-toggle .section-title {
margin-bottom: 0;
}
.filter-title-icon {
display: none;
color: var(--brand);
font-size: 14px;
}
.filter-count {
min-width: 18px;
height: 18px;
padding: 0 6px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
color: #fff;
background: var(--brand);
font-size: 11px;
font-style: normal;
font-weight: 900;
line-height: 1;
}
.filter-toggle-meta {
display: none;
align-items: center;
gap: 6px;
color: #667085;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.filter-toggle-meta .anticon {
font-size: 11px;
}
.filter-summary {
display: none;
}
.filter-mobile-actions {
display: none;
}
.section-title {
display: flex;
align-items: center;
@@ -474,6 +537,8 @@ input {
justify-content: center;
min-height: 44px;
padding-top: 4px;
flex-wrap: wrap;
gap: 10px;
}
.advanced-toggle {
@@ -1155,8 +1220,78 @@ input {
}
.filter-board {
padding: 14px 14px 10px;
padding: 0;
border-radius: 12px;
overflow: hidden;
}
.filter-toggle {
margin: 0;
padding: 12px 14px;
cursor: pointer;
transition: background 0.15s ease;
}
.filter-toggle:active {
background: #fafbfc;
}
.filter-board.is-open .filter-toggle {
border-bottom: 1px solid #eef2f6;
}
.filter-title-icon {
display: inline-flex;
}
.filter-toggle .section-title i {
display: none;
}
.filter-toggle-meta {
display: inline-flex;
}
.filter-summary {
display: flex;
flex-wrap: nowrap;
gap: 6px;
padding: 0 14px 12px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.filter-summary::-webkit-scrollbar {
display: none;
}
.filter-summary-chip {
flex: none;
height: 26px;
padding: 0 10px;
display: inline-flex;
align-items: center;
border-radius: 999px;
color: var(--brand-dark);
background: var(--brand-soft);
border: 1px solid var(--brand-line);
font-size: 11px;
font-weight: 800;
white-space: nowrap;
}
.filter-body {
display: none;
padding: 12px 14px 14px;
}
.filter-board.is-open .filter-body {
display: block;
}
.filter-board.is-open .filter-summary {
display: none;
}
.result-area {
@@ -1166,24 +1301,104 @@ input {
}
.section-title {
margin-bottom: 10px;
margin-bottom: 0;
font-size: 14px;
}
.filter-grid {
grid-template-columns: 1fr;
gap: 10px;
}
.filter-row,
.range-fields > label {
grid-template-columns: 1fr;
gap: 7px;
grid-template-columns: 44px minmax(0, 1fr);
align-items: center;
gap: 8px;
}
.filter-label,
.range-fields > label > span,
.filter-row > span.filter-label,
.compact-select > .filter-label {
min-height: 28px;
font-size: 12px;
color: #667085;
}
.chip-list {
flex-wrap: nowrap;
gap: 6px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding-bottom: 1px;
mask-image: linear-gradient(90deg, #000 0%, #000 calc(100% - 18px), transparent 100%);
}
.chip-list::-webkit-scrollbar {
display: none;
}
.chip-list button {
min-height: 32px;
height: 32px;
padding: 0 11px;
flex: none;
min-width: 0;
min-height: 28px;
height: 28px;
padding: 0 10px;
font-size: 12px;
}
.compact-select .ant-select {
width: 100%;
}
.compact-select .ant-select-selector {
min-height: 32px !important;
}
.advanced-panel {
margin-top: 12px;
padding-top: 12px;
}
.advanced-panel.open {
gap: 10px;
}
.range-fields {
grid-template-columns: 1fr;
gap: 10px;
}
.filter-footer {
flex-direction: column;
align-items: stretch;
min-height: 0;
margin-top: 4px;
padding-top: 10px;
gap: 10px;
}
.filter-mobile-actions {
display: grid;
grid-template-columns: 1fr 1.6fr;
gap: 8px;
}
.filter-mobile-actions .ant-btn {
height: 40px;
border-radius: 10px;
font-weight: 800;
}
.filter-mobile-actions .ant-btn-primary {
box-shadow: 0 6px 14px rgba(255, 107, 0, 0.18);
}
.advanced-toggle {
justify-content: center;
width: 100%;
}
.sort-segment {