feat: add IM admin P1 operations

This commit is contained in:
richjohnrambo
2026-08-21 17:25:19 +08:00
parent 77b19a8157
commit c89e35d0e0
35 changed files with 2587 additions and 53 deletions
+42 -23
View File
@@ -147,46 +147,65 @@
### P1-2 文件治理
状态:未实现。
状态:部分实现。
能力:
已实现:
1. 文件列表。
2. 文件用途、大小、状态查询。
3. 隔离、删除。
4. 下载审计。
5. 孤儿文件和过期文件清理任务。
4. 文件详情查看审计。
5. 孤儿文件手动清理。
6. 孤儿文件定时任务 `imFileCleanupOrphan`,参数为保留天数、单租户处理上限。
待实现/配合:
1. 下载审计:需要 IM 服务端文件下载链路或后台下载代理在实际下载时写入审计。
2. 底层对象物理删除:当前后台将 `im_fileuploads.status` 标记为已删除,物理对象清理需由文件服务或 IM 服务端清理器消费 `location`。
3. 过期文件策略:需要先定义不同 `purpose` 的保留周期和是否允许自动删除。
4. 文件用途识别目前基于 `im_filemsglinks`/群文件索引做基础归类,头像、举报证据、反馈附件等用途需要 IM 服务端补齐绑定关系或独立索引。
### P1-3 外部服务配置
状态:未实现 IM 专属配置。
状态:部分实现。
范围:
已实现:
1. SMS 服务商。
2. Push/APNS/FCM。
3. TURN/STUN。
4. 对象存储。
5. 审核插件。
1. SMS 服务商配置。
2. Push/APNS/FCM 配置。
3. TURN/STUN 配置。
4. 对象存储配置。
5. 审核插件配置。
6. 敏感字段 `secret_json` 只写入,不明文回显,仅返回 `hasSecret`。
7. 基础配置完整性检查,写入 `status`、`checked_at`、`check_result`。
8. 配置变更和检查写入 IM 后台审计。
要求:
待实现/配合:
1. 敏感字段只写入,不明文回显。
2. 提供“已配置/未配置”和连通性检查。
3. 配置变更写审计。
1. 真实连通性检查:需要各服务商 SDK 或 IM 服务端提供探测接口。
2. IM 服务端 `bootstrap`、短信、Push、TURN、对象存储、审核链路需要读取 `im_external_config`。
3. `im_external_config` 为新增控制库表,需要执行 `server/storage/data/generate/im_external_config_menu.sql`。
### P1-4 任务看板
状态:未实现统一任务后台。
状态:部分实现。
范围:
已实现:
1. 租户健康巡检。
2. Outbox 巡检。
3. 文件清理。
4. Push 重试。
5. 搜索索引重建。
6. 邀请码投影补偿。
1. 只读任务看板,按租户汇总 IM 业务库健康状态。
2. Outbox 巡检:统计业务事件和群事件的待投递、失败数量。
3. 文件清理巡检:统计孤儿文件和待物理清理文件数量。
4. 展示最新业务事件、最新群事件、最新文件更新时间。
5. 根据失败/待处理/待清理数量给出基础建议动作。
待实现/配合:
1. 主动执行任务:当前看板只读,不直接触发修复或调度。
2. Push 重试:需要 IM 服务端暴露 Push 失败队列、重试入口或统一任务表。
3. 搜索索引重建:需要 IM 服务端定义索引任务入口、进度查询和失败重试模型。
4. 邀请码投影补偿:需要 IM 服务端或后台定义投影任务表、幂等键和重放策略。
5. 文件物理清理任务详情:需要文件服务或 IM 服务端回写物理删除结果。
6. 跨租户任务执行审计:执行型任务落地后需补充独立权限点和审计明细。
## 后续 P2
@@ -0,0 +1,41 @@
package imexternalconfig
import (
"hotgo/internal/model/input/form"
"hotgo/internal/model/input/sysin"
"github.com/gogf/gf/v2/frame/g"
)
type ListReq struct {
g.Meta `path:"/imExternalConfig/list" method:"get" tags:"IM外部服务配置" summary:"获取IM外部服务配置列表"`
sysin.ImExternalConfigListInp
}
type ListRes struct {
form.PageRes
List []*sysin.ImExternalConfigListModel `json:"list" dc:"数据列表"`
}
type ViewReq struct {
g.Meta `path:"/imExternalConfig/view" method:"get" tags:"IM外部服务配置" summary:"获取IM外部服务配置详情"`
sysin.ImExternalConfigViewInp
}
type ViewRes struct {
*sysin.ImExternalConfigViewModel
}
type UpdateReq struct {
g.Meta `path:"/imExternalConfig/update" method:"post" tags:"IM外部服务配置" summary:"更新IM外部服务配置"`
sysin.ImExternalConfigUpdateInp
}
type UpdateRes struct{}
type CheckReq struct {
g.Meta `path:"/imExternalConfig/check" method:"post" tags:"IM外部服务配置" summary:"检查IM外部服务配置"`
sysin.ImExternalConfigCheckInp
}
type CheckRes struct{}
+60
View File
@@ -0,0 +1,60 @@
// Package imfile
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package imfile
import (
"hotgo/internal/model/input/form"
"hotgo/internal/model/input/sysin"
"github.com/gogf/gf/v2/frame/g"
)
// ListReq 查询IM文件列表
type ListReq struct {
g.Meta `path:"/imFile/list" method:"get" tags:"IM文件治理" summary:"获取IM文件列表"`
sysin.ImFileListInp
}
type ListRes struct {
form.PageRes
List []*sysin.ImFileListModel `json:"list" dc:"数据列表"`
}
// ViewReq 获取IM文件详情
type ViewReq struct {
g.Meta `path:"/imFile/view" method:"get" tags:"IM文件治理" summary:"获取IM文件详情"`
sysin.ImFileViewInp
}
type ViewRes struct {
*sysin.ImFileViewModel
}
// QuarantineReq 隔离IM文件
type QuarantineReq struct {
g.Meta `path:"/imFile/quarantine" method:"post" tags:"IM文件治理" summary:"隔离IM文件"`
sysin.ImFileActionInp
}
type QuarantineRes struct{}
// DeleteReq 删除IM文件
type DeleteReq struct {
g.Meta `path:"/imFile/delete" method:"post" tags:"IM文件治理" summary:"删除IM文件"`
sysin.ImFileActionInp
}
type DeleteRes struct{}
// CleanupOrphanReq 清理IM孤儿文件
type CleanupOrphanReq struct {
g.Meta `path:"/imFile/cleanupOrphan" method:"post" tags:"IM文件治理" summary:"清理IM孤儿文件"`
sysin.ImFileCleanupOrphanInp
}
type CleanupOrphanRes struct {
*sysin.ImFileCleanupOrphanModel
}
@@ -0,0 +1,18 @@
package imtaskdashboard
import (
"hotgo/internal/model/input/form"
"hotgo/internal/model/input/sysin"
"github.com/gogf/gf/v2/frame/g"
)
type ListReq struct {
g.Meta `path:"/imTaskDashboard/list" method:"get" tags:"IM任务看板" summary:"获取IM任务看板列表"`
sysin.ImTaskDashboardListInp
}
type ListRes struct {
form.PageRes
List []*sysin.ImTaskDashboardListModel `json:"list" dc:"数据列表"`
}
@@ -0,0 +1,35 @@
package consts
import (
"hotgo/internal/library/dict"
"hotgo/internal/model"
)
func init() {
dict.RegisterEnums("ImExternalServiceOptions", "IM外部服务选项", ImExternalServiceOptions)
dict.RegisterEnums("ImExternalConfigStatusOptions", "IM外部配置状态选项", ImExternalConfigStatusOptions)
}
const (
ImExternalServiceSms = "sms"
ImExternalServicePush = "push"
ImExternalServiceTurn = "turn"
ImExternalServiceStorage = "storage"
ImExternalServiceModeration = "moderation"
ImExternalConfigStatusConfigured = "configured"
ImExternalConfigStatusUnconfigured = "unconfigured"
)
var ImExternalServiceOptions = []*model.Option{
dict.GenSuccessOption(ImExternalServiceSms, "SMS"),
dict.GenPrimaryOption(ImExternalServicePush, "Push"),
dict.GenInfoOption(ImExternalServiceTurn, "TURN/STUN"),
dict.GenWarningOption(ImExternalServiceStorage, "对象存储"),
dict.GenDefaultOption(ImExternalServiceModeration, "审核插件"),
}
var ImExternalConfigStatusOptions = []*model.Option{
dict.GenSuccessOption(ImExternalConfigStatusConfigured, "已配置"),
dict.GenWarningOption(ImExternalConfigStatusUnconfigured, "未配置"),
}
+52
View File
@@ -0,0 +1,52 @@
// Package consts
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package consts
import (
"hotgo/internal/library/dict"
"hotgo/internal/model"
)
func init() {
dict.RegisterEnums("ImFileStatusOptions", "IM文件状态选项", ImFileStatusOptions)
dict.RegisterEnums("ImFilePurposeOptions", "IM文件用途选项", ImFilePurposeOptions)
}
const (
ImFileStatusUploading = 0
ImFileStatusCompleted = 1
ImFileStatusFailed = 2
ImFileStatusDeleted = 3
ImFileStatusQuarantined = -1
ImFilePurposeUnknown = "unknown"
ImFilePurposeChat = "chat"
ImFilePurposeUserAvatar = "user_avatar"
ImFilePurposeGroupAvatar = "group_avatar"
ImFilePurposeGroupFile = "group_file"
ImFilePurposeAbuseEvidence = "abuse_evidence"
ImFilePurposeFeedback = "feedback"
ImFilePurposeOrphan = "orphan"
)
var ImFileStatusOptions = []*model.Option{
dict.GenWarningOption(ImFileStatusUploading, "上传中"),
dict.GenSuccessOption(ImFileStatusCompleted, "已完成"),
dict.GenErrorOption(ImFileStatusFailed, "失败"),
dict.GenDefaultOption(ImFileStatusDeleted, "已删除"),
dict.GenErrorOption(ImFileStatusQuarantined, "已隔离"),
}
var ImFilePurposeOptions = []*model.Option{
dict.GenDefaultOption(ImFilePurposeUnknown, "未知"),
dict.GenSuccessOption(ImFilePurposeChat, "聊天附件"),
dict.GenInfoOption(ImFilePurposeUserAvatar, "用户头像"),
dict.GenInfoOption(ImFilePurposeGroupAvatar, "群头像"),
dict.GenPrimaryOption(ImFilePurposeGroupFile, "群文件"),
dict.GenWarningOption(ImFilePurposeAbuseEvidence, "举报证据"),
dict.GenWarningOption(ImFilePurposeFeedback, "反馈附件"),
dict.GenDefaultOption(ImFilePurposeOrphan, "孤儿文件"),
}
@@ -0,0 +1,22 @@
package consts
import (
"hotgo/internal/library/dict"
"hotgo/internal/model"
)
func init() {
dict.RegisterEnums("ImTaskDashboardStatusOptions", "IM任务看板状态选项", ImTaskDashboardStatusOptions)
}
const (
ImTaskDashboardStatusHealthy = "healthy"
ImTaskDashboardStatusWarning = "warning"
ImTaskDashboardStatusError = "error"
)
var ImTaskDashboardStatusOptions = []*model.Option{
dict.GenSuccessOption(ImTaskDashboardStatusHealthy, "正常"),
dict.GenWarningOption(ImTaskDashboardStatusWarning, "待处理"),
dict.GenErrorOption(ImTaskDashboardStatusError, "异常"),
}
@@ -0,0 +1,46 @@
package sys
import (
"context"
"hotgo/api/admin/imexternalconfig"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
)
var ImExternalConfig = cImExternalConfig{}
type cImExternalConfig struct{}
func (c *cImExternalConfig) List(ctx context.Context, req *imexternalconfig.ListReq) (res *imexternalconfig.ListRes, err error) {
list, totalCount, err := service.SysImExternalConfig().List(ctx, &req.ImExternalConfigListInp)
if err != nil {
return
}
if list == nil {
list = []*sysin.ImExternalConfigListModel{}
}
res = new(imexternalconfig.ListRes)
res.List = list
res.PageRes.Pack(req, totalCount)
return
}
func (c *cImExternalConfig) View(ctx context.Context, req *imexternalconfig.ViewReq) (res *imexternalconfig.ViewRes, err error) {
data, err := service.SysImExternalConfig().View(ctx, &req.ImExternalConfigViewInp)
if err != nil {
return
}
res = new(imexternalconfig.ViewRes)
res.ImExternalConfigViewModel = data
return
}
func (c *cImExternalConfig) Update(ctx context.Context, req *imexternalconfig.UpdateReq) (res *imexternalconfig.UpdateRes, err error) {
err = service.SysImExternalConfig().Update(ctx, &req.ImExternalConfigUpdateInp)
return
}
func (c *cImExternalConfig) Check(ctx context.Context, req *imexternalconfig.CheckReq) (res *imexternalconfig.CheckRes, err error) {
err = service.SysImExternalConfig().Check(ctx, &req.ImExternalConfigCheckInp)
return
}
@@ -0,0 +1,68 @@
// Package sys
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package sys
import (
"context"
"hotgo/api/admin/imfile"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
)
var (
ImFile = cImFile{}
)
type cImFile struct{}
// List 查看IM文件列表
func (c *cImFile) List(ctx context.Context, req *imfile.ListReq) (res *imfile.ListRes, err error) {
list, totalCount, err := service.SysImFile().List(ctx, &req.ImFileListInp)
if err != nil {
return
}
if list == nil {
list = []*sysin.ImFileListModel{}
}
res = new(imfile.ListRes)
res.List = list
res.PageRes.Pack(req, totalCount)
return
}
// View 获取IM文件详情
func (c *cImFile) View(ctx context.Context, req *imfile.ViewReq) (res *imfile.ViewRes, err error) {
data, err := service.SysImFile().View(ctx, &req.ImFileViewInp)
if err != nil {
return
}
res = new(imfile.ViewRes)
res.ImFileViewModel = data
return
}
// Quarantine 隔离IM文件
func (c *cImFile) Quarantine(ctx context.Context, req *imfile.QuarantineReq) (res *imfile.QuarantineRes, err error) {
err = service.SysImFile().Quarantine(ctx, &req.ImFileActionInp)
return
}
// Delete 删除IM文件
func (c *cImFile) Delete(ctx context.Context, req *imfile.DeleteReq) (res *imfile.DeleteRes, err error) {
err = service.SysImFile().Delete(ctx, &req.ImFileActionInp)
return
}
// CleanupOrphan 清理IM孤儿文件
func (c *cImFile) CleanupOrphan(ctx context.Context, req *imfile.CleanupOrphanReq) (res *imfile.CleanupOrphanRes, err error) {
data, err := service.SysImFile().CleanupOrphan(ctx, &req.ImFileCleanupOrphanInp)
if err != nil {
return
}
res = new(imfile.CleanupOrphanRes)
res.ImFileCleanupOrphanModel = data
return
}
@@ -0,0 +1,26 @@
package sys
import (
"context"
"hotgo/api/admin/imtaskdashboard"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
)
var ImTaskDashboard = cImTaskDashboard{}
type cImTaskDashboard struct{}
func (c *cImTaskDashboard) List(ctx context.Context, req *imtaskdashboard.ListReq) (res *imtaskdashboard.ListRes, err error) {
list, totalCount, err := service.SysImTaskDashboard().List(ctx, &req.ImTaskDashboardListInp)
if err != nil {
return
}
if list == nil {
list = []*sysin.ImTaskDashboardListModel{}
}
res = new(imtaskdashboard.ListRes)
res.List = list
res.PageRes.Pack(req, totalCount)
return
}
@@ -0,0 +1,98 @@
// Package crons
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package crons
import (
"context"
"hotgo/internal/consts"
"hotgo/internal/dao"
"hotgo/internal/library/cron"
"hotgo/internal/library/imtenant"
"hotgo/internal/model/entity"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/gconv"
)
func init() {
cron.Register(ImFileCleanupOrphan)
}
// ImFileCleanupOrphan 清理IM孤儿文件,参数:保留天数、单租户处理上限。
var ImFileCleanupOrphan = &cImFileCleanupOrphan{name: "imFileCleanupOrphan"}
type cImFileCleanupOrphan struct {
name string
}
func (c *cImFileCleanupOrphan) GetName() string {
return c.name
}
func (c *cImFileCleanupOrphan) Execute(ctx context.Context, parser *cron.Parser) (err error) {
days := 7
limit := 100
if len(parser.Args) > 0 {
days = gconv.Int(parser.Args[0])
}
if len(parser.Args) > 1 {
limit = gconv.Int(parser.Args[1])
}
if days <= 0 {
days = 7
}
if limit <= 0 || limit > 1000 {
limit = 100
}
var tenants []*entity.ImTenant
if err = imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx).
Where(dao.ImTenant.Columns().State, consts.ImTenantStateActive).
Scan(&tenants); err != nil {
parser.Logger.Warningf(ctx, "cron ImFileCleanupOrphan load tenants err:%+v", err)
return err
}
cutoff := gtime.Now().AddDate(0, 0, -days)
for _, tenant := range tenants {
db, _, dbErr := imtenant.DB(ctx, tenant.Id)
if dbErr != nil {
parser.Logger.Warningf(ctx, "cron ImFileCleanupOrphan tenant:%d db err:%+v", tenant.Id, dbErr)
continue
}
rows, dbErr := db.Model("im_fileuploads fu").Safe().Ctx(ctx).
LeftJoin("im_filemsglinks fml", "fml.fileid=fu.id").
Fields("fu.id").
Where("fml.id IS NULL").
WhereLT("fu.updatedat", cutoff).
WhereNotIn("fu.status", []int{consts.ImFileStatusDeleted, consts.ImFileStatusQuarantined}).
Group("fu.id").
OrderAsc("fu.updatedat").
Limit(limit).
All()
if dbErr != nil {
parser.Logger.Warningf(ctx, "cron ImFileCleanupOrphan tenant:%d scan err:%+v", tenant.Id, dbErr)
continue
}
if len(rows) == 0 {
continue
}
ids := make([]int64, 0, len(rows))
for _, row := range rows {
ids = append(ids, row["id"].Int64())
}
if _, dbErr = db.Model("im_fileuploads").Safe().Ctx(ctx).
WhereIn("id", ids).
Data(g.Map{"status": consts.ImFileStatusDeleted, "updatedat": gtime.Now()}).
Update(); dbErr != nil {
parser.Logger.Warningf(ctx, "cron ImFileCleanupOrphan tenant:%d update err:%+v", tenant.Id, dbErr)
continue
}
parser.Logger.Infof(ctx, "cron ImFileCleanupOrphan tenant:%d count:%d", tenant.Id, len(ids))
}
return nil
}
@@ -0,0 +1,227 @@
package sys
import (
"context"
"hotgo/internal/consts"
"hotgo/internal/dao"
"hotgo/internal/library/contexts"
"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 sSysImExternalConfig struct{}
func NewSysImExternalConfig() *sSysImExternalConfig { return &sSysImExternalConfig{} }
func init() {
service.RegisterSysImExternalConfig(NewSysImExternalConfig())
}
func (s *sSysImExternalConfig) List(ctx context.Context, in *sysin.ImExternalConfigListInp) (list []*sysin.ImExternalConfigListModel, totalCount int, err error) {
if err = in.Filter(ctx); err != nil {
return
}
tenant, err := s.resolveTenant(ctx, in.TenantId)
if err != nil {
return
}
if err = s.ensureRows(ctx, tenant.Id); err != nil {
return
}
mod := s.model(ctx).Where("tenant_id", tenant.Id)
if in.Service != "" {
mod = mod.Where("service", in.Service)
}
if in.Provider != "" {
mod = mod.WhereLike("provider", "%"+in.Provider+"%")
}
if in.Enabled != nil {
mod = mod.Where("enabled", *in.Enabled)
}
mod = mod.Page(in.Page, in.PerPage).OrderAsc("service")
if err = mod.ScanAndCount(&list, &totalCount, false); err != nil {
err = gerror.Wrap(err, "获取IM外部服务配置失败,请稍后重试!")
return
}
for _, item := range list {
item.TenantName = tenant.Name
}
return
}
func (s *sSysImExternalConfig) View(ctx context.Context, in *sysin.ImExternalConfigViewInp) (res *sysin.ImExternalConfigViewModel, err error) {
if err = in.Filter(ctx); err != nil {
return
}
tenant, err := s.resolveTenant(ctx, in.TenantId)
if err != nil {
return nil, err
}
if err = s.ensureRows(ctx, tenant.Id); err != nil {
return nil, err
}
if err = s.model(ctx).Where("tenant_id", tenant.Id).Where("service", in.Service).Scan(&res); err != nil {
return nil, gerror.Wrap(err, "获取IM外部服务配置详情失败,请稍后重试!")
}
if res != nil {
res.TenantName = tenant.Name
}
return
}
func (s *sSysImExternalConfig) Update(ctx context.Context, in *sysin.ImExternalConfigUpdateInp) (err error) {
if err = in.Filter(ctx); err != nil {
return
}
if !s.validService(in.Service) {
return gerror.New("服务类型不正确")
}
tenant, err := s.resolveTenant(ctx, in.TenantId)
if err != nil {
return err
}
if err = s.ensureRows(ctx, tenant.Id); err != nil {
return err
}
configJson := "{}"
if in.ConfigJson != nil && in.ConfigJson.String() != "" && in.ConfigJson.String() != "null" {
configJson = in.ConfigJson.String()
}
data := g.Map{
"provider": in.Provider,
"endpoint": in.Endpoint,
"enabled": in.Enabled,
"config_json": configJson,
"updated_at": gtime.Now(),
"updated_by": contexts.GetUserId(ctx),
}
if in.SecretJson != nil && in.SecretJson.String() != "" && in.SecretJson.String() != "null" && in.SecretJson.String() != "{}" {
data["secret_json"] = in.SecretJson.String()
}
if _, err = imtenant.RegistryDB().Model("im_external_config").Safe().Ctx(ctx).
Where("tenant_id", tenant.Id).Where("service", in.Service).Data(data).Update(); err != nil {
return gerror.Wrap(err, "更新IM外部服务配置失败,请稍后重试!")
}
_ = writeImAdminAudit(ctx, tenant.Id, "im_external_config_update", "im_external_config", 0, "success", g.Map{
"service": in.Service, "provider": in.Provider, "enabled": in.Enabled,
})
return nil
}
func (s *sSysImExternalConfig) Check(ctx context.Context, in *sysin.ImExternalConfigCheckInp) (err error) {
if err = in.Filter(ctx); err != nil {
return
}
tenant, err := s.resolveTenant(ctx, in.TenantId)
if err != nil {
return err
}
if err = s.ensureRows(ctx, tenant.Id); err != nil {
return err
}
var row gdb.Record
if row, err = imtenant.RegistryDB().Model("im_external_config").Safe().Ctx(ctx).
Where("tenant_id", tenant.Id).Where("service", in.Service).One(); err != nil {
return gerror.Wrap(err, "检查IM外部服务配置失败,请稍后重试!")
}
status := consts.ImExternalConfigStatusConfigured
result := "配置完整"
if row == nil || row["enabled"].Int() != 1 || row["provider"].String() == "" {
status = consts.ImExternalConfigStatusUnconfigured
result = "未启用或服务商为空"
}
if _, err = imtenant.RegistryDB().Model("im_external_config").Safe().Ctx(ctx).
Where("tenant_id", tenant.Id).Where("service", in.Service).
Data(g.Map{"status": status, "checked_at": gtime.Now(), "check_result": result, "updated_at": gtime.Now()}).
Update(); err != nil {
return gerror.Wrap(err, "更新IM外部服务检查结果失败,请稍后重试!")
}
_ = writeImAdminAudit(ctx, tenant.Id, "im_external_config_check", "im_external_config", 0, "success", g.Map{
"service": in.Service, "status": status,
})
return nil
}
func (s *sSysImExternalConfig) model(ctx context.Context) *gdb.Model {
return imtenant.RegistryDB().Model("im_external_config").Safe().Ctx(ctx).Fields(
"tenant_id",
"id",
"service",
"provider",
"endpoint",
"enabled",
"status",
"CAST(config_json AS CHAR) config_json",
"CASE WHEN secret_json IS NULL OR CAST(secret_json AS CHAR)='{}' THEN 0 ELSE 1 END has_secret",
"checked_at",
"check_result",
"created_at",
"updated_at",
)
}
func (s *sSysImExternalConfig) resolveTenant(ctx context.Context, selectedTenantId int64) (tenant *entity.ImTenant, err error) {
scope, err := resolveImAdminScope(ctx, selectedTenantId)
if err != nil {
return nil, err
}
if err = imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx).WherePri(scope.TenantId).Scan(&tenant); err != nil {
return nil, gerror.Wrap(err, "获取IM租户失败")
}
if tenant == nil {
return nil, gerror.New("IM租户不存在")
}
return tenant, nil
}
func (s *sSysImExternalConfig) ensureRows(ctx context.Context, tenantId int64) error {
services := []string{
consts.ImExternalServiceSms,
consts.ImExternalServicePush,
consts.ImExternalServiceTurn,
consts.ImExternalServiceStorage,
consts.ImExternalServiceModeration,
}
for _, serviceName := range services {
count, err := imtenant.RegistryDB().Model("im_external_config").Safe().Ctx(ctx).
Where("tenant_id", tenantId).Where("service", serviceName).Count()
if err != nil {
return gerror.Wrap(err, "初始化IM外部服务配置失败")
}
if count > 0 {
continue
}
_, err = imtenant.RegistryDB().Exec(ctx, `INSERT INTO im_external_config
(tenant_id,service,provider,endpoint,enabled,status,config_json,secret_json,created_by,updated_by)
VALUES(?,?,?,?,?,?,CAST(? AS JSON),CAST(? AS JSON),?,?)`,
tenantId, serviceName, "", "", 0, consts.ImExternalConfigStatusUnconfigured, "{}", "{}", contexts.GetUserId(ctx), contexts.GetUserId(ctx))
if err != nil {
return gerror.Wrap(err, "初始化IM外部服务配置失败")
}
}
return nil
}
func (s *sSysImExternalConfig) validService(serviceName string) bool {
switch serviceName {
case consts.ImExternalServiceSms, consts.ImExternalServicePush, consts.ImExternalServiceTurn, consts.ImExternalServiceStorage, consts.ImExternalServiceModeration:
return true
default:
return false
}
}
func externalConfigJson(value string) *gjson.Json {
if value == "" {
return gjson.New("{}")
}
return gjson.New(value)
}
+234
View File
@@ -0,0 +1,234 @@
// Package sys
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package sys
import (
"context"
"hotgo/internal/consts"
"hotgo/internal/model/entity"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"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"
)
type sSysImFile struct{}
func NewSysImFile() *sSysImFile {
return &sSysImFile{}
}
func init() {
service.RegisterSysImFile(NewSysImFile())
}
// List 获取IM文件列表
func (s *sSysImFile) List(ctx context.Context, in *sysin.ImFileListInp) (list []*sysin.ImFileListModel, 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.fileModel(ctx, db)
mod = s.applyWhere(mod, in)
mod = mod.Page(in.Page, in.PerPage).OrderDesc("fu.updatedat").OrderDesc("fu.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
}
// View 获取IM文件详情
func (s *sSysImFile) View(ctx context.Context, in *sysin.ImFileViewInp) (res *sysin.ImFileViewModel, err error) {
if err = in.Filter(ctx); err != nil {
return
}
db, tenant, err := resolveImTenantDB(ctx, in.TenantId)
if err != nil {
return nil, err
}
if err = s.fileModel(ctx, db).Where("fu.id", in.Id).Scan(&res); err != nil {
return nil, gerror.Wrap(err, "获取IM文件详情失败,请稍后重试!")
}
if res == nil {
return nil, nil
}
res.TenantId = tenant.Id
res.TenantName = tenant.Name
if err = db.Model("im_filemsglinks").Safe().Ctx(ctx).
Fields("id", "createdat created_at", "fileid file_id", "COALESCE(msgid,0) msg_id", "COALESCE(topic,'') topic", "COALESCE(userid,0) user_id").
Where("fileid", in.Id).
OrderDesc("id").
Limit(50).
Scan(&res.Links); err != nil {
return nil, gerror.Wrap(err, "获取IM文件引用失败,请稍后重试!")
}
_ = writeImAdminAudit(ctx, tenant.Id, "im_file_view", "im_file", in.Id, "success", g.Map{
"file_id": in.Id,
})
return
}
// Quarantine 隔离IM文件
func (s *sSysImFile) Quarantine(ctx context.Context, in *sysin.ImFileActionInp) (err error) {
if err = in.Filter(ctx); err != nil {
return
}
return s.updateStatus(ctx, in, consts.ImFileStatusQuarantined, "im_file_quarantine")
}
// Delete 删除IM文件
func (s *sSysImFile) Delete(ctx context.Context, in *sysin.ImFileActionInp) (err error) {
if err = in.Filter(ctx); err != nil {
return
}
return s.updateStatus(ctx, in, consts.ImFileStatusDeleted, "im_file_delete")
}
// CleanupOrphan 清理IM孤儿文件
func (s *sSysImFile) CleanupOrphan(ctx context.Context, in *sysin.ImFileCleanupOrphanInp) (res *sysin.ImFileCleanupOrphanModel, err error) {
if err = in.Filter(ctx); err != nil {
return
}
db, tenant, err := resolveImTenantDB(ctx, in.TenantId)
if err != nil {
return nil, err
}
cutoff := gtime.Now().AddDate(0, 0, -in.Days)
rows, err := db.Model("im_fileuploads fu").Safe().Ctx(ctx).
LeftJoin("im_filemsglinks fml", "fml.fileid=fu.id").
Fields("fu.id", "fu.location").
Where("fml.id IS NULL").
WhereLT("fu.updatedat", cutoff).
WhereNotIn("fu.status", []int{consts.ImFileStatusDeleted, consts.ImFileStatusQuarantined}).
Group("fu.id").
OrderAsc("fu.updatedat").
Limit(in.Limit).
All()
if err != nil {
return nil, gerror.Wrap(err, "扫描IM孤儿文件失败,请稍后重试!")
}
res = &sysin.ImFileCleanupOrphanModel{Locations: []string{}}
if len(rows) == 0 {
return res, nil
}
ids := make([]int64, 0, len(rows))
for _, row := range rows {
ids = append(ids, row["id"].Int64())
if location := row["location"].String(); location != "" {
res.Locations = append(res.Locations, location)
}
}
if _, err = db.Model("im_fileuploads").Safe().Ctx(ctx).
WhereIn("id", ids).
Data(g.Map{"status": consts.ImFileStatusDeleted, "updatedat": gtime.Now()}).
Update(); err != nil {
return nil, gerror.Wrap(err, "标记IM孤儿文件失败,请稍后重试!")
}
res.Count = len(ids)
_ = writeImAdminAudit(ctx, tenant.Id, "im_file_cleanup_orphan", "im_file", 0, "success", g.Map{
"days": in.Days, "limit": in.Limit, "count": res.Count,
})
return
}
func (s *sSysImFile) fileModel(ctx context.Context, db gdb.DB) *gdb.Model {
return db.Model("im_fileuploads fu").Safe().Ctx(ctx).
LeftJoin("im_filemsglinks fml", "fml.fileid=fu.id").
Fields(
"fu.id",
"fu.createdat created_at",
"fu.updatedat updated_at",
"COALESCE(fu.userid,0) user_id",
"fu.status",
"fu.mimetype mime_type",
"fu.size",
"COALESCE(fu.etag,'') etag",
"fu.location",
"COUNT(fml.id) link_count",
"COALESCE(MIN(fml.topic),'') first_topic",
"COALESCE(MIN(fml.msgid),0) first_msg_id",
"MIN(fml.createdat) first_link_at",
`CASE
WHEN COUNT(fml.id)=0 THEN 'orphan'
WHEN SUM(CASE WHEN fml.topic LIKE 'grp%' THEN 1 ELSE 0 END)>0 THEN 'chat'
ELSE 'chat'
END purpose`,
).
Group("fu.id")
}
func (s *sSysImFile) applyWhere(mod *gdb.Model, in *sysin.ImFileListInp) *gdb.Model {
if in.FileId > 0 {
mod = mod.Where("fu.id", in.FileId)
}
if in.UserId > 0 {
mod = mod.Where("fu.userid", in.UserId)
}
if in.Status != nil {
mod = mod.Where("fu.status", *in.Status)
}
if in.MimeType != "" {
mod = mod.WhereLike("fu.mimetype", "%"+in.MimeType+"%")
}
if in.Topic != "" {
mod = mod.WhereLike("fml.topic", "%"+in.Topic+"%")
}
if in.Purpose == consts.ImFilePurposeOrphan {
mod = mod.Having("link_count = 0")
}
if in.Purpose != "" && in.Purpose != consts.ImFilePurposeOrphan && in.Purpose != consts.ImFilePurposeUnknown {
mod = mod.Having("purpose", in.Purpose)
}
if len(in.CreatedAt) == 2 {
mod = mod.WhereBetween("fu.createdat", in.CreatedAt[0], in.CreatedAt[1])
}
if len(in.UpdatedAt) == 2 {
mod = mod.WhereBetween("fu.updatedat", in.UpdatedAt[0], in.UpdatedAt[1])
}
return mod
}
func (s *sSysImFile) updateStatus(ctx context.Context, in *sysin.ImFileActionInp, status int, action string) (err error) {
db, tenant, err := resolveImTenantDB(ctx, in.TenantId)
if err != nil {
return err
}
current, err := db.Model("im_fileuploads").Safe().Ctx(ctx).Where("id", in.Id).One()
if err != nil {
return gerror.Wrap(err, "获取IM文件失败,请稍后重试!")
}
if current == nil {
return gerror.New("IM文件不存在")
}
if current["status"].Int() == consts.ImFileStatusDeleted && status == consts.ImFileStatusDeleted {
return gerror.New("IM文件已删除")
}
if _, err = db.Model("im_fileuploads").Safe().Ctx(ctx).
Where("id", in.Id).
Data(g.Map{"status": status, "updatedat": gtime.Now()}).
Update(); err != nil {
return gerror.Wrap(err, "更新IM文件状态失败,请稍后重试!")
}
_ = writeImAdminAudit(ctx, tenant.Id, action, "im_file", in.Id, "success", g.Map{
"file_id": in.Id, "before_status": current["status"].Int(), "after_status": status, "reason": in.Reason,
})
return nil
}
func (s *sSysImFile) tenantDB(ctx context.Context, selectedTenantId int64) (db gdb.DB, tenant *entity.ImTenant, err error) {
return resolveImTenantDB(ctx, selectedTenantId)
}
@@ -0,0 +1,117 @@
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/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
)
type sSysImTaskDashboard struct{}
func NewSysImTaskDashboard() *sSysImTaskDashboard { return &sSysImTaskDashboard{} }
func init() {
service.RegisterSysImTaskDashboard(NewSysImTaskDashboard())
}
func (s *sSysImTaskDashboard) List(ctx context.Context, in *sysin.ImTaskDashboardListInp) (list []*sysin.ImTaskDashboardListModel, totalCount int, err error) {
if err = in.Filter(ctx); err != nil {
return
}
scope, err := resolveImAdminScopeWithAll(ctx, in.TenantId, true)
if err != nil {
return
}
mod := imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx)
if scope.TenantId > 0 {
mod = mod.WherePri(scope.TenantId)
}
if in.TenantId > 0 {
mod = mod.WherePri(in.TenantId)
}
mod = mod.Page(in.Page, in.PerPage).OrderAsc(dao.ImTenant.Columns().Id)
var tenants []*entity.ImTenant
if err = mod.ScanAndCount(&tenants, &totalCount, false); err != nil {
err = gerror.Wrap(err, "获取IM任务看板租户失败,请稍后重试!")
return
}
list = make([]*sysin.ImTaskDashboardListModel, 0, len(tenants))
for _, tenant := range tenants {
item := s.buildTenantSummary(ctx, tenant)
if in.Status != "" && item.Status != in.Status {
continue
}
list = append(list, item)
}
if in.Status != "" {
totalCount = len(list)
}
return
}
func (s *sSysImTaskDashboard) buildTenantSummary(ctx context.Context, tenant *entity.ImTenant) *sysin.ImTaskDashboardListModel {
item := &sysin.ImTaskDashboardListModel{
TenantId: tenant.Id,
TenantName: tenant.Name,
TenantCode: tenant.Code,
TenantState: tenant.State,
DbStatus: "ok",
Status: consts.ImTaskDashboardStatusHealthy,
RecommendedNextAction: "无需处理",
}
db, _, err := imtenant.DB(ctx, tenant.Id)
if err != nil {
item.DbStatus = "error"
item.Status = consts.ImTaskDashboardStatusError
item.RecommendedNextAction = "检查租户数据库连接"
return item
}
item.OutboxPendingCount, _ = s.count(ctx, db, "im_business_events_outbox", "status='pending'")
item.OutboxFailedCount, _ = s.count(ctx, db, "im_business_events_outbox", "status='failed'")
item.GroupOutboxPending, _ = s.count(ctx, db, "im_group_events_outbox", "status='pending'")
item.GroupOutboxFailed, _ = s.count(ctx, db, "im_group_events_outbox", "status='failed'")
item.OrphanFileCount, _ = s.countOrphanFiles(ctx, db)
item.DeletedFileCount, _ = s.count(ctx, db, "im_fileuploads", "status=3")
item.LastOutboxCreatedAt, _ = s.maxTime(ctx, db, "im_business_events_outbox", "created_at")
item.LastGroupEventAt, _ = s.maxTime(ctx, db, "im_group_events_outbox", "created_at")
item.LastFileUpdatedAt, _ = s.maxTime(ctx, db, "im_fileuploads", "updatedat")
if item.OutboxFailedCount > 0 || item.GroupOutboxFailed > 0 {
item.Status = consts.ImTaskDashboardStatusError
item.RecommendedNextAction = "处理失败Outbox事件"
} else if item.OutboxPendingCount > 0 || item.GroupOutboxPending > 0 || item.OrphanFileCount > 0 || item.DeletedFileCount > 0 {
item.Status = consts.ImTaskDashboardStatusWarning
item.RecommendedNextAction = "检查待处理任务和文件清理"
}
return item
}
func (s *sSysImTaskDashboard) count(ctx context.Context, db gdb.DB, table string, where string) (int, error) {
return db.Model(table).Safe().Ctx(ctx).Where(where).Count()
}
func (s *sSysImTaskDashboard) countOrphanFiles(ctx context.Context, db gdb.DB) (int, error) {
return db.Model("im_fileuploads fu").Safe().Ctx(ctx).
LeftJoin("im_filemsglinks fml", "fml.fileid=fu.id").
Where("fml.id IS NULL").
WhereNotIn("fu.status", []int{consts.ImFileStatusDeleted, consts.ImFileStatusQuarantined}).
Count()
}
func (s *sSysImTaskDashboard) maxTime(ctx context.Context, db gdb.DB, table string, field string) (res *gtime.Time, err error) {
var row struct {
Value *gtime.Time `orm:"value"`
}
if err = db.Model(table).Safe().Ctx(ctx).Fields("MAX(" + field + ") value").Scan(&row); err != nil {
return nil, err
}
return row.Value, nil
}
@@ -0,0 +1,85 @@
package sysin
import (
"context"
"hotgo/internal/model/input/form"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
)
type ImExternalConfigListInp struct {
form.PageReq
TenantId int64 `json:"tenantId" dc:"租户ID"`
Service string `json:"service" dc:"服务"`
Provider string `json:"provider" dc:"服务商"`
Enabled *int `json:"enabled" dc:"是否启用"`
}
func (in *ImExternalConfigListInp) Filter(ctx context.Context) (err error) { return }
type ImExternalConfigListModel struct {
TenantId int64 `json:"tenantId" dc:"租户ID"`
TenantName string `json:"tenantName" dc:"租户名称"`
Id int64 `json:"id" dc:"ID"`
Service string `json:"service" dc:"服务"`
Provider string `json:"provider" dc:"服务商"`
Endpoint string `json:"endpoint" dc:"端点"`
Enabled int `json:"enabled" dc:"是否启用"`
Status string `json:"status" dc:"配置状态"`
ConfigJson string `json:"configJson" dc:"普通配置"`
HasSecret bool `json:"hasSecret" dc:"是否有敏感配置"`
CheckedAt *gtime.Time `json:"checkedAt" dc:"检查时间"`
CheckResult string `json:"checkResult" dc:"检查结果"`
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"`
}
type ImExternalConfigViewInp struct {
TenantId int64 `json:"tenantId" dc:"租户ID"`
Service string `json:"service" v:"required#服务不能为空" dc:"服务"`
}
func (in *ImExternalConfigViewInp) Filter(ctx context.Context) (err error) {
if in.Service == "" {
return gerror.New("服务不能为空")
}
return
}
type ImExternalConfigViewModel struct {
ImExternalConfigListModel
}
type ImExternalConfigUpdateInp struct {
TenantId int64 `json:"tenantId" dc:"租户ID"`
Service string `json:"service" v:"required#服务不能为空" dc:"服务"`
Provider string `json:"provider" dc:"服务商"`
Endpoint string `json:"endpoint" dc:"端点"`
Enabled int `json:"enabled" dc:"是否启用"`
ConfigJson *gjson.Json `json:"configJson" dc:"普通配置"`
SecretJson *gjson.Json `json:"secretJson" dc:"敏感配置,空则保持原值"`
}
func (in *ImExternalConfigUpdateInp) Filter(ctx context.Context) (err error) {
if in.Service == "" {
return gerror.New("服务不能为空")
}
if in.Enabled != 0 && in.Enabled != 1 {
return gerror.New("启用状态不正确")
}
return
}
type ImExternalConfigCheckInp struct {
TenantId int64 `json:"tenantId" dc:"租户ID"`
Service string `json:"service" v:"required#服务不能为空" dc:"服务"`
}
func (in *ImExternalConfigCheckInp) Filter(ctx context.Context) (err error) {
if in.Service == "" {
return gerror.New("服务不能为空")
}
return
}
@@ -0,0 +1,116 @@
// Package sysin
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package sysin
import (
"context"
"hotgo/internal/model/input/form"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
)
type ImFileListInp struct {
form.PageReq
TenantId int64 `json:"tenantId" dc:"租户ID"`
FileId int64 `json:"fileId" dc:"文件ID"`
UserId int64 `json:"userId" dc:"上传用户ID"`
Status *int `json:"status" dc:"状态"`
Purpose string `json:"purpose" dc:"用途"`
MimeType string `json:"mimeType" dc:"MIME类型"`
Topic string `json:"topic" dc:"关联Topic"`
CreatedAt []*gtime.Time `json:"createdAt" dc:"创建时间"`
UpdatedAt []*gtime.Time `json:"updatedAt" dc:"更新时间"`
}
func (in *ImFileListInp) Filter(ctx context.Context) (err error) {
return
}
type ImFileListModel struct {
TenantId int64 `json:"tenantId" dc:"租户ID"`
TenantName string `json:"tenantName" dc:"租户名称"`
Id int64 `json:"id" dc:"文件ID"`
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"`
UserId int64 `json:"userId" dc:"上传用户ID"`
Status int `json:"status" dc:"状态"`
MimeType string `json:"mimeType" dc:"MIME类型"`
Size int64 `json:"size" dc:"大小"`
Etag string `json:"etag" dc:"ETag"`
Location string `json:"location" dc:"文件位置"`
Purpose string `json:"purpose" dc:"用途"`
LinkCount int `json:"linkCount" dc:"引用数"`
FirstTopic string `json:"firstTopic" dc:"首个关联Topic"`
FirstMsgId int64 `json:"firstMsgId" dc:"首个关联消息ID"`
FirstLinkAt *gtime.Time `json:"firstLinkAt" dc:"首个关联时间"`
}
type ImFileViewInp struct {
TenantId int64 `json:"tenantId" dc:"租户ID"`
Id int64 `json:"id" v:"required#文件ID不能为空" dc:"文件ID"`
}
func (in *ImFileViewInp) Filter(ctx context.Context) (err error) {
if in.Id <= 0 {
return gerror.New("文件ID不能为空")
}
return
}
type ImFileViewModel struct {
ImFileListModel
Links []*ImFileLinkModel `json:"links" dc:"引用列表"`
}
type ImFileLinkModel struct {
Id int64 `json:"id" dc:"ID"`
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
FileId int64 `json:"fileId" dc:"文件ID"`
MsgId int64 `json:"msgId" dc:"消息ID"`
Topic string `json:"topic" dc:"Topic"`
UserId int64 `json:"userId" dc:"用户ID"`
}
type ImFileActionInp struct {
TenantId int64 `json:"tenantId" dc:"租户ID"`
Id int64 `json:"id" v:"required#文件ID不能为空" dc:"文件ID"`
Reason string `json:"reason" dc:"原因"`
}
func (in *ImFileActionInp) Filter(ctx context.Context) (err error) {
if in.Id <= 0 {
return gerror.New("文件ID不能为空")
}
if len([]rune(in.Reason)) > 200 {
return gerror.New("原因不能超过200个字符")
}
return
}
type ImFileCleanupOrphanInp struct {
TenantId int64 `json:"tenantId" dc:"租户ID"`
Days int `json:"days" dc:"保留天数"`
Limit int `json:"limit" dc:"处理上限"`
}
func (in *ImFileCleanupOrphanInp) Filter(ctx context.Context) (err error) {
if in.Days <= 0 {
in.Days = 7
}
if in.Limit <= 0 {
in.Limit = 100
}
if in.Limit > 1000 {
return gerror.New("单次清理上限不能超过1000")
}
return
}
type ImFileCleanupOrphanModel struct {
Count int `json:"count" dc:"标记数量"`
Locations []string `json:"locations" dc:"待物理清理位置"`
}
@@ -0,0 +1,35 @@
package sysin
import (
"context"
"hotgo/internal/model/input/form"
"github.com/gogf/gf/v2/os/gtime"
)
type ImTaskDashboardListInp struct {
form.PageReq
TenantId int64 `json:"tenantId" dc:"租户ID"`
Status string `json:"status" dc:"状态"`
}
func (in *ImTaskDashboardListInp) Filter(ctx context.Context) (err error) { return }
type ImTaskDashboardListModel struct {
TenantId int64 `json:"tenantId" dc:"租户ID"`
TenantName string `json:"tenantName" dc:"租户名称"`
TenantCode string `json:"tenantCode" dc:"租户编码"`
TenantState int `json:"tenantState" dc:"租户状态"`
DbStatus string `json:"dbStatus" dc:"数据库状态"`
Status string `json:"status" dc:"看板状态"`
OutboxPendingCount int `json:"outboxPendingCount" dc:"业务Outbox待处理"`
OutboxFailedCount int `json:"outboxFailedCount" dc:"业务Outbox失败"`
GroupOutboxPending int `json:"groupOutboxPending" dc:"群Outbox待处理"`
GroupOutboxFailed int `json:"groupOutboxFailed" dc:"群Outbox失败"`
OrphanFileCount int `json:"orphanFileCount" dc:"孤儿文件数"`
DeletedFileCount int `json:"deletedFileCount" dc:"待物理清理文件数"`
LastOutboxCreatedAt *gtime.Time `json:"lastOutboxCreatedAt" dc:"最新Outbox时间"`
LastGroupEventAt *gtime.Time `json:"lastGroupEventAt" dc:"最新群事件时间"`
LastFileUpdatedAt *gtime.Time `json:"lastFileUpdatedAt" dc:"最新文件更新时间"`
RecommendedNextAction string `json:"recommendedNextAction" dc:"建议动作"`
}
@@ -0,0 +1,7 @@
package genrouter
import "hotgo/internal/controller/admin/sys"
func init() {
LoginRequiredRouter = append(LoginRequiredRouter, sys.ImExternalConfig) // IM外部服务配置
}
@@ -0,0 +1,12 @@
// Package genrouter
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package genrouter
import "hotgo/internal/controller/admin/sys"
func init() {
LoginRequiredRouter = append(LoginRequiredRouter, sys.ImFile) // IM文件治理
}
@@ -0,0 +1,7 @@
package genrouter
import "hotgo/internal/controller/admin/sys"
func init() {
LoginRequiredRouter = append(LoginRequiredRouter, sys.ImTaskDashboard) // IM任务看板
}
+92 -30
View File
@@ -293,6 +293,32 @@ type (
// Dead 标记IM Outbox死信
Dead(ctx context.Context, in *sysin.ImOutboxActionInp) (err error)
}
ISysImFile interface {
// List 获取IM文件列表
List(ctx context.Context, in *sysin.ImFileListInp) (list []*sysin.ImFileListModel, totalCount int, err error)
// View 获取IM文件详情
View(ctx context.Context, in *sysin.ImFileViewInp) (res *sysin.ImFileViewModel, err error)
// Quarantine 隔离IM文件
Quarantine(ctx context.Context, in *sysin.ImFileActionInp) (err error)
// Delete 删除IM文件
Delete(ctx context.Context, in *sysin.ImFileActionInp) (err error)
// CleanupOrphan 清理IM孤儿文件
CleanupOrphan(ctx context.Context, in *sysin.ImFileCleanupOrphanInp) (res *sysin.ImFileCleanupOrphanModel, err error)
}
ISysImExternalConfig interface {
// List 获取IM外部服务配置列表
List(ctx context.Context, in *sysin.ImExternalConfigListInp) (list []*sysin.ImExternalConfigListModel, totalCount int, err error)
// View 获取IM外部服务配置详情
View(ctx context.Context, in *sysin.ImExternalConfigViewInp) (res *sysin.ImExternalConfigViewModel, err error)
// Update 更新IM外部服务配置
Update(ctx context.Context, in *sysin.ImExternalConfigUpdateInp) (err error)
// Check 检查IM外部服务配置
Check(ctx context.Context, in *sysin.ImExternalConfigCheckInp) (err error)
}
ISysImTaskDashboard interface {
// List 获取IM任务看板列表
List(ctx context.Context, in *sysin.ImTaskDashboardListInp) (list []*sysin.ImTaskDashboardListModel, totalCount int, err error)
}
ISysImAuditLog interface {
// List 获取IM审计日志列表
List(ctx context.Context, in *sysin.ImAuditLogListInp) (list []*sysin.ImAuditLogListModel, totalCount int, err error)
@@ -536,36 +562,39 @@ type (
)
var (
localSysAddons ISysAddons
localSysAddonsConfig ISysAddonsConfig
localSysAttachment ISysAttachment
localSysBlacklist ISysBlacklist
localSysConfig ISysConfig
localSysCron ISysCron
localSysCronGroup ISysCronGroup
localSysCurdDemo ISysCurdDemo
localSysImTenant ISysImTenant
localSysImRegistration ISysImRegistration
localSysImInvite ISysImInvite
localSysImUser ISysImUser
localSysImGroup ISysImGroup
localSysImRealname ISysImRealname
localSysImWorkOrder ISysImWorkOrder
localSysImOutbox ISysImOutbox
localSysImAuditLog ISysImAuditLog
localSysDictData ISysDictData
localSysDictType ISysDictType
localSysEmsLog ISysEmsLog
localSysGenCodes ISysGenCodes
localSysLog ISysLog
localSysLoginLog ISysLoginLog
localSysNormalTreeDemo ISysNormalTreeDemo
localSysOptionTreeDemo ISysOptionTreeDemo
localSysProvinces ISysProvinces
localSysServeLicense ISysServeLicense
localSysServeLog ISysServeLog
localSysSmsLog ISysSmsLog
localSysTestCategory ISysTestCategory
localSysAddons ISysAddons
localSysAddonsConfig ISysAddonsConfig
localSysAttachment ISysAttachment
localSysBlacklist ISysBlacklist
localSysConfig ISysConfig
localSysCron ISysCron
localSysCronGroup ISysCronGroup
localSysCurdDemo ISysCurdDemo
localSysImTenant ISysImTenant
localSysImRegistration ISysImRegistration
localSysImInvite ISysImInvite
localSysImUser ISysImUser
localSysImGroup ISysImGroup
localSysImRealname ISysImRealname
localSysImWorkOrder ISysImWorkOrder
localSysImOutbox ISysImOutbox
localSysImFile ISysImFile
localSysImExternalConfig ISysImExternalConfig
localSysImTaskDashboard ISysImTaskDashboard
localSysImAuditLog ISysImAuditLog
localSysDictData ISysDictData
localSysDictType ISysDictType
localSysEmsLog ISysEmsLog
localSysGenCodes ISysGenCodes
localSysLog ISysLog
localSysLoginLog ISysLoginLog
localSysNormalTreeDemo ISysNormalTreeDemo
localSysOptionTreeDemo ISysOptionTreeDemo
localSysProvinces ISysProvinces
localSysServeLicense ISysServeLicense
localSysServeLog ISysServeLog
localSysSmsLog ISysSmsLog
localSysTestCategory ISysTestCategory
)
func SysAddons() ISysAddons {
@@ -744,6 +773,39 @@ func RegisterSysImOutbox(i ISysImOutbox) {
localSysImOutbox = i
}
func SysImFile() ISysImFile {
if localSysImFile == nil {
panic("implement not found for interface ISysImFile, forgot register?")
}
return localSysImFile
}
func RegisterSysImFile(i ISysImFile) {
localSysImFile = i
}
func SysImExternalConfig() ISysImExternalConfig {
if localSysImExternalConfig == nil {
panic("implement not found for interface ISysImExternalConfig, forgot register?")
}
return localSysImExternalConfig
}
func RegisterSysImExternalConfig(i ISysImExternalConfig) {
localSysImExternalConfig = i
}
func SysImTaskDashboard() ISysImTaskDashboard {
if localSysImTaskDashboard == nil {
panic("implement not found for interface ISysImTaskDashboard, forgot register?")
}
return localSysImTaskDashboard
}
func RegisterSysImTaskDashboard(i ISysImTaskDashboard) {
localSysImTaskDashboard = i
}
func SysImAuditLog() ISysImAuditLog {
if localSysImAuditLog == nil {
panic("implement not found for interface ISysImAuditLog, forgot register?")
@@ -0,0 +1,50 @@
SET @now = NOW();
CREATE TABLE IF NOT EXISTS `im_external_config` (
`id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` bigint NOT NULL,
`service` varchar(32) NOT NULL,
`provider` varchar(64) NOT NULL DEFAULT '',
`endpoint` varchar(255) NOT NULL DEFAULT '',
`enabled` tinyint NOT NULL DEFAULT 0,
`status` varchar(32) NOT NULL DEFAULT 'unconfigured',
`config_json` json DEFAULT NULL,
`secret_json` json DEFAULT NULL,
`checked_at` datetime DEFAULT NULL,
`check_result` varchar(255) NOT NULL DEFAULT '',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`created_by` bigint NOT NULL DEFAULT 0,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`updated_by` bigint NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_im_external_config_tenant_service` (`tenant_id`,`service`),
KEY `idx_im_external_config_service_status` (`service`,`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, '0', 'IM管理', 'imManager', '/im', '', '1', '/im/tenant/index', '', '', 'LAYOUT', '1', '', '1', '0', '', '0', '0', '0', '1', '', '200', '', '1', @now, @now
WHERE @imRootId IS NULL;
SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1;
SELECT @configId := `id` FROM `hg_admin_menu` WHERE `name` = 'imExternalConfigIndex' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @imRootId, '外部服务配置', 'imExternalConfigIndex', 'external-config/index', '', '2', '', '/imExternalConfig/list', '', '/imExternalConfig/index', '1', 'imManager', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '80', '', '1', @now, @now
WHERE @configId IS NULL;
SELECT @configId := `id` FROM `hg_admin_menu` WHERE `name` = 'imExternalConfigIndex' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @configId, '配置详情', 'imExternalConfigView', '', '', '3', '', '/imExternalConfig/view', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @configId, ' '), '10', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imExternalConfigView');
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @configId, '更新配置', 'imExternalConfigUpdate', '', '', '3', '', '/imExternalConfig/update', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @configId, ' '), '20', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imExternalConfigUpdate');
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @configId, '检查配置', 'imExternalConfigCheck', '', '', '3', '', '/imExternalConfig/check', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @configId, ' '), '30', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imExternalConfigCheck');
@@ -0,0 +1,33 @@
SET @now = NOW();
SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, '0', 'IM管理', 'imManager', '/im', '', '1', '/im/tenant/index', '', '', 'LAYOUT', '1', '', '1', '0', '', '0', '0', '0', '1', '', '200', '', '1', @now, @now
WHERE @imRootId IS NULL;
SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1;
SELECT @fileId := `id` FROM `hg_admin_menu` WHERE `name` = 'imFileIndex' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @imRootId, '文件治理', 'imFileIndex', 'file/index', '', '2', '', '/imFile/list', '', '/imFile/index', '1', 'imManager', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '70', '', '1', @now, @now
WHERE @fileId IS NULL;
SELECT @fileId := `id` FROM `hg_admin_menu` WHERE `name` = 'imFileIndex' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @fileId, '文件详情', 'imFileView', '', '', '3', '', '/imFile/view', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @fileId, ' '), '10', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imFileView');
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @fileId, '隔离文件', 'imFileQuarantine', '', '', '3', '', '/imFile/quarantine', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @fileId, ' '), '20', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imFileQuarantine');
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @fileId, '删除文件', 'imFileDelete', '', '', '3', '', '/imFile/delete', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @fileId, ' '), '30', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imFileDelete');
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @fileId, '清理孤儿文件', 'imFileCleanupOrphan', '', '', '3', '', '/imFile/cleanupOrphan', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @fileId, ' '), '40', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imFileCleanupOrphan');
@@ -0,0 +1,13 @@
SET @now = NOW();
SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, '0', 'IM管理', 'imManager', '/im', '', '1', '/im/tenant/index', '', '', 'LAYOUT', '1', '', '1', '0', '', '0', '0', '0', '1', '', '200', '', '1', @now, @now
WHERE @imRootId IS NULL;
SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @imRootId, '任务看板', 'imTaskDashboardIndex', 'task-dashboard/index', '', '2', '', '/imTaskDashboard/list', '', '/imTaskDashboard/index', '1', 'imManager', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '90', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imTaskDashboardIndex');
+17
View File
@@ -0,0 +1,17 @@
import { http } from '@/utils/http/axios';
export function List(params) {
return http.request({ url: '/imExternalConfig/list', method: 'GET', params });
}
export function View(params) {
return http.request({ url: '/imExternalConfig/view', method: 'GET', params });
}
export function Update(params) {
return http.request({ url: '/imExternalConfig/update', method: 'POST', params });
}
export function Check(params) {
return http.request({ url: '/imExternalConfig/check', method: 'POST', params });
}
+46
View File
@@ -0,0 +1,46 @@
import { http } from '@/utils/http/axios';
// 获取IM文件列表
export function List(params) {
return http.request({
url: '/imFile/list',
method: 'GET',
params,
});
}
// 获取IM文件详情
export function View(params) {
return http.request({
url: '/imFile/view',
method: 'GET',
params,
});
}
// 隔离IM文件
export function Quarantine(params) {
return http.request({
url: '/imFile/quarantine',
method: 'POST',
params,
});
}
// 删除IM文件
export function Delete(params) {
return http.request({
url: '/imFile/delete',
method: 'POST',
params,
});
}
// 清理IM孤儿文件
export function CleanupOrphan(params) {
return http.request({
url: '/imFile/cleanupOrphan',
method: 'POST',
params,
});
}
+5
View File
@@ -0,0 +1,5 @@
import { http } from '@/utils/http/axios';
export function List(params) {
return http.request({ url: '/imTaskDashboard/list', method: 'GET', params });
}
+118
View File
@@ -0,0 +1,118 @@
<template>
<n-modal
v-model:show="showModal"
preset="dialog"
:show-icon="false"
:title="'外部服务配置 - ' + serviceLabel"
:style="{ width: dialogWidth }"
>
<n-form ref="formRef" :model="formValue" label-placement="left" label-width="90">
<n-form-item label="服务商">
<n-input v-model:value="formValue.provider" placeholder="请输入服务商" />
</n-form-item>
<n-form-item label="端点">
<n-input v-model:value="formValue.endpoint" placeholder="请输入服务端点" />
</n-form-item>
<n-form-item label="启用">
<n-switch v-model:value="enabledValue" />
</n-form-item>
<n-form-item label="普通配置">
<n-input
v-model:value="formValue.configJson"
type="textarea"
:autosize="{ minRows: 5, maxRows: 10 }"
placeholder="请输入JSON配置"
/>
</n-form-item>
<n-form-item label="敏感配置">
<n-input
v-model:value="formValue.secretJson"
type="textarea"
:autosize="{ minRows: 4, maxRows: 8 }"
placeholder="留空则保持原敏感配置"
/>
</n-form-item>
</n-form>
<template #action>
<n-space>
<n-button @click="closeForm">取消</n-button>
<n-button type="primary" :loading="submitLoading" @click="handleSubmit">保存</n-button>
</n-space>
</template>
</n-modal>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { useMessage } from 'naive-ui';
import { useDictStore } from '@/store/modules/dict';
import { adaModalWidth } from '@/utils/hotgo';
import { newState, State } from './model';
import { Update } from '@/api/imExternalConfig';
const emit = defineEmits(['reloadTable']);
const message = useMessage();
const dict = useDictStore();
const showModal = ref(false);
const submitLoading = ref(false);
const formRef = ref();
const formValue = ref<State>(newState(null));
const dialogWidth = computed(() => adaModalWidth(760));
const serviceLabel = computed(
() =>
dict.getLabel('ImExternalServiceOptions', formValue.value.service) || formValue.value.service
);
const enabledValue = computed({
get: () => formValue.value.enabled === 1,
set: (value: boolean) => {
formValue.value.enabled = value ? 1 : 0;
},
});
function closeForm() {
showModal.value = false;
}
function parseJson(value: string, field: string) {
const text = (value || '').trim();
if (!text) return null;
try {
return JSON.parse(text);
} catch {
throw new Error(`${field}不是合法JSON`);
}
}
async function handleSubmit() {
submitLoading.value = true;
try {
const configJson = parseJson(formValue.value.configJson || '{}', '普通配置') || {};
const secretJson = parseJson(formValue.value.secretJson, '敏感配置');
await Update({
tenantId: formValue.value.tenantId,
service: formValue.value.service,
provider: formValue.value.provider,
endpoint: formValue.value.endpoint,
enabled: formValue.value.enabled,
configJson,
secretJson,
});
message.success('操作成功');
closeForm();
emit('reloadTable');
} catch (e: any) {
message.error(e.message || '操作失败');
} finally {
submitLoading.value = false;
}
}
function openModal(state: State) {
formValue.value = newState(state);
formValue.value.secretJson = '';
if (!formValue.value.configJson) formValue.value.configJson = '{}';
showModal.value = true;
}
defineExpose({ openModal });
</script>
+133
View File
@@ -0,0 +1,133 @@
<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.service"
:actionColumn="actionColumn"
:scroll-x="scrollX"
:resizeHeightOffset="-10000"
/>
</n-card>
<EditModal ref="editRef" @reload-table="reloadTable" />
</div>
</template>
<script lang="ts" setup>
import { computed, h, onMounted, reactive, ref } from 'vue';
import { useDialog, useMessage } from 'naive-ui';
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 { Check, List } from '@/api/imExternalConfig';
import { columns, loadOptions, loadTenantOptions, schemas, State, tenantOptions } from './model';
import { adaTableScrollX } from '@/utils/hotgo';
import EditModal from './edit.vue';
const message = useMessage();
const dialog = useDialog();
const { hasPermission } = usePermission();
const userStore = useUserStore();
const isPlatformAdmin = computed(() => userStore.isCompanyDept);
const actionRef = ref();
const searchFormRef = ref<any>({});
const editRef = ref();
const tenantSchema = schemas.value.find((item) => item.field === 'tenantId');
if (tenantSchema) {
tenantSchema.ifShow = () => isPlatformAdmin.value;
}
const actionColumn = reactive({
width: 170,
title: '操作',
key: 'action',
fixed: 'right',
render(record: State) {
return h(TableAction as any, {
style: 'button',
actions: [
{
label: '编辑',
auth: ['/imExternalConfig/update'],
onClick: () => editRef.value.openModal(record),
},
{
label: '检查',
type: 'warning',
auth: ['/imExternalConfig/check'],
onClick: () => handleCheck(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 List(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 handleCheck(record: State) {
dialog.warning({
title: '检查配置',
content: '将执行基础配置完整性检查,是否确认?',
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
await Check({ tenantId: record.tenantId, service: record.service });
message.success('操作成功');
reloadTable();
},
});
}
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>
<style lang="less" scoped></style>
+138
View File
@@ -0,0 +1,138 @@
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 service = '';
public provider = '';
public endpoint = '';
public enabled = 0;
public status = '';
public configJson = '{}';
public secretJson = '';
public hasSecret = false;
public checkedAt = '';
public checkResult = '';
public createdAt = '';
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: 'service',
component: 'NSelect',
label: '服务',
componentProps: {
clearable: true,
options: computed(() => dict.getOptions('ImExternalServiceOptions')),
placeholder: '请选择服务',
},
},
{
field: 'provider',
component: 'NInput',
label: '服务商',
componentProps: { placeholder: '请输入服务商' },
},
{
field: 'enabled',
component: 'NSelect',
label: '启用',
componentProps: {
clearable: true,
options: [
{ label: '启用', value: 1 },
{ label: '停用', value: 0 },
],
placeholder: '请选择启用状态',
},
},
]);
export const columns = [
{
title: '服务',
key: 'service',
align: 'left',
width: 130,
render(row: State) {
return dict.getLabel('ImExternalServiceOptions', row.service);
},
},
{ title: '服务商', key: 'provider', align: 'left', width: 140 },
{ title: '端点', key: 'endpoint', align: 'left', width: 220 },
{
title: '启用',
key: 'enabled',
align: 'left',
width: 90,
render(row: State) {
return row.enabled ? '启用' : '停用';
},
},
{
title: '配置状态',
key: 'status',
align: 'left',
width: 110,
render(row: State) {
return dict.getLabel('ImExternalConfigStatusOptions', row.status);
},
},
{
title: '敏感配置',
key: 'hasSecret',
align: 'left',
width: 110,
render(row: State) {
return row.hasSecret ? '已配置' : '未配置';
},
},
{ title: '检查结果', key: 'checkResult', align: 'left', width: 220 },
{ title: '检查时间', key: 'checkedAt', align: 'left', width: 180 },
{ title: '更新时间', key: 'updatedAt', align: 'left', width: 180 },
];
export function loadOptions() {
dict.loadOptions(['ImExternalServiceOptions', 'ImExternalConfigStatusOptions']);
}
export async function loadTenantOptions() {
const res = await TenantList({ page: 1, perPage: 20 });
tenantOptions.value = (res?.list || []).map((item) => ({
label: `${item.name}(${item.code})`,
value: item.id,
}));
return tenantOptions.value;
}
+187
View File
@@ -0,0 +1,187 @@
<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"
/>
<n-space class="table-toolbar">
<n-button
v-if="hasPermission(['/imFile/cleanupOrphan'])"
type="warning"
@click="handleCleanupOrphan"
>
清理孤儿文件
</n-button>
</n-space>
<BasicTable
ref="actionRef"
:columns="columns"
:request="loadDataTable"
:row-key="(row) => row.tenantId + '-' + row.id"
:actionColumn="actionColumn"
:scroll-x="scrollX"
:resizeHeightOffset="-10000"
/>
</n-card>
<ViewModal ref="viewRef" />
</div>
</template>
<script lang="ts" setup>
import { computed, h, onMounted, reactive, ref } from 'vue';
import { useDialog, useMessage } from 'naive-ui';
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 { CleanupOrphan, Delete, List, Quarantine } from '@/api/imFile';
import { columns, loadOptions, loadTenantOptions, schemas, State, tenantOptions } from './model';
import { adaTableScrollX } from '@/utils/hotgo';
import ViewModal from './view.vue';
const message = useMessage();
const dialog = useDialog();
const { hasPermission } = usePermission();
const userStore = useUserStore();
const isPlatformAdmin = computed(() => userStore.isCompanyDept);
const actionRef = ref();
const searchFormRef = ref<any>({});
const viewRef = ref();
const tenantSchema = schemas.value.find((item) => item.field === 'tenantId');
if (tenantSchema) {
tenantSchema.ifShow = () => isPlatformAdmin.value;
}
const actionColumn = reactive({
width: 220,
title: '操作',
key: 'action',
fixed: 'right',
render(record: State) {
return h(TableAction as any, {
style: 'button',
actions: [
{
label: '详情',
auth: ['/imFile/view'],
onClick: () => viewRef.value.openModal(record),
},
{
label: '隔离',
type: 'warning',
ifShow: () => record.status === 1,
auth: ['/imFile/quarantine'],
onClick: () => handleQuarantine(record),
},
{
label: '删除',
type: 'error',
ifShow: () => record.status !== 3,
auth: ['/imFile/delete'],
onClick: () => handleDelete(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 List(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 handleQuarantine(record: State) {
dialog.warning({
title: '隔离文件',
content: '隔离后客户端将无法按正常文件状态访问该资源,是否确认?',
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
await Quarantine({ tenantId: record.tenantId, id: record.id, reason: '后台管理员隔离' });
message.success('操作成功');
reloadTable();
},
});
}
function handleDelete(record: State) {
dialog.error({
title: '删除文件',
content: '删除会把文件标记为已删除,后续由清理任务处理物理对象,是否确认?',
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
await Delete({ tenantId: record.tenantId, id: record.id, reason: '后台管理员删除' });
message.success('操作成功');
reloadTable();
},
});
}
function handleCleanupOrphan() {
const params = searchFormRef.value?.formModel || {};
if (isPlatformAdmin.value && !params.tenantId) {
message.warning('请先选择租户');
return;
}
dialog.warning({
title: '清理孤儿文件',
content: '将标记7天前无引用的文件为已删除,单次最多100个,是否确认?',
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
const res = await CleanupOrphan({ tenantId: params.tenantId, days: 7, limit: 100 });
message.success(`已标记 ${res?.count || 0} 个孤儿文件`);
reloadTable();
},
});
}
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>
<style lang="less" scoped>
.table-toolbar {
margin-bottom: 12px;
}
</style>
+161
View File
@@ -0,0 +1,161 @@
import { computed, ref } from 'vue';
import { cloneDeep } from 'lodash-es';
import { FormSchema } from '@/components/Form';
import { defRangeShortcuts } from '@/utils/dateUtil';
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 createdAt = '';
public updatedAt = '';
public userId = 0;
public status = 0;
public mimeType = '';
public size = 0;
public etag = '';
public location = '';
public purpose = '';
public linkCount = 0;
public firstTopic = '';
public firstMsgId = 0;
public firstLinkAt = '';
public links: any[] = [];
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: 'fileId',
component: 'NInput',
label: '文件ID',
componentProps: { placeholder: '请输入文件ID' },
},
{
field: 'userId',
component: 'NInput',
label: '上传用户',
componentProps: { placeholder: '请输入上传用户ID' },
},
{
field: 'status',
component: 'NSelect',
label: '状态',
componentProps: {
clearable: true,
options: computed(() => dict.getOptions('ImFileStatusOptions')),
placeholder: '请选择状态',
},
},
{
field: 'purpose',
component: 'NSelect',
label: '用途',
componentProps: {
clearable: true,
options: computed(() => dict.getOptions('ImFilePurposeOptions')),
placeholder: '请选择用途',
},
},
{
field: 'mimeType',
component: 'NInput',
label: 'MIME',
componentProps: { placeholder: '请输入MIME类型' },
},
{
field: 'topic',
component: 'NInput',
label: 'Topic',
componentProps: { placeholder: '请输入关联Topic' },
},
{
field: 'updatedAt',
component: 'NDatePicker',
label: '更新时间',
componentProps: {
type: 'datetimerange',
clearable: true,
shortcuts: defRangeShortcuts(),
},
},
]);
export const columns = [
{ title: '文件ID', key: 'id', align: 'left', width: 180 },
{
title: '状态',
key: 'status',
align: 'left',
width: 110,
render(row: State) {
return dict.getLabel('ImFileStatusOptions', row.status);
},
},
{
title: '用途',
key: 'purpose',
align: 'left',
width: 120,
render(row: State) {
return dict.getLabel('ImFilePurposeOptions', row.purpose) || row.purpose;
},
},
{ title: '上传用户', key: 'userId', align: 'left', width: 120 },
{ title: 'MIME', key: 'mimeType', align: 'left', width: 180 },
{ title: '大小', key: 'size', align: 'left', width: 110 },
{ title: '引用数', key: 'linkCount', align: 'left', width: 90 },
{ title: '首个Topic', key: 'firstTopic', align: 'left', width: 180 },
{ title: '首个消息', key: 'firstMsgId', align: 'left', width: 110 },
{ title: '位置', key: 'location', align: 'left', width: 260 },
{ title: '创建时间', key: 'createdAt', align: 'left', width: 180 },
{ title: '更新时间', key: 'updatedAt', align: 'left', width: 180 },
];
export const linkColumns = [
{ title: 'ID', key: 'id', align: 'left', width: 90 },
{ title: '消息ID', key: 'msgId', align: 'left', width: 100 },
{ title: 'Topic', key: 'topic', align: 'left', width: 180 },
{ title: '用户ID', key: 'userId', align: 'left', width: 120 },
{ title: '创建时间', key: 'createdAt', align: 'left', width: 180 },
];
export function loadOptions() {
dict.loadOptions(['ImFileStatusOptions', 'ImFilePurposeOptions']);
}
export async function loadTenantOptions() {
const res = await TenantList({ page: 1, perPage: 20 });
tenantOptions.value = (res?.list || []).map((item) => ({
label: `${item.name}(${item.code})`,
value: item.id,
}));
return tenantOptions.value;
}
+77
View File
@@ -0,0 +1,77 @@
<template>
<n-modal
v-model:show="showModal"
preset="dialog"
:show-icon="false"
:title="'文件详情 #' + formValue.id"
:style="{ width: dialogWidth }"
>
<n-spin :show="loading" description="请稍候...">
<n-descriptions label-placement="left" bordered :column="settingStore.isMobile ? 1 : 2">
<n-descriptions-item label="租户">{{ formValue.tenantName }}</n-descriptions-item>
<n-descriptions-item label="状态">{{ statusLabel }}</n-descriptions-item>
<n-descriptions-item label="用途">{{ purposeLabel }}</n-descriptions-item>
<n-descriptions-item label="上传用户">{{ formValue.userId || '-' }}</n-descriptions-item>
<n-descriptions-item label="MIME">{{ formValue.mimeType }}</n-descriptions-item>
<n-descriptions-item label="大小">{{ formValue.size }}</n-descriptions-item>
<n-descriptions-item label="ETag">{{ formValue.etag || '-' }}</n-descriptions-item>
<n-descriptions-item label="引用数">{{ formValue.linkCount }}</n-descriptions-item>
<n-descriptions-item label="创建时间">{{ formValue.createdAt }}</n-descriptions-item>
<n-descriptions-item label="更新时间">{{ formValue.updatedAt }}</n-descriptions-item>
<n-descriptions-item label="文件位置" :span="2">
{{ formValue.location || '-' }}
</n-descriptions-item>
</n-descriptions>
<BasicTable
class="link-table"
:columns="linkColumns"
:dataSource="formValue.links"
:pagination="false"
:scroll-x="760"
:resizeHeightOffset="-10000"
/>
</n-spin>
</n-modal>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { useDictStore } from '@/store/modules/dict';
import { useProjectSettingStore } from '@/store/modules/projectSetting';
import { BasicTable } from '@/components/Table';
import { adaModalWidth } from '@/utils/hotgo';
import { linkColumns, newState, State } from './model';
import { View } from '@/api/imFile';
const dict = useDictStore();
const settingStore = useProjectSettingStore();
const showModal = ref(false);
const loading = ref(false);
const formValue = ref<State>(newState(null));
const dialogWidth = computed(() => adaModalWidth(980));
const statusLabel = computed(() => dict.getLabel('ImFileStatusOptions', formValue.value.status));
const purposeLabel = computed(
() => dict.getLabel('ImFilePurposeOptions', formValue.value.purpose) || formValue.value.purpose
);
function openModal(state: State) {
showModal.value = true;
formValue.value = newState(state);
loading.value = true;
View({ tenantId: state.tenantId, id: state.id })
.then((res) => {
formValue.value = newState(res);
})
.finally(() => {
loading.value = false;
});
}
defineExpose({ openModal });
</script>
<style lang="less" scoped>
.link-table {
margin-top: 12px;
}
</style>
+81
View File
@@ -0,0 +1,81 @@
<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"
:scroll-x="scrollX"
:resizeHeightOffset="-10000"
/>
</n-card>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { useMessage } from 'naive-ui';
import { BasicForm, useForm } from '@/components/Form/index';
import { BasicTable } from '@/components/Table';
import { useUserStore } from '@/store/modules/user';
import { List } from '@/api/imTaskDashboard';
import { columns, loadOptions, loadTenantOptions, schemas, tenantOptions } from './model';
import { adaTableScrollX } from '@/utils/hotgo';
const message = useMessage();
const userStore = useUserStore();
const isPlatformAdmin = computed(() => userStore.isCompanyDept);
const actionRef = ref();
const searchFormRef = ref<any>({});
const tenantSchema = schemas.value.find((item) => item.field === 'tenantId');
if (tenantSchema) {
tenantSchema.ifShow = () => isPlatformAdmin.value;
}
const scrollX = computed(() => adaTableScrollX(columns, 0));
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 };
return await List(params);
};
function reloadTable() {
actionRef.value?.reload();
}
async function handleReset() {
await setFieldsValue({});
reloadTable();
}
onMounted(async () => {
loadOptions();
if (isPlatformAdmin.value) {
await loadTenantOptions();
if (tenantOptions.value.length === 0) {
message.warning('暂无可用租户');
}
}
reloadTable();
});
</script>
<style lang="less" scoped></style>
+88
View File
@@ -0,0 +1,88 @@
import { computed, ref } from 'vue';
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 tenantCode = '';
public tenantState = '';
public dbStatus = '';
public status = '';
public outboxPendingCount = 0;
public outboxFailedCount = 0;
public groupOutboxPending = 0;
public groupOutboxFailed = 0;
public orphanFileCount = 0;
public deletedFileCount = 0;
public lastOutboxCreatedAt = '';
public lastGroupEventAt = '';
public lastFileUpdatedAt = '';
public recommendedNextAction = '';
}
export const schemas = ref<FormSchema[]>([
{
field: 'tenantId',
component: 'NSelect',
label: '租户',
componentProps: {
clearable: true,
options: computed(() => tenantOptions.value),
placeholder: '请选择租户',
},
},
{
field: 'status',
component: 'NSelect',
label: '状态',
componentProps: {
clearable: true,
options: computed(() => dict.getOptions('ImTaskDashboardStatusOptions')),
placeholder: '请选择状态',
},
},
]);
export const columns = [
{ title: '租户', key: 'tenantName', align: 'left', width: 160 },
{ title: '企业码', key: 'tenantCode', align: 'left', width: 120 },
{
title: '状态',
key: 'status',
align: 'left',
width: 110,
render(row: State) {
return dict.getLabel('ImTaskDashboardStatusOptions', row.status);
},
},
{ title: 'DB', key: 'dbStatus', align: 'left', width: 90 },
{ title: '业务待投递', key: 'outboxPendingCount', align: 'left', width: 110 },
{ title: '业务失败', key: 'outboxFailedCount', align: 'left', width: 100 },
{ title: '群待投递', key: 'groupOutboxPending', align: 'left', width: 100 },
{ title: '群失败', key: 'groupOutboxFailed', align: 'left', width: 90 },
{ title: '孤儿文件', key: 'orphanFileCount', align: 'left', width: 100 },
{ title: '待物理清理', key: 'deletedFileCount', align: 'left', width: 110 },
{ title: '建议动作', key: 'recommendedNextAction', align: 'left', width: 220 },
{ title: '最新业务事件', key: 'lastOutboxCreatedAt', align: 'left', width: 180 },
{ title: '最新群事件', key: 'lastGroupEventAt', align: 'left', width: 180 },
{ title: '最新文件更新', key: 'lastFileUpdatedAt', align: 'left', width: 180 },
];
export function loadOptions() {
dict.loadOptions(['ImTaskDashboardStatusOptions']);
}
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;
}