Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5020bd1532 |
@@ -1,85 +1,12 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Read(./.env.example)",
|
||||
|
||||
"Bash(cat:*)",
|
||||
"Bash(head:*)",
|
||||
"Bash(tail:*)",
|
||||
"Bash(less:*)",
|
||||
"Bash(wc:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(tree:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(rg:*)",
|
||||
"Bash(fd:*)",
|
||||
"Bash(file:*)",
|
||||
"Bash(stat:*)",
|
||||
"Bash(du:*)",
|
||||
"Bash(diff:*)",
|
||||
"Bash(which:*)",
|
||||
"Bash(whoami:*)",
|
||||
"Bash(echo:*)",
|
||||
"Bash(pwd:*)",
|
||||
"Bash(date:*)",
|
||||
"Bash(uname:*)",
|
||||
|
||||
"Bash(git status:*)",
|
||||
"Bash(git diff:*)",
|
||||
"Bash(git log:*)",
|
||||
"Bash(git branch:*)",
|
||||
"Bash(git show:*)",
|
||||
"Bash(git remote:*)",
|
||||
"Bash(git tag:*)",
|
||||
"Bash(git stash list:*)",
|
||||
"Bash(git blame:*)",
|
||||
"Bash(git shortlog:*)",
|
||||
"Bash(git rev-parse:*)",
|
||||
|
||||
"Task",
|
||||
|
||||
"Edit",
|
||||
"Write",
|
||||
"MultiEdit",
|
||||
|
||||
"Bash(pnpm install:*)",
|
||||
"Bash(pnpm add:*)",
|
||||
"Bash(pnpm run build:*)",
|
||||
"Bash(pnpm prisma generate:*)",
|
||||
"Bash(pnpm run lint:*)",
|
||||
"Bash(pip install:*)",
|
||||
"Bash(pip3 install:*)",
|
||||
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(cp:*)",
|
||||
"Bash(mv:*)",
|
||||
"Bash(rm:*)",
|
||||
"Bash(touch:*)",
|
||||
"Bash(chmod:*)",
|
||||
"Bash(chown:*)",
|
||||
"Bash(ln:*)",
|
||||
"Bash(tar:*)",
|
||||
"Bash(unzip:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(wget:*)",
|
||||
"Bash(sed:*)",
|
||||
"Bash(awk:*)",
|
||||
"Bash(sort:*)",
|
||||
"Bash(uniq:*)",
|
||||
"Bash(cut:*)",
|
||||
"Bash(xargs:*)",
|
||||
"Bash(tee:*)",
|
||||
"Bash(jq:*)",
|
||||
|
||||
"mcp__ide__getDiagnostics"
|
||||
"Read(./.env.example)"
|
||||
],
|
||||
"deny": [
|
||||
"Read(./.env)",
|
||||
"Read(./.env.development)",
|
||||
"Read(./.env.production)",
|
||||
|
||||
"Read(./.git/config)"
|
||||
"Read(./.env.production)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,415 +0,0 @@
|
||||
---
|
||||
name: pku-iaaa
|
||||
description: |
|
||||
Integrate Peking University (PKU) IAAA unified authentication system into web applications.
|
||||
Use this skill whenever the user wants to implement PKU IAAA SSO login, PKU unified identity
|
||||
authentication, or connect their app to iaaa.pku.edu.cn. Also trigger when the user mentions
|
||||
"北大统一认证", "IAAA认证", "北大SSO", "PKU login", or wants to add campus authentication
|
||||
for a Peking University application. Covers login redirect, token validation, proxy SSO,
|
||||
and account verification — for any web framework (Django, Spring Boot, Express.js, Flask, etc).
|
||||
---
|
||||
|
||||
# 北京大学 IAAA 统一身份认证集成
|
||||
|
||||
本 skill 指导你将北京大学 IAAA(统一安全系统)身份认证集成到任意 Web 应用中。
|
||||
|
||||
## 前置条件
|
||||
|
||||
集成前需在 IAAA 注册应用系统,准备以下信息:
|
||||
- **应用系统 ID**(`appId`):英文字母开头,可含数字
|
||||
- **Key**:由 IAAA 提供,用于生成消息摘要
|
||||
- **服务器 IP 地址**:应用系统所在服务器的 IP
|
||||
- **回调地址 URL**(`redirectUrl`):用于接收 token 的回调端点
|
||||
|
||||
申请方式:管理部门用户登录校内门户 → "信息服务"或"办事大厅" → 搜索"统一身份认证应用备案申请"。
|
||||
注册后可在 `https://i3a.pku.edu.cn/iaaa/index.jsp` 自行管理服务器 IP 和回调 URL。
|
||||
|
||||
## 开始集成前,先确认需求
|
||||
|
||||
在动手写代码之前,先向用户确认:
|
||||
|
||||
1. **使用什么框架?** (Django / Spring Boot / Express.js / Flask / 其他)
|
||||
2. **需要哪种级别的集成?**
|
||||
- 基本认证(登录跳转 + Token 验证)— 绝大多数场景只需这个
|
||||
- 代理 SSO(中心应用 + 分支应用架构)— 参见 `references/proxy-sso.md`
|
||||
- 账号验证(仅校验某个学号/职工号是否存在)— 参见 `references/account-verify.md`
|
||||
3. **系统是否还有非校内账号?** 如果是,需要同时保留自有登录入口(影响前端跳转参数)
|
||||
|
||||
## 认证流程概览
|
||||
|
||||
```
|
||||
用户点击登录 ──POST──> iaaa.pku.edu.cn/iaaa/oauth.jsp
|
||||
│
|
||||
用户输入账号密码认证
|
||||
│
|
||||
认证成功,生成 token
|
||||
│
|
||||
302 重定向到 redirectUrl?token=xxx
|
||||
│
|
||||
应用服务端收到 token
|
||||
│
|
||||
服务端调用 validate.do 验证 token
|
||||
│
|
||||
验证成功,获取用户信息(identityId 等)
|
||||
│
|
||||
匹配本地用户,创建会话
|
||||
```
|
||||
|
||||
## 第一步:登录跳转(前端)
|
||||
|
||||
用户点击登录时,通过 POST 表单跳转到 IAAA 统一登录页面。
|
||||
|
||||
### 关键参数
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `appID` | 注册的应用系统 ID |
|
||||
| `redirectUrl` | 回调地址(验证 token 的端点),若含参数需 URLEncode |
|
||||
| `redirectLogonUrl` | (可选)应用自有登录地址,当系统还有非校内账号时使用 |
|
||||
|
||||
### 实现要点
|
||||
|
||||
- 必须使用 POST 方法提交表单到 `https://iaaa.pku.edu.cn/iaaa/oauth.jsp`
|
||||
- `redirectUrl` 的协议必须与实际部署一致(从当前请求动态获取,不要硬编码 `http://`)
|
||||
- 回调 URL 的尾部斜杠很重要——IAAA 可能因缺少尾部斜杠而拒绝回调
|
||||
|
||||
### 参考实现(JavaScript 前端通用)
|
||||
|
||||
```javascript
|
||||
function redirectToIAAALogin(appID, callbackPath, loginPath) {
|
||||
const form = document.createElement('form');
|
||||
form.action = 'https://iaaa.pku.edu.cn/iaaa/oauth.jsp';
|
||||
form.method = 'POST';
|
||||
form.style.display = 'none';
|
||||
|
||||
function addField(name, value) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = name;
|
||||
input.value = value;
|
||||
form.appendChild(input);
|
||||
}
|
||||
|
||||
// 使用当前页面协议,不要硬编码 http://
|
||||
const baseUrl = `${location.protocol}//${location.host}`;
|
||||
addField('appID', appID);
|
||||
addField('redirectUrl', `${baseUrl}${callbackPath}`);
|
||||
if (loginPath) {
|
||||
addField('redirectLogonUrl', `${baseUrl}${loginPath}`);
|
||||
}
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
```
|
||||
|
||||
## 第二步:Token 验证(服务端)
|
||||
|
||||
IAAA 认证成功后,会重定向浏览器到 `redirectUrl?token=<TOKEN>`。服务端需要验证这个 token。
|
||||
|
||||
### 验证端点
|
||||
|
||||
```
|
||||
GET https://iaaa.pku.edu.cn/iaaa/svc/token/validate.do
|
||||
?remoteAddr={REMOTE_ADDR}
|
||||
&appId={APP_ID}
|
||||
&token={TOKEN}
|
||||
&msgAbs={MSG_ABS}
|
||||
```
|
||||
|
||||
如果不需要返回用户详细信息(只需 identityId),可使用简单验证端点:
|
||||
```
|
||||
GET https://iaaa.pku.edu.cn/iaaa/svc/token/validateSimple.do?...(参数相同)
|
||||
```
|
||||
|
||||
### 参数说明
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `remoteAddr` | 访问应用系统的用户**真实 IP 地址** |
|
||||
| `appId` | 应用系统 ID |
|
||||
| `token` | IAAA 传回的票据 |
|
||||
| `msgAbs` | MD5 消息摘要(见下方计算方法) |
|
||||
|
||||
### msgAbs 计算方法(极其重要)
|
||||
|
||||
1. 取除 `msgAbs` 以外的所有请求参数
|
||||
2. 按**参数名升序**排列
|
||||
3. 拼接为查询字符串格式:`appId={APP_ID}&remoteAddr={REMOTE_ADDR}&token={TOKEN}`
|
||||
4. 将此字符串与 Key 直接拼接(不加分隔符):`PARA_STR + Key`
|
||||
5. 对拼接结果计算 MD5(UTF-8 编码,32 位十六进制,大小写不敏感)
|
||||
|
||||
**Python 示例:**
|
||||
```python
|
||||
import hashlib
|
||||
|
||||
para_str = f"appId={app_id}&remoteAddr={remote_addr}&token={token}"
|
||||
raw = para_str + key
|
||||
msg_abs = hashlib.md5(raw.encode('utf-8')).hexdigest()
|
||||
```
|
||||
|
||||
**Java 示例:**
|
||||
```java
|
||||
String paraStr = "appId=" + appId + "&remoteAddr=" + remoteAddr + "&token=" + token;
|
||||
String raw = paraStr + key;
|
||||
MessageDigest md = MessageDigest.getInstance("MD5");
|
||||
byte[] digest = md.digest(raw.getBytes(StandardCharsets.UTF_8));
|
||||
String msgAbs = String.format("%032x", new BigInteger(1, digest));
|
||||
```
|
||||
|
||||
**Node.js 示例:**
|
||||
```javascript
|
||||
const crypto = require('crypto');
|
||||
const paraStr = `appId=${appId}&remoteAddr=${remoteAddr}&token=${token}`;
|
||||
const msgAbs = crypto.createHash('md5').update(paraStr + key).digest('hex');
|
||||
```
|
||||
|
||||
### 获取客户端真实 IP
|
||||
|
||||
反向代理(Nginx 等)环境下,客户端 IP 通常在 `X-Forwarded-For` 头中。取第一个值(最左侧)作为真实 IP:
|
||||
|
||||
```python
|
||||
# Python / Django
|
||||
remote_addr = request.META.get('HTTP_X_FORWARDED_FOR', '').split(',')[0].strip() \
|
||||
or request.META.get('REMOTE_ADDR', '')
|
||||
```
|
||||
|
||||
```java
|
||||
// Java / Spring Boot
|
||||
String remoteAddr = request.getHeader("X-Forwarded-For");
|
||||
if (remoteAddr != null && !remoteAddr.isEmpty()) {
|
||||
remoteAddr = remoteAddr.split(",")[0].trim();
|
||||
} else {
|
||||
remoteAddr = request.getRemoteAddr();
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// Node.js / Express
|
||||
const remoteAddr = (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
|
||||
|| req.socket.remoteAddress;
|
||||
```
|
||||
|
||||
### 返回结果
|
||||
|
||||
**成功:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"errCode": "0",
|
||||
"errMsg": "认证成功",
|
||||
"userInfo": {
|
||||
"name": "用户姓名",
|
||||
"status": "账号状态(开通/禁止)",
|
||||
"identityId": "身份账号(学号/职工号)",
|
||||
"deptId": "所在院系码",
|
||||
"dept": "所在院系",
|
||||
"identityType": "身份类别(学生/职工)",
|
||||
"detailType": "身份细类",
|
||||
"identityStatus": "身份状态(在校/减离)",
|
||||
"campus": "校区"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**失败:**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"errCode": "错误码",
|
||||
"errMsg": "错误信息"
|
||||
}
|
||||
```
|
||||
|
||||
简单验证端点(`validateSimple.do`)成功时 `userInfo` 中只包含 `identityId`。
|
||||
|
||||
### 服务端验证的完整参考实现
|
||||
|
||||
#### Django
|
||||
|
||||
```python
|
||||
import hashlib
|
||||
import logging
|
||||
import requests
|
||||
from django.contrib.auth import login, get_user_model
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import render, reverse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
|
||||
IAAA_APP_ID = "your_app_id"
|
||||
IAAA_KEY = "your_key"
|
||||
IAAA_USERID_FIELD = "username" # User 模型中与 identityId 对应的字段
|
||||
|
||||
def iaaa_callback(request):
|
||||
"""IAAA 认证回调视图"""
|
||||
token = request.GET.get('token')
|
||||
if not token:
|
||||
return render(request, 'login_failed.html', {'msg': '未收到认证票据'})
|
||||
|
||||
# 获取客户端真实 IP
|
||||
remote_addr = (
|
||||
request.META.get('HTTP_X_FORWARDED_FOR', '').split(',')[0].strip()
|
||||
or request.META.get('REMOTE_ADDR', '')
|
||||
)
|
||||
|
||||
# 计算消息摘要(参数按字母序排列)
|
||||
para_str = f"appId={IAAA_APP_ID}&remoteAddr={remote_addr}&token={token}"
|
||||
msg_abs = hashlib.md5((para_str + IAAA_KEY).encode('utf-8')).hexdigest()
|
||||
|
||||
# 调用 IAAA 验证服务
|
||||
validate_url = (
|
||||
f"https://iaaa.pku.edu.cn/iaaa/svc/token/validate.do"
|
||||
f"?{para_str}&msgAbs={msg_abs}"
|
||||
)
|
||||
|
||||
try:
|
||||
resp = requests.get(validate_url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
except Exception as e:
|
||||
logger.error(f"IAAA token 验证请求失败: {e}")
|
||||
return render(request, 'login_failed.html', {'msg': '身份认证服务暂不可用,请稍后重试'})
|
||||
|
||||
if not result.get('success'):
|
||||
err_code = result.get('errCode', '未知')
|
||||
err_msg = result.get('errMsg', '未知错误')
|
||||
logger.warning(f"IAAA 认证失败: errCode={err_code}, errMsg={err_msg}")
|
||||
return render(request, 'login_failed.html', {'msg': f'认证失败:{err_msg}'})
|
||||
|
||||
# 认证成功,提取用户身份
|
||||
user_info = result.get('userInfo', {})
|
||||
identity_id = user_info.get('identityId', '')
|
||||
if not identity_id:
|
||||
logger.warning(f"IAAA 认证成功但未返回 identityId: {result}")
|
||||
return render(request, 'login_failed.html', {'msg': '无法获取您的身份账号'})
|
||||
|
||||
# 匹配本地用户
|
||||
user = User.objects.filter(**{IAAA_USERID_FIELD: identity_id}).first()
|
||||
if user is None:
|
||||
logger.info(f"IAAA 用户 {identity_id} 在系统中不存在")
|
||||
return render(request, 'login_failed.html', {'msg': '系统中不存在与您北大账号对应的用户'})
|
||||
|
||||
# 登录
|
||||
login(request, user)
|
||||
logger.info(f"IAAA 登录成功: {identity_id} ({user_info.get('name', '')})")
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
```
|
||||
|
||||
#### Spring Boot
|
||||
|
||||
```java
|
||||
@GetMapping("/iaaa/callback")
|
||||
public String iaaaCallback(
|
||||
@RequestParam String token,
|
||||
HttpServletRequest request,
|
||||
HttpSession session) throws Exception {
|
||||
|
||||
// 获取客户端真实 IP
|
||||
String remoteAddr = request.getHeader("X-Forwarded-For");
|
||||
if (remoteAddr != null && !remoteAddr.isEmpty()) {
|
||||
remoteAddr = remoteAddr.split(",")[0].trim();
|
||||
} else {
|
||||
remoteAddr = request.getRemoteAddr();
|
||||
}
|
||||
|
||||
// 计算消息摘要
|
||||
String paraStr = "appId=" + appId + "&remoteAddr=" + remoteAddr + "&token=" + token;
|
||||
String msgAbs = md5(paraStr + key);
|
||||
|
||||
// 调用验证服务
|
||||
String url = "https://iaaa.pku.edu.cn/iaaa/svc/token/validate.do?"
|
||||
+ paraStr + "&msgAbs=" + msgAbs;
|
||||
RestTemplate rest = new RestTemplate();
|
||||
Map<String, Object> result = rest.getForObject(url, Map.class);
|
||||
|
||||
if (!(Boolean) result.get("success")) {
|
||||
String errMsg = (String) result.getOrDefault("errMsg", "认证失败");
|
||||
// 处理失败...
|
||||
return "redirect:/login?error=" + URLEncoder.encode(errMsg, "UTF-8");
|
||||
}
|
||||
|
||||
Map<String, String> userInfo = (Map<String, String>) result.get("userInfo");
|
||||
String identityId = userInfo.get("identityId");
|
||||
// 查找本地用户,创建会话...
|
||||
|
||||
return "redirect:/";
|
||||
}
|
||||
|
||||
private String md5(String input) throws Exception {
|
||||
MessageDigest md = MessageDigest.getInstance("MD5");
|
||||
byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
|
||||
return String.format("%032x", new java.math.BigInteger(1, digest));
|
||||
}
|
||||
```
|
||||
|
||||
#### Express.js
|
||||
|
||||
```javascript
|
||||
const crypto = require('crypto');
|
||||
const axios = require('axios');
|
||||
|
||||
app.get('/iaaa/callback', async (req, res) => {
|
||||
const { token } = req.query;
|
||||
if (!token) return res.redirect('/login?error=no_token');
|
||||
|
||||
const remoteAddr = (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
|
||||
|| req.socket.remoteAddress;
|
||||
|
||||
const paraStr = `appId=${APP_ID}&remoteAddr=${remoteAddr}&token=${token}`;
|
||||
const msgAbs = crypto.createHash('md5').update(paraStr + KEY).digest('hex');
|
||||
|
||||
const url = `https://iaaa.pku.edu.cn/iaaa/svc/token/validate.do?${paraStr}&msgAbs=${msgAbs}`;
|
||||
|
||||
try {
|
||||
const { data } = await axios.get(url);
|
||||
|
||||
if (!data.success) {
|
||||
console.warn(`IAAA 认证失败: ${data.errCode} - ${data.errMsg}`);
|
||||
return res.redirect(`/login?error=${encodeURIComponent(data.errMsg || '认证失败')}`);
|
||||
}
|
||||
|
||||
const { identityId, name } = data.userInfo;
|
||||
if (!identityId) {
|
||||
return res.redirect('/login?error=no_identity');
|
||||
}
|
||||
|
||||
// 查找本地用户,创建 session...
|
||||
const user = await User.findOne({ where: { username: identityId } });
|
||||
if (!user) {
|
||||
return res.redirect('/login?error=user_not_found');
|
||||
}
|
||||
|
||||
req.session.userId = user.id;
|
||||
console.log(`IAAA 登录成功: ${identityId} (${name})`);
|
||||
res.redirect('/');
|
||||
} catch (err) {
|
||||
console.error('IAAA 验证请求失败:', err.message);
|
||||
res.redirect('/login?error=service_unavailable');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 常见易错点
|
||||
|
||||
实现时特别注意以下几点,这些是实际集成中最常见的 bug 来源:
|
||||
|
||||
1. **协议硬编码**:`redirectUrl` 中不要写死 `http://`,应从当前请求或配置动态获取。生产环境通常是 HTTPS,协议不匹配会导致回调失败。
|
||||
|
||||
2. **msgAbs 参数排序**:参数必须严格按参数名字母升序拼接。当前 `validate.do` 只有三个参数(`appId`, `remoteAddr`, `token`),恰好已是字母序。但如果使用代理认证等其他端点(参数更多),一定要排序。
|
||||
|
||||
3. **错误响应处理**:IAAA 返回 `success: false` 时,务必提取 `errCode` 和 `errMsg` 记录到日志并展示给用户,不要只返回一个笼统的"认证失败"。
|
||||
|
||||
4. **客户端 IP**:`remoteAddr` 必须是用户的真实 IP。如果应用在反向代理之后,使用 `X-Forwarded-For` 头的第一个值。IP 不匹配会导致 token 验证失败。
|
||||
|
||||
5. **回调 URL 尾部斜杠**:IAAA 在注册时绑定了精确的回调 URL,运行时传入的 `redirectUrl` 必须完全匹配(包括尾部斜杠)。
|
||||
|
||||
6. **服务端可达性**:应用服务器必须能访问 `iaaa.pku.edu.cn` 的 443 端口(HTTPS)。
|
||||
|
||||
## 高级功能
|
||||
|
||||
对于需要代理 SSO(中心应用 + 分支应用)或账号验证的场景,参阅:
|
||||
|
||||
- `references/proxy-sso.md` — 代理身份认证(单点登录多个应用)
|
||||
- `references/account-verify.md` — 验证北京大学账号是否存在
|
||||
@@ -1,78 +0,0 @@
|
||||
# 北京大学账号验证
|
||||
|
||||
用于验证某个学号/职工号是否为有效的北京大学账号,不涉及登录流程。
|
||||
|
||||
## API
|
||||
|
||||
```
|
||||
GET https://iaaa.pku.edu.cn/iaaa/svc/pub/validate.do
|
||||
?userId={USERID}
|
||||
&userName={USERNAME}
|
||||
&appId={APPID}
|
||||
&msgAbs={MD5MSG}
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `userId` | 学号或职工号 |
|
||||
| `userName` | 账号姓名(传输时需 URLEncode,但计算 MD5 摘要时使用原始值,不做 URLEncode) |
|
||||
| `appId` | 应用 ID |
|
||||
| `msgAbs` | MD5 消息摘要 |
|
||||
|
||||
## msgAbs 计算
|
||||
|
||||
与其他端点规则一致:除 `msgAbs` 外所有参数按参数名升序排列拼接,再拼接 Key,取 MD5。
|
||||
|
||||
PARA_STR 排序后为:`appId={APPID}&userId={USERID}&userName={USERNAME}`
|
||||
|
||||
注意:`userName` 在 PARA_STR 中使用**原始值**(不做 URLEncode),只有在 URL 传输时才 URLEncode。
|
||||
|
||||
## 返回
|
||||
|
||||
**正常:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"valid": true,
|
||||
"userType": "用户身份类别"
|
||||
}
|
||||
```
|
||||
|
||||
`valid` 为 `true` 表示账号存在,`false` 表示不存在。
|
||||
|
||||
**异常:**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"errCode": "错误代码",
|
||||
"errMsg": "错误信息"
|
||||
}
|
||||
```
|
||||
|
||||
## Python 示例
|
||||
|
||||
```python
|
||||
import hashlib
|
||||
import urllib.parse
|
||||
import requests
|
||||
|
||||
def verify_pku_account(user_id: str, user_name: str, app_id: str, key: str) -> dict:
|
||||
"""验证北大账号是否存在"""
|
||||
# msgAbs 中 userName 使用原始值
|
||||
para_str = f"appId={app_id}&userId={user_id}&userName={user_name}"
|
||||
msg_abs = hashlib.md5((para_str + key).encode('utf-8')).hexdigest()
|
||||
|
||||
# URL 中 userName 需要 URLEncode
|
||||
url = (
|
||||
f"https://iaaa.pku.edu.cn/iaaa/svc/pub/validate.do"
|
||||
f"?appId={app_id}"
|
||||
f"&userId={user_id}"
|
||||
f"&userName={urllib.parse.quote(user_name)}"
|
||||
f"&msgAbs={msg_abs}"
|
||||
)
|
||||
resp = requests.get(url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
```
|
||||
@@ -1,120 +0,0 @@
|
||||
# 代理身份认证(Proxy SSO)
|
||||
|
||||
适用场景:应用 O(中心应用)登录后,用户点击进入应用 A、B、C(分支应用)时无需重新登录。
|
||||
|
||||
## 流程
|
||||
|
||||
```
|
||||
用户登录中心应用 O
|
||||
│
|
||||
├─ O 使用 validateWithProxy.do 验证 token → 获得 grantToken
|
||||
│
|
||||
├─ 用户点击分支应用 A
|
||||
│ ├─ O 调用 validateProxyGrantToken.do → 获得分支应用 A 的 token
|
||||
│ └─ 将 token 传给分支应用 A → A 用 validate.do 验证
|
||||
│
|
||||
└─ 用户退出中心应用 O
|
||||
└─ O 调用 expireProxy.do 注销 grantToken
|
||||
```
|
||||
|
||||
## API 详情
|
||||
|
||||
### 1. 中心应用验证(获取 grantToken)
|
||||
|
||||
```
|
||||
GET https://iaaa.pku.edu.cn/iaaa/svc/token/validateWithProxy.do
|
||||
?remoteAddr={REMOTE_ADDR}
|
||||
&appId={APP_ID}
|
||||
&token={TOKEN}
|
||||
&msgAbs={MSG_ABS}
|
||||
```
|
||||
|
||||
参数和 msgAbs 计算方式与标准 `validate.do` 相同。
|
||||
|
||||
**成功返回:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"errCode": "0",
|
||||
"errMsg": "认证成功",
|
||||
"userInfo": {
|
||||
"name": "用户姓名",
|
||||
"status": "账号状态",
|
||||
"identityId": "身份账号",
|
||||
"deptId": "所在院系码",
|
||||
"dept": "所在院系",
|
||||
"identityType": "身份类别",
|
||||
"detailType": "身份细类",
|
||||
"identityStatus": "身份状态"
|
||||
},
|
||||
"grantToken": "代理票据"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 获取分支应用 Token
|
||||
|
||||
```
|
||||
GET https://iaaa.pku.edu.cn/iaaa/svc/token/validateProxyGrantToken.do
|
||||
?remoteAddr={REMOTE_ADDR}
|
||||
&appId={APP_ID}
|
||||
&targetAppId={TARGET_APP_ID}
|
||||
&grantToken={GRANT_TOKEN}
|
||||
×tamp={TIMESTAMP}
|
||||
&msgAbs={MSG_ABS}
|
||||
```
|
||||
|
||||
**参数:**
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `remoteAddr` | 客户端 IP |
|
||||
| `appId` | 中心应用 ID |
|
||||
| `targetAppId` | 分支应用 ID |
|
||||
| `grantToken` | 上一步获得的代理票据 |
|
||||
| `timestamp` | 当前时间戳(long 型整数) |
|
||||
| `msgAbs` | MD5 摘要,PARA_STR 为除 msgAbs 外所有参数按参数名升序拼接 |
|
||||
|
||||
注意:此端点参数较多,PARA_STR 排序后为:
|
||||
`appId={}&grantToken={}&remoteAddr={}&targetAppId={}×tamp={}`
|
||||
|
||||
**成功返回:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"errCode": "0",
|
||||
"errMsg": "验证成功",
|
||||
"token": "分支应用访问票据"
|
||||
}
|
||||
```
|
||||
|
||||
**错误码 12(需要手机验证):** 重定向用户到:
|
||||
```
|
||||
https://iaaa.pku.edu.cn/iaaa/ma4proxy.jsp
|
||||
?proxyAppId={中心应用ID}
|
||||
&appId={分支应用ID}
|
||||
&userId={用户账号}
|
||||
&grantToken={GRANT_TOKEN}
|
||||
&redirectUrl={分支应用回调地址,需URLEncode}
|
||||
```
|
||||
|
||||
### 3. 分支应用验证 Token
|
||||
|
||||
分支应用收到 token 后,使用标准 `validate.do` 或 `validateSimple.do` 验证,与普通认证流程相同。
|
||||
|
||||
### 4. 注销代理 Token
|
||||
|
||||
中心应用退出时或必要时调用:
|
||||
|
||||
```
|
||||
GET https://iaaa.pku.edu.cn/iaaa/svc/token/expireProxy.do
|
||||
?appId={APP_ID}
|
||||
&grantToken={GRANT_TOKEN}
|
||||
&msgAbs={MSG_ABS}
|
||||
```
|
||||
|
||||
msgAbs 的 PARA_STR:`appId={}&grantToken={}`(按参数名升序)
|
||||
|
||||
**返回:**
|
||||
```json
|
||||
{"success": true, "errCode": "0", "errMsg": "注销成功"}
|
||||
```
|
||||
@@ -45,13 +45,10 @@ RUN apt-get update && apt-get install -y \
|
||||
telnet \
|
||||
redis-tools \
|
||||
iputils-ping \
|
||||
dnsutils \
|
||||
potrace \
|
||||
imagemagick \
|
||||
zsh \
|
||||
vim \
|
||||
rsync \
|
||||
openjdk-17-jdk \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 安装 Python 3.12
|
||||
@@ -98,12 +95,8 @@ RUN curl -fsSL https://code-server.dev/install.sh | sh -s -- --version=${CODE_SE
|
||||
|
||||
# 安装 npm 全局包
|
||||
RUN npm install -g \
|
||||
@musistudio/claude-code-router \
|
||||
pm2
|
||||
|
||||
# 安装 claude code 编程代理
|
||||
RUN curl -fsSL https://claude.ai/install.sh | bash && \
|
||||
echo 'eval "$(ccr activate)"' >> /root/.zshrc
|
||||
@anthropic-ai/claude-code \
|
||||
@musistudio/claude-code-router
|
||||
|
||||
# 创建工作目录
|
||||
RUN mkdir -p /workspace /root/.local/share/code-server/User
|
||||
@@ -120,7 +113,7 @@ RUN code-server --install-extension ms-ceintl.vscode-language-pack-zh-hans \
|
||||
&& code-server --install-extension dbaeumer.vscode-eslint \
|
||||
&& code-server --install-extension prisma.prisma \
|
||||
&& code-server --install-extension ecmel.vscode-html-css \
|
||||
&& code-server --install-extension cweijan.vscode-database-client2 \
|
||||
&& code-server --install-extension cweijan.vscode-redis-client \
|
||||
&& code-server --install-extension anthropic.claude-code
|
||||
|
||||
# 配置 code-server (密码将在启动时设置)
|
||||
|
||||
@@ -23,8 +23,6 @@ services:
|
||||
- claude-code-router:/root/.claude-code-router
|
||||
# SSH 配置
|
||||
- ssh:/root/.ssh
|
||||
# 零碎文件持久化
|
||||
- persist:/root/.hair-keeper-persist
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- TZ=Asia/Shanghai
|
||||
@@ -42,12 +40,6 @@ services:
|
||||
memory: 4G
|
||||
|
||||
volumes:
|
||||
code-server-config:
|
||||
code-server-data:
|
||||
claude:
|
||||
claude-code-router:
|
||||
ssh:
|
||||
persist:
|
||||
# node_modules 卷,避免主机和容器之间的文件系统差异
|
||||
node_modules:
|
||||
# pnpm store 卷,加速依赖安装
|
||||
|
||||
@@ -1,40 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================
|
||||
# 文件持久化:启动时恢复 + 后台定期同步到命名卷
|
||||
# ============================================
|
||||
PERSIST_DIR="/root/.hair-keeper-persist"
|
||||
PERSIST_FILES=(
|
||||
"/root/.claude.json"
|
||||
"/root/.zsh_history"
|
||||
"/root/.bash_history"
|
||||
)
|
||||
|
||||
mkdir -p "$PERSIST_DIR"
|
||||
|
||||
# 启动时恢复:持久化目录中的文件优先覆盖到原路径
|
||||
for filepath in "${PERSIST_FILES[@]}"; do
|
||||
filename=$(basename "$filepath")
|
||||
persist_path="$PERSIST_DIR/$filename"
|
||||
if [ -f "$persist_path" ] && [ -s "$persist_path" ]; then
|
||||
cp "$persist_path" "$filepath"
|
||||
fi
|
||||
done
|
||||
|
||||
# 后台定期同步:将原路径文件同步回持久化目录(每60秒)
|
||||
(
|
||||
while true; do
|
||||
sleep 60
|
||||
for filepath in "${PERSIST_FILES[@]}"; do
|
||||
filename=$(basename "$filepath")
|
||||
persist_path="$PERSIST_DIR/$filename"
|
||||
if [ -f "$filepath" ]; then
|
||||
rsync -a "$filepath" "$persist_path"
|
||||
fi
|
||||
done
|
||||
done
|
||||
) &
|
||||
|
||||
# 设置默认密码
|
||||
DEV_PASSWORD=${DEV_PASSWORD:-clouddev}
|
||||
|
||||
@@ -77,20 +42,5 @@ echo ""
|
||||
echo "提示: 可通过环境变量 DEV_PASSWORD 自定义密码"
|
||||
echo ""
|
||||
|
||||
# 容器停止时执行最终同步
|
||||
cleanup() {
|
||||
echo "Syncing persistent files before shutdown..."
|
||||
for filepath in "${PERSIST_FILES[@]}"; do
|
||||
filename=$(basename "$filepath")
|
||||
persist_path="$PERSIST_DIR/$filename"
|
||||
if [ -f "$filepath" ]; then
|
||||
rsync -a "$filepath" "$persist_path"
|
||||
fi
|
||||
done
|
||||
exit 0
|
||||
}
|
||||
trap cleanup SIGTERM SIGINT
|
||||
|
||||
# 保持容器运行
|
||||
tail -f /dev/null &
|
||||
wait $!
|
||||
tail -f /dev/null
|
||||
@@ -51,14 +51,6 @@ NEXTAUTH_SECRET=
|
||||
PKUAI_API_KEY=
|
||||
PKUAI_API_BASE=
|
||||
|
||||
# 北京大学 IAAA 统一认证(可选,不配置则不启用)
|
||||
## 在 IAAA 注册的应用系统 ID
|
||||
IAAA_APP_ID=
|
||||
## IAAA 提供的密钥,用于生成消息摘要
|
||||
IAAA_KEY=
|
||||
## 设为 true 则进入登录页后自动跳转到统一认证(默认 false)
|
||||
IAAA_AUTO_REDIRECT=
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -15,7 +15,6 @@
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/.next-prod/
|
||||
/out/
|
||||
|
||||
# production
|
||||
@@ -52,6 +51,3 @@ tasks.md
|
||||
# lock 一般的项目需要用git管理,但这个是模板项目就不管理
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
|
||||
# claude
|
||||
.claude/*.local.*
|
||||
70
CLAUDE.md
70
CLAUDE.md
@@ -3,7 +3,7 @@
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 项目说明
|
||||
本项目模板(Hair Keeper v1.3.0)是一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证(支持SSO单点登录)、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑。
|
||||
本项目模板(Hair Keeper v1.1.0)是一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑。
|
||||
|
||||
Hair Keeper是个诙谐有趣的名称,和项目内容毫无关系。
|
||||
|
||||
@@ -50,45 +50,43 @@ Hair Keeper是个诙谐有趣的名称,和项目内容毫无关系。
|
||||
|
||||
## 重要目录和文件
|
||||
### 前端
|
||||
- `src/components/common/`:进一步封装的高级通用控件,例如下拉菜单、对话框表单、响应式tabs等控件
|
||||
- `src/components/features/`:进一步封装的控件,通常更重或者与业务关联强需要在不同的页面中复用
|
||||
- `src/components/ai-elements/`:ai对话相关的组件
|
||||
- `src/components/data-details/`:专注于数据展示的可复用控件,例如detail-badge-list、detail-copyable、detail-list、detail-timeline等控件
|
||||
- `src/components/data-table/`:专注于数据表格的可复用控件,本项目模板自带了基础的data-table、过滤器、排序、分页、列可见性切换等功能
|
||||
- `src/components/icons/`:项目的自定义图标可以写在这个文件夹
|
||||
- `src/components/layout/`:应用的完整布局框架和导航系统以及可复用的布局容器
|
||||
- `src/components/ui/`:高度可定制可复用的基础UI组件,通常源自第三方库
|
||||
- `src/app/(main)/`:开发者在这里自行实现的所有业务的页面
|
||||
- `src/app/(main)/dev/`:辅助开发的页面,本项目模板在其中实现了许多功能,代码在实现业务时也可以借鉴参考
|
||||
- `src/app/(main)/settings/`:全局设置,由开发者根据业务需求进行补充和实现
|
||||
- `src/app/(main)/users/`:用户管理模块,提供用户CRUD、角色管理、批量授权等完整的用户管理功能的页面和组件实现
|
||||
- `src/hooks/`:可复用React Hooks库,部分复杂的组件也通过hook实现Headless UI逻辑与样式分离,组件中可复用的逻辑都可以放在这
|
||||
- `src/lib/trpc.ts`:创建并导出tRPC React客户端实例,用于前端与后端API通信
|
||||
- `src/lib/stores/`:通过zustand管理的全局的状态
|
||||
- `components/common/`:进一步封装的高级通用控件,例如下拉菜单、对话框表单、响应式tabs等控件
|
||||
- `components/features/`:进一步封装的控件,通常更重或者与业务关联强需要在不同的页面中复用
|
||||
- `components/ai-elements/`:ai对话相关的组件
|
||||
- `components/data-details/`:专注于数据展示的可复用控件,例如detail-badge-list、detail-copyable、detail-list、detail-timeline等控件
|
||||
- `components/data-table/`:专注于数据表格的可复用控件,本项目模板自带了基础的data-table、过滤器、排序、分页、列可见性切换等功能
|
||||
- `components/icons/`:项目的自定义图标可以写在这个文件夹
|
||||
- `components/layout/`:应用的完整布局框架和导航系统以及可复用的布局容器
|
||||
- `components/ui/`:高度可定制可复用的基础UI组件,通常源自第三方库
|
||||
- `app/(main)/`:开发者在这里自行实现的所有业务的页面
|
||||
- `app/(main)/dev/`:辅助开发的页面,本项目模板在其中实现了许多功能,代码在实现业务时也可以借鉴参考
|
||||
- `app/(main)/settings/`:全局设置,由开发者根据业务需求进行补充和实现
|
||||
- `app/(main)/users/`:用户管理模块,提供用户CRUD、角色管理、批量授权等完整的用户管理功能的页面和组件实现
|
||||
- `hooks/`:可复用React Hooks库,部分复杂的组件也通过hook实现Headless UI逻辑与样式分离,组件中可复用的逻辑都可以放在这
|
||||
- `lib/trpc.ts`:创建并导出tRPC React客户端实例,用于前端与后端API通信
|
||||
- `lib/stores/`:通过zustand管理的全局的状态
|
||||
|
||||
### 后端
|
||||
- `src/server/routers/`:项目trpc api定义文件,开发者主要在这里定义和实现业务的后端API
|
||||
- `src/server/routers/_app.ts`:`appRouter`根路由定义,需要添加子路由时在此处注册
|
||||
- `src/server/routers/common.ts`:定义需要在多个模块中复用的通用业务接口路由
|
||||
- `src/server/routers/jobs.ts`:tRPC任务进度订阅路由
|
||||
- `src/server/routers/selection.ts`:用于记录用户选择的选项或者输入的内容,优化用户的输入体验
|
||||
- `src/server/routers/global.ts`:系统全局和特定业务关联不大的一些api
|
||||
- `src/server/routers/dev/`:开发模式下的辅助功能需要的trpc api
|
||||
- `src/server/queues/`:消息队列和worker,通过其中的index.ts统一导出,任务状态更新采用trpc SSE subscription,接口定义在`server/routers/jobs.ts`中
|
||||
- `src/server/agents`:LLM的对接和使用
|
||||
- `src/server/service/`:服务层模块集合,封装后端业务逻辑和系统服务
|
||||
- `src/server/service/session.ts`:会话管理服务,基于Redis实现权限变更后强制重新登录
|
||||
- `src/server/service/iaaa.ts`:北京大学统一认证(IAAA)对接服务,通过环境变量配置即可启用
|
||||
- `src/server/service/dev/`:开发模式下的辅助功能需要的后台服务
|
||||
- `src/server/utils/`:服务端专用工具函数库,为后端业务逻辑提供基础设施支持
|
||||
- `src/api/dev/`:开发模式下的辅助功能需要的api
|
||||
- `server/routers/`:项目trpc api定义文件,开发者主要在这里定义和实现业务的后端API
|
||||
- `server/routers/_app.ts`:`appRouter`根路由定义,需要添加子路由时在此处注册
|
||||
- `server/routers/common.ts`:定义需要在多个模块中复用的通用业务接口路由
|
||||
- `server/routers/jobs.ts`:tRPC任务进度订阅路由
|
||||
- `server/routers/selection.ts`:用于记录用户选择的选项或者输入的内容,优化用户的输入体验
|
||||
- `server/routers/global.ts`:系统全局和特定业务关联不大的一些api
|
||||
- `server/routers/dev/`:开发模式下的辅助功能需要的trpc api
|
||||
- `server/queues/`:消息队列和worker,通过其中的index.ts统一导出,任务状态更新采用trpc SSE subscription,接口定义在`server/routers/jobs.ts`中
|
||||
- `server/agents`:LLM的对接和使用
|
||||
- `server/service/`:服务层模块集合,封装后端业务逻辑和系统服务
|
||||
- `server/service/dev/`:开发模式下的辅助功能需要的后台服务
|
||||
- `server/utils/`:服务端专用工具函数库,为后端业务逻辑提供基础设施支持
|
||||
- `api/dev/`:开发模式下的辅助功能需要的api
|
||||
|
||||
### 其他
|
||||
- `src/constants/`:项目全局常量管理
|
||||
- `src/constants/permissions.ts`:权限定义,支持前后端一致的权限控制,支持解析复杂的权限表达式(如"A&B|(C&D)")
|
||||
- `src/lib/schema/`:集中管理数据验证schema,定义前后端统一的数据结构和验证规则,前端对默认值等其他要求写在表单组件中,后端对默认值等其他要求写在接口文件中,使用z.input而不是z.infer来获取Schema的输入类型
|
||||
- `src/lib/algorithom.ts`:通用计算机算法实现,例如拓扑排序
|
||||
- `src/lib/format.ts`:数据格式化工具函数库
|
||||
- `constants/`:项目全局常量管理
|
||||
- `constants/permissions.ts`:权限定义,支持前后端一致的权限控制,支持解析复杂的权限表达式(如"A&B|(C&D)")
|
||||
- `lib/schema/`:集中管理数据验证schema,定义前后端统一的数据结构和验证规则,前端对默认值等其他要求写在表单组件中,后端对默认值等其他要求写在接口文件中,使用z.input而不是z.infer来获取Schema的输入类型
|
||||
- `lib/algorithom.ts`:通用计算机算法实现,例如拓扑排序
|
||||
- `lib/format.ts`:数据格式化工具函数库
|
||||
|
||||
## 非标准命令
|
||||
- `pnpm run dev:attach`:这会使用tmux在名为nextdev的session中启动pnpm run dev,便于在开发页面或其他地方与开发服务器交互
|
||||
|
||||
76
README.md
76
README.md
@@ -1,16 +1,11 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 项目说明
|
||||
本项目模板(Hair Keeper v1.3.0)是一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证(支持SSO单点登录)、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑。
|
||||
本项目模板(Hair Keeper v1.1.0)是一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑。
|
||||
|
||||
Hair Keeper是个诙谐有趣的名称,和项目内容毫无关系。
|
||||
|
||||
开发者直接在本项目模板的基础上进行开发,本项目源代码完全对开发者可见并可以随时修改、扩展功能、增加新的组件和模块,开发者尽量遵从如下文表述的约定和项目文件组织规则。
|
||||
|
||||
## 主要依赖库
|
||||
本项目使用pnpm作为包管理器
|
||||
- 基础:next + react + trpc + prisma
|
||||
- UI基础框架:tailwindcss + radix-ui(基于shadcn/ui库添加组件) + lucide-react + sonner(toast)
|
||||
- 图表等高级UI:recharts(图表) + xyflow/react(节点图 dagre自动布局) + embla-carousel-react + dnd-kit/sortable
|
||||
@@ -50,45 +45,44 @@ Hair Keeper是个诙谐有趣的名称,和项目内容毫无关系。
|
||||
|
||||
## 重要目录和文件
|
||||
### 前端
|
||||
- `src/components/common/`:进一步封装的高级通用控件,例如下拉菜单、对话框表单、响应式tabs等控件
|
||||
- `src/components/features/`:进一步封装的控件,通常更重或者与业务关联强需要在不同的页面中复用
|
||||
- `src/components/ai-elements/`:ai对话相关的组件
|
||||
- `src/components/data-details/`:专注于数据展示的可复用控件,例如detail-badge-list、detail-copyable、detail-list、detail-timeline等控件
|
||||
- `src/components/data-table/`:专注于数据表格的可复用控件,本项目模板自带了基础的data-table、过滤器、排序、分页、列可见性切换等功能
|
||||
- `src/components/icons/`:项目的自定义图标可以写在这个文件夹
|
||||
- `src/components/layout/`:应用的完整布局框架和导航系统以及可复用的布局容器
|
||||
- `src/components/ui/`:高度可定制可复用的基础UI组件,通常源自第三方库
|
||||
- `src/app/(main)/`:开发者在这里自行实现的所有业务的页面
|
||||
- `src/app/(main)/dev/`:辅助开发的页面,本项目模板在其中实现了许多功能,代码在实现业务时也可以借鉴参考
|
||||
- `src/app/(main)/settings/`:全局设置,由开发者根据业务需求进行补充和实现
|
||||
- `src/app/(main)/users/`:用户管理模块,提供用户CRUD、角色管理、批量授权等完整的用户管理功能的页面和组件实现
|
||||
- `src/hooks/`:可复用React Hooks库,部分复杂的组件也通过hook实现Headless UI逻辑与样式分离,组件中可复用的逻辑都可以放在这
|
||||
- `src/lib/trpc.ts`:创建并导出tRPC React客户端实例,用于前端与后端API通信
|
||||
- `src/lib/stores/`:通过zustand管理的全局的状态
|
||||
- `components/common/`:进一步封装的高级通用控件,例如下拉菜单、对话框表单、响应式tabs等控件
|
||||
- `components/features/`:进一步封装的控件,通常更重或者与业务关联强需要在不同的页面中复用
|
||||
- `components/ai-elements/`:ai对话相关的组件
|
||||
- `components/data-details/`:专注于数据展示的可复用控件,例如detail-badge-list、detail-copyable、detail-list、detail-timeline等控件
|
||||
- `components/data-table/`:专注于数据表格的可复用控件,本项目模板自带了基础的data-table、过滤器、排序、分页、列可见性切换等功能
|
||||
- `components/icons/`:项目的自定义图标可以写在这个文件夹
|
||||
- `components/layout/`:应用的完整布局框架和导航系统以及可复用的布局容器
|
||||
- `components/ui/`:高度可定制可复用的基础UI组件,通常源自第三方库
|
||||
- `app/(main)/`:开发者在这里自行实现的所有业务的页面
|
||||
- `app/(main)/dev/`:辅助开发的页面,本项目模板在其中实现了许多功能,代码在实现业务时也可以借鉴参考
|
||||
- `app/(main)/settings/`:全局设置,由开发者根据业务需求进行补充和实现
|
||||
- `app/(main)/users/`:用户管理模块,提供用户CRUD、角色管理、批量授权等完整的用户管理功能的页面和组件实现
|
||||
- `hooks/`:可复用React Hooks库,部分复杂的组件也通过hook实现Headless UI逻辑与样式分离,组件中可复用的逻辑都可以放在这
|
||||
- `lib/trpc.ts`:创建并导出tRPC React客户端实例,用于前端与后端API通信
|
||||
- `lib/stores/`:通过zustand管理的全局的状态
|
||||
|
||||
### 后端
|
||||
- `src/server/routers/`:项目trpc api定义文件,开发者主要在这里定义和实现业务的后端API
|
||||
- `src/server/routers/_app.ts`:`appRouter`根路由定义,需要添加子路由时在此处注册
|
||||
- `src/server/routers/common.ts`:定义需要在多个模块中复用的通用业务接口路由
|
||||
- `src/server/routers/jobs.ts`:tRPC任务进度订阅路由
|
||||
- `src/server/routers/selection.ts`:用于记录用户选择的选项或者输入的内容,优化用户的输入体验
|
||||
- `src/server/routers/global.ts`:系统全局和特定业务关联不大的一些api
|
||||
- `src/server/routers/dev/`:开发模式下的辅助功能需要的trpc api
|
||||
- `src/server/queues/`:消息队列和worker,通过其中的index.ts统一导出,任务状态更新采用trpc SSE subscription,接口定义在`server/routers/jobs.ts`中
|
||||
- `src/server/agents`:LLM的对接和使用
|
||||
- `src/server/service/`:服务层模块集合,封装后端业务逻辑和系统服务
|
||||
- `src/server/service/session.ts`:会话管理服务,基于Redis实现权限变更后强制重新登录
|
||||
- `src/server/service/iaaa.ts`:北京大学统一认证(IAAA)对接服务,通过环境变量配置即可启用
|
||||
- `src/server/service/dev/`:开发模式下的辅助功能需要的后台服务
|
||||
- `src/server/utils/`:服务端专用工具函数库,为后端业务逻辑提供基础设施支持
|
||||
- `src/api/dev/`:开发模式下的辅助功能需要的api
|
||||
- `server/routers/`:项目trpc api定义文件,开发者主要在这里定义和实现业务的后端API
|
||||
- `server/routers/_app.ts`:`appRouter`根路由定义,需要添加子路由时在此处注册
|
||||
- `server/routers/common.ts`:定义需要在多个模块中复用的通用业务接口路由
|
||||
- `server/routers/jobs.ts`:tRPC任务进度订阅路由
|
||||
- `server/routers/selection.ts`:用于记录用户选择的选项或者输入的内容,优化用户的输入体验
|
||||
- `server/routers/global.ts`:系统全局和特定业务关联不大的一些api
|
||||
- `server/routers/dev/`:开发模式下的辅助功能需要的trpc api
|
||||
- `server/queues/`:消息队列和worker,通过其中的index.ts统一导出,任务状态更新采用trpc SSE subscription,接口定义在`server/routers/jobs.ts`中
|
||||
- `server/agents`:LLM的对接和使用
|
||||
- `server/service/`:服务层模块集合,封装后端业务逻辑和系统服务
|
||||
- `server/service/dev/`:开发模式下的辅助功能需要的后台服务
|
||||
- `server/utils/`:服务端专用工具函数库,为后端业务逻辑提供基础设施支持
|
||||
- `api/dev/`:开发模式下的辅助功能需要的api
|
||||
|
||||
### 其他
|
||||
- `src/constants/`:项目全局常量管理
|
||||
- `src/constants/permissions.ts`:权限定义,支持前后端一致的权限控制,支持解析复杂的权限表达式(如"A&B|(C&D)")
|
||||
- `src/lib/schema/`:集中管理数据验证schema,定义前后端统一的数据结构和验证规则,前端对默认值等其他要求写在表单组件中,后端对默认值等其他要求写在接口文件中,使用z.input而不是z.infer来获取Schema的输入类型
|
||||
- `src/lib/algorithom.ts`:通用计算机算法实现,例如拓扑排序
|
||||
- `src/lib/format.ts`:数据格式化工具函数库
|
||||
- `constants/`:项目全局常量管理
|
||||
- `constants/permissions.ts`:权限定义,支持前后端一致的权限控制,支持解析复杂的权限表达式(如"A&B|(C&D)")
|
||||
- `constants/menu.ts`: 菜单管理工具库,提供菜单项定义、查询、权限过滤等功能
|
||||
- `lib/schema/`:集中管理数据验证schema,定义前后端统一的数据结构和验证规则,前端对默认值等其他要求写在表单组件中,后端对默认值等其他要求写在接口文件中,使用z.input而不是z.infer来获取Schema的输入类型
|
||||
- `lib/algorithom.ts`:通用计算机算法实现,例如拓扑排序
|
||||
- `lib/format.ts`:数据格式化工具函数库
|
||||
|
||||
## 非标准命令
|
||||
- `pnpm run dev:attach`:这会使用tmux在名为nextdev的session中启动pnpm run dev,便于在开发页面或其他地方与开发服务器交互
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
distDir: process.env.NODE_ENV === "production" ? ".next-prod" : '.next', // 开发和生产环境输出到不同的目录,这样可以同时运行开发服务器和生产服务器
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hair-keeper",
|
||||
"version": "1.3.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000 --turbo",
|
||||
"dev:attach": "DEV_TERMINAL=nextdev;tmux new-session -A -s $DEV_TERMINAL\\; send-keys \"pnpm run dev\" ^M",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"start": "next start -p 3000",
|
||||
"lint": "next lint && tsc --noEmit",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"build:analyze": "ANALYZE=true next build"
|
||||
@@ -74,7 +74,7 @@
|
||||
"minio": "^8.0.6",
|
||||
"motion": "^12.23.22",
|
||||
"nanoid": "^5.1.6",
|
||||
"next": "~15.4.10",
|
||||
"next": "~15.4.8",
|
||||
"next-auth": "^4.24.11",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.6.0",
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# Hair Keeper开发容器使用帮助
|
||||
## AI编程代理用法
|
||||
命令行中:
|
||||
|
||||
* `ccr code` 打开一个新对话
|
||||
* `ccr code --resume` 回到之前某个对话
|
||||
* `ccr model` 切换编程代理使用的模型,也可以添加新模型
|
||||
|
||||
对话中:
|
||||
|
||||
* `ESC` 连按2次可以查看和回溯到之前的某一轮对话
|
||||
* `alt+tab` 切换模型是否打开思维链,默认是开启的,请关闭,因为思考模式很慢,只在极度复杂的任务时才打开
|
||||
* `\` 如果您的提示词过长,一行输入不下,可以在行末输入这个字符来换行
|
||||
* `@` 如果您需要引用项目中的文件,可以用这个符号
|
||||
* `/ide` 输入这个命令,AI会和您的IDE进行联动,这样您在IDE中选中的代码会被自动发送给AI,AI在修改代码时也会在IDE中打开代码预览。
|
||||
|
||||
其他:
|
||||
|
||||
* `CLAUDE.md` 文件中的内容每次对话都会发送给AI,如果您有什么要强调的,可以写在这里面,您也可以输入`/init` 让AI自动扫描项目并编写CLAUDE.md,一般这里面写的都是项目的约定、编码习惯和开发目标。
|
||||
|
||||
## 项目与开发环境
|
||||
##### 常见命令:
|
||||
* `pnpm run dev` 打开开发服务器
|
||||
* `pnpm run lint` 检查代码中是否存在明显错误,建议每次AI进行了一次大修改,先用这个命令排查错误,有错误就粘贴给AI让它解决
|
||||
* `pnpm run build` 构建和打包项目,耗时很长,如果AI尝试执行这个命令,阻止它并告诉它只需要执行`pnpm run lint` 排查错误
|
||||
* `pnpm prisma migrate dev --name add_some_tables` 如果您对`schema.prisma` 进行了修改并希望修改能同步到数据库,执行这条命令,`add_some_tables` 请替换成能够描述您实际修改的标识符
|
||||
|
||||
##### 访问容器内部服务(SSH转发):
|
||||
如果您是通过浏览器访问云开发容器,有时候可能需要访问云开发容器内部的本地服务,您可以通过SSH转发来实现。
|
||||
|
||||
例如我们可以通过这种方法来使用Prisma Studio(prisma提供的数据库管理工具):
|
||||
|
||||
```bash
|
||||
pnpm prisma studio --port 5555 # 在容器中执行
|
||||
ssh -N -L 5555:127.0.0.1:5555 root@cloud.liuyh.com -p <Hair Keeper容器的SSH服务映射的外部端口> # 在本地主机中执行,然后输入您开发环节的密码
|
||||
```
|
||||
然后您可以在本地输入 [http://localhost:5555/](http://localhost:5555/) 访问容器内部运行的Prisma Studio。
|
||||
|
||||
虽然您访问的是本地地址,但是Prisma Studio服务是运行在远程服务器的容器中的,SSH则是连接两者的桥梁。
|
||||
|
||||
## 代码仓库快速入门
|
||||
* `git push origin main` 推送本地代码到远程仓库,推送完成后您可以访问这个链接查看您的代码,也就是说您的代码在服务器上有了个备份,避免意外删除或丢失,通过远程仓库,您还可以与其他人合作开发一个项目,git能够解决代码的版本问题和不同成员修改的合并问题。
|
||||
|
||||
* `git add -A && git commit -m "修改了xxx文件、新增了xxx功能"` 在您对代码进行了一定的修改后,可以执行这条命令,相当于一个存档点,也便于您后续查看开发历史。在您进行了几次修改,准备结束今天的工作时,您可以执行`git push origin main` 将代码同步到远程仓库,避免代码丢失。
|
||||
|
||||
@@ -316,11 +316,7 @@ EOF
|
||||
|
||||
## 项目说明
|
||||
|
||||
本项目基于 Hair Keeper 模板构建(详见 @TEMPLATE_README.md),目前尚未实现业务功能。
|
||||
|
||||
## 重要提示
|
||||
- 完成用户的任务后,务必运行`pnpm run lint`检查代码错误并修复,不要运行build
|
||||
- 尽量使用pnpm执行npm相关的命令
|
||||
本项目基于 Hair Keeper 模板构建(详见 @TEMPLATE_README.md),目前尚未实现业务功能
|
||||
EOF
|
||||
print_success "新的 CLAUDE.md 已创建"
|
||||
|
||||
@@ -350,8 +346,6 @@ EOF
|
||||
"api_base_url": "${PKUAI_API_BASE}api/anthropic/v1/messages",
|
||||
"api_key": "${PKUAI_API_KEY}",
|
||||
"models": [
|
||||
"claude-opus-4-6",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-sonnet-4-5-20250929",
|
||||
"claude-opus-4-5-20251101",
|
||||
"claude-haiku-4-5-20251001"
|
||||
@@ -374,13 +368,13 @@ EOF
|
||||
}
|
||||
},
|
||||
"Router": {
|
||||
"default": "pku-anthropic,claude-opus-4-6",
|
||||
"default": "pku-anthropic,claude-opus-4-5-20251101",
|
||||
"background": "pku-anthropic,claude-haiku-4-5-20251001",
|
||||
"think": "pku-anthropic,-claude-opus4-6",
|
||||
"longContext": "pku-anthropic,claude-opus-4-6",
|
||||
"think": "pku-anthropic,claude-opus-4-5-20251101",
|
||||
"longContext": "pku-anthropic,claude-opus-4-5-20251101",
|
||||
"longContextThreshold": 80000,
|
||||
"webSearch": "",
|
||||
"image": "pku-anthropic,claude-opus-4-6"
|
||||
"image": "pku-anthropic,claude-opus-4-5-20251101"
|
||||
},
|
||||
"CUSTOM_ROUTER_PATH": ""
|
||||
}
|
||||
@@ -407,7 +401,6 @@ EOF
|
||||
echo ""
|
||||
git init
|
||||
git config init.defaultBranch main
|
||||
git config core.pager "less -F -X"
|
||||
print_info "设置 Git 用户配置..."
|
||||
git config user.email "$GIT_USER_EMAIL"
|
||||
git config user.name "$GIT_USER_NAME"
|
||||
|
||||
4
simple_deploy.sh
Executable file → Normal file
4
simple_deploy.sh
Executable file → Normal file
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# 此脚本用来一键部署到生产服务器
|
||||
# 需事先配置好ssh免密登录,目标服务器需要安装好node、pnpm、tsx、pm2来运行程序
|
||||
# 需事先配置好ssh免密登录,目标服务器需要安装好node、pnpm、tsx、pm2来运行容器
|
||||
set -e
|
||||
|
||||
# 配置
|
||||
@@ -20,7 +20,7 @@ TEMP_DIR=$(mktemp -d)
|
||||
trap "rm -rf $TEMP_DIR" EXIT
|
||||
|
||||
# 复制必要文件
|
||||
cp -r .next-prod $TEMP_DIR/
|
||||
cp -r .next $TEMP_DIR/
|
||||
cp -r public $TEMP_DIR/
|
||||
cp -r prisma $TEMP_DIR/
|
||||
cp package.json $TEMP_DIR/
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/bin/bash
|
||||
# 此脚本用来直接在当前目录下一键部署生产服务器
|
||||
# 需要安装好node、pnpm、pm2来运行程序
|
||||
set -e
|
||||
|
||||
# 配置
|
||||
PROJECT_NAME="hair-keeper" # 可自由修改,默认使用本项目模板的名称
|
||||
PORT="8000"
|
||||
|
||||
pnpm run lint
|
||||
echo "🔨 开始构建项目..."
|
||||
pnpm run build
|
||||
|
||||
echo "🗄️ 运行数据库迁移..."
|
||||
npx prisma migrate deploy
|
||||
npx prisma generate
|
||||
|
||||
echo "🔄 使用PM2重启服务..."
|
||||
pm2 describe hair-keeper > /dev/null 2>&1 && pm2 delete hair-keeper
|
||||
pm2 start pnpm --name $PROJECT_NAME -- start -p ${PORT}
|
||||
# 保存当前 PM2 进程列表的快照,使其在系统重启后能自动恢复
|
||||
pm2 save
|
||||
|
||||
echo "✅ 部署完成!服务运行在端口 ${PORT}"
|
||||
echo "📊 查看服务状态: pm2 status"
|
||||
echo "📝 查看日志: pm2 logs $PROJECT_NAME"
|
||||
echo "❌ 关闭服务: pm2 delete $PROJECT_NAME"
|
||||
echo "🎉 部署成功!"
|
||||
@@ -1,243 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { signIn } from "next-auth/react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { z } from "zod"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { AlertTriangle, ExternalLink } from "lucide-react"
|
||||
import type { IaaaClientConfig } from "./page"
|
||||
|
||||
// 登录表单验证 schema
|
||||
const loginSchema = z.object({
|
||||
id: z.string().min(1, "请输入用户ID"),
|
||||
password: z.string().min(1, "请输入密码"),
|
||||
})
|
||||
|
||||
type LoginFormData = z.infer<typeof loginSchema>
|
||||
|
||||
/** IAAA 回调错误码到用户提示的映射 */
|
||||
const IAAA_ERROR_MESSAGES: Record<string, string> = {
|
||||
iaaa_no_token: '未收到认证票据,请重试',
|
||||
iaaa_validate_failed: '统一认证验证失败,请重试',
|
||||
iaaa_user_not_found: '系统中不存在与您北大账号对应的用户,请联系管理员',
|
||||
iaaa_server_error: '服务器内部错误,请稍后重试',
|
||||
}
|
||||
|
||||
/** 创建隐藏表单 POST 跳转到 IAAA 统一认证页面 */
|
||||
function redirectToIaaa(appId: string, callbackPath: string) {
|
||||
const form = document.createElement('form')
|
||||
form.action = 'https://iaaa.pku.edu.cn/iaaa/oauth.jsp'
|
||||
form.method = 'POST'
|
||||
form.style.display = 'none'
|
||||
|
||||
function addField(name: string, value: string) {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'hidden'
|
||||
input.name = name
|
||||
input.value = value
|
||||
form.appendChild(input)
|
||||
}
|
||||
|
||||
const baseUrl = `${location.protocol}//${location.host}`
|
||||
addField('appID', appId)
|
||||
addField('redirectUrl', `${baseUrl}${callbackPath}`)
|
||||
// 保留应用自有登录入口,以便用户可以选择回到密码登录
|
||||
addField('redirectLogonUrl', `${baseUrl}/login`)
|
||||
|
||||
document.body.appendChild(form)
|
||||
form.submit()
|
||||
}
|
||||
|
||||
export function LoginForm({ iaaaConfig }: { iaaaConfig: IaaaClientConfig }) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const autoRedirected = useRef(false)
|
||||
const [showDevHint, setShowDevHint] = useState(false)
|
||||
|
||||
const form = useForm<LoginFormData>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: {
|
||||
id: "",
|
||||
password: "",
|
||||
},
|
||||
})
|
||||
|
||||
// IAAA 回调错误提示(使用 iaaa_error 参数名,避免与 next-auth 的 error 参数冲突)
|
||||
const iaaaErrorCode = searchParams.get('iaaa_error')
|
||||
const iaaaError = iaaaErrorCode ? IAAA_ERROR_MESSAGES[iaaaErrorCode] : null
|
||||
|
||||
// 自动跳转:配置开启且 IAAA 已启用时,进入登录页自动跳转到统一认证
|
||||
// 仅在非错误回调场景下触发(避免验证失败后死循环)
|
||||
useEffect(() => {
|
||||
if (
|
||||
iaaaConfig.enabled &&
|
||||
iaaaConfig.autoRedirect &&
|
||||
!iaaaErrorCode &&
|
||||
!autoRedirected.current
|
||||
) {
|
||||
autoRedirected.current = true
|
||||
redirectToIaaa(iaaaConfig.appId, iaaaConfig.callbackPath)
|
||||
}
|
||||
}, [iaaaConfig, iaaaErrorCode])
|
||||
|
||||
const onSubmit = async (data: LoginFormData) => {
|
||||
try {
|
||||
const result = await signIn("credentials", {
|
||||
id: data.id,
|
||||
password: data.password,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
form.setError("root", {
|
||||
type: "manual",
|
||||
message: "用户ID或密码错误"
|
||||
})
|
||||
} else if (result?.ok) {
|
||||
router.push("/")
|
||||
router.refresh()
|
||||
}
|
||||
} catch (error) {
|
||||
form.setError("root", {
|
||||
type: "manual",
|
||||
message: "登录失败,请重试"
|
||||
})
|
||||
console.error("Login error:", error)
|
||||
}
|
||||
}
|
||||
|
||||
// IAAA 是否可见:已启用,或开发模式下需要显示配置提醒
|
||||
const showIaaa = iaaaConfig.enabled || iaaaConfig.showInDev
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl text-center">登录</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* IAAA 回调错误提示 */}
|
||||
{iaaaError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>{iaaaError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 密码登录表单 */}
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>用户ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="请输入用户ID"
|
||||
disabled={form.formState.isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>密码</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
disabled={form.formState.isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{form.formState.errors.root && (
|
||||
<div className="text-sm text-red-600 text-center">
|
||||
{form.formState.errors.root.message}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? "登录中..." : "登录"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{/* IAAA 统一认证登录 */}
|
||||
{showIaaa && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
if (iaaaConfig.enabled) {
|
||||
redirectToIaaa(iaaaConfig.appId, iaaaConfig.callbackPath)
|
||||
} else {
|
||||
setShowDevHint(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
北京大学统一认证登录
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 开发模式下未配置 IAAA 的提示对话框(仅开发模式渲染,避免泄露内部架构) */}
|
||||
{iaaaConfig.showInDev && <Dialog open={showDevHint} onOpenChange={setShowDevHint}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>北大统一认证未配置</DialogTitle>
|
||||
<DialogDescription>
|
||||
IAAA 统一认证功能需要配置以下环境变量才能使用:
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="font-medium">1. 在 IAAA 平台注册应用时填写:</p>
|
||||
<div className="rounded-md bg-muted p-3 font-mono text-xs space-y-1">
|
||||
<div>系统URL:<span className="text-muted-foreground">https://你的域名</span></div>
|
||||
<div>服务器IP:<span className="text-muted-foreground">应用服务器的出口IP</span></div>
|
||||
<div>回调URL:<span className="text-muted-foreground">https://你的域名/api/auth/iaaa/callback</span></div>
|
||||
</div>
|
||||
<p className="font-medium">2. 将获取的凭据写入 <code className="bg-muted px-1 py-0.5 rounded">.env</code> 文件:</p>
|
||||
<div className="rounded-md bg-muted p-3 font-mono text-xs space-y-1">
|
||||
<div>IAAA_APP_ID=你的应用ID</div>
|
||||
<div>IAAA_KEY=你的密钥</div>
|
||||
<div className="text-muted-foreground"># 可选:进入登录页自动跳转统一认证</div>
|
||||
<div>IAAA_AUTO_REDIRECT=true</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
配置完成后重启开发服务器即可生效。
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +1,122 @@
|
||||
import { getIaaaClientConfig } from "@/server/service/iaaa"
|
||||
import { LoginForm } from "./login-form"
|
||||
'use client'
|
||||
|
||||
export interface IaaaClientConfig {
|
||||
enabled: boolean
|
||||
appId: string
|
||||
callbackPath: string
|
||||
autoRedirect: boolean
|
||||
/** 开发模式下未配置 IAAA,需要显示配置提醒 */
|
||||
showInDev: boolean
|
||||
}
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { signIn } from "next-auth/react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { z } from "zod"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
|
||||
|
||||
// 登录表单验证 schema
|
||||
const loginSchema = z.object({
|
||||
id: z.string().min(1, "请输入用户ID"),
|
||||
password: z.string().min(1, "请输入密码"),
|
||||
})
|
||||
|
||||
type LoginFormData = z.infer<typeof loginSchema>
|
||||
|
||||
export default function LoginPage() {
|
||||
const config = getIaaaClientConfig()
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
const router = useRouter()
|
||||
|
||||
const iaaaConfig: IaaaClientConfig = {
|
||||
...config,
|
||||
showInDev: isDev && !config.enabled,
|
||||
const form = useForm<LoginFormData>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: {
|
||||
id: "",
|
||||
password: "",
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async (data: LoginFormData) => {
|
||||
try {
|
||||
const result = await signIn("credentials", {
|
||||
id: data.id,
|
||||
password: data.password,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
form.setError("root", {
|
||||
type: "manual",
|
||||
message: "用户ID或密码错误"
|
||||
})
|
||||
} else if (result?.ok) {
|
||||
// 登录成功,重定向到首页
|
||||
router.push("/")
|
||||
router.refresh()
|
||||
}
|
||||
} catch (error) {
|
||||
form.setError("root", {
|
||||
type: "manual",
|
||||
message: "登录失败,请重试"
|
||||
})
|
||||
console.error("Login error:", error)
|
||||
}
|
||||
}
|
||||
|
||||
return <LoginForm iaaaConfig={iaaaConfig} />
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl text-center">登录</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>用户ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="请输入用户ID"
|
||||
disabled={form.formState.isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>密码</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
disabled={form.formState.isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{form.formState.errors.root && (
|
||||
<div className="text-sm text-red-600 text-center">
|
||||
{form.formState.errors.root.message}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? "登录中..." : "登录"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GitBranch, GitCommit as GitCommitIcon, CornerRightUp, RotateCcw, AlertTriangle, RefreshCw, GitCommit, Upload } from 'lucide-react'
|
||||
import { GitBranch, GitCommit as GitCommitIcon, CornerRightUp, RotateCcw, AlertTriangle, RefreshCw, GitCommit } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
@@ -73,7 +73,7 @@ export function VersionControl({ isOpen }: { isOpen: boolean }) {
|
||||
const [isLoadingMore, setIsLoadingMore] = React.useState(false)
|
||||
const [commitType, setCommitType] = React.useState<'normal' | 'amend' | null>(null)
|
||||
const [confirmAction, setConfirmAction] = React.useState<{
|
||||
type: 'checkout' | 'checkout-branch' | 'revert' | 'reset' | 'push'
|
||||
type: 'checkout' | 'checkout-branch' | 'revert' | 'reset'
|
||||
commitId?: string
|
||||
message?: string
|
||||
title?: string
|
||||
@@ -191,17 +191,6 @@ export function VersionControl({ isOpen }: { isOpen: boolean }) {
|
||||
},
|
||||
})
|
||||
|
||||
// 推送远程仓库mutation
|
||||
const pushToRemoteMutation = trpc.devPanel!.pushToRemote.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message)
|
||||
setConfirmAction(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
// 处理分支选择(仅用于查看历史,不切换实际分支)
|
||||
const handleBranchChange = (branchName: string | null) => {
|
||||
if (!branchName) return
|
||||
@@ -317,21 +306,9 @@ export function VersionControl({ isOpen }: { isOpen: boolean }) {
|
||||
resetToCommitMutation.mutate({ commitId: confirmAction.commitId })
|
||||
}
|
||||
break
|
||||
case 'push':
|
||||
pushToRemoteMutation.mutate({ branchName: selectedBranch })
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 处理推送远程仓库
|
||||
const handlePushToRemote = () => {
|
||||
setConfirmAction({
|
||||
type: 'push',
|
||||
title: '推送到远程仓库',
|
||||
description: `确定要将分支 "${selectedBranch}" 的最新提交推送到远程仓库吗?`,
|
||||
})
|
||||
}
|
||||
|
||||
// 手动刷新所有数据
|
||||
const handleRefresh = () => {
|
||||
refetchBranches()
|
||||
@@ -380,7 +357,7 @@ export function VersionControl({ isOpen }: { isOpen: boolean }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右半部分:Commit按钮、推送按钮和刷新按钮 */}
|
||||
{/* 右半部分:Commit按钮和刷新按钮 */}
|
||||
<div className="flex items-center gap-2 flex-1 justify-end">
|
||||
<Button
|
||||
onClick={() => setShowCommitDialog(true)}
|
||||
@@ -390,16 +367,6 @@ export function VersionControl({ isOpen }: { isOpen: boolean }) {
|
||||
<GitCommit className="mr-2 h-4 w-4" />
|
||||
提交更改
|
||||
</Button>
|
||||
{selectedBranch && !selectedBranch.startsWith('origin/') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePushToRemote}
|
||||
disabled={pushToRemoteMutation.isPending}
|
||||
title="推送到远程仓库"
|
||||
>
|
||||
<Upload className={cn("h-4 w-4", pushToRemoteMutation.isPending && "animate-pulse")} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRefresh}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { encode } from 'next-auth/jwt'
|
||||
import { db } from '@/server/db'
|
||||
import { authOptions, buildUserJwtPayload, userAuthInclude } from '@/server/auth'
|
||||
import { validateIaaaToken } from '@/server/service/iaaa'
|
||||
import { clearSessionInvalidation } from '@/server/service/session'
|
||||
|
||||
/**
|
||||
* IAAA 统一认证回调路由
|
||||
*
|
||||
* IAAA 认证成功后会 302 重定向到此路由,携带 token 参数。
|
||||
* 本路由验证 token、查找本地用户、生成 JWT session cookie,然后重定向到首页。
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const token = req.nextUrl.searchParams.get('token')
|
||||
const loginUrl = new URL('/login', req.url)
|
||||
|
||||
if (!token) {
|
||||
loginUrl.searchParams.set('iaaa_error', 'iaaa_no_token')
|
||||
return NextResponse.redirect(loginUrl)
|
||||
}
|
||||
|
||||
// 获取客户端真实 IP(反向代理场景取 X-Forwarded-For 第一个值)
|
||||
const remoteAddr =
|
||||
req.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
|
||||
req.headers.get('x-real-ip') ||
|
||||
'127.0.0.1'
|
||||
|
||||
// 调用 IAAA 验证
|
||||
const userInfo = await validateIaaaToken(token, remoteAddr)
|
||||
if (!userInfo) {
|
||||
loginUrl.searchParams.set('iaaa_error', 'iaaa_validate_failed')
|
||||
return NextResponse.redirect(loginUrl)
|
||||
}
|
||||
|
||||
// 用 identityId(学号/职工号)匹配本地用户
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userInfo.identityId },
|
||||
include: userAuthInclude,
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
console.warn(`[IAAA] 用户 ${userInfo.identityId}(${userInfo.name})在系统中不存在`)
|
||||
loginUrl.searchParams.set('iaaa_error', 'iaaa_user_not_found')
|
||||
return NextResponse.redirect(loginUrl)
|
||||
}
|
||||
|
||||
// 更新最近登录时间
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
})
|
||||
|
||||
// 清除会话失效标记
|
||||
await clearSessionInvalidation(user.id)
|
||||
|
||||
// 构建 JWT payload(与密码登录完全一致)
|
||||
const payload = buildUserJwtPayload(user)
|
||||
|
||||
// 生成 next-auth 兼容的 JWT
|
||||
const secret = authOptions.secret || process.env.NEXTAUTH_SECRET
|
||||
if (!secret) {
|
||||
console.error('[IAAA] NEXTAUTH_SECRET 未配置')
|
||||
loginUrl.searchParams.set('iaaa_error', 'iaaa_server_error')
|
||||
return NextResponse.redirect(loginUrl)
|
||||
}
|
||||
|
||||
const maxAge = authOptions.session?.maxAge ?? 30 * 24 * 60 * 60
|
||||
const encodedToken = await encode({
|
||||
token: { ...payload, sub: payload.id },
|
||||
secret,
|
||||
maxAge,
|
||||
})
|
||||
|
||||
// 设置 session cookie 并重定向到首页
|
||||
// next-auth 在 HTTPS 环境下使用 __Secure- 前缀
|
||||
const useSecureCookie = req.nextUrl.protocol === 'https:'
|
||||
const cookieName = useSecureCookie
|
||||
? '__Secure-next-auth.session-token'
|
||||
: 'next-auth.session-token'
|
||||
|
||||
const response = NextResponse.redirect(new URL('/', req.url))
|
||||
response.cookies.set(cookieName, encodedToken, {
|
||||
httpOnly: true,
|
||||
secure: useSecureCookie,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge,
|
||||
})
|
||||
|
||||
console.info(`[IAAA] 登录成功: ${userInfo.identityId}(${userInfo.name})`)
|
||||
return response
|
||||
}
|
||||
@@ -267,7 +267,7 @@ export function FormDialog({
|
||||
if (formRef.current) {
|
||||
// 查找第一个可聚焦的输入元素
|
||||
const firstInput = formRef.current.querySelector<HTMLElement>(
|
||||
'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled]), button[role="combobox"]:not([disabled])'
|
||||
'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled])'
|
||||
)
|
||||
if (firstInput) {
|
||||
firstInput.focus()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import React, { createContext, useContext, useEffect, useRef, useState } from 'react'
|
||||
import React, { createContext, useContext, useState, useEffect, useRef } from 'react'
|
||||
import { UseFormReturn, ControllerRenderProps } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
@@ -19,43 +19,20 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Loader2, ChevronLeft, ChevronRight, Check } from 'lucide-react'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
// 字段配置类型定义
|
||||
export interface StepFieldConfig {
|
||||
name: string
|
||||
label: string
|
||||
required?: boolean
|
||||
render: (props: { field: ControllerRenderProps<any, any> }) => React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
// 步骤配置类型定义
|
||||
export interface StepConfig {
|
||||
name: string
|
||||
title: string
|
||||
description?: string
|
||||
fields: StepFieldConfig[]
|
||||
className?: string // 步骤内容区域的网格样式
|
||||
}
|
||||
|
||||
// MultiStepFormDialog Context
|
||||
export interface MultiStepFormDialogContextValue {
|
||||
form: UseFormReturn<any>
|
||||
close: () => void
|
||||
steps: StepConfig[]
|
||||
currentStep: number
|
||||
totalSteps: number
|
||||
onCancel: () => void
|
||||
onPrevious: () => void
|
||||
onNext: () => void
|
||||
isSubmitting: boolean
|
||||
isValidating: boolean
|
||||
submitButtonText: string
|
||||
isFirstStep: boolean
|
||||
isLastStep: boolean
|
||||
goToStep: (step: number) => void
|
||||
goNext: () => void
|
||||
goPrev: () => void
|
||||
isLoading?: boolean
|
||||
canGoNext: () => Promise<boolean>
|
||||
}
|
||||
|
||||
const MultiStepFormDialogContext = createContext<MultiStepFormDialogContextValue | null>(null)
|
||||
@@ -68,343 +45,91 @@ export function useMultiStepFormDialogContext() {
|
||||
return context
|
||||
}
|
||||
|
||||
// 步骤指示器组件
|
||||
export interface StepIndicatorProps {
|
||||
className?: string
|
||||
showLabels?: boolean
|
||||
clickable?: boolean
|
||||
// 字段配置类型定义
|
||||
export interface FormFieldConfig {
|
||||
name: string
|
||||
label: string
|
||||
required?: boolean
|
||||
render: (props: { field: ControllerRenderProps<any, any> }) => React.ReactNode // 将...field传递给UI控件交给react-hook-form管理
|
||||
className?: string // 允许为单个字段指定自定义样式
|
||||
}
|
||||
|
||||
export function StepIndicator({ className, showLabels = false, clickable = false }: StepIndicatorProps) {
|
||||
const { steps, currentStep, goToStep, isLoading } = useMultiStepFormDialogContext()
|
||||
// 步骤配置类型定义
|
||||
export interface StepConfig {
|
||||
title: string
|
||||
description?: string
|
||||
fields: FormFieldConfig[]
|
||||
}
|
||||
|
||||
// 多步骤表单操作按钮栏组件
|
||||
export function MultiStepFormActionBar() {
|
||||
const {
|
||||
onCancel,
|
||||
onPrevious,
|
||||
onNext,
|
||||
isSubmitting,
|
||||
isValidating,
|
||||
submitButtonText,
|
||||
isFirstStep,
|
||||
isLastStep
|
||||
} = useMultiStepFormDialogContext()
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center gap-2", className)}>
|
||||
{steps.map((step, index) => {
|
||||
const isCompleted = index < currentStep
|
||||
const isCurrent = index === currentStep
|
||||
const isClickable = clickable && !isLoading && index <= currentStep
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.name}>
|
||||
{index > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"h-0.5 w-8 transition-colors",
|
||||
index <= currentStep ? "bg-primary" : "bg-muted"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isClickable}
|
||||
onClick={() => isClickable && goToStep(index)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 transition-colors",
|
||||
isClickable && "cursor-pointer hover:opacity-80",
|
||||
!isClickable && "cursor-default"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-full border-2 text-sm font-medium transition-colors",
|
||||
isCompleted && "border-primary bg-primary text-primary-foreground",
|
||||
isCurrent && "border-primary text-primary",
|
||||
!isCompleted && !isCurrent && "border-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isCompleted ? <Check className="h-4 w-4" /> : index + 1}
|
||||
</div>
|
||||
{showLabels && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium hidden sm:inline",
|
||||
isCurrent && "text-primary",
|
||||
!isCurrent && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 步骤内容组件
|
||||
export interface StepContentProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function StepContent({ className = 'grid grid-cols-1 gap-4' }: StepContentProps) {
|
||||
const { form, steps, currentStep, isLoading } = useMultiStepFormDialogContext()
|
||||
const currentStepConfig = steps[currentStep]
|
||||
|
||||
if (!currentStepConfig) return null
|
||||
|
||||
// 如果正在加载,显示骨架屏
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn("p-1", className, currentStepConfig.className)}>
|
||||
{currentStepConfig.fields.map((fieldConfig) => (
|
||||
<div key={fieldConfig.name} className={cn("space-y-2", fieldConfig.className || '')}>
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between pt-6 border-t">
|
||||
<div className="flex space-x-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("p-1", className, currentStepConfig.className)}>
|
||||
{currentStepConfig.fields.map((fieldConfig) => (
|
||||
<FormField
|
||||
key={fieldConfig.name}
|
||||
control={form.control}
|
||||
name={fieldConfig.name}
|
||||
render={({ field }) => (
|
||||
<FormItem className={fieldConfig.className || ''}>
|
||||
<FormLabel className="flex items-center gap-1">
|
||||
{fieldConfig.label}
|
||||
{fieldConfig.required && <span className="text-red-500">*</span>}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
{fieldConfig.render({ field })}
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
<div className="flex space-x-2">
|
||||
{!isFirstStep && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onPrevious}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
上一步
|
||||
</Button>
|
||||
)}
|
||||
{!isLastStep && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
disabled={isSubmitting || isValidating}
|
||||
>
|
||||
{isValidating ? '验证中...' : '下一步'}
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className={!isLastStep ? 'hidden' : ''}
|
||||
>
|
||||
{isSubmitting ? `${submitButtonText}中...` : submitButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 步骤标题组件(显示当前步骤的标题和描述)
|
||||
export interface StepHeaderProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function StepHeader({ className }: StepHeaderProps) {
|
||||
const { steps, currentStep } = useMultiStepFormDialogContext()
|
||||
const currentStepConfig = steps[currentStep]
|
||||
|
||||
if (!currentStepConfig) return null
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-1", className)}>
|
||||
<h4 className="font-medium">{currentStepConfig.title}</h4>
|
||||
{currentStepConfig.description && (
|
||||
<p className="text-sm text-muted-foreground">{currentStepConfig.description}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 上一步按钮组件
|
||||
export interface PrevStepActionProps extends Omit<React.ComponentProps<typeof Button>, 'onClick' | 'type'> {
|
||||
children?: React.ReactNode
|
||||
onPrev?: () => void
|
||||
}
|
||||
|
||||
export function PrevStepAction({
|
||||
children,
|
||||
variant = 'outline',
|
||||
onPrev,
|
||||
...props
|
||||
}: PrevStepActionProps) {
|
||||
const { goPrev, isFirstStep, isLoading } = useMultiStepFormDialogContext()
|
||||
|
||||
const handleClick = () => {
|
||||
if (onPrev) {
|
||||
onPrev()
|
||||
} else {
|
||||
goPrev()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
onClick={handleClick}
|
||||
disabled={isFirstStep || isLoading || props.disabled}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
上一步
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// 下一步按钮组件
|
||||
export interface NextStepActionProps extends Omit<React.ComponentProps<typeof Button>, 'onClick' | 'type'> {
|
||||
children?: React.ReactNode
|
||||
onNext?: () => Promise<boolean> | boolean
|
||||
validateCurrentStep?: boolean
|
||||
}
|
||||
|
||||
export function NextStepAction({
|
||||
children,
|
||||
variant = 'default',
|
||||
onNext,
|
||||
validateCurrentStep = true,
|
||||
...props
|
||||
}: NextStepActionProps) {
|
||||
const { goNext, isLastStep, isLoading, canGoNext } = useMultiStepFormDialogContext()
|
||||
const [isValidating, setIsValidating] = useState(false)
|
||||
|
||||
const handleClick = async () => {
|
||||
setIsValidating(true)
|
||||
try {
|
||||
if (validateCurrentStep) {
|
||||
const canProceed = await canGoNext()
|
||||
if (!canProceed) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (onNext) {
|
||||
const shouldProceed = await onNext()
|
||||
if (!shouldProceed) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
goNext()
|
||||
} finally {
|
||||
setIsValidating(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLastStep) return null
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
onClick={handleClick}
|
||||
disabled={isLoading || isValidating || props.disabled}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
下一步
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
{isValidating && <Loader2 className="ml-2 h-4 w-4 animate-spin" />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// 取消按钮组件
|
||||
export interface StepCancelActionProps extends Omit<React.ComponentProps<typeof Button>, 'onClick' | 'type'> {
|
||||
children?: React.ReactNode
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export function StepCancelAction({ children = '取消', variant = 'outline', onCancel, ...props }: StepCancelActionProps) {
|
||||
const { close } = useMultiStepFormDialogContext()
|
||||
|
||||
const handleClick = () => {
|
||||
if (onCancel) {
|
||||
onCancel()
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button type="button" variant={variant} onClick={handleClick} disabled={props.disabled} {...props}>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// 提交按钮组件(仅在最后一步显示)
|
||||
export interface StepSubmitActionProps extends Omit<React.ComponentProps<typeof Button>, 'onClick' | 'type'> {
|
||||
onSubmit: (data: any) => Promise<void> | void
|
||||
children?: React.ReactNode
|
||||
disabled?: boolean
|
||||
isSubmitting?: boolean
|
||||
showSpinningLoader?: boolean
|
||||
showOnlyOnLastStep?: boolean
|
||||
}
|
||||
|
||||
export function StepSubmitAction({
|
||||
onSubmit,
|
||||
children = '提交',
|
||||
disabled = false,
|
||||
isSubmitting = false,
|
||||
showSpinningLoader = true,
|
||||
showOnlyOnLastStep = true,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: StepSubmitActionProps) {
|
||||
const { form, isLastStep, isLoading } = useMultiStepFormDialogContext()
|
||||
|
||||
if (showOnlyOnLastStep && !isLastStep) return null
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
onClick={form.handleSubmit(onSubmit)}
|
||||
disabled={isSubmitting || disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{isSubmitting && showSpinningLoader && <Loader2 className="ml-2 h-4 w-4 animate-spin" />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// 操作按钮栏组件
|
||||
export interface StepActionBarProps {
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function StepActionBar({ children, className }: StepActionBarProps) {
|
||||
return (
|
||||
<div className={cn("flex justify-between", className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 左侧操作区
|
||||
export function StepActionBarLeft({ children, className }: { children?: React.ReactNode; className?: string }) {
|
||||
return <div className={cn("flex gap-2", className)}>{children}</div>
|
||||
}
|
||||
|
||||
// 右侧操作区
|
||||
export function StepActionBarRight({ children, className }: { children?: React.ReactNode; className?: string }) {
|
||||
return <div className={cn("flex gap-2", className)}>{children}</div>
|
||||
}
|
||||
|
||||
// 主对话框组件
|
||||
export interface MultiStepFormDialogProps {
|
||||
isOpen: boolean
|
||||
title: string
|
||||
description: string
|
||||
form: UseFormReturn<any>
|
||||
onSubmit: (data: any) => Promise<void> | void
|
||||
steps: StepConfig[]
|
||||
contentClassName?: string
|
||||
gridClassName?: string
|
||||
|
||||
/* action */
|
||||
onClose: () => void
|
||||
className?: string
|
||||
formClassName?: string
|
||||
children: React.ReactNode
|
||||
isLoading?: boolean
|
||||
initialStep?: number
|
||||
isSubmitting: boolean
|
||||
submitButtonText: string
|
||||
}
|
||||
|
||||
export function MultiStepFormDialog({
|
||||
@@ -412,33 +137,30 @@ export function MultiStepFormDialog({
|
||||
title,
|
||||
description,
|
||||
form,
|
||||
onSubmit,
|
||||
steps,
|
||||
contentClassName = 'max-w-4xl',
|
||||
gridClassName = 'grid grid-cols-1 md:grid-cols-2 gap-4',
|
||||
onClose,
|
||||
className = 'max-w-lg',
|
||||
formClassName,
|
||||
children,
|
||||
isLoading = false,
|
||||
initialStep = 0,
|
||||
isSubmitting,
|
||||
submitButtonText,
|
||||
}: MultiStepFormDialogProps) {
|
||||
const isMobile = useIsMobile()
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [isValidating, setIsValidating] = useState(false)
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
const [currentStep, setCurrentStep] = useState(initialStep)
|
||||
|
||||
// 重置步骤当对话框关闭或重新打开时
|
||||
// 当对话框打开或步骤改变时,自动聚焦到第一个表单输入控件
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setCurrentStep(initialStep)
|
||||
}
|
||||
}, [isOpen, initialStep])
|
||||
|
||||
// 当对话框打开或步骤变化时,自动聚焦到第一个表单输入控件
|
||||
useEffect(() => {
|
||||
if (isOpen && !isLoading) {
|
||||
// 使当前拥有焦点的元素(通常是用来触发打开这个drawer的控件)失去焦点,不然控制台会警告焦点在一个要被隐藏于屏幕阅读器的控件上
|
||||
(document.activeElement as HTMLElement)?.blur()
|
||||
// 使用 setTimeout 确保 DOM 已完全渲染
|
||||
const timer = setTimeout(() => {
|
||||
if (formRef.current) {
|
||||
// 查找第一个可聚焦的输入元素
|
||||
const firstInput = formRef.current.querySelector<HTMLElement>(
|
||||
'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled]), button[role="combobox"]:not([disabled])'
|
||||
'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled])'
|
||||
)
|
||||
if (firstInput) {
|
||||
firstInput.focus()
|
||||
@@ -448,78 +170,138 @@ export function MultiStepFormDialog({
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isOpen, isLoading, currentStep])
|
||||
}, [isOpen, currentStep])
|
||||
|
||||
const close = () => {
|
||||
const handleSubmit = async (data: any) => {
|
||||
await onSubmit(data)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setCurrentStep(0)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const totalSteps = steps.length
|
||||
const handleNext = async () => {
|
||||
if (currentStep < steps.length - 1) {
|
||||
setIsValidating(true)
|
||||
try {
|
||||
// 验证当前步骤的字段,只有验证通过才能跳转到下一步
|
||||
const currentStepFields = currentStepConfig.fields.map(field => field.name)
|
||||
const isValid = await form.trigger(currentStepFields)
|
||||
if (isValid) {
|
||||
setCurrentStep(currentStep + 1)
|
||||
}
|
||||
} finally {
|
||||
setIsValidating(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep(currentStep - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const isFirstStep = currentStep === 0
|
||||
const isLastStep = currentStep === totalSteps - 1
|
||||
const isLastStep = currentStep === steps.length - 1
|
||||
const currentStepConfig = steps[currentStep]
|
||||
|
||||
const goToStep = (step: number) => {
|
||||
if (step >= 0 && step < totalSteps) {
|
||||
setCurrentStep(step)
|
||||
}
|
||||
}
|
||||
|
||||
const goNext = () => {
|
||||
if (!isLastStep) {
|
||||
setCurrentStep((prev) => prev + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const goPrev = () => {
|
||||
if (!isFirstStep) {
|
||||
setCurrentStep((prev) => prev - 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证当前步骤的字段
|
||||
const canGoNext = async (): Promise<boolean> => {
|
||||
const currentStepConfig = steps[currentStep]
|
||||
if (!currentStepConfig) return true
|
||||
|
||||
const fieldNames = currentStepConfig.fields.map((f) => f.name)
|
||||
const result = await form.trigger(fieldNames as any)
|
||||
return result
|
||||
}
|
||||
// 步骤指示器组件
|
||||
const stepIndicator = (
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
{steps.map((step, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
index <= currentStep
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="ml-2 text-sm">
|
||||
<div className={index <= currentStep ? 'text-primary font-medium' : 'text-muted-foreground'}>
|
||||
{step.title}
|
||||
</div>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={`w-12 h-0.5 mx-4 ${index < currentStep ? 'bg-primary' : 'bg-muted'}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Context 值
|
||||
const contextValue: MultiStepFormDialogContextValue = {
|
||||
form,
|
||||
close,
|
||||
steps,
|
||||
currentStep,
|
||||
totalSteps,
|
||||
onCancel: handleClose,
|
||||
onPrevious: handlePrevious,
|
||||
onNext: handleNext,
|
||||
isSubmitting,
|
||||
isValidating,
|
||||
submitButtonText,
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
goToStep,
|
||||
goNext,
|
||||
goPrev,
|
||||
isLoading,
|
||||
canGoNext,
|
||||
isLastStep
|
||||
}
|
||||
|
||||
// 表单内容组件
|
||||
const formContent = (
|
||||
<MultiStepFormDialogContext.Provider value={contextValue}>
|
||||
<Form {...form}>
|
||||
<form ref={formRef} className={cn("space-y-6", formClassName)}>
|
||||
{children}
|
||||
</form>
|
||||
</Form>
|
||||
<div className="space-y-4">
|
||||
<Form {...form}>
|
||||
<form ref={formRef} onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
|
||||
{/* 当前步骤标题和描述 */}
|
||||
<div className="border-b pb-4">
|
||||
<h3 className="text-lg font-medium">{currentStepConfig.title}</h3>
|
||||
{currentStepConfig.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{currentStepConfig.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 当前步骤的字段 */}
|
||||
<div className={cn("p-1", gridClassName)}>
|
||||
{currentStepConfig.fields.map((fieldConfig) => (
|
||||
<FormField
|
||||
key={fieldConfig.name}
|
||||
control={form.control}
|
||||
name={fieldConfig.name}
|
||||
render={({ field }) => (
|
||||
<FormItem className={fieldConfig.className || ''}>
|
||||
<FormLabel className="flex items-center gap-1">
|
||||
{fieldConfig.label}
|
||||
{fieldConfig.required && <span className="text-red-500">*</span>}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
{fieldConfig.render({ field })}
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<MultiStepFormActionBar />
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</MultiStepFormDialogContext.Provider>
|
||||
)
|
||||
|
||||
// 根据设备类型渲染不同的组件
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={isOpen} onOpenChange={(open) => !open && close()}>
|
||||
<DrawerContent className={className}>
|
||||
<Drawer open={isOpen} onOpenChange={handleClose}>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{title}</DrawerTitle>
|
||||
<DrawerDescription>{description}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="px-4 pb-4 overflow-y-auto max-h-[70vh]">
|
||||
{stepIndicator}
|
||||
{formContent}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
@@ -528,13 +310,14 @@ export function MultiStepFormDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && close()}>
|
||||
<DialogContent className={className} showCloseButton={false}>
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className={contentClassName}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="overflow-y-auto max-h-[70vh]">
|
||||
{stepIndicator}
|
||||
<div className='overflow-y-auto max-h-[70vh]'>
|
||||
{formContent}
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,32 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { SessionProvider as NextAuthSessionProvider, signOut, useSession } from "next-auth/react"
|
||||
import { useEffect, useRef } from "react"
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
/** 检测会话失效并自动登出 */
|
||||
function SessionInvalidationGuard({ children }: { children: React.ReactNode }) {
|
||||
const { data: session, status } = useSession()
|
||||
const pathname = usePathname()
|
||||
const signingOut = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
// 已认证但 session 中没有 user(会话被标记失效),自动登出清除 cookie
|
||||
if (status === 'authenticated' && !(session as any)?.user?.id && !signingOut.current && pathname !== '/login') {
|
||||
signingOut.current = true
|
||||
signOut({ callbackUrl: '/login' })
|
||||
}
|
||||
}, [status, session, pathname])
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
import { SessionProvider as NextAuthSessionProvider } from "next-auth/react"
|
||||
|
||||
export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<NextAuthSessionProvider>
|
||||
<SessionInvalidationGuard>
|
||||
{children}
|
||||
</SessionInvalidationGuard>
|
||||
</NextAuthSessionProvider>
|
||||
)
|
||||
return <NextAuthSessionProvider>{children}</NextAuthSessionProvider>
|
||||
}
|
||||
@@ -3,4 +3,4 @@
|
||||
*/
|
||||
export const SITE_NAME = 'Hair Keeper'
|
||||
export const SITE_DESCRIPTION = '高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑'
|
||||
export const SITE_VERSION = 'v1.3.0'
|
||||
export const SITE_VERSION = 'v1.1.0'
|
||||
@@ -9,8 +9,8 @@ export default withAuth(
|
||||
const token = req.nextauth.token
|
||||
const pathname = req.nextUrl.pathname
|
||||
|
||||
// 如果用户已登录且访问登录页面,重定向到首页(会话失效的用户除外,需留在登录页)
|
||||
if (pathname === "/login" && token && !token.sessionInvalid) {
|
||||
// 如果用户已登录且访问登录页面,重定向到首页
|
||||
if (pathname === "/login" && token) {
|
||||
return NextResponse.redirect(new URL("/", req.url))
|
||||
}
|
||||
|
||||
@@ -50,11 +50,6 @@ export default withAuth(
|
||||
return true
|
||||
}
|
||||
|
||||
// 会话已被标记失效,强制重新登录
|
||||
if (token?.sessionInvalid) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 其他路由需要有效的 token
|
||||
return !!token
|
||||
},
|
||||
|
||||
@@ -4,37 +4,6 @@ import type { User as NextAuthUser } from "next-auth"
|
||||
import CredentialsProvider from "next-auth/providers/credentials"
|
||||
import bcrypt from "bcryptjs"
|
||||
import { db } from "./db"
|
||||
import { clearSessionInvalidation, isSessionInvalidated } from "./service/session"
|
||||
|
||||
/** 用户查询时需要 include 的关联 */
|
||||
export const userAuthInclude = {
|
||||
roles: { include: { permissions: true } },
|
||||
dept: true,
|
||||
} as const
|
||||
|
||||
/** 从数据库用户对象构建 JWT payload(供密码登录和 IAAA 登录共用) */
|
||||
export function buildUserJwtPayload(user: {
|
||||
id: string
|
||||
name: string | null
|
||||
status: string | null
|
||||
deptCode: string | null
|
||||
isSuperAdmin: boolean
|
||||
roles: Array<{ name: string; permissions: Array<{ name: string }> }>
|
||||
}) {
|
||||
const roles = user.roles.map((r) => r.name)
|
||||
const permissions = Array.from(
|
||||
new Set(user.roles.flatMap((r) => r.permissions.map((p) => p.name)))
|
||||
)
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
status: user.status,
|
||||
deptCode: user.deptCode,
|
||||
roles,
|
||||
permissions,
|
||||
isSuperAdmin: user.isSuperAdmin,
|
||||
}
|
||||
}
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
@@ -52,8 +21,15 @@ export const authOptions: NextAuthOptions = {
|
||||
try {
|
||||
// 查找用户
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: credentials.id },
|
||||
include: userAuthInclude,
|
||||
where: {
|
||||
id: credentials.id
|
||||
},
|
||||
include: {
|
||||
roles: {
|
||||
include: { permissions: true }
|
||||
},
|
||||
dept: true
|
||||
}
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
@@ -63,6 +39,7 @@ export const authOptions: NextAuthOptions = {
|
||||
// 验证密码
|
||||
const isPasswordValid = await bcrypt.compare(credentials.password, user.password)
|
||||
|
||||
|
||||
if (!isPasswordValid) {
|
||||
return null
|
||||
}
|
||||
@@ -73,10 +50,22 @@ export const authOptions: NextAuthOptions = {
|
||||
data: { lastLoginAt: new Date() }
|
||||
})
|
||||
|
||||
// 清除会话失效标记(用户已重新登录,获得最新权限)
|
||||
await clearSessionInvalidation(user.id)
|
||||
|
||||
return buildUserJwtPayload(user) as any
|
||||
// 返回用户信息、角色和权限
|
||||
const roles = user.roles.map((r) => r.name)
|
||||
const permissions = Array.from(
|
||||
new Set(user.roles.flatMap((r) =>
|
||||
r.permissions.map((p) => p.name)
|
||||
))
|
||||
)
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
status: user.status,
|
||||
deptCode: user.deptCode,
|
||||
roles,
|
||||
permissions,
|
||||
isSuperAdmin: user.isSuperAdmin
|
||||
} as any
|
||||
} catch (error) {
|
||||
console.error("Auth error:", error)
|
||||
return null
|
||||
@@ -109,20 +98,10 @@ export const authOptions: NextAuthOptions = {
|
||||
permissions: u.permissions,
|
||||
isSuperAdmin: u.isSuperAdmin,
|
||||
}
|
||||
} else if (token.id) {
|
||||
// 后续请求:检查会话是否已被标记失效
|
||||
const invalidated = await isSessionInvalidated(token.id as string)
|
||||
if (invalidated) {
|
||||
token.sessionInvalid = true
|
||||
}
|
||||
}
|
||||
return token
|
||||
},
|
||||
async session({ session, token }) {
|
||||
// 会话已被标记失效,返回不含用户信息的session
|
||||
if (token.sessionInvalid) {
|
||||
return { expires: session.expires } as any
|
||||
}
|
||||
// 将JWT token中的信息传递给session
|
||||
if (session.user) {
|
||||
const t = token as any
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'server-only'
|
||||
import { Client } from 'minio';
|
||||
|
||||
// 内部客户端:用于 statObject、removeObject 等服务端直连操作,以及 presignedPostPolicy
|
||||
// 初始化 MinIO 客户端
|
||||
export const minioClient = new Client({
|
||||
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
|
||||
port: parseInt(process.env.MINIO_API_PORT || '9000'),
|
||||
@@ -10,32 +10,6 @@ export const minioClient = new Client({
|
||||
secretKey: process.env.MINIO_ROOT_PASSWORD || '',
|
||||
});
|
||||
|
||||
/**
|
||||
* 公网客户端:用于生成 presigned GET/PUT URL
|
||||
*
|
||||
* AWS Signature V4 会将 Host 头纳入签名计算,因此 presigned GET/PUT URL
|
||||
* 必须使用与浏览器访问一致的公网地址生成,否则签名校验失败。
|
||||
* 而 POST Policy 不含 Host 签名,只需替换返回的 URL 地址即可。
|
||||
*/
|
||||
function createPresignClient(): Client {
|
||||
const serverUrl = process.env.MINIO_SERVER_URL;
|
||||
if (!serverUrl) return minioClient;
|
||||
|
||||
const parsed = new URL(serverUrl);
|
||||
const useSSL = parsed.protocol === 'https:';
|
||||
const port = parsed.port ? parseInt(parsed.port) : (useSSL ? 443 : 80);
|
||||
|
||||
return new Client({
|
||||
endPoint: parsed.hostname,
|
||||
port,
|
||||
useSSL,
|
||||
accessKey: process.env.MINIO_ROOT_USER || '',
|
||||
secretKey: process.env.MINIO_ROOT_PASSWORD || '',
|
||||
});
|
||||
}
|
||||
|
||||
const presignClient = createPresignClient();
|
||||
|
||||
export const BUCKET_NAME = process.env.MINIO_BUCKET || 'app-files';
|
||||
|
||||
/**
|
||||
@@ -192,9 +166,6 @@ export async function generatePresignedPostPolicy(
|
||||
// 精确匹配
|
||||
policy.setContentType(allowedContentType);
|
||||
}
|
||||
} else {
|
||||
// 未指定类型限制时,允许任意 Content-Type(客户端上传时会设置此字段,Policy 中必须声明)
|
||||
policy.policy.conditions.push(['starts-with', '$Content-Type', '']);
|
||||
}
|
||||
|
||||
if (allowOriginalFilename) {
|
||||
@@ -284,8 +255,8 @@ export async function generatePresignedGetObject(
|
||||
}
|
||||
}
|
||||
|
||||
// 使用公网客户端生成预签名 URL,确保 V4 签名中的 Host 与浏览器访问地址一致
|
||||
const url = await presignClient.presignedGetObject(
|
||||
// 生成预签名 URL
|
||||
const url = await minioClient.presignedGetObject(
|
||||
BUCKET_NAME,
|
||||
objectName,
|
||||
expirySeconds,
|
||||
@@ -293,7 +264,7 @@ export async function generatePresignedGetObject(
|
||||
);
|
||||
|
||||
return {
|
||||
url,
|
||||
url: replaceUrlBase(url),
|
||||
expiresIn: expirySeconds,
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
resetToCommit,
|
||||
getCurrentBranch,
|
||||
hasUncommittedChanges,
|
||||
pushToRemote,
|
||||
} from '@/server/utils/git-helper'
|
||||
|
||||
|
||||
@@ -225,25 +224,6 @@ export const devPanelRouter = createTRPCRouter({
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* 推送分支到远程仓库
|
||||
*/
|
||||
pushToRemote: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({
|
||||
branchName: z.string().min(1, '分支名称不能为空'),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
await pushToRemote(input.branchName)
|
||||
return { success: true, message: `已推送分支 ${input.branchName} 到远程仓库` }
|
||||
} catch (error: any) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `推送失败: ${error.message}`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
export type DevPanelRouter = typeof devPanelRouter
|
||||
@@ -7,7 +7,6 @@ import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import { inferProcedureOutput, TRPCError } from '@trpc/server'
|
||||
import pLimit from 'p-limit'
|
||||
import { invalidateUserSessions, invalidateSessionsByRoleId } from '@/server/service/session'
|
||||
|
||||
// 从环境变量获取并发限制,默认为16
|
||||
const dbParallelLimit = pLimit(parseInt(process.env.DB_PARALLEL_LIMIT || '16', 10))
|
||||
@@ -117,7 +116,7 @@ export const usersRouter = createTRPCRouter({
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '角色名称已被其他角色使用' })
|
||||
}
|
||||
|
||||
const updatedRole = await ctx.db.role.update({
|
||||
return ctx.db.role.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
@@ -130,11 +129,6 @@ export const usersRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 标记持有该角色的所有用户会话失效
|
||||
await invalidateSessionsByRoleId(id)
|
||||
|
||||
return updatedRole
|
||||
}),
|
||||
|
||||
deleteRole: permissionRequiredProcedure(Permissions.USER_MANAGE)
|
||||
@@ -212,9 +206,6 @@ export const usersRouter = createTRPCRouter({
|
||||
processedCount += batch.length
|
||||
}
|
||||
|
||||
// 标记所有受影响用户的会话失效
|
||||
await invalidateUserSessions(users.map(u => u.id))
|
||||
|
||||
return { count: processedCount }
|
||||
}),
|
||||
|
||||
@@ -274,7 +265,7 @@ export const usersRouter = createTRPCRouter({
|
||||
updateData.password = await bcrypt.hash(password, 12)
|
||||
}
|
||||
|
||||
const updatedUser = await ctx.db.user.update({
|
||||
return ctx.db.user.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
@@ -282,11 +273,6 @@ export const usersRouter = createTRPCRouter({
|
||||
dept: true
|
||||
}
|
||||
})
|
||||
|
||||
// 标记用户会话失效,强制重新登录以获取最新权限
|
||||
await invalidateUserSessions([id])
|
||||
|
||||
return updatedUser
|
||||
}),
|
||||
|
||||
getById: permissionRequiredProcedure(Permissions.USER_MANAGE).input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
|
||||
@@ -314,9 +300,6 @@ export const usersRouter = createTRPCRouter({
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '不能删除自己的账户' })
|
||||
}
|
||||
|
||||
// 标记用户会话失效
|
||||
await invalidateUserSessions([id])
|
||||
|
||||
// 删除用户
|
||||
return ctx.db.user.delete({
|
||||
where: { id },
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import 'server-only'
|
||||
import crypto from 'crypto'
|
||||
|
||||
const IAAA_VALIDATE_URL = 'https://iaaa.pku.edu.cn/iaaa/svc/token/validate.do'
|
||||
|
||||
export interface IaaaUserInfo {
|
||||
name: string
|
||||
status: string
|
||||
identityId: string
|
||||
deptId: string
|
||||
dept: string
|
||||
identityType: string
|
||||
detailType: string
|
||||
identityStatus: string
|
||||
campus: string
|
||||
}
|
||||
|
||||
interface IaaaValidateResult {
|
||||
success: boolean
|
||||
errCode: string
|
||||
errMsg: string
|
||||
userInfo?: IaaaUserInfo
|
||||
}
|
||||
|
||||
/** 检查 IAAA 统一认证是否已配置启用 */
|
||||
export function isIaaaEnabled(): boolean {
|
||||
return !!(process.env.IAAA_APP_ID && process.env.IAAA_KEY)
|
||||
}
|
||||
|
||||
/** 获取 IAAA 配置(仅返回前端需要的非敏感信息) */
|
||||
export function getIaaaClientConfig() {
|
||||
return {
|
||||
enabled: isIaaaEnabled(),
|
||||
appId: process.env.IAAA_APP_ID || '',
|
||||
callbackPath: '/api/auth/iaaa/callback',
|
||||
autoRedirect: process.env.IAAA_AUTO_REDIRECT === 'true',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 IAAA Token
|
||||
* @param token IAAA 回调携带的 token
|
||||
* @param remoteAddr 用户真实 IP 地址
|
||||
* @returns 验证成功返回用户信息,失败返回 null 并记录日志
|
||||
*/
|
||||
export async function validateIaaaToken(
|
||||
token: string,
|
||||
remoteAddr: string
|
||||
): Promise<IaaaUserInfo | null> {
|
||||
const appId = process.env.IAAA_APP_ID
|
||||
const key = process.env.IAAA_KEY
|
||||
|
||||
if (!appId || !key) {
|
||||
console.error('[IAAA] 环境变量 IAAA_APP_ID 或 IAAA_KEY 未配置')
|
||||
return null
|
||||
}
|
||||
|
||||
// 参数按字母序拼接:appId, remoteAddr, token
|
||||
const paraStr = `appId=${appId}&remoteAddr=${remoteAddr}&token=${token}`
|
||||
const msgAbs = crypto.createHash('md5').update(paraStr + key).digest('hex')
|
||||
|
||||
const url = `${IAAA_VALIDATE_URL}?${paraStr}&msgAbs=${msgAbs}`
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, { signal: AbortSignal.timeout(10000) })
|
||||
if (!resp.ok) {
|
||||
console.error(`[IAAA] 验证请求失败: HTTP ${resp.status}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const result: IaaaValidateResult = await resp.json()
|
||||
|
||||
if (!result.success) {
|
||||
console.warn(`[IAAA] 认证失败: errCode=${result.errCode}, errMsg=${result.errMsg}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const userInfo = result.userInfo
|
||||
if (!userInfo?.identityId) {
|
||||
console.warn('[IAAA] 认证成功但未返回 identityId')
|
||||
return null
|
||||
}
|
||||
|
||||
return userInfo
|
||||
} catch (error) {
|
||||
console.error('[IAAA] Token 验证请求异常:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import 'server-only'
|
||||
import { getRedisClient } from '@/server/redis'
|
||||
import { db } from '@/server/db'
|
||||
|
||||
const SESSION_INVALID_PREFIX = 'session:invalid:'
|
||||
const SESSION_INVALID_TTL = 30 * 24 * 60 * 60 // 30天,与JWT maxAge一致
|
||||
|
||||
/** 标记指定用户的会话失效 */
|
||||
export async function invalidateUserSessions(userIds: string[]) {
|
||||
if (userIds.length === 0) return
|
||||
const redis = getRedisClient()
|
||||
const pipeline = redis.pipeline()
|
||||
for (const id of userIds) {
|
||||
pipeline.set(`${SESSION_INVALID_PREFIX}${id}`, '1', 'EX', SESSION_INVALID_TTL)
|
||||
}
|
||||
await pipeline.exec()
|
||||
}
|
||||
|
||||
/** 检查用户会话是否已被标记失效 */
|
||||
export async function isSessionInvalidated(userId: string): Promise<boolean> {
|
||||
const redis = getRedisClient()
|
||||
const result = await redis.exists(`${SESSION_INVALID_PREFIX}${userId}`)
|
||||
return result === 1
|
||||
}
|
||||
|
||||
/** 用户重新登录后清除失效标记 */
|
||||
export async function clearSessionInvalidation(userId: string) {
|
||||
const redis = getRedisClient()
|
||||
await redis.del(`${SESSION_INVALID_PREFIX}${userId}`)
|
||||
}
|
||||
|
||||
/** 标记某角色下所有用户的会话失效 */
|
||||
export async function invalidateSessionsByRoleId(roleId: number) {
|
||||
const users = await db.user.findMany({
|
||||
where: { roles: { some: { id: roleId } } },
|
||||
select: { id: true },
|
||||
})
|
||||
await invalidateUserSessions(users.map(u => u.id))
|
||||
}
|
||||
@@ -632,19 +632,3 @@ export async function hasUncommittedChanges(): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送当前分支到远程仓库
|
||||
* @param branchName 分支名称
|
||||
*/
|
||||
export async function pushToRemote(branchName: string): Promise<void> {
|
||||
try {
|
||||
await execAsync(`git push origin "${branchName}"`, {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('推送到远程仓库失败:', error)
|
||||
throw new Error(`无法推送到远程仓库: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@@ -23,19 +19,9 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
"next-env.d.ts",
|
||||
".next-prod/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user