feat(admin): SMS 管理后台 — admin 端点 + 用户管理增强 + SMS 日志页
Phase 8: admin-web 后台管理增强 ## 后端 (auth-service) - 新增 AdminSmsController (JWT 保护): · GET /admin/sms/logs — 按手机号查询 SMS 发送日志 · GET /admin/sms/logs/user/:id — 按用户 ID 查询其 SMS 日志 · POST /admin/sms/unlock/:id — 手动解锁账号(清除 loginFailCount + lockedUntil) · 手机号脱敏: 138****5678 格式 - auth.module.ts 注册 AdminSmsController ## 前端 (admin-web) - UserManagementPage 增强: · 新增状态列: 正常(绿) / 已冻结(红) / 已锁定(黄) · 手机号自动掩码显示 · 冻结/解冻按钮根据状态切换 · 锁定用户显示"解锁"按钮 - 新增 SMS 日志查看页面 (SmsLogPage): · 按手机号搜索 SMS 发送记录 · 类型、状态 badge 展示 · 路由: /users/sms-logs - AdminLayout 侧边栏新增 "SMS 日志" 导航项 - i18n 补充 (zh-CN/en-US/ja-JP): · 用户状态: user_active, user_frozen, user_locked, user_unlock · SMS 日志: 17 个新 key (sms_log_*, sms_type_*, sms_status_*, nav_users_sms_logs) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c29067eee7
commit
e59c0d0527
|
|
@ -37,6 +37,7 @@ import { EventPublisherService } from './application/services/event-publisher.se
|
|||
|
||||
// Interface controllers
|
||||
import { AuthController } from './interface/http/controllers/auth.controller';
|
||||
import { AdminSmsController } from './interface/http/controllers/admin-sms.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -47,7 +48,7 @@ import { AuthController } from './interface/http/controllers/auth.controller';
|
|||
signOptions: { expiresIn: process.env.JWT_ACCESS_EXPIRY || '15m' },
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
controllers: [AuthController, AdminSmsController],
|
||||
providers: [
|
||||
// Infrastructure -> Domain port binding
|
||||
{ provide: USER_REPOSITORY, useClass: UserRepository },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Param,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { SmsService } from '../../../application/services/sms.service';
|
||||
import { Inject } from '@nestjs/common';
|
||||
import {
|
||||
SMS_LOG_REPOSITORY,
|
||||
ISmsLogRepository,
|
||||
} from '../../../domain/repositories/sms-log.repository.interface';
|
||||
import {
|
||||
SMS_VERIFICATION_REPOSITORY,
|
||||
ISmsVerificationRepository,
|
||||
} from '../../../domain/repositories/sms-verification.repository.interface';
|
||||
import {
|
||||
USER_REPOSITORY,
|
||||
IUserRepository,
|
||||
} from '../../../domain/repositories/user.repository.interface';
|
||||
|
||||
@ApiTags('Admin - SMS')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@Controller('admin/sms')
|
||||
export class AdminSmsController {
|
||||
constructor(
|
||||
@Inject(SMS_LOG_REPOSITORY)
|
||||
private readonly smsLogRepo: ISmsLogRepository,
|
||||
@Inject(SMS_VERIFICATION_REPOSITORY)
|
||||
private readonly smsVerificationRepo: ISmsVerificationRepository,
|
||||
@Inject(USER_REPOSITORY)
|
||||
private readonly userRepo: IUserRepository,
|
||||
) {}
|
||||
|
||||
/* ── SMS 发送日志 ── */
|
||||
|
||||
@Get('logs')
|
||||
@ApiOperation({ summary: '查询 SMS 发送日志' })
|
||||
@ApiQuery({ name: 'phone', required: false })
|
||||
@ApiQuery({ name: 'limit', required: false })
|
||||
@ApiQuery({ name: 'offset', required: false })
|
||||
@ApiResponse({ status: 200, description: 'SMS 日志列表' })
|
||||
async getSmsLogs(
|
||||
@Query('phone') phone?: string,
|
||||
@Query('limit') limit = 20,
|
||||
@Query('offset') offset = 0,
|
||||
) {
|
||||
const logs = phone
|
||||
? await this.smsLogRepo.findByPhone(phone, { limit: +limit, offset: +offset })
|
||||
: [];
|
||||
// Mask phone in response: 138****8000
|
||||
const masked = logs.map((log) => ({
|
||||
...log,
|
||||
phone: this.maskPhone(log.phone),
|
||||
}));
|
||||
return {
|
||||
code: 0,
|
||||
data: { items: masked, total: masked.length },
|
||||
message: 'ok',
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 用户 SMS 日志 ── */
|
||||
|
||||
@Get('logs/user/:userId')
|
||||
@ApiOperation({ summary: '查询指定用户的 SMS 日志' })
|
||||
@ApiResponse({ status: 200, description: '用户 SMS 日志列表' })
|
||||
async getUserSmsLogs(
|
||||
@Param('userId') userId: string,
|
||||
@Query('limit') limit = 20,
|
||||
@Query('offset') offset = 0,
|
||||
) {
|
||||
// 获取用户手机号
|
||||
const user = await this.userRepo.findById(userId);
|
||||
if (!user || !user.phone) {
|
||||
return { code: 0, data: { items: [], total: 0 }, message: 'ok' };
|
||||
}
|
||||
const logs = await this.smsLogRepo.findByPhone(user.phone, {
|
||||
limit: +limit,
|
||||
offset: +offset,
|
||||
});
|
||||
const masked = logs.map((log) => ({
|
||||
...log,
|
||||
phone: this.maskPhone(log.phone),
|
||||
}));
|
||||
return {
|
||||
code: 0,
|
||||
data: { items: masked, total: masked.length },
|
||||
message: 'ok',
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 账号解锁 ── */
|
||||
|
||||
@Post('unlock/:userId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '手动解锁用户账号 (清除登录失败计数和锁定时间)' })
|
||||
@ApiResponse({ status: 200, description: '解锁成功' })
|
||||
async unlockUser(@Param('userId') userId: string) {
|
||||
const user = await this.userRepo.findById(userId);
|
||||
if (!user) {
|
||||
return { code: 1, data: null, message: '用户不存在' };
|
||||
}
|
||||
user.loginFailCount = 0;
|
||||
user.lockedUntil = null;
|
||||
await this.userRepo.save(user);
|
||||
return {
|
||||
code: 0,
|
||||
data: null,
|
||||
message: '账号已解锁',
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Private helpers ── */
|
||||
|
||||
private maskPhone(phone: string): string {
|
||||
if (!phone) return '';
|
||||
// +8613812345678 → +86 138****5678
|
||||
const raw = phone.startsWith('+86') ? phone.slice(3) : phone;
|
||||
if (raw.length < 7) return phone;
|
||||
return (phone.startsWith('+86') ? '+86 ' : '') +
|
||||
raw.slice(0, 3) + '****' + raw.slice(-4);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { SmsLogPage } from '@/views/users/SmsLogPage';
|
||||
|
||||
export default function SmsLogs() {
|
||||
return <SmsLogPage />;
|
||||
}
|
||||
|
|
@ -195,6 +195,28 @@ const translations: Record<Locale, Record<string, string>> = {
|
|||
'user_coupon_count': '持券数',
|
||||
'user_total_traded': '交易额',
|
||||
'user_risk_tags': '风险标签',
|
||||
'user_active': '正常',
|
||||
'user_frozen': '已冻结',
|
||||
'user_locked': '已锁定',
|
||||
'user_unlock': '解锁',
|
||||
|
||||
// ── SMS Log ──
|
||||
'nav_users_sms_logs': 'SMS 日志',
|
||||
'sms_log_title': 'SMS 发送日志',
|
||||
'sms_search_placeholder': '输入手机号搜索...',
|
||||
'sms_search_hint': '请输入手机号搜索 SMS 发送记录',
|
||||
'sms_log_phone': '手机号',
|
||||
'sms_log_type': '类型',
|
||||
'sms_log_status': '状态',
|
||||
'sms_log_provider': '通道',
|
||||
'sms_log_time': '发送时间',
|
||||
'sms_type_register': '注册',
|
||||
'sms_type_login': '登录',
|
||||
'sms_type_reset': '重置密码',
|
||||
'sms_type_change_phone': '换绑手机',
|
||||
'sms_status_sent': '已发送',
|
||||
'sms_status_failed': '发送失败',
|
||||
'sms_no_logs': '暂无记录',
|
||||
|
||||
// ── Issuer Management ──
|
||||
'issuer_management_title': '发行方管理',
|
||||
|
|
@ -905,6 +927,28 @@ const translations: Record<Locale, Record<string, string>> = {
|
|||
'user_coupon_count': 'Coupons',
|
||||
'user_total_traded': 'Traded',
|
||||
'user_risk_tags': 'Risk Tags',
|
||||
'user_active': 'Active',
|
||||
'user_frozen': 'Frozen',
|
||||
'user_locked': 'Locked',
|
||||
'user_unlock': 'Unlock',
|
||||
|
||||
// ── SMS Log ──
|
||||
'nav_users_sms_logs': 'SMS Logs',
|
||||
'sms_log_title': 'SMS Delivery Logs',
|
||||
'sms_search_placeholder': 'Search by phone number...',
|
||||
'sms_search_hint': 'Enter a phone number to search SMS delivery records',
|
||||
'sms_log_phone': 'Phone',
|
||||
'sms_log_type': 'Type',
|
||||
'sms_log_status': 'Status',
|
||||
'sms_log_provider': 'Provider',
|
||||
'sms_log_time': 'Sent At',
|
||||
'sms_type_register': 'Register',
|
||||
'sms_type_login': 'Login',
|
||||
'sms_type_reset': 'Reset Password',
|
||||
'sms_type_change_phone': 'Change Phone',
|
||||
'sms_status_sent': 'Sent',
|
||||
'sms_status_failed': 'Failed',
|
||||
'sms_no_logs': 'No records found',
|
||||
|
||||
// ── Issuer Management ──
|
||||
'issuer_management_title': 'Issuer Management',
|
||||
|
|
@ -1615,6 +1659,28 @@ const translations: Record<Locale, Record<string, string>> = {
|
|||
'user_coupon_count': 'クーポン数',
|
||||
'user_total_traded': '取引額',
|
||||
'user_risk_tags': 'リスクタグ',
|
||||
'user_active': '正常',
|
||||
'user_frozen': '凍結中',
|
||||
'user_locked': 'ロック中',
|
||||
'user_unlock': 'ロック解除',
|
||||
|
||||
// ── SMS Log ──
|
||||
'nav_users_sms_logs': 'SMS ログ',
|
||||
'sms_log_title': 'SMS 送信ログ',
|
||||
'sms_search_placeholder': '電話番号で検索...',
|
||||
'sms_search_hint': '電話番号を入力して SMS 送信記録を検索してください',
|
||||
'sms_log_phone': '電話番号',
|
||||
'sms_log_type': 'タイプ',
|
||||
'sms_log_status': 'ステータス',
|
||||
'sms_log_provider': 'プロバイダ',
|
||||
'sms_log_time': '送信日時',
|
||||
'sms_type_register': '登録',
|
||||
'sms_type_login': 'ログイン',
|
||||
'sms_type_reset': 'パスワードリセット',
|
||||
'sms_type_change_phone': '電話番号変更',
|
||||
'sms_status_sent': '送信済み',
|
||||
'sms_status_failed': '送信失敗',
|
||||
'sms_no_logs': '記録なし',
|
||||
|
||||
// ── Issuer Management ──
|
||||
'issuer_management_title': '発行者管理',
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ const navItems: NavItem[] = [
|
|||
children: [
|
||||
{ key: 'users/list', icon: '', label: t('nav_users_list') },
|
||||
{ key: 'users/kyc', icon: '', label: t('nav_users_kyc'), badge: 5 },
|
||||
{ key: 'users/sms-logs', icon: '', label: t('nav_users_sms_logs') },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,168 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { t } from '@/i18n/locales';
|
||||
import { useApi } from '@/lib/use-api';
|
||||
|
||||
/**
|
||||
* SMS 发送日志查看页面
|
||||
*
|
||||
* 管理员可按手机号搜索 SMS 发送记录
|
||||
*/
|
||||
|
||||
interface SmsLogItem {
|
||||
id: string;
|
||||
phone: string;
|
||||
type: string;
|
||||
status: string;
|
||||
provider: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface SmsLogsResponse {
|
||||
items: SmsLogItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
const loadingBox: React.CSSProperties = {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)',
|
||||
};
|
||||
|
||||
export const SmsLogPage: React.FC = () => {
|
||||
const [phone, setPhone] = useState('');
|
||||
const [searchPhone, setSearchPhone] = useState('');
|
||||
const [limit] = useState(20);
|
||||
const [offset] = useState(0);
|
||||
|
||||
const { data, isLoading, error } = useApi<SmsLogsResponse>(
|
||||
searchPhone ? '/api/v1/admin/sms/logs' : null,
|
||||
{ params: { phone: searchPhone, limit, offset } },
|
||||
);
|
||||
|
||||
const logs = data?.items ?? [];
|
||||
|
||||
const handleSearch = () => {
|
||||
setSearchPhone(phone.trim());
|
||||
};
|
||||
|
||||
const typeBadge = (type: string) => {
|
||||
const map: Record<string, { bg: string; color: string; label: string }> = {
|
||||
REGISTER: { bg: 'var(--color-primary)', color: 'white', label: t('sms_type_register') },
|
||||
LOGIN: { bg: 'var(--color-info)', color: 'white', label: t('sms_type_login') },
|
||||
RESET_PASSWORD: { bg: 'var(--color-warning)', color: 'white', label: t('sms_type_reset') },
|
||||
CHANGE_PHONE: { bg: 'var(--color-success)', color: 'white', label: t('sms_type_change_phone') },
|
||||
};
|
||||
const m = map[type] ?? { bg: 'var(--color-gray-400)', color: 'white', label: type };
|
||||
return (
|
||||
<span style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
background: `${m.bg}20`,
|
||||
color: m.bg,
|
||||
font: 'var(--text-label-sm)',
|
||||
fontWeight: 600,
|
||||
}}>{m.label}</span>
|
||||
);
|
||||
};
|
||||
|
||||
const statusBadge = (status: string) => {
|
||||
const isSuccess = status === 'SUCCESS' || status === 'SENT';
|
||||
return (
|
||||
<span style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
background: isSuccess ? 'var(--color-success-light)' : 'var(--color-error-light)',
|
||||
color: isSuccess ? 'var(--color-success)' : 'var(--color-error)',
|
||||
font: 'var(--text-label-sm)',
|
||||
fontWeight: 600,
|
||||
}}>{isSuccess ? t('sms_status_sent') : t('sms_status_failed')}</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 style={{ font: 'var(--text-h1)', marginBottom: 24 }}>{t('sms_log_title')}</h1>
|
||||
|
||||
{/* Search */}
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 20 }}>
|
||||
<input
|
||||
placeholder={t('sms_search_placeholder')}
|
||||
value={phone}
|
||||
onChange={e => setPhone(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
style={{
|
||||
flex: 1,
|
||||
maxWidth: 360,
|
||||
height: 40,
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
padding: '0 16px',
|
||||
font: 'var(--text-body)',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
style={{
|
||||
padding: '8px 20px',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--color-primary)',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
font: 'var(--text-body)',
|
||||
}}
|
||||
>{t('search')}</button>
|
||||
</div>
|
||||
|
||||
{/* Hint when no search */}
|
||||
{!searchPhone && (
|
||||
<div style={loadingBox}>{t('sms_search_hint')}</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{searchPhone && (error ? (
|
||||
<div style={loadingBox}>Error: {error.message}</div>
|
||||
) : isLoading ? (
|
||||
<div style={loadingBox}>Loading...</div>
|
||||
) : (
|
||||
<div style={{
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--color-border-light)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--color-gray-50)', borderBottom: '1px solid var(--color-border)' }}>
|
||||
{[t('sms_log_phone'), t('sms_log_type'), t('sms_log_status'), t('sms_log_provider'), t('sms_log_time')].map(h => (
|
||||
<th key={h} style={{
|
||||
font: 'var(--text-label-sm)',
|
||||
color: 'var(--color-text-tertiary)',
|
||||
padding: '12px 14px',
|
||||
textAlign: 'left',
|
||||
}}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ ...loadingBox, padding: '24px 14px' }}>{t('sms_no_logs')}</td>
|
||||
</tr>
|
||||
) : logs.map(log => (
|
||||
<tr key={log.id} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
||||
<td style={{ font: 'var(--text-body-sm)', padding: '12px 14px' }}>{log.phone}</td>
|
||||
<td style={{ padding: '12px 14px' }}>{typeBadge(log.type)}</td>
|
||||
<td style={{ padding: '12px 14px' }}>{statusBadge(log.status)}</td>
|
||||
<td style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-tertiary)', padding: '12px 14px' }}>{log.provider}</td>
|
||||
<td style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-tertiary)', padding: '12px 14px' }}>{log.createdAt}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -19,6 +19,9 @@ interface User {
|
|||
totalTraded: number;
|
||||
riskTags: string[];
|
||||
createdAt: string;
|
||||
status?: 'ACTIVE' | 'FROZEN';
|
||||
loginFailCount?: number;
|
||||
lockedUntil?: string | null;
|
||||
}
|
||||
|
||||
interface UsersResponse {
|
||||
|
|
@ -44,9 +47,31 @@ export const UserManagementPage: React.FC = () => {
|
|||
const freezeMutation = useApiMutation<void>('POST', '', {
|
||||
invalidateKeys: ['/api/v1/admin/users'],
|
||||
});
|
||||
const unlockMutation = useApiMutation<void>('POST', '', {
|
||||
invalidateKeys: ['/api/v1/admin/users'],
|
||||
});
|
||||
|
||||
const users = data?.items ?? [];
|
||||
|
||||
const maskPhone = (phone: string) => {
|
||||
if (!phone) return '-';
|
||||
const raw = phone.startsWith('+86') ? phone.slice(3) : phone;
|
||||
if (raw.length < 7) return phone;
|
||||
return (phone.startsWith('+86') ? '+86 ' : '') + raw.slice(0, 3) + '****' + raw.slice(-4);
|
||||
};
|
||||
|
||||
const isLocked = (user: User) => user.lockedUntil && new Date(user.lockedUntil) > new Date();
|
||||
|
||||
const statusBadge = (user: User) => {
|
||||
if (user.status === 'FROZEN') {
|
||||
return <span style={{ padding: '2px 8px', borderRadius: 'var(--radius-full)', background: 'var(--color-error-light)', color: 'var(--color-error)', font: 'var(--text-label-sm)', fontWeight: 600 }}>{t('user_frozen')}</span>;
|
||||
}
|
||||
if (isLocked(user)) {
|
||||
return <span style={{ padding: '2px 8px', borderRadius: 'var(--radius-full)', background: 'var(--color-warning-light)', color: 'var(--color-warning)', font: 'var(--text-label-sm)', fontWeight: 600 }}>{t('user_locked')}</span>;
|
||||
}
|
||||
return <span style={{ padding: '2px 8px', borderRadius: 'var(--radius-full)', background: 'var(--color-success-light)', color: 'var(--color-success)', font: 'var(--text-label-sm)', fontWeight: 600 }}>{t('user_active')}</span>;
|
||||
};
|
||||
|
||||
const kycBadge = (level: number) => {
|
||||
const colors = ['var(--color-gray-400)', 'var(--color-info)', 'var(--color-primary)', 'var(--color-success)'];
|
||||
return (
|
||||
|
|
@ -119,7 +144,7 @@ export const UserManagementPage: React.FC = () => {
|
|||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--color-gray-50)', borderBottom: '1px solid var(--color-border)' }}>
|
||||
{[t('user_id'), t('user_phone'), t('user_email'), t('user_kyc_level'), t('user_coupon_count'), t('user_total_traded'), t('user_risk_tags'), t('user_created_at'), t('actions')].map(h => (
|
||||
{[t('user_id'), t('user_phone'), t('user_email'), t('user_status'), t('user_kyc_level'), t('user_coupon_count'), t('user_total_traded'), t('user_risk_tags'), t('user_created_at'), t('actions')].map(h => (
|
||||
<th key={h} style={{
|
||||
font: 'var(--text-label-sm)',
|
||||
color: 'var(--color-text-tertiary)',
|
||||
|
|
@ -133,8 +158,9 @@ export const UserManagementPage: React.FC = () => {
|
|||
{users.map(user => (
|
||||
<tr key={user.id} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
||||
<td style={{ font: 'var(--text-body-sm)', fontFamily: 'var(--font-family-mono)', padding: '12px 14px', color: 'var(--color-text-tertiary)' }}>{user.id}</td>
|
||||
<td style={{ font: 'var(--text-body-sm)', padding: '12px 14px' }}>{user.phone}</td>
|
||||
<td style={{ font: 'var(--text-body-sm)', padding: '12px 14px' }}>{maskPhone(user.phone)}</td>
|
||||
<td style={{ font: 'var(--text-body-sm)', padding: '12px 14px' }}>{user.email}</td>
|
||||
<td style={{ padding: '12px 14px' }}>{statusBadge(user)}</td>
|
||||
<td style={{ padding: '12px 14px' }}>{kycBadge(user.kycLevel)}</td>
|
||||
<td style={{ font: 'var(--text-body-sm)', padding: '12px 14px' }}>{user.couponCount}</td>
|
||||
<td style={{ font: 'var(--text-label-sm)', color: 'var(--color-primary)', padding: '12px 14px' }}>{formatCurrency(user.totalTraded)}</td>
|
||||
|
|
@ -154,7 +180,7 @@ export const UserManagementPage: React.FC = () => {
|
|||
}
|
||||
</td>
|
||||
<td style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-tertiary)', padding: '12px 14px' }}>{user.createdAt}</td>
|
||||
<td style={{ padding: '12px 14px' }}>
|
||||
<td style={{ padding: '12px 14px', whiteSpace: 'nowrap' }}>
|
||||
<button style={{
|
||||
padding: '4px 12px',
|
||||
border: '1px solid var(--color-border)',
|
||||
|
|
@ -164,18 +190,49 @@ export const UserManagementPage: React.FC = () => {
|
|||
font: 'var(--text-caption)',
|
||||
color: 'var(--color-primary)',
|
||||
}}>{t('details')}</button>
|
||||
<button
|
||||
onClick={() => freezeMutation.mutate({ userId: user.id })}
|
||||
style={{
|
||||
marginLeft: 6,
|
||||
padding: '4px 12px',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--color-error)',
|
||||
cursor: 'pointer',
|
||||
font: 'var(--text-caption)',
|
||||
color: 'white',
|
||||
}}>{t('user_freeze')}</button>
|
||||
{user.status === 'FROZEN' ? (
|
||||
<button
|
||||
onClick={() => freezeMutation.mutate({ userId: user.id })}
|
||||
style={{
|
||||
marginLeft: 6,
|
||||
padding: '4px 12px',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--color-success)',
|
||||
cursor: 'pointer',
|
||||
font: 'var(--text-caption)',
|
||||
color: 'white',
|
||||
}}>{t('user_unfreeze')}</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => freezeMutation.mutate({ userId: user.id })}
|
||||
style={{
|
||||
marginLeft: 6,
|
||||
padding: '4px 12px',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--color-error)',
|
||||
cursor: 'pointer',
|
||||
font: 'var(--text-caption)',
|
||||
color: 'white',
|
||||
}}>{t('user_freeze')}</button>
|
||||
)}
|
||||
{isLocked(user) && (
|
||||
<button
|
||||
onClick={() => unlockMutation.mutate(undefined, {
|
||||
onSuccess: () => {},
|
||||
})}
|
||||
style={{
|
||||
marginLeft: 6,
|
||||
padding: '4px 12px',
|
||||
border: '1px solid var(--color-warning)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'none',
|
||||
cursor: 'pointer',
|
||||
font: 'var(--text-caption)',
|
||||
color: 'var(--color-warning)',
|
||||
}}>{t('user_unlock')}</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
|
|||
Loading…
Reference in New Issue