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

@@ -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");

View File

@@ -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>