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:
@@ -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 } }>(
|
||||||
|
|||||||
@@ -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`),
|
||||||
|
|
||||||
|
|||||||
@@ -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. Обычно 1–10.
|
||||||
|
</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,22 +368,58 @@ 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()
|
||||||
|
const portList = wsPorts[ws.id]
|
||||||
|
const portsLoading = wsPortsLoading[ws.id] ?? false
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
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 */}
|
{/* 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')} />
|
<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 */}
|
{/* Name */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{ws.name}</p>
|
<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>}
|
{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>
|
</div>
|
||||||
{/* COM port input */}
|
|
||||||
|
{/* 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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="input w-28 text-sm font-mono"
|
className="input w-28 text-sm font-mono"
|
||||||
@@ -319,7 +427,23 @@ export function TTLockPage() {
|
|||||||
value={wsComPorts[ws.id] ?? ''}
|
value={wsComPorts[ws.id] ?? ''}
|
||||||
onChange={e => setWsComPorts(prev => ({ ...prev, [ws.id]: e.target.value }))}
|
onChange={e => setWsComPorts(prev => ({ ...prev, [ws.id]: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
{/* Save */}
|
)}
|
||||||
|
|
||||||
|
{/* 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={wsSaving[ws.id]}
|
disabled={wsSaving[ws.id]}
|
||||||
@@ -332,14 +456,18 @@ export function TTLockPage() {
|
|||||||
} catch { setError('Ошибка сохранения') }
|
} catch { setError('Ошибка сохранения') }
|
||||||
finally { setWsSaving(prev => ({ ...prev, [ws.id]: false })) }
|
finally { setWsSaving(prev => ({ ...prev, [ws.id]: false })) }
|
||||||
}}
|
}}
|
||||||
className="btn-secondary text-xs px-3 py-1.5 shrink-0"
|
className="btn-secondary p-1.5 shrink-0"
|
||||||
|
title="Сохранить COM-порт"
|
||||||
>
|
>
|
||||||
{wsSaving[ws.id] ? <RefreshCw size={12} className="animate-spin" /> : <Save size={13} />}
|
{wsSaving[ws.id]
|
||||||
|
? <RefreshCw size={13} className="animate-spin" />
|
||||||
|
: <HardDriveDownload size={13} />}
|
||||||
</button>
|
</button>
|
||||||
{/* Test */}
|
|
||||||
|
{/* Test encoder */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!ws.isOnline || !wsComPorts[ws.id]?.trim() || wsTesting[ws.id]}
|
disabled={!ws.isOnline || !hasPort || wsTesting[ws.id]}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setWsTesting(prev => ({ ...prev, [ws.id]: true }))
|
setWsTesting(prev => ({ ...prev, [ws.id]: true }))
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -347,16 +475,40 @@ export function TTLockPage() {
|
|||||||
await api.ttlock.testEncoder(slug, ws.id)
|
await api.ttlock.testEncoder(slug, ws.id)
|
||||||
showSuccess(`Энкодер на «${ws.name}» отвечает — всё готово!`)
|
showSuccess(`Энкодер на «${ws.name}» отвечает — всё готово!`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Ошибка проверки')
|
const msg = e instanceof Error ? e.message : 'Ошибка проверки'
|
||||||
|
setError(msg)
|
||||||
} finally { setWsTesting(prev => ({ ...prev, [ws.id]: false })) }
|
} finally { setWsTesting(prev => ({ ...prev, [ws.id]: false })) }
|
||||||
}}
|
}}
|
||||||
className="btn-secondary text-xs px-3 py-1.5 shrink-0"
|
className="btn-secondary p-1.5 shrink-0"
|
||||||
title={!ws.isOnline ? 'Агент офлайн' : !wsComPorts[ws.id]?.trim() ? 'Укажите COM-порт' : 'Проверить связь с энкодером'}
|
title={!ws.isOnline ? 'Агент офлайн' : !hasPort ? 'Укажите COM-порт' : 'Проверить энкодер'}
|
||||||
>
|
>
|
||||||
{wsTesting[ws.id] ? <RefreshCw size={12} className="animate-spin" /> : <Check size={13} />}
|
{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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
Reference in New Issue
Block a user