Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
140 lines
6.6 KiB
TypeScript
140 lines
6.6 KiB
TypeScript
import { useQuery } from '@tanstack/react-query';
|
|
import { useEffect, useState } from 'react';
|
|
import { api } from '../api.ts';
|
|
import { ErrorNote, Toggle } from '../components/ui.tsx';
|
|
import { useAction } from '../hooks.ts';
|
|
import { useCrm } from './i18n.ts';
|
|
|
|
interface Settings {
|
|
enabled: boolean;
|
|
freshDays: number;
|
|
pauseSec: number;
|
|
maxPages: number;
|
|
quietHours: boolean;
|
|
quietFrom: number;
|
|
quietTo: number;
|
|
categories: number[];
|
|
useKey: boolean;
|
|
removalCheck: boolean;
|
|
removalBatch: number;
|
|
recheckHours: number;
|
|
jinaKey: { source: 'settings' | 'env' | 'none'; tail: string | null };
|
|
allCategories: { id: number; propertyType: 'apartment' | 'house' | 'commercial' | 'land' | 'garage'; dealType: 'sale' | 'rent' }[];
|
|
}
|
|
|
|
/** Настройки парсера для суперадмина. Применяются со следующего прохода, без перезапуска сервиса. */
|
|
export function ParserSettings() {
|
|
const { c, t } = useCrm();
|
|
const x = c.ps;
|
|
const loaded = useQuery({ queryKey: ['admin', 'parser', 'settings'], queryFn: () => api<Settings>('/admin/parser/settings') });
|
|
const [f, setF] = useState<Settings | null>(null);
|
|
const [key, setKey] = useState('');
|
|
useEffect(() => { if (loaded.data) setF(loaded.data); }, [loaded.data]);
|
|
const save = useAction(
|
|
(body: object) => api<Settings>('/admin/parser/settings', { method: 'PATCH', body }),
|
|
['admin', 'parser'],
|
|
);
|
|
if (!f) return <ErrorNote error={loaded.error} />;
|
|
|
|
const number = (field: 'freshDays' | 'pauseSec' | 'maxPages' | 'removalBatch' | 'recheckHours', label: string, min: number, max: number, hint?: string) => (
|
|
<div>
|
|
<label className="label" htmlFor={`ps-${field}`}>{label}</label>
|
|
<input id={`ps-${field}`} type="number" required min={min} max={max} className="field" value={f[field]} onChange={(e) => setF({ ...f, [field]: Number(e.target.value) })} />
|
|
{hint && <p className="mt-1.5 text-xs text-muted">{hint}</p>}
|
|
</div>
|
|
);
|
|
const toggleCategory = (id: number) =>
|
|
setF({ ...f, categories: f.categories.includes(id) ? f.categories.filter((v) => v !== id) : [...f.categories, id] });
|
|
|
|
return (
|
|
<form
|
|
className="card mt-8 space-y-6 p-5 sm:p-6"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
const { jinaKey: _jinaKey, allCategories: _all, ...body } = f;
|
|
save.mutate({ ...body, ...(key.trim() ? { jinaApiKey: key.trim() } : {}) }, { onSuccess: () => setKey('') });
|
|
}}
|
|
>
|
|
<div>
|
|
<h2 className="text-lg font-medium tracking-tight">{x.title}</h2>
|
|
<p className="mt-1 text-sm text-muted">{x.hint}</p>
|
|
</div>
|
|
|
|
<Toggle checked={f.enabled} onChange={(v) => setF({ ...f, enabled: v })} label={x.enabled} />
|
|
|
|
<div className="grid gap-4 sm:grid-cols-3">
|
|
{number('freshDays', x.freshDays, 1, 30, x.freshHint)}
|
|
{number('pauseSec', x.pauseSec, 0, 3600)}
|
|
{number('maxPages', x.maxPages, 1, 10, x.pagesHint)}
|
|
</div>
|
|
|
|
<div className="space-y-4 border-t border-line pt-5">
|
|
<h3 className="font-medium">{x.quietTitle}</h3>
|
|
<p className="text-sm text-muted">{x.quietHint}</p>
|
|
<Toggle checked={f.quietHours} onChange={(v) => setF({ ...f, quietHours: v })} label={x.quietOn} />
|
|
<div className="grid gap-4 sm:grid-cols-3">
|
|
{(['quietFrom', 'quietTo'] as const).map((field) => (
|
|
<div key={field}>
|
|
<label className="label" htmlFor={`ps-${field}`}>{x[field]}</label>
|
|
<select
|
|
id={`ps-${field}`} className="field" disabled={!f.quietHours}
|
|
value={f[field]} onChange={(e) => setF({ ...f, [field]: Number(e.target.value) })}
|
|
>
|
|
{Array.from({ length: 24 }, (_, h) => <option key={h} value={h}>{`${String(h).padStart(2, '0')}:00`}</option>)}
|
|
</select>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{f.quietHours && f.quietFrom === f.quietTo && <p className="text-sm text-warn" role="status">{x.quietSame}</p>}
|
|
</div>
|
|
|
|
<fieldset>
|
|
<legend className="label">{x.categories}</legend>
|
|
<div className="flex flex-wrap gap-2">
|
|
{f.allCategories.map((cat) => {
|
|
const on = f.categories.includes(cat.id);
|
|
return (
|
|
<label key={cat.id} className={`flex min-h-9 cursor-pointer items-center rounded-lg border px-3 text-sm transition-colors has-focus-visible:outline-2 has-focus-visible:outline-signal ${on ? 'border-capture/60 bg-capture/15 text-text' : 'border-line-strong text-muted hover:text-text'}`}>
|
|
<input type="checkbox" className="sr-only" checked={on} onChange={() => toggleCategory(cat.id)} />
|
|
{c.ptype[cat.propertyType]} · {c.deal[cat.dealType].toLowerCase()}
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
</fieldset>
|
|
|
|
<div className="space-y-4 border-t border-line pt-5">
|
|
<h3 className="font-medium">{x.keyTitle}</h3>
|
|
<p className="text-sm text-muted">{x.keyHint}</p>
|
|
<Toggle checked={f.useKey} onChange={(v) => setF({ ...f, useKey: v })} label={x.useKey} />
|
|
<div className="grid items-end gap-3 sm:grid-cols-[1fr_auto]">
|
|
<div>
|
|
<label className="label" htmlFor="ps-key">
|
|
{x.key}: {f.jinaKey.source === 'none' ? x.keyNone : `…${f.jinaKey.tail} (${f.jinaKey.source === 'env' ? x.keyEnv : x.keySet})`}
|
|
</label>
|
|
<input id="ps-key" type="password" autoComplete="off" className="field" maxLength={200} placeholder={x.keyNew} value={key} onChange={(e) => setKey(e.target.value)} />
|
|
</div>
|
|
{f.jinaKey.source === 'settings' && (
|
|
<button type="button" className="btn btn-ghost" disabled={save.isPending} onClick={() => confirm(x.keyConfirm) && save.mutate({ jinaApiKey: '' })}>{x.keyClear}</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4 border-t border-line pt-5">
|
|
<h3 className="font-medium">{x.removalTitle}</h3>
|
|
<Toggle checked={f.removalCheck} onChange={(v) => setF({ ...f, removalCheck: v })} label={x.removalCheck} />
|
|
<div className="grid gap-4 sm:grid-cols-3">
|
|
{number('recheckHours', x.recheckHours, 1, 720)}
|
|
{number('removalBatch', x.removalBatch, 1, 100, x.batchHint(f.removalBatch * 144))}
|
|
</div>
|
|
</div>
|
|
|
|
<ErrorNote error={save.error} />
|
|
<div className="flex items-center gap-3">
|
|
<button className="btn btn-primary" disabled={save.isPending}>{t.common.save}</button>
|
|
{save.isSuccess && <span className="text-sm text-capture" role="status">{t.settings.saved}</span>}
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|