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
|
||||
}
|
||||
|
||||
#[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)]
|
||||
pub fn run() {
|
||||
let client = reqwest::Client::builder()
|
||||
@@ -345,7 +433,10 @@ pub fn run() {
|
||||
api_mkdir,
|
||||
api_rename_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!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -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,6 +721,7 @@ onMounted(async () => {
|
||||
</button>
|
||||
</div>
|
||||
<div class="tool-right">
|
||||
<template v-if="nav === 'files'">
|
||||
<input
|
||||
v-model="searchKeyword"
|
||||
type="text"
|
||||
@@ -578,6 +735,10 @@ onMounted(async () => {
|
||||
<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
|
||||
<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)"
|
||||
>
|
||||
<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 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="settings-form">
|
||||
<label>
|
||||
API 服务地址
|
||||
<input v-model="appConfig.baseUrl" type="text" placeholder="https://cs.workyai.cn" />
|
||||
|
||||
<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>
|
||||
<button class="solid-btn" @click="applySettings">保存设置</button>
|
||||
|
||||
<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 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>
|
||||
|
||||
Reference in New Issue
Block a user