feat(desktop): implement share management and hide endpoint settings

This commit is contained in:
2026-02-18 17:17:49 +08:00
parent e343f6ac2a
commit 3c483d2093
2 changed files with 585 additions and 144 deletions

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
type NavKey = "files" | "transfers" | "shares" | "settings";
type NavKey = "files" | "transfers" | "shares";
type FileItem = {
name: string;
@@ -16,6 +16,20 @@ type FileItem = {
isDirectory?: boolean;
};
type ShareItem = {
id: number;
share_code: string;
share_url: string;
share_path: string;
share_type: "file" | "directory";
has_password?: boolean;
view_count?: number;
download_count?: number;
created_at?: string;
expires_at?: string | null;
storage_type?: string;
};
type BridgeResponse = {
ok: boolean;
status: number;
@@ -27,7 +41,7 @@ const authenticated = ref(false);
const user = ref<Record<string, any> | null>(null);
const appConfig = reactive({
baseUrl: localStorage.getItem("desktop_api_base") || "https://cs.workyai.cn",
baseUrl: "https://cs.workyai.cn",
});
const loginForm = reactive({
@@ -52,12 +66,18 @@ const pathState = reactive({
const files = ref<FileItem[]>([]);
const selectedFileName = ref("");
const searchKeyword = ref("");
const shares = ref<ShareItem[]>([]);
const transferTasks = ref([
{ id: "T-20260218-01", name: "需求说明-v3.pdf", speed: "3.5 MB/s", progress: 83, status: "downloading" },
{ id: "T-20260218-02", name: "发布包-2026-02-17.zip", speed: "1.2 MB/s", progress: 42, status: "uploading" },
{ id: "T-20260218-03", name: "工单导出.xlsx", speed: "-", progress: 100, status: "done" },
]);
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 toast = reactive({
visible: false,
@@ -69,10 +89,17 @@ let toastTimer: ReturnType<typeof setTimeout> | null = null;
const navItems = computed(() => [
{ key: "files" as const, label: "全部文件", hint: `${files.value.length}` },
{ key: "transfers" as const, label: "传输列表", hint: `${transferTasks.value.length} 个任务` },
{ key: "shares" as const, label: "我的分享", hint: "分享管理" },
{ key: "settings" as const, label: "客户端设置", hint: "连接与偏好" },
{ key: "shares" as const, label: "我的分享", hint: `${shares.value.length}` },
]);
const sortedShares = computed(() => {
return [...shares.value].sort((a, b) => {
const ta = new Date(a.created_at || 0).getTime();
const tb = new Date(b.created_at || 0).getTime();
return tb - ta;
});
});
const filteredFiles = computed(() => {
const key = searchKeyword.value.trim().toLowerCase();
if (!key) return files.value;
@@ -193,6 +220,58 @@ function fileIcon(item: FileItem) {
return "📄";
}
function normalizeSharePath(rawPath: string | undefined) {
const normalized = normalizePath(rawPath || "/");
return normalized;
}
function getShareDisplayName(share: ShareItem) {
const normalized = normalizeSharePath(share.share_path);
if (normalized === "/") return "全部文件";
const segments = normalized.split("/").filter(Boolean);
return segments[segments.length - 1] || normalized;
}
function getShareExpireLabel(value: string | null | undefined) {
if (!value) return "永久有效";
const text = formatDate(value);
const expired = new Date(value).getTime() <= Date.now();
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) {
await navigator.clipboard.writeText(text);
showToast(successMessage, "success");
return;
}
} catch {
// fall through to legacy copy path
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const ok = document.execCommand("copy");
document.body.removeChild(textarea);
showToast(ok ? successMessage : "复制失败,请手动复制", ok ? "success" : "error");
}
function showToast(message: string, type = "info") {
toast.message = message;
toast.type = type;
@@ -248,11 +327,100 @@ async function loadFiles(targetPath = pathState.currentPath) {
pathState.loading = false;
}
async function loadShares(silent = false) {
if (!silent) sharesLoading.value = true;
const response = await invokeBridge("api_get_my_shares", {
baseUrl: appConfig.baseUrl,
});
if (response.ok && response.data?.success) {
shares.value = Array.isArray(response.data.shares) ? response.data.shares : [];
} else if (!silent) {
showToast(response.data?.message || "获取分享列表失败", "error");
}
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;
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,
});
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) {
await copyText(shareUrl, "分享链接已复制");
}
return;
}
showToast(response.data?.message || "创建分享失败", "error");
}
async function deleteShare(share: ShareItem) {
const confirmed = window.confirm(`确认删除分享 ${share.share_code} 吗?`);
if (!confirmed) return;
const response = await invokeBridge("api_delete_share", {
baseUrl: appConfig.baseUrl,
shareId: share.id,
});
if (response.ok && response.data?.success) {
showToast("分享已删除", "success");
await loadShares(true);
return;
}
showToast(response.data?.message || "删除分享失败", "error");
}
async function copyShareLink(share: ShareItem) {
const url = String(share.share_url || "").trim();
if (!url) {
showToast("分享链接为空", "error");
return;
}
await copyText(url, "链接已复制");
}
async function openShareLink(share: ShareItem) {
const url = String(share.share_url || "").trim();
if (!url) {
showToast("分享链接为空", "error");
return;
}
await openUrl(url);
}
async function restoreSession() {
const ok = await loadProfile();
if (!ok) return;
authenticated.value = true;
await loadFiles("/");
await loadShares(true);
}
function buildItemPath(item: FileItem) {
@@ -338,20 +506,6 @@ async function handleLogout() {
showToast("已退出客户端", "info");
}
async function applySettings() {
const nextUrl = appConfig.baseUrl.trim().replace(/\/+$/, "");
if (!nextUrl.startsWith("http://") && !nextUrl.startsWith("https://")) {
showToast("API 地址必须以 http:// 或 https:// 开头", "error");
return;
}
appConfig.baseUrl = nextUrl;
localStorage.setItem("desktop_api_base", nextUrl);
showToast("已保存服务地址", "success");
if (authenticated.value) {
await restoreSession();
}
}
async function createFolder() {
if (pathState.loading) return;
const folderName = window.prompt("请输入新文件夹名称");
@@ -463,6 +617,12 @@ async function jumpToPath(nextPath: string) {
await loadFiles(nextPath);
}
watch(nav, async (next) => {
if (next === "shares" && authenticated.value) {
await loadShares();
}
});
onMounted(async () => {
await restoreSession();
});
@@ -486,7 +646,7 @@ onMounted(async () => {
</div>
<div class="brand-card">
<strong>独立工作台</strong>
<span>文件分享设置一体化</span>
<span>文件分享传输一体化</span>
</div>
<div class="brand-card">
<strong>可接现有后端</strong>
@@ -497,10 +657,6 @@ onMounted(async () => {
<section class="login-panel">
<h2>登录云盘</h2>
<label>
服务地址
<input v-model="appConfig.baseUrl" type="text" placeholder="https://cs.workyai.cn" />
</label>
<label>
用户名
<input v-model="loginForm.username" type="text" placeholder="请输入账号" />
@@ -565,19 +721,24 @@ onMounted(async () => {
</button>
</div>
<div class="tool-right">
<input
v-model="searchKeyword"
type="text"
class="search-input"
placeholder="输入关键词回车全局搜索..."
@keyup.enter="runGlobalSearch"
/>
<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 v-if="nav === 'files'">
<input
v-model="searchKeyword"
type="text"
class="search-input"
placeholder="输入关键词回车全局搜索..."
@keyup.enter="runGlobalSearch"
/>
<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'">
<button class="action-btn" @click="loadShares()">刷新分享</button>
</template>
</div>
</header>
@@ -592,31 +753,21 @@ onMounted(async () => {
<div v-if="pathState.loading" class="empty-tip">正在加载目录...</div>
<div v-else-if="pathState.error" class="empty-tip error">{{ pathState.error }}</div>
<div v-else-if="filteredFiles.length === 0" class="empty-tip">当前目录暂无文件</div>
<div v-else class="table-wrap">
<table>
<thead>
<tr>
<th>名称</th>
<th>类型</th>
<th>大小</th>
<th>修改时间</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in filteredFiles"
:key="item.name"
:class="{ selected: selectedFileName === item.name }"
@click="selectFile(item)"
@dblclick="openItem(item)"
>
<td class="name-cell"><span class="icon">{{ fileIcon(item) }}</span>{{ item.displayName || item.name }}</td>
<td>{{ fileTypeLabel(item) }}</td>
<td>{{ item.isDirectory ? "-" : (item.sizeFormatted || formatBytes(item.size)) }}</td>
<td>{{ formatDate(item.modifiedAt) }}</td>
</tr>
</tbody>
</table>
<div v-else class="icon-grid">
<button
v-for="item in filteredFiles"
:key="item.name"
type="button"
class="file-card"
:class="{ selected: selectedFileName === item.name }"
@click="selectFile(item)"
@dblclick="openItem(item)"
>
<div class="file-icon-glyph">{{ fileIcon(item) }}</div>
<div class="file-name" :title="item.displayName || item.name">{{ item.displayName || item.name }}</div>
<div class="file-meta">{{ fileTypeLabel(item) }} · {{ item.isDirectory ? "-" : (item.sizeFormatted || formatBytes(item.size)) }}</div>
<div class="file-time">{{ formatDate(item.modifiedAt) }}</div>
</button>
</div>
</template>
@@ -625,7 +776,8 @@ onMounted(async () => {
<h3>传输任务</h3>
<span>上传/下载队列</span>
</div>
<div class="task-list">
<div v-if="transferTasks.length === 0" class="empty-tip">暂无传输任务</div>
<div v-else class="task-list">
<div v-for="task in transferTasks" :key="task.id" class="task-row">
<div>
<strong>{{ task.name }}</strong>
@@ -643,25 +795,77 @@ onMounted(async () => {
<template v-else-if="nav === 'shares'">
<div class="panel-head">
<h3>分享管理</h3>
<span>桌面端下阶段接入分享创建与回收</span>
<h3>我的分享</h3>
<span>创建查看复制与删除</span>
</div>
<div class="placeholder-card">
<p>当前版本已预留分享模块位下一步会接入分享链接列表和直链复制</p>
</div>
</template>
<template v-else>
<div class="panel-head">
<h3>客户端设置</h3>
<span>连接地址与基础偏好</span>
<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>
</div>
<div class="settings-form">
<label>
API 服务地址
<input v-model="appConfig.baseUrl" type="text" placeholder="https://cs.workyai.cn" />
</label>
<button class="solid-btn" @click="applySettings">保存设置</button>
<div v-if="sharesLoading" class="empty-tip">正在加载分享列表...</div>
<div v-else-if="sortedShares.length === 0" class="empty-tip">暂无分享记录</div>
<div v-else class="share-list">
<div v-for="share in sortedShares" :key="share.id" class="share-item">
<div class="share-main">
<div class="share-title">
<strong :title="share.share_path">{{ getShareDisplayName(share) }}</strong>
<span class="share-badge">{{ share.share_type === "directory" ? "文件夹" : "文件" }}</span>
<span class="share-badge">{{ share.has_password ? "密码保护" : "公开" }}</span>
</div>
<div class="share-link" :title="share.share_url">{{ share.share_url }}</div>
<div class="share-meta">
<span>分享码 {{ share.share_code }}</span>
<span>访问 {{ share.view_count || 0 }}</span>
<span>下载 {{ share.download_count || 0 }}</span>
<span>到期 {{ getShareExpireLabel(share.expires_at) }}</span>
</div>
</div>
<div class="share-actions">
<button class="action-btn" @click="openShareLink(share)">打开</button>
<button class="action-btn" @click="copyShareLink(share)">复制</button>
<button class="action-btn danger" @click="deleteShare(share)">删除</button>
</div>
</div>
</div>
</template>
</section>
@@ -815,7 +1019,10 @@ label {
color: #435872;
}
input {
input[type="text"],
input[type="password"],
input[type="number"],
input[type="email"] {
width: 100%;
height: 42px;
border: 1px solid #d6dfe9;
@@ -826,7 +1033,22 @@ input {
background: #fff;
}
input:focus {
select {
width: 100%;
height: 42px;
border: 1px solid #d6dfe9;
border-radius: 12px;
padding: 0 12px;
font-size: 14px;
color: #1f2b3a;
background: #fff;
}
input[type="text"]:focus,
input[type="password"]:focus,
input[type="number"]:focus,
input[type="email"]:focus,
select:focus {
border-color: #1d6fff;
outline: none;
box-shadow: 0 0 0 3px rgba(29, 111, 255, 0.12);
@@ -1096,55 +1318,62 @@ input:focus {
font-size: 12px;
}
.table-wrap {
.icon-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
align-content: start;
min-height: 0;
overflow: auto;
padding-right: 4px;
}
.file-card {
border: 1px solid #d8e1ee;
border-radius: 12px;
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
thead th {
position: sticky;
top: 0;
background: #f4f8ff;
color: #4b637f;
font-size: 12px;
background: #fff;
text-align: left;
padding: 10px;
border-bottom: 1px solid #d8e1ee;
}
tbody td {
border-bottom: 1px solid #edf2f7;
padding: 10px;
font-size: 13px;
}
tbody tr {
padding: 12px 10px;
cursor: pointer;
}
tbody tr:hover {
background: #f7faff;
}
tbody tr.selected {
background: #eaf2ff;
}
.name-cell {
display: flex;
align-items: center;
gap: 8px;
flex-direction: column;
gap: 4px;
min-height: 122px;
}
.icon {
width: 22px;
text-align: center;
.file-card:hover {
border-color: #c6d7ee;
box-shadow: 0 6px 12px rgba(34, 72, 116, 0.08);
}
.file-card.selected {
border-color: #7baaf8;
background: #eef5ff;
box-shadow: 0 0 0 2px rgba(29, 111, 255, 0.1);
}
.file-icon-glyph {
font-size: 30px;
line-height: 1;
margin-bottom: 6px;
}
.file-name {
font-size: 13px;
font-weight: 600;
color: #1f2b3a;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-meta,
.file-time {
font-size: 11px;
color: #60768f;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-list {
@@ -1194,25 +1423,138 @@ tbody tr.selected {
background: linear-gradient(90deg, #1d6fff, #57a2ff);
}
.placeholder-card {
border: 1px dashed #b6c8df;
background: #f5f9ff;
.share-create-toolbar {
border: 1px solid #d8e1ee;
border-radius: 12px;
padding: 14px;
}
.placeholder-card p {
margin: 0;
color: #4f6984;
line-height: 1.6;
font-size: 13px;
}
.settings-form {
max-width: 520px;
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;
gap: 8px;
overflow: auto;
min-height: 0;
}
.share-item {
border: 1px solid #d8e1ee;
border-radius: 12px;
background: #fff;
padding: 10px;
display: grid;
gap: 10px;
grid-template-columns: 1fr auto;
align-items: center;
}
.share-main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.share-title {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.share-title strong {
font-size: 14px;
color: #203043;
}
.share-badge {
display: inline-flex;
align-items: center;
height: 22px;
border-radius: 999px;
padding: 0 8px;
background: #eef5ff;
color: #3d5f8a;
font-size: 11px;
}
.share-link,
.share-meta {
font-size: 12px;
color: #5a718c;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.share-meta {
display: flex;
gap: 12px;
}
.share-actions {
display: flex;
align-items: center;
gap: 6px;
}
.detail-panel h3 {
@@ -1298,5 +1640,13 @@ tbody tr.selected {
.main-grid {
grid-template-columns: 1fr;
}
.share-item {
grid-template-columns: 1fr;
}
.share-actions {
justify-content: flex-start;
}
}
</style>