This commit is contained in:
owenchen committed 2026-10-15 13:08:28 +08:00
1 parent ceb3e7beec
commit f6f3735ab3
29 files changed
+8376 -140

No files matched your search

@@ -84,11 +84,24 @@ function onCardTap(file: FileItem): void {
</view>
<view class="fgl-card__body" @tap="onCardTap(file)">
<!-- 文件夹:纯 CSS 造型(图标库没有 folder),主体 + 左上页签 -->
<view v-if="file.kind === 'folder'" class="fgl-card__folder">
<view class="fgl-card__folder-tab" />
<!-- 图标区包装:共享域的权限/收藏角标叠加在图标右上角(旧 MyComputerFamily 同款) -->
<view class="fgl-card__icon-wrap">
<!-- 文件夹:纯 CSS 造型(图标库没有 folder),主体 + 左上页签 -->
<view v-if="file.kind === 'folder'" class="fgl-card__folder">
<view class="fgl-card__folder-tab" />
</view>
<u-icon v-else :name="visualOf(file.kind)!.icon" size="88" :color="visualOf(file.kind)!.color" />
<!-- 锁仅目录显示(旧系统:文件不挂锁):绿开锁=有权限,灰闭锁=无权限;
蓝分享标=已被收藏共享,两者文件/目录都可显示;
已共享的目录不再叠绿开锁(开锁是有权限的子集,两个图标同屏冗余) -->
<view v-if="(file.kind === 'folder' && file.permission && !file.shared) || file.shared"
class="fgl-card__badges">
<u-icon v-if="file.kind === 'folder' && file.permission && !file.shared"
:name="file.permission === 'Y' ? 'lock-open' : 'lock'" size="28"
:color="file.permission === 'Y' ? '#52c41a' : '#8c8c8c'" />
<u-icon v-if="file.shared" name="share-fill" size="28" color="#1890ff" />
</view>
</view>
<u-icon v-else :name="visualOf(file.kind)!.icon" size="88" :color="visualOf(file.kind)!.color" />
<view class="fgl-card__info">
<text class="fgl-card__name">
@@ -165,6 +178,23 @@ function onCardTap(file: FileItem): void {
padding: 36rpx 12rpx 0;
}
/* 图标区包装:角标 absolute 定位的参照系 */
.fgl-card__icon-wrap {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
/* 共享角标组:压在图标右上角(旧 MyComputerFamily badge 同位) */
.fgl-card__badges {
position: absolute;
top: -8rpx;
right: -44rpx;
display: flex;
gap: 4rpx;
}
/* ---- 纯 CSS 文件夹:蓝色渐变圆角主体 + 左上页签 ---- */
.fgl-card__folder {
position: relative;
@@ -263,6 +293,12 @@ function onCardTap(file: FileItem): void {
border-radius: 6rpx 6rpx 0 0;
}
/* 列表态图标小(72rpx),角标组贴紧一点不出格 */
.file-grid-list--list .fgl-card__badges {
top: -12rpx;
right: -8rpx;
}
.file-grid-list--list .fgl-card__info {
flex: 1;
min-width: 0;
@@ -36,6 +36,11 @@ defineProps({
type: Boolean,
default: true,
},
/** 多选开关按钮:无批量动作的页面(如共享大厅)传 false */
showSelect: {
type: Boolean,
default: true,
},
});
const emit = defineEmits<{
@@ -77,8 +82,8 @@ const sortIconDark = sortGlyph("#1f2329");
<view class="file-tab-filter__tool" @tap="emit('toggle-view')">
<u-icon :name="viewMode === 'grid' ? 'list' : 'grid-fill'" size="36" color="#1f2329" />
</view>
<!-- 多选开关:未进入给自绘方框箭头字形,进入后给关闭圆(高亮蓝) -->
<view class="file-tab-filter__tool" @tap="emit('toggle-select')">
<!-- 多选开关:未进入给自绘方框箭头字形,进入后给关闭圆(高亮蓝);无批量动作的页面隐藏 -->
<view v-if="showSelect" class="file-tab-filter__tool" @tap="emit('toggle-select')">
<image v-if="!selectMode" class="file-tab-filter__tool-img" :src="selectIconDark" mode="aspectFit" />
<u-icon v-else name="close-circle" size="36" color="#2b8fff" />
</view>
@@ -34,6 +34,11 @@ const props = defineProps({
type: String,
default: "",
},
/** 输入框占位文案:缺省沿用「新建文件夹」语义,群描述/申请原因等场景由页面覆写 */
placeholder: {
type: String,
default: "",
},
});
const emit = defineEmits<{
@@ -75,8 +80,9 @@ function onConfirm(): void {
<text class="folder-name-dialog__title">
{{ 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">
<input v-model="name" class="folder-name-dialog__input"
:placeholder="props.placeholder || t('FilePage.folderPlaceholder')" :maxlength="50" :disabled="props.submitting"
confirm-type="done" @confirm="onConfirm">
<view class="folder-name-dialog__actions">
<view class="folder-name-dialog__btn" @click="emit('close')">
<text class="folder-name-dialog__btn-text">
@@ -0,0 +1,392 @@
<script setup lang="ts">
/**
* 共享家人目标选择器(底部抽屉,共享大厅同款两态):顶层列家人群组卡片
* (图2 样式),点群组进组后逐级浏览目录,「共享到此」提交当前所在层。
* 替代旧的收藏夹单选列表——目标不再限定收藏过的目录,群组根与任何有权限
* 的目录都能接收共享(后端 CreateFamilyGroupFolder 纯插入不校验目录)。
* 数据链与共享大厅同口径:FindFamilyDmsFolders 拉群组,FindFamilyBySearchCriteria
* 挂 [family_name + file_path] 两段(组根层标识 = 群组编号,子层 = 目录系统名)。
* 组件不发共享请求:点「共享到此」把当前层三元组 confirm 给页面,
* 页面按每个待共享文件组装 FamilyGroupFolderEntity 批量提交(多选文件一次发)。
*/
import type { PathNode } from "@/data/custom/fileBrowser";
import { useLocale } from "uview-pro";
import { computed, ref, watch } from "vue";
import { DmsFileSearchCriteria } from "@/data/auto/dmsFileSearchCriteria";
import { FamilyDmsFileViewDisplayData } from "@/data/auto/familyDmsFileViewDisplayData";
import { SearchFilter } from "@/data/auto/searchFilter";
import dmsFileService from "@/service/master/dmsFileService";
/** 共享目标层:组 + 顶层目录系统名(旧系统取消共享按它查权限)+ 当前目录标识 */
export interface ShareTarget {
familyName: string;
topFolderName: string;
filePath: string;
}
/** 浏览路径的一段:value = 群组编号(组根)或目录系统名(子层) */
interface CrumbRow {
value: string;
displayName: string;
}
const props = defineProps({
/** 抽屉是否展开 */
visible: {
type: Boolean,
default: false,
},
/** 已选待共享文件数(顶部「已选择 N 项」提示) */
selectedCount: {
type: Number,
default: 0,
},
});
const emit = defineEmits<{
(e: "close"): void;
/** 点「共享到此」,带上当前所在层的目标三元组 */
(e: "confirm", target: ShareTarget): void;
}>();
const { t } = useLocale();
const loading = ref(false);
/** 大厅顶层:群组卡列表(value = 群组编号) */
const groups = ref<Array<{ value: string; label: string }>>([]);
/** 组内浏览路径栈:[0] 恒为组根(群组编号),末项 = 当前所在目录 */
const trail = ref<CrumbRow[]>([]);
/** 当前群组编号;空串 = 停在大厅顶层 */
const familyName = ref("");
/** 当前层子目录行(只列目录,文件不能当共享目标) */
const dirs = ref<Array<{ value: string; label: string; permitted: boolean }>>([]);
const inGroup = computed(() => !!trail.value.length);
/** 「共享到此」只在进入下一层目录后出现:群组名那一层(trail 只有组根一段)不允许直接共享 */
const canShareHere = computed(() => trail.value.length >= 2);
/** 「共享到此」的目标三元组:组根 TopFolderName = 群组编号,子层 = 路径首段目录 */
const currentTarget = computed<ShareTarget | null>(() => {
if (!inGroup.value) {
return null;
}
return {
familyName: familyName.value,
topFolderName: trail.value[0].value,
filePath: trail.value[trail.value.length - 1].value,
};
});
// 每次展开重拉群组并回大厅顶层:群组可能被别的端改过,缓存会弹进已消失的坑
watch(
() => props.visible,
async (on) => {
if (on) {
trail.value = [];
familyName.value = "";
await loadGroups();
}
},
);
async function loadGroups(): Promise<void> {
loading.value = true;
try {
const options = (await dmsFileService.FindFamilyDmsFolders()) ?? [];
groups.value = options.map(opt => ({ value: opt.BindValue, label: opt.DisplayName || opt.BindValue }));
}
finally {
loading.value = false;
}
}
/** 查当前层子目录:两段过滤与共享大厅 loadFolder 同口径(组根标识=群组编号照挂) */
async function loadDirs(): Promise<void> {
loading.value = true;
try {
const criteria = new DmsFileSearchCriteria();
const familyFilter = new SearchFilter();
familyFilter.Column = FamilyDmsFileViewDisplayData.DB_FAMILY_NAME;
familyFilter.Type = "SingleSelection";
familyFilter.Operator = "single";
familyFilter.Value = familyName.value;
const pathFilter = new SearchFilter();
pathFilter.Column = FamilyDmsFileViewDisplayData.DB_FILE_PATH;
pathFilter.Type = "SingleSelection";
pathFilter.Operator = "single";
pathFilter.Value = trail.value[trail.value.length - 1].value;
criteria.Filter = [familyFilter, pathFilter];
criteria.limit = 999999;
criteria.page = 1;
criteria.GridColumns = [];
const data = await dmsFileService.FindFamilyBySearchCriteria(criteria);
dirs.value = (data?.Families ?? [])
.filter(r => r.IsDirectoryIndc === "Y")
.map(r => ({
value: r.FileName,
label: r.OrgFileName || r.FileName,
permitted: r.PermissionIndc === "Y",
}))
.sort((a, b) => a.label.localeCompare(b.label, "zh-CN"));
}
finally {
loading.value = false;
}
}
function enterGroup(group: { value: string; label: string }): void {
familyName.value = group.value;
trail.value = [{ value: group.value, displayName: group.label }];
dirs.value = [];
void loadDirs();
}
/** 下钻子目录;无权限的目录进不去(与共享大厅 PermissionIndc 口径一致) */
function enterDir(row: { value: string; label: string; permitted: boolean }): void {
if (!row.permitted) {
uni.showToast({ title: t("ShareHallPage.noPermission"), icon: "none" });
return;
}
trail.value = [...trail.value, { value: row.value, displayName: row.label }];
void loadDirs();
}
/** 组内面包屑:首段=群组名,单击回该层(当前段组件内自行不响应) */
const crumbItems = computed<PathNode[]>(() => trail.value.map(row => ({
value: row.value,
displayName: row.displayName,
})));
function onJump(index: number): void {
trail.value = trail.value.slice(0, index + 1);
void loadDirs();
}
function onShareHere(): void {
const target = currentTarget.value;
if (target) {
emit("confirm", target);
}
}
</script>
<template>
<u-popup :model-value="props.visible" mode="bottom" round="24" @close="emit('close')">
<view class="folder-share-picker">
<!-- 头部:大厅层显示标题,组内显示面包屑(单击回层、超长自动折叠)+ 关闭 -->
<view class="folder-share-picker__head">
<text v-if="!inGroup" class="folder-share-picker__title">
{{ t("FilePage.shareTitle") }}
</text>
<view v-else class="folder-share-picker__crumb">
<file-breadcrumb :items="crumbItems" :show-single="true" @nav="onJump" />
</view>
<view class="folder-share-picker__close" @tap="emit('close')">
<u-icon name="close" size="36" color="#8f959e" />
</view>
</view>
<text v-if="props.selectedCount" class="folder-share-picker__picked">
{{ t("FilePage.moveSelected").replace("{count}", String(props.selectedCount)) }}
</text>
<scroll-view scroll-y class="folder-share-picker__list">
<view v-if="loading" class="folder-share-picker__empty">
<u-loading mode="circle" />
</view>
<template v-else>
<!-- 大厅顶层:群组卡片(共享大厅图2 同款:浅灰圆卡 + 蓝色文件夹) -->
<template v-if="!inGroup">
<view v-if="!groups.length" class="folder-share-picker__empty">
<text class="folder-share-picker__empty-text">
{{ t("FilePage.pickerEmpty") }}
</text>
</view>
<view v-for="g in groups" :key="g.value" class="folder-share-picker__group"
@tap="enterGroup(g)">
<view class="folder-share-picker__folder" />
<text class="folder-share-picker__group-text">
{{ g.label }}
</text>
</view>
</template>
<!-- 组内:子目录行(点行进下层,「共享到此」提交当前层) -->
<template v-else>
<view v-if="!dirs.length" class="folder-share-picker__empty">
<text class="folder-share-picker__empty-text">
{{ t("FilePage.moveNoSub") }}
</text>
</view>
<view v-for="row in dirs" :key="row.value" class="folder-share-picker__row"
@tap="enterDir(row)">
<view class="folder-share-picker__folder" />
<text class="folder-share-picker__row-text"
:class="{ 'folder-share-picker__row-text--dim': !row.permitted }">
{{ row.label }}
</text>
<u-icon v-if="!row.permitted" name="lock" size="26" color="#8c8c8c" />
<u-icon v-else name="arrow-right" size="26" color="#c0c4cc" />
</view>
</template>
</template>
</scroll-view>
<!-- 底部:通栏「共享到此」提交当前层(群组名那一层不显示——必须进到下一层目录才能共享) -->
<view v-if="canShareHere" class="folder-share-picker__footer">
<view class="folder-share-picker__btn" @tap="onShareHere">
<text class="folder-share-picker__btn-text">
{{ t("FilePage.shareHere") }}
</text>
</view>
</view>
<view class="folder-share-picker__safe" />
</view>
</u-popup>
</template>
<style lang="scss" scoped>
.folder-share-picker {
padding: 8rpx 32rpx 0;
background-color: #ffffff;
&__head {
display: flex;
align-items: center;
height: 108rpx;
}
&__title {
flex: 1;
font-size: 32rpx;
font-weight: 600;
color: #1f2329;
text-align: center;
}
/* 面包屑占满剩余宽度(自身可收缩,超长时组件内折叠出 …) */
&__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;
}
/* 群组卡片:共享大厅顶层同款浅灰圆底行 */
&__group {
display: flex;
align-items: center;
gap: 24rpx;
margin-bottom: 16rpx;
padding: 28rpx 24rpx;
border-radius: 16rpx;
background-color: #f7f8fa;
}
&__group-text {
flex: 1;
overflow: hidden;
font-size: 30rpx;
font-weight: 500;
color: #1f2329;
text-overflow: ellipsis;
white-space: nowrap;
}
&__row {
display: flex;
align-items: center;
gap: 20rpx;
padding: 24rpx 12rpx;
border-radius: 16rpx;
}
&__row-text {
display: flex;
flex: 1;
overflow: hidden;
font-size: 30rpx;
color: #1f2329;
text-overflow: ellipsis;
white-space: nowrap;
&--dim {
color: #9aa0a6;
}
}
/* CSS 画文件夹(图标库无 folder 字形,同 folder-move-picker / 图2 蓝色系) */
&__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: "";
}
}
&__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-text {
font-size: 28rpx;
font-weight: 600;
color: #ffffff;
}
&__safe {
height: calc(24rpx + env(safe-area-inset-bottom));
}
}
</style>
@@ -47,6 +47,11 @@ const props = defineProps({
type: Boolean,
default: true,
},
/** 搜索图标开关:无搜索场景的页面/层级传 false(如共享大厅顶层群组卡列表) */
showSearchToggle: {
type: Boolean,
default: true,
},
});
const emit = defineEmits<{
@@ -78,8 +83,8 @@ const transferIcon = computed(() => transferGlyph(props.transferring ? TRANSFER_
{{ props.transferring > 99 ? "99+" : props.transferring }}
</text>
</view>
<!-- 切换搜索框显隐 -->
<view class="page-navbar__btn" @tap="emit('toggle-search')">
<!-- 切换搜索框显隐;无搜索场景的层级隐藏 -->
<view v-if="props.showSearchToggle" class="page-navbar__btn" @tap="emit('toggle-search')">
<u-icon name="search" size="38" :color="props.searchOn ? '#2b8fff' : '#8f959e'" />
</view>
<!-- 切换快捷入口区显隐;无快捷入口的页面隐藏 -->
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+4
View File
@@ -3,6 +3,10 @@ export * from "./useAdminMenu";
export * from "./useAdminPick";
export * from "./useAuthGuard";
export * from "./useDmsFiles";
export * from "./useFamilyGroups";
export * from "./useLang";
export * from "./useMediaShow";
export * from "./usePublishAlbums";
export * from "./useShareHall";
export * from "./useThemeGradient";
export * from "./useTrashFiles";
+16 -13
View File
@@ -95,6 +95,21 @@ export function formatSize(bytes: number | undefined): string {
return `${i === 0 ? value : value.toFixed(1)} ${units[i]}`;
}
/** 卡片时间文案(文件页/回收站/共享大厅共用):今天「{todayLabel} HH:mm」,其余 YYYY-MM-DD */
export function formatModifiedLabel(time: number, todayLabel: string): string {
if (!time) {
return "";
}
const date = new Date(time);
const now = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
// if/else 写法防编辑器折行
if (date.toDateString() === now.toDateString()) {
return `${todayLabel} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
export function useDmsFiles() {
const { t } = useLocale();
@@ -112,18 +127,6 @@ export function useDmsFiles() {
function toFileItem(record: DmsFileUploadViewDisplayData): 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/else 写法防编辑器折行
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,
@@ -133,7 +136,7 @@ export function useDmsFiles() {
sizeText: formatSize(
record.FileSize ? record.FileSize * 1024 : undefined,
),
modifiedAt,
modifiedAt: formatModifiedLabel(time, t("FilePage.todayLabel")),
sortTime: time,
path: record.FileName,
};
@@ -0,0 +1,353 @@
/**
* 家人群组数据源:把旧 WebVue 的 FamilyGroupEnquiry.vue(列表 + 申请加入)与
* FamilyGroupOperation.vue(建群/改群 + 共享目录 + 待审成员 + 正式成员 + 目录权限)
* 两套页面的调用链合并到一个 composable,供「共享」tab 页两态复用。
*
* 后端契约(CloudBuilder.Master.Service/FamilyGroupService.auto.cs 实测):
* 1. 列表 FindBySearchCriteria -> data.Datas:后端自动追加「有效 且(公开 或 我是成员)」,
* 所以列表天然= 公开群 + 我的群,不用前端再筛;关键字挂 [family_local_name text contain]。
* 2. 详情 FindByKey(familyName) -> FamilyGroupData:
* - 非管理员时 FamilyGroupPermissions=null、部分 Managers=null、Folders 的 IsSharePermission 标记有无权限;
* - 管理员才有 FamilyGroupRequests(待审)与完整 Managers/Permissions;
* - 隐私群非成员返回空 FamilyGroupData(FamilyGroup.FamilyName 为空串当作没数据)。
* 3. 建群 Insert / 改群 Update:整个 FamilyGroupData 回传,目录靠 FolderId 匹配(新项 FolderId 递增、
* CreatedBy 空串=后端插入)、成员靠 IsManager 布尔改 ManagerIndc、权限在 Update 里不处理(走即时接口)。
* 4. 即时动作接口:
* - 申请加入 InsertFamilyGroupManager({FamilyName, Notice})(用户名后端取当前登录者,忽略入参);
* - 同意 UpdateFamilyGroupManager({FamilyName, UserName, ValidIndc:"Y"});
* - 拒绝/移除成员 DeleteFamilyGroupManager({FamilyName, UserName});
* - 分配目录权限 InsertFamilyGroupPermission([{FamilyName, FolderName, UserName}]);
* - 解散群 Delete(familyName)(后端校验必须是管理员)。
* 5. 删已入库的共享目录前先 FindDmsFolderMapByFolderName(folderName) 查有无内容,有则拦(旧系统同逻辑)。
*/
import type { FamilyGroupData } from "@/data/auto/familyGroupData";
import type { FamilyGroupEntity } from "@/data/auto/familyGroupEntity";
import type { FamilyGroupFolderDisplayData } from "@/data/auto/familyGroupFolderDisplayData";
import type { FamilyGroupManagerDisplayData } from "@/data/auto/familyGroupManagerDisplayData";
import { useLocale } from "uview-pro";
import { computed, ref } from "vue";
import { FamilyGroupData as FamilyGroupDataModel } from "@/data/auto/familyGroupData";
import { FamilyGroupFolderDisplayData as FolderRow } from "@/data/auto/familyGroupFolderDisplayData";
import { FamilyGroupManagerEntity } from "@/data/auto/familyGroupManagerEntity";
import { FamilyGroupPermissionEntity } from "@/data/auto/familyGroupPermissionEntity";
import { FamilyGroupSearchCriteria } from "@/data/auto/familyGroupSearchCriteria";
import { SearchFilter } from "@/data/auto/searchFilter";
import dmsFileService from "@/service/master/dmsFileService";
import familyGroupService from "@/service/master/familyGroupService";
/** 列表一次拉全(旧页面分页在移动端不需要,划到底再考虑) */
const FIND_LIMIT = 999999;
/** 创建日期只取年月日:卡片展示用,不需要 formatModifiedLabel 的「今天」语义 */
function formatDate(value: Date | string | undefined): string {
if (!value) {
return "";
}
const time =
typeof value === "string" ? new Date(value).getTime() : value.getTime();
if (!time) {
return "";
}
const d = new Date(time);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
export function useFamilyGroups() {
const { t } = useLocale();
// ===== 列表态 =====
const groups = ref<FamilyGroupEntity[]>([]);
const loading = ref(false);
const errorText = ref("");
// ===== 详情态(FamilyGroupOperation)=====
const detail = ref<FamilyGroupData>(new FamilyGroupDataModel());
/** 正在新建(还没落库的空白群):详情按编辑态渲染,保存走 Insert */
const creating = ref(false);
/** 编辑态:字段可改、目录/成员/权限可增删,出现保存/取消 */
const isEdit = ref(false);
// 当前登录用户名:判断「我是不是某成员」「我还没加入」用
const myName = ref("");
/** 是否停在群管理详情(false=群组列表) */
const inDetail = computed(
() => !!detail.value.FamilyGroup?.FamilyName || creating.value,
);
/** 当前用户是否群管理员(后端 FindByKey 权威回填,比前端猜可靠) */
const isManager = computed(() => !!detail.value.IsManager);
/** 是否已是成员:正式成员名单里有我(非管理员时后端只回我自己的那条) */
const isMember = computed(() =>
(detail.value.FamilyGroupManagers ?? []).some(
(m) => m.UserName === myName.value,
),
);
function setMyUser(name: string): void {
myName.value = name;
}
// ===== 列表 =====
async function loadList(keyword = ""): Promise<void> {
loading.value = true;
errorText.value = "";
try {
const criteria = new FamilyGroupSearchCriteria();
const filters: SearchFilter[] = [];
if (keyword.trim()) {
const kw = new SearchFilter();
kw.Column = "family_local_name";
kw.Type = "text";
kw.Operator = "contain";
kw.Value = keyword.trim();
filters.push(kw);
}
criteria.Filter = filters;
criteria.limit = FIND_LIMIT;
criteria.page = 1;
criteria.GridColumns = [];
const data = await familyGroupService.FindBySearchCriteria(criteria);
groups.value = data?.Datas ?? [];
} catch (error: any) {
groups.value = [];
errorText.value = error?.message || t("FilePage.loadFailed");
} finally {
loading.value = false;
}
}
// ===== 进详情 =====
async function openGroup(familyName: string): Promise<void> {
loading.value = true;
errorText.value = "";
try {
const data = await familyGroupService.FindByKey(familyName);
// 隐私群非成员 / 记录不存在:后端回空壳(FamilyName 空串),页面按「无权限查看」处理
if (!data?.FamilyGroup?.FamilyName) {
detail.value = new FamilyGroupDataModel();
errorText.value = t("SharePage.noAccess");
return;
}
detail.value = normalize(data);
} catch (error: any) {
errorText.value = error?.message || t("FilePage.loadFailed");
} finally {
loading.value = false;
}
}
/** 后端可能把非管理员的 Managers/Permissions/Requests 置 null,统一补空数组供模板遍历 */
function normalize(data: FamilyGroupData): FamilyGroupData {
data.FamilyGroupFolders = data.FamilyGroupFolders ?? [];
data.FamilyGroupManagers = data.FamilyGroupManagers ?? [];
data.FamilyGroupRequests = data.FamilyGroupRequests ?? [];
data.FamilyGroupPermissions = data.FamilyGroupPermissions ?? [];
return data;
}
/** 退出详情回列表:可能刚改过群,重拉列表 */
async function exitDetail(): Promise<void> {
detail.value = new FamilyGroupDataModel();
creating.value = false;
isEdit.value = false;
await loadList();
}
// ===== 新建群 =====
function startCreate(): void {
const blank = new FamilyGroupDataModel();
blank.IsOpen = true;
blank.IsAdd = true;
detail.value = blank;
creating.value = true;
isEdit.value = true;
}
/** 进入编辑(仅管理员):快照原值便于取消还原 */
function startEdit(): void {
isEdit.value = true;
}
/** 取消编辑:新建态直接退出详情,改群态重新拉详情丢弃本地改动 */
async function cancelEdit(): Promise<void> {
if (creating.value) {
await exitDetail();
return;
}
isEdit.value = false;
await openGroup(detail.value.FamilyGroup.FamilyName);
}
// ===== 共享目录(挂在 FamilyGroupData.FamilyGroupFolders,保存时后端落库)=====
function addFolder(localName: string, notice = ""): void {
const folders = detail.value.FamilyGroupFolders ?? [];
const maxId = folders.reduce((max, f) => Math.max(max, f.FolderId || 0), 0);
const row = new FolderRow();
row.FolderId = maxId + 1;
row.FolderLocalName = localName;
row.Notice = notice;
folders.push(row);
}
/**
* 删目录:已入库的(有 FolderName)先查有无文件内容,有则拦;
* 连带把该目录的本地权限记录一起摘掉(旧 deleteFamilyGroupFolder 同逻辑),保存生效。
*/
async function removeFolder(
folderId: number,
folderName: string,
): Promise<void> {
if (folderName) {
const count =
await dmsFileService.FindDmsFolderMapByFolderName(folderName);
if (count && count > 0) {
throw new Error(t("SharePage.folderNotEmpty"));
}
}
detail.value.FamilyGroupFolders = (
detail.value.FamilyGroupFolders ?? []
).filter((f) => f.FolderId !== folderId);
if (folderName) {
detail.value.FamilyGroupPermissions = (
detail.value.FamilyGroupPermissions ?? []
).filter((p) => p.FolderName !== folderName);
}
}
// ===== 成员 / 审核 / 权限 =====
/** 同意申请:即时接口,成功后重拉详情 */
async function approveRequest(userName: string): Promise<void> {
const entity = new FamilyGroupManagerEntity();
entity.FamilyName = familyNameOf();
entity.UserName = userName;
entity.ValidIndc = "Y";
await familyGroupService.UpdateFamilyGroupManager(entity);
await openGroup(familyNameOf());
}
/** 拒绝申请 / 移除成员:都走 DeleteFamilyGroupManager(拒绝的是待审那条,移除的是正式那条,同一接口) */
async function removeMember(userName: string): Promise<void> {
const entity = new FamilyGroupManagerEntity();
entity.FamilyName = familyNameOf();
entity.UserName = userName;
await familyGroupService.DeleteFamilyGroupManager(entity);
await openGroup(familyNameOf());
}
/** 设/取消管理员:编辑态改本地 IsManager,保存群时后端 SaveFamilyGroupManagers 落 ManagerIndc */
function toggleManager(userName: string): void {
const target = (detail.value.FamilyGroupManagers ?? []).find(
(m) => m.UserName === userName,
);
if (target) {
target.IsManager = !target.IsManager;
}
}
/** 分配目录权限给某成员:即时接口,成功后重拉详情 */
async function assignPermission(
folderName: string,
userName: string,
): Promise<void> {
const entity = new FamilyGroupPermissionEntity();
entity.FamilyName = familyNameOf();
entity.FolderName = folderName;
entity.UserName = userName;
await familyGroupService.InsertFamilyGroupPermission([entity]);
await openGroup(familyNameOf());
}
/** 申请加入当前群(非成员,公开且可申请的群才会露这个入口) */
async function applyJoin(notice: string): Promise<void> {
const entity = new FamilyGroupManagerEntity();
entity.FamilyName = familyNameOf();
entity.Notice = notice;
await familyGroupService.InsertFamilyGroupManager(entity);
}
// ===== 保存 / 解散 =====
async function save(): Promise<void> {
const submitted = buildSubmitData();
const result = creating.value
? await familyGroupService.Insert(submitted)
: await familyGroupService.Update(submitted);
detail.value = normalize(result);
creating.value = false;
isEdit.value = false;
}
/** 群名去空白;提交与本地判定都以此为准 */
function familyNameOf(): string {
return detail.value.FamilyGroup?.FamilyName?.trim() ?? "";
}
/** 组装提交体:OpenIndc/AddIndc 后端从 IsOpen/IsAdd 派生,这里只保证布尔与名称就绪、数组归一 */
function buildSubmitData(): FamilyGroupData {
const data = detail.value;
data.FamilyGroup.FamilyLocalName = (
data.FamilyGroup.FamilyLocalName || ""
).trim();
data.FamilyGroupFolders = data.FamilyGroupFolders ?? [];
data.FamilyGroupManagers = data.FamilyGroupManagers ?? [];
data.FamilyGroupRequests = data.FamilyGroupRequests ?? [];
data.FamilyGroupPermissions = data.FamilyGroupPermissions ?? [];
return data;
}
async function dissolve(): Promise<void> {
await familyGroupService.Delete(familyNameOf());
await exitDetail();
}
/** 某目录已授权的成员名(分配权限时从候选里排掉,避免重复授权) */
function authorizedUsers(folderName: string): string[] {
return (detail.value.FamilyGroupPermissions ?? [])
.filter((p) => p.FolderName === folderName)
.map((p) => p.UserName);
}
return {
// state
groups,
loading,
errorText,
detail,
inDetail,
creating,
isEdit,
isManager,
isMember,
setMyUser,
// list
loadList,
openGroup,
exitDetail,
// create/edit
startCreate,
startEdit,
cancelEdit,
// folders
addFolder,
removeFolder,
// members / requests / permissions
approveRequest,
removeMember,
toggleManager,
assignPermission,
applyJoin,
// save / dissolve
save,
dissolve,
// helpers
authorizedUsers,
formatDate,
};
}
/** 详情遍历常用的展示类型转出,页面 import type 用 */
export type {
FamilyGroupData,
FamilyGroupEntity,
FamilyGroupFolderDisplayData,
FamilyGroupManagerDisplayData,
};
@@ -0,0 +1,165 @@
/**
* 展览播放器数据装载:把旧 WebVue 的 Preview.vue / PreviewAudio.vue / PreviewVideo.vue
* 三页 onMounted 的取数逻辑收敛成一个 kind 参数化 composable,供三个展示页复用。
*
* 三态取数(照抄旧页语义):
* 1. mode=list:key 当作「目录路径」,走 DMS 查询 [FilePath single=key] + [FileType single=P/A/V]
* -> 文件页 album/musicHall/videoHall 快捷入口用,展览当前目录里的全部该类文件;
* 2. 否则 key 非空:按专辑编号取明细(photo 用 FindPublishPhotoDetailByPublishName,
* audio/video 无该方法 -> FindByKey(key).Details);
* 3. 否则(无 key):取默认专辑(photo FindPublishPhotoDetail 全量,audio/video FindPublishXxxDefault)。
*
* 后端把同一素材给两个地址:缩略图/转码给 Formatted*,原图/原码给 Url/FileName。
* 幻灯轮播与列表用缩略图(加载快),精看/下载/播放用原图原码。视频原码 Url 对专辑明细
* 存的是 OrderKey,需优先取 FormattedUrl/FormattedM3u8。
*/
import { DmsFileSearchCriteria } from "@/data/auto/dmsFileSearchCriteria";
import { DmsFileUploadViewDisplayData as UploadView } from "@/data/auto/dmsFileUploadViewDisplayData";
import { SearchFilter } from "@/data/auto/searchFilter";
import { FileTypePolicy } from "@/policy/custom/fileTypePolicy";
import dmsFileService from "@/service/master/dmsFileService";
import publishAudioService from "@/service/master/publishAudioService";
import publishPhotoService from "@/service/master/publishPhotoService";
import publishVideoService from "@/service/master/publishVideoService";
const FIND_LIMIT = 999999;
/** 幻灯片项:舞台/列表用 thumb,精看/下载用 full */
export interface SlideItem {
url: string;
thumb: string;
full: string;
name?: string;
}
/** 音轨项 */
export interface TrackItem {
url: string;
pic?: string;
title?: string;
lrc?: string;
name?: string;
}
/** 视频项:url=原码,m3u8=转码 HLS */
export interface VideoItem {
url: string;
m3u8?: string;
pic?: string;
title?: string;
name?: string;
}
/** 去扩展名得展示名 */
function baseName(path: string): string {
return String(path || "").replace(/\.[^./\\]+$/, "");
}
/** 构造 DMS 目录+类型过滤条件(mode=list) */
function listCriteria(path: string, fileType: string): DmsFileSearchCriteria {
const criteria = new DmsFileSearchCriteria();
const filters: SearchFilter[] = [];
const pathMatch = new SearchFilter();
pathMatch.Column = UploadView.DB_FILE_PATH;
pathMatch.Type = "SingleSelection";
pathMatch.Operator = "single";
pathMatch.Value = path;
filters.push(pathMatch);
const typeMatch = new SearchFilter();
typeMatch.Column = UploadView.DB_FILE_TYPE;
typeMatch.Type = "SingleSelection";
typeMatch.Operator = "single";
typeMatch.Value = fileType;
filters.push(typeMatch);
criteria.Filter = filters;
criteria.limit = FIND_LIMIT;
criteria.page = 1;
criteria.GridColumns = [];
return criteria;
}
async function listRows(fileType: string, path: string): Promise<any[]> {
const data = await dmsFileService.FindBySearchCriteria(
listCriteria(path, fileType),
);
return data?.Datas ?? [];
}
export function useMediaShow() {
/** 相册展览条目 */
async function loadPhotos(mode: string, key: string): Promise<SlideItem[]> {
if (mode === "list") {
const rows = await listRows(FileTypePolicy.PHOTO, key);
return rows.map((r: any) => ({
url: r.FormattedPic || r.Url,
thumb: r.FormattedPic || r.Url,
full: r.Url || r.FormattedPic,
name: baseName(r.OrgFileName || r.Url),
}));
}
let details: any[] = [];
if (key) {
details =
await publishPhotoService.FindPublishPhotoDetailByPublishName(key);
} else {
details = await publishPhotoService.FindPublishPhotoDetail();
}
return (details ?? []).map((d: any) => ({
url: d.FormattedPic || d.FileName,
thumb: d.FormattedPic || d.FileName,
full: d.FileName || d.FormattedPic,
name: baseName(d.FileName) || d.Notice || undefined,
}));
}
/** 音乐厅曲目 */
async function loadTracks(mode: string, key: string): Promise<TrackItem[]> {
if (mode === "list") {
const rows = await listRows(FileTypePolicy.AUDIO, key);
return rows.map((r: any) => ({
url: r.Url,
pic: r.FormattedPic || "",
title: r.OrgFileName || baseName(r.Url),
name: baseName(r.OrgFileName || r.Url),
}));
}
const data = key
? await publishAudioService.FindByKey(key)
: await publishAudioService.FindPublishAudioDefault();
const details = (data?.Details ?? []) as any[];
return details.map((d: any) => ({
url: d.FormattedUrl || d.Url,
pic: d.FormattedPic || d.Pic || "",
title: d.Title || baseName(d.Url),
lrc: d.FormattedLrc || d.Lrc || "",
name: d.Title || baseName(d.Url),
}));
}
/** 影视厅片源 */
async function loadVideos(mode: string, key: string): Promise<VideoItem[]> {
if (mode === "list") {
const rows = await listRows(FileTypePolicy.VIDEO, key);
return rows.map((r: any) => ({
url: r.Url,
m3u8: r.FormattedM3u8 || "",
pic: r.FormattedPic || "",
title: r.OrgFileName || baseName(r.Url),
name: baseName(r.OrgFileName || r.Url),
}));
}
const data = key
? await publishVideoService.FindByKey(key)
: await publishVideoService.FindPublishVideoDefault();
const details = (data?.Details ?? []) as any[];
return details.map((d: any) => ({
url: d.FormattedUrl || d.Url,
m3u8: d.FormattedM3u8 || "",
pic: d.FormattedPic || d.Pic || "",
title: d.Title || baseName(d.Url),
name: d.Title || baseName(d.Url),
}));
}
return { loadPhotos, loadTracks, loadVideos };
}
@@ -0,0 +1,407 @@
/**
* 多媒体「专辑」数据源:把旧 WebVue 的三套专辑管理页
* PublishPhotoEnquiry + PublishPhotoOperation(相册管理)
* PublishAudioEnquiry + PublishAudioOperation(音乐专辑)
* PublishVideoEnquiry + PublishVideoOperation(影视专辑)
* 融合成一个 kind 参数化 composable,供底部「多媒体」tab 单页三段式两态复用。
*
* 关键契约(照抄旧 Operation,勿自作主张):
* 1. 列表 FindBySearchCriteria -> photo 用 data.Publishs、audio/video 用 data.Datas;
* 后端已按「有效 且(公开 或 我是成员)」过滤,前端不再筛。
* 2. 详情 FindByKey(publishName) -> PublishXxxData { PublishXxx, Details[], FamilyGroups[] },
* 明细随 FindByKey 一并回,不单独查明细(展览页才用 FindPublishPhotoDetailByPublishName)。
* 3. 编辑:明细上移/下移/删除、公开群组增删——全部是本地改 data,最后整包 Insert/Update 落库
* (旧 upSon/downSon/deleteSon/addFamilyGroup/deleteFamilyGroup 均无独立后端接口)。
* 4. 保存判定:PublishXxx.CreatedBy 有值走 Update,否则 Insert(新专辑编号由后端生成)。
* 5. 归属:OwnerName !== 当前登录名 -> 只能查看,不能改/删(旧系统同逻辑)。
* 6. 专辑「加入新内容」不在本页——沿用文件页发布链(file.vue onPublishConfirm)。
*
* 三套实体(PublishPhoto/Audio/VideoEntity)公共字段命名一致,故用统一视图访问;
* 差异只在顶层属性名(PublishPhoto/PublishAudio/PublishVideo)与列表键(Publishs/Datas)。
*/
import { useLocale } from "uview-pro";
import { computed, ref } from "vue";
import { PublishAudioData } from "@/data/auto/publishAudioData";
import { PublishAudioSearchCriteria } from "@/data/auto/publishAudioSearchCriteria";
import { PublishPhotoData } from "@/data/auto/publishPhotoData";
import { PublishPhotoSearchCriteria } from "@/data/auto/publishPhotoSearchCriteria";
import { PublishVideoData } from "@/data/auto/publishVideoData";
import { PublishVideoSearchCriteria } from "@/data/auto/publishVideoSearchCriteria";
import { SearchFilter } from "@/data/auto/searchFilter";
import publishAudioService from "@/service/master/publishAudioService";
import publishPhotoService from "@/service/master/publishPhotoService";
import publishVideoService from "@/service/master/publishVideoService";
/** 三种专辑类型 */
export type PublishKind = "photo" | "audio" | "video";
/** 三套实体的公共可编辑字段(命名逐字一致) */
export interface PublishAlbumEntity {
PublishName: string;
PublishLocalName: string;
Notice: string;
OpenIndc: string;
DefaultIndc: string;
OwnerName: string;
CreatedBy: string;
OrderBy: number;
[key: string]: any;
}
/** 明细行:photo 用 FileName(原图)+ FormattedPic(缩略图);audio/video 用 Url/Title/Pic/Lrc + Formatted* */
export interface PublishAlbumDetail {
DetailId: number;
OrderBy: number;
Notice: string;
[key: string]: any;
}
/** 公开群组行 */
export interface PublishAlbumGroup {
FamilyName: string;
FamilyLocalName?: string;
Notice?: string;
[key: string]: any;
}
/** 列表一次拉全(旧页面分页在移动端不需要) */
const FIND_LIMIT = 999999;
/** 五个增删改查方法在三服务里签名一致,抽成最小接口供适配器持有 */
interface PublishCrudService {
FindByKey: (key: string) => Promise<any>;
FindBySearchCriteria: (criteria: any) => Promise<any>;
Insert: (data: any) => Promise<any>;
Update: (data: any) => Promise<any>;
Delete: (key: string) => Promise<any>;
}
interface KindAdapter {
/** 顶层实体属性名 */
entityKey: "PublishPhoto" | "PublishAudio" | "PublishVideo";
/** 列表结果键:photo=Publishs,audio/video=Datas */
listKey: "Publishs" | "Datas";
/** 查询标题的列名(三表 DB 列均为 publish_local_name) */
titleColumn: string;
service: PublishCrudService;
/** 空白 Data 工厂(新建时用,带默认实体 + 空数组) */
newBlank: () => Record<string, any>;
/** 空白查询条件工厂 */
newCriteria: () => any;
}
const ADAPTERS: Record<PublishKind, KindAdapter> = {
photo: {
entityKey: "PublishPhoto",
listKey: "Publishs",
titleColumn: "publish_local_name",
service: publishPhotoService,
newBlank: () => new PublishPhotoData(),
newCriteria: () => new PublishPhotoSearchCriteria(),
},
audio: {
entityKey: "PublishAudio",
listKey: "Datas",
titleColumn: "publish_local_name",
service: publishAudioService,
newBlank: () => new PublishAudioData(),
newCriteria: () => new PublishAudioSearchCriteria(),
},
video: {
entityKey: "PublishVideo",
listKey: "Datas",
titleColumn: "publish_local_name",
service: publishVideoService,
newBlank: () => new PublishVideoData(),
newCriteria: () => new PublishVideoSearchCriteria(),
},
};
/** 创建日期只取年月日:卡片展示用 */
export function formatAlbumDate(value: Date | string | undefined): string {
if (!value) {
return "";
}
const time =
typeof value === "string" ? new Date(value).getTime() : value.getTime();
if (!time) {
return "";
}
const d = new Date(time);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
export function usePublishAlbums() {
const { t } = useLocale();
// ===== 当前分段(相册/音乐/影视)=====
const kind = ref<PublishKind>("photo");
const adapter = () => ADAPTERS[kind.value];
// ===== 列表态 =====
const list = ref<PublishAlbumEntity[]>([]);
const loading = ref(false);
const errorText = ref("");
// ===== 编辑态(Operation)=====
/** 当前详情:整包 PublishXxxData(含实体 + Details + FamilyGroups),保存原样回传 */
const detail = ref<Record<string, any> | null>(null);
const creating = ref(false);
const isEdit = ref(false);
/** 是否非本人专辑:进详情按后端 OwnerName 判定,决定能否改/删 */
const notOwner = ref(false);
const myName = ref("");
function setMyUser(name: string): void {
myName.value = name;
}
/** 顶层实体(三 kind 字段命名一致,统一视图) */
const album = computed<PublishAlbumEntity | null>(
() => (detail.value ? detail.value[adapter().entityKey] : null) ?? null,
);
const details = computed<PublishAlbumDetail[]>(
() => detail.value?.Details ?? [],
);
const groups = computed<PublishAlbumGroup[]>(
() => detail.value?.FamilyGroups ?? [],
);
/** 是否停在专辑详情(false=专辑列表) */
const inDetail = computed(() => !!album.value?.PublishName || creating.value);
/** 归属判定:新建态恒可改;已入库需 OwnerName 命中当前登录名 */
const isOwner = computed(
() => creating.value || (!notOwner.value && !!album.value),
);
const canAmend = computed(() => inDetail.value && isOwner.value);
const canDelete = computed(() => inDetail.value && isOwner.value);
// ===== 分段切换 =====
async function setKind(next: PublishKind): Promise<void> {
if (kind.value === next) {
return;
}
kind.value = next;
// 切分段:退出可能打开的详情,并按新 kind 重新拉列表(否则下方内容不刷新)
detail.value = null;
creating.value = false;
isEdit.value = false;
notOwner.value = false;
await loadList();
}
// ===== 列表 =====
async function loadList(keyword = ""): Promise<void> {
loading.value = true;
errorText.value = "";
try {
const a = adapter();
const criteria = a.newCriteria();
const filters: SearchFilter[] = [];
if (keyword.trim()) {
const kw = new SearchFilter();
kw.Column = a.titleColumn;
kw.Type = "text";
kw.Operator = "contain";
kw.Value = keyword.trim();
filters.push(kw);
}
criteria.Filter = filters;
criteria.limit = FIND_LIMIT;
criteria.page = 1;
criteria.GridColumns = [];
const data = await a.service.FindBySearchCriteria(criteria);
list.value = (data?.[a.listKey] ?? []) as PublishAlbumEntity[];
} catch (error: any) {
list.value = [];
errorText.value = error?.message || t("FilePage.loadFailed");
} finally {
loading.value = false;
}
}
// ===== 进详情 =====
async function openAlbum(publishName: string): Promise<void> {
loading.value = true;
errorText.value = "";
try {
const data = await adapter().service.FindByKey(publishName);
const entity = data?.[adapter().entityKey];
if (!entity?.PublishName) {
detail.value = null;
errorText.value = t("MediaPage.noAccess");
return;
}
data.Details = data.Details ?? [];
data.FamilyGroups = data.FamilyGroups ?? [];
notOwner.value = entity.OwnerName !== myName.value;
detail.value = data;
} catch (error: any) {
errorText.value = error?.message || t("FilePage.loadFailed");
} finally {
loading.value = false;
}
}
/** 退出详情回列表;keepKind=true 时不清分段(切分段内部调用) */
async function exitDetail(keepKind = false): Promise<void> {
detail.value = null;
creating.value = false;
isEdit.value = false;
notOwner.value = false;
if (!keepKind) {
await loadList();
}
}
// ===== 新建 / 编辑 =====
function startCreate(): void {
const blank = adapter().newBlank();
blank.Details = [];
blank.FamilyGroups = [];
detail.value = blank;
creating.value = true;
isEdit.value = true;
notOwner.value = false;
}
function startEdit(): void {
isEdit.value = true;
}
async function cancelEdit(): Promise<void> {
if (creating.value) {
await exitDetail();
return;
}
isEdit.value = false;
await openAlbum(album.value!.PublishName);
}
// ===== 明细本地增删改(保存整包落库)=====
/** 上移(-1)/下移(+1):与相邻行交换位置,越界不动(旧 upSon/downSon) */
function moveDetail(index: number, dir: -1 | 1): void {
const rows = detail.value?.Details as PublishAlbumDetail[] | undefined;
if (!rows) {
return;
}
const target = index + dir;
if (target < 0 || target >= rows.length) {
return;
}
const [item] = rows.splice(index, 1);
rows.splice(target, 0, item);
}
/** 删除明细(旧 deleteSon):按 DetailId 过滤 */
function removeDetail(detailId: number): void {
if (!detail.value) {
return;
}
detail.value.Details = (
detail.value.Details as PublishAlbumDetail[]
).filter((d) => d.DetailId !== detailId);
}
// ===== 公开群组本地增删(保存整包落库)=====
/** 加入公开群组(旧 addFamilyGroup -> handleSelectFamilyGroup);已存在则忽略 */
function addGroup(
familyName: string,
familyLocalName = "",
notice = "",
): void {
if (!detail.value) {
return;
}
const rows = detail.value.FamilyGroups as PublishAlbumGroup[];
if (rows.some((g) => g.FamilyName === familyName)) {
return;
}
rows.push({
FamilyName: familyName,
FamilyLocalName: familyLocalName,
Notice: notice,
});
}
function removeGroup(familyName: string): void {
if (!detail.value) {
return;
}
detail.value.FamilyGroups = (
detail.value.FamilyGroups as PublishAlbumGroup[]
).filter((g) => g.FamilyName !== familyName);
}
// ===== 保存 / 删除 =====
async function save(): Promise<void> {
const a = adapter();
const submitted = detail.value!;
const entity = submitted[a.entityKey] as PublishAlbumEntity;
entity.PublishLocalName = (entity.PublishLocalName || "").trim();
submitted.Details = submitted.Details ?? [];
submitted.FamilyGroups = submitted.FamilyGroups ?? [];
const result = entity.CreatedBy
? await a.service.Update(submitted)
: await a.service.Insert(submitted);
result.Details = result.Details ?? [];
result.FamilyGroups = result.FamilyGroups ?? [];
detail.value = result;
notOwner.value =
(result[a.entityKey] as PublishAlbumEntity).OwnerName !== myName.value;
creating.value = false;
isEdit.value = false;
}
async function dissolve(): Promise<void> {
await adapter().service.Delete(album.value!.PublishName);
await exitDetail();
}
/** 专辑当前编号(供展览跳转拼参数) */
function publishNameOf(): string {
return album.value?.PublishName?.trim() ?? "";
}
return {
// kind
kind,
setKind,
// state
list,
loading,
errorText,
detail,
album,
details,
groups,
inDetail,
creating,
isEdit,
isOwner,
canAmend,
canDelete,
setMyUser,
// list / detail
loadList,
openAlbum,
exitDetail,
// create / edit
startCreate,
startEdit,
cancelEdit,
// details
moveDetail,
removeDetail,
// groups
addGroup,
removeGroup,
// save / dissolve
save,
dissolve,
// helpers
publishNameOf,
formatDate: formatAlbumDate,
};
}
@@ -0,0 +1,258 @@
/**
* 共享大厅数据源:复刻旧 WebVue FamilyGroupShare.vue 的调用链(D:\Docker\WebVue\src\views\master)
*
* 1. 顶层(大厅):FindFamilyDmsFolders() -> SelectOption[],每个家人群组就是一张「共享目录」卡片,
* 顶层没有任何写操作(不建目录/不上传);
* 2. 进组:FindFamilyBySearchCriteria -> data.Families,Filter 恒挂
* [FamilyName single 群组] + [FilePath single 当前目录];组记录的 file_path 存的是
* 父级标识(组根直属 = 群组编号,子目录 = 父目录 FileName),组根传空串会被
* 后端忽略空值条件把全组所有层级返出(实测子目录被提到组根),故空串转群组编号;
* 关键字追加 [OrgFileName text contain];
* 3. 下钻:旧 onOpenFolder 双条件 IsDirectoryIndc=Y && PermissionIndc=Y,
* 无权限的目录点进去要提示;
* 4. 组内新建目录:CreateFamilyDmsFolder({ FilePath, FolderLocalName, FamilyName, Notice })
* (旧 DmsFolderModal 的 isFamilyGroup 分支,不是文件页的 CreateDmsFolder);
* 5. 取消共享:DeleteFamilyDmsFolder(record)(旧 onDelete,摘掉的是共享映射不动原文件)。
*/
import type { FamilyDmsFileViewDisplayData } from "@/data/auto/familyDmsFileViewDisplayData";
import type { FileItem, PathNode } from "@/data/custom/fileBrowser";
import { useLocale } from "uview-pro";
import { computed, ref } from "vue";
import { DmsFileSearchCriteria } from "@/data/auto/dmsFileSearchCriteria";
import { FamilyDmsFileViewDisplayData as FamilyView } from "@/data/auto/familyDmsFileViewDisplayData";
import { FamilyDmsFileViewEntity } from "@/data/auto/familyDmsFileViewEntity";
import { SearchFilter } from "@/data/auto/searchFilter";
import dmsFileService from "@/service/master/dmsFileService";
import {
formatModifiedLabel,
formatSize,
kindOf,
parseTime,
} from "./useDmsFiles";
/** 目录场景一次拉全,与旧页面 pagingParams.pageSize 同值 */
const FIND_LIMIT = 999999;
export function useShareHall() {
const { t } = useLocale();
/** 大厅顶层:群组卡片(path = FamilyName,id 用序号——群组本身没有 FileId) */
const groups = ref<FileItem[]>([]);
/** 当前进入的群组系统名;空串 = 停在大厅顶层 */
const currentFamily = ref("");
const files = ref<FileItem[]>([]);
/** FileId -> 原始共享记录:下钻/取消共享/下载要拿全字段 */
const records = ref(new Map<number, FamilyDmsFileViewDisplayData>());
const loading = ref(false);
const errorText = ref("");
const currPath = ref("");
const pathStack = ref<PathNode[]>([]);
/** 是否在组内(组根也算):决定导航返回、面包屑与「新建文件夹」按钮显隐 */
const inGroup = computed(() => !!currentFamily.value);
async function init(): Promise<void> {
loading.value = true;
errorText.value = "";
try {
const options = (await dmsFileService.FindFamilyDmsFolders()) ?? [];
groups.value = options.map((opt, index) => ({
id: index + 1,
name: opt.DisplayName || opt.BindValue,
kind: "folder" as const,
sizeText: "",
modifiedAt: "",
sortTime: 0,
path: opt.BindValue,
}));
} catch (error: any) {
// 错误提示已由 http.interceptor 统一 toast,这里只留页面内可见的失败态
groups.value = [];
errorText.value = error?.message || t("FilePage.loadFailed");
} finally {
loading.value = false;
}
}
function toFileItem(record: FamilyDmsFileViewDisplayData): FileItem {
const time = parseTime(record.CreatedDatetime);
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: formatModifiedLabel(time, t("FilePage.todayLabel")),
sortTime: time,
path: record.FileName,
// 锁/共享角标照旧 MyComputerFamily:锁仅目录渲染(组件层控制),
// 共享角标 = PermissionIndc=Y 且 FavoriteIndc=Y(收藏过共享)
permission: record.PermissionIndc === "Y" ? "Y" : "N",
shared: record.PermissionIndc === "Y" && record.FavoriteIndc === "Y",
};
}
/** 查当前群组的一个目录;path 空串 = 组根,转群组编号(组直属记录的 file_path 存的就是它) */
async function loadFolder(path: string, keyword = ""): Promise<void> {
const queryPath = path || currentFamily.value;
loading.value = true;
errorText.value = "";
try {
const criteria = new DmsFileSearchCriteria();
const filters: SearchFilter[] = [];
const familyFilter = new SearchFilter();
familyFilter.Column = FamilyView.DB_FAMILY_NAME;
familyFilter.Type = "SingleSelection";
familyFilter.Operator = "single";
familyFilter.Value = currentFamily.value;
filters.push(familyFilter);
const pathFilter = new SearchFilter();
pathFilter.Column = FamilyView.DB_FILE_PATH;
pathFilter.Type = "SingleSelection";
pathFilter.Operator = "single";
pathFilter.Value = queryPath;
filters.push(pathFilter);
if (keyword.trim()) {
const nameFilter = new SearchFilter();
nameFilter.Column = FamilyView.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.FindFamilyBySearchCriteria(criteria);
const rows = data?.Families ?? [];
// 文件夹置顶(旧页面 orderBy IsDirectoryIndc desc),组内保持后端顺序
const dirs: FamilyDmsFileViewDisplayData[] = [];
const others: FamilyDmsFileViewDisplayData[] = [];
for (const row of rows) {
if (row.IsDirectoryIndc === "Y") {
dirs.push(row);
} else {
others.push(row);
}
}
const sorted = [...dirs, ...others];
files.value = sorted.map(toFileItem);
const nextRecords = new Map<number, FamilyDmsFileViewDisplayData>();
for (const row of sorted) {
nextRecords.set(row.FileId, row);
}
records.value = nextRecords;
currPath.value = queryPath;
} catch (error: any) {
files.value = [];
records.value = new Map();
errorText.value = error?.message || t("FilePage.loadFailed");
} finally {
loading.value = false;
}
}
/** 进组:路径栈重置为组根(旧系统 rootPath="" 的组根语义),value 空串供面包屑回组根 */
async function enterGroup(group: FileItem): Promise<void> {
currentFamily.value = group.path;
pathStack.value = [{ value: "", displayName: group.name }];
await loadFolder("");
}
/** 退出到大厅:清组与列表,重拉群组(组内可能刚建过目录) */
async function exitGroup(): Promise<void> {
currentFamily.value = "";
files.value = [];
records.value = new Map();
pathStack.value = [];
currPath.value = "";
await init();
}
/**
* 点文件夹下钻;非文件夹返回 false 交给页面处理打开动作。
* 无权限的目录(旧 onOpenFolder 的 PermissionIndc 检查)抛提示文案由页面 toast
*/
async function openFolder(file: FileItem): Promise<boolean> {
if (file.kind !== "folder" || !file.path) {
return false;
}
const record = records.value.get(file.id);
if (record?.IsDirectoryIndc !== "Y") {
return false;
}
if (record.PermissionIndc !== "Y") {
throw new Error(t("ShareHallPage.noPermission"));
}
pathStack.value.push({ value: file.path, displayName: file.name });
await loadFolder(file.path);
return true;
}
/** 返回上一级:组根再退交回大厅(页面调 exitGroup) */
async function back(): Promise<boolean> {
if (pathStack.value.length <= 1) {
return false;
}
pathStack.value.pop();
const prev = pathStack.value[pathStack.value.length - 1];
await loadFolder(prev.value);
return true;
}
/** 面包屑跳转:截断到第 index 层并查该层;已在该层则忽略 */
async function goTo(index: number): Promise<void> {
if (index < 0 || index >= pathStack.value.length - 1) {
return;
}
pathStack.value = pathStack.value.slice(0, index + 1);
await loadFolder(pathStack.value[index].value);
}
/** 组内新建目录(旧 DmsFolderModal isFamilyGroup 分支):挂在当前目录下 */
async function createFolder(localName: string): Promise<void> {
const entity = new FamilyDmsFileViewEntity();
entity.FilePath = currPath.value;
entity.FolderLocalName = localName;
entity.FamilyName = currentFamily.value;
entity.Notice = "";
await dmsFileService.CreateFamilyDmsFolder(entity);
await loadFolder(currPath.value);
}
/** 取消共享(旧 onDelete):摘掉群组里的目录映射,原文件仍在所有者网盘 */
async function unshare(file: FileItem): Promise<void> {
const record = records.value.get(file.id);
if (record) {
await dmsFileService.DeleteFamilyDmsFolder(record);
await loadFolder(currPath.value);
}
}
return {
groups,
files,
records,
loading,
errorText,
pathStack,
currentFamily,
inGroup,
init,
enterGroup,
exitGroup,
openFolder,
back,
goTo,
loadFolder,
createFolder,
unshare,
};
}
@@ -17,7 +17,12 @@ 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";
import {
formatModifiedLabel,
formatSize,
kindOf,
parseTime,
} from "./useDmsFiles";
/** 目录场景一次拉全,与旧页面 pagingParams.pageSize 同值 */
const FIND_LIMIT = 999999;
@@ -35,18 +40,6 @@ export function useTrashFiles() {
/** 删除日志 -> 卡片条目:时间列展示的是删除时间(记录 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,
@@ -55,7 +48,7 @@ export function useTrashFiles() {
sizeText: formatSize(
record.FileSize ? record.FileSize * 1024 : undefined,
),
modifiedAt,
modifiedAt: formatModifiedLabel(time, t("FilePage.todayLabel")),
sortTime: time,
path: record.FilePath,
};
@@ -21,6 +21,13 @@ export interface FileItem {
sortTime: number;
/** DMS 记录的 FileName:文件夹点它下钻(当作下次查询的 FilePath),文件留作下载/预览键 */
path: string;
/**
* 共享域角标(旧 MyComputerFamily 同义,仅目录显示锁):
* "Y" 有权限(开锁绿)/ "N" 无权限(闭锁灰);缺省不显示——文件页/回收站/顶层群组卡没有这个概念
*/
permission?: "Y" | "N";
/** 已被收藏共享(PermissionIndc=Y 且 FavoriteIndc=Y):显示蓝色共享角标 */
shared?: boolean;
}
/** 快捷功能入口的单项:图标 + 渐变底色,页面给什么画什么 */
+111 -1
View File
@@ -30,7 +30,7 @@
"album": "Family Album",
"musicHall": "Music Hall",
"videoHall": "Video Hall",
"shareCenter": "Share Center",
"shareCenter": "Share Hall",
"trash": "Trash",
"catAll": "All",
"catImage": "Images",
@@ -85,6 +85,7 @@
"moveHere": "Move here",
"moveNoSub": "No subfolders",
"shareTitle": "Choose a family folder",
"shareHere": "Share here",
"shareDone": "Shared",
"shareNoFile": "No shareable files",
"publishNoFile": "No publishable files",
@@ -117,6 +118,115 @@
"pickFirst": "Select items first",
"emptyText": "Trash is empty"
},
"ShareHallPage": {
"title": "Share Hall",
"noPermission": "No permission for this folder",
"enter": "Open",
"preview": "Preview",
"play": "Play",
"download": "Download",
"unshare": "Unshare",
"unshareConfirmText": "Unshare \"{name}\"? The original file stays in its owner's drive",
"unshareDone": "Unshared",
"createDone": "Folder created",
"emptyGroups": "No shared groups yet",
"emptyText": "This folder is empty"
},
"SharePage": {
"title": "Family Groups",
"createTitle": "Create Group",
"noAccess": "This group is private and you are not a member",
"unnamed": "Unnamed",
"chipPublic": "Public",
"chipPrivate": "Private",
"chipJoinable": "Joinable",
"chipManager": "Admin",
"chipMember": "Member",
"emptyGroups": "No groups to join yet",
"noDesc": "No description",
"cancel": "Cancel",
"save": "Save",
"amend": "Edit",
"dissolve": "Disband",
"dissolveConfirm": "Disband \"{name}\"? Folders and memberships will be removed together",
"dissolveDone": "Group disbanded",
"saveDone": "Saved",
"createDone": "Group created",
"nameRequired": "Enter a group name",
"groupInfo": "Group Info",
"groupName": "Group Name",
"groupDesc": "Description",
"publicSetting": "Public group",
"joinApply": "Allow join requests",
"folders": "Shared Folders",
"newFolder": "New Folder",
"add": "Add",
"folderPh": "Enter folder name",
"notePh": "Note (optional)",
"noFolders": "No shared folders",
"deleteFolder": "Delete Folder",
"deleteFolderConfirm": "Delete this shared folder? It cannot be deleted if it still contains items",
"folderNotEmpty": "The folder still contains items and cannot be deleted",
"delete": "Delete",
"assign": "Assign",
"assignDone": "Permission assigned",
"noCandidate": "All members already have this folder",
"requests": "Pending Requests",
"approve": "Approve",
"approveDone": "Approved",
"reject": "Reject",
"rejectConfirm": "Reject this request?",
"rejectDone": "Rejected",
"members": "Members",
"removeMember": "Remove",
"removeMemberConfirm": "Remove this member from the group? Their folder permissions will be cleared",
"permissions": "Folder Permissions",
"applyJoin": "Join",
"submitApply": "Submit",
"joinPh": "Tell the admin why you want to join",
"applyDone": "Request submitted, awaiting review"
},
"MediaPage": {
"title": "Media",
"createTitle": "New Album",
"segPhoto": "Photos",
"segAudio": "Music",
"segVideo": "Video",
"emptyAlbums": "No albums yet",
"unnamed": "Untitled",
"noDesc": "No description",
"noAccess": "You don't have access to this album",
"chipPublic": "Public",
"chipDefault": "Default",
"albumInfo": "Album Info",
"albumNo": "Album No.",
"albumName": "Album Name",
"albumDesc": "Album Description",
"autoNo": "Generated on save",
"publicLabel": "Public to everyone",
"defaultLabel": "Set as default",
"nameRequired": "Please enter an album name",
"details": "Album Items",
"noDetails": "No items yet. Publish content to this album from the Files page",
"moveUp": "Up",
"moveDown": "Down",
"delete": "Delete",
"deleteDetailConfirm": "Remove this item from the album?",
"groups": "Shared Groups",
"groupName": "Group Name",
"groupDesc": "Group Description",
"addGroup": "Add Group",
"noGroups": "Not published to any group yet",
"noGroupsAvailable": "No groups available, or all already published",
"exhibit": "Exhibit",
"amend": "Edit",
"save": "Save",
"cancel": "Cancel",
"deleteConfirm": "Delete album \"{name}\"? Items and group publishing will be removed",
"deleteDone": "Album deleted",
"saveDone": "Saved",
"createDone": "Album created"
},
"TransportPage": {
"title": "Transfers",
"active": "Active",
+111 -1
View File
@@ -30,7 +30,7 @@
"album": "有家相册",
"musicHall": "音乐厅",
"videoHall": "影视厅",
"shareCenter": "共享中心",
"shareCenter": "共享大厅",
"trash": "回收站",
"catAll": "全部",
"catImage": "图片",
@@ -85,6 +85,7 @@
"moveHere": "移动到此",
"moveNoSub": "没有子文件夹",
"shareTitle": "选择家人目录",
"shareHere": "共享到此",
"shareDone": "共享成功",
"shareNoFile": "没有可共享的文件",
"publishNoFile": "没有可发布的文件",
@@ -117,6 +118,115 @@
"pickFirst": "请先勾选条目",
"emptyText": "回收站是空的"
},
"ShareHallPage": {
"title": "共享大厅",
"noPermission": "您还没有该目录的权限",
"enter": "打开",
"preview": "预览",
"play": "播放",
"download": "下载",
"unshare": "取消共享",
"unshareConfirmText": "确定取消共享「{name}」?原文件仍在其所有者网盘中",
"unshareDone": "已取消共享",
"createDone": "已创建文件夹",
"emptyGroups": "还没有共享的群组",
"emptyText": "这个目录是空的"
},
"SharePage": {
"title": "家庭群组",
"createTitle": "创建群组",
"noAccess": "该群组为私密,您还不是成员",
"unnamed": "未命名",
"chipPublic": "公开",
"chipPrivate": "私密",
"chipJoinable": "可申请",
"chipManager": "管理员",
"chipMember": "成员",
"emptyGroups": "还没有可加入的群组",
"noDesc": "暂无群组描述",
"cancel": "取消",
"save": "保存",
"amend": "修改",
"dissolve": "解散群",
"dissolveConfirm": "确定解散「{name}」?解散后目录与成员关系一并清除",
"dissolveDone": "已解散群组",
"saveDone": "已保存",
"createDone": "群组已创建",
"nameRequired": "请输入群组名称",
"groupInfo": "群组信息",
"groupName": "群组名称",
"groupDesc": "群组描述",
"publicSetting": "公开群组",
"joinApply": "允许申请加入",
"folders": "共享目录",
"newFolder": "新建目录",
"add": "添加",
"folderPh": "请输入目录名称",
"notePh": "备注(选填)",
"noFolders": "暂无共享目录",
"deleteFolder": "删除目录",
"deleteFolderConfirm": "确定删除该共享目录?目录内若仍有内容将无法删除",
"folderNotEmpty": "目录内仍有内容,无法删除",
"delete": "删除",
"assign": "分配权限",
"assignDone": "已分配权限",
"noCandidate": "成员都已获得该目录权限",
"requests": "待审核成员",
"approve": "同意",
"approveDone": "已通过",
"reject": "拒绝",
"rejectConfirm": "确定拒绝该申请?",
"rejectDone": "已拒绝",
"members": "正式成员",
"removeMember": "移出",
"removeMemberConfirm": "确定将该成员移出群组?其目录权限一并清除",
"permissions": "目录权限",
"applyJoin": "申请加入",
"submitApply": "提交申请",
"joinPh": "请填写申请原因,方便管理员审核",
"applyDone": "申请已提交,请等待审核"
},
"MediaPage": {
"title": "多媒体",
"createTitle": "新建专辑",
"segPhoto": "相册",
"segAudio": "音乐",
"segVideo": "影视",
"emptyAlbums": "还没有专辑",
"unnamed": "未命名",
"noDesc": "暂无描述",
"noAccess": "该专辑无权限查看",
"chipPublic": "公开",
"chipDefault": "默认",
"albumInfo": "专辑信息",
"albumNo": "专辑编号",
"albumName": "专辑名称",
"albumDesc": "专辑描述",
"autoNo": "保存后由系统生成",
"publicLabel": "对所有人公开",
"defaultLabel": "设为默认专辑",
"nameRequired": "请输入专辑名称",
"details": "专辑明细",
"noDetails": "暂无明细,请到「文件」页发布内容到本专辑",
"moveUp": "上移",
"moveDown": "下移",
"delete": "删除",
"deleteDetailConfirm": "确定从专辑中移除该明细?",
"groups": "公开群组",
"groupName": "群组名称",
"groupDesc": "群组描述",
"addGroup": "添加群组",
"noGroups": "尚未发布到任何群组",
"noGroupsAvailable": "没有可添加的群组,或都已发布过",
"exhibit": "展览",
"amend": "修改",
"save": "保存",
"cancel": "取消",
"deleteConfirm": "确定删除专辑「{name}」?明细与群组发布关系一并清除",
"deleteDone": "已删除专辑",
"saveDone": "已保存",
"createDone": "专辑已创建"
},
"TransportPage": {
"title": "传输列表",
"active": "进行中",
+16
View File
@@ -52,9 +52,25 @@
{
"path": "pages/media/play/play"
},
// 相册展览(短地址 /photo-show):多媒体专辑「展览」+ 文件页相册快捷入口,全屏幻灯
{
"path": "pages/media/photo-show/photo-show"
},
// 音乐厅(短地址 /audio-hall):多媒体音乐专辑「展览」+ 文件页音乐快捷入口,黑胶播放器
{
"path": "pages/media/audio-hall/audio-hall"
},
// 影视厅(短地址 /video-hall):多媒体影视专辑「展览」+ 文件页影视快捷入口,自绘播放器
{
"path": "pages/media/video-hall/video-hall"
},
// 回收站(短地址 /trash):文件页快捷入口跳入,还原/彻底删除
{
"path": "pages/trash/trash"
},
// 共享大厅(短地址 /share-hall):文件页快捷入口跳入,家人共享目录浏览与组内建目录
{
"path": "pages/share-hall/share-hall"
}
],
// 全局配置
@@ -0,0 +1,77 @@
<script setup lang="ts">
/**
* 音乐厅页(对齐旧 WebVue views/master/PreviewAudio.vue):
* 黑胶唱机播放器外壳。入参经 query 传:mode=list&key=<目录路径> 播放当前目录音频;
* 或 key=<专辑编号> 播放某音乐专辑;无 key 走默认专辑。取数逻辑见 composables/useMediaShow。
* 隐藏控制区时(v-model:controls)本壳顺带隐掉导航/系统栏做整页沉浸(H5 元素全屏在组件内处理)。
*/
import type { VinylTrack } from "@/components/vinyl-audio-player/vinyl-audio-player.vue";
import { onLoad } from "@dcloudio/uni-app";
import { ref } from "vue";
import { useMediaShow } from "@/composables";
const { loadTracks } = useMediaShow();
const tracks = ref<VinylTrack[]>([]);
const controls = ref(true);
const loading = ref(true);
const errorText = ref("");
function closeMe(): void {
if (getCurrentPages().length > 1) {
uni.navigateBack();
return;
}
uni.switchTab({
url: "/pages/tab/media",
fail: () => uni.reLaunch({ url: "/pages/tab/media" }),
});
}
onLoad(async (query) => {
const mode = String(query?.mode || "");
const key = decodeURIComponent(String(query?.key || ""));
try {
const items = await loadTracks(mode, key);
tracks.value = items.map(t => ({
name: t.name || t.title || "",
author: "有家相册",
url: t.url,
cover: t.pic || undefined,
lrc: t.lrc || undefined,
}));
} catch (error: any) {
errorText.value = error?.message || "加载失败";
} finally {
loading.value = false;
}
});
</script>
<template>
<app-page :hide-nav="true" :show-tabbar="false">
<view v-if="loading" class="show-loading">
<text class="show-loading__text">加载中…</text>
</view>
<view v-else-if="errorText" class="show-loading" @tap="closeMe">
<text class="show-loading__text">{{ errorText }}</text>
</view>
<vinyl-audio-player v-else v-model:controls="controls" :tracks="tracks" @close="closeMe" />
</app-page>
</template>
<style lang="scss" scoped>
.show-loading {
display: flex;
position: fixed;
inset: 0;
align-items: center;
justify-content: center;
background: #0b0d10;
}
.show-loading__text {
font-size: 28rpx;
color: #9aa0a6;
}
</style>
@@ -0,0 +1,70 @@
<script setup lang="ts">
/**
* 相册展览页(对齐旧 WebVue views/master/Preview.vue):
* 全屏幻灯播放器外壳。入参经 query 传:mode=list&key=<目录路径> 展览当前目录图片;
* 或 key=<专辑编号> 展览某相册;无 key 走默认相册。取数逻辑见 composables/useMediaShow。
* 隐藏控制区时(v-model:controls)本壳顺带隐掉导航/系统栏做整页沉浸(H5 元素全屏在组件内处理)。
*/
import type { SlideItem } from "@/composables";
import { onLoad } from "@dcloudio/uni-app";
import { ref } from "vue";
import { useMediaShow } from "@/composables";
const { loadPhotos } = useMediaShow();
const items = ref<SlideItem[]>([]);
const controls = ref(true);
const loading = ref(true);
const errorText = ref("");
function closeMe(): void {
if (getCurrentPages().length > 1) {
uni.navigateBack();
return;
}
uni.switchTab({
url: "/pages/tab/media",
fail: () => uni.reLaunch({ url: "/pages/tab/media" }),
});
}
onLoad(async (query) => {
const mode = String(query?.mode || "");
const key = decodeURIComponent(String(query?.key || ""));
try {
items.value = await loadPhotos(mode, key);
} catch (error: any) {
errorText.value = error?.message || "加载失败";
} finally {
loading.value = false;
}
});
</script>
<template>
<app-page :hide-nav="true" :show-tabbar="false">
<view v-if="loading" class="show-loading">
<text class="show-loading__text">加载中…</text>
</view>
<view v-else-if="errorText" class="show-loading" @tap="closeMe">
<text class="show-loading__text">{{ errorText }}</text>
</view>
<photo-slide-player v-else v-model:controls="controls" :items="items" @close="closeMe" />
</app-page>
</template>
<style lang="scss" scoped>
.show-loading {
display: flex;
position: fixed;
inset: 0;
align-items: center;
justify-content: center;
background: #000;
}
.show-loading__text {
font-size: 28rpx;
color: #9aa0a6;
}
</style>
@@ -0,0 +1,76 @@
<script setup lang="ts">
/**
* 影视厅页(对齐旧 WebVue views/master/PreviewVideo.vue):
* 自绘控制播放器外壳。入参经 query 传:mode=list&key=<目录路径> 播放当前目录视频;
* 或 key=<专辑编号> 播放某影视专辑;无 key 走默认专辑。取数逻辑见 composables/useMediaShow。
* 隐藏控制区时(v-model:controls)本壳顺带隐掉导航/系统栏做整页沉浸。
*/
import type { VideoSource } from "@/components/video-player/video-player.vue";
import { onLoad } from "@dcloudio/uni-app";
import { ref } from "vue";
import { useMediaShow } from "@/composables";
const { loadVideos } = useMediaShow();
const videos = ref<VideoSource[]>([]);
const controls = ref(true);
const loading = ref(true);
const errorText = ref("");
function closeMe(): void {
if (getCurrentPages().length > 1) {
uni.navigateBack();
return;
}
uni.switchTab({
url: "/pages/tab/media",
fail: () => uni.reLaunch({ url: "/pages/tab/media" }),
});
}
onLoad(async (query) => {
const mode = String(query?.mode || "");
const key = decodeURIComponent(String(query?.key || ""));
try {
const items = await loadVideos(mode, key);
videos.value = items.map(v => ({
name: v.name || v.title || "",
url: v.url,
m3u8: v.m3u8,
pic: v.pic,
}));
} catch (error: any) {
errorText.value = error?.message || "加载失败";
} finally {
loading.value = false;
}
});
</script>
<template>
<app-page :hide-nav="true" :show-tabbar="false">
<view v-if="loading" class="show-loading">
<text class="show-loading__text">加载中…</text>
</view>
<view v-else-if="errorText" class="show-loading" @tap="closeMe">
<text class="show-loading__text">{{ errorText }}</text>
</view>
<video-player v-else v-model:controls="controls" :videos="videos" @close="closeMe" />
</app-page>
</template>
<style lang="scss" scoped>
.show-loading {
display: flex;
position: fixed;
inset: 0;
align-items: center;
justify-content: center;
background: #0b0d10;
}
.show-loading__text {
font-size: 28rpx;
color: #9aa0a6;
}
</style>
@@ -0,0 +1,485 @@
<script setup lang="ts">
/**
* 共享大厅页(短地址 /share-hall,从文件页快捷入口「共享大厅」跳入):
* 两态结构——顶层大厅只列家人群组(每张卡=一个共享目录,无任何写操作),
* 点群组进组后才出现搜索/分类/排序/新建文件夹(旧系统 FamilyGroupShare 语义:
* 大厅顶层不建目录,进组才挂 CreateFamilyDmsFolder)。
* 组内条目动作按类型分流:目录下钻(无权限提示)、图片预览、视频/音频播放、
* 下载、取消共享(DeleteFamilyDmsFolder 只摘映射不动原文件)。
* 数据链在 composables/useShareHall;权限沿用 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, useShareHall, useThemeGradient } from "@/composables";
import { pageUrl } from "@/router/routes";
import { fetchFileBytes, saveFileBytes, toPlayableUrl } from "@/utils/dmsBytes";
import { clearPlayback, setPlayback } from "@/utils/playbackBus";
const { t } = useLocale();
// 页面底渐变跟随主题(与文件页同一派生逻辑)
const { themeBackground } = useThemeGradient();
const {
groups,
files,
records,
loading,
errorText,
pathStack,
inGroup,
init,
enterGroup,
exitGroup,
openFolder,
back,
goTo,
loadFolder,
createFolder,
unshare,
} = useShareHall();
// 每次回到页面重拉:大厅群组与组内目录都可能被别的端改过
useAuthGuard(async () => {
if (inGroup.value) {
await loadFolder(pathStack.value[pathStack.value.length - 1]?.value ?? "");
}
else {
await init();
}
});
// ===== 页面状态 =====
const keyword = ref("");
const category = ref<FileCategory>("all");
const viewMode = ref<"grid" | "list">("list");
/** 排序模式:0 时间降序 / 1 升序 / 2 名称(与文件页同套) */
const sortMode = ref(0);
const showSearch = ref(true);
/** 新建文件夹对话框(folder-name-dialog 复用文件页组件) */
const folderDialogVisible = ref(false);
const folderSubmitting = ref(false);
/** 动作提交中标记:showLoading 期间防连点 */
const busy = ref(false);
/** 导航标题:大厅顶层「共享大厅」,进组后跟随当前层名(组根=群组名) */
const navTitle = computed(() =>
inGroup.value
? pathStack.value[pathStack.value.length - 1]?.displayName || t("ShareHallPage.title")
: t("ShareHallPage.title"),
);
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 = (inGroup.value ? files.value : groups.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;
});
});
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 同套路) */
async function withBusy(task: () => Promise<void>, tip?: string): Promise<void> {
if (busy.value) {
return;
}
busy.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();
busy.value = false;
}
}
// ===== 导航 / 搜索 / 分类 =====
async function onBack(): Promise<void> {
// 组内:退一层目录,组根再退回大厅;顶层:退出页面
if (inGroup.value) {
keyword.value = "";
if (!(await back())) {
await exitGroup();
}
return;
}
// 直链直达 /share-hall 时没有返回栈:兜回文件页
if (getCurrentPages().length > 1) {
uni.navigateBack();
return;
}
uni.reLaunch({ url: pageUrl("fileTab") });
}
function onToggleSearch(): void {
showSearch.value = !showSearch.value;
}
/** 关键字确认键走服务端 contain 重查(仅组内;大厅顶层本地过滤群组名) */
async function onSearch(): Promise<void> {
if (inGroup.value) {
await loadFolder(pathStack.value[pathStack.value.length - 1]?.value ?? "", keyword.value);
}
}
/** 失败态重试:组内重查当前层,顶层重拉群组列表 */
async function onRetry(): Promise<void> {
if (inGroup.value) {
await onSearch();
return;
}
await init();
}
function onCategoryChange(key: FileCategory): void {
category.value = key;
}
function onSort(): void {
sortMode.value = (sortMode.value + 1) % 3;
}
function onToggleView(): void {
viewMode.value = viewMode.value === "grid" ? "list" : "grid";
}
/** 面包屑点某段:截断到该层(组根段回组根而非出组,出组走返回箭头) */
async function onCrumbNav(index: number): Promise<void> {
keyword.value = "";
await goTo(index);
}
// ===== 条目动作层 =====
/** 点目录:有权限下钻,无权限由 openFolder 抛文案 toast */
async function enterFolder(file: FileItem): Promise<void> {
try {
await openFolder(file);
keyword.value = "";
}
catch (error: any) {
toast(error?.message || t("FilePage.actionFailed"));
}
}
async function doPreview(file: FileItem): Promise<void> {
const record = records.value.get(file.id);
if (!record) {
return;
}
await withBusy(async () => {
const urls = [toPlayableUrl(await fetchFileBytes(record))];
uni.previewImage({ urls, current: urls[0] });
});
}
async function doDownload(file: FileItem): Promise<void> {
const record = records.value.get(file.id);
if (!record) {
return;
}
await withBusy(async () => {
await saveFileBytes(await fetchFileBytes(record));
toast(t("FilePage.downloadDone"));
}, t("FilePage.downloading"));
}
/** 视频/音频播放:Url 直链直接进播放页,否则字节拼整后本地播(与文件页同策略) */
async function doPlay(file: FileItem): Promise<void> {
const record = records.value.get(file.id);
if (!record) {
return;
}
const kind = file.kind === "video" ? "video" : "audio";
if (kind === "video" && record.Url) {
goPlay(kind, file.name, record.Url);
return;
}
await withBusy(async () => {
const payload = await fetchFileBytes(record);
goPlay(kind, file.name, toPlayableUrl(payload));
}, t("FilePage.loadingMedia"));
}
function goPlay(kind: "video" | "audio", name: string, url: string): void {
// 播放地址走模块级总线传递,query 只带类型与展示名(文件页同款:blob 地址经 query 二次编码会跳转失败)
setPlayback({ type: kind, name, url });
uni.navigateTo({
url: `${pageUrl("mediaPlay")}?type=${kind}&name=${encodeURIComponent(name)}`,
fail: (err: any) => {
clearPlayback();
toast(err?.errMsg || t("FilePage.actionFailed"));
},
});
}
async function doUnshare(file: FileItem): Promise<void> {
const ok = await confirmDialog(
t("ShareHallPage.unshare"),
t("ShareHallPage.unshareConfirmText").replace("{name}", file.name),
);
if (!ok) {
return;
}
await withBusy(async () => {
await unshare(file);
toast(t("ShareHallPage.unshareDone"));
});
}
/** 按条目类型拼动作集(系统动作面板),取消共享任何类型都有 */
function sheetActions(file: FileItem): Array<{ key: string; label: string }> {
const actions: Array<{ key: string; label: string }> = [];
if (file.kind === "folder") {
actions.push({ key: "enter", label: t("ShareHallPage.enter") });
}
if (file.kind === "image") {
actions.push({ key: "preview", label: t("ShareHallPage.preview") });
}
if (file.kind === "video" || file.kind === "audio") {
actions.push({ key: "play", label: t("ShareHallPage.play") });
}
if (file.kind !== "folder") {
actions.push({ key: "download", label: t("ShareHallPage.download") });
}
// 大厅顶层的群组卡没有共享记录,不给取消共享
if (inGroup.value) {
actions.push({ key: "unshare", label: t("ShareHallPage.unshare") });
}
return actions;
}
function openSheet(file: FileItem): void {
const actions = sheetActions(file);
uni.showActionSheet({
itemList: actions.map(a => a.label),
success: (res) => {
const action = actions[res.tapIndex];
if (action.key === "enter") {
// 顶层群组卡的「打开」是进组,组内目录卡的「打开」才是下钻
if (inGroup.value) {
enterFolder(file);
}
else {
enterTopGroup(file);
}
}
else if (action.key === "preview") {
doPreview(file);
}
else if (action.key === "play") {
doPlay(file);
}
else if (action.key === "download") {
doDownload(file);
}
else if (action.key === "unshare") {
doUnshare(file);
}
},
});
}
/** 单击条目:目录进组/下钻,图片预览,音视频播放,文档走动作面板(无预览能力) */
function onOpen(file: FileItem): void {
if (!inGroup.value) {
enterTopGroup(file);
return;
}
if (file.kind === "folder") {
enterFolder(file);
}
else if (file.kind === "image") {
doPreview(file);
}
else if (file.kind === "video" || file.kind === "audio") {
doPlay(file);
}
else {
openSheet(file);
}
}
/** 大厅顶层点群组卡:进组 */
async function enterTopGroup(group: FileItem): Promise<void> {
await withBusy(async () => {
await enterGroup(group);
category.value = "all";
keyword.value = "";
});
}
// ===== 新建文件夹(仅组内) =====
function onNewFolder(): void {
folderDialogVisible.value = true;
}
async function onCreateFolder(name: string): Promise<void> {
folderSubmitting.value = true;
try {
await createFolder(name);
toast(t("ShareHallPage.createDone"));
folderDialogVisible.value = false;
}
catch (error: any) {
toast(error?.message || t("FilePage.actionFailed"));
}
finally {
folderSubmitting.value = false;
}
}
</script>
<template>
<!-- 二级页不带 tabbar(app-page 默认关) -->
<app-page :hide-nav="true">
<view class="hall-page" :style="{ background: themeBackground }">
<view class="hall-page__head">
<!-- 导航:标题随层级变化;返回箭头常显(顶层退出回文件页,组内退层);搜索图标仅组内;传输/快捷入口开关关闭 -->
<page-navbar :title="navTitle" :show-back="true" :search-on="showSearch" :show-transfer="false"
:show-quick-toggle="false" :show-search-toggle="inGroup" @back="onBack"
@toggle-search="onToggleSearch" />
<!-- 组内才有的路径条:两层以上显示(组根不显示,与文件页惯例一致) -->
<view v-if="inGroup && pathStack.length > 1" class="hall-page__crumb">
<file-breadcrumb :items="pathStack" @nav="onCrumbNav" />
</view>
<search-input v-if="inGroup && showSearch" v-model="keyword"
:placeholder="t('FilePage.searchPlaceholder')" @search="onSearch" />
<!-- 分类栏:新建文件夹仅组内出现(大厅顶层无写操作),共享大厅不做批量多选 -->
<file-tab-filter v-if="inGroup" :tabs="tabs" :active="category" :view-mode="viewMode"
:show-new-folder="true" :show-select="false" @change="onCategoryChange" @sort="onSort"
@toggle-view="onToggleView" @new-folder="onNewFolder" />
</view>
<!-- 滚动窗口外层包装=卡片本体:margin/圆角/白底/裁剪在这层(与文件页同因) -->
<view class="hall-page__scroll-wrap">
<scroll-view scroll-y class="hall-page__scroll">
<view class="hall-page__card">
<view v-if="loading" class="hall-page__state">
<text class="hall-page__state-text">
{{ t("FilePage.loading") }}
</text>
</view>
<view v-else-if="errorText" class="hall-page__state">
<text class="hall-page__state-text hall-page__state-text--error">
{{ errorText }}
</text>
<text class="hall-page__retry" @click="onRetry()">
{{ t("FilePage.retryBut") }}
</text>
</view>
<file-grid-list v-else :files="visibleFiles" :view-mode="viewMode" :selected-ids="[]"
:empty-text="inGroup ? t('ShareHallPage.emptyText') : t('ShareHallPage.emptyGroups')"
@open="onOpen" @more="openSheet" />
</view>
<view class="hall-page__bottom-space" />
</scroll-view>
</view>
<!-- 新建文件夹:复用文件页命名对话框(默认文案就是新建) -->
<folder-name-dialog :visible="folderDialogVisible" :submitting="folderSubmitting"
@close="folderDialogVisible = false" @confirm="onCreateFolder" />
</view>
</app-page>
</template>
<style lang="scss" scoped>
/* 骨架样式与文件页/回收站同套(fixed 全屏 + head/scroll-wrap 卡片分区),前缀换 hall-page */
.hall-page {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
flex-direction: column;
overflow: hidden;
/* 渐变底由 script 的 themeBackground 按主题 primary 动态计算(内联 style 下发) */
}
.hall-page__head {
flex-shrink: 0;
}
/* 面包屑分区:与文件页同节奏的间隔(面包屑自身就是白卡片) */
.hall-page__crumb {
padding: 20rpx 24rpx 0;
}
/* 滚动窗口外层包装=卡片本体 */
.hall-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 的圆角窗口裁剪 */
.hall-page__scroll {
height: 100%;
}
.hall-page__bottom-space {
height: 48rpx;
}
.hall-page__state {
display: flex;
align-items: center;
justify-content: center;
gap: 24rpx;
padding: 64rpx 32rpx;
}
.hall-page__state-text {
font-size: 26rpx;
color: #9aa0a6;
}
.hall-page__state-text--error {
color: #f0413e;
}
.hall-page__retry {
padding: 8rpx 28rpx;
border-radius: 999rpx;
font-size: 26rpx;
color: #2b8fff;
background-color: #eaf4ff;
}
</style>
+56 -50
View File
@@ -15,6 +15,7 @@
* 权限暂用 UserOperation(见 routes.ts 的 PAGE_PERMISSIONS);useAuthGuard 兜冷启动直达。
*/
import type { FolderRow } from "@/components/folder-move-picker/folder-move-picker.vue";
import type { ShareTarget } from "@/components/folder-share-picker/folder-share-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";
@@ -22,14 +23,12 @@ 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";
@@ -180,12 +179,37 @@ function onSearch(value: string): void {
search(value);
}
/** 展览/播放三入口:以当前目录为上下文,mode=list + key=目录路径跳对应播放器页 */
function goShowWithFolder(name: "photoShow" | "audioHall" | "videoHall"): void {
const folder = pathStack.value[pathStack.value.length - 1]?.value ?? "";
uni.navigateTo({
url: `${pageUrl(name)}?mode=list&key=${encodeURIComponent(folder)}`,
fail: (err: any) => toast(err?.errMsg || t("FilePage.actionFailed")),
});
}
function onQuickClick(key: string): void {
// 回收站已落地(pages/trash);其余入口仍是占位提示
// 回收站/共享大厅(pages/trash、pages/share-hall);相册/音乐厅/影视厅展览当前目录
if (key === "trash") {
uni.navigateTo({ url: pageUrl("trash") });
return;
}
if (key === "shareCenter") {
uni.navigateTo({ url: pageUrl("shareHall") });
return;
}
if (key === "album") {
goShowWithFolder("photoShow");
return;
}
if (key === "musicHall") {
goShowWithFolder("audioHall");
return;
}
if (key === "videoHall") {
goShowWithFolder("videoHall");
return;
}
const entry = quickEntries.value.find(e => e.key === key);
toast(entry ? `${entry.label} · ${t("FilePage.todoTip")}` : t("FilePage.todoTip"));
}
@@ -600,15 +624,12 @@ function goPlay(type: "video" | "audio", name: string, url: string): void {
});
}
// ----- 共享家人:选收藏夹里的家人目录,组装 FamilyGroupFolderEntity 批量建(旧 handleSelectShare)-----
// ----- 共享家人:共享大厅式抽屉选目标目录(群组卡→逐级下钻→共享到此),
// 多选文件一次批量建(CreateFamilyGroupFolder 数组入参,旧 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> {
function actionShare(targets: FileItem[]): void {
const picked = targets.filter(f => f.kind !== "folder");
if (!picked.length) {
toast(t("FilePage.shareNoFile"));
@@ -616,41 +637,11 @@ async function actionShare(targets: FileItem[]): Promise<void> {
}
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;
}
async function onShareConfirm(target: ShareTarget): Promise<void> {
const filesToShare = shareTargets.value;
shareVisible.value = false;
await withBusy(async () => {
const rows = filesToShare.map((file) => {
const record = recordOf(file);
@@ -660,7 +651,7 @@ async function onShareConfirm(key: string): Promise<void> {
// 旧系统组装:FolderName 文件系统名、FolderLocalName 原始名、FilePath 目标共享目录
fgp.FolderName = record?.FileName ?? file.path;
fgp.FolderLocalName = record?.OrgFileName ?? file.name;
fgp.FilePath = target.key;
fgp.FilePath = target.filePath;
return fgp;
});
await dmsFileService.CreateFamilyGroupFolder(rows);
@@ -701,7 +692,8 @@ async function actionPublish(targets: FileItem[]): Promise<void> {
publishVisible.value = true;
publishLoading.value = true;
try {
let names: string[] = [];
// 拉到的专辑行含名称(PublishLocalName)/描述(Notice)/编号(PublishName),不能只留编号
let rows: Array<{ PublishName?: string; PublishLocalName?: string; Notice?: string }> = [];
if (first.kind === "image") {
const criteria = new PublishPhotoSearchCriteria();
criteria.Filter = [];
@@ -709,7 +701,7 @@ async function actionPublish(targets: FileItem[]): Promise<void> {
criteria.page = 1;
criteria.GridColumns = [];
const data = await publishPhotoService.FindBySearchCriteria(criteria);
names = (data?.Publishs ?? []).map(p => p.PublishName);
rows = data?.Publishs ?? [];
}
else if (first.kind === "video") {
const criteria = new PublishVideoSearchCriteria();
@@ -718,7 +710,7 @@ async function actionPublish(targets: FileItem[]): Promise<void> {
criteria.page = 1;
criteria.GridColumns = [];
const data = await publishVideoService.FindBySearchCriteria(criteria);
names = (data?.Datas ?? []).map(p => p.PublishName);
rows = data?.Datas ?? [];
}
else {
const criteria = new PublishAudioSearchCriteria();
@@ -727,9 +719,24 @@ async function actionPublish(targets: FileItem[]): Promise<void> {
criteria.page = 1;
criteria.GridColumns = [];
const data = await publishAudioService.FindBySearchCriteria(criteria);
names = (data?.Datas ?? []).map(p => p.PublishName);
rows = data?.Datas ?? [];
}
publishItems.value = names.filter(Boolean).map(n => ({ key: n, label: n }));
// 主行显示专辑名称(无名称回落编号),副行显示描述 + 编号;key 仍用编号供 onPublishConfirm 发布
publishItems.value = rows
.filter(p => p?.PublishName)
.map((p) => {
const code = p.PublishName || "";
const hasName = !!p.PublishLocalName;
const label = p.PublishLocalName || code;
const parts: string[] = [];
if (p.Notice) {
parts.push(p.Notice);
}
if (hasName) {
parts.push(code);
}
return { key: code, label, sub: parts.join(" · ") };
});
}
catch (error: any) {
toast(error?.message || t("FilePage.actionFailed"));
@@ -966,9 +973,8 @@ const transferIconWhite = transferGlyph("#ffffff");
<!-- 移动:网盘式逐级下钻选择器(进层浏览,「移动到此」提交当前层后直接调移动接口) -->
<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"
<!-- 共享家人:共享大厅式抽屉(群组卡→逐级下钻→「共享到此」),多选文件一次批量共享 -->
<folder-share-picker :visible="shareVisible" :selected-count="shareTargets.length" @close="shareVisible = false"
@confirm="onShareConfirm" />
<!-- 发布:选专辑 -->
<option-picker :visible="publishVisible" :title="publishTitle" :items="publishItems" :loading="publishLoading"
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+12
View File
@@ -28,8 +28,16 @@ export const PAGE_PATHS = {
transport: "pages/transport/transport",
// 媒体播放页(短地址 /play):文件页视频/音频动作跳入,query 带 type/name/url
mediaPlay: "pages/media/play/play",
// 相册展览(短地址 /photo-show):多媒体页专辑「展览」+ 文件页相册快捷入口跳入,query 带 mode/key
photoShow: "pages/media/photo-show/photo-show",
// 音乐厅(短地址 /audio-hall):多媒体页音乐专辑「展览」+ 文件页音乐快捷入口跳入,query 带 mode/key
audioHall: "pages/media/audio-hall/audio-hall",
// 影视厅(短地址 /video-hall):多媒体页影视专辑「展览」+ 文件页影视快捷入口跳入,query 带 mode/key
videoHall: "pages/media/video-hall/video-hall",
// 回收站(短地址 /trash):文件页快捷入口跳入,还原/彻底删除
trash: "pages/trash/trash",
// 共享大厅(短地址 /share-hall):文件页快捷入口跳入,家人共享目录浏览
shareHall: "pages/share-hall/share-hall",
} as const;
export type PageName = keyof typeof PAGE_PATHS;
@@ -84,7 +92,11 @@ export const PAGE_PERMISSIONS: Partial<Record<PageName, PermissionNames>> = {
mineTab: "DmsFile",
transport: "DmsFile",
mediaPlay: "DmsFile",
photoShow: "DmsFile",
audioHall: "DmsFile",
videoHall: "DmsFile",
trash: "DmsFile",
shareHall: "DmsFile",
};
/**
+15 -5
View File
@@ -44,7 +44,12 @@ export interface UploadItem {
finishedAt: number;
}
/** 并发 3(旧系统 FileUpload 默认 4,移动端蜂窝网络下保守一档) */
/**
* 多文件并发上传(并发 3,旧系统 FileUpload 默认 4,移动端蜂窝网络下保守一档)。
* 并发传同一目录曾触发后端 dms_folder_map 主键 Duplicate entry,根因是编号生成器
* SecurityGeneratorNumberService.GetNo 的非原子「读-改-写」;后端已改为数据库端原子自增,
* 故此处恢复并发、pump() 也不再按 targetPath 串行。
*/
const MAX_PARALLEL = 3;
/** 可中止句柄:uni 的 UploadTask 与 XHR 都满足这个最小面 */
@@ -166,12 +171,17 @@ export const useUploadQueueStore = defineStore(
}
// ===== 队列驱动 =====
/** 补位式调度:不满并发上限就把排队任务逐个点着 */
/**
* 补位式调度:不满并发上限就把排队任务逐个点着。
* 后端编号生成器(GetNo)已改成数据库端原子自增,多文件并发传同一目录不再争抢
* dms_folder_map 主键,故不再按 targetPath 串行,同目录也可并行、充分提速。
*/
function pump(): void {
let busy = items.value.filter(
const busy = items.value.filter(
(item: UploadItem) => item.status === "uploading",
).length;
while (busy < MAX_PARALLEL) {
let free = MAX_PARALLEL - busy;
while (free > 0) {
const next = items.value.find(
(item: UploadItem) => item.status === "queued",
);
@@ -179,7 +189,7 @@ export const useUploadQueueStore = defineStore(
break;
}
runTask(next);
busy += 1;
free -= 1;
}
}
@@ -0,0 +1,20 @@
/**
* 播放模式(列表/单曲/随机)自定义图标(内联 SVG,不依赖 iconfont 字体文件)。
* 字形为用户从 iconfont.cn 挑选的「循环双箭头 + 播放三角」图标(viewBox 1024),
* 原 fill 色改为按参数注入:点亮青 / 未点亮白两态复用同一组 path。
* 颜色嵌进 data URI,无需 CSS mask(兼容性最好)。
* 保留两条 <path>(外框圆角矩形+环 / 中央播放三角),合并成单 path 会因 fill-rule 破坏镂空。
*/
/* 字形分段存(每个元素一条子路径):单行超长拼接会撞 operator-linebreak/Prettier 对撞 */
const PLAY_MODE_SEGMENTS = [
"M813.248 1024H209.92A210.176 210.176 0 0 1 0 814.016V210.752A210.176 210.176 0 0 1 209.92 0.832h603.328a210.112 210.112 0 0 1 209.856 209.92v603.264A210.112 210.112 0 0 1 813.248 1024zM225.472 73.408a153.088 153.088 0 0 0-152.896 152.896v572.096a153.152 153.152 0 0 0 152.896 152.96h572.224a153.088 153.088 0 0 0 152.896-152.96V226.304a153.024 153.024 0 0 0-152.896-152.896H225.472z m429.952 621.44a222.528 222.528 0 0 1-149.056 39.296c-2.496-0.128-4.928-0.64-7.424-0.896-4.736-0.704-9.472-1.216-14.144-2.112l-8.448-2.048a165.12 165.12 0 0 1-13.44-3.392l-6.4-2.112a225.92 225.92 0 0 1-15.104-5.568c-1.152-0.384-2.24-0.96-3.264-1.408-5.824-2.624-11.52-5.184-17.088-8.192l-0.768-0.448a223.36 223.36 0 0 1-50.816-38.144c-0.256-0.128-0.448-0.512-0.704-0.768a183.552 183.552 0 0 1-16.064-17.984 223.488 223.488 0 0 1-48.768-139.136h58.688L268.608 370.816 174.656 511.936h58.688c0 61.888 19.328 119.232 52.096 166.592 0.384 0.64 0.64 1.344 1.088 1.92 3.392 4.928 7.168 9.344 10.816 14.08 1.344 1.664 2.624 3.52 3.968 5.056 5.376 6.528 11.072 12.736 17.024 18.752 0.576 0.512 1.024 1.216 1.6 1.664 19.712 19.712 41.856 36.16 65.984 49.472 0.704 0.384 1.344 0.768 1.984 1.024 6.912 3.84 14.08 7.168 21.312 10.432l5.44 2.432c6.208 2.496 12.608 4.672 19.008 6.912l9.152 3.008c5.632 1.664 11.328 3.136 17.088 4.352 3.776 0.96 7.68 1.984 11.584 2.624 1.6 0.384 3.2 0.96 4.736 1.216 5.44 0.896 10.944 1.408 16.384 2.048 1.984 0.256 3.904 0.704 5.824 0.832a292.096 292.096 0 0 0 197.376-51.712 35.264 35.264 0 0 0-40.384-57.792m165.888-182.912c0-61.632-19.2-118.912-51.84-166.144a12.032 12.032 0 0 0-1.344-2.368c-4.032-5.824-8.448-11.2-12.8-16.64l-1.536-1.984a292.864 292.864 0 0 0-109.76-82.496c-1.28-0.448-2.304-1.088-3.52-1.536a345.6 345.6 0 0 0-28.224-10.048 244.48 244.48 0 0 0-18.496-4.8c-3.392-0.768-6.848-1.664-10.304-2.304-1.728-0.384-3.392-0.896-5.12-1.216-4.608-0.768-9.152-1.088-13.888-1.728l-9.536-1.28a290.88 290.88 0 0 0-23.424-1.088c-1.408 0-2.688-0.256-4.16-0.256l-0.768 0.064a291.84 291.84 0 0 0-167.872 53.12 35.2 35.2 0 1 0 40.576 57.664 222.08 222.08 0 0 1 149.312-39.168l5.952 0.64c5.248 0.704 10.624 1.472 15.68 2.496 2.368 0.448 4.544 1.088 6.848 1.6 5.12 1.088 10.24 2.368 15.168 3.776l4.672 1.536c5.696 1.92 11.2 3.968 16.768 6.144l1.6 0.768a222.528 222.528 0 0 1 84.608 63.68c31.808 38.592 50.816 87.872 50.816 141.568h-58.816l94.08 141.056 94.016-141.056h-58.688z",
"M630.72 492.672l-156.16-113.856a17.152 17.152 0 0 0-19.776-0.576 23.552 23.552 0 0 0-10.112 19.904v227.712c0 8.256 3.84 15.872 10.112 19.904 2.88 1.92 6.144 2.88 9.408 2.88a17.984 17.984 0 0 0 10.432-3.456l156.096-113.92A23.68 23.68 0 0 0 639.872 512a24.064 24.064 0 0 0-9.152-19.328z",
];
export function playModeGlyph(color: string): string {
const paths = PLAY_MODE_SEGMENTS.map(
(d) => `<path fill="${color}" d="${d}" />`,
).join("");
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">${paths}</svg>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}