迁移用户审计和领取页

This commit is contained in:
yml2213
2026-07-07 18:31:55 +08:00
parent 45d4df74fc
commit 0b61aa4a14
5 changed files with 2376 additions and 3 deletions
+6 -3
View File
@@ -6,11 +6,14 @@ import { getAdminRole, hasAdminSession } from '@/utils/admin-auth'
import AdminLayout from '@/layouts/AdminLayout' import AdminLayout from '@/layouts/AdminLayout'
const AdminDashboardPage = lazy(() => import('@/pages/admin/AdminDashboardPage')) const AdminDashboardPage = lazy(() => import('@/pages/admin/AdminDashboardPage'))
const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AdminAuditLogsPage'))
const AdminLoginPage = lazy(() => import('@/pages/admin/AdminLoginPage')) const AdminLoginPage = lazy(() => import('@/pages/admin/AdminLoginPage'))
const AdminOrderDetailPage = lazy(() => import('@/pages/admin/AdminOrderDetailPage')) const AdminOrderDetailPage = lazy(() => import('@/pages/admin/AdminOrderDetailPage'))
const AdminOrdersPage = lazy(() => import('@/pages/admin/AdminOrdersPage')) const AdminOrdersPage = lazy(() => import('@/pages/admin/AdminOrdersPage'))
const AdminTaskDetailPage = lazy(() => import('@/pages/admin/AdminTaskDetailPage')) const AdminTaskDetailPage = lazy(() => import('@/pages/admin/AdminTaskDetailPage'))
const AdminTasksPage = lazy(() => import('@/pages/admin/AdminTasksPage')) const AdminTasksPage = lazy(() => import('@/pages/admin/AdminTasksPage'))
const AdminUsersPage = lazy(() => import('@/pages/admin/AdminUsersPage'))
const ClaimPage = lazy(() => import('@/pages/claim/ClaimPage'))
const NotFoundPage = lazy(() => import('@/pages/NotFoundPage')) const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'))
function RequireAdmin() { function RequireAdmin() {
@@ -55,14 +58,14 @@ export default function App() {
<Route path="tasks" element={<AdminTasksPage />} /> <Route path="tasks" element={<AdminTasksPage />} />
<Route path="tasks/:taskId" element={<AdminTaskDetailPage />} /> <Route path="tasks/:taskId" element={<AdminTaskDetailPage />} />
<Route element={<RequireRole roles={['admin']} />}> <Route element={<RequireRole roles={['admin']} />}>
<Route path="users" element={<NotFoundPage title="用户管理迁移中" />} /> <Route path="users" element={<AdminUsersPage />} />
<Route path="platform-shops" element={<NotFoundPage title="平台配置迁移中" />} /> <Route path="platform-shops" element={<NotFoundPage title="平台配置迁移中" />} />
<Route path="platform-fulfillment" element={<NotFoundPage title="履约配置迁移中" />} /> <Route path="platform-fulfillment" element={<NotFoundPage title="履约配置迁移中" />} />
<Route path="audit-logs" element={<NotFoundPage title="审计日志迁移中" />} /> <Route path="audit-logs" element={<AdminAuditLogsPage />} />
</Route> </Route>
</Route> </Route>
</Route> </Route>
<Route path="/claim/:token" element={<NotFoundPage title="领取页迁移中" />} /> <Route path="/claim/:token" element={<ClaimPage />} />
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
</Routes> </Routes>
</Suspense> </Suspense>
@@ -0,0 +1,231 @@
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'
import { Alert, Button, Card, DatePicker, Empty, Form, Input, Select, Space, Table, Typography } from 'antd'
import type { TableColumnsType } from 'antd'
import { useQuery } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { useMemo } from 'react'
import { useSearchParams } from 'react-router'
import PageHeader from '@/components/admin/PageHeader'
import StatusTag from '@/components/admin/StatusTag'
import { fetchAdminAuditLogs } from '@/services/admin'
import type { AdminAuditLogItem } from '@/types/admin'
import { hasAdminRole } from '@/utils/admin-auth'
import { formatAuditAction, formatAuditTargetType } from '@/utils/admin-display'
import { adminAuditActionOptions, adminAuditTargetTypeOptions } from '@/utils/admin-options'
import { formatAdminDateTime } from '@/utils/admin-time'
import { stringifyDisplayJson } from '@/utils/date-time'
type AuditFilterForm = {
actorUsername?: string
action?: string
targetType?: string
dateRange?: unknown
}
export default function AdminAuditLogsPage() {
const isAdmin = hasAdminRole('admin')
const [searchParams, setSearchParams] = useSearchParams()
const page = Number(searchParams.get('page') || 1) || 1
const pageSize = Number(searchParams.get('pageSize') || 20) || 20
const filters = {
actorUsername: searchParams.get('actorUsername') || '',
action: searchParams.get('action') || '',
targetType: searchParams.get('targetType') || '',
dateFrom: searchParams.get('dateFrom') || '',
dateTo: searchParams.get('dateTo') || '',
}
const queryParams = useMemo(
() => ({ page, pageSize, ...filters }),
[
filters.action,
filters.actorUsername,
filters.dateFrom,
filters.dateTo,
filters.targetType,
page,
pageSize,
],
)
const query = useQuery({
queryKey: ['admin-audit-logs', queryParams],
enabled: isAdmin,
queryFn: () => fetchAdminAuditLogs(queryParams),
})
const data = query.data?.data
const columns: TableColumnsType<AdminAuditLogItem> = [
{
title: '时间',
dataIndex: 'createdAt',
width: 180,
render: (value) => formatAdminDateTime(value),
},
{
title: '操作人',
minWidth: 140,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text strong>{row.actorUsername || '-'}</Typography.Text>
<StatusTag value={row.actorRole} />
</div>
),
},
{
title: '动作',
dataIndex: 'action',
minWidth: 180,
render: (value) => <Typography.Text strong>{formatAuditAction(value)}</Typography.Text>,
},
{
title: '目标',
minWidth: 180,
render: (_, row) => (
<Typography.Text strong>
{formatAuditTargetType(row.targetType)} #{row.targetId || '-'}
</Typography.Text>
),
},
{
title: '摘要',
minWidth: 260,
render: (_, row) => <span className="payload-text">{formatPayload(row.payload)}</span>,
},
]
function applyFilters(values: AuditFilterForm) {
const [dateFrom, dateTo] = Array.isArray(values.dateRange)
? values.dateRange.map((item: { format?: (pattern: string) => string }) =>
item?.format ? item.format('YYYY-MM-DD') : '',
)
: ['', '']
setSearchParams(
cleanParams({
actorUsername: values.actorUsername,
action: values.action,
targetType: values.targetType,
dateFrom,
dateTo,
page: 1,
pageSize,
}),
)
}
function resetFilters() {
setSearchParams(cleanParams({ page: 1, pageSize }))
}
return (
<section className="page-stack">
<PageHeader
title="操作审计"
description="集中查看高风险后台动作,便于排查谁在什么时间改了什么。"
extra={<Typography.Text type="secondary"> {data?.pagination.total || 0} </Typography.Text>}
/>
{!isAdmin ? (
<Alert type="warning" showIcon message="仅管理员可以访问操作审计" />
) : (
<>
<Card title="筛选审计日志">
<Form<AuditFilterForm>
layout="inline"
initialValues={{
actorUsername: filters.actorUsername,
action: filters.action,
targetType: filters.targetType,
dateRange:
filters.dateFrom && filters.dateTo
? [dayjs(filters.dateFrom), dayjs(filters.dateTo)]
: undefined,
}}
onFinish={applyFilters}
className="filter-form"
>
<Form.Item name="actorUsername">
<Input allowClear placeholder="操作账号" />
</Form.Item>
<Form.Item name="action">
<Select
allowClear
placeholder="动作"
options={adminAuditActionOptions}
style={{ minWidth: 180 }}
/>
</Form.Item>
<Form.Item name="targetType">
<Select
allowClear
placeholder="目标类型"
options={adminAuditTargetTypeOptions}
style={{ minWidth: 150 }}
/>
</Form.Item>
<Form.Item name="dateRange">
<DatePicker.RangePicker />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
</Button>
<Button icon={<ReloadOutlined />} onClick={resetFilters}>
</Button>
</Space>
</Form.Item>
</Form>
</Card>
{query.error ? (
<Alert
type="error"
showIcon
message={query.error instanceof Error ? query.error.message : '读取审计日志失败'}
/>
) : null}
<Card>
<Table<AdminAuditLogItem>
rowKey="logId"
loading={query.isLoading || query.isFetching}
columns={columns}
dataSource={data?.items || []}
locale={{ emptyText: <Empty description="暂无审计记录" /> }}
scroll={{ x: 980 }}
pagination={{
current: data?.pagination.page || page,
pageSize: data?.pagination.pageSize || pageSize,
total: data?.pagination.total || 0,
showTotal: (total) => `${total}`,
onChange: (nextPage, nextPageSize) => {
setSearchParams(
cleanParams({ ...filters, page: nextPage, pageSize: nextPageSize }),
)
},
}}
/>
</Card>
</>
)}
</section>
)
}
function formatPayload(payload: Record<string, unknown>) {
const text = stringifyDisplayJson(payload, 0)
return text.length > 140 ? `${text.slice(0, 140)}...` : text
}
function cleanParams(params: Record<string, unknown>) {
const next = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
const normalized = String(value ?? '').trim()
if (normalized) {
next.set(key, normalized)
}
})
return next
}
@@ -0,0 +1,454 @@
import {
DownOutlined,
PlusOutlined,
ReloadOutlined,
SearchOutlined,
} from '@ant-design/icons'
import {
Alert,
App,
Button,
Card,
Dropdown,
Empty,
Form,
Input,
Modal,
Result,
Select,
Space,
Table,
Tag,
Typography,
} from 'antd'
import type { MenuProps, TableColumnsType } from 'antd'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { useSearchParams } from 'react-router'
import PageHeader from '@/components/admin/PageHeader'
import StatusTag from '@/components/admin/StatusTag'
import {
createAdminUser,
fetchAdminUsers,
resetAdminUserPassword,
updateAdminUserRole,
updateAdminUserStatus,
} from '@/services/admin'
import type { AdminRole, AdminUserListItem } from '@/types/admin'
import { getAdminUserId, hasAdminRole } from '@/utils/admin-auth'
import { adminUserRoleOptions, adminUserStatusOptions } from '@/utils/admin-options'
import { formatAdminDateTime } from '@/utils/admin-time'
type UserFilterForm = {
username?: string
role?: string
status?: string
}
type CreateUserForm = {
username: string
password: string
role: AdminRole
}
const USER_ROLE_OPTIONS = [
{ label: '普通运营', value: 'operator' },
{ label: '客服', value: 'support' },
{ label: '管理员', value: 'admin' },
]
export default function AdminUsersPage() {
const isAdmin = hasAdminRole('admin')
const currentUserId = getAdminUserId()
const queryClient = useQueryClient()
const { message, modal } = App.useApp()
const [searchParams, setSearchParams] = useSearchParams()
const [createForm] = Form.useForm<CreateUserForm>()
const [creating, setCreating] = useState(false)
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null)
const page = Number(searchParams.get('page') || 1) || 1
const pageSize = Number(searchParams.get('pageSize') || 20) || 20
const filters = {
username: searchParams.get('username') || '',
role: searchParams.get('role') || '',
status: searchParams.get('status') || '',
}
const queryParams = useMemo(
() => ({ page, pageSize, ...filters }),
[filters.role, filters.status, filters.username, page, pageSize],
)
const query = useQuery({
queryKey: ['admin-users', queryParams],
enabled: isAdmin,
queryFn: () => fetchAdminUsers(queryParams),
})
const data = query.data?.data
async function reloadUsers() {
await queryClient.invalidateQueries({ queryKey: ['admin-users'] })
}
async function submitCreate(values: CreateUserForm) {
setCreating(true)
try {
await createAdminUser({
username: values.username.trim(),
password: values.password.trim(),
role: values.role,
status: 'active',
})
message.success('后台用户已创建')
createForm.resetFields()
setSearchParams(cleanParams({ ...filters, page: 1, pageSize }))
await reloadUsers()
} catch (error) {
message.error(error instanceof Error ? error.message : '创建后台用户失败')
} finally {
setCreating(false)
}
}
function confirmUpdateRole(item: AdminUserListItem, nextRole: AdminRole) {
if (item.role === nextRole) return
modal.confirm({
title: '确认操作',
content: `确认将 ${item.username} 调整为${formatRoleLabel(nextRole)}吗?`,
okText: '继续执行',
cancelText: '取消',
centered: true,
onOk: async () => {
setActionLoadingId(item.userId)
try {
await updateAdminUserRole(item.userId, { role: nextRole })
message.success('用户角色已更新')
await reloadUsers()
} catch (error) {
message.error(error instanceof Error ? error.message : '更新用户角色失败')
} finally {
setActionLoadingId(null)
}
},
})
}
function confirmToggleStatus(item: AdminUserListItem) {
const nextStatus = item.status === 'active' ? 'disabled' : 'active'
modal.confirm({
title: '确认操作',
content: `确认将 ${item.username}${nextStatus === 'active' ? '启用' : '停用'}吗?`,
okText: '继续执行',
cancelText: '取消',
centered: true,
onOk: async () => {
setActionLoadingId(item.userId)
try {
await updateAdminUserStatus(item.userId, { status: nextStatus })
message.success('用户状态已更新')
await reloadUsers()
} catch (error) {
message.error(error instanceof Error ? error.message : '更新用户状态失败')
} finally {
setActionLoadingId(null)
}
},
})
}
function openResetPasswordModal(item: AdminUserListItem) {
const form = ModalFormStore.create<{ password: string }>()
modal.confirm({
title: '重置密码',
content: (
<Form
layout="vertical"
preserve={false}
initialValues={{ password: '' }}
ref={form.bind}
className="modal-form"
>
<Form.Item
name="password"
label={`请输入 ${item.username} 的新密码`}
rules={[
{ required: true, message: '请输入新密码' },
{ min: 8, message: '密码至少 8 位' },
]}
>
<Input.Password placeholder="至少 8 位" />
</Form.Item>
</Form>
),
okText: '提交',
cancelText: '取消',
centered: true,
onOk: async () => {
const values = await form.validate()
setActionLoadingId(item.userId)
try {
await resetAdminUserPassword(item.userId, { password: values.password.trim() })
message.success('用户密码已重置')
await reloadUsers()
} catch (error) {
message.error(error instanceof Error ? error.message : '重置密码失败')
} finally {
setActionLoadingId(null)
}
},
})
}
function applyFilters(values: UserFilterForm) {
setSearchParams(cleanParams({ ...values, page: 1, pageSize }))
}
function resetFilters() {
setSearchParams(cleanParams({ page: 1, pageSize }))
}
if (!isAdmin) {
return (
<section className="page-stack">
<PageHeader title="后台用户" description="管理员可维护后台账号、角色和启停状态。" />
<Result status="warning" title="仅管理员可以访问用户管理。" />
</section>
)
}
const columns: TableColumnsType<AdminUserListItem> = [
{
title: 'ID',
dataIndex: 'userId',
width: 72,
align: 'center',
},
{
title: '账号',
dataIndex: 'username',
minWidth: 150,
render: (value, row) => (
<Space>
<Typography.Text strong>{value}</Typography.Text>
{row.userId === currentUserId ? <Tag></Tag> : null}
</Space>
),
},
{
title: '角色',
dataIndex: 'role',
width: 120,
render: (value: AdminRole) => formatRoleLabel(value),
},
{
title: '状态',
dataIndex: 'status',
width: 140,
render: (value) => <StatusTag value={value} />,
},
{
title: '创建时间',
dataIndex: 'createdAt',
width: 170,
render: (value) => formatAdminDateTime(value),
},
{
title: '更新时间',
dataIndex: 'updatedAt',
width: 170,
render: (value) => formatAdminDateTime(value),
},
{
title: '操作',
width: 310,
render: (_, row) => {
const isCurrent = row.userId === currentUserId
const roleMenuItems: MenuProps['items'] = USER_ROLE_OPTIONS.map((item) => ({
key: item.value,
label: `设为${item.label}`,
disabled: row.role === item.value,
}))
return (
<Space wrap>
<Dropdown
disabled={isCurrent}
menu={{
items: roleMenuItems,
onClick: ({ key }) => confirmUpdateRole(row, key as AdminRole),
}}
>
<Button disabled={isCurrent} loading={actionLoadingId === row.userId}>
<DownOutlined />
</Button>
</Dropdown>
<Button
disabled={isCurrent}
loading={actionLoadingId === row.userId}
onClick={() => confirmToggleStatus(row)}
>
{row.status === 'active' ? '停用' : '启用'}
</Button>
<Button
type="primary"
loading={actionLoadingId === row.userId}
onClick={() => openResetPasswordModal(row)}
>
</Button>
</Space>
)
},
},
]
return (
<section className="page-stack">
<PageHeader
title="后台用户"
description="管理员可维护后台账号、角色和启停状态,避免继续使用单一口令。"
/>
<Card title="筛选用户">
<Form<UserFilterForm>
layout="inline"
initialValues={filters}
onFinish={applyFilters}
className="filter-form"
>
<Form.Item name="username">
<Input allowClear placeholder="账号筛选" />
</Form.Item>
<Form.Item name="role">
<Select
allowClear
placeholder="角色"
options={adminUserRoleOptions}
style={{ minWidth: 140 }}
/>
</Form.Item>
<Form.Item name="status">
<Select
allowClear
placeholder="状态"
options={adminUserStatusOptions}
style={{ minWidth: 140 }}
/>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
</Button>
<Button icon={<ReloadOutlined />} onClick={resetFilters}>
</Button>
</Space>
</Form.Item>
</Form>
</Card>
<Card title="新增用户">
<Form<CreateUserForm>
form={createForm}
layout="inline"
initialValues={{ role: 'operator' }}
onFinish={submitCreate}
className="filter-form"
>
<Form.Item name="username" rules={[{ required: true, message: '请输入新账号' }]}>
<Input placeholder="新账号" />
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 8, message: '密码至少 8 位' },
]}
>
<Input.Password placeholder="新密码,至少 8 位" />
</Form.Item>
<Form.Item name="role" rules={[{ required: true, message: '请选择角色' }]}>
<Select options={USER_ROLE_OPTIONS} style={{ minWidth: 140 }} />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" icon={<PlusOutlined />} loading={creating}>
</Button>
</Form.Item>
</Form>
</Card>
{query.error ? (
<Alert
type="error"
showIcon
message={query.error instanceof Error ? query.error.message : '读取后台用户失败'}
/>
) : null}
<Card
title="用户列表"
extra={<Typography.Text type="secondary"> {data?.pagination.total || 0} </Typography.Text>}
>
<Table<AdminUserListItem>
rowKey="userId"
loading={query.isLoading || query.isFetching}
columns={columns}
dataSource={data?.items || []}
locale={{ emptyText: <Empty description="暂无用户" /> }}
scroll={{ x: 1060 }}
pagination={{
current: data?.pagination.page || page,
pageSize: data?.pagination.pageSize || pageSize,
total: data?.pagination.total || 0,
showTotal: (total) => `${total}`,
onChange: (nextPage, nextPageSize) => {
setSearchParams(cleanParams({ ...filters, page: nextPage, pageSize: nextPageSize }))
},
}}
/>
</Card>
</section>
)
}
function formatRoleLabel(role: AdminRole) {
if (role === 'admin') return '管理员'
if (role === 'support') return '客服'
return '普通运营'
}
function cleanParams(params: Record<string, unknown>) {
const next = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
const normalized = String(value ?? '').trim()
if (normalized) {
next.set(key, normalized)
}
})
return next
}
class ModalFormStore<T extends object> {
private form: ReturnType<typeof Form.useForm<T>>[0] | null = null
static create<T extends object>() {
return new ModalFormStore<T>()
}
bind = (instance: unknown) => {
this.form = instance as ReturnType<typeof Form.useForm<T>>[0] | null
}
async validate(): Promise<T> {
if (!this.form) {
return {} as T
}
return this.form.validateFields()
}
}
File diff suppressed because it is too large Load Diff
+523
View File
@@ -172,6 +172,403 @@ select {
margin-top: 0; margin-top: 0;
} }
.modal-form {
margin-top: 14px;
}
.payload-text {
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 12px;
color: #4b5563;
word-break: break-all;
}
.claim-page {
min-height: 100vh;
padding: 24px;
background: linear-gradient(180deg, #f8fafc 0%, #eef4fb 100%);
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
}
.claim-header-card,
.claim-content-card {
width: min(680px, 100%);
border-color: rgba(148, 163, 184, 0.18);
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.08);
}
.claim-header-card {
position: relative;
}
.claim-header-card .ant-card-body,
.claim-content-card .ant-card-body {
display: flex;
flex-direction: column;
gap: 18px;
}
.claim-header-copy {
min-width: 0;
padding-right: 220px;
}
.claim-header-copy h1 {
margin: 0;
color: #0f172a;
font-size: 30px;
line-height: 1.25;
overflow-wrap: anywhere;
}
.claim-header-copy p,
.claim-muted {
margin: 0;
color: #64748b;
line-height: 1.6;
}
.claim-step-indicator {
position: absolute;
top: 24px;
right: 24px;
max-width: 210px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 7px;
}
.claim-step-dots {
display: flex;
gap: 8px;
}
.claim-step-dot {
width: 32px;
height: 32px;
border-radius: 50%;
background: #e5e7eb;
color: #94a3b8;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
}
.claim-step-dot.active {
background: #1677ff;
color: #ffffff;
}
.claim-step-progress-text {
color: #64748b;
font-size: 13px;
font-weight: 700;
line-height: 1.4;
text-align: right;
overflow-wrap: anywhere;
}
.claim-content-card .ant-typography {
margin: 0;
}
.claim-state-card .ant-card-body {
min-height: 240px;
align-items: center;
justify-content: center;
text-align: center;
}
.claim-large-icon {
font-size: 44px;
}
.claim-icon-success {
color: #16a34a;
}
.claim-icon-warning {
color: #d97706;
}
.claim-icon-info {
color: #1677ff;
}
.claim-icon-error {
color: #dc2626;
}
.claim-guide-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.claim-guide-figure {
margin: 0;
background: #f8fafc;
border: 1px solid #e5e7eb;
border-radius: 6px;
overflow: hidden;
}
.claim-guide-figure .ant-image,
.claim-guide-figure img {
width: 100%;
display: block;
}
.claim-guide-figure figcaption {
padding: 10px 12px 14px;
color: #475569;
font-size: 13px;
}
.claim-qr-block {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 14px;
border-radius: 8px;
background: #f8fbff;
border: 1px solid #dbeafe;
}
.claim-qr-guide-panel {
display: grid;
grid-template-columns: 42px minmax(0, 280px) 42px;
grid-template-areas:
'left code right'
'. hint .';
gap: 10px;
align-items: center;
justify-content: center;
width: 100%;
max-width: 410px;
min-width: 0;
}
.claim-scan-label {
align-self: stretch;
min-height: 150px;
padding: 10px 6px;
color: #e11d48;
font-weight: 900;
font-size: 17px;
line-height: 1.2;
writing-mode: vertical-rl;
text-orientation: upright;
display: flex;
align-items: center;
justify-content: center;
}
.claim-scan-label-left {
grid-area: left;
}
.claim-scan-label-right {
grid-area: right;
}
.claim-qr-code {
grid-area: code;
width: 100%;
max-width: 280px;
aspect-ratio: 1 / 1;
padding: 10px;
background: #ffffff;
border-radius: 8px;
border: 1px solid #dbeafe;
}
.claim-save-hint {
grid-area: hint;
margin: 0;
justify-self: center;
color: #64748b;
font-size: 14px;
font-weight: 700;
line-height: 1.4;
text-align: center;
}
.claim-role-panel {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
.claim-role-panel.pending {
opacity: 0.82;
}
.claim-role-panel-item {
min-width: 0;
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
align-items: center;
gap: 12px;
}
.claim-role-panel-item span {
color: #64748b;
font-size: 13px;
line-height: 1.4;
white-space: nowrap;
}
.claim-role-panel-item strong {
min-width: 0;
color: #0f172a;
font-size: 17px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.claim-action-row {
width: 100%;
}
.claim-action-tooltip-wrap {
display: inline-flex;
}
.claim-meta-footer {
display: flex;
flex-wrap: wrap;
gap: 4px 10px;
justify-content: center;
color: #94a3b8;
font-size: 11px;
line-height: 1.5;
}
.claim-info-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.claim-info-item {
min-width: 0;
padding: 16px;
border-radius: 6px;
background: #f8fafc;
border: 1px solid #e5e7eb;
display: flex;
flex-direction: column;
gap: 6px;
}
.claim-info-item span {
color: #64748b;
font-size: 13px;
}
.claim-info-item strong {
color: #0f172a;
font-size: 17px;
overflow-wrap: anywhere;
}
.claim-result-header {
display: flex;
align-items: flex-start;
gap: 14px;
}
.claim-feifei-main {
display: grid;
gap: 8px;
}
.claim-feifei-main p {
margin: 0;
color: #1677ff;
font-weight: 700;
}
.claim-feifei-label {
color: #64748b;
font-size: 13px;
}
.claim-product-items {
display: flex;
flex-direction: column;
gap: 10px;
}
.claim-product-items-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #64748b;
font-size: 13px;
}
.claim-product-items-header strong {
color: #1677ff;
font-weight: 700;
}
.claim-product-items-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.claim-product-item-row {
min-height: 44px;
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 6px;
background: #f8fafc;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.claim-product-item-row span {
min-width: 0;
color: #0f172a;
font-weight: 700;
line-height: 1.35;
overflow-wrap: anywhere;
}
.claim-product-item-row strong {
flex: 0 0 auto;
color: #0f172a;
font-size: 15px;
}
.claim-product-items.compact {
margin-top: 10px;
gap: 8px;
}
.claim-product-items.compact .claim-product-items-list {
gap: 6px;
}
.claim-product-items.compact .claim-product-item-row {
min-height: 36px;
padding: 8px 10px;
background: rgba(248, 250, 252, 0.72);
}
@media (max-width: 900px) { @media (max-width: 900px) {
.admin-sider { .admin-sider {
position: fixed !important; position: fixed !important;
@@ -193,3 +590,129 @@ select {
display: grid; display: grid;
} }
} }
@media (max-width: 720px) {
.claim-page {
padding: 14px;
}
.claim-header-card .ant-card-body,
.claim-content-card .ant-card-body {
gap: 14px;
}
.claim-header-copy {
padding-right: 150px;
}
.claim-header-copy h1 {
font-size: 24px;
line-height: 1.22;
}
.claim-header-copy p {
font-size: 14px;
line-height: 1.4;
}
.claim-step-indicator {
top: 16px;
right: 16px;
max-width: 140px;
}
.claim-step-dots {
gap: 5px;
}
.claim-step-dot {
width: 26px;
height: 26px;
font-size: 13px;
}
.claim-step-progress-text {
font-size: 12px;
}
.claim-guide-grid,
.claim-info-grid {
grid-template-columns: 1fr;
}
.claim-qr-block {
padding: 12px;
}
.claim-qr-guide-panel {
grid-template-columns: 34px minmax(0, 1fr) 34px;
gap: 8px;
max-width: 100%;
}
.claim-scan-label {
min-height: 128px;
padding: 8px 4px;
font-size: 14px;
}
.claim-qr-code {
max-width: 232px;
padding: 8px;
}
.claim-save-hint {
font-size: 13px;
}
.claim-role-panel-item {
grid-template-columns: 76px minmax(0, 1fr);
gap: 10px;
}
.claim-role-panel-item span {
font-size: 12px;
}
.claim-role-panel-item strong {
font-size: 16px;
}
.claim-action-row {
display: flex;
flex-direction: column;
align-items: stretch;
}
.claim-action-row .ant-space-item,
.claim-action-row .ant-btn,
.claim-action-tooltip-wrap {
width: 100%;
}
.claim-meta-footer {
justify-content: flex-start;
}
.claim-result-header {
display: grid;
}
}
@media (max-width: 420px) {
.claim-header-copy {
padding-right: 124px;
}
.claim-header-copy h1 {
font-size: 22px;
}
.claim-header-copy p {
padding-right: 0;
}
.claim-step-indicator {
max-width: 116px;
}
}