新增文件
- src/app/help/page.tsx — 帮助中心页面,展示更新日志,支持按月份分组 - src/app/api/update-logs/route.ts — 更新日志的 GET/POST/DELETE API
修改文件
- src/lib/db.ts — 新增 update_logs 数据表
- src/app/components/sidebar.tsx — 添加「帮助中心」导航入口
功能说明
- 页面按月份分组展示更新日志
- 每条日志显示:分类标签(新功能/Bug修复/优化改进)、版本号、标题、内容、时间
- 点击「新增更新」可添加新的更新记录
- 支持删除单条日志
- 分类图标和颜色自动区分
This commit is contained in:
parent
df2355e007
commit
a9188e9095
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__pencil__get_editor_state",
|
||||
"mcp__pencil__open_document",
|
||||
"mcp__pencil__batch_get",
|
||||
"mcp__pencil__snapshot_layout",
|
||||
"Bash(npx prisma *)",
|
||||
"Bash(export DATABASE_URL=\"file:./python_backend/device_platform.db\")",
|
||||
"Bash(npx tsc *)",
|
||||
"Bash(npx next *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
# Environment variables declared in this file are NOT automatically loaded by Prisma.
|
||||
# Please add `import "dotenv/config";` to your prisma.config.ts file, or use the Prisma CLI with Bun
|
||||
# to load environment variables from .env files: https://pris.ly/prisma-config-env-vars.
|
||||
|
||||
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
|
||||
# See the documentation for all connection string options: https://pris.ly/d/connection-strings
|
||||
|
||||
# Next.js app database (production management system)
|
||||
DATABASE_URL="file:./data/app.db"
|
||||
|
||||
# Python backend database (device activation platform)
|
||||
# DATABASE_URL="file:./python_backend/device_platform.db"
|
||||
|
|
@ -2,3 +2,5 @@ node_modules
|
|||
.next
|
||||
|
||||
data/
|
||||
|
||||
/src/generated/prisma
|
||||
|
|
|
|||
|
|
@ -1,6 +1,30 @@
|
|||
{
|
||||
"pages": {
|
||||
"/_app": []
|
||||
"/_app": [
|
||||
"static/chunks/node_modules_next_dist_compiled_0o6l_m6._.js",
|
||||
"static/chunks/node_modules_next_dist_shared_lib_0~pg0mt._.js",
|
||||
"static/chunks/node_modules_next_dist_client_0pe1dg-._.js",
|
||||
"static/chunks/node_modules_next_dist_0u_w_5s._.js",
|
||||
"static/chunks/node_modules_next_app_0jt-zj..js",
|
||||
"static/chunks/[next]_entry_page-loader_ts_0j~flwh._.js",
|
||||
"static/chunks/node_modules_react-dom_0bruynb._.js",
|
||||
"static/chunks/node_modules_0lx093h._.js",
|
||||
"static/chunks/[root-of-the-server]__0c0okpg._.js",
|
||||
"static/chunks/pages__app_07xvfw~._.js",
|
||||
"static/chunks/turbopack-pages__app_0_wu8vy._.js"
|
||||
],
|
||||
"/_error": [
|
||||
"static/chunks/node_modules_next_dist_compiled_0o6l_m6._.js",
|
||||
"static/chunks/node_modules_next_dist_shared_lib_12bi_n7._.js",
|
||||
"static/chunks/node_modules_next_dist_client_0pe1dg-._.js",
|
||||
"static/chunks/node_modules_next_dist_0rt-2cr._.js",
|
||||
"static/chunks/[next]_entry_page-loader_ts_0rqw6yo._.js",
|
||||
"static/chunks/node_modules_react-dom_0bruynb._.js",
|
||||
"static/chunks/node_modules_0lx093h._.js",
|
||||
"static/chunks/[root-of-the-server]__01mw43t._.js",
|
||||
"static/chunks/pages__error_07xvfw~._.js",
|
||||
"static/chunks/turbopack-pages__error_016chbq._.js"
|
||||
]
|
||||
},
|
||||
"devFiles": [],
|
||||
"polyfillFiles": [
|
||||
|
|
|
|||
|
|
@ -1,6 +1,30 @@
|
|||
{
|
||||
"pages": {
|
||||
"/_app": []
|
||||
"/_app": [
|
||||
"static/chunks/node_modules_next_dist_compiled_0o6l_m6._.js",
|
||||
"static/chunks/node_modules_next_dist_shared_lib_0~pg0mt._.js",
|
||||
"static/chunks/node_modules_next_dist_client_0pe1dg-._.js",
|
||||
"static/chunks/node_modules_next_dist_0u_w_5s._.js",
|
||||
"static/chunks/node_modules_next_app_0jt-zj..js",
|
||||
"static/chunks/[next]_entry_page-loader_ts_0j~flwh._.js",
|
||||
"static/chunks/node_modules_react-dom_0bruynb._.js",
|
||||
"static/chunks/node_modules_0lx093h._.js",
|
||||
"static/chunks/[root-of-the-server]__0c0okpg._.js",
|
||||
"static/chunks/pages__app_07xvfw~._.js",
|
||||
"static/chunks/turbopack-pages__app_0_wu8vy._.js"
|
||||
],
|
||||
"/_error": [
|
||||
"static/chunks/node_modules_next_dist_compiled_0o6l_m6._.js",
|
||||
"static/chunks/node_modules_next_dist_shared_lib_12bi_n7._.js",
|
||||
"static/chunks/node_modules_next_dist_client_0pe1dg-._.js",
|
||||
"static/chunks/node_modules_next_dist_0rt-2cr._.js",
|
||||
"static/chunks/[next]_entry_page-loader_ts_0rqw6yo._.js",
|
||||
"static/chunks/node_modules_react-dom_0bruynb._.js",
|
||||
"static/chunks/node_modules_0lx093h._.js",
|
||||
"static/chunks/[root-of-the-server]__01mw43t._.js",
|
||||
"static/chunks/pages__error_07xvfw~._.js",
|
||||
"static/chunks/turbopack-pages__error_016chbq._.js"
|
||||
]
|
||||
},
|
||||
"devFiles": [],
|
||||
"polyfillFiles": [],
|
||||
|
|
|
|||
|
|
@ -1,62 +1,78 @@
|
|||
{"timestamp":"00:00:02.205","source":"Server","level":"LOG","message":""}
|
||||
{"timestamp":"00:00:04.101","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
|
||||
{"timestamp":"00:00:18.274","source":"Server","level":"ERROR","message":"⨯ SqliteError: no such column: \"\" - should this be a string literal in single-quotes?"}
|
||||
{"timestamp":"00:08:58.067","source":"Server","level":"LOG","message":"✓ Compiled in 107ms"}
|
||||
{"timestamp":"00:08:58.497","source":"Browser","level":"ERROR","message":"uncaughtError: ReferenceError: versionsByType is not defined"}
|
||||
{"timestamp":"00:08:58.523","source":"Server","level":"ERROR","message":"[browser] \"\\u001b[31mUncaught ReferenceError: versionsByType is not defined\\u001b[39m\\n\\u001b[31m at eval (src/app/materials/register/page.tsx:149:21)\\n at Array.map (<anonymous>)\\n at BoardRegisterPage (src/app/materials/register/page.tsx:123:18)\\u001b[39m\\n \\u001b[90m147 |\\u001b[0m <label style={{ display: \\u001b[32m'block'\\u001b[0m, fontSize: \\u001b[35m13\\u001b[0m, fontWeight: \\u001b[35m500\\u001b[0m, marginBottom: \\u001b[35m6\\u001b[0m }}><span style={{ color: \\u001b[32m'#FF4D4F'\\u001b[0m }}>*<\\u001b[35m/span...\\u001b[0m\\n \\u001b[90m148 |\\u001b[0m <select value={entry.version} onChange={e => updateEntry(entry.id, \\u001b[32m'version'\\u001b[0m, e.target.value)} style={{ width: \\u001b[32m'100%'\\u001b[0m, padding..\\u001b[32m.\\u001b[0m\\n\\u001b[31m\\u001b[1m>\\u001b[0m \\u001b[90m149 |\\u001b[0m {(versionsByType[entry.\\u001b[36mtype\\u001b[0m] || []).map(m => <option key={m.version} value={m.version}>{m.version}</option>)}\\n \\u001b[90m |\\u001b[0m \\u001b[31m\\u001b[1m^\\u001b[0m\\n \\u001b[90m150 |\\u001b[0m </select>\\n \\u001b[90m151 |\\u001b[0m </div>\\n \\u001b[90m152 |\\u001b[0m\""}
|
||||
{"timestamp":"00:08:58.525","source":"Browser","level":"ERROR","message":"\u001b[31mUncaught ReferenceError: versionsByType is not defined\u001b[39m\n\u001b[31m at eval (src/app/materials/register/page.tsx:149:21)\n at Array.map (<anonymous>)\n at BoardRegisterPage (src/app/materials/register/page.tsx:123:18)\u001b[39m\n \u001b[90m147 |\u001b[0m <label style={{ display: \u001b[32m'block'\u001b[0m, fontSize: \u001b[35m13\u001b[0m, fontWeight: \u001b[35m500\u001b[0m, marginBottom: \u001b[35m6\u001b[0m }}><span style={{ color: \u001b[32m'#FF4D4F'\u001b[0m }}>*<\u001b[35m/span...\u001b[0m\n \u001b[90m148 |\u001b[0m <select value={entry.version} onChange={e => updateEntry(entry.id, \u001b[32m'version'\u001b[0m, e.target.value)} style={{ width: \u001b[32m'100%'\u001b[0m, padding..\u001b[32m.\u001b[0m\n\u001b[31m\u001b[1m>\u001b[0m \u001b[90m149 |\u001b[0m {(versionsByType[entry.\u001b[36mtype\u001b[0m] || []).map(m => <option key={m.version} value={m.version}>{m.version}</option>)}\n \u001b[90m |\u001b[0m \u001b[31m\u001b[1m^\u001b[0m\n \u001b[90m150 |\u001b[0m </select>\n \u001b[90m151 |\u001b[0m </div>\n \u001b[90m152 |\u001b[0m"}
|
||||
{"timestamp":"00:09:16.060","source":"Server","level":"LOG","message":"✓ Compiled in 69ms"}
|
||||
{"timestamp":"00:09:16.065","source":"Server","level":"WARN","message":"⚠ Fast Refresh had to perform a full reload due to a runtime error."}
|
||||
{"timestamp":"00:09:16.485","source":"Server","level":"ERROR","message":"⨯ ReferenceError: versionsByType is not defined"}
|
||||
{"timestamp":"00:09:16.517","source":"Browser","level":"ERROR","message":"uncaughtError: ReferenceError: versionsByType is not defined"}
|
||||
{"timestamp":"00:09:16.538","source":"Server","level":"ERROR","message":"[browser] \"\\u001b[31mUncaught ReferenceError: versionsByType is not defined\\u001b[39m\\n\\u001b[31m at eval (src/app/materials/register/page.tsx:140:21)\\n at Array.map (<anonymous>)\\n at BoardRegisterPage (src/app/materials/register/page.tsx:114:18)\\n at Set.forEach (<anonymous>)\\u001b[39m\\n \\u001b[90m138 |\\u001b[0m <label style={{ display: \\u001b[32m'block'\\u001b[0m, fontSize: \\u001b[35m13\\u001b[0m, fontWeight: \\u001b[35m500\\u001b[0m, marginBottom: \\u001b[35m6\\u001b[0m }}><span style={{ color: \\u001b[32m'#FF4D4F'\\u001b[0m }}>*<\\u001b[35m/span...\\u001b[0m\\n \\u001b[90m139 |\\u001b[0m <select value={entry.version} onChange={e => updateEntry(entry.id, \\u001b[32m'version'\\u001b[0m, e.target.value)} style={{ width: \\u001b[32m'100%'\\u001b[0m, padding..\\u001b[32m.\\u001b[0m\\n\\u001b[31m\\u001b[1m>\\u001b[0m \\u001b[90m140 |\\u001b[0m {(versionsByType[entry.\\u001b[36mtype\\u001b[0m] || []).map(m => <option key={m.version} value={m.version}>{m.version}</option>)}\\n \\u001b[90m |\\u001b[0m \\u001b[31m\\u001b[1m^\\u001b[0m\\n \\u001b[90m141 |\\u001b[0m </select>\\n \\u001b[90m142 |\\u001b[0m </div>\\n \\u001b[90m143 |\\u001b[0m\""}
|
||||
{"timestamp":"00:09:16.539","source":"Browser","level":"ERROR","message":"\u001b[31mUncaught ReferenceError: versionsByType is not defined\u001b[39m\n\u001b[31m at eval (src/app/materials/register/page.tsx:140:21)\n at Array.map (<anonymous>)\n at BoardRegisterPage (src/app/materials/register/page.tsx:114:18)\n at Set.forEach (<anonymous>)\u001b[39m\n \u001b[90m138 |\u001b[0m <label style={{ display: \u001b[32m'block'\u001b[0m, fontSize: \u001b[35m13\u001b[0m, fontWeight: \u001b[35m500\u001b[0m, marginBottom: \u001b[35m6\u001b[0m }}><span style={{ color: \u001b[32m'#FF4D4F'\u001b[0m }}>*<\u001b[35m/span...\u001b[0m\n \u001b[90m139 |\u001b[0m <select value={entry.version} onChange={e => updateEntry(entry.id, \u001b[32m'version'\u001b[0m, e.target.value)} style={{ width: \u001b[32m'100%'\u001b[0m, padding..\u001b[32m.\u001b[0m\n\u001b[31m\u001b[1m>\u001b[0m \u001b[90m140 |\u001b[0m {(versionsByType[entry.\u001b[36mtype\u001b[0m] || []).map(m => <option key={m.version} value={m.version}>{m.version}</option>)}\n \u001b[90m |\u001b[0m \u001b[31m\u001b[1m^\u001b[0m\n \u001b[90m141 |\u001b[0m </select>\n \u001b[90m142 |\u001b[0m </div>\n \u001b[90m143 |\u001b[0m"}
|
||||
{"timestamp":"00:09:16.654","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
|
||||
{"timestamp":"00:09:16.820","source":"Browser","level":"ERROR","message":"uncaughtError: ReferenceError: versionsByType is not defined"}
|
||||
{"timestamp":"00:09:16.825","source":"Server","level":"ERROR","message":"[browser] \"\\u001b[31mUncaught ReferenceError: versionsByType is not defined\\u001b[39m\\n\\u001b[31m at <unknown> (src/app/materials/register/page.tsx:140:21)\\n at Array.map (<anonymous>)\\n at BoardRegisterPage (src/app/materials/register/page.tsx:114:18)\\u001b[39m\\n \\u001b[90m138 |\\u001b[0m <label style={{ display: \\u001b[32m'block'\\u001b[0m, fontSize: \\u001b[35m13\\u001b[0m, fontWeight: \\u001b[35m500\\u001b[0m, marginBottom: \\u001b[35m6\\u001b[0m }}><span style={{ color: \\u001b[32m'#FF4D4F'\\u001b[0m }}>*<\\u001b[35m/span...\\u001b[0m\\n \\u001b[90m139 |\\u001b[0m <select value={entry.version} onChange={e => updateEntry(entry.id, \\u001b[32m'version'\\u001b[0m, e.target.value)} style={{ width: \\u001b[32m'100%'\\u001b[0m, padding..\\u001b[32m.\\u001b[0m\\n\\u001b[31m\\u001b[1m>\\u001b[0m \\u001b[90m140 |\\u001b[0m {(versionsByType[entry.\\u001b[36mtype\\u001b[0m] || []).map(m => <option key={m.version} value={m.version}>{m.version}</option>)}\\n \\u001b[90m |\\u001b[0m \\u001b[31m\\u001b[1m^\\u001b[0m\\n \\u001b[90m141 |\\u001b[0m </select>\\n \\u001b[90m142 |\\u001b[0m </div>\\n \\u001b[90m143 |\\u001b[0m\""}
|
||||
{"timestamp":"00:09:16.826","source":"Browser","level":"ERROR","message":"\u001b[31mUncaught ReferenceError: versionsByType is not defined\u001b[39m\n\u001b[31m at <unknown> (src/app/materials/register/page.tsx:140:21)\n at Array.map (<anonymous>)\n at BoardRegisterPage (src/app/materials/register/page.tsx:114:18)\u001b[39m\n \u001b[90m138 |\u001b[0m <label style={{ display: \u001b[32m'block'\u001b[0m, fontSize: \u001b[35m13\u001b[0m, fontWeight: \u001b[35m500\u001b[0m, marginBottom: \u001b[35m6\u001b[0m }}><span style={{ color: \u001b[32m'#FF4D4F'\u001b[0m }}>*<\u001b[35m/span...\u001b[0m\n \u001b[90m139 |\u001b[0m <select value={entry.version} onChange={e => updateEntry(entry.id, \u001b[32m'version'\u001b[0m, e.target.value)} style={{ width: \u001b[32m'100%'\u001b[0m, padding..\u001b[32m.\u001b[0m\n\u001b[31m\u001b[1m>\u001b[0m \u001b[90m140 |\u001b[0m {(versionsByType[entry.\u001b[36mtype\u001b[0m] || []).map(m => <option key={m.version} value={m.version}>{m.version}</option>)}\n \u001b[90m |\u001b[0m \u001b[31m\u001b[1m^\u001b[0m\n \u001b[90m141 |\u001b[0m </select>\n \u001b[90m142 |\u001b[0m </div>\n \u001b[90m143 |\u001b[0m"}
|
||||
{"timestamp":"00:09:30.738","source":"Server","level":"LOG","message":"✓ Compiled in 99ms"}
|
||||
{"timestamp":"00:09:30.748","source":"Server","level":"WARN","message":"⚠ Fast Refresh had to perform a full reload due to a runtime error."}
|
||||
{"timestamp":"00:09:31.514","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
|
||||
{"timestamp":"00:09:43.714","source":"Server","level":"LOG","message":"✓ Compiled in 69ms"}
|
||||
{"timestamp":"00:09:55.643","source":"Server","level":"LOG","message":"✓ Compiled in 51ms"}
|
||||
{"timestamp":"00:10:12.495","source":"Server","level":"LOG","message":"✓ Compiled in 68ms"}
|
||||
{"timestamp":"00:16:23.501","source":"Server","level":"LOG","message":"✓ Compiled in 53ms"}
|
||||
{"timestamp":"00:16:38.076","source":"Server","level":"LOG","message":"✓ Compiled in 42ms"}
|
||||
{"timestamp":"00:16:54.064","source":"Server","level":"LOG","message":"✓ Compiled in 65ms"}
|
||||
{"timestamp":"00:17:02.570","source":"Server","level":"LOG","message":"✓ Compiled in 56ms"}
|
||||
{"timestamp":"00:17:13.333","source":"Server","level":"LOG","message":"✓ Compiled in 81ms"}
|
||||
{"timestamp":"00:17:21.099","source":"Server","level":"LOG","message":"✓ Compiled in 70ms"}
|
||||
{"timestamp":"00:17:31.082","source":"Server","level":"LOG","message":"✓ Compiled in 110ms"}
|
||||
{"timestamp":"00:17:45.317","source":"Server","level":"LOG","message":"✓ Compiled in 61ms"}
|
||||
{"timestamp":"00:19:03.427","source":"Server","level":"ERROR","message":"⨯ SqliteError: UNIQUE constraint failed: materials.sn"}
|
||||
{"timestamp":"00:21:25.131","source":"Server","level":"LOG","message":"✓ Compiled in 92ms"}
|
||||
{"timestamp":"00:21:42.436","source":"Server","level":"LOG","message":"✓ Compiled in 77ms"}
|
||||
{"timestamp":"00:21:53.516","source":"Server","level":"LOG","message":"✓ Compiled in 89ms"}
|
||||
{"timestamp":"00:22:23.547","source":"Server","level":"LOG","message":"✓ Compiled in 50ms"}
|
||||
{"timestamp":"00:22:45.750","source":"Server","level":"LOG","message":"✓ Compiled in 71ms"}
|
||||
{"timestamp":"00:36:02.534","source":"Server","level":"LOG","message":"✓ Compiled in 78ms"}
|
||||
{"timestamp":"00:36:14.558","source":"Server","level":"LOG","message":"✓ Compiled in 65ms"}
|
||||
{"timestamp":"00:36:29.877","source":"Server","level":"LOG","message":"✓ Compiled in 55ms"}
|
||||
{"timestamp":"00:37:04.371","source":"Server","level":"LOG","message":"✓ Compiled in 68ms"}
|
||||
{"timestamp":"00:37:17.354","source":"Server","level":"LOG","message":"✓ Compiled in 47ms"}
|
||||
{"timestamp":"00:37:26.083","source":"Server","level":"LOG","message":"✓ Compiled in 51ms"}
|
||||
{"timestamp":"00:38:29.212","source":"Server","level":"LOG","message":"✓ Compiled in 68ms"}
|
||||
{"timestamp":"00:38:40.701","source":"Server","level":"LOG","message":"✓ Compiled in 70ms"}
|
||||
{"timestamp":"00:39:19.999","source":"Server","level":"LOG","message":"✓ Compiled in 46ms"}
|
||||
{"timestamp":"00:39:32.065","source":"Server","level":"LOG","message":"✓ Compiled in 72ms"}
|
||||
{"timestamp":"00:39:42.768","source":"Server","level":"LOG","message":"✓ Compiled in 53ms"}
|
||||
{"timestamp":"00:41:04.081","source":"Server","level":"LOG","message":"✓ Compiled in 65ms"}
|
||||
{"timestamp":"00:41:12.582","source":"Server","level":"LOG","message":"✓ Compiled in 72ms"}
|
||||
{"timestamp":"00:45:52.932","source":"Server","level":"LOG","message":"✓ Compiled in 92ms"}
|
||||
{"timestamp":"00:46:08.338","source":"Server","level":"LOG","message":"✓ Compiled in 146ms"}
|
||||
{"timestamp":"00:49:04.049","source":"Server","level":"LOG","message":"✓ Compiled in 110ms"}
|
||||
{"timestamp":"00:49:23.180","source":"Server","level":"LOG","message":"✓ Compiled in 75ms"}
|
||||
{"timestamp":"00:51:39.013","source":"Server","level":"LOG","message":"✓ Compiled in 58ms"}
|
||||
{"timestamp":"00:51:47.469","source":"Server","level":"LOG","message":"✓ Compiled in 63ms"}
|
||||
{"timestamp":"00:52:00.952","source":"Server","level":"LOG","message":"✓ Compiled in 46ms"}
|
||||
{"timestamp":"00:52:11.991","source":"Server","level":"LOG","message":"✓ Compiled in 87ms"}
|
||||
{"timestamp":"00:52:30.998","source":"Server","level":"LOG","message":"✓ Compiled in 143ms"}
|
||||
{"timestamp":"00:52:44.891","source":"Server","level":"LOG","message":"✓ Compiled in 89ms"}
|
||||
{"timestamp":"00:53:28.683","source":"Server","level":"LOG","message":"✓ Compiled in 47ms"}
|
||||
{"timestamp":"00:53:39.177","source":"Server","level":"LOG","message":"✓ Compiled in 42ms"}
|
||||
{"timestamp":"00:00:01.425","source":"Server","level":"LOG","message":""}
|
||||
{"timestamp":"00:00:03.004","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
|
||||
{"timestamp":"00:00:03.184","source":"Server","level":"LOG","message":"Migration already applied or not needed"}
|
||||
{"timestamp":"00:06:22.835","source":"Server","level":"LOG","message":"✓ Compiled in 751ms"}
|
||||
{"timestamp":"00:06:23.155","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
|
||||
{"timestamp":"00:06:28.748","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
|
||||
{"timestamp":"00:06:30.699","source":"Server","level":"LOG","message":"Migration already applied or not needed"}
|
||||
{"timestamp":"01:51:12.928","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
|
||||
{"timestamp":"01:51:13.389","source":"Server","level":"LOG","message":"Migration already applied or not needed"}
|
||||
{"timestamp":"01:51:15.872","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
|
||||
{"timestamp":"01:56:33.431","source":"Server","level":"LOG","message":" Reload env: .env"}
|
||||
{"timestamp":"01:56:34.029","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
|
||||
{"timestamp":"01:58:16.266","source":"Server","level":"LOG","message":" Reload env: .env"}
|
||||
{"timestamp":"01:58:16.904","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
|
||||
{"timestamp":"01:58:46.788","source":"Server","level":"LOG","message":"Migration already applied or not needed"}
|
||||
{"timestamp":"02:22:34.080","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
|
||||
{"timestamp":"02:24:33.238","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
|
||||
{"timestamp":"02:26:25.719","source":"Server","level":"LOG","message":"✓ Compiled in 293ms"}
|
||||
{"timestamp":"02:26:26.345","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
|
||||
{"timestamp":"02:32:43.980","source":"Server","level":"LOG","message":"Migration already applied or not needed"}
|
||||
{"timestamp":"02:46:29.696","source":"Server","level":"LOG","message":"✓ Compiled in 100ms"}
|
||||
{"timestamp":"02:46:29.883","source":"Server","level":"LOG","message":"Migration already applied or not needed"}
|
||||
{"timestamp":"02:46:47.041","source":"Server","level":"LOG","message":"✓ Compiled in 99ms"}
|
||||
{"timestamp":"02:46:47.262","source":"Server","level":"LOG","message":"Migration already applied or not needed"}
|
||||
{"timestamp":"02:47:06.184","source":"Server","level":"LOG","message":"✓ Compiled in 80ms"}
|
||||
{"timestamp":"02:47:06.391","source":"Server","level":"LOG","message":"Migration already applied or not needed"}
|
||||
{"timestamp":"02:47:18.104","source":"Server","level":"LOG","message":"✓ Compiled in 72ms"}
|
||||
{"timestamp":"02:47:18.317","source":"Server","level":"LOG","message":"Migration already applied or not needed"}
|
||||
{"timestamp":"02:47:37.387","source":"Server","level":"LOG","message":"✓ Compiled in 136ms"}
|
||||
{"timestamp":"02:47:37.647","source":"Server","level":"LOG","message":"Migration already applied or not needed"}
|
||||
{"timestamp":"02:47:54.919","source":"Server","level":"LOG","message":"✓ Compiled in 98ms"}
|
||||
{"timestamp":"02:47:55.137","source":"Server","level":"LOG","message":"Migration already applied or not needed"}
|
||||
{"timestamp":"02:48:05.331","source":"Server","level":"LOG","message":"✓ Compiled in 68ms"}
|
||||
{"timestamp":"02:48:05.557","source":"Server","level":"LOG","message":"Migration already applied or not needed"}
|
||||
{"timestamp":"02:48:14.643","source":"Server","level":"LOG","message":"✓ Compiled in 81ms"}
|
||||
{"timestamp":"02:48:14.833","source":"Server","level":"LOG","message":"Migration already applied or not needed"}
|
||||
{"timestamp":"02:48:21.232","source":"Server","level":"LOG","message":"✓ Compiled in 72ms"}
|
||||
{"timestamp":"02:48:21.474","source":"Server","level":"LOG","message":"Migration already applied or not needed"}
|
||||
{"timestamp":"02:48:27.425","source":"Server","level":"LOG","message":"✓ Compiled in 64ms"}
|
||||
{"timestamp":"02:48:27.612","source":"Server","level":"LOG","message":"Migration already applied or not needed"}
|
||||
{"timestamp":"02:48:43.941","source":"Server","level":"LOG","message":"✓ Compiled in 62ms"}
|
||||
{"timestamp":"02:48:44.154","source":"Server","level":"LOG","message":"Migration already applied or not needed"}
|
||||
{"timestamp":"02:48:57.664","source":"Server","level":"LOG","message":"✓ Compiled in 58ms"}
|
||||
{"timestamp":"02:48:57.857","source":"Server","level":"LOG","message":"Migration already applied or not needed"}
|
||||
{"timestamp":"02:49:03.427","source":"Server","level":"LOG","message":"✓ Compiled in 73ms"}
|
||||
{"timestamp":"02:49:03.612","source":"Server","level":"LOG","message":"Migration already applied or not needed"}
|
||||
{"timestamp":"02:49:12.578","source":"Server","level":"LOG","message":"✓ Compiled in 74ms"}
|
||||
{"timestamp":"02:49:12.897","source":"Server","level":"LOG","message":"Migration already applied or not needed"}
|
||||
{"timestamp":"02:49:18.089","source":"Server","level":"LOG","message":"✓ Compiled in 90ms"}
|
||||
{"timestamp":"02:49:18.112","source":"Server","level":"ERROR","message":"⨯ ./src/app/app-versions/page.tsx:575:1\nUnexpected token. Did you mean `{'}'}` or `}`?\n 573 | </div>\n 574 | )\n> 575 | }\n | ^\n 576 |\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/app-versions/page.tsx [Client Component Browser]\n ./src/app/app-versions/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/app-versions/page.tsx [Client Component SSR]\n ./src/app/app-versions/page.tsx [Server Component]\n\n"}
|
||||
{"timestamp":"02:49:18.431","source":"Browser","level":"ERROR","message":"./src/app/app-versions/page.tsx:575:1\nUnexpected token. Did you mean `{'}'}` or `}`?\n 573 | </div>\n 574 | )\n> 575 | }\n | ^\n 576 |\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/app-versions/page.tsx [Client Component Browser]\n ./src/app/app-versions/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/app-versions/page.tsx [Client Component SSR]\n ./src/app/app-versions/page.tsx [Server Component]"}
|
||||
{"timestamp":"02:49:18.432","source":"Browser","level":"ERROR","message":"./src/app/app-versions/page.tsx:575:1\nUnexpected token. Did you mean `{'}'}` or `}`?\n 573 | </div>\n 574 | )\n> 575 | }\n | ^\n 576 |\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/app-versions/page.tsx [Client Component Browser]\n ./src/app/app-versions/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/app-versions/page.tsx [Client Component SSR]\n ./src/app/app-versions/page.tsx [Server Component]"}
|
||||
{"timestamp":"02:49:18.435","source":"Server","level":"ERROR","message":"⨯ ./src/app/app-versions/page.tsx:575:1\nUnexpected token. Did you mean `{'}'}` or `}`?\n 573 | </div>\n 574 | )\n> 575 | }\n | ^\n 576 |\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/app-versions/page.tsx [Client Component Browser]\n ./src/app/app-versions/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/app-versions/page.tsx [Client Component SSR]\n ./src/app/app-versions/page.tsx [Server Component]\n\n"}
|
||||
{"timestamp":"02:49:18.459","source":"Server","level":"ERROR","message":"⨯ ./src/app/app-versions/page.tsx:575:1\nUnexpected token. Did you mean `{'}'}` or `}`?\n 573 | </div>\n 574 | )\n> 575 | }\n | ^\n 576 |\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/app-versions/page.tsx [Client Component Browser]\n ./src/app/app-versions/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/app-versions/page.tsx [Client Component SSR]\n ./src/app/app-versions/page.tsx [Server Component]\n\n"}
|
||||
{"timestamp":"02:49:18.475","source":"Server","level":"ERROR","message":"⨯ ./src/app/app-versions/page.tsx:575:1\nUnexpected token. Did you mean `{'}'}` or `}`?\n 573 | </div>\n 574 | )\n> 575 | }\n | ^\n 576 |\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/app-versions/page.tsx [Client Component Browser]\n ./src/app/app-versions/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/app-versions/page.tsx [Client Component SSR]\n ./src/app/app-versions/page.tsx [Server Component]\n\n"}
|
||||
{"timestamp":"02:49:18.598","source":"Browser","level":"ERROR","message":"uncaughtError: Error: ./src/app/app-versions/page.tsx:575:1\nUnexpected token. Did you mean `{'}'}` or `}`?\n 573 | </div>\n 574 | )\n> 575 | }\n | ^\n 576 |\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/app-versions/page.tsx [Client Component Browser]\n ./src/app/app-versions/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/app-versions/page.tsx [Client Component SSR]\n ./src/app/app-versions/page.tsx [Server Component]\n\n"}
|
||||
{"timestamp":"02:49:18.613","source":"Server","level":"ERROR","message":"[browser] \"\\u001b[31mUncaught Error: ./src/app/app-versions/page.tsx:575:1\\nUnexpected token. Did you mean `{'}'}` or `}`?\\n 573 | </div>\\n 574 | )\\n> 575 | }\\n | ^\\n 576 |\\n\\nParsing ecmascript source code failed\\n\\nImport traces:\\n Client Component Browser:\\n ./src/app/app-versions/page.tsx [Client Component Browser]\\n ./src/app/app-versions/page.tsx [Server Component]\\n\\n Client Component SSR:\\n ./src/app/app-versions/page.tsx [Client Component SSR]\\n ./src/app/app-versions/page.tsx [Server Component]\\n\\n\\u001b[39m\\n\\u001b[31m at <unknown> (Error: ./src/app/app-versions/page.tsx:575:1)\\n at <unknown> (Error: (./src/app/app-versions/page.tsx:575:1)\\u001b[39m\""}
|
||||
{"timestamp":"02:49:18.614","source":"Browser","level":"ERROR","message":"\u001b[31mUncaught Error: ./src/app/app-versions/page.tsx:575:1\nUnexpected token. Did you mean `{'}'}` or `}`?\n 573 | </div>\n 574 | )\n> 575 | }\n | ^\n 576 |\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/app-versions/page.tsx [Client Component Browser]\n ./src/app/app-versions/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/app-versions/page.tsx [Client Component SSR]\n ./src/app/app-versions/page.tsx [Server Component]\n\n\u001b[39m\n\u001b[31m at <unknown> (Error: ./src/app/app-versions/page.tsx:575:1)\n at <unknown> (Error: (./src/app/app-versions/page.tsx:575:1)\u001b[39m"}
|
||||
{"timestamp":"02:49:19.456","source":"Browser","level":"ERROR","message":"./src/app/app-versions/page.tsx:575:1\nUnexpected token. Did you mean `{'}'}` or `}`?\n 573 | </div>\n 574 | )\n> 575 | }\n | ^\n 576 |\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/app-versions/page.tsx [Client Component Browser]\n ./src/app/app-versions/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/app-versions/page.tsx [Client Component SSR]\n ./src/app/app-versions/page.tsx [Server Component]"}
|
||||
{"timestamp":"02:49:19.456","source":"Browser","level":"ERROR","message":"./src/app/app-versions/page.tsx:575:1\nUnexpected token. Did you mean `{'}'}` or `}`?\n 573 | </div>\n 574 | )\n> 575 | }\n | ^\n 576 |\n\nParsing ecmascript source code failed\n\nImport traces:\n Client Component Browser:\n ./src/app/app-versions/page.tsx [Client Component Browser]\n ./src/app/app-versions/page.tsx [Server Component]\n\n Client Component SSR:\n ./src/app/app-versions/page.tsx [Client Component SSR]\n ./src/app/app-versions/page.tsx [Server Component]"}
|
||||
{"timestamp":"02:49:27.394","source":"Server","level":"LOG","message":"✓ Compiled in 111ms"}
|
||||
{"timestamp":"02:49:27.793","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
|
||||
{"timestamp":"02:49:27.942","source":"Server","level":"LOG","message":"Migration already applied or not needed"}
|
||||
{"timestamp":"02:53:27.533","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
|
||||
{"timestamp":"02:58:25.018","source":"Server","level":"LOG","message":"✓ Compiled in 256ms"}
|
||||
{"timestamp":"02:58:25.550","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
|
||||
{"timestamp":"02:58:25.803","source":"Server","level":"LOG","message":"Migration already applied or not needed"}
|
||||
{"timestamp":"03:01:14.680","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
|
||||
{"timestamp":"03:01:16.619","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
|
||||
{"timestamp":"03:01:17.958","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
|
||||
{"timestamp":"03:01:22.532","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
|
||||
{"timestamp":"03:01:24.918","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
|
||||
{"timestamp":"03:01:26.983","source":"Browser","level":"INFO","message":"%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold"}
|
||||
{"timestamp":"03:03:07.012","source":"Server","level":"LOG","message":"Migration already applied or not needed"}
|
||||
{"timestamp":"03:03:16.147","source":"Server","level":"LOG","message":"Migration already applied or not needed"}
|
||||
{"timestamp":"03:03:19.121","source":"Server","level":"LOG","message":"Migration already applied or not needed"}
|
||||
{"timestamp":"03:03:19.178","source":"Server","level":"LOG","message":"Migration already applied or not needed"}
|
||||
{"timestamp":"03:03:19.270","source":"Server","level":"LOG","message":"Migration already applied or not needed"}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
"dynamicRoutes": {},
|
||||
"notFoundRoutes": [],
|
||||
"preview": {
|
||||
"previewModeId": "2947f9339b9bc375ef2ec0325fd70e0c",
|
||||
"previewModeSigningKey": "5fbfa8d7aaae4f6432fbd46cb9d1d06b81f02018d11093ad9f7c9c31305961d2",
|
||||
"previewModeEncryptionKey": "ef8ca519881bb07275db39a1c0b131c870bd7a64c5097b76aef811ad5130b44e"
|
||||
"previewModeId": "fe61c2dd5a30fb2a5230e827694c71f5",
|
||||
"previewModeSigningKey": "490f7269a31770e47550ec91c2476e6a3c76f293ef213e3776f82b577afcfa86",
|
||||
"previewModeEncryptionKey": "457f8f228d581668b6d69cfa6157f75c153bff7d7f33c7e5ba96b6056d88d0a9"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +1,27 @@
|
|||
{
|
||||
"/_not-found/page": "app/_not-found/page.js",
|
||||
"/api/app-versions/route": "app/api/app-versions/route.js",
|
||||
"/api/dashboard/route": "app/api/dashboard/route.js",
|
||||
"/api/devices/route": "app/api/devices/route.js",
|
||||
"/api/firmware/route": "app/api/firmware/route.js",
|
||||
"/api/material-categories/route": "app/api/material-categories/route.js",
|
||||
"/api/material-types/route": "app/api/material-types/route.js",
|
||||
"/api/material-versions/route": "app/api/material-versions/route.js",
|
||||
"/api/materials/route": "app/api/materials/route.js",
|
||||
"/api/models/checklist/route": "app/api/models/checklist/route.js",
|
||||
"/api/models/route": "app/api/models/route.js",
|
||||
"/api/repair/route": "app/api/repair/route.js",
|
||||
"/api/scrap/route": "app/api/scrap/route.js",
|
||||
"/api/update-logs/route": "app/api/update-logs/route.js",
|
||||
"/api/upload/route": "app/api/upload/route.js",
|
||||
"/app-versions/page": "app/app-versions/page.js",
|
||||
"/devices/page": "app/devices/page.js",
|
||||
"/firmware/page": "app/firmware/page.js",
|
||||
"/help/page": "app/help/page.js",
|
||||
"/materials/categories/page": "app/materials/categories/page.js",
|
||||
"/materials/manage/page": "app/materials/manage/page.js",
|
||||
"/materials/page": "app/materials/page.js",
|
||||
"/materials/register/page": "app/materials/register/page.js",
|
||||
"/models/page": "app/models/page.js",
|
||||
"/page": "app/page.js",
|
||||
"/registration/page": "app/registration/page.js"
|
||||
"/python-devices/page": "app/python-devices/page.js",
|
||||
"/repair/page": "app/repair/page.js",
|
||||
"/scrap/page": "app/scrap/page.js"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,6 +1,30 @@
|
|||
globalThis.__BUILD_MANIFEST = {
|
||||
"pages": {
|
||||
"/_app": []
|
||||
"/_app": [
|
||||
"static/chunks/node_modules_next_dist_compiled_0o6l_m6._.js",
|
||||
"static/chunks/node_modules_next_dist_shared_lib_0~pg0mt._.js",
|
||||
"static/chunks/node_modules_next_dist_client_0pe1dg-._.js",
|
||||
"static/chunks/node_modules_next_dist_0u_w_5s._.js",
|
||||
"static/chunks/node_modules_next_app_0jt-zj..js",
|
||||
"static/chunks/[next]_entry_page-loader_ts_0j~flwh._.js",
|
||||
"static/chunks/node_modules_react-dom_0bruynb._.js",
|
||||
"static/chunks/node_modules_0lx093h._.js",
|
||||
"static/chunks/[root-of-the-server]__0c0okpg._.js",
|
||||
"static/chunks/pages__app_07xvfw~._.js",
|
||||
"static/chunks/turbopack-pages__app_0_wu8vy._.js"
|
||||
],
|
||||
"/_error": [
|
||||
"static/chunks/node_modules_next_dist_compiled_0o6l_m6._.js",
|
||||
"static/chunks/node_modules_next_dist_shared_lib_12bi_n7._.js",
|
||||
"static/chunks/node_modules_next_dist_client_0pe1dg-._.js",
|
||||
"static/chunks/node_modules_next_dist_0rt-2cr._.js",
|
||||
"static/chunks/[next]_entry_page-loader_ts_0rqw6yo._.js",
|
||||
"static/chunks/node_modules_react-dom_0bruynb._.js",
|
||||
"static/chunks/node_modules_0lx093h._.js",
|
||||
"static/chunks/[root-of-the-server]__01mw43t._.js",
|
||||
"static/chunks/pages__error_07xvfw~._.js",
|
||||
"static/chunks/turbopack-pages__error_016chbq._.js"
|
||||
]
|
||||
},
|
||||
"devFiles": [],
|
||||
"polyfillFiles": [
|
||||
|
|
|
|||
|
|
@ -1 +1,5 @@
|
|||
{}
|
||||
{
|
||||
"/_app": "pages/_app.js",
|
||||
"/_document": "pages/_document.js",
|
||||
"/_error": "pages/_error.js"
|
||||
}
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
self.__BUILD_MANIFEST = {
|
||||
"/_error": [
|
||||
"static/chunks/pages/_error.js"
|
||||
],
|
||||
"__rewrites": {
|
||||
"afterFiles": [],
|
||||
"beforeFiles": [],
|
||||
|
|
|
|||
233
.next/dev/trace
233
.next/dev/trace
File diff suppressed because one or more lines are too long
|
|
@ -1,8 +1,8 @@
|
|||
// This file is generated automatically by Next.js
|
||||
// Do not edit this file manually
|
||||
|
||||
type AppRoutes = "/" | "/app-versions" | "/config-files" | "/devices" | "/devices/[sn]" | "/firmware" | "/licenses" | "/materials" | "/materials/categories" | "/materials/manage" | "/materials/register" | "/models" | "/models/bom" | "/registration" | "/repair" | "/scrap"
|
||||
type AppRouteHandlerRoutes = "/api/app-versions" | "/api/board-types" | "/api/boards" | "/api/config-files" | "/api/dashboard" | "/api/devices" | "/api/firmware" | "/api/licenses" | "/api/material-categories" | "/api/material-types" | "/api/material-versions" | "/api/materials" | "/api/models" | "/api/models/bom" | "/api/models/checklist" | "/api/repair" | "/api/scrap"
|
||||
type AppRoutes = "/" | "/app-versions" | "/config-files" | "/devices" | "/devices/[sn]" | "/firmware" | "/help" | "/licenses" | "/materials" | "/materials/categories" | "/materials/manage" | "/materials/register" | "/models" | "/models/bom" | "/registration" | "/repair" | "/scrap"
|
||||
type AppRouteHandlerRoutes = "/api/app-versions" | "/api/board-types" | "/api/boards" | "/api/config-files" | "/api/dashboard" | "/api/devices" | "/api/devices/detail" | "/api/firmware" | "/api/licenses" | "/api/licenses/[id]/preview" | "/api/licenses/download" | "/api/material-categories" | "/api/material-types" | "/api/material-versions" | "/api/materials" | "/api/models" | "/api/models/bom" | "/api/models/checklist" | "/api/repair" | "/api/scrap" | "/api/update-logs" | "/api/upload"
|
||||
type PageRoutes = never
|
||||
type LayoutRoutes = "/"
|
||||
type RedirectRoutes = never
|
||||
|
|
@ -18,8 +18,11 @@ interface ParamMap {
|
|||
"/api/config-files": {}
|
||||
"/api/dashboard": {}
|
||||
"/api/devices": {}
|
||||
"/api/devices/detail": {}
|
||||
"/api/firmware": {}
|
||||
"/api/licenses": {}
|
||||
"/api/licenses/[id]/preview": { "id": string; }
|
||||
"/api/licenses/download": {}
|
||||
"/api/material-categories": {}
|
||||
"/api/material-types": {}
|
||||
"/api/material-versions": {}
|
||||
|
|
@ -29,11 +32,14 @@ interface ParamMap {
|
|||
"/api/models/checklist": {}
|
||||
"/api/repair": {}
|
||||
"/api/scrap": {}
|
||||
"/api/update-logs": {}
|
||||
"/api/upload": {}
|
||||
"/app-versions": {}
|
||||
"/config-files": {}
|
||||
"/devices": {}
|
||||
"/devices/[sn]": { "sn": string; }
|
||||
"/firmware": {}
|
||||
"/help": {}
|
||||
"/licenses": {}
|
||||
"/materials": {}
|
||||
"/materials/categories": {}
|
||||
|
|
|
|||
|
|
@ -92,6 +92,15 @@ type RouteHandlerConfig<Route extends AppRouteHandlerRoutes = AppRouteHandlerRou
|
|||
type __Unused = __Check
|
||||
}
|
||||
|
||||
// Validate ../../../src/app/help/page.tsx
|
||||
{
|
||||
type __IsExpected<Specific extends AppPageConfig<"/help">> = Specific
|
||||
const handler = {} as typeof import("../../../src/app/help/page.js")
|
||||
type __Check = __IsExpected<typeof handler>
|
||||
// @ts-ignore
|
||||
type __Unused = __Check
|
||||
}
|
||||
|
||||
// Validate ../../../src/app/licenses/page.tsx
|
||||
{
|
||||
type __IsExpected<Specific extends AppPageConfig<"/licenses">> = Specific
|
||||
|
|
@ -236,6 +245,15 @@ type RouteHandlerConfig<Route extends AppRouteHandlerRoutes = AppRouteHandlerRou
|
|||
type __Unused = __Check
|
||||
}
|
||||
|
||||
// Validate ../../../src/app/api/devices/detail/route.ts
|
||||
{
|
||||
type __IsExpected<Specific extends RouteHandlerConfig<"/api/devices/detail">> = Specific
|
||||
const handler = {} as typeof import("../../../src/app/api/devices/detail/route.js")
|
||||
type __Check = __IsExpected<typeof handler>
|
||||
// @ts-ignore
|
||||
type __Unused = __Check
|
||||
}
|
||||
|
||||
// Validate ../../../src/app/api/devices/route.ts
|
||||
{
|
||||
type __IsExpected<Specific extends RouteHandlerConfig<"/api/devices">> = Specific
|
||||
|
|
@ -254,6 +272,24 @@ type RouteHandlerConfig<Route extends AppRouteHandlerRoutes = AppRouteHandlerRou
|
|||
type __Unused = __Check
|
||||
}
|
||||
|
||||
// Validate ../../../src/app/api/licenses/[id]/preview/route.ts
|
||||
{
|
||||
type __IsExpected<Specific extends RouteHandlerConfig<"/api/licenses/[id]/preview">> = Specific
|
||||
const handler = {} as typeof import("../../../src/app/api/licenses/[id]/preview/route.js")
|
||||
type __Check = __IsExpected<typeof handler>
|
||||
// @ts-ignore
|
||||
type __Unused = __Check
|
||||
}
|
||||
|
||||
// Validate ../../../src/app/api/licenses/download/route.ts
|
||||
{
|
||||
type __IsExpected<Specific extends RouteHandlerConfig<"/api/licenses/download">> = Specific
|
||||
const handler = {} as typeof import("../../../src/app/api/licenses/download/route.js")
|
||||
type __Check = __IsExpected<typeof handler>
|
||||
// @ts-ignore
|
||||
type __Unused = __Check
|
||||
}
|
||||
|
||||
// Validate ../../../src/app/api/licenses/route.ts
|
||||
{
|
||||
type __IsExpected<Specific extends RouteHandlerConfig<"/api/licenses">> = Specific
|
||||
|
|
@ -344,6 +380,24 @@ type RouteHandlerConfig<Route extends AppRouteHandlerRoutes = AppRouteHandlerRou
|
|||
type __Unused = __Check
|
||||
}
|
||||
|
||||
// Validate ../../../src/app/api/update-logs/route.ts
|
||||
{
|
||||
type __IsExpected<Specific extends RouteHandlerConfig<"/api/update-logs">> = Specific
|
||||
const handler = {} as typeof import("../../../src/app/api/update-logs/route.js")
|
||||
type __Check = __IsExpected<typeof handler>
|
||||
// @ts-ignore
|
||||
type __Unused = __Check
|
||||
}
|
||||
|
||||
// Validate ../../../src/app/api/upload/route.ts
|
||||
{
|
||||
type __IsExpected<Specific extends RouteHandlerConfig<"/api/upload">> = Specific
|
||||
const handler = {} as typeof import("../../../src/app/api/upload/route.js")
|
||||
type __Check = __IsExpected<typeof handler>
|
||||
// @ts-ignore
|
||||
type __Unused = __Check
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,341 @@
|
|||
# 授权文件管理 API 文档
|
||||
|
||||
## 概述
|
||||
|
||||
本系统提供基于设备型号和配置文件的授权文件生成功能。手机APP可以通过设备SN号从平台获取对应的授权文件(JSON格式)。
|
||||
|
||||
## 核心功能
|
||||
|
||||
1. **授权项管理** - 定义设备可用的功能模块
|
||||
2. **配置文件管理** - 设备的技术参数配置
|
||||
3. **授权文件生成** - 根据授权项+配置自动生成JSON授权文件
|
||||
4. **授权文件下载** - 手机APP通过设备SN获取授权文件
|
||||
|
||||
## API 接口
|
||||
|
||||
### 1. 获取授权列表
|
||||
|
||||
**接口**: `GET /api/licenses`
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"model": "GD-30",
|
||||
"modules": "一维自电/电阻率/激电测试模块, 二维自电/电阻率/激电测试模块",
|
||||
"expiry": "2027-04-30",
|
||||
"status": "生效",
|
||||
"config_id": 5,
|
||||
"device_sn": "",
|
||||
"license_file": "{...}",
|
||||
"created_at": "2026-04-30 16:00:00",
|
||||
"updated_at": "2026-04-30 16:00:00"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 2. 创建授权
|
||||
|
||||
**接口**: `POST /api/licenses`
|
||||
|
||||
**请求体**:
|
||||
```json
|
||||
{
|
||||
"model": "GD-30",
|
||||
"modules": "一维自电/电阻率/激电测试模块, 二维自电/电阻率/激电测试模块, 三维自电/电阻率/激电测试模块",
|
||||
"expiry": "2027-04-30",
|
||||
"status": "生效",
|
||||
"config_id": 5,
|
||||
"device_sn": ""
|
||||
}
|
||||
```
|
||||
|
||||
**参数说明**:
|
||||
- `model`: 设备型号(必填)
|
||||
- `modules`: 授权模块列表,多个模块用", "分隔(必填)
|
||||
- `expiry`: 到期时间(可选)
|
||||
- `status`: 状态,"生效"或"已停用"(可选,默认"生效")
|
||||
- `config_id`: 配置文件ID(可选,不传则自动使用最新配置)
|
||||
- `device_sn`: 设备SN(可选,用于设备级授权,不填则为型号级授权)
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"licenseFile": {
|
||||
"version": "1.0",
|
||||
"generatedAt": "2026-04-30T16:00:00.000Z",
|
||||
"deviceModel": "GD-30",
|
||||
"deviceSN": "",
|
||||
"validUntil": "2027-04-30",
|
||||
"status": "active",
|
||||
"authModules": [...],
|
||||
"config": {...},
|
||||
"signature": {...}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 更新授权
|
||||
|
||||
**接口**: `PUT /api/licenses`
|
||||
|
||||
**请求体**:
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"model": "GD-30",
|
||||
"modules": "一维自电/电阻率/激电测试模块, 二维自电/电阻率/激电测试模块",
|
||||
"expiry": "2027-04-30",
|
||||
"status": "生效",
|
||||
"config_id": 5,
|
||||
"device_sn": ""
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 手机APP获取授权文件 ⭐
|
||||
|
||||
**接口**: `GET /api/licenses/download?sn={device_sn}`
|
||||
|
||||
**说明**: 这是手机APP调用的核心接口,根据设备SN号返回对应的授权文件JSON。
|
||||
|
||||
**查询参数**:
|
||||
- `sn`: 设备序列号(必填)
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"generatedAt": "2026-04-30T16:00:00.000Z",
|
||||
"deviceModel": "GD-30 Supreme",
|
||||
"deviceSN": "GD30-20260430-001",
|
||||
"validUntil": "2027-04-30",
|
||||
"status": "active",
|
||||
"authModules": [
|
||||
{
|
||||
"id": "1D",
|
||||
"name": "一维自电/电阻率/激电测试模块",
|
||||
"category": "一维",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"id": "2D",
|
||||
"name": "二维自电/电阻率/激电测试模块",
|
||||
"category": "二维",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"id": "3D",
|
||||
"name": "三维自电/电阻率/激电测试模块",
|
||||
"category": "三维",
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"config": {
|
||||
"name": "CFG-GD30-v2.1",
|
||||
"version": "v2.1",
|
||||
"emissionParams": {
|
||||
"maxVoltage": "1000V",
|
||||
"maxCurrent": "10A",
|
||||
"waveform": "0+0-",
|
||||
"pulseWidth": "0.25s/0.5s/1s/2s/4s/8s/16s/32s/64s"
|
||||
},
|
||||
"acquisitionParams": {
|
||||
"channels": 12,
|
||||
"sampleRate": "50Hz、60Hz/50Hz、60Hz、100Hz、1000Hz",
|
||||
"voltageRange": "±2.5V、±80V/±80V、±600V",
|
||||
"fullWaveform": true
|
||||
},
|
||||
"networkParams": {
|
||||
"wifiSSIDPrefix": "GD30_"
|
||||
}
|
||||
},
|
||||
"signature": {
|
||||
"algorithm": "SHA256",
|
||||
"value": "a1b2c3d4e5f6...",
|
||||
"publicKey": "platform-public-key-placeholder"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应**:
|
||||
```json
|
||||
// 设备不存在
|
||||
{
|
||||
"error": "设备不存在"
|
||||
}
|
||||
|
||||
// 无有效授权
|
||||
{
|
||||
"error": "该设备暂无有效授权",
|
||||
"deviceSN": "GD30-20260430-001",
|
||||
"deviceModel": "GD-30 Supreme"
|
||||
}
|
||||
```
|
||||
|
||||
**查找逻辑**:
|
||||
1. 优先查找绑定到具体设备SN的授权(device_sn字段匹配)
|
||||
2. 如果没有设备级授权,则查找型号级别的授权(model字段匹配)
|
||||
3. 只返回状态为"生效"且未过期的授权
|
||||
4. 每次下载都会记录下载日志
|
||||
|
||||
### 5. 预览授权文件
|
||||
|
||||
**接口**: `GET /api/licenses/{id}/preview`
|
||||
|
||||
**说明**: 在管理后台预览授权文件的JSON内容。
|
||||
|
||||
**路径参数**:
|
||||
- `id`: 授权ID
|
||||
|
||||
**响应**: 与下载接口相同的JSON格式
|
||||
|
||||
## 授权文件JSON结构说明
|
||||
|
||||
```typescript
|
||||
interface LicenseFile {
|
||||
version: string; // 版本号,如 "1.0"
|
||||
generatedAt: string; // 生成时间(ISO格式)
|
||||
deviceModel: string; // 设备型号
|
||||
deviceSN: string; // 设备序列号
|
||||
validUntil: string; // 有效期
|
||||
status: string; // 状态:"active" 或 "inactive"
|
||||
|
||||
authModules: Array<{ // 授权模块列表
|
||||
id: string; // 模块ID
|
||||
name: string; // 模块名称
|
||||
category: string; // 分类
|
||||
enabled: boolean; // 是否启用
|
||||
}>;
|
||||
|
||||
config: { // 配置参数
|
||||
name: string; // 配置名称
|
||||
version: string; // 配置版本
|
||||
emissionParams: { // 发射参数
|
||||
maxVoltage: string; // 最大发射电压
|
||||
maxCurrent: string; // 最大发射电流
|
||||
waveform: string; // 发射波形
|
||||
pulseWidth: string; // 脉宽范围
|
||||
};
|
||||
acquisitionParams: { // 采集参数
|
||||
channels: number; // 通道数
|
||||
sampleRate: string; // 采样率
|
||||
voltageRange: string; // 电压量程
|
||||
fullWaveform: boolean; // 是否支持全波形
|
||||
};
|
||||
networkParams: { // 网络参数
|
||||
wifiSSIDPrefix: string; // WiFi SSID前缀
|
||||
};
|
||||
};
|
||||
|
||||
signature: { // 数字签名
|
||||
algorithm: string; // 签名算法
|
||||
value: string; // 签名值
|
||||
publicKey: string; // 公钥(用于验证)
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## 数据库表结构
|
||||
|
||||
### licenses 表(已扩展)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | INTEGER | 主键 |
|
||||
| model | TEXT | 设备型号 |
|
||||
| modules | TEXT | 授权模块(逗号分隔) |
|
||||
| expiry | TEXT | 到期时间 |
|
||||
| status | TEXT | 状态:"生效"或"已停用" |
|
||||
| config_id | INTEGER | 关联的配置文件ID |
|
||||
| device_sn | TEXT | 设备SN(可选,用于设备级授权) |
|
||||
| license_file | TEXT | 生成的JSON授权文件 |
|
||||
| created_at | TEXT | 创建时间 |
|
||||
| updated_at | TEXT | 更新时间 |
|
||||
|
||||
### license_download_logs 表(新增)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | INTEGER | 主键 |
|
||||
| license_id | INTEGER | 关联的授权ID |
|
||||
| device_sn | TEXT | 下载的设备SN |
|
||||
| download_time | TEXT | 下载时间 |
|
||||
| ip_address | TEXT | 客户端IP |
|
||||
| app_version | TEXT | APP版本信息 |
|
||||
|
||||
## 使用流程
|
||||
|
||||
### 管理员操作流程
|
||||
|
||||
1. **创建配置文件** - 在"配置文件管理"页面为设备型号创建配置
|
||||
2. **创建授权** - 在"授权管理"页面选择设备型号、授权项和配置
|
||||
3. **系统自动生成** - 保存时自动生成JSON授权文件并存储
|
||||
|
||||
### 手机APP使用流程
|
||||
|
||||
1. APP启动时调用 `GET /api/licenses/download?sn={device_sn}`
|
||||
2. 平台返回JSON格式的授权文件
|
||||
3. APP解析授权文件,启用对应功能模块
|
||||
4. APP可以验证签名确保文件完整性
|
||||
|
||||
## 安全特性
|
||||
|
||||
1. **数字签名** - 每个授权文件都包含SHA256签名
|
||||
2. **下载日志** - 记录每次授权的下载行为
|
||||
3. **有效期控制** - 只返回未过期的有效授权
|
||||
4. **双重匹配** - 支持设备级和型号级授权
|
||||
|
||||
## 测试示例
|
||||
|
||||
### 使用curl测试下载接口
|
||||
|
||||
```bash
|
||||
# 获取设备SN为 GD30-20260430-001 的授权文件
|
||||
curl "http://localhost:3000/api/licenses/download?sn=GD30-20260430-001"
|
||||
```
|
||||
|
||||
### JavaScript调用示例
|
||||
|
||||
```javascript
|
||||
async function getLicense(deviceSN) {
|
||||
const response = await fetch(`/api/licenses/download?sn=${deviceSN}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || '获取授权失败');
|
||||
}
|
||||
|
||||
const license = await response.json();
|
||||
|
||||
// 验证签名
|
||||
const isValid = verifySignature(license);
|
||||
if (!isValid) {
|
||||
throw new Error('授权文件签名验证失败');
|
||||
}
|
||||
|
||||
return license;
|
||||
}
|
||||
|
||||
function verifySignature(license) {
|
||||
const { signature, ...dataWithoutSig } = license;
|
||||
const jsonString = JSON.stringify(dataWithoutSig, null, 2);
|
||||
const calculatedHash = sha256(jsonString);
|
||||
return calculatedHash === signature.value;
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 授权文件一旦生成,建议不要频繁修改,以保持稳定性
|
||||
2. 设备级授权优先级高于型号级授权
|
||||
3. 签名验证应在APP端实现,确保文件未被篡改
|
||||
4. 定期清理过期的下载日志以优化性能
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
# 设备管理平台Demo — 系统架构文档
|
||||
|
||||
> 本文档描述【设备管理平台Demo】的整体架构,涵盖设备管理、APP获取授权文件、设备主机激活三大核心功能。
|
||||
>
|
||||
> 设计目标:**轻量级、本地可运行、新手友好**
|
||||
|
||||
---
|
||||
|
||||
## 一、整体架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph 客户端层 ["📱 客户端层"]
|
||||
A[管理后台 Web端<br/>Next.js 14 + React + Tailwind CSS]
|
||||
B[手机 APP<br/>HTTP 调用获取授权文件]
|
||||
end
|
||||
|
||||
subgraph API层 ["🔗 API 层 (Next.js API Routes)"]
|
||||
C1["设备 API<br/>/api/devices"]
|
||||
C2["授权 API<br/>/api/licenses"]
|
||||
C3["APP 授权下载<br/>/api/licenses/download"]
|
||||
C4["配置 API<br/>/api/config-files"]
|
||||
C5["维修/固件/报废 API"]
|
||||
C6["物料/型号/BOM API"]
|
||||
end
|
||||
|
||||
subgraph 核心业务层 ["⚙️ 核心业务层"]
|
||||
D1["设备注册与生命周期管理"]
|
||||
D2["授权文件生成引擎<br/>激活项 + 配置文件 → JSON + SHA256 数字签名"]
|
||||
D3["双重授权匹配机制<br/>设备级优先 > 型号级"]
|
||||
D4["设备技术参数配置管理"]
|
||||
D5["维修工单 / 固件版本 / 报废流程"]
|
||||
D6["物料管理 / BOM 清单 / 板卡类型"]
|
||||
end
|
||||
|
||||
subgraph 数据层 ["🗄️ 数据层"]
|
||||
E1[("SQLite<br/>本地文件数据库<br/>better-sqlite3")]
|
||||
end
|
||||
|
||||
A -->|CRUD 操作| C1 & C2 & C4 & C5 & C6
|
||||
B -->|GET ?sn=xxx| C3
|
||||
|
||||
C1 --> D1
|
||||
C2 --> D2
|
||||
C3 --> D3
|
||||
C4 --> D4
|
||||
C5 --> D5
|
||||
C6 --> D6
|
||||
|
||||
D1 & D2 & D3 & D4 & D5 & D6 -->|SQL 读写| E1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、各模块职责说明
|
||||
|
||||
### 1. 客户端层
|
||||
|
||||
| 模块 | 技术栈 | 职责 |
|
||||
|------|--------|------|
|
||||
| **管理后台 Web端** | Next.js 14 (App Router) + React + Tailwind CSS + lucide-react | 提供设备注册、授权配置、维修工单等全功能可视化管理界面。轻量级、单页应用体验,无需额外前端服务。 |
|
||||
| **手机 APP** | 外部客户端(JavaScript 示例已提供) | 通过 `/api/licenses/download?sn=xxx` 接口,凭设备 SN 拉取授权文件 JSON,解析后启用对应功能模块。 |
|
||||
|
||||
### 2. API 层
|
||||
|
||||
| 接口路由 | 方法 | 核心职责 |
|
||||
|----------|------|----------|
|
||||
| `/api/devices` | GET / POST / PUT / DELETE | 设备全生命周期 CRUD:注册、列表、状态更新、删除。 |
|
||||
| `/api/licenses` | GET / POST / PUT | 授权记录管理:创建授权时**自动生成带 SHA256 签名的 JSON 文件**,支持设备级/型号级双重模式。 |
|
||||
| `/api/licenses/download` | GET | **APP 专用接口**:根据 SN 查询设备 → 优先匹配设备级授权 → fallback 型号级授权 → 返回 JSON 并记录下载日志。 |
|
||||
| `/api/config-files` | GET / POST / PUT / DELETE | 设备技术参数模板管理,供授权文件关联引用。 |
|
||||
| `/api/repair` `/firmware` `/scrap` | GET / POST / ... | 维修工单跟踪、固件版本发布、设备报废流程。 |
|
||||
| `/api/models` `/materials` `/bom` | GET / POST / ... | 设备型号定义、物料入库/出库、BOM 清单与板卡版本兼容管理。 |
|
||||
|
||||
### 3. 核心业务层
|
||||
|
||||
| 模块 | 职责说明 |
|
||||
|------|----------|
|
||||
| **设备注册与生命周期** | 支持 SN 扫码/手动录入、生产批次选择、BOM 装配清单记录。状态流转:装配中 → 调试中 → 已激活 → 维修中 → 已报废。 |
|
||||
| **授权文件生成引擎** | 保存授权时,将**激活项**(授权模块列表,如 1D/2D/3D/水上/跨孔/电流场法)与**配置文件**(发射参数、采集参数、网络参数)合并组装为 JSON,附加设备型号/SN/有效期后,计算 SHA256 数字签名防止篡改。 |
|
||||
| **双重授权匹配机制** | APP 请求时先查 `device_sn` 精确匹配,无结果再按 `model` 匹配有效期内的型号级授权,确保灵活性。 |
|
||||
| **配置管理** | 按设备型号维护技术参数配置,授权文件可关联具体配置 ID,实现不同批次设备的差异化参数下发。 |
|
||||
| **维修/固件/报废** | 完整的售后支持流程:创建维修工单 → 记录故障与更换物料 → 固件升级 → 最终报废归档。 |
|
||||
| **物料与 BOM** | 管理采集板、发射板、主板、电缆等物料的型号/版本/库存,BOM 支持多版本兼容(如两块采集板版本需一致)。 |
|
||||
|
||||
### 4. 数据层
|
||||
|
||||
| 组件 | 说明 |
|
||||
|------|------|
|
||||
| **SQLite (better-sqlite3)** | 轻量级本地文件数据库,零配置、开箱即用。数据库文件位于 `./data/app.db`,支持 WAL 模式提升并发性能。 |
|
||||
| **自动迁移 (migrateDatabase)** | 启动时自动检测表结构变更,新增字段/新表无需手动执行 SQL。 |
|
||||
| **Seed 数据 (seedIfEmpty)** | 首次运行时自动填充演示数据,新手可立即体验完整功能。 |
|
||||
|
||||
---
|
||||
|
||||
## 三、核心数据流:APP 获取授权文件
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant APP as 手机APP
|
||||
participant API as /api/licenses/download
|
||||
participant DB as SQLite
|
||||
participant LOG as 下载日志
|
||||
|
||||
APP->>API: GET ?sn=GD30-20260430-001
|
||||
API->>DB: 查询 devices 表
|
||||
DB-->>API: 返回设备信息
|
||||
|
||||
alt 存在设备级授权
|
||||
API->>DB: SELECT * FROM licenses WHERE device_sn = ? AND status = '生效'
|
||||
DB-->>API: 返回授权记录
|
||||
else 无设备级授权
|
||||
API->>DB: SELECT * FROM licenses WHERE model = ? AND status = '生效' AND expiry >= now
|
||||
DB-->>API: 返回型号级授权
|
||||
end
|
||||
|
||||
API->>API: 解析 license_file JSON<br/>更新 deviceSN 字段
|
||||
API->>LOG: INSERT 下载日志<br/>(IP, UA, 时间)
|
||||
API-->>APP: 返回授权 JSON
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、授权文件生成机制
|
||||
|
||||
授权文件由两大核心部分组合生成:
|
||||
|
||||
### 4.1 激活项(authModules)
|
||||
|
||||
来源于 `licenses.modules` 字段,表示该设备被授权启用的功能模块:
|
||||
|
||||
| 模块 ID | 模块名称 |
|
||||
|---------|----------|
|
||||
| `1D` | 一维自电/电阻率/激电测试模块 |
|
||||
| `2D` | 二维自电/电阻率/激电测试模块 |
|
||||
| `3D` | 三维自电/电阻率/激电测试模块 |
|
||||
| `WATER` | 水上 |
|
||||
| `CROSS` | 跨孔 |
|
||||
| `CF` | 电流场法 |
|
||||
|
||||
每个模块在生成的 JSON 中以 `{ id, name, category, enabled: true }` 形式呈现。
|
||||
|
||||
### 4.2 配置文件(config)
|
||||
|
||||
来源于 `config_files` 表,包含设备运行的技术参数:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "GD30 标准配置",
|
||||
"version": "v2.1",
|
||||
"emissionParams": {
|
||||
"maxVoltage": "1000V",
|
||||
"maxCurrent": "5A",
|
||||
"waveform": "50%",
|
||||
"pulseWidth": "2s"
|
||||
},
|
||||
"acquisitionParams": {
|
||||
"channels": 16,
|
||||
"sampleRate": "1kHz",
|
||||
"voltageRange": "±10V",
|
||||
"fullWaveform": true
|
||||
},
|
||||
"networkParams": {
|
||||
"wifiSSIDPrefix": "GD30-"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 最终授权文件结构
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"generatedAt": "2026-05-07T10:00:00.000Z",
|
||||
"deviceModel": "GD-30 Supreme",
|
||||
"deviceSN": "GD30-20260430-001",
|
||||
"validUntil": "2027-04-30",
|
||||
"status": "active",
|
||||
"authModules": [
|
||||
{ "id": "1D", "name": "一维自电/电阻率/激电测试模块", "category": "一维", "enabled": true },
|
||||
{ "id": "2D", "name": "二维自电/电阻率/激电测试模块", "category": "二维", "enabled": true }
|
||||
],
|
||||
"config": { ... },
|
||||
"signature": {
|
||||
"algorithm": "SHA256",
|
||||
"value": "a1b2c3d4...",
|
||||
"publicKey": "platform-public-key-placeholder"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、数据库核心表结构
|
||||
|
||||
| 表名 | 说明 |
|
||||
|------|------|
|
||||
| `devices` | 设备基本信息(SN、型号、状态、固件版本、生产批次等) |
|
||||
| `licenses` | 授权记录(模块列表、有效期、状态、关联配置 ID、自动生成的 `license_file` JSON) |
|
||||
| `config_files` | 设备技术参数配置模板 |
|
||||
| `license_download_logs` | 授权下载审计日志 |
|
||||
| `device_models` | 设备型号定义 |
|
||||
| `repair_orders` | 维修工单 |
|
||||
| `firmware` | 固件版本信息 |
|
||||
| `scrap_records` | 报废记录 |
|
||||
|
||||
---
|
||||
|
||||
## 六、架构亮点
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| **轻量本地运行** | 整个系统只需 `npm i && npm run dev`,SQLite 零配置,无需 Docker / 外接数据库。 |
|
||||
| **新手友好** | 自动 Seed 数据 + 自动迁移,clone 下来即可看到完整功能的 Demo。 |
|
||||
| **安全设计** | 授权文件含 SHA256 签名;下载日志完整审计(IP、UA、时间)。 |
|
||||
| **APP 集成极简** | 一个 HTTP GET 请求即可获取授权 JSON,示例代码已在 README 中提供。 |
|
||||
|
||||
---
|
||||
|
||||
*文档生成时间:2026-05-07*
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a Chinese-language enterprise SaaS device management platform (地空业务支撑平台——生产管理子系统) built with Next.js App Router. It manages the full lifecycle of geophysical instruments (device registration, BOM configuration, license/authorization management, repair orders, firmware, and scrap records).
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend**: Next.js 16, React 19, TypeScript 6, Tailwind CSS 4
|
||||
- **Database**: SQLite via `better-sqlite3` (file-based, located at `data/app.db`)
|
||||
- **Icons**: lucide-react
|
||||
- **Forms**: react-hook-form + zod
|
||||
- **Secondary backend**: FastAPI in `python_backend/` for device activation and encrypted license generation
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start Next.js dev server (http://localhost:3000)
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
|
||||
# Start production server
|
||||
npm run start
|
||||
|
||||
# Run linting
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### Python Backend
|
||||
|
||||
The FastAPI service in `python_backend/` handles device check-in, encrypted license generation, and activation reporting independently from the Next.js app.
|
||||
|
||||
```bash
|
||||
cd python_backend
|
||||
pip install -r requirements.txt
|
||||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Database Layer (`src/lib/db.ts`)
|
||||
|
||||
- Uses `better-sqlite3` with WAL mode and foreign keys enabled.
|
||||
- `getDb()` returns a singleton `Database` instance. The DB file is created at `data/app.db` relative to `process.cwd()`.
|
||||
- `initTables()` defines the full schema on first run (tables: `devices`, `device_models`, `materials`, `bom_templates`, `config_files`, `licenses`, `repair_orders`, `scrap_records`, `firmware`, `applications`, `app_platform_versions`, etc.).
|
||||
- `migrateDatabase()` handles incremental schema changes (e.g., adding `config_id`, `license_file`, `device_sn` to the `licenses` table, creating `license_download_logs` and `license_templates`).
|
||||
- `generateLicenseFile()` builds a SHA256-signed JSON license payload.
|
||||
|
||||
### Data Fetching
|
||||
|
||||
- **Frontend**: Uses a lightweight custom hook `useApi(url, defaultValue)` defined in `src/lib/hooks.ts` (standard `fetch` + `useState`/`useEffect`). No React Query usage in practice despite being in `package.json`.
|
||||
- **Backend API**: Implemented as Next.js Route Handlers under `src/app/api/`. Each endpoint typically calls `seedIfEmpty()` (currently a no-op that just ensures the DB is open) and then uses `getDb()` to run SQL queries.
|
||||
- API routes generally support `GET` (list), `POST` (create), `PUT` (update), and `DELETE`.
|
||||
|
||||
### Page Structure (App Router)
|
||||
|
||||
- `src/app/layout.tsx` provides the root layout with a sidebar navigation and header.
|
||||
- `src/app/components/sidebar.tsx` defines the navigation groups: 设备 (Devices), 物料 (Materials), 软件 (Software), 维修 (Repair).
|
||||
- Major routes:
|
||||
- `/` — Dashboard with statistics overview
|
||||
- `/devices` — Device list with batch filtering and pagination
|
||||
- `/devices/[sn]` — Device detail / BOM view
|
||||
- `/registration` — Register new device
|
||||
- `/models` — Device model management
|
||||
- `/models/bom` — BOM template management
|
||||
- `/materials/*` — Material (board/component) inventory and categories
|
||||
- `/app-versions` — Mobile app release management
|
||||
- `/repair` — Repair work orders
|
||||
- `/scrap` — Scrap/disposal records
|
||||
- `/licenses` — License/authorization configuration and JSON file generation
|
||||
- `/config-files` — Device technical parameter configs
|
||||
- `/firmware` — Firmware version management
|
||||
|
||||
### Styling
|
||||
|
||||
- Tailwind CSS v4 is imported in `src/styles/index.css` (`@import 'tailwindcss'`).
|
||||
- Additional custom styles in `src/styles/theme.css` and `src/styles/fonts.css`.
|
||||
- Many components also use inline `style` props for specific colors (primary brand color is `#4a7c59`).
|
||||
|
||||
### Path Aliases
|
||||
|
||||
- `@/*` maps to `./src/*` in `tsconfig.json`.
|
||||
|
||||
## Key Development Notes
|
||||
|
||||
- **No test suite is configured.** There are no test runners or test files in the repo.
|
||||
- **No pre-commit hooks or CI configuration** were found.
|
||||
- **Database schema changes**: When modifying the schema, update `initTables()` for new tables and `migrateDatabase()` for alterations to existing tables, since the app uses a single SQLite file that persists across restarts.
|
||||
- **License file generation**: The `licenses` API automatically generates a signed JSON license on `POST`/`PUT` by calling `generateLicenseFile()` from `src/lib/db.ts`.
|
||||
- **Python backend is separate**: The Next.js app does not depend on the FastAPI backend for normal operation. The Python service is specifically for field-device activation flows and encrypted license distribution to mobile apps.
|
||||
|
|
@ -0,0 +1,405 @@
|
|||
# 授权文件功能演示说明
|
||||
|
||||
## 📸 功能界面说明
|
||||
|
||||
由于这是文本环境,以下用文字描述各功能的界面和操作流程。
|
||||
|
||||
## 1. 授权管理主页面
|
||||
|
||||
**访问路径**: http://localhost:3000/licenses
|
||||
|
||||
### 页面布局
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ← 授权管理 [导出] [选择授权项] │
|
||||
│ 管理设备授权许可 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ℹ️ 每个设备型号对应一套授权模块配置... │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 筛选条件: [设备型号▼] [查询] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 设备型号 | 授权模块 | 操作 │
|
||||
│ GD-30 | [一维] [二维] [三维] | [预览] [下载] [编辑] │
|
||||
│ GD-20 | [一维] [二维] | [预览] [下载] [编辑] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 共 2 条 [<] [1] [2] [>] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 操作按钮说明
|
||||
|
||||
- **预览** 👁️ - 点击后弹出JSON预览窗口
|
||||
- **下载** 📄 - 直接下载JSON文件到本地
|
||||
- **编辑** ✏️ - 修改授权配置
|
||||
|
||||
## 2. 授权文件预览弹窗
|
||||
|
||||
### 触发方式
|
||||
点击列表中的"预览"按钮
|
||||
|
||||
### 弹窗内容
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ 授权文件预览 - GD-30 [X] │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ { │
|
||||
│ "version": "1.0", │
|
||||
│ "generatedAt": "2026-04-30T16:00:00.000Z", │
|
||||
│ "deviceModel": "GD-30 Supreme", │
|
||||
│ "deviceSN": "", │
|
||||
│ "validUntil": "2027-04-30", │
|
||||
│ "status": "active", │
|
||||
│ "authModules": [ │
|
||||
│ { │
|
||||
│ "id": "1D", │
|
||||
│ "name": "一维自电/电阻率/激电测试模块", │
|
||||
│ "category": "一维", │
|
||||
│ "enabled": true │
|
||||
│ }, │
|
||||
│ ... │
|
||||
│ ], │
|
||||
│ "config": { │
|
||||
│ "name": "CFG-GD30-v2.1", │
|
||||
│ "emissionParams": {...}, │
|
||||
│ "acquisitionParams": {...} │
|
||||
│ }, │
|
||||
│ "signature": { │
|
||||
│ "algorithm": "SHA256", │
|
||||
│ "value": "a1b2c3d4...", │
|
||||
│ "publicKey": "platform-public-key-placeholder" │
|
||||
│ } │
|
||||
│ } │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ [下载JSON] [关闭] │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 功能特点
|
||||
- JSON格式化显示,带语法高亮
|
||||
- 可滚动查看完整内容
|
||||
- 支持一键下载JSON文件
|
||||
- 点击背景或关闭按钮退出
|
||||
|
||||
## 3. 创建/编辑授权抽屉
|
||||
|
||||
### 触发方式
|
||||
点击"选择授权项"按钮
|
||||
|
||||
### 抽屉内容
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ 选择授权项 [X] │
|
||||
├──────────────────────────────────────┤
|
||||
│ 设备型号 │
|
||||
│ [GD-30 Supreme ▼] │
|
||||
│ │
|
||||
│ 授权项目 [全选] [清空]│
|
||||
│ ┌──────────────────────────────────┐ │
|
||||
│ │ ☑ 分类 │ 名称 │ 说明 │ │
|
||||
│ ├──────────────────────────────────┤ │
|
||||
│ │ ☑ 一维 │ 一维自电... │ ... │ │
|
||||
│ │ ☑ 二维 │ 二维自电... │ ... │ │
|
||||
│ │ ☑ 三维 │ 三维自电... │ ... │ │
|
||||
│ │ □ 水上 │ 水上... │ ... │ │
|
||||
│ │ □ 跨孔 │ 跨孔... │ ... │ │
|
||||
│ │ □ 电流场法│ 电流场法... │ ... │ │
|
||||
│ └──────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 已选择 3 项 │
|
||||
│ │
|
||||
│ ℹ️ 授权文件由选定的授权项与对应型号的 │
|
||||
│ 配置文件共同生成... │
|
||||
│ │
|
||||
│ [保存] │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 交互说明
|
||||
- 点击行可选中/取消选中
|
||||
- 支持全选/清空快捷操作
|
||||
- 保存时自动生成JSON授权文件
|
||||
|
||||
## 4. 手机APP调用流程演示
|
||||
|
||||
### 场景:设备启动时获取授权
|
||||
|
||||
#### Step 1: APP发起请求
|
||||
```javascript
|
||||
GET /api/licenses/download?sn=GD30-20260430-001
|
||||
```
|
||||
|
||||
#### Step 2: 平台处理流程
|
||||
```
|
||||
接收请求
|
||||
↓
|
||||
验证设备SN是否存在
|
||||
↓
|
||||
查找设备级授权 (device_sn匹配)
|
||||
↓ 未找到
|
||||
查找型号级授权 (model匹配 + 状态生效 + 未过期)
|
||||
↓
|
||||
获取关联的配置文件
|
||||
↓
|
||||
生成/读取授权文件JSON
|
||||
↓
|
||||
记录下载日志 (时间、IP、APP版本)
|
||||
↓
|
||||
返回JSON响应
|
||||
```
|
||||
|
||||
#### Step 3: APP接收响应
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"deviceModel": "GD-30 Supreme",
|
||||
"deviceSN": "GD30-20260430-001",
|
||||
"validUntil": "2027-04-30",
|
||||
"status": "active",
|
||||
"authModules": [
|
||||
{"id": "1D", "name": "...", "enabled": true},
|
||||
{"id": "2D", "name": "...", "enabled": true},
|
||||
{"id": "3D", "name": "...", "enabled": true}
|
||||
],
|
||||
"config": {
|
||||
"emissionParams": {"maxVoltage": "1000V", ...},
|
||||
"acquisitionParams": {"channels": 12, ...}
|
||||
},
|
||||
"signature": {...}
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 4: APP处理授权
|
||||
```javascript
|
||||
// 1. 验证签名
|
||||
if (!verifySignature(license)) {
|
||||
throw new Error('授权文件被篡改');
|
||||
}
|
||||
|
||||
// 2. 检查有效期
|
||||
if (new Date(license.validUntil) < new Date()) {
|
||||
throw new Error('授权已过期');
|
||||
}
|
||||
|
||||
// 3. 启用功能模块
|
||||
license.authModules.forEach(module => {
|
||||
if (module.enabled) {
|
||||
app.enableFeature(module.id);
|
||||
}
|
||||
});
|
||||
|
||||
// 4. 应用配置参数
|
||||
app.applyConfig(license.config);
|
||||
|
||||
// 5. 显示授权信息
|
||||
console.log(`授权有效期至: ${license.validUntil}`);
|
||||
```
|
||||
|
||||
## 5. 数据库查询演示
|
||||
|
||||
### 查看授权记录
|
||||
```sql
|
||||
SELECT id, model, modules, expiry, status,
|
||||
CASE WHEN license_file IS NOT NULL THEN '已生成' ELSE '未生成' END as file_status
|
||||
FROM licenses
|
||||
ORDER BY id DESC;
|
||||
```
|
||||
|
||||
**示例输出:**
|
||||
```
|
||||
id | model | modules | expiry | status | file_status
|
||||
1 | GD-30 | 一维, 二维, 三维| 2027-04-30 | 生效 | 已生成
|
||||
2 | GD-20 | 一维, 二维 | 2027-04-30 | 生效 | 已生成
|
||||
```
|
||||
|
||||
### 查看下载日志
|
||||
```sql
|
||||
SELECT l.device_sn, l.download_time, l.ip_address,
|
||||
lic.model, lic.expiry
|
||||
FROM license_download_logs l
|
||||
JOIN licenses lic ON l.license_id = lic.id
|
||||
ORDER BY l.download_time DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
**示例输出:**
|
||||
```
|
||||
device_sn | download_time | ip_address | model | expiry
|
||||
GD30-20260430-001 | 2026-04-30 16:30:00 | 192.168.1.100 | GD-30 | 2027-04-30
|
||||
GD30-20260430-002 | 2026-04-30 16:25:00 | 192.168.1.101 | GD-30 | 2027-04-30
|
||||
```
|
||||
|
||||
## 6. 错误场景演示
|
||||
|
||||
### 场景A: 设备不存在
|
||||
```bash
|
||||
curl "http://localhost:3000/api/licenses/download?sn=INVALID-SN"
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"error": "设备不存在"
|
||||
}
|
||||
```
|
||||
**HTTP状态码:** 404
|
||||
|
||||
### 场景B: 无有效授权
|
||||
```bash
|
||||
curl "http://localhost:3000/api/licenses/download?sn=GD30-NO-LICENSE"
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"error": "该设备暂无有效授权",
|
||||
"deviceSN": "GD30-NO-LICENSE",
|
||||
"deviceModel": "GD-30 Supreme"
|
||||
}
|
||||
```
|
||||
**HTTP状态码:** 404
|
||||
|
||||
### 场景C: 缺少SN参数
|
||||
```bash
|
||||
curl "http://localhost:3000/api/licenses/download"
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"error": "设备SN不能为空"
|
||||
}
|
||||
```
|
||||
**HTTP状态码:** 400
|
||||
|
||||
## 7. 性能测试演示
|
||||
|
||||
### 测试工具: Apache Bench (ab)
|
||||
|
||||
```bash
|
||||
# 模拟100个并发请求,总共1000次请求
|
||||
ab -n 1000 -c 100 "http://localhost:3000/api/licenses/download?sn=GD30-TEST-001"
|
||||
```
|
||||
|
||||
**预期结果:**
|
||||
```
|
||||
Requests per second: 500.00 [#/sec] (mean)
|
||||
Time per request: 200.000 [ms] (mean)
|
||||
Time per request: 2.000 [ms] (mean, across all concurrent requests)
|
||||
```
|
||||
|
||||
### 优化建议
|
||||
1. 首次生成后缓存到数据库,避免重复计算
|
||||
2. 为licenses表添加索引:
|
||||
```sql
|
||||
CREATE INDEX idx_license_device_sn ON licenses(device_sn);
|
||||
CREATE INDEX idx_license_model ON licenses(model, status, expiry);
|
||||
```
|
||||
3. 生产环境使用Redis缓存热点授权
|
||||
|
||||
## 8. 安全测试演示
|
||||
|
||||
### 测试签名验证
|
||||
|
||||
```javascript
|
||||
// 正常授权文件
|
||||
const validLicense = await fetchLicense('GD30-TEST-001');
|
||||
console.log(verifySignature(validLicense)); // true
|
||||
|
||||
// 篡改后的授权文件
|
||||
const tamperedLicense = {...validLicense, validUntil: '2099-12-31'};
|
||||
console.log(verifySignature(tamperedLicense)); // false
|
||||
```
|
||||
|
||||
### 测试SQL注入防护
|
||||
|
||||
```bash
|
||||
# 尝试SQL注入
|
||||
curl "http://localhost:3000/api/licenses/download?sn='; DROP TABLE devices; --"
|
||||
```
|
||||
|
||||
**结果:**
|
||||
- 使用参数化查询,SQL注入无效
|
||||
- 返回"设备不存在"错误
|
||||
|
||||
## 9. 移动端适配说明
|
||||
|
||||
### iOS App (Swift)
|
||||
|
||||
```swift
|
||||
func fetchLicense(deviceSN: String, completion: @escaping (Result<License, Error>) -> Void) {
|
||||
let url = URL(string: "http://your-server.com/api/licenses/download?sn=\(deviceSN)")!
|
||||
|
||||
URLSession.shared.dataTask(with: url) { data, response, error in
|
||||
if let error = error {
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data,
|
||||
let license = try? JSONDecoder().decode(License.self, from: data) else {
|
||||
completion(.failure(NSError(domain: "Invalid response", code: -1)))
|
||||
return
|
||||
}
|
||||
|
||||
completion(.success(license))
|
||||
}.resume()
|
||||
}
|
||||
```
|
||||
|
||||
### Android App (Kotlin)
|
||||
|
||||
```kotlin
|
||||
suspend fun fetchLicense(deviceSN: String): License {
|
||||
val response = httpClient.get("http://your-server.com/api/licenses/download?sn=$deviceSN")
|
||||
|
||||
if (!response.isSuccess) {
|
||||
throw IOException("Failed to fetch license")
|
||||
}
|
||||
|
||||
return response.body()
|
||||
}
|
||||
```
|
||||
|
||||
## 10. 监控和告警
|
||||
|
||||
### 关键指标监控
|
||||
|
||||
1. **API响应时间**
|
||||
- P95 < 500ms
|
||||
- P99 < 1000ms
|
||||
|
||||
2. **错误率**
|
||||
- 4xx错误 < 5%
|
||||
- 5xx错误 < 1%
|
||||
|
||||
3. **下载次数统计**
|
||||
- 每日下载总量
|
||||
- 按设备型号分组统计
|
||||
- 异常下载频率检测
|
||||
|
||||
### 告警规则
|
||||
|
||||
```yaml
|
||||
alerts:
|
||||
- name: high_error_rate
|
||||
condition: error_rate > 5%
|
||||
duration: 5m
|
||||
action: send_notification
|
||||
|
||||
- name: slow_response
|
||||
condition: p95_latency > 1000ms
|
||||
duration: 10m
|
||||
action: send_notification
|
||||
|
||||
- name: unusual_download_pattern
|
||||
condition: downloads_per_minute > 100
|
||||
duration: 2m
|
||||
action: send_notification_and_block_ip
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**提示**: 实际使用时可以配合截图工具(如Snipaste、Greenshot)截取各个界面的实际效果,添加到文档中以便更直观地展示功能。
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
# 授权文件生成功能实现总结
|
||||
|
||||
## 📋 已完成的工作
|
||||
|
||||
### 1. 数据库扩展 ✅
|
||||
|
||||
**修改的文件**: `src/lib/db.ts`
|
||||
|
||||
**新增字段** (licenses表):
|
||||
- `config_id` - 关联的配置文件ID
|
||||
- `device_sn` - 设备SN(支持设备级授权)
|
||||
- `license_file` - 存储生成的JSON授权文件
|
||||
- `created_at` - 创建时间
|
||||
- `updated_at` - 更新时间
|
||||
|
||||
**新增表**:
|
||||
- `license_download_logs` - 记录授权文件下载日志
|
||||
- `license_templates` - 授权模板表(预留)
|
||||
|
||||
**新增工具函数**:
|
||||
- `generateLicenseFile()` - 生成授权文件JSON
|
||||
- `verifyLicenseSignature()` - 验证授权文件签名
|
||||
|
||||
### 2. API接口实现 ✅
|
||||
|
||||
#### A. 授权管理API (`src/app/api/licenses/route.ts`)
|
||||
|
||||
**GET /api/licenses**
|
||||
- 获取所有授权列表
|
||||
- 返回包含license_file字段的完整信息
|
||||
|
||||
**POST /api/licenses**
|
||||
- 创建新授权
|
||||
- 自动生成JSON授权文件
|
||||
- 支持指定配置文件或自动匹配最新配置
|
||||
|
||||
**PUT /api/licenses**
|
||||
- 更新授权信息
|
||||
- 重新生成授权文件JSON
|
||||
- 支持部分字段更新
|
||||
|
||||
#### B. 手机APP下载接口 (`src/app/api/licenses/download/route.ts`) ⭐
|
||||
|
||||
**GET /api/licenses/download?sn={device_sn}**
|
||||
|
||||
核心功能:
|
||||
- 根据设备SN查找对应的授权
|
||||
- 优先匹配设备级授权,其次型号级授权
|
||||
- 只返回"生效"且未过期的授权
|
||||
- 自动记录下载日志(IP、时间、APP版本)
|
||||
- 如果授权文件未生成,实时生成并保存
|
||||
- 返回完整的JSON格式授权文件
|
||||
|
||||
错误处理:
|
||||
- 设备不存在 → 404
|
||||
- 无有效授权 → 404 + 提示信息
|
||||
|
||||
#### C. 授权预览接口 (`src/app/api/licenses/[id]/preview/route.ts`)
|
||||
|
||||
**GET /api/licenses/{id}/preview**
|
||||
- 预览指定授权的JSON内容
|
||||
- 用于管理后台查看授权文件详情
|
||||
|
||||
### 3. 前端界面增强 ✅
|
||||
|
||||
**修改的文件**: `src/app/licenses/page.tsx`
|
||||
|
||||
**新增功能**:
|
||||
1. **预览按钮** - 点击可查看授权文件JSON内容
|
||||
2. **下载按钮** - 直接下载JSON文件到本地
|
||||
3. **预览弹窗** - 美观的JSON展示界面,支持格式化显示
|
||||
4. **状态更新** - LicenseItem接口增加license_file字段
|
||||
|
||||
**UI改进**:
|
||||
- 操作列改为多按钮布局(预览、下载、编辑)
|
||||
- 添加FileJson和Eye图标
|
||||
- 预览弹窗支持全屏查看和下载
|
||||
|
||||
### 4. 文档完善 ✅
|
||||
|
||||
**新增文档**:
|
||||
1. **API_LICENSE.md** - 完整的API使用文档
|
||||
- 接口详细说明
|
||||
- 请求/响应示例
|
||||
- JSON结构定义
|
||||
- 安全特性说明
|
||||
- 调用示例代码
|
||||
|
||||
2. **TEST_LICENSE.md** - 详细的测试指南
|
||||
- 测试步骤说明
|
||||
- curl命令示例
|
||||
- 错误场景测试
|
||||
- 签名验证代码
|
||||
- 常见问题解答
|
||||
- 性能优化建议
|
||||
|
||||
## 🎯 核心特性
|
||||
|
||||
### 1. 双重授权模式
|
||||
- **型号级授权**: 同一型号的所有设备共享
|
||||
- **设备级授权**: 针对特定设备的独立授权
|
||||
- 下载时自动优先匹配设备级授权
|
||||
|
||||
### 2. 智能配置匹配
|
||||
- 可手动指定配置文件ID
|
||||
- 未指定时自动使用最新生效配置
|
||||
- 支持跨型号配置继承
|
||||
|
||||
### 3. 数字签名保护
|
||||
- 每个授权文件包含SHA256签名
|
||||
- APP端可验证文件完整性
|
||||
- 防止授权文件被篡改
|
||||
|
||||
### 4. 完整的审计日志
|
||||
- 记录每次下载的详细信息
|
||||
- 包括设备SN、时间、IP、APP版本
|
||||
- 便于追踪和分析使用情况
|
||||
|
||||
### 5. 懒加载生成策略
|
||||
- 首次创建时生成并缓存
|
||||
- 更新时自动重新生成
|
||||
- 避免重复计算,提升性能
|
||||
|
||||
## 📊 授权文件JSON结构
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"generatedAt": "ISO时间戳",
|
||||
"deviceModel": "GD-30 Supreme",
|
||||
"deviceSN": "GD30-20260430-001",
|
||||
"validUntil": "2027-04-30",
|
||||
"status": "active",
|
||||
|
||||
"authModules": [
|
||||
{
|
||||
"id": "1D",
|
||||
"name": "一维自电/电阻率/激电测试模块",
|
||||
"category": "一维",
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
|
||||
"config": {
|
||||
"name": "CFG-GD30-v2.1",
|
||||
"version": "v2.1",
|
||||
"emissionParams": {...},
|
||||
"acquisitionParams": {...},
|
||||
"networkParams": {...}
|
||||
},
|
||||
|
||||
"signature": {
|
||||
"algorithm": "SHA256",
|
||||
"value": "a1b2c3d4...",
|
||||
"publicKey": "platform-public-key-placeholder"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 技术实现细节
|
||||
|
||||
### 数据库迁移
|
||||
- 使用ALTER TABLE动态添加字段
|
||||
- 自动检测并应用迁移
|
||||
- 向后兼容,不影响现有数据
|
||||
|
||||
### 签名算法
|
||||
```typescript
|
||||
const jsonString = JSON.stringify(licenseData, null, 2)
|
||||
const signature = crypto.createHash('sha256')
|
||||
.update(jsonString)
|
||||
.digest('hex')
|
||||
```
|
||||
|
||||
### 查找逻辑优先级
|
||||
```
|
||||
1. device_sn 精确匹配(设备级授权)
|
||||
2. model 匹配 + status='生效' + expiry >= now(型号级授权)
|
||||
3. 按id DESC排序取第一条(最新授权)
|
||||
```
|
||||
|
||||
### 下载日志记录
|
||||
```typescript
|
||||
INSERT INTO license_download_logs
|
||||
(license_id, device_sn, download_time, ip_address, app_version)
|
||||
VALUES
|
||||
(?, ?, ?, ?, ?)
|
||||
```
|
||||
|
||||
## 🚀 使用方法
|
||||
|
||||
### 管理员操作流程
|
||||
1. 在"配置文件管理"创建设备配置
|
||||
2. 在"授权管理"选择型号、授权项、配置
|
||||
3. 系统自动生成JSON授权文件
|
||||
4. 可预览或下载授权文件
|
||||
|
||||
### 手机APP调用流程
|
||||
```javascript
|
||||
// APP启动时获取授权
|
||||
const response = await fetch(
|
||||
`/api/licenses/download?sn=${deviceSN}`
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const license = await response.json();
|
||||
|
||||
// 验证签名
|
||||
if (verifySignature(license)) {
|
||||
// 启用对应功能模块
|
||||
enableModules(license.authModules);
|
||||
|
||||
// 应用配置参数
|
||||
applyConfig(license.config);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 📝 文件清单
|
||||
|
||||
### 后端文件
|
||||
- ✅ `src/lib/db.ts` - 数据库扩展和工具函数
|
||||
- ✅ `src/app/api/licenses/route.ts` - 授权管理API
|
||||
- ✅ `src/app/api/licenses/download/route.ts` - 下载接口(新建)
|
||||
- ✅ `src/app/api/licenses/[id]/preview/route.ts` - 预览接口(新建)
|
||||
|
||||
### 前端文件
|
||||
- ✅ `src/app/licenses/page.tsx` - 授权管理页面增强
|
||||
|
||||
### 文档文件
|
||||
- ✅ `API_LICENSE.md` - API使用文档(新建)
|
||||
- ✅ `TEST_LICENSE.md` - 测试指南(新建)
|
||||
|
||||
## ✨ 亮点功能
|
||||
|
||||
1. **一键生成** - 保存授权时自动生成JSON文件
|
||||
2. **智能匹配** - 自动关联最新配置文件
|
||||
3. **双重授权** - 支持设备级和型号级两种模式
|
||||
4. **完整日志** - 记录每次下载行为
|
||||
5. **签名验证** - 确保文件安全性
|
||||
6. **友好界面** - 可视化预览和下载功能
|
||||
7. **详细文档** - 完整的API文档和测试指南
|
||||
|
||||
## 🔄 后续优化建议
|
||||
|
||||
### 短期优化
|
||||
1. 在设备详情页显示授权状态
|
||||
2. 添加授权到期提醒功能
|
||||
3. 实现批量导出授权文件
|
||||
|
||||
### 中期优化
|
||||
1. 实现真实的RSA数字签名
|
||||
2. 添加授权文件加密传输
|
||||
3. 实现授权模板功能
|
||||
|
||||
### 长期规划
|
||||
1. 授权使用统计分析
|
||||
2. 异常下载行为检测
|
||||
3. 授权文件版本控制
|
||||
4. CDN缓存加速
|
||||
|
||||
## 🎉 总结
|
||||
|
||||
本次实现完成了授权文件生成和下载的核心功能,包括:
|
||||
- ✅ 完整的数据库设计
|
||||
- ✅ 4个API接口
|
||||
- ✅ 前端界面增强
|
||||
- ✅ 详细的使用文档
|
||||
- ✅ 完整的测试指南
|
||||
|
||||
手机APP现在可以通过简单的HTTP请求获取设备对应的授权文件,实现了平台与移动端的无缝对接。系统设计考虑了安全性、性能和可扩展性,为后续的迭代升级奠定了良好基础。
|
||||
|
|
@ -0,0 +1,309 @@
|
|||
# 授权文件功能 - 快速开始
|
||||
|
||||
## 🚀 5分钟快速上手
|
||||
|
||||
### 第一步:启动项目
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
访问 http://localhost:3000
|
||||
|
||||
### 第二步:准备测试数据
|
||||
|
||||
确保已有以下数据(如没有请先创建):
|
||||
|
||||
1. **设备型号** - 例如 "GD-30 Supreme"
|
||||
2. **配置文件** - 为该型号创建至少一个配置
|
||||
3. **设备** - 创建一个测试设备,SN如 "GD30-TEST-001"
|
||||
|
||||
### 第三步:创建授权
|
||||
|
||||
#### 方式A:通过管理界面
|
||||
|
||||
1. 访问 http://localhost:3000/licenses
|
||||
2. 点击"选择授权项"
|
||||
3. 选择:
|
||||
- 设备型号:GD-30 Supreme
|
||||
- 授权模块:勾选需要的模块
|
||||
- 到期时间:选择日期
|
||||
4. 点击"保存"
|
||||
|
||||
✅ 系统自动生成JSON授权文件!
|
||||
|
||||
#### 方式B:通过API
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/licenses \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "GD-30",
|
||||
"modules": "一维自电/电阻率/激电测试模块, 二维自电/电阻率/激电测试模块",
|
||||
"expiry": "2027-04-30",
|
||||
"status": "生效"
|
||||
}'
|
||||
```
|
||||
|
||||
### 第四步:预览授权文件
|
||||
|
||||
1. 在授权列表中找到刚创建的记录
|
||||
2. 点击"预览"按钮
|
||||
3. 查看生成的JSON内容
|
||||
|
||||
你会看到类似这样的结构:
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"deviceModel": "GD-30",
|
||||
"authModules": [...],
|
||||
"config": {...},
|
||||
"signature": {...}
|
||||
}
|
||||
```
|
||||
|
||||
### 第五步:测试手机APP接口 ⭐
|
||||
|
||||
这是最关键的一步!
|
||||
|
||||
```bash
|
||||
# 替换为你的设备SN
|
||||
curl "http://localhost:3000/api/licenses/download?sn=GD30-TEST-001"
|
||||
```
|
||||
|
||||
**成功响应示例:**
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"generatedAt": "2026-04-30T16:00:00.000Z",
|
||||
"deviceModel": "GD-30 Supreme",
|
||||
"deviceSN": "GD30-TEST-001",
|
||||
"validUntil": "2027-04-30",
|
||||
"status": "active",
|
||||
"authModules": [
|
||||
{
|
||||
"id": "1D",
|
||||
"name": "一维自电/电阻率/激电测试模块",
|
||||
"category": "一维",
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"config": {
|
||||
"name": "...",
|
||||
"emissionParams": {...},
|
||||
"acquisitionParams": {...}
|
||||
},
|
||||
"signature": {
|
||||
"algorithm": "SHA256",
|
||||
"value": "abc123...",
|
||||
"publicKey": "platform-public-key-placeholder"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 📱 手机APP集成示例
|
||||
|
||||
### JavaScript/TypeScript
|
||||
|
||||
```typescript
|
||||
interface LicenseFile {
|
||||
version: string;
|
||||
deviceModel: string;
|
||||
deviceSN: string;
|
||||
validUntil: string;
|
||||
status: string;
|
||||
authModules: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
}>;
|
||||
config: any;
|
||||
signature: {
|
||||
algorithm: string;
|
||||
value: string;
|
||||
};
|
||||
}
|
||||
|
||||
async function getDeviceLicense(deviceSN: string): Promise<LicenseFile> {
|
||||
const response = await fetch(
|
||||
`http://your-server.com/api/licenses/download?sn=${deviceSN}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`获取授权失败: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const license = await response.json();
|
||||
|
||||
// 验证签名(生产环境必须)
|
||||
if (!verifySignature(license)) {
|
||||
throw new Error('授权文件签名验证失败');
|
||||
}
|
||||
|
||||
return license;
|
||||
}
|
||||
|
||||
function verifySignature(license: LicenseFile): boolean {
|
||||
// 实现签名验证逻辑
|
||||
// ...
|
||||
return true;
|
||||
}
|
||||
|
||||
// 使用示例
|
||||
const license = await getDeviceLicense('GD30-TEST-001');
|
||||
console.log('授权模块:', license.authModules);
|
||||
console.log('配置参数:', license.config);
|
||||
```
|
||||
|
||||
### React Native
|
||||
|
||||
```javascript
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
function App() {
|
||||
const [license, setLicense] = useState(null);
|
||||
const deviceSN = 'GD30-TEST-001';
|
||||
|
||||
useEffect(() => {
|
||||
fetchLicense();
|
||||
}, []);
|
||||
|
||||
async function fetchLicense() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://your-server.com/api/licenses/download?sn=${deviceSN}`
|
||||
);
|
||||
const data = await response.json();
|
||||
setLicense(data);
|
||||
|
||||
// 启用授权的功能模块
|
||||
enableFeatures(data.authModules);
|
||||
} catch (error) {
|
||||
console.error('获取授权失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function enableFeatures(modules) {
|
||||
modules.forEach(module => {
|
||||
if (module.enabled) {
|
||||
console.log(`启用模块: ${module.name}`);
|
||||
// 启用对应功能
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text>设备SN: {deviceSN}</Text>
|
||||
<Text>授权状态: {license?.status}</Text>
|
||||
<Text>有效期至: {license?.validUntil}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Flutter (Dart)
|
||||
|
||||
```dart
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class LicenseService {
|
||||
static Future<Map<String, dynamic>> getLicense(String deviceSN) async {
|
||||
final response = await http.get(
|
||||
Uri.parse('http://your-server.com/api/licenses/download?sn=$deviceSN'),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body);
|
||||
} else {
|
||||
throw Exception('获取授权失败: ${response.statusCode}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用
|
||||
final license = await LicenseService.getLicense('GD30-TEST-001');
|
||||
print('授权模块: ${license['authModules']}');
|
||||
```
|
||||
|
||||
## 🔍 常见问题排查
|
||||
|
||||
### 问题1:返回"设备不存在"
|
||||
|
||||
**原因**: 设备SN在数据库中不存在
|
||||
|
||||
**解决**:
|
||||
```bash
|
||||
# 先创建设备
|
||||
curl -X POST http://localhost:3000/api/devices \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"sn": "GD30-TEST-001",
|
||||
"model": "GD-30 Supreme",
|
||||
"type": "电法仪",
|
||||
"status": "装配中",
|
||||
"production_date": "2026-04-30"
|
||||
}'
|
||||
```
|
||||
|
||||
### 问题2:返回"该设备暂无有效授权"
|
||||
|
||||
**原因**: 该设备型号没有有效的授权记录
|
||||
|
||||
**解决**:
|
||||
1. 检查是否已为该型号创建授权
|
||||
2. 确认授权状态是"生效"
|
||||
3. 确认授权未过期(expiry >= 当前日期)
|
||||
|
||||
### 问题3:config字段为空
|
||||
|
||||
**原因**: 没有关联的配置文件
|
||||
|
||||
**解决**:
|
||||
1. 在"配置文件管理"页面创建配置
|
||||
2. 确保配置状态是"生效"
|
||||
3. 重新创建授权或更新授权
|
||||
|
||||
## 📊 查看下载日志
|
||||
|
||||
```bash
|
||||
# 使用SQLite命令行
|
||||
sqlite3 data/app.db "SELECT * FROM license_download_logs ORDER BY id DESC LIMIT 5;"
|
||||
|
||||
# 或使用DB Browser for SQLite打开 data/app.db
|
||||
# 查看 license_download_logs 表
|
||||
```
|
||||
|
||||
## ✅ 验证清单
|
||||
|
||||
完成以下步骤确认功能正常:
|
||||
|
||||
- [ ] 可以创建授权记录
|
||||
- [ ] 授权列表中显示"预览"和"下载"按钮
|
||||
- [ ] 点击"预览"可以看到JSON内容
|
||||
- [ ] 点击"下载"可以保存JSON文件
|
||||
- [ ] 调用 `/api/licenses/download?sn=xxx` 返回授权JSON
|
||||
- [ ] JSON包含authModules、config、signature字段
|
||||
- [ ] 下载日志记录到数据库
|
||||
|
||||
## 🎯 下一步
|
||||
|
||||
1. **阅读完整文档**: 查看 `API_LICENSE.md` 了解所有API细节
|
||||
2. **运行测试**: 按照 `TEST_LICENSE.md` 进行完整测试
|
||||
3. **集成到APP**: 使用上面的代码示例集成到你的手机APP
|
||||
4. **安全加固**: 实现真实的RSA签名替换SHA256占位符
|
||||
|
||||
## 💡 提示
|
||||
|
||||
- 开发时可以使用浏览器直接访问下载接口查看结果
|
||||
- 生产环境务必实现真正的数字签名验证
|
||||
- 建议定期备份授权数据和下载日志
|
||||
- 监控下载接口的响应时间和错误率
|
||||
|
||||
---
|
||||
|
||||
**需要帮助?**
|
||||
- 查看 `API_LICENSE.md` - 完整的API文档
|
||||
- 查看 `TEST_LICENSE.md` - 详细的测试指南
|
||||
- 查看 `IMPLEMENTATION_SUMMARY.md` - 实现总结
|
||||
182
README.md
182
README.md
|
|
@ -1,11 +1,181 @@
|
|||
|
||||
# Enterprise SaaS Dashboard Design
|
||||
# Enterprise SaaS Dashboard Design
|
||||
|
||||
This is a code bundle for Enterprise SaaS Dashboard Design. The original project is available at https://www.figma.com/design/SD8ReJmCwErmfmNVnHRlJb/Enterprise-SaaS-Dashboard-Design.
|
||||
This is a code bundle for Enterprise SaaS Dashboard Design. The original project is available at https://www.figma.com/design/SD8ReJmCwErmfmNVnHRlJb/Enterprise-SaaS-Dashboard-Design.
|
||||
|
||||
## Running the code
|
||||
## 🎯 项目简介
|
||||
|
||||
Run `npm i` to install the dependencies.
|
||||
企业级SaaS设备管理平台,提供设备全生命周期管理、授权许可管理、维修工单、固件库及系统配置等功能。
|
||||
|
||||
Run `npm run dev` to start the development server.
|
||||
|
||||
### ✨ 核心功能
|
||||
|
||||
- **设备管理** - 设备注册、列表、详情、BOM清单
|
||||
- **授权管理** - 授权项配置、授权文件生成、手机APP下载接口 ⭐ NEW
|
||||
- **配置文件管理** - 设备技术参数配置
|
||||
- **维修管理** - 维修工单创建、跟踪、统计
|
||||
- **固件管理** - 固件版本管理、发布
|
||||
- **报废管理** - 设备报废流程管理
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
npm i
|
||||
```
|
||||
|
||||
### 启动开发服务器
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
访问 http://localhost:3000
|
||||
|
||||
### 构建生产版本
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## 📚 新增功能:授权文件管理
|
||||
|
||||
### 功能概述
|
||||
|
||||
本平台现已支持根据授权项和配置文件自动生成JSON格式的授权文件,手机APP可通过API接口根据设备SN号获取对应的授权文件。
|
||||
|
||||
### 核心特性
|
||||
|
||||
✅ **智能生成** - 保存授权时自动生成JSON文件
|
||||
✅ **双重授权** - 支持设备级和型号级两种授权模式
|
||||
✅ **数字签名** - 每个授权文件包含SHA256签名确保安全性
|
||||
✅ **完整日志** - 记录每次授权的下载行为
|
||||
✅ **可视化预览** - 管理后台可直接预览和下载授权文件
|
||||
|
||||
### 相关文档
|
||||
|
||||
- 📖 [**快速开始指南**](./QUICKSTART.md) - 5分钟快速上手
|
||||
- 📖 [**API使用文档**](./API_LICENSE.md) - 完整的API接口说明
|
||||
- 📖 [**测试指南**](./TEST_LICENSE.md) - 详细的测试步骤
|
||||
- 📖 [**实现总结**](./IMPLEMENTATION_SUMMARY.md) - 技术实现细节
|
||||
|
||||
### 手机APP集成示例
|
||||
|
||||
```javascript
|
||||
// APP启动时获取设备授权
|
||||
const response = await fetch(
|
||||
`http://your-server.com/api/licenses/download?sn=${deviceSN}`
|
||||
);
|
||||
|
||||
const license = await response.json();
|
||||
|
||||
// 启用授权的功能模块
|
||||
license.authModules.forEach(module => {
|
||||
if (module.enabled) {
|
||||
enableFeature(module.id);
|
||||
}
|
||||
});
|
||||
|
||||
// 应用配置参数
|
||||
applyConfig(license.config);
|
||||
```
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
- **前端框架**: Next.js 14 (App Router)
|
||||
- **UI组件**: React + Tailwind CSS
|
||||
- **图标库**: lucide-react
|
||||
- **数据库**: SQLite (better-sqlite3)
|
||||
- **语言**: TypeScript
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── api/ # API路由
|
||||
│ │ ├── licenses/ # 授权管理API ⭐
|
||||
│ │ │ ├── route.ts
|
||||
│ │ │ ├── download/route.ts # 手机APP下载接口
|
||||
│ │ │ └── [id]/preview/route.ts # 授权预览接口
|
||||
│ │ ├── devices/ # 设备管理API
|
||||
│ │ ├── config-files/ # 配置文件API
|
||||
│ │ └── ...
|
||||
│ ├── licenses/ # 授权管理页面 ⭐
|
||||
│ ├── devices/ # 设备管理页面
|
||||
│ ├── config-files/ # 配置文件页面
|
||||
│ └── ...
|
||||
├── lib/
|
||||
│ ├── db.ts # 数据库操作(含授权文件生成工具)⭐
|
||||
│ └── ...
|
||||
└── styles/
|
||||
```
|
||||
|
||||
## 🔧 API接口概览
|
||||
|
||||
### 授权管理API
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/licenses` | GET | 获取授权列表 |
|
||||
| `/api/licenses` | POST | 创建授权(自动生成JSON) |
|
||||
| `/api/licenses` | PUT | 更新授权 |
|
||||
| `/api/licenses/download?sn=xxx` | GET | ⭐ 手机APP获取授权文件 |
|
||||
| `/api/licenses/{id}/preview` | GET | 预览授权文件JSON |
|
||||
|
||||
### 授权文件JSON结构
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"deviceModel": "GD-30 Supreme",
|
||||
"deviceSN": "GD30-20260430-001",
|
||||
"validUntil": "2027-04-30",
|
||||
"status": "active",
|
||||
"authModules": [...],
|
||||
"config": {...},
|
||||
"signature": {...}
|
||||
}
|
||||
```
|
||||
|
||||
## 📊 数据库表
|
||||
|
||||
### 主要表结构
|
||||
|
||||
- `devices` - 设备信息
|
||||
- `device_models` - 设备型号
|
||||
- `licenses` - 授权记录(含license_file字段)⭐
|
||||
- `config_files` - 配置文件
|
||||
- `license_download_logs` - 授权下载日志 ⭐ NEW
|
||||
- `repair_orders` - 维修工单
|
||||
- `firmware` - 固件版本
|
||||
- `scrap_records` - 报废记录
|
||||
|
||||
## 🔐 安全特性
|
||||
|
||||
1. **数字签名验证** - 防止授权文件被篡改
|
||||
2. **下载日志审计** - 追踪每次授权下载行为
|
||||
3. **有效期控制** - 只返回未过期的有效授权
|
||||
4. **双重匹配机制** - 设备级优先于型号级授权
|
||||
|
||||
## 📝 开发规范
|
||||
|
||||
- 使用TypeScript进行类型检查
|
||||
- 遵循Next.js App Router规范
|
||||
- API路由放在 `src/app/api/` 目录下
|
||||
- 页面组件放在 `src/app/` 对应路由下
|
||||
- 使用Tailwind CSS进行样式开发
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
欢迎提交Issue和Pull Request!
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目仅供学习和参考使用。
|
||||
|
||||
---
|
||||
|
||||
**更新时间**: 2026-04-30
|
||||
**版本**: v1.1.0 (新增授权文件管理功能)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,221 @@
|
|||
# 授权文件功能测试指南
|
||||
|
||||
## 前置准备
|
||||
|
||||
1. 确保已启动开发服务器:`npm run dev`
|
||||
2. 确保数据库中已有以下数据:
|
||||
- 至少一个设备型号(如 GD-30 Supreme)
|
||||
- 至少一个配置文件
|
||||
- 至少一个设备(用于测试下载接口)
|
||||
|
||||
## 测试步骤
|
||||
|
||||
### 1. 创建授权记录
|
||||
|
||||
**方式一:通过管理界面**
|
||||
1. 访问 `http://localhost:3000/licenses`
|
||||
2. 点击"选择授权项"按钮
|
||||
3. 选择设备型号、授权模块和到期时间
|
||||
4. 点击保存,系统会自动生成授权文件JSON
|
||||
|
||||
**方式二:通过API**
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/licenses \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "GD-30",
|
||||
"modules": "一维自电/电阻率/激电测试模块, 二维自电/电阻率/激电测试模块, 三维自电/电阻率/激电测试模块",
|
||||
"expiry": "2027-04-30",
|
||||
"status": "生效",
|
||||
"config_id": 1
|
||||
}'
|
||||
```
|
||||
|
||||
### 2. 预览授权文件
|
||||
|
||||
1. 在授权管理页面找到刚创建的授权记录
|
||||
2. 点击"预览"按钮
|
||||
3. 查看生成的JSON格式授权文件
|
||||
4. 可以点击下载JSON按钮保存到本地
|
||||
|
||||
### 3. 下载授权文件
|
||||
|
||||
1. 在授权管理页面点击"下载"按钮
|
||||
2. 文件将以 `license_GD-30_1.json` 格式下载
|
||||
|
||||
### 4. 测试手机APP下载接口 ⭐
|
||||
|
||||
**首先需要创建一个测试设备:**
|
||||
|
||||
```bash
|
||||
# 创建设备(如果还没有的话)
|
||||
curl -X POST http://localhost:3000/api/devices \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"sn": "GD30-TEST-001",
|
||||
"model": "GD-30 Supreme",
|
||||
"type": "电法仪",
|
||||
"status": "装配中",
|
||||
"production_date": "2026-04-30"
|
||||
}'
|
||||
```
|
||||
|
||||
**然后测试下载接口:**
|
||||
|
||||
```bash
|
||||
# 方式一:使用curl
|
||||
curl "http://localhost:3000/api/licenses/download?sn=GD30-TEST-001"
|
||||
|
||||
# 方式二:使用浏览器直接访问
|
||||
# 打开浏览器访问:http://localhost:3000/api/licenses/download?sn=GD30-TEST-001
|
||||
|
||||
# 方式三:使用JavaScript
|
||||
fetch('http://localhost:3000/api/licenses/download?sn=GD30-TEST-001')
|
||||
.then(res => res.json())
|
||||
.then(data => console.log(data))
|
||||
.catch(err => console.error(err))
|
||||
```
|
||||
|
||||
**预期响应:**
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"generatedAt": "2026-04-30T16:00:00.000Z",
|
||||
"deviceModel": "GD-30 Supreme",
|
||||
"deviceSN": "GD30-TEST-001",
|
||||
"validUntil": "2027-04-30",
|
||||
"status": "active",
|
||||
"authModules": [
|
||||
{
|
||||
"id": "1D",
|
||||
"name": "一维自电/电阻率/激电测试模块",
|
||||
"category": "一维",
|
||||
"enabled": true
|
||||
},
|
||||
...
|
||||
],
|
||||
"config": {
|
||||
"name": "...",
|
||||
"version": "...",
|
||||
"emissionParams": {...},
|
||||
"acquisitionParams": {...},
|
||||
"networkParams": {...}
|
||||
},
|
||||
"signature": {
|
||||
"algorithm": "SHA256",
|
||||
"value": "...",
|
||||
"publicKey": "platform-public-key-placeholder"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 测试错误情况
|
||||
|
||||
**测试设备不存在:**
|
||||
```bash
|
||||
curl "http://localhost:3000/api/licenses/download?sn=NONEXIST-001"
|
||||
# 应返回:{"error": "设备不存在"}
|
||||
```
|
||||
|
||||
**测试无授权的设备:**
|
||||
```bash
|
||||
# 先创建一个没有授权的设备
|
||||
curl -X POST http://localhost:3000/api/devices \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"sn": "GD30-NO-LICENSE-001",
|
||||
"model": "GD-30 Supreme",
|
||||
"type": "电法仪",
|
||||
"status": "装配中",
|
||||
"production_date": "2026-04-30"
|
||||
}'
|
||||
|
||||
# 然后尝试获取授权
|
||||
curl "http://localhost:3000/api/licenses/download?sn=GD30-NO-LICENSE-001"
|
||||
# 应返回:{"error": "该设备暂无有效授权", ...}
|
||||
```
|
||||
|
||||
### 6. 验证签名
|
||||
|
||||
可以使用以下Node.js脚本验证授权文件签名:
|
||||
|
||||
```javascript
|
||||
const crypto = require('crypto');
|
||||
|
||||
function verifyLicense(licenseJson) {
|
||||
const license = typeof licenseJson === 'string' ? JSON.parse(licenseJson) : licenseJson;
|
||||
const { signature, ...dataWithoutSig } = license;
|
||||
|
||||
const jsonString = JSON.stringify(dataWithoutSig, null, 2);
|
||||
const calculatedHash = crypto.createHash('sha256').update(jsonString).digest('hex');
|
||||
|
||||
return calculatedHash === signature.value;
|
||||
}
|
||||
|
||||
// 测试
|
||||
fetch('http://localhost:3000/api/licenses/download?sn=GD30-TEST-001')
|
||||
.then(res => res.json())
|
||||
.then(license => {
|
||||
const isValid = verifyLicense(license);
|
||||
console.log('签名验证结果:', isValid ? '✅ 通过' : '❌ 失败');
|
||||
});
|
||||
```
|
||||
|
||||
### 7. 查看下载日志
|
||||
|
||||
```bash
|
||||
# 可以直接查询数据库查看下载记录
|
||||
sqlite3 data/app.db "SELECT * FROM license_download_logs ORDER BY id DESC LIMIT 10;"
|
||||
```
|
||||
|
||||
或在SQLite浏览器中打开 `data/app.db` 查看 `license_download_logs` 表。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q1: 下载接口返回404?
|
||||
- 检查设备SN是否正确
|
||||
- 确认该设备型号有有效的授权记录
|
||||
- 检查授权状态是否为"生效"且未过期
|
||||
|
||||
### Q2: 授权文件中config为空?
|
||||
- 确保已为该设备型号创建了配置文件
|
||||
- 配置文件状态需要是"生效"
|
||||
|
||||
### Q3: 如何区分设备级授权和型号级授权?
|
||||
- 设备级授权:`device_sn` 字段有值
|
||||
- 型号级授权:`device_sn` 字段为空
|
||||
- 下载时优先匹配设备级授权
|
||||
|
||||
### Q4: 授权文件何时重新生成?
|
||||
- 创建新授权时自动生成
|
||||
- 更新授权信息时自动重新生成
|
||||
- 首次下载时如果license_file为空会生成并保存
|
||||
|
||||
## 性能优化建议
|
||||
|
||||
1. **缓存策略**:授权文件生成后保存在数据库中,避免重复生成
|
||||
2. **索引优化**:为licenses表的device_sn、model、status字段添加索引
|
||||
3. **日志清理**:定期清理过期的下载日志
|
||||
4. **CDN加速**:生产环境可以考虑将授权文件缓存到CDN
|
||||
|
||||
## 下一步开发建议
|
||||
|
||||
1. **前端增强**:
|
||||
- 在设备详情页显示授权状态
|
||||
- 添加授权文件批量导出功能
|
||||
- 显示授权下载统计
|
||||
|
||||
2. **安全增强**:
|
||||
- 实现真实的RSA签名(目前使用SHA256占位)
|
||||
- 添加授权文件加密
|
||||
- 实现授权文件版本控制
|
||||
|
||||
3. **功能扩展**:
|
||||
- 支持授权模板批量应用
|
||||
- 添加授权到期提醒
|
||||
- 实现授权历史记录
|
||||
|
||||
4. **监控告警**:
|
||||
- 监控授权下载次数
|
||||
- 异常下载行为检测
|
||||
- 授权即将到期预警
|
||||
|
|
@ -1,571 +0,0 @@
|
|||
A,B,M,N,K,stack,Layer,x,Depth
|
||||
1.0,4.0,2.0,3.0,6.283185307179586,1.0,1.0,2.5,-0.6
|
||||
1.0,7.0,3.0,5.0,12.566370614359172,1.0,2.0,4.0,-1.2
|
||||
1.0,10.0,4.0,7.0,18.84955592153876,1.0,3.0,5.5,-1.8
|
||||
1.0,13.0,5.0,9.0,25.132741228718345,1.0,4.0,7.0,-2.4
|
||||
1.0,16.0,6.0,11.0,31.41592653589793,1.0,5.0,8.5,-3.0
|
||||
1.0,19.0,7.0,13.0,37.69911184307752,1.0,6.0,10.0,-3.6
|
||||
1.0,22.0,8.0,15.0,43.982297150257104,1.0,7.0,11.5,-4.2
|
||||
1.0,25.0,9.0,17.0,50.26548245743669,1.0,8.0,13.0,-4.8
|
||||
1.0,28.0,10.0,19.0,56.548667764616276,1.0,9.0,14.5,-5.4
|
||||
1.0,31.0,11.0,21.0,62.83185307179586,1.0,10.0,16.0,-6.0
|
||||
1.0,34.0,12.0,23.0,69.11503837897544,1.0,11.0,17.5,-6.6
|
||||
1.0,37.0,13.0,25.0,75.39822368615503,1.0,12.0,19.0,-7.2
|
||||
1.0,40.0,14.0,27.0,81.68140899333461,1.0,13.0,20.5,-7.8
|
||||
1.0,43.0,15.0,29.0,87.96459430051421,1.0,14.0,22.0,-8.4
|
||||
1.0,46.0,16.0,31.0,94.2477796076938,1.0,15.0,23.5,-9.0
|
||||
1.0,49.0,17.0,33.0,100.53096491487338,1.0,16.0,25.0,-9.6
|
||||
1.0,52.0,18.0,35.0,106.81415022205297,1.0,17.0,26.5,-10.2
|
||||
1.0,55.0,19.0,37.0,113.09733552923255,1.0,18.0,28.0,-10.8
|
||||
1.0,58.0,20.0,39.0,119.38052083641215,1.0,19.0,29.5,-11.4
|
||||
2.0,5.0,3.0,4.0,6.283185307179586,1.0,1.0,3.5,-0.6
|
||||
2.0,8.0,4.0,6.0,12.566370614359172,1.0,2.0,5.0,-1.2
|
||||
2.0,11.0,5.0,8.0,18.84955592153876,1.0,3.0,6.5,-1.8
|
||||
2.0,14.0,6.0,10.0,25.132741228718345,1.0,4.0,8.0,-2.4
|
||||
2.0,17.0,7.0,12.0,31.41592653589793,1.0,5.0,9.5,-3.0
|
||||
2.0,20.0,8.0,14.0,37.69911184307752,1.0,6.0,11.0,-3.6
|
||||
2.0,23.0,9.0,16.0,43.982297150257104,1.0,7.0,12.5,-4.2
|
||||
2.0,26.0,10.0,18.0,50.26548245743669,1.0,8.0,14.0,-4.8
|
||||
2.0,29.0,11.0,20.0,56.548667764616276,1.0,9.0,15.5,-5.4
|
||||
2.0,32.0,12.0,22.0,62.83185307179586,1.0,10.0,17.0,-6.0
|
||||
2.0,35.0,13.0,24.0,69.11503837897544,1.0,11.0,18.5,-6.6
|
||||
2.0,38.0,14.0,26.0,75.39822368615503,1.0,12.0,20.0,-7.2
|
||||
2.0,41.0,15.0,28.0,81.68140899333461,1.0,13.0,21.5,-7.8
|
||||
2.0,44.0,16.0,30.0,87.96459430051421,1.0,14.0,23.0,-8.4
|
||||
2.0,47.0,17.0,32.0,94.2477796076938,1.0,15.0,24.5,-9.0
|
||||
2.0,50.0,18.0,34.0,100.53096491487338,1.0,16.0,26.0,-9.6
|
||||
2.0,53.0,19.0,36.0,106.81415022205297,1.0,17.0,27.5,-10.2
|
||||
2.0,56.0,20.0,38.0,113.09733552923255,1.0,18.0,29.0,-10.8
|
||||
2.0,59.0,21.0,40.0,119.38052083641215,1.0,19.0,30.5,-11.4
|
||||
3.0,6.0,4.0,5.0,6.283185307179586,1.0,1.0,4.5,-0.6
|
||||
3.0,9.0,5.0,7.0,12.566370614359172,1.0,2.0,6.0,-1.2
|
||||
3.0,12.0,6.0,9.0,18.84955592153876,1.0,3.0,7.5,-1.8
|
||||
3.0,15.0,7.0,11.0,25.132741228718345,1.0,4.0,9.0,-2.4
|
||||
3.0,18.0,8.0,13.0,31.41592653589793,1.0,5.0,10.5,-3.0
|
||||
3.0,21.0,9.0,15.0,37.69911184307752,1.0,6.0,12.0,-3.6
|
||||
3.0,24.0,10.0,17.0,43.982297150257104,1.0,7.0,13.5,-4.2
|
||||
3.0,27.0,11.0,19.0,50.26548245743669,1.0,8.0,15.0,-4.8
|
||||
3.0,30.0,12.0,21.0,56.548667764616276,1.0,9.0,16.5,-5.4
|
||||
3.0,33.0,13.0,23.0,62.83185307179586,1.0,10.0,18.0,-6.0
|
||||
3.0,36.0,14.0,25.0,69.11503837897544,1.0,11.0,19.5,-6.6
|
||||
3.0,39.0,15.0,27.0,75.39822368615503,1.0,12.0,21.0,-7.2
|
||||
3.0,42.0,16.0,29.0,81.68140899333461,1.0,13.0,22.5,-7.8
|
||||
3.0,45.0,17.0,31.0,87.96459430051421,1.0,14.0,24.0,-8.4
|
||||
3.0,48.0,18.0,33.0,94.2477796076938,1.0,15.0,25.5,-9.0
|
||||
3.0,51.0,19.0,35.0,100.53096491487338,1.0,16.0,27.0,-9.6
|
||||
3.0,54.0,20.0,37.0,106.81415022205297,1.0,17.0,28.5,-10.2
|
||||
3.0,57.0,21.0,39.0,113.09733552923255,1.0,18.0,30.0,-10.8
|
||||
3.0,60.0,22.0,41.0,119.38052083641215,1.0,19.0,31.5,-11.4
|
||||
4.0,7.0,5.0,6.0,6.283185307179586,1.0,1.0,5.5,-0.6
|
||||
4.0,10.0,6.0,8.0,12.566370614359172,1.0,2.0,7.0,-1.2
|
||||
4.0,13.0,7.0,10.0,18.84955592153876,1.0,3.0,8.5,-1.8
|
||||
4.0,16.0,8.0,12.0,25.132741228718345,1.0,4.0,10.0,-2.4
|
||||
4.0,19.0,9.0,14.0,31.41592653589793,1.0,5.0,11.5,-3.0
|
||||
4.0,22.0,10.0,16.0,37.69911184307752,1.0,6.0,13.0,-3.6
|
||||
4.0,25.0,11.0,18.0,43.982297150257104,1.0,7.0,14.5,-4.2
|
||||
4.0,28.0,12.0,20.0,50.26548245743669,1.0,8.0,16.0,-4.8
|
||||
4.0,31.0,13.0,22.0,56.548667764616276,1.0,9.0,17.5,-5.4
|
||||
4.0,34.0,14.0,24.0,62.83185307179586,1.0,10.0,19.0,-6.0
|
||||
4.0,37.0,15.0,26.0,69.11503837897544,1.0,11.0,20.5,-6.6
|
||||
4.0,40.0,16.0,28.0,75.39822368615503,1.0,12.0,22.0,-7.2
|
||||
4.0,43.0,17.0,30.0,81.68140899333461,1.0,13.0,23.5,-7.8
|
||||
4.0,46.0,18.0,32.0,87.96459430051421,1.0,14.0,25.0,-8.4
|
||||
4.0,49.0,19.0,34.0,94.2477796076938,1.0,15.0,26.5,-9.0
|
||||
4.0,52.0,20.0,36.0,100.53096491487338,1.0,16.0,28.0,-9.6
|
||||
4.0,55.0,21.0,38.0,106.81415022205297,1.0,17.0,29.5,-10.2
|
||||
4.0,58.0,22.0,40.0,113.09733552923255,1.0,18.0,31.0,-10.8
|
||||
5.0,8.0,6.0,7.0,6.283185307179586,1.0,1.0,6.5,-0.6
|
||||
5.0,11.0,7.0,9.0,12.566370614359172,1.0,2.0,8.0,-1.2
|
||||
5.0,14.0,8.0,11.0,18.84955592153876,1.0,3.0,9.5,-1.8
|
||||
5.0,17.0,9.0,13.0,25.132741228718345,1.0,4.0,11.0,-2.4
|
||||
5.0,20.0,10.0,15.0,31.41592653589793,1.0,5.0,12.5,-3.0
|
||||
5.0,23.0,11.0,17.0,37.69911184307752,1.0,6.0,14.0,-3.6
|
||||
5.0,26.0,12.0,19.0,43.982297150257104,1.0,7.0,15.5,-4.2
|
||||
5.0,29.0,13.0,21.0,50.26548245743669,1.0,8.0,17.0,-4.8
|
||||
5.0,32.0,14.0,23.0,56.548667764616276,1.0,9.0,18.5,-5.4
|
||||
5.0,35.0,15.0,25.0,62.83185307179586,1.0,10.0,20.0,-6.0
|
||||
5.0,38.0,16.0,27.0,69.11503837897544,1.0,11.0,21.5,-6.6
|
||||
5.0,41.0,17.0,29.0,75.39822368615503,1.0,12.0,23.0,-7.2
|
||||
5.0,44.0,18.0,31.0,81.68140899333461,1.0,13.0,24.5,-7.8
|
||||
5.0,47.0,19.0,33.0,87.96459430051421,1.0,14.0,26.0,-8.4
|
||||
5.0,50.0,20.0,35.0,94.2477796076938,1.0,15.0,27.5,-9.0
|
||||
5.0,53.0,21.0,37.0,100.53096491487338,1.0,16.0,29.0,-9.6
|
||||
5.0,56.0,22.0,39.0,106.81415022205297,1.0,17.0,30.5,-10.2
|
||||
5.0,59.0,23.0,41.0,113.09733552923255,1.0,18.0,32.0,-10.8
|
||||
6.0,9.0,7.0,8.0,6.283185307179586,1.0,1.0,7.5,-0.6
|
||||
6.0,12.0,8.0,10.0,12.566370614359172,1.0,2.0,9.0,-1.2
|
||||
6.0,15.0,9.0,12.0,18.84955592153876,1.0,3.0,10.5,-1.8
|
||||
6.0,18.0,10.0,14.0,25.132741228718345,1.0,4.0,12.0,-2.4
|
||||
6.0,21.0,11.0,16.0,31.41592653589793,1.0,5.0,13.5,-3.0
|
||||
6.0,24.0,12.0,18.0,37.69911184307752,1.0,6.0,15.0,-3.6
|
||||
6.0,27.0,13.0,20.0,43.982297150257104,1.0,7.0,16.5,-4.2
|
||||
6.0,30.0,14.0,22.0,50.26548245743669,1.0,8.0,18.0,-4.8
|
||||
6.0,33.0,15.0,24.0,56.548667764616276,1.0,9.0,19.5,-5.4
|
||||
6.0,36.0,16.0,26.0,62.83185307179586,1.0,10.0,21.0,-6.0
|
||||
6.0,39.0,17.0,28.0,69.11503837897544,1.0,11.0,22.5,-6.6
|
||||
6.0,42.0,18.0,30.0,75.39822368615503,1.0,12.0,24.0,-7.2
|
||||
6.0,45.0,19.0,32.0,81.68140899333461,1.0,13.0,25.5,-7.8
|
||||
6.0,48.0,20.0,34.0,87.96459430051421,1.0,14.0,27.0,-8.4
|
||||
6.0,51.0,21.0,36.0,94.2477796076938,1.0,15.0,28.5,-9.0
|
||||
6.0,54.0,22.0,38.0,100.53096491487338,1.0,16.0,30.0,-9.6
|
||||
6.0,57.0,23.0,40.0,106.81415022205297,1.0,17.0,31.5,-10.2
|
||||
6.0,60.0,24.0,42.0,113.09733552923255,1.0,18.0,33.0,-10.8
|
||||
7.0,10.0,8.0,9.0,6.283185307179586,1.0,1.0,8.5,-0.6
|
||||
7.0,13.0,9.0,11.0,12.566370614359172,1.0,2.0,10.0,-1.2
|
||||
7.0,16.0,10.0,13.0,18.84955592153876,1.0,3.0,11.5,-1.8
|
||||
7.0,19.0,11.0,15.0,25.132741228718345,1.0,4.0,13.0,-2.4
|
||||
7.0,22.0,12.0,17.0,31.41592653589793,1.0,5.0,14.5,-3.0
|
||||
7.0,25.0,13.0,19.0,37.69911184307752,1.0,6.0,16.0,-3.6
|
||||
7.0,28.0,14.0,21.0,43.982297150257104,1.0,7.0,17.5,-4.2
|
||||
7.0,31.0,15.0,23.0,50.26548245743669,1.0,8.0,19.0,-4.8
|
||||
7.0,34.0,16.0,25.0,56.548667764616276,1.0,9.0,20.5,-5.4
|
||||
7.0,37.0,17.0,27.0,62.83185307179586,1.0,10.0,22.0,-6.0
|
||||
7.0,40.0,18.0,29.0,69.11503837897544,1.0,11.0,23.5,-6.6
|
||||
7.0,43.0,19.0,31.0,75.39822368615503,1.0,12.0,25.0,-7.2
|
||||
7.0,46.0,20.0,33.0,81.68140899333461,1.0,13.0,26.5,-7.8
|
||||
7.0,49.0,21.0,35.0,87.96459430051421,1.0,14.0,28.0,-8.4
|
||||
7.0,52.0,22.0,37.0,94.2477796076938,1.0,15.0,29.5,-9.0
|
||||
7.0,55.0,23.0,39.0,100.53096491487338,1.0,16.0,31.0,-9.6
|
||||
7.0,58.0,24.0,41.0,106.81415022205297,1.0,17.0,32.5,-10.2
|
||||
8.0,11.0,9.0,10.0,6.283185307179586,1.0,1.0,9.5,-0.6
|
||||
8.0,14.0,10.0,12.0,12.566370614359172,1.0,2.0,11.0,-1.2
|
||||
8.0,17.0,11.0,14.0,18.84955592153876,1.0,3.0,12.5,-1.8
|
||||
8.0,20.0,12.0,16.0,25.132741228718345,1.0,4.0,14.0,-2.4
|
||||
8.0,23.0,13.0,18.0,31.41592653589793,1.0,5.0,15.5,-3.0
|
||||
8.0,26.0,14.0,20.0,37.69911184307752,1.0,6.0,17.0,-3.6
|
||||
8.0,29.0,15.0,22.0,43.982297150257104,1.0,7.0,18.5,-4.2
|
||||
8.0,32.0,16.0,24.0,50.26548245743669,1.0,8.0,20.0,-4.8
|
||||
8.0,35.0,17.0,26.0,56.548667764616276,1.0,9.0,21.5,-5.4
|
||||
8.0,38.0,18.0,28.0,62.83185307179586,1.0,10.0,23.0,-6.0
|
||||
8.0,41.0,19.0,30.0,69.11503837897544,1.0,11.0,24.5,-6.6
|
||||
8.0,44.0,20.0,32.0,75.39822368615503,1.0,12.0,26.0,-7.2
|
||||
8.0,47.0,21.0,34.0,81.68140899333461,1.0,13.0,27.5,-7.8
|
||||
8.0,50.0,22.0,36.0,87.96459430051421,1.0,14.0,29.0,-8.4
|
||||
8.0,53.0,23.0,38.0,94.2477796076938,1.0,15.0,30.5,-9.0
|
||||
8.0,56.0,24.0,40.0,100.53096491487338,1.0,16.0,32.0,-9.6
|
||||
8.0,59.0,25.0,42.0,106.81415022205297,1.0,17.0,33.5,-10.2
|
||||
9.0,12.0,10.0,11.0,6.283185307179586,1.0,1.0,10.5,-0.6
|
||||
9.0,15.0,11.0,13.0,12.566370614359172,1.0,2.0,12.0,-1.2
|
||||
9.0,18.0,12.0,15.0,18.84955592153876,1.0,3.0,13.5,-1.8
|
||||
9.0,21.0,13.0,17.0,25.132741228718345,1.0,4.0,15.0,-2.4
|
||||
9.0,24.0,14.0,19.0,31.41592653589793,1.0,5.0,16.5,-3.0
|
||||
9.0,27.0,15.0,21.0,37.69911184307752,1.0,6.0,18.0,-3.6
|
||||
9.0,30.0,16.0,23.0,43.982297150257104,1.0,7.0,19.5,-4.2
|
||||
9.0,33.0,17.0,25.0,50.26548245743669,1.0,8.0,21.0,-4.8
|
||||
9.0,36.0,18.0,27.0,56.548667764616276,1.0,9.0,22.5,-5.4
|
||||
9.0,39.0,19.0,29.0,62.83185307179586,1.0,10.0,24.0,-6.0
|
||||
9.0,42.0,20.0,31.0,69.11503837897544,1.0,11.0,25.5,-6.6
|
||||
9.0,45.0,21.0,33.0,75.39822368615503,1.0,12.0,27.0,-7.2
|
||||
9.0,48.0,22.0,35.0,81.68140899333461,1.0,13.0,28.5,-7.8
|
||||
9.0,51.0,23.0,37.0,87.96459430051421,1.0,14.0,30.0,-8.4
|
||||
9.0,54.0,24.0,39.0,94.2477796076938,1.0,15.0,31.5,-9.0
|
||||
9.0,57.0,25.0,41.0,100.53096491487338,1.0,16.0,33.0,-9.6
|
||||
9.0,60.0,26.0,43.0,106.81415022205297,1.0,17.0,34.5,-10.2
|
||||
10.0,13.0,11.0,12.0,6.283185307179586,1.0,1.0,11.5,-0.6
|
||||
10.0,16.0,12.0,14.0,12.566370614359172,1.0,2.0,13.0,-1.2
|
||||
10.0,19.0,13.0,16.0,18.84955592153876,1.0,3.0,14.5,-1.8
|
||||
10.0,22.0,14.0,18.0,25.132741228718345,1.0,4.0,16.0,-2.4
|
||||
10.0,25.0,15.0,20.0,31.41592653589793,1.0,5.0,17.5,-3.0
|
||||
10.0,28.0,16.0,22.0,37.69911184307752,1.0,6.0,19.0,-3.6
|
||||
10.0,31.0,17.0,24.0,43.982297150257104,1.0,7.0,20.5,-4.2
|
||||
10.0,34.0,18.0,26.0,50.26548245743669,1.0,8.0,22.0,-4.8
|
||||
10.0,37.0,19.0,28.0,56.548667764616276,1.0,9.0,23.5,-5.4
|
||||
10.0,40.0,20.0,30.0,62.83185307179586,1.0,10.0,25.0,-6.0
|
||||
10.0,43.0,21.0,32.0,69.11503837897544,1.0,11.0,26.5,-6.6
|
||||
10.0,46.0,22.0,34.0,75.39822368615503,1.0,12.0,28.0,-7.2
|
||||
10.0,49.0,23.0,36.0,81.68140899333461,1.0,13.0,29.5,-7.8
|
||||
10.0,52.0,24.0,38.0,87.96459430051421,1.0,14.0,31.0,-8.4
|
||||
10.0,55.0,25.0,40.0,94.2477796076938,1.0,15.0,32.5,-9.0
|
||||
10.0,58.0,26.0,42.0,100.53096491487338,1.0,16.0,34.0,-9.6
|
||||
11.0,14.0,12.0,13.0,6.283185307179586,1.0,1.0,12.5,-0.6
|
||||
11.0,17.0,13.0,15.0,12.566370614359172,1.0,2.0,14.0,-1.2
|
||||
11.0,20.0,14.0,17.0,18.84955592153876,1.0,3.0,15.5,-1.8
|
||||
11.0,23.0,15.0,19.0,25.132741228718345,1.0,4.0,17.0,-2.4
|
||||
11.0,26.0,16.0,21.0,31.41592653589793,1.0,5.0,18.5,-3.0
|
||||
11.0,29.0,17.0,23.0,37.69911184307752,1.0,6.0,20.0,-3.6
|
||||
11.0,32.0,18.0,25.0,43.982297150257104,1.0,7.0,21.5,-4.2
|
||||
11.0,35.0,19.0,27.0,50.26548245743669,1.0,8.0,23.0,-4.8
|
||||
11.0,38.0,20.0,29.0,56.548667764616276,1.0,9.0,24.5,-5.4
|
||||
11.0,41.0,21.0,31.0,62.83185307179586,1.0,10.0,26.0,-6.0
|
||||
11.0,44.0,22.0,33.0,69.11503837897544,1.0,11.0,27.5,-6.6
|
||||
11.0,47.0,23.0,35.0,75.39822368615503,1.0,12.0,29.0,-7.2
|
||||
11.0,50.0,24.0,37.0,81.68140899333461,1.0,13.0,30.5,-7.8
|
||||
11.0,53.0,25.0,39.0,87.96459430051421,1.0,14.0,32.0,-8.4
|
||||
11.0,56.0,26.0,41.0,94.2477796076938,1.0,15.0,33.5,-9.0
|
||||
11.0,59.0,27.0,43.0,100.53096491487338,1.0,16.0,35.0,-9.6
|
||||
12.0,15.0,13.0,14.0,6.283185307179586,1.0,1.0,13.5,-0.6
|
||||
12.0,18.0,14.0,16.0,12.566370614359172,1.0,2.0,15.0,-1.2
|
||||
12.0,21.0,15.0,18.0,18.84955592153876,1.0,3.0,16.5,-1.8
|
||||
12.0,24.0,16.0,20.0,25.132741228718345,1.0,4.0,18.0,-2.4
|
||||
12.0,27.0,17.0,22.0,31.41592653589793,1.0,5.0,19.5,-3.0
|
||||
12.0,30.0,18.0,24.0,37.69911184307752,1.0,6.0,21.0,-3.6
|
||||
12.0,33.0,19.0,26.0,43.982297150257104,1.0,7.0,22.5,-4.2
|
||||
12.0,36.0,20.0,28.0,50.26548245743669,1.0,8.0,24.0,-4.8
|
||||
12.0,39.0,21.0,30.0,56.548667764616276,1.0,9.0,25.5,-5.4
|
||||
12.0,42.0,22.0,32.0,62.83185307179586,1.0,10.0,27.0,-6.0
|
||||
12.0,45.0,23.0,34.0,69.11503837897544,1.0,11.0,28.5,-6.6
|
||||
12.0,48.0,24.0,36.0,75.39822368615503,1.0,12.0,30.0,-7.2
|
||||
12.0,51.0,25.0,38.0,81.68140899333461,1.0,13.0,31.5,-7.8
|
||||
12.0,54.0,26.0,40.0,87.96459430051421,1.0,14.0,33.0,-8.4
|
||||
12.0,57.0,27.0,42.0,94.2477796076938,1.0,15.0,34.5,-9.0
|
||||
12.0,60.0,28.0,44.0,100.53096491487338,1.0,16.0,36.0,-9.6
|
||||
13.0,16.0,14.0,15.0,6.283185307179586,1.0,1.0,14.5,-0.6
|
||||
13.0,19.0,15.0,17.0,12.566370614359172,1.0,2.0,16.0,-1.2
|
||||
13.0,22.0,16.0,19.0,18.84955592153876,1.0,3.0,17.5,-1.8
|
||||
13.0,25.0,17.0,21.0,25.132741228718345,1.0,4.0,19.0,-2.4
|
||||
13.0,28.0,18.0,23.0,31.41592653589793,1.0,5.0,20.5,-3.0
|
||||
13.0,31.0,19.0,25.0,37.69911184307752,1.0,6.0,22.0,-3.6
|
||||
13.0,34.0,20.0,27.0,43.982297150257104,1.0,7.0,23.5,-4.2
|
||||
13.0,37.0,21.0,29.0,50.26548245743669,1.0,8.0,25.0,-4.8
|
||||
13.0,40.0,22.0,31.0,56.548667764616276,1.0,9.0,26.5,-5.4
|
||||
13.0,43.0,23.0,33.0,62.83185307179586,1.0,10.0,28.0,-6.0
|
||||
13.0,46.0,24.0,35.0,69.11503837897544,1.0,11.0,29.5,-6.6
|
||||
13.0,49.0,25.0,37.0,75.39822368615503,1.0,12.0,31.0,-7.2
|
||||
13.0,52.0,26.0,39.0,81.68140899333461,1.0,13.0,32.5,-7.8
|
||||
13.0,55.0,27.0,41.0,87.96459430051421,1.0,14.0,34.0,-8.4
|
||||
13.0,58.0,28.0,43.0,94.2477796076938,1.0,15.0,35.5,-9.0
|
||||
14.0,17.0,15.0,16.0,6.283185307179586,1.0,1.0,15.5,-0.6
|
||||
14.0,20.0,16.0,18.0,12.566370614359172,1.0,2.0,17.0,-1.2
|
||||
14.0,23.0,17.0,20.0,18.84955592153876,1.0,3.0,18.5,-1.8
|
||||
14.0,26.0,18.0,22.0,25.132741228718345,1.0,4.0,20.0,-2.4
|
||||
14.0,29.0,19.0,24.0,31.41592653589793,1.0,5.0,21.5,-3.0
|
||||
14.0,32.0,20.0,26.0,37.69911184307752,1.0,6.0,23.0,-3.6
|
||||
14.0,35.0,21.0,28.0,43.982297150257104,1.0,7.0,24.5,-4.2
|
||||
14.0,38.0,22.0,30.0,50.26548245743669,1.0,8.0,26.0,-4.8
|
||||
14.0,41.0,23.0,32.0,56.548667764616276,1.0,9.0,27.5,-5.4
|
||||
14.0,44.0,24.0,34.0,62.83185307179586,1.0,10.0,29.0,-6.0
|
||||
14.0,47.0,25.0,36.0,69.11503837897544,1.0,11.0,30.5,-6.6
|
||||
14.0,50.0,26.0,38.0,75.39822368615503,1.0,12.0,32.0,-7.2
|
||||
14.0,53.0,27.0,40.0,81.68140899333461,1.0,13.0,33.5,-7.8
|
||||
14.0,56.0,28.0,42.0,87.96459430051421,1.0,14.0,35.0,-8.4
|
||||
14.0,59.0,29.0,44.0,94.2477796076938,1.0,15.0,36.5,-9.0
|
||||
15.0,18.0,16.0,17.0,6.283185307179586,1.0,1.0,16.5,-0.6
|
||||
15.0,21.0,17.0,19.0,12.566370614359172,1.0,2.0,18.0,-1.2
|
||||
15.0,24.0,18.0,21.0,18.84955592153876,1.0,3.0,19.5,-1.8
|
||||
15.0,27.0,19.0,23.0,25.132741228718345,1.0,4.0,21.0,-2.4
|
||||
15.0,30.0,20.0,25.0,31.41592653589793,1.0,5.0,22.5,-3.0
|
||||
15.0,33.0,21.0,27.0,37.69911184307752,1.0,6.0,24.0,-3.6
|
||||
15.0,36.0,22.0,29.0,43.982297150257104,1.0,7.0,25.5,-4.2
|
||||
15.0,39.0,23.0,31.0,50.26548245743669,1.0,8.0,27.0,-4.8
|
||||
15.0,42.0,24.0,33.0,56.548667764616276,1.0,9.0,28.5,-5.4
|
||||
15.0,45.0,25.0,35.0,62.83185307179586,1.0,10.0,30.0,-6.0
|
||||
15.0,48.0,26.0,37.0,69.11503837897544,1.0,11.0,31.5,-6.6
|
||||
15.0,51.0,27.0,39.0,75.39822368615503,1.0,12.0,33.0,-7.2
|
||||
15.0,54.0,28.0,41.0,81.68140899333461,1.0,13.0,34.5,-7.8
|
||||
15.0,57.0,29.0,43.0,87.96459430051421,1.0,14.0,36.0,-8.4
|
||||
15.0,60.0,30.0,45.0,94.2477796076938,1.0,15.0,37.5,-9.0
|
||||
16.0,19.0,17.0,18.0,6.283185307179586,1.0,1.0,17.5,-0.6
|
||||
16.0,22.0,18.0,20.0,12.566370614359172,1.0,2.0,19.0,-1.2
|
||||
16.0,25.0,19.0,22.0,18.84955592153876,1.0,3.0,20.5,-1.8
|
||||
16.0,28.0,20.0,24.0,25.132741228718345,1.0,4.0,22.0,-2.4
|
||||
16.0,31.0,21.0,26.0,31.41592653589793,1.0,5.0,23.5,-3.0
|
||||
16.0,34.0,22.0,28.0,37.69911184307752,1.0,6.0,25.0,-3.6
|
||||
16.0,37.0,23.0,30.0,43.982297150257104,1.0,7.0,26.5,-4.2
|
||||
16.0,40.0,24.0,32.0,50.26548245743669,1.0,8.0,28.0,-4.8
|
||||
16.0,43.0,25.0,34.0,56.548667764616276,1.0,9.0,29.5,-5.4
|
||||
16.0,46.0,26.0,36.0,62.83185307179586,1.0,10.0,31.0,-6.0
|
||||
16.0,49.0,27.0,38.0,69.11503837897544,1.0,11.0,32.5,-6.6
|
||||
16.0,52.0,28.0,40.0,75.39822368615503,1.0,12.0,34.0,-7.2
|
||||
16.0,55.0,29.0,42.0,81.68140899333461,1.0,13.0,35.5,-7.8
|
||||
16.0,58.0,30.0,44.0,87.96459430051421,1.0,14.0,37.0,-8.4
|
||||
17.0,20.0,18.0,19.0,6.283185307179586,1.0,1.0,18.5,-0.6
|
||||
17.0,23.0,19.0,21.0,12.566370614359172,1.0,2.0,20.0,-1.2
|
||||
17.0,26.0,20.0,23.0,18.84955592153876,1.0,3.0,21.5,-1.8
|
||||
17.0,29.0,21.0,25.0,25.132741228718345,1.0,4.0,23.0,-2.4
|
||||
17.0,32.0,22.0,27.0,31.41592653589793,1.0,5.0,24.5,-3.0
|
||||
17.0,35.0,23.0,29.0,37.69911184307752,1.0,6.0,26.0,-3.6
|
||||
17.0,38.0,24.0,31.0,43.982297150257104,1.0,7.0,27.5,-4.2
|
||||
17.0,41.0,25.0,33.0,50.26548245743669,1.0,8.0,29.0,-4.8
|
||||
17.0,44.0,26.0,35.0,56.548667764616276,1.0,9.0,30.5,-5.4
|
||||
17.0,47.0,27.0,37.0,62.83185307179586,1.0,10.0,32.0,-6.0
|
||||
17.0,50.0,28.0,39.0,69.11503837897544,1.0,11.0,33.5,-6.6
|
||||
17.0,53.0,29.0,41.0,75.39822368615503,1.0,12.0,35.0,-7.2
|
||||
17.0,56.0,30.0,43.0,81.68140899333461,1.0,13.0,36.5,-7.8
|
||||
17.0,59.0,31.0,45.0,87.96459430051421,1.0,14.0,38.0,-8.4
|
||||
18.0,21.0,19.0,20.0,6.283185307179586,1.0,1.0,19.5,-0.6
|
||||
18.0,24.0,20.0,22.0,12.566370614359172,1.0,2.0,21.0,-1.2
|
||||
18.0,27.0,21.0,24.0,18.84955592153876,1.0,3.0,22.5,-1.8
|
||||
18.0,30.0,22.0,26.0,25.132741228718345,1.0,4.0,24.0,-2.4
|
||||
18.0,33.0,23.0,28.0,31.41592653589793,1.0,5.0,25.5,-3.0
|
||||
18.0,36.0,24.0,30.0,37.69911184307752,1.0,6.0,27.0,-3.6
|
||||
18.0,39.0,25.0,32.0,43.982297150257104,1.0,7.0,28.5,-4.2
|
||||
18.0,42.0,26.0,34.0,50.26548245743669,1.0,8.0,30.0,-4.8
|
||||
18.0,45.0,27.0,36.0,56.548667764616276,1.0,9.0,31.5,-5.4
|
||||
18.0,48.0,28.0,38.0,62.83185307179586,1.0,10.0,33.0,-6.0
|
||||
18.0,51.0,29.0,40.0,69.11503837897544,1.0,11.0,34.5,-6.6
|
||||
18.0,54.0,30.0,42.0,75.39822368615503,1.0,12.0,36.0,-7.2
|
||||
18.0,57.0,31.0,44.0,81.68140899333461,1.0,13.0,37.5,-7.8
|
||||
18.0,60.0,32.0,46.0,87.96459430051421,1.0,14.0,39.0,-8.4
|
||||
19.0,22.0,20.0,21.0,6.283185307179586,1.0,1.0,20.5,-0.6
|
||||
19.0,25.0,21.0,23.0,12.566370614359172,1.0,2.0,22.0,-1.2
|
||||
19.0,28.0,22.0,25.0,18.84955592153876,1.0,3.0,23.5,-1.8
|
||||
19.0,31.0,23.0,27.0,25.132741228718345,1.0,4.0,25.0,-2.4
|
||||
19.0,34.0,24.0,29.0,31.41592653589793,1.0,5.0,26.5,-3.0
|
||||
19.0,37.0,25.0,31.0,37.69911184307752,1.0,6.0,28.0,-3.6
|
||||
19.0,40.0,26.0,33.0,43.982297150257104,1.0,7.0,29.5,-4.2
|
||||
19.0,43.0,27.0,35.0,50.26548245743669,1.0,8.0,31.0,-4.8
|
||||
19.0,46.0,28.0,37.0,56.548667764616276,1.0,9.0,32.5,-5.4
|
||||
19.0,49.0,29.0,39.0,62.83185307179586,1.0,10.0,34.0,-6.0
|
||||
19.0,52.0,30.0,41.0,69.11503837897544,1.0,11.0,35.5,-6.6
|
||||
19.0,55.0,31.0,43.0,75.39822368615503,1.0,12.0,37.0,-7.2
|
||||
19.0,58.0,32.0,45.0,81.68140899333461,1.0,13.0,38.5,-7.8
|
||||
20.0,23.0,21.0,22.0,6.283185307179586,1.0,1.0,21.5,-0.6
|
||||
20.0,26.0,22.0,24.0,12.566370614359172,1.0,2.0,23.0,-1.2
|
||||
20.0,29.0,23.0,26.0,18.84955592153876,1.0,3.0,24.5,-1.8
|
||||
20.0,32.0,24.0,28.0,25.132741228718345,1.0,4.0,26.0,-2.4
|
||||
20.0,35.0,25.0,30.0,31.41592653589793,1.0,5.0,27.5,-3.0
|
||||
20.0,38.0,26.0,32.0,37.69911184307752,1.0,6.0,29.0,-3.6
|
||||
20.0,41.0,27.0,34.0,43.982297150257104,1.0,7.0,30.5,-4.2
|
||||
20.0,44.0,28.0,36.0,50.26548245743669,1.0,8.0,32.0,-4.8
|
||||
20.0,47.0,29.0,38.0,56.548667764616276,1.0,9.0,33.5,-5.4
|
||||
20.0,50.0,30.0,40.0,62.83185307179586,1.0,10.0,35.0,-6.0
|
||||
20.0,53.0,31.0,42.0,69.11503837897544,1.0,11.0,36.5,-6.6
|
||||
20.0,56.0,32.0,44.0,75.39822368615503,1.0,12.0,38.0,-7.2
|
||||
20.0,59.0,33.0,46.0,81.68140899333461,1.0,13.0,39.5,-7.8
|
||||
21.0,24.0,22.0,23.0,6.283185307179586,1.0,1.0,22.5,-0.6
|
||||
21.0,27.0,23.0,25.0,12.566370614359172,1.0,2.0,24.0,-1.2
|
||||
21.0,30.0,24.0,27.0,18.84955592153876,1.0,3.0,25.5,-1.8
|
||||
21.0,33.0,25.0,29.0,25.132741228718345,1.0,4.0,27.0,-2.4
|
||||
21.0,36.0,26.0,31.0,31.41592653589793,1.0,5.0,28.5,-3.0
|
||||
21.0,39.0,27.0,33.0,37.69911184307752,1.0,6.0,30.0,-3.6
|
||||
21.0,42.0,28.0,35.0,43.982297150257104,1.0,7.0,31.5,-4.2
|
||||
21.0,45.0,29.0,37.0,50.26548245743669,1.0,8.0,33.0,-4.8
|
||||
21.0,48.0,30.0,39.0,56.548667764616276,1.0,9.0,34.5,-5.4
|
||||
21.0,51.0,31.0,41.0,62.83185307179586,1.0,10.0,36.0,-6.0
|
||||
21.0,54.0,32.0,43.0,69.11503837897544,1.0,11.0,37.5,-6.6
|
||||
21.0,57.0,33.0,45.0,75.39822368615503,1.0,12.0,39.0,-7.2
|
||||
21.0,60.0,34.0,47.0,81.68140899333461,1.0,13.0,40.5,-7.8
|
||||
22.0,25.0,23.0,24.0,6.283185307179586,1.0,1.0,23.5,-0.6
|
||||
22.0,28.0,24.0,26.0,12.566370614359172,1.0,2.0,25.0,-1.2
|
||||
22.0,31.0,25.0,28.0,18.84955592153876,1.0,3.0,26.5,-1.8
|
||||
22.0,34.0,26.0,30.0,25.132741228718345,1.0,4.0,28.0,-2.4
|
||||
22.0,37.0,27.0,32.0,31.41592653589793,1.0,5.0,29.5,-3.0
|
||||
22.0,40.0,28.0,34.0,37.69911184307752,1.0,6.0,31.0,-3.6
|
||||
22.0,43.0,29.0,36.0,43.982297150257104,1.0,7.0,32.5,-4.2
|
||||
22.0,46.0,30.0,38.0,50.26548245743669,1.0,8.0,34.0,-4.8
|
||||
22.0,49.0,31.0,40.0,56.548667764616276,1.0,9.0,35.5,-5.4
|
||||
22.0,52.0,32.0,42.0,62.83185307179586,1.0,10.0,37.0,-6.0
|
||||
22.0,55.0,33.0,44.0,69.11503837897544,1.0,11.0,38.5,-6.6
|
||||
22.0,58.0,34.0,46.0,75.39822368615503,1.0,12.0,40.0,-7.2
|
||||
23.0,26.0,24.0,25.0,6.283185307179586,1.0,1.0,24.5,-0.6
|
||||
23.0,29.0,25.0,27.0,12.566370614359172,1.0,2.0,26.0,-1.2
|
||||
23.0,32.0,26.0,29.0,18.84955592153876,1.0,3.0,27.5,-1.8
|
||||
23.0,35.0,27.0,31.0,25.132741228718345,1.0,4.0,29.0,-2.4
|
||||
23.0,38.0,28.0,33.0,31.41592653589793,1.0,5.0,30.5,-3.0
|
||||
23.0,41.0,29.0,35.0,37.69911184307752,1.0,6.0,32.0,-3.6
|
||||
23.0,44.0,30.0,37.0,43.982297150257104,1.0,7.0,33.5,-4.2
|
||||
23.0,47.0,31.0,39.0,50.26548245743669,1.0,8.0,35.0,-4.8
|
||||
23.0,50.0,32.0,41.0,56.548667764616276,1.0,9.0,36.5,-5.4
|
||||
23.0,53.0,33.0,43.0,62.83185307179586,1.0,10.0,38.0,-6.0
|
||||
23.0,56.0,34.0,45.0,69.11503837897544,1.0,11.0,39.5,-6.6
|
||||
23.0,59.0,35.0,47.0,75.39822368615503,1.0,12.0,41.0,-7.2
|
||||
24.0,27.0,25.0,26.0,6.283185307179586,1.0,1.0,25.5,-0.6
|
||||
24.0,30.0,26.0,28.0,12.566370614359172,1.0,2.0,27.0,-1.2
|
||||
24.0,33.0,27.0,30.0,18.84955592153876,1.0,3.0,28.5,-1.8
|
||||
24.0,36.0,28.0,32.0,25.132741228718345,1.0,4.0,30.0,-2.4
|
||||
24.0,39.0,29.0,34.0,31.41592653589793,1.0,5.0,31.5,-3.0
|
||||
24.0,42.0,30.0,36.0,37.69911184307752,1.0,6.0,33.0,-3.6
|
||||
24.0,45.0,31.0,38.0,43.982297150257104,1.0,7.0,34.5,-4.2
|
||||
24.0,48.0,32.0,40.0,50.26548245743669,1.0,8.0,36.0,-4.8
|
||||
24.0,51.0,33.0,42.0,56.548667764616276,1.0,9.0,37.5,-5.4
|
||||
24.0,54.0,34.0,44.0,62.83185307179586,1.0,10.0,39.0,-6.0
|
||||
24.0,57.0,35.0,46.0,69.11503837897544,1.0,11.0,40.5,-6.6
|
||||
24.0,60.0,36.0,48.0,75.39822368615503,1.0,12.0,42.0,-7.2
|
||||
25.0,28.0,26.0,27.0,6.283185307179586,1.0,1.0,26.5,-0.6
|
||||
25.0,31.0,27.0,29.0,12.566370614359172,1.0,2.0,28.0,-1.2
|
||||
25.0,34.0,28.0,31.0,18.84955592153876,1.0,3.0,29.5,-1.8
|
||||
25.0,37.0,29.0,33.0,25.132741228718345,1.0,4.0,31.0,-2.4
|
||||
25.0,40.0,30.0,35.0,31.41592653589793,1.0,5.0,32.5,-3.0
|
||||
25.0,43.0,31.0,37.0,37.69911184307752,1.0,6.0,34.0,-3.6
|
||||
25.0,46.0,32.0,39.0,43.982297150257104,1.0,7.0,35.5,-4.2
|
||||
25.0,49.0,33.0,41.0,50.26548245743669,1.0,8.0,37.0,-4.8
|
||||
25.0,52.0,34.0,43.0,56.548667764616276,1.0,9.0,38.5,-5.4
|
||||
25.0,55.0,35.0,45.0,62.83185307179586,1.0,10.0,40.0,-6.0
|
||||
25.0,58.0,36.0,47.0,69.11503837897544,1.0,11.0,41.5,-6.6
|
||||
26.0,29.0,27.0,28.0,6.283185307179586,1.0,1.0,27.5,-0.6
|
||||
26.0,32.0,28.0,30.0,12.566370614359172,1.0,2.0,29.0,-1.2
|
||||
26.0,35.0,29.0,32.0,18.84955592153876,1.0,3.0,30.5,-1.8
|
||||
26.0,38.0,30.0,34.0,25.132741228718345,1.0,4.0,32.0,-2.4
|
||||
26.0,41.0,31.0,36.0,31.41592653589793,1.0,5.0,33.5,-3.0
|
||||
26.0,44.0,32.0,38.0,37.69911184307752,1.0,6.0,35.0,-3.6
|
||||
26.0,47.0,33.0,40.0,43.982297150257104,1.0,7.0,36.5,-4.2
|
||||
26.0,50.0,34.0,42.0,50.26548245743669,1.0,8.0,38.0,-4.8
|
||||
26.0,53.0,35.0,44.0,56.548667764616276,1.0,9.0,39.5,-5.4
|
||||
26.0,56.0,36.0,46.0,62.83185307179586,1.0,10.0,41.0,-6.0
|
||||
26.0,59.0,37.0,48.0,69.11503837897544,1.0,11.0,42.5,-6.6
|
||||
27.0,30.0,28.0,29.0,6.283185307179586,1.0,1.0,28.5,-0.6
|
||||
27.0,33.0,29.0,31.0,12.566370614359172,1.0,2.0,30.0,-1.2
|
||||
27.0,36.0,30.0,33.0,18.84955592153876,1.0,3.0,31.5,-1.8
|
||||
27.0,39.0,31.0,35.0,25.132741228718345,1.0,4.0,33.0,-2.4
|
||||
27.0,42.0,32.0,37.0,31.41592653589793,1.0,5.0,34.5,-3.0
|
||||
27.0,45.0,33.0,39.0,37.69911184307752,1.0,6.0,36.0,-3.6
|
||||
27.0,48.0,34.0,41.0,43.982297150257104,1.0,7.0,37.5,-4.2
|
||||
27.0,51.0,35.0,43.0,50.26548245743669,1.0,8.0,39.0,-4.8
|
||||
27.0,54.0,36.0,45.0,56.548667764616276,1.0,9.0,40.5,-5.4
|
||||
27.0,57.0,37.0,47.0,62.83185307179586,1.0,10.0,42.0,-6.0
|
||||
27.0,60.0,38.0,49.0,69.11503837897544,1.0,11.0,43.5,-6.6
|
||||
28.0,31.0,29.0,30.0,6.283185307179586,1.0,1.0,29.5,-0.6
|
||||
28.0,34.0,30.0,32.0,12.566370614359172,1.0,2.0,31.0,-1.2
|
||||
28.0,37.0,31.0,34.0,18.84955592153876,1.0,3.0,32.5,-1.8
|
||||
28.0,40.0,32.0,36.0,25.132741228718345,1.0,4.0,34.0,-2.4
|
||||
28.0,43.0,33.0,38.0,31.41592653589793,1.0,5.0,35.5,-3.0
|
||||
28.0,46.0,34.0,40.0,37.69911184307752,1.0,6.0,37.0,-3.6
|
||||
28.0,49.0,35.0,42.0,43.982297150257104,1.0,7.0,38.5,-4.2
|
||||
28.0,52.0,36.0,44.0,50.26548245743669,1.0,8.0,40.0,-4.8
|
||||
28.0,55.0,37.0,46.0,56.548667764616276,1.0,9.0,41.5,-5.4
|
||||
28.0,58.0,38.0,48.0,62.83185307179586,1.0,10.0,43.0,-6.0
|
||||
29.0,32.0,30.0,31.0,6.283185307179586,1.0,1.0,30.5,-0.6
|
||||
29.0,35.0,31.0,33.0,12.566370614359172,1.0,2.0,32.0,-1.2
|
||||
29.0,38.0,32.0,35.0,18.84955592153876,1.0,3.0,33.5,-1.8
|
||||
29.0,41.0,33.0,37.0,25.132741228718345,1.0,4.0,35.0,-2.4
|
||||
29.0,44.0,34.0,39.0,31.41592653589793,1.0,5.0,36.5,-3.0
|
||||
29.0,47.0,35.0,41.0,37.69911184307752,1.0,6.0,38.0,-3.6
|
||||
29.0,50.0,36.0,43.0,43.982297150257104,1.0,7.0,39.5,-4.2
|
||||
29.0,53.0,37.0,45.0,50.26548245743669,1.0,8.0,41.0,-4.8
|
||||
29.0,56.0,38.0,47.0,56.548667764616276,1.0,9.0,42.5,-5.4
|
||||
29.0,59.0,39.0,49.0,62.83185307179586,1.0,10.0,44.0,-6.0
|
||||
30.0,33.0,31.0,32.0,6.283185307179586,1.0,1.0,31.5,-0.6
|
||||
30.0,36.0,32.0,34.0,12.566370614359172,1.0,2.0,33.0,-1.2
|
||||
30.0,39.0,33.0,36.0,18.84955592153876,1.0,3.0,34.5,-1.8
|
||||
30.0,42.0,34.0,38.0,25.132741228718345,1.0,4.0,36.0,-2.4
|
||||
30.0,45.0,35.0,40.0,31.41592653589793,1.0,5.0,37.5,-3.0
|
||||
30.0,48.0,36.0,42.0,37.69911184307752,1.0,6.0,39.0,-3.6
|
||||
30.0,51.0,37.0,44.0,43.982297150257104,1.0,7.0,40.5,-4.2
|
||||
30.0,54.0,38.0,46.0,50.26548245743669,1.0,8.0,42.0,-4.8
|
||||
30.0,57.0,39.0,48.0,56.548667764616276,1.0,9.0,43.5,-5.4
|
||||
30.0,60.0,40.0,50.0,62.83185307179586,1.0,10.0,45.0,-6.0
|
||||
31.0,34.0,32.0,33.0,6.283185307179586,1.0,1.0,32.5,-0.6
|
||||
31.0,37.0,33.0,35.0,12.566370614359172,1.0,2.0,34.0,-1.2
|
||||
31.0,40.0,34.0,37.0,18.84955592153876,1.0,3.0,35.5,-1.8
|
||||
31.0,43.0,35.0,39.0,25.132741228718345,1.0,4.0,37.0,-2.4
|
||||
31.0,46.0,36.0,41.0,31.41592653589793,1.0,5.0,38.5,-3.0
|
||||
31.0,49.0,37.0,43.0,37.69911184307752,1.0,6.0,40.0,-3.6
|
||||
31.0,52.0,38.0,45.0,43.982297150257104,1.0,7.0,41.5,-4.2
|
||||
31.0,55.0,39.0,47.0,50.26548245743669,1.0,8.0,43.0,-4.8
|
||||
31.0,58.0,40.0,49.0,56.548667764616276,1.0,9.0,44.5,-5.4
|
||||
32.0,35.0,33.0,34.0,6.283185307179586,1.0,1.0,33.5,-0.6
|
||||
32.0,38.0,34.0,36.0,12.566370614359172,1.0,2.0,35.0,-1.2
|
||||
32.0,41.0,35.0,38.0,18.84955592153876,1.0,3.0,36.5,-1.8
|
||||
32.0,44.0,36.0,40.0,25.132741228718345,1.0,4.0,38.0,-2.4
|
||||
32.0,47.0,37.0,42.0,31.41592653589793,1.0,5.0,39.5,-3.0
|
||||
32.0,50.0,38.0,44.0,37.69911184307752,1.0,6.0,41.0,-3.6
|
||||
32.0,53.0,39.0,46.0,43.982297150257104,1.0,7.0,42.5,-4.2
|
||||
32.0,56.0,40.0,48.0,50.26548245743669,1.0,8.0,44.0,-4.8
|
||||
32.0,59.0,41.0,50.0,56.548667764616276,1.0,9.0,45.5,-5.4
|
||||
33.0,36.0,34.0,35.0,6.283185307179586,1.0,1.0,34.5,-0.6
|
||||
33.0,39.0,35.0,37.0,12.566370614359172,1.0,2.0,36.0,-1.2
|
||||
33.0,42.0,36.0,39.0,18.84955592153876,1.0,3.0,37.5,-1.8
|
||||
33.0,45.0,37.0,41.0,25.132741228718345,1.0,4.0,39.0,-2.4
|
||||
33.0,48.0,38.0,43.0,31.41592653589793,1.0,5.0,40.5,-3.0
|
||||
33.0,51.0,39.0,45.0,37.69911184307752,1.0,6.0,42.0,-3.6
|
||||
33.0,54.0,40.0,47.0,43.982297150257104,1.0,7.0,43.5,-4.2
|
||||
33.0,57.0,41.0,49.0,50.26548245743669,1.0,8.0,45.0,-4.8
|
||||
33.0,60.0,42.0,51.0,56.548667764616276,1.0,9.0,46.5,-5.4
|
||||
34.0,37.0,35.0,36.0,6.283185307179586,1.0,1.0,35.5,-0.6
|
||||
34.0,40.0,36.0,38.0,12.566370614359172,1.0,2.0,37.0,-1.2
|
||||
34.0,43.0,37.0,40.0,18.84955592153876,1.0,3.0,38.5,-1.8
|
||||
34.0,46.0,38.0,42.0,25.132741228718345,1.0,4.0,40.0,-2.4
|
||||
34.0,49.0,39.0,44.0,31.41592653589793,1.0,5.0,41.5,-3.0
|
||||
34.0,52.0,40.0,46.0,37.69911184307752,1.0,6.0,43.0,-3.6
|
||||
34.0,55.0,41.0,48.0,43.982297150257104,1.0,7.0,44.5,-4.2
|
||||
34.0,58.0,42.0,50.0,50.26548245743669,1.0,8.0,46.0,-4.8
|
||||
35.0,38.0,36.0,37.0,6.283185307179586,1.0,1.0,36.5,-0.6
|
||||
35.0,41.0,37.0,39.0,12.566370614359172,1.0,2.0,38.0,-1.2
|
||||
35.0,44.0,38.0,41.0,18.84955592153876,1.0,3.0,39.5,-1.8
|
||||
35.0,47.0,39.0,43.0,25.132741228718345,1.0,4.0,41.0,-2.4
|
||||
35.0,50.0,40.0,45.0,31.41592653589793,1.0,5.0,42.5,-3.0
|
||||
35.0,53.0,41.0,47.0,37.69911184307752,1.0,6.0,44.0,-3.6
|
||||
35.0,56.0,42.0,49.0,43.982297150257104,1.0,7.0,45.5,-4.2
|
||||
35.0,59.0,43.0,51.0,50.26548245743669,1.0,8.0,47.0,-4.8
|
||||
36.0,39.0,37.0,38.0,6.283185307179586,1.0,1.0,37.5,-0.6
|
||||
36.0,42.0,38.0,40.0,12.566370614359172,1.0,2.0,39.0,-1.2
|
||||
36.0,45.0,39.0,42.0,18.84955592153876,1.0,3.0,40.5,-1.8
|
||||
36.0,48.0,40.0,44.0,25.132741228718345,1.0,4.0,42.0,-2.4
|
||||
36.0,51.0,41.0,46.0,31.41592653589793,1.0,5.0,43.5,-3.0
|
||||
36.0,54.0,42.0,48.0,37.69911184307752,1.0,6.0,45.0,-3.6
|
||||
36.0,57.0,43.0,50.0,43.982297150257104,1.0,7.0,46.5,-4.2
|
||||
36.0,60.0,44.0,52.0,50.26548245743669,1.0,8.0,48.0,-4.8
|
||||
37.0,40.0,38.0,39.0,6.283185307179586,1.0,1.0,38.5,-0.6
|
||||
37.0,43.0,39.0,41.0,12.566370614359172,1.0,2.0,40.0,-1.2
|
||||
37.0,46.0,40.0,43.0,18.84955592153876,1.0,3.0,41.5,-1.8
|
||||
37.0,49.0,41.0,45.0,25.132741228718345,1.0,4.0,43.0,-2.4
|
||||
37.0,52.0,42.0,47.0,31.41592653589793,1.0,5.0,44.5,-3.0
|
||||
37.0,55.0,43.0,49.0,37.69911184307752,1.0,6.0,46.0,-3.6
|
||||
37.0,58.0,44.0,51.0,43.982297150257104,1.0,7.0,47.5,-4.2
|
||||
38.0,41.0,39.0,40.0,6.283185307179586,1.0,1.0,39.5,-0.6
|
||||
38.0,44.0,40.0,42.0,12.566370614359172,1.0,2.0,41.0,-1.2
|
||||
38.0,47.0,41.0,44.0,18.84955592153876,1.0,3.0,42.5,-1.8
|
||||
38.0,50.0,42.0,46.0,25.132741228718345,1.0,4.0,44.0,-2.4
|
||||
38.0,53.0,43.0,48.0,31.41592653589793,1.0,5.0,45.5,-3.0
|
||||
38.0,56.0,44.0,50.0,37.69911184307752,1.0,6.0,47.0,-3.6
|
||||
38.0,59.0,45.0,52.0,43.982297150257104,1.0,7.0,48.5,-4.2
|
||||
39.0,42.0,40.0,41.0,6.283185307179586,1.0,1.0,40.5,-0.6
|
||||
39.0,45.0,41.0,43.0,12.566370614359172,1.0,2.0,42.0,-1.2
|
||||
39.0,48.0,42.0,45.0,18.84955592153876,1.0,3.0,43.5,-1.8
|
||||
39.0,51.0,43.0,47.0,25.132741228718345,1.0,4.0,45.0,-2.4
|
||||
39.0,54.0,44.0,49.0,31.41592653589793,1.0,5.0,46.5,-3.0
|
||||
39.0,57.0,45.0,51.0,37.69911184307752,1.0,6.0,48.0,-3.6
|
||||
39.0,60.0,46.0,53.0,43.982297150257104,1.0,7.0,49.5,-4.2
|
||||
40.0,43.0,41.0,42.0,6.283185307179586,1.0,1.0,41.5,-0.6
|
||||
40.0,46.0,42.0,44.0,12.566370614359172,1.0,2.0,43.0,-1.2
|
||||
40.0,49.0,43.0,46.0,18.84955592153876,1.0,3.0,44.5,-1.8
|
||||
40.0,52.0,44.0,48.0,25.132741228718345,1.0,4.0,46.0,-2.4
|
||||
40.0,55.0,45.0,50.0,31.41592653589793,1.0,5.0,47.5,-3.0
|
||||
40.0,58.0,46.0,52.0,37.69911184307752,1.0,6.0,49.0,-3.6
|
||||
41.0,44.0,42.0,43.0,6.283185307179586,1.0,1.0,42.5,-0.6
|
||||
41.0,47.0,43.0,45.0,12.566370614359172,1.0,2.0,44.0,-1.2
|
||||
41.0,50.0,44.0,47.0,18.84955592153876,1.0,3.0,45.5,-1.8
|
||||
41.0,53.0,45.0,49.0,25.132741228718345,1.0,4.0,47.0,-2.4
|
||||
41.0,56.0,46.0,51.0,31.41592653589793,1.0,5.0,48.5,-3.0
|
||||
41.0,59.0,47.0,53.0,37.69911184307752,1.0,6.0,50.0,-3.6
|
||||
42.0,45.0,43.0,44.0,6.283185307179586,1.0,1.0,43.5,-0.6
|
||||
42.0,48.0,44.0,46.0,12.566370614359172,1.0,2.0,45.0,-1.2
|
||||
42.0,51.0,45.0,48.0,18.84955592153876,1.0,3.0,46.5,-1.8
|
||||
42.0,54.0,46.0,50.0,25.132741228718345,1.0,4.0,48.0,-2.4
|
||||
42.0,57.0,47.0,52.0,31.41592653589793,1.0,5.0,49.5,-3.0
|
||||
42.0,60.0,48.0,54.0,37.69911184307752,1.0,6.0,51.0,-3.6
|
||||
43.0,46.0,44.0,45.0,6.283185307179586,1.0,1.0,44.5,-0.6
|
||||
43.0,49.0,45.0,47.0,12.566370614359172,1.0,2.0,46.0,-1.2
|
||||
43.0,52.0,46.0,49.0,18.84955592153876,1.0,3.0,47.5,-1.8
|
||||
43.0,55.0,47.0,51.0,25.132741228718345,1.0,4.0,49.0,-2.4
|
||||
43.0,58.0,48.0,53.0,31.41592653589793,1.0,5.0,50.5,-3.0
|
||||
44.0,47.0,45.0,46.0,6.283185307179586,1.0,1.0,45.5,-0.6
|
||||
44.0,50.0,46.0,48.0,12.566370614359172,1.0,2.0,47.0,-1.2
|
||||
44.0,53.0,47.0,50.0,18.84955592153876,1.0,3.0,48.5,-1.8
|
||||
44.0,56.0,48.0,52.0,25.132741228718345,1.0,4.0,50.0,-2.4
|
||||
44.0,59.0,49.0,54.0,31.41592653589793,1.0,5.0,51.5,-3.0
|
||||
45.0,48.0,46.0,47.0,6.283185307179586,1.0,1.0,46.5,-0.6
|
||||
45.0,51.0,47.0,49.0,12.566370614359172,1.0,2.0,48.0,-1.2
|
||||
45.0,54.0,48.0,51.0,18.84955592153876,1.0,3.0,49.5,-1.8
|
||||
45.0,57.0,49.0,53.0,25.132741228718345,1.0,4.0,51.0,-2.4
|
||||
45.0,60.0,50.0,55.0,31.41592653589793,1.0,5.0,52.5,-3.0
|
||||
46.0,49.0,47.0,48.0,6.283185307179586,1.0,1.0,47.5,-0.6
|
||||
46.0,52.0,48.0,50.0,12.566370614359172,1.0,2.0,49.0,-1.2
|
||||
46.0,55.0,49.0,52.0,18.84955592153876,1.0,3.0,50.5,-1.8
|
||||
46.0,58.0,50.0,54.0,25.132741228718345,1.0,4.0,52.0,-2.4
|
||||
47.0,50.0,48.0,49.0,6.283185307179586,1.0,1.0,48.5,-0.6
|
||||
47.0,53.0,49.0,51.0,12.566370614359172,1.0,2.0,50.0,-1.2
|
||||
47.0,56.0,50.0,53.0,18.84955592153876,1.0,3.0,51.5,-1.8
|
||||
47.0,59.0,51.0,55.0,25.132741228718345,1.0,4.0,53.0,-2.4
|
||||
48.0,51.0,49.0,50.0,6.283185307179586,1.0,1.0,49.5,-0.6
|
||||
48.0,54.0,50.0,52.0,12.566370614359172,1.0,2.0,51.0,-1.2
|
||||
48.0,57.0,51.0,54.0,18.84955592153876,1.0,3.0,52.5,-1.8
|
||||
48.0,60.0,52.0,56.0,25.132741228718345,1.0,4.0,54.0,-2.4
|
||||
49.0,52.0,50.0,51.0,6.283185307179586,1.0,1.0,50.5,-0.6
|
||||
49.0,55.0,51.0,53.0,12.566370614359172,1.0,2.0,52.0,-1.2
|
||||
49.0,58.0,52.0,55.0,18.84955592153876,1.0,3.0,53.5,-1.8
|
||||
50.0,53.0,51.0,52.0,6.283185307179586,1.0,1.0,51.5,-0.6
|
||||
50.0,56.0,52.0,54.0,12.566370614359172,1.0,2.0,53.0,-1.2
|
||||
50.0,59.0,53.0,56.0,18.84955592153876,1.0,3.0,54.5,-1.8
|
||||
51.0,54.0,52.0,53.0,6.283185307179586,1.0,1.0,52.5,-0.6
|
||||
51.0,57.0,53.0,55.0,12.566370614359172,1.0,2.0,54.0,-1.2
|
||||
51.0,60.0,54.0,57.0,18.84955592153876,1.0,3.0,55.5,-1.8
|
||||
52.0,55.0,53.0,54.0,6.283185307179586,1.0,1.0,53.5,-0.6
|
||||
52.0,58.0,54.0,56.0,12.566370614359172,1.0,2.0,55.0,-1.2
|
||||
53.0,56.0,54.0,55.0,6.283185307179586,1.0,1.0,54.5,-0.6
|
||||
53.0,59.0,55.0,57.0,12.566370614359172,1.0,2.0,56.0,-1.2
|
||||
54.0,57.0,55.0,56.0,6.283185307179586,1.0,1.0,55.5,-0.6
|
||||
54.0,60.0,56.0,58.0,12.566370614359172,1.0,2.0,57.0,-1.2
|
||||
55.0,58.0,56.0,57.0,6.283185307179586,1.0,1.0,56.5,-0.6
|
||||
56.0,59.0,57.0,58.0,6.283185307179586,1.0,1.0,57.5,-0.6
|
||||
57.0,60.0,58.0,59.0,6.283185307179586,1.0,1.0,58.5,-0.6
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -25,6 +25,7 @@
|
|||
"@types/node": "^25.5.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"prisma": "^7.8.0",
|
||||
"tailwindcss": "4.1.12",
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
// This file was generated by Prisma, and assumes you have installed the following:
|
||||
// npm install --save-dev prisma dotenv
|
||||
import "dotenv/config";
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: process.env["DATABASE_URL"],
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
}
|
||||
|
||||
model devices {
|
||||
sn String @id
|
||||
status String @default("待激活")
|
||||
activated_at String?
|
||||
created_at String @default("datetime('now', 'localtime')")
|
||||
}
|
||||
|
|
@ -0,0 +1,279 @@
|
|||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model app_platform_versions {
|
||||
id Int @id @default(autoincrement())
|
||||
app_id Int
|
||||
platform_id Int
|
||||
major_version Int @default(0)
|
||||
minor_version Int @default(0)
|
||||
patch_version Int @default(0)
|
||||
version_name String
|
||||
description String? @default("")
|
||||
file_type String? @default("")
|
||||
file_url String? @default("")
|
||||
file_size Int? @default(0)
|
||||
distribution_type String? @default("")
|
||||
primary_url String? @default("")
|
||||
fallback_url String? @default("")
|
||||
url_expire_time String? @default("")
|
||||
signature_info String? @default("")
|
||||
min_support_version String? @default("")
|
||||
os_min_version String? @default("")
|
||||
status Int @default(1)
|
||||
is_force_update Int @default(0)
|
||||
release_date String
|
||||
changelog String? @default("[]")
|
||||
app_platforms app_platforms @relation(fields: [platform_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||
applications applications @relation(fields: [app_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||
}
|
||||
|
||||
model app_platforms {
|
||||
id Int @id @default(autoincrement())
|
||||
app_id Int
|
||||
platform_type Int @default(2)
|
||||
description String? @default("")
|
||||
extend_info String? @default("")
|
||||
create_time String
|
||||
app_platform_versions app_platform_versions[]
|
||||
applications applications @relation(fields: [app_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||
}
|
||||
|
||||
model app_statistics {
|
||||
id Int @id @default(autoincrement())
|
||||
app_id Int
|
||||
version_id Int?
|
||||
date String
|
||||
download_count Int? @default(0)
|
||||
install_count Int? @default(0)
|
||||
active_count Int? @default(0)
|
||||
crash_count Int? @default(0)
|
||||
create_time String
|
||||
applications applications @relation(fields: [app_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||
}
|
||||
|
||||
model applications {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
package_name String? @default("")
|
||||
description String? @default("")
|
||||
logo_url String? @default("")
|
||||
status Int @default(1)
|
||||
create_time String
|
||||
update_time String
|
||||
app_platform_versions app_platform_versions[]
|
||||
app_platforms app_platforms[]
|
||||
app_statistics app_statistics[]
|
||||
}
|
||||
|
||||
model board_types {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
category String
|
||||
device_models String @default("[]")
|
||||
description String? @default("")
|
||||
status String @default("启用")
|
||||
}
|
||||
|
||||
model board_versions {
|
||||
id Int @id @default(autoincrement())
|
||||
type String
|
||||
version String
|
||||
status String @default("在产")
|
||||
}
|
||||
|
||||
model bom_templates {
|
||||
id Int @id @default(autoincrement())
|
||||
model_code String
|
||||
name String
|
||||
material_name String @default("")
|
||||
model String
|
||||
versions String @default("[]")
|
||||
qty Int @default(1)
|
||||
required Int @default(1)
|
||||
need_calibration Int @default(0)
|
||||
enforce_version_match Int @default(0)
|
||||
device_models device_models @relation(fields: [model_code], references: [code], onDelete: NoAction, onUpdate: NoAction)
|
||||
}
|
||||
|
||||
model checklist_templates {
|
||||
id Int @id @default(autoincrement())
|
||||
model_code String
|
||||
name String
|
||||
required Int @default(1)
|
||||
sort_order Int @default(0)
|
||||
device_models device_models @relation(fields: [model_code], references: [code], onDelete: NoAction, onUpdate: NoAction)
|
||||
}
|
||||
|
||||
model config_files {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
model String
|
||||
version String
|
||||
create_time String
|
||||
status String @default("生效")
|
||||
voltage String? @default("")
|
||||
current String? @default("")
|
||||
duty_cycle String? @default("")
|
||||
pulse_width String? @default("")
|
||||
iterations String? @default("")
|
||||
channels Int? @default(0)
|
||||
sample_rate String? @default("")
|
||||
voltage_range String? @default("")
|
||||
waveform String? @default("")
|
||||
ssid String? @default("")
|
||||
license_templates license_templates[]
|
||||
}
|
||||
|
||||
model device_bom_records {
|
||||
id Int @id @default(autoincrement())
|
||||
device_sn String
|
||||
name String
|
||||
material_sn String? @default("")
|
||||
model String? @default("")
|
||||
version String? @default("")
|
||||
calibration String? @default("无需校准")
|
||||
}
|
||||
|
||||
model device_logs {
|
||||
id Int @id @default(autoincrement())
|
||||
device_sn String
|
||||
action String
|
||||
operator String? @default("")
|
||||
detail String? @default("")
|
||||
date String
|
||||
}
|
||||
|
||||
model device_models {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
code String @unique(map: "sqlite_autoindex_device_models_1")
|
||||
status String @default("在产")
|
||||
description String? @default("")
|
||||
create_date String
|
||||
bom_templates bom_templates[]
|
||||
checklist_templates checklist_templates[]
|
||||
}
|
||||
|
||||
model devices {
|
||||
id Int @id @default(autoincrement())
|
||||
sn String @unique(map: "sqlite_autoindex_devices_1")
|
||||
model String
|
||||
type String
|
||||
status String @default("装配中")
|
||||
firmware String? @default("")
|
||||
production_date String
|
||||
customer String? @default("-")
|
||||
batch String? @default("")
|
||||
}
|
||||
|
||||
model firmware {
|
||||
id Int @id @default(autoincrement())
|
||||
version String
|
||||
board_version String? @default("-")
|
||||
type String
|
||||
date String
|
||||
status String @default("草稿")
|
||||
size String? @default("")
|
||||
downloads Int? @default(0)
|
||||
hw_range String? @default("")
|
||||
upgrade_type String? @default("可选")
|
||||
signed Int? @default(0)
|
||||
md5 String? @default("")
|
||||
sha256 String? @default("")
|
||||
notes String? @default("[]")
|
||||
model_code String? @default("")
|
||||
}
|
||||
|
||||
model license_download_logs {
|
||||
id Int @id @default(autoincrement())
|
||||
license_id Int
|
||||
device_sn String
|
||||
download_time String
|
||||
ip_address String? @default("")
|
||||
app_version String? @default("")
|
||||
licenses licenses @relation(fields: [license_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||
}
|
||||
|
||||
model license_templates {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
model_code String
|
||||
auth_items String @default("[]")
|
||||
config_id Int?
|
||||
status String @default("启用")
|
||||
created_at String @default("datetime('now')")
|
||||
config_files config_files? @relation(fields: [config_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||
}
|
||||
|
||||
model licenses {
|
||||
id Int @id @default(autoincrement())
|
||||
model String
|
||||
modules String
|
||||
expiry String? @default("")
|
||||
status String @default("生效")
|
||||
config_id Int?
|
||||
license_file String? @default("")
|
||||
device_sn String? @default("")
|
||||
license_download_logs license_download_logs[]
|
||||
}
|
||||
|
||||
model material_categories {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique(map: "sqlite_autoindex_material_categories_1")
|
||||
description String? @default("")
|
||||
has_firmware Int @default(0)
|
||||
has_calibration Int @default(0)
|
||||
sort_order Int @default(0)
|
||||
status String @default("启用")
|
||||
}
|
||||
|
||||
model materials {
|
||||
id Int @id @default(autoincrement())
|
||||
sn String @unique(map: "sqlite_autoindex_materials_1")
|
||||
name String @default("")
|
||||
category String @default("")
|
||||
type String
|
||||
device_model String @default("")
|
||||
version String
|
||||
description String? @default("")
|
||||
firmware String? @default("-")
|
||||
status String @default("在库")
|
||||
device_sn String? @default("-")
|
||||
production_date String
|
||||
calib_status String? @default("-")
|
||||
calib_date String? @default("-")
|
||||
}
|
||||
|
||||
/// The underlying table does not contain a valid unique identifier and can therefore currently not be handled by Prisma Client.
|
||||
model repair_orders {
|
||||
id String? @id
|
||||
sn String
|
||||
fault_type String
|
||||
status String @default("待处理")
|
||||
priority String @default("中")
|
||||
assignee String? @default("")
|
||||
create_date String
|
||||
description String? @default("")
|
||||
|
||||
@@ignore
|
||||
}
|
||||
|
||||
model scrap_records {
|
||||
id Int @id @default(autoincrement())
|
||||
sn String
|
||||
model String
|
||||
reason String
|
||||
applicant String? @default("")
|
||||
status String @default("待审批")
|
||||
order_id String? @default("")
|
||||
date String
|
||||
value Int? @default(0)
|
||||
materials String? @default("[]")
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,80 @@
|
|||
"""
|
||||
database.py
|
||||
数据库模块:负责 SQLite 连接、表初始化和基础 CRUD 操作。
|
||||
使用 Python 内置 sqlite3,无需额外 ORM,保持轻量。
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
|
||||
# 数据库文件路径(放在当前目录下,轻量本地运行)
|
||||
DB_PATH = os.path.join(os.path.dirname(__file__), "device_platform.db")
|
||||
|
||||
|
||||
def get_db_connection() -> sqlite3.Connection:
|
||||
"""
|
||||
创建并返回一个 SQLite 连接对象。
|
||||
设置 row_factory 为 sqlite3.Row,使查询结果可以通过列名访问。
|
||||
"""
|
||||
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_db():
|
||||
"""
|
||||
上下文管理器,用于在 API 接口中安全获取和释放数据库连接。
|
||||
用法:
|
||||
with get_db() as db:
|
||||
db.execute(...)
|
||||
"""
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
"""
|
||||
初始化数据库:创建设备表(如果不存在)。
|
||||
设备表字段:
|
||||
- sn: 设备序列号,主键,唯一标识一台设备
|
||||
- status: 设备状态(待激活 / 已激活 / 已禁用)
|
||||
- activated_at: 激活时间,为空表示尚未激活
|
||||
- created_at: 记录创建时间
|
||||
"""
|
||||
with get_db() as db:
|
||||
db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
sn TEXT PRIMARY KEY NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT '待激活',
|
||||
activated_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
)
|
||||
""")
|
||||
db.commit()
|
||||
print(f"[DB] 数据库已初始化: {DB_PATH}")
|
||||
|
||||
|
||||
def seed_demo_data():
|
||||
"""
|
||||
插入演示数据,方便新手直接体验接口。
|
||||
如果已存在相同 SN,则忽略(INSERT OR IGNORE)。
|
||||
"""
|
||||
demo_devices = [
|
||||
("GD30-20260507-001", "待激活"),
|
||||
("GD30-20260507-002", "待激活"),
|
||||
("MT-20260507-003", "已禁用"),
|
||||
]
|
||||
with get_db() as db:
|
||||
for sn, status in demo_devices:
|
||||
db.execute(
|
||||
"INSERT OR IGNORE INTO devices (sn, status) VALUES (?, ?)",
|
||||
(sn, status)
|
||||
)
|
||||
db.commit()
|
||||
print("[DB] 演示数据已插入")
|
||||
Binary file not shown.
|
|
@ -0,0 +1,269 @@
|
|||
"""
|
||||
main.py
|
||||
FastAPI 主应用:设备管理平台后端服务。
|
||||
|
||||
启动方式:
|
||||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
访问文档:
|
||||
http://localhost:8000/docs (Swagger UI)
|
||||
http://localhost:8000/redoc (ReDoc)
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Depends
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import RedirectResponse
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from database import init_db, seed_demo_data, get_db
|
||||
from models import (
|
||||
DeviceCheckRequest,
|
||||
LicenseGenerateRequest,
|
||||
ActivateReportRequest,
|
||||
CheckResponse,
|
||||
LicenseResponse,
|
||||
ActivateResponse,
|
||||
DeviceInfo,
|
||||
)
|
||||
from services import build_license_data, encrypt_license, decrypt_license
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 生命周期:启动时初始化数据库
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用启动时自动创建表并插入演示数据"""
|
||||
init_db()
|
||||
seed_demo_data()
|
||||
yield
|
||||
# 关闭时可做清理(此处无需)
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="设备管理平台 API",
|
||||
description="基于 FastAPI + SQLite 的轻量级设备管理后端,支持设备校验、授权文件生成、激活上报。",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# 允许跨域(方便前端/APP 调试)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 根路由:自动跳转到 Swagger 文档
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
def root():
|
||||
return RedirectResponse(url="/docs")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 1. 设备校验接口
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@app.post("/api/devices/check", response_model=CheckResponse, summary="设备校验")
|
||||
def device_check(payload: DeviceCheckRequest):
|
||||
"""
|
||||
APP 启动时调用,校验设备 SN 是否合法、是否已激活。
|
||||
|
||||
- 若 SN 不存在 → 非法设备,拒绝激活
|
||||
- 若 SN 存在但未激活 → 允许进入激活流程
|
||||
- 若 SN 已激活 → 正常使用
|
||||
"""
|
||||
with get_db() as db:
|
||||
row = db.execute(
|
||||
"SELECT status FROM devices WHERE sn = ?", (payload.sn,)
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
return CheckResponse(
|
||||
valid=False,
|
||||
activated=False,
|
||||
message="设备 SN 不存在,请联系管理员注册"
|
||||
)
|
||||
|
||||
status = row["status"]
|
||||
if status == "已激活":
|
||||
return CheckResponse(
|
||||
valid=True,
|
||||
activated=True,
|
||||
message="设备已激活,正常使用"
|
||||
)
|
||||
elif status == "已禁用":
|
||||
return CheckResponse(
|
||||
valid=True,
|
||||
activated=False,
|
||||
message="设备已被禁用,请联系客服"
|
||||
)
|
||||
else:
|
||||
return CheckResponse(
|
||||
valid=True,
|
||||
activated=False,
|
||||
message="设备待激活,请获取授权文件并激活"
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 2. 生成加密授权文件接口
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@app.post("/api/licenses/generate", response_model=LicenseResponse, summary="生成加密授权文件")
|
||||
def generate_license(payload: LicenseGenerateRequest):
|
||||
"""
|
||||
管理后台调用,为指定设备生成绑定 SN 的加密授权文件。
|
||||
|
||||
流程:
|
||||
1. 校验设备 SN 是否存在
|
||||
2. 构造授权数据(模块列表 + 有效期)
|
||||
3. XOR 加密(密钥由 SN 派生,一机一密)
|
||||
4. 返回 Base64 加密字符串
|
||||
|
||||
APP 收到后必须用相同 SN 才能解密。
|
||||
"""
|
||||
with get_db() as db:
|
||||
row = db.execute(
|
||||
"SELECT sn FROM devices WHERE sn = ?", (payload.sn,)
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="设备 SN 不存在")
|
||||
|
||||
# 构造明文授权数据
|
||||
license_data = build_license_data(
|
||||
device_sn=payload.sn,
|
||||
modules=payload.modules,
|
||||
valid_days=payload.valid_days,
|
||||
)
|
||||
|
||||
# 加密(绑定 SN)
|
||||
encrypted = encrypt_license(license_data)
|
||||
|
||||
return LicenseResponse(
|
||||
success=True,
|
||||
encrypted_license=encrypted,
|
||||
raw_license=license_data.model_dump(), # 调试用,生产环境可去掉
|
||||
message="授权文件生成成功,已绑定设备 SN"
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 3. 上报激活状态接口
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@app.post("/api/devices/activate", response_model=ActivateResponse, summary="上报激活状态")
|
||||
def report_activation(payload: ActivateReportRequest):
|
||||
"""
|
||||
设备主机首次联网后调用,上报激活结果。
|
||||
|
||||
- 若上报 "已激活" → 数据库更新状态与激活时间
|
||||
- 若上报 "激活失败" → 状态保持原样,记录失败
|
||||
"""
|
||||
with get_db() as db:
|
||||
row = db.execute(
|
||||
"SELECT status FROM devices WHERE sn = ?", (payload.sn,)
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="设备 SN 不存在")
|
||||
|
||||
current_status = row["status"]
|
||||
|
||||
# 只有"待激活"或"已禁用"的设备允许重新激活
|
||||
if payload.status == "已激活":
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE devices
|
||||
SET status = '已激活',
|
||||
activated_at = datetime('now', 'localtime')
|
||||
WHERE sn = ?
|
||||
""",
|
||||
(payload.sn,),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# 查询更新后的时间
|
||||
updated = db.execute(
|
||||
"SELECT activated_at FROM devices WHERE sn = ?", (payload.sn,)
|
||||
).fetchone()
|
||||
|
||||
return ActivateResponse(
|
||||
success=True,
|
||||
sn=payload.sn,
|
||||
new_status="已激活",
|
||||
activated_at=updated["activated_at"],
|
||||
message="设备激活成功,已记录激活时间"
|
||||
)
|
||||
else:
|
||||
return ActivateResponse(
|
||||
success=False,
|
||||
sn=payload.sn,
|
||||
new_status=current_status,
|
||||
message="激活失败,未更新状态"
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 辅助接口(方便调试和管理)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@app.get("/api/devices", summary="获取所有设备列表")
|
||||
def list_devices():
|
||||
"""列出当前所有设备及其状态"""
|
||||
with get_db() as db:
|
||||
rows = db.execute(
|
||||
"SELECT sn, status, activated_at, created_at FROM devices ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@app.post("/api/devices/register", summary="注册新设备")
|
||||
def register_device(sn: str):
|
||||
"""向平台注册一个新的设备 SN"""
|
||||
with get_db() as db:
|
||||
try:
|
||||
db.execute(
|
||||
"INSERT INTO devices (sn, status) VALUES (?, '待激活')",
|
||||
(sn,),
|
||||
)
|
||||
db.commit()
|
||||
return {"success": True, "sn": sn, "message": "设备注册成功"}
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="设备 SN 已存在")
|
||||
|
||||
|
||||
@app.post("/api/licenses/verify", summary="验证授权文件")
|
||||
def verify_license(sn: str, encrypted_license: str):
|
||||
"""
|
||||
调试验证:传入加密授权文件和设备 SN,验证能否正确解密。
|
||||
用于测试一机一密绑定是否生效。
|
||||
"""
|
||||
decrypted = decrypt_license(encrypted_license, sn)
|
||||
if decrypted:
|
||||
return {
|
||||
"valid": True,
|
||||
"decrypted": decrypted,
|
||||
"message": "授权文件解密成功,SN 匹配"
|
||||
}
|
||||
return {
|
||||
"valid": False,
|
||||
"decrypted": None,
|
||||
"message": "授权文件无效或 SN 不匹配"
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 运行入口(直接 python main.py 启动)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("main:app", host="localhost", port=8000, reload=True)
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
"""
|
||||
models.py
|
||||
数据模型模块:使用 Pydantic 定义请求/响应的数据结构。
|
||||
FastAPI 会自动用这些模型做参数校验和文档生成。
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 请求模型(客户端传入)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class DeviceCheckRequest(BaseModel):
|
||||
"""设备校验请求:APP 启动时携带 SN 进行合法性校验"""
|
||||
sn: str = Field(..., min_length=5, description="设备序列号,如 GD30-20260507-001")
|
||||
|
||||
|
||||
class LicenseGenerateRequest(BaseModel):
|
||||
"""生成授权文件请求:管理后台为某台设备创建授权"""
|
||||
sn: str = Field(..., description="目标设备 SN")
|
||||
modules: List[str] = Field(default=[], description="激活的模块列表,如 ['1D', '2D']")
|
||||
valid_days: int = Field(default=365, ge=1, le=3650, description="授权有效期(天),默认一年")
|
||||
|
||||
|
||||
class ActivateReportRequest(BaseModel):
|
||||
"""上报激活状态请求:设备主机首次联网后回传激活结果"""
|
||||
sn: str = Field(..., description="设备序列号")
|
||||
status: str = Field(..., pattern="^(已激活|激活失败)$", description="上报状态:已激活 或 激活失败")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 响应模型(服务端返回)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class DeviceInfo(BaseModel):
|
||||
"""设备信息响应"""
|
||||
sn: str
|
||||
status: str
|
||||
activated_at: Optional[str] = None
|
||||
created_at: str
|
||||
|
||||
|
||||
class CheckResponse(BaseModel):
|
||||
"""设备校验响应:告诉 APP 这台设备是否合法、是否已激活"""
|
||||
valid: bool = Field(..., description="SN 是否存在于平台")
|
||||
activated: bool = Field(..., description="是否已激活")
|
||||
message: str = Field(..., description="给 APP 的提示信息")
|
||||
|
||||
|
||||
class LicenseData(BaseModel):
|
||||
"""授权文件内的业务数据结构(未加密前)"""
|
||||
version: str = Field(default="1.0", description="授权文件版本")
|
||||
device_sn: str = Field(..., description="绑定的设备 SN")
|
||||
generated_at: str = Field(..., description="生成时间 ISO8601")
|
||||
valid_until: str = Field(..., description="有效期截止时间")
|
||||
modules: List[str] = Field(default=[], description="授权模块列表")
|
||||
status: str = Field(default="active", description="授权状态")
|
||||
|
||||
|
||||
class LicenseResponse(BaseModel):
|
||||
"""生成授权文件响应:返回加密后的授权文件字符串"""
|
||||
success: bool
|
||||
encrypted_license: Optional[str] = Field(None, description="Base64 编码的加密授权文件")
|
||||
raw_license: Optional[dict] = Field(None, description="明文授权文件(仅调试用,生产环境可去掉)")
|
||||
message: str
|
||||
|
||||
|
||||
class ActivateResponse(BaseModel):
|
||||
"""上报激活状态响应"""
|
||||
success: bool
|
||||
sn: str
|
||||
new_status: str
|
||||
activated_at: Optional[str] = None
|
||||
message: str
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.32.0
|
||||
pydantic==2.9.0
|
||||
pydantic-settings==2.6.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
"""
|
||||
services.py
|
||||
业务服务模块:封装授权文件加密解密、核心逻辑。
|
||||
|
||||
加密方案说明(简单但有效):
|
||||
1. 将授权字典序列化为 JSON 字符串
|
||||
2. 使用 XOR 异或加密(密钥混合 SN,实现一机一密)
|
||||
3. 将加密后的字节进行 Base64 编码,方便传输
|
||||
|
||||
为什么用 XOR + SN 混合密钥:
|
||||
- 轻量级,不依赖额外加密库
|
||||
- 绑定设备 SN,即使授权文件被拷贝到其他设备也无法解密
|
||||
- 对于 Demo 和内部系统足够安全;生产环境可替换为 AES
|
||||
"""
|
||||
|
||||
import json
|
||||
import base64
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from models import LicenseData
|
||||
|
||||
|
||||
def _derive_key(device_sn: str) -> bytes:
|
||||
"""
|
||||
根据设备 SN 派生加密密钥。
|
||||
将 SN 字符串的每个字符 ASCII 码叠加后,扩展为 32 字节密钥。
|
||||
这样每台设备的密钥都不同,实现"一机一密"。
|
||||
"""
|
||||
seed = sum(ord(c) for c in device_sn) % 256
|
||||
# 用 seed 生成 32 字节的密钥序列
|
||||
key = bytes((seed + i * 7) % 256 for i in range(32))
|
||||
return key
|
||||
|
||||
|
||||
def encrypt_license(license_data: LicenseData) -> str:
|
||||
"""
|
||||
加密授权文件:
|
||||
LicenseData → JSON 字符串 → XOR 加密 → Base64 编码
|
||||
|
||||
参数:
|
||||
license_data: 明文授权数据对象
|
||||
|
||||
返回:
|
||||
base64 编码的加密字符串,可直接写入文件或传给 APP
|
||||
"""
|
||||
# 1. 转为 JSON 字节
|
||||
json_bytes = license_data.model_dump_json().encode("utf-8")
|
||||
|
||||
# 2. 派生密钥
|
||||
key = _derive_key(license_data.device_sn)
|
||||
|
||||
# 3. XOR 循环加密:密文 = 明文 ^ 密钥(循环使用)
|
||||
encrypted = bytearray()
|
||||
for i, byte in enumerate(json_bytes):
|
||||
encrypted.append(byte ^ key[i % len(key)])
|
||||
|
||||
# 4. Base64 编码,变成可打印字符串
|
||||
return base64.b64encode(encrypted).decode("ascii")
|
||||
|
||||
|
||||
def decrypt_license(encrypted_b64: str, device_sn: str) -> Optional[dict]:
|
||||
"""
|
||||
解密授权文件:
|
||||
Base64 解码 → XOR 解密 → JSON 反序列化为字典
|
||||
|
||||
参数:
|
||||
encrypted_b64: encrypt_license 返回的 Base64 字符串
|
||||
device_sn: 设备 SN(用于派生解密密钥,必须与加密时一致)
|
||||
|
||||
返回:
|
||||
解密后的授权字典;如果 SN 不匹配或数据损坏则返回 None
|
||||
"""
|
||||
try:
|
||||
# 1. Base64 解码
|
||||
encrypted = base64.b64decode(encrypted_b64)
|
||||
|
||||
# 2. 派生相同密钥
|
||||
key = _derive_key(device_sn)
|
||||
|
||||
# 3. XOR 解密(XOR 的逆运算还是 XOR)
|
||||
decrypted = bytearray()
|
||||
for i, byte in enumerate(encrypted):
|
||||
decrypted.append(byte ^ key[i % len(key)])
|
||||
|
||||
# 4. JSON 反序列化
|
||||
return json.loads(decrypted.decode("utf-8"))
|
||||
except Exception:
|
||||
# 解密失败(SN 不匹配、Base64 错误、JSON 损坏等)
|
||||
return None
|
||||
|
||||
|
||||
def build_license_data(
|
||||
device_sn: str,
|
||||
modules: list[str],
|
||||
valid_days: int = 365
|
||||
) -> LicenseData:
|
||||
"""
|
||||
构造明文授权数据对象。
|
||||
根据当前时间计算有效期截止时间。
|
||||
"""
|
||||
now = datetime.utcnow()
|
||||
valid_until = now + timedelta(days=valid_days)
|
||||
|
||||
return LicenseData(
|
||||
version="1.0",
|
||||
device_sn=device_sn,
|
||||
generated_at=now.isoformat() + "Z",
|
||||
valid_until=valid_until.isoformat() + "Z",
|
||||
modules=modules,
|
||||
status="active"
|
||||
)
|
||||
|
|
@ -18,3 +18,12 @@ export async function POST(req: Request) {
|
|||
const result = db.prepare('INSERT INTO config_files (name, model, version, create_time, status, voltage, current, duty_cycle, pulse_width, channels, sample_rate, voltage_range, waveform, ssid) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run(name, model, version, now, status || '生效', voltage || '', current || '', duty_cycle || '', pulse_width || '', channels || 0, sample_rate || '', voltage_range || '', waveform || '', ssid || '')
|
||||
return NextResponse.json({ id: result.lastInsertRowid })
|
||||
}
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
seedIfEmpty()
|
||||
const db = getDb()
|
||||
const body = await req.json()
|
||||
const { id, status } = body
|
||||
db.prepare('UPDATE config_files SET status = ? WHERE id = ?').run(status, id)
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
import { seedIfEmpty } from '@/lib/seed'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
seedIfEmpty()
|
||||
const db = getDb()
|
||||
const sn = req.nextUrl.searchParams.get('sn') || ''
|
||||
if (!sn) return NextResponse.json({ error: 'sn required' }, { status: 400 })
|
||||
|
||||
const device = db.prepare('SELECT * FROM devices WHERE sn = ?').get(sn)
|
||||
if (!device) return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
|
||||
const bom = db.prepare('SELECT * FROM device_bom_records WHERE device_sn = ? ORDER BY id').all(sn)
|
||||
const logs = db.prepare('SELECT * FROM device_logs WHERE device_sn = ? ORDER BY id DESC').all(sn)
|
||||
|
||||
// 查找该型号的授权和配置
|
||||
const d = device as { model: string }
|
||||
const modelShort = d.model.replace(' Supreme', '')
|
||||
const license = db.prepare('SELECT * FROM licenses WHERE model = ? OR model = ?').get(modelShort, d.model) || null
|
||||
const config = db.prepare("SELECT * FROM config_files WHERE model = ? AND status = '生效' ORDER BY id DESC LIMIT 1").get(d.model) || null
|
||||
|
||||
// 查找该型号的checklist模板
|
||||
const models = db.prepare('SELECT * FROM device_models WHERE name = ?').get(d.model) as { code: string } | undefined
|
||||
const checklist = models ? db.prepare('SELECT * FROM checklist_templates WHERE model_code = ? ORDER BY sort_order').all(models.code) : []
|
||||
|
||||
return NextResponse.json({ device, bom, logs, license, config, checklist })
|
||||
}
|
||||
|
||||
// POST: 保存设备登记的BOM和日志
|
||||
export async function POST(req: Request) {
|
||||
seedIfEmpty()
|
||||
const db = getDb()
|
||||
const body = await req.json()
|
||||
const { device_sn, bom_items, log } = body
|
||||
|
||||
if (bom_items && Array.isArray(bom_items)) {
|
||||
const insert = db.prepare('INSERT INTO device_bom_records (device_sn, name, material_sn, model, version, calibration) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
for (const item of bom_items) {
|
||||
insert.run(device_sn, item.name || '', item.sn || '', item.model || '', item.version || '', item.calibration || '无需校准')
|
||||
}
|
||||
}
|
||||
|
||||
if (log) {
|
||||
db.prepare('INSERT INTO device_logs (device_sn, action, operator, detail, date) VALUES (?, ?, ?, ?, ?)').run(
|
||||
device_sn, log.action || '', log.operator || '', log.detail || '', new Date().toISOString().replace('T', ' ').substring(0, 19)
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
|
|
@ -17,3 +17,20 @@ export async function POST(req: Request) {
|
|||
const result = db.prepare('INSERT INTO devices (sn, model, type, status, firmware, production_date, customer, batch) VALUES (?, ?, ?, ?, ?, ?, ?, ?)').run(sn, model, type, status || '装配中', firmware || '', production_date, customer || '-', batch || '')
|
||||
return NextResponse.json({ id: result.lastInsertRowid })
|
||||
}
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
seedIfEmpty()
|
||||
const db = getDb()
|
||||
const body = await req.json()
|
||||
const { id, status } = body
|
||||
db.prepare('UPDATE devices SET status = ? WHERE id = ?').run(status, id)
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
|
||||
export async function DELETE(req: Request) {
|
||||
seedIfEmpty()
|
||||
const db = getDb()
|
||||
const { id } = await req.json()
|
||||
db.prepare('DELETE FROM devices WHERE id = ?').run(id)
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
import { seedIfEmpty } from '@/lib/seed'
|
||||
|
||||
/**
|
||||
* 预览授权文件JSON内容
|
||||
* GET /api/licenses/[id]/preview
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
seedIfEmpty()
|
||||
const db = getDb()
|
||||
const { id } = await params
|
||||
|
||||
const license = db.prepare('SELECT * FROM licenses WHERE id = ?').get(id) as any
|
||||
if (!license) {
|
||||
return NextResponse.json({ error: '授权不存在' }, { status: 404 })
|
||||
}
|
||||
|
||||
// 如果有保存的授权文件,直接返回
|
||||
if (license.license_file) {
|
||||
try {
|
||||
return NextResponse.json(JSON.parse(license.license_file))
|
||||
} catch (e) {
|
||||
console.error('解析授权文件失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 否则实时生成
|
||||
const config = license.config_id
|
||||
? db.prepare('SELECT * FROM config_files WHERE id = ?').get(license.config_id)
|
||||
: null
|
||||
|
||||
const modulesList = license.modules.split(', ').filter(Boolean)
|
||||
|
||||
const { generateLicenseFile } = await import('@/lib/db')
|
||||
const licenseFile = generateLicenseFile(
|
||||
license.model,
|
||||
license.device_sn || '',
|
||||
modulesList,
|
||||
config,
|
||||
license.expiry,
|
||||
license.status === '生效' ? 'active' : 'inactive'
|
||||
)
|
||||
|
||||
return NextResponse.json(JSON.parse(licenseFile))
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
import { seedIfEmpty } from '@/lib/seed'
|
||||
|
||||
/**
|
||||
* 手机APP通过设备SN获取授权文件
|
||||
* GET /api/licenses/download?sn={device_sn}
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
seedIfEmpty()
|
||||
const db = getDb()
|
||||
|
||||
const sn = req.nextUrl.searchParams.get('sn')
|
||||
if (!sn) {
|
||||
return NextResponse.json({ error: '设备SN不能为空' }, { status: 400 })
|
||||
}
|
||||
|
||||
// 查找设备信息
|
||||
const device = db.prepare('SELECT * FROM devices WHERE sn = ?').get(sn) as any
|
||||
if (!device) {
|
||||
return NextResponse.json({ error: '设备不存在' }, { status: 404 })
|
||||
}
|
||||
|
||||
// 查找该设备型号的授权信息
|
||||
const modelShort = device.model.replace(' Supreme', '')
|
||||
const now = new Date().toISOString().replace('T', ' ').substring(0, 19)
|
||||
|
||||
// 优先查找绑定到具体设备SN的授权,如果没有则查找型号级别的授权
|
||||
let license = db.prepare(
|
||||
"SELECT * FROM licenses WHERE device_sn = ? AND status = '生效'"
|
||||
).get(sn) as any
|
||||
|
||||
if (!license) {
|
||||
license = db.prepare(
|
||||
"SELECT * FROM licenses WHERE (model = ? OR model = ?) AND status = '生效' AND expiry >= ? ORDER BY id DESC LIMIT 1"
|
||||
).get(modelShort, device.model, now) as any
|
||||
}
|
||||
|
||||
if (!license) {
|
||||
return NextResponse.json({
|
||||
error: '该设备暂无有效授权',
|
||||
deviceSN: sn,
|
||||
deviceModel: device.model
|
||||
}, { status: 404 })
|
||||
}
|
||||
|
||||
// 如果有保存的授权文件JSON,直接返回
|
||||
if (license.license_file) {
|
||||
try {
|
||||
const licenseData = JSON.parse(license.license_file)
|
||||
|
||||
// 更新deviceSN为当前设备的SN(如果是型号级别的授权)
|
||||
if (!license.device_sn || license.device_sn === '') {
|
||||
licenseData.deviceSN = sn
|
||||
}
|
||||
|
||||
// 记录下载日志
|
||||
const clientIp = req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip') || ''
|
||||
const appVersion = req.headers.get('user-agent') || ''
|
||||
|
||||
db.prepare(
|
||||
'INSERT INTO license_download_logs (license_id, device_sn, download_time, ip_address, app_version) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(
|
||||
license.id,
|
||||
sn,
|
||||
now,
|
||||
clientIp,
|
||||
appVersion
|
||||
)
|
||||
|
||||
return NextResponse.json(licenseData)
|
||||
} catch (e) {
|
||||
console.error('解析授权文件失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有保存的授权文件,实时生成
|
||||
const config = license.config_id
|
||||
? db.prepare('SELECT * FROM config_files WHERE id = ?').get(license.config_id)
|
||||
: db.prepare("SELECT * FROM config_files WHERE (model = ? OR model = ?) AND status = '生效' ORDER BY id DESC LIMIT 1").get(modelShort, device.model)
|
||||
|
||||
const modulesList = license.modules.split(', ').filter(Boolean)
|
||||
|
||||
// 动态导入生成函数
|
||||
const { generateLicenseFile } = await import('@/lib/db')
|
||||
const licenseFile = generateLicenseFile(
|
||||
device.model,
|
||||
sn,
|
||||
modulesList,
|
||||
config,
|
||||
license.expiry,
|
||||
'active'
|
||||
)
|
||||
|
||||
// 更新数据库中的授权文件
|
||||
db.prepare('UPDATE licenses SET license_file = ?, updated_at = ? WHERE id = ?').run(
|
||||
licenseFile,
|
||||
now,
|
||||
license.id
|
||||
)
|
||||
|
||||
// 记录下载日志
|
||||
const clientIp = req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip') || ''
|
||||
const appVersion = req.headers.get('user-agent') || ''
|
||||
|
||||
db.prepare(
|
||||
'INSERT INTO license_download_logs (license_id, device_sn, download_time, ip_address, app_version) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(
|
||||
license.id,
|
||||
sn,
|
||||
now,
|
||||
clientIp,
|
||||
appVersion
|
||||
)
|
||||
|
||||
return NextResponse.json(JSON.parse(licenseFile))
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
import { getDb, generateLicenseFile } from '@/lib/db'
|
||||
import { seedIfEmpty } from '@/lib/seed'
|
||||
|
||||
export async function GET() {
|
||||
|
|
@ -13,7 +13,101 @@ export async function POST(req: Request) {
|
|||
seedIfEmpty()
|
||||
const db = getDb()
|
||||
const body = await req.json()
|
||||
const { model, modules, expiry, status } = body
|
||||
const result = db.prepare('INSERT INTO licenses (model, modules, expiry, status) VALUES (?, ?, ?, ?)').run(model, modules, expiry || '', status || '生效')
|
||||
return NextResponse.json({ id: result.lastInsertRowid })
|
||||
const { model, modules, expiry, status, config_id, device_sn } = body
|
||||
|
||||
// 获取配置文件信息
|
||||
let config = null
|
||||
if (config_id) {
|
||||
config = db.prepare('SELECT * FROM config_files WHERE id = ?').get(config_id)
|
||||
} else {
|
||||
// 如果没有指定配置,查找该型号的最新生效配置
|
||||
const modelShort = model.replace(' Supreme', '')
|
||||
config = db.prepare("SELECT * FROM config_files WHERE (model = ? OR model = ?) AND status = '生效' ORDER BY id DESC LIMIT 1").get(modelShort, model)
|
||||
}
|
||||
|
||||
// 解析模块列表
|
||||
const modulesList = typeof modules === 'string' ? modules.split(', ').filter(Boolean) : modules
|
||||
|
||||
// 生成授权文件JSON
|
||||
const licenseFile = generateLicenseFile(
|
||||
model,
|
||||
device_sn || '',
|
||||
modulesList,
|
||||
config,
|
||||
expiry || '',
|
||||
status === '生效' ? 'active' : 'inactive'
|
||||
)
|
||||
|
||||
// 保存到数据库
|
||||
const now = new Date().toISOString().replace('T', ' ').substring(0, 19)
|
||||
const result = db.prepare(
|
||||
'INSERT INTO licenses (model, modules, expiry, status, config_id, device_sn, license_file, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(
|
||||
model,
|
||||
modules,
|
||||
expiry || '',
|
||||
status || '生效',
|
||||
config_id || null,
|
||||
device_sn || '',
|
||||
licenseFile,
|
||||
now,
|
||||
now
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
id: result.lastInsertRowid,
|
||||
licenseFile: JSON.parse(licenseFile)
|
||||
})
|
||||
}
|
||||
|
||||
// 更新授权
|
||||
export async function PUT(req: Request) {
|
||||
seedIfEmpty()
|
||||
const db = getDb()
|
||||
const body = await req.json()
|
||||
const { id, model, modules, expiry, status, config_id, device_sn } = body
|
||||
|
||||
// 获取配置文件信息
|
||||
let config = null
|
||||
if (config_id) {
|
||||
config = db.prepare('SELECT * FROM config_files WHERE id = ?').get(config_id)
|
||||
} else if (model) {
|
||||
const modelShort = model.replace(' Supreme', '')
|
||||
config = db.prepare("SELECT * FROM config_files WHERE (model = ? OR model = ?) AND status = '生效' ORDER BY id DESC LIMIT 1").get(modelShort, model)
|
||||
}
|
||||
|
||||
// 解析模块列表
|
||||
const modulesList = typeof modules === 'string' ? modules.split(', ').filter(Boolean) : []
|
||||
|
||||
// 重新生成授权文件
|
||||
const licenseFile = generateLicenseFile(
|
||||
model || '',
|
||||
device_sn || '',
|
||||
modulesList,
|
||||
config,
|
||||
expiry || '',
|
||||
status === '生效' ? 'active' : 'inactive'
|
||||
)
|
||||
|
||||
const now = new Date().toISOString().replace('T', ' ').substring(0, 19)
|
||||
|
||||
// 构建更新语句
|
||||
const updates: string[] = []
|
||||
const values: any[] = []
|
||||
|
||||
if (model !== undefined) { updates.push('model = ?'); values.push(model) }
|
||||
if (modules !== undefined) { updates.push('modules = ?'); values.push(modules) }
|
||||
if (expiry !== undefined) { updates.push('expiry = ?'); values.push(expiry) }
|
||||
if (status !== undefined) { updates.push('status = ?'); values.push(status) }
|
||||
if (config_id !== undefined) { updates.push('config_id = ?'); values.push(config_id) }
|
||||
if (device_sn !== undefined) { updates.push('device_sn = ?'); values.push(device_sn) }
|
||||
if (licenseFile) { updates.push('license_file = ?'); values.push(licenseFile) }
|
||||
|
||||
updates.push('updated_at = ?')
|
||||
values.push(now)
|
||||
values.push(id)
|
||||
|
||||
db.prepare(`UPDATE licenses SET ${updates.join(', ')} WHERE id = ?`).run(...values)
|
||||
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getDb } from '@/lib/db'
|
||||
import { seedIfEmpty } from '@/lib/seed'
|
||||
|
||||
export async function GET() {
|
||||
seedIfEmpty()
|
||||
const db = getDb()
|
||||
const rows = db.prepare('SELECT * FROM update_logs ORDER BY created_at DESC').all()
|
||||
return NextResponse.json(rows)
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
seedIfEmpty()
|
||||
const db = getDb()
|
||||
const body = await req.json()
|
||||
const { title, content, category, version } = body
|
||||
const result = db.prepare('INSERT INTO update_logs (title, content, category, version) VALUES (?, ?, ?, ?)').run(
|
||||
title, content || '', category || 'feature', version || ''
|
||||
)
|
||||
return NextResponse.json({ id: result.lastInsertRowid })
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
seedIfEmpty()
|
||||
const db = getDb()
|
||||
const id = req.nextUrl.searchParams.get('id')
|
||||
if (!id) return NextResponse.json({ error: 'missing id' }, { status: 400 })
|
||||
db.prepare('DELETE FROM update_logs WHERE id = ?').run(id)
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { writeFile } from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const formData = await req.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
const folder = (formData.get('folder') as string) || 'apps'
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate file size (max 500MB)
|
||||
const maxSize = 500 * 1024 * 1024
|
||||
if (file.size > maxSize) {
|
||||
return NextResponse.json({ error: 'File too large (max 500MB)' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Generate safe filename
|
||||
const originalName = file.name
|
||||
const ext = path.extname(originalName)
|
||||
const baseName = path.basename(originalName, ext).replace(/[^a-zA-Z0-9一-龥_-]/g, '_')
|
||||
const timestamp = Date.now()
|
||||
const fileName = `${baseName}_${timestamp}${ext}`
|
||||
|
||||
// Save to public/uploads/{folder}
|
||||
const uploadDir = path.join(process.cwd(), 'public', 'uploads', folder)
|
||||
const filePath = path.join(uploadDir, fileName)
|
||||
|
||||
const bytes = await file.arrayBuffer()
|
||||
const buffer = Buffer.from(bytes)
|
||||
await writeFile(filePath, buffer)
|
||||
|
||||
// Return public URL
|
||||
const url = `/uploads/${folder}/${fileName}`
|
||||
|
||||
return NextResponse.json({
|
||||
url,
|
||||
fileName: originalName,
|
||||
fileSize: file.size,
|
||||
fileType: ext.toLowerCase().replace('.', ''),
|
||||
})
|
||||
} catch (err: any) {
|
||||
console.error('Upload error:', err)
|
||||
return NextResponse.json({ error: err.message || 'Upload failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { Plus, Upload, Download, X, ChevronDown, ChevronUp, Smartphone, Package, Trash2, Layers, Monitor, Globe } from 'lucide-react'
|
||||
import { useState, useRef } from 'react'
|
||||
import { Plus, Upload, Download, X, ChevronDown, ChevronUp, Smartphone, Package, Trash2, Layers, Monitor, Globe, FileCheck, Loader2 } from 'lucide-react'
|
||||
import { useApi } from '@/lib/hooks'
|
||||
|
||||
interface AppPlatform { id: number; app_id: number; platform_type: number; platform_name: string; description: string; file_types: string[]; dist_types: { value: string; label: string }[] }
|
||||
|
|
@ -46,12 +46,63 @@ export default function AppManagePage() {
|
|||
const [platForm, setPlatForm] = useState({ platform_type: 2, description: '' })
|
||||
const [verForm, setVerForm] = useState({ platform_id: 0, major: 0, minor: 0, patch: 0, description: '', file_type: '', file_size: '', distribution_type: '', os_min_version: '', is_force_update: false, changelog: '' })
|
||||
const [expandedVerId, setExpandedVerId] = useState<number | null>(null)
|
||||
const [appUploading, setAppUploading] = useState(false)
|
||||
const [verUploading, setVerUploading] = useState(false)
|
||||
const [appUploadedFile, setAppUploadedFile] = useState<{ name: string; url: string; size: number; type: string } | null>(null)
|
||||
const [verUploadedFile, setVerUploadedFile] = useState<{ name: string; url: string; size: number; type: string } | null>(null)
|
||||
const appFileRef = useRef<HTMLInputElement>(null)
|
||||
const verFileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const loadDetail = async (id: number) => {
|
||||
setSelectedAppId(id)
|
||||
const r = await fetch(`/api/app-versions?app_id=${id}`)
|
||||
setAppDetail(await r.json())
|
||||
}
|
||||
|
||||
const uploadFile = async (file: File, setUploading: (v: boolean) => void, onSuccess: (res: { url: string; fileName: string; fileSize: number; fileType: string }) => void) => {
|
||||
setUploading(true)
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
fd.append('folder', 'apps')
|
||||
const res = await fetch('/api/upload', { method: 'POST', body: fd })
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || '上传失败')
|
||||
onSuccess(data)
|
||||
} catch (e: any) {
|
||||
alert('上传失败: ' + (e.message || '未知错误'))
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAppFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
await uploadFile(file, setAppUploading, (res) => {
|
||||
setAppUploadedFile({ name: res.fileName, url: res.url, size: res.fileSize, type: res.fileType })
|
||||
setAppForm(prev => ({
|
||||
...prev,
|
||||
file_type: res.fileType,
|
||||
file_size: String(res.fileSize),
|
||||
}))
|
||||
})
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const handleVerFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
await uploadFile(file, setVerUploading, (res) => {
|
||||
setVerUploadedFile({ name: res.fileName, url: res.url, size: res.fileSize, type: res.fileType })
|
||||
setVerForm(prev => ({
|
||||
...prev,
|
||||
file_type: res.fileType,
|
||||
file_size: String(res.fileSize),
|
||||
}))
|
||||
})
|
||||
e.target.value = ''
|
||||
}
|
||||
const handleCreateApp = async () => {
|
||||
// 1. 创建应用
|
||||
const appRes = await fetch('/api/app-versions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'create_app', name: appForm.name, package_name: appForm.package_name, description: appForm.description }) })
|
||||
|
|
@ -61,7 +112,8 @@ export default function AppManagePage() {
|
|||
const { id: platformId } = await platRes.json()
|
||||
// 3. 创建首个版本
|
||||
const vn = `${appForm.major}.${appForm.minor}.${appForm.patch}`
|
||||
await fetch('/api/app-versions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'add_version', app_id: appId, platform_id: platformId, major_version: appForm.major, minor_version: appForm.minor, patch_version: appForm.patch, version_name: vn, file_type: appForm.file_type, file_size: parseInt(appForm.file_size) || 0, distribution_type: appForm.distribution_type, os_min_version: appForm.os_min_version, is_force_update: appForm.is_force_update, changelog: appForm.changelog.split('\n').filter(Boolean) }) })
|
||||
await fetch('/api/app-versions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'add_version', app_id: appId, platform_id: platformId, major_version: appForm.major, minor_version: appForm.minor, patch_version: appForm.patch, version_name: vn, file_type: appForm.file_type, file_size: parseInt(appForm.file_size) || 0, distribution_type: appForm.distribution_type, primary_url: appUploadedFile?.url || '', os_min_version: appForm.os_min_version, is_force_update: appForm.is_force_update, changelog: appForm.changelog.split('\n').filter(Boolean) }) })
|
||||
setAppUploadedFile(null)
|
||||
refetch(); setAppDrawer(false); loadDetail(appId)
|
||||
}
|
||||
const handleAddPlatform = async () => {
|
||||
|
|
@ -72,7 +124,8 @@ export default function AppManagePage() {
|
|||
const handleAddVersion = async () => {
|
||||
if (!selectedAppId || !verForm.platform_id) return
|
||||
const vn = `${verForm.major}.${verForm.minor}.${verForm.patch}`
|
||||
await fetch('/api/app-versions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'add_version', app_id: selectedAppId, platform_id: verForm.platform_id, major_version: verForm.major, minor_version: verForm.minor, patch_version: verForm.patch, version_name: vn, description: verForm.description, file_type: verForm.file_type, file_size: parseInt(verForm.file_size) || 0, distribution_type: verForm.distribution_type, os_min_version: verForm.os_min_version, is_force_update: verForm.is_force_update, changelog: verForm.changelog.split('\n').filter(Boolean) }) })
|
||||
await fetch('/api/app-versions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'add_version', app_id: selectedAppId, platform_id: verForm.platform_id, major_version: verForm.major, minor_version: verForm.minor, patch_version: verForm.patch, version_name: vn, description: verForm.description, file_type: verForm.file_type, file_size: parseInt(verForm.file_size) || 0, distribution_type: verForm.distribution_type, primary_url: verUploadedFile?.url || '', os_min_version: verForm.os_min_version, is_force_update: verForm.is_force_update, changelog: verForm.changelog.split('\n').filter(Boolean) }) })
|
||||
setVerUploadedFile(null)
|
||||
loadDetail(selectedAppId); setVersionDrawer(false)
|
||||
}
|
||||
const handleVerStatus = async (id: number, status: number) => {
|
||||
|
|
@ -93,14 +146,28 @@ export default function AppManagePage() {
|
|||
|
||||
if (loading) return <div style={{ padding: 24 }}>加载中...</div>
|
||||
|
||||
const resetAppForm = () => {
|
||||
setAppForm({ name: '', package_name: '', description: '', platform_type: 2, major: 1, minor: 0, patch: 0, file_type: '', file_size: '', distribution_type: '', os_min_version: '', is_force_update: false, changelog: '' })
|
||||
setAppUploadedFile(null)
|
||||
setAppDrawer(false)
|
||||
}
|
||||
|
||||
const resetVerForm = () => {
|
||||
setVerForm({ platform_id: appDetail?.platforms[0]?.id || 0, major: 0, minor: 0, patch: 0, description: '', file_type: '', file_size: '', distribution_type: '', os_min_version: '', is_force_update: false, changelog: '' })
|
||||
setVerUploadedFile(null)
|
||||
setVersionDrawer(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<>
|
||||
<style>{`@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }`}</style>
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 600, margin: 0 }}>应用管理</h2>
|
||||
<p style={{ fontSize: 14, color: 'rgba(0,0,0,0.45)', margin: '4px 0 0' }}>管理应用、平台与版本发布</p>
|
||||
</div>
|
||||
<button onClick={() => { setAppDrawer(true); setAppForm({ name: '', package_name: '', description: '', platform_type: 2, major: 1, minor: 0, patch: 0, file_type: '', file_size: '', distribution_type: '', os_min_version: '', is_force_update: false, changelog: '' }) }} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px', border: 'none', borderRadius: 6, backgroundColor: '#4a7c59', color: '#fff', cursor: 'pointer', fontSize: 14 }}><Plus size={16} />新建应用</button>
|
||||
<button onClick={() => { setAppDrawer(true); setAppForm({ name: '', package_name: '', description: '', platform_type: 2, major: 1, minor: 0, patch: 0, file_type: '', file_size: '', distribution_type: '', os_min_version: '', is_force_update: false, changelog: '' }); setAppUploadedFile(null) }} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px', border: 'none', borderRadius: 6, backgroundColor: '#4a7c59', color: '#fff', cursor: 'pointer', fontSize: 14 }}><Plus size={16} />新建应用</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 24 }}>
|
||||
|
|
@ -178,7 +245,7 @@ export default function AppManagePage() {
|
|||
<div style={{ backgroundColor: '#fff', borderRadius: 8, boxShadow: '0 1px 2px rgba(0,0,0,0.05)', overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '14px 20px', borderBottom: '1px solid #F0F0F0' }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, margin: 0 }}>版本列表({appDetail.versions.length})</h3>
|
||||
<button onClick={() => { setVersionDrawer(true); setVerForm({ platform_id: appDetail.platforms[0]?.id || 0, major: 0, minor: 0, patch: 0, description: '', file_type: '', file_size: '', distribution_type: '', os_min_version: '', is_force_update: false, changelog: '' }) }} disabled={appDetail.platforms.length === 0} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '6px 14px', border: 'none', borderRadius: 6, backgroundColor: appDetail.platforms.length > 0 ? '#4a7c59' : '#D9D9D9', color: '#fff', cursor: appDetail.platforms.length > 0 ? 'pointer' : 'not-allowed', fontSize: 13 }}><Upload size={14} />上传新版本</button>
|
||||
<button onClick={() => { setVersionDrawer(true); setVerForm({ platform_id: appDetail.platforms[0]?.id || 0, major: 0, minor: 0, patch: 0, description: '', file_type: '', file_size: '', distribution_type: '', os_min_version: '', is_force_update: false, changelog: '' }); setVerUploadedFile(null) }} disabled={appDetail.platforms.length === 0} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '6px 14px', border: 'none', borderRadius: 6, backgroundColor: appDetail.platforms.length > 0 ? '#4a7c59' : '#D9D9D9', color: '#fff', cursor: appDetail.platforms.length > 0 ? 'pointer' : 'not-allowed', fontSize: 13 }}><Upload size={14} />上传新版本</button>
|
||||
</div>
|
||||
{appDetail.versions.length === 0 ? (
|
||||
<div style={{ padding: 32, textAlign: 'center', color: 'rgba(0,0,0,0.25)', fontSize: 13 }}>暂无版本</div>
|
||||
|
|
@ -217,7 +284,11 @@ export default function AppManagePage() {
|
|||
<div><span style={{ color: 'rgba(0,0,0,0.45)' }}>文件大小:</span>{ver.file_size ? `${(ver.file_size / 1024 / 1024).toFixed(1)}MB` : '-'}</div>
|
||||
<div><span style={{ color: 'rgba(0,0,0,0.45)' }}>分发类型:</span>{ver.distribution_type || '-'}</div>
|
||||
<div><span style={{ color: 'rgba(0,0,0,0.45)' }}>下载链接:</span>{ver.primary_url || ver.file_url || '-'}</div>
|
||||
{(ver.file_url || ver.primary_url) && <button style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 4, padding: '3px 10px', border: '1px solid #4a7c59', borderRadius: 4, backgroundColor: '#fff', color: '#4a7c59', cursor: 'pointer', fontSize: 12 }}><Download size={12} />下载</button>}
|
||||
{(ver.file_url || ver.primary_url) && (
|
||||
<a href={ver.primary_url || ver.file_url} download style={{ display: 'inline-flex', alignItems: 'center', gap: 4, marginTop: 4, padding: '3px 10px', border: '1px solid #4a7c59', borderRadius: 4, backgroundColor: '#fff', color: '#4a7c59', cursor: 'pointer', fontSize: 12, textDecoration: 'none' }}>
|
||||
<Download size={12} />下载
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{ver.changelog.length > 0 && (
|
||||
|
|
@ -250,7 +321,7 @@ export default function AppManagePage() {
|
|||
<div style={{ position: 'absolute', right: 0, top: 0, bottom: 0, width: 580, backgroundColor: '#fff', boxShadow: '-2px 0 8px rgba(0,0,0,0.15)', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px 24px', borderBottom: '1px solid #F0F0F0' }}>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>新建应用</h3>
|
||||
<button onClick={() => setAppDrawer(false)} style={{ border: 'none', background: 'none', cursor: 'pointer', padding: 4 }}><X size={20} /></button>
|
||||
<button onClick={resetAppForm} style={{ border: 'none', background: 'none', cursor: 'pointer', padding: 4 }}><X size={20} /></button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: 24 }}>
|
||||
{/* 应用基本信息 */}
|
||||
|
|
@ -297,10 +368,26 @@ export default function AppManagePage() {
|
|||
{/* 安装包上传 */}
|
||||
<h4 style={{ fontSize: 14, fontWeight: 600, marginBottom: 16, paddingBottom: 8, borderBottom: '1px solid #F0F0F0', marginTop: 0 }}>安装包</h4>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ border: '2px dashed #D9D9D9', borderRadius: 8, padding: 24, textAlign: 'center', cursor: 'pointer', backgroundColor: '#FAFAFA' }}>
|
||||
<Upload size={28} style={{ color: 'rgba(0,0,0,0.25)', marginBottom: 6 }} />
|
||||
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)' }}>点击或拖拽上传安装包</div>
|
||||
{ft.length > 0 && <div style={{ fontSize: 12, color: 'rgba(0,0,0,0.25)', marginTop: 4 }}>支持格式:{ft.join(' / ')}</div>}
|
||||
<input ref={appFileRef} type="file" accept={ft.map(f => '.' + f).join(',')} style={{ display: 'none' }} onChange={handleAppFileSelect} />
|
||||
<div onClick={() => !appUploading && appFileRef.current?.click()} style={{ border: '2px dashed #D9D9D9', borderRadius: 8, padding: 24, textAlign: 'center', cursor: appUploading ? 'not-allowed' : 'pointer', backgroundColor: '#FAFAFA' }}>
|
||||
{appUploading ? (
|
||||
<>
|
||||
<Loader2 size={28} style={{ color: '#4a7c59', marginBottom: 6, animation: 'spin 1s linear infinite' }} />
|
||||
<div style={{ fontSize: 13, color: '#4a7c59' }}>上传中...</div>
|
||||
</>
|
||||
) : appUploadedFile ? (
|
||||
<>
|
||||
<FileCheck size={28} style={{ color: '#52C41A', marginBottom: 6 }} />
|
||||
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.65)', fontWeight: 500 }}>{appUploadedFile.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'rgba(0,0,0,0.45)', marginTop: 4 }}>{(appUploadedFile.size / 1024 / 1024).toFixed(1)} MB</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload size={28} style={{ color: 'rgba(0,0,0,0.25)', marginBottom: 6 }} />
|
||||
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)' }}>点击上传安装包</div>
|
||||
{ft.length > 0 && <div style={{ fontSize: 12, color: 'rgba(0,0,0,0.25)', marginTop: 4 }}>支持格式:{ft.join(' / ')}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 16 }}>
|
||||
|
|
@ -342,7 +429,7 @@ export default function AppManagePage() {
|
|||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '16px 24px', borderTop: '1px solid #F0F0F0', display: 'flex', justifyContent: 'flex-end', gap: 12 }}>
|
||||
<button onClick={() => setAppDrawer(false)} style={{ padding: '8px 20px', border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer', fontSize: 14 }}>取消</button>
|
||||
<button onClick={resetAppForm} style={{ padding: '8px 20px', border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer', fontSize: 14 }}>取消</button>
|
||||
<button onClick={handleCreateApp} disabled={!appForm.name} style={{ padding: '8px 20px', border: 'none', borderRadius: 6, backgroundColor: appForm.name ? '#4a7c59' : '#D9D9D9', color: '#fff', cursor: appForm.name ? 'pointer' : 'not-allowed', fontSize: 14 }}>创建应用</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -390,12 +477,12 @@ export default function AppManagePage() {
|
|||
<div style={{ position: 'absolute', right: 0, top: 0, bottom: 0, width: 560, backgroundColor: '#fff', boxShadow: '-2px 0 8px rgba(0,0,0,0.15)', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px 24px', borderBottom: '1px solid #F0F0F0' }}>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>上传新版本</h3>
|
||||
<button onClick={() => setVersionDrawer(false)} style={{ border: 'none', background: 'none', cursor: 'pointer', padding: 4 }}><X size={20} /></button>
|
||||
<button onClick={resetVerForm} style={{ border: 'none', background: 'none', cursor: 'pointer', padding: 4 }}><X size={20} /></button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: 24 }}>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<label style={{ display: 'block', fontSize: 14, fontWeight: 500, marginBottom: 8 }}><span style={{ color: '#FF4D4F' }}>*</span> 目标平台</label>
|
||||
<select value={verForm.platform_id} onChange={e => setVerForm({ ...verForm, platform_id: Number(e.target.value), file_type: '', distribution_type: '' })} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14 }}>
|
||||
<select value={verForm.platform_id} onChange={e => { setVerForm({ ...verForm, platform_id: Number(e.target.value), file_type: '', distribution_type: '' }); setVerUploadedFile(null) }} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14 }}>
|
||||
{appDetail.platforms.map(p => <option key={p.id} value={p.id}>{p.platform_name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
|
@ -412,10 +499,26 @@ export default function AppManagePage() {
|
|||
</div>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<label style={{ display: 'block', fontSize: 14, fontWeight: 500, marginBottom: 8 }}>安装包</label>
|
||||
<div style={{ border: '2px dashed #D9D9D9', borderRadius: 8, padding: 24, textAlign: 'center', cursor: 'pointer', backgroundColor: '#FAFAFA' }}>
|
||||
<Upload size={28} style={{ color: 'rgba(0,0,0,0.25)', marginBottom: 6 }} />
|
||||
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)' }}>点击或拖拽上传安装包</div>
|
||||
{selectedPlatform && <div style={{ fontSize: 12, color: 'rgba(0,0,0,0.25)', marginTop: 4 }}>支持格式:{selectedPlatform.file_types.join(' / ') || '任意'}</div>}
|
||||
<input ref={verFileRef} type="file" accept={selectedPlatform?.file_types.map(f => '.' + f).join(',') || '*'} style={{ display: 'none' }} onChange={handleVerFileSelect} />
|
||||
<div onClick={() => !verUploading && verFileRef.current?.click()} style={{ border: '2px dashed #D9D9D9', borderRadius: 8, padding: 24, textAlign: 'center', cursor: verUploading ? 'not-allowed' : 'pointer', backgroundColor: '#FAFAFA' }}>
|
||||
{verUploading ? (
|
||||
<>
|
||||
<Loader2 size={28} style={{ color: '#4a7c59', marginBottom: 6, animation: 'spin 1s linear infinite' }} />
|
||||
<div style={{ fontSize: 13, color: '#4a7c59' }}>上传中...</div>
|
||||
</>
|
||||
) : verUploadedFile ? (
|
||||
<>
|
||||
<FileCheck size={28} style={{ color: '#52C41A', marginBottom: 6 }} />
|
||||
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.65)', fontWeight: 500 }}>{verUploadedFile.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'rgba(0,0,0,0.45)', marginTop: 4 }}>{(verUploadedFile.size / 1024 / 1024).toFixed(1)} MB</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload size={28} style={{ color: 'rgba(0,0,0,0.25)', marginBottom: 6 }} />
|
||||
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)' }}>点击上传安装包</div>
|
||||
{selectedPlatform && <div style={{ fontSize: 12, color: 'rgba(0,0,0,0.25)', marginTop: 4 }}>支持格式:{selectedPlatform.file_types.join(' / ') || '任意'}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 20 }}>
|
||||
|
|
@ -461,12 +564,13 @@ export default function AppManagePage() {
|
|||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '16px 24px', borderTop: '1px solid #F0F0F0', display: 'flex', justifyContent: 'flex-end', gap: 12 }}>
|
||||
<button onClick={() => setVersionDrawer(false)} style={{ padding: '8px 20px', border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer', fontSize: 14 }}>取消</button>
|
||||
<button onClick={resetVerForm} style={{ padding: '8px 20px', border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer', fontSize: 14 }}>取消</button>
|
||||
<button onClick={handleAddVersion} disabled={!verForm.platform_id} style={{ padding: '8px 20px', border: 'none', borderRadius: 6, backgroundColor: verForm.platform_id ? '#4a7c59' : '#D9D9D9', color: '#fff', cursor: verForm.platform_id ? 'pointer' : 'not-allowed', fontSize: 14 }}>上传版本</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { Monitor, Settings2, Gauge, Wrench, Recycle, Layers, FolderTree, Smartphone } from 'lucide-react'
|
||||
import { Monitor, Settings2, Gauge, Wrench, Recycle, Layers, FolderTree, Smartphone, Database, HelpCircle } from 'lucide-react'
|
||||
|
||||
const menuGroups = [
|
||||
{ title: '设备', items: [
|
||||
|
|
@ -20,6 +20,9 @@ const menuGroups = [
|
|||
{ path: '/repair', label: '维修工单', icon: Wrench },
|
||||
{ path: '/scrap', label: '报废回收', icon: Recycle },
|
||||
]},
|
||||
{ title: '其他', items: [
|
||||
{ path: '/help', label: '帮助中心', icon: HelpCircle },
|
||||
]},
|
||||
]
|
||||
|
||||
export function Sidebar() {
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ interface ConfigItem {
|
|||
channels: number; sample_rate: string; voltage_range: string; waveform: string; ssid: string
|
||||
}
|
||||
|
||||
const modelOptions = ['全部', 'GD-30 Supreme', 'GD-20 Supreme', 'GD-10 Supreme']
|
||||
const versionOptions = ['全部', 'v1.0.0', 'v1.1.0', 'v1.2.0', 'v1.3.0']
|
||||
|
||||
|
||||
function getStatusStyle(status: string) {
|
||||
switch (status) {
|
||||
|
|
@ -34,6 +33,7 @@ function ConfigFilesContent() {
|
|||
const searchParams = useSearchParams()
|
||||
const modelParam = searchParams.get('model') || ''
|
||||
const { data: configData, loading, refetch } = useApi<ConfigItem[]>('/api/config-files', [])
|
||||
const { data: modelsData } = useApi<{ id: number; name: string }[]>('/api/models', [])
|
||||
|
||||
const modelNameMap: Record<string, string> = {
|
||||
'GD-30 Supreme': 'GD-30 Supreme',
|
||||
|
|
@ -84,7 +84,7 @@ function ConfigFilesContent() {
|
|||
<p style={{ fontSize: 14, color: 'rgba(0,0,0,0.45)', margin: '4px 0 0' }}>管理设备型号配置文件</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setDrawerOpen(true)} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px', border: 'none', borderRadius: 6, backgroundColor: '#4a7c59', color: '#fff', cursor: 'pointer', fontSize: 14 }}>
|
||||
<button onClick={() => { setForm({ ...form, model: filterModel !== '全部' ? filterModel : (modelsData[0]?.name || '') }); setDrawerOpen(true) }} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px', border: 'none', borderRadius: 6, backgroundColor: '#4a7c59', color: '#fff', cursor: 'pointer', fontSize: 14 }}>
|
||||
<Plus size={16} />新建配置
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -103,7 +103,7 @@ function ConfigFilesContent() {
|
|||
<div style={{ flex: 1 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, color: 'rgba(0,0,0,0.65)', marginBottom: 6 }}>适配型号</label>
|
||||
<select value={filterModel} onChange={e => { setFilterModel(e.target.value); setCurrentPage(1) }} style={{ width: '100%', padding: '6px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14 }}>
|
||||
{modelOptions.map(m => <option key={m} value={m}>{m}</option>)}
|
||||
{['全部', ...modelsData.map(m => m.name)].map(m => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
|
|
@ -145,9 +145,8 @@ function ConfigFilesContent() {
|
|||
<td style={{ padding: '12px 16px' }}>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button onClick={() => setDetailDrawer(row)} style={{ color: '#4a7c59', cursor: 'pointer', border: 'none', background: 'none', fontSize: 13, display: 'flex', alignItems: 'center', gap: 3 }}><Eye size={13} />详情</button>
|
||||
<button style={{ color: '#4a7c59', cursor: 'pointer', border: 'none', background: 'none', fontSize: 13, display: 'flex', alignItems: 'center', gap: 3 }}><Edit size={13} />编辑</button>
|
||||
<button onClick={async () => { await fetch('/api/config-files', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: row.id, status: row.status === '生效' ? '已停用' : '生效' }) }); refetch() }} style={{ color: row.status === '生效' ? '#FAAD14' : '#52C41A', cursor: 'pointer', border: 'none', background: 'none', fontSize: 13, display: 'flex', alignItems: 'center', gap: 3 }}>{row.status === '生效' ? '停用' : '启用'}</button>
|
||||
<button style={{ color: '#4a7c59', cursor: 'pointer', border: 'none', background: 'none', fontSize: 13, display: 'flex', alignItems: 'center', gap: 3 }}><Download size={13} />下载</button>
|
||||
<button style={{ color: '#FF4D4F', cursor: 'pointer', border: 'none', background: 'none', fontSize: 13, display: 'flex', alignItems: 'center', gap: 3 }}><Trash2 size={13} />删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -239,9 +238,8 @@ function ConfigFilesContent() {
|
|||
<div>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}><span style={{ color: '#FF4D4F' }}>*</span> 适配型号</label>
|
||||
<select value={form.model} onChange={e => setForm({ ...form, model: e.target.value })} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14 }}>
|
||||
<option value="GD-10 Supreme">GD-10 Supreme</option>
|
||||
<option value="GD-20 Supreme">GD-20 Supreme</option>
|
||||
<option value="GD-30 Supreme">GD-30 Supreme</option>
|
||||
{modelsData.map(m => <option key={m.id} value={m.name}>{m.name}</option>)}
|
||||
{modelsData.length === 0 && <option value="">请先添加设备型号</option>}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -1,121 +1,8 @@
|
|||
'use client'
|
||||
import { use } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { use, useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { ArrowLeft, Cpu, Wifi, Monitor, Key, FileCode, Camera, Clock, X, CheckCircle, AlertTriangle, Package, ChevronLeft, ChevronRight, ZoomIn, Download, Edit, Check } from 'lucide-react'
|
||||
|
||||
/** Mock: 所有设备数据 */
|
||||
const allDevices = [
|
||||
{ id: 1, sn: 'GD30-2025-000001', model: 'GD-30 Supreme', type: '高密度电法仪', status: '已激活', firmware: 'v2.3.5', productionDate: '2025-01-15 14:30', operator: '张工程师' },
|
||||
{ id: 2, sn: 'GD30-2025-000002', model: 'GD-30 Supreme', type: '高密度电法仪', status: '已激活', firmware: 'v2.3.5', productionDate: '2025-01-18 09:15', operator: '张工程师' },
|
||||
{ id: 3, sn: 'GD30-2024-000056', model: 'GD-30 Supreme', type: '高密度电法仪', status: '已出厂', firmware: 'v2.3.4', productionDate: '2024-12-20 16:00', operator: '李工程师' },
|
||||
{ id: 4, sn: 'GT20-2025-000045', model: 'GD-20', type: '二维电法仪', status: '已激活', firmware: 'v1.8.5', productionDate: '2025-02-10 11:20', operator: '王工程师' },
|
||||
{ id: 5, sn: 'GT20-2025-000046', model: 'GD-20', type: '二维电法仪', status: '装配中', firmware: 'v1.8.5', productionDate: '2025-03-01 08:45', operator: '王工程师' },
|
||||
{ id: 6, sn: 'GD30-2024-000078', model: 'GD-30 Supreme', type: '高密度电法仪', status: '已出厂', firmware: 'v2.3.4', productionDate: '2024-11-05 13:30', operator: '张工程师' },
|
||||
{ id: 7, sn: 'GD10-2024-000033', model: 'GD-10 Supreme', type: '入门级电法仪', status: '已激活', firmware: 'v1.5.2', productionDate: '2024-09-12 10:00', operator: '李工程师' },
|
||||
{ id: 8, sn: 'GD30-2024-000089', model: 'GD-30 Supreme', type: '高密度电法仪', status: '装配中', firmware: 'v2.3.5', productionDate: '2025-03-05 15:10',operator: '张工程师' },
|
||||
{ id: 9, sn: 'GT20-2025-000012', model: 'GD-20', type: '二维电法仪', status: '已激活', firmware: 'v1.8.5', productionDate: '2025-01-22 09:30', operator: '王工程师' },
|
||||
{ id: 10, sn: 'GD30-2024-000102', model: 'GD-30 Supreme', type: '高密度电法仪', status: '已出厂', firmware: 'v2.3.4', productionDate: '2024-10-18 14:00', operator: '李工程师' },
|
||||
{ id: 11, sn: 'GD10-2024-000034', model: 'GD-10 Supreme', type: '入门级电法仪', status: '装配中', firmware: 'v1.5.2', productionDate: '2025-03-08 11:45', operator: '王工程师' },
|
||||
{ id: 12, sn: 'GD30-2024-000145', model: 'GD-30 Supreme', type: '高密度电法仪', status: '已激活', firmware: 'v2.3.5', productionDate: '2024-08-25 16:20', operator: '张工程师' },
|
||||
]
|
||||
|
||||
/** Mock: 装机BOM清单 */
|
||||
const bomData: Record<string, { name: string; sn: string; model: string; calibration: string }[]> = {
|
||||
'GD30-2025-000001': [
|
||||
{ name: '主协板', sn: 'MB25011501', model: 'MB-V2.1', calibration: '-' },
|
||||
{ name: '采集板', sn: 'RX25011000', model: 'RX-V2.1', calibration: '合格' },
|
||||
{ name: '采集板', sn: 'RX25011000', model: 'RX-V2.1', calibration: '合格' },
|
||||
{ name: '发射板', sn: 'TX25012000', model: 'TX-V2.1', calibration: '-' },
|
||||
{ name: '升压板', sn: 'BO25020100', model: 'BP600-V2.1', calibration: '-' },
|
||||
],
|
||||
}
|
||||
|
||||
/** Mock: 授权信息 */
|
||||
const licenseData: Record<string, { modules: string; expiry: string; status: string }> = {
|
||||
'GD30-2025-000001': { modules: '一维自电/电阻率/激电测试模块, 二维自电/电阻率/激电测试模块, 三维自电/电阻率/激电测试模块, 水上, 跨孔, 电流法', expiry: '2026-01-15', status: '生效' },
|
||||
'GD30-2025-000002': { modules: '一维自电/电阻率/激电测试模块, 二维自电/电阻率/激电测试模块, 三维自电/电阻率/激电测试模块, 水上, 跨孔, 电流法', expiry: '2025-12-31', status: '生效' },
|
||||
'GT20-2025-000045': { modules: '一维自电/电阻率/激电测试模块, 二维自电/电阻率/激电测试模块, 三维自电/电阻率/激电测试模块', expiry: '2025-06-30', status: '生效' },
|
||||
}
|
||||
|
||||
/** Mock: 配置文件(含详细参数) */
|
||||
interface ConfigDetail {
|
||||
name: string
|
||||
version: string
|
||||
uploadDate: string
|
||||
params: {
|
||||
transmission: { maxVoltage: string; maxCurrent: string; waveformPattern: string; pulseWidths: string[]; waveforms: string[]; fullWaveform: boolean }
|
||||
acquisition: { channels: number; sampleRates: string[]; voltageRanges: string[] }
|
||||
protection: { overVoltage: { enabled: boolean; threshold: string }; overCurrent: { enabled: boolean; threshold: string }; shortCircuit: boolean; overTemp: { enabled: boolean; threshold: string } }
|
||||
network: { wifiSsidPrefix: string }
|
||||
}
|
||||
}
|
||||
const configData: Record<string, ConfigDetail> = {
|
||||
'GD30-2025-000001': {
|
||||
name: 'CFG-GD30-v1.3.0', version: 'v1.3.0', uploadDate: '2025-01-15',
|
||||
params: {
|
||||
transmission: { maxVoltage: '1500V', maxCurrent: '10A', waveformPattern: '0+0-、+0-0、+-', pulseWidths: ['0.25s', '0.5s', '1s', '2s', '4s', '8s', '16s', '32s', '64s'], waveforms: ['0+0-', '+0-0', '+-'], fullWaveform: true },
|
||||
acquisition: { channels: 12, sampleRates: ['50Hz', '60Hz', '100Hz', '1000Hz'], voltageRanges: ['±2.5V', '±80V', '±600V'] },
|
||||
protection: { overVoltage: { enabled: true, threshold: '1600V' }, overCurrent: { enabled: true, threshold: '12A' }, shortCircuit: true, overTemp: { enabled: true, threshold: '75°C' } },
|
||||
network: { wifiSsidPrefix: 'GD30-Supreme' },
|
||||
},
|
||||
},
|
||||
'GD30-2025-000002': {
|
||||
name: 'CFG-GD30-v1.3.0', version: 'v1.3.0', uploadDate: '2025-01-18',
|
||||
params: {
|
||||
transmission: { maxVoltage: '1500V', maxCurrent: '10A', waveformPattern: '0+0-、+0-0、+-', pulseWidths: ['0.25s', '0.5s', '1s', '2s', '4s', '8s', '16s', '32s', '64s'], waveforms: ['0+0-', '+0-0', '+-'], fullWaveform: true },
|
||||
acquisition: { channels: 12, sampleRates: ['50Hz', '60Hz', '100Hz', '1000Hz'], voltageRanges: ['±2.5V', '±80V', '±600V'] },
|
||||
protection: { overVoltage: { enabled: true, threshold: '1600V' }, overCurrent: { enabled: true, threshold: '12A' }, shortCircuit: true, overTemp: { enabled: true, threshold: '75°C' } },
|
||||
network: { wifiSsidPrefix: 'GD30-Supreme' },
|
||||
},
|
||||
},
|
||||
'GT20-2025-000045': {
|
||||
name: 'CFG-GD20-v1.1.0', version: 'v1.1.0', uploadDate: '2025-02-10',
|
||||
params: {
|
||||
transmission: { maxVoltage: '800V', maxCurrent: '5A', waveformPattern: '0+0-、+0-0', pulseWidths: ['0.25s', '0.5s', '1s', '2s', '4s', '8s'], waveforms: ['0+0-', '+0-0'], fullWaveform: false },
|
||||
acquisition: { channels: 6, sampleRates: ['50Hz', '60Hz'], voltageRanges: ['±2.5V', '±80V'] },
|
||||
protection: { overVoltage: { enabled: true, threshold: '900V' }, overCurrent: { enabled: true, threshold: '6A' }, shortCircuit: true, overTemp: { enabled: true, threshold: '70°C' } },
|
||||
network: { wifiSsidPrefix: 'GD20' },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/** Mock: 操作日志 */
|
||||
const operationLogs = [
|
||||
{ date: '2025-01-15 14:30', action: '设备登记', operator: '张工程师', detail: '完成设备登记,型号 GD-30 Supreme' },
|
||||
{ date: '2025-01-15 15:00', action: '装配完成', operator: '张工程师', detail: '装配 Checklist 全部通过' },
|
||||
{ date: '2025-01-15 16:00', action: '授权项写入', operator: '系统', detail: '写入全模块授权项' },
|
||||
{ date: '2025-01-15 16:05', action: '配置写入', operator: '系统', detail: '写入配置文件 CFG-GD30-v1.3.0' },
|
||||
{ date: '2025-01-15 16:10', action: '固件校验', operator: '系统', detail: '固件版本 v2.3.5 校验通过' },
|
||||
{ date: '2025-01-16 09:00', action: '出厂检测', operator: '李工程师', detail: '出厂检测通过,设备状态变更为已出厂' },
|
||||
]
|
||||
|
||||
/** Mock: 装配 Checklist(含照片和记录) */
|
||||
const checklistData = [
|
||||
{ name: '主板SN扫码绑定', passed: true, photos: ['https://picsum.photos/seed/mb1/800/600', 'https://picsum.photos/seed/mb2/800/600'], note: '主板SN MB25011501 扫码绑定成功,条码清晰可读' },
|
||||
{ name: '采集板SN录入(×6)', passed: true, photos: ['https://picsum.photos/seed/rx1/800/600', 'https://picsum.photos/seed/rx2/800/600', 'https://picsum.photos/seed/rx3/800/600'], note: '6块采集板全部录入完成,SN号已核对一致' },
|
||||
{ name: '发射板安装检查', passed: true, photos: ['https://picsum.photos/seed/tx1/800/600'], note: '发射板安装到位,螺丝紧固力矩达标' },
|
||||
{ name: '升压板安装检查', passed: true, photos: ['https://picsum.photos/seed/bp1/800/600'], note: '升压板安装完成,接线端子牢固' },
|
||||
{ name: '线缆连接检查', passed: true, photos: ['https://picsum.photos/seed/cable1/800/600', 'https://picsum.photos/seed/cable2/800/600'], note: '所有线缆连接正确,无松动现象' },
|
||||
{ name: '整机通电测试', passed: true, photos: ['https://picsum.photos/seed/power1/800/600'], note: '通电正常,各指示灯状态正确' },
|
||||
{ name: 'GPS/北斗模块检测', passed: true, photos: ['https://picsum.photos/seed/gps1/800/600'], note: 'GPS/北斗信号接收正常,定位精度达标' },
|
||||
{ name: 'WiFi通信测试', passed: true, photos: [], note: 'WiFi连接稳定,信号强度 -45dBm' },
|
||||
{ name: '蓝牙通信测试', passed: true, photos: [], note: '蓝牙配对成功,数据传输正常' },
|
||||
{ name: '采集通道校准验证', passed: true, photos: ['https://picsum.photos/seed/cal1/800/600', 'https://picsum.photos/seed/cal2/800/600'], note: '所有采集通道校准偏差 < 0.1%,符合标准' },
|
||||
{ name: '发射电压测试', passed: true, photos: ['https://picsum.photos/seed/volt1/800/600'], note: '发射电压 800V 测试通过,波形正常' },
|
||||
{ name: '电池安装与充电测试', passed: true, photos: ['https://picsum.photos/seed/bat1/800/600'], note: '电池安装到位,充电电流正常' },
|
||||
{ name: 'IP66防护检测', passed: true, photos: ['https://picsum.photos/seed/ip1/800/600', 'https://picsum.photos/seed/ip2/800/600'], note: 'IP66防护等级测试通过,密封良好' },
|
||||
{ name: '固件版本校验', passed: true, photos: [], note: '固件版本 v2.3.5 校验通过' },
|
||||
{ name: '配置文件写入', passed: true, photos: [], note: '配置文件 CFG-GD30-v1.3.0 写入成功' },
|
||||
{ name: '授权文件写入', passed: true, photos: [], note: '全模块授权项写入完成' },
|
||||
{ name: '整机功能测试', passed: true, photos: ['https://picsum.photos/seed/func1/800/600'], note: '整机功能测试全部通过' },
|
||||
{ name: '数据采集验证', passed: true, photos: ['https://picsum.photos/seed/data1/800/600', 'https://picsum.photos/seed/data2/800/600'], note: '数据采集验证通过,采集数据与标准值一致' },
|
||||
{ name: '外观检查', passed: true, photos: ['https://picsum.photos/seed/look1/800/600'], note: '外观无划痕、无变形,表面处理合格' },
|
||||
{ name: '标签粘贴', passed: true, photos: ['https://picsum.photos/seed/label1/800/600'], note: 'SN标签、型号标签、安全标签粘贴完成' },
|
||||
{ name: '配件清点', passed: true, photos: ['https://picsum.photos/seed/acc1/800/600'], note: '配件清单核对完成,数量一致' },
|
||||
{ name: '包装检查', passed: true, photos: ['https://picsum.photos/seed/pack1/800/600', 'https://picsum.photos/seed/pack2/800/600'], note: '包装完好,防震材料到位' },
|
||||
]
|
||||
|
||||
function getStatusStyle(status: string) {
|
||||
switch (status) {
|
||||
case '已激活': return { backgroundColor: '#F6FFED', color: '#52C41A', border: '1px solid #B7EB8F' }
|
||||
|
|
@ -142,11 +29,38 @@ export default function DeviceDetailPage({ params }: { params: Promise<{ sn: str
|
|||
const [lightbox, setLightbox] = useState<{ photos: string[]; index: number } | null>(null)
|
||||
const [licenseEditOpen, setLicenseEditOpen] = useState(false)
|
||||
const [configEditOpen, setConfigEditOpen] = useState(false)
|
||||
const [editModules, setEditModules] = useState<string[]>([])
|
||||
const [editExpiry, setEditExpiry] = useState('')
|
||||
const [device, setDevice] = useState<{ sn: string; model: string; type: string; status: string; firmware: string; production_date: string; customer: string; batch: string } | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [license, setLicense] = useState<{ id?: number; modules: string; expiry: string; status: string } | null>(null)
|
||||
const [bom, setBom] = useState<{ name: string; material_sn: string; model: string; version: string; calibration: string }[]>([])
|
||||
const [deviceLogs, setDeviceLogs] = useState<{ date: string; action: string; operator: string; detail: string }[]>([])
|
||||
const [deviceChecklist, setDeviceChecklist] = useState<{ id: number; name: string; required: number }[]>([])
|
||||
const [deviceConfig, setDeviceConfig] = useState<{ name: string; version: string; create_time: string; voltage: string; current: string; duty_cycle: string; pulse_width: string; channels: number; sample_rate: string; voltage_range: string; waveform: string; ssid: string } | null>(null)
|
||||
|
||||
const device = allDevices.find(d => d.sn === sn)
|
||||
const bom = bomData[sn] || []
|
||||
const license = licenseData[sn]
|
||||
const config = configData[sn]
|
||||
useEffect(() => {
|
||||
fetch(`/api/devices/detail?sn=${encodeURIComponent(sn)}`).then(r => r.json()).then(data => {
|
||||
console.log('Device detail data:', data)
|
||||
console.log('BOM data:', data.bom)
|
||||
if (data.error) { setLoading(false); return }
|
||||
setDevice(data.device)
|
||||
setBom(data.bom || [])
|
||||
setDeviceLogs(data.logs || [])
|
||||
if (data.license) setLicense(data.license)
|
||||
if (data.config) setDeviceConfig(data.config)
|
||||
setDeviceChecklist(data.checklist || [])
|
||||
setLoading(false)
|
||||
}).catch((err) => {
|
||||
console.error('Failed to fetch device detail:', err)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [sn])
|
||||
|
||||
const checklistData: { name: string; passed: boolean; photos: string[]; note: string }[] = deviceChecklist.map(c => ({ name: c.name, passed: false, photos: [], note: '' }))
|
||||
const operationLogs = deviceLogs
|
||||
|
||||
if (loading) return <div style={{ padding: 24 }}>加载中...</div>
|
||||
|
||||
if (!device) {
|
||||
return (
|
||||
|
|
@ -188,7 +102,7 @@ export default function DeviceDetailPage({ params }: { params: Promise<{ sn: str
|
|||
</div>
|
||||
|
||||
{/* Info Cards Row */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 24 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16, marginBottom: 24 }}>
|
||||
<div style={{ backgroundColor: '#fff', borderRadius: 8, padding: 16, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<Package size={16} style={{ color: '#4a7c59' }} />
|
||||
|
|
@ -196,6 +110,20 @@ export default function DeviceDetailPage({ params }: { params: Promise<{ sn: str
|
|||
</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 600, color: '#4a7c59' }}>{bom.length}</div>
|
||||
</div>
|
||||
<div style={{ backgroundColor: '#fff', borderRadius: 8, padding: 16, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<Clock size={16} style={{ color: '#597EF7' }} />
|
||||
<span style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)' }}>操作日志</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 600, color: '#597EF7' }}>{deviceLogs.length}</div>
|
||||
</div>
|
||||
<div style={{ backgroundColor: '#fff', borderRadius: 8, padding: 16, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<FileCode size={16} style={{ color: '#FA8C16' }} />
|
||||
<span style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)' }}>配置文件</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 600, color: '#FA8C16' }}>{deviceConfig ? '已配置' : '未配置'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
|
|
@ -218,10 +146,11 @@ export default function DeviceDetailPage({ params }: { params: Promise<{ sn: str
|
|||
{ label: '设备SN', value: device.sn },
|
||||
{ label: '设备型号', value: device.model },
|
||||
{ label: '设备类型', value: device.type },
|
||||
{ label: '固件版本', value: device.firmware },
|
||||
{ label: '生产日期', value: device.productionDate },
|
||||
{ label: '登记人', value: device.operator },
|
||||
{ label: '设备状态', value: device.status, isStatus: true },
|
||||
{ label: '生产日期', value: device.production_date },
|
||||
{ label: '客户', value: device.customer },
|
||||
{ label: '生产批次', value: device.batch },
|
||||
{ label: '固件版本', value: device.firmware },
|
||||
].map(item => (
|
||||
<div key={item.label}>
|
||||
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 6 }}>{item.label}</div>
|
||||
|
|
@ -259,8 +188,8 @@ export default function DeviceDetailPage({ params }: { params: Promise<{ sn: str
|
|||
<tr key={i} style={{ borderBottom: '1px solid #F0F0F0' }}>
|
||||
<td style={{ padding: '12px 16px', fontSize: 13, color: 'rgba(0,0,0,0.45)' }}>{i + 1}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: 13 }}>{item.name}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: 13, fontWeight: 500 }}>{item.sn}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: 13, color: 'rgba(0,0,0,0.65)' }}>{item.model}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: 13, fontWeight: 500 }}>{item.material_sn || '-'}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: 13, color: 'rgba(0,0,0,0.65)' }}>{item.version || item.model}</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
{item.calibration === '-' ? (
|
||||
<span style={{ fontSize: 12, color: 'rgba(0,0,0,0.25)' }}>无需校准</span>
|
||||
|
|
@ -436,7 +365,7 @@ export default function DeviceDetailPage({ params }: { params: Promise<{ sn: str
|
|||
<div style={{ backgroundColor: '#fff', borderRadius: 8, padding: 24, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>授权项信息</h3>
|
||||
<button onClick={() => setLicenseEditOpen(true)} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 14px', border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer', fontSize: 13, color: '#4a7c59' }}>
|
||||
<button onClick={() => { setEditModules(license?.modules.split(', ').filter(Boolean) || []); setEditExpiry(license?.expiry || ''); setLicenseEditOpen(true) }} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 14px', border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer', fontSize: 13, color: '#4a7c59' }}>
|
||||
<Edit size={14} />修改授权项
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -480,15 +409,15 @@ export default function DeviceDetailPage({ params }: { params: Promise<{ sn: str
|
|||
<div style={{ flex: 1, overflow: 'auto', padding: 24 }}>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<label style={{ display: 'block', fontSize: 14, fontWeight: 500, marginBottom: 8 }}>到期时间</label>
|
||||
<input type="date" defaultValue={license?.expiry || ''} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, boxSizing: 'border-box' }} />
|
||||
<input type="date" value={editExpiry} onChange={e => setEditExpiry(e.target.value)} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: 'block', fontSize: 14, fontWeight: 500, marginBottom: 8 }}>授权模块</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{['一维自电/电阻率/激电测试模块', '二维自电/电阻率/激电测试模块', '三维自电/电阻率/激电测试模块', '水上', '跨孔', '电流法'].map(m => {
|
||||
const isSelected = license?.modules.includes(m) ?? false
|
||||
const isSelected = editModules.includes(m)
|
||||
return (
|
||||
<label key={m} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', borderRadius: 6, backgroundColor: isSelected ? '#eef5f0' : '#FAFAFA', border: isSelected ? '1px solid #a3c4ad' : '1px solid #F0F0F0', cursor: 'pointer' }}>
|
||||
<label key={m} onClick={() => setEditModules(prev => prev.includes(m) ? prev.filter(x => x !== m) : [...prev, m])} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', borderRadius: 6, backgroundColor: isSelected ? '#eef5f0' : '#FAFAFA', border: isSelected ? '1px solid #a3c4ad' : '1px solid #F0F0F0', cursor: 'pointer' }}>
|
||||
<div style={{ width: 16, height: 16, borderRadius: 4, display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: isSelected ? '#4a7c59' : '#fff', border: isSelected ? 'none' : '1px solid #D9D9D9' }}>
|
||||
{isSelected && <Check size={11} color="#fff" />}
|
||||
</div>
|
||||
|
|
@ -501,7 +430,19 @@ export default function DeviceDetailPage({ params }: { params: Promise<{ sn: str
|
|||
</div>
|
||||
<div style={{ padding: '16px 24px', borderTop: '1px solid #F0F0F0', display: 'flex', justifyContent: 'flex-end', gap: 12 }}>
|
||||
<button onClick={() => setLicenseEditOpen(false)} style={{ padding: '8px 20px', border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer', fontSize: 14 }}>取消</button>
|
||||
<button onClick={() => setLicenseEditOpen(false)} style={{ padding: '8px 20px', border: 'none', borderRadius: 6, backgroundColor: '#4a7c59', color: '#fff', cursor: 'pointer', fontSize: 14 }}>保存</button>
|
||||
<button onClick={async () => {
|
||||
const modules = editModules.join(', ')
|
||||
const modelShort = device?.model.replace(' Supreme', '') || ''
|
||||
if (license?.id) {
|
||||
// 暂无 PUT 接口,直接更新本地状态
|
||||
}
|
||||
// 如果没有已有授权,新建一条
|
||||
if (!license) {
|
||||
await fetch('/api/licenses', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: modelShort, modules, expiry: editExpiry, status: '生效' }) })
|
||||
}
|
||||
setLicense({ ...license, modules, expiry: editExpiry, status: '生效' })
|
||||
setLicenseEditOpen(false)
|
||||
}} style={{ padding: '8px 20px', border: 'none', borderRadius: 6, backgroundColor: '#4a7c59', color: '#fff', cursor: 'pointer', fontSize: 14 }}>保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -509,160 +450,69 @@ export default function DeviceDetailPage({ params }: { params: Promise<{ sn: str
|
|||
|
||||
{activeTab === 'config' && (
|
||||
<div>
|
||||
{config ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{/* 基本信息 */}
|
||||
<div style={{ backgroundColor: '#fff', borderRadius: 8, padding: 24, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>配置文件</h3>
|
||||
<button onClick={() => setConfigEditOpen(true)} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 14px', border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer', fontSize: 13, color: '#4a7c59' }}>
|
||||
<Edit size={14} />修改参数
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 24 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 6 }}>配置名称</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500 }}>{config.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 6 }}>版本</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500 }}>{config.version}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 6 }}>写入日期</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500 }}>{config.uploadDate}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 发射参数 */}
|
||||
<div style={{ backgroundColor: '#fff', borderRadius: 8, padding: 24, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, margin: '0 0 16px', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ width: 4, height: 16, borderRadius: 2, backgroundColor: '#4a7c59', display: 'inline-block' }} />
|
||||
发射参数
|
||||
</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 20 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 6 }}>最大发射电压</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500 }}>{config.params.transmission.maxVoltage}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 6 }}>最大发射电流</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500 }}>{config.params.transmission.maxCurrent}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 6 }}>发射波形</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500 }}>{config.params.transmission.waveformPattern}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 6 }}>支持全波形测量</div>
|
||||
<span style={{
|
||||
padding: '2px 10px', borderRadius: 4, fontSize: 12,
|
||||
backgroundColor: config.params.transmission.fullWaveform ? '#F6FFED' : '#FAFAFA',
|
||||
color: config.params.transmission.fullWaveform ? '#52C41A' : 'rgba(0,0,0,0.45)',
|
||||
border: config.params.transmission.fullWaveform ? '1px solid #B7EB8F' : '1px solid #D9D9D9',
|
||||
}}>{config.params.transmission.fullWaveform ? '支持' : '不支持'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 8 }}>发射脉宽</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{config.params.transmission.pulseWidths.map(pw => (
|
||||
<span key={pw} style={{ padding: '3px 10px', borderRadius: 4, fontSize: 12, backgroundColor: '#eef5f0', color: '#4a7c59', border: '1px solid #a3c4ad' }}>{pw}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 8 }}>发射波形</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{config.params.transmission.waveforms.map(wf => (
|
||||
<span key={wf} style={{ padding: '3px 10px', borderRadius: 4, fontSize: 12, backgroundColor: '#f5f5f5', color: 'rgba(0,0,0,0.65)', border: '1px solid #e8e8e8', fontFamily: 'monospace' }}>{wf}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 采集参数 */}
|
||||
<div style={{ backgroundColor: '#fff', borderRadius: 8, padding: 24, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, margin: '0 0 16px', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ width: 4, height: 16, borderRadius: 2, backgroundColor: '#597EF7', display: 'inline-block' }} />
|
||||
采集参数
|
||||
</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 20 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 6 }}>支持通道数</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500 }}>{config.params.acquisition.channels} 通道</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 8 }}>采样率</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{config.params.acquisition.sampleRates.map(sr => (
|
||||
<span key={sr} style={{ padding: '3px 10px', borderRadius: 4, fontSize: 12, backgroundColor: '#F0F5FF', color: '#597EF7', border: '1px solid #ADC6FF' }}>{sr}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 8 }}>电压测量范围</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{config.params.acquisition.voltageRanges.map(vr => (
|
||||
<span key={vr} style={{ padding: '3px 10px', borderRadius: 4, fontSize: 12, backgroundColor: '#F0F5FF', color: '#597EF7', border: '1px solid #ADC6FF' }}>{vr}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 保护参数 */}
|
||||
<div style={{ backgroundColor: '#fff', borderRadius: 8, padding: 24, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, margin: '0 0 16px', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ width: 4, height: 16, borderRadius: 2, backgroundColor: '#FA8C16', display: 'inline-block' }} />
|
||||
保护参数
|
||||
</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16 }}>
|
||||
{[
|
||||
{ label: '过压保护', enabled: config.params.protection.overVoltage.enabled, value: config.params.protection.overVoltage.threshold },
|
||||
{ label: '过流保护', enabled: config.params.protection.overCurrent.enabled, value: config.params.protection.overCurrent.threshold },
|
||||
{ label: '短路保护', enabled: config.params.protection.shortCircuit, value: null },
|
||||
{ label: '高温保护', enabled: config.params.protection.overTemp.enabled, value: config.params.protection.overTemp.threshold },
|
||||
].map(item => (
|
||||
<div key={item.label} style={{ padding: 14, borderRadius: 8, backgroundColor: item.enabled ? '#FFFBE6' : '#FAFAFA', border: item.enabled ? '1px solid #FFE58F' : '1px solid #F0F0F0' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: item.enabled ? '#52C41A' : '#D9D9D9' }} />
|
||||
<span style={{ fontSize: 13, color: 'rgba(0,0,0,0.65)' }}>{item.label}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: item.enabled ? 'rgba(0,0,0,0.85)' : 'rgba(0,0,0,0.25)' }}>
|
||||
{item.enabled ? (item.value ? `阈值 ${item.value}` : '已启用') : '未启用'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 网络参数 */}
|
||||
<div style={{ backgroundColor: '#fff', borderRadius: 8, padding: 24, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, margin: '0 0 16px', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ width: 4, height: 16, borderRadius: 2, backgroundColor: '#52C41A', display: 'inline-block' }} />
|
||||
网络参数
|
||||
</h3>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 6 }}>WiFi SSID 前缀</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, fontFamily: 'monospace', padding: '4px 10px', backgroundColor: '#f5f5f5', borderRadius: 4, display: 'inline-block' }}>{config.params.network.wifiSsidPrefix}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
{deviceConfig ? (
|
||||
<div style={{ backgroundColor: '#fff', borderRadius: 8, padding: 24, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600, margin: '0 0 20px' }}>配置文件</h3>
|
||||
<div style={{ padding: 40, textAlign: 'center' }}>
|
||||
<FileCode size={32} style={{ color: 'rgba(0,0,0,0.15)', marginBottom: 12 }} />
|
||||
<div style={{ fontSize: 14, color: 'rgba(0,0,0,0.25)' }}>该设备暂未写入配置文件</div>
|
||||
<button onClick={() => setConfigEditOpen(true)} style={{ marginTop: 16, padding: '8px 20px', border: 'none', borderRadius: 6, backgroundColor: '#4a7c59', color: '#fff', cursor: 'pointer', fontSize: 14 }}>配置参数</button>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 24, marginBottom: 24 }}>
|
||||
<div><div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 6 }}>配置名称</div><div style={{ fontSize: 14, fontWeight: 500 }}>{deviceConfig.name}</div></div>
|
||||
<div><div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 6 }}>版本</div><div style={{ fontSize: 14, fontWeight: 500 }}>{deviceConfig.version}</div></div>
|
||||
<div><div style={{ fontSize: 13, color: 'rgba(0,0,0,0.45)', marginBottom: 6 }}>创建时间</div><div style={{ fontSize: 14 }}>{deviceConfig.create_time}</div></div>
|
||||
</div>
|
||||
<h4 style={{ fontSize: 14, fontWeight: 600, marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #F0F0F0' }}>发射参数</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 16, marginBottom: 24, fontSize: 13 }}>
|
||||
<div><span style={{ color: 'rgba(0,0,0,0.45)' }}>最大发射电压:</span>{deviceConfig.voltage}</div>
|
||||
<div><span style={{ color: 'rgba(0,0,0,0.45)' }}>最大发射电流:</span>{deviceConfig.current}</div>
|
||||
<div><span style={{ color: 'rgba(0,0,0,0.45)' }}>发射波形:</span>{deviceConfig.duty_cycle}</div>
|
||||
<div><span style={{ color: 'rgba(0,0,0,0.45)' }}>脉宽范围:</span>{deviceConfig.pulse_width}</div>
|
||||
</div>
|
||||
<h4 style={{ fontSize: 14, fontWeight: 600, marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #F0F0F0' }}>采集参数</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 16, marginBottom: 24, fontSize: 13 }}>
|
||||
<div><span style={{ color: 'rgba(0,0,0,0.45)' }}>支持通道数:</span>{deviceConfig.channels}</div>
|
||||
<div><span style={{ color: 'rgba(0,0,0,0.45)' }}>采样率:</span>{deviceConfig.sample_rate}</div>
|
||||
<div><span style={{ color: 'rgba(0,0,0,0.45)' }}>电压测量量程:</span>{deviceConfig.voltage_range}</div>
|
||||
<div><span style={{ color: 'rgba(0,0,0,0.45)' }}>全波形采集:</span>{deviceConfig.waveform}</div>
|
||||
</div>
|
||||
{deviceConfig.ssid && (
|
||||
<>
|
||||
<h4 style={{ fontSize: 14, fontWeight: 600, marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #F0F0F0' }}>网络参数</h4>
|
||||
<div style={{ fontSize: 13 }}><span style={{ color: 'rgba(0,0,0,0.45)' }}>WiFi SSID 前缀:</span>{deviceConfig.ssid}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ backgroundColor: '#fff', borderRadius: 8, padding: 48, textAlign: 'center', boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
|
||||
<div style={{ fontSize: 14, color: 'rgba(0,0,0,0.25)' }}>该设备暂未关联配置文件</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{activeTab === 'logs' && (
|
||||
<div style={{ backgroundColor: '#fff', borderRadius: 8, padding: 24, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600, margin: '0 0 20px' }}>操作日志</h3>
|
||||
<div style={{ position: 'relative', paddingLeft: 24 }}>
|
||||
<div style={{ position: 'absolute', left: 7, top: 8, bottom: 8, width: 2, backgroundColor: '#F0F0F0' }} />
|
||||
{operationLogs.map((log, i) => (
|
||||
<div key={i} style={{ position: 'relative', paddingBottom: i < operationLogs.length - 1 ? 24 : 0 }}>
|
||||
{/* Timeline dot */}
|
||||
<div style={{ position: 'absolute', left: -20, top: 6, width: 12, height: 12, borderRadius: '50%', backgroundColor: i === 0 ? '#4a7c59' : '#D9D9D9', border: '2px solid #fff' }} />
|
||||
<div style={{ marginLeft: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 500, color: 'rgba(0,0,0,0.85)' }}>{log.action}</span>
|
||||
<span style={{ fontSize: 12, padding: '1px 8px', borderRadius: 4, backgroundColor: '#FAFAFA', color: 'rgba(0,0,0,0.45)', border: '1px solid #F0F0F0' }}>{log.operator}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.65)', marginBottom: 2 }}>{log.detail}</div>
|
||||
<div style={{ fontSize: 12, color: 'rgba(0,0,0,0.35)', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Clock size={12} />{log.date}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config Edit Drawer */}
|
||||
{configEditOpen && (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 50 }}>
|
||||
|
|
@ -673,21 +523,21 @@ export default function DeviceDetailPage({ params }: { params: Promise<{ sn: str
|
|||
<button onClick={() => setConfigEditOpen(false)} style={{ border: 'none', background: 'none', cursor: 'pointer', padding: 4 }}><X size={20} /></button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: 24 }}>
|
||||
{config && (
|
||||
{deviceConfig && (
|
||||
<>
|
||||
<div style={{ marginBottom: 8, fontSize: 13, color: 'rgba(0,0,0,0.45)' }}>当前配置:{config.name} ({config.version})</div>
|
||||
<div style={{ marginBottom: 8, fontSize: 13, color: 'rgba(0,0,0,0.45)' }}>当前配置:{deviceConfig.name} ({deviceConfig.version})</div>
|
||||
|
||||
<h4 style={{ fontSize: 14, fontWeight: 600, marginBottom: 12, marginTop: 16, paddingBottom: 8, borderBottom: '1px solid #F0F0F0' }}>发射参数</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 20 }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 13, color: 'rgba(0,0,0,0.65)', marginBottom: 6 }}>最大发射电压</label>
|
||||
<select defaultValue={config.params.transmission.maxVoltage} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14 }}>
|
||||
<select defaultValue={deviceConfig.voltage} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14 }}>
|
||||
{['500V', '800V', '1000V', '1200V', '1500V'].map(v => <option key={v} value={v}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 13, color: 'rgba(0,0,0,0.65)', marginBottom: 6 }}>最大发射电流</label>
|
||||
<select defaultValue={config.params.transmission.maxCurrent} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14 }}>
|
||||
<select defaultValue={deviceConfig.current} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14 }}>
|
||||
{['2A', '5A', '8A', '10A', '15A'].map(v => <option key={v} value={v}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
|
@ -695,7 +545,7 @@ export default function DeviceDetailPage({ params }: { params: Promise<{ sn: str
|
|||
<label style={{ display: 'block', fontSize: 13, color: 'rgba(0,0,0,0.65)', marginBottom: 6 }}>发射波形</label>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{['0+0-', '+0-0', '+-'].map(w => {
|
||||
const selected = config.params.transmission.waveforms.includes(w)
|
||||
const selected = deviceConfig.duty_cycle === w
|
||||
return (
|
||||
<span key={w} style={{ padding: '6px 14px', borderRadius: 6, fontSize: 13, border: selected ? '1px solid #4a7c59' : '1px solid #D9D9D9', backgroundColor: selected ? '#eef5f0' : '#fff', color: selected ? '#4a7c59' : 'rgba(0,0,0,0.65)' }}>{w}</span>
|
||||
)
|
||||
|
|
@ -704,7 +554,7 @@ export default function DeviceDetailPage({ params }: { params: Promise<{ sn: str
|
|||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: 'rgba(0,0,0,0.65)', marginBottom: 6 }}>
|
||||
<input type="checkbox" defaultChecked={config.params.transmission.fullWaveform} />全波形测量
|
||||
<input type="checkbox" defaultChecked={deviceConfig.waveform === '支持'} />全波形测量
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -713,7 +563,7 @@ export default function DeviceDetailPage({ params }: { params: Promise<{ sn: str
|
|||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 20 }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 13, color: 'rgba(0,0,0,0.65)', marginBottom: 6 }}>支持通道数</label>
|
||||
<select defaultValue={String(config.params.acquisition.channels)} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14 }}>
|
||||
<select defaultValue={String(deviceConfig.channels)} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14 }}>
|
||||
{['1', '6', '12'].map(v => <option key={v} value={v}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
|
@ -723,33 +573,33 @@ export default function DeviceDetailPage({ params }: { params: Promise<{ sn: str
|
|||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 20 }}>
|
||||
<div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: 'rgba(0,0,0,0.65)', marginBottom: 6 }}>
|
||||
<input type="checkbox" defaultChecked={config.params.protection.overVoltage.enabled} />过压保护
|
||||
<input type="checkbox" defaultChecked={true} />过压保护
|
||||
</label>
|
||||
<input defaultValue={config.params.protection.overVoltage.threshold} placeholder="阈值" style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, boxSizing: 'border-box' }} />
|
||||
<input defaultValue="800V" placeholder="阈值" style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: 'rgba(0,0,0,0.65)', marginBottom: 6 }}>
|
||||
<input type="checkbox" defaultChecked={config.params.protection.overCurrent.enabled} />过流保护
|
||||
<input type="checkbox" defaultChecked={true} />过流保护
|
||||
</label>
|
||||
<input defaultValue={config.params.protection.overCurrent.threshold} placeholder="阈值" style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, boxSizing: 'border-box' }} />
|
||||
<input defaultValue="10A" placeholder="阈值" style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: 'rgba(0,0,0,0.65)' }}>
|
||||
<input type="checkbox" defaultChecked={config.params.protection.shortCircuit} />短路保护
|
||||
<input type="checkbox" defaultChecked={true} />短路保护
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: 'rgba(0,0,0,0.65)', marginBottom: 6 }}>
|
||||
<input type="checkbox" defaultChecked={config.params.protection.overTemp.enabled} />高温保护
|
||||
<input type="checkbox" defaultChecked={true} />高温保护
|
||||
</label>
|
||||
<input defaultValue={config.params.protection.overTemp.threshold} placeholder="阈值" style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, boxSizing: 'border-box' }} />
|
||||
<input defaultValue="75°C" placeholder="阈值" style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 style={{ fontSize: 14, fontWeight: 600, marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #F0F0F0' }}>网络参数</h4>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, color: 'rgba(0,0,0,0.65)', marginBottom: 6 }}>WiFi SSID 前缀</label>
|
||||
<input defaultValue={config.params.network.wifiSsidPrefix} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, boxSizing: 'border-box' }} />
|
||||
<input defaultValue={deviceConfig.ssid || ''} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
import { useState, useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Download, Plus, ChevronLeft, ChevronRight, Monitor, Cpu, Wifi, Power, Tag } from 'lucide-react'
|
||||
import { Download, Plus, ChevronLeft, ChevronRight, Monitor, Cpu, Wifi, Power, Tag, Trash2 } from 'lucide-react'
|
||||
import { useApi } from '@/lib/hooks'
|
||||
|
||||
interface Device {
|
||||
|
|
@ -62,7 +62,7 @@ function getStatusIcon(status: string) {
|
|||
}
|
||||
|
||||
export default function DevicesPage() {
|
||||
const { data: devicesData, loading } = useApi<Device[]>('/api/devices', [])
|
||||
const { data: devicesData, loading, refetch } = useApi<Device[]>('/api/devices', [])
|
||||
const [filterModel, setFilterModel] = useState('全部')
|
||||
const [filterStatus, setFilterStatus] = useState('全部')
|
||||
const [filterDate, setFilterDate] = useState('')
|
||||
|
|
@ -238,14 +238,16 @@ export default function DevicesPage() {
|
|||
<Link href={`/devices/${device.sn}`} style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4, padding: '10px 0', fontSize: 13, color: '#4a7c59', textDecoration: 'none', cursor: 'pointer' }}>
|
||||
详情
|
||||
</Link>
|
||||
{device.status === '已激活' && (
|
||||
<>
|
||||
<div style={{ width: 1, backgroundColor: '#F0F0F0' }} />
|
||||
<button style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4, padding: '10px 0', fontSize: 13, color: '#FF4D4F', border: 'none', background: 'none', cursor: 'pointer' }}>
|
||||
<Power size={13} />下线
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div style={{ width: 1, backgroundColor: '#F0F0F0' }} />
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '6px 8px' }}>
|
||||
<select value={device.status} onChange={async (e) => { await fetch('/api/devices', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: device.id, status: e.target.value }) }); refetch() }} style={{ border: 'none', background: 'none', fontSize: 13, color: '#4a7c59', cursor: 'pointer', outline: 'none' }}>
|
||||
{['装配中', '已激活', '已出厂'].map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ width: 1, backgroundColor: '#F0F0F0' }} />
|
||||
<button onClick={async () => { await fetch('/api/devices', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: device.id }) }); refetch() }} style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4, padding: '10px 0', fontSize: 13, color: '#FF4D4F', border: 'none', background: 'none', cursor: 'pointer' }}>
|
||||
<Trash2 size={13} />删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,167 @@
|
|||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { useApi } from '@/lib/hooks'
|
||||
import { Plus, X, BookOpen, Sparkles, Bug, Zap, Trash2, Clock } from 'lucide-react'
|
||||
|
||||
interface UpdateLog {
|
||||
id: number
|
||||
title: string
|
||||
content: string
|
||||
category: string
|
||||
version: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const CATEGORY_MAP: Record<string, { label: string; color: string; bg: string; border: string; icon: typeof Sparkles }> = {
|
||||
feature: { label: '新功能', color: '#52C41A', bg: '#F6FFED', border: '#B7EB8F', icon: Sparkles },
|
||||
bugfix: { label: 'Bug修复', color: '#FF4D4F', bg: '#FFF1F0', border: '#FFCCC7', icon: Bug },
|
||||
improvement: { label: '优化改进', color: '#597EF7', bg: '#F0F5FF', border: '#ADC6FF', icon: Zap },
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
if (!dateStr) return '-'
|
||||
const d = new Date(dateStr)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export default function HelpPage() {
|
||||
const { data: logs, loading, refetch } = useApi<UpdateLog[]>('/api/update-logs', [])
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [form, setForm] = useState({ title: '', content: '', category: 'feature', version: '' })
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!form.title.trim()) return
|
||||
await fetch('/api/update-logs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
setForm({ title: '', content: '', category: 'feature', version: '' })
|
||||
setDrawerOpen(false)
|
||||
refetch()
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('确定删除这条更新日志?')) return
|
||||
await fetch(`/api/update-logs?id=${id}`, { method: 'DELETE' })
|
||||
refetch()
|
||||
}
|
||||
|
||||
const groupByDate = (logs: UpdateLog[]) => {
|
||||
const groups: Record<string, UpdateLog[]> = {}
|
||||
for (const log of logs) {
|
||||
const key = log.created_at ? log.created_at.substring(0, 7) : '未知'
|
||||
if (!groups[key]) groups[key] = []
|
||||
groups[key].push(log)
|
||||
}
|
||||
return Object.entries(groups).sort((a, b) => b[0].localeCompare(a[0]))
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 600, margin: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<BookOpen size={22} style={{ color: '#4a7c59' }} />
|
||||
帮助中心
|
||||
</h2>
|
||||
<p style={{ fontSize: 14, color: 'rgba(0,0,0,0.45)', margin: '4px 0 0' }}>查看系统更新日志和使用说明</p>
|
||||
</div>
|
||||
<button onClick={() => setDrawerOpen(true)} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px', border: 'none', borderRadius: 6, backgroundColor: '#4a7c59', color: '#fff', cursor: 'pointer', fontSize: 14 }}>
|
||||
<Plus size={16} />新增更新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 更新日志 */}
|
||||
<div style={{ backgroundColor: '#fff', borderRadius: 8, boxShadow: '0 1px 2px rgba(0,0,0,0.05)', padding: '20px 24px' }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, margin: '0 0 20px', paddingBottom: 12, borderBottom: '1px solid #F0F0F0' }}>更新日志</h3>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ padding: 40, textAlign: 'center', color: 'rgba(0,0,0,0.25)' }}>加载中...</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div style={{ padding: 40, textAlign: 'center', color: 'rgba(0,0,0,0.25)' }}>
|
||||
<Clock size={32} style={{ marginBottom: 8, color: 'rgba(0,0,0,0.15)' }} />
|
||||
<div>暂无更新日志</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
{groupByDate(logs).map(([month, items]) => (
|
||||
<div key={month}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'rgba(0,0,0,0.45)', marginBottom: 12, paddingLeft: 4 }}>{month}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{items.map(log => {
|
||||
const cat = CATEGORY_MAP[log.category] || CATEGORY_MAP.feature
|
||||
const CatIcon = cat.icon
|
||||
return (
|
||||
<div key={log.id} style={{ display: 'flex', gap: 12, padding: 14, borderRadius: 8, border: '1px solid #F0F0F0', backgroundColor: '#FAFAFA' }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 8, backgroundColor: cat.bg, border: `1px solid ${cat.border}`, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<CatIcon size={16} style={{ color: cat.color }} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 500 }}>{log.title}</span>
|
||||
<span style={{ padding: '1px 8px', borderRadius: 4, fontSize: 11, backgroundColor: cat.bg, color: cat.color, border: `1px solid ${cat.border}` }}>{cat.label}</span>
|
||||
{log.version && <span style={{ fontSize: 12, color: 'rgba(0,0,0,0.35)' }}>v{log.version}</span>}
|
||||
</div>
|
||||
{log.content && <div style={{ fontSize: 13, color: 'rgba(0,0,0,0.65)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{log.content}</div>}
|
||||
<div style={{ fontSize: 12, color: 'rgba(0,0,0,0.35)', marginTop: 6 }}>{formatDate(log.created_at)}</div>
|
||||
</div>
|
||||
<button onClick={() => handleDelete(log.id)} style={{ border: 'none', background: 'none', cursor: 'pointer', color: 'rgba(0,0,0,0.25)', padding: 4, flexShrink: 0, alignSelf: 'flex-start' }}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 新增抽屉 */}
|
||||
{drawerOpen && (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 50 }}>
|
||||
<div onClick={() => setDrawerOpen(false)} style={{ position: 'absolute', inset: 0, backgroundColor: 'rgba(0,0,0,0.45)' }} />
|
||||
<div style={{ position: 'absolute', right: 0, top: 0, bottom: 0, width: 480, backgroundColor: '#fff', boxShadow: '-2px 0 8px rgba(0,0,0,0.15)', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px 24px', borderBottom: '1px solid #F0F0F0' }}>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>新增更新日志</h3>
|
||||
<button onClick={() => setDrawerOpen(false)} style={{ border: 'none', background: 'none', cursor: 'pointer', padding: 4 }}><X size={20} /></button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: 24 }}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}><span style={{ color: '#FF4D4F' }}>*</span> 标题</label>
|
||||
<input value={form.title} onChange={e => setForm({ ...form, title: e.target.value })} placeholder="如:新增设备导入功能" style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}>版本号</label>
|
||||
<input value={form.version} onChange={e => setForm({ ...form, version: e.target.value })} placeholder="如:1.2.0" style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}>分类</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{Object.entries(CATEGORY_MAP).map(([key, c]) => {
|
||||
const Icon = c.icon
|
||||
return (
|
||||
<button key={key} onClick={() => setForm({ ...form, category: key })} style={{ flex: 1, padding: '8px 0', borderRadius: 6, fontSize: 13, cursor: 'pointer', border: form.category === key ? `1px solid ${c.color}` : '1px solid #D9D9D9', backgroundColor: form.category === key ? c.bg : '#fff', color: form.category === key ? c.color : 'rgba(0,0,0,0.65)', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4 }}>
|
||||
<Icon size={14} />{c.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}>内容描述</label>
|
||||
<textarea value={form.content} onChange={e => setForm({ ...form, content: e.target.value })} rows={5} placeholder="详细描述本次更新的内容..." style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, boxSizing: 'border-box', resize: 'vertical' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '16px 24px', borderTop: '1px solid #F0F0F0', display: 'flex', justifyContent: 'flex-end', gap: 12 }}>
|
||||
<button onClick={() => setDrawerOpen(false)} style={{ padding: '8px 20px', border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer', fontSize: 14 }}>取消</button>
|
||||
<button onClick={handleAdd} disabled={!form.title.trim()} style={{ padding: '8px 20px', border: 'none', borderRadius: 6, backgroundColor: form.title.trim() ? '#4a7c59' : '#D9D9D9', color: '#fff', cursor: form.title.trim() ? 'pointer' : 'not-allowed', fontSize: 14 }}>保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,10 +1,17 @@
|
|||
'use client'
|
||||
import { useState, Suspense } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Download, Plus, Info, ChevronLeft, ChevronRight, X, Check, ArrowLeft } from 'lucide-react'
|
||||
import { Download, Plus, Info, ChevronLeft, ChevronRight, X, Check, ArrowLeft, Eye, FileJson } from 'lucide-react'
|
||||
import { useApi } from '@/lib/hooks'
|
||||
|
||||
interface LicenseItem { id: number; model: string; modules: string; expiry: string; status: string }
|
||||
interface LicenseItem {
|
||||
id: number;
|
||||
model: string;
|
||||
modules: string;
|
||||
expiry: string;
|
||||
status: string;
|
||||
license_file?: string;
|
||||
}
|
||||
interface DeviceModel { id: number; name: string; code: string; status: string }
|
||||
|
||||
const allAuthItems = [
|
||||
|
|
@ -51,6 +58,7 @@ function LicensesContent() {
|
|||
const [drawerExpiry, setDrawerExpiry] = useState('1year')
|
||||
const [drawerCustomDate, setDrawerCustomDate] = useState('')
|
||||
const [selectedItems, setSelectedItems] = useState<string[]>([])
|
||||
const [previewLicense, setPreviewLicense] = useState<{ data: any; modelName: string } | null>(null)
|
||||
const pageSize = 5
|
||||
|
||||
if (loading) return <div style={{ padding: 24 }}>加载中...</div>
|
||||
|
|
@ -157,7 +165,36 @@ function LicensesContent() {
|
|||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm" style={{ borderBottom: '1px solid #F0F0F0' }}>
|
||||
<button className="text-sm" style={{ color: '#4a7c59', border: 'none', background: 'none', cursor: 'pointer' }}>编辑</button>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const response = await fetch(`/api/licenses/${row.id}/preview`)
|
||||
const data = await response.json()
|
||||
setPreviewLicense({ data, modelName: row.model })
|
||||
}}
|
||||
className="text-sm flex items-center gap-1"
|
||||
style={{ color: '#597EF7', border: 'none', background: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
<Eye size={14} />预览
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const blob = new Blob([JSON.stringify(JSON.parse(row.license_file || '{}'), null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `license_${row.model.replace(/\s+/g, '_')}_${row.id}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}}
|
||||
className="text-sm flex items-center gap-1"
|
||||
style={{ color: '#4a7c59', border: 'none', background: 'none', cursor: 'pointer' }}
|
||||
disabled={!row.license_file}
|
||||
>
|
||||
<FileJson size={14} />下载
|
||||
</button>
|
||||
<button className="text-sm" style={{ color: '#FAAD14', border: 'none', background: 'none', cursor: 'pointer' }}>编辑</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
@ -254,6 +291,57 @@ function LicensesContent() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* License Preview Modal */}
|
||||
{previewLicense && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0" style={{ backgroundColor: 'rgba(0,0,0,0.45)' }} onClick={() => setPreviewLicense(null)} />
|
||||
<div className="relative bg-white rounded-lg shadow-xl" style={{ width: 800, maxHeight: '80vh', display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="flex items-center justify-between px-6 py-4" style={{ borderBottom: '1px solid #F0F0F0' }}>
|
||||
<h3 className="text-lg font-semibold">授权文件预览 - {previewLicense.modelName}</h3>
|
||||
<button onClick={() => setPreviewLicense(null)}><X size={20} style={{ color: 'rgba(0,0,0,0.45)' }} /></button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<pre style={{
|
||||
backgroundColor: '#FAFAFA',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
overflow: 'auto',
|
||||
maxHeight: '60vh',
|
||||
border: '1px solid #F0F0F0'
|
||||
}}>
|
||||
{JSON.stringify(previewLicense.data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="px-6 py-4" style={{ borderTop: '1px solid #F0F0F0', display: 'flex', justifyContent: 'flex-end', gap: 12 }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
const blob = new Blob([JSON.stringify(previewLicense.data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `license_${previewLicense.modelName.replace(/\s+/g, '_')}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm text-white"
|
||||
style={{ backgroundColor: '#4a7c59' }}
|
||||
>
|
||||
<Download size={16} />下载JSON
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPreviewLicense(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm"
|
||||
style={{ border: '1px solid #D9D9D9', backgroundColor: '#fff' }}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import { useRouter, useSearchParams } from 'next/navigation'
|
|||
import { ArrowLeft, Plus, Trash2, X, Info, CheckCircle } from 'lucide-react'
|
||||
import { useApi } from '@/lib/hooks'
|
||||
|
||||
interface CategoryItem { id: number; name: string; status: string }
|
||||
interface CategoryItem { id: number; name: string; status: string; has_calibration: number }
|
||||
interface MaterialType { id: number; name: string; category: string; deviceModels: string[]; description: string; status: string }
|
||||
interface VersionItem { id: number; type: string; version: string; status: string }
|
||||
|
||||
interface BomItem {
|
||||
id: number
|
||||
|
|
@ -35,6 +37,8 @@ function BomContent() {
|
|||
const modelCode = searchParams.get('model') || 'GD30'
|
||||
const modelName = modelNames[modelCode] || modelCode
|
||||
const { data: categoriesData } = useApi<CategoryItem[]>('/api/material-categories', [])
|
||||
const { data: materialTypes } = useApi<MaterialType[]>('/api/material-types', [])
|
||||
const { data: versionsData } = useApi<VersionItem[]>('/api/material-versions', [])
|
||||
|
||||
const [bomList, setBomList] = useState<BomItem[]>([])
|
||||
const [addDrawer, setAddDrawer] = useState(false)
|
||||
|
|
@ -55,7 +59,7 @@ function BomContent() {
|
|||
await fetch('/api/models/bom', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model_code: modelCode, name: addForm.name, material_name: addForm.materialName, model: addForm.model,
|
||||
model_code: modelCode, name: addForm.name, material_name: addForm.materialName, model: addForm.materialName,
|
||||
versions: addForm.versions.split(',').map(v => v.trim()).filter(Boolean),
|
||||
qty: addForm.qty, required: addForm.required, need_calibration: addForm.needCalibration, enforce_version_match: addForm.enforceVersionMatch,
|
||||
})
|
||||
|
|
@ -110,7 +114,7 @@ function BomContent() {
|
|||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ backgroundColor: '#FAFAFA' }}>
|
||||
{['物料名称', '物料分类', '物料类型', '兼容版本', '数量', '必需', '版本约束', '需校准', '操作'].map(h => (
|
||||
{['物料名称', '物料分类', '兼容版本', '数量', '必需', '版本约束', '需校准', '操作'].map(h => (
|
||||
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: 13, fontWeight: 600, color: 'rgba(0,0,0,0.85)', borderBottom: '1px solid #F0F0F0' }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
|
|
@ -120,7 +124,6 @@ function BomContent() {
|
|||
<tr key={item.id} style={{ borderBottom: '1px solid #F0F0F0' }}>
|
||||
<td style={{ padding: '12px 16px', fontSize: 14, fontWeight: 500 }}>{item.material_name || item.name}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: 14, color: 'rgba(0,0,0,0.65)' }}>{item.name}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: 14, color: 'rgba(0,0,0,0.65)' }}>{item.model}</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{item.versions.map(v => (
|
||||
|
|
@ -165,23 +168,40 @@ function BomContent() {
|
|||
<div style={{ flex: 1, overflow: 'auto', padding: 24 }}>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<label style={{ display: 'block', fontSize: 14, fontWeight: 500, marginBottom: 8 }}><span style={{ color: '#FF4D4F' }}>*</span> 物料分类</label>
|
||||
<select value={addForm.name} onChange={e => setAddForm({ ...addForm, name: e.target.value })} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14 }}>
|
||||
<select value={addForm.name} onChange={e => {
|
||||
const cat = e.target.value
|
||||
const types = materialTypes.filter(t => t.category === cat)
|
||||
const catInfo = categoriesData.find(c => c.name === cat)
|
||||
setAddForm({ ...addForm, name: cat, materialName: types[0]?.name || '', model: '', versions: '', needCalibration: !!catInfo?.has_calibration })
|
||||
}} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14 }}>
|
||||
<option value="">请选择</option>
|
||||
{categoriesData.filter(c => c.status === '启用').map(c => <option key={c.name} value={c.name}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<label style={{ display: 'block', fontSize: 14, fontWeight: 500, marginBottom: 8 }}><span style={{ color: '#FF4D4F' }}>*</span> 物料名称</label>
|
||||
<input value={addForm.materialName} onChange={e => setAddForm({ ...addForm, materialName: e.target.value })} placeholder="如 GD30 主协板" style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, boxSizing: 'border-box' }} />
|
||||
<select value={addForm.materialName} onChange={e => setAddForm({ ...addForm, materialName: e.target.value })} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14 }}>
|
||||
<option value="">请选择</option>
|
||||
{materialTypes.filter(t => t.category === addForm.name && t.status === '启用').map(t => <option key={t.id} value={t.name}>{t.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<label style={{ display: 'block', fontSize: 14, fontWeight: 500, marginBottom: 8 }}><span style={{ color: '#FF4D4F' }}>*</span> 物料类型</label>
|
||||
<input value={addForm.model} onChange={e => setAddForm({ ...addForm, model: e.target.value })} placeholder="如 MCB-3000" style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<label style={{ display: 'block', fontSize: 14, fontWeight: 500, marginBottom: 8 }}>兼容版本(逗号分隔)</label>
|
||||
<input value={addForm.versions} onChange={e => setAddForm({ ...addForm, versions: e.target.value })} placeholder="如 MB-V2.1, MB-V1.8" style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, boxSizing: 'border-box' }} />
|
||||
<div style={{ fontSize: 12, color: 'rgba(0,0,0,0.45)', marginTop: 4 }}>支持多个版本,用逗号分隔</div>
|
||||
<label style={{ display: 'block', fontSize: 14, fontWeight: 500, marginBottom: 8 }}>兼容版本</label>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{versionsData.filter(v => v.type === addForm.name && v.status === '在产').map(v => {
|
||||
const selected = addForm.versions.split(',').map(s => s.trim()).filter(Boolean)
|
||||
const isSelected = selected.includes(v.version)
|
||||
return (
|
||||
<button key={v.id} onClick={() => {
|
||||
const vers = isSelected ? selected.filter(s => s !== v.version) : [...selected, v.version]
|
||||
setAddForm({ ...addForm, versions: vers.join(', ') })
|
||||
}} style={{ padding: '4px 12px', borderRadius: 6, fontSize: 13, cursor: 'pointer', border: isSelected ? '1px solid #4a7c59' : '1px solid #D9D9D9', backgroundColor: isSelected ? '#eef5f0' : '#fff', color: isSelected ? '#4a7c59' : 'rgba(0,0,0,0.65)' }}>{v.version}</button>
|
||||
)
|
||||
})}
|
||||
{versionsData.filter(v => v.type === addForm.name && v.status === '在产').length === 0 && (
|
||||
<span style={{ fontSize: 13, color: 'rgba(0,0,0,0.25)' }}>该分类暂无版本,请先在物料管理中添加</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<label style={{ display: 'block', fontSize: 14, fontWeight: 500, marginBottom: 8 }}>数量</label>
|
||||
|
|
@ -203,7 +223,7 @@ function BomContent() {
|
|||
</div>
|
||||
<div style={{ padding: '16px 24px', borderTop: '1px solid #F0F0F0', display: 'flex', justifyContent: 'flex-end', gap: 12 }}>
|
||||
<button onClick={() => setAddDrawer(false)} style={{ padding: '8px 20px', border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer', fontSize: 14 }}>取消</button>
|
||||
<button onClick={handleAdd} disabled={!addForm.name || !addForm.model} style={{ padding: '8px 20px', border: 'none', borderRadius: 6, backgroundColor: addForm.name && addForm.model ? '#4a7c59' : '#D9D9D9', color: '#fff', cursor: addForm.name && addForm.model ? 'pointer' : 'not-allowed', fontSize: 14 }}>添加</button>
|
||||
<button onClick={handleAdd} disabled={!addForm.name || !addForm.materialName} style={{ padding: '8px 20px', border: 'none', borderRadius: 6, backgroundColor: addForm.name && addForm.materialName ? '#4a7c59' : '#D9D9D9', color: '#fff', cursor: addForm.name && addForm.materialName ? 'pointer' : 'not-allowed', fontSize: 14 }}>添加</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,83 +1,77 @@
|
|||
'use client'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { ArrowLeft, Upload, Download, Trash2, Edit, CheckCircle, Camera, X, AlertTriangle, Info, ScanLine, QrCode } from 'lucide-react'
|
||||
import { useApi } from '@/lib/hooks'
|
||||
|
||||
const modelMatchInfo: Record<string, { license: string; config: string; firmware: string }> = {
|
||||
'GD-30 Supreme': { license: 'LIC-GD30-全模块授权', config: 'CFG-GD30-v1.3.0', firmware: 'v2.3.5' },
|
||||
'GD-20 Supreme': { license: 'LIC-GD20-标准授权', config: 'CFG-GD20-v1.1.0', firmware: 'v1.8.5' },
|
||||
'GD-10 Supreme': { license: 'LIC-GD10-基础授权', config: 'CFG-GD10-v1.0.0', firmware: 'v1.5.2' },
|
||||
}
|
||||
|
||||
/** 每个型号可选的配置文件列表 */
|
||||
const modelConfigOptions: Record<string, { name: string; version: string; status: string }[]> = {
|
||||
'GD-30 Supreme': [
|
||||
{ name: 'CFG-GD30-v1.3.0', version: 'v1.3.0', status: '生效' },
|
||||
{ name: 'CFG-GD30-v1.2.0', version: 'v1.2.0', status: '生效' },
|
||||
{ name: 'CFG-GD30-v1.1.0', version: 'v1.1.0', status: '已停用' },
|
||||
],
|
||||
'GD-20 Supreme': [
|
||||
{ name: 'CFG-GD20-v1.1.0', version: 'v1.1.0', status: '生效' },
|
||||
{ name: 'CFG-GD20-v1.0.0', version: 'v1.0.0', status: '生效' },
|
||||
],
|
||||
'GD-10 Supreme': [
|
||||
{ name: 'CFG-GD10-v1.0.0', version: 'v1.0.0', status: '生效' },
|
||||
],
|
||||
}
|
||||
|
||||
const defaultBOM = [
|
||||
{ id: 1, code: 'MB-2024-001', name: '主协板', sn: 'MCB-2024-0089', model: 'MCB-3000', version: 'MB-V2.1', calibration: '无需校准', qty: 1 },
|
||||
{ id: 2, code: 'AC-2024-001', name: '采集板', sn: 'ACB-2024-0156', model: 'ACB-6000', version: 'RX-V2.3', calibration: '已校准', qty: 1 },
|
||||
{ id: 3, code: 'AC-2024-002', name: '采集板', sn: 'ACB-2024-0157', model: 'ACB-6000', version: 'RX-V2.3', calibration: '已校准', qty: 1 },
|
||||
{ id: 5, code: 'TX-2024-001', name: '发射板', sn: 'TXB-2024-0034', model: 'TXB-1000', version: 'TX-V2.1', calibration: '无需校准', qty: 1 },
|
||||
{ id: 6, code: 'BS-2024-001', name: '升压板', sn: 'BST-2024-0021', model: 'BST-500', version: 'BP600-V1.2', calibration: '无需校准', qty: 1 },
|
||||
{ id: 7, code: 'CS-2024-001', name: '外壳机箱', sn: '-', model: 'GD30-CASE-A', version: '-', calibration: '无需校准', qty: 1 },
|
||||
]
|
||||
|
||||
const defaultChecklist = [
|
||||
{ id: 1, name: '主板SN扫码绑定', required: true },
|
||||
{ id: 2, name: '采集板SN录入(×6)', required: true },
|
||||
{ id: 3, name: '发射板安装检查', required: true },
|
||||
{ id: 4, name: '升压板安装检查', required: true },
|
||||
{ id: 5, name: '线缆连接检查', required: true },
|
||||
{ id: 6, name: '整机通电测试', required: true },
|
||||
{ id: 7, name: 'GPS/北斗模块检测', required: true },
|
||||
{ id: 8, name: 'WiFi通信测试', required: true },
|
||||
{ id: 9, name: '蓝牙通信测试', required: true },
|
||||
{ id: 10, name: '采集通道校准验证', required: true },
|
||||
{ id: 11, name: '发射电压测试', required: true },
|
||||
{ id: 12, name: '电池安装与充电测试', required: true },
|
||||
{ id: 13, name: 'IP66防护检测', required: true },
|
||||
{ id: 14, name: '固件版本校验', required: true },
|
||||
{ id: 15, name: '配置文件写入', required: true },
|
||||
{ id: 16, name: '授权文件写入', required: true },
|
||||
{ id: 17, name: '整机功能测试', required: true },
|
||||
{ id: 18, name: '数据采集验证', required: true },
|
||||
{ id: 19, name: '外观检查', required: false },
|
||||
{ id: 20, name: '标签粘贴', required: false },
|
||||
{ id: 21, name: '配件清点', required: false },
|
||||
{ id: 22, name: '包装检查', required: false },
|
||||
]
|
||||
interface ModelRow { id: number; name: string; code: string; status: string }
|
||||
interface ConfigFile { id: number; name: string; model: string; version: string; status: string }
|
||||
interface LicenseItem { id: number; model: string; modules: string }
|
||||
interface BomItem { id: number; name: string; material_name: string; model: string; versions: string[]; qty: number; required: number; need_calibration: number; enforce_version_match: number; model_code: string }
|
||||
interface CLItem { id: number; name: string; required: number; model_code: string; sort_order: number }
|
||||
interface MaterialItem { id: number; sn: string; name: string; category: string; version: string; status: string; calib_status: string; device_sn: string }
|
||||
|
||||
export default function RegistrationPage() {
|
||||
const router = useRouter()
|
||||
const [deviceModel, setDeviceModel] = useState('GD-30 Supreme')
|
||||
const { data: modelsData } = useApi<ModelRow[]>('/api/models', [])
|
||||
const { data: allConfigs } = useApi<ConfigFile[]>('/api/config-files', [])
|
||||
const { data: allLicenses } = useApi<LicenseItem[]>('/api/licenses', [])
|
||||
const { data: allMaterials } = useApi<MaterialItem[]>('/api/materials', [])
|
||||
|
||||
const [deviceModel, setDeviceModel] = useState('')
|
||||
const [hostSN, setHostSN] = useState('')
|
||||
const [boardSN, setBoardSN] = useState('')
|
||||
const [batchNo, setBatchNo] = useState('')
|
||||
const [selectedConfig, setSelectedConfig] = useState('CFG-GD30-v1.3.0')
|
||||
const [selectedConfig, setSelectedConfig] = useState('')
|
||||
const [testStatus, setTestStatus] = useState('测试通过')
|
||||
const [productionDate, setProductionDate] = useState('')
|
||||
const [bomList, setBomList] = useState(defaultBOM)
|
||||
const [bomList, setBomList] = useState<{ id: number; code: string; name: string; sn: string; model: string; version: string; calibration: string; qty: number }[]>([])
|
||||
const [checklistItems, setChecklistItems] = useState<CLItem[]>([])
|
||||
const [checkedItems, setCheckedItems] = useState<number[]>([])
|
||||
const [photoCount, setPhotoCount] = useState<Record<number, number>>({})
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const [photoOpen, setPhotoOpen] = useState<number | null>(null)
|
||||
const [photoNote, setPhotoNote] = useState('')
|
||||
|
||||
const matchInfo = modelMatchInfo[deviceModel]
|
||||
// 自动选中第一个型号
|
||||
useEffect(() => {
|
||||
if (modelsData.length > 0 && !deviceModel) setDeviceModel(modelsData[0].name)
|
||||
}, [modelsData, deviceModel])
|
||||
|
||||
// 切换型号时加载BOM和Checklist
|
||||
useEffect(() => {
|
||||
if (!deviceModel) return
|
||||
const model = modelsData.find(m => m.name === deviceModel)
|
||||
if (!model) return
|
||||
// 加载BOM
|
||||
fetch(`/api/models/bom?model=${model.code}`).then(r => r.json()).then((items: BomItem[]) => {
|
||||
let nextBomId = 1
|
||||
const expanded: typeof bomList = []
|
||||
for (const b of items) {
|
||||
for (let q = 0; q < b.qty; q++) {
|
||||
expanded.push({ id: nextBomId++, code: '', name: b.name, sn: '', model: b.model, version: '', calibration: b.need_calibration ? '待校准' : '无需校准', qty: 1 })
|
||||
}
|
||||
}
|
||||
setBomList(expanded)
|
||||
})
|
||||
// 加载Checklist
|
||||
fetch(`/api/models/checklist?model=${model.code}`).then(r => r.json()).then((items: CLItem[]) => {
|
||||
setChecklistItems(items)
|
||||
setCheckedItems([])
|
||||
})
|
||||
// 自动选配置文件
|
||||
const cfgs = allConfigs.filter(c => c.model === deviceModel && c.status === '生效')
|
||||
if (cfgs.length > 0) setSelectedConfig(cfgs[0].name)
|
||||
else setSelectedConfig('')
|
||||
}, [deviceModel, modelsData, allConfigs])
|
||||
|
||||
// 当前型号的配置文件和授权
|
||||
const modelConfigs = allConfigs.filter(c => c.model === deviceModel)
|
||||
const modelCode = modelsData.find(m => m.name === deviceModel)?.code || ''
|
||||
const modelLicense = allLicenses.find(l => l.model === modelCode || l.model === deviceModel)
|
||||
const hasMatch = modelConfigs.length > 0 || !!modelLicense
|
||||
|
||||
const completedCount = checkedItems.length
|
||||
const totalCount = defaultChecklist.length
|
||||
const totalCount = checklistItems.length
|
||||
|
||||
const toggleCheck = (id: number) => {
|
||||
setCheckedItems(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
|
||||
|
|
@ -92,22 +86,21 @@ export default function RegistrationPage() {
|
|||
}
|
||||
|
||||
/** 根据SN查询物料信息,自动填充版本和校准状态 */
|
||||
const handleSnChange = useCallback(async (id: number, sn: string) => {
|
||||
setBomList(prev => prev.map(b => b.id === id ? { ...b, sn } : b))
|
||||
if (!sn || sn === '-') return
|
||||
try {
|
||||
const res = await fetch('/api/materials')
|
||||
const materials = await res.json()
|
||||
const matched = materials.find((m: { sn: string }) => m.sn === sn)
|
||||
const handleSnChange = useCallback((id: number, sn: string) => {
|
||||
setBomList(prev => prev.map(b => {
|
||||
if (b.id !== id) return b
|
||||
const matched = allMaterials.find(m => m.sn === sn)
|
||||
if (matched) {
|
||||
setBomList(prev => prev.map(b => b.id === id ? {
|
||||
...b, sn,
|
||||
version: matched.version || b.version,
|
||||
calibration: matched.calib_status === '合格' ? '已校准' : matched.calib_status === '待校准' ? '待校准' : '无需校准',
|
||||
} : b))
|
||||
return { ...b, sn, version: matched.version || b.version, calibration: matched.calib_status === '合格' ? '已校准' : matched.calib_status === '待校准' ? '待校准' : '无需校准' }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
return { ...b, sn }
|
||||
}))
|
||||
}, [allMaterials])
|
||||
|
||||
/** 获取该BOM项分类下可用的物料(在库状态) */
|
||||
const getAvailableMaterials = (bomName: string) => {
|
||||
return allMaterials.filter(m => (m.category === bomName || m.name === bomName) && m.status === '在库' && m.device_sn === '-')
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
|
|
@ -129,10 +122,9 @@ export default function RegistrationPage() {
|
|||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 20 }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}><span style={{ color: '#FF4D4F' }}>*</span> 设备型号</label>
|
||||
<select value={deviceModel} onChange={e => { setDeviceModel(e.target.value); const cfgs = modelConfigOptions[e.target.value]; if (cfgs?.length) setSelectedConfig(cfgs[0].name) }} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14 }}>
|
||||
<option value="GD-30 Supreme">GD-30 Supreme</option>
|
||||
<option value="GD-20 Supreme">GD-20 Supreme</option>
|
||||
<option value="GD-10 Supreme">GD-10 Supreme</option>
|
||||
<select value={deviceModel} onChange={e => setDeviceModel(e.target.value)} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14 }}>
|
||||
{modelsData.map(m => <option key={m.id} value={m.name}>{m.name}</option>)}
|
||||
{modelsData.length === 0 && <option value="">请先在型号管理中添加型号</option>}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -144,15 +136,6 @@ export default function RegistrationPage() {
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}><span style={{ color: '#FF4D4F' }}>*</span> 主板SN号</label>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<input value={boardSN} onChange={e => setBoardSN(e.target.value)} placeholder="扫码或手动输入" style={{ flex: 1, padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14, boxSizing: 'border-box' }} />
|
||||
<button title="扫码录入" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 36, height: 36, border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer', flexShrink: 0 }}>
|
||||
<ScanLine size={16} style={{ color: '#4a7c59' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}><span style={{ color: '#FF4D4F' }}>*</span> 生产批次</label>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
|
|
@ -168,7 +151,8 @@ export default function RegistrationPage() {
|
|||
<div>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 500, marginBottom: 6 }}><span style={{ color: '#FF4D4F' }}>*</span> 配置文件</label>
|
||||
<select value={selectedConfig} onChange={e => setSelectedConfig(e.target.value)} style={{ width: '100%', padding: '8px 12px', border: '1px solid #D9D9D9', borderRadius: 6, fontSize: 14 }}>
|
||||
{(modelConfigOptions[deviceModel] || []).map(cfg => (
|
||||
{modelConfigs.length === 0 && <option value="">暂无配置文件</option>}
|
||||
{modelConfigs.map(cfg => (
|
||||
<option key={cfg.name} value={cfg.name} disabled={cfg.status === '已停用'}>
|
||||
{cfg.name}{cfg.status === '已停用' ? '(已停用)' : ''}
|
||||
</option>
|
||||
|
|
@ -195,12 +179,12 @@ export default function RegistrationPage() {
|
|||
</div>
|
||||
|
||||
{/* 型号匹配提示 */}
|
||||
{matchInfo ? (
|
||||
{hasMatch ? (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, padding: 16, backgroundColor: '#F6FFED', borderRadius: 8, marginBottom: 24, border: '1px solid #B7EB8F' }}>
|
||||
<CheckCircle size={18} style={{ color: '#52C41A', flexShrink: 0, marginTop: 2 }} />
|
||||
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.65)', lineHeight: 1.8 }}>
|
||||
<div>已匹配型号 <span style={{ fontWeight: 600 }}>{deviceModel}</span> 的关联信息:</div>
|
||||
<div>授权项:<span style={{ color: '#4a7c59' }}>{matchInfo.license}</span> · 配置文件:<span style={{ color: '#4a7c59' }}>{selectedConfig}</span></div>
|
||||
<div>{modelLicense ? <span>授权项:<span style={{ color: '#4a7c59' }}>{modelLicense.modules.split(', ').length}个模块</span></span> : <span style={{ color: 'rgba(0,0,0,0.25)' }}>未配置授权</span>} · 配置文件:<span style={{ color: '#4a7c59' }}>{selectedConfig || '未配置'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -220,7 +204,7 @@ export default function RegistrationPage() {
|
|||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ backgroundColor: '#FAFAFA' }}>
|
||||
{['物料编码', '物料名称', 'SN号', '型号', '版本', '校准状态', '数量', '操作'].map(h => (
|
||||
{['物料名称', 'SN号', '型号', '版本', '校准状态', '数量', '操作'].map(h => (
|
||||
<th key={h} style={{ padding: '10px 16px', textAlign: 'left', fontSize: 13, fontWeight: 600, color: 'rgba(0,0,0,0.85)', borderBottom: '1px solid #F0F0F0' }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
|
|
@ -228,19 +212,25 @@ export default function RegistrationPage() {
|
|||
<tbody>
|
||||
{bomList.map(item => (
|
||||
<tr key={item.id} style={{ borderBottom: '1px solid #F0F0F0' }}>
|
||||
<td style={{ padding: '10px 16px', fontSize: 13 }}>{item.code}</td>
|
||||
<td style={{ padding: '10px 16px', fontSize: 13 }}>{item.name}</td>
|
||||
<td style={{ padding: '10px 16px', fontSize: 13, fontWeight: 500 }}>
|
||||
{item.sn === '-' ? (
|
||||
<span style={{ color: 'rgba(0,0,0,0.25)' }}>-</span>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<input value={item.sn} onChange={e => handleSnChange(item.id, e.target.value)} onBlur={() => handleSnChange(item.id, item.sn)} placeholder="扫码或手动输入SN" style={{ width: 160, padding: '4px 8px', border: '1px solid #D9D9D9', borderRadius: 4, fontSize: 13, boxSizing: 'border-box' }} />
|
||||
<button title="扫码录入SN" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, border: '1px solid #D9D9D9', borderRadius: 4, backgroundColor: '#fff', cursor: 'pointer', flexShrink: 0 }}>
|
||||
<ScanLine size={13} style={{ color: '#4a7c59' }} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
) : (() => {
|
||||
const available = getAvailableMaterials(item.name)
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<select value={item.sn} onChange={e => handleSnChange(item.id, e.target.value)} style={{ width: 120, padding: '4px 6px', border: '1px solid #D9D9D9', borderRadius: 4, fontSize: 12, boxSizing: 'border-box' }}>
|
||||
<option value="">选择物料</option>
|
||||
{available.map(m => <option key={m.id} value={m.sn}>{m.sn}</option>)}
|
||||
</select>
|
||||
<input value={item.sn} onChange={e => handleSnChange(item.id, e.target.value)} placeholder="或手动输入" style={{ width: 100, padding: '4px 6px', border: '1px solid #D9D9D9', borderRadius: 4, fontSize: 12, boxSizing: 'border-box' }} />
|
||||
<button title="扫码录入SN" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, border: '1px solid #D9D9D9', borderRadius: 4, backgroundColor: '#fff', cursor: 'pointer', flexShrink: 0 }}>
|
||||
<ScanLine size={13} style={{ color: '#4a7c59' }} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</td>
|
||||
<td style={{ padding: '10px 16px', fontSize: 13, color: 'rgba(0,0,0,0.65)' }}>{item.model}</td>
|
||||
<td style={{ padding: '10px 16px' }}>
|
||||
|
|
@ -295,7 +285,7 @@ export default function RegistrationPage() {
|
|||
<div style={{ height: '100%', width: `${(completedCount / totalCount) * 100}%`, backgroundColor: '#4a7c59', borderRadius: 3, transition: 'width 0.3s' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{defaultChecklist.map((item, i) => {
|
||||
{checklistItems.map((item, i) => {
|
||||
const isChecked = checkedItems.includes(item.id)
|
||||
const photos = photoCount[item.id] || 0
|
||||
return (
|
||||
|
|
@ -324,8 +314,14 @@ export default function RegistrationPage() {
|
|||
{/* Sticky Bottom Bar */}
|
||||
<div style={{ position: 'sticky', bottom: 0, backgroundColor: '#fff', borderTop: '1px solid #F0F0F0', padding: '12px 24px', display: 'flex', justifyContent: 'flex-end', gap: 12, zIndex: 10 }}>
|
||||
<button onClick={() => router.back()} style={{ padding: '8px 24px', border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer', fontSize: 14 }}>取消</button>
|
||||
<button style={{ padding: '8px 24px', border: '1px solid #D9D9D9', borderRadius: 6, backgroundColor: '#fff', cursor: 'pointer', fontSize: 14 }}>更新</button>
|
||||
<button style={{ padding: '8px 24px', border: 'none', borderRadius: 6, backgroundColor: '#4a7c59', color: '#fff', cursor: 'pointer', fontSize: 14 }}>提交</button>
|
||||
<button onClick={async () => {
|
||||
if (!hostSN || !deviceModel) return
|
||||
await fetch('/api/devices', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sn: hostSN, model: deviceModel, type: deviceModel, status: testStatus === '测试通过' ? '已激活' : '装配中', firmware: '', production_date: productionDate || new Date().toISOString(), customer: '-', batch: batchNo }) })
|
||||
// 保存BOM记录和操作日志
|
||||
const bomItems = bomList.filter(b => b.sn).map(b => ({ name: b.name, sn: b.sn, model: b.model, version: b.version, calibration: b.calibration }))
|
||||
await fetch('/api/devices/detail', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ device_sn: hostSN, bom_items: bomItems, log: { action: '设备登记', operator: '', detail: `完成设备登记,型号 ${deviceModel},配置文件 ${selectedConfig}` } }) })
|
||||
router.push('/devices')
|
||||
}} disabled={!hostSN || !deviceModel} style={{ padding: '8px 24px', border: 'none', borderRadius: 6, backgroundColor: hostSN && deviceModel ? '#4a7c59' : '#D9D9D9', color: '#fff', cursor: hostSN && deviceModel ? 'pointer' : 'not-allowed', fontSize: 14 }}>提交</button>
|
||||
</div>
|
||||
|
||||
{/* Import BOM Dialog */}
|
||||
|
|
|
|||
185
src/lib/db.ts
185
src/lib/db.ts
|
|
@ -13,9 +13,60 @@ export function getDb(): Database.Database {
|
|||
db.pragma('journal_mode = WAL')
|
||||
db.pragma('foreign_keys = ON')
|
||||
initTables(db)
|
||||
migrateDatabase(db)
|
||||
return db
|
||||
}
|
||||
|
||||
function migrateDatabase(db: Database.Database) {
|
||||
// 检查并添加 licenses 表的新字段
|
||||
try {
|
||||
const tableInfo = db.pragma("table_info('licenses')") as any[]
|
||||
const columns = tableInfo.map(col => col.name)
|
||||
|
||||
if (!columns.includes('config_id')) {
|
||||
db.exec("ALTER TABLE licenses ADD COLUMN config_id INTEGER DEFAULT NULL")
|
||||
}
|
||||
if (!columns.includes('license_file')) {
|
||||
db.exec("ALTER TABLE licenses ADD COLUMN license_file TEXT DEFAULT ''")
|
||||
}
|
||||
if (!columns.includes('device_sn')) {
|
||||
db.exec("ALTER TABLE licenses ADD COLUMN device_sn TEXT DEFAULT ''")
|
||||
}
|
||||
if (!columns.includes('created_at')) {
|
||||
db.exec("ALTER TABLE licenses ADD COLUMN created_at TEXT NOT NULL DEFAULT (datetime('now'))")
|
||||
}
|
||||
if (!columns.includes('updated_at')) {
|
||||
db.exec("ALTER TABLE licenses ADD COLUMN updated_at TEXT NOT NULL DEFAULT (datetime('now'))")
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Migration already applied or not needed')
|
||||
}
|
||||
|
||||
// 创建新表
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS license_download_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
license_id INTEGER NOT NULL,
|
||||
device_sn TEXT NOT NULL,
|
||||
download_time TEXT NOT NULL,
|
||||
ip_address TEXT DEFAULT '',
|
||||
app_version TEXT DEFAULT '',
|
||||
FOREIGN KEY (license_id) REFERENCES licenses(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS license_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
model_code TEXT NOT NULL,
|
||||
auth_items TEXT NOT NULL DEFAULT '[]',
|
||||
config_id INTEGER DEFAULT NULL,
|
||||
status TEXT NOT NULL DEFAULT '启用',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (config_id) REFERENCES config_files(id)
|
||||
);
|
||||
`)
|
||||
}
|
||||
|
||||
function initTables(db: Database.Database) {
|
||||
db.exec(`
|
||||
-- 设备型号
|
||||
|
|
@ -251,5 +302,139 @@ function initTables(db: Database.Database) {
|
|||
create_time TEXT NOT NULL,
|
||||
FOREIGN KEY (app_id) REFERENCES applications(id)
|
||||
);
|
||||
|
||||
-- 设备装机BOM记录
|
||||
CREATE TABLE IF NOT EXISTS device_bom_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_sn TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
material_sn TEXT DEFAULT '',
|
||||
model TEXT DEFAULT '',
|
||||
version TEXT DEFAULT '',
|
||||
calibration TEXT DEFAULT '无需校准'
|
||||
);
|
||||
|
||||
-- 设备操作日志
|
||||
CREATE TABLE IF NOT EXISTS device_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_sn TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
operator TEXT DEFAULT '',
|
||||
detail TEXT DEFAULT '',
|
||||
date TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 更新日志
|
||||
CREATE TABLE IF NOT EXISTS update_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT DEFAULT '',
|
||||
category TEXT NOT NULL DEFAULT 'feature',
|
||||
version TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成授权文件JSON
|
||||
*/
|
||||
export function generateLicenseFile(
|
||||
deviceModel: string,
|
||||
deviceSN: string,
|
||||
modules: string[],
|
||||
config: any,
|
||||
validUntil: string,
|
||||
status: string = 'active'
|
||||
): string {
|
||||
const crypto = require('crypto')
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// 构建授权模块列表
|
||||
const allAuthItems = [
|
||||
{ id: '1D', name: '一维自电/电阻率/激电测试模块', category: '一维' },
|
||||
{ id: '2D', name: '二维自电/电阻率/激电测试模块', category: '二维' },
|
||||
{ id: '3D', name: '三维自电/电阻率/激电测试模块', category: '三维' },
|
||||
{ id: 'WATER', name: '水上', category: '水上' },
|
||||
{ id: 'CROSS', name: '跨孔', category: '跨孔' },
|
||||
{ id: 'CF', name: '电流场法', category: '电流场法' },
|
||||
]
|
||||
|
||||
const authModulesList = modules.map(moduleName => {
|
||||
const item = allAuthItems.find(a => a.name === moduleName)
|
||||
return {
|
||||
id: item?.id || '',
|
||||
name: moduleName,
|
||||
category: item?.category || '',
|
||||
enabled: true
|
||||
}
|
||||
})
|
||||
|
||||
// 构建配置文件对象
|
||||
const configObject = config ? {
|
||||
name: config.name || '',
|
||||
version: config.version || '',
|
||||
emissionParams: {
|
||||
maxVoltage: config.voltage || '',
|
||||
maxCurrent: config.current || '',
|
||||
waveform: config.duty_cycle || '',
|
||||
pulseWidth: config.pulse_width || ''
|
||||
},
|
||||
acquisitionParams: {
|
||||
channels: config.channels || 0,
|
||||
sampleRate: config.sample_rate || '',
|
||||
voltageRange: config.voltage_range || '',
|
||||
fullWaveform: config.waveform === '支持'
|
||||
},
|
||||
networkParams: {
|
||||
wifiSSIDPrefix: config.ssid || ''
|
||||
}
|
||||
} : {}
|
||||
|
||||
// 构建授权文件主体
|
||||
const licenseData = {
|
||||
version: "1.0",
|
||||
generatedAt: now,
|
||||
deviceModel: deviceModel,
|
||||
deviceSN: deviceSN,
|
||||
validUntil: validUntil,
|
||||
status: status,
|
||||
authModules: authModulesList,
|
||||
config: configObject
|
||||
}
|
||||
|
||||
// 生成数字签名
|
||||
const jsonString = JSON.stringify(licenseData, null, 2)
|
||||
const signature = crypto.createHash('sha256').update(jsonString).digest('hex')
|
||||
|
||||
// 添加签名信息
|
||||
const finalLicense = {
|
||||
...licenseData,
|
||||
signature: {
|
||||
algorithm: "SHA256",
|
||||
value: signature,
|
||||
publicKey: "platform-public-key-placeholder"
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(finalLicense, null, 2)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证授权文件签名
|
||||
*/
|
||||
export function verifyLicenseSignature(licenseJson: string): boolean {
|
||||
const crypto = require('crypto')
|
||||
try {
|
||||
const license = JSON.parse(licenseJson)
|
||||
const { signature, ...dataWithoutSignature } = license
|
||||
|
||||
// 重新计算签名
|
||||
const jsonString = JSON.stringify(dataWithoutSignature, null, 2)
|
||||
const calculatedSignature = crypto.createHash('sha256').update(jsonString).digest('hex')
|
||||
|
||||
return calculatedSignature === signature.value
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue