feat(M5): 前端完善 + 部署 — Vue3 全量页面、仪表盘统计与我的审计接口、systemd/sudoers/迁移脚本、部署文档
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Executable
+102
@@ -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" → TEXT;AUTOINCREMENT 去掉(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 库"
|
||||
@@ -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)。
|
||||
|
||||
|
||||
@@ -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
@@ -0,0 +1,85 @@
|
||||
# API 文档(RESTful v1)
|
||||
|
||||
统一前缀 `/api/v1`;会话基于 cookie(HttpOnly/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 |
|
||||
|
||||
## 用户 /users(admin)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| 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_keys,SSH 立即失效) |
|
||||
| POST | `/users/:id/enable` | 启用(按 DB 密钥恢复) |
|
||||
| POST | `/users/:id/extend` | 延期。body: `{days(0=默认)}` → `{expire_at}`;已过期在回收期内可恢复 |
|
||||
| DELETE | `/users/:id` | 删除并回收(系统账号+家目录+密钥,保留审计) |
|
||||
| GET | `/users/:id/keys` | 用户密钥列表 |
|
||||
|
||||
## 我的 /me(user)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
|
||||
## 审计 /audit(admin)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| 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` 下载) |
|
||||
|
||||
## 系统设置 /settings(admin)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/settings` | 全部设置项 `{items: [{key, value, overridden}]}`(config 默认 + settings 覆盖) |
|
||||
| PUT | `/settings` | 更新 `{key, value}`(白名单 key:`policy.default_ttl` / `policy.recycle_period` / `policy.audit_retention`,值为时长) |
|
||||
|
||||
## 仪表盘 /admin(admin)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| 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
@@ -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
|
||||
```
|
||||
|
||||
> 代理环境:容器构建无法继承 proxychains,Makefile 通过 `--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 数据可迁移至 MySQL(deploy/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 文件;MySQL:mysqldump |
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// 仪表盘统计接口(admin):GET /admin/stats(PLAN §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)
|
||||
}
|
||||
@@ -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:embed;dev 阶段由 Vite dev server 代理,见 Makefile dev)
|
||||
|
||||
@@ -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 BOM,Excel 直接打开不乱码。
|
||||
func (s *AuditService) ExportCSV(ctx context.Context, w io.Writer, since, until *time.Time) error {
|
||||
|
||||
@@ -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 记录,保证两侧一致。
|
||||
|
||||
@@ -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};
|
||||
@@ -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}
|
||||
@@ -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};
|
||||
@@ -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}
|
||||
@@ -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};
|
||||
@@ -0,0 +1 @@
|
||||
.toolbar[data-v-84a7123e]{display:flex;gap:8px;margin-bottom:14px}
|
||||
@@ -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)}
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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 +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 M0:Go (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};
|
||||
@@ -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};
|
||||
@@ -0,0 +1 @@
|
||||
.card-header[data-v-9032698c]{display:flex;align-items:center;justify-content:space-between}
|
||||
@@ -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}
|
||||
@@ -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("验证码已发送(邮件或 CLI:usernode 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};
|
||||
@@ -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};
|
||||
@@ -0,0 +1 @@
|
||||
.pager[data-v-5c05de0c]{margin-top:14px;justify-content:flex-end}
|
||||
@@ -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}
|
||||
@@ -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};
|
||||
@@ -0,0 +1 @@
|
||||
.settings-table[data-v-90a30993],.notice[data-v-90a30993]{margin-top:16px}
|
||||
@@ -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};
|
||||
@@ -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}
|
||||
@@ -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
@@ -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 _};
|
||||
@@ -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};
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
-92
File diff suppressed because one or more lines are too long
+101
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -1,11 +1,31 @@
|
||||
import axios from 'axios'
|
||||
import axios, { AxiosError } from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// Axios 实例:baseURL 为 /api/v1(Vite dev proxy 转发到 Go 后端;
|
||||
// 生产由 go:embed 同源提供)。M1 起在此统一拦截 401 / 错误码。
|
||||
// Axios 实例:baseURL 为 /api/v1(Vite dev proxy 转发到 Go 后端;生产 go:embed 同源)。
|
||||
// withCredentials:cookie 会话。
|
||||
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
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
Vendored
+2
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 M0:Go (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>
|
||||
@@ -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('验证码已发送(邮件或 CLI:usernode 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>
|
||||
@@ -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>
|
||||
@@ -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> 导出 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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> 新建用户
|
||||
</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>
|
||||
@@ -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> 上传公钥
|
||||
</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user