增强跨盘秒传可复选
This commit is contained in:
1 parent
4cffa98fb5
commit
78efa1cfad
167 files changed
+485
-101
No files matched your search
@@ -37,6 +37,20 @@ export interface CrossTransferScanResult {
|
||||
files: CrossTransferScanFile[];
|
||||
}
|
||||
|
||||
export interface CrossTransferScanSource {
|
||||
parent_id: string;
|
||||
display_path: string;
|
||||
ancestor_ids?: string[];
|
||||
}
|
||||
|
||||
export interface CrossTransferScanRequest {
|
||||
source_account_id: number;
|
||||
method: string;
|
||||
sources?: CrossTransferScanSource[];
|
||||
source_parent_id?: string;
|
||||
source_display_path?: string;
|
||||
}
|
||||
|
||||
export interface CrossTransferRelayTask {
|
||||
task_id: string;
|
||||
source_account_id: number;
|
||||
@@ -72,22 +86,12 @@ export function listCrossTransferRoutes() {
|
||||
return http.get<CrossTransferRoute[]>("/cross-transfer/routes");
|
||||
}
|
||||
|
||||
export function scanCrossTransferSource(body: {
|
||||
source_account_id: number;
|
||||
source_parent_id: string;
|
||||
method: string;
|
||||
source_display_path?: string;
|
||||
}) {
|
||||
export function scanCrossTransferSource(body: CrossTransferScanRequest) {
|
||||
return http.post<CrossTransferScanResult>("/cross-transfer/scan", body);
|
||||
}
|
||||
|
||||
export function scanCrossTransferSourceStream(
|
||||
body: {
|
||||
source_account_id: number;
|
||||
source_parent_id: string;
|
||||
method: string;
|
||||
source_display_path?: string;
|
||||
},
|
||||
body: CrossTransferScanRequest,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return streamCrossTransferNDJSON<Record<string, unknown>>("/cross-transfer/scan/stream", body, signal);
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
<div class="panel-pick">
|
||||
<button class="combo" @click="openPicker('src')">
|
||||
<span class="c-ic"><i class="fas fa-hdd"></i></span>
|
||||
<span class="c-text" :class="{ placeholder: !src }">{{ src ? (src.accName + ' · ' + src.path) : '选择账号 · 目录' }}</span>
|
||||
<span class="c-text" :class="{ placeholder: !src }" :title="srcPickerTitle">{{ srcPickerText }}</span>
|
||||
<span class="c-caret"><i class="fas fa-chevron-down"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -330,7 +330,10 @@
|
||||
:accounts="pickerAccounts"
|
||||
:account-id="pickerInitialAccountId"
|
||||
:initial-path="pickerInitialPath"
|
||||
:multi-select="pickerMode === 'src'"
|
||||
:initial-selections="pickerMode === 'src' ? pickerInitialSelections : []"
|
||||
:title="pickerMode === 'src' ? '选择源目录' : '选择目标目录'"
|
||||
:confirm-text="pickerMode === 'src' ? '确认选择' : '选择当前目录'"
|
||||
show-refresh
|
||||
@close="pickerOpen = false"
|
||||
@resolve="onPickerResolve"
|
||||
@@ -408,6 +411,7 @@ const pickerMode = ref('src')
|
||||
const pickerAccounts = ref([])
|
||||
const pickerInitialAccountId = ref(null)
|
||||
const pickerInitialPath = ref('')
|
||||
const pickerInitialSelections = ref([])
|
||||
|
||||
const relayTasksHref = computed(() => (
|
||||
router.resolve({ path: '/', query: { taskPanel: 'relay' } }).href
|
||||
@@ -418,6 +422,15 @@ const showFooterScrollTips = computed(() => !showProgressBar.value && !relayNoti
|
||||
const currentFooterTip = computed(() => footerScrollTips[footerTipIndex.value] || footerScrollTips[0])
|
||||
const isScanPhase = computed(() => phaseStatus.value.includes('扫描'))
|
||||
const isBaiduMd5Route = computed(() => curRoute.value?.method === 'md5' && srcDriver.value === 'baidu_open')
|
||||
const sourceRoots = computed(() => Array.isArray(src.value?.sources) ? src.value.sources : [])
|
||||
const srcPickerText = computed(() => {
|
||||
if (!src.value) return '选择账号 · 目录'
|
||||
if (sourceRoots.value.length === 1) return `${src.value.accName} · ${sourceRoots.value[0].path}`
|
||||
return `${src.value.accName} · 已选 ${sourceRoots.value.length} 个目录`
|
||||
})
|
||||
const srcPickerTitle = computed(() => (
|
||||
src.value ? sourceRoots.value.map(item => item.path).join('\n') : ''
|
||||
))
|
||||
|
||||
const accounts = ref([])
|
||||
const srcTreeRef = ref(null)
|
||||
@@ -687,26 +700,52 @@ async function openPicker(mode) {
|
||||
pickerMode.value = mode;
|
||||
pickerAccounts.value = accs;
|
||||
pickerInitialAccountId.value = Number(cur?.accId || accs[0]?.id || 0) || null;
|
||||
pickerInitialPath.value = cur?.path || "";
|
||||
pickerInitialPath.value = mode === 'src'
|
||||
? (cur?.sources?.length === 1 ? cur.sources[0].path : '')
|
||||
: (cur?.path || '');
|
||||
pickerInitialSelections.value = mode === 'src'
|
||||
? (cur?.sources || []).map(item => ({
|
||||
id: item.parentId,
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
ancestorIds: [...(item.ancestorIds || [])],
|
||||
}))
|
||||
: [];
|
||||
pickerOpen.value = true;
|
||||
}
|
||||
|
||||
function onPickerResolve(payload) {
|
||||
pickerOpen.value = false;
|
||||
const sel = {
|
||||
accId: payload.accountId,
|
||||
accName: payload.accountName,
|
||||
parentId: payload.parentId,
|
||||
path: payload.path,
|
||||
};
|
||||
if (pickerMode.value === "src") {
|
||||
src.value = sel;
|
||||
const selected = Array.isArray(payload.selections) && payload.selections.length
|
||||
? payload.selections
|
||||
: [{
|
||||
id: payload.parentId,
|
||||
name: payload.path.split('/').filter(Boolean).pop() || '根目录',
|
||||
path: payload.path,
|
||||
ancestorIds: [],
|
||||
}]
|
||||
src.value = {
|
||||
accId: payload.accountId,
|
||||
accName: payload.accountName,
|
||||
sources: selected.map(item => ({
|
||||
parentId: item.id,
|
||||
name: item.name,
|
||||
path: item.path || '/',
|
||||
ancestorIds: [...(item.ancestorIds || [])],
|
||||
})),
|
||||
};
|
||||
srcTree.value = null;
|
||||
probeFiles.value = [];
|
||||
scanSummary.value = null;
|
||||
scanLimitReason.value = '';
|
||||
} else {
|
||||
dst.value = sel;
|
||||
dst.value = {
|
||||
accId: payload.accountId,
|
||||
accName: payload.accountName,
|
||||
parentId: payload.parentId,
|
||||
path: payload.path,
|
||||
};
|
||||
}
|
||||
dstTree.value = null;
|
||||
resetMetrics();
|
||||
@@ -726,8 +765,11 @@ async function scanSource(clearTree = true) {
|
||||
try {
|
||||
for await (const msg of scanCrossTransferSourceStream({
|
||||
source_account_id: Number(src.value.accId),
|
||||
source_parent_id: src.value.parentId,
|
||||
source_display_path: src.value.path || "",
|
||||
sources: sourceRoots.value.map(item => ({
|
||||
parent_id: item.parentId,
|
||||
display_path: item.path || '/',
|
||||
ancestor_ids: item.ancestorIds || [],
|
||||
})),
|
||||
method: curRoute.value.method,
|
||||
}, abortCtrl.value?.signal)) {
|
||||
if (msg.event === 'progress') {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Crumb } from "@/stores/browser";
|
||||
import AppModal from "@/components/base/AppModal.vue";
|
||||
import DriverIcon from "@/components/driver/DriverIcon.vue";
|
||||
import FolderSelector from "./FolderSelector.vue";
|
||||
import type { FolderSelection } from "./FolderSelector.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -21,8 +22,9 @@ const props = withDefaults(
|
||||
// 初始定位面包屑,仅对初始 accountId 生效。
|
||||
initialBreadcrumb?: Crumb[];
|
||||
initialPath?: string;
|
||||
// 锁定浏览根为任务目录,不可回到更上层。
|
||||
rootAnchor?: { parentId: string; path: string; label?: string };
|
||||
multiSelect?: boolean;
|
||||
initialSelections?: FolderSelection[];
|
||||
}>(),
|
||||
{
|
||||
title: "选择目录",
|
||||
@@ -36,15 +38,24 @@ const props = withDefaults(
|
||||
initialBreadcrumb: () => [],
|
||||
initialPath: "",
|
||||
rootAnchor: undefined,
|
||||
multiSelect: false,
|
||||
initialSelections: () => [],
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
resolve: [payload: { accountId: number; accountName: string; parentId: string; path: string }];
|
||||
resolve: [payload: {
|
||||
accountId: number;
|
||||
accountName: string;
|
||||
parentId: string;
|
||||
path: string;
|
||||
selections?: FolderSelection[];
|
||||
}];
|
||||
}>();
|
||||
|
||||
const selAccount = ref<number | null>(props.accountId);
|
||||
const selectedItems = ref<FolderSelection[]>([]);
|
||||
|
||||
function initAccount() {
|
||||
if (props.accountId != null) {
|
||||
@@ -54,10 +65,22 @@ function initAccount() {
|
||||
selAccount.value = props.accounts[0]?.id ?? null;
|
||||
}
|
||||
|
||||
function initSelections() {
|
||||
selectedItems.value = props.multiSelect
|
||||
? props.initialSelections.map((item) => ({
|
||||
...item,
|
||||
ancestorIds: [...(item.ancestorIds ?? [])],
|
||||
}))
|
||||
: [];
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) initAccount();
|
||||
if (open) {
|
||||
initAccount();
|
||||
initSelections();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
@@ -70,7 +93,18 @@ function accountDriverLabel(account: Account) {
|
||||
return account.driver_card_name?.trim() || account.driver_type;
|
||||
}
|
||||
|
||||
function onResolve(folder: { accountId: number; parentId: string; path: string }) {
|
||||
function selectAccount(accountId: number) {
|
||||
if (selAccount.value === accountId) return;
|
||||
selAccount.value = accountId;
|
||||
selectedItems.value = [];
|
||||
}
|
||||
|
||||
function onResolve(folder: {
|
||||
accountId: number;
|
||||
parentId: string;
|
||||
path: string;
|
||||
selections?: FolderSelection[];
|
||||
}) {
|
||||
emit("resolve", { ...folder, accountName: accountName.value });
|
||||
}
|
||||
</script>
|
||||
@@ -90,7 +124,7 @@ function onResolve(folder: { accountId: number; parentId: string; path: string }
|
||||
type="button"
|
||||
class="folder-picker__account"
|
||||
:class="{ active: a.id === selAccount }"
|
||||
@click="selAccount = a.id"
|
||||
@click="selectAccount(a.id)"
|
||||
>
|
||||
<DriverIcon
|
||||
:name="accountDriverLabel(a)"
|
||||
@@ -119,6 +153,9 @@ function onResolve(folder: { accountId: number; parentId: string; path: string }
|
||||
:initial-breadcrumb="selAccount === accountId ? initialBreadcrumb : []"
|
||||
:initial-path="selAccount === accountId ? initialPath : ''"
|
||||
:root-anchor="selAccount === accountId ? rootAnchor : undefined"
|
||||
:multi-select="multiSelect"
|
||||
:selected-items="multiSelect ? selectedItems : undefined"
|
||||
@update:selected-items="selectedItems = $event"
|
||||
@resolve="onResolve"
|
||||
@cancel="emit('close')"
|
||||
/>
|
||||
|
||||
@@ -16,7 +16,12 @@ import "@/styles/file-list.css";
|
||||
|
||||
type FolderSortKey = Extract<SortKey, "name" | "modified">;
|
||||
type FolderLoader = (parentId: string, opts?: { forceRefresh?: boolean }) => Promise<FileItem[]>;
|
||||
export type FolderSelection = { id: string; name: string };
|
||||
export type FolderSelection = {
|
||||
id: string;
|
||||
name: string;
|
||||
path?: string;
|
||||
ancestorIds?: string[];
|
||||
};
|
||||
|
||||
const ROOT: Crumb = { id: "", name: "根目录" };
|
||||
|
||||
@@ -140,33 +145,56 @@ function normalizeSelectPath(id: string) {
|
||||
return String(id || "").replace(/^\/+|\/+$/g, "");
|
||||
}
|
||||
|
||||
function isAncestorPath(ancestor: string, child: string) {
|
||||
const a = normalizeSelectPath(ancestor);
|
||||
const c = normalizeSelectPath(child);
|
||||
if (!a || !c || a === c) return false;
|
||||
return c.startsWith(`${a}/`);
|
||||
function normalizedSelectionPath(path?: string) {
|
||||
return `/${String(path || "").split("/").filter(Boolean).join("/")}`;
|
||||
}
|
||||
|
||||
function selectionState(id: string): SelectState {
|
||||
const key = normalizeSelectPath(id);
|
||||
if (!key && !id) {
|
||||
// 根目录自身一般不出现在行内;无 key 时只看是否有任意已选
|
||||
return activeSelections.value.length ? "partial" : "none";
|
||||
}
|
||||
const currentAncestorIds = computed(() =>
|
||||
breadcrumb.value.map((item) => String(item.id || "")).filter(Boolean),
|
||||
);
|
||||
|
||||
function selectionForDir(dir: FileItem): FolderSelection {
|
||||
const base = currentPath.value === "/" ? "" : currentPath.value;
|
||||
return {
|
||||
id: String(dir.id),
|
||||
name: dir.name || String(dir.id),
|
||||
path: normalizedSelectionPath(`${base}/${dir.name || ""}`),
|
||||
ancestorIds: [...currentAncestorIds.value],
|
||||
};
|
||||
}
|
||||
|
||||
function isAncestorSelection(ancestor: FolderSelection, child: FolderSelection) {
|
||||
if (child.ancestorIds?.some((id) => String(id) === String(ancestor.id))) return true;
|
||||
const ancestorPath = normalizedSelectionPath(ancestor.path);
|
||||
const childPath = normalizedSelectionPath(child.path);
|
||||
return ancestorPath !== "/"
|
||||
&& childPath !== "/"
|
||||
&& ancestorPath !== childPath
|
||||
&& childPath.startsWith(`${ancestorPath}/`);
|
||||
}
|
||||
|
||||
function selectionState(dir: FileItem): SelectState {
|
||||
const item = selectionForDir(dir);
|
||||
const items = activeSelections.value;
|
||||
if (items.some((item) => normalizeSelectPath(item.id) === key)) return "checked";
|
||||
if (items.some((item) => isAncestorPath(item.id, key))) return "covered";
|
||||
if (items.some((item) => isAncestorPath(key, item.id))) return "partial";
|
||||
if (items.some((selected) => String(selected.id) === item.id)) return "checked";
|
||||
if (items.some((selected) => isAncestorSelection(selected, item))) return "covered";
|
||||
if (items.some((selected) => isAncestorSelection(item, selected))) return "partial";
|
||||
return "none";
|
||||
}
|
||||
|
||||
function isSelected(id: string) {
|
||||
const state = selectionState(id);
|
||||
function isSelected(dir: FileItem) {
|
||||
const state = selectionState(dir);
|
||||
return state === "checked" || state === "covered";
|
||||
}
|
||||
|
||||
function isPartialSelected(id: string) {
|
||||
return selectionState(id) === "partial";
|
||||
function isPartialSelected(dir: FileItem) {
|
||||
return selectionState(dir) === "partial";
|
||||
}
|
||||
|
||||
function selectionKey(item: FolderSelection) {
|
||||
const id = normalizeSelectPath(item.id);
|
||||
if (id) return id;
|
||||
return normalizedSelectionPath(item.path);
|
||||
}
|
||||
|
||||
function commitSelections(next: FolderSelection[]) {
|
||||
@@ -175,42 +203,31 @@ function commitSelections(next: FolderSelection[]) {
|
||||
return;
|
||||
}
|
||||
const map: Record<string, FolderSelection> = {};
|
||||
for (const item of next) map[item.id] = item;
|
||||
for (const item of next) map[selectionKey(item)] = item;
|
||||
selectedMap.value = map;
|
||||
}
|
||||
|
||||
/** 勾选/取消时父子互斥:选父清子孙,选子清祖先。 */
|
||||
function withPathExclusive(current: FolderSelection[], item: FolderSelection, selected: boolean) {
|
||||
const id = normalizeSelectPath(item.id);
|
||||
function withHierarchyExclusive(current: FolderSelection[], item: FolderSelection, selected: boolean) {
|
||||
const next = current.filter((entry) => {
|
||||
const entryId = normalizeSelectPath(entry.id);
|
||||
if (entryId === id) return false;
|
||||
if (isAncestorPath(entryId, id) || isAncestorPath(id, entryId)) return false;
|
||||
if (String(entry.id) === String(item.id)) return false;
|
||||
if (isAncestorSelection(entry, item) || isAncestorSelection(item, entry)) return false;
|
||||
return true;
|
||||
});
|
||||
if (selected) next.push({ id: item.id, name: item.name || item.id });
|
||||
if (selected) next.push(item);
|
||||
return next;
|
||||
}
|
||||
|
||||
function toggleSelect(dir: FileItem, checked?: boolean) {
|
||||
if (!props.multiSelect) return;
|
||||
const id = String(dir.id);
|
||||
const state = selectionState(id);
|
||||
if (state === "covered") {
|
||||
// 取消覆盖它的上级全选
|
||||
const key = normalizeSelectPath(id);
|
||||
commitSelections(
|
||||
activeSelections.value.filter((item) => !isAncestorPath(item.id, key)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const state = selectionState(dir);
|
||||
if (state === "covered") return;
|
||||
const item = selectionForDir(dir);
|
||||
if (state === "partial") {
|
||||
// 半选 → 全选当前目录(吸收已选子孙)
|
||||
commitSelections(withPathExclusive(activeSelections.value, { id, name: dir.name || id }, true));
|
||||
commitSelections(withHierarchyExclusive(activeSelections.value, item, true));
|
||||
return;
|
||||
}
|
||||
const selected = checked ?? state !== "checked";
|
||||
commitSelections(withPathExclusive(activeSelections.value, { id, name: dir.name || id }, selected));
|
||||
commitSelections(withHierarchyExclusive(activeSelections.value, item, selected));
|
||||
}
|
||||
|
||||
async function listDirs(parentId: string, opts?: { forceRefresh?: boolean }): Promise<FileItem[]> {
|
||||
@@ -290,7 +307,7 @@ const headerSelectState = computed<SelectState>(() => {
|
||||
let checkedOrCovered = 0;
|
||||
let partial = 0;
|
||||
for (const dir of sortedDirs.value) {
|
||||
const state = selectionState(String(dir.id));
|
||||
const state = selectionState(dir);
|
||||
if (state === "checked" || state === "covered") checkedOrCovered += 1;
|
||||
else if (state === "partial") partial += 1;
|
||||
}
|
||||
@@ -304,13 +321,12 @@ function toggleSelectAllVisible() {
|
||||
if (headerSelectState.value === "checked") {
|
||||
let next = activeSelections.value;
|
||||
for (const dir of sortedDirs.value) {
|
||||
const id = String(dir.id);
|
||||
const key = normalizeSelectPath(id);
|
||||
const item = selectionForDir(dir);
|
||||
next = next.filter(
|
||||
(item) =>
|
||||
normalizeSelectPath(item.id) !== key
|
||||
&& !isAncestorPath(item.id, key)
|
||||
&& !isAncestorPath(key, item.id),
|
||||
(selected) =>
|
||||
String(selected.id) !== item.id
|
||||
&& !isAncestorSelection(selected, item)
|
||||
&& !isAncestorSelection(item, selected),
|
||||
);
|
||||
}
|
||||
commitSelections(next);
|
||||
@@ -318,7 +334,7 @@ function toggleSelectAllVisible() {
|
||||
}
|
||||
let next = activeSelections.value;
|
||||
for (const dir of sortedDirs.value) {
|
||||
next = withPathExclusive(next, { id: String(dir.id), name: dir.name || String(dir.id) }, true);
|
||||
next = withHierarchyExclusive(next, selectionForDir(dir), true);
|
||||
}
|
||||
commitSelections(next);
|
||||
}
|
||||
@@ -570,24 +586,25 @@ watch(
|
||||
:key="dir.id"
|
||||
class="folder-table-row"
|
||||
:class="{
|
||||
selected: multiSelect && (isSelected(String(dir.id)) || isPartialSelected(String(dir.id))),
|
||||
selected: multiSelect && (isSelected(dir) || isPartialSelected(dir)),
|
||||
}"
|
||||
@click="openDir(dir)"
|
||||
>
|
||||
<label
|
||||
v-if="multiSelect"
|
||||
class="checkbox-col"
|
||||
:title="selectionState(String(dir.id)) === 'covered'
|
||||
? '已包含在上级全选中,点击取消上级全选'
|
||||
: selectionState(String(dir.id)) === 'partial'
|
||||
:title="selectionState(dir) === 'covered'
|
||||
? '已包含在上级目录中,请先取消上级目录'
|
||||
: selectionState(dir) === 'partial'
|
||||
? '已选部分子目录,点击改为全选此目录'
|
||||
: `选择 ${dir.name}`"
|
||||
@click.stop
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isSelected(String(dir.id))"
|
||||
:indeterminate="isPartialSelected(String(dir.id))"
|
||||
:checked="isSelected(dir)"
|
||||
:indeterminate="isPartialSelected(dir)"
|
||||
:disabled="selectionState(dir) === 'covered'"
|
||||
:aria-label="`选择 ${dir.name}`"
|
||||
@change="toggleSelect(dir, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user