mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
feat(devices): inline rename UI for connected HWID devices
Pairs with the bot commit that adds the user_device_aliases table. Surfaces the new local_name field on the existing devices list in both the user-facing subscription page and the admin user-detail page, with the same inline-edit pattern in both places. User-side (src/pages/Subscription.tsx): - pencil button next to each device row toggles edit mode - input is focused automatically, capped at 64 chars (matches the backend ALIAS_MAX_LENGTH and DB column width) - Enter saves, Escape cancels, empty input clears the alias - display priority: local_name → device_model → platform - works identically in classic / single-tariff / multi-tariff — subscriptionId is forwarded as a query param like every other device-management endpoint already does Admin-side (src/pages/AdminUserDetail.tsx): - same pencil + inline input pattern, admin acts on behalf of the user. notify.success on save, loadDevices() refresh. API (src/api/subscription.ts + src/api/adminUsers.ts): - new renameDevice(hwid, name, subscriptionId?) on subscriptionApi - new renameUserDevice(userId, hwid, name) on adminUsersApi - existing getDevices/getUserDevices contracts widened with local_name?: string | null on the returned device shape Locales (src/locales/ru.json): - subscription.renameDevice / .renameDeviceSave / .renameDeviceCancel / .renameDevicePlaceholder / .deviceRenamed - admin.users.detail.devices.rename / .renameSave / .renamed
This commit is contained in:
@@ -719,7 +719,13 @@ export const adminUsersApi = {
|
|||||||
userId: number,
|
userId: number,
|
||||||
subscriptionId?: number,
|
subscriptionId?: number,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
devices: { hwid: string; platform: string; device_model: string; created_at: string | null }[];
|
devices: {
|
||||||
|
hwid: string;
|
||||||
|
platform: string;
|
||||||
|
device_model: string;
|
||||||
|
created_at: string | null;
|
||||||
|
local_name?: string | null;
|
||||||
|
}[];
|
||||||
total: number;
|
total: number;
|
||||||
device_limit: number;
|
device_limit: number;
|
||||||
}> => {
|
}> => {
|
||||||
@@ -729,6 +735,19 @@ export const adminUsersApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Set / clear local device alias on behalf of the user (admin override).
|
||||||
|
renameUserDevice: async (
|
||||||
|
userId: number,
|
||||||
|
hwid: string,
|
||||||
|
name: string | null,
|
||||||
|
): Promise<{ hwid: string; local_name: string | null }> => {
|
||||||
|
const response = await apiClient.patch(
|
||||||
|
`/cabinet/admin/users/${userId}/devices/${encodeURIComponent(hwid)}/name`,
|
||||||
|
{ name },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
// Delete single device
|
// Delete single device
|
||||||
deleteUserDevice: async (
|
deleteUserDevice: async (
|
||||||
userId: number,
|
userId: number,
|
||||||
|
|||||||
@@ -264,6 +264,7 @@ export const subscriptionApi = {
|
|||||||
platform: string;
|
platform: string;
|
||||||
device_model: string;
|
device_model: string;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
|
local_name?: string | null;
|
||||||
}>;
|
}>;
|
||||||
total: number;
|
total: number;
|
||||||
device_limit: number;
|
device_limit: number;
|
||||||
@@ -275,6 +276,27 @@ export const subscriptionApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a user-local alias for a device. Pass an empty/null `name` to
|
||||||
|
* clear the alias. Returns the final `local_name` after server-side
|
||||||
|
* normalization (trimmed + length-capped).
|
||||||
|
*
|
||||||
|
* Scope is per-(user, hwid), so the same alias appears across all of
|
||||||
|
* the user's subscriptions in multi-tariff mode.
|
||||||
|
*/
|
||||||
|
renameDevice: async (
|
||||||
|
hwid: string,
|
||||||
|
name: string | null,
|
||||||
|
subscriptionId?: number,
|
||||||
|
): Promise<{ hwid: string; local_name: string | null }> => {
|
||||||
|
const response = await apiClient.patch(
|
||||||
|
`/cabinet/subscription/devices/${encodeURIComponent(hwid)}/name`,
|
||||||
|
{ name },
|
||||||
|
withSubId(subscriptionId),
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
deleteDevice: async (
|
deleteDevice: async (
|
||||||
hwid: string,
|
hwid: string,
|
||||||
subscriptionId?: number,
|
subscriptionId?: number,
|
||||||
|
|||||||
@@ -492,6 +492,11 @@
|
|||||||
"confirmDeleteAllDevices": "Удалить все устройства?",
|
"confirmDeleteAllDevices": "Удалить все устройства?",
|
||||||
"deviceDeleted": "Устройство удалено",
|
"deviceDeleted": "Устройство удалено",
|
||||||
"allDevicesDeleted": "Все устройства удалены",
|
"allDevicesDeleted": "Все устройства удалены",
|
||||||
|
"renameDevice": "Переименовать",
|
||||||
|
"renameDeviceSave": "Сохранить",
|
||||||
|
"renameDeviceCancel": "Отмена",
|
||||||
|
"renameDevicePlaceholder": "Имя устройства",
|
||||||
|
"deviceRenamed": "Имя устройства обновлено",
|
||||||
"platform": "Платформа",
|
"platform": "Платформа",
|
||||||
"model": "Модель",
|
"model": "Модель",
|
||||||
"connectedAt": "Подключено",
|
"connectedAt": "Подключено",
|
||||||
@@ -3581,7 +3586,10 @@
|
|||||||
"none": "Нет подключённых устройств",
|
"none": "Нет подключённых устройств",
|
||||||
"resetAll": "Сбросить все",
|
"resetAll": "Сбросить все",
|
||||||
"deleted": "Устройство удалено",
|
"deleted": "Устройство удалено",
|
||||||
"allDeleted": "Все устройства сброшены"
|
"allDeleted": "Все устройства сброшены",
|
||||||
|
"rename": "Переименовать",
|
||||||
|
"renameSave": "Сохранить",
|
||||||
|
"renamed": "Имя устройства обновлено"
|
||||||
},
|
},
|
||||||
"referral": {
|
"referral": {
|
||||||
"title": "Реферальная программа",
|
"title": "Реферальная программа",
|
||||||
|
|||||||
@@ -338,11 +338,20 @@ export default function AdminUserDetail() {
|
|||||||
|
|
||||||
// Devices
|
// Devices
|
||||||
const [devices, setDevices] = useState<
|
const [devices, setDevices] = useState<
|
||||||
{ hwid: string; platform: string; device_model: string; created_at: string | null }[]
|
{
|
||||||
|
hwid: string;
|
||||||
|
platform: string;
|
||||||
|
device_model: string;
|
||||||
|
created_at: string | null;
|
||||||
|
local_name?: string | null;
|
||||||
|
}[]
|
||||||
>([]);
|
>([]);
|
||||||
const [devicesTotal, setDevicesTotal] = useState(0);
|
const [devicesTotal, setDevicesTotal] = useState(0);
|
||||||
const [deviceLimit, setDeviceLimit] = useState(0);
|
const [deviceLimit, setDeviceLimit] = useState(0);
|
||||||
const [devicesLoading, setDevicesLoading] = useState(false);
|
const [devicesLoading, setDevicesLoading] = useState(false);
|
||||||
|
const [editingDeviceHwid, setEditingDeviceHwid] = useState<string | null>(null);
|
||||||
|
const [editingDeviceName, setEditingDeviceName] = useState('');
|
||||||
|
const [renameSaving, setRenameSaving] = useState(false);
|
||||||
|
|
||||||
// Gifts
|
// Gifts
|
||||||
const [giftsData, setGiftsData] = useState<AdminUserGiftsResponse | null>(null);
|
const [giftsData, setGiftsData] = useState<AdminUserGiftsResponse | null>(null);
|
||||||
@@ -760,6 +769,25 @@ export default function AdminUserDetail() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Admin renames a device on behalf of the user. Empty/whitespace input
|
||||||
|
// clears the alias and falls back to the platform/model default.
|
||||||
|
const handleRenameDevice = async (hwid: string) => {
|
||||||
|
if (!userId) return;
|
||||||
|
setRenameSaving(true);
|
||||||
|
try {
|
||||||
|
const trimmed = editingDeviceName.trim();
|
||||||
|
await adminUsersApi.renameUserDevice(userId, hwid, trimmed || null);
|
||||||
|
notify.success(t('admin.users.detail.devices.renamed', 'Имя устройства обновлено'));
|
||||||
|
setEditingDeviceHwid(null);
|
||||||
|
setEditingDeviceName('');
|
||||||
|
await loadDevices();
|
||||||
|
} catch {
|
||||||
|
notify.error(t('admin.users.userActions.error'), t('common.error'));
|
||||||
|
} finally {
|
||||||
|
setRenameSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleResetDevices = async () => {
|
const handleResetDevices = async () => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
@@ -2427,44 +2455,135 @@ export default function AdminUserDetail() {
|
|||||||
</div>
|
</div>
|
||||||
) : devices.length > 0 ? (
|
) : devices.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{devices.map((device) => (
|
{devices.map((device) => {
|
||||||
<div
|
const isEditing = editingDeviceHwid === device.hwid;
|
||||||
key={device.hwid}
|
// Display priority: alias \u2192 model \u2192 platform \u2192 hwid prefix.
|
||||||
className="flex items-center justify-between rounded-lg bg-dark-700/50 px-3 py-2"
|
const displayName =
|
||||||
>
|
(device.local_name && device.local_name.trim()) ||
|
||||||
<div className="min-w-0 flex-1">
|
device.platform ||
|
||||||
<div className="truncate text-xs font-medium text-dark-200">
|
device.device_model ||
|
||||||
{device.platform || device.device_model || device.hwid.slice(0, 12)}
|
device.hwid.slice(0, 12);
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-[10px] text-dark-500">
|
return (
|
||||||
{device.device_model && device.platform && (
|
<div
|
||||||
<span>{device.device_model}</span>
|
key={device.hwid}
|
||||||
|
className="flex items-center justify-between rounded-lg bg-dark-700/50 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autoFocus
|
||||||
|
value={editingDeviceName}
|
||||||
|
maxLength={64}
|
||||||
|
placeholder={
|
||||||
|
device.platform ||
|
||||||
|
device.device_model ||
|
||||||
|
device.hwid.slice(0, 12)
|
||||||
|
}
|
||||||
|
onChange={(e) => setEditingDeviceName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleRenameDevice(device.hwid);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
setEditingDeviceHwid(null);
|
||||||
|
setEditingDeviceName('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full rounded-md bg-dark-900/70 px-2 py-1 text-xs font-medium text-dark-50 outline-none ring-1 ring-dark-600/60 focus:ring-accent-500/50"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="truncate text-xs font-medium text-dark-200">
|
||||||
|
{displayName}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="font-mono">{device.hwid.slice(0, 8)}...</span>
|
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-dark-500">
|
||||||
{device.created_at && (
|
{device.device_model && device.platform && (
|
||||||
<span>
|
<span>{device.device_model}</span>
|
||||||
{new Date(device.created_at).toLocaleDateString(locale)}
|
)}
|
||||||
</span>
|
<span className="font-mono">{device.hwid.slice(0, 8)}...</span>
|
||||||
|
{device.created_at && (
|
||||||
|
<span>
|
||||||
|
{new Date(device.created_at).toLocaleDateString(locale)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-2 flex shrink-0 items-center gap-1">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRenameDevice(device.hwid)}
|
||||||
|
disabled={renameSaving}
|
||||||
|
className="rounded-lg px-2 py-1 text-xs text-success-400 transition-all hover:bg-success-500/15 disabled:opacity-50"
|
||||||
|
title={t(
|
||||||
|
'admin.users.detail.devices.renameSave',
|
||||||
|
t(
|
||||||
|
'common.save',
|
||||||
|
'\u0421\u043E\u0445\u0440\u0430\u043D\u0438\u0442\u044C',
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
\u2713
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingDeviceHwid(null);
|
||||||
|
setEditingDeviceName('');
|
||||||
|
}}
|
||||||
|
disabled={renameSaving}
|
||||||
|
className="rounded-lg px-2 py-1 text-xs text-dark-500 transition-all hover:bg-dark-700 disabled:opacity-50"
|
||||||
|
title={t(
|
||||||
|
'common.cancel',
|
||||||
|
'\u041E\u0442\u043C\u0435\u043D\u0430',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
\u2715
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingDeviceHwid(device.hwid);
|
||||||
|
setEditingDeviceName(device.local_name || '');
|
||||||
|
}}
|
||||||
|
className="rounded-lg px-2 py-1 text-xs text-dark-500 transition-all hover:bg-accent-500/15 hover:text-accent-400"
|
||||||
|
title={t(
|
||||||
|
'admin.users.detail.devices.rename',
|
||||||
|
'\u041F\u0435\u0440\u0435\u0438\u043C\u0435\u043D\u043E\u0432\u0430\u0442\u044C',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
\u270F\uFE0F
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
handleInlineConfirm(`deleteDevice_${device.hwid}`, () =>
|
||||||
|
handleDeleteDevice(device.hwid),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className={`rounded-lg px-2 py-1 text-xs transition-all disabled:opacity-50 ${
|
||||||
|
confirmingAction === `deleteDevice_${device.hwid}`
|
||||||
|
? 'bg-error-500 text-white'
|
||||||
|
: 'text-dark-500 hover:bg-error-500/15 hover:text-error-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{confirmingAction === `deleteDevice_${device.hwid}`
|
||||||
|
? '?'
|
||||||
|
: '\u00D7'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
);
|
||||||
onClick={() =>
|
})}
|
||||||
handleInlineConfirm(`deleteDevice_${device.hwid}`, () =>
|
|
||||||
handleDeleteDevice(device.hwid),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
disabled={actionLoading}
|
|
||||||
className={`ml-2 shrink-0 rounded-lg px-2 py-1 text-xs transition-all disabled:opacity-50 ${
|
|
||||||
confirmingAction === `deleteDevice_${device.hwid}`
|
|
||||||
? 'bg-error-500 text-white'
|
|
||||||
: 'text-dark-500 hover:bg-error-500/15 hover:text-error-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{confirmingAction === `deleteDevice_${device.hwid}` ? '?' : '\u00D7'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-2 text-center text-xs text-dark-500">
|
<div className="py-2 text-center text-xs text-dark-500">
|
||||||
|
|||||||
@@ -308,6 +308,23 @@ export default function Subscription() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Local device alias (rename) state. Only one device can be in edit-mode
|
||||||
|
// at a time — `editingDeviceHwid` doubles as both the toggle and the
|
||||||
|
// identifier of the row being edited.
|
||||||
|
const [editingDeviceHwid, setEditingDeviceHwid] = useState<string | null>(null);
|
||||||
|
const [editingDeviceName, setEditingDeviceName] = useState('');
|
||||||
|
const DEVICE_ALIAS_MAX_LENGTH = 64;
|
||||||
|
|
||||||
|
const renameDeviceMutation = useMutation({
|
||||||
|
mutationFn: ({ hwid, name }: { hwid: string; name: string | null }) =>
|
||||||
|
subscriptionApi.renameDevice(hwid, name, subscriptionId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] });
|
||||||
|
setEditingDeviceHwid(null);
|
||||||
|
setEditingDeviceName('');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Pause subscription mutation
|
// Pause subscription mutation
|
||||||
const pauseMutation = useMutation({
|
const pauseMutation = useMutation({
|
||||||
mutationFn: () => subscriptionApi.togglePause(subscriptionId),
|
mutationFn: () => subscriptionApi.togglePause(subscriptionId),
|
||||||
@@ -2403,73 +2420,203 @@ export default function Subscription() {
|
|||||||
? `${devicesData.total} · ∞`
|
? `${devicesData.total} · ∞`
|
||||||
: `${devicesData.total} / ${t('subscription.devices', { count: devicesData.device_limit })}`}
|
: `${devicesData.total} / ${t('subscription.devices', { count: devicesData.device_limit })}`}
|
||||||
</div>
|
</div>
|
||||||
{devicesData.devices.map((device) => (
|
{devicesData.devices.map((device) => {
|
||||||
<div
|
const isEditing = editingDeviceHwid === device.hwid;
|
||||||
key={device.hwid}
|
// Display priority: user alias → device model → platform.
|
||||||
className="flex items-center justify-between rounded-[12px] p-3.5"
|
const displayName =
|
||||||
style={{
|
(device.local_name && device.local_name.trim()) ||
|
||||||
background: g.innerBg,
|
device.device_model ||
|
||||||
border: `1px solid ${g.innerBorder}`,
|
device.platform;
|
||||||
}}
|
|
||||||
>
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div
|
||||||
<div
|
key={device.hwid}
|
||||||
className="flex h-9 w-9 items-center justify-center rounded-[10px]"
|
className="flex items-center justify-between rounded-[12px] p-3.5"
|
||||||
style={{ background: g.trackBg }}
|
style={{
|
||||||
>
|
background: g.innerBg,
|
||||||
<svg
|
border: `1px solid ${g.innerBorder}`,
|
||||||
width="16"
|
}}
|
||||||
height="16"
|
>
|
||||||
viewBox="0 0 24 24"
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
fill="none"
|
<div
|
||||||
stroke={g.textSecondary}
|
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px]"
|
||||||
strokeWidth="1.5"
|
style={{ background: g.trackBg }}
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
>
|
||||||
<path d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
|
<svg
|
||||||
</svg>
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke={g.textSecondary}
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autoFocus
|
||||||
|
value={editingDeviceName}
|
||||||
|
maxLength={DEVICE_ALIAS_MAX_LENGTH}
|
||||||
|
placeholder={device.device_model || device.platform}
|
||||||
|
onChange={(e) => setEditingDeviceName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const trimmed = editingDeviceName.trim();
|
||||||
|
renameDeviceMutation.mutate({
|
||||||
|
hwid: device.hwid,
|
||||||
|
name: trimmed || null,
|
||||||
|
});
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
setEditingDeviceHwid(null);
|
||||||
|
setEditingDeviceName('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full rounded-md border-none bg-transparent px-2 py-1 text-sm font-semibold text-dark-50 outline-none focus:ring-1"
|
||||||
|
style={{
|
||||||
|
background: g.trackBg,
|
||||||
|
boxShadow: `inset 0 0 0 1px ${g.innerBorder}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="truncate text-sm font-semibold text-dark-50">
|
||||||
|
{displayName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-dark-50/30">
|
||||||
|
<span className="truncate">{device.platform}</span>
|
||||||
|
<span className="font-mono text-dark-50/20">
|
||||||
|
{device.hwid.slice(0, 8).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex flex-shrink-0 items-center gap-1">
|
||||||
<div className="text-sm font-semibold text-dark-50">
|
{isEditing ? (
|
||||||
{device.device_model || device.platform}
|
<>
|
||||||
</div>
|
<button
|
||||||
<div className="flex items-center gap-1.5 text-[11px] text-dark-50/30">
|
type="button"
|
||||||
<span>{device.platform}</span>
|
onClick={() => {
|
||||||
<span className="font-mono text-dark-50/20">
|
const trimmed = editingDeviceName.trim();
|
||||||
{device.hwid.slice(0, 8).toUpperCase()}
|
renameDeviceMutation.mutate({
|
||||||
</span>
|
hwid: device.hwid,
|
||||||
</div>
|
name: trimmed || null,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={renameDeviceMutation.isPending}
|
||||||
|
className="p-2 transition-colors"
|
||||||
|
style={{ color: g.textSecondary }}
|
||||||
|
title={t('subscription.renameDeviceSave', 'Сохранить')}
|
||||||
|
aria-label={t('subscription.renameDeviceSave', 'Сохранить')}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingDeviceHwid(null);
|
||||||
|
setEditingDeviceName('');
|
||||||
|
}}
|
||||||
|
disabled={renameDeviceMutation.isPending}
|
||||||
|
className="p-2 transition-colors"
|
||||||
|
style={{ color: g.textFaint }}
|
||||||
|
title={t('subscription.renameDeviceCancel', 'Отмена')}
|
||||||
|
aria-label={t('subscription.renameDeviceCancel', 'Отмена')}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingDeviceHwid(device.hwid);
|
||||||
|
setEditingDeviceName(device.local_name || '');
|
||||||
|
}}
|
||||||
|
className="p-2 transition-colors"
|
||||||
|
style={{ color: g.textFaint }}
|
||||||
|
title={t('subscription.renameDevice', 'Переименовать')}
|
||||||
|
aria-label={t('subscription.renameDevice', 'Переименовать')}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(t('subscription.confirmDeleteDevice'))) {
|
||||||
|
deleteDeviceMutation.mutate(device.hwid);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={deleteDeviceMutation.isPending}
|
||||||
|
className="p-2 transition-colors"
|
||||||
|
style={{ color: g.textFaint }}
|
||||||
|
title={t('subscription.deleteDevice')}
|
||||||
|
aria-label={t('subscription.deleteDevice')}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
);
|
||||||
onClick={() => {
|
})}
|
||||||
if (confirm(t('subscription.confirmDeleteDevice'))) {
|
|
||||||
deleteDeviceMutation.mutate(device.hwid);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={deleteDeviceMutation.isPending}
|
|
||||||
className="p-2 transition-colors"
|
|
||||||
style={{ color: g.textFaint }}
|
|
||||||
title={t('subscription.deleteDevice')}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-8 text-center text-[12px] text-dark-50/25">
|
<div className="py-8 text-center text-[12px] text-dark-50/25">
|
||||||
|
|||||||
@@ -141,6 +141,12 @@ export interface Device {
|
|||||||
platform: string;
|
platform: string;
|
||||||
device_model: string;
|
device_model: string;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
|
/**
|
||||||
|
* User-set local alias persisted in the bot DB (`user_device_aliases`).
|
||||||
|
* `null` when the user hasn't renamed the device — clients fall back
|
||||||
|
* to `device_model` / `platform` for display.
|
||||||
|
*/
|
||||||
|
local_name?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DevicesResponse {
|
export interface DevicesResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user