Files
Docker/uniapp/youjiala/src/pages/tab/file.vue
T
2026-10-22 09:09:13 +08:00

1290 lines
48 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* 底部导航「文件」页:按截图拆解为独立组件(components/ 下各自一个 vue 文件),
* 页面只持有状态与数据,经 props 单向下发、事件(emit)上报,组件之间不互相引用:
* page-navbar 顶部导航(左对齐标题 + 右对齐操作图标;仅子目录态显示返回箭头)
* file-breadcrumb 目录面包屑(仅子目录态:左起排列,放不下折成 根+…+尾部段)
* search-input 搜索框(v-model 即时过滤,确认键触发服务端搜索)
* quick-entry 快捷功能入口(横向滚动图标组)
* file-tab-filter 分类标签 + 新建文件夹 + 排序 + 视图切换 + 多选开关
* file-grid-list 文件网格/列表卡片
* float-add-btn 右下角悬浮加号(直接选文件上传)
* 数据来自后端 DMS 接口(composables/useDmsFiles,调用链对齐旧 WebVue DmsFileEnquiry.vue);
* 组件契约(data/custom/fileBrowser.ts)不关心数据源,演示数据到真实接口只动页面层。
* 目录导航:点文件夹下钻;子目录态隐藏底部 tabbar,返回箭头/面包屑退层级。
* 权限暂用 UserOperation(见 routes.ts 的 PAGE_PERMISSIONS);useAuthGuard 兜冷启动直达。
*/
import type { FolderRow } from "@/components/folder-move-picker/folder-move-picker.vue";
import type { ShareTarget } from "@/components/folder-share-picker/folder-share-picker.vue";
import type { ImageViewerItem } from "@/components/image-viewer/image-viewer.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 { 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 publishAudioService from "@/service/master/publishAudioService";
import publishPhotoService from "@/service/master/publishPhotoService";
import publishVideoService from "@/service/master/publishVideoService";
import { useMediaIntentStore } from "@/stores/mediaIntent";
import { useUploadQueueStore } from "@/stores/uploadQueue";
import { chooseFiles } from "@/utils/chooseFiles";
import { fetchFileBytes, saveFileBytes, toImageUrl, toPlayableUrl } from "@/utils/dmsBytes";
import { absoluteFileUrl } from "@/utils/fileUrl";
import { clearPlayback, setPlayback } from "@/utils/playbackBus";
import { transferGlyph } from "@/utils/transferGlyph";
const { t } = useLocale();
// 页面底跟随主题(派生逻辑在 composables/useThemeGradient,多页共用):
// 顶部主题色光晕 → 自然过渡到近白内容区,替代整页铺色的灰浊感
const { themeGlowBackground } = useThemeGradient();
const { files, records, loading, errorText, pathStack, atRoot, init, openTrail, retry, openFolder, back, goTo, search } = useDmsFiles();
// 上传队列(全局 store):入队在本页、胶囊读它的进行中计数、守卫回调消费它的面包屑导航请求
const uploadQueue = useUploadQueueStore();
// 跨 tab 意图通道:发布面板「新建相册」→ reLaunch 去媒体页自动进创建态
const mediaIntent = useMediaIntentStore();
// 首次显示拉根目录,之后每次回到页面只刷新当前目录(重进 tab 不该丢位置重新拉全链);
// 回页末尾消费传输页的面包屑导航请求(reLaunch 重建后恢复到目标目录)
let started = false;
useAuthGuard(async () => {
if (started) {
await retry();
}
else {
started = true;
await init();
}
const trail = uploadQueue.takeNav();
if (trail) {
await openTrail(trail);
}
});
// ===== 页面状态(全部单向下发,组件只上报意图) =====
const keyword = ref("");
const category = ref<FileCategory>("all");
const viewMode = ref<"grid" | "list">("list");
const selectMode = ref(false);
const selectedIds = ref<number[]>([]);
/** 排序模式:0 时间降序 / 1 时间升序 / 2 名称,点导航栏排序按钮循环 */
const sortMode = ref(0);
/* 搜索框 / 快捷入口显隐:navbar 右侧两个图标手动切换;
默认规则——进入子目录(有面包屑)时隐藏、回根目录恢复显示 */
const showSearch = ref(true);
const showQuick = ref(true);
watch(atRoot, (root) => {
showSearch.value = root;
showQuick.value = root;
});
/** 导航栏标题跟随当前目录:根为「我的云盘」,下钻后是文件夹名 */
const navTitle = computed(() => pathStack.value[pathStack.value.length - 1]?.displayName || t("FilePage.title"));
const quickEntries = computed<QuickEntryItem[]>(() => [
{ key: "album", label: t("FilePage.album"), icon: "photo", bgFrom: "#ff9a8b", bgTo: "#f0524a" },
{ key: "musicHall", label: t("FilePage.musicHall"), icon: "volume", bgFrom: "#a78bfa", bgTo: "#6d43e0" },
{ key: "videoHall", label: t("FilePage.videoHall"), icon: "play-circle-fill", bgFrom: "#63b3ff", bgTo: "#1f7ae0" },
{ key: "shareCenter", label: t("FilePage.shareCenter"), icon: "share-fill", bgFrom: "#5ec2ff", bgTo: "#2b8fff" },
{ key: "trash", label: t("FilePage.trash"), icon: "trash", bgFrom: "#b6bcc6", bgTo: "#7d848f" },
]);
const tabs = computed<FileTabItem[]>(() => [
{ key: "all", label: t("FilePage.catAll") },
{ key: "image", label: t("FilePage.catImage") },
{ key: "audio", label: t("FilePage.catAudio") },
{ key: "video", label: t("FilePage.catVideo") },
{ key: "document", label: t("FilePage.catDocument") },
]);
/**
* 分类为本地过滤(后端按目录返回全量,切标签不该重新打接口);
* 选中分类时目录一起被过滤掉,只有「全部」显示文件夹;
* 关键字在确认键时走服务端 contain 过滤,输入过程中先本地收敛已加载数据。
*/
const visibleFiles = computed<FileItem[]>(() => {
const kw = keyword.value.trim().toLowerCase();
const list = files.value.filter(
f => (category.value === "all" || f.kind === category.value)
&& (!kw || f.name.toLowerCase().includes(kw)),
);
const bySort = (a: FileItem, b: FileItem) => {
if (sortMode.value === 2) {
return a.name.localeCompare(b.name, "zh-CN");
}
return sortMode.value === 1 ? a.sortTime - b.sortTime : b.sortTime - a.sortTime;
};
// 文件夹组恒置顶(对齐后端 IsDirectoryIndc desc 排序与截图样式),组内再按当前排序
const folders = list.filter(f => f.kind === "folder").sort(bySort);
const others = list.filter(f => f.kind !== "folder").sort(bySort);
return [...folders, ...others];
});
function toast(title: string): void {
uni.showToast({ title, icon: "none" });
}
// ===== 导航栏 / 面包屑 =====
function onToggleSearch(): void {
showSearch.value = !showSearch.value;
}
function onToggleQuick(): void {
showQuick.value = !showQuick.value;
}
async function onBack(): Promise<void> {
// 返回箭头只在子目录态出现:退一层目录;根目录走页面退出兜底
if (await back()) {
keyword.value = "";
selectedIds.value = [];
return;
}
// tab 页 reLaunch 进来的没有返回栈,回不去就回首页
if (getCurrentPages().length > 1) {
uni.navigateBack();
return;
}
uni.reLaunch({ url: pageUrl("dashboard") });
}
/** 点面包屑某一段:截断到该层并重查(goTo 内部处理「已在该层」空点) */
async function onCrumbNav(index: number): Promise<void> {
if (index >= pathStack.value.length - 1) {
return;
}
await goTo(index);
keyword.value = "";
selectedIds.value = [];
}
const sortLabels = computed(() => [
t("FilePage.sortTimeDesc"),
t("FilePage.sortTimeAsc"),
t("FilePage.sortName"),
]);
function onSort(): void {
sortMode.value = (sortMode.value + 1) % 3;
toast(sortLabels.value[sortMode.value] ?? "");
}
// ===== 搜索 / 快捷入口 =====
function onSearch(value: string): void {
search(value);
}
/** 展览/播放三入口:以当前目录为上下文,mode=list + key=目录路径跳对应播放器页 */
function goShowWithFolder(name: "photoShow" | "audioHall" | "videoHall"): void {
const folder = pathStack.value[pathStack.value.length - 1]?.value ?? "";
uni.navigateTo({
url: `${pageUrl(name)}?mode=list&key=${encodeURIComponent(folder)}`,
fail: (err: any) => toast(err?.errMsg || t("FilePage.actionFailed")),
});
}
/** 单击音频/视频:进音乐厅/影视厅,start 带 FileId 让厅内定位到该条并开播 */
function openInHall(file: FileItem): void {
const folder = pathStack.value[pathStack.value.length - 1]?.value ?? "";
const hall = file.kind === "audio" ? "audioHall" : "videoHall";
uni.navigateTo({
url: `${pageUrl(hall)}?mode=list&key=${encodeURIComponent(folder)}&start=${file.id}`,
fail: (err: any) => toast(err?.errMsg || t("FilePage.actionFailed")),
});
}
function onQuickClick(key: string): void {
// 回收站/共享大厅(pages/trash、pages/share-hall);相册/音乐厅/影视厅展览当前目录
if (key === "trash") {
uni.navigateTo({ url: pageUrl("trash") });
return;
}
if (key === "shareCenter") {
uni.navigateTo({ url: pageUrl("shareHall") });
return;
}
if (key === "album") {
goShowWithFolder("photoShow");
return;
}
if (key === "musicHall") {
goShowWithFolder("audioHall");
return;
}
if (key === "videoHall") {
goShowWithFolder("videoHall");
return;
}
const entry = quickEntries.value.find(e => e.key === key);
toast(entry ? `${entry.label} · ${t("FilePage.todoTip")}` : t("FilePage.todoTip"));
}
// ===== 分类 / 视图 / 多选 =====
function onCategoryChange(key: FileCategory): void {
category.value = key;
selectedIds.value = [];
}
function onToggleView(): void {
viewMode.value = viewMode.value === "grid" ? "list" : "grid";
}
function onToggleSelect(): void {
selectMode.value = !selectMode.value;
if (!selectMode.value) {
selectedIds.value = [];
}
}
/** 全选:勾满当前过滤后可见的全部条目(被分类/关键字挡住的看不见就不选) */
function onSelectAll(): void {
selectedIds.value = visibleFiles.value.map(f => f.id);
}
/** 取消全选:只清勾选,不退出多选模式 */
function onClearSelect(): void {
selectedIds.value = [];
}
// ===== 多选底部操作栏(常驻非模态,不挡继续勾选)=====
// 动作集照旧系统 OperatorStation 的多选子集:共享/发布要求无目录且同媒体类型,
// 删除恒可用;重命名/移动在旧系统就是 isSingleItem 专属,多选不显示。
interface MultiAction {
key: string;
label: string;
icon: string;
danger?: boolean;
}
/** 选中条目从全量 files 取:切换分类后被挡出可见集的已选项也要参与判定 */
const selectedFiles = computed(() => files.value.filter(f => selectedIds.value.includes(f.id)));
/** 多选时是否全部为同一媒体类型(图片/视频/音频),文档与目录不算 */
const isSameMediaType = computed(() => {
const kinds = selectedFiles.value.map(f => f.kind);
if (kinds.length < 2) {
return false;
}
const [first] = kinds;
return (first === "image" || first === "video" || first === "audio") && kinds.every(k => k === first);
});
/** 旧系统共享/发布条件:!isDirectorySelected && (isSingleItem || isSameMediaTypeSelected) */
const canSharePublish = computed(() => {
const picked = selectedFiles.value;
if (!picked.length || picked.some(f => f.kind === "folder")) {
return false;
}
return picked.length === 1 || isSameMediaType.value;
});
/** 选中集的统一媒体类型(动态命名用):类型不一致或含目录时为 null */
const pickedKind = computed<FileKind | null>(() => {
const picked = selectedFiles.value;
if (!picked.length) {
return null;
}
const [first] = picked;
return picked.every(f => f.kind === first.kind) ? first.kind : null;
});
/** 旧系统 publishTooltip 简化口径:发布只服务媒体类型,统一「发布专辑」(按钮不超 4 字) */
const publishLabel = computed(() => {
const kind = pickedKind.value;
return kind === "image" || kind === "video" || kind === "audio"
? t("FilePage.actionPublishAlbum")
: t("FilePage.actionPublish");
});
/** 旧系统 previewTooltip 简化口径:图片=相册预览、视频=视频播放、音频=音乐播放(均不超 4 字) */
const previewLabel = computed(() => {
switch (pickedKind.value) {
case "image": return t("FilePage.actionAlbumShow");
case "video": return t("FilePage.actionVideoPlay");
case "audio": return t("FilePage.actionAudioPlay");
default: return t("FilePage.actionPreview");
}
});
const previewIcon = computed(() => {
if (pickedKind.value === "video") {
return "play-circle-fill";
}
return pickedKind.value === "audio" ? "volume" : "eye";
});
/** 预览按钮的分流动作 key:视频/音频进音乐厅/影视厅(hall,与单击条目同一条路径),
* 图片走相册连览(preview);文档没有移动端预览器,不显示该按钮 */
const previewKey = computed(() => {
const kind = pickedKind.value;
if (kind === "video" || kind === "audio") {
return "hall";
}
return kind === "image" ? "preview" : "";
});
/** 选中含文件即可下载(旧系统 onDownloadSelected 就是滤掉目录逐个循环下载) */
const hasFileSelected = computed(() => selectedFiles.value.some(f => f.kind !== "folder"));
/** 排1:下载(含文件即有)+ 共享/发布(同媒体类型且无目录,文档无发布功能不显示)+ 删除恒在 */
const multiActionsTop = computed<MultiAction[]>(() => {
const actions: MultiAction[] = [];
if (hasFileSelected.value) {
actions.push({ key: "download", label: t("FilePage.actionDownload"), icon: "download" });
}
if (canSharePublish.value) {
actions.push({ key: "share", label: t("FilePage.actionShareShort"), icon: "share-fill" });
// 发布仅图片/视频/音频(单选文档时共享仍在,发布不显示)
if (pickedKind.value !== "document") {
actions.push({ key: "publish", label: publishLabel.value, icon: "volume-fill" });
}
}
actions.push({ key: "delete", label: t("FilePage.actionDelete"), icon: "trash", danger: true });
return actions;
});
/** 排2:预览——无目录且单选或同媒体类型(多图多选即相册连览);重命名仅单选非目录;
* 移动有选即在(目录/混选都能整批挪);取消恒在 */
const multiActionsBottom = computed<MultiAction[]>(() => {
const actions: MultiAction[] = [];
const picked = selectedFiles.value;
if (picked.length) {
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" });
}
actions.push({ key: "move", label: t("FilePage.actionMove"), icon: "arrow-right-double" });
}
actions.push({ key: "cancel", label: t("FilePage.actionCancel"), icon: "close" });
return actions;
});
/** 多选动作:取消退出多选,其余与单选面板同一入口(runAction 按 key 分流) */
function onMultiAction(key: string): void {
if (key === "cancel") {
onToggleSelect();
return;
}
runAction(key, selectedFiles.value);
}
function onToggleSelectItem(id: number): void {
const index = selectedIds.value.indexOf(id);
if (index >= 0) {
selectedIds.value.splice(index, 1);
return;
}
selectedIds.value.push(id);
}
// ===== 列表条目 / 悬浮按钮 =====
async function onOpen(file: FileItem): Promise<void> {
// 文件夹下钻走真实接口;图片连览、音视频进厅播放,文档与右侧「…」同口径弹出操作面板
if (await openFolder(file)) {
keyword.value = "";
selectedIds.value = [];
return;
}
if (file.kind === "image") {
// 连览当前可见列表的全部图片(按列表排序),从点击这张开始,左右滑动切前后一张
actionPreview(visibleFiles.value.filter(f => f.kind === "image"), file);
}
else if (file.kind === "video" || file.kind === "audio") {
// 单击即进音乐厅/影视厅(以当前目录为播放列表),从这一条开始播;
// 「…」面板里的试听/播放仍走单文件播放页(actionPlay)
openInHall(file);
}
else {
// 文档没有移动端预览器:与右侧「…」一致弹出操作面板(下载/重命名/移动/删除等由用户选),
// 不再单击即静默下载
onItemMore(file);
}
}
// 「…」单选操作面板:记目标条目即展开(面板内动作按 kind 派生,见 file-action-sheet)
const actionFile = ref<FileItem | null>(null);
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<void>, tip?: string): Promise<void> {
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<boolean> {
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<void> {
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<FileItem | null>(null);
function actionRename(targets: FileItem[]): void {
const [first] = targets;
if (!first) {
return;
}
renameTarget.value = first;
renameVisible.value = true;
}
async function onRenameConfirm(name: string): Promise<void> {
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<FileItem[]>([]);
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<void> {
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<void> {
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));
}
}
// #ifdef APP-PLUS
toast(t("FilePage.downloadDoneApp"));
// #endif
// #ifndef APP-PLUS
toast(t("FilePage.downloadDone"));
// #endif
}, t("FilePage.downloading"));
}
// ----- 图片查看器(缩略图秒开 + 按需原图,见 components/image-viewer)-----
const viewerVisible = ref(false);
const viewerItems = ref<ImageViewerItem[]>([]);
/** 打开时定位的起始图(连览时从点击那张开始) */
const viewerStartIndex = ref(0);
/** 图片预览:单选连览当前列表全部图片、多选只连览选中的图片。
* 缩略图先行——thumbUrl 列表已加载过(甚至命中 HTTP 缓存),组装进查看器秒开;
* 原图按需拉取(查看器底部「查看原图」触发 loadFull,走分片拼整 + 落盘旧通道)。
* 替代旧 uni.previewImage 链路:它要先串行拉全部原图字节落盘才弹窗,慢的根因在此。
* start 传了就从那张开始,否则从第一张开始 */
function actionPreview(targets: FileItem[], start?: FileItem): void {
const images = targets.filter(f => f.kind === "image");
if (!images.length) {
toast(t("FilePage.previewNoImage"));
return;
}
viewerStartIndex.value = start ? Math.max(0, images.findIndex(f => f.id === start.id)) : 0;
viewerItems.value = images.map((file) => {
const item: ImageViewerItem = {
name: file.name,
// 列表宫格同款 Scale 缩略图(后端拼好的直链);老文件无缩略图时留空,查看器会自动拉原图兜底
thumb: file.thumbUrl || "",
loadFull: async () => {
const record = recordOf(file);
if (!record) {
throw new Error(t("FilePage.actionFailed"));
}
// App 端 WebView 不吃 file:// 本地文件,高清图走 data: URI(toImageUrl 内部分流)
return toImageUrl(await fetchFileBytes(record));
},
};
return item;
});
viewerVisible.value = true;
}
/** 视频/音频播放:视频有直链 Url 直接进播放页,否则字节拼整后本地播(H5 blob: 地址经 query 传递) */
async function actionPlay(target: FileItem): Promise<void> {
const record = recordOf(target);
if (!record) {
return;
}
if (target.kind === "video" && record.Url) {
// 后端 Url 按请求 Host 拼绝对地址,UAT 反代未传原始 Host 时会拼成内网 IP,H5 下不可达:先过 absoluteFileUrl 归一
goPlay("video", target.name, absoluteFileUrl(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"));
},
});
}
// ----- 共享到群组目录:共享大厅式抽屉选目标目录(群组卡→逐级下钻→共享到此),
// 多选文件一次批量建(CreateFamilyGroupFolder 数组入参,旧 handleSelectShare 同口径)-----
const shareVisible = ref(false);
const shareTargets = ref<FileItem[]>([]);
function actionShare(targets: FileItem[]): void {
const picked = targets.filter(f => f.kind !== "folder");
if (!picked.length) {
toast(t("FilePage.shareNoFile"));
return;
}
shareTargets.value = picked;
shareVisible.value = true;
}
async function onShareConfirm(target: ShareTarget): Promise<void> {
const filesToShare = shareTargets.value;
shareVisible.value = false;
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.filePath;
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<PickerOption[]>([]);
const publishTargets = ref<FileItem[]>([]);
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");
});
/** 确认键按类型叫「发布相册/发布音乐专辑/发布视频专辑」,不再笼统叫「创建」 */
const publishConfirmText = computed(() => {
if (publishKind.value === "video") {
return t("FilePage.publishConfirmVideo");
}
return publishKind.value === "audio" ? t("FilePage.publishConfirmAudio") : t("FilePage.publishConfirmPhoto");
});
/** 附加「新建」行文案:与确认键同类型口径 */
const publishCreateText = computed(() => {
if (publishKind.value === "video") {
return t("FilePage.publishCreateVideo");
}
return publishKind.value === "audio" ? t("FilePage.publishCreateAudio") : t("FilePage.publishCreatePhoto");
});
/** 专辑行首图标:与媒体页分段图标同口径 */
const publishRowIcon = computed(() =>
publishKind.value === "video" ? "play-circle-fill" : publishKind.value === "audio" ? "volume" : "photo",
);
/** 「新建相册」行:关弹层 → 登记创建意图 → 切到媒体 tab(onShow 消费后进创建态) */
function goCreateAlbum(): void {
publishVisible.value = false;
const kindMap: Record<"image" | "video" | "audio", "photo" | "video" | "audio"> = {
image: "photo",
video: "video",
audio: "audio",
};
mediaIntent.requestCreate(kindMap[publishKind.value]);
// 项目无原生 tabBar(自定义 app-tabbar),switchTab 会报 can not switch to no-tabBar page,
// 切 tab 与 app-tabbar.onChange 同套路用 reLaunch;意图由媒体页首显回调消费
uni.reLaunch({ url: pageUrl("mediaTab") });
}
async function actionPublish(targets: FileItem[]): Promise<void> {
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 {
// 拉到的专辑行含名称(PublishLocalName)/描述(Notice)/编号(PublishName),不能只留编号
let rows: Array<{ PublishName?: string; PublishLocalName?: string; Notice?: 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);
rows = data?.Publishs ?? [];
}
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);
rows = data?.Datas ?? [];
}
else {
const criteria = new PublishAudioSearchCriteria();
criteria.Filter = [];
criteria.limit = 999;
criteria.page = 1;
criteria.GridColumns = [];
const data = await publishAudioService.FindBySearchCriteria(criteria);
rows = data?.Datas ?? [];
}
// 主行显示专辑名称(无名称回落编号),副行显示描述 + 编号;key 仍用编号供 onPublishConfirm 发布
publishItems.value = rows
.filter(p => p?.PublishName)
.map((p) => {
const code = p.PublishName || "";
const hasName = !!p.PublishLocalName;
const label = p.PublishLocalName || code;
const parts: string[] = [];
if (p.Notice) {
parts.push(p.Notice);
}
if (hasName) {
parts.push(code);
}
return { key: code, label, sub: parts.join(" · "), icon: publishRowIcon.value };
});
}
catch (error: any) {
toast(error?.message || t("FilePage.actionFailed"));
}
finally {
publishLoading.value = false;
}
}
async function onPublishConfirm(name: string): Promise<void> {
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<string, (targets: FileItem[]) => void> = {
download: actionDownload,
delete: actionDelete,
rename: actionRename,
move: actionMove,
share: actionShare,
publish: actionPublish,
preview: actionPreview,
// 多选栏的「视频播放/音乐播放」:与单击条目一致,进影视厅/音乐厅并从该条开播;
// 面板(file-action-sheet)的同名动作仍走 play 单文件播放页
hall: targets => targets[0] && openInHall(targets[0]),
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;
runAction(key, [file]);
}
// 新建文件夹命名对话框(CreateDmsFolder 在途标记防连点)
const folderDialogVisible = ref(false);
const folderSubmitting = ref(false);
// 上传队列状态见文件头部的 uploadQueue store
/** 悬浮「+」:新建文件夹已拆到工具栏图标,这里直接选文件上传 */
function onAdd(): void {
pickAndUpload();
}
/** 工具栏「文件夹+」图标:弹新建文件夹命名对话框 */
function onNewFolder(): void {
folderDialogVisible.value = true;
}
/** 名称限制对齐后端目录模型:不能含路径分隔符(FolderLocalName 直接参与路径拼接) */
async function onCreateFolder(name: string): Promise<void> {
if (/[\\/]/.test(name)) {
toast(t("FilePage.folderInvalid"));
return;
}
folderSubmitting.value = true;
try {
const entity = new DmsFileEntity();
entity.FileName = name;
entity.FilePath = pathStack.value[pathStack.value.length - 1]?.value ?? "";
await dmsFileService.CreateDmsFolder(entity);
folderDialogVisible.value = false;
toast(t("FilePage.folderCreated"));
// 新建完直接刷当前目录,不等切回页面才重查
retry();
}
catch (error: any) {
// 业务异常主拦截器已弹模态,这里只兜网络层失败
if (error?.errMsg) {
toast(error.errMsg);
}
}
finally {
folderSubmitting.value = false;
}
}
/** 选文件→入队(快照面包屑链供传输页展示/跳回)→跳传输列表;之后用户跳走不影响上传 */
async function pickAndUpload(): Promise<void> {
try {
const picked = await chooseFiles();
if (!picked.length) {
return;
}
uploadQueue.addFiles(picked, [...pathStack.value]);
uni.navigateTo({ url: pageUrl("transport") });
}
catch (error: any) {
toast(error?.errMsg || error?.message || t("FilePage.todoTip"));
}
}
function goTransport(): void {
uni.navigateTo({ url: pageUrl("transport") });
}
/** 胶囊内的自绘传输字形(白字蓝底,颜色固定,模块级生成一次) */
const transferIconWhite = transferGlyph("#ffffff");
</script>
<template>
<!-- 弹层(操作面板/抽屉/对话框)或多选态展开时收起 tabbar:tabbar 在页面 fixed 层叠上下文之外,
弹层 z-index 再高也压不住它,直接隐藏最干净(主流云盘同款交互) -->
<app-page :hide-nav="true"
:show-tabbar="atRoot && !actionFile && !selectMode && !folderDialogVisible && !renameVisible && !moveVisible && !shareVisible && !publishVisible && !viewerVisible">
<view class="file-page" :style="{ background: themeGlowBackground }">
<!-- 上半屏整体固定,只有文件列表所在的 scroll-view 内部滚动(整页不滚) -->
<view class="file-page__head">
<page-navbar :title="navTitle" :show-back="!atRoot" :search-on="showSearch" :quick-on="showQuick"
:transferring="uploadQueue.uploadingCount" @back="onBack" @toggle-search="onToggleSearch"
@toggle-quick="onToggleQuick" @transfer="goTransport" />
<!-- 子目录态才有的路径条:白色衬底包住面包屑(外面不再是灰页面底),左起全量显示,溢出折叠 -->
<view v-if="!atRoot" class="file-page__crumb">
<file-breadcrumb :items="pathStack" @nav="onCrumbNav" />
</view>
<!-- 搜索框与快捷入口由 navbar 右侧图标切换显隐(进子目录默认隐藏);
快捷入口外层包装负责卡片定位(scroll-view 自身不能带左右 margin) -->
<search-input v-if="showSearch" v-model="keyword" :placeholder="t('FilePage.searchPlaceholder')"
@search="onSearch" />
<view v-if="showQuick" class="file-page__quick">
<quick-entry :items="quickEntries" @click="onQuickClick" />
</view>
<file-tab-filter :tabs="tabs" :active="category" :view-mode="viewMode" :select-mode="selectMode"
@change="onCategoryChange" @sort="onSort" @toggle-view="onToggleView" @toggle-select="onToggleSelect"
@new-folder="onNewFolder" />
<view v-if="selectMode" class="file-page__selection">
<text class="file-page__selection-text">
{{ t("FilePage.selectedCount", { count: selectedIds.length }) }}
</text>
<view class="file-page__selection-actions">
<view class="file-page__selection-btn file-page__selection-btn--primary" @click="onSelectAll">
<u-icon name="checkmark-circle-fill" size="28" color="#2b8fff" />
<text class="file-page__selection-btn-text">
{{ t("FilePage.selectAll") }}
</text>
</view>
<view class="file-page__selection-btn" @click="onClearSelect">
<u-icon name="minus-circle-fill" size="28" color="#5c626b" />
<text class="file-page__selection-btn-text">
{{ t("FilePage.unselectAll") }}
</text>
</view>
</view>
</view>
</view>
<!-- 滚动窗口外层包装:margin/圆角/白底/裁剪放这层(scroll-view 根节点固定
100% 宽,自带左右 margin 会右侧溢出被裁,与 quick-entry 同坑实测过) -->
<view class="file-page__scroll-wrap">
<scroll-view scroll-y class="file-page__scroll">
<!-- 文件列表内容容器:卡片外观由 wrap 层负责 -->
<view class="file-page__card">
<view v-if="loading" class="file-page__state">
<text class="file-page__state-text">
{{ t("FilePage.loading") }}
</text>
</view>
<view v-else-if="errorText" class="file-page__state">
<text class="file-page__state-text file-page__state-text--error">
{{ errorText }}
</text>
<text class="file-page__retry" @click="retry()">
{{ t("FilePage.retryBut") }}
</text>
</view>
<file-grid-list v-else :files="visibleFiles" :view-mode="viewMode" :select-mode="selectMode"
:selected-ids="selectedIds" :empty-text="t('FilePage.emptyText')" @open="onOpen" @more="onItemMore"
@toggle-select="onToggleSelectItem" />
</view>
<!-- 列表尾部让位悬浮 tabbar;子目录态 tabbar 隐藏,占位相应收小 -->
<view class="file-page__tabbar-space" :class="{ 'file-page__tabbar-space--slim': !atRoot }" />
</scroll-view>
</view>
<!-- 多选态加号让位底部操作栏,避免两个悬浮物打架 -->
<float-add-btn v-if="!selectMode" @click="onAdd" />
<!-- 上传中悬浮胶囊:任意目录下一键回传输列表(任务在 store 里,跳走不中断) -->
<view v-if="uploadQueue.uploadingCount && !selectMode" class="file-page__transfer" @click="goTransport">
<image class="file-page__transfer-img" :src="transferIconWhite" mode="aspectFit" />
<text class="file-page__transfer-text">
{{ uploadQueue.uploadingCount }} {{ t("TransportPage.transferring") }}
</text>
</view>
<file-action-sheet :visible="!!actionFile" :file="actionFile" @close="actionFile = null"
@action="onSheetAction" />
<!-- 图片查看器:缩略图秒开连览,底部「查看原图」按需拉原图(全屏遮罩,打开时收起 tabbar) -->
<image-viewer :visible="viewerVisible" :items="viewerItems" :start-index="viewerStartIndex"
@close="viewerVisible = false" />
<folder-name-dialog :visible="folderDialogVisible" :submitting="folderSubmitting"
@close="folderDialogVisible = false" @confirm="onCreateFolder" />
<!-- 重命名:同一个命名对话框的第二个实例(预填当前名 + 重命名文案) -->
<folder-name-dialog :visible="renameVisible" :submitting="renameSubmitting" :title="t('FilePage.renameTitle')"
:confirm-label="t('FilePage.renameConfirm')" :initial-value="renameTarget?.name ?? ''"
@close="renameVisible = false" @confirm="onRenameConfirm" />
<!-- 移动:网盘式逐级下钻选择器(进层浏览,「移动到此」提交当前层后直接调移动接口) -->
<folder-move-picker :visible="moveVisible" :exclude-name="moveExcludeName" :source-path="moveSourcePath"
:selected-count="moveTargets.length" @close="moveVisible = false" @confirm="onMovePick" />
<!-- 共享到群组目录:共享大厅式抽屉(群组卡→逐级下钻→「共享到此」),多选文件一次批量共享 -->
<folder-share-picker :visible="shareVisible" :selected-count="shareTargets.length" @close="shareVisible = false"
@confirm="onShareConfirm" />
<!-- 发布:选专辑(确认键按类型叫「发布相册」等;附「新建相册」行跳媒体页创建态) -->
<option-picker :visible="publishVisible" :title="publishTitle" :items="publishItems" :loading="publishLoading"
:empty-text="t('FilePage.pickerEmpty')" :confirm-text="publishConfirmText" :create-text="publishCreateText"
@close="publishVisible = false" @create="goCreateAlbum" @confirm="onPublishConfirm" />
<!-- 多选底部操作栏:常驻非模态(无遮罩),勾选与动作可交替进行;
动作集是旧系统多选子集——共享/发布按同媒体类型条件,重命名/移动仅单选 -->
<view v-if="selectMode" class="file-page__actions">
<view class="file-page__actions-row">
<view v-for="action in multiActionsTop" :key="action.key" class="file-page__action-btn"
:class="{ 'file-page__action-btn--danger': action.danger }" @click="onMultiAction(action.key)">
<u-icon :name="action.icon" size="32" :color="action.danger ? '#f0413e' : '#1f2329'" />
<text class="file-page__action-text">
{{ action.label }}
</text>
</view>
</view>
<view class="file-page__actions-row">
<view v-for="action in multiActionsBottom" :key="action.key" class="file-page__action-btn"
@click="onMultiAction(action.key)">
<u-icon :name="action.icon" size="32" color="#1f2329" />
<text class="file-page__action-text">
{{ action.label }}
</text>
</view>
</view>
</view>
</view>
</app-page>
</template>
<style lang="scss" scoped>
.file-page {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
flex-direction: column;
overflow: hidden;
/* 渐变底由 script 的 pageBackground 按主题 primary 动态计算(内联 style 下发) */
}
.file-page__head {
flex-shrink: 0;
}
/* 面包屑分区:透明衬底露灰页面底,与其余卡片同节奏的间隔(面包屑自身就是白卡片) */
.file-page__crumb {
padding: 20rpx 24rpx 0;
}
/* 快捷入口卡片定位(组件内 scroll-view 不能自带左右 margin,挪到这层) */
.file-page__quick {
margin: 20rpx 24rpx 0;
}
/* 滚动窗口外层包装=卡片本体:margin/圆角/白底/裁剪在这层,
左右 20rpx 间隔对称(scroll-view 自身带 margin 会右侧溢出被裁) */
.file-page__scroll-wrap {
flex: 1;
min-height: 0;
margin: 20rpx 20rpx 0;
border-radius: 32rpx 32rpx 0 0;
background-color: #ffffff;
overflow: hidden;
}
/* scroll-view 只当纯滚动容器垫满 wrap,滚动内容被 wrap 的圆角窗口裁剪 */
.file-page__scroll {
height: 100%;
}
.file-grid-list--list[data-v-2dfb0235] {
padding: 0.15rem 0.25rem 0.15rem !important;
}
.file-page__tabbar-space {
height: 140rpx;
&--slim {
height: 40rpx;
}
}
/* 上传中悬浮胶囊:左下角常驻(避开右侧可拖动加号),点击回传输列表 */
.file-page__transfer {
position: fixed;
bottom: 220rpx;
left: 24rpx;
z-index: 8;
display: flex;
align-items: center;
gap: 10rpx;
padding: 16rpx 28rpx;
border-radius: 999rpx;
background-color: rgba(41, 121, 255, 0.9);
box-shadow: 0 6rpx 16rpx rgba(41, 121, 255, 0.35);
}
.file-page__transfer-text {
font-size: 24rpx;
color: #ffffff;
}
/* 胶囊内自绘传输字形:与原文案图标同尺寸(26rpx) */
.file-page__transfer-img {
width: 26rpx;
height: 26rpx;
}
.file-page__selection {
display: flex;
align-items: center;
justify-content: space-between;
margin: 20rpx 24rpx 0;
padding: 16rpx 32rpx;
border-radius: 24rpx;
background-color: #ffffff;
}
.file-page__selection-text {
font-size: 24rpx;
color: #2b8fff;
}
/* 全选 / 取消全选:贴右并排的图标胶囊按钮 */
.file-page__selection-actions {
display: flex;
align-items: center;
gap: 20rpx;
}
.file-page__selection-btn {
display: flex;
align-items: center;
gap: 8rpx;
padding: 10rpx 24rpx;
border: 2rpx solid #e6e7ea;
border-radius: 999rpx;
background-color: #f7f8fa;
&--primary {
border-color: #bcdcff;
background-color: #eaf4ff;
}
}
.file-page__selection-btn-text {
font-size: 24rpx;
color: #5c626b;
.file-page__selection-btn--primary & {
color: #2b8fff;
}
}
/* 多选底部操作栏:贴底常驻白底板,两排等分胶囊按钮 */
.file-page__actions {
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 9;
display: flex;
flex-direction: column;
gap: 16rpx;
padding: 24rpx 24rpx calc(24rpx + env(safe-area-inset-bottom));
border-radius: 32rpx 32rpx 0 0;
background-color: #ffffff;
box-shadow: 0 -4rpx 24rpx rgba(31, 35, 41, 0.06);
}
.file-page__actions-row {
display: flex;
gap: 16rpx;
}
.file-page__action-btn {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
gap: 10rpx;
padding: 22rpx 0;
border-radius: 999rpx;
background-color: #f5f6f8;
&--danger {
background-color: #fdecec;
}
}
.file-page__action-text {
font-size: 26rpx;
color: #1f2329;
.file-page__action-btn--danger & {
color: #f0413e;
}
}
.file-page__state {
display: flex;
align-items: center;
justify-content: center;
gap: 24rpx;
padding: 64rpx 32rpx;
}
.file-page__state-text {
font-size: 26rpx;
color: #9aa0a6;
}
.file-page__state-text--error {
color: #f0413e;
}
.file-page__retry {
padding: 8rpx 28rpx;
border-radius: 999rpx;
font-size: 26rpx;
color: #2b8fff;
background-color: #eaf4ff;
}
</style>