feat: TTLock page redesign — sector circles, COM dropdown, DLL diagnostics

- SectorPicker: circles 1–16, click to toggle (blue=selected, gray=not)
- COM port: dropdown populated from agent's serial ports list + refresh button
- Per-workstation buttons: HardDriveDownload (save), Zap (test encoder)
- Workstations without COM port shown as dimmed with amber 'Энкодер не настроен'
- DLL entry point error shown with hint to check TTHotel libs/index.js
- Backend: GET /ttlock/dll-info?workstation_id= → reads TTHotel index.js via agent
- api.ts: added dllInfo() call

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-01 22:11:22 +03:00
parent 070a333609
commit 061fc165b5
3 changed files with 253 additions and 69 deletions

View File

@@ -98,6 +98,35 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
return { ok: true } return { ok: true }
}) })
// ── GET /api/hotels/:slug/ttlock/dll-info?workstation_id=... ────────────────
// Читаем index.js из TTHotel чтобы узнать реальные имена функций DLL
fastify.get<SlugParam & { Querystring: { workstation_id: string } }>(
'/api/hotels/:slug/ttlock/dll-info',
{ onRequest: [fastify.authenticate] },
async (req, reply) => {
const { slug } = req.params
if (!canManage(req.user.hotelSlug, req.user.role, slug))
return reply.code(403).send({ error: 'Forbidden' })
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { workstation_id } = req.query
if (!workstation_id) return reply.code(400).send({ error: 'workstation_id required' })
try {
const result = await sendCommandAndWait(workstation_id, {
type: 'ttlock:read_index_js',
}, 8000) as { ok?: boolean; content?: string; path?: string; error?: string }
if (!result.ok) return reply.code(502).send({ error: result.error ?? 'Ошибка чтения файла' })
return { ok: true, content: result.content, path: result.path }
} catch (err) {
const msg = err instanceof Error ? err.message : 'Агент не ответил'
return reply.code(502).send({ error: msg })
}
},
)
// ── POST /api/hotels/:slug/ttlock/test ────────────────────────────────────── // ── POST /api/hotels/:slug/ttlock/test ──────────────────────────────────────
// Проверяем: агент онлайн, энкодер отвечает на COM-порту // Проверяем: агент онлайн, энкодер отвечает на COM-порту
fastify.post<SlugParam & { Body: { workstation_id: string } }>( fastify.post<SlugParam & { Body: { workstation_id: string } }>(

View File

@@ -616,6 +616,9 @@ export const api = {
testEncoder: (slug: string, workstationId: string) => testEncoder: (slug: string, workstationId: string) =>
req<{ ok: boolean }>('POST', `/api/hotels/${slug}/ttlock/test`, { workstationId }), req<{ ok: boolean }>('POST', `/api/hotels/${slug}/ttlock/test`, { workstationId }),
dllInfo: (slug: string, workstationId: string) =>
req<{ ok: boolean; content: string; path: string }>('GET', `/api/hotels/${slug}/ttlock/dll-info?workstation_id=${workstationId}`),
getRoomLocks: (slug: string) => getRoomLocks: (slug: string) =>
req<RoomLockMapping[]>('GET', `/api/hotels/${slug}/ttlock/room-locks`), req<RoomLockMapping[]>('GET', `/api/hotels/${slug}/ttlock/room-locks`),

View File

@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
import { import {
KeyRound, Save, RefreshCw, Check, X, AlertCircle, KeyRound, Save, RefreshCw, Check, X, AlertCircle,
ChevronDown, ChevronRight, Trash2, Plus, Eye, EyeOff, ChevronDown, ChevronRight, Trash2, Plus, Eye, EyeOff,
Monitor, Lock, Unlock, Info, Lock, Unlock, Info, HardDriveDownload, Zap, Terminal,
} from 'lucide-react' } from 'lucide-react'
import { api, type TTLockConfig, type RoomLockMapping, type Workstation } from '../lib/api' import { api, type TTLockConfig, type RoomLockMapping, type Workstation } from '../lib/api'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
@@ -65,6 +65,62 @@ function Hint({ children }: { children: React.ReactNode }) {
) )
} }
// ── Sector Picker ─────────────────────────────────────────────────────────────
function SectorPicker({ value, onChange }: {
value: string
onChange: (v: string) => void
}) {
// Parse comma-separated string into a Set of numbers
const selected = new Set(
value.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n) && n >= 1 && n <= 16)
)
const toggle = (n: number) => {
const next = new Set(selected)
if (next.has(n)) {
next.delete(n)
} else {
next.add(n)
}
// Sort numerically and join
const sorted = Array.from(next).sort((a, b) => a - b)
onChange(sorted.join(','))
}
return (
<div>
<div className="flex flex-wrap gap-2 mt-1">
{Array.from({ length: 16 }, (_, i) => i + 1).map(n => (
<button
key={n}
type="button"
onClick={() => toggle(n)}
title={`Сектор ${n}`}
className={cn(
'w-9 h-9 rounded-full text-sm font-semibold transition-colors border-2',
selected.has(n)
? 'bg-brand-600 border-brand-600 text-white hover:bg-brand-700 hover:border-brand-700'
: 'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-500 dark:text-slate-400 hover:border-brand-400 hover:text-brand-600',
)}
>
{n}
</button>
))}
</div>
<p className="text-xs text-slate-500 mt-2">
Выбрано: <code className="bg-slate-100 dark:bg-slate-800 px-1 rounded font-mono">{value || '(нет)'}</code>
{' — '}секторы Mifare-карты, которые использует TTHotel. Обычно 110.
</p>
</div>
)
}
// ── DLL error note ────────────────────────────────────────────────────────────
function isDllEntryError(msg: string): boolean {
return msg.includes('точку входа') || msg.toLowerCase().includes('entrypoint') || msg.toLowerCase().includes('entry point')
}
// ── Main Page ───────────────────────────────────────────────────────────────── // ── Main Page ─────────────────────────────────────────────────────────────────
export function TTLockPage() { export function TTLockPage() {
@@ -91,6 +147,10 @@ export function TTLockPage() {
const [wsSaving, setWsSaving] = useState<Record<string, boolean>>({}) const [wsSaving, setWsSaving] = useState<Record<string, boolean>>({})
const [wsTesting, setWsTesting] = useState<Record<string, boolean>>({}) const [wsTesting, setWsTesting] = useState<Record<string, boolean>>({})
// Per-workstation port list state
const [wsPorts, setWsPorts] = useState<Record<string, { port: string; description?: string }[]>>({})
const [wsPortsLoading, setWsPortsLoading] = useState<Record<string, boolean>>({})
// Форма привязки замков // Форма привязки замков
const [addRoomId, setAddRoomId] = useState('') const [addRoomId, setAddRoomId] = useState('')
const [addLockMac, setAddLockMac] = useState('') const [addLockMac, setAddLockMac] = useState('')
@@ -180,6 +240,18 @@ export function TTLockPage() {
} }
} }
const handleLoadPorts = async (wsId: string) => {
setWsPortsLoading(prev => ({ ...prev, [wsId]: true }))
try {
const result = await api.workstations.listPorts(slug, wsId)
setWsPorts(prev => ({ ...prev, [wsId]: result.ports }))
} catch {
// If agent offline or error, leave wsPorts[wsId] undefined — fallback to text input
} finally {
setWsPortsLoading(prev => ({ ...prev, [wsId]: false }))
}
}
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
@@ -208,7 +280,17 @@ export function TTLockPage() {
{error && ( {error && (
<div className="flex items-start gap-2 p-4 rounded-xl bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm"> <div className="flex items-start gap-2 p-4 rounded-xl bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm">
<AlertCircle size={16} className="mt-0.5 shrink-0" /> <AlertCircle size={16} className="mt-0.5 shrink-0" />
<span className="flex-1">{error}</span> <div className="flex-1 space-y-1">
<span>{error}</span>
{isDllEntryError(error) && (
<div className="mt-2 p-2 rounded bg-red-100 dark:bg-red-900/40 text-xs">
<strong>Имена функций в DLL не совпадают.</strong>{' '}
Откройте файл{' '}
<code className="font-mono">C:\Program Files (x86)\TTHotel\resources\libs\index.js</code>{' '}
и сообщите содержимое разработчику.
</div>
)}
</div>
<button onClick={() => setError(null)}><X size={14} /></button> <button onClick={() => setError(null)}><X size={14} /></button>
</div> </div>
)} )}
@@ -268,20 +350,10 @@ export function TTLockPage() {
</div> </div>
</div> </div>
{/* Секторы карты */} {/* Секторы карты — визуальный пикер */}
<div> <div>
<label className="form-label">Секторы карты</label> <label className="form-label">Секторы карты</label>
<input <SectorPicker value={cardSectors} onChange={setCardSectors} />
type="text"
value={cardSectors}
onChange={e => setCardSectors(e.target.value)}
placeholder="1,2,3,4,5,6,7,8,9,10"
className="input w-full font-mono"
/>
<p className="text-xs text-slate-500 mt-1">
Секторы Mifare-карты, которые использует TTHotel. Посмотрите в настройках TTHotel.
Обычно: <code className="bg-slate-100 dark:bg-slate-800 px-1 rounded">1,2,3,4,5,6,7,8,9,10</code>
</p>
</div> </div>
<button onClick={handleSave} disabled={saving} className="btn-primary flex items-center gap-2"> <button onClick={handleSave} disabled={saving} className="btn-primary flex items-center gap-2">
@@ -296,67 +368,147 @@ export function TTLockPage() {
<div className="space-y-4"> <div className="space-y-4">
<Hint> <Hint>
Для каждого рабочего места укажите COM-порт USB-энкодера карт (найти в Диспетчере устройств Порты). Для каждого рабочего места укажите COM-порт USB-энкодера карт (найти в Диспетчере устройств Порты).
Например: <strong>COM3</strong>. Агент должен быть онлайн. Нажмите <strong></strong> рядом с полем, чтобы загрузить список портов с агента.
Агент должен быть онлайн.
</Hint> </Hint>
{workstations.length === 0 && ( {workstations.length === 0 && (
<p className="text-sm text-slate-500">Нет рабочих мест. Добавьте их на странице «Оборудование».</p> <p className="text-sm text-slate-500">Нет рабочих мест. Добавьте их на странице «Оборудование».</p>
)} )}
<div className="space-y-3"> <div className="space-y-3">
{workstations.map(ws => ( {workstations.map(ws => {
<div key={ws.id} className="flex items-center gap-3 p-3 rounded-xl border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/40"> const hasPort = !!wsComPorts[ws.id]?.trim()
{/* Online dot */} const portList = wsPorts[ws.id]
<div className={cn('w-2.5 h-2.5 rounded-full shrink-0', ws.isOnline ? 'bg-emerald-500' : 'bg-slate-300 dark:bg-slate-600')} /> const portsLoading = wsPortsLoading[ws.id] ?? false
{/* Name */}
<div className="flex-1 min-w-0"> return (
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{ws.name}</p> <div
{ws.hostname && <p className="text-xs text-slate-400 truncate">{ws.hostname}</p>} key={ws.id}
className={cn(
'flex items-center gap-3 p-3 rounded-xl border bg-slate-50 dark:bg-slate-800/40',
hasPort
? 'border-slate-200 dark:border-slate-700'
: 'border-slate-200 dark:border-slate-700 opacity-60',
)}
>
{/* Online dot */}
<div className={cn('w-2.5 h-2.5 rounded-full shrink-0', ws.isOnline ? 'bg-emerald-500' : 'bg-slate-300 dark:bg-slate-600')} />
{/* Name */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{ws.name}</p>
{ws.hostname && <p className="text-xs text-slate-400 truncate">{ws.hostname}</p>}
{!hasPort && (
<p className="text-xs text-amber-500 dark:text-amber-400 mt-0.5">Энкодер не настроен</p>
)}
</div>
{/* COM port — dropdown if ports loaded, text input otherwise */}
<div className="flex items-center gap-1 shrink-0">
{portList && portList.length > 0 ? (
<select
className="input w-36 text-sm font-mono py-1.5"
value={wsComPorts[ws.id] ?? ''}
onChange={e => setWsComPorts(prev => ({ ...prev, [ws.id]: e.target.value }))}
>
<option value=""> Выберите </option>
{portList.map(p => (
<option key={p.port} value={p.port}>
{p.port}{p.description ? `${p.description}` : ''}
</option>
))}
</select>
) : portList && portList.length === 0 ? (
<span className="text-xs text-slate-400 w-36 text-center">Порты не найдены</span>
) : (
<input
type="text"
className="input w-28 text-sm font-mono"
placeholder="COM3"
value={wsComPorts[ws.id] ?? ''}
onChange={e => setWsComPorts(prev => ({ ...prev, [ws.id]: e.target.value }))}
/>
)}
{/* Load ports button */}
<button
type="button"
onClick={() => handleLoadPorts(ws.id)}
disabled={portsLoading || !ws.isOnline}
title={!ws.isOnline ? 'Агент офлайн' : 'Загрузить список COM-портов'}
className="btn-secondary p-1.5 shrink-0"
>
{portsLoading
? <RefreshCw size={13} className="animate-spin" />
: <RefreshCw size={13} />}
</button>
</div>
{/* Save COM port */}
<button
type="button"
disabled={wsSaving[ws.id]}
onClick={async () => {
setWsSaving(prev => ({ ...prev, [ws.id]: true }))
try {
await api.workstations.update(slug, ws.id, { ttlockComPort: wsComPorts[ws.id]?.trim() || null })
showSuccess(`COM-порт для «${ws.name}» сохранён`)
await load()
} catch { setError('Ошибка сохранения') }
finally { setWsSaving(prev => ({ ...prev, [ws.id]: false })) }
}}
className="btn-secondary p-1.5 shrink-0"
title="Сохранить COM-порт"
>
{wsSaving[ws.id]
? <RefreshCw size={13} className="animate-spin" />
: <HardDriveDownload size={13} />}
</button>
{/* Test encoder */}
<button
type="button"
disabled={!ws.isOnline || !hasPort || wsTesting[ws.id]}
onClick={async () => {
setWsTesting(prev => ({ ...prev, [ws.id]: true }))
setError(null)
try {
await api.ttlock.testEncoder(slug, ws.id)
showSuccess(`Энкодер на «${ws.name}» отвечает — всё готово!`)
} catch (e) {
const msg = e instanceof Error ? e.message : 'Ошибка проверки'
setError(msg)
} finally { setWsTesting(prev => ({ ...prev, [ws.id]: false })) }
}}
className="btn-secondary p-1.5 shrink-0"
title={!ws.isOnline ? 'Агент офлайн' : !hasPort ? 'Укажите COM-порт' : 'Проверить энкодер'}
>
{wsTesting[ws.id]
? <RefreshCw size={13} className="animate-spin" />
: <Zap size={13} />}
</button>
{/* Diagnose DLL */}
<button
type="button"
disabled={!ws.isOnline}
onClick={async () => {
setError(null)
try {
await api.ttlock.testEncoder(slug, ws.id)
showSuccess(`Диагностика «${ws.name}»: DLL загружена успешно`)
} catch (e) {
const msg = e instanceof Error ? e.message : 'Ошибка диагностики DLL'
setError(msg)
}
}}
className="btn-secondary p-1.5 shrink-0"
title={!ws.isOnline ? 'Агент офлайн' : 'Диагностика DLL'}
>
<Terminal size={13} />
</button>
</div> </div>
{/* COM port input */} )
<input })}
type="text"
className="input w-28 text-sm font-mono"
placeholder="COM3"
value={wsComPorts[ws.id] ?? ''}
onChange={e => setWsComPorts(prev => ({ ...prev, [ws.id]: e.target.value }))}
/>
{/* Save */}
<button
type="button"
disabled={wsSaving[ws.id]}
onClick={async () => {
setWsSaving(prev => ({ ...prev, [ws.id]: true }))
try {
await api.workstations.update(slug, ws.id, { ttlockComPort: wsComPorts[ws.id]?.trim() || null })
showSuccess(`COM-порт для «${ws.name}» сохранён`)
await load()
} catch { setError('Ошибка сохранения') }
finally { setWsSaving(prev => ({ ...prev, [ws.id]: false })) }
}}
className="btn-secondary text-xs px-3 py-1.5 shrink-0"
>
{wsSaving[ws.id] ? <RefreshCw size={12} className="animate-spin" /> : <Save size={13} />}
</button>
{/* Test */}
<button
type="button"
disabled={!ws.isOnline || !wsComPorts[ws.id]?.trim() || wsTesting[ws.id]}
onClick={async () => {
setWsTesting(prev => ({ ...prev, [ws.id]: true }))
setError(null)
try {
await api.ttlock.testEncoder(slug, ws.id)
showSuccess(`Энкодер на «${ws.name}» отвечает — всё готово!`)
} catch (e) {
setError(e instanceof Error ? e.message : 'Ошибка проверки')
} finally { setWsTesting(prev => ({ ...prev, [ws.id]: false })) }
}}
className="btn-secondary text-xs px-3 py-1.5 shrink-0"
title={!ws.isOnline ? 'Агент офлайн' : !wsComPorts[ws.id]?.trim() ? 'Укажите COM-порт' : 'Проверить связь с энкодером'}
>
{wsTesting[ws.id] ? <RefreshCw size={12} className="animate-spin" /> : <Check size={13} />}
</button>
</div>
))}
</div> </div>
</div> </div>
</Section> </Section>