feat(desktop): implement share management and hide endpoint settings
This commit is contained in:
@@ -325,6 +325,94 @@ async fn api_get_download_url(
|
|||||||
request_json(&state.client, Method::GET, api_url, None, None).await
|
request_json(&state.client, Method::GET, api_url, None, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn api_get_my_shares(
|
||||||
|
state: tauri::State<'_, ApiState>,
|
||||||
|
base_url: String,
|
||||||
|
) -> Result<BridgeResponse, String> {
|
||||||
|
request_with_optional_csrf(
|
||||||
|
&state.client,
|
||||||
|
Method::GET,
|
||||||
|
&base_url,
|
||||||
|
"/api/share/my",
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn api_create_share(
|
||||||
|
state: tauri::State<'_, ApiState>,
|
||||||
|
base_url: String,
|
||||||
|
share_type: String,
|
||||||
|
file_path: String,
|
||||||
|
file_name: Option<String>,
|
||||||
|
password: Option<String>,
|
||||||
|
expiry_days: Option<i32>,
|
||||||
|
) -> Result<BridgeResponse, String> {
|
||||||
|
let mut body = Map::new();
|
||||||
|
body.insert("share_type".to_string(), Value::String(share_type));
|
||||||
|
body.insert("file_path".to_string(), Value::String(file_path));
|
||||||
|
|
||||||
|
if let Some(name) = file_name {
|
||||||
|
if !name.trim().is_empty() {
|
||||||
|
body.insert("file_name".to_string(), Value::String(name.trim().to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(raw_password) = password {
|
||||||
|
let value = raw_password.trim();
|
||||||
|
if value.is_empty() {
|
||||||
|
body.insert("password".to_string(), Value::Null);
|
||||||
|
} else {
|
||||||
|
body.insert("password".to_string(), Value::String(value.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(days) = expiry_days {
|
||||||
|
if days > 0 {
|
||||||
|
body.insert("expiry_days".to_string(), Value::Number(days.into()));
|
||||||
|
} else {
|
||||||
|
body.insert("expiry_days".to_string(), Value::Null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
body.insert("expiry_days".to_string(), Value::Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
request_with_optional_csrf(
|
||||||
|
&state.client,
|
||||||
|
Method::POST,
|
||||||
|
&base_url,
|
||||||
|
"/api/share/create",
|
||||||
|
Some(Value::Object(body)),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn api_delete_share(
|
||||||
|
state: tauri::State<'_, ApiState>,
|
||||||
|
base_url: String,
|
||||||
|
share_id: u64,
|
||||||
|
) -> Result<BridgeResponse, String> {
|
||||||
|
if share_id == 0 {
|
||||||
|
return Err("无效的分享ID".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let csrf_token = fetch_csrf_token(&state.client, &base_url).await?;
|
||||||
|
let path = format!("/api/share/{}", share_id);
|
||||||
|
request_json(
|
||||||
|
&state.client,
|
||||||
|
Method::DELETE,
|
||||||
|
join_api_url(&base_url, &path),
|
||||||
|
None,
|
||||||
|
csrf_token,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
@@ -345,7 +433,10 @@ pub fn run() {
|
|||||||
api_mkdir,
|
api_mkdir,
|
||||||
api_rename_file,
|
api_rename_file,
|
||||||
api_delete_file,
|
api_delete_file,
|
||||||
api_get_download_url
|
api_get_download_url,
|
||||||
|
api_get_my_shares,
|
||||||
|
api_create_share,
|
||||||
|
api_delete_share
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<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 { invoke } from "@tauri-apps/api/core";
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
|
|
||||||
type NavKey = "files" | "transfers" | "shares" | "settings";
|
type NavKey = "files" | "transfers" | "shares";
|
||||||
|
|
||||||
type FileItem = {
|
type FileItem = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -16,6 +16,20 @@ type FileItem = {
|
|||||||
isDirectory?: boolean;
|
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 = {
|
type BridgeResponse = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
status: number;
|
status: number;
|
||||||
@@ -27,7 +41,7 @@ const authenticated = ref(false);
|
|||||||
const user = ref<Record<string, any> | null>(null);
|
const user = ref<Record<string, any> | null>(null);
|
||||||
|
|
||||||
const appConfig = reactive({
|
const appConfig = reactive({
|
||||||
baseUrl: localStorage.getItem("desktop_api_base") || "https://cs.workyai.cn",
|
baseUrl: "https://cs.workyai.cn",
|
||||||
});
|
});
|
||||||
|
|
||||||
const loginForm = reactive({
|
const loginForm = reactive({
|
||||||
@@ -52,12 +66,18 @@ const pathState = reactive({
|
|||||||
const files = ref<FileItem[]>([]);
|
const files = ref<FileItem[]>([]);
|
||||||
const selectedFileName = ref("");
|
const selectedFileName = ref("");
|
||||||
const searchKeyword = ref("");
|
const searchKeyword = ref("");
|
||||||
|
const shares = ref<ShareItem[]>([]);
|
||||||
|
|
||||||
const transferTasks = ref([
|
const transferTasks = ref<{ id: string; name: string; speed: string; progress: number; status: string }[]>([]);
|
||||||
{ id: "T-20260218-01", name: "需求说明-v3.pdf", speed: "3.5 MB/s", progress: 83, status: "downloading" },
|
const sharesLoading = ref(false);
|
||||||
{ 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 shareCreate = reactive({
|
||||||
]);
|
creating: false,
|
||||||
|
expiryPreset: "never" as "never" | "1" | "7" | "30" | "custom",
|
||||||
|
customDays: 7,
|
||||||
|
enablePassword: false,
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
|
||||||
const toast = reactive({
|
const toast = reactive({
|
||||||
visible: false,
|
visible: false,
|
||||||
@@ -69,10 +89,17 @@ let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
|||||||
const navItems = computed(() => [
|
const navItems = computed(() => [
|
||||||
{ key: "files" as const, label: "全部文件", hint: `${files.value.length} 项` },
|
{ key: "files" as const, label: "全部文件", hint: `${files.value.length} 项` },
|
||||||
{ key: "transfers" as const, label: "传输列表", hint: `${transferTasks.value.length} 个任务` },
|
{ key: "transfers" as const, label: "传输列表", hint: `${transferTasks.value.length} 个任务` },
|
||||||
{ key: "shares" as const, label: "我的分享", hint: "分享管理" },
|
{ key: "shares" as const, label: "我的分享", hint: `${shares.value.length} 条` },
|
||||||
{ key: "settings" as const, label: "客户端设置", hint: "连接与偏好" },
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
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 filteredFiles = computed(() => {
|
||||||
const key = searchKeyword.value.trim().toLowerCase();
|
const key = searchKeyword.value.trim().toLowerCase();
|
||||||
if (!key) return files.value;
|
if (!key) return files.value;
|
||||||
@@ -193,6 +220,58 @@ function fileIcon(item: FileItem) {
|
|||||||
return "📄";
|
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") {
|
function showToast(message: string, type = "info") {
|
||||||
toast.message = message;
|
toast.message = message;
|
||||||
toast.type = type;
|
toast.type = type;
|
||||||
@@ -248,11 +327,100 @@ async function loadFiles(targetPath = pathState.currentPath) {
|
|||||||
pathState.loading = false;
|
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() {
|
async function restoreSession() {
|
||||||
const ok = await loadProfile();
|
const ok = await loadProfile();
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
authenticated.value = true;
|
authenticated.value = true;
|
||||||
await loadFiles("/");
|
await loadFiles("/");
|
||||||
|
await loadShares(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildItemPath(item: FileItem) {
|
function buildItemPath(item: FileItem) {
|
||||||
@@ -338,20 +506,6 @@ async function handleLogout() {
|
|||||||
showToast("已退出客户端", "info");
|
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() {
|
async function createFolder() {
|
||||||
if (pathState.loading) return;
|
if (pathState.loading) return;
|
||||||
const folderName = window.prompt("请输入新文件夹名称");
|
const folderName = window.prompt("请输入新文件夹名称");
|
||||||
@@ -463,6 +617,12 @@ async function jumpToPath(nextPath: string) {
|
|||||||
await loadFiles(nextPath);
|
await loadFiles(nextPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(nav, async (next) => {
|
||||||
|
if (next === "shares" && authenticated.value) {
|
||||||
|
await loadShares();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await restoreSession();
|
await restoreSession();
|
||||||
});
|
});
|
||||||
@@ -486,7 +646,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="brand-card">
|
<div class="brand-card">
|
||||||
<strong>独立工作台</strong>
|
<strong>独立工作台</strong>
|
||||||
<span>文件、分享、设置一体化</span>
|
<span>文件、分享、传输一体化</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="brand-card">
|
<div class="brand-card">
|
||||||
<strong>可接现有后端</strong>
|
<strong>可接现有后端</strong>
|
||||||
@@ -497,10 +657,6 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<section class="login-panel">
|
<section class="login-panel">
|
||||||
<h2>登录云盘</h2>
|
<h2>登录云盘</h2>
|
||||||
<label>
|
|
||||||
服务地址
|
|
||||||
<input v-model="appConfig.baseUrl" type="text" placeholder="https://cs.workyai.cn" />
|
|
||||||
</label>
|
|
||||||
<label>
|
<label>
|
||||||
用户名
|
用户名
|
||||||
<input v-model="loginForm.username" type="text" placeholder="请输入账号" />
|
<input v-model="loginForm.username" type="text" placeholder="请输入账号" />
|
||||||
@@ -565,19 +721,24 @@ onMounted(async () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="tool-right">
|
<div class="tool-right">
|
||||||
<input
|
<template v-if="nav === 'files'">
|
||||||
v-model="searchKeyword"
|
<input
|
||||||
type="text"
|
v-model="searchKeyword"
|
||||||
class="search-input"
|
type="text"
|
||||||
placeholder="输入关键词回车全局搜索..."
|
class="search-input"
|
||||||
@keyup.enter="runGlobalSearch"
|
placeholder="输入关键词回车全局搜索..."
|
||||||
/>
|
@keyup.enter="runGlobalSearch"
|
||||||
<button class="action-btn" @click="runGlobalSearch">搜索</button>
|
/>
|
||||||
<button class="action-btn" @click="createFolder">新建文件夹</button>
|
<button class="action-btn" @click="runGlobalSearch">搜索</button>
|
||||||
<button class="action-btn" @click="renameSelected">重命名</button>
|
<button class="action-btn" @click="createFolder">新建文件夹</button>
|
||||||
<button class="action-btn danger" @click="deleteSelected">删除</button>
|
<button class="action-btn" @click="renameSelected">重命名</button>
|
||||||
<button class="solid-btn" @click="downloadSelected">下载</button>
|
<button class="action-btn danger" @click="deleteSelected">删除</button>
|
||||||
<button class="action-btn" @click="loadFiles(pathState.currentPath)">刷新</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -592,31 +753,21 @@ onMounted(async () => {
|
|||||||
<div v-if="pathState.loading" class="empty-tip">正在加载目录...</div>
|
<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="pathState.error" class="empty-tip error">{{ pathState.error }}</div>
|
||||||
<div v-else-if="filteredFiles.length === 0" class="empty-tip">当前目录暂无文件</div>
|
<div v-else-if="filteredFiles.length === 0" class="empty-tip">当前目录暂无文件</div>
|
||||||
<div v-else class="table-wrap">
|
<div v-else class="icon-grid">
|
||||||
<table>
|
<button
|
||||||
<thead>
|
v-for="item in filteredFiles"
|
||||||
<tr>
|
:key="item.name"
|
||||||
<th>名称</th>
|
type="button"
|
||||||
<th>类型</th>
|
class="file-card"
|
||||||
<th>大小</th>
|
:class="{ selected: selectedFileName === item.name }"
|
||||||
<th>修改时间</th>
|
@click="selectFile(item)"
|
||||||
</tr>
|
@dblclick="openItem(item)"
|
||||||
</thead>
|
>
|
||||||
<tbody>
|
<div class="file-icon-glyph">{{ fileIcon(item) }}</div>
|
||||||
<tr
|
<div class="file-name" :title="item.displayName || item.name">{{ item.displayName || item.name }}</div>
|
||||||
v-for="item in filteredFiles"
|
<div class="file-meta">{{ fileTypeLabel(item) }} · {{ item.isDirectory ? "-" : (item.sizeFormatted || formatBytes(item.size)) }}</div>
|
||||||
:key="item.name"
|
<div class="file-time">{{ formatDate(item.modifiedAt) }}</div>
|
||||||
:class="{ selected: selectedFileName === item.name }"
|
</button>
|
||||||
@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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -625,7 +776,8 @@ onMounted(async () => {
|
|||||||
<h3>传输任务</h3>
|
<h3>传输任务</h3>
|
||||||
<span>上传/下载队列</span>
|
<span>上传/下载队列</span>
|
||||||
</div>
|
</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 v-for="task in transferTasks" :key="task.id" class="task-row">
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ task.name }}</strong>
|
<strong>{{ task.name }}</strong>
|
||||||
@@ -643,25 +795,77 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<template v-else-if="nav === 'shares'">
|
<template v-else-if="nav === 'shares'">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h3>分享管理</h3>
|
<h3>我的分享</h3>
|
||||||
<span>桌面端下阶段接入分享创建与回收</span>
|
<span>创建、查看、复制与删除</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="placeholder-card">
|
|
||||||
<p>当前版本已预留分享模块位,下一步会接入分享链接列表和直链复制。</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
<div class="share-create-toolbar">
|
||||||
<div class="panel-head">
|
<div class="share-target">
|
||||||
<h3>客户端设置</h3>
|
当前选中:
|
||||||
<span>连接地址与基础偏好</span>
|
<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>
|
||||||
<div class="settings-form">
|
|
||||||
<label>
|
<div v-if="sharesLoading" class="empty-tip">正在加载分享列表...</div>
|
||||||
API 服务地址
|
<div v-else-if="sortedShares.length === 0" class="empty-tip">暂无分享记录</div>
|
||||||
<input v-model="appConfig.baseUrl" type="text" placeholder="https://cs.workyai.cn" />
|
<div v-else class="share-list">
|
||||||
</label>
|
<div v-for="share in sortedShares" :key="share.id" class="share-item">
|
||||||
<button class="solid-btn" @click="applySettings">保存设置</button>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
@@ -815,7 +1019,10 @@ label {
|
|||||||
color: #435872;
|
color: #435872;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input[type="text"],
|
||||||
|
input[type="password"],
|
||||||
|
input[type="number"],
|
||||||
|
input[type="email"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 42px;
|
height: 42px;
|
||||||
border: 1px solid #d6dfe9;
|
border: 1px solid #d6dfe9;
|
||||||
@@ -826,7 +1033,22 @@ input {
|
|||||||
background: #fff;
|
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;
|
border-color: #1d6fff;
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: 0 0 0 3px rgba(29, 111, 255, 0.12);
|
box-shadow: 0 0 0 3px rgba(29, 111, 255, 0.12);
|
||||||
@@ -1096,55 +1318,62 @@ input:focus {
|
|||||||
font-size: 12px;
|
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: 1px solid #d8e1ee;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: auto;
|
background: #fff;
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
thead th {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
background: #f4f8ff;
|
|
||||||
color: #4b637f;
|
|
||||||
font-size: 12px;
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 10px;
|
padding: 12px 10px;
|
||||||
border-bottom: 1px solid #d8e1ee;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody td {
|
|
||||||
border-bottom: 1px solid #edf2f7;
|
|
||||||
padding: 10px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody tr {
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
|
||||||
|
|
||||||
tbody tr:hover {
|
|
||||||
background: #f7faff;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody tr.selected {
|
|
||||||
background: #eaf2ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.name-cell {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 4px;
|
||||||
|
min-height: 122px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.file-card:hover {
|
||||||
width: 22px;
|
border-color: #c6d7ee;
|
||||||
text-align: center;
|
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 {
|
.task-list {
|
||||||
@@ -1194,25 +1423,138 @@ tbody tr.selected {
|
|||||||
background: linear-gradient(90deg, #1d6fff, #57a2ff);
|
background: linear-gradient(90deg, #1d6fff, #57a2ff);
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder-card {
|
.share-create-toolbar {
|
||||||
border: 1px dashed #b6c8df;
|
border: 1px solid #d8e1ee;
|
||||||
background: #f5f9ff;
|
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 14px;
|
background: #f8fbff;
|
||||||
}
|
padding: 10px;
|
||||||
|
|
||||||
.placeholder-card p {
|
|
||||||
margin: 0;
|
|
||||||
color: #4f6984;
|
|
||||||
line-height: 1.6;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-form {
|
|
||||||
max-width: 520px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
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 {
|
.detail-panel h3 {
|
||||||
@@ -1298,5 +1640,13 @@ tbody tr.selected {
|
|||||||
.main-grid {
|
.main-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.share-item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user