增加打手登录设备管理与三台设备限制

This commit is contained in:
yml2213
2026-08-17 15:26:40 +08:00
parent 8d18daa0fe
commit f938ab0cba
13 changed files with 722 additions and 20 deletions
@@ -7,11 +7,18 @@ import {
ThunderboltOutlined,
UserOutlined,
} from '@ant-design/icons'
import { App, Button, Form, Input, Space, Tabs } from 'antd'
import { Alert, App, Button, Form, Input, Modal, Space, Table, Tabs, Typography } from 'antd'
import { useEffect, useState } from 'react'
import { Navigate, useNavigate } from 'react-router'
import { loginWorker, registerWorker, sendWorkerSmsCode } from '@/services/worker'
import {
deleteWorkerSessionWithCredentials,
loginWorker,
manageWorkerSessions,
registerWorker,
sendWorkerSmsCode,
} from '@/services/worker'
import type { WorkerSessionDevice } from '@/types/worker-platform'
import { hasWorkerSession, setWorkerSession } from '@/utils/worker-auth'
import { isWorkerPasswordStrong, WORKER_PASSWORD_RULE_MESSAGE } from '@/utils/worker-password'
@@ -40,7 +47,11 @@ export default function WorkerLoginPage() {
const { message, modal } = App.useApp()
const [loading, setLoading] = useState(false)
const [registerForm] = Form.useForm()
const [deviceForm] = Form.useForm<{ username: string; password: string }>()
const [countdown, setCountdown] = useState(0)
const [deviceManagerOpen, setDeviceManagerOpen] = useState(false)
const [deviceManagerLoading, setDeviceManagerLoading] = useState(false)
const [managedDevices, setManagedDevices] = useState<WorkerSessionDevice[]>([])
useEffect(() => {
if (countdown <= 0) return
@@ -71,12 +82,48 @@ export default function WorkerLoginPage() {
void navigate('/hall', { replace: true })
}
} catch (error) {
if (String((error as { errorCode?: string })?.errorCode || '') === 'worker_device_limit') {
deviceForm.setFieldsValue(values)
setDeviceManagerOpen(true)
await loadManagedDevices(values)
return
}
message.error(error instanceof Error ? error.message : '登录失败')
} finally {
setLoading(false)
}
}
async function loadManagedDevices(values?: { username: string; password: string }) {
const credentials = values || deviceForm.getFieldsValue()
if (!credentials.username || !credentials.password) {
return
}
setDeviceManagerLoading(true)
try {
const response = await manageWorkerSessions(credentials)
setManagedDevices(response.data.items)
} catch (error) {
message.error(error instanceof Error ? error.message : '读取登录设备失败')
} finally {
setDeviceManagerLoading(false)
}
}
async function removeManagedDevice(device: WorkerSessionDevice) {
const credentials = deviceForm.getFieldsValue()
setDeviceManagerLoading(true)
try {
await deleteWorkerSessionWithCredentials({ ...credentials, sessionId: device.sessionId })
message.success('登录设备已删除,请重新登录')
await loadManagedDevices(credentials)
} catch (error) {
message.error(error instanceof Error ? error.message : '删除登录设备失败')
} finally {
setDeviceManagerLoading(false)
}
}
async function requestSmsCode() {
const phone = String(registerForm.getFieldValue('phone') || '').trim()
if (!/^1[3-9]\d{9}$/.test(phone)) {
@@ -214,6 +261,14 @@ export default function WorkerLoginPage() {
>
</Button>
<Button
type="link"
icon={<GlobalOutlined />}
block
onClick={() => setDeviceManagerOpen(true)}
>
</Button>
</Form>
</>
),
@@ -322,6 +377,74 @@ export default function WorkerLoginPage() {
</div>
</section>
</div>
<Modal
title="登录设备管理"
open={deviceManagerOpen}
width={isMobileViewport() ? '92%' : 720}
destroyOnHidden
footer={null}
onCancel={() => setDeviceManagerOpen(false)}
>
<Form
form={deviceForm}
layout="vertical"
onFinish={(values) => void loadManagedDevices(values)}
style={{ marginBottom: 16 }}
>
<Form.Item name="username" rules={[{ required: true, message: '请输入账号' }]}>
<Input prefix={<UserOutlined />} placeholder="手机号 / 昵称 / 账号" />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={deviceManagerLoading}>
</Button>
</Form.Item>
</Form>
<Alert
showIcon
type="info"
message="验证账号密码后,可删除旧设备。所有 PC 和手机合计最多同时在线 3 台。"
style={{ marginBottom: 16 }}
/>
<Table
size="small"
rowKey="sessionId"
loading={deviceManagerLoading}
dataSource={managedDevices}
pagination={false}
columns={[
{
title: '设备',
render: (_, item) => (
<Space direction="vertical" size={0}>
<Typography.Text strong>{item.deviceName || item.deviceType}</Typography.Text>
<Typography.Text type="secondary">{item.ipAddress || '未知 IP'}</Typography.Text>
</Space>
),
},
{
title: '最近活动',
render: (_, item) => item.lastSeenAt.replace('T', ' ').slice(0, 19),
},
{
title: '操作',
width: 100,
render: (_, item) => (
<Button danger type="link" onClick={() => void removeManagedDevice(item)}>
</Button>
),
},
]}
/>
</Modal>
</main>
)
}
function isMobileViewport() {
return typeof window !== 'undefined' && window.innerWidth < 768
}
@@ -2,6 +2,7 @@ import {
BankOutlined,
CopyOutlined,
CustomerServiceOutlined,
GlobalOutlined,
LockOutlined,
LogoutOutlined,
ReloadOutlined,
@@ -44,6 +45,8 @@ import {
createWorkerWithdrawRequest,
fetchWorkerFinanceRequests,
fetchWorkerProfile,
fetchWorkerSessions,
deleteWorkerSession,
fetchWorkerWalletLedgers,
logoutWorker,
} from '@/services/worker'
@@ -51,6 +54,7 @@ import type {
UploadedFile,
WorkerFinanceRequest,
WorkerProfileSummary,
WorkerSessionDevice,
WorkerUser,
WorkerWalletLedger,
} from '@/types/worker-platform'
@@ -104,6 +108,7 @@ export default function WorkerProfilePage() {
const [withdrawalAccountOpen, setWithdrawalAccountOpen] = useState(false)
const [passwordOpen, setPasswordOpen] = useState(false)
const [contactOpen, setContactOpen] = useState(false)
const [devicesOpen, setDevicesOpen] = useState(false)
const [submittingRecharge, setSubmittingRecharge] = useState(false)
const [submittingWithdraw, setSubmittingWithdraw] = useState(false)
const [submittingWithdrawalAccount, setSubmittingWithdrawalAccount] = useState(false)
@@ -115,6 +120,13 @@ export default function WorkerProfilePage() {
const selectedWithdrawChannel = Form.useWatch('accountChannel', withdrawForm)
const selectedWithdrawalAccountChannel = Form.useWatch('accountChannel', withdrawalAccountForm)
const devicesQuery = useQuery({
queryKey: ['worker-sessions'],
queryFn: () => fetchWorkerSessions(),
enabled: devicesOpen,
retry: false,
})
const profileQuery = useQuery({
queryKey: ['worker-profile'],
queryFn: () => fetchWorkerProfile(),
@@ -186,6 +198,16 @@ export default function WorkerProfilePage() {
}
}
async function removeWorkerDevice(device: WorkerSessionDevice) {
try {
await deleteWorkerSession(device.sessionId)
message.success('登录设备已删除')
await devicesQuery.refetch()
} catch (error) {
message.error(error instanceof Error ? error.message : '删除登录设备失败')
}
}
function openRechargeModal() {
if (!canUseFinanceActions) {
message.warning('当前账号尚未通过审核,暂时不能发起充值申请')
@@ -611,6 +633,19 @@ export default function WorkerProfilePage() {
</div>
<span></span>
</button>
<button
type="button"
className="worker-profile-mobile-tool-item"
onClick={() => setDevicesOpen(true)}
>
<div
className="worker-profile-mobile-tool-icon"
style={{ background: '#f0fdfa', color: '#0f766e' }}
>
<GlobalOutlined />
</div>
<span></span>
</button>
<button
type="button"
className="worker-profile-mobile-tool-item"
@@ -1120,6 +1155,9 @@ export default function WorkerProfilePage() {
<Button icon={<LockOutlined />} onClick={() => setPasswordOpen(true)}>
</Button>
<Button icon={<GlobalOutlined />} onClick={() => setDevicesOpen(true)}>
</Button>
<Button icon={<CustomerServiceOutlined />} onClick={() => setContactOpen(true)}>
</Button>
@@ -1626,6 +1664,55 @@ export default function WorkerProfilePage() {
</Form>
</Modal>
<Modal
title="登录设备"
open={devicesOpen}
width={isMobile ? '92%' : 720}
destroyOnHidden
footer={null}
onCancel={() => setDevicesOpen(false)}
>
<Alert
showIcon
type="info"
message={`所有设备合计最多同时在线 ${devicesQuery.data?.data.maxDevices || 3} 台,删除旧设备后才能在新设备登录。`}
/>
<Table
size="small"
loading={devicesQuery.isLoading || devicesQuery.isFetching}
rowKey="sessionId"
dataSource={devicesQuery.data?.data.items || []}
pagination={false}
style={{ marginTop: 16 }}
columns={[
{
title: '设备',
render: (_, item) => (
<Space direction="vertical" size={0}>
<Typography.Text strong>{item.deviceName || item.deviceType}</Typography.Text>
<Typography.Text type="secondary">{item.ipAddress || '未知 IP'}</Typography.Text>
</Space>
),
},
{ title: '最近活动', render: (_, item) => formatDateTime(item.lastSeenAt) },
{
title: '操作',
width: 100,
render: (_, item) => (
<Button
danger
type="link"
disabled={item.current}
onClick={() => void removeWorkerDevice(item)}
>
{item.current ? '当前设备' : '删除'}
</Button>
),
},
]}
/>
</Modal>
<Modal
title="修改密码"
open={passwordOpen}