hikvision-web 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,193 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { Table, Button, Input, Space, Modal, Select, Tag } from 'antd';
3
+ import { SearchOutlined, PlusOutlined, EditOutlined, WarningOutlined } from '@ant-design/icons';
4
+ import { useAppStore } from '../store/appStore';
5
+ import { cardApi } from '../services/api';
6
+ import { Link } from 'react-router-dom';
7
+
8
+ const { Search } = Input;
9
+
10
+ export default function CardList() {
11
+ const { cards, isCardLoading, loadCards } = useAppStore();
12
+
13
+ const [searchCardNo, setSearchCardNo] = useState('');
14
+ const [page, setPage] = useState(1);
15
+ const [pageSize, setPageSize] = useState(20);
16
+ const [unbindModal, setUnbindModal] = useState<{ visible: boolean; cardNo?: string; personName?: string }>({ visible: false });
17
+
18
+ useEffect(() => {
19
+ const timer = setTimeout(() => {
20
+ loadCards({ pageNo: page, pageSize });
21
+ }, 300);
22
+ return () => clearTimeout(timer);
23
+ }, [page, pageSize]);
24
+
25
+ const handleSearch = () => {
26
+ setPage(1);
27
+ loadCards({ pageNo: 1, pageSize });
28
+ };
29
+
30
+ const handleUnbind = async () => {
31
+ if (!unbindModal.cardNo) return;
32
+ try {
33
+ // 先获取卡片信息获取 personId
34
+ const result: any = await cardApi.getByCardNo(unbindModal.cardNo);
35
+ await cardApi.unbind(unbindModal.cardNo, result.card?.personId || '');
36
+ alert('退卡成功');
37
+ setUnbindModal({ visible: false });
38
+ loadCards({ pageNo: page, pageSize });
39
+ } catch (error: any) {
40
+ alert(`退卡失败: ${error.message || error}`);
41
+ }
42
+ };
43
+
44
+ const columns = [
45
+ {
46
+ title: '卡片 ID',
47
+ dataIndex: 'cardId',
48
+ key: 'cardId',
49
+ width: 200,
50
+ render: (text: string) => <code style={{ fontSize: 12 }}>{text}</code>,
51
+ },
52
+ {
53
+ title: '卡号',
54
+ dataIndex: 'cardNo',
55
+ key: 'cardNo',
56
+ width: 150,
57
+ },
58
+ {
59
+ title: '卡片类型',
60
+ dataIndex: 'cardType',
61
+ key: 'cardType',
62
+ width: 80,
63
+ render: (t: string) => {
64
+ const map: Record<string, string> = { '0': '普通卡', '1': '临时卡', '2': '特殊卡' };
65
+ return map[t] || t;
66
+ },
67
+ },
68
+ {
69
+ title: '持卡人',
70
+ dataIndex: 'personName',
71
+ key: 'personName',
72
+ width: 100,
73
+ },
74
+ {
75
+ title: '状态',
76
+ dataIndex: 'status',
77
+ key: 'status',
78
+ width: 80,
79
+ render: (s: string) => {
80
+ const map: Record<string, { text: string; color: string }> = {
81
+ '0': { text: '正常', color: 'green' },
82
+ '1': { text: '挂失', color: 'orange' },
83
+ '2': { text: '退卡', color: 'gray' },
84
+ '3': { text: '损坏', color: 'red' },
85
+ };
86
+ const item = map[s] || { text: s, color: 'default' };
87
+ return <Tag color={item.color}>{item.text}</Tag>;
88
+ },
89
+ },
90
+ {
91
+ title: '发卡时间',
92
+ dataIndex: 'issueTime',
93
+ key: 'issueTime',
94
+ width: 160,
95
+ render: (t: string) => t ? new Date(t).toLocaleString() : '-',
96
+ },
97
+ {
98
+ title: '操作',
99
+ key: 'actions',
100
+ width: 180,
101
+ render: (_: any, record: any) => (
102
+ <Space size="small">
103
+ {record.status === '0' && (
104
+ <Button
105
+ type="link"
106
+ danger
107
+ icon={<WarningOutlined />}
108
+ size="small"
109
+ onClick={() => setUnbindModal({ visible: true, cardNo: record.cardNo, personName: record.personName })}
110
+ >
111
+ 退卡
112
+ </Button>
113
+ )}
114
+ {record.status === '1' && (
115
+ <Button
116
+ type="link"
117
+ icon={<EditOutlined />}
118
+ size="small"
119
+ onClick={async () => {
120
+ try {
121
+ await cardApi.unloss(record.cardNo);
122
+ alert('解挂成功');
123
+ loadCards({ pageNo: page, pageSize });
124
+ } catch (e: any) {
125
+ alert(`解挂失败: ${e.message || e}`);
126
+ }
127
+ }}
128
+ >
129
+ 解挂
130
+ </Button>
131
+ )}
132
+ <Link to={`/card/add?ref=${record.cardNo}`}>
133
+ <Button type="link" icon={<PlusOutlined />} size="small">复制</Button>
134
+ </Link>
135
+ </Space>
136
+ ),
137
+ },
138
+ ];
139
+
140
+ return (
141
+ <div>
142
+ <div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
143
+ <h2 className="page-title" style={{ marginBottom: 0 }}>卡片管理</h2>
144
+ <Link to="/card/add">
145
+ <Button type="primary" icon={<PlusOutlined />}>开卡</Button>
146
+ </Link>
147
+ </div>
148
+
149
+ <div style={{ marginBottom: 16, display: 'flex', gap: 8 }}>
150
+ <Search
151
+ placeholder="搜索卡号"
152
+ value={searchCardNo}
153
+ onChange={(e) => setSearchCardNo(e.target.value)}
154
+ onPressEnter={handleSearch}
155
+ style={{ width: 200 }}
156
+ />
157
+ <Button icon={<SearchOutlined />} onClick={handleSearch}>搜索</Button>
158
+ <Select
159
+ value={pageSize}
160
+ onChange={(v) => { setPageSize(v); setPage(1); }}
161
+ style={{ width: 100 }}
162
+ options={[{ value: 10, label: '10/页' }, { value: 20, label: '20/页' }, { value: 50, label: '50/页' }]}
163
+ />
164
+ </div>
165
+
166
+ <Table
167
+ columns={columns}
168
+ dataSource={cards}
169
+ rowKey="cardNo"
170
+ loading={isCardLoading}
171
+ pagination={{
172
+ current: page,
173
+ pageSize,
174
+ showSizeChanger: true,
175
+ showTotal: (t) => `共 ${t} 条`,
176
+ onChange: (p, ps) => { setPage(p); setPageSize(ps); },
177
+ }}
178
+ />
179
+
180
+ <Modal
181
+ title="确认退卡"
182
+ open={unbindModal.visible}
183
+ onOk={handleUnbind}
184
+ onCancel={() => setUnbindModal({ visible: false })}
185
+ okText="确认退卡"
186
+ okButtonProps={{ danger: true }}
187
+ >
188
+ <p>确定要为 <strong>{unbindModal.personName}</strong> 退卡 <strong>{unbindModal.cardNo}</strong> 吗?</p>
189
+ <p style={{ color: '#ff4d4f' }}>此操作不可撤销!</p>
190
+ </Modal>
191
+ </div>
192
+ );
193
+ }
@@ -0,0 +1,300 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { Card, Row, Col, Statistic, Spin, Typography, Tabs, Progress } from 'antd';
3
+ import { UserOutlined, TeamOutlined, CarOutlined, IdcardOutlined } from '@ant-design/icons';
4
+ import { statApi } from '../services/api';
5
+
6
+ const { Title } = Typography;
7
+ const { TabPane } = Tabs;
8
+
9
+ interface Stats {
10
+ personCount: number;
11
+ orgCount: number;
12
+ vehicleCount: number;
13
+ cardCount: number;
14
+ }
15
+
16
+ interface OrgPersonStat {
17
+ orgIndexCode: string;
18
+ orgName: string;
19
+ orgPath: string;
20
+ personCount: number;
21
+ }
22
+
23
+ interface VehicleStat {
24
+ total: number;
25
+ boundCount: number;
26
+ unboundCount: number;
27
+ boundPercent: number;
28
+ unboundPercent: number;
29
+ }
30
+
31
+ interface CardStat {
32
+ total: number;
33
+ activeCount: number;
34
+ inactiveCount: number;
35
+ activePercent: number;
36
+ inactivePercent: number;
37
+ }
38
+
39
+ export default function DashboardPage() {
40
+ const [loading, setLoading] = useState(true);
41
+ const [stats, setStats] = useState<Stats | null>(null);
42
+ const [orgStats, setOrgStats] = useState<OrgPersonStat[]>([]);
43
+ const [vehicleStat, setVehicleStat] = useState<VehicleStat | null>(null);
44
+ const [cardStat, setCardStat] = useState<CardStat | null>(null);
45
+ const [detailLoading, setDetailLoading] = useState(false);
46
+ const [error, setError] = useState<string | null>(null);
47
+
48
+ useEffect(() => {
49
+ loadBasicStats();
50
+ }, []);
51
+
52
+ const loadBasicStats = async () => {
53
+ setLoading(true);
54
+ setError(null);
55
+ try {
56
+ const res: any = await statApi.getStats();
57
+ if (res.success) {
58
+ setStats(res.data);
59
+ } else {
60
+ setError(res.message || '加载失败');
61
+ }
62
+ } catch (e: any) {
63
+ setError(e.message || '加载失败');
64
+ } finally {
65
+ setLoading(false);
66
+ }
67
+ };
68
+
69
+ const loadDetailStats = async () => {
70
+ if (orgStats.length > 0) return; // 已加载
71
+ setDetailLoading(true);
72
+ try {
73
+ const orgRes: any = await statApi.getPersonsByOrg();
74
+ const vehicleRes: any = await statApi.getVehiclesByStatus();
75
+ const cardRes: any = await statApi.getCardsByStatus();
76
+
77
+ if (orgRes.success) setOrgStats(orgRes.data);
78
+ if (vehicleRes.success) setVehicleStat(vehicleRes.data);
79
+ if (cardRes.success) setCardStat(cardRes.data);
80
+ } catch (e: any) {
81
+ console.error('加载明细失败', e);
82
+ } finally {
83
+ setDetailLoading(false);
84
+ }
85
+ };
86
+
87
+ if (loading) {
88
+ return (
89
+ <div style={{ textAlign: 'center', padding: 60 }}>
90
+ <Spin size="large" />
91
+ <div style={{ marginTop: 16 }}>加载中...</div>
92
+ </div>
93
+ );
94
+ }
95
+
96
+ if (error) {
97
+ return (
98
+ <div style={{ textAlign: 'center', padding: 60 }}>
99
+ <div style={{ color: '#ff4d4f' }}>加载失败: {error}</div>
100
+ <div style={{ marginTop: 16 }}>
101
+ <a onClick={loadBasicStats}>重新加载</a>
102
+ </div>
103
+ </div>
104
+ );
105
+ }
106
+
107
+ return (
108
+ <div style={{ padding: 24 }}>
109
+ <Title level={3}>📊 系统概览</Title>
110
+
111
+ <Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
112
+ <Col xs={24} sm={12} md={6}>
113
+ <Card>
114
+ <Statistic
115
+ title="人员总数"
116
+ value={stats?.personCount || 0}
117
+ prefix={<UserOutlined />}
118
+ valueStyle={{ color: '#1890ff' }}
119
+ />
120
+ </Card>
121
+ </Col>
122
+
123
+ <Col xs={24} sm={12} md={6}>
124
+ <Card>
125
+ <Statistic
126
+ title="组织总数"
127
+ value={stats?.orgCount || 0}
128
+ prefix={<TeamOutlined />}
129
+ valueStyle={{ color: '#52c41a' }}
130
+ />
131
+ </Card>
132
+ </Col>
133
+
134
+ <Col xs={24} sm={12} md={6}>
135
+ <Card>
136
+ <Statistic
137
+ title="车辆总数"
138
+ value={stats?.vehicleCount || 0}
139
+ prefix={<CarOutlined />}
140
+ valueStyle={{ color: '#faad14' }}
141
+ />
142
+ </Card>
143
+ </Col>
144
+
145
+ <Col xs={24} sm={12} md={6}>
146
+ <Card>
147
+ <Statistic
148
+ title="卡片总数"
149
+ value={stats?.cardCount || 0}
150
+ prefix={<IdcardOutlined />}
151
+ valueStyle={{ color: '#f5222d' }}
152
+ />
153
+ </Card>
154
+ </Col>
155
+ </Row>
156
+
157
+ <Tabs defaultActiveKey="overview" onTabClick={loadDetailStats}>
158
+ <TabPane tab="人员分布" key="persons-by-org">
159
+ <Card
160
+ title="各组织人员分布"
161
+ extra={<span style={{ color: '#999' }}>共 {orgStats.length} 个组织,{stats?.personCount || 0} 人</span>}
162
+ loading={detailLoading}
163
+ >
164
+ {orgStats.length > 0 ? (
165
+ <div style={{ maxHeight: 500, overflowY: 'auto' }}>
166
+ {orgStats.map((org) => {
167
+ const percent = stats?.personCount ? Math.round(org.personCount / stats.personCount * 100) : 0;
168
+ return (
169
+ <div key={org.orgIndexCode} style={{ marginBottom: 12 }}>
170
+ <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
171
+ <span title={org.orgPath} style={{ maxWidth: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{org.orgPath}</span>
172
+ <span style={{ color: '#666' }}>{org.personCount} 人 ({percent}%)</span>
173
+ </div>
174
+ <Progress
175
+ percent={percent}
176
+ strokeColor={percent > 20 ? '#1890ff' : '#52c41a'}
177
+ size="small"
178
+ showInfo={false}
179
+ />
180
+ </div>
181
+ );
182
+ })}
183
+ </div>
184
+ ) : (
185
+ <div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
186
+ 点击标签页加载数据
187
+ </div>
188
+ )}
189
+ </Card>
190
+ </TabPane>
191
+
192
+ <TabPane tab="车辆状态" key="vehicles-by-status">
193
+ <Card title="车辆绑定状态" loading={detailLoading}>
194
+ {vehicleStat ? (
195
+ <Row gutter={[16, 16]}>
196
+ <Col xs={24} sm={8}>
197
+ <Card size="small">
198
+ <Statistic
199
+ title="已绑定"
200
+ value={vehicleStat.boundCount}
201
+ suffix={`/ ${vehicleStat.total}`}
202
+ valueStyle={{ color: '#52c41a' }}
203
+ />
204
+ <Progress
205
+ percent={vehicleStat.boundPercent}
206
+ strokeColor="#52c41a"
207
+ style={{ marginTop: 8 }}
208
+ />
209
+ </Card>
210
+ </Col>
211
+ <Col xs={24} sm={8}>
212
+ <Card size="small">
213
+ <Statistic
214
+ title="未绑定"
215
+ value={vehicleStat.unboundCount}
216
+ suffix={`/ ${vehicleStat.total}`}
217
+ valueStyle={{ color: '#ff4d4f' }}
218
+ />
219
+ <Progress
220
+ percent={vehicleStat.unboundPercent}
221
+ strokeColor="#ff4d4f"
222
+ style={{ marginTop: 8 }}
223
+ />
224
+ </Card>
225
+ </Col>
226
+ <Col xs={24} sm={8}>
227
+ <Card size="small" type="inner">
228
+ <Statistic
229
+ title="绑定率"
230
+ value={vehicleStat.boundPercent}
231
+ suffix="%"
232
+ valueStyle={{ color: '#1890ff' }}
233
+ />
234
+ </Card>
235
+ </Col>
236
+ </Row>
237
+ ) : (
238
+ <div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
239
+ 点击标签页加载数据
240
+ </div>
241
+ )}
242
+ </Card>
243
+ </TabPane>
244
+
245
+ <TabPane tab="卡片状态" key="cards-by-status">
246
+ <Card title="卡片状态" loading={detailLoading}>
247
+ {cardStat ? (
248
+ <Row gutter={[16, 16]}>
249
+ <Col xs={24} sm={8}>
250
+ <Card size="small">
251
+ <Statistic
252
+ title="正常卡片"
253
+ value={cardStat.activeCount}
254
+ suffix={`/ ${cardStat.total}`}
255
+ valueStyle={{ color: '#52c41a' }}
256
+ />
257
+ <Progress
258
+ percent={cardStat.activePercent}
259
+ strokeColor="#52c41a"
260
+ style={{ marginTop: 8 }}
261
+ />
262
+ </Card>
263
+ </Col>
264
+ <Col xs={24} sm={8}>
265
+ <Card size="small">
266
+ <Statistic
267
+ title="禁用/未发放"
268
+ value={cardStat.inactiveCount}
269
+ suffix={`/ ${cardStat.total}`}
270
+ valueStyle={{ color: '#ff4d4f' }}
271
+ />
272
+ <Progress
273
+ percent={cardStat.inactivePercent}
274
+ strokeColor="#ff4d4f"
275
+ style={{ marginTop: 8 }}
276
+ />
277
+ </Card>
278
+ </Col>
279
+ <Col xs={24} sm={8}>
280
+ <Card size="small" type="inner">
281
+ <Statistic
282
+ title="正常率"
283
+ value={cardStat.activePercent}
284
+ suffix="%"
285
+ valueStyle={{ color: '#1890ff' }}
286
+ />
287
+ </Card>
288
+ </Col>
289
+ </Row>
290
+ ) : (
291
+ <div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
292
+ 点击标签页加载数据
293
+ </div>
294
+ )}
295
+ </Card>
296
+ </TabPane>
297
+ </Tabs>
298
+ </div>
299
+ );
300
+ }