Lead Radar: исходный код

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
yuriandClaude Opus 5.5 committed 2026-10-25 23:11:04 +04:00
commit 9749da5ae7
201 files changed
+97685

No files matched your search

+116
View File
@@ -0,0 +1,116 @@
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;
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>
<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>
);
}