Selaa lähdekoodia

✨ feat(passenger): 实现乘客数据Excel导出功能

- 添加xlsx库依赖(版本0.18.5),支持Excel格式导出
- 重构导出逻辑,使用xlsx库实现Excel文件生成与下载
- 设置工作表列宽优化显示效果
- 更新文档,标记数据导出功能为已完成

📝 docs(story): 更新乘客管理功能文档

- 修改任务列表,标记数据导出功能为已完成
- 更新文件列表,添加xlsx库依赖说明
- 更新完成状态列表,添加数据导出功能完成标记
- 修改Passengers.tsx文件描述,注明已集成xlsx导出功能

🔧 chore(settings): 添加客户端构建命令到设置文件

- 在settings.local.json中添加"Bash(pnpm run build:client:*)"命令
yourname 3 kuukautta sitten
vanhempi
sitoutus
a8933801d2

+ 2 - 1
.claude/settings.local.json

@@ -50,7 +50,8 @@
       "Bash(pnpm build:*)",
       "Bash(npx tsc:*)",
       "Bash(pnpm build:weapp:*)",
-      "Bash(pnpm run build:weapp:*)"
+      "Bash(pnpm run build:weapp:*)",
+      "Bash(pnpm run build:client:*)"
     ],
     "deny": [],
     "ask": []

+ 7 - 3
docs/stories/005.005.story.md

@@ -34,7 +34,7 @@ Approved
   - [x] 创建 `src/client/admin/pages/Passengers.tsx` 页面组件
   - [x] 实现乘客列表表格显示
   - [x] 实现按用户筛选功能
-  - [x] 实现数据导出功能
+  - [x] 实现数据导出功能 (使用xlsx库实现Excel导出)
 - [x] 集成乘客页面到管理后台路由 (AC: 1)
   - [x] 在管理后台路由配置中添加乘客页面
   - [x] 更新侧边栏菜单,添加乘客管理入口
@@ -242,7 +242,8 @@ const { data, isLoading, refetch } = useQuery({
 4. ✅ 乘客管理API路由已实现(管理后台专用)
 5. ✅ 管理后台乘客信息页面已创建
 6. ✅ 乘客页面已集成到管理后台路由
-7. ⚠️ 乘客管理测试待编写
+7. ✅ 数据导出功能已实现(使用xlsx库支持Excel格式导出)
+8. ⚠️ 乘客管理测试待编写
 
 ### File List
 **后端文件:**
@@ -252,11 +253,14 @@ const { data, isLoading, refetch } = useQuery({
 - `src/server/api/admin/passengers/index.ts` - 管理后台乘客API路由
 
 **前端文件:**
-- `src/client/admin/pages/Passengers.tsx` - 管理后台乘客信息页面
+- `src/client/admin/pages/Passengers.tsx` - 管理后台乘客信息页面(已集成xlsx导出功能)
 - `src/client/admin/routes.tsx` - 路由配置(已添加乘客页面)
 - `src/client/admin/menu.tsx` - 侧边栏菜单(已添加乘客管理入口)
 - `src/client/api.ts` - API客户端(已添加管理后台乘客客户端)
 
+**依赖更新:**
+- `package.json` - 已添加xlsx库依赖(版本0.18.5)
+
 **共享文件:**
 - `src/share/passenger.types.ts` - 乘客相关类型定义
 

+ 5 - 4
package.json

@@ -5,8 +5,8 @@
   "scripts": {
     "dev": "concurrently \"pnpm run dev:mini\" \"pnpm run dev:web\" \"pnpm run dev:weapp\"",
     "dev:web": "PORT=8080 node server",
-    "dev:mini": "cd mini && pnpm run dev:h5", 
-    "dev:weapp": "cd mini && pnpm run dev:weapp", 
+    "dev:mini": "cd mini && pnpm run dev:h5",
+    "dev:weapp": "cd mini && pnpm run dev:weapp",
     "build": "npm run typecheck && npm run build:client && npm run build:server",
     "build:client": "cross-env NODE_ENV=production vite build --outDir dist/client --manifest --mode production",
     "build:server": "cross-env NODE_ENV=production vite build --ssr src/server/index.tsx --outDir dist/server --mode production",
@@ -104,16 +104,17 @@
     "react-resizable-panels": "^3.0.4",
     "react-router": "^7.7.0",
     "react-router-dom": "^7.7.0",
-    "recharts": "2.15.4",
     "react-toastify": "^11.0.5",
+    "recharts": "2.15.4",
     "reflect-metadata": "^0.2.2",
     "sirv": "^3.0.1",
-    "uuid": "^11.1.0",
     "sonner": "^2.0.7",
     "tailwind-merge": "^3.3.1",
     "tw-animate-css": "^1.3.6",
     "typeorm": "^0.3.25",
+    "uuid": "^11.1.0",
     "vaul": "^1.1.2",
+    "xlsx": "^0.18.5",
     "zod": "^4.0.15"
   },
   "devDependencies": {

+ 72 - 0
pnpm-lock.yaml

@@ -233,6 +233,9 @@ importers:
       vaul:
         specifier: ^1.1.2
         version: 1.1.2(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+      xlsx:
+        specifier: ^0.18.5
+        version: 0.18.5
       zod:
         specifier: ^4.0.15
         version: 4.0.15
@@ -1850,6 +1853,10 @@ packages:
     engines: {node: '>=0.4.0'}
     hasBin: true
 
+  adler-32@1.3.1:
+    resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
+    engines: {node: '>=0.8'}
+
   ajv@6.12.6:
     resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
 
@@ -2011,6 +2018,10 @@ packages:
     resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
     engines: {node: '>=6'}
 
+  cfb@1.2.2:
+    resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
+    engines: {node: '>=0.8'}
+
   chai@5.3.3:
     resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
     engines: {node: '>=18'}
@@ -2051,6 +2062,10 @@ packages:
       react: ^18 || ^19 || ^19.0.0-rc
       react-dom: ^18 || ^19 || ^19.0.0-rc
 
+  codepage@1.15.0:
+    resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
+    engines: {node: '>=0.8'}
+
   color-convert@2.0.1:
     resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
     engines: {node: '>=7.0.0'}
@@ -2082,6 +2097,11 @@ packages:
     resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==}
     engines: {node: '>=18'}
 
+  crc-32@1.2.2:
+    resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
+    engines: {node: '>=0.8'}
+    hasBin: true
+
   cross-env@7.0.3:
     resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
     engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
@@ -2484,6 +2504,10 @@ packages:
     resolution: {integrity: sha512-8e1++BCiTzUno9v5IZ2J6bv4RU+3UKDmqWUQD0MIMVCd9AdhWkO1gw57oo1mNEX1dMq2EGI+FbWz4B92pscSQg==}
     engines: {node: '>= 18'}
 
+  frac@1.1.2:
+    resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
+    engines: {node: '>=0.8'}
+
   fsevents@2.3.2:
     resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
     engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -3604,6 +3628,10 @@ packages:
     resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
     engines: {node: '>= 0.6'}
 
+  ssf@0.11.2:
+    resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
+    engines: {node: '>=0.8'}
+
   stackback@0.0.2:
     resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
 
@@ -4035,10 +4063,18 @@ packages:
     engines: {node: '>=8'}
     hasBin: true
 
+  wmf@1.0.2:
+    resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
+    engines: {node: '>=0.8'}
+
   word-wrap@1.2.5:
     resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
     engines: {node: '>=0.10.0'}
 
+  word@0.3.0:
+    resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
+    engines: {node: '>=0.8'}
+
   wrap-ansi@7.0.0:
     resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
     engines: {node: '>=10'}
@@ -4047,6 +4083,11 @@ packages:
     resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
     engines: {node: '>=12'}
 
+  xlsx@0.18.5:
+    resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
+    engines: {node: '>=0.8'}
+    hasBin: true
+
   xml2js@0.6.2:
     resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
     engines: {node: '>=4.0.0'}
@@ -5520,6 +5561,8 @@ snapshots:
 
   acorn@8.15.0: {}
 
+  adler-32@1.3.1: {}
+
   ajv@6.12.6:
     dependencies:
       fast-deep-equal: 3.1.3
@@ -5701,6 +5744,11 @@ snapshots:
 
   callsites@3.1.0: {}
 
+  cfb@1.2.2:
+    dependencies:
+      adler-32: 1.3.1
+      crc-32: 1.2.2
+
   chai@5.3.3:
     dependencies:
       assertion-error: 2.0.1
@@ -5746,6 +5794,8 @@ snapshots:
       - '@types/react'
       - '@types/react-dom'
 
+  codepage@1.15.0: {}
+
   color-convert@2.0.1:
     dependencies:
       color-name: 1.1.4
@@ -5786,6 +5836,8 @@ snapshots:
 
   cookie@1.0.2: {}
 
+  crc-32@1.2.2: {}
+
   cross-env@7.0.3:
     dependencies:
       cross-spawn: 7.0.6
@@ -6280,6 +6332,8 @@ snapshots:
 
   formdata-node@6.0.3: {}
 
+  frac@1.1.2: {}
+
   fsevents@2.3.2:
     optional: true
 
@@ -7410,6 +7464,10 @@ snapshots:
   sqlstring@2.3.3:
     optional: true
 
+  ssf@0.11.2:
+    dependencies:
+      frac: 1.1.2
+
   stackback@0.0.2: {}
 
   standard-as-callback@2.1.0: {}
@@ -7877,8 +7935,12 @@ snapshots:
       siginfo: 2.0.0
       stackback: 0.0.2
 
+  wmf@1.0.2: {}
+
   word-wrap@1.2.5: {}
 
+  word@0.3.0: {}
+
   wrap-ansi@7.0.0:
     dependencies:
       ansi-styles: 4.3.0
@@ -7891,6 +7953,16 @@ snapshots:
       string-width: 5.1.2
       strip-ansi: 7.1.0
 
+  xlsx@0.18.5:
+    dependencies:
+      adler-32: 1.3.1
+      cfb: 1.2.2
+      codepage: 1.15.0
+      crc-32: 1.2.2
+      ssf: 0.11.2
+      wmf: 1.0.2
+      word: 0.3.0
+
   xml2js@0.6.2:
     dependencies:
       sax: 1.4.1

+ 37 - 25
src/client/admin/pages/Passengers.tsx

@@ -4,6 +4,7 @@ import { format } from 'date-fns';
 import { Search, Filter, X, Download } from 'lucide-react';
 import { passengerClient } from '@/client/api';
 import type { InferResponseType } from 'hono/client';
+import * as XLSX from 'xlsx';
 import { Button } from '@/client/components/ui/button';
 import { Input } from '@/client/components/ui/input';
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/client/components/ui/card';
@@ -134,34 +135,45 @@ export const PassengersPage = () => {
       const data = await res.json();
       const passengers = data.data;
 
-      // 创建CSV内容
-      const headers = ['姓名', '证件类型', '证件号码', '手机号', '默认乘客', '所属用户', '创建时间'];
-      const csvContent = [
-        headers.join(','),
-        ...passengers.map((passenger: PassengerResponse) => [
-          passenger.name,
-          passenger.idType,
-          passenger.idNumber,
-          passenger.phone,
-          passenger.isDefault ? '是' : '否',
-          passenger.user?.username || '未知用户',
-          format(new Date(passenger.createdAt), 'yyyy-MM-dd HH:mm')
-        ].join(','))
-      ].join('\n');
-
-      // 创建Blob并下载
-      const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
-      const link = document.createElement('a');
-      const url = URL.createObjectURL(blob);
-      link.setAttribute('href', url);
-      link.setAttribute('download', `乘客信息_${format(new Date(), 'yyyy-MM-dd_HH-mm')}.csv`);
-      link.style.visibility = 'hidden';
-      document.body.appendChild(link);
-      link.click();
-      document.body.removeChild(link);
+      // 准备Excel数据
+      const excelData = passengers.map((passenger: PassengerResponse) => ({
+        '姓名': passenger.name,
+        '证件类型': passenger.idType,
+        '证件号码': passenger.idNumber,
+        '手机号': passenger.phone,
+        '默认乘客': passenger.isDefault ? '是' : '否',
+        '所属用户': passenger.user?.username || '未知用户',
+        '创建时间': format(new Date(passenger.createdAt), 'yyyy-MM-dd HH:mm')
+      }));
+
+      // 创建工作簿
+      const wb = XLSX.utils.book_new();
+
+      // 创建工作表
+      const ws = XLSX.utils.json_to_sheet(excelData);
+
+      // 设置列宽
+      const colWidths = [
+        { wch: 12 }, // 姓名
+        { wch: 12 }, // 证件类型
+        { wch: 20 }, // 证件号码
+        { wch: 15 }, // 手机号
+        { wch: 10 }, // 默认乘客
+        { wch: 15 }, // 所属用户
+        { wch: 20 }  // 创建时间
+      ];
+      ws['!cols'] = colWidths;
+
+      // 添加工作表到工作簿
+      XLSX.utils.book_append_sheet(wb, ws, '乘客信息');
+
+      // 生成Excel文件并下载
+      const fileName = `乘客信息_${format(new Date(), 'yyyy-MM-dd_HH-mm')}.xlsx`;
+      XLSX.writeFile(wb, fileName);
 
       toast.success('导出成功');
     } catch (error) {
+      console.error('导出失败:', error);
       toast.error('导出失败,请重试');
     }
   };