feat(M5): 前端完善 + 部署 — Vue3 全量页面、仪表盘统计与我的审计接口、systemd/sudoers/迁移脚本、部署文档

This commit is contained in:
2026-08-30 10:50:46 +08:00
parent c0e2ff975a
commit b305d7b371
67 changed files with 3209 additions and 171 deletions
+17
View File
@@ -83,3 +83,20 @@ func (h *AuditHandler) Export(c *gin.Context) {
// 失败由审计归档兜底),正常路径写入 CSV。
_ = h.svc.ExportCSV(c.Request.Context(), c.Writer, since, until)
}
// MyAudit GET /me/audit —— 外部用户查看与自己相关的审计痕迹(PLAN §8 个人中心)。
func (h *AuditHandler) MyAudit(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
sess := sessionFrom(c)
if sess == nil {
fail(c, http.StatusUnauthorized, "未登录")
return
}
rows, total, err := h.svc.QueryByUser(c.Request.Context(), sess.RefID, page, pageSize)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
ok(c, gin.H{"total": total, "items": rows})
}
+3 -1
View File
@@ -27,6 +27,7 @@ type Handler struct {
Approval *ApprovalHandler
Audit *AuditHandler
Settings *SettingsHandler
Stats *StatsHandler
authSvc *service.AuthService
auditSvc *service.AuditService
@@ -45,6 +46,7 @@ func New(cfg *config.Config, authSvc *service.AuthService, userSvc *service.User
h.Approval = &ApprovalHandler{svc: approvalSvc, cfg: cfg, h: h}
h.Audit = NewAuditHandler(auditSvc, cfg)
h.Settings = NewSettingsHandler(settingsSvc)
h.Stats = NewStatsHandler(userSvc)
return h
}
@@ -71,7 +73,7 @@ type HealthHandler struct {
func (h *HealthHandler) Healthz(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"version": "0.3.0-m4",
"version": "0.3.0-m5",
"uptime": time.Since(h.startedAt).String(),
"go": runtime.Version(),
"timestamp": time.Now().UTC().Format(time.RFC3339),
+108
View File
@@ -0,0 +1,108 @@
package api_test
import (
"context"
"net/http"
"testing"
)
// TestAPIStats 仪表盘统计:admin 登录后 /admin/stats 返回各状态数与待审批数。
func TestAPIStats(t *testing.T) {
app := setupTestApp(t)
w := app.doJSON(http.MethodGet, "/api/v1/admin/stats", nil)
if w.Code != http.StatusUnauthorized {
t.Fatalf("unauth stats status = %d, want 401", w.Code)
}
w = app.doJSON(http.MethodPost, "/api/v1/auth/admin/login", map[string]string{"username": "root", "password": "Passw0rd"})
ck := sessionCookie(t, w)
// 建 1 个用户 + 1 个待审批申请,stats 应反映
w = app.doJSON(http.MethodPost, "/api/v1/users", map[string]any{
"username": "m5stats", "email": "m5stats@example.com",
"supervisor": "profA", "purpose": "test",
}, ck)
if w.Code != http.StatusOK {
t.Fatalf("create user status = %d, body=%s", w.Code, w.Body.String())
}
w = app.doJSON(http.MethodPost, "/api/v1/approvals", map[string]any{
"username": "m5apply", "email": "m5apply@example.com",
"supervisor": "profB", "purpose": "apply test",
})
if w.Code != http.StatusOK {
t.Fatalf("submit approval status = %d, body=%s", w.Code, w.Body.String())
}
w = app.doJSON(http.MethodGet, "/api/v1/admin/stats", nil, ck)
if w.Code != http.StatusOK {
t.Fatalf("stats status = %d, body=%s", w.Code, w.Body.String())
}
m := decodeBody(t, w)
data := m["data"].(map[string]any)
if data["total"].(float64) != 1 {
t.Fatalf("stats total = %v, want 1", data["total"])
}
if data["pending_approvals"].(float64) != 1 {
t.Fatalf("pending_approvals = %v, want 1", data["pending_approvals"])
}
if _, ok := data["expiring_soon"]; !ok {
t.Fatalf("stats missing expiring_soon: %v", data)
}
}
// TestAPIMeAudit 外部用户"我的审计":登录后可查自己的审计痕迹,管理端接口拒绝 user 会话。
func TestAPIMeAudit(t *testing.T) {
app := setupTestApp(t)
// 建用户 + 通过 OTP 登录(复用 m3 流程)
adminW := app.doJSON(http.MethodPost, "/api/v1/auth/admin/login", map[string]string{"username": "root", "password": "Passw0rd"})
ack := sessionCookie(t, adminW)
adminW = app.doJSON(http.MethodPost, "/api/v1/users", map[string]any{
"username": "meaudit", "email": "meaudit@example.com",
}, ack)
if adminW.Code != http.StatusOK {
t.Fatalf("create user status = %d, body=%s", adminW.Code, adminW.Body.String())
}
// OTP 双通道:经 store 取验证码(模拟用户看到图形验证码)+ 登录
// OTP 双通道:经 store 取验证码(模拟用户看到图形验证码)+ 登录
cap, err := app.captchas.New()
if err != nil {
t.Fatalf("captcha new: %v", err)
}
sendW := app.doJSON(http.MethodPost, "/api/v1/auth/otp/send", map[string]string{
"username": "meaudit", "captcha_id": cap.ID, "captcha_code": cap.Text,
})
if sendW.Code != http.StatusOK {
t.Fatalf("otp send status = %d, body=%s", sendW.Code, sendW.Body.String())
}
code, err := app.otps.Current(context.Background(), "ext_meaudit")
if err != nil {
t.Fatalf("get otp: %v", err)
}
loginW := app.doJSON(http.MethodPost, "/api/v1/auth/otp/login", map[string]string{"username": "meaudit", "code": code})
if loginW.Code != http.StatusOK {
t.Fatalf("otp login status = %d, body=%s", loginW.Code, loginW.Body.String())
}
uck := sessionCookie(t, loginW)
// 用户访问管理端审计 → 403
w := app.doJSON(http.MethodGet, "/api/v1/audit", nil, uck)
if w.Code != http.StatusForbidden {
t.Fatalf("user access admin audit status = %d, want 403", w.Code)
}
// 我的审计:登录成功等操作应可见
w = app.doJSON(http.MethodGet, "/api/v1/me/audit", nil, uck)
if w.Code != http.StatusOK {
t.Fatalf("me/audit status = %d, body=%s", w.Code, w.Body.String())
}
m := decodeBody(t, w)
data := m["data"].(map[string]any)
if data["total"].(float64) < 1 {
t.Fatalf("me/audit total = %v, want >= 1", data["total"])
}
}
+30
View File
@@ -0,0 +1,30 @@
// 仪表盘统计接口(admin):GET /admin/statsPLAN §8 仪表盘数据)。
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"ws_usernode/internal/service"
)
// StatsHandler 仪表盘统计。
type StatsHandler struct {
userSvc *service.UserService
}
// NewStatsHandler 创建统计 handler。
func NewStatsHandler(userSvc *service.UserService) *StatsHandler {
return &StatsHandler{userSvc: userSvc}
}
// Stats GET /admin/stats —— 用户状态分布、近期待过期、待审批数。
func (h *StatsHandler) Stats(c *gin.Context) {
st, err := h.userSvc.Stats(c.Request.Context())
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
ok(c, st)
}
+7
View File
@@ -63,6 +63,7 @@ func New(cfg *config.Config, h *api.Handler, sessions auth.SessionStore, log *sl
me.POST("/keys", h.Key.Create)
me.PATCH("/keys/:id", h.Key.Rename)
me.DELETE("/keys/:id", h.Key.Revoke)
me.GET("/audit", h.Audit.MyAudit) // 我的审计痕迹(个人中心)
}
// 申请审批(M3):提交公开;列表与审批需管理员会话
@@ -86,6 +87,12 @@ func New(cfg *config.Config, h *api.Handler, sessions auth.SessionStore, log *sl
settings.GET("", h.Settings.List)
settings.PUT("", h.Settings.Update)
}
// 仪表盘统计(admin):用户状态分布 / 近期待过期 / 待审批数
adminGrp := v1.Group("/admin", sessionMiddleware(sessions), requireUserType(auth.SessionUserAdmin))
{
adminGrp.GET("/stats", h.Stats.Stats)
}
}
// 前端静态资源(go:embeddev 阶段由 Vite dev server 代理,见 Makefile dev
+25
View File
@@ -101,6 +101,31 @@ func (s *AuditService) Query(ctx context.Context, f AuditFilter) ([]model.AuditL
return rows, total, nil
}
// QueryByUser 查询与指定用户相关的审计记录(个人中心"我的审计痕迹"PLAN §8)。
// 匹配该用户作为操作者(actor_id 对用户会话)或作为操作对象(resource_type=user 且 resource_id 为数字 ID)。
func (s *AuditService) QueryByUser(ctx context.Context, userID uint, page, pageSize int) ([]model.AuditLog, int64, error) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 200 {
pageSize = 200
}
q := s.db.WithContext(ctx).Model(&model.AuditLog{}).
Where("actor_id = ? OR (resource_type = ? AND resource_id = ?)", userID, "user", strconv.FormatUint(uint64(userID), 10))
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []model.AuditLog
if err := q.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&rows).Error; err != nil {
return nil, 0, err
}
return rows, total, nil
}
// ExportCSV 导出审计为 CSV(手动导出接口)。since/until 为空则导出全量。
// 输出带 UTF-8 BOMExcel 直接打开不乱码。
func (s *AuditService) ExportCSV(ctx context.Context, w io.Writer, since, until *time.Time) error {
+58 -4
View File
@@ -77,10 +77,11 @@ func (s *UserService) GetByID(ctx context.Context, id uint) (*model.User, error)
// UserFilter 用户列表筛选条件。
type UserFilter struct {
Status string // active / disabled / expired,空为全部
Supervisor string // 挂靠老师模糊匹配
Page int
PageSize int
Status string // active / disabled / expired,空为全部
Supervisor string // 挂靠老师模糊匹配
ExpiresBefore *time.Time // 仅返回 expire_at 早于该时刻的用户(仪表盘"近期待过期"用)
Page int
PageSize int
}
// List 分页查询用户(admin)。
@@ -92,6 +93,9 @@ func (s *UserService) List(ctx context.Context, f UserFilter) ([]model.User, int
if f.Supervisor != "" {
q = q.Where("supervisor LIKE ?", "%"+f.Supervisor+"%")
}
if f.ExpiresBefore != nil {
q = q.Where("expire_at IS NOT NULL AND expire_at < ?", f.ExpiresBefore)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
@@ -113,6 +117,56 @@ func (s *UserService) List(ctx context.Context, f UserFilter) ([]model.User, int
return users, total, nil
}
// UserStats 仪表盘统计(PLAN §8):各状态用户数、近 30 天待过期用户、待审批申请数。
type UserStats struct {
Total int64 `json:"total"`
Active int64 `json:"active"`
Disabled int64 `json:"disabled"`
Expired int64 `json:"expired"`
ExpiringSoon []model.User `json:"expiring_soon"` // 30 天内到期且仍 active
PendingApprovals int64 `json:"pending_approvals"`
}
// Stats 汇总仪表盘统计。
func (s *UserService) Stats(ctx context.Context) (*UserStats, error) {
st := &UserStats{}
q := s.db.WithContext(ctx).Model(&model.User{})
if err := q.Count(&st.Total).Error; err != nil {
return nil, err
}
byStatus := map[string]int64{}
var rows []struct {
Status string
C int64
}
if err := s.db.WithContext(ctx).Model(&model.User{}).Select("status, count(*) as c").Group("status").Scan(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
byStatus[r.Status] = r.C
}
st.Active = byStatus[model.UserStatusActive]
st.Disabled = byStatus[model.UserStatusDisabled]
st.Expired = byStatus[model.UserStatusExpired]
// 近 30 天待过期(active 且 expire_at 在 30 天内)
window := time.Now().Add(30 * 24 * time.Hour)
var soon []model.User
if err := s.db.WithContext(ctx).Model(&model.User{}).
Where("status = ? AND expire_at IS NOT NULL AND expire_at < ?", model.UserStatusActive, window).
Order("expire_at ASC").Limit(10).Find(&soon).Error; err != nil {
return nil, err
}
st.ExpiringSoon = soon
var pending int64
if err := s.db.WithContext(ctx).Model(&model.Approval{}).Where("status = ?", model.StatusPending).Count(&pending).Error; err != nil {
return nil, err
}
st.PendingApprovals = pending
return st, nil
}
// Create 创建外部用户:DB 记录 + 系统账号(useradd + passwd -l)。
// username 不含前缀;ttl 为有效期时长,0 表示用配置默认(90 天)。
// 系统建号失败时回滚 DB 记录,保证两侧一致。
+1
View File
@@ -0,0 +1 @@
import{d as O,u as R,l as z,w as n,b as t,e as I,a as e,h as a,t as d,s as j,k as i,x as q,y as m,f as E}from"./index-OD70Gdif.js";import{_ as F}from"./_plugin-vue_export-helper-DlAUqK2U.js";const G={class:"logo"},H={class:"page-title"},J={class:"user-chip"},K=O({__name:"AdminLayout",setup(P){const r=q(),v=E(),_=R(),w=m(()=>r.path),c=m(()=>{var s;return((s=_.me)==null?void 0:s.username)??""}),x=m(()=>{var s;return((s=_.me)==null?void 0:s.email)??""});async function g(){await _.logout(),v.push("/login")}return(s,o)=>{const y=t("Monitor"),l=t("el-icon"),k=t("Odometer"),u=t("el-menu-item"),A=t("User"),C=t("Tickets"),D=t("Document"),b=t("Setting"),S=t("el-menu"),B=t("el-aside"),L=t("el-avatar"),M=t("ArrowDown"),p=t("el-dropdown-item"),N=t("el-dropdown-menu"),T=t("el-dropdown"),U=t("el-header"),V=t("router-view"),h=t("el-main"),f=t("el-container");return I(),z(f,{class:"admin-layout"},{default:n(()=>[e(B,{width:"220px",class:"aside"},{default:n(()=>[a("div",G,[e(l,null,{default:n(()=>[e(y)]),_:1}),o[0]||(o[0]=a("span",null,"用户管理节点",-1))]),e(S,{"default-active":w.value,router:"",class:"menu"},{default:n(()=>[e(u,{index:"/admin/dashboard"},{default:n(()=>[e(l,null,{default:n(()=>[e(k)]),_:1}),o[1]||(o[1]=a("span",null,"仪表盘",-1))]),_:1}),e(u,{index:"/admin/users"},{default:n(()=>[e(l,null,{default:n(()=>[e(A)]),_:1}),o[2]||(o[2]=a("span",null,"用户管理",-1))]),_:1}),e(u,{index:"/admin/approvals"},{default:n(()=>[e(l,null,{default:n(()=>[e(C)]),_:1}),o[3]||(o[3]=a("span",null,"申请审批",-1))]),_:1}),e(u,{index:"/admin/audit"},{default:n(()=>[e(l,null,{default:n(()=>[e(D)]),_:1}),o[4]||(o[4]=a("span",null,"审计日志",-1))]),_:1}),e(u,{index:"/admin/settings"},{default:n(()=>[e(l,null,{default:n(()=>[e(b)]),_:1}),o[5]||(o[5]=a("span",null,"系统设置",-1))]),_:1})]),_:1},8,["default-active"])]),_:1}),e(f,null,{default:n(()=>[e(U,{class:"header"},{default:n(()=>[a("span",H,d(j(r).meta.title??""),1),e(T,{onCommand:g},{dropdown:n(()=>[e(N,null,{default:n(()=>[e(p,{disabled:""},{default:n(()=>[i(d(x.value),1)]),_:1}),e(p,{divided:"",command:"logout"},{default:n(()=>[...o[6]||(o[6]=[i("退出登录",-1)])]),_:1})]),_:1})]),default:n(()=>[a("span",J,[e(L,{size:28,class:"avatar"},{default:n(()=>[i(d(c.value.slice(0,1).toUpperCase()),1)]),_:1}),a("span",null,d(c.value),1),e(l,null,{default:n(()=>[e(M)]),_:1})])]),_:1})]),_:1}),e(h,{class:"main"},{default:n(()=>[e(V)]),_:1})]),_:1})]),_:1})}}}),X=F(K,[["__scopeId","data-v-b8d71dcf"]]);export{X as default};
+1
View File
@@ -0,0 +1 @@
.admin-layout[data-v-b8d71dcf]{min-height:100vh}.aside[data-v-b8d71dcf]{background:var(--el-bg-color);border-right:1px solid var(--el-border-color-light)}.logo[data-v-b8d71dcf]{display:flex;align-items:center;gap:8px;height:60px;padding:0 16px;font-weight:600;font-size:15px;border-bottom:1px solid var(--el-border-color-light)}.menu[data-v-b8d71dcf]{border-right:none}.header[data-v-b8d71dcf]{display:flex;align-items:center;justify-content:space-between;background:var(--el-bg-color);border-bottom:1px solid var(--el-border-color-light)}.page-title[data-v-b8d71dcf]{font-size:15px;font-weight:600}.user-chip[data-v-b8d71dcf]{display:flex;align-items:center;gap:8px;cursor:pointer;outline:none}.avatar[data-v-b8d71dcf]{background:var(--el-color-primary);color:#fff}.main[data-v-b8d71dcf]{padding:20px}
+1
View File
@@ -0,0 +1 @@
import{d as w,c as k,a as l,w as t,b as a,e as C,h as f,k as n,i as q,s as z,f as U,r as _,m as A,v as B,E}from"./index-OD70Gdif.js";import{_ as N}from"./_plugin-vue_export-helper-DlAUqK2U.js";const R={class:"apply-page"},M=w({__name:"ApplyView",setup(O){const g=U(),p=_(!1),r=A({username:"",email:"",supervisor:"",purpose:""}),v={username:[{required:!0,message:"请输入用户名",trigger:"blur"},{pattern:/^[a-z][a-z0-9_]{1,30}$/,message:"小写字母开头,仅小写字母/数字/下划线,2~31 位",trigger:"blur"}],email:[{required:!0,message:"请输入邮箱",trigger:"blur"},{type:"email",message:"邮箱格式不正确",trigger:"blur"}],supervisor:[{required:!0,message:"请输入挂靠老师",trigger:"blur"}],purpose:[{required:!0,message:"请说明用途",trigger:"blur"}]},i=_();async function m(){var e;if(await((e=i.value)==null?void 0:e.validate().catch(()=>!1))){p.value=!0;try{await B({username:r.username.trim(),email:r.email.trim(),supervisor:r.supervisor.trim(),purpose:r.purpose.trim()}),E.success("申请已提交,等待管理员审批(结果将邮件通知)"),Object.assign(r,{username:"",email:"",supervisor:"",purpose:""})}catch{}finally{p.value=!1}}}return(c,e)=>{const b=a("el-text"),V=a("el-divider"),u=a("el-input"),s=a("el-form-item"),d=a("el-button"),y=a("el-form"),x=a("el-card");return C(),k("div",R,[l(x,{class:"apply-card",shadow:"always"},{default:t(()=>[e[8]||(e[8]=f("h2",null,"申请服务器账号",-1)),l(b,{type:"info"},{default:t(()=>[...e[5]||(e[5]=[n("提交后由管理员审批;通过后自动创建账号(用户名将带 ",-1),f("code",null,"ext_",-1),n(" 前缀),结果以邮件通知。",-1)])]),_:1}),l(V),l(y,{ref_key:"formRef",ref:i,model:r,rules:v,"label-width":"90px",onSubmit:q(m,["prevent"])},{default:t(()=>[l(s,{label:"用户名",prop:"username"},{default:t(()=>[l(u,{modelValue:r.username,"onUpdate:modelValue":e[0]||(e[0]=o=>r.username=o),placeholder:"如 zhangsan(自动成为 ext_zhangsan",maxlength:"31"},null,8,["modelValue"])]),_:1}),l(s,{label:"邮箱",prop:"email"},{default:t(()=>[l(u,{modelValue:r.email,"onUpdate:modelValue":e[1]||(e[1]=o=>r.email=o),placeholder:"接收通知与 OTP 验证码"},null,8,["modelValue"])]),_:1}),l(s,{label:"挂靠老师",prop:"supervisor"},{default:t(()=>[l(u,{modelValue:r.supervisor,"onUpdate:modelValue":e[2]||(e[2]=o=>r.supervisor=o)},null,8,["modelValue"])]),_:1}),l(s,{label:"用途",prop:"purpose"},{default:t(()=>[l(u,{modelValue:r.purpose,"onUpdate:modelValue":e[3]||(e[3]=o=>r.purpose=o),type:"textarea",rows:3,placeholder:"说明申请用途"},null,8,["modelValue"])]),_:1}),l(s,null,{default:t(()=>[l(d,{type:"primary",loading:p.value,onClick:m},{default:t(()=>[...e[6]||(e[6]=[n("提交申请",-1)])]),_:1},8,["loading"]),l(d,{onClick:e[4]||(e[4]=o=>z(g).push("/login"))},{default:t(()=>[...e[7]||(e[7]=[n("返回登录",-1)])]),_:1})]),_:1})]),_:1},8,["model"])]),_:1})])}}}),$=N(M,[["__scopeId","data-v-d21561d9"]]);export{$ as default};
+1
View File
@@ -0,0 +1 @@
.apply-page[data-v-d21561d9]{min-height:100vh;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#1f2d3d,#2c3e50);padding:24px}.apply-card[data-v-d21561d9]{width:520px;border-radius:8px}
+1
View File
@@ -0,0 +1 @@
import{d as G,o as O,c as h,a as e,w as l,Q,r as i,b as o,e as m,h as H,k as n,B as J,l as x,t as _,s as j,G as K,D as L,m as P,O as W,R as B,E as k,C as X}from"./index-OD70Gdif.js";import{b as Y,f as Z}from"./format-BLhrtLRJ.js";import{_ as ee}from"./_plugin-vue_export-helper-DlAUqK2U.js";const te={class:"toolbar"},le=G({__name:"ApprovalsView",setup(ae){const g=i([]),b=i(""),f=i(!1),T={pending:"warning",approved:"success",rejected:"danger"};async function u(){f.value=!0;try{const r=await Q(b.value||void 0);g.value=r.items}catch{}finally{f.value=!1}}const d=i(!1),v=i(null),p=P({reason:""}),y=i(!1);function q(r){v.value=r,p.reason="",d.value=!0}async function A(r){try{await W.confirm(`通过后将为 ${r.username_requested} 自动创建账号(ext_${r.username_requested})并邮件通知,确认?`,"审批通过",{type:"warning",confirmButtonText:"确认通过",cancelButtonText:"取消"})}catch{return}try{await B(r.id,!0,""),k.success("已通过,账号创建中"),u()}catch{}}async function D(){if(v.value){if(!p.reason.trim()){k.warning("拒绝时必须填写理由");return}y.value=!0;try{await B(v.value.id,!1,p.reason.trim()),k.success("已拒绝"),d.value=!1,u()}catch{}finally{y.value=!1}}}return O(u),(r,a)=>{const w=o("el-option"),$=o("el-select"),c=o("el-button"),s=o("el-table-column"),E=o("el-tag"),V=o("el-text"),N=o("el-table"),F=o("el-empty"),M=o("el-card"),C=o("el-form-item"),R=o("el-input"),U=o("el-form"),z=o("el-dialog"),I=X("loading");return m(),h("div",null,[e(M,{shadow:"never"},{default:l(()=>[H("div",te,[e($,{modelValue:b.value,"onUpdate:modelValue":a[0]||(a[0]=t=>b.value=t),placeholder:"全部状态",clearable:"",style:{width:"150px"},onChange:u},{default:l(()=>[e(w,{label:"待审批",value:"pending"}),e(w,{label:"已通过",value:"approved"}),e(w,{label:"已拒绝",value:"rejected"})]),_:1},8,["modelValue"]),e(c,{type:"primary",onClick:u},{default:l(()=>[...a[4]||(a[4]=[n("刷新",-1)])]),_:1})]),J((m(),x(N,{data:g.value,stripe:""},{default:l(()=>[e(s,{prop:"id",label:"ID",width:"70"}),e(s,{prop:"username_requested",label:"申请用户名",width:"140"},{default:l(({row:t})=>[n("ext_"+_(t.username_requested),1)]),_:1}),e(s,{prop:"email",label:"邮箱","min-width":"180"}),e(s,{prop:"supervisor",label:"挂靠老师",width:"120"}),e(s,{prop:"purpose",label:"用途","min-width":"160","show-overflow-tooltip":""}),e(s,{label:"状态",width:"90"},{default:l(({row:t})=>[e(E,{type:T[t.status]??"info",size:"small"},{default:l(()=>[n(_(j(Y)[t.status]??t.status),1)]),_:2},1032,["type"])]),_:1}),e(s,{label:"提交时间",width:"170"},{default:l(({row:t})=>[n(_(j(Z)(t.created_at)),1)]),_:1}),e(s,{label:"拒绝理由","min-width":"140","show-overflow-tooltip":""},{default:l(({row:t})=>[n(_(t.reason||"-"),1)]),_:1}),e(s,{label:"操作",width:"150",fixed:"right"},{default:l(({row:t})=>[t.status==="pending"?(m(),h(K,{key:0},[e(c,{link:"",type:"success",onClick:S=>A(t)},{default:l(()=>[...a[5]||(a[5]=[n("通过",-1)])]),_:1},8,["onClick"]),e(c,{link:"",type:"danger",onClick:S=>q(t)},{default:l(()=>[...a[6]||(a[6]=[n("拒绝",-1)])]),_:1},8,["onClick"])],64)):(m(),x(V,{key:1,type:"info",size:"small"},{default:l(()=>[...a[7]||(a[7]=[n("已处理",-1)])]),_:1}))]),_:1})]),_:1},8,["data"])),[[I,f.value]]),!f.value&&g.value.length===0?(m(),x(F,{key:0,description:"暂无申请"})):L("",!0)]),_:1}),e(z,{modelValue:d.value,"onUpdate:modelValue":a[3]||(a[3]=t=>d.value=t),title:"拒绝申请",width:"440px"},{footer:l(()=>[e(c,{onClick:a[2]||(a[2]=t=>d.value=!1)},{default:l(()=>[...a[8]||(a[8]=[n("取消",-1)])]),_:1}),e(c,{type:"danger",loading:y.value,onClick:D},{default:l(()=>[...a[9]||(a[9]=[n("确认拒绝",-1)])]),_:1},8,["loading"])]),default:l(()=>[e(U,{"label-width":"80px"},{default:l(()=>[e(C,{label:"用户名"},{default:l(()=>[e(V,null,{default:l(()=>{var t;return[n(_((t=v.value)==null?void 0:t.username_requested),1)]}),_:1})]),_:1}),e(C,{label:"理由",required:""},{default:l(()=>[e(R,{modelValue:p.reason,"onUpdate:modelValue":a[1]||(a[1]=t=>p.reason=t),type:"textarea",rows:3,placeholder:"必填,将邮件通知申请人"},null,8,["modelValue"])]),_:1})]),_:1})]),_:1},8,["modelValue"])])}}}),re=ee(le,[["__scopeId","data-v-84a7123e"]]);export{re as default};
+1
View File
@@ -0,0 +1 @@
.toolbar[data-v-84a7123e]{display:flex;gap:8px;margin-bottom:14px}
+1
View File
@@ -0,0 +1 @@
.toolbar[data-v-d8004883]{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px}.spacer[data-v-d8004883]{flex:1}.pager[data-v-d8004883]{margin-top:14px;justify-content:flex-end}.rid[data-v-d8004883]{margin-left:6px;font-size:12px;color:var(--el-text-color-secondary)}
+1
View File
@@ -0,0 +1 @@
import{d as H,o as O,c as E,a as t,w as o,A as K,b as n,r,m as N,e as h,h as g,j as C,k as i,B as P,l as j,t as u,s as w,S as q,C as F}from"./index-OD70Gdif.js";import{a as G,p as J,f as L}from"./format-BLhrtLRJ.js";import{_ as Q}from"./_plugin-vue_export-helper-DlAUqK2U.js";const R={class:"toolbar"},W={class:"rid"},X=H({__name:"AuditView",setup(Z){const y=r([]),b=r(0),d=r(1),_=r(20),m=r(!1),l=N({actor:"",action:"",resource_type:"",since:"",until:""}),k={success:"success",failed:"danger"};async function p(){m.value=!0;try{const v=await K({page:d.value,page_size:_.value,actor:l.actor||void 0,action:l.action||void 0,resource_type:l.resource_type||void 0,since:l.since?new Date(l.since).toISOString():void 0,until:l.until?new Date(l.until).toISOString():void 0});y.value=v.items,b.value=v.total}catch{}finally{m.value=!1}}function c(){d.value=1,p()}function z(){window.open(q({since:l.since?new Date(l.since).toISOString():void 0,until:l.until?new Date(l.until).toISOString():void 0}),"_blank")}return O(p),(v,a)=>{const V=n("el-input"),f=n("el-option"),U=n("el-select"),D=n("el-date-picker"),S=n("el-button"),Y=n("Download"),I=n("el-icon"),s=n("el-table-column"),x=n("el-tag"),T=n("el-table"),B=n("el-pagination"),M=n("el-card"),A=F("loading");return h(),E("div",null,[t(M,{shadow:"never"},{default:o(()=>[g("div",R,[t(V,{modelValue:l.actor,"onUpdate:modelValue":a[0]||(a[0]=e=>l.actor=e),placeholder:"操作者",clearable:"",style:{width:"150px"},onKeyup:C(c,["enter"])},null,8,["modelValue"]),t(V,{modelValue:l.action,"onUpdate:modelValue":a[1]||(a[1]=e=>l.action=e),placeholder:"动作",clearable:"",style:{width:"160px"},onKeyup:C(c,["enter"])},null,8,["modelValue"]),t(U,{modelValue:l.resource_type,"onUpdate:modelValue":a[2]||(a[2]=e=>l.resource_type=e),placeholder:"资源类型",clearable:"",style:{width:"130px"},onChange:c},{default:o(()=>[t(f,{label:"用户",value:"user"}),t(f,{label:"密钥",value:"ssh_key"}),t(f,{label:"申请",value:"approval"})]),_:1},8,["modelValue"]),t(D,{modelValue:l.since,"onUpdate:modelValue":a[3]||(a[3]=e=>l.since=e),type:"datetime",placeholder:"起始时间",style:{width:"190px"},"value-format":"YYYY-MM-DDTHH:mm:ss"},null,8,["modelValue"]),t(D,{modelValue:l.until,"onUpdate:modelValue":a[4]||(a[4]=e=>l.until=e),type:"datetime",placeholder:"结束时间",style:{width:"190px"},"value-format":"YYYY-MM-DDTHH:mm:ss"},null,8,["modelValue"]),t(S,{type:"primary",onClick:c},{default:o(()=>[...a[7]||(a[7]=[i("查询",-1)])]),_:1}),a[9]||(a[9]=g("div",{class:"spacer"},null,-1)),t(S,{type:"success",onClick:z},{default:o(()=>[t(I,null,{default:o(()=>[t(Y)]),_:1}),a[8]||(a[8]=i(" 导出 CSV ",-1))]),_:1})]),P((h(),j(T,{data:y.value,stripe:""},{default:o(()=>[t(s,{prop:"id",label:"ID",width:"80"}),t(s,{label:"动作","min-width":"130"},{default:o(({row:e})=>[i(u(w(G)(e.action)),1)]),_:1}),t(s,{prop:"actor_name",label:"操作者",width:"120"}),t(s,{label:"资源","min-width":"130"},{default:o(({row:e})=>[t(x,{size:"small",type:"info"},{default:o(()=>[i(u(e.resource_type),1)]),_:2},1024),g("span",W,u(e.resource_id),1)]),_:1}),t(s,{label:"详情","min-width":"180","show-overflow-tooltip":""},{default:o(({row:e})=>[i(u(w(J)(e.detail)),1)]),_:1}),t(s,{label:"结果",width:"80"},{default:o(({row:e})=>[t(x,{type:k[e.result]??"info",size:"small"},{default:o(()=>[i(u(e.result==="success"?"成功":"失败"),1)]),_:2},1032,["type"])]),_:1}),t(s,{prop:"ip",label:"IP",width:"130"}),t(s,{label:"时间",width:"170"},{default:o(({row:e})=>[i(u(w(L)(e.created_at)),1)]),_:1})]),_:1},8,["data"])),[[A,m.value]]),t(B,{"current-page":d.value,"onUpdate:currentPage":a[5]||(a[5]=e=>d.value=e),"page-size":_.value,"onUpdate:pageSize":a[6]||(a[6]=e=>_.value=e),total:b.value,"page-sizes":[20,50,100],layout:"total, sizes, prev, pager, next",class:"pager",onCurrentChange:p,onSizeChange:p},null,8,["current-page","page-size","total"])]),_:1})])}}}),le=Q(X,[["__scopeId","data-v-d8004883"]]);export{le as default};
+1
View File
@@ -0,0 +1 @@
import{d as N,o as T,z as A,A as S,B as q,c as E,a as s,w as a,r as g,b as u,C as I,e as D,h as l,t as n,s as p,k as _,l as M,D as P,f as R}from"./index-OD70Gdif.js";import{u as U,f as V,a as j}from"./format-BLhrtLRJ.js";import{_ as F}from"./_plugin-vue_export-helper-DlAUqK2U.js";const G={class:"stat-num"},H={class:"stat-num"},J={class:"stat-num"},K={class:"stat-num"},L={class:"stat-num"},O={class:"stat-num"},Q={class:"card-header"},W={class:"card-header"},X=N({__name:"DashboardView",setup(Y){const b=R(),o=g(null),w=g([]),y=g(!0);T(async()=>{try{const[m,t]=await Promise.all([A(),S({page:1,page_size:10})]);o.value=m,w.value=t.items}catch{}finally{y.value=!1}});function v(m){b.push(m?{path:"/admin/users",query:{status:m}}:"/admin/users")}return(m,t)=>{const d=u("el-card"),i=u("el-col"),k=u("el-row"),x=u("el-button"),r=u("el-table-column"),C=u("el-tag"),$=u("el-table"),h=u("el-empty"),B=I("loading");return q((D(),E("div",null,[s(k,{gutter:16},{default:a(()=>[s(i,{span:4},{default:a(()=>[s(d,{shadow:"never",class:"stat-card",onClick:t[0]||(t[0]=e=>v())},{default:a(()=>{var e;return[l("div",G,n(((e=o.value)==null?void 0:e.total)??"-"),1),t[8]||(t[8]=l("div",{class:"stat-label"},"用户总数",-1))]}),_:1})]),_:1}),s(i,{span:4},{default:a(()=>[s(d,{shadow:"never",class:"stat-card success",onClick:t[1]||(t[1]=e=>v("active"))},{default:a(()=>{var e;return[l("div",H,n(((e=o.value)==null?void 0:e.active)??"-"),1),t[9]||(t[9]=l("div",{class:"stat-label"},"正常",-1))]}),_:1})]),_:1}),s(i,{span:4},{default:a(()=>[s(d,{shadow:"never",class:"stat-card warning",onClick:t[2]||(t[2]=e=>v("disabled"))},{default:a(()=>{var e;return[l("div",J,n(((e=o.value)==null?void 0:e.disabled)??"-"),1),t[10]||(t[10]=l("div",{class:"stat-label"},"已禁用",-1))]}),_:1})]),_:1}),s(i,{span:4},{default:a(()=>[s(d,{shadow:"never",class:"stat-card danger",onClick:t[3]||(t[3]=e=>v("expired"))},{default:a(()=>{var e;return[l("div",K,n(((e=o.value)==null?void 0:e.expired)??"-"),1),t[11]||(t[11]=l("div",{class:"stat-label"},"已过期",-1))]}),_:1})]),_:1}),s(i,{span:4},{default:a(()=>[s(d,{shadow:"never",class:"stat-card primary",onClick:t[4]||(t[4]=e=>p(b).push("/admin/approvals"))},{default:a(()=>{var e;return[l("div",L,n(((e=o.value)==null?void 0:e.pending_approvals)??"-"),1),t[12]||(t[12]=l("div",{class:"stat-label"},"待审批申请",-1))]}),_:1})]),_:1}),s(i,{span:4},{default:a(()=>[s(d,{shadow:"never",class:"stat-card info",onClick:t[5]||(t[5]=e=>v())},{default:a(()=>{var e,c;return[l("div",O,n(((c=(e=o.value)==null?void 0:e.expiring_soon)==null?void 0:c.length)??"-"),1),t[13]||(t[13]=l("div",{class:"stat-label"},"30 天内到期",-1))]}),_:1})]),_:1})]),_:1}),s(k,{gutter:16,class:"second-row"},{default:a(()=>[s(i,{span:12},{default:a(()=>[s(d,{shadow:"never"},{header:a(()=>[l("div",Q,[t[15]||(t[15]=l("b",null,"近期待过期",-1)),s(x,{link:"",type:"primary",onClick:t[6]||(t[6]=e=>p(b).push("/admin/users"))},{default:a(()=>[...t[14]||(t[14]=[_("全部用户",-1)])]),_:1})])]),default:a(()=>{var e,c,z;return[s($,{data:((e=o.value)==null?void 0:e.expiring_soon)??[],size:"small"},{default:a(()=>[s(r,{prop:"username",label:"用户名",width:"150"}),s(r,{label:"状态",width:"90"},{default:a(({row:f})=>[s(C,{type:f.status==="active"?"success":"info",size:"small"},{default:a(()=>[_(n(p(U)[f.status]??f.status),1)]),_:2},1032,["type"])]),_:1}),s(r,{label:"过期时间","min-width":"160"},{default:a(({row:f})=>[_(n(p(V)(f.expire_at)),1)]),_:1})]),_:1},8,["data"]),(z=(c=o.value)==null?void 0:c.expiring_soon)!=null&&z.length?P("",!0):(D(),M(h,{key:0,description:"近 30 天无到期账号","image-size":60}))]}),_:1})]),_:1}),s(i,{span:12},{default:a(()=>[s(d,{shadow:"never"},{header:a(()=>[l("div",W,[t[17]||(t[17]=l("b",null,"最近审计",-1)),s(x,{link:"",type:"primary",onClick:t[7]||(t[7]=e=>p(b).push("/admin/audit"))},{default:a(()=>[...t[16]||(t[16]=[_("全部审计",-1)])]),_:1})])]),default:a(()=>[s($,{data:w.value,size:"small"},{default:a(()=>[s(r,{label:"动作","min-width":"130"},{default:a(({row:e})=>[_(n(p(j)(e.action)),1)]),_:1}),s(r,{prop:"actor_name",label:"操作者",width:"120"}),s(r,{label:"结果",width:"80"},{default:a(({row:e})=>[s(C,{type:e.result==="success"?"success":"danger",size:"small"},{default:a(()=>[_(n(e.result==="success"?"成功":"失败"),1)]),_:2},1032,["type"])]),_:1}),s(r,{label:"时间","min-width":"160"},{default:a(({row:e})=>[_(n(p(V)(e.created_at)),1)]),_:1})]),_:1},8,["data"])]),_:1})]),_:1})]),_:1})])),[[B,y.value]])}}}),st=F(X,[["__scopeId","data-v-5827ec1e"]]);export{st as default};
+1
View File
@@ -0,0 +1 @@
.stat-card[data-v-5827ec1e]{cursor:pointer;text-align:center}.stat-num[data-v-5827ec1e]{font-size:28px;font-weight:700}.stat-label[data-v-5827ec1e]{margin-top:4px;color:var(--el-text-color-secondary);font-size:13px}.stat-card.success .stat-num[data-v-5827ec1e]{color:var(--el-color-success)}.stat-card.warning .stat-num[data-v-5827ec1e]{color:var(--el-color-warning)}.stat-card.danger .stat-num[data-v-5827ec1e]{color:var(--el-color-danger)}.stat-card.primary .stat-num[data-v-5827ec1e]{color:var(--el-color-primary)}.stat-card.info .stat-num[data-v-5827ec1e]{color:var(--el-color-info)}.second-row[data-v-5827ec1e]{margin-top:16px}.card-header[data-v-5827ec1e]{display:flex;align-items:center;justify-content:space-between}
-1
View File
@@ -1 +0,0 @@
import{d as i,o as _,c as f,w as o,r as m,a,b as v,e as l,f as n,g as s,t as w}from"./index-DU8VsiSb.js";const g=i({__name:"HomeView",setup(x){const t=m(null);return _(async()=>{try{const r=await fetch("/healthz");t.value=r.ok}catch{t.value=!1}}),(r,e)=>{const u=a("el-tag"),d=a("el-divider"),p=a("el-text"),c=a("el-card");return v(),f(c,{shadow:"never"},{header:o(()=>[...e[0]||(e[0]=[l("b",null,"工程骨架已就绪",-1)])]),default:o(()=>[e[3]||(e[3]=l("p",null,[n("ws_usernode M0Go (Gin + GORM) 后端与 Vue3 前端脚手架已打通,API 前缀 "),l("code",null,"/api/v1"),n("。")],-1)),l("p",null,[e[1]||(e[1]=n(" 后端健康检查: ",-1)),s(u,{type:t.value===null?"info":t.value?"success":"danger"},{default:o(()=>[n(w(t.value===null?"检测中…":t.value?"正常":"不可达(请先启动 Go 后端)"),1)]),_:1},8,["type"])]),s(d),s(p,{type:"info"},{default:o(()=>[...e[2]||(e[2]=[n("登录 / 用户管理 / 密钥 / 审批 / 审计页面将在 M1 起逐步落地。",-1)])]),_:1})]),_:1})}}});export{g as default};
+1
View File
@@ -0,0 +1 @@
import{d as P,o as j,c as $,a as l,w as t,r as u,V as G,b as n,e as c,B as I,l as C,k as o,t as S,s as W,G as X,D as Y,h as K,m as J,E as _,W as L,X as Q,O as Z,Y as ee,C as le}from"./index-OD70Gdif.js";import{f as te}from"./format-BLhrtLRJ.js";import{_ as ae}from"./_plugin-vue_export-helper-DlAUqK2U.js";const ne={class:"card-header"},oe=P({__name:"KeysView",setup(se){const v=u([]),y=u(!1),m=u(!1),k=u(!1),s=J({name:"",public_key:""});async function g(){y.value=!0;try{const i=await G();v.value=i.items}catch{}finally{y.value=!1}}async function U(){if(!s.name.trim()){_.warning("请填写密钥名称");return}if(!s.public_key.trim()){_.warning("请粘贴公钥内容");return}k.value=!0;try{await L({name:s.name.trim(),public_key:s.public_key.trim()}),_.success("上传成功,已同步到 authorized_keys"),m.value=!1,Object.assign(s,{name:"",public_key:""}),g()}catch{}finally{k.value=!1}}const p=u(!1),b=u(null),f=u(""),V=u(!1);function z(i){b.value=i,f.value=i.name,p.value=!0}async function N(){if(!(!b.value||!f.value.trim())){V.value=!0;try{await Q(b.value.id,f.value.trim()),_.success("已重命名"),p.value=!1,g()}catch{}finally{V.value=!1}}}async function T(i){try{await Z.confirm(`吊销密钥「${i.name}」(${i.fingerprint})后将立即从 authorized_keys 移除,SSH 登录立即失效。确认?`,"吊销密钥",{type:"warning",confirmButtonText:"确认吊销",cancelButtonText:"取消"})}catch{return}try{await ee(i.id),_.success("已吊销"),g()}catch{}}return j(g),(i,e)=>{const D=n("Plus"),E=n("el-icon"),r=n("el-button"),d=n("el-table-column"),H=n("el-tag"),M=n("el-text"),h=n("el-table"),R=n("el-empty"),q=n("el-card"),w=n("el-input"),x=n("el-form-item"),A=n("el-form"),B=n("el-dialog"),F=le("loading");return c(),$("div",null,[l(q,{shadow:"never"},{header:t(()=>[K("div",ne,[K("b",null,"我的 SSH 公钥("+S(v.value.length)+"",1),l(r,{type:"primary",size:"small",onClick:e[0]||(e[0]=a=>m.value=!0)},{default:t(()=>[l(E,null,{default:t(()=>[l(D)]),_:1}),e[8]||(e[8]=o(" 上传公钥 ",-1))]),_:1})])]),default:t(()=>[I((c(),C(h,{data:v.value,stripe:""},{default:t(()=>[l(d,{prop:"name",label:"名称","min-width":"140"}),l(d,{prop:"key_type",label:"类型",width:"120"}),l(d,{prop:"fingerprint",label:"指纹","min-width":"200","show-overflow-tooltip":""}),l(d,{label:"状态",width:"90"},{default:t(({row:a})=>[l(H,{type:a.status==="active"?"success":"danger",size:"small"},{default:t(()=>[o(S(a.status==="active"?"生效":"已吊销"),1)]),_:2},1032,["type"])]),_:1}),l(d,{label:"上传时间",width:"170"},{default:t(({row:a})=>[o(S(W(te)(a.created_at)),1)]),_:1}),l(d,{label:"操作",width:"140",fixed:"right"},{default:t(({row:a})=>[a.status==="active"?(c(),$(X,{key:0},[l(r,{link:"",type:"primary",onClick:O=>z(a)},{default:t(()=>[...e[9]||(e[9]=[o("重命名",-1)])]),_:1},8,["onClick"]),l(r,{link:"",type:"danger",onClick:O=>T(a)},{default:t(()=>[...e[10]||(e[10]=[o("吊销",-1)])]),_:1},8,["onClick"])],64)):(c(),C(M,{key:1,type:"info",size:"small"},{default:t(()=>[...e[11]||(e[11]=[o("已吊销",-1)])]),_:1}))]),_:1})]),_:1},8,["data"])),[[F,y.value]]),!y.value&&v.value.length===0?(c(),C(R,{key:0,description:"暂无密钥,上传公钥后即可 SSH 登录"})):Y("",!0)]),_:1}),l(B,{modelValue:m.value,"onUpdate:modelValue":e[4]||(e[4]=a=>m.value=a),title:"上传 SSH 公钥",width:"520px"},{footer:t(()=>[l(r,{onClick:e[3]||(e[3]=a=>m.value=!1)},{default:t(()=>[...e[12]||(e[12]=[o("取消",-1)])]),_:1}),l(r,{type:"primary",loading:k.value,onClick:U},{default:t(()=>[...e[13]||(e[13]=[o("上传",-1)])]),_:1},8,["loading"])]),default:t(()=>[l(A,{"label-width":"80px"},{default:t(()=>[l(x,{label:"名称",required:""},{default:t(()=>[l(w,{modelValue:s.name,"onUpdate:modelValue":e[1]||(e[1]=a=>s.name=a),placeholder:"如 我的笔记本",maxlength:"64"},null,8,["modelValue"])]),_:1}),l(x,{label:"公钥",required:""},{default:t(()=>[l(w,{modelValue:s.public_key,"onUpdate:modelValue":e[2]||(e[2]=a=>s.public_key=a),type:"textarea",rows:5,placeholder:"ssh-ed25519 AAAA... 或 ssh-rsa AAAA..."},null,8,["modelValue"])]),_:1})]),_:1})]),_:1},8,["modelValue"]),l(B,{modelValue:p.value,"onUpdate:modelValue":e[7]||(e[7]=a=>p.value=a),title:"重命名密钥",width:"400px"},{footer:t(()=>[l(r,{onClick:e[6]||(e[6]=a=>p.value=!1)},{default:t(()=>[...e[14]||(e[14]=[o("取消",-1)])]),_:1}),l(r,{type:"primary",loading:V.value,onClick:N},{default:t(()=>[...e[15]||(e[15]=[o("保存",-1)])]),_:1},8,["loading"])]),default:t(()=>[l(A,{"label-width":"80px"},{default:t(()=>[l(x,{label:"名称"},{default:t(()=>[l(w,{modelValue:f.value,"onUpdate:modelValue":e[5]||(e[5]=a=>f.value=a),maxlength:"64"},null,8,["modelValue"])]),_:1})]),_:1})]),_:1},8,["modelValue"])])}}}),de=ae(oe,[["__scopeId","data-v-9032698c"]]);export{de as default};
+1
View File
@@ -0,0 +1 @@
.card-header[data-v-9032698c]{display:flex;align-items:center;justify-content:space-between}
+1
View File
@@ -0,0 +1 @@
.login-page[data-v-16050750]{min-height:100vh;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#1f2d3d,#2c3e50)}.login-card[data-v-16050750]{width:400px;border-radius:8px}.brand[data-v-16050750]{display:flex;align-items:center;justify-content:center;gap:10px;margin-bottom:8px}.brand h2[data-v-16050750]{margin:0;font-size:18px}.captcha-row[data-v-16050750]{display:flex;gap:8px;width:100%}.captcha-img[data-v-16050750]{width:110px;height:32px;border:1px solid var(--el-border-color);border-radius:4px;cursor:pointer;flex-shrink:0}.submit[data-v-16050750]{width:100%;margin-top:4px}.foot[data-v-16050750]{margin-top:16px;text-align:center;font-size:13px}
+1
View File
@@ -0,0 +1 @@
import{d as z,u as D,o as R,c as $,a,w as o,r as m,g as G,b as n,e as V,f as H,h as w,i as I,j as C,k as _,l as B,t as J,m as K,E as g,n as Q,p as W,q as X}from"./index-OD70Gdif.js";import{_ as Y}from"./_plugin-vue_export-helper-DlAUqK2U.js";const Z={class:"login-page"},ee={class:"brand"},ae={class:"captcha-row"},oe={class:"foot"},le=z({__name:"LoginView",setup(te){const k=H(),L=D(),f=m("admin"),u=m(!1),d=K({username:"",password:""}),l=K({username:"",code:"",captcha_id:"",captcha_code:""}),s=m(null),b=m(!1),i=m(0);let x;async function r(){b.value=!0;try{s.value=await G()}catch{s.value=null}finally{b.value=!1}}function O(U){i.value=U,x=window.setInterval(()=>{i.value-=1,i.value<=0&&(window.clearInterval(x),i.value=0)},1e3)}async function h(){if(!d.username||!d.password){g.warning("请输入用户名和密码");return}u.value=!0;try{await Q(d),await L.fetchMe(),k.push("/admin/dashboard")}catch{}finally{u.value=!1}}async function M(){if(!l.username||!l.captcha_code){g.warning("请输入用户名和图形验证码");return}if(!(!s.value&&(await r(),!s.value)))try{await W({username:l.username,captcha_id:s.value.captcha_id,captcha_code:l.captcha_code}),g.success("验证码已发送(邮件或 CLIusernode user otp"),O(60),l.captcha_code="",await r()}catch{await r()}}async function y(){if(!l.username||!l.code){g.warning("请输入用户名和 OTP 验证码");return}u.value=!0;try{await X({username:l.username,code:l.code}),await L.fetchMe(),k.push("/me/profile")}catch{}finally{u.value=!1}}function E(){f.value==="user"&&!s.value&&r()}return R(()=>{f.value==="user"&&r()}),(U,e)=>{const N=n("Monitor"),P=n("el-icon"),c=n("el-input"),p=n("el-form-item"),v=n("el-button"),S=n("el-form"),T=n("el-tab-pane"),A=n("el-image"),F=n("el-tabs"),j=n("router-link"),q=n("el-card");return V(),$("div",Z,[a(q,{class:"login-card",shadow:"always"},{default:o(()=>[w("div",ee,[a(P,{size:30},{default:o(()=>[a(N)]),_:1}),e[6]||(e[6]=w("h2",null,"服务器用户管理节点",-1))]),a(F,{modelValue:f.value,"onUpdate:modelValue":e[5]||(e[5]=t=>f.value=t),stretch:"",onTabChange:E},{default:o(()=>[a(T,{label:"管理员登录",name:"admin"},{default:o(()=>[a(S,{"label-position":"top",onSubmit:I(h,["prevent"])},{default:o(()=>[a(p,{label:"用户名"},{default:o(()=>[a(c,{modelValue:d.username,"onUpdate:modelValue":e[0]||(e[0]=t=>d.username=t),placeholder:"管理员用户名",autocomplete:"username"},null,8,["modelValue"])]),_:1}),a(p,{label:"密码"},{default:o(()=>[a(c,{modelValue:d.password,"onUpdate:modelValue":e[1]||(e[1]=t=>d.password=t),type:"password",placeholder:"密码","show-password":"",autocomplete:"current-password",onKeyup:C(h,["enter"])},null,8,["modelValue"])]),_:1}),a(v,{type:"primary",class:"submit",loading:u.value,onClick:h},{default:o(()=>[...e[7]||(e[7]=[_("登录",-1)])]),_:1},8,["loading"])]),_:1})]),_:1}),a(T,{label:"外部用户登录",name:"user"},{default:o(()=>[a(S,{"label-position":"top",onSubmit:I(y,["prevent"])},{default:o(()=>[a(p,{label:"用户名"},{default:o(()=>[a(c,{modelValue:l.username,"onUpdate:modelValue":e[2]||(e[2]=t=>l.username=t),placeholder:"用户名(可带或不带 ext_ 前缀)",autocomplete:"username"},null,8,["modelValue"])]),_:1}),a(p,{label:"图形验证码"},{default:o(()=>[w("div",ae,[a(c,{modelValue:l.captcha_code,"onUpdate:modelValue":e[3]||(e[3]=t=>l.captcha_code=t),placeholder:"4 位验证码",maxlength:"4",onKeyup:C(M,["enter"])},null,8,["modelValue"]),s.value?(V(),B(A,{key:0,src:s.value.image,fit:"contain",class:"captcha-img",title:"点击刷新",onClick:r},null,8,["src"])):(V(),B(v,{key:1,loading:b.value,onClick:r},{default:o(()=>[...e[8]||(e[8]=[_("获取",-1)])]),_:1},8,["loading"]))])]),_:1}),a(p,{label:"登录验证码(OTP"},{default:o(()=>[a(c,{modelValue:l.code,"onUpdate:modelValue":e[4]||(e[4]=t=>l.code=t),placeholder:"6 位 OTP(邮件或 CLI 获取)",maxlength:"6",onKeyup:C(y,["enter"])},null,8,["modelValue"])]),_:1}),a(v,{type:"primary",class:"submit",loading:u.value,onClick:M,disabled:i.value>0},{default:o(()=>[_(J(i.value>0?`重新发送(${i.value}s`:"发送验证码"),1)]),_:1},8,["loading","disabled"]),a(v,{type:"success",class:"submit",loading:u.value,onClick:y},{default:o(()=>[...e[9]||(e[9]=[_("登录",-1)])]),_:1},8,["loading"])]),_:1})]),_:1})]),_:1},8,["modelValue"]),w("div",oe,[a(j,{to:"/apply"},{default:o(()=>[...e[10]||(e[10]=[_("申请新账号",-1)])]),_:1})])]),_:1})])}}}),ue=Y(le,[["__scopeId","data-v-16050750"]]);export{ue as default};
+1
View File
@@ -0,0 +1 @@
import{d as x,o as C,c as B,a,w as t,Z as V,b as s,r as n,e as g,B as k,l as D,k as i,t as r,s as m,h as S,C as A}from"./index-OD70Gdif.js";import{a as M,p as N,f as T}from"./format-BLhrtLRJ.js";import{_ as I}from"./_plugin-vue_export-helper-DlAUqK2U.js";const P=x({__name:"MyAuditView",setup(U){const f=n([]),v=n(0),c=n(1),u=n(20),d=n(!1);async function p(){d.value=!0;try{const _=await V(c.value,u.value);f.value=_.items,v.value=_.total}catch{}finally{d.value=!1}}return C(p),(_,l)=>{const o=s("el-table-column"),w=s("el-tag"),b=s("el-table"),y=s("el-pagination"),h=s("el-card"),z=A("loading");return g(),B("div",null,[a(h,{shadow:"never"},{header:t(()=>[...l[2]||(l[2]=[S("b",null,"我的审计痕迹",-1)])]),default:t(()=>[k((g(),D(b,{data:f.value,stripe:""},{default:t(()=>[a(o,{label:"动作","min-width":"140"},{default:t(({row:e})=>[i(r(m(M)(e.action)),1)]),_:1}),a(o,{label:"详情","min-width":"200","show-overflow-tooltip":""},{default:t(({row:e})=>[i(r(m(N)(e.detail)),1)]),_:1}),a(o,{label:"结果",width:"80"},{default:t(({row:e})=>[a(w,{type:e.result==="success"?"success":"danger",size:"small"},{default:t(()=>[i(r(e.result==="success"?"成功":"失败"),1)]),_:2},1032,["type"])]),_:1}),a(o,{prop:"ip",label:"IP",width:"130"}),a(o,{label:"时间",width:"170"},{default:t(({row:e})=>[i(r(m(T)(e.created_at)),1)]),_:1})]),_:1},8,["data"])),[[z,d.value]]),a(y,{"current-page":c.value,"onUpdate:currentPage":l[0]||(l[0]=e=>c.value=e),"page-size":u.value,"onUpdate:pageSize":l[1]||(l[1]=e=>u.value=e),total:v.value,"page-sizes":[10,20,50],layout:"total, sizes, prev, pager, next",class:"pager",onCurrentChange:p,onSizeChange:p},null,8,["current-page","page-size","total"])]),_:1})])}}}),q=I(P,[["__scopeId","data-v-5c05de0c"]]);export{q as default};
+1
View File
@@ -0,0 +1 @@
.pager[data-v-5c05de0c]{margin-top:14px;justify-content:flex-end}
+1
View File
@@ -0,0 +1 @@
.profile-card[data-v-d8b0b471]{max-width:640px}.notice[data-v-d8b0b471]{margin-top:16px}.soon-tag[data-v-d8b0b471]{margin-left:8px}
+1
View File
@@ -0,0 +1 @@
import{d as D,u as N,o as T,F as z,B as F,c as f,a as t,w as a,r as v,y as H,b as r,C as P,e as u,G as A,k as l,t as o,s as i,h as y,l as d,D as b}from"./index-OD70Gdif.js";import{u as E,f as g,d as x}from"./format-BLhrtLRJ.js";import{_ as G}from"./_plugin-vue_export-helper-DlAUqK2U.js";const I=D({__name:"ProfileView",setup(L){const k=N(),e=v(null),_=v(!0),w=H(()=>{var c;return((c=k.me)==null?void 0:c.username)??""}),S={active:"success",disabled:"info",expired:"danger"};return T(async()=>{try{const n=(await z({page_size:100})).items.find(s=>s.username===w.value)??null;e.value=n}catch{}finally{_.value=!1}}),(c,n)=>{const s=r("el-descriptions-item"),p=r("el-tag"),V=r("el-descriptions"),m=r("el-alert"),h=r("el-empty"),B=r("el-card"),C=P("loading");return F((u(),f("div",null,[t(B,{shadow:"never",class:"profile-card"},{header:a(()=>[...n[0]||(n[0]=[y("b",null,"账号信息",-1)])]),default:a(()=>[e.value?(u(),f(A,{key:0},[t(V,{column:1,border:""},{default:a(()=>[t(s,{label:"用户名"},{default:a(()=>[l(o(e.value.username),1)]),_:1}),t(s,{label:"邮箱"},{default:a(()=>[l(o(e.value.email),1)]),_:1}),t(s,{label:"挂靠老师"},{default:a(()=>[l(o(e.value.supervisor||"-"),1)]),_:1}),t(s,{label:"用途"},{default:a(()=>[l(o(e.value.purpose||"-"),1)]),_:1}),t(s,{label:"状态"},{default:a(()=>[t(p,{type:S[e.value.status]??"info",size:"small"},{default:a(()=>[l(o(i(E)[e.value.status]??e.value.status),1)]),_:1},8,["type"])]),_:1}),t(s,{label:"过期时间"},{default:a(()=>[y("span",null,o(i(g)(e.value.expire_at)),1),e.value.status==="active"&&(i(x)(e.value.expire_at)??999)<=30?(u(),d(p,{key:0,type:"warning",size:"small",class:"soon-tag"},{default:a(()=>[l(" 剩余 "+o(i(x)(e.value.expire_at))+" 天 ",1)]),_:1})):b("",!0)]),_:1}),t(s,{label:"最近登录"},{default:a(()=>[l(o(i(g)(e.value.last_login_at)),1)]),_:1}),t(s,{label:"Shell"},{default:a(()=>[l(o(e.value.shell),1)]),_:1})]),_:1}),e.value.status==="expired"?(u(),d(m,{key:0,type:"error",closable:!1,class:"notice"},{default:a(()=>[...n[1]||(n[1]=[l(" 账号已过期。请在回收期内联系管理员办理延期,否则将被自动回收删除。 ",-1)])]),_:1})):b("",!0),t(m,{type:"info",closable:!1,class:"notice"},{default:a(()=>[...n[2]||(n[2]=[l(" 系统账号口令已锁定(仅支持 SSH 密钥登录)。请到「我的密钥」上传公钥后即可 SSH 登录。 ",-1)])]),_:1})],64)):(u(),d(h,{key:1,description:"未找到账号信息"}))]),_:1})])),[[C,_.value]])}}}),q=G(I,[["__scopeId","data-v-d8b0b471"]]);export{q as default};
+1
View File
@@ -0,0 +1 @@
.settings-table[data-v-90a30993],.notice[data-v-90a30993]{margin-top:16px}
+1
View File
@@ -0,0 +1 @@
import{d as D,o as E,c as M,B as N,l as _,w as e,r,T,b as o,C as U,e as d,a as n,k as s,t as p,h as m,y as I,U as K,E as P}from"./index-OD70Gdif.js";import{_ as $}from"./_plugin-vue_export-helper-DlAUqK2U.js";const j=D({__name:"SettingsView",setup(q){const f=r([]),u=r(!1),c=r(""),y={"policy.default_ttl":{label:"默认有效期",hint:"新账号默认有效期(如 2160h = 90 天)"},"policy.recycle_period":{label:"回收期",hint:"到期后保留时长,期内可延期恢复(如 720h = 30 天)"},"policy.audit_retention":{label:"审计保留",hint:"审计日志保留时长,超期先归档再清理(如 720h)"}},k=I(()=>f.value.filter(a=>a.key in y).map(a=>({...a,...y[a.key]})));async function v(){u.value=!0;try{const a=await T();f.value=a.items}catch{}finally{u.value=!1}}async function h(a){c.value=a.key;try{await K(a.key,a.value.trim()),P.success("已保存并生效"),await v()}catch{}finally{c.value=""}}return E(v),(a,t)=>{const w=o("el-text"),i=o("el-table-column"),b=o("el-tag"),V=o("el-input"),x=o("el-button"),S=o("el-table"),C=o("el-alert"),B=o("el-card"),z=U("loading");return d(),M("div",null,[N((d(),_(B,{shadow:"never"},{header:e(()=>[...t[0]||(t[0]=[m("b",null,"系统设置",-1)])]),default:e(()=>[n(w,{type:"info"},{default:e(()=>[...t[1]||(t[1]=[s("以下策略通过 settings 表动态覆盖配置文件默认值,保存后立即生效。",-1)])]),_:1}),n(S,{data:k.value,stripe:"",class:"settings-table"},{default:e(()=>[n(i,{label:"配置项",width:"140"},{default:e(({row:l})=>[s(p(l.label),1)]),_:1}),n(i,{label:"Key",width:"220"},{default:e(({row:l})=>[m("code",null,p(l.key),1)]),_:1}),n(i,{label:"当前值"},{default:e(({row:l})=>[n(V,{modelValue:l.value,"onUpdate:modelValue":g=>l.value=g,placeholder:"时长格式,如 2160h"},{suffix:e(()=>[l.overridden?(d(),_(b,{key:0,type:"warning",size:"small"},{default:e(()=>[...t[2]||(t[2]=[s("已覆盖",-1)])]),_:1})):(d(),_(b,{key:1,type:"info",size:"small"},{default:e(()=>[...t[3]||(t[3]=[s("默认",-1)])]),_:1}))]),_:2},1032,["modelValue","onUpdate:modelValue"])]),_:1}),n(i,{label:"说明","min-width":"240"},{default:e(({row:l})=>[s(p(l.hint),1)]),_:1}),n(i,{label:"操作",width:"100"},{default:e(({row:l})=>[n(x,{type:"primary",size:"small",loading:c.value===l.key,onClick:g=>h(l)},{default:e(()=>[...t[4]||(t[4]=[s("保存",-1)])]),_:1},8,["loading","onClick"])]),_:1})]),_:1},8,["data"]),n(C,{type:"info",closable:!1,class:"notice"},{default:e(()=>[...t[5]||(t[5]=[s(" 提示:SMTP、会话时长等参数在 ",-1),m("code",null,"config.toml",-1),s(" 中配置(或环境变量覆盖),不在本页修改。 ",-1)])]),_:1})]),_:1})),[[z,u.value]])])}}}),G=$(j,[["__scopeId","data-v-90a30993"]]);export{G as default};
+1
View File
@@ -0,0 +1 @@
.user-layout[data-v-2262b0f9]{min-height:100vh}.header[data-v-2262b0f9]{display:flex;align-items:center;gap:24px;background:var(--el-bg-color);border-bottom:1px solid var(--el-border-color-light)}.logo[data-v-2262b0f9]{display:flex;align-items:center;gap:8px;font-weight:600;font-size:15px}.nav[data-v-2262b0f9]{flex:1;border-bottom:none}.user-chip[data-v-2262b0f9]{display:flex;align-items:center;gap:8px;cursor:pointer;outline:none}.avatar[data-v-2262b0f9]{background:var(--el-color-primary);color:#fff}.main[data-v-2262b0f9]{padding:20px}
+1
View File
@@ -0,0 +1 @@
import{d as M,u as N,l as U,w as o,b as t,e as V,a as e,h as s,k as a,t as c,y as m,f as b,x as z}from"./index-OD70Gdif.js";import{_ as R}from"./_plugin-vue_export-helper-DlAUqK2U.js";const S={class:"logo"},I={class:"user-chip"},T=M({__name:"UserLayout",setup(j){const p=z(),i=b(),_=N(),f=m(()=>p.path),r=m(()=>{var l;return((l=_.me)==null?void 0:l.username)??""});async function w(){await _.logout(),i.push("/login")}return(l,n)=>{const v=t("Monitor"),d=t("el-icon"),u=t("el-menu-item"),x=t("el-menu"),y=t("el-avatar"),g=t("ArrowDown"),C=t("el-dropdown-item"),h=t("el-dropdown-menu"),k=t("el-dropdown"),A=t("el-header"),B=t("router-view"),D=t("el-main"),L=t("el-container");return V(),U(L,{class:"user-layout"},{default:o(()=>[e(A,{class:"header"},{default:o(()=>[s("span",S,[e(d,null,{default:o(()=>[e(v)]),_:1}),n[0]||(n[0]=s("span",null,"服务器用户管理",-1))]),e(x,{"default-active":f.value,router:"",mode:"horizontal",ellipsis:!1,class:"nav"},{default:o(()=>[e(u,{index:"/me/profile"},{default:o(()=>[...n[1]||(n[1]=[a("账号信息",-1)])]),_:1}),e(u,{index:"/me/keys"},{default:o(()=>[...n[2]||(n[2]=[a("我的密钥",-1)])]),_:1}),e(u,{index:"/me/audit"},{default:o(()=>[...n[3]||(n[3]=[a("我的审计",-1)])]),_:1})]),_:1},8,["default-active"]),e(k,{onCommand:w},{dropdown:o(()=>[e(h,null,{default:o(()=>[e(C,{command:"logout"},{default:o(()=>[...n[4]||(n[4]=[a("退出登录",-1)])]),_:1})]),_:1})]),default:o(()=>[s("span",I,[e(y,{size:26,class:"avatar"},{default:o(()=>[a(c(r.value.slice(0,1).toUpperCase()),1)]),_:1}),s("span",null,c(r.value),1),e(d,null,{default:o(()=>[e(g)]),_:1})])]),_:1})]),_:1}),e(D,{class:"main"},{default:o(()=>[e(B)]),_:1})]),_:1})}}}),F=R(T,[["__scopeId","data-v-2262b0f9"]]);export{F as default};
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
.toolbar[data-v-e4076ba9]{display:flex;gap:8px;margin-bottom:14px}.spacer[data-v-e4076ba9]{flex:1}.pager[data-v-e4076ba9]{margin-top:14px;justify-content:flex-end}.soon-tag[data-v-e4076ba9]{margin-left:6px}.actions[data-v-e4076ba9]{margin-top:16px;display:flex;gap:8px;flex-wrap:wrap}.tip[data-v-e4076ba9]{font-size:12px;color:var(--el-text-color-secondary)}
@@ -0,0 +1 @@
const s=(t,r)=>{const o=t.__vccOpts||t;for(const[c,e]of r)o[c]=e;return o};export{s as _};
+1
View File
@@ -0,0 +1 @@
function a(e){if(!e)return"-";const t=new Date(e);return Number.isNaN(t.getTime())?e:t.toLocaleString("zh-CN",{hour12:!1})}function n(e){if(!e)return null;const t=new Date(e).getTime()-Date.now();return Math.ceil(t/864e5)}const u={active:"正常",disabled:"已禁用",expired:"已过期"},s={pending:"待审批",approved:"已通过",rejected:"已拒绝"},r={"auth.admin_login":"管理员登录","auth.otp_send":"发送 OTP","auth.otp_login":"OTP 登录","auth.logout":"登出","user.create":"创建用户","user.update":"更新用户","user.disable":"禁用用户","user.enable":"启用用户","user.extend":"延期用户","user.delete":"删除用户","key.create":"上传密钥","key.rename":"重命名密钥","key.revoke":"吊销密钥","approval.submit":"提交申请","approval.review":"审批申请","lifecycle.expire":"到期锁定","lifecycle.recycle":"回收删除"};function i(e){return r[e]||e}function o(e){try{return JSON.stringify(JSON.parse(e))}catch{return e}}export{i as a,s as b,n as d,a as f,o as p,u};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ws_usernode 用户管理</title>
<script type="module" crossorigin src="/assets/index-DU8VsiSb.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C0mNeYZw.css">
<script type="module" crossorigin src="/assets/index-OD70Gdif.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BgNExOi6.css">
</head>
<body>
<div id="app"></div>