FolderPickerDialog.vue 7.63 KB
<template>
  <el-dialog
    v-model="dialogVisible"
    title="选择目标文件夹"
    width="420px"
    :close-on-click-modal="false"
    @open="handleDialogOpen"
  >
    <div class="folder-picker-body">
      <!-- 根目录选项 -->
      <div
        class="folder-item root-item"
        :class="{ selected: selectedFolderId === null }"
        @click="selectedFolderId = null"
      >
        <span class="item-indent" />
        <i class="fas fa-home folder-icon root-icon" />
        <span class="folder-name">我的文档(根目录)</span>
      </div>

      <!-- 文件夹树列表(扁平化,用缩进表示层级) -->
      <template v-for="item in flatItems" :key="item.id">
        <div
          v-if="!excludeIds.includes(String(item.id))"
          class="folder-item"
          :class="{ selected: selectedFolderId === item.id }"
          :style="{ paddingLeft: (item.depth + 1) * 20 + 12 + 'px' }"
          @click="selectedFolderId = item.id"
        >
          <!-- 展开/折叠图标 -->
          <span
            v-if="item.hasChildren !== false"
            class="expand-icon"
            @click.stop="toggleExpand(item)"
          >
            <i
              v-if="item.loading"
              class="fas fa-spinner fa-spin"
              style="font-size: 10px"
            />
            <i
              v-else
              class="fas"
              :class="item.expanded ? 'fa-chevron-down' : 'fa-chevron-right'"
              style="font-size: 10px"
            />
          </span>
          <span v-else class="expand-icon expand-placeholder" />
          <i class="fas fa-folder folder-icon" />
          <span class="folder-name">{{ item.name }}</span>
        </div>
      </template>

      <!-- 空状态 -->
      <div v-if="flatItems.length === 0 && !loading" class="empty-state">
        <i class="fas fa-folder-open" />
        <span>没有可用的子文件夹</span>
      </div>

      <div v-if="loading" class="loading-state">
        <i class="fas fa-spinner fa-spin" />
        <span>加载中...</span>
      </div>
    </div>

    <template #footer>
      <el-button @click="handleCancel">取消</el-button>
      <el-button type="primary" @click="handleConfirm">
        移动到此处
      </el-button>
    </template>
  </el-dialog>
</template>

<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { getFiles } from "@/api/files";

interface FolderNode {
  id: string | number;
  name: string;
  depth: number;
  expanded: boolean;
  loading: boolean;
  hasChildren: boolean | undefined; // undefined = unknown
  children?: FolderNode[];
  parentId?: string | number;
}

interface Props {
  visible: boolean;
  excludeIds?: (string | number)[];
}

const props = withDefaults(defineProps<Props>(), {
  excludeIds: () => [],
});

const emit = defineEmits<{
  (e: "confirm", targetFolderId: string | number | null): void;
  (e: "cancel"): void;
  (e: "update:visible", val: boolean): void;
}>();

const dialogVisible = computed({
  get: () => props.visible,
  set: (val) => emit("update:visible", val),
});

const selectedFolderId = ref<string | number | null>(null);
const loading = ref(false);

// 扁平化的文件夹列表(用于渲染树,避免递归组件)
const flatItems = ref<FolderNode[]>([]);

// 展开状态映射:id -> children
const childrenCache = ref<Map<string | number, FolderNode[]>>(new Map());

// 计算扁平化列表(根据展开状态)
const buildFlatItems = (
  nodes: FolderNode[],
  result: FolderNode[] = [],
): FolderNode[] => {
  for (const node of nodes) {
    result.push(node);
    if (node.expanded && node.children) {
      buildFlatItems(node.children, result);
    }
  }
  return result;
};

// 顶层文件夹列表
const rootFolders = ref<FolderNode[]>([]);

// 刷新扁平列表
const refreshFlatItems = () => {
  flatItems.value = buildFlatItems(rootFolders.value);
};

// 加载文件夹(指定 parentId,返回子文件夹列表)
const loadFolders = async (
  parentId?: string | number,
  depth = 0,
): Promise<FolderNode[]> => {
  try {
    const res = await getFiles(parentId);
    if (!res || !res.data) return [];

    const items = Array.isArray(res.data)
      ? res.data
      : res.data.files || res.data.items || [];

    return items
      .filter(
        (item: any) =>
          item.isFolder ||
          item.folder ||
          item.type === "folder" ||
          item.is_folder,
      )
      .map((item: any) => ({
        id: item.id,
        name: item.name || item.folderName || "",
        depth,
        expanded: false,
        loading: false,
        hasChildren: undefined, // 未知,等展开时再判断
        children: undefined,
        parentId,
      }));
  } catch {
    return [];
  }
};

// 展开/折叠文件夹
const toggleExpand = async (item: FolderNode) => {
  if (item.expanded) {
    // 折叠
    item.expanded = false;
    refreshFlatItems();
    return;
  }

  // 展开 - 如果没有缓存则加载子文件夹
  if (!item.children) {
    item.loading = true;
    refreshFlatItems();

    const children = await loadFolders(item.id, item.depth + 1);
    item.children = children;
    item.hasChildren = children.length > 0;
    item.loading = false;
  }

  item.expanded = true;
  refreshFlatItems();
};

// 对话框打开时加载根文件夹
const handleDialogOpen = async () => {
  selectedFolderId.value = null;
  loading.value = true;
  flatItems.value = [];
  rootFolders.value = [];
  childrenCache.value = new Map();

  rootFolders.value = await loadFolders(undefined, 0);
  loading.value = false;
  refreshFlatItems();
};

const handleConfirm = () => {
  emit("confirm", selectedFolderId.value);
  dialogVisible.value = false;
};

const handleCancel = () => {
  emit("cancel");
  dialogVisible.value = false;
};

// 监听 visible 变化
watch(
  () => props.visible,
  (val) => {
    if (!val) {
      // 关闭时重置
      selectedFolderId.value = null;
    }
  },
);
</script>

<style scoped lang="scss">
.folder-picker-body {
  max-height: 360px;
  overflow-y: auto;
  border: 1px solid var(--color-border);
  border-radius: 6px;
  padding: 4px 0;

  &::-webkit-scrollbar {
    width: 6px;
  }

  &::-webkit-scrollbar-thumb {
    background: var(--color-border);
    border-radius: 3px;
  }
}

.folder-item {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 8px 12px;
  cursor: pointer;
  transition: background-color 0.15s;
  border-radius: 4px;
  margin: 1px 4px;
  user-select: none;

  &:hover {
    background-color: var(--color-hover);
  }

  &.selected {
    background-color: rgba(30, 112, 255, 0.15);

    .folder-name {
      color: var(--color-primary);
      font-weight: 500;
    }
  }

  &.root-item {
    border-bottom: 1px solid var(--color-border);
    margin-bottom: 4px;
    border-radius: 4px 4px 0 0;
    font-weight: 500;
  }
}

.item-indent {
  display: inline-block;
  width: 16px;
  flex-shrink: 0;
}

.expand-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 16px;
  height: 16px;
  flex-shrink: 0;
  cursor: pointer;
  color: var(--color-text-secondary);
  border-radius: 2px;

  &:hover {
    background-color: rgba(0, 0, 0, 0.06);
  }

  &.expand-placeholder {
    cursor: default;

    &:hover {
      background: none;
    }
  }
}

.folder-icon {
  font-size: 14px;
  color: #f0a500;
  flex-shrink: 0;

  &.root-icon {
    color: var(--color-primary);
  }
}

.folder-name {
  flex: 1;
  font-size: 14px;
  color: var(--color-text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.empty-state,
.loading-state {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  padding: 24px;
  color: var(--color-text-secondary);
  font-size: 14px;
}
</style>