ow
This commit is contained in:
1 parent
e740ec77ec
commit
ceb3e7beec
20 files changed
+2184
-42
No files matched your search
@@ -466,11 +466,16 @@ function timestamp(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载并打开文件(默认按 Excel 处理)
|
||||
* 关键防御:先探测 content-type——后端报错时返回的是 JSON 而不是文件
|
||||
* 裸 arraybuffer 请求(旁路通道):文件下载/分片取回/预览字节等面向字节的场景共用。
|
||||
* 关键防御与 downloadFile 同源——后端报错时回的是 JSON 而不是文件,
|
||||
* 按 content-type 探测后转成错误文案抛出(业务异常不再当字节流落盘)
|
||||
*/
|
||||
export async function downloadFile(options: DownloadOptions): Promise<void> {
|
||||
const { url, method = "POST", data, filename, extension = "xlsx" } = options;
|
||||
export async function requestBuffer(options: {
|
||||
url: string;
|
||||
method?: "GET" | "POST";
|
||||
data?: any;
|
||||
}): Promise<ArrayBuffer> {
|
||||
const { url, method = "POST", data } = options;
|
||||
const fullUrl = url.indexOf("http") === 0 ? url : baseUrl + url;
|
||||
const auth = bearerAuth();
|
||||
|
||||
@@ -493,7 +498,7 @@ export async function downloadFile(options: DownloadOptions): Promise<void> {
|
||||
const contentType = String(
|
||||
header["content-type"] || header.Content_Type || header.ContentType || "",
|
||||
);
|
||||
if (contentType.includes("application/json")) {
|
||||
if (contentType.includes("json")) {
|
||||
const text
|
||||
= typeof res.data === "string"
|
||||
? res.data
|
||||
@@ -513,19 +518,24 @@ export async function downloadFile(options: DownloadOptions): Promise<void> {
|
||||
notifyError("", message);
|
||||
throw markErrorNotified(new Error(message));
|
||||
}
|
||||
return res.data as ArrayBuffer;
|
||||
}
|
||||
|
||||
const fileName = `${filename}${timestamp()}.${extension}`;
|
||||
|
||||
/** 字节按指定文件名落盘:H5 走 blob+<a>;小程序/App 写本地目录后调起系统文档预览 */
|
||||
export async function saveArrayBuffer(bytes: ArrayBuffer, fileName: string): Promise<void> {
|
||||
// #ifdef H5
|
||||
// H5:blob + 隐藏 <a> 触发浏览器下载
|
||||
const downloadUrl = URL.createObjectURL(new Blob([res.data as ArrayBuffer]));
|
||||
const downloadUrl = URL.createObjectURL(new Blob([bytes]));
|
||||
const link = document.createElement("a");
|
||||
link.href = downloadUrl;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
// 浏览器读 blob 是异步任务:click 后立即 revoke 会让下载列表现「Failed - No file」,延迟释放
|
||||
setTimeout(() => {
|
||||
link.remove();
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
}, 3000);
|
||||
// #endif
|
||||
|
||||
// #ifndef H5
|
||||
@@ -544,7 +554,7 @@ export async function downloadFile(options: DownloadOptions): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
fs.writeFile({
|
||||
filePath,
|
||||
data: res.data as ArrayBuffer,
|
||||
data: bytes,
|
||||
success: () => resolve(),
|
||||
fail: (err: any) => reject(new Error(err.errMsg || "写入文件失败")),
|
||||
});
|
||||
@@ -560,5 +570,15 @@ export async function downloadFile(options: DownloadOptions): Promise<void> {
|
||||
// #endif
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载并打开文件(默认按 Excel 处理):请求与落盘两段均已抽为公用函数,
|
||||
* 文件页的下载动作直接复用 requestBuffer + saveArrayBuffer
|
||||
*/
|
||||
export async function downloadFile(options: DownloadOptions): Promise<void> {
|
||||
const { url, method = "POST", data, filename, extension = "xlsx" } = options;
|
||||
const bytes = await requestBuffer({ url, method, data });
|
||||
await saveArrayBuffer(bytes, `${filename}${timestamp()}.${extension}`);
|
||||
}
|
||||
|
||||
// 导出(main.ts httpPlugin 注册用)
|
||||
export { httpInterceptor, httpRequestConfig };
|
||||
@@ -31,6 +31,11 @@ defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
/** 新建文件夹按钮开关:回收站等无建目录场景的页面传 false */
|
||||
showNewFolder: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -60,8 +65,8 @@ const sortIconDark = sortGlyph("#1f2329");
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="file-tab-filter__tools">
|
||||
<!-- 新建文件夹:直接弹命名对话框(上传已从悬浮「+」拆分,这里只管建目录) -->
|
||||
<view class="file-tab-filter__tool" @tap="emit('new-folder')">
|
||||
<!-- 新建文件夹:直接弹命名对话框(上传已从悬浮「+」拆分,这里只管建目录);回收站页关掉 -->
|
||||
<view v-if="showNewFolder" class="file-tab-filter__tool" @tap="emit('new-folder')">
|
||||
<image class="file-tab-filter__tool-img--folder" :src="folderIconDark" mode="aspectFit" />
|
||||
</view>
|
||||
<!-- 排序(从页面导航栏迁来):自绘三横线+下箭头,比原 level 更有降序感 -->
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 移动目标目录选择器(底部抽屉,网盘式逐级下钻):替代旧的整树展开——目录多时树又长又难扫,
|
||||
* 改成「进一层看一层、看中了点『移动到此』」的浏览式交互。
|
||||
* 数据仍走 FindDmsFolderData 一次拿全(文件夹 + 父子映射),本地按层取子级,下钻不打接口。
|
||||
* 规则对齐旧系统:根 = CreatedBy 等于 FolderLocalName 的那条;excludeName(被移动文件夹的
|
||||
* 系统名)连同子树整体不可进——不能把文件夹挪进自己怀里;sourcePath(条目现居目录)下
|
||||
* 「移动到此」置灰——原地移动无意义。
|
||||
* 组件不发移动请求:点「移动到此」把当前所在层 confirm 给页面层调 MoveDmsFolder。
|
||||
*/
|
||||
import type { PathNode } from "@/data/custom/fileBrowser";
|
||||
import { useLocale } from "uview-pro";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import dmsFileService from "@/service/master/dmsFileService";
|
||||
|
||||
/** 一个目录层:系统名(提交用)+ 显示名 */
|
||||
export interface FolderRow {
|
||||
folderName: string;
|
||||
localName: string;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
/** 抽屉是否展开 */
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
/** 剔除的目录系统名(被移动文件夹自身,其子树随之不可见) */
|
||||
excludeName: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
/** 被移动条目现居目录的系统名:该层下「移动到此」置灰 */
|
||||
sourcePath: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
/** 已选条目数(顶部「已选择 N 项」提示) */
|
||||
selectedCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "close"): void;
|
||||
/** 点「移动到此」,带上当前所在目录 */
|
||||
(e: "confirm", row: FolderRow): void;
|
||||
}>();
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
const loading = ref(false);
|
||||
/** 浏览路径栈:[0] 恒为根,末项 = 当前所在目录 */
|
||||
const trail = ref<FolderRow[]>([]);
|
||||
/** 全量索引:folderName → 实体 / 父子映射(每次重拉重建) */
|
||||
let byName = new Map<string, { FolderName: string; FolderLocalName: string; CreatedDatetime: Date | string }>();
|
||||
let maps: Array<{ FolderNameParent: string; FolderNameSon: string }> = [];
|
||||
|
||||
const current = computed<FolderRow | null>(() => trail.value[trail.value.length - 1] ?? null);
|
||||
|
||||
/** 当前层的子目录行(滤掉 excludeName 子树根,按显示名排序) */
|
||||
const rows = computed<FolderRow[]>(() => {
|
||||
if (!current.value) {
|
||||
return [];
|
||||
}
|
||||
return maps
|
||||
.filter(m => m.FolderNameParent === current.value!.folderName && m.FolderNameSon !== props.excludeName)
|
||||
.map(m => byName.get(m.FolderNameSon))
|
||||
.filter((f): f is NonNullable<typeof f> => !!f)
|
||||
.sort((a, b) => a.FolderLocalName.localeCompare(b.FolderLocalName, "zh-CN"))
|
||||
.map(f => ({ folderName: f.FolderName, localName: f.FolderLocalName }));
|
||||
});
|
||||
|
||||
/** 「移动到此」置灰:没有可提交层,或正站在条目现居目录 */
|
||||
const canMoveHere = computed(() => !!current.value && current.value.folderName !== props.sourcePath);
|
||||
|
||||
// 每次展开重拉并回到根:目录可能被别的操作改过,缓存会钻进了不存在的坑
|
||||
watch(
|
||||
() => props.visible,
|
||||
async (on) => {
|
||||
if (on) {
|
||||
await load();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await dmsFileService.FindDmsFolderData();
|
||||
const folders = data?.DmsFolders ?? [];
|
||||
maps = data?.DmsFoldersMaps ?? [];
|
||||
byName = new Map(folders.map(f => [f.FolderName, f]));
|
||||
const root = folders.find(f => f.CreatedBy === f.FolderLocalName);
|
||||
trail.value = root ? [{ folderName: root.FolderName, localName: root.FolderLocalName }] : [];
|
||||
}
|
||||
finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 目录创建时间展示(后端回 ISO 串/Date 均可能),取不到就不显示 */
|
||||
function fmtDate(folderName: string): string {
|
||||
const raw = byName.get(folderName)?.CreatedDatetime;
|
||||
const date = raw instanceof Date ? raw : new Date(String(raw ?? ""));
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return "";
|
||||
}
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
function onEnter(row: FolderRow): void {
|
||||
trail.value = [...trail.value, row];
|
||||
}
|
||||
|
||||
/** 头部面包屑(复用 file-breadcrumb):根段显示「我的网盘」,单击任意段回该层 */
|
||||
const crumbItems = computed<PathNode[]>(() => trail.value.map((row, index) => ({
|
||||
value: row.folderName,
|
||||
displayName: index === 0 ? t("FilePage.moveRoot") : row.localName,
|
||||
})));
|
||||
|
||||
/** 面包屑点某段:截断到该层(当前段组件内自行不响应) */
|
||||
function onJump(index: number): void {
|
||||
trail.value = trail.value.slice(0, index + 1);
|
||||
}
|
||||
|
||||
function onMoveHere(): void {
|
||||
if (current.value && canMoveHere.value) {
|
||||
emit("confirm", current.value);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<u-popup :model-value="props.visible" mode="bottom" round="24" @close="emit('close')">
|
||||
<view class="folder-move-picker">
|
||||
<!-- 头部:面包屑路径(复用 file-breadcrumb,单击回层、超长自动折叠)+ 关闭 -->
|
||||
<view class="folder-move-picker__head">
|
||||
<view class="folder-move-picker__crumb">
|
||||
<file-breadcrumb :items="crumbItems" :show-single="true" @nav="onJump" />
|
||||
</view>
|
||||
<view class="folder-move-picker__close" @tap="emit('close')">
|
||||
<u-icon name="close" size="36" color="#8f959e" />
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="props.selectedCount" class="folder-move-picker__picked">
|
||||
{{ t("FilePage.moveSelected").replace("{count}", String(props.selectedCount)) }}
|
||||
</text>
|
||||
<scroll-view scroll-y class="folder-move-picker__list">
|
||||
<view v-if="loading" class="folder-move-picker__empty">
|
||||
<u-loading mode="circle" />
|
||||
</view>
|
||||
<template v-else>
|
||||
<view v-if="!rows.length" class="folder-move-picker__empty">
|
||||
<text class="folder-move-picker__empty-text">
|
||||
{{ t("FilePage.moveNoSub") }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-for="row in rows" :key="row.folderName" class="folder-move-picker__row" @tap="onEnter(row)">
|
||||
<!-- 图标库无 folder 字形,沿用项目 CSS 画文件夹惯例(同 file-action-sheet) -->
|
||||
<view class="folder-move-picker__folder" />
|
||||
<view class="folder-move-picker__row-main">
|
||||
<text class="folder-move-picker__row-text">
|
||||
{{ row.localName }}
|
||||
</text>
|
||||
<text v-if="fmtDate(row.folderName)" class="folder-move-picker__row-sub">
|
||||
{{ fmtDate(row.folderName) }}
|
||||
</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="26" color="#c0c4cc" />
|
||||
</view>
|
||||
</template>
|
||||
</scroll-view>
|
||||
<!-- 底部:通栏「移动到此」提交当前层(站在原地目录时置灰) -->
|
||||
<view class="folder-move-picker__footer">
|
||||
<view class="folder-move-picker__btn" :class="{ 'folder-move-picker__btn--muted': !canMoveHere }"
|
||||
@tap="onMoveHere">
|
||||
<text class="folder-move-picker__btn-text">
|
||||
{{ t("FilePage.moveHere") }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="folder-move-picker__safe" />
|
||||
</view>
|
||||
</u-popup>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.folder-move-picker {
|
||||
padding: 8rpx 32rpx 0;
|
||||
background-color: #ffffff;
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 108rpx;
|
||||
}
|
||||
|
||||
/* 面包屑占满剩余宽度(自身可收缩,超长时组件内折叠出 …) */
|
||||
&__crumb {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__close {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
padding: 12rpx;
|
||||
}
|
||||
|
||||
&__picked {
|
||||
display: block;
|
||||
padding: 0 8rpx 16rpx;
|
||||
font-size: 26rpx;
|
||||
color: #5c626b;
|
||||
}
|
||||
|
||||
&__list {
|
||||
max-height: 640rpx;
|
||||
min-height: 240rpx;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80rpx 0;
|
||||
}
|
||||
|
||||
&__empty-text {
|
||||
font-size: 26rpx;
|
||||
color: #8f959e;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
padding: 24rpx 12rpx;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
&__folder {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 56rpx;
|
||||
height: 44rpx;
|
||||
border-radius: 10rpx;
|
||||
background: linear-gradient(180deg, #8ecbff, #4aa3ff);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: -10rpx;
|
||||
left: 0;
|
||||
width: 24rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 6rpx 6rpx 0 0;
|
||||
background: linear-gradient(180deg, #8ecbff, #4aa3ff);
|
||||
content: "";
|
||||
}
|
||||
}
|
||||
|
||||
&__row-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__row-text {
|
||||
overflow: hidden;
|
||||
font-size: 30rpx;
|
||||
color: #1f2329;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__row-sub {
|
||||
font-size: 22rpx;
|
||||
color: #8f959e;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: 24rpx;
|
||||
border-top: 1rpx solid #f0f1f3;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 76rpx;
|
||||
border-radius: 999rpx;
|
||||
background-color: #2b8fff;
|
||||
}
|
||||
|
||||
&__btn--muted {
|
||||
background-color: #d4d8de;
|
||||
}
|
||||
|
||||
&__btn-text {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
&__safe {
|
||||
height: calc(24rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 新建文件夹命名对话框:居中卡片(标题 + 输入 + 取消/创建),
|
||||
* 只负责收集名称——非法字符校验、CreateDmsFolder 接口与结果提示由页面分流。
|
||||
* 命名对话框(新建文件夹 / 重命名共用):居中卡片(标题 + 输入 + 取消/确认)。
|
||||
* 只负责收集名称——非法字符校验、接口与结果提示由页面分流。
|
||||
* 新建态不传 title/confirmLabel 走默认「新建文件夹/创建」;重命名态由页面传入并预填 initialValue。
|
||||
* 提交成功后由父级收起 visible;submitting 期间禁用按钮防连点。
|
||||
*/
|
||||
import { useLocale } from "uview-pro";
|
||||
@@ -13,11 +14,26 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
/** 创建请求在途:置灰两个按钮 */
|
||||
/** 创建/重命名请求在途:置灰两个按钮 */
|
||||
submitting: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
/** 标题文案,默认「新建文件夹」 */
|
||||
title: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
/** 确认按钮文案,默认「创建」 */
|
||||
confirmLabel: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
/** 展开时预填的名称(重命名带当前名),空则清空输入 */
|
||||
initialValue: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -34,12 +50,12 @@ const name = ref("");
|
||||
/** 空白名不算可提交 */
|
||||
const canSubmit = computed(() => name.value.trim().length > 0);
|
||||
|
||||
// 每次展开清掉上次的残留输入(对话框实例常驻 tab 页)
|
||||
// 每次展开按 initialValue 预填(重命名带当前名、新建为空),聚焦即全选方便改(对话框实例常驻 tab 页)
|
||||
watch(
|
||||
() => props.visible,
|
||||
(on) => {
|
||||
if (on) {
|
||||
name.value = "";
|
||||
name.value = props.initialValue || "";
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -57,7 +73,7 @@ function onConfirm(): void {
|
||||
@close="emit('close')">
|
||||
<view class="folder-name-dialog">
|
||||
<text class="folder-name-dialog__title">
|
||||
{{ t("FilePage.addFolder") }}
|
||||
{{ props.title || t("FilePage.addFolder") }}
|
||||
</text>
|
||||
<input v-model="name" class="folder-name-dialog__input" :placeholder="t('FilePage.folderPlaceholder')"
|
||||
:maxlength="50" :disabled="props.submitting" confirm-type="done" @confirm="onConfirm">
|
||||
@@ -70,7 +86,7 @@ function onConfirm(): void {
|
||||
<view class="folder-name-dialog__btn folder-name-dialog__btn--primary"
|
||||
:class="{ 'folder-name-dialog__btn--muted': !canSubmit || props.submitting }" @click="onConfirm">
|
||||
<text class="folder-name-dialog__btn-text folder-name-dialog__btn-text--primary">
|
||||
{{ t("FilePage.folderConfirm") }}
|
||||
{{ props.confirmLabel || t("FilePage.folderConfirm") }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 通用单选列表弹层(底部抽屉):标题 + 可滚动选项行 + 确认按钮。
|
||||
* 「共享家人」选目标群组目录、「发布」选专辑都吃它——数据由页面拉好喂进来,组件不发请求。
|
||||
* 行为:展开时重置为「未选中」(防上次残留误提交);点行仅选中,点确认才 emit confirm(key);
|
||||
* loading 显示转圈占位,items 空且不在 loading 时显示 emptyText。
|
||||
*/
|
||||
import { useLocale } from "uview-pro";
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
export interface PickerOption {
|
||||
key: string;
|
||||
/** 主文案(如群组目录名 / 专辑名) */
|
||||
label: string;
|
||||
/** 次要说明(如共享路径 / 专辑描述),缺省不占行 */
|
||||
sub?: string;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
/** 弹层是否展开 */
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
/** 候选项;key 必须非空且唯一 */
|
||||
items: {
|
||||
type: Array as () => PickerOption[],
|
||||
default: () => [],
|
||||
},
|
||||
/** 数据在途:列表区显示加载占位、确认按钮禁用 */
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
/** 空列表文案 */
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 遮罩/取消关闭 */
|
||||
(e: "close"): void;
|
||||
/** 确认并带上选中项 key */
|
||||
(e: "confirm", key: string): void;
|
||||
}>();
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
const picked = ref("");
|
||||
|
||||
// 每次展开清掉上次选择
|
||||
watch(
|
||||
() => props.visible,
|
||||
(on) => {
|
||||
if (on) {
|
||||
picked.value = "";
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function onConfirm(): void {
|
||||
if (picked.value && !props.loading) {
|
||||
emit("confirm", picked.value);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- u-popup 只认 modelValue(v-model);loading 中禁点遮罩防误关 -->
|
||||
<u-popup :model-value="props.visible" mode="bottom" round="24" :close-on-click-overlay="!props.loading"
|
||||
@close="emit('close')">
|
||||
<view class="option-picker">
|
||||
<text class="option-picker__title">
|
||||
{{ props.title }}
|
||||
</text>
|
||||
<scroll-view scroll-y class="option-picker__list">
|
||||
<view v-if="props.loading" class="option-picker__empty">
|
||||
<u-loading mode="circle" />
|
||||
</view>
|
||||
<template v-else>
|
||||
<view v-if="!props.items.length" class="option-picker__empty">
|
||||
<text class="option-picker__empty-text">
|
||||
{{ props.emptyText || t("FilePage.pickerEmpty") }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-for="item in props.items" :key="item.key" class="option-picker__row"
|
||||
:class="{ 'option-picker__row--picked': picked === item.key }" @tap="picked = item.key">
|
||||
<view class="option-picker__row-main">
|
||||
<text class="option-picker__row-label">
|
||||
{{ item.label }}
|
||||
</text>
|
||||
<text v-if="item.sub" class="option-picker__row-sub">
|
||||
{{ item.sub }}
|
||||
</text>
|
||||
</view>
|
||||
<u-icon v-if="picked === item.key" name="checkmark" size="36" color="#2b8fff" />
|
||||
<view v-else class="option-picker__radio" />
|
||||
</view>
|
||||
</template>
|
||||
</scroll-view>
|
||||
<view class="option-picker__footer">
|
||||
<view class="option-picker__btn" @tap="emit('close')">
|
||||
<text class="option-picker__btn-text">
|
||||
{{ t("FilePage.folderCancel") }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="option-picker__btn option-picker__btn--primary"
|
||||
:class="{ 'option-picker__btn--muted': !picked || props.loading }" @tap="onConfirm">
|
||||
<text class="option-picker__btn-text option-picker__btn-text--primary">
|
||||
{{ t("FilePage.folderConfirm") }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="option-picker__safe" />
|
||||
</view>
|
||||
</u-popup>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.option-picker {
|
||||
padding: 40rpx 32rpx 0;
|
||||
background-color: #ffffff;
|
||||
|
||||
&__title {
|
||||
display: block;
|
||||
padding-bottom: 24rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__list {
|
||||
max-height: 560rpx;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80rpx 0;
|
||||
}
|
||||
|
||||
&__empty-text {
|
||||
font-size: 26rpx;
|
||||
color: #8f959e;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx 20rpx;
|
||||
border-radius: 16rpx;
|
||||
|
||||
&--picked {
|
||||
background-color: #f0f6ff;
|
||||
}
|
||||
}
|
||||
|
||||
&__row-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
&__row-label {
|
||||
overflow: hidden;
|
||||
font-size: 28rpx;
|
||||
color: #1f2329;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__row-sub {
|
||||
overflow: hidden;
|
||||
font-size: 22rpx;
|
||||
color: #8f959e;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 未选中态的空心圆点 */
|
||||
&__radio {
|
||||
flex-shrink: 0;
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
border: 3rpx solid #d4d7dd;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 76rpx;
|
||||
border-radius: 999rpx;
|
||||
background-color: #f5f6f8;
|
||||
|
||||
&--primary {
|
||||
background-color: #2b8fff;
|
||||
}
|
||||
|
||||
/* 未选择或提交中:主按钮置灰(仍可点,由 onConfirm 拦截) */
|
||||
&--muted {
|
||||
background-color: #b9d6ff;
|
||||
}
|
||||
}
|
||||
|
||||
&__btn-text {
|
||||
font-size: 28rpx;
|
||||
color: #5c626b;
|
||||
|
||||
&--primary {
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
&__safe {
|
||||
height: calc(32rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -37,6 +37,16 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
/** 传输列表入口开关:回收站等无上传场景的页面传 false */
|
||||
showTransfer: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
/** 快捷入口区开关:无快捷入口的页面传 false(连带隐藏切换按钮) */
|
||||
showQuickToggle: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -61,8 +71,8 @@ const transferIcon = computed(() => transferGlyph(props.transferring ? TRANSFER_
|
||||
{{ props.title }}
|
||||
</text>
|
||||
<view class="page-navbar__side page-navbar__side--right">
|
||||
<!-- 传输列表入口(常显):有进行中任务时点亮 + 数字角标 -->
|
||||
<view class="page-navbar__btn page-navbar__btn--transfer" @tap="emit('transfer')">
|
||||
<!-- 传输列表入口(常显):有进行中任务时点亮 + 数字角标;回收站页隐藏 -->
|
||||
<view v-if="props.showTransfer" class="page-navbar__btn page-navbar__btn--transfer" @tap="emit('transfer')">
|
||||
<image class="page-navbar__transfer-img" :src="transferIcon" mode="aspectFit" />
|
||||
<text v-if="props.transferring" class="page-navbar__badge">
|
||||
{{ props.transferring > 99 ? "99+" : props.transferring }}
|
||||
@@ -72,8 +82,8 @@ const transferIcon = computed(() => transferGlyph(props.transferring ? TRANSFER_
|
||||
<view class="page-navbar__btn" @tap="emit('toggle-search')">
|
||||
<u-icon name="search" size="38" :color="props.searchOn ? '#2b8fff' : '#8f959e'" />
|
||||
</view>
|
||||
<!-- 切换快捷入口区显隐 -->
|
||||
<view class="page-navbar__btn" @tap="emit('toggle-quick')">
|
||||
<!-- 切换快捷入口区显隐;无快捷入口的页面隐藏 -->
|
||||
<view v-if="props.showQuickToggle" class="page-navbar__btn" @tap="emit('toggle-quick')">
|
||||
<u-icon name="grid" size="38" :color="props.quickOn ? '#2b8fff' : '#8f959e'" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from "./useAuthGuard";
|
||||
export * from "./useDmsFiles";
|
||||
export * from "./useLang";
|
||||
export * from "./useThemeGradient";
|
||||
export * from "./useTrashFiles";
|
||||
@@ -26,8 +26,16 @@ import dmsFileService from "@/service/master/dmsFileService";
|
||||
/** 目录场景一次拉全,与旧页面 pagingParams.pageSize 同值 */
|
||||
const FIND_LIMIT = 999999;
|
||||
|
||||
/** 类型判定所需的最小结构:文件视图与删除日志视图(回收站)共用同一套判定 */
|
||||
export interface KindSource {
|
||||
IsDirectoryIndc?: string;
|
||||
FileCategory?: string;
|
||||
OrderKey?: string;
|
||||
OrgFileName?: string;
|
||||
}
|
||||
|
||||
/** FileCategory 编码 -> 页面分类;缺省或未知编码走扩展名兜底 */
|
||||
function kindOf(record: DmsFileUploadViewDisplayData): FileKind {
|
||||
export function kindOf(record: KindSource): FileKind {
|
||||
if (record.IsDirectoryIndc === "Y") {
|
||||
return "folder";
|
||||
}
|
||||
@@ -55,8 +63,8 @@ function kindOf(record: DmsFileUploadViewDisplayData): FileKind {
|
||||
return "document";
|
||||
}
|
||||
|
||||
/** 兼容 "2026-05-13 08:17:00" 这类 iOS Date 解析失败的格式 */
|
||||
function parseTime(value: Date | string | undefined): number {
|
||||
/** 兼容 "2026-05-13 08:17:00" 这类 iOS Date 解析失败的格式(回收站列表同引擎) */
|
||||
export function parseTime(value: Date | string | undefined): number {
|
||||
if (!value) {
|
||||
return 0;
|
||||
}
|
||||
@@ -73,7 +81,7 @@ function parseTime(value: Date | string | undefined): number {
|
||||
}
|
||||
|
||||
/** 字节数 -> 可读大小文案;0/缺省返回空串(文件夹不显示大小) */
|
||||
function formatSize(bytes: number | undefined): string {
|
||||
export function formatSize(bytes: number | undefined): string {
|
||||
if (!bytes || bytes <= 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 回收站数据源:复刻旧 WebVue DmsFileDeleteEnquiry.vue 的调用链(D:\Docker\WebVue\src\views\master)
|
||||
*
|
||||
* 1. FindDmsFolderDeleteLogs() -> SelectOption[]:与文件页 FindDmsFolders 同构,
|
||||
* rps[0] 是空占位项、rps[1] 才是根目录(旧页面 rootPath = rps[1].BindValue);
|
||||
* 2. FindDeleteLogBySearchCriteria(criteria) -> data.Logs:Filter = [FilePath single 根路径],
|
||||
* 关键字追加 [OrgFileName text contain](旧 onSearchByName 两段过滤),limit 一次拉全;
|
||||
* 3. 类型/大小/时间判定复用 useDmsFiles 的 kindOf/formatSize/parseTime(删除日志视图字段同构);
|
||||
* 4. 动作对齐旧系统:RestoreDmsFile(FileId) 还原、DeleteLog(FileId) 彻底删除,
|
||||
* 均逐条循环提交(旧 onRestore / onDeleteLogSelected 的 for 循环原样保留)。
|
||||
*/
|
||||
import type { DmsFileUploadDeleteLogViewDisplayData } from "@/data/auto/dmsFileUploadDeleteLogViewDisplayData";
|
||||
import type { FileItem } from "@/data/custom/fileBrowser";
|
||||
import { useLocale } from "uview-pro";
|
||||
import { ref } from "vue";
|
||||
import { DmsFileSearchCriteria } from "@/data/auto/dmsFileSearchCriteria";
|
||||
import { DmsFileUploadDeleteLogViewDisplayData as DeleteLogView } from "@/data/auto/dmsFileUploadDeleteLogViewDisplayData";
|
||||
import { SearchFilter } from "@/data/auto/searchFilter";
|
||||
import dmsFileService from "@/service/master/dmsFileService";
|
||||
import { formatSize, kindOf, parseTime } from "./useDmsFiles";
|
||||
|
||||
/** 目录场景一次拉全,与旧页面 pagingParams.pageSize 同值 */
|
||||
const FIND_LIMIT = 999999;
|
||||
|
||||
export function useTrashFiles() {
|
||||
const { t } = useLocale();
|
||||
|
||||
const files = ref<FileItem[]>([]);
|
||||
/** FileId -> 原始删除记录:恢复/彻底删除按 FileId 提交 */
|
||||
const records = ref(new Map<number, DmsFileUploadDeleteLogViewDisplayData>());
|
||||
const loading = ref(false);
|
||||
const errorText = ref("");
|
||||
const rootPath = ref("");
|
||||
|
||||
/** 删除日志 -> 卡片条目:时间列展示的是删除时间(记录 CreatedDatetime),path 留原目录备查 */
|
||||
function toFileItem(record: DmsFileUploadDeleteLogViewDisplayData): FileItem {
|
||||
const time = parseTime(record.CreatedDatetime);
|
||||
const date = time ? new Date(time) : null;
|
||||
const now = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
let modifiedAt = "";
|
||||
if (date) {
|
||||
// 与文件页同款文案:今天显示「今天 HH:mm」,其余显示日期
|
||||
if (date.toDateString() === now.toDateString()) {
|
||||
modifiedAt = `${t("FilePage.todayLabel")} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
} else {
|
||||
modifiedAt = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: record.FileId,
|
||||
name: record.OrgFileName || record.FileName,
|
||||
kind: kindOf(record),
|
||||
// FileSize 单位是 KB(旧系统下载缓冲 (FileSize+1)*1024 字节佐证),先换算成字节
|
||||
sizeText: formatSize(
|
||||
record.FileSize ? record.FileSize * 1024 : undefined,
|
||||
),
|
||||
modifiedAt,
|
||||
sortTime: time,
|
||||
path: record.FilePath,
|
||||
};
|
||||
}
|
||||
|
||||
/** 查询:FilePath=根路径过滤(旧 onSearchByFilePath 语义,空路径查全量)+ 关键字 contain */
|
||||
async function load(keyword = ""): Promise<void> {
|
||||
loading.value = true;
|
||||
errorText.value = "";
|
||||
try {
|
||||
const criteria = new DmsFileSearchCriteria();
|
||||
const filters: SearchFilter[] = [];
|
||||
if (rootPath.value) {
|
||||
const pathFilter = new SearchFilter();
|
||||
pathFilter.Column = DeleteLogView.DB_FILE_PATH;
|
||||
pathFilter.Type = "SingleSelection";
|
||||
pathFilter.Operator = "single";
|
||||
pathFilter.Value = rootPath.value;
|
||||
filters.push(pathFilter);
|
||||
}
|
||||
if (keyword.trim()) {
|
||||
const nameFilter = new SearchFilter();
|
||||
nameFilter.Column = DeleteLogView.DB_ORG_FILE_NAME;
|
||||
nameFilter.Type = "text";
|
||||
nameFilter.Operator = "contain";
|
||||
nameFilter.Value = keyword.trim();
|
||||
filters.push(nameFilter);
|
||||
}
|
||||
criteria.Filter = filters;
|
||||
criteria.limit = FIND_LIMIT;
|
||||
criteria.page = 1;
|
||||
criteria.GridColumns = [];
|
||||
const data = await dmsFileService.FindDeleteLogBySearchCriteria(criteria);
|
||||
const rows = data?.Logs ?? [];
|
||||
files.value = rows.map(toFileItem);
|
||||
const nextRecords = new Map<
|
||||
number,
|
||||
DmsFileUploadDeleteLogViewDisplayData
|
||||
>();
|
||||
for (const row of rows) {
|
||||
nextRecords.set(row.FileId, row);
|
||||
}
|
||||
records.value = nextRecords;
|
||||
} catch (error: any) {
|
||||
// 错误提示已由 http.interceptor 统一 toast,这里只留页面内可见的失败态
|
||||
files.value = [];
|
||||
records.value = new Map();
|
||||
errorText.value = error?.message || t("FilePage.loadFailed");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 初始化:拉删除日志的目录选项拿根路径,再查回收站(旧 onMounted -> initBindOption -> onSearchByFilePath) */
|
||||
async function init(): Promise<void> {
|
||||
try {
|
||||
const options = (await dmsFileService.FindDmsFolderDeleteLogs()) ?? [];
|
||||
// rps[0] 是空占位项,rps[1] 才是根目录;只回一条时退回 [0](空 BindValue 查全量,与文件页同策略)
|
||||
const root = options.length > 1 ? options[1] : options[0];
|
||||
rootPath.value = root?.BindValue ?? "";
|
||||
await load();
|
||||
} catch (error: any) {
|
||||
errorText.value = error?.message || t("FilePage.loadFailed");
|
||||
}
|
||||
}
|
||||
|
||||
/** 还原(旧 onRestore):逐条 RestoreDmsFile,成功与否由调用方 toast/刷新 */
|
||||
async function restore(ids: number[]): Promise<void> {
|
||||
for (const id of ids) {
|
||||
await dmsFileService.RestoreDmsFile(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** 彻底删除(旧 onDeleteLogSelected):逐条 DeleteLog,物理删记录不可恢复 */
|
||||
async function purge(ids: number[]): Promise<void> {
|
||||
for (const id of ids) {
|
||||
await dmsFileService.DeleteLog(id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
files,
|
||||
records,
|
||||
loading,
|
||||
errorText,
|
||||
init,
|
||||
load,
|
||||
restore,
|
||||
purge,
|
||||
};
|
||||
}
|
||||
@@ -72,7 +72,50 @@
|
||||
"myCloud": "My Cloud",
|
||||
"loading": "Loading…",
|
||||
"loadFailed": "Failed to load, please retry",
|
||||
"retryBut": "Retry"
|
||||
"retryBut": "Retry",
|
||||
"renameTitle": "Rename",
|
||||
"renameConfirm": "Save",
|
||||
"renameDone": "Renamed",
|
||||
"deleteConfirmText": "Delete {count} item(s)? You can restore them in Trash",
|
||||
"deleteDone": "Deleted",
|
||||
"moveTitle": "Choose destination folder",
|
||||
"moveRoot": "My Drive",
|
||||
"moveDone": "Moved",
|
||||
"moveSelected": "{count} item(s) selected",
|
||||
"moveHere": "Move here",
|
||||
"moveNoSub": "No subfolders",
|
||||
"shareTitle": "Choose a family folder",
|
||||
"shareDone": "Shared",
|
||||
"shareNoFile": "No shareable files",
|
||||
"publishNoFile": "No publishable files",
|
||||
"publishUnsupported": "This file type cannot be published",
|
||||
"publishPickPhoto": "Choose a photo album",
|
||||
"publishPickAudio": "Choose an audio album",
|
||||
"publishPickVideo": "Choose a video album",
|
||||
"publishDone": "Published",
|
||||
"downloadNoFile": "No downloadable files",
|
||||
"downloadDone": "Downloaded",
|
||||
"downloading": "Downloading…",
|
||||
"previewNoImage": "No previewable images",
|
||||
"loadingMedia": "Preparing media…",
|
||||
"pleaseWait": "Working…",
|
||||
"actionFailed": "Operation failed",
|
||||
"pickerEmpty": "Nothing to choose",
|
||||
"playTitle": "Now playing",
|
||||
"detailName": "Name",
|
||||
"detailSize": "Size",
|
||||
"detailTime": "Time",
|
||||
"detailOrder": "Storage key"
|
||||
},
|
||||
"TrashPage": {
|
||||
"title": "Trash",
|
||||
"restore": "Restore",
|
||||
"purge": "Delete permanently",
|
||||
"restoreDone": "Restored",
|
||||
"purgeDone": "Permanently deleted",
|
||||
"purgeConfirmText": "Permanently delete {count} item(s)? This cannot be undone",
|
||||
"pickFirst": "Select items first",
|
||||
"emptyText": "Trash is empty"
|
||||
},
|
||||
"TransportPage": {
|
||||
"title": "Transfers",
|
||||
|
||||
@@ -72,7 +72,50 @@
|
||||
"myCloud": "我的云盘",
|
||||
"loading": "加载中…",
|
||||
"loadFailed": "加载失败,请重试",
|
||||
"retryBut": "重试"
|
||||
"retryBut": "重试",
|
||||
"renameTitle": "重命名",
|
||||
"renameConfirm": "保存",
|
||||
"renameDone": "已重命名",
|
||||
"deleteConfirmText": "确定删除 {count} 项?删除后可在回收站恢复",
|
||||
"deleteDone": "已删除",
|
||||
"moveTitle": "选择目标文件夹",
|
||||
"moveRoot": "我的网盘",
|
||||
"moveDone": "已移动",
|
||||
"moveSelected": "已选择 {count} 项",
|
||||
"moveHere": "移动到此",
|
||||
"moveNoSub": "没有子文件夹",
|
||||
"shareTitle": "选择家人目录",
|
||||
"shareDone": "共享成功",
|
||||
"shareNoFile": "没有可共享的文件",
|
||||
"publishNoFile": "没有可发布的文件",
|
||||
"publishUnsupported": "该类型文件不能发布",
|
||||
"publishPickPhoto": "选择相册",
|
||||
"publishPickAudio": "选择音乐集",
|
||||
"publishPickVideo": "选择影视集",
|
||||
"publishDone": "发布成功",
|
||||
"downloadNoFile": "没有可下载的文件",
|
||||
"downloadDone": "下载完成",
|
||||
"downloading": "下载中…",
|
||||
"previewNoImage": "没有可预览的图片",
|
||||
"loadingMedia": "媒体准备中…",
|
||||
"pleaseWait": "处理中…",
|
||||
"actionFailed": "操作失败",
|
||||
"pickerEmpty": "暂无可选项",
|
||||
"playTitle": "正在播放",
|
||||
"detailName": "名称",
|
||||
"detailSize": "大小",
|
||||
"detailTime": "时间",
|
||||
"detailOrder": "存储标识"
|
||||
},
|
||||
"TrashPage": {
|
||||
"title": "回收站",
|
||||
"restore": "恢复",
|
||||
"purge": "彻底删除",
|
||||
"restoreDone": "已恢复",
|
||||
"purgeDone": "已彻底删除",
|
||||
"purgeConfirmText": "将彻底删除 {count} 项,操作不可恢复",
|
||||
"pickFirst": "请先勾选条目",
|
||||
"emptyText": "回收站是空的"
|
||||
},
|
||||
"TransportPage": {
|
||||
"title": "传输列表",
|
||||
|
||||
@@ -47,6 +47,14 @@
|
||||
// 传输列表:uploadQueue store 的视图页,选文件上传后自动跳入
|
||||
{
|
||||
"path": "pages/transport/transport"
|
||||
},
|
||||
// 媒体播放页(短地址 /play):文件页视频/音频播放动作的目标页
|
||||
{
|
||||
"path": "pages/media/play/play"
|
||||
},
|
||||
// 回收站(短地址 /trash):文件页快捷入口跳入,还原/彻底删除
|
||||
{
|
||||
"path": "pages/trash/trash"
|
||||
}
|
||||
],
|
||||
// 全局配置
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -26,6 +26,10 @@ export const PAGE_PATHS = {
|
||||
mineTab: "pages/tab/mine",
|
||||
// 传输列表(短地址 /transport):与文件页同一功能域
|
||||
transport: "pages/transport/transport",
|
||||
// 媒体播放页(短地址 /play):文件页视频/音频动作跳入,query 带 type/name/url
|
||||
mediaPlay: "pages/media/play/play",
|
||||
// 回收站(短地址 /trash):文件页快捷入口跳入,还原/彻底删除
|
||||
trash: "pages/trash/trash",
|
||||
} as const;
|
||||
|
||||
export type PageName = keyof typeof PAGE_PATHS;
|
||||
@@ -79,6 +83,8 @@ export const PAGE_PERMISSIONS: Partial<Record<PageName, PermissionNames>> = {
|
||||
shareTab: "DmsFile",
|
||||
mineTab: "DmsFile",
|
||||
transport: "DmsFile",
|
||||
mediaPlay: "DmsFile",
|
||||
trash: "DmsFile",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -80,7 +80,7 @@ function resolveChunkSize(): number {
|
||||
* App 侧 UploadTask 的 multipart filename 取 filePath 的 basename,无法直接满后端
|
||||
* uid-序号-总数.ext 协议(同 H5 踩过的 IndexOutOfRange),先把文件复制成
|
||||
* _doc/uploads/uid-1-1.ext 再传;真机验证前不引入分片循环(读不了本地字节切片)。
|
||||
* 项目 types 未含 plus 声明,本地宽松兑底(仅 App 运行时真实存在)。
|
||||
* 项目 types 未含 plus 声明,本地宽松兜底(仅 App 运行时真实存在)。
|
||||
*/
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
declare const plus: any;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* DMS 文件字节取回(对齐旧 WebVue DmsFileEnquiry 的下载链 GetOrgPictureFile/DownloadFileCombine):
|
||||
* - 视频且 Url 有值:原码文件直接按 URL 取回(旧 downloadByUrl 通道);
|
||||
* - 其余:FindByKey 拿实体 → OrderKey 按 "-" 拆出 [前缀..., 分片号, 总片数.扩展名],
|
||||
* 分片号从 1 循环到总片数逐片 DownloadFileCombine,写进 (FileSize+1)*1024 的缓冲拼回整文件
|
||||
* (FileSize 单位 KB,旧系统同款缓冲预分配)。
|
||||
* 落盘/播放双出口:saveFileBytes 走拦截器 saveArrayBuffer;toPlayableUrl 给预览与播放页
|
||||
* (H5 是 blob: URL,小程序/App 写本地用户目录返回文件路径)。
|
||||
*/
|
||||
import { requestBuffer, saveArrayBuffer } from "@/common/http.interceptor";
|
||||
import { FileTypePolicy } from "@/policy/custom/fileTypePolicy";
|
||||
import dmsFileService from "@/service/master/dmsFileService";
|
||||
|
||||
/** 取回结果:整文件字节 + 展示文件名(下载落盘与生成播放地址共用) */
|
||||
export interface FileBytes {
|
||||
bytes: ArrayBuffer;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** 分片取回并拼整(非视频通道,参数是 FindByKey 回的文件实体) */
|
||||
async function fetchCombineBytes(
|
||||
orderKey: string,
|
||||
fileSizeKb: number,
|
||||
): Promise<ArrayBuffer> {
|
||||
const segments = orderKey.split("-");
|
||||
const total =
|
||||
Number.parseInt(segments[segments.length - 1].split(".")[0], 10) || 1;
|
||||
const merged = new Uint8Array((fileSizeKb + 1) * 1024);
|
||||
let offset = 0;
|
||||
// 串行逐片:并发打多片旧系统没做、后端也没限流承诺,跟旧行为保持一致
|
||||
for (let index = 1; index <= total; index += 1) {
|
||||
segments[segments.length - 2] = String(index);
|
||||
const chunk = await dmsFileService.DownloadFileCombine(segments.join("-"));
|
||||
merged.set(new Uint8Array(chunk as ArrayBuffer), offset);
|
||||
offset += (chunk as ArrayBuffer).byteLength;
|
||||
}
|
||||
// 缓冲按 (FileSize+1)KB 预分配可能比真实字节多几字节,按实际写入长度截断
|
||||
return merged.slice(0, offset).buffer as ArrayBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按记录取回整文件字节:
|
||||
* record 传列表页的原始记录(DmsFileUploadViewDisplayData 全字段)
|
||||
*/
|
||||
export async function fetchFileBytes(record: {
|
||||
FileId: number;
|
||||
Url?: string;
|
||||
OrderKey?: string;
|
||||
FileSize?: number;
|
||||
FileCategory?: string;
|
||||
OrgFileName?: string;
|
||||
FileName?: string;
|
||||
}): Promise<FileBytes> {
|
||||
const name = record.OrgFileName || record.FileName || "file";
|
||||
// 视频且 Url 直指原码:跳过分片通道(旧 onDownloadFile 的视频分支)
|
||||
if (record.Url && record.FileCategory === FileTypePolicy.VIDEO) {
|
||||
const bytes = await requestBuffer({ url: record.Url, method: "GET" });
|
||||
return { bytes, name };
|
||||
}
|
||||
const entity = await dmsFileService.FindByKey(record.FileId);
|
||||
const bytes = await fetchCombineBytes(
|
||||
entity.OrderKey,
|
||||
Number(entity.FileSize) || 0,
|
||||
);
|
||||
return { bytes, name: entity.OrgFileName || entity.FileName || name };
|
||||
}
|
||||
|
||||
/** 字节落盘为原始文件名(H5 <a download>;小程序/App 写目录后 openDocument) */
|
||||
export async function saveFileBytes(file: FileBytes): Promise<void> {
|
||||
await saveArrayBuffer(file.bytes, file.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成可预览/可播放的地址:
|
||||
* H5 返回 blob: URL(用完交给调用方 revoke,播放页 onUnload 处理);
|
||||
* 小程序/App 写本地用户目录返回文件路径(video/audio/image 组件都吃)
|
||||
*/
|
||||
export function toPlayableUrl(file: FileBytes): string {
|
||||
// #ifdef H5
|
||||
return URL.createObjectURL(new Blob([file.bytes]));
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
let userDataPath = "";
|
||||
// #ifdef MP-WEIXIN
|
||||
userDataPath = (uni as any).env?.USER_DATA_PATH ?? "";
|
||||
// #endif
|
||||
// #ifdef APP-PLUS
|
||||
userDataPath = "_doc";
|
||||
// #endif
|
||||
const filePath = `${userDataPath}/${file.name}`;
|
||||
uni
|
||||
.getFileSystemManager()
|
||||
.writeFileSync(filePath, file.bytes, undefined as any);
|
||||
return filePath;
|
||||
// #endif
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 播放上下文总线:文件页 → 播放页传递播放地址的通道。
|
||||
* 不走 navigateTo query:H5 的 blob: 地址含 "://" 且拼接后很长,
|
||||
* 经 query 预编码再被路由二次编码会令跳转静默失败(本机实测踩过)。
|
||||
* 同一次 JS 运行内模块级单例直接传值,query 只留展示名做直连访问兜底。
|
||||
*/
|
||||
import { ref } from "vue";
|
||||
|
||||
export interface PlaybackContext {
|
||||
type: "video" | "audio";
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const current = ref<PlaybackContext | null>(null);
|
||||
|
||||
export function setPlayback(ctx: PlaybackContext): void {
|
||||
current.value = ctx;
|
||||
}
|
||||
|
||||
/** 读取并清空(一次性消费,防止陈旧上下文污染下一次直连访问) */
|
||||
export function takePlayback(): PlaybackContext | null {
|
||||
const ctx = current.value;
|
||||
current.value = null;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** 丢弃待播上下文(跳转失败时清脏,不让陈旧 blob 地址滞留) */
|
||||
export function clearPlayback(): void {
|
||||
current.value = null;
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { RequestMeta, RequestOptions } from "uview-pro";
|
||||
import { http } from "uview-pro";
|
||||
import { downloadFile, uploadFile } from "@/common/http.interceptor";
|
||||
import {
|
||||
downloadFile,
|
||||
requestBuffer,
|
||||
uploadFile,
|
||||
} from "@/common/http.interceptor";
|
||||
|
||||
/**
|
||||
* 代码生成的 service 层(src/service/**)专用适配入口
|
||||
@@ -46,9 +50,19 @@ export interface UploadConfigLite {
|
||||
|
||||
/**
|
||||
* 通用请求:返回后端 JSON body(LoginProfile / XxxResultData 等实体结构)
|
||||
* 401 续期、业务异常提示均由 common/http.interceptor 统一处理
|
||||
* 401 续期、业务异常提示均由 common/http.interceptor 统一处理。
|
||||
* 生成器给 DownloadFile 系列(Combine/Preview)标了 responseType: "arraybuffer",
|
||||
* uview-pro http 通道不透传它——命中时改走拦截器的裸 buffer 旁路(含同款 JSON 错误探测),
|
||||
* service 层生成产物保持零修改。
|
||||
*/
|
||||
export async function request<T = any>(config: RequestConfigLite): Promise<T> {
|
||||
if (config.responseType === "arraybuffer") {
|
||||
return requestBuffer({
|
||||
url: config.url,
|
||||
method: String(config.method ?? "post").toUpperCase() as "GET" | "POST",
|
||||
data: config.data,
|
||||
}) as Promise<T>;
|
||||
}
|
||||
return http.request<T>({
|
||||
url: config.url,
|
||||
// uni.request 要求 method 大写,生成器写的是小写,这里统一归一化
|
||||
|
||||
Reference in new issue
Block a user