-实现了`generateCaptcha`函数来创建随机验证码。 -添加了`drawCaptcha`函数,在画布上渲染带有视觉噪声的验证码。 feat:引入数据操作实用程序 -为数据处理创建了“normalizeText”、“filterRows”、“attachSheetName”、“flatenRows”和“buildDuplicates”函数。 -添加了用于记录管理的“createLoginLog”、“createRecord”、“mapMovieToRecord”和“createFolder”函数。 -实现了“buildTree”函数来构建分层数据结构。 feat:实现滚动锁定机制 -添加了“lockScroll”和“unlockScrolls”功能,以管理模式交互期间的后台滚动行为。 feat:创建存储实用程序函数 -引入了从本地存储中读取、写入和删除项目的功能。 chore:设置Vite配置 -配置了使用Vue进行开发的Vite,包括服务器设置和API代理。
504 lines
11 KiB
Vue
504 lines
11 KiB
Vue
<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>
|