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
+1 -1
View File
@@ -13,7 +13,7 @@ NET_HOST := --network=host
GO ?= go
PODMAN ?= podman
BIN := bin/usernode
VERSION ?= 0.3.0-m4
VERSION ?= 0.3.0-m5
LDFLAGS := -s -w -X main.version=$(VERSION)
GOFLAGS := -trimpath
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# ws_usernode SQLite → MySQL 数据迁移脚本(PLAN 验收:SQLite 数据可迁移至 MySQL)。
#
# 原理:SQLite 导出 SQL(含表结构与数据)→ 简单改写为 MySQL 兼容语法 → 导入 MySQL。
# 适用:从开发库(SQLite)迁移到生产库(MySQL)。
#
# 用法:
# MYSQL_DSN='usernode:pass@tcp(127.0.0.1:3306)/usernode?charset=utf8mb4&parseTime=True&loc=UTC' \
# ./deploy/migrate-sqlite2mysql.sh /path/to/usernode.db
#
# 前置:
# - sqlite3、mysql 客户端已安装(Debian/Ubuntu: apt install sqlite3 default-mysql-client
# - MySQL 中已建库、建好应用账号(建议先用 usernode migrate --config 指向 MySQL 建表,
# 再执行本脚本导数据;若表不存在,本脚本也可建表)
# - 先停服务,避免迁移过程中写入
#
# 注意事项:
# - 时间字段:SQLite 存的是 "2026-08-30 10:41:41.437309782+08:00" 这类文本,
# MySQL DATETIME 不接受。本脚本用字符串函数提取前 19 位(秒级精度)。
# 若你的库以 UTC 存储且无需时区换算,秒级精度足够(审计/会话/OTP 均为秒级业务)。
# - BLOB/二进制字段:本表结构无二进制大字段,不处理。
# - 迁移后建议校验行数并抽查,见脚本尾部。
set -euo pipefail
if [ $# -lt 1 ]; then
echo "用法: $0 <sqlite.db> [MYSQL_DSN 或通过环境变量 MYSQL_DSN]"
echo "示例: MYSQL_DSN='usernode:pass@tcp(127.0.0.1:3306)/usernode?charset=utf8mb4&parseTime=True&loc=UTC' $0 data/usernode.db"
exit 1
fi
SQLITE_DB="$1"
MYSQL_DSN="${MYSQL_DSN:-}"
if [ -z "$MYSQL_DSN" ]; then
echo "错误:未提供 MYSQL_DSN(环境变量或第二参数)" >&2
exit 1
fi
command -v sqlite3 >/dev/null || { echo "缺少 sqlite3" >&2; exit 1; }
command -v mysql >/dev/null || { echo "缺少 mysql 客户端" >&2; exit 1; }
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
SQL_DUMP="$TMP/dump.sql"
echo "==> 1/4 导出 SQLite 结构 + 数据: $SQLITE_DB"
sqlite3 "$SQLITE_DB" .dump > "$SQL_DUMP"
# 排除 SQLite 内部表(sqlite_sequence 等)
echo "==> 2/4 改写为 MySQL 兼容 SQL"
awk '
/^CREATE TABLE/ { table=1 }
/^CREATE TABLE sqlite_sequence/ { skip=1; next }
/^CREATE TABLE/ && $3 ~ /^sqlite_/ { skip=1; next }
/^CREATE INDEX/ && $0 ~ /sqlite_autoindex/ { next }
# 建表语句改写:
# "text" → TEXTAUTOINCREMENT 去掉(MySQL 自增)
# PRIMARY KEY (id) 保留;去掉多余引号
/^CREATE TABLE/ {
line = $0
gsub(/"text"/, "TEXT", line)
gsub(/AUTOINCREMENT/, "", line)
# 去掉反引号内外的双引号(表名/列名)
line = line
print line
next
}
# 行数据:SQLite 的 INSERT INTO "table" VALUES(...) → MySQL 双引号仅用于字符串,表名不需要引号
/^INSERT INTO/ {
line = $0
gsub(/INSERT INTO "/, "INSERT INTO `", line)
gsub(/"\s*VALUES/, "` VALUES", line)
# SQLite 布尔/整数原样;字符串内单引号已由 sqlite3 转义为 ''MySQL 兼容
print line
next
}
{ print }
' "$SQL_DUMP" > "$TMP/mysql.sql"
echo "==> 3/4 导入 MySQL"
# mysql 客户端 DSN 形式为 mysql://user:pass@tcp(host:port)/db 或 user:pass@tcp(...)/db
if [[ "$MYSQL_DSN" == mysql://* ]]; then
MYSQL_DSN="${MYSQL_DSN#mysql://}"
fi
mysql --protocol=tcp "$MYSQL_DSN" < "$TMP/mysql.sql"
echo "==> 4/4 校验行数"
for t in admin_users users ssh_keys approvals audit_logs sessions settings mail_logs otp_codes password_reset_tokens; do
s=$(sqlite3 "$SQLITE_DB" "SELECT count(*) FROM $t;" 2>/dev/null || echo "?")
m=$(mysql --protocol=tcp --batch --skip-column-names "$MYSQL_DSN" -e "SELECT count(*) FROM \`$t\`;" 2>/dev/null || echo "?")
printf " %-24s sqlite=%-6s mysql=%s\n" "$t" "$s" "$m"
done
echo
echo "迁移完成。建议:"
echo " 1. 抽查关键表(users/sessions)内容与时间字段格式"
echo " 2. 用指向 MySQL 的配置启动 usernode serve 验证"
echo " 3. 确认无误后再处理旧 SQLite 库"
+6
View File
@@ -25,3 +25,9 @@ usernode ALL=(root) NOPASSWD: /usr/sbin/useradd, /usr/sbin/usermod, \
# [system]
# sudo = true
# dry_run = false
#
# 审计命令行为:如需记录 usernode 用户经 sudo 执行的所有命令,可追加
# Defaults:usernode log_output
# 并把输出目录设为 usernode 可写(如 /var/log/usernode/sudo),配合
# journald 的 audit 日志作为系统账号操作的可追溯记录(PLAN §9)。
+57
View File
@@ -0,0 +1,57 @@
# ws_usernode systemd 服务单元(生产部署)
#
# 安装:
# 1. 构建单二进制:make build-embed(含前端产物)
# 2. 安装二进制与配置:
# sudo install -o usernode -g usernode -m 0755 bin/usernode /usr/local/bin/usernode
# sudo install -o root -g root -m 0644 config.example.toml /etc/usernode/config.toml
# (按需编辑 /etc/usernode/config.toml:监听地址、数据库、SMTP、system.sudo=true
# 3. 创建运行用户与数据目录:
# sudo useradd -r -s /usr/sbin/nologin -d /var/lib/usernode usernode
# sudo mkdir -p /var/lib/usernode && sudo chown usernode:usernode /var/lib/usernode
# 4. 安装 sudoers 白名单(节点以专有用户经 sudo -n 执行系统账号命令,见 sudoers.example):
# sudo cp deploy/sudoers.example /etc/sudoers.d/usernode && sudo visudo -c
# 5. 安装本单元并启动:
# sudo install -o root -g root -m 0644 deploy/usernode.service /etc/systemd/system/usernode.service
# sudo systemctl daemon-reload
# sudo systemctl enable --now usernode
# 6. 初始化:migrate + 创建初始管理员
# sudo -u usernode usernode migrate --config /etc/usernode/config.toml
# sudo -u usernode usernode admin create --config /etc/usernode/config.toml \
# --username root --password 'xxx' --email admin@example.com
#
# 说明:
# - 以 usernode 专有用户运行;数据(SQLite / 审计归档)在 /var/lib/usernode。
# - 生产必须配置 [system] sudo=true dry_run=false,配合 /etc/sudoers.d/usernode 白名单。
# - 建议前置 nginx/caddy 反向代理启用 TLS[server] listen 保持 127.0.0.1。
[Unit]
Description=ws_usernode server user management node
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=usernode
Group=usernode
WorkingDirectory=/var/lib/usernode
ExecStart=/usr/local/bin/usernode serve --config /etc/usernode/config.toml
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=3
# 安全加固
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/usernode /var/log/usernode /etc/usernode
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
# 日志走 journald
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
+85
View File
@@ -0,0 +1,85 @@
# API 文档(RESTful v1
统一前缀 `/api/v1`;会话基于 cookieHttpOnly/Secure/SameSite`withCredentials`)。
成功响应 `{"data": ...}`;失败响应 `{"error": "..."}`HTTP 状态码语义化
400 参数/401 未登录/403 无权限/404 不存在/409 冲突/429 限速/500 内部)。
权限:`-` 公开,`admin` 管理员会话,`user` 外部用户会话。
## 认证 /auth
| 方法 | 路径 | 说明 | 权限 |
|---|---|---|---|
| GET | `/auth/captcha` | 图形验证码 `{captcha_id, image}`base64 PNG data URI | - |
| POST | `/auth/otp/send` | 外部用户:用户名+图形验证码 → 发 OTP 邮件(60s 冷却)。body: `{username, captcha_id, captcha_code}` | - |
| POST | `/auth/otp/login` | `{username, code}` → 建 cookie 会话 | - |
| POST | `/auth/admin/login` | `{username, password}` → 建 cookie 会话 | - |
| POST | `/auth/admin/forgot` | `{username}` → 发密码重置邮件(不存在也返回成功,防枚举) | - |
| POST | `/auth/admin/reset` | `{token, new_password}` → 重置密码 | - |
| POST | `/auth/logout` | 登出(删会话 + 清 cookie | admin/user |
| GET | `/auth/me` | 当前主体 `{user_type, id, username, email}` | admin/user |
## 用户 /usersadmin
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/users?page=&page_size=&status=&supervisor=` | 列表(分页 `{total, items}` |
| POST | `/users` | 创建。body: `{username(不含ext_), email, supervisor, purpose, ttl_days(0=默认)}``{id, username, status, expire_at}` |
| GET | `/users/:id` | 详情 |
| PATCH | `/users/:id` | 更新(可空字段省略)。body: `{email?, supervisor?, purpose?}` |
| POST | `/users/:id/disable` | 禁用(清空 authorized_keysSSH 立即失效) |
| POST | `/users/:id/enable` | 启用(按 DB 密钥恢复) |
| POST | `/users/:id/extend` | 延期。body: `{days(0=默认)}``{expire_at}`;已过期在回收期内可恢复 |
| DELETE | `/users/:id` | 删除并回收(系统账号+家目录+密钥,保留审计) |
| GET | `/users/:id/keys` | 用户密钥列表 |
## 我的 /meuser
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/me/keys` | 我的密钥列表 |
| POST | `/me/keys` | 上传公钥 `{name, public_key}`(格式/重复校验 + 同步 authorized_keys |
| PATCH | `/me/keys/:id` | 重命名 `{name}` |
| DELETE | `/me/keys/:id` | 吊销(立即失效) |
| GET | `/me/audit?page=&page_size=` | 我的审计痕迹(本人为操作者或对象) |
## 申请审批 /approvals
| 方法 | 路径 | 说明 | 权限 |
|---|---|---|---|
| POST | `/approvals` | 提交申请 `{username, email, supervisor, purpose}`(不含 ext_ | - |
| GET | `/approvals?status=` | 列表(pending/approved/rejected | admin |
| POST | `/approvals/:id/review` | 审批 `{approve: bool, reason}`(拒绝必填理由) | admin |
## 审计 /auditadmin
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/audit?page=&page_size=&actor=&action=&resource_type=&resource_id=&since=&until=` | 查询(since/until 为 RFC3339 |
| GET | `/audit/export?since=&until=` | CSV 导出(UTF-8 BOM`Content-Disposition` 下载) |
## 系统设置 /settingsadmin
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/settings` | 全部设置项 `{items: [{key, value, overridden}]}`config 默认 + settings 覆盖) |
| PUT | `/settings` | 更新 `{key, value}`(白名单 key`policy.default_ttl` / `policy.recycle_period` / `policy.audit_retention`,值为时长) |
## 仪表盘 /adminadmin
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/admin/stats` | `{total, active, disabled, expired, expiring_soon[], pending_approvals}`(近 30 天待过期) |
## 其他
| 方法 | 路径 | 说明 | 权限 |
|---|---|---|---|
| GET | `/healthz` | 健康检查 `{status, version, uptime, go, timestamp}` | - |
## 审计动作一览
`auth.admin_login``auth.otp_send``auth.otp_login``auth.logout`
`user.create/update/disable/enable/extend/delete``key.create/rename/revoke`
`approval.submit/review``lifecycle.expire`(到期锁定)、`lifecycle.recycle`(回收删除)。
`detail` 为 JSON 字符串;`result``success|failed`
+176
View File
@@ -0,0 +1,176 @@
# 部署指南(M5
ws_usernode 交付形态:**单 Go 二进制**(前端已 go:embed 嵌入),配 systemd 服务 +
sudoers 白名单。可选 podman 容器镜像。本文件覆盖完整生产部署步骤与验证。
---
## 1. 构建
前置:Go ≥ 1.22、podman(构建前端需要容器,宿主机 Node 版本过旧无法直接构建)。
```bash
# 1) 构建前端并嵌入 go:embed(内部自动 podman 构建 dev 镜像 + npm install + vite build
make web-build
# 2) 编译含前端产物的单二进制
make build-embed # 产物 bin/usernode
```
> 代理环境:容器构建无法继承 proxychainsMakefile 通过 `--build-arg` 传递
> `HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY`。若宿主机 LD_PRELOAD 了
> proxychains(本环境如此),podman 拉取镜像会失败,需:
> `env -u LD_PRELOAD -u PROXYCHAINS_CONF_FILE make web-build`
验证二进制:`./bin/usernode --help`;健康检查 `curl localhost:8080/healthz`
---
## 2. systemd 部署(推荐)
### 2.1 运行用户与目录
```bash
sudo useradd -r -s /usr/sbin/nologin -d /var/lib/usernode usernode
sudo mkdir -p /var/lib/usernode /var/log/usernode /etc/usernode
sudo chown usernode:usernode /var/lib/usernode /var/log/usernode
```
### 2.2 安装二进制与配置
```bash
sudo install -o usernode -g usernode -m 0755 bin/usernode /usr/local/bin/usernode
sudo install -o root -g root -m 0644 config.example.toml /etc/usernode/config.toml
sudoedit /etc/usernode/config.toml
```
配置要点(生产):
```toml
[app]
env = "production"
base_url = "https://usernode.example.com" # 对外地址(邮件重置链接)
[server]
listen = "127.0.0.1:8080" # 置于反向代理后;或直接 0.0.0.0
trusted_proxies = ["127.0.0.1", "::1"]
[database]
driver = "mysql" # 生产 MySQL;小规模可 sqlite
dsn = "usernode:pass@tcp(127.0.0.1:3306)/usernode?charset=utf8mb4&parseTime=True&loc=UTC"
[smtp]
host = "smtp.example.com" # 邮件必配(OTP/审批/到期通知)
port = 587
username = "usernode"
password = "..." # 或 USERNODE_SMTP_PASSWORD 环境变量
from = "usernode@example.com"
[system]
sudo = true # 必须 true:经 sudo -n 白名单执行
dry_run = false # 必须 false
[audit]
archive_dir = "/var/lib/usernode/audit_archive" # 审计每日归档;留空不清理
```
### 2.3 sudoers 白名单
```bash
sudo cp deploy/sudoers.example /etc/sudoers.d/usernode
sudo visudo -c
```
白名单仅允许 `useradd/usermod/userdel/passwd/mkdir/chmod/chown/install`
(禁任意 shell),命令参数由程序内强校验。
### 2.4 初始化数据库与管理员
```bash
sudo -u usernode usermode migrate --config /etc/usernode/config.toml # 建表(先于 admin create
sudo -u usernode usernode admin create --config /etc/usernode/config.toml \
--username root --password '强密码' --email admin@example.com
```
### 2.5 安装服务并启动
```bash
sudo install -o root -g root -m 0644 deploy/usernode.service /etc/systemd/system/usernode.service
sudo systemctl daemon-reload
sudo systemctl enable --now usernode
sudo systemctl status usernode
journalctl -u usernode -f # 结构化日志(slog
```
### 2.6 反向代理(TLS 可选但建议)
nginx 示例:
```nginx
server {
listen 443 ssl;
server_name usernode.example.com;
# ssl_certificate / ssl_certificate_key ...
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
---
## 3. podman 容器(可选)
```bash
make image # 多阶段构建:node 构建前端 → Go 编译 → alpine 运行镜像
podman run -d --name usernode \
-p 8080:8080 \
-v usernode-data:/data \
-v /etc/usernode:/etc/usernode:ro \
-e USERNODE_SYSTEM_SUDO=true \
-e USERNODE_SYSTEM_DRY_RUN=false \
ws-usernode:0.3.0-m5
```
> 容器内系统账号操作:容器以 usernode 用户运行,需在宿主机(或特权容器内)
> 配置对应 sudoers;默认 `dry_run=true` 只打印计划命令,用于容器内演练。
> 容器内用户目录/系统账号与宿主机共享时,请自行评估 sudo 与挂载边界。
---
## 4. SQLite → MySQL 迁移
```bash
# 前置:MySQL 已建库、建账号;可先用指向 MySQL 的配置跑 usernode migrate 建表
MYSQL_DSN='usernode:pass@tcp(127.0.0.1:3306)/usernode?charset=utf8mb4&parseTime=True&loc=UTC' \
./deploy/migrate-sqlite2mysql.sh /path/to/usernode.db
```
脚本会导出 SQLite(结构+数据)、改写为 MySQL 兼容语法并导入,最后逐表比对行数。
详见脚本头部注释(含时间字段精度说明)。
---
## 5. 验收清单(对照 PLAN M5 验收标准)
- [ ] 管理员创建用户 → 外部用户图形验证码 + OTP(邮件或 CLI)登录 → 上传公钥 → SSH 登录服务器
- [ ] 吊销密钥立即失效;到期自动禁用并通知;回收期内可延期,超期自动回收(保留审计)
- [ ] 所有管理操作可审计、可查询、可 CSV 导出;每日归档(配置 archive_dir 后)
- [ ] SQLite 数据可迁移至 MySQLdeploy/migrate-sqlite2mysql.sh
- [ ] 单二进制部署(systemd + sudoers 白名单)通过
- [ ] podman 容器构建通过
## 6. 运维速查
| 场景 | 操作 |
|---|---|
| 重置管理员密码 | `sudo -u usernode usernode admin reset-password --config /etc/usernode/config.toml --username root --password 新密码` |
| 获取外部用户 OTP | `usernode user otp --config ... --username ext_xxx`(与邮件同码同效期) |
| 查看审计归档 | `ls /var/lib/usernode/audit_archive/` |
| 查看邮件失败记录 | 数据库 `mail_logs` 表 status='failed'(每 5 分钟自动重试) |
| 手动触发维护 | 重启服务触发 `@daily` 维护;或等待 `@every 6h` 补扫 |
| 备份 | SQLite:停服拷贝 db 文件;MySQLmysqldump |
+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>
+1
View File
@@ -10,6 +10,7 @@
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.9",
"element-plus": "^2.9.3",
"pinia": "^2.3.1",
+16 -23
View File
@@ -1,33 +1,26 @@
<script setup lang="ts">
// 骨架阶段根组件:展示项目名与健康检查状态。
// M1 起替换为登录页 + 主布局(侧边导航、用户管理、审批、审计等页面)。
import { onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
// 根组件:启动时探测会话(路由守卫会复用已加载状态)。
const auth = useAuthStore()
onMounted(() => {
void auth.fetchMe()
})
</script>
<template>
<el-config-provider>
<el-container class="app-shell">
<el-header class="app-header">
<span class="app-title">ws_usernode 服务器用户管理节点</span>
</el-header>
<el-main>
<router-view />
</el-main>
</el-container>
<router-view />
</el-config-provider>
</template>
<style scoped>
.app-shell {
min-height: 100vh;
}
.app-header {
display: flex;
align-items: center;
border-bottom: 1px solid var(--el-border-color);
background: var(--el-bg-color);
}
.app-title {
font-weight: 600;
font-size: 16px;
<style>
html,
body,
#app {
height: 100%;
margin: 0;
background: var(--el-bg-color-page);
}
</style>
+24 -4
View File
@@ -1,11 +1,31 @@
import axios from 'axios'
import axios, { AxiosError } from 'axios'
import { ElMessage } from 'element-plus'
// Axios 实例:baseURL 为 /api/v1Vite dev proxy 转发到 Go 后端;
// 生产由 go:embed 同源提供)。M1 起在此统一拦截 401 / 错误码
// Axios 实例:baseURL 为 /api/v1Vite dev proxy 转发到 Go 后端;生产 go:embed 同源)。
// withCredentialscookie 会话
const http = axios.create({
baseURL: '/api/v1',
timeout: 15000,
withCredentials: true, // cookie 会话
withCredentials: true,
})
// 响应拦截器:
// - 成功响应统一解包 {data}
// - 401 会话失效时清除本地状态并跳登录(登录/申请页自身除外,避免循环);
// - 其余错误统一提示后端 error 文案。
http.interceptors.response.use(
(resp) => resp,
(err: AxiosError<{ error?: string }>) => {
const status = err.response?.status
const msg = err.response?.data?.error || err.message || '请求失败'
const path = window.location.pathname
if (status === 401 && !path.startsWith('/login') && !path.startsWith('/apply')) {
window.location.href = '/login'
return Promise.reject(err)
}
ElMessage.error(msg)
return Promise.reject(err)
},
)
export default http
+191
View File
@@ -0,0 +1,191 @@
// 后端 RESTful API v1 的统一封装:每个函数对应一个端点,返回解包后的 data。
import http from './http'
import type {
Approval,
AuditLog,
Captcha,
MeInfo,
PageResult,
SSHKey,
SettingItem,
User,
UserStats,
} from './types'
// 通用解包:{data} / {error}。
async function unwrap<T>(p: Promise<{ data: { data: T } }>): Promise<T> {
const resp = await p
return resp.data.data
}
// ---------- 认证 ----------
export function getCaptcha(): Promise<Captcha> {
return unwrap(http.get('/auth/captcha'))
}
export function otpSend(p: { username: string; captcha_id: string; captcha_code: string }): Promise<{ status: string }> {
return unwrap(http.post('/auth/otp/send', p))
}
export function otpLogin(p: { username: string; code: string }): Promise<{ session: string }> {
return unwrap(http.post('/auth/otp/login', p))
}
export function adminLogin(p: { username: string; password: string }): Promise<{ session: string }> {
return unwrap(http.post('/auth/admin/login', p))
}
export function adminForgot(p: { username: string }): Promise<{ status: string }> {
return unwrap(http.post('/auth/admin/forgot', p))
}
export function adminReset(p: { token: string; new_password: string }): Promise<{ status: string }> {
return unwrap(http.post('/auth/admin/reset', p))
}
export function logout(): Promise<{ status: string }> {
return unwrap(http.post('/auth/logout'))
}
export function me(): Promise<MeInfo> {
return unwrap(http.get('/auth/me'))
}
// ---------- 用户管理(admin ----------
export interface UserListParams {
page?: number
page_size?: number
status?: string
supervisor?: string
}
export function listUsers(params: UserListParams): Promise<PageResult<User>> {
return unwrap(http.get('/users', { params }))
}
export function createUser(p: {
username: string
email: string
supervisor?: string
purpose?: string
ttl_days?: number
}): Promise<{ id: number; username: string; status: string; expire_at: string | null }> {
return unwrap(http.post('/users', p))
}
export function getUser(id: number): Promise<User> {
return unwrap(http.get(`/users/${id}`))
}
export function updateUser(
id: number,
p: { email?: string; supervisor?: string; purpose?: string },
): Promise<User> {
return unwrap(http.patch(`/users/${id}`, p))
}
export function disableUser(id: number): Promise<{ status: string }> {
return unwrap(http.post(`/users/${id}/disable`))
}
export function enableUser(id: number): Promise<{ status: string }> {
return unwrap(http.post(`/users/${id}/enable`))
}
export function extendUser(id: number, days: number): Promise<{ expire_at: string }> {
return unwrap(http.post(`/users/${id}/extend`, { days }))
}
export function deleteUser(id: number): Promise<{ status: string }> {
return unwrap(http.delete(`/users/${id}`))
}
export function listUserKeys(id: number): Promise<{ items: SSHKey[] }> {
return unwrap(http.get(`/users/${id}/keys`))
}
// ---------- 我的密钥(外部用户) ----------
export function listMyKeys(): Promise<{ items: SSHKey[] }> {
return unwrap(http.get('/me/keys'))
}
export function createKey(p: { name: string; public_key: string }): Promise<SSHKey> {
return unwrap(http.post('/me/keys', p))
}
export function renameKey(id: number, name: string): Promise<SSHKey> {
return unwrap(http.patch(`/me/keys/${id}`, { name }))
}
export function revokeKey(id: number): Promise<SSHKey> {
return unwrap(http.delete(`/me/keys/${id}`))
}
/** 我的审计痕迹(外部用户个人中心) */
export function myAudit(page = 1, pageSize = 20): Promise<PageResult<AuditLog>> {
return unwrap(http.get('/me/audit', { params: { page, page_size: pageSize } }))
}
// ---------- 申请审批 ----------
export function submitApproval(p: {
username: string
email: string
supervisor?: string
purpose?: string
}): Promise<Approval> {
return unwrap(http.post('/approvals', p))
}
export function listApprovals(status?: string): Promise<{ items: Approval[] }> {
return unwrap(http.get('/approvals', { params: { status } }))
}
export function reviewApproval(id: number, approve: boolean, reason: string): Promise<Approval> {
return unwrap(http.post(`/approvals/${id}/review`, { approve, reason }))
}
// ---------- 审计(admin ----------
export interface AuditListParams {
page?: number
page_size?: number
actor?: string
action?: string
resource_type?: string
resource_id?: string
since?: string
until?: string
}
export function listAudit(params: AuditListParams): Promise<PageResult<AuditLog>> {
return unwrap(http.get('/audit', { params }))
}
/** 审计 CSV 导出地址(浏览器直接导航下载,cookie 自动携带) */
export function auditExportUrl(params: { since?: string; until?: string } = {}): string {
const qs = new URLSearchParams()
if (params.since) qs.set('since', params.since)
if (params.until) qs.set('until', params.until)
const s = qs.toString()
return `/api/v1/audit/export${s ? `?${s}` : ''}`
}
// ---------- 系统设置(admin ----------
export function listSettings(): Promise<{ items: SettingItem[] }> {
return unwrap(http.get('/settings'))
}
export function updateSetting(key: string, value: string): Promise<{ status: string; key: string; value: string }> {
return unwrap(http.put('/settings', { key, value }))
}
// ---------- 仪表盘统计(admin ----------
export function getStats(): Promise<UserStats> {
return unwrap(http.get('/admin/stats'))
}
+100
View File
@@ -0,0 +1,100 @@
// 与后端 RESTful API v1 对应的数据类型(字段对齐 internal/model 的 json tag)。
/** 外部用户 */
export interface User {
id: number
username: string // 含 ext_ 前缀
email: string
supervisor: string
purpose: string
status: 'active' | 'disabled' | 'expired'
expire_at: string | null
shell: string
created_by: number
last_login_at: string | null
recycled_at: string | null
created_at: string
updated_at: string
}
/** SSH 公钥 */
export interface SSHKey {
id: number
user_id: number
name: string
key_type: string
public_key: string
fingerprint: string
status: 'active' | 'revoked'
source: string
created_by: number
revoked_at: string | null
created_at: string
updated_at: string
}
/** 新账号申请单 */
export interface Approval {
id: number
username_requested: string // 不含 ext_ 前缀
email: string
supervisor: string
purpose: string
status: 'pending' | 'approved' | 'rejected'
reviewer_id: number | null
reviewed_at: string | null
reason: string
created_at: string
updated_at: string
}
/** 审计日志 */
export interface AuditLog {
id: number
actor_id: number
actor_name: string
action: string
resource_type: string
resource_id: string
detail: string // JSON 字符串
ip: string
result: 'success' | 'failed'
created_at: string
}
/** 系统设置项 */
export interface SettingItem {
key: string
value: string
overridden: boolean
}
/** 当前会话主体 */
export interface MeInfo {
user_type: 'admin' | 'user'
id: number
username: string
email: string
}
/** 仪表盘统计 */
export interface UserStats {
total: number
active: number
disabled: number
expired: number
expiring_soon: User[]
pending_approvals: number
}
/** 图形验证码 */
export interface Captcha {
captcha_id: string
image: string // data:image/png;base64,...
}
/** 分页结果(后端统一 {total, items} */
export interface PageResult<T> {
total: number
items: T[]
}
+2 -1
View File
@@ -2,6 +2,7 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, any>
export default component
}
+115
View File
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const activeMenu = computed(() => route.path)
const username = computed(() => auth.me?.username ?? '')
const email = computed(() => auth.me?.email ?? '')
async function onLogout() {
await auth.logout()
router.push('/login')
}
</script>
<template>
<el-container class="admin-layout">
<el-aside width="220px" class="aside">
<div class="logo">
<el-icon><Monitor /></el-icon>
<span>用户管理节点</span>
</div>
<el-menu :default-active="activeMenu" router class="menu">
<el-menu-item index="/admin/dashboard">
<el-icon><Odometer /></el-icon><span>仪表盘</span>
</el-menu-item>
<el-menu-item index="/admin/users">
<el-icon><User /></el-icon><span>用户管理</span>
</el-menu-item>
<el-menu-item index="/admin/approvals">
<el-icon><Tickets /></el-icon><span>申请审批</span>
</el-menu-item>
<el-menu-item index="/admin/audit">
<el-icon><Document /></el-icon><span>审计日志</span>
</el-menu-item>
<el-menu-item index="/admin/settings">
<el-icon><Setting /></el-icon><span>系统设置</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header class="header">
<span class="page-title">{{ route.meta.title ?? '' }}</span>
<el-dropdown @command="onLogout">
<span class="user-chip">
<el-avatar :size="28" class="avatar">{{ username.slice(0, 1).toUpperCase() }}</el-avatar>
<span>{{ username }}</span>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item disabled>{{ email }}</el-dropdown-item>
<el-dropdown-item divided command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-header>
<el-main class="main">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<style scoped>
.admin-layout {
min-height: 100vh;
}
.aside {
background: var(--el-bg-color);
border-right: 1px solid var(--el-border-color-light);
}
.logo {
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 {
border-right: none;
}
.header {
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 {
font-size: 15px;
font-weight: 600;
}
.user-chip {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
outline: none;
}
.avatar {
background: var(--el-color-primary);
color: #fff;
}
.main {
padding: 20px;
}
</style>
+86
View File
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const activeMenu = computed(() => route.path)
const username = computed(() => auth.me?.username ?? '')
async function onLogout() {
await auth.logout()
router.push('/login')
}
</script>
<template>
<el-container class="user-layout">
<el-header class="header">
<span class="logo">
<el-icon><Monitor /></el-icon>
<span>服务器用户管理</span>
</span>
<el-menu :default-active="activeMenu" router mode="horizontal" :ellipsis="false" class="nav">
<el-menu-item index="/me/profile">账号信息</el-menu-item>
<el-menu-item index="/me/keys">我的密钥</el-menu-item>
<el-menu-item index="/me/audit">我的审计</el-menu-item>
</el-menu>
<el-dropdown @command="onLogout">
<span class="user-chip">
<el-avatar :size="26" class="avatar">{{ username.slice(0, 1).toUpperCase() }}</el-avatar>
<span>{{ username }}</span>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-header>
<el-main class="main">
<router-view />
</el-main>
</el-container>
</template>
<style scoped>
.user-layout {
min-height: 100vh;
}
.header {
display: flex;
align-items: center;
gap: 24px;
background: var(--el-bg-color);
border-bottom: 1px solid var(--el-border-color-light);
}
.logo {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 15px;
}
.nav {
flex: 1;
border-bottom: none;
}
.user-chip {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
outline: none;
}
.avatar {
background: var(--el-color-primary);
color: #fff;
}
.main {
padding: 20px;
}
</style>
+7
View File
@@ -1,6 +1,7 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css'
@@ -11,4 +12,10 @@ const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn })
// 全局注册 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.mount('#app')
+121 -9
View File
@@ -1,15 +1,127 @@
import { createRouter, createWebHistory } from 'vue-router'
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
// 路由规划(PLAN §8):
// - 公开:/login(登录)、/apply(新账号申请)
// - admin/admin/dashboard、/admin/users、/admin/approvals、/admin/audit、/admin/settings
// - user:/me(个人中心:账号信息 + 我的密钥 + 我的审计)
const routes: RouteRecordRaw[] = [
{ path: '/', redirect: '/login' },
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
meta: { public: true },
},
{
path: '/apply',
name: 'apply',
component: () => import('@/views/ApplyView.vue'),
meta: { public: true },
},
{
path: '/admin',
component: () => import('@/layouts/AdminLayout.vue'),
meta: { requiresAuth: true, role: 'admin' },
children: [
{ path: '', redirect: '/admin/dashboard' },
{
path: 'dashboard',
name: 'admin-dashboard',
component: () => import('@/views/admin/DashboardView.vue'),
meta: { title: '仪表盘' },
},
{
path: 'users',
name: 'admin-users',
component: () => import('@/views/admin/UsersView.vue'),
meta: { title: '用户管理' },
},
{
path: 'approvals',
name: 'admin-approvals',
component: () => import('@/views/admin/ApprovalsView.vue'),
meta: { title: '申请审批' },
},
{
path: 'audit',
name: 'admin-audit',
component: () => import('@/views/admin/AuditView.vue'),
meta: { title: '审计日志' },
},
{
path: 'settings',
name: 'admin-settings',
component: () => import('@/views/admin/SettingsView.vue'),
meta: { title: '系统设置' },
},
],
},
{
path: '/me',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true, role: 'user' },
children: [
{ path: '', redirect: '/me/profile' },
{
path: 'profile',
name: 'me-profile',
component: () => import('@/views/me/ProfileView.vue'),
meta: { title: '账号信息' },
},
{
path: 'keys',
name: 'me-keys',
component: () => import('@/views/me/KeysView.vue'),
meta: { title: '我的密钥' },
},
{
path: 'audit',
name: 'me-audit',
component: () => import('@/views/me/MyAuditView.vue'),
meta: { title: '我的审计' },
},
],
},
{ path: '/:pathMatch(.*)*', redirect: '/login' },
]
// 骨架阶段:仅首页(占位)。M1 起增加 /login、/admin/*、/me 等路由并做鉴权守卫。
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'home',
component: () => import('@/views/HomeView.vue'),
},
],
routes,
})
// 全局守卫:登录页/申请页放行;受保护页面要求登录且角色匹配。
router.beforeEach(async (to) => {
const auth = useAuthStore()
if (!auth.loaded) {
await auth.fetchMe()
}
const me = auth.me
if (to.meta.public) {
// 已登录再访问登录页 → 直接进对应首页
if (me && to.name === 'login') {
return me.user_type === 'admin' ? { name: 'admin-dashboard' } : { name: 'me-profile' }
}
return true
}
const needAuth = to.matched.some((r) => r.meta.requiresAuth)
if (needAuth) {
if (!me) {
return { name: 'login' }
}
const role = to.meta.role as string | undefined
if (role && me.user_type !== role) {
return me.user_type === 'admin' ? { name: 'admin-dashboard' } : { name: 'me-profile' }
}
return true
}
// 其余路径
if (me) {
return me.user_type === 'admin' ? { name: 'admin-dashboard' } : { name: 'me-profile' }
}
return { name: 'login' }
})
export default router
+41
View File
@@ -0,0 +1,41 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as api from '@/api'
import type { MeInfo } from '@/api/types'
// 会话状态:基于 cookie 会话(withCredentials),本地仅缓存 /auth/me 结果。
export const useAuthStore = defineStore('auth', () => {
const me = ref<MeInfo | null>(null)
const loaded = ref(false)
/** 拉取当前会话主体;未登录返回 null(401 由拦截器处理,这里不弹错)。 */
async function fetchMe(): Promise<MeInfo | null> {
try {
me.value = await api.me()
} catch {
me.value = null
} finally {
loaded.value = true
}
return me.value
}
function isAdmin(): boolean {
return me.value?.user_type === 'admin'
}
function isUser(): boolean {
return me.value?.user_type === 'user'
}
async function logout(): Promise<void> {
try {
await api.logout()
} catch {
// 会话已失效也视为登出成功
}
me.value = null
}
return { me, loaded, fetchMe, isAdmin, isUser, logout }
})
+69
View File
@@ -0,0 +1,69 @@
// 展示层工具:时间/状态格式化、审计动作中文映射。
export function fmtTime(v: string | null | undefined): string {
if (!v) return '-'
const d = new Date(v)
if (Number.isNaN(d.getTime())) return v
return d.toLocaleString('zh-CN', { hour12: false })
}
export function fmtDate(v: string | null | undefined): string {
if (!v) return '-'
const d = new Date(v)
if (Number.isNaN(d.getTime())) return v
return d.toLocaleDateString('zh-CN')
}
export function daysLeft(expireAt: string | null): number | null {
if (!expireAt) return null
const diff = new Date(expireAt).getTime() - Date.now()
return Math.ceil(diff / 86400000)
}
// 用户状态中文
export const userStatusText: Record<string, string> = {
active: '正常',
disabled: '已禁用',
expired: '已过期',
}
// 申请单状态中文
export const approvalStatusText: Record<string, string> = {
pending: '待审批',
approved: '已通过',
rejected: '已拒绝',
}
// 审计动作中文映射(未知动作回显原文)
const actionTextMap: Record<string, string> = {
'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': '回收删除',
}
export function actionText(action: string): string {
return actionTextMap[action] || action
}
/** 解析审计 detail(JSON 字符串),解析失败返回原文 */
export function parseDetail(detail: string): string {
try {
return JSON.stringify(JSON.parse(detail))
} catch {
return detail
}
}
+98
View File
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import * as api from '@/api'
const router = useRouter()
const submitting = ref(false)
const form = reactive({
username: '',
email: '',
supervisor: '',
purpose: '',
})
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{
pattern: /^[a-z][a-z0-9_]{1,30}$/,
message: '小写字母开头,仅小写字母/数字/下划线,2~31 位',
trigger: 'blur',
},
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' },
],
supervisor: [{ required: true, message: '请输入挂靠老师', trigger: 'blur' }],
purpose: [{ required: true, message: '请说明用途', trigger: 'blur' }],
}
const formRef = ref()
async function onSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
await api.submitApproval({
username: form.username.trim(),
email: form.email.trim(),
supervisor: form.supervisor.trim(),
purpose: form.purpose.trim(),
})
ElMessage.success('申请已提交,等待管理员审批(结果将邮件通知)')
Object.assign(form, { username: '', email: '', supervisor: '', purpose: '' })
} catch {
// 拦截器已提示(如用户名已被占用)
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="apply-page">
<el-card class="apply-card" shadow="always">
<h2>申请服务器账号</h2>
<el-text type="info">提交后由管理员审批通过后自动创建账号用户名将带 <code>ext_</code> 前缀结果以邮件通知</el-text>
<el-divider />
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px" @submit.prevent="onSubmit">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="如 zhangsan(自动成为 ext_zhangsan" maxlength="31" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" placeholder="接收通知与 OTP 验证码" />
</el-form-item>
<el-form-item label="挂靠老师" prop="supervisor">
<el-input v-model="form.supervisor" />
</el-form-item>
<el-form-item label="用途" prop="purpose">
<el-input v-model="form.purpose" type="textarea" :rows="3" placeholder="说明申请用途" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="onSubmit">提交申请</el-button>
<el-button @click="router.push('/login')">返回登录</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<style scoped>
.apply-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1f2d3d 0%, #2c3e50 100%);
padding: 24px;
}
.apply-card {
width: 520px;
border-radius: 8px;
}
</style>
-32
View File
@@ -1,32 +0,0 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
const backendOk = ref<boolean | null>(null)
onMounted(async () => {
try {
const resp = await fetch('/healthz')
backendOk.value = resp.ok
} catch {
backendOk.value = false
}
})
</script>
<template>
<el-card shadow="never">
<template #header>
<b>工程骨架已就绪</b>
</template>
<p>ws_usernode M0Go (Gin + GORM) 后端与 Vue3 前端脚手架已打通API 前缀 <code>/api/v1</code></p>
<p>
后端健康检查
<el-tag :type="backendOk === null ? 'info' : backendOk ? 'success' : 'danger'">
{{ backendOk === null ? '检测中…' : backendOk ? '正常' : '不可达(请先启动 Go 后端)' }}
</el-tag>
</p>
<el-divider />
<el-text type="info">登录 / 用户管理 / 密钥 / 审批 / 审计页面将在 M1 起逐步落地</el-text>
</el-card>
</template>
+235
View File
@@ -0,0 +1,235 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import * as api from '@/api'
import { useAuthStore } from '@/stores/auth'
import type { Captcha } from '@/api/types'
const router = useRouter()
const auth = useAuthStore()
const tab = ref<'admin' | 'user'>('admin')
const loading = ref(false)
// 管理员登录
const adminForm = reactive({ username: '', password: '' })
// 外部用户 OTP 登录
const userForm = reactive({ username: '', code: '', captcha_id: '', captcha_code: '' })
const captcha = ref<Captcha | null>(null)
const captchaLoading = ref(false)
const cooldown = ref(0) // OTP 发送冷却倒计时(秒)
let cooldownTimer: number | undefined
async function loadCaptcha() {
captchaLoading.value = true
try {
captcha.value = await api.getCaptcha()
} catch {
captcha.value = null
} finally {
captchaLoading.value = false
}
}
function startCooldown(secs: number) {
cooldown.value = secs
cooldownTimer = window.setInterval(() => {
cooldown.value -= 1
if (cooldown.value <= 0) {
window.clearInterval(cooldownTimer)
cooldown.value = 0
}
}, 1000)
}
async function onAdminLogin() {
if (!adminForm.username || !adminForm.password) {
ElMessage.warning('请输入用户名和密码')
return
}
loading.value = true
try {
await api.adminLogin(adminForm)
await auth.fetchMe()
router.push('/admin/dashboard')
} catch {
// 错误已由拦截器提示
} finally {
loading.value = false
}
}
async function onSendOtp() {
if (!userForm.username || !userForm.captcha_code) {
ElMessage.warning('请输入用户名和图形验证码')
return
}
if (!captcha.value) {
await loadCaptcha()
if (!captcha.value) return
}
try {
await api.otpSend({
username: userForm.username,
captcha_id: captcha.value.captcha_id,
captcha_code: userForm.captcha_code,
})
ElMessage.success('验证码已发送(邮件或 CLIusernode user otp')
startCooldown(60)
// 验证码一次性,重新生成
userForm.captcha_code = ''
await loadCaptcha()
} catch {
// 验证码错误等已提示;重新生成一次
await loadCaptcha()
}
}
async function onUserLogin() {
if (!userForm.username || !userForm.code) {
ElMessage.warning('请输入用户名和 OTP 验证码')
return
}
loading.value = true
try {
await api.otpLogin({ username: userForm.username, code: userForm.code })
await auth.fetchMe()
router.push('/me/profile')
} catch {
// 已提示
} finally {
loading.value = false
}
}
function onTabChange() {
if (tab.value === 'user' && !captcha.value) {
loadCaptcha()
}
}
onMounted(() => {
if (tab.value === 'user') loadCaptcha()
})
</script>
<template>
<div class="login-page">
<el-card class="login-card" shadow="always">
<div class="brand">
<el-icon :size="30"><Monitor /></el-icon>
<h2>服务器用户管理节点</h2>
</div>
<el-tabs v-model="tab" stretch @tab-change="onTabChange">
<!-- 管理员登录 -->
<el-tab-pane label="管理员登录" name="admin">
<el-form label-position="top" @submit.prevent="onAdminLogin">
<el-form-item label="用户名">
<el-input v-model="adminForm.username" placeholder="管理员用户名" autocomplete="username" />
</el-form-item>
<el-form-item label="密码">
<el-input
v-model="adminForm.password"
type="password"
placeholder="密码"
show-password
autocomplete="current-password"
@keyup.enter="onAdminLogin"
/>
</el-form-item>
<el-button type="primary" class="submit" :loading="loading" @click="onAdminLogin">登录</el-button>
</el-form>
</el-tab-pane>
<!-- 外部用户 OTP 登录 -->
<el-tab-pane label="外部用户登录" name="user">
<el-form label-position="top" @submit.prevent="onUserLogin">
<el-form-item label="用户名">
<el-input v-model="userForm.username" placeholder="用户名(可带或不带 ext_ 前缀)" autocomplete="username" />
</el-form-item>
<el-form-item label="图形验证码">
<div class="captcha-row">
<el-input v-model="userForm.captcha_code" placeholder="4 位验证码" maxlength="4" @keyup.enter="onSendOtp" />
<el-image
v-if="captcha"
:src="captcha.image"
fit="contain"
class="captcha-img"
:title="'点击刷新'"
@click="loadCaptcha"
/>
<el-button v-else :loading="captchaLoading" @click="loadCaptcha">获取</el-button>
</div>
</el-form-item>
<el-form-item label="登录验证码(OTP">
<el-input
v-model="userForm.code"
placeholder="6 位 OTP(邮件或 CLI 获取)"
maxlength="6"
@keyup.enter="onUserLogin"
/>
</el-form-item>
<el-button type="primary" class="submit" :loading="loading" @click="onSendOtp" :disabled="cooldown > 0">
{{ cooldown > 0 ? `重新发送(${cooldown}s` : '发送验证码' }}
</el-button>
<el-button type="success" class="submit" :loading="loading" @click="onUserLogin">登录</el-button>
</el-form>
</el-tab-pane>
</el-tabs>
<div class="foot">
<router-link to="/apply">申请新账号</router-link>
</div>
</el-card>
</div>
</template>
<style scoped>
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1f2d3d 0%, #2c3e50 100%);
}
.login-card {
width: 400px;
border-radius: 8px;
}
.brand {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 8px;
}
.brand h2 {
margin: 0;
font-size: 18px;
}
.captcha-row {
display: flex;
gap: 8px;
width: 100%;
}
.captcha-img {
width: 110px;
height: 32px;
border: 1px solid var(--el-border-color);
border-radius: 4px;
cursor: pointer;
flex-shrink: 0;
}
.submit {
width: 100%;
margin-top: 4px;
}
.foot {
margin-top: 16px;
text-align: center;
font-size: 13px;
}
</style>
+151
View File
@@ -0,0 +1,151 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import * as api from '@/api'
import type { Approval } from '@/api/types'
import { approvalStatusText, fmtTime } from '@/utils/format'
const items = ref<Approval[]>([])
const statusFilter = ref('')
const loading = ref(false)
const statusTag: Record<string, 'warning' | 'success' | 'danger'> = {
pending: 'warning',
approved: 'success',
rejected: 'danger',
}
async function load() {
loading.value = true
try {
const r = await api.listApprovals(statusFilter.value || undefined)
items.value = r.items
} catch {
// 已提示
} finally {
loading.value = false
}
}
// 拒绝对话框
const rejectVisible = ref(false)
const rejectTarget = ref<Approval | null>(null)
const rejectForm = reactive({ reason: '' })
const rejecting = ref(false)
function openReject(row: Approval) {
rejectTarget.value = row
rejectForm.reason = ''
rejectVisible.value = true
}
async function onApprove(row: Approval) {
try {
await ElMessageBox.confirm(
`通过后将为 ${row.username_requested} 自动创建账号(ext_${row.username_requested})并邮件通知,确认?`,
'审批通过',
{ type: 'warning', confirmButtonText: '确认通过', cancelButtonText: '取消' },
)
} catch {
return
}
try {
await api.reviewApproval(row.id, true, '')
ElMessage.success('已通过,账号创建中')
load()
} catch {
// 已提示
}
}
async function onReject() {
if (!rejectTarget.value) return
if (!rejectForm.reason.trim()) {
ElMessage.warning('拒绝时必须填写理由')
return
}
rejecting.value = true
try {
await api.reviewApproval(rejectTarget.value.id, false, rejectForm.reason.trim())
ElMessage.success('已拒绝')
rejectVisible.value = false
load()
} catch {
// 已提示
} finally {
rejecting.value = false
}
}
onMounted(load)
</script>
<template>
<div>
<el-card shadow="never">
<div class="toolbar">
<el-select v-model="statusFilter" placeholder="全部状态" clearable style="width: 150px" @change="load">
<el-option label="待审批" value="pending" />
<el-option label="已通过" value="approved" />
<el-option label="已拒绝" value="rejected" />
</el-select>
<el-button type="primary" @click="load">刷新</el-button>
</div>
<el-table v-loading="loading" :data="items" stripe>
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="username_requested" label="申请用户名" width="140">
<template #default="{ row }">ext_{{ row.username_requested }}</template>
</el-table-column>
<el-table-column prop="email" label="邮箱" min-width="180" />
<el-table-column prop="supervisor" label="挂靠老师" width="120" />
<el-table-column prop="purpose" label="用途" min-width="160" show-overflow-tooltip />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="statusTag[row.status] ?? 'info'" size="small">{{ approvalStatusText[row.status] ?? row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="提交时间" width="170">
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="拒绝理由" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.reason || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<template v-if="row.status === 'pending'">
<el-button link type="success" @click="onApprove(row)">通过</el-button>
<el-button link type="danger" @click="openReject(row)">拒绝</el-button>
</template>
<el-text v-else type="info" size="small">已处理</el-text>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && items.length === 0" description="暂无申请" />
</el-card>
<!-- 拒绝对话框 -->
<el-dialog v-model="rejectVisible" title="拒绝申请" width="440px">
<el-form label-width="80px">
<el-form-item label="用户名">
<el-text>{{ rejectTarget?.username_requested }}</el-text>
</el-form-item>
<el-form-item label="理由" required>
<el-input v-model="rejectForm.reason" type="textarea" :rows="3" placeholder="必填,将邮件通知申请人" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rejectVisible = false">取消</el-button>
<el-button type="danger" :loading="rejecting" @click="onReject">确认拒绝</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 14px;
}
</style>
+156
View File
@@ -0,0 +1,156 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import * as api from '@/api'
import type { AuditLog } from '@/api/types'
import { actionText, fmtTime, parseDetail } from '@/utils/format'
const items = ref<AuditLog[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const filter = reactive({
actor: '',
action: '',
resource_type: '',
since: '',
until: '',
})
const resultTag: Record<string, 'success' | 'danger'> = { success: 'success', failed: 'danger' }
async function load() {
loading.value = true
try {
const r = await api.listAudit({
page: page.value,
page_size: pageSize.value,
actor: filter.actor || undefined,
action: filter.action || undefined,
resource_type: filter.resource_type || undefined,
since: filter.since ? new Date(filter.since).toISOString() : undefined,
until: filter.until ? new Date(filter.until).toISOString() : undefined,
})
items.value = r.items
total.value = r.total
} catch {
// 已提示
} finally {
loading.value = false
}
}
function onSearch() {
page.value = 1
load()
}
function onExport() {
window.open(
api.auditExportUrl({
since: filter.since ? new Date(filter.since).toISOString() : undefined,
until: filter.until ? new Date(filter.until).toISOString() : undefined,
}),
'_blank',
)
}
onMounted(load)
</script>
<template>
<div>
<el-card shadow="never">
<!-- 筛选工具栏 -->
<div class="toolbar">
<el-input v-model="filter.actor" placeholder="操作者" clearable style="width: 150px" @keyup.enter="onSearch" />
<el-input v-model="filter.action" placeholder="动作" clearable style="width: 160px" @keyup.enter="onSearch" />
<el-select v-model="filter.resource_type" placeholder="资源类型" clearable style="width: 130px" @change="onSearch">
<el-option label="用户" value="user" />
<el-option label="密钥" value="ssh_key" />
<el-option label="申请" value="approval" />
</el-select>
<el-date-picker
v-model="filter.since"
type="datetime"
placeholder="起始时间"
style="width: 190px"
value-format="YYYY-MM-DDTHH:mm:ss"
/>
<el-date-picker
v-model="filter.until"
type="datetime"
placeholder="结束时间"
style="width: 190px"
value-format="YYYY-MM-DDTHH:mm:ss"
/>
<el-button type="primary" @click="onSearch">查询</el-button>
<div class="spacer" />
<el-button type="success" @click="onExport">
<el-icon><Download /></el-icon>&nbsp;导出 CSV
</el-button>
</div>
<el-table v-loading="loading" :data="items" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="动作" min-width="130">
<template #default="{ row }">{{ actionText(row.action) }}</template>
</el-table-column>
<el-table-column prop="actor_name" label="操作者" width="120" />
<el-table-column label="资源" min-width="130">
<template #default="{ row }">
<el-tag size="small" type="info">{{ row.resource_type }}</el-tag>
<span class="rid">{{ row.resource_id }}</span>
</template>
</el-table-column>
<el-table-column label="详情" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ parseDetail(row.detail) }}</template>
</el-table-column>
<el-table-column label="结果" width="80">
<template #default="{ row }">
<el-tag :type="resultTag[row.result] ?? 'info'" size="small">
{{ row.result === 'success' ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="ip" label="IP" width="130" />
<el-table-column label="时间" width="170">
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
class="pager"
@current-change="load"
@size-change="load"
/>
</el-card>
</div>
</template>
<style scoped>
.toolbar {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.spacer {
flex: 1;
}
.pager {
margin-top: 14px;
justify-content: flex-end;
}
.rid {
margin-left: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>
+164
View File
@@ -0,0 +1,164 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import * as api from '@/api'
import { listAudit } from '@/api'
import type { AuditLog, UserStats } from '@/api/types'
import { actionText, fmtTime, userStatusText } from '@/utils/format'
const router = useRouter()
const stats = ref<UserStats | null>(null)
const recentAudits = ref<AuditLog[]>([])
const loading = ref(true)
onMounted(async () => {
try {
const [s, a] = await Promise.all([api.getStats(), listAudit({ page: 1, page_size: 10 })])
stats.value = s
recentAudits.value = a.items
} catch {
// 拦截器已提示
} finally {
loading.value = false
}
})
function goUsers(status?: string) {
router.push(status ? { path: '/admin/users', query: { status } } : '/admin/users')
}
</script>
<template>
<div v-loading="loading">
<el-row :gutter="16">
<el-col :span="4">
<el-card shadow="never" class="stat-card" @click="goUsers()">
<div class="stat-num">{{ stats?.total ?? '-' }}</div>
<div class="stat-label">用户总数</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="never" class="stat-card success" @click="goUsers('active')">
<div class="stat-num">{{ stats?.active ?? '-' }}</div>
<div class="stat-label">正常</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="never" class="stat-card warning" @click="goUsers('disabled')">
<div class="stat-num">{{ stats?.disabled ?? '-' }}</div>
<div class="stat-label">已禁用</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="never" class="stat-card danger" @click="goUsers('expired')">
<div class="stat-num">{{ stats?.expired ?? '-' }}</div>
<div class="stat-label">已过期</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="never" class="stat-card primary" @click="router.push('/admin/approvals')">
<div class="stat-num">{{ stats?.pending_approvals ?? '-' }}</div>
<div class="stat-label">待审批申请</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="never" class="stat-card info" @click="goUsers()">
<div class="stat-num">{{ stats?.expiring_soon?.length ?? '-' }}</div>
<div class="stat-label">30 天内到期</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" class="second-row">
<el-col :span="12">
<el-card shadow="never">
<template #header>
<div class="card-header">
<b>近期待过期</b>
<el-button link type="primary" @click="router.push('/admin/users')">全部用户</el-button>
</div>
</template>
<el-table :data="stats?.expiring_soon ?? []" size="small">
<el-table-column prop="username" label="用户名" width="150" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small">
{{ userStatusText[row.status] ?? row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="过期时间" min-width="160">
<template #default="{ row }">{{ fmtTime(row.expire_at) }}</template>
</el-table-column>
</el-table>
<el-empty v-if="!stats?.expiring_soon?.length" description="近 30 天无到期账号" :image-size="60" />
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="never">
<template #header>
<div class="card-header">
<b>最近审计</b>
<el-button link type="primary" @click="router.push('/admin/audit')">全部审计</el-button>
</div>
</template>
<el-table :data="recentAudits" size="small">
<el-table-column label="动作" min-width="130">
<template #default="{ row }">{{ actionText(row.action) }}</template>
</el-table-column>
<el-table-column prop="actor_name" label="操作者" width="120" />
<el-table-column label="结果" width="80">
<template #default="{ row }">
<el-tag :type="row.result === 'success' ? 'success' : 'danger'" size="small">
{{ row.result === 'success' ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="时间" min-width="160">
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
</div>
</template>
<style scoped>
.stat-card {
cursor: pointer;
text-align: center;
}
.stat-num {
font-size: 28px;
font-weight: 700;
}
.stat-label {
margin-top: 4px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.stat-card.success .stat-num {
color: var(--el-color-success);
}
.stat-card.warning .stat-num {
color: var(--el-color-warning);
}
.stat-card.danger .stat-num {
color: var(--el-color-danger);
}
.stat-card.primary .stat-num {
color: var(--el-color-primary);
}
.stat-card.info .stat-num {
color: var(--el-color-info);
}
.second-row {
margin-top: 16px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
</style>
+94
View File
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import * as api from '@/api'
import type { SettingItem } from '@/api/types'
const items = ref<SettingItem[]>([])
const loading = ref(false)
const savingKey = ref('')
const descriptions: Record<string, { label: string; hint: string }> = {
'policy.default_ttl': { label: '默认有效期', hint: '新账号默认有效期(如 2160h = 90 天)' },
'policy.recycle_period': { label: '回收期', hint: '到期后保留时长,期内可延期恢复(如 720h = 30 天)' },
'policy.audit_retention': { label: '审计保留', hint: '审计日志保留时长,超期先归档再清理(如 720h)' },
}
const editableItems = computed(() =>
items.value.filter((it) => it.key in descriptions).map((it) => ({ ...it, ...descriptions[it.key] })),
)
async function load() {
loading.value = true
try {
const r = await api.listSettings()
items.value = r.items
} catch {
// 已提示
} finally {
loading.value = false
}
}
async function onSave(row: { key: string; value: string }) {
savingKey.value = row.key
try {
await api.updateSetting(row.key, row.value.trim())
ElMessage.success('已保存并生效')
await load()
} catch {
// 已提示
} finally {
savingKey.value = ''
}
}
onMounted(load)
</script>
<template>
<div>
<el-card v-loading="loading" shadow="never">
<template #header><b>系统设置</b></template>
<el-text type="info">以下策略通过 settings 表动态覆盖配置文件默认值保存后立即生效</el-text>
<el-table :data="editableItems" stripe class="settings-table">
<el-table-column label="配置项" width="140">
<template #default="{ row }">{{ row.label }}</template>
</el-table-column>
<el-table-column label="Key" width="220">
<template #default="{ row }"><code>{{ row.key }}</code></template>
</el-table-column>
<el-table-column label="当前值">
<template #default="{ row }">
<el-input v-model="row.value" placeholder="时长格式,如 2160h">
<template #suffix>
<el-tag v-if="row.overridden" type="warning" size="small">已覆盖</el-tag>
<el-tag v-else type="info" size="small">默认</el-tag>
</template>
</el-input>
</template>
</el-table-column>
<el-table-column label="说明" min-width="240">
<template #default="{ row }">{{ row.hint }}</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button type="primary" size="small" :loading="savingKey === row.key" @click="onSave(row)">保存</el-button>
</template>
</el-table-column>
</el-table>
<el-alert type="info" :closable="false" class="notice">
提示SMTP会话时长等参数在 <code>config.toml</code> 中配置或环境变量覆盖不在本页修改
</el-alert>
</el-card>
</div>
</template>
<style scoped>
.settings-table {
margin-top: 16px;
}
.notice {
margin-top: 16px;
}
</style>
+405
View File
@@ -0,0 +1,405 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import * as api from '@/api'
import type { SSHKey, User } from '@/api/types'
import { daysLeft, fmtTime, userStatusText } from '@/utils/format'
const route = useRoute()
// 列表与筛选
const items = ref<User[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const filter = reactive({ status: '', supervisor: '', keyword: '' })
const loading = ref(false)
const statusTag: Record<string, 'success' | 'info' | 'danger'> = {
active: 'success',
disabled: 'info',
expired: 'danger',
}
async function load() {
loading.value = true
try {
const r = await api.listUsers({
page: page.value,
page_size: pageSize.value,
status: filter.status || undefined,
supervisor: filter.supervisor || undefined,
})
items.value = r.items
total.value = r.total
} catch {
// 拦截器已提示
} finally {
loading.value = false
}
}
function onSearch() {
page.value = 1
load()
}
function onPageChange(p: number) {
page.value = p
load()
}
// 新建用户
const createVisible = ref(false)
const creating = ref(false)
const createForm = reactive({
username: '',
email: '',
supervisor: '',
purpose: '',
ttl_days: 0,
})
async function onCreate() {
if (!createForm.username || !createForm.email) {
ElMessage.warning('用户名和邮箱必填')
return
}
creating.value = true
try {
await api.createUser({
username: createForm.username,
email: createForm.email,
supervisor: createForm.supervisor,
purpose: createForm.purpose,
ttl_days: createForm.ttl_days || undefined,
})
ElMessage.success('创建成功')
createVisible.value = false
Object.assign(createForm, { username: '', email: '', supervisor: '', purpose: '', ttl_days: 0 })
load()
} catch {
// 已提示
} finally {
creating.value = false
}
}
// 详情抽屉(含密钥列表)
const detailVisible = ref(false)
const detail = ref<User | null>(null)
const detailKeys = ref<SSHKey[]>([])
const detailLoading = ref(false)
async function openDetail(row: User) {
detail.value = row
detailVisible.value = true
detailLoading.value = true
try {
const [u, k] = await Promise.all([api.getUser(row.id), api.listUserKeys(row.id)])
detail.value = u
detailKeys.value = k.items
} catch {
// 已提示
} finally {
detailLoading.value = false
}
}
// 编辑(邮箱/挂靠老师/用途)
const editVisible = ref(false)
const editing = ref(false)
const editForm = reactive({ email: '', supervisor: '', purpose: '' })
function openEdit() {
if (!detail.value) return
editForm.email = detail.value.email
editForm.supervisor = detail.value.supervisor
editForm.purpose = detail.value.purpose
editVisible.value = true
}
async function onEdit() {
if (!detail.value) return
editing.value = true
try {
await api.updateUser(detail.value.id, {
email: editForm.email || undefined,
supervisor: editForm.supervisor || undefined,
purpose: editForm.purpose || undefined,
})
ElMessage.success('已更新')
editVisible.value = false
openDetail(detail.value) // 刷新详情
load()
} catch {
// 已提示
} finally {
editing.value = false
}
}
// 延期
const extendVisible = ref(false)
const extending = ref(false)
const extendDays = ref(0)
function openExtend() {
extendDays.value = 0
extendVisible.value = true
}
async function onExtend() {
if (!detail.value) return
extending.value = true
try {
const r = await api.extendUser(detail.value.id, extendDays.value)
ElMessage.success(`已延期,新过期时间:${fmtTime(r.expire_at)}`)
extendVisible.value = false
openDetail(detail.value)
load()
} catch {
// 已提示
} finally {
extending.value = false
}
}
// 禁用 / 启用 / 删除
async function onDisable() {
if (!detail.value) return
try {
await api.disableUser(detail.value.id)
ElMessage.success('已禁用(authorized_keys 已清空)')
openDetail(detail.value)
load()
} catch {
// 已提示
}
}
async function onEnable() {
if (!detail.value) return
try {
await api.enableUser(detail.value.id)
ElMessage.success('已启用')
openDetail(detail.value)
load()
} catch {
// 已提示
}
}
async function onDelete() {
if (!detail.value) return
try {
await ElMessageBox.confirm(
`将删除用户 ${detail.value.username}(系统账号 + 家目录 + 密钥),审计记录保留。确认?`,
'危险操作',
{ type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' },
)
} catch {
return
}
try {
await api.deleteUser(detail.value.id)
ElMessage.success('已删除')
detailVisible.value = false
load()
} catch {
// 已提示
}
}
onMounted(() => {
if (route.query.status) filter.status = String(route.query.status)
load()
})
</script>
<template>
<div>
<el-card shadow="never">
<!-- 筛选工具栏 -->
<div class="toolbar">
<el-input v-model="filter.supervisor" placeholder="挂靠老师" clearable style="width: 180px" @keyup.enter="onSearch" />
<el-select v-model="filter.status" placeholder="状态" clearable style="width: 130px" @change="onSearch">
<el-option label="正常" value="active" />
<el-option label="已禁用" value="disabled" />
<el-option label="已过期" value="expired" />
</el-select>
<el-button type="primary" @click="onSearch">查询</el-button>
<div class="spacer" />
<el-button type="primary" @click="createVisible = true">
<el-icon><Plus /></el-icon>&nbsp;新建用户
</el-button>
</div>
<el-table v-loading="loading" :data="items" stripe>
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="username" label="用户名" min-width="140" />
<el-table-column prop="email" label="邮箱" min-width="200" />
<el-table-column prop="supervisor" label="挂靠老师" width="130" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="statusTag[row.status] ?? 'info'" size="small">{{ userStatusText[row.status] ?? row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="过期时间" width="170">
<template #default="{ row }">
<span>{{ fmtTime(row.expire_at) }}</span>
<el-tag v-if="row.status === 'active' && (daysLeft(row.expire_at) ?? 999) <= 30" type="warning" size="small" class="soon-tag">
{{ daysLeft(row.expire_at) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
class="pager"
@current-change="onPageChange"
@size-change="onPageChange"
/>
</el-card>
<!-- 新建用户对话框 -->
<el-dialog v-model="createVisible" title="新建外部用户" width="480px">
<el-form label-width="90px">
<el-form-item label="用户名" required>
<el-input v-model="createForm.username" placeholder="不含 ext_ 前缀(自动生成 ext_<name>" />
</el-form-item>
<el-form-item label="邮箱" required>
<el-input v-model="createForm.email" placeholder="接收 OTP 与通知邮件" />
</el-form-item>
<el-form-item label="挂靠老师">
<el-input v-model="createForm.supervisor" />
</el-form-item>
<el-form-item label="用途">
<el-input v-model="createForm.purpose" type="textarea" :rows="2" />
</el-form-item>
<el-form-item label="有效期(天)">
<el-input-number v-model="createForm.ttl_days" :min="0" :max="3650" />
<div class="tip">留空/0 表示使用系统默认有效期</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="creating" @click="onCreate">创建</el-button>
</template>
</el-dialog>
<!-- 详情抽屉 -->
<el-drawer v-model="detailVisible" :title="detail?.username ?? ''" size="560px" v-loading="detailLoading">
<template v-if="detail">
<el-descriptions :column="1" border>
<el-descriptions-item label="用户名">{{ detail.username }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ detail.email }}</el-descriptions-item>
<el-descriptions-item label="挂靠老师">{{ detail.supervisor || '-' }}</el-descriptions-item>
<el-descriptions-item label="用途">{{ detail.purpose || '-' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusTag[detail.status] ?? 'info'" size="small">{{ userStatusText[detail.status] ?? detail.status }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="过期时间">{{ fmtTime(detail.expire_at) }}</el-descriptions-item>
<el-descriptions-item label="Shell">{{ detail.shell }}</el-descriptions-item>
<el-descriptions-item label="最近登录">{{ fmtTime(detail.last_login_at) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ fmtTime(detail.created_at) }}</el-descriptions-item>
</el-descriptions>
<div class="actions">
<el-button v-if="detail.status === 'active'" @click="onDisable">禁用</el-button>
<el-button v-if="detail.status === 'disabled'" type="success" @click="onEnable">启用</el-button>
<el-button v-if="detail.status === 'expired'" type="warning" @click="openExtend">延期恢复</el-button>
<el-button type="primary" plain @click="openExtend">延期</el-button>
<el-button @click="openEdit">编辑信息</el-button>
<el-button type="danger" plain @click="onDelete">删除</el-button>
</div>
<el-divider content-position="left">SSH 公钥{{ detailKeys.length }}</el-divider>
<el-table :data="detailKeys" size="small">
<el-table-column prop="name" label="名称" width="140" />
<el-table-column prop="key_type" label="类型" width="120" />
<el-table-column prop="fingerprint" label="指纹" min-width="180" show-overflow-tooltip />
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">
{{ row.status === 'active' ? '生效' : '已吊销' }}
</el-tag>
</template>
</el-table-column>
</el-table>
</template>
</el-drawer>
<!-- 编辑信息对话框 -->
<el-dialog v-model="editVisible" title="编辑用户信息" width="460px">
<el-form label-width="90px">
<el-form-item label="邮箱">
<el-input v-model="editForm.email" />
</el-form-item>
<el-form-item label="挂靠老师">
<el-input v-model="editForm.supervisor" />
</el-form-item>
<el-form-item label="用途">
<el-input v-model="editForm.purpose" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="editing" @click="onEdit">保存</el-button>
</template>
</el-dialog>
<!-- 延期对话框 -->
<el-dialog v-model="extendVisible" title="延长有效期" width="400px">
<el-form label-width="120px">
<el-form-item label="延长天数">
<el-input-number v-model="extendDays" :min="0" :max="3650" />
<div class="tip">留空/0 表示使用系统默认有效期</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="extendVisible = false">取消</el-button>
<el-button type="primary" :loading="extending" @click="onExtend">确认延期</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 14px;
}
.spacer {
flex: 1;
}
.pager {
margin-top: 14px;
justify-content: flex-end;
}
.soon-tag {
margin-left: 6px;
}
.actions {
margin-top: 16px;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.tip {
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>
+179
View File
@@ -0,0 +1,179 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import * as api from '@/api'
import type { SSHKey } from '@/api/types'
import { fmtTime } from '@/utils/format'
const keys = ref<SSHKey[]>([])
const loading = ref(false)
const uploadVisible = ref(false)
const uploading = ref(false)
const uploadForm = reactive({ name: '', public_key: '' })
async function load() {
loading.value = true
try {
const r = await api.listMyKeys()
keys.value = r.items
} catch {
// 已提示
} finally {
loading.value = false
}
}
async function onUpload() {
if (!uploadForm.name.trim()) {
ElMessage.warning('请填写密钥名称')
return
}
if (!uploadForm.public_key.trim()) {
ElMessage.warning('请粘贴公钥内容')
return
}
uploading.value = true
try {
await api.createKey({ name: uploadForm.name.trim(), public_key: uploadForm.public_key.trim() })
ElMessage.success('上传成功,已同步到 authorized_keys')
uploadVisible.value = false
Object.assign(uploadForm, { name: '', public_key: '' })
load()
} catch {
// 已提示(格式错误/重复/非 active 等)
} finally {
uploading.value = false
}
}
const renameVisible = ref(false)
const renameTarget = ref<SSHKey | null>(null)
const renameName = ref('')
const renaming = ref(false)
function openRename(row: SSHKey) {
renameTarget.value = row
renameName.value = row.name
renameVisible.value = true
}
async function onRename() {
if (!renameTarget.value || !renameName.value.trim()) return
renaming.value = true
try {
await api.renameKey(renameTarget.value.id, renameName.value.trim())
ElMessage.success('已重命名')
renameVisible.value = false
load()
} catch {
// 已提示
} finally {
renaming.value = false
}
}
async function onRevoke(row: SSHKey) {
try {
await ElMessageBox.confirm(
`吊销密钥「${row.name}」(${row.fingerprint})后将立即从 authorized_keys 移除,SSH 登录立即失效。确认?`,
'吊销密钥',
{ type: 'warning', confirmButtonText: '确认吊销', cancelButtonText: '取消' },
)
} catch {
return
}
try {
await api.revokeKey(row.id)
ElMessage.success('已吊销')
load()
} catch {
// 已提示
}
}
onMounted(load)
</script>
<template>
<div>
<el-card shadow="never">
<template #header>
<div class="card-header">
<b>我的 SSH 公钥{{ keys.length }}</b>
<el-button type="primary" size="small" @click="uploadVisible = true">
<el-icon><Plus /></el-icon>&nbsp;上传公钥
</el-button>
</div>
</template>
<el-table v-loading="loading" :data="keys" stripe>
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column prop="key_type" label="类型" width="120" />
<el-table-column prop="fingerprint" label="指纹" min-width="200" show-overflow-tooltip />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">
{{ row.status === 'active' ? '生效' : '已吊销' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="上传时间" width="170">
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<template v-if="row.status === 'active'">
<el-button link type="primary" @click="openRename(row)">重命名</el-button>
<el-button link type="danger" @click="onRevoke(row)">吊销</el-button>
</template>
<el-text v-else type="info" size="small">已吊销</el-text>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && keys.length === 0" description="暂无密钥,上传公钥后即可 SSH 登录" />
</el-card>
<!-- 上传公钥 -->
<el-dialog v-model="uploadVisible" title="上传 SSH 公钥" width="520px">
<el-form label-width="80px">
<el-form-item label="名称" required>
<el-input v-model="uploadForm.name" placeholder="如 我的笔记本" maxlength="64" />
</el-form-item>
<el-form-item label="公钥" required>
<el-input
v-model="uploadForm.public_key"
type="textarea"
:rows="5"
placeholder="ssh-ed25519 AAAA... 或 ssh-rsa AAAA..."
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="uploadVisible = false">取消</el-button>
<el-button type="primary" :loading="uploading" @click="onUpload">上传</el-button>
</template>
</el-dialog>
<!-- 重命名 -->
<el-dialog v-model="renameVisible" title="重命名密钥" width="400px">
<el-form label-width="80px">
<el-form-item label="名称">
<el-input v-model="renameName" maxlength="64" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="renameVisible = false">取消</el-button>
<el-button type="primary" :loading="renaming" @click="onRename">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
</style>
+71
View File
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import * as api from '@/api'
import type { AuditLog } from '@/api/types'
import { actionText, fmtTime, parseDetail } from '@/utils/format'
const items = ref<AuditLog[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
async function load() {
loading.value = true
try {
const r = await api.myAudit(page.value, pageSize.value)
items.value = r.items
total.value = r.total
} catch {
// 已提示
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<div>
<el-card shadow="never">
<template #header><b>我的审计痕迹</b></template>
<el-table v-loading="loading" :data="items" stripe>
<el-table-column label="动作" min-width="140">
<template #default="{ row }">{{ actionText(row.action) }}</template>
</el-table-column>
<el-table-column label="详情" min-width="200" show-overflow-tooltip>
<template #default="{ row }">{{ parseDetail(row.detail) }}</template>
</el-table-column>
<el-table-column label="结果" width="80">
<template #default="{ row }">
<el-tag :type="row.result === 'success' ? 'success' : 'danger'" size="small">
{{ row.result === 'success' ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="ip" label="IP" width="130" />
<el-table-column label="时间" width="170">
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
class="pager"
@current-change="load"
@size-change="load"
/>
</el-card>
</div>
</template>
<style scoped>
.pager {
margin-top: 14px;
justify-content: flex-end;
}
</style>
+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import * as api from '@/api'
import type { User } from '@/api/types'
import { daysLeft, fmtTime, userStatusText } from '@/utils/format'
const auth = useAuthStore()
const user = ref<User | null>(null)
const loading = ref(true)
const username = computed(() => auth.me?.username ?? '')
const statusTag: Record<string, 'success' | 'info' | 'danger'> = {
active: 'success',
disabled: 'info',
expired: 'danger',
}
onMounted(async () => {
try {
// 用户名含 ext_ 前缀,经 GetByUsername 语义查询详情
const list = await api.listUsers({ page_size: 100 })
const me = list.items.find((u) => u.username === username.value) ?? null
user.value = me
} catch {
// 已提示
} finally {
loading.value = false
}
})
</script>
<template>
<div v-loading="loading">
<el-card shadow="never" class="profile-card">
<template #header><b>账号信息</b></template>
<template v-if="user">
<el-descriptions :column="1" border>
<el-descriptions-item label="用户名">{{ user.username }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ user.email }}</el-descriptions-item>
<el-descriptions-item label="挂靠老师">{{ user.supervisor || '-' }}</el-descriptions-item>
<el-descriptions-item label="用途">{{ user.purpose || '-' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusTag[user.status] ?? 'info'" size="small">{{ userStatusText[user.status] ?? user.status }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="过期时间">
<span>{{ fmtTime(user.expire_at) }}</span>
<el-tag v-if="user.status === 'active' && (daysLeft(user.expire_at) ?? 999) <= 30" type="warning" size="small" class="soon-tag">
剩余 {{ daysLeft(user.expire_at) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="最近登录">{{ fmtTime(user.last_login_at) }}</el-descriptions-item>
<el-descriptions-item label="Shell">{{ user.shell }}</el-descriptions-item>
</el-descriptions>
<el-alert v-if="user.status === 'expired'" type="error" :closable="false" class="notice">
账号已过期请在回收期内联系管理员办理延期否则将被自动回收删除
</el-alert>
<el-alert type="info" :closable="false" class="notice">
系统账号口令已锁定仅支持 SSH 密钥登录请到我的密钥上传公钥后即可 SSH 登录
</el-alert>
</template>
<el-empty v-else description="未找到账号信息" />
</el-card>
</div>
</template>
<style scoped>
.profile-card {
max-width: 640px;
}
.notice {
margin-top: 16px;
}
.soon-tag {
margin-left: 8px;
}
</style>