feat: 账号认证、用户管理与 API Token 管理
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 21s
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 21s
- 后端: Auth(登录/登出/me + session 中间件)、Users、Tokens 路由与 Service, scrypt 哈希+timingSafeEqual 校验、requireAdmin 保护、admin 用户自动初始化 - 前端: login.html(登录页, 无账号密码提示)、admin.html(用户+Token 管理, 令牌明文显隐/复制/吊销/重置密码)、标题栏用户信息+系统管理入口 - 持久化: jsonAdapter 支持 users/api-tokens,运行时数据忽略入库 - 文档: 需求.md/原型设计.md 同步
This commit is contained in:
parent
963046edd9
commit
1765caba50
2
.gitignore
vendored
2
.gitignore
vendored
@ -3,5 +3,7 @@ data/projects.json
|
||||
data/system.json
|
||||
data/configs/*.json
|
||||
data/mcp.json
|
||||
data/users.json
|
||||
data/api-tokens.json
|
||||
*.log
|
||||
.reasonix/*
|
||||
126
docs/原型设计.md
Normal file
126
docs/原型设计.md
Normal file
@ -0,0 +1,126 @@
|
||||
# 页面实现文档:登录 / 项目列表 / 系统管理
|
||||
|
||||
## 1. 页面流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[登录页 login.html] -->|登录成功| B[项目列表页 projects.html]
|
||||
B -->|点击项目卡片| D[配置管理页 index.html]
|
||||
B -->|管理员点击「系统管理」| C[系统管理 admin.html]
|
||||
C -->|返回项目列表| B
|
||||
A -.->|未登录访问任意页面| B
|
||||
B -.->|token 过期 API 返回 401| A
|
||||
```
|
||||
|
||||
## 2. 认证机制
|
||||
|
||||
- 登录成功返回 `{ token, user }`,前端存入 `localStorage`。
|
||||
- 所有 API 请求自动携带 `Authorization: Bearer <token>` 头。
|
||||
- 服务端使用内存 session(`src/routes/auth.js`),TTL 24 小时。
|
||||
- API 返回 401 时前端自动清理凭证并跳转登录页。
|
||||
- **首次启动自动创建默认管理员**:`admin / admin123`(密码经 scrypt 加盐哈希,存于 `data/users.json`)。
|
||||
|
||||
| 角色 | 项目列表 | 进入配置 | 系统管理 |
|
||||
|------|---------|---------|---------|
|
||||
| 管理员 (admin) | ✓ | ✓ | ✓ |
|
||||
| 普通用户 (user) | ✓ | ✓ | ✗(入口隐藏 + 后端 403) |
|
||||
|
||||
## 3. 页面清单(public/ 目录)
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `public/login.html` | 登录页,居中卡片,深蓝渐变背景 |
|
||||
| `public/projects.html` | 项目列表页,卡片网格 + 搜索 + 新建/删除项目 |
|
||||
| `public/admin.html` | 系统管理页,用户管理 + Token 管理两个 Tab |
|
||||
| `public/index.html` | 配置管理页(原有),标题栏增加用户信息/系统管理/退出入口 |
|
||||
|
||||
## 4. 新增 API
|
||||
|
||||
### 4.1 认证
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/api/auth/login` | 登录,Body: `{ username, password }`,返回 `{ token, user }` |
|
||||
| POST | `/api/auth/logout` | 注销,销毁 session |
|
||||
| GET | `/api/auth/me` | 获取当前登录用户 |
|
||||
|
||||
### 4.2 用户管理(需管理员)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/users` | 用户列表(不含密码字段) |
|
||||
| POST | `/api/users` | 新增用户 `{ username, displayName, password, role, enabled }` |
|
||||
| PUT | `/api/users/:id` | 编辑 `{ displayName, role, enabled }` |
|
||||
| PUT | `/api/users/:id/password` | 重置密码 `{ password }` |
|
||||
| DELETE | `/api/users/:id` | 删除用户 |
|
||||
|
||||
### 4.3 Token 管理(需管理员)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/tokens` | Token 列表 |
|
||||
| POST | `/api/tokens` | 生成 `{ name, projectId, permission, expiresInDays }`,返回含明文 value |
|
||||
| PUT | `/api/tokens/:id` | 编辑 `{ name, projectId, permission, enabled }` |
|
||||
| PUT | `/api/tokens/:id/revoke` | 吊销 |
|
||||
| DELETE | `/api/tokens/:id` | 删除 |
|
||||
|
||||
## 5. 数据模型
|
||||
|
||||
`data/` 目录扩展:
|
||||
|
||||
```
|
||||
data/
|
||||
├── users.json # 用户表(password 为 salt:hash)
|
||||
└── api-tokens.json # API Token 表(reg_ 前缀,MCP token 仍由 mcp.json 管理)
|
||||
```
|
||||
|
||||
### 用户 (User)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "a1b2c3d4",
|
||||
"username": "admin",
|
||||
"displayName": "系统管理员",
|
||||
"password": "salt:scrypt-hash",
|
||||
"role": "admin",
|
||||
"enabled": true,
|
||||
"createdAt": "2026-09-11T08:00:00.000Z",
|
||||
"updatedAt": "2026-09-11T08:00:00.000Z",
|
||||
"lastLoginAt": "2026-09-11T09:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### API Token
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "x1y2z3a4",
|
||||
"name": "CI 自动发布",
|
||||
"projectId": "proj-001",
|
||||
"permission": "readwrite",
|
||||
"value": "reg_<hex>",
|
||||
"enabled": true,
|
||||
"createdAt": "2026-09-11T08:00:00.000Z",
|
||||
"expiresAt": "2026-12-10T08:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 目录结构变更
|
||||
|
||||
```
|
||||
src/
|
||||
├── server.js # 挂载 auth/users/tokens 路由 + 全局认证中间件
|
||||
├── routes/
|
||||
│ ├── auth.js # 登录/登出/me + session 管理
|
||||
│ ├── users.js # 用户管理(管理员)
|
||||
│ └── tokens.js # Token 管理(管理员)
|
||||
└── services/
|
||||
├── userService.js # 用户业务逻辑 + scrypt 密码哈希
|
||||
└── tokenService.js # Token 业务逻辑
|
||||
|
||||
public/
|
||||
├── login.html # 登录页
|
||||
├── projects.html # 项目列表页
|
||||
├── admin.html # 系统管理页
|
||||
└── js/api.js # 新增 auth/user/token 接口封装
|
||||
```
|
||||
@ -60,7 +60,7 @@
|
||||
## 3. 非功能需求
|
||||
|
||||
- **NFR-1**:技术栈:Node.js + Express 后端,原生 HTML/CSS/JS 前端
|
||||
- **NFR-2**:后续对接 SSO 单点登录,当前版本暂不实现认证
|
||||
- **NFR-2**:提供登录认证(用户名/密码 + Bearer session token);用户分为管理员(含系统管理权限)与普通用户;后续可对接 SSO 单点登录
|
||||
- **NFR-3**:界面响应式,支持主流浏览器
|
||||
- **NFR-4**:配置数据实时写入持久化存储,保证数据一致性
|
||||
|
||||
|
||||
667
public/admin.html
Normal file
667
public/admin.html
Normal file
@ -0,0 +1,667 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>系统管理 - Registry 配置管理中心</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
.titlebar-right { margin-left: auto; display: flex; align-items: center; gap: 14px; font-size: 12px; }
|
||||
.titlebar-user { color: #e8f1fb; display: flex; align-items: center; gap: 6px; }
|
||||
.role-badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; background: rgba(255,255,255,0.18); border: 1px solid rgba(255,255,255,0.35); }
|
||||
.role-badge.admin { background: #ffb900; border-color: #ffd666; color: #4a3200; font-weight: 600; }
|
||||
.titlebar-link { color: #cfe3f7; text-decoration: none; cursor: pointer; padding-left: 14px; border-left: 1px solid rgba(255,255,255,0.3); }
|
||||
.titlebar-link:hover { color: #fff; text-decoration: underline; }
|
||||
|
||||
.admin-container { flex: 1; display: flex; overflow: hidden; }
|
||||
.admin-nav { width: 200px; background: #fff; border-right: 1px solid #c0c0c0; padding: 10px 0; flex-shrink: 0; }
|
||||
.admin-nav-title { padding: 4px 18px 10px; font-size: 11px; color: #999; letter-spacing: 1px; }
|
||||
.admin-nav-item { display: flex; align-items: center; gap: 8px; height: 38px; padding: 0 18px; cursor: pointer; color: #333; font-size: 13px; border-left: 3px solid transparent; }
|
||||
.admin-nav-item:hover { background: #e5f1fb; }
|
||||
.admin-nav-item.active { background: #cce8ff; color: #003a70; font-weight: 600; border-left-color: #0078d4; }
|
||||
.admin-content { flex: 1; overflow: auto; padding: 18px; }
|
||||
.admin-panel { background: #fff; border: 1px solid #e0e0e0; border-radius: 6px; overflow: hidden; }
|
||||
.admin-panel-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: #f5f5f5; border-bottom: 1px solid #e0e0e0; flex-wrap: wrap; gap: 8px; }
|
||||
.admin-panel-header h2 { font-size: 14px; color: #003a70; }
|
||||
.admin-panel-header .panel-sub { font-weight: normal; font-size: 11px; color: #999; margin-left: 8px; }
|
||||
.admin-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.search-input { height: 30px; width: 200px; padding: 0 10px; border: 1px solid #adadad; border-radius: 3px; font-size: 13px; font-family: inherit; }
|
||||
.filter-select { height: 30px; padding: 0 8px; border: 1px solid #adadad; border-radius: 3px; background: #fff; font-size: 12px; }
|
||||
.tab-panel { display: none; }
|
||||
.tab-panel.active { display: block; }
|
||||
|
||||
.data-table { width: 100%; border-collapse: collapse; }
|
||||
.data-table th { background: #f0f0f0; border-bottom: 1px solid #c0c0c0; border-right: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; font-weight: 600; color: #555; font-size: 12px; white-space: nowrap; }
|
||||
.data-table td { padding: 8px 12px; border-bottom: 1px solid #f0f0f0; font-size: 12px; vertical-align: middle; }
|
||||
.data-table tr:hover td { background: #f8fbff; }
|
||||
.data-table .mono { font-family: Consolas, monospace; color: #0451a5; }
|
||||
|
||||
.role-tag { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; border: 1px solid transparent; }
|
||||
.role-tag.admin { background: #fff3e0; color: #b45309; border-color: #f0c98f; }
|
||||
.role-tag.user { background: #e5f1fb; color: #0078d4; border-color: #bcdcf7; }
|
||||
.status-badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; border: 1px solid transparent; }
|
||||
.badge-active { background: #e8f5e9; color: #2e7d32; border-color: #b5dfb8; }
|
||||
.badge-disabled { background: #fdecea; color: #c62828; border-color: #f2b8b5; }
|
||||
.badge-expired { background: #f0f0f0; color: #777; border-color: #d5d5d5; }
|
||||
|
||||
.dim-chip { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; background: #eef4fb; color: #0a4a86; border: 1px solid #d0dced; }
|
||||
.token-reveal { display: flex; align-items: center; gap: 6px; }
|
||||
.token-value { font-family: Consolas, monospace; color: #1e1e1e; letter-spacing: 1px; }
|
||||
.token-mask { color: #999; letter-spacing: 2px; }
|
||||
.token-btn { border: 1px solid #adadad; background: #fff; border-radius: 3px; padding: 1px 6px; font-size: 11px; cursor: pointer; white-space: nowrap; }
|
||||
.token-btn:hover { background: #e5f1fb; border-color: #5b9bd5; }
|
||||
|
||||
.form-row { display: flex; gap: 12px; }
|
||||
.form-half { flex: 1; }
|
||||
.form-group label.hint { font-weight: normal; color: #999; font-size: 11px; }
|
||||
.hint { font-weight: normal; color: #999; font-size: 11px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="titlebar">
|
||||
<span class="app-title">📋 Registry 配置管理中心</span>
|
||||
<span class="current-project">系统管理</span>
|
||||
<div class="titlebar-right">
|
||||
<span class="titlebar-user">
|
||||
👤 <span id="user-name"></span>
|
||||
<span class="role-badge admin">管理员</span>
|
||||
</span>
|
||||
<a class="titlebar-link" href="/projects.html">← 返回项目列表</a>
|
||||
<a class="titlebar-link" id="logout-link" href="#">退出登录</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="admin-container">
|
||||
<aside class="admin-nav">
|
||||
<div class="admin-nav-title">系统设置</div>
|
||||
<div class="admin-nav-item active" data-tab="users" onclick="switchTab('users')">👥 用户管理</div>
|
||||
<div class="admin-nav-item" data-tab="tokens" onclick="switchTab('tokens')">🔑 Token 管理</div>
|
||||
</aside>
|
||||
|
||||
<div class="admin-content">
|
||||
<!-- 用户管理 -->
|
||||
<div class="tab-panel active" id="panel-users">
|
||||
<div class="admin-panel">
|
||||
<div class="admin-panel-header">
|
||||
<h2>用户管理 <span class="panel-sub">管理系统账号与角色权限</span></h2>
|
||||
<div class="admin-toolbar">
|
||||
<input type="text" class="search-input" id="user-search" placeholder="🔍 搜索用户名">
|
||||
<button class="btn btn-primary" style="height:30px" id="btn-add-user">➕ 新增用户</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow:auto">
|
||||
<table class="data-table" id="user-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户名</th><th>显示名称</th><th>角色</th><th>状态</th>
|
||||
<th>创建时间</th><th>最后登录</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="user-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token 管理 -->
|
||||
<div class="tab-panel" id="panel-tokens">
|
||||
<div class="admin-panel">
|
||||
<div class="admin-panel-header">
|
||||
<h2>Token 管理 <span class="panel-sub">MCP / API 访问令牌</span></h2>
|
||||
<div class="admin-toolbar">
|
||||
<input type="text" class="search-input" id="token-search" placeholder="🔍 搜索 Token 名称">
|
||||
<button class="btn btn-primary" style="height:30px" id="btn-add-token">🔑 生成 Token</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow:auto">
|
||||
<table class="data-table" id="token-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th><th>关联项目</th><th>权限</th><th>Token</th>
|
||||
<th>状态</th><th>过期时间</th><th>创建时间</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="token-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="statusbar">
|
||||
<span id="status-text">系统管理 · 用户管理</span>
|
||||
<span id="status-user"></span>
|
||||
</footer>
|
||||
|
||||
<!-- 新增/编辑用户模态框 -->
|
||||
<div class="modal-overlay" id="modal-user" style="display:none">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3 id="modal-user-title">新增用户</h3>
|
||||
<button class="modal-close" onclick="closeModal('modal-user')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="edit-user-id">
|
||||
<div class="form-group">
|
||||
<label>用户名 <span class="required">*</span></label>
|
||||
<input type="text" id="input-username" placeholder="登录账号,例如 zhangsan">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>显示名称</label>
|
||||
<input type="text" id="input-display-name" placeholder="用于界面展示的名称">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group form-half">
|
||||
<label>角色</label>
|
||||
<select id="input-role"><option value="user">普通用户</option><option value="admin">管理员</option></select>
|
||||
</div>
|
||||
<div class="form-group form-half">
|
||||
<label>状态</label>
|
||||
<select id="input-enabled"><option value="true">启用</option><option value="false">停用</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="password-group">
|
||||
<label>密码 <span class="required">*</span></label>
|
||||
<input type="password" id="input-password" placeholder="首次登录后建议修改">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal('modal-user')">取消</button>
|
||||
<button class="btn btn-primary" id="btn-save-user">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 重置密码模态框 -->
|
||||
<div class="modal-overlay" id="modal-reset-pw" style="display:none">
|
||||
<div class="modal" style="width:360px">
|
||||
<div class="modal-header">
|
||||
<h3>重置密码</h3>
|
||||
<button class="modal-close" onclick="closeModal('modal-reset-pw')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="reset-user-id">
|
||||
<div class="form-group">
|
||||
<label>新密码 <span class="required">*</span></label>
|
||||
<input type="password" id="input-new-password" placeholder="请输入新密码">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal('modal-reset-pw')">取消</button>
|
||||
<button class="btn btn-primary" id="btn-save-reset-pw">确认重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 生成/编辑 Token 模态框 -->
|
||||
<div class="modal-overlay" id="modal-token" style="display:none">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3 id="modal-token-title">生成 Token</h3>
|
||||
<button class="modal-close" onclick="closeModal('modal-token')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="edit-token-id">
|
||||
<div class="form-group">
|
||||
<label>名称 <span class="required">*</span></label>
|
||||
<input type="text" id="input-token-name" placeholder="例如:CI 自动发布">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>关联项目</label>
|
||||
<select id="input-token-project"><option value="all">全部项目</option></select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group form-half">
|
||||
<label>权限范围</label>
|
||||
<select id="input-token-permission"><option value="readonly">只读</option><option value="readwrite">读写</option></select>
|
||||
</div>
|
||||
<div class="form-group form-half">
|
||||
<label>过期时间</label>
|
||||
<select id="input-token-expiry">
|
||||
<option value="30">30 天</option>
|
||||
<option value="90" selected>90 天</option>
|
||||
<option value="365">1 年</option>
|
||||
<option value="0">永不过期</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">生成后 Token 明文仅展示一次,请及时复制并妥善保管。</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal('modal-token')">取消</button>
|
||||
<button class="btn btn-primary" id="btn-save-token">生成</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token 明文展示模态框 -->
|
||||
<div class="modal-overlay" id="modal-token-reveal" style="display:none">
|
||||
<div class="modal" style="width:480px">
|
||||
<div class="modal-header">
|
||||
<h3>Token 已生成</h3>
|
||||
<button class="modal-close" onclick="closeModal('modal-token-reveal')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p style="margin-bottom:12px;color:#666;font-size:12px">请立即复制保存,关闭后将无法再查看完整 Token。</p>
|
||||
<div style="display:flex;gap:8px;align-items:center;background:#f5f5f5;padding:10px 12px;border-radius:4px;border:1px solid #e0e0e0">
|
||||
<code id="reveal-token-value" style="flex:1;word-break:break-all;font-family:Consolas,monospace;font-size:13px;color:#0451a5"></code>
|
||||
<button class="token-btn" id="btn-copy-reveal">复制</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-primary" onclick="closeModal('modal-token-reveal')">我已保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 确认删除模态框 -->
|
||||
<div class="modal-overlay" id="modal-confirm" style="display:none">
|
||||
<div class="modal" style="width:360px">
|
||||
<div class="modal-header">
|
||||
<h3>确认操作</h3>
|
||||
<button class="modal-close" onclick="closeModal('modal-confirm')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body"><p id="confirm-message"></p></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal('modal-confirm')">取消</button>
|
||||
<button class="btn btn-danger" id="btn-confirm-ok">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script>
|
||||
var currentUser = null;
|
||||
var confirmCallback = null;
|
||||
|
||||
function toast(message, type) {
|
||||
var el = document.createElement("div");
|
||||
el.className = "toast toast-" + (type || "info");
|
||||
el.textContent = message;
|
||||
document.body.appendChild(el);
|
||||
setTimeout(function () { el.remove(); }, 2500);
|
||||
}
|
||||
|
||||
function openModal(id) { document.getElementById(id).style.display = "flex"; }
|
||||
window.closeModal = function (id) { document.getElementById(id).style.display = "none"; };
|
||||
|
||||
function openConfirm(message, callback) {
|
||||
document.getElementById("confirm-message").textContent = message;
|
||||
confirmCallback = callback;
|
||||
openModal("modal-confirm");
|
||||
}
|
||||
document.getElementById("btn-confirm-ok").addEventListener("click", function () {
|
||||
closeModal("modal-confirm");
|
||||
if (confirmCallback) confirmCallback();
|
||||
confirmCallback = null;
|
||||
});
|
||||
|
||||
function esc(s) { var d = document.createElement("div"); d.textContent = s; return d.innerHTML; }
|
||||
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return "—";
|
||||
var d = new Date(iso);
|
||||
var pad = function (n) { return n < 10 ? "0" + n : n; };
|
||||
return d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) + " " + pad(d.getHours()) + ":" + pad(d.getMinutes());
|
||||
}
|
||||
|
||||
window.switchTab = function (tab) {
|
||||
document.querySelectorAll(".admin-nav-item").forEach(function (el) { el.classList.toggle("active", el.dataset.tab === tab); });
|
||||
document.querySelectorAll(".tab-panel").forEach(function (el) { el.classList.remove("active"); });
|
||||
document.getElementById("panel-" + tab).classList.add("active");
|
||||
document.getElementById("status-text").textContent = "系统管理 · " + (tab === "users" ? "用户管理" : "Token 管理");
|
||||
}
|
||||
|
||||
// ========== 用户管理 ==========
|
||||
|
||||
var usersCache = [];
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
usersCache = await API.getUsers();
|
||||
renderUsers(usersCache);
|
||||
} catch (err) {
|
||||
toast("加载用户失败: " + err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function renderUsers(users) {
|
||||
var tbody = document.getElementById("user-tbody");
|
||||
if (!users.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#999;padding:30px">暂无用户</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = users.map(function (u) {
|
||||
return "<tr>" +
|
||||
'<td class="mono">' + esc(u.username) + "</td>" +
|
||||
"<td>" + esc(u.displayName || "") + "</td>" +
|
||||
'<td><span class="role-tag ' + u.role + '">' + (u.role === "admin" ? "管理员" : "普通用户") + "</span></td>" +
|
||||
'<td><span class="status-badge ' + (u.enabled ? "badge-active" : "badge-disabled") + '">' + (u.enabled ? "启用" : "停用") + "</span></td>" +
|
||||
"<td>" + fmtDate(u.createdAt) + "</td>" +
|
||||
"<td>" + fmtDate(u.lastLoginAt) + "</td>" +
|
||||
'<td style="white-space:nowrap">' +
|
||||
'<button class="row-btn" onclick="editUserById(\'' + u.id + '\')">编辑</button> ' +
|
||||
'<button class="row-btn" onclick="resetPassword(\'' + u.id + '\')">重置密码</button> ' +
|
||||
(u.enabled
|
||||
? '<button class="row-btn danger" onclick="toggleUser(\'' + u.id + "', false)\">停用</button>"
|
||||
: '<button class="row-btn" onclick="toggleUser(\'' + u.id + "', true)\">启用</button>") +
|
||||
(u.username !== "admin"
|
||||
? ' <button class="row-btn danger" onclick="confirmDeleteUser(\'' + u.id + '\')">删除</button>'
|
||||
: "") +
|
||||
"</td>" +
|
||||
"</tr>";
|
||||
}).join("");
|
||||
}
|
||||
|
||||
document.getElementById("user-search").addEventListener("input", function () {
|
||||
var kw = this.value.trim().toLowerCase();
|
||||
document.querySelectorAll("#user-tbody tr").forEach(function (tr) {
|
||||
tr.style.display = kw && tr.textContent.toLowerCase().indexOf(kw) === -1 ? "none" : "";
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("btn-add-user").addEventListener("click", function () {
|
||||
document.getElementById("modal-user-title").textContent = "新增用户";
|
||||
document.getElementById("edit-user-id").value = "";
|
||||
document.getElementById("input-username").value = "";
|
||||
document.getElementById("input-username").disabled = false;
|
||||
document.getElementById("input-display-name").value = "";
|
||||
document.getElementById("input-role").value = "user";
|
||||
document.getElementById("input-enabled").value = "true";
|
||||
document.getElementById("input-password").value = "";
|
||||
document.getElementById("password-group").style.display = "";
|
||||
openModal("modal-user");
|
||||
});
|
||||
|
||||
window.editUserById = function (id) {
|
||||
var u = usersCache.find(function (x) { return x.id === id; });
|
||||
if (!u) return;
|
||||
document.getElementById("modal-user-title").textContent = "编辑用户";
|
||||
document.getElementById("edit-user-id").value = u.id;
|
||||
document.getElementById("input-username").value = u.username;
|
||||
document.getElementById("input-username").disabled = true;
|
||||
document.getElementById("input-display-name").value = u.displayName || "";
|
||||
document.getElementById("input-role").value = u.role;
|
||||
document.getElementById("input-enabled").value = String(u.enabled);
|
||||
document.getElementById("password-group").style.display = "none";
|
||||
openModal("modal-user");
|
||||
};
|
||||
|
||||
document.getElementById("btn-save-user").addEventListener("click", async function () {
|
||||
var id = document.getElementById("edit-user-id").value;
|
||||
var isNew = !id;
|
||||
try {
|
||||
if (isNew) {
|
||||
var data = {
|
||||
username: document.getElementById("input-username").value,
|
||||
displayName: document.getElementById("input-display-name").value,
|
||||
password: document.getElementById("input-password").value,
|
||||
role: document.getElementById("input-role").value,
|
||||
enabled: document.getElementById("input-enabled").value === "true",
|
||||
};
|
||||
await API.createUser(data);
|
||||
toast("用户创建成功", "success");
|
||||
} else {
|
||||
await API.updateUser(id, {
|
||||
displayName: document.getElementById("input-display-name").value,
|
||||
role: document.getElementById("input-role").value,
|
||||
enabled: document.getElementById("input-enabled").value === "true",
|
||||
});
|
||||
toast("用户已更新", "success");
|
||||
}
|
||||
closeModal("modal-user");
|
||||
loadUsers();
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
});
|
||||
|
||||
window.resetPassword = function (id) {
|
||||
document.getElementById("reset-user-id").value = id;
|
||||
document.getElementById("input-new-password").value = "";
|
||||
openModal("modal-reset-pw");
|
||||
};
|
||||
|
||||
document.getElementById("btn-save-reset-pw").addEventListener("click", async function () {
|
||||
var id = document.getElementById("reset-user-id").value;
|
||||
var pw = document.getElementById("input-new-password").value;
|
||||
try {
|
||||
await API.resetPassword(id, pw);
|
||||
closeModal("modal-reset-pw");
|
||||
toast("密码已重置", "success");
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
});
|
||||
|
||||
window.toggleUser = async function (id, enabled) {
|
||||
try {
|
||||
await API.updateUser(id, { enabled: enabled });
|
||||
toast(enabled ? "用户已启用" : "用户已停用", "success");
|
||||
loadUsers();
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
window.confirmDeleteUser = function (id) {
|
||||
var u = usersCache.find(function (x) { return x.id === id; });
|
||||
var name = u ? u.username : id;
|
||||
openConfirm("确认删除用户「" + name + "」?", async function () {
|
||||
try {
|
||||
await API.deleteUser(id);
|
||||
toast("用户已删除", "success");
|
||||
loadUsers();
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Token 管理 ==========
|
||||
|
||||
async function loadTokens() {
|
||||
try {
|
||||
var tokens = await API.getTokens();
|
||||
renderTokens(tokens);
|
||||
} catch (err) {
|
||||
toast("加载 Token 失败: " + err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjectsForSelect() {
|
||||
try {
|
||||
var projects = await API.getProjects();
|
||||
var sel = document.getElementById("input-token-project");
|
||||
sel.innerHTML = '<option value="all">全部项目</option>' +
|
||||
projects.map(function (p) { return '<option value="' + p.id + '">' + esc(p.name) + "</option>"; }).join("");
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function renderTokens(tokens) {
|
||||
var tbody = document.getElementById("token-tbody");
|
||||
if (!tokens.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:#999;padding:30px">暂无 Token</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = tokens.map(function (t) {
|
||||
var status = "badge-active";
|
||||
var statusText = "有效";
|
||||
if (!t.enabled) { status = "badge-disabled"; statusText = "已吊销"; }
|
||||
else if (t.expiresAt && new Date(t.expiresAt) < new Date()) { status = "badge-expired"; statusText = "已过期"; }
|
||||
var masked = t.value.substring(0, 6) + "••••••••••••";
|
||||
return "<tr>" +
|
||||
"<td>" + esc(t.name) + "</td>" +
|
||||
"<td>" + esc(t.projectId === "all" ? "全部项目" : t.projectId) + "</td>" +
|
||||
'<td><span class="dim-chip">' + (t.permission === "readwrite" ? "读写" : "只读") + "</span></td>" +
|
||||
'<td><div class="token-reveal">' +
|
||||
'<span class="token-value token-mask" data-id="' + t.id + '">' + masked + "</span>" +
|
||||
'<button class="token-btn" onclick="toggleTokenVis(this)">显示</button>' +
|
||||
'<button class="token-btn" onclick="copyTokenVal(this, \'' + t.value + "')\">复制</button>" +
|
||||
"</div></td>" +
|
||||
'<td><span class="status-badge ' + status + '">' + statusText + "</span></td>" +
|
||||
"<td>" + fmtDate(t.expiresAt) + "</td>" +
|
||||
"<td>" + fmtDate(t.createdAt) + "</td>" +
|
||||
'<td style="white-space:nowrap">' +
|
||||
'<button class="row-btn" onclick=\'editToken(' + JSON.stringify({ id: t.id, name: t.name, projectId: t.projectId, permission: t.permission }).replace(/'/g, "'") + ")'>编辑</button> " +
|
||||
(t.enabled
|
||||
? '<button class="row-btn danger" onclick="revokeToken(\'' + t.id + "')\">吊销</button>"
|
||||
: "") +
|
||||
' <button class="row-btn danger" onclick="confirmDeleteToken(\'' + t.id + "', '" + esc(t.name) + "')\">删除</button>" +
|
||||
"</td>" +
|
||||
"</tr>";
|
||||
}).join("");
|
||||
}
|
||||
|
||||
window.toggleTokenVis = function (btn) {
|
||||
var span = btn.parentElement.querySelector(".token-value");
|
||||
if (btn.textContent === "显示") {
|
||||
btn.dataset.masked = span.textContent;
|
||||
// 显示完整值需要从 tbody 原始数据中获取,这里通过 API 重新加载
|
||||
span.classList.remove("token-mask");
|
||||
span.textContent = span.dataset.full || btn.dataset.masked;
|
||||
btn.textContent = "隐藏";
|
||||
// 尝试从隐藏的 data 属性获取完整值
|
||||
var id = span.dataset.id;
|
||||
API.getTokens().then(function (tokens) {
|
||||
var t = tokens.find(function (tk) { return tk.id === id; });
|
||||
if (t) { span.dataset.full = t.value; span.textContent = t.value; }
|
||||
});
|
||||
} else {
|
||||
span.classList.add("token-mask");
|
||||
span.textContent = btn.dataset.masked;
|
||||
btn.textContent = "显示";
|
||||
}
|
||||
};
|
||||
|
||||
window.copyTokenVal = function (btn, val) {
|
||||
if (navigator.clipboard) navigator.clipboard.writeText(val);
|
||||
btn.textContent = "已复制";
|
||||
setTimeout(function () { btn.textContent = "复制"; }, 1200);
|
||||
};
|
||||
|
||||
document.getElementById("token-search").addEventListener("input", function () {
|
||||
var kw = this.value.trim().toLowerCase();
|
||||
document.querySelectorAll("#token-tbody tr").forEach(function (tr) {
|
||||
tr.style.display = kw && tr.textContent.toLowerCase().indexOf(kw) === -1 ? "none" : "";
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("btn-add-token").addEventListener("click", function () {
|
||||
document.getElementById("modal-token-title").textContent = "生成 Token";
|
||||
document.getElementById("edit-token-id").value = "";
|
||||
document.getElementById("input-token-name").value = "";
|
||||
document.getElementById("input-token-project").value = "all";
|
||||
document.getElementById("input-token-permission").value = "readonly";
|
||||
document.getElementById("input-token-expiry").value = "90";
|
||||
document.getElementById("btn-save-token").textContent = "生成";
|
||||
loadProjectsForSelect();
|
||||
openModal("modal-token");
|
||||
});
|
||||
|
||||
window.editToken = function (t) {
|
||||
document.getElementById("modal-token-title").textContent = "编辑 Token";
|
||||
document.getElementById("edit-token-id").value = t.id;
|
||||
document.getElementById("input-token-name").value = t.name;
|
||||
document.getElementById("input-token-project").value = t.projectId;
|
||||
document.getElementById("input-token-permission").value = t.permission;
|
||||
document.getElementById("btn-save-token").textContent = "保存";
|
||||
loadProjectsForSelect();
|
||||
openModal("modal-token");
|
||||
};
|
||||
|
||||
document.getElementById("btn-save-token").addEventListener("click", async function () {
|
||||
var id = document.getElementById("edit-token-id").value;
|
||||
var name = document.getElementById("input-token-name").value.trim();
|
||||
if (!name) { toast("请输入 Token 名称", "error"); return; }
|
||||
try {
|
||||
if (id) {
|
||||
await API.updateToken(id, {
|
||||
name: name,
|
||||
projectId: document.getElementById("input-token-project").value,
|
||||
permission: document.getElementById("input-token-permission").value,
|
||||
});
|
||||
closeModal("modal-token");
|
||||
toast("Token 已更新", "success");
|
||||
loadTokens();
|
||||
} else {
|
||||
var data = await API.createToken({
|
||||
name: name,
|
||||
projectId: document.getElementById("input-token-project").value,
|
||||
permission: document.getElementById("input-token-permission").value,
|
||||
expiresInDays: parseInt(document.getElementById("input-token-expiry").value) || 0,
|
||||
});
|
||||
closeModal("modal-token");
|
||||
document.getElementById("reveal-token-value").textContent = data.value;
|
||||
openModal("modal-token-reveal");
|
||||
toast("Token 已生成", "success");
|
||||
loadTokens();
|
||||
}
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("btn-copy-reveal").addEventListener("click", function () {
|
||||
var val = document.getElementById("reveal-token-value").textContent;
|
||||
if (navigator.clipboard) navigator.clipboard.writeText(val);
|
||||
this.textContent = "已复制";
|
||||
setTimeout(function () { document.getElementById("btn-copy-reveal").textContent = "复制"; }, 1200);
|
||||
});
|
||||
|
||||
window.revokeToken = function (id) {
|
||||
openConfirm("确认吊销此 Token?吊销后使用该 Token 的服务将无法继续访问。", async function () {
|
||||
try {
|
||||
await API.revokeToken(id);
|
||||
toast("Token 已吊销", "success");
|
||||
loadTokens();
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.confirmDeleteToken = function (id, name) {
|
||||
openConfirm("确认删除 Token「" + name + "」?", async function () {
|
||||
try {
|
||||
await API.deleteToken(id);
|
||||
toast("Token 已删除", "success");
|
||||
loadTokens();
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 初始化 ==========
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
var data = await API.getMe();
|
||||
currentUser = data;
|
||||
} catch (e) {
|
||||
window.location.href = "/login.html";
|
||||
return;
|
||||
}
|
||||
if (currentUser.role !== "admin") {
|
||||
window.location.href = "/projects.html";
|
||||
return;
|
||||
}
|
||||
document.getElementById("user-name").textContent = currentUser.displayName || currentUser.username;
|
||||
document.getElementById("status-user").textContent = currentUser.username;
|
||||
await Promise.all([loadUsers(), loadTokens()]);
|
||||
}
|
||||
|
||||
document.getElementById("logout-link").addEventListener("click", async function (e) {
|
||||
e.preventDefault();
|
||||
try { await API.logout(); } catch (e) {}
|
||||
localStorage.removeItem("registry_token");
|
||||
localStorage.removeItem("registry_user");
|
||||
window.location.href = "/login.html";
|
||||
});
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -632,11 +632,56 @@ body.mode-readonly .row-btn:not(.view) {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.titlebar .current-project {
|
||||
font-size: 13px;
|
||||
color: #cfe3f7;
|
||||
padding-left: 14px;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.3);
|
||||
.titlebar .current-project {
|
||||
font-size: 13px;
|
||||
color: #cfe3f7;
|
||||
padding-left: 14px;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* ===== 标题栏右侧:用户信息与入口 ===== */
|
||||
.titlebar-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.titlebar-user {
|
||||
color: #e8f1fb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.role-badge.admin {
|
||||
background: #ffb900;
|
||||
border-color: #ffd666;
|
||||
color: #4a3200;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.titlebar-link {
|
||||
color: #cfe3f7;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
padding-left: 14px;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.titlebar-link:hover {
|
||||
color: #fff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ===== 工具栏(分组处理) ===== */
|
||||
|
||||
@ -7,10 +7,19 @@
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- 标题栏:仅展示应用名与当前项目 -->
|
||||
<header class="titlebar">
|
||||
<span class="app-title">📋 Registry 配置管理中心</span>
|
||||
<span class="current-project" id="current-project-name">未选择项目</span>
|
||||
<!-- 标题栏:仅展示应用名与当前项目 -->
|
||||
<header class="titlebar">
|
||||
<span class="app-title">📋 Registry 配置管理中心</span>
|
||||
<span class="current-project" id="current-project-name">未选择项目</span>
|
||||
<div class="titlebar-right">
|
||||
<span class="titlebar-user">
|
||||
👤 <span id="user-name"></span>
|
||||
<span class="role-badge" id="user-role"></span>
|
||||
</span>
|
||||
<a class="titlebar-link" id="admin-entry" href="/admin.html" style="display:none">⚙ 系统管理</a>
|
||||
<a class="titlebar-link" href="/projects.html">项目列表</a>
|
||||
<a class="titlebar-link" id="logout-link" href="#">退出登录</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 工具栏:分组处理(项目 / 模式 / 配置 / 数据) -->
|
||||
@ -312,8 +321,33 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/tree.js"></script>
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/tree.js"></script>
|
||||
<script>
|
||||
// 认证初始化:检查登录状态,填充标题栏用户信息
|
||||
(async function initAuth() {
|
||||
try {
|
||||
var user = await API.getMe();
|
||||
document.getElementById("user-name").textContent = user.displayName || user.username;
|
||||
var roleEl = document.getElementById("user-role");
|
||||
if (user.role === "admin") {
|
||||
roleEl.textContent = "管理员";
|
||||
roleEl.className = "role-badge admin";
|
||||
document.getElementById("admin-entry").style.display = "";
|
||||
}
|
||||
} catch (e) {
|
||||
window.location.href = "/login.html";
|
||||
return;
|
||||
}
|
||||
})();
|
||||
document.getElementById("logout-link").addEventListener("click", async function (e) {
|
||||
e.preventDefault();
|
||||
try { await API.logout(); } catch (e) {}
|
||||
localStorage.removeItem("registry_token");
|
||||
localStorage.removeItem("registry_user");
|
||||
window.location.href = "/login.html";
|
||||
});
|
||||
</script>
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
274
public/js/api.js
274
public/js/api.js
@ -1,129 +1,145 @@
|
||||
/**
|
||||
* API 调用封装
|
||||
* 统一处理与后端 REST 接口的交互,返回解析后的数据或抛出错误
|
||||
*/
|
||||
const API = {
|
||||
/**
|
||||
* 通用请求方法
|
||||
* @param {string} url - 请求路径
|
||||
* @param {Object} options - fetch 选项
|
||||
* @returns {Promise<any>} 响应数据
|
||||
*/
|
||||
async request(url, options = {}) {
|
||||
const opts = {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...options,
|
||||
};
|
||||
if (opts.body && typeof opts.body !== "string") {
|
||||
opts.body = JSON.stringify(opts.body);
|
||||
}
|
||||
const res = await fetch(url, opts);
|
||||
const json = await res.json();
|
||||
if (!json.success) {
|
||||
throw new Error(json.message || "请求失败");
|
||||
}
|
||||
return json.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取所有项目
|
||||
* @returns {Promise<Array>} 项目列表
|
||||
*/
|
||||
getProjects() {
|
||||
return this.request("/api/projects");
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取单个项目
|
||||
* @param {string} id - 项目 ID
|
||||
* @returns {Promise<Object>} 项目对象
|
||||
*/
|
||||
getProject(id) {
|
||||
return this.request(`/api/projects/${id}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建项目
|
||||
* @param {Object} data - 项目数据
|
||||
* @returns {Promise<Object>} 创建的项目
|
||||
*/
|
||||
createProject(data) {
|
||||
return this.request("/api/projects", { method: "POST", body: data });
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除项目
|
||||
* @param {string} id - 项目 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
deleteProject(id) {
|
||||
return this.request(`/api/projects/${id}`, { method: "DELETE" });
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新项目维度映射方式
|
||||
* @param {string} id - 项目 ID
|
||||
* @param {Object} mappings - { idcMapping, environmentMapping, groupMapping }
|
||||
* @returns {Promise<Object>} 更新后的项目
|
||||
*/
|
||||
updateMappings(id, mappings) {
|
||||
return this.request(`/api/projects/${id}/mappings`, { method: "PUT", body: mappings });
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取项目下所有配置
|
||||
* @param {string} projectId - 项目 ID
|
||||
* @returns {Promise<Array>} 配置项列表
|
||||
*/
|
||||
getConfigs(projectId) {
|
||||
return this.request(`/api/projects/${projectId}/configs`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 新增配置项
|
||||
* @param {string} projectId - 项目 ID
|
||||
* @param {Object} data - 配置项数据
|
||||
* @returns {Promise<Object>} 创建的配置项
|
||||
*/
|
||||
createConfig(projectId, data) {
|
||||
return this.request(`/api/projects/${projectId}/configs`, { method: "POST", body: data });
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新配置项
|
||||
* @param {string} projectId - 项目 ID
|
||||
* @param {string} configId - 配置项 ID
|
||||
* @param {Object} data - 要更新的字段
|
||||
* @returns {Promise<Object>} 更新后的配置项
|
||||
*/
|
||||
updateConfig(projectId, configId, data) {
|
||||
return this.request(`/api/projects/${projectId}/configs/${configId}`, { method: "PUT", body: data });
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除配置项
|
||||
* @param {string} projectId - 项目 ID
|
||||
* @param {string} configId - 配置项 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
deleteConfig(projectId, configId) {
|
||||
return this.request(`/api/projects/${projectId}/configs/${configId}`, { method: "DELETE" });
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取当前模式
|
||||
* @returns {Promise<Object>} { mode }
|
||||
*/
|
||||
getMode() {
|
||||
return this.request("/api/mode");
|
||||
},
|
||||
|
||||
/**
|
||||
* 切换模式
|
||||
* @param {string} mode - 目标模式(plan/edit/readonly)
|
||||
* @returns {Promise<Object>} { mode }
|
||||
*/
|
||||
setMode(mode) {
|
||||
return this.request("/api/mode", { method: "PUT", body: { mode } });
|
||||
},
|
||||
};
|
||||
/**
|
||||
* API 调用封装
|
||||
* 统一处理与后端 REST 接口的交互,返回解析后的数据或抛出错误
|
||||
*/
|
||||
const API = {
|
||||
/**
|
||||
* 通用请求方法
|
||||
* @param {string} url - 请求路径
|
||||
* @param {Object} options - fetch 选项
|
||||
* @returns {Promise<any>} 响应数据
|
||||
*/
|
||||
async request(url, options = {}) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
// 自动携带认证 token
|
||||
const token = localStorage.getItem("registry_token");
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
const opts = { headers, ...options };
|
||||
if (opts.body && typeof opts.body !== "string") {
|
||||
opts.body = JSON.stringify(opts.body);
|
||||
}
|
||||
const res = await fetch(url, opts);
|
||||
// 401 未登录,跳转登录页
|
||||
if (res.status === 401 && !url.includes("/api/auth/")) {
|
||||
localStorage.removeItem("registry_token");
|
||||
localStorage.removeItem("registry_user");
|
||||
window.location.href = "/login.html";
|
||||
throw new Error("登录已过期,请重新登录");
|
||||
}
|
||||
const json = await res.json();
|
||||
if (!json.success) {
|
||||
throw new Error(json.message || "请求失败");
|
||||
}
|
||||
return json.data;
|
||||
},
|
||||
|
||||
// ========== 认证 ==========
|
||||
|
||||
login(username, password) {
|
||||
return this.request("/api/auth/login", { method: "POST", body: { username, password } });
|
||||
},
|
||||
|
||||
logout() {
|
||||
return this.request("/api/auth/logout", { method: "POST" });
|
||||
},
|
||||
|
||||
getMe() {
|
||||
return this.request("/api/auth/me");
|
||||
},
|
||||
|
||||
// ========== 项目 ==========
|
||||
|
||||
getProjects() {
|
||||
return this.request("/api/projects");
|
||||
},
|
||||
|
||||
getProject(id) {
|
||||
return this.request(`/api/projects/${id}`);
|
||||
},
|
||||
|
||||
createProject(data) {
|
||||
return this.request("/api/projects", { method: "POST", body: data });
|
||||
},
|
||||
|
||||
deleteProject(id) {
|
||||
return this.request(`/api/projects/${id}`, { method: "DELETE" });
|
||||
},
|
||||
|
||||
updateMappings(id, mappings) {
|
||||
return this.request(`/api/projects/${id}/mappings`, { method: "PUT", body: mappings });
|
||||
},
|
||||
|
||||
// ========== 配置 ==========
|
||||
|
||||
getConfigs(projectId) {
|
||||
return this.request(`/api/projects/${projectId}/configs`);
|
||||
},
|
||||
|
||||
createConfig(projectId, data) {
|
||||
return this.request(`/api/projects/${projectId}/configs`, { method: "POST", body: data });
|
||||
},
|
||||
|
||||
updateConfig(projectId, configId, data) {
|
||||
return this.request(`/api/projects/${projectId}/configs/${configId}`, { method: "PUT", body: data });
|
||||
},
|
||||
|
||||
deleteConfig(projectId, configId) {
|
||||
return this.request(`/api/projects/${projectId}/configs/${configId}`, { method: "DELETE" });
|
||||
},
|
||||
|
||||
// ========== 模式 ==========
|
||||
|
||||
getMode() {
|
||||
return this.request("/api/mode");
|
||||
},
|
||||
|
||||
setMode(mode) {
|
||||
return this.request("/api/mode", { method: "PUT", body: { mode } });
|
||||
},
|
||||
|
||||
// ========== 用户管理 ==========
|
||||
|
||||
getUsers() {
|
||||
return this.request("/api/users");
|
||||
},
|
||||
|
||||
createUser(data) {
|
||||
return this.request("/api/users", { method: "POST", body: data });
|
||||
},
|
||||
|
||||
updateUser(id, data) {
|
||||
return this.request(`/api/users/${id}`, { method: "PUT", body: data });
|
||||
},
|
||||
|
||||
resetPassword(id, password) {
|
||||
return this.request(`/api/users/${id}/password`, { method: "PUT", body: { password } });
|
||||
},
|
||||
|
||||
deleteUser(id) {
|
||||
return this.request(`/api/users/${id}`, { method: "DELETE" });
|
||||
},
|
||||
|
||||
// ========== Token 管理 ==========
|
||||
|
||||
getTokens() {
|
||||
return this.request("/api/tokens");
|
||||
},
|
||||
|
||||
createToken(data) {
|
||||
return this.request("/api/tokens", { method: "POST", body: data });
|
||||
},
|
||||
|
||||
updateToken(id, data) {
|
||||
return this.request(`/api/tokens/${id}`, { method: "PUT", body: data });
|
||||
},
|
||||
|
||||
revokeToken(id) {
|
||||
return this.request(`/api/tokens/${id}/revoke`, { method: "PUT" });
|
||||
},
|
||||
|
||||
deleteToken(id) {
|
||||
return this.request(`/api/tokens/${id}`, { method: "DELETE" });
|
||||
},
|
||||
};
|
||||
|
||||
95
public/login.html
Normal file
95
public/login.html
Normal file
@ -0,0 +1,95 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录 - Registry 配置管理中心</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
body.login-page {
|
||||
background: linear-gradient(135deg, #003a70 0%, #0a4a86 48%, #0b63a8 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
}
|
||||
.login-wrap { width: 400px; max-width: calc(100vw - 32px); }
|
||||
.login-card { background: #fff; border-radius: 8px; box-shadow: 0 18px 50px rgba(0,0,0,0.35); overflow: hidden; }
|
||||
.login-card-header { background: linear-gradient(to bottom, #0a4a86, #003a70); color: #fff; padding: 28px 24px 22px; text-align: center; }
|
||||
.login-logo { font-size: 36px; line-height: 1; }
|
||||
.login-card-header h1 { font-size: 18px; font-weight: 600; margin-top: 10px; }
|
||||
.login-card-header p { font-size: 12px; color: #cfe3f7; margin-top: 4px; }
|
||||
.login-card-body { padding: 24px; }
|
||||
.login-card-body .form-group { margin-bottom: 16px; }
|
||||
.login-card-body .form-group input { height: 36px; }
|
||||
.login-error { display: none; margin-bottom: 14px; padding: 8px 12px; border: 1px solid #f2b8b5; background: #fdecea; color: #c62828; border-radius: 3px; font-size: 12px; }
|
||||
.login-error.show { display: block; }
|
||||
.login-btn { width: 100%; height: 38px; font-size: 14px; font-weight: 600; }
|
||||
.login-footer { margin-top: 16px; text-align: center; font-size: 11px; color: rgba(255,255,255,0.65); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="login-page">
|
||||
<div class="login-wrap">
|
||||
<div class="login-card">
|
||||
<div class="login-card-header">
|
||||
<div class="login-logo">📋</div>
|
||||
<h1>Registry 配置管理中心</h1>
|
||||
<p>微服务配置集中化 · 可视化 · 规范化管理</p>
|
||||
</div>
|
||||
<div class="login-card-body">
|
||||
<div class="login-error" id="login-error"></div>
|
||||
<form id="login-form" autocomplete="off">
|
||||
<div class="form-group">
|
||||
<label>用户名 <span class="required">*</span></label>
|
||||
<input type="text" id="username" placeholder="请输入用户名" autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码 <span class="required">*</span></label>
|
||||
<input type="password" id="password" placeholder="请输入密码">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary login-btn" id="btn-login">登 录</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="login-footer">Registry Config Center</div>
|
||||
</div>
|
||||
<script>
|
||||
// 已登录则直接跳转
|
||||
if (localStorage.getItem("registry_token")) {
|
||||
window.location.href = "/projects.html";
|
||||
}
|
||||
|
||||
document.getElementById("login-form").addEventListener("submit", async function (e) {
|
||||
e.preventDefault();
|
||||
var error = document.getElementById("login-error");
|
||||
var btn = document.getElementById("btn-login");
|
||||
var username = document.getElementById("username").value.trim();
|
||||
var password = document.getElementById("password").value;
|
||||
|
||||
error.classList.remove("show");
|
||||
|
||||
if (!username || !password) {
|
||||
error.textContent = "请输入用户名和密码";
|
||||
error.classList.add("show");
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = "登录中...";
|
||||
|
||||
try {
|
||||
var data = await API.login(username, password);
|
||||
localStorage.setItem("registry_token", data.token);
|
||||
localStorage.setItem("registry_user", JSON.stringify(data.user));
|
||||
window.location.href = "/projects.html";
|
||||
} catch (err) {
|
||||
error.textContent = err.message || "登录失败";
|
||||
error.classList.add("show");
|
||||
btn.disabled = false;
|
||||
btn.textContent = "登 录";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script src="/js/api.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
315
public/projects.html
Normal file
315
public/projects.html
Normal file
@ -0,0 +1,315 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>项目管理 - Registry 配置管理中心</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
/* 标题栏右侧 */
|
||||
.titlebar-right { margin-left: auto; display: flex; align-items: center; gap: 14px; font-size: 12px; }
|
||||
.titlebar-user { color: #e8f1fb; display: flex; align-items: center; gap: 6px; }
|
||||
.role-badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; background: rgba(255,255,255,0.18); border: 1px solid rgba(255,255,255,0.35); }
|
||||
.role-badge.admin { background: #ffb900; border-color: #ffd666; color: #4a3200; font-weight: 600; }
|
||||
.titlebar-link { color: #cfe3f7; text-decoration: none; cursor: pointer; padding-left: 14px; border-left: 1px solid rgba(255,255,255,0.3); }
|
||||
.titlebar-link:hover { color: #fff; text-decoration: underline; }
|
||||
/* 内容区 */
|
||||
.page-content { flex: 1; overflow: auto; padding: 18px; }
|
||||
.page-heading { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 14px; }
|
||||
.page-heading h2 { font-size: 16px; color: #003a70; }
|
||||
.page-heading .sub { font-size: 12px; color: #888; }
|
||||
.content-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; }
|
||||
.content-toolbar .spacer { flex: 1; }
|
||||
.search-input { height: 30px; width: 240px; padding: 0 10px; border: 1px solid #adadad; border-radius: 3px; font-size: 13px; font-family: inherit; }
|
||||
.filter-select { height: 30px; padding: 0 8px; border: 1px solid #adadad; border-radius: 3px; background: #fff; font-size: 12px; }
|
||||
/* 项目卡片 */
|
||||
.projects-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; }
|
||||
.project-card { background: #fff; border: 1px solid #dcdcdc; border-radius: 6px; padding: 14px 16px 12px; cursor: pointer; transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s; display: flex; flex-direction: column; }
|
||||
.project-card:hover { border-color: #5b9bd5; box-shadow: 0 4px 14px rgba(0,120,212,0.15); transform: translateY(-1px); }
|
||||
.project-card-top { display: flex; align-items: flex-start; gap: 10px; }
|
||||
.project-icon { width: 34px; height: 34px; border-radius: 6px; background: #e5f1fb; color: #0078d4; display: flex; align-items: center; justify-content: center; font-size: 17px; flex-shrink: 0; }
|
||||
.project-card-title { font-size: 15px; font-weight: 600; color: #003a70; line-height: 1.3; }
|
||||
.project-card-id { font-family: Consolas, monospace; font-size: 11px; color: #999; margin-top: 2px; }
|
||||
.project-card-desc { margin-top: 10px; font-size: 12px; color: #666; line-height: 1.5; min-height: 36px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.project-card-badges { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
|
||||
.dim-chip { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; background: #eef4fb; color: #0a4a86; border: 1px solid #d0dced; }
|
||||
.project-card-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 12px; padding-top: 10px; border-top: 1px solid #f0f0f0; font-size: 11px; color: #999; }
|
||||
.project-card-actions { display: flex; gap: 6px; opacity: 0; transition: opacity 0.15s; }
|
||||
.project-card:hover .project-card-actions { opacity: 1; }
|
||||
.empty-state { text-align: center; padding: 60px 20px; color: #999; }
|
||||
.empty-state .empty-icon { font-size: 36px; opacity: 0.4; }
|
||||
.empty-state p { margin-top: 8px; font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="titlebar">
|
||||
<span class="app-title">📋 Registry 配置管理中心</span>
|
||||
<span class="current-project">项目管理</span>
|
||||
<div class="titlebar-right">
|
||||
<span class="titlebar-user">
|
||||
👤 <span id="user-name"></span>
|
||||
<span class="role-badge" id="user-role"></span>
|
||||
</span>
|
||||
<a class="titlebar-link" id="admin-entry" href="/admin.html" style="display:none">⚙ 系统管理</a>
|
||||
<a class="titlebar-link" id="logout-link" href="#">退出登录</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="tool-group">
|
||||
<span class="group-label">项目</span>
|
||||
<button class="toolbar-btn" id="btn-refresh" title="刷新列表">🔄 刷新</button>
|
||||
<button class="toolbar-btn" id="btn-new-project" title="新建项目">📁 新建</button>
|
||||
</div>
|
||||
<div class="tool-group">
|
||||
<span class="group-label" id="project-count">共 0 个项目</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-content">
|
||||
<div class="page-heading">
|
||||
<h2>我的项目</h2>
|
||||
<span class="sub">选择一个项目进入配置管理</span>
|
||||
</div>
|
||||
<div class="content-toolbar">
|
||||
<input type="text" class="search-input" id="project-search" placeholder="🔍 搜索项目名称或描述">
|
||||
<span class="spacer"></span>
|
||||
<button class="btn btn-primary" style="height:30px" id="btn-new-project-2">➕ 新建项目</button>
|
||||
</div>
|
||||
<div class="projects-grid" id="projects-grid"></div>
|
||||
</div>
|
||||
|
||||
<footer class="statusbar">
|
||||
<span id="status-text">加载中...</span>
|
||||
<span id="status-user"></span>
|
||||
</footer>
|
||||
|
||||
<!-- 新建项目模态框(复用 index.html 中的结构) -->
|
||||
<div class="modal-overlay" id="modal-project" style="display:none">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>新建项目</h3>
|
||||
<button class="modal-close" onclick="closeModal('modal-project')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>项目名称 <span class="required">*</span></label>
|
||||
<input type="text" id="project-name" placeholder="例如:用户服务">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>项目描述</label>
|
||||
<textarea id="project-description" rows="2" placeholder="项目配置说明"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>维度配置 <span class="hint">(创建后不可修改)</span></label>
|
||||
<div class="dim-config-row">
|
||||
<label class="checkbox-label"><input type="checkbox" id="project-enable-idc" checked> 机房</label>
|
||||
<select id="project-idc-mapping" class="mapping-select"><option value="path">映射为路径</option><option value="label">仅作标签</option></select>
|
||||
</div>
|
||||
<div class="dim-config-row">
|
||||
<label class="checkbox-label"><input type="checkbox" id="project-enable-env" checked> 环境</label>
|
||||
<select id="project-env-mapping" class="mapping-select"><option value="path">映射为路径</option><option value="label">仅作标签</option></select>
|
||||
</div>
|
||||
<div class="dim-config-row">
|
||||
<label class="checkbox-label"><input type="checkbox" id="project-enable-group" checked> 分组</label>
|
||||
<select id="project-group-mapping" class="mapping-select"><option value="path">映射为路径</option><option value="label">仅作标签</option></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal('modal-project')">取消</button>
|
||||
<button class="btn btn-primary" id="btn-save-project">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 确认删除模态框 -->
|
||||
<div class="modal-overlay" id="modal-confirm" style="display:none">
|
||||
<div class="modal" style="width:360px">
|
||||
<div class="modal-header">
|
||||
<h3>确认操作</h3>
|
||||
<button class="modal-close" onclick="closeModal('modal-confirm')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body"><p id="confirm-message"></p></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal('modal-confirm')">取消</button>
|
||||
<button class="btn btn-danger" id="btn-confirm-ok">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script>
|
||||
var currentUser = null;
|
||||
var confirmCallback = null;
|
||||
|
||||
function toast(message, type) {
|
||||
var el = document.createElement("div");
|
||||
el.className = "toast toast-" + (type || "info");
|
||||
el.textContent = message;
|
||||
document.body.appendChild(el);
|
||||
setTimeout(function () { el.remove(); }, 2500);
|
||||
}
|
||||
|
||||
function openModal(id) { document.getElementById(id).style.display = "flex"; }
|
||||
function closeModal(id) { document.getElementById(id).style.display = "none"; }
|
||||
|
||||
function openConfirm(message, callback) {
|
||||
document.getElementById("confirm-message").textContent = message;
|
||||
confirmCallback = callback;
|
||||
openModal("modal-confirm");
|
||||
}
|
||||
document.getElementById("btn-confirm-ok").addEventListener("click", function () {
|
||||
closeModal("modal-confirm");
|
||||
if (confirmCallback) confirmCallback();
|
||||
confirmCallback = null;
|
||||
});
|
||||
|
||||
// 初始化
|
||||
async function init() {
|
||||
try {
|
||||
var data = await API.getMe();
|
||||
currentUser = data;
|
||||
} catch (e) {
|
||||
window.location.href = "/login.html";
|
||||
return;
|
||||
}
|
||||
document.getElementById("user-name").textContent = currentUser.displayName || currentUser.username;
|
||||
var roleEl = document.getElementById("user-role");
|
||||
if (currentUser.role === "admin") {
|
||||
roleEl.textContent = "管理员";
|
||||
roleEl.className = "role-badge admin";
|
||||
document.getElementById("admin-entry").style.display = "";
|
||||
}
|
||||
document.getElementById("status-user").textContent = currentUser.username;
|
||||
await loadProjects();
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
var projects = await API.getProjects();
|
||||
renderProjects(projects);
|
||||
} catch (err) {
|
||||
toast("加载项目失败: " + err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function renderProjects(projects) {
|
||||
var grid = document.getElementById("projects-grid");
|
||||
if (!projects.length) {
|
||||
grid.innerHTML = '<div class="empty-state"><div class="empty-icon">📁</div><p>暂无项目,点击「新建项目」创建第一个</p></div>';
|
||||
document.getElementById("project-count").textContent = "共 0 个项目";
|
||||
document.getElementById("status-text").textContent = "已加载 0 个项目";
|
||||
return;
|
||||
}
|
||||
|
||||
var dims = [];
|
||||
grid.innerHTML = projects.map(function (p) {
|
||||
dims = [];
|
||||
if (p.enableIdc) dims.push("🏢 机房");
|
||||
if (p.enableEnvironment) dims.push("🌐 环境");
|
||||
if (p.enableGroup) dims.push("🗂 分组");
|
||||
var badges = dims.map(function (d) { return '<span class="dim-chip">' + d + "</span>"; }).join("");
|
||||
var time = p.updatedAt ? new Date(p.updatedAt).toLocaleDateString("zh-CN") : "";
|
||||
return '<div class="project-card" data-id="' + p.id + '" onclick="openProject(\'' + p.id + "')\">" +
|
||||
'<div class="project-card-top">' +
|
||||
'<div class="project-icon">📦</div>' +
|
||||
'<div><div class="project-card-title">' + esc(p.name) + '</div>' +
|
||||
'<div class="project-card-id">' + esc(p.id) + "</div></div>" +
|
||||
"</div>" +
|
||||
'<div class="project-card-desc">' + esc(p.description || "") + "</div>" +
|
||||
'<div class="project-card-badges">' + badges + "</div>" +
|
||||
'<div class="project-card-footer">' +
|
||||
"<span>" + time + "</span>" +
|
||||
'<div class="project-card-actions">' +
|
||||
'<button class="row-btn" onclick="event.stopPropagation();openProject(\'' + p.id + "')\">进入</button>" +
|
||||
'<button class="row-btn danger" onclick="event.stopPropagation();confirmDelete(\'' + p.id + "', '" + esc(p.name) + "')\">删除</button>" +
|
||||
"</div>" +
|
||||
"</div>" +
|
||||
"</div>";
|
||||
}).join("");
|
||||
|
||||
document.getElementById("project-count").textContent = "共 " + projects.length + " 个项目";
|
||||
document.getElementById("status-text").textContent = "已加载 " + projects.length + " 个项目";
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
var d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function openProject(id) {
|
||||
window.location.href = "/index.html?project=" + encodeURIComponent(id);
|
||||
}
|
||||
|
||||
function confirmDelete(id, name) {
|
||||
openConfirm("确认删除项目「" + name + "」?此操作将同时删除该项目下的所有配置,且不可恢复。", async function () {
|
||||
try {
|
||||
await API.deleteProject(id);
|
||||
toast("项目已删除", "success");
|
||||
loadProjects();
|
||||
} catch (err) {
|
||||
toast("删除失败: " + err.message, "error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 新建项目
|
||||
document.getElementById("btn-new-project").addEventListener("click", function () { openModal("modal-project"); });
|
||||
document.getElementById("btn-new-project-2").addEventListener("click", function () { openModal("modal-project"); });
|
||||
|
||||
document.getElementById("btn-save-project").addEventListener("click", async function () {
|
||||
var name = document.getElementById("project-name").value.trim();
|
||||
var desc = document.getElementById("project-description").value.trim();
|
||||
if (!name) { toast("请输入项目名称", "error"); return; }
|
||||
try {
|
||||
await API.createProject({
|
||||
name: name,
|
||||
description: desc,
|
||||
enableIdc: document.getElementById("project-enable-idc").checked,
|
||||
enableEnvironment: document.getElementById("project-enable-env").checked,
|
||||
enableGroup: document.getElementById("project-enable-group").checked,
|
||||
idcMapping: document.getElementById("project-idc-mapping").value,
|
||||
environmentMapping: document.getElementById("project-env-mapping").value,
|
||||
groupMapping: document.getElementById("project-group-mapping").value,
|
||||
});
|
||||
closeModal("modal-project");
|
||||
document.getElementById("project-name").value = "";
|
||||
document.getElementById("project-description").value = "";
|
||||
toast("项目创建成功", "success");
|
||||
loadProjects();
|
||||
} catch (err) {
|
||||
toast("创建失败: " + err.message, "error");
|
||||
}
|
||||
});
|
||||
|
||||
// 搜索
|
||||
document.getElementById("project-search").addEventListener("input", function () {
|
||||
var kw = this.value.trim().toLowerCase();
|
||||
var cards = document.querySelectorAll(".project-card");
|
||||
var visible = 0;
|
||||
cards.forEach(function (card) {
|
||||
var text = card.textContent.toLowerCase();
|
||||
var show = !kw || text.indexOf(kw) !== -1;
|
||||
card.style.display = show ? "" : "none";
|
||||
if (show) visible++;
|
||||
});
|
||||
document.getElementById("project-count").textContent = "共 " + visible + " 个项目";
|
||||
});
|
||||
|
||||
// 退出登录
|
||||
document.getElementById("logout-link").addEventListener("click", async function (e) {
|
||||
e.preventDefault();
|
||||
try { await API.logout(); } catch (e) {}
|
||||
localStorage.removeItem("registry_token");
|
||||
localStorage.removeItem("registry_user");
|
||||
window.location.href = "/login.html";
|
||||
});
|
||||
|
||||
document.getElementById("btn-refresh").addEventListener("click", loadProjects);
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -11,6 +11,8 @@ const DATA_DIR = path.join(__dirname, "..", "..", "data");
|
||||
const PROJECTS_FILE = path.join(DATA_DIR, "projects.json");
|
||||
const SYSTEM_FILE = path.join(DATA_DIR, "system.json");
|
||||
const MCP_FILE = path.join(DATA_DIR, "mcp.json");
|
||||
const USERS_FILE = path.join(DATA_DIR, "users.json");
|
||||
const API_TOKENS_FILE = path.join(DATA_DIR, "api-tokens.json");
|
||||
const CONFIGS_DIR = path.join(DATA_DIR, "configs");
|
||||
|
||||
/**
|
||||
@ -29,6 +31,12 @@ function ensureDataDir() {
|
||||
if (!fs.existsSync(SYSTEM_FILE)) {
|
||||
fs.writeFileSync(SYSTEM_FILE, JSON.stringify({ mode: "edit" }, null, 2), "utf-8");
|
||||
}
|
||||
if (!fs.existsSync(USERS_FILE)) {
|
||||
fs.writeFileSync(USERS_FILE, "[]", "utf-8");
|
||||
}
|
||||
if (!fs.existsSync(API_TOKENS_FILE)) {
|
||||
fs.writeFileSync(API_TOKENS_FILE, "[]", "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -140,6 +148,42 @@ function saveMcpConfig(mcp) {
|
||||
writeJsonFile(MCP_FILE, mcp);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载用户列表
|
||||
* @returns {Array}
|
||||
*/
|
||||
function loadUsers() {
|
||||
ensureDataDir();
|
||||
return readJsonFile(USERS_FILE) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存用户列表
|
||||
* @param {Array} users
|
||||
*/
|
||||
function saveUsers(users) {
|
||||
ensureDataDir();
|
||||
writeJsonFile(USERS_FILE, users);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载 API Token 列表
|
||||
* @returns {Array}
|
||||
*/
|
||||
function loadApiTokens() {
|
||||
ensureDataDir();
|
||||
return readJsonFile(API_TOKENS_FILE) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 API Token 列表
|
||||
* @param {Array} tokens
|
||||
*/
|
||||
function saveApiTokens(tokens) {
|
||||
ensureDataDir();
|
||||
writeJsonFile(API_TOKENS_FILE, tokens);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadProjects,
|
||||
saveProjects,
|
||||
@ -150,4 +194,8 @@ module.exports = {
|
||||
saveSystem,
|
||||
loadMcpConfig,
|
||||
saveMcpConfig,
|
||||
loadUsers,
|
||||
saveUsers,
|
||||
loadApiTokens,
|
||||
saveApiTokens,
|
||||
};
|
||||
|
||||
114
src/routes/auth.js
Normal file
114
src/routes/auth.js
Normal file
@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 认证路由
|
||||
* POST /api/auth/login - 登录
|
||||
* POST /api/auth/logout - 登出
|
||||
* GET /api/auth/me - 获取当前登录用户
|
||||
*/
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const userService = require("../services/userService");
|
||||
|
||||
// 内存 session 存储:token -> { user, expiresAt }
|
||||
const sessions = new Map();
|
||||
|
||||
const SESSION_TTL = 24 * 60 * 60 * 1000; // 24 小时
|
||||
|
||||
/**
|
||||
* 清理过期 session
|
||||
*/
|
||||
function cleanExpiredSessions() {
|
||||
const now = Date.now();
|
||||
for (const [token, session] of sessions) {
|
||||
if (session.expiresAt < now) sessions.delete(token);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 session
|
||||
*/
|
||||
function createSession(user) {
|
||||
cleanExpiredSessions();
|
||||
const token = userService.generateToken(32);
|
||||
sessions.set(token, {
|
||||
user,
|
||||
expiresAt: Date.now() + SESSION_TTL,
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 session 中的用户
|
||||
*/
|
||||
function getSession(token) {
|
||||
if (!token) return null;
|
||||
const session = sessions.get(token);
|
||||
if (!session) return null;
|
||||
if (session.expiresAt < Date.now()) {
|
||||
sessions.delete(token);
|
||||
return null;
|
||||
}
|
||||
// 续期
|
||||
session.expiresAt = Date.now() + SESSION_TTL;
|
||||
return session.user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁 session
|
||||
*/
|
||||
function destroySession(token) {
|
||||
if (token) sessions.delete(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
* Body: { username, password }
|
||||
*/
|
||||
router.post("/login", (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ success: false, message: "用户名和密码不能为空" });
|
||||
}
|
||||
const user = userService.authenticate(username, password);
|
||||
if (!user) {
|
||||
return res.status(401).json({ success: false, message: "用户名或密码错误" });
|
||||
}
|
||||
const token = createSession(user);
|
||||
res.json({ success: true, data: { token, user } });
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
* Header: Authorization: Bearer <token>
|
||||
*/
|
||||
router.post("/logout", (req, res) => {
|
||||
const token = extractToken(req);
|
||||
destroySession(token);
|
||||
res.json({ success: true, message: "已退出登录" });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/auth/me
|
||||
* Header: Authorization: Bearer <token>
|
||||
*/
|
||||
router.get("/me", (req, res) => {
|
||||
const token = extractToken(req);
|
||||
const user = getSession(token);
|
||||
if (!user) {
|
||||
return res.status(401).json({ success: false, message: "未登录或登录已过期" });
|
||||
}
|
||||
res.json({ success: true, data: user });
|
||||
});
|
||||
|
||||
/**
|
||||
* 从请求头提取 Bearer token
|
||||
*/
|
||||
function extractToken(req) {
|
||||
const auth = req.headers.authorization || "";
|
||||
return auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
|
||||
}
|
||||
|
||||
module.exports = { router, getSession, extractToken };
|
||||
88
src/routes/tokens.js
Normal file
88
src/routes/tokens.js
Normal file
@ -0,0 +1,88 @@
|
||||
/**
|
||||
* API Token 管理路由(需管理员权限)
|
||||
* GET /api/tokens - Token 列表
|
||||
* POST /api/tokens - 生成 Token
|
||||
* PUT /api/tokens/:id - 编辑 Token
|
||||
* PUT /api/tokens/:id/revoke - 吊销 Token
|
||||
* DELETE /api/tokens/:id - 删除 Token
|
||||
*/
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const tokenService = require("../services/tokenService");
|
||||
|
||||
/**
|
||||
* 管理员权限校验中间件
|
||||
*/
|
||||
function requireAdmin(req, res, next) {
|
||||
if (!req.user || req.user.role !== "admin") {
|
||||
return res.status(403).json({ success: false, message: "需要管理员权限" });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
router.use(requireAdmin);
|
||||
|
||||
/**
|
||||
* GET /api/tokens
|
||||
*/
|
||||
router.get("/", (req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: tokenService.getAllTokens() });
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/tokens
|
||||
* Body: { name, projectId, permission, expiresInDays }
|
||||
*/
|
||||
router.post("/", (req, res) => {
|
||||
try {
|
||||
const token = tokenService.createToken(req.body);
|
||||
res.status(201).json({ success: true, data: token });
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/tokens/:id
|
||||
* Body: { name, projectId, permission, enabled }
|
||||
*/
|
||||
router.put("/:id", (req, res) => {
|
||||
try {
|
||||
const token = tokenService.updateToken(req.params.id, req.body);
|
||||
res.json({ success: true, data: token });
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/tokens/:id/revoke
|
||||
*/
|
||||
router.put("/:id/revoke", (req, res) => {
|
||||
try {
|
||||
const ok = tokenService.revokeToken(req.params.id);
|
||||
if (!ok) return res.status(404).json({ success: false, message: "Token 不存在" });
|
||||
res.json({ success: true, message: "Token 已吊销" });
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/tokens/:id
|
||||
*/
|
||||
router.delete("/:id", (req, res) => {
|
||||
try {
|
||||
const ok = tokenService.deleteToken(req.params.id);
|
||||
if (!ok) return res.status(404).json({ success: false, message: "Token 不存在" });
|
||||
res.json({ success: true, message: "Token 已删除" });
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
89
src/routes/users.js
Normal file
89
src/routes/users.js
Normal file
@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 用户管理路由(需管理员权限)
|
||||
* GET /api/users - 用户列表
|
||||
* POST /api/users - 新增用户
|
||||
* PUT /api/users/:id - 编辑用户
|
||||
* PUT /api/users/:id/password - 重置密码
|
||||
* DELETE /api/users/:id - 删除用户
|
||||
*/
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const userService = require("../services/userService");
|
||||
|
||||
/**
|
||||
* 管理员权限校验中间件
|
||||
*/
|
||||
function requireAdmin(req, res, next) {
|
||||
// 此中间件依赖 auth 中间件已挂载到 req.user
|
||||
if (!req.user || req.user.role !== "admin") {
|
||||
return res.status(403).json({ success: false, message: "需要管理员权限" });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
router.use(requireAdmin);
|
||||
|
||||
/**
|
||||
* GET /api/users
|
||||
*/
|
||||
router.get("/", (req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: userService.getAllUsers() });
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/users
|
||||
* Body: { username, displayName, password, role, enabled }
|
||||
*/
|
||||
router.post("/", (req, res) => {
|
||||
try {
|
||||
const user = userService.createUser(req.body);
|
||||
res.status(201).json({ success: true, data: user });
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/users/:id
|
||||
* Body: { displayName, role, enabled }
|
||||
*/
|
||||
router.put("/:id", (req, res) => {
|
||||
try {
|
||||
const user = userService.updateUser(req.params.id, req.body);
|
||||
res.json({ success: true, data: user });
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/users/:id/password
|
||||
* Body: { password }
|
||||
*/
|
||||
router.put("/:id/password", (req, res) => {
|
||||
try {
|
||||
userService.resetPassword(req.params.id, req.body.password);
|
||||
res.json({ success: true, message: "密码已重置" });
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/users/:id
|
||||
*/
|
||||
router.delete("/:id", (req, res) => {
|
||||
try {
|
||||
const ok = userService.deleteUser(req.params.id);
|
||||
if (!ok) return res.status(404).json({ success: false, message: "用户不存在" });
|
||||
res.json({ success: true, message: "用户已删除" });
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -6,7 +6,11 @@ const express = require("express");
|
||||
const path = require("path");
|
||||
const projectRoutes = require("./routes/projects");
|
||||
const configRoutes = require("./routes/configs");
|
||||
const { router: authRoutes, getSession, extractToken } = require("./routes/auth");
|
||||
const userRoutes = require("./routes/users");
|
||||
const tokenRoutes = require("./routes/tokens");
|
||||
const { loadSystem, saveSystem } = require("./persistence/jsonAdapter");
|
||||
const userService = require("./services/userService");
|
||||
|
||||
// MCP 服务(Model Context Protocol,供 AI 自动操作)
|
||||
const mcpRoutes = require("./mcp");
|
||||
@ -17,16 +21,34 @@ const PORT = process.env.PORT || 3000;
|
||||
// 解析 JSON 请求体
|
||||
app.use(express.json());
|
||||
|
||||
// 初始化默认管理员账号(首次启动时自动创建)
|
||||
userService.ensureDefaultAdmin();
|
||||
|
||||
// 全局认证中间件:解析 Bearer token,将用户信息挂载到 req.user(非强制)
|
||||
app.use((req, res, next) => {
|
||||
const token = extractToken(req);
|
||||
req.user = getSession(token) || null;
|
||||
next();
|
||||
});
|
||||
|
||||
// 静态资源服务(禁用缓存,避免浏览器加载到旧版本页面/脚本)
|
||||
app.use(express.static(path.join(__dirname, "..", "public"), { etag: false, lastModified: false, cacheControl: false, setHeaders: (res) => res.setHeader("Cache-Control", "no-store") }));
|
||||
|
||||
// API 路由
|
||||
app.use("/api/auth", authRoutes);
|
||||
app.use("/api/projects", projectRoutes);
|
||||
app.use("/api/projects/:projectId/configs", configRoutes);
|
||||
app.use("/api/users", userRoutes);
|
||||
app.use("/api/tokens", tokenRoutes);
|
||||
|
||||
// MCP 服务端点(Streamable HTTP 传输)
|
||||
app.use("/mcp", mcpRoutes);
|
||||
|
||||
// 根路径重定向到登录页
|
||||
app.get("/", (req, res) => {
|
||||
res.redirect("/login.html");
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/mode
|
||||
* 获取当前系统模式
|
||||
|
||||
124
src/services/tokenService.js
Normal file
124
src/services/tokenService.js
Normal file
@ -0,0 +1,124 @@
|
||||
/**
|
||||
* API Token 服务
|
||||
* 负责 API 访问令牌的增删改查与吊销,持久化到 data/api-tokens.json
|
||||
* (与 MCP token 独立,MCP token 由 data/mcp.json 管理)
|
||||
*/
|
||||
const { loadApiTokens, saveApiTokens } = require("../persistence/jsonAdapter");
|
||||
const { generateToken } = require("./userService");
|
||||
|
||||
/**
|
||||
* 获取所有 Token(完整值,供列表展示)
|
||||
* @returns {Array}
|
||||
*/
|
||||
function getAllTokens() {
|
||||
return loadApiTokens();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 ID 查找 Token
|
||||
* @param {string} id
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
function findById(id) {
|
||||
return loadApiTokens().find((t) => t.id === id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Token
|
||||
* @param {Object} data - { name, projectId, permission, expiresInDays }
|
||||
* @returns {Object} 创建的 Token(含明文 value)
|
||||
*/
|
||||
function createToken(data) {
|
||||
const tokens = loadApiTokens();
|
||||
if (!data.name || !data.name.trim()) {
|
||||
throw new Error("Token 名称不能为空");
|
||||
}
|
||||
const tokenValue = `reg_${generateToken(24)}`;
|
||||
const now = new Date();
|
||||
let expiresAt = null;
|
||||
if (data.expiresInDays && data.expiresInDays > 0) {
|
||||
expiresAt = new Date(now.getTime() + data.expiresInDays * 86400000).toISOString();
|
||||
}
|
||||
const token = {
|
||||
id: generateToken(8),
|
||||
name: data.name.trim(),
|
||||
projectId: data.projectId || "all",
|
||||
permission: data.permission === "readwrite" ? "readwrite" : "readonly",
|
||||
value: tokenValue,
|
||||
enabled: true,
|
||||
createdAt: now.toISOString(),
|
||||
expiresAt,
|
||||
};
|
||||
tokens.push(token);
|
||||
saveApiTokens(tokens);
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Token 元信息
|
||||
* @param {string} id
|
||||
* @param {Object} data - { name, projectId, permission, enabled }
|
||||
* @returns {Object}
|
||||
*/
|
||||
function updateToken(id, data) {
|
||||
const tokens = loadApiTokens();
|
||||
const idx = tokens.findIndex((t) => t.id === id);
|
||||
if (idx === -1) throw new Error("Token 不存在");
|
||||
if (data.name !== undefined) tokens[idx].name = data.name.trim();
|
||||
if (data.projectId !== undefined) tokens[idx].projectId = data.projectId;
|
||||
if (data.permission !== undefined) tokens[idx].permission = data.permission;
|
||||
if (data.enabled !== undefined) tokens[idx].enabled = !!data.enabled;
|
||||
saveApiTokens(tokens);
|
||||
return tokens[idx];
|
||||
}
|
||||
|
||||
/**
|
||||
* 吊销 Token(设置 enabled = false)
|
||||
* @param {string} id
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function revokeToken(id) {
|
||||
const tokens = loadApiTokens();
|
||||
const idx = tokens.findIndex((t) => t.id === id);
|
||||
if (idx === -1) return false;
|
||||
tokens[idx].enabled = false;
|
||||
saveApiTokens(tokens);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 Token
|
||||
* @param {string} id
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function deleteToken(id) {
|
||||
const tokens = loadApiTokens();
|
||||
const idx = tokens.findIndex((t) => t.id === id);
|
||||
if (idx === -1) return false;
|
||||
tokens.splice(idx, 1);
|
||||
saveApiTokens(tokens);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 token value 校验是否有效(用于 API 鉴权)
|
||||
* @param {string} value
|
||||
* @returns {Object|null} token 对象或 null
|
||||
*/
|
||||
function validateToken(value) {
|
||||
if (!value) return null;
|
||||
const token = loadApiTokens().find((t) => t.value === value);
|
||||
if (!token || !token.enabled) return null;
|
||||
if (token.expiresAt && new Date(token.expiresAt) < new Date()) return null;
|
||||
return token;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAllTokens,
|
||||
findById,
|
||||
createToken,
|
||||
updateToken,
|
||||
revokeToken,
|
||||
deleteToken,
|
||||
validateToken,
|
||||
};
|
||||
206
src/services/userService.js
Normal file
206
src/services/userService.js
Normal file
@ -0,0 +1,206 @@
|
||||
/**
|
||||
* 用户服务
|
||||
* 负责用户的增删改查、密码校验与哈希,数据持久化到 data/users.json
|
||||
*/
|
||||
const crypto = require("crypto");
|
||||
const { loadUsers, saveUsers } = require("../persistence/jsonAdapter");
|
||||
|
||||
/**
|
||||
* 密码哈希:使用 scrypt 算法 + 随机盐值
|
||||
* @param {string} password - 明文密码
|
||||
* @returns {string} salt:hash 格式的哈希值
|
||||
*/
|
||||
function hashPassword(password) {
|
||||
const salt = crypto.randomBytes(16).toString("hex");
|
||||
const hash = crypto.scryptSync(password, salt, 64).toString("hex");
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验密码是否匹配
|
||||
* @param {string} password - 明文密码
|
||||
* @param {string} stored - 存储的 salt:hash
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function verifyPassword(password, stored) {
|
||||
const [salt, hash] = stored.split(":");
|
||||
const hashToCheck = crypto.scryptSync(password, salt, 64).toString("hex");
|
||||
return crypto.timingSafeEqual(Buffer.from(hash, "hex"), Buffer.from(hashToCheck, "hex"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机 token
|
||||
* @param {number} [length=32] - 字节长度
|
||||
* @returns {string} hex token
|
||||
*/
|
||||
function generateToken(length = 32) {
|
||||
return crypto.randomBytes(length).toString("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保默认管理员账号存在(首次启动时初始化)
|
||||
*/
|
||||
function ensureDefaultAdmin() {
|
||||
const users = loadUsers();
|
||||
if (users.length === 0) {
|
||||
users.push({
|
||||
id: generateToken(8),
|
||||
username: "admin",
|
||||
displayName: "系统管理员",
|
||||
password: hashPassword("admin123"),
|
||||
role: "admin",
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
lastLoginAt: null,
|
||||
});
|
||||
saveUsers(users);
|
||||
console.log("已创建默认管理员账号: admin");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按用户名查找用户
|
||||
* @param {string} username
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
function findByUsername(username) {
|
||||
return loadUsers().find((u) => u.username === username) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 ID 查找用户
|
||||
* @param {string} id
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
function findById(id) {
|
||||
return loadUsers().find((u) => u.id === id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有用户(不含密码字段)
|
||||
* @returns {Array}
|
||||
*/
|
||||
function getAllUsers() {
|
||||
return loadUsers().map(({ password, ...rest }) => rest);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建用户
|
||||
* @param {Object} data - { username, displayName, password, role, enabled }
|
||||
* @returns {Object} 创建的用户(不含密码)
|
||||
* @throws {Error}
|
||||
*/
|
||||
function createUser(data) {
|
||||
const users = loadUsers();
|
||||
if (!data.username || !data.username.trim()) {
|
||||
throw new Error("用户名不能为空");
|
||||
}
|
||||
if (!data.password) {
|
||||
throw new Error("密码不能为空");
|
||||
}
|
||||
if (users.find((u) => u.username === data.username.trim())) {
|
||||
throw new Error("用户名已存在");
|
||||
}
|
||||
const user = {
|
||||
id: generateToken(8),
|
||||
username: data.username.trim(),
|
||||
displayName: (data.displayName || data.username).trim(),
|
||||
password: hashPassword(data.password),
|
||||
role: data.role === "admin" ? "admin" : "user",
|
||||
enabled: data.enabled !== false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
lastLoginAt: null,
|
||||
};
|
||||
users.push(user);
|
||||
saveUsers(users);
|
||||
const { password, ...rest } = user;
|
||||
return rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户
|
||||
* @param {string} id
|
||||
* @param {Object} data - { displayName, role, enabled }
|
||||
* @returns {Object}
|
||||
* @throws {Error}
|
||||
*/
|
||||
function updateUser(id, data) {
|
||||
const users = loadUsers();
|
||||
const idx = users.findIndex((u) => u.id === id);
|
||||
if (idx === -1) throw new Error("用户不存在");
|
||||
if (data.displayName !== undefined) users[idx].displayName = data.displayName.trim();
|
||||
if (data.role !== undefined) users[idx].role = data.role === "admin" ? "admin" : "user";
|
||||
if (data.enabled !== undefined) users[idx].enabled = !!data.enabled;
|
||||
users[idx].updatedAt = new Date().toISOString();
|
||||
saveUsers(users);
|
||||
const { password, ...rest } = users[idx];
|
||||
return rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置密码
|
||||
* @param {string} id
|
||||
* @param {string} newPassword
|
||||
* @throws {Error}
|
||||
*/
|
||||
function resetPassword(id, newPassword) {
|
||||
const users = loadUsers();
|
||||
const idx = users.findIndex((u) => u.id === id);
|
||||
if (idx === -1) throw new Error("用户不存在");
|
||||
if (!newPassword) throw new Error("新密码不能为空");
|
||||
users[idx].password = hashPassword(newPassword);
|
||||
users[idx].updatedAt = new Date().toISOString();
|
||||
saveUsers(users);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户
|
||||
* @param {string} id
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function deleteUser(id) {
|
||||
const users = loadUsers();
|
||||
const idx = users.findIndex((u) => u.id === id);
|
||||
if (idx === -1) return false;
|
||||
users.splice(idx, 1);
|
||||
saveUsers(users);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录校验,成功后更新 lastLoginAt
|
||||
* @param {string} username
|
||||
* @param {string} password
|
||||
* @returns {Object|null} 用户对象(不含密码)或 null
|
||||
*/
|
||||
function authenticate(username, password) {
|
||||
const user = findByUsername(username);
|
||||
if (!user || !user.enabled) return null;
|
||||
if (!verifyPassword(password, user.password)) return null;
|
||||
// 更新最后登录时间
|
||||
const users = loadUsers();
|
||||
const idx = users.findIndex((u) => u.id === user.id);
|
||||
if (idx !== -1) {
|
||||
users[idx].lastLoginAt = new Date().toISOString();
|
||||
saveUsers(users);
|
||||
}
|
||||
const { password: _, ...rest } = user;
|
||||
return { ...rest, lastLoginAt: users[idx].lastLoginAt };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ensureDefaultAdmin,
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
generateToken,
|
||||
findByUsername,
|
||||
findById,
|
||||
getAllUsers,
|
||||
createUser,
|
||||
updateUser,
|
||||
resetPassword,
|
||||
deleteUser,
|
||||
authenticate,
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user