feat(desktop): square file cards and context-menu file actions
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user