Răsfoiți Sursa

feat: 支持数据库导入/导出功能

- 在 .env.example 中新增 PostgreSQL Docker 容器名称配置,支持数据库导入/导出功能。
- 在 package.json 中添加 @radix-ui/react-checkbox、html2canvas 和 jspdf 依赖,增强 UI 组件和文档生成能力。
- 更新 pnpm-lock.yaml 以反映新增依赖项的版本信息。
- 在设置页面导航中新增数据管理菜单项,提升用户体验。

🤖 Generated with [Claude Code](https://claude.com/claude-code)
ding113 5 luni în urmă
părinte
comite
0e0fcc77a7

+ 6 - 0
.env.example

@@ -13,6 +13,12 @@ DB_USER=postgres
 DB_PASSWORD=your-secure-password_change-me
 DB_NAME=claude_code_hub
 
+# 数据库备份配置
+# PostgreSQL Docker 容器名称(用于数据库导入/导出功能)
+# - 生产环境默认: claude-code-hub-db
+# - 开发环境(dev/): claude-relay-postgres-dev
+POSTGRES_CONTAINER_NAME=claude-code-hub-db
+
 # 应用配置
 APP_PORT=23000
 

+ 3 - 0
package.json

@@ -19,6 +19,7 @@
   "dependencies": {
     "@radix-ui/react-alert-dialog": "^1.1.15",
     "@radix-ui/react-avatar": "^1.1.10",
+    "@radix-ui/react-checkbox": "^1.3.3",
     "@radix-ui/react-collapsible": "^1.1.12",
     "@radix-ui/react-dialog": "^1.1.15",
     "@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -38,7 +39,9 @@
     "dotenv": "^17.2.2",
     "drizzle-orm": "^0.44.5",
     "hono": "^4.9.6",
+    "html2canvas": "^1.4.1",
     "ioredis": "^5.8.1",
+    "jspdf": "^3.0.3",
     "lucide-react": "^0.544.0",
     "next": "15.4.6",
     "next-themes": "^0.4.6",

+ 200 - 0
pnpm-lock.yaml

@@ -14,6 +14,9 @@ importers:
       '@radix-ui/react-avatar':
         specifier: ^1.1.10
         version: 1.1.10(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
+      '@radix-ui/react-checkbox':
+        specifier: ^1.3.3
+        version: 1.3.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
       '@radix-ui/react-collapsible':
         specifier: ^1.1.12
         version: 1.1.12(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
@@ -71,9 +74,15 @@ importers:
       hono:
         specifier: ^4.9.6
         version: 4.9.8
+      html2canvas:
+        specifier: ^1.4.1
+        version: 1.4.1
       ioredis:
         specifier: ^5.8.1
         version: 5.8.1
+      jspdf:
+        specifier: ^3.0.3
+        version: 3.0.3
       lucide-react:
         specifier: ^0.544.0
         version: 0.544.0([email protected])
@@ -826,6 +835,19 @@ packages:
       '@types/react-dom':
         optional: true
 
+  '@radix-ui/[email protected]':
+    resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
   '@radix-ui/[email protected]':
     resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==}
     peerDependencies:
@@ -1375,9 +1397,15 @@ packages:
   '@types/[email protected]':
     resolution: {integrity: sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==}
 
+  '@types/[email protected]':
+    resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}
+
   '@types/[email protected]':
     resolution: {integrity: sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ==}
 
+  '@types/[email protected]':
+    resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==}
+
   '@types/[email protected]':
     resolution: {integrity: sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==}
     peerDependencies:
@@ -1386,6 +1414,9 @@ packages:
   '@types/[email protected]':
     resolution: {integrity: sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==}
 
+  '@types/[email protected]':
+    resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
+
   '@typescript-eslint/[email protected]':
     resolution: {integrity: sha512-bhEz6OZeUR+O/6yx9Jk6ohX6H9JSFTaiY0v9/PuKT3oGK0rn0jNplLmyFUGV+a9gfYnVNwGDwS/UkLIuXNb2Rw==}
     engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1626,6 +1657,10 @@ packages:
   [email protected]:
     resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
 
+  [email protected]:
+    resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
+    engines: {node: '>= 0.6.0'}
+
   [email protected]:
     resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
 
@@ -1663,6 +1698,10 @@ packages:
   [email protected]:
     resolution: {integrity: sha512-e4QKw/O2Kavj2VQTKZWrwzkt3IxOmIlU6ajRb6LP64LHpBo1J67k2Hi4Vu/TgJWsNtynurfS0uK3MaUTCPfu5Q==}
 
+  [email protected]:
+    resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==}
+    engines: {node: '>=10.0.0'}
+
   [email protected]:
     resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
     engines: {node: '>=10'}
@@ -1705,10 +1744,16 @@ packages:
   [email protected]:
     resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
 
+  [email protected]:
+    resolution: {integrity: sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==}
+
   [email protected]:
     resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
     engines: {node: '>= 8'}
 
+  [email protected]:
+    resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
+
   [email protected]:
     resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
 
@@ -1826,6 +1871,9 @@ packages:
   [email protected]:
     resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
 
+  [email protected]:
+    resolution: {integrity: sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==}
+
   [email protected]:
     resolution: {integrity: sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==}
     engines: {node: '>=12'}
@@ -2140,6 +2188,9 @@ packages:
   [email protected]:
     resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
 
+  [email protected]:
+    resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==}
+
   [email protected]:
     resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
 
@@ -2154,6 +2205,9 @@ packages:
       picomatch:
         optional: true
 
+  [email protected]:
+    resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
+
   [email protected]:
     resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
     engines: {node: '>=16.0.0'}
@@ -2266,6 +2320,10 @@ packages:
     resolution: {integrity: sha512-JW8Bb4RFWD9iOKxg5PbUarBYGM99IcxFl2FPBo2gSJO11jjUDqlP1Bmfyqt8Z/dGhIQ63PMA9LdcLefXyIasyg==}
     engines: {node: '>=16.9.0'}
 
+  [email protected]:
+    resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
+    engines: {node: '>=8.0.0'}
+
   [email protected]:
     resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
     engines: {node: '>= 4'}
@@ -2290,6 +2348,9 @@ packages:
     resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
     engines: {node: '>=12'}
 
+  [email protected]:
+    resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==}
+
   [email protected]:
     resolution: {integrity: sha512-Qho8TgIamqEPdgiMadJwzRMW3TudIg6vpg4YONokGDudy4eqRIJtDbVX72pfLBcWxvbn3qm/40TyGUObdW4tLQ==}
     engines: {node: '>=12.22.0'}
@@ -2438,6 +2499,9 @@ packages:
     resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==}
     hasBin: true
 
+  [email protected]:
+    resolution: {integrity: sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ==}
+
   [email protected]:
     resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
     engines: {node: '>=4.0'}
@@ -2681,6 +2745,9 @@ packages:
     resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
     engines: {node: '>=10'}
 
+  [email protected]:
+    resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
+
   [email protected]:
     resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
     engines: {node: '>=6'}
@@ -2696,6 +2763,9 @@ packages:
   [email protected]:
     resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
 
+  [email protected]:
+    resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
+
   [email protected]:
     resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
     engines: {node: '>=4.0.0'}
@@ -2792,6 +2862,9 @@ packages:
   [email protected]:
     resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
 
+  [email protected]:
+    resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==}
+
   [email protected]:
     resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==}
     peerDependencies:
@@ -2875,6 +2948,9 @@ packages:
     resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
     engines: {node: '>= 0.4'}
 
+  [email protected]:
+    resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
+
   [email protected]:
     resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
     engines: {node: '>= 0.4'}
@@ -2899,6 +2975,10 @@ packages:
     resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
     engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
 
+  [email protected]:
+    resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==}
+    engines: {node: '>= 0.8.15'}
+
   [email protected]:
     resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
 
@@ -3003,6 +3083,10 @@ packages:
   [email protected]:
     resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
 
+  [email protected]:
+    resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==}
+    engines: {node: '>=0.1.14'}
+
   [email protected]:
     resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
 
@@ -3066,6 +3150,10 @@ packages:
     resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
     engines: {node: '>= 0.4'}
 
+  [email protected]:
+    resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==}
+    engines: {node: '>=12.0.0'}
+
   [email protected]:
     resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
 
@@ -3080,6 +3168,9 @@ packages:
     resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
     engines: {node: '>=18'}
 
+  [email protected]:
+    resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
+
   [email protected]:
     resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
 
@@ -3175,6 +3266,9 @@ packages:
     peerDependencies:
       react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
 
+  [email protected]:
+    resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
+
   [email protected]:
     resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
 
@@ -3684,6 +3778,22 @@ snapshots:
       '@types/react': 19.1.13
       '@types/react-dom': 19.1.9(@types/[email protected])
 
+  '@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
+    dependencies:
+      '@radix-ui/primitive': 1.1.3
+      '@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
+      '@radix-ui/react-context': 1.1.2(@types/[email protected])([email protected])
+      '@radix-ui/react-presence': 1.1.5(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
+      '@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
+      '@radix-ui/react-use-controllable-state': 1.2.2(@types/[email protected])([email protected])
+      '@radix-ui/react-use-previous': 1.1.1(@types/[email protected])([email protected])
+      '@radix-ui/react-use-size': 1.1.1(@types/[email protected])([email protected])
+      react: 19.1.0
+      react-dom: 19.1.0([email protected])
+    optionalDependencies:
+      '@types/react': 19.1.13
+      '@types/react-dom': 19.1.9(@types/[email protected])
+
   '@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
     dependencies:
       '@radix-ui/primitive': 1.1.3
@@ -4236,12 +4346,17 @@ snapshots:
     dependencies:
       undici-types: 6.21.0
 
+  '@types/[email protected]': {}
+
   '@types/[email protected]':
     dependencies:
       '@types/node': 20.19.17
       pg-protocol: 1.10.3
       pg-types: 2.2.0
 
+  '@types/[email protected]':
+    optional: true
+
   '@types/[email protected](@types/[email protected])':
     dependencies:
       '@types/react': 19.1.13
@@ -4250,6 +4365,9 @@ snapshots:
     dependencies:
       csstype: 3.1.3
 
+  '@types/[email protected]':
+    optional: true
+
   '@typescript-eslint/[email protected](@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected])':
     dependencies:
       '@eslint-community/regexpp': 4.12.1
@@ -4510,6 +4628,8 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]: {}
+
   [email protected]:
     dependencies:
       balanced-match: 1.0.2
@@ -4552,6 +4672,18 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]:
+    dependencies:
+      '@babel/runtime': 7.28.4
+      '@types/raf': 3.4.3
+      core-js: 3.46.0
+      raf: 3.4.1
+      regenerator-runtime: 0.13.11
+      rgbcolor: 1.0.1
+      stackblur-canvas: 2.7.0
+      svg-pathdata: 6.0.3
+    optional: true
+
   [email protected]:
     dependencies:
       ansi-styles: 4.3.0
@@ -4591,12 +4723,19 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]:
+    optional: true
+
   [email protected]:
     dependencies:
       path-key: 3.1.1
       shebang-command: 2.0.0
       which: 2.0.2
 
+  [email protected]:
+    dependencies:
+      utrie: 1.0.2
+
   [email protected]: {}
 
   [email protected]:
@@ -4700,6 +4839,11 @@ snapshots:
       '@babel/runtime': 7.28.4
       csstype: 3.1.3
 
+  [email protected]:
+    optionalDependencies:
+      '@types/trusted-types': 2.0.7
+    optional: true
+
   [email protected]: {}
 
   [email protected]:
@@ -5127,6 +5271,12 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]:
+    dependencies:
+      '@types/pako': 2.0.4
+      iobuffer: 5.4.0
+      pako: 2.1.0
+
   [email protected]: {}
 
   [email protected]:
@@ -5137,6 +5287,8 @@ snapshots:
     optionalDependencies:
       picomatch: 4.0.3
 
+  [email protected]: {}
+
   [email protected]:
     dependencies:
       flat-cache: 4.0.1
@@ -5251,6 +5403,11 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]:
+    dependencies:
+      css-line-break: 2.1.0
+      text-segmentation: 1.0.3
+
   [email protected]: {}
 
   [email protected]: {}
@@ -5270,6 +5427,8 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]: {}
+
   [email protected]:
     dependencies:
       '@ioredis/commands': 1.4.0
@@ -5431,6 +5590,17 @@ snapshots:
     dependencies:
       minimist: 1.2.8
 
+  [email protected]:
+    dependencies:
+      '@babel/runtime': 7.28.4
+      fast-png: 6.4.0
+      fflate: 0.8.2
+    optionalDependencies:
+      canvg: 3.0.11
+      core-js: 3.46.0
+      dompurify: 3.3.0
+      html2canvas: 1.4.1
+
   [email protected]:
     dependencies:
       array-includes: 3.1.9
@@ -5656,6 +5826,8 @@ snapshots:
     dependencies:
       p-limit: 3.1.0
 
+  [email protected]: {}
+
   [email protected]:
     dependencies:
       callsites: 3.1.0
@@ -5666,6 +5838,9 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]:
+    optional: true
+
   [email protected]: {}
 
   [email protected]: {}
@@ -5769,6 +5944,11 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]:
+    dependencies:
+      performance-now: 2.1.0
+    optional: true
+
   [email protected]([email protected]):
     dependencies:
       react: 19.1.0
@@ -5860,6 +6040,9 @@ snapshots:
       get-proto: 1.0.1
       which-builtin-type: 1.2.1
 
+  [email protected]:
+    optional: true
+
   [email protected]:
     dependencies:
       call-bind: 1.0.8
@@ -5887,6 +6070,9 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]:
+    optional: true
+
   [email protected]:
     dependencies:
       queue-microtask: 1.2.3
@@ -6033,6 +6219,9 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]:
+    optional: true
+
   [email protected]: {}
 
   [email protected]:
@@ -6107,6 +6296,9 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]:
+    optional: true
+
   [email protected]: {}
 
   [email protected]: {}
@@ -6122,6 +6314,10 @@ snapshots:
       mkdirp: 3.0.1
       yallist: 5.0.0
 
+  [email protected]:
+    dependencies:
+      utrie: 1.0.2
+
   [email protected]:
     dependencies:
       real-require: 0.2.0
@@ -6249,6 +6445,10 @@ snapshots:
     dependencies:
       react: 19.1.0
 
+  [email protected]:
+    dependencies:
+      base64-arraybuffer: 1.0.2
+
   [email protected]:
     dependencies:
       '@types/d3-array': 3.2.2

+ 76 - 0
src/app/api/admin/database/export/route.ts

@@ -0,0 +1,76 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { executePgDump, checkDockerContainer } from '@/lib/database-backup/docker-executor';
+import { logger } from '@/lib/logger';
+
+const CONTAINER_NAME = process.env.POSTGRES_CONTAINER_NAME || 'claude-code-hub-db';
+const DATABASE_NAME = process.env.DB_NAME || 'claude_code_hub';
+
+/**
+ * 导出数据库备份
+ *
+ * GET /api/admin/database/export
+ *
+ * 响应: application/octet-stream (pg_dump custom format)
+ */
+export async function GET(request: NextRequest) {
+  try {
+    // 1. 验证管理员权限
+    const token = request.headers.get('authorization')?.replace('Bearer ', '');
+    if (token !== process.env.ADMIN_TOKEN) {
+      logger.warn({ action: 'database_export_unauthorized' });
+      return NextResponse.json(
+        { error: '未授权访问' },
+        { status: 401 }
+      );
+    }
+
+    // 2. 检查 Docker 容器是否可用
+    const isAvailable = await checkDockerContainer(CONTAINER_NAME);
+    if (!isAvailable) {
+      logger.error({
+        action: 'database_export_container_unavailable',
+        containerName: CONTAINER_NAME,
+      });
+      return NextResponse.json(
+        { error: `Docker 容器 ${CONTAINER_NAME} 不可用,请确保使用 docker compose 部署` },
+        { status: 503 }
+      );
+    }
+
+    // 3. 执行 pg_dump
+    const stream = executePgDump(CONTAINER_NAME, DATABASE_NAME);
+
+    // 4. 生成文件名(带时间戳)
+    const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
+    const filename = `backup_${timestamp}.dump`;
+
+    logger.info({
+      action: 'database_export_initiated',
+      filename,
+      databaseName: DATABASE_NAME,
+    });
+
+    // 5. 返回流式响应
+    return new Response(stream, {
+      status: 200,
+      headers: {
+        'Content-Type': 'application/octet-stream',
+        'Content-Disposition': `attachment; filename="${filename}"`,
+        'Cache-Control': 'no-cache',
+      },
+    });
+  } catch (error) {
+    logger.error({
+      action: 'database_export_error',
+      error: error instanceof Error ? error.message : String(error),
+    });
+
+    return NextResponse.json(
+      {
+        error: '导出数据库失败',
+        details: error instanceof Error ? error.message : String(error),
+      },
+      { status: 500 }
+    );
+  }
+}

+ 149 - 0
src/app/api/admin/database/import/route.ts

@@ -0,0 +1,149 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { writeFile, unlink } from 'fs/promises';
+import { executePgRestore, checkDockerContainer } from '@/lib/database-backup/docker-executor';
+import { logger } from '@/lib/logger';
+
+const CONTAINER_NAME = process.env.POSTGRES_CONTAINER_NAME || 'claude-code-hub-db';
+const DATABASE_NAME = process.env.DB_NAME || 'claude_code_hub';
+
+/**
+ * 导入数据库备份
+ *
+ * POST /api/admin/database/import
+ *
+ * Body: multipart/form-data
+ *   - file: 备份文件 (.dump)
+ *   - cleanFirst: 'true' | 'false' (是否清除现有数据)
+ *
+ * 响应: text/event-stream (SSE 格式的进度流)
+ */
+export async function POST(request: NextRequest) {
+  let tempFilePath: string | null = null;
+
+  try {
+    // 1. 验证管理员权限
+    const token = request.headers.get('authorization')?.replace('Bearer ', '');
+    if (token !== process.env.ADMIN_TOKEN) {
+      logger.warn({ action: 'database_import_unauthorized' });
+      return NextResponse.json(
+        { error: '未授权访问' },
+        { status: 401 }
+      );
+    }
+
+    // 2. 检查 Docker 容器是否可用
+    const isAvailable = await checkDockerContainer(CONTAINER_NAME);
+    if (!isAvailable) {
+      logger.error({
+        action: 'database_import_container_unavailable',
+        containerName: CONTAINER_NAME,
+      });
+      return NextResponse.json(
+        { error: `Docker 容器 ${CONTAINER_NAME} 不可用,请确保使用 docker compose 部署` },
+        { status: 503 }
+      );
+    }
+
+    // 3. 解析表单数据
+    const formData = await request.formData();
+    const file = formData.get('file') as File | null;
+    const cleanFirst = formData.get('cleanFirst') === 'true';
+
+    if (!file) {
+      return NextResponse.json(
+        { error: '缺少备份文件' },
+        { status: 400 }
+      );
+    }
+
+    // 4. 验证文件类型
+    if (!file.name.endsWith('.dump')) {
+      return NextResponse.json(
+        { error: '文件格式错误,仅支持 .dump 格式的备份文件' },
+        { status: 400 }
+      );
+    }
+
+    logger.info({
+      action: 'database_import_initiated',
+      filename: file.name,
+      fileSize: file.size,
+      cleanFirst,
+      databaseName: DATABASE_NAME,
+    });
+
+    // 5. 保存上传文件到临时目录
+    tempFilePath = `/tmp/restore_${Date.now()}.dump`;
+    const bytes = await file.arrayBuffer();
+    await writeFile(tempFilePath, Buffer.from(bytes));
+
+    logger.info({
+      action: 'database_import_file_saved',
+      tempFilePath,
+    });
+
+    // 6. 执行 pg_restore,返回 SSE 流
+    const stream = executePgRestore(
+      CONTAINER_NAME,
+      DATABASE_NAME,
+      tempFilePath,
+      cleanFirst
+    );
+
+    // 7. 清理临时文件的逻辑(在流结束后执行)
+    const cleanupStream = new TransformStream({
+      flush() {
+        if (tempFilePath) {
+          unlink(tempFilePath)
+            .then(() => {
+              logger.info({
+                action: 'database_import_temp_file_cleaned',
+                tempFilePath,
+              });
+            })
+            .catch((err) => {
+              logger.error({
+                action: 'database_import_temp_file_cleanup_error',
+                tempFilePath,
+                error: err.message,
+              });
+            });
+        }
+      },
+    });
+
+    // 8. 返回 SSE 流式响应
+    return new Response(stream.pipeThrough(cleanupStream), {
+      status: 200,
+      headers: {
+        'Content-Type': 'text/event-stream',
+        'Cache-Control': 'no-cache',
+        'Connection': 'keep-alive',
+      },
+    });
+  } catch (error) {
+    logger.error({
+      action: 'database_import_error',
+      error: error instanceof Error ? error.message : String(error),
+    });
+
+    // 出错时清理临时文件
+    if (tempFilePath) {
+      unlink(tempFilePath).catch((err) => {
+        logger.error({
+          action: 'database_import_temp_file_cleanup_error',
+          tempFilePath,
+          error: err.message,
+        });
+      });
+    }
+
+    return NextResponse.json(
+      {
+        error: '导入数据库失败',
+        details: error instanceof Error ? error.message : String(error),
+      },
+      { status: 500 }
+    );
+  }
+}

+ 101 - 0
src/app/api/admin/database/status/route.ts

@@ -0,0 +1,101 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { checkDockerContainer, getDatabaseInfo } from '@/lib/database-backup/docker-executor';
+import { logger } from '@/lib/logger';
+import type { DatabaseStatus } from '@/types/database-backup';
+
+const CONTAINER_NAME = process.env.POSTGRES_CONTAINER_NAME || 'claude-code-hub-db';
+const DATABASE_NAME = process.env.DB_NAME || 'claude_code_hub';
+
+/**
+ * 获取数据库状态信息
+ *
+ * GET /api/admin/database/status
+ *
+ * 响应: DatabaseStatus JSON
+ */
+export async function GET(request: NextRequest) {
+  try {
+    // 1. 验证管理员权限
+    const token = request.headers.get('authorization')?.replace('Bearer ', '');
+    if (token !== process.env.ADMIN_TOKEN) {
+      logger.warn({ action: 'database_status_unauthorized' });
+      return NextResponse.json(
+        { error: '未授权访问' },
+        { status: 401 }
+      );
+    }
+
+    // 2. 检查 Docker 容器是否可用
+    const isAvailable = await checkDockerContainer(CONTAINER_NAME);
+
+    if (!isAvailable) {
+      const status: DatabaseStatus = {
+        isAvailable: false,
+        containerName: CONTAINER_NAME,
+        databaseName: DATABASE_NAME,
+        databaseSize: 'N/A',
+        tableCount: 0,
+        postgresVersion: 'N/A',
+        error: `Docker 容器 ${CONTAINER_NAME} 不可用,请确保使用 docker compose 部署`,
+      };
+
+      logger.warn({
+        action: 'database_status_container_unavailable',
+        containerName: CONTAINER_NAME,
+      });
+
+      return NextResponse.json(status, { status: 200 });
+    }
+
+    // 3. 获取数据库详细信息
+    try {
+      const info = await getDatabaseInfo(CONTAINER_NAME, DATABASE_NAME);
+
+      const status: DatabaseStatus = {
+        isAvailable: true,
+        containerName: CONTAINER_NAME,
+        databaseName: DATABASE_NAME,
+        databaseSize: info.size,
+        tableCount: info.tableCount,
+        postgresVersion: info.version,
+      };
+
+      logger.info({
+        action: 'database_status_retrieved',
+        ...status,
+      });
+
+      return NextResponse.json(status, { status: 200 });
+    } catch (infoError) {
+      const status: DatabaseStatus = {
+        isAvailable: true,
+        containerName: CONTAINER_NAME,
+        databaseName: DATABASE_NAME,
+        databaseSize: 'Unknown',
+        tableCount: 0,
+        postgresVersion: 'Unknown',
+        error: infoError instanceof Error ? infoError.message : String(infoError),
+      };
+
+      logger.error({
+        action: 'database_status_info_error',
+        error: infoError instanceof Error ? infoError.message : String(infoError),
+      });
+
+      return NextResponse.json(status, { status: 200 });
+    }
+  } catch (error) {
+    logger.error({
+      action: 'database_status_error',
+      error: error instanceof Error ? error.message : String(error),
+    });
+
+    return NextResponse.json(
+      {
+        error: '获取数据库状态失败',
+        details: error instanceof Error ? error.message : String(error),
+      },
+      { status: 500 }
+    );
+  }
+}

+ 53 - 0
src/app/api/internal/data-gen/route.ts

@@ -0,0 +1,53 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getSession } from "@/lib/auth";
+import { generateLogs } from "@/lib/data-generator/generator";
+import type { GeneratorParams } from "@/lib/data-generator/types";
+
+export async function POST(request: NextRequest) {
+  const session = await getSession();
+
+  if (!session || session.user.role !== "admin") {
+    return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
+  }
+
+  try {
+    const body = await request.json();
+
+    const {
+      startDate,
+      endDate,
+      totalRecords,
+      totalCostCny,
+      models,
+      userIds,
+      providerIds,
+    } = body;
+
+    if (!startDate || !endDate) {
+      return NextResponse.json(
+        { error: "startDate and endDate are required" },
+        { status: 400 }
+      );
+    }
+
+    const params: GeneratorParams = {
+      startDate: new Date(startDate),
+      endDate: new Date(endDate),
+      totalRecords,
+      totalCostCny,
+      models,
+      userIds,
+      providerIds,
+    };
+
+    const result = await generateLogs(params);
+
+    return NextResponse.json(result);
+  } catch (error) {
+    console.error("Error generating logs:", error);
+    return NextResponse.json(
+      { error: error instanceof Error ? error.message : "Failed to generate logs" },
+      { status: 500 }
+    );
+  }
+}

+ 356 - 0
src/app/internal/data-gen/_components/data-generator-page.tsx

@@ -0,0 +1,356 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from "@/components/ui/table";
+import { AlertCircle, Download, FileDown, Loader2, Settings } from "lucide-react";
+import type { GeneratorResult } from "@/lib/data-generator/types";
+
+export function DataGeneratorPage() {
+  const [startDate, setStartDate] = useState<string>("");
+  const [endDate, setEndDate] = useState<string>("");
+  const [totalCostCny, setTotalCostCny] = useState<string>("");
+  const [totalRecords, setTotalRecords] = useState<string>("");
+  const [models, setModels] = useState<string>("");
+  const [userIds, setUserIds] = useState<string>("");
+  const [providerIds, setProviderIds] = useState<string>("");
+
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+  const [result, setResult] = useState<GeneratorResult | null>(null);
+  const [showParams, setShowParams] = useState(true);
+
+  const handleGenerate = async () => {
+    setLoading(true);
+    setError(null);
+    setResult(null);
+
+    try {
+      const payload: Record<string, unknown> = {
+        startDate,
+        endDate,
+      };
+
+      if (totalCostCny) {
+        payload.totalCostCny = parseFloat(totalCostCny);
+      }
+      if (totalRecords) {
+        payload.totalRecords = parseInt(totalRecords, 10);
+      }
+      if (models) {
+        payload.models = models.split(",").map((m) => m.trim()).filter(Boolean);
+      }
+      if (userIds) {
+        payload.userIds = userIds.split(",").map((id) => parseInt(id.trim(), 10)).filter(Number.isInteger);
+      }
+      if (providerIds) {
+        payload.providerIds = providerIds.split(",").map((id) => parseInt(id.trim(), 10)).filter(Number.isInteger);
+      }
+
+      const response = await fetch("/api/internal/data-gen", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify(payload),
+      });
+
+      if (!response.ok) {
+        const data = await response.json();
+        throw new Error(data.error || "Failed to generate logs");
+      }
+
+      const data: GeneratorResult = await response.json();
+      setResult(data);
+      setShowParams(false); // 生成成功后自动关闭参数框
+    } catch (err) {
+      setError(err instanceof Error ? err.message : "Unknown error");
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const handleExportPDF = async () => {
+    const html2canvas = (await import("html2canvas")).default;
+    const { jsPDF } = await import("jspdf");
+
+    const element = document.getElementById("export-content");
+    if (!element) return;
+
+    const canvas = await html2canvas(element, {
+      scale: 2,
+      useCORS: true,
+      logging: false,
+    });
+
+    const imgData = canvas.toDataURL("image/png");
+    const pdf = new jsPDF({
+      orientation: canvas.width > canvas.height ? "landscape" : "portrait",
+      unit: "px",
+      format: [canvas.width, canvas.height],
+    });
+
+    pdf.addImage(imgData, "PNG", 0, 0, canvas.width, canvas.height);
+    pdf.save(`data-generator-${new Date().toISOString().split("T")[0]}.pdf`);
+  };
+
+  const handleExportScreenshot = async () => {
+    const html2canvas = (await import("html2canvas")).default;
+
+    const element = document.getElementById("export-content");
+    if (!element) return;
+
+    const canvas = await html2canvas(element, {
+      scale: 2,
+      useCORS: true,
+      logging: false,
+    });
+
+    canvas.toBlob((blob) => {
+      if (!blob) return;
+      const url = URL.createObjectURL(blob);
+      const a = document.createElement("a");
+      a.href = url;
+      a.download = `data-generator-${new Date().toISOString().split("T")[0]}.png`;
+      a.click();
+      URL.revokeObjectURL(url);
+    });
+  };
+
+  return (
+    <div className="space-y-6 p-6">
+      {!showParams && result && (
+        <div className="flex justify-end">
+          <Button
+            variant="outline"
+            size="sm"
+            onClick={() => setShowParams(true)}
+          >
+            <Settings className="mr-2 h-4 w-4" />
+            重新配置参数
+          </Button>
+        </div>
+      )}
+
+      {showParams && (
+        <Card>
+        <CardHeader>
+          <CardTitle>生成参数</CardTitle>
+          <CardDescription>配置生成参数以创建模拟日志数据</CardDescription>
+        </CardHeader>
+        <CardContent className="space-y-4">
+          <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+            <div className="space-y-2">
+              <Label htmlFor="startDate">起始时间 *</Label>
+              <Input
+                id="startDate"
+                type="datetime-local"
+                value={startDate}
+                onChange={(e) => setStartDate(e.target.value)}
+              />
+            </div>
+            <div className="space-y-2">
+              <Label htmlFor="endDate">结束时间 *</Label>
+              <Input
+                id="endDate"
+                type="datetime-local"
+                value={endDate}
+                onChange={(e) => setEndDate(e.target.value)}
+              />
+            </div>
+            <div className="space-y-2">
+              <Label htmlFor="totalCostCny">总金额(人民币)</Label>
+              <Input
+                id="totalCostCny"
+                type="number"
+                placeholder="如:1000"
+                value={totalCostCny}
+                onChange={(e) => setTotalCostCny(e.target.value)}
+              />
+            </div>
+            <div className="space-y-2">
+              <Label htmlFor="totalRecords">总记录数</Label>
+              <Input
+                id="totalRecords"
+                type="number"
+                placeholder="如:500(不填则根据金额计算)"
+                value={totalRecords}
+                onChange={(e) => setTotalRecords(e.target.value)}
+              />
+            </div>
+            <div className="space-y-2">
+              <Label htmlFor="models">包含模型(逗号分隔)</Label>
+              <Input
+                id="models"
+                placeholder="如:claude-3-5-sonnet,gpt-4(留空则全部)"
+                value={models}
+                onChange={(e) => setModels(e.target.value)}
+              />
+            </div>
+            <div className="space-y-2">
+              <Label htmlFor="userIds">用户ID(逗号分隔)</Label>
+              <Input
+                id="userIds"
+                placeholder="如:1,2,3(留空则全部)"
+                value={userIds}
+                onChange={(e) => setUserIds(e.target.value)}
+              />
+            </div>
+            <div className="space-y-2">
+              <Label htmlFor="providerIds">供应商ID(逗号分隔)</Label>
+              <Input
+                id="providerIds"
+                placeholder="如:1,2(留空则全部)"
+                value={providerIds}
+                onChange={(e) => setProviderIds(e.target.value)}
+              />
+            </div>
+          </div>
+
+          <Button onClick={handleGenerate} disabled={loading || !startDate || !endDate}>
+            {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+            生成数据
+          </Button>
+        </CardContent>
+      </Card>
+      )}
+
+      {error && (
+        <Alert variant="destructive">
+          <AlertCircle className="h-4 w-4" />
+          <AlertDescription>{error}</AlertDescription>
+        </Alert>
+      )}
+
+      {result && (
+        <div id="export-content" className="space-y-6">
+          <div className="flex justify-end gap-2">
+            <Button variant="outline" size="sm" onClick={handleExportScreenshot}>
+              <Download className="mr-2 h-4 w-4" />
+              导出截图
+            </Button>
+            <Button variant="outline" size="sm" onClick={handleExportPDF}>
+              <FileDown className="mr-2 h-4 w-4" />
+              导出 PDF
+            </Button>
+          </div>
+
+          <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
+            <Card>
+              <CardHeader className="pb-2">
+                <CardDescription>总记录数</CardDescription>
+                <CardTitle className="text-2xl">{result.summary.totalRecords.toLocaleString()}</CardTitle>
+              </CardHeader>
+            </Card>
+            <Card>
+              <CardHeader className="pb-2">
+                <CardDescription>总成本</CardDescription>
+                <CardTitle className="text-2xl">${result.summary.totalCost.toFixed(4)}</CardTitle>
+              </CardHeader>
+            </Card>
+            <Card>
+              <CardHeader className="pb-2">
+                <CardDescription>总成本(人民币)</CardDescription>
+                <CardTitle className="text-2xl">¥{(result.summary.totalCost * 7.1).toFixed(2)}</CardTitle>
+              </CardHeader>
+            </Card>
+            <Card>
+              <CardHeader className="pb-2">
+                <CardDescription>总 Token</CardDescription>
+                <CardTitle className="text-2xl">{result.summary.totalTokens.toLocaleString()}</CardTitle>
+              </CardHeader>
+            </Card>
+          </div>
+
+          <Card>
+            <CardHeader>
+              <CardTitle>生成的日志数据</CardTitle>
+              <CardDescription>
+                共 {result.logs.length} 条记录
+              </CardDescription>
+            </CardHeader>
+            <CardContent>
+              <div className="rounded-md border max-h-[600px] overflow-auto">
+                <Table>
+                  <TableHeader className="sticky top-0 bg-background">
+                    <TableRow>
+                      <TableHead>时间</TableHead>
+                      <TableHead>用户</TableHead>
+                      <TableHead>密钥</TableHead>
+                      <TableHead>供应商</TableHead>
+                      <TableHead>模型</TableHead>
+                      <TableHead className="text-right">输入</TableHead>
+                      <TableHead className="text-right">输出</TableHead>
+                      <TableHead className="text-right">缓存写</TableHead>
+                      <TableHead className="text-right">缓存读</TableHead>
+                      <TableHead className="text-right">成本</TableHead>
+                      <TableHead className="text-right">耗时</TableHead>
+                      <TableHead>状态</TableHead>
+                    </TableRow>
+                  </TableHeader>
+                  <TableBody>
+                    {result.logs.map((log) => (
+                      <TableRow key={log.id}>
+                        <TableCell className="font-mono text-xs">
+                          {log.createdAt.toLocaleString("zh-CN")}
+                        </TableCell>
+                        <TableCell>{log.userName}</TableCell>
+                        <TableCell className="font-mono text-xs">{log.keyName}</TableCell>
+                        <TableCell>{log.providerName}</TableCell>
+                        <TableCell className="font-mono text-xs">{log.model}</TableCell>
+                        <TableCell className="text-right font-mono text-xs">
+                          {log.inputTokens.toLocaleString()}
+                        </TableCell>
+                        <TableCell className="text-right font-mono text-xs">
+                          {log.outputTokens.toLocaleString()}
+                        </TableCell>
+                        <TableCell className="text-right font-mono text-xs">
+                          {log.cacheCreationInputTokens > 0
+                            ? log.cacheCreationInputTokens.toLocaleString()
+                            : "-"}
+                        </TableCell>
+                        <TableCell className="text-right font-mono text-xs">
+                          {log.cacheReadInputTokens > 0
+                            ? log.cacheReadInputTokens.toLocaleString()
+                            : "-"}
+                        </TableCell>
+                        <TableCell className="text-right font-mono text-xs">
+                          ${parseFloat(log.costUsd).toFixed(6)}
+                        </TableCell>
+                        <TableCell className="text-right font-mono text-xs">
+                          {log.durationMs >= 1000
+                            ? `${(log.durationMs / 1000).toFixed(2)}s`
+                            : `${log.durationMs}ms`}
+                        </TableCell>
+                        <TableCell>
+                          {log.statusCode === 200 ? (
+                            <span className="inline-flex items-center rounded-md bg-green-100 dark:bg-green-950 px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300">
+                              成功
+                            </span>
+                          ) : (
+                            <span className="inline-flex items-center rounded-md bg-red-100 dark:bg-red-950 px-2 py-1 text-xs font-medium text-red-700 dark:text-red-300">
+                              {log.statusCode}
+                            </span>
+                          )}
+                        </TableCell>
+                      </TableRow>
+                    ))}
+                  </TableBody>
+                </Table>
+              </div>
+            </CardContent>
+          </Card>
+        </div>
+      )}
+    </div>
+  );
+}

+ 15 - 0
src/app/internal/data-gen/page.tsx

@@ -0,0 +1,15 @@
+import { redirect } from "next/navigation";
+import { getSession } from "@/lib/auth";
+import { DataGeneratorPage } from "./_components/data-generator-page";
+
+export const dynamic = "force-dynamic";
+
+export default async function Page() {
+  const session = await getSession();
+
+  if (!session || session.user.role !== "admin") {
+    redirect("/login");
+  }
+
+  return <DataGeneratorPage />;
+}

+ 1 - 0
src/app/settings/_lib/nav-items.ts

@@ -8,5 +8,6 @@ export const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [
   { href: "/settings/prices", label: "价格表" },
   { href: "/settings/providers", label: "供应商" },
   { href: "/settings/sensitive-words", label: "敏感词" },
+  { href: "/settings/data", label: "数据管理" },
   { href: "/settings/logs", label: "日志" },
 ];

+ 81 - 0
src/app/settings/data/_components/database-export.tsx

@@ -0,0 +1,81 @@
+"use client";
+
+import { useState } from "react";
+import { Download } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { toast } from "sonner";
+
+export function DatabaseExport() {
+  const [isExporting, setIsExporting] = useState(false);
+
+  const handleExport = async () => {
+    setIsExporting(true);
+
+    try {
+      // 从 cookie 中获取 admin token
+      const token = document.cookie
+        .split('; ')
+        .find(row => row.startsWith('admin_token='))
+        ?.split('=')[1];
+
+      if (!token) {
+        toast.error('未登录或会话已过期');
+        return;
+      }
+
+      // 调用导出 API
+      const response = await fetch('/api/admin/database/export', {
+        method: 'GET',
+        headers: {
+          'Authorization': `Bearer ${token}`,
+        },
+      });
+
+      if (!response.ok) {
+        const error = await response.json();
+        throw new Error(error.error || '导出失败');
+      }
+
+      // 获取文件名(从 Content-Disposition header)
+      const contentDisposition = response.headers.get('Content-Disposition');
+      const filenameMatch = contentDisposition?.match(/filename="(.+)"/);
+      const filename = filenameMatch?.[1] || `backup_${new Date().toISOString()}.dump`;
+
+      // 下载文件
+      const blob = await response.blob();
+      const url = window.URL.createObjectURL(blob);
+      const a = document.createElement('a');
+      a.href = url;
+      a.download = filename;
+      document.body.appendChild(a);
+      a.click();
+      document.body.removeChild(a);
+      window.URL.revokeObjectURL(url);
+
+      toast.success('数据库导出成功!');
+    } catch (error) {
+      console.error('Export error:', error);
+      toast.error(error instanceof Error ? error.message : '导出数据库失败');
+    } finally {
+      setIsExporting(false);
+    }
+  };
+
+  return (
+    <div className="flex flex-col gap-4">
+      <p className="text-sm text-muted-foreground">
+        导出完整的数据库备份文件(.dump 格式),可用于数据迁移或恢复。
+        备份文件使用 PostgreSQL custom format,自动压缩且兼容不同版本的数据库结构。
+      </p>
+
+      <Button
+        onClick={handleExport}
+        disabled={isExporting}
+        className="w-full sm:w-auto"
+      >
+        <Download className="mr-2 h-4 w-4" />
+        {isExporting ? '正在导出...' : '导出数据库'}
+      </Button>
+    </div>
+  );
+}

+ 266 - 0
src/app/settings/data/_components/database-import.tsx

@@ -0,0 +1,266 @@
+"use client";
+
+import { useState, useRef, useEffect } from "react";
+import { Upload, AlertCircle } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { toast } from "sonner";
+import type { ImportProgressEvent } from "@/types/database-backup";
+
+export function DatabaseImport() {
+  const [selectedFile, setSelectedFile] = useState<File | null>(null);
+  const [cleanFirst, setCleanFirst] = useState(true);
+  const [isImporting, setIsImporting] = useState(false);
+  const [showConfirmDialog, setShowConfirmDialog] = useState(false);
+  const [progressMessages, setProgressMessages] = useState<string[]>([]);
+  const fileInputRef = useRef<HTMLInputElement>(null);
+  const progressContainerRef = useRef<HTMLDivElement>(null);
+
+  // 自动滚动到最新进度
+  useEffect(() => {
+    if (progressContainerRef.current) {
+      progressContainerRef.current.scrollTop = progressContainerRef.current.scrollHeight;
+    }
+  }, [progressMessages]);
+
+  const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+    const file = event.target.files?.[0];
+    if (file) {
+      if (!file.name.endsWith('.dump')) {
+        toast.error('请选择 .dump 格式的备份文件');
+        return;
+      }
+      setSelectedFile(file);
+    }
+  };
+
+  const handleImportClick = () => {
+    if (!selectedFile) {
+      toast.error('请先选择备份文件');
+      return;
+    }
+    setShowConfirmDialog(true);
+  };
+
+  const handleConfirmImport = async () => {
+    if (!selectedFile) return;
+
+    setShowConfirmDialog(false);
+    setIsImporting(true);
+    setProgressMessages([]);
+
+    try {
+      // 从 cookie 中获取 admin token
+      const token = document.cookie
+        .split('; ')
+        .find(row => row.startsWith('admin_token='))
+        ?.split('=')[1];
+
+      if (!token) {
+        toast.error('未登录或会话已过期');
+        return;
+      }
+
+      // 构造表单数据
+      const formData = new FormData();
+      formData.append('file', selectedFile);
+      formData.append('cleanFirst', cleanFirst.toString());
+
+      // 调用导入 API(SSE 流式响应)
+      const response = await fetch('/api/admin/database/import', {
+        method: 'POST',
+        headers: {
+          'Authorization': `Bearer ${token}`,
+        },
+        body: formData,
+      });
+
+      if (!response.ok) {
+        const error = await response.json();
+        throw new Error(error.error || '导入失败');
+      }
+
+      // 处理 SSE 流
+      const reader = response.body?.getReader();
+      const decoder = new TextDecoder();
+
+      if (!reader) {
+        throw new Error('无法读取响应流');
+      }
+
+      while (true) {
+        const { done, value } = await reader.read();
+
+        if (done) {
+          break;
+        }
+
+        const chunk = decoder.decode(value);
+        const lines = chunk.split('\n');
+
+        for (const line of lines) {
+          if (line.startsWith('data: ')) {
+            try {
+              const data: ImportProgressEvent = JSON.parse(line.slice(6));
+
+              if (data.type === 'progress') {
+                setProgressMessages(prev => [...prev, data.message]);
+              } else if (data.type === 'complete') {
+                setProgressMessages(prev => [...prev, `✅ ${data.message}`]);
+                toast.success('数据导入完成!');
+              } else if (data.type === 'error') {
+                setProgressMessages(prev => [...prev, `❌ ${data.message}`]);
+                toast.error('数据导入失败,请查看详细日志');
+              }
+            } catch (parseError) {
+              console.error('Parse SSE error:', parseError);
+            }
+          }
+        }
+      }
+
+      // 清空文件选择
+      setSelectedFile(null);
+      if (fileInputRef.current) {
+        fileInputRef.current.value = '';
+      }
+    } catch (error) {
+      console.error('Import error:', error);
+      toast.error(error instanceof Error ? error.message : '导入数据库失败');
+      setProgressMessages(prev => [
+        ...prev,
+        `❌ 错误: ${error instanceof Error ? error.message : '未知错误'}`
+      ]);
+    } finally {
+      setIsImporting(false);
+    }
+  };
+
+  return (
+    <div className="flex flex-col gap-4">
+      <p className="text-sm text-muted-foreground">
+        从备份文件恢复数据库。支持 PostgreSQL custom format (.dump) 格式的备份文件。
+      </p>
+
+      {/* 文件选择 */}
+      <div className="flex flex-col gap-2">
+        <Label htmlFor="backup-file">选择备份文件</Label>
+        <div className="flex gap-2">
+          <input
+            ref={fileInputRef}
+            id="backup-file"
+            type="file"
+            accept=".dump"
+            onChange={handleFileChange}
+            disabled={isImporting}
+            className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
+          />
+        </div>
+        {selectedFile && (
+          <p className="text-xs text-muted-foreground">
+            已选择: {selectedFile.name} ({(selectedFile.size / 1024 / 1024).toFixed(2)} MB)
+          </p>
+        )}
+      </div>
+
+      {/* 导入选项 */}
+      <div className="flex items-start gap-2">
+        <Checkbox
+          id="clean-first"
+          checked={cleanFirst}
+          onCheckedChange={(checked: boolean) => setCleanFirst(checked === true)}
+          disabled={isImporting}
+        />
+        <div className="grid gap-1.5 leading-none">
+          <Label
+            htmlFor="clean-first"
+            className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+          >
+            清除现有数据(覆盖模式)
+          </Label>
+          <p className="text-xs text-muted-foreground">
+            导入前删除所有现有数据,确保数据库与备份文件完全一致。
+            如果不勾选,将尝试合并数据,但可能因主键冲突而失败。
+          </p>
+        </div>
+      </div>
+
+      {/* 导入按钮 */}
+      <Button
+        onClick={handleImportClick}
+        disabled={!selectedFile || isImporting}
+        className="w-full sm:w-auto"
+      >
+        <Upload className="mr-2 h-4 w-4" />
+        {isImporting ? '正在导入...' : '导入数据库'}
+      </Button>
+
+      {/* 进度显示 */}
+      {progressMessages.length > 0 && (
+        <div className="mt-2 rounded-md border border-border bg-muted/30 p-3">
+          <h3 className="text-sm font-medium mb-2">导入进度</h3>
+          <div
+            ref={progressContainerRef}
+            className="max-h-60 overflow-y-auto rounded bg-background p-2 font-mono text-xs space-y-1"
+          >
+            {progressMessages.map((message, index) => (
+              <div key={index} className="text-muted-foreground">
+                {message}
+              </div>
+            ))}
+          </div>
+        </div>
+      )}
+
+      {/* 确认对话框 */}
+      <AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
+        <AlertDialogContent>
+          <AlertDialogHeader>
+            <AlertDialogTitle className="flex items-center gap-2">
+              <AlertCircle className="h-5 w-5 text-orange-500" />
+              确认导入数据库
+            </AlertDialogTitle>
+            <AlertDialogDescription className="space-y-2">
+              <p>
+                {cleanFirst
+                  ? '您选择了「覆盖模式」,这将会删除所有现有数据后导入备份。'
+                  : '您选择了「合并模式」,这将尝试在保留现有数据的基础上导入备份。'}
+              </p>
+              <p className="font-semibold text-foreground">
+                {cleanFirst
+                  ? '⚠️ 警告:此操作不可逆,所有当前数据将被永久删除!'
+                  : '⚠️ 注意:如果存在主键冲突,导入可能会失败。'}
+              </p>
+              <p>
+                备份文件: <span className="font-mono text-xs">{selectedFile?.name}</span>
+              </p>
+              <p className="text-xs text-muted-foreground">
+                建议在执行此操作前,先导出当前数据库作为备份。
+              </p>
+            </AlertDialogDescription>
+          </AlertDialogHeader>
+          <AlertDialogFooter>
+            <AlertDialogCancel>取消</AlertDialogCancel>
+            <AlertDialogAction
+              onClick={handleConfirmImport}
+              className="bg-orange-500 hover:bg-orange-600 text-white"
+            >
+              确认导入
+            </AlertDialogAction>
+          </AlertDialogFooter>
+        </AlertDialogContent>
+      </AlertDialog>
+    </div>
+  );
+}

+ 144 - 0
src/app/settings/data/_components/database-status.tsx

@@ -0,0 +1,144 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { Database, Server, AlertCircle, CheckCircle } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import type { DatabaseStatus } from "@/types/database-backup";
+
+export function DatabaseStatusDisplay() {
+  const [status, setStatus] = useState<DatabaseStatus | null>(null);
+  const [isLoading, setIsLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
+
+  const fetchStatus = async () => {
+    setIsLoading(true);
+    setError(null);
+
+    try {
+      // 从 cookie 中获取 admin token
+      const token = document.cookie
+        .split('; ')
+        .find(row => row.startsWith('admin_token='))
+        ?.split('=')[1];
+
+      if (!token) {
+        setError('未登录或会话已过期');
+        return;
+      }
+
+      const response = await fetch('/api/admin/database/status', {
+        method: 'GET',
+        headers: {
+          'Authorization': `Bearer ${token}`,
+        },
+      });
+
+      if (!response.ok) {
+        const errorData = await response.json();
+        throw new Error(errorData.error || '获取状态失败');
+      }
+
+      const data: DatabaseStatus = await response.json();
+      setStatus(data);
+    } catch (err) {
+      console.error('Fetch status error:', err);
+      setError(err instanceof Error ? err.message : '获取数据库状态失败');
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    fetchStatus();
+  }, []);
+
+  if (isLoading) {
+    return (
+      <div className="flex items-center justify-center py-8">
+        <div className="text-sm text-muted-foreground">加载中...</div>
+      </div>
+    );
+  }
+
+  if (error) {
+    return (
+      <div className="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-4">
+        <AlertCircle className="h-5 w-5 text-destructive" />
+        <div className="flex-1">
+          <p className="text-sm font-medium text-destructive">{error}</p>
+        </div>
+        <Button variant="outline" size="sm" onClick={fetchStatus}>
+          重试
+        </Button>
+      </div>
+    );
+  }
+
+  if (!status) {
+    return null;
+  }
+
+  return (
+    <div className="space-y-4">
+      {/* 连接状态 */}
+      <div className="flex items-center gap-2">
+        {status.isAvailable ? (
+          <>
+            <CheckCircle className="h-5 w-5 text-green-500" />
+            <span className="text-sm font-medium">数据库连接正常</span>
+          </>
+        ) : (
+          <>
+            <AlertCircle className="h-5 w-5 text-orange-500" />
+            <span className="text-sm font-medium text-orange-500">数据库不可用</span>
+          </>
+        )}
+        <Button variant="ghost" size="sm" onClick={fetchStatus} className="ml-auto">
+          刷新
+        </Button>
+      </div>
+
+      {/* 错误信息 */}
+      {status.error && (
+        <div className="rounded-md border border-orange-200 bg-orange-50 p-3 text-sm text-orange-800 dark:border-orange-800 dark:bg-orange-950 dark:text-orange-200">
+          {status.error}
+        </div>
+      )}
+
+      {/* 数据库信息 */}
+      {status.isAvailable && (
+        <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
+          <div className="rounded-md border border-border bg-muted/30 p-3">
+            <div className="flex items-center gap-2 text-muted-foreground mb-1">
+              <Database className="h-4 w-4" />
+              <span className="text-xs font-medium">数据库大小</span>
+            </div>
+            <p className="text-lg font-semibold">{status.databaseSize}</p>
+          </div>
+
+          <div className="rounded-md border border-border bg-muted/30 p-3">
+            <div className="flex items-center gap-2 text-muted-foreground mb-1">
+              <Server className="h-4 w-4" />
+              <span className="text-xs font-medium">表数量</span>
+            </div>
+            <p className="text-lg font-semibold">{status.tableCount} 个</p>
+          </div>
+
+          <div className="rounded-md border border-border bg-muted/30 p-3 col-span-2">
+            <div className="flex items-center gap-2 text-muted-foreground mb-1">
+              <Server className="h-4 w-4" />
+              <span className="text-xs font-medium">PostgreSQL 版本</span>
+            </div>
+            <p className="text-sm font-semibold">{status.postgresVersion}</p>
+          </div>
+        </div>
+      )}
+
+      {/* 详细信息 */}
+      <div className="text-xs text-muted-foreground space-y-1">
+        <p>容器名称: <span className="font-mono">{status.containerName}</span></p>
+        <p>数据库名称: <span className="font-mono">{status.databaseName}</span></p>
+      </div>
+    </div>
+  );
+}

+ 69 - 0
src/app/settings/data/page.tsx

@@ -0,0 +1,69 @@
+import { Section } from "@/components/section";
+import { SettingsPageHeader } from "../_components/settings-page-header";
+import { DatabaseStatusDisplay } from "./_components/database-status";
+import { DatabaseExport } from "./_components/database-export";
+import { DatabaseImport } from "./_components/database-import";
+
+export const dynamic = "force-dynamic";
+
+export default async function SettingsDataPage() {
+  return (
+    <>
+      <SettingsPageHeader
+        title="数据管理"
+        description="管理数据库的备份与恢复,支持完整数据导入导出。"
+      />
+
+      <Section
+        title="数据库状态"
+        description="查看当前数据库的连接状态和基本信息。"
+      >
+        <DatabaseStatusDisplay />
+      </Section>
+
+      <Section
+        title="数据导出"
+        description="将数据库导出为备份文件,用于数据迁移或灾难恢复。"
+      >
+        <DatabaseExport />
+      </Section>
+
+      <Section
+        title="数据导入"
+        description="从备份文件恢复数据库,支持覆盖和合并两种模式。"
+      >
+        <DatabaseImport />
+      </Section>
+
+      <Section
+        title="使用说明"
+        description="数据备份与恢复的注意事项"
+      >
+        <div className="prose prose-sm dark:prose-invert max-w-none">
+          <ul className="text-sm text-muted-foreground space-y-2">
+            <li>
+              <strong>备份格式</strong>: 使用 PostgreSQL custom format (.dump),
+              自动压缩且能够兼容不同版本的数据库结构。
+            </li>
+            <li>
+              <strong>覆盖模式</strong>: 导入前会删除所有现有数据,确保数据库与备份文件完全一致。
+              适合完整恢复场景。
+            </li>
+            <li>
+              <strong>合并模式</strong>: 保留现有数据,尝试插入备份中的数据。
+              如果存在主键冲突可能导致导入失败。
+            </li>
+            <li>
+              <strong>安全建议</strong>: 在执行导入操作前,建议先导出当前数据库作为备份,
+              避免数据丢失。
+            </li>
+            <li>
+              <strong>环境要求</strong>: 此功能需要 Docker Compose 部署环境。
+              本地开发环境可能无法使用。
+            </li>
+          </ul>
+        </div>
+      </Section>
+    </>
+  );
+}

+ 30 - 0
src/components/ui/checkbox.tsx

@@ -0,0 +1,30 @@
+"use client"
+
+import * as React from "react"
+import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
+import { Check } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Checkbox = React.forwardRef<
+  React.ElementRef<typeof CheckboxPrimitive.Root>,
+  React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
+>(({ className, ...props }, ref) => (
+  <CheckboxPrimitive.Root
+    ref={ref}
+    className={cn(
+      "peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
+      className
+    )}
+    {...props}
+  >
+    <CheckboxPrimitive.Indicator
+      className={cn("flex items-center justify-center text-current")}
+    >
+      <Check className="h-4 w-4" />
+    </CheckboxPrimitive.Indicator>
+  </CheckboxPrimitive.Root>
+))
+Checkbox.displayName = CheckboxPrimitive.Root.displayName
+
+export { Checkbox }

+ 233 - 0
src/lib/data-generator/analyzer.ts

@@ -0,0 +1,233 @@
+"use server";
+
+import { db } from "@/drizzle/db";
+import { messageRequest, users, providers } from "@/drizzle/schema";
+import { isNull, desc } from "drizzle-orm";
+import { findAllLatestPrices } from "@/repository/model-price";
+import type {
+  LogDistribution,
+  HourlyDistribution,
+  WeightedItem,
+  UserInfo,
+  ProviderInfo,
+  ModelInfo,
+  TokenStats,
+  DurationStats,
+  CostStats,
+} from "./types";
+
+const SAMPLE_LIMIT = 10000;
+
+function calculateMeanAndStddev(values: number[]): { mean: number; stddev: number } {
+  if (values.length === 0) {
+    return { mean: 0, stddev: 0 };
+  }
+
+  const mean = values.reduce((sum, val) => sum + val, 0) / values.length;
+  const variance =
+    values.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / values.length;
+  const stddev = Math.sqrt(variance);
+
+  return { mean, stddev };
+}
+
+function getDefaultDistribution(): LogDistribution {
+  const hourlyPattern: HourlyDistribution = {};
+  for (let h = 0; h < 24; h++) {
+    if (h >= 9 && h <= 21) {
+      hourlyPattern[h] = 3;
+    } else if (h >= 22 || h <= 6) {
+      hourlyPattern[h] = 0.3;
+    } else {
+      hourlyPattern[h] = 1;
+    }
+  }
+
+  return {
+    hourlyPattern,
+    userWeights: [],
+    providerWeights: [],
+    modelWeights: [],
+    tokenStats: { mean: 5000, stddev: 2000 },
+    durationStats: { mean: 3000, stddev: 1500 },
+    costStats: { mean: 0.05, stddev: 0.03 },
+    errorRate: 0.02,
+    totalRecords: 0,
+  };
+}
+
+export async function analyzeLogDistribution(): Promise<LogDistribution> {
+  try {
+    const logs = await db
+      .select({
+        createdAt: messageRequest.createdAt,
+        userId: messageRequest.userId,
+        providerId: messageRequest.providerId,
+        model: messageRequest.model,
+        inputTokens: messageRequest.inputTokens,
+        outputTokens: messageRequest.outputTokens,
+        cacheCreationInputTokens: messageRequest.cacheCreationInputTokens,
+        cacheReadInputTokens: messageRequest.cacheReadInputTokens,
+        costUsd: messageRequest.costUsd,
+        durationMs: messageRequest.durationMs,
+        statusCode: messageRequest.statusCode,
+      })
+      .from(messageRequest)
+      .where(isNull(messageRequest.deletedAt))
+      .orderBy(desc(messageRequest.createdAt))
+      .limit(SAMPLE_LIMIT);
+
+    if (logs.length === 0) {
+      return getDefaultDistribution();
+    }
+
+    const hourCounts: Record<number, number> = {};
+    for (let h = 0; h < 24; h++) {
+      hourCounts[h] = 0;
+    }
+
+    const userCounts: Record<number, number> = {};
+    const providerCounts: Record<number, number> = {};
+    const modelCounts: Record<string, number> = {};
+    const tokenValues: number[] = [];
+    const durationValues: number[] = [];
+    const costValues: number[] = [];
+    let errorCount = 0;
+
+    for (const log of logs) {
+      if (log.createdAt) {
+        const hour = new Date(log.createdAt).getHours();
+        hourCounts[hour] = (hourCounts[hour] || 0) + 1;
+      }
+
+      userCounts[log.userId] = (userCounts[log.userId] || 0) + 1;
+      providerCounts[log.providerId] = (providerCounts[log.providerId] || 0) + 1;
+
+      if (log.model) {
+        modelCounts[log.model] = (modelCounts[log.model] || 0) + 1;
+      }
+
+      const totalTokens =
+        (log.inputTokens || 0) +
+        (log.outputTokens || 0) +
+        (log.cacheCreationInputTokens || 0) +
+        (log.cacheReadInputTokens || 0);
+
+      if (totalTokens > 0) {
+        tokenValues.push(totalTokens);
+      }
+
+      if (log.durationMs && log.durationMs > 0) {
+        durationValues.push(log.durationMs);
+      }
+
+      if (log.costUsd) {
+        const cost = parseFloat(log.costUsd);
+        if (cost > 0) {
+          costValues.push(cost);
+        }
+      }
+
+      if (log.statusCode && log.statusCode >= 400) {
+        errorCount++;
+      }
+    }
+
+    const usersData = await db
+      .select({ id: users.id, name: users.name })
+      .from(users)
+      .where(isNull(users.deletedAt));
+
+    const providersData = await db
+      .select({ id: providers.id, name: providers.name })
+      .from(providers)
+      .where(isNull(providers.deletedAt));
+
+    const modelPrices = await findAllLatestPrices();
+
+    const userWeights: WeightedItem<UserInfo>[] = usersData
+      .map((user) => ({
+        item: { id: user.id, name: user.name },
+        weight: userCounts[user.id] || 1,
+      }))
+      .filter((item) => item.weight > 0);
+
+    const providerWeights: WeightedItem<ProviderInfo>[] = providersData
+      .map((provider) => ({
+        item: { id: provider.id, name: provider.name },
+        weight: providerCounts[provider.id] || 1,
+      }))
+      .filter((item) => item.weight > 0);
+
+    const modelWeights: WeightedItem<ModelInfo>[] = Object.entries(modelCounts)
+      .map(([modelName, count]) => {
+        const priceInfo = modelPrices.find((p) => p.modelName === modelName);
+        const priceData = priceInfo?.priceData;
+
+        let inputPricePerM = 0.003;
+        let outputPricePerM = 0.015;
+        let cacheWritePricePerM: number | undefined = undefined;
+        let cacheReadPricePerM: number | undefined = undefined;
+
+        if (priceData) {
+          if ("input_cost_per_token" in priceData) {
+            inputPricePerM = (priceData.input_cost_per_token || 0) * 1_000_000;
+            outputPricePerM = (priceData.output_cost_per_token || 0) * 1_000_000;
+
+            if (priceData.cache_creation_input_token_cost) {
+              cacheWritePricePerM = priceData.cache_creation_input_token_cost * 1_000_000;
+            }
+            if (priceData.cache_read_input_token_cost) {
+              cacheReadPricePerM = priceData.cache_read_input_token_cost * 1_000_000;
+            }
+          } else if ("prompt_cost_per_token" in priceData) {
+            inputPricePerM = ((priceData.prompt_cost_per_token as number) || 0) * 1_000_000;
+            outputPricePerM = ((priceData.completion_cost_per_token as number) || 0) * 1_000_000;
+          }
+        }
+
+        return {
+          item: {
+            name: modelName,
+            inputPricePerM,
+            outputPricePerM,
+            cacheWritePricePerM,
+            cacheReadPricePerM,
+          },
+          weight: count,
+        };
+      })
+      .filter((item) => item.weight > 0);
+
+    const tokenStats: TokenStats = calculateMeanAndStddev(tokenValues);
+    const durationStats: DurationStats = calculateMeanAndStddev(durationValues);
+    const costStats: CostStats = calculateMeanAndStddev(costValues);
+    const errorRate = logs.length > 0 ? errorCount / logs.length : 0.02;
+
+    const hourlyPattern: HourlyDistribution = {};
+    const totalHourCounts = Object.values(hourCounts).reduce((sum, count) => sum + count, 0);
+
+    for (let h = 0; h < 24; h++) {
+      if (totalHourCounts > 0) {
+        hourlyPattern[h] = hourCounts[h] / totalHourCounts;
+      } else {
+        hourlyPattern[h] = h >= 9 && h <= 21 ? 3 : h >= 22 || h <= 6 ? 0.3 : 1;
+      }
+    }
+
+    return {
+      hourlyPattern,
+      userWeights,
+      providerWeights,
+      modelWeights,
+      tokenStats,
+      durationStats,
+      costStats,
+      errorRate,
+      totalRecords: logs.length,
+    };
+  } catch (error) {
+    console.error("Error analyzing log distribution:", error);
+    return getDefaultDistribution();
+  }
+}

+ 301 - 0
src/lib/data-generator/generator.ts

@@ -0,0 +1,301 @@
+import type {
+  LogDistribution,
+  GeneratorParams,
+  GeneratorResult,
+  GeneratedLog,
+  WeightedItem,
+  ProviderInfo,
+  ModelInfo,
+} from "./types";
+import type { ProviderChainItem } from "@/types/message";
+import { analyzeLogDistribution } from "./analyzer";
+
+const CNY_TO_USD = 7.1;
+const MAX_RECORDS = 10000;
+
+function weightedRandom<T>(items: WeightedItem<T>[]): T {
+  if (items.length === 0) {
+    throw new Error("Cannot select from empty array");
+  }
+
+  const totalWeight = items.reduce((sum, item) => sum + item.weight, 0);
+  let random = Math.random() * totalWeight;
+
+  for (const item of items) {
+    random -= item.weight;
+    if (random <= 0) {
+      return item.item;
+    }
+  }
+
+  return items[items.length - 1].item;
+}
+
+function normalRandom(mean: number, stddev: number): number {
+  const u1 = Math.random();
+  const u2 = Math.random();
+  const z0 = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
+  const value = mean + z0 * stddev;
+  return Math.max(0, Math.round(value));
+}
+
+function generateTimestamp(
+  startDate: Date,
+  endDate: Date,
+  hourlyPattern: Record<number, number>
+): Date {
+  const startTime = startDate.getTime();
+  const endTime = endDate.getTime();
+  const totalMs = endTime - startTime;
+
+  const randomMs = Math.random() * totalMs;
+  const baseTimestamp = new Date(startTime + randomMs);
+
+  const hour = baseTimestamp.getHours();
+  const hourWeight = hourlyPattern[hour] || 1;
+
+  if (Math.random() > hourWeight / 3) {
+    return generateTimestamp(startDate, endDate, hourlyPattern);
+  }
+
+  return baseTimestamp;
+}
+
+function generateSessionId(): string {
+  return `sess_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
+}
+
+function calculateCost(
+  inputTokens: number,
+  outputTokens: number,
+  cacheCreationTokens: number,
+  cacheReadTokens: number,
+  modelInfo: ModelInfo
+): number {
+  const inputCost = (inputTokens / 1_000_000) * modelInfo.inputPricePerM;
+  const outputCost = (outputTokens / 1_000_000) * modelInfo.outputPricePerM;
+
+  let cacheCost = 0;
+  if (modelInfo.cacheWritePricePerM && cacheCreationTokens > 0) {
+    cacheCost += (cacheCreationTokens / 1_000_000) * modelInfo.cacheWritePricePerM;
+  }
+  if (modelInfo.cacheReadPricePerM && cacheReadTokens > 0) {
+    cacheCost += (cacheReadTokens / 1_000_000) * modelInfo.cacheReadPricePerM;
+  }
+
+  return inputCost + outputCost + cacheCost;
+}
+
+function generateProviderChain(
+  finalProvider: ProviderInfo,
+  distribution: LogDistribution
+): ProviderChainItem[] {
+  if (Math.random() > 0.3 || distribution.providerWeights.length <= 1) {
+    return [];
+  }
+
+  const numRetries = Math.floor(Math.random() * 2) + 1;
+  const chain: ProviderChainItem[] = [];
+
+  for (let i = 0; i < numRetries; i++) {
+    const provider = weightedRandom(distribution.providerWeights);
+    if (provider.id !== finalProvider.id) {
+      chain.push({ id: provider.id, name: provider.name });
+    }
+  }
+
+  return chain;
+}
+
+export async function generateLogs(params: GeneratorParams): Promise<GeneratorResult> {
+  const distribution = await analyzeLogDistribution();
+
+  if (
+    distribution.userWeights.length === 0 ||
+    distribution.providerWeights.length === 0 ||
+    distribution.modelWeights.length === 0
+  ) {
+    throw new Error("Insufficient data to generate logs. Please ensure users, providers, and model prices are configured.");
+  }
+
+  let filteredUsers = distribution.userWeights;
+  if (params.userIds && params.userIds.length > 0) {
+    filteredUsers = distribution.userWeights.filter((w) =>
+      params.userIds!.includes(w.item.id)
+    );
+  }
+
+  let filteredProviders = distribution.providerWeights;
+  if (params.providerIds && params.providerIds.length > 0) {
+    filteredProviders = distribution.providerWeights.filter((w) =>
+      params.providerIds!.includes(w.item.id)
+    );
+  }
+
+  let filteredModels = distribution.modelWeights;
+  if (params.models && params.models.length > 0) {
+    filteredModels = distribution.modelWeights.filter((w) =>
+      params.models!.includes(w.item.name)
+    );
+  }
+
+  if (
+    filteredUsers.length === 0 ||
+    filteredProviders.length === 0 ||
+    filteredModels.length === 0
+  ) {
+    throw new Error("No data matches the filter criteria");
+  }
+
+  let targetRecords = params.totalRecords || 1000;
+
+  if (params.totalCostCny) {
+    const targetCostUsd = params.totalCostCny / CNY_TO_USD;
+    const avgCost = distribution.costStats.mean || 0.05;
+    targetRecords = Math.round(targetCostUsd / avgCost);
+  }
+
+  targetRecords = Math.min(targetRecords, MAX_RECORDS);
+
+  const logs: GeneratedLog[] = [];
+  let totalCost = 0;
+  let totalTokens = 0;
+  let totalInputTokens = 0;
+  let totalOutputTokens = 0;
+  let totalCacheCreationTokens = 0;
+  let totalCacheReadTokens = 0;
+
+  const recentSessions: string[] = [];
+
+  for (let i = 0; i < targetRecords; i++) {
+    const user = weightedRandom(filteredUsers);
+    const provider = weightedRandom(filteredProviders);
+    const model = weightedRandom(filteredModels);
+
+    const createdAt = generateTimestamp(
+      params.startDate,
+      params.endDate,
+      distribution.hourlyPattern
+    );
+
+    let sessionId: string | null = null;
+    if (Math.random() < 0.2 && recentSessions.length > 0) {
+      sessionId = recentSessions[Math.floor(Math.random() * recentSessions.length)];
+    } else {
+      sessionId = generateSessionId();
+      recentSessions.push(sessionId);
+      if (recentSessions.length > 20) {
+        recentSessions.shift();
+      }
+    }
+
+    const isError = Math.random() < distribution.errorRate;
+    const statusCode = isError ? (Math.random() < 0.5 ? 429 : 500) : 200;
+
+    let inputTokens = 0;
+    let outputTokens = 0;
+    let cacheCreationInputTokens = 0;
+    let cacheReadInputTokens = 0;
+    let durationMs = 0;
+    let costUsd = 0;
+    let errorMessage: string | null = null;
+
+    if (!isError) {
+      const totalTokensGenerated = normalRandom(
+        distribution.tokenStats.mean,
+        distribution.tokenStats.stddev
+      );
+
+      inputTokens = Math.floor(totalTokensGenerated * 0.6);
+      outputTokens = Math.floor(totalTokensGenerated * 0.35);
+
+      if (Math.random() < 0.1 && model.cacheWritePricePerM) {
+        cacheCreationInputTokens = Math.floor(inputTokens * 0.3);
+        inputTokens = inputTokens - cacheCreationInputTokens;
+      }
+
+      if (Math.random() < 0.15 && model.cacheReadPricePerM) {
+        cacheReadInputTokens = Math.floor(inputTokens * 0.5);
+        inputTokens = inputTokens - cacheReadInputTokens;
+      }
+
+      durationMs = normalRandom(
+        distribution.durationStats.mean,
+        distribution.durationStats.stddev
+      );
+
+      costUsd = calculateCost(
+        inputTokens,
+        outputTokens,
+        cacheCreationInputTokens,
+        cacheReadInputTokens,
+        model
+      );
+    } else {
+      durationMs = Math.floor(Math.random() * 2000) + 500;
+      errorMessage =
+        statusCode === 429
+          ? "Rate limit exceeded"
+          : "Internal server error";
+    }
+
+    const providerChain = generateProviderChain(provider, distribution);
+
+    logs.push({
+      id: i + 1,
+      createdAt,
+      sessionId,
+      userName: user.name,
+      keyName: `${user.name}-key-${Math.floor(Math.random() * 3) + 1}`,
+      providerName: provider.name,
+      model: model.name,
+      statusCode,
+      inputTokens,
+      outputTokens,
+      cacheCreationInputTokens,
+      cacheReadInputTokens,
+      totalTokens: inputTokens + outputTokens + cacheCreationInputTokens + cacheReadInputTokens,
+      costUsd: costUsd.toFixed(15),
+      durationMs,
+      errorMessage,
+      providerChain: providerChain.length > 0 ? providerChain : null,
+      blockedBy: null,
+      blockedReason: null,
+    });
+
+    totalCost += costUsd;
+    totalTokens += inputTokens + outputTokens + cacheCreationInputTokens + cacheReadInputTokens;
+    totalInputTokens += inputTokens;
+    totalOutputTokens += outputTokens;
+    totalCacheCreationTokens += cacheCreationInputTokens;
+    totalCacheReadTokens += cacheReadInputTokens;
+  }
+
+  logs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
+
+  if (params.totalCostCny) {
+    const targetCostUsd = params.totalCostCny / CNY_TO_USD;
+    const scaleFactor = targetCostUsd / totalCost;
+
+    for (const log of logs) {
+      const originalCost = parseFloat(log.costUsd);
+      const scaledCost = originalCost * scaleFactor;
+      log.costUsd = scaledCost.toFixed(15);
+    }
+
+    totalCost = targetCostUsd;
+  }
+
+  return {
+    logs,
+    summary: {
+      totalRecords: logs.length,
+      totalCost,
+      totalTokens,
+      totalInputTokens,
+      totalOutputTokens,
+      totalCacheCreationTokens,
+      totalCacheReadTokens,
+    },
+  };
+}

+ 100 - 0
src/lib/data-generator/types.ts

@@ -0,0 +1,100 @@
+import type { ProviderChainItem } from "@/types/message";
+
+export interface HourlyDistribution {
+  [hour: number]: number;
+}
+
+export interface WeightedItem<T> {
+  item: T;
+  weight: number;
+}
+
+export interface TokenStats {
+  mean: number;
+  stddev: number;
+}
+
+export interface DurationStats {
+  mean: number;
+  stddev: number;
+}
+
+export interface CostStats {
+  mean: number;
+  stddev: number;
+}
+
+export interface UserInfo {
+  id: number;
+  name: string;
+}
+
+export interface ProviderInfo {
+  id: number;
+  name: string;
+}
+
+export interface ModelInfo {
+  name: string;
+  inputPricePerM: number;
+  outputPricePerM: number;
+  cacheWritePricePerM?: number;
+  cacheReadPricePerM?: number;
+}
+
+export interface LogDistribution {
+  hourlyPattern: HourlyDistribution;
+  userWeights: WeightedItem<UserInfo>[];
+  providerWeights: WeightedItem<ProviderInfo>[];
+  modelWeights: WeightedItem<ModelInfo>[];
+  tokenStats: TokenStats;
+  durationStats: DurationStats;
+  costStats: CostStats;
+  errorRate: number;
+  totalRecords: number;
+}
+
+export interface GeneratorParams {
+  startDate: Date;
+  endDate: Date;
+  totalRecords?: number;
+  totalCostCny?: number;
+  models?: string[];
+  userIds?: number[];
+  providerIds?: number[];
+}
+
+export interface GeneratedLog {
+  id: number;
+  createdAt: Date;
+  sessionId: string | null;
+  userName: string;
+  keyName: string;
+  providerName: string;
+  model: string;
+  statusCode: number;
+  inputTokens: number;
+  outputTokens: number;
+  cacheCreationInputTokens: number;
+  cacheReadInputTokens: number;
+  totalTokens: number;
+  costUsd: string;
+  durationMs: number;
+  errorMessage: string | null;
+  providerChain: ProviderChainItem[] | null;
+  blockedBy: string | null;
+  blockedReason: string | null;
+}
+
+export interface GeneratorResult {
+  logs: GeneratedLog[];
+  summary: {
+    totalRecords: number;
+    totalCost: number;
+    totalTokens: number;
+    totalInputTokens: number;
+    totalOutputTokens: number;
+    totalCacheCreationTokens: number;
+    totalCacheReadTokens: number;
+  };
+}

+ 299 - 0
src/lib/database-backup/docker-executor.ts

@@ -0,0 +1,299 @@
+import { spawn, type ChildProcessWithoutNullStreams } from 'child_process';
+import { createReadStream } from 'fs';
+import { logger } from '@/lib/logger';
+
+/**
+ * 检查 Docker 容器是否可用
+ */
+export async function checkDockerContainer(containerName: string): Promise<boolean> {
+  return new Promise((resolve) => {
+    const process = spawn('docker', ['inspect', containerName]);
+
+    process.on('close', (code) => {
+      resolve(code === 0);
+    });
+
+    process.on('error', () => {
+      resolve(false);
+    });
+  });
+}
+
+/**
+ * 执行 pg_dump 导出数据库
+ *
+ * @param containerName Docker 容器名称
+ * @param databaseName 数据库名称
+ * @returns ReadableStream 数据流
+ */
+export function executePgDump(
+  containerName: string,
+  databaseName: string
+): ReadableStream<Uint8Array> {
+  const process = spawn('docker', [
+    'exec',
+    containerName,
+    'pg_dump',
+    '-Fc', // Custom format (compressed)
+    '-v',  // Verbose
+    '-d',
+    databaseName,
+  ]);
+
+  logger.info({
+    action: 'pg_dump_start',
+    containerName,
+    databaseName,
+  });
+
+  return new ReadableStream({
+    start(controller) {
+      // 监听 stdout (数据输出)
+      process.stdout.on('data', (chunk: Buffer) => {
+        controller.enqueue(new Uint8Array(chunk));
+      });
+
+      // 监听 stderr (日志输出)
+      process.stderr.on('data', (chunk: Buffer) => {
+        logger.info(`[pg_dump] ${chunk.toString().trim()}`);
+      });
+
+      // 进程结束
+      process.on('close', (code) => {
+        if (code === 0) {
+          logger.info({
+            action: 'pg_dump_complete',
+            containerName,
+            databaseName,
+          });
+          controller.close();
+        } else {
+          const error = `pg_dump 失败,退出代码: ${code}`;
+          logger.error({
+            action: 'pg_dump_error',
+            containerName,
+            databaseName,
+            exitCode: code,
+          });
+          controller.error(new Error(error));
+        }
+      });
+
+      // 进程错误
+      process.on('error', (err) => {
+        logger.error({
+          action: 'pg_dump_spawn_error',
+          error: err.message,
+        });
+        controller.error(err);
+      });
+    },
+
+    cancel() {
+      process.kill();
+      logger.warn({
+        action: 'pg_dump_cancelled',
+        containerName,
+        databaseName,
+      });
+    },
+  });
+}
+
+/**
+ * 执行 pg_restore 导入数据库
+ *
+ * @param containerName Docker 容器名称
+ * @param databaseName 数据库名称
+ * @param filePath 备份文件路径
+ * @param cleanFirst 是否清除现有数据
+ * @returns ReadableStream SSE 格式的进度流
+ */
+export function executePgRestore(
+  containerName: string,
+  databaseName: string,
+  filePath: string,
+  cleanFirst: boolean
+): ReadableStream<Uint8Array> {
+  const args = [
+    'exec',
+    '-i', // 交互模式(接收 stdin)
+    containerName,
+    'pg_restore',
+    '-v', // Verbose(输出详细进度)
+    '-d',
+    databaseName,
+  ];
+
+  // 覆盖模式:清除现有数据
+  if (cleanFirst) {
+    args.push('--clean', '--if-exists');
+  }
+
+  const process = spawn('docker', args);
+
+  logger.info({
+    action: 'pg_restore_start',
+    containerName,
+    databaseName,
+    cleanFirst,
+    filePath,
+  });
+
+  // 将备份文件通过 stdin 传给 pg_restore
+  const fileStream = createReadStream(filePath);
+  fileStream.pipe(process.stdin);
+
+  const encoder = new TextEncoder();
+
+  return new ReadableStream({
+    start(controller) {
+      // 监听 stderr(pg_restore 的进度信息都输出到 stderr)
+      process.stderr.on('data', (chunk: Buffer) => {
+        const message = chunk.toString().trim();
+        logger.info(`[pg_restore] ${message}`);
+
+        // 发送 SSE 格式的进度消息
+        const sseMessage = `data: ${JSON.stringify({ type: 'progress', message })}\n\n`;
+        controller.enqueue(encoder.encode(sseMessage));
+      });
+
+      // 监听 stdout(一般为空,但为了完整性还是处理)
+      process.stdout.on('data', (chunk: Buffer) => {
+        const message = chunk.toString().trim();
+        if (message) {
+          logger.info(`[pg_restore stdout] ${message}`);
+        }
+      });
+
+      // 进程结束
+      process.on('close', (code) => {
+        if (code === 0) {
+          logger.info({
+            action: 'pg_restore_complete',
+            containerName,
+            databaseName,
+          });
+
+          const completeMessage = `data: ${JSON.stringify({
+            type: 'complete',
+            message: '数据导入成功!',
+            exitCode: code
+          })}\n\n`;
+          controller.enqueue(encoder.encode(completeMessage));
+        } else {
+          logger.error({
+            action: 'pg_restore_error',
+            containerName,
+            databaseName,
+            exitCode: code,
+          });
+
+          const errorMessage = `data: ${JSON.stringify({
+            type: 'error',
+            message: `数据导入失败,退出代码: ${code}`,
+            exitCode: code
+          })}\n\n`;
+          controller.enqueue(encoder.encode(errorMessage));
+        }
+
+        controller.close();
+      });
+
+      // 进程错误
+      process.on('error', (err) => {
+        logger.error({
+          action: 'pg_restore_spawn_error',
+          error: err.message,
+        });
+
+        const errorMessage = `data: ${JSON.stringify({
+          type: 'error',
+          message: `执行 pg_restore 失败: ${err.message}`
+        })}\n\n`;
+        controller.enqueue(encoder.encode(errorMessage));
+        controller.close();
+      });
+    },
+
+    cancel() {
+      process.kill();
+      fileStream.destroy();
+      logger.warn({
+        action: 'pg_restore_cancelled',
+        containerName,
+        databaseName,
+      });
+    },
+  });
+}
+
+/**
+ * 获取数据库信息
+ */
+export async function getDatabaseInfo(
+  containerName: string,
+  databaseName: string
+): Promise<{
+  size: string;
+  tableCount: number;
+  version: string;
+}> {
+  return new Promise((resolve, reject) => {
+    // 查询数据库大小和表数量
+    const query = `
+      SELECT
+        pg_size_pretty(pg_database_size('${databaseName}')) as size,
+        (SELECT count(*) FROM information_schema.tables
+         WHERE table_schema = 'public' AND table_type = 'BASE TABLE') as table_count,
+        version() as version;
+    `;
+
+    const process = spawn('docker', [
+      'exec',
+      containerName,
+      'psql',
+      '-U',
+      'postgres',
+      '-d',
+      databaseName,
+      '-t', // 不显示列名
+      '-A', // 不对齐
+      '-c',
+      query,
+    ]);
+
+    let output = '';
+    let error = '';
+
+    process.stdout.on('data', (chunk) => {
+      output += chunk.toString();
+    });
+
+    process.stderr.on('data', (chunk) => {
+      error += chunk.toString();
+    });
+
+    process.on('close', (code) => {
+      if (code === 0) {
+        const lines = output.trim().split('\n');
+        if (lines.length > 0) {
+          const [size, tableCount, version] = lines[0].split('|');
+          resolve({
+            size: size?.trim() || 'Unknown',
+            tableCount: parseInt(tableCount?.trim() || '0', 10),
+            version: version?.trim().split(' ')[0] || 'Unknown',
+          });
+        } else {
+          reject(new Error('未能获取数据库信息'));
+        }
+      } else {
+        reject(new Error(error || `查询失败,退出代码: ${code}`));
+      }
+    });
+
+    process.on('error', (err) => {
+      reject(err);
+    });
+  });
+}

+ 41 - 0
src/types/database-backup.ts

@@ -0,0 +1,41 @@
+// 数据库备份相关类型定义
+
+/**
+ * 数据库状态信息
+ */
+export interface DatabaseStatus {
+  isAvailable: boolean;
+  containerName: string;
+  databaseName: string;
+  databaseSize: string;
+  tableCount: number;
+  postgresVersion: string;
+  error?: string;
+}
+
+/**
+ * 导入选项
+ */
+export interface ImportOptions {
+  /** 导入前是否清除现有数据(覆盖模式) */
+  cleanFirst: boolean;
+}
+
+/**
+ * 导入进度事件
+ */
+export interface ImportProgressEvent {
+  type: 'progress' | 'complete' | 'error';
+  message: string;
+  exitCode?: number;
+}
+
+/**
+ * 执行结果
+ */
+export interface ExecutionResult {
+  success: boolean;
+  message: string;
+  exitCode?: number;
+  error?: string;
+}