merge: 合并 main 到 feat/cost-center-wip(同步抖音券、设备认证与图标等最新改动)
This commit is contained in:
@@ -5,18 +5,29 @@ 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 isMacPlatform =
|
||||
typeof navigator !== 'undefined' && /Macintosh|Mac OS X|iPhone|iPad/i.test(navigator.userAgent)
|
||||
|
||||
const brandFeatures = [
|
||||
{
|
||||
icon: <DashboardOutlined />,
|
||||
@@ -25,8 +36,8 @@ const brandFeatures = [
|
||||
},
|
||||
{
|
||||
icon: <SafetyCertificateOutlined />,
|
||||
title: '权限管控',
|
||||
desc: '按角色开放操作,操作可审计',
|
||||
title: '本机认证',
|
||||
desc: '账号密码 + 短信验证码 + DCAuth 设备签名',
|
||||
},
|
||||
{
|
||||
icon: <SettingOutlined />,
|
||||
@@ -35,14 +46,140 @@ const brandFeatures = [
|
||||
},
|
||||
]
|
||||
|
||||
/** 与站点图标同源的品牌标记:橙色渐变圆角方块 + 白色仪表盘四宫格。 */
|
||||
function BrandMark({ size = 42 }: { size?: number }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 64 64"
|
||||
role="img"
|
||||
aria-label="运营后台管理中心"
|
||||
style={{ display: 'block', borderRadius: 10 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="orderBrand"
|
||||
x1="6"
|
||||
y1="2"
|
||||
x2="58"
|
||||
y2="62"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#FB923C" />
|
||||
<stop offset="0.5" stopColor="#F97316" />
|
||||
<stop offset="1" stopColor="#EA580C" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="17" fill="url(#orderBrand)" />
|
||||
<g fill="#fff">
|
||||
<rect x="15" y="15" width="15" height="19" rx="4.5" />
|
||||
<rect x="34" y="15" width="15" height="13" rx="4.5" />
|
||||
<rect x="15" y="38" width="15" height="11" rx="4.5" opacity="0.72" />
|
||||
<rect x="34" y="32" width="15" height="17" rx="4.5" opacity="0.9" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
const submittingRef = useRef(false)
|
||||
|
||||
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) {
|
||||
if (submittingRef.current) return
|
||||
submittingRef.current = true
|
||||
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 && isMacPlatform ? 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 {
|
||||
submittingRef.current = false
|
||||
setLoading(false)
|
||||
setWaiting(false)
|
||||
setDevLaunchUrl('')
|
||||
}
|
||||
}
|
||||
|
||||
async function submitLegacyLogin() {
|
||||
if (submittingRef.current) return
|
||||
let values: Pick<LoginForm, 'username' | 'password'>
|
||||
try {
|
||||
values = await form.validateFields(['username', 'password'])
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
submittingRef.current = true
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await loginAdmin({
|
||||
username: values.username.trim(),
|
||||
@@ -52,27 +189,49 @@ 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 {
|
||||
submittingRef.current = false
|
||||
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">
|
||||
<DashboardOutlined />
|
||||
<BrandMark size={42} />
|
||||
</span>
|
||||
<strong>Order Site Admin</strong>
|
||||
</div>
|
||||
|
||||
<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,29 +250,80 @@ 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 />
|
||||
<BrandMark size={36} />
|
||||
</span>
|
||||
<strong>Order Site Admin</strong>
|
||||
</div>
|
||||
|
||||
<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 未唤起 DCAuth"
|
||||
description={
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Typography.Text type="secondary">
|
||||
首次使用先安装一次,之后浏览器点登录即可直接唤起:
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
code
|
||||
copyable={{ text: 'cd apps/dcauth && ./build-macos.sh' }}
|
||||
style={{ wordBreak: 'break-all' }}
|
||||
>
|
||||
cd apps/dcauth && ./build-macos.sh
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
已安装但仍未弹出时,可在终端手动执行:
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
code
|
||||
copyable={{ text: `open "${devLaunchUrl}"` }}
|
||||
style={{ wordBreak: 'break-all' }}
|
||||
>
|
||||
open "{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 +336,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 +383,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,29 @@ 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.deviceIdMasked}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
绑定于 {formatAdminDateTime(row.deviceBoundAt || '')}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
) : (
|
||||
<Tag>未绑定</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
@@ -343,8 +469,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 +491,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 +508,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 +563,7 @@ export default function AdminUsersPage() {
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
title="后台用户"
|
||||
description="管理员可维护后台账号、姓名、角色和启停状态,避免继续使用单一口令。"
|
||||
description="管理员可维护后台账号、姓名、手机号、测试账号、角色、设备绑定和启停状态。"
|
||||
/>
|
||||
|
||||
<Card className="users-action-card">
|
||||
@@ -462,7 +613,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,7 +621,7 @@ 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"
|
||||
@@ -481,9 +632,23 @@ export default function AdminUsersPage() {
|
||||
>
|
||||
<Input.Password placeholder="新密码,至少 8 位" className="users-input-lg" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="phone"
|
||||
rules={[
|
||||
{
|
||||
pattern: /^1[3-9]\d{9}$/,
|
||||
message: '请输入正确的 11 位手机号',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<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 +681,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,35 @@ 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
|
||||
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,15 @@ export interface AdminUserListItem {
|
||||
displayName: string
|
||||
role: AdminRole
|
||||
status: AdminUserStatus
|
||||
phone: string
|
||||
phoneMasked: string
|
||||
phoneBoundAt: string | null
|
||||
testAccount: boolean
|
||||
deviceBound: boolean
|
||||
deviceId: string
|
||||
deviceIdMasked: string
|
||||
deviceBoundAt: string | null
|
||||
deviceLastUsedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user