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

+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>