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

@@ -1,6 +1,11 @@
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { RefreshRounded, DragIndicatorRounded } from "@mui/icons-material"; import {
RefreshRounded,
DragIndicatorRounded,
CheckBoxRounded,
CheckBoxOutlineBlankRounded,
} from "@mui/icons-material";
import { import {
Box, Box,
Typography, Typography,
@@ -49,11 +54,24 @@ interface Props {
onEdit: () => void; onEdit: () => void;
onSave?: (prev?: string, curr?: string) => void; onSave?: (prev?: string, curr?: string) => void;
onDelete: () => void; onDelete: () => void;
batchMode?: boolean;
isSelected?: boolean;
onSelectionChange?: () => void;
} }
export const ProfileItem = (props: Props) => { export const ProfileItem = (props: Props) => {
const { selected, activating, itemData, onSelect, onEdit, onSave, onDelete } = const {
props; selected,
activating,
itemData,
onSelect,
onEdit,
onSave,
onDelete,
batchMode,
isSelected,
onSelectionChange,
} = props;
const { const {
attributes, attributes,
listeners, listeners,
@@ -363,7 +381,12 @@ export const ProfileItem = (props: Props) => {
label: "Delete", label: "Delete",
handler: () => { handler: () => {
setAnchorEl(null); setAnchorEl(null);
setConfirmOpen(true); if (batchMode) {
// If in batch mode, just toggle selection instead of showing delete confirmation
onSelectionChange && onSelectionChange();
} else {
setConfirmOpen(true);
}
}, },
disabled: false, disabled: false,
}, },
@@ -402,7 +425,12 @@ export const ProfileItem = (props: Props) => {
label: "Delete", label: "Delete",
handler: () => { handler: () => {
setAnchorEl(null); setAnchorEl(null);
setConfirmOpen(true); if (batchMode) {
// If in batch mode, just toggle selection instead of showing delete confirmation
onSelectionChange && onSelectionChange();
} else {
setConfirmOpen(true);
}
}, },
disabled: false, disabled: false,
}, },
@@ -510,9 +538,29 @@ export const ProfileItem = (props: Props) => {
)} )}
<Box position="relative"> <Box position="relative">
<Box sx={{ display: "flex", justifyContent: "start" }}> <Box sx={{ display: "flex", justifyContent: "start" }}>
{batchMode && (
<IconButton
size="small"
sx={{ padding: "2px", marginRight: "4px", marginLeft: "-8px" }}
onClick={(e) => {
e.stopPropagation();
onSelectionChange && onSelectionChange();
}}
>
{isSelected ? (
<CheckBoxRounded color="primary" />
) : (
<CheckBoxOutlineBlankRounded />
)}
</IconButton>
)}
<Box <Box
ref={setNodeRef} ref={setNodeRef}
sx={{ display: "flex", margin: "auto 0" }} sx={{
display: "flex",
margin: "auto 0",
...(batchMode && { marginLeft: "-4px" }),
}}
{...attributes} {...attributes}
{...listeners} {...listeners}
> >
@@ -527,7 +575,7 @@ export const ProfileItem = (props: Props) => {
</Box> </Box>
<Typography <Typography
width="calc(100% - 36px)" width={batchMode ? "calc(100% - 56px)" : "calc(100% - 36px)"}
sx={{ fontSize: "18px", fontWeight: "600", lineHeight: "26px" }} sx={{ fontSize: "18px", fontWeight: "600", lineHeight: "26px" }}
variant="h6" variant="h6"
component="h2" component="h2"

View File

@@ -682,5 +682,13 @@
"Secret copied to clipboard": "Secret copied to clipboard", "Secret copied to clipboard": "Secret copied to clipboard",
"Saving...": "Saving...", "Saving...": "Saving...",
"Proxy node already exists in chain": "Proxy node already exists in chain", "Proxy node already exists in chain": "Proxy node already exists in chain",
"Detection timeout or failed": "Detection timeout or failed" "Detection timeout or failed": "Detection timeout or failed",
"Batch Operations": "Batch Operations",
"Delete Selected Profiles": "Delete Selected Profiles",
"Deselect All": "Deselect All",
"Done": "Done",
"items": "items",
"Select All": "Select All",
"Selected": "Selected",
"Selected profiles deleted successfully": "Selected profiles deleted successfully"
} }

View File

@@ -561,5 +561,13 @@
"Copy to clipboard": "クリックしてコピー", "Copy to clipboard": "クリックしてコピー",
"Port Config": "ポート設定", "Port Config": "ポート設定",
"Configuration saved successfully": "ランダム設定を保存完了", "Configuration saved successfully": "ランダム設定を保存完了",
"Enable one-click random API port and key. Click to randomize the port and key": "ワンクリックでランダム API ポートとキーを有効化。ポートとキーをランダム化するにはクリックしてください" "Enable one-click random API port and key. Click to randomize the port and key": "ワンクリックでランダム API ポートとキーを有効化。ポートとキーをランダム化するにはクリックしてください",
"Batch Operations": "バッチ操作",
"Delete Selected Profiles": "選択したプロファイルを削除",
"Deselect All": "すべての選択を解除",
"Done": "完了",
"items": "アイテム",
"Select All": "すべて選択",
"Selected": "選択済み",
"Selected profiles deleted successfully": "選択したプロファイルが正常に削除されました"
} }

View File

@@ -589,5 +589,13 @@
"No (IP Banned By Disney+)": "Нет (IP забанен Disney+)", "No (IP Banned By Disney+)": "Нет (IP забанен Disney+)",
"Unsupported Country/Region": "Страна/регион не поддерживается", "Unsupported Country/Region": "Страна/регион не поддерживается",
"Failed (Network Connection)": "Ошибка подключения", "Failed (Network Connection)": "Ошибка подключения",
"Invalid Profile URL": "Недопустимая ссылка на профиль, введите адрес, начинающийся с http:// или https://" "Invalid Profile URL": "Недопустимая ссылка на профиль, введите адрес, начинающийся с http:// или https://",
"Batch Operations": "Пакетные операции",
"Delete Selected Profiles": "Удалить выбранные профили",
"Deselect All": "Отменить выбор всех",
"Done": "Готово",
"items": "элементы",
"Select All": "Выбрать все",
"Selected": "Выбрано",
"Selected profiles deleted successfully": "Выбранные профили успешно удалены"
} }

View File

@@ -606,5 +606,13 @@
"Originals Only": "Yalnızca Orijinaller", "Originals Only": "Yalnızca Orijinaller",
"No (IP Banned By Disney+)": "Hayır (IP Disney+ Tarafından Yasaklandı)", "No (IP Banned By Disney+)": "Hayır (IP Disney+ Tarafından Yasaklandı)",
"Unsupported Country/Region": "Desteklenmeyen Ülke/Bölge", "Unsupported Country/Region": "Desteklenmeyen Ülke/Bölge",
"Failed (Network Connection)": "Başarısız (Ağ Bağlantısı)" "Failed (Network Connection)": "Başarısız (Ağ Bağlantısı)",
"Batch Operations": "Toplu İşlemler",
"Delete Selected Profiles": "Seçili Profilleri Sil",
"Deselect All": "Tüm Seçimi Kaldır",
"Done": "Tamam",
"items": "öğeler",
"Select All": "Tümünü Seç",
"Selected": "Seçildi",
"Selected profiles deleted successfully": "Seçili profiller başarıyla silindi"
} }

View File

@@ -682,5 +682,13 @@
"Secret copied to clipboard": "访问密钥已复制到剪贴板", "Secret copied to clipboard": "访问密钥已复制到剪贴板",
"Saving...": "保存中...", "Saving...": "保存中...",
"Proxy node already exists in chain": "该节点已在链式代理表中", "Proxy node already exists in chain": "该节点已在链式代理表中",
"Detection timeout or failed": "检测超时或失败" "Detection timeout or failed": "检测超时或失败",
"Batch Operations": "批量操作",
"Delete Selected Profiles": "删除选中订阅",
"Deselect All": "取消全选",
"Done": "完成",
"items": "项目",
"Select All": "全选",
"Selected": "已选中",
"Selected profiles deleted successfully": "选中的订阅已成功删除"
} }

View File

@@ -18,6 +18,10 @@ import {
LocalFireDepartmentRounded, LocalFireDepartmentRounded,
RefreshRounded, RefreshRounded,
TextSnippetOutlined, TextSnippetOutlined,
CheckBoxOutlineBlankRounded,
CheckBoxRounded,
IndeterminateCheckBoxRounded,
DeleteRounded,
} from "@mui/icons-material"; } from "@mui/icons-material";
import { LoadingButton } from "@mui/lab"; import { LoadingButton } from "@mui/lab";
import { Box, Button, Divider, Grid, IconButton, Stack } from "@mui/material"; import { Box, Button, Divider, Grid, IconButton, Stack } from "@mui/material";
@@ -104,6 +108,12 @@ const ProfilePage = () => {
const [activatings, setActivatings] = useState<string[]>([]); const [activatings, setActivatings] = useState<string[]>([]);
const [loading, setLoading] = useState(false); 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); const switchingProfileRef = useRef<string | null>(null);
@@ -648,6 +658,88 @@ const ProfilePage = () => {
if (text) setUrl(text); 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 mode = useThemeMode();
const islight = mode === "light" ? true : false; const islight = mode === "light" ? true : false;
const dividercolor = islight const dividercolor = islight
@@ -714,51 +806,102 @@ const ProfilePage = () => {
contentStyle={{ height: "100%" }} contentStyle={{ height: "100%" }}
header={ header={
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<IconButton {!batchMode ? (
size="small" <>
color="inherit" {/* Batch mode toggle button */}
title={t("Update All Profiles")} <IconButton
onClick={onUpdateAll} size="small"
> color="inherit"
<RefreshRounded /> title={t("Batch Operations")}
</IconButton> onClick={toggleBatchMode}
>
<CheckBoxOutlineBlankRounded />
</IconButton>
<IconButton <IconButton
size="small" size="small"
color="inherit" color="inherit"
title={t("View Runtime Config")} title={t("Update All Profiles")}
onClick={() => configRef.current?.open()} onClick={onUpdateAll}
> >
<TextSnippetOutlined /> <RefreshRounded />
</IconButton> </IconButton>
<IconButton <IconButton
size="small" size="small"
color="primary" color="inherit"
title={t("Reactivate Profiles")} title={t("View Runtime Config")}
onClick={() => onEnhance(true)} onClick={() => configRef.current?.open()}
> >
<LocalFireDepartmentRounded /> <TextSnippetOutlined />
</IconButton> </IconButton>
{/* 故障检测和紧急恢复按钮 */} <IconButton
{(error || isStale) && ( size="small"
<IconButton color="primary"
size="small" title={t("Reactivate Profiles")}
color="warning" onClick={() => onEnhance(true)}
title="数据异常,点击强制刷新" >
onClick={onEmergencyRefresh} <LocalFireDepartmentRounded />
sx={{ </IconButton>
animation: "pulse 2s infinite",
"@keyframes pulse": { {/* 故障检测和紧急恢复按钮 */}
"0%": { opacity: 1 }, {(error || isStale) && (
"50%": { opacity: 0.5 }, <IconButton
"100%": { opacity: 1 }, size="small"
}, color="warning"
}} title="数据异常,点击强制刷新"
> onClick={onEmergencyRefresh}
<ClearRounded /> sx={{
</IconButton> 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> </Box>
} }
@@ -861,7 +1004,16 @@ const ProfilePage = () => {
// Notice.success(t("Clash Core Restarted"), 1000); // 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> </Grid>
))} ))}