Lead Radar: исходный код
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
9749da5ae7
201 files changed
+97685
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user