fix(android): 修复App文件下载、PDF卡片展示与本地媒体发送
This commit is contained in:
parent
e4a663594d
commit
f7b79418f9
@ -1,5 +1,13 @@
|
|||||||
# App 更新说明
|
# App 更新说明
|
||||||
|
|
||||||
|
# 1.0.36
|
||||||
|
- 修复 Android App 内文件卡片点击「下载」无反应的问题:前端优先调用 Android DownloadBridge,WebView 同时注册 `setDownloadListener` 兜底,使用系统 DownloadManager 下载到 Download 目录
|
||||||
|
- PDF 文件卡片移除单独的「预览」按钮,改为与桌面端一致的卡片下方 inline 预览(iframe),视觉和交互统一
|
||||||
|
- 修复 Android App 内图片/视频「从本地发送」容易失败的问题:
|
||||||
|
- 延长文件选择器关闭后的 backdrop 点击屏蔽时长,避免 touch 穿透误关选择弹窗导致上传中断
|
||||||
|
- Android 端统一使用 `ACTION_GET_CONTENT` 选择器(按 `image/*` / `video/*` 过滤),避免不同 ROM 的相册/Photo Picker 返回临时 URI 导致上传失效
|
||||||
|
- 上传链路增强对 Android WebView 返回文件无文件名/无扩展名的兼容:前端根据 MIME 类型补全文件名,后端在 filename 缺失时自动推断兜底文件名
|
||||||
|
|
||||||
# 1.0.35
|
# 1.0.35
|
||||||
- 新增 PDF 文件原生预览:集成 AndroidPdfViewer,点击卡片「预览」按钮可在 App 内直接查看 PDF,不再依赖浏览器
|
- 新增 PDF 文件原生预览:集成 AndroidPdfViewer,点击卡片「预览」按钮可在 App 内直接查看 PDF,不再依赖浏览器
|
||||||
|
|
||||||
|
|||||||
@ -16,8 +16,8 @@ android {
|
|||||||
applicationId = "com.cyjai.agent"
|
applicationId = "com.cyjai.agent"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 37
|
versionCode = 38
|
||||||
versionName = "1.0.35"
|
versionName = "1.0.36"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
|||||||
@ -2,17 +2,20 @@ package com.cyjai.agent
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.DownloadManager
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.Environment
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.webkit.CookieManager
|
import android.webkit.CookieManager
|
||||||
|
import android.webkit.DownloadListener
|
||||||
import android.webkit.JavascriptInterface
|
import android.webkit.JavascriptInterface
|
||||||
import android.webkit.PermissionRequest
|
import android.webkit.PermissionRequest
|
||||||
import android.webkit.ValueCallback
|
import android.webkit.ValueCallback
|
||||||
@ -21,6 +24,7 @@ import android.webkit.WebResourceRequest
|
|||||||
import android.webkit.WebSettings
|
import android.webkit.WebSettings
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.activity.OnBackPressedCallback
|
import androidx.activity.OnBackPressedCallback
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
@ -45,6 +49,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "MainActivity"
|
private const val TAG = "MainActivity"
|
||||||
private const val REQUEST_RECORD_AUDIO = 300
|
private const val REQUEST_RECORD_AUDIO = 300
|
||||||
|
private const val REQUEST_WRITE_STORAGE = 301
|
||||||
private const val HOME_URL = "https://agent.cyjai.com"
|
private const val HOME_URL = "https://agent.cyjai.com"
|
||||||
private const val WEB_ASSET_VERSION = "20260406_4"
|
private const val WEB_ASSET_VERSION = "20260406_4"
|
||||||
}
|
}
|
||||||
@ -102,6 +107,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
webView.addJavascriptInterface(ThemeBridge(), "AndroidThemeBridge")
|
webView.addJavascriptInterface(ThemeBridge(), "AndroidThemeBridge")
|
||||||
webView.addJavascriptInterface(PdfPreviewBridge(), "AndroidPdfBridge")
|
webView.addJavascriptInterface(PdfPreviewBridge(), "AndroidPdfBridge")
|
||||||
|
webView.addJavascriptInterface(DownloadBridge(), "AndroidDownloadBridge")
|
||||||
|
|
||||||
// 语音识别桥接(立即注册,前端始终可检测到;引擎在首次使用时懒初始化)
|
// 语音识别桥接(立即注册,前端始终可检测到;引擎在首次使用时懒初始化)
|
||||||
voiceBridge = VoiceBridge(this@MainActivity, webView)
|
voiceBridge = VoiceBridge(this@MainActivity, webView)
|
||||||
@ -148,6 +154,18 @@ class MainActivity : AppCompatActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Android 9 及以下需要写外部存储权限才能使用 DownloadManager
|
||||||
|
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P &&
|
||||||
|
ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
ActivityCompat.requestPermissions(
|
||||||
|
this,
|
||||||
|
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
|
||||||
|
REQUEST_WRITE_STORAGE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
webView.webViewClient = object : WebViewClient() {
|
webView.webViewClient = object : WebViewClient() {
|
||||||
override fun onPageFinished(view: WebView?, url: String?) {
|
override fun onPageFinished(view: WebView?, url: String?) {
|
||||||
super.onPageFinished(view, url)
|
super.onPageFinished(view, url)
|
||||||
@ -167,6 +185,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
webView.setDownloadListener(DownloadListener { url, _, contentDisposition, _, _ ->
|
||||||
|
startSystemDownload(url, suggestFileName(contentDisposition, url))
|
||||||
|
})
|
||||||
|
|
||||||
webView.webChromeClient = object : WebChromeClient() {
|
webView.webChromeClient = object : WebChromeClient() {
|
||||||
override fun onPermissionRequest(request: PermissionRequest) {
|
override fun onPermissionRequest(request: PermissionRequest) {
|
||||||
// 按需放行媒体权限(例如文件上传触发的媒体访问)
|
// 按需放行媒体权限(例如文件上传触发的媒体访问)
|
||||||
@ -181,9 +203,21 @@ class MainActivity : AppCompatActivity() {
|
|||||||
this@MainActivity.filePathCallback?.onReceiveValue(null)
|
this@MainActivity.filePathCallback?.onReceiveValue(null)
|
||||||
this@MainActivity.filePathCallback = filePathCallback
|
this@MainActivity.filePathCallback = filePathCallback
|
||||||
|
|
||||||
val intent = fileChooserParams?.createIntent() ?: Intent(Intent.ACTION_GET_CONTENT).apply {
|
// 统一使用 ACTION_GET_CONTENT,避免不同 ROM 对 Photo Picker / 相册的兼容差异
|
||||||
|
// 导致返回的 URI/文件对象在上传时失效。根据 acceptTypes 设置过滤类型,
|
||||||
|
// 图片选择仍只显示图片文件,但走文件管理器而不是系统相册。
|
||||||
|
val acceptTypes = fileChooserParams?.acceptTypes?.filter { it.isNotBlank() }?.takeIf { it.isNotEmpty() }
|
||||||
|
?: arrayListOf("*/*")
|
||||||
|
val allowMultiple = fileChooserParams?.mode == FileChooserParams.MODE_OPEN_MULTIPLE
|
||||||
|
val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
|
||||||
addCategory(Intent.CATEGORY_OPENABLE)
|
addCategory(Intent.CATEGORY_OPENABLE)
|
||||||
type = "*/*"
|
type = acceptTypes.first()
|
||||||
|
if (allowMultiple) {
|
||||||
|
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||||
|
}
|
||||||
|
if (acceptTypes.size > 1) {
|
||||||
|
putExtra(Intent.EXTRA_MIME_TYPES, acceptTypes.toTypedArray())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fileChooserLauncher.launch(intent)
|
fileChooserLauncher.launch(intent)
|
||||||
return true
|
return true
|
||||||
@ -384,6 +418,52 @@ class MainActivity : AppCompatActivity() {
|
|||||||
fun isPdfPreviewSupported(): Boolean = true
|
fun isPdfPreviewSupported(): Boolean = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inner class DownloadBridge {
|
||||||
|
@JavascriptInterface
|
||||||
|
fun downloadFile(fileUrl: String?, fileName: String?) {
|
||||||
|
val url = fileUrl ?: return
|
||||||
|
val name = fileName ?: suggestFileName(null, url)
|
||||||
|
runOnUiThread {
|
||||||
|
startSystemDownload(url, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startSystemDownload(url: String, fileName: String) {
|
||||||
|
try {
|
||||||
|
val request = DownloadManager.Request(Uri.parse(url))
|
||||||
|
.setTitle(fileName)
|
||||||
|
.setDescription("正在下载...")
|
||||||
|
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||||
|
.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
|
||||||
|
.setAllowedOverRoaming(true)
|
||||||
|
.setAllowedOverMetered(true)
|
||||||
|
|
||||||
|
val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||||
|
downloadManager.enqueue(request)
|
||||||
|
Toast.makeText(this, "已开始下载:$fileName", Toast.LENGTH_SHORT).show()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "下载失败: ${e.message}", e)
|
||||||
|
Toast.makeText(this, "下载启动失败,请重试", Toast.LENGTH_LONG).show()
|
||||||
|
// 兜底:尝试用浏览器打开
|
||||||
|
try {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||||
|
startActivity(intent)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun suggestFileName(contentDisposition: String?, url: String): String {
|
||||||
|
contentDisposition?.let {
|
||||||
|
val regex = Regex("filename\\*?=\\s*\"?([^\";]+)\"?", RegexOption.IGNORE_CASE)
|
||||||
|
regex.find(it)?.groupValues?.get(1)?.trim()?.takeIf { name -> name.isNotBlank() }?.let { return it }
|
||||||
|
}
|
||||||
|
val uri = Uri.parse(url)
|
||||||
|
uri.lastPathSegment?.takeIf { it.isNotBlank() && it.contains(".") }?.let { return it }
|
||||||
|
uri.getQueryParameter("path")?.split("/")?.lastOrNull()?.takeIf { it.isNotBlank() }?.let { return it }
|
||||||
|
return "download_${System.currentTimeMillis()}"
|
||||||
|
}
|
||||||
|
|
||||||
inner class ThemeBridge {
|
inner class ThemeBridge {
|
||||||
@JavascriptInterface
|
@JavascriptInterface
|
||||||
fun onThemeChanged(theme: String?) {
|
fun onThemeChanged(theme: String?) {
|
||||||
|
|||||||
@ -47,6 +47,40 @@ from server.monitor import get_cached_monitor_snapshot
|
|||||||
from server.files import sanitize_filename_preserve_unicode
|
from server.files import sanitize_filename_preserve_unicode
|
||||||
|
|
||||||
UPLOAD_FOLDER_NAME = ".agents/user_upload"
|
UPLOAD_FOLDER_NAME = ".agents/user_upload"
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_filename_from_upload(uploaded_file) -> str:
|
||||||
|
"""当上传请求未提供文件名时,根据 MIME 类型或文件扩展名推断一个兜底文件名。"""
|
||||||
|
mime_type = (uploaded_file.content_type or '').strip().lower()
|
||||||
|
if mime_type:
|
||||||
|
# 优先使用 mimetypes 库的 guess_extension,它通常比硬编码映射更全面
|
||||||
|
ext = mimetypes.guess_extension(mime_type, strict=False)
|
||||||
|
if ext:
|
||||||
|
return f"upload_{int(time.time())}{ext}"
|
||||||
|
# 常见类型的兜底映射
|
||||||
|
type_map = {
|
||||||
|
'image/jpeg': '.jpg',
|
||||||
|
'image/jpg': '.jpg',
|
||||||
|
'image/png': '.png',
|
||||||
|
'image/gif': '.gif',
|
||||||
|
'image/webp': '.webp',
|
||||||
|
'image/bmp': '.bmp',
|
||||||
|
'image/svg+xml': '.svg',
|
||||||
|
'video/mp4': '.mp4',
|
||||||
|
'video/quicktime': '.mov',
|
||||||
|
'video/x-matroska': '.mkv',
|
||||||
|
'video/avi': '.avi',
|
||||||
|
'video/webm': '.webm',
|
||||||
|
'application/pdf': '.pdf',
|
||||||
|
'text/plain': '.txt',
|
||||||
|
'text/markdown': '.md',
|
||||||
|
}
|
||||||
|
ext = type_map.get(mime_type)
|
||||||
|
if ext:
|
||||||
|
return f"upload_{int(time.time())}{ext}"
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
@chat_bp.route('/api/upload', methods=['POST'])
|
@chat_bp.route('/api/upload', methods=['POST'])
|
||||||
@api_login_required
|
@api_login_required
|
||||||
@with_terminal
|
@with_terminal
|
||||||
@ -70,14 +104,25 @@ def upload_file(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
|||||||
uploaded_file = request.files['file']
|
uploaded_file = request.files['file']
|
||||||
original_name = (request.form.get('filename') or '').strip()
|
original_name = (request.form.get('filename') or '').strip()
|
||||||
|
|
||||||
if not uploaded_file or not uploaded_file.filename or uploaded_file.filename.strip() == '':
|
if not uploaded_file:
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": "未找到文件",
|
||||||
|
"message": "请求中缺少文件内容"
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
raw_name = original_name or uploaded_file.filename or ''
|
||||||
|
if not raw_name.strip():
|
||||||
|
# 文件名缺失时,尝试根据 MIME 类型或文件内容推断一个兜底文件名
|
||||||
|
raw_name = _derive_filename_from_upload(uploaded_file)
|
||||||
|
|
||||||
|
if not raw_name.strip():
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": "文件名为空",
|
"error": "文件名为空",
|
||||||
"message": "请选择要上传的文件"
|
"message": "请选择要上传的文件"
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
raw_name = original_name or uploaded_file.filename
|
|
||||||
filename = sanitize_filename_preserve_unicode(raw_name)
|
filename = sanitize_filename_preserve_unicode(raw_name)
|
||||||
if not filename:
|
if not filename:
|
||||||
filename = secure_filename(raw_name)
|
filename = secure_filename(raw_name)
|
||||||
|
|||||||
@ -4,14 +4,6 @@
|
|||||||
<span class="sfc-file-icon" aria-hidden="true"></span>
|
<span class="sfc-file-icon" aria-hidden="true"></span>
|
||||||
<span class="sfc-name" :title="displayName">{{ displayName }}</span>
|
<span class="sfc-name" :title="displayName">{{ displayName }}</span>
|
||||||
<div class="sfc-actions">
|
<div class="sfc-actions">
|
||||||
<button
|
|
||||||
v-if="canAndroidPdfPreview"
|
|
||||||
type="button"
|
|
||||||
class="sfc-btn sfc-btn-preview"
|
|
||||||
@click="previewPdf"
|
|
||||||
>
|
|
||||||
预览
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
v-if="canCopy"
|
v-if="canCopy"
|
||||||
type="button"
|
type="button"
|
||||||
@ -102,9 +94,6 @@ const props = defineProps<{
|
|||||||
preview?: string;
|
preview?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// ---- 环境检测 ----
|
|
||||||
const isAndroidApp = typeof window !== 'undefined' && Boolean((window as any).AndroidPdfBridge);
|
|
||||||
|
|
||||||
// ---- 状态 ----
|
// ---- 状态 ----
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
@ -119,15 +108,9 @@ const displayName = computed(() => props.name || props.path.split('/').pop() ||
|
|||||||
|
|
||||||
const canPreview = computed(() => {
|
const canPreview = computed(() => {
|
||||||
if (props.preview === 'off') return false;
|
if (props.preview === 'off') return false;
|
||||||
// Android App 内 PDF 不走 iframe 内嵌预览,由原生 PdfPreviewActivity 处理
|
|
||||||
if (isAndroidApp && fileType.value === 'pdf') return false;
|
|
||||||
return ['text', 'code', 'json', 'csv', 'markdown', 'image', 'pdf'].includes(fileType.value);
|
return ['text', 'code', 'json', 'csv', 'markdown', 'image', 'pdf'].includes(fileType.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
const canAndroidPdfPreview = computed(() => {
|
|
||||||
return isAndroidApp && fileType.value === 'pdf';
|
|
||||||
});
|
|
||||||
|
|
||||||
const canCopy = computed(() => ['text', 'code', 'json', 'csv', 'markdown'].includes(fileType.value));
|
const canCopy = computed(() => ['text', 'code', 'json', 'csv', 'markdown'].includes(fileType.value));
|
||||||
|
|
||||||
const metaText = computed(() => {
|
const metaText = computed(() => {
|
||||||
@ -245,6 +228,18 @@ async function loadContent() {
|
|||||||
async function handleDownload() {
|
async function handleDownload() {
|
||||||
const url = `/api/download/file?path=${encodeURIComponent(props.path)}`;
|
const url = `/api/download/file?path=${encodeURIComponent(props.path)}`;
|
||||||
const name = props.path.split('/').pop() || 'file';
|
const name = props.path.split('/').pop() || 'file';
|
||||||
|
|
||||||
|
// Android App 内通过原生桥接下载,WebView 的 a.download 通常无法触发系统下载器
|
||||||
|
const androidBridge = (window as any).AndroidDownloadBridge;
|
||||||
|
if (androidBridge && typeof androidBridge.downloadFile === 'function') {
|
||||||
|
try {
|
||||||
|
androidBridge.downloadFile(url, name);
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ShowFileCard] Android 桥接下载失败,回退:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(url);
|
const resp = await fetch(url);
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
@ -280,13 +275,6 @@ function openFullImage() {
|
|||||||
if (contentUrl.value) window.open(contentUrl.value, '_blank');
|
if (contentUrl.value) window.open(contentUrl.value, '_blank');
|
||||||
}
|
}
|
||||||
|
|
||||||
function previewPdf() {
|
|
||||||
const bridge = (window as any).AndroidPdfBridge;
|
|
||||||
if (bridge && typeof bridge.previewPdf === 'function') {
|
|
||||||
bridge.previewPdf(contentUrl.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onImageError() {
|
function onImageError() {
|
||||||
error.value = '图片加载失败';
|
error.value = '图片加载失败';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -117,8 +117,8 @@ const confirm = () => emit('confirm', Array.from(selectedSet.value));
|
|||||||
|
|
||||||
const triggerLocal = () => {
|
const triggerLocal = () => {
|
||||||
if (props.uploading) return;
|
if (props.uploading) return;
|
||||||
// 屏蔽接下来 500ms 内的 backdrop click,防止系统文件选择器关闭后的 touch 穿透误关 overlay
|
// 屏蔽接下来 1500ms 内的 backdrop click,防止系统文件选择器关闭后的 touch 穿透误关 overlay
|
||||||
ignoreBackdropClickUntil = Date.now() + 500;
|
ignoreBackdropClickUntil = Date.now() + 1500;
|
||||||
localInput.value?.click();
|
localInput.value?.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -119,8 +119,8 @@ const confirm = () => emit('confirm', Array.from(selectedSet.value));
|
|||||||
|
|
||||||
const triggerLocal = () => {
|
const triggerLocal = () => {
|
||||||
if (props.uploading) return;
|
if (props.uploading) return;
|
||||||
// 屏蔽接下来 500ms 内的 backdrop click,防止系统文件选择器关闭后的 touch 穿透误关 overlay
|
// 屏蔽接下来 1500ms 内的 backdrop click,防止系统文件选择器关闭后的 touch 穿透误关 overlay
|
||||||
ignoreBackdropClickUntil = Date.now() + 500;
|
ignoreBackdropClickUntil = Date.now() + 1500;
|
||||||
localInput.value?.click();
|
localInput.value?.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,30 @@ import { useUiStore } from './ui';
|
|||||||
import { useFileStore } from './file';
|
import { useFileStore } from './file';
|
||||||
import { usePolicyStore } from './policy';
|
import { usePolicyStore } from './policy';
|
||||||
|
|
||||||
|
function deriveFilenameFromType(mimeType: string | undefined): string | null {
|
||||||
|
if (!mimeType) return null;
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'image/jpeg': 'image.jpg',
|
||||||
|
'image/jpg': 'image.jpg',
|
||||||
|
'image/png': 'image.png',
|
||||||
|
'image/gif': 'image.gif',
|
||||||
|
'image/webp': 'image.webp',
|
||||||
|
'image/bmp': 'image.bmp',
|
||||||
|
'image/svg+xml': 'image.svg',
|
||||||
|
'video/mp4': 'video.mp4',
|
||||||
|
'video/quicktime': 'video.mov',
|
||||||
|
'video/x-matroska': 'video.mkv',
|
||||||
|
'video/avi': 'video.avi',
|
||||||
|
'video/webm': 'video.webm',
|
||||||
|
'application/pdf': 'document.pdf',
|
||||||
|
'text/plain': 'document.txt',
|
||||||
|
'text/markdown': 'document.md',
|
||||||
|
};
|
||||||
|
// 兼容类似 image/jpeg; charset=utf-8 的情况
|
||||||
|
const normalized = mimeType.split(';')[0].trim().toLowerCase();
|
||||||
|
return map[normalized] || null;
|
||||||
|
}
|
||||||
|
|
||||||
interface UploadResult {
|
interface UploadResult {
|
||||||
path: string;
|
path: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
@ -135,9 +159,10 @@ export const useUploadStore = defineStore('upload', {
|
|||||||
duration: null
|
duration: null
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
|
const safeName = file.name || deriveFilenameFromType(file.type) || `upload_${Date.now()}`;
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('filename', file.name || '');
|
formData.append('filename', safeName);
|
||||||
const response = await fetch('/api/upload', {
|
const response = await fetch('/api/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user