feat(desktop): square file cards and context-menu file actions

This commit is contained in:
2026-02-18 18:30:53 +08:00
parent 3c483d2093
commit 9da90f38cc
2 changed files with 210 additions and 162 deletions

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
@@ -70,13 +70,11 @@ const shares = ref<ShareItem[]>([]);
const transferTasks = ref<{ id: string; name: string; speed: string; progress: number; status: string }[]>([]);
const sharesLoading = ref(false);
const shareCreate = reactive({
creating: false,
expiryPreset: "never" as "never" | "1" | "7" | "30" | "custom",
customDays: 7,
enablePassword: false,
password: "",
const contextMenu = reactive({
visible: false,
x: 0,
y: 0,
item: null as FileItem | null,
});
const toast = reactive({
@@ -239,16 +237,6 @@ function getShareExpireLabel(value: string | null | undefined) {
return expired ? `已过期 · ${text}` : text;
}
function resolveShareExpiryDays() {
if (shareCreate.expiryPreset === "never") return null;
if (shareCreate.expiryPreset === "custom") {
const days = Number(shareCreate.customDays);
if (!Number.isFinite(days) || days < 1 || days > 365) return null;
return Math.floor(days);
}
return Number(shareCreate.expiryPreset);
}
async function copyText(text: string, successMessage: string) {
try {
if (navigator.clipboard?.writeText) {
@@ -340,37 +328,18 @@ async function loadShares(silent = false) {
if (!silent) sharesLoading.value = false;
}
async function createShareFromSelected() {
const current = selectedFile.value;
if (!current) {
showToast("请先在文件视图选中一个文件或文件夹", "info");
return;
}
const expiryDays = resolveShareExpiryDays();
if (shareCreate.expiryPreset === "custom" && !expiryDays) {
showToast("自定义有效期需要 1-365 天", "error");
return;
}
const password = shareCreate.enablePassword ? shareCreate.password.trim() : "";
if (shareCreate.enablePassword && !password) {
showToast("请填写分享密码", "error");
return;
}
shareCreate.creating = true;
async function createShareForItem(current: FileItem) {
const response = await invokeBridge("api_create_share", {
baseUrl: appConfig.baseUrl,
shareType: current.isDirectory || current.type === "directory" ? "directory" : "file",
filePath: buildItemPath(current),
fileName: current.displayName || current.name,
password: shareCreate.enablePassword ? password : null,
expiryDays,
password: null,
expiryDays: null,
});
shareCreate.creating = false;
if (response.ok && response.data?.success) {
showToast("分享创建成功", "success");
shareCreate.password = "";
await loadShares(true);
const shareUrl = String(response.data.share_url || "");
if (shareUrl) {
@@ -382,6 +351,31 @@ async function createShareFromSelected() {
showToast(response.data?.message || "创建分享失败", "error");
}
async function createDirectLinkForItem(current: FileItem) {
if (current.isDirectory || current.type === "directory") {
showToast("文件夹不支持生成直链", "info");
return;
}
const response = await invokeBridge("api_create_direct_link", {
baseUrl: appConfig.baseUrl,
filePath: buildItemPath(current),
fileName: current.displayName || current.name,
expiryDays: null,
});
if (response.ok && response.data?.success) {
showToast("直链创建成功", "success");
const directUrl = String(response.data.direct_url || "");
if (directUrl) {
await copyText(directUrl, "直链已复制");
}
return;
}
showToast(response.data?.message || "创建直链失败", "error");
}
async function deleteShare(share: ShareItem) {
const confirmed = window.confirm(`确认删除分享 ${share.share_code} 吗?`);
if (!confirmed) return;
@@ -523,8 +517,8 @@ async function createFolder() {
showToast(response.data?.message || "创建文件夹失败", "error");
}
async function renameSelected() {
const current = selectedFile.value;
async function renameSelected(target?: FileItem | null) {
const current = target || selectedFile.value;
if (!current) {
showToast("请先选中文件或文件夹", "info");
return;
@@ -545,8 +539,8 @@ async function renameSelected() {
showToast(response.data?.message || "重命名失败", "error");
}
async function deleteSelected() {
const current = selectedFile.value;
async function deleteSelected(target?: FileItem | null) {
const current = target || selectedFile.value;
if (!current) {
showToast("请先选中文件或文件夹", "info");
return;
@@ -567,8 +561,8 @@ async function deleteSelected() {
showToast(response.data?.message || "删除失败", "error");
}
async function downloadSelected() {
const current = selectedFile.value;
async function downloadSelected(target?: FileItem | null) {
const current = target || selectedFile.value;
if (!current) {
showToast("请先选中文件", "info");
return;
@@ -617,6 +611,59 @@ async function jumpToPath(nextPath: string) {
await loadFiles(nextPath);
}
function closeContextMenu() {
contextMenu.visible = false;
contextMenu.item = null;
}
function openContextMenu(event: MouseEvent, item: FileItem) {
selectFile(item);
contextMenu.item = item;
const maxX = window.innerWidth - 220;
const maxY = window.innerHeight - 260;
contextMenu.x = Math.max(8, Math.min(event.clientX, maxX));
contextMenu.y = Math.max(8, Math.min(event.clientY, maxY));
contextMenu.visible = true;
}
async function executeContextAction(action: "open" | "download" | "rename" | "delete" | "share" | "direct") {
const item = contextMenu.item;
closeContextMenu();
if (!item) return;
if (action === "open") {
await openItem(item);
return;
}
if (action === "download") {
await downloadSelected(item);
return;
}
if (action === "rename") {
await renameSelected(item);
return;
}
if (action === "delete") {
await deleteSelected(item);
return;
}
if (action === "share") {
await createShareForItem(item);
return;
}
await createDirectLinkForItem(item);
}
function handleGlobalClick() {
if (contextMenu.visible) closeContextMenu();
}
function handleGlobalKey(event: KeyboardEvent) {
if (event.key === "Escape" && contextMenu.visible) {
closeContextMenu();
}
}
watch(nav, async (next) => {
if (next === "shares" && authenticated.value) {
await loadShares();
@@ -624,8 +671,15 @@ watch(nav, async (next) => {
});
onMounted(async () => {
window.addEventListener("click", handleGlobalClick);
window.addEventListener("keydown", handleGlobalKey);
await restoreSession();
});
onBeforeUnmount(() => {
window.removeEventListener("click", handleGlobalClick);
window.removeEventListener("keydown", handleGlobalKey);
});
</script>
<template>
@@ -731,9 +785,6 @@ onMounted(async () => {
/>
<button class="action-btn" @click="runGlobalSearch">搜索</button>
<button class="action-btn" @click="createFolder">新建文件夹</button>
<button class="action-btn" @click="renameSelected">重命名</button>
<button class="action-btn danger" @click="deleteSelected">删除</button>
<button class="solid-btn" @click="downloadSelected">下载</button>
<button class="action-btn" @click="loadFiles(pathState.currentPath)">刷新</button>
</template>
<template v-else-if="nav === 'shares'">
@@ -762,6 +813,7 @@ onMounted(async () => {
:class="{ selected: selectedFileName === item.name }"
@click="selectFile(item)"
@dblclick="openItem(item)"
@contextmenu.prevent="openContextMenu($event, item)"
>
<div class="file-icon-glyph">{{ fileIcon(item) }}</div>
<div class="file-name" :title="item.displayName || item.name">{{ item.displayName || item.name }}</div>
@@ -796,50 +848,7 @@ onMounted(async () => {
<template v-else-if="nav === 'shares'">
<div class="panel-head">
<h3>我的分享</h3>
<span>创建查看复制与删除</span>
</div>
<div class="share-create-toolbar">
<div class="share-target">
当前选中
<strong>{{ selectedFile ? (selectedFile.displayName || selectedFile.name) : "未选择文件/文件夹" }}</strong>
</div>
<div class="share-create-controls">
<label class="compact-field">
有效期
<select v-model="shareCreate.expiryPreset">
<option value="never">永久</option>
<option value="1">1 </option>
<option value="7">7 </option>
<option value="30">30 </option>
<option value="custom">自定义</option>
</select>
</label>
<label v-if="shareCreate.expiryPreset === 'custom'" class="compact-field">
天数
<input v-model.number="shareCreate.customDays" type="number" min="1" max="365" />
</label>
<label class="inline-check">
<input v-model="shareCreate.enablePassword" type="checkbox" />
启用密码
</label>
<input
v-if="shareCreate.enablePassword"
v-model="shareCreate.password"
type="text"
class="password-input"
maxlength="32"
placeholder="输入访问密码"
/>
<button class="solid-btn" :disabled="shareCreate.creating" @click="createShareFromSelected">
{{ shareCreate.creating ? "创建中..." : "创建分享" }}
</button>
</div>
<span>仅展示已分享文件及操作</span>
</div>
<div v-if="sharesLoading" class="empty-tip">正在加载分享列表...</div>
@@ -904,6 +913,24 @@ onMounted(async () => {
</section>
</div>
<div v-if="contextMenu.visible" class="context-mask" @click="closeContextMenu"></div>
<div
v-if="contextMenu.visible && contextMenu.item"
class="context-menu"
:style="{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }"
@click.stop
>
<button class="context-item" @click="executeContextAction('open')">
{{ contextMenu.item.isDirectory ? "打开文件夹" : "打开/下载" }}
</button>
<button v-if="!contextMenu.item.isDirectory" class="context-item" @click="executeContextAction('download')">下载文件</button>
<button class="context-item" @click="executeContextAction('rename')">重命名</button>
<button class="context-item danger" @click="executeContextAction('delete')">删除</button>
<div class="context-divider"></div>
<button class="context-item" @click="executeContextAction('share')">生成分享链接</button>
<button v-if="!contextMenu.item.isDirectory" class="context-item" @click="executeContextAction('direct')">生成直链</button>
</div>
<div v-if="toast.visible" class="toast" :class="toast.type">{{ toast.message }}</div>
</div>
</template>
@@ -1321,7 +1348,7 @@ select:focus {
.icon-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
align-content: start;
min-height: 0;
overflow: auto;
@@ -1337,8 +1364,11 @@ select:focus {
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: 4px;
min-height: 122px;
width: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
}
.file-card:hover {
@@ -1353,7 +1383,7 @@ select:focus {
}
.file-icon-glyph {
font-size: 30px;
font-size: 28px;
line-height: 1;
margin-bottom: 6px;
}
@@ -1423,71 +1453,6 @@ select:focus {
background: linear-gradient(90deg, #1d6fff, #57a2ff);
}
.share-create-toolbar {
border: 1px solid #d8e1ee;
border-radius: 12px;
background: #f8fbff;
padding: 10px;
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 10px;
}
.share-target {
font-size: 12px;
color: #4d6683;
}
.share-target strong {
color: #213247;
}
.share-create-controls {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: flex-end;
}
.compact-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: #4d6683;
min-width: 92px;
}
.compact-field select,
.compact-field input {
height: 34px;
border-radius: 10px;
font-size: 13px;
}
.inline-check {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #4d6683;
margin-right: 4px;
}
.inline-check input[type="checkbox"] {
width: 16px;
height: 16px;
margin: 0;
}
.password-input {
width: 160px;
height: 34px;
border-radius: 10px;
font-size: 13px;
}
.share-list {
display: flex;
flex-direction: column;
@@ -1557,6 +1522,50 @@ select:focus {
gap: 6px;
}
.context-mask {
position: fixed;
inset: 0;
z-index: 80;
}
.context-menu {
position: fixed;
z-index: 90;
width: 210px;
border: 1px solid #d1dced;
border-radius: 12px;
background: #ffffff;
box-shadow: 0 12px 24px rgba(25, 52, 86, 0.2);
padding: 6px;
}
.context-item {
width: 100%;
height: 34px;
border: 0;
border-radius: 8px;
background: transparent;
text-align: left;
padding: 0 10px;
font-size: 13px;
color: #2a4664;
cursor: pointer;
}
.context-item:hover {
background: #eef4ff;
}
.context-item.danger {
color: #b53f3f;
}
.context-divider {
height: 1px;
background: #e5edf7;
margin: 4px 6px;
}
.detail-panel h3 {
margin: 0;
font-size: 16px;