更新
This commit is contained in:
+1
-1
@@ -24,7 +24,7 @@ cbuild
|
|||||||
*.exe
|
*.exe
|
||||||
vender/
|
vender/
|
||||||
vendor/
|
vendor/
|
||||||
# 各产品线 CI 编译产物(goframe_elevue / goframe_antdvue / goframe_naivevue …)
|
# 各产品线 CI 编译产物
|
||||||
/goframe_*
|
/goframe_*
|
||||||
|
|
||||||
# ----- 运行时数据(不入库) -----
|
# ----- 运行时数据(不入库) -----
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ GoFrame v2 后端已按 `document/architecture.md`(唯一权威定稿)完整
|
|||||||
- **命名**:入参/出参 json 一律驼峰;**例外**:登录/令牌接口返回 `access_token/refresh_token/expires_in/token_type`(OAuth2 惯例,前端按此读取,勿改驼峰)
|
- **命名**:入参/出参 json 一律驼峰;**例外**:登录/令牌接口返回 `access_token/refresh_token/expires_in/token_type`(OAuth2 惯例,前端按此读取,勿改驼峰)
|
||||||
- IP 归属地:`utility/ip2region`(xdb 于 manifest/data/ip2region.xdb,配置 ip2region.path),登录日志/操作日志 location 自动填充;受信代理列表 `api.proxyIps`(支持 CIDR,信任 X-Forwarded-For)
|
- IP 归属地:`utility/ip2region`(xdb 于 manifest/data/ip2region.xdb,配置 ip2region.path),登录日志/操作日志 location 自动填充;受信代理列表 `api.proxyIps`(支持 CIDR,信任 X-Forwarded-For)
|
||||||
- **logic 共享能力层** `internal/logic/base.go`(横切能力,组合式复用):文件字段(SaveFileField/SaveFileFields/SaveMultiFileField)、富文本占位符(LocalizeContentFields/ExpandContentFields,`[IMG_URL]` 为持久化契约)、枚举文本(DictGetText/SerializeMapOnRead)、唯一校验(CheckUnique,仅为 UX 层,唯一性真值靠 `uk_*` 索引)、事务(WithTrans,事务内禁 HTTP/文件IO、bcrypt 先算)、分块查询(ChunkQuery)、泛型树(BuildTree)、临时目录清理(CleanTemp,每日 03:30 gcron 触发,file.tempExpireDays 控制保留天数);纪律:只依赖 dao/utility/consts,导出函数上限15个
|
- **logic 共享能力层** `internal/logic/base.go`(横切能力,组合式复用):文件字段(SaveFileField/SaveFileFields/SaveMultiFileField)、富文本占位符(LocalizeContentFields/ExpandContentFields,`[IMG_URL]` 为持久化契约)、枚举文本(DictGetText/SerializeMapOnRead)、唯一校验(CheckUnique,仅为 UX 层,唯一性真值靠 `uk_*` 索引)、事务(WithTrans,事务内禁 HTTP/文件IO、bcrypt 先算)、分块查询(ChunkQuery)、泛型树(BuildTree)、临时目录清理(CleanTemp,每日 03:30 gcron 触发,file.tempExpireDays 控制保留天数);纪律:只依赖 dao/utility/consts,导出函数上限15个
|
||||||
- **{field}Text 字典驱动**:枚举字段显示名从 `dict_item` 解析(唯一真相),模块头部声明 `xxxSerializeMaps = map[字段]字典编码`(编码规范 `{模块snake}_{字段snake}`,如 `user_status`、`job_execute_policy`),响应行用 `DictGetText(ctx, code, value)` 填充;字典由生成器 createDict 按**列注释枚举**自动创建(支持 `1-正常` 与 `1=正常` 两种写法),文案在字典管理页修改;进程内缓存300s。例外:`operation_log.type` 存注解常量值,用静态表 `LogTypeText`(consts.LogTypeNames)
|
- **{field}Text 字典驱动**:枚举字段显示名从 `dict_item` 解析(唯一真相),模块头部声明 `xxxSerializeMaps = map[字段]字典编码`(编码规范 `{模块snake}_{字段snake}`,如 `user_status`、`job_execute_policy`),响应行用 `DictGetText(ctx, code, value)` 填充;字典由生成器 createDict 按**列注释枚举**自动创建(支持 `1-正常` 与 `1=正常` 两种写法),文案在字典管理页修改;进程内缓存300s。例外:`operation_log.type` 存标签常量值,用静态表 `LogTypeText`(consts.LogTypeNames)
|
||||||
- **生成器保护** `skipTables`:仅关联表(role_menu/user_role)整表跳过;其余表统一文件级处理——**缺失补生成、存在跳过、`generator.overwrite: true` 时覆盖**(唯一开关在 config.yaml,覆盖结果单独计入 Overwritten),菜单/字典/路由等幂等副作用照常执行
|
- **生成器保护** `skipTables`:仅关联表(role_menu/user_role)整表跳过;其余表统一文件级处理——**缺失补生成、存在跳过、`generator.overwrite: true` 时覆盖**(唯一开关在 config.yaml,覆盖结果单独计入 Overwritten),菜单/字典/路由等幂等副作用照常执行
|
||||||
- 城市编解码:`ResolveCityNames / ResolveCityCodes`(internal/logic 包级函数),user 模块 Add/Update/Import/Detail 已接入
|
- 城市编解码:`ResolveCityNames / ResolveCityCodes`(internal/logic 包级函数),user 模块 Add/Update/Import/Detail 已接入
|
||||||
- 代码生成器 CLI:`go run ./cmd/generator -tables "goframe_x|注释" [-preview|-batch]`;覆盖模式由 `generator.overwrite` 配置统一控制;dao/entity/do 四件由生成器直接产出,生成后只需 `go build ./...` 并重启(`gf gen dao` 仅在**表结构变更**时用于刷新已实现模块的 dao)
|
- 代码生成器 CLI:`go run ./cmd/generator -tables "goframe_x|注释" [-preview|-batch]`;覆盖模式由 `generator.overwrite` 配置统一控制;dao/entity/do 四件由生成器直接产出,生成后只需 `go build ./...` 并重启(`gf gen dao` 仅在**表结构变更**时用于刷新已实现模块的 dao)
|
||||||
|
|||||||
@@ -42,6 +42,8 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/gogf/gf/v2/net/ghttp"
|
"github.com/gogf/gf/v2/net/ghttp"
|
||||||
"github.com/gogf/gf/v2/os/gtime"
|
"github.com/gogf/gf/v2/os/gtime"
|
||||||
"github.com/gogf/gf/v2/util/gmeta"
|
"github.com/gogf/gf/v2/util/gmeta"
|
||||||
@@ -177,6 +179,15 @@ type ExampleImportRes struct {
|
|||||||
Reasons []string `json:"reasons"` // 失败原因列表
|
Reasons []string `json:"reasons"` // 失败原因列表
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResMsg 导入结果提示语(response.MsgProvider,前端成功通道直接弹出):
|
||||||
|
// 无失败行时"成功导入 X 条数据",有失败行时"成功导入 X 条,失败 Y 条"
|
||||||
|
func (r *ExampleImportRes) ResMsg() string {
|
||||||
|
if r.Failed > 0 {
|
||||||
|
return fmt.Sprintf("成功导入 %d 条,失败 %d 条", r.Success, r.Failed)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("成功导入 %d 条数据", r.Success)
|
||||||
|
}
|
||||||
|
|
||||||
// ExampleExportReq 导出案例演示请求
|
// ExampleExportReq 导出案例演示请求
|
||||||
type ExampleExportReq struct {
|
type ExampleExportReq struct {
|
||||||
gmeta.Meta `method:"get" path:"/api/example/export" summary:"导出案例演示" permission:"sys:example:export" log:"案例演示-导出数据" logType:"export"`
|
gmeta.Meta `method:"get" path:"/api/example/export" summary:"导出案例演示" permission:"sys:example:export" log:"案例演示-导出数据" logType:"export"`
|
||||||
|
|||||||
@@ -42,6 +42,8 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/gogf/gf/v2/net/ghttp"
|
"github.com/gogf/gf/v2/net/ghttp"
|
||||||
"github.com/gogf/gf/v2/os/gtime"
|
"github.com/gogf/gf/v2/os/gtime"
|
||||||
"github.com/gogf/gf/v2/util/gmeta"
|
"github.com/gogf/gf/v2/util/gmeta"
|
||||||
@@ -174,6 +176,15 @@ type LevelImportRes struct {
|
|||||||
Reasons []string `json:"reasons"` // 失败原因列表
|
Reasons []string `json:"reasons"` // 失败原因列表
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResMsg 导入结果提示语(response.MsgProvider,前端成功通道直接弹出):
|
||||||
|
// 无失败行时"成功导入 X 条数据",有失败行时"成功导入 X 条,失败 Y 条"
|
||||||
|
func (r *LevelImportRes) ResMsg() string {
|
||||||
|
if r.Failed > 0 {
|
||||||
|
return fmt.Sprintf("成功导入 %d 条,失败 %d 条", r.Success, r.Failed)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("成功导入 %d 条数据", r.Success)
|
||||||
|
}
|
||||||
|
|
||||||
// LevelExportReq 导出职级请求
|
// LevelExportReq 导出职级请求
|
||||||
// 按查询条件导出符合条件的记录
|
// 按查询条件导出符合条件的记录
|
||||||
type LevelExportReq struct {
|
type LevelExportReq struct {
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ type LoginLogItem struct {
|
|||||||
StatusText string `json:"statusText"` // 状态文本
|
StatusText string `json:"statusText"` // 状态文本
|
||||||
Error string `json:"error"` // 错误信息
|
Error string `json:"error"` // 错误信息
|
||||||
CreateUser string `json:"createUser"` // 创建人
|
CreateUser string `json:"createUser"` // 创建人
|
||||||
|
Avatar string `json:"avatar"` // 登录账号头像(读侧按用户名回查补全,已补访问域名)
|
||||||
CreateTime *gtime.Time `json:"createTime"` // 创建时间
|
CreateTime *gtime.Time `json:"createTime"` // 创建时间
|
||||||
UpdateTime *gtime.Time `json:"updateTime"` // 更新时间
|
UpdateTime *gtime.Time `json:"updateTime"` // 更新时间
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ type OperLogItem struct {
|
|||||||
StatusText string `json:"statusText"` // 状态文本
|
StatusText string `json:"statusText"` // 状态文本
|
||||||
Error string `json:"error"` // 错误信息
|
Error string `json:"error"` // 错误信息
|
||||||
CreateUser string `json:"createUser"` // 操作人
|
CreateUser string `json:"createUser"` // 操作人
|
||||||
|
Avatar string `json:"avatar"` // 操作人头像(读侧按用户名回查补全,已补访问域名)
|
||||||
CreateTime *gtime.Time `json:"createTime"` // 创建时间
|
CreateTime *gtime.Time `json:"createTime"` // 创建时间
|
||||||
UpdateTime *gtime.Time `json:"updateTime"` // 更新时间
|
UpdateTime *gtime.Time `json:"updateTime"` // 更新时间
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-14
@@ -189,28 +189,37 @@ type TenantBatchDeleteReq struct {
|
|||||||
// TenantBatchDeleteRes 批量删除结果
|
// TenantBatchDeleteRes 批量删除结果
|
||||||
type TenantBatchDeleteRes struct{}
|
type TenantBatchDeleteRes struct{}
|
||||||
|
|
||||||
// TenantAccountReq 创建租户账号请求
|
// TenantAccountReq 添加租户账号请求
|
||||||
// 同时创建租户及其初始管理员账号
|
// 在指定租户下创建用户账号(租户管理页的用户列表入口),
|
||||||
|
// 字段口径与 UserAddReq 一致(avatar 提交上传接口返回的地址,
|
||||||
|
// city 为四级区划编码数组),另带目标租户ID tenantId
|
||||||
type TenantAccountReq struct {
|
type TenantAccountReq struct {
|
||||||
gmeta.Meta `method:"post" path:"/api/tenant/account" summary:"创建租户账号" permission:"sys:tenant:add" log:"租户管理-创建租户账号" logType:"add"`
|
gmeta.Meta `method:"post" path:"/api/tenant/account" summary:"添加租户账号" permission:"sys:tenant:add" log:"租户管理-添加账号" logType:"add"`
|
||||||
Name string `json:"name" v:"required#请输入租户名称" dc:"租户名称"` // 租户名称
|
TenantId int `json:"tenantId" v:"required#缺少租户ID" dc:"目标租户ID"` // 目标租户ID
|
||||||
Contact string `json:"contact" dc:"联系人"` // 联系人
|
Username string `json:"username" v:"required#请输入用户名|length:2,30#用户名长度需在2~30位" dc:"用户名"` // 用户名
|
||||||
Mobile string `json:"mobile" v:"phone#手机号格式不正确" dc:"联系电话"` // 联系电话
|
Password string `json:"password" v:"required#请输入密码|length:6,32#密码长度需在6~32位" dc:"密码"` // 密码
|
||||||
|
Realname string `json:"realname" v:"required#请输入姓名" dc:"姓名"` // 姓名
|
||||||
|
Gender int `json:"gender" dc:"性别:1-男 2-女 3-保密"` // 性别
|
||||||
|
Mobile string `json:"mobile" v:"phone#手机号格式不正确" dc:"手机号"` // 手机号
|
||||||
Email string `json:"email" v:"email#邮箱格式不正确" dc:"邮箱"` // 邮箱
|
Email string `json:"email" v:"email#邮箱格式不正确" dc:"邮箱"` // 邮箱
|
||||||
Address string `json:"address" dc:"地址"` // 地址
|
Roles []int64 `json:"roles" dc:"角色ID列表"` // 角色ID列表
|
||||||
ExpireTime string `json:"expireTime" dc:"过期时间"` // 过期时间
|
DeptId int `json:"deptId" dc:"部门ID"` // 部门ID
|
||||||
MaxUserNum int `json:"maxUserNum" dc:"用户数上限"` // 用户数上限
|
LevelId int `json:"levelId" dc:"职级ID"` // 职级ID
|
||||||
|
PositionId int `json:"positionId" dc:"岗位ID"` // 岗位ID
|
||||||
|
City []string `json:"city" dc:"省市区街道编码数组"` // 省市区街道编码数组
|
||||||
|
Address string `json:"address" dc:"详细地址"` // 详细地址
|
||||||
|
Avatar string `json:"avatar" dc:"头像"` // 头像
|
||||||
|
Intro string `json:"intro" dc:"个人简介"` // 个人简介
|
||||||
|
Status int `json:"status" dc:"状态:1-正常 2-禁用"` // 状态
|
||||||
|
Sort int `json:"sort" dc:"排序"` // 排序
|
||||||
Note string `json:"note" dc:"备注"` // 备注
|
Note string `json:"note" dc:"备注"` // 备注
|
||||||
Username string `json:"username" v:"required#请输入管理员账号|length:2,30#账号长度需在2~30位" dc:"初始管理员账号"` // 初始管理员账号
|
|
||||||
Password string `json:"password" v:"required#请输入密码|length:6,32#密码长度需在6~32位" dc:"初始管理员密码"` // 初始管理员密码
|
|
||||||
Realname string `json:"realname" dc:"管理员姓名"` // 管理员姓名
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TenantAccountRes 创建租户账号结果
|
// TenantAccountRes 添加租户账号结果
|
||||||
type TenantAccountRes struct {
|
type TenantAccountRes struct {
|
||||||
TenantId int64 `json:"tenantId"` // 租户ID
|
TenantId int64 `json:"tenantId"` // 租户ID
|
||||||
Code string `json:"code"` // 租户编码
|
Code string `json:"code"` // 租户编码
|
||||||
UserId int64 `json:"userId"` // 管理员用户ID
|
UserId int64 `json:"userId"` // 新用户ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// TenantUsersReq 查询租户用户请求
|
// TenantUsersReq 查询租户用户请求
|
||||||
|
|||||||
@@ -42,6 +42,8 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/gogf/gf/v2/net/ghttp"
|
"github.com/gogf/gf/v2/net/ghttp"
|
||||||
"github.com/gogf/gf/v2/os/gtime"
|
"github.com/gogf/gf/v2/os/gtime"
|
||||||
"github.com/gogf/gf/v2/util/gmeta"
|
"github.com/gogf/gf/v2/util/gmeta"
|
||||||
@@ -255,6 +257,15 @@ type UserImportRes struct {
|
|||||||
Reasons []string `json:"reasons"` // 失败原因列表
|
Reasons []string `json:"reasons"` // 失败原因列表
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResMsg 导入结果提示语(response.MsgProvider,前端成功通道直接弹出):
|
||||||
|
// 无失败行时"成功导入 X 条数据",有失败行时"成功导入 X 条,失败 Y 条"
|
||||||
|
func (r *UserImportRes) ResMsg() string {
|
||||||
|
if r.Failed > 0 {
|
||||||
|
return fmt.Sprintf("成功导入 %d 条,失败 %d 条", r.Success, r.Failed)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("成功导入 %d 条数据", r.Success)
|
||||||
|
}
|
||||||
|
|
||||||
// UserExportReq 导出用户请求
|
// UserExportReq 导出用户请求
|
||||||
// 按查询条件导出符合条件的记录
|
// 按查询条件导出符合条件的记录
|
||||||
type UserExportReq struct {
|
type UserExportReq struct {
|
||||||
|
|||||||
@@ -102,8 +102,8 @@ func (c *CTenant) BatchDelete(ctx context.Context, req *v1.TenantBatchDeleteReq)
|
|||||||
return service.Tenant().BatchDelete(ctx, req)
|
return service.Tenant().BatchDelete(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Account 创建租户账号
|
// Account 添加租户账号
|
||||||
// 同时创建租户及其初始管理员账号
|
// 在指定租户下创建用户账号,委托 service.Tenant.Account
|
||||||
func (c *CTenant) Account(ctx context.Context, req *v1.TenantAccountReq) (res *v1.TenantAccountRes, err error) {
|
func (c *CTenant) Account(ctx context.Context, req *v1.TenantAccountReq) (res *v1.TenantAccountRes, err error) {
|
||||||
return service.Tenant().Account(ctx, req)
|
return service.Tenant().Account(ctx, req)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ func SerializeMapOnRead(ctx context.Context, data map[string]any, fieldDict map[
|
|||||||
|
|
||||||
// LogTypeText 操作日志类型转中文名
|
// LogTypeText 操作日志类型转中文名
|
||||||
//
|
//
|
||||||
// 该字段存储的是注解常量值(1=新增…40=解绑),与字典编码体系不一致,
|
// 该字段存储的是标签常量值(1=新增…40=解绑),与字典编码体系不一致,
|
||||||
// 故使用静态映射表而非字典;未知值回退原数字。
|
// 故使用静态映射表而非字典;未知值回退原数字。
|
||||||
func LogTypeText(v int) string {
|
func LogTypeText(v int) string {
|
||||||
if n, ok := consts.LogTypeNames[v]; ok {
|
if n, ok := consts.LogTypeNames[v]; ok {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import (
|
|||||||
"easygoadmin/api/v1"
|
"easygoadmin/api/v1"
|
||||||
"easygoadmin/internal/dao"
|
"easygoadmin/internal/dao"
|
||||||
"easygoadmin/internal/service"
|
"easygoadmin/internal/service"
|
||||||
|
"easygoadmin/utility/upload"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ============================== 登录日志业务实现 ==============================
|
// ============================== 登录日志业务实现 ==============================
|
||||||
@@ -72,6 +73,44 @@ var loginLogSpec = ModuleSpec[v1.LoginLogItem, v1.LoginLogItem, v1.LoginLogDetai
|
|||||||
EqFields: []string{"log_type", "status"},
|
EqFields: []string{"log_type", "status"},
|
||||||
OrderBy: "id DESC",
|
OrderBy: "id DESC",
|
||||||
SerializeMaps: loginLogSerializeMaps,
|
SerializeMaps: loginLogSerializeMaps,
|
||||||
|
// 头像按登录账号用户名回查补齐(失败登录无对应用户时留空)
|
||||||
|
AfterPageList: func(ctx context.Context, list []*v1.LoginLogItem, req any) error {
|
||||||
|
return fillLoginLogAvatars(ctx, list)
|
||||||
|
},
|
||||||
|
AfterDetail: func(ctx context.Context, item *v1.LoginLogDetailRes, req any) error {
|
||||||
|
return fillLoginLogAvatars(ctx, []*v1.LoginLogItem{&item.LoginLogItem})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillLoginLogAvatars 按登录账号用户名批量查询并回填头像
|
||||||
|
//
|
||||||
|
// 日志表不冗余存头像:读侧将用户名去重后一次 IN 查询用户表的
|
||||||
|
// username/avatar 相对路径,经 upload.GetFileUrl 补全访问域名;
|
||||||
|
// 查询失败原样上抛。无有效用户名或用户不存在(登录失败记录)时留空。
|
||||||
|
func fillLoginLogAvatars(ctx context.Context, items []*v1.LoginLogItem) error {
|
||||||
|
nameMap := map[string]bool{}
|
||||||
|
names := make([]string, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
if it.Username != "" && !nameMap[it.Username] {
|
||||||
|
nameMap[it.Username] = true
|
||||||
|
names = append(names, it.Username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(names) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rows, err := dao.User.Ctx(ctx).WhereIn("username", names).Fields("username, avatar").All()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
avatarMap := make(map[string]string, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
avatarMap[r["username"].String()] = upload.GetFileUrl(ctx, r["avatar"].String())
|
||||||
|
}
|
||||||
|
for i := range items {
|
||||||
|
items[i].Avatar = avatarMap[items[i].Username]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -95,7 +134,15 @@ func (s *sLoginLog) List(ctx context.Context, req *v1.LoginLogListReq) (res v1.L
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return derefSlice(list), nil
|
rows := derefSlice(list)
|
||||||
|
ptrs := make([]*v1.LoginLogItem, len(rows))
|
||||||
|
for i := range rows {
|
||||||
|
ptrs[i] = &rows[i]
|
||||||
|
}
|
||||||
|
if err := fillLoginLogAvatars(ctx, ptrs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detail 登录日志详情
|
// Detail 登录日志详情
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import (
|
|||||||
"easygoadmin/internal/dao"
|
"easygoadmin/internal/dao"
|
||||||
"easygoadmin/internal/middleware"
|
"easygoadmin/internal/middleware"
|
||||||
"easygoadmin/internal/service"
|
"easygoadmin/internal/service"
|
||||||
|
"easygoadmin/utility/upload"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ============================== 操作日志业务实现 ==============================
|
// ============================== 操作日志业务实现 ==============================
|
||||||
@@ -70,19 +71,50 @@ var operLogSpec = ModuleSpec[v1.OperLogItem, v1.OperLogItem, v1.OperLogDetailRes
|
|||||||
EqFields: []string{"type", "status"},
|
EqFields: []string{"type", "status"},
|
||||||
OrderBy: "id DESC",
|
OrderBy: "id DESC",
|
||||||
SerializeMaps: operLogSerializeMaps,
|
SerializeMaps: operLogSerializeMaps,
|
||||||
// typeText 走静态映射(注解常量值,与字典体系无关)
|
// typeText 走静态映射(标签常量值,与字典体系无关);头像按操作人用户名回查补齐
|
||||||
AfterPageList: func(ctx context.Context, list []*v1.OperLogItem, req any) error {
|
AfterPageList: func(ctx context.Context, list []*v1.OperLogItem, req any) error {
|
||||||
for _, item := range list {
|
for _, item := range list {
|
||||||
item.TypeText = LogTypeText(item.Type)
|
item.TypeText = LogTypeText(item.Type)
|
||||||
}
|
}
|
||||||
return nil
|
return fillOperLogAvatars(ctx, list)
|
||||||
},
|
},
|
||||||
AfterDetail: func(ctx context.Context, item *v1.OperLogDetailRes, req any) error {
|
AfterDetail: func(ctx context.Context, item *v1.OperLogDetailRes, req any) error {
|
||||||
item.TypeText = LogTypeText(item.Type)
|
item.TypeText = LogTypeText(item.Type)
|
||||||
return nil
|
return fillOperLogAvatars(ctx, []*v1.OperLogItem{&item.OperLogItem})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fillOperLogAvatars 按操作人用户名批量查询并回填头像
|
||||||
|
//
|
||||||
|
// 日志表只落 create_user(用户名),头像不冗余存储:读侧将用户名去重后
|
||||||
|
// 一次 IN 查询用户表的 username/avatar 相对路径,经 upload.GetFileUrl
|
||||||
|
// 补全访问域名;查询失败原样上抛。无有效用户名(未认证请求)时直接返回。
|
||||||
|
func fillOperLogAvatars(ctx context.Context, items []*v1.OperLogItem) error {
|
||||||
|
nameMap := map[string]bool{}
|
||||||
|
names := make([]string, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
if it.CreateUser != "" && !nameMap[it.CreateUser] {
|
||||||
|
nameMap[it.CreateUser] = true
|
||||||
|
names = append(names, it.CreateUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(names) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rows, err := dao.User.Ctx(ctx).WhereIn("username", names).Fields("username, avatar").All()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
avatarMap := make(map[string]string, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
avatarMap[r["username"].String()] = upload.GetFileUrl(ctx, r["avatar"].String())
|
||||||
|
}
|
||||||
|
for i := range items {
|
||||||
|
items[i].Avatar = avatarMap[items[i].CreateUser]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
service.RegisterOperLog(&sOperLog{})
|
service.RegisterOperLog(&sOperLog{})
|
||||||
// 操作日志中间件落库接线(异步批量,见 middleware/operation_log.go)
|
// 操作日志中间件落库接线(异步批量,见 middleware/operation_log.go)
|
||||||
@@ -131,10 +163,16 @@ func (s *sOperLog) List(ctx context.Context, req *v1.OperLogListReq) (res v1.Ope
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, item := range list {
|
rows := derefSlice(list)
|
||||||
item.TypeText = LogTypeText(item.Type)
|
ptrs := make([]*v1.OperLogItem, len(rows))
|
||||||
|
for i := range rows {
|
||||||
|
rows[i].TypeText = LogTypeText(rows[i].Type)
|
||||||
|
ptrs[i] = &rows[i]
|
||||||
}
|
}
|
||||||
return derefSlice(list), nil
|
if err := fillOperLogAvatars(ctx, ptrs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detail 操作日志详情
|
// Detail 操作日志详情
|
||||||
|
|||||||
+39
-67
@@ -55,7 +55,6 @@ import (
|
|||||||
"easygoadmin/internal/dao"
|
"easygoadmin/internal/dao"
|
||||||
"easygoadmin/internal/service"
|
"easygoadmin/internal/service"
|
||||||
"easygoadmin/utility/cache"
|
"easygoadmin/utility/cache"
|
||||||
"easygoadmin/utility/crypto"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ============================== 租户业务实现 ==============================
|
// ============================== 租户业务实现 ==============================
|
||||||
@@ -307,80 +306,53 @@ func (s *sTenant) Add(ctx context.Context, req *v1.TenantAddReq) (res *v1.IdRes,
|
|||||||
return &v1.IdRes{Id: idv}, nil
|
return &v1.IdRes{Id: idv}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Account 创建租户账号
|
// Account 添加租户账号
|
||||||
//
|
//
|
||||||
// 同时创建租户及其初始管理员账号;
|
// 在指定租户(req.TenantId)下创建用户账号,返回目标租户ID、租户编码与新用户ID。
|
||||||
// bcrypt 属 CPU 密集操作,放在事务外预计算,保持短事务
|
// 平台级专属:拦截租户侧身份——本操作按请求显式指定的目标租户写入,
|
||||||
|
// 且唯一性校验/用户数上限的查询链依赖平台视角(无租户收敛)。
|
||||||
|
// 目标租户不存在返回 CodeTenantNotFound;账号唯一性、用户数上限、编码生成、
|
||||||
|
// 密码加盐、头像转正、角色关联复用用户新增链路(sUser.addWithTenant)。
|
||||||
func (s *sTenant) Account(ctx context.Context, req *v1.TenantAccountReq) (res *v1.TenantAccountRes, err error) {
|
func (s *sTenant) Account(ctx context.Context, req *v1.TenantAccountReq) (res *v1.TenantAccountRes, err error) {
|
||||||
// 租户:自增回填(临时编码占位 → T%04d 修正)
|
if !consts.IsPlatform(ctx) {
|
||||||
tempCode := fmt.Sprintf("T%010d", time.Now().UnixNano()%10000000000)
|
return nil, gerror.NewCode(consts.CodeForbidden)
|
||||||
tData := map[string]any{
|
|
||||||
"name": req.Name,
|
|
||||||
"code": tempCode,
|
|
||||||
"contact": req.Contact,
|
|
||||||
"mobile": req.Mobile,
|
|
||||||
"email": req.Email,
|
|
||||||
"address": req.Address,
|
|
||||||
"status": 1,
|
|
||||||
"max_user_num": req.MaxUserNum,
|
|
||||||
"note": req.Note,
|
|
||||||
}
|
}
|
||||||
if req.ExpireTime != "" {
|
// 目标租户存在性校验(同时取编码供响应回传)
|
||||||
if t := gtime.New(req.ExpireTime); t != nil {
|
one, e := dao.Tenant.Ctx(ctx).Where("id", req.TenantId).Fields("code").One()
|
||||||
tData["expire_time"] = t
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
}
|
}
|
||||||
|
if len(one) == 0 {
|
||||||
|
return nil, gerror.NewCode(consts.CodeTenantNotFound)
|
||||||
}
|
}
|
||||||
// bcrypt 属 CPU 密集操作,放在事务外预计算(短事务纪律)
|
// 用户表单字段转用户新增入参,按目标租户落库
|
||||||
salt := crypto.GenerateSalt()
|
idRes, err := (&sUser{}).addWithTenant(ctx, req.TenantId, &v1.UserAddReq{
|
||||||
hashed, err := crypto.HashPassword(req.Password, salt)
|
Username: req.Username,
|
||||||
|
Password: req.Password,
|
||||||
|
Realname: req.Realname,
|
||||||
|
Gender: req.Gender,
|
||||||
|
Mobile: req.Mobile,
|
||||||
|
Email: req.Email,
|
||||||
|
Roles: req.Roles,
|
||||||
|
DeptId: req.DeptId,
|
||||||
|
LevelId: req.LevelId,
|
||||||
|
PositionId: req.PositionId,
|
||||||
|
City: req.City,
|
||||||
|
Address: req.Address,
|
||||||
|
Avatar: req.Avatar,
|
||||||
|
Intro: req.Intro,
|
||||||
|
Status: req.Status,
|
||||||
|
Sort: req.Sort,
|
||||||
|
Note: req.Note,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
realname := req.Realname
|
return &v1.TenantAccountRes{
|
||||||
if realname == "" {
|
TenantId: int64(req.TenantId),
|
||||||
realname = req.Username
|
Code: one["code"].String(),
|
||||||
}
|
UserId: idRes.Id,
|
||||||
|
}, nil
|
||||||
// 租户 + 初始管理员三段写入保持原子性(全部经 txCtx)
|
|
||||||
var tenantId, uid int64
|
|
||||||
var code string
|
|
||||||
if err = WithTrans(ctx, func(txCtx context.Context) error {
|
|
||||||
tResult, e := dao.Tenant.Ctx(txCtx).Data(tData).Insert()
|
|
||||||
if e != nil {
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
tenantId, _ = tResult.LastInsertId()
|
|
||||||
code = fmt.Sprintf("T%04d", tenantId)
|
|
||||||
if _, e = dao.Tenant.Ctx(txCtx).Where("id", tenantId).Data(map[string]any{"code": code}).Update(); e != nil {
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
// 初始管理员(tenant_id 由 hooks 自动填充)
|
|
||||||
tenantCtx := consts.WithTenantID(txCtx, int(tenantId))
|
|
||||||
uResult, e := dao.User.Ctx(tenantCtx).Data(map[string]any{
|
|
||||||
"username": req.Username,
|
|
||||||
"code": fmt.Sprintf("%s-000001", code), // 首个用户序号 000001
|
|
||||||
"password": hashed,
|
|
||||||
"salt": salt,
|
|
||||||
"realname": realname,
|
|
||||||
"mobile": req.Mobile,
|
|
||||||
"email": req.Email,
|
|
||||||
"province_code": "",
|
|
||||||
"city_code": "",
|
|
||||||
"district_code": "",
|
|
||||||
"street_code": "",
|
|
||||||
"address": "",
|
|
||||||
"status": 1,
|
|
||||||
"tenant_id": int(tenantId),
|
|
||||||
}).Insert()
|
|
||||||
if e != nil {
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
uid, _ = uResult.LastInsertId()
|
|
||||||
return nil
|
|
||||||
}); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &v1.TenantAccountRes{TenantId: tenantId, Code: code, UserId: uid}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Users 查询租户用户列表
|
// Users 查询租户用户列表
|
||||||
|
|||||||
+13
-2
@@ -277,9 +277,20 @@ func (s *sUser) Detail(ctx context.Context, req *v1.UserDetailReq) (res *v1.User
|
|||||||
|
|
||||||
// Add 新增用户
|
// Add 新增用户
|
||||||
//
|
//
|
||||||
// 用户名与手机号租户内唯一,校验租户用户上限,自动生成用户编码并写入 user_role
|
// 入参 *v1.UserAddReq;返回新记录ID供前端刷新定位。
|
||||||
|
// 租户取当前登录租户,落库链路见 addWithTenant。
|
||||||
func (s *sUser) Add(ctx context.Context, req *v1.UserAddReq) (res *v1.IdRes, err error) {
|
func (s *sUser) Add(ctx context.Context, req *v1.UserAddReq) (res *v1.IdRes, err error) {
|
||||||
tenantId := consts.GetTenantID(ctx)
|
return s.addWithTenant(ctx, consts.GetTenantID(ctx), req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addWithTenant 在指定租户下创建用户(Add 与租户账号添加的共用实现)
|
||||||
|
//
|
||||||
|
// tenantId 为目标租户ID:Add 传当前登录租户,租户账号添加(Tenant.Account)
|
||||||
|
// 传请求携带的目标租户。顺序契约:用户名/手机号租户内唯一校验 → 租户用户数
|
||||||
|
// 上限校验 → 生成用户编码 → 密码双重加盐 → 组装落库 map → 头像 temp 转正 →
|
||||||
|
// 写入 gin_user → 重建角色关联;重复返回 CodeRecordExists,超限返回
|
||||||
|
// CodeTenantUserLimit,查询/加密错误原样上抛。
|
||||||
|
func (s *sUser) addWithTenant(ctx context.Context, tenantId int, req *v1.UserAddReq) (res *v1.IdRes, err error) {
|
||||||
if exists, e := s.usernameExists(ctx, req.Username, tenantId, 0); e != nil {
|
if exists, e := s.usernameExists(ctx, req.Username, tenantId, 0); e != nil {
|
||||||
return nil, e
|
return nil, e
|
||||||
} else if exists {
|
} else if exists {
|
||||||
|
|||||||
@@ -164,6 +164,13 @@ func makeHandler(method reflect.Value, reqType reflect.Type, rm meta.RouteMeta)
|
|||||||
response.Write(r, response.Ok(nil))
|
response.Write(r, response.Ok(nil))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.Write(r, response.Ok(out[0].Interface()))
|
data := out[0].Interface()
|
||||||
|
// 成功提示语:结果对象实现 response.MsgProvider 时按其口径定制
|
||||||
|
// (如导入结果"成功导入 X 条数据"),否则回退默认"操作成功"
|
||||||
|
if mp, ok := data.(response.MsgProvider); ok {
|
||||||
|
response.Write(r, response.OkMsg(mp.ResMsg(), data))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Write(r, response.Ok(data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,8 +76,8 @@ type ITenant interface {
|
|||||||
Delete(ctx context.Context, req *v1.TenantDeleteReq) (res *v1.TenantDeleteRes, err error)
|
Delete(ctx context.Context, req *v1.TenantDeleteReq) (res *v1.TenantDeleteRes, err error)
|
||||||
// BatchDelete 批量删除租户
|
// BatchDelete 批量删除租户
|
||||||
BatchDelete(ctx context.Context, req *v1.TenantBatchDeleteReq) (res *v1.TenantBatchDeleteRes, err error)
|
BatchDelete(ctx context.Context, req *v1.TenantBatchDeleteReq) (res *v1.TenantBatchDeleteRes, err error)
|
||||||
// Account 创建租户账号
|
// Account 添加租户账号
|
||||||
// 同时创建租户及其初始管理员账号
|
// 在指定租户下创建用户账号
|
||||||
Account(ctx context.Context, req *v1.TenantAccountReq) (res *v1.TenantAccountRes, err error)
|
Account(ctx context.Context, req *v1.TenantAccountReq) (res *v1.TenantAccountRes, err error)
|
||||||
// Users 查询租户用户列表
|
// Users 查询租户用户列表
|
||||||
Users(ctx context.Context, req *v1.TenantUsersReq) (res *v1.TenantUsersRes, err error)
|
Users(ctx context.Context, req *v1.TenantUsersReq) (res *v1.TenantUsersRes, err error)
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ redis:
|
|||||||
# JWT 双令牌:accessToken 短时效 + refreshToken 长时效轮换
|
# JWT 双令牌:accessToken 短时效 + refreshToken 长时效轮换
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
jwt:
|
jwt:
|
||||||
secret: "easygoadmin-goframe-elevue-jwt-secret-2026" # 签名密钥
|
secret: "easygoadmin-goframe-jwt-secret-2026" # 签名密钥
|
||||||
issuer: "easygoadmin" # 签发者
|
issuer: "easygoadmin" # 签发者
|
||||||
accessTokenExpire: 7200 # 访问令牌有效期(秒),2 小时
|
accessTokenExpire: 7200 # 访问令牌有效期(秒),2 小时
|
||||||
refreshTokenExpire: 604800 # 刷新令牌有效期(秒),7 天
|
refreshTokenExpire: 604800 # 刷新令牌有效期(秒),7 天
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -43,6 +43,8 @@ package v1
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
{{- if .HasImport}}
|
{{- if .HasImport}}
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/gogf/gf/v2/net/ghttp"
|
"github.com/gogf/gf/v2/net/ghttp"
|
||||||
{{- end}}
|
{{- end}}
|
||||||
{{- if .NeedGtime}}
|
{{- if .NeedGtime}}
|
||||||
@@ -182,6 +184,15 @@ type {{.ModelClass}}ImportRes struct {
|
|||||||
Failed int `json:"failed"` // 失败数
|
Failed int `json:"failed"` // 失败数
|
||||||
Reasons []string `json:"reasons"` // 失败原因列表
|
Reasons []string `json:"reasons"` // 失败原因列表
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResMsg 导入结果提示语(response.MsgProvider,前端成功通道直接弹出):
|
||||||
|
// 无失败行时"成功导入 X 条数据",有失败行时"成功导入 X 条,失败 Y 条"
|
||||||
|
func (r *{{.ModelClass}}ImportRes) ResMsg() string {
|
||||||
|
if r.Failed > 0 {
|
||||||
|
return fmt.Sprintf("成功导入 %d 条,失败 %d 条", r.Success, r.Failed)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("成功导入 %d 条数据", r.Success)
|
||||||
|
}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
{{- if .HasExport}}
|
{{- if .HasExport}}
|
||||||
|
|
||||||
|
|||||||
@@ -272,7 +272,7 @@
|
|||||||
upload.value!.clearFiles();
|
upload.value!.clearFiles();
|
||||||
closeLoading();
|
closeLoading();
|
||||||
if (file.code == 0) {
|
if (file.code == 0) {
|
||||||
message('导入成功,' + file.msg);
|
message(file.msg);
|
||||||
reloadTable();
|
reloadTable();
|
||||||
} else {
|
} else {
|
||||||
message(file.msg ? file.msg : '导入失败', 'error');
|
message(file.msg ? file.msg : '导入失败', 'error');
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
import { h } from 'vue';
|
import { h } from 'vue';
|
||||||
import { ElTag } from 'element-plus';
|
import { ElTag, ElAvatar } from 'element-plus';
|
||||||
|
|
||||||
|
// 灰色副文本统一风格
|
||||||
|
const subText: Record<string, string> = {
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#666',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 请求方式标签类型
|
||||||
|
function methodTagType(method?: string) {
|
||||||
|
const m = (method || '').toUpperCase();
|
||||||
|
if (m === 'GET') return 'success';
|
||||||
|
if (m === 'POST') return 'primary';
|
||||||
|
if (m === 'PUT' || m === 'PATCH') return 'warning';
|
||||||
|
if (m === 'DELETE') return 'danger';
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
|
||||||
export const columns = [
|
export const columns = [
|
||||||
{
|
{
|
||||||
@@ -14,53 +30,101 @@ export const columns = [
|
|||||||
{
|
{
|
||||||
label: '访客',
|
label: '访客',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
minWidth: 200,
|
minWidth: 220,
|
||||||
render(record) {
|
render(record) {
|
||||||
return h('div', [
|
const textBlock = h('div', { style: { minWidth: '0', flex: '1' } }, [
|
||||||
h(
|
h(
|
||||||
'div',
|
'div',
|
||||||
{
|
{
|
||||||
style: { marginBottom: '5px' },
|
style: { marginBottom: '4px', fontWeight: 'bold' },
|
||||||
},
|
},
|
||||||
record.row.username,
|
record.row.username || '-',
|
||||||
),
|
),
|
||||||
h(
|
h(
|
||||||
'div',
|
'div',
|
||||||
{
|
{
|
||||||
style: { marginBottom: '5px' },
|
style: { marginBottom: '4px' },
|
||||||
},
|
},
|
||||||
record.row.ip,
|
[
|
||||||
|
h(
|
||||||
|
ElTag,
|
||||||
|
{
|
||||||
|
type: 'info',
|
||||||
|
size: 'small',
|
||||||
|
effect: 'plain',
|
||||||
|
},
|
||||||
|
{ default: () => record.row.ip || '-' },
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
style: { marginBottom: '4px' },
|
||||||
|
},
|
||||||
|
[
|
||||||
h(
|
h(
|
||||||
ElTag,
|
ElTag,
|
||||||
{
|
{
|
||||||
type: 'primary',
|
type: 'primary',
|
||||||
|
size: 'small',
|
||||||
},
|
},
|
||||||
{
|
{ default: () => record.row.location || '-' },
|
||||||
default: () => record.row.location,
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
return h('div', { style: { display: 'flex', alignItems: 'center' } }, [
|
||||||
|
h(
|
||||||
|
ElAvatar,
|
||||||
|
{
|
||||||
|
src: record.row.avatar,
|
||||||
|
size: 48,
|
||||||
|
shape: 'square',
|
||||||
|
fit: 'fill',
|
||||||
|
style: {
|
||||||
|
marginRight: '12px',
|
||||||
|
flexShrink: 0,
|
||||||
|
background: '#409eff',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ default: () => (record.row.username || '?').slice(0, 1) },
|
||||||
|
),
|
||||||
|
textBlock,
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '请求接口',
|
label: '请求接口',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
minWidth: 200,
|
minWidth: 220,
|
||||||
render(record) {
|
render(record) {
|
||||||
return h('div', [
|
return h('div', [
|
||||||
h(
|
h(
|
||||||
ElTag,
|
ElTag,
|
||||||
{
|
{
|
||||||
type: 'info',
|
type: methodTagType(record.row.requestMethod),
|
||||||
|
size: 'small',
|
||||||
style: { marginBottom: '5px' },
|
style: { marginBottom: '5px' },
|
||||||
},
|
},
|
||||||
|
{ default: () => record.row.requestMethod || '-' },
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
{
|
{
|
||||||
default: () => record.row.requestMethod,
|
style: { ...subText, wordBreak: 'break-all', marginBottom: '4px' },
|
||||||
|
title: record.row.url,
|
||||||
},
|
},
|
||||||
|
'接口:' + (record.row.url || '-'),
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
style: { fontWeight: 'bold' },
|
||||||
|
},
|
||||||
|
'名称:' + (record.row.title || '-'),
|
||||||
),
|
),
|
||||||
h('div', '接口:' + record.row.url),
|
|
||||||
h('div', '名称:' + record.row.title),
|
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -69,26 +133,37 @@ export const columns = [
|
|||||||
align: 'left',
|
align: 'left',
|
||||||
minWidth: 250,
|
minWidth: 250,
|
||||||
render(record) {
|
render(record) {
|
||||||
|
const abnormal = record.row.status != 0;
|
||||||
return h('div', [
|
return h('div', [
|
||||||
h('span', '状态:'),
|
h('span', { style: subText }, '状态:'),
|
||||||
h(
|
h(
|
||||||
ElTag,
|
ElTag,
|
||||||
{
|
{
|
||||||
type: record.row.status == 0 ? 'success' : 'danger',
|
type: abnormal ? 'danger' : 'success',
|
||||||
|
size: 'small',
|
||||||
style: { marginBottom: '5px' },
|
style: { marginBottom: '5px' },
|
||||||
},
|
},
|
||||||
{
|
{ default: () => (abnormal ? '异常' : '正常') },
|
||||||
default: () => (record.row.status == 0 ? '正常' : '异常'),
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
h(
|
h(
|
||||||
'div',
|
'div',
|
||||||
{
|
{
|
||||||
style: { marginBottom: '5px' },
|
style: {
|
||||||
|
...subText,
|
||||||
|
marginBottom: '4px',
|
||||||
|
color: abnormal ? '#f56c6c' : '#666',
|
||||||
|
fontWeight: abnormal ? 'bold' : 'normal',
|
||||||
},
|
},
|
||||||
'请求耗时:' + record.row.consumeTime + 'ms',
|
},
|
||||||
|
'请求耗时:' + (record.row.consumeTime ?? '-') + 'ms',
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
style: subText,
|
||||||
|
},
|
||||||
|
'响应时间:' + (record.row.updateTime || '-'),
|
||||||
),
|
),
|
||||||
h('div', '响应时间:' + record.row.updateTime),
|
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -98,9 +173,27 @@ export const columns = [
|
|||||||
minWidth: 200,
|
minWidth: 200,
|
||||||
render(record) {
|
render(record) {
|
||||||
return h('div', [
|
return h('div', [
|
||||||
h('div', '系统:' + record.row.os),
|
h(
|
||||||
h('div', '类型:' + record.row.logTypeText),
|
'div',
|
||||||
h('div', '来源:' + record.row.sourceText),
|
{
|
||||||
|
style: { marginBottom: '4px' },
|
||||||
|
},
|
||||||
|
'系统:' + (record.row.os || '-'),
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
style: { marginBottom: '4px' },
|
||||||
|
},
|
||||||
|
'类型:' + (record.row.logTypeText || '-'),
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
style: subText,
|
||||||
|
},
|
||||||
|
'来源:' + (record.row.sourceText || '-'),
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,45 +2,92 @@
|
|||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="props.visible"
|
v-model="props.visible"
|
||||||
title="登录日志详情"
|
title="登录日志详情"
|
||||||
width="750"
|
width="860"
|
||||||
:close-on-click-modal="false"
|
:close-on-click-modal="false"
|
||||||
:before-close="dialogClose"
|
:before-close="dialogClose"
|
||||||
>
|
>
|
||||||
<el-descriptions class="margin-top" :column="2" border>
|
<!-- 概览条:头像 + 操作用户 + 状态标签 + 摘要 -->
|
||||||
<el-descriptions-item label="操作用户" label-class-name="des-width">
|
<div class="overview rounded-lg">
|
||||||
{{ formData.createUser }}
|
<el-avatar
|
||||||
</el-descriptions-item>
|
:src="formData.avatar"
|
||||||
<el-descriptions-item label="IP地址" label-class-name="des-width">
|
:size="56"
|
||||||
{{ formData.ip }}
|
shape="square"
|
||||||
</el-descriptions-item>
|
fit="fill"
|
||||||
<el-descriptions-item label="操作系统">
|
class="overview-avatar"
|
||||||
{{ formData.os }}
|
>
|
||||||
</el-descriptions-item>
|
{{ (formData.createUser || '?').slice(0, 1) }}
|
||||||
<el-descriptions-item label="浏览器">
|
</el-avatar>
|
||||||
{{ formData.browser }}
|
<div class="ml-4 min-w-0 flex-1">
|
||||||
</el-descriptions-item>
|
<div class="flex items-center flex-wrap">
|
||||||
<el-descriptions-item label="请求耗时"> {{ formData.consumeTime }}ms </el-descriptions-item>
|
<span class="text-lg font-bold">{{ formData.createUser || '-' }}</span>
|
||||||
<el-descriptions-item label="请求地区">
|
<el-tag :type="formData.status ? 'danger' : 'success'" size="small" class="ml-3">
|
||||||
{{ formData.location }}
|
{{ formData.status ? '异常' : '正常' }}
|
||||||
</el-descriptions-item>
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-500 text-sm mt-1 truncate" :title="formData.ip">
|
||||||
|
{{ formData.ip || '-' }} · {{ formData.location || '-' }}
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-400 text-xs mt-1 truncate" :title="formData.os">
|
||||||
|
{{ formData.os || '-' }} · {{ formData.browser || '-' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right text-gray-400 text-xs overview-time">
|
||||||
|
<div>请求耗时</div>
|
||||||
|
<div class="text-gray-600 text-sm mt-1">{{ formData.consumeTime ?? '-' }}ms</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 请求信息 -->
|
||||||
|
<el-descriptions class="section-gap" title="请求信息" :column="2" border>
|
||||||
<el-descriptions-item label="请求方式">
|
<el-descriptions-item label="请求方式">
|
||||||
{{ formData.requestMethod }}
|
<el-tag size="small">{{ formData.requestMethod || '-' }}</el-tag>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="请求状态">
|
<el-descriptions-item label="请求状态">
|
||||||
<el-tag :type="formData.status ? 'danger' : 'success'">{{
|
<el-tag :type="formData.status ? 'danger' : 'success'" size="small">
|
||||||
formData.status ? '异常' : '正常'
|
{{ formData.status ? '异常' : '正常' }}
|
||||||
}}</el-tag>
|
</el-tag>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="调用方法" :span="3">
|
<el-descriptions-item label="请求地区">{{ formData.location || '-' }}</el-descriptions-item>
|
||||||
{{ formData.method }}
|
<el-descriptions-item label="请求耗时"
|
||||||
</el-descriptions-item>
|
>{{ formData.consumeTime ?? '-' }}ms</el-descriptions-item
|
||||||
<el-descriptions-item label="请求参数" :span="3">
|
>
|
||||||
{{ formData.param }}
|
<el-descriptions-item label="调用方法" :span="2">
|
||||||
</el-descriptions-item>
|
<span class="field-break">{{ formData.method || '-' }}</span>
|
||||||
<el-descriptions-item label="返回结果" :span="3">
|
|
||||||
{{ formData.result }}
|
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
|
||||||
|
<!-- 登录环境 -->
|
||||||
|
<el-descriptions class="section-gap" title="登录环境" :column="2" border>
|
||||||
|
<el-descriptions-item label="操作用户">{{ formData.createUser || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="IP地址">
|
||||||
|
<el-tag type="info" size="small" effect="plain">{{ formData.ip || '-' }}</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="操作系统">{{ formData.os || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="浏览器">{{ formData.browser || '-' }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<!-- 请求参数 -->
|
||||||
|
<div class="code-block section-gap">
|
||||||
|
<div class="code-head flex items-center justify-between">
|
||||||
|
<span>请求参数</span>
|
||||||
|
<el-button type="primary" link size="small" @click="copyText(formData.param)"
|
||||||
|
>复制</el-button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<pre class="code-body">{{ formData.param || '-' }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 返回结果 -->
|
||||||
|
<div class="code-block mt-3">
|
||||||
|
<div class="code-head flex items-center justify-between">
|
||||||
|
<span>返回结果</span>
|
||||||
|
<el-button type="primary" link size="small" @click="copyText(formData.result)"
|
||||||
|
>复制</el-button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<pre class="code-body">{{ formData.result || '-' }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="dialog-footer">
|
<span class="dialog-footer">
|
||||||
<el-button @click="dialogClose">取消</el-button>
|
<el-button @click="dialogClose">取消</el-button>
|
||||||
@@ -51,6 +98,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { getLoginLogDetail } from '@/api/system/loginLog';
|
import { getLoginLogDetail } from '@/api/system/loginLog';
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { message } from '@/utils/auth';
|
||||||
|
|
||||||
const emit = defineEmits(['success', 'update:visible']);
|
const emit = defineEmits(['success', 'update:visible']);
|
||||||
|
|
||||||
@@ -59,6 +107,7 @@
|
|||||||
*/
|
*/
|
||||||
const formData = ref({
|
const formData = ref({
|
||||||
createUser: '',
|
createUser: '',
|
||||||
|
avatar: '',
|
||||||
ip: '',
|
ip: '',
|
||||||
os: '',
|
os: '',
|
||||||
browser: '',
|
browser: '',
|
||||||
@@ -94,6 +143,22 @@
|
|||||||
emit('update:visible', false);
|
emit('update:visible', false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制文本
|
||||||
|
*/
|
||||||
|
const copyText = async (text?: string) => {
|
||||||
|
if (!text) {
|
||||||
|
message('无内容可复制');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
message('复制成功');
|
||||||
|
} catch {
|
||||||
|
message('复制失败,请手动选择复制');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置表单数据
|
* 设置表单数据
|
||||||
*/
|
*/
|
||||||
@@ -113,6 +178,60 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
.overview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: linear-gradient(120deg, #f5f7ff 0%, #f8fafc 100%);
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
|
||||||
|
.overview-avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #409eff;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-time {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-gap {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-break {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-head {
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #303133;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
max-height: 160px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
:deep(.des-width) {
|
:deep(.des-width) {
|
||||||
width: 120px;
|
width: 120px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,28 @@
|
|||||||
import { h } from 'vue';
|
import { h } from 'vue';
|
||||||
import { ElTag } from 'element-plus';
|
import { ElTag, ElAvatar } from 'element-plus';
|
||||||
|
|
||||||
|
// 灰色副文本统一风格
|
||||||
|
const subText: Record<string, string> = {
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#666',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 请求方式标签类型
|
||||||
|
function methodTagType(method?: string) {
|
||||||
|
const m = (method || '').toUpperCase();
|
||||||
|
if (m === 'GET') return 'success';
|
||||||
|
if (m === 'POST') return 'primary';
|
||||||
|
if (m === 'PUT' || m === 'PATCH') return 'warning';
|
||||||
|
if (m === 'DELETE') return 'danger';
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 操作类型标签类型:删除/异常类用 danger,修改用 warning,其余 primary
|
||||||
|
function typeTagType(type?: number) {
|
||||||
|
if (type === 3) return 'danger'; // 删除
|
||||||
|
if (type === 2) return 'warning'; // 修改
|
||||||
|
return 'primary';
|
||||||
|
}
|
||||||
|
|
||||||
export const columns = [
|
export const columns = [
|
||||||
{
|
{
|
||||||
@@ -14,93 +37,173 @@ export const columns = [
|
|||||||
{
|
{
|
||||||
label: '访客',
|
label: '访客',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
minWidth: 200,
|
minWidth: 220,
|
||||||
render(record) {
|
render(record) {
|
||||||
return h('div', [
|
const textBlock = h('div', { style: { minWidth: '0', flex: '1' } }, [
|
||||||
h(
|
h(
|
||||||
'div',
|
'div',
|
||||||
{
|
{
|
||||||
style: { marginBottom: '5px' },
|
style: { marginBottom: '4px', fontWeight: 'bold' },
|
||||||
},
|
},
|
||||||
record.row.createUser,
|
[
|
||||||
|
record.row.createUser || '-',
|
||||||
|
record.row.typeText
|
||||||
|
? h(
|
||||||
|
ElTag,
|
||||||
|
{
|
||||||
|
type: typeTagType(record.row.type),
|
||||||
|
size: 'small',
|
||||||
|
style: { marginLeft: '8px' },
|
||||||
|
},
|
||||||
|
{ default: () => record.row.typeText },
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
],
|
||||||
),
|
),
|
||||||
h(
|
h(
|
||||||
'div',
|
'div',
|
||||||
{
|
{
|
||||||
style: { marginBottom: '5px' },
|
style: { marginBottom: '4px' },
|
||||||
},
|
},
|
||||||
record.row.ip,
|
[
|
||||||
),
|
|
||||||
h(
|
h(
|
||||||
ElTag,
|
ElTag,
|
||||||
{
|
{
|
||||||
type: 'primary',
|
type: 'info',
|
||||||
},
|
size: 'small',
|
||||||
{
|
effect: 'plain',
|
||||||
default: () => record.row.location,
|
|
||||||
},
|
},
|
||||||
|
{ default: () => record.row.ip || '-' },
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
style: subText,
|
||||||
|
},
|
||||||
|
'地点:' + (record.row.location || '-'),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
return h('div', { style: { display: 'flex', alignItems: 'center' } }, [
|
||||||
|
h(
|
||||||
|
ElAvatar,
|
||||||
|
{
|
||||||
|
src: record.row.avatar,
|
||||||
|
size: 48,
|
||||||
|
shape: 'square',
|
||||||
|
fit: 'fill',
|
||||||
|
style: {
|
||||||
|
marginRight: '12px',
|
||||||
|
flexShrink: 0,
|
||||||
|
background: '#409eff',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ default: () => (record.row.createUser || '?').slice(0, 1) },
|
||||||
|
),
|
||||||
|
textBlock,
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '请求接口',
|
label: '请求接口',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
minWidth: 200,
|
minWidth: 220,
|
||||||
render(record) {
|
render(record) {
|
||||||
return h('div', [
|
return h('div', [
|
||||||
h(
|
h(
|
||||||
ElTag,
|
ElTag,
|
||||||
{
|
{
|
||||||
type: 'info',
|
type: methodTagType(record.row.requestMethod),
|
||||||
|
size: 'small',
|
||||||
style: { marginBottom: '5px' },
|
style: { marginBottom: '5px' },
|
||||||
},
|
},
|
||||||
|
{ default: () => record.row.requestMethod || '-' },
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
{
|
{
|
||||||
default: () => record.row.requestMethod,
|
style: { ...subText, wordBreak: 'break-all', marginBottom: '4px' },
|
||||||
|
title: record.row.url,
|
||||||
},
|
},
|
||||||
|
'接口:' + (record.row.url || '-'),
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
style: { fontWeight: 'bold' },
|
||||||
|
},
|
||||||
|
'名称:' + (record.row.title || '-'),
|
||||||
),
|
),
|
||||||
h('div', '接口:' + record.row.url),
|
|
||||||
h('div', '名称:' + record.row.title),
|
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '接口响应',
|
label: '接口响应',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
minWidth: 250,
|
minWidth: 200,
|
||||||
render(record) {
|
render(record) {
|
||||||
|
const abnormal = record.row.status != 0;
|
||||||
return h('div', [
|
return h('div', [
|
||||||
h('span', '状态:'),
|
|
||||||
h(
|
h(
|
||||||
ElTag,
|
ElTag,
|
||||||
{
|
{
|
||||||
type: record.row.status == 0 ? 'success' : 'danger',
|
type: abnormal ? 'danger' : 'success',
|
||||||
|
size: 'small',
|
||||||
style: { marginBottom: '5px' },
|
style: { marginBottom: '5px' },
|
||||||
},
|
},
|
||||||
{
|
{ default: () => (abnormal ? '异常' : '正常') },
|
||||||
default: () => (record.row.status == 0 ? '正常' : '异常'),
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
h(
|
h(
|
||||||
'div',
|
'div',
|
||||||
{
|
{
|
||||||
style: { marginBottom: '5px' },
|
style: {
|
||||||
|
...subText,
|
||||||
|
marginBottom: '4px',
|
||||||
|
color: abnormal ? '#f56c6c' : '#666',
|
||||||
|
fontWeight: abnormal ? 'bold' : 'normal',
|
||||||
},
|
},
|
||||||
'请求耗时:' + record.row.consumeTime + 'ms',
|
},
|
||||||
|
'请求耗时:' + (record.row.consumeTime ?? '-') + 'ms',
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
style: subText,
|
||||||
|
},
|
||||||
|
'响应时间:' + (record.row.updateTime || '-'),
|
||||||
),
|
),
|
||||||
h('div', '响应时间:' + record.row.updateTime),
|
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '操作来源',
|
label: '操作来源',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
minWidth: 200,
|
minWidth: 180,
|
||||||
render(record) {
|
render(record) {
|
||||||
return h('div', [
|
return h('div', [
|
||||||
h('div', '系统:' + record.row.os),
|
h(
|
||||||
h('div', '类型:' + record.row.typeText),
|
'div',
|
||||||
h('div', '来源:' + record.row.sourceText),
|
{
|
||||||
|
style: { marginBottom: '4px' },
|
||||||
|
},
|
||||||
|
[h('span', { style: subText }, '系统:'), h('span', record.row.os || '-')],
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
style: { marginBottom: '4px' },
|
||||||
|
},
|
||||||
|
[h('span', { style: subText }, '来源:'), h('span', record.row.sourceText || '-')],
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
style: subText,
|
||||||
|
},
|
||||||
|
'浏览器:' + (record.row.browser || '-'),
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,60 +2,114 @@
|
|||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="props.visible"
|
v-model="props.visible"
|
||||||
title="操作日志详情"
|
title="操作日志详情"
|
||||||
width="750"
|
width="860"
|
||||||
:close-on-click-modal="false"
|
:close-on-click-modal="false"
|
||||||
:before-close="dialogClose"
|
:before-close="dialogClose"
|
||||||
>
|
>
|
||||||
<el-descriptions class="margin-top" :column="2" border>
|
<!-- 概览条:头像 + 操作人 + 状态/类型标签 + 摘要 -->
|
||||||
<el-descriptions-item label="日志标题" label-class-name="des-width">
|
<div class="overview rounded-lg">
|
||||||
{{ formData.title }}
|
<el-avatar
|
||||||
</el-descriptions-item>
|
:src="formData.avatar"
|
||||||
<el-descriptions-item label="操作用户" label-class-name="des-width">
|
:size="56"
|
||||||
{{ formData.createUser }}
|
shape="square"
|
||||||
</el-descriptions-item>
|
fit="fill"
|
||||||
<el-descriptions-item label="请求IP">
|
class="overview-avatar"
|
||||||
{{ formData.ip }}
|
>
|
||||||
</el-descriptions-item>
|
{{ (formData.createUser || '?').slice(0, 1) }}
|
||||||
<el-descriptions-item label="操作系统">
|
</el-avatar>
|
||||||
{{ formData.os }}
|
<div class="ml-4 min-w-0 flex-1">
|
||||||
</el-descriptions-item>
|
<div class="flex items-center flex-wrap">
|
||||||
<el-descriptions-item label="浏览器">
|
<span class="text-lg font-bold">{{ formData.createUser || '-' }}</span>
|
||||||
{{ formData.browser }}
|
<el-tag :type="formData.status ? 'danger' : 'success'" size="small" class="ml-3">
|
||||||
</el-descriptions-item>
|
{{ formData.status ? '异常' : '正常' }}
|
||||||
<el-descriptions-item label="操作类型">
|
</el-tag>
|
||||||
|
<el-tag v-if="formData.typeText" type="primary" size="small" class="ml-2">
|
||||||
{{ formData.typeText }}
|
{{ formData.typeText }}
|
||||||
</el-descriptions-item>
|
</el-tag>
|
||||||
<el-descriptions-item label="操作来源">
|
<el-tag v-if="formData.sourceText" type="info" size="small" class="ml-2">
|
||||||
{{ formData.sourceText }}
|
{{ formData.sourceText }}
|
||||||
</el-descriptions-item>
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-500 text-sm mt-1 truncate" :title="formData.title">
|
||||||
|
{{ formData.title || '-' }}
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-400 text-xs mt-1 truncate" :title="formData.url">
|
||||||
|
{{ formData.url || '-' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right text-gray-400 text-xs overview-time">
|
||||||
|
<div>响应时间</div>
|
||||||
|
<div class="text-gray-600 text-sm mt-1">{{ formData.updateTime || '-' }}</div>
|
||||||
|
<div class="mt-2">创建时间</div>
|
||||||
|
<div class="text-gray-600 text-sm mt-1">{{ formData.createTime || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 请求信息 -->
|
||||||
|
<el-descriptions class="section-gap" title="请求信息" :column="2" border>
|
||||||
<el-descriptions-item label="请求方式">
|
<el-descriptions-item label="请求方式">
|
||||||
{{ formData.requestMethod }}
|
<el-tag size="small">{{ formData.requestMethod || '-' }}</el-tag>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="请求URL">
|
<el-descriptions-item label="请求耗时">
|
||||||
{{ formData.url }}
|
<span :class="{ 'consume-abnormal': formData.status }">
|
||||||
|
{{ formData.consumeTime ?? '-' }}ms
|
||||||
|
</span>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="请求耗时"> {{ formData.consumeTime }}ms </el-descriptions-item>
|
<el-descriptions-item label="请求URL" :span="2">
|
||||||
<el-descriptions-item label="请求地区">
|
<span class="field-break">{{ formData.url || '-' }}</span>
|
||||||
{{ formData.location }}
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="调用方法" :span="2">
|
||||||
|
<span class="field-break">{{ formData.method || '-' }}</span>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="请求状态">
|
<el-descriptions-item label="请求状态">
|
||||||
<el-tag :type="formData.status ? 'danger' : 'success'">{{
|
<el-tag :type="formData.status ? 'danger' : 'success'" size="small">
|
||||||
formData.status ? '异常' : '正常'
|
{{ formData.status ? '异常' : '正常' }}
|
||||||
}}</el-tag>
|
</el-tag>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="调用方法" :span="3">
|
<el-descriptions-item label="错误信息">
|
||||||
{{ formData.method }}
|
<span class="field-break">{{ formData.error || '-' }}</span>
|
||||||
</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="请求参数" :span="3">
|
|
||||||
{{ formData.param }}
|
|
||||||
</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="返回结果" :span="3">
|
|
||||||
{{ formData.result }}
|
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
|
||||||
|
<!-- 操作环境 -->
|
||||||
|
<el-descriptions class="section-gap" title="操作环境" :column="2" border>
|
||||||
|
<el-descriptions-item label="操作人">{{ formData.createUser || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="操作类型">{{ formData.typeText || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="操作来源">{{ formData.sourceText || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="请求IP">
|
||||||
|
<el-tag type="info" size="small" effect="plain">{{ formData.ip || '-' }}</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="请求地区">{{ formData.location || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="操作系统">{{ formData.os || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="浏览器">{{ formData.browser || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="日志标题">{{ formData.title || '-' }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<!-- 请求参数 -->
|
||||||
|
<div class="code-block section-gap">
|
||||||
|
<div class="code-head flex items-center justify-between">
|
||||||
|
<span>请求参数</span>
|
||||||
|
<el-button type="primary" link size="small" @click="copyText(formData.param)"
|
||||||
|
>复制</el-button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<pre class="code-body">{{ formData.param || '-' }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 返回结果 -->
|
||||||
|
<div class="code-block mt-3">
|
||||||
|
<div class="code-head flex items-center justify-between">
|
||||||
|
<span>返回结果</span>
|
||||||
|
<el-button type="primary" link size="small" @click="copyText(formData.result)"
|
||||||
|
>复制</el-button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<pre class="code-body">{{ formData.result || '-' }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="dialog-footer">
|
<span class="dialog-footer">
|
||||||
<el-button @click="dialogClose">取消</el-button>
|
<el-button @click="dialogClose">关 闭</el-button>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@@ -63,6 +117,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { getOperLogDetail } from '@/api/system/operLog';
|
import { getOperLogDetail } from '@/api/system/operLog';
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { message } from '@/utils/auth';
|
||||||
|
|
||||||
const emit = defineEmits(['success', 'update:visible']);
|
const emit = defineEmits(['success', 'update:visible']);
|
||||||
|
|
||||||
@@ -72,7 +127,9 @@
|
|||||||
const formData = ref({
|
const formData = ref({
|
||||||
title: '',
|
title: '',
|
||||||
createUser: '',
|
createUser: '',
|
||||||
|
avatar: '',
|
||||||
ip: '',
|
ip: '',
|
||||||
|
location: '',
|
||||||
os: '',
|
os: '',
|
||||||
browser: '',
|
browser: '',
|
||||||
consumeTime: '',
|
consumeTime: '',
|
||||||
@@ -81,6 +138,12 @@
|
|||||||
method: '',
|
method: '',
|
||||||
param: '',
|
param: '',
|
||||||
result: '',
|
result: '',
|
||||||
|
error: '',
|
||||||
|
url: '',
|
||||||
|
typeText: '',
|
||||||
|
sourceText: '',
|
||||||
|
updateTime: '',
|
||||||
|
createTime: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,6 +169,22 @@
|
|||||||
emit('update:visible', false);
|
emit('update:visible', false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制文本
|
||||||
|
*/
|
||||||
|
const copyText = async (text?: string) => {
|
||||||
|
if (!text) {
|
||||||
|
message('无内容可复制');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
message('复制成功');
|
||||||
|
} catch {
|
||||||
|
message('复制失败,请手动选择复制');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置表单数据
|
* 设置表单数据
|
||||||
*/
|
*/
|
||||||
@@ -125,6 +204,65 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
.overview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: linear-gradient(120deg, #f5f7ff 0%, #f8fafc 100%);
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
|
||||||
|
.overview-avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #409eff;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-time {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-gap {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-break {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consume-abnormal {
|
||||||
|
color: #f56c6c;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-head {
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #303133;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
max-height: 160px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
:deep(.des-width) {
|
:deep(.des-width) {
|
||||||
width: 120px;
|
width: 120px;
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
@@ -30,6 +30,14 @@ type PageData struct {
|
|||||||
Total int `json:"total"` // 总记录数
|
Total int `json:"total"` // 总记录数
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MsgProvider 业务结果自定义成功提示语接口
|
||||||
|
//
|
||||||
|
// 成功信封默认 msg 为"操作成功";实现本接口的结果对象可按自身数据生成
|
||||||
|
// 面向用户的提示语(如导入结果"成功导入 X 条数据"),由统一出口优先采用。
|
||||||
|
type MsgProvider interface {
|
||||||
|
ResMsg() string
|
||||||
|
}
|
||||||
|
|
||||||
// Ok 构造成功响应
|
// Ok 构造成功响应
|
||||||
//
|
//
|
||||||
// data 为 nil 时输出空对象,避免前端判空逻辑失败
|
// data 为 nil 时输出空对象,避免前端判空逻辑失败
|
||||||
|
|||||||
Reference in New Issue
Block a user