网盘
观察到 soffice 无法加载某些word文件,显示Error: source file could not be loaded,但把 docx 的内容复制到另一个 docx 中就好了,原理未知
路径越界
- 当路径输入 / 时,后端会拼接成 /app/storage/file/clouddrive//test.py,然后由于是 // ,会直接到根目录下,变成/app/test.py,产生漏洞
文件分片上传 10MB/20s
网盘上传及比较
- 远程
python3 -c "import os;open('remote_files.txt','w',encoding='utf-8').write('\n'.join(sorted([os.path.relpath(os.path.join(r,f),'.') for r,_,fs in os.walk('.') for f in fs])))" - 本地
python -Xutf8 -c "import os;open('local_files.txt','w',encoding='utf-8').write('\n'.join(sorted([os.path.relpath(os.path.join(r,f),'.') for r,_,fs in os.walk('.') for f in fs])))" - 比较
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os
# ========================
# 配置区域
# ========================
LOCAL_FILE = "local_files.txt" # 本地完整文件列表
REMOTE_FILE = "remote_files.txt" # 远程文件列表
OUTPUT_FILE = "missing_files.txt" # 输出缺失文件列表
# ========================
def normalize_path(p):
"""
统一路径格式:
- 将反斜杠 \ 替换为 /
- 去掉开头的 ./ 或 /
- 去掉尾部 /
- 去掉前后空格
"""
if not p:
return ""
p = p.strip()
p = p.replace("\\", "/")
p = p.lstrip("./")
if p.endswith("/") and p != "/":
p = p[:-1]
return p
def read_file_set(path):
"""
读取文件列表,并对每一行做路径规范化
返回 set
"""
with open(path, encoding="utf-8") as f:
return set(normalize_path(line) for line in f if line.strip())
def main():
print("读取文件列表...")
local_files = read_file_set(LOCAL_FILE)
remote_files = read_file_set(REMOTE_FILE)
print(f"本地文件数: {len(local_files)}")
print(f"远程文件数: {len(remote_files)}")
# 找出远程缺失文件
missing = sorted(local_files - remote_files)
print(f"远程缺失文件总数: {len(missing)}")
# 写入文件
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
f.write("\n".join(missing))
print(f"缺失文件列表已保存到: {OUTPUT_FILE}")
if __name__ == "__main__":
main()
关于夸克浏览器右键菜单
- 最开始在电脑、手机谷歌浏览器、手机 OPPO 自带浏览器上长按均能触发右键菜单
- 而夸克则会出现下载图片等智能助手的逻辑
- 在尝试了
- 使用 preventDefault() 阻止上传
- 使用 -webkit-touch-callout: none 等常见阻止浏览器触发长按自定义菜单的配置
- 使用图片遮罩
- 使用未知路径图片(长按出现举报按钮)
- 使用 canvas 绘制图片(长按不出现任何东西)
- 以上在其他三个浏览器中均测试正常
- 之后
- 初步认为克浏览器对图片长按的拦截是系统级行为
- 在夸克中禁用智能助手所有功能,启用无图模式,无效
- 夸克中无法关闭智能助手
- 最后在测试原测试页面右键菜单时发现存在一些办法有效
- 监听 interatcion 事件,获取点击坐标(而不是元素)
- 随后根据坐标判定对应容器
- 期间共三份代码,1 是原逻辑,2 是中途出现的失败逻辑,3 是有效逻辑
代码 1
<template>
<div class="file-manager" @click="closeContextMenu">
<!-- 面包屑 + 搜索框 容器 -->
<div class="top-bar">
<div class="breadcrumb">
<span
v-for="(part, index) in pathParts"
:key="index"
@click="navigateTo(index)"
class="breadcrumb-item"
>
{{ part.name }} /
</span>
</div>
<div class="controls">
<!-- 排序控件 -->
<div class="sort-container">
<select v-model="sortBy" class="sort-select">
<option value="name">名称</option>
<option value="modified">修改时间</option>
<option value="size">大小</option>
</select>
<button
@click="toggleSortOrder"
class="sort-order"
:class="{ 'desc': sortOrder === 'desc' }"
>
▼
</button>
</div>
<!-- 搜索框 -->
<div class="search-bar">
<input
v-model="searchKeyword"
@input="handleSearch"
placeholder="搜索文件名..."
class="search-input"
/>
</div>
</div>
</div>
<!-- 文件列表 -->
<div class="file-list" @contextmenu.prevent="openBlankContextMenu">
<!-- 空状态 -->
<div
v-if="sortedItems.length === 0"
class="empty-placeholder"
@contextmenu.prevent="openBlankContextMenu"
>
此文件夹为空,右键可上传文件或新建文件夹
</div>
<!-- 有内容时渲染文件项 -->
<div
v-for="item in sortedItems"
:key="item.path"
class="file-item"
:draggable="true"
@dragstart="handleDragStart(item)"
@dragover.prevent="handleDragOver"
@drop="handleDrop(item)"
@contextmenu.prevent="openContextMenu($event, item)"
@click="handleItemClick(item)"
>
<img :src="getIconForItem(item)" class="icon" />
<div class="details">
<span>{{ item.name }}</span>
<time>{{ formatDate(item.modified) }}</time>
</div>
</div>
</div>
<!-- 右键菜单 -->
<div
v-if="contextMenu.visible"
class="context-menu"
:style="{ top: contextMenu.y + 'px', left: contextMenu.x + 'px' }"
>
<template v-if="contextMenu.target">
<div @click="handleDownload">下载</div>
<div @click="startRename">重命名</div>
<div @click="startMove">移动到...</div>
<div @click="moveToRoot">移动至根目录</div>
<div @click.stop="handleDelete">删除</div>
</template>
<template v-else>
<div @click="triggerFileUpload">上传文件</div>
<div @click="triggerFolderUpload">上传文件夹</div>
<div @click="createNewFolder">新建文件夹</div>
</template>
</div>
<!-- 隐藏上传控件 -->
<input
type="file"
ref="fileInput"
@change="handleFileUpload"
multiple
style="display: none"
>
<input
type="file"
ref="folderInput"
@change="handleFolderUpload"
webkitdirectory
style="display: none"
>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { http } from '@/api.js'
// 排序相关状态
const sortBy = ref('name')
const sortOrder = ref('desc')
// 排序后的文件列表
const sortedItems = computed(() => {
return [...items.value].sort((a, b) => {
let compareValue = 0
if (sortBy.value === 'name') {
compareValue = a.name.localeCompare(b.name)
}
else if (sortBy.value === 'modified') {
compareValue = new Date(a.modified) - new Date(b.modified)
}
else if (sortBy.value === 'size') {
compareValue = a.size - b.size
}
return sortOrder.value === 'asc' ? compareValue : -compareValue
})
})
// 切换排序顺序
const toggleSortOrder = () => {
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
}
// 文件管理器核心逻辑
const currentPath = ref('none')
const items = ref([])
const draggingItem = ref(null)
const contextMenu = ref({
visible: false,
x: 0,
y: 0,
target: null
})
const fileInput = ref(null)
const folderInput = ref(null)
const searchKeyword = ref('')
// 文件图标配置
const iconMap = {
'pdf': '/icons/pdf.png',
'doc': '/icons/word.png',
'docx': '/icons/word.png',
'xls': '/icons/excel.png',
'xlsx': '/icons/excel.png',
'ppt': '/icons/ppt.png',
'pptx': '/icons/ppt.png',
'zip': '/icons/zip.png',
'rar': '/icons/zip.png',
'txt': '/icons/txt.png',
'jpg': '/icons/image.png',
'jpeg': '/icons/image.png',
'png': '/icons/image.png',
'gif': '/icons/image.png',
'mp4': '/icons/video.png',
'mp3': '/icons/audio.png',
'md': '/icons/markdown.png',
'default': '/icons/file.png'
}
const folderIcon = '/icons/folder.png'
// 初始化加载
onMounted(() => {
loadData(currentPath.value)
})
// 文件图标获取
const getIconForItem = (item) => {
if (item.is_dir) return folderIcon
const ext = item.name.split('.').pop().toLowerCase()
return iconMap[ext] || iconMap['default']
}
// 数据加载
const loadData = async (path) => {
try {
const res = await http.get(`/browse/${encodeURIComponent(path || 'none')}`)
items.value = res.data.items
} catch (error) {
console.error('Error loading directory:', error)
}
}
// 路径处理
const pathParts = computed(() => {
const parts = currentPath.value === 'none' ? [] : currentPath.value.split('/')
return parts.reduce((acc, part, index) => {
if (part) {
acc.push({
name: part,
path: parts.slice(0, index + 1).join('/')
})
}
return acc
}, [{ name: '根目录', path: 'none' }])
})
// 导航功能
const navigateTo = (index) => {
const target = pathParts.value[index]
currentPath.value = target.path
loadData(currentPath.value)
}
// 右键菜单处理
const openBlankContextMenu = (e) => {
if (!e.target.closest('.file-item')) {
contextMenu.value = {
visible: true,
x: e.pageX,
y: e.pageY,
target: null
}
}
}
const openContextMenu = (e, target) => {
contextMenu.value = {
visible: true,
x: e.pageX,
y: e.pageY,
target
}
}
const closeContextMenu = () => {
contextMenu.value.visible = false
}
// 文件操作方法
const startRename = async () => {
const item = contextMenu.value.target
const newName = prompt('输入新名称', item.name)
if (newName) {
try {
await http.put('/rename', {
old_path: item.path,
new_path: newName
})
loadData(currentPath.value)
} catch (error) {
alert('重命名失败: ' + error.response?.data?.detail || error.message)
}
}
}
const handleDelete = async () => {
const item = contextMenu.value.target
if (!confirm(`确定要永久删除 ${item.name} 吗?`)) return
try {
await http.delete('/files', {
data: { path: item.path }
})
alert('删除成功')
loadData(currentPath.value)
} catch (error) {
alert(`删除失败: ${error.response?.data?.detail || error.message}`)
} finally {
contextMenu.value.visible = false
}
}
const moveToRoot = async () => {
const item = contextMenu.value.target
try {
await http.post('/move', {
src_path: item.path,
dst_path: item.name
})
loadData(currentPath.value)
alert('移动成功')
} catch (error) {
alert('移动失败: ' + error.response?.data?.detail || error.message)
} finally {
contextMenu.value.visible = false
}
}
// 下载逻辑
const handleDownload = async () => {
const item = contextMenu.value.target
try {
const response = await http.get(`/download/${encodeURIComponent(item.path)}`, {
responseType: 'blob'
})
const url = window.URL.createObjectURL(new Blob([response.data]))
const link = document.createElement('a')
link.href = url
link.setAttribute('download', item.name)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
} catch (error) {
alert('下载失败: ' + error.response?.data?.detail || error.message)
}
}
// 拖拽功能
const handleDragStart = (item) => {
draggingItem.value = item
}
const handleDragOver = (e) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
}
const handleDrop = async (target) => {
if (draggingItem.value && target.is_dir) {
try {
await http.post('/move', {
src_path: draggingItem.value.path,
dst_path: `${target.path}/${draggingItem.value.name}`
})
loadData(currentPath.value)
} catch (error) {
alert('移动失败: ' + error.response?.data?.detail || error.message)
}
}
draggingItem.value = null
}
// 新建文件夹
const createNewFolder = async () => {
const folderName = prompt('输入文件夹名称')
if (folderName) {
try {
await http.post('/folders', {
path: `${currentPath.value === 'none' ? '' : currentPath.value}/${folderName}`
})
loadData(currentPath.value)
} catch (error) {
alert('创建失败: ' + error.response?.data?.detail || error.message)
}
}
}
// 移动功能
const startMove = async () => {
const item = contextMenu.value.target
const targetPath = prompt('输入目标路径', currentPath.value)
if (targetPath) {
try {
await http.post('/move', {
src_path: item.path,
dst_path: targetPath
})
loadData(currentPath.value)
} catch (error) {
alert('移动失败: ' + error.response?.data?.detail || error.message)
}
}
}
// 上传处理
const triggerFileUpload = () => {
contextMenu.value.visible = false
fileInput.value.click()
}
const triggerFolderUpload = () => {
contextMenu.value.visible = false
folderInput.value.click()
}
const handleFileUpload = async (e) => {
const files = e.target.files
if (files.length === 0) return
try {
const formData = new FormData()
const basePath = currentPath.value === 'none' ? '' : currentPath.value
for (let i = 0; i < files.length; i++) {
formData.append('files', files[i])
}
formData.append('paths', basePath)
await http.post('/files', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
loadData(currentPath.value)
} catch (error) {
alert('上传失败: ' + error.response?.data?.detail || error.message)
} finally {
e.target.value = ''
}
}
const handleFolderUpload = async (e) => {
const files = e.target.files
if (files.length === 0) return
try {
const formData = new FormData()
const basePath = currentPath.value === 'none' ? '' : currentPath.value
Array.from(files).forEach(file => {
const relativePath = file.webkitRelativePath || file.name
const fullPath = basePath ? `${basePath}/${relativePath}` : relativePath
formData.append('paths', fullPath)
formData.append('files', file)
})
await http.post('/file_folders', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
loadData(currentPath.value)
alert('文件夹上传成功')
} catch (error) {
alert('上传失败: ' + (error.response?.data?.detail || error.message))
} finally {
e.target.value = ''
}
}
// 时间格式化
const formatDate = (isoString) => {
const date = new Date(isoString)
return `${date.toLocaleDateString()} ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
}
// 处理文件点击
const handleItemClick = (item) => {
if (item.is_dir) {
currentPath.value = item.path
loadData(currentPath.value)
} else {
const previewPath = `/cab/preview/${encodeURIComponent(item.path)}`
window.open(previewPath, '_blank')
}
}
// 搜索逻辑
const handleSearch = async () => {
const keyword = searchKeyword.value.trim()
if (keyword === '') {
loadData(currentPath.value)
return
}
try {
const res = await http.get('/search', {
params: {
path: currentPath.value,
keyword
}
})
items.value = res.data.items
} catch (error) {
alert('搜索失败: ' + error.response?.data?.detail || error.message)
}
}
</script>
<style scoped>
.file-manager {
padding: 20px;
min-height: 100vh;
}
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
gap: 20px;
}
.controls {
display: flex;
align-items: center;
gap: 12px;
}
.sort-container {
display: flex;
align-items: center;
background: #f5f5f5;
border-radius: 4px;
padding: 4px;
}
.sort-select {
padding: 6px 12px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: white;
outline: none;
cursor: pointer;
}
.sort-order {
margin-left: 8px;
cursor: pointer;
background: none;
border: none;
transform: rotate(0deg);
transition: transform 0.2s;
font-size: 12px;
padding: 4px 8px;
}
.sort-order.desc {
transform: rotate(180deg);
}
.breadcrumb {
background: #f5f5f5;
padding: 10px;
border-radius: 4px;
flex-grow: 1;
white-space: nowrap;
overflow-x: auto;
min-width: 200px;
}
.breadcrumb-item {
cursor: pointer;
padding: 0 5px;
}
.breadcrumb-item:hover {
color: #409eff;
}
.search-bar {
flex-shrink: 0;
}
.search-input {
padding: 8px 12px;
font-size: 14px;
border: 1px solid #ccc;
border-radius: 6px;
width: 250px;
transition: border-color 0.3s;
}
.search-input:focus {
outline: none;
border-color: #409eff;
box-shadow: 0 0 3px rgba(64, 158, 255, 0.5);
}
.file-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 20px;
position: relative;
z-index: 1;
max-height: 90vh; /* 可根据需要调整高度 */
overflow-y: auto;
padding-right: 6px; /* 避免滚动条覆盖内容 */
}
.file-item {
padding: 15px;
border: 1px solid #ebeef5;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
text-align: center;
}
.file-item:hover {
transform: translateY(-3px);
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.details {
margin-top: 8px;
font-size: 12px;
}
.details time {
color: #909399;
display: block;
margin-top: 4px;
}
.icon {
width: 64px;
height: 64px;
object-fit: contain;
}
[draggable] {
opacity: 1;
transition: opacity 0.3s;
}
[draggable]:hover {
opacity: 0.8;
}
.context-menu {
position: fixed;
background: white;
border: 1px solid #ebeef5;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
z-index: 9999;
min-width: 120px;
}
.context-menu div {
padding: 8px 15px;
cursor: pointer;
transition: background 0.3s;
}
.context-menu div:hover {
background: #f5f7fa;
}
.empty-placeholder {
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 14px;
border: 2px dashed #dcdfe6;
border-radius: 6px;
background: #f9f9f9;
cursor: context-menu;
}
@media (max-width: 768px) {
.top-bar {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.controls {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.search-input {
width: 100%;
}
.sort-container {
justify-content: space-between;
}
}
</style>
Details
代码2
将代码 3 中的片段 2 替换成下方的片段 1- 片段 1
const handleInteraction = (event) => {
const { type, x, y, target } = event.detail
if (type === 'rightClick') {
// 1. 直接信任 event.detail.target
const contextTarget = target.closest('[data-context-type]')
// 2. 没找到就什么都不做
if (!contextTarget) return
...
}
}
- 片段 2
const handleInteraction = (event) => {
const { type, x, y, target } = event.detail
if (type === 'rightClick') {
// 1. 先用坐标再找元素
const elementAtPoint = document.elementFromPoint(x, y)
// 2. 再向上找最近的 [data-context-type]
const contextTarget = elementAtPoint
? elementAtPoint.closest('[data-context-type]')
: null
// 3. 如果没找到,就当作空白区域
if (!contextTarget) {
openBlankContextMenu({ pageX: x, pageY: y })
return
}
...
}
}
代码3
<template>
<Sidebar :githubLink="'http://wwweibu.github.io/Lrobot/docs/2使用指南/8功能开发/2页面功能#wiki'"/>
<div class="file-manager" @click="closeContextMenu">
<!-- 面包屑 + 搜索框 容器 -->
<div class="top-bar">
<div class="breadcrumb">
<span
v-for="(part, index) in pathParts"
:key="index"
@click="navigateTo(index)"
class="breadcrumb-item"
>
{{ part.name }} /
</span>
</div>
<div class="controls">
<!-- 排序控件 -->
<div class="sort-container">
<select v-model="sortBy" class="sort-select">
<option value="name">名称</option>
<option value="modified">修改时间</option>
<option value="size">大小</option>
</select>
<button
@click="toggleSortOrder"
class="sort-order"
:class="{ 'desc': sortOrder === 'desc' }"
>
▼
</button>
</div>
<!-- 搜索框 -->
<div class="search-bar">
<input
v-model="searchKeyword"
@input="handleSearch"
placeholder="搜索文件名..."
class="search-input"
/>
</div>
</div>
</div>
<!-- 文件列表 -->
<div class="file-list">
<!-- 空状态 -->
<div
v-if="sortedItems.length === 0"
class="empty-placeholder"
data-context-type="blank"
>
此文件夹为空,右键可上传文件或新建文件夹
</div>
<!-- 有内容时渲染文件项 -->
<div
v-for="item in sortedItems"
:key="item.path"
class="file-item"
:draggable="true"
:data-context-type="'file'"
:data-file-path="item.path"
:data-file-name="item.name"
:data-is-dir="item.is_dir"
@dragstart="handleDragStart(item)"
@dragover.prevent="handleDragOver"
@drop="handleDrop(item)"
@click="handleItemClick(item)"
>
<img :src="getIconForItem(item)" class="icon" />
<div class="details">
<span>{{ item.name }}</span>
<time>{{ formatDate(item.modified) }}</time>
</div>
</div>
</div>
<!-- 右键菜单 -->
<div
v-if="contextMenu.visible"
class="context-menu"
:style="{ top: contextMenu.y + 'px', left: contextMenu.x + 'px' }"
>
<template v-if="contextMenu.target">
<div @click="handleDownload">下载</div>
<div @click="startRename">重命名</div>
<div @click="startMove">移动到...</div>
<div @click="moveToRoot">移动至根目录</div>
<div @click.stop="handleDelete">删除</div>
</template>
<template v-else>
<div @click="triggerFileUpload">上传文件</div>
<div @click="triggerFolderUpload">上传文件夹</div>
<div @click="createNewFolder">新建文件夹</div>
</template>
</div>
<!-- 隐藏上传控件 -->
<input
type="file"
ref="fileInput"
@change="handleFileUpload"
multiple
style="display: none"
>
<input
type="file"
ref="folderInput"
@change="handleFolderUpload"
webkitdirectory
style="display: none"
>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { http } from '@/api.js'
import Sidebar from './Sidebar.vue'
let loadDataReqId = 0
// 排序相关状态
const sortBy = ref('name')
const sortOrder = ref('desc')
// 排序后的文件列表
const sortedItems = computed(() => {
return [...items.value].sort((a, b) => {
let compareValue = 0
if (sortBy.value === 'name') {
compareValue = a.name.localeCompare(b.name)
}
else if (sortBy.value === 'modified') {
compareValue = new Date(a.modified) - new Date(b.modified)
}
else if (sortBy.value === 'size') {
compareValue = a.size - b.size
}
return sortOrder.value === 'asc' ? compareValue : -compareValue
})
})
// 切换排序顺序
const toggleSortOrder = () => {
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
}
// 文件管理器核心逻辑
const currentPath = ref('none')
const items = ref([])
const draggingItem = ref(null)
const contextMenu = ref({
visible: false,
x: 0,
y: 0,
target: null
})
const fileInput = ref(null)
const folderInput = ref(null)
const searchKeyword = ref('')
// 文件图标配置
const iconMap = {
'pdf': '/icons/pdf.png',
'doc': '/icons/word.png',
'docx': '/icons/word.png',
'xls': '/icons/excel.png',
'xlsx': '/icons/excel.png',
'ppt': '/icons/ppt.png',
'pptx': '/icons/ppt.png',
'zip': '/icons/zip.png',
'rar': '/icons/zip.png',
'txt': '/icons/txt.png',
'jpg': '/icons/image.png',
'jpeg': '/icons/image.png',
'png': '/icons/image.png',
'gif': '/icons/image.png',
'mp4': '/icons/video.png',
'mp3': '/icons/audio.png',
'md': '/icons/markdown.png',
'default': '/icons/file.png'
}
const folderIcon = '/icons/folder.png'
// 统一的交互事件处理函数
const handleInteraction = (event) => {
const { type, x, y, target } = event.detail
if (type === 'rightClick') {
console.log('收到 rightClick 事件:', { type, x, y, target })
// 使用坐标来确定目标元素,而不依赖传入的 target
const elementAtPoint = document.elementFromPoint(x, y)
// 查找最近的具有 data-context-type 属性的元素
const contextTarget = elementAtPoint ? elementAtPoint.closest('[data-context-type]') : null
if (!contextTarget) {
// 如果没找到特定目标,默认当作空白区域处理
const mockEvent = { pageX: x, pageY: y }
openBlankContextMenu(mockEvent)
return
}
const contextType = contextTarget.getAttribute('data-context-type')
if (contextType === 'file') {
// 处理文件项右键
const filePath = contextTarget.getAttribute('data-file-path')
const fileName = contextTarget.getAttribute('data-file-name')
const isDir = contextTarget.getAttribute('data-is-dir') === 'true'
const fileItem = {
path: filePath,
name: fileName,
is_dir: isDir
}
// 创建模拟的事件对象
const mockEvent = { pageX: x, pageY: y }
openContextMenu(mockEvent, fileItem)
} else if (contextType === 'blank') {
// 处理空白区域右键
const mockEvent = { pageX: x, pageY: y }
openBlankContextMenu(mockEvent)
}
}
}
// 初始化加载
onMounted(() => {
loadData(currentPath.value)
// 添加统一的交互事件监听器
window.addEventListener('interaction', handleInteraction)
})
// 组件卸载时移除事件监听器
onUnmounted(() => {
window.removeEventListener('interaction', handleInteraction)
})
// 文件图标获取
const getIconForItem = (item) => {
if (item.is_dir) return folderIcon
const ext = item.name.split('.').pop().toLowerCase()
return iconMap[ext] || iconMap['default']
}
// 数据加载
const loadData = async (path) => {
const reqId = ++loadDataReqId
try {
const res = await http.get(`/browse/${encodeURIComponent(path || 'none')}`,{timeout:15000})
if (reqId !== loadDataReqId) return
items.value = res.data.items
} catch (error) {
if (reqId !== loadDataReqId) return
console.error('Error loading directory:', error)
}
}
// 路径处理
const pathParts = computed(() => {
const parts = currentPath.value === 'none' ? [] : currentPath.value.split('/')
return parts.reduce((acc, part, index) => {
if (part) {
acc.push({
name: part,
path: parts.slice(0, index + 1).join('/')
})
}
return acc
}, [{ name: '网盘', path: 'none' }])
})
// 导航功能
const navigateTo = (index) => {
const target = pathParts.value[index]
currentPath.value = target.path
loadData(currentPath.value)
}
// 右键菜单处理
const openBlankContextMenu = (e) => {
contextMenu.value = {
visible: true,
x: e.pageX,
y: e.pageY,
target: null
}
}
const openContextMenu = (e, target) => {
contextMenu.value = {
visible: true,
x: e.pageX,
y: e.pageY,
target
}
}
const closeContextMenu = () => {
contextMenu.value.visible = false
}
// 文件操作方法
const startRename = async () => {
const item = contextMenu.value.target
const newName = prompt('输入新名称', item.name)
if (newName) {
try {
await http.put('/rename', {
old_path: item.path,
new_path: newName
})
loadData(currentPath.value)
} catch (error) {
alert('重命名失败: ' + error.response?.data?.detail || error.message || '网络异常,请稍后重试')
}
}
}
const handleDelete = async () => {
const item = contextMenu.value.target
if (!confirm(`确定要永久删除 ${item.name} 吗?`)) return
try {
await http.delete('/files', {
data: { path: item.path }
})
alert('删除成功')
loadData(currentPath.value)
} catch (error) {
alert(`删除失败: ${error.response?.data?.detail || error.message || '网络异常,请稍后重试'}`)
} finally {
contextMenu.value.visible = false
}
}
const moveToRoot = async () => {
const item = contextMenu.value.target
try {
await http.post('/move', {
src_path: item.path,
dst_path: item.name
})
loadData(currentPath.value)
alert('移动成功')
} catch (error) {
alert('移动失败: ' + error.response?.data?.detail || error.message || '网络异常,请稍后重试')
} finally {
contextMenu.value.visible = false
}
}
// 下载逻辑
const handleDownload = async () => {
const item = contextMenu.value.target
try {
const response = await http.get(`/download/${encodeURIComponent(item.path)}`, {
responseType: 'blob'
})
const url = window.URL.createObjectURL(new Blob([response.data]))
const link = document.createElement('a')
link.href = url
link.setAttribute('download', item.name)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
} catch (error) {
alert('下载失败: ' + error.response?.data?.detail || error.message || '网络异常,请稍后重试')
}
}
// 拖拽功能
const handleDragStart = (item) => {
draggingItem.value = item
}
const handleDragOver = (e) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
}
const handleDrop = async (target) => {
if (draggingItem.value && target.is_dir) {
try {
await http.post('/move', {
src_path: draggingItem.value.path,
dst_path: `${target.path}/${draggingItem.value.name}`
})
loadData(currentPath.value)
} catch (error) {
alert('移动失败: ' + error.response?.data?.detail || error.message || '网络异常,请稍后重试')
}
}
draggingItem.value = null
}
// 新建文件夹
const createNewFolder = async () => {
const folderName = prompt('输入文件夹名称')
if (folderName) {
try {
await http.post('/folders', {
path: `${currentPath.value === 'none' ? '' : currentPath.value}/${folderName}`
})
loadData(currentPath.value)
} catch (error) {
alert('创建失败: ' + error.response?.data?.detail || error.message || '网络异常,请稍后重试')
}
}
}
// 移动功能
const startMove = async () => {
const item = contextMenu.value.target
const targetPath = prompt('输入目标路径', currentPath.value)
if (targetPath) {
try {
await http.post('/move', {
src_path: item.path,
dst_path: targetPath
})
loadData(currentPath.value)
} catch (error) {
alert('移动失败: ' + error.response?.data?.detail || error.message || '网络异常,请稍后重试')
}
}
}
// 上传处理
const triggerFileUpload = () => {
contextMenu.value.visible = false
fileInput.value.click()
}
const triggerFolderUpload = () => {
contextMenu.value.visible = false
folderInput.value.click()
}
const handleFileUpload = async (e) => {
const files = e.target.files
if (files.length === 0) return
try {
const formData = new FormData()
const basePath = currentPath.value === 'none' ? '' : currentPath.value
for (let i = 0; i < files.length; i++) {
formData.append('files', files[i])
}
formData.append('paths', basePath)
await http.post('/files', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
loadData(currentPath.value)
} catch (error) {
alert('上传失败: ' + error.response?.data?.detail || error.message || '网络异常,请稍后重试')
} finally {
e.target.value = ''
}
}
const handleFolderUpload = async (e) => {
const files = e.target.files
if (files.length === 0) return
try {
const formData = new FormData()
const basePath = currentPath.value === 'none' ? '' : currentPath.value
Array.from(files).forEach(file => {
const relativePath = file.webkitRelativePath || file.name
const fullPath = basePath ? `${basePath}/${relativePath}` : relativePath
formData.append('paths', fullPath)
formData.append('files', file)
})
await http.post('/file_folders', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
loadData(currentPath.value)
alert('文件夹上传成功')
} catch (error) {
alert('上传失败: ' + (error.response?.data?.detail || error.message || '网络异常,请稍后重试'))
} finally {
e.target.value = ''
}
}
// 时间格式化
const formatDate = (isoString) => {
const date = new Date(isoString)
return `${date.toLocaleDateString()} ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
}
// 处理文件点击
const handleItemClick = (item) => {
if (item.is_dir) {
currentPath.value = item.path
loadData(currentPath.value)
} else {
const previewPath = `/cab/preview/${encodeURIComponent(item.path)}`
window.open(previewPath, '_blank')
}
}
// 搜索逻辑
const handleSearch = async () => {
const keyword = searchKeyword.value.trim()
if (keyword === '') {
loadData(currentPath.value)
return
}
try {
const res = await http.get('/search', {
params: {
path: currentPath.value,
keyword
},
timeout: 60000
})
items.value = res.data.items
} catch (error) {
alert('搜索失败: ' + error.response?.data?.detail || error.message || '网络异常,请稍后重试')
}
}
</script>
<style scoped>
.file-manager {
padding: 20px;
min-height: 100vh;
}
@media (min-width: 768px) {
.file-manager {
margin-top: 40px; /* Sidebar 高度 */
}
}
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
gap: 20px;
}
.controls {
display: flex;
align-items: center;
gap: 12px;
}
.sort-container {
display: flex;
align-items: center;
background: #f5f5f5;
border-radius: 4px;
padding: 4px;
}
.sort-select {
padding: 6px 12px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: white;
outline: none;
cursor: pointer;
}
.sort-order {
margin-left: 8px;
cursor: pointer;
background: none;
border: none;
transform: rotate(0deg);
transition: transform 0.2s;
font-size: 12px;
padding: 4px 8px;
}
.sort-order.desc {
transform: rotate(180deg);
}
.breadcrumb {
background: #f5f5f5;
padding: 10px;
border-radius: 4px;
flex-grow: 1;
white-space: nowrap;
overflow-x: auto;
min-width: 200px;
}
.breadcrumb-item {
cursor: pointer;
padding: 0 5px;
}
.breadcrumb-item:hover {
color: #409eff;
}
.search-bar {
flex-shrink: 0;
}
.search-input {
padding: 8px 12px;
font-size: 14px;
border: 1px solid #ccc;
border-radius: 6px;
width: 250px;
transition: border-color 0.3s;
}
.search-input:focus {
outline: none;
border-color: #409eff;
box-shadow: 0 0 3px rgba(64, 158, 255, 0.5);
}
.file-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 20px;
position: relative;
z-index: 1;
max-height: 90vh; /* 可根据需要调整高度 */
overflow-y: auto;
padding-right: 6px; /* 避免滚动条覆盖内容 */
}
.file-item {
padding: 15px;
border: 1px solid #ebeef5;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
text-align: center;
}
.file-item:hover {
transform: translateY(-3px);
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.details {
margin-top: 8px;
font-size: 12px;
}
.details time {
color: #909399;
display: block;
margin-top: 4px;
}
.icon {
width: 64px;
height: 64px;
object-fit: contain;
}
[draggable] {
opacity: 1;
transition: opacity 0.3s;
}
[draggable]:hover {
opacity: 0.8;
}
.context-menu {
position: fixed;
background: white;
border: 1px solid #ebeef5;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
z-index: 9999;
min-width: 120px;
}
.context-menu div {
padding: 8px 15px;
cursor: pointer;
transition: background 0.3s;
}
.context-menu div:hover {
background: #f5f7fa;
}
.empty-placeholder {
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 14px;
border: 2px dashed #dcdfe6;
border-radius: 6px;
background: #f9f9f9;
cursor: context-menu;
}
@media (max-width: 768px) {
.top-bar {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.controls {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.search-input {
width: 100%;
}
.sort-container {
justify-content: space-between;
}
}
</style>
- 其中 1 是绑定右键菜单 contextMenu 的,2 是绑定页面元素 event.detail.target 的,3 是通过坐标直接计算容器的
- 推测可能是夸克为了实现智能助手的相关逻辑,删除了 windows 中跟右键相关的触发事件、元素,定义了自己的一套实现方式,故此类方法失效
- 修复后可触发右键菜单(但智能助手也会出现,需要点一下消失)
关于腾讯收集表自动导出
- 使用 ExcelJS 无法解析 xls,需要后端转换成 xlsx
- 解析腾讯收集表时发现解析失败
- 猜测可能是伪装成 xlsx 的 xls,改名成 xls,再次访问,经过后端转换后成功访问
- 结论:腾讯收集表导出时直接把 xls 改名成了 xlsx,平时使用 excel 打开时由于二者兼容,所以感受不到区别