feat(admin): Control Center redesign, Explore dual-view и полный RU/EN i18n
Refs EventHub/EventHubFrontAdmin#32
This commit is contained in:
+304
-180
@@ -1,202 +1,326 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, Descriptions, Spin, Button, Form, Input, Select, Tag, message, Space, Avatar } from 'antd';
|
||||
import { UserOutlined } from '@ant-design/icons';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useUpdateProfile } from '../../hooks/useProfile';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import dayjs from 'dayjs';
|
||||
import { Pencil } from 'lucide-react';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useUpdateProfile } from '@/hooks/useProfile';
|
||||
import { formatStatusLabel } from '@/utils/statusLabels';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { notify } from '@/lib/notify';
|
||||
|
||||
type ProfileFormValues = {
|
||||
nickname?: string;
|
||||
timezone?: string;
|
||||
language?: string;
|
||||
phone?: string;
|
||||
avatar_url?: string;
|
||||
preferences?: string;
|
||||
};
|
||||
|
||||
const roleBadgeVariant = (role: string) => {
|
||||
if (role === 'superadmin') return 'destructive' as const;
|
||||
if (role === 'admin') return 'default' as const;
|
||||
return 'secondary' as const;
|
||||
};
|
||||
|
||||
const isBadValue = (val: unknown) =>
|
||||
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
|
||||
|
||||
const ProfilePage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuthStore();
|
||||
const updateProfile = useUpdateProfile();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// Редактирование своего профиля доступно всем ролям (PUT /v1/admin/me)
|
||||
const handleSave = () => {
|
||||
if (!user) return;
|
||||
form.validateFields().then((values) => {
|
||||
const allowed = ['nickname', 'timezone', 'language', 'phone', 'avatar_url', 'preferences'] as const;
|
||||
const payload = Object.fromEntries(
|
||||
Object.entries(values).filter(([key, v]) =>
|
||||
(allowed as readonly string[]).includes(key) && v !== '' && v !== undefined && v !== null
|
||||
)
|
||||
);
|
||||
if (payload.preferences) {
|
||||
try {
|
||||
payload.preferences = JSON.parse(payload.preferences as string);
|
||||
} catch {
|
||||
message.error('Поле «Настройки» должно быть валидным JSON');
|
||||
return;
|
||||
}
|
||||
}
|
||||
updateProfile.mutate(
|
||||
payload,
|
||||
{
|
||||
onSuccess: () => {
|
||||
setEditing(false);
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
const profileSchema = useMemo(
|
||||
() =>
|
||||
z.object({
|
||||
nickname: z.string().optional(),
|
||||
timezone: z.string().optional(),
|
||||
language: z.string().optional(),
|
||||
phone: z.string().optional(),
|
||||
avatar_url: z.string().optional(),
|
||||
preferences: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(v) =>
|
||||
!v ||
|
||||
v.trim() === '' ||
|
||||
(() => {
|
||||
try {
|
||||
JSON.parse(v);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})(),
|
||||
{ message: t('profile.invalidJson') }
|
||||
),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const startEditing = () => {
|
||||
if (!user) return;
|
||||
const clean = (val: any) => (val === '-' || val === 'undefined' ? undefined : val);
|
||||
form.setFieldsValue({
|
||||
nickname: clean(user.nickname),
|
||||
email: clean(user.email),
|
||||
timezone: clean(user.timezone),
|
||||
language: clean(user.language),
|
||||
phone: clean(user.phone),
|
||||
avatar_url: clean(user.avatar_url),
|
||||
preferences: user.preferences && !isBadValue(user.preferences) ? JSON.stringify(user.preferences) : '',
|
||||
});
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const isBadValue = (val: any) =>
|
||||
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
|
||||
const form = useForm<ProfileFormValues>({
|
||||
resolver: zodResolver(profileSchema),
|
||||
defaultValues: {
|
||||
nickname: '',
|
||||
timezone: '',
|
||||
language: '',
|
||||
phone: '',
|
||||
avatar_url: '',
|
||||
preferences: '',
|
||||
},
|
||||
});
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (isBadValue(dateStr)) return '-';
|
||||
if (isBadValue(dateStr)) return t('common.emDash');
|
||||
const d = dayjs(dateStr);
|
||||
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
|
||||
};
|
||||
|
||||
if (!user) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
|
||||
const startEditing = () => {
|
||||
if (!user) return;
|
||||
const clean = (val: unknown) => (isBadValue(val) ? '' : String(val));
|
||||
form.reset({
|
||||
nickname: clean(user.nickname),
|
||||
timezone: clean(user.timezone),
|
||||
language: clean(user.language),
|
||||
phone: clean(user.phone),
|
||||
avatar_url: clean(user.avatar_url),
|
||||
preferences:
|
||||
user.preferences && !isBadValue(user.preferences)
|
||||
? JSON.stringify(user.preferences, null, 2)
|
||||
: '',
|
||||
});
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const onSave = (values: ProfileFormValues) => {
|
||||
if (!user) return;
|
||||
const allowed = ['nickname', 'timezone', 'language', 'phone', 'avatar_url', 'preferences'] as const;
|
||||
const payload: Record<string, unknown> = {};
|
||||
for (const key of allowed) {
|
||||
const v = values[key];
|
||||
if (v !== '' && v !== undefined && v !== null) {
|
||||
if (key === 'preferences') {
|
||||
try {
|
||||
payload.preferences = JSON.parse(v);
|
||||
} catch {
|
||||
notify.error(t('profile.preferencesInvalidJson'));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
payload[key] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
updateProfile.mutate(payload, { onSuccess: () => setEditing(false) });
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-8 w-40" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const displayName = !isBadValue(user.nickname) ? user.nickname! : user.email;
|
||||
const avatarSrc = !isBadValue(user.avatar_url) ? user.avatar_url! : undefined;
|
||||
const emDash = t('common.emDash');
|
||||
|
||||
return (
|
||||
<Card title="Мой профиль">
|
||||
{!editing ? (
|
||||
<>
|
||||
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<Avatar
|
||||
size={64}
|
||||
icon={<UserOutlined />}
|
||||
src={!isBadValue(user.avatar_url) ? user.avatar_url! : undefined}
|
||||
/>
|
||||
<div>
|
||||
<h3>{!isBadValue(user.nickname) ? user.nickname : user.email}</h3>
|
||||
<Tag
|
||||
color={
|
||||
user.role === 'superadmin'
|
||||
? 'red'
|
||||
: user.role === 'admin'
|
||||
? 'blue'
|
||||
: user.role === 'moderator'
|
||||
? 'purple'
|
||||
: 'cyan'
|
||||
}
|
||||
>
|
||||
{user.role}
|
||||
</Tag>
|
||||
<Card className="max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('profile.title')}</CardTitle>
|
||||
<CardDescription>{t('profile.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{!editing ? (
|
||||
<>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-16 w-16">
|
||||
<AvatarImage src={avatarSrc} alt={displayName} />
|
||||
<AvatarFallback>{displayName.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{displayName}</h3>
|
||||
<Badge variant={roleBadgeVariant(user.role)} className="mt-1">
|
||||
{user.role}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Descriptions bordered column={1}>
|
||||
<Descriptions.Item label="Email">{user.email}</Descriptions.Item>
|
||||
<Descriptions.Item label="Ник">{isBadValue(user.nickname) ? '-' : user.nickname}</Descriptions.Item>
|
||||
<Descriptions.Item label="Роль">
|
||||
<Tag
|
||||
color={
|
||||
user.role === 'superadmin'
|
||||
? 'red'
|
||||
: user.role === 'admin'
|
||||
? 'blue'
|
||||
: user.role === 'moderator'
|
||||
? 'purple'
|
||||
: 'cyan'
|
||||
}
|
||||
>
|
||||
{user.role}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Статус">
|
||||
<Tag color={user.status === 'active' ? 'green' : 'red'}>{user.status}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Часовой пояс">{isBadValue(user.timezone) ? '-' : user.timezone}</Descriptions.Item>
|
||||
<Descriptions.Item label="Язык">{isBadValue(user.language) ? '-' : user.language}</Descriptions.Item>
|
||||
<Descriptions.Item label="Телефон">{isBadValue(user.phone) ? '-' : user.phone}</Descriptions.Item>
|
||||
<Descriptions.Item label="Аватар URL">
|
||||
{isBadValue(user.avatar_url) ? '-' : <a href={user.avatar_url || undefined} target="_blank" rel="noreferrer">{user.avatar_url}</a>}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Настройки">
|
||||
{isBadValue(user.preferences) ? '-' : <pre>{JSON.stringify(user.preferences, null, 2)}</pre>}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Последний вход">{formatDate(user.last_login)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Button type="primary" style={{ marginTop: 16 }} onClick={startEditing}>
|
||||
Редактировать
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Form form={form} layout="vertical" onFinish={handleSave}>
|
||||
<Form.Item label="Email" name="email">
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
<Form.Item label="Ник" name="nickname">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="Роль" name="role">
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
<Form.Item label="Статус" name="status">
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
<Form.Item name="timezone" label="Часовой пояс">
|
||||
<Select placeholder="Выберите пояс" allowClear>
|
||||
<Select.Option value="UTC">UTC</Select.Option>
|
||||
<Select.Option value="Europe/Moscow">Europe/Moscow (Москва)</Select.Option>
|
||||
<Select.Option value="Europe/London">Europe/London (Лондон)</Select.Option>
|
||||
<Select.Option value="Europe/Berlin">Europe/Berlin (Берлин)</Select.Option>
|
||||
<Select.Option value="America/New_York">America/New_York (Нью-Йорк)</Select.Option>
|
||||
<Select.Option value="Asia/Tokyo">Asia/Tokyo (Токио)</Select.Option>
|
||||
<Select.Option value="Asia/Shanghai">Asia/Shanghai (Шанхай)</Select.Option>
|
||||
<Select.Option value="Australia/Sydney">Australia/Sydney (Сидней)</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="language" label="Язык">
|
||||
<Select placeholder="Выберите язык" allowClear>
|
||||
<Select.Option value="ru">Русский</Select.Option>
|
||||
<Select.Option value="en">English</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="Телефон">
|
||||
<Input placeholder="+7 (999) 123-45-67" />
|
||||
</Form.Item>
|
||||
<Form.Item name="avatar_url" label="URL аватара">
|
||||
<Input placeholder="https://example.com/avatar.jpg" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="preferences"
|
||||
label="Настройки (JSON)"
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (value && value.trim().length > 0) {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
} catch {
|
||||
return Promise.reject(new Error('Невалидный JSON'));
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.TextArea rows={4} placeholder='{"key": "value"}' />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={updateProfile.isPending}>
|
||||
Сохранить
|
||||
|
||||
<Separator />
|
||||
|
||||
<dl className="grid gap-3 text-sm">
|
||||
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||
<dt className="text-muted-foreground">{t('common.email')}</dt>
|
||||
<dd>{user.email}</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||
<dt className="text-muted-foreground">{t('common.nickname')}</dt>
|
||||
<dd>{isBadValue(user.nickname) ? emDash : user.nickname}</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||
<dt className="text-muted-foreground">{t('common.status')}</dt>
|
||||
<dd>
|
||||
<Badge variant={user.status === 'active' ? 'default' : 'destructive'}>
|
||||
{formatStatusLabel(user.status)}
|
||||
</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||
<dt className="text-muted-foreground">{t('common.timezone')}</dt>
|
||||
<dd>{isBadValue(user.timezone) ? emDash : user.timezone}</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||
<dt className="text-muted-foreground">{t('common.language')}</dt>
|
||||
<dd>{isBadValue(user.language) ? emDash : user.language}</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||
<dt className="text-muted-foreground">{t('common.phone')}</dt>
|
||||
<dd>{isBadValue(user.phone) ? emDash : user.phone}</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||
<dt className="text-muted-foreground">{t('profile.avatarUrl')}</dt>
|
||||
<dd>
|
||||
{isBadValue(user.avatar_url) ? (
|
||||
emDash
|
||||
) : (
|
||||
<a href={user.avatar_url!} target="_blank" rel="noreferrer" className="text-primary hover:underline">
|
||||
{user.avatar_url}
|
||||
</a>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||
<dt className="text-muted-foreground">{t('profile.preferences')}</dt>
|
||||
<dd>
|
||||
{isBadValue(user.preferences) ? (
|
||||
emDash
|
||||
) : (
|
||||
<pre className="overflow-auto rounded-md bg-muted p-2 text-xs">
|
||||
{JSON.stringify(user.preferences, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||
<dt className="text-muted-foreground">{t('common.lastLogin')}</dt>
|
||||
<dd>{formatDate(user.last_login)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<Button onClick={startEditing}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
{t('profile.edit')}
|
||||
</Button>
|
||||
<Button onClick={() => setEditing(false)}>Отмена</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<form className="space-y-4" onSubmit={form.handleSubmit(onSave)}>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('common.email')}</Label>
|
||||
<Input value={user.email} disabled />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nickname">{t('common.nickname')}</Label>
|
||||
<Input id="nickname" {...form.register('nickname')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('common.role')}</Label>
|
||||
<Input value={user.role} disabled />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('common.timezone')}</Label>
|
||||
<Controller
|
||||
name="timezone"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value || undefined} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('common.selectTimezone')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="UTC">UTC</SelectItem>
|
||||
<SelectItem value="Europe/Moscow">Europe/Moscow</SelectItem>
|
||||
<SelectItem value="Europe/London">Europe/London</SelectItem>
|
||||
<SelectItem value="Europe/Berlin">Europe/Berlin</SelectItem>
|
||||
<SelectItem value="America/New_York">America/New_York</SelectItem>
|
||||
<SelectItem value="Asia/Tokyo">Asia/Tokyo</SelectItem>
|
||||
<SelectItem value="Asia/Shanghai">Asia/Shanghai</SelectItem>
|
||||
<SelectItem value="Australia/Sydney">Australia/Sydney</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('common.language')}</Label>
|
||||
<Controller
|
||||
name="language"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value || undefined} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('common.selectLanguage')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ru">{t('common.langRu')}</SelectItem>
|
||||
<SelectItem value="en">{t('common.langEn')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">{t('common.phone')}</Label>
|
||||
<Input id="phone" placeholder="+7 (999) 123-45-67" {...form.register('phone')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="avatar_url">{t('profile.avatarUrl')}</Label>
|
||||
<Input id="avatar_url" placeholder="https://example.com/avatar.jpg" {...form.register('avatar_url')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="preferences">{t('profile.preferencesJson')}</Label>
|
||||
<Textarea id="preferences" rows={4} placeholder='{"key": "value"}' {...form.register('preferences')} />
|
||||
{form.formState.errors.preferences && (
|
||||
<p className="text-sm text-destructive">{form.formState.errors.preferences.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={updateProfile.isPending}>
|
||||
{updateProfile.isPending ? t('common.saving') : t('common.save')}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setEditing(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilePage;
|
||||
export default ProfilePage;
|
||||
|
||||
Reference in New Issue
Block a user