This commit is contained in:
owenchen committed 2026-10-14 21:23:40 +08:00
1 parent e740ec77ec
commit ceb3e7beec
20 files changed
+2280 -138

No files matched your search

@@ -0,0 +1,318 @@
<script setup lang="ts">
/**
* 移动目标目录选择器(底部抽屉,网盘式逐级下钻):替代旧的整树展开——目录多时树又长又难扫,
* 改成「进一层看一层、看中了点『移动到此』」的浏览式交互。
* 数据仍走 FindDmsFolderData 一次拿全(文件夹 + 父子映射),本地按层取子级,下钻不打接口。
* 规则对齐旧系统:根 = CreatedBy 等于 FolderLocalName 的那条;excludeName(被移动文件夹的
* 系统名)连同子树整体不可进——不能把文件夹挪进自己怀里;sourcePath(条目现居目录)下
* 「移动到此」置灰——原地移动无意义。
* 组件不发移动请求:点「移动到此」把当前所在层 confirm 给页面层调 MoveDmsFolder。
*/
import type { PathNode } from "@/data/custom/fileBrowser";
import { useLocale } from "uview-pro";
import { computed, ref, watch } from "vue";
import dmsFileService from "@/service/master/dmsFileService";
/** 一个目录层:系统名(提交用)+ 显示名 */
export interface FolderRow {
folderName: string;
localName: string;
}
const props = defineProps({
/** 抽屉是否展开 */
visible: {
type: Boolean,
default: false,
},
/** 剔除的目录系统名(被移动文件夹自身,其子树随之不可见) */
excludeName: {
type: String,
default: "",
},
/** 被移动条目现居目录的系统名:该层下「移动到此」置灰 */
sourcePath: {
type: String,
default: "",
},
/** 已选条目数(顶部「已选择 N 项」提示) */
selectedCount: {
type: Number,
default: 0,
},
});
const emit = defineEmits<{
(e: "close"): void;
/** 点「移动到此」,带上当前所在目录 */
(e: "confirm", row: FolderRow): void;
}>();
const { t } = useLocale();
const loading = ref(false);
/** 浏览路径栈:[0] 恒为根,末项 = 当前所在目录 */
const trail = ref<FolderRow[]>([]);
/** 全量索引:folderName → 实体 / 父子映射(每次重拉重建) */
let byName = new Map<string, { FolderName: string; FolderLocalName: string; CreatedDatetime: Date | string }>();
let maps: Array<{ FolderNameParent: string; FolderNameSon: string }> = [];
const current = computed<FolderRow | null>(() => trail.value[trail.value.length - 1] ?? null);
/** 当前层的子目录行(滤掉 excludeName 子树根,按显示名排序) */
const rows = computed<FolderRow[]>(() => {
if (!current.value) {
return [];
}
return maps
.filter(m => m.FolderNameParent === current.value!.folderName && m.FolderNameSon !== props.excludeName)
.map(m => byName.get(m.FolderNameSon))
.filter((f): f is NonNullable<typeof f> => !!f)
.sort((a, b) => a.FolderLocalName.localeCompare(b.FolderLocalName, "zh-CN"))
.map(f => ({ folderName: f.FolderName, localName: f.FolderLocalName }));
});
/** 「移动到此」置灰:没有可提交层,或正站在条目现居目录 */
const canMoveHere = computed(() => !!current.value && current.value.folderName !== props.sourcePath);
// 每次展开重拉并回到根:目录可能被别的操作改过,缓存会钻进了不存在的坑
watch(
() => props.visible,
async (on) => {
if (on) {
await load();
}
},
);
async function load(): Promise<void> {
loading.value = true;
try {
const data = await dmsFileService.FindDmsFolderData();
const folders = data?.DmsFolders ?? [];
maps = data?.DmsFoldersMaps ?? [];
byName = new Map(folders.map(f => [f.FolderName, f]));
const root = folders.find(f => f.CreatedBy === f.FolderLocalName);
trail.value = root ? [{ folderName: root.FolderName, localName: root.FolderLocalName }] : [];
}
finally {
loading.value = false;
}
}
/** 目录创建时间展示(后端回 ISO 串/Date 均可能),取不到就不显示 */
function fmtDate(folderName: string): string {
const raw = byName.get(folderName)?.CreatedDatetime;
const date = raw instanceof Date ? raw : new Date(String(raw ?? ""));
if (Number.isNaN(date.getTime())) {
return "";
}
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function onEnter(row: FolderRow): void {
trail.value = [...trail.value, row];
}
/** 头部面包屑(复用 file-breadcrumb):根段显示「我的网盘」,单击任意段回该层 */
const crumbItems = computed<PathNode[]>(() => trail.value.map((row, index) => ({
value: row.folderName,
displayName: index === 0 ? t("FilePage.moveRoot") : row.localName,
})));
/** 面包屑点某段:截断到该层(当前段组件内自行不响应) */
function onJump(index: number): void {
trail.value = trail.value.slice(0, index + 1);
}
function onMoveHere(): void {
if (current.value && canMoveHere.value) {
emit("confirm", current.value);
}
}
</script>
<template>
<u-popup :model-value="props.visible" mode="bottom" round="24" @close="emit('close')">
<view class="folder-move-picker">
<!-- 头部:面包屑路径(复用 file-breadcrumb,单击回层、超长自动折叠)+ 关闭 -->
<view class="folder-move-picker__head">
<view class="folder-move-picker__crumb">
<file-breadcrumb :items="crumbItems" :show-single="true" @nav="onJump" />
</view>
<view class="folder-move-picker__close" @tap="emit('close')">
<u-icon name="close" size="36" color="#8f959e" />
</view>
</view>
<text v-if="props.selectedCount" class="folder-move-picker__picked">
{{ t("FilePage.moveSelected").replace("{count}", String(props.selectedCount)) }}
</text>
<scroll-view scroll-y class="folder-move-picker__list">
<view v-if="loading" class="folder-move-picker__empty">
<u-loading mode="circle" />
</view>
<template v-else>
<view v-if="!rows.length" class="folder-move-picker__empty">
<text class="folder-move-picker__empty-text">
{{ t("FilePage.moveNoSub") }}
</text>
</view>
<view v-for="row in rows" :key="row.folderName" class="folder-move-picker__row" @tap="onEnter(row)">
<!-- 图标库无 folder 字形,沿用项目 CSS 画文件夹惯例(同 file-action-sheet) -->
<view class="folder-move-picker__folder" />
<view class="folder-move-picker__row-main">
<text class="folder-move-picker__row-text">
{{ row.localName }}
</text>
<text v-if="fmtDate(row.folderName)" class="folder-move-picker__row-sub">
{{ fmtDate(row.folderName) }}
</text>
</view>
<u-icon name="arrow-right" size="26" color="#c0c4cc" />
</view>
</template>
</scroll-view>
<!-- 底部:通栏「移动到此」提交当前层(站在原地目录时置灰) -->
<view class="folder-move-picker__footer">
<view class="folder-move-picker__btn" :class="{ 'folder-move-picker__btn--muted': !canMoveHere }"
@tap="onMoveHere">
<text class="folder-move-picker__btn-text">
{{ t("FilePage.moveHere") }}
</text>
</view>
</view>
<view class="folder-move-picker__safe" />
</view>
</u-popup>
</template>
<style lang="scss" scoped>
.folder-move-picker {
padding: 8rpx 32rpx 0;
background-color: #ffffff;
&__head {
display: flex;
align-items: center;
height: 108rpx;
}
/* 面包屑占满剩余宽度(自身可收缩,超长时组件内折叠出 …) */
&__crumb {
flex: 1;
min-width: 0;
overflow: hidden;
}
&__close {
display: flex;
flex-shrink: 0;
padding: 12rpx;
}
&__picked {
display: block;
padding: 0 8rpx 16rpx;
font-size: 26rpx;
color: #5c626b;
}
&__list {
max-height: 640rpx;
min-height: 240rpx;
}
&__empty {
display: flex;
align-items: center;
justify-content: center;
padding: 80rpx 0;
}
&__empty-text {
font-size: 26rpx;
color: #8f959e;
}
&__row {
display: flex;
align-items: center;
gap: 20rpx;
padding: 24rpx 12rpx;
border-radius: 16rpx;
}
&__folder {
position: relative;
flex-shrink: 0;
width: 56rpx;
height: 44rpx;
border-radius: 10rpx;
background: linear-gradient(180deg, #8ecbff, #4aa3ff);
&::before {
position: absolute;
top: -10rpx;
left: 0;
width: 24rpx;
height: 12rpx;
border-radius: 6rpx 6rpx 0 0;
background: linear-gradient(180deg, #8ecbff, #4aa3ff);
content: "";
}
}
&__row-main {
display: flex;
flex: 1;
flex-direction: column;
gap: 6rpx;
overflow: hidden;
}
&__row-text {
overflow: hidden;
font-size: 30rpx;
color: #1f2329;
text-overflow: ellipsis;
white-space: nowrap;
}
&__row-sub {
font-size: 22rpx;
color: #8f959e;
}
&__footer {
display: flex;
align-items: center;
padding-top: 24rpx;
border-top: 1rpx solid #f0f1f3;
}
&__btn {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
height: 76rpx;
border-radius: 999rpx;
background-color: #2b8fff;
}
&__btn--muted {
background-color: #d4d8de;
}
&__btn-text {
font-size: 28rpx;
font-weight: 600;
color: #ffffff;
}
&__safe {
height: calc(24rpx + env(safe-area-inset-bottom));
}
}
</style>