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:
Dawn
2025-12-23 10:21:40 +08:00
parent 8719def4b2
commit f050a60a55
20 changed files with 806 additions and 483 deletions

3
.rules
View File

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

@@ -2872,6 +2872,7 @@ dependencies = [
"lazy_static",
"libc",
"libsqlite3-sys",
"md-5",
"migration",
"mime_guess",
"moka",

View File

@@ -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"] }

View File

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

File diff suppressed because one or more lines are too long

View File

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

View 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))
}

View File

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

View File

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

View File

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

View File

@@ -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(() => {

View File

@@ -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'
}
// 通话状态枚举

View File

@@ -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)
}
}
// 处理其他文件(显示弹窗)

View File

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

View File

@@ -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} 发送失败`)
}
})
})

View File

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

View File

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

View File

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

View File

@@ -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'
}
/**
* 支持的视频扩展名(统一定义)
*/

View File

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