|
| 1 | +import { KeyOutlined, CopyOutlined, DeleteOutlined } from '@ant-design/icons'; |
| 2 | +import { |
| 3 | + Button, |
| 4 | + Card, |
| 5 | + Checkbox, |
| 6 | + Form, |
| 7 | + Input, |
| 8 | + message, |
| 9 | + Modal, |
| 10 | + Popconfirm, |
| 11 | + Select, |
| 12 | + Space, |
| 13 | + Table, |
| 14 | + Tag, |
| 15 | + Typography, |
| 16 | +} from 'antd'; |
| 17 | +import type { ColumnsType } from 'antd/es/table'; |
| 18 | +import dayjs from 'dayjs'; |
| 19 | +import { useState } from 'react'; |
| 20 | +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; |
| 21 | +import { api } from '@/services/api'; |
| 22 | + |
| 23 | +const { Paragraph } = Typography; |
| 24 | + |
| 25 | +function ApiTokensPage() { |
| 26 | + const queryClient = useQueryClient(); |
| 27 | + const [createModalVisible, setCreateModalVisible] = useState(false); |
| 28 | + const [newToken, setNewToken] = useState<string | null>(null); |
| 29 | + const [form] = Form.useForm(); |
| 30 | + |
| 31 | + const { data, isLoading } = useQuery({ |
| 32 | + queryKey: ['apiTokens'], |
| 33 | + queryFn: api.listApiTokens, |
| 34 | + }); |
| 35 | + |
| 36 | + const createMutation = useMutation({ |
| 37 | + mutationFn: api.createApiToken, |
| 38 | + onSuccess: (result) => { |
| 39 | + if (result?.token) { |
| 40 | + setNewToken(result.token); |
| 41 | + setCreateModalVisible(false); |
| 42 | + message.success('API Token 创建成功'); |
| 43 | + queryClient.invalidateQueries({ queryKey: ['apiTokens'] }); |
| 44 | + form.resetFields(); |
| 45 | + } |
| 46 | + }, |
| 47 | + onError: (error: Error) => { |
| 48 | + message.error(error.message || '创建失败'); |
| 49 | + }, |
| 50 | + }); |
| 51 | + |
| 52 | + const revokeMutation = useMutation({ |
| 53 | + mutationFn: api.revokeApiToken, |
| 54 | + onSuccess: () => { |
| 55 | + message.success('Token 已撤销'); |
| 56 | + queryClient.invalidateQueries({ queryKey: ['apiTokens'] }); |
| 57 | + }, |
| 58 | + onError: (error: Error) => { |
| 59 | + message.error(error.message || '撤销失败'); |
| 60 | + }, |
| 61 | + }); |
| 62 | + |
| 63 | + const handleCreate = async (values: { |
| 64 | + name: string; |
| 65 | + permissions: string[]; |
| 66 | + expiresIn?: number; |
| 67 | + }) => { |
| 68 | + const permissions = { |
| 69 | + read: values.permissions?.includes('read'), |
| 70 | + write: values.permissions?.includes('write'), |
| 71 | + delete: values.permissions?.includes('delete'), |
| 72 | + }; |
| 73 | + const expiresAt = values.expiresIn |
| 74 | + ? dayjs().add(values.expiresIn, 'day').toISOString() |
| 75 | + : undefined; |
| 76 | + await createMutation.mutateAsync({ |
| 77 | + name: values.name, |
| 78 | + permissions, |
| 79 | + expiresAt, |
| 80 | + }); |
| 81 | + }; |
| 82 | + |
| 83 | + const columns: ColumnsType<ApiToken> = [ |
| 84 | + { |
| 85 | + title: '名称', |
| 86 | + dataIndex: 'name', |
| 87 | + key: 'name', |
| 88 | + render: (name: string, record: ApiToken) => ( |
| 89 | + <Space> |
| 90 | + <KeyOutlined /> |
| 91 | + {name} |
| 92 | + {record.isRevoked && <Tag color="red">已撤销</Tag>} |
| 93 | + {record.isExpired && !record.isRevoked && ( |
| 94 | + <Tag color="orange">已过期</Tag> |
| 95 | + )} |
| 96 | + </Space> |
| 97 | + ), |
| 98 | + }, |
| 99 | + { |
| 100 | + title: 'Token', |
| 101 | + dataIndex: 'tokenSuffix', |
| 102 | + key: 'tokenSuffix', |
| 103 | + render: (tokenSuffix: string) => ( |
| 104 | + <span className="font-mono text-gray-500">****{tokenSuffix}</span> |
| 105 | + ), |
| 106 | + }, |
| 107 | + { |
| 108 | + title: '权限', |
| 109 | + dataIndex: 'permissions', |
| 110 | + key: 'permissions', |
| 111 | + render: (permissions: ApiToken['permissions']) => ( |
| 112 | + <Space> |
| 113 | + {permissions?.read && <Tag color="blue">读取</Tag>} |
| 114 | + {permissions?.write && <Tag color="green">写入</Tag>} |
| 115 | + {permissions?.delete && <Tag color="red">删除</Tag>} |
| 116 | + </Space> |
| 117 | + ), |
| 118 | + }, |
| 119 | + { |
| 120 | + title: '过期时间', |
| 121 | + dataIndex: 'expiresAt', |
| 122 | + key: 'expiresAt', |
| 123 | + render: (expiresAt: string | null) => |
| 124 | + expiresAt ? dayjs(expiresAt).format('YYYY-MM-DD HH:mm') : '永不过期', |
| 125 | + }, |
| 126 | + { |
| 127 | + title: '最后使用', |
| 128 | + dataIndex: 'lastUsedAt', |
| 129 | + key: 'lastUsedAt', |
| 130 | + render: (lastUsedAt: string | null) => |
| 131 | + lastUsedAt ? dayjs(lastUsedAt).format('YYYY-MM-DD HH:mm') : '从未使用', |
| 132 | + }, |
| 133 | + { |
| 134 | + title: '创建时间', |
| 135 | + dataIndex: 'createdAt', |
| 136 | + key: 'createdAt', |
| 137 | + render: (createdAt: string) => dayjs(createdAt).format('YYYY-MM-DD HH:mm'), |
| 138 | + }, |
| 139 | + { |
| 140 | + title: '操作', |
| 141 | + key: 'action', |
| 142 | + render: (_: unknown, record: ApiToken) => ( |
| 143 | + <Popconfirm |
| 144 | + title="确认撤销" |
| 145 | + description="撤销后此 Token 将无法再使用,确定要撤销吗?" |
| 146 | + onConfirm={() => revokeMutation.mutate(record.id)} |
| 147 | + okText="确定" |
| 148 | + cancelText="取消" |
| 149 | + disabled={record.isRevoked} |
| 150 | + > |
| 151 | + <Button |
| 152 | + type="text" |
| 153 | + danger |
| 154 | + icon={<DeleteOutlined />} |
| 155 | + disabled={record.isRevoked} |
| 156 | + > |
| 157 | + 撤销 |
| 158 | + </Button> |
| 159 | + </Popconfirm> |
| 160 | + ), |
| 161 | + }, |
| 162 | + ]; |
| 163 | + |
| 164 | + return ( |
| 165 | + <div className="body"> |
| 166 | + <Card |
| 167 | + title="API Token 管理" |
| 168 | + extra={ |
| 169 | + <Button |
| 170 | + type="primary" |
| 171 | + icon={<KeyOutlined />} |
| 172 | + onClick={() => setCreateModalVisible(true)} |
| 173 | + > |
| 174 | + 创建 Token |
| 175 | + </Button> |
| 176 | + } |
| 177 | + > |
| 178 | + <Paragraph type="secondary" className="mb-4"> |
| 179 | + API Token 可用于 CI/CD 流程或自动化脚本中调用 Pushy API。每个用户最多可创建 |
| 180 | + 10 个 Token。 |
| 181 | + </Paragraph> |
| 182 | + <Table |
| 183 | + columns={columns} |
| 184 | + dataSource={data?.data} |
| 185 | + loading={isLoading} |
| 186 | + rowKey="id" |
| 187 | + pagination={false} |
| 188 | + /> |
| 189 | + </Card> |
| 190 | + |
| 191 | + <Modal |
| 192 | + title="创建 API Token" |
| 193 | + open={createModalVisible} |
| 194 | + onCancel={() => { |
| 195 | + setCreateModalVisible(false); |
| 196 | + form.resetFields(); |
| 197 | + }} |
| 198 | + footer={null} |
| 199 | + destroyOnClose |
| 200 | + > |
| 201 | + <Form form={form} layout="vertical" onFinish={handleCreate}> |
| 202 | + <Form.Item |
| 203 | + label="Token 名称" |
| 204 | + name="name" |
| 205 | + rules={[{ required: true, message: '请输入 Token 名称' }]} |
| 206 | + > |
| 207 | + <Input placeholder="例如:CI/CD Pipeline" maxLength={100} /> |
| 208 | + </Form.Item> |
| 209 | + <Form.Item |
| 210 | + label="权限" |
| 211 | + name="permissions" |
| 212 | + rules={[{ required: true, message: '请至少选择一个权限' }]} |
| 213 | + > |
| 214 | + <Checkbox.Group> |
| 215 | + <Space direction="vertical"> |
| 216 | + <Checkbox value="read"> |
| 217 | + <b>读取 (read)</b> - 查看应用、版本、原生包信息 |
| 218 | + </Checkbox> |
| 219 | + <Checkbox value="write"> |
| 220 | + <b>写入 (write)</b> - 创建和更新应用、发布版本、上传原生包 |
| 221 | + </Checkbox> |
| 222 | + <Checkbox value="delete"> |
| 223 | + <b>删除 (delete)</b> - 删除应用、版本、原生包 |
| 224 | + </Checkbox> |
| 225 | + </Space> |
| 226 | + </Checkbox.Group> |
| 227 | + </Form.Item> |
| 228 | + <Form.Item label="过期时间" name="expiresIn" initialValue={180}> |
| 229 | + <Select |
| 230 | + options={[ |
| 231 | + { value: 0, label: '永不过期' }, |
| 232 | + { value: 30, label: '30 天' }, |
| 233 | + { value: 90, label: '90 天' }, |
| 234 | + { value: 180, label: '180 天' }, |
| 235 | + { value: 360, label: '360 天' }, |
| 236 | + ]} |
| 237 | + /> |
| 238 | + </Form.Item> |
| 239 | + <Form.Item className="mb-0"> |
| 240 | + <Button |
| 241 | + type="primary" |
| 242 | + htmlType="submit" |
| 243 | + loading={createMutation.isPending} |
| 244 | + block |
| 245 | + > |
| 246 | + 创建 |
| 247 | + </Button> |
| 248 | + </Form.Item> |
| 249 | + </Form> |
| 250 | + </Modal> |
| 251 | + |
| 252 | + <Modal |
| 253 | + title="Token 创建成功" |
| 254 | + open={!!newToken} |
| 255 | + onOk={() => setNewToken(null)} |
| 256 | + onCancel={() => setNewToken(null)} |
| 257 | + cancelButtonProps={{ style: { display: 'none' } }} |
| 258 | + okText="我已保存" |
| 259 | + > |
| 260 | + <div className="my-4"> |
| 261 | + <Paragraph type="warning" className="mb-2"> |
| 262 | + ⚠️ 请立即复制并安全保存此 Token,关闭后将无法再次查看! |
| 263 | + </Paragraph> |
| 264 | + <Input.TextArea |
| 265 | + value={newToken || ''} |
| 266 | + readOnly |
| 267 | + autoSize={{ minRows: 2 }} |
| 268 | + className="font-mono" |
| 269 | + /> |
| 270 | + <Button |
| 271 | + icon={<CopyOutlined />} |
| 272 | + className="mt-2" |
| 273 | + onClick={() => { |
| 274 | + if (newToken) { |
| 275 | + navigator.clipboard.writeText(newToken); |
| 276 | + message.success('已复制到剪贴板'); |
| 277 | + } |
| 278 | + }} |
| 279 | + > |
| 280 | + 复制 Token |
| 281 | + </Button> |
| 282 | + </div> |
| 283 | + </Modal> |
| 284 | + </div> |
| 285 | + ); |
| 286 | +} |
| 287 | + |
| 288 | +export const Component = ApiTokensPage; |
0 commit comments