diff --git a/CloudBuilder/CloudBuilder.Master.Service/FamilyGroupService.auto.cs b/CloudBuilder/CloudBuilder.Master.Service/FamilyGroupService.auto.cs index aca7826..4614561 100644 --- a/CloudBuilder/CloudBuilder.Master.Service/FamilyGroupService.auto.cs +++ b/CloudBuilder/CloudBuilder.Master.Service/FamilyGroupService.auto.cs @@ -481,7 +481,7 @@ namespace CloudBuilder.Master.Service dto.FolderName = familyGroupFolderEntity.FolderName; - + familyGroupPermission = new FamilyGroupPermissionEntity(); familyGroupPermission.FamilyName = familyName; familyGroupPermission.FolderName = dto.FolderName; diff --git a/CloudBuilder/CloudBuilder.Master.Service/PublishAudioService.auto.cs b/CloudBuilder/CloudBuilder.Master.Service/PublishAudioService.auto.cs index 5a11c7b..c246c60 100644 --- a/CloudBuilder/CloudBuilder.Master.Service/PublishAudioService.auto.cs +++ b/CloudBuilder/CloudBuilder.Master.Service/PublishAudioService.auto.cs @@ -53,21 +53,28 @@ namespace CloudBuilder.Master.Service }; data.FamilyGroups = q.ToArray(); - if (publish.OwnerName != service.UserId && !YesNoPolicy.IsYes(publish.OpenIndc) && data.FamilyGroups.Length == 0) + + IRepository repositoryFamilyGroupManager = repository.GetRepository>(); + + //归属与群组身份:所有者全权;群管理员可删/改信息;群成员仅可改明细 + data.IsOwner = publish.OwnerName == service.UserId; + data.IsMember = data.IsOwner; + data.IsManager = data.IsOwner; + if (!data.IsOwner && data.FamilyGroups.Length > 0) + { + string[] familyNames = data.FamilyGroups.Select(x => x.FamilyName).ToArray(); + FamilyGroupManagerEntity[] mine = repositoryFamilyGroupManager.DetachedEntities + .Where(x => x.UserName == service.UserId && x.ValidIndc == YesNoPolicy.YES && familyNames.Contains(x.FamilyName)) + .ToArray(); + + data.IsMember = mine.Length > 0; + data.IsManager = mine.Any(x => x.ManagerIndc == YesNoPolicy.YES); + } + + if (!data.IsOwner && !YesNoPolicy.IsYes(publish.OpenIndc) && !data.IsMember) { throw new ValidatedException("没有权限."); } - else if (publish.OwnerName != service.UserId && !YesNoPolicy.IsYes(publish.OpenIndc) && data.FamilyGroups.Length > 0) - { - IRepository repositoryFamilyGroupManager = repository.GetRepository>(); - - IQueryable? fgm = from e in repositoryFamilyGroupManager.DetachedEntities - where e.UserName == service.UserId && data.FamilyGroups.Where(x => x.FamilyName == e.FamilyName).Count() > 0 - select e.UserName; - - if (fgm.Count() == 0) - throw new ValidatedException("没有权限."); - } data.PublishAudio = publish; @@ -76,19 +83,47 @@ namespace CloudBuilder.Master.Service if (data.Details != null && data.Details.Length > 0) { + // 明细的文件可能由群成员(非专辑所有者)从自己的网盘里挑:目录与源文件名都要按 dms_file 的真实归属取, + // 否则拼出的地址会落到专辑所有者目录下,取不到文件(500)。 + IRepository repositoryDmsFile = repository.GetRepository>(); + string[] orderKeys = data.Details + .SelectMany(x => new[] { x.Pic, x.Url, x.Lrc }) + .Where(x => !string.IsNullOrEmpty(x)) + .Select(x => x!) + .Distinct() + .ToArray(); + Dictionary files = new Dictionary(); + if (orderKeys.Length > 0) + { + DmsFileEntity[] found = repositoryDmsFile.DetachedEntities + .Where(x => x.OrderKey != null && orderKeys.Contains(x.OrderKey)) + .ToArray(); + foreach (DmsFileEntity file in found) + { + if (!string.IsNullOrEmpty(file.OrderKey) && !files.ContainsKey(file.OrderKey)) + { + files.Add(file.OrderKey, file); + } + } + } + foreach (var item in data.Details) { + item.PicName = DmsOrgName(files, item.Pic); + item.UrlName = DmsOrgName(files, item.Url); + item.LrcName = DmsOrgName(files, item.Lrc); + if (!string.IsNullOrEmpty(item.Pic) && FileTypePolicy.GetType(item.Pic!) == FileTypePolicy.PHOTO) { - item.FormattedPic = item.FormattedPic = FileTypePolicy.PackPicUrl(service, item.CreatedBy, item.Pic); + item.FormattedPic = FileTypePolicy.PackPicUrl(service, DmsOwner(files, item.Pic, item.CreatedBy), item.Pic); } if (!string.IsNullOrEmpty(item.Url) && FileTypePolicy.GetType(item.Url!) == FileTypePolicy.AUDIO) { - item.FormattedUrl = FileTypePolicy.PackUrl(service, item.CreatedBy, item.Url); + item.FormattedUrl = FileTypePolicy.PackUrl(service, DmsOwner(files, item.Url, item.CreatedBy), item.Url); } if (!string.IsNullOrEmpty(item.Lrc) && FileTypePolicy.GetType(item.Lrc!) == FileTypePolicy.LYRIC) { - item.FormattedLrc = FileTypePolicy.PackUrl(service, item.CreatedBy, item.Lrc); + item.FormattedLrc = FileTypePolicy.PackUrl(service, DmsOwner(files, item.Lrc, item.CreatedBy), item.Lrc); } } @@ -97,6 +132,41 @@ namespace CloudBuilder.Master.Service return data; } + /// 明细展示用的源文件名:查不到 dms_file 时回落物理名,界面不至于空白。 + private static string DmsOrgName(Dictionary files, string? orderKey) + { + if (string.IsNullOrEmpty(orderKey)) + { + return string.Empty; + } + + if (files.TryGetValue(orderKey, out DmsFileEntity file) && !string.IsNullOrEmpty(file.OrgFileName)) + { + return file.OrgFileName; + } + + return orderKey; + } + + /// 文件真实归属目录(网盘按人分目录):查不到就回落明细创建人,保持旧行为。 + private static string DmsOwner(Dictionary files, string? orderKey, string fallback) + { + if (!string.IsNullOrEmpty(orderKey) && files.TryGetValue(orderKey, out DmsFileEntity file)) + { + if (!string.IsNullOrEmpty(file.CreatedBy)) + { + return file.CreatedBy; + } + + if (!string.IsNullOrEmpty(file.OwerId)) + { + return file.OwerId; + } + } + + return fallback; + } + [PermissionOperate(name: MasterPermissionPolicy.PublishAudio, operate: Operate.Find)] public PublishAudioResultData FindBySearchCriteria(PublishAudioSearchCriteria criteria) { @@ -195,20 +265,68 @@ namespace CloudBuilder.Master.Service [UnitOfWork] public PublishAudioData Update(PublishAudioData data) { - PublishAudioEntity publishEntity = repository.DetachedEntities.Where(x => x.OwnerName == service.UserId && x.PublishName == data.PublishAudio.PublishName).FirstOrDefault(); - if (publishEntity == null) + //归属校验:所有者可全改;公开群组管理员可改信息/群组;普通群成员只能改明细 + PublishAudioEntity original = repository.DetachedEntities.Where(x => x.PublishName == data.PublishAudio.PublishName).FirstOrDefault(); + if (original == null) { - throw new ValidatedException("你不是发布者无法修改."); + throw new ValidatedException("记录不存在."); + } + + bool isOwner = original.OwnerName == service.UserId; + bool isManager = false; + bool isMember = false; + if (!isOwner) + { + IRepository repositoryPublishAudioFamilyGroup = repository.GetRepository>(); + IRepository repositoryFamilyGroupManager = repository.GetRepository>(); + + string[] familyNames = repositoryPublishAudioFamilyGroup.DetachedEntities + .Where(x => x.PublishName == original.PublishName) + .Select(x => x.FamilyName) + .ToArray(); + if (familyNames.Length == 0) + { + throw new ValidatedException("没有权限."); + } + + FamilyGroupManagerEntity[] mine = repositoryFamilyGroupManager.DetachedEntities + .Where(x => x.UserName == service.UserId && x.ValidIndc == YesNoPolicy.YES && familyNames.Contains(x.FamilyName)) + .ToArray(); + isMember = mine.Length > 0; + isManager = mine.Any(x => x.ManagerIndc == YesNoPolicy.YES); + if (!isMember) + { + throw new ValidatedException("没有权限."); + } + } + + //非所有者保留归属与建单信息,防客户端伪造 + data.PublishAudio.OwnerName = original.OwnerName; + data.PublishAudio.CreatedBy = original.CreatedBy; + data.PublishAudio.CreatedDatetime = original.CreatedDatetime; + if (!isOwner && !isManager) + { + //非所有者且非管理员(普通群成员)只能改明细:专辑展示字段一律以库中为准 + data.PublishAudio.PublishLocalName = original.PublishLocalName; + data.PublishAudio.Notice = original.Notice; + data.PublishAudio.OpenIndc = original.OpenIndc; + data.PublishAudio.ExpireDatetime = original.ExpireDatetime; + data.PublishAudio.OrderBy = original.OrderBy; + } + if (!isOwner) + { + //默认专辑是所有者个人偏好,群管理员也不能改 + data.PublishAudio.DefaultIndc = original.DefaultIndc; } ValidateHelper.Validate(service, data.PublishAudio, PublishAudioEntity.DB_NAME_PUBLISH_AUDIO); - if (data.PublishAudio.DefaultIndc == YesNoPolicy.YES) + if (isOwner && data.PublishAudio.DefaultIndc == YesNoPolicy.YES) { CancelDefaultIndc(data.PublishAudio.PublishName); } - publishEntity = new PublishAudioEntity(); + PublishAudioEntity publishEntity = new PublishAudioEntity(); ObjectCopyHelper.Copy(data.PublishAudio, publishEntity); repository.UpdateNow(publishEntity); @@ -216,11 +334,34 @@ namespace CloudBuilder.Master.Service //只有删除和更新,没有插入 SavePublishAudioDetails(publishEntity.PublishName, data.Details); - SavePublishAudioFamilyGroup(publishEntity.PublishName, data.FamilyGroups); + //群组增删只对所有者/群管理员开放;普通成员提交的 FamilyGroups 不落库 + if (isOwner || isManager) + { + SavePublishAudioFamilyGroup(publishEntity.PublishName, data.FamilyGroups); + } return FindByKey(publishEntity.PublishName); } + /// 当前登录人是否为该专辑任一公开群组的管理员(正式成员且 ManagerIndc=Y) + private bool IsGroupManagerOfPublish(string publishName) + { + IRepository repositoryFamilyGroup = repository.GetRepository>(); + IRepository repositoryFamilyGroupManager = repository.GetRepository>(); + + string[] familyNames = repositoryFamilyGroup.DetachedEntities + .Where(x => x.PublishName == publishName) + .Select(x => x.FamilyName) + .ToArray(); + if (familyNames.Length == 0) + { + return false; + } + + return repositoryFamilyGroupManager.DetachedEntities + .Any(x => x.UserName == service.UserId && x.ValidIndc == YesNoPolicy.YES && x.ManagerIndc == YesNoPolicy.YES && familyNames.Contains(x.FamilyName)); + } + private void SavePublishAudioDetails(string publishName, PublishAudioDetailEntity[] details) { if (details != null && details.Length > 0) @@ -320,10 +461,15 @@ namespace CloudBuilder.Master.Service [UnitOfWork] public void Delete(string key) { - PublishAudioEntity publishEntity = repository.DetachedEntities.Where(x => x.OwnerName == service.UserId && x.PublishName == key).FirstOrDefault(); + PublishAudioEntity publishEntity = repository.DetachedEntities.Where(x => x.PublishName == key).FirstOrDefault(); if (publishEntity == null) { - throw new ValidatedException("你不是发布者无法删除."); + throw new ValidatedException("记录不存在."); + } + //删除专辑:所有者或该专辑任一公开群组的管理员 + if (publishEntity.OwnerName != service.UserId && !IsGroupManagerOfPublish(key)) + { + throw new ValidatedException("没有权限."); } repository.DeleteNow(publishEntity); diff --git a/CloudBuilder/CloudBuilder.Master.Service/PublishPhotoService.auto.cs b/CloudBuilder/CloudBuilder.Master.Service/PublishPhotoService.auto.cs index ddf9bc6..b80b759 100644 --- a/CloudBuilder/CloudBuilder.Master.Service/PublishPhotoService.auto.cs +++ b/CloudBuilder/CloudBuilder.Master.Service/PublishPhotoService.auto.cs @@ -56,21 +56,28 @@ namespace CloudBuilder.Master.Service }; data.FamilyGroups = q.ToArray(); - if (publish.OwnerName != service.UserId && !YesNoPolicy.IsYes(publish.OpenIndc) && data.FamilyGroups.Length == 0) + + IRepository repositoryFamilyGroupManager = repository.GetRepository>(); + + //归属与群组身份:所有者全权;群管理员可删/改信息;群成员仅可改明细 + data.IsOwner = publish.OwnerName == service.UserId; + data.IsMember = data.IsOwner; + data.IsManager = data.IsOwner; + if (!data.IsOwner && data.FamilyGroups.Length > 0) + { + string[] familyNames = data.FamilyGroups.Select(x => x.FamilyName).ToArray(); + FamilyGroupManagerEntity[] mine = repositoryFamilyGroupManager.DetachedEntities + .Where(x => x.UserName == service.UserId && x.ValidIndc == YesNoPolicy.YES && familyNames.Contains(x.FamilyName)) + .ToArray(); + + data.IsMember = mine.Length > 0; + data.IsManager = mine.Any(x => x.ManagerIndc == YesNoPolicy.YES); + } + + if (!data.IsOwner && !YesNoPolicy.IsYes(publish.OpenIndc) && !data.IsMember) { throw new ValidatedException("没有权限."); } - else if (publish.OwnerName != service.UserId && !YesNoPolicy.IsYes(publish.OpenIndc) && data.FamilyGroups.Length > 0) - { - IRepository repositoryFamilyGroupManager = repository.GetRepository>(); - - IQueryable? fgm = from e in repositoryFamilyGroupManager.DetachedEntities - where e.UserName == service.UserId && data.FamilyGroups.Where(x => x.FamilyName == e.FamilyName).Count() > 0 - select e.UserName; - - if (fgm.Count() == 0) - throw new ValidatedException("没有权限."); - } data.PublishPhoto = publish; @@ -215,21 +222,68 @@ namespace CloudBuilder.Master.Service [UnitOfWork] public PublishPhotoData Update(PublishPhotoData data) { - //只有自己创建的才能修改 - PublishPhotoEntity publishEntity = repository.DetachedEntities.Where(x => x.OwnerName == service.UserId && x.PublishName == data.PublishPhoto.PublishName).FirstOrDefault(); - if (publishEntity == null) + //归属校验:所有者可全改;公开群组管理员可改信息/群组;普通群成员只能改明细 + PublishPhotoEntity original = repository.DetachedEntities.Where(x => x.PublishName == data.PublishPhoto.PublishName).FirstOrDefault(); + if (original == null) { throw new ValidatedException("记录不存在."); } + bool isOwner = original.OwnerName == service.UserId; + bool isManager = false; + bool isMember = false; + if (!isOwner) + { + IRepository repositoryPublishPhotoFamilyGroup = repository.GetRepository>(); + IRepository repositoryFamilyGroupManager = repository.GetRepository>(); + + string[] familyNames = repositoryPublishPhotoFamilyGroup.DetachedEntities + .Where(x => x.PublishName == original.PublishName) + .Select(x => x.FamilyName) + .ToArray(); + if (familyNames.Length == 0) + { + throw new ValidatedException("没有权限."); + } + + FamilyGroupManagerEntity[] mine = repositoryFamilyGroupManager.DetachedEntities + .Where(x => x.UserName == service.UserId && x.ValidIndc == YesNoPolicy.YES && familyNames.Contains(x.FamilyName)) + .ToArray(); + isMember = mine.Length > 0; + isManager = mine.Any(x => x.ManagerIndc == YesNoPolicy.YES); + if (!isMember) + { + throw new ValidatedException("没有权限."); + } + } + + //非所有者保留归属与建单信息,防客户端伪造 + data.PublishPhoto.OwnerName = original.OwnerName; + data.PublishPhoto.CreatedBy = original.CreatedBy; + data.PublishPhoto.CreatedDatetime = original.CreatedDatetime; + if (!isOwner && !isManager) + { + //非所有者且非管理员(普通群成员)只能改明细:专辑展示字段一律以库中为准 + data.PublishPhoto.PublishLocalName = original.PublishLocalName; + data.PublishPhoto.Notice = original.Notice; + data.PublishPhoto.OpenIndc = original.OpenIndc; + data.PublishPhoto.ExpireDatetime = original.ExpireDatetime; + data.PublishPhoto.OrderBy = original.OrderBy; + } + if (!isOwner) + { + //默认专辑是所有者个人偏好,群管理员也不能改 + data.PublishPhoto.DefaultIndc = original.DefaultIndc; + } + ValidateHelper.Validate(service, data.PublishPhoto, PublishPhotoEntity.DB_NAME_PUBLISH_PHOTO); - if (data.PublishPhoto.DefaultIndc == YesNoPolicy.YES) + if (isOwner && data.PublishPhoto.DefaultIndc == YesNoPolicy.YES) { CancelDefaultIndc(data.PublishPhoto.PublishName); } - publishEntity = new PublishPhotoEntity(); + PublishPhotoEntity publishEntity = new PublishPhotoEntity(); ObjectCopyHelper.Copy(data.PublishPhoto, publishEntity); repository.UpdateNow(publishEntity); @@ -237,11 +291,34 @@ namespace CloudBuilder.Master.Service //只有删除和更新,没有插入 SavePublishPhotoDetails(publishEntity.PublishName, data.Details); - SavePublishPhotoFamilyGroup(publishEntity.PublishName, data.FamilyGroups); + //群组增删只对所有者/群管理员开放;普通成员提交的 FamilyGroups 不落库 + if (isOwner || isManager) + { + SavePublishPhotoFamilyGroup(publishEntity.PublishName, data.FamilyGroups); + } return FindByKey(publishEntity.PublishName); } + /// 当前登录人是否为该相册任一公开群组的管理员(正式成员且 ManagerIndc=Y) + private bool IsGroupManagerOfPublish(string publishName) + { + IRepository repositoryFamilyGroup = repository.GetRepository>(); + IRepository repositoryFamilyGroupManager = repository.GetRepository>(); + + string[] familyNames = repositoryFamilyGroup.DetachedEntities + .Where(x => x.PublishName == publishName) + .Select(x => x.FamilyName) + .ToArray(); + if (familyNames.Length == 0) + { + return false; + } + + return repositoryFamilyGroupManager.DetachedEntities + .Any(x => x.UserName == service.UserId && x.ValidIndc == YesNoPolicy.YES && x.ManagerIndc == YesNoPolicy.YES && familyNames.Contains(x.FamilyName)); + } + private void SavePublishPhotoFamilyGroup(string publishName, PublishPhotoFamilyGroupEntity[] familyGroups) { IRepository repositoryPublishPhotoFamilyGroup = repository.GetRepository>(); @@ -317,11 +394,16 @@ namespace CloudBuilder.Master.Service [UnitOfWork] public void Delete(string key) { - PublishPhotoEntity publishEntity = repository.DetachedEntities.Where(x => x.OwnerName == service.UserId && x.PublishName == key).FirstOrDefault(); + PublishPhotoEntity publishEntity = repository.DetachedEntities.Where(x => x.PublishName == key).FirstOrDefault(); if (publishEntity == null) { throw new ValidatedException("记录不存在."); } + //删除相册:所有者或该相册任一公开群组的管理员 + if (publishEntity.OwnerName != service.UserId && !IsGroupManagerOfPublish(key)) + { + throw new ValidatedException("没有权限."); + } repository.DeleteNow(publishEntity); diff --git a/CloudBuilder/CloudBuilder.Master.Service/PublishVideoService.auto.cs b/CloudBuilder/CloudBuilder.Master.Service/PublishVideoService.auto.cs index d6b34ca..09f8f46 100644 --- a/CloudBuilder/CloudBuilder.Master.Service/PublishVideoService.auto.cs +++ b/CloudBuilder/CloudBuilder.Master.Service/PublishVideoService.auto.cs @@ -53,21 +53,28 @@ namespace CloudBuilder.Master.Service }; data.FamilyGroups = q.ToArray(); - if (publish.OwnerName != service.UserId && !YesNoPolicy.IsYes(publish.OpenIndc) && data.FamilyGroups.Length == 0) + + IRepository repositoryFamilyGroupManager = repository.GetRepository>(); + + //归属与群组身份:所有者全权;群管理员可删/改信息;群成员仅可改明细 + data.IsOwner = publish.OwnerName == service.UserId; + data.IsMember = data.IsOwner; + data.IsManager = data.IsOwner; + if (!data.IsOwner && data.FamilyGroups.Length > 0) + { + string[] familyNames = data.FamilyGroups.Select(x => x.FamilyName).ToArray(); + FamilyGroupManagerEntity[] mine = repositoryFamilyGroupManager.DetachedEntities + .Where(x => x.UserName == service.UserId && x.ValidIndc == YesNoPolicy.YES && familyNames.Contains(x.FamilyName)) + .ToArray(); + + data.IsMember = mine.Length > 0; + data.IsManager = mine.Any(x => x.ManagerIndc == YesNoPolicy.YES); + } + + if (!data.IsOwner && !YesNoPolicy.IsYes(publish.OpenIndc) && !data.IsMember) { throw new ValidatedException("没有权限."); } - else if (publish.OwnerName != service.UserId && !YesNoPolicy.IsYes(publish.OpenIndc) && data.FamilyGroups.Length > 0) - { - IRepository repositoryFamilyGroupManager = repository.GetRepository>(); - - IQueryable? fgm = from e in repositoryFamilyGroupManager.DetachedEntities - where e.UserName == service.UserId && data.FamilyGroups.Where(x => x.FamilyName == e.FamilyName).Count() > 0 - select e.UserName; - - if (fgm.Count() == 0) - throw new ValidatedException("没有权限."); - } data.PublishVideo = publish; @@ -90,9 +97,11 @@ namespace CloudBuilder.Master.Service //暂时不支持转码 DmsFileEntity dmsFileEntity = repositoryDmsFile.DetachedEntities.Where(x => x.OrderKey == item.Url).FirstOrDefault(); - item.FormattedPic = FileTypePolicy.PackPicUrl(service, item.CreatedBy, item.Url); - item.FormattedUrl = FileTypePolicy.PackUrl(service, item.CreatedBy, item.Url); - item.FormattedM3u8 = FileTypePolicy.PackM3u8Url(service, item.CreatedBy, item.Url); + // 文件可能由群成员从自己的网盘里挑:按 dms_file 的真实归属目录拼,查不到才回落明细创建人 + string owner = !string.IsNullOrEmpty(dmsFileEntity?.CreatedBy) ? dmsFileEntity.CreatedBy : item.CreatedBy; + item.FormattedPic = FileTypePolicy.PackPicUrl(service, owner, item.Url); + item.FormattedUrl = FileTypePolicy.PackUrl(service, owner, item.Url); + item.FormattedM3u8 = FileTypePolicy.PackM3u8Url(service, owner, item.Url); } } } @@ -198,20 +207,68 @@ namespace CloudBuilder.Master.Service [UnitOfWork] public PublishVideoData Update(PublishVideoData data) { - PublishVideoEntity publishEntity = repository.DetachedEntities.Where(x => x.OwnerName == service.UserId && x.PublishName == data.PublishVideo.PublishName).FirstOrDefault(); - if (publishEntity == null) + //归属校验:所有者可全改;公开群组管理员可改信息/群组;普通群成员只能改明细 + PublishVideoEntity original = repository.DetachedEntities.Where(x => x.PublishName == data.PublishVideo.PublishName).FirstOrDefault(); + if (original == null) { throw new ValidatedException("记录不存在."); } + bool isOwner = original.OwnerName == service.UserId; + bool isManager = false; + bool isMember = false; + if (!isOwner) + { + IRepository repositoryPublishVideoFamilyGroup = repository.GetRepository>(); + IRepository repositoryFamilyGroupManager = repository.GetRepository>(); + + string[] familyNames = repositoryPublishVideoFamilyGroup.DetachedEntities + .Where(x => x.PublishName == original.PublishName) + .Select(x => x.FamilyName) + .ToArray(); + if (familyNames.Length == 0) + { + throw new ValidatedException("没有权限."); + } + + FamilyGroupManagerEntity[] mine = repositoryFamilyGroupManager.DetachedEntities + .Where(x => x.UserName == service.UserId && x.ValidIndc == YesNoPolicy.YES && familyNames.Contains(x.FamilyName)) + .ToArray(); + isMember = mine.Length > 0; + isManager = mine.Any(x => x.ManagerIndc == YesNoPolicy.YES); + if (!isMember) + { + throw new ValidatedException("没有权限."); + } + } + + //非所有者保留归属与建单信息,防客户端伪造 + data.PublishVideo.OwnerName = original.OwnerName; + data.PublishVideo.CreatedBy = original.CreatedBy; + data.PublishVideo.CreatedDatetime = original.CreatedDatetime; + if (!isOwner && !isManager) + { + //非所有者且非管理员(普通群成员)只能改明细:专辑展示字段一律以库中为准 + data.PublishVideo.PublishLocalName = original.PublishLocalName; + data.PublishVideo.Notice = original.Notice; + data.PublishVideo.OpenIndc = original.OpenIndc; + data.PublishVideo.ExpireDatetime = original.ExpireDatetime; + data.PublishVideo.OrderBy = original.OrderBy; + } + if (!isOwner) + { + //默认专辑是所有者个人偏好,群管理员也不能改 + data.PublishVideo.DefaultIndc = original.DefaultIndc; + } + ValidateHelper.Validate(service, data.PublishVideo, PublishVideoEntity.DB_NAME_PUBLISH_VIDEO); - if (data.PublishVideo.DefaultIndc == YesNoPolicy.YES) + if (isOwner && data.PublishVideo.DefaultIndc == YesNoPolicy.YES) { CancelDefaultIndc(data.PublishVideo.PublishName); } - publishEntity = new PublishVideoEntity(); + PublishVideoEntity publishEntity = new PublishVideoEntity(); ObjectCopyHelper.Copy(data.PublishVideo, publishEntity); repository.UpdateNow(publishEntity); @@ -219,11 +276,34 @@ namespace CloudBuilder.Master.Service //只有删除和更新,没有插入 SavePublishVideoDetails(publishEntity.PublishName, data.Details); - SavePublishVideoFamilyGroup(publishEntity.PublishName, data.FamilyGroups); + //群组增删只对所有者/群管理员开放;普通成员提交的 FamilyGroups 不落库 + if (isOwner || isManager) + { + SavePublishVideoFamilyGroup(publishEntity.PublishName, data.FamilyGroups); + } return FindByKey(publishEntity.PublishName); } + /// 当前登录人是否为该专辑任一公开群组的管理员(正式成员且 ManagerIndc=Y) + private bool IsGroupManagerOfPublish(string publishName) + { + IRepository repositoryFamilyGroup = repository.GetRepository>(); + IRepository repositoryFamilyGroupManager = repository.GetRepository>(); + + string[] familyNames = repositoryFamilyGroup.DetachedEntities + .Where(x => x.PublishName == publishName) + .Select(x => x.FamilyName) + .ToArray(); + if (familyNames.Length == 0) + { + return false; + } + + return repositoryFamilyGroupManager.DetachedEntities + .Any(x => x.UserName == service.UserId && x.ValidIndc == YesNoPolicy.YES && x.ManagerIndc == YesNoPolicy.YES && familyNames.Contains(x.FamilyName)); + } + private void SavePublishVideoDetails(string publishName, PublishVideoDetailEntity[] details) { if (details != null && details.Length > 0) @@ -323,11 +403,16 @@ namespace CloudBuilder.Master.Service [UnitOfWork] public void Delete(string key) { - PublishVideoEntity publishEntity = repository.DetachedEntities.Where(x => x.OwnerName == service.UserId && x.PublishName == key).FirstOrDefault(); + PublishVideoEntity publishEntity = repository.DetachedEntities.Where(x => x.PublishName == key).FirstOrDefault(); if (publishEntity == null) { throw new ValidatedException("记录不存在."); } + //删除专辑:所有者或该专辑任一公开群组的管理员 + if (publishEntity.OwnerName != service.UserId && !IsGroupManagerOfPublish(key)) + { + throw new ValidatedException("没有权限."); + } repository.DeleteNow(publishEntity); diff --git a/CloudBuilder/CloudBuilder.Master/Data/PublishAudioData.cs b/CloudBuilder/CloudBuilder.Master/Data/PublishAudioData.cs index 5efad0f..35389d5 100644 --- a/CloudBuilder/CloudBuilder.Master/Data/PublishAudioData.cs +++ b/CloudBuilder/CloudBuilder.Master/Data/PublishAudioData.cs @@ -16,5 +16,12 @@ namespace CloudBuilder.Master.Data public PublishAudioFamilyGroupEntity[]? FamilyGroups { get; set; } + /// 当前登录人是否专辑所有者 + public bool IsOwner { get; set; } + /// 当前登录人是否为公开群组的管理员(可删专辑/移除群组) + public bool IsManager { get; set; } + /// 当前登录人是否属公开群组的正式成员(含权限继承可见者,仅可改明细) + public bool IsMember { get; set; } + } } diff --git a/CloudBuilder/CloudBuilder.Master/Data/PublishAudioDetailDisplayData.cs b/CloudBuilder/CloudBuilder.Master/Data/PublishAudioDetailDisplayData.cs index 2d99180..1558cb0 100644 --- a/CloudBuilder/CloudBuilder.Master/Data/PublishAudioDetailDisplayData.cs +++ b/CloudBuilder/CloudBuilder.Master/Data/PublishAudioDetailDisplayData.cs @@ -26,5 +26,13 @@ namespace CloudBuilder.Master.Data public string? FormattedPic { get; set; } public string? FormattedLrc { get; set; } + + /// + /// 三个文件字段的源文件名(dms_file.org_file_name):库里存的是物理名(order_key), + /// 界面要给人看原名。不落库(本类 NotMapped),由 FindByKey 现查现填。 + /// + public string? PicName { get; set; } + public string? UrlName { get; set; } + public string? LrcName { get; set; } } } diff --git a/CloudBuilder/CloudBuilder.Master/Data/PublishPhotoData.cs b/CloudBuilder/CloudBuilder.Master/Data/PublishPhotoData.cs index 6353a7f..b9dae9e 100644 --- a/CloudBuilder/CloudBuilder.Master/Data/PublishPhotoData.cs +++ b/CloudBuilder/CloudBuilder.Master/Data/PublishPhotoData.cs @@ -16,5 +16,12 @@ namespace CloudBuilder.Master.Data public PublishPhotoDetailDisplayData[]? Details { get; set; } public PublishPhotoFamilyGroupDisplayData[]? FamilyGroups { get; set; } + + /// 当前登录人是否相册所有者 + public bool IsOwner { get; set; } + /// 当前登录人是否为公开群组的管理员(可删相册/移除群组) + public bool IsManager { get; set; } + /// 当前登录人是否属公开群组的正式成员(含权限继承可见者,仅可改明细) + public bool IsMember { get; set; } } } diff --git a/CloudBuilder/CloudBuilder.Master/Data/PublishVideoData.cs b/CloudBuilder/CloudBuilder.Master/Data/PublishVideoData.cs index b67ec83..2fb4147 100644 --- a/CloudBuilder/CloudBuilder.Master/Data/PublishVideoData.cs +++ b/CloudBuilder/CloudBuilder.Master/Data/PublishVideoData.cs @@ -15,5 +15,12 @@ namespace CloudBuilder.Master.Data public PublishVideoDetailDisplayData[]? Details { get; set; } public PublishVideoFamilyGroupEntity[]? FamilyGroups { get; set; } + + /// 当前登录人是否专辑所有者 + public bool IsOwner { get; set; } + /// 当前登录人是否为公开群组的管理员(可删专辑/移除群组) + public bool IsManager { get; set; } + /// 当前登录人是否属公开群组的正式成员(含权限继承可见者,仅可改明细) + public bool IsMember { get; set; } } } diff --git a/CloudBuilder/CloudBuilder.Web.Entry/Properties/launchSettings.json b/CloudBuilder/CloudBuilder.Web.Entry/Properties/launchSettings.json index 99c0e9b..57fef96 100644 --- a/CloudBuilder/CloudBuilder.Web.Entry/Properties/launchSettings.json +++ b/CloudBuilder/CloudBuilder.Web.Entry/Properties/launchSettings.json @@ -8,7 +8,7 @@ "ASPNETCORE_ENVIRONMENT": "Development" }, "dotnetRunMessages": true, - "applicationUrl": "http://172.16.253.53:5235" + "applicationUrl": "http://localhost:5235" }, "IIS Express": { "commandName": "IISExpress", diff --git a/uniapp/youjiala/src/components/file-picker-sheet/file-picker-sheet.vue b/uniapp/youjiala/src/components/file-picker-sheet/file-picker-sheet.vue new file mode 100644 index 0000000..734f104 --- /dev/null +++ b/uniapp/youjiala/src/components/file-picker-sheet/file-picker-sheet.vue @@ -0,0 +1,316 @@ + + + + + diff --git a/uniapp/youjiala/src/composables/useMediaShow.ts b/uniapp/youjiala/src/composables/useMediaShow.ts index 61ed353..02da17a 100644 --- a/uniapp/youjiala/src/composables/useMediaShow.ts +++ b/uniapp/youjiala/src/composables/useMediaShow.ts @@ -24,6 +24,7 @@ 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 { absoluteFileUrl } from "@/utils/fileUrl"; import { kindOf } from "./useDmsFiles"; const FIND_LIMIT = 999999; @@ -130,12 +131,15 @@ export function useMediaShow() { async function loadPhotos(mode: string, key: string): Promise { 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), - })); + return rows.map((r: any) => { + const thumb = absoluteFileUrl(r.FormattedPic || r.Url); + return { + url: thumb, + thumb, + full: absoluteFileUrl(r.Url || r.FormattedPic), + name: baseName(r.OrgFileName || r.Url), + }; + }); } let details: any[] = []; if (key) { @@ -144,12 +148,17 @@ export function useMediaShow() { } 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, - })); + // 相册明细的 FormattedPic/FileName 线上是 / 开头的相对路径(后端只在 Debugger + // 附着时才拼域名),展览用 直接渲染会加载失败,统一补成绝对地址 + return (details ?? []).map((d: any) => { + const thumb = absoluteFileUrl(d.FormattedPic || d.FileName); + return { + url: thumb, + thumb, + full: absoluteFileUrl(d.FileName || d.FormattedPic), + name: baseName(d.FileName) || d.Notice || undefined, + }; + }); } /** 音乐厅曲目 */ @@ -157,9 +166,9 @@ export function useMediaShow() { if (mode === "list") { const rows = await listRows(FileTypePolicy.AUDIO, key); return rows.map((r: any) => ({ - url: r.Url, + url: absoluteFileUrl(r.Url), id: r.FileId, - pic: r.FormattedPic || "", + pic: absoluteFileUrl(r.FormattedPic), title: r.OrgFileName || baseName(r.Url), name: baseName(r.OrgFileName || r.Url), })); @@ -168,9 +177,9 @@ export function useMediaShow() { // 共享中心跳厅:以共享目录为播放列表(与文件页 list 同形态,数据源换共享接口) const rows = await shareRows(key, "audio"); return rows.map((r: any) => ({ - url: r.Url, + url: absoluteFileUrl(r.Url), id: r.FileId, - pic: r.FormattedPic || "", + pic: absoluteFileUrl(r.FormattedPic), title: r.OrgFileName || baseName(r.Url), name: baseName(r.OrgFileName || r.Url), })); @@ -180,10 +189,10 @@ export function useMediaShow() { : await publishAudioService.FindPublishAudioDefault(); const details = (data?.Details ?? []) as any[]; return details.map((d: any) => ({ - url: d.FormattedUrl || d.Url, - pic: d.FormattedPic || d.Pic || "", + url: absoluteFileUrl(d.FormattedUrl || d.Url), + pic: absoluteFileUrl(d.FormattedPic || d.Pic), title: d.Title || baseName(d.Url), - lrc: d.FormattedLrc || d.Lrc || "", + lrc: absoluteFileUrl(d.FormattedLrc || d.Lrc), name: d.Title || baseName(d.Url), })); } @@ -193,10 +202,10 @@ export function useMediaShow() { if (mode === "list") { const rows = await listRows(FileTypePolicy.VIDEO, key); return rows.map((r: any) => ({ - url: r.Url, + url: absoluteFileUrl(r.Url), id: r.FileId, - m3u8: r.FormattedM3u8 || "", - pic: r.FormattedPic || "", + m3u8: absoluteFileUrl(r.FormattedM3u8), + pic: absoluteFileUrl(r.FormattedPic), title: r.OrgFileName || baseName(r.Url), name: baseName(r.OrgFileName || r.Url), })); @@ -205,10 +214,10 @@ export function useMediaShow() { // 共享中心跳厅:以共享目录为片源列表(与文件页 list 同形态,数据源换共享接口) const rows = await shareRows(key, "video"); return rows.map((r: any) => ({ - url: r.Url, + url: absoluteFileUrl(r.Url), id: r.FileId, - m3u8: r.FormattedM3u8 || "", - pic: r.FormattedPic || "", + m3u8: absoluteFileUrl(r.FormattedM3u8), + pic: absoluteFileUrl(r.FormattedPic), title: r.OrgFileName || baseName(r.Url), name: baseName(r.OrgFileName || r.Url), })); @@ -218,9 +227,9 @@ export function useMediaShow() { : 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 || "", + url: absoluteFileUrl(d.FormattedUrl || d.Url), + m3u8: absoluteFileUrl(d.FormattedM3u8), + pic: absoluteFileUrl(d.FormattedPic || d.Pic), title: d.Title || baseName(d.Url), name: d.Title || baseName(d.Url), })); diff --git a/uniapp/youjiala/src/composables/usePublishAlbums.ts b/uniapp/youjiala/src/composables/usePublishAlbums.ts index 2754b59..e435b24 100644 --- a/uniapp/youjiala/src/composables/usePublishAlbums.ts +++ b/uniapp/youjiala/src/composables/usePublishAlbums.ts @@ -7,13 +7,15 @@ * * 关键契约(照抄旧 Operation,勿自作主张): * 1. 列表 FindBySearchCriteria -> photo 用 data.Publishs、audio/video 用 data.Datas; - * 后端已按「有效 且(公开 或 我是成员)」过滤,前端不再筛。 + * 后端按「我是所有者 或 公开(OpenIndc=Y)」过滤,前端不再筛。 * 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 !== 当前登录名 -> 只能查看,不能改/删(旧系统同逻辑)。 + * 5. 权限三层(后端 IsOwner/IsManager/IsMember 下发,前端只负责显隐): + * 所有者全权;公开群管理员可改信息/管群组/删专辑;普通群成员(含权限继承可见者)只能改明细。 + * 非成员且非公开 -> FindByKey 直接抛「没有权限.」。 * 6. 专辑「加入新内容」不在本页——沿用文件页发布链(file.vue onPublishConfirm)。 * * 三套实体(PublishPhoto/Audio/VideoEntity)公共字段命名一致,故用统一视图访问; @@ -61,6 +63,11 @@ export interface PublishAlbumGroup { FamilyName: string; FamilyLocalName?: string; Notice?: string; + /* 后端实体把这三个字段声明成非空 string,.NET 会按隐式 [Required] 校验: + 整行缺键直接 400,必须显式给空串(新增行语义),见 normalizeGroups */ + CreatedBy?: string; + PublishName?: string; + UpdatedBy?: string; [key: string]: any; } @@ -170,12 +177,49 @@ export function usePublishAlbums() { /** 是否停在专辑详情(false=专辑列表) */ const inDetail = computed(() => !!album.value?.PublishName || creating.value); - /** 归属判定:新建态恒可改;已入库需 OwnerName 命中当前登录名 */ - const isOwner = computed( - () => creating.value || (!notOwner.value && !!album.value), + /** 后端下发的归属/群组身份(旧后端无这三个字段时回退按 OwnerName 判定) */ + const identity = computed( + () => + (detail.value ?? {}) as { + IsOwner?: boolean; + IsManager?: boolean; + IsMember?: boolean; + }, + ); + /** 归属判定:新建态恒可改;已入库优先用后端 IsOwner,旧后端回退 OwnerName 比对 */ + const isOwner = computed(() => { + if (creating.value) { + return true; + } + if (!album.value) { + return false; + } + return typeof identity.value.IsOwner === "boolean" + ? identity.value.IsOwner + : !notOwner.value; + }); + /** 是否为该专辑任一公开群组的管理员(Group 的 IsManager,可删相册/改信息/管理群组) */ + const isGroupManager = computed( + () => !creating.value && !!identity.value.IsManager, + ); + /** 是否为该专辑任一公开群组的正式成员(含权限继承可见者,仅可改明细) */ + const isGroupMember = computed( + () => !creating.value && !!identity.value.IsMember, + ); + /** 相册信息/群组增删:所有者或群管理员 */ + const canManageAlbum = computed( + () => inDetail.value && (isOwner.value || isGroupManager.value), + ); + /** 明细编辑(上移/下移/描述/删除):所有者或群成员 */ + const canAmendDetails = computed( + () => inDetail.value && (isOwner.value || isGroupMember.value), + ); + /** 兼容旧调用点:canAmend 按「可改明细」口径 */ + const canAmend = computed(() => canAmendDetails.value); + /** 删除相册:所有者或群管理员(普通成员只能改明细) */ + const canDelete = computed( + () => inDetail.value && (isOwner.value || isGroupManager.value), ); - const canAmend = computed(() => inDetail.value && isOwner.value); - const canDelete = computed(() => inDetail.value && isOwner.value); // ===== 分段切换 ===== async function setKind(next: PublishKind): Promise { @@ -335,13 +379,27 @@ export function usePublishAlbums() { } // ===== 保存 / 删除 ===== + /** 公开群组行规整:后端 PublishXxxFamilyGroupEntity 的 CreatedBy/PublishName/UpdatedBy 是非空 string, + * .NET 把它当隐式 [Required]——整行缺键直接 400「The xxx field is required.」(uat 实测)。 + * 补空串才既过模型校验、又能被后端 SavePublishXxxFamilyGroup 的 IsNullOrEmpty(CreatedBy) + * 判定为新增行而真正落库;填当前用户名会被当成已存在行静默丢弃。 + * 已有行(FindByKey 回传)自带后端值,?? 不会覆盖。 */ + function normalizeGroups(rows: PublishAlbumGroup[]): PublishAlbumGroup[] { + return rows.map((row) => ({ + ...row, + CreatedBy: row.CreatedBy ?? "", + PublishName: row.PublishName ?? "", + UpdatedBy: row.UpdatedBy ?? "", + })); + } + async function save(): Promise { 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 ?? []; + submitted.FamilyGroups = normalizeGroups(submitted.FamilyGroups ?? []); const result = entity.CreatedBy ? await a.service.Update(submitted) : await a.service.Insert(submitted); @@ -380,7 +438,11 @@ export function usePublishAlbums() { creating, isEdit, isOwner, + isGroupManager, + isGroupMember, canAmend, + canAmendDetails, + canManageAlbum, canDelete, setMyUser, // list / detail diff --git a/uniapp/youjiala/src/data/auto/publishAudioData.ts b/uniapp/youjiala/src/data/auto/publishAudioData.ts index ff06e0a..29936e0 100644 --- a/uniapp/youjiala/src/data/auto/publishAudioData.ts +++ b/uniapp/youjiala/src/data/auto/publishAudioData.ts @@ -7,5 +7,8 @@ export class PublishAudioData public PublishAudio: PublishAudioEntity = new PublishAudioEntity(); public Details: PublishAudioDetailDisplayData[] = []; public FamilyGroups: PublishAudioFamilyGroupEntity[] = []; + public IsOwner: boolean =false; + public IsManager: boolean =false; + public IsMember: boolean =false; } diff --git a/uniapp/youjiala/src/data/auto/publishAudioDetailDisplayData.ts b/uniapp/youjiala/src/data/auto/publishAudioDetailDisplayData.ts index 72d8ac6..f2621fc 100644 --- a/uniapp/youjiala/src/data/auto/publishAudioDetailDisplayData.ts +++ b/uniapp/youjiala/src/data/auto/publishAudioDetailDisplayData.ts @@ -5,5 +5,8 @@ export class PublishAudioDetailDisplayData extends PublishAudioDetailEntity public FormattedUrl: string = ""; public FormattedPic: string = ""; public FormattedLrc: string = ""; + public PicName: string = ""; + public UrlName: string = ""; + public LrcName: string = ""; } diff --git a/uniapp/youjiala/src/data/auto/publishPhotoData.ts b/uniapp/youjiala/src/data/auto/publishPhotoData.ts index b512f73..9c025df 100644 --- a/uniapp/youjiala/src/data/auto/publishPhotoData.ts +++ b/uniapp/youjiala/src/data/auto/publishPhotoData.ts @@ -7,5 +7,8 @@ export class PublishPhotoData public PublishPhoto: PublishPhotoEntity = new PublishPhotoEntity(); public Details: PublishPhotoDetailDisplayData[] = []; public FamilyGroups: PublishPhotoFamilyGroupDisplayData[] = []; + public IsOwner: boolean =false; + public IsManager: boolean =false; + public IsMember: boolean =false; } diff --git a/uniapp/youjiala/src/data/auto/publishVideoData.ts b/uniapp/youjiala/src/data/auto/publishVideoData.ts index 4b06da7..722fb97 100644 --- a/uniapp/youjiala/src/data/auto/publishVideoData.ts +++ b/uniapp/youjiala/src/data/auto/publishVideoData.ts @@ -7,5 +7,8 @@ export class PublishVideoData public PublishVideo: PublishVideoEntity = new PublishVideoEntity(); public Details: PublishVideoDetailDisplayData[] = []; public FamilyGroups: PublishVideoFamilyGroupEntity[] = []; + public IsOwner: boolean =false; + public IsManager: boolean =false; + public IsMember: boolean =false; } diff --git a/uniapp/youjiala/src/locale/lang/en-US.json b/uniapp/youjiala/src/locale/lang/en-US.json index 0b27a96..5cecf60 100644 --- a/uniapp/youjiala/src/locale/lang/en-US.json +++ b/uniapp/youjiala/src/locale/lang/en-US.json @@ -37,7 +37,6 @@ "myTheme": "My Theme", "mySecurity": "Security", "familyTree": "Family Trees", - "familyPerson": "Family Members", "loginAgent": "Agent Login", "changePassword": "Change Password", "changeLocalName": "Change Nickname", @@ -630,6 +629,7 @@ "todoTip": "Coming soon", "todayLabel": "Today", "myCloud": "My Cloud", + "pickFile": "Select File", "loading": "Loading…", "loadFailed": "Failed to load, please retry", "retryBut": "Retry", @@ -790,8 +790,13 @@ "noDesc": "No description", "noAccess": "You don't have access to this album", "chipPublic": "Public", + "chipPrivate": "Private", "chipDefault": "Default", + "chipNotDefault": "Not default", "albumInfo": "Album Info", + "tabInfo": "Overview", + "tabDetails": "Items", + "tabGroups": "Groups", "albumNo": "Album No.", "albumName": "Album Name", "albumDesc": "Album Description", @@ -806,6 +811,13 @@ "moveDown": "Down", "delete": "Delete", "deleteDetailConfirm": "Remove this item from the album?", + "editDetail": "Edit Item", + "detailNotice": "Item Description", + "detailTitle": "Title", + "detailAuthor": "Singer", + "detailCover": "Cover", + "detailUrl": "Address", + "detailLrc": "Lyrics", "groups": "Shared Groups", "groupName": "Group Name", "groupDesc": "Group Description", diff --git a/uniapp/youjiala/src/locale/lang/zh-CN.json b/uniapp/youjiala/src/locale/lang/zh-CN.json index 6e95f5a..0936ad9 100644 --- a/uniapp/youjiala/src/locale/lang/zh-CN.json +++ b/uniapp/youjiala/src/locale/lang/zh-CN.json @@ -37,7 +37,6 @@ "myTheme": "我的主题", "mySecurity": "安全管理", "familyTree": "族谱管理", - "familyPerson": "家族成员", "loginAgent": "代理登录", "changePassword": "修改密码", "changeLocalName": "修改昵称", @@ -630,6 +629,7 @@ "todoTip": "功能开发中,敬请期待", "todayLabel": "今天", "myCloud": "我的云盘", + "pickFile": "选择文件", "loading": "加载中…", "loadFailed": "加载失败,请重试", "retryBut": "重试", @@ -790,8 +790,13 @@ "noDesc": "暂无描述", "noAccess": "该专辑无权限查看", "chipPublic": "公开", + "chipPrivate": "不公开", "chipDefault": "默认", + "chipNotDefault": "非默认", "albumInfo": "专辑信息", + "tabInfo": "概览", + "tabDetails": "明细", + "tabGroups": "群组", "albumNo": "专辑编号", "albumName": "专辑名称", "albumDesc": "专辑描述", @@ -806,6 +811,13 @@ "moveDown": "下移", "delete": "删除", "deleteDetailConfirm": "确定从专辑中移除该明细?", + "editDetail": "编辑明细", + "detailNotice": "明细描述", + "detailTitle": "标题", + "detailAuthor": "歌手", + "detailCover": "封面", + "detailUrl": "地址", + "detailLrc": "歌词", "groups": "公开群组", "groupName": "群组名称", "groupDesc": "群组描述", @@ -813,7 +825,7 @@ "noGroups": "尚未发布到任何群组", "noGroupsAvailable": "没有可添加的群组,或都已发布过", "exhibit": "展览", - "amend": "修改", + "amend": "编辑", "save": "保存", "cancel": "取消", "deleteConfirm": "确定删除专辑「{name}」?明细与群组发布关系一并清除", @@ -935,4 +947,4 @@ "r404_3": "页面已下线或改名,收藏夹里还是旧地址", "r404_4": "服务器刚重启、菜单还没加载出来,刷新一次即可" } -} +} \ No newline at end of file diff --git a/uniapp/youjiala/src/md/file-url.md b/uniapp/youjiala/src/md/file-url.md new file mode 100644 index 0000000..3deb089 --- /dev/null +++ b/uniapp/youjiala/src/md/file-url.md @@ -0,0 +1,91 @@ +# 文件地址与访问线路(图片 / 音频 / 视频 URL 的归一与回落) + +适用范围:`src/utils/fileUrl.ts`、`src/common/apiEndpoint.ts`、`src/composables/useMediaShow.ts`、`src/pages/tab/media.vue`,以及后端 `CloudBuilder.Master.Service/PublishPhotoService.auto.cs`、`CloudBuilder.Web.Entry/Program.cs`、`appsettings.json` 中 `ApplicationUrl` / `FileServiceSettings` 两组配置。 + +本文只讲「后端下发的文件地址怎么变成端上能直接加载的 URL」,接口地址分层见 [environment.md](./environment.md),请求错误口径见 [error-handling.md](./error-handling.md)。 + +--- + +## 1. 一句话结论 + +后端下发的文件地址有**三种形态**,端上不能直接塞进 `` / 播放器,必须统一过 `absoluteFileUrl()`;加载失败还要给一次公网回落 `wanFileUrl()`。 + +## 2. 后端地址的三种形态 + +以相册明细 `PublishPhotoDetailDisplayData` 为例(`PublishPhoto/FindByKey`): + +| 形态 | 什么时候出现 | 样例 | 端上直接用的后果 | +| --- | --- | --- | --- | +| 相对路径 | 线上(`Debugger.IsAttached = false`) | `/FileServer/Scale/DmsFile/003/jpg/x.jpg` | App 当成本地资源路径,加载失败(灰块) | +| 回环绝对地址 | VS 调试,`appsettings.json` 的 `ApplicationUrl = http://localhost:5235` | `http://localhost:5235/FileServer/Scale/...` | 手机上的 localhost 指向手机自己,必然失败 | +| 正常绝对地址 | 音频 / 视频明细、或后端已拼好域名时 | `https://uat.youjiala.com/FileServer/...` | 原样返回即可 | + +根因链: + +- `PackImageScareFileName(isAttached, fileName)` 里 `isAttached ? ServiceSettingsPolicy 的 ApplicationUrl : ""`,即**只有调试态才拼前缀**; +- 同一方法会把 `Backup` 目录替换成 `Scale`(缩略图),`FindByKey` 里另有一次 `PackImageFileName` 保留原图路径; +- 后端本身是能出静态文件的:`Program.cs` 用 `UseStaticFiles` 把 `FileServiceSettings.PhysicalFileDirectory`(`D:\Net8\FileServer`)挂到 `RequestPath`(`/FileServer`)。所以问题是**地址前缀不对**,不是后端不提供文件。 + +## 3. 为什么 H5 正常、App 不正常 + +| 端 | 接口线路 | 图片地址 | 结果 | +| --- | --- | --- | --- | +| H5 dev | `http://localhost:5235`(VS 调试) | 后端拼的 `http://localhost:5235/FileServer/...` | 浏览器与后端同机,localhost 成立,显示正常 | +| App 内网直连 | `http://172.16.253.53:5235`(同一台开发机的局域网地址) | 后端拼的 `http://localhost:5235/FileServer/...` | 手机上 localhost 不可达,灰块 | +| App 公网 | `https://uat.youjiala.com`(非调试) | 相对路径 `/FileServer/...` | 未补全则灰块;补全后正常 | + +结论:**H5 的“正常”是假象**,它恰好和调试后端同机;判断修复是否生效要在 App 上验证。 + +## 4. 归一规则(`src/utils/fileUrl.ts`) + +``` +stripLoopbackHost(url) 把 localhost / 127.0.0.1 / 0.0.0.0 剥成纯路径;非回环返回 null +withBase(base, path) 相对路径拼到 base;已绝对 / data: / blob: 原样返回(幂等) +absoluteFileUrl(path) stripLoopbackHost 之后按 apiBaseUrl() 重拼 —— 当前线路地址 +wanFileUrl(path) 把任意地址的 path+query 换到 WebSitePolicy.WebApiRoot —— 公网地址 +``` + +`apiBaseUrl()` 来自访问线路(`api-endpoint-setting`):`lan` 模式返回用户在「我的 → 访问线路」填的内网地址,否则返回公网(App 开发构建取 `WebApiRootDevApp`,发布构建取 `WebApiRoot`)。 + +实测映射(Node 侧验证过同一套逻辑): + +| 输入 | `absoluteFileUrl`(内网 172.16.253.53:5235) | `wanFileUrl` | +| --- | --- | --- | +| `http://localhost:5235/FileServer/Scale/...` | `http://172.16.253.53:5235/FileServer/Scale/...` | `https://uat.youjiala.com/FileServer/Scale/...` | +| `/FileServer/Scale/...` | `http://172.16.253.53:5235/FileServer/Scale/...` | `https://uat.youjiala.com/FileServer/Scale/...` | +| `http://172.16.253.53:5235/...?v=1` | 原样 | `https://uat.youjiala.com/...?v=1` | +| `https://uat.youjiala.com/...` | 原样 | 原样(调用方判重后不再重试) | +| `""` / `undefined` | `""` | `""` | + +## 5. 消费面:哪些地方必须过 + +| 位置 | 处理 | +| --- | --- | +| `pages/tab/media.vue` 专辑明细缩略图 | `coverOf()` 过 `absoluteFileUrl`;`@error` 时用 `coverFallback[DetailId] = wanFileUrl(...)` 再试一次,仍失败才 `coverFailed` 显示占位图标 | +| `composables/useMediaShow.ts` `loadPhotos` 的 list 分支与相册明细分支 | `url` / `thumb` / `full` 全部过 `absoluteFileUrl` | +| `composables/useMediaShow.ts` `loadTracks` / `loadVideos` | `url`、`pic`、`lrc`、`m3u8` 全部过 `absoluteFileUrl`(调试态下它们同样是 localhost 前缀) | + +纪律:**凡是渲染后端文件地址的字段,一律先过 `absoluteFileUrl`;可交互/可预览的图片再挂一层 `@error` 回落 `wanFileUrl`。** 不要把 `FormattedPic` / `FileName` / `FormattedUrl` 直接绑到 `src`。 + +## 6. 新增字段时的固定步骤 + +1. 确认接口返回的字段是路径还是完整 URL(看 uat 实际响应,不要按字段名猜); +2. 渲染前套 `absoluteFileUrl`; +3. 图片类再加 `@error` → `wanFileUrl` 一次回落 → 仍失败才占位; +4. `npx vue-tsc --noEmit` 退出码 0; +5. App 端(尤其内网线路)实际看一次,H5 正常不代表 App 正常。 + +## 7. 排查清单 + +- 图片灰块:先打印/抓包看实际 `src`,判断属于上面三种形态中的哪一种; +- 断言图片存在:对 uat 地址做 `HEAD`(`https://uat.youjiala.com/FileServer/Scale/...`、`.../Backup/...` 均应为 200); +- 内网线路取不到:确认手机与开发机同一 Wi-Fi、后端监听局域网地址(不是只监听 `127.0.0.1`)、`D:\Net8\FileServer` 下有该文件; +- 回落到公网仍失败:检查 `WebSitePolicy.WebApiRoot`(`.env` 的 `VITE_WEB_API_ROOT`)与 uat 上文件是否存在。 + +## 8. 可选的后端根治 + +当前前端已经兜住三种形态,后端改不改都能用。若要少一层兜底,任一即可: + +- 把 `appsettings.json` 的 `ApplicationUrl` 从 `http://localhost:5235` 改成开发机的局域网地址(如 `http://172.16.253.53:5235`),调试态直接给手机可达地址; +- 或让 `PackImageScareFileName` 按当前请求的 Scheme+Host 拼前缀(与 `FileTypePolicy.BuildApplicationUrl` 的口径一致),线上也返回绝对地址; +- 多实例部署时让各实例的 `PhysicalFileDirectory` 指向同一份共享存储,否则文件只会留在上传它的那一台上。 diff --git a/uniapp/youjiala/src/md/media-permission.md b/uniapp/youjiala/src/md/media-permission.md new file mode 100644 index 0000000..a5030d9 --- /dev/null +++ b/uniapp/youjiala/src/md/media-permission.md @@ -0,0 +1,154 @@ +# 多媒体专辑权限(相册 / 音乐 / 影视) + +> 定稿 2026-10:三层角色(所有者 / 群管理员 / 普通群成员)+ 旁观者只读。相册(photo)、音乐(audio)、影视(video)三套逻辑同构,仅实体/仓储类型不同;前后端口径一致,后端为最终裁决。 + +## 一、角色定义与判定 + +后端 `PublishXxxService.FindByKey` 恒定下发三个布尔标记,前端一切显隐只认它们: + +| 角色 | 判定条件 | 后端标记 | 说明 | +|---|---|---|---| +| **所有者** | `publish_xxx.owner_name == 当前登录 UserName` | `IsOwner=true`(同时 `IsMember/IsManager=true`) | 建单人的全权身份 | +| **群管理员** | 该专辑 `publish_xxx_family_group` 任一 `FamilyName`,在 `family_group_manager` 中存在 `UserName=自己 且 ValidIndc=Y 且 ManagerIndc=Y` | `IsManager=true` | 发布到的群组中任一群的管理员即可 | +| **普通群成员** | 同上但 `ManagerIndc=N`(只要 `ValidIndc=Y`) | `IsMember=true` | 含通过权限继承加入群组的有效成员 | +| **旁观者** | 以上都不是;专辑 `OpenIndc=Y` 时仅可读 | 三者均 false | 私有专辑 `FindByKey` 直接抛「没有权限.」 | + +注意: + +- 所有者分支不查群表,`IsMember/IsManager` 直接置 true; +- 「权限继承加入的群组」落地为 `family_group_manager.ValidIndc=Y` 的有效成员行,`ManagerIndc` 决定管理员身份; +- 旧后端不返回三标记时,前端 `isOwner` 回退按 `OwnerName` 比对(仅兼容,不应依赖)。 + +## 二、能力矩阵 + +| 操作 | 所有者 | 群管理员 | 普通群成员 | 旁观者 | +|---|:---:|:---:|:---:|:---:| +| 查看详情 | ✓ | ✓ | ✓ | 仅 `OpenIndc=Y` | +| 改专辑信息(名称 / 描述 / 公开 / 排序 / 过期) | ✓ | ✓ | ✗ | ✗ | +| 改「设为默认专辑」 | ✓ | ✗ | ✗ | ✗ | +| 明细编辑(上移 / 下移 / 描述 / 删除) | ✓ | ✓ | ✓ | ✗ | +| 群组增删(发布去向) | ✓ | ✓ | ✗ | ✗ | +| 删除整张专辑 | ✓ | ✓ | ✗ | ✗ | +| 展览(播放器) | ✓ | ✓ | ✓ | ✓ | + +群管理员具备明细编辑权,因为其 `IsMember` 同时为 true。 + +## 三、后端实现 + +三个文件同构:`CloudBuilder.Master.Service/PublishPhotoService.auto.cs`、`PublishAudioService.auto.cs`、`PublishVideoService.auto.cs`。 + +### 3.1 FindByKey — 身份计算与访问闸门 + +1. 按 `PublishName` 取专辑,取 `FamilyGroups`(join `family_group` 的展示行); +2. `IsOwner = owner_name == 当前用户`,并先令 `IsMember = IsManager = IsOwner`; +3. 非所有者且 `FamilyGroups` 非空时,取所有 `FamilyName`,查 `family_group_manager` 中 `UserName=自己 且 ValidIndc=Y` 的行: + - 有行 → `IsMember=true`; + - 其中任一行 `ManagerIndc=Y` → `IsManager=true`; +4. 访问闸门:`!IsOwner && OpenIndc != 'Y' && !IsMember` → `ValidatedException("没有权限.")`。 + +### 3.2 Update — 按角色回写保护字段 + +1. 按 `PublishName` 查库中原记录,不存在 → 「记录不存在.」; +2. `isOwner = original.OwnerName == 当前用户`; +3. 非所有者:查该专辑群组 `FamilyName` 集合 → 查自己的有效成员行(`ValidIndc=Y`)→ `isMember/isManager`;群组集合为空或无成员行 → 「没有权限.」; +4. 防伪造:无条件回写 `OwnerName / CreatedBy / CreatedDatetime`(以库中为准); +5. **`!isOwner && !isManager`** → 回写展示字段 `PublishLocalName / Notice / OpenIndc / ExpireDatetime / OrderBy`(普通成员只能改明细); +6. **`!isOwner`** → 回写 `DefaultIndc`(默认专辑是所有者个人偏好,群管理员也不能改); +7. 所有者且 `DefaultIndc=Y` → `CancelDefaultIndc`(同一用户其它专辑取消默认); +8. `SavePublishXxxDetails` 所有正式成员都会执行(明细落库); +9. `SavePublishXxxFamilyGroup` 仅 `isOwner || isManager` 执行(普通成员提交的群组数组被忽略)。 + +> 历史坑:第 5 步条件若写成 `!isManager`,所有者在非所有者分支外 `isManager` 恒为 false,会导致所有者的公开/名称/描述/排序被库中旧值回写(保存成功但值弹回)。三套 Service 必须保持 `!isOwner && !isManager`。 + +### 3.3 Delete — 所有者或群管理员 + +1. 按 key 查专辑,不存在 → 「记录不存在.」; +2. `OwnerName != 当前用户 && !IsGroupManagerOfPublish(key)` → 「没有权限.」; +3. 删除主表 + 全部明细 + 全部群组关联行。 + +### 3.4 IsGroupManagerOfPublish + +- 取 `publish_xxx_family_group` 中该专辑的 `FamilyName` 集合; +- 集合为空 → false; +- 在 `family_group_manager` 中查 `UserName=自己 且 ValidIndc=Y 且 ManagerIndc=Y && FamilyName 命中` → 任一存在即 true。 + +## 四、前端映射 + +### 4.1 composable 开关(`src/composables/usePublishAlbums.ts`) + +```text +identity = detail 上的 IsOwner / IsManager / IsMember(旧后端回退 OwnerName) +isOwner = creating || (IsOwner 是布尔 ? IsOwner : !notOwner) +isGroupManager = !creating && IsManager +isGroupMember = !creating && IsMember +canManageAlbum = inDetail && (isOwner || isGroupManager) // 改信息 / 管群组 / 删专辑 +canAmendDetails = inDetail && (isOwner || isGroupMember) // 改明细 +canDelete = inDetail && (isOwner || isGroupManager) +``` + +`canAmend` 保留为 `canAmendDetails` 的别名,兼容旧调用。 + +### 4.2 页面入口显隐(`src/pages/tab/media.vue`) + +| UI 入口 | 显示条件 | +|---|---| +| 概览卡「编辑」文字链 + 底部弹层(名称/描述) | `canManageAlbum` | +| 概览卡「对所有人公开」开关 | `canManageAlbum`(否则显示公开/私有文本) | +| 概览卡「设为默认专辑」开关 | `isOwner`(否则显示默认/非默认文本) | +| 明细行操作区(上移 / 下移 / 编辑 / 删除) | `canAmendDetails` | +| 明细描述编辑弹层保存 | 从操作区进入,本质同 `canAmendDetails` | +| 群组页签「+ 添加群组」与行「删除」 | `canManageAlbum` | +| 头卡删除按钮 | `canDelete` | +| 头卡展览按钮 | 所有人 | + +### 4.3 自动保存门控 + +- `commitNow` / `commitSoon` 在 `!album || creating || !canAmendDetails` 时直接返回; +- 普通群成员改动明细后提交的是整包 `Update`,后端按角色只落明细,展示字段与默认标记强制回写; +- 概览卡两个开关直改后走同一防抖通道(250ms 合并),默认开关由后端取消其它专辑默认。 + +## 五、列表可见性与访问闸门 + +`FindBySearchCriteria` 的 where(正常列表,`IsModal=false`): + +```text +and (owner_name = 当前用户 or open_indc = 'Y') +``` + +- 结论:**私有专辑(`OpenIndc=N`)即使发布给群组,也不会出现在多媒体列表**;群成员只能从已公开的专辑进入,再按身份获得改写权; +- `FindByKey` 对私有专辑放行所有者与有效群成员,因此从其他入口(如播放器携带 key)仍可打开; +- 若需要「私有群共享专辑也出现在成员列表」,需在该 where 上追加 `EXISTS` 子查询(`publish_xxx_family_group` + `family_group_manager`),目前未实现。 + +## 六、涉及文件 + +| 端 | 文件 | 职责 | +|---|---|---| +| 后端 | `CloudBuilder.Master/Data/PublishPhotoData.cs`、`PublishAudioData.cs`、`PublishVideoData.cs` | 新增 `IsOwner / IsManager / IsMember` 三标记 | +| 后端 | `CloudBuilder.Master.Service/PublishPhotoService.auto.cs`、`PublishAudioService.auto.cs`、`PublishVideoService.auto.cs` | FindByKey 身份计算、Update 分角色回写、Delete 放行群管理员、IsGroupManagerOfPublish | +| 前端 | `src/composables/usePublishAlbums.ts` | 权限 computed、保存门控数据链 | +| 前端 | `src/pages/tab/media.vue` | 入口显隐、概览开关直改、明细编辑弹层 | +| 生成 | `src/data/auto/publishPhotoData.ts`、`publishAudioData.ts`、`publishVideoData.ts` | 代码生成器由 Data 类同步,勿手改 | + +## 七、测试账号验证口径 + +以「想家」`PP26101400001`(owner=002,发布群 FM000001,群内 002 为管理员、001 为普通成员)为例: + +| 账号 | 列表可见 | 概览「编辑」 | 公开开关 | 默认开关 | 明细操作 | 群组增删 | 删除专辑 | +|---|---|:---:|:---:|:---:|:---:|:---:|:---:| +| 002(所有者) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| 002 以外的群管理员 | 仅公开专辑 | ✓ | ✓ | ✗ | ✓ | ✓ | ✓ | +| 001(普通成员) | 仅公开专辑 | ✗ | ✗(文本) | ✗(文本) | ✓ | ✗ | ✗ | +| 003(非成员) | 仅公开专辑 | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | + +## 八、变更注意 + +1. 三套 Service 必须同步改动,任何一套漏改都会造成 photo/audio/video 行为不一致; +2. 「普通成员锁展示字段」条件固定为 `!isOwner && !isManager`,不要简化为 `!isManager`; +3. 前端入口显隐必须与后端校验一致:可见即应成功,不可见即不应发请求; +4. 明细的增删排序没有独立接口,全部随整包 `Update` 落库;群组增删同样走整包提交; +5. `CloudBuilder.Master` 是 DLL 引用链,改动 Data/Service 后按 Master → Master.Service → Web.Entry 顺序重新生成,前端 `data/auto` 才会同步。 + +## 相关文档 + +- [permission.md](./permission.md):页面与功能级权限体系(登录态 PermissionContext) +- [family-tree-permission.md](./family-tree-permission.md):族谱权限;群管理员身份的上游 `family_group_manager` 行由其成员关系产生 diff --git a/uniapp/youjiala/src/pages.json b/uniapp/youjiala/src/pages.json index 8d31515..8754ec0 100644 --- a/uniapp/youjiala/src/pages.json +++ b/uniapp/youjiala/src/pages.json @@ -109,14 +109,8 @@ { "path": "pages/security/help-feature-operation" }, - // 家族成员(/family-person-enquiry、/family-person-operation):旧 master/FamilyPerson 两页迁移,含族员选择器 - // 族谱管理已融合进首页(pages/dashboard/dashboard 两态页),旧 family-tree-* 两页已退役 - { - "path": "pages/security/family-person-enquiry" - }, - { - "path": "pages/security/family-person-operation" - }, + // 族谱管理已融合进首页(pages/dashboard/dashboard 两态页),旧 family-tree-* 两页已退役; + // 家族成员两页同样已退役(检索/维护已并入族谱设计) // 底部导航测试页(首页复用上面的 dashboard):文件/多媒体/共享/我的 { "path": "pages/tab/file" diff --git a/uniapp/youjiala/src/pages/security/family-person-enquiry.vue b/uniapp/youjiala/src/pages/security/family-person-enquiry.vue deleted file mode 100644 index 0136350..0000000 --- a/uniapp/youjiala/src/pages/security/family-person-enquiry.vue +++ /dev/null @@ -1,207 +0,0 @@ - - - - - diff --git a/uniapp/youjiala/src/pages/security/family-person-operation.vue b/uniapp/youjiala/src/pages/security/family-person-operation.vue deleted file mode 100644 index e40c8ba..0000000 --- a/uniapp/youjiala/src/pages/security/family-person-operation.vue +++ /dev/null @@ -1,738 +0,0 @@ - - - - - diff --git a/uniapp/youjiala/src/pages/tab/media.vue b/uniapp/youjiala/src/pages/tab/media.vue index d845c56..3746e47 100644 --- a/uniapp/youjiala/src/pages/tab/media.vue +++ b/uniapp/youjiala/src/pages/tab/media.vue @@ -5,12 +5,16 @@ * PublishVideoEnquiry/Operation(影视专辑)融合进一个单页三段式,交互部分参考「共享」tab。 * 顶部分段「相册 / 音乐 / 影视」切换 kind;列表态显示专辑卡片(带 tabbar), * 点卡片进入编辑态(隐 tabbar):专辑信息 + 明细重排/删除 + 公开群组增删 + 保存/删除/展览。 + * 权限:所有者全权;公开群管理员可改信息/管群组/删专辑;普通群成员只能改明细(入口按后端标记显隐)。 * 数据链全在 composables/usePublishAlbums;本页只渲染 + 把点击转成调用。 * 「加新内容进专辑」仍走文件页发布链,本页不重复上传;「展览」按钮在 M4 接线到播放器页。 */ -import type { PublishAlbumDetail, PublishAlbumEntity, PublishKind } from "@/composables"; +import type { PublishAlbumDetail, PublishAlbumEntity, PublishAlbumGroup, PublishKind } from "@/composables"; +import type { DmsFileUploadViewDisplayData } from "@/data/auto/dmsFileUploadViewDisplayData"; +import type { FileKind } from "@/data/custom/fileBrowser"; +import { onHide } from "@dcloudio/uni-app"; import { useLocale } from "uview-pro"; -import { computed, ref } from "vue"; +import { computed, reactive, ref } from "vue"; import { useAuthGuard, useFamilyGroups, @@ -20,6 +24,7 @@ import { } from "@/composables"; import { pageUrl } from "@/router/routes"; import { useLoginProfileStore, useMediaIntentStore } from "@/stores"; +import { absoluteFileUrl, wanFileUrl } from "@/utils/fileUrl"; const { t } = useLocale(); const { themeBackground } = useThemeGradient(); @@ -40,20 +45,20 @@ const { loading, errorText, album, + detail, details, groups, inDetail, creating, - isEdit, - canAmend, + isOwner, + canAmendDetails, + canManageAlbum, canDelete, setMyUser, loadList, openAlbum, exitDetail, startCreate, - startEdit, - cancelEdit, moveDetail, removeDetail, addGroup, @@ -110,15 +115,56 @@ function kindIcon(k: PublishKind): string { return SEGMENTS.find(s => s.key === k)?.icon ?? "photo"; } -/** 三张分区卡(专辑信息/明细/群组)折叠态:默认全展开,点标题行切换 */ -const folded = ref>({ - info: false, - details: false, - groups: false, -}); +/** 详情三段 Tab(照群组详情):概览 / 明细 / 群组;只切视图,编辑各自独立即时保存 */ +const activeTab = ref<"info" | "details" | "groups">("info"); -function toggleFold(key: "info" | "details" | "groups"): void { - folded.value[key] = !folded.value[key]; +/** 专辑信息编辑弹层开关(进详情/离开详情时复位,声明提前避免函数内先用后定义) */ +const albumEditorVisible = ref(false); + +/** 明细编辑弹层:音轨可改标题/歌手/封面/地址/歌词/备注,相册/影视只编辑备注 */ +interface DetailDraft { + title: string; + author: string; + /** 物理名(提交值)与源文件名(显示值):库里存 GUID,弹层显示原文件名 */ + pic: string; + picName: string; + url: string; + urlName: string; + lrc: string; + lrcName: string; + notice: string; +} + +/** 暂存草稿与正在编辑的行(引用 Details 数组内对象) */ +const detailEditorVisible = ref(false); +const detailDraft = reactive({ + title: "", + author: "", + pic: "", + picName: "", + url: "", + urlName: "", + lrc: "", + lrcName: "", + notice: "", +}); +let editingDetail: PublishAlbumDetail | null = null; + +/** 明细文件选择弹层:pickerField 决定回填封面/地址/歌词 */ +const filePickerVisible = ref(false); +const filePickerAccept = ref(["image"]); +let pickerField: "pic" | "url" | "lrc" = "pic"; +/** 字段对应的可选文件类型:封面图片、地址音频、歌词文档 */ +const PICKER_ACCEPT: Record<"pic" | "url" | "lrc", FileKind[]> = { + pic: ["image"], + url: ["audio"], + lrc: ["document"], +}; +/** 选择文件时记录后端地址:保存时同步 Formatted*,让列表封面立即换成新图 */ +let pickedUrls: Partial> = {}; + +function switchTab(next: "info" | "details" | "groups"): void { + activeTab.value = next; } function albumDesc(entity: PublishAlbumEntity): string { @@ -127,16 +173,35 @@ function albumDesc(entity: PublishAlbumEntity): string { /** 明细封面地址:photo 用缩略图/原图,audio/video 用 Pic */ function coverOf(detail: PublishAlbumDetail): string { - return detail.FormattedPic || detail.Pic || detail.FileName || ""; + // 相册明细的 FormattedPic/FileName 线上是 / 开头的相对路径,必须补成绝对地址, + // 否则 App 端 当本地路径加载失败(灰块占位) + return absoluteFileUrl(detail.FormattedPic || detail.Pic || detail.FileName || ""); } /** 加载失败的封面(按 DetailId 记录):图片地址取不到时回落成占位图标,避免留白 */ const coverFailed = ref>({}); -function onCoverError(detailId: number): void { - if (detailId == null) { + +/** 内网线路下图 404 后换用的公网地址(按 DetailId 记录),见 onCoverError */ +const coverFallback = ref>({}); + +/** 明细最终使用的封面地址:优先用回落成功的公网地址,否则走当前线路地址 */ +function coverSrc(detail: PublishAlbumDetail): string { + return coverFallback.value[detail.DetailId] || coverOf(detail); +} + +function onCoverError(detail: PublishAlbumDetail): void { + if (detail.DetailId == null) { return; } - coverFailed.value = { ...coverFailed.value, [detailId]: true }; + // 内网直连时图片基址是内网地址,而内网实例的本地文件目录里未必有这张图 + // (多套后端共用同一 MySQL、文件各存各的盘),此时换公网地址再试一次; + // 公网也取不到才真正回落成占位图标 + const fallback = wanFileUrl(detail.FormattedPic || detail.Pic || detail.FileName || ""); + if (fallback && fallback !== coverSrc(detail)) { + coverFallback.value = { ...coverFallback.value, [detail.DetailId]: fallback }; + return; + } + coverFailed.value = { ...coverFailed.value, [detail.DetailId]: true }; } function toast(title: string): void { @@ -195,7 +260,13 @@ async function onSearch(): Promise { async function onOpenAlbum(entity: PublishAlbumEntity): Promise { keyword.value = ""; + activeTab.value = "info"; + albumEditorVisible.value = false; + detailEditorVisible.value = false; + filePickerVisible.value = false; + editingDetail = null; coverFailed.value = {}; + coverFallback.value = {}; await withBusy(async () => { await openAlbum(entity.PublishName); }); @@ -205,17 +276,28 @@ async function onOpenAlbum(entity: PublishAlbumEntity): Promise { } function onCreateAlbum(): void { + activeTab.value = "info"; startCreate(); } -// ===== 详情:返回 / 编辑 / 保存 / 删除 / 展览 ===== +// ===== 详情:返回 / 保存 / 删除 / 展览 ===== +/** 离开详情:先把防抖窗口里的改动补提交,再退出(不要把用户刚改的内容带丢) */ async function onBack(): Promise { + albumEditorVisible.value = false; + detailEditorVisible.value = false; + filePickerVisible.value = false; + editingDetail = null; if (!inDetail.value) { if (getCurrentPages().length > 1) { uni.navigateBack(); } return; } + if (creating.value) { + await exitDetail(); + return; + } + await flushCommit(); await exitDetail(); } @@ -223,26 +305,213 @@ async function onRetry(): Promise { await loadList(keyword.value); } -function onAmend(): void { - startEdit(); +// ===== 独立保存:不再有全局「修改」态,字段/明细/群组各自改完即落库 ===== + +/** 防抖句柄:连续输入/连点排序合并成一次整包 Update */ +let commitTimer: ReturnType | null = null; +/** 保存在途句柄:并发调用不再各自发请求,统一等这一轮(含补提交)收尾;resolve 表示本轮是否全部落库成功 */ +let savingPromise: Promise | null = null; +/** 提交期间又产生改动:本轮结束后按最新状态补提交一次 */ +let commitAgain = false; +/** 本地改动版本号:保存回包时据此判断请求期间是否又有新编辑 */ +let editVersion = 0; + +/** kind -> 详情包里的实体属性名(三套实体同构,只有键名不同) */ +const ENTITY_KEYS: Record = { + photo: "PublishPhoto", + audio: "PublishAudio", + video: "PublishVideo", +}; + +/** + * 保存回包会整包替换 detail;如果请求期间用户又改了东西,那些编辑落在旧对象上, + * 直接替换会把界面“回滚”成请求发出时的值(表现为刚打的字/刚拖的顺序消失)。 + * 这里把最新本地值搬回回包对象:只有身份字段(编号/创建人)用服务端的。 + */ +function carryLocalEdits(local: Record | null, fresh: Record | null): void { + if (!local || !fresh) { + return; + } + const key = ENTITY_KEYS[kind.value]; + const src = local[key]; + const dst = fresh[key]; + if (src && dst) { + dst.PublishLocalName = src.PublishLocalName; + dst.Notice = src.Notice; + dst.OpenIndc = src.OpenIndc; + dst.DefaultIndc = src.DefaultIndc; + } + // 明细行服务端都带 DetailId,直接采用最新本地列表(含顺序/删除/描述编辑) + fresh.Details = local.Details ?? []; + // 群组新增行 CreatedBy 为空,借用回包里同群组的身份字段,避免下次提交被当成新增行重复插入 + const freshGroups: PublishAlbumGroup[] = fresh.FamilyGroups ?? []; + fresh.FamilyGroups = (local.FamilyGroups ?? []).map((row: PublishAlbumGroup) => { + if (row.CreatedBy) { + return row; + } + const matched = freshGroups.find(g => g.FamilyName === row.FamilyName); + return matched?.CreatedBy ? { ...row, CreatedBy: matched.CreatedBy } : row; + }); } -async function onCancelEdit(): Promise { - await cancelEdit(); +/** + * 立即提交一轮整包保存。并发调用不重复发请求: + * - 有在途请求时置 commitAgain 并等它收尾(收尾里会按最新状态补提交); + * - 这样 flushCommit 才能真正等到“最后一次改动已落库”再退出详情。 + */ +async function commitNow(): Promise { + // 创建态必须先点动作栏「保存」拿到后端生成的专辑编号,不走自动保存; + // 非本人专辑也绝不能自动 Update:后端 Update 按 OwnerName 过滤,会回「记录不存在.」 + // (公开专辑列表里能打开别人的专辑,返回时的 flush 会误触发) + if (!album.value || creating.value || !canAmendDetails.value) { + return true; + } + if (savingPromise) { + commitAgain = true; + return savingPromise; + } + const version = editVersion; + const snapshot = detail.value; + const run = (async (): Promise => { + let ok = true; + try { + await save(); + if (editVersion !== version) { + carryLocalEdits(snapshot, detail.value); + } + } + catch (error: any) { + ok = false; + toast(error?.message || t("FilePage.actionFailed")); + } + finally { + savingPromise = null; + if (commitAgain) { + commitAgain = false; + const followOk = await commitNow(); + ok = ok && followOk; + } + } + return ok; + })(); + savingPromise = run; + return run; } +/** 改动后的合并提交:250ms 内的多次改动只发一次请求 */ +function commitSoon(): void { + if (!album.value || creating.value || !canAmendDetails.value) { + return; + } + editVersion += 1; + if (commitTimer) { + clearTimeout(commitTimer); + } + commitTimer = setTimeout(() => { + commitTimer = null; + void commitNow(); + }, 250); +} + +/** 立即提交(离开详情前调用,会等在途请求与补提交都结束;返回是否全部落库成功) */ +async function flushCommit(): Promise { + if (commitTimer) { + clearTimeout(commitTimer); + commitTimer = null; + } + return commitNow(); +} + +// 切走 tab / 页面隐藏时补提交,避免防抖窗口内的改动没落库 +onHide(() => { + void flushCommit(); +}); + +/** 新建专辑保存:只有创建态走这里(已有专辑各块改动自动落库) */ async function onSave(): Promise { if (!(album.value?.PublishLocalName || "").trim()) { toast(t("MediaPage.nameRequired")); return; } - const wasCreating = creating.value; await withBusy(async () => { await save(); - toast(wasCreating ? t("MediaPage.createDone") : t("MediaPage.saveDone")); + toast(t("MediaPage.createDone")); }); } +/** 专辑信息编辑弹层(照群组详情):卡片只读展示,右上角「编辑」从底部改名称/描述/两个开关 */ +const albumDraft = ref({ name: "", notice: "", open: false, default: false }); + +function openAlbumEditor(): void { + const a = album.value; + if (!a) { + return; + } + albumDraft.value = { + name: a.PublishLocalName || "", + notice: a.Notice || "", + open: a.OpenIndc === "Y", + default: a.DefaultIndc === "Y", + }; + albumEditorVisible.value = true; +} + +function closeAlbumEditor(): void { + albumEditorVisible.value = false; +} + +function onDraftOpen(value: boolean): void { + albumDraft.value.open = value; +} + +function onDraftDefault(value: boolean): void { + albumDraft.value.default = value; +} + +/** 概览卡直改「对所有人公开」:所有者/群管理员;连点走 250ms 防抖合并落库 */ +function onToggleOpen(value: boolean): void { + if (!album.value || !canManageAlbum.value) { + return; + } + album.value.OpenIndc = value ? "Y" : "N"; + commitSoon(); +} + +/** 概览卡直改「设为默认专辑」:个人偏好,仅所有者;后端 Update 会取消其它专辑的默认 */ +function onToggleDefault(value: boolean): void { + if (!album.value || !isOwner.value) { + return; + } + album.value.DefaultIndc = value ? "Y" : "N"; + commitSoon(); +} + +/** 弹层保存:草稿一次写回实体再整单落库;新建态只改本地,交给动作栏「保存」Insert */ +async function saveAlbumEditor(): Promise { + const name = albumDraft.value.name.trim(); + if (!name) { + toast(t("MediaPage.nameRequired")); + return; + } + const a = album.value; + if (!a) { + return; + } + a.PublishLocalName = name; + a.Notice = albumDraft.value.notice; + a.OpenIndc = albumDraft.value.open ? "Y" : "N"; + a.DefaultIndc = albumDraft.value.default ? "Y" : "N"; + albumEditorVisible.value = false; + if (creating.value) { + return; + } + // 走自动保存同一条单飞通道:等待在途请求结束再落库,失败已在 commitNow 里提示 + const ok = await flushCommit(); + if (ok) { + toast(t("MediaPage.saveDone")); + } +} + async function onDelete(): Promise { const ok = await confirmDialog( t("MediaPage.delete"), @@ -251,13 +520,14 @@ async function onDelete(): Promise { if (!ok) { return; } + // 删除前把未提交的改动落库,避免“删掉的是一条旧数据”的假象 + await flushCommit(); await withBusy(async () => { await dissolve(); toast(t("MediaPage.deleteDone")); }); } -/** kind -> 展厅页名 */ function exhibitPageOf(k: PublishKind): "photoShow" | "audioHall" | "videoHall" { return k === "photo" ? "photoShow" : k === "audio" ? "audioHall" : "videoHall"; } @@ -283,6 +553,7 @@ function onIconExhibit(entity: PublishAlbumEntity): void { // ===== 明细:上移 / 下移 / 删除 ===== function onMoveDetail(index: number, dir: -1 | 1): void { moveDetail(index, dir); + commitSoon(); } async function onRemoveDetail(detailId: number): Promise { @@ -291,6 +562,96 @@ async function onRemoveDetail(detailId: number): Promise { return; } removeDetail(detailId); + commitSoon(); +} + +/** 打开明细编辑弹层:音轨带出标题/歌手/封面/地址/歌词,其它类型只用备注 */ +function onEditDetail(item: PublishAlbumDetail): void { + if (!canAmendDetails.value) { + return; + } + editingDetail = item; + detailDraft.title = item.Title || ""; + detailDraft.author = item.Author || ""; + // 库里存物理名(order_key),展示用后端随明细一并返的源文件名;后端未升级时回落物理名 + detailDraft.pic = item.Pic || ""; + detailDraft.picName = item.PicName || item.Pic || ""; + detailDraft.url = item.Url || ""; + detailDraft.urlName = item.UrlName || item.Url || ""; + detailDraft.lrc = item.Lrc || ""; + detailDraft.lrcName = item.LrcName || item.Lrc || ""; + detailDraft.notice = item.Notice || ""; + pickedUrls = {}; + detailEditorVisible.value = true; +} + +function closeDetailEditor(): void { + detailEditorVisible.value = false; + editingDetail = null; + pickedUrls = {}; +} + +/** 打开文件选择:按字段限定可选文件类型 */ +function openFilePicker(field: "pic" | "url" | "lrc"): void { + pickerField = field; + filePickerAccept.value = PICKER_ACCEPT[field]; + filePickerVisible.value = true; +} + +/** 选中文件:物理名(OrderKey)写草稿——这是提交值,源文件名只用于输入框展示 */ +function onPickFile(record: DmsFileUploadViewDisplayData): void { + // 旧系统一致:明细的 Pic/Url/Lrc 存 DMS 的 OrderKey,存 FileName(文件编号)拼不出图片地址 + const physical = record?.OrderKey || record?.FileName || ""; + const orgName = record?.OrgFileName || physical; + if (pickerField === "pic") { + detailDraft.pic = physical; + detailDraft.picName = orgName; + } + else if (pickerField === "url") { + detailDraft.url = physical; + detailDraft.urlName = orgName; + } + else { + detailDraft.lrc = physical; + detailDraft.lrcName = orgName; + } + pickedUrls = { ...pickedUrls, [pickerField]: record?.Url || "" }; +} + +/** 弹层保存:音轨写回六字段,其它类型只写备注;随后走自动保存单飞通道立即落库 */ +async function saveDetailEditor(): Promise { + if (!editingDetail) { + return; + } + editingDetail.Notice = detailDraft.notice; + if (kind.value === "audio") { + editingDetail.Title = detailDraft.title; + editingDetail.Author = detailDraft.author; + editingDetail.Pic = detailDraft.pic; + editingDetail.Url = detailDraft.url; + editingDetail.Lrc = detailDraft.lrc; + // 源文件名不落库(后端 NotMapped),但本地要跟着换,否则不重新拉取就还是旧名字 + editingDetail.PicName = detailDraft.picName; + editingDetail.UrlName = detailDraft.urlName; + editingDetail.LrcName = detailDraft.lrcName; + // 列表封面优先取 FormattedPic:换了封面就同步新地址,否则由 Pic 重新拼 + if (pickedUrls.pic !== undefined) { + editingDetail.FormattedPic = pickedUrls.pic; + } + if (pickedUrls.url !== undefined) { + editingDetail.FormattedUrl = pickedUrls.url; + } + if (pickedUrls.lrc !== undefined) { + editingDetail.FormattedLrc = pickedUrls.lrc; + } + } + detailEditorVisible.value = false; + editingDetail = null; + pickedUrls = {}; + const ok = await flushCommit(); + if (ok) { + toast(t("MediaPage.saveDone")); + } } // ===== 公开群组:选择器 / 删除 ===== @@ -310,12 +671,14 @@ async function onAddGroup(): Promise { success: (res) => { const pick = candidates[res.tapIndex]; addGroup(pick.FamilyName, pick.FamilyLocalName, pick.Notice || ""); + commitSoon(); }, }); } function onRemoveGroup(familyName: string): void { removeGroup(familyName); + commitSoon(); } @@ -398,44 +761,44 @@ function onRemoveGroup(familyName: string): void { - - - - - - - - {{ album?.PublishLocalName || t("MediaPage.unnamed") }} - - - {{ album?.PublishName }} - - - - {{ t("MediaPage.chipPublic") }} + + + + + + + + + {{ album?.PublishLocalName || t("MediaPage.unnamed") }} - - {{ t("MediaPage.chipDefault") }} + + {{ album?.PublishName }} + + + {{ t("MediaPage.chipPublic") }} + + + {{ t("MediaPage.chipDefault") }} + + - + - + - - - - - - + + + {{ t("MediaPage.cancel") }} @@ -443,99 +806,132 @@ function onRemoveGroup(familyName: string): void { - - - - {{ t("MediaPage.albumInfo") }} - + + + + {{ t("MediaPage.tabInfo") }} - + + {{ t("MediaPage.tabDetails") }} + {{ details.length }} + + + {{ t("MediaPage.tabGroups") }} + {{ groups.length }} + + + + + + + {{ t("MediaPage.albumInfo") }} + + + {{ t("MediaPage.amend") }} + + + {{ t("MediaPage.albumNo") }} - - {{ - album?.PublishName - }} - + {{ album?.PublishName }} - + {{ t("MediaPage.albumName") }} - + {{ album?.PublishLocalName || t("MediaPage.unnamed") }} {{ t("MediaPage.albumDesc") }} -