This commit is contained in:
owenchen committed 2026-10-21 15:05:55 +08:00
1 parent fd67fa8f63
commit 211d2cd253
31 files changed
+1830 -454

No files matched your search

@@ -45,6 +45,9 @@ import { copyIntoAppUploadDir } from "@/utils/appUploadFile";
// 不含 H5 运行时补的短地址别名,故路径比较只能用真实路径,跳转 URL 才走 pageUrl()
const loginPage = toRealPath("login");
const timeout = 60000;
// 常规接口超时:断网/线路不可达时 60 秒才会失败,App 里表现为按钮一直转(“一直停留”),
// 收到 20 秒快速收敛;分片上传仍用上面的 timeout(大文件需要更长时间)
const requestTimeout = 20000;
// ==================== 登录态(JWT) ====================
@@ -290,7 +293,7 @@ const httpRequestConfig: RequestConfig = {
header: {
"content-type": "application/json",
},
timeout,
timeout: requestTimeout,
meta: {
originalData: true, // 拦截器收到完整响应(含 statusCode/header),自行解包
toast: true, // 出错默认提示,单次请求可传 meta.toast=false 关闭
@@ -117,4 +117,20 @@ function onChange(index: number): void {
border-top: 1rpx solid rgba(0, 0, 0, 0.06);
box-shadow: 0 -6rpx 28rpx rgba(0, 0, 0, 0.18);
}
/*
* 中间「多媒体」tab 的文字与其它 tab 文字水平对齐。
* u-tabbar 原逻辑:普通项内容=[图标 40rpx + gap 8rpx + 文字] 在容器内垂直居中;
* 中间项图标绝对定位凸出、不占流,组件给容器硬加 padding-top=图标半径+8rpx(内联 style),
* 文字于是单独在剩余空间里居中,与普通项文字上下错位。
* 修法:压掉内联 padding-top,给文字补上与普通项等高的前置占位(iconSize 40 + gap 8 = 48rpx),
* 两边内容总高一致、同一条基线。若以后改 iconSize/gap,这里的 48rpx 要同步。
*/
:deep(.u-tabbar__content__circle__container) {
padding-top: 0 !important;
}
:deep(.u-tabbar__content__circle__container .u-tabbar__content__item__text) {
margin-top: 48rpx;
}
</style>
@@ -70,7 +70,8 @@ const gridActions = computed<ActionDef[]>(() => {
return actions;
});
/** 行式区:媒体预览/播放(按类型取一)+ 共享/发布(仅文件,目录在旧系统被过滤) */
/** 行式区:媒体预览/播放(按类型取一)+ 共享/发布(仅文件,目录在旧系统被过滤;
* 发布只服务图片/视频/音频,文档没有发布功能不显示) */
const listActions = computed<ActionDef[]>(() => {
const file = props.file;
if (!file) {
@@ -89,9 +90,10 @@ const listActions = computed<ActionDef[]>(() => {
}
if (file.kind !== "folder") {
actions.push({ key: "share", label: t("FilePage.actionShare"), icon: "share-fill" });
// 发布命名:媒体类型统一「发布专辑」(不超 4 字),文档仍「发布」
const isMedia = file.kind === "image" || file.kind === "video" || file.kind === "audio";
actions.push({ key: "publish", label: isMedia ? t("FilePage.actionPublishAlbum") : t("FilePage.actionPublish"), icon: "volume-fill" });
// 发布命名:媒体类型统一「发布专辑」(不超 4 字);文档不显示发布
if (file.kind !== "document") {
actions.push({ key: "publish", label: t("FilePage.actionPublishAlbum"), icon: "volume-fill" });
}
}
return actions;
});
@@ -1,6 +1,6 @@
<script setup lang="ts">
/**
* 共享家人目标选择器(底部抽屉,共享大厅同款两态):顶层列家人群组卡片
* 共享到群组目录目标选择器(底部抽屉,共享大厅同款两态):顶层列家人群组卡片
* (图2 样式),点群组进组后逐级浏览目录,「共享到此」提交当前所在层。
* 替代旧的收藏夹单选列表——目标不再限定收藏过的目录,群组根与任何有权限
* 的目录都能接收共享(后端 CreateFamilyGroupFolder 纯插入不校验目录)。
@@ -5,6 +5,8 @@
* 页面注入的 loadFull(分片拼整 + 落盘),就绪后立即原地替换为高清图。
* 顶部工具组(左旋/右旋/放大/缩小/高清/关闭):旋转与缩放是每张图独立状态,
* 换图后各自恢复;旋转字形为自绘 SVG(utils/rotateGlyph,图标库无旋转语义)。
* 手势:双指捏合缩放(围绕两指中点,未放大也能捏),放大后单指拖动平移;
* 捏合/放大期间 swiper 翻页让位,小图状态单指横扫才切上/下一张。
* 缩略图缺失(老文件没有 Scale 图)时打开即自动拉原图兜底。
* 关闭时回收 H5 blob: 地址(App 端是落盘文件路径,留着下次预览复用)。
* 组件不认识 service/记录类型:原图加载器由页面以函数注入(与 admin 组件「不认识 service」约定一致)。
@@ -44,8 +46,10 @@ const props = withDefaults(
defineProps<{
visible: boolean;
items?: ImageViewerItem[];
/** 打开时定位的起始图(连览整个列表时从点击那张开始),越界自动夹取 */
startIndex?: number;
}>(),
{ items: () => [] },
{ items: () => [], startIndex: 0 },
);
const emit = defineEmits<{
@@ -69,7 +73,6 @@ watch(
() => props.visible,
(v) => {
if (v) {
index.value = 0;
slots.value = (props.items ?? []).map(item => ({
src: item.thumb || "",
full: undefined,
@@ -80,6 +83,8 @@ watch(
y: 0,
dragging: false,
}));
// 先建槽后定位:起始索引按实际条数夹取,越界落回第一张
index.value = Math.min(Math.max(0, props.startIndex), Math.max(0, slots.value.length - 1));
// 没有缩略图的老文件:打开即自动拉原图兜底,不留白等用户点按钮
slots.value.forEach((_, i) => {
if (!slots.value[i].src) {
@@ -136,11 +141,41 @@ let dragStartY = 0;
let dragOriginX = 0;
let dragOriginY = 0;
/** 放大后才启用单指平移:小图状态单指手势留给 swiper 翻页 */
// 双指捏合会话:起始间距与起始倍率/偏移,松到一指后交还单指拖动
const pinching = ref(false);
let pinchStartDist = 0;
let pinchStartScale = 1;
let pinchStartX = 0;
let pinchStartY = 0;
let pinchFocusX = 0;
let pinchFocusY = 0;
/** 两指间距(px):捏合倍率按它的变化比例算 */
function touchDistance(a: any, b: any): number {
return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
}
/** 手势开始:双指进捏合会话(未放大也能捏),单指仅在放大后拖动,小图状态把手势留给 swiper 翻页 */
function onImageTouchStart(e: any): void {
const slot = slots.value[index.value];
const touch = e.touches?.[0];
if (!slot || !touch || slot.scale <= 1) {
if (!slot) {
return;
}
const touches = e.touches ?? [];
if (touches.length >= 2) {
pinching.value = true;
// 沿用 dragging 关掉 transform 过渡,捏合才有跟手感
slot.dragging = true;
pinchStartDist = Math.max(1, touchDistance(touches[0], touches[1]));
pinchStartScale = slot.scale;
pinchStartX = slot.x;
pinchStartY = slot.y;
pinchFocusX = (touches[0].clientX + touches[1].clientX) / 2;
pinchFocusY = (touches[0].clientY + touches[1].clientY) / 2;
return;
}
const touch = touches[0];
if (!touch || slot.scale <= 1) {
return;
}
slot.dragging = true;
@@ -150,11 +185,27 @@ function onImageTouchStart(e: any): void {
dragOriginY = slot.y;
}
/** 拖动平移:transform 里 translate 在最外层,位移沿屏幕轴、与旋转角无关,始终跟手 */
/** 手势移动:双指按间距比例缩放(围绕两指中点,图片不会从手指下跑掉);单指平移沿屏幕轴,与旋转角无关 */
function onImageTouchMove(e: any): void {
const slot = slots.value[index.value];
const touch = e.touches?.[0];
if (!slot || !slot.dragging || !touch) {
if (!slot) {
return;
}
const touches = e.touches ?? [];
if (pinching.value && touches.length >= 2) {
const next = Math.min(4, Math.max(0.6, pinchStartScale * touchDistance(touches[0], touches[1]) / pinchStartDist));
// 围绕焦点缩放:把焦点相对图片中心的偏移按倍率差缩放,替代单纯改 scale(否则总向屏幕中心收缩)
const k = next / pinchStartScale;
const fx = pinchFocusX - viewWidth / 2;
const fy = pinchFocusY - viewHeight / 2;
slot.scale = next;
slot.x = fx - k * (fx - pinchStartX);
slot.y = fy - k * (fy - pinchStartY);
clampOffset(slot);
return;
}
const touch = touches[0];
if (!slot.dragging || !touch || slot.scale <= 1) {
return;
}
slot.x = dragOriginX + touch.clientX - dragStartX;
@@ -162,15 +213,29 @@ function onImageTouchMove(e: any): void {
clampOffset(slot);
}
function onImageTouchEnd(): void {
/** 手势结束:松完则收尾;双指松掉一根时用剩下那根就地接续拖动,避免图片跳一下 */
function onImageTouchEnd(e?: any): void {
const slot = slots.value[index.value];
if (slot) {
if (!slot) {
return;
}
const touches = e?.touches ?? [];
if (touches.length === 0) {
pinching.value = false;
slot.dragging = false;
return;
}
if (pinching.value) {
pinching.value = false;
dragStartX = touches[0].clientX;
dragStartY = touches[0].clientY;
dragOriginX = slot.x;
dragOriginY = slot.y;
}
}
/** 放大(>1 倍)时禁用 swiper 翻页,把单指手势让给图片平移 */
const panEnabled = computed(() => (slots.value[index.value]?.scale ?? 1) > 1);
/** 双指捏合或放大(>1 倍)时禁用 swiper 翻页,把单指手势让给图片平移 */
const panEnabled = computed(() => pinching.value || (slots.value[index.value]?.scale ?? 1) > 1);
/** 放大/缩小:倍率夹在 0.6~4,超出范围按钮照点不生效(变换仍平滑);缩放后收一下平移量 */
function zoomCurrent(factor: number): void {
@@ -389,7 +454,7 @@ function onImageError(i: number): void {
transition: transform 0.25s ease;
}
/* 拖动中关掉过渡:否则图片会追着手指爬,跟手感全无 */
/* 手势会话中(拖动/捏合)关掉过渡:否则图片会追着手指爬,跟手感全无 */
.iv-img--dragging {
transition: none;
}
@@ -0,0 +1,219 @@
<script setup lang="ts">
/**
* 通用条目动作面板:头部文件卡(类型图标 + 名称/元信息)+ 宫格动作 + 取消通栏。
* file-action-sheet 的通用版——动作集由调用方按业务喂进来(共享中心的下载/取消共享等),
* 组件只渲染与派发,不认识 service;视觉与网盘动作面板保持一致,替代系统 showActionSheet。
*/
import type { PropType } from "vue";
import type { FileItem, FileKind } from "@/data/custom/fileBrowser";
import { useLocale } from "uview-pro";
import { useThemeTokens } from "@/composables";
export interface ItemSheetAction {
key: string;
label: string;
icon: string;
/** 危险动作(取消共享/删除):图标与文字标红 */
danger?: boolean;
}
const props = defineProps({
/** 面板是否展开 */
visible: {
type: Boolean,
default: false,
},
/** 目标条目;null 时渲染空面板(正常情况下 visible=true 必有值) */
file: {
type: Object as PropType<FileItem | null>,
default: null,
},
/** 本次条目可用的动作集(调用方按类型派生) */
actions: {
type: Array as PropType<ItemSheetAction[]>,
default: () => [],
},
});
const emit = defineEmits<{
/** 面板关闭(遮罩/取消按钮) */
(e: "close"): void;
/** 点了某个动作,原样带上目标条目 */
(e: "action", key: string, file: FileItem): void;
}>();
const { t } = useLocale();
// 文件夹图形随主题(与 file-action-sheet 同一强调渐变)
const { accentGradient, accentSoft } = useThemeTokens();
// 类型 -> 头部图标与配色,与 file-grid-list 的 kindVisual 同一套
const kindVisual: Record<Exclude<FileKind, "folder">, { icon: string; color: string }> = {
image: { icon: "photo-fill", color: "#0ea5e9" },
video: { icon: "play-right-fill", color: "#8b5cf6" },
document: { icon: "file-text-fill", color: "#3b82f6" },
audio: { icon: "volume-up", color: "#f59e0b" },
};
function onAction(action: ItemSheetAction): void {
if (props.file) {
emit("action", action.key, props.file);
}
}
</script>
<template>
<!-- u-popup 只认 modelValue(v-model),绑 :show 会静默弹不出 -->
<u-popup :model-value="visible" mode="bottom" round="24" @close="emit('close')">
<view class="item-action-sheet">
<!-- 头部白卡:类型图标 + 名称/元信息 -->
<view class="item-action-sheet__head">
<view v-if="file && file.kind === 'folder'" class="item-action-sheet__folder">
<view class="item-action-sheet__folder-tab" :style="{ background: accentSoft }" />
<view class="item-action-sheet__folder-body" :style="{ background: accentGradient }" />
</view>
<u-icon v-else-if="file" :name="kindVisual[file.kind as Exclude<FileKind, 'folder'>].icon" size="64"
:color="kindVisual[file.kind as Exclude<FileKind, 'folder'>].color" />
<view class="item-action-sheet__meta">
<text class="item-action-sheet__name">
{{ file?.name }}
</text>
<text class="item-action-sheet__sub">
{{ file && file.kind !== 'folder' ? `${file.sizeText} · ` : '' }}{{ file?.modifiedAt }}
</text>
</view>
</view>
<!-- 宫格动作:白卡上图标下文字,危险动作标红 -->
<view class="item-action-sheet__grid">
<view v-for="action in actions" :key="action.key" class="item-action-sheet__grid-btn"
@tap="onAction(action)">
<u-icon :name="action.icon" size="44" :color="action.danger ? '#f0413e' : '#1f2329'" />
<text class="item-action-sheet__grid-text"
:style="action.danger ? { color: '#f0413e' } : undefined">
{{ action.label }}
</text>
</view>
</view>
<!-- 取消通栏:退出锚点 -->
<view class="item-action-sheet__cancel" @tap="emit('close')">
<text class="item-action-sheet__cancel-text">
{{ t("FilePage.folderCancel") }}
</text>
</view>
<view class="item-action-sheet__safe" />
</view>
</u-popup>
</template>
<style lang="scss" scoped>
/* 底部抽屉统一风格(对齐 file-action-sheet):浅灰面板底 + 白色圆角卡分块 */
.item-action-sheet {
padding: 32rpx 24rpx 0;
background-color: #f2f3f5;
&__head {
display: flex;
align-items: center;
gap: 24rpx;
margin-bottom: 20rpx;
padding: 28rpx 32rpx;
border-radius: 20rpx;
background-color: #ffffff;
}
/* 文件夹造型与 file-grid-list 同源:主体 + 左上页签 */
&__folder {
position: relative;
flex-shrink: 0;
width: 84rpx;
height: 68rpx;
}
&__folder-tab {
position: absolute;
top: 0;
left: 0;
width: 36rpx;
height: 16rpx;
border-radius: 8rpx 8rpx 0 0;
}
&__folder-body {
position: absolute;
top: 12rpx;
left: 0;
right: 0;
bottom: 0;
border-radius: 10rpx;
}
&__meta {
display: flex;
flex: 1;
flex-direction: column;
gap: 8rpx;
min-width: 0;
}
&__name {
overflow: hidden;
font-size: 32rpx;
font-weight: 600;
color: #1f2329;
text-overflow: ellipsis;
white-space: nowrap;
}
&__sub {
font-size: 24rpx;
color: #8f959e;
}
&__grid {
display: flex;
gap: 16rpx;
margin-bottom: 20rpx;
}
/* 宫格钮:白卡、上图标下文字居中,动作数 ≤4 时平分一行 */
&__grid-btn {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
padding: 28rpx 0;
border-radius: 20rpx;
background-color: #ffffff;
}
&__grid-text {
margin-top: 14rpx;
font-size: 24rpx;
color: #1f2329;
}
/* 取消通栏:与内容隔一段灰底收尾,防误点动作区 */
&__cancel {
display: flex;
align-items: center;
justify-content: center;
margin-top: 28rpx;
padding: 26rpx 0;
border-radius: 20rpx;
background-color: #ffffff;
}
&__cancel-text {
font-size: 28rpx;
color: #5c626b;
}
/* 底部留白:贴屏安全区(H5 无 env 时也有 32rpx 呼吸) */
&__safe {
height: calc(32rpx + env(safe-area-inset-bottom));
}
}
</style>
@@ -1,7 +1,7 @@
<script setup lang="ts">
/**
* 通用单选列表弹层(底部抽屉):标题 + 可滚动选项行 + 确认按钮。
* 「共享家人」选目标群组目录、「发布」选专辑都吃它——数据由页面拉好喂进来,组件不发请求。
* 「共享到群组目录」选目标群组目录、「发布」选专辑都吃它——数据由页面拉好喂进来,组件不发请求。
* 行为:展开时重置为「未选中」(防上次残留误提交);点行仅选中,点确认才 emit confirm(key);
* loading 显示转圈占位,items 空且不在 loading 时显示 emptyText。
*/
@@ -83,6 +83,14 @@ export function useFamilyGroups() {
),
);
/** 我的申请是否待审:非管理员时后端把「我自己的 ValidIndc=N 申请」放进 FamilyGroupRequests
* (管理员视角这里才是全群待审名单,两种视角前端都只用 UserName 比对我自己) */
const isPending = computed(() =>
(detail.value.FamilyGroupRequests ?? []).some(
(r) => r.UserName === myName.value,
),
);
function setMyUser(name: string): void {
myName.value = name;
}
@@ -257,12 +265,26 @@ export function useFamilyGroups() {
await openGroup(familyNameOf());
}
/** 移除某成员对某目录的权限:摘掉明细后整单提交,后端 SavePermissions 按「缺失即删」落库
* (无单条 Delete 接口;未展示的管理员记录仍在明细里,不会被连带删掉) */
async function removePermission(
folderName: string,
userName: string,
): Promise<void> {
detail.value.FamilyGroupPermissions = (
detail.value.FamilyGroupPermissions ?? []
).filter((p) => !(p.FolderName === folderName && p.UserName === userName));
await save();
}
/** 申请加入当前群(非成员,公开且可申请的群才会露这个入口) */
async function applyJoin(notice: string): Promise<void> {
const entity = new FamilyGroupManagerEntity();
entity.FamilyName = familyNameOf();
entity.Notice = notice;
await familyGroupService.InsertFamilyGroupManager(entity);
// 提交后刷新详情:后端会把我的待审申请放进 FamilyGroupRequests,头卡据此显示「已申请,待审核」
await openGroup(familyNameOf());
}
// ===== 保存 / 解散 =====
@@ -317,6 +339,7 @@ export function useFamilyGroups() {
isEdit,
isManager,
isMember,
isPending,
setMyUser,
// list
loadList,
@@ -334,6 +357,7 @@ export function useFamilyGroups() {
removeMember,
toggleManager,
assignPermission,
removePermission,
applyJoin,
// save / dissolve
save,
@@ -2,12 +2,14 @@
* 展览播放器数据装载:把旧 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,
* 2. mode=share:key 为「群组系统名|共享目录路径」,走 FindFamilyBySearchCriteria 拉共享目录
* (共享中心的媒体不存在自己网盘里,list 模式拉不到),再按 kindOf 在本地筛出媒体;
* 3. 否则 key 非空:按专辑编号取明细(photo 用 FindPublishPhotoDetailByPublishName,
* audio/video 无该方法 -> FindByKey(key).Details);
* 3. 否则(无 key):取默认专辑(photo FindPublishPhotoDetail 全量,audio/video FindPublishXxxDefault)。
* 4. 否则(无 key):取默认专辑(photo FindPublishPhotoDetail 全量,audio/video FindPublishXxxDefault)。
*
* 后端把同一素材给两个地址:缩略图/转码给 Formatted*,原图/原码给 Url/FileName。
* 幻灯轮播与列表用缩略图(加载快),精看/下载/播放用原图原码。视频原码 Url 对专辑明细
@@ -15,12 +17,14 @@
*/
import { DmsFileSearchCriteria } from "@/data/auto/dmsFileSearchCriteria";
import { DmsFileUploadViewDisplayData as UploadView } from "@/data/auto/dmsFileUploadViewDisplayData";
import { FamilyDmsFileViewDisplayData as FamilyView } from "@/data/auto/familyDmsFileViewDisplayData";
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";
import { kindOf } from "./useDmsFiles";
const FIND_LIMIT = 999999;
@@ -89,6 +93,38 @@ async function listRows(fileType: string, path: string): Promise<any[]> {
return data?.Datas ?? [];
}
/** 拆「群组系统名|共享目录路径」:组根路径为空(与 useShareHall 组根语义一致) */
function splitShareKey(key: string): [string, string] {
const idx = key.indexOf("|");
return idx < 0 ? [key, ""] : [key.slice(0, idx), key.slice(idx + 1)];
}
/** mode=share:拉共享目录全量,媒体类型本地用 kindOf 筛(后端共享接口不做类型过滤) */
async function shareRows(key: string, kind: "audio" | "video"): Promise<any[]> {
const [familyName, path] = splitShareKey(key);
const criteria = new DmsFileSearchCriteria();
const filters: SearchFilter[] = [];
const familyMatch = new SearchFilter();
familyMatch.Column = FamilyView.DB_FAMILY_NAME;
familyMatch.Type = "SingleSelection";
familyMatch.Operator = "single";
familyMatch.Value = familyName;
filters.push(familyMatch);
const pathMatch = new SearchFilter();
pathMatch.Column = FamilyView.DB_FILE_PATH;
pathMatch.Type = "SingleSelection";
pathMatch.Operator = "single";
// 组根直属记录的 FilePath 存的是群组编号(useShareHall.loadFolder 同口径)
pathMatch.Value = path || familyName;
filters.push(pathMatch);
criteria.Filter = filters;
criteria.limit = FIND_LIMIT;
criteria.page = 1;
criteria.GridColumns = [];
const data = await dmsFileService.FindFamilyBySearchCriteria(criteria);
return (data?.Families ?? []).filter((r) => kindOf(r) === kind);
}
export function useMediaShow() {
/** 相册展览条目 */
async function loadPhotos(mode: string, key: string): Promise<SlideItem[]> {
@@ -128,6 +164,17 @@ export function useMediaShow() {
name: baseName(r.OrgFileName || r.Url),
}));
}
if (mode === "share") {
// 共享中心跳厅:以共享目录为播放列表(与文件页 list 同形态,数据源换共享接口)
const rows = await shareRows(key, "audio");
return rows.map((r: any) => ({
url: r.Url,
id: r.FileId,
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();
@@ -154,6 +201,18 @@ export function useMediaShow() {
name: baseName(r.OrgFileName || r.Url),
}));
}
if (mode === "share") {
// 共享中心跳厅:以共享目录为片源列表(与文件页 list 同形态,数据源换共享接口)
const rows = await shareRows(key, "video");
return rows.map((r: any) => ({
url: r.Url,
id: r.FileId,
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();
@@ -12,7 +12,9 @@
* 无权限的目录点进去要提示;
* 4. 组内新建目录:CreateFamilyDmsFolder({ FilePath, FolderLocalName, FamilyName, Notice })
* (旧 DmsFolderModal 的 isFamilyGroup 分支,不是文件页的 CreateDmsFolder);
* 5. 取消共享:DeleteFamilyDmsFolder(record)(旧 onDelete,摘掉的是共享映射不动原文件)。
* 5. 取消共享:DeleteFamilyDmsFolder(record)(旧 onDelete,摘掉的是共享映射不动原文件);
* 后端不校验操作人身份,显隐规则在前端收口:仅群管理员或该条共享的创建者(CreatedBy)可见;
* 6. 管理员身份:进组时 familyGroupService.FindByKey(family).IsManager 权威回填。
*/
import type { FamilyDmsFileViewDisplayData } from "@/data/auto/familyDmsFileViewDisplayData";
import type { FileItem, PathNode } from "@/data/custom/fileBrowser";
@@ -23,6 +25,7 @@ import { FamilyDmsFileViewDisplayData as FamilyView } from "@/data/auto/familyDm
import { FamilyDmsFileViewEntity } from "@/data/auto/familyDmsFileViewEntity";
import { SearchFilter } from "@/data/auto/searchFilter";
import dmsFileService from "@/service/master/dmsFileService";
import familyGroupService from "@/service/master/familyGroupService";
import {
formatModifiedLabel,
formatSize,
@@ -47,6 +50,10 @@ export function useShareHall() {
const errorText = ref("");
const currPath = ref("");
const pathStack = ref<PathNode[]>([]);
/** 当前登录名:判断「这条共享是不是我共享进来的」(记录 CreatedBy 就是共享操作人) */
const myUser = ref("");
/** 我是否当前群管理员:进组时 FindByKey 权威回填(非成员/非管理员为 false) */
const isManager = ref(false);
/** 是否在组内(组根也算):决定导航返回、面包屑与「新建文件夹」按钮显隐 */
const inGroup = computed(() => !!currentFamily.value);
@@ -162,11 +169,62 @@ export function useShareHall() {
}
}
/** 记录当前登录名(页面登录态就绪后回填) */
function setMyUser(name: string): void {
myUser.value = name;
}
/** 进组后回填管理员身份:失败只当非管理员(少露一个取消共享,不阻断浏览) */
async function loadManagerState(): Promise<void> {
if (!currentFamily.value) {
isManager.value = false;
return;
}
try {
const data = await familyGroupService.FindByKey(currentFamily.value);
isManager.value = !!data?.IsManager;
} catch {
isManager.value = false;
}
}
/** 「取消共享」显隐:群管理员,或这条共享的创建者本人(CreatedBy=把文件共享进群的人);
* 其他成员即使能浏览也不能取消,前端收口(后端 DeleteFamilyDmsFolder 无操作人校验) */
function canUnshare(file: FileItem): boolean {
if (isManager.value) {
return true;
}
const record = records.value.get(file.id);
return !!record && !!myUser.value && record.CreatedBy === myUser.value;
}
/** 进组:路径栈重置为组根(旧系统 rootPath="" 的组根语义),value 空串供面包屑回组根 */
async function enterGroup(group: FileItem): Promise<void> {
currentFamily.value = group.path;
pathStack.value = [{ value: "", displayName: group.name }];
await loadFolder("");
// 取消共享的显隐依赖管理员身份:进组时一并回填
await loadManagerState();
}
/** 群组详情「共享目录」行直达:跳过大厅直接开到指定群组的指定目录(folder 空=组根);
* 目录路径语义同 openFolder 下钻:FolderName 即 family_dms_file_view 的 file_name */
async function openAt(
familyName: string,
familyLabel: string,
folder: string,
folderLabel: string,
): Promise<void> {
currentFamily.value = familyName;
pathStack.value = [{ value: "", displayName: familyLabel || familyName }];
if (folder) {
pathStack.value.push({
value: folder,
displayName: folderLabel || folder,
});
}
await loadFolder(folder);
await loadManagerState();
}
/** 退出到大厅:清组与列表,重拉群组(组内可能刚建过目录) */
@@ -176,6 +234,7 @@ export function useShareHall() {
records.value = new Map();
pathStack.value = [];
currPath.value = "";
isManager.value = false;
await init();
}
@@ -248,8 +307,12 @@ export function useShareHall() {
pathStack,
currentFamily,
inGroup,
isManager,
setMyUser,
canUnshare,
init,
enterGroup,
openAt,
exitGroup,
openFolder,
back,
@@ -9,6 +9,7 @@ export class FamilyGroupData
public IsManager: boolean =false;
public IsOpen: boolean =false;
public IsAdd: boolean =false;
public IsOpenDir: boolean =false;
public FamilyGroupFolders: FamilyGroupFolderDisplayData[] = [];
public FamilyGroupManagers: FamilyGroupManagerDisplayData[] = [];
public FamilyGroupRequests: FamilyGroupManagerDisplayData[] = [];
@@ -1,40 +1,43 @@
export class FamilyGroupEntity
{
public AddIndc: string = "";
public CreatedBy: string = "";
public CreatedDatetime: Date = new Date();
public ExpireDatetime?: Date= null!;
public FamilyLocalName: string = "";
public FamilyName: string = "";
public Notice: string = "";
public OpenIndc: string = "";
public UpdatedBy: string = "";
public CreatedBy: string = "";
public UpdatedDatetime: Date = new Date();
public UpdatedBy: string = "";
public FamilyName: string = "";
public FamilyLocalName: string = "";
public Notice: string = "";
public ExpireDatetime?: Date= null!;
public OpenIndc: string = "";
public AddIndc: string = "";
public ValidIndc: string = "";
public OpenDirIndc: string = "";
static readonly ADD_INDC : string ='AddIndc';
static readonly CREATED_BY : string ='CreatedBy';
static readonly CREATED_DATETIME : string ='CreatedDatetime';
static readonly EXPIRE_DATETIME : string ='ExpireDatetime';
static readonly FAMILY_LOCAL_NAME : string ='FamilyLocalName';
static readonly FAMILY_NAME : string ='FamilyName';
static readonly NOTICE : string ='Notice';
static readonly OPEN_INDC : string ='OpenIndc';
static readonly UPDATED_BY : string ='UpdatedBy';
static readonly CREATED_BY : string ='CreatedBy';
static readonly UPDATED_DATETIME : string ='UpdatedDatetime';
static readonly UPDATED_BY : string ='UpdatedBy';
static readonly FAMILY_NAME : string ='FamilyName';
static readonly FAMILY_LOCAL_NAME : string ='FamilyLocalName';
static readonly NOTICE : string ='Notice';
static readonly EXPIRE_DATETIME : string ='ExpireDatetime';
static readonly OPEN_INDC : string ='OpenIndc';
static readonly ADD_INDC : string ='AddIndc';
static readonly VALID_INDC : string ='ValidIndc';
static readonly OPEN_DIR_INDC : string ='OpenDirIndc';
static readonly DB_NAME_FAMILY_GROUP : string ='family_group';
static readonly DB_NAME_FIELDS : string ='add_indc,created_by,created_datetime,expire_datetime,family_local_name,family_name,notice,open_indc,updated_by,updated_datetime,valid_indc';
static readonly DB_ADD_INDC : string ='add_indc';
static readonly DB_CREATED_BY : string ='created_by';
static readonly DB_NAME_FIELDS : string ='created_datetime,created_by,updated_datetime,updated_by,family_name,family_local_name,notice,expire_datetime,open_indc,add_indc,valid_indc,open_dir_indc';
static readonly DB_CREATED_DATETIME : string ='created_datetime';
static readonly DB_EXPIRE_DATETIME : string ='expire_datetime';
static readonly DB_FAMILY_LOCAL_NAME : string ='family_local_name';
static readonly DB_FAMILY_NAME : string ='family_name';
static readonly DB_NOTICE : string ='notice';
static readonly DB_OPEN_INDC : string ='open_indc';
static readonly DB_UPDATED_BY : string ='updated_by';
static readonly DB_CREATED_BY : string ='created_by';
static readonly DB_UPDATED_DATETIME : string ='updated_datetime';
static readonly DB_UPDATED_BY : string ='updated_by';
static readonly DB_FAMILY_NAME : string ='family_name';
static readonly DB_FAMILY_LOCAL_NAME : string ='family_local_name';
static readonly DB_NOTICE : string ='notice';
static readonly DB_EXPIRE_DATETIME : string ='expire_datetime';
static readonly DB_OPEN_INDC : string ='open_indc';
static readonly DB_ADD_INDC : string ='add_indc';
static readonly DB_VALID_INDC : string ='valid_indc';
static readonly DB_OPEN_DIR_INDC : string ='open_dir_indc';
}
@@ -3,9 +3,13 @@ import { FamilyGroupManagerEntity } from "./familyGroupManagerEntity";
export class FamilyGroupManagerDisplayData extends FamilyGroupManagerEntity
{
public UserLocalName: string = "";
public UserMobile: string = "";
public AvatarUrl: string = "";
public IsManager: boolean =false;
static readonly USER_LOCAL_NAME : string ='UserLocalName';
static readonly DB_USER_LOCAL_NAME : string ='user_local_name';
static readonly USER_MOBILE : string ='UserMobile';
static readonly AVATAR_URL : string ='AvatarUrl';
static readonly IS_MANAGER : string ='IsManager';
}
+24 -4
View File
@@ -29,7 +29,7 @@
"home": "Home",
"file": "Cloud Drive",
"media": "Media",
"share": "Share",
"share": "Groups",
"mine": "Me"
},
"MinePage": {
@@ -608,7 +608,7 @@
"actionMove": "Move",
"actionDownload": "Download",
"actionDelete": "Delete",
"actionShare": "Share to Family",
"actionShare": "Share to Group Folder",
"actionShareShort": "Share",
"actionPublish": "Publish",
"actionPublishAlbum": "Publish Album",
@@ -644,7 +644,7 @@
"moveSelected": "{count} item(s) selected",
"moveHere": "Move here",
"moveNoSub": "No subfolders",
"shareTitle": "Choose a family folder",
"shareTitle": "Choose a group folder",
"shareHere": "Share here",
"shareDone": "Shared",
"shareNoFile": "No shareable files",
@@ -704,7 +704,7 @@
"emptyText": "This folder is empty"
},
"SharePage": {
"title": "Family Groups",
"title": "My Groups",
"searchPlaceholder": "Search group name",
"createTitle": "Create Group",
"noAccess": "This group is private and you are not a member",
@@ -712,6 +712,8 @@
"chipPublic": "Public",
"chipPrivate": "Private",
"chipJoinable": "Joinable",
"chipPending": "Applied, pending review",
"chipAudit": "Pending",
"chipManager": "Admin",
"chipMember": "Member",
"emptyGroups": "No groups to join yet",
@@ -720,18 +722,32 @@
"save": "Save",
"amend": "Edit",
"dissolve": "Disband",
"dissolveHint": "Shared folders and memberships will be deleted together",
"dissolveConfirm": "Disband \"{name}\"? Folders and memberships will be removed together",
"dissolveDone": "Group disbanded",
"saveDone": "Saved",
"createDone": "Group created",
"nameRequired": "Enter a group name",
"tabOverview": "Overview",
"tabMembers": "Members",
"tabPerms": "Permissions",
"statMembers": "members",
"statManagers": "managers",
"statPending": "pending",
"groupInfo": "Group Info",
"groupName": "Group Name",
"groupDesc": "Description",
"publicSetting": "Public group",
"joinApply": "Allow join requests",
"joinClosed": "Closed",
"openDir": "Share all folders",
"openDirPublic": "Shared",
"openDirPrivate": "Restricted",
"folders": "Shared Folders",
"newFolder": "New Folder",
"editFolder": "Edit folder",
"folderName": "Folder name",
"folderNote": "Note",
"add": "Add",
"folderPh": "Enter folder name",
"notePh": "Note (optional)",
@@ -750,9 +766,13 @@
"rejectConfirm": "Reject this request?",
"rejectDone": "Rejected",
"members": "Members",
"noMembers": "No members yet",
"removeMember": "Remove",
"removeMemberConfirm": "Remove this member from the group? Their folder permissions will be cleared",
"permissions": "Folder Permissions",
"revoke": "Revoke",
"revokeConfirm": "Remove this member's access to the folder? They will no longer be able to open it",
"revokeDone": "Folder access revoked",
"applyJoin": "Join",
"submitApply": "Submit",
"joinPh": "Tell the admin why you want to join",
+26 -6
View File
@@ -29,7 +29,7 @@
"home": "首页",
"file": "网盘",
"media": "多媒体",
"share": "共享",
"share": "群组",
"mine": "我的"
},
"MinePage": {
@@ -608,7 +608,7 @@
"actionMove": "移动",
"actionDownload": "下载",
"actionDelete": "删除",
"actionShare": "共享家人",
"actionShare": "共享到群组目录",
"actionShareShort": "共享",
"actionPublish": "发布",
"actionPublishAlbum": "发布专辑",
@@ -644,7 +644,7 @@
"moveSelected": "已选择 {count} 项",
"moveHere": "移动到此",
"moveNoSub": "没有子文件夹",
"shareTitle": "选择家人目录",
"shareTitle": "选择群组目录",
"shareHere": "共享到此",
"shareDone": "共享成功",
"shareNoFile": "没有可共享的文件",
@@ -704,7 +704,7 @@
"emptyText": "这个目录是空的"
},
"SharePage": {
"title": "家庭群组",
"title": "我的群组",
"searchPlaceholder": "搜索群组名称",
"createTitle": "创建群组",
"noAccess": "该群组为私密,您还不是成员",
@@ -712,26 +712,42 @@
"chipPublic": "公开",
"chipPrivate": "私密",
"chipJoinable": "可申请",
"chipPending": "已申请,待审核",
"chipAudit": "待审核",
"chipManager": "管理员",
"chipMember": "成员",
"emptyGroups": "还没有可加入的群组",
"noDesc": "暂无群组描述",
"cancel": "取消",
"save": "保存",
"amend": "修改",
"amend": "编辑",
"dissolve": "解散群",
"dissolveHint": "会同时删除全部共享目录与成员关系",
"dissolveConfirm": "确定解散「{name}」?解散后目录与成员关系一并清除",
"dissolveDone": "已解散群组",
"saveDone": "已保存",
"createDone": "群组已创建",
"nameRequired": "请输入群组名称",
"tabOverview": "概览",
"tabMembers": "成员",
"tabPerms": "权限",
"statMembers": "位成员",
"statManagers": "管理员",
"statPending": "待审核",
"groupInfo": "群组信息",
"groupName": "群组名称",
"groupDesc": "群组描述",
"publicSetting": "公开群组",
"joinApply": "允许申请加入",
"joinClosed": "不开放",
"openDir": "公开成员目录权限",
"openDirPublic": "公开",
"openDirPrivate": "仅授权",
"folders": "共享目录",
"newFolder": "新建目录",
"editFolder": "编辑目录",
"folderName": "目录名称",
"folderNote": "备注",
"add": "添加",
"folderPh": "请输入目录名称",
"notePh": "备注(选填)",
@@ -750,9 +766,13 @@
"rejectConfirm": "确定拒绝该申请?",
"rejectDone": "已拒绝",
"members": "正式成员",
"noMembers": "暂无正式成员",
"removeMember": "移出",
"removeMemberConfirm": "确定将该成员移出群组?其目录权限一并清除",
"permissions": "目录权限",
"revoke": "移除",
"revokeConfirm": "确定移除该成员对此目录的权限?移除后其将无法再访问该目录",
"revokeDone": "已移除目录权限",
"applyJoin": "申请加入",
"submitApply": "提交申请",
"joinPh": "请填写申请原因,方便管理员审核",
@@ -915,4 +935,4 @@
"r404_3": "页面已下线或改名,收藏夹里还是旧地址",
"r404_4": "服务器刚重启、菜单还没加载出来,刷新一次即可"
}
}
}
+20 -1
View File
@@ -1,7 +1,11 @@
import * as Pinia from "pinia";
import uViewPro, { httpPlugin } from "uview-pro";
import { createSSRApp } from "vue";
import { DEFAULT_LOCALE } from "@/common/locale-const";
import {
DEFAULT_LOCALE,
UI_LOCALE_EN,
currentUiLocale,
} from "@/common/locale-const";
import themes from "@/common/uview-pro.theme";
import { enUS, mergeDbLocaleMessages, zhCN } from "@/locale";
// #ifdef H5
@@ -20,6 +24,21 @@ registerCleanUrls();
setupUnknownPathFallback();
// #endif
/**
* uni.showModal 按钮文案本地化:App 端走系统原生弹窗,按钮默认英文 OK/CANCEL
* (H5 才是「确定/取消」);全项目确认弹窗几乎都不显式传 confirmText/cancelText,
* 故全局包一层按当前界面语言补齐,显式传了的仍以调用方为准。
*/
const rawShowModal = uni.showModal.bind(uni);
(uni as any).showModal = (options: any) => {
const isEn = currentUiLocale() === UI_LOCALE_EN;
return rawShowModal({
confirmText: isEn ? "OK" : "确定",
cancelText: isEn ? "Cancel" : "取消",
...(options && typeof options === "object" ? options : {}),
});
};
export function createApp() {
const app = createSSRApp(App);
app.use(uViewPro, {
@@ -2,6 +2,7 @@
/**
* 音乐厅页(对齐旧 WebVue views/master/PreviewAudio.vue):
* 黑胶唱机播放器外壳。入参经 query 传:mode=list&key=<目录路径> 播放当前目录音频;
* mode=share&key=<群组系统名|共享目录路径> 播放共享目录音频(共享中心跳入);
* 或 key=<专辑编号> 播放某音乐专辑;无 key 走默认专辑。取数逻辑见 composables/useMediaShow。
* 文件页单击某首音频进来时额外带 start=<FileId>,定位到该曲并从它开始播。
* 隐藏控制区时(v-model:controls)本壳顺带隐掉导航/系统栏做整页沉浸(H5 元素全屏在组件内处理)。
@@ -69,14 +70,8 @@ onLoad(async (query) => {
<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"
:start-index="startIndex"
:auto-play="autoStart"
@close="closeMe"
/>
<vinyl-audio-player v-else v-model:controls="controls" :tracks="tracks" :start-index="startIndex"
:auto-play="autoStart" @close="closeMe" />
</app-page>
</template>
@@ -2,6 +2,7 @@
/**
* 影视厅页(对齐旧 WebVue views/master/PreviewVideo.vue):
* 自绘控制播放器外壳。入参经 query 传:mode=list&key=<目录路径> 播放当前目录视频;
* mode=share&key=<群组系统名|共享目录路径> 播放共享目录视频(共享中心跳入);
* 或 key=<专辑编号> 播放某影视专辑;无 key 走默认专辑。取数逻辑见 composables/useMediaShow。
* 文件页单击某个视频进来时额外带 start=<FileId>,定位到该片源并从它开始播。
* 隐藏控制区时(v-model:controls)本壳顺带隐掉导航/系统栏做整页沉浸。
@@ -68,14 +69,8 @@ onLoad(async (query) => {
<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"
:start-index="startIndex"
:auto-play="autoStart"
@close="closeMe"
/>
<video-player v-else v-model:controls="controls" :videos="videos" :start-index="startIndex" :auto-play="autoStart"
@close="closeMe" />
</app-page>
</template>
@@ -5,16 +5,20 @@
* 点群组进组后才出现搜索/分类/排序/新建文件夹(旧系统 FamilyGroupShare 语义:
* 大厅顶层不建目录,进组才挂 CreateFamilyDmsFolder)。
* 组内条目动作按类型分流:目录下钻(无权限提示)、图片预览、视频/音频播放、
* 下载、取消共享(DeleteFamilyDmsFolder 只摘映射不动原文件)。
* 下载、取消共享(DeleteFamilyDmsFolder 只摘映射不动原文件;仅管理员或该条共享的创建者可见);
* 「…」与单击非媒体文件统一弹自绘动作面板(item-action-sheet,视觉同网盘)。
* 数据链在 composables/useShareHall;权限沿用 DmsFile(routes.ts),useAuthGuard 兜冷启动直达。
*/
import type { ImageViewerItem } from "@/components/image-viewer/image-viewer.vue";
import type { ItemSheetAction } from "@/components/item-action-sheet/item-action-sheet.vue";
import type { FileCategory, FileItem, FileTabItem } from "@/data/custom/fileBrowser";
import { onLoad } from "@dcloudio/uni-app";
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";
import { useLoginProfileStore } from "@/stores";
import { fetchFileBytes, saveFileBytes, toImageUrl } from "@/utils/dmsBytes";
const { t } = useLocale();
@@ -28,9 +32,13 @@ const {
loading,
errorText,
pathStack,
currentFamily,
inGroup,
setMyUser,
canUnshare,
init,
enterGroup,
openAt,
exitGroup,
openFolder,
back,
@@ -40,8 +48,46 @@ const {
unshare,
} = useShareHall();
const profile = useLoginProfileStore();
/** 安全解码 query 参数(未编码/非法序列不抛 URIError) */
function decodeParam(value: unknown): string {
try {
return decodeURIComponent(String(value ?? ""));
}
catch {
return String(value ?? "");
}
}
// ===== 群组详情「共享目录」行直达参数(family/label/folder/folderLabel)=====
// onLoad 早于 useAuthGuard 的 onShow 回调,先把参数存起来,登录校验通过后消费
const entryTarget = ref<{ family: string; label: string; folder: string; folderLabel: string } | null>(null);
onLoad((query) => {
const family = decodeParam(query?.family);
if (!family) {
return;
}
entryTarget.value = {
family,
label: decodeParam(query?.label),
folder: decodeParam(query?.folder),
folderLabel: decodeParam(query?.folderLabel),
};
});
// 每次回到页面重拉:大厅群组与组内目录都可能被别的端改过
useAuthGuard(async () => {
// 取消共享的显隐要拿当前登录名比对记录创建者(管理员身份由进组时回填)
setMyUser(profile.username);
// 带直达参数首次显示:跳过大厅直接开到目标群组的指定目录(消费一次后清空,返回再显不重走)
const target = entryTarget.value;
if (target) {
entryTarget.value = null;
await openAt(target.family, target.label, target.folder, target.folderLabel);
return;
}
if (inGroup.value) {
await loadFolder(pathStack.value[pathStack.value.length - 1]?.value ?? "");
}
@@ -197,15 +243,36 @@ async function enterFolder(file: FileItem): Promise<void> {
}
}
async function doPreview(file: FileItem): Promise<void> {
const record = records.value.get(file.id);
if (!record) {
// ----- 图片查看器(与文件页同款:缩略图秒开 + 按需原图 + 双指缩放/拖动)-----
const viewerVisible = ref(false);
const viewerItems = ref<ImageViewerItem[]>([]);
/** 打开时定位的起始图(连览时从点击那张开始) */
const viewerStartIndex = ref(0);
/** 图片预览:连览当前可见列表的全部图片(按列表排序),从点击那张开始,左右滑动切前/后一张 */
function doPreview(file: FileItem): void {
const images = visibleFiles.value.filter(f => f.kind === "image");
if (!images.length) {
return;
}
await withBusy(async () => {
const urls = [toPlayableUrl(await fetchFileBytes(record))];
uni.previewImage({ urls, current: urls[0] });
viewerStartIndex.value = Math.max(0, images.findIndex(f => f.id === file.id));
viewerItems.value = images.map((item) => {
const preview: ImageViewerItem = {
name: item.name,
// 列表已加载的 Scale 缩略图秒开;老文件无缩略图时查看器会自动拉原图兜底
thumb: item.thumbUrl || "",
loadFull: async () => {
const record = records.value.get(item.id);
if (!record) {
throw new Error(t("FilePage.actionFailed"));
}
// App 端 WebView 不吃 file:// 本地文件,高清图走 data: URI(toImageUrl 内部分流)
return toImageUrl(await fetchFileBytes(record));
},
};
return preview;
});
viewerVisible.value = true;
}
async function doDownload(file: FileItem): Promise<void> {
@@ -224,32 +291,15 @@ async function doDownload(file: FileItem): Promise<void> {
}, 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 });
/** 视频/音频播放:进音乐厅/影视厅(以当前共享目录为播放列表,从该条开始播,与文件页同款);
* key 传「群组系统名|目录路径」,厅页 mode=share 走共享接口拉同目录媒体 */
function doPlay(file: FileItem): void {
const family = currentFamily.value;
const path = pathStack.value[pathStack.value.length - 1]?.value ?? "";
const hall = file.kind === "audio" ? "audioHall" : "videoHall";
uni.navigateTo({
url: `${pageUrl("mediaPlay")}?type=${kind}&name=${encodeURIComponent(name)}`,
fail: (err: any) => {
clearPlayback();
toast(err?.errMsg || t("FilePage.actionFailed"));
},
url: `${pageUrl(hall)}?mode=share&key=${encodeURIComponent(`${family}|${path}`)}&start=${file.id}`,
fail: (err: any) => toast(err?.errMsg || t("FilePage.actionFailed")),
});
}
@@ -267,60 +317,62 @@ async function doUnshare(file: FileItem): Promise<void> {
});
}
/** 按条目类型拼动作集(系统动作面板),取消共享任何类型都有 */
function sheetActions(file: FileItem): Array<{ key: string; label: string }> {
const actions: Array<{ key: string; label: string }> = [];
/** 按条目类型拼动作集(与网盘动作面板同一视觉,图标均为 uview 已验证字形) */
function sheetActions(file: FileItem): ItemSheetAction[] {
const actions: ItemSheetAction[] = [];
if (file.kind === "folder") {
actions.push({ key: "enter", label: t("ShareHallPage.enter") });
actions.push({ key: "enter", label: t("ShareHallPage.enter"), icon: "arrow-right" });
}
if (file.kind === "image") {
actions.push({ key: "preview", label: t("ShareHallPage.preview") });
actions.push({ key: "preview", label: t("ShareHallPage.preview"), icon: "eye" });
}
if (file.kind === "video" || file.kind === "audio") {
actions.push({ key: "play", label: t("ShareHallPage.play") });
actions.push({ key: "play", label: t("ShareHallPage.play"), icon: "play-circle-fill" });
}
if (file.kind !== "folder") {
actions.push({ key: "download", label: t("ShareHallPage.download") });
actions.push({ key: "download", label: t("ShareHallPage.download"), icon: "download" });
}
// 大厅顶层的群组卡没有共享记录,不给取消共享
if (inGroup.value) {
actions.push({ key: "unshare", label: t("ShareHallPage.unshare") });
// 取消共享:大厅顶层(无记录)不显示;仅群管理员或该条共享的创建者可见(后端无操作人校验,前端收口);不可逆标红
if (canUnshare(file)) {
actions.push({ key: "unshare", label: t("ShareHallPage.unshare"), icon: "minus-circle", danger: true });
}
return actions;
}
/** 当前动作面板目标;null 为关闭(「…」与单击非媒体文件共用同一入口) */
const sheetFile = ref<FileItem | null>(null);
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);
}
},
});
sheetFile.value = file;
}
/** 单击条目:目录进组/下钻,图片预览,音视频播放,文档走动作面板(无预览能力) */
/** 面板动作分发(键与 sheetActions 对应) */
function onSheetAction(key: string, file: FileItem): void {
sheetFile.value = null;
if (key === "enter") {
// 顶层群组卡的「打开」是进组,组内目录卡的「打开」才是下钻
if (inGroup.value) {
enterFolder(file);
}
else {
enterTopGroup(file);
}
}
else if (key === "preview") {
doPreview(file);
}
else if (key === "play") {
doPlay(file);
}
else if (key === "download") {
doDownload(file);
}
else if (key === "unshare") {
doUnshare(file);
}
}
/** 单击条目:目录进组/下钻,图片预览,音视频播放,文档与「…」同口径弹动作面板 */
function onOpen(file: FileItem): void {
if (!inGroup.value) {
enterTopGroup(file);
@@ -416,6 +468,12 @@ async function onCreateFolder(name: string): Promise<void> {
<!-- 新建文件夹:复用文件页命名对话框(默认文案就是新建) -->
<folder-name-dialog :visible="folderDialogVisible" :submitting="folderSubmitting"
@close="folderDialogVisible = false" @confirm="onCreateFolder" />
<!-- 条目动作面板:「…」与单击非媒体文件共用(自绘面板,视觉同网盘动作面板) -->
<item-action-sheet :visible="!!sheetFile" :file="sheetFile"
:actions="sheetFile ? sheetActions(sheetFile) : []" @close="sheetFile = null" @action="onSheetAction" />
<!-- 图片查看器:与文件页同款(缩略图秒开连览 + 按需原图 + 手势) -->
<image-viewer :visible="viewerVisible" :items="viewerItems" :start-index="viewerStartIndex"
@close="viewerVisible = false" />
</view>
</app-page>
</template>
+23 -13
View File
@@ -299,7 +299,7 @@ const pickedKind = computed<FileKind | null>(() => {
return picked.every(f => f.kind === first.kind) ? first.kind : null;
});
/** 旧系统 publishTooltip 简化口径:媒体类型统一「发布专辑」(按钮不超 4 字),文档仍「发布」 */
/** 旧系统 publishTooltip 简化口径:发布只服务媒体类型,统一「发布专辑」(按钮不超 4 字) */
const publishLabel = computed(() => {
const kind = pickedKind.value;
return kind === "image" || kind === "video" || kind === "audio"
@@ -337,7 +337,7 @@ const previewKey = computed(() => {
/** 选中含文件即可下载(旧系统 onDownloadSelected 就是滤掉目录逐个循环下载) */
const hasFileSelected = computed(() => selectedFiles.value.some(f => f.kind !== "folder"));
/** 排1:下载(含文件即有)+ 共享/发布(同媒体类型且无目录)+ 删除恒在 */
/** 排1:下载(含文件即有)+ 共享/发布(同媒体类型且无目录,文档无发布功能不显示)+ 删除恒在 */
const multiActionsTop = computed<MultiAction[]>(() => {
const actions: MultiAction[] = [];
if (hasFileSelected.value) {
@@ -345,7 +345,10 @@ const multiActionsTop = computed<MultiAction[]>(() => {
}
if (canSharePublish.value) {
actions.push({ key: "share", label: t("FilePage.actionShareShort"), icon: "share-fill" });
actions.push({ key: "publish", label: publishLabel.value, icon: "volume-fill" });
// 发布仅图片/视频/音频(单选文档时共享仍在,发布不显示)
if (pickedKind.value !== "document") {
actions.push({ key: "publish", label: publishLabel.value, icon: "volume-fill" });
}
}
actions.push({ key: "delete", label: t("FilePage.actionDelete"), icon: "trash", danger: true });
return actions;
@@ -389,14 +392,15 @@ function onToggleSelectItem(id: number): void {
// ===== 列表条目 / 悬浮按钮 =====
async function onOpen(file: FileItem): Promise<void> {
// 文件夹下钻走真实接口;文件点开即预览/播放(旧系统 onViewPic 同款分流)
// 文件夹下钻走真实接口;图片连览、音视频进厅播放,文档与右侧「…」同口径弹出操作面板
if (await openFolder(file)) {
keyword.value = "";
selectedIds.value = [];
return;
}
if (file.kind === "image") {
actionPreview([file]);
// 连览当前可见列表的全部图片(按列表排序),从点击这张开始,左右滑动切前后一张
actionPreview(visibleFiles.value.filter(f => f.kind === "image"), file);
}
else if (file.kind === "video" || file.kind === "audio") {
// 单击即进音乐厅/影视厅(以当前目录为播放列表),从这一条开始播;
@@ -404,8 +408,9 @@ async function onOpen(file: FileItem): Promise<void> {
openInHall(file);
}
else {
// 文档没有移动端预览器,点开按下载处理(比占位 toast 实用)
actionDownload([file]);
// 文档没有移动端预览器:与右侧「…」一致弹出操作面板(下载/重命名/移动/删除等由用户选),
// 不再单击即静默下载
onItemMore(file);
}
}
@@ -597,17 +602,21 @@ async function actionDownload(targets: FileItem[]): Promise<void> {
// ----- 图片查看器(缩略图秒开 + 按需原图,见 components/image-viewer)-----
const viewerVisible = ref(false);
const viewerItems = ref<ImageViewerItem[]>([]);
/** 打开时定位的起始图(连览时从点击那张开始) */
const viewerStartIndex = ref(0);
/** 图片预览:单选一张、多选多图相册连览。
/** 图片预览:单选连览当前列表全部图片、多选只连览选中的图片。
* 缩略图先行——thumbUrl 列表已加载过(甚至命中 HTTP 缓存),组装进查看器秒开;
* 原图按需拉取(查看器底部「查看原图」触发 loadFull,走分片拼整 + 落盘旧通道)。
* 替代旧 uni.previewImage 链路:它要先串行拉全部原图字节落盘才弹窗,慢的根因在此 */
function actionPreview(targets: FileItem[]): void {
* 替代旧 uni.previewImage 链路:它要先串行拉全部原图字节落盘才弹窗,慢的根因在此。
* start 传了就从那张开始,否则从第一张开始 */
function actionPreview(targets: FileItem[], start?: FileItem): void {
const images = targets.filter(f => f.kind === "image");
if (!images.length) {
toast(t("FilePage.previewNoImage"));
return;
}
viewerStartIndex.value = start ? Math.max(0, images.findIndex(f => f.id === start.id)) : 0;
viewerItems.value = images.map((file) => {
const item: ImageViewerItem = {
name: file.name,
@@ -656,7 +665,7 @@ function goPlay(type: "video" | "audio", name: string, url: string): void {
});
}
// ----- 共享家人:共享大厅式抽屉选目标目录(群组卡→逐级下钻→共享到此),
// ----- 共享到群组目录:共享大厅式抽屉选目标目录(群组卡→逐级下钻→共享到此),
// 多选文件一次批量建(CreateFamilyGroupFolder 数组入参,旧 handleSelectShare 同口径)-----
const shareVisible = ref(false);
const shareTargets = ref<FileItem[]>([]);
@@ -1035,7 +1044,8 @@ const transferIconWhite = transferGlyph("#ffffff");
<file-action-sheet :visible="!!actionFile" :file="actionFile" @close="actionFile = null"
@action="onSheetAction" />
<!-- 图片查看器:缩略图秒开连览,底部「查看原图」按需拉原图(全屏遮罩,打开时收起 tabbar) -->
<image-viewer :visible="viewerVisible" :items="viewerItems" @close="viewerVisible = false" />
<image-viewer :visible="viewerVisible" :items="viewerItems" :start-index="viewerStartIndex"
@close="viewerVisible = false" />
<folder-name-dialog :visible="folderDialogVisible" :submitting="folderSubmitting"
@close="folderDialogVisible = false" @confirm="onCreateFolder" />
<!-- 重命名:同一个命名对话框的第二个实例(预填当前名 + 重命名文案) -->
@@ -1045,7 +1055,7 @@ const transferIconWhite = transferGlyph("#ffffff");
<!-- 移动:网盘式逐级下钻选择器(进层浏览,「移动到此」提交当前层后直接调移动接口) -->
<folder-move-picker :visible="moveVisible" :exclude-name="moveExcludeName" :source-path="moveSourcePath"
:selected-count="moveTargets.length" @close="moveVisible = false" @confirm="onMovePick" />
<!-- 共享家人:共享大厅式抽屉(群组卡→逐级下钻→「共享到此」),多选文件一次批量共享 -->
<!-- 共享到群组目录:共享大厅式抽屉(群组卡→逐级下钻→「共享到此」),多选文件一次批量共享 -->
<folder-share-picker :visible="shareVisible" :selected-count="shareTargets.length" @close="shareVisible = false"
@confirm="onShareConfirm" />
<!-- 发布:选专辑(确认键按类型叫「发布相册」等;附「新建相册」行跳媒体页创建态) -->
File diff suppressed because it is too large. Load diff
+29 -13
View File
@@ -4,10 +4,12 @@
* 骨架与文件页同套组件(导航/搜索/分类标签/列表/多选栏),差异——
* 无上传、无新建文件夹、无快捷入口、不下钻目录;动作集按旧系统
* DmsFileDeleteEnquiry 收敛为两个:还原 RestoreDmsFile、彻底删除 DeleteLog,
* 单条点击弹系统动作面板,多选栏批量,彻底删除先过确认框。
* 单条点击弹自绘动作面板(取消键跟页面语言,系统 showActionSheet 的取消键固定英文 Cancel),
* 多选栏批量,彻底删除先过确认框。
* 数据链在 composables/useTrashFiles(FindDeleteLogBySearchCriteria -> Logs),
* 条目时间列展示的是删除时间。权限沿用 DmsFile(routes.ts),useAuthGuard 兜冷启动直达。
*/
import type { ItemSheetAction } from "@/components/item-action-sheet/item-action-sheet.vue";
import type { FileCategory, FileItem, FileTabItem } from "@/data/custom/fileBrowser";
import { useLocale } from "uview-pro";
import { computed, ref } from "vue";
@@ -160,19 +162,30 @@ function onClearSelect(): void {
// ===== 动作层(单条面板与多选栏共用) =====
/** 点条目/点⋯:系统动作面板二选一(回收站条目不做预览播放——记录已进删除态) */
// ----- 单条动作面板:自绘面板(item-action-sheet,视觉同网盘/共享中心)-----
const sheetFile = ref<FileItem | null>(null);
/** 面板动作集:回收站条目不做预览播放(记录已进删除态),只有还原/彻底删除 */
function sheetActions(): ItemSheetAction[] {
return [
{ key: "restore", label: t("TrashPage.restore"), icon: "rewind-left" },
{ key: "purge", label: t("TrashPage.purge"), icon: "trash", danger: true },
];
}
/** 点条目/点⋯:打开自绘动作面板二选一 */
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]);
}
},
});
sheetFile.value = file;
}
function onSheetAction(key: string, file: FileItem): void {
sheetFile.value = null;
if (key === "restore") {
doRestore([file]);
}
else if (key === "purge") {
doPurge([file]);
}
}
/** 动作完成后的收尾:清勾选、退多选态、按当前关键字重查 */
@@ -306,6 +319,9 @@ function onMultiPurge(): void {
</view>
</view>
</view>
<!-- 单条动作面板:自绘面板(取消键走页面语言,系统 showActionSheet 的 Cancel 固定英文) -->
<item-action-sheet :visible="!!sheetFile" :file="sheetFile" :actions="sheetActions()"
@close="sheetFile = null" @action="onSheetAction" />
</view>
</app-page>
</template>
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="#059669" d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2z"/>
<circle fill="#059669" cx="9" cy="7" r="4"/>
<path fill="none" stroke="#059669" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" d="M22 21v-2a4 4 0 0 0-3-3.87"/>
<path fill="none" stroke="#059669" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>

After

Width:  |  Height:  |  Size: 458 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#606266" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M22 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>

After

Width:  |  Height:  |  Size: 332 B

+4 -2
View File
@@ -44,8 +44,10 @@ export const useTabbarStore = defineStore(
},
{
langKey: "TabBar.share",
iconPath: "share",
selectedIconPath: "share-fill",
// 群组用自定义双人 SVG(与「我的」单人 account 区分;形状同 lucide users);
// 图片图标不吃 color,故未选中/选中各烘一色(描边灰 #606266 / 实心绿 #059669)
iconPath: "/static/tabbar/group.svg",
selectedIconPath: "/static/tabbar/group-active.svg",
},
{
langKey: "TabBar.mine",