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:
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "選択したプロファイルが正常に削除されました"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "Выбранные профили успешно удалены"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "选中的订阅已成功删除"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user