article
This commit is contained in:
1 parent
c6eb00b406
commit
1c91f62ff6
58 files changed
+3869
-50
No files matched your search
@@ -22,4 +22,5 @@
|
||||
/CloudBuilder.Master.Service/obj
|
||||
/CloudBuilder.Master.Gui/obj
|
||||
/CloudBuilder.Master.Gui/bin
|
||||
/.dbtmp
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 有家文化传承(文章)服务:主表 article + 群组关联 article_family_group + 配图明细 article_filename。
|
||||
/// 可见性:仅文章关联族谱群组的正式成员可见;未审核(approved_indc<>Y)仅作者本人可见。
|
||||
/// 作者中文名/头像按 created_by join security_user 回填;封面与配图存物理名、按上传目录打包访问地址。
|
||||
/// </summary>
|
||||
public class ArticleService : IArticleService
|
||||
{
|
||||
private readonly IApplicationService service;
|
||||
|
||||
private IRepository<ArticleEntity> repository;
|
||||
public ArticleService(IApplicationService service)
|
||||
{
|
||||
this.service = service;
|
||||
repository = service.GetRepository<IRepository<ArticleEntity>>();
|
||||
}
|
||||
|
||||
[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<ArticleFamilyGroupEntity> repositoryArticleFamilyGroup = repository.GetRepository<IRepository<ArticleFamilyGroupEntity>>();
|
||||
IRepository<FamilyGroupEntity> repositoryFamilyGroup = repository.GetRepository<IRepository<FamilyGroupEntity>>();
|
||||
|
||||
IQueryable<ArticleFamilyGroupDisplayData> 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<FamilyGroupManagerEntity> repositoryFamilyGroupManager = repository.GetRepository<IRepository<FamilyGroupManagerEntity>>();
|
||||
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<ArticleFilenameEntity> repositoryArticleFilename = repository.GetRepository<IRepository<ArticleFilenameEntity>>();
|
||||
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<FamilyGroupManagerEntity> repositoryFamilyGroupManager = repository.GetRepository<IRepository<FamilyGroupManagerEntity>>();
|
||||
string[] myGroups = repositoryFamilyGroupManager.DetachedEntities
|
||||
.Where(x => x.UserName == userId && x.ValidIndc == YesNoPolicy.YES)
|
||||
.Select(x => x.FamilyName).Distinct().ToArray();
|
||||
|
||||
// 这些群组下挂的文章编号
|
||||
IRepository<ArticleFamilyGroupEntity> repositoryArticleFamilyGroup = repository.GetRepository<IRepository<ArticleFamilyGroupEntity>>();
|
||||
string[] articleNamesInMyGroups = myGroups.Length == 0
|
||||
? new string[0]
|
||||
: repositoryArticleFamilyGroup.DetachedEntities
|
||||
.Where(x => myGroups.Contains(x.FamilyName))
|
||||
.Select(x => x.ArticleName).Distinct().ToArray();
|
||||
|
||||
IQueryable<ArticleEntity> 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<ArticleEntity> 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<ISecurityGeneratorNumberService>()!;
|
||||
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<ArticleFamilyGroupEntity> repositoryArticleFamilyGroup = repository.GetRepository<IRepository<ArticleFamilyGroupEntity>>();
|
||||
IRepository<FamilyGroupManagerEntity> repositoryFamilyGroupManager = repository.GetRepository<IRepository<FamilyGroupManagerEntity>>();
|
||||
|
||||
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<ArticleFamilyGroupEntity> repositoryFamilyGroup = repository.GetRepository<IRepository<ArticleFamilyGroupEntity>>();
|
||||
ArticleFamilyGroupEntity[] groups = repositoryFamilyGroup.Entities.Where(x => x.ArticleName == key).ToArray();
|
||||
foreach (ArticleFamilyGroupEntity gp in groups)
|
||||
{
|
||||
repositoryFamilyGroup.DeleteNow(gp);
|
||||
}
|
||||
|
||||
IRepository<ArticleFilenameEntity> repositoryFilename = repository.GetRepository<IRepository<ArticleFilenameEntity>>();
|
||||
ArticleFilenameEntity[] files = repositoryFilename.Entities.Where(x => x.ArticleName == key).ToArray();
|
||||
foreach (ArticleFilenameEntity f in files)
|
||||
{
|
||||
repositoryFilename.DeleteNow(f);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>当前登录人是否为该文章任一关联群组的管理员(正式成员且 ManagerIndc=Y)</summary>
|
||||
private bool IsGroupManagerOfArticle(string articleName)
|
||||
{
|
||||
IRepository<ArticleFamilyGroupEntity> repositoryArticleFamilyGroup = repository.GetRepository<IRepository<ArticleFamilyGroupEntity>>();
|
||||
IRepository<FamilyGroupManagerEntity> repositoryFamilyGroupManager = repository.GetRepository<IRepository<FamilyGroupManagerEntity>>();
|
||||
|
||||
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<ArticleFamilyGroupEntity> repositoryArticleFamilyGroup = repository.GetRepository<IRepository<ArticleFamilyGroupEntity>>();
|
||||
|
||||
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<ArticleFilenameEntity> repositoryArticleFilename = repository.GetRepository<IRepository<ArticleFilenameEntity>>();
|
||||
|
||||
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<SysTableSchemaViewEntity> sysTableSchemas = service.ServiceProvider.GetRequiredService<ISysTableSchemaViewService>().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) ?? "";
|
||||
}
|
||||
|
||||
/// <summary>按用户名 join security_user 回填单个作者的中文名与头像。</summary>
|
||||
private void FillAuthor(ArticleDisplayData display, string userName)
|
||||
{
|
||||
if (display == null || string.IsNullOrEmpty(userName)) return;
|
||||
IRepository<SecurityUserEntity> repositoryUser = repository.GetRepository<IRepository<SecurityUserEntity>>();
|
||||
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 ?? "") ?? "";
|
||||
}
|
||||
|
||||
/// <summary>批量 join:一次取回页内所有作者的中文名与头像,避免 N+1。</summary>
|
||||
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<SecurityUserEntity> repositoryUser = repository.GetRepository<IRepository<SecurityUserEntity>>();
|
||||
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 ?? "") ?? "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 排除实体类标记
|
||||
/// </summary>
|
||||
[NotMapped]
|
||||
[TypeScript]
|
||||
public class ArticleDisplayData : ArticleEntity
|
||||
{
|
||||
public ArticleDisplayData()
|
||||
{
|
||||
}
|
||||
public ArticleDisplayData(ArticleEntity entity)
|
||||
{
|
||||
CopyFrom(entity);
|
||||
}
|
||||
|
||||
/// <summary>作者中文名(按 CreatedBy join security_user.user_local_name)</summary>
|
||||
public string? AuthorLocalName { get; set; }
|
||||
|
||||
/// <summary>作者头像访问地址(按 CreatedBy join security_user.user_icon_img_path 打包)</summary>
|
||||
public string? AuthorAvatarUrl { get; set; }
|
||||
|
||||
/// <summary>封面访问地址(由 ArticleCoverFilename 物理名打包,未设置为空)</summary>
|
||||
public string? CoverUrl { get; set; }
|
||||
|
||||
/// <summary>当前登录人是否作者本人(前端据此显示编辑/删除/审核/置顶)</summary>
|
||||
public bool IsOwner { get; set; }
|
||||
|
||||
/// <summary>当前登录人是否关联群组的正式成员</summary>
|
||||
public bool IsMember { get; set; }
|
||||
|
||||
/// <summary>当前登录人是否关联群组的管理员</summary>
|
||||
public bool IsManager { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 文章配图明细展示数据:在实体(FileName 存物理名)基础上补一个打包后的访问地址,
|
||||
/// 供前端图片 Tab 直接渲染;提交时仍回传物理名 FileName,ImageUrl 只读。
|
||||
/// </summary>
|
||||
[NotMapped]
|
||||
[TypeScript]
|
||||
public class ArticleFilenameDisplayData : ArticleFilenameEntity
|
||||
{
|
||||
public ArticleFilenameDisplayData()
|
||||
{
|
||||
}
|
||||
|
||||
public ArticleFilenameDisplayData(ArticleFilenameEntity entity)
|
||||
{
|
||||
CopyFrom(entity);
|
||||
}
|
||||
|
||||
/// <summary>配图访问地址(由 FileName 物理名打包,未设置为空)</summary>
|
||||
public string? ImageUrl { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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<ArticleFamilyGroupEntityKey>
|
||||
{
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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<ArticleFamilyGroupEntity>().HasKey(c => new { c.ArticleName, c.FamilyName}); */
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,10 @@ builder.Entity<FamilyTreePermissionUserViewEntity>(eb => { eb.HasNoKey(); });
|
||||
|
||||
builder.Entity<DmsFileUsageViewEntity>(eb => { eb.HasNoKey(); });
|
||||
|
||||
|
||||
|
||||
builder.Entity<ArticleFamilyGroupEntity>().HasKey(c => new {c.ArticleName,c.FamilyName});
|
||||
|
||||
//***END***
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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***
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -18,5 +18,11 @@ namespace CloudBuilder.Master.Policy
|
||||
|
||||
/// <summary>身份证照片目录(与前端身份证上传 FilePath 头一致,小写历史沿用)</summary>
|
||||
public const string ID_CARD = "idcard";
|
||||
|
||||
/// <summary>文章封面目录(article.article_cover_filename 对应,前端上传 FilePath 头须一致)</summary>
|
||||
public const string ARTICLE_COVER = "ArticleCover";
|
||||
|
||||
/// <summary>文章正文/配图目录(article_filename.file_name 对应,前端上传 FilePath 头须一致)</summary>
|
||||
public const string ARTICLE_IMAGE = "ArticleImage";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
|
||||
using CloudBuilder.Core.Authorization;
|
||||
using CloudBuilder.Core.Service;
|
||||
namespace CloudBuilder.Master.SearchCriteria
|
||||
{
|
||||
[TypeScript]
|
||||
public class ArticleSearchCriteria: AbstractSearchCriteria
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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<ArticleData, string ,ArticleSearchCriteria>
|
||||
{
|
||||
ArticleResultData FindBySearchCriteria(ArticleSearchCriteria tc);
|
||||
}
|
||||
}
|
||||
@@ -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<ArticleData>("/Article/FindByKey?key=" + key.ToString())).GetAwaiter().GetResult(); }
|
||||
|
||||
public ArticleResultData FindBySearchCriteria(ArticleSearchCriteria criteria)
|
||||
{
|
||||
return Task.Run(() => serviceRequest.PostAsJsonAsync<ArticleSearchCriteria,ArticleResultData>("/Article/FindBySearchCriteria" , criteria)).GetAwaiter().GetResult(); }
|
||||
|
||||
public ArticleData Insert(ArticleData data)
|
||||
{
|
||||
return Task.Run(() => serviceRequest.PostAsJsonAsync<ArticleData,ArticleData>("/Article/Insert" , data)).GetAwaiter().GetResult(); }
|
||||
|
||||
public ArticleData Update(ArticleData data)
|
||||
{
|
||||
return Task.Run(() => serviceRequest.PostAsJsonAsync<ArticleData,ArticleData>("/Article/Update" , data)).GetAwaiter().GetResult(); }
|
||||
|
||||
public void Delete(string key)
|
||||
{
|
||||
Task.Run(() => serviceRequest.PostAsync("/Article/Delete?key=" + key.ToString())).GetAwaiter().GetResult(); }
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
+3
-3
@@ -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
|
||||
{
|
||||
|
||||
+3
-3
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
+3
-3
@@ -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
|
||||
{
|
||||
|
||||
+3
-3
@@ -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
|
||||
{
|
||||
|
||||
+3
-3
@@ -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
|
||||
{
|
||||
|
||||
+3
-3
@@ -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
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,621 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 有家文化传承「文章」底部编辑弹层(新建 / 编辑复用,图3 的修改入口)。
|
||||
* 受控组件:只渲染 + 交互,数据整包托管给父级传入的 useArticles 实例(props.api.detail),
|
||||
* 标题/正文/封面/群组/配图全部本地改整包,保存读富文本 html 回写 Brief 后 api.save() 落库。
|
||||
* 契约(对齐相册编辑弹层 + 后端 UploadFolderPolicy):
|
||||
* 1. 封面走 /DmsFile/UploadFile,FilePath 头 = "ArticleCover";DB 存分片物理名 ArticleCoverFilename;
|
||||
* 2. 正文配图同样走 /DmsFile/UploadFile,FilePath 头 = "ArticleImage",物理名进 article_filename 明细;
|
||||
* 3. 富文本用 uni-app 内置 <editor>(H5 + App webview):ready 取 editorCtx,打开时 setContents 灌 Brief,
|
||||
* 保存前 getContents 取回 html;插入图片后同步登记配图明细;
|
||||
* 4. 群组候选复用 useFamilyGroups(我可见的群),增删只改本地整包,保存由后端按 FamilyName 差量落库。
|
||||
*/
|
||||
import type { useArticles } from "@/composables/useArticles";
|
||||
import { useLocale } from "uview-pro";
|
||||
import { computed, getCurrentInstance, ref, watch } from "vue";
|
||||
import { buildChunkFileName, uploadFile } from "@/common/http.interceptor";
|
||||
import { useFamilyGroups } from "@/composables";
|
||||
import { briefForDisplay, briefForStore } from "@/composables/useArticles";
|
||||
import { absoluteFileUrl, backupFileUrl } from "@/utils/fileUrl";
|
||||
|
||||
type ArticleApi = ReturnType<typeof useArticles>;
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
api: ArticleApi;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "close"): void;
|
||||
(e: "saved"): void;
|
||||
}>();
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
/** 与后端 UploadFolderPolicy 同名的落盘目录(上传 FilePath 头 / 保存前预览打包地址共用) */
|
||||
const COVER_FOLDER = "ArticleCover";
|
||||
const IMAGE_FOLDER = "ArticleImage";
|
||||
|
||||
const familyGroups = useFamilyGroups();
|
||||
|
||||
// 整包托管:直接引用 api 的 computed/ref,改动即反映到 api.detail
|
||||
const article = props.api.article;
|
||||
const groups = props.api.groups;
|
||||
const filenames = props.api.filenames;
|
||||
|
||||
const saving = ref(false);
|
||||
const coverUploading = ref(false);
|
||||
/** 封面预览:新上传用本地临时地址兜底,编辑态用后端打包的 CoverUrl */
|
||||
const coverPreview = ref("");
|
||||
|
||||
// ===== 富文本编辑器 =====
|
||||
let editorCtx: any = null;
|
||||
const editorReady = ref(false);
|
||||
/** 弹层在 editor ready 前就要求灌内容时暂存,ready 后补写 */
|
||||
let pendingHtml: string | null = null;
|
||||
|
||||
const editorInstance = getCurrentInstance();
|
||||
|
||||
/** 当前应灌进编辑器的正文(限宽样式烘入 + img src 归一到当前端可达绝对地址) */
|
||||
function currentHtml(): string {
|
||||
return briefForDisplay(article.value?.Brief);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拿到 editor 上下文后统一收尾:置就绪 + 灌入正文。
|
||||
* u-popup 内容是 v-if,弹层每次重开都会重建 <editor>:旧 ctx 已失效,
|
||||
* 新实例 ready 时只要弹层开着就重取最新 Brief 补灌(不依赖 pendingHtml 是否被消费)。
|
||||
*/
|
||||
function bindEditorContext(ctx: any): void {
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
editorCtx = ctx;
|
||||
editorReady.value = true;
|
||||
if (props.visible && pendingHtml === null) {
|
||||
pendingHtml = currentHtml();
|
||||
}
|
||||
if (pendingHtml !== null) {
|
||||
editorCtx.setContents({ html: pendingHtml });
|
||||
pendingHtml = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取 uni-app <editor> 上下文的跨端正确写法:createSelectorQuery().select().context()。
|
||||
* H5/App 端 ready 事件的 e.detail.editor 可能为 undefined(曾导致正文读写全废),
|
||||
* 故以 selector query 为准,e.detail.editor 仅作兜底;小程序端需 .in(组件实例) 限定作用域。
|
||||
*/
|
||||
function onEditorReady(e?: any): void {
|
||||
const hasCtx = (c: any): boolean => !!c && typeof c.setContents === "function";
|
||||
if (hasCtx(e?.detail?.editor)) {
|
||||
bindEditorContext(e.detail.editor);
|
||||
return;
|
||||
}
|
||||
const query = editorInstance
|
||||
? uni.createSelectorQuery().in(editorInstance.proxy)
|
||||
: uni.createSelectorQuery();
|
||||
query.select("#article-editor")
|
||||
.context((res: any) => bindEditorContext(res?.context))
|
||||
.exec();
|
||||
}
|
||||
|
||||
function format(key: string): void {
|
||||
editorCtx?.format(key, {});
|
||||
}
|
||||
|
||||
/** 取回当前富文本 html(getContents 异步,包一层 Promise 供保存串行使用)。
|
||||
* 编辑器未就绪或读取失败时兑当前 Brief,绝不可返回空串——否则一次失败就把正文洗掉 */
|
||||
function readHtml(): Promise<string> {
|
||||
if (!editorReady.value || !editorCtx) {
|
||||
return Promise.resolve(String(article.value?.Brief ?? ""));
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
editorCtx.getContents({
|
||||
success: (res: any) => resolve(res.html || String(article.value?.Brief ?? "")),
|
||||
fail: () => resolve(String(article.value?.Brief ?? "")),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 打开时把整包 Brief 灌进编辑器;关闭不重置,便于连续编辑 */
|
||||
watch(
|
||||
() => props.visible,
|
||||
(open) => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
// 灌入前给插图烘上限宽样式:编辑器内也按屏宽显示,且样式随保存回写进 Brief
|
||||
pendingHtml = currentHtml();
|
||||
// 封面预览:后端已保存态用打包的 CoverUrl;未保存的新上传按落盘口径拼直链(上传即落盘,保存前就能取到图)
|
||||
coverPreview.value = article.value?.CoverUrl
|
||||
? absoluteFileUrl(article.value.CoverUrl)
|
||||
: backupFileUrl(COVER_FOLDER, article.value?.ArticleCoverFilename);
|
||||
// 旧 ctx 可能指向上次弹层里已销毁的 editor:打不进去就留给新实例 ready 时补灌
|
||||
if (editorReady.value && editorCtx) {
|
||||
try {
|
||||
editorCtx.setContents({ html: pendingHtml });
|
||||
pendingHtml = null;
|
||||
}
|
||||
catch {
|
||||
/* 弹层重建了 editor,等 @ready 重新取上下文后补写 */
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// ===== 封面上传 =====
|
||||
function onPickCover(): void {
|
||||
if (coverUploading.value) {
|
||||
return;
|
||||
}
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
success: async (res: any) => {
|
||||
const filePath = String(res?.tempFilePaths?.[0] ?? "");
|
||||
const fileName = String(res?.tempFiles?.[0]?.name || `cover-${Date.now()}.png`);
|
||||
if (!filePath || !article.value) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
coverUploading.value = true;
|
||||
const physicalName = buildChunkFileName(fileName);
|
||||
await uploadFile({
|
||||
url: "/DmsFile/UploadFile",
|
||||
filePath,
|
||||
name: fileName,
|
||||
fileName: physicalName,
|
||||
header: { FilePath: COVER_FOLDER },
|
||||
});
|
||||
article.value.ArticleCoverFilename = physicalName;
|
||||
coverPreview.value = filePath;
|
||||
}
|
||||
catch (error: any) {
|
||||
uni.showToast({ title: error?.message || t("article.uploadFailed"), icon: "none" });
|
||||
}
|
||||
finally {
|
||||
coverUploading.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 正文配图:上传 + 编辑器内联 + 登记明细 =====
|
||||
function onInsertImage(): void {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
success: async (res: any) => {
|
||||
const filePath = String(res?.tempFilePaths?.[0] ?? "");
|
||||
const fileName = String(res?.tempFiles?.[0]?.name || `img-${Date.now()}.png`);
|
||||
if (!filePath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const physicalName = buildChunkFileName(fileName);
|
||||
await uploadFile({
|
||||
url: "/DmsFile/UploadFile",
|
||||
filePath,
|
||||
name: fileName,
|
||||
fileName: physicalName,
|
||||
header: { FilePath: IMAGE_FOLDER },
|
||||
});
|
||||
props.api.addImage(physicalName);
|
||||
// 内联 src 用落盘直链(非裸物理名):保存前可见,且这个地址会烘进 Brief,保存后仍有效
|
||||
editorCtx?.insertImage({ src: backupFileUrl(IMAGE_FOLDER, physicalName), alt: fileName }, () => { });
|
||||
}
|
||||
catch (error: any) {
|
||||
uni.showToast({ title: error?.message || t("article.uploadFailed"), icon: "none" });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onRemoveImage(physicalName: string): void {
|
||||
props.api.removeImage(physicalName);
|
||||
}
|
||||
|
||||
/** 复用已上传配图:点缩略图把图重新插回正文(不重复上传),插入位置跟随编辑器最后光标 */
|
||||
function onInsertExisting(img: { ImageUrl: string; FileName: string }): void {
|
||||
const src = absoluteFileUrl(img.ImageUrl) || backupFileUrl(IMAGE_FOLDER, img.FileName);
|
||||
if (!src) {
|
||||
return;
|
||||
}
|
||||
if (!editorReady.value || !editorCtx) {
|
||||
uni.showToast({ title: t("article.editorNotReady"), icon: "none" });
|
||||
return;
|
||||
}
|
||||
editorCtx.insertImage({ src, alt: img.FileName }, () => {
|
||||
uni.showToast({ title: t("article.imageInserted"), icon: "none" });
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 群组选择器(复用共享数据源,ActionSheet 选一个未关联的群) =====
|
||||
async function onAddGroup(): Promise<void> {
|
||||
await familyGroups.loadList();
|
||||
const candidates = familyGroups.groups.value.filter(
|
||||
g => !groups.value.some(row => row.FamilyName === g.FamilyName),
|
||||
);
|
||||
if (!candidates.length) {
|
||||
uni.showToast({ title: t("article.noGroupCandidate"), icon: "none" });
|
||||
return;
|
||||
}
|
||||
uni.showActionSheet({
|
||||
itemList: candidates.map(g => g.FamilyLocalName || g.FamilyName),
|
||||
success: (res) => {
|
||||
const pick = candidates[res.tapIndex];
|
||||
props.api.addGroup(pick.FamilyName, pick.FamilyLocalName || "");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onRemoveGroup(familyName: string): void {
|
||||
props.api.removeGroup(familyName);
|
||||
}
|
||||
|
||||
// ===== 保存 / 取消 =====
|
||||
async function onSave(): Promise<void> {
|
||||
if (!article.value) {
|
||||
return;
|
||||
}
|
||||
if (!(article.value.ArticleTitle || "").trim()) {
|
||||
uni.showToast({ title: t("article.titleRequired"), icon: "none" });
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
// 保存前烘限宽样式并把 img src 剥成 host 无关相对路径:新插入/复用的图也带约束、且落库跨环境可移植
|
||||
article.value.Brief = briefForStore(await readHtml());
|
||||
await props.api.save();
|
||||
emit("saved");
|
||||
}
|
||||
catch (error: any) {
|
||||
uni.showToast({ title: error?.message || t("FilePage.actionFailed"), icon: "none" });
|
||||
}
|
||||
finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const imageCount = computed(() => filenames.value.length);
|
||||
|
||||
/** 标题双算子:读写透传到整包 article.ArticleTitle,避开模板对可空 computed 的非空断言 */
|
||||
const title = computed({
|
||||
get: () => article.value?.ArticleTitle ?? "",
|
||||
set: (val: string) => {
|
||||
if (article.value) {
|
||||
article.value.ArticleTitle = val;
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<u-popup :model-value="visible" mode="bottom" round="24" @close="emit('close')">
|
||||
<view class="article-editor">
|
||||
<view class="article-editor__head">
|
||||
<text class="article-editor__title">{{ t("article.editTitle") }}</text>
|
||||
<u-icon name="close" size="32" color="#8f959e" @click="emit('close')" />
|
||||
</view>
|
||||
|
||||
<scroll-view scroll-y class="article-editor__scroll">
|
||||
<view class="article-editor__form">
|
||||
<!-- 标题 -->
|
||||
<view class="ae-field">
|
||||
<text class="ae-field__label">{{ t("article.fieldTitle") }}</text>
|
||||
<input v-model="title" class="ae-field__input" :maxlength="100"
|
||||
:placeholder="t('article.titleRequired')">
|
||||
</view>
|
||||
|
||||
<!-- 封面 -->
|
||||
<view class="ae-field">
|
||||
<text class="ae-field__label">{{ t("article.fieldCover") }}</text>
|
||||
<view class="ae-cover" @tap="onPickCover">
|
||||
<image v-if="coverPreview" class="ae-cover__img" :src="coverPreview" mode="aspectFill" />
|
||||
<view v-else class="ae-cover__ph">
|
||||
<u-icon name="camera-fill" size="40" color="#c0c4cc" />
|
||||
<text class="ae-cover__ph-text">{{ t("article.coverPick") }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 正文富文本 -->
|
||||
<view class="ae-field">
|
||||
<text class="ae-field__label">{{ t("article.fieldContent") }}</text>
|
||||
<view class="ae-toolbar">
|
||||
<text class="ae-toolbar__btn" @tap="format('bold')"><text
|
||||
class="ae-toolbar__b">B</text></text>
|
||||
<text class="ae-toolbar__btn" @tap="format('italic')"><text
|
||||
class="ae-toolbar__i">I</text></text>
|
||||
<text class="ae-toolbar__btn" @tap="format('underline')"><text
|
||||
class="ae-toolbar__u">U</text></text>
|
||||
<text class="ae-toolbar__btn" @tap="format('header')">{{ t("article.fmtHeader") }}</text>
|
||||
<text class="ae-toolbar__btn" @tap="format('insertOrderedList')">{{ t("article.fmtList")
|
||||
}}</text>
|
||||
<text class="ae-toolbar__btn" @tap="onInsertImage">{{ t("article.fmtImage") }}</text>
|
||||
</view>
|
||||
<editor id="article-editor" class="ae-editor" :read-only="false" placeholder=""
|
||||
@ready="onEditorReady" />
|
||||
</view>
|
||||
|
||||
<!-- 配图明细 -->
|
||||
<view class="ae-field">
|
||||
<view class="ae-field__rowhead">
|
||||
<text class="ae-field__label">{{ t("article.fieldImages") }}({{ imageCount }})</text>
|
||||
<text class="ae-field__add" @tap="onInsertImage">+ {{ t("article.addImage") }}</text>
|
||||
</view>
|
||||
<view v-if="!imageCount" class="ae-empty"><text>{{ t("article.noImage") }}</text></view>
|
||||
<template v-else>
|
||||
<text class="ae-images__tip">{{ t("article.imageTapHint") }}</text>
|
||||
<scroll-view scroll-x class="ae-images">
|
||||
<view v-for="img in filenames" :key="img.FileName" class="ae-images__item">
|
||||
<image class="ae-images__img" mode="aspectFill"
|
||||
:src="absoluteFileUrl(img.ImageUrl) || backupFileUrl(IMAGE_FOLDER, img.FileName)"
|
||||
@tap="onInsertExisting(img)" />
|
||||
<view class="ae-images__del" @tap.stop="onRemoveImage(img.FileName)">
|
||||
<u-icon name="close" size="20" color="#ffffff" />
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
</view>
|
||||
|
||||
<!-- 关联群组 -->
|
||||
<view class="ae-field">
|
||||
<view class="ae-field__rowhead">
|
||||
<text class="ae-field__label">{{ t("article.fieldGroups") }}</text>
|
||||
<text class="ae-field__add" @tap="onAddGroup">+ {{ t("article.addGroup") }}</text>
|
||||
</view>
|
||||
<view v-if="!groups.length" class="ae-empty"><text>{{ t("article.noGroup") }}</text></view>
|
||||
<view v-for="g in groups" :key="g.FamilyName" class="ae-group">
|
||||
<text class="ae-group__name">{{ g.FamilyLocalName || g.FamilyName }}</text>
|
||||
<text class="ae-group__del" @tap="onRemoveGroup(g.FamilyName)">{{ t("article.delete")
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="article-editor__foot">
|
||||
<view class="article-editor__btn" @tap="emit('close')">
|
||||
<text>{{ t("article.cancel") }}</text>
|
||||
</view>
|
||||
<view class="article-editor__btn article-editor__btn--primary" :class="{ 'is-busy': saving }"
|
||||
@tap="onSave">
|
||||
<text>{{ saving ? t("FilePage.loading") : t("article.save") }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</u-popup>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.article-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 86vh;
|
||||
padding: 32rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
|
||||
background-color: #ffffff;
|
||||
border-radius: 24rpx 24rpx 0 0;
|
||||
}
|
||||
|
||||
.article-editor__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.article-editor__title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.article-editor__scroll {
|
||||
/* 明确封顶高度:scroll-view 在 uni-app/H5 需确定高度才会内部滚动,
|
||||
否则内容(尤其加配图后)撑满把底部取消/保存顶出可视区(同 person-editor / option-picker) */
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
.article-editor__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28rpx;
|
||||
}
|
||||
|
||||
.ae-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.ae-field__label {
|
||||
font-size: 26rpx;
|
||||
color: #5c626b;
|
||||
}
|
||||
|
||||
.ae-field__rowhead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ae-field__add {
|
||||
font-size: 24rpx;
|
||||
color: var(--fg-primary, #2b8fff);
|
||||
}
|
||||
|
||||
.ae-field__input {
|
||||
height: 76rpx;
|
||||
padding: 0 20rpx;
|
||||
font-size: 28rpx;
|
||||
color: #1f2329;
|
||||
background-color: #f5f6f8;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.ae-cover {
|
||||
width: 220rpx;
|
||||
height: 150rpx;
|
||||
overflow: hidden;
|
||||
border-radius: 16rpx;
|
||||
background-color: #f5f6f8;
|
||||
}
|
||||
|
||||
.ae-cover__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ae-cover__ph {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.ae-cover__ph-text {
|
||||
font-size: 22rpx;
|
||||
color: #bfc3c9;
|
||||
}
|
||||
|
||||
.ae-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
padding: 12rpx;
|
||||
background-color: #f5f6f8;
|
||||
border-radius: 16rpx 16rpx 0 0;
|
||||
}
|
||||
|
||||
.ae-toolbar__btn {
|
||||
min-width: 56rpx;
|
||||
padding: 6rpx 16rpx;
|
||||
font-size: 26rpx;
|
||||
color: #5c626b;
|
||||
text-align: center;
|
||||
background-color: #ffffff;
|
||||
border-radius: 10rpx;
|
||||
}
|
||||
|
||||
.ae-toolbar__b {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ae-toolbar__i {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ae-toolbar__u {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ae-editor {
|
||||
min-height: 560rpx;
|
||||
padding: 20rpx;
|
||||
font-size: 28rpx;
|
||||
line-height: 44rpx;
|
||||
color: #1f2329;
|
||||
background-color: #fafbfc;
|
||||
border: 1rpx solid #eceef1;
|
||||
border-radius: 0 0 16rpx 16rpx;
|
||||
}
|
||||
|
||||
.ae-empty {
|
||||
padding: 28rpx;
|
||||
font-size: 24rpx;
|
||||
color: #9aa0a6;
|
||||
text-align: center;
|
||||
background-color: #f7f8fa;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.ae-images {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ae-images__tip {
|
||||
font-size: 22rpx;
|
||||
color: #9aa0a6;
|
||||
}
|
||||
|
||||
.ae-images__item {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 140rpx;
|
||||
height: 140rpx;
|
||||
margin-right: 16rpx;
|
||||
overflow: hidden;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.ae-images__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ae-images__del {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
background-color: rgba(0, 0, 0, 0.45);
|
||||
border-radius: 0 0 0 16rpx;
|
||||
}
|
||||
|
||||
.ae-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20rpx 24rpx;
|
||||
background-color: #f7f8fa;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.ae-group__name {
|
||||
font-size: 28rpx;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.ae-group__del {
|
||||
font-size: 24rpx;
|
||||
color: #f0413e;
|
||||
}
|
||||
|
||||
.article-editor__foot {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
.article-editor__btn {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 88rpx;
|
||||
font-size: 30rpx;
|
||||
color: #5c626b;
|
||||
background-color: #f2f3f5;
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
|
||||
.article-editor__btn--primary {
|
||||
color: #ffffff;
|
||||
background: var(--fg-gradient, #ff9d45);
|
||||
}
|
||||
|
||||
.article-editor__btn--primary.is-busy {
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
@@ -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<void> {
|
||||
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([
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48' fill='none' stroke='#ffffff' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'>",
|
||||
"<path d='M10 12c0-2 1.6-3.6 3.6-3.6H30v30H13.6C11.6 38.4 10 36.8 10 34.8Z'/>",
|
||||
"<path d='M30 8.4h6.4c2 0 3.6 1.6 3.6 3.6v22.8c0 2-1.6 3.6-3.6 3.6H30'/>",
|
||||
"<path d='M15 16h10M15 22h10'/>",
|
||||
"</svg>",
|
||||
].join(""))}`;
|
||||
|
||||
async function loadArticles(): Promise<void> {
|
||||
await articlesApi.loadList();
|
||||
}
|
||||
|
||||
function onToggleArticleSearch(): void {
|
||||
showArticleSearch.value = !showArticleSearch.value;
|
||||
}
|
||||
|
||||
async function onArticleSearch(): Promise<void> {
|
||||
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<void> {
|
||||
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<FamilyTreeData>(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({
|
||||
|
||||
<template>
|
||||
<!-- 列表态显示 tabbar,进详情(或弹层展开)收起;未认证底部弹层也在列表态弹出,须一并隐 tabbar,否则盖住底部按钮 -->
|
||||
<app-page :hide-nav="true" :show-tabbar="!inDetail && !idPopup">
|
||||
<!-- 文章底部编辑层同理:不隐 tabbar 会盖住弹层底部的「取消/保存」(tabbar 与弹层不在同一层叠上下文,z-index 压不过) -->
|
||||
<app-page :hide-nav="true" :show-tabbar="!inDetail && !idPopup && !articleEditorVisible">
|
||||
<view class="tree-page" :style="[{ background: themeBackground }, themeVars]">
|
||||
<!-- ===== 列表态:软底分区面板(标题 + 搜索/新建/伸缩圆钮组 + 白底族谱卡) ===== -->
|
||||
<!-- 隐藏原生滚动条:否则 Chrome 滚动条占右侧 ~17px,面板左右留白不对称 -->
|
||||
@@ -1634,6 +1692,69 @@ defineExpose({
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
<!-- ===== 有家文化传承:与族谱面板并列的文章卡 ===== -->
|
||||
<view class="tree-panel article-panel">
|
||||
<view class="tree-panel__head">
|
||||
<text class="tree-panel__title">{{ t("article.title") }}</text>
|
||||
<view class="tree-panel__acts">
|
||||
<view class="tree-panel__act" @tap="onToggleArticleSearch">
|
||||
<u-icon name="search" size="34" :color="primary" />
|
||||
</view>
|
||||
<view class="tree-panel__act tree-panel__act--add" @tap="onCreateArticle">
|
||||
<u-icon name="plus" size="30" color="#ffffff" />
|
||||
</view>
|
||||
<view class="tree-panel__act" @tap="articleCollapsed = !articleCollapsed">
|
||||
<u-icon :name="articleCollapsed ? 'arrow-up' : 'arrow-down'" size="34" :color="primary" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="showArticleSearch" class="tree-panel__search">
|
||||
<search-input v-model="articleKeyword" :placeholder="t('article.searchPlaceholder')"
|
||||
@search="onArticleSearch" />
|
||||
</view>
|
||||
<view v-if="!articleCollapsed" class="tree-panel__list">
|
||||
<view v-if="articlesApi.loading.value" class="tree-page__state">
|
||||
<text class="tree-page__state-text">{{ t("FilePage.loading") }}</text>
|
||||
</view>
|
||||
<view v-else-if="articlesApi.errorText.value" class="tree-page__state">
|
||||
<text class="tree-page__state-text tree-page__state-text--error">{{ articlesApi.errorText.value }}</text>
|
||||
<text class="tree-page__retry" @click="loadArticles">{{ t("FilePage.retryBut") }}</text>
|
||||
</view>
|
||||
<template v-else>
|
||||
<view v-if="!homeArticles.length" class="tree-page__empty">
|
||||
<text class="tree-page__state-text">{{ t("article.empty") }}</text>
|
||||
</view>
|
||||
<view v-for="row in homeArticles" :key="row.ArticleName" class="article-card"
|
||||
@tap="onOpenArticle(row.ArticleName)">
|
||||
<view class="article-card__cover">
|
||||
<image v-if="row.CoverUrl || row.ArticleCoverFilename" class="article-card__cover-img"
|
||||
:src="absoluteFileUrl(row.CoverUrl) || backupFileUrl('ArticleCover', row.ArticleCoverFilename)"
|
||||
mode="aspectFill" />
|
||||
<image v-else class="article-card__glyph" :src="articleGlyph" mode="aspectFit" />
|
||||
</view>
|
||||
<view class="article-card__main">
|
||||
<view class="article-card__titlerow">
|
||||
<text class="article-card__name">{{ row.ArticleTitle || t("article.unnamed") }}</text>
|
||||
<text v-if="row.TopIndc === 'Y'" class="article-card__chip article-card__chip--top">{{
|
||||
t("article.tagTop") }}</text>
|
||||
<text v-if="row.ApprovedIndc !== 'Y'" class="article-card__chip article-card__chip--pending">{{
|
||||
t("article.tagPending") }}</text>
|
||||
</view>
|
||||
<text class="article-card__desc">{{ articlesApi.plainText(row.Brief, 100) || t("article.noBrief")
|
||||
}}</text>
|
||||
<view class="article-card__meta">
|
||||
<image v-if="row.AuthorAvatarUrl" class="article-card__avatar"
|
||||
:src="absoluteFileUrl(row.AuthorAvatarUrl)" mode="aspectFill" />
|
||||
<view v-else class="article-card__avatar article-card__avatar--empty" />
|
||||
<text class="article-card__author">{{ row.AuthorLocalName || t("article.unknownAuthor") }}</text>
|
||||
<text class="article-card__date">{{ articlesApi.formatDate(row.CreatedDatetime) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="30" color="#c0c4cc" />
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- ===== 详情态:族谱管理 ===== -->
|
||||
@@ -2698,6 +2819,10 @@ defineExpose({
|
||||
</view>
|
||||
</view>
|
||||
</u-popup>
|
||||
|
||||
<!-- 有家文化传承:首页「+」新建底部编辑层(不另开页) -->
|
||||
<article-editor :visible="articleEditorVisible" :api="articlesApi" @close="closeArticleEditor"
|
||||
@saved="onArticleSaved" />
|
||||
</view>
|
||||
</app-page>
|
||||
</template>
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 里的 <img> 默认按原图尺寸渲染,手机屏宽不够就横向撑爆 */
|
||||
const FIT_IMG_STYLE = "max-width:100%;height:auto;display:block;";
|
||||
|
||||
/** 给正文 HTML 里所有 <img> 烘上限宽内联样式(跨端统一口径)。
|
||||
* scoped 样式穿透不了 rich-text 内容,只能改 HTML 本身;已存 style 的合并并去掉旧尺寸约束。
|
||||
* 在灌编辑器前调用:样式随 getContents 回写进 Brief 落库,只读态与重开编辑同一效果。 */
|
||||
export function fitBriefImages(html: string | undefined): string {
|
||||
return String(html ?? "").replace(/<img\b[^>]*>/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(/^<img/i, `<img style="${FIT_IMG_STYLE}"`);
|
||||
});
|
||||
}
|
||||
|
||||
/** 展示用 Brief:限图宽 + 把内联 img src 归一到当前端可达绝对地址(只读 rich-text 与编辑器共用,修 App 上正文插图不显示) */
|
||||
export function briefForDisplay(html?: string | null): string {
|
||||
return mapBriefImageSrc(fitBriefImages(String(html ?? "")), "display");
|
||||
}
|
||||
|
||||
/** 存库用 Brief:限图宽 + 把内联 img src 剥成 host 无关相对路径 /FileServer/...(跨环境可移植) */
|
||||
export function briefForStore(html: string): string {
|
||||
return mapBriefImageSrc(fitBriefImages(html), "store");
|
||||
}
|
||||
|
||||
export function useArticles() {
|
||||
const { t } = useLocale();
|
||||
|
||||
// ===== 列表态 =====
|
||||
const list = ref<ArticleDisplayData[]>([]);
|
||||
const loading = ref(false);
|
||||
const errorText = ref("");
|
||||
|
||||
// ===== 详情 / 编辑态 =====
|
||||
const detail = ref<ArticleData | null>(null);
|
||||
const creating = ref(false);
|
||||
const isEdit = ref(false);
|
||||
|
||||
const article = computed<ArticleDisplayData | null>(
|
||||
() => detail.value?.Article ?? null,
|
||||
);
|
||||
const groups = computed<ArticleFamilyGroupDisplayData[]>(
|
||||
() => detail.value?.FamilyGroups ?? [],
|
||||
);
|
||||
const filenames = computed<ArticleFilenameDisplayData[]>(
|
||||
() => 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const name = article.value?.ArticleName;
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
await articleService.Delete(name);
|
||||
await exitDetail();
|
||||
}
|
||||
|
||||
/** 行内即时切换审核/置顶:改本地整包后立即 save 落库(对齐相册即时保存模式) */
|
||||
async function setFlag(
|
||||
field: "ApprovedIndc" | "TopIndc",
|
||||
on: boolean,
|
||||
): Promise<void> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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[] = [];
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
|
||||
import { ArticleFamilyGroupEntity } from "./articleFamilyGroupEntity";
|
||||
export class ArticleFamilyGroupDisplayData extends ArticleFamilyGroupEntity
|
||||
{
|
||||
public FamilyLocalName: string = "";
|
||||
public Notice: string = "";
|
||||
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
|
||||
export class ArticleFamilyGroupEntityKey
|
||||
{
|
||||
public ArticleName: string = "";
|
||||
public FamilyName: string = "";
|
||||
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { ArticleFilenameEntity } from "./articleFilenameEntity";
|
||||
export class ArticleFilenameDisplayData extends ArticleFilenameEntity {
|
||||
public ImageUrl: string = "";
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
|
||||
import { ArticleDisplayData } from "./articleDisplayData";
|
||||
import { SearchCriteriaResultData } from "./searchCriteriaResultData";
|
||||
export class ArticleResultData extends SearchCriteriaResultData
|
||||
{
|
||||
public Datas: ArticleDisplayData[] = [];
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
import { AbstractSearchCriteria } from "./abstractSearchCriteria";
|
||||
export class ArticleSearchCriteria extends AbstractSearchCriteria
|
||||
{
|
||||
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "密码",
|
||||
|
||||
@@ -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 | `<img>` 内联烘上限宽样式,见 §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 `<editor>`:正文富文本、封面上传、配图上传/复用、保存回写 |
|
||||
| 详情页 | `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` / `<editor>` 里的 `<img>` 默认按**原图尺寸**渲染,手机屏宽不够就横向撑爆;且 scoped 样式穿不透 `rich-text` 内容,只能改 HTML 本身。`fitBriefImages(html)` 给每个 `<img>` 剥掉 `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"`——**关闭即销毁**,重开时内部 `<editor>` 是全新实例;而模块级 `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`。
|
||||
@@ -155,6 +155,10 @@
|
||||
// 共享大厅(短地址 /share-hall):文件页快捷入口跳入,家人共享目录浏览与组内建目录
|
||||
{
|
||||
"path": "pages/share-hall/share-hall"
|
||||
},
|
||||
// 有家文化传承文章详情(短地址 /article):首页文章卡跳入,只读/管理两态 + 底部富文本编辑
|
||||
{
|
||||
"path": "pages/article/article"
|
||||
}
|
||||
],
|
||||
// 全局配置
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 有家文化传承「文章」详情页(短地址 /article):图2 只读态与图3 管理态同页两态。
|
||||
* 入参经 query:name=<ArticleName> 打开某篇文章;mode=create 直接进新建态并弹底部编辑层。
|
||||
* 数据链全在 composables/useArticles,本页只渲染 + 把点击转成调用;编辑复用 components/article-editor。
|
||||
* 角色分权(后端 FindByKey 下发 IsOwner/IsManager/IsMember,前端只负责显隐):
|
||||
* - 只读态(普通成员,图2):封面 + 标题 + 富文本正文;未审核非作者后端已拦,进不来这里;
|
||||
* - 管理态(作者/群管理员,图3):顶部封面卡(编号 + 编辑/删除)+ 概览/图片/群组三段 Tab,
|
||||
* 概览含「已审核 / 置顶」行内即时开关(改后立即 Update 落库)。
|
||||
*/
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { useLocale } from "uview-pro";
|
||||
import { computed, ref } from "vue";
|
||||
import { briefForDisplay, useArticles, useThemeGradient, useThemeTokens } from "@/composables";
|
||||
import { smartBack } from "@/router/routes";
|
||||
import { absoluteFileUrl, backupFileUrl } from "@/utils/fileUrl";
|
||||
|
||||
const { t } = useLocale();
|
||||
const { themeBackground } = useThemeGradient();
|
||||
const { primary } = useThemeTokens();
|
||||
|
||||
const api = useArticles();
|
||||
const { article, groups, filenames, loading, errorText, coverUrl } = api;
|
||||
|
||||
/** 底部编辑弹层可见(新建/编辑共用) */
|
||||
const editorVisible = ref(false);
|
||||
/** 管理态三段 Tab:概览 / 图片 / 群组 */
|
||||
const activeTab = ref<"overview" | "images" | "groups">("overview");
|
||||
|
||||
/** 管理态:作者或任一关联群组管理员(图3);否则只读态(图2) */
|
||||
const manageMode = computed(() => !!article.value && api.canManage.value);
|
||||
|
||||
async function openByQuery(name: string): Promise<void> {
|
||||
await api.openArticle(name);
|
||||
if (errorText.value) {
|
||||
uni.showToast({ title: errorText.value, icon: "none" });
|
||||
}
|
||||
}
|
||||
|
||||
onLoad(async (query) => {
|
||||
const mode = String(query?.mode || "");
|
||||
const name = decodeURIComponent(String(query?.name || ""));
|
||||
if (mode === "create") {
|
||||
api.startCreate();
|
||||
editorVisible.value = true;
|
||||
return;
|
||||
}
|
||||
if (name) {
|
||||
await openByQuery(name);
|
||||
}
|
||||
});
|
||||
|
||||
function onBack(): void {
|
||||
smartBack();
|
||||
}
|
||||
|
||||
function onEdit(): void {
|
||||
api.startEdit();
|
||||
editorVisible.value = true;
|
||||
}
|
||||
|
||||
function onCloseEditor(): void {
|
||||
editorVisible.value = false;
|
||||
// 新建态取消:无落库直接退出;编辑态取消:丢弃本地改动重拉详情
|
||||
if (api.creating.value) {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
void api.cancelEdit();
|
||||
}
|
||||
|
||||
async function onEditorSaved(): Promise<void> {
|
||||
editorVisible.value = false;
|
||||
if (article.value?.ArticleName) {
|
||||
await api.openArticle(article.value.ArticleName);
|
||||
}
|
||||
}
|
||||
|
||||
function onApproveToggle(on: boolean): void {
|
||||
void runFlag("ApprovedIndc", on);
|
||||
}
|
||||
|
||||
function onTopToggle(on: boolean): void {
|
||||
void runFlag("TopIndc", on);
|
||||
}
|
||||
|
||||
async function runFlag(field: "ApprovedIndc" | "TopIndc", on: boolean): Promise<void> {
|
||||
try {
|
||||
await api.setFlag(field, on);
|
||||
}
|
||||
catch (error: any) {
|
||||
uni.showToast({ title: error?.message || t("FilePage.actionFailed"), icon: "none" });
|
||||
// 失败回滚本地:重拉详情还原开关
|
||||
if (article.value?.ArticleName) {
|
||||
await api.openArticle(article.value.ArticleName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete(): void {
|
||||
uni.showModal({
|
||||
title: t("article.deleteTitle"),
|
||||
content: t("article.deleteConfirm"),
|
||||
confirmColor: primary.value,
|
||||
success: async (res) => {
|
||||
if (!res.confirm) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.remove();
|
||||
onBack();
|
||||
}
|
||||
catch (error: any) {
|
||||
uni.showToast({ title: error?.message || t("FilePage.actionFailed"), icon: "none" });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function imageSrc(row: { ImageUrl: string; FileName: string }): string {
|
||||
// 已保存态后端下发打包好的 ImageUrl;极端缺失时按落盘口径拼直链,不拼裸物理名坏地址
|
||||
return absoluteFileUrl(row.ImageUrl) || backupFileUrl("ArticleImage", row.FileName);
|
||||
}
|
||||
|
||||
/** 只读态正文:给插图烘上限宽内联样式(scoped 穿透不了 rich-text,只能改 HTML)并把内联 img src 归一到当前端可达地址(修 App 上正文插图不显示) */
|
||||
const briefHtml = computed(() => briefForDisplay(article.value?.Brief));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<app-page :hide-nav="true" :show-tabbar="false">
|
||||
<view class="article-page" :style="{ background: themeBackground }">
|
||||
<view class="article-page__head">
|
||||
<hero-navbar :title="t('article.title')" @back="onBack" />
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="article-page__state">
|
||||
<text class="article-page__state-text">{{ t("FilePage.loading") }}</text>
|
||||
</view>
|
||||
<view v-else-if="errorText && !article" class="article-page__state">
|
||||
<text class="article-page__state-text article-page__state-text--error">{{ errorText }}</text>
|
||||
</view>
|
||||
|
||||
<scroll-view v-else-if="article" scroll-y class="article-page__scroll">
|
||||
<!-- ===== 管理态头卡(图3):顶部封面 + 下方编号行(编号 + 编辑/删除)===== -->
|
||||
<template v-if="manageMode">
|
||||
<view class="ac-hero">
|
||||
<image v-if="coverUrl" class="ac-hero__cover" :src="coverUrl" mode="aspectFill" />
|
||||
<view class="ac-hero__bar">
|
||||
<view class="ac-hero__code">
|
||||
<text class="ac-hero__code-text">{{ article.ArticleName }}</text>
|
||||
</view>
|
||||
<view class="ac-hero__acts">
|
||||
<view class="ac-hero__act" @tap="onEdit">
|
||||
<u-icon name="edit-pen" size="30" :color="primary" />
|
||||
</view>
|
||||
<view class="ac-hero__act ac-hero__act--del" @tap="onDelete">
|
||||
<u-icon name="trash" size="30" color="#f0413e" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 三段 Tab -->
|
||||
<view class="ac-tabs">
|
||||
<view class="ac-tab" :class="{ 'ac-tab--on': activeTab === 'overview' }"
|
||||
@tap="activeTab = 'overview'">
|
||||
<text>{{ t("article.tabOverview") }}</text>
|
||||
</view>
|
||||
<view class="ac-tab" :class="{ 'ac-tab--on': activeTab === 'images' }"
|
||||
@tap="activeTab = 'images'">
|
||||
<text>{{ t("article.tabImages") }}</text>
|
||||
<text class="ac-tab__cnt">{{ filenames.length }}</text>
|
||||
</view>
|
||||
<view class="ac-tab" :class="{ 'ac-tab--on': activeTab === 'groups' }"
|
||||
@tap="activeTab = 'groups'">
|
||||
<text>{{ t("article.tabGroups") }}</text>
|
||||
<text class="ac-tab__cnt">{{ groups.length }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 概览:标题 + 正文 + 已审核/置顶即时开关 -->
|
||||
<view v-show="activeTab === 'overview'" class="ac-card">
|
||||
<view class="ac-field ac-field--row">
|
||||
<text class="ac-field__label">{{ t("article.fieldApproved") }}</text>
|
||||
<u-switch :model-value="article.ApprovedIndc === 'Y'" :active-color="primary"
|
||||
@change="(v: any) => onApproveToggle(!!v)" />
|
||||
</view>
|
||||
<view class="ac-field ac-field--row">
|
||||
<text class="ac-field__label">{{ t("article.fieldTop") }}</text>
|
||||
<u-switch :model-value="article.TopIndc === 'Y'" :active-color="primary"
|
||||
@change="(v: any) => onTopToggle(!!v)" />
|
||||
</view>
|
||||
<view class="ac-divider" />
|
||||
<text class="ac-title">{{ article.ArticleTitle || t("article.unnamed") }}</text>
|
||||
<rich-text class="ac-body" :nodes="briefHtml" />
|
||||
</view>
|
||||
|
||||
<!-- 图片:正文配图明细 -->
|
||||
<view v-show="activeTab === 'images'" class="ac-card">
|
||||
<view v-if="!filenames.length" class="ac-empty"><text>{{ t("article.noImage") }}</text></view>
|
||||
<view v-else class="ac-images">
|
||||
<image v-for="img in filenames" :key="img.FileName" class="ac-images__item"
|
||||
:src="imageSrc(img)" mode="aspectFill" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 群组:关联族谱 -->
|
||||
<view v-show="activeTab === 'groups'" class="ac-card">
|
||||
<view v-if="!groups.length" class="ac-empty"><text>{{ t("article.noGroup") }}</text></view>
|
||||
<view v-for="g in groups" :key="g.FamilyName" class="ac-group">
|
||||
<text class="ac-group__name">{{ g.FamilyLocalName || g.FamilyName }}</text>
|
||||
<text class="ac-group__code">{{ g.FamilyName }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- ===== 只读态(图2):普通成员视图,顶部封面 + 标题 + 作者/日期 + 正文 ===== -->
|
||||
<template v-else>
|
||||
<view class="ar-body">
|
||||
<image v-if="coverUrl" class="ar-cover" :src="coverUrl" mode="aspectFill" />
|
||||
<text class="ar-title">{{ article.ArticleTitle || t("article.unnamed") }}</text>
|
||||
<view class="ar-meta">
|
||||
<image v-if="article.AuthorAvatarUrl" class="ar-meta__avatar"
|
||||
:src="absoluteFileUrl(article.AuthorAvatarUrl)" mode="aspectFill" />
|
||||
<text class="ar-meta__author">{{ article.AuthorLocalName || t("article.unknownAuthor")
|
||||
}}</text>
|
||||
<text class="ar-meta__date">{{ api.formatDate(article.CreatedDatetime) }}</text>
|
||||
</view>
|
||||
<view class="ar-divider" />
|
||||
<rich-text class="ar-content" :nodes="briefHtml" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view class="article-page__space" />
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部编辑弹层(新建 / 编辑复用) -->
|
||||
<article-editor :visible="editorVisible" :api="api" @close="onCloseEditor" @saved="onEditorSaved" />
|
||||
</view>
|
||||
</app-page>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.article-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.article-page__head {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.article-page__scroll {
|
||||
/* box-sizing 必须 border-box:否则 padding 24rpx 会加到 100% 宽之外,
|
||||
右侧内边距溢出屏外的,把日期、正文插图右缘同宽裁掉(管理态/只读态同一滚动容器都受影响) */
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 24rpx;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.article-page__state {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 120rpx 32rpx;
|
||||
}
|
||||
|
||||
.article-page__state-text {
|
||||
font-size: 26rpx;
|
||||
color: #9aa0a6;
|
||||
}
|
||||
|
||||
.article-page__state-text--error {
|
||||
color: #f0413e;
|
||||
}
|
||||
|
||||
.article-page__space {
|
||||
height: 48rpx;
|
||||
}
|
||||
|
||||
/* ===== 管理态头卡 ===== */
|
||||
.ac-hero {
|
||||
overflow: hidden;
|
||||
border-radius: 28rpx;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 8rpx 24rpx rgba(31, 35, 41, 0.06);
|
||||
}
|
||||
|
||||
.ac-hero__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20rpx 28rpx;
|
||||
}
|
||||
|
||||
/* 管理态头卡封面:整宽嵌在卡内,ac-hero 已 overflow:hidden + 圆角,图自动被裁成下圆角卡面 */
|
||||
.ac-hero__cover {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 320rpx;
|
||||
background-color: #f2f3f5;
|
||||
}
|
||||
|
||||
.ac-hero__code-text {
|
||||
font-size: 24rpx;
|
||||
color: #8f959e;
|
||||
}
|
||||
|
||||
.ac-hero__acts {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.ac-hero__act {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
background-color: var(--fg-soft, #eaf4ff);
|
||||
}
|
||||
|
||||
.ac-hero__act--del {
|
||||
background-color: #fdeceb;
|
||||
}
|
||||
|
||||
/* ===== 三段 Tab ===== */
|
||||
.ac-tabs {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
margin: 24rpx 0;
|
||||
}
|
||||
|
||||
.ac-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
padding: 14rpx 28rpx;
|
||||
font-size: 26rpx;
|
||||
color: #5c626b;
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
|
||||
.ac-tab--on {
|
||||
color: #ffffff;
|
||||
background: var(--fg-gradient, #ff9d45);
|
||||
}
|
||||
|
||||
.ac-tab__cnt {
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.ac-card {
|
||||
padding: 28rpx;
|
||||
border-radius: 28rpx;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 8rpx 24rpx rgba(31, 35, 41, 0.06);
|
||||
}
|
||||
|
||||
.ac-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10rpx;
|
||||
padding: 12rpx 0;
|
||||
}
|
||||
|
||||
.ac-field--row {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ac-field__label {
|
||||
font-size: 28rpx;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.ac-divider {
|
||||
height: 1rpx;
|
||||
margin: 20rpx 0;
|
||||
background-color: #eceef1;
|
||||
}
|
||||
|
||||
.ac-title {
|
||||
display: block;
|
||||
margin: 8rpx 0 20rpx;
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.ac-body {
|
||||
font-size: 28rpx;
|
||||
line-height: 46rpx;
|
||||
color: #3d4149;
|
||||
}
|
||||
|
||||
.ac-empty {
|
||||
padding: 60rpx 0;
|
||||
font-size: 26rpx;
|
||||
color: #9aa0a6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ac-images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.ac-images__item {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.ac-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20rpx 0;
|
||||
border-bottom: 1rpx solid #f0f1f3;
|
||||
}
|
||||
|
||||
.ac-group:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ac-group__name {
|
||||
font-size: 28rpx;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.ac-group__code {
|
||||
font-size: 22rpx;
|
||||
color: #bfc3c9;
|
||||
}
|
||||
|
||||
/* ===== 只读态(今日头条式通栏直排:无卡片包裹,标题/作者/正文直接顺排)===== */
|
||||
.ar-body {
|
||||
padding: 8rpx 0 0;
|
||||
}
|
||||
|
||||
/* 只读态顶部封面:自带圆角与背景占位,与正文同宽(受 scroll 24rpx 内边距约束) */
|
||||
.ar-cover {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 360rpx;
|
||||
margin-bottom: 24rpx;
|
||||
border-radius: 20rpx;
|
||||
background-color: #f2f3f5;
|
||||
}
|
||||
|
||||
.ar-title {
|
||||
display: block;
|
||||
font-size: 42rpx;
|
||||
font-weight: 700;
|
||||
line-height: 60rpx;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.ar-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.ar-meta__avatar {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
border-radius: 50%;
|
||||
background-color: #f2f3f5;
|
||||
}
|
||||
|
||||
.ar-meta__author {
|
||||
font-size: 24rpx;
|
||||
color: #5c626b;
|
||||
}
|
||||
|
||||
.ar-meta__date {
|
||||
margin-left: auto;
|
||||
font-size: 22rpx;
|
||||
color: #bfc3c9;
|
||||
}
|
||||
|
||||
.ar-divider {
|
||||
height: 1rpx;
|
||||
margin: 24rpx 0;
|
||||
background-color: #eceef1;
|
||||
}
|
||||
|
||||
.ar-content {
|
||||
font-size: 30rpx;
|
||||
line-height: 52rpx;
|
||||
color: #1f2329;
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ArticleData>(
|
||||
{
|
||||
url: "/Article/FindByKey?key="+data,
|
||||
method: "post",
|
||||
data,
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
async FindBySearchCriteria(data: ArticleSearchCriteria) {
|
||||
return request<ArticleResultData>(
|
||||
{
|
||||
url: "/Article/FindBySearchCriteria",
|
||||
method: "post",
|
||||
data,
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
async Insert(data: ArticleData) {
|
||||
return request<ArticleData>(
|
||||
{
|
||||
url: "/Article/Insert",
|
||||
method: "post",
|
||||
data,
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
async Update(data: ArticleData) {
|
||||
return request<ArticleData>(
|
||||
{
|
||||
url: "/Article/Update",
|
||||
method: "post",
|
||||
data,
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
async Delete(data: string) {
|
||||
return request<any>(
|
||||
{
|
||||
url: "/Article/Delete?key="+data,
|
||||
method: "post",
|
||||
data,
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
const articleService= new ArticleService();
|
||||
export default articleService;
|
||||
@@ -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 里每个 <img> 的 src。
|
||||
*
|
||||
* 为什么单独处理:响应层 normalizePayloadFileUrls 只改「整个字段值就是绝对 FileServer URL」的串,
|
||||
* 而 Brief 是 HTML(以 <p> 开头),absoluteHost 判非绝对 → 漏改。后端 Debug 会把 localhost:5235
|
||||
* 打进正文内联 <img src>,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(
|
||||
/(<img\b[^>]*?\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);
|
||||
|
||||
Reference in new issue
Block a user