This commit is contained in:
owenchen committed 2026-10-14 21:23:40 +08:00
1 parent e740ec77ec
commit ceb3e7beec
20 files changed
+2280 -138

No files matched your search

@@ -0,0 +1,186 @@
<script setup lang="ts">
/**
* 媒体播放页:文件页「视频播放 / 音乐播放」动作打开(旧系统 play-video / play-audio 的移动端对应)。
* 入参:主通道是播放上下文总线(utils/playbackBus,type/name/url);query 只带
* type+name 做直连访问兜底(历史形态的 url 参数仍可识读)。
* 视频用原生 <video> 控件;音频用 innerAudioContext(uni 全端 API,不依赖 H5 <audio>)自绘卡片。
* 离开页面销毁播放器并 revoke blob 地址,防内存泄漏。
*/
import { onLoad, onUnload } from "@dcloudio/uni-app";
import { useLocale } from "uview-pro";
import { ref } from "vue";
import { takePlayback } from "@/utils/playbackBus";
const { t } = useLocale();
const kind = ref<"video" | "audio">("video");
const name = ref("");
const url = ref("");
// ===== 音频自绘播放态(innerAudioContext 驱动)=====
const playing = ref(false);
const position = ref(0);
const duration = ref(0);
let audioCtx: UniApp.InnerAudioContext | null = null;
/** 秒 -> m:ss 展示文案 */
function fmtTime(seconds: number): string {
const s = Math.max(0, Math.floor(seconds));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
function setupAudio(): void {
audioCtx = uni.createInnerAudioContext();
audioCtx.src = url.value;
audioCtx.onPlay(() => {
playing.value = true;
});
audioCtx.onPause(() => {
playing.value = false;
});
audioCtx.onEnded(() => {
playing.value = false;
position.value = 0;
});
audioCtx.onTimeUpdate(() => {
position.value = audioCtx?.currentTime ?? 0;
duration.value = audioCtx?.duration ?? 0;
});
}
function onTogglePlay(): void {
if (!audioCtx) {
return;
}
if (playing.value) {
audioCtx.pause();
return;
}
audioCtx.play();
}
/** 进度条点击拖动:innerAudioContext.seek 秒位 */
function onSeek(event: { detail: { value: number } }): void {
audioCtx?.seek(event.detail.value);
}
onLoad((query) => {
// 总线优先;直连访问(手工输地址/分享链接)退回 query 解析
const ctx = takePlayback();
kind.value = ctx?.type ?? (query?.type === "audio" ? "audio" : "video");
name.value = ctx?.name || decodeURIComponent(query?.name || "");
url.value = ctx?.url || decodeURIComponent(query?.url || "");
if (kind.value === "audio" && url.value) {
setupAudio();
}
});
onUnload(() => {
audioCtx?.destroy();
audioCtx = null;
// #ifdef H5
if (url.value.startsWith("blob:")) {
URL.revokeObjectURL(url.value);
}
// #endif
});
</script>
<template>
<app-page :nav-title="name || t('FilePage.playTitle')">
<view class="media-play">
<!-- 视频:原生播放器全控件(投屏/全屏都交给组件默认能力) -->
<video v-if="kind === 'video'" class="media-play__video" :src="url" controls object-fit="contain" />
<!-- 音频:封面卡片 + 进度 + 播放开关 -->
<view v-else class="media-play__audio">
<view class="media-play__disc">
<u-icon name="volume-fill" size="96" color="#ffffff" />
</view>
<text class="media-play__name">
{{ name }}
</text>
<slider class="media-play__slider" :value="position" :min="0" :max="duration || 1" :block-size="20"
active-color="#2b8fff" @change="onSeek" />
<view class="media-play__time">
<text class="media-play__time-text">
{{ fmtTime(position) }}
</text>
<text class="media-play__time-text">
{{ fmtTime(duration) }}
</text>
</view>
<view class="media-play__toggle" @tap="onTogglePlay">
<u-icon :name="playing ? 'pause-circle-fill' : 'play-circle-fill'" size="120" color="#2b8fff" />
</view>
</view>
</view>
</app-page>
</template>
<style lang="scss" scoped>
.media-play {
display: flex;
min-height: calc(100vh - var(--window-top, 0px) - 88rpx);
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32rpx;
&__video {
width: 100%;
border-radius: 16rpx;
background-color: #000000;
}
&__audio {
display: flex;
align-items: center;
width: 100%;
flex-direction: column;
gap: 36rpx;
}
/* 黑胶质感圆盘:渐变底 + 大音量字形 */
&__disc {
display: flex;
width: 280rpx;
height: 280rpx;
align-items: center;
justify-content: center;
border-radius: 50%;
background: linear-gradient(135deg, #6ec2ff 0%, #2b8fff 100%);
box-shadow: 0 16rpx 48rpx rgba(43, 143, 255, 0.35);
}
&__name {
max-width: 90%;
overflow: hidden;
font-size: 32rpx;
font-weight: 600;
color: #1f2329;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
&__slider {
width: 80%;
}
&__time {
display: flex;
width: 80%;
justify-content: space-between;
}
&__time-text {
font-size: 22rpx;
color: #8f959e;
}
&__toggle {
display: flex;
padding: 8rpx;
}
}
</style>
+490 -11
View File
@@ -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<MultiAction[]>(() => {
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<MultiAction[]>(() => {
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<void> {
// 文件夹下钻走真实接口;打开文件(预览/播放)尚未实现,先占位
// 文件夹下钻走真实接口;文件点开即预览/播放(旧系统 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<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));
}
}
toast(t("FilePage.downloadDone"));
}, t("FilePage.downloading"));
}
/** 图片预览:单选一张、多选多图相册连览。
* 走原图拼整通道(旧 MyComputer @preview-image → GetOrgPictureFile 就是分片拼整);
* DownloadFilePreview 是旧缩略图通道,依赖上传时生成的 scale 目录,本地库没有缩略图只会 400 */
async function actionPreview(targets: FileItem[]): Promise<void> {
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<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"));
},
});
}
// ----- 共享家人:选收藏夹里的家人目录,组装 FamilyGroupFolderEntity 批量建(旧 handleSelectShare)-----
const shareVisible = ref(false);
const shareLoading = ref(false);
const shareItems = ref<PickerOption[]>([]);
/** 展示用 items 之外的组装字段(FamilyName/TopFolderName 提交才需要) */
const shareFolders = ref<Array<{ key: string; familyName: string; topFolderName: string }>>([]);
const shareTargets = ref<FileItem[]>([]);
async function actionShare(targets: FileItem[]): Promise<void> {
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<void> {
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<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");
});
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 {
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<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,
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");
</script>
<template>
<!-- 操作面板/多选操作栏展开时收起 tabbar:tabbar 在页面 fixed 层叠上下文之外,
<!-- 弹层(操作面板/抽屉/对话框)或多选态展开时收起 tabbar:tabbar 在页面 fixed 层叠上下文之外,
弹层 z-index 再高也压不住它,直接隐藏最干净(主流云盘同款交互) -->
<app-page :hide-nav="true" :show-tabbar="atRoot && !actionFile && !selectMode && !folderDialogVisible">
<app-page :hide-nav="true"
:show-tabbar="atRoot && !actionFile && !selectMode && !folderDialogVisible && !renameVisible && !moveVisible && !shareVisible && !publishVisible">
<view class="file-page" :style="{ background: themeBackground }">
<!-- 上半屏整体固定,只有文件列表所在的 scroll-view 内部滚动(整页不滚) -->
<view class="file-page__head">
@@ -494,6 +959,20 @@ const transferIconWhite = transferGlyph("#ffffff");
@action="onSheetAction" />
<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" />
<!-- 共享家人:选家人目录 -->
<option-picker :visible="shareVisible" :title="t('FilePage.shareTitle')" :items="shareItems"
:loading="shareLoading" :empty-text="t('FilePage.pickerEmpty')" @close="shareVisible = false"
@confirm="onShareConfirm" />
<!-- 发布:选专辑 -->
<option-picker :visible="publishVisible" :title="publishTitle" :items="publishItems" :loading="publishLoading"
:empty-text="t('FilePage.pickerEmpty')" @close="publishVisible = false" @confirm="onPublishConfirm" />
<!-- 多选底部操作栏:常驻非模态(无遮罩),勾选与动作可交替进行;
动作集是旧系统多选子集——共享/发布按同媒体类型条件,重命名/移动仅单选 -->
<view v-if="selectMode" class="file-page__actions">
+470
View File
@@ -0,0 +1,470 @@
<script setup lang="ts">
/**
* 回收站页(短地址 /trash,从文件页快捷入口「回收站」跳入):
* 骨架与文件页同套组件(导航/搜索/分类标签/列表/多选栏),差异——
* 无上传、无新建文件夹、无快捷入口、不下钻目录;动作集按旧系统
* DmsFileDeleteEnquiry 收敛为两个:还原 RestoreDmsFile、彻底删除 DeleteLog,
* 单条点击弹系统动作面板,多选栏批量,彻底删除先过确认框。
* 数据链在 composables/useTrashFiles(FindDeleteLogBySearchCriteria -> Logs),
* 条目时间列展示的是删除时间。权限沿用 DmsFile(routes.ts),useAuthGuard 兜冷启动直达。
*/
import type { FileCategory, FileItem, FileTabItem } from "@/data/custom/fileBrowser";
import { useLocale } from "uview-pro";
import { computed, ref } from "vue";
import { useAuthGuard, useThemeGradient, useTrashFiles } from "@/composables";
import { pageUrl } from "@/router/routes";
const { t } = useLocale();
// 页面底渐变跟随主题(与文件页同一派生逻辑)
const { themeBackground } = useThemeGradient();
const { files, loading, errorText, init, load, restore, purge } = useTrashFiles();
// 每次回到页面都重查:回收站内容可能被别的端删改,列表不该留旧影
useAuthGuard(async () => {
await init();
});
// ===== 页面状态(与文件页同套:全部单向下发,组件只上报意图) =====
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);
const showSearch = ref(true);
/** 动作提交中标记:showLoading 期间防连点 */
const busy = ref(false);
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") },
]);
/** 分类/关键字本地过滤(回收站一次拉全,切标签不打接口);无目录层级概念,文件夹记录与文件同列 */
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)),
);
return [...list].sort((a, b) => {
if (sortMode.value === 2) {
return a.name.localeCompare(b.name, "zh-CN");
}
return sortMode.value === 1 ? a.sortTime - b.sortTime : b.sortTime - a.sortTime;
});
});
/** 当前勾选的条目(多选栏动作的作用对象) */
const selectedFiles = computed<FileItem[]>(() =>
visibleFiles.value.filter(f => selectedIds.value.includes(f.id)),
);
function toast(title: string): void {
uni.showToast({ title, icon: "none" });
}
/** 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),
});
});
}
/** 动作防重 + loading 包裹(与文件页 withBusy 同套路,出错统一 toast) */
async function withBusy(task: () => Promise<void>): Promise<void> {
if (busy.value) {
return;
}
busy.value = true;
uni.showLoading({ title: t("FilePage.pleaseWait"), mask: true });
try {
await task();
}
catch (error: any) {
toast(error?.message || t("FilePage.actionFailed"));
}
finally {
uni.hideLoading();
busy.value = false;
}
}
// ===== 导航 / 搜索 / 分类 =====
function onBack(): void {
// 直链直达 /trash 时没有返回栈:兜回文件页
if (getCurrentPages().length > 1) {
uni.navigateBack();
return;
}
uni.reLaunch({ url: pageUrl("fileTab") });
}
function onToggleSearch(): void {
showSearch.value = !showSearch.value;
}
/** 关键字确认键走服务端 contain 重查(输入过程中的本地过滤由 visibleFiles 完成) */
async function onSearch(): Promise<void> {
await load(keyword.value);
}
function onCategoryChange(key: FileCategory): void {
category.value = key;
selectedIds.value = [];
}
function onSort(): void {
sortMode.value = (sortMode.value + 1) % 3;
}
function onToggleView(): void {
viewMode.value = viewMode.value === "grid" ? "list" : "grid";
}
function onToggleSelect(): void {
selectMode.value = !selectMode.value;
if (!selectMode.value) {
selectedIds.value = [];
}
}
function onToggleSelectItem(id: number): void {
const index = selectedIds.value.indexOf(id);
if (index >= 0) {
selectedIds.value.splice(index, 1);
}
else {
selectedIds.value.push(id);
}
}
function onSelectAll(): void {
selectedIds.value = visibleFiles.value.map(f => f.id);
}
function onClearSelect(): void {
selectedIds.value = [];
}
// ===== 动作层(单条面板与多选栏共用) =====
/** 点条目/点⋯:系统动作面板二选一(回收站条目不做预览播放——记录已进删除态) */
function openSheet(file: FileItem): void {
uni.showActionSheet({
itemList: [t("TrashPage.restore"), t("TrashPage.purge")],
success: (res) => {
if (res.tapIndex === 0) {
doRestore([file]);
}
else {
doPurge([file]);
}
},
});
}
/** 动作完成后的收尾:清勾选、退多选态、按当前关键字重查 */
async function afterAction(): Promise<void> {
selectedIds.value = [];
selectMode.value = false;
await load(keyword.value);
}
async function doRestore(targets: FileItem[]): Promise<void> {
await withBusy(async () => {
await restore(targets.map(f => f.id));
toast(t("TrashPage.restoreDone"));
await afterAction();
});
}
async function doPurge(targets: FileItem[]): Promise<void> {
const ok = await confirmDialog(
t("TrashPage.purge"),
t("TrashPage.purgeConfirmText").replace("{count}", String(targets.length)),
);
if (!ok) {
return;
}
await withBusy(async () => {
await purge(targets.map(f => f.id));
toast(t("TrashPage.purgeDone"));
await afterAction();
});
}
/** 多选栏动作:无勾选先提示,不进 confirm/接口 */
function requireSelection(): FileItem[] | null {
if (!selectedFiles.value.length) {
toast(t("TrashPage.pickFirst"));
return null;
}
return selectedFiles.value;
}
function onMultiRestore(): void {
const targets = requireSelection();
if (targets) {
doRestore(targets);
}
}
function onMultiPurge(): void {
const targets = requireSelection();
if (targets) {
doPurge(targets);
}
}
</script>
<template>
<!-- 二级页不带 tabbar(app-page 默认关);多选栏展开时列表尾部占位加高 -->
<app-page :hide-nav="true">
<view class="trash-page" :style="{ background: themeBackground }">
<view class="trash-page__head">
<!-- 与文件页同款导航:标题固定「回收站」,返回箭头常显,传输/快捷入口开关关闭 -->
<page-navbar :title="t('TrashPage.title')" :show-back="true" :search-on="showSearch"
:show-transfer="false" :show-quick-toggle="false" @back="onBack" @toggle-search="onToggleSearch" />
<search-input v-if="showSearch" v-model="keyword" :placeholder="t('FilePage.searchPlaceholder')"
@search="onSearch" />
<!-- 分类栏复用文件页组件:新建文件夹开关关闭 -->
<file-tab-filter :tabs="tabs" :active="category" :view-mode="viewMode" :select-mode="selectMode"
:show-new-folder="false" @change="onCategoryChange" @sort="onSort" @toggle-view="onToggleView"
@toggle-select="onToggleSelect" />
<view v-if="selectMode" class="trash-page__selection">
<text class="trash-page__selection-text">
{{ t("FilePage.selectedCount", { count: selectedIds.length }) }}
</text>
<view class="trash-page__selection-actions">
<view class="trash-page__selection-btn trash-page__selection-btn--primary" @click="onSelectAll">
<u-icon name="checkmark-circle-fill" size="28" color="#2b8fff" />
<text class="trash-page__selection-btn-text">
{{ t("FilePage.selectAll") }}
</text>
</view>
<view class="trash-page__selection-btn" @click="onClearSelect">
<u-icon name="minus-circle-fill" size="28" color="#5c626b" />
<text class="trash-page__selection-btn-text">
{{ t("FilePage.unselectAll") }}
</text>
</view>
</view>
</view>
</view>
<!-- 滚动窗口外层包装=卡片本体:margin/圆角/白底/裁剪在这层(与文件页同因) -->
<view class="trash-page__scroll-wrap">
<scroll-view scroll-y class="trash-page__scroll">
<view class="trash-page__card">
<view v-if="loading" class="trash-page__state">
<text class="trash-page__state-text">
{{ t("FilePage.loading") }}
</text>
</view>
<view v-else-if="errorText" class="trash-page__state">
<text class="trash-page__state-text trash-page__state-text--error">
{{ errorText }}
</text>
<text class="trash-page__retry" @click="onSearch()">
{{ t("FilePage.retryBut") }}
</text>
</view>
<file-grid-list v-else :files="visibleFiles" :view-mode="viewMode" :select-mode="selectMode"
:selected-ids="selectedIds" :empty-text="t('TrashPage.emptyText')" @open="openSheet"
@more="openSheet" @toggle-select="onToggleSelectItem" />
</view>
<!-- 底部留白:多选栏盖在列表上,占位让最后一行不被遮 -->
<view class="trash-page__bottom-space"
:class="{ 'trash-page__bottom-space--actions': selectMode }" />
</scroll-view>
</view>
<!-- 多选底部操作栏:回收站动作集就两个(还原/彻底删除),一排铺开 -->
<view v-if="selectMode" class="trash-page__actions">
<view class="trash-page__actions-row">
<view class="trash-page__action-btn" @click="onMultiRestore">
<u-icon name="rewind-left" size="32" color="#1f2329" />
<text class="trash-page__action-text">
{{ t("TrashPage.restore") }}
</text>
</view>
<view class="trash-page__action-btn trash-page__action-btn--danger" @click="onMultiPurge">
<u-icon name="trash-fill" size="32" color="#f0413e" />
<text class="trash-page__action-text">
{{ t("TrashPage.purge") }}
</text>
</view>
</view>
</view>
</view>
</app-page>
</template>
<style lang="scss" scoped>
/* 骨架样式与文件页同套(fixed 全屏 + head/scroll-wrap 卡片分区),前缀换 trash-page */
.trash-page {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
flex-direction: column;
overflow: hidden;
/* 渐变底由 script 的 themeBackground 按主题 primary 动态计算(内联 style 下发) */
}
.trash-page__head {
flex-shrink: 0;
}
/* 滚动窗口外层包装=卡片本体:margin/圆角/白底/裁剪在这层 */
.trash-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 的圆角窗口裁剪 */
.trash-page__scroll {
height: 100%;
}
/* 列表底部留白:给多选操作栏让位(展开时加高) */
.trash-page__bottom-space {
height: 48rpx;
&--actions {
height: 220rpx;
}
}
.trash-page__selection {
display: flex;
align-items: center;
justify-content: space-between;
margin: 20rpx 24rpx 0;
padding: 16rpx 32rpx;
border-radius: 24rpx;
background-color: #ffffff;
}
.trash-page__selection-text {
font-size: 24rpx;
color: #2b8fff;
}
/* 全选 / 取消全选:贴右并排的图标胶囊按钮 */
.trash-page__selection-actions {
display: flex;
align-items: center;
gap: 20rpx;
}
.trash-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;
}
}
.trash-page__selection-btn-text {
font-size: 24rpx;
color: #5c626b;
.trash-page__selection-btn--primary & {
color: #2b8fff;
}
}
/* 多选底部操作栏:贴底常驻白底板,胶囊按钮等分 */
.trash-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);
}
.trash-page__actions-row {
display: flex;
gap: 16rpx;
}
.trash-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;
}
}
.trash-page__action-text {
font-size: 26rpx;
color: #1f2329;
.trash-page__action-btn--danger & {
color: #f0413e;
}
}
.trash-page__state {
display: flex;
align-items: center;
justify-content: center;
gap: 24rpx;
padding: 64rpx 32rpx;
}
.trash-page__state-text {
font-size: 26rpx;
color: #9aa0a6;
}
.trash-page__state-text--error {
color: #f0413e;
}
.trash-page__retry {
padding: 8rpx 28rpx;
border-radius: 999rpx;
font-size: 26rpx;
color: #2b8fff;
background-color: #eaf4ff;
}
</style>