feat(ui): implement profiles batch select and i18n (#4972)

* feat(ui): implement profiles batch select and i18n

* refactor: adjust button position and icon

* style: lint fmt
This commit is contained in:
Sline
2025-10-08 12:02:55 +08:00
committed by GitHub
parent 2bc720534d
commit 72aa56007c
7 changed files with 295 additions and 55 deletions

View File

@@ -18,6 +18,10 @@ import {
LocalFireDepartmentRounded,
RefreshRounded,
TextSnippetOutlined,
CheckBoxOutlineBlankRounded,
CheckBoxRounded,
IndeterminateCheckBoxRounded,
DeleteRounded,
} from "@mui/icons-material";
import { LoadingButton } from "@mui/lab";
import { Box, Button, Divider, Grid, IconButton, Stack } from "@mui/material";
@@ -104,6 +108,12 @@ const ProfilePage = () => {
const [activatings, setActivatings] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
// Batch selection states
const [batchMode, setBatchMode] = useState(false);
const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(
new Set(),
);
// 防止重复切换
const switchingProfileRef = useRef<string | null>(null);
@@ -648,6 +658,88 @@ const ProfilePage = () => {
if (text) setUrl(text);
};
// Batch selection functions
const toggleBatchMode = () => {
setBatchMode(!batchMode);
if (!batchMode) {
// Entering batch mode - clear previous selections
setSelectedProfiles(new Set());
}
};
const toggleProfileSelection = (uid: string) => {
setSelectedProfiles((prev) => {
const newSet = new Set(prev);
if (newSet.has(uid)) {
newSet.delete(uid);
} else {
newSet.add(uid);
}
return newSet;
});
};
const selectAllProfiles = () => {
setSelectedProfiles(new Set(profileItems.map((item) => item.uid)));
};
const clearAllSelections = () => {
setSelectedProfiles(new Set());
};
const isAllSelected = () => {
return (
profileItems.length > 0 && profileItems.length === selectedProfiles.size
);
};
const getSelectionState = () => {
if (selectedProfiles.size === 0) {
return "none"; // 无选择
} else if (selectedProfiles.size === profileItems.length) {
return "all"; // 全选
} else {
return "partial"; // 部分选择
}
};
const deleteSelectedProfiles = useLockFn(async () => {
if (selectedProfiles.size === 0) return;
try {
// Get all currently activating profiles
const currentActivating =
profiles.current && selectedProfiles.has(profiles.current)
? [profiles.current]
: [];
setActivatings((prev) => [...new Set([...prev, ...currentActivating])]);
// Delete all selected profiles
for (const uid of selectedProfiles) {
await deleteProfile(uid);
}
await mutateProfiles();
await mutateLogs();
// If any deleted profile was current, enhance profiles
if (currentActivating.length > 0) {
await onEnhance(false);
}
// Clear selections and exit batch mode
setSelectedProfiles(new Set());
setBatchMode(false);
showNotice("success", t("Selected profiles deleted successfully"));
} catch (err: any) {
showNotice("error", err?.message || err.toString());
} finally {
setActivatings([]);
}
});
const mode = useThemeMode();
const islight = mode === "light" ? true : false;
const dividercolor = islight
@@ -714,51 +806,102 @@ const ProfilePage = () => {
contentStyle={{ height: "100%" }}
header={
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<IconButton
size="small"
color="inherit"
title={t("Update All Profiles")}
onClick={onUpdateAll}
>
<RefreshRounded />
</IconButton>
{!batchMode ? (
<>
{/* Batch mode toggle button */}
<IconButton
size="small"
color="inherit"
title={t("Batch Operations")}
onClick={toggleBatchMode}
>
<CheckBoxOutlineBlankRounded />
</IconButton>
<IconButton
size="small"
color="inherit"
title={t("View Runtime Config")}
onClick={() => configRef.current?.open()}
>
<TextSnippetOutlined />
</IconButton>
<IconButton
size="small"
color="inherit"
title={t("Update All Profiles")}
onClick={onUpdateAll}
>
<RefreshRounded />
</IconButton>
<IconButton
size="small"
color="primary"
title={t("Reactivate Profiles")}
onClick={() => onEnhance(true)}
>
<LocalFireDepartmentRounded />
</IconButton>
<IconButton
size="small"
color="inherit"
title={t("View Runtime Config")}
onClick={() => configRef.current?.open()}
>
<TextSnippetOutlined />
</IconButton>
{/* 故障检测和紧急恢复按钮 */}
{(error || isStale) && (
<IconButton
size="small"
color="warning"
title="数据异常,点击强制刷新"
onClick={onEmergencyRefresh}
sx={{
animation: "pulse 2s infinite",
"@keyframes pulse": {
"0%": { opacity: 1 },
"50%": { opacity: 0.5 },
"100%": { opacity: 1 },
},
}}
>
<ClearRounded />
</IconButton>
<IconButton
size="small"
color="primary"
title={t("Reactivate Profiles")}
onClick={() => onEnhance(true)}
>
<LocalFireDepartmentRounded />
</IconButton>
{/* 故障检测和紧急恢复按钮 */}
{(error || isStale) && (
<IconButton
size="small"
color="warning"
title="数据异常,点击强制刷新"
onClick={onEmergencyRefresh}
sx={{
animation: "pulse 2s infinite",
"@keyframes pulse": {
"0%": { opacity: 1 },
"50%": { opacity: 0.5 },
"100%": { opacity: 1 },
},
}}
>
<ClearRounded />
</IconButton>
)}
</>
) : (
// Batch mode header
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<IconButton
size="small"
color="inherit"
title={isAllSelected() ? t("Deselect All") : t("Select All")}
onClick={
isAllSelected() ? clearAllSelections : selectAllProfiles
}
>
{getSelectionState() === "all" ? (
<CheckBoxRounded />
) : getSelectionState() === "partial" ? (
<IndeterminateCheckBoxRounded />
) : (
<CheckBoxOutlineBlankRounded />
)}
</IconButton>
<IconButton
size="small"
color="error"
title={t("Delete Selected Profiles")}
onClick={deleteSelectedProfiles}
disabled={selectedProfiles.size === 0}
>
<DeleteRounded />
</IconButton>
<Button size="small" variant="outlined" onClick={toggleBatchMode}>
{t("Done")}
</Button>
<Box
sx={{ flex: 1, textAlign: "right", color: "text.secondary" }}
>
{t("Selected")} {selectedProfiles.size} {t("items")}
</Box>
</Box>
)}
</Box>
}
@@ -861,7 +1004,16 @@ const ProfilePage = () => {
// Notice.success(t("Clash Core Restarted"), 1000);
}
}}
onDelete={() => onDelete(item.uid)}
onDelete={() => {
if (batchMode) {
toggleProfileSelection(item.uid);
} else {
onDelete(item.uid);
}
}}
batchMode={batchMode}
isSelected={selectedProfiles.has(item.uid)}
onSelectionChange={() => toggleProfileSelection(item.uid)}
/>
</Grid>
))}