From ceb3e7beecb705450c82cbc85afc92e627d3d704 Mon Sep 17 00:00:00 2001 From: owenchen Date: Wed, 14 Oct 2026 21:23:40 +0800 Subject: [PATCH] ow --- .../youjiala/src/common/http.interceptor.ts | 42 +- .../file-tab-filter/file-tab-filter.vue | 9 +- .../folder-move-picker/folder-move-picker.vue | 318 +++++++++++ .../folder-name-dialog/folder-name-dialog.vue | 218 ++++---- .../option-picker/option-picker.vue | 239 +++++++++ .../components/page-navbar/page-navbar.vue | 18 +- uniapp/youjiala/src/composables/index.ts | 1 + .../youjiala/src/composables/useDmsFiles.ts | 16 +- .../youjiala/src/composables/useTrashFiles.ts | 149 ++++++ uniapp/youjiala/src/locale/lang/en-US.json | 45 +- uniapp/youjiala/src/locale/lang/zh-CN.json | 45 +- uniapp/youjiala/src/pages.json | 8 + uniapp/youjiala/src/pages/media/play/play.vue | 186 +++++++ uniapp/youjiala/src/pages/tab/file.vue | 501 +++++++++++++++++- uniapp/youjiala/src/pages/trash/trash.vue | 470 ++++++++++++++++ uniapp/youjiala/src/router/routes.ts | 6 + uniapp/youjiala/src/stores/uploadQueue.ts | 2 +- uniapp/youjiala/src/utils/dmsBytes.ts | 96 ++++ uniapp/youjiala/src/utils/playbackBus.ts | 31 ++ uniapp/youjiala/src/utils/request.ts | 18 +- 20 files changed, 2280 insertions(+), 138 deletions(-) create mode 100644 uniapp/youjiala/src/components/folder-move-picker/folder-move-picker.vue create mode 100644 uniapp/youjiala/src/components/option-picker/option-picker.vue create mode 100644 uniapp/youjiala/src/composables/useTrashFiles.ts create mode 100644 uniapp/youjiala/src/pages/media/play/play.vue create mode 100644 uniapp/youjiala/src/pages/trash/trash.vue create mode 100644 uniapp/youjiala/src/utils/dmsBytes.ts create mode 100644 uniapp/youjiala/src/utils/playbackBus.ts diff --git a/uniapp/youjiala/src/common/http.interceptor.ts b/uniapp/youjiala/src/common/http.interceptor.ts index 267f092..5330810 100644 --- a/uniapp/youjiala/src/common/http.interceptor.ts +++ b/uniapp/youjiala/src/common/http.interceptor.ts @@ -466,11 +466,16 @@ function timestamp(): string { } /** - * 下载并打开文件(默认按 Excel 处理) - * 关键防御:先探测 content-type——后端报错时返回的是 JSON 而不是文件 + * 裸 arraybuffer 请求(旁路通道):文件下载/分片取回/预览字节等面向字节的场景共用。 + * 关键防御与 downloadFile 同源——后端报错时回的是 JSON 而不是文件, + * 按 content-type 探测后转成错误文案抛出(业务异常不再当字节流落盘) */ -export async function downloadFile(options: DownloadOptions): Promise { - const { url, method = "POST", data, filename, extension = "xlsx" } = options; +export async function requestBuffer(options: { + url: string; + method?: "GET" | "POST"; + data?: any; +}): Promise { + const { url, method = "POST", data } = options; const fullUrl = url.indexOf("http") === 0 ? url : baseUrl + url; const auth = bearerAuth(); @@ -493,7 +498,7 @@ export async function downloadFile(options: DownloadOptions): Promise { const contentType = String( header["content-type"] || header.Content_Type || header.ContentType || "", ); - if (contentType.includes("application/json")) { + if (contentType.includes("json")) { const text = typeof res.data === "string" ? res.data @@ -513,19 +518,24 @@ export async function downloadFile(options: DownloadOptions): Promise { notifyError("", message); throw markErrorNotified(new Error(message)); } + return res.data as ArrayBuffer; +} - const fileName = `${filename}${timestamp()}.${extension}`; - +/** 字节按指定文件名落盘:H5 走 blob+;小程序/App 写本地目录后调起系统文档预览 */ +export async function saveArrayBuffer(bytes: ArrayBuffer, fileName: string): Promise { // #ifdef H5 // H5:blob + 隐藏 触发浏览器下载 - const downloadUrl = URL.createObjectURL(new Blob([res.data as ArrayBuffer])); + const downloadUrl = URL.createObjectURL(new Blob([bytes])); const link = document.createElement("a"); link.href = downloadUrl; link.download = fileName; document.body.appendChild(link); link.click(); - link.remove(); - URL.revokeObjectURL(downloadUrl); + // 浏览器读 blob 是异步任务:click 后立即 revoke 会让下载列表现「Failed - No file」,延迟释放 + setTimeout(() => { + link.remove(); + URL.revokeObjectURL(downloadUrl); + }, 3000); // #endif // #ifndef H5 @@ -544,7 +554,7 @@ export async function downloadFile(options: DownloadOptions): Promise { await new Promise((resolve, reject) => { fs.writeFile({ filePath, - data: res.data as ArrayBuffer, + data: bytes, success: () => resolve(), fail: (err: any) => reject(new Error(err.errMsg || "写入文件失败")), }); @@ -560,5 +570,15 @@ export async function downloadFile(options: DownloadOptions): Promise { // #endif } +/** + * 下载并打开文件(默认按 Excel 处理):请求与落盘两段均已抽为公用函数, + * 文件页的下载动作直接复用 requestBuffer + saveArrayBuffer + */ +export async function downloadFile(options: DownloadOptions): Promise { + const { url, method = "POST", data, filename, extension = "xlsx" } = options; + const bytes = await requestBuffer({ url, method, data }); + await saveArrayBuffer(bytes, `${filename}${timestamp()}.${extension}`); +} + // 导出(main.ts httpPlugin 注册用) export { httpInterceptor, httpRequestConfig }; diff --git a/uniapp/youjiala/src/components/file-tab-filter/file-tab-filter.vue b/uniapp/youjiala/src/components/file-tab-filter/file-tab-filter.vue index b0b2f71..9962b1a 100644 --- a/uniapp/youjiala/src/components/file-tab-filter/file-tab-filter.vue +++ b/uniapp/youjiala/src/components/file-tab-filter/file-tab-filter.vue @@ -31,6 +31,11 @@ defineProps({ type: Boolean, default: false, }, + /** 新建文件夹按钮开关:回收站等无建目录场景的页面传 false */ + showNewFolder: { + type: Boolean, + default: true, + }, }); const emit = defineEmits<{ @@ -60,8 +65,8 @@ const sortIconDark = sortGlyph("#1f2329"); - - + + diff --git a/uniapp/youjiala/src/components/folder-move-picker/folder-move-picker.vue b/uniapp/youjiala/src/components/folder-move-picker/folder-move-picker.vue new file mode 100644 index 0000000..d3b98ee --- /dev/null +++ b/uniapp/youjiala/src/components/folder-move-picker/folder-move-picker.vue @@ -0,0 +1,318 @@ + + + + + diff --git a/uniapp/youjiala/src/components/folder-name-dialog/folder-name-dialog.vue b/uniapp/youjiala/src/components/folder-name-dialog/folder-name-dialog.vue index 1291a23..787a9b1 100644 --- a/uniapp/youjiala/src/components/folder-name-dialog/folder-name-dialog.vue +++ b/uniapp/youjiala/src/components/folder-name-dialog/folder-name-dialog.vue @@ -1,30 +1,46 @@ diff --git a/uniapp/youjiala/src/components/option-picker/option-picker.vue b/uniapp/youjiala/src/components/option-picker/option-picker.vue new file mode 100644 index 0000000..a3e6e26 --- /dev/null +++ b/uniapp/youjiala/src/components/option-picker/option-picker.vue @@ -0,0 +1,239 @@ + + + + + diff --git a/uniapp/youjiala/src/components/page-navbar/page-navbar.vue b/uniapp/youjiala/src/components/page-navbar/page-navbar.vue index b0982b3..f6f130a 100644 --- a/uniapp/youjiala/src/components/page-navbar/page-navbar.vue +++ b/uniapp/youjiala/src/components/page-navbar/page-navbar.vue @@ -37,6 +37,16 @@ const props = defineProps({ type: Number, default: 0, }, + /** 传输列表入口开关:回收站等无上传场景的页面传 false */ + showTransfer: { + type: Boolean, + default: true, + }, + /** 快捷入口区开关:无快捷入口的页面传 false(连带隐藏切换按钮) */ + showQuickToggle: { + type: Boolean, + default: true, + }, }); const emit = defineEmits<{ @@ -61,8 +71,8 @@ const transferIcon = computed(() => transferGlyph(props.transferring ? TRANSFER_ {{ props.title }} - - + + {{ props.transferring > 99 ? "99+" : props.transferring }} @@ -72,8 +82,8 @@ const transferIcon = computed(() => transferGlyph(props.transferring ? TRANSFER_ - - + + diff --git a/uniapp/youjiala/src/composables/index.ts b/uniapp/youjiala/src/composables/index.ts index 497993d..4e06709 100644 --- a/uniapp/youjiala/src/composables/index.ts +++ b/uniapp/youjiala/src/composables/index.ts @@ -5,3 +5,4 @@ export * from "./useAuthGuard"; export * from "./useDmsFiles"; export * from "./useLang"; export * from "./useThemeGradient"; +export * from "./useTrashFiles"; diff --git a/uniapp/youjiala/src/composables/useDmsFiles.ts b/uniapp/youjiala/src/composables/useDmsFiles.ts index 6c5e9f7..126fa78 100644 --- a/uniapp/youjiala/src/composables/useDmsFiles.ts +++ b/uniapp/youjiala/src/composables/useDmsFiles.ts @@ -26,8 +26,16 @@ import dmsFileService from "@/service/master/dmsFileService"; /** 目录场景一次拉全,与旧页面 pagingParams.pageSize 同值 */ const FIND_LIMIT = 999999; +/** 类型判定所需的最小结构:文件视图与删除日志视图(回收站)共用同一套判定 */ +export interface KindSource { + IsDirectoryIndc?: string; + FileCategory?: string; + OrderKey?: string; + OrgFileName?: string; +} + /** FileCategory 编码 -> 页面分类;缺省或未知编码走扩展名兜底 */ -function kindOf(record: DmsFileUploadViewDisplayData): FileKind { +export function kindOf(record: KindSource): FileKind { if (record.IsDirectoryIndc === "Y") { return "folder"; } @@ -55,8 +63,8 @@ function kindOf(record: DmsFileUploadViewDisplayData): FileKind { return "document"; } -/** 兼容 "2026-05-13 08:17:00" 这类 iOS Date 解析失败的格式 */ -function parseTime(value: Date | string | undefined): number { +/** 兼容 "2026-05-13 08:17:00" 这类 iOS Date 解析失败的格式(回收站列表同引擎) */ +export function parseTime(value: Date | string | undefined): number { if (!value) { return 0; } @@ -73,7 +81,7 @@ function parseTime(value: Date | string | undefined): number { } /** 字节数 -> 可读大小文案;0/缺省返回空串(文件夹不显示大小) */ -function formatSize(bytes: number | undefined): string { +export function formatSize(bytes: number | undefined): string { if (!bytes || bytes <= 0) { return ""; } diff --git a/uniapp/youjiala/src/composables/useTrashFiles.ts b/uniapp/youjiala/src/composables/useTrashFiles.ts new file mode 100644 index 0000000..9bf2782 --- /dev/null +++ b/uniapp/youjiala/src/composables/useTrashFiles.ts @@ -0,0 +1,149 @@ +/** + * 回收站数据源:复刻旧 WebVue DmsFileDeleteEnquiry.vue 的调用链(D:\Docker\WebVue\src\views\master) + * + * 1. FindDmsFolderDeleteLogs() -> SelectOption[]:与文件页 FindDmsFolders 同构, + * rps[0] 是空占位项、rps[1] 才是根目录(旧页面 rootPath = rps[1].BindValue); + * 2. FindDeleteLogBySearchCriteria(criteria) -> data.Logs:Filter = [FilePath single 根路径], + * 关键字追加 [OrgFileName text contain](旧 onSearchByName 两段过滤),limit 一次拉全; + * 3. 类型/大小/时间判定复用 useDmsFiles 的 kindOf/formatSize/parseTime(删除日志视图字段同构); + * 4. 动作对齐旧系统:RestoreDmsFile(FileId) 还原、DeleteLog(FileId) 彻底删除, + * 均逐条循环提交(旧 onRestore / onDeleteLogSelected 的 for 循环原样保留)。 + */ +import type { DmsFileUploadDeleteLogViewDisplayData } from "@/data/auto/dmsFileUploadDeleteLogViewDisplayData"; +import type { FileItem } from "@/data/custom/fileBrowser"; +import { useLocale } from "uview-pro"; +import { ref } from "vue"; +import { DmsFileSearchCriteria } from "@/data/auto/dmsFileSearchCriteria"; +import { DmsFileUploadDeleteLogViewDisplayData as DeleteLogView } from "@/data/auto/dmsFileUploadDeleteLogViewDisplayData"; +import { SearchFilter } from "@/data/auto/searchFilter"; +import dmsFileService from "@/service/master/dmsFileService"; +import { formatSize, kindOf, parseTime } from "./useDmsFiles"; + +/** 目录场景一次拉全,与旧页面 pagingParams.pageSize 同值 */ +const FIND_LIMIT = 999999; + +export function useTrashFiles() { + const { t } = useLocale(); + + const files = ref([]); + /** FileId -> 原始删除记录:恢复/彻底删除按 FileId 提交 */ + const records = ref(new Map()); + const loading = ref(false); + const errorText = ref(""); + const rootPath = ref(""); + + /** 删除日志 -> 卡片条目:时间列展示的是删除时间(记录 CreatedDatetime),path 留原目录备查 */ + function toFileItem(record: DmsFileUploadDeleteLogViewDisplayData): FileItem { + const time = parseTime(record.CreatedDatetime); + const date = time ? new Date(time) : null; + const now = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + let modifiedAt = ""; + if (date) { + // 与文件页同款文案:今天显示「今天 HH:mm」,其余显示日期 + if (date.toDateString() === now.toDateString()) { + modifiedAt = `${t("FilePage.todayLabel")} ${pad(date.getHours())}:${pad(date.getMinutes())}`; + } else { + modifiedAt = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; + } + } + return { + id: record.FileId, + name: record.OrgFileName || record.FileName, + kind: kindOf(record), + // FileSize 单位是 KB(旧系统下载缓冲 (FileSize+1)*1024 字节佐证),先换算成字节 + sizeText: formatSize( + record.FileSize ? record.FileSize * 1024 : undefined, + ), + modifiedAt, + sortTime: time, + path: record.FilePath, + }; + } + + /** 查询:FilePath=根路径过滤(旧 onSearchByFilePath 语义,空路径查全量)+ 关键字 contain */ + async function load(keyword = ""): Promise { + loading.value = true; + errorText.value = ""; + try { + const criteria = new DmsFileSearchCriteria(); + const filters: SearchFilter[] = []; + if (rootPath.value) { + const pathFilter = new SearchFilter(); + pathFilter.Column = DeleteLogView.DB_FILE_PATH; + pathFilter.Type = "SingleSelection"; + pathFilter.Operator = "single"; + pathFilter.Value = rootPath.value; + filters.push(pathFilter); + } + if (keyword.trim()) { + const nameFilter = new SearchFilter(); + nameFilter.Column = DeleteLogView.DB_ORG_FILE_NAME; + nameFilter.Type = "text"; + nameFilter.Operator = "contain"; + nameFilter.Value = keyword.trim(); + filters.push(nameFilter); + } + criteria.Filter = filters; + criteria.limit = FIND_LIMIT; + criteria.page = 1; + criteria.GridColumns = []; + const data = await dmsFileService.FindDeleteLogBySearchCriteria(criteria); + const rows = data?.Logs ?? []; + files.value = rows.map(toFileItem); + const nextRecords = new Map< + number, + DmsFileUploadDeleteLogViewDisplayData + >(); + for (const row of rows) { + nextRecords.set(row.FileId, row); + } + records.value = nextRecords; + } catch (error: any) { + // 错误提示已由 http.interceptor 统一 toast,这里只留页面内可见的失败态 + files.value = []; + records.value = new Map(); + errorText.value = error?.message || t("FilePage.loadFailed"); + } finally { + loading.value = false; + } + } + + /** 初始化:拉删除日志的目录选项拿根路径,再查回收站(旧 onMounted -> initBindOption -> onSearchByFilePath) */ + async function init(): Promise { + try { + const options = (await dmsFileService.FindDmsFolderDeleteLogs()) ?? []; + // rps[0] 是空占位项,rps[1] 才是根目录;只回一条时退回 [0](空 BindValue 查全量,与文件页同策略) + const root = options.length > 1 ? options[1] : options[0]; + rootPath.value = root?.BindValue ?? ""; + await load(); + } catch (error: any) { + errorText.value = error?.message || t("FilePage.loadFailed"); + } + } + + /** 还原(旧 onRestore):逐条 RestoreDmsFile,成功与否由调用方 toast/刷新 */ + async function restore(ids: number[]): Promise { + for (const id of ids) { + await dmsFileService.RestoreDmsFile(id); + } + } + + /** 彻底删除(旧 onDeleteLogSelected):逐条 DeleteLog,物理删记录不可恢复 */ + async function purge(ids: number[]): Promise { + for (const id of ids) { + await dmsFileService.DeleteLog(id); + } + } + + return { + files, + records, + loading, + errorText, + init, + load, + restore, + purge, + }; +} diff --git a/uniapp/youjiala/src/locale/lang/en-US.json b/uniapp/youjiala/src/locale/lang/en-US.json index 1fa7668..86fda41 100644 --- a/uniapp/youjiala/src/locale/lang/en-US.json +++ b/uniapp/youjiala/src/locale/lang/en-US.json @@ -72,7 +72,50 @@ "myCloud": "My Cloud", "loading": "Loading…", "loadFailed": "Failed to load, please retry", - "retryBut": "Retry" + "retryBut": "Retry", + "renameTitle": "Rename", + "renameConfirm": "Save", + "renameDone": "Renamed", + "deleteConfirmText": "Delete {count} item(s)? You can restore them in Trash", + "deleteDone": "Deleted", + "moveTitle": "Choose destination folder", + "moveRoot": "My Drive", + "moveDone": "Moved", + "moveSelected": "{count} item(s) selected", + "moveHere": "Move here", + "moveNoSub": "No subfolders", + "shareTitle": "Choose a family folder", + "shareDone": "Shared", + "shareNoFile": "No shareable files", + "publishNoFile": "No publishable files", + "publishUnsupported": "This file type cannot be published", + "publishPickPhoto": "Choose a photo album", + "publishPickAudio": "Choose an audio album", + "publishPickVideo": "Choose a video album", + "publishDone": "Published", + "downloadNoFile": "No downloadable files", + "downloadDone": "Downloaded", + "downloading": "Downloading…", + "previewNoImage": "No previewable images", + "loadingMedia": "Preparing media…", + "pleaseWait": "Working…", + "actionFailed": "Operation failed", + "pickerEmpty": "Nothing to choose", + "playTitle": "Now playing", + "detailName": "Name", + "detailSize": "Size", + "detailTime": "Time", + "detailOrder": "Storage key" + }, + "TrashPage": { + "title": "Trash", + "restore": "Restore", + "purge": "Delete permanently", + "restoreDone": "Restored", + "purgeDone": "Permanently deleted", + "purgeConfirmText": "Permanently delete {count} item(s)? This cannot be undone", + "pickFirst": "Select items first", + "emptyText": "Trash is empty" }, "TransportPage": { "title": "Transfers", diff --git a/uniapp/youjiala/src/locale/lang/zh-CN.json b/uniapp/youjiala/src/locale/lang/zh-CN.json index 9bede39..e2d9ee6 100644 --- a/uniapp/youjiala/src/locale/lang/zh-CN.json +++ b/uniapp/youjiala/src/locale/lang/zh-CN.json @@ -72,7 +72,50 @@ "myCloud": "我的云盘", "loading": "加载中…", "loadFailed": "加载失败,请重试", - "retryBut": "重试" + "retryBut": "重试", + "renameTitle": "重命名", + "renameConfirm": "保存", + "renameDone": "已重命名", + "deleteConfirmText": "确定删除 {count} 项?删除后可在回收站恢复", + "deleteDone": "已删除", + "moveTitle": "选择目标文件夹", + "moveRoot": "我的网盘", + "moveDone": "已移动", + "moveSelected": "已选择 {count} 项", + "moveHere": "移动到此", + "moveNoSub": "没有子文件夹", + "shareTitle": "选择家人目录", + "shareDone": "共享成功", + "shareNoFile": "没有可共享的文件", + "publishNoFile": "没有可发布的文件", + "publishUnsupported": "该类型文件不能发布", + "publishPickPhoto": "选择相册", + "publishPickAudio": "选择音乐集", + "publishPickVideo": "选择影视集", + "publishDone": "发布成功", + "downloadNoFile": "没有可下载的文件", + "downloadDone": "下载完成", + "downloading": "下载中…", + "previewNoImage": "没有可预览的图片", + "loadingMedia": "媒体准备中…", + "pleaseWait": "处理中…", + "actionFailed": "操作失败", + "pickerEmpty": "暂无可选项", + "playTitle": "正在播放", + "detailName": "名称", + "detailSize": "大小", + "detailTime": "时间", + "detailOrder": "存储标识" + }, + "TrashPage": { + "title": "回收站", + "restore": "恢复", + "purge": "彻底删除", + "restoreDone": "已恢复", + "purgeDone": "已彻底删除", + "purgeConfirmText": "将彻底删除 {count} 项,操作不可恢复", + "pickFirst": "请先勾选条目", + "emptyText": "回收站是空的" }, "TransportPage": { "title": "传输列表", diff --git a/uniapp/youjiala/src/pages.json b/uniapp/youjiala/src/pages.json index 1556bf8..71b7151 100644 --- a/uniapp/youjiala/src/pages.json +++ b/uniapp/youjiala/src/pages.json @@ -47,6 +47,14 @@ // 传输列表:uploadQueue store 的视图页,选文件上传后自动跳入 { "path": "pages/transport/transport" + }, + // 媒体播放页(短地址 /play):文件页视频/音频播放动作的目标页 + { + "path": "pages/media/play/play" + }, + // 回收站(短地址 /trash):文件页快捷入口跳入,还原/彻底删除 + { + "path": "pages/trash/trash" } ], // 全局配置 diff --git a/uniapp/youjiala/src/pages/media/play/play.vue b/uniapp/youjiala/src/pages/media/play/play.vue new file mode 100644 index 0000000..8744272 --- /dev/null +++ b/uniapp/youjiala/src/pages/media/play/play.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/uniapp/youjiala/src/pages/tab/file.vue b/uniapp/youjiala/src/pages/tab/file.vue index fca80a5..842a080 100644 --- a/uniapp/youjiala/src/pages/tab/file.vue +++ b/uniapp/youjiala/src/pages/tab/file.vue @@ -14,15 +14,29 @@ * 目录导航:点文件夹下钻;子目录态隐藏底部 tabbar,返回箭头/面包屑退层级。 * 权限暂用 UserOperation(见 routes.ts 的 PAGE_PERMISSIONS);useAuthGuard 兜冷启动直达。 */ +import type { FolderRow } from "@/components/folder-move-picker/folder-move-picker.vue"; +import type { PickerOption } from "@/components/option-picker/option-picker.vue"; import type { FileCategory, FileItem, FileKind, FileTabItem, QuickEntryItem } from "@/data/custom/fileBrowser"; import { useLocale } from "uview-pro"; import { computed, ref, watch } from "vue"; import { useAuthGuard, useDmsFiles, useThemeGradient } from "@/composables"; import { DmsFileEntity } from "@/data/auto/dmsFileEntity"; +import { FamilyGroupFolderEntity } from "@/data/auto/familyGroupFolderEntity"; +import { FamilyGroupManagerUserSearchCriteria } from "@/data/auto/familyGroupManagerUserSearchCriteria"; +import { PublishAudioSearchCriteria } from "@/data/auto/publishAudioSearchCriteria"; +import { PublishData } from "@/data/auto/publishData"; +import { PublishPhotoSearchCriteria } from "@/data/auto/publishPhotoSearchCriteria"; +import { PublishVideoSearchCriteria } from "@/data/auto/publishVideoSearchCriteria"; import { pageUrl } from "@/router/routes"; import dmsFileService from "@/service/master/dmsFileService"; +import familyGroupService from "@/service/master/familyGroupService"; +import publishAudioService from "@/service/master/publishAudioService"; +import publishPhotoService from "@/service/master/publishPhotoService"; +import publishVideoService from "@/service/master/publishVideoService"; import { useUploadQueueStore } from "@/stores/uploadQueue"; import { chooseFiles } from "@/utils/chooseFiles"; +import { fetchFileBytes, saveFileBytes, toPlayableUrl } from "@/utils/dmsBytes"; +import { clearPlayback, setPlayback } from "@/utils/playbackBus"; import { transferGlyph } from "@/utils/transferGlyph"; const { t } = useLocale(); @@ -30,7 +44,7 @@ const { t } = useLocale(); // 页面底渐变跟随主题(派生逻辑在 composables/useThemeGradient,多页共用) const { themeBackground } = useThemeGradient(); -const { files, loading, errorText, pathStack, atRoot, init, openTrail, retry, openFolder, back, goTo, search } = useDmsFiles(); +const { files, records, loading, errorText, pathStack, atRoot, init, openTrail, retry, openFolder, back, goTo, search } = useDmsFiles(); // 上传队列(全局 store):入队在本页、胶囊读它的进行中计数、守卫回调消费它的面包屑导航请求 const uploadQueue = useUploadQueueStore(); @@ -167,6 +181,11 @@ function onSearch(value: string): void { } function onQuickClick(key: string): void { + // 回收站已落地(pages/trash);其余入口仍是占位提示 + if (key === "trash") { + uni.navigateTo({ url: pageUrl("trash") }); + return; + } const entry = quickEntries.value.find(e => e.key === key); toast(entry ? `${entry.label} · ${t("FilePage.todoTip")}` : t("FilePage.todoTip")); } @@ -266,6 +285,16 @@ const previewIcon = computed(() => { return pickedKind.value === "audio" ? "volume" : "eye"; }); +/** 预览按钮的分流动作 key:视频/音频走播放页(play),图片走相册连览(preview); + * 文档没有移动端预览器,不显示该按钮 */ +const previewKey = computed(() => { + const kind = pickedKind.value; + if (kind === "video" || kind === "audio") { + return "play"; + } + return kind === "image" ? "preview" : ""; +}); + /** 选中含文件即可下载(旧系统 onDownloadSelected 就是滤掉目录逐个循环下载) */ const hasFileSelected = computed(() => selectedFiles.value.some(f => f.kind !== "folder")); @@ -289,8 +318,8 @@ const multiActionsBottom = computed(() => { const actions: MultiAction[] = []; const picked = selectedFiles.value; if (picked.length) { - if (canSharePublish.value) { - actions.push({ key: "preview", label: previewLabel.value, icon: previewIcon.value }); + if (canSharePublish.value && previewKey.value) { + actions.push({ key: previewKey.value, label: previewLabel.value, icon: previewIcon.value }); } if (picked.length === 1 && picked[0].kind !== "folder") { actions.push({ key: "rename", label: t("FilePage.actionRename"), icon: "edit-pen" }); @@ -301,13 +330,13 @@ const multiActionsBottom = computed(() => { return actions; }); -/** 多选动作:取消直接退出多选,其余待真实接口接入后在此分流 */ +/** 多选动作:取消退出多选,其余与单选面板同一入口(runAction 按 key 分流) */ function onMultiAction(key: string): void { if (key === "cancel") { onToggleSelect(); return; } - toast(`${key} · ${t("FilePage.todoTip")}`); + runAction(key, selectedFiles.value); } function onToggleSelectItem(id: number): void { @@ -321,13 +350,22 @@ function onToggleSelectItem(id: number): void { // ===== 列表条目 / 悬浮按钮 ===== async function onOpen(file: FileItem): Promise { - // 文件夹下钻走真实接口;打开文件(预览/播放)尚未实现,先占位 + // 文件夹下钻走真实接口;文件点开即预览/播放(旧系统 onViewPic 同款分流) if (await openFolder(file)) { keyword.value = ""; selectedIds.value = []; return; } - toast(`${file.name} · ${t("FilePage.todoTip")}`); + if (file.kind === "image") { + actionPreview([file]); + } + else if (file.kind === "video" || file.kind === "audio") { + actionPlay(file); + } + else { + // 文档没有移动端预览器,点开按下载处理(比占位 toast 实用) + actionDownload([file]); + } } // 「…」单选操作面板:记目标条目即展开(面板内动作按 kind 派生,见 file-action-sheet) @@ -337,10 +375,436 @@ function onItemMore(file: FileItem): void { actionFile.value = file; } -/** 面板动作回调:真实接口逐个接入后在此分流,当前统一占位提示并收起面板 */ +/** + * ===== 条目动作层(单选面板与多选栏共用,runAction 是唯一入口)===== + * 调用链对齐旧系统 DmsFileEnquiry:删除 Delete、重命名 UpdateDmsFolder、 + * 移动 MoveDmsFolder、下载=分片取回拼整(utils/dmsBytes)、共享 CreateFamilyGroupFolder、 + * 发布 InsertPublishXxxDetail;目标记录从 useDmsFiles 的 records 拿全字段。 + */ + +/** 动作在途标记:showLoading 全屏遮罩外再兑一层防连点 */ +const actionBusy = ref(false); + +/** 包一层全屏 loading:接口异常统一 toast(业务异常主拦截器已弹模态,这里兜网络/流程错误) */ +async function withBusy(task: () => Promise, tip?: string): Promise { + if (actionBusy.value) { + return; + } + actionBusy.value = true; + uni.showLoading({ title: tip || t("FilePage.pleaseWait"), mask: true }); + try { + await task(); + } + catch (error: any) { + toast(error?.message || t("FilePage.actionFailed")); + } + finally { + uni.hideLoading(); + actionBusy.value = false; + } +} + +/** showModal 包 Promise(删除确认) */ +function confirmDialog(title: string, content: string): Promise { + return new Promise((resolve) => { + uni.showModal({ + title, + content, + success: res => resolve(!!res.confirm), + fail: () => resolve(false), + }); + }); +} + +/** 列表原始记录(全字段):OrderKey/Url/系统 FileName 等隐藏字段只有它里有 */ +function recordOf(file: FileItem) { + return records.value.get(file.id); +} + +// ----- 删除:旧系统 onDelete 逐条 Delete(软删,回收站可 RestoreDmsFile 恢复)----- +async function actionDelete(targets: FileItem[]): Promise { + const ok = await confirmDialog( + t("FilePage.actionDelete"), + t("FilePage.deleteConfirmText").replace("{count}", String(targets.length)), + ); + if (!ok) { + return; + } + await withBusy(async () => { + for (const file of targets) { + await dmsFileService.Delete(file.id); + } + toast(t("FilePage.deleteDone")); + selectedIds.value = []; + retry(); + }); +} + +// ----- 重命名:命名对话框复用(预填当前名),提交 UpdateDmsFolder(目录同款,旧 onAmend)----- +const renameVisible = ref(false); +const renameSubmitting = ref(false); +const renameTarget = ref(null); + +function actionRename(targets: FileItem[]): void { + const [first] = targets; + if (!first) { + return; + } + renameTarget.value = first; + renameVisible.value = true; +} + +async function onRenameConfirm(name: string): Promise { + const target = renameTarget.value; + if (!target) { + return; + } + if (/[\\/]/.test(name)) { + toast(t("FilePage.folderInvalid")); + return; + } + const record = recordOf(target); + renameSubmitting.value = true; + try { + const entity = new DmsFileEntity(); + entity.FileId = target.id; + entity.FileName = name; + // 旧系统取值:FilePath 是所在目录 FolderLocalName,根目录(CreatedBy==FolderLocalName)传空串 + const folderLocal = record?.FolderLocalName ?? ""; + entity.FilePath = record && record.CreatedBy === folderLocal ? "" : folderLocal; + await dmsFileService.UpdateDmsFolder(entity); + renameVisible.value = false; + toast(t("FilePage.renameDone")); + retry(); + } + catch (error: any) { + if (error?.message) { + toast(error.message); + } + } + finally { + renameSubmitting.value = false; + } +} + +// ----- 移动:folder-move-picker 网盘式逐级下钻选目标;旧系统仅单选,这里多选批量逐个挪 ----- +const moveVisible = ref(false); +const moveTargets = ref([]); + +function actionMove(targets: FileItem[]): void { + if (!targets.length) { + return; + } + moveTargets.value = [...targets]; + moveVisible.value = true; +} + +/** 移动文件夹时把自己从候选里剔除(其子树随之不可见);文件无需剔除 */ +const moveExcludeName = computed(() => { + const [first] = moveTargets.value; + return first && first.kind === "folder" ? recordOf(first)?.FileName ?? "" : ""; +}); + +/** 单选且非目录时禁掉「原地移动」(多选来源目录可能不同,不做置灰) */ +const moveSourcePath = computed(() => { + const [first] = moveTargets.value; + return moveTargets.value.length === 1 ? recordOf(first)?.FilePath ?? "" : ""; +}); + +async function onMovePick(row: FolderRow): Promise { + moveVisible.value = false; + const targets = moveTargets.value; + await withBusy(async () => { + for (const file of targets) { + const record = recordOf(file); + const entity = new DmsFileEntity(); + entity.FileId = file.id; + // 旧系统 onMove:FileName 传 OrgFileName,FilePath 是目标目录系统名 + entity.FileName = record?.OrgFileName ?? file.name; + entity.FilePath = row.folderName; + await dmsFileService.MoveDmsFolder(entity); + } + toast(t("FilePage.moveDone")); + selectedIds.value = []; + retry(); + }); +} + +// ----- 下载:视频 Url 直取、其余分片拼整;逐个串行(浏览器拦并发多文件)----- +async function actionDownload(targets: FileItem[]): Promise { + const picked = targets.filter(f => f.kind !== "folder"); + if (!picked.length) { + toast(t("FilePage.downloadNoFile")); + return; + } + await withBusy(async () => { + for (const file of picked) { + const record = recordOf(file); + if (record) { + await saveFileBytes(await fetchFileBytes(record)); + } + } + toast(t("FilePage.downloadDone")); + }, t("FilePage.downloading")); +} + +/** 图片预览:单选一张、多选多图相册连览。 + * 走原图拼整通道(旧 MyComputer @preview-image → GetOrgPictureFile 就是分片拼整); + * DownloadFilePreview 是旧缩略图通道,依赖上传时生成的 scale 目录,本地库没有缩略图只会 400 */ +async function actionPreview(targets: FileItem[]): Promise { + const images = targets.filter(f => f.kind === "image"); + if (!images.length) { + toast(t("FilePage.previewNoImage")); + return; + } + await withBusy(async () => { + const urls: string[] = []; + for (const file of images) { + const record = recordOf(file); + if (record) { + urls.push(toPlayableUrl(await fetchFileBytes(record))); + } + } + if (urls.length) { + uni.previewImage({ urls, current: urls[0] }); + } + }); +} + +/** 视频/音频播放:视频有直链 Url 直接进播放页,否则字节拼整后本地播(H5 blob: 地址经 query 传递) */ +async function actionPlay(target: FileItem): Promise { + const record = recordOf(target); + if (!record) { + return; + } + if (target.kind === "video" && record.Url) { + goPlay("video", target.name, record.Url); + return; + } + await withBusy(async () => { + const payload = await fetchFileBytes(record); + goPlay(target.kind === "video" ? "video" : "audio", target.name, toPlayableUrl(payload)); + }, t("FilePage.loadingMedia")); +} + +function goPlay(type: "video" | "audio", name: string, url: string): void { + // 播放地址走模块级总线传递,query 只带类型与展示名(blob 地址经 query 二次编码会跳转失败); + // fail 不兜底就是静默不跳,这里把原因顶出来 + setPlayback({ type, name, url }); + uni.navigateTo({ + url: `${pageUrl("mediaPlay")}?type=${type}&name=${encodeURIComponent(name)}`, + fail: (err: any) => { + clearPlayback(); + toast(err?.errMsg || t("FilePage.actionFailed")); + }, + }); +} + +// ----- 共享家人:选收藏夹里的家人目录,组装 FamilyGroupFolderEntity 批量建(旧 handleSelectShare)----- +const shareVisible = ref(false); +const shareLoading = ref(false); +const shareItems = ref([]); +/** 展示用 items 之外的组装字段(FamilyName/TopFolderName 提交才需要) */ +const shareFolders = ref>([]); +const shareTargets = ref([]); + +async function actionShare(targets: FileItem[]): Promise { + const picked = targets.filter(f => f.kind !== "folder"); + if (!picked.length) { + toast(t("FilePage.shareNoFile")); + return; + } + shareTargets.value = picked; + shareVisible.value = true; + shareLoading.value = true; + try { + const criteria = new FamilyGroupManagerUserSearchCriteria(); + criteria.Filter = []; + criteria.limit = 999; + criteria.page = 1; + criteria.GridColumns = []; + const data = await familyGroupService.FamilyFolderFavoriteBySearchCriteria(criteria); + const favorites = data?.Favorites ?? []; + shareFolders.value = favorites.map(f => ({ + key: f.FolderName, + familyName: f.FamilyName, + topFolderName: f.TopFolderName, + })); + shareItems.value = favorites.map(f => ({ + key: f.FolderName, + label: `${f.FamilyLocalName} / ${f.FolderLocalName}`, + sub: f.Notice, + })); + } + catch (error: any) { + toast(error?.message || t("FilePage.actionFailed")); + } + finally { + shareLoading.value = false; + } +} + +async function onShareConfirm(key: string): Promise { + const target = shareFolders.value.find(f => f.key === key); + shareVisible.value = false; + if (!target) { + return; + } + const filesToShare = shareTargets.value; + await withBusy(async () => { + const rows = filesToShare.map((file) => { + const record = recordOf(file); + const fgp = new FamilyGroupFolderEntity(); + fgp.FamilyName = target.familyName; + fgp.TopFolderName = target.topFolderName; + // 旧系统组装:FolderName 文件系统名、FolderLocalName 原始名、FilePath 目标共享目录 + fgp.FolderName = record?.FileName ?? file.path; + fgp.FolderLocalName = record?.OrgFileName ?? file.name; + fgp.FilePath = target.key; + return fgp; + }); + await dmsFileService.CreateFamilyGroupFolder(rows); + toast(t("FilePage.shareDone")); + selectedIds.value = []; + }); +} + +// ----- 发布:按媒体类型选专辑,InsertPublishXxxDetail 逐文件入集(旧 handleSelectPublish*)----- +const publishVisible = ref(false); +const publishLoading = ref(false); +const publishItems = ref([]); +const publishTargets = ref([]); +const publishKind = ref<"image" | "video" | "audio">("image"); + +/** 发布弹窗标题按媒体类型取相册/音乐集/影集名目(旧系统三个独立 Enquiry 弹窗) */ +const publishTitle = computed(() => { + if (publishKind.value === "video") { + return t("FilePage.publishPickVideo"); + } + return publishKind.value === "audio" ? t("FilePage.publishPickAudio") : t("FilePage.publishPickPhoto"); +}); + +async function actionPublish(targets: FileItem[]): Promise { + const picked = targets.filter(f => f.kind !== "folder"); + if (!picked.length) { + toast(t("FilePage.publishNoFile")); + return; + } + const [first] = picked; + if (first.kind !== "image" && first.kind !== "video" && first.kind !== "audio") { + toast(t("FilePage.publishUnsupported")); + return; + } + // 旧系统按同类型发布:混选只发与第一个文件同类型的条目 + publishKind.value = first.kind; + publishTargets.value = picked.filter(f => f.kind === first.kind); + publishVisible.value = true; + publishLoading.value = true; + try { + let names: string[] = []; + if (first.kind === "image") { + const criteria = new PublishPhotoSearchCriteria(); + criteria.Filter = []; + criteria.limit = 999; + criteria.page = 1; + criteria.GridColumns = []; + const data = await publishPhotoService.FindBySearchCriteria(criteria); + names = (data?.Publishs ?? []).map(p => p.PublishName); + } + else if (first.kind === "video") { + const criteria = new PublishVideoSearchCriteria(); + criteria.Filter = []; + criteria.limit = 999; + criteria.page = 1; + criteria.GridColumns = []; + const data = await publishVideoService.FindBySearchCriteria(criteria); + names = (data?.Datas ?? []).map(p => p.PublishName); + } + else { + const criteria = new PublishAudioSearchCriteria(); + criteria.Filter = []; + criteria.limit = 999; + criteria.page = 1; + criteria.GridColumns = []; + const data = await publishAudioService.FindBySearchCriteria(criteria); + names = (data?.Datas ?? []).map(p => p.PublishName); + } + publishItems.value = names.filter(Boolean).map(n => ({ key: n, label: n })); + } + catch (error: any) { + toast(error?.message || t("FilePage.actionFailed")); + } + finally { + publishLoading.value = false; + } +} + +async function onPublishConfirm(name: string): Promise { + publishVisible.value = false; + const targets = publishTargets.value; + const kind = publishKind.value; + await withBusy(async () => { + for (const file of targets) { + const data = new PublishData(); + data.FileId = file.id; + data.PublishName = name; + if (kind === "image") { + await publishPhotoService.InsertPublishPhotoDetail(data); + } + else if (kind === "video") { + await publishVideoService.InsertPublishVideoDetail(data); + } + else { + await publishAudioService.InsertPublishAudioDetail(data); + } + } + toast(t("FilePage.publishDone")); + selectedIds.value = []; + }); +} + +// ----- 详情:只读卡片(旧系统信息抽屉的移动端简化)----- +function actionDetail(file: FileItem): void { + const record = recordOf(file); + const lines = [ + `${t("FilePage.detailName")}: ${file.name}`, + file.sizeText ? `${t("FilePage.detailSize")}: ${file.sizeText}` : "", + `${t("FilePage.detailTime")}: ${file.modifiedAt}`, + record?.OrderKey ? `${t("FilePage.detailOrder")}: ${record.OrderKey}` : "", + ].filter(Boolean); + uni.showModal({ title: t("FilePage.actionDetail"), content: lines.join("\n"), showCancel: false }); +} + +/** key -> 动作实现表:面板宫格/行式与多选栏的 key 全部从这里分发 */ +const actionRunners: Record void> = { + download: actionDownload, + delete: actionDelete, + rename: actionRename, + move: actionMove, + share: actionShare, + publish: actionPublish, + preview: actionPreview, + play: targets => targets[0] && actionPlay(targets[0]), + detail: targets => targets[0] && actionDetail(targets[0]), +}; + +/** 面板/多选栏唯一动作入口:单选传 [file],多选传勾选集 */ +function runAction(key: string, targets: FileItem[]): void { + if (!targets.length) { + return; + } + const run = actionRunners[key]; + if (run) { + run(targets); + return; + } + toast(t("FilePage.todoTip")); +} + +/** 面板动作回调:收起面板后交给 runAction 分流 */ function onSheetAction(key: string, file: FileItem): void { actionFile.value = null; - toast(`${file.name} · ${key} · ${t("FilePage.todoTip")}`); + runAction(key, [file]); } // 新建文件夹命名对话框(CreateDmsFolder 在途标记防连点) @@ -411,9 +875,10 @@ const transferIconWhite = transferGlyph("#ffffff");