Compare commits

22 Commits
main ... main

Author SHA1 Message Date
25762eee84 Hair Keeper v1.3.0版本更新:支持北京大学统一认证(IAAA)SSO登录;基于Redis实现权限变更后强制重新登录;解决MinIO客户端直传在反向代理部署下的兼容问题;云开发容器增加文件持久化机制、rsync、JDK17、Database Client插件
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 15:23:31 +08:00
66c96fd331 云开发容器通过符号链接和命名卷实现零碎文件持久化机制
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 14:31:47 +08:00
dce89ed79f 云开发环境添加rsync、JDK17,替换Redis插件为Database Client
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 11:31:32 +08:00
ff9b5918fd 解决MinIO 客户端直传架构在反向代理部署下预签名 URL 失效问题 2026-03-18 11:27:52 +08:00
b9c83617ad 提供北京大学统一认证支持,仅需配置环境变量 2026-03-18 09:34:14 +08:00
a6ae3b8845 添加北京大学统一认证接入支持skill 2026-03-17 16:58:29 +08:00
c76e7a7529 hair keeper增加claude-sonnet-4-6模型,claude code不允许读取.git/config,claude code可免确认执行一系列相对安全的指令 2026-03-10 13:40:59 +08:00
fee430438c 基于Redis失效标记,实现用户权限变更后强制重新登录 2026-03-10 11:18:30 +08:00
d34bff2f79 Hair Keeper v1.2.0版本更新:nextjs升级到~15.4.10;支持在开发容器内快速部署(默认绑定8000端口);开发者面板页面git工具支持推送远程仓库;新增多步表单控件;新增开发容器工具:dnsutils;新增Hair Keeper开发容器使用帮助 2026-02-11 16:39:28 +08:00
1e514e6631 增加quickstart.md Hair Keeper开发容器使用帮助 2026-02-04 16:32:34 +08:00
b459607d31 feat: 新增多步表单控件 2026-02-03 11:57:06 +08:00
796ffcfe00 feat: 开发者面板页面git工具支持推送远程仓库 2026-01-30 12:01:44 +08:00
37f9faf2a4 优化CLAUDE.md;添加本地快速部署脚本;form-dialog打开后能聚焦下拉菜单 2026-01-06 17:12:06 +08:00
7f15051f18 修复bug,进一步优化quickstart.sh 2025-12-11 10:39:55 +08:00
9d32874e1e 优化quickstart.sh,添加远程仓库推送相关配置 2025-12-10 15:43:21 +08:00
fab2b34a03 开发终端兼容云端环境;minio客户端直传兼容云端环境;开发容器增加vim和claude code插件 2025-12-10 13:35:41 +08:00
5024477b74 添加quickstart.sh脚本帮助用户快速使用模板项目 2025-12-09 15:07:17 +08:00
1349317f88 next修复CVE-2025-66478漏洞,编程代理改成claude 2025-12-08 17:01:59 +08:00
c1fda9bb7e fix: 删除部分会引发类型错误的无用代码 2025-11-18 23:17:31 +08:00
91d39b3145 fix: 手机端关闭侧边栏后刷新页面的延迟300ms=>350ms,instrumentation.ts好像得写在src内部以适配生产环境,
新增simple_deploy.sh便于部署
2025-11-18 20:32:28 +08:00
2a80a44972 feat: 增加DEFAULT_USER_PASSWORD,作为创建用户时的默认密码,添加p-limit库,添加DB_PARALLEL_LIMIT = 32环境变量作为“数据库批次操作默认并发数” 限制只有超级管理员才能创建超级管理员用户 删除用户时可以级联删除SelectionLog 添加zustand全局状态管理 一对多的院系管理功能 ,支持增删改查院系管理员信息、用户可以在header中切换管理的院系 2025-11-18 20:10:48 +08:00
7f3190a223 fix: docker-compose.yml添加全局的name属性吗,让docker-compose控制容器、卷和网络的命名 2025-11-16 15:51:15 +08:00
70 changed files with 4228 additions and 696 deletions

85
.claude/settings.json Normal file
View File

@@ -0,0 +1,85 @@
{
"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"
],
"deny": [
"Read(./.env)",
"Read(./.env.development)",
"Read(./.env.production)",
"Read(./.git/config)"
]
}
}

View File

@@ -0,0 +1,415 @@
---
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. 对拼接结果计算 MD5UTF-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` — 验证北京大学账号是否存在

View File

@@ -0,0 +1,78 @@
# 北京大学账号验证
用于验证某个学号/职工号是否为有效的北京大学账号,不涉及登录流程。
## 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()
```

View File

@@ -0,0 +1,120 @@
# 代理身份认证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}
&timestamp={TIMESTAMP}
&msgAbs={MSG_ABS}
```
**参数:**
| 参数 | 说明 |
|------|------|
| `remoteAddr` | 客户端 IP |
| `appId` | 中心应用 ID |
| `targetAppId` | 分支应用 ID |
| `grantToken` | 上一步获得的代理票据 |
| `timestamp` | 当前时间戳long 型整数) |
| `msgAbs` | MD5 摘要PARA_STR 为除 msgAbs 外所有参数按参数名升序拼接 |
注意此端点参数较多PARA_STR 排序后为:
`appId={}&grantToken={}&remoteAddr={}&targetAppId={}&timestamp={}`
**成功返回:**
```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": "注销成功"}
```

View File

@@ -44,9 +44,14 @@ RUN apt-get update && apt-get install -y \
cmake \
telnet \
redis-tools \
iputils-ping \
dnsutils \
potrace \
imagemagick \
zsh \
vim \
rsync \
openjdk-17-jdk \
&& rm -rf /var/lib/apt/lists/*
# 安装 Python 3.12
@@ -93,8 +98,12 @@ RUN curl -fsSL https://code-server.dev/install.sh | sh -s -- --version=${CODE_SE
# 安装 npm 全局包
RUN npm install -g \
@anthropic-ai/claude-code \
@musistudio/claude-code-router
@musistudio/claude-code-router \
pm2
# 安装 claude code 编程代理
RUN curl -fsSL https://claude.ai/install.sh | bash && \
echo 'eval "$(ccr activate)"' >> /root/.zshrc
# 创建工作目录
RUN mkdir -p /workspace /root/.local/share/code-server/User
@@ -108,11 +117,11 @@ RUN mkdir -p /var/run/sshd && \
RUN code-server --install-extension ms-ceintl.vscode-language-pack-zh-hans \
&& code-server --install-extension bierner.markdown-mermaid \
&& code-server --install-extension ms-python.python \
&& code-server --install-extension rooveterinaryinc.roo-cline \
&& 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-redis-client
&& code-server --install-extension cweijan.vscode-database-client2 \
&& code-server --install-extension anthropic.claude-code
# 配置 code-server (密码将在启动时设置)
RUN mkdir -p /root/.config/code-server && \

View File

@@ -29,6 +29,7 @@
- **cmake**: 构建工具
- **telnet**: 网络调试
- **redis-tools**: Redis 命令行工具
- **ping**: 网络连通性测试
- **potrace**: 位图转矢量图
- **imagemagick**: 图像处理工具
- **uv**: 快速 Python 包管理器

View File

@@ -11,14 +11,20 @@ services:
- "7681:7681" # ttyd (Web Terminal)
- "3000:3000" # Next.js Dev Server
volumes:
# 项目代码映射(使用 cached 模式提高性能)
# 项目代码映射(如果映射到Windows/Mac宿主机的本地目录使用 cached 模式提高性能)
- ../:/workspace:cached
# node_modules 使用命名卷以提高性能
- node_modules:/workspace/node_modules
# pnpm store 缓存
- pnpm_store:/root/.local/share/pnpm/store
# Git 配置(可选,如果需要保留 Git 配置)
- ~/.gitconfig:/root/.gitconfig:ro
# Code Server 配置(包含 config.yaml 和密码)
- code-server-config:/root/.config/code-server
# Code Server 数据(插件、用户设置、扩展数据)
- code-server-data:/root/.local/share/code-server
# Claude Code 配置和保存数据
- claude:/root/.claude
# Claude Code Router 配置和保存数据
- claude-code-router:/root/.claude-code-router
# SSH 配置
- ssh:/root/.ssh
# 零碎文件持久化
- persist:/root/.hair-keeper-persist
environment:
- NODE_ENV=development
- TZ=Asia/Shanghai
@@ -36,6 +42,12 @@ services:
memory: 4G
volumes:
code-server-config:
code-server-data:
claude:
claude-code-router:
ssh:
persist:
# node_modules 卷,避免主机和容器之间的文件系统差异
node_modules:
# pnpm store 卷,加速依赖安装

View File

@@ -1,5 +1,40 @@
#!/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}
@@ -42,5 +77,20 @@ 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
tail -f /dev/null &
wait $!

View File

@@ -26,6 +26,7 @@ POSTGRESQL_PASSWORD=
POSTGRESQL_PORT=
DATABASE_URL=
REDIS_HOST=
REDIS_PORT=
REDIS_PASSWORD=
@@ -40,19 +41,31 @@ MINIO_BUCKET=
# 应用相关
SUPER_ADMIN_PASSWORD=
USER_DEFAULT_PASSWORD=
## 数据库批次操作默认并发数
DB_PARALLEL_LIMIT =
# NextAuth.js Configuration
NEXTAUTH_SECRET=
NEXTAUTH_URL=
PKUAI_API_KEY=
PKUAI_API_BASE=
# 北京大学 IAAA 统一认证(可选,不配置则不启用)
## 在 IAAA 注册的应用系统 ID
IAAA_APP_ID=
## IAAA 提供的密钥,用于生成消息摘要
IAAA_KEY=
## 设为 true 则进入登录页后自动跳转到统一认证(默认 false
IAAA_AUTO_REDIRECT=
# 仅在开发环境加载(写在.env.development中
PORT=
NEXT_PUBLIC_DEV_TERMINAL_DEFAULT_PORT=
NEXT_PUBLIC_DEV_TERMINAL_URL=
DEV_TERMINAL=

4
.gitignore vendored
View File

@@ -15,6 +15,7 @@
# next.js
/.next/
/.next-prod/
/out/
# production
@@ -51,3 +52,6 @@ tasks.md
# lock 一般的项目需要用git管理但这个是模板项目就不管理
package-lock.json
pnpm-lock.yaml
# claude
.claude/*.local.*

View File

@@ -1,12 +0,0 @@
{
"mcpServers":{
"ai-elements": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://registry.ai-sdk.dev/api/mcp"
]
}
}
}

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"prisma.pinToPrisma6": true
}

96
CLAUDE.md Normal file
View File

@@ -0,0 +1,96 @@
# 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是个诙谐有趣的名称和项目内容毫无关系。
开发者直接在本项目模板的基础上进行开发,本项目源代码完全对开发者可见并可以随时修改、扩展功能、增加新的组件和模块,开发者尽量遵从如下文表述的约定和项目文件组织规则。
## 主要依赖库
本项目使用pnpm作为包管理器
- 基础next + react + trpc + prisma
- UI基础框架tailwindcss + radix-ui(基于shadcn/ui库添加组件) + lucide-react + sonner(toast)
- 图表等高级UIrecharts(图表) + xyflow/react(节点图 dagre自动布局) + embla-carousel-react + dnd-kit/sortable
- 用户交互增强motion(动画) + framer-motion(动画) + use-gesture/react(手势)
- Headless UIreact-hook-form + tanstack/react-table + headless-tree/react
- 数据和存储pg(PostgreSQL) + ioredis + minio
- 后台任务及消息队列bullmq
- AI大模型交互: ai + ai-sdk/react + ai-elements(基于shadcn/ui库添加组件)
- 辅助性库lodash + zod + date-fns + nanoid + zustand + p-limit
- 其他next-auth + bcryptjs + nuqs + superjson(前后端序列化类型安全) + copy-to-clipboard
## 项目约定
### 前端
- 可使用`pnpx shadcn@latest add`添加UI控件会添加到components/ui中
- 表单输入组件提供value、onChange和disabled三种属性的支持可方便的集成到react-hook-form中
- z-index约定常规对话框及其遮罩z-50表单控件的Popover为z-60全屏预览及遮罩z-70
- tailwindcss v4支持直接编写如 w-[10px] 这样的任意值非必要不写style支持样式类合并`import { cn } from "@/lib/utils"`
-`import { useCallbackRef } from "@/hooks/use-callback-ref"`这个钩子构建引用不变但逻辑总是最新的函数,解决闭包陷阱
### 后端
- tRPC接口报错时可使用TRPCError例如throw new TRPCError({ code: 'NOT_FOUND', message: '' })
- server/trpc.ts中导出了createTRPCRouter本质是t.router用来创建路由预定义了publicProcedure用于创建无权限限制、也不需要登录的api
- server/trpc.ts中预定义了permissionRequiredProcedure用来创建限制特定权限访问的api例如permissionRequiredProcedure(Permissions.USER_MANAGE)空字符串表示无权限要求但是需要用户登录约定用permissionRequiredProcedure('SUPER_ADMIN_ONLY')限制超级管理员才能访问该权限不在Permissions中定义只有超级管理员才能绕过授权限制访问所有接口因此SUPER_ADMIN_ONLY这个字符串只是一个通用的约定。
- 数据库批次操作时,使用`const dbParallelLimit = pLimit(parseInt(process.env.DB_PARALLEL_LIMIT || '16', 10))`控制最大并发数
### 数据和存储
- Prisma 生成的客户端输出为默认路径,导入时使用`@prisma/client`可以从中导入定义的数据表的ts类型例如`import type { User } from '@prisma/client'`
- 数据库连接使用 `server/db.ts` 中的全局单例 `db`,不要直接实例化 PrismaClient
- 时间字段统一使用 `@db.Timestamptz` 类型
- 前后端参数传递尽量使用扁平结构而非嵌套结构
- 文件的上传和下载采用“客户端直传”架构基于MinIO服务器端只负责授权和生成预签名URL
### 开发模式
为了方便开发,本项目模板内置了在开发模式下可用的大量页面和功能
- 仅在开发阶段使用的页面、布局、api等会被NextJS识别并处理的文件以dev.tsx、dev.ts、dev.jsx、dev.js为后缀不会被打包到生产环境
- 仅在开发阶段使用的数据模型以Dev开头对应的数据表以dev_开头
## 重要目录和文件
### 前端
- `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管理的全局的状态
### 后端
- `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
### 其他
- `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`:数据格式化工具函数库
## 非标准命令
- `pnpm run dev:attach`这会使用tmux在名为nextdev的session中启动pnpm run dev便于在开发页面或其他地方与开发服务器交互
- `pnpm run db:seed`:数据库种子数据,`prisma/init_data/`存放种子初始化数据
- `pnpm run build:analyze`打包项目并使用next/bundle-analyzer进行分析

View File

@@ -1,11 +1,16 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目说明
本项目模板Hair Keeper v1.0.0是一个高度集成、深度定制、约定优于配置的全栈Web应用模板旨在保持灵活性的同时提供一套基于成熟架构的开发底座自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能提供AI开发辅助免于纠结功能如何实现可快速上手专注于业务逻辑。
本项目模板Hair Keeper v1.3.0是一个高度集成、深度定制、约定优于配置的全栈Web应用模板旨在保持灵活性的同时提供一套基于成熟架构的开发底座自带身份认证支持SSO单点登录、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能提供AI开发辅助免于纠结功能如何实现可快速上手专注于业务逻辑。
Hair Keeper是个诙谐有趣的名称和项目内容毫无关系。
开发者直接在本项目模板的基础上进行开发,本项目源代码完全对开发者可见并可以随时修改、扩展功能、增加新的组件和模块,开发者尽量遵从如下文表述的约定和项目文件组织规则。
## 主要依赖库
本项目使用pnpm作为包管理器
- 基础next + react + trpc + prisma
- UI基础框架tailwindcss + radix-ui(基于shadcn/ui库添加组件) + lucide-react + sonner(toast)
- 图表等高级UIrecharts(图表) + xyflow/react(节点图 dagre自动布局) + embla-carousel-react + dnd-kit/sortable
@@ -14,7 +19,7 @@ Hair Keeper是个诙谐有趣的名称和项目内容毫无关系。
- 数据和存储pg(PostgreSQL) + ioredis + minio
- 后台任务及消息队列bullmq
- AI大模型交互: ai + ai-sdk/react + ai-elements(基于shadcn/ui库添加组件)
- 辅助性库lodash + zod + date-fns + nanoid
- 辅助性库lodash + zod + date-fns + nanoid + zustand + p-limit
- 其他next-auth + bcryptjs + nuqs + superjson(前后端序列化类型安全) + copy-to-clipboard
## 项目约定
@@ -27,11 +32,12 @@ Hair Keeper是个诙谐有趣的名称和项目内容毫无关系。
### 后端
- tRPC接口报错时可使用TRPCError例如throw new TRPCError({ code: 'NOT_FOUND', message: '' })
- server/trpc.ts中预定义了publicProcedure用于创建无权限限制、也不需要登录的api
- server/trpc.ts中预定义了publicProcedurepermissionRequiredProcedure用来创建限制特定权限访问的路由例如permissionRequiredProcedure(Permissions.USER_MANAGE)空字符串表示无权限要求但是需要用户登录约定用permissionRequiredProcedure('SUPER_ADMIN_ONLY')限制超级管理员才能访问该权限不在Permissions中定义只有超级管理员才能绕过授权限制访问所有接口因此SUPER_ADMIN_ONLY这个字符串只是一个通用的约定。
- server/trpc.ts中导出了createTRPCRouter本质是t.router用来创建路由预定义了publicProcedure用于创建无权限限制、也不需要登录的api
- server/trpc.ts中预定义了permissionRequiredProcedure用来创建限制特定权限访问的api例如permissionRequiredProcedure(Permissions.USER_MANAGE)空字符串表示无权限要求但是需要用户登录约定用permissionRequiredProcedure('SUPER_ADMIN_ONLY')限制超级管理员才能访问该权限不在Permissions中定义只有超级管理员才能绕过授权限制访问所有接口因此SUPER_ADMIN_ONLY这个字符串只是一个通用的约定。
- 数据库批次操作时,使用`const dbParallelLimit = pLimit(parseInt(process.env.DB_PARALLEL_LIMIT || '16', 10))`控制最大并发数
### 数据和存储
- Prisma 生成的客户端输出为默认路径,导入时使用`@prisma/client`
- Prisma 生成的客户端输出为默认路径,导入时使用`@prisma/client`可以从中导入定义的数据表的ts类型例如`import type { User } from '@prisma/client'`
- 数据库连接使用 `server/db.ts` 中的全局单例 `db`,不要直接实例化 PrismaClient
- 时间字段统一使用 `@db.Timestamptz` 类型
- 前后端参数传递尽量使用扁平结构而非嵌套结构
@@ -44,42 +50,45 @@ Hair Keeper是个诙谐有趣的名称和项目内容毫无关系。
## 重要目录和文件
### 前端
- `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通信
- `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管理的全局的状态
### 后端
- `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/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
### 其他
- `constants/`:项目全局常量管理
- `constants/permissions.ts`:权限定义,支持前后端一致的权限控制,支持解析复杂的权限表达式(如"A&B|(C&D)"
- `lib/schema/`集中管理数据验证schema定义前后端统一的数据结构和验证规则前端对默认值等其他要求写在表单组件中后端对默认值等其他要求写在接口文件中使用z.input而不是z.infer来获取Schema的输入类型
- `lib/algorithom.ts`:通用计算机算法实现,例如拓扑排序
- `lib/format.ts`:数据格式化工具函数库
- `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`:数据格式化工具函数库
## 非标准命令
- `pnpm run dev:attach`这会使用tmux在名为nextdev的session中启动pnpm run dev便于在开发页面或其他地方与开发服务器交互

View File

@@ -1,7 +1,8 @@
name: hair-keeper
services:
postgresql:
image: bitnami/postgresql:17 # https://hub.docker.com/r/bitnami/postgresql
container_name: hair-keeper-dev-pg
restart: always
ports:
- "${POSTGRESQL_PORT}:5432"
@@ -10,21 +11,19 @@ services:
POSTGRESQL_PASSWORD: ${POSTGRESQL_PASSWORD}
POSTGRESQL_INITDB_ARGS: "--encoding=UTF-8 --locale=C"
volumes:
- hair_keeper_postgresql_data:/bitnami/postgresql
- postgresql_data:/bitnami/postgresql
redis:
image: redis:8-alpine # https://hub.docker.com/_/redis
container_name: hair-keeper-dev-redis
ports:
- "${REDIS_PORT}:6379"
command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes
volumes:
- hair_keeper_redis_data:/data
- redis_data:/data
restart: always
minio:
image: minio/minio:RELEASE.2025-07-23T15-54-02Z
container_name: hair-keeper-dev-minio
ports:
- "${MINIO_API_PORT}:9000" # API端口
- "${MINIO_CONSOLE_PORT}:9001" # Console端口
@@ -33,11 +32,11 @@ services:
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
MINIO_SERVER_URL: ${MINIO_SERVER_URL}
volumes:
- hair_keeper_minio_data:/data
- minio_data:/data
command: server /data --console-address ":9001"
restart: always
volumes:
hair_keeper_postgresql_data:
hair_keeper_redis_data:
hair_keeper_minio_data:
postgresql_data:
redis_data:
minio_data:

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
distDir: process.env.NODE_ENV === "production" ? ".next-prod" : '.next', // 开发和生产环境输出到不同的目录,这样可以同时运行开发服务器和生产服务器
};

View File

@@ -1,13 +1,13 @@
{
"name": "hair-keeper",
"version": "0.1.0",
"version": "1.3.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 -p 3000",
"lint": "eslint",
"start": "next start",
"lint": "next lint && tsc --noEmit",
"db:seed": "tsx prisma/seed.ts",
"build:analyze": "ANALYZE=true next build"
},
@@ -74,10 +74,11 @@
"minio": "^8.0.6",
"motion": "^12.23.22",
"nanoid": "^5.1.6",
"next": "~15.4.0",
"next": "~15.4.10",
"next-auth": "^4.24.11",
"next-themes": "^0.4.6",
"nuqs": "^2.6.0",
"p-limit": "^7.2.0",
"pg": "^8.16.3",
"prism-react-renderer": "^2.4.1",
"prisma": "^6.15.0",
@@ -95,7 +96,8 @@
"tailwind-merge": "^3.3.1",
"use-stick-to-bottom": "^1.1.1",
"vaul": "^1.1.2",
"zod": "^4.1.9"
"zod": "^4.1.9",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@@ -107,7 +109,6 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.5.2",
"react-icons": "^5.5.0",
"react-live": "^4.1.8",
"shadcn": "^3.5.0",
"tailwindcss": "^4",

View File

@@ -0,0 +1,5 @@
-- DropForeignKey
ALTER TABLE "selection_log" DROP CONSTRAINT "selection_log_userId_fkey";
-- AddForeignKey
ALTER TABLE "selection_log" ADD CONSTRAINT "selection_log_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,26 @@
-- AlterTable
ALTER TABLE "user" ADD COLUMN "current_managed_dept" VARCHAR(5);
-- CreateTable
CREATE TABLE "dept_admin" (
"id" SERIAL NOT NULL,
"uid" VARCHAR(30) NOT NULL,
"dept_code" VARCHAR(5) NOT NULL,
"admin_email" VARCHAR(100),
"admin_line_phone" VARCHAR(100),
"admin_mobile_phone" VARCHAR(100),
"note" VARCHAR(1000),
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "dept_admin_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "dept_admin_uid_dept_code_key" ON "dept_admin"("uid", "dept_code");
-- AddForeignKey
ALTER TABLE "dept_admin" ADD CONSTRAINT "dept_admin_uid_fkey" FOREIGN KEY ("uid") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "dept_admin" ADD CONSTRAINT "dept_admin_dept_code_fkey" FOREIGN KEY ("dept_code") REFERENCES "dept"("code") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -19,6 +19,7 @@ model User {
name String?
status String? // 在校/减离/NULL
deptCode String? @map("dept_code") // 所属院系代码(外键)
currentManagedDept String? @map("current_managed_dept") @db.VarChar(5) // 当前正在管理的院系代码,用于支持院系管理员类型的角色
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
password String
@@ -29,6 +30,7 @@ model User {
dept Dept? @relation(fields: [deptCode], references: [code])
roles Role[] // 多对多关联角色
selectionLogs SelectionLog[] // 选择日志
deptAdmins DeptAdmin[] // 作为院系管理员的信息
@@map("user")
}
@@ -41,10 +43,31 @@ model Dept {
// 关联
users User[]
deptAdmins DeptAdmin[] // 院系管理员
@@map("dept")
}
// 院系管理员表
model DeptAdmin {
id Int @id @default(autoincrement())
uid String @db.VarChar(30) // 管理员用户ID外键
deptCode String @map("dept_code") @db.VarChar(5) // 院系代码(外键)
adminEmail String? @map("admin_email") @db.VarChar(100) // 管理员邮箱
adminLinePhone String? @map("admin_line_phone") @db.VarChar(100) // 管理员座机
adminMobilePhone String? @map("admin_mobile_phone") @db.VarChar(100) // 管理员手机
note String? @db.VarChar(1000) // 备注
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
// 关联
user User @relation(fields: [uid], references: [id])
dept Dept @relation(fields: [deptCode], references: [code])
@@unique([uid, deptCode], name: "uidx_uid_dept_code")
@@map("dept_admin")
}
// 角色表
model Role {
id Int @id @default(autoincrement())
@@ -68,7 +91,7 @@ model Permission {
model SelectionLog {
id Int @id @default(autoincrement())
userId String // 关联到用户
user User @relation(fields: [userId], references: [id])
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// 用于标识是哪个的选项,用.进行分隔,例如"user.filter.dept"
context String

View File

@@ -3,9 +3,13 @@ import bcrypt from 'bcryptjs'
import { Permissions, ALL_PERMISSIONS } from '../src/constants/permissions'
import fs from 'fs'
import path from 'path'
import pLimit from 'p-limit'
const prisma = new PrismaClient()
// 从环境变量获取并发限制默认为16
const dbParallelLimit = pLimit(parseInt(process.env.DB_PARALLEL_LIMIT || '16', 10))
// 解析 JSON 文件并导入院系数据
async function importDepartments() {
const jsonPath = path.join(__dirname, 'init_data', '院系.json')
@@ -16,7 +20,7 @@ async function importDepartments() {
await Promise.all(
departments.map((dept: any) => {
return prisma.dept.upsert({
return dbParallelLimit(() => prisma.dept.upsert({
where: { code: dept.id },
update: {
name: dept.name,
@@ -27,7 +31,7 @@ async function importDepartments() {
name: dept.name,
fullName: dept.full_name,
},
})
}))
})
)
console.log('院系数据导入完成')
@@ -37,13 +41,15 @@ async function main() {
console.log('开始数据库初始化...')
// 插入权限
for (const permName of ALL_PERMISSIONS) {
await prisma.permission.upsert({
where: { name: permName },
update: {},
create: { name: permName },
await Promise.all(
ALL_PERMISSIONS.map((permName) => {
return dbParallelLimit(() => prisma.permission.upsert({
where: { name: permName },
update: {},
create: { name: permName },
}))
})
}
)
// 角色与权限映射
const rolePermissionsMap: Record<string, string[]> = {
@@ -51,18 +57,20 @@ async function main() {
}
// 插入角色
for (const [roleName, perms] of Object.entries(rolePermissionsMap)) {
await prisma.role.upsert({
where: { name: roleName },
update: {},
create: {
name: roleName,
permissions: {
connect: perms.map((name) => ({ name })),
await Promise.all(
Object.entries(rolePermissionsMap).map(([roleName, perms]) => {
return dbParallelLimit(() => prisma.role.upsert({
where: { name: roleName },
update: {},
create: {
name: roleName,
permissions: {
connect: perms.map((name) => ({ name })),
},
},
},
}))
})
}
)
await importDepartments()
@@ -74,33 +82,37 @@ async function main() {
{ id: 'unknown', name: '未知用户', status: '在校', deptCode: '00001', roleNames: [] },
]
for (const u of usersToCreate) {
const password = await bcrypt.hash(u.password ?? '123456', 12)
await prisma.user.upsert({
where: { id: u.id },
update: {
name: u.name,
status: u.status,
deptCode: u.deptCode,
password,
isSuperAdmin: u.isSuperAdmin ?? false,
roles: {
set: u.roleNames.map((name) => ({ name })),
},
},
create: {
id: u.id,
name: u.name,
status: u.status,
deptCode: u.deptCode,
password,
isSuperAdmin: u.isSuperAdmin ?? false,
roles: {
connect: u.roleNames.map((name) => ({ name })),
},
},
await Promise.all(
usersToCreate.map((u) => {
return dbParallelLimit(async () => {
const password = await bcrypt.hash(u.password || process.env.USER_DEFAULT_PASSWORD || 'jeep4ahxahx7ee7U', 12)
await prisma.user.upsert({
where: { id: u.id },
update: {
name: u.name,
status: u.status,
deptCode: u.deptCode,
password,
isSuperAdmin: u.isSuperAdmin ?? false,
roles: {
set: u.roleNames.map((name) => ({ name })),
},
},
create: {
id: u.id,
name: u.name,
status: u.status,
deptCode: u.deptCode,
password,
isSuperAdmin: u.isSuperAdmin ?? false,
roles: {
connect: u.roleNames.map((name) => ({ name })),
},
},
})
})
})
}
)
// 插入文件类型(仅开发环境)
const fileTypes = [
@@ -125,18 +137,19 @@ async function main() {
{ id: 'OTHER', name: '其他', description: '无法归入以上分类的文件' },
]
for (let index = 0; index < fileTypes.length; index++) {
const fileType = fileTypes[index]
await prisma.devFileType.upsert({
where: { id: fileType.id },
update: {
name: fileType.name,
description: fileType.description,
order: (index + 1) * 10,
},
create: {...fileType, order: (index + 1) * 10},
await Promise.all(
fileTypes.map((fileType, index) => {
return dbParallelLimit(() => prisma.devFileType.upsert({
where: { id: fileType.id },
update: {
name: fileType.name,
description: fileType.description,
order: (index + 1) * 10,
},
create: {...fileType, order: (index + 1) * 10},
}))
})
}
)
console.log('文件类型数据初始化完成')
// 插入依赖包类型(仅开发环境)
@@ -152,18 +165,19 @@ async function main() {
{ id: 'NODEJS_CORE', name: 'Node.js核心', description: 'Node.js运行时内置的模块用于底层操作如文件系统、路径处理等例如fs、path、child_process、util、module、os、http、crypto、events、stream、process、net、url、assert。' },
]
for (let index = 0; index < pkgTypes.length; index++) {
const pkgType = pkgTypes[index]
await prisma.devPkgType.upsert({
where: { id: pkgType.id },
update: {
name: pkgType.name,
description: pkgType.description,
order: (index + 1) * 10,
},
create: {...pkgType, order: (index + 1) * 10},
await Promise.all(
pkgTypes.map((pkgType, index) => {
return dbParallelLimit(() => prisma.devPkgType.upsert({
where: { id: pkgType.id },
update: {
name: pkgType.name,
description: pkgType.description,
order: (index + 1) * 10,
},
create: {...pkgType, order: (index + 1) * 10},
}))
})
}
)
console.log('依赖包类型数据初始化完成')
console.log('数据库初始化完成')
@@ -177,4 +191,4 @@ main()
console.error(e)
await prisma.$disconnect()
process.exit(1)
})
})

45
quickstart.md Normal file
View File

@@ -0,0 +1,45 @@
# Hair Keeper开发容器使用帮助
## AI编程代理用法
命令行中:
* `ccr code` 打开一个新对话
* `ccr code --resume` 回到之前某个对话
* `ccr model` 切换编程代理使用的模型,也可以添加新模型
对话中:
* `ESC` 连按2次可以查看和回溯到之前的某一轮对话
* `alt+tab` 切换模型是否打开思维链,默认是开启的,请关闭,因为思考模式很慢,只在极度复杂的任务时才打开
* `\` 如果您的提示词过长,一行输入不下,可以在行末输入这个字符来换行
* `@` 如果您需要引用项目中的文件,可以用这个符号
* `/ide` 输入这个命令AI会和您的IDE进行联动这样您在IDE中选中的代码会被自动发送给AIAI在修改代码时也会在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 Studioprisma提供的数据库管理工具
```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` 将代码同步到远程仓库,避免代码丢失。

457
quickstart.sh Executable file
View File

@@ -0,0 +1,457 @@
#!/bin/bash
# Hair Keeper 模板项目快速配置脚本
# 用于帮助用户快速配置环境变量和开发工具
set -e
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# 打印带颜色的信息
print_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
print_header() {
echo ""
echo -e "${CYAN}========================================${NC}"
echo -e "${CYAN} $1${NC}"
echo -e "${CYAN}========================================${NC}"
echo ""
}
# 读取用户输入,支持默认值
read_input() {
local prompt="$1"
local default="$2"
local allow_empty="$3"
local result
if [ -n "$default" ]; then
echo -n "$prompt [默认: $default]: " >&2
read result
result="${result:-$default}"
else
if [ "$allow_empty" = "true" ]; then
echo -n "$prompt (可为空): " >&2
read result
else
while [ -z "$result" ]; do
echo -n "$prompt: " >&2
read result
if [ -z "$result" ]; then
print_warning "此项不能为空,请重新输入"
fi
done
fi
fi
echo "$result"
}
# 读取密码输入(隐藏输入)
read_password() {
local prompt="$1"
local allow_empty="$2"
local result
if [ "$allow_empty" = "true" ]; then
echo -n "$prompt (可为空): " >&2
read -s result
echo "" >&2
else
while [ -z "$result" ]; do
echo -n "$prompt: " >&2
read -s result
echo "" >&2
if [ -z "$result" ]; then
print_warning "此项不能为空,请重新输入"
fi
done
fi
echo "$result"
}
# 生成随机密码
generate_password() {
local length="$1"
if command -v pwgen &> /dev/null; then
pwgen -s "$length" 1
else
# 如果没有 pwgen使用 openssl 或 /dev/urandom
if command -v openssl &> /dev/null; then
openssl rand -base64 "$length" | tr -dc 'a-zA-Z0-9' | head -c "$length"
else
cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c "$length"
fi
fi
}
# 选择菜单
select_option() {
local prompt="$1"
shift
local options=("$@")
local choice
echo "$prompt" >&2
for i in "${!options[@]}"; do
echo " $((i+1)). ${options[$i]}" >&2
done
while true; do
echo -n "请输入选项编号: " >&2
read choice
if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le "${#options[@]}" ]; then
echo "$choice"
return
fi
print_warning "无效选项,请重新输入"
done
}
# 主函数
main() {
print_header "Hair Keeper 模板项目快速配置"
echo "本脚本将帮助您快速配置项目环境变量和开发工具"
echo ""
# ==================== 环境变量配置 ====================
print_header "环境变量配置"
# --- PostgreSQL 配置 ---
print_info "配置 PostgreSQL 数据库..."
echo ""
POSTGRESQL_USERNAME=$(read_input "PostgreSQL 用户名" "postgres")
POSTGRESQL_PASSWORD=$(read_password "PostgreSQL 密码" "true")
POSTGRESQL_PORT=$(read_input "PostgreSQL 端口" "5432")
echo ""
db_url_choice=$(select_option "DATABASE_URL 配置方式:" "自动构造 PostgreSQL URL" "手动输入完整 URL")
if [ "$db_url_choice" = "1" ]; then
POSTGRESQL_HOSTNAME=$(read_input "PostgreSQL 主机名" "postgresql")
POSTGRESQL_DBNAME=$(read_input "PostgreSQL 数据库名" "postgres")
POSTGRESQL_SCHEMA=$(read_input "PostgreSQL 模式名" "public")
if [ -n "$POSTGRESQL_PASSWORD" ]; then
DATABASE_URL="postgresql://${POSTGRESQL_USERNAME}:${POSTGRESQL_PASSWORD}@${POSTGRESQL_HOSTNAME}:${POSTGRESQL_PORT}/${POSTGRESQL_DBNAME}?schema=${POSTGRESQL_SCHEMA}"
else
DATABASE_URL="postgresql://${POSTGRESQL_USERNAME}@${POSTGRESQL_HOSTNAME}:${POSTGRESQL_PORT}/${POSTGRESQL_DBNAME}?schema=${POSTGRESQL_SCHEMA}"
fi
else
DATABASE_URL=$(read_input "DATABASE_URL" "")
fi
echo ""
print_success "PostgreSQL 配置完成"
echo ""
# --- Redis 配置 ---
print_info "配置 Redis..."
echo ""
REDIS_HOST=$(read_input "Redis 主机名" "redis")
REDIS_PORT=$(read_input "Redis 端口" "6379")
REDIS_PASSWORD=$(read_password "Redis 密码" "true")
echo ""
print_success "Redis 配置完成"
echo ""
# --- MinIO 配置 ---
print_info "配置 MinIO 对象存储..."
echo ""
MINIO_ENDPOINT=$(read_input "MinIO 端点地址" "minio")
MINIO_API_PORT=$(read_input "MinIO API 端口" "9000")
MINIO_CONSOLE_PORT=$(read_input "MinIO 控制台端口" "9001")
MINIO_USE_SSL=$(read_input "MinIO 是否使用 SSL (true/false)" "false")
MINIO_ROOT_USER=$(read_input "MinIO 用户名" "admin")
MINIO_ROOT_PASSWORD=$(read_password "MinIO 密码" "true")
MINIO_SERVER_URL=$(read_input "MinIO 服务器 URL (用于生成公开访问链接)" "" "true")
MINIO_BUCKET=$(read_input "MinIO 存储桶名称" "app-files")
echo ""
print_success "MinIO 配置完成"
echo ""
# --- 应用配置 ---
print_info "配置应用参数..."
echo ""
SUPER_ADMIN_PASSWORD=$(generate_password 16)
USER_DEFAULT_PASSWORD=$(generate_password 16)
NEXTAUTH_SECRET=$(generate_password 32)
print_info "已自动生成以下密码:"
echo " SUPER_ADMIN_PASSWORD: $SUPER_ADMIN_PASSWORD"
echo " USER_DEFAULT_PASSWORD: $USER_DEFAULT_PASSWORD"
echo " NEXTAUTH_SECRET: $NEXTAUTH_SECRET"
echo ""
DB_PARALLEL_LIMIT=$(read_input "数据库批次操作默认并发数" "32")
echo ""
print_success "应用参数配置完成"
echo ""
# --- AI API 配置 ---
print_info "配置 AI API..."
echo ""
PKUAI_API_KEY=$(read_password "PKU AI API Key" "true")
PKUAI_API_BASE=$(read_input "PKU AI API Base URL" "https://chat.noc.pku.edu.cn/")
echo ""
print_success "AI API 配置完成"
echo ""
# --- 开发环境配置 ---
print_info "配置开发环境参数..."
echo ""
DEV_PORT=$(read_input "开发服务器端口" "3000")
DEV_TERMINAL_PORT=$(read_input "开发终端默认端口" "7681")
DEV_TERMINAL_URL=$(read_input "开发终端 URL" "" "true")
DEV_TERMINAL=$(read_input "开发终端 tmux session 名称" "nextdev")
echo ""
print_success "开发环境配置完成"
echo ""
# ==================== 写入配置文件 ====================
print_header "写入配置文件"
# 写入 .env 文件
print_info "写入 .env 文件..."
cat > .env << EOF
# ==================== 容器相关 ====================
POSTGRESQL_USERNAME=${POSTGRESQL_USERNAME}
POSTGRESQL_PASSWORD=${POSTGRESQL_PASSWORD}
POSTGRESQL_PORT=${POSTGRESQL_PORT}
DATABASE_URL="${DATABASE_URL}"
REDIS_HOST=${REDIS_HOST}
REDIS_PORT=${REDIS_PORT}
REDIS_PASSWORD=${REDIS_PASSWORD}
MINIO_ENDPOINT=${MINIO_ENDPOINT}
MINIO_API_PORT=${MINIO_API_PORT}
MINIO_CONSOLE_PORT=${MINIO_CONSOLE_PORT}
MINIO_USE_SSL=${MINIO_USE_SSL}
MINIO_ROOT_USER=${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}
MINIO_SERVER_URL=${MINIO_SERVER_URL}
MINIO_BUCKET=${MINIO_BUCKET}
# ==================== 应用相关 ====================
SUPER_ADMIN_PASSWORD=${SUPER_ADMIN_PASSWORD}
USER_DEFAULT_PASSWORD=${USER_DEFAULT_PASSWORD}
## 数据库批次操作默认并发数
DB_PARALLEL_LIMIT=${DB_PARALLEL_LIMIT}
# ==================== NextAuth.js Configuration ====================
NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
# ==================== AI API ====================
PKUAI_API_KEY=${PKUAI_API_KEY}
PKUAI_API_BASE=${PKUAI_API_BASE}
EOF
print_success ".env 文件已创建"
# 写入 .env.development 文件
print_info "写入 .env.development 文件..."
cat > .env.development << EOF
# 仅在开发环境加载
PORT=${DEV_PORT}
NEXT_PUBLIC_DEV_TERMINAL_DEFAULT_PORT=${DEV_TERMINAL_PORT}
NEXT_PUBLIC_DEV_TERMINAL_URL=${DEV_TERMINAL_URL}
DEV_TERMINAL=${DEV_TERMINAL}
EOF
print_success ".env.development 文件已创建"
# 写入 .env.production 文件
print_info "写入 .env.production 文件..."
cat > .env.production << EOF
# 仅在生产环境加载
EOF
print_success ".env.production 文件已创建"
# ==================== Claude Code 编程代理配置 ====================
print_header "Claude Code 编程代理配置"
# 重命名 CLAUDE.md
if [ -f "CLAUDE.md" ]; then
print_info "重命名 CLAUDE.md 为 TEMPLATE_README.md..."
mv CLAUDE.md TEMPLATE_README.md
print_success "CLAUDE.md 已重命名为 TEMPLATE_README.md"
fi
# 创建新的 CLAUDE.md
print_info "创建新的 CLAUDE.md..."
cat > CLAUDE.md << 'EOF'
# CLAUDE.md
本文件为 AI 代理(如 Claude提供在本代码库中工作的指导说明。
## 项目说明
本项目基于 Hair Keeper 模板构建(详见 @TEMPLATE_README.md目前尚未实现业务功能。
## 重要提示
- 完成用户的任务后,务必运行`pnpm run lint`检查代码错误并修复不要运行build
- 尽量使用pnpm执行npm相关的命令
EOF
print_success "新的 CLAUDE.md 已创建"
# 清空 README.md
print_info "清空 README.md..."
> README.md
print_success "README.md 已清空"
# 创建 Claude Code Router 配置
print_info "配置 Claude Code Router..."
mkdir -p ~/.claude-code-router
cat > ~/.claude-code-router/config.json << EOF
{
"LOG": false,
"LOG_LEVEL": "debug",
"CLAUDE_PATH": "",
"HOST": "127.0.0.1",
"PORT": 3456,
"APIKEY": "",
"API_TIMEOUT_MS": "600000",
"PROXY_URL": "",
"transformers": [],
"Providers": [
{
"name": "pku-anthropic",
"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"
],
"transformer": {
"use": [
"Anthropic"
]
}
}
],
"StatusLine": {
"enabled": false,
"currentStyle": "default",
"default": {
"modules": []
},
"powerline": {
"modules": []
}
},
"Router": {
"default": "pku-anthropic,claude-opus-4-6",
"background": "pku-anthropic,claude-haiku-4-5-20251001",
"think": "pku-anthropic,-claude-opus4-6",
"longContext": "pku-anthropic,claude-opus-4-6",
"longContextThreshold": 80000,
"webSearch": "",
"image": "pku-anthropic,claude-opus-4-6"
},
"CUSTOM_ROUTER_PATH": ""
}
EOF
print_success "Claude Code Router 配置已写入 ~/.claude-code-router/config.json"
# ==================== Git 版本控制初始化 ====================
print_header "Git 版本控制初始化"
# 删除模板项目的 git 仓库
if [ -d ".git" ]; then
print_info "删除模板项目的 git 仓库..."
rm -rf .git
print_success "已删除 .git 目录"
fi
# 获取用户的 git 配置信息
print_info "配置 Git 用户信息..."
echo ""
GIT_USER_EMAIL=$(read_input "Git 用户邮箱" "")
GIT_USER_NAME=$(read_input "Git 用户名" "")
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"
print_success "Git 用户配置已设置"
# 初始化新的 git 仓库
print_info "初始化 Git 仓库..."
git add .
git commit -m "init"
git branch -M main
print_success "Git 仓库已初始化并完成首次提交 (主分支: main)"
# 配置远程仓库
echo ""
print_info "配置远程仓库 (格式: https://用户名:密码@gitea.example.com/用户名/仓库名.git)"
GIT_REMOTE_URL=$(read_input "远程仓库链接" "" "true")
if [ -n "$GIT_REMOTE_URL" ]; then
git remote add origin "$GIT_REMOTE_URL"
print_success "远程仓库已配置: $GIT_REMOTE_URL"
else
print_info "跳过远程仓库配置"
fi
# ==================== 完成提示 ====================
print_header "配置完成!"
echo -e "${GREEN}所有配置已完成!${NC}"
echo ""
echo "Claude Code 编程代理使用说明:"
echo -e " ${CYAN}ccr code${NC} - 打开 Claude Code 编程代理,可通过对话完成编程任务"
echo -e " ${CYAN}ccr ui${NC} - 配置 Claude Code 编程代理对接的大模型"
echo -e " ${CYAN}ccr code --resume${NC} - 查看历史对话"
echo ""
echo "接下来您可以进行以下操作:"
echo -e " ${CYAN}1. pnpm install${NC} - 安装依赖包"
echo -e " ${CYAN}2. pnpm prisma migrate dev${NC} - 迁移数据库并初始化 Prisma 客户端"
echo -e " ${CYAN}3. pnpm run db:seed${NC} - 初始化数据库数据"
echo -e " ${CYAN}4. pnpm run dev${NC} - 运行开发服务器"
echo -e " ${CYAN}5. ccr code${NC} - 打开编程代理后,先输入 ${YELLOW}/init <我的项目的主要功能是...>${NC} 初始化"
echo " 然后再开始使用 AI 完成编程任务"
echo ""
print_success "祝您开发愉快!"
}
# 运行主函数
main

69
simple_deploy.sh Executable file
View File

@@ -0,0 +1,69 @@
#!/bin/bash
# 此脚本用来一键部署到生产服务器
# 需事先配置好ssh免密登录目标服务器需要安装好node、pnpm、tsx、pm2来运行程序
set -e
# 配置
REMOTE_USER=""
REMOTE_HOST=""
REMOTE_PORT=""
PROJECT_NAME=""
REMOTE_DIR="~/$PROJECT_NAME"
pnpm run lint
echo "🔨 开始构建项目..."
pnpm run build
echo "📦 准备部署文件..."
# 创建临时目录
TEMP_DIR=$(mktemp -d)
trap "rm -rf $TEMP_DIR" EXIT
# 复制必要文件
cp -r .next-prod $TEMP_DIR/
cp -r public $TEMP_DIR/
cp -r prisma $TEMP_DIR/
cp package.json $TEMP_DIR/
cp pnpm-lock.yaml $TEMP_DIR/
cp next.config.ts $TEMP_DIR/
echo "🚀 上传文件到服务器..."
ssh ${REMOTE_USER}@${REMOTE_HOST} "mkdir -p ${REMOTE_DIR}"
rsync -avz --delete \
--exclude=node_modules \
--exclude=.git \
$TEMP_DIR/ ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/
# 上传环境变量文件
echo "📝 上传环境变量文件..."
[ -f .env ] && scp .env ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/.env
[ -f .env.production ] && scp .env.production ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/.env.production
echo "⚙️ 在服务器上配置和启动服务..."
ssh ${REMOTE_USER}@${REMOTE_HOST} << ENDSSH
cd ${REMOTE_DIR}
# 安装生产依赖包括prisma用于数据库迁移和pm2用于进程管理
echo "📥 安装依赖..."
pnpm install --prod
# 运行数据库迁移
echo "🗄️ 运行数据库迁移..."
npx prisma migrate deploy
npx prisma generate
# 使用PM2管理Next.js服务
echo "🔄 使用PM2重启服务..."
/home/user/.local/share/pnpm/pm2 delete $PROJECT_NAME || true
/home/user/.local/share/pnpm/pm2 start pnpm --name $PROJECT_NAME -- start -p ${REMOTE_PORT}
# 保存当前 PM2 进程列表的快照,使其在系统重启后能自动恢复
/home/user/.local/share/pnpm/pm2 save
echo "✅ 部署完成!服务运行在端口 ${REMOTE_PORT}"
echo "📊 查看服务状态: pm2 status"
echo "📝 查看日志: pm2 logs $PROJECT_NAME"
echo "❌ 关闭服务: pm2 delete $PROJECT_NAME"
ENDSSH
echo "🎉 部署成功!"
echo "访问地址: http://${REMOTE_HOST}:${REMOTE_PORT}"

28
simple_deploy_local.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/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 "🎉 部署成功!"

View File

@@ -0,0 +1,243 @@
'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>
)
}

View File

@@ -1,122 +1,23 @@
'use client'
import { getIaaaClientConfig } from "@/server/service/iaaa"
import { LoginForm } from "./login-form"
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 interface IaaaClientConfig {
enabled: boolean
appId: string
callbackPath: string
autoRedirect: boolean
/** 开发模式下未配置 IAAA需要显示配置提醒 */
showInDev: boolean
}
export default function LoginPage() {
const router = useRouter()
const config = getIaaaClientConfig()
const isDev = process.env.NODE_ENV === 'development'
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)
}
const iaaaConfig: IaaaClientConfig = {
...config,
showInDev: isDev && !config.enabled,
}
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>
)
}
return <LoginForm iaaaConfig={iaaaConfig} />
}

View File

@@ -1,4 +1,4 @@
import { GitBranch, GitCommit as GitCommitIcon, CornerRightUp, RotateCcw, AlertTriangle, RefreshCw, GitCommit } from 'lucide-react'
import { GitBranch, GitCommit as GitCommitIcon, CornerRightUp, RotateCcw, AlertTriangle, RefreshCw, GitCommit, Upload } 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'
type: 'checkout' | 'checkout-branch' | 'revert' | 'reset' | 'push'
commitId?: string
message?: string
title?: string
@@ -191,6 +191,17 @@ 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
@@ -306,9 +317,21 @@ 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()
@@ -357,7 +380,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)}
@@ -367,6 +390,16 @@ 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}

View File

@@ -126,8 +126,7 @@ export function DevTools() {
variant="outline"
size="sm"
onClick={() => {
const port = process.env.NEXT_PUBLIC_DEV_TERMINAL_DEFAULT_PORT || '7681'
window.open(`http://localhost:${port}`, '_blank')
window.open(process.env.NEXT_PUBLIC_DEV_TERMINAL_URL || `http://localhost:${process.env.NEXT_PUBLIC_DEV_TERMINAL_DEFAULT_PORT || '7681'}`, '_blank')
}}
disabled={!terminalLoaded}
className="gap-1.5"
@@ -194,7 +193,7 @@ export function DevTools() {
<div className="w-full h-full">
{terminalLoaded ? (
<iframe
src={`http://localhost:${process.env.NEXT_PUBLIC_DEV_TERMINAL_DEFAULT_PORT || '7681'}`}
src={process.env.NEXT_PUBLIC_DEV_TERMINAL_URL || `http://localhost:${process.env.NEXT_PUBLIC_DEV_TERMINAL_DEFAULT_PORT || '7681'}`}
className="w-full h-full border-0 rounded-md bg-black"
title="开发终端"
/>

View File

@@ -0,0 +1,247 @@
'use client'
import { ColumnDef } from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
import { Edit, Trash2, MoreHorizontal } from 'lucide-react'
import { formatDate } from '@/lib/format'
import { Checkbox } from '@/components/ui/checkbox'
import { DataTableColumnHeader } from '@/components/data-table/column-header'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import type { DeptAdmin } from '@/server/routers/dept-admin'
// 操作回调类型
export type DeptAdminActions = {
onEdit: (id: number) => void
onDelete: (id: number) => void
}
// 列定义选项类型
export type DeptAdminColumnsOptions = {
depts?: Array<{ code: string; name: string; fullName: string }>
}
// 创建院系管理员表格列定义
export const createDeptAdminColumns = (
actions: DeptAdminActions,
options: DeptAdminColumnsOptions = {}
): ColumnDef<DeptAdmin>[] => [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
size: 32,
enableSorting: false,
enableHiding: false,
},
{
id: 'id',
accessorKey: 'id',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="ID" />
),
cell: ({ row }) => <div className="font-medium">{row.original.id}</div>,
meta: {
label: 'ID',
},
},
{
id: 'uid',
accessorKey: 'uid',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="用户ID" />
),
cell: ({ row }) => <div className="font-medium">{row.original.uid}</div>,
enableColumnFilter: true,
meta: {
label: '用户ID',
filter: {
placeholder: '请输入用户ID',
variant: 'text',
}
},
},
{
id: 'userName',
accessorKey: 'user.name',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="姓名" />
),
cell: ({ row }) => <div>{row.original.user?.name || '-'}</div>,
enableColumnFilter: true,
meta: {
label: '姓名',
filter: {
placeholder: '请输入姓名',
variant: 'text',
}
},
},
{
id: 'deptCode',
accessorKey: 'deptCode',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="院系" />
),
cell: ({ row }) => (
<div>
<div className="font-medium">{row.original.dept?.name || '-'}</div>
<div className="text-xs text-muted-foreground">{row.original.deptCode}</div>
</div>
),
enableColumnFilter: true,
meta: {
label: '院系',
filter: {
variant: 'multiSelect',
options: options.depts?.map(dept => ({
id: dept.code,
name: dept.fullName,
})) || [],
}
},
},
{
id: 'adminEmail',
accessorKey: 'adminEmail',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="邮箱" />
),
cell: ({ row }) => <div>{row.original.adminEmail || '-'}</div>,
enableColumnFilter: true,
meta: {
label: '邮箱',
filter: {
placeholder: '请输入邮箱',
variant: 'text',
}
},
},
{
id: 'adminLinePhone',
accessorKey: 'adminLinePhone',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="座机" />
),
cell: ({ row }) => <div>{row.original.adminLinePhone || '-'}</div>,
enableColumnFilter: true,
meta: {
label: '座机',
filter: {
placeholder: '请输入座机',
variant: 'text',
}
},
},
{
id: 'adminMobilePhone',
accessorKey: 'adminMobilePhone',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="手机" />
),
cell: ({ row }) => <div>{row.original.adminMobilePhone || '-'}</div>,
enableColumnFilter: true,
meta: {
label: '手机',
filter: {
placeholder: '请输入手机',
variant: 'text',
}
},
},
{
id: 'note',
accessorKey: 'note',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="备注" />
),
cell: ({ row }) => (
<div className="max-w-[200px] truncate" title={row.original.note || ''}>
{row.original.note || '-'}
</div>
),
enableSorting: false,
meta: {
label: '备注',
},
},
{
id: 'createdAt',
accessorKey: 'createdAt',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="创建时间" />
),
cell: ({ row }) => {
return <div>{formatDate(row.original.createdAt) || '-'}</div>
},
meta: {
label: '创建时间',
}
},
{
id: 'updatedAt',
accessorKey: 'updatedAt',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="更新时间" />
),
cell: ({ row }) => {
return <div>{formatDate(row.original.updatedAt) || '-'}</div>
},
meta: {
label: '更新时间',
}
},
{
id: 'actions',
cell: ({ row }) => {
const deptAdmin = row.original
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6 md:w-9">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only"></span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => actions.onEdit(deptAdmin.id)}>
<Edit className="h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => actions.onDelete(deptAdmin.id)}
>
<Trash2 className="h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
},
size: 32,
enableSorting: false,
enableHiding: false,
},
]

View File

@@ -0,0 +1,186 @@
'use client'
import React, { useState } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { trpc } from '@/lib/trpc'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Dialog, DialogTrigger } from '@/components/ui/dialog'
import { Plus } from 'lucide-react'
import { toast } from 'sonner'
import { FormDialog, FormActionBar, FormGridContent, FormCancelAction, FormSubmitAction, type FormFieldConfig } from '@/components/common/form-dialog'
import {
AdvancedSelect,
SelectPopover,
SelectTrigger,
SelectContent,
SelectInput,
SelectItemList,
SelectedName
} from '@/components/common/advanced-select'
import { useSmartSelectOptions } from '@/hooks/use-smart-select-options'
import { Textarea } from '@/components/ui/textarea'
// 创建院系管理员的 schema
const createDeptAdminSchema = z.object({
uid: z.string().min(1, '用户ID不能为空'),
deptCode: z.string().min(1, '院系代码不能为空'),
adminEmail: z.string().email('邮箱格式不正确').optional().or(z.literal('')),
adminLinePhone: z.string().optional(),
adminMobilePhone: z.string().optional(),
note: z.string().optional(),
})
type CreateDeptAdminInput = z.input<typeof createDeptAdminSchema>
const createDeptAdminDefaultValues: CreateDeptAdminInput = {
uid: '',
deptCode: '',
adminEmail: '',
adminLinePhone: '',
adminMobilePhone: '',
note: '',
}
interface DeptAdminCreateDialogProps {
onDeptAdminCreated: () => void
}
export function DeptAdminCreateDialog({ onDeptAdminCreated }: DeptAdminCreateDialogProps) {
// 表单 dialog 控制
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
// react-hook-form 管理创建表单
const createForm = useForm<CreateDeptAdminInput>({
resolver: zodResolver(createDeptAdminSchema),
defaultValues: createDeptAdminDefaultValues,
})
// 获取院系列表
const { data: depts } = trpc.common.getDepts.useQuery()
const deptOptions = depts?.map(dept => ({ id: dept.code, name: dept.fullName, shortName: dept.name })) || []
const { sortedOptions: sortedDeptOptions, logSelection: logDeptSelection } = useSmartSelectOptions({
options: deptOptions,
context: 'deptAdmin.create.dept',
scope: 'personal',
})
// 创建院系管理员 mutation
const createDeptAdminMutation = trpc.deptAdmin.create.useMutation({
onSuccess: () => {
setIsCreateDialogOpen(false)
createForm.reset(createDeptAdminDefaultValues)
toast.success('院系管理员创建成功')
onDeptAdminCreated()
},
onError: (error) => {
toast.error(error.message || '创建院系管理员失败')
},
})
// 定义字段配置
const formFields: FormFieldConfig[] = React.useMemo(() => [
{
name: 'uid',
label: '用户ID',
required: true,
render: ({ field }) => (
<Input {...field} placeholder="请输入用户ID职工号" />
),
},
{
name: 'deptCode',
label: '院系',
required: true,
render: ({ field }) => (
<AdvancedSelect
{...field}
options={sortedDeptOptions}
onChange={(value) => { logDeptSelection(value); field.onChange(value) }}
filterFunction={(option, searchValue) => {
const search = searchValue.toLowerCase()
return option.id.includes(search) || option.name.toLowerCase().includes(search) ||
(option.shortName && option.shortName.toLowerCase().includes(search))
}}
>
<SelectPopover>
<SelectTrigger placeholder="请选择院系">
<SelectedName />
</SelectTrigger>
<SelectContent>
<SelectInput placeholder="搜索院系名称/代码" />
<SelectItemList />
</SelectContent>
</SelectPopover>
</AdvancedSelect>
),
},
{
name: 'adminEmail',
label: '邮箱',
render: ({ field }) => (
<Input {...field} type="email" placeholder="请输入邮箱" />
),
},
{
name: 'adminLinePhone',
label: '座机',
render: ({ field }) => (
<Input {...field} placeholder="请输入座机号码" />
),
},
{
name: 'adminMobilePhone',
label: '手机',
render: ({ field }) => (
<Input {...field} placeholder="请输入手机号码" />
),
},
{
name: 'note',
label: '备注',
render: ({ field }) => (
<Textarea {...field} placeholder="请输入备注信息" className="min-h-[80px]" />
),
},
], [sortedDeptOptions, logDeptSelection])
const handleSubmit = async (data: CreateDeptAdminInput) => {
createDeptAdminMutation.mutate(data)
}
const handleClose = () => {
setIsCreateDialogOpen(false)
}
return (
<>
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogTrigger asChild>
<Button className="flex items-center gap-2">
<Plus className="h-4 w-4" />
</Button>
</DialogTrigger>
</Dialog>
<FormDialog
isOpen={isCreateDialogOpen}
title="创建院系管理员"
description="请填写院系管理员信息"
form={createForm}
fields={formFields}
onClose={handleClose}
>
<FormGridContent />
<FormActionBar>
<FormCancelAction />
<FormSubmitAction onSubmit={handleSubmit} isSubmitting={createDeptAdminMutation.isPending}></FormSubmitAction>
</FormActionBar>
</FormDialog>
</>
)
}

View File

@@ -0,0 +1,84 @@
'use client'
import React from 'react'
import { trpc } from '@/lib/trpc'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { toast } from 'sonner'
interface DeptAdminDeleteDialogProps {
deptAdminId: number | null
isOpen: boolean
onClose: () => void
onDeptAdminDeleted: () => void
}
export function DeptAdminDeleteDialog({
deptAdminId,
isOpen,
onClose,
onDeptAdminDeleted,
}: DeptAdminDeleteDialogProps) {
// 获取院系管理员详情
const { data: deptAdmin } = trpc.deptAdmin.getById.useQuery(
{ id: deptAdminId! },
{ enabled: !!deptAdminId && isOpen }
)
// 删除院系管理员 mutation
const deleteDeptAdminMutation = trpc.deptAdmin.delete.useMutation({
onSuccess: () => {
onClose()
toast.success('院系管理员删除成功')
onDeptAdminDeleted()
},
onError: (error) => {
toast.error(error.message || '删除院系管理员失败')
},
})
const handleConfirm = () => {
if (deptAdminId) {
deleteDeptAdminMutation.mutate({ id: deptAdminId })
}
}
return (
<AlertDialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{deptAdmin ? (
<>
<strong>{deptAdmin.user?.name || deptAdmin.uid}</strong> <strong>{deptAdmin.dept?.name || deptAdmin.deptCode}</strong>
<br />
</>
) : (
'确定要删除该院系管理员吗?此操作无法撤销。'
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteDeptAdminMutation.isPending}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
disabled={deleteDeptAdminMutation.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteDeptAdminMutation.isPending ? '删除中...' : '确认删除'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,194 @@
'use client'
import React, { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { trpc } from '@/lib/trpc'
import { Input } from '@/components/ui/input'
import { toast } from 'sonner'
import { FormDialog, FormActionBar, FormGridContent, FormCancelAction, FormSubmitAction, type FormFieldConfig } from '@/components/common/form-dialog'
import {
AdvancedSelect,
SelectPopover,
SelectTrigger,
SelectContent,
SelectInput,
SelectItemList,
SelectedName
} from '@/components/common/advanced-select'
import { useSmartSelectOptions } from '@/hooks/use-smart-select-options'
import { Textarea } from '@/components/ui/textarea'
// 更新院系管理员的 schema
const updateDeptAdminSchema = z.object({
id: z.number(),
uid: z.string().min(1, '用户ID不能为空'),
deptCode: z.string().min(1, '院系代码不能为空'),
adminEmail: z.string().email('邮箱格式不正确').optional().or(z.literal('')),
adminLinePhone: z.string().optional(),
adminMobilePhone: z.string().optional(),
note: z.string().optional(),
})
type UpdateDeptAdminInput = z.input<typeof updateDeptAdminSchema>
interface DeptAdminUpdateDialogProps {
deptAdminId: number | null
isOpen: boolean
onClose: () => void
onDeptAdminUpdated: () => void
}
export function DeptAdminUpdateDialog({
deptAdminId,
isOpen,
onClose,
onDeptAdminUpdated,
}: DeptAdminUpdateDialogProps) {
// react-hook-form 管理更新表单
const updateForm = useForm<UpdateDeptAdminInput>({
resolver: zodResolver(updateDeptAdminSchema),
defaultValues: {
id: 0,
uid: '',
deptCode: '',
adminEmail: '',
adminLinePhone: '',
adminMobilePhone: '',
note: '',
},
})
// 获取院系列表
const { data: depts } = trpc.common.getDepts.useQuery()
const deptOptions = depts?.map(dept => ({ id: dept.code, name: dept.fullName, shortName: dept.name })) || []
const { sortedOptions: sortedDeptOptions, logSelection: logDeptSelection } = useSmartSelectOptions({
options: deptOptions,
context: 'deptAdmin.update.dept',
scope: 'personal',
})
// 获取院系管理员详情
const { data: deptAdmin, isLoading } = trpc.deptAdmin.getById.useQuery(
{ id: deptAdminId! },
{ enabled: !!deptAdminId && isOpen }
)
// 更新院系管理员 mutation
const updateDeptAdminMutation = trpc.deptAdmin.update.useMutation({
onSuccess: () => {
onClose()
toast.success('院系管理员更新成功')
onDeptAdminUpdated()
},
onError: (error) => {
toast.error(error.message || '更新院系管理员失败')
},
})
// 当获取到院系管理员数据时,填充表单
useEffect(() => {
if (deptAdmin) {
updateForm.reset({
id: deptAdmin.id,
uid: deptAdmin.uid,
deptCode: deptAdmin.deptCode,
adminEmail: deptAdmin.adminEmail || '',
adminLinePhone: deptAdmin.adminLinePhone || '',
adminMobilePhone: deptAdmin.adminMobilePhone || '',
note: deptAdmin.note || '',
})
}
}, [deptAdmin, updateForm])
// 定义字段配置
const formFields: FormFieldConfig[] = React.useMemo(() => [
{
name: 'uid',
label: '用户ID',
required: true,
render: ({ field }) => (
<Input {...field} placeholder="请输入用户ID职工号" />
),
},
{
name: 'deptCode',
label: '院系',
required: true,
render: ({ field }) => (
<AdvancedSelect
{...field}
options={sortedDeptOptions}
onChange={(value) => { logDeptSelection(value); field.onChange(value) }}
filterFunction={(option, searchValue) => {
const search = searchValue.toLowerCase()
return option.id.includes(search) || option.name.toLowerCase().includes(search) ||
(option.shortName && option.shortName.toLowerCase().includes(search))
}}
>
<SelectPopover>
<SelectTrigger placeholder="请选择院系">
<SelectedName />
</SelectTrigger>
<SelectContent>
<SelectInput placeholder="搜索院系名称/代码" />
<SelectItemList />
</SelectContent>
</SelectPopover>
</AdvancedSelect>
),
},
{
name: 'adminEmail',
label: '邮箱',
render: ({ field }) => (
<Input {...field} type="email" placeholder="请输入邮箱" />
),
},
{
name: 'adminLinePhone',
label: '座机',
render: ({ field }) => (
<Input {...field} placeholder="请输入座机号码" />
),
},
{
name: 'adminMobilePhone',
label: '手机',
render: ({ field }) => (
<Input {...field} placeholder="请输入手机号码" />
),
},
{
name: 'note',
label: '备注',
render: ({ field }) => (
<Textarea {...field} placeholder="请输入备注信息" className="min-h-[80px]" />
),
},
], [sortedDeptOptions, logDeptSelection])
const handleSubmit = async (data: UpdateDeptAdminInput) => {
updateDeptAdminMutation.mutate(data)
}
return (
<FormDialog
isOpen={isOpen}
title="编辑院系管理员"
description="修改院系管理员信息"
form={updateForm}
fields={formFields}
onClose={onClose}
isLoading={isLoading}
>
<FormGridContent />
<FormActionBar>
<FormCancelAction />
<FormSubmitAction onSubmit={handleSubmit} isSubmitting={updateDeptAdminMutation.isPending}></FormSubmitAction>
</FormActionBar>
</FormDialog>
)
}

View File

@@ -0,0 +1,143 @@
'use client'
import React, { useState, useCallback, useMemo, Suspense } from 'react'
import { trpc } from '@/lib/trpc'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { DeptAdminCreateDialog } from './components/DeptAdminCreateDialog'
import { DeptAdminUpdateDialog } from './components/DeptAdminUpdateDialog'
import { DeptAdminDeleteDialog } from './components/DeptAdminDeleteDialog'
import { DataTable } from '@/components/data-table/data-table'
import { DataTableToolbar } from '@/components/data-table/toolbar'
import { createDeptAdminColumns, type DeptAdminColumnsOptions } from './columns'
import type { DeptAdmin } from '@/server/routers/dept-admin'
import { useDataTable } from '@/hooks/use-data-table'
import { keepPreviousData } from '@tanstack/react-query'
import { DataTableSortList } from '@/components/data-table/sort-list'
import { toast } from 'sonner'
import { DataTableSkeleton } from '@/components/data-table/table-skeleton'
interface DeptAdminPageDataTableProps {
onEdit: (id: number) => void
onDelete: (id: number) => void
}
function DeptAdminPageDataTable({ onEdit, onDelete }: DeptAdminPageDataTableProps) {
// 获取部门列表用于过滤器选项
const { data: depts } = trpc.common.getDepts.useQuery()
// 创建表格列定义选项
const columnsOptions: DeptAdminColumnsOptions = useMemo(() => ({
depts: depts || [],
}), [depts])
// 创建表格列定义
const columns = useMemo(() => createDeptAdminColumns({
onEdit,
onDelete,
}, columnsOptions), [onEdit, onDelete, columnsOptions])
// 使用 useDataTable hook传入 queryFn
const { table, queryResult } = useDataTable<DeptAdmin>({
columns,
initialState: {
pagination: { pageIndex: 1, pageSize: 10 },
columnPinning: { left: ["select"], right: ["actions"] },
columnVisibility: { id: false }
},
getRowId: (row) => row.id.toString(),
queryFn: useCallback((params) => {
const result = trpc.deptAdmin.list.useQuery(params, {
placeholderData: keepPreviousData,
})
if (result.error) {
toast.error("获取院系管理员数据失败:" + result.error.toString().substring(0, 100))
}
return result
}, []),
})
return (
<DataTable table={table} isLoading={queryResult.isLoading}>
<DataTableToolbar table={table}>
<DataTableSortList table={table} />
</DataTableToolbar>
</DataTable>
)
}
export default function DeptAdminPage() {
// 更新院系管理员对话框状态
const [updateDeptAdminId, setUpdateDeptAdminId] = useState<number | null>(null)
const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false)
// 删除院系管理员对话框状态
const [deleteDeptAdminId, setDeleteDeptAdminId] = useState<number | null>(null)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
// 用于刷新数据的 utils
const utils = trpc.useUtils()
// 处理编辑院系管理员
const handleEditDeptAdmin = useCallback((id: number) => {
setUpdateDeptAdminId(id)
setIsUpdateDialogOpen(true)
}, [])
// 关闭更新对话框
const handleCloseUpdateDialog = useCallback(() => {
setIsUpdateDialogOpen(false)
setUpdateDeptAdminId(null)
}, [])
// 处理删除院系管理员
const handleDeleteDeptAdmin = useCallback((id: number) => {
setDeleteDeptAdminId(id)
setIsDeleteDialogOpen(true)
}, [])
// 关闭删除对话框
const handleCloseDeleteDialog = useCallback(() => {
setIsDeleteDialogOpen(false)
setDeleteDeptAdminId(null)
}, [])
// 刷新院系管理员列表
const handleRefreshDeptAdmins = useCallback(() => {
utils.deptAdmin.list.invalidate()
}, [utils])
return (
<div className="space-y-6">
{/* 院系管理员列表和创建按钮 */}
<Card>
<CardHeader className="flex items-center justify-between">
<CardTitle className="text-lg"></CardTitle>
<div className="flex items-center gap-2">
<DeptAdminCreateDialog onDeptAdminCreated={handleRefreshDeptAdmins} />
</div>
</CardHeader>
<CardContent>
<Suspense fallback={<DataTableSkeleton columnCount={10} rowCount={10} />}>
<DeptAdminPageDataTable onEdit={handleEditDeptAdmin} onDelete={handleDeleteDeptAdmin} />
</Suspense>
</CardContent>
</Card>
{/* 更新院系管理员对话框 */}
<DeptAdminUpdateDialog
deptAdminId={updateDeptAdminId}
isOpen={isUpdateDialogOpen}
onClose={handleCloseUpdateDialog}
onDeptAdminUpdated={handleRefreshDeptAdmins}
/>
{/* 删除院系管理员对话框 */}
<DeptAdminDeleteDialog
deptAdminId={deleteDeptAdminId}
isOpen={isDeleteDialogOpen}
onClose={handleCloseDeleteDialog}
onDeptAdminDeleted={handleRefreshDeptAdmins}
/>
</div>
)
}

View File

@@ -0,0 +1,13 @@
"use client";
export default function UsersLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
{children}
</>
);
}

View File

@@ -207,7 +207,7 @@ export function RoleManagementDialog() {
options={permissions.map(p => ({ ...p, id: p.id.toString() }))}
value={editingRole.permissionIds.map(String)}
onChange={handlePermissionChange}
multiple={{ enable: true, limit: 1 }}
multiple={{ enable: true }}
>
<SelectPopover>
<SelectTrigger placeholder="选择权限">

View File

@@ -218,6 +218,7 @@ export function UserUpdateDialog({ userId, isOpen, onClose, onUserUpdated }: Use
form={updateForm}
fields={formFields}
onClose={handleClose}
isLoading={isLoadingUser}
>
<FormGridContent />
<FormActionBar>

View File

@@ -29,7 +29,7 @@ export function WelcomeDialog({ open, onOpenChange }: WelcomeDialogProps) {
<DialogTitle className="text-2xl"></DialogTitle>
</div>
<DialogDescription className="pt-4 text-base">
{/* 内容暂时没想好,先不实现 */}
Hair Keeper
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2 pt-4">

View File

@@ -0,0 +1,93 @@
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
}

View File

@@ -32,12 +32,14 @@ import {
import { useIsMobile } from '@/hooks/use-mobile'
import { cn } from '@/lib/utils'
import { Loader2 } from 'lucide-react'
import { Skeleton } from '@/components/ui/skeleton'
// FormDialog Context
export interface FormDialogContextValue {
form: UseFormReturn<any>
close: () => void
fields: FormFieldConfig[]
isLoading?: boolean
}
const FormDialogContext = createContext<FormDialogContextValue | null>(null)
@@ -77,7 +79,7 @@ export function FormCancelAction({ children = '取消', variant = 'outline', onC
}
return (
<Button type="button" variant={variant} onClick={handleClick} {...props}>
<Button type="button" variant={variant} onClick={handleClick} disabled={props.disabled} {...props}>
{children}
</Button>
)
@@ -99,7 +101,7 @@ export function FormResetAction({
confirmDescription = '确定要重置表单吗?表单将回到打开时的状态。',
...props
}: FormResetActionProps) {
const { form } = useFormDialogContext()
const { form, isLoading } = useFormDialogContext()
const [showConfirm, setShowConfirm] = useState(false)
const handleConfirm = () => {
@@ -113,7 +115,7 @@ export function FormResetAction({
return (
<>
<Button type="button" variant={variant} onClick={() => setShowConfirm(true)} {...props}>
<Button type="button" variant={variant} onClick={() => setShowConfirm(true)} disabled={isLoading || props.disabled} {...props}>
{children}
</Button>
@@ -151,14 +153,14 @@ export function FormSubmitAction({
variant = 'default',
...props
}: FormSubmitActionProps) {
const { form } = useFormDialogContext()
const { form, isLoading } = useFormDialogContext()
return (
<Button
type="button"
variant={variant}
onClick={form.handleSubmit(onSubmit)}
disabled={isSubmitting || disabled}
disabled={isSubmitting || disabled || isLoading}
{...props}
>
{children}
@@ -186,7 +188,21 @@ export interface FormGridContentProps {
}
export function FormGridContent({ className = 'grid grid-cols-1 gap-4' }: FormGridContentProps) {
const { form, fields } = useFormDialogContext()
const { form, fields, isLoading } = useFormDialogContext()
// 如果正在加载,显示骨架屏
if (isLoading) {
return (
<div className={cn("p-1", className)}>
{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>
)
}
return (
<div className={cn("p-1", className)}>
@@ -223,6 +239,7 @@ export interface FormDialogProps {
className?: string // 允许自定义对话框内容样式,可控制宽度
formClassName?: string // 允许自定义表格样式
children: React.ReactNode // 操作按钮区域内容
isLoading?: boolean // 是否正在加载数据
}
export function FormDialog({
@@ -235,13 +252,14 @@ export function FormDialog({
className = 'max-w-md',
formClassName,
children,
isLoading = false,
}: FormDialogProps) {
const isMobile = useIsMobile()
const formRef = useRef<HTMLFormElement>(null)
// 当对话框打开时,自动聚焦到第一个表单输入控件
useEffect(() => {
if (isOpen) {
if (isOpen && !isLoading) {
// 使当前拥有焦点的元素通常是用来触发打开这个drawer的控件失去焦点不然控制台会警告焦点在一个要被隐藏于屏幕阅读器的控件上
(document.activeElement as HTMLElement)?.blur();
// 使用 setTimeout 确保 DOM 已完全渲染
@@ -249,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])'
'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled]), button[role="combobox"]:not([disabled])'
)
if (firstInput) {
firstInput.focus()
@@ -259,7 +277,7 @@ export function FormDialog({
return () => clearTimeout(timer)
}
}, [isOpen])
}, [isOpen, isLoading])
const close = () => {
onClose()
@@ -269,7 +287,8 @@ export function FormDialog({
const contextValue: FormDialogContextValue = {
form,
close,
fields
fields,
isLoading
}
// 表单内容组件,在 Dialog 和 Drawer 中复用

View File

@@ -1,6 +1,6 @@
'use client'
import React, { createContext, useContext, useState, useEffect, useRef } from 'react'
import React, { createContext, useContext, useEffect, useRef, useState } 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,20 +19,43 @@ 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 {
onCancel: () => void
onPrevious: () => void
onNext: () => void
isSubmitting: boolean
isValidating: boolean
submitButtonText: string
form: UseFormReturn<any>
close: () => void
steps: StepConfig[]
currentStep: number
totalSteps: number
isFirstStep: boolean
isLastStep: boolean
goToStep: (step: number) => void
goNext: () => void
goPrev: () => void
isLoading?: boolean
canGoNext: () => Promise<boolean>
}
const MultiStepFormDialogContext = createContext<MultiStepFormDialogContextValue | null>(null)
@@ -45,91 +68,343 @@ export function useMultiStepFormDialogContext() {
return context
}
// 字段配置类型定义
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 interface StepIndicatorProps {
className?: string
showLabels?: boolean
clickable?: boolean
}
// 步骤配置类型定义
export interface StepConfig {
title: string
description?: string
fields: FormFieldConfig[]
}
// 多步骤表单操作按钮栏组件
export function MultiStepFormActionBar() {
const {
onCancel,
onPrevious,
onNext,
isSubmitting,
isValidating,
submitButtonText,
isFirstStep,
isLastStep
} = useMultiStepFormDialogContext()
export function StepIndicator({ className, showLabels = false, clickable = false }: StepIndicatorProps) {
const { steps, currentStep, goToStep, isLoading } = useMultiStepFormDialogContext()
return (
<div className="flex justify-between pt-6 border-t">
<div className="flex space-x-2">
<Button type="button" variant="outline" onClick={onCancel}>
</Button>
</div>
<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 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>
)
}
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>
)
}
// 步骤标题组件(显示当前步骤的标题和描述)
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
isSubmitting: boolean
submitButtonText: string
className?: string
formClassName?: string
children: React.ReactNode
isLoading?: boolean
initialStep?: number
}
export function MultiStepFormDialog({
@@ -137,30 +412,33 @@ export function MultiStepFormDialog({
title,
description,
form,
onSubmit,
steps,
contentClassName = 'max-w-4xl',
gridClassName = 'grid grid-cols-1 md:grid-cols-2 gap-4',
onClose,
isSubmitting,
submitButtonText,
className = 'max-w-lg',
formClassName,
children,
isLoading = false,
initialStep = 0,
}: 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) {
// 使当前拥有焦点的元素通常是用来触发打开这个drawer的控件失去焦点不然控制台会警告焦点在一个要被隐藏于屏幕阅读器的控件上
setCurrentStep(initialStep)
}
}, [isOpen, initialStep])
// 当对话框打开或步骤变化时,自动聚焦到第一个表单输入控件
useEffect(() => {
if (isOpen && !isLoading) {
(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])'
'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled]), button[role="combobox"]:not([disabled])'
)
if (firstInput) {
firstInput.focus()
@@ -170,138 +448,78 @@ export function MultiStepFormDialog({
return () => clearTimeout(timer)
}
}, [isOpen, currentStep])
}, [isOpen, isLoading, currentStep])
const handleSubmit = async (data: any) => {
await onSubmit(data)
}
const handleClose = () => {
setCurrentStep(0)
const close = () => {
onClose()
}
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 totalSteps = steps.length
const isFirstStep = currentStep === 0
const isLastStep = currentStep === steps.length - 1
const currentStepConfig = steps[currentStep]
const isLastStep = currentStep === totalSteps - 1
// 步骤指示器组件
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 = {
onCancel: handleClose,
onPrevious: handlePrevious,
onNext: handleNext,
isSubmitting,
isValidating,
submitButtonText,
isFirstStep,
isLastStep
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 contextValue: MultiStepFormDialogContextValue = {
form,
close,
steps,
currentStep,
totalSteps,
isFirstStep,
isLastStep,
goToStep,
goNext,
goPrev,
isLoading,
canGoNext,
}
// 表单内容组件
const formContent = (
<MultiStepFormDialogContext.Provider value={contextValue}>
<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>
<Form {...form}>
<form ref={formRef} className={cn("space-y-6", formClassName)}>
{children}
</form>
</Form>
</MultiStepFormDialogContext.Provider>
)
// 根据设备类型渲染不同的组件
if (isMobile) {
return (
<Drawer open={isOpen} onOpenChange={handleClose}>
<DrawerContent>
<Drawer open={isOpen} onOpenChange={(open) => !open && close()}>
<DrawerContent className={className}>
<DrawerHeader>
<DrawerTitle>{title}</DrawerTitle>
<DrawerDescription>{description}</DrawerDescription>
</DrawerHeader>
<div className="px-4 pb-4 overflow-y-auto max-h-[70vh]">
{stepIndicator}
{formContent}
</div>
</DrawerContent>
@@ -310,17 +528,16 @@ export function MultiStepFormDialog({
}
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className={contentClassName}>
<Dialog open={isOpen} onOpenChange={(open) => !open && close()}>
<DialogContent className={className} showCloseButton={false}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{stepIndicator}
<div className='overflow-y-auto max-h-[70vh]'>
<div className="overflow-y-auto max-h-[70vh]">
{formContent}
</div>
</DialogContent>
</Dialog>
)
}
}

View File

@@ -1,122 +0,0 @@
"use client";
import type { Column } from "@tanstack/react-table";
import * as React from "react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { ExtendedColumnFilter } from "@/types/data-table";
interface DataTableRangeFilterProps<TData> extends React.ComponentProps<"div"> {
filter: ExtendedColumnFilter<TData>;
column: Column<TData>;
inputId: string;
onFilterUpdate: (
filterId: string,
updates: Partial<Omit<ExtendedColumnFilter<TData>, "filterId">>,
) => void;
}
export function DataTableRangeFilter<TData>({
filter,
column,
inputId,
onFilterUpdate,
className,
...props
}: DataTableRangeFilterProps<TData>) {
const meta = column.columnDef.meta;
const [min, max] = React.useMemo(() => {
const range = column.columnDef.meta?.filter?.range;
if (range) return range;
const values = column.getFacetedMinMaxValues();
if (!values) return [0, 100];
return [values[0], values[1]];
}, [column]);
const formatValue = React.useCallback(
(value: string | number | undefined) => {
if (value === undefined || value === "") return "";
const numValue = Number(value);
return Number.isNaN(numValue)
? ""
: numValue.toLocaleString(undefined, {
maximumFractionDigits: 0,
});
},
[],
);
const value = React.useMemo(() => {
if (Array.isArray(filter.value)) return filter.value.map(formatValue);
return [formatValue(filter.value), ""];
}, [filter.value, formatValue]);
const onRangeValueChange = React.useCallback(
(value: string, isMin?: boolean) => {
const numValue = Number(value);
const currentValues = Array.isArray(filter.value)
? filter.value
: ["", ""];
const otherValue = isMin
? (currentValues[1] ?? "")
: (currentValues[0] ?? "");
if (
value === "" ||
(!Number.isNaN(numValue) &&
(isMin
? numValue >= min && numValue <= (Number(otherValue) || max)
: numValue <= max && numValue >= (Number(otherValue) || min)))
) {
onFilterUpdate(filter.filterId, {
value: isMin ? [value, otherValue] : [otherValue, value],
});
}
},
[filter.filterId, filter.value, min, max, onFilterUpdate],
);
return (
<div
data-slot="range"
className={cn("flex w-full items-center gap-2", className)}
{...props}
>
<Input
id={`${inputId}-min`}
type="number"
aria-label={`${meta?.label} minimum value`}
aria-valuemin={min}
aria-valuemax={max}
data-slot="range-min"
inputMode="numeric"
placeholder={min.toString()}
min={min}
max={max}
className="h-8 w-full rounded"
defaultValue={value[0]}
onChange={(event) => onRangeValueChange(event.target.value, true)}
/>
<span className="sr-only shrink-0 text-muted-foreground">to</span>
<Input
id={`${inputId}-max`}
type="number"
aria-label={`${meta?.label} maximum value`}
aria-valuemin={min}
aria-valuemax={max}
data-slot="range-max"
inputMode="numeric"
placeholder={max.toString()}
min={min}
max={max}
className="h-8 w-full rounded"
defaultValue={value[1]}
onChange={(event) => onRangeValueChange(event.target.value)}
/>
</div>
);
}

View File

@@ -45,7 +45,7 @@ export function AppSidebar({ menuItems, ...props }: AppSidebarProps) {
// 等待侧边栏关闭动画完成后再导航
setTimeout(() => {
router.push(href)
}, 300)
}, 350)
}
}, [isMobile, setOpenMobile, router])

View File

@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { User, LogOut, KeyRound } from 'lucide-react'
import { DevPanel } from '@/app/(main)/dev/panel'
import { ChangePasswordDialog } from '@/components/layout/change-password-dialog'
@@ -14,7 +14,6 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { SidebarTrigger } from '@/components/ui/sidebar'
import { signOut } from 'next-auth/react'
@@ -23,6 +22,11 @@ import { useTheme } from 'next-themes'
import { ThemeToggleButton, useThemeTransition } from '@/components/common/theme-toggle-button'
import { getMenuTitle } from '@/constants/menu'
import type { User as AppUser } from '@/types/user'
import { useUserStore } from '@/lib/stores/userStore'
import { toast } from 'sonner'
import { trpc } from '@/lib/trpc'
import { AdvancedSelect, SelectContent, SelectedName, SelectInput, SelectItemList, SelectPopover, SelectTrigger } from '@/components/common/advanced-select'
import type { Dept } from '@prisma/client'
interface HeaderProps {
user?: AppUser
@@ -38,6 +42,47 @@ export function Header({ user }: HeaderProps) {
const pageTitle = getMenuTitle(pathname, 2) // 只匹配到第二级菜单
// 从zustand store获取当前管理的院系信息
const { setCurrentManagedDept } = useUserStore()
// 获取可管理的院系列表(包含当前管理的院系信息)
const { data: managedDeptsData } = trpc.users.getManagedDepts.useQuery()
// 本地状态管理
const [currentManagedDeptCode, setCurrentManagedDeptCode] = useState<string | null>(null)
const [managedDepts, setManagedDepts] = useState<Array<Dept>>([])
// 初始化数据
useEffect(() => {
if (managedDeptsData && managedDeptsData.currentDept !== undefined) {
setCurrentManagedDeptCode(managedDeptsData.currentDept)
setManagedDepts(managedDeptsData.depts)
// 更新store中的当前管理院系信息
const deptInfo = managedDeptsData.depts.find(dept => dept.code === managedDeptsData.currentDept) || null
setCurrentManagedDept(deptInfo)
}
}, [managedDeptsData, setCurrentManagedDept])
// 切换管理院系
const switchManagedDeptMutation = trpc.users.switchManagedDept.useMutation({
onSuccess: (data) => {
toast.success('切换管理院系成功')
// 更新本地状态
setCurrentManagedDeptCode(data.deptCode)
// 更新store中的当前管理院系信息
setCurrentManagedDept(managedDepts.find(dept => dept.code === data.deptCode) || null)
},
onError: (error) => {
toast.error(error.message || '切换管理院系失败')
},
})
// 处理院系切换
const handleDeptChange = (deptCode: string | null) => {
switchManagedDeptMutation.mutate({ deptCode })
}
const handleThemeToggle = () => {
startTransition(() => {
setTheme(theme === 'dark' ? 'light' : 'dark')
@@ -105,6 +150,52 @@ export function Header({ user }: HeaderProps) {
<DropdownMenuItem disabled>
{user.isSuperAdmin ? '超级管理员' : (Array.isArray(user.roles) ? user.roles.join('、') : user.roles)}
</DropdownMenuItem>
{/* 管理院系 - 根据可管理院系数量决定显示方式 */}
{managedDepts.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel></DropdownMenuLabel>
{managedDepts.length === 1 ? (
// 只有一个可管理院系时,直接显示院系名称
<DropdownMenuItem disabled>
{managedDepts[0].fullName}
</DropdownMenuItem>
) : (
// 多个可管理院系时,显示下拉选择器
<div className="px-2 py-1.5">
<AdvancedSelect
value={currentManagedDeptCode}
onChange={handleDeptChange}
options={managedDepts.map(dept => ({
id: dept.code,
name: dept.fullName,
shortName: dept.name
}))}
disabled={switchManagedDeptMutation.isPending}
filterFunction={(option, searchValue) => {
const search = searchValue.toLowerCase()
return option.id.includes(search) ||
option.name.toLowerCase().includes(search) ||
(option.shortName && option.shortName.toLowerCase().includes(search))
}}
>
<SelectPopover>
<SelectTrigger
placeholder="请选择管理院系"
className="h-9"
>
<SelectedName />
</SelectTrigger>
<SelectContent>
{managedDepts.length > 5 && <SelectInput placeholder="搜索院系名称/代码" />}
<SelectItemList />
</SelectContent>
</SelectPopover>
</AdvancedSelect>
</div>
)}
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600 cursor-pointer"
@@ -131,4 +222,4 @@ export function Header({ user }: HeaderProps) {
/>
</header>
)
}
}

View File

@@ -1,7 +1,32 @@
'use client'
import { SessionProvider as NextAuthSessionProvider } from "next-auth/react"
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}</>
}
export function SessionProvider({ children }: { children: React.ReactNode }) {
return <NextAuthSessionProvider>{children}</NextAuthSessionProvider>
}
return (
<NextAuthSessionProvider>
<SessionInvalidationGuard>
{children}
</SessionInvalidationGuard>
</NextAuthSessionProvider>
)
}

View File

@@ -27,7 +27,9 @@ import {
FileBarChart,
Globe,
Menu,
LucideIcon
LucideIcon,
User,
Building
} from 'lucide-react'
/**
@@ -62,6 +64,8 @@ export const menuIconMap: Record<string, LucideIcon> = {
'ClipboardCheck': ClipboardCheck,
'FileBarChart': FileBarChart,
'Globe': Globe,
'User': User,
'Building': Building
}
/**

View File

@@ -220,6 +220,20 @@ export const menuItems: MenuItem[] = [
href: '/users',
icon: 'UserCog',
permission: Permissions.USER_MANAGE,
children: [
{
title: '用户与授权',
href: '/users/user-info',
icon: 'UserCog',
permission: Permissions.USER_MANAGE,
},
{
title: '院系管理员',
href: '/users/dept-admin',
icon: 'Building',
permission: Permissions.USER_MANAGE,
}
]
},
{
title: '系统设置',

View File

@@ -3,4 +3,4 @@
*/
export const SITE_NAME = 'Hair Keeper'
export const SITE_DESCRIPTION = '高度集成、深度定制、约定优于配置的全栈Web应用模板旨在保持灵活性的同时提供一套基于成熟架构的开发底座自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能提供AI开发辅助免于纠结功能如何实现可快速上手专注于业务逻辑'
export const SITE_VERSION = 'v1.0.0'
export const SITE_VERSION = 'v1.3.0'

View File

@@ -0,0 +1,18 @@
import { create } from 'zustand'
import type { Dept } from '@prisma/client'
/**
* 用户状态管理Store
* 用于管理用户相关的全局状态,如当前管理的院系
*/
interface UserStore {
// 当前管理的院系
currentManagedDept: Dept | null
// 设置当前管理的院系
setCurrentManagedDept: (deptInfo: Dept | null) => void
}
export const useUserStore = create<UserStore>((set) => ({
currentManagedDept: null,
setCurrentManagedDept: (deptInfo) => set({ currentManagedDept: deptInfo }),
}))

View File

@@ -9,8 +9,8 @@ export default withAuth(
const token = req.nextauth.token
const pathname = req.nextUrl.pathname
// 如果用户已登录且访问登录页面,重定向到首页
if (pathname === "/login" && token) {
// 如果用户已登录且访问登录页面,重定向到首页(会话失效的用户除外,需留在登录页)
if (pathname === "/login" && token && !token.sessionInvalid) {
return NextResponse.redirect(new URL("/", req.url))
}
@@ -49,7 +49,12 @@ export default withAuth(
if (req.nextUrl.pathname === "/login") {
return true
}
// 会话已被标记失效,强制重新登录
if (token?.sessionInvalid) {
return false
}
// 其他路由需要有效的 token
return !!token
},

View File

@@ -4,6 +4,37 @@ 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: [
@@ -21,15 +52,8 @@ export const authOptions: NextAuthOptions = {
try {
// 查找用户
const user = await db.user.findUnique({
where: {
id: credentials.id
},
include: {
roles: {
include: { permissions: true }
},
dept: true
}
where: { id: credentials.id },
include: userAuthInclude,
})
if (!user) {
@@ -38,7 +62,6 @@ export const authOptions: NextAuthOptions = {
// 验证密码
const isPasswordValid = await bcrypt.compare(credentials.password, user.password)
if (!isPasswordValid) {
return null
@@ -49,23 +72,11 @@ export const authOptions: NextAuthOptions = {
where: { id: user.id },
data: { lastLoginAt: new Date() }
})
// 返回用户信息、角色和权限
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
// 清除会话失效标记(用户已重新登录,获得最新权限
await clearSessionInvalidation(user.id)
return buildUserJwtPayload(user) as any
} catch (error) {
console.error("Auth error:", error)
return null
@@ -98,10 +109,20 @@ 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

View File

@@ -1,7 +1,7 @@
import 'server-only'
import { Client } from 'minio';
// 初始化 MinIO 客户端
// 内部客户端:用于 statObject、removeObject 等服务端直连操作,以及 presignedPostPolicy
export const minioClient = new Client({
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
port: parseInt(process.env.MINIO_API_PORT || '9000'),
@@ -10,8 +10,61 @@ 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';
/**
* 获取客户端访问的基础 URL
* 优先使用 MINIO_SERVER_URL公网地址否则使用内部地址
*/
function getClientBaseUrl(): string {
if (process.env.MINIO_SERVER_URL) {
return process.env.MINIO_SERVER_URL;
}
const protocol = process.env.MINIO_USE_SSL === 'true' ? 'https' : 'http';
const endpoint = process.env.MINIO_ENDPOINT || 'localhost';
const port = process.env.MINIO_API_PORT || '9000';
return `${protocol}://${endpoint}:${port}`;
}
/**
* 替换预签名 URL 中的内部地址为客户端可访问的地址
*/
function replaceUrlBase(originalUrl: string): string {
const clientBase = getClientBaseUrl();
const url = new URL(originalUrl);
const clientUrl = new URL(clientBase);
url.protocol = clientUrl.protocol;
url.hostname = clientUrl.hostname;
url.port = clientUrl.port; // 空字符串表示使用协议默认端口
return url.toString();
}
// 桶初始化标志
let bucketInitialized = false;
@@ -139,6 +192,9 @@ export async function generatePresignedPostPolicy(
// 精确匹配
policy.setContentType(allowedContentType);
}
} else {
// 未指定类型限制时,允许任意 Content-Type客户端上传时会设置此字段Policy 中必须声明)
policy.policy.conditions.push(['starts-with', '$Content-Type', '']);
}
if (allowOriginalFilename) {
@@ -150,7 +206,7 @@ export async function generatePresignedPostPolicy(
const presignedData = await minioClient.presignedPostPolicy(policy);
return {
postURL: presignedData.postURL,
postURL: replaceUrlBase(presignedData.postURL),
formData: presignedData.formData,
objectName,
};
@@ -228,8 +284,8 @@ export async function generatePresignedGetObject(
}
}
// 生成预签名 URL
const url = await minioClient.presignedGetObject(
// 使用公网客户端生成预签名 URL,确保 V4 签名中的 Host 与浏览器访问地址一致
const url = await presignClient.presignedGetObject(
BUCKET_NAME,
objectName,
expirySeconds,
@@ -343,9 +399,5 @@ export async function getObjectMetadata(objectName: string) {
* @returns 公开访问 URL
*/
export function getPublicUrl(objectName: string): string {
const protocol = process.env.MINIO_USE_SSL === 'true' ? 'https' : 'http';
const endpoint = process.env.MINIO_ENDPOINT || 'localhost';
const port = process.env.MINIO_API_PORT || '9000';
return `${protocol}://${endpoint}:${port}/${BUCKET_NAME}/${objectName}`;
return `${getClientBaseUrl()}/${BUCKET_NAME}/${objectName}`;
}

View File

@@ -13,7 +13,7 @@ export function getRedisClient() {
const redisPassword = process.env.REDIS_PASSWORD
redisClient = new Redis({
host: 'localhost',
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(redisPort, 10),
password: redisPassword,
maxRetriesPerRequest: null, // BullMQ 推荐设置

View File

@@ -1,5 +1,6 @@
import { createTRPCRouter } from '@/server/trpc'
import { usersRouter } from './users'
import { deptAdminRouter } from './dept-admin'
import { selectionRouter } from './selection'
import { uploadRouter } from './upload'
import { globalRouter } from './global'
@@ -14,6 +15,7 @@ import { commonRouter } from './common'
export const appRouter = createTRPCRouter({
common: commonRouter,
users: usersRouter,
deptAdmin: deptAdminRouter,
selection: selectionRouter,
upload: uploadRouter,
global: globalRouter,
@@ -27,4 +29,4 @@ export const appRouter = createTRPCRouter({
} : {})
})
export type AppRouter = typeof appRouter
export type AppRouter = typeof appRouter

View File

@@ -1,8 +1,12 @@
// 通用接口,与特定业务关联性不强,需要在不同的地方反复使用
import { createTRPCRouter, permissionRequiredProcedure } from '@/server/trpc'
import { inferProcedureOutput } from '@trpc/server';
export const commonRouter = createTRPCRouter({
getDepts: permissionRequiredProcedure('').query(({ ctx }) =>
ctx.db.dept.findMany({ orderBy: { code: 'asc' } })
),
})
})
export type CommonRouter = typeof commonRouter;
export type Dept = inferProcedureOutput<CommonRouter['getDepts']>[number]

View File

@@ -0,0 +1,206 @@
import { createTRPCRouter, permissionRequiredProcedure } from '@/server/trpc'
import { Permissions } from '@/constants/permissions'
import { dataTableQueryParamsSchema } from '@/lib/schema/data-table'
import { transformDataTableQueryParams } from '@/server/utils/data-table-helper'
import { z } from 'zod'
import { inferProcedureOutput, TRPCError } from '@trpc/server'
// 创建院系管理员的 schema
const createDeptAdminSchema = z.object({
uid: z.string().min(1, '用户ID不能为空'),
deptCode: z.string().min(1, '院系代码不能为空'),
adminEmail: z.string().email('邮箱格式不正确').optional().or(z.literal('')),
adminLinePhone: z.string().optional(),
adminMobilePhone: z.string().optional(),
note: z.string().optional(),
})
// 更新院系管理员的 schema
const updateDeptAdminSchema = z.object({
id: z.number(),
uid: z.string().min(1, '用户ID不能为空'),
deptCode: z.string().min(1, '院系代码不能为空'),
adminEmail: z.string().email('邮箱格式不正确').optional().or(z.literal('')),
adminLinePhone: z.string().optional(),
adminMobilePhone: z.string().optional(),
note: z.string().optional(),
})
export const deptAdminRouter = createTRPCRouter({
list: permissionRequiredProcedure(Permissions.USER_MANAGE)
.input(dataTableQueryParamsSchema)
.query(async ({ ctx, input }) => {
const { where, orderBy, skip, take } = transformDataTableQueryParams(input, {
model: 'DeptAdmin',
columns: {
id: { field: 'id', variant: 'number', sortable: true },
uid: { field: 'uid', variant: 'text', sortable: true },
userName: { field: 'user.name', variant: 'text', sortable: true },
deptCode: { field: 'deptCode', variant: 'multiSelect', sortable: true },
adminEmail: { field: 'adminEmail', variant: 'text', sortable: true },
adminLinePhone: { field: 'adminLinePhone', variant: 'text', sortable: true },
adminMobilePhone: { field: 'adminMobilePhone', variant: 'text', sortable: true },
createdAt: { field: 'createdAt', sortable: true },
updatedAt: { field: 'updatedAt', sortable: true },
},
})
const [data, total] = await Promise.all([
ctx.db.deptAdmin.findMany({
where,
orderBy: orderBy.some(item => 'id' in item) ? orderBy : [...orderBy, { id: 'asc' }],
skip,
take,
include: {
user: { select: { id: true, name: true } },
dept: { select: { code: true, name: true, fullName: true } },
},
}),
ctx.db.deptAdmin.count({ where }),
])
return { data, total }
}),
create: permissionRequiredProcedure(Permissions.USER_MANAGE)
.input(createDeptAdminSchema)
.mutation(async ({ ctx, input }) => {
const { uid, deptCode, adminEmail, adminLinePhone, adminMobilePhone, note } = input
// 检查用户是否存在
const user = await ctx.db.user.findUnique({ where: { id: uid } })
if (!user) {
throw new TRPCError({ code: 'NOT_FOUND', message: '用户不存在' })
}
// 检查院系是否存在
const dept = await ctx.db.dept.findUnique({ where: { code: deptCode } })
if (!dept) {
throw new TRPCError({ code: 'NOT_FOUND', message: '院系不存在' })
}
// 检查是否已存在相同的用户-院系组合
const existing = await ctx.db.deptAdmin.findUnique({
where: {
uidx_uid_dept_code: {
uid,
deptCode,
},
},
})
if (existing) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '该用户已是该院系的管理员' })
}
return ctx.db.deptAdmin.create({
data: {
uid,
deptCode,
adminEmail: adminEmail?.trim() || null,
adminLinePhone: adminLinePhone?.trim() || null,
adminMobilePhone: adminMobilePhone?.trim() || null,
note: note?.trim() || null,
},
include: {
user: { select: { id: true, name: true } },
dept: { select: { code: true, name: true, fullName: true } },
},
})
}),
update: permissionRequiredProcedure(Permissions.USER_MANAGE)
.input(updateDeptAdminSchema)
.mutation(async ({ ctx, input }) => {
const { id, uid, deptCode, adminEmail, adminLinePhone, adminMobilePhone, note } = input
// 检查院系管理员是否存在
const existing = await ctx.db.deptAdmin.findUnique({ where: { id } })
if (!existing) {
throw new TRPCError({ code: 'NOT_FOUND', message: '院系管理员不存在' })
}
// 检查用户是否存在
const user = await ctx.db.user.findUnique({ where: { id: uid } })
if (!user) {
throw new TRPCError({ code: 'NOT_FOUND', message: '用户不存在' })
}
// 检查院系是否存在
const dept = await ctx.db.dept.findUnique({ where: { code: deptCode } })
if (!dept) {
throw new TRPCError({ code: 'NOT_FOUND', message: '院系不存在' })
}
// 如果修改了 uid 或 deptCode检查新的组合是否已存在
if (uid !== existing.uid || deptCode !== existing.deptCode) {
const duplicate = await ctx.db.deptAdmin.findUnique({
where: {
uidx_uid_dept_code: {
uid,
deptCode,
},
},
})
if (duplicate) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '该用户已是该院系的管理员' })
}
}
return ctx.db.deptAdmin.update({
where: { id },
data: {
uid,
deptCode,
adminEmail: adminEmail?.trim() || null,
adminLinePhone: adminLinePhone?.trim() || null,
adminMobilePhone: adminMobilePhone?.trim() || null,
note: note?.trim() || null,
},
include: {
user: { select: { id: true, name: true } },
dept: { select: { code: true, name: true, fullName: true } },
},
})
}),
getById: permissionRequiredProcedure(Permissions.USER_MANAGE)
.input(z.object({ id: z.number() }))
.query(async ({ ctx, input }) => {
const deptAdmin = await ctx.db.deptAdmin.findUnique({
where: { id: input.id },
include: {
user: { select: { id: true, name: true } },
dept: { select: { code: true, name: true, fullName: true } },
},
})
if (!deptAdmin) {
throw new TRPCError({ code: 'NOT_FOUND', message: '院系管理员不存在' })
}
return deptAdmin
}),
delete: permissionRequiredProcedure(Permissions.USER_MANAGE)
.input(z.object({ id: z.number() }))
.mutation(async ({ ctx, input }) => {
const { id } = input
// 检查院系管理员是否存在
const existing = await ctx.db.deptAdmin.findUnique({ where: { id } })
if (!existing) {
throw new TRPCError({ code: 'NOT_FOUND', message: '院系管理员不存在' })
}
return ctx.db.deptAdmin.delete({
where: { id },
include: {
user: { select: { id: true, name: true } },
dept: { select: { code: true, name: true, fullName: true } },
},
})
}),
})
export type DeptAdminRouter = typeof deptAdminRouter
export type DeptAdmin = inferProcedureOutput<DeptAdminRouter['list']>['data'][number]

View File

@@ -12,6 +12,7 @@ import {
resetToCommit,
getCurrentBranch,
hasUncommittedChanges,
pushToRemote,
} from '@/server/utils/git-helper'
@@ -224,6 +225,25 @@ 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

View File

@@ -225,26 +225,7 @@ export function createDownloadProcedure(config: DownloadConfig) {
* 提供文件上传相关的接口,使用工厂函数创建特定业务的上传接口
*/
export const uploadRouter = createTRPCRouter({
// 供给照片批量上传接口
supplyPhotos: createBatchUploadProcedure({
category: 'transfer/supply',
maxSize: 1 * 1024 * 1024, // 1MB
allowedContentType: 'image/*',
expirySeconds: 3600,
permission: Permissions.TRANSFER_SUPPLY_CREATE,
}),
supplyPdfs: createBatchUploadProcedure({
category: 'transfer/supply',
maxSize: 1 * 1024 * 1024, // 1MB
allowedContentType: 'application/pdf',
expirySeconds: 3600,
permission: Permissions.TRANSFER_SUPPLY_CREATE,
}),
// 已上传的供给照片下载
downloadSupplyPhotos: createDownloadProcedure({
expirySeconds: 3600,
permission: Permissions.TRANSFER_SUPPLY_CREATE,
})
});
export type SingleUploadProcedureInput = inferProcedureInput<ReturnType<typeof createSingleUploadProcedure>>; // createSingleUploadProcedure 创建的接口调用参数

View File

@@ -6,6 +6,11 @@ import { transformDataTableQueryParams } from '@/server/utils/data-table-helper'
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))
export const usersRouter = createTRPCRouter({
list: permissionRequiredProcedure(Permissions.USER_MANAGE)
@@ -112,7 +117,7 @@ export const usersRouter = createTRPCRouter({
throw new TRPCError({ code: 'BAD_REQUEST', message: '角色名称已被其他角色使用' })
}
return ctx.db.role.update({
const updatedRole = await ctx.db.role.update({
where: { id },
data: {
name,
@@ -125,6 +130,11 @@ export const usersRouter = createTRPCRouter({
}
}
})
// 标记持有该角色的所有用户会话失效
await invalidateSessionsByRoleId(id)
return updatedRole
}),
deleteRole: permissionRequiredProcedure(Permissions.USER_MANAGE)
@@ -186,26 +196,36 @@ export const usersRouter = createTRPCRouter({
await Promise.all(
batch.map(user =>
ctx.db.user.update({
where: { id: user.id },
data: {
roles: action === 'grant'
? { connect: { id: roleId } }
: { disconnect: { id: roleId } }
}
})
dbParallelLimit(() =>
ctx.db.user.update({
where: { id: user.id },
data: {
roles: action === 'grant'
? { connect: { id: roleId } }
: { disconnect: { id: roleId } }
}
})
)
)
)
processedCount += batch.length
}
// 标记所有受影响用户的会话失效
await invalidateUserSessions(users.map(u => u.id))
return { count: processedCount }
}),
create: permissionRequiredProcedure(Permissions.USER_MANAGE).input(createUserSchema).mutation(async ({ ctx, input }) => {
const { id, name, status, deptCode, password, roleIds, isSuperAdmin } = input
// 检查是否尝试创建超级管理员,只有超级管理员才能创建超级管理员
if (isSuperAdmin && !ctx.session?.user?.isSuperAdmin) {
throw new TRPCError({ code: 'FORBIDDEN', message: '只有超级管理员才能创建超级管理员用户' })
}
const existingUser = await ctx.db.user.findUnique({ where: { id } })
if (existingUser) throw new TRPCError({ code: 'BAD_REQUEST', message: '用户ID已存在' })
@@ -235,6 +255,11 @@ export const usersRouter = createTRPCRouter({
const existingUser = await ctx.db.user.findUnique({ where: { id } })
if (!existingUser) throw new TRPCError({ code: 'NOT_FOUND', message: '用户不存在' })
// 检查是否尝试修改 isSuperAdmin 字段,只有超级管理员才能操作
if (isSuperAdmin !== existingUser.isSuperAdmin && !ctx.session?.user?.isSuperAdmin) {
throw new TRPCError({ code: 'FORBIDDEN', message: '只有超级管理员才能修改超级管理员权限' })
}
// 准备更新数据
const updateData: any = {
name: name?.trim() || '',
@@ -249,7 +274,7 @@ export const usersRouter = createTRPCRouter({
updateData.password = await bcrypt.hash(password, 12)
}
return ctx.db.user.update({
const updatedUser = await ctx.db.user.update({
where: { id },
data: updateData,
include: {
@@ -257,6 +282,11 @@ 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 }) => {
@@ -284,6 +314,9 @@ export const usersRouter = createTRPCRouter({
throw new TRPCError({ code: 'BAD_REQUEST', message: '不能删除自己的账户' })
}
// 标记用户会话失效
await invalidateUserSessions([id])
// 删除用户
return ctx.db.user.delete({
where: { id },
@@ -372,6 +405,102 @@ export const usersRouter = createTRPCRouter({
))
).sort((a, b) => a.id - b.id),
}
}),
// 获取当前用户可管理的院系列表和正在管理的院系
getManagedDepts: permissionRequiredProcedure('')
.query(async ({ ctx }) => {
const userId = ctx.session!.user.id
// 获取用户当前信息
const currentUser = await ctx.db.user.findUnique({
where: { id: userId },
select: { currentManagedDept: true }
})
let depts: Array<{ code: string; name: string; fullName: string }>
// 超级管理员可以管理所有院系
if (ctx.session?.user?.isSuperAdmin) {
depts = await ctx.db.dept.findMany({
orderBy: { code: 'asc' }
})
} else {
// 普通用户只能管理自己被授权的院系
const deptAdmins = await ctx.db.deptAdmin.findMany({
where: { uid: userId },
include: { dept: true },
orderBy: { deptCode: 'asc' }
})
depts = deptAdmins.map(da => da.dept)
}
// 如果用户当前没有管理院系,但有可管理的院系,自动设置为第一个
let currentDept = currentUser?.currentManagedDept
if (!currentDept && depts.length > 0) {
currentDept = depts[0].code
await ctx.db.user.update({
where: { id: userId },
data: { currentManagedDept: currentDept }
})
}
return {
currentDept,
depts
}
}),
// 切换当前管理的院系
switchManagedDept: permissionRequiredProcedure('')
.input(z.object({
deptCode: z.string().nullable()
}))
.mutation(async ({ ctx, input }) => {
const { deptCode } = input
// 如果要切换到某个院系,需要验证权限
if (deptCode) {
// 超级管理员可以切换到任意院系
if (!ctx.session?.user?.isSuperAdmin) {
// 普通用户需要验证是否有该院系的管理权限
const deptAdmin = await ctx.db.deptAdmin.findUnique({
where: {
uidx_uid_dept_code: {
uid: ctx.session!.user.id,
deptCode: deptCode
}
}
})
if (!deptAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '您没有该院系的管理权限'
})
}
}
// 验证院系是否存在
const dept = await ctx.db.dept.findUnique({
where: { code: deptCode }
})
if (!dept) {
throw new TRPCError({
code: 'NOT_FOUND',
message: '院系不存在'
})
}
}
// 更新用户的当前管理院系
await ctx.db.user.update({
where: { id: ctx.session!.user.id },
data: { currentManagedDept: deptCode }
})
return { success: true, deptCode }
})
})

View File

@@ -46,7 +46,8 @@ export function startTerminalService() {
'-p', port,
'-t', 'titleFixed=开发终端',
'-t', 'fontSize=14',
'-i', '127.0.0.1',
'-c', `super_admin:${process.env.SUPER_ADMIN_PASSWORD}`,
'-i', process.env.SUPER_ADMIN_PASSWORD ? '0.0.0.0' : "127.0.0.1",
'--writable',
'tmux', 'new', '-A',
'-s', process.env.DEV_TERMINAL || 'nextdev',

View File

@@ -0,0 +1,89 @@
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
}
}

View File

@@ -0,0 +1,39 @@
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))
}

View File

@@ -632,3 +632,19 @@ 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)}`)
}
}

View File

@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -19,9 +23,19 @@
}
],
"paths": {
"@/*": ["./src/*"]
"@/*": [
"./src/*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"next-env.d.ts",
".next-prod/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}