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:
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
KeyRound, Save, RefreshCw, Check, X, AlertCircle,
|
||||
ChevronDown, ChevronRight, Trash2, Plus, Eye, EyeOff,
|
||||
Monitor, Lock, Unlock, Info,
|
||||
Lock, Unlock, Info, HardDriveDownload, Zap, Terminal,
|
||||
} from 'lucide-react'
|
||||
import { api, type TTLockConfig, type RoomLockMapping, type Workstation } from '../lib/api'
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function TTLockPage() {
|
||||
@@ -91,6 +147,10 @@ export function TTLockPage() {
|
||||
const [wsSaving, setWsSaving] = 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 [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) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@@ -208,7 +280,17 @@ export function TTLockPage() {
|
||||
{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">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@@ -268,20 +350,10 @@ export function TTLockPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Секторы карты */}
|
||||
{/* Секторы карты — визуальный пикер */}
|
||||
<div>
|
||||
<label className="form-label">Секторы карты</label>
|
||||
<input
|
||||
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>
|
||||
<SectorPicker value={cardSectors} onChange={setCardSectors} />
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Hint>
|
||||
Для каждого рабочего места укажите COM-порт USB-энкодера карт (найти в Диспетчере устройств → Порты).
|
||||
Например: <strong>COM3</strong>. Агент должен быть онлайн.
|
||||
Нажмите <strong>↺</strong> рядом с полем, чтобы загрузить список портов с агента.
|
||||
Агент должен быть онлайн.
|
||||
</Hint>
|
||||
{workstations.length === 0 && (
|
||||
<p className="text-sm text-slate-500">Нет рабочих мест. Добавьте их на странице «Оборудование».</p>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{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">
|
||||
{/* 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>}
|
||||
{workstations.map(ws => {
|
||||
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 */}
|
||||
<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>
|
||||
{/* 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>
|
||||
</Section>
|
||||
|
||||
Reference in New Issue
Block a user