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,231 @@
1
+ /**
2
+ * 车辆表单页面(添加/编辑车辆)
3
+ *
4
+ * 重要概念澄清(2026-05-23):
5
+ * - 车辆群组 (car-group):停车场功能,用于车辆出入控制
6
+ * - 车辆分类 (category):系统预置分类(固定车、临时车等)+ 自定义群组
7
+ *
8
+ * 注意:车辆群组仅用于停车场管理,与人员通行无关
9
+ */
10
+
11
+ import { useEffect, useState } from 'react';
12
+ import { useParams, useNavigate } from 'react-router-dom';
13
+ import { Form, Input, Select, Button, message, Card, Spin, Space } from 'antd';
14
+ import { ArrowLeftOutlined, SaveOutlined, ReloadOutlined } from '@ant-design/icons';
15
+ import { vehicleApi, vehicleGroupApi, personApi } from '../services/api';
16
+ import { SYSTEM_CATEGORY_ID_ARRAY, getCategoryName } from '../utils/constants';
17
+
18
+ const { Option } = Select;
19
+
20
+ interface PersonItem {
21
+ personId: string;
22
+ personName: string;
23
+ orgName?: string;
24
+ jobNo?: string;
25
+ }
26
+
27
+ interface CarGroup {
28
+ groupId: string;
29
+ groupName: string;
30
+ groupType: '0' | '1';
31
+ type?: 'system' | 'custom';
32
+ }
33
+
34
+ export default function VehicleForm() {
35
+ const { vehicleId } = useParams();
36
+ const navigate = useNavigate();
37
+ const isEdit = !!vehicleId;
38
+ const [form] = Form.useForm();
39
+ const [loading, setLoading] = useState(false);
40
+ const [submitting, setSubmitting] = useState(false);
41
+ const [persons, setPersons] = useState<PersonItem[]>([]);
42
+ const [vehicleGroups, setVehicleGroups] = useState<CarGroup[]>([]);
43
+
44
+ useEffect(() => {
45
+ loadPersons();
46
+ loadVehicleGroups();
47
+ if (isEdit && vehicleId) {
48
+ loadVehicle();
49
+ }
50
+ }, [vehicleId]);
51
+
52
+ const loadPersons = async () => {
53
+ try {
54
+ const result: any = await personApi.list({ pageNo: 1, pageSize: 100 });
55
+ setPersons((result?.data?.list || result?.list || []).map((p: any) => ({
56
+ personId: p.personId,
57
+ personName: p.personName,
58
+ orgName: p.orgName,
59
+ jobNo: p.jobNo,
60
+ })));
61
+ } catch (error: any) {
62
+ console.error('加载人员失败:', error);
63
+ }
64
+ };
65
+
66
+ const loadVehicleGroups = async () => {
67
+ try {
68
+ const res: any = await vehicleGroupApi.list();
69
+ const list = res?.data || res || [];
70
+ // 添加 type 标识
71
+ const groupsWithType = (Array.isArray(list) ? list : []).map((g: any) => ({
72
+ ...g,
73
+ type: SYSTEM_CATEGORY_ID_ARRAY.includes(g.groupId) ? 'system' as const : 'custom' as const,
74
+ }));
75
+ setVehicleGroups(groupsWithType);
76
+ } catch (error) {
77
+ console.error('加载车辆群组失败:', error);
78
+ }
79
+ };
80
+
81
+ const loadVehicle = async () => {
82
+ if (!vehicleId) return;
83
+ setLoading(true);
84
+ try {
85
+ const data = await vehicleApi.getById(vehicleId);
86
+ const d = data?.data || data;
87
+ form.setFieldsValue({
88
+ plateNo: d.plateNo,
89
+ vehicleType: d.vehicleType,
90
+ vehicleColor: d.vehicleColor,
91
+ vehicleModel: d.vehicleModel,
92
+ personId: d.personId,
93
+ groupId: d.vehicleGroup || d.groupId,
94
+ });
95
+ } catch (error: any) {
96
+ message.error(`加载失败: ${error.message || error}`);
97
+ } finally {
98
+ setLoading(false);
99
+ }
100
+ };
101
+
102
+ const handleSubmit = async (values: any) => {
103
+ setSubmitting(true);
104
+ try {
105
+ const dto = {
106
+ plateNo: values.plateNo,
107
+ vehicleType: values.vehicleType as '0' | '1' | '2' | '3',
108
+ vehicleColor: values.vehicleColor,
109
+ vehicleModel: values.vehicleModel,
110
+ personId: values.personId,
111
+ groupId: values.groupId,
112
+ };
113
+
114
+ if (isEdit && vehicleId) {
115
+ await vehicleApi.update(vehicleId, dto);
116
+ message.success('更新成功');
117
+ navigate('/vehicle');
118
+ } else {
119
+ await vehicleApi.save(dto);
120
+ message.success('添加成功');
121
+ navigate('/vehicle');
122
+ }
123
+ } catch (error: any) {
124
+ message.error(`操作失败: ${error.message || error}`);
125
+ } finally {
126
+ setSubmitting(false);
127
+ }
128
+ };
129
+
130
+ // 渲染群组选项
131
+ const renderGroupOption = (g: CarGroup) => {
132
+ const label = g.type === 'system'
133
+ ? `${g.groupName} (${getCategoryName(g.groupId)})`
134
+ : g.groupName;
135
+ return (
136
+ <Option key={g.groupId} value={g.groupId}>
137
+ {label}
138
+ </Option>
139
+ );
140
+ };
141
+
142
+ // 按类型分组显示
143
+ const systemGroups = vehicleGroups.filter(g => g.type === 'system');
144
+ const customGroups = vehicleGroups.filter(g => g.type === 'custom');
145
+
146
+ if (loading) {
147
+ return <div style={{ padding: 40, textAlign: 'center' }}><Spin size="large" /></div>;
148
+ }
149
+
150
+ return (
151
+ <div>
152
+ <div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
153
+ <Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/vehicle')}>返回</Button>
154
+ <h2>{isEdit ? '编辑车辆' : '添加车辆'}</h2>
155
+ </div>
156
+
157
+ <Card>
158
+ <Form
159
+ form={form}
160
+ layout="vertical"
161
+ onFinish={handleSubmit}
162
+ initialValues={{ vehicleType: '0' }}
163
+ >
164
+ <Form.Item
165
+ name="plateNo"
166
+ label="车牌号"
167
+ rules={[{ required: true, message: '请输入车牌号' }]}
168
+ >
169
+ <Input placeholder="如:京A12345" />
170
+ </Form.Item>
171
+
172
+ <Form.Item
173
+ name="vehicleType"
174
+ label="车辆类型"
175
+ rules={[{ required: true, message: '请选择车辆类型' }]}
176
+ >
177
+ <Select placeholder="请选择车辆类型">
178
+ <Option value="0">燃油车</Option>
179
+ <Option value="1">新能源车</Option>
180
+ <Option value="2">电动车</Option>
181
+ <Option value="3">其他</Option>
182
+ </Select>
183
+ </Form.Item>
184
+
185
+ <Form.Item name="vehicleColor" label="颜色">
186
+ <Input placeholder="如:白色、黑色" />
187
+ </Form.Item>
188
+
189
+ <Form.Item name="vehicleModel" label="品牌型号">
190
+ <Input placeholder="如:特斯拉 Model 3" />
191
+ </Form.Item>
192
+
193
+ <Form.Item name="personId" label="所属人员">
194
+ <Select placeholder="选择所属人员" showSearch allowClear>
195
+ {persons.map((p) => (
196
+ <Option key={p.personId} value={p.personId}>
197
+ {p.personName} - {p.orgName || '-'} {p.jobNo ? `(${p.jobNo})` : ''}
198
+ </Option>
199
+ ))}
200
+ </Select>
201
+ </Form.Item>
202
+
203
+ <Form.Item name="groupId" label="车辆群组/分类">
204
+ <Select placeholder="选择车辆群组或分类" allowClear>
205
+ {systemGroups.length > 0 && (
206
+ <Select.OptGroup label="系统分类">
207
+ {systemGroups.map(renderGroupOption)}
208
+ </Select.OptGroup>
209
+ )}
210
+ {customGroups.length > 0 && (
211
+ <Select.OptGroup label="自定义群组">
212
+ {customGroups.map(renderGroupOption)}
213
+ </Select.OptGroup>
214
+ )}
215
+ </Select>
216
+ </Form.Item>
217
+
218
+ <Form.Item style={{ marginTop: 24 }}>
219
+ <Space>
220
+ <Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={submitting}>
221
+ {isEdit ? '保存修改' : '添加车辆'}
222
+ </Button>
223
+ <Button icon={<ReloadOutlined />} onClick={() => form.resetFields()}>重置</Button>
224
+ <Button onClick={() => navigate('/vehicle')}>取消</Button>
225
+ </Space>
226
+ </Form.Item>
227
+ </Form>
228
+ </Card>
229
+ </div>
230
+ );
231
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * 车辆列表页面
3
+ */
4
+
5
+ import { useEffect, useState } from 'react';
6
+ import { Table, Button, Input, Space, Modal, Select, Tag, message } from 'antd';
7
+ import { SearchOutlined, PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
8
+ import { useAppStore } from '../store/appStore';
9
+ import { vehicleApi } from '../services/api';
10
+ import { Link } from 'react-router-dom';
11
+ import { SYSTEM_CATEGORY_ID_ARRAY, getCategoryName } from '../utils/constants';
12
+
13
+ const { Search } = Input;
14
+
15
+ export default function VehicleList() {
16
+ const { vehicles, isVehicleLoading, loadVehicles } = useAppStore();
17
+
18
+ const [searchPlate, setSearchPlate] = useState('');
19
+ const [searchPerson, setSearchPerson] = useState('');
20
+ const [page, setPage] = useState(1);
21
+ const [pageSize, setPageSize] = useState(20);
22
+ const [deleteModal, setDeleteModal] = useState<{ visible: boolean; vehicleId?: string; plateNo?: string }>({ visible: false });
23
+
24
+ useEffect(() => {
25
+ const timer = setTimeout(() => {
26
+ loadVehicles({ pageNo: page, pageSize, plateNo: searchPlate, personName: searchPerson });
27
+ }, 300);
28
+ return () => clearTimeout(timer);
29
+ }, [page, pageSize, searchPlate, searchPerson]);
30
+
31
+ const handleSearch = () => {
32
+ setPage(1);
33
+ loadVehicles({ pageNo: 1, pageSize, plateNo: searchPlate, personName: searchPerson });
34
+ };
35
+
36
+ const handleDelete = async () => {
37
+ if (!deleteModal.vehicleId) return;
38
+ try {
39
+ await vehicleApi.delete(deleteModal.vehicleId);
40
+ message.success('删除成功');
41
+ setDeleteModal({ visible: false });
42
+ loadVehicles({ pageNo: page, pageSize });
43
+ } catch (error: any) {
44
+ message.error(`删除失败: ${error.message || error}`);
45
+ }
46
+ };
47
+
48
+ const handleRefresh = () => {
49
+ loadVehicles({ pageNo: page, pageSize, plateNo: searchPlate, personName: searchPerson });
50
+ };
51
+
52
+ // 渲染群组名称
53
+ const renderGroupName = (record: any) => {
54
+ const groupId = record.vehicleGroup || record.categoryCode || record.groupId;
55
+ if (!groupId) return '-';
56
+ const isSystem = SYSTEM_CATEGORY_ID_ARRAY.includes(groupId);
57
+ if (isSystem) {
58
+ return <Tag color="blue">{getCategoryName(groupId)}</Tag>;
59
+ }
60
+ const label = record.vehicleGroupName || record.categoryName || record.groupName || groupId;
61
+ return <Tag color="green">{label}</Tag>;
62
+ };
63
+
64
+ const columns = [
65
+ {
66
+ title: '车辆 ID',
67
+ dataIndex: 'vehicleId',
68
+ key: 'vehicleId',
69
+ width: 200,
70
+ render: (text: string) => <code style={{ fontSize: 12 }}>{text}</code>,
71
+ },
72
+ {
73
+ title: '车牌号',
74
+ dataIndex: 'plateNo',
75
+ key: 'plateNo',
76
+ width: 120,
77
+ },
78
+ {
79
+ title: '车辆类型',
80
+ dataIndex: 'vehicleType',
81
+ key: 'vehicleType',
82
+ width: 80,
83
+ render: (t: string) => {
84
+ const map: Record<string, string> = { '0': '燃油车', '1': '新能源车', '2': '电动车', '3': '其他' };
85
+ return map[t] || t;
86
+ },
87
+ },
88
+ {
89
+ title: '颜色',
90
+ dataIndex: 'vehicleColor',
91
+ key: 'vehicleColor',
92
+ width: 80,
93
+ },
94
+ {
95
+ title: '品牌型号',
96
+ dataIndex: 'vehicleModel',
97
+ key: 'vehicleModel',
98
+ width: 120,
99
+ },
100
+ {
101
+ title: '所属人员',
102
+ dataIndex: 'personName',
103
+ key: 'personName',
104
+ width: 100,
105
+ },
106
+ {
107
+ title: '群组/分类',
108
+ dataIndex: 'groupId',
109
+ key: 'groupId',
110
+ width: 120,
111
+ render: ( _: any, record: any) => renderGroupName(record),
112
+ },
113
+ {
114
+ title: '操作',
115
+ key: 'actions',
116
+ width: 150,
117
+ render: (_: any, record: any) => (
118
+ <Space size="small">
119
+ <Link to={`/vehicle/edit/${record.vehicleId}`}>
120
+ <Button type="link" icon={<EditOutlined />} size="small">编辑</Button>
121
+ </Link>
122
+ <Button
123
+ type="link"
124
+ danger
125
+ icon={<DeleteOutlined />}
126
+ size="small"
127
+ onClick={() => setDeleteModal({ visible: true, vehicleId: record.vehicleId, plateNo: record.plateNo })}
128
+ >
129
+ 删除
130
+ </Button>
131
+ </Space>
132
+ ),
133
+ },
134
+ ];
135
+
136
+ return (
137
+ <div>
138
+ <div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
139
+ <h2 className="page-title" style={{ marginBottom: 0 }}>车辆管理</h2>
140
+ <Space>
141
+ <Link to="/vehicle/add">
142
+ <Button type="primary" icon={<PlusOutlined />}>添加车辆</Button>
143
+ </Link>
144
+ </Space>
145
+ </div>
146
+
147
+ <div style={{ marginBottom: 16, display: 'flex', gap: 8, alignItems: 'center' }}>
148
+ <Search
149
+ placeholder="搜索车牌号"
150
+ value={searchPlate}
151
+ onChange={(e) => setSearchPlate(e.target.value)}
152
+ onPressEnter={handleSearch}
153
+ style={{ width: 160 }}
154
+ />
155
+ <Input
156
+ placeholder="搜索人员"
157
+ value={searchPerson}
158
+ onChange={(e) => setSearchPerson(e.target.value)}
159
+ onPressEnter={handleSearch}
160
+ style={{ width: 160 }}
161
+ />
162
+ <Button icon={<SearchOutlined />} onClick={handleSearch}>搜索</Button>
163
+ <Button icon={<ReloadOutlined />} onClick={handleRefresh}>刷新</Button>
164
+ <Select
165
+ value={pageSize}
166
+ onChange={(v) => { setPageSize(v); setPage(1); }}
167
+ style={{ width: 100 }}
168
+ options={[{ value: 10, label: '10/页' }, { value: 20, label: '20/页' }, { value: 50, label: '50/页' }]}
169
+ />
170
+ </div>
171
+
172
+ <Table
173
+ columns={columns}
174
+ dataSource={vehicles}
175
+ rowKey="vehicleId"
176
+ loading={isVehicleLoading}
177
+ pagination={{
178
+ current: page,
179
+ pageSize,
180
+ showSizeChanger: true,
181
+ showTotal: (t) => `共 ${t} 条`,
182
+ onChange: (p, ps) => { setPage(p); setPageSize(ps); },
183
+ }}
184
+ />
185
+
186
+ <Modal
187
+ title="确认删除"
188
+ open={deleteModal.visible}
189
+ onOk={handleDelete}
190
+ onCancel={() => setDeleteModal({ visible: false })}
191
+ okText="确认删除"
192
+ okButtonProps={{ danger: true }}
193
+ >
194
+ <p>确定要删除车辆 <strong>{deleteModal.plateNo}</strong> 吗?</p>
195
+ <p style={{ color: '#ff4d4f' }}>此操作不可撤销!</p>
196
+ </Modal>
197
+ </div>
198
+ );
199
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * API 服务层 - 浏览器端调用 Express API Server
3
+ */
4
+
5
+ import axios from 'axios';
6
+
7
+ const API_BASE = '/api';
8
+ const API_KEY_STORAGE = 'hikvision_api_key';
9
+
10
+ const apiClient = axios.create({
11
+ baseURL: API_BASE,
12
+ timeout: 30000,
13
+ });
14
+
15
+ apiClient.interceptors.response.use(
16
+ (response) => response.data,
17
+ (error) => Promise.reject(error)
18
+ );
19
+
20
+ // 请求拦截器:自动添加 API Key
21
+ apiClient.interceptors.request.use(async (config) => {
22
+ const apiKey = localStorage.getItem(API_KEY_STORAGE);
23
+ if (apiKey) {
24
+ config.headers['x-api-key'] = apiKey;
25
+ }
26
+ return config;
27
+ });
28
+
29
+
30
+ // 保存 API Key 到本地存储
31
+ export const saveApiKey = (key: string) => {
32
+ localStorage.setItem(API_KEY_STORAGE, key);
33
+ };
34
+
35
+ // 获取 API Key
36
+ export const getApiKey = () => localStorage.getItem(API_KEY_STORAGE) || '';
37
+
38
+
39
+ // 清除 API Key
40
+ export const clearApiKey = () => localStorage.removeItem(API_KEY_STORAGE);
41
+
42
+ // ========== 人员 API ==========
43
+ export const personApi = {
44
+ list: (params: { pageNo: number; pageSize: number; personName?: string; orgIndexCode?: string }) =>
45
+ apiClient.get('/persons', { params }),
46
+ search: (params: { pageNo: number; pageSize: number; personName?: string }) =>
47
+ apiClient.get('/persons/search', { params: { pageNo: params.pageNo, pageSize: params.pageSize, name: params.personName } }),
48
+ getById: (personId: string) =>
49
+ apiClient.get(`/persons/${encodeURIComponent(personId)}`),
50
+ create: (data: any) => apiClient.post('/persons', data),
51
+ update: (personId: string, data: any) => apiClient.put(`/persons/${encodeURIComponent(personId)}`, data),
52
+ delete: (personId: string) => apiClient.delete(`/persons/${encodeURIComponent(personId)}`),
53
+ batchDelete: (personIds: string[]) => apiClient.post('/persons/batch-delete', { personIds }),
54
+
55
+ };
56
+
57
+ // ========== 组织 API ==========
58
+ export const orgApi = {
59
+ list: () => apiClient.get('/orgs'),
60
+ getById: (orgIndexCode: string) =>
61
+ apiClient.get(`/orgs/${encodeURIComponent(orgIndexCode)}`),
62
+ getRoot: () => apiClient.get('/orgs/root'),
63
+ getChildren: (orgIndexCode: string) =>
64
+ apiClient.get(`/orgs/${encodeURIComponent(orgIndexCode)}/children`),
65
+ create: (data: any) => apiClient.post('/orgs', data),
66
+ update: (orgIndexCode: string, data: any) =>
67
+ apiClient.put(`/orgs/${encodeURIComponent(orgIndexCode)}`, data),
68
+ delete: (orgIndexCodes: string[]) =>
69
+ apiClient.delete('/orgs', { data: { orgIndexCodes } }),
70
+ sync: (params: { startTime: string; endTime: string }) =>
71
+ apiClient.post('/orgs/sync', params),
72
+ };
73
+
74
+ // ========== 卡片 API ==========
75
+ export const cardApi = {
76
+ list: (params: { pageNo: number; pageSize: number; plateNo?: string; personName?: string }) =>
77
+ apiClient.get('/cards', { params }),
78
+ getByCardNo: (cardNo: string) =>
79
+ apiClient.get(`/cards/${encodeURIComponent(cardNo)}`),
80
+ batchIssue: (cards: any[]) => apiClient.post('/cards/issue', { cards }),
81
+ unbind: (personId: string, cardNo: string) => apiClient.post('/cards/unbind', { personId, cardNo }),
82
+ unloss: (cardNo: string) => apiClient.post('/cards/unloss', { cardNo }),
83
+ };
84
+
85
+ // ========== 车辆 API ==========
86
+ export const vehicleApi = {
87
+ list: (params: { pageNo: number; pageSize: number; plateNo?: string; personName?: string }) =>
88
+ apiClient.get('/vehicles', { params }),
89
+ getById: (vehicleId: string) =>
90
+ apiClient.get(`/vehicles/${encodeURIComponent(vehicleId)}`),
91
+ save: (data: any) => apiClient.post('/vehicles', data),
92
+ saveOrUpdate: (data: any) => apiClient.post('/vehicles', data),
93
+ update: (vehicleId: string, data: any) => apiClient.put(`/vehicles/${encodeURIComponent(vehicleId)}`, data),
94
+ delete: (vehicleId: string) => apiClient.delete(`/vehicles/${encodeURIComponent(vehicleId)}`),
95
+ };
96
+
97
+ // ========== 分组 API ==========
98
+ export const vehicleGroupApi = {
99
+ list: () => apiClient.get('/vehicle-groups'),
100
+ create: (data: any) => apiClient.post('/vehicle-groups', data),
101
+ update: (groupId: string, data: any) => apiClient.put(`/vehicle-groups/${encodeURIComponent(groupId)}`, data),
102
+ delete: (groupId: string) => apiClient.delete(`/vehicle-groups/${encodeURIComponent(groupId)}`),
103
+ };
104
+
105
+ // ========== 绑定关系 API ==========
106
+ export const bindingApi = {
107
+ listPersonCardBindings: () => apiClient.get('/bindings/person-card'),
108
+ listPersonVehicleBindings: () => apiClient.get('/bindings/person-vehicle'),
109
+ getPersonBindings: (personId: string) => apiClient.get('/persons/' + personId + '/bindings'),
110
+ bindPersonCard: (personId: string, cardNo: string) => apiClient.post('/bindings/person-card', { personId, cardNo }),
111
+ unbindPersonCard: (personId: string, cardNo: string) => apiClient.delete(`/bindings/person-card?personId=${personId}&cardNo=${encodeURIComponent(cardNo)}`),
112
+ bindPersonVehicle: (personId: string, vehicleId: string) => apiClient.post('/bindings/person-vehicle', { personId, vehicleId }),
113
+ unbindPersonVehicle: (personId: string, vehicleId: string) => apiClient.delete(`/bindings/person-vehicle?personId=${personId}&vehicleId=${encodeURIComponent(vehicleId)}`),
114
+ };
115
+
116
+ // ========== 同步 API ==========
117
+ export const syncApi = {
118
+ getStatus: () => apiClient.get('/sync/status'),
119
+ getRecords: (params: { pageNo: number; pageSize: number }) => apiClient.get('/sync/records', { params }),
120
+ };
121
+
122
+ // ========== 统计 API ==========
123
+ export const statApi = {
124
+ getStats: () => apiClient.get('/stats'),
125
+ getPersonsByOrg: () => apiClient.get('/stats/persons-by-org'),
126
+ getVehiclesByStatus: () => apiClient.get('/stats/vehicles-by-status'),
127
+ getCardsByStatus: () => apiClient.get('/stats/cards-by-status'),
128
+ };