feat: add IM org and notification admin

This commit is contained in:
richjohnrambo
2026-08-21 17:54:54 +08:00
parent c89e35d0e0
commit 204f96e07a
22 changed files with 2354 additions and 0 deletions
+42
View File
@@ -121,6 +121,28 @@
## 后续 P1 ## 后续 P1
### P1-0 组织架构管理
状态:部分实现。
已实现:
1. 部门列表。
2. 部门新增、编辑、移动。
3. 部门删除前校验子部门和成员。
4. 部门移动防环校验。
5. 部门成员列表。
6. 成员添加、编辑、调岗、移除。
7. 组织搜索基础接口,覆盖部门和成员。
8. 组织操作写入 IM 后台审计。
待实现/配合:
1. 组织数据权限:当前后台按租户选库隔离,部门级 `org_admin` 数据范围尚未建模。
2. 导入/导出:需要定义 Excel 字段、失败明细和幂等更新策略。
3. 离职用户处理策略:当前支持 `employment_state` 和 `contactable`,客户端/IM 服务端需按该状态裁剪通讯录和可联系入口。
4. 组织版本增量同步:当前维护部门/成员版本号,客户端增量同步协议需由 IM 服务端继续承接。
### P1-1 群治理处置动作 ### P1-1 群治理处置动作
状态:已完成基础后台闭环。 状态:已完成基础后台闭环。
@@ -207,6 +229,26 @@
5. 文件物理清理任务详情:需要文件服务或 IM 服务端回写物理删除结果。 5. 文件物理清理任务详情:需要文件服务或 IM 服务端回写物理删除结果。
6. 跨租户任务执行审计:执行型任务落地后需补充独立权限点和审计明细。 6. 跨租户任务执行审计:执行型任务落地后需补充独立权限点和审计明细。
### P1-5 系统通知模板
状态:部分实现。
已实现:
1. 租户级通知模板列表。
2. 账号安全、好友申请、群事件、文件事件、会议提醒、日报月报提醒、工单处理等默认场景初始化。
3. 站内通知、Push、短信渠道开关。
4. 标题模板、内容模板、扩展 JSON 配置。
5. 模板启用/禁用。
6. 模板变更写入 IM 后台审计。
待实现/配合:
1. 实际投递:需要 IM 服务端 Outbox 消费器或系统 Topic 桥接读取模板并发送。
2. 投递记录:需要 IM 服务端回写站内、Push、短信投递状态和失败原因。
3. 模板变量校验:当前仅校验非空和 JSON 格式,变量白名单、缺失变量检查需要按具体场景定义。
4. 多语言模板:当前为单语言模板,后续如客户端支持多语言需扩展语言维度。
## 后续 P2 ## 后续 P2
1. 工作台模块配置。 1. 工作台模块配置。
@@ -0,0 +1,25 @@
package imnotification
import (
"hotgo/internal/model/input/form"
"hotgo/internal/model/input/sysin"
"github.com/gogf/gf/v2/frame/g"
)
type TemplateListReq struct {
g.Meta `path:"/imNotification/templateList" method:"get" tags:"IM通知模板" summary:"获取IM通知模板列表"`
sysin.ImNotificationTemplateListInp
}
type TemplateListRes struct {
form.PageRes
List []*sysin.ImNotificationTemplateListModel `json:"list" dc:"数据列表"`
}
type TemplateUpdateReq struct {
g.Meta `path:"/imNotification/templateUpdate" method:"post" tags:"IM通知模板" summary:"更新IM通知模板"`
sysin.ImNotificationTemplateUpdateInp
}
type TemplateUpdateRes struct{}
+65
View File
@@ -0,0 +1,65 @@
package imorg
import (
"hotgo/internal/model/input/form"
"hotgo/internal/model/input/sysin"
"github.com/gogf/gf/v2/frame/g"
)
type DepartmentListReq struct {
g.Meta `path:"/imOrg/departmentList" method:"get" tags:"IM组织架构" summary:"获取IM组织部门列表"`
sysin.ImOrgDepartmentListInp
}
type DepartmentListRes struct {
form.PageRes
List []*sysin.ImOrgDepartmentModel `json:"list" dc:"数据列表"`
}
type DepartmentEditReq struct {
g.Meta `path:"/imOrg/departmentEdit" method:"post" tags:"IM组织架构" summary:"新增/编辑IM组织部门"`
sysin.ImOrgDepartmentEditInp
}
type DepartmentEditRes struct{}
type DepartmentDeleteReq struct {
g.Meta `path:"/imOrg/departmentDelete" method:"post" tags:"IM组织架构" summary:"删除IM组织部门"`
sysin.ImOrgDepartmentDeleteInp
}
type DepartmentDeleteRes struct{}
type MemberListReq struct {
g.Meta `path:"/imOrg/memberList" method:"get" tags:"IM组织架构" summary:"获取IM组织成员列表"`
sysin.ImOrgMemberListInp
}
type MemberListRes struct {
form.PageRes
List []*sysin.ImOrgMemberModel `json:"list" dc:"数据列表"`
}
type MemberEditReq struct {
g.Meta `path:"/imOrg/memberEdit" method:"post" tags:"IM组织架构" summary:"新增/编辑IM组织成员"`
sysin.ImOrgMemberEditInp
}
type MemberEditRes struct{}
type MemberDeleteReq struct {
g.Meta `path:"/imOrg/memberDelete" method:"post" tags:"IM组织架构" summary:"删除IM组织成员"`
sysin.ImOrgMemberDeleteInp
}
type MemberDeleteRes struct{}
type SearchReq struct {
g.Meta `path:"/imOrg/search" method:"get" tags:"IM组织架构" summary:"搜索IM组织"`
sysin.ImOrgSearchInp
}
type SearchRes struct {
*sysin.ImOrgSearchModel
}
+39
View File
@@ -0,0 +1,39 @@
package consts
import (
"hotgo/internal/library/dict"
"hotgo/internal/model"
)
func init() {
dict.RegisterEnums("ImNotificationSceneOptions", "IM通知场景选项", ImNotificationSceneOptions)
dict.RegisterEnums("ImNotificationTemplateStatusOptions", "IM通知模板状态选项", ImNotificationTemplateStatusOptions)
}
const (
ImNotificationSceneAccountSecurity = "account_security"
ImNotificationSceneFriendRequest = "friend_request"
ImNotificationSceneGroupEvent = "group_event"
ImNotificationSceneFileEvent = "file_event"
ImNotificationSceneMeetingReminder = "meeting_reminder"
ImNotificationSceneReportReminder = "report_reminder"
ImNotificationSceneWorkOrder = "work_order"
ImNotificationTemplateEnabled = 1
ImNotificationTemplateDisabled = 0
)
var ImNotificationSceneOptions = []*model.Option{
dict.GenPrimaryOption(ImNotificationSceneAccountSecurity, "账号安全"),
dict.GenInfoOption(ImNotificationSceneFriendRequest, "好友申请"),
dict.GenInfoOption(ImNotificationSceneGroupEvent, "群事件"),
dict.GenDefaultOption(ImNotificationSceneFileEvent, "文件事件"),
dict.GenWarningOption(ImNotificationSceneMeetingReminder, "会议提醒"),
dict.GenWarningOption(ImNotificationSceneReportReminder, "日报月报提醒"),
dict.GenErrorOption(ImNotificationSceneWorkOrder, "工单处理"),
}
var ImNotificationTemplateStatusOptions = []*model.Option{
dict.GenSuccessOption(ImNotificationTemplateEnabled, "启用"),
dict.GenWarningOption(ImNotificationTemplateDisabled, "禁用"),
}
+38
View File
@@ -0,0 +1,38 @@
package consts
import (
"hotgo/internal/library/dict"
"hotgo/internal/model"
)
func init() {
dict.RegisterEnums("ImOrgDepartmentStateOptions", "IM组织部门状态选项", ImOrgDepartmentStateOptions)
dict.RegisterEnums("ImOrgEmploymentStateOptions", "IM组织成员在职状态选项", ImOrgEmploymentStateOptions)
dict.RegisterEnums("ImOrgContactableOptions", "IM组织成员可联系选项", ImOrgContactableOptions)
}
const (
ImOrgDepartmentStateActive = "active"
ImOrgDepartmentStateDisabled = "disabled"
ImOrgEmploymentStateActive = "active"
ImOrgEmploymentStateLeft = "left"
ImOrgContactableYes = 1
ImOrgContactableNo = 0
)
var ImOrgDepartmentStateOptions = []*model.Option{
dict.GenSuccessOption(ImOrgDepartmentStateActive, "启用"),
dict.GenWarningOption(ImOrgDepartmentStateDisabled, "停用"),
}
var ImOrgEmploymentStateOptions = []*model.Option{
dict.GenSuccessOption(ImOrgEmploymentStateActive, "在职"),
dict.GenDefaultOption(ImOrgEmploymentStateLeft, "离职"),
}
var ImOrgContactableOptions = []*model.Option{
dict.GenSuccessOption(ImOrgContactableYes, "可联系"),
dict.GenWarningOption(ImOrgContactableNo, "不可联系"),
}
@@ -0,0 +1,31 @@
package sys
import (
"context"
"hotgo/api/admin/imnotification"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
)
var ImNotification = cImNotification{}
type cImNotification struct{}
func (c *cImNotification) TemplateList(ctx context.Context, req *imnotification.TemplateListReq) (res *imnotification.TemplateListRes, err error) {
list, totalCount, err := service.SysImNotification().TemplateList(ctx, &req.ImNotificationTemplateListInp)
if err != nil {
return
}
if list == nil {
list = []*sysin.ImNotificationTemplateListModel{}
}
res = new(imnotification.TemplateListRes)
res.List = list
res.PageRes.Pack(req, totalCount)
return
}
func (c *cImNotification) TemplateUpdate(ctx context.Context, req *imnotification.TemplateUpdateReq) (res *imnotification.TemplateUpdateRes, err error) {
err = service.SysImNotification().TemplateUpdate(ctx, &req.ImNotificationTemplateUpdateInp)
return
}
@@ -0,0 +1,69 @@
package sys
import (
"context"
"hotgo/api/admin/imorg"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
)
var ImOrg = cImOrg{}
type cImOrg struct{}
func (c *cImOrg) DepartmentList(ctx context.Context, req *imorg.DepartmentListReq) (res *imorg.DepartmentListRes, err error) {
list, totalCount, err := service.SysImOrg().DepartmentList(ctx, &req.ImOrgDepartmentListInp)
if err != nil {
return
}
if list == nil {
list = []*sysin.ImOrgDepartmentModel{}
}
res = new(imorg.DepartmentListRes)
res.List = list
res.PageRes.Pack(req, totalCount)
return
}
func (c *cImOrg) DepartmentEdit(ctx context.Context, req *imorg.DepartmentEditReq) (res *imorg.DepartmentEditRes, err error) {
err = service.SysImOrg().DepartmentEdit(ctx, &req.ImOrgDepartmentEditInp)
return
}
func (c *cImOrg) DepartmentDelete(ctx context.Context, req *imorg.DepartmentDeleteReq) (res *imorg.DepartmentDeleteRes, err error) {
err = service.SysImOrg().DepartmentDelete(ctx, &req.ImOrgDepartmentDeleteInp)
return
}
func (c *cImOrg) MemberList(ctx context.Context, req *imorg.MemberListReq) (res *imorg.MemberListRes, err error) {
list, totalCount, err := service.SysImOrg().MemberList(ctx, &req.ImOrgMemberListInp)
if err != nil {
return
}
if list == nil {
list = []*sysin.ImOrgMemberModel{}
}
res = new(imorg.MemberListRes)
res.List = list
res.PageRes.Pack(req, totalCount)
return
}
func (c *cImOrg) MemberEdit(ctx context.Context, req *imorg.MemberEditReq) (res *imorg.MemberEditRes, err error) {
err = service.SysImOrg().MemberEdit(ctx, &req.ImOrgMemberEditInp)
return
}
func (c *cImOrg) MemberDelete(ctx context.Context, req *imorg.MemberDeleteReq) (res *imorg.MemberDeleteRes, err error) {
err = service.SysImOrg().MemberDelete(ctx, &req.ImOrgMemberDeleteInp)
return
}
func (c *cImOrg) Search(ctx context.Context, req *imorg.SearchReq) (res *imorg.SearchRes, err error) {
data, err := service.SysImOrg().Search(ctx, &req.ImOrgSearchInp)
if err != nil {
return
}
res = &imorg.SearchRes{ImOrgSearchModel: data}
return
}
@@ -0,0 +1,186 @@
package sys
import (
"context"
"hotgo/internal/consts"
"hotgo/internal/dao"
"hotgo/internal/library/imtenant"
"hotgo/internal/model/entity"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
type sSysImNotification struct{}
func NewSysImNotification() *sSysImNotification {
return &sSysImNotification{}
}
func init() {
service.RegisterSysImNotification(NewSysImNotification())
}
func (s *sSysImNotification) TemplateList(ctx context.Context, in *sysin.ImNotificationTemplateListInp) (list []*sysin.ImNotificationTemplateListModel, totalCount int, err error) {
if err = in.Filter(ctx); err != nil {
return
}
scope, err := resolveImAdminScopeWithAll(ctx, in.TenantId, true)
if err != nil {
return
}
tenants, err := s.listTenants(ctx, scope.TenantId, in.TenantId)
if err != nil {
return
}
list = []*sysin.ImNotificationTemplateListModel{}
for _, tenant := range tenants {
if err = s.ensureTemplates(ctx, tenant.Id); err != nil {
return
}
var tenantList []*sysin.ImNotificationTemplateListModel
mod := imtenant.RegistryDB().Model("im_notification_template").Safe().Ctx(ctx).
Where("tenant_id", tenant.Id)
if in.Scene != "" {
mod = mod.Where("scene", in.Scene)
}
if in.Status != nil {
mod = mod.Where("status", *in.Status)
}
if err = mod.OrderAsc("id").Scan(&tenantList); err != nil {
return nil, 0, gerror.Wrap(err, "获取IM通知模板失败,请稍后重试!")
}
for _, item := range tenantList {
item.TenantName = tenant.Name
if parsed, parseErr := gjson.DecodeToJson(item.PayloadJson); parseErr == nil && parsed != nil {
item.PayloadJson = parsed.MustToJsonString()
}
}
list = append(list, tenantList...)
}
totalCount = len(list)
start, end := pageBounds(in.Page, in.PerPage, totalCount)
if start >= totalCount {
return []*sysin.ImNotificationTemplateListModel{}, totalCount, nil
}
return list[start:end], totalCount, nil
}
func (s *sSysImNotification) TemplateUpdate(ctx context.Context, in *sysin.ImNotificationTemplateUpdateInp) (err error) {
if err = in.Filter(ctx); err != nil {
return
}
scope, err := resolveImAdminScope(ctx, in.TenantId)
if err != nil {
return
}
if err = s.ensureTemplates(ctx, scope.TenantId); err != nil {
return
}
payload := "{}"
if in.PayloadJson != nil {
payload = in.PayloadJson.MustToJsonString()
}
if _, err = imtenant.RegistryDB().Model("im_notification_template").Safe().Ctx(ctx).
Where("tenant_id", scope.TenantId).
Where("scene", in.Scene).
Data(g.Map{
"in_app_enabled": in.InAppEnabled,
"push_enabled": in.PushEnabled,
"sms_enabled": in.SmsEnabled,
"status": in.Status,
"title_template": in.TitleTemplate,
"content_template": in.ContentTemplate,
"payload_json": payload,
"updated_at": gtime.Now(),
}).Update(); err != nil {
return gerror.Wrap(err, "更新IM通知模板失败,请稍后重试!")
}
_ = writeImAdminAudit(ctx, scope.TenantId, "im_notification_template_update", "im_notification_template", 0, "success", g.Map{"scene": in.Scene})
return nil
}
func (s *sSysImNotification) listTenants(ctx context.Context, scopeTenantId, requestedTenantId int64) (tenants []*entity.ImTenant, err error) {
mod := imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx)
if scopeTenantId > 0 {
mod = mod.WherePri(scopeTenantId)
} else if requestedTenantId > 0 {
mod = mod.WherePri(requestedTenantId)
}
if err = mod.OrderAsc(dao.ImTenant.Columns().Id).Scan(&tenants); err != nil {
return nil, gerror.Wrap(err, "获取IM租户失败,请稍后重试!")
}
return tenants, nil
}
func (s *sSysImNotification) ensureTemplates(ctx context.Context, tenantId int64) error {
for _, item := range defaultNotificationTemplates() {
count, err := imtenant.RegistryDB().Model("im_notification_template").Safe().Ctx(ctx).
Where("tenant_id", tenantId).
Where("scene", item.Scene).
Count()
if err != nil {
return gerror.Wrap(err, "检查IM通知模板失败,请稍后重试!")
}
if count > 0 {
continue
}
if _, err = imtenant.RegistryDB().Model("im_notification_template").Safe().Ctx(ctx).Data(g.Map{
"tenant_id": tenantId,
"scene": item.Scene,
"name": item.Name,
"in_app_enabled": 1,
"push_enabled": item.PushEnabled,
"sms_enabled": 0,
"status": consts.ImNotificationTemplateEnabled,
"title_template": item.Title,
"content_template": item.Content,
"payload_json": gdb.Raw("CAST('{}' AS JSON)"),
"created_at": gtime.Now(),
"updated_at": gtime.Now(),
}).Insert(); err != nil {
return gerror.Wrap(err, "初始化IM通知模板失败,请稍后重试!")
}
}
return nil
}
type notificationTemplateSeed struct {
Scene string
Name string
Title string
Content string
PushEnabled int
}
func defaultNotificationTemplates() []*notificationTemplateSeed {
return []*notificationTemplateSeed{
{Scene: consts.ImNotificationSceneAccountSecurity, Name: "账号安全", Title: "账号安全提醒", Content: "你的账号发生安全事件:{{event}}", PushEnabled: 1},
{Scene: consts.ImNotificationSceneFriendRequest, Name: "好友申请", Title: "新的好友申请", Content: "{{user}} 请求添加你为好友", PushEnabled: 1},
{Scene: consts.ImNotificationSceneGroupEvent, Name: "群事件", Title: "群通知", Content: "{{group}} 有新的群事件:{{event}}", PushEnabled: 1},
{Scene: consts.ImNotificationSceneFileEvent, Name: "文件事件", Title: "文件通知", Content: "{{file}} 状态已更新", PushEnabled: 0},
{Scene: consts.ImNotificationSceneMeetingReminder, Name: "会议提醒", Title: "会议提醒", Content: "{{meeting}} 将于 {{time}} 开始", PushEnabled: 1},
{Scene: consts.ImNotificationSceneReportReminder, Name: "日报月报提醒", Title: "填报提醒", Content: "请及时提交 {{report_type}}", PushEnabled: 1},
{Scene: consts.ImNotificationSceneWorkOrder, Name: "工单处理", Title: "工单状态更新", Content: "你的工单 {{ticket}} 已更新", PushEnabled: 1},
}
}
func pageBounds(page, perPage, total int) (start, end int) {
if page <= 0 {
page = 1
}
if perPage <= 0 {
perPage = 10
}
start = (page - 1) * perPage
end = start + perPage
if end > total {
end = total
}
return
}
+362
View File
@@ -0,0 +1,362 @@
package sys
import (
"context"
"hotgo/internal/consts"
"hotgo/internal/model/input/form"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"strings"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/guid"
)
type sSysImOrg struct{}
func NewSysImOrg() *sSysImOrg {
return &sSysImOrg{}
}
func init() {
service.RegisterSysImOrg(NewSysImOrg())
}
func (s *sSysImOrg) DepartmentList(ctx context.Context, in *sysin.ImOrgDepartmentListInp) (list []*sysin.ImOrgDepartmentModel, totalCount int, err error) {
if err = in.Filter(ctx); err != nil {
return
}
db, tenant, err := resolveImTenantDB(ctx, in.TenantId)
if err != nil {
return
}
mod := s.departmentModel(ctx, db)
if in.DepartmentId != "" {
mod = mod.Where("d.department_id", strings.TrimSpace(in.DepartmentId))
}
if in.ParentId != "" {
mod = mod.Where("d.parent_id", strings.TrimSpace(in.ParentId))
}
if in.Name != "" {
mod = mod.WhereLike("d.name", "%"+strings.TrimSpace(in.Name)+"%")
}
if in.State != "" {
mod = mod.Where("d.state", strings.TrimSpace(in.State))
}
mod = mod.Page(in.Page, in.PerPage).OrderAsc("d.sort_order").OrderAsc("d.id")
if err = mod.ScanAndCount(&list, &totalCount, false); err != nil {
err = gerror.Wrap(err, "获取IM组织部门失败,请稍后重试!")
return
}
for _, item := range list {
item.TenantId = tenant.Id
item.TenantName = tenant.Name
}
return
}
func (s *sSysImOrg) DepartmentEdit(ctx context.Context, in *sysin.ImOrgDepartmentEditInp) (err error) {
if err = in.Filter(ctx); err != nil {
return
}
db, tenant, err := resolveImTenantDB(ctx, in.TenantId)
if err != nil {
return
}
if in.ParentId != "root" {
if exists, err := s.departmentExists(ctx, db, in.ParentId); err != nil {
return err
} else if !exists {
return gerror.New("上级部门不存在")
}
}
if in.Id > 0 {
current, err := db.Model("im_org_departments").Safe().Ctx(ctx).Where("id", in.Id).One()
if err != nil {
return gerror.Wrap(err, "获取IM组织部门失败,请稍后重试!")
}
if current.IsEmpty() {
return gerror.New("部门不存在")
}
departmentId := current["department_id"].String()
if in.ParentId == departmentId {
return gerror.New("上级部门不能选择自身")
}
if err = s.ensureNoDepartmentCycle(ctx, db, departmentId, in.ParentId); err != nil {
return err
}
if _, err = db.Model("im_org_departments").Safe().Ctx(ctx).
Where("id", in.Id).
Data(g.Map{
"name": in.Name,
"parent_id": in.ParentId,
"sort_order": in.SortOrder,
"state": in.State,
"version": gdb.Raw("version+1"),
"updated_at": gtime.Now(),
}).Update(); err != nil {
return gerror.Wrap(err, "更新IM组织部门失败,请稍后重试!")
}
_ = writeImAdminAudit(ctx, tenant.Id, "im_org_department_update", "im_org_department", in.Id, "success", g.Map{"department_id": departmentId})
return nil
}
departmentId := in.DepartmentId
if departmentId == "" {
departmentId = "dep_" + strings.ToLower(guid.S())
}
if exists, err := s.departmentExists(ctx, db, departmentId); err != nil {
return err
} else if exists {
return gerror.New("部门ID已存在")
}
if _, err = db.Model("im_org_departments").Safe().Ctx(ctx).Data(g.Map{
"department_id": departmentId,
"name": in.Name,
"parent_id": in.ParentId,
"sort_order": in.SortOrder,
"state": in.State,
"version": 1,
"created_at": gtime.Now(),
"updated_at": gtime.Now(),
}).Insert(); err != nil {
return gerror.Wrap(err, "创建IM组织部门失败,请稍后重试!")
}
_ = writeImAdminAudit(ctx, tenant.Id, "im_org_department_create", "im_org_department", 0, "success", g.Map{"department_id": departmentId})
return nil
}
func (s *sSysImOrg) DepartmentDelete(ctx context.Context, in *sysin.ImOrgDepartmentDeleteInp) (err error) {
if err = in.Filter(ctx); err != nil {
return
}
db, tenant, err := resolveImTenantDB(ctx, in.TenantId)
if err != nil {
return
}
if in.DepartmentId == "root" {
return gerror.New("根部门不能删除")
}
if exists, err := s.departmentExists(ctx, db, in.DepartmentId); err != nil {
return err
} else if !exists {
return gerror.New("部门不存在")
}
childCount, err := db.Model("im_org_departments").Safe().Ctx(ctx).Where("parent_id", in.DepartmentId).Count()
if err != nil {
return gerror.Wrap(err, "检查子部门失败,请稍后重试!")
}
if childCount > 0 {
return gerror.New("存在子部门,请先处理后再删除")
}
memberCount, err := db.Model("im_org_members").Safe().Ctx(ctx).Where("department_id", in.DepartmentId).Count()
if err != nil {
return gerror.Wrap(err, "检查部门成员失败,请稍后重试!")
}
if memberCount > 0 {
return gerror.New("存在部门成员,请先移除后再删除")
}
if _, err = db.Model("im_org_departments").Safe().Ctx(ctx).Where("department_id", in.DepartmentId).Delete(); err != nil {
return gerror.Wrap(err, "删除IM组织部门失败,请稍后重试!")
}
_ = writeImAdminAudit(ctx, tenant.Id, "im_org_department_delete", "im_org_department", 0, "success", g.Map{"department_id": in.DepartmentId})
return nil
}
func (s *sSysImOrg) MemberList(ctx context.Context, in *sysin.ImOrgMemberListInp) (list []*sysin.ImOrgMemberModel, totalCount int, err error) {
if err = in.Filter(ctx); err != nil {
return
}
db, tenant, err := resolveImTenantDB(ctx, in.TenantId)
if err != nil {
return
}
mod := s.memberModel(ctx, db)
if in.DepartmentId != "" {
mod = mod.Where("m.department_id", strings.TrimSpace(in.DepartmentId))
}
if in.UserId > 0 {
mod = mod.Where("m.user_id", in.UserId)
}
if in.EmploymentState != "" {
mod = mod.Where("m.employment_state", strings.TrimSpace(in.EmploymentState))
}
if q := strings.TrimSpace(in.Query); q != "" {
like := "%" + q + "%"
mod = mod.Where("(m.display_name LIKE ? OR m.mobile LIKE ? OR m.email LIKE ? OR CAST(m.user_id AS CHAR) LIKE ?)", like, like, like, like)
}
mod = mod.Page(in.Page, in.PerPage).OrderAsc("m.sort_order").OrderAsc("m.id")
if err = mod.ScanAndCount(&list, &totalCount, false); err != nil {
err = gerror.Wrap(err, "获取IM组织成员失败,请稍后重试!")
return
}
for _, item := range list {
item.TenantId = tenant.Id
item.TenantName = tenant.Name
}
return
}
func (s *sSysImOrg) MemberEdit(ctx context.Context, in *sysin.ImOrgMemberEditInp) (err error) {
if err = in.Filter(ctx); err != nil {
return
}
db, tenant, err := resolveImTenantDB(ctx, in.TenantId)
if err != nil {
return
}
if exists, err := s.departmentExists(ctx, db, in.DepartmentId); err != nil {
return err
} else if !exists {
return gerror.New("部门不存在")
}
userCount, err := db.Model("im_users").Safe().Ctx(ctx).Where("id", in.UserId).Count()
if err != nil {
return gerror.Wrap(err, "检查IM用户失败,请稍后重试!")
}
if userCount == 0 {
return gerror.New("IM用户不存在")
}
data := g.Map{
"department_id": in.DepartmentId,
"user_id": in.UserId,
"display_name": in.DisplayName,
"position": in.Position,
"avatar_file_id": in.AvatarFileId,
"mobile": in.Mobile,
"email": in.Email,
"employment_state": in.EmploymentState,
"contactable": in.Contactable,
"sort_order": in.SortOrder,
"updated_at": gtime.Now(),
}
if in.Id > 0 {
data["version"] = gdb.Raw("version+1")
if _, err = db.Model("im_org_members").Safe().Ctx(ctx).Where("id", in.Id).Data(data).Update(); err != nil {
return gerror.Wrap(err, "更新IM组织成员失败,请稍后重试!")
}
_ = writeImAdminAudit(ctx, tenant.Id, "im_org_member_update", "im_org_member", in.Id, "success", g.Map{"department_id": in.DepartmentId, "user_id": in.UserId})
return nil
}
data["version"] = 1
data["created_at"] = gtime.Now()
if _, err = db.Model("im_org_members").Safe().Ctx(ctx).Data(data).Insert(); err != nil {
return gerror.Wrap(err, "添加IM组织成员失败,请稍后重试!")
}
_ = writeImAdminAudit(ctx, tenant.Id, "im_org_member_create", "im_org_member", 0, "success", g.Map{"department_id": in.DepartmentId, "user_id": in.UserId})
return nil
}
func (s *sSysImOrg) MemberDelete(ctx context.Context, in *sysin.ImOrgMemberDeleteInp) (err error) {
if err = in.Filter(ctx); err != nil {
return
}
db, tenant, err := resolveImTenantDB(ctx, in.TenantId)
if err != nil {
return
}
current, err := db.Model("im_org_members").Safe().Ctx(ctx).Where("id", in.Id).One()
if err != nil {
return gerror.Wrap(err, "获取IM组织成员失败,请稍后重试!")
}
if current.IsEmpty() {
return gerror.New("组织成员不存在")
}
if _, err = db.Model("im_org_members").Safe().Ctx(ctx).Where("id", in.Id).Delete(); err != nil {
return gerror.Wrap(err, "删除IM组织成员失败,请稍后重试!")
}
_ = writeImAdminAudit(ctx, tenant.Id, "im_org_member_delete", "im_org_member", in.Id, "success", g.Map{"department_id": current["department_id"].String(), "user_id": current["user_id"].Int64()})
return nil
}
func (s *sSysImOrg) Search(ctx context.Context, in *sysin.ImOrgSearchInp) (res *sysin.ImOrgSearchModel, err error) {
if err = in.Filter(ctx); err != nil {
return
}
departments, _, err := s.DepartmentList(ctx, &sysin.ImOrgDepartmentListInp{
PageReq: form.PageReq{Page: 1, PerPage: 20, Pagination: true},
TenantId: in.TenantId,
Name: in.Query,
State: consts.ImOrgDepartmentStateActive,
})
if err != nil {
return nil, err
}
members, _, err := s.MemberList(ctx, &sysin.ImOrgMemberListInp{
PageReq: form.PageReq{Page: 1, PerPage: 20, Pagination: true},
TenantId: in.TenantId,
Query: in.Query,
EmploymentState: consts.ImOrgEmploymentStateActive,
})
if err != nil {
return nil, err
}
res = &sysin.ImOrgSearchModel{Departments: departments, Members: members}
return
}
func (s *sSysImOrg) departmentModel(ctx context.Context, db gdb.DB) *gdb.Model {
return db.Model("im_org_departments d").Safe().Ctx(ctx).Fields(
"d.id",
"d.department_id",
"d.name",
"d.parent_id",
"d.sort_order",
"d.version",
"d.state",
"d.created_at",
"d.updated_at",
"(SELECT COUNT(*) FROM im_org_departments child WHERE child.parent_id=d.department_id AND child.state='active') child_count",
"(SELECT COUNT(*) FROM im_org_members m WHERE m.department_id=d.department_id) member_count",
)
}
func (s *sSysImOrg) memberModel(ctx context.Context, db gdb.DB) *gdb.Model {
return db.Model("im_org_members m").Safe().Ctx(ctx).
LeftJoin("im_org_departments d", "d.department_id=m.department_id").
Fields(
"m.id",
"m.department_id",
"COALESCE(d.name,'') department_name",
"m.user_id",
"m.display_name",
"m.position",
"m.avatar_file_id",
"m.mobile",
"m.email",
"m.employment_state",
"m.contactable",
"m.sort_order",
"m.version",
"m.created_at",
"m.updated_at",
)
}
func (s *sSysImOrg) departmentExists(ctx context.Context, db gdb.DB, departmentId string) (bool, error) {
count, err := db.Model("im_org_departments").Safe().Ctx(ctx).Where("department_id", strings.TrimSpace(departmentId)).Count()
if err != nil {
return false, gerror.Wrap(err, "检查IM组织部门失败,请稍后重试!")
}
return count > 0, nil
}
func (s *sSysImOrg) ensureNoDepartmentCycle(ctx context.Context, db gdb.DB, departmentId, parentId string) error {
next := strings.TrimSpace(parentId)
for next != "" && next != "root" {
if next == departmentId {
return gerror.New("上级部门不能选择自身或下级部门")
}
row, err := db.Model("im_org_departments").Safe().Ctx(ctx).Fields("parent_id").Where("department_id", next).One()
if err != nil {
return gerror.Wrap(err, "检查部门层级失败,请稍后重试!")
}
if row.IsEmpty() {
return gerror.New("上级部门不存在")
}
next = row["parent_id"].String()
}
return nil
}
@@ -0,0 +1,69 @@
package sysin
import (
"context"
"hotgo/internal/consts"
"hotgo/internal/library/dict"
"hotgo/internal/model/input/form"
"strings"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
)
type ImNotificationTemplateListInp struct {
form.PageReq
TenantId int64 `json:"tenantId" dc:"租户ID"`
Scene string `json:"scene" dc:"通知场景"`
Status *int `json:"status" dc:"状态"`
}
func (in *ImNotificationTemplateListInp) Filter(ctx context.Context) (err error) { return }
type ImNotificationTemplateListModel struct {
TenantId int64 `json:"tenantId" dc:"租户ID"`
TenantName string `json:"tenantName" dc:"租户名称"`
Id int64 `json:"id" dc:"ID"`
Scene string `json:"scene" dc:"通知场景"`
Name string `json:"name" dc:"场景名称"`
InAppEnabled int `json:"inAppEnabled" dc:"站内通知"`
PushEnabled int `json:"pushEnabled" dc:"Push通知"`
SmsEnabled int `json:"smsEnabled" dc:"短信通知"`
Status int `json:"status" dc:"状态"`
TitleTemplate string `json:"titleTemplate" dc:"标题模板"`
ContentTemplate string `json:"contentTemplate" dc:"内容模板"`
PayloadJson string `json:"payloadJson" dc:"扩展JSON"`
UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"`
}
type ImNotificationTemplateUpdateInp struct {
TenantId int64 `json:"tenantId" dc:"租户ID"`
Scene string `json:"scene" v:"required#通知场景不能为空" dc:"通知场景"`
InAppEnabled int `json:"inAppEnabled" dc:"站内通知"`
PushEnabled int `json:"pushEnabled" dc:"Push通知"`
SmsEnabled int `json:"smsEnabled" dc:"短信通知"`
Status int `json:"status" dc:"状态"`
TitleTemplate string `json:"titleTemplate" dc:"标题模板"`
ContentTemplate string `json:"contentTemplate" dc:"内容模板"`
PayloadJson *gjson.Json `json:"payloadJson" dc:"扩展JSON"`
}
func (in *ImNotificationTemplateUpdateInp) Filter(ctx context.Context) (err error) {
in.Scene = strings.TrimSpace(in.Scene)
in.TitleTemplate = strings.TrimSpace(in.TitleTemplate)
in.ContentTemplate = strings.TrimSpace(in.ContentTemplate)
if !dict.HasOptionKey(consts.ImNotificationSceneOptions, in.Scene) {
return gerror.New("通知场景不合法")
}
if !dict.HasOptionKey(consts.ImNotificationTemplateStatusOptions, in.Status) {
return gerror.New("通知模板状态不合法")
}
if in.TitleTemplate == "" {
return gerror.New("标题模板不能为空")
}
if in.ContentTemplate == "" {
return gerror.New("内容模板不能为空")
}
return nil
}
+184
View File
@@ -0,0 +1,184 @@
package sysin
import (
"context"
"hotgo/internal/consts"
"hotgo/internal/library/dict"
"hotgo/internal/model/input/form"
"strings"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
)
type ImOrgDepartmentListInp struct {
form.PageReq
TenantId int64 `json:"tenantId" dc:"租户ID"`
DepartmentId string `json:"departmentId" dc:"部门ID"`
ParentId string `json:"parentId" dc:"上级部门ID"`
Name string `json:"name" dc:"部门名称"`
State string `json:"state" dc:"状态"`
}
func (in *ImOrgDepartmentListInp) Filter(ctx context.Context) (err error) { return }
type ImOrgDepartmentModel struct {
TenantId int64 `json:"tenantId" dc:"租户ID"`
TenantName string `json:"tenantName" dc:"租户名称"`
Id int64 `json:"id" dc:"ID"`
DepartmentId string `json:"departmentId" dc:"部门ID"`
Name string `json:"name" dc:"部门名称"`
ParentId string `json:"parentId" dc:"上级部门ID"`
SortOrder int `json:"sortOrder" dc:"排序"`
Version int `json:"version" dc:"版本"`
State string `json:"state" dc:"状态"`
ChildCount int `json:"childCount" dc:"子部门数"`
MemberCount int `json:"memberCount" dc:"成员数"`
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"`
}
type ImOrgDepartmentEditInp struct {
TenantId int64 `json:"tenantId" dc:"租户ID"`
Id int64 `json:"id" dc:"ID"`
DepartmentId string `json:"departmentId" dc:"部门ID"`
Name string `json:"name" dc:"部门名称"`
ParentId string `json:"parentId" dc:"上级部门ID"`
SortOrder int `json:"sortOrder" dc:"排序"`
State string `json:"state" dc:"状态"`
}
func (in *ImOrgDepartmentEditInp) Filter(ctx context.Context) (err error) {
in.Name = strings.TrimSpace(in.Name)
in.DepartmentId = strings.TrimSpace(in.DepartmentId)
in.ParentId = strings.TrimSpace(in.ParentId)
in.State = strings.TrimSpace(in.State)
if in.Name == "" {
return gerror.New("部门名称不能为空")
}
if in.ParentId == "" {
in.ParentId = "root"
}
if in.State == "" {
in.State = consts.ImOrgDepartmentStateActive
}
if !dict.HasOptionKey(consts.ImOrgDepartmentStateOptions, in.State) {
return gerror.New("部门状态不合法")
}
return
}
type ImOrgDepartmentDeleteInp struct {
TenantId int64 `json:"tenantId" dc:"租户ID"`
DepartmentId string `json:"departmentId" v:"required#部门ID不能为空" dc:"部门ID"`
}
func (in *ImOrgDepartmentDeleteInp) Filter(ctx context.Context) (err error) {
in.DepartmentId = strings.TrimSpace(in.DepartmentId)
if in.DepartmentId == "" {
return gerror.New("部门ID不能为空")
}
return
}
type ImOrgMemberListInp struct {
form.PageReq
TenantId int64 `json:"tenantId" dc:"租户ID"`
DepartmentId string `json:"departmentId" dc:"部门ID"`
UserId int64 `json:"userId" dc:"用户ID"`
Query string `json:"query" dc:"关键词"`
EmploymentState string `json:"employmentState" dc:"在职状态"`
}
func (in *ImOrgMemberListInp) Filter(ctx context.Context) (err error) { return }
type ImOrgMemberModel struct {
TenantId int64 `json:"tenantId" dc:"租户ID"`
TenantName string `json:"tenantName" dc:"租户名称"`
Id int64 `json:"id" dc:"ID"`
DepartmentId string `json:"departmentId" dc:"部门ID"`
DepartmentName string `json:"departmentName" dc:"部门名称"`
UserId int64 `json:"userId" dc:"用户ID"`
DisplayName string `json:"displayName" dc:"展示名称"`
Position string `json:"position" dc:"岗位"`
AvatarFileId string `json:"avatarFileId" dc:"头像文件ID"`
Mobile string `json:"mobile" dc:"手机号"`
Email string `json:"email" dc:"邮箱"`
EmploymentState string `json:"employmentState" dc:"在职状态"`
Contactable int `json:"contactable" dc:"是否可联系"`
SortOrder int `json:"sortOrder" dc:"排序"`
Version int `json:"version" dc:"版本"`
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"`
}
type ImOrgMemberEditInp struct {
TenantId int64 `json:"tenantId" dc:"租户ID"`
Id int64 `json:"id" dc:"ID"`
DepartmentId string `json:"departmentId" dc:"部门ID"`
UserId int64 `json:"userId" dc:"用户ID"`
DisplayName string `json:"displayName" dc:"展示名称"`
Position string `json:"position" dc:"岗位"`
AvatarFileId string `json:"avatarFileId" dc:"头像文件ID"`
Mobile string `json:"mobile" dc:"手机号"`
Email string `json:"email" dc:"邮箱"`
EmploymentState string `json:"employmentState" dc:"在职状态"`
Contactable int `json:"contactable" dc:"是否可联系"`
SortOrder int `json:"sortOrder" dc:"排序"`
}
func (in *ImOrgMemberEditInp) Filter(ctx context.Context) (err error) {
in.DepartmentId = strings.TrimSpace(in.DepartmentId)
in.DisplayName = strings.TrimSpace(in.DisplayName)
in.Position = strings.TrimSpace(in.Position)
in.AvatarFileId = strings.TrimSpace(in.AvatarFileId)
in.Mobile = strings.TrimSpace(in.Mobile)
in.Email = strings.TrimSpace(in.Email)
in.EmploymentState = strings.TrimSpace(in.EmploymentState)
if in.DepartmentId == "" {
return gerror.New("部门ID不能为空")
}
if in.UserId <= 0 {
return gerror.New("用户ID不能为空")
}
if in.EmploymentState == "" {
in.EmploymentState = consts.ImOrgEmploymentStateActive
}
if !dict.HasOptionKey(consts.ImOrgEmploymentStateOptions, in.EmploymentState) {
return gerror.New("在职状态不合法")
}
if !dict.HasOptionKey(consts.ImOrgContactableOptions, in.Contactable) {
return gerror.New("可联系状态不合法")
}
return
}
type ImOrgMemberDeleteInp struct {
TenantId int64 `json:"tenantId" dc:"租户ID"`
Id int64 `json:"id" v:"required#成员关系ID不能为空" dc:"ID"`
}
func (in *ImOrgMemberDeleteInp) Filter(ctx context.Context) (err error) {
if in.Id <= 0 {
return gerror.New("成员关系ID不能为空")
}
return
}
type ImOrgSearchInp struct {
TenantId int64 `json:"tenantId" dc:"租户ID"`
Query string `json:"query" v:"required#关键词不能为空" dc:"关键词"`
}
func (in *ImOrgSearchInp) Filter(ctx context.Context) (err error) {
in.Query = strings.TrimSpace(in.Query)
if in.Query == "" {
return gerror.New("关键词不能为空")
}
return
}
type ImOrgSearchModel struct {
Departments []*ImOrgDepartmentModel `json:"departments" dc:"部门"`
Members []*ImOrgMemberModel `json:"members" dc:"成员"`
}
@@ -0,0 +1,7 @@
package genrouter
import "hotgo/internal/controller/admin/sys"
func init() {
LoginRequiredRouter = append(LoginRequiredRouter, sys.ImNotification) // IM通知模板
}
@@ -0,0 +1,7 @@
package genrouter
import "hotgo/internal/controller/admin/sys"
func init() {
LoginRequiredRouter = append(LoginRequiredRouter, sys.ImOrg) // IM组织架构
}
+46
View File
@@ -293,6 +293,22 @@ type (
// Dead 标记IM Outbox死信 // Dead 标记IM Outbox死信
Dead(ctx context.Context, in *sysin.ImOutboxActionInp) (err error) Dead(ctx context.Context, in *sysin.ImOutboxActionInp) (err error)
} }
ISysImOrg interface {
// DepartmentList 获取IM组织部门列表
DepartmentList(ctx context.Context, in *sysin.ImOrgDepartmentListInp) (list []*sysin.ImOrgDepartmentModel, totalCount int, err error)
// DepartmentEdit 新增/编辑IM组织部门
DepartmentEdit(ctx context.Context, in *sysin.ImOrgDepartmentEditInp) (err error)
// DepartmentDelete 删除IM组织部门
DepartmentDelete(ctx context.Context, in *sysin.ImOrgDepartmentDeleteInp) (err error)
// MemberList 获取IM组织成员列表
MemberList(ctx context.Context, in *sysin.ImOrgMemberListInp) (list []*sysin.ImOrgMemberModel, totalCount int, err error)
// MemberEdit 新增/编辑IM组织成员
MemberEdit(ctx context.Context, in *sysin.ImOrgMemberEditInp) (err error)
// MemberDelete 删除IM组织成员
MemberDelete(ctx context.Context, in *sysin.ImOrgMemberDeleteInp) (err error)
// Search 搜索IM组织
Search(ctx context.Context, in *sysin.ImOrgSearchInp) (res *sysin.ImOrgSearchModel, err error)
}
ISysImFile interface { ISysImFile interface {
// List 获取IM文件列表 // List 获取IM文件列表
List(ctx context.Context, in *sysin.ImFileListInp) (list []*sysin.ImFileListModel, totalCount int, err error) List(ctx context.Context, in *sysin.ImFileListInp) (list []*sysin.ImFileListModel, totalCount int, err error)
@@ -315,6 +331,12 @@ type (
// Check 检查IM外部服务配置 // Check 检查IM外部服务配置
Check(ctx context.Context, in *sysin.ImExternalConfigCheckInp) (err error) Check(ctx context.Context, in *sysin.ImExternalConfigCheckInp) (err error)
} }
ISysImNotification interface {
// TemplateList 获取IM通知模板列表
TemplateList(ctx context.Context, in *sysin.ImNotificationTemplateListInp) (list []*sysin.ImNotificationTemplateListModel, totalCount int, err error)
// TemplateUpdate 更新IM通知模板
TemplateUpdate(ctx context.Context, in *sysin.ImNotificationTemplateUpdateInp) (err error)
}
ISysImTaskDashboard interface { ISysImTaskDashboard interface {
// List 获取IM任务看板列表 // List 获取IM任务看板列表
List(ctx context.Context, in *sysin.ImTaskDashboardListInp) (list []*sysin.ImTaskDashboardListModel, totalCount int, err error) List(ctx context.Context, in *sysin.ImTaskDashboardListInp) (list []*sysin.ImTaskDashboardListModel, totalCount int, err error)
@@ -578,8 +600,10 @@ var (
localSysImRealname ISysImRealname localSysImRealname ISysImRealname
localSysImWorkOrder ISysImWorkOrder localSysImWorkOrder ISysImWorkOrder
localSysImOutbox ISysImOutbox localSysImOutbox ISysImOutbox
localSysImOrg ISysImOrg
localSysImFile ISysImFile localSysImFile ISysImFile
localSysImExternalConfig ISysImExternalConfig localSysImExternalConfig ISysImExternalConfig
localSysImNotification ISysImNotification
localSysImTaskDashboard ISysImTaskDashboard localSysImTaskDashboard ISysImTaskDashboard
localSysImAuditLog ISysImAuditLog localSysImAuditLog ISysImAuditLog
localSysDictData ISysDictData localSysDictData ISysDictData
@@ -773,6 +797,17 @@ func RegisterSysImOutbox(i ISysImOutbox) {
localSysImOutbox = i localSysImOutbox = i
} }
func SysImOrg() ISysImOrg {
if localSysImOrg == nil {
panic("implement not found for interface ISysImOrg, forgot register?")
}
return localSysImOrg
}
func RegisterSysImOrg(i ISysImOrg) {
localSysImOrg = i
}
func SysImFile() ISysImFile { func SysImFile() ISysImFile {
if localSysImFile == nil { if localSysImFile == nil {
panic("implement not found for interface ISysImFile, forgot register?") panic("implement not found for interface ISysImFile, forgot register?")
@@ -795,6 +830,17 @@ func RegisterSysImExternalConfig(i ISysImExternalConfig) {
localSysImExternalConfig = i localSysImExternalConfig = i
} }
func SysImNotification() ISysImNotification {
if localSysImNotification == nil {
panic("implement not found for interface ISysImNotification, forgot register?")
}
return localSysImNotification
}
func RegisterSysImNotification(i ISysImNotification) {
localSysImNotification = i
}
func SysImTaskDashboard() ISysImTaskDashboard { func SysImTaskDashboard() ISysImTaskDashboard {
if localSysImTaskDashboard == nil { if localSysImTaskDashboard == nil {
panic("implement not found for interface ISysImTaskDashboard, forgot register?") panic("implement not found for interface ISysImTaskDashboard, forgot register?")
@@ -0,0 +1,31 @@
SET @now := NOW();
SET @imRootId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1);
CREATE TABLE IF NOT EXISTS `im_notification_template` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`tenant_id` BIGINT NOT NULL,
`scene` VARCHAR(64) NOT NULL,
`name` VARCHAR(64) NOT NULL DEFAULT '',
`in_app_enabled` TINYINT NOT NULL DEFAULT 1,
`push_enabled` TINYINT NOT NULL DEFAULT 1,
`sms_enabled` TINYINT NOT NULL DEFAULT 0,
`status` TINYINT NOT NULL DEFAULT 1,
`title_template` VARCHAR(255) NOT NULL DEFAULT '',
`content_template` TEXT NOT NULL,
`payload_json` JSON DEFAULT NULL,
`created_at` DATETIME DEFAULT NULL,
`updated_at` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_im_notification_template_tenant_scene` (`tenant_id`, `scene`),
KEY `idx_im_notification_template_tenant_status` (`tenant_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='IM通知模板配置';
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `component`, `route`, `hidden`, `active_menu`, `is_frame`, `is_cache`, `affix`, `sort`, `status`, `created_by`, `updated_by`, `remark`, `level`, `tree`, `created_at`, `updated_at`)
SELECT NULL, @imRootId, '通知模板', 'imNotificationIndex', 'notification/index', '', '2', '', '/imNotification/templateList', '', '/imNotification/index', '1', 'imManager', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '75', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imNotificationIndex');
SET @imNotificationId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imNotificationIndex' LIMIT 1);
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `component`, `route`, `hidden`, `active_menu`, `is_frame`, `is_cache`, `affix`, `sort`, `status`, `created_by`, `updated_by`, `remark`, `level`, `tree`, `created_at`, `updated_at`)
SELECT NULL, @imNotificationId, '模板更新', 'imNotificationTemplateUpdate', '', '', '3', '', '/imNotification/templateUpdate', '', '', '1', '', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @imNotificationId, ' '), '10', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imNotificationTemplateUpdate');
@@ -0,0 +1,32 @@
SET @now := NOW();
SET @imRootId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1);
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `component`, `route`, `hidden`, `active_menu`, `is_frame`, `is_cache`, `affix`, `sort`, `status`, `created_by`, `updated_by`, `remark`, `level`, `tree`, `created_at`, `updated_at`)
SELECT NULL, @imRootId, '组织架构', 'imOrgIndex', 'org/index', '', '2', '', '/imOrg/departmentList', '', '/imOrg/index', '1', 'imManager', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '65', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imOrgIndex');
SET @imOrgId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imOrgIndex' LIMIT 1);
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `component`, `route`, `hidden`, `active_menu`, `is_frame`, `is_cache`, `affix`, `sort`, `status`, `created_by`, `updated_by`, `remark`, `level`, `tree`, `created_at`, `updated_at`)
SELECT NULL, @imOrgId, '部门编辑', 'imOrgDepartmentEdit', '', '', '3', '', '/imOrg/departmentEdit', '', '', '1', '', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @imOrgId, ' '), '10', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imOrgDepartmentEdit');
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `component`, `route`, `hidden`, `active_menu`, `is_frame`, `is_cache`, `affix`, `sort`, `status`, `created_by`, `updated_by`, `remark`, `level`, `tree`, `created_at`, `updated_at`)
SELECT NULL, @imOrgId, '部门删除', 'imOrgDepartmentDelete', '', '', '3', '', '/imOrg/departmentDelete', '', '', '1', '', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @imOrgId, ' '), '20', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imOrgDepartmentDelete');
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `component`, `route`, `hidden`, `active_menu`, `is_frame`, `is_cache`, `affix`, `sort`, `status`, `created_by`, `updated_by`, `remark`, `level`, `tree`, `created_at`, `updated_at`)
SELECT NULL, @imOrgId, '成员列表', 'imOrgMemberList', '', '', '3', '', '/imOrg/memberList', '', '', '1', '', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @imOrgId, ' '), '30', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imOrgMemberList');
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `component`, `route`, `hidden`, `active_menu`, `is_frame`, `is_cache`, `affix`, `sort`, `status`, `created_by`, `updated_by`, `remark`, `level`, `tree`, `created_at`, `updated_at`)
SELECT NULL, @imOrgId, '成员编辑', 'imOrgMemberEdit', '', '', '3', '', '/imOrg/memberEdit', '', '', '1', '', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @imOrgId, ' '), '40', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imOrgMemberEdit');
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `component`, `route`, `hidden`, `active_menu`, `is_frame`, `is_cache`, `affix`, `sort`, `status`, `created_by`, `updated_by`, `remark`, `level`, `tree`, `created_at`, `updated_at`)
SELECT NULL, @imOrgId, '成员移除', 'imOrgMemberDelete', '', '', '3', '', '/imOrg/memberDelete', '', '', '1', '', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @imOrgId, ' '), '50', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imOrgMemberDelete');
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `component`, `route`, `hidden`, `active_menu`, `is_frame`, `is_cache`, `affix`, `sort`, `status`, `created_by`, `updated_by`, `remark`, `level`, `tree`, `created_at`, `updated_at`)
SELECT NULL, @imOrgId, '组织搜索', 'imOrgSearch', '', '', '3', '', '/imOrg/search', '', '', '1', '', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @imOrgId, ' '), '60', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imOrgSearch');
+9
View File
@@ -0,0 +1,9 @@
import { http } from '@/utils/http/axios';
export function TemplateList(params) {
return http.request({ url: '/imNotification/templateList', method: 'GET', params });
}
export function TemplateUpdate(params) {
return http.request({ url: '/imNotification/templateUpdate', method: 'POST', params });
}
+29
View File
@@ -0,0 +1,29 @@
import { http } from '@/utils/http/axios';
export function DepartmentList(params) {
return http.request({ url: '/imOrg/departmentList', method: 'GET', params });
}
export function DepartmentEdit(params) {
return http.request({ url: '/imOrg/departmentEdit', method: 'POST', params });
}
export function DepartmentDelete(params) {
return http.request({ url: '/imOrg/departmentDelete', method: 'POST', params });
}
export function MemberList(params) {
return http.request({ url: '/imOrg/memberList', method: 'GET', params });
}
export function MemberEdit(params) {
return http.request({ url: '/imOrg/memberEdit', method: 'POST', params });
}
export function MemberDelete(params) {
return http.request({ url: '/imOrg/memberDelete', method: 'POST', params });
}
export function Search(params) {
return http.request({ url: '/imOrg/search', method: 'GET', params });
}
+228
View File
@@ -0,0 +1,228 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="IM通知模板" />
</div>
<n-card :bordered="false" class="proCard">
<BasicForm
ref="searchFormRef"
@register="register"
@submit="reloadTable"
@reset="handleReset"
@keyup.enter="reloadTable"
/>
<BasicTable
ref="actionRef"
:columns="columns"
:request="loadDataTable"
:row-key="(row) => row.tenantId + '-' + row.scene"
:actionColumn="actionColumn"
:scroll-x="scrollX"
:resizeHeightOffset="-10000"
/>
</n-card>
<n-modal
v-model:show="showModal"
preset="dialog"
:show-icon="false"
:title="'通知模板 - ' + sceneLabel"
:style="{ width: dialogWidth }"
>
<n-form
ref="formRef"
:model="formValue"
:rules="rules"
label-placement="left"
label-width="90"
>
<n-form-item label="站内通知">
<n-switch v-model:value="inAppEnabled" />
</n-form-item>
<n-form-item label="Push通知">
<n-switch v-model:value="pushEnabled" />
</n-form-item>
<n-form-item label="短信通知">
<n-switch v-model:value="smsEnabled" />
</n-form-item>
<n-form-item label="状态">
<n-select
v-model:value="formValue.status"
:options="statusOptions"
placeholder="请选择状态"
/>
</n-form-item>
<n-form-item label="标题模板" path="titleTemplate">
<n-input v-model:value="formValue.titleTemplate" placeholder="请输入标题模板" />
</n-form-item>
<n-form-item label="内容模板" path="contentTemplate">
<n-input
v-model:value="formValue.contentTemplate"
type="textarea"
:autosize="{ minRows: 4, maxRows: 8 }"
placeholder="请输入内容模板"
/>
</n-form-item>
<n-form-item label="扩展JSON">
<n-input
v-model:value="formValue.payloadJson"
type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
placeholder="请输入JSON配置"
/>
</n-form-item>
</n-form>
<template #action>
<n-space>
<n-button @click="showModal = false">取消</n-button>
<n-button type="primary" :loading="submitLoading" @click="handleSubmit">保存</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, h, onMounted, reactive, ref } from 'vue';
import { useMessage } from 'naive-ui';
import { useDictStore } from '@/store/modules/dict';
import { BasicForm, useForm } from '@/components/Form/index';
import { BasicTable, TableAction } from '@/components/Table';
import { usePermission } from '@/hooks/web/usePermission';
import { useUserStore } from '@/store/modules/user';
import { TemplateList, TemplateUpdate } from '@/api/imNotification';
import { adaModalWidth, adaTableScrollX } from '@/utils/hotgo';
import {
columns,
loadOptions,
loadTenantOptions,
newState,
rules,
schemas,
State,
tenantOptions,
} from './model';
const dict = useDictStore();
const message = useMessage();
const { hasPermission } = usePermission();
const userStore = useUserStore();
const isPlatformAdmin = computed(() => userStore.isCompanyDept);
const actionRef = ref();
const searchFormRef = ref<any>({});
const formRef = ref();
const showModal = ref(false);
const submitLoading = ref(false);
const formValue = ref<State>(newState(null));
const dialogWidth = computed(() => adaModalWidth(720));
const statusOptions = computed(() => dict.getOptions('ImNotificationTemplateStatusOptions'));
const sceneLabel = computed(
() => dict.getLabel('ImNotificationSceneOptions', formValue.value.scene) || formValue.value.name
);
const tenantSchema = schemas.value.find((item) => item.field === 'tenantId');
if (tenantSchema) {
tenantSchema.ifShow = () => isPlatformAdmin.value;
}
const inAppEnabled = computed({
get: () => formValue.value.inAppEnabled === 1,
set: (value: boolean) => {
formValue.value.inAppEnabled = value ? 1 : 0;
},
});
const pushEnabled = computed({
get: () => formValue.value.pushEnabled === 1,
set: (value: boolean) => {
formValue.value.pushEnabled = value ? 1 : 0;
},
});
const smsEnabled = computed({
get: () => formValue.value.smsEnabled === 1,
set: (value: boolean) => {
formValue.value.smsEnabled = value ? 1 : 0;
},
});
const actionColumn = reactive({
width: 100,
title: '操作',
key: 'action',
fixed: 'right',
render(record: State) {
return h(TableAction as any, {
style: 'button',
actions: [
{
label: '编辑',
auth: ['/imNotification/templateUpdate'],
onClick: () => handleEdit(record),
},
],
});
},
});
const scrollX = computed(() => adaTableScrollX(columns, actionColumn.width));
const [register, { setFieldsValue }] = useForm({
gridProps: { cols: '1 s:1 m:2 l:3 xl:4 2xl:4' },
labelWidth: 90,
schemas,
});
const loadDataTable = async (res) => {
const params = { ...searchFormRef.value?.formModel, ...res };
if (isPlatformAdmin.value && !params.tenantId)
return { list: [], page: 1, pageCount: 0, totalCount: 0 };
return await TemplateList(params);
};
function reloadTable() {
actionRef.value?.reload();
}
async function handleReset() {
const defaults: Recordable = {};
if (isPlatformAdmin.value && tenantOptions.value.length > 0)
defaults.tenantId = tenantOptions.value[0].value;
await setFieldsValue(defaults);
reloadTable();
}
function handleEdit(record: State) {
formValue.value = newState(record);
if (!formValue.value.payloadJson) formValue.value.payloadJson = '{}';
showModal.value = true;
}
async function handleSubmit() {
submitLoading.value = true;
try {
await formRef.value?.validate();
let payloadJson = {};
const text = (formValue.value.payloadJson || '').trim();
if (text) payloadJson = JSON.parse(text);
await TemplateUpdate({ ...formValue.value, payloadJson });
message.success('操作成功');
showModal.value = false;
reloadTable();
} catch (e: any) {
message.error(e.message || '操作失败');
} finally {
submitLoading.value = false;
}
}
onMounted(async () => {
loadOptions();
if (isPlatformAdmin.value) {
await loadTenantOptions();
if (tenantOptions.value.length > 0) {
await setFieldsValue({ tenantId: tenantOptions.value[0].value });
} else {
message.warning('暂无可用租户');
}
}
reloadTable();
});
</script>
+150
View File
@@ -0,0 +1,150 @@
import { computed, ref } from 'vue';
import { cloneDeep } from 'lodash-es';
import { FormSchema } from '@/components/Form';
import { useDictStore } from '@/store/modules/dict';
import { List as TenantList } from '@/api/imTenant';
const dict = useDictStore();
export const tenantOptions = ref<any[]>([]);
export class State {
public tenantId = 0;
public tenantName = '';
public id = 0;
public scene = '';
public name = '';
public inAppEnabled = 1;
public pushEnabled = 1;
public smsEnabled = 0;
public status = 1;
public titleTemplate = '';
public contentTemplate = '';
public payloadJson = '{}';
public updatedAt = '';
constructor(state?: Partial<State>) {
if (state) Object.assign(this, state);
}
}
export function newState(state: State | Record<string, any> | null): State {
if (state !== null) {
if (state instanceof State) return cloneDeep(state);
return new State(state);
}
return new State();
}
export const schemas = ref<FormSchema[]>([
{
field: 'tenantId',
component: 'NSelect',
label: '租户',
componentProps: {
clearable: false,
options: computed(() => tenantOptions.value),
placeholder: '请选择租户',
},
},
{
field: 'scene',
component: 'NSelect',
label: '场景',
componentProps: {
clearable: true,
options: computed(() => dict.getOptions('ImNotificationSceneOptions')),
placeholder: '请选择场景',
},
},
{
field: 'status',
component: 'NSelect',
label: '状态',
componentProps: {
clearable: true,
options: computed(() => dict.getOptions('ImNotificationTemplateStatusOptions')),
placeholder: '请选择状态',
},
},
]);
export const columns = [
{ title: '租户', key: 'tenantName', align: 'left', width: 150 },
{
title: '场景',
key: 'scene',
align: 'left',
width: 140,
render(row: State) {
return dict.getLabel('ImNotificationSceneOptions', row.scene) || row.name;
},
},
{
title: '状态',
key: 'status',
align: 'left',
width: 90,
render(row: State) {
return dict.getLabel('ImNotificationTemplateStatusOptions', row.status);
},
},
{
title: '站内',
key: 'inAppEnabled',
align: 'left',
width: 80,
render(row: State) {
return row.inAppEnabled === 1 ? '开启' : '关闭';
},
},
{
title: 'Push',
key: 'pushEnabled',
align: 'left',
width: 80,
render(row: State) {
return row.pushEnabled === 1 ? '开启' : '关闭';
},
},
{
title: '短信',
key: 'smsEnabled',
align: 'left',
width: 80,
render(row: State) {
return row.smsEnabled === 1 ? '开启' : '关闭';
},
},
{ title: '标题模板', key: 'titleTemplate', align: 'left', width: 180 },
{ title: '内容模板', key: 'contentTemplate', align: 'left', width: 260 },
{ title: '更新时间', key: 'updatedAt', align: 'left', width: 180 },
];
export const rules = {
titleTemplate: {
required: true,
trigger: ['blur', 'input'],
type: 'string',
message: '请输入标题模板',
},
contentTemplate: {
required: true,
trigger: ['blur', 'input'],
type: 'string',
message: '请输入内容模板',
},
};
export function loadOptions() {
dict.loadOptions(['ImNotificationSceneOptions', 'ImNotificationTemplateStatusOptions']);
}
export async function loadTenantOptions() {
const res = await TenantList({ page: 1, perPage: 100 });
tenantOptions.value = (res?.list || []).map((item) => ({
label: `${item.name}(${item.code})`,
value: item.id,
}));
return tenantOptions.value;
}
+501
View File
@@ -0,0 +1,501 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="IM组织架构" />
</div>
<n-card :bordered="false" class="proCard">
<n-grid :x-gap="16" :y-gap="16" responsive="screen" cols="1 l:2">
<n-grid-item>
<BasicForm
ref="departmentSearchRef"
@register="registerDepartment"
@submit="reloadDepartments"
@reset="handleDepartmentReset"
@keyup.enter="reloadDepartments"
/>
<n-space class="table-toolbar">
<n-button
v-if="hasPermission(['/imOrg/departmentEdit'])"
type="primary"
@click="handleDepartmentAdd"
>
新增部门
</n-button>
</n-space>
<BasicTable
ref="departmentTableRef"
:columns="departmentColumns"
:request="loadDepartmentTable"
:row-key="(row) => row.departmentId"
:actionColumn="departmentActionColumn"
:scroll-x="departmentScrollX"
:resizeHeightOffset="-10000"
@update:checked-row-keys="handleDepartmentChecked"
/>
</n-grid-item>
<n-grid-item>
<BasicForm
ref="memberSearchRef"
@register="registerMember"
@submit="reloadMembers"
@reset="handleMemberReset"
@keyup.enter="reloadMembers"
/>
<n-space class="table-toolbar">
<n-button
v-if="hasPermission(['/imOrg/memberEdit'])"
type="primary"
:disabled="!selectedDepartment.departmentId"
@click="handleMemberAdd"
>
添加成员
</n-button>
</n-space>
<BasicTable
ref="memberTableRef"
:columns="memberColumns"
:request="loadMemberTable"
:row-key="(row) => row.id"
:actionColumn="memberActionColumn"
:scroll-x="memberScrollX"
:resizeHeightOffset="-10000"
/>
</n-grid-item>
</n-grid>
</n-card>
<n-modal
v-model:show="departmentModalVisible"
preset="dialog"
:show-icon="false"
:title="departmentForm.id > 0 ? '编辑部门' : '新增部门'"
:style="{ width: dialogWidth }"
>
<n-form
ref="departmentFormRef"
:model="departmentForm"
:rules="departmentRules"
label-placement="left"
label-width="90"
>
<n-form-item label="部门名称" path="name">
<n-input v-model:value="departmentForm.name" placeholder="请输入部门名称" />
</n-form-item>
<n-form-item label="上级部门">
<n-select
v-model:value="departmentForm.parentId"
:options="parentDepartmentOptions"
filterable
placeholder="请选择上级部门"
/>
</n-form-item>
<n-form-item label="部门ID">
<n-input
v-model:value="departmentForm.departmentId"
:disabled="departmentForm.id > 0"
placeholder="留空自动生成"
/>
</n-form-item>
<n-form-item label="排序">
<n-input-number v-model:value="departmentForm.sortOrder" />
</n-form-item>
<n-form-item label="状态" path="state">
<n-select
v-model:value="departmentForm.state"
:options="departmentStateOptions"
placeholder="请选择状态"
/>
</n-form-item>
</n-form>
<template #action>
<n-space>
<n-button @click="departmentModalVisible = false">取消</n-button>
<n-button type="primary" :loading="submitLoading" @click="submitDepartment"
>保存</n-button
>
</n-space>
</template>
</n-modal>
<n-modal
v-model:show="memberModalVisible"
preset="dialog"
:show-icon="false"
:title="memberForm.id > 0 ? '编辑成员' : '添加成员'"
:style="{ width: dialogWidth }"
>
<n-form
ref="memberFormRef"
:model="memberForm"
:rules="memberRules"
label-placement="left"
label-width="90"
>
<n-form-item label="部门" path="departmentId">
<n-select
v-model:value="memberForm.departmentId"
:options="departmentOptions"
filterable
placeholder="请选择部门"
/>
</n-form-item>
<n-form-item label="用户ID" path="userId">
<n-input-number v-model:value="memberForm.userId" :disabled="memberForm.id > 0" />
</n-form-item>
<n-form-item label="姓名">
<n-input v-model:value="memberForm.displayName" placeholder="请输入展示姓名" />
</n-form-item>
<n-form-item label="岗位">
<n-input v-model:value="memberForm.position" placeholder="请输入岗位" />
</n-form-item>
<n-form-item label="手机号">
<n-input v-model:value="memberForm.mobile" placeholder="请输入手机号" />
</n-form-item>
<n-form-item label="邮箱">
<n-input v-model:value="memberForm.email" placeholder="请输入邮箱" />
</n-form-item>
<n-form-item label="在职状态" path="employmentState">
<n-select
v-model:value="memberForm.employmentState"
:options="employmentStateOptions"
placeholder="请选择在职状态"
/>
</n-form-item>
<n-form-item label="可联系">
<n-switch v-model:value="memberContactable" />
</n-form-item>
<n-form-item label="排序">
<n-input-number v-model:value="memberForm.sortOrder" />
</n-form-item>
</n-form>
<template #action>
<n-space>
<n-button @click="memberModalVisible = false">取消</n-button>
<n-button type="primary" :loading="submitLoading" @click="submitMember">保存</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, h, onMounted, reactive, ref } from 'vue';
import { useDialog, useMessage } from 'naive-ui';
import { useDictStore } from '@/store/modules/dict';
import { BasicForm, useForm } from '@/components/Form/index';
import { BasicTable, TableAction } from '@/components/Table';
import { usePermission } from '@/hooks/web/usePermission';
import { useUserStore } from '@/store/modules/user';
import { adaModalWidth, adaTableScrollX } from '@/utils/hotgo';
import {
DepartmentDelete,
DepartmentEdit,
DepartmentList,
MemberDelete,
MemberEdit,
MemberList,
} from '@/api/imOrg';
import {
departmentColumns,
departmentOptions,
departmentRules,
departmentSchemas,
DepartmentState,
loadOptions,
loadTenantOptions,
memberColumns,
memberRules,
memberSchemas,
MemberState,
newDepartmentState,
newMemberState,
tenantOptions,
} from './model';
const dict = useDictStore();
const dialog = useDialog();
const message = useMessage();
const { hasPermission } = usePermission();
const userStore = useUserStore();
const isPlatformAdmin = computed(() => userStore.isCompanyDept);
const departmentSearchRef = ref<any>({});
const memberSearchRef = ref<any>({});
const departmentTableRef = ref();
const memberTableRef = ref();
const departmentFormRef = ref();
const memberFormRef = ref();
const selectedDepartment = ref<DepartmentState>(newDepartmentState(null));
const departmentRows = ref<DepartmentState[]>([]);
const departmentForm = ref<DepartmentState>(newDepartmentState(null));
const memberForm = ref<MemberState>(newMemberState(null));
const departmentModalVisible = ref(false);
const memberModalVisible = ref(false);
const submitLoading = ref(false);
const dialogWidth = computed(() => adaModalWidth(640));
const tenantSchema = departmentSchemas.value.find((item) => item.field === 'tenantId');
if (tenantSchema) {
tenantSchema.ifShow = () => isPlatformAdmin.value;
}
const departmentStateOptions = computed(() => dict.getOptions('ImOrgDepartmentStateOptions'));
const employmentStateOptions = computed(() => dict.getOptions('ImOrgEmploymentStateOptions'));
const parentDepartmentOptions = computed(() => [
{ label: '根部门', value: 'root' },
...departmentOptions.value.filter((item) => item.value !== departmentForm.value.departmentId),
]);
const memberContactable = computed({
get: () => memberForm.value.contactable === 1,
set: (value: boolean) => {
memberForm.value.contactable = value ? 1 : 0;
},
});
const departmentActionColumn = reactive({
width: 180,
title: '操作',
key: 'action',
fixed: 'right',
render(record: DepartmentState) {
return h(TableAction as any, {
style: 'button',
actions: [
{
label: '成员',
auth: ['/imOrg/memberList'],
onClick: () => selectDepartment(record),
},
{
label: '编辑',
auth: ['/imOrg/departmentEdit'],
onClick: () => handleDepartmentEdit(record),
},
{
label: '删除',
type: 'error',
auth: ['/imOrg/departmentDelete'],
ifShow: () => record.childCount === 0 && record.memberCount === 0,
onClick: () => handleDepartmentDelete(record),
},
],
});
},
});
const memberActionColumn = reactive({
width: 150,
title: '操作',
key: 'action',
fixed: 'right',
render(record: MemberState) {
return h(TableAction as any, {
style: 'button',
actions: [
{
label: '编辑',
auth: ['/imOrg/memberEdit'],
onClick: () => handleMemberEdit(record),
},
{
label: '移除',
type: 'error',
auth: ['/imOrg/memberDelete'],
onClick: () => handleMemberDelete(record),
},
],
});
},
});
const departmentScrollX = computed(() =>
adaTableScrollX(departmentColumns, departmentActionColumn.width)
);
const memberScrollX = computed(() => adaTableScrollX(memberColumns, memberActionColumn.width));
const [registerDepartment, { setFieldsValue: setDepartmentFields }] = useForm({
gridProps: { cols: '1 s:1 m:2 l:3 xl:3 2xl:3' },
labelWidth: 90,
schemas: departmentSchemas,
});
const [registerMember, { setFieldsValue: setMemberFields }] = useForm({
gridProps: { cols: '1 s:1 m:2 l:2 xl:2 2xl:2' },
labelWidth: 90,
schemas: memberSchemas,
});
const currentTenantId = computed(() => departmentSearchRef.value?.formModel?.tenantId || 0);
const loadDepartmentTable = async (res) => {
const params = { ...departmentSearchRef.value?.formModel, ...res };
if (isPlatformAdmin.value && !params.tenantId)
return { list: [], page: 1, pageCount: 0, totalCount: 0 };
const data = await DepartmentList(params);
departmentRows.value = data?.list || [];
departmentOptions.value = departmentRows.value.map((item) => ({
label: `${item.name}(${item.departmentId})`,
value: item.departmentId,
}));
if (!selectedDepartment.value.departmentId && departmentRows.value.length > 0) {
selectedDepartment.value = newDepartmentState(departmentRows.value[0]);
reloadMembers();
}
return data;
};
const loadMemberTable = async (res) => {
if (!selectedDepartment.value.departmentId)
return { list: [], page: 1, pageCount: 0, totalCount: 0 };
const params = {
...memberSearchRef.value?.formModel,
...res,
tenantId: currentTenantId.value,
departmentId: selectedDepartment.value.departmentId,
};
return await MemberList(params);
};
function reloadDepartments() {
departmentTableRef.value?.reload();
}
function reloadMembers() {
memberTableRef.value?.reload();
}
async function handleDepartmentReset() {
const defaults: Recordable = {};
if (isPlatformAdmin.value && tenantOptions.value.length > 0)
defaults.tenantId = tenantOptions.value[0].value;
selectedDepartment.value = newDepartmentState(null);
await setDepartmentFields(defaults);
reloadDepartments();
}
async function handleMemberReset() {
await setMemberFields({});
reloadMembers();
}
function handleDepartmentChecked(keys) {
const key = keys?.[0];
const row = departmentRows.value.find((item) => item.departmentId === key);
if (row) selectDepartment(row);
}
function selectDepartment(record: DepartmentState) {
selectedDepartment.value = newDepartmentState(record);
reloadMembers();
}
function handleDepartmentAdd() {
departmentForm.value = newDepartmentState({
tenantId: currentTenantId.value,
parentId: selectedDepartment.value.departmentId || 'root',
state: 'active',
});
departmentModalVisible.value = true;
}
function handleDepartmentEdit(record: DepartmentState) {
departmentForm.value = newDepartmentState(record);
departmentModalVisible.value = true;
}
async function submitDepartment() {
submitLoading.value = true;
try {
await departmentFormRef.value?.validate();
await DepartmentEdit({ ...departmentForm.value, tenantId: currentTenantId.value });
message.success('操作成功');
departmentModalVisible.value = false;
selectedDepartment.value = newDepartmentState(null);
reloadDepartments();
} finally {
submitLoading.value = false;
}
}
function handleDepartmentDelete(record: DepartmentState) {
dialog.error({
title: '删除部门',
content: `确认删除部门 ${record.name}?`,
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
await DepartmentDelete({
tenantId: currentTenantId.value,
departmentId: record.departmentId,
});
message.success('操作成功');
selectedDepartment.value = newDepartmentState(null);
reloadDepartments();
reloadMembers();
},
});
}
function handleMemberAdd() {
memberForm.value = newMemberState({
tenantId: currentTenantId.value,
departmentId: selectedDepartment.value.departmentId,
employmentState: 'active',
contactable: 1,
});
memberModalVisible.value = true;
}
function handleMemberEdit(record: MemberState) {
memberForm.value = newMemberState(record);
memberModalVisible.value = true;
}
async function submitMember() {
submitLoading.value = true;
try {
await memberFormRef.value?.validate();
await MemberEdit({ ...memberForm.value, tenantId: currentTenantId.value });
message.success('操作成功');
memberModalVisible.value = false;
reloadDepartments();
reloadMembers();
} finally {
submitLoading.value = false;
}
}
function handleMemberDelete(record: MemberState) {
dialog.error({
title: '移除成员',
content: `确认将 ${record.displayName || record.userId} 从部门移除?`,
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
await MemberDelete({ tenantId: currentTenantId.value, id: record.id });
message.success('操作成功');
reloadDepartments();
reloadMembers();
},
});
}
onMounted(async () => {
loadOptions();
if (isPlatformAdmin.value) {
await loadTenantOptions();
if (tenantOptions.value.length > 0) {
await setDepartmentFields({ tenantId: tenantOptions.value[0].value });
} else {
message.warning('暂无可用租户');
}
}
reloadDepartments();
});
</script>
<style lang="less" scoped>
.table-toolbar {
margin-bottom: 12px;
}
</style>
+204
View File
@@ -0,0 +1,204 @@
import { computed, ref } from 'vue';
import { cloneDeep } from 'lodash-es';
import { FormSchema } from '@/components/Form';
import { useDictStore } from '@/store/modules/dict';
import { List as TenantList } from '@/api/imTenant';
const dict = useDictStore();
export const tenantOptions = ref<any[]>([]);
export const departmentOptions = ref<any[]>([]);
export class DepartmentState {
public tenantId = 0;
public tenantName = '';
public id = 0;
public departmentId = '';
public name = '';
public parentId = 'root';
public sortOrder = 0;
public version = 1;
public state = 'active';
public childCount = 0;
public memberCount = 0;
public createdAt = '';
public updatedAt = '';
constructor(state?: Partial<DepartmentState>) {
if (state) Object.assign(this, state);
}
}
export class MemberState {
public tenantId = 0;
public tenantName = '';
public id = 0;
public departmentId = '';
public departmentName = '';
public userId = 0;
public displayName = '';
public position = '';
public avatarFileId = '';
public mobile = '';
public email = '';
public employmentState = 'active';
public contactable = 1;
public sortOrder = 0;
public version = 1;
public createdAt = '';
public updatedAt = '';
constructor(state?: Partial<MemberState>) {
if (state) Object.assign(this, state);
}
}
export function newDepartmentState(state: DepartmentState | Record<string, any> | null) {
if (state !== null) {
if (state instanceof DepartmentState) return cloneDeep(state);
return new DepartmentState(state);
}
return new DepartmentState();
}
export function newMemberState(state: MemberState | Record<string, any> | null) {
if (state !== null) {
if (state instanceof MemberState) return cloneDeep(state);
return new MemberState(state);
}
return new MemberState();
}
export const departmentSchemas = ref<FormSchema[]>([
{
field: 'tenantId',
component: 'NSelect',
label: '租户',
componentProps: {
clearable: false,
options: computed(() => tenantOptions.value),
placeholder: '请选择租户',
},
},
{
field: 'name',
component: 'NInput',
label: '部门名称',
componentProps: { placeholder: '请输入部门名称' },
},
{
field: 'state',
component: 'NSelect',
label: '状态',
componentProps: {
clearable: true,
options: computed(() => dict.getOptions('ImOrgDepartmentStateOptions')),
placeholder: '请选择状态',
},
},
]);
export const memberSchemas = ref<FormSchema[]>([
{
field: 'query',
component: 'NInput',
label: '关键词',
componentProps: { placeholder: '姓名/手机号/邮箱/用户ID' },
},
{
field: 'employmentState',
component: 'NSelect',
label: '在职状态',
componentProps: {
clearable: true,
options: computed(() => dict.getOptions('ImOrgEmploymentStateOptions')),
placeholder: '请选择在职状态',
},
},
]);
export const departmentColumns = [
{ title: '部门ID', key: 'departmentId', align: 'left', width: 170 },
{ title: '部门名称', key: 'name', align: 'left', width: 160 },
{ title: '上级部门', key: 'parentId', align: 'left', width: 130 },
{
title: '状态',
key: 'state',
align: 'left',
width: 90,
render(row: DepartmentState) {
return dict.getLabel('ImOrgDepartmentStateOptions', row.state);
},
},
{ title: '子部门', key: 'childCount', align: 'left', width: 80 },
{ title: '成员', key: 'memberCount', align: 'left', width: 80 },
{ title: '排序', key: 'sortOrder', align: 'left', width: 80 },
{ title: '更新时间', key: 'updatedAt', align: 'left', width: 180 },
];
export const memberColumns = [
{ title: '用户ID', key: 'userId', align: 'left', width: 110 },
{ title: '姓名', key: 'displayName', align: 'left', width: 130 },
{ title: '部门', key: 'departmentName', align: 'left', width: 130 },
{ title: '岗位', key: 'position', align: 'left', width: 130 },
{ title: '手机号', key: 'mobile', align: 'left', width: 140 },
{ title: '邮箱', key: 'email', align: 'left', width: 180 },
{
title: '在职状态',
key: 'employmentState',
align: 'left',
width: 100,
render(row: MemberState) {
return dict.getLabel('ImOrgEmploymentStateOptions', row.employmentState);
},
},
{
title: '可联系',
key: 'contactable',
align: 'left',
width: 90,
render(row: MemberState) {
return dict.getLabel('ImOrgContactableOptions', row.contactable);
},
},
{ title: '排序', key: 'sortOrder', align: 'left', width: 80 },
{ title: '更新时间', key: 'updatedAt', align: 'left', width: 180 },
];
export const departmentRules = {
name: { required: true, trigger: ['blur', 'input'], type: 'string', message: '请输入部门名称' },
state: { required: true, trigger: ['blur', 'change'], type: 'string', message: '请选择状态' },
};
export const memberRules = {
departmentId: {
required: true,
trigger: ['blur', 'change'],
type: 'string',
message: '请选择部门',
},
userId: { required: true, trigger: ['blur', 'input'], type: 'number', message: '请输入用户ID' },
employmentState: {
required: true,
trigger: ['blur', 'change'],
type: 'string',
message: '请选择在职状态',
},
};
export function loadOptions() {
dict.loadOptions([
'ImOrgDepartmentStateOptions',
'ImOrgEmploymentStateOptions',
'ImOrgContactableOptions',
]);
}
export async function loadTenantOptions() {
const res = await TenantList({ page: 1, perPage: 100 });
tenantOptions.value = (res?.list || []).map((item) => ({
label: `${item.name}(${item.code})`,
value: item.id,
}));
return tenantOptions.value;
}