增强跨盘秒传可复选

This commit is contained in:
King committed 2026-07-30 17:09:53 +08:00
1 parent 4cffa98fb5
commit 78efa1cfad
167 files changed
+485 -101

No files matched your search

+16 -12
View File
@@ -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);
+54 -12
View File
@@ -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') {
+42 -5
View File
@@ -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')"
/>
+71 -54
View File
@@ -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)"
/>