1278 lines
47 KiB
Vue
1278 lines
47 KiB
Vue
<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 { 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" });
|
||
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> {
|
||
// 文件夹下钻走真实接口;文件点开即预览/播放(旧系统 onViewPic 同款分流)
|
||
if (await openFolder(file)) {
|
||
keyword.value = "";
|
||
selectedIds.value = [];
|
||
return;
|
||
}
|
||
if (file.kind === "image") {
|
||
actionPreview([file]);
|
||
}
|
||
else if (file.kind === "video" || file.kind === "audio") {
|
||
// 单击即进音乐厅/影视厅(以当前目录为播放列表),从这一条开始播;
|
||
// 「…」面板里的试听/播放仍走单文件播放页(actionPlay)
|
||
openInHall(file);
|
||
}
|
||
else {
|
||
// 文档没有移动端预览器,点开按下载处理(比占位 toast 实用)
|
||
actionDownload([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[]>([]);
|
||
|
||
/** 图片预览:单选一张、多选多图相册连览。
|
||
* 缩略图先行——thumbUrl 列表已加载过(甚至命中 HTTP 缓存),组装进查看器秒开;
|
||
* 原图按需拉取(查看器底部「查看原图」触发 loadFull,走分片拼整 + 落盘旧通道)。
|
||
* 替代旧 uni.previewImage 链路:它要先串行拉全部原图字节落盘才弹窗,慢的根因在此 */
|
||
function actionPreview(targets: FileItem[]): void {
|
||
const images = targets.filter(f => f.kind === "image");
|
||
if (!images.length) {
|
||
toast(t("FilePage.previewNoImage"));
|
||
return;
|
||
}
|
||
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) {
|
||
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"));
|
||
},
|
||
});
|
||
}
|
||
|
||
// ----- 共享家人:共享大厅式抽屉选目标目录(群组卡→逐级下钻→共享到此),
|
||
// 多选文件一次批量建(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" @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>
|