diff --git a/CloudBuilder/.gitignore b/CloudBuilder/.gitignore index c2371a9..9237ac8 100644 --- a/CloudBuilder/.gitignore +++ b/CloudBuilder/.gitignore @@ -22,4 +22,5 @@ /CloudBuilder.Master.Service/obj /CloudBuilder.Master.Gui/obj /CloudBuilder.Master.Gui/bin +/.dbtmp diff --git a/CloudBuilder/CloudBuilder.Master.Service/ArticleService.auto.cs b/CloudBuilder/CloudBuilder.Master.Service/ArticleService.auto.cs new file mode 100644 index 0000000..1fcf793 --- /dev/null +++ b/CloudBuilder/CloudBuilder.Master.Service/ArticleService.auto.cs @@ -0,0 +1,423 @@ + +using CloudBuilder.Core.DatabaseAccessor; +using CloudBuilder.Core.DatabaseAccessor.Entity; +using CloudBuilder.Core.Extensions; +using CloudBuilder.Core.Policy; +using CloudBuilder.Core.Service; +using CloudBuilder.Master.Data; +using CloudBuilder.Master.Entity; +using CloudBuilder.Master.Policy; +using CloudBuilder.Master.SearchCriteria; +using CloudBuilder.Security.Entity; +using CloudBuilder.Security.Policy; +using CloudBuilder.Security.Service; +using CloudBuilder.Security.Utility; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection; +using System.Diagnostics; +namespace CloudBuilder.Master.Service +{ + /// + /// 有家文化传承(文章)服务:主表 article + 群组关联 article_family_group + 配图明细 article_filename。 + /// 可见性:仅文章关联族谱群组的正式成员可见;未审核(approved_indc<>Y)仅作者本人可见。 + /// 作者中文名/头像按 created_by join security_user 回填;封面与配图存物理名、按上传目录打包访问地址。 + /// + public class ArticleService : IArticleService + { + private readonly IApplicationService service; + + private IRepository repository; + public ArticleService(IApplicationService service) + { + this.service = service; + repository = service.GetRepository>(); + } + + [PermissionOperate(name: MasterPermissionPolicy.Article, operate: Operate.Find)] + public ArticleData FindByKey(string key) + { + ArticleData data = new ArticleData(); + if (string.IsNullOrEmpty(key)) return data; + + ArticleEntity article = repository.DetachedEntities.Where(x => x.ArticleName == key).FirstOrDefault(); + if (article == null) return data; + + IRepository repositoryArticleFamilyGroup = repository.GetRepository>(); + IRepository repositoryFamilyGroup = repository.GetRepository>(); + + IQueryable q = from e in repositoryArticleFamilyGroup.DetachedEntities + join fg in repositoryFamilyGroup.DetachedEntities on e.FamilyName equals fg.FamilyName + where e.ArticleName == key + select new ArticleFamilyGroupDisplayData(fg) + { + CreatedBy = e.CreatedBy, + CreatedDatetime = e.CreatedDatetime, + UpdatedBy = e.UpdatedBy, + UpdatedDatetime = e.UpdatedDatetime, + ArticleName = e.ArticleName, + }; + + ArticleFamilyGroupDisplayData[] familyGroups = q.ToArray(); + data.FamilyGroups = familyGroups; + + // 归属与群组身份:作者全权;群成员可读;群管理员可改信息/群组/审核/置顶 + string[] familyNames = familyGroups.Select(x => x.FamilyName).ToArray(); + bool isOwner = article.CreatedBy == service.UserId; + bool isMember = isOwner; + bool isManager = isOwner; + if (!isOwner && familyNames.Length > 0) + { + IRepository repositoryFamilyGroupManager = repository.GetRepository>(); + 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 (!isOwner && !isMember) + { + throw new ValidatedException("没有权限."); + } + if (!isOwner && !YesNoPolicy.IsYes(article.ApprovedIndc)) + { + throw new ValidatedException("文章尚未审核通过."); + } + + ArticleDisplayData display = new ArticleDisplayData(article) + { + IsOwner = isOwner, + IsMember = isMember, + IsManager = isManager, + CoverUrl = PackCover(article.ArticleCoverFilename), + }; + FillAuthor(display, article.CreatedBy); + data.Article = display; + + IRepository repositoryArticleFilename = repository.GetRepository>(); + data.Filenames = repositoryArticleFilename.DetachedEntities + .Where(x => x.ArticleName == key) + .OrderBy(x => x.FileId) + .Select(x => new ArticleFilenameDisplayData(x)) + .ToArray(); + foreach (ArticleFilenameDisplayData f in data.Filenames) + { + f.ImageUrl = PackImage(f.FileName); + } + + return data; + } + + [PermissionOperate(name: MasterPermissionPolicy.Article, operate: Operate.Find)] + public ArticleResultData FindBySearchCriteria(ArticleSearchCriteria criteria) + { + ArticleResultData data = new ArticleResultData(); + string userId = service.UserId; + + // 我加入的群组(正式成员) + IRepository repositoryFamilyGroupManager = repository.GetRepository>(); + string[] myGroups = repositoryFamilyGroupManager.DetachedEntities + .Where(x => x.UserName == userId && x.ValidIndc == YesNoPolicy.YES) + .Select(x => x.FamilyName).Distinct().ToArray(); + + // 这些群组下挂的文章编号 + IRepository repositoryArticleFamilyGroup = repository.GetRepository>(); + string[] articleNamesInMyGroups = myGroups.Length == 0 + ? new string[0] + : repositoryArticleFamilyGroup.DetachedEntities + .Where(x => myGroups.Contains(x.FamilyName)) + .Select(x => x.ArticleName).Distinct().ToArray(); + + IQueryable query = repository.DetachedEntities + .Where(x => x.CreatedBy == userId || articleNamesInMyGroups.Contains(x.ArticleName)) + .Where(x => x.ApprovedIndc == YesNoPolicy.YES || x.CreatedBy == userId); + + // 关键字:命中 ArticleTitle 的过滤器取标题模糊(首页搜索用) + string keyword = criteria.Filter? + .FirstOrDefault(f => f.Column == ArticleEntity.ARTICLE_TITLE || f.Column == ArticleEntity.DB_ARTICLE_TITLE)?.Value; + if (!string.IsNullOrWhiteSpace(keyword)) + { + keyword = keyword.Trim(); + query = query.Where(x => x.ArticleTitle.Contains(keyword)); + } + + // 排序:置顶优先,其次 OrderBy 升序(CloudBuilder 约定值小者靠前),最后按创建时间倒序兜底 + IOrderedQueryable ordered = query + .OrderByDescending(x => x.TopIndc == YesNoPolicy.YES) + .ThenBy(x => x.OrderBy) + .ThenByDescending(x => x.CreatedDatetime); + + data.Total = ordered.Count(); + + int limit = criteria.limit > 0 ? criteria.limit : 20; + int page = criteria.page > 0 ? criteria.page : 1; + ArticleEntity[] slice = ordered.Skip((page - 1) * limit).Take(limit).ToArray(); + + ArticleDisplayData[] list = slice.Select(x => new ArticleDisplayData(x) + { + CoverUrl = PackCover(x.ArticleCoverFilename), + }).ToArray(); + FillAuthors(list); + + data.Limit = limit; + data.Page = page; + data.Datas = list; + return data; + } + + [PermissionOperate(name: MasterPermissionPolicy.Article, operate: Operate.Insert)] + [UnitOfWork] + public ArticleData Insert(ArticleData data) + { + if (data == null || data.Article == null) + { + throw new ValidatedException("文章内容为空."); + } + + Validate(data.Article); + + ArticleEntity article = new ArticleEntity(); + ObjectCopyHelper.Copy(data.Article, article); + + ISecurityGeneratorNumberService securityGeneratorNumberService = service.ServiceProvider.GetService()!; + article.ArticleName = securityGeneratorNumberService.GetNo(MasterPrefixPolicy.ARTICLE, MasterPrefixPolicy.ARTICLE_ARTICLE_NAME, MasterPrefixPolicy.ARTICLE_PREFIX, YesNoPolicy.YES, MasterPrefixPolicy.ARTICLE_FORMAT); + article.CreatedBy = service.UserId; + article.UpdatedBy = service.UserId; + article.CreatedDatetime = DateTime.Now; + article.UpdatedDatetime = article.CreatedDatetime; + // 新文章默认未审核、不置顶(审核由作者/管理员在详情页开关控制) + if (string.IsNullOrEmpty(article.ApprovedIndc)) article.ApprovedIndc = YesNoPolicy.NO; + if (string.IsNullOrEmpty(article.TopIndc)) article.TopIndc = YesNoPolicy.NO; + + repository.InsertNow(article); + + // 先主表再子表,避免子表关联不到主键 + SaveArticleFamilyGroup(article.ArticleName, data.FamilyGroups); + SaveArticleFilenames(article.ArticleName, data.Filenames); + + return FindByKey(article.ArticleName); + } + + [PermissionOperate(name: MasterPermissionPolicy.Article, operate: Operate.Update)] + [UnitOfWork] + public ArticleData Update(ArticleData data) + { + if (data == null || data.Article == null) + { + throw new ValidatedException("文章内容为空."); + } + + ArticleEntity original = repository.DetachedEntities.Where(x => x.ArticleName == data.Article.ArticleName).FirstOrDefault(); + if (original == null) + { + throw new ValidatedException("记录不存在."); + } + + IRepository repositoryArticleFamilyGroup = repository.GetRepository>(); + IRepository repositoryFamilyGroupManager = repository.GetRepository>(); + + bool isOwner = original.CreatedBy == service.UserId; + bool isManager = false; + bool isMember = false; + if (!isOwner) + { + string[] familyNames = repositoryArticleFamilyGroup.DetachedEntities + .Where(x => x.ArticleName == original.ArticleName) + .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.Article.CreatedBy = original.CreatedBy; + data.Article.CreatedDatetime = original.CreatedDatetime; + if (!isOwner && !isManager) + { + // 普通群成员不可改正文/封面/审核/置顶:一律以库中为准 + data.Article.ArticleTitle = original.ArticleTitle; + data.Article.Brief = original.Brief; + data.Article.ArticleCoverFilename = original.ArticleCoverFilename; + data.Article.ApprovedIndc = original.ApprovedIndc; + data.Article.TopIndc = original.TopIndc; + data.Article.OrderBy = original.OrderBy; + } + + ValidateHelper.Validate(service, data.Article, ArticleEntity.DB_NAME_ARTICLE); + + ArticleEntity article = new ArticleEntity(); + ObjectCopyHelper.Copy(data.Article, article); + article.UpdatedBy = service.UserId; + article.UpdatedDatetime = DateTime.Now; + + repository.UpdateNow(article); + + // 群组增删与配图只对作者/群管理员开放 + if (isOwner || isManager) + { + SaveArticleFamilyGroup(article.ArticleName, data.FamilyGroups); + SaveArticleFilenames(article.ArticleName, data.Filenames); + } + + return FindByKey(article.ArticleName); + } + + [PermissionOperate(name: MasterPermissionPolicy.Article, operate: Operate.Delete)] + [UnitOfWork] + public void Delete(string key) + { + ArticleEntity article = repository.DetachedEntities.Where(x => x.ArticleName == key).FirstOrDefault(); + if (article == null) return; + + // 删除:作者或关联群组管理员 + if (article.CreatedBy != service.UserId && !IsGroupManagerOfArticle(key)) + { + throw new ValidatedException("没有权限."); + } + + repository.DeleteNow(article); + + IRepository repositoryFamilyGroup = repository.GetRepository>(); + ArticleFamilyGroupEntity[] groups = repositoryFamilyGroup.Entities.Where(x => x.ArticleName == key).ToArray(); + foreach (ArticleFamilyGroupEntity gp in groups) + { + repositoryFamilyGroup.DeleteNow(gp); + } + + IRepository repositoryFilename = repository.GetRepository>(); + ArticleFilenameEntity[] files = repositoryFilename.Entities.Where(x => x.ArticleName == key).ToArray(); + foreach (ArticleFilenameEntity f in files) + { + repositoryFilename.DeleteNow(f); + } + } + + /// 当前登录人是否为该文章任一关联群组的管理员(正式成员且 ManagerIndc=Y) + private bool IsGroupManagerOfArticle(string articleName) + { + IRepository repositoryArticleFamilyGroup = repository.GetRepository>(); + IRepository repositoryFamilyGroupManager = repository.GetRepository>(); + + string[] familyNames = repositoryArticleFamilyGroup.DetachedEntities + .Where(x => x.ArticleName == articleName) + .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 SaveArticleFamilyGroup(string articleName, ArticleFamilyGroupEntity[] familyGroups) + { + IRepository repositoryArticleFamilyGroup = repository.GetRepository>(); + + ArticleFamilyGroupEntity[] olds = repositoryArticleFamilyGroup.Entities.Where(x => x.ArticleName == articleName).ToArray(); + foreach (ArticleFamilyGroupEntity old in olds) + { + if (familyGroups == null || familyGroups.Length == 0 || familyGroups.Count(x => x.FamilyName == old.FamilyName) == 0) + { + repositoryArticleFamilyGroup.DeleteNow(old); + } + } + + if (familyGroups != null) + { + foreach (ArticleFamilyGroupEntity dto in familyGroups) + { + if (olds.Any(x => x.FamilyName == dto.FamilyName)) continue; + repositoryArticleFamilyGroup.InsertNow(new ArticleFamilyGroupEntity() + { + ArticleName = articleName, + FamilyName = dto.FamilyName, + }); + } + } + } + + private void SaveArticleFilenames(string articleName, ArticleFilenameEntity[] filenames) + { + IRepository repositoryArticleFilename = repository.GetRepository>(); + + ArticleFilenameEntity[] olds = repositoryArticleFilename.Entities.Where(x => x.ArticleName == articleName).ToArray(); + foreach (ArticleFilenameEntity old in olds) + { + if (filenames == null || filenames.Length == 0 || filenames.Count(x => x.FileName == old.FileName) == 0) + { + repositoryArticleFilename.DeleteNow(old); + } + } + + if (filenames != null) + { + foreach (ArticleFilenameEntity dto in filenames) + { + if (olds.Any(x => x.FileName == dto.FileName)) continue; + repositoryArticleFilename.InsertNow(new ArticleFilenameEntity() + { + ArticleName = articleName, + FileName = dto.FileName, + }); + } + } + } + + private void Validate(ArticleEntity article) + { + List sysTableSchemas = service.ServiceProvider.GetRequiredService().FindByTableName(ArticleEntity.DB_NAME_ARTICLE); + ValidateHelper.ValidateString(article, sysTableSchemas.ToArray()); + } + + private string PackCover(string fileName) + { + if (string.IsNullOrEmpty(fileName)) return ""; + return FileTypePolicy.PackAvatarUrl(service, UploadFolderPolicy.ARTICLE_COVER, fileName) ?? ""; + } + + private string PackImage(string fileName) + { + if (string.IsNullOrEmpty(fileName)) return ""; + return FileTypePolicy.PackAvatarUrl(service, UploadFolderPolicy.ARTICLE_IMAGE, fileName) ?? ""; + } + + /// 按用户名 join security_user 回填单个作者的中文名与头像。 + private void FillAuthor(ArticleDisplayData display, string userName) + { + if (display == null || string.IsNullOrEmpty(userName)) return; + IRepository repositoryUser = repository.GetRepository>(); + SecurityUserEntity user = repositoryUser.DetachedEntities.Where(x => x.UserName == userName).FirstOrDefault(); + display.AuthorLocalName = user?.UserLocalName ?? userName; + display.AuthorAvatarUrl = FileTypePolicy.PackAvatarUrl(service, UploadFolderPolicy.USER_AVATAR, user?.UserIconImgPath ?? "") ?? ""; + } + + /// 批量 join:一次取回页内所有作者的中文名与头像,避免 N+1。 + private void FillAuthors(ArticleDisplayData[] list) + { + if (list == null || list.Length == 0) return; + string[] names = list.Select(x => x.CreatedBy).Where(x => !string.IsNullOrEmpty(x)).Distinct().ToArray(); + if (names.Length == 0) return; + + IRepository repositoryUser = repository.GetRepository>(); + SecurityUserEntity[] users = repositoryUser.DetachedEntities.Where(x => names.Contains(x.UserName)).ToArray(); + + foreach (ArticleDisplayData item in list) + { + SecurityUserEntity user = users.Where(y => y.UserName == item.CreatedBy).FirstOrDefault(); + item.AuthorLocalName = user?.UserLocalName ?? item.CreatedBy; + item.AuthorAvatarUrl = FileTypePolicy.PackAvatarUrl(service, UploadFolderPolicy.USER_AVATAR, user?.UserIconImgPath ?? "") ?? ""; + } + } + } +} diff --git a/CloudBuilder/CloudBuilder.Master/Data/ArticleData.cs b/CloudBuilder/CloudBuilder.Master/Data/ArticleData.cs new file mode 100644 index 0000000..68cfc93 --- /dev/null +++ b/CloudBuilder/CloudBuilder.Master/Data/ArticleData.cs @@ -0,0 +1,20 @@ +using CloudBuilder.Core.Authorization; +using CloudBuilder.Master.Entity; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace CloudBuilder.Master.Data +{ + [TypeScript] + public class ArticleData + { + public ArticleDisplayData? Article { get; set; } + + public ArticleFamilyGroupDisplayData[]? FamilyGroups { get; set; } + + public ArticleFilenameDisplayData[]? Filenames { get; set; } + } +} diff --git a/CloudBuilder/CloudBuilder.Master/Data/ArticleDisplayData.cs b/CloudBuilder/CloudBuilder.Master/Data/ArticleDisplayData.cs new file mode 100644 index 0000000..f6ed670 --- /dev/null +++ b/CloudBuilder/CloudBuilder.Master/Data/ArticleDisplayData.cs @@ -0,0 +1,45 @@ +using CloudBuilder.Core.Authorization; +using CloudBuilder.Master.Entity; +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace CloudBuilder.Master.Data +{ + /// + /// 排除实体类标记 + /// + [NotMapped] + [TypeScript] + public class ArticleDisplayData : ArticleEntity + { + public ArticleDisplayData() + { + } + public ArticleDisplayData(ArticleEntity entity) + { + CopyFrom(entity); + } + + /// 作者中文名(按 CreatedBy join security_user.user_local_name) + public string? AuthorLocalName { get; set; } + + /// 作者头像访问地址(按 CreatedBy join security_user.user_icon_img_path 打包) + public string? AuthorAvatarUrl { get; set; } + + /// 封面访问地址(由 ArticleCoverFilename 物理名打包,未设置为空) + public string? CoverUrl { get; set; } + + /// 当前登录人是否作者本人(前端据此显示编辑/删除/审核/置顶) + public bool IsOwner { get; set; } + + /// 当前登录人是否关联群组的正式成员 + public bool IsMember { get; set; } + + /// 当前登录人是否关联群组的管理员 + public bool IsManager { get; set; } + } +} diff --git a/CloudBuilder/CloudBuilder.Master/Data/ArticleFamilyGroupDisplayData.cs b/CloudBuilder/CloudBuilder.Master/Data/ArticleFamilyGroupDisplayData.cs new file mode 100644 index 0000000..c1305be --- /dev/null +++ b/CloudBuilder/CloudBuilder.Master/Data/ArticleFamilyGroupDisplayData.cs @@ -0,0 +1,31 @@ +using CloudBuilder.Core.Authorization; +using CloudBuilder.Master.Entity; +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace CloudBuilder.Master.Data +{ + [NotMapped] + [TypeScript] + public class ArticleFamilyGroupDisplayData : ArticleFamilyGroupEntity + { + public ArticleFamilyGroupDisplayData() + { + } + + public ArticleFamilyGroupDisplayData(FamilyGroupEntity entity) + { + this.FamilyLocalName = entity.FamilyLocalName; + this.FamilyName = entity.FamilyName; + this.Notice = entity.Notice; + } + + public string? FamilyLocalName { get; set; } + + public string? Notice { get; set; } + } +} diff --git a/CloudBuilder/CloudBuilder.Master/Data/ArticleFilenameDisplayData.cs b/CloudBuilder/CloudBuilder.Master/Data/ArticleFilenameDisplayData.cs new file mode 100644 index 0000000..46487a4 --- /dev/null +++ b/CloudBuilder/CloudBuilder.Master/Data/ArticleFilenameDisplayData.cs @@ -0,0 +1,32 @@ +using CloudBuilder.Core.Authorization; +using CloudBuilder.Master.Entity; +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace CloudBuilder.Master.Data +{ + /// + /// 文章配图明细展示数据:在实体(FileName 存物理名)基础上补一个打包后的访问地址, + /// 供前端图片 Tab 直接渲染;提交时仍回传物理名 FileName,ImageUrl 只读。 + /// + [NotMapped] + [TypeScript] + public class ArticleFilenameDisplayData : ArticleFilenameEntity + { + public ArticleFilenameDisplayData() + { + } + + public ArticleFilenameDisplayData(ArticleFilenameEntity entity) + { + CopyFrom(entity); + } + + /// 配图访问地址(由 FileName 物理名打包,未设置为空) + public string? ImageUrl { get; set; } + } +} diff --git a/CloudBuilder/CloudBuilder.Master/Data/ArticleResultData.cs b/CloudBuilder/CloudBuilder.Master/Data/ArticleResultData.cs new file mode 100644 index 0000000..2ad2830 --- /dev/null +++ b/CloudBuilder/CloudBuilder.Master/Data/ArticleResultData.cs @@ -0,0 +1,17 @@ +using CloudBuilder.Core.Authorization; +using CloudBuilder.Core.Service; +using CloudBuilder.Master.Entity; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace CloudBuilder.Master.Data +{ + [TypeScript] + public class ArticleResultData : SearchCriteriaResultData + { + public ArticleDisplayData[]? Datas { get; set; } + } +} diff --git a/CloudBuilder/CloudBuilder.Master/Entity/ArticleEntity.auto.cs b/CloudBuilder/CloudBuilder.Master/Entity/ArticleEntity.auto.cs new file mode 100644 index 0000000..a6ded48 --- /dev/null +++ b/CloudBuilder/CloudBuilder.Master/Entity/ArticleEntity.auto.cs @@ -0,0 +1,198 @@ + +using CloudBuilder.Core.Authorization; +using CloudBuilder.Core.DatabaseAccessor.Entity; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +namespace CloudBuilder.Master.Entity + { + [TypeScript] + [Table("article")] + public class ArticleEntity : ViewEntityBase + { + + public ArticleEntity() + { + } + + [Column("created_datetime")] + public DateTime CreatedDatetime { get; set; } + [Column("created_by")] + public string CreatedBy { get; set; } + [Column("updated_datetime")] + public DateTime UpdatedDatetime { get; set; } + [Column("updated_by")] + public string UpdatedBy { get; set; } + [Key] + [Column("article_name")] + public string ArticleName { get; set; } + [Column("article_title")] + public string ArticleTitle { get; set; } + [Column("brief")] + public string? Brief { get; set; } + [Column("article_cover_filename")] + public string? ArticleCoverFilename { get; set; } + [Column("approved_indc")] + public string? ApprovedIndc { get; set; } + [Column("top_indc")] + public string? TopIndc { get; set; } + [Column("order_by")] + public int OrderBy { get; set; } + + public const string CREATED_DATETIME = "CreatedDatetime"; + public const string CREATED_BY = "CreatedBy"; + public const string UPDATED_DATETIME = "UpdatedDatetime"; + public const string UPDATED_BY = "UpdatedBy"; + public const string ARTICLE_NAME = "ArticleName"; + public const string ARTICLE_TITLE = "ArticleTitle"; + public const string BRIEF = "Brief"; + public const string ARTICLE_COVER_FILENAME = "ArticleCoverFilename"; + public const string APPROVED_INDC = "ApprovedIndc"; + public const string TOP_INDC = "TopIndc"; + public const string ORDER_BY = "OrderBy"; + + public const string DB_NAME_ARTICLE = "article"; + public const string DB_NAME_FIELDS="created_datetime,created_by,updated_datetime,updated_by,article_name,article_title,brief,article_cover_filename,approved_indc,top_indc,order_by"; + + public const string DB_CREATED_DATETIME = "created_datetime"; + public const string DB_CREATED_BY = "created_by"; + public const string DB_UPDATED_DATETIME = "updated_datetime"; + public const string DB_UPDATED_BY = "updated_by"; + public const string DB_ARTICLE_NAME = "article_name"; + public const string DB_ARTICLE_TITLE = "article_title"; + public const string DB_BRIEF = "brief"; + public const string DB_ARTICLE_COVER_FILENAME = "article_cover_filename"; + public const string DB_APPROVED_INDC = "approved_indc"; + public const string DB_TOP_INDC = "top_indc"; + public const string DB_ORDER_BY = "order_by"; + + private string ProviderName { get; set; } + + public static ArticleEntity GetInstance(string providerName) + { + return new ArticleEntity() { ProviderName = providerName }; + } + + public virtual void CopyFrom(ArticleEntity source) + { + CopyFrom(source, true); + } + + public virtual void CopyFrom(ArticleEntity source, bool includeSystemFields) + { + if (source == null) + { + throw new NullReferenceException("Source entity is null."); + } + + this.CreatedDatetime = source.CreatedDatetime; + this.CreatedBy = source.CreatedBy; + this.UpdatedDatetime = source.UpdatedDatetime; + this.UpdatedBy = source.UpdatedBy; + this.ArticleName = source.ArticleName; + this.ArticleTitle = source.ArticleTitle; + this.Brief = source.Brief; + this.ArticleCoverFilename = source.ArticleCoverFilename; + this.ApprovedIndc = source.ApprovedIndc; + this.TopIndc = source.TopIndc; + this.OrderBy = source.OrderBy; + + if (includeSystemFields) + { + this.CreatedDatetime = source.CreatedDatetime; + this.CreatedBy = source.CreatedBy; + this.UpdatedDatetime = source.UpdatedDatetime; + this.UpdatedBy = source.UpdatedBy; + } + } + + public override string GetSelectFrom() + { + if (ProviderName.Contains("MySql")) + { + return GetSelectFromByMySql(); + } + else if (ProviderName.Contains("SqlServer")) + { + return GetSelectFromByBySqlServer(); + } + else + { + throw new NotSupportedException($"不支持的数据库提供程序:{ProviderName}"); + } + } + public string GetSelectFromByMySql() + { + return $" select {DB_NAME_FIELDS} from { DB_NAME_ARTICLE} with(nolock) "; + } + public string GetSelectFromByBySqlServer() + { + return $" select {DB_NAME_FIELDS} from { DB_NAME_ARTICLE} "; + } + + public override string GetSelectCountFrom(string sqlWhere) + { + if (ProviderName.Contains("MySql")) + { + return GetSelectCountFromByMySql(sqlWhere); + } + else if (ProviderName.Contains("SqlServer")) + { + return GetSelectCountFromBySqlServer(sqlWhere); + } + else + { + throw new NotSupportedException($"不支持的数据库提供程序:{ProviderName}"); + } + } + + public string GetSelectCountFromByMySql(string sqlWhere) + { + return $" select count(*) from { DB_NAME_ARTICLE} where 1=1 {sqlWhere}"; + } + public string GetSelectCountFromBySqlServer(string sqlWhere) + { + return $" select count(*) from { DB_NAME_ARTICLE} with(nolock) where 1=1 {sqlWhere}"; + } + + public override string GetSelectTopFrom(int limit, int start, string orderby, string orderbytag, string sqlWhere) + { + if (ProviderName.Contains("MySql")) + { + return GetSelectTopFromByMySql(limit, start, orderby, orderbytag, sqlWhere); + } + else if (ProviderName.Contains("SqlServer")) + { + return GetSelectTopFromBySqlServer(limit, start, orderby, orderbytag, sqlWhere); + } + else + { + throw new NotSupportedException($"不支持的数据库提供程序:{ProviderName}"); + } + } + + public string GetSelectTopFromByMySql(int limit, int start,string orderby,string orderbytag, string sqlWhere) + { + if (string.IsNullOrEmpty(orderbytag) || orderbytag.Trim() != "desc") orderbytag = "asc"; + if (string.IsNullOrEmpty(orderby) || !DB_NAME_FIELDS.Contains(orderby)) orderby = DB_ARTICLE_NAME ; + int offset = limit * (start - 1); + return $" select {DB_NAME_FIELDS} from (select {DB_NAME_FIELDS} ,row_number() over(order by {orderby} {orderbytag}) as num from { DB_NAME_ARTICLE} where 1=1 {sqlWhere} ) a where num> {offset} LIMIT {limit} "; + } + + public string GetSelectTopFromBySqlServer(int limit, int start,string orderby,string orderbytag, string sqlWhere) + { + if (string.IsNullOrEmpty(orderbytag) || orderbytag.Trim() != "desc") orderbytag = "asc"; + if (string.IsNullOrEmpty(orderby) || !DB_NAME_FIELDS.Contains(orderby)) orderby = DB_ARTICLE_NAME ; + string top = limit > 0 ? $"top {limit}" : ""; + return $" select {top} {DB_NAME_FIELDS} from (select {DB_NAME_FIELDS} ,row_number() over(order by {orderby} {orderbytag}) as num from { DB_NAME_ARTICLE} with(nolock) where 1=1 {sqlWhere} ) a where num> {limit * (start - 1)}"; + } + + public ArticleEntity Clone() + { + ArticleEntity result = new ArticleEntity(); + result.CopyFrom(this); + + return result; + } + + } +} diff --git a/CloudBuilder/CloudBuilder.Master/Entity/ArticleFamilyGroupEntity.auto.cs b/CloudBuilder/CloudBuilder.Master/Entity/ArticleFamilyGroupEntity.auto.cs new file mode 100644 index 0000000..ee8959d --- /dev/null +++ b/CloudBuilder/CloudBuilder.Master/Entity/ArticleFamilyGroupEntity.auto.cs @@ -0,0 +1,172 @@ + +using CloudBuilder.Core.Authorization; +using CloudBuilder.Core.DatabaseAccessor.Entity; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +namespace CloudBuilder.Master.Entity + { + [TypeScript] + [Table("article_family_group")] + public class ArticleFamilyGroupEntity : EntityBase + { + + public ArticleFamilyGroupEntity() + { + } + + [Column("created_datetime")] + public DateTime CreatedDatetime { get; set; } + [Column("created_by")] + public string CreatedBy { get; set; } + [Column("updated_datetime")] + public DateTime UpdatedDatetime { get; set; } + [Column("updated_by")] + public string UpdatedBy { get; set; } + [Column("article_name")] + public string ArticleName { get; set; } + [Column("family_name")] + public string FamilyName { get; set; } + + public const string CREATED_DATETIME = "CreatedDatetime"; + public const string CREATED_BY = "CreatedBy"; + public const string UPDATED_DATETIME = "UpdatedDatetime"; + public const string UPDATED_BY = "UpdatedBy"; + public const string ARTICLE_NAME = "ArticleName"; + public const string FAMILY_NAME = "FamilyName"; + + public const string DB_NAME_ARTICLE_FAMILY_GROUP = "article_family_group"; + public const string DB_NAME_FIELDS="created_datetime,created_by,updated_datetime,updated_by,article_name,family_name"; + + public const string DB_CREATED_DATETIME = "created_datetime"; + public const string DB_CREATED_BY = "created_by"; + public const string DB_UPDATED_DATETIME = "updated_datetime"; + public const string DB_UPDATED_BY = "updated_by"; + public const string DB_ARTICLE_NAME = "article_name"; + public const string DB_FAMILY_NAME = "family_name"; + + private string ProviderName { get; set; } + + public static ArticleFamilyGroupEntity GetInstance(string providerName) + { + return new ArticleFamilyGroupEntity() { ProviderName = providerName }; + } + + public virtual void CopyFrom(ArticleFamilyGroupEntity source) + { + CopyFrom(source, true); + } + + public virtual void CopyFrom(ArticleFamilyGroupEntity source, bool includeSystemFields) + { + if (source == null) + { + throw new NullReferenceException("Source entity is null."); + } + + this.CreatedDatetime = source.CreatedDatetime; + this.CreatedBy = source.CreatedBy; + this.UpdatedDatetime = source.UpdatedDatetime; + this.UpdatedBy = source.UpdatedBy; + this.ArticleName = source.ArticleName; + this.FamilyName = source.FamilyName; + + if (includeSystemFields) + { + this.CreatedDatetime = source.CreatedDatetime; + this.CreatedBy = source.CreatedBy; + this.UpdatedDatetime = source.UpdatedDatetime; + this.UpdatedBy = source.UpdatedBy; + } + } + + public override string GetSelectFrom() + { + if (ProviderName.Contains("MySql")) + { + return GetSelectFromByMySql(); + } + else if (ProviderName.Contains("SqlServer")) + { + return GetSelectFromByBySqlServer(); + } + else + { + throw new NotSupportedException($"不支持的数据库提供程序:{ProviderName}"); + } + } + public string GetSelectFromByMySql() + { + return $" select {DB_NAME_FIELDS} from { DB_NAME_ARTICLE_FAMILY_GROUP} with(nolock) "; + } + public string GetSelectFromByBySqlServer() + { + return $" select {DB_NAME_FIELDS} from { DB_NAME_ARTICLE_FAMILY_GROUP} "; + } + + public override string GetSelectCountFrom(string sqlWhere) + { + if (ProviderName.Contains("MySql")) + { + return GetSelectCountFromByMySql(sqlWhere); + } + else if (ProviderName.Contains("SqlServer")) + { + return GetSelectCountFromBySqlServer(sqlWhere); + } + else + { + throw new NotSupportedException($"不支持的数据库提供程序:{ProviderName}"); + } + } + + public string GetSelectCountFromByMySql(string sqlWhere) + { + return $" select count(*) from { DB_NAME_ARTICLE_FAMILY_GROUP} where 1=1 {sqlWhere}"; + } + public string GetSelectCountFromBySqlServer(string sqlWhere) + { + return $" select count(*) from { DB_NAME_ARTICLE_FAMILY_GROUP} with(nolock) where 1=1 {sqlWhere}"; + } + + public override string GetSelectTopFrom(int limit, int start, string orderby, string orderbytag, string sqlWhere) + { + if (ProviderName.Contains("MySql")) + { + return GetSelectTopFromByMySql(limit, start, orderby, orderbytag, sqlWhere); + } + else if (ProviderName.Contains("SqlServer")) + { + return GetSelectTopFromBySqlServer(limit, start, orderby, orderbytag, sqlWhere); + } + else + { + throw new NotSupportedException($"不支持的数据库提供程序:{ProviderName}"); + } + } + + public string GetSelectTopFromByMySql(int limit, int start,string orderby,string orderbytag, string sqlWhere) + { + if (string.IsNullOrEmpty(orderbytag) || orderbytag.Trim() != "desc") orderbytag = "asc"; + if (string.IsNullOrEmpty(orderby) || !DB_NAME_FIELDS.Contains(orderby)) orderby = DB_ARTICLE_NAME ; + int offset = limit * (start - 1); + return $" select {DB_NAME_FIELDS} from (select {DB_NAME_FIELDS} ,row_number() over(order by {orderby} {orderbytag}) as num from { DB_NAME_ARTICLE_FAMILY_GROUP} where 1=1 {sqlWhere} ) a where num> {offset} LIMIT {limit} "; + } + + public string GetSelectTopFromBySqlServer(int limit, int start,string orderby,string orderbytag, string sqlWhere) + { + if (string.IsNullOrEmpty(orderbytag) || orderbytag.Trim() != "desc") orderbytag = "asc"; + if (string.IsNullOrEmpty(orderby) || !DB_NAME_FIELDS.Contains(orderby)) orderby = DB_ARTICLE_NAME ; + string top = limit > 0 ? $"top {limit}" : ""; + return $" select {top} {DB_NAME_FIELDS} from (select {DB_NAME_FIELDS} ,row_number() over(order by {orderby} {orderbytag}) as num from { DB_NAME_ARTICLE_FAMILY_GROUP} with(nolock) where 1=1 {sqlWhere} ) a where num> {limit * (start - 1)}"; + } + + public ArticleFamilyGroupEntity Clone() + { + ArticleFamilyGroupEntity result = new ArticleFamilyGroupEntity(); + result.CopyFrom(this); + + return result; + } + + } +} diff --git a/CloudBuilder/CloudBuilder.Master/Entity/ArticleFamilyGroupEntityKey.auto.cs b/CloudBuilder/CloudBuilder.Master/Entity/ArticleFamilyGroupEntityKey.auto.cs new file mode 100644 index 0000000..639c449 --- /dev/null +++ b/CloudBuilder/CloudBuilder.Master/Entity/ArticleFamilyGroupEntityKey.auto.cs @@ -0,0 +1,33 @@ + +using CloudBuilder.Core.Authorization; +namespace CloudBuilder.Master +{ + [TypeScript] + public class ArticleFamilyGroupEntityKey + { + public string ArticleName { get; set; } + public string FamilyName { get; set; } + + public override bool Equals(object obj) + { + if (this == obj) return true; + + ArticleFamilyGroupEntityKey pkey = obj as ArticleFamilyGroupEntityKey; + if (pkey == null) return false; + + if (ArticleName != pkey.ArticleName) return false; + if (FamilyName != pkey.FamilyName) return false; + + return true; + } + + + public override string ToString() + { + string declareSql=""; + declareSql=declareSql+$"declare @__tkey_ArticleName_0 varchar(50) select @__tkey_ArticleName_0 ='{ArticleName}'; " ; declareSql=declareSql+$"declare @__tkey_FamilyName_1 varchar(50) select @__tkey_FamilyName_1 ='{FamilyName}'; " ; + return declareSql; + } + } + } + /* builder.Entity().HasKey(c => new { c.ArticleName, c.FamilyName}); */ diff --git a/CloudBuilder/CloudBuilder.Master/Entity/ArticleFilenameEntity.auto.cs b/CloudBuilder/CloudBuilder.Master/Entity/ArticleFilenameEntity.auto.cs new file mode 100644 index 0000000..1d3472c --- /dev/null +++ b/CloudBuilder/CloudBuilder.Master/Entity/ArticleFilenameEntity.auto.cs @@ -0,0 +1,179 @@ + +using CloudBuilder.Core.Authorization; +using CloudBuilder.Core.DatabaseAccessor.Entity; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +namespace CloudBuilder.Master.Entity + { + [TypeScript] + [Table("article_filename")] + public class ArticleFilenameEntity : ViewEntityBase + { + + public ArticleFilenameEntity() + { + } + + [Column("created_datetime")] + public DateTime CreatedDatetime { get; set; } + [Column("created_by")] + public string CreatedBy { get; set; } + [Column("updated_datetime")] + public DateTime UpdatedDatetime { get; set; } + [Column("updated_by")] + public string UpdatedBy { get; set; } + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + [Column("file_id")] + public long FileId { get; set; } + [Column("article_name")] + public string ArticleName { get; set; } + [Column("file_name")] + public string? FileName { get; set; } + + public const string CREATED_DATETIME = "CreatedDatetime"; + public const string CREATED_BY = "CreatedBy"; + public const string UPDATED_DATETIME = "UpdatedDatetime"; + public const string UPDATED_BY = "UpdatedBy"; + public const string FILE_ID = "FileId"; + public const string ARTICLE_NAME = "ArticleName"; + public const string FILE_NAME = "FileName"; + + public const string DB_NAME_ARTICLE_FILENAME = "article_filename"; + public const string DB_NAME_FIELDS="created_datetime,created_by,updated_datetime,updated_by,file_id,article_name,file_name"; + + public const string DB_CREATED_DATETIME = "created_datetime"; + public const string DB_CREATED_BY = "created_by"; + public const string DB_UPDATED_DATETIME = "updated_datetime"; + public const string DB_UPDATED_BY = "updated_by"; + public const string DB_FILE_ID = "file_id"; + public const string DB_ARTICLE_NAME = "article_name"; + public const string DB_FILE_NAME = "file_name"; + + private string ProviderName { get; set; } + + public static ArticleFilenameEntity GetInstance(string providerName) + { + return new ArticleFilenameEntity() { ProviderName = providerName }; + } + + public virtual void CopyFrom(ArticleFilenameEntity source) + { + CopyFrom(source, true); + } + + public virtual void CopyFrom(ArticleFilenameEntity source, bool includeSystemFields) + { + if (source == null) + { + throw new NullReferenceException("Source entity is null."); + } + + this.CreatedDatetime = source.CreatedDatetime; + this.CreatedBy = source.CreatedBy; + this.UpdatedDatetime = source.UpdatedDatetime; + this.UpdatedBy = source.UpdatedBy; + this.FileId = source.FileId; + this.ArticleName = source.ArticleName; + this.FileName = source.FileName; + + if (includeSystemFields) + { + this.CreatedDatetime = source.CreatedDatetime; + this.CreatedBy = source.CreatedBy; + this.UpdatedDatetime = source.UpdatedDatetime; + this.UpdatedBy = source.UpdatedBy; + } + } + + public override string GetSelectFrom() + { + if (ProviderName.Contains("MySql")) + { + return GetSelectFromByMySql(); + } + else if (ProviderName.Contains("SqlServer")) + { + return GetSelectFromByBySqlServer(); + } + else + { + throw new NotSupportedException($"不支持的数据库提供程序:{ProviderName}"); + } + } + public string GetSelectFromByMySql() + { + return $" select {DB_NAME_FIELDS} from { DB_NAME_ARTICLE_FILENAME} with(nolock) "; + } + public string GetSelectFromByBySqlServer() + { + return $" select {DB_NAME_FIELDS} from { DB_NAME_ARTICLE_FILENAME} "; + } + + public override string GetSelectCountFrom(string sqlWhere) + { + if (ProviderName.Contains("MySql")) + { + return GetSelectCountFromByMySql(sqlWhere); + } + else if (ProviderName.Contains("SqlServer")) + { + return GetSelectCountFromBySqlServer(sqlWhere); + } + else + { + throw new NotSupportedException($"不支持的数据库提供程序:{ProviderName}"); + } + } + + public string GetSelectCountFromByMySql(string sqlWhere) + { + return $" select count(*) from { DB_NAME_ARTICLE_FILENAME} where 1=1 {sqlWhere}"; + } + public string GetSelectCountFromBySqlServer(string sqlWhere) + { + return $" select count(*) from { DB_NAME_ARTICLE_FILENAME} with(nolock) where 1=1 {sqlWhere}"; + } + + public override string GetSelectTopFrom(int limit, int start, string orderby, string orderbytag, string sqlWhere) + { + if (ProviderName.Contains("MySql")) + { + return GetSelectTopFromByMySql(limit, start, orderby, orderbytag, sqlWhere); + } + else if (ProviderName.Contains("SqlServer")) + { + return GetSelectTopFromBySqlServer(limit, start, orderby, orderbytag, sqlWhere); + } + else + { + throw new NotSupportedException($"不支持的数据库提供程序:{ProviderName}"); + } + } + + public string GetSelectTopFromByMySql(int limit, int start,string orderby,string orderbytag, string sqlWhere) + { + if (string.IsNullOrEmpty(orderbytag) || orderbytag.Trim() != "desc") orderbytag = "asc"; + if (string.IsNullOrEmpty(orderby) || !DB_NAME_FIELDS.Contains(orderby)) orderby = DB_FILE_ID ; + int offset = limit * (start - 1); + return $" select {DB_NAME_FIELDS} from (select {DB_NAME_FIELDS} ,row_number() over(order by {orderby} {orderbytag}) as num from { DB_NAME_ARTICLE_FILENAME} where 1=1 {sqlWhere} ) a where num> {offset} LIMIT {limit} "; + } + + public string GetSelectTopFromBySqlServer(int limit, int start,string orderby,string orderbytag, string sqlWhere) + { + if (string.IsNullOrEmpty(orderbytag) || orderbytag.Trim() != "desc") orderbytag = "asc"; + if (string.IsNullOrEmpty(orderby) || !DB_NAME_FIELDS.Contains(orderby)) orderby = DB_FILE_ID ; + string top = limit > 0 ? $"top {limit}" : ""; + return $" select {top} {DB_NAME_FIELDS} from (select {DB_NAME_FIELDS} ,row_number() over(order by {orderby} {orderbytag}) as num from { DB_NAME_ARTICLE_FILENAME} with(nolock) where 1=1 {sqlWhere} ) a where num> {limit * (start - 1)}"; + } + + public ArticleFilenameEntity Clone() + { + ArticleFilenameEntity result = new ArticleFilenameEntity(); + result.CopyFrom(this); + + return result; + } + + } +} diff --git a/CloudBuilder/CloudBuilder.Master/Entity/CloudBuilderMasterRegistEntity.auto.cs b/CloudBuilder/CloudBuilder.Master/Entity/CloudBuilderMasterRegistEntity.auto.cs index 50b17da..9bd357c 100644 --- a/CloudBuilder/CloudBuilder.Master/Entity/CloudBuilderMasterRegistEntity.auto.cs +++ b/CloudBuilder/CloudBuilder.Master/Entity/CloudBuilderMasterRegistEntity.auto.cs @@ -118,6 +118,10 @@ builder.Entity(eb => { eb.HasNoKey(); }); builder.Entity(eb => { eb.HasNoKey(); }); + + +builder.Entity().HasKey(c => new {c.ArticleName,c.FamilyName}); + //***END*** } } diff --git a/CloudBuilder/CloudBuilder.Master/Policy/MasterPermissionPolicy.auto.cs b/CloudBuilder/CloudBuilder.Master/Policy/MasterPermissionPolicy.auto.cs index 01cfdaf..63df82e 100644 --- a/CloudBuilder/CloudBuilder.Master/Policy/MasterPermissionPolicy.auto.cs +++ b/CloudBuilder/CloudBuilder.Master/Policy/MasterPermissionPolicy.auto.cs @@ -17,6 +17,8 @@ public const string PublishVideo = "PublishVideo"; public const string PublishPhoto = "PublishPhoto"; public const string PublishAudio = "PublishAudio"; + +public const string Article = "Article"; //***END*** } } \ No newline at end of file diff --git a/CloudBuilder/CloudBuilder.Master/Policy/MasterPrefixPolicy.cs b/CloudBuilder/CloudBuilder.Master/Policy/MasterPrefixPolicy.cs index dca8d6c..f1545af 100644 --- a/CloudBuilder/CloudBuilder.Master/Policy/MasterPrefixPolicy.cs +++ b/CloudBuilder/CloudBuilder.Master/Policy/MasterPrefixPolicy.cs @@ -37,6 +37,12 @@ namespace CloudBuilder.Master.Policy public const string AUDIO_PUBLISH = "AUDIO_PUBLISH"; public const string AUDIO_PUBLISH_PUBLISH_NAME = "PUBLISH_NAME"; + // 有家文化传承文章:主键 article_name 走号段生成器(前缀 PP、格式 yymmdd00000,与相册同号段格式但独立计数) + public const string ARTICLE = "ARTICLE"; + public const string ARTICLE_ARTICLE_NAME = "ARTICLE_NAME"; + public const string ARTICLE_PREFIX = "PP"; + public const string ARTICLE_FORMAT = "yymmdd00000"; + public const string DMS_FOLDER_DELETE_LOG = "DMS_FOLDER_DELETE_LOG"; public const string DMS_FOLDER_DELETE_LOG_FOLDER_NAME = "FOLDER_NAME"; diff --git a/CloudBuilder/CloudBuilder.Master/Policy/UploadFolderPolicy.cs b/CloudBuilder/CloudBuilder.Master/Policy/UploadFolderPolicy.cs index 3173d85..6ec697b 100644 --- a/CloudBuilder/CloudBuilder.Master/Policy/UploadFolderPolicy.cs +++ b/CloudBuilder/CloudBuilder.Master/Policy/UploadFolderPolicy.cs @@ -18,5 +18,11 @@ namespace CloudBuilder.Master.Policy /// 身份证照片目录(与前端身份证上传 FilePath 头一致,小写历史沿用) public const string ID_CARD = "idcard"; + + /// 文章封面目录(article.article_cover_filename 对应,前端上传 FilePath 头须一致) + public const string ARTICLE_COVER = "ArticleCover"; + + /// 文章正文/配图目录(article_filename.file_name 对应,前端上传 FilePath 头须一致) + public const string ARTICLE_IMAGE = "ArticleImage"; } } diff --git a/CloudBuilder/CloudBuilder.Master/SearchCriteria/ArticleSearchCriteria.auto.cs b/CloudBuilder/CloudBuilder.Master/SearchCriteria/ArticleSearchCriteria.auto.cs new file mode 100644 index 0000000..4d53be8 --- /dev/null +++ b/CloudBuilder/CloudBuilder.Master/SearchCriteria/ArticleSearchCriteria.auto.cs @@ -0,0 +1,11 @@ + +using CloudBuilder.Core.Authorization; +using CloudBuilder.Core.Service; +namespace CloudBuilder.Master.SearchCriteria +{ + [TypeScript] + public class ArticleSearchCriteria: AbstractSearchCriteria + { + + } +} diff --git a/CloudBuilder/CloudBuilder.Master/Service/IArticleService.auto.cs b/CloudBuilder/CloudBuilder.Master/Service/IArticleService.auto.cs new file mode 100644 index 0000000..6c4fe68 --- /dev/null +++ b/CloudBuilder/CloudBuilder.Master/Service/IArticleService.auto.cs @@ -0,0 +1,14 @@ + + +using CloudBuilder.Core.Service; +using CloudBuilder.Master.Data; +using CloudBuilder.Master.Entity; +using CloudBuilder.Master.SearchCriteria; + +namespace CloudBuilder.Master.Service +{ + public interface IArticleService :IServiceBase + { + ArticleResultData FindBySearchCriteria(ArticleSearchCriteria tc); + } +} diff --git a/CloudBuilder/CloudBuilder.Request/Master/ArticleRequest.auto.cs b/CloudBuilder/CloudBuilder.Request/Master/ArticleRequest.auto.cs new file mode 100644 index 0000000..10e2670 --- /dev/null +++ b/CloudBuilder/CloudBuilder.Request/Master/ArticleRequest.auto.cs @@ -0,0 +1,46 @@ + +using CloudBuilder.Core.DependencyInjection.Request; +using CloudBuilder.Core.Request; +using CloudBuilder.Core.Service; +using Microsoft.Extensions.DependencyInjection; +using CloudBuilder.Master.Service; + using CloudBuilder.Core.DatabaseAccessor.Entity; + using CloudBuilder.Master.Data; + using System; + using CloudBuilder.Master.SearchCriteria; + +namespace CloudBuilder.Request.Master +{ + +public class ArticleRequest : IRequest +{ + +private readonly IServiceRequest serviceRequest; +public ArticleRequest(IServiceRequest serviceRequest) +{ +this.serviceRequest = serviceRequest; +} + + public ArticleData FindByKey(string key) + { +return Task.Run(() => serviceRequest.PostAsJsonAsync("/Article/FindByKey?key=" + key.ToString())).GetAwaiter().GetResult(); } + + public ArticleResultData FindBySearchCriteria(ArticleSearchCriteria criteria) + { +return Task.Run(() => serviceRequest.PostAsJsonAsync("/Article/FindBySearchCriteria" , criteria)).GetAwaiter().GetResult(); } + + public ArticleData Insert(ArticleData data) + { +return Task.Run(() => serviceRequest.PostAsJsonAsync("/Article/Insert" , data)).GetAwaiter().GetResult(); } + + public ArticleData Update(ArticleData data) + { +return Task.Run(() => serviceRequest.PostAsJsonAsync("/Article/Update" , data)).GetAwaiter().GetResult(); } + + public void Delete(string key) + { + Task.Run(() => serviceRequest.PostAsync("/Article/Delete?key=" + key.ToString())).GetAwaiter().GetResult(); } + + +} +} \ No newline at end of file diff --git a/CloudBuilder/CloudBuilder.Request/Master/DmsFileRequest.auto.cs b/CloudBuilder/CloudBuilder.Request/Master/DmsFileRequest.auto.cs index abecc59..9aff09a 100644 --- a/CloudBuilder/CloudBuilder.Request/Master/DmsFileRequest.auto.cs +++ b/CloudBuilder/CloudBuilder.Request/Master/DmsFileRequest.auto.cs @@ -5,11 +5,11 @@ using CloudBuilder.Core.Service; using Microsoft.Extensions.DependencyInjection; using CloudBuilder.Master.Service; using CloudBuilder.Core.DatabaseAccessor.Entity; + using CloudBuilder.Master.Data; + using System; + using CloudBuilder.Master.SearchCriteria; using Microsoft.AspNetCore.Http; using CloudBuilder.Master.Entity; - using System; - using CloudBuilder.Master.Data; - using CloudBuilder.Master.SearchCriteria; namespace CloudBuilder.Request.Master { diff --git a/CloudBuilder/CloudBuilder.Request/Master/FamilyGroupRequest.auto.cs b/CloudBuilder/CloudBuilder.Request/Master/FamilyGroupRequest.auto.cs index 0ad25b7..580c8e2 100644 --- a/CloudBuilder/CloudBuilder.Request/Master/FamilyGroupRequest.auto.cs +++ b/CloudBuilder/CloudBuilder.Request/Master/FamilyGroupRequest.auto.cs @@ -5,11 +5,11 @@ using CloudBuilder.Core.Service; using Microsoft.Extensions.DependencyInjection; using CloudBuilder.Master.Service; using CloudBuilder.Core.DatabaseAccessor.Entity; + using CloudBuilder.Master.Data; + using System; + using CloudBuilder.Master.SearchCriteria; using Microsoft.AspNetCore.Http; using CloudBuilder.Master.Entity; - using System; - using CloudBuilder.Master.Data; - using CloudBuilder.Master.SearchCriteria; namespace CloudBuilder.Request.Master { diff --git a/CloudBuilder/CloudBuilder.Request/Master/FamilyPersonRequest.auto.cs b/CloudBuilder/CloudBuilder.Request/Master/FamilyPersonRequest.auto.cs index cf033b0..b4e3ea2 100644 --- a/CloudBuilder/CloudBuilder.Request/Master/FamilyPersonRequest.auto.cs +++ b/CloudBuilder/CloudBuilder.Request/Master/FamilyPersonRequest.auto.cs @@ -5,11 +5,11 @@ using CloudBuilder.Core.Service; using Microsoft.Extensions.DependencyInjection; using CloudBuilder.Master.Service; using CloudBuilder.Core.DatabaseAccessor.Entity; + using CloudBuilder.Master.Data; + using System; + using CloudBuilder.Master.SearchCriteria; using Microsoft.AspNetCore.Http; using CloudBuilder.Master.Entity; - using System; - using CloudBuilder.Master.Data; - using CloudBuilder.Master.SearchCriteria; namespace CloudBuilder.Request.Master { diff --git a/CloudBuilder/CloudBuilder.Request/Master/FamilyTreeRequest.auto.cs b/CloudBuilder/CloudBuilder.Request/Master/FamilyTreeRequest.auto.cs index fcc4e2d..b45a91a 100644 --- a/CloudBuilder/CloudBuilder.Request/Master/FamilyTreeRequest.auto.cs +++ b/CloudBuilder/CloudBuilder.Request/Master/FamilyTreeRequest.auto.cs @@ -5,11 +5,11 @@ using CloudBuilder.Core.Service; using Microsoft.Extensions.DependencyInjection; using CloudBuilder.Master.Service; using CloudBuilder.Core.DatabaseAccessor.Entity; + using CloudBuilder.Master.Data; + using System; + using CloudBuilder.Master.SearchCriteria; using Microsoft.AspNetCore.Http; using CloudBuilder.Master.Entity; - using System; - using CloudBuilder.Master.Data; - using CloudBuilder.Master.SearchCriteria; namespace CloudBuilder.Request.Master { diff --git a/CloudBuilder/CloudBuilder.Request/Master/FileTypePolicyRequest.auto.cs b/CloudBuilder/CloudBuilder.Request/Master/FileTypePolicyRequest.auto.cs index c540786..ec31711 100644 --- a/CloudBuilder/CloudBuilder.Request/Master/FileTypePolicyRequest.auto.cs +++ b/CloudBuilder/CloudBuilder.Request/Master/FileTypePolicyRequest.auto.cs @@ -5,11 +5,11 @@ using CloudBuilder.Core.Service; using Microsoft.Extensions.DependencyInjection; using CloudBuilder.Master.Service; using CloudBuilder.Core.DatabaseAccessor.Entity; + using CloudBuilder.Master.Data; + using System; + using CloudBuilder.Master.SearchCriteria; using Microsoft.AspNetCore.Http; using CloudBuilder.Master.Entity; - using System; - using CloudBuilder.Master.Data; - using CloudBuilder.Master.SearchCriteria; namespace CloudBuilder.Request.Master { diff --git a/CloudBuilder/CloudBuilder.Request/Master/PublishAudioRequest.auto.cs b/CloudBuilder/CloudBuilder.Request/Master/PublishAudioRequest.auto.cs index 9423d19..3b66018 100644 --- a/CloudBuilder/CloudBuilder.Request/Master/PublishAudioRequest.auto.cs +++ b/CloudBuilder/CloudBuilder.Request/Master/PublishAudioRequest.auto.cs @@ -5,11 +5,11 @@ using CloudBuilder.Core.Service; using Microsoft.Extensions.DependencyInjection; using CloudBuilder.Master.Service; using CloudBuilder.Core.DatabaseAccessor.Entity; + using CloudBuilder.Master.Data; + using System; + using CloudBuilder.Master.SearchCriteria; using Microsoft.AspNetCore.Http; using CloudBuilder.Master.Entity; - using System; - using CloudBuilder.Master.Data; - using CloudBuilder.Master.SearchCriteria; namespace CloudBuilder.Request.Master { diff --git a/CloudBuilder/CloudBuilder.Request/Master/PublishPhotoRequest.auto.cs b/CloudBuilder/CloudBuilder.Request/Master/PublishPhotoRequest.auto.cs index 13c3f46..7672e58 100644 --- a/CloudBuilder/CloudBuilder.Request/Master/PublishPhotoRequest.auto.cs +++ b/CloudBuilder/CloudBuilder.Request/Master/PublishPhotoRequest.auto.cs @@ -5,11 +5,11 @@ using CloudBuilder.Core.Service; using Microsoft.Extensions.DependencyInjection; using CloudBuilder.Master.Service; using CloudBuilder.Core.DatabaseAccessor.Entity; + using CloudBuilder.Master.Data; + using System; + using CloudBuilder.Master.SearchCriteria; using Microsoft.AspNetCore.Http; using CloudBuilder.Master.Entity; - using System; - using CloudBuilder.Master.Data; - using CloudBuilder.Master.SearchCriteria; namespace CloudBuilder.Request.Master { diff --git a/CloudBuilder/CloudBuilder.Request/Master/PublishVideoRequest.auto.cs b/CloudBuilder/CloudBuilder.Request/Master/PublishVideoRequest.auto.cs index c3a0c7e..ccbee3b 100644 --- a/CloudBuilder/CloudBuilder.Request/Master/PublishVideoRequest.auto.cs +++ b/CloudBuilder/CloudBuilder.Request/Master/PublishVideoRequest.auto.cs @@ -5,11 +5,11 @@ using CloudBuilder.Core.Service; using Microsoft.Extensions.DependencyInjection; using CloudBuilder.Master.Service; using CloudBuilder.Core.DatabaseAccessor.Entity; + using CloudBuilder.Master.Data; + using System; + using CloudBuilder.Master.SearchCriteria; using Microsoft.AspNetCore.Http; using CloudBuilder.Master.Entity; - using System; - using CloudBuilder.Master.Data; - using CloudBuilder.Master.SearchCriteria; namespace CloudBuilder.Request.Master { diff --git a/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/ArticleController.auto.cs b/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/ArticleController.auto.cs new file mode 100644 index 0000000..8ff88b9 --- /dev/null +++ b/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/ArticleController.auto.cs @@ -0,0 +1,134 @@ + +using CloudBuilder.Core.DatabaseAccessor; +using CloudBuilder.Core.Policy; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.Transactions; +using CloudBuilder.Core.Service; +using CloudBuilder.Master.Service; + using CloudBuilder.Core.DatabaseAccessor.Entity; + using CloudBuilder.Master.Data; + using System; + using CloudBuilder.Master.SearchCriteria; + +namespace CloudBuilder.Web.Entry.Controllers.Master +{ +[Authorize(AuthorizePolicy.PERMISSION)] +[ApiController] +[Route("[controller]/[action]")] +public class ArticleController : ControllerBase +{ +private readonly IApplicationService service; +private readonly IArticleService articleService; + +public ArticleController(IApplicationService service, IArticleService articleService) +{ +this.service = service; +this.articleService= articleService; +} + + [PermissionOperate(name: "Article", operate: "Find")] + [HttpPost] + public dynamic FindByKey(string key) + { + try + { + + var result = articleService.FindByKey(key); + return result; + } + catch (ValidatedException vex) + { + return vex; + } + catch (Exception ex) + { + return new ServiceErrorException(ex); + } + } + + [PermissionOperate(name: "Article", operate: "Find")] + [HttpPost] + public dynamic FindBySearchCriteria(ArticleSearchCriteria criteria) + { + try + { + + var result = articleService.FindBySearchCriteria(criteria); + return result; + } + catch (ValidatedException vex) + { + return vex; + } + catch (Exception ex) + { + return new ServiceErrorException(ex); + } + } + + [PermissionOperate(name: "Article", operate: "Insert")] + [HttpPost] + [UnitOfWork] + public dynamic Insert(ArticleData data) + { + try + { + + var result = articleService.Insert(data); + return result; + } + catch (ValidatedException vex) + { + return vex; + } + catch (Exception ex) + { + return new ServiceErrorException(ex); + } + } + + [PermissionOperate(name: "Article", operate: "Update")] + [HttpPost] + [UnitOfWork] + public dynamic Update(ArticleData data) + { + try + { + + var result = articleService.Update(data); + return result; + } + catch (ValidatedException vex) + { + return vex; + } + catch (Exception ex) + { + return new ServiceErrorException(ex); + } + } + + [PermissionOperate(name: "Article", operate: "Delete")] + [HttpPost] + [UnitOfWork] + public dynamic Delete(string key){ + try + { + articleService.Delete(key); + + return string.Empty; + } + catch (ValidatedException vex) + { + return vex; + } + catch (Exception ex) + { + return new ServiceErrorException(ex); + } + } + + +} +} \ No newline at end of file diff --git a/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/DmsFileController.auto.cs b/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/DmsFileController.auto.cs index 4bf90cc..54641ad 100644 --- a/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/DmsFileController.auto.cs +++ b/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/DmsFileController.auto.cs @@ -7,11 +7,11 @@ using System.Transactions; using CloudBuilder.Core.Service; using CloudBuilder.Master.Service; using CloudBuilder.Core.DatabaseAccessor.Entity; + using CloudBuilder.Master.Data; + using System; + using CloudBuilder.Master.SearchCriteria; using Microsoft.AspNetCore.Http; using CloudBuilder.Master.Entity; - using System; - using CloudBuilder.Master.Data; - using CloudBuilder.Master.SearchCriteria; namespace CloudBuilder.Web.Entry.Controllers.Master { diff --git a/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/FamilyGroupController.auto.cs b/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/FamilyGroupController.auto.cs index 35bc44b..89bf77d 100644 --- a/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/FamilyGroupController.auto.cs +++ b/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/FamilyGroupController.auto.cs @@ -7,11 +7,11 @@ using System.Transactions; using CloudBuilder.Core.Service; using CloudBuilder.Master.Service; using CloudBuilder.Core.DatabaseAccessor.Entity; + using CloudBuilder.Master.Data; + using System; + using CloudBuilder.Master.SearchCriteria; using Microsoft.AspNetCore.Http; using CloudBuilder.Master.Entity; - using System; - using CloudBuilder.Master.Data; - using CloudBuilder.Master.SearchCriteria; namespace CloudBuilder.Web.Entry.Controllers.Master { diff --git a/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/FamilyPersonController.auto.cs b/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/FamilyPersonController.auto.cs index aaf8d7d..fcdcb3d 100644 --- a/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/FamilyPersonController.auto.cs +++ b/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/FamilyPersonController.auto.cs @@ -7,11 +7,11 @@ using System.Transactions; using CloudBuilder.Core.Service; using CloudBuilder.Master.Service; using CloudBuilder.Core.DatabaseAccessor.Entity; + using CloudBuilder.Master.Data; + using System; + using CloudBuilder.Master.SearchCriteria; using Microsoft.AspNetCore.Http; using CloudBuilder.Master.Entity; - using System; - using CloudBuilder.Master.Data; - using CloudBuilder.Master.SearchCriteria; namespace CloudBuilder.Web.Entry.Controllers.Master { diff --git a/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/FamilyTreeController.auto.cs b/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/FamilyTreeController.auto.cs index 57bb614..6407eb6 100644 --- a/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/FamilyTreeController.auto.cs +++ b/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/FamilyTreeController.auto.cs @@ -7,11 +7,11 @@ using System.Transactions; using CloudBuilder.Core.Service; using CloudBuilder.Master.Service; using CloudBuilder.Core.DatabaseAccessor.Entity; + using CloudBuilder.Master.Data; + using System; + using CloudBuilder.Master.SearchCriteria; using Microsoft.AspNetCore.Http; using CloudBuilder.Master.Entity; - using System; - using CloudBuilder.Master.Data; - using CloudBuilder.Master.SearchCriteria; namespace CloudBuilder.Web.Entry.Controllers.Master { diff --git a/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/FileTypePolicyController.auto.cs b/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/FileTypePolicyController.auto.cs index 678055e..6713947 100644 --- a/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/FileTypePolicyController.auto.cs +++ b/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/FileTypePolicyController.auto.cs @@ -7,11 +7,11 @@ using System.Transactions; using CloudBuilder.Core.Service; using CloudBuilder.Master.Service; using CloudBuilder.Core.DatabaseAccessor.Entity; + using CloudBuilder.Master.Data; + using System; + using CloudBuilder.Master.SearchCriteria; using Microsoft.AspNetCore.Http; using CloudBuilder.Master.Entity; - using System; - using CloudBuilder.Master.Data; - using CloudBuilder.Master.SearchCriteria; namespace CloudBuilder.Web.Entry.Controllers.Master { diff --git a/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/PublishAudioController.auto.cs b/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/PublishAudioController.auto.cs index ef709fe..94a1240 100644 --- a/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/PublishAudioController.auto.cs +++ b/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/PublishAudioController.auto.cs @@ -7,11 +7,11 @@ using System.Transactions; using CloudBuilder.Core.Service; using CloudBuilder.Master.Service; using CloudBuilder.Core.DatabaseAccessor.Entity; + using CloudBuilder.Master.Data; + using System; + using CloudBuilder.Master.SearchCriteria; using Microsoft.AspNetCore.Http; using CloudBuilder.Master.Entity; - using System; - using CloudBuilder.Master.Data; - using CloudBuilder.Master.SearchCriteria; namespace CloudBuilder.Web.Entry.Controllers.Master { diff --git a/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/PublishPhotoController.auto.cs b/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/PublishPhotoController.auto.cs index f3f5220..982d8f0 100644 --- a/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/PublishPhotoController.auto.cs +++ b/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/PublishPhotoController.auto.cs @@ -7,11 +7,11 @@ using System.Transactions; using CloudBuilder.Core.Service; using CloudBuilder.Master.Service; using CloudBuilder.Core.DatabaseAccessor.Entity; + using CloudBuilder.Master.Data; + using System; + using CloudBuilder.Master.SearchCriteria; using Microsoft.AspNetCore.Http; using CloudBuilder.Master.Entity; - using System; - using CloudBuilder.Master.Data; - using CloudBuilder.Master.SearchCriteria; namespace CloudBuilder.Web.Entry.Controllers.Master { diff --git a/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/PublishVideoController.auto.cs b/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/PublishVideoController.auto.cs index 5ea65f6..39f09c0 100644 --- a/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/PublishVideoController.auto.cs +++ b/CloudBuilder/CloudBuilder.Web.Entry/Controllers/Master/PublishVideoController.auto.cs @@ -7,11 +7,11 @@ using System.Transactions; using CloudBuilder.Core.Service; using CloudBuilder.Master.Service; using CloudBuilder.Core.DatabaseAccessor.Entity; + using CloudBuilder.Master.Data; + using System; + using CloudBuilder.Master.SearchCriteria; using Microsoft.AspNetCore.Http; using CloudBuilder.Master.Entity; - using System; - using CloudBuilder.Master.Data; - using CloudBuilder.Master.SearchCriteria; namespace CloudBuilder.Web.Entry.Controllers.Master { diff --git a/CloudBuilder/Data/insert.js b/CloudBuilder/Data/insert.js new file mode 100644 index 0000000..5ee2b55 --- /dev/null +++ b/CloudBuilder/Data/insert.js @@ -0,0 +1,87 @@ +const mysql = require("mysql2/promise"); + +const NOW = () => new Date(); +const AUD = ["created_datetime", "created_by", "updated_datetime", "updated_by"]; + +(async () => { + const conn = await mysql.createConnection({ + host: "nas.youjiala.com", port: 3307, user: "root", + password: "Root@1237890", database: "Furion", charset: "utf8mb4", + multipleStatements: false, + }); + + const run = async (sql, params, label) => { + const [res] = await conn.execute(sql, params); + console.log(` [${label}] changedRows=${res.changedRows ?? res.affectedRows}`); + return res; + }; + + try { + await conn.query("START TRANSACTION"); + + // 1) 权限 security_permission + await run( + `INSERT INTO security_permission (${AUD.join(",")},permission_name,dependent,description,delegate_indc) + VALUES (?,?,?,?,'Article','','有家文化传承','') + ON DUPLICATE KEY UPDATE updated_datetime=VALUES(updated_datetime),description=VALUES(description)`, + [NOW(), "001", NOW(), "001"], "permission"); + + // 2) 功能 security_function(容器 + 查询 + 管理,镜像 photo_dic 家族) + const funcs = [ + ["article_dic", "有家文化", "article_dic", "N", "", "", "picture", ""], + ["ArticleEnquiry", "文化传承查询", "ArticleEnquiry", "Y", "/src/views/master/ArticleEnquiry.vue", "/master/ArticleEnquiry", "wenjiansousuo", ""], + ["ArticleOperation", "文化传承管理", "ArticleOperation", "Y", "/src/views/master/ArticleOperation.vue", "/master/ArticleOperation", "picture", ""], + ]; + for (const f of funcs) { + await run( + `INSERT INTO security_function (${AUD.join(",")},function_name,function_local_name,function_eng_name,function_indc,page_url,api_route,icon,page_name) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE updated_datetime=VALUES(updated_datetime),function_local_name=VALUES(function_local_name),page_url=VALUES(page_url),api_route=VALUES(api_route),icon=VALUES(icon)`, + [NOW(), "001", NOW(), "001", f[0], f[1], f[2], f[3], f[4], f[5], f[6], f[7]], "function:" + f[0]); + } + + // 3) 功能树 security_function_map(挂到 BOOTUP 下,并把两个子功能挂进 article_dic) + const fmap = [["BOOTUP", "article_dic", 7], ["article_dic", "ArticleEnquiry", 1], ["article_dic", "ArticleOperation", 2]]; + for (const m of fmap) { + await run( + `INSERT INTO security_function_map (${AUD.join(",")},function_name_parent,function_name_son,orderby) + VALUES (?,?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE updated_datetime=VALUES(updated_datetime),orderby=VALUES(orderby)`, + [NOW(), "001", NOW(), "001", m[0], m[1], m[2]], "fmap:" + m[0] + ">" + m[1]); + } + + // 4) 权限-功能映射 security_permission_function_map + const pfmap = [["Article", "ArticleEnquiry"], ["Article", "ArticleOperation"]]; + for (const m of pfmap) { + await run( + `INSERT INTO security_permission_function_map (${AUD.join(",")},permission_name,function_name) + VALUES (?,?,?,?,?,?) + ON DUPLICATE KEY UPDATE updated_datetime=VALUES(updated_datetime)`, + [NOW(), "001", NOW(), "001", m[0], m[1]], "pfmap:" + m[1]); + } + + // 5) 组权限分配 security_group_permission(授 vip_group 四动作全 Y,镜像 PublishPhoto) + await run( + `INSERT INTO security_group_permission (${AUD.join(",")},group_name,permission_name,view_indc,create_indc,amend_indc,delete_indc,dependent) + VALUES (?,?,?,?,'vip_group','Article','Y','Y','Y','Y','') + ON DUPLICATE KEY UPDATE updated_datetime=VALUES(updated_datetime),view_indc='Y',create_indc='Y',amend_indc='Y',delete_indc='Y'`, + [NOW(), "001", NOW(), "001"], "group_perm:vip_group"); + + await conn.query("COMMIT"); + console.log("\nCOMMIT OK"); + + // 回读校验 + const check = async (t, s) => { const [r] = await conn.query(s); console.log("\n== " + t + " =="); console.log(JSON.stringify(r)); }; + await check("permission", "SELECT permission_name,description FROM security_permission WHERE permission_name='Article'"); + await check("function", "SELECT function_name,function_local_name,function_indc,icon FROM security_function WHERE function_name LIKE 'article%' OR function_name LIKE 'Article%'"); + await check("function_map", "SELECT function_name_parent,function_name_son,orderby FROM security_function_map WHERE function_name_son LIKE 'article%' OR function_name_son LIKE 'Article%'"); + await check("perm_function_map", "SELECT permission_name,function_name FROM security_permission_function_map WHERE permission_name='Article'"); + await check("group_permission", "SELECT group_name,permission_name,view_indc,create_indc,amend_indc,delete_indc FROM security_group_permission WHERE permission_name='Article'"); + } catch (e) { + await conn.query("ROLLBACK"); + console.log("ERROR -> ROLLBACK: " + e.message); + process.exitCode = 1; + } finally { + await conn.end(); + } +})(); diff --git a/uniapp/youjiala/src/components/article-editor/article-editor.vue b/uniapp/youjiala/src/components/article-editor/article-editor.vue new file mode 100644 index 0000000..02e1044 --- /dev/null +++ b/uniapp/youjiala/src/components/article-editor/article-editor.vue @@ -0,0 +1,621 @@ + + + + + diff --git a/uniapp/youjiala/src/components/family-tree-home/family-tree-home.vue b/uniapp/youjiala/src/components/family-tree-home/family-tree-home.vue index b3180c5..c7379dd 100644 --- a/uniapp/youjiala/src/components/family-tree-home/family-tree-home.vue +++ b/uniapp/youjiala/src/components/family-tree-home/family-tree-home.vue @@ -20,6 +20,7 @@ import { brandGlyph } from "@/common/brand"; import { buildChunkFileName, uploadFile } from "@/common/http.interceptor"; import { userTypeGlyph, userTypePillBg } from "@/common/userTypeIcons"; import { useIdCardVerify, useThemeGradient, useThemeTokens } from "@/composables"; +import { useArticles } from "@/composables/useArticles"; import { formatFileSize, formatFileTime, useFamilyTreeExchange } from "@/composables/useFamilyTreeExchange"; import { usePersonPickConfig } from "@/composables/usePickerConfigs"; import { usePolicyOptions } from "@/composables/usePolicyOptions"; @@ -37,8 +38,9 @@ import familyPersonService from "@/service/master/familyPersonService"; import familyTreeService from "@/service/master/familyTreeService"; import dmsFileService from "@/service/master/dmsFileService"; import { useLoginProfileStore } from "@/stores"; +import { pageUrl } from "@/router/routes"; import { chooseFiles } from "@/utils/chooseFiles"; -import { absoluteFileUrl } from "@/utils/fileUrl"; +import { absoluteFileUrl, backupFileUrl } from "@/utils/fileUrl"; /** ready:组件挂载完成通知(时序口径见文件底部 onMounted 注释) */ const emit = defineEmits<{ (e: "ready"): void }>(); @@ -239,6 +241,60 @@ async function onRetry(): Promise { await loadTrees(); } +// ===== 有家文化传承(文章)卡片:与族谱列表并列,数据源见 composables/useArticles ===== +const articlesApi = useArticles(); +const articleKeyword = ref(""); +const showArticleSearch = ref(false); +const articleCollapsed = ref(false); +/** 首页「+」新建弹层可见(编辑层不另开页,直接底部弹出,同相册/群编辑) */ +const articleEditorVisible = ref(false); +/** 首页卡只展示最新 5 条(列表已按置顶、创建时间排序) */ +const homeArticles = computed(() => articlesApi.list.value.slice(0, 5)); + +/** 无封面时的书卷字形:内联 SVG(同 treeGlyph 套路),白描卷轴 */ +const articleGlyph = `data:image/svg+xml,${encodeURIComponent([ + "", + "", + "", + "", + "", +].join(""))}`; + +async function loadArticles(): Promise { + await articlesApi.loadList(); +} + +function onToggleArticleSearch(): void { + showArticleSearch.value = !showArticleSearch.value; +} + +async function onArticleSearch(): Promise { + await articlesApi.loadList(articleKeyword.value); +} + +function onCreateArticle(): void { + // 首页「+」直接弹底部编辑层(不另开页):新建空白整包,保存走 useArticles.save + articlesApi.startCreate(); + articleEditorVisible.value = true; +} + +function closeArticleEditor(): void { + articleEditorVisible.value = false; + void articlesApi.cancelEdit(); +} + +async function onArticleSaved(): Promise { + articleEditorVisible.value = false; + await loadArticles(); +} + +function onOpenArticle(name: string): void { + if (!name) { + return; + } + uni.navigateTo({ url: `${pageUrl("articlePage")}?name=${encodeURIComponent(name)}` }); +} + // ===== 详情态数据(原 family-tree-operation 全量照搬) ===== const data = ref(new FamilyTreeData()); const tree = computed(() => data.value.FamilyTree); @@ -1520,6 +1576,7 @@ defineExpose({ reloadIfList(): void { if (!inDetail.value) { loadTrees(); + void loadArticles(); void fetchStorage(); } }, @@ -1528,7 +1585,8 @@ defineExpose({ @@ -3264,6 +3389,127 @@ defineExpose({ color: #bfc3c9; } +/* ===== 有家文化传承文章卡(复用 tree-panel 面板几何,卡体换横向图文) ===== */ +.article-card { + display: flex; + align-items: center; + gap: 20rpx; + margin: 0 0 20rpx; + padding: 24rpx; + border-radius: 24rpx; + background-color: #ffffff; + box-shadow: 0 6rpx 18rpx rgba(31, 35, 41, 0.05); +} + +.article-card:last-child { + margin-bottom: 0; +} + +.article-card__cover { + display: flex; + flex: none; + align-items: center; + justify-content: center; + width: 128rpx; + height: 128rpx; + overflow: hidden; + border-radius: 20rpx; + background: var(--fg-gradient, linear-gradient(135deg, #4a9dff, #2b8fff)); +} + +.article-card__cover-img { + width: 100%; + height: 100%; +} + +.article-card__glyph { + width: 60rpx; + height: 60rpx; +} + +.article-card__main { + display: flex; + flex: 1; + min-width: 0; + flex-direction: column; + gap: 8rpx; +} + +.article-card__titlerow { + display: flex; + align-items: center; + gap: 10rpx; +} + +.article-card__name { + flex: 1; + min-width: 0; + font-size: 32rpx; + font-weight: 600; + color: #1f2329; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.article-card__chip { + flex: none; + padding: 2rpx 12rpx; + border-radius: 8rpx; + font-size: 20rpx; + color: #5c626b; + background-color: #f2f3f5; +} + +.article-card__chip--top { + color: var(--fg-primary, #2b8fff); + background-color: var(--fg-soft, #eaf4ff); +} + +.article-card__chip--pending { + color: #d48806; + background-color: #fff7e6; +} + +.article-card__desc { + font-size: 24rpx; + line-height: 34rpx; + color: #8f959e; + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.article-card__meta { + display: flex; + align-items: center; + gap: 10rpx; +} + +.article-card__avatar { + flex: none; + width: 36rpx; + height: 36rpx; + border-radius: 50%; + background-color: #f2f3f5; +} + +.article-card__avatar--empty { + background: var(--fg-soft, #eaf4ff); +} + +.article-card__author { + font-size: 22rpx; + color: #5c626b; +} + +.article-card__date { + margin-left: auto; + font-size: 20rpx; + color: #bfc3c9; +} + /* ===== 详情头卡 + 圆形动作钮(与共享页同套) ===== */ .detail-hero { position: relative; diff --git a/uniapp/youjiala/src/composables/index.ts b/uniapp/youjiala/src/composables/index.ts index 257bab9..8c3d7c7 100644 --- a/uniapp/youjiala/src/composables/index.ts +++ b/uniapp/youjiala/src/composables/index.ts @@ -1,6 +1,7 @@ export * from "./useAdminList"; export * from "./useAdminMenu"; export * from "./useAdminPick"; +export * from "./useArticles"; export * from "./useAuthGuard"; export * from "./useDmsFiles"; export * from "./useFamilyGroups"; diff --git a/uniapp/youjiala/src/composables/useArticles.ts b/uniapp/youjiala/src/composables/useArticles.ts new file mode 100644 index 0000000..c1a0fa2 --- /dev/null +++ b/uniapp/youjiala/src/composables/useArticles.ts @@ -0,0 +1,371 @@ +/** + * 有家文化传承「文章」数据源:列表 / 详情 / 新建 / 编辑 / 保存 / 删除 / 审核 / 置顶 / 群组关联 / 配图。 + * + * 契约(对齐 usePublishAlbums 与后端 ArticleService): + * 1. 列表 FindBySearchCriteria -> data.Datas(ArticleDisplayData[]),后端已按「群组成员 + 审核」 + * 过滤并按置顶、创建时间排序,前端不再筛; + * 2. 详情 FindByKey(articleName) -> ArticleData { Article, FamilyGroups[], Filenames[] }, + * 后端在 Article 上直接下发 IsOwner/IsManager/IsMember 与 AuthorLocalName/AuthorAvatarUrl/CoverUrl; + * 3. 编辑一律本地改整包 data,最后 Insert/Update 落库(无独立子表接口); + * 4. 保存判定:Article.CreatedBy 有值走 Update,否则 Insert(新编号由后端号段生成); + * 5. 封面/配图存物理名,展示地址走 absoluteFileUrl(后端已打包 CoverUrl/ImageUrl,绝对地址幂等)。 + */ +import { useLocale } from "uview-pro"; +import { computed, ref } from "vue"; +import { ArticleData } from "@/data/auto/articleData"; +import { ArticleDisplayData } from "@/data/auto/articleDisplayData"; +import { ArticleFamilyGroupDisplayData } from "@/data/auto/articleFamilyGroupDisplayData"; +import { ArticleFilenameDisplayData } from "@/data/auto/articleFilenameDisplayData"; +import { ArticleSearchCriteria } from "@/data/auto/articleSearchCriteria"; +import { SearchFilter } from "@/data/auto/searchFilter"; +import articleService from "@/service/master/articleService"; +import { + absoluteFileUrl, + backupFileUrl, + mapBriefImageSrc, +} from "@/utils/fileUrl"; + +/** 列表一次拉全(移动端分页在文章场景暂不需要) */ +const FIND_LIMIT = 999999; + +/** 是/否标志(与后端 YesNoPolicy 对齐) */ +export const YES = "Y"; +export const NO = "N"; + +/** 创建日期只取年月日:卡片展示用 */ +export function formatArticleDate(value: Date | string | undefined): string { + if (!value) { + return ""; + } + const time = + typeof value === "string" ? new Date(value).getTime() : value.getTime(); + if (!time) { + return ""; + } + const d = new Date(time); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +/** 富文本 HTML 去标签取纯文本,供列表摘要截断 */ +export function plainTextOf(html: string | undefined, max = 100): string { + const text = String(html ?? "") + .replace(/<[^>]*>/g, "") + .replace(/ /gi, " ") + .replace(/&/gi, "&") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/\s+/g, " ") + .trim(); + return text.length > max ? `${text.slice(0, max)}…` : text; +} + +/** 插图限宽样式:rich-text / editor 里的 默认按原图尺寸渲染,手机屏宽不够就横向撑爆 */ +const FIT_IMG_STYLE = "max-width:100%;height:auto;display:block;"; + +/** 给正文 HTML 里所有 烘上限宽内联样式(跨端统一口径)。 + * scoped 样式穿透不了 rich-text 内容,只能改 HTML 本身;已存 style 的合并并去掉旧尺寸约束。 + * 在灌编辑器前调用:样式随 getContents 回写进 Brief 落库,只读态与重开编辑同一效果。 */ +export function fitBriefImages(html: string | undefined): string { + return String(html ?? "").replace(/]*>/gi, (tag) => { + // 去掉旧的宽高属性与 style 里的 max-width/height,再统一烘上限宽样式 + const cleaned = tag + .replace(/\s(?:width|height)="[^"]*"/gi, "") + .replace(/\sstyle="([^"]*)"/gi, (_m, s: string) => { + const rest = s + .replace(/max-width:[^;"]*;?/gi, "") + .replace(/height:[^;"]*;?/gi, "") + .trim(); + return rest ? ` style="${rest}"` : ""; + }); + return /\sstyle="/i.test(cleaned) + ? cleaned.replace(/\sstyle="/i, ` style="${FIT_IMG_STYLE}`) + : cleaned.replace(/^([]); + const loading = ref(false); + const errorText = ref(""); + + // ===== 详情 / 编辑态 ===== + const detail = ref(null); + const creating = ref(false); + const isEdit = ref(false); + + const article = computed( + () => detail.value?.Article ?? null, + ); + const groups = computed( + () => detail.value?.FamilyGroups ?? [], + ); + const filenames = computed( + () => detail.value?.Filenames ?? [], + ); + + const inDetail = computed( + () => !!article.value?.ArticleName || creating.value, + ); + /** 归属:后端 FindByKey 下发 IsOwner/IsManager/IsMember;新建态恒为作者 */ + const isOwner = computed(() => creating.value || !!article.value?.IsOwner); + const isGroupManager = computed( + () => !creating.value && !!article.value?.IsManager, + ); + const isGroupMember = computed( + () => !creating.value && !!article.value?.IsMember, + ); + /** 可管理(改正文/封面/群组/审核/置顶/删除):作者或群管理员 */ + const canManage = computed( + () => inDetail.value && (isOwner.value || isGroupManager.value), + ); + /** 可删除:作者或群管理员 */ + const canDelete = computed(() => canManage.value); + + // ===== 列表 ===== + async function loadList(keyword = ""): Promise { + loading.value = true; + errorText.value = ""; + try { + const criteria = new ArticleSearchCriteria(); + const filters: SearchFilter[] = []; + if (keyword.trim()) { + const kw = new SearchFilter(); + kw.Column = ArticleDisplayData.ARTICLE_TITLE; + kw.Type = "text"; + kw.Operator = "contain"; + kw.Value = keyword.trim(); + filters.push(kw); + } + criteria.Filter = filters; + criteria.limit = FIND_LIMIT; + criteria.page = 1; + criteria.GridColumns = []; + const data = await articleService.FindBySearchCriteria(criteria); + list.value = (data?.Datas ?? []) as ArticleDisplayData[]; + } catch (error: any) { + list.value = []; + errorText.value = error?.message || t("FilePage.loadFailed"); + } finally { + loading.value = false; + } + } + + // ===== 进详情 ===== + async function openArticle(articleName: string): Promise { + loading.value = true; + errorText.value = ""; + try { + const data = await articleService.FindByKey(articleName); + if (!data?.Article?.ArticleName) { + detail.value = null; + errorText.value = t("MediaPage.noAccess"); + return; + } + data.FamilyGroups = data.FamilyGroups ?? []; + data.Filenames = data.Filenames ?? []; + detail.value = data; + } catch (error: any) { + errorText.value = error?.message || t("FilePage.loadFailed"); + } finally { + loading.value = false; + } + } + + async function exitDetail(): Promise { + detail.value = null; + creating.value = false; + isEdit.value = false; + await loadList(); + } + + // ===== 新建 / 编辑 ===== + function startCreate(): void { + const blank = new ArticleData(); + blank.FamilyGroups = []; + blank.Filenames = []; + blank.Article.ApprovedIndc = NO; + blank.Article.TopIndc = NO; + detail.value = blank; + creating.value = true; + isEdit.value = true; + } + + function startEdit(): void { + isEdit.value = true; + } + + async function cancelEdit(): Promise { + if (creating.value) { + await exitDetail(); + return; + } + // 整包已空(如弹层关闭事件二次触发、exitDetail 后 detail 已 null)直接返回: + // 否则 article.value!.ArticleName 会踩空抛 "Cannot read properties of null (reading 'ArticleName')" + const name = article.value?.ArticleName; + if (!name) { + return; + } + isEdit.value = false; + await openArticle(name); + } + + // ===== 群组关联本地增删(保存整包落库)===== + /** 群组行规整:后端 ArticleFamilyGroupEntity 的 CreatedBy/ArticleName/UpdatedBy 为非空 string, + * 缺键会触发模型校验 400,补空串让新增行既过校验又能被后端按 FamilyName 差量落库 */ + function normalizeGroups( + rows: ArticleFamilyGroupDisplayData[], + ): ArticleFamilyGroupDisplayData[] { + return rows.map((row) => ({ + ...row, + CreatedBy: row.CreatedBy ?? "", + ArticleName: row.ArticleName ?? "", + UpdatedBy: row.UpdatedBy ?? "", + })); + } + + function addGroup(familyName: string, familyLocalName = ""): void { + if (!detail.value) { + return; + } + const rows = detail.value.FamilyGroups; + if (rows.some((g) => g.FamilyName === familyName)) { + return; + } + const row = new ArticleFamilyGroupDisplayData(); + row.FamilyName = familyName; + row.FamilyLocalName = familyLocalName; + rows.push(row); + } + + function removeGroup(familyName: string): void { + if (!detail.value) { + return; + } + detail.value.FamilyGroups = detail.value.FamilyGroups.filter( + (g) => g.FamilyName !== familyName, + ); + } + + // ===== 配图本地增删 ===== + function addImage(physicalName: string): void { + if (!detail.value || !physicalName) { + return; + } + const rows = detail.value.Filenames; + if (rows.some((f) => f.FileName === physicalName)) { + return; + } + const row = new ArticleFilenameDisplayData(); + row.FileName = physicalName; + rows.push(row); + } + + function removeImage(physicalName: string): void { + if (!detail.value) { + return; + } + detail.value.Filenames = detail.value.Filenames.filter( + (f) => f.FileName !== physicalName, + ); + } + + // ===== 保存 / 删除 / 即时开关 ===== + async function save(): Promise { + const submitted = detail.value!; + const entity = submitted.Article; + entity.ArticleTitle = (entity.ArticleTitle || "").trim(); + submitted.FamilyGroups = normalizeGroups(submitted.FamilyGroups ?? []); + submitted.Filenames = submitted.Filenames ?? []; + const result = entity.CreatedBy + ? await articleService.Update(submitted) + : await articleService.Insert(submitted); + result.FamilyGroups = result.FamilyGroups ?? []; + result.Filenames = result.Filenames ?? []; + detail.value = result; + creating.value = false; + isEdit.value = false; + } + + async function remove(): Promise { + const name = article.value?.ArticleName; + if (!name) { + return; + } + await articleService.Delete(name); + await exitDetail(); + } + + /** 行内即时切换审核/置顶:改本地整包后立即 save 落库(对齐相册即时保存模式) */ + async function setFlag( + field: "ApprovedIndc" | "TopIndc", + on: boolean, + ): Promise { + if (!detail.value?.Article) { + return; + } + detail.value.Article[field] = on ? YES : NO; + await save(); + } + + /** 封面展示地址:已保存态用后端打包的 CoverUrl(绝对地址幂等); + * 未保存的新上传只有裸物理名,按落盘口径 /FileServer/Backup/ArticleCover/{ext}/{name} 拼直链 */ + const coverUrl = computed(() => + article.value?.CoverUrl + ? absoluteFileUrl(article.value.CoverUrl) + : backupFileUrl("ArticleCover", article.value?.ArticleCoverFilename), + ); + + return { + // state + list, + loading, + errorText, + detail, + article, + groups, + filenames, + coverUrl, + inDetail, + creating, + isEdit, + isOwner, + isGroupManager, + isGroupMember, + canManage, + canDelete, + // list / detail + loadList, + openArticle, + exitDetail, + // create / edit + startCreate, + startEdit, + cancelEdit, + // groups / images + addGroup, + removeGroup, + addImage, + removeImage, + // save / delete / flags + save, + remove, + setFlag, + // helpers + formatDate: formatArticleDate, + plainText: plainTextOf, + }; +} diff --git a/uniapp/youjiala/src/data/auto/articleData.ts b/uniapp/youjiala/src/data/auto/articleData.ts new file mode 100644 index 0000000..1303af5 --- /dev/null +++ b/uniapp/youjiala/src/data/auto/articleData.ts @@ -0,0 +1,11 @@ + +import { ArticleDisplayData } from "./articleDisplayData"; +import { ArticleFamilyGroupDisplayData } from "./articleFamilyGroupDisplayData"; +import { ArticleFilenameDisplayData } from "./articleFilenameDisplayData"; +export class ArticleData +{ + public Article: ArticleDisplayData = new ArticleDisplayData(); + public FamilyGroups: ArticleFamilyGroupDisplayData[] = []; + public Filenames: ArticleFilenameDisplayData[] = []; + +} diff --git a/uniapp/youjiala/src/data/auto/articleDisplayData.ts b/uniapp/youjiala/src/data/auto/articleDisplayData.ts new file mode 100644 index 0000000..34b983e --- /dev/null +++ b/uniapp/youjiala/src/data/auto/articleDisplayData.ts @@ -0,0 +1,9 @@ +import { ArticleEntity } from "./articleEntity"; +export class ArticleDisplayData extends ArticleEntity { + public AuthorLocalName: string = ""; + public AuthorAvatarUrl: string = ""; + public CoverUrl: string = ""; + public IsOwner: boolean = false; + public IsMember: boolean = false; + public IsManager: boolean = false; +} diff --git a/uniapp/youjiala/src/data/auto/articleEntity.ts b/uniapp/youjiala/src/data/auto/articleEntity.ts new file mode 100644 index 0000000..de3a5fe --- /dev/null +++ b/uniapp/youjiala/src/data/auto/articleEntity.ts @@ -0,0 +1,40 @@ + +export class ArticleEntity +{ + public CreatedDatetime: Date = new Date(); + public CreatedBy: string = ""; + public UpdatedDatetime: Date = new Date(); + public UpdatedBy: string = ""; + public ArticleName: string = ""; + public ArticleTitle: string = ""; + public Brief: string = ""; + public ArticleCoverFilename: string = ""; + public ApprovedIndc: string = ""; + public TopIndc: string = ""; + public OrderBy: number = 0; + + static readonly CREATED_DATETIME : string ='CreatedDatetime'; + static readonly CREATED_BY : string ='CreatedBy'; + static readonly UPDATED_DATETIME : string ='UpdatedDatetime'; + static readonly UPDATED_BY : string ='UpdatedBy'; + static readonly ARTICLE_NAME : string ='ArticleName'; + static readonly ARTICLE_TITLE : string ='ArticleTitle'; + static readonly BRIEF : string ='Brief'; + static readonly ARTICLE_COVER_FILENAME : string ='ArticleCoverFilename'; + static readonly APPROVED_INDC : string ='ApprovedIndc'; + static readonly TOP_INDC : string ='TopIndc'; + static readonly ORDER_BY : string ='OrderBy'; + static readonly DB_NAME_ARTICLE : string ='article'; + static readonly DB_NAME_FIELDS : string ='created_datetime,created_by,updated_datetime,updated_by,article_name,article_title,brief,article_cover_filename,approved_indc,top_indc,order_by'; + static readonly DB_CREATED_DATETIME : string ='created_datetime'; + 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_ARTICLE_NAME : string ='article_name'; + static readonly DB_ARTICLE_TITLE : string ='article_title'; + static readonly DB_BRIEF : string ='brief'; + static readonly DB_ARTICLE_COVER_FILENAME : string ='article_cover_filename'; + static readonly DB_APPROVED_INDC : string ='approved_indc'; + static readonly DB_TOP_INDC : string ='top_indc'; + static readonly DB_ORDER_BY : string ='order_by'; +} diff --git a/uniapp/youjiala/src/data/auto/articleFamilyGroupDisplayData.ts b/uniapp/youjiala/src/data/auto/articleFamilyGroupDisplayData.ts new file mode 100644 index 0000000..1373088 --- /dev/null +++ b/uniapp/youjiala/src/data/auto/articleFamilyGroupDisplayData.ts @@ -0,0 +1,8 @@ + +import { ArticleFamilyGroupEntity } from "./articleFamilyGroupEntity"; +export class ArticleFamilyGroupDisplayData extends ArticleFamilyGroupEntity +{ + public FamilyLocalName: string = ""; + public Notice: string = ""; + +} diff --git a/uniapp/youjiala/src/data/auto/articleFamilyGroupEntity.ts b/uniapp/youjiala/src/data/auto/articleFamilyGroupEntity.ts new file mode 100644 index 0000000..f9403f8 --- /dev/null +++ b/uniapp/youjiala/src/data/auto/articleFamilyGroupEntity.ts @@ -0,0 +1,25 @@ + +export class ArticleFamilyGroupEntity +{ + public CreatedDatetime: Date = new Date(); + public CreatedBy: string = ""; + public UpdatedDatetime: Date = new Date(); + public UpdatedBy: string = ""; + public ArticleName: string = ""; + public FamilyName: string = ""; + + static readonly CREATED_DATETIME : string ='CreatedDatetime'; + static readonly CREATED_BY : string ='CreatedBy'; + static readonly UPDATED_DATETIME : string ='UpdatedDatetime'; + static readonly UPDATED_BY : string ='UpdatedBy'; + static readonly ARTICLE_NAME : string ='ArticleName'; + static readonly FAMILY_NAME : string ='FamilyName'; + static readonly DB_NAME_ARTICLE_FAMILY_GROUP : string ='article_family_group'; + static readonly DB_NAME_FIELDS : string ='created_datetime,created_by,updated_datetime,updated_by,article_name,family_name'; + static readonly DB_CREATED_DATETIME : string ='created_datetime'; + 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_ARTICLE_NAME : string ='article_name'; + static readonly DB_FAMILY_NAME : string ='family_name'; +} diff --git a/uniapp/youjiala/src/data/auto/articleFamilyGroupEntityKey.ts b/uniapp/youjiala/src/data/auto/articleFamilyGroupEntityKey.ts new file mode 100644 index 0000000..ef3da02 --- /dev/null +++ b/uniapp/youjiala/src/data/auto/articleFamilyGroupEntityKey.ts @@ -0,0 +1,7 @@ + +export class ArticleFamilyGroupEntityKey +{ + public ArticleName: string = ""; + public FamilyName: string = ""; + +} diff --git a/uniapp/youjiala/src/data/auto/articleFilenameDisplayData.ts b/uniapp/youjiala/src/data/auto/articleFilenameDisplayData.ts new file mode 100644 index 0000000..29556a0 --- /dev/null +++ b/uniapp/youjiala/src/data/auto/articleFilenameDisplayData.ts @@ -0,0 +1,4 @@ +import { ArticleFilenameEntity } from "./articleFilenameEntity"; +export class ArticleFilenameDisplayData extends ArticleFilenameEntity { + public ImageUrl: string = ""; +} diff --git a/uniapp/youjiala/src/data/auto/articleFilenameEntity.ts b/uniapp/youjiala/src/data/auto/articleFilenameEntity.ts new file mode 100644 index 0000000..f37a8f1 --- /dev/null +++ b/uniapp/youjiala/src/data/auto/articleFilenameEntity.ts @@ -0,0 +1,28 @@ + +export class ArticleFilenameEntity +{ + public CreatedDatetime: Date = new Date(); + public CreatedBy: string = ""; + public UpdatedDatetime: Date = new Date(); + public UpdatedBy: string = ""; + public FileId: number = 0; + public ArticleName: string = ""; + public FileName: string = ""; + + static readonly CREATED_DATETIME : string ='CreatedDatetime'; + static readonly CREATED_BY : string ='CreatedBy'; + static readonly UPDATED_DATETIME : string ='UpdatedDatetime'; + static readonly UPDATED_BY : string ='UpdatedBy'; + static readonly FILE_ID : string ='FileId'; + static readonly ARTICLE_NAME : string ='ArticleName'; + static readonly FILE_NAME : string ='FileName'; + static readonly DB_NAME_ARTICLE_FILENAME : string ='article_filename'; + static readonly DB_NAME_FIELDS : string ='created_datetime,created_by,updated_datetime,updated_by,file_id,article_name,file_name'; + static readonly DB_CREATED_DATETIME : string ='created_datetime'; + 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_FILE_ID : string ='file_id'; + static readonly DB_ARTICLE_NAME : string ='article_name'; + static readonly DB_FILE_NAME : string ='file_name'; +} diff --git a/uniapp/youjiala/src/data/auto/articleResultData.ts b/uniapp/youjiala/src/data/auto/articleResultData.ts new file mode 100644 index 0000000..9072d23 --- /dev/null +++ b/uniapp/youjiala/src/data/auto/articleResultData.ts @@ -0,0 +1,8 @@ + +import { ArticleDisplayData } from "./articleDisplayData"; +import { SearchCriteriaResultData } from "./searchCriteriaResultData"; +export class ArticleResultData extends SearchCriteriaResultData +{ + public Datas: ArticleDisplayData[] = []; + +} diff --git a/uniapp/youjiala/src/data/auto/articleSearchCriteria.ts b/uniapp/youjiala/src/data/auto/articleSearchCriteria.ts new file mode 100644 index 0000000..121ce0a --- /dev/null +++ b/uniapp/youjiala/src/data/auto/articleSearchCriteria.ts @@ -0,0 +1,6 @@ + +import { AbstractSearchCriteria } from "./abstractSearchCriteria"; +export class ArticleSearchCriteria extends AbstractSearchCriteria +{ + +} diff --git a/uniapp/youjiala/src/locale/lang/en-US.json b/uniapp/youjiala/src/locale/lang/en-US.json index 5cecf60..ca23cdd 100644 --- a/uniapp/youjiala/src/locale/lang/en-US.json +++ b/uniapp/youjiala/src/locale/lang/en-US.json @@ -12,6 +12,46 @@ "avatarUploadedTip": "Avatar updated", "avatarUploadFailed": "Avatar upload failed" }, + "article": { + "title": "Family Culture", + "searchPlaceholder": "Search article title", + "empty": "No culture articles yet", + "unnamed": "Untitled article", + "noBrief": "No content", + "unknownAuthor": "Youjiala user", + "tagTop": "Pinned", + "tagPending": "Pending", + "editTitle": "Edit Article", + "fieldTitle": "Title", + "titleRequired": "Please enter an article title", + "fieldCover": "Cover", + "coverPick": "Tap to upload cover", + "fieldContent": "Content", + "fmtHeader": "Heading", + "fmtList": "List", + "fmtImage": "Image", + "fieldImages": "Images", + "addImage": "Add image", + "noImage": "No images", + "imageTapHint": "Tap a thumbnail to insert it into the content again", + "imageInserted": "Inserted into content", + "editorNotReady": "Editor not ready yet, please retry", + "fieldGroups": "Groups", + "addGroup": "Add group", + "noGroup": "No linked group", + "noGroupCandidate": "No group available to link", + "uploadFailed": "Upload failed", + "tabOverview": "Overview", + "tabImages": "Images", + "tabGroups": "Groups", + "fieldApproved": "Approved", + "fieldTop": "Pin to top", + "delete": "Delete", + "cancel": "Cancel", + "save": "Save", + "deleteTitle": "Delete Article", + "deleteConfirm": "Delete this article and its linked records?" + }, "LoginForm": { "userLabel": "Username", "passwordLabel": "Password", diff --git a/uniapp/youjiala/src/locale/lang/zh-CN.json b/uniapp/youjiala/src/locale/lang/zh-CN.json index 0936ad9..fb69669 100644 --- a/uniapp/youjiala/src/locale/lang/zh-CN.json +++ b/uniapp/youjiala/src/locale/lang/zh-CN.json @@ -12,6 +12,46 @@ "avatarUploadedTip": "头像已更新", "avatarUploadFailed": "头像上传失败" }, + "article": { + "title": "有家文化传承", + "searchPlaceholder": "搜索文章标题", + "empty": "还没有文化传承文章", + "unnamed": "未命名文章", + "noBrief": "暂无内容", + "unknownAuthor": "有家用户", + "tagTop": "置顶", + "tagPending": "待审核", + "editTitle": "编辑文章", + "fieldTitle": "标题", + "titleRequired": "请输入文章标题", + "fieldCover": "封面", + "coverPick": "点击上传封面", + "fieldContent": "正文", + "fmtHeader": "标题", + "fmtList": "列表", + "fmtImage": "插图", + "fieldImages": "配图", + "addImage": "添加配图", + "noImage": "暂无配图", + "imageTapHint": "点击缩略图可重新插入正文", + "imageInserted": "已插入正文", + "editorNotReady": "编辑器未就绪,请稍候再试", + "fieldGroups": "关联群组", + "addGroup": "添加群组", + "noGroup": "未关联任何群组", + "noGroupCandidate": "没有可关联的群组", + "uploadFailed": "上传失败", + "tabOverview": "概览", + "tabImages": "图片", + "tabGroups": "群组", + "fieldApproved": "已审核", + "fieldTop": "置顶", + "delete": "删除", + "cancel": "取消", + "save": "保存", + "deleteTitle": "删除文章", + "deleteConfirm": "确定删除这篇文章及其关联记录吗?" + }, "LoginForm": { "userLabel": "用户名", "passwordLabel": "密码", diff --git a/uniapp/youjiala/src/md/article.md b/uniapp/youjiala/src/md/article.md new file mode 100644 index 0000000..23994bf --- /dev/null +++ b/uniapp/youjiala/src/md/article.md @@ -0,0 +1,260 @@ +# 有家文化传承(Article)功能归档 + +> 适用范围:「有家文化传承」(文章 / Article)从**数据模型、编号、权限可见性、后端服务、文件落盘与地址、前端编排、富文本编辑器、详情页 UI** 的整条链路。 +> 本文是该特性的单一事实来源(single source of truth)。 +> 涉及工程:后端 `CloudBuilder`(.NET 8 / Furion,`CloudBuilder.Master` + `CloudBuilder.Master.Service`)、前端 `uniapp youjiala`(Vue3 + uni-app + uview-pro)。 +> 相关文档:文件地址归一见 [file-url.md](./file-url.md),权限体系见 [permission.md](./permission.md),页面路由见 [page-routing.md](./page-routing.md)。 + +--- + +## 1. 一句话概览 + +一篇「文化传承」文章 = 主表 `article` + 关联的族谱群组 `article_family_group`(决定谁可见)+ 配图明细 `article_filename`。作者撰写并挂群组,**群组正式成员可读**,**未审核文章仅作者本人可见**;作者/群管理员可改正文、封面、配图、审核与置顶。首页以卡片列表呈现,详情页分「管理态 / 只读态」两副面孔,编辑走富文本弹层。 + +--- + +## 2. 数据模型 + +三张表,主键均为业务编号 `article_name`(号段生成,见第 3 节)。 + +### 2.1 主表 `article`(`CloudBuilder.Master/Entity/ArticleEntity.auto.cs`) + +| 字段(DB) | 属性 | 含义 | 备注 | +|---|---|---|---| +| `article_name` | `ArticleName` | 主键编号 | 号段生成,见 §3 | +| `article_title` | `ArticleTitle` | 标题 | 首页搜索按此模糊 | +| `brief` | `Brief` | 正文富文本 HTML | `` 内联烘上限宽样式,见 §8.2 | +| `article_cover_filename` | `ArticleCoverFilename` | 封面**物理文件名** | 非 URL;展示时打包,见 §6 | +| `approved_indc` | `ApprovedIndc` | 是否已审核 `Y/N` | 新文章默认 `N` | +| `top_indc` | `TopIndc` | 是否置顶 `Y/N` | 新文章默认 `N` | +| `order_by` | `OrderBy` (int) | 排序值 | 升序,值小靠前;见 §5 | +| `created_by` / `created_datetime` | | 作者账号名 / 建单时间 | 作者中文名、头像由此 join 回填 | +| `updated_by` / `updated_datetime` | | 最后更新人 / 时间 | | + +### 2.2 群组关联 `article_family_group`(`ArticleFamilyGroupEntity`) + +一行一条「文章 ↔ 族谱群组」关系:`ArticleName` + `FamilyName`。决定文章对哪些群组成员可见。增删走差量(`SaveArticleFamilyGroup`)。 + +### 2.3 配图明细 `article_filename`(`ArticleFilenameEntity.auto.cs`) + +一行一张配图:`ArticleName` + `FileName`(物理名,可空)+ `FileId`(明细序号,按此升序返回)。`CreatedBy/UpdatedBy` 非空。增删走差量(`SaveArticleFilenames`),**仅作者/群管理员落子表**。 + +--- + +## 3. 编号生成 + +`article_name` 走号段生成器 `ISecurityGeneratorNumberService.GetNo`(`CloudBuilder.Master/Policy/MasterPrefixPolicy.cs`): + +| 常量 | 值 | +|---|---| +| `ARTICLE` / `ARTICLE_ARTICLE_NAME` | `ARTICLE` / `ARTICLE_NAME`(号段键) | +| `ARTICLE_PREFIX` | `PP` | +| `ARTICLE_FORMAT` | `yymmdd00000` | + +即与相册同格式但**独立计数**,样例 `PP26102200001`。 + +--- + +## 4. 权限与可见性(核心闸门) + +权限策略名 `MasterPermissionPolicy.Article = "Article"`,四操作 `Find/Insert/Update/Delete`,标注在 `ArticleService.auto.cs` 各方法上(`[PermissionOperate]`)。 + +### 4.1 三档身份(`FindByKey` / `Update` 内计算) + +| 身份 | 判定 | 能力 | +|---|---|---| +| **作者 isOwner** | `article.created_by == 当前 userId` | 全权:读/改正文/封面/配图/群组/审核/置顶/删 | +| **群管理员 isManager** | 关联群组的 `family_group_manager` 行 `ValidIndc=Y` 且 `ManagerIndc=Y` | 改信息/群组/审核/置顶、删;**非作者** | +| **群成员 isMember** | 关联群组的 `family_group_manager` 行 `ValidIndc=Y` | 只读(且文章须已审核) | + +`isOwner` 时 `isMember`/`isManager` 直接置真(作者天然全权)。 + +### 4.2 单篇读取闸门(`FindByKey`) + +``` +非作者且非成员 → throw ValidatedException("没有权限.") +非作者且未审核(ApprovedIndc≠Y) → throw ValidatedException("文章尚未审核通过.") +``` + +### 4.3 列表可见性(`FindBySearchCriteria`) + +先取「我加入的正式群组」`myGroups`(`family_group_manager.ValidIndc=Y`),再取「这些群组下挂的文章编号」`articleNamesInMyGroups`,过滤: + +``` +(CreatedBy == 我 OR articleNamesInMyGroups.Contains(ArticleName)) +AND (ApprovedIndc == Y OR CreatedBy == 我) +``` + +**结论:未审核文章只有作者本人能在列表看到**——这是设计闸门,不是 bug。要让普通成员(如 003)看到某篇文章,作者/群管理员须把该文章的「已审核」开关打开。 + +### 4.4 改/删的字段级防护(`Update`) + +- 非作者:`CreatedBy`/`CreatedDatetime` 一律以库为准(防客户端伪造归属)。 +- 普通成员(非 owner 且非 manager):`ArticleTitle/Brief/ArticleCoverFilename/ApprovedIndc/TopIndc/OrderBy` 全部回灌库中值——**改不动**。 +- 群组关联与配图子表:仅 `isOwner || isManager` 才调用 `SaveArticleFamilyGroup` / `SaveArticleFilenames`。 +- `Delete`:作者或任一关联群管理员,级联删群组与配图子表行。 + +--- + +## 5. 排序规则 + +列表排序在服务端 LINQ 硬编码(`FindBySearchCriteria`): + +```csharp +.OrderByDescending(x => x.TopIndc == YesNoPolicy.YES) // 置顶优先 +.ThenBy(x => x.OrderBy) // 排序值升序(CloudBuilder 约定值小靠前) +.ThenByDescending(x => x.CreatedDatetime); // 创建时间倒序兜底 +``` + +即「**置顶 + 排序组合**」。`OrderBy` 升序与明细列表、Locale 列表口径一致;时间倒序仅作同 `order_by`(如都为 0)时的稳定兜底。 + +> ⚠️ **已知边界**:App 内暂无 `order_by` 编辑入口(管理态只有「置顶」「已审核」两个开关),故当前所有文章 `order_by=0`,排序组合实际退化为「置顶 + 时间倒序」。要启用「排序」这一级,需在 CloudBuilder 管理 GUI / 数据库设 `order_by`,或在管理态面板补一个「排序值」数字输入框(走即时保存,与置顶开关同机制)。 + +--- + +## 6. 文件落盘与访问地址 + +封面与配图**存物理文件名**(裸名如 `uni-1792639791154-1-1.jpg`),不存 URL;展示时按上传目录打包。 + +### 6.1 落盘目录常量(`CloudBuilder.Master/Policy/UploadFolderPolicy.cs`) + +| 常量 | 值 | 对应 | +|---|---|---| +| `ARTICLE_COVER` | `ArticleCover` | `article.article_cover_filename` | +| `ARTICLE_IMAGE` | `ArticleImage` | `article_filename.file_name` | + +前端上传时 `FilePath` 头须与之一致。 + +### 6.2 后端打包(`ArticleService.PackCover / PackImage`) + +复用头像同机制 `FileTypePolicy.PackAvatarUrl(service, folder, fileName)`,结构: + +``` +{ApplicationUrl}/FileServer/Backup/{folder}/{ext}/{fileName} +``` + +`FindByKey` 里对每条配图 `ImageUrl = PackImage(FileName)`;封面 `CoverUrl = PackCover(...)`。作者头像走 `USER_AVATAR` 目录。 + +### 6.3 前端直链拼装(`utils/fileUrl.ts` 新增 `backupFileUrl`) + +上传即落盘(与业务数据是否保存无关),故**保存前预览也能取到图**。前端用 `backupFileUrl(folder, name)` 按后端同口径拼直链: + +``` +backupFileUrl(folder, fileName) -> string + 裸物理名 -> absoluteFileUrl(`/FileServer/Backup/${folder}/${ext}/${name}`) + 已是 /FileServer/ 直链、http/blob/data、绝对路径 -> 幂等放行(过 absoluteFileUrl) +``` + +**历史坑**:曾把裸名直接 `absoluteFileUrl(physicalName)` 拼成坏地址 `{base}/uni-xxx.jpg`(缺 `/FileServer/Backup/{folder}/{ext}/`),既烘进 `Brief` 又导致预览坏。现封面预览、内联插图、缩略图统一走 `backupFileUrl`。地址归一细则见 [file-url.md](./file-url.md)。 + +--- + +## 7. 前端架构 + +| 层 | 文件 | 职责 | +|---|---|---| +| API(自动生成,**不可手改**) | `service/master/articleService.ts` | `/Article/FindByKey`、`/FindBySearchCriteria`、`/Insert`、`/Update`、`/Delete` | +| 数据实体(自动生成) | `data/auto/articleEntity.ts`、`articleFilenameEntity.ts`、`articleFamilyGroupEntity.ts`、`articleData.ts`、`articleResultData.ts` | 与后端字段对齐 | +| 组合式 | `composables/useArticles.ts` | 状态与编排:`useArticles()` 返回 `{ article, groups, filenames, loading, errorText, coverUrl, setFlag, ... }`;导出 `YES/NO`、`formatArticleDate`、`plainTextOf`、`fitBriefImages` | +| 编辑弹层 | `components/article-editor/article-editor.vue` | `u-popup` + uni ``:正文富文本、封面上传、配图上传/复用、保存回写 | +| 详情页 | `pages/article/article.vue`(路由 `pages/article/article`) | 管理态 / 只读态两副面孔、审核与置顶开关、配图与群组 Tab | +| 首页卡片 | `components/family-tree-home/family-tree-home.vue` | 「有家文化传承」区列表卡片(封面、置顶/待审 chip、摘要) | + +`useArticles.ts` 明确「后端已排好序,前端不再筛」——列表顺序以服务端为准。 + +--- + +## 8. 富文本编辑器要点(`article-editor.vue`) + +### 8.1 跨端上下文获取 + +H5 端 `e.detail.editor` 为 `undefined`,须用: + +``` +uni.createSelectorQuery().in(instance.proxy).select("#id").context(res => res.context).exec() +``` + +取到 editor 上下文后再 `setContents / insertImage / getContents`。 + +### 8.2 插图限宽 `fitBriefImages` + +`rich-text` / `` 里的 `` 默认按**原图尺寸**渲染,手机屏宽不够就横向撑爆;且 scoped 样式穿不透 `rich-text` 内容,只能改 HTML 本身。`fitBriefImages(html)` 给每个 `` 剥掉 `width/height` 属性与旧 `max-width/height` 内联样式,再烘上: + +``` +max-width:100%;height:auto;display:block; +``` + +调用点:**灌编辑器前**(`currentHtml()`)与**保存前**(`onSave` 里 `readHtml()` 之后)各一次,使样式随 `getContents` 回写进 `Brief` 落库,只读态与重开编辑同一效果。详情页 `article.vue` 用 `briefHtml = computed(() => fitBriefImages(...))` 绑 `rich-text`。 + +### 8.3 编辑弹层二次打开正文空白的坑(重要) + +`uview-pro` 的 `u-popup` 内容层是 `v-if="visibleSync"`——**关闭即销毁**,重开时内部 `` 是全新实例;而模块级 `editorCtx`/`editorReady` 跨开关持久,仍指向**已销毁的旧实例**,`setContents` 静默失效 → 第二次打开正文空白。 + +修复三件套: +- 抽 `currentHtml()` 统一取最新 `Brief`(过 `fitBriefImages`)。 +- `bindEditorContext`(`@ready` 回调):在 `visible` 且无挂起内容时重取最新 `Brief` 并灌入。 +- `watch(visible)` 打开分支:对旧 `ctx.setContents` 包 `try/catch`,失败则留给新实例 `@ready` 补灌。 +- `readHtml` 的 `fail` 分支兜底**当前 `Brief`**(绝不返回空串,否则会把正文洗没)。 + +### 8.4 复用已上传配图(`onInsertExisting`) + +配图缩略图可点,把**已上传**的图重新插回正文(不重复上传): + +``` +onInsertExisting(img): + src = absoluteFileUrl(img.ImageUrl) || backupFileUrl(IMAGE_FOLDER, img.FileName) + 编辑器未就绪 -> toast(article.editorNotReady) + 否则 editorCtx.insertImage({ src, alt }, () -> toast(article.imageInserted)) +``` + +列表卡片/详情页缩略图 `src` 同口径:`absoluteFileUrl(ImageUrl) || backupFileUrl("ArticleImage", FileName)`。 + +--- + +## 9. 详情页 UI 口径 + +- **两态均不展示封面**:管理态与只读态都移除了封面块;封面只保留在**首页列表卡片**与**编辑弹层上传项**。(产品拍板:详情页不显示封面,其他正常显示。) +- **只读态「今日头条」式通栏直排**:不做卡片包裹——`.ar-body` 去白底/圆角/阴影/内边距,标题→作者/日期→分隔线→正文顺排;`.ar-content` 行高 52rpx、色 `#1f2329`。 +- **管理态**:编号条(编辑/删除图标,编辑图标有效名 `edit-pen`)+ 概览/图片/群组 Tab + 审核/置顶开关。 +- **右侧边距修复**:`.article-page__scroll`(`scroll-view`)原 `padding: 24rpx` 但盒模型未设 `border-box`,padding 加到 100% 宽**之外**,右侧内边距溢出屏、把日期与正文插图右缘同宽裁掉(左有留白右无、不对称)。修复: + +```css +.article-page__scroll { + box-sizing: border-box; /* padding 计入宽度内 */ + overflow-x: hidden; /* 横向溢出兜底 */ + flex: 1; min-height: 0; padding: 24rpx; +} +``` + +管理态与只读态共用此滚动容器,一改两治。浏览器实测(376px 视口):日期完整、插图左右各约 12px 对称、`scrollWidth == clientWidth` 无横向溢出。 + +--- + +## 10. 国际化(`locale/lang/zh-CN.json` / `en-US.json`,`article` 段) + +本特性新增关键提示键(节选): + +| 键 | 中文 | +|---|---| +| `article.imageTapHint` | 点击缩略图可重新插入正文 | +| `article.imageInserted` | 已插入正文 | +| `article.editorNotReady` | 编辑器未就绪,请稍候再试 | + +其余如 `article.unnamed`、`article.fieldTop`、`article.tagTop`、`article.tagPending` 等沿用既有命名。 + +--- + +## 11. 已知边界与待办 + +1. **`order_by` 无 App 内编辑入口**:见 §5,排序组合的「排序」级暂不生效。 +2. **详情页直达路由**:直接访问 `http://localhost:5173/#/pages/article/article?name=...` 会渲染成首页 dashboard,须从首页文章卡点进去才走短地址 `/article?name=...`。独立于本特性,待单独跟进。 +3. **后端排序/可见性改动需重启**:`ArticleService.auto.cs` 是服务代码逻辑,非权限数据,改后须重新生成并重启 `CloudBuilder.Web.Entry`(重登无效)。 + +--- + +## 12. 验证方法(自动化优先) + +- **前端类型裁决**:`node D:\Docker\uniapp\youjiala\node_modules\vue-tsc\bin\vue-tsc.js --noEmit`,看退出码 0(eslint 风格噪声一贯忽略)。 +- **后端编译**:`dotnet build CloudBuilder.Master.Service\...csproj`,确认 0 error(既有 `DmsFileService` nullable 警告无关)。 +- **连库核对**:`node` 绝对路径 + `mysql2`(`nas.youjiala.com:3307/Furion`,utf8mb4),核对 `article` / `article_family_group` / `article_filename` / `family_group_manager` 数据链与排序。PowerShell 内嵌引号会毁 `node -e`,须写临时 `.js`(放已 gitignore 的 `.dbtmp/`)执行。 +- **视觉核验**:浏览器子代理设手机竖屏(约 376px)截图,量日期/插图右缘留白与 `scrollWidth vs clientWidth`。 diff --git a/uniapp/youjiala/src/pages.json b/uniapp/youjiala/src/pages.json index 8754ec0..d3d1476 100644 --- a/uniapp/youjiala/src/pages.json +++ b/uniapp/youjiala/src/pages.json @@ -155,6 +155,10 @@ // 共享大厅(短地址 /share-hall):文件页快捷入口跳入,家人共享目录浏览与组内建目录 { "path": "pages/share-hall/share-hall" + }, + // 有家文化传承文章详情(短地址 /article):首页文章卡跳入,只读/管理两态 + 底部富文本编辑 + { + "path": "pages/article/article" } ], // 全局配置 diff --git a/uniapp/youjiala/src/pages/article/article.vue b/uniapp/youjiala/src/pages/article/article.vue new file mode 100644 index 0000000..0691b5c --- /dev/null +++ b/uniapp/youjiala/src/pages/article/article.vue @@ -0,0 +1,504 @@ + + + + + diff --git a/uniapp/youjiala/src/router/routes.ts b/uniapp/youjiala/src/router/routes.ts index 5d6fd1d..5538be2 100644 --- a/uniapp/youjiala/src/router/routes.ts +++ b/uniapp/youjiala/src/router/routes.ts @@ -78,6 +78,8 @@ export const PAGE_PATHS = { shareHall: "pages/share-hall/share-hall", // 我的主题(短地址 /theme):「我的」页跳入,主题配色与深色模式设置 themePage: "pages/theme/theme", + // 有家文化传承文章详情(短地址 /article):首页文章卡「+」/点条目跳入,只读/管理两态 + articlePage: "pages/article/article", } as const; export type PageName = keyof typeof PAGE_PATHS; diff --git a/uniapp/youjiala/src/service/master/articleService.ts b/uniapp/youjiala/src/service/master/articleService.ts new file mode 100644 index 0000000..19ccd3d --- /dev/null +++ b/uniapp/youjiala/src/service/master/articleService.ts @@ -0,0 +1,62 @@ + +import { request } from "@/utils/request"; + import type { ArticleData } from "@/data/auto/articleData"; + import type { ArticleSearchCriteria } from "@/data/auto/articleSearchCriteria"; + import type { ArticleResultData } from "@/data/auto/articleResultData"; + +class ArticleService{ + + async FindByKey(data: string) { + return request( + { + url: "/Article/FindByKey?key="+data, + method: "post", + data, + } + ) + }; + + async FindBySearchCriteria(data: ArticleSearchCriteria) { + return request( + { + url: "/Article/FindBySearchCriteria", + method: "post", + data, + } + ) + }; + + async Insert(data: ArticleData) { + return request( + { + url: "/Article/Insert", + method: "post", + data, + } + ) + }; + + async Update(data: ArticleData) { + return request( + { + url: "/Article/Update", + method: "post", + data, + } + ) + }; + + async Delete(data: string) { + return request( + { + url: "/Article/Delete?key="+data, + method: "post", + data, + } + ) + }; + +} + +const articleService= new ArticleService(); +export default articleService; \ No newline at end of file diff --git a/uniapp/youjiala/src/utils/fileUrl.ts b/uniapp/youjiala/src/utils/fileUrl.ts index b977b13..acb28f5 100644 --- a/uniapp/youjiala/src/utils/fileUrl.ts +++ b/uniapp/youjiala/src/utils/fileUrl.ts @@ -146,6 +146,67 @@ export function wanFileUrl(path?: string | null): string { * 提交用的相对路径 /FileServer/... 与裸物理名(无主机)、blob:/data:/本地路径均不含“主机+/FileServer”,天然被排除 */ const FILE_URL_PATH_MARKER = "/FileServer/"; +/** + * 上传即落盘的备份文件直链:/FileServer/Backup/{folder}/{ext}/{物理名}。 + * + * 为什么需要:DmsFile/UploadFile 上传成功后文件立刻落盘(与是否保存业务数据无关), + * 后端读取态才用 PackAvatarUrl 把物理名打成这个结构;前端「保存前预览 / 富文本内联插图」 + * 拿不到后端打包地址,直接 absoluteFileUrl(裸物理名) 会拼出 {base}/uni-xxx.jpg 的坏地址。 + * 这里按后端同口径自行拼相对路径再过 absoluteFileUrl(H5 锁 origin、App 跟线路), + * 保存前后同一个地址都能取到图。folder 须与后端 UploadFolderPolicy 一致(如 ArticleCover / ArticleImage)。 + */ +export function backupFileUrl( + folder: string, + fileName?: string | null, +): string { + const name = String(fileName ?? "").trim(); + if (!name) { + return ""; + } + // 已是绝对/相对直链或本地资源:交给 absoluteFileUrl 归一,不再重复打包 + if ( + name.includes(FILE_URL_PATH_MARKER) || + /^(?:https?:|blob:|data:|\/)/i.test(name) + ) { + return absoluteFileUrl(name); + } + const dot = name.lastIndexOf("."); + const ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : "ext"; + return absoluteFileUrl(`/FileServer/Backup/${folder}/${ext}/${name}`); +} + +/** + * 改写富文本(文章正文 Brief)HTML 里每个 的 src。 + * + * 为什么单独处理:响应层 normalizePayloadFileUrls 只改「整个字段值就是绝对 FileServer URL」的串, + * 而 Brief 是 HTML(以

开头),absoluteHost 判非绝对 → 漏改。后端 Debug 会把 localhost:5235 + * 打进正文内联 ,App 上 localhost 指向手机自己,插图必挂;封面/配图走 absoluteFileUrl 才没事。 + * - display:把 src 归一到当前端可达的绝对地址(absoluteFileUrl,App 跟线路、H5 锁 origin); + * - store:把 src 剥成 host 无关的相对路径 /FileServer/...(toPathAndQuery),存库后跨环境可移植。 + * 判据极窄:只碰含 /FileServer/ 的地址;blob:/data:/file:/设备路径等本地资源、外部图 URL 一律原样放行。 + */ +export function mapBriefImageSrc( + html: string, + mode: "display" | "store", +): string { + return String(html ?? "").replace( + /(]*?\ssrc=)(["'])([^"']*)\2/gi, + (whole, prefix: string, quote: string, src: string) => { + const trimmed = String(src).trim(); + if ( + !trimmed || + isLocalResource(trimmed) || + !trimmed.includes(FILE_URL_PATH_MARKER) + ) { + return whole; + } + const next = + mode === "display" ? absoluteFileUrl(trimmed) : toPathAndQuery(trimmed); + return next ? `${prefix}${quote}${next}${quote}` : whole; + }, + ); +} + /** 取绝对 http(s) 地址的主机(小写、含端口);非绝对地址返回 null */ function absoluteHost(url: string): string | null { const matched = /^https?:\/\/([^/]+)/i.exec(url);