Files
Docker/uniapp/youjiala/src/pages/trash/trash.vue
T
2026-10-16 11:57:29 +08:00

471 lines
15 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">
/**
* 回收站页(短地址 /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">
<!-- 与传输页同款沉浸头部:标题固定「回收站」,返回箭头常显,右侧搜索胶囊 -->
<hero-navbar :title="t('TrashPage.title')" :show-search="true" :search-on="showSearch" @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>