refactor(config): ♻️ enable incremental compilation and add MD5 dependency
Enable incremental compilation in both dev and release profiles for faster rebuilds Add md-5 crate dependency for hash computation Add language response rule to AI configuration closed #443
This commit is contained in:
3
.rules
3
.rules
@@ -120,6 +120,9 @@ Theme tokens live in `src/styles/scss/global/variable.scss`, but prefer inline U
|
||||
- For multi-property helpers, apply directives are available because `@unocss/transformer-directives` is enabled: `@apply text-[--text-color]`.
|
||||
- When a component needs conditional theming, toggle `data-theme` on `<html>` (light/dark) or add scoped data attributes (e.g. `data-theme="compact"`) and extend `variable.scss` with the selector.
|
||||
|
||||
### language
|
||||
- The language of the reply is determined based on the language of the user's question. For example, if a user asks a question in simplified Chinese, reply in simplified Chinese.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@@ -2872,6 +2872,7 @@ dependencies = [
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"libsqlite3-sys",
|
||||
"md-5",
|
||||
"migration",
|
||||
"mime_guess",
|
||||
"moka",
|
||||
|
||||
@@ -18,6 +18,7 @@ entity = { path = "entity" }
|
||||
opt-level = 0 # 无优化
|
||||
debug = true # 开启调试信息
|
||||
overflow-checks = true # 整数溢出检查
|
||||
incremental = true # 启用增量编译
|
||||
|
||||
[profile.release]
|
||||
panic = "abort" # 崩溃时直接终止
|
||||
@@ -26,6 +27,7 @@ lto = true # 启用链接到优化
|
||||
opt-level = 3 # 最大优化
|
||||
strip = true # 删除调试符号
|
||||
debug = false # 关闭调试信息
|
||||
incremental = true # 启用增量编译
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
@@ -111,6 +113,7 @@ bytes = "1.11"
|
||||
dotenv = "0.15.0"
|
||||
pulldown-cmark = "0.13"
|
||||
libsqlite3-sys = { version = "0.30.1", features = ["bundled-sqlcipher-vendored-openssl"] }
|
||||
md-5 = "0.10"
|
||||
|
||||
# WebSocket 相关依赖
|
||||
tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] }
|
||||
|
||||
6
src-tauri/gen/apple/hula_iOS/Info.plist
vendored
6
src-tauri/gen/apple/hula_iOS/Info.plist
vendored
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>3.0.6</string>
|
||||
<string>3.0.7</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>3.0.6</string>
|
||||
<string>3.0.7</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAccessibilityUsageDescription</key>
|
||||
@@ -49,4 +49,4 @@
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>HuLa needs local network access to reach your development server</string>
|
||||
</dict>
|
||||
</plist>
|
||||
</plist>
|
||||
|
||||
2
src-tauri/gen/schemas/acl-manifests.json
vendored
2
src-tauri/gen/schemas/acl-manifests.json
vendored
File diff suppressed because one or more lines are too long
@@ -13,6 +13,7 @@ pub mod message_mark_command;
|
||||
pub mod request_command;
|
||||
pub mod room_member_command;
|
||||
pub mod setting_command;
|
||||
pub mod upload_command;
|
||||
pub mod user_command;
|
||||
|
||||
// A custom task for setting the state of a setup task
|
||||
|
||||
328
src-tauri/src/command/upload_command.rs
Normal file
328
src-tauri/src/command/upload_command.rs
Normal file
@@ -0,0 +1,328 @@
|
||||
use bytes::Bytes;
|
||||
use futures_util::stream::try_unfold;
|
||||
use md5::{Digest, Md5};
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
use tauri::{AppHandle, Manager, ipc::Channel, path::BaseDirectory};
|
||||
use tokio::{fs::File, io::AsyncReadExt};
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UploadProgressPayload {
|
||||
pub progress_total: u64,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct QiniuMkblkResponse {
|
||||
ctx: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct QiniuMkfileResponse {
|
||||
key: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn upload_file_put(
|
||||
app_handle: AppHandle,
|
||||
url: String,
|
||||
path: String,
|
||||
base_dir: Option<String>,
|
||||
headers: Option<HashMap<String, String>>,
|
||||
on_progress: Channel<UploadProgressPayload>,
|
||||
) -> Result<(), String> {
|
||||
let file_path = resolve_upload_path(&app_handle, &path, base_dir.as_deref())?;
|
||||
upload_put(url, file_path, headers.unwrap_or_default(), on_progress).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn qiniu_upload_resumable(
|
||||
app_handle: AppHandle,
|
||||
path: String,
|
||||
base_dir: Option<String>,
|
||||
token: String,
|
||||
domain: String,
|
||||
scene: Option<String>,
|
||||
account: Option<String>,
|
||||
storage_prefix: Option<String>,
|
||||
enable_deduplication: Option<bool>,
|
||||
on_progress: Channel<UploadProgressPayload>,
|
||||
) -> Result<String, String> {
|
||||
let file_path = resolve_upload_path(&app_handle, &path, base_dir.as_deref())?;
|
||||
let file_name = file_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or_else(|| "Failed to determine file name".to_string())?
|
||||
.to_string();
|
||||
|
||||
qiniu_resumable_upload(
|
||||
file_path,
|
||||
token,
|
||||
domain,
|
||||
scene.unwrap_or_else(|| "chat".to_string()),
|
||||
account,
|
||||
storage_prefix,
|
||||
enable_deduplication.unwrap_or(false),
|
||||
file_name,
|
||||
on_progress,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn resolve_upload_path(
|
||||
app_handle: &AppHandle,
|
||||
path: &str,
|
||||
base_dir: Option<&str>,
|
||||
) -> Result<PathBuf, String> {
|
||||
let path_buf = PathBuf::from(path);
|
||||
if path_buf.is_absolute() {
|
||||
return Ok(path_buf);
|
||||
}
|
||||
|
||||
let Some(base_dir) = base_dir else {
|
||||
return Ok(path_buf);
|
||||
};
|
||||
|
||||
let base_dir = match base_dir {
|
||||
"AppCache" | "appCache" | "app_cache" => BaseDirectory::AppCache,
|
||||
"AppData" | "appData" | "app_data" => BaseDirectory::AppData,
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"Unsupported baseDir: {base_dir}, expected AppCache/AppData"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
app_handle
|
||||
.path()
|
||||
.resolve(path, base_dir)
|
||||
.map_err(|e| format!("Failed to resolve file path: {e}"))
|
||||
}
|
||||
|
||||
async fn upload_put(
|
||||
url: String,
|
||||
file_path: PathBuf,
|
||||
headers: HashMap<String, String>,
|
||||
on_progress: Channel<UploadProgressPayload>,
|
||||
) -> Result<(), String> {
|
||||
let file = File::open(&file_path)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to open file: {e}"))?;
|
||||
let total = file
|
||||
.metadata()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read file metadata: {e}"))?
|
||||
.len();
|
||||
|
||||
let chunk_size: usize = 4 * 1024 * 1024;
|
||||
let stream = try_unfold(
|
||||
(file, 0_u64, on_progress),
|
||||
move |(mut file, mut transferred, on_progress)| async move {
|
||||
let mut buf = vec![0u8; chunk_size];
|
||||
let read = file.read(&mut buf).await?;
|
||||
|
||||
if read == 0 {
|
||||
return Ok::<_, std::io::Error>(None);
|
||||
}
|
||||
|
||||
buf.truncate(read);
|
||||
transferred = transferred.saturating_add(read as u64);
|
||||
|
||||
let _ = on_progress.send(UploadProgressPayload {
|
||||
progress_total: transferred,
|
||||
total,
|
||||
});
|
||||
|
||||
Ok(Some((Bytes::from(buf), (file, transferred, on_progress))))
|
||||
},
|
||||
);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let mut request = client
|
||||
.put(url)
|
||||
.header(reqwest::header::CONTENT_LENGTH, total)
|
||||
.body(reqwest::Body::wrap_stream(stream));
|
||||
|
||||
for (key, value) in headers {
|
||||
request = request.header(key, value);
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Upload request failed: {e}"))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
Err(format!("Upload failed with status {status}: {body}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn hex_lower(bytes: &[u8]) -> String {
|
||||
const LUT: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut out = String::with_capacity(bytes.len() * 2);
|
||||
for &b in bytes {
|
||||
out.push(LUT[(b >> 4) as usize] as char);
|
||||
out.push(LUT[(b & 0x0f) as usize] as char);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn build_qiniu_key(options: QiniuKeyOptions<'_>) -> String {
|
||||
let timestamp = options.timestamp_ms;
|
||||
|
||||
if options.total_size > options.chunk_threshold {
|
||||
let prefix = options
|
||||
.storage_prefix
|
||||
.filter(|p| !p.is_empty())
|
||||
.unwrap_or(options.scene);
|
||||
return format!("{prefix}/{timestamp}_{}", options.file_name);
|
||||
}
|
||||
|
||||
if options.enable_deduplication {
|
||||
if let (Some(account), Some(md5_hex)) = (options.account, options.md5_hex) {
|
||||
let suffix = options.file_name.split('.').last().unwrap_or("");
|
||||
return format!("{}/{}/{}.{}", options.scene, account, md5_hex, suffix);
|
||||
}
|
||||
}
|
||||
|
||||
format!("{}/{timestamp}_{}", options.scene, options.file_name)
|
||||
}
|
||||
|
||||
struct QiniuKeyOptions<'a> {
|
||||
scene: &'a str,
|
||||
account: Option<&'a str>,
|
||||
storage_prefix: Option<&'a str>,
|
||||
enable_deduplication: bool,
|
||||
total_size: u64,
|
||||
chunk_threshold: u64,
|
||||
file_name: &'a str,
|
||||
timestamp_ms: u128,
|
||||
md5_hex: Option<&'a str>,
|
||||
}
|
||||
|
||||
async fn qiniu_resumable_upload(
|
||||
file_path: PathBuf,
|
||||
token: String,
|
||||
domain: String,
|
||||
scene: String,
|
||||
account: Option<String>,
|
||||
storage_prefix: Option<String>,
|
||||
enable_deduplication: bool,
|
||||
file_name: String,
|
||||
on_progress: Channel<UploadProgressPayload>,
|
||||
) -> Result<String, String> {
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
|
||||
let domain = domain.trim_end_matches('/').to_string();
|
||||
let mut file = File::open(&file_path)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to open file: {e}"))?;
|
||||
let total = file
|
||||
.metadata()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read file metadata: {e}"))?
|
||||
.len();
|
||||
|
||||
let chunk_size: u64 = 4 * 1024 * 1024;
|
||||
let chunk_threshold: u64 = 4 * 1024 * 1024;
|
||||
let timestamp_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0);
|
||||
|
||||
let should_hash =
|
||||
enable_deduplication && total <= chunk_threshold && account.as_deref().is_some();
|
||||
let mut hasher = should_hash.then(Md5::new);
|
||||
let mut contexts: Vec<String> = Vec::new();
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let mut transferred: u64 = 0;
|
||||
|
||||
while transferred < total {
|
||||
let remaining = total.saturating_sub(transferred);
|
||||
let len = std::cmp::min(chunk_size, remaining) as usize;
|
||||
let mut buf = vec![0u8; len];
|
||||
file.read_exact(&mut buf)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read file: {e}"))?;
|
||||
|
||||
if let Some(ref mut h) = hasher {
|
||||
h.update(&buf);
|
||||
}
|
||||
|
||||
let mkblk_url = format!("{domain}/mkblk/{len}");
|
||||
let response = client
|
||||
.post(mkblk_url)
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
|
||||
.header(reqwest::header::AUTHORIZATION, format!("UpToken {token}"))
|
||||
.body(Bytes::from(buf))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Upload request failed: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("mkblk failed with status {status}: {body}"));
|
||||
}
|
||||
|
||||
let payload = response
|
||||
.json::<QiniuMkblkResponse>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse mkblk response: {e}"))?;
|
||||
|
||||
contexts.push(payload.ctx);
|
||||
transferred = transferred.saturating_add(len as u64);
|
||||
let _ = on_progress.send(UploadProgressPayload {
|
||||
progress_total: transferred,
|
||||
total,
|
||||
});
|
||||
}
|
||||
|
||||
let md5_hex = hasher.map(|h| {
|
||||
let digest = h.finalize();
|
||||
hex_lower(digest.as_ref())
|
||||
});
|
||||
let key = build_qiniu_key(QiniuKeyOptions {
|
||||
scene: &scene,
|
||||
account: account.as_deref(),
|
||||
storage_prefix: storage_prefix.as_deref(),
|
||||
enable_deduplication,
|
||||
total_size: total,
|
||||
chunk_threshold,
|
||||
file_name: &file_name,
|
||||
timestamp_ms,
|
||||
md5_hex: md5_hex.as_deref(),
|
||||
});
|
||||
let encoded_key = STANDARD.encode(&key);
|
||||
|
||||
let mkfile_url = format!("{domain}/mkfile/{total}/key/{encoded_key}");
|
||||
let response = client
|
||||
.post(mkfile_url)
|
||||
.header(reqwest::header::CONTENT_TYPE, "text/plain")
|
||||
.header(reqwest::header::AUTHORIZATION, format!("UpToken {token}"))
|
||||
.body(contexts.join(","))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Finalize request failed: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("mkfile failed with status {status}: {body}"));
|
||||
}
|
||||
|
||||
let payload = response
|
||||
.json::<QiniuMkfileResponse>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse mkfile response: {e}"))?;
|
||||
|
||||
Ok(payload.key.unwrap_or(key))
|
||||
}
|
||||
@@ -385,6 +385,7 @@ fn get_invoke_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Se
|
||||
use crate::command::markdown_command::{get_readme_html, parse_markdown};
|
||||
#[cfg(mobile)]
|
||||
use crate::command::set_complete;
|
||||
use crate::command::upload_command::{qiniu_upload_resumable, upload_file_put};
|
||||
use crate::command::user_command::{
|
||||
get_user_tokens, save_user_info, update_token, update_user_last_opt_time,
|
||||
};
|
||||
@@ -478,6 +479,8 @@ fn get_invoke_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Se
|
||||
// Markdown 相关命令
|
||||
parse_markdown,
|
||||
get_readme_html,
|
||||
upload_file_put,
|
||||
qiniu_upload_resumable,
|
||||
#[cfg(mobile)]
|
||||
set_complete,
|
||||
#[cfg(mobile)]
|
||||
|
||||
@@ -73,13 +73,14 @@
|
||||
import { formatBytes } from '@/utils/Formatting'
|
||||
import { isMac, isWindows } from '@/utils/PlatformConstants'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { UploadFile } from '@/utils/FileType'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
show: boolean
|
||||
files: File[]
|
||||
files: UploadFile[]
|
||||
}>(),
|
||||
{
|
||||
show: false,
|
||||
@@ -89,7 +90,7 @@ const props = withDefaults(
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:show', value: boolean): void
|
||||
(e: 'confirm', files: File[]): void
|
||||
(e: 'confirm', files: UploadFile[]): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
@@ -98,7 +99,7 @@ const visible = computed({
|
||||
set: (value: boolean) => emit('update:show', value)
|
||||
})
|
||||
|
||||
const fileList = ref<File[]>([])
|
||||
const fileList = ref<UploadFile[]>([])
|
||||
|
||||
watch(
|
||||
() => props.files,
|
||||
|
||||
@@ -248,6 +248,7 @@ import { useSendOptions } from '@/views/moreWindow/settings/config.ts'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { MobilePanelStateEnum } from '@/enums'
|
||||
import { useI18n, I18nT } from 'vue-i18n'
|
||||
import type { UploadFile } from '@/utils/FileType'
|
||||
|
||||
interface Props {
|
||||
isAIMode?: boolean
|
||||
@@ -281,7 +282,7 @@ const groupStore = useGroupStore()
|
||||
|
||||
// 文件上传弹窗状态
|
||||
const showFileModal = ref(false)
|
||||
const pendingFiles = ref<File[]>([])
|
||||
const pendingFiles = ref<UploadFile[]>([])
|
||||
|
||||
/** 引入useMsgInput的相关方法 */
|
||||
const {
|
||||
@@ -366,7 +367,7 @@ const handleInternalInput = (e: Event) => {
|
||||
}
|
||||
|
||||
// 显示文件弹窗的回调函数
|
||||
const showFileModalCallback = (files: File[]) => {
|
||||
const showFileModalCallback = (files: UploadFile[]) => {
|
||||
pendingFiles.value = files
|
||||
showFileModal.value = true
|
||||
}
|
||||
@@ -376,7 +377,7 @@ const onPaste = async (e: ClipboardEvent) => {
|
||||
}
|
||||
|
||||
// 处理弹窗确认
|
||||
const handleFileConfirm = async (files: File[]) => {
|
||||
const handleFileConfirm = async (files: UploadFile[]) => {
|
||||
try {
|
||||
await sendFilesDirect(files)
|
||||
} catch (error) {
|
||||
|
||||
@@ -785,12 +785,18 @@ const handleViewAnnouncement = (): void => {
|
||||
}
|
||||
|
||||
// 监听滚动到底部的事件
|
||||
useMitt.on(MittEnum.CHAT_SCROLL_BOTTOM, async () => {
|
||||
// 只有消息数量超过60条才进行重置和刷新
|
||||
if (chatStore.chatMessageList.length > 20) {
|
||||
chatStore.clearRedundantMessages(globalStore.currentSessionRoomId)
|
||||
}
|
||||
scrollToBottom()
|
||||
let scrollBottomScheduled = false
|
||||
useMitt.on(MittEnum.CHAT_SCROLL_BOTTOM, () => {
|
||||
if (scrollBottomScheduled) return
|
||||
scrollBottomScheduled = true
|
||||
requestAnimationFrame(() => {
|
||||
scrollBottomScheduled = false
|
||||
// 只有消息数量超过60条才进行重置和刷新
|
||||
if (chatStore.chatMessageList.length > 60) {
|
||||
chatStore.clearRedundantMessages(globalStore.currentSessionRoomId)
|
||||
}
|
||||
scrollToBottom()
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -569,7 +569,11 @@ export enum TauriCommand {
|
||||
/** AI 消息流式发送 */
|
||||
AI_MESSAGE_SEND_STREAM = 'ai_message_send_stream',
|
||||
/** 生成 MinIO 预签名 URL */
|
||||
GENERATE_MINIO_PRESIGNED_URL = 'generate_minio_presigned_url'
|
||||
GENERATE_MINIO_PRESIGNED_URL = 'generate_minio_presigned_url',
|
||||
/** 通过 Rust 端 PUT 上传本地文件 */
|
||||
UPLOAD_FILE_PUT = 'upload_file_put',
|
||||
/** 通过 Rust 端七牛分片上传本地文件 */
|
||||
QINIU_UPLOAD_RESUMABLE = 'qiniu_upload_resumable'
|
||||
}
|
||||
|
||||
// 通话状态枚举
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
|
||||
import { BaseDirectory, create, exists, mkdir } from '@tauri-apps/plugin-fs'
|
||||
import { BaseDirectory, create, exists, mkdir, readFile } from '@tauri-apps/plugin-fs'
|
||||
import { info } from '@tauri-apps/plugin-log'
|
||||
import GraphemeSplitter from 'grapheme-splitter'
|
||||
import type { Ref } from 'vue'
|
||||
@@ -12,8 +12,10 @@ import { useGlobalStore } from '@/stores/global.ts'
|
||||
import { useUserStore } from '@/stores/user.ts'
|
||||
import { AvatarUtils } from '@/utils/AvatarUtils'
|
||||
import { removeTag } from '@/utils/Formatting'
|
||||
import { SUPPORTED_IMAGE_EXTENSIONS, getFileExtension } from '@/utils/FileType'
|
||||
import { getSessionDetailWithFriends } from '@/utils/ImRequestUtils'
|
||||
import { getImageCache } from '@/utils/PathUtil.ts'
|
||||
import { isPathUploadFile, type UploadFile } from '@/utils/FileType'
|
||||
import { isMobile } from '@/utils/PlatformConstants'
|
||||
import { invokeWithErrorHandler } from '../utils/TauriInvokeHandler'
|
||||
|
||||
@@ -797,7 +799,7 @@ export const useCommon = () => {
|
||||
* @param dom 输入框dom
|
||||
* @param showFileModal 显示文件弹窗的回调函数
|
||||
*/
|
||||
const handlePaste = async (e: any, dom: HTMLElement, showFileModal?: (files: File[]) => void) => {
|
||||
const handlePaste = async (e: any, dom: HTMLElement, showFileModal?: (files: UploadFile[]) => void) => {
|
||||
e.preventDefault()
|
||||
if (e.clipboardData.files.length > 0) {
|
||||
// 使用通用文件处理函数
|
||||
@@ -861,9 +863,9 @@ export const useCommon = () => {
|
||||
* @param resetCallback 重置回调函数(可选)
|
||||
*/
|
||||
const processFiles = async (
|
||||
files: File[],
|
||||
files: UploadFile[],
|
||||
dom: HTMLElement,
|
||||
showFileModal?: (files: File[]) => void,
|
||||
showFileModal?: (files: UploadFile[]) => void,
|
||||
resetCallback?: () => void
|
||||
) => {
|
||||
if (!files) return
|
||||
@@ -875,31 +877,37 @@ export const useCommon = () => {
|
||||
}
|
||||
|
||||
// 分类文件:图片 or 其他文件
|
||||
const imageFiles: File[] = []
|
||||
const otherFiles: File[] = []
|
||||
const imageFiles: UploadFile[] = []
|
||||
const otherFiles: UploadFile[] = []
|
||||
|
||||
for (const file of files) {
|
||||
// 检查文件大小
|
||||
const fileSizeInMB = file.size / 1024 / 1024
|
||||
if (fileSizeInMB > 100) {
|
||||
window.$message.warning(`文件 ${file.name} 超过100MB`)
|
||||
if (fileSizeInMB > 500) {
|
||||
window.$message.warning(`文件 ${file.name} 超过500MB`)
|
||||
continue
|
||||
}
|
||||
|
||||
const fileType = file.type
|
||||
const mimeType = file.type || ''
|
||||
const extension = getFileExtension(file.name)
|
||||
const isImage =
|
||||
(mimeType.startsWith('image/') || SUPPORTED_IMAGE_EXTENSIONS.includes(extension as any)) &&
|
||||
extension !== 'svg' &&
|
||||
!mimeType.includes('svg')
|
||||
|
||||
// 加上includes用于保底判断文件类型不是mime时的处理逻辑
|
||||
if (fileType.startsWith('image/') || ['png', 'jpg', 'jpeg', 'webp', 'gif'].includes(fileType)) {
|
||||
imageFiles.push(file)
|
||||
} else {
|
||||
// 视频和其他文件通过弹窗处理
|
||||
otherFiles.push(file)
|
||||
}
|
||||
if (isImage) imageFiles.push(file)
|
||||
else otherFiles.push(file)
|
||||
}
|
||||
|
||||
// 处理图片文件(直接插入输入框)
|
||||
for (const file of imageFiles) {
|
||||
await imgPaste(file, dom)
|
||||
if (isPathUploadFile(file)) {
|
||||
const fileData = await readFile(file.path)
|
||||
const fileObj = new File([fileData], file.name, { type: file.type })
|
||||
await imgPaste(fileObj, dom)
|
||||
} else {
|
||||
await imgPaste(file, dom)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理其他文件(显示弹窗)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computed, reactive, readonly } from 'vue'
|
||||
import type { UploadFile } from '@/utils/FileType'
|
||||
|
||||
export type FileUploadItem = {
|
||||
id: string
|
||||
@@ -49,7 +50,7 @@ export const useFileUploadQueue = () => {
|
||||
/**
|
||||
* 初始化队列
|
||||
*/
|
||||
const initQueue = (files: File[]) => {
|
||||
const initQueue = (files: UploadFile[]) => {
|
||||
queue.items = files.map((file, index) => ({
|
||||
id: `${Date.now()}_${index}`,
|
||||
name: file.name,
|
||||
|
||||
@@ -14,10 +14,9 @@ import { useGlobalStore } from '@/stores/global.ts'
|
||||
import { useGroupStore } from '@/stores/group.ts'
|
||||
import { useSettingStore } from '@/stores/setting.ts'
|
||||
import { messageStrategyMap } from '@/strategy/MessageStrategy.ts'
|
||||
import { fixFileMimeType, getMessageTypeByFile } from '@/utils/FileType.ts'
|
||||
import { generateVideoThumbnail } from '@/utils/VideoThumbnail'
|
||||
import { processClipboardImage } from '@/utils/ImageUtils.ts'
|
||||
import { getReplyContent } from '@/utils/MessageReply.ts'
|
||||
import { isPathUploadFile, type PathUploadFile, type UploadFile } from '@/utils/FileType'
|
||||
import { isMac, isMobile, isWindows } from '@/utils/PlatformConstants'
|
||||
import { type SelectionRange, useCommon } from './useCommon.ts'
|
||||
import { globalFileUploadQueue } from './useFileUploadQueue.ts'
|
||||
@@ -71,18 +70,26 @@ export const useMsgInput = (messageInputDom: Ref) => {
|
||||
const groupStore = useGroupStore()
|
||||
const chatStore = useChatStore()
|
||||
const globalStore = useGlobalStore()
|
||||
const { uploadToQiniu } = useUpload()
|
||||
const { getCursorSelectionRange, updateSelectionRange, focusOn } = useCursorManager()
|
||||
const {
|
||||
triggerInputEvent,
|
||||
insertNode,
|
||||
getMessageContentType,
|
||||
getEditorRange,
|
||||
imgPaste,
|
||||
saveCacheFile,
|
||||
reply,
|
||||
userUid
|
||||
} = useCommon()
|
||||
const { triggerInputEvent, insertNode, getMessageContentType, getEditorRange, imgPaste, reply, userUid } = useCommon()
|
||||
|
||||
const createRafProgressUpdater = (tempMsgId: string) => {
|
||||
let scheduled = false
|
||||
let latest = 0
|
||||
return (value: number) => {
|
||||
latest = value
|
||||
if (scheduled) return
|
||||
scheduled = true
|
||||
requestAnimationFrame(() => {
|
||||
scheduled = false
|
||||
chatStore.updateMsg({
|
||||
msgId: tempMsgId,
|
||||
status: MessageStatusEnum.SENDING,
|
||||
uploadProgress: latest
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
const settingStore = useSettingStore()
|
||||
const { chat } = storeToRefs(settingStore)
|
||||
/** 艾特选项的key */
|
||||
@@ -797,27 +804,17 @@ export const useMsgInput = (messageInputDom: Ref) => {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 视频文件处理函数 ====================
|
||||
const processVideoFile = async (
|
||||
// ==================== 通用文件处理函数 ====================
|
||||
const processGenericFile = async (
|
||||
file: File,
|
||||
tempMsgId: string,
|
||||
messageStrategy: any,
|
||||
targetRoomId: string
|
||||
): Promise<void> => {
|
||||
const tempMsg = messageStrategy.buildMessageType(
|
||||
tempMsgId,
|
||||
{
|
||||
url: URL.createObjectURL(file),
|
||||
size: file.size,
|
||||
fileName: file.name,
|
||||
thumbUrl: '',
|
||||
thumbWidth: 300,
|
||||
thumbHeight: 150,
|
||||
thumbSize: 0
|
||||
},
|
||||
globalStore,
|
||||
userUid
|
||||
)
|
||||
const msg = await messageStrategy.getMsg('', reply, [file])
|
||||
const messageBody = messageStrategy.buildMessageBody(msg, reply)
|
||||
|
||||
const tempMsg = messageStrategy.buildMessageType(tempMsgId, { ...messageBody, url: '' }, globalStore, userUid)
|
||||
tempMsg.message.roomId = targetRoomId
|
||||
tempMsg.message.status = MessageStatusEnum.SENDING
|
||||
chatStore.pushMsg(tempMsg)
|
||||
@@ -828,44 +825,27 @@ export const useMsgInput = (messageInputDom: Ref) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const [videoPath, thumbnailFile] = await Promise.all([
|
||||
saveCacheFile(file, 'video/'),
|
||||
generateVideoThumbnail(file)
|
||||
])
|
||||
const updateProgress = createRafProgressUpdater(tempMsgId)
|
||||
const progressCallback = (pct: number) => {
|
||||
if (!isProgressActive) return
|
||||
updateProgress(pct)
|
||||
}
|
||||
|
||||
const localThumbUrl = URL.createObjectURL(thumbnailFile)
|
||||
chatStore.updateMsg({
|
||||
msgId: tempMsgId,
|
||||
status: MessageStatusEnum.SENDING,
|
||||
body: { ...tempMsg.message.body, thumbUrl: localThumbUrl, thumbSize: thumbnailFile.size }
|
||||
const { uploadUrl, downloadUrl, config } = await messageStrategy.uploadFile(msg.path, {
|
||||
provider: UploadProviderEnum.QINIU
|
||||
})
|
||||
|
||||
const videoUploadResult = await messageStrategy.uploadFile(videoPath, { provider: UploadProviderEnum.QINIU })
|
||||
const qiniuConfig = videoUploadResult.config
|
||||
|
||||
const { progress, onChange } = messageStrategy.getUploadProgress()
|
||||
onChange((event: string) => {
|
||||
if (!isProgressActive || event !== 'progress') return
|
||||
chatStore.updateMsg({
|
||||
msgId: tempMsgId,
|
||||
status: MessageStatusEnum.SENDING,
|
||||
uploadProgress: progress.value
|
||||
})
|
||||
})
|
||||
|
||||
const [videoUploadResponse, thumbnailUploadResponse] = await Promise.all([
|
||||
messageStrategy.doUpload(videoPath, videoUploadResult.uploadUrl, {
|
||||
provider: UploadProviderEnum.QINIU,
|
||||
...qiniuConfig
|
||||
}),
|
||||
uploadToQiniu(thumbnailFile, qiniuConfig.scene || 'CHAT', qiniuConfig, true)
|
||||
])
|
||||
const doUploadResult = await messageStrategy.doUpload(msg.path, uploadUrl, { ...config, progressCallback })
|
||||
|
||||
cleanup()
|
||||
|
||||
const finalVideoUrl = videoUploadResponse?.qiniuUrl || videoUploadResult.downloadUrl
|
||||
const finalThumbnailUrl =
|
||||
thumbnailUploadResponse?.downloadUrl || `${qiniuConfig.domain}/${thumbnailUploadResponse?.key}`
|
||||
messageBody.url = config?.provider === UploadProviderEnum.QINIU ? doUploadResult?.qiniuUrl : downloadUrl
|
||||
delete messageBody.path
|
||||
|
||||
chatStore.updateMsg({
|
||||
msgId: tempMsgId,
|
||||
body: messageBody,
|
||||
status: MessageStatusEnum.SENDING
|
||||
})
|
||||
|
||||
const successChannel = new Channel<any>()
|
||||
const errorChannel = new Channel<string>()
|
||||
@@ -890,101 +870,45 @@ export const useMsgInput = (messageInputDom: Ref) => {
|
||||
data: {
|
||||
id: tempMsgId,
|
||||
roomId: targetRoomId,
|
||||
msgType: MsgEnum.VIDEO,
|
||||
body: {
|
||||
url: finalVideoUrl,
|
||||
size: file.size,
|
||||
fileName: file.name,
|
||||
thumbUrl: finalThumbnailUrl,
|
||||
thumbWidth: 300,
|
||||
thumbHeight: 150,
|
||||
thumbSize: thumbnailFile.size,
|
||||
localPath: videoPath,
|
||||
senderUid: userUid.value
|
||||
}
|
||||
msgType: MsgEnum.FILE,
|
||||
body: messageBody
|
||||
},
|
||||
successChannel,
|
||||
errorChannel
|
||||
})
|
||||
|
||||
URL.revokeObjectURL(tempMsg.message.body.url)
|
||||
URL.revokeObjectURL(localThumbUrl)
|
||||
chatStore.updateSessionLastActiveTime(targetRoomId)
|
||||
} catch (error) {
|
||||
cleanup()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 图片文件处理函数 ====================
|
||||
const processImageFile = async (
|
||||
file: File,
|
||||
const processGenericPathFile = async (
|
||||
file: PathUploadFile,
|
||||
tempMsgId: string,
|
||||
messageStrategy: any,
|
||||
targetRoomId: string
|
||||
): Promise<void> => {
|
||||
const msg = await messageStrategy.getMsg('', reply, [file])
|
||||
const messageBody = messageStrategy.buildMessageBody(msg, reply)
|
||||
|
||||
const tempMsg = messageStrategy.buildMessageType(tempMsgId, messageBody, globalStore, userUid)
|
||||
tempMsg.message.roomId = targetRoomId
|
||||
tempMsg.message.status = MessageStatusEnum.SENDING
|
||||
chatStore.pushMsg(tempMsg)
|
||||
const { uploadUrl, downloadUrl, config } = await messageStrategy.uploadFile(msg.path, {
|
||||
provider: UploadProviderEnum.QINIU
|
||||
})
|
||||
const doUploadResult = await messageStrategy.doUpload(msg.path, uploadUrl, config)
|
||||
|
||||
messageBody.url = config?.provider === UploadProviderEnum.QINIU ? doUploadResult?.qiniuUrl : downloadUrl
|
||||
delete messageBody.path
|
||||
|
||||
chatStore.updateMsg({
|
||||
msgId: tempMsgId,
|
||||
body: messageBody,
|
||||
status: MessageStatusEnum.SENDING
|
||||
})
|
||||
|
||||
const successChannel = new Channel<any>()
|
||||
const errorChannel = new Channel<string>()
|
||||
|
||||
successChannel.onmessage = (message) => {
|
||||
chatStore.updateMsg({
|
||||
msgId: tempMsgId,
|
||||
status: MessageStatusEnum.SUCCESS,
|
||||
newMsgId: message.message.id,
|
||||
body: message.message.body,
|
||||
timeBlock: message.timeBlock
|
||||
})
|
||||
useMitt.emit(MittEnum.CHAT_SCROLL_BOTTOM)
|
||||
const MAX_UPLOAD_SIZE = 500 * 1024 * 1024
|
||||
if (file.size > MAX_UPLOAD_SIZE) {
|
||||
throw new Error('文件大小不能超过500MB')
|
||||
}
|
||||
|
||||
errorChannel.onmessage = () => {
|
||||
chatStore.updateMsg({ msgId: tempMsgId, status: MessageStatusEnum.FAILED })
|
||||
useMitt.emit(MittEnum.CHAT_SCROLL_BOTTOM)
|
||||
const msg = {
|
||||
type: MsgEnum.FILE,
|
||||
path: file.path,
|
||||
fileName: file.name,
|
||||
size: file.size,
|
||||
mimeType: file.type,
|
||||
reply: reply.value.content
|
||||
? {
|
||||
content: reply.value.content,
|
||||
key: reply.value.key
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
await invoke(TauriCommand.SEND_MSG, {
|
||||
data: {
|
||||
id: tempMsgId,
|
||||
roomId: targetRoomId,
|
||||
msgType: MsgEnum.IMAGE,
|
||||
body: messageBody
|
||||
},
|
||||
successChannel,
|
||||
errorChannel
|
||||
})
|
||||
|
||||
URL.revokeObjectURL(msg.url)
|
||||
chatStore.updateSessionLastActiveTime(targetRoomId)
|
||||
}
|
||||
|
||||
// ==================== 通用文件处理函数 ====================
|
||||
const processGenericFile = async (
|
||||
file: File,
|
||||
tempMsgId: string,
|
||||
messageStrategy: any,
|
||||
targetRoomId: string
|
||||
): Promise<void> => {
|
||||
const msg = await messageStrategy.getMsg('', reply, [file])
|
||||
const messageBody = messageStrategy.buildMessageBody(msg, reply)
|
||||
|
||||
const tempMsg = messageStrategy.buildMessageType(tempMsgId, { ...messageBody, url: '' }, globalStore, userUid)
|
||||
@@ -998,20 +922,16 @@ export const useMsgInput = (messageInputDom: Ref) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const { progress, onChange } = messageStrategy.getUploadProgress()
|
||||
onChange((event: string) => {
|
||||
if (!isProgressActive || event !== 'progress') return
|
||||
chatStore.updateMsg({
|
||||
msgId: tempMsgId,
|
||||
status: MessageStatusEnum.SENDING,
|
||||
uploadProgress: progress.value
|
||||
})
|
||||
})
|
||||
const updateProgress = createRafProgressUpdater(tempMsgId)
|
||||
const progressCallback = (pct: number) => {
|
||||
if (!isProgressActive) return
|
||||
updateProgress(pct)
|
||||
}
|
||||
|
||||
const { uploadUrl, downloadUrl, config } = await messageStrategy.uploadFile(msg.path, {
|
||||
provider: UploadProviderEnum.QINIU
|
||||
})
|
||||
const doUploadResult = await messageStrategy.doUpload(msg.path, uploadUrl, config)
|
||||
const doUploadResult = await messageStrategy.doUpload(msg.path, uploadUrl, { ...config, progressCallback })
|
||||
|
||||
cleanup()
|
||||
|
||||
@@ -1159,57 +1079,93 @@ export const useMsgInput = (messageInputDom: Ref) => {
|
||||
* 发送文件的函数(优化版 - 并发处理,逐个显示)
|
||||
* @param files 要发送的文件数组
|
||||
*/
|
||||
const sendFilesDirect = async (files: File[]) => {
|
||||
const sendFilesDirect = async (files: UploadFile[]) => {
|
||||
const targetRoomId = globalStore.currentSessionRoomId
|
||||
|
||||
// 初始化文件上传队列
|
||||
globalFileUploadQueue.initQueue(files)
|
||||
|
||||
// 创建并发限制器(同时处理3个文件)
|
||||
const baseTempId = Date.now()
|
||||
const jobs = files.map((file, index) => {
|
||||
const fileId = globalFileUploadQueue.queue.items[index]?.id
|
||||
const tempMsgId = String(baseTempId * 1000 + index)
|
||||
|
||||
if (isPathUploadFile(file)) {
|
||||
return { file, fileId, tempMsgId }
|
||||
}
|
||||
return { file, fileId, tempMsgId }
|
||||
})
|
||||
|
||||
// 先把「文件消息」占位插入消息列表,避免大文件准备/上传前的空窗期
|
||||
const fileStrategy = messageStrategyMap[MsgEnum.FILE]
|
||||
const replyPayload = reply.value.content
|
||||
? {
|
||||
body: reply.value.content,
|
||||
id: reply.value.key,
|
||||
username: reply.value.accountName,
|
||||
type: MsgEnum.FILE
|
||||
}
|
||||
: undefined
|
||||
|
||||
for (const job of jobs) {
|
||||
const tempMsg = fileStrategy.buildMessageType(
|
||||
job.tempMsgId,
|
||||
{
|
||||
url: '',
|
||||
fileName: job.file.name,
|
||||
size: job.file.size,
|
||||
mimeType: job.file.type,
|
||||
replyMsgId: reply.value.content ? reply.value.key : undefined,
|
||||
reply: replyPayload
|
||||
},
|
||||
globalStore,
|
||||
userUid
|
||||
)
|
||||
tempMsg.message.roomId = targetRoomId
|
||||
tempMsg.message.status = MessageStatusEnum.SENDING
|
||||
tempMsg.uploadProgress = 0
|
||||
void chatStore.pushMsg(tempMsg)
|
||||
}
|
||||
useMitt.emit(MittEnum.CHAT_SCROLL_BOTTOM)
|
||||
|
||||
// 让 UI 先渲染占位消息,再开始耗时上传/分片逻辑
|
||||
await nextTick()
|
||||
await new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => resolve())
|
||||
})
|
||||
|
||||
// 控制并发数,避免同时上传过多文件拖慢渲染与交互
|
||||
const limit = pLimit(3)
|
||||
|
||||
// 并发处理所有文件
|
||||
const tasks = files.map((file, index) => {
|
||||
const fileId = globalFileUploadQueue.queue.items[index]?.id
|
||||
|
||||
const tasks = jobs.map((job) => {
|
||||
return limit(async () => {
|
||||
let msgType: MsgEnum = MsgEnum.TEXT
|
||||
let tempMsgId = ''
|
||||
const tempMsgId = job.tempMsgId
|
||||
|
||||
try {
|
||||
// 更新队列状态
|
||||
if (fileId) {
|
||||
globalFileUploadQueue.updateFileStatus(fileId, 'uploading', 0)
|
||||
if (job.fileId) {
|
||||
globalFileUploadQueue.updateFileStatus(job.fileId, 'uploading', 0)
|
||||
}
|
||||
|
||||
// 文件类型处理
|
||||
const processedFile = fixFileMimeType(file)
|
||||
msgType = getMessageTypeByFile(processedFile)
|
||||
if (msgType === MsgEnum.VOICE) msgType = MsgEnum.FILE
|
||||
|
||||
// 生成唯一消息ID
|
||||
tempMsgId = `${Date.now()}_${Math.random().toString(36).substring(2, 11)}`
|
||||
const messageStrategy = messageStrategyMap[msgType]
|
||||
|
||||
// 根据类型调用对应处理函数,传递固定的 roomId
|
||||
if (msgType === MsgEnum.VIDEO) {
|
||||
await processVideoFile(processedFile, tempMsgId, messageStrategy, targetRoomId)
|
||||
} else if (msgType === MsgEnum.IMAGE) {
|
||||
await processImageFile(processedFile, tempMsgId, messageStrategy, targetRoomId)
|
||||
} else if (msgType === MsgEnum.FILE) {
|
||||
await processGenericFile(processedFile, tempMsgId, messageStrategy, targetRoomId)
|
||||
if (isPathUploadFile(job.file)) {
|
||||
const messageStrategy = messageStrategyMap[MsgEnum.FILE]
|
||||
await processGenericPathFile(job.file, tempMsgId, messageStrategy, targetRoomId)
|
||||
} else {
|
||||
const messageStrategy = messageStrategyMap[MsgEnum.FILE]
|
||||
await processGenericFile(job.file, tempMsgId, messageStrategy, targetRoomId)
|
||||
}
|
||||
|
||||
// 成功 - 更新队列状态
|
||||
if (fileId) {
|
||||
globalFileUploadQueue.updateFileStatus(fileId, 'completed', 100)
|
||||
if (job.fileId) {
|
||||
globalFileUploadQueue.updateFileStatus(job.fileId, 'completed', 100)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`${file.name} 发送失败:`, error)
|
||||
console.error(`${job.file.name} 发送失败:`, error)
|
||||
|
||||
// 失败 - 更新队列和消息状态
|
||||
if (fileId) {
|
||||
globalFileUploadQueue.updateFileStatus(fileId, 'failed', 0)
|
||||
if (job.fileId) {
|
||||
globalFileUploadQueue.updateFileStatus(job.fileId, 'failed', 0)
|
||||
}
|
||||
|
||||
if (tempMsgId) {
|
||||
@@ -1219,7 +1175,7 @@ export const useMsgInput = (messageInputDom: Ref) => {
|
||||
})
|
||||
}
|
||||
|
||||
window.$message.error(`${file.name} 发送失败`)
|
||||
window.$message.error(`${job.file.name} 发送失败`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { BaseDirectory, readFile } from '@tauri-apps/plugin-fs'
|
||||
import { Channel, invoke } from '@tauri-apps/api/core'
|
||||
import { BaseDirectory, remove, stat, writeFile } from '@tauri-apps/plugin-fs'
|
||||
import { fetch } from '@tauri-apps/plugin-http'
|
||||
import { createEventHook } from '@vueuse/core'
|
||||
import { UploadSceneEnum } from '@/enums'
|
||||
import { TauriCommand, UploadSceneEnum } from '@/enums'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { extractFileName, getMimeTypeFromExtension } from '@/utils/Formatting'
|
||||
import { extractFileName } from '@/utils/Formatting'
|
||||
import { getImageDimensions } from '@/utils/ImageUtils'
|
||||
import { getQiniuToken, getUploadProvider } from '@/utils/ImRequestUtils'
|
||||
import { isAndroid, isMobile } from '@/utils/PlatformConstants'
|
||||
@@ -56,14 +57,17 @@ interface ChunkProgressInfo {
|
||||
currentChunkProgress: number
|
||||
}
|
||||
|
||||
const Max = 100 // 单位M
|
||||
const Max = 500 // 单位M
|
||||
const MAX_FILE_SIZE = Max * 1024 * 1024 // 最大上传限制
|
||||
const DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024 // 默认分片大小:4MB
|
||||
const QINIU_CHUNK_SIZE = 4 * 1024 * 1024 // 七牛云分片大小:4MB
|
||||
const CHUNK_THRESHOLD = 4 * 1024 * 1024 // 4MB,超过此大小的文件将使用分片上传
|
||||
|
||||
let cryptoJS: any | null = null
|
||||
|
||||
const isAbsolutePath = (path: string): boolean => {
|
||||
return /^(\/|[A-Za-z]:[\\/]|\\\\)/.test(path)
|
||||
}
|
||||
|
||||
const loadCryptoJS = async () => {
|
||||
if (!cryptoJS) {
|
||||
const module = await import('crypto-js')
|
||||
@@ -90,6 +94,38 @@ export const useUpload = () => {
|
||||
const { on: onChange, trigger } = createEventHook()
|
||||
const onStart = createEventHook()
|
||||
|
||||
const uploadFileWithTauriPut = async (targetUrl: string, file: File, contentType: string) => {
|
||||
const baseDir = isMobile() ? BaseDirectory.AppData : BaseDirectory.AppCache
|
||||
const baseDirName = isMobile() ? 'AppData' : 'AppCache'
|
||||
const safeFileName = file.name.replace(/[\\/]/g, '_')
|
||||
const tempPath = `temp-upload-${Date.now()}-${safeFileName}`
|
||||
|
||||
try {
|
||||
await writeFile(tempPath, file.stream(), { baseDir })
|
||||
|
||||
const onProgress = new Channel<{ progressTotal: number; total: number }>()
|
||||
let lastProgress = -1
|
||||
onProgress.onmessage = ({ progressTotal, total }) => {
|
||||
const pct = total > 0 ? Math.floor((progressTotal / total) * 100) : 0
|
||||
if (pct !== lastProgress) {
|
||||
lastProgress = pct
|
||||
progress.value = pct
|
||||
trigger('progress')
|
||||
}
|
||||
}
|
||||
|
||||
await invoke(TauriCommand.UPLOAD_FILE_PUT, {
|
||||
url: targetUrl,
|
||||
path: tempPath,
|
||||
baseDir: baseDirName,
|
||||
headers: { 'Content-Type': contentType },
|
||||
onProgress
|
||||
})
|
||||
} finally {
|
||||
await remove(tempPath, { baseDir }).catch(() => void 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算文件的MD5哈希值
|
||||
* @param file 文件
|
||||
@@ -124,29 +160,6 @@ export const useUpload = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据文件名获取文件类型
|
||||
* @param fileName 文件名
|
||||
*/
|
||||
const getFileType = (fileName: string): string => {
|
||||
const extension = fileName.split('.').pop()?.toLowerCase()
|
||||
|
||||
// 对于图片类型,使用统一的 getMimeTypeFromExtension 函数
|
||||
if (['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'svg'].includes(extension || '')) {
|
||||
return getMimeTypeFromExtension(fileName)
|
||||
}
|
||||
|
||||
// 其他文件类型
|
||||
switch (extension) {
|
||||
case 'mp4':
|
||||
return 'video/mp4'
|
||||
case 'mp3':
|
||||
return 'audio/mp3'
|
||||
default:
|
||||
return 'application/octet-stream' // 默认类型
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件哈希
|
||||
* @param options 上传配置
|
||||
@@ -176,77 +189,6 @@ export const useUpload = () => {
|
||||
return key
|
||||
}
|
||||
|
||||
/**
|
||||
* 分片上传到默认存储
|
||||
* @param url 上传链接
|
||||
* @param file 文件
|
||||
*/
|
||||
const uploadToDefaultWithChunks = async (url: string, file: File) => {
|
||||
progress.value = 0
|
||||
const chunkSize = DEFAULT_CHUNK_SIZE
|
||||
const totalSize = file.size
|
||||
const totalChunks = Math.ceil(totalSize / chunkSize)
|
||||
|
||||
console.log('开始默认存储分片上传:', {
|
||||
fileName: file.name,
|
||||
fileSize: totalSize,
|
||||
chunkSize,
|
||||
totalChunks
|
||||
})
|
||||
|
||||
try {
|
||||
// 创建一个临时的上传会话ID
|
||||
const uploadId = `upload_${Date.now()}_${Math.random().toString(36).substring(2)}`
|
||||
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const start = i * chunkSize
|
||||
const end = Math.min(start + chunkSize, totalSize)
|
||||
const chunk = file.slice(start, end)
|
||||
const chunkArrayBuffer = await chunk.arrayBuffer()
|
||||
|
||||
// 为每个分片添加必要的头信息
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'X-Chunk-Index': i.toString(),
|
||||
'X-Total-Chunks': totalChunks.toString(),
|
||||
'X-Upload-Id': uploadId,
|
||||
'X-File-Name': file.name,
|
||||
'X-File-Size': totalSize.toString()
|
||||
}
|
||||
|
||||
// 如果是最后一个分片,添加完成标记
|
||||
if (i === totalChunks - 1) {
|
||||
headers['X-Last-Chunk'] = 'true'
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: chunkArrayBuffer,
|
||||
duplex: 'half'
|
||||
} as RequestInit)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`分片 ${i + 1}/${totalChunks} 上传失败: ${response.statusText}`)
|
||||
}
|
||||
|
||||
// 更新进度
|
||||
progress.value = Math.floor(((i + 1) / totalChunks) * 100)
|
||||
trigger('progress') // 触发进度事件
|
||||
|
||||
console.log(`分片 ${i + 1}/${totalChunks} 上传成功, 进度: ${progress.value}%`)
|
||||
}
|
||||
|
||||
isUploading.value = false
|
||||
progress.value = 100
|
||||
trigger('success')
|
||||
} catch (error) {
|
||||
isUploading.value = false
|
||||
console.error('默认存储分片上传失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件到七牛云
|
||||
* @param file 文件
|
||||
@@ -523,19 +465,15 @@ export const useUpload = () => {
|
||||
await onStart.trigger(fileInfo)
|
||||
|
||||
if ((cred as any)?.uploadUrl) {
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const response = await fetch((cred as any).uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': file.type || 'application/octet-stream' },
|
||||
body: arrayBuffer,
|
||||
duplex: 'half'
|
||||
} as RequestInit)
|
||||
const contentType = file.type || 'application/octet-stream'
|
||||
|
||||
isUploading.value = true
|
||||
progress.value = 0
|
||||
|
||||
await uploadFileWithTauriPut((cred as any).uploadUrl, file, contentType)
|
||||
|
||||
isUploading.value = false
|
||||
progress.value = 100
|
||||
if (!response.ok) {
|
||||
await trigger('fail')
|
||||
throw new Error(`上传失败: ${response.statusText}`)
|
||||
}
|
||||
fileInfo.value = { ...fileInfo.value!, downloadUrl: (cred as any).downloadUrl }
|
||||
trigger('success')
|
||||
return { downloadUrl: (cred as any).downloadUrl }
|
||||
@@ -572,25 +510,16 @@ export const useUpload = () => {
|
||||
await onStart.trigger(fileInfo)
|
||||
|
||||
const presign = await getQiniuToken({ scene: options?.scene, fileName: file.name })
|
||||
const contentType = file.type || 'application/octet-stream'
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const response = await fetch(presign.uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': file.type || 'application/octet-stream'
|
||||
},
|
||||
body: arrayBuffer,
|
||||
duplex: 'half'
|
||||
} as RequestInit)
|
||||
isUploading.value = true
|
||||
progress.value = 0
|
||||
|
||||
await uploadFileWithTauriPut(presign.uploadUrl, file, contentType)
|
||||
|
||||
isUploading.value = false
|
||||
progress.value = 100
|
||||
|
||||
if (!response.ok) {
|
||||
trigger('fail')
|
||||
throw new Error(`上传失败: ${response.statusText}`)
|
||||
}
|
||||
|
||||
fileInfo.value = { ...fileInfo.value!, downloadUrl: presign.downloadUrl }
|
||||
trigger('success')
|
||||
return { downloadUrl: presign.downloadUrl }
|
||||
@@ -661,6 +590,8 @@ export const useUpload = () => {
|
||||
* @param options 上传选项
|
||||
*/
|
||||
const doUpload = async (path: string, uploadUrl: string, options?: any): Promise<{ qiniuUrl: string } | string> => {
|
||||
const absolutePath = isAbsolutePath(path)
|
||||
|
||||
// 如果是七牛云上传
|
||||
if (uploadUrl === UploadProviderEnum.QINIU && options) {
|
||||
const fileName = extractFileName(path)
|
||||
@@ -675,19 +606,38 @@ export const useUpload = () => {
|
||||
options.region = (cred as any).region
|
||||
} else if ((cred as any)?.uploadUrl) {
|
||||
const baseDir = isMobile() ? BaseDirectory.AppData : BaseDirectory.AppCache
|
||||
const file = await readFile(path, { baseDir })
|
||||
const response = await fetch((cred as any).uploadUrl, {
|
||||
method: 'PUT',
|
||||
const baseDirName = isMobile() ? 'AppData' : 'AppCache'
|
||||
const fileStat = absolutePath ? await stat(path) : await stat(path, { baseDir })
|
||||
|
||||
if (fileStat.size > MAX_FILE_SIZE) {
|
||||
throw new Error(`文件大小不能超过${Max}MB`)
|
||||
}
|
||||
|
||||
isUploading.value = true
|
||||
progress.value = 0
|
||||
|
||||
const onProgress = new Channel<{ progressTotal: number; total: number }>()
|
||||
let lastProgress = -1
|
||||
onProgress.onmessage = ({ progressTotal, total }) => {
|
||||
const pct = total > 0 ? Math.floor((progressTotal / total) * 100) : 0
|
||||
if (pct !== lastProgress) {
|
||||
lastProgress = pct
|
||||
progress.value = pct
|
||||
trigger('progress')
|
||||
options?.progressCallback?.(pct)
|
||||
}
|
||||
}
|
||||
|
||||
await invoke(TauriCommand.UPLOAD_FILE_PUT, {
|
||||
url: (cred as any).uploadUrl,
|
||||
path,
|
||||
...(absolutePath ? {} : { baseDir: baseDirName }),
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
body: file,
|
||||
duplex: 'half'
|
||||
} as RequestInit)
|
||||
onProgress
|
||||
})
|
||||
|
||||
isUploading.value = false
|
||||
progress.value = 100
|
||||
if (!response.ok) {
|
||||
trigger('fail')
|
||||
throw new Error(`上传失败: ${response.statusText}`)
|
||||
}
|
||||
trigger('success')
|
||||
return (cred as any).downloadUrl
|
||||
}
|
||||
@@ -697,55 +647,49 @@ export const useUpload = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const baseDir = isMobile() ? BaseDirectory.AppData : BaseDirectory.AppCache
|
||||
const file = await readFile(path, { baseDir })
|
||||
console.log(`📁 读取文件: ${path}, 大小: ${file.length} bytes`)
|
||||
if (!options.domain || !options.token) {
|
||||
throw new Error('获取上传凭证失败,请重试')
|
||||
}
|
||||
|
||||
const fileObj = new File([new Uint8Array(file)], fileName, { type: getFileType(fileName) })
|
||||
console.log(`📦 创建File对象: ${fileName}, 原始大小: ${fileObj.size} bytes, 数组大小: ${file.length} bytes`)
|
||||
const baseDir = isMobile() ? BaseDirectory.AppData : BaseDirectory.AppCache
|
||||
const baseDirName = isMobile() ? 'AppData' : 'AppCache'
|
||||
const fileStat = absolutePath ? await stat(path) : await stat(path, { baseDir })
|
||||
|
||||
if (fileStat.size > MAX_FILE_SIZE) {
|
||||
throw new Error(`文件大小不能超过${Max}MB`)
|
||||
}
|
||||
|
||||
isUploading.value = true
|
||||
progress.value = 0
|
||||
|
||||
const useChunks = fileObj.size > CHUNK_THRESHOLD
|
||||
if (useChunks) {
|
||||
const r = await uploadToQiniuWithChunks(
|
||||
fileObj,
|
||||
{
|
||||
token: options.token,
|
||||
domain: options.domain,
|
||||
storagePrefix: options.storagePrefix,
|
||||
region: options.region
|
||||
},
|
||||
QINIU_CHUNK_SIZE,
|
||||
true
|
||||
)
|
||||
isUploading.value = false
|
||||
progress.value = 100
|
||||
const qiniuUrl = `${configStore.config.qiNiu.ossDomain}/${(r as any).key}`
|
||||
trigger('success')
|
||||
return qiniuUrl
|
||||
} else {
|
||||
const r = await uploadToQiniu(
|
||||
fileObj,
|
||||
options.scene,
|
||||
{
|
||||
token: options.token,
|
||||
domain: options.domain,
|
||||
storagePrefix: options.storagePrefix,
|
||||
region: options.region
|
||||
},
|
||||
options.enableDeduplication
|
||||
)
|
||||
isUploading.value = false
|
||||
progress.value = 100
|
||||
if ((r as any).downloadUrl) {
|
||||
trigger('success')
|
||||
return (r as any).downloadUrl
|
||||
const onProgress = new Channel<{ progressTotal: number; total: number }>()
|
||||
let lastProgress = -1
|
||||
onProgress.onmessage = ({ progressTotal, total }) => {
|
||||
const pct = total > 0 ? Math.floor((progressTotal / total) * 100) : 0
|
||||
if (pct !== lastProgress) {
|
||||
lastProgress = pct
|
||||
progress.value = pct
|
||||
trigger('progress')
|
||||
options?.progressCallback?.(pct)
|
||||
}
|
||||
trigger('fail')
|
||||
throw new Error('上传失败')
|
||||
}
|
||||
|
||||
const key = await invoke<string>(TauriCommand.QINIU_UPLOAD_RESUMABLE, {
|
||||
path,
|
||||
...(absolutePath ? {} : { baseDir: baseDirName }),
|
||||
token: options.token,
|
||||
domain: options.domain,
|
||||
scene: options.scene,
|
||||
account: userStore.userInfo?.account,
|
||||
storagePrefix: options.storagePrefix,
|
||||
enableDeduplication: Boolean(options.enableDeduplication),
|
||||
onProgress
|
||||
})
|
||||
|
||||
isUploading.value = false
|
||||
progress.value = 100
|
||||
trigger('success')
|
||||
return `${configStore.config.qiNiu.ossDomain}/${key}`
|
||||
} catch (error) {
|
||||
isUploading.value = false
|
||||
trigger('fail')
|
||||
@@ -756,42 +700,47 @@ export const useUpload = () => {
|
||||
// 使用默认上传方式
|
||||
console.log('执行文件上传:', path)
|
||||
try {
|
||||
if (!uploadUrl) {
|
||||
throw new Error('获取上传链接失败,请重试')
|
||||
}
|
||||
|
||||
const baseDir = isMobile() ? BaseDirectory.AppData : BaseDirectory.AppCache
|
||||
const file = await readFile(path, { baseDir })
|
||||
const baseDirName = isMobile() ? 'AppData' : 'AppCache'
|
||||
const fileStat = absolutePath ? await stat(path) : await stat(path, { baseDir })
|
||||
|
||||
// 添加文件大小检查
|
||||
if (file.length > MAX_FILE_SIZE) {
|
||||
if (fileStat.size > MAX_FILE_SIZE) {
|
||||
throw new Error(`文件大小不能超过${Max}MB`)
|
||||
}
|
||||
|
||||
isUploading.value = true
|
||||
progress.value = 0
|
||||
|
||||
if (file.length > CHUNK_THRESHOLD && options?.provider !== UploadProviderEnum.MINIO) {
|
||||
// 转换file的类型
|
||||
// TODO:本地上传还需要测试
|
||||
const fileObj = new File([new Uint8Array(file)], __filename, { type: 'application/octet-stream' })
|
||||
await uploadToDefaultWithChunks(uploadUrl, fileObj)
|
||||
} else {
|
||||
const response = await fetch(uploadUrl, {
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
method: 'PUT',
|
||||
body: file,
|
||||
duplex: 'half'
|
||||
} as RequestInit)
|
||||
|
||||
isUploading.value = false
|
||||
progress.value = 100
|
||||
|
||||
if (!response.ok) {
|
||||
trigger('fail')
|
||||
throw new Error(`上传失败: ${response.statusText}`)
|
||||
const onProgress = new Channel<{ progressTotal: number; total: number }>()
|
||||
let lastProgress = -1
|
||||
onProgress.onmessage = ({ progressTotal, total }) => {
|
||||
const pct = total > 0 ? Math.floor((progressTotal / total) * 100) : 0
|
||||
if (pct !== lastProgress) {
|
||||
lastProgress = pct
|
||||
progress.value = pct
|
||||
trigger('progress')
|
||||
options?.progressCallback?.(pct)
|
||||
}
|
||||
|
||||
console.log('文件上传成功')
|
||||
trigger('success')
|
||||
}
|
||||
|
||||
await invoke(TauriCommand.UPLOAD_FILE_PUT, {
|
||||
url: uploadUrl,
|
||||
path,
|
||||
...(absolutePath ? {} : { baseDir: baseDirName }),
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
onProgress
|
||||
})
|
||||
|
||||
isUploading.value = false
|
||||
progress.value = 100
|
||||
console.log('文件上传成功')
|
||||
trigger('success')
|
||||
|
||||
// 返回下载URL
|
||||
return options?.downloadUrl
|
||||
} catch (error) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import pLimit from 'p-limit'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ErrorType } from '@/common/exception'
|
||||
import { MittEnum, type MessageStatusEnum, MsgEnum, RoomTypeEnum, StoresEnum, TauriCommand } from '@/enums'
|
||||
import { MittEnum, MessageStatusEnum, MsgEnum, RoomTypeEnum, StoresEnum, TauriCommand } from '@/enums'
|
||||
import type { MarkItemType, MessageType, RevokedMsgType, SessionItem } from '@/services/types'
|
||||
import { useGlobalStore } from '@/stores/global.ts'
|
||||
import { useFeedStore } from '@/stores/feed.ts'
|
||||
@@ -191,6 +191,16 @@ export const useChatStore = defineStore(
|
||||
// 消息加载状态
|
||||
const messageOptions = reactive<Record<string, { isLast: boolean; isLoading: boolean; cursor: string }>>({})
|
||||
|
||||
// 切换会话时保留本地临时消息(例如上传中的文件消息),避免 reload 消息列表时丢失
|
||||
const transientStatuses = new Set<MessageStatusEnum>([
|
||||
MessageStatusEnum.PENDING,
|
||||
MessageStatusEnum.SENDING,
|
||||
MessageStatusEnum.FAILED
|
||||
])
|
||||
const shouldKeepTransientMessage = (msg?: MessageType) => {
|
||||
return msg?.message?.status ? transientStatuses.has(msg.message.status) : false
|
||||
}
|
||||
|
||||
// 回复消息的映射关系
|
||||
const replyMapping = reactive<Record<string, Record<string, string[]>>>({})
|
||||
// 存储撤回的消息内容和时间
|
||||
@@ -283,12 +293,28 @@ export const useChatStore = defineStore(
|
||||
if (roomId !== currentRoomId) {
|
||||
// 只清空消息内容,保留响应式对象结构
|
||||
for (const msgId in messageMap[roomId]) {
|
||||
const msg = messageMap[roomId][msgId]
|
||||
if (shouldKeepTransientMessage(msg)) continue
|
||||
delete messageMap[roomId][msgId]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清空指定房间消息,但保留本地临时消息(上传/发送中或失败)
|
||||
const clearRoomMessagesExceptTransient = (roomId: string) => {
|
||||
if (!messageMap[roomId]) {
|
||||
messageMap[roomId] = {}
|
||||
return
|
||||
}
|
||||
|
||||
for (const msgId in messageMap[roomId]) {
|
||||
const msg = messageMap[roomId][msgId]
|
||||
if (shouldKeepTransientMessage(msg)) continue
|
||||
delete messageMap[roomId][msgId]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换聊天室
|
||||
* @description
|
||||
@@ -315,9 +341,7 @@ export const useChatStore = defineStore(
|
||||
cleanupExpiredRecalledMessages()
|
||||
|
||||
// 1. 清空当前房间的旧消息数据
|
||||
if (messageMap[roomId]) {
|
||||
messageMap[roomId] = {}
|
||||
}
|
||||
clearRoomMessagesExceptTransient(roomId)
|
||||
|
||||
// 2. 重置消息加载状态
|
||||
currentMessageOptions.value = {
|
||||
@@ -1018,7 +1042,8 @@ export const useChatStore = defineStore(
|
||||
newMsgId,
|
||||
body,
|
||||
uploadProgress,
|
||||
timeBlock
|
||||
timeBlock,
|
||||
roomId
|
||||
}: {
|
||||
msgId: string
|
||||
status: MessageStatusEnum
|
||||
@@ -1026,37 +1051,46 @@ export const useChatStore = defineStore(
|
||||
body?: any
|
||||
uploadProgress?: number
|
||||
timeBlock?: number
|
||||
roomId?: string
|
||||
}) => {
|
||||
const msg = currentMessageMap.value?.[msgId]
|
||||
if (msg) {
|
||||
msg.message.status = status
|
||||
// 只在 timeBlock 有值时才更新,避免覆盖原有值
|
||||
if (timeBlock !== undefined) {
|
||||
msg.timeBlock = timeBlock
|
||||
}
|
||||
if (newMsgId) {
|
||||
msg.message.id = newMsgId
|
||||
}
|
||||
if (body) {
|
||||
msg.message.body = body
|
||||
}
|
||||
if (uploadProgress !== undefined) {
|
||||
console.log(`更新消息进度: ${uploadProgress}% (消息ID: ${msgId})`)
|
||||
// 确保响应式更新,创建新的消息对象
|
||||
const updatedMsg = { ...msg, uploadProgress }
|
||||
if (currentMessageMap.value) {
|
||||
currentMessageMap.value[msg.message.id] = updatedMsg
|
||||
}
|
||||
// 强制触发响应式更新
|
||||
messageMap[globalStore.currentSessionRoomId] = { ...currentMessageMap.value }
|
||||
} else {
|
||||
if (currentMessageMap.value) {
|
||||
currentMessageMap.value[msg.message.id] = msg
|
||||
}
|
||||
}
|
||||
if (newMsgId && msgId !== newMsgId && currentMessageMap.value) {
|
||||
delete currentMessageMap.value[msgId]
|
||||
}
|
||||
const resolvedRoomId =
|
||||
(roomId && messageMap[roomId]?.[msgId] ? roomId : undefined) ??
|
||||
(messageMap[globalStore.currentSessionRoomId]?.[msgId] ? globalStore.currentSessionRoomId : undefined) ??
|
||||
findRoomIdByMsgId(msgId)
|
||||
|
||||
if (!resolvedRoomId) return
|
||||
|
||||
const roomMessages = messageMap[resolvedRoomId]
|
||||
if (!roomMessages) return
|
||||
|
||||
const msg = roomMessages[msgId]
|
||||
if (!msg) return
|
||||
|
||||
msg.message.status = status
|
||||
// 只在 timeBlock 有值时才更新,避免覆盖原有值
|
||||
if (timeBlock !== undefined) {
|
||||
msg.timeBlock = timeBlock
|
||||
}
|
||||
if (body) {
|
||||
msg.message.body = body
|
||||
}
|
||||
|
||||
const nextMsgId = newMsgId ?? msg.message.id
|
||||
if (newMsgId) {
|
||||
msg.message.id = newMsgId
|
||||
}
|
||||
|
||||
if (uploadProgress !== undefined) {
|
||||
console.log(`更新消息进度: ${uploadProgress}% (消息ID: ${msgId})`)
|
||||
roomMessages[nextMsgId] = { ...msg, uploadProgress }
|
||||
messageMap[resolvedRoomId] = { ...roomMessages }
|
||||
} else {
|
||||
roomMessages[nextMsgId] = msg
|
||||
}
|
||||
|
||||
if (newMsgId && msgId !== newMsgId) {
|
||||
delete roomMessages[msgId]
|
||||
messageMap[resolvedRoomId] = { ...roomMessages }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -259,10 +259,8 @@ class ImageMessageStrategyImpl extends AbstractMessageStrategy {
|
||||
|
||||
// 将文件保存到缓存目录
|
||||
const tempPath = `temp-image-${Date.now()}-${file.name}`
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const uint8Array = new Uint8Array(arrayBuffer)
|
||||
const baseDir = isMobile() ? BaseDirectory.AppData : BaseDirectory.AppCache
|
||||
await writeFile(tempPath, uint8Array, { baseDir })
|
||||
await writeFile(tempPath, file.stream(), { baseDir })
|
||||
|
||||
return {
|
||||
type: this.msgType,
|
||||
@@ -564,8 +562,8 @@ class LocationMessageStrategyImpl extends AbstractMessageStrategy {
|
||||
* 处理文件消息
|
||||
*/
|
||||
class FileMessageStrategyImpl extends AbstractMessageStrategy {
|
||||
// 最大上传文件大小 100MB
|
||||
private readonly MAX_UPLOAD_SIZE = 100 * 1024 * 1024
|
||||
// 最大上传文件大小 500MB
|
||||
private readonly MAX_UPLOAD_SIZE = 500 * 1024 * 1024
|
||||
private _uploadHook: ReturnType<typeof useUpload> | null = null
|
||||
|
||||
constructor() {
|
||||
@@ -587,7 +585,7 @@ class FileMessageStrategyImpl extends AbstractMessageStrategy {
|
||||
private async validateFile(file: File): Promise<File> {
|
||||
// 检查文件大小
|
||||
if (file.size > this.MAX_UPLOAD_SIZE) {
|
||||
throw new AppException('文件大小不能超过100MB')
|
||||
throw new AppException('文件大小不能超过500MB')
|
||||
}
|
||||
return file
|
||||
}
|
||||
@@ -637,10 +635,8 @@ class FileMessageStrategyImpl extends AbstractMessageStrategy {
|
||||
const tempPath = `temp-file-${Date.now()}-${validatedFile.name}`
|
||||
|
||||
// 将文件保存到临时位置
|
||||
const arrayBuffer = await validatedFile.arrayBuffer()
|
||||
const uint8Array = new Uint8Array(arrayBuffer)
|
||||
const baseDir = isMobile() ? BaseDirectory.AppData : BaseDirectory.AppCache
|
||||
await writeFile(tempPath, uint8Array, { baseDir })
|
||||
await writeFile(tempPath, validatedFile.stream(), { baseDir })
|
||||
|
||||
return {
|
||||
type: this.msgType,
|
||||
@@ -863,10 +859,8 @@ class VideoMessageStrategyImpl extends AbstractMessageStrategy {
|
||||
|
||||
// 将文件保存到缓存目录
|
||||
const tempPath = `temp-video-${Date.now()}-${file.name}`
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const uint8Array = new Uint8Array(arrayBuffer)
|
||||
const baseDir = isMobile() ? BaseDirectory.AppData : BaseDirectory.AppCache
|
||||
await writeFile(tempPath, uint8Array, { baseDir })
|
||||
await writeFile(tempPath, validatedFile.stream(), { baseDir })
|
||||
|
||||
return {
|
||||
type: this.msgType,
|
||||
@@ -1018,13 +1012,9 @@ class VideoMessageStrategyImpl extends AbstractMessageStrategy {
|
||||
// 将File对象写入临时文件,然后使用现有的doUpload方法
|
||||
const tempPath = `temp-thumbnail-${Date.now()}-${thumbnailFile.name}`
|
||||
|
||||
// 将File对象转换为ArrayBuffer,然后写入临时文件
|
||||
const arrayBuffer = await thumbnailFile.arrayBuffer()
|
||||
const uint8Array = new Uint8Array(arrayBuffer)
|
||||
|
||||
// 写入临时文件
|
||||
const baseDir = isMobile() ? BaseDirectory.AppData : BaseDirectory.AppCache
|
||||
await writeFile(tempPath, uint8Array, { baseDir })
|
||||
await writeFile(tempPath, thumbnailFile.stream(), { baseDir })
|
||||
|
||||
// enableDeduplication启用文件去重,使用哈希值计算
|
||||
const result = await this.uploadHook.doUpload(tempPath, uploadUrl, { ...options, enableDeduplication: true })
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
import { MsgEnum } from '@/enums'
|
||||
|
||||
/**
|
||||
* 上传文件类型(支持浏览器 File 与 Tauri 路径文件)
|
||||
*/
|
||||
export type PathUploadFile = {
|
||||
kind: 'path'
|
||||
path: string
|
||||
name: string
|
||||
size: number
|
||||
type: string
|
||||
}
|
||||
|
||||
export type UploadFile = File | PathUploadFile
|
||||
|
||||
/**
|
||||
* 判断文件是否为 PathUploadFile
|
||||
*/
|
||||
export const isPathUploadFile = (file: UploadFile): file is PathUploadFile => {
|
||||
return typeof file === 'object' && (file as PathUploadFile).kind === 'path'
|
||||
}
|
||||
|
||||
/**
|
||||
* 支持的视频扩展名(统一定义)
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { join } from '@tauri-apps/api/path'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { copyFile, readFile } from '@tauri-apps/plugin-fs'
|
||||
import { copyFile, stat } from '@tauri-apps/plugin-fs'
|
||||
import type { FilesMeta } from '@/services/types'
|
||||
import type { PathUploadFile } from '@/utils/FileType'
|
||||
import { extractFileName } from '@/utils/Formatting'
|
||||
import { useUserStore } from '../stores/user'
|
||||
import { getFilesMeta } from './PathUtil'
|
||||
@@ -23,7 +24,7 @@ class FileUtil {
|
||||
* filesMeta: 选中的文件元数据列表
|
||||
*/
|
||||
static async openAndCopyFile(): Promise<{
|
||||
files: File[]
|
||||
files: PathUploadFile[]
|
||||
filesMeta: FilesMeta
|
||||
} | null> {
|
||||
// 获取文件路径列表
|
||||
@@ -35,11 +36,12 @@ class FileUtil {
|
||||
if (!selected) {
|
||||
return null
|
||||
}
|
||||
const filesMeta = await getFilesMeta<FilesMeta>(selected)
|
||||
await FileUtil.copyUploadFile(selected, filesMeta)
|
||||
const selectedPaths = Array.isArray(selected) ? selected : [selected]
|
||||
const filesMeta = await getFilesMeta<FilesMeta>(selectedPaths)
|
||||
void FileUtil.copyUploadFile(selectedPaths, filesMeta)
|
||||
|
||||
return {
|
||||
files: await FileUtil.map2File(selected, filesMeta),
|
||||
files: await FileUtil.map2PathUploadFile(selectedPaths, filesMeta),
|
||||
filesMeta: filesMeta
|
||||
}
|
||||
}
|
||||
@@ -55,30 +57,42 @@ class FileUtil {
|
||||
for (const filePathStr of files) {
|
||||
const fileMeta = filesMeta.find((f) => f.path === filePathStr)
|
||||
if (fileMeta) {
|
||||
copyFile(filePathStr, await join(userResourceDir, fileMeta.name))
|
||||
try {
|
||||
await copyFile(filePathStr, await join(userResourceDir, fileMeta.name))
|
||||
} catch (error) {
|
||||
console.error('[FileUtil] 复制文件失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将选中的文件路径列表和文件元数据列表转换为 File 对象列表
|
||||
* 将选中的文件路径列表和文件元数据列表转换为路径文件对象列表
|
||||
* @param files 选中的文件路径列表
|
||||
* @param filesMeta 选中的文件元数据列表
|
||||
* @returns File 对象列表
|
||||
* @returns 路径文件对象列表
|
||||
*/
|
||||
static async map2File(files: string[], filesMeta: FilesMeta): Promise<File[]> {
|
||||
static async map2PathUploadFile(files: string[], filesMeta: FilesMeta): Promise<PathUploadFile[]> {
|
||||
return await Promise.all(
|
||||
files.map(async (path) => {
|
||||
const fileData = await readFile(path)
|
||||
const fileName = extractFileName(path)
|
||||
const blob = new Blob([new Uint8Array(fileData)])
|
||||
|
||||
// 找到对应路径的文件,并且获取其类型
|
||||
const fileMeta = filesMeta.find((f) => f.path === path)
|
||||
const fileType = fileMeta?.mime_type || fileMeta?.file_type
|
||||
const fileName = fileMeta?.name || extractFileName(path)
|
||||
const fileType = fileMeta?.mime_type || 'application/octet-stream'
|
||||
|
||||
// 最后手动传入blob中,因为blob无法自动判断文件类型
|
||||
return new File([blob], fileName, { type: fileType })
|
||||
let size = 0
|
||||
try {
|
||||
size = (await stat(path)).size
|
||||
} catch (error) {
|
||||
console.error('[FileUtil] 获取文件大小失败:', error)
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'path',
|
||||
path,
|
||||
name: fileName,
|
||||
size,
|
||||
type: fileType
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user