Skip to content

Commit 6616b62

Browse files
committed
feat: support ungrouped and exclude-group filters in account group filter
Replace the single-select group filter on the Accounts page with a tri-state dropdown: each group can be cycled between only/hide/off, plus quick options for all groups and ungrouped accounts. Also show the groups column in table view by default. Closes #229
1 parent 0b6585f commit 6616b62

4 files changed

Lines changed: 287 additions & 31 deletions

File tree

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { useEffect, useMemo, useRef, useState } from "react";
2+
import { Ban, Check, ChevronDown, Eye } from "lucide-react";
3+
import { useTranslation } from "react-i18next";
4+
import type { AccountGroup } from "../types";
5+
6+
const FALLBACK_GROUP_COLOR = "#2563eb";
7+
8+
function normalizeGroupColor(color?: string): string {
9+
const value = (color || "").trim();
10+
return /^#[0-9a-fA-F]{6}$/.test(value) ? value : FALLBACK_GROUP_COLOR;
11+
}
12+
13+
export interface AccountGroupFilterValue {
14+
ungrouped: boolean;
15+
include: number[];
16+
exclude: number[];
17+
}
18+
19+
export const EMPTY_ACCOUNT_GROUP_FILTER: AccountGroupFilterValue = {
20+
ungrouped: false,
21+
include: [],
22+
exclude: [],
23+
};
24+
25+
export function isAccountGroupFilterEmpty(
26+
value: AccountGroupFilterValue,
27+
): boolean {
28+
return (
29+
!value.ungrouped && value.include.length === 0 && value.exclude.length === 0
30+
);
31+
}
32+
33+
export function accountMatchesGroupFilter(
34+
groupIds: number[],
35+
filter: AccountGroupFilterValue,
36+
): boolean {
37+
if (filter.ungrouped) return groupIds.length === 0;
38+
if (
39+
filter.include.length > 0 &&
40+
!filter.include.some((id) => groupIds.includes(id))
41+
)
42+
return false;
43+
if (filter.exclude.some((id) => groupIds.includes(id))) return false;
44+
return true;
45+
}
46+
47+
// 分组被删除或列表刷新后,清理筛选中已不存在的分组 ID。
48+
export function pruneAccountGroupFilter(
49+
value: AccountGroupFilterValue,
50+
groups: AccountGroup[],
51+
): AccountGroupFilterValue {
52+
const valid = new Set(groups.map((group) => group.id));
53+
const include = value.include.filter((id) => valid.has(id));
54+
const exclude = value.exclude.filter((id) => valid.has(id));
55+
if (
56+
include.length === value.include.length &&
57+
exclude.length === value.exclude.length
58+
)
59+
return value;
60+
return { ...value, include, exclude };
61+
}
62+
63+
type GroupState = "off" | "include" | "exclude";
64+
65+
function groupStateOf(
66+
value: AccountGroupFilterValue,
67+
id: number,
68+
): GroupState {
69+
if (value.include.includes(id)) return "include";
70+
if (value.exclude.includes(id)) return "exclude";
71+
return "off";
72+
}
73+
74+
export interface AccountGroupFilterSelectProps {
75+
groups: AccountGroup[];
76+
value: AccountGroupFilterValue;
77+
onChange: (value: AccountGroupFilterValue) => void;
78+
className?: string;
79+
}
80+
81+
export default function AccountGroupFilterSelect({
82+
groups,
83+
value,
84+
onChange,
85+
className,
86+
}: AccountGroupFilterSelectProps) {
87+
const { t } = useTranslation();
88+
const [open, setOpen] = useState(false);
89+
const rootRef = useRef<HTMLDivElement>(null);
90+
91+
useEffect(() => {
92+
if (!open) return;
93+
94+
const handlePointerDown = (event: MouseEvent) => {
95+
if (!rootRef.current?.contains(event.target as Node)) {
96+
setOpen(false);
97+
}
98+
};
99+
100+
const handleEscape = (event: KeyboardEvent) => {
101+
if (event.key === "Escape") {
102+
setOpen(false);
103+
}
104+
};
105+
106+
document.addEventListener("mousedown", handlePointerDown);
107+
document.addEventListener("keydown", handleEscape);
108+
return () => {
109+
document.removeEventListener("mousedown", handlePointerDown);
110+
document.removeEventListener("keydown", handleEscape);
111+
};
112+
}, [open]);
113+
114+
const isEmpty = isAccountGroupFilterEmpty(value);
115+
116+
const summary = useMemo(() => {
117+
if (isEmpty) return t("accounts.groupsFilter");
118+
if (value.ungrouped) return t("accounts.groupFilterUngrouped");
119+
if (value.include.length === 1 && value.exclude.length === 0) {
120+
const group = groups.find((item) => item.id === value.include[0]);
121+
if (group) return group.name;
122+
}
123+
return t("accounts.groupFilterSummary", {
124+
count: value.include.length + value.exclude.length,
125+
});
126+
}, [groups, isEmpty, t, value]);
127+
128+
const cycleGroup = (id: number) => {
129+
const state = groupStateOf(value, id);
130+
const include = value.include.filter((item) => item !== id);
131+
const exclude = value.exclude.filter((item) => item !== id);
132+
if (state === "off") include.push(id);
133+
else if (state === "include") exclude.push(id);
134+
onChange({ ungrouped: false, include, exclude });
135+
};
136+
137+
return (
138+
<div ref={rootRef} className={`relative ${className ?? ""}`}>
139+
<button
140+
type="button"
141+
className={`flex h-8 w-full items-center justify-between gap-1.5 rounded-lg border border-input bg-background px-2.5 text-left text-[13px] shadow-xs transition-[border-color,box-shadow] hover:border-primary/30 hover:bg-accent/40 ${
142+
open ? "border-primary/35 ring-[3px] ring-primary/10" : ""
143+
} ${isEmpty ? "text-foreground" : "text-primary"}`}
144+
onClick={() => setOpen((current) => !current)}
145+
>
146+
<span className="truncate">{summary}</span>
147+
<ChevronDown
148+
className={`size-3.5 shrink-0 text-muted-foreground transition-transform ${open ? "rotate-180" : ""}`}
149+
/>
150+
</button>
151+
152+
{open ? (
153+
<div className="absolute left-0 top-[calc(100%+0.5rem)] z-50 w-64 overflow-hidden rounded-lg border border-border bg-popover shadow-[0_18px_40px_hsl(222_30%_18%/0.12)] backdrop-blur-sm">
154+
<div className="space-y-0.5 p-1.5">
155+
<button
156+
type="button"
157+
className={`flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-[13px] transition-colors ${
158+
isEmpty
159+
? "bg-primary/10 font-medium text-primary"
160+
: "text-foreground hover:bg-accent/70"
161+
}`}
162+
onClick={() => {
163+
onChange(EMPTY_ACCOUNT_GROUP_FILTER);
164+
setOpen(false);
165+
}}
166+
>
167+
<span className="flex size-3.5 shrink-0 items-center justify-center">
168+
{isEmpty ? <Check className="size-3.5" /> : null}
169+
</span>
170+
{t("accounts.groupsFilter")}
171+
</button>
172+
<button
173+
type="button"
174+
className={`flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-[13px] transition-colors ${
175+
value.ungrouped
176+
? "bg-primary/10 font-medium text-primary"
177+
: "text-foreground hover:bg-accent/70"
178+
}`}
179+
onClick={() => {
180+
onChange(
181+
value.ungrouped
182+
? EMPTY_ACCOUNT_GROUP_FILTER
183+
: { ungrouped: true, include: [], exclude: [] },
184+
);
185+
setOpen(false);
186+
}}
187+
>
188+
<span className="flex size-3.5 shrink-0 items-center justify-center">
189+
{value.ungrouped ? <Check className="size-3.5" /> : null}
190+
</span>
191+
{t("accounts.groupFilterUngrouped")}
192+
</button>
193+
</div>
194+
195+
{groups.length > 0 ? (
196+
<>
197+
<div className="border-t border-border" />
198+
<div className="max-h-64 space-y-0.5 overflow-auto p-1.5">
199+
{groups.map((group) => {
200+
const state = groupStateOf(value, group.id);
201+
const color = normalizeGroupColor(group.color);
202+
return (
203+
<button
204+
key={group.id}
205+
type="button"
206+
className={`flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-[13px] transition-colors ${
207+
state === "include"
208+
? "bg-primary/10 text-primary"
209+
: state === "exclude"
210+
? "bg-destructive/10 text-destructive"
211+
: "text-foreground hover:bg-accent/70"
212+
}`}
213+
onClick={() => cycleGroup(group.id)}
214+
>
215+
<span
216+
className="size-2.5 shrink-0 rounded-full"
217+
style={{ backgroundColor: color }}
218+
/>
219+
<span
220+
className={`min-w-0 flex-1 truncate font-medium ${
221+
state === "exclude" ? "line-through opacity-80" : ""
222+
}`}
223+
>
224+
{group.name}
225+
</span>
226+
{state === "include" ? (
227+
<span className="inline-flex shrink-0 items-center gap-1 text-[11px] font-semibold">
228+
<Eye className="size-3" />
229+
{t("accounts.groupFilterOnly")}
230+
</span>
231+
) : null}
232+
{state === "exclude" ? (
233+
<span className="inline-flex shrink-0 items-center gap-1 text-[11px] font-semibold">
234+
<Ban className="size-3" />
235+
{t("accounts.groupFilterHide")}
236+
</span>
237+
) : null}
238+
</button>
239+
);
240+
})}
241+
</div>
242+
<div className="border-t border-border px-3 py-1.5 text-[11px] text-muted-foreground">
243+
{t("accounts.groupFilterHint")}
244+
</div>
245+
</>
246+
) : null}
247+
</div>
248+
) : null}
249+
</div>
250+
);
251+
}

frontend/src/locales/en.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,11 @@
739739
"groupsSelected": "{{count}} groups selected",
740740
"groupsUnbound": "No groups assigned",
741741
"groupsFilter": "All groups",
742+
"groupFilterUngrouped": "Ungrouped",
743+
"groupFilterSummary": "Groups · {{count}}",
744+
"groupFilterOnly": "Only",
745+
"groupFilterHide": "Hide",
746+
"groupFilterHint": "Click a group to cycle: only → hide → off",
742747
"groupsNone": "No account groups",
743748
"groupManage": "Manage Groups",
744749
"groupManageTitle": "Account Group Management",

frontend/src/locales/zh.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,11 @@
739739
"groupsSelected": "已选择 {{count}} 个分组",
740740
"groupsUnbound": "未绑定分组",
741741
"groupsFilter": "全部分组",
742+
"groupFilterUngrouped": "未分组",
743+
"groupFilterSummary": "分组筛选 · {{count}}",
744+
"groupFilterOnly": "只看",
745+
"groupFilterHide": "屏蔽",
746+
"groupFilterHint": "点击分组可在「只看 → 屏蔽 → 取消」之间切换",
742747
"groupsNone": "暂无账号分组",
743748
"groupManage": "管理分组",
744749
"groupManageTitle": "账号分组管理",

frontend/src/pages/Accounts.tsx

Lines changed: 26 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ import Sub2APIImportModal from "../components/Sub2APIImportModal";
9393
import AccountQuotaDistributionChart from "../components/AccountQuotaDistributionChart";
9494
import AccountRateLimitRecoveryChart from "../components/AccountRateLimitRecoveryChart";
9595
import AccountGroupMultiSelect from "../components/AccountGroupMultiSelect";
96+
import AccountGroupFilterSelect, {
97+
EMPTY_ACCOUNT_GROUP_FILTER,
98+
accountMatchesGroupFilter,
99+
pruneAccountGroupFilter,
100+
type AccountGroupFilterValue,
101+
} from "../components/AccountGroupFilterSelect";
96102
import ChipInput from "../components/ChipInput";
97103

98104
const ACCOUNT_BATCH_CONCURRENCY = 6;
@@ -139,10 +145,7 @@ function getDefaultAccountVisibleColumns(): Record<
139145
boolean
140146
> {
141147
return Object.fromEntries(
142-
ACCOUNT_TABLE_COLUMNS.map((column) => [
143-
column,
144-
column !== "tags" && column !== "groups",
145-
]),
148+
ACCOUNT_TABLE_COLUMNS.map((column) => [column, column !== "tags"]),
146149
) as Record<AccountTableColumn, boolean>;
147150
}
148151

@@ -160,9 +163,7 @@ function getInitialAccountVisibleColumns(): Record<
160163
return Object.fromEntries(
161164
ACCOUNT_TABLE_COLUMNS.map((column) => [
162165
column,
163-
column === "tags" || column === "groups"
164-
? parsed[column] === true
165-
: parsed[column] !== false,
166+
column === "tags" ? parsed[column] === true : parsed[column] !== false,
166167
]),
167168
) as Record<AccountTableColumn, boolean>;
168169
} catch {
@@ -625,7 +626,9 @@ export default function Accounts() {
625626
const [editGroupIds, setEditGroupIds] = useState<number[]>([]);
626627
const [tagFilter, setTagFilter] = useState<string>("");
627628
const [domainFilter, setDomainFilter] = useState<string>("");
628-
const [groupFilter, setGroupFilter] = useState<number | null>(null);
629+
const [groupFilter, setGroupFilter] = useState<AccountGroupFilterValue>(
630+
EMPTY_ACCOUNT_GROUP_FILTER,
631+
);
629632
const [allGroups, setAllGroups] = useState<AccountGroup[]>([]);
630633
const [showGroupManager, setShowGroupManager] = useState(false);
631634
const [groupDraft, setGroupDraft] = useState<AccountGroupDraft>({
@@ -960,11 +963,8 @@ export default function Accounts() {
960963
}, [viewMode]);
961964

962965
useEffect(() => {
963-
if (groupFilter === null) return;
964-
if (!allGroups.some((group) => group.id === groupFilter)) {
965-
setGroupFilter(null);
966-
}
967-
}, [allGroups, groupFilter]);
966+
setGroupFilter((current) => pruneAccountGroupFilter(current, allGroups));
967+
}, [allGroups]);
968968

969969
useEffect(() => {
970970
const needsUsageReload = (account: AccountRow) => {
@@ -1180,10 +1180,7 @@ export default function Accounts() {
11801180
}
11811181
if (tagFilter && !(account.tags ?? []).includes(tagFilter)) return false;
11821182
if (domainFilter && getAccountEmailDomain(account) !== domainFilter) return false;
1183-
if (
1184-
groupFilter !== null &&
1185-
!(account.group_ids ?? []).includes(groupFilter)
1186-
)
1183+
if (!accountMatchesGroupFilter(account.group_ids ?? [], groupFilter))
11871184
return false;
11881185
return true;
11891186
});
@@ -2751,7 +2748,12 @@ export default function Accounts() {
27512748
showToast(t("accounts.groupDeleted"));
27522749
setEditGroupIds((current) => current.filter((id) => id !== group.id));
27532750
setBatchGroupIds((current) => current.filter((id) => id !== group.id));
2754-
if (groupFilter === group.id) setGroupFilter(null);
2751+
setGroupFilter((current) =>
2752+
pruneAccountGroupFilter(
2753+
current,
2754+
allGroups.filter((item) => item.id !== group.id),
2755+
),
2756+
);
27552757
if (groupDraft.id === group.id) resetGroupDraft();
27562758
await Promise.all([reload(), reloadGroups()]);
27572759
} catch (error) {
@@ -3227,21 +3229,14 @@ export default function Accounts() {
32273229
? t("accounts.hideEmailDomainTags")
32283230
: t("accounts.showEmailDomainTags")}
32293231
</Button>
3230-
<Select
3231-
className="w-36 shrink-0"
3232-
compact
3233-
value={groupFilter === null ? "all" : String(groupFilter)}
3234-
onValueChange={(value) => {
3235-
setGroupFilter(value === "all" ? null : Number(value));
3232+
<AccountGroupFilterSelect
3233+
className="w-40 shrink-0"
3234+
groups={allGroups}
3235+
value={groupFilter}
3236+
onChange={(value) => {
3237+
setGroupFilter(value);
32363238
setPage(1);
32373239
}}
3238-
options={[
3239-
{ value: "all", label: t("accounts.groupsFilter") },
3240-
...allGroups.map((group) => ({
3241-
value: String(group.id),
3242-
label: group.name,
3243-
})),
3244-
]}
32453240
/>
32463241
<Button
32473242
type="button"

0 commit comments

Comments
 (0)