vue/src/components/TreeNode.vue
liRQ d9f87f72b5 壮举:添加验证码生成和绘图实用程序
-实现了`generateCaptcha`函数来创建随机验证码。

-添加了`drawCaptcha`函数,在画布上渲染带有视觉噪声的验证码。

feat:引入数据操作实用程序

-为数据处理创建了“normalizeText”、“filterRows”、“attachSheetName”、“flatenRows”和“buildDuplicates”函数。

-添加了用于记录管理的“createLoginLog”、“createRecord”、“mapMovieToRecord”和“createFolder”函数。

-实现了“buildTree”函数来构建分层数据结构。

feat:实现滚动锁定机制

-添加了“lockScroll”和“unlockScrolls”功能,以管理模式交互期间的后台滚动行为。

feat:创建存储实用程序函数

-引入了从本地存储中读取、写入和删除项目的功能。

chore:设置Vite配置

-配置了使用Vue进行开发的Vite,包括服务器设置和API代理。
2026-06-29 10:19:00 +08:00

504 lines
11 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="tree-item" :style="{ marginLeft: depth * 24 + 'px' }">
<div
class="tree-item__header"
:class="{ 'is-folder': node.type === 'folder', 'is-selected': isSelected }"
@click="handleClick"
@mouseenter="onHeaderMouseEnter"
@mouseleave="onHeaderMouseLeave"
>
<!-- 多选模式下的复选框 -->
<input
v-if="multiSelectMode"
type="checkbox"
class="tree-checkbox"
:checked="isSelected"
@click.stop
@change="handleCheckboxChange"
/>
<!-- 文件夹/文件图标 -->
<span v-if="multiSelectMode" class="tree-icon tree-icon--checkbox"></span>
<span v-else-if="node.type === 'folder'" class="tree-icon">
{{ isExpanded ? '📂' : '📁' }}
</span>
<span v-else class="tree-icon">📄</span>
<span v-if="node.type === 'folder' && node.isLoading" class="tree-loading">加载中...</span>
<!-- 刮削状态徽章(仅文件类型) -->
<span
v-if="node.type === 'file' && node.scrapeStatus"
class="tree-scrape-badge"
:class="`tree-scrape-badge--${node.scrapeStatus}`"
:title="getScrapeStatusTitle(node)"
>
{{ getScrapeStatusText(node.scrapeStatus) }}
</span>
<!-- 排序权重标签 -->
<span v-if="node.type === 'folder'" class="tree-sort-badge" :title="`排序权重:${node.sortWeight ?? 0}`">
{{ node.sortWeight ?? 0 }}
</span>
<span class="tree-title">{{ node.title }}</span>
<!-- 文件夹描述提示图标 -->
<span
v-if="node.type === 'folder' && node.description"
class="tree-info-badge"
title="有描述"
></span>
<div class="tree-actions" @click.stop>
<!-- 刮削进度条(刮削中时显示) -->
<div
v-if="node.type === 'file' && node.scrapeStatus === 'scraping'"
class="tree-scrape-progress"
>
<div class="tree-scrape-progress__bar"></div>
</div>
<button
v-if="node.type === 'folder'"
type="button"
class="action-btn"
title="添加子文件夹"
@click="handleAddFolder"
>
+📁
</button>
<button
v-if="node.type === 'folder'"
type="button"
class="action-btn"
title="添加文件"
@click="handleAddFile"
>
+📄
</button>
<button
v-if="node.type === 'file'"
type="button"
class="action-btn action-btn--rescrape"
title="重新刮削"
@click="handleRescrape"
>
🔄
</button>
<button
v-if="node.type === 'file'"
type="button"
class="action-btn action-btn--scrape"
title="查询刮削状态"
@click="handleScrapeStatus"
>
🔍
</button>
<button
type="button"
class="action-btn"
title="编辑"
@click="handleEdit"
>
✏️
</button>
<button
type="button"
class="action-btn action-btn--danger"
title="删除"
@click="handleDelete"
>
🗑️
</button>
</div>
</div>
<!-- 文件夹描述悬浮提示Teleport 到 body -->
<Teleport to="body">
<Transition name="tooltip-fade">
<div
v-if="showTooltip"
class="app-tooltip"
:style="tooltipStyle"
>
<div class="app-tooltip__header">
<span class="app-tooltip__icon">📁</span>
<span class="app-tooltip__title">{{ node.title }}</span>
</div>
<div class="app-tooltip__body">
<p v-if="node.description" class="app-tooltip__desc">{{ node.description }}</p>
<span v-else class="app-tooltip__empty">暂无描述</span>
<div v-if="node.sortWeight" class="app-tooltip__meta">
排序权重:{{ node.sortWeight }}
</div>
</div>
<div class="app-tooltip__arrow"></div>
</div>
</Transition>
</Teleport>
<!-- 递归渲染子节点 -->
<Transition name="expand">
<div v-if="node.type === 'folder' && isExpanded && node.children?.length" class="tree-children">
<TreeNode
v-for="child in node.children"
:key="child.id"
:node="child"
:depth="depth + 1"
:expanded-folder-ids="expandedFolderIds"
:multi-select-mode="multiSelectMode"
:selected-ids="selectedIds"
@toggle-select="$emit('toggle-select', $event)"
@add-folder="$emit('add-folder', $event)"
@add-file="$emit('add-file', $event)"
@expand="$emit('expand', $event)"
@edit="$emit('edit', $event)"
@delete="$emit('delete', $event)"
@scrape-status="$emit('scrape-status', $event)"
@rescrape="$emit('rescrape', $event)"
/>
</div>
</Transition>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const props = defineProps({
node: {
type: Object,
required: true,
},
depth: {
type: Number,
default: 0,
},
expandedFolderIds: {
type: Set,
default: () => new Set(),
},
multiSelectMode: {
type: Boolean,
default: false,
},
selectedIds: {
type: Set,
default: () => new Set(),
},
})
const emit = defineEmits(['add-folder', 'add-file', 'expand', 'edit', 'delete', 'toggle-select', 'scrape-status', 'rescrape'])
const isSelected = computed(() => props.selectedIds.has(props.node.id))
const isExpanded = ref(false)
const showTooltip = ref(false)
const tooltipPos = ref({ top: 0, left: 0 })
let tooltipTimer = null
// 当 buildTree 重建节点时,通过全局展开状态恢复 isExpanded
watch(
() => props.expandedFolderIds.has(props.node.id),
(shouldExpand) => {
if (shouldExpand && !isExpanded.value) {
isExpanded.value = true
}
},
{ immediate: true },
)
const tooltipStyle = computed(() => ({
position: 'fixed',
top: `${tooltipPos.value.top}px`,
left: `${tooltipPos.value.left}px`,
transform: 'translateX(-50%)',
zIndex: 99999,
}))
function onHeaderMouseEnter(event) {
if (props.node.type !== 'folder') return
const el = event.currentTarget
const rect = el.getBoundingClientRect()
tooltipPos.value = {
top: rect.bottom + 8,
left: rect.left + rect.width / 2,
}
tooltipTimer = setTimeout(() => {
showTooltip.value = true
}, 300)
}
function onHeaderMouseLeave() {
clearTimeout(tooltipTimer)
showTooltip.value = false
}
function handleClick(event) {
// 多选模式下,点击复选框触发选择/取消选择
if (props.multiSelectMode && event.target.type !== 'checkbox') {
emit('toggle-select', props.node.id)
}
// 文件夹展开逻辑
toggleExpand()
}
function handleCheckboxChange(event) {
emit('toggle-select', props.node.id)
}
function toggleExpand() {
if (props.node.type === 'folder') {
const nextExpanded = !isExpanded.value
isExpanded.value = nextExpanded
if (nextExpanded) {
// 展开时立即将文件夹ID加入全局展开状态确保buildTree重建时能保持展开
props.expandedFolderIds.add(props.node.id)
emit('expand', props.node)
} else {
// 收起时从全局展开状态中移除
props.expandedFolderIds.delete(props.node.id)
}
}
}
function handleAddFolder() {
emit('add-folder', props.node.id)
}
function handleAddFile() {
emit('add-file', props.node.id)
}
function handleEdit() {
emit('edit', props.node)
}
function handleDelete() {
emit('delete', props.node)
}
function handleScrapeStatus() {
emit('scrape-status', props.node)
}
function handleRescrape() {
console.log('[TreeNode] handleRescrape called, node:', props.node)
emit('rescrape', props.node)
}
function getScrapeStatusText(status) {
const statusMap = {
scraping: '刮削中',
success: '成功',
failed: '失败',
}
return statusMap[status] || ''
}
function getScrapeStatusTitle(node) {
const parts = []
if (node.scrapeMessage) parts.push(node.scrapeMessage)
if (node.scrapeRating) parts.push(`评分: ${node.scrapeRating}`)
if (node.scrapeCategory) parts.push(`分类: ${node.scrapeCategory}`)
return parts.length > 0 ? parts.join(' | ') : node.scrapeStatus
}
</script>
<style scoped>
.tree-item {
margin-bottom: 4px;
}
.tree-item__header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 8px;
background: rgba(99, 102, 241, 0.05);
cursor: pointer;
transition: all 0.2s;
}
.tree-item__header:hover {
background: rgba(99, 102, 241, 0.1);
}
.tree-item__header.is-folder {
font-weight: 600;
}
.tree-icon {
font-size: 18px;
flex-shrink: 0;
}
.tree-icon--checkbox {
width: 18px;
visibility: hidden;
}
.tree-checkbox {
width: 16px;
height: 16px;
cursor: pointer;
flex-shrink: 0;
}
.tree-item__header.is-selected {
background: rgba(99, 102, 241, 0.2);
}
.tree-loading {
flex-shrink: 0;
font-size: 12px;
color: #6366f1;
}
.tree-sort-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: rgba(99, 102, 241, 0.12);
color: #6366f1;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
line-height: 1;
letter-spacing: 0.02em;
}
.tree-title {
flex: 1;
font-size: 14px;
color: #1f2937;
}
.tree-info-badge {
font-size: 12px;
flex-shrink: 0;
opacity: 0.6;
transition: opacity 0.2s;
}
.tree-item__header:hover .tree-info-badge {
opacity: 1;
}
.tree-actions {
display: flex;
gap: 4px;
opacity: 1;
transition: opacity 0.2s;
}
.tree-item__header:hover .tree-actions {
opacity: 1;
}
.action-btn {
padding: 4px 8px;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.action-btn:hover {
background: rgba(99, 102, 241, 0.15);
}
.action-btn--danger:hover {
background: rgba(239, 68, 68, 0.15);
}
.tree-children {
margin-top: 4px;
}
.expand-enter-active,
.expand-leave-active {
transition: all 0.3s ease;
max-height: 1000px;
overflow: hidden;
}
.expand-enter-from,
.expand-leave-to {
max-height: 0;
opacity: 0;
}
/* ===== 刮削状态徽章样式 ===== */
.tree-scrape-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 36px;
height: 20px;
padding: 0 6px;
border-radius: 10px;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
line-height: 1;
}
.tree-scrape-badge--scraping {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
animation: scrape-pulse 2s ease-in-out infinite;
}
.tree-scrape-badge--success {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
.tree-scrape-badge--failed {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
@keyframes scrape-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
/* 刮削进度条动画 */
.tree-scrape-progress {
width: 40px;
height: 3px;
background: rgba(59, 130, 246, 0.1);
border-radius: 2px;
overflow: hidden;
flex-shrink: 0;
}
.tree-scrape-progress__bar {
width: 40%;
height: 100%;
background: linear-gradient(90deg, #3b82f6, #60a5fa);
border-radius: 2px;
animation: scrape-progress-slide 1.2s ease-in-out infinite;
}
@keyframes scrape-progress-slide {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(350%);
}
}
</style>