feat(admin): Control Center redesign, Explore dual-view и полный RU/EN i18n
CI / test (push) Failing after 3m9s
CI / push-image (push) Has been skipped
CI / ci-done (push) Failing after 0s

Refs EventHub/EventHubFrontAdmin#32
This commit is contained in:
2026-07-14 17:58:33 +03:00
parent de379e28bf
commit fd2c1f10ad
95 changed files with 12699 additions and 4410 deletions
+304 -180
View File
@@ -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;