From 20bc65b1f3c1c7b0758ab88f49cb76977f097c22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=BF=97=E5=BC=BA?= <357099073@qq.com> Date: Wed, 26 Aug 2026 16:15:23 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0oa=E7=9A=84=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/api/reimburse.js | 7 + backend/src/views/apps/oa/dashboard/index.vue | 583 +++++++++--------- backend/src/views/system/loginLog/index.vue | 8 +- go/controllers/backend_reimburse.go | 148 +++++ go/controllers/platform_auth.go | 4 +- go/controllers/platform_login_log.go | 259 ++++++++ go/routers/backend/backend.go | 1 + go/routers/platform/platform.go | 5 + platform/src/api/loginLog.js | 58 ++ .../system/loginLog/components/detail.vue | 161 +++++ platform/src/views/system/loginLog/index.vue | 376 +++++++++++ 11 files changed, 1328 insertions(+), 282 deletions(-) create mode 100644 go/controllers/platform_login_log.go create mode 100644 platform/src/api/loginLog.js create mode 100644 platform/src/views/system/loginLog/components/detail.vue create mode 100644 platform/src/views/system/loginLog/index.vue diff --git a/backend/src/api/reimburse.js b/backend/src/api/reimburse.js index 2ef8e1b..120d7e3 100644 --- a/backend/src/api/reimburse.js +++ b/backend/src/api/reimburse.js @@ -1,5 +1,12 @@ import request from '@/utils/request' +export function getReimbursementDashboard() { + return request({ + url: '/backend/reimbursements/dashboard', + method: 'get' + }) +} + export function getReimbursementList(params) { return request({ url: '/backend/reimbursements', diff --git a/backend/src/views/apps/oa/dashboard/index.vue b/backend/src/views/apps/oa/dashboard/index.vue index 1df08e9..5e73906 100644 --- a/backend/src/views/apps/oa/dashboard/index.vue +++ b/backend/src/views/apps/oa/dashboard/index.vue @@ -1,255 +1,256 @@ - diff --git a/backend/src/views/system/loginLog/index.vue b/backend/src/views/system/loginLog/index.vue index b2a9e29..2078f58 100644 --- a/backend/src/views/system/loginLog/index.vue +++ b/backend/src/views/system/loginLog/index.vue @@ -140,25 +140,25 @@ > 详情 - 删除 - + --> -
+
diff --git a/go/controllers/backend_reimburse.go b/go/controllers/backend_reimburse.go index 96e8d33..7763150 100644 --- a/go/controllers/backend_reimburse.go +++ b/go/controllers/backend_reimburse.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "path" + "sort" "strconv" "strings" "time" @@ -1149,3 +1150,150 @@ func nullableReimburseString(value string) *string { func uint64Ptr(value uint64) *uint64 { return &value } + +// Dashboard GET /backend/reimbursements/dashboard — 报销统计(周/月/年 + 近6个月趋势 + 状态分布) +func (c *BackendReimburseController) Dashboard() { + claims, ok := c.claims() + if !ok { + c.reply(401, "未登录或无权限", nil) + return + } + now := time.Now() + y, m, _ := now.Date() + today := time.Date(y, m, now.Day(), 0, 0, 0, 0, now.Location()) + tomorrow := today.AddDate(0, 0, 1) + weekday := int(today.Weekday()) + if weekday == 0 { + weekday = 7 + } + weekStart := today.AddDate(0, 0, -(weekday - 1)) + monthStart := time.Date(y, m, 1, 0, 0, 0, 0, now.Location()) + yearStart := time.Date(y, 1, 1, 0, 0, 0, 0, now.Location()) + + var rows []models.BackendReimbursement + _, err := models.Orm.QueryTable(new(models.BackendReimbursement)). + Filter("tid", claims.TenantId). + Filter("user_id", claims.UserID). + Filter("is_deleted", 0). + All(&rows) + if err != nil { + c.reply(500, "查询失败", nil) + return + } + + // ponytail: 个人报销单量小,一次全量查询内存聚合;量大时改 SQL GROUP BY + newStat := func() map[string]interface{} { + return map[string]interface{}{"total": 0.0, "paid": 0.0, "unpaid": 0.0, "count": 0} + } + addTo := func(stat map[string]interface{}, row *models.BackendReimbursement) { + stat["total"] = stat["total"].(float64) + row.TotalAmount + stat["count"] = stat["count"].(int) + 1 + if row.Status == 5 { // 已打款视为已报销 + stat["paid"] = stat["paid"].(float64) + row.TotalAmount + } else { + stat["unpaid"] = stat["unpaid"].(float64) + row.TotalAmount + } + } + inRange := func(t, start, end time.Time) bool { + return !t.Before(start) && t.Before(end) + } + + weekStat, monthStat, yearStat := newStat(), newStat(), newStat() + monthKeys := make([]string, 0, 6) + monthStats := map[string]map[string]interface{}{} + for i := 5; i >= 0; i-- { + key := time.Date(y, m-time.Month(i), 1, 0, 0, 0, 0, now.Location()).Format("2006-01") + monthKeys = append(monthKeys, key) + monthStats[key] = newStat() + } + statusNames := map[int8]string{0: "草稿", 1: "审批中", 2: "已通过", 3: "已驳回", 4: "已撤回", 5: "已打款"} + statusDist := map[string]interface{}{} + + for i := range rows { + row := &rows[i] + if inRange(row.ApplyDate, weekStart, tomorrow) { + addTo(weekStat, row) + } + if inRange(row.ApplyDate, monthStart, tomorrow) { + addTo(monthStat, row) + } + if !row.ApplyDate.Before(yearStart) { + addTo(yearStat, row) + } + if st, ok := monthStats[row.ApplyDate.Format("2006-01")]; ok { + addTo(st, row) + } + sk := strconv.Itoa(int(row.Status)) + dist, ok := statusDist[sk].(map[string]interface{}) + if !ok { + dist = map[string]interface{}{"name": statusNames[row.Status], "count": 0, "amount": 0.0} + statusDist[sk] = dist + } + dist["count"] = dist["count"].(int) + 1 + dist["amount"] = dist["amount"].(float64) + row.TotalAmount + } + + trend := make([]map[string]interface{}, 0, len(monthKeys)) + for _, key := range monthKeys { + st := monthStats[key] + trend = append(trend, map[string]interface{}{ + "month": key, "total": st["total"], "paid": st["paid"], + "unpaid": st["unpaid"], "count": st["count"], + }) + } + + // 报销类型分析:仅统计已打款(status=5)的报销单费用明细 + paidIDs := map[uint64]bool{} + for i := range rows { + if rows[i].Status == 5 { + paidIDs[rows[i].ID] = true + } + } + var items []models.BackendReimbursementItem + _, err = models.Orm.QueryTable(new(models.BackendReimbursementItem)). + Filter("tid", claims.TenantId). + Filter("uid", claims.UserID). + Filter("is_deleted", 0). + All(&items) + if err != nil { + c.reply(500, "查询失败", nil) + return + } + typeAgg := map[string]*struct { + count int + amount float64 + }{} + for i := range items { + if !paidIDs[items[i].ReimbursementID] { + continue + } + name := strings.TrimSpace(items[i].ExpenseType) + if name == "" { + name = "未分类" + } + agg, ok := typeAgg[name] + if !ok { + agg = &struct { + count int + amount float64 + }{} + typeAgg[name] = agg + } + agg.count++ + agg.amount += items[i].Amount + } + expenseTypes := make([]map[string]interface{}, 0, len(typeAgg)) + for name, agg := range typeAgg { + expenseTypes = append(expenseTypes, map[string]interface{}{ + "name": name, "count": agg.count, "amount": agg.amount, + }) + } + sort.Slice(expenseTypes, func(i, j int) bool { + return expenseTypes[i]["amount"].(float64) > expenseTypes[j]["amount"].(float64) + }) + + c.reply(200, "success", map[string]interface{}{ + "week": weekStat, "month": monthStat, "year": yearStat, + "trend": trend, "status_dist": statusDist, "expense_types": expenseTypes, + }) +} diff --git a/go/controllers/platform_auth.go b/go/controllers/platform_auth.go index fd12675..97c3461 100644 --- a/go/controllers/platform_auth.go +++ b/go/controllers/platform_auth.go @@ -129,6 +129,7 @@ func (c *PlatformAuthController) LoginPlatform() { // 控制器只做 HTTP 解析与响应编排,业务逻辑放 services 层 token, loginUser, err := services.PlatformAdminLogin(req.Account, req.Password) if err != nil { + RecordLoginLog(nil, 0, req.Account, "", "", "password", 0, err.Error(), c.Ctx.Input.IP(), c.Ctx.Request.UserAgent()) c.Data["json"] = map[string]interface{}{ "code": 401, "msg": err.Error(), @@ -137,6 +138,8 @@ func (c *PlatformAuthController) LoginPlatform() { return } + RecordLoginLog(nil, loginUser.ID, loginUser.Account, loginUser.Name, "", "password", 1, "登录成功", c.Ctx.Input.IP(), c.Ctx.Request.UserAgent()) + c.Data["json"] = map[string]interface{}{ "code": 200, "msg": "登录成功", @@ -422,4 +425,3 @@ func (c *PlatformAuthController) SendResetCode() { } _ = c.ServeJSON() } - diff --git a/go/controllers/platform_login_log.go b/go/controllers/platform_login_log.go new file mode 100644 index 0000000..15416e8 --- /dev/null +++ b/go/controllers/platform_login_log.go @@ -0,0 +1,259 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + "time" + + "server/models" + "server/pkg/jwtutil" + + "github.com/beego/beego/v2/client/orm" + beego "github.com/beego/beego/v2/server/web" +) + +// PlatformLoginLogController 登录日志(yz_system_login_log,平台端) +type PlatformLoginLogController struct { + beego.Controller +} + +func (c *PlatformLoginLogController) jsonErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func (c *PlatformLoginLogController) platformClaims() (*jwtutil.Claims, error) { + auth := c.Ctx.Request.Header.Get("Authorization") + if auth == "" { + return nil, fmt.Errorf("未登录") + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return nil, fmt.Errorf("认证信息格式错误") + } + claims, err := jwtutil.ParseToken(parts[1]) + if err != nil { + return nil, fmt.Errorf("无效的token") + } + if claims.UserType != "platform" { + return nil, fmt.Errorf("无权访问") + } + return claims, nil +} + +// List GET /platform/loginLogs?page=1&pageSize=20&keyword=&status=&loginType=&startTime=&endTime= +func (c *PlatformLoginLogController) List() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + page, _ := c.GetInt("page", 1) + pageSize, _ := c.GetInt("pageSize", 20) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 200 { + pageSize = 200 + } + + keyword := strings.TrimSpace(c.GetString("keyword")) + statusStr := strings.TrimSpace(c.GetString("status")) + loginType := strings.TrimSpace(c.GetString("loginType")) + startTimeStr := strings.TrimSpace(c.GetString("startTime")) + endTimeStr := strings.TrimSpace(c.GetString("endTime")) + + qs := models.Orm.QueryTable(new(models.SystemLoginLog)).Filter("delete_time__isnull", true) + + cond := orm.NewCondition() + needCond := false + + if statusStr != "" { + if st, err := strconv.Atoi(statusStr); err == nil { + cond = cond.And("status", st) + needCond = true + } + } + if loginType != "" { + cond = cond.And("login_type", loginType) + needCond = true + } + if keyword != "" { + kw := orm.NewCondition(). + Or("account__icontains", keyword). + Or("user_name__icontains", keyword). + Or("tenant_name__icontains", keyword). + Or("ip__icontains", keyword) + if uid, err := strconv.ParseUint(keyword, 10, 64); err == nil && uid > 0 { + kw = kw.Or("user_id", uid) + } + cond = cond.AndCond(kw) + needCond = true + } + if t, err := parseTimeFlexible(startTimeStr); err == nil && !t.IsZero() { + cond = cond.And("create_time__gte", t) + needCond = true + } + if t, err := parseTimeFlexible(endTimeStr); err == nil && !t.IsZero() { + cond = cond.And("create_time__lte", t) + needCond = true + } + + if needCond { + qs = qs.SetCond(cond) + } + + total, err := qs.Count() + if err != nil { + c.jsonErr(500, 500, "获取登录日志失败: "+err.Error()) + return + } + + var rows []models.SystemLoginLog + _, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&rows) + if err != nil { + c.jsonErr(500, 500, "获取登录日志失败: "+err.Error()) + return + } + + list := make([]map[string]interface{}, 0, len(rows)) + for i := range rows { + item := map[string]interface{}{ + "id": rows[i].ID, + "tid": rows[i].Tid, + "user_id": rows[i].UserID, + "account": rows[i].Account, + "user_name": rows[i].UserName, + "tenant_name": rows[i].TenantName, + "login_type": rows[i].LoginType, + "status": rows[i].Status, + "message": rows[i].Message, + "ip": rows[i].IP, + "user_agent": rows[i].UserAgent, + "create_time": rows[i].CreateTime.Format("2006-01-02 15:04:05"), + } + list = append(list, item) + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{ + "list": list, + "total": total, + }, + } + _ = c.ServeJSON() +} + +// Detail GET /platform/loginLogs/:id +func (c *PlatformLoginLogController) Detail() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效ID") + return + } + var row models.SystemLoginLog + err = models.Orm.QueryTable(new(models.SystemLoginLog)). + Filter("id", id). + Filter("delete_time__isnull", true). + One(&row) + if err != nil { + c.jsonErr(404, 404, "记录不存在") + return + } + out := map[string]interface{}{ + "id": row.ID, + "tid": row.Tid, + "user_id": row.UserID, + "account": row.Account, + "user_name": row.UserName, + "tenant_name": row.TenantName, + "login_type": row.LoginType, + "status": row.Status, + "message": row.Message, + "ip": row.IP, + "user_agent": row.UserAgent, + "create_time": row.CreateTime.Format("2006-01-02 15:04:05"), + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": out} + _ = c.ServeJSON() +} + +// Delete DELETE /platform/loginLogs/:id +func (c *PlatformLoginLogController) Delete() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效ID") + return + } + now := time.Now() + n, err := models.Orm.QueryTable(new(models.SystemLoginLog)). + Filter("id", id). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"delete_time": now}) + if err != nil { + c.jsonErr(500, 500, "删除失败: "+err.Error()) + return + } + if n == 0 { + c.jsonErr(404, 404, "记录不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除成功"} + _ = c.ServeJSON() +} + +type platformLoginLogBatchDeletePayload struct { + IDs []uint64 `json:"ids"` +} + +// BatchDelete POST /platform/loginLogs/batchDelete +func (c *PlatformLoginLogController) BatchDelete() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + var p platformLoginLogBatchDeletePayload + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + if len(p.IDs) == 0 { + c.jsonErr(400, 400, "请选择要删除的日志") + return + } + now := time.Now() + _, err = models.Orm.QueryTable(new(models.SystemLoginLog)). + Filter("id__in", p.IDs). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"delete_time": now}) + if err != nil { + c.jsonErr(500, 500, "批量删除失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "批量删除成功"} + _ = c.ServeJSON() +} diff --git a/go/routers/backend/backend.go b/go/routers/backend/backend.go index 42bcea7..a05ffb9 100644 --- a/go/routers/backend/backend.go +++ b/go/routers/backend/backend.go @@ -204,6 +204,7 @@ func RegisterAuthRoutes() { // 报销与审批管理 beego.Router("/backend/reimbursements", &controllers.BackendReimburseController{}, "get:List;post:Create") + beego.Router("/backend/reimbursements/dashboard", &controllers.BackendReimburseController{}, "get:Dashboard") beego.Router("/backend/reimbursements/types/manage", &controllers.BackendReimburseController{}, "get:TypesManage") beego.Router("/backend/reimbursements/types/:type_id", &controllers.BackendReimburseController{}, "post:UpdateType;delete:DeleteType") beego.Router("/backend/reimbursements/types", &controllers.BackendReimburseController{}, "get:Types;post:CreateType") diff --git a/go/routers/platform/platform.go b/go/routers/platform/platform.go index 0fa30a2..18029f9 100644 --- a/go/routers/platform/platform.go +++ b/go/routers/platform/platform.go @@ -79,6 +79,11 @@ func Register() { beego.Router("/platform/operationLogs/:id", &controllers.PlatformOperationLogController{}, "get:Detail;delete:Delete") beego.Router("/platform/operationLogs/batchDelete", &controllers.PlatformOperationLogController{}, "post:BatchDelete") + // 登录日志(yz_system_login_log) + beego.Router("/platform/loginLogs", &controllers.PlatformLoginLogController{}, "get:List") + beego.Router("/platform/loginLogs/batchDelete", &controllers.PlatformLoginLogController{}, "post:BatchDelete") + beego.Router("/platform/loginLogs/:id", &controllers.PlatformLoginLogController{}, "get:Detail;delete:Delete") + // 域名管理(主域名池 / 租户域名) beego.Router("/platform/domain/pool/index", &controllers.PlatformDomainPoolController{}, "get:Index") beego.Router("/platform/domain/pool/getEnabledDomains", &controllers.PlatformDomainPoolController{}, "get:GetEnabledDomains") diff --git a/platform/src/api/loginLog.js b/platform/src/api/loginLog.js new file mode 100644 index 0000000..ec5353c --- /dev/null +++ b/platform/src/api/loginLog.js @@ -0,0 +1,58 @@ +import request from "@/utils/request"; + +/** + * 获取登录日志列表 + * @param {Object} params 查询参数 + * @param {number} params.page 页码 + * @param {number} params.pageSize 每页数量 + * @param {string} params.keyword 关键词搜索(账号/姓名/租户/IP) + * @param {string} params.status 状态筛选 + * @param {string} params.loginType 登录方式筛选 + * @param {string} params.startTime 开始时间 + * @param {string} params.endTime 结束时间 + * @returns {Promise} + */ +export function getLoginLogs(params) { + return request({ + url: "/platform/loginLogs", + method: "get", + params, + }); +} + +/** + * 获取登录日志详情 + * @param {number|string} id 日志ID + * @returns {Promise} + */ +export function getLoginLogDetail(id) { + return request({ + url: `/platform/loginLogs/${id}`, + method: "get", + }); +} + +/** + * 删除登录日志 + * @param {number|string} id 日志ID + * @returns {Promise} + */ +export function deleteLoginLog(id) { + return request({ + url: `/platform/loginLogs/${id}`, + method: "delete", + }); +} + +/** + * 批量删除登录日志 + * @param {Array} ids 日志ID数组 + * @returns {Promise} + */ +export function batchDeleteLoginLogs(ids) { + return request({ + url: "/platform/loginLogs/batchDelete", + method: "post", + data: { ids }, + }); +} diff --git a/platform/src/views/system/loginLog/components/detail.vue b/platform/src/views/system/loginLog/components/detail.vue new file mode 100644 index 0000000..ad52941 --- /dev/null +++ b/platform/src/views/system/loginLog/components/detail.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/platform/src/views/system/loginLog/index.vue b/platform/src/views/system/loginLog/index.vue new file mode 100644 index 0000000..bdfce97 --- /dev/null +++ b/platform/src/views/system/loginLog/index.vue @@ -0,0 +1,376 @@ + + + + +