feat(dcauth): 新增后台手机号与设备认证登录
登录链路改为账号密码 + 短信验证码 + DCAuth 设备签名,并新增后台手机号绑定、 登录日志、设备绑定与重置接口。
This commit is contained in:
@@ -5,16 +5,24 @@ import {
|
||||
SettingOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { App, Button, Form, Input } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import { Alert, App, Button, Divider, Form, Input, Space, Typography } from 'antd'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
import { loginAdmin } from '@/services/admin'
|
||||
import {
|
||||
completeAdminLogin,
|
||||
fetchAdminDeviceStatus,
|
||||
loginAdmin,
|
||||
prepareAdminLogin,
|
||||
sendAdminLoginSmsCode,
|
||||
} from '@/services/admin'
|
||||
import type { AdminDevicePrepareResponse } from '@/types/admin'
|
||||
import { setAdminSession } from '@/utils/admin-auth'
|
||||
|
||||
type LoginForm = {
|
||||
username: string
|
||||
password: string
|
||||
smsCode: string
|
||||
}
|
||||
|
||||
const brandFeatures = [
|
||||
@@ -25,8 +33,8 @@ const brandFeatures = [
|
||||
},
|
||||
{
|
||||
icon: <SafetyCertificateOutlined />,
|
||||
title: '权限管控',
|
||||
desc: '按角色开放操作,操作可审计',
|
||||
title: '本机认证',
|
||||
desc: '账号密码 + 短信验证码 + DCAuth 设备签名',
|
||||
},
|
||||
{
|
||||
icon: <SettingOutlined />,
|
||||
@@ -38,11 +46,95 @@ const brandFeatures = [
|
||||
export default function AdminLoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const { message } = App.useApp()
|
||||
const [form] = Form.useForm<LoginForm>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [sendingCode, setSendingCode] = useState(false)
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
const [phoneMasked, setPhoneMasked] = useState('')
|
||||
const [waiting, setWaiting] = useState(false)
|
||||
const [devLaunchUrl, setDevLaunchUrl] = useState('')
|
||||
const timerRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) window.clearInterval(timerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
function startCountdown(seconds: number) {
|
||||
setCountdown(seconds)
|
||||
if (timerRef.current) window.clearInterval(timerRef.current)
|
||||
timerRef.current = window.setInterval(() => {
|
||||
setCountdown((current) => {
|
||||
if (current <= 1) {
|
||||
if (timerRef.current) window.clearInterval(timerRef.current)
|
||||
return 0
|
||||
}
|
||||
return current - 1
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function sendCode() {
|
||||
try {
|
||||
const values = await form.validateFields(['username', 'password'])
|
||||
setSendingCode(true)
|
||||
const response = await sendAdminLoginSmsCode({
|
||||
username: values.username.trim(),
|
||||
password: values.password.trim(),
|
||||
})
|
||||
setPhoneMasked(response.data.phoneMasked)
|
||||
startCountdown(60)
|
||||
message.success(`验证码已发送至 ${response.data.phoneMasked}`)
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '验证码发送失败')
|
||||
} finally {
|
||||
setSendingCode(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitLogin(values: LoginForm) {
|
||||
setLoading(true)
|
||||
setWaiting(false)
|
||||
try {
|
||||
const prepared = await prepareAdminLogin({
|
||||
username: values.username.trim(),
|
||||
password: values.password.trim(),
|
||||
smsCode: values.smsCode.trim(),
|
||||
apiBase: window.location.origin,
|
||||
})
|
||||
setPhoneMasked(prepared.data.phoneMasked)
|
||||
setWaiting(true)
|
||||
setDevLaunchUrl(import.meta.env.DEV ? prepared.data.launchUrl : '')
|
||||
message.info('已发起本机验证,请在弹出的 DCAuth 窗口中确认')
|
||||
launchProtocol(prepared.data.launchUrl)
|
||||
|
||||
const completed = await waitForDevice(prepared.data)
|
||||
setAdminSession(completed.token, completed.expiresAt, completed.user)
|
||||
message.success('登录成功')
|
||||
void navigate('/admin/dashboard', { replace: true })
|
||||
} catch (error) {
|
||||
const code = (error as { errorCode?: string })?.errorCode
|
||||
if (code === 'admin_phone_unbound') {
|
||||
message.warning('该账号未绑定手机号,请联系管理员绑定后再使用安全登录')
|
||||
} else {
|
||||
message.error(error instanceof Error ? error.message : '登录失败')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setWaiting(false)
|
||||
setDevLaunchUrl('')
|
||||
}
|
||||
}
|
||||
|
||||
async function submitLegacyLogin() {
|
||||
let values: Pick<LoginForm, 'username' | 'password'>
|
||||
try {
|
||||
values = await form.validateFields(['username', 'password'])
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await loginAdmin({
|
||||
username: values.username.trim(),
|
||||
@@ -52,16 +144,37 @@ export default function AdminLoginPage() {
|
||||
message.success('登录成功')
|
||||
void navigate('/admin/dashboard', { replace: true })
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '后台登录失败')
|
||||
message.error(error instanceof Error ? error.message : '账号密码登录失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForDevice(prepared: AdminDevicePrepareResponse) {
|
||||
for (let attempt = 0; attempt < 120; attempt += 1) {
|
||||
await sleep(1000)
|
||||
const status = await fetchAdminDeviceStatus(prepared.challengeId, prepared.pollToken)
|
||||
if (status.data.status === 'authorized' && status.data.deviceTicket) {
|
||||
const completed = await completeAdminLogin({
|
||||
challengeId: prepared.challengeId,
|
||||
pollToken: prepared.pollToken,
|
||||
deviceTicket: status.data.deviceTicket,
|
||||
})
|
||||
return completed.data
|
||||
}
|
||||
if (status.data.status === 'expired' || status.data.status === 'rejected') {
|
||||
throw new Error(status.data.message || '本机验证已过期,请重新登录')
|
||||
}
|
||||
if (attempt === 4) {
|
||||
message.info('如果浏览器没有弹出 DCAuth,请先安装并运行一次 DCAuth.exe')
|
||||
}
|
||||
}
|
||||
throw new Error('等待本机验证超时,请确认 DCAuth 已安装并允许浏览器唤起')
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="auth-page">
|
||||
<div className="auth-shell">
|
||||
{/* 品牌信息区:移动端隐藏 */}
|
||||
<aside className="auth-brand">
|
||||
<div className="auth-brand-logo">
|
||||
<span className="auth-logo-mark">
|
||||
@@ -72,7 +185,7 @@ export default function AdminLoginPage() {
|
||||
|
||||
<div className="auth-brand-body">
|
||||
<h2>运营后台管理中心</h2>
|
||||
<p>统一管理订单履约、打手调度与财务结算,按角色开放不同操作权限。</p>
|
||||
<p>账号密码验证通过后,还需短信验证码和本机 DCAuth 设备签名才能进入后台。</p>
|
||||
<div className="auth-brand-features">
|
||||
{brandFeatures.map((item) => (
|
||||
<div className="auth-brand-feature" key={item.title}>
|
||||
@@ -91,10 +204,8 @@ export default function AdminLoginPage() {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 登录表单区 */}
|
||||
<section className="auth-form-side">
|
||||
<div className="auth-form-card">
|
||||
{/* 移动端精简品牌栏 */}
|
||||
<div className="auth-mobile-brand">
|
||||
<span className="auth-logo-mark">
|
||||
<DashboardOutlined />
|
||||
@@ -104,16 +215,58 @@ export default function AdminLoginPage() {
|
||||
|
||||
<div className="auth-form-header">
|
||||
<h1>运营后台登录</h1>
|
||||
<p>使用账号密码进入后台,并按角色开放不同操作权限</p>
|
||||
<p>账号密码 + 短信验证码 + 本机 DCAuth 设备验证</p>
|
||||
</div>
|
||||
|
||||
<Form<LoginForm> layout="vertical" requiredMark={false} onFinish={submitLogin}>
|
||||
{waiting ? (
|
||||
<>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="等待本机确认"
|
||||
description="请在弹出的 DCAuth 窗口中点击确认。若未弹出,请先安装并运行一次 DCAuth.exe。"
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
{import.meta.env.DEV && devLaunchUrl ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="macOS 开发调试"
|
||||
description={
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Typography.Text type="secondary">
|
||||
浏览器无法直接唤起 Mac 程序。复制下面的链接,在终端执行:
|
||||
</Typography.Text>
|
||||
<Typography.Paragraph copyable={{ text: devLaunchUrl }} style={{ margin: 0 }}>
|
||||
{devLaunchUrl}
|
||||
</Typography.Paragraph>
|
||||
<Typography.Text code copyable={{ text: `cd apps/dcauth && go run . "${devLaunchUrl}"` }}>
|
||||
cd apps/dcauth && go run . "{devLaunchUrl}"
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Form<LoginForm>
|
||||
form={form}
|
||||
layout="vertical"
|
||||
requiredMark={false}
|
||||
onFinish={submitLogin}
|
||||
>
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="账号"
|
||||
rules={[{ required: true, message: '请输入账号' }]}
|
||||
>
|
||||
<Input prefix={<UserOutlined />} placeholder="输入账号" autoComplete="username" />
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="中文 / 英文 / 数字账号"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
@@ -126,11 +279,46 @@ export default function AdminLoginPage() {
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="smsCode"
|
||||
label={`短信验证码${phoneMasked ? `(${phoneMasked})` : ''}`}
|
||||
rules={[{ required: true, message: '请输入短信验证码' }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<SafetyCertificateOutlined />}
|
||||
placeholder="6 位短信验证码"
|
||||
maxLength={6}
|
||||
autoComplete="one-time-code"
|
||||
addonAfter={
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
htmlType="button"
|
||||
loading={sendingCode}
|
||||
disabled={countdown > 0}
|
||||
onClick={() => void sendCode()}
|
||||
style={{ padding: 0, height: 'auto' }}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s 后重发` : '获取验证码'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Button type="primary" htmlType="submit" block loading={loading} size="large">
|
||||
登录后台
|
||||
安全登录 · 拉起本机验证
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<Divider plain style={{ margin: '18px 0 12px' }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
技术测试账号 / 未绑定手机号
|
||||
</Typography.Text>
|
||||
</Divider>
|
||||
<Button type="link" block onClick={() => void submitLegacyLogin()} disabled={loading}>
|
||||
账号密码登录(测试 / 过渡)
|
||||
</Button>
|
||||
|
||||
<div className="auth-form-footer">仅限授权运营人员访问</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -138,3 +326,16 @@ export default function AdminLoginPage() {
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function launchProtocol(url: string) {
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.style.display = 'none'
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
window.setTimeout(() => anchor.remove(), 1000)
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Result,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
@@ -27,7 +28,10 @@ import {
|
||||
fetchAdminRolePermissions,
|
||||
fetchAdminUsers,
|
||||
resetAdminUserPassword,
|
||||
resetAdminUserDevice,
|
||||
updateAdminUserDisplayName,
|
||||
updateAdminUserPhone,
|
||||
updateAdminUserTestAccount,
|
||||
updateAdminUserRole,
|
||||
updateAdminUserStatus,
|
||||
updateAdminRolePermissions,
|
||||
@@ -53,6 +57,8 @@ type CreateUserForm = {
|
||||
username: string
|
||||
password: string
|
||||
role: AdminRole
|
||||
phone?: string
|
||||
testAccount?: boolean
|
||||
}
|
||||
|
||||
type DisplayNameForm = {
|
||||
@@ -129,6 +135,8 @@ export default function AdminUsersPage() {
|
||||
password: values.password.trim(),
|
||||
role: values.role,
|
||||
status: 'active',
|
||||
phone: values.phone?.trim() || '',
|
||||
testAccount: values.testAccount === true,
|
||||
})
|
||||
message.success('后台用户已创建')
|
||||
createForm.resetFields()
|
||||
@@ -293,6 +301,101 @@ export default function AdminUsersPage() {
|
||||
})
|
||||
}
|
||||
|
||||
function openBindPhoneModal(item: AdminUserListItem) {
|
||||
const form = ModalFormStore.create<{ phone: string }>()
|
||||
|
||||
modal.confirm({
|
||||
title: '绑定手机号',
|
||||
content: (
|
||||
<Form
|
||||
layout="vertical"
|
||||
preserve={false}
|
||||
initialValues={{ phone: item.phone || '' }}
|
||||
ref={form.bind}
|
||||
className="modal-form"
|
||||
>
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label={`${displayNameOf(item)} 的登录手机号`}
|
||||
rules={[
|
||||
{ required: true, message: '请输入手机号' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的 11 位手机号' },
|
||||
]}
|
||||
extra="绑定后该账号必须使用账号密码 + 短信验证码 + 本机 DCAuth 登录"
|
||||
>
|
||||
<Input placeholder="11 位手机号" maxLength={11} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
okText: '保存',
|
||||
cancelText: '取消',
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
const values = await form.validate()
|
||||
setActionLoadingId(item.userId)
|
||||
try {
|
||||
await updateAdminUserPhone(item.userId, { phone: values.phone.trim() })
|
||||
message.success('手机号已绑定')
|
||||
await reloadUsers()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '绑定手机号失败')
|
||||
} finally {
|
||||
setActionLoadingId(null)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function confirmResetDevice(item: AdminUserListItem) {
|
||||
if (!item.deviceBound) return
|
||||
modal.confirm({
|
||||
title: '重置设备绑定',
|
||||
content: `确认解除 ${displayNameOf(item)} 的当前设备吗?解绑后该账号需要重新完成首次设备绑定。`,
|
||||
okText: '确认解绑',
|
||||
cancelText: '取消',
|
||||
okButtonProps: { danger: true },
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
setActionLoadingId(item.userId)
|
||||
try {
|
||||
await resetAdminUserDevice(item.userId)
|
||||
message.success('设备绑定已重置')
|
||||
await reloadUsers()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '重置设备失败')
|
||||
} finally {
|
||||
setActionLoadingId(null)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function confirmToggleTestAccount(item: AdminUserListItem) {
|
||||
const next = !item.testAccount
|
||||
modal.confirm({
|
||||
title: next ? '设为技术测试账号' : '取消技术测试账号',
|
||||
content: next
|
||||
? `确认让 ${displayNameOf(item)} 跳过短信验证码和 DCAuth 设备验证,仅使用账号密码登录吗?仅建议用于技术测试。`
|
||||
: `确认恢复 ${displayNameOf(item)} 的完整安全登录流程吗?`,
|
||||
okText: next ? '设为测试账号' : '恢复正式账号',
|
||||
cancelText: '取消',
|
||||
okButtonProps: { danger: next },
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
setActionLoadingId(item.userId)
|
||||
try {
|
||||
await updateAdminUserTestAccount(item.userId, { testAccount: next })
|
||||
message.success(next ? '已设为技术测试账号' : '已恢复正式账号')
|
||||
await reloadUsers()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '更新测试账号设置失败')
|
||||
} finally {
|
||||
setActionLoadingId(null)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function applyFilters(values: UserFilterForm) {
|
||||
setSearchParams(cleanParams({ ...values, page: 1, pageSize }))
|
||||
}
|
||||
@@ -334,6 +437,33 @@ export default function AdminUsersPage() {
|
||||
minWidth: 140,
|
||||
render: (value: string) => <Typography.Text type="secondary">{value}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '手机号',
|
||||
dataIndex: 'phone',
|
||||
width: 150,
|
||||
render: (value: string) =>
|
||||
value ? (
|
||||
<Typography.Text>{value}</Typography.Text>
|
||||
) : (
|
||||
<Tag color="orange">未绑定</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '登录设备',
|
||||
dataIndex: 'deviceBound',
|
||||
width: 210,
|
||||
render: (_: boolean, row) =>
|
||||
row.deviceBound ? (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Text>{row.machineCodeMask || row.deviceIdMasked}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
绑定于 {formatAdminDateTime(row.deviceBoundAt || '')}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
) : (
|
||||
<Tag>未绑定</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
@@ -343,8 +473,13 @@ export default function AdminUsersPage() {
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 140,
|
||||
render: (value) => <StatusTag value={value} />,
|
||||
width: 170,
|
||||
render: (value, row) => (
|
||||
<Space wrap>
|
||||
<StatusTag value={value} />
|
||||
{row.testAccount ? <Tag color="purple">技术测试</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
@@ -360,7 +495,7 @@ export default function AdminUsersPage() {
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 390,
|
||||
width: 760,
|
||||
render: (_, row) => {
|
||||
const isCurrent = row.userId === currentUserId
|
||||
const roleMenuItems: MenuProps['items'] = USER_ROLE_OPTIONS.map((item) => ({
|
||||
@@ -377,6 +512,26 @@ export default function AdminUsersPage() {
|
||||
>
|
||||
改名
|
||||
</Button>
|
||||
<Button
|
||||
loading={actionLoadingId === row.userId}
|
||||
onClick={() => openBindPhoneModal(row)}
|
||||
>
|
||||
绑定手机号
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!row.deviceBound}
|
||||
danger
|
||||
loading={actionLoadingId === row.userId}
|
||||
onClick={() => confirmResetDevice(row)}
|
||||
>
|
||||
重置设备
|
||||
</Button>
|
||||
<Button
|
||||
loading={actionLoadingId === row.userId}
|
||||
onClick={() => confirmToggleTestAccount(row)}
|
||||
>
|
||||
{row.testAccount ? '取消测试账号' : '设为测试账号'}
|
||||
</Button>
|
||||
<Dropdown
|
||||
disabled={isCurrent}
|
||||
menu={{
|
||||
@@ -412,7 +567,7 @@ export default function AdminUsersPage() {
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
title="后台用户"
|
||||
description="管理员可维护后台账号、姓名、角色和启停状态,避免继续使用单一口令。"
|
||||
description="管理员可维护后台账号、姓名、手机号、测试账号、角色、设备绑定和启停状态。"
|
||||
/>
|
||||
|
||||
<Card className="users-action-card">
|
||||
@@ -462,7 +617,7 @@ export default function AdminUsersPage() {
|
||||
<Form<CreateUserForm>
|
||||
form={createForm}
|
||||
layout="inline"
|
||||
initialValues={{ role: 'operator' }}
|
||||
initialValues={{ role: 'operator', testAccount: false }}
|
||||
onFinish={submitCreate}
|
||||
className="filter-form users-action-form"
|
||||
>
|
||||
@@ -470,20 +625,28 @@ export default function AdminUsersPage() {
|
||||
<Input placeholder="姓名(可中文)" className="users-input-md" maxLength={32} />
|
||||
</Form.Item>
|
||||
<Form.Item name="username" rules={[{ required: true, message: '请输入新账号' }]}>
|
||||
<Input placeholder="登录账号(英文/数字)" className="users-input-md" />
|
||||
<Input placeholder="登录账号(中文/英文/数字)" className="users-input-md" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" rules={[{ required: true, message: '请输入新密码' }, { min: 8, message: '密码至少 8 位' }]}>
|
||||
<Input.Password placeholder="新密码,至少 8 位" className="users-input-lg" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
name="phone"
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 8, message: '密码至少 8 位' },
|
||||
{
|
||||
pattern: /^1[3-9]\d{9}$/,
|
||||
message: '请输入正确的 11 位手机号',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="新密码,至少 8 位" className="users-input-lg" />
|
||||
<Input placeholder="绑定手机号(可选)" className="users-input-md" maxLength={11} />
|
||||
</Form.Item>
|
||||
<Form.Item name="role" rules={[{ required: true, message: '请选择角色' }]}>
|
||||
<Select options={USER_ROLE_OPTIONS} className="users-select-sm" />
|
||||
</Form.Item>
|
||||
<Form.Item name="testAccount" valuePropName="checked">
|
||||
<Switch checkedChildren="技术测试" unCheckedChildren="正式账号" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<PlusOutlined />} loading={creating}>
|
||||
新增用户
|
||||
@@ -516,7 +679,7 @@ export default function AdminUsersPage() {
|
||||
columns={columns}
|
||||
dataSource={data?.items || []}
|
||||
locale={{ emptyText: <Empty description="暂无用户" /> }}
|
||||
scroll={{ x: 1220 }}
|
||||
scroll={{ x: 1680 }}
|
||||
pagination={buildAdminTablePagination({
|
||||
current: data?.pagination.page || page,
|
||||
pageSize: data?.pagination.pageSize || pageSize,
|
||||
|
||||
@@ -1,10 +1,44 @@
|
||||
import { apiGet, apiPost } from '@/lib/http'
|
||||
import type { AdminLoginResponse, AdminSessionSummary } from '@/types/admin'
|
||||
import type {
|
||||
AdminDevicePrepareResponse,
|
||||
AdminDeviceStatusResponse,
|
||||
AdminLoginResponse,
|
||||
AdminSessionSummary,
|
||||
AdminSmsCodeResponse,
|
||||
} from '@/types/admin'
|
||||
|
||||
export function loginAdmin(payload: { username: string; password: string }) {
|
||||
return apiPost<AdminLoginResponse>('/api/v1/admin/auth/login', payload)
|
||||
}
|
||||
|
||||
export function sendAdminLoginSmsCode(payload: { username: string; password: string }) {
|
||||
return apiPost<AdminSmsCodeResponse>('/api/v1/admin/auth/login/sms-code', payload)
|
||||
}
|
||||
|
||||
export function prepareAdminLogin(payload: {
|
||||
username: string
|
||||
password: string
|
||||
smsCode: string
|
||||
apiBase: string
|
||||
}) {
|
||||
return apiPost<AdminDevicePrepareResponse>('/api/v1/admin/auth/login/prepare', payload)
|
||||
}
|
||||
|
||||
export function fetchAdminDeviceStatus(challengeId: string, pollToken: string) {
|
||||
return apiGet<AdminDeviceStatusResponse>(
|
||||
`/api/dcauth/challenge/${encodeURIComponent(challengeId)}/status`,
|
||||
{ pollToken },
|
||||
)
|
||||
}
|
||||
|
||||
export function completeAdminLogin(payload: {
|
||||
challengeId: string
|
||||
pollToken: string
|
||||
deviceTicket: string
|
||||
}) {
|
||||
return apiPost<AdminLoginResponse>('/api/v1/admin/auth/login/complete', payload)
|
||||
}
|
||||
|
||||
export function fetchAdminSession() {
|
||||
return apiGet<AdminSessionSummary>('/api/v1/admin/auth/session')
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ export function createAdminUser(payload: {
|
||||
password: string
|
||||
role: string
|
||||
status?: string
|
||||
phone?: string
|
||||
testAccount?: boolean
|
||||
}) {
|
||||
return apiPost<{ user: AdminUserListItem }>('/api/v1/admin/users', payload)
|
||||
}
|
||||
@@ -64,3 +66,24 @@ export function resetAdminUserPassword(userId: number | string, payload: { passw
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function updateAdminUserPhone(userId: number | string, payload: { phone: string }) {
|
||||
return apiPost<{ user: AdminUserListItem }>(`/api/v1/admin/users/${userId}/phone`, payload)
|
||||
}
|
||||
|
||||
export function resetAdminUserDevice(userId: number | string) {
|
||||
return apiPost<{ user: AdminUserListItem; reset: boolean }>(
|
||||
`/api/v1/admin/users/${userId}/device/reset`,
|
||||
{},
|
||||
)
|
||||
}
|
||||
|
||||
export function updateAdminUserTestAccount(
|
||||
userId: number | string,
|
||||
payload: { testAccount: boolean },
|
||||
) {
|
||||
return apiPost<{ user: AdminUserListItem; testAccount: boolean }>(
|
||||
`/api/v1/admin/users/${userId}/test-account`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,10 +9,36 @@ export interface AdminLoginResponse {
|
||||
username: string
|
||||
displayName: string
|
||||
role: AdminRole
|
||||
deviceId?: string
|
||||
permissions?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdminSmsCodeResponse {
|
||||
sent: boolean
|
||||
phoneMasked: string
|
||||
expiresInSeconds: number
|
||||
provider: string
|
||||
}
|
||||
|
||||
export interface AdminDevicePrepareResponse {
|
||||
challengeId: string
|
||||
challenge: string
|
||||
expiresAt: string
|
||||
pollToken: string
|
||||
launchUrl: string
|
||||
phoneMasked: string
|
||||
app: string
|
||||
}
|
||||
|
||||
export interface AdminDeviceStatusResponse {
|
||||
status: 'pending' | 'authorized' | 'ticket_issued' | 'consumed' | 'expired' | 'rejected'
|
||||
deviceTicket?: string
|
||||
deviceId?: string
|
||||
machineCodeMask?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface AdminSessionSummary {
|
||||
authenticated: boolean
|
||||
expiresAt: string
|
||||
|
||||
@@ -3,6 +3,11 @@ export type { AdminPagination } from './common'
|
||||
|
||||
// Auth types
|
||||
export type { AdminRole, AdminUserStatus, AdminLoginResponse, AdminSessionSummary } from './auth'
|
||||
export type {
|
||||
AdminDevicePrepareResponse,
|
||||
AdminDeviceStatusResponse,
|
||||
AdminSmsCodeResponse,
|
||||
} from './auth'
|
||||
|
||||
// Users types
|
||||
export type { AdminUserListItem } from './users'
|
||||
|
||||
@@ -6,6 +6,16 @@ export interface AdminUserListItem {
|
||||
displayName: string
|
||||
role: AdminRole
|
||||
status: AdminUserStatus
|
||||
phone: string
|
||||
phoneMasked: string
|
||||
phoneBoundAt: string | null
|
||||
testAccount: boolean
|
||||
deviceBound: boolean
|
||||
deviceId: string
|
||||
deviceIdMasked: string
|
||||
machineCodeMask: string
|
||||
deviceBoundAt: string | null
|
||||
deviceLastUsedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user