Compare commits
14
Commits
ed6bc69ca3
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32f258aec2 | ||
|
|
d46d131404 | ||
|
|
29d5f844a8 | ||
|
|
8f65677408 | ||
|
|
bd240551a8 | ||
|
|
7f0e086888 | ||
|
|
189f63af91 | ||
|
|
37a427ea90 | ||
|
|
4cd1738b18 | ||
|
|
791187a127 | ||
|
|
9dcee35427 | ||
|
|
08f5b7be8f | ||
|
|
8a6578d5ab | ||
|
|
e38a5a04ce |
@@ -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,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
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# RainWeb 软件授权协议(定制版)
|
||||||
|
|
||||||
|
**版本 1.0 · 2026-08-12**
|
||||||
|
**原作者(Initial Author):Xianyunah(Rainnya 家族)**
|
||||||
|
|
||||||
|
## 一、授权范围
|
||||||
|
|
||||||
|
本协议授予任何获得本软件副本(含源码、文档、构建产物)的个人或组织(下称"被授权人")以下权利:
|
||||||
|
|
||||||
|
1. **使用**:允许安装、运行、复制本软件,用于个人学习、研究、内部开发。
|
||||||
|
2. **修改**:允许对本软件源码进行修改、扩展、二次开发。
|
||||||
|
3. **分发**:允许分发本软件的原始版本或修改后的衍生版本。
|
||||||
|
|
||||||
|
## 二、禁止事项
|
||||||
|
|
||||||
|
被授权人**不得**:
|
||||||
|
|
||||||
|
1. **商业使用**:不得将本软件或其衍生版本用于任何商业目的,包括但不限于:
|
||||||
|
- 直接销售本软件或其衍生版本
|
||||||
|
- 提供收费的托管服务(SaaS/PaaS)
|
||||||
|
- 将本软件作为商业产品的组成部分
|
||||||
|
- 通过本软件获取直接或间接的商业收益
|
||||||
|
2. **闭源**:本软件及其衍生版本必须保持源代码公开可用,不得以任何形式闭源或隐藏源代码。
|
||||||
|
3. **使用品牌**:不得使用「Rainnya」「雨喵」「RainWeb」名称、logo、视觉标识(含紫色雨夜视觉主题)对本软件的衍生版本进行品牌宣传、背书或暗示官方身份,除非获得原作者书面授权。
|
||||||
|
|
||||||
|
## 三、义务条款(Copyleft)
|
||||||
|
|
||||||
|
1. **衍生版本必须开源**:任何基于本软件的修改、扩展、整合版本,其源代码必须遵循本协议开源发布,且必须包含本 LICENSE 文件。
|
||||||
|
2. **同协议授权**:衍生版本必须使用与本协议相同或更严格(限制更多)的授权条款。
|
||||||
|
3. **署名**:任何二次分发(原始或衍生版本)必须保留本 LICENSE 文件,并显著标明:
|
||||||
|
- 最初作者:**Xianyunah(Rainnya 家族)**
|
||||||
|
- 原始项目名称与原始仓库地址
|
||||||
|
- 若为修改版,须明确标注"基于 RainWeb 修改"并说明修改内容
|
||||||
|
|
||||||
|
## 四、免责声明
|
||||||
|
|
||||||
|
本软件按"现状"(AS IS)提供,不附带任何明示或暗示的担保,包括但不限于适销性、特定用途适用性与非侵权保证。原作者对使用本软件产生的任何损害概不负责,无论基于何种法律理论(合同、侵权或其他)。
|
||||||
|
|
||||||
|
## 五、协议终止
|
||||||
|
|
||||||
|
被授权人违反本协议任何条款,其本协议项下的所有权利自动终止,且须立即停止使用、分发并销毁全部副本。
|
||||||
|
|
||||||
|
## 六、其他
|
||||||
|
|
||||||
|
- 本协议与 GPL-3.0 的精神一致(copyleft),但额外附加了非商业与品牌保留条款,属"源代码可用(source-available)"授权,**非 OSI 认证的开源协议**。
|
||||||
|
- 若本协议任一条款被判定无效,不影响其余条款效力。
|
||||||
|
- 授权事宜咨询:rainnya.asia
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**版权 © 2024 Xianyunah(Rainnya 家族)· 保留所有权利**
|
||||||
Generated
+86
-5
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 token;builtin 为 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 上报一次性 proof;token 一次性,失败自动重拉图。
|
||||||
|
*/
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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-1:MMPI 等量表的因子归属在 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]);
|
||||||
|
|
||||||
|
// ===== dnd(Pointer/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;
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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=true(fail-closed:enabled + 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 siteKey;turnstile 模式仍需 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 = () => {
|
/** 登录/注册提交:按模式携带对应验证字段(内置=proof,Turnstile=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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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-3:dirty 跟踪(切换确认 + 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-3:dirty 时挂 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 && (
|
||||||
|
|||||||
@@ -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-1:q.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-3:dirty 时挂 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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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-icons,priority 更高)。
|
||||||
|
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,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+17
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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) => {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -73,6 +73,16 @@ function initMainDB() {
|
|||||||
dbRun('INSERT INTO settings (key, value) VALUES (?, ?)', [k, v]);
|
dbRun('INSERT INTO settings (key, value) VALUES (?, ?)', [k, v]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 验证码默认设置 seed:captcha_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 };
|
||||||
|
|||||||
+48
-9
@@ -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,12 +77,15 @@ 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 已构建)
|
||||||
const distPath = path.join(__dirname, '../client/dist');
|
let staticPath;
|
||||||
const clientPath = path.join(__dirname, '../client');
|
|
||||||
const staticPath = fs.existsSync(distPath) ? distPath : clientPath;
|
function registerStatic() {
|
||||||
app.use(express.static(staticPath));
|
const distPath = path.join(__dirname, '../client/dist');
|
||||||
app.get('*', (req, res) => {
|
const clientPath = path.join(__dirname, '../client');
|
||||||
|
staticPath = fs.existsSync(distPath) ? distPath : clientPath;
|
||||||
|
app.use(express.static(staticPath));
|
||||||
|
app.get('*', (req, res) => {
|
||||||
const filePath = path.join(staticPath, 'index.html');
|
const filePath = path.join(staticPath, 'index.html');
|
||||||
res.sendFile(filePath, err => {
|
res.sendFile(filePath, err => {
|
||||||
if (err) {
|
if (err) {
|
||||||
@@ -79,10 +93,35 @@ app.get('*', (req, res) => {
|
|||||||
res.status(500).json({ success: false, error: { code: 'SYS_001', message: '页面文件不存在' } });
|
res.status(500).json({ success: false, error: { code: 'SYS_001', message: '页面文件不存在' } });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 部署兜底: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();
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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_proof(JWT + 一次性消费)
|
||||||
|
// 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 };
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } });
|
||||||
|
|||||||
Reference in New Issue
Block a user