feat(desktop): align requested 4/5/6/8 with resume queue batch and one-click update
This commit is contained in:
@@ -5,6 +5,7 @@ use serde_json::{Map, Value};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
@@ -108,6 +109,35 @@ fn alloc_download_path(download_dir: &Path, preferred_name: &str) -> PathBuf {
|
||||
first
|
||||
}
|
||||
|
||||
fn build_download_resume_temp_path(download_dir: &Path, preferred_name: &str, url: &str) -> PathBuf {
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
use std::hash::{Hash, Hasher};
|
||||
preferred_name.hash(&mut hasher);
|
||||
url.hash(&mut hasher);
|
||||
let digest = format!("{:016x}", hasher.finish());
|
||||
let safe_name = sanitize_file_name(preferred_name);
|
||||
let temp_name = format!(".{}.{}.part", safe_name, digest);
|
||||
download_dir.join(temp_name)
|
||||
}
|
||||
|
||||
async fn parse_response_as_bridge(response: reqwest::Response) -> Result<BridgeResponse, String> {
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| format!("读取响应失败: {}", err))?;
|
||||
let data = match serde_json::from_str::<Value>(&text) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(_) => fallback_json(status, &text),
|
||||
};
|
||||
|
||||
Ok(BridgeResponse {
|
||||
ok: status.is_success(),
|
||||
status: status.as_u16(),
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
async fn request_json(
|
||||
client: &reqwest::Client,
|
||||
method: Method,
|
||||
@@ -528,22 +558,6 @@ async fn api_native_download(
|
||||
return Err("下载地址不能为空".to_string());
|
||||
}
|
||||
|
||||
let response = state
|
||||
.client
|
||||
.get(&trimmed_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("下载请求失败: {}", err))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Ok(BridgeResponse {
|
||||
ok: false,
|
||||
status: status.as_u16(),
|
||||
data: fallback_json(status, "下载失败"),
|
||||
});
|
||||
}
|
||||
|
||||
let preferred_name = file_name
|
||||
.as_deref()
|
||||
.map(|name| name.trim())
|
||||
@@ -556,11 +570,75 @@ async fn api_native_download(
|
||||
.map_err(|err| format!("创建下载目录失败: {}", err))?;
|
||||
}
|
||||
|
||||
let save_path = alloc_download_path(&download_dir, preferred_name);
|
||||
let mut target_file =
|
||||
fs::File::create(&save_path).map_err(|err| format!("创建文件失败: {}", err))?;
|
||||
let resume_temp_path = build_download_resume_temp_path(&download_dir, preferred_name, &trimmed_url);
|
||||
let existing_size = if resume_temp_path.exists() {
|
||||
fs::metadata(&resume_temp_path)
|
||||
.ok()
|
||||
.map(|meta| meta.len())
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let mut downloaded_bytes: u64 = 0;
|
||||
let mut request = state.client.get(&trimmed_url);
|
||||
if existing_size > 0 {
|
||||
request = request.header("Range", format!("bytes={}-", existing_size));
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("下载请求失败: {}", err))?;
|
||||
let status = response.status();
|
||||
|
||||
if status == reqwest::StatusCode::RANGE_NOT_SATISFIABLE && existing_size > 0 {
|
||||
let save_path = alloc_download_path(&download_dir, preferred_name);
|
||||
fs::rename(&resume_temp_path, &save_path)
|
||||
.map_err(|err| format!("完成断点下载失败: {}", err))?;
|
||||
let mut data = Map::new();
|
||||
data.insert("success".to_string(), Value::Bool(true));
|
||||
data.insert(
|
||||
"savePath".to_string(),
|
||||
Value::String(save_path.to_string_lossy().to_string()),
|
||||
);
|
||||
data.insert(
|
||||
"downloadedBytes".to_string(),
|
||||
Value::Number(serde_json::Number::from(existing_size)),
|
||||
);
|
||||
data.insert(
|
||||
"resumedBytes".to_string(),
|
||||
Value::Number(serde_json::Number::from(existing_size)),
|
||||
);
|
||||
return Ok(BridgeResponse {
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: Value::Object(data),
|
||||
});
|
||||
}
|
||||
|
||||
if !status.is_success() {
|
||||
return Ok(BridgeResponse {
|
||||
ok: false,
|
||||
status: status.as_u16(),
|
||||
data: fallback_json(status, "下载失败"),
|
||||
});
|
||||
}
|
||||
|
||||
let append_mode = existing_size > 0 && status == reqwest::StatusCode::PARTIAL_CONTENT;
|
||||
if !append_mode && resume_temp_path.exists() {
|
||||
fs::remove_file(&resume_temp_path)
|
||||
.map_err(|err| format!("重置断点下载文件失败: {}", err))?;
|
||||
}
|
||||
|
||||
let mut target_file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.append(append_mode)
|
||||
.truncate(!append_mode)
|
||||
.open(&resume_temp_path)
|
||||
.map_err(|err| format!("创建文件失败: {}", err))?;
|
||||
|
||||
let mut downloaded_bytes: u64 = if append_mode { existing_size } else { 0 };
|
||||
let mut stream = response;
|
||||
while let Some(chunk) = stream
|
||||
.chunk()
|
||||
@@ -577,6 +655,10 @@ async fn api_native_download(
|
||||
.flush()
|
||||
.map_err(|err| format!("刷新文件失败: {}", err))?;
|
||||
|
||||
let save_path = alloc_download_path(&download_dir, preferred_name);
|
||||
fs::rename(&resume_temp_path, &save_path)
|
||||
.map_err(|err| format!("保存下载文件失败: {}", err))?;
|
||||
|
||||
let mut data = Map::new();
|
||||
data.insert("success".to_string(), Value::Bool(true));
|
||||
data.insert(
|
||||
@@ -587,6 +669,10 @@ async fn api_native_download(
|
||||
"downloadedBytes".to_string(),
|
||||
Value::Number(serde_json::Number::from(downloaded_bytes)),
|
||||
);
|
||||
data.insert(
|
||||
"resumedBytes".to_string(),
|
||||
Value::Number(serde_json::Number::from(if append_mode { existing_size } else { 0 })),
|
||||
);
|
||||
|
||||
Ok(BridgeResponse {
|
||||
ok: true,
|
||||
@@ -711,6 +797,162 @@ async fn api_list_local_files(dir_path: String) -> Result<BridgeResponse, String
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn api_upload_file_resumable(
|
||||
state: tauri::State<'_, ApiState>,
|
||||
base_url: String,
|
||||
file_path: String,
|
||||
target_path: String,
|
||||
chunk_size: Option<u64>,
|
||||
) -> Result<BridgeResponse, String> {
|
||||
let trimmed_path = file_path.trim().to_string();
|
||||
if trimmed_path.is_empty() {
|
||||
return Err("上传文件路径不能为空".to_string());
|
||||
}
|
||||
|
||||
let source_path = PathBuf::from(&trimmed_path);
|
||||
if !source_path.exists() {
|
||||
return Err("上传文件不存在".to_string());
|
||||
}
|
||||
if !source_path.is_file() {
|
||||
return Err("仅支持上传文件,不支持文件夹".to_string());
|
||||
}
|
||||
|
||||
let metadata = fs::metadata(&source_path).map_err(|err| format!("读取文件信息失败: {}", err))?;
|
||||
let file_size = metadata.len();
|
||||
if file_size == 0 {
|
||||
return Err("空文件不支持分片上传".to_string());
|
||||
}
|
||||
|
||||
let file_name = source_path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(|name| name.to_string())
|
||||
.ok_or_else(|| "无法识别文件名".to_string())?;
|
||||
let normalized_target = if target_path.trim().is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
target_path
|
||||
};
|
||||
let effective_chunk = chunk_size.unwrap_or(4 * 1024 * 1024).clamp(256 * 1024, 32 * 1024 * 1024);
|
||||
|
||||
let csrf_token = fetch_csrf_token(&state.client, &base_url).await?;
|
||||
let mut init_body = Map::new();
|
||||
init_body.insert("filename".to_string(), Value::String(file_name.clone()));
|
||||
init_body.insert("path".to_string(), Value::String(normalized_target));
|
||||
init_body.insert(
|
||||
"size".to_string(),
|
||||
Value::Number(serde_json::Number::from(file_size)),
|
||||
);
|
||||
init_body.insert(
|
||||
"chunk_size".to_string(),
|
||||
Value::Number(serde_json::Number::from(effective_chunk)),
|
||||
);
|
||||
|
||||
let init_resp = request_json(
|
||||
&state.client,
|
||||
Method::POST,
|
||||
join_api_url(&base_url, "/api/upload/resumable/init"),
|
||||
Some(Value::Object(init_body)),
|
||||
csrf_token.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if !init_resp.ok || !init_resp.data.get("success").and_then(Value::as_bool).unwrap_or(false) {
|
||||
return Ok(init_resp);
|
||||
}
|
||||
|
||||
let session_id = init_resp
|
||||
.data
|
||||
.get("session_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
.ok_or_else(|| "分片上传会话创建失败".to_string())?;
|
||||
let server_chunk_size = init_resp
|
||||
.data
|
||||
.get("chunk_size")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(effective_chunk)
|
||||
.max(1);
|
||||
let total_chunks = init_resp
|
||||
.data
|
||||
.get("total_chunks")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or_else(|| ((file_size + server_chunk_size - 1) / server_chunk_size).max(1));
|
||||
let uploaded_chunks: std::collections::HashSet<u64> = init_resp
|
||||
.data
|
||||
.get("uploaded_chunks")
|
||||
.and_then(Value::as_array)
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(Value::as_u64)
|
||||
.collect::<std::collections::HashSet<u64>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut source = fs::File::open(&source_path).map_err(|err| format!("打开文件失败: {}", err))?;
|
||||
for chunk_index in 0..total_chunks {
|
||||
if uploaded_chunks.contains(&chunk_index) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let offset = chunk_index * server_chunk_size;
|
||||
let remaining = file_size.saturating_sub(offset);
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
let read_size = std::cmp::min(remaining, server_chunk_size) as usize;
|
||||
|
||||
source
|
||||
.seek(SeekFrom::Start(offset))
|
||||
.map_err(|err| format!("读取分片失败: {}", err))?;
|
||||
let mut buf = vec![0_u8; read_size];
|
||||
source
|
||||
.read_exact(&mut buf)
|
||||
.map_err(|err| format!("读取分片失败: {}", err))?;
|
||||
|
||||
let chunk_part_name = format!("{}.part{}", file_name, chunk_index);
|
||||
let multipart = reqwest::multipart::Form::new()
|
||||
.text("session_id", session_id.clone())
|
||||
.text("chunk_index", chunk_index.to_string())
|
||||
.part(
|
||||
"chunk",
|
||||
reqwest::multipart::Part::bytes(buf).file_name(chunk_part_name),
|
||||
);
|
||||
|
||||
let mut request = state
|
||||
.client
|
||||
.post(join_api_url(&base_url, "/api/upload/resumable/chunk"))
|
||||
.header("Accept", "application/json")
|
||||
.timeout(Duration::from_secs(60 * 10))
|
||||
.multipart(multipart);
|
||||
if let Some(token) = csrf_token.clone() {
|
||||
request = request.header("X-CSRF-Token", token);
|
||||
}
|
||||
|
||||
let chunk_resp = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("上传分片失败: {}", err))?;
|
||||
let chunk_bridge = parse_response_as_bridge(chunk_resp).await?;
|
||||
if !chunk_bridge.ok || !chunk_bridge.data.get("success").and_then(Value::as_bool).unwrap_or(false) {
|
||||
return Ok(chunk_bridge);
|
||||
}
|
||||
}
|
||||
|
||||
let mut complete_body = Map::new();
|
||||
complete_body.insert("session_id".to_string(), Value::String(session_id));
|
||||
request_json(
|
||||
&state.client,
|
||||
Method::POST,
|
||||
join_api_url(&base_url, "/api/upload/resumable/complete"),
|
||||
Some(Value::Object(complete_body)),
|
||||
csrf_token,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn api_upload_file(
|
||||
state: tauri::State<'_, ApiState>,
|
||||
@@ -820,6 +1062,7 @@ pub fn run() {
|
||||
api_native_download,
|
||||
api_check_client_update,
|
||||
api_list_local_files,
|
||||
api_upload_file_resumable,
|
||||
api_upload_file
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { openPath, openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||
@@ -47,6 +47,22 @@ type LocalSyncFileItem = {
|
||||
modifiedMs: number;
|
||||
};
|
||||
|
||||
type TransferTaskKind = "upload" | "download";
|
||||
type TransferTaskStatus = "queued" | "uploading" | "downloading" | "done" | "failed";
|
||||
type TransferTask = {
|
||||
id: string;
|
||||
kind: TransferTaskKind;
|
||||
name: string;
|
||||
speed: string;
|
||||
progress: number;
|
||||
status: TransferTaskStatus;
|
||||
note?: string;
|
||||
filePath?: string;
|
||||
targetPath?: string;
|
||||
downloadUrl?: string;
|
||||
fileName?: string;
|
||||
};
|
||||
|
||||
const nav = ref<NavKey>("files");
|
||||
const authenticated = ref(false);
|
||||
const user = ref<Record<string, any> | null>(null);
|
||||
@@ -78,9 +94,14 @@ const files = ref<FileItem[]>([]);
|
||||
const selectedFileName = ref("");
|
||||
const searchKeyword = ref("");
|
||||
const shares = ref<ShareItem[]>([]);
|
||||
const batchMode = ref(false);
|
||||
const batchSelectedNames = ref<string[]>([]);
|
||||
|
||||
const transferTasks = ref<{ id: string; name: string; speed: string; progress: number; status: string; note?: string }[]>([]);
|
||||
const transferTasks = ref<TransferTask[]>([]);
|
||||
const sharesLoading = ref(false);
|
||||
const transferQueue = reactive({
|
||||
paused: false,
|
||||
});
|
||||
const fileViewState = reactive({
|
||||
filter: "all",
|
||||
sortBy: "modifiedAt",
|
||||
@@ -240,6 +261,11 @@ const selectedFile = computed(() => {
|
||||
return files.value.find((item) => item.name === selectedFileName.value) || null;
|
||||
});
|
||||
|
||||
const batchSelectedItems = computed(() => {
|
||||
const selectedSet = new Set(batchSelectedNames.value);
|
||||
return files.value.filter((item) => selectedSet.has(item.name));
|
||||
});
|
||||
|
||||
const fileStats = computed(() => {
|
||||
const folders = files.value.filter((item) => item.isDirectory || item.type === "directory").length;
|
||||
const docs = files.value.length - folders;
|
||||
@@ -393,18 +419,19 @@ function showToast(message: string, type = "info") {
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
function prependTransferTask(task: { id: string; name: string; speed: string; progress: number; status: string; note?: string }) {
|
||||
transferTasks.value = [task, ...transferTasks.value.slice(0, 49)];
|
||||
function prependTransferTask(task: TransferTask) {
|
||||
transferTasks.value = [task, ...transferTasks.value.slice(0, 119)];
|
||||
}
|
||||
|
||||
function updateTransferTask(
|
||||
id: string,
|
||||
patch: Partial<{ name: string; speed: string; progress: number; status: string; note?: string }>,
|
||||
patch: Partial<TransferTask>,
|
||||
) {
|
||||
transferTasks.value = transferTasks.value.map((task) => (task.id === id ? { ...task, ...patch } : task));
|
||||
}
|
||||
|
||||
function getTaskStatusLabel(status: string) {
|
||||
if (status === "queued") return "排队中";
|
||||
if (status === "uploading") return "上传中";
|
||||
if (status === "downloading") return "下载中";
|
||||
if (status === "done") return "已完成";
|
||||
@@ -412,6 +439,52 @@ function getTaskStatusLabel(status: string) {
|
||||
return status;
|
||||
}
|
||||
|
||||
function isTaskRunning(status: string) {
|
||||
return status === "uploading" || status === "downloading";
|
||||
}
|
||||
|
||||
function removeTransferTask(taskId: string) {
|
||||
transferTasks.value = transferTasks.value.filter((task) => task.id !== taskId);
|
||||
}
|
||||
|
||||
function clearCompletedTransferTasks() {
|
||||
transferTasks.value = transferTasks.value.filter((task) => task.status !== "done" && task.status !== "failed");
|
||||
}
|
||||
|
||||
function toggleTransferQueuePause() {
|
||||
transferQueue.paused = !transferQueue.paused;
|
||||
showToast(transferQueue.paused ? "传输队列已暂停(进行中的任务会跑完)" : "传输队列已恢复", "info");
|
||||
}
|
||||
|
||||
async function waitForTransferQueue() {
|
||||
while (transferQueue.paused) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 240));
|
||||
}
|
||||
}
|
||||
|
||||
function isBatchSelected(name: string) {
|
||||
return batchSelectedNames.value.includes(name);
|
||||
}
|
||||
|
||||
function toggleBatchMode() {
|
||||
batchMode.value = !batchMode.value;
|
||||
batchSelectedNames.value = [];
|
||||
}
|
||||
|
||||
function clearBatchSelection() {
|
||||
batchSelectedNames.value = [];
|
||||
}
|
||||
|
||||
function toggleBatchSelection(item: FileItem) {
|
||||
const name = item.name;
|
||||
if (!name) return;
|
||||
if (isBatchSelected(name)) {
|
||||
batchSelectedNames.value = batchSelectedNames.value.filter((v) => v !== name);
|
||||
return;
|
||||
}
|
||||
batchSelectedNames.value = [...batchSelectedNames.value, name];
|
||||
}
|
||||
|
||||
function normalizeRelativePath(rawPath: string) {
|
||||
return String(rawPath || "").replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
}
|
||||
@@ -563,12 +636,58 @@ async function checkClientUpdate(showResultToast = true) {
|
||||
}
|
||||
}
|
||||
|
||||
async function openUpdateDownloadUrl() {
|
||||
async function installLatestUpdate() {
|
||||
if (!updateState.downloadUrl) {
|
||||
showToast("当前没有可用的更新下载地址", "info");
|
||||
return;
|
||||
}
|
||||
await openUrl(updateState.downloadUrl);
|
||||
|
||||
const taskId = `UPD-${Date.now()}`;
|
||||
const installerName = `wanwan-cloud-desktop_v${updateState.latestVersion || updateState.currentVersion}.exe`;
|
||||
prependTransferTask({
|
||||
id: taskId,
|
||||
kind: "download",
|
||||
name: installerName,
|
||||
speed: "更新包下载",
|
||||
progress: 2,
|
||||
status: "downloading",
|
||||
note: "正在下载升级安装包",
|
||||
downloadUrl: updateState.downloadUrl,
|
||||
fileName: installerName,
|
||||
});
|
||||
|
||||
const response = await invokeBridge("api_native_download", {
|
||||
url: updateState.downloadUrl,
|
||||
fileName: installerName,
|
||||
});
|
||||
|
||||
if (response.ok && response.data?.success) {
|
||||
updateTransferTask(taskId, {
|
||||
speed: "-",
|
||||
progress: 100,
|
||||
status: "done",
|
||||
note: "更新包已下载,准备启动安装",
|
||||
});
|
||||
const savePath = String(response.data?.savePath || "").trim();
|
||||
if (savePath) {
|
||||
try {
|
||||
await openPath(savePath);
|
||||
} catch (error) {
|
||||
console.error("open installer failed", error);
|
||||
}
|
||||
}
|
||||
showToast("更新包已下载,已尝试启动安装程序", "success");
|
||||
return;
|
||||
}
|
||||
|
||||
const message = String(response.data?.message || "下载更新包失败");
|
||||
updateTransferTask(taskId, {
|
||||
speed: "-",
|
||||
progress: 0,
|
||||
status: "failed",
|
||||
note: message,
|
||||
});
|
||||
showToast(message, "error");
|
||||
}
|
||||
|
||||
async function chooseSyncDirectory() {
|
||||
@@ -587,6 +706,34 @@ async function chooseSyncDirectory() {
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFileWithResume(filePath: string, targetPath: string) {
|
||||
const resumableResponse = await invokeBridge("api_upload_file_resumable", {
|
||||
baseUrl: appConfig.baseUrl,
|
||||
filePath,
|
||||
targetPath,
|
||||
chunkSize: 4 * 1024 * 1024,
|
||||
});
|
||||
|
||||
if (resumableResponse.ok && resumableResponse.data?.success) {
|
||||
return resumableResponse;
|
||||
}
|
||||
|
||||
const message = String(resumableResponse.data?.message || "");
|
||||
if (
|
||||
message.includes("当前存储模式不支持分片上传")
|
||||
|| message.includes("分片上传会话")
|
||||
|| message.includes("上传会话")
|
||||
) {
|
||||
return await invokeBridge("api_upload_file", {
|
||||
baseUrl: appConfig.baseUrl,
|
||||
filePath,
|
||||
targetPath,
|
||||
});
|
||||
}
|
||||
|
||||
return resumableResponse;
|
||||
}
|
||||
|
||||
function rebuildSyncScheduler() {
|
||||
clearSyncScheduler();
|
||||
if (!authenticated.value || !syncState.autoEnabled || !syncState.localDir.trim()) {
|
||||
@@ -665,6 +812,7 @@ async function runSyncOnce(trigger: "manual" | "auto" = "manual") {
|
||||
|
||||
const successPaths = new Set<string>();
|
||||
for (let index = 0; index < changedItems.length; index += 1) {
|
||||
await waitForTransferQueue();
|
||||
const item = changedItems[index];
|
||||
const relPath = normalizeRelativePath(item.relativePath);
|
||||
const fileName = extractFileNameFromPath(relPath) || `同步文件${index + 1}`;
|
||||
@@ -675,18 +823,19 @@ async function runSyncOnce(trigger: "manual" | "auto" = "manual") {
|
||||
|
||||
prependTransferTask({
|
||||
id: taskId,
|
||||
kind: "upload",
|
||||
name: fileName,
|
||||
speed: trigger === "auto" ? "自动同步" : "手动同步",
|
||||
progress: 10,
|
||||
status: "uploading",
|
||||
progress: 2,
|
||||
status: "queued",
|
||||
note: targetPath,
|
||||
});
|
||||
|
||||
const resp = await invokeBridge("api_upload_file", {
|
||||
baseUrl: appConfig.baseUrl,
|
||||
filePath: item.path,
|
||||
targetPath,
|
||||
fileName,
|
||||
});
|
||||
updateTransferTask(taskId, { status: "uploading", speed: "上传中", progress: 10 });
|
||||
|
||||
const resp = await uploadFileWithResume(item.path, targetPath);
|
||||
|
||||
if (resp.ok && resp.data?.success) {
|
||||
syncState.uploadedCount += 1;
|
||||
@@ -751,6 +900,7 @@ async function loadFiles(targetPath = pathState.currentPath) {
|
||||
pathState.currentPath = normalizePath(response.data.path || normalizedPath);
|
||||
pathState.mode = "directory";
|
||||
selectedFileName.value = files.value[0]?.name || "";
|
||||
batchSelectedNames.value = [];
|
||||
} else {
|
||||
pathState.error = response.data?.message || "读取文件列表失败";
|
||||
showToast(pathState.error, "error");
|
||||
@@ -785,7 +935,7 @@ async function getSignedUrlForItem(item: FileItem, mode: "download" | "preview")
|
||||
return "";
|
||||
}
|
||||
|
||||
async function createShareForItem(current: FileItem) {
|
||||
async function createShareForItem(current: FileItem, silent = false) {
|
||||
const response = await invokeBridge("api_create_share", {
|
||||
baseUrl: appConfig.baseUrl,
|
||||
shareType: current.isDirectory || current.type === "directory" ? "directory" : "file",
|
||||
@@ -796,22 +946,29 @@ async function createShareForItem(current: FileItem) {
|
||||
});
|
||||
|
||||
if (response.ok && response.data?.success) {
|
||||
showToast("分享创建成功", "success");
|
||||
if (!silent) {
|
||||
showToast("分享创建成功", "success");
|
||||
}
|
||||
await loadShares(true);
|
||||
const shareUrl = String(response.data.share_url || "");
|
||||
if (shareUrl) {
|
||||
if (!silent && shareUrl) {
|
||||
await copyText(shareUrl, "分享链接已复制");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
showToast(response.data?.message || "创建分享失败", "error");
|
||||
if (!silent) {
|
||||
showToast(response.data?.message || "创建分享失败", "error");
|
||||
}
|
||||
throw new Error(String(response.data?.message || "创建分享失败"));
|
||||
}
|
||||
|
||||
async function createDirectLinkForItem(current: FileItem) {
|
||||
async function createDirectLinkForItem(current: FileItem, silent = false) {
|
||||
if (current.isDirectory || current.type === "directory") {
|
||||
showToast("文件夹不支持生成直链", "info");
|
||||
return;
|
||||
if (!silent) {
|
||||
showToast("文件夹不支持生成直链", "info");
|
||||
}
|
||||
throw new Error("文件夹不支持生成直链");
|
||||
}
|
||||
|
||||
const response = await invokeBridge("api_create_direct_link", {
|
||||
@@ -822,15 +979,20 @@ async function createDirectLinkForItem(current: FileItem) {
|
||||
});
|
||||
|
||||
if (response.ok && response.data?.success) {
|
||||
showToast("直链创建成功", "success");
|
||||
if (!silent) {
|
||||
showToast("直链创建成功", "success");
|
||||
}
|
||||
const directUrl = String(response.data.direct_url || "");
|
||||
if (directUrl) {
|
||||
if (!silent && directUrl) {
|
||||
await copyText(directUrl, "直链已复制");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
showToast(response.data?.message || "创建直链失败", "error");
|
||||
if (!silent) {
|
||||
showToast(response.data?.message || "创建直链失败", "error");
|
||||
}
|
||||
throw new Error(String(response.data?.message || "创建直链失败"));
|
||||
}
|
||||
|
||||
async function deleteShare(share: ShareItem) {
|
||||
@@ -959,6 +1121,8 @@ async function handleLogout() {
|
||||
user.value = null;
|
||||
files.value = [];
|
||||
selectedFileName.value = "";
|
||||
batchMode.value = false;
|
||||
batchSelectedNames.value = [];
|
||||
loginForm.password = "";
|
||||
nav.value = "files";
|
||||
syncState.localDir = "";
|
||||
@@ -1015,14 +1179,16 @@ async function renameSelected(target?: FileItem | null) {
|
||||
showToast(response.data?.message || "重命名失败", "error");
|
||||
}
|
||||
|
||||
async function deleteSelected(target?: FileItem | null) {
|
||||
async function deleteSelected(target?: FileItem | null, silent = false) {
|
||||
const current = target || selectedFile.value;
|
||||
if (!current) {
|
||||
showToast("请先选中文件或文件夹", "info");
|
||||
if (!silent) showToast("请先选中文件或文件夹", "info");
|
||||
return;
|
||||
}
|
||||
const confirmed = window.confirm(`确认删除「${current.displayName || current.name}」吗?`);
|
||||
if (!confirmed) return;
|
||||
if (!silent) {
|
||||
const confirmed = window.confirm(`确认删除「${current.displayName || current.name}」吗?`);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
const response = await invokeBridge("api_delete_file", {
|
||||
baseUrl: appConfig.baseUrl,
|
||||
@@ -1030,11 +1196,16 @@ async function deleteSelected(target?: FileItem | null) {
|
||||
fileName: current.name,
|
||||
});
|
||||
if (response.ok && response.data?.success) {
|
||||
showToast("删除成功", "success");
|
||||
await loadFiles(pathState.currentPath);
|
||||
if (!silent) {
|
||||
showToast("删除成功", "success");
|
||||
await loadFiles(pathState.currentPath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
showToast(response.data?.message || "删除失败", "error");
|
||||
if (!silent) {
|
||||
showToast(response.data?.message || "删除失败", "error");
|
||||
}
|
||||
throw new Error(String(response.data?.message || "删除失败"));
|
||||
}
|
||||
|
||||
async function downloadSelected(target?: FileItem | null) {
|
||||
@@ -1053,11 +1224,17 @@ async function downloadSelected(target?: FileItem | null) {
|
||||
const taskId = `D-${Date.now()}`;
|
||||
prependTransferTask({
|
||||
id: taskId,
|
||||
kind: "download",
|
||||
name: current.displayName || current.name,
|
||||
speed: "原生下载",
|
||||
speed: "等待下载",
|
||||
progress: 1,
|
||||
status: "downloading",
|
||||
status: "queued",
|
||||
downloadUrl: signedUrl,
|
||||
fileName: current.displayName || current.name,
|
||||
note: "支持断点续传",
|
||||
});
|
||||
await waitForTransferQueue();
|
||||
updateTransferTask(taskId, { speed: "原生下载", status: "downloading", progress: 10, note: "下载中" });
|
||||
|
||||
const nativeResponse = await invokeBridge("api_native_download", {
|
||||
url: signedUrl,
|
||||
@@ -1065,7 +1242,9 @@ async function downloadSelected(target?: FileItem | null) {
|
||||
});
|
||||
|
||||
if (nativeResponse.ok && nativeResponse.data?.success) {
|
||||
updateTransferTask(taskId, { speed: "-", progress: 100, status: "done", note: "下载成功" });
|
||||
const resumedBytes = Number(nativeResponse.data?.resumedBytes || 0);
|
||||
const resumeText = resumedBytes > 0 ? `,已续传 ${formatBytes(resumedBytes)}` : "";
|
||||
updateTransferTask(taskId, { speed: "-", progress: 100, status: "done", note: `下载成功${resumeText}` });
|
||||
const savedPath = nativeResponse.data?.savePath ? `\n${nativeResponse.data.savePath}` : "";
|
||||
showToast(`下载完成${savedPath}`, "success");
|
||||
return;
|
||||
@@ -1077,10 +1256,15 @@ async function downloadSelected(target?: FileItem | null) {
|
||||
}
|
||||
|
||||
function selectFile(item: FileItem) {
|
||||
if (batchMode.value) {
|
||||
toggleBatchSelection(item);
|
||||
return;
|
||||
}
|
||||
selectedFileName.value = item.name;
|
||||
}
|
||||
|
||||
async function openItem(item: FileItem) {
|
||||
if (batchMode.value) return;
|
||||
selectFile(item);
|
||||
if (item.isDirectory || item.type === "directory") {
|
||||
const nextPath = buildItemPath(item);
|
||||
@@ -1103,6 +1287,7 @@ function closeContextMenu() {
|
||||
}
|
||||
|
||||
function openContextMenu(event: MouseEvent, item: FileItem) {
|
||||
if (batchMode.value) return;
|
||||
selectFile(item);
|
||||
contextMenu.item = item;
|
||||
const maxX = window.innerWidth - 220;
|
||||
@@ -1117,27 +1302,155 @@ async function executeContextAction(action: "open" | "download" | "rename" | "de
|
||||
closeContextMenu();
|
||||
if (!item) return;
|
||||
|
||||
if (action === "open") {
|
||||
await openItem(item);
|
||||
try {
|
||||
if (action === "open") {
|
||||
await openItem(item);
|
||||
return;
|
||||
}
|
||||
if (action === "download") {
|
||||
await downloadSelected(item);
|
||||
return;
|
||||
}
|
||||
if (action === "rename") {
|
||||
await renameSelected(item);
|
||||
return;
|
||||
}
|
||||
if (action === "delete") {
|
||||
await deleteSelected(item);
|
||||
return;
|
||||
}
|
||||
if (action === "share") {
|
||||
await createShareForItem(item);
|
||||
return;
|
||||
}
|
||||
await createDirectLinkForItem(item);
|
||||
} catch {
|
||||
// errors already handled by action method
|
||||
}
|
||||
}
|
||||
|
||||
async function retryTransferTask(taskId: string) {
|
||||
const task = transferTasks.value.find((item) => item.id === taskId);
|
||||
if (!task) return;
|
||||
if (isTaskRunning(task.status)) return;
|
||||
if (task.status === "done") return;
|
||||
|
||||
if (task.kind === "upload") {
|
||||
if (!task.filePath || !task.targetPath) {
|
||||
updateTransferTask(taskId, { status: "failed", note: "缺少上传任务参数" });
|
||||
return;
|
||||
}
|
||||
await waitForTransferQueue();
|
||||
updateTransferTask(taskId, { status: "uploading", speed: "重试上传", progress: 10, note: "正在重试" });
|
||||
const response = await uploadFileWithResume(task.filePath, task.targetPath);
|
||||
if (response.ok && response.data?.success) {
|
||||
updateTransferTask(taskId, { status: "done", speed: "-", progress: 100, note: "重试成功" });
|
||||
if (nav.value === "files") {
|
||||
await loadFiles(pathState.currentPath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
updateTransferTask(taskId, {
|
||||
status: "failed",
|
||||
speed: "-",
|
||||
progress: 0,
|
||||
note: String(response.data?.message || "重试上传失败"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (action === "download") {
|
||||
await downloadSelected(item);
|
||||
|
||||
if (!task.downloadUrl) {
|
||||
updateTransferTask(taskId, { status: "failed", note: "缺少下载地址" });
|
||||
return;
|
||||
}
|
||||
if (action === "rename") {
|
||||
await renameSelected(item);
|
||||
await waitForTransferQueue();
|
||||
updateTransferTask(taskId, { status: "downloading", speed: "重试下载", progress: 10, note: "正在重试" });
|
||||
const response = await invokeBridge("api_native_download", {
|
||||
url: task.downloadUrl,
|
||||
fileName: task.fileName || task.name,
|
||||
});
|
||||
if (response.ok && response.data?.success) {
|
||||
const resumedBytes = Number(response.data?.resumedBytes || 0);
|
||||
const resumeText = resumedBytes > 0 ? `,已续传 ${formatBytes(resumedBytes)}` : "";
|
||||
updateTransferTask(taskId, { status: "done", speed: "-", progress: 100, note: `下载成功${resumeText}` });
|
||||
return;
|
||||
}
|
||||
if (action === "delete") {
|
||||
await deleteSelected(item);
|
||||
updateTransferTask(taskId, {
|
||||
status: "failed",
|
||||
speed: "-",
|
||||
progress: 0,
|
||||
note: String(response.data?.message || "重试下载失败"),
|
||||
});
|
||||
}
|
||||
|
||||
async function batchDeleteSelected() {
|
||||
if (!batchMode.value || batchSelectedItems.value.length === 0) {
|
||||
showToast("请先勾选批量文件", "info");
|
||||
return;
|
||||
}
|
||||
if (action === "share") {
|
||||
await createShareForItem(item);
|
||||
const confirmed = window.confirm(`确认批量删除 ${batchSelectedItems.value.length} 个项目吗?`);
|
||||
if (!confirmed) return;
|
||||
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
const items = [...batchSelectedItems.value];
|
||||
for (const item of items) {
|
||||
try {
|
||||
await deleteSelected(item, true);
|
||||
success += 1;
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
await loadFiles(pathState.currentPath);
|
||||
clearBatchSelection();
|
||||
const failedText = failed > 0 ? `,失败 ${failed} 个` : "";
|
||||
showToast(`批量删除完成:成功 ${success} 个${failedText}`, failed > 0 ? "info" : "success");
|
||||
}
|
||||
|
||||
async function batchShareSelected() {
|
||||
if (!batchMode.value || batchSelectedItems.value.length === 0) {
|
||||
showToast("请先勾选批量文件", "info");
|
||||
return;
|
||||
}
|
||||
await createDirectLinkForItem(item);
|
||||
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
const items = [...batchSelectedItems.value];
|
||||
for (const item of items) {
|
||||
try {
|
||||
await createShareForItem(item, true);
|
||||
success += 1;
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
await loadShares(true);
|
||||
const failedText = failed > 0 ? `,失败 ${failed} 个` : "";
|
||||
showToast(`批量分享完成:成功 ${success} 个${failedText}`, failed > 0 ? "info" : "success");
|
||||
}
|
||||
|
||||
async function batchDirectLinkSelected() {
|
||||
if (!batchMode.value || batchSelectedItems.value.length === 0) {
|
||||
showToast("请先勾选批量文件", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
const items = [...batchSelectedItems.value];
|
||||
for (const item of items) {
|
||||
try {
|
||||
await createDirectLinkForItem(item, true);
|
||||
success += 1;
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
await loadShares(true);
|
||||
const failedText = failed > 0 ? `,失败 ${failed} 个` : "";
|
||||
showToast(`批量直链完成:成功 ${success} 个${failedText}`, failed > 0 ? "info" : "success");
|
||||
}
|
||||
|
||||
function handleGlobalClick() {
|
||||
@@ -1181,23 +1494,25 @@ async function uploadDroppedFiles(paths: string[]) {
|
||||
|
||||
let successCount = 0;
|
||||
for (let index = 0; index < uniquePaths.length; index += 1) {
|
||||
await waitForTransferQueue();
|
||||
const filePath = uniquePaths[index];
|
||||
const displayName = extractFileNameFromPath(filePath) || `文件${index + 1}`;
|
||||
const taskId = `U-${Date.now()}-${index}`;
|
||||
|
||||
prependTransferTask({
|
||||
id: taskId,
|
||||
kind: "upload",
|
||||
name: displayName,
|
||||
speed: "上传中",
|
||||
progress: 8,
|
||||
status: "uploading",
|
||||
});
|
||||
|
||||
const response = await invokeBridge("api_upload_file", {
|
||||
baseUrl: appConfig.baseUrl,
|
||||
speed: "等待上传",
|
||||
progress: 2,
|
||||
status: "queued",
|
||||
filePath,
|
||||
targetPath: pathState.currentPath,
|
||||
fileName: displayName,
|
||||
});
|
||||
updateTransferTask(taskId, { status: "uploading", speed: "上传中", progress: 10 });
|
||||
|
||||
const response = await uploadFileWithResume(filePath, pathState.currentPath);
|
||||
|
||||
if (response.ok && response.data?.success) {
|
||||
successCount += 1;
|
||||
@@ -1411,10 +1726,19 @@ onBeforeUnmount(() => {
|
||||
<option v-for="opt in fileSortOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||
</select>
|
||||
<button class="action-btn" @click="toggleFileSortOrder">{{ fileViewState.sortOrder === "asc" ? "升序" : "降序" }}</button>
|
||||
<button class="action-btn" :class="{ active: batchMode }" @click="toggleBatchMode">{{ batchMode ? "退出批量" : "批量选择" }}</button>
|
||||
<button v-if="batchMode" class="action-btn" @click="clearBatchSelection">清空勾选</button>
|
||||
<button v-if="batchMode" class="action-btn danger" :disabled="batchSelectedItems.length === 0" @click="batchDeleteSelected">批量删除</button>
|
||||
<button v-if="batchMode" class="action-btn" :disabled="batchSelectedItems.length === 0" @click="batchShareSelected">批量分享</button>
|
||||
<button v-if="batchMode" class="action-btn" :disabled="batchSelectedItems.length === 0" @click="batchDirectLinkSelected">批量直链</button>
|
||||
<button class="action-btn" @click="runGlobalSearch">搜索</button>
|
||||
<button class="action-btn" @click="createFolder">新建文件夹</button>
|
||||
<button class="action-btn" @click="loadFiles(pathState.currentPath)">刷新</button>
|
||||
</template>
|
||||
<template v-else-if="nav === 'transfers'">
|
||||
<button class="action-btn" @click="toggleTransferQueuePause">{{ transferQueue.paused ? "继续队列" : "暂停队列" }}</button>
|
||||
<button class="action-btn" @click="clearCompletedTransferTasks">清理已结束</button>
|
||||
</template>
|
||||
<template v-else-if="nav === 'shares'">
|
||||
<button class="action-btn" @click="loadShares()">刷新分享</button>
|
||||
</template>
|
||||
@@ -1428,7 +1752,7 @@ onBeforeUnmount(() => {
|
||||
<button class="action-btn" :disabled="updateState.checking" @click="checkClientUpdate()">
|
||||
{{ updateState.checking ? "检查中..." : "检查更新" }}
|
||||
</button>
|
||||
<button class="action-btn" :disabled="!updateState.available || !updateState.downloadUrl" @click="openUpdateDownloadUrl">立即更新</button>
|
||||
<button class="action-btn" :disabled="!updateState.available || !updateState.downloadUrl" @click="installLatestUpdate">立即更新</button>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
@@ -1451,11 +1775,14 @@ onBeforeUnmount(() => {
|
||||
:key="item.name"
|
||||
type="button"
|
||||
class="file-card"
|
||||
:class="{ selected: selectedFileName === item.name }"
|
||||
:class="{ selected: selectedFileName === item.name, batchSelected: isBatchSelected(item.name) }"
|
||||
@click="selectFile(item)"
|
||||
@dblclick="openItem(item)"
|
||||
@contextmenu.prevent="openContextMenu($event, item)"
|
||||
>
|
||||
<div v-if="batchMode" class="batch-check" :class="{ active: isBatchSelected(item.name) }">
|
||||
{{ isBatchSelected(item.name) ? "✓" : "" }}
|
||||
</div>
|
||||
<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>
|
||||
@@ -1476,7 +1803,7 @@ onBeforeUnmount(() => {
|
||||
<template v-else-if="nav === 'transfers'">
|
||||
<div class="panel-head">
|
||||
<h3>传输任务</h3>
|
||||
<span>上传/下载队列</span>
|
||||
<span>{{ transferQueue.paused ? "队列已暂停(进行中任务会继续)" : "上传/下载队列" }}</span>
|
||||
</div>
|
||||
<div v-if="transferTasks.length === 0" class="empty-tip">暂无传输任务</div>
|
||||
<div v-else class="task-list">
|
||||
@@ -1491,6 +1818,10 @@ onBeforeUnmount(() => {
|
||||
<div class="progress">
|
||||
<div class="bar" :style="{ width: `${task.progress}%` }" />
|
||||
</div>
|
||||
<div class="task-actions">
|
||||
<button v-if="task.status === 'failed'" class="mini-btn" @click="retryTransferTask(task.id)">重试</button>
|
||||
<button v-if="task.status === 'done' || task.status === 'failed'" class="mini-btn ghost" @click="removeTransferTask(task.id)">移除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1654,6 +1985,11 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="batchMode" class="selected-info">
|
||||
<h4>批量操作</h4>
|
||||
<p>已勾选:{{ batchSelectedItems.length }} 项</p>
|
||||
<p>可执行:批量删除、批量分享、批量直链</p>
|
||||
</div>
|
||||
<div v-if="selectedFile" class="selected-info">
|
||||
<h4>{{ selectedFile.displayName || selectedFile.name }}</h4>
|
||||
<p>类型:{{ fileTypeLabel(selectedFile) }}</p>
|
||||
@@ -2164,6 +2500,12 @@ select:focus {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action-btn.active {
|
||||
border-color: #6a99de;
|
||||
background: #eaf3ff;
|
||||
color: #1d4f93;
|
||||
}
|
||||
|
||||
.action-btn.danger {
|
||||
border-color: #efc3c3;
|
||||
color: #b53f3f;
|
||||
@@ -2243,6 +2585,7 @@ select:focus {
|
||||
width: 144px;
|
||||
height: 144px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.file-card:hover {
|
||||
@@ -2256,6 +2599,32 @@ select:focus {
|
||||
box-shadow: 0 0 0 2px rgba(29, 111, 255, 0.1);
|
||||
}
|
||||
|
||||
.file-card.batchSelected {
|
||||
border-color: #3f84ec;
|
||||
box-shadow: 0 0 0 2px rgba(63, 132, 236, 0.14);
|
||||
}
|
||||
|
||||
.batch-check {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #c3d3e7;
|
||||
background: #fff;
|
||||
color: #2a5da6;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.batch-check.active {
|
||||
border-color: #3f84ec;
|
||||
background: #e6f0ff;
|
||||
}
|
||||
|
||||
.file-icon-glyph {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
@@ -2342,6 +2711,31 @@ select:focus {
|
||||
color: #4a6381;
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mini-btn {
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #c5d4e8;
|
||||
background: #fff;
|
||||
color: #2f4f74;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mini-btn:hover {
|
||||
background: #f3f8ff;
|
||||
}
|
||||
|
||||
.mini-btn.ghost {
|
||||
color: #6882a0;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 8px;
|
||||
background: #e4edf9;
|
||||
|
||||
Reference in New Issue
Block a user