feat(log):新增用户登录日志

This commit is contained in:
qian committed 2026-05-16 09:23:52 +08:00
1 parent ce9a20366c
commit bd0d852e0f
15 files changed
+769 -29

No files matched your search

+1
View File
@@ -189,6 +189,7 @@ type IAdminV1 interface {
Admin2FAVerify(ctx context.Context, req *v1.Admin2FAVerifyReq) (res *v1.Admin2FAVerifyRes, err error)
Admin2FAConfirm(ctx context.Context, req *v1.Admin2FAConfirmReq) (res *v1.Admin2FAConfirmRes, err error)
AdminLoginHistory(ctx context.Context, req *v1.AdminLoginHistoryReq) (res *v1.AdminLoginHistoryRes, err error)
AdminTenantLoginHistory(ctx context.Context, req *v1.AdminTenantLoginHistoryReq) (res *v1.AdminTenantLoginHistoryRes, err error)
AdminSettingsCategories(ctx context.Context, req *v1.AdminSettingsCategoriesReq) (res *v1.AdminSettingsCategoriesRes, err error)
AdminSettingsGet(ctx context.Context, req *v1.AdminSettingsGetReq) (res *v1.AdminSettingsGetRes, err error)
AdminSettingsUpdate(ctx context.Context, req *v1.AdminSettingsUpdateReq) (res *v1.AdminSettingsUpdateRes, err error)
+41
View File
@@ -120,3 +120,44 @@ type LoginHistoryItem struct {
FailReason string `json:"fail_reason"`
CreatedAt string `json:"created_at"`
}
// ============================================================
// 租户用户登录历史(管理后台查看)
// ============================================================
// AdminTenantLoginHistoryReq 租户用户登录历史(管理后台查看)
type AdminTenantLoginHistoryReq struct {
g.Meta `path:"/security/tenant-login-history" method:"get" tags:"管理后台-安全" summary:"租户用户登录历史"`
Page int `json:"page" in:"query" d:"1" dc:"页码"`
PageSize int `json:"page_size" in:"query" d:"20" dc:"每页数量"`
TenantID int64 `json:"tenant_id" in:"query" dc:"租户ID筛选"`
Username string `json:"username" in:"query" dc:"用户名(模糊搜索)"`
IpAddress string `json:"ip_address" in:"query" dc:"IP地址(模糊搜索)"`
Success *bool `json:"success" in:"query" dc:"登录状态:true成功/false失败"`
LoginMethod string `json:"login_method" in:"query" dc:"登录方式:password/totp/sso/backup_code"`
StartTime string `json:"start_time" in:"query" dc:"开始时间(格式:2006-01-02)"`
EndTime string `json:"end_time" in:"query" dc:"结束时间(格式:2006-01-02)"`
}
type AdminTenantLoginHistoryRes struct {
List []AdminTenantLoginHistoryItem `json:"list"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
type AdminTenantLoginHistoryItem struct {
ID int64 `json:"id"`
UserId int64 `json:"user_id"`
TenantId int64 `json:"tenant_id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
LoginMethod string `json:"login_method"`
IpAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"`
Location string `json:"location"`
IsNewDevice bool `json:"is_new_device"`
Success bool `json:"success"`
FailReason string `json:"fail_reason"`
CreatedAt string `json:"created_at"`
}
+13 -4
View File
@@ -90,11 +90,17 @@ type Tenant2FAConfirmRes struct {
// 登录历史
// ============================================================
// TenantLoginHistoryReq 登录历史列表
// TenantLoginHistoryReq 登录历史列表(owner/admin 查看所有成员,member 仅查看自己)
type TenantLoginHistoryReq struct {
g.Meta `path:"/security/login-history" method:"get" tags:"租户控制台-安全" summary:"登录历史"`
Page int `json:"page" in:"query" d:"1" dc:"页码"`
PageSize int `json:"page_size" in:"query" d:"20" dc:"每页数量"`
g.Meta `path:"/security/login-history" method:"get" tags:"租户控制台-安全" summary:"登录历史"`
Page int `json:"page" in:"query" d:"1" dc:"页码"`
PageSize int `json:"page_size" in:"query" d:"20" dc:"每页数量"`
Username string `json:"username" in:"query" dc:"用户名(模糊搜索,仅owner/admin有效)"`
IpAddress string `json:"ip_address" in:"query" dc:"IP地址(模糊搜索)"`
Success *bool `json:"success" in:"query" dc:"登录状态:true成功/false失败"`
LoginMethod string `json:"login_method" in:"query" dc:"登录方式:password/totp/sso"`
StartTime string `json:"start_time" in:"query" dc:"开始时间(格式:2006-01-02)"`
EndTime string `json:"end_time" in:"query" dc:"结束时间(格式:2006-01-02)"`
}
type TenantLoginHistoryRes struct {
@@ -106,6 +112,9 @@ type TenantLoginHistoryRes struct {
type TenantLoginHistoryItem struct {
ID int64 `json:"id"`
UserId int64 `json:"user_id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
LoginMethod string `json:"login_method"`
IpAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"`
@@ -0,0 +1,12 @@
package admin
import (
"context"
v1 "github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/service"
)
func (c *ControllerV1) AdminTenantLoginHistory(ctx context.Context, req *v1.AdminTenantLoginHistoryReq) (res *v1.AdminTenantLoginHistoryRes, err error) {
return service.Admin().TenantLoginHistory(ctx, req)
}
+124
View File
@@ -314,6 +314,130 @@ func buildAdminUserMap(ctx context.Context, records []entity.AudLoginHistory) ma
return m
}
// TenantLoginHistory returns login history for tenant users (admin view).
func (s *sAdmin) TenantLoginHistory(ctx context.Context, req *v1.AdminTenantLoginHistoryReq) (*v1.AdminTenantLoginHistoryRes, error) {
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
q := dao.AudLoginHistory.Ctx(ctx).Where("user_type", "tenant")
if req.TenantID > 0 {
q = q.Where("tenant_id", req.TenantID)
}
if req.Username != "" {
userIds, err := tenantUserIdsByKeyword(ctx, req.TenantID, req.Username)
if err != nil {
return nil, err
}
if len(userIds) == 0 {
return &v1.AdminTenantLoginHistoryRes{
List: []v1.AdminTenantLoginHistoryItem{}, Total: 0, Page: page, PageSize: pageSize,
}, nil
}
q = q.WhereIn("user_id", userIds)
}
if req.IpAddress != "" {
q = q.WhereLike("ip_address", "%"+req.IpAddress+"%")
}
if req.Success != nil {
q = q.Where("success", *req.Success)
}
if req.LoginMethod != "" {
q = q.Where("login_method", req.LoginMethod)
}
if req.StartTime != "" {
q = q.WhereGTE("created_at", req.StartTime+" 00:00:00")
}
if req.EndTime != "" {
q = q.WhereLTE("created_at", req.EndTime+" 23:59:59")
}
total, err := q.Count()
if err != nil {
return nil, err
}
var records []entity.AudLoginHistory
err = q.OrderDesc("created_at").Page(page, pageSize).Scan(&records)
if err != nil {
return nil, err
}
userMap := buildTenantUserMap(ctx, records)
items := make([]v1.AdminTenantLoginHistoryItem, len(records))
for i, r := range records {
items[i] = v1.AdminTenantLoginHistoryItem{
ID: r.Id,
UserId: r.UserId,
TenantId: r.TenantId,
Username: userMap[r.UserId].Username,
DisplayName: userMap[r.UserId].DisplayName,
LoginMethod: r.LoginMethod,
IpAddress: r.IpAddress,
UserAgent: r.UserAgent,
Location: r.Location,
IsNewDevice: r.IsNewDevice,
Success: r.Success,
FailReason: r.FailReason,
}
if r.CreatedAt != nil {
items[i].CreatedAt = r.CreatedAt.Format("Y-m-d H:i:s")
}
}
return &v1.AdminTenantLoginHistoryRes{
List: items, Total: total, Page: page, PageSize: pageSize,
}, nil
}
type tenantUserBrief struct {
Username string
DisplayName string
}
func tenantUserIdsByKeyword(ctx context.Context, tenantID int64, keyword string) ([]int64, error) {
q := dao.TntUsers.Ctx(ctx).Fields("id")
q = q.Where("username LIKE ? OR display_name LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
if tenantID > 0 {
q = q.Where("tenant_id", tenantID)
}
var users []entity.TntUsers
err := q.Scan(&users)
if err != nil {
return nil, err
}
ids := make([]int64, len(users))
for i, u := range users {
ids[i] = u.Id
}
return ids, nil
}
func buildTenantUserMap(ctx context.Context, records []entity.AudLoginHistory) map[int64]tenantUserBrief {
m := make(map[int64]tenantUserBrief)
idSet := make(map[int64]struct{})
for _, r := range records {
if r.UserId > 0 {
idSet[r.UserId] = struct{}{}
}
}
if len(idSet) == 0 {
return m
}
ids := make([]int64, 0, len(idSet))
for id := range idSet {
ids = append(ids, id)
}
var users []entity.TntUsers
_ = dao.TntUsers.Ctx(ctx).
Fields("id, username, display_name").
WhereIn("id", ids).
Scan(&users)
for _, u := range users {
m[u.Id] = tenantUserBrief{Username: u.Username, DisplayName: u.DisplayName}
}
return m
}
// Pending TOTP secret is stored in Redis temporarily during setup flow.
func getPendingTOTPSecret(ctx context.Context, userID int64) (string, error) {
key := fmt.Sprintf("2fa:pending:admin:%d", userID)
+19 -6
View File
@@ -183,6 +183,11 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant
return nil, err
}
// Get client info early for login history recording
ipAddress := g.RequestFromCtx(ctx).GetClientIp()
ua := g.RequestFromCtx(ctx).Header.Get("User-Agent")
deviceFP := common.DeviceFingerprint(ua, ipAddress)
var tenant entity.TntTenants
var user entity.TntUsers
@@ -197,6 +202,7 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant
return nil, err
}
if user.Id == 0 {
_ = common.RecordLoginHistory(ctx, "tenant", 0, 0, "password", ipAddress, ua, deviceFP, false, "用户不存在")
return nil, common.NewBusinessError(consts.CodeInvalidCredentials, consts.MsgInvalidCredentials)
}
@@ -207,12 +213,14 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant
return nil, err
}
if tenant.Id == 0 {
_ = common.RecordLoginHistory(ctx, "tenant", user.Id, user.TenantId, "password", ipAddress, ua, deviceFP, false, "租户不存在")
return nil, common.NewBusinessError(consts.CodeInvalidCredentials, consts.MsgInvalidCredentials)
}
} else {
// RAM login: account is username@tenant_code
parts := strings.SplitN(account, "@", 2)
if len(parts) != 2 {
_ = common.RecordLoginHistory(ctx, "tenant", 0, 0, "password", ipAddress, ua, deviceFP, false, "账号格式错误")
return nil, common.NewBadRequestError("账号格式错误,应为 用户名@组织代码")
}
username := parts[0]
@@ -225,6 +233,7 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant
return nil, err
}
if tenant.Id == 0 {
_ = common.RecordLoginHistory(ctx, "tenant", 0, 0, "password", ipAddress, ua, deviceFP, false, "租户不存在")
return nil, common.NewBusinessError(consts.CodeInvalidCredentials, consts.MsgInvalidCredentials)
}
@@ -237,34 +246,40 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant
return nil, err
}
if user.Id == 0 {
_ = common.RecordLoginHistory(ctx, "tenant", 0, tenant.Id, "password", ipAddress, ua, deviceFP, false, "用户不存在")
return nil, common.NewBusinessError(consts.CodeInvalidCredentials, consts.MsgInvalidCredentials)
}
}
if tenant.Status != "active" {
_ = common.RecordLoginHistory(ctx, "tenant", user.Id, tenant.Id, "password", ipAddress, ua, deviceFP, false, "租户已停用")
return nil, common.NewBusinessError(consts.CodeTenantSuspended, consts.MsgTenantSuspended)
}
// Check account lockout
if user.LockedUntil != nil && time.Now().Before(user.LockedUntil.Time) {
remaining := time.Until(user.LockedUntil.Time).Minutes()
_ = common.RecordLoginHistory(ctx, "tenant", user.Id, tenant.Id, "password", ipAddress, ua, deviceFP, false, "账号已锁定")
return nil, common.NewBusinessError(consts.CodeAccountLocked,
fmt.Sprintf("账号已被锁定,%d 分钟后重试", int(remaining)))
}
// Check status
if user.Status != "active" {
_ = common.RecordLoginHistory(ctx, "tenant", user.Id, tenant.Id, "password", ipAddress, ua, deviceFP, false, "账号已禁用")
return nil, common.NewBusinessError(consts.CodeInvalidCredentials, consts.MsgInvalidCredentials)
}
// Check IP whitelist
ipAddr := g.RequestFromCtx(ctx).GetClientIp()
if !CheckIPWhitelist(ctx, tenant.Id, ipAddr) {
if !CheckIPWhitelist(ctx, tenant.Id, ipAddress) {
_ = common.RecordLoginHistory(ctx, "tenant", user.Id, tenant.Id, "password", ipAddress, ua, deviceFP, false, "IP不在白名单")
return nil, common.NewBusinessError(consts.CodeIpRestricted, consts.MsgIpRestricted)
}
// Verify password
if !crypto.VerifyPassword(req.Password, user.PasswordHash) {
_ = common.RecordLoginHistory(ctx, "tenant", user.Id, tenant.Id, "password", ipAddress, ua, deviceFP, false, "密码错误")
// Increment failed attempts
failedAttempts := user.FailedAttempts + 1
updateData := do.TntUsers{FailedAttempts: failedAttempts}
@@ -295,10 +310,6 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant
LockedUntil: nil,
}).Update()
}
// Get client info
ipAddress := g.RequestFromCtx(ctx).GetClientIp()
ua := g.RequestFromCtx(ctx).Header.Get("User-Agent")
deviceFP := common.DeviceFingerprint(ua, ipAddress)
// Check if 2FA is enabled → return provisional token
if user.TotpEnabled {
@@ -349,6 +360,8 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant
LastLoginIp: ipAddress,
}).Update()
_ = common.RecordLoginHistory(ctx, "tenant", user.Id, tenant.Id, "password", ipAddress, ua, deviceFP, true, "")
res := &v1.TenantLoginRes{
AccessToken: tokenPair.AccessToken,
RefreshToken: refreshToken,
+96 -18
View File
@@ -190,41 +190,71 @@ func (s *sTenant) ConfirmHighRisk(ctx context.Context, req *v1.Tenant2FAConfirmR
return &v1.Tenant2FAConfirmRes{ConfirmToken: token}, nil
}
// LoginHistory returns the login history for the current tenant user.
// LoginHistory returns the login history for tenant users.
// owner/admin see all members in the tenant; member sees only own records.
func (s *sTenant) LoginHistory(ctx context.Context, req *v1.TenantLoginHistoryReq) (*v1.TenantLoginHistoryRes, error) {
userID := ctxUserID(ctx)
page := req.Page
pageSize := req.PageSize
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
tenantID := ctxTenantID(ctx)
role := ctxUserRole(ctx)
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
q := dao.AudLoginHistory.Ctx(ctx).
Where("user_type", "tenant").
Where("tenant_id", tenantID)
// Role-based access: member only sees own records
if role == "member" {
q = q.Where("user_id", userID)
} else if req.Username != "" {
// owner/admin can filter by username
userIds, err := tenantUserIdsByUsernameLocal(ctx, tenantID, req.Username)
if err != nil {
return nil, err
}
if len(userIds) == 0 {
return &v1.TenantLoginHistoryRes{
List: []v1.TenantLoginHistoryItem{}, Total: 0, Page: page, PageSize: pageSize,
}, nil
}
q = q.WhereIn("user_id", userIds)
}
total, err := dao.AudLoginHistory.Ctx(ctx).
Where("user_type", "tenant").
Where("user_id", userID).
Count()
if req.IpAddress != "" {
q = q.WhereLike("ip_address", "%"+req.IpAddress+"%")
}
if req.Success != nil {
q = q.Where("success", *req.Success)
}
if req.LoginMethod != "" {
q = q.Where("login_method", req.LoginMethod)
}
if req.StartTime != "" {
q = q.WhereGTE("created_at", req.StartTime+" 00:00:00")
}
if req.EndTime != "" {
q = q.WhereLTE("created_at", req.EndTime+" 23:59:59")
}
total, err := q.Count()
if err != nil {
return nil, err
}
var records []entity.AudLoginHistory
err = dao.AudLoginHistory.Ctx(ctx).
Where("user_type", "tenant").
Where("user_id", userID).
OrderDesc("created_at").
Page(page, pageSize).
Scan(&records)
err = q.OrderDesc("created_at").Page(page, pageSize).Scan(&records)
if err != nil {
return nil, err
}
userMap := buildTenantUserMapLocal(ctx, records)
items := make([]v1.TenantLoginHistoryItem, len(records))
for i, r := range records {
items[i] = v1.TenantLoginHistoryItem{
ID: r.Id,
UserId: r.UserId,
Username: userMap[r.UserId].Username,
DisplayName: userMap[r.UserId].DisplayName,
LoginMethod: r.LoginMethod,
IpAddress: r.IpAddress,
UserAgent: r.UserAgent,
@@ -246,6 +276,54 @@ func (s *sTenant) LoginHistory(ctx context.Context, req *v1.TenantLoginHistoryRe
}, nil
}
type tenantUserBriefLocal struct {
Username string
DisplayName string
}
func tenantUserIdsByUsernameLocal(ctx context.Context, tenantID int64, keyword string) ([]int64, error) {
var users []entity.TntUsers
err := dao.TntUsers.Ctx(ctx).
Fields("id").
Where("tenant_id", tenantID).
Where("username LIKE ? OR display_name LIKE ?", "%"+keyword+"%", "%"+keyword+"%").
Scan(&users)
if err != nil {
return nil, err
}
ids := make([]int64, len(users))
for i, u := range users {
ids[i] = u.Id
}
return ids, nil
}
func buildTenantUserMapLocal(ctx context.Context, records []entity.AudLoginHistory) map[int64]tenantUserBriefLocal {
m := make(map[int64]tenantUserBriefLocal)
idSet := make(map[int64]struct{})
for _, r := range records {
if r.UserId > 0 {
idSet[r.UserId] = struct{}{}
}
}
if len(idSet) == 0 {
return m
}
ids := make([]int64, 0, len(idSet))
for id := range idSet {
ids = append(ids, id)
}
var users []entity.TntUsers
_ = dao.TntUsers.Ctx(ctx).
Fields("id, username, display_name").
WhereIn("id", ids).
Scan(&users)
for _, u := range users {
m[u.Id] = tenantUserBriefLocal{Username: u.Username, DisplayName: u.DisplayName}
}
return m
}
func getPendingTOTPSecret(ctx context.Context, userID int64) (string, error) {
key := fmt.Sprintf("2fa:pending:tenant:%d", userID)
val, err := g.Redis().Do(ctx, "GET", key)
+2
View File
@@ -329,6 +329,8 @@ type (
ConfirmHighRisk(ctx context.Context, req *v1.Admin2FAConfirmReq) (*v1.Admin2FAConfirmRes, error)
// LoginHistory returns the login history for all admin users with search filters.
LoginHistory(ctx context.Context, req *v1.AdminLoginHistoryReq) (*v1.AdminLoginHistoryRes, error)
// TenantLoginHistory returns login history for tenant users (admin view).
TenantLoginHistory(ctx context.Context, req *v1.AdminTenantLoginHistoryReq) (*v1.AdminTenantLoginHistoryRes, error)
// GetSettingsCategories returns all available setting categories.
GetSettingsCategories(ctx context.Context, _ *v1.AdminSettingsCategoriesReq) (*v1.AdminSettingsCategoriesRes, error)
// GetSettings retrieves settings with schema for a given category.
+2 -1
View File
@@ -277,7 +277,8 @@ type (
RegenerateBackupCodes(ctx context.Context, req *v1.Tenant2FARegenerateBackupCodesReq) (*v1.Tenant2FARegenerateBackupCodesRes, error)
// ConfirmHighRisk generates a confirm token for high-risk operations.
ConfirmHighRisk(ctx context.Context, req *v1.Tenant2FAConfirmReq) (*v1.Tenant2FAConfirmRes, error)
// LoginHistory returns the login history for the current tenant user.
// LoginHistory returns the login history for tenant users.
// owner/admin see all members in the tenant; member sees only own records.
LoginHistory(ctx context.Context, req *v1.TenantLoginHistoryReq) (*v1.TenantLoginHistoryRes, error)
// GetIPWhitelist returns the tenant's IP whitelist configuration.
GetIPWhitelist(ctx context.Context, _ *v1.TenantIPWhitelistGetReq) (*v1.TenantIPWhitelistGetRes, error)
+6
View File
@@ -120,6 +120,12 @@ const adminRoutes: RouteRecordRaw[] = [
component: () => import('@/views/security/LoginHistoryPage.vue'),
meta: { title: '登录历史' },
},
{
path: 'tenant-login-history',
name: 'AdminTenantLoginHistory',
component: () => import('@/views/security/TenantLoginHistoryPage.vue'),
meta: { title: '租户登录历史' },
},
{
path: 'task-logs',
name: 'AdminTaskLogs',
+1
View File
@@ -186,6 +186,7 @@ const menuGroups = [
icon: IconSafe,
items: [
{ name: 'AdminLoginHistory', label: '登录历史', icon: IconClockCircle },
{ name: 'AdminTenantLoginHistory', label: '租户登录历史', icon: IconClockCircle },
{ name: 'AdminSessions', label: '会话管理', icon: IconClockCircle },
{ name: 'AdminPermissions', label: '权限管理', icon: IconSafe },
{ name: 'AdminAudit', label: '操作日志', icon: IconFile },
@@ -0,0 +1,207 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import PageHeader from '@/components/PageHeader.vue'
import request from '@/utils/request'
const list = ref<any[]>([])
const loading = ref(false)
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
showTotal: true,
})
const searchForm = reactive({
tenant_id: undefined as number | undefined,
username: '',
ip_address: '',
success: undefined as boolean | undefined,
login_method: '',
start_time: '',
end_time: '',
})
const successOptions = [
{ label: '全部', value: undefined },
{ label: '成功', value: true },
{ label: '失败', value: false },
]
const methodOptions = [
{ label: '全部', value: '' },
{ label: '密码', value: 'password' },
{ label: '双因素', value: 'totp' },
{ label: '单点登录', value: 'sso' },
{ label: '恢复码', value: 'backup_code' },
]
onMounted(() => {
fetchData()
})
async function fetchData() {
loading.value = true
try {
const params: Record<string, any> = {
page: pagination.current,
page_size: pagination.pageSize,
}
if (searchForm.tenant_id) params.tenant_id = searchForm.tenant_id
if (searchForm.username) params.username = searchForm.username
if (searchForm.ip_address) params.ip_address = searchForm.ip_address
if (searchForm.success !== undefined) params.success = searchForm.success
if (searchForm.login_method) params.login_method = searchForm.login_method
if (searchForm.start_time) params.start_time = searchForm.start_time
if (searchForm.end_time) params.end_time = searchForm.end_time
const res = await request.get('/admin/security/tenant-login-history', { params })
if (res.data?.code === 0) {
list.value = res.data.data.list || []
pagination.total = res.data.data.total || 0
}
} finally {
loading.value = false
}
}
function onPageChange(page: number) {
pagination.current = page
fetchData()
}
function onSearch() {
pagination.current = 1
fetchData()
}
function onReset() {
searchForm.tenant_id = undefined
searchForm.username = ''
searchForm.ip_address = ''
searchForm.success = undefined
searchForm.login_method = ''
searchForm.start_time = ''
searchForm.end_time = ''
pagination.current = 1
fetchData()
}
function methodLabel(method: string) {
const map: Record<string, string> = {
password: '密码',
totp: '双因素',
sso: '单点登录',
backup_code: '恢复码',
}
return map[method] || method
}
function methodColor(method: string) {
const map: Record<string, string> = {
password: 'blue',
totp: 'green',
sso: 'purple',
backup_code: 'orange',
}
return map[method] || 'gray'
}
function onDateChange(dateString: string | undefined, type: 'start' | 'end') {
if (type === 'start') {
searchForm.start_time = dateString || ''
} else {
searchForm.end_time = dateString || ''
}
}
</script>
<template>
<div class="page-table">
<PageHeader title="租户登录历史" description="查看所有租户用户的登录记录" />
<ACard :bordered="false" style="margin-bottom: 16px">
<AForm :model="searchForm" layout="inline" @submit="onSearch">
<AFormItem label="租户ID">
<AInputNumber v-model="searchForm.tenant_id" placeholder="租户ID" allow-clear style="width: 120px" />
</AFormItem>
<AFormItem label="用户名">
<AInput v-model="searchForm.username" placeholder="搜索用户名" allow-clear style="width: 140px" />
</AFormItem>
<AFormItem label="IP 地址">
<AInput v-model="searchForm.ip_address" placeholder="搜索 IP" allow-clear style="width: 140px" />
</AFormItem>
<AFormItem label="状态">
<ASelect v-model="searchForm.success" :options="successOptions" placeholder="全部" allow-clear style="width: 100px" />
</AFormItem>
<AFormItem label="登录方式">
<ASelect v-model="searchForm.login_method" :options="methodOptions" placeholder="全部" allow-clear style="width: 120px" />
</AFormItem>
<AFormItem label="开始日期">
<ADatePicker v-model="searchForm.start_time" style="width: 150px" @change="(v: any) => onDateChange(v, 'start')" />
</AFormItem>
<AFormItem label="结束日期">
<ADatePicker v-model="searchForm.end_time" style="width: 150px" @change="(v: any) => onDateChange(v, 'end')" />
</AFormItem>
<AFormItem>
<ASpace>
<AButton type="primary" html-type="submit">搜索</AButton>
<AButton @click="onReset">重置</AButton>
</ASpace>
</AFormItem>
</AForm>
</ACard>
<ACard :bordered="false">
<ATable
:data="list"
:loading="loading"
:bordered="false"
:stripe="true"
row-key="id"
:pagination="{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
showTotal: true,
onChange: onPageChange,
}"
>
<template #columns>
<ATableColumn title="时间" data-index="created_at" :width="180" />
<ATableColumn title="租户ID" data-index="tenant_id" :width="90" />
<ATableColumn title="用户" :width="160">
<template #cell="{ record }">
<span>{{ record.display_name || record.username }}</span>
<span v-if="record.display_name" style="color: var(--ta-text-tertiary); margin-left: 4px">({{ record.username }})</span>
</template>
</ATableColumn>
<ATableColumn title="登录方式" data-index="login_method" :width="110">
<template #cell="{ record }">
<ATag :color="methodColor(record.login_method)" size="small">{{ methodLabel(record.login_method) }}</ATag>
</template>
</ATableColumn>
<ATableColumn title="IP 地址" data-index="ip_address" :width="150" />
<ATableColumn title="设备" data-index="user_agent" :width="200" ellipsis />
<ATableColumn title="状态" :width="80">
<template #cell="{ record }">
<ATag :color="record.success ? 'green' : 'red'" size="small">{{ record.success ? '成功' : '失败' }}</ATag>
</template>
</ATableColumn>
<ATableColumn title="新设备" :width="80">
<template #cell="{ record }">
<ATag v-if="record.is_new_device" color="orangered" size="small">新设备</ATag>
<span v-else>-</span>
</template>
</ATableColumn>
<ATableColumn title="失败原因" data-index="fail_reason" :width="180" ellipsis>
<template #cell="{ record }">
<span v-if="record.fail_reason">{{ record.fail_reason }}</span>
<span v-else>-</span>
</template>
</ATableColumn>
</template>
</ATable>
</ACard>
</div>
</template>
+6
View File
@@ -195,6 +195,12 @@ const tenantRoutes: RouteRecordRaw[] = [
component: () => import('@/views/settings/AuditConfigPage.vue'),
meta: { title: '审计设置', roles: ['owner', 'admin'] },
},
{
path: 'login-history',
name: 'TenantLoginHistory',
component: () => import('@/views/settings/LoginHistoryPage.vue'),
meta: { title: '登录历史', roles: ['owner', 'admin', 'member'] },
},
{
path: 'profile',
name: 'TenantProfile',
@@ -0,0 +1,223 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import Icon from '@/components/common/Icon.vue'
import request from '@/utils/request'
const list = ref<any[]>([])
const loading = ref(false)
const page = ref(1)
const pageSize = 20
const total = ref(0)
const filterUsername = ref('')
const filterIpAddress = ref('')
const filterSuccess = ref('')
const filterMethod = ref('')
const filterStartDate = ref('')
const filterEndDate = ref('')
onMounted(() => {
fetchData()
})
async function fetchData() {
loading.value = true
try {
const params: Record<string, any> = {
page: page.value,
page_size: pageSize,
}
if (filterUsername.value) params.username = filterUsername.value
if (filterIpAddress.value) params.ip_address = filterIpAddress.value
if (filterSuccess.value !== '') params.success = filterSuccess.value === 'true'
if (filterMethod.value) params.login_method = filterMethod.value
if (filterStartDate.value) params.start_time = filterStartDate.value
if (filterEndDate.value) params.end_time = filterEndDate.value
const res = await request.get('/tenant/security/login-history', { params })
const raw = res.data?.data
list.value = raw?.list || []
total.value = raw?.total || 0
} catch {
list.value = []
} finally {
loading.value = false
}
}
function applyFilters() {
page.value = 1
fetchData()
}
function resetFilters() {
filterUsername.value = ''
filterIpAddress.value = ''
filterSuccess.value = ''
filterMethod.value = ''
filterStartDate.value = ''
filterEndDate.value = ''
page.value = 1
fetchData()
}
const methodBadge: Record<string, string> = {
password: 'badge-primary',
totp: 'badge-success',
sso: 'badge-purple',
}
const methodLabel: Record<string, string> = {
password: '密码',
totp: '双因素',
sso: '单点登录',
}
</script>
<template>
<div>
<div class="page-header">
<h1 class="page-title">登录历史</h1>
<p class="page-description">查看组织成员的登录记录,包括成功和失败的登录尝试</p>
</div>
<!-- Filters -->
<div class="card mb-6">
<div class="card-body">
<div class="flex flex-wrap items-end justify-between gap-4">
<div class="flex flex-1 flex-wrap items-end gap-4">
<div class="w-full sm:w-auto sm:min-w-[120px]">
<label class="input-label">用户名</label>
<input
v-model="filterUsername"
type="text"
placeholder="搜索用户名"
class="input"
@keyup.enter="applyFilters"
/>
</div>
<div class="w-full sm:w-auto sm:min-w-[140px]">
<label class="input-label">IP 地址</label>
<input
v-model="filterIpAddress"
type="text"
placeholder="搜索 IP"
class="input"
@keyup.enter="applyFilters"
/>
</div>
<div class="w-full sm:w-auto sm:min-w-[100px]">
<label class="input-label">状态</label>
<select v-model="filterSuccess" class="input bg-white">
<option value="">全部</option>
<option value="true">成功</option>
<option value="false">失败</option>
</select>
</div>
<div class="w-full sm:w-auto sm:min-w-[100px]">
<label class="input-label">登录方式</label>
<select v-model="filterMethod" class="input bg-white">
<option value="">全部</option>
<option value="password">密码</option>
<option value="totp">双因素</option>
<option value="sso">单点登录</option>
</select>
</div>
<div class="w-full sm:w-auto sm:min-w-[140px]">
<label class="input-label">开始日期</label>
<input v-model="filterStartDate" type="date" class="input" />
</div>
<div class="w-full sm:w-auto sm:min-w-[140px]">
<label class="input-label">结束日期</label>
<input v-model="filterEndDate" type="date" class="input" />
</div>
</div>
<div class="flex items-center gap-2">
<button class="btn btn-secondary" @click="applyFilters">
<Icon name="search" size="sm" />
搜索
</button>
<button class="btn btn-ghost" @click="resetFilters">重置</button>
</div>
</div>
</div>
</div>
<!-- Table -->
<div class="card">
<div v-if="loading" class="p-8 flex justify-center">
<div class="spinner h-6 w-6 border-primary-500"></div>
</div>
<div v-else-if="list.length > 0">
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>时间</th>
<th>用户</th>
<th>登录方式</th>
<th>IP 地址</th>
<th>设备</th>
<th>状态</th>
<th>失败原因</th>
</tr>
</thead>
<tbody>
<tr v-for="item in list" :key="item.id">
<td class="whitespace-nowrap">{{ item.created_at }}</td>
<td>
<div>{{ item.display_name || item.username || '-' }}</div>
<div v-if="item.display_name && item.username" class="text-xs text-gray-400">{{ item.username }}</div>
</td>
<td>
<span class="badge" :class="methodBadge[item.login_method] || 'badge-gray'">
{{ methodLabel[item.login_method] || item.login_method }}
</span>
</td>
<td class="font-mono text-sm">{{ item.ip_address }}</td>
<td class="max-w-[200px] truncate text-gray-500" :title="item.user_agent">{{ item.user_agent }}</td>
<td>
<span v-if="item.success" class="badge badge-success">成功</span>
<span v-else class="badge badge-danger">失败</span>
</td>
<td class="text-gray-500">{{ item.fail_reason || '-' }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div v-if="total > pageSize" class="card-footer flex justify-between items-center">
<span class="text-sm text-gray-500">共 {{ total }} 条记录</span>
<div class="flex items-center gap-2">
<button
class="btn btn-ghost btn-sm"
:disabled="page <= 1"
@click="page--; fetchData()"
>
上一页
</button>
<span class="text-sm text-gray-500">
{{ page }} / {{ Math.ceil(total / pageSize) }}
</span>
<button
class="btn btn-ghost btn-sm"
:disabled="page * pageSize >= total"
@click="page++; fetchData()"
>
下一页
</button>
</div>
</div>
</div>
<!-- Empty state -->
<div v-else class="empty-state">
<Icon name="shield" size="xl" class="empty-state-icon" />
<p class="empty-state-title">暂无登录记录</p>
<p class="empty-state-description">当前没有任何登录历史记录</p>
</div>
</div>
</div>
</template>
@@ -334,6 +334,22 @@ onMounted(() => {
暂无会话
</div>
</div>
<!-- Login History Link Card -->
<router-link to="/tenant/login-history" class="card card-hover block">
<div class="card-body flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="h-10 w-10 rounded-xl bg-primary-100 flex items-center justify-center">
<Icon name="shield" size="md" class="text-primary-500" />
</div>
<div>
<p class="text-sm font-medium text-gray-900">登录历史</p>
<p class="text-sm text-gray-500">查看登录记录,包括成功和失败的登录尝试</p>
</div>
</div>
<Icon name="chevronRight" size="md" class="text-gray-400" />
</div>
</router-link>
</div>
</div>
</template>