This commit is contained in:
owenchen committed 2026-10-14 18:50:17 +08:00
1 parent 3194f98ac0
commit e740ec77ec
24 files changed
+1561 -65

No files matched your search

+127 -19
View File
@@ -6,9 +6,9 @@
* file-breadcrumb 目录面包屑(仅子目录态:左起排列,放不下折成 根+…+尾部段)
* search-input 搜索框(v-model 即时过滤,确认键触发服务端搜索)
* quick-entry 快捷功能入口(横向滚动图标组)
* file-tab-filter 分类标签 + 排序 + 视图切换 + 多选开关
* file-tab-filter 分类标签 + 新建文件夹 + 排序 + 视图切换 + 多选开关
* file-grid-list 文件网格/列表卡片
* float-add-btn 右下角悬浮加号
* float-add-btn 右下角悬浮加号(直接选文件上传)
* 数据来自后端 DMS 接口(composables/useDmsFiles,调用链对齐旧 WebVue DmsFileEnquiry.vue);
* 组件契约(data/custom/fileBrowser.ts)不关心数据源,演示数据到真实接口只动页面层。
* 目录导航:点文件夹下钻;子目录态隐藏底部 tabbar,返回箭头/面包屑退层级。
@@ -18,24 +18,38 @@ import type { FileCategory, FileItem, FileKind, FileTabItem, QuickEntryItem } fr
import { useLocale } from "uview-pro";
import { computed, ref, watch } from "vue";
import { useAuthGuard, useDmsFiles, useThemeGradient } from "@/composables";
import { DmsFileEntity } from "@/data/auto/dmsFileEntity";
import { pageUrl } from "@/router/routes";
import dmsFileService from "@/service/master/dmsFileService";
import { useUploadQueueStore } from "@/stores/uploadQueue";
import { chooseFiles } from "@/utils/chooseFiles";
import { transferGlyph } from "@/utils/transferGlyph";
const { t } = useLocale();
// 页面底渐变跟随主题(派生逻辑在 composables/useThemeGradient,多页共用)
const { themeBackground } = useThemeGradient();
const { files, loading, errorText, pathStack, atRoot, init, retry, openFolder, back, goTo, search } = useDmsFiles();
const { files, loading, errorText, pathStack, atRoot, init, openTrail, retry, openFolder, back, goTo, search } = useDmsFiles();
// 首次显示拉根目录,之后每次回到页面只刷新当前目录(重进 tab 不该丢位置重新拉全链)
// 上传队列(全局 store):入队在本页、胶囊读它的进行中计数、守卫回调消费它的面包屑导航请求
const uploadQueue = useUploadQueueStore();
// 首次显示拉根目录,之后每次回到页面只刷新当前目录(重进 tab 不该丢位置重新拉全链);
// 回页末尾消费传输页的面包屑导航请求(reLaunch 重建后恢复到目标目录)
let started = false;
useAuthGuard(() => {
useAuthGuard(async () => {
if (started) {
retry();
return;
await retry();
}
else {
started = true;
await init();
}
const trail = uploadQueue.takeNav();
if (trail) {
await openTrail(trail);
}
started = true;
init();
});
// ===== 页面状态(全部单向下发,组件只上报意图) =====
@@ -102,11 +116,6 @@ function toast(title: string): void {
uni.showToast({ title, icon: "none" });
}
/** 待接入真实功能时的统一占位提示 */
function todoTip(): void {
toast(t("FilePage.todoTip"));
}
// ===== 导航栏 / 面包屑 =====
function onToggleSearch(): void {
showSearch.value = !showSearch.value;
@@ -334,20 +343,83 @@ function onSheetAction(key: string, file: FileItem): void {
toast(`${file.name} · ${key} · ${t("FilePage.todoTip")}`);
}
// 新建文件夹命名对话框(CreateDmsFolder 在途标记防连点)
const folderDialogVisible = ref(false);
const folderSubmitting = ref(false);
// 上传队列状态见文件头部的 uploadQueue store
/** 悬浮「+」:新建文件夹已拆到工具栏图标,这里直接选文件上传 */
function onAdd(): void {
todoTip();
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">
<app-page :hide-nav="true" :show-tabbar="atRoot && !actionFile && !selectMode && !folderDialogVisible">
<view class="file-page" :style="{ background: themeBackground }">
<!-- 上半屏整体固定,只有文件列表所在的 scroll-view 内部滚动(整页不滚) -->
<view class="file-page__head">
<page-navbar :title="navTitle" :show-back="!atRoot" :search-on="showSearch" :quick-on="showQuick" @back="onBack"
@toggle-search="onToggleSearch" @toggle-quick="onToggleQuick" />
<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" />
@@ -360,7 +432,8 @@ function onAdd(): void {
<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" />
@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 }) }}
@@ -410,8 +483,17 @@ function onAdd(): void {
</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" />
<folder-name-dialog :visible="folderDialogVisible" :submitting="folderSubmitting"
@close="folderDialogVisible = false" @confirm="onCreateFolder" />
<!-- 多选底部操作栏:常驻非模态(无遮罩),勾选与动作可交替进行;
动作集是旧系统多选子集——共享/发布按同媒体类型条件,重命名/移动仅单选 -->
<view v-if="selectMode" class="file-page__actions">
@@ -493,6 +575,32 @@ function onAdd(): void {
}
}
/* 上传中悬浮胶囊:左下角常驻(避开右侧可拖动加号),点击回传输列表 */
.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;
@@ -0,0 +1,261 @@
<script setup lang="ts">
/**
* 传输列表页:uploadQueue store 的视图层。
*
* 任务状态全部住在 store 里,本页只渲染与派发操作——上传中切走、回来看进度不断;
* 与旧系统 OperatorStation 的 upload-progress/success/interrupted 事件链同一语义,
* 只是把「页面持有状态」翻转成了「store 持有状态」。
*/
import type { UploadItem } from "@/stores/uploadQueue";
import { useLocale } from "uview-pro";
import { useAuthGuard, useThemeGradient } from "@/composables";
import { pageUrl } from "@/router/routes";
import { useUploadQueueStore } from "@/stores/uploadQueue";
const { t } = useLocale();
const { themeBackground } = useThemeGradient();
const uploadQueue = useUploadQueueStore();
// 本页无后端数据要拉,守卫只做登录拦截(未登录直接送去登录页)
useAuthGuard();
/** 字节数可读化:0/拿不到显示 -- */
function formatSize(bytes: number): string {
if (!bytes || bytes < 0) {
return "--";
}
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let index = 0;
while (value >= 1024 && index < units.length - 1) {
value /= 1024;
index += 1;
}
return `${value.toFixed(index === 0 || value >= 100 ? 0 : 1)} ${units[index]}`;
}
/** 状态行:上传中带速度与百分比,失败附错误摘要 */
function statusLine(item: UploadItem): string {
switch (item.status) {
case "queued":
return t("TransportPage.queued");
case "uploading":
return `${t("TransportPage.uploading")} · ${formatSize(item.speed)}/s · ${item.progress}%`;
case "success":
return t("TransportPage.success");
case "cancelled":
return t("TransportPage.cancelled");
default:
return `${t("TransportPage.failed")}${item.error ? ` · ${item.error}` : ""}`;
}
}
/** 按扩展名挑图标(字形均已核实存在于 uview-pro iconfont) */
function kindIcon(name: string): string {
const ext = name.split(".").pop()?.toLowerCase() || "";
if (["jpg", "jpeg", "png", "gif", "webp", "bmp", "heic"].includes(ext)) {
return "photo";
}
if (["mp4", "mov", "avi", "mkv", "webm"].includes(ext)) {
return "play-circle";
}
if (["mp3", "wav", "m4a", "aac", "flac"].includes(ext)) {
return "volume";
}
return "file-text";
}
function statusColor(item: UploadItem): string {
if (item.status === "failed") {
return "#f0413e";
}
if (item.status === "success") {
return "#19be6b";
}
return "#8f959e";
}
/**
* 点条目面包屑:登记目标目录链后 reLaunch 回文件 tab(与 app-tabbar 同款跳法);
* 点任意段都去该任务的末段目录(入队时的目标目录),文件页 onShow 消费请求后恢复位置
*/
function goTargetFolder(item: UploadItem): void {
uploadQueue.requestNav(item.targetTrail);
uni.reLaunch({ url: pageUrl("fileTab") });
}
/** 返回键:F5 刷新后历史栈已空,navigateBack 无效——无栈时兜底回文件 tab(本页就是文件页的附属页) */
function onBack(): void {
if (getCurrentPages().length > 1) {
uni.navigateBack();
return;
}
uni.reLaunch({ url: pageUrl("fileTab") });
}
</script>
<template>
<app-page :nav-title="t('TransportPage.title')" :custom-back="onBack">
<view class="transport-page" :style="{ background: themeBackground }">
<view v-if="!uploadQueue.items.length" class="transport-page__empty">
<u-empty mode="list" :text="t('TransportPage.empty')" icon-size="120" />
</view>
<template v-else>
<!-- 进行中队(排队 + 上传中) -->
<view v-if="uploadQueue.activeItems.length" class="transport-page__group">
<text class="transport-page__group-title">
{{ t("TransportPage.active") }} ({{ uploadQueue.activeItems.length }})
</text>
<view v-for="item in uploadQueue.activeItems" :key="item.id" class="transport-item">
<view class="transport-item__row">
<u-icon :name="kindIcon(item.name)" size="44" color="#2b8fff" />
<view class="transport-item__main">
<text class="transport-item__name">
{{ item.name }}
</text>
<text class="transport-item__status">
{{ statusLine(item) }} · {{ formatSize(item.size) }}
</text>
<u-line-progress v-if="item.status === 'uploading'" :percent="item.progress" height="8"
:show-percent="false" active-color="#2b8fff" />
</view>
<view class="transport-item__btn" @tap="uploadQueue.cancel(item.id)">
{{ t("TransportPage.cancel") }}
</view>
</view>
<view v-if="item.targetTrail.length" class="transport-item__crumb" @tap="goTargetFolder(item)">
<file-breadcrumb :items="item.targetTrail" show-single />
</view>
</view>
</view>
<!-- 已结束队(成功 / 失败 / 取消) -->
<view v-if="uploadQueue.doneItems.length" class="transport-page__group">
<view class="transport-page__group-head">
<text class="transport-page__group-title">
{{ t("TransportPage.done") }} ({{ uploadQueue.doneItems.length }})
</text>
<text class="transport-page__clear" @tap="uploadQueue.clearDone()">
{{ t("TransportPage.clearDone") }}
</text>
</view>
<view v-for="item in uploadQueue.doneItems" :key="item.id" class="transport-item">
<view class="transport-item__row">
<u-icon :name="kindIcon(item.name)" size="44" :color="statusColor(item)" />
<view class="transport-item__main">
<text class="transport-item__name">
{{ item.name }}
</text>
<text class="transport-item__status" :style="{ color: statusColor(item) }">
{{ statusLine(item) }} · {{ formatSize(item.size) }}
</text>
</view>
<view v-if="item.status !== 'success'" class="transport-item__btn" @tap="uploadQueue.retry(item.id)">
{{ t("TransportPage.retry") }}
</view>
<view class="transport-item__btn transport-item__btn--plain" @tap="uploadQueue.remove(item.id)">
{{ t("TransportPage.remove") }}
</view>
</view>
<view v-if="item.targetTrail.length" class="transport-item__crumb" @tap="goTargetFolder(item)">
<file-breadcrumb :items="item.targetTrail" show-single />
</view>
</view>
</view>
</template>
</view>
</app-page>
</template>
<style lang="scss" scoped>
.transport-page {
min-height: calc(100vh - var(--window-top, 0px));
padding: 24rpx 24rpx calc(48rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
&__empty {
padding-top: 200rpx;
}
&__group {
margin-bottom: 32rpx;
}
&__group-head {
display: flex;
align-items: center;
justify-content: space-between;
}
&__group-title {
display: block;
padding: 8rpx 8rpx 16rpx;
font-size: 26rpx;
color: #8f959e;
}
&__clear {
padding: 8rpx 8rpx 16rpx;
font-size: 26rpx;
color: #2979ff;
}
}
.transport-item {
display: flex;
flex-direction: column;
gap: 12rpx;
margin-bottom: 16rpx;
padding: 24rpx;
border-radius: 20rpx;
background-color: #ffffff;
/* 行主体:图标 + 文本区 + 动作按钮横向排列(面包屑另占一行) */
&__row {
display: flex;
align-items: center;
gap: 20rpx;
}
/* 目标目录面包屑行:整行可点,点任意段都跳回文件页的目标目录(单段也走同款卡片) */
&__crumb {
padding-left: 64rpx;
}
&__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8rpx;
}
&__name {
font-size: 28rpx;
color: #1f2329;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__status {
font-size: 22rpx;
color: #8f959e;
}
&__btn {
flex-shrink: 0;
padding: 12rpx 24rpx;
border-radius: 999rpx;
font-size: 24rpx;
color: #2979ff;
background-color: #eaf4ff;
&--plain {
color: #8f959e;
background-color: #f5f6f8;
}
}
}
</style>