Compare commits

..
14 Commits
Author SHA1 Message Date
miaomiao 32f258aec2 feat: 量表库分享(内置量表 /s/:id 链接 + 卡片分享按钮,剪贴板失败降级 Modal) 2026-08-30 00:32:58 +08:00
miaomiao d46d131404 fix: 水印缩放卡死(MutationObserver 无限循环——仅在删空时重建 + resize 200ms 防抖) 2026-08-30 00:18:26 +08:00
miaomiao 29d5f844a8 docs: Rainnya 系列项目统一软件授权协议(LICENSE) 2026-08-29 22:07:42 +08:00
miaomiao 8f65677408 feat: 内置 SVG 本地验证码(captcha_type 切换,替代/并存 CF Turnstile)+ proof 一次性机制 + 前端双模式 2026-08-29 21:59:26 +08:00
miaomiao bd240551a8 fix: v2 编辑器因子一致性警告误报(MMPI 多因子归属不再误判) 2026-08-12 17:52:53 +08:00
miaomiao 7f0e086888 feat: v2 可视化量表编辑器(卡片流+虚拟列表+拖拽+因子分组+批量+核对视图+撤销重做+草稿)+ ora P1 修复(MMPI items 因子迁移/展开区击键/双编辑器冲突保护) 2026-08-12 17:27:08 +08:00
miaomiao 189f63af91 fix: 水印 shadow root 复用(user 重渲染时二次 attachShadow 抛错) 2026-08-12 15:05:59 +08:00
miaomiao 37a427ea90 fix: 水印占位符空值不再原样显示(hasOwnProperty 区分缺失/空值)+ user 加载完成后重渲染 2026-08-12 15:01:11 +08:00
miaomiao 4cd1738b18 fix: RainID 启用态统一判定(rainid_configured fail-closed 布尔 + 前端 Tag/按钮/半配置警示) 2026-08-12 14:53:19 +08:00
miaomiao 791187a127 fix: 设置保存 500(node:sqlite 布尔绑定抛错)— setSetting 强制转字符串 + 前端剔除派生 configured 布尔 2026-08-12 04:23:29 +08:00
miaomiao 9dcee35427 fix: antd 生态合并单 chunk 修复白屏(maxSize 拆分切断循环依赖导致 ESC undefined) 2026-08-12 04:00:59 +08:00
miaomiao 08f5b7be8f feat: 默认端口改 3501 + dotenv 支持(.env 可配 PORT/CORS/JWT)+ .env.example 文档 2026-08-12 03:55:20 +08:00
miaomiao 8a6578d5ab chore: release/ 目录加入 gitignore(打包产物存放位置) 2026-08-12 03:48:46 +08:00
miaomiao e38a5a04ce feat: 免配置部署(密钥自动持久化 + postinstall 自动构建)+ RainID 管理端完备性 2026-08-12 03:12:47 +08:00
49 changed files with 2894 additions and 252 deletions
+15
View File
@@ -0,0 +1,15 @@
# psytest-engine 环境变量(可选,默认值开箱即用)
# 复制为 .env 修改后生效(.env 已被 gitignore,不会入库)
# 服务端口(默认 3501
PORT=3501
# CORS 白名单(逗号分隔的域名,跨域部署时才需要;同源部署留空即可)
# CORS_ORIGIN=https://a.example.com,https://b.example.com
# 会话密钥(不设会自动生成并持久化到 data/.secrets.json,重启不失效)
# JWT_SECRET=
# VERIFY_SALT=
# RainID client secret(也可在管理后台设置,此处仅作覆盖用)
# RAINID_CLIENT_SECRET=
+1
View File
@@ -1,6 +1,7 @@
node_modules/ node_modules/
client/node_modules/ client/node_modules/
data/ data/
release/
.scales/ .scales/
scales/.index.json.bak scales/.index.json.bak
.DS_Store .DS_Store
+51
View File
@@ -0,0 +1,51 @@
# RainWeb 软件授权协议(定制版)
**版本 1.0 · 2026-08-12**
**原作者(Initial Author):XianyunahRainnya 家族)**
## 一、授权范围
本协议授予任何获得本软件副本(含源码、文档、构建产物)的个人或组织(下称"被授权人")以下权利:
1. **使用**:允许安装、运行、复制本软件,用于个人学习、研究、内部开发。
2. **修改**:允许对本软件源码进行修改、扩展、二次开发。
3. **分发**:允许分发本软件的原始版本或修改后的衍生版本。
## 二、禁止事项
被授权人**不得**
1. **商业使用**:不得将本软件或其衍生版本用于任何商业目的,包括但不限于:
- 直接销售本软件或其衍生版本
- 提供收费的托管服务(SaaS/PaaS)
- 将本软件作为商业产品的组成部分
- 通过本软件获取直接或间接的商业收益
2. **闭源**:本软件及其衍生版本必须保持源代码公开可用,不得以任何形式闭源或隐藏源代码。
3. **使用品牌**:不得使用「Rainnya」「雨喵」「RainWeb」名称、logo、视觉标识(含紫色雨夜视觉主题)对本软件的衍生版本进行品牌宣传、背书或暗示官方身份,除非获得原作者书面授权。
## 三、义务条款(Copyleft
1. **衍生版本必须开源**:任何基于本软件的修改、扩展、整合版本,其源代码必须遵循本协议开源发布,且必须包含本 LICENSE 文件。
2. **同协议授权**:衍生版本必须使用与本协议相同或更严格(限制更多)的授权条款。
3. **署名**:任何二次分发(原始或衍生版本)必须保留本 LICENSE 文件,并显著标明:
- 最初作者:**XianyunahRainnya 家族)**
- 原始项目名称与原始仓库地址
- 若为修改版,须明确标注"基于 RainWeb 修改"并说明修改内容
## 四、免责声明
本软件按"现状"(AS IS)提供,不附带任何明示或暗示的担保,包括但不限于适销性、特定用途适用性与非侵权保证。原作者对使用本软件产生的任何损害概不负责,无论基于何种法律理论(合同、侵权或其他)。
## 五、协议终止
被授权人违反本协议任何条款,其本协议项下的所有权利自动终止,且须立即停止使用、分发并销毁全部副本。
## 六、其他
- 本协议与 GPL-3.0 的精神一致(copyleft),但额外附加了非商业与品牌保留条款,属"源代码可用(source-available"授权,**非 OSI 认证的开源协议**。
- 若本协议任一条款被判定无效,不影响其余条款效力。
- 授权事宜咨询:rainnya.asia
---
**版权 © 2024 XianyunahRainnya 家族)· 保留所有权利**
+86 -5
View File
@@ -1,14 +1,17 @@
{ {
"name": "client-temp", "name": "psytest-engine-client",
"version": "0.0.0", "version": "0.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "client-temp", "name": "psytest-engine-client",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.3.2", "@ant-design/icons": "^6.3.2",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@tanstack/react-virtual": "^3.14.9",
"antd": "^6.5.0", "antd": "^6.5.0",
"axios": "^1.18.1", "axios": "^1.18.1",
"dayjs": "^1.11.21", "dayjs": "^1.11.21",
@@ -115,6 +118,59 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.11.3", "version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
@@ -842,6 +898,33 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tanstack/react-virtual": {
"version": "3.14.9",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.9.tgz",
"integrity": "sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.17.7"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.17.7",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.7.tgz",
"integrity": "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.3", "version": "0.10.3",
"dev": true, "dev": true,
@@ -2455,9 +2538,7 @@
}, },
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"dev": true, "license": "0BSD"
"license": "0BSD",
"optional": true
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "6.0.3", "version": "6.0.3",
+3
View File
@@ -11,6 +11,9 @@
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.3.2", "@ant-design/icons": "^6.3.2",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@tanstack/react-virtual": "^3.14.9",
"antd": "^6.5.0", "antd": "^6.5.0",
"axios": "^1.18.1", "axios": "^1.18.1",
"dayjs": "^1.11.21", "dayjs": "^1.11.21",
+2
View File
@@ -19,6 +19,7 @@ import ScaleManagement from "./pages/admin/ScaleManagement";
import RecordManagement from "./pages/admin/RecordManagement"; import RecordManagement from "./pages/admin/RecordManagement";
import AdminSettings from "./pages/admin/AdminSettings"; import AdminSettings from "./pages/admin/AdminSettings";
import ScaleEditor from "./pages/admin/ScaleEditor"; import ScaleEditor from "./pages/admin/ScaleEditor";
import ScaleEditorV2 from "./pages/admin/ScaleEditorV2";
import DataCollection from "./pages/admin/DataCollection"; import DataCollection from "./pages/admin/DataCollection";
import ScaleReviews from "./pages/admin/ScaleReviews"; import ScaleReviews from "./pages/admin/ScaleReviews";
import AdminUserScales from "./pages/admin/AdminUserScales"; import AdminUserScales from "./pages/admin/AdminUserScales";
@@ -79,6 +80,7 @@ const App: React.FC = () => (
<Route path="/admin/settings" element={<ProtectedRoute requiredPermission="settings.view"><AdminSettings /></ProtectedRoute>} /> <Route path="/admin/settings" element={<ProtectedRoute requiredPermission="settings.view"><AdminSettings /></ProtectedRoute>} />
<Route path="/admin/data" element={<ProtectedRoute requiredPermission="samples.view"><DataCollection /></ProtectedRoute>} /> <Route path="/admin/data" element={<ProtectedRoute requiredPermission="samples.view"><DataCollection /></ProtectedRoute>} />
<Route path="/admin/scale-editor" element={<ProtectedRoute requiredPermission="scales.manage"><ScaleEditor /></ProtectedRoute>} /> <Route path="/admin/scale-editor" element={<ProtectedRoute requiredPermission="scales.manage"><ScaleEditor /></ProtectedRoute>} />
<Route path="/admin/scale-editor-v2" element={<ProtectedRoute requiredPermission="scales.manage"><ScaleEditorV2 /></ProtectedRoute>} />
<Route path="/admin/scale-editor/preview" element={<ProtectedRoute requiredPermission="scales.manage"><ScalePreview /></ProtectedRoute>} /> <Route path="/admin/scale-editor/preview" element={<ProtectedRoute requiredPermission="scales.manage"><ScalePreview /></ProtectedRoute>} />
</Route> </Route>
</Routes> </Routes>
+26 -6
View File
@@ -27,9 +27,11 @@ api.interceptors.response.use(
// Auth // Auth
export const authApi = { export const authApi = {
login: (username: string, password: string, turnstileToken?: string) => api.post("/auth/login", { username, password, turnstileToken }).then((r) => r.data), login: (username: string, password: string, turnstileToken?: string, captchaProof?: string) =>
api.post("/auth/login", { username, password, ...(turnstileToken ? { turnstileToken } : {}), ...(captchaProof ? { captcha_proof: captchaProof } : {}) }).then((r) => r.data),
sendCode: (email: string) => api.post("/auth/send-code", { email }).then((r) => r.data), sendCode: (email: string) => api.post("/auth/send-code", { email }).then((r) => r.data),
register: (name: string, email: string, code: string, password: string, turnstileToken?: string) => api.post("/auth/register", { name, email, code, password, turnstileToken }).then((r) => r.data), register: (name: string, email: string, code: string, password: string, turnstileToken?: string, captchaProof?: string) =>
api.post("/auth/register", { name, email, code, password, ...(turnstileToken ? { turnstileToken } : {}), ...(captchaProof ? { captcha_proof: captchaProof } : {}) }).then((r) => r.data),
me: () => api.get("/auth/me").then((r) => r.data), me: () => api.get("/auth/me").then((r) => r.data),
oidcExchange: (ticket: string) => api.post("/auth/oidc/exchange", { ticket }).then((r) => r.data), oidcExchange: (ticket: string) => api.post("/auth/oidc/exchange", { ticket }).then((r) => r.data),
// 游客通道:POST /api/auth/guest -> { success, data: { token, user: { isGuest: true } } }guest JWT 2h // 游客通道:POST /api/auth/guest -> { success, data: { token, user: { isGuest: true } } }guest JWT 2h
@@ -42,9 +44,24 @@ export const settingsApi = {
}; };
// Captcha // Captcha
export interface CaptchaStatus {
siteKey: string;
actions: Record<string, boolean>;
type?: "turnstile" | "builtin" | "none" | "both";
builtin?: boolean;
}
export interface BuiltinCaptchaData {
token: string;
svg: string;
}
export const captchaApi = { export const captchaApi = {
status: () => api.get("/captcha/status").then((r) => r.data.data), status: () => api.get<ApiResponse<CaptchaStatus>>("/captcha/status").then((r) => r.data.data),
verify: (token: string) => api.post("/captcha/verify", { token }).then((r) => r.data), verify: (token: string) => api.post("/captcha/verify", { token }).then((r) => r.data),
image: () => api.get<ApiResponse<BuiltinCaptchaData>>("/captcha/image").then((r) => r.data.data),
verifyBuiltin: (token: string, answer: string) =>
api.post("/captcha/verify", { token, answer }).then((r) => r.data.data as { proof: string }),
}; };
// Scales // Scales
@@ -74,12 +91,15 @@ export const aiApi = {
export const userScaleApi = { export const userScaleApi = {
list: (status?: string) => api.get("/user-scales", { params: status ? { status } : {} }).then((r) => r.data.data), list: (status?: string) => api.get("/user-scales", { params: status ? { status } : {} }).then((r) => r.data.data),
get: (id: string) => api.get(`/user-scales/${id}`).then((r) => r.data.data), get: (id: string) => api.get(`/user-scales/${id}`).then((r) => r.data.data),
create: (id: string, content: any, turnstileToken?: string) => api.post("/user-scales", { id, content, turnstileToken }).then((r) => r.data.data), create: (id: string, content: any, turnstileToken?: string, captchaProof?: string) =>
api.post("/user-scales", { id, content, ...(turnstileToken ? { turnstileToken } : {}), ...(captchaProof ? { captcha_proof: captchaProof } : {}) }).then((r) => r.data.data),
update: (id: string, content: any) => api.put(`/user-scales/${id}`, { content }), update: (id: string, content: any) => api.put(`/user-scales/${id}`, { content }),
delete: (id: string) => api.delete(`/user-scales/${id}`), delete: (id: string) => api.delete(`/user-scales/${id}`),
share: (id: string, turnstileToken?: string) => api.post(`/user-scales/${id}/share`, { turnstileToken }).then((r) => r.data.data), share: (id: string, turnstileToken?: string, captchaProof?: string) =>
api.post(`/user-scales/${id}/share`, { ...(turnstileToken ? { turnstileToken } : {}), ...(captchaProof ? { captcha_proof: captchaProof } : {}) }).then((r) => r.data.data),
unshare: (id: string) => api.post(`/user-scales/${id}/unshare`).then((r) => r.data), unshare: (id: string) => api.post(`/user-scales/${id}/unshare`).then((r) => r.data),
submitReview: (id: string, turnstileToken?: string) => api.post(`/user-scales/${id}/submit-review`, { turnstileToken }).then((r) => r.data), submitReview: (id: string, turnstileToken?: string, captchaProof?: string) =>
api.post(`/user-scales/${id}/submit-review`, { ...(turnstileToken ? { turnstileToken } : {}), ...(captchaProof ? { captcha_proof: captchaProof } : {}) }).then((r) => r.data),
getByShareCode: (code: string) => api.get(`/s/${code}`).then((r) => r.data.data), getByShareCode: (code: string) => api.get(`/s/${code}`).then((r) => r.data.data),
}; };
+246
View File
@@ -0,0 +1,246 @@
import React, { useEffect, useRef, useState } from "react";
import { Modal, Input, Button, Spin, message } from "antd";
import { ReloadOutlined, CheckCircleOutlined } from "@ant-design/icons";
import { captchaApi } from "../api";
declare global {
interface Window { turnstile?: { render: (el: string | HTMLElement, opts: any) => string; getResponse: (wid: string) => string; reset: (wid: string) => void }; }
}
export type CaptchaMode = "turnstile" | "builtin";
export interface CaptchaModalProps {
open: boolean;
mode: CaptchaMode;
siteKey?: string;
onOk: (proof: string) => void;
onCancel: () => void;
}
/**
* 通用人机验证弹窗:
* - turnstile 模式:Cloudflare Turnstile widget
* - builtin 模式:内置 SVG 验证码(image + 输入 + 验证 → 一次性 proof)
* onOk(proof)turnstile 为 widget tokenbuiltin 为 verifyBuiltin 返回的 JWT proof
*/
const CaptchaModal: React.FC<CaptchaModalProps> = ({ open, mode, siteKey, onOk, onCancel }) => {
const [svg, setSvg] = useState("");
const [bToken, setBToken] = useState("");
const [answer, setAnswer] = useState("");
const [verifying, setVerifying] = useState(false);
const [verified, setVerified] = useState(false);
const proofRef = useRef("");
const widgetRoot = useRef<HTMLDivElement>(null);
const widRef = useRef<string | null>(null);
const loadImage = async () => {
try {
const data = await captchaApi.image();
setBToken(data.token);
setSvg(data.svg);
setVerified(false);
proofRef.current = "";
} catch {
message.error("验证码获取失败,请重试");
}
};
// builtin 模式:打开时拉取验证码图片
useEffect(() => {
if (open && mode === "builtin") {
setSvg(""); setBToken(""); setAnswer(""); setVerified(false); proofRef.current = "";
loadImage();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, mode]);
// turnstile 模式:打开时渲染 widget(等待 turnstile 脚本就绪)
useEffect(() => {
if (!open || mode !== "turnstile" || !siteKey) return;
let tries = 0;
const timer = setInterval(() => {
const ts = window.turnstile;
if (ts) {
clearInterval(timer);
setTimeout(() => {
const el = widgetRoot.current;
if (el && !el.children.length) {
widRef.current = ts.render(el, { sitekey: siteKey, theme: "light" });
}
}, 100);
} else if (++tries > 30) {
clearInterval(timer);
}
}, 200);
return () => clearInterval(timer);
}, [open, mode, siteKey]);
const handleVerifyBuiltin = async () => {
if (!bToken) { message.warning("验证码尚未加载,请重试"); return; }
if (!answer) { message.warning("请输入图中字符"); return; }
setVerifying(true);
try {
const data = await captchaApi.verifyBuiltin(bToken, answer);
proofRef.current = data.proof;
setVerified(true);
message.success("验证通过");
} catch (e: any) {
message.error(e.response?.data?.error?.message || "验证失败,请重试");
// token 一次性:失败后重拉验证码
setAnswer("");
await loadImage();
} finally {
setVerifying(false);
}
};
const handleOk = () => {
if (mode === "builtin") {
if (!verified || !proofRef.current) { message.warning("请先完成验证"); return; }
onOk(proofRef.current);
return;
}
let token = "";
try {
const ts = window.turnstile;
token = ts && widRef.current ? ts.getResponse(widRef.current) || "" : "";
} catch { /* ignore */ }
if (!token) { message.warning("请完成人机验证"); return; }
onOk(token);
};
const handleCancel = () => {
try { if (window.turnstile && widRef.current) window.turnstile.reset(widRef.current); } catch { /* ignore */ }
onCancel();
};
return (
<Modal title="人机验证" open={open} onOk={handleOk} onCancel={handleCancel}
okText="确认" width={380} destroyOnClose>
{mode === "turnstile" ? (
<>
<p style={{ textAlign: "center", marginBottom: 16 }}></p>
<div ref={widgetRoot} style={{ display: "flex", justifyContent: "center", minHeight: 65 }} />
</>
) : (
<>
<p style={{ textAlign: "center", marginBottom: 12 }}></p>
<div style={{ textAlign: "center", marginBottom: 12, minHeight: 70 }}>
{svg ? (
<img
src={`data:image/svg+xml;utf8,${encodeURIComponent(svg)}`}
alt="验证码"
title="点击更换"
onClick={loadImage}
style={{ maxWidth: "100%", maxHeight: 84, borderRadius: 8, background: "#fff", border: "1px solid #eee", cursor: "pointer" }}
/>
) : (
<Spin />
)}
</div>
<Input
placeholder="输入图中字符"
value={answer}
disabled={verifying}
onChange={(e) => setAnswer(e.target.value)}
onPressEnter={handleVerifyBuiltin}
/>
<div style={{ marginTop: 12, display: "flex", justifyContent: "space-between" }}>
<Button size="small" icon={<ReloadOutlined />} onClick={loadImage}></Button>
<Button type="primary" size="small" loading={verifying} onClick={handleVerifyBuiltin}>
{verified ? "已验证" : "验证"}
</Button>
</div>
{verified && (
<div style={{ marginTop: 12, textAlign: "center", color: "#15803d" }}>
<CheckCircleOutlined />
</div>
)}
</>
)}
</Modal>
);
};
export default CaptchaModal;
export interface BuiltinCaptchaInlineProps {
proof: string;
onProofChange: (proof: string) => void;
}
/**
* 内嵌式内置验证码(用于表单弹窗内联):自动拉图 + 输入 + 验证
* 验证成功后通过 onProofChange 上报一次性 prooftoken 一次性,失败自动重拉图。
*/
export const BuiltinCaptchaInline: React.FC<BuiltinCaptchaInlineProps> = ({ proof, onProofChange }) => {
const [svg, setSvg] = useState("");
const [bToken, setBToken] = useState("");
const [answer, setAnswer] = useState("");
const [verifying, setVerifying] = useState(false);
const loadImage = async () => {
try {
const data = await captchaApi.image();
setBToken(data.token);
setSvg(data.svg);
setAnswer("");
onProofChange("");
} catch {
message.error("验证码获取失败,请重试");
}
};
useEffect(() => { loadImage(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, []);
const handleVerify = async () => {
if (!bToken) { message.warning("验证码尚未加载,请重试"); return; }
if (!answer) { message.warning("请输入图中字符"); return; }
setVerifying(true);
try {
const data = await captchaApi.verifyBuiltin(bToken, answer);
onProofChange(data.proof);
message.success("验证通过");
} catch (e: any) {
message.error(e.response?.data?.error?.message || "验证失败,请重试");
await loadImage();
} finally {
setVerifying(false);
}
};
return (
<div>
<div style={{ textAlign: "center", marginBottom: 8, minHeight: 60 }}>
{svg ? (
<img
src={`data:image/svg+xml;utf8,${encodeURIComponent(svg)}`}
alt="验证码"
title="点击更换"
onClick={loadImage}
style={{ maxWidth: "100%", maxHeight: 80, borderRadius: 8, background: "#fff", border: "1px solid #eee", cursor: "pointer" }}
/>
) : (
<Spin />
)}
</div>
<div style={{ display: "flex", gap: 8 }}>
<Input
placeholder="输入图中字符"
value={answer}
disabled={verifying}
onChange={(e) => setAnswer(e.target.value)}
onPressEnter={handleVerify}
/>
<Button type="primary" loading={verifying} onClick={handleVerify}
style={proof ? { background: "#15803d", borderColor: "#15803d" } : undefined}>
{proof ? "已验证" : "验证"}
</Button>
</div>
<div style={{ marginTop: 8, fontSize: 12, color: "#999", display: "flex", justifyContent: "space-between" }}>
<span></span>
<a onClick={loadImage}></a>
</div>
</div>
);
};
+105
View File
@@ -0,0 +1,105 @@
import React, { useState } from "react";
import { Button, Select, Switch, Typography, Popconfirm } from "antd";
import { DeleteOutlined, CopyOutlined } from "@ant-design/icons";
const { Text } = Typography;
/** 视觉隐藏但保留在可访问性树中的播报区域 */
const srOnly: React.CSSProperties = {
position: "absolute",
width: 1,
height: 1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
};
export interface QuestionBatchBarProps {
selectedCount: number;
totalCount: number;
allFactors: string[];
onSelectAll: () => void;
onClear: () => void;
onSetFactor: (factor: string | undefined) => void;
onSetReverse: (reverse: boolean) => void;
onDelete: () => void;
onDuplicate: () => void;
}
/** 批量操作栏:全选/清空 + 批量设因子/反向 + 批量删除/复制;操作结果通过 aria-live 播报 */
const QuestionBatchBar: React.FC<QuestionBatchBarProps> = ({
selectedCount, totalCount, allFactors,
onSelectAll, onClear, onSetFactor, onSetReverse, onDelete, onDuplicate,
}) => {
const hasSel = selectedCount > 0;
const [announce, setAnnounce] = useState("");
const notify = (msg: string) => setAnnounce(msg);
const runSelectAll = () => { onSelectAll(); notify(`已全选 ${totalCount}`); };
const runClear = () => { onClear(); notify("已清空选择"); };
const runSetFactor = (f: string | undefined) => { onSetFactor(f); notify(f ? `已为 ${selectedCount} 题设置因子「${f}` : `已为 ${selectedCount} 题清除因子`); };
const runSetReverse = (v: boolean) => { onSetReverse(v); notify(`已为 ${selectedCount}${v ? '开启' : '关闭'}反向计分`); };
const runDelete = () => { onDelete(); notify(`已删除 ${selectedCount}`); };
const runDuplicate = () => { onDuplicate(); notify(`已复制 ${selectedCount}`); };
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
flexWrap: "wrap",
padding: "6px 12px",
marginBottom: 8,
background: hasSel ? "#eef4fb" : "#f8fafc",
border: hasSel ? "1px solid #cfe0f2" : "1px solid #eef2f7",
borderRadius: 10,
}}
>
<span role="status" aria-live="polite" style={srOnly}>{announce}</span>
<Text strong style={{ color: "#1e3a5f", fontSize: 13 }}>
{hasSel ? `已选 ${selectedCount}` : "批量操作"}
</Text>
<Button size="small" onClick={runSelectAll} disabled={totalCount === 0}></Button>
<Button size="small" onClick={runClear} disabled={!hasSel}></Button>
<span style={{ borderLeft: "1px solid #e2e8f0", height: 18 }} />
<span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
<Select
size="small"
style={{ width: 120 }}
placeholder="批量设因子"
allowClear
disabled={!hasSel}
onChange={runSetFactor}
options={allFactors.map((f) => ({ value: f, label: f }))}
/>
</span>
<span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
<Switch size="small" disabled={!hasSel} onChange={runSetReverse} />
</span>
<span style={{ borderLeft: "1px solid #e2e8f0", height: 18 }} />
<Popconfirm
title={`删除选中的 ${selectedCount} 题?`}
okText="删除"
cancelText="取消"
onConfirm={runDelete}
disabled={!hasSel}
>
<Button size="small" danger icon={<DeleteOutlined />} disabled={!hasSel}></Button>
</Popconfirm>
<Popconfirm
title="复制选中的题目?复制题将追加到列表尾部,ID 取 maxId+1"
okText="复制"
cancelText="取消"
onConfirm={runDuplicate}
disabled={!hasSel}
>
<Button size="small" icon={<CopyOutlined />} disabled={!hasSel}></Button>
</Popconfirm>
</div>
);
};
export default QuestionBatchBar;
+220
View File
@@ -0,0 +1,220 @@
import React, { useState, useEffect, useRef } from "react";
import { Card, Button, Space, Typography, Tag, Input, Popconfirm, Checkbox } from "antd";
import {
HolderOutlined, DownOutlined, CopyOutlined, DeleteOutlined,
ArrowUpOutlined, ArrowDownOutlined,
} from "@ant-design/icons";
import { softTag } from "./ResultUtils";
import QuestionCardExpanded from "./QuestionCardExpanded";
import type { Question, ResponseOption } from "../types";
const { Text } = Typography;
const MONO = "SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace";
export interface QuestionCardProps {
question: Question;
/** 数组序号(≠题目 ID),用于上移/下移与提交定位 */
index: number;
isLast?: boolean;
allFactors: string[];
defaultOptions?: ResponseOption[];
weightMode?: boolean;
/** dnd-kit 拖柄属性(listeners/attributes),由 QuestionList 传入;为空时禁用拖拽 */
dragHandleProps?: Record<string, unknown>;
onCommit: (patch: Partial<Question>) => void;
onDelete: () => void;
onDuplicate: () => void;
onMove: (dir: -1 | 1) => void;
expanded: boolean;
onToggleExpand: () => void;
/** 批量勾选(可选;由 QuestionList 传入,拖拽期间不渲染) */
selected?: boolean;
onToggleSelect?: () => void;
}
/** 题干:本地 draft + 防抖提交,击键只重渲染本卡 */
const QuestionCard: React.FC<QuestionCardProps> = ({
question, index, isLast, allFactors, defaultOptions, weightMode,
dragHandleProps, onCommit, onDelete, onDuplicate, onMove,
expanded, onToggleExpand, selected, onToggleSelect,
}) => {
const [editingText, setEditingText] = useState(false);
const [textDraft, setTextDraft] = useState(question.text);
const [hovered, setHovered] = useState(false);
const [handleHover, setHandleHover] = useState(false);
const textTimer = useRef<number | null>(null);
const textPending = useRef<string | null>(null);
// 用 ref 持有最新 onCommit,避免父级重渲染导致 cleanup 提前提交草稿
const onCommitRef = useRef(onCommit);
onCommitRef.current = onCommit;
// 外部重置(父级提交后)同步;键入期间父级 question.text 不变,不会打断输入
useEffect(() => {
setTextDraft(question.text);
}, [question.text]);
// 卸载时落盘未提交的题干(虚拟列表滚动出视口前触发)
useEffect(() => () => {
if (textTimer.current) window.clearTimeout(textTimer.current);
if (textPending.current !== null) {
onCommitRef.current({ text: textPending.current });
textPending.current = null;
}
}, []);
const startEdit = () => {
setEditingText(true);
};
const onTextChange = (v: string) => {
setTextDraft(v);
textPending.current = v;
if (textTimer.current) window.clearTimeout(textTimer.current);
textTimer.current = window.setTimeout(() => {
const t = textPending.current;
textPending.current = null;
if (t !== null) onCommitRef.current({ text: t });
}, 500);
};
const finishEdit = () => {
if (textTimer.current) { window.clearTimeout(textTimer.current); textTimer.current = null; }
const t = textPending.current;
textPending.current = null;
if (t !== null) onCommitRef.current({ text: t });
setEditingText(false);
};
const optionPreview = question.options && question.options.length > 0
? question.options.map((o) => o.label).join(" ")
: (defaultOptions || []).slice(0, 4).map((o) => `${o.label}`).join(" ");
const draggable = !!dragHandleProps;
return (
<Card
size="small"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
borderRadius: 12,
border: hovered ? "1px solid #d7e0ec" : "1px solid #eef2f7",
boxShadow: hovered ? "0 4px 12px rgba(30,58,95,0.08)" : "0 1px 2px rgba(15,23,42,0.04)",
}}
styles={{ body: { padding: "12px 16px" } }}
>
<div style={{ display: "flex", alignItems: "flex-start", gap: 6 }}>
{/* 拖柄(KeyboardSensor 启用后聚焦即可 Space/方向键排序) */}
<div
{...(dragHandleProps || {})}
className={draggable ? "pt-focusable" : undefined}
aria-label="拖动排序(聚焦后按空格或方向键移动)"
onMouseEnter={() => setHandleHover(true)}
onMouseLeave={() => setHandleHover(false)}
style={{
cursor: draggable ? "grab" : "default",
color: handleHover ? "#1e3a5f" : "#94a3b8",
background: handleHover ? "rgba(30,58,95,0.08)" : "transparent",
borderRadius: 6,
padding: "6px 6px 4px",
marginLeft: -6,
fontSize: 16,
lineHeight: 1,
touchAction: "none",
}}
>
<HolderOutlined />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
{/* 头部:勾选 + 题号 + 徽标 + 操作 */}
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
{onToggleSelect && (
<Checkbox
checked={!!selected}
onChange={onToggleSelect}
aria-label={`选择题目 #${question.id}`}
style={{ marginRight: -4 }}
/>
)}
<Text strong style={{ fontSize: 13, color: "#1e3a5f", fontFamily: MONO, letterSpacing: 0.3 }}>#{question.id}</Text>
{question.factor && <Tag style={{ ...softTag("#1e3a5f"), marginInlineEnd: 0 }}>{question.factor}</Tag>}
{question.reverse && <Tag style={{ ...softTag("#b45309"), marginInlineEnd: 0 }}></Tag>}
{question.keyed && <Tag style={{ ...softTag("#475569"), marginInlineEnd: 0 }}>{question.keyed}</Tag>}
<div style={{ flex: 1 }} />
<Space size={0}>
<Button type="text" aria-label={expanded ? "收起题目详情" : "展开题目详情"} icon={<DownOutlined rotate={expanded ? 180 : 0} />} onClick={onToggleExpand} />
<Button type="text" aria-label="复制题目" icon={<CopyOutlined />} onClick={onDuplicate} />
<Popconfirm title="确定删除该题?" onConfirm={onDelete} okText="删除" cancelText="取消">
<Button type="text" danger aria-label="删除题目" icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
</div>
{/* 题干:内联编辑 */}
{editingText ? (
<Input.TextArea
autoSize
autoFocus
size="small"
value={textDraft}
onChange={(e) => onTextChange(e.target.value)}
onBlur={finishEdit}
onPressEnter={(e) => { e.preventDefault(); finishEdit(); }}
placeholder="请输入题干…"
style={{ marginBottom: 4 }}
/>
) : (
<div
onClick={startEdit}
role="button"
tabIndex={0}
className="pt-focusable"
aria-label="编辑题干"
onKeyDown={(e) => { if (e.key === 'Enter') startEdit(); }}
style={{
cursor: "text",
fontSize: 14,
lineHeight: 1.6,
color: question.text ? "#1f2937" : "#64748b",
padding: "2px 0",
}}
>
{question.text || "点击输入题干…"}
</div>
)}
{/* 选项预览 */}
{!expanded && optionPreview && (
<div style={{ color: "#475569", fontSize: 12, marginTop: 4, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{optionPreview}
</div>
)}
{/* 展开区 */}
{expanded && (
<QuestionCardExpanded
question={question}
allFactors={allFactors}
weightMode={weightMode}
onCommit={onCommit}
/>
)}
{/* 底部操作条 */}
<div style={{ display: "flex", gap: 2, marginTop: 8, borderTop: "1px solid #f1f5f9", paddingTop: 6 }}>
<Button type="text" style={{ color: "#475569" }} onClick={onDuplicate}></Button>
<Popconfirm title="确定删除该题?" onConfirm={onDelete} okText="删除" cancelText="取消">
<Button type="text" danger></Button>
</Popconfirm>
<Button type="text" icon={<ArrowUpOutlined />} disabled={index === 0} onClick={() => onMove(-1)}></Button>
<Button type="text" icon={<ArrowDownOutlined />} disabled={isLast} onClick={() => onMove(1)}></Button>
</div>
</div>
</div>
</Card>
);
};
export default QuestionCard;
@@ -0,0 +1,202 @@
import React, { useState, useEffect, useRef } from "react";
import { Button, Input, InputNumber, Select, Switch, Typography } from "antd";
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons";
import type { Question, QuestionOption } from "../types";
const { Text } = Typography;
interface Props {
question: Question;
allFactors: string[];
weightMode?: boolean;
onCommit: (patch: Partial<Question>) => void;
}
/** 展开区:因子/反向/权重 + 专属选项 + 条件字段;本地 draft + 防抖提交 */
const QuestionCardExpanded: React.FC<Props> = ({ question, allFactors, weightMode, onCommit }) => {
const [optRows, setOptRows] = useState<QuestionOption[]>(question.options || []);
// P1-2:字符串/数组字段用本地 draft,避免父级重渲染时受控值回弹丢击键
const [partDraft, setPartDraft] = useState(question.part || "");
const [subscaleDraft, setSubscaleDraft] = useState(question.subscale || "");
const [dimensionDraft, setDimensionDraft] = useState(question.dimension || "");
const [scoreMapDraft, setScoreMapDraft] = useState(question.scoreMap ? question.scoreMap.join(", ") : "");
const pending = useRef<Partial<Question> | null>(null);
const timer = useRef<number | null>(null);
const onCommitRef = useRef(onCommit);
onCommitRef.current = onCommit;
const scheduleCommit = (patch: Partial<Question>) => {
pending.current = { ...pending.current, ...patch };
if (timer.current) window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => {
const p = pending.current;
pending.current = null;
if (p && Object.keys(p).length > 0) onCommitRef.current(p);
}, 500);
};
const flushCommit = () => {
if (timer.current) { window.clearTimeout(timer.current); timer.current = null; }
const p = pending.current;
pending.current = null;
if (p && Object.keys(p).length > 0) onCommitRef.current(p);
};
// 卸载时把未提交的草稿落盘(虚拟列表滚动出视口前触发)
useEffect(() => () => flushCommit(), []);
// 父级提交后同步(提交内容与本地一致,无副作用)
useEffect(() => {
setOptRows(question.options || []);
}, [question.options]);
useEffect(() => { setPartDraft(question.part || ""); }, [question.part]);
useEffect(() => { setSubscaleDraft(question.subscale || ""); }, [question.subscale]);
useEffect(() => { setDimensionDraft(question.dimension || ""); }, [question.dimension]);
useEffect(() => {
// scoreMap:仅在当前草稿可解析且与 props 不一致时才回填;输入非法时保留草稿
if (!question.scoreMap) { setScoreMapDraft(""); return; }
const parsed = parseScoreMap(scoreMapDraft);
if (parsed === null) return;
if (JSON.stringify(parsed) !== JSON.stringify(question.scoreMap)) {
setScoreMapDraft(question.scoreMap.join(", "));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [question.scoreMap]);
const parseScoreMap = (v: string): number[] | null => {
const tokens = v.split(",").map((s) => s.trim()).filter(Boolean);
if (tokens.length === 0) return [];
if (!tokens.every((t) => !isNaN(Number(t)))) return null;
return tokens.map(Number);
};
const updateOpt = (idx: number, field: keyof QuestionOption, value: string | number | undefined) => {
const copy = optRows.map((o, i) => (i === idx ? { ...o, [field]: value } : o));
setOptRows(copy);
scheduleCommit({ options: copy });
};
const removeOpt = (idx: number) => {
const copy = optRows.filter((_, i) => i !== idx);
setOptRows(copy);
scheduleCommit({ options: copy });
};
const addOpt = () => {
const copy = [...optRows, { value: optRows.length, label: "" }];
setOptRows(copy);
scheduleCommit({ options: copy });
};
// 计分映射:非法输入暂存不提交(保留草稿供用户修正)
const onScoreMapChange = (v: string) => {
setScoreMapDraft(v);
const parsed = parseScoreMap(v);
if (parsed !== null) {
scheduleCommit({ scoreMap: parsed.length > 0 ? parsed : undefined });
}
};
const strField = (label: string, draft: string, field: keyof Question, setDraft: (v: string) => void) => (
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Text type="secondary" style={{ fontSize: 12 }}>{label}</Text>
<Input
size="small"
style={{ width: 160 }}
value={draft}
onChange={(e) => {
setDraft(e.target.value);
scheduleCommit({ [field]: e.target.value } as Partial<Question>);
}}
/>
</div>
);
return (
<div style={{ marginTop: 10, padding: "12px 14px", background: "#f8fafc", borderRadius: 10, border: "1px solid #eef2f7" }}>
{/* 基础字段 */}
<div style={{ display: "flex", flexWrap: "wrap", gap: 16 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
<Select
size="small"
showSearch
allowClear
style={{ width: 150 }}
placeholder="(无)"
value={question.factor || undefined}
onChange={(v: string | undefined) => onCommit({ factor: v || undefined })}
options={allFactors.map((f) => ({ value: f, label: f }))}
/>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
<Switch size="small" checked={!!question.reverse} onChange={(v) => onCommit({ reverse: v })} />
</div>
{weightMode && (
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
<InputNumber size="small" min={0} step={0.1} value={question.weight ?? 1}
onChange={(v) => onCommit({ weight: v ?? undefined })} style={{ width: 80 }} />
</div>
)}
</div>
{/* 专属选项 */}
<div style={{ marginTop: 12, borderTop: "1px solid #e8eef5", paddingTop: 12 }}>
<Text type="secondary" style={{ fontSize: 12, display: "block", marginBottom: 4 }}>使</Text>
{optRows.length > 0 && (
<div style={{ display: "flex", gap: 6, fontSize: 11, color: "#64748b", padding: "0 4px" }}>
<span style={{ width: 60 }}></span>
<span style={{ flex: 1 }}></span>
<span style={{ width: 72 }}></span>
<span style={{ width: 32 }} />
</div>
)}
{optRows.map((o, i) => (
<div key={i} style={{ display: "flex", gap: 6, marginTop: 4, alignItems: "center" }}>
<Input size="small" style={{ width: 60 }} placeholder="值" value={String(o.value)}
onChange={(e) => updateOpt(i, "value", e.target.value)} />
<Input size="small" style={{ flex: 1 }} placeholder="标签" value={o.label}
onChange={(e) => updateOpt(i, "label", e.target.value)} />
<InputNumber size="small" style={{ width: 72 }} placeholder="得分" value={o.score}
onChange={(v) => updateOpt(i, "score", v ?? undefined)} />
<Button type="text" danger icon={<DeleteOutlined />} aria-label="删除选项行"
style={{ minWidth: 32, minHeight: 32 }}
onClick={() => removeOpt(i)} />
</div>
))}
<Button size="small" type="dashed" icon={<PlusOutlined />} onClick={addOpt}
style={{ marginTop: 6, color: "#1e3a5f" }}></Button>
</div>
{/* 条件字段:有值才显示 */}
<div style={{ display: "flex", flexWrap: "wrap", gap: 16, marginTop: 12, borderTop: "1px solid #e8eef5", paddingTop: 12 }}>
{question.keyed !== undefined && (
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Text type="secondary" style={{ fontSize: 12 }}> (keyed)</Text>
<Select size="small" style={{ width: 90 }} value={question.keyed}
onChange={(v) => onCommit({ keyed: (v || undefined) as Question["keyed"] })}
options={[{ value: "是", label: "是" }, { value: "否", label: "否" }]} />
</div>
)}
{question.filler !== undefined && (
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Text type="secondary" style={{ fontSize: 12 }}> (filler)</Text>
<Switch size="small" checked={!!question.filler} onChange={(v) => onCommit({ filler: v })} />
</div>
)}
{question.scoreMap !== undefined && (
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Text type="secondary" style={{ fontSize: 12 }}> (scoreMap)</Text>
<Input size="small" style={{ width: 200 }} value={scoreMapDraft}
placeholder="如 0,1,2,3,4" onChange={(e) => onScoreMapChange(e.target.value)} />
</div>
)}
{question.part !== undefined && strField("分量表 (part)", partDraft, "part", setPartDraft)}
{question.subscale !== undefined && strField("分量表 (subscale)", subscaleDraft, "subscale", setSubscaleDraft)}
{question.dimension !== undefined && strField("维度 (dimension)", dimensionDraft, "dimension", setDimensionDraft)}
</div>
</div>
);
};
export default QuestionCardExpanded;
+132
View File
@@ -0,0 +1,132 @@
import React, { useMemo, useState } from "react";
import { Button, Input, InputNumber, Select, Space, Switch, Table, Typography } from "antd";
import { SearchOutlined, AimOutlined } from "@ant-design/icons";
import type { Question } from "../types";
const { Text } = Typography;
interface Props {
questions: Question[];
allFactors: string[];
weightMode?: boolean;
onCommitQuestion: (index: number, patch: Partial<Question>) => void;
onJumpToCard: (questionId: number) => void;
}
/** 只读核对视图:虚拟表格 + 快捷修改 reverse/factor/weight,跳转卡片视图定位 */
const QuestionCheckView: React.FC<Props> = ({ questions, allFactors, weightMode, onCommitQuestion, onJumpToCard }) => {
const [factorFilter, setFactorFilter] = useState<string>();
const [quick, setQuick] = useState<"all" | "reverse" | "ungrouped">("all");
const [search, setSearch] = useState("");
const filtered = useMemo(() => {
return questions
.map((q, idx) => ({ q, idx }))
.filter(({ q }) => {
if (factorFilter) {
const g = q.factor || "未分组";
if (g !== factorFilter) return false;
}
if (quick === "reverse" && !q.reverse) return false;
if (quick === "ungrouped" && q.factor) return false;
if (search) {
if (search.startsWith("#")) {
const id = parseInt(search.slice(1), 10);
if (isNaN(id) || q.id !== id) return false;
} else if (!q.text.includes(search) && !String(q.id).includes(search)) return false;
}
return true;
});
}, [questions, factorFilter, quick, search]);
const columns = [
{ title: "ID", dataIndex: "id", width: 70, render: (id: number) => <Text strong style={{ color: "#1e3a5f", fontFamily: "SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace" }}>#{id}</Text> },
{
title: "题干", dataIndex: "text", ellipsis: true,
render: (text: string) => text || <Text type="secondary"></Text>,
},
{
title: "反向", width: 70,
render: (_: unknown, r: Question & { __idx: number }) => (
<Switch size="small" checked={!!r.reverse} onChange={(v) => onCommitQuestion(r.__idx, { reverse: v })} />
),
},
{
title: "因子", width: 140,
render: (_: unknown, r: Question & { __idx: number }) => (
<Select
size="small"
showSearch
allowClear
style={{ width: 120 }}
value={r.factor || undefined}
placeholder="(无)"
onChange={(v: string | undefined) => onCommitQuestion(r.__idx, { factor: v || undefined })}
options={allFactors.map((f) => ({ value: f, label: f }))}
/>
),
},
...(weightMode ? [{
title: "权重", width: 90,
render: (_: unknown, r: Question & { __idx: number }) => (
<InputNumber size="small" min={0} step={0.1} value={r.weight ?? 1}
onChange={(v) => onCommitQuestion(r.__idx, { weight: v ?? undefined })} style={{ width: 70 }} />
),
}] : []),
{ title: "专属选项", width: 90, render: (_: unknown, r: Question) => (r.options?.length || 0) },
{
title: "操作", width: 90,
render: (_: unknown, r: Question) => (
<Button type="link" size="small" icon={<AimOutlined />} onClick={() => onJumpToCard(r.id)}></Button>
),
},
];
return (
<div>
<Space wrap style={{ marginBottom: 12 }}>
<Input
prefix={<SearchOutlined />}
placeholder="搜索题干 / #题号"
value={search}
onChange={(e) => setSearch(e.target.value)}
allowClear
style={{ width: 200, borderRadius: 8 }}
/>
<Select
placeholder="按因子筛选"
allowClear
style={{ width: 150 }}
value={factorFilter}
onChange={(v) => setFactorFilter(v)}
options={[...allFactors.map((f) => ({ value: f, label: f })), { value: "未分组", label: "未分组" }]}
/>
<Select
value={quick}
style={{ width: 120 }}
onChange={(v) => setQuick(v)}
options={[
{ value: "all", label: "全部" },
{ value: "reverse", label: "仅反向" },
{ value: "ungrouped", label: "仅未分组" },
]}
/>
<Text type="secondary" style={{ fontSize: 12 }}>{filtered.length} </Text>
</Space>
<div style={{ border: "1px solid #eef2f7", borderRadius: 12, overflow: "hidden" }}>
<Table
virtual
rowKey="id"
size="small"
columns={columns}
dataSource={filtered.map(({ q, idx }) => ({ ...q, __idx: idx }))}
pagination={false}
scroll={{ x: 900, y: "calc(100vh - 400px)" }}
locale={{ emptyText: "无匹配题目" }}
/>
</div>
</div>
);
};
export default QuestionCheckView;
+482
View File
@@ -0,0 +1,482 @@
import React, { useMemo, useRef, useState, useEffect, useCallback } from "react";
import { Empty, Tag, Typography } from "antd";
import { useVirtualizer } from "@tanstack/react-virtual";
import {
DndContext, DragOverlay, MeasuringStrategy,
PointerSensor, TouchSensor, KeyboardSensor, useSensor, useSensors,
type DragStartEvent, type DragEndEvent, type DragCancelEvent,
} from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, useSortable, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import QuestionCard from "./QuestionCard";
import QuestionBatchBar from "./QuestionBatchBar";
import type { Question, ResponseOption } from "../types";
const { Text } = Typography;
const UNGROUPED = "未分组";
const MODE_LABELS: Record<string, string> = {
sum: "求和", directional: "方向计分", weighted: "权重", composite: "复合",
};
/** 因子归属:q.factor 优先,否则取 scoring.factors[f].items 含该题 id 的因子(MMPI 模型) */
function groupOf(q: Question, itemFactorMap: Map<number, string>): string {
return q.factor ?? itemFactorMap.get(q.id) ?? UNGROUPED;
}
/** 由 scoring.factors[].items 构建 题目 id → 因子名 映射 */
function buildItemFactorMap(scoringFactors: Record<string, { items?: number[] }> | undefined): Map<number, string> {
const m = new Map<number, string>();
for (const [f, def] of Object.entries(scoringFactors || {})) {
for (const id of def?.items || []) {
if (!m.has(id)) m.set(id, f);
}
}
return m;
}
export interface QuestionListProps {
questions: Question[];
search: string;
allFactors: string[];
/** scoring.factors 结构,用于分组头显示计分方式与 items 因子归属 */
scoringFactors?: Record<string, { name?: string; items?: number[]; scoring?: { mode?: string } }>;
defaultOptions?: ResponseOption[];
weightMode?: boolean;
/** 添加题目/校验跳转后滚动到指定题目 ID */
scrollToId?: number;
scrollNonce?: number;
onCommitQuestion: (index: number, patch: Partial<Question>) => void;
onDeleteQuestion: (index: number) => void;
onDuplicateQuestion: (index: number) => void;
onMoveQuestion: (index: number, dir: -1 | 1) => void;
onReorder: (oldIndex: number, newIndex: number) => void;
/** 跨组拖拽:移到目标组(targetQuestionIndex 为空表示落到组头) */
onDropIntoGroup: (movedIndex: number, targetGroup: string, targetQuestionIndex?: number) => void;
onBatchSetFactor: (ids: number[], factor?: string) => void;
onBatchSetReverse: (ids: number[], reverse: boolean) => void;
onBatchDelete: (ids: number[]) => void;
onBatchDuplicate: (ids: number[]) => void;
}
interface FilteredEntry {
q: Question;
realIndex: number;
}
type FlatRow =
| { key: string; kind: "header"; group: string; count: number; mode?: string }
| { key: string; kind: "question"; group: string; entry: FilteredEntry };
/** 可拖拽行:useSortable 在此层调用(DragOverlay 不能复用同一 useSortable 组件) */
const SortableRow: React.FC<{
id: string;
disabled?: boolean;
children: (dragHandleProps: Record<string, unknown>) => React.ReactNode;
}> = ({ id, disabled, children }) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled });
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Translate.toString(transform),
transition,
opacity: isDragging ? 0.45 : 1,
width: "100%",
}}
>
{children({ ...attributes, ...listeners })}
</div>
);
};
/** 分组头(虚拟行内):作为拖拽落点(droppable-only,无 listeners/attributes → 不可拖起) */
const GroupHeaderRow: React.FC<{ row: Extract<FlatRow, { kind: "header" }> }> = ({ row }) => {
const { setNodeRef, transform, transition } = useSortable({ id: row.key });
return (
<div ref={setNodeRef} style={{ transform: CSS.Translate.toString(transform), transition }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 12px",
background: "rgba(30,58,95,0.05)",
border: "1px solid transparent",
borderBottom: "1px solid #dbe4ef",
borderRadius: 8,
margin: "4px 2px",
}}
>
<Text strong style={{ fontSize: 13, color: "#1e3a5f" }}>{row.group}</Text>
<Tag style={{ marginInlineEnd: 0, fontSize: 11, color: "#475569", background: "rgba(30,58,95,0.08)", borderColor: "transparent" }}>{row.count} </Tag>
{row.mode && (
<Tag style={{ marginInlineEnd: 0, fontSize: 11, color: "#155e75", background: "rgba(8,145,178,0.1)", borderColor: "transparent" }}>
{MODE_LABELS[row.mode] || row.mode}
</Tag>
)}
</div>
</div>
);
};
const QuestionList: React.FC<QuestionListProps> = ({
questions, search, allFactors, scoringFactors, defaultOptions, weightMode,
scrollToId, scrollNonce,
onCommitQuestion, onDeleteQuestion, onDuplicateQuestion, onMoveQuestion,
onReorder, onDropIntoGroup,
onBatchSetFactor, onBatchSetReverse, onBatchDelete, onBatchDuplicate,
}) => {
const parentRef = useRef<HTMLDivElement | null>(null);
const [expanded, setExpanded] = useState<Set<number>>(new Set());
const [selected, setSelected] = useState<Set<number>>(new Set());
const [activeDrag, setActiveDrag] = useState<FilteredEntry | null>(null);
const [dragging, setDragging] = useState(false);
const [scrollTop, setScrollTop] = useState(0);
const hasFilter = !!search.trim();
// P1-1MMPI 等量表的因子归属在 scoring.factors[].items(题号数组),题目上无 q.factor
const itemFactorMap = useMemo(() => buildItemFactorMap(scoringFactors), [scoringFactors]);
const effectiveGroup = useCallback((q: Question) => groupOf(q, itemFactorMap), [itemFactorMap]);
// ===== 过滤 =====
const filteredEntries = useMemo<FilteredEntry[]>(() => {
const s = search.trim();
const list: FilteredEntry[] = [];
questions.forEach((q, realIndex) => {
if (!s) { list.push({ q, realIndex }); return; }
if (s.startsWith("#")) {
const id = parseInt(s.slice(1), 10);
if (!isNaN(id) && q.id === id) list.push({ q, realIndex });
return;
}
if (q.text.includes(s) || String(q.id).includes(s)) list.push({ q, realIndex });
});
return list;
}, [questions, search]);
// ===== 分组顺序:scoring.factors key 优先,再按题目出现顺序,未分组最后 =====
const factorOrder = useMemo(() => {
const order: string[] = [];
for (const k of Object.keys(scoringFactors || {})) if (!order.includes(k)) order.push(k);
for (const q of questions) {
const f = q.factor;
if (f && !order.includes(f)) order.push(f);
}
if (!order.includes(UNGROUPED)) order.push(UNGROUPED);
return order;
}, [scoringFactors, questions]);
// ===== 扁平行(分组模式:header + question;过滤模式:仅 question =====
const flatRows = useMemo<FlatRow[]>(() => {
if (hasFilter) {
return filteredEntries.map((e) => ({
key: String(e.q.id), kind: "question" as const, group: effectiveGroup(e.q), entry: e,
}));
}
const byGroup = new Map<string, FilteredEntry[]>();
for (const e of filteredEntries) {
const g = effectiveGroup(e.q);
if (!byGroup.has(g)) byGroup.set(g, []);
byGroup.get(g)!.push(e);
}
const groups = factorOrder.filter((g) => byGroup.has(g));
for (const g of byGroup.keys()) if (!groups.includes(g)) groups.push(g);
const rows: FlatRow[] = [];
for (const g of groups) {
const mode = scoringFactors?.[g]?.scoring?.mode;
rows.push({ key: `H:${g}`, kind: "header", group: g, count: byGroup.get(g)!.length, mode });
for (const e of byGroup.get(g)!) rows.push({ key: String(e.q.id), kind: "question", group: g, entry: e });
}
return rows;
}, [filteredEntries, hasFilter, factorOrder, scoringFactors, effectiveGroup]);
// ===== 组内序号(上移/下移按钮用) =====
const groupPos = useMemo(() => {
const m = new Map<number, { idx: number; count: number }>();
if (!hasFilter) {
let curIdx = 0;
let curCount = 0;
for (const r of flatRows) {
if (r.kind === "header") {
curIdx = 0;
curCount = r.count;
} else if (r.kind === "question" && r.entry) {
m.set(r.entry.q.id, { idx: curIdx, count: curCount });
curIdx++;
}
}
}
return m;
}, [flatRows, hasFilter]);
const groupMeta = useMemo(() => {
const meta: Record<string, { count: number; mode?: string }> = {};
for (const r of flatRows) {
if (r.kind === "header") meta[r.group] = { count: r.count, mode: r.mode };
}
return meta;
}, [flatRows]);
// ===== 虚拟列表 =====
const virtualizer = useVirtualizer({
count: flatRows.length,
getScrollElement: () => parentRef.current,
estimateSize: (i) => (flatRows[i]?.kind === "header" ? 36 : 120),
overscan: 5,
measureElement: (el) => el.getBoundingClientRect().height,
});
const virtualItems = virtualizer.getVirtualItems();
// ===== 当前分组(吸顶条) =====
const currentGroup = useMemo(() => {
if (hasFilter || virtualItems.length === 0) return null;
const atTop = virtualItems.find((v) => v.end > scrollTop) || virtualItems[0];
const row = flatRows[atTop?.index ?? 0];
if (!row) return null;
const meta = groupMeta[row.group] || { count: 0 };
return { group: row.group, count: meta.count, mode: meta.mode };
}, [hasFilter, virtualItems, flatRows, groupMeta, scrollTop]);
const onScroll = useCallback(() => {
setScrollTop(parentRef.current?.scrollTop ?? 0);
}, []);
// 添加题目/校验跳转后滚动(按题目 ID 定位)
useEffect(() => {
if (scrollNonce !== undefined && scrollToId !== undefined) {
const idx = flatRows.findIndex((r) => r.kind === "question" && r.entry!.q.id === scrollToId);
if (idx !== -1) virtualizer.scrollToIndex(idx, { align: "auto" });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrollNonce]);
// ===== dndPointer/Touch/Keyboard:拖柄聚焦后 Space 或方向键排序) =====
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 120, tolerance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const handleDragStart = (e: DragStartEvent) => {
setDragging(true);
const row = flatRows.find((r): r is Extract<FlatRow, { kind: "question" }> =>
r.kind === "question" && r.key === String(e.active.id));
if (row) setActiveDrag(row.entry);
};
const handleDragEnd = (e: DragEndEvent) => {
setDragging(false);
setActiveDrag(null);
const { active, over } = e;
if (!over || active.id === over.id) return;
const fromRow = flatRows.find((r): r is Extract<FlatRow, { kind: "question" }> =>
r.kind === "question" && r.key === String(active.id));
const overRow = flatRows.find((r) => r.key === String(over.id));
if (!fromRow || !overRow) return;
if (overRow.kind === "header") {
// 落到组头:移入该组并置于组首
onDropIntoGroup(fromRow.entry.realIndex, overRow.group);
return;
}
if (overRow.group === fromRow.group) {
onReorder(fromRow.entry.realIndex, overRow.entry.realIndex);
} else {
// 跨组:重排并修改被拖题的 factor
onDropIntoGroup(fromRow.entry.realIndex, overRow.group, overRow.entry.realIndex);
}
};
const handleDragCancel = (_e: DragCancelEvent) => {
setDragging(false);
setActiveDrag(null);
};
// ===== 批量勾选 =====
const selectedIds = useMemo(() => [...selected], [selected]);
const toggleSelect = (id: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleSelectAll = () => setSelected(new Set(filteredEntries.map((e) => e.q.id)));
const handleClear = () => setSelected(new Set());
const handleBatchDelete = () => {
onBatchDelete(selectedIds);
setSelected(new Set());
};
// ===== 组内移动(上移/下移按钮) =====
const handleGroupMove = (entry: FilteredEntry, dir: -1 | 1) => {
if (hasFilter) { onMoveQuestion(entry.realIndex, dir); return; }
const members = flatRows.filter((r): r is Extract<FlatRow, { kind: "question" }> =>
r.kind === "question" && r.group === effectiveGroup(entry.q));
const gIdx = members.findIndex((r) => r.entry.q.id === entry.q.id);
const target = members[gIdx + dir];
if (target) onReorder(entry.realIndex, target.entry.realIndex);
};
const toggleExpand = (id: number) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return (
<div>
<QuestionBatchBar
selectedCount={selectedIds.length}
totalCount={filteredEntries.length}
allFactors={allFactors}
onSelectAll={handleSelectAll}
onClear={handleClear}
onSetFactor={(f) => onBatchSetFactor(selectedIds, f)}
onSetReverse={(v) => onBatchSetReverse(selectedIds, v)}
onDelete={handleBatchDelete}
onDuplicate={() => onBatchDuplicate(selectedIds)}
/>
<div
ref={parentRef}
onScroll={onScroll}
style={{
height: "calc(100vh - 340px)",
minHeight: 320,
overflowY: "auto",
border: "1px solid #eef2f7",
borderRadius: 12,
background: "#fafbfc",
padding: "0 6px 8px",
}}
>
{/* 吸顶当前分组条(仅分组模式) */}
{currentGroup && (
<div
style={{
position: "sticky",
top: 0,
zIndex: 30,
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 12px",
background: "rgba(238,244,251,0.88)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
borderBottom: "1px solid #cfe0f2",
marginBottom: 4,
borderRadius: "0 0 8px 8px",
}}
>
<Text strong style={{ fontSize: 13, color: "#1e3a5f" }}>{currentGroup.group}</Text>
<Tag style={{ marginInlineEnd: 0, fontSize: 11, color: "#475569", background: "rgba(30,58,95,0.08)", borderColor: "transparent" }}>{currentGroup.count} </Tag>
{currentGroup.mode && (
<Tag style={{ marginInlineEnd: 0, fontSize: 11, color: "#155e75", background: "rgba(8,145,178,0.1)", borderColor: "transparent" }}>
{MODE_LABELS[currentGroup.mode] || currentGroup.mode}
</Tag>
)}
</div>
)}
{flatRows.length === 0 ? (
<div style={{ display: "flex", justifyContent: "center", paddingTop: 80 }}>
<Empty description={hasFilter ? "无匹配题目" : "暂无题目,点击「添加题目」创建"} />
</div>
) : (
<div style={{ height: virtualizer.getTotalSize(), width: "100%", position: "relative" }}>
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
// 虚拟列表只有视口内卡片在 DOM 中:Always 策略在拖拽期间每帧重测 droppable rect
measuring={{ droppable: { strategy: MeasuringStrategy.Always } }}
>
<SortableContext items={flatRows.map((r) => r.key)} strategy={verticalListSortingStrategy}>
{virtualItems.map((vr) => {
const row = flatRows[vr.index];
const rowStyle = {
position: "absolute" as const,
top: 0,
left: 0,
width: "100%",
transform: `translateY(${vr.start}px)`,
padding: "2px 0",
};
if (row.kind === "header") {
return (
<div key={row.key} data-index={vr.index} ref={virtualizer.measureElement} style={rowStyle}>
<GroupHeaderRow row={row} />
</div>
);
}
const q = row.entry!.q;
const pos = groupPos.get(q.id);
return (
<div key={row.key} data-index={vr.index} ref={virtualizer.measureElement} style={rowStyle}>
<SortableRow id={row.key} disabled={hasFilter}>
{(dragHandleProps) => (
<QuestionCard
question={q}
index={hasFilter ? row.entry!.realIndex : (pos?.idx ?? 0)}
isLast={hasFilter ? row.entry!.realIndex === questions.length - 1 : (pos ? pos.idx === pos.count - 1 : true)}
allFactors={allFactors}
defaultOptions={defaultOptions}
weightMode={weightMode}
dragHandleProps={dragHandleProps}
onCommit={(patch) => onCommitQuestion(row.entry!.realIndex, patch)}
onDelete={() => onDeleteQuestion(row.entry!.realIndex)}
onDuplicate={() => onDuplicateQuestion(row.entry!.realIndex)}
onMove={(dir) => handleGroupMove(row.entry!, dir)}
expanded={expanded.has(q.id)}
onToggleExpand={() => toggleExpand(q.id)}
selected={!dragging && selected.has(q.id)}
onToggleSelect={!dragging ? () => toggleSelect(q.id) : undefined}
/>
)}
</SortableRow>
</div>
);
})}
</SortableContext>
<DragOverlay dropAnimation={null}>
{activeDrag && (
<div
style={{
background: "#fff",
border: "1px solid #cfe0f2",
borderRadius: 12,
boxShadow: "0 12px 32px rgba(30,58,95,0.18)",
transform: "scale(1.02)",
padding: "10px 14px",
width: 380,
maxWidth: "calc(100vw - 32px)",
}}
>
<div style={{ fontSize: 13, fontWeight: 600, color: "#1e3a5f", marginBottom: 4 }}>
#{activeDrag.q.id}
{effectiveGroup(activeDrag.q) !== UNGROUPED && (
<span style={{ marginLeft: 8, fontWeight: 400 }}>{effectiveGroup(activeDrag.q)}</span>
)}
</div>
<div style={{ fontSize: 13, color: "#1f2937", lineHeight: 1.5 }}>
{activeDrag.q.text || "(空题干)"}
</div>
</div>
)}
</DragOverlay>
</DndContext>
</div>
)}
</div>
</div>
);
};
export default QuestionList;
+25 -11
View File
@@ -11,7 +11,11 @@ function matchPage(current: string, pageKey: string): boolean {
} }
function replacePlaceholders(text: string, vars: Record<string, string>): string { function replacePlaceholders(text: string, vars: Record<string, string>): string {
return text.replace(/\{(\w+)\}/g, (_, key) => vars[key] || `{${key}}`); // hasOwnProperty 区分「未提供占位符」(原样保留)与「值为空串」(替换为空)
// 否则空串会被 || 当成缺失 → {userId} 等占位符原样显示(线上事故)
return text.replace(/\{(\w+)\}/g, (_, key) =>
Object.prototype.hasOwnProperty.call(vars, key) ? vars[key] : `{${key}}`
);
} }
interface WatermarkProps { interface WatermarkProps {
@@ -47,12 +51,13 @@ const Watermark: React.FC<WatermarkProps> = ({ enabled, text, opacity, angle, pa
const el = containerRef.current; const el = containerRef.current;
if (!el) return; if (!el) return;
// Clear previous shadow root // Shadow root 复用:元素已挂载过 shadow 则直接复用(attachShadow 对已挂载的元素
while (el.firstChild) el.removeChild(el.firstChild); // 二次调用会抛 "Shadow root cannot be created on a host which already hosts a shadow tree"
// user 加载完成后 effect 重跑即触发此路径,必须复用而非重建)
// Create shadow root for isolation + anti-tamper
let shadow: ShadowRoot; let shadow: ShadowRoot;
if (typeof el.attachShadow === 'function') { if (el.shadowRoot) {
shadow = el.shadowRoot;
} else if (typeof el.attachShadow === 'function') {
shadow = el.attachShadow({ mode: 'open' }); shadow = el.attachShadow({ mode: 'open' });
} else { } else {
return; return;
@@ -105,24 +110,33 @@ const Watermark: React.FC<WatermarkProps> = ({ enabled, text, opacity, angle, pa
renderWatermark(); renderWatermark();
// MutationObserver: auto-rebuild if deleted // MutationObserver: 仅当水印被外部删除(shadow 变空)时重建(防篡改)。
// 不能监听任意 childList 变化——renderWatermark 自身的清空+重建会触发它,
// 造成「重建→触发→重建」无限循环,窗口缩放时直接卡死(线上事故)。
// renderWatermark 的清空+重建是同步的,微任务回调触发时子节点已恢复(count>0),不会误触发。
let observer: MutationObserver | null = null; let observer: MutationObserver | null = null;
if (typeof MutationObserver !== 'undefined') { if (typeof MutationObserver !== 'undefined') {
observer = new MutationObserver(() => { observer = new MutationObserver(() => {
if (!el.isConnected) return; if (!el.isConnected || !el.shadowRoot) return;
renderWatermark(); if (el.shadowRoot.childElementCount === 0) renderWatermark();
}); });
observer.observe(shadow, { childList: true, subtree: true }); observer.observe(shadow, { childList: true, subtree: true });
} }
const onResize = () => renderWatermark(); // resize 防抖:拖拽缩放窗口期间 resize 事件高频触发,全量重建水印网格开销大,合并到 200ms 后一次
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
const onResize = () => {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(renderWatermark, 200);
};
window.addEventListener('resize', onResize); window.addEventListener('resize', onResize);
return () => { return () => {
if (resizeTimer) clearTimeout(resizeTimer);
window.removeEventListener('resize', onResize); window.removeEventListener('resize', onResize);
if (observer) observer.disconnect(); if (observer) observer.disconnect();
}; };
}, [enabled, text, opacity, angle, pages, extraVars?.recordId, extraVars?.scaleName]); }, [enabled, text, opacity, angle, pages, extraVars?.recordId, extraVars?.scaleName, user?.id, user?.name]);
if (!enabled) return null; if (!enabled) return null;
+111
View File
@@ -0,0 +1,111 @@
import { useCallback, useEffect, useReducer, useRef } from "react";
import type { Scale } from "../types";
/**
* 撤销/重做历史(v2 编辑器)。
* - 入栈基于 JSON 深拷贝(scale 结构小,可接受)
* - 题干防抖提交合并:opts.merge 时,与上次提交间隔 < 500ms 则只覆盖不新增快照
* - Ctrl/Cmd+Z 撤销、Ctrl/Cmd+Shift+Z / Ctrl+Y 重做;输入框聚焦时不触发
*/
const LIMIT = 50;
const MERGE_MS = 500;
interface HistoryState {
scale: Scale;
past: Scale[];
future: Scale[];
}
type Action =
| { type: "commit"; mutator: (prev: Scale) => Scale; merged: boolean }
| { type: "replace"; scale: Scale }
| { type: "undo" }
| { type: "redo" };
const clone = (s: Scale): Scale => JSON.parse(JSON.stringify(s));
function reducer(state: HistoryState, action: Action): HistoryState {
switch (action.type) {
case "commit": {
const next = action.mutator(state.scale);
if (next === state.scale) return state;
if (action.merged && state.past.length > 0) {
// 合并提交:不新增快照,直接覆盖当前 scale
return { scale: next, past: state.past, future: [] };
}
return { scale: next, past: [...state.past.slice(-(LIMIT - 1)), clone(state.scale)], future: [] };
}
case "replace":
return { scale: action.scale, past: [], future: [] };
case "undo": {
if (state.past.length === 0) return state;
const prev = state.past[state.past.length - 1];
return { scale: prev, past: state.past.slice(0, -1), future: [clone(state.scale), ...state.future].slice(0, LIMIT) };
}
case "redo": {
if (state.future.length === 0) return state;
const [next, ...rest] = state.future;
return { scale: next, past: [...state.past, clone(state.scale)].slice(-LIMIT), future: rest };
}
default:
return state;
}
}
export function useScaleHistory(initialScale: Scale) {
const [state, dispatch] = useReducer(reducer, initialScale, (s) => ({ scale: s, past: [], future: [] }));
const lastMergeTime = useRef(0);
const commit = useCallback((mutator: (prev: Scale) => Scale, opts?: { merge?: boolean }) => {
let merged = false;
if (opts?.merge) {
const now = Date.now();
merged = now - lastMergeTime.current < MERGE_MS;
lastMergeTime.current = now;
}
dispatch({ type: "commit", mutator, merged });
}, []);
const replace = useCallback((scale: Scale) => {
dispatch({ type: "replace", scale });
}, []);
const undo = useCallback(() => dispatch({ type: "undo" }), []);
const redo = useCallback(() => dispatch({ type: "redo" }), []);
// 全局键盘监听:排除输入框/文本域/下拉焦点(避免与原生撤销冲突)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
if (target) {
const tag = target.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable) return;
}
const mod = e.ctrlKey || e.metaKey;
if (!mod) return;
const key = e.key.toLowerCase();
if (key === "z") {
e.preventDefault();
if (e.shiftKey) redo();
else undo();
} else if (key === "y") {
e.preventDefault();
redo();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [undo, redo]);
return {
scale: state.scale,
commit,
replace,
undo,
redo,
canUndo: state.past.length > 0,
canRedo: state.future.length > 0,
};
}
+35 -47
View File
@@ -1,9 +1,10 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect } from 'react';
import { Card, Form, Input, Button, Typography, message, Tabs, Modal } from 'antd'; import { Card, Form, Input, Button, Typography, message, Tabs } from 'antd';
import { MailOutlined, LockOutlined, UserOutlined, SendOutlined, IdcardOutlined, CheckCircleOutlined } from '@ant-design/icons'; import { MailOutlined, LockOutlined, UserOutlined, SendOutlined, IdcardOutlined, CheckCircleOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAppStore } from '../store'; import { useAppStore } from '../store';
import { authApi, captchaApi, settingsApi } from '../api'; import { authApi, captchaApi, settingsApi } from '../api';
import CaptchaModal, { type CaptchaMode } from '../components/CaptchaModal';
const { Text } = Typography; const { Text } = Typography;
@@ -30,6 +31,7 @@ const LoginPage: React.FC = () => {
const [devCode, setDevCode] = useState(''); const [devCode, setDevCode] = useState('');
const [activeTab, setActiveTab] = useState('login'); const [activeTab, setActiveTab] = useState('login');
const [captchaInfo, setCaptchaInfo] = useState<{ siteKey: string; actions: Record<string, boolean> }>({ siteKey: '', actions: {} }); const [captchaInfo, setCaptchaInfo] = useState<{ siteKey: string; actions: Record<string, boolean> }>({ siteKey: '', actions: {} });
const [captchaMode, setCaptchaMode] = useState<CaptchaMode>('turnstile');
const [captchaModal, setCaptchaModal] = useState<string | null>(null); const [captchaModal, setCaptchaModal] = useState<string | null>(null);
const [captchaToken, setCaptchaToken] = useState(''); const [captchaToken, setCaptchaToken] = useState('');
const [captchaVerified, setCaptchaVerified] = useState(false); const [captchaVerified, setCaptchaVerified] = useState(false);
@@ -37,7 +39,6 @@ const LoginPage: React.FC = () => {
const [rainidEnabled, setRainidEnabled] = useState(false); const [rainidEnabled, setRainidEnabled] = useState(false);
const [registerRedirect, setRegisterRedirect] = useState(false); const [registerRedirect, setRegisterRedirect] = useState(false);
const [oidcBusy, setOidcBusy] = useState(false); const [oidcBusy, setOidcBusy] = useState(false);
const captchaWidgetId = useRef<string | null>(null);
// RainID OIDC 回跳收尾:一次性过渡码换 token / 错误码映射 / 登出回跳清 token。 // RainID OIDC 回跳收尾:一次性过渡码换 token / 错误码映射 / 登出回跳清 token。
// OIDC 走整页跳转,此 effect 仅在页面挂载时执行一次。 // OIDC 走整页跳转,此 effect 仅在页面挂载时执行一次。
@@ -66,65 +67,46 @@ const LoginPage: React.FC = () => {
settingsApi.getPublic().then(data => { settingsApi.getPublic().then(data => {
if (data) { if (data) {
if (data.login_bg) setLoginBg(data.login_bg); if (data.login_bg) setLoginBg(data.login_bg);
if (data.rainid_enabled === '1') setRainidEnabled(true); // SSO 按钮仅当 rainid_configured=truefail-closedenabled + client_id + secret 齐全)时显示
if (data.rainid_configured) setRainidEnabled(true);
if (data.rainid_register_redirect === '1') setRegisterRedirect(true); if (data.rainid_register_redirect === '1') setRegisterRedirect(true);
} }
}).catch(() => {}); }).catch(() => {});
captchaApi.status().then(data => { captchaApi.status().then(data => {
if (data.siteKey) { if (!data) return;
setCaptchaInfo({ siteKey: data.siteKey, actions: data.actions || {} }); const type = data.type || 'turnstile';
if (!document.querySelector('script[src*="turnstile"]')) { const useBuiltin = type === 'builtin' || type === 'both';
setCaptchaMode(useBuiltin ? 'builtin' : 'turnstile');
setCaptchaInfo({ siteKey: data.siteKey || '', actions: data.actions || {} });
if (data.siteKey && !useBuiltin && !document.querySelector('script[src*="turnstile"]')) {
const s = document.createElement('script'); const s = document.createElement('script');
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'; s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
s.async = true; s.defer = true; s.async = true; s.defer = true;
document.head.appendChild(s); document.head.appendChild(s);
} }
}
}).catch(() => {}); }).catch(() => {});
}, []); }, []);
useEffect(() => { // 内置模式不依赖 Turnstile siteKeyturnstile 模式仍需 siteKey 才要求验证
if (captchaModal && window.turnstile && captchaInfo.siteKey) { const needCaptcha = (action: string) => captchaMode === 'builtin'
setTimeout(() => { ? !!captchaInfo.actions[action]
const el = document.getElementById('cf-captcha-modal'); : !!(captchaInfo.actions[action] && captchaInfo.siteKey);
if (el && !el.children.length && window.turnstile) {
captchaWidgetId.current = window.turnstile.render('#cf-captcha-modal', {
sitekey: captchaInfo.siteKey, theme: 'light',
});
}
}, 200);
}
}, [captchaModal, captchaInfo.siteKey]);
const needCaptcha = (action: string) => !!(captchaInfo.actions[action] && captchaInfo.siteKey);
const currentAction = activeTab === 'login' ? 'login' : 'register'; const currentAction = activeTab === 'login' ? 'login' : 'register';
const showCaptchaBtn = needCaptcha(currentAction); const showCaptchaBtn = needCaptcha(currentAction);
const getToken = () => {
const ts = window.turnstile;
if (!ts || !captchaWidgetId.current) return '';
try { return ts.getResponse(captchaWidgetId.current) || ''; } catch { return ''; }
};
const resetCaptcha = () => { const resetCaptcha = () => {
const ts = window.turnstile;
if (ts && captchaWidgetId.current) try { ts.reset(captchaWidgetId.current); } catch {}
setCaptchaToken(''); setCaptchaToken('');
setCaptchaVerified(false); setCaptchaVerified(false);
}; };
const openCaptcha = () => { const openCaptcha = () => {
captchaWidgetId.current = null;
setCaptchaModal(currentAction); setCaptchaModal(currentAction);
}; };
const confirmCaptcha = () => { /** 登录/注册提交:按模式携带对应验证字段(内置=proofTurnstile=token */
const token = getToken(); const captchaPayload = () => captchaMode === 'builtin'
if (!token) { message.warning('请完成人机验证'); return; } ? { captchaProof: captchaToken, turnstileToken: undefined }
setCaptchaToken(token); : { captchaProof: undefined, turnstileToken: captchaToken };
setCaptchaVerified(true);
setCaptchaModal(null);
};
const startCountdown = () => { const startCountdown = () => {
setCountdown(60); setCountdown(60);
@@ -202,11 +184,12 @@ const LoginPage: React.FC = () => {
if (showCaptchaBtn && !captchaVerified) { message.warning('请先完成人机验证'); return; } if (showCaptchaBtn && !captchaVerified) { message.warning('请先完成人机验证'); return; }
setLoading(true); setLoading(true);
try { try {
const res = await authApi.login(values.username, values.password, captchaToken); const { turnstileToken, captchaProof } = captchaPayload();
const res = await authApi.login(values.username, values.password, turnstileToken, captchaProof);
if (res.success) handleLoginSuccess(res); if (res.success) handleLoginSuccess(res);
} catch (e: any) { } catch (e: any) {
message.error(e.response?.data?.error?.message || '登录失败'); message.error(e.response?.data?.error?.message || '登录失败');
// Turnstile proof 为一次性消费:登录失败后作废,重置验证状态允许重新验证 // 验证 proof 为一次性消费:失败后作废,重置验证状态允许重新验证
resetCaptcha(); resetCaptcha();
} finally { setLoading(false); } } finally { setLoading(false); }
}; };
@@ -215,7 +198,8 @@ const LoginPage: React.FC = () => {
if (showCaptchaBtn && !captchaVerified) { message.warning('请先完成人机验证'); return; } if (showCaptchaBtn && !captchaVerified) { message.warning('请先完成人机验证'); return; }
setLoading(true); setLoading(true);
try { try {
const res = await authApi.register(values.name, values.email, values.code, values.password, captchaToken); const { turnstileToken, captchaProof } = captchaPayload();
const res = await authApi.register(values.name, values.email, values.code, values.password, turnstileToken, captchaProof);
if (res.success) { login(res.data.token, res.data.user); message.success('注册成功'); navigate('/'); } if (res.success) { login(res.data.token, res.data.user); message.success('注册成功'); navigate('/'); }
} catch (e: any) { } catch (e: any) {
message.error(e.response?.data?.error?.message || '注册失败'); message.error(e.response?.data?.error?.message || '注册失败');
@@ -343,13 +327,17 @@ const LoginPage: React.FC = () => {
]} /> ]} />
</Card> </Card>
<Modal title="人机验证" open={!!captchaModal} <CaptchaModal
onOk={confirmCaptcha} open={!!captchaModal}
mode={captchaMode}
siteKey={captchaInfo.siteKey}
onOk={(proof) => {
setCaptchaToken(proof);
setCaptchaVerified(true);
setCaptchaModal(null);
}}
onCancel={() => { setCaptchaModal(null); resetCaptcha(); }} onCancel={() => { setCaptchaModal(null); resetCaptcha(); }}
okText="确认" width={380} destroyOnClose> />
<p style={{ textAlign: 'center', marginBottom: 16 }}></p>
<div id="cf-captcha-modal" style={{ display: 'flex', justifyContent: 'center', minHeight: 65 }} />
</Modal>
</div> </div>
); );
}; };
+45 -38
View File
@@ -4,13 +4,10 @@ import { PlusOutlined, EditOutlined, DeleteOutlined, ShareAltOutlined, SendOutli
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { userScaleApi, captchaApi } from "../api"; import { userScaleApi, captchaApi } from "../api";
import { useAppStore } from "../store"; import { useAppStore } from "../store";
import CaptchaModal, { BuiltinCaptchaInline, type CaptchaMode } from "../components/CaptchaModal";
const { Text } = Typography; const { Text } = Typography;
declare global {
interface Window { turnstile?: { render: (el: string, opts: any) => string; getResponse: (wid: string) => string; reset: (wid: string) => void }; }
}
function userIdSuffix(id: string): string { function userIdSuffix(id: string): string {
let hash = 0; let hash = 0;
for (let i = 0; i < id.length; i++) { for (let i = 0; i < id.length; i++) {
@@ -39,14 +36,20 @@ const MyScales: React.FC = () => {
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
const [captchaState, setCaptchaState] = useState<{ action: string; fn: (t: string) => Promise<void> } | null>(null); const [captchaState, setCaptchaState] = useState<{ action: string; fn: (t: string) => Promise<void> } | null>(null);
const [siteKey, setSiteKey] = useState(''); const [siteKey, setSiteKey] = useState('');
const [captchaMode, setCaptchaMode] = useState<CaptchaMode>('turnstile');
const [createProof, setCreateProof] = useState('');
const cfWidget = useRef<string | null>(null); const cfWidget = useRef<string | null>(null);
// Load Turnstile once // Load Turnstile once (仅 turnstile 模式需要)
useEffect(() => { useEffect(() => {
captchaApi.status().then(data => { captchaApi.status().then(data => {
if (!data) return;
const type = data.type || 'turnstile';
const useBuiltin = type === 'builtin' || type === 'both';
setCaptchaMode(useBuiltin ? 'builtin' : 'turnstile');
if (data.siteKey) { if (data.siteKey) {
setSiteKey(data.siteKey); setSiteKey(data.siteKey);
if (!window.turnstile) { if (!useBuiltin && !window.turnstile) {
const s = document.createElement('script'); const s = document.createElement('script');
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'; s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
s.async = true; s.defer = true; s.async = true; s.defer = true;
@@ -56,18 +59,6 @@ const MyScales: React.FC = () => {
}).catch(() => {}); }).catch(() => {});
}, []); }, []);
// Render captcha when modal opens
useEffect(() => {
if (captchaState && siteKey && window.turnstile) {
setTimeout(() => {
const el = document.getElementById('cf-widget');
if (el && !el.children.length && window.turnstile) {
cfWidget.current = window.turnstile.render('#cf-widget', { sitekey: siteKey, theme: 'light' });
}
}, 300);
}
}, [captchaState, siteKey]);
const getToken = () => { const getToken = () => {
const ts = window.turnstile; const ts = window.turnstile;
if (ts && cfWidget.current) return ts.getResponse(cfWidget.current); if (ts && cfWidget.current) return ts.getResponse(cfWidget.current);
@@ -89,7 +80,7 @@ const MyScales: React.FC = () => {
useEffect(() => { loadData(); }, [loadData]); useEffect(() => { loadData(); }, [loadData]);
const handleCreate = async (turnstileToken = '') => { const handleCreate = async (captchaToken = '') => {
if (!newId || !newName) { message.warning("请填写ID和名称"); return; } if (!newId || !newName) { message.warning("请填写ID和名称"); return; }
const suffix = userIdSuffix(user?.id || ""); const suffix = userIdSuffix(user?.id || "");
const finalId = `${newId}-${suffix}`; const finalId = `${newId}-${suffix}`;
@@ -103,7 +94,8 @@ const MyScales: React.FC = () => {
interpretation: { rules: [] }, interpretation: { rules: [] },
}; };
try { try {
await userScaleApi.create(finalId, content, turnstileToken); if (captchaMode === 'builtin') await userScaleApi.create(finalId, content, undefined, captchaToken);
else await userScaleApi.create(finalId, content, captchaToken);
message.success(`创建成功,量表ID: ${finalId}`); message.success(`创建成功,量表ID: ${finalId}`);
setCreateModal(false); setCreateModal(false);
setNewId(""); setNewName(""); setNewId(""); setNewName("");
@@ -118,14 +110,18 @@ const MyScales: React.FC = () => {
loadData(); loadData();
}; };
const handleShare = async (id: string, turnstileToken = '') => { const handleShare = async (id: string, captchaToken = '') => {
const res = await userScaleApi.share(id, turnstileToken); const res = captchaMode === 'builtin'
? await userScaleApi.share(id, undefined, captchaToken)
: await userScaleApi.share(id, captchaToken);
const siteUrl = localStorage.getItem("site_url") || window.location.origin; const siteUrl = localStorage.getItem("site_url") || window.location.origin;
setShareModal({ url: `${siteUrl}${res.url}`, code: res.shareCode }); setShareModal({ url: `${siteUrl}${res.url}`, code: res.shareCode });
}; };
const handleSubmitReview = async (id: string, turnstileToken = '') => { const handleSubmitReview = async (id: string, captchaToken = '') => {
const res = await userScaleApi.submitReview(id, turnstileToken); const res = captchaMode === 'builtin'
? await userScaleApi.submitReview(id, undefined, captchaToken)
: await userScaleApi.submitReview(id, captchaToken);
if (res.error) { message.error(res.error); return; } if (res.error) { message.error(res.error); return; }
message.success("已提交审核"); message.success("已提交审核");
loadData(); loadData();
@@ -182,12 +178,15 @@ const MyScales: React.FC = () => {
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 16 }}> <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 16 }}>
<h2 style={{ margin: 0, fontSize: 18 }}></h2> <h2 style={{ margin: 0, fontSize: 18 }}></h2>
<Button type="primary" icon={<PlusOutlined />} <Button type="primary" icon={<PlusOutlined />}
onClick={() => { setCreateModal(true); setTimeout(() => { onClick={() => {
setCreateModal(true);
if (captchaMode === 'turnstile') setTimeout(() => {
const ts = window.turnstile; const ts = window.turnstile;
if (siteKey && ts && document.getElementById('cf-widget-create') && !document.getElementById('cf-widget-create')?.children.length) { if (siteKey && ts && document.getElementById('cf-widget-create') && !document.getElementById('cf-widget-create')?.children.length) {
cfWidget.current = ts.render('#cf-widget-create', { sitekey: siteKey, theme: 'light' }); cfWidget.current = ts.render('#cf-widget-create', { sitekey: siteKey, theme: 'light' });
} }
}, 300); }} }, 300);
}}
style={{ background: "#1e3a5f", border: "none" }}></Button> style={{ background: "#1e3a5f", border: "none" }}></Button>
</div> </div>
<Card style={{ borderRadius: 12 }}> <Card style={{ borderRadius: 12 }}>
@@ -197,13 +196,19 @@ const MyScales: React.FC = () => {
<Modal title="创建量表" open={createModal} <Modal title="创建量表" open={createModal}
onOk={async () => { onOk={async () => {
const ct = getToken();
const need = await needCaptcha('create_scale'); const need = await needCaptcha('create_scale');
if (captchaMode === 'builtin') {
if (need && !createProof) { message.warning('请先完成人机验证'); return; }
await handleCreate(createProof);
setCreateProof('');
} else {
const ct = getToken();
if (need && !ct) { message.warning('请完成人机验证'); return; } if (need && !ct) { message.warning('请完成人机验证'); return; }
await handleCreate(ct); await handleCreate(ct);
resetWidget(); resetWidget();
}
}} }}
onCancel={() => { setCreateModal(false); setNewId(""); setNewName(""); resetWidget(); }} okText="创建" destroyOnClose> onCancel={() => { setCreateModal(false); setNewId(""); setNewName(""); setCreateProof(""); resetWidget(); }} okText="创建" destroyOnClose>
<Space direction="vertical" style={{ width: "100%" }}> <Space direction="vertical" style={{ width: "100%" }}>
<div> <div>
<label htmlFor="new-scale-id" style={{ display: "block", marginBottom: 4 }}>ID</label> <label htmlFor="new-scale-id" style={{ display: "block", marginBottom: 4 }}>ID</label>
@@ -213,7 +218,11 @@ const MyScales: React.FC = () => {
<label htmlFor="new-scale-name" style={{ display: "block", marginBottom: 4 }}></label> <label htmlFor="new-scale-name" style={{ display: "block", marginBottom: 4 }}></label>
<Input id="new-scale-name" value={newName} onChange={e => setNewName(e.target.value)} placeholder="如 我的自评量表" /> <Input id="new-scale-name" value={newName} onChange={e => setNewName(e.target.value)} placeholder="如 我的自评量表" />
</div> </div>
{captchaMode === 'builtin' ? (
<BuiltinCaptchaInline proof={createProof} onProofChange={setCreateProof} />
) : (
<div id="cf-widget-create" style={{ display: 'flex', justifyContent: 'center', minHeight: 50 }} /> <div id="cf-widget-create" style={{ display: 'flex', justifyContent: 'center', minHeight: 50 }} />
)}
</Space> </Space>
</Modal> </Modal>
@@ -227,19 +236,17 @@ const MyScales: React.FC = () => {
)} )}
</Modal> </Modal>
<Modal title="人机验证" open={!!captchaState} <CaptchaModal
onOk={async () => { open={!!captchaState}
const token = getToken(); mode={captchaMode}
if (!token) { message.warning('请完成验证'); return; } siteKey={siteKey}
onOk={async (proof) => {
const fn = captchaState?.fn; const fn = captchaState?.fn;
setCaptchaState(null); setCaptchaState(null);
if (fn) await fn(token); if (fn) await fn(proof);
}} }}
onCancel={() => { setCaptchaState(null); resetWidget(); }} onCancel={() => { setCaptchaState(null); }}
okText="确认" width={380} destroyOnClose> />
<p style={{ textAlign: 'center', marginBottom: 16 }}></p>
<div id="cf-widget" style={{ display: 'flex', justifyContent: 'center', minHeight: 65 }} />
</Modal>
</div> </div>
); );
}; };
+47 -2
View File
@@ -1,14 +1,16 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Card, Row, Col, Input, Segmented, Empty, Typography, Space, Button, Tag, message } from 'antd'; import { Card, Row, Col, Input, Segmented, Empty, Typography, Space, Button, Tag, message, Modal } from 'antd';
import { import {
ClockCircleOutlined, ClockCircleOutlined,
FileTextOutlined, FileTextOutlined,
SearchOutlined, SearchOutlined,
PlayCircleOutlined, PlayCircleOutlined,
ShareAltOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAppStore } from '../store'; import { useAppStore } from '../store';
import { scaleApi } from '../api'; import { scaleApi } from '../api';
import type { ScaleSummary } from '../types';
const { Text, Paragraph } = Typography; const { Text, Paragraph } = Typography;
@@ -34,6 +36,7 @@ const ScaleList: React.FC = () => {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [activeCategory, setActiveCategory] = useState<string | null>(null); const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [startingId, setStartingId] = useState<string | null>(null); const [startingId, setStartingId] = useState<string | null>(null);
const [shareTarget, setShareTarget] = useState<{ url: string; name: string } | null>(null);
const categories = Array.from(new Set(scales.map(s => s.category))); const categories = Array.from(new Set(scales.map(s => s.category)));
@@ -56,6 +59,20 @@ const ScaleList: React.FC = () => {
} }
}; };
const handleShare = async (scale: ScaleSummary) => {
const url = `${window.location.origin}/s/${scale.id}`;
try {
if (!navigator.clipboard?.writeText) {
throw new Error('Clipboard API 不可用');
}
await navigator.clipboard.writeText(url);
message.success('分享链接已复制');
} catch {
// 降级:无剪贴板权限/非 https 等,弹窗展示链接供手动复制
setShareTarget({ url, name: scale.name });
}
};
return ( return (
<div> <div>
<div style={{ marginBottom: 24 }}> <div style={{ marginBottom: 24 }}>
@@ -127,6 +144,7 @@ const ScaleList: React.FC = () => {
<ClockCircleOutlined /> {scale.duration} <ClockCircleOutlined /> {scale.duration}
</Text> </Text>
</div> </div>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Button <Button
type="primary" type="primary"
size="small" size="small"
@@ -134,10 +152,19 @@ const ScaleList: React.FC = () => {
loading={starting} loading={starting}
disabled={!!startingId && !starting} disabled={!!startingId && !starting}
onClick={(e) => { e.stopPropagation(); handleStart(scale.id); }} onClick={(e) => { e.stopPropagation(); handleStart(scale.id); }}
style={{ marginTop: 8 }}
> >
</Button> </Button>
<Button
size="small"
icon={<ShareAltOutlined />}
aria-label={`分享:${scale.name}`}
onClick={(e) => { e.stopPropagation(); handleShare(scale); }}
onKeyDown={(e) => e.stopPropagation()}
>
</Button>
</div>
</Space> </Space>
</Card> </Card>
</Col> </Col>
@@ -145,6 +172,24 @@ const ScaleList: React.FC = () => {
})} })}
</Row> </Row>
)} )}
<Modal
title={`分享量表:${shareTarget?.name ?? ''}`}
open={!!shareTarget}
onCancel={() => setShareTarget(null)}
footer={null}
width={440}
destroyOnHidden
>
<Paragraph type="secondary" style={{ marginBottom: 12 }}>
</Paragraph>
<Input
readOnly
value={shareTarget?.url}
onFocus={(e) => e.target.select()}
/>
</Modal>
</div> </div>
); );
}; };
+41 -5
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Card, Form, Input, Button, Typography, message, Space, Tag, Spin, Switch, InputNumber, Select } from 'antd'; import { Card, Form, Input, Button, Typography, message, Space, Tag, Spin, Switch, InputNumber, Select } from 'antd';
import { SaveOutlined, MailOutlined, RobotOutlined, LockOutlined, SendOutlined, SafetyCertificateOutlined, IdcardOutlined } from '@ant-design/icons'; import { SaveOutlined, MailOutlined, RobotOutlined, LockOutlined, SendOutlined, SafetyCertificateOutlined, IdcardOutlined, WarningOutlined } from '@ant-design/icons';
import { adminApi, aiApi } from '../../api'; import { adminApi, aiApi } from '../../api';
const { Title, Text } = Typography; const { Title, Text } = Typography;
@@ -34,6 +34,11 @@ const AdminSettings: React.FC = () => {
for (const k of secretKeys) { for (const k of secretKeys) {
if (!body[k]) delete body[k]; if (!body[k]) delete body[k];
} }
// GET 返回的 *_configured 是派生布尔(仅展示配置态),不是设置项:
// 回 POST 会让后端写成陈旧行,且 node:sqlite 布尔绑定会抛错导致保存失败,剔除之。
for (const k of ['turnstile_configured', 'smtp_configured', 'ai_configured']) {
delete body[k];
}
await adminApi.saveSettings(body); await adminApi.saveSettings(body);
setDirty(false); setDirty(false);
message.success('所有设置已保存'); message.success('所有设置已保存');
@@ -74,6 +79,8 @@ const AdminSettings: React.FC = () => {
const aiConfigured = settings.ai_configured; const aiConfigured = settings.ai_configured;
const smtpConfigured = settings.smtp_configured; const smtpConfigured = settings.smtp_configured;
// RainID 回调/登出地址均基于 site_url 生成;未填时给出占位提示
const siteUrl = settings.site_url?.trim() || '<站点域名>';
return ( return (
<div> <div>
@@ -333,7 +340,7 @@ const AdminSettings: React.FC = () => {
<Space> <Space>
<IdcardOutlined style={{ color: '#1e3a5f' }} /> <IdcardOutlined style={{ color: '#1e3a5f' }} />
<span>RainID </span> <span>RainID </span>
{settings.rainid_enabled === '1' ? <Tag color="success"></Tag> : <Tag></Tag>} {settings.rainid_configured ? <Tag color="success"></Tag> : <Tag></Tag>}
</Space> </Space>
} }
style={{ borderRadius: 12 }} style={{ borderRadius: 12 }}
@@ -344,22 +351,51 @@ const AdminSettings: React.FC = () => {
<Text type="secondary" style={{ display: 'block', marginBottom: 16 }}> <Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
RainID ROPC SSO使 RainID RainID ROPC SSO使 RainID
</Text> </Text>
<div style={{ marginBottom: 16, padding: '12px 16px', background: '#f0f9ff', borderLeft: '3px solid #1e3a5f', borderRadius: 8 }}>
<Text strong style={{ fontSize: 13, color: '#1e293b', display: 'block', marginBottom: 6 }}> RainID Admin </Text>
<Text style={{ fontSize: 12, color: '#475569', display: 'block', marginBottom: 2 }}>
<Text code>{siteUrl}/api/auth/oidc/callback</Text>
</Text>
<Text style={{ fontSize: 12, color: '#475569', display: 'block', marginBottom: 2 }}>
<Text code>{siteUrl}/login?oidc_logout=1</Text> query
</Text>
<Text style={{ fontSize: 12, color: '#475569', display: 'block', marginBottom: 2 }}>
ROPC
</Text>
<Text style={{ fontSize: 12, color: '#475569', display: 'block' }}>
</Text>
</div>
{settings.rainid_enabled === '1' && !settings.rainid_configured && (
<div style={{ marginBottom: 16, padding: '12px 16px', background: '#fffbeb', borderLeft: '3px solid #b45309', borderRadius: 8, display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<WarningOutlined style={{ color: '#b45309', marginTop: 2 }} />
<Text style={{ fontSize: 12, color: '#475569' }}>
<Text strong style={{ color: '#92400e' }}>RainID </Text>
Client Secret Client Secret
</Text>
</div>
)}
<Form layout="vertical" style={{ maxWidth: 600 }}> <Form layout="vertical" style={{ maxWidth: 600 }}>
<Form.Item label="注册托管" help="开启后注册 Tab 整页跳转 RainID 注册页,不再显示本地注册表单"> <Form.Item label="注册托管" help="开启后注册 Tab 整页跳转 RainID 注册页,不再显示本地注册表单">
<Switch checked={settings.rainid_register_redirect === '1'} onChange={v => updateSetting('rainid_register_redirect', v ? '1' : '0')} /> <Switch checked={settings.rainid_register_redirect === '1'} onChange={v => updateSetting('rainid_register_redirect', v ? '1' : '0')} />
</Form.Item> </Form.Item>
<Form.Item label="Client ID"> <Form.Item label="Client ID" help="RainID Admin 应用登记的 Client ID(非机密)">
<Input value={settings.rainid_client_id || ''} <Input value={settings.rainid_client_id || ''}
onChange={e => updateSetting('rainid_client_id', e.target.value)} onChange={e => updateSetting('rainid_client_id', e.target.value)}
placeholder="RainID 应用 Client ID(非机密)" /> placeholder="RainID 应用 Client ID(非机密)" />
{settings.rainid_client_id && (
<div style={{ marginTop: 4 }}>
<Tag color="#15803d" style={{ fontSize: 11 }}></Tag>
</div>
)}
</Form.Item> </Form.Item>
<Form.Item label="Client Secret(机密)" help="保存在服务端且不回读;留空提交则保持原值不变"> <Form.Item label="Client Secret(机密)" help="保存在服务端且不回读,首次保存后无法回显;留空提交则保持原值不变,须与 RainID Admin 登记的应用 secret 一致">
<Input.Password value={settings.rainid_client_secret || ''} <Input.Password value={settings.rainid_client_secret || ''}
onChange={e => updateSetting('rainid_client_secret', e.target.value)} onChange={e => updateSetting('rainid_client_secret', e.target.value)}
placeholder="••••••••••••" placeholder="••••••••••••"
autoComplete="new-password" /> autoComplete="new-password" />
</Form.Item> </Form.Item>
<Form.Item label="Discovery URL"> <Form.Item label="Discovery URL" help="OIDC Discovery 端点,默认 https://rainid.rainnya.asia/oauth,一般无需修改">
<Input value={settings.rainid_discovery_url || ''} <Input value={settings.rainid_discovery_url || ''}
onChange={e => updateSetting('rainid_discovery_url', e.target.value)} onChange={e => updateSetting('rainid_discovery_url', e.target.value)}
placeholder="https://rainid.rainnya.asia/oauth" /> placeholder="https://rainid.rainnya.asia/oauth" />
+42 -1
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { Card, Tabs, Button, Space, message, Modal, Input, Spin, Tag } from "antd"; import { Card, Tabs, Button, Space, message, Modal, Input, Spin, Tag, Segmented } from "antd";
import { SaveOutlined, ExportOutlined, ImportOutlined, ArrowLeftOutlined, ExperimentOutlined } from "@ant-design/icons"; import { SaveOutlined, ExportOutlined, ImportOutlined, ArrowLeftOutlined, ExperimentOutlined } from "@ant-design/icons";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { adminApi } from "../../api"; import { adminApi } from "../../api";
@@ -50,6 +50,8 @@ const ScaleEditor: React.FC = () => {
const [validation, setValidation] = useState<ValidationResult | null>(null); const [validation, setValidation] = useState<ValidationResult | null>(null);
const [validating, setValidating] = useState(false); const [validating, setValidating] = useState(false);
const [activeTab, setActiveTab] = useState("basic"); const [activeTab, setActiveTab] = useState("basic");
// P1-3dirty 跟踪(切换确认 + beforeunload
const [dirty, setDirty] = useState(false);
useEffect(() => { useEffect(() => {
if (editId) { if (editId) {
@@ -58,6 +60,7 @@ const ScaleEditor: React.FC = () => {
.getRawScale(editId) .getRawScale(editId)
.then((data) => { .then((data) => {
setScale(data); setScale(data);
setDirty(false);
return adminApi.validateScale(data); return adminApi.validateScale(data);
}) })
.then((vr) => setValidation(vr)) .then((vr) => setValidation(vr))
@@ -83,10 +86,12 @@ const ScaleEditor: React.FC = () => {
const updateScale = useCallback((updates: Partial<Scale>) => { const updateScale = useCallback((updates: Partial<Scale>) => {
setScale((prev) => ({ ...prev, ...updates })); setScale((prev) => ({ ...prev, ...updates }));
setDirty(true);
}, []); }, []);
const updateMeta = useCallback((metaUpdates: Partial<Scale["meta"]>) => { const updateMeta = useCallback((metaUpdates: Partial<Scale["meta"]>) => {
setScale((prev) => ({ ...prev, meta: { ...prev.meta, ...metaUpdates } })); setScale((prev) => ({ ...prev, meta: { ...prev.meta, ...metaUpdates } }));
setDirty(true);
}, []); }, []);
const handleSave = async () => { const handleSave = async () => {
@@ -110,6 +115,7 @@ const ScaleEditor: React.FC = () => {
try { try {
const res = await adminApi.saveScale(scale.meta.id, scale); const res = await adminApi.saveScale(scale.meta.id, scale);
if (res.data?.validation) setValidation(res.data.validation); if (res.data?.validation) setValidation(res.data.validation);
setDirty(false);
message.success("保存成功"); message.success("保存成功");
loadScales(); loadScales();
if (!editId) navigate(`/admin/scale-editor?id=${scale.meta.id}`); if (!editId) navigate(`/admin/scale-editor?id=${scale.meta.id}`);
@@ -143,6 +149,7 @@ const ScaleEditor: React.FC = () => {
return; return;
} }
setScale(parsed); setScale(parsed);
setDirty(true);
setImportVisible(false); setImportVisible(false);
setImportText(""); setImportText("");
message.success("导入成功"); message.success("导入成功");
@@ -177,6 +184,33 @@ const ScaleEditor: React.FC = () => {
const hasError = validation && !validation.valid; const hasError = validation && !validation.valid;
// P1-3:切 v2 前若有脏修改 → 确认(防静默丢弃)
const handleSwitchVersion = () => {
const go = () => navigate(`/admin/scale-editor-v2?id=${editId || scale.meta.id || ""}`);
if (dirty) {
Modal.confirm({
title: "有未保存修改",
content: "切换编辑器将丢失未保存的修改,确定继续?",
okText: "继续切换",
cancelText: "取消",
onOk: go,
});
} else {
go();
}
};
// P1-3dirty 时挂 beforeunload(关闭/刷新标签页提示)
useEffect(() => {
if (!dirty) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = "";
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [dirty]);
if (loading) return <Spin size="large" style={{ display: "block", margin: "100px auto" }} />; if (loading) return <Spin size="large" style={{ display: "block", margin: "100px auto" }} />;
return ( return (
@@ -188,6 +222,13 @@ const ScaleEditor: React.FC = () => {
{editId && <Tag color="blue">{scale.meta.id}</Tag>} {editId && <Tag color="blue">{scale.meta.id}</Tag>}
{scale.$schema && <Tag color="green">{scale.$schema}</Tag>} {scale.$schema && <Tag color="green">{scale.$schema}</Tag>}
<span style={{ color: "#94a3b8", fontSize: 13 }}>{scale.questions.length} </span> <span style={{ color: "#94a3b8", fontSize: 13 }}>{scale.questions.length} </span>
<Segmented
value="v1"
options={[{ label: "v1 编辑器", value: "v1" }, { label: "v2 编辑器", value: "v2" }]}
onChange={(v) => {
if (v === "v2") handleSwitchVersion();
}}
/>
</Space> </Space>
<Space> <Space>
{validation && ( {validation && (
+557
View File
@@ -0,0 +1,557 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { Button, Space, message, Input, Spin, Tag, Segmented, Alert, Typography, Modal } from "antd";
import {
SaveOutlined, PlusOutlined, SearchOutlined, ArrowLeftOutlined,
UndoOutlined, RedoOutlined, ExperimentOutlined, LoadingOutlined, CheckCircleOutlined,
} from "@ant-design/icons";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useAppStore } from "../../store";
import { adminApi } from "../../api";
import type { Scale, ValidationResult, ValidationIssue, Question, FactorDef } from "../../types";
import ValidationBar from "../../components/ValidationBar";
import QuestionList from "../../components/QuestionList";
import QuestionCheckView from "../../components/QuestionCheckView";
import { useScaleHistory } from "../../components/useScaleHistory";
const defaultScale: Scale = {
$schema: "psytest-v2",
meta: {
id: "", name: "", nameEn: "", version: "1.0",
author: "", year: new Date().getFullYear(), category: "",
description: "", timeRange: "", duration: "",
},
settings: { shuffle: false, required: true, showProgress: true, allowBack: true, allowSkip: false, dataCollection: "none", dataFields: ["gender", "age"], dataPrompt: "", requireDemographics: false },
responseOptions: {
type: "likert",
options: [
{ value: 0, label: "完全没有" },
{ value: 1, label: "几天" },
{ value: 2, label: "一半以上天数" },
{ value: 3, label: "几乎每天" },
],
},
questions: [],
scoring: { method: "sum", ranges: [] },
interpretation: { rules: [] },
};
const { Text } = Typography;
/** 在 factors 的 items 中找到包含该题目 id 的因子(MMPI 模型) */
function findFactorByItemId(factors: Record<string, FactorDef> | undefined, id: number): string | undefined {
for (const [f, def] of Object.entries(factors || {})) {
if (def.items?.includes(id)) return f;
}
return undefined;
}
/**
* P1-1:改因子时同步迁移 scoring.factors[].items。
* 目标因子在 scoring.factors 中定义 → 从旧因子 items 移除并追加到新因子 items;
* 旧归属来自 items(q.factor 为空)同样移除;目标/来源为自由 factor 标记则只写 q.factor。
*/
function applyFactorChange(prev: Scale, questionId: number, oldFactor: string | undefined, newFactor: string | undefined): Scale {
const factors = prev.scoring?.factors;
if (!factors) return prev;
const nextFactors: Record<string, FactorDef> = {};
for (const [k, v] of Object.entries(factors)) {
nextFactors[k] = v.items ? { ...v, items: [...v.items] } : { ...v };
}
if (oldFactor && nextFactors[oldFactor]) {
nextFactors[oldFactor] = {
...nextFactors[oldFactor],
items: (nextFactors[oldFactor].items || []).filter((id) => id !== questionId),
};
}
if (newFactor && nextFactors[newFactor]) {
const items = nextFactors[newFactor].items || [];
if (!items.includes(questionId)) items.push(questionId);
nextFactors[newFactor] = { ...nextFactors[newFactor], items };
}
return { ...prev, scoring: { ...prev.scoring, factors: nextFactors } };
}
/** v2 可视化量表编辑器(卡片流 + 虚拟列表 + 拖拽 + 分组 + 批量 + 撤销 + 核对视图 + 草稿) */
const ScaleEditorV2: React.FC = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const editId = searchParams.get("id");
const { setPreviewScale } = useAppStore();
const { scale, commit, replace, undo, redo, canUndo, canRedo } = useScaleHistory(defaultScale);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [validation, setValidation] = useState<ValidationResult | null>(null);
const [validating, setValidating] = useState(false);
const [dirty, setDirty] = useState(false);
const [savedAt, setSavedAt] = useState<number | null>(null);
const [search, setSearch] = useState("");
const [view, setView] = useState<"card" | "check">("card");
const [scrollTarget, setScrollTarget] = useState<{ id: number; nonce: number }>({ id: -1, nonce: 0 });
const [draftPrompt, setDraftPrompt] = useState<{ updatedAt: number; scale: Scale } | null>(null);
const draftKey = scale.meta.id ? `scale-draft-${scale.meta.id}` : null;
// P1-3:保存时对比「加载时的服务端版本」与「当前服务端版本」,检测双开覆盖
const loadedJsonRef = useRef<string | null>(null);
// 加载(与 v1 ScaleEditor 同模式;完成后检查本地草稿,避免与异步加载竞态)
useEffect(() => {
if (!editId) return;
setLoading(true);
adminApi
.getRawScale(editId)
.then((data) => {
loadedJsonRef.current = JSON.stringify(data);
replace(data);
return adminApi.validateScale(data);
})
.then((vr) => setValidation(vr))
.catch(() => {
message.error("加载量表失败");
navigate("/admin/scales");
})
.finally(() => {
setLoading(false);
try {
const raw = localStorage.getItem(`scale-draft-${editId}`);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed?.scale && parsed?.updatedAt) setDraftPrompt(parsed);
}
} catch { /* 草稿损坏则忽略 */ }
});
}, [editId, replace, navigate]);
// 800ms 防抖校验(与 v1 同模式)
useEffect(() => {
if (scale.meta.id) {
setValidating(true);
const timer = setTimeout(() => {
adminApi.validateScale(scale).then(setValidation).finally(() => setValidating(false));
}, 800);
return () => clearTimeout(timer);
} else {
setValidation(null);
}
}, [scale]);
// 本地草稿:每 5s 写入(仅 dirty 且有 id 时)
useEffect(() => {
if (!dirty || !draftKey) return;
const timer = setInterval(() => {
try {
localStorage.setItem(draftKey, JSON.stringify({ updatedAt: Date.now(), scale }));
} catch { /* 存储满等异常忽略 */ }
}, 5000);
return () => clearInterval(timer);
}, [dirty, draftKey, scale]);
const updateScale = useCallback((updates: Partial<Scale>) => {
commit((prev) => ({ ...prev, ...updates }));
setDirty(true);
}, [commit]);
const questions = scale.questions;
const maxId = questions.length > 0 ? Math.max(...questions.map((q) => q.id)) : 0;
const allFactors = useMemo(() => Array.from(new Set([
...Object.keys(scale.scoring?.factors || {}),
...questions.map((q) => q.factor).filter((f): f is string => !!f),
])), [scale.scoring?.factors, questions]);
const weightMode = !!(scale.scoring?.weightedItems && scale.scoring.weightedItems.length > 0) ||
Object.values(scale.scoring?.factors || {}).some((f) => f.scoring?.weighted);
const hasError = validation && !validation.valid;
// P1-1q.factor 指向已定义因子但题目 id 不在其 items 中的数量(MMPI 天然多因子归属,只查主归属错位)
const factorInconsistencyCount = useMemo(() => {
const factors = scale.scoring?.factors;
if (!factors) return 0;
let n = 0;
for (const q of scale.questions) {
if (!q.factor) continue;
// 仅当 q.factor 是已定义因子、但 items 不含该题 id 时视为不一致(UI 分组与计分错位)
if (q.factor in factors && !(factors[q.factor].items?.includes(q.id))) n++;
}
return n;
}, [scale]);
// ===== 题目操作(绝不改动已有题目 ID;新题 ID = maxId + 1 =====
const handleAddQuestion = () => {
const newId = maxId + 1;
updateScale({ questions: [...questions, { id: newId, text: "新题目" }] });
setSearch("");
setScrollTarget({ id: newId, nonce: Date.now() });
};
const handleCommitQuestion = useCallback((index: number, patch: Partial<Question>) => {
// 题干防抖提交合并:连续 500ms 内的纯 text 提交只记一次快照
const merge = Object.keys(patch).length === 1 && "text" in patch;
commit((prev) => {
const copy = [...prev.questions];
if (index < 0 || index >= copy.length) return prev;
const q = copy[index];
const nextQ = { ...q, ...patch };
copy[index] = nextQ;
let next: Scale = { ...prev, questions: copy };
// P1-1:因子变更同步迁移 scoring.factors[].items
if ("factor" in patch && prev.scoring?.factors) {
const oldFactor = q.factor ?? findFactorByItemId(prev.scoring.factors, q.id);
next = applyFactorChange(next, q.id, oldFactor, nextQ.factor);
}
return next;
}, { merge });
setDirty(true);
}, [commit]);
const handleDeleteQuestion = useCallback((index: number) => {
commit((prev) => ({
...prev,
questions: prev.questions.filter((_, i) => i !== index),
}));
setDirty(true);
}, [commit]);
const handleDuplicateQuestion = useCallback((index: number) => {
commit((prev) => {
const src = prev.questions[index];
if (!src) return prev;
const copyId = prev.questions.length > 0 ? Math.max(...prev.questions.map((q) => q.id)) + 1 : 1;
const copy = { ...src, id: copyId, text: src.text ? `${src.text}(副本)` : "新题目(副本)" };
const next = [...prev.questions];
next.splice(index + 1, 0, copy);
return { ...prev, questions: next };
});
setDirty(true);
}, [commit]);
const handleMoveQuestion = useCallback((index: number, dir: -1 | 1) => {
commit((prev) => {
const next = [...prev.questions];
const newIndex = index + dir;
if (newIndex < 0 || newIndex >= next.length) return prev;
[next[index], next[newIndex]] = [next[newIndex], next[index]];
return { ...prev, questions: next };
});
setDirty(true);
}, [commit]);
const handleReorder = useCallback((oldIndex: number, newIndex: number) => {
if (oldIndex === newIndex) return;
commit((prev) => {
const next = [...prev.questions];
const [moved] = next.splice(oldIndex, 1);
next.splice(newIndex, 0, moved);
return { ...prev, questions: next };
});
setDirty(true);
}, [commit]);
// 跨组拖拽:重排 + 修改被拖题的 factor(同步迁移 items
const handleDropIntoGroup = useCallback((movedIndex: number, targetGroup: string, targetQuestionIndex?: number) => {
commit((prev) => {
const next = [...prev.questions];
if (movedIndex < 0 || movedIndex >= next.length) return prev;
const [moved] = next.splice(movedIndex, 1);
const newFactor = targetGroup === "未分组" ? undefined : targetGroup;
let insertAt: number;
if (targetQuestionIndex === undefined) {
// 落到组头:置于该组第一题之前;组空则末尾
const firstIdx = next.findIndex((q) => (q.factor || findFactorByItemId(prev.scoring?.factors, q.id) || "未分组") === targetGroup);
insertAt = firstIdx === -1 ? next.length : firstIdx;
} else {
insertAt = Math.max(0, targetQuestionIndex > movedIndex ? targetQuestionIndex - 1 : targetQuestionIndex);
}
next.splice(insertAt, 0, { ...moved, factor: newFactor });
let result: Scale = { ...prev, questions: next };
if (prev.scoring?.factors) {
const oldFactor = moved.factor ?? findFactorByItemId(prev.scoring.factors, moved.id);
result = applyFactorChange(result, moved.id, oldFactor, newFactor);
}
return result;
});
setDirty(true);
}, [commit]);
// ===== 批量操作 =====
const handleBatchSetFactor = useCallback((ids: number[], factor?: string) => {
commit((prev) => {
let nextScoring: Scale = prev;
const questions = prev.questions.map((q) => {
if (!ids.includes(q.id)) return q;
// P1-1:批量设因子同步迁移 items
if (prev.scoring?.factors) {
const oldFactor = q.factor ?? findFactorByItemId(prev.scoring.factors, q.id);
nextScoring = applyFactorChange(nextScoring, q.id, oldFactor, factor);
}
return { ...q, factor };
});
return { ...nextScoring, questions };
});
setDirty(true);
}, [commit]);
const handleBatchSetReverse = useCallback((ids: number[], reverse: boolean) => {
commit((prev) => ({
...prev,
questions: prev.questions.map((q) => (ids.includes(q.id) ? { ...q, reverse } : q)),
}));
setDirty(true);
}, [commit]);
const handleBatchDelete = useCallback((ids: number[]) => {
commit((prev) => ({
...prev,
questions: prev.questions.filter((q) => !ids.includes(q.id)),
}));
setDirty(true);
}, [commit]);
const handleBatchDuplicate = useCallback((ids: number[]) => {
commit((prev) => {
const maxIdNow = prev.questions.length > 0 ? Math.max(...prev.questions.map((q) => q.id)) : 0;
let nextId = maxIdNow + 1;
const copies: Question[] = [];
for (const q of prev.questions) {
if (ids.includes(q.id)) {
copies.push({ ...q, id: nextId++, text: q.text ? `${q.text}(副本)` : "新题目(副本)" });
}
}
return { ...prev, questions: [...prev.questions, ...copies] };
});
setDirty(true);
}, [commit]);
// ===== 保存 / 预览 =====
const handleSave = async () => {
if (!scale.meta.id) { message.error("请先填写量表ID(在 v1 编辑器的基本信息中设置)"); return; }
if (!scale.meta.name) { message.error("请填写量表名称"); return; }
if (scale.questions.length === 0) { message.error("请添加至少一道题目"); return; }
setSaving(true);
try {
// P1-3:保存前版本冲突检测——对比加载时的服务端版本与当前服务端版本
if (editId && loadedJsonRef.current !== null) {
const fresh = await adminApi.getRawScale(editId);
const freshJson = JSON.stringify(fresh);
if (freshJson !== loadedJsonRef.current) {
message.error("量表已在其他窗口被修改,请刷新后重试");
setSaving(false);
return;
}
}
const res = await adminApi.saveScale(scale.meta.id, scale);
if (res.data?.validation) setValidation(res.data.validation);
setDirty(false);
setSavedAt(Date.now());
loadedJsonRef.current = JSON.stringify(scale);
if (draftKey) localStorage.removeItem(draftKey);
message.success("保存成功");
} catch (e: any) {
const errData = e.response?.data;
if (errData?.error?.validation) {
setValidation(errData.error.validation);
message.error("保存失败:量表结构校验未通过");
} else {
message.error(errData?.error?.message || "保存失败");
}
} finally {
setSaving(false);
}
};
const handlePreview = async () => {
try {
const vr = await adminApi.validateScale(scale);
if (!vr.valid) message.warning("量表存在校验错误,预览可能不完整");
} catch { /* 校验失败不阻塞预览 */ }
setPreviewScale(scale);
navigate("/admin/scale-editor/preview");
};
const handleRestoreDraft = () => {
if (draftPrompt) {
replace(draftPrompt.scale);
setDirty(true);
setDraftPrompt(null);
message.success("已恢复草稿");
}
};
const handleDiscardDraft = () => {
if (draftKey) localStorage.removeItem(draftKey);
setDraftPrompt(null);
};
const handleIssueClick = (issue: ValidationIssue) => {
if (issue.field && issue.field.startsWith("questions.")) {
const idx = parseInt(issue.field.split(".")[1], 10);
const q = questions[idx];
if (q) setScrollTarget({ id: q.id, nonce: Date.now() });
}
};
const handleJumpToCard = (id: number) => {
setSearch(`#${id}`);
setView("card");
setScrollTarget({ id, nonce: Date.now() });
};
// P1-3:切 v1 前若有脏修改 → 确认(防静默丢弃)
const handleSwitchVersion = (target: "v1" | "v2") => {
const go = () => navigate(`/admin/scale-editor${target === "v2" ? "-v2" : ""}?id=${editId || scale.meta.id || ""}`);
if (dirty) {
Modal.confirm({
title: "有未保存修改",
content: "切换编辑器将丢失未保存的修改,确定继续?",
okText: "继续切换",
cancelText: "取消",
onOk: go,
});
} else {
go();
}
};
// P1-3dirty 时挂 beforeunload(关闭/刷新标签页提示)
useEffect(() => {
if (!dirty) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = "";
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [dirty]);
if (loading) return <Spin size="large" style={{ display: "block", margin: "100px auto" }} />;
return (
<div>
{/* 顶部工具栏 */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12, flexWrap: "wrap", gap: 8 }}>
<Space wrap>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate("/admin/scales")}></Button>
<h2 style={{ margin: 0, fontSize: 18 }}>v2 </h2>
{editId && <Tag color="blue">{scale.meta.id}</Tag>}
<span style={{ color: "#64748b", fontSize: 13 }}>{scale.questions.length} </span>
{validation && (
<Tag
color={hasError ? "red" : (validation.warnings.length > 0 ? "orange" : "success")}
style={{ fontSize: 12 }}
>
{hasError ? `错误 ${validation.errors.length}` : (validation.warnings.length > 0 ? `警告 ${validation.warnings.length}` : "通过")}
</Tag>
)}
{validating && <Spin size="small" />}
<Segmented
value="v2"
options={[{ label: "v1 编辑器", value: "v1" }, { label: "v2 编辑器", value: "v2" }]}
onChange={(v) => {
if (v === "v1") handleSwitchVersion("v1");
}}
/>
<Segmented
value={view}
options={[{ label: "卡片视图", value: "card" }, { label: "核对视图", value: "check" }]}
onChange={(v) => setView(v as "card" | "check")}
/>
</Space>
<Space wrap>
<Button icon={<UndoOutlined />} onClick={undo} disabled={!canUndo}></Button>
<Button icon={<RedoOutlined />} onClick={redo} disabled={!canRedo}></Button>
<Input
prefix={<SearchOutlined />}
placeholder="搜索题干 / #题号"
value={search}
onChange={(e) => setSearch(e.target.value)}
allowClear
style={{ width: 200, borderRadius: 8 }}
/>
<Button icon={<ExperimentOutlined />} onClick={handlePreview} disabled={scale.questions.length === 0}></Button>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddQuestion}
style={{ background: "#1e3a5f", border: "none" }}></Button>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSave}
loading={saving}
disabled={!dirty || (hasError ?? false)}
style={{ background: hasError ? undefined : "#1e3a5f", border: "none" }}
>
</Button>
{saving ? (
<Text style={{ fontSize: 12, color: "#1e3a5f" }}><LoadingOutlined /> </Text>
) : dirty ? (
<Text style={{ fontSize: 12, color: "#b45309" }}> </Text>
) : savedAt ? (
<Text style={{ fontSize: 12, color: "#15803d" }}><CheckCircleOutlined /> </Text>
) : null}
</Space>
</div>
{draftPrompt && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 12, borderRadius: 8 }}
message="检测到未保存草稿"
description={'该量表存在本地未保存的编辑草稿(上次编辑于 ' + new Date(draftPrompt.updatedAt).toLocaleString() + ')。'}
action={
<Space>
<Button size="small" type="primary" onClick={handleRestoreDraft}>稿</Button>
<Button size="small" onClick={handleDiscardDraft}></Button>
</Space>
}
/>
)}
<ValidationBar result={validation} onIssueClick={handleIssueClick} />
{factorInconsistencyCount > 0 && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 12, borderRadius: 8 }}
message={`${factorInconsistencyCount} 题的因子标记与计分因子表不一致`}
description="部分题目的 q.factor 与 scoring.factors[].items 归属不一致,保存后评分引擎仍按 items 计算。建议核对因子归属(不阻断保存)。"
/>
)}
{view === "card" ? (
<QuestionList
questions={scale.questions}
search={search}
allFactors={allFactors}
scoringFactors={scale.scoring?.factors}
defaultOptions={scale.responseOptions.options}
weightMode={weightMode}
scrollToId={scrollTarget.id}
scrollNonce={scrollTarget.nonce}
onCommitQuestion={handleCommitQuestion}
onDeleteQuestion={handleDeleteQuestion}
onDuplicateQuestion={handleDuplicateQuestion}
onMoveQuestion={handleMoveQuestion}
onReorder={handleReorder}
onDropIntoGroup={handleDropIntoGroup}
onBatchSetFactor={handleBatchSetFactor}
onBatchSetReverse={handleBatchSetReverse}
onBatchDelete={handleBatchDelete}
onBatchDuplicate={handleBatchDuplicate}
/>
) : (
<QuestionCheckView
questions={scale.questions}
allFactors={allFactors}
weightMode={weightMode}
onCommitQuestion={handleCommitQuestion}
onJumpToCard={handleJumpToCard}
/>
)}
</div>
);
};
export default ScaleEditorV2;
+4 -2
View File
@@ -1,6 +1,6 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Table, Card, Tag, Input, Button, Space, Typography, message, Popconfirm, Modal } from "antd"; import { Table, Card, Tag, Input, Button, Space, Typography, message, Popconfirm, Modal } from "antd";
import { ReloadOutlined, SearchOutlined, PlusOutlined, EditOutlined, DeleteOutlined, CopyOutlined } from "@ant-design/icons"; import { ReloadOutlined, SearchOutlined, PlusOutlined, EditOutlined, DeleteOutlined, CopyOutlined, ExperimentOutlined } from "@ant-design/icons";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useAppStore } from "../../store"; import { useAppStore } from "../../store";
import { adminApi } from "../../api"; import { adminApi } from "../../api";
@@ -61,10 +61,12 @@ const ScaleManagement: React.FC = () => {
{ title: "题数", dataIndex: "questionCount", width: 60, render: (n: number) => <strong>{n}</strong> }, { title: "题数", dataIndex: "questionCount", width: 60, render: (n: number) => <strong>{n}</strong> },
{ title: "时长", dataIndex: "duration", width: 100 }, { title: "时长", dataIndex: "duration", width: 100 },
{ {
title: "操作", width: 200, title: "操作", width: 300,
render: (_: any, record: any) => ( render: (_: any, record: any) => (
<Space> <Space>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => navigate(`/admin/scale-editor?id=${record.id}`)}></Button> <Button type="link" size="small" icon={<EditOutlined />} onClick={() => navigate(`/admin/scale-editor?id=${record.id}`)}></Button>
<Button type="primary" size="small" icon={<ExperimentOutlined />} onClick={() => navigate(`/admin/scale-editor-v2?id=${record.id}`)}
style={{ background: "#1e3a5f" }}>v2编辑器</Button>
<Button type="link" size="small" icon={<CopyOutlined />} onClick={() => { <Button type="link" size="small" icon={<CopyOutlined />} onClick={() => {
setCloneModal({ visible: true, sourceId: record.id }); setCloneModal({ visible: true, sourceId: record.id });
setNewCloneId(`${record.id}-copy`); setNewCloneId(`${record.id}-copy`);
@@ -1,7 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Button, Input, InputNumber, Table, Space, Modal, message, Select, Form, Typography, Card } from 'antd'; import { Button, Input, InputNumber, Table, Space, Modal, message, Select, Form, Typography, Card } from 'antd';
import { PlusOutlined, DeleteOutlined, ImportOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons'; import { PlusOutlined, DeleteOutlined, ImportOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
import type { Scale, Question, ResponseOption } from '../../../types'; import type { Scale, Question, ResponseOption, QuestionOption } from '../../../types';
const { Text } = Typography; const { Text } = Typography;
@@ -37,7 +37,7 @@ const QuestionsResponseTab: React.FC<Props> = ({ scale, updateScale }) => {
const [importVisible, setImportVisible] = useState(false); const [importVisible, setImportVisible] = useState(false);
const [importText, setImportText] = useState(''); const [importText, setImportText] = useState('');
const [editIdx, setEditIdx] = useState<number | null>(null); const [editIdx, setEditIdx] = useState<number | null>(null);
const [optionsModal, setOptionsModal] = useState<{ idx: number; options: { value: string; label: string; score?: number }[] } | null>(null); const [optionsModal, setOptionsModal] = useState<{ idx: number; options: QuestionOption[] } | null>(null);
const opts = scale.responseOptions; const opts = scale.responseOptions;
const questions = scale.questions; const questions = scale.questions;
+15 -1
View File
@@ -13,6 +13,13 @@ export interface ResponseOptions {
options: ResponseOption[]; options: ResponseOption[];
} }
/** 题目专属选项(value 兼容 mbti 的字符串 "A"/"B" 与数字分制) */
export interface QuestionOption {
value: string | number;
label: string;
score?: number;
}
export interface Question { export interface Question {
id: number; id: number;
text: string; text: string;
@@ -20,7 +27,14 @@ export interface Question {
reverse?: boolean; reverse?: boolean;
factor?: string; factor?: string;
weight?: number; weight?: number;
options?: { value: string; label: string; score?: number }[]; // 后端实际读取的字段(eq/epq 量表等):作答值→计分值映射、是/否计分方向、不计分题、分量表标记
scoreMap?: number[];
keyed?: '是' | '否';
filler?: boolean;
part?: string;
subscale?: string;
dimension?: string;
options?: QuestionOption[];
responseOptions?: ResponseOptions; responseOptions?: ResponseOptions;
} }
+5 -16
View File
@@ -6,7 +6,7 @@ export default defineConfig({
server: { server: {
port: 5173, port: 5173,
proxy: { proxy: {
'/api': 'http://localhost:3001', '/api': 'http://localhost:3501',
}, },
}, },
build: { build: {
@@ -49,22 +49,11 @@ export default defineConfig({
}, },
{ {
name: 'vendor-antd', name: 'vendor-antd',
test: /node_modules[\\/]antd[\\/]/, // antd 生态(antd + @ant-design/cssinjs + rc-* + emotion + stylis)必须同一 chunk
// antd 内部存在跨包循环依赖(antd↔cssinjs↔rc-*),拆分会导致运行时导出 undefined
// (线上事故:ESC 解构 undefined 白屏)。icons 无内部循环可独立(见 vendor-iconspriority 更高)。
test: /node_modules[\\/](antd|@ant-design|@rc-component|rc-|@emotion|stylis)[\\/]/,
priority: 50, priority: 50,
maxSize: 450 * 1024,
},
{
// antd 依赖的组件原语(@rc-component/*、rc-*
name: 'vendor-rc',
test: /node_modules[\\/](@rc-component|rc-)/,
priority: 40,
maxSize: 450 * 1024,
},
{
// @ant-design/cssinjs、@emotion、stylis 等样式运行时
name: 'vendor-antd-style',
test: /node_modules[\\/](@ant-design|@emotion|stylis)[\\/]/,
priority: 30,
}, },
], ],
}, },
+17
View File
@@ -7,9 +7,11 @@
"": { "": {
"name": "psytest-engine", "name": "psytest-engine",
"version": "1.0.0", "version": "1.0.0",
"hasInstallScript": true,
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.4.2",
"express": "^4.18.2", "express": "^4.18.2",
"express-async-errors": "^3.1.1", "express-async-errors": "^3.1.1",
"express-rate-limit": "^8.6.2", "express-rate-limit": "^8.6.2",
@@ -20,6 +22,9 @@
}, },
"devDependencies": { "devDependencies": {
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
},
"engines": {
"node": ">=23.4"
} }
}, },
"node_modules/accepts": { "node_modules/accepts": {
@@ -246,6 +251,18 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+3 -1
View File
@@ -9,11 +9,13 @@
"scripts": { "scripts": {
"start": "node server/index.js", "start": "node server/index.js",
"dev": "node server/index.js", "dev": "node server/index.js",
"smoke:rainid": "node scripts/rainid-smoke.js" "smoke:rainid": "node scripts/rainid-smoke.js",
"postinstall": "cd client && npm install && npm run build"
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.4.2",
"express": "^4.18.2", "express": "^4.18.2",
"express-async-errors": "^3.1.1", "express-async-errors": "^3.1.1",
"express-rate-limit": "^8.6.2", "express-rate-limit": "^8.6.2",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"version": 1, "version": 1,
"updatedAt": "2026-08-11T15:58:39.325Z", "updatedAt": "2026-08-12T09:26:38.944Z",
"scales": { "scales": {
"gad7": { "gad7": {
"name": "GAD-7 广泛性焦虑量表", "name": "GAD-7 广泛性焦虑量表",
@@ -12,7 +12,7 @@
"name": "MMPI 明尼苏达多项人格测验", "name": "MMPI 明尼苏达多项人格测验",
"file": "mmpi.psytest", "file": "mmpi.psytest",
"hash": "cfa6ed750a3a", "hash": "cfa6ed750a3a",
"updatedAt": "2026-08-11T15:42:27.205Z" "updatedAt": "2026-08-12T08:38:51.050Z"
}, },
"phq9": { "phq9": {
"name": "PHQ-9 患者健康问卷-9", "name": "PHQ-9 患者健康问卷-9",
+2 -2
View File
@@ -4,7 +4,7 @@ function req(method, path, body, token) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const headers = { 'Content-Type': 'application/json' }; const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token; if (token) headers['Authorization'] = 'Bearer ' + token;
const r = http.request({ hostname: 'localhost', port: 3001, path, method, headers }, res => { const r = http.request({ hostname: 'localhost', port: 3501, path, method, headers }, res => {
let data = ''; let data = '';
res.on('data', c => data += c); res.on('data', c => data += c);
res.on('end', () => { res.on('end', () => {
@@ -69,7 +69,7 @@ async function main() {
} }
} }
console.log(''); console.log('');
console.log('3. 打开 http://localhost:3001 查看完整结果页面!'); console.log('3. 打开 http://localhost:3501 查看完整结果页面!');
} }
main().catch(e => console.error('错误:', e.message)); main().catch(e => console.error('错误:', e.message));
+1 -1
View File
@@ -54,7 +54,7 @@ async function main() {
} }
console.log('\n=== Now start the server with: node server/index.js ==='); console.log('\n=== Now start the server with: node server/index.js ===');
console.log('Then open http://localhost:3001 to see the beautiful UI'); console.log('Then open http://localhost:3501 to see the beautiful UI');
} }
main().catch(e => console.error('ERROR:', e.message, e.stack)); main().catch(e => console.error('ERROR:', e.message, e.stack));
+2 -2
View File
@@ -11,7 +11,7 @@ server.stderr.on('data', d => process.stderr.write('[srv-err] ' + d));
setTimeout(async () => { setTimeout(async () => {
const http = require('http'); const http = require('http');
const BASE = { hostname: 'localhost', port: 3001 }; const BASE = { hostname: 'localhost', port: 3501 };
function req(method, p, body, token) { function req(method, p, body, token) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -86,7 +86,7 @@ setTimeout(async () => {
} }
console.log(''); console.log('');
console.log('=== DONE ==='); console.log('=== DONE ===');
console.log('Open http://localhost:3001 to see the result!'); console.log('Open http://localhost:3501 to see the result!');
} catch (e) { } catch (e) {
console.error('ERROR:', e.message); console.error('ERROR:', e.message);
} }
+3 -3
View File
@@ -5,7 +5,7 @@ function req(method, path, body, token) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const headers = { 'Content-Type': 'application/json' }; const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token; if (token) headers['Authorization'] = 'Bearer ' + token;
const r = http.request({ hostname: 'localhost', port: 3001, path, method, headers }, res => { const r = http.request({ hostname: 'localhost', port: 3501, path, method, headers }, res => {
let data = ''; let data = '';
res.on('data', c => data += c); res.on('data', c => data += c);
res.on('end', () => { res.on('end', () => {
@@ -50,11 +50,11 @@ async function main() {
console.log('Interpretations:', data.interpretation.length); console.log('Interpretations:', data.interpretation.length);
console.log('Profiles:', data.scores.matchedProfiles?.length || 0); console.log('Profiles:', data.scores.matchedProfiles?.length || 0);
console.log('Answers in response:', Object.keys(data.answers || {}).length); console.log('Answers in response:', Object.keys(data.answers || {}).length);
console.log('\nOpen http://localhost:3001 in your browser!'); console.log('\nOpen http://localhost:3501 in your browser!');
} }
main().catch(e => { main().catch(e => {
console.error('FATAL:', e.message); console.error('FATAL:', e.message);
console.log('\nMake sure the server is running on port 3001 first!'); console.log('\nMake sure the server is running on port 3501 first!');
process.exit(1); process.exit(1);
}); });
+3 -3
View File
@@ -1,5 +1,5 @@
// Import and start server directly in-process // Import and start server directly in-process
process.env.PORT = '3001'; process.env.PORT = '3501';
const app = require('../server/index'); const app = require('../server/index');
const http = require('http'); const http = require('http');
@@ -8,7 +8,7 @@ function req(method, path, body, token) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const headers = { 'Content-Type': 'application/json' }; const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token; if (token) headers['Authorization'] = 'Bearer ' + token;
const r = http.request({ hostname: 'localhost', port: 3001, path, method, headers }, res => { const r = http.request({ hostname: 'localhost', port: 3501, path, method, headers }, res => {
let data = ''; let data = '';
res.on('data', c => data += c); res.on('data', c => data += c);
res.on('end', () => { res.on('end', () => {
@@ -56,7 +56,7 @@ async function main() {
console.log('Total:', d.scores.total, '| Level:', d.scores.level); console.log('Total:', d.scores.total, '| Level:', d.scores.level);
console.log('L=' + d.scores.factors.l.tScore + ' F=' + d.scores.factors.f.tScore + ' K=' + d.scores.factors.k.tScore); console.log('L=' + d.scores.factors.l.tScore + ' F=' + d.scores.factors.f.tScore + ' K=' + d.scores.factors.k.tScore);
console.log('Rules:', d.interpretation.length, '| Profiles:', d.scores.matchedProfiles?.length || 0); console.log('Rules:', d.interpretation.length, '| Profiles:', d.scores.matchedProfiles?.length || 0);
console.log('\n=== Server running at http://localhost:3001 ==='); console.log('\n=== Server running at http://localhost:3501 ===');
} else { } else {
console.log('Error:', JSON.stringify(submit.error)); console.log('Error:', JSON.stringify(submit.error));
} }
+1 -1
View File
@@ -1,5 +1,5 @@
const http = require('http'); const http = require('http');
const BASE = { hostname: 'localhost', port: 3001 }; const BASE = { hostname: 'localhost', port: 3501 };
function request(method, path, body, token) { function request(method, path, body, token) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
+1 -1
View File
@@ -17,7 +17,7 @@ setTimeout(async () => {
} }
const http = require('http'); const http = require('http');
const BASE = { hostname: 'localhost', port: 3001 }; const BASE = { hostname: 'localhost', port: 3501 };
function req(method, p, body, token) { function req(method, p, body, token) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
+4 -4
View File
@@ -19,7 +19,7 @@ function req(method, p, body, token) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const h = { 'Content-Type': 'application/json' }; const h = { 'Content-Type': 'application/json' };
if (token) h['Authorization'] = 'Bearer ' + token; if (token) h['Authorization'] = 'Bearer ' + token;
const r = http.request({ hostname: 'localhost', port: 3001, path: p, method, headers: h }, res => { const r = http.request({ hostname: 'localhost', port: 3501, path: p, method, headers: h }, res => {
let d = ''; let d = '';
res.on('data', c => d += c); res.on('data', c => d += c);
res.on('end', () => { res.on('end', () => {
@@ -60,9 +60,9 @@ setTimeout(async () => {
console.log(' Interpretations:', data.interpretation.length); console.log(' Interpretations:', data.interpretation.length);
console.log(' Profiles:', data.scores.matchedProfiles?.length || 0); console.log(' Profiles:', data.scores.matchedProfiles?.length || 0);
console.log(' Answers returned:', Object.keys(data.answers || {}).length); console.log(' Answers returned:', Object.keys(data.answers || {}).length);
console.log('\n=== Server running on http://localhost:3001 ==='); console.log('\n=== Server running on http://localhost:3501 ===');
console.log('Open http://localhost:3001/admin/scales to start'); console.log('Open http://localhost:3501/admin/scales to start');
console.log('Or open http://localhost:3001/ to take tests'); console.log('Or open http://localhost:3501/ to take tests');
} catch (e) { } catch (e) {
console.error('\nERROR:', e.message); console.error('\nERROR:', e.message);
server.kill(); server.kill();
+2 -2
View File
@@ -15,7 +15,7 @@ function req(method, p, body, token) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const h = { 'Content-Type': 'application/json' }; const h = { 'Content-Type': 'application/json' };
if (token) h['Authorization'] = 'Bearer ' + token; if (token) h['Authorization'] = 'Bearer ' + token;
const r = http.request({ hostname: 'localhost', port: 3001, path: p, method, headers: h }, res => { const r = http.request({ hostname: 'localhost', port: 3501, path: p, method, headers: h }, res => {
let d = ''; let d = '';
res.on('data', c => d += c); res.on('data', c => d += c);
res.on('end', () => { res.on('end', () => {
@@ -67,7 +67,7 @@ setTimeout(async () => {
console.log(' Interpretations:', data.interpretation.length); console.log(' Interpretations:', data.interpretation.length);
console.log(' Profiles matched:', data.scores.matchedProfiles?.length || 0); console.log(' Profiles matched:', data.scores.matchedProfiles?.length || 0);
console.log(' Answers in response:', Object.keys(data.answers || {}).length); console.log(' Answers in response:', Object.keys(data.answers || {}).length);
console.log('\n3. Server running at http://localhost:3001'); console.log('\n3. Server running at http://localhost:3501');
console.log(' Open the browser to see the beautiful result page!'); console.log(' Open the browser to see the beautiful result page!');
// Keep running for 5 more minutes // Keep running for 5 more minutes
+3 -12
View File
@@ -1,17 +1,9 @@
const path = require('path'); const path = require('path');
const crypto = require('crypto'); require('dotenv').config();
// 密钥默认值不再硬编码:env 缺失时生成随机值并警告(重启后会话/校验失效)
function resolveSecret(envKey) {
if (process.env[envKey]) return process.env[envKey];
const secret = crypto.randomBytes(32).toString('hex');
console.warn(`[Config] 未配置 ${envKey},已生成随机密钥,重启后所有会话失效;生产请显式设置`);
return secret;
}
// JWT_SECRET / VERIFY_SALT 不再从 config 导出:统一走 server/lib/secrets.js(自动持久化)
module.exports = { module.exports = {
PORT: process.env.PORT || 3001, PORT: process.env.PORT || 3501,
JWT_SECRET: resolveSecret('JWT_SECRET'),
DATA_DIR: path.join(__dirname, '../data'), DATA_DIR: path.join(__dirname, '../data'),
SCALES_DIR: path.join(__dirname, '../scales'), SCALES_DIR: path.join(__dirname, '../scales'),
DB_PATH: path.join(__dirname, '../data/psytest.db'), DB_PATH: path.join(__dirname, '../data/psytest.db'),
@@ -19,5 +11,4 @@ module.exports = {
DB_SAMPLES_PATH: path.join(__dirname, '../data/samples.db'), DB_SAMPLES_PATH: path.join(__dirname, '../data/samples.db'),
DIST_PATH: path.join(__dirname, '../client/dist'), DIST_PATH: path.join(__dirname, '../client/dist'),
CLIENT_PATH: path.join(__dirname, '../client'), CLIENT_PATH: path.join(__dirname, '../client'),
VERIFY_SALT: resolveSecret('VERIFY_SALT'),
}; };
+13 -1
View File
@@ -73,6 +73,16 @@ function initMainDB() {
dbRun('INSERT INTO settings (key, value) VALUES (?, ?)', [k, v]); dbRun('INSERT INTO settings (key, value) VALUES (?, ?)', [k, v]);
} }
} }
// 验证码默认设置 seedcaptcha_type 默认 turnstile(内置 SVG / none / both 可后台切换)
const captchaDefaults = {
captcha_type: 'turnstile',
};
for (const [k, v] of Object.entries(captchaDefaults)) {
if (!dbGet('SELECT value FROM settings WHERE key = ?', [k])) {
dbRun('INSERT INTO settings (key, value) VALUES (?, ?)', [k, v]);
}
}
} }
function checkIntegrity() { function checkIntegrity() {
@@ -107,7 +117,9 @@ function getSetting(key, defaultVal = '') {
} }
function setSetting(key, value) { function setSetting(key, value) {
dbRun('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)', [key, value]); // node:sqlite 只接受 number/string/bigint/Buffer/null,布尔会抛 TypeError
// (线上事故:GET 返回的 *_configured 布尔被前端回 POST → 保存设置 500)
dbRun('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)', [key, value == null ? '' : String(value)]);
} }
module.exports = { initMainDB, saveDB, dbAll, dbGet, dbRun, getSetting, setSetting }; module.exports = { initMainDB, saveDB, dbAll, dbGet, dbRun, getSetting, setSetting };
+43 -4
View File
@@ -3,6 +3,7 @@ const express = require('express');
const cors = require('cors'); const cors = require('cors');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const { spawn } = require('child_process');
const { PORT } = require('./config'); const { PORT } = require('./config');
const { initMainDB } = require('./db-main'); const { initMainDB } = require('./db-main');
const { initRecordsDB } = require('./db-records'); const { initRecordsDB } = require('./db-records');
@@ -30,11 +31,19 @@ app.use('/api/samples', require('./routes/samples'));
app.use('/api/user-scales', require('./routes/user-scales')); app.use('/api/user-scales', require('./routes/user-scales'));
app.use('/api/captcha', require('./routes/captcha').captchaRouter); app.use('/api/captcha', require('./routes/captcha').captchaRouter);
// Public: share link data API // Public: share link data API
// 分享链接 /s/<内置量表id>(如 /s/phq9)与用户量表分享码 /s/<随机码> 共用此入口:
// 先查用户量表分享码(现状)→ 未命中(或草稿不可用)则把 code 当内置量表 id 查;均未命中返回 404
app.get('/api/s/:code', (req, res) => { app.get('/api/s/:code', (req, res) => {
const us = require('./services/user-scales'); const us = require('./services/user-scales');
const scale = us.getUserScaleByShareCode(req.params.code); const scale = us.getUserScaleByShareCode(req.params.code);
if (!scale || scale.status === 'draft') return res.status(404).json({ success: false, error: { code: 'SCALE_001', message: '量表不存在' } }); if (scale && scale.status !== 'draft') {
res.json({ success: true, data: { id: scale.id, name: scale.name, content: scale.content, ownerId: scale.owner_id } }); return res.json({ success: true, data: { id: scale.id, name: scale.name, content: scale.content, ownerId: scale.owner_id } });
}
const builtin = require('./services/scales').getScale(req.params.code);
if (builtin) {
return res.json({ success: true, data: { id: builtin.meta.id, name: builtin.meta.name, content: builtin, ownerId: null } });
}
return res.status(404).json({ success: false, error: { code: 'SCALE_001', message: '量表不存在' } });
}); });
// Public settings (no auth required) // Public settings (no auth required)
app.get('/api/settings/public', (req, res) => { app.get('/api/settings/public', (req, res) => {
@@ -55,6 +64,8 @@ app.get('/api/settings/public', (req, res) => {
watermark_pages: getSetting('watermark_pages', '["result","records"]'), watermark_pages: getSetting('watermark_pages', '["result","records"]'),
rainid_enabled: getSetting('rainid_enabled', '0'), rainid_enabled: getSetting('rainid_enabled', '0'),
rainid_register_redirect: getSetting('rainid_register_redirect', '0'), rainid_register_redirect: getSetting('rainid_register_redirect', '0'),
// 与 lib/rainid.js 的 getOidcSettings().enabled 同一 fail-closed 判定(含 secret 的 env/.env.json 通道)
rainid_configured: require('./lib/rainid').getOidcSettings().enabled,
}}); }});
}); });
@@ -66,10 +77,13 @@ app.use('/api', (req, res) => {
res.status(404).json({ success: false, error: { code: 'API_404', message: '接口不存在' } }); res.status(404).json({ success: false, error: { code: 'API_404', message: '接口不存在' } });
}); });
// Frontend static files // Frontend static files(静态目录解析放在 start() 内,先确保 dist 已构建)
let staticPath;
function registerStatic() {
const distPath = path.join(__dirname, '../client/dist'); const distPath = path.join(__dirname, '../client/dist');
const clientPath = path.join(__dirname, '../client'); const clientPath = path.join(__dirname, '../client');
const staticPath = fs.existsSync(distPath) ? distPath : clientPath; staticPath = fs.existsSync(distPath) ? distPath : clientPath;
app.use(express.static(staticPath)); app.use(express.static(staticPath));
app.get('*', (req, res) => { app.get('*', (req, res) => {
const filePath = path.join(staticPath, 'index.html'); const filePath = path.join(staticPath, 'index.html');
@@ -80,9 +94,34 @@ app.get('*', (req, res) => {
} }
}); });
}); });
}
// 部署兜底:dist 缺失时自动在 client/ 下执行 npm run build,失败不崩溃(仅提示)
async function ensureClientDist() {
const distIndex = path.join(__dirname, '../client/dist/index.html');
if (fs.existsSync(distIndex)) return true;
console.warn('[Server] 未检测到 client/dist,尝试自动构建前端…');
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
try {
await new Promise((resolve, reject) => {
const child = spawn(npmCmd, ['run', 'build'], { cwd: path.join(__dirname, '../client'), stdio: 'inherit' });
child.on('error', reject);
child.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`前端构建退出码 ${code}`))));
});
if (!fs.existsSync(distIndex)) throw new Error('构建完成但未生成 client/dist/index.html');
console.log('[Server] 前端自动构建完成');
return true;
} catch (e) {
console.error(`[Server] 前端自动构建失败: ${e.message}`);
console.error('[Server] 服务仍将启动,但前端页面不可用(请在 client/ 下执行 npm install && npm run build');
return false;
}
}
// Start // Start
async function start() { async function start() {
await ensureClientDist();
registerStatic();
await initMainDB(); await initMainDB();
await initRecordsDB(); await initRecordsDB();
await initSamplesDB(); await initSamplesDB();
+52
View File
@@ -0,0 +1,52 @@
/*
* JWT_SECRET / VERIFY_SALT 自动持久化任务 1
* 优先级 process.env.xxx存在则用不写文件 data/.secrets.json 生成并原子写回tmp+rename
* 模块级缓存函数幂等 require db-main/config避免循环依赖
*/
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const SECRETS_FILE = path.join(__dirname, '..', '..', 'data', '.secrets.json');
const cache = {};
function loadFile() {
try {
return JSON.parse(fs.readFileSync(SECRETS_FILE, 'utf-8'));
} catch {
return {};
}
}
function persist(key, value) {
const dir = path.dirname(SECRETS_FILE);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const data = { ...loadFile(), [key]: value };
const tmp = SECRETS_FILE + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8');
fs.renameSync(tmp, SECRETS_FILE); // 原子替换
}
function resolveSecret(cacheKey, envName, fileKey) {
if (cache[cacheKey]) return cache[cacheKey];
if (process.env[envName]) {
cache[cacheKey] = process.env[envName];
return cache[cacheKey];
}
const file = loadFile();
if (file[fileKey]) {
cache[cacheKey] = file[fileKey];
return cache[cacheKey];
}
const secret = crypto.randomBytes(32).toString('hex');
persist(fileKey, secret);
console.warn(`[Secrets] 未配置 ${envName},已生成随机密钥并持久化到 ${SECRETS_FILE}(重启后会话保持有效)`);
cache[cacheKey] = secret;
return cache[cacheKey];
}
function getJwtSecret() { return resolveSecret('jwt', 'JWT_SECRET', 'jwtSecret'); }
function getVerifySalt() { return resolveSecret('salt', 'VERIFY_SALT', 'verifySalt'); }
module.exports = { getJwtSecret, getVerifySalt };
+4 -4
View File
@@ -1,5 +1,5 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { JWT_SECRET } = require('../config'); const { getJwtSecret } = require('../lib/secrets');
const { ERR } = require('./error'); const { ERR } = require('./error');
function authRequired(req, res, next) { function authRequired(req, res, next) {
@@ -7,7 +7,7 @@ function authRequired(req, res, next) {
const token = req.headers.authorization?.replace('Bearer ', ''); const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) throw ERR.AUTH_002(); if (!token) throw ERR.AUTH_002();
try { try {
req.user = jwt.verify(token, JWT_SECRET); req.user = jwt.verify(token, getJwtSecret());
// 游客通道:isGuest token 直接放行(req.user.isGuest=true),不强制 users 表存在该用户 // 游客通道:isGuest token 直接放行(req.user.isGuest=true),不强制 users 表存在该用户
next(); next();
} catch { } catch {
@@ -19,7 +19,7 @@ function adminRequired(req, res, next) {
const token = req.headers.authorization?.replace('Bearer ', ''); const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) throw ERR.AUTH_002(); if (!token) throw ERR.AUTH_002();
try { try {
const decoded = jwt.verify(token, JWT_SECRET); const decoded = jwt.verify(token, getJwtSecret());
if (decoded.role !== 'superadmin' && decoded.role !== 'admin') throw ERR.AUTH_003(); if (decoded.role !== 'superadmin' && decoded.role !== 'admin') throw ERR.AUTH_003();
req.user = decoded; req.user = decoded;
next(); next();
@@ -34,7 +34,7 @@ function requirePermission(...perms) {
const token = req.headers.authorization?.replace('Bearer ', ''); const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) throw ERR.AUTH_002(); if (!token) throw ERR.AUTH_002();
try { try {
const decoded = jwt.verify(token, JWT_SECRET); const decoded = jwt.verify(token, getJwtSecret());
// superadmin has all permissions // superadmin has all permissions
if (decoded.role === 'superadmin') { if (decoded.role === 'superadmin') {
req.user = decoded; req.user = decoded;
+4
View File
@@ -7,6 +7,7 @@ const mainDB = require('../db-main');
const recDB = require('../db-records'); const recDB = require('../db-records');
const { loadScales, getAllScales, getScale } = require('../services/scales'); const { loadScales, getAllScales, getScale } = require('../services/scales');
const us = require('../services/user-scales'); const us = require('../services/user-scales');
const rainid = require('../lib/rainid');
const { ERR } = require('../middleware/error'); const { ERR } = require('../middleware/error');
const router = express.Router(); const router = express.Router();
@@ -213,6 +214,9 @@ router.get('/settings', requirePermission('settings.view'), (req, res) => {
s.turnstile_configured = !!mainDB.getSetting('turnstile_secret_key'); s.turnstile_configured = !!mainDB.getSetting('turnstile_secret_key');
s.smtp_configured = !!mainDB.getSetting('smtp_pass'); s.smtp_configured = !!mainDB.getSetting('smtp_pass');
s.ai_configured = !!mainDB.getSetting('ai_api_key'); s.ai_configured = !!mainDB.getSetting('ai_api_key');
// rainid_configured:复用 getOidcSettings().enabled 的 fail-closed 判定(rainid_enabled='1' && client_id && secret
// secret 含 env/.env.json 通道)——替代明文 secret,暴露 RainID 是否真正可用,避免与功能侧逻辑分叉
s.rainid_configured = rainid.getOidcSettings().enabled;
res.json({ success: true, data: s }); res.json({ success: true, data: s });
}); });
+11 -12
View File
@@ -4,12 +4,12 @@ const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const rateLimit = require('express-rate-limit'); const rateLimit = require('express-rate-limit');
const { JWT_SECRET } = require('../config'); const { getJwtSecret } = require('../lib/secrets');
const mainDB = require('../db-main'); const mainDB = require('../db-main');
const { authRequired } = require('../middleware/auth'); const { authRequired } = require('../middleware/auth');
const { sendVerificationCode } = require('../services/email'); const { sendVerificationCode } = require('../services/email');
const { ERR, AppError } = require('../middleware/error'); const { ERR, AppError } = require('../middleware/error');
const { verifyTurnstile } = require('./captcha'); const { verifyCaptcha } = require('./captcha');
const { ropcPasswordGrant } = require('../lib/rainid'); const { ropcPasswordGrant } = require('../lib/rainid');
const router = express.Router(); const router = express.Router();
@@ -42,8 +42,7 @@ const guestLimiter = rateLimit({
}); });
router.post('/login', loginLimiter, async (req, res) => { router.post('/login', loginLimiter, async (req, res) => {
const { turnstileToken } = req.body; if (!await verifyCaptcha(req, 'login')) throw ERR.AUTH_004('请完成人机验证');
if (!await verifyTurnstile(turnstileToken, 'login')) throw ERR.AUTH_004('请完成人机验证');
const { username, password } = req.body; const { username, password } = req.body;
if (!username || !password) throw ERR.AUTH_004('请输入用户名和密码'); if (!username || !password) throw ERR.AUTH_004('请输入用户名和密码');
@@ -59,7 +58,7 @@ router.post('/login', loginLimiter, async (req, res) => {
if (!bcrypt.compareSync(password, localUser.password_hash)) throw ERR.AUTH_001('用户名或密码错误'); if (!bcrypt.compareSync(password, localUser.password_hash)) throw ERR.AUTH_001('用户名或密码错误');
mainDB.dbRun('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?', [localUser.id]); mainDB.dbRun('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?', [localUser.id]);
const permissions = localUser.permissions ? JSON.parse(localUser.permissions) : []; const permissions = localUser.permissions ? JSON.parse(localUser.permissions) : [];
const token = jwt.sign({ id: localUser.id, name: localUser.name, email: localUser.email, role: localUser.role, status: localUser.status, permissions }, JWT_SECRET, { expiresIn: '7d' }); const token = jwt.sign({ id: localUser.id, name: localUser.name, email: localUser.email, role: localUser.role, status: localUser.status, permissions }, getJwtSecret(), { expiresIn: '7d' });
return res.json({ success: true, data: { token, user: { id: localUser.id, name: localUser.name, email: localUser.email, role: localUser.role, status: localUser.status || 'active', permissions } } }); return res.json({ success: true, data: { token, user: { id: localUser.id, name: localUser.name, email: localUser.email, role: localUser.role, status: localUser.status || 'active', permissions } } });
} }
// ROPC 转发(2FA 用户被拒返回 mapOidcError 固定文案) // ROPC 转发(2FA 用户被拒返回 mapOidcError 固定文案)
@@ -69,7 +68,7 @@ router.post('/login', loginLimiter, async (req, res) => {
// 封禁用户不得经 ROPC 重获 JWT(P1-2,与本地路径对齐) // 封禁用户不得经 ROPC 重获 JWT(P1-2,与本地路径对齐)
if (user.status === 'banned') throw ERR.AUTH_001('账号已被封禁,请联系管理员'); if (user.status === 'banned') throw ERR.AUTH_001('账号已被封禁,请联系管理员');
const permissions = user.permissions ? JSON.parse(user.permissions) : []; const permissions = user.permissions ? JSON.parse(user.permissions) : [];
const token = jwt.sign({ id: user.id, name: user.name, email: user.email, role: user.role, status: user.status, permissions }, JWT_SECRET, { expiresIn: '7d' }); const token = jwt.sign({ id: user.id, name: user.name, email: user.email, role: user.role, status: user.status, permissions }, getJwtSecret(), { expiresIn: '7d' });
return res.json({ success: true, data: { token, user: { id: user.id, name: user.name, email: user.email, role: user.role, status: user.status || 'active', permissions } } }); return res.json({ success: true, data: { token, user: { id: user.id, name: user.name, email: user.email, role: user.role, status: user.status || 'active', permissions } } });
} }
@@ -82,7 +81,7 @@ router.post('/login', loginLimiter, async (req, res) => {
} }
mainDB.dbRun('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?', [user.id]); mainDB.dbRun('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?', [user.id]);
const permissions = user.permissions ? JSON.parse(user.permissions) : []; const permissions = user.permissions ? JSON.parse(user.permissions) : [];
const token = jwt.sign({ id: user.id, name: user.name, email: user.email, role: user.role, status: user.status, permissions }, JWT_SECRET, { expiresIn: '7d' }); const token = jwt.sign({ id: user.id, name: user.name, email: user.email, role: user.role, status: user.status, permissions }, getJwtSecret(), { expiresIn: '7d' });
res.json({ success: true, data: { token, user: { id: user.id, name: user.name, email: user.email, role: user.role, status: user.status || 'active', permissions } } }); res.json({ success: true, data: { token, user: { id: user.id, name: user.name, email: user.email, role: user.role, status: user.status || 'active', permissions } } });
}); });
@@ -91,9 +90,9 @@ router.post('/register', async (req, res) => {
if (mainDB.getSetting('rainid_enabled') === '1' && mainDB.getSetting('rainid_register_redirect') === '1') { if (mainDB.getSetting('rainid_enabled') === '1' && mainDB.getSetting('rainid_register_redirect') === '1') {
throw ERR.AUTH_004('注册已托管至 RainID,请使用 RainID 账号登录'); throw ERR.AUTH_004('注册已托管至 RainID,请使用 RainID 账号登录');
} }
const { name, email, code, password, turnstileToken } = req.body; const { name, email, code, password } = req.body;
if (!name || !email || !code || !password) throw ERR.AUTH_004('请填写完整信息'); if (!name || !email || !code || !password) throw ERR.AUTH_004('请填写完整信息');
if (!await verifyTurnstile(turnstileToken, 'register')) throw ERR.AUTH_004('请完成人机验证'); if (!await verifyCaptcha(req, 'register')) throw ERR.AUTH_004('请完成人机验证');
const rec = mainDB.dbGet('SELECT * FROM verification_codes WHERE email = ? AND code = ?', [email, code]); const rec = mainDB.dbGet('SELECT * FROM verification_codes WHERE email = ? AND code = ?', [email, code]);
if (!rec || new Date(rec.expires_at) < new Date()) throw ERR.AUTH_004('验证码无效或已过期'); if (!rec || new Date(rec.expires_at) < new Date()) throw ERR.AUTH_004('验证码无效或已过期');
if (mainDB.dbGet('SELECT * FROM users WHERE email = ?', [email])) throw ERR.AUTH_004('该邮箱已注册'); if (mainDB.dbGet('SELECT * FROM users WHERE email = ?', [email])) throw ERR.AUTH_004('该邮箱已注册');
@@ -101,7 +100,7 @@ router.post('/register', async (req, res) => {
const id = uuidv4(); const id = uuidv4();
mainDB.dbRun('INSERT INTO users (id, name, email, password_hash, role, is_guest, status) VALUES (?, ?, ?, ?, ?, 0, ?)', [id, name, email, bcrypt.hashSync(password, 10), 'user', 'active']); mainDB.dbRun('INSERT INTO users (id, name, email, password_hash, role, is_guest, status) VALUES (?, ?, ?, ?, ?, 0, ?)', [id, name, email, bcrypt.hashSync(password, 10), 'user', 'active']);
mainDB.dbRun('DELETE FROM verification_codes WHERE email = ?', [email]); mainDB.dbRun('DELETE FROM verification_codes WHERE email = ?', [email]);
const token = jwt.sign({ id, name, email, role: 'user', status: 'active', permissions: [] }, JWT_SECRET, { expiresIn: '7d' }); const token = jwt.sign({ id, name, email, role: 'user', status: 'active', permissions: [] }, getJwtSecret(), { expiresIn: '7d' });
res.json({ success: true, data: { token, user: { id, name, email, role: 'user', status: 'active', permissions: [] } } }); res.json({ success: true, data: { token, user: { id, name, email, role: 'user', status: 'active', permissions: [] } } });
}); });
@@ -129,7 +128,7 @@ router.get('/me', authRequired, (req, res) => {
if (needsRenew) { if (needsRenew) {
result._token = jwt.sign( result._token = jwt.sign(
{ id: user.id, name: user.name, email: user.email, role: user.role, status: user.status, permissions: user.permissions }, { id: user.id, name: user.name, email: user.email, role: user.role, status: user.status, permissions: user.permissions },
JWT_SECRET, { expiresIn: '7d' } getJwtSecret(), { expiresIn: '7d' }
); );
} }
res.json({ success: true, data: result }); res.json({ success: true, data: result });
@@ -140,7 +139,7 @@ router.post('/guest', guestLimiter, (req, res) => {
const guestId = 'guest_' + crypto.randomBytes(8).toString('hex'); const guestId = 'guest_' + crypto.randomBytes(8).toString('hex');
const token = jwt.sign( const token = jwt.sign(
{ id: guestId, name: '游客', role: 'user', isGuest: true, status: 'active', permissions: [] }, { id: guestId, name: '游客', role: 'user', isGuest: true, status: 'active', permissions: [] },
JWT_SECRET, getJwtSecret(),
{ expiresIn: '2h' } { expiresIn: '2h' }
); );
res.json({ success: true, data: { token, user: { name: '游客', role: 'user', isGuest: true } } }); res.json({ success: true, data: { token, user: { name: '游客', role: 'user', isGuest: true } } });
+179 -15
View File
@@ -1,10 +1,42 @@
const express = require('express'); const express = require('express');
const https = require('https'); const https = require('https');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const mainDB = require('../db-main'); const mainDB = require('../db-main');
const { getJwtSecret } = require('../lib/secrets');
const router = express.Router(); const router = express.Router();
const ACTIONS = ['login', 'register', 'create_scale', 'share_scale', 'submit_review']; const ACTIONS = ['login', 'register', 'create_scale', 'share_scale', 'submit_review'];
const CAPTCHA_TYPES = ['turnstile', 'builtin', 'none', 'both'];
// ===================== 内置 SVG 验证码(内存态) =====================
// 字符集:去易混 I/L/O/0/1
const ANSWER_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
const CAPTCHA_TTL_MS = 10 * 60 * 1000; // 答案 10min
const PROOF_TTL_MS = 5 * 60 * 1000; // proof 5min
const CLEANUP_MS = 60 * 1000; // 每分钟清扫
const SVG_COLORS = ['#d32f2f', '#1976d2', '#388e3c', '#f57c00', '#7b1fa2', '#e91e63', '#0097a7'];
// 答案存储:token → { code, createdAt }(一次性,取用即删)
const captchaStore = new Map();
// 已消费 proof:proof → 消费时间戳(防重放,5min 清理)
const usedProofs = new Map();
setInterval(() => {
const now = Date.now();
for (const [key, v] of captchaStore) {
if (now - v.createdAt > CAPTCHA_TTL_MS) captchaStore.delete(key);
}
for (const [key, t] of usedProofs) {
if (now - t > PROOF_TTL_MS) usedProofs.delete(key);
}
}, CLEANUP_MS).unref?.();
function getCaptchaType() {
const v = mainDB.getSetting('captcha_type', 'turnstile');
return CAPTCHA_TYPES.includes(v) ? v : 'turnstile';
}
function getSiteKey() { function getSiteKey() {
return mainDB.getSetting('turnstile_site_key', ''); return mainDB.getSetting('turnstile_site_key', '');
@@ -14,26 +46,126 @@ function getSecretKey() {
return mainDB.getSetting('turnstile_secret_key', ''); return mainDB.getSetting('turnstile_secret_key', '');
} }
function needCaptcha(action) { function actionEnabled(action) {
if (!ACTIONS.includes(action)) return false; if (!ACTIONS.includes(action)) return false;
const siteKey = getSiteKey(); return mainDB.getSetting(`captcha_${action}`, '1') !== '0';
const secretKey = getSecretKey();
if (!siteKey || !secretKey) return false;
const val = mainDB.getSetting(`captcha_${action}`, '1');
return val !== '0';
} }
// GET /api/captcha/status function needCaptcha(action) {
router.get('/status', (req, res) => { if (!ACTIONS.includes(action)) return false;
const siteKey = getSiteKey(); const type = getCaptchaType();
const actions = {}; if (type === 'none') return false;
for (const a of ACTIONS) actions[a] = needCaptcha(a); const hasKeys = !!(getSiteKey() && getSecretKey());
res.json({ success: true, data: { enabled: !!siteKey, siteKey, actions } }); if (type === 'builtin') return actionEnabled(action); // 不依赖外部 key
if (type === 'turnstile') {
if (!hasKeys) return false;
return actionEnabled(action);
}
if (type === 'both') {
// 任一启用:builtin 无条件(只要 action 开启),turnstile 需 key 齐全
if (actionEnabled(action)) return true;
return hasKeys && actionEnabled(action);
}
return false;
}
// ===================== 内置 SVG 生成 =====================
function generateSvgCaptcha(code) {
const w = 280, h = 72;
let bg = `<rect width="${w}" height="${h}" fill="#f5f5f5" rx="10"/>`;
let noise = '';
// 干扰线
for (let i = 0; i < 12; i++) {
const x1 = Math.random() * w, y1 = Math.random() * h;
const x2 = Math.random() * w, y2 = Math.random() * h;
noise += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${SVG_COLORS[i % SVG_COLORS.length]}" stroke-width="${1 + Math.random() * 3}" opacity="0.25"/>`;
}
// 噪点
for (let i = 0; i < 80; i++) {
noise += `<circle cx="${Math.random() * w}" cy="${Math.random() * h}" r="${1 + Math.random() * 3}" fill="${SVG_COLORS[i % SVG_COLORS.length]}" opacity="0.15"/>`;
}
// 曲线
for (let i = 0; i < 4; i++) {
const x1 = Math.random() * w, y1 = Math.random() * h;
const cx = Math.random() * w, cy = Math.random() * h;
const x2 = Math.random() * w, y2 = Math.random() * h;
noise += `<path d="M${x1} ${y1} Q${cx} ${cy} ${x2} ${y2}" stroke="${SVG_COLORS[i]}" fill="none" stroke-width="1.5" opacity="0.2"/>`;
}
// 旋转 + 多彩字母(字符集不含 XML 特殊字符,可直接内联)
let letters = '';
const spacing = w / (code.length + 1);
for (let i = 0; i < code.length; i++) {
const x = spacing * (i + 0.5) + (Math.random() - 0.5) * 15;
const y = 40 + (Math.random() - 0.5) * 20;
const rotation = (Math.random() - 0.5) * 45;
const fontSize = 28 + Math.random() * 14;
const color = SVG_COLORS[i % SVG_COLORS.length];
letters += `<text x="${x}" y="${y}" transform="rotate(${rotation},${x},${y})" font-size="${fontSize}" font-family="Arial,sans-serif" font-weight="bold" fill="${color}" text-anchor="middle" dominant-baseline="middle">${code[i]}</text>`;
}
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${bg}${noise}${letters}</svg>`;
}
// ===================== proof JWT(一次性防重放) =====================
function issueProof(action = '') {
return jwt.sign(
{ captcha: true, action, jti: crypto.randomBytes(16).toString('hex') },
getJwtSecret(),
{ expiresIn: '5m' }
);
}
function consumeProof(proof, action) {
if (typeof proof !== 'string' || !proof) {
return { ok: false, code: 'CAPTCHA_004', message: '请先完成人机验证' };
}
let payload;
try {
payload = jwt.verify(proof, getJwtSecret());
} catch {
return { ok: false, code: 'CAPTCHA_005', message: '验证码验证已过期' };
}
if (!payload || payload.captcha !== true) {
return { ok: false, code: 'CAPTCHA_005', message: '验证码验证已过期' };
}
// action 绑定:签发时绑定了 action 则必须匹配当前请求(防跨动作复用)
if (payload.action && payload.action !== action) {
return { ok: false, code: 'CAPTCHA_005', message: '验证码验证已过期' };
}
if (usedProofs.has(proof)) {
return { ok: false, code: 'CAPTCHA_006', message: '验证码已使用' };
}
usedProofs.set(proof, Date.now());
return { ok: true, payload };
}
// ===================== 内置验证码路由 =====================
// GET /api/captcha/image
router.get('/image', (req, res) => {
let code = '';
for (let i = 0; i < 4; i++) code += ANSWER_CHARS[Math.floor(Math.random() * ANSWER_CHARS.length)];
const token = crypto.randomBytes(16).toString('hex');
captchaStore.set(token, { code, createdAt: Date.now() });
res.json({ success: true, data: { token, svg: generateSvgCaptcha(code), expiresIn: CAPTCHA_TTL_MS / 1000 } });
}); });
// POST /api/captcha/verify // POST /api/captcha/verify
// 带 answer → 内置 SVG 校验(一次性删除 + 签发 proof)
// 不带 answer → 兼容旧前端:Turnstile siteverify
router.post('/verify', (req, res) => { router.post('/verify', (req, res) => {
const { token, action } = req.body; const { token, answer, action } = req.body;
if (answer !== undefined) {
const entry = typeof token === 'string' ? captchaStore.get(token) : undefined;
if (!entry) {
return res.json({ success: false, error: { code: 'CAPTCHA_004', message: '验证码已过期或不存在' } });
}
captchaStore.delete(token); // 一次性删除
if (String(entry.code).toLowerCase() !== String(answer).toLowerCase().trim()) {
return res.json({ success: false, error: { code: 'CAPTCHA_005', message: '验证码错误' } });
}
const proof = issueProof(action || '');
return res.json({ success: true, data: { proof } });
}
// ---- 兼容分支:Turnstile siteverify(原逻辑) ----
if (!needCaptcha(action)) return res.json({ success: true }); if (!needCaptcha(action)) return res.json({ success: true });
if (!token) return res.json({ success: false, error: { code: 'CAPTCHA_001', message: '缺少 token' } }); if (!token) return res.json({ success: false, error: { code: 'CAPTCHA_001', message: '缺少 token' } });
const secret = getSecretKey(); const secret = getSecretKey();
@@ -57,7 +189,17 @@ router.post('/verify', (req, res) => {
reqCF.end(); reqCF.end();
}); });
// Helper: async verify for route usage // GET /api/captcha/status
router.get('/status', (req, res) => {
const type = getCaptchaType();
const siteKey = getSiteKey();
const builtin = type === 'builtin' || type === 'both';
const actions = {};
for (const a of ACTIONS) actions[a] = needCaptcha(a);
res.json({ success: true, data: { enabled: !!siteKey || builtin, type, builtin, siteKey, actions } });
});
// ===================== Turnstile siteverify(内部复用) =====================
function verifyTurnstile(token, action) { function verifyTurnstile(token, action) {
return new Promise((resolve) => { return new Promise((resolve) => {
if (!needCaptcha(action)) return resolve(true); if (!needCaptcha(action)) return resolve(true);
@@ -84,4 +226,26 @@ function verifyTurnstile(token, action) {
}); });
} }
module.exports = { captchaRouter: router, verifyTurnstile, needCaptcha }; // ===================== 统一验证码入口(业务路由用) =====================
// builtin/both → 校验 req.body.captcha_proofJWT + 一次性消费)
// turnstile/both → 校验 req.body.turnstileToken(服务端 siteverify
// !needCaptcha → true
async function verifyCaptcha(req, action) {
if (!needCaptcha(action)) return true;
const type = getCaptchaType();
const body = req.body || {};
if (type === 'builtin') {
return consumeProof(body.captcha_proof, action).ok;
}
if (type === 'turnstile') {
return await verifyTurnstile(body.turnstileToken, action);
}
if (type === 'both') {
// 任一通过即可
if (consumeProof(body.captcha_proof, action).ok) return true;
return await verifyTurnstile(body.turnstileToken, action);
}
return false;
}
module.exports = { captchaRouter: router, verifyTurnstile, verifyCaptcha, needCaptcha };
+2 -2
View File
@@ -7,7 +7,7 @@ const express = require('express');
const crypto = require('crypto'); const crypto = require('crypto');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const openidClient = require('openid-client'); // v6 函数式 API const openidClient = require('openid-client'); // v6 函数式 API
const { JWT_SECRET } = require('../config'); const { getJwtSecret } = require('../lib/secrets');
const { const {
getOidcSettings, getOidcSettings,
getOidcConfig, getOidcConfig,
@@ -93,7 +93,7 @@ router.get('/callback', async (req, res) => {
return res.status(401).json({ error: '账号已被封禁,请联系管理员' }); return res.status(401).json({ error: '账号已被封禁,请联系管理员' });
} }
const permissions = user.permissions ? JSON.parse(user.permissions) : []; const permissions = user.permissions ? JSON.parse(user.permissions) : [];
const token = jwt.sign({ id: user.id, name: user.name, email: user.email, role: user.role, status: user.status, permissions }, JWT_SECRET, { expiresIn: '7d' }); const token = jwt.sign({ id: user.id, name: user.name, email: user.email, role: user.role, status: user.status, permissions }, getJwtSecret(), { expiresIn: '7d' });
const ticket = crypto.randomBytes(16).toString('hex'); const ticket = crypto.randomBytes(16).toString('hex');
oidcTickets.set(ticket, { oidcTickets.set(ticket, {
token, token,
+2 -2
View File
@@ -7,7 +7,7 @@ const { getScale } = require('../services/scales');
const { calculateScore } = require('../services/scoring'); const { calculateScore } = require('../services/scoring');
const { interpret } = require('../services/interpret'); const { interpret } = require('../services/interpret');
const { ERR } = require('../middleware/error'); const { ERR } = require('../middleware/error');
const { VERIFY_SALT } = require('../config'); const { getVerifySalt } = require('../lib/secrets');
const router = express.Router(); const router = express.Router();
@@ -57,7 +57,7 @@ router.get('/:id/verify', authRequired, (req, res) => {
if (!record) throw ERR.RECORD_001(); if (!record) throw ERR.RECORD_001();
const scoresStr = typeof record.scores === 'string' ? record.scores : JSON.stringify(record.scores); const scoresStr = typeof record.scores === 'string' ? record.scores : JSON.stringify(record.scores);
const computedHash = crypto.createHash('sha256') const computedHash = crypto.createHash('sha256')
.update(`${record.id}-${scoresStr}-${VERIFY_SALT}`) .update(`${record.id}-${scoresStr}-${getVerifySalt()}`)
.digest('hex') .digest('hex')
.slice(0, 16); .slice(0, 16);
const valid = computedHash === record.verify_hash; const valid = computedHash === record.verify_hash;
+2 -2
View File
@@ -9,7 +9,7 @@ const us = require('../services/user-scales');
const { calculateScore } = require('../services/scoring'); const { calculateScore } = require('../services/scoring');
const { interpret } = require('../services/interpret'); const { interpret } = require('../services/interpret');
const { ERR } = require('../middleware/error'); const { ERR } = require('../middleware/error');
const { VERIFY_SALT } = require('../config'); const { getVerifySalt } = require('../lib/secrets');
const router = express.Router(); const router = express.Router();
@@ -34,7 +34,7 @@ function generateRecordId(userId, scaleId, answers, timestamp) {
function generateVerifyHash(recordId, scores) { function generateVerifyHash(recordId, scores) {
const scoresStr = typeof scores === 'string' ? scores : JSON.stringify(scores); const scoresStr = typeof scores === 'string' ? scores : JSON.stringify(scores);
return crypto.createHash('sha256') return crypto.createHash('sha256')
.update(`${recordId}-${scoresStr}-${VERIFY_SALT}`) .update(`${recordId}-${scoresStr}-${getVerifySalt()}`)
.digest('hex') .digest('hex')
.slice(0, 16); .slice(0, 16);
} }
+5 -7
View File
@@ -3,7 +3,7 @@ const { authRequired, requirePermission } = require('../middleware/auth');
const { validateScale } = require('../services/validator'); const { validateScale } = require('../services/validator');
const us = require('../services/user-scales'); const us = require('../services/user-scales');
const { ERR } = require('../middleware/error'); const { ERR } = require('../middleware/error');
const { verifyTurnstile, needCaptcha } = require('./captcha'); const { verifyCaptcha } = require('./captcha');
const router = express.Router(); const router = express.Router();
@@ -17,9 +17,9 @@ router.get('/', authRequired, (req, res) => {
// Create new // Create new
router.post('/', authRequired, async (req, res) => { router.post('/', authRequired, async (req, res) => {
if (req.user.isGuest) throw ERR.AUTH_005(); if (req.user.isGuest) throw ERR.AUTH_005();
const { id, content, turnstileToken } = req.body; const { id, content } = req.body;
if (!id || !content) throw ERR.SCALE_002("缺少 id 和 content"); if (!id || !content) throw ERR.SCALE_002("缺少 id 和 content");
if (!await verifyTurnstile(turnstileToken, 'create_scale')) throw ERR.AUTH_004('请完成人机验证'); if (!await verifyCaptcha(req, 'create_scale')) throw ERR.AUTH_004('请完成人机验证');
const validation = validateScale(content); const validation = validateScale(content);
if (validation.errors.length > 0) { if (validation.errors.length > 0) {
return res.status(400).json({ success: false, error: { code: 'SCALE_002', message: '校验未通过', validation } }); return res.status(400).json({ success: false, error: { code: 'SCALE_002', message: '校验未通过', validation } });
@@ -58,8 +58,7 @@ router.delete('/:id', authRequired, (req, res) => {
// Generate share link // Generate share link
router.post('/:id/share', authRequired, async (req, res) => { router.post('/:id/share', authRequired, async (req, res) => {
if (req.user.isGuest) throw ERR.AUTH_005(); if (req.user.isGuest) throw ERR.AUTH_005();
const { turnstileToken } = req.body; if (!await verifyCaptcha(req, 'share_scale')) throw ERR.AUTH_004('请完成人机验证');
if (!await verifyTurnstile(turnstileToken, 'share_scale')) throw ERR.AUTH_004('请完成人机验证');
const code = us.generateShareCode(req.params.id, req.user.id); const code = us.generateShareCode(req.params.id, req.user.id);
if (!code) throw ERR.SCALE_001(); if (!code) throw ERR.SCALE_001();
res.json({ success: true, data: { shareCode: code, url: `/s/${code}` } }); res.json({ success: true, data: { shareCode: code, url: `/s/${code}` } });
@@ -75,8 +74,7 @@ router.post('/:id/unshare', authRequired, (req, res) => {
// Submit for review // Submit for review
router.post('/:id/submit-review', authRequired, async (req, res) => { router.post('/:id/submit-review', authRequired, async (req, res) => {
if (req.user.isGuest) throw ERR.AUTH_005(); if (req.user.isGuest) throw ERR.AUTH_005();
const { turnstileToken } = req.body; if (!await verifyCaptcha(req, 'submit_review')) throw ERR.AUTH_004('请完成人机验证');
if (!await verifyTurnstile(turnstileToken, 'submit_review')) throw ERR.AUTH_004('请完成人机验证');
const result = us.submitForReview(req.params.id, req.user.id); const result = us.submitForReview(req.params.id, req.user.id);
if (!result) throw ERR.SCALE_001(); if (!result) throw ERR.SCALE_001();
if (result.error) return res.status(400).json({ success: false, error: { code: 'SCALE_002', message: result.error, validation: result.validation } }); if (result.error) return res.status(400).json({ success: false, error: { code: 'SCALE_002', message: result.error, validation: result.validation } });