fix photo

This commit is contained in:
lekss361 2026-04-27 20:06:18 +03:00
parent 5056438fcf
commit c079ac2a77
5 changed files with 224 additions and 44 deletions

View file

@ -3,6 +3,14 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
reactStrictMode: true, reactStrictMode: true,
output: "standalone", output: "standalone",
images: {
remotePatterns: [
{ protocol: "https", hostname: "xn--80az8a.xn--d1aqf.xn--p1ai" },
{ protocol: "https", hostname: "наш.дом.рф" },
],
formats: ["image/webp"],
minimumCacheTTL: 60 * 60 * 24 * 7,
},
// In dev (no Caddy) Next.js itself proxies /api/* and /health to the backend // In dev (no Caddy) Next.js itself proxies /api/* and /health to the backend
// so the browser can use same-origin relative URLs. // so the browser can use same-origin relative URLs.
// In prod Caddy intercepts these paths before they reach Next.js, // In prod Caddy intercepts these paths before they reach Next.js,

View file

@ -118,16 +118,47 @@ export default function ScrapeAdminPage() {
}); });
const revokeMutation = useMutation({ const revokeMutation = useMutation({
mutationFn: (task_id: string) => mutationFn: ({
apiFetch<{ revoked: boolean }>("/api/v1/admin/scrape/revoke", { task_id,
method: "POST", terminate,
headers: { "X-Admin-Token": token }, }: {
body: JSON.stringify({ task_id, terminate: false }), task_id: string;
}), terminate: boolean;
onSuccess: () => }) =>
queryClient.invalidateQueries({ queryKey: ["scrape-queue"] }), apiFetch<{ revoked: boolean; terminate: boolean }>(
"/api/v1/admin/scrape/revoke",
{
method: "POST",
headers: { "X-Admin-Token": token },
body: JSON.stringify({ task_id, terminate }),
},
),
onSuccess: (data) => {
console.log("[scrape] revoked:", data);
queryClient.invalidateQueries({ queryKey: ["scrape-queue"] });
queryClient.invalidateQueries({ queryKey: ["scrape-runs"] });
},
onError: (err) => {
console.error("[scrape] revoke failed:", err);
alert(`Не удалось отменить: ${String(err)}`);
},
}); });
const cancelActive = (taskId: string) => {
if (
!confirm(
"Прервать выполняющийся таск (SIGTERM)? Скрапер остановится мгновенно.",
)
) {
return;
}
revokeMutation.mutate({ task_id: taskId, terminate: true });
};
const cancelReserved = (taskId: string) => {
revokeMutation.mutate({ task_id: taskId, terminate: false });
};
const failures = useQuery({ const failures = useQuery({
queryKey: ["scrape-failures", failuresRunId, token], queryKey: ["scrape-failures", failuresRunId, token],
queryFn: () => { queryFn: () => {
@ -420,10 +451,12 @@ export default function ScrapeAdminPage() {
</td> </td>
<td style={td}> <td style={td}>
<button <button
onClick={() => revokeMutation.mutate(t.task_id)} onClick={() => cancelActive(t.task_id)}
disabled={revokeMutation.isPending}
style={dangerBtn} style={dangerBtn}
title="SIGTERM прерывает запущенный скрейп мгновенно"
> >
Отменить 🛑 Прервать
</button> </button>
</td> </td>
</tr> </tr>
@ -487,10 +520,12 @@ export default function ScrapeAdminPage() {
</td> </td>
<td style={td}> <td style={td}>
<button <button
onClick={() => revokeMutation.mutate(t.task_id)} onClick={() => cancelReserved(t.task_id)}
disabled={revokeMutation.isPending}
style={dangerBtn} style={dangerBtn}
title="Удалить из очереди — worker не запустит этот таск"
> >
Отменить Удалить из очереди
</button> </button>
</td> </td>
</tr> </tr>

View file

@ -1,9 +1,11 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useObjectInfrastructure } from "@/lib/analytics-api"; import { useObjectInfrastructure } from "@/lib/analytics-api";
const PAGE_SIZE = 50;
const CATEGORY_COLORS: Record<string, string> = { const CATEGORY_COLORS: Record<string, string> = {
Спорт: "#0a7a3a", Спорт: "#0a7a3a",
Продукты: "#1d4ed8", Продукты: "#1d4ed8",
@ -23,11 +25,16 @@ interface Props {
export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) { export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
const [maxDist, setMaxDist] = useState(2000); const [maxDist, setMaxDist] = useState(2000);
const [activeCat, setActiveCat] = useState<string | null>(null); const [activeCat, setActiveCat] = useState<string | null>(null);
const { data, isLoading } = useObjectInfrastructure(objId, { const [shown, setShown] = useState(PAGE_SIZE);
const { data, isLoading, isFetching } = useObjectInfrastructure(objId, {
category: activeCat ?? undefined, category: activeCat ?? undefined,
max_distance: maxDist, max_distance: maxDist,
}); });
useEffect(() => {
setShown(PAGE_SIZE);
}, [activeCat, maxDist]);
const stats = useMemo(() => { const stats = useMemo(() => {
const byCat: Record<string, number> = {}; const byCat: Record<string, number> = {};
for (const p of data ?? []) { for (const p of data ?? []) {
@ -87,6 +94,8 @@ export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
borderRadius: 8, borderRadius: 8,
overflow: "hidden", overflow: "hidden",
position: "relative", position: "relative",
opacity: isFetching ? 0.7 : 1,
transition: "opacity 120ms",
}} }}
> >
<div <div
@ -101,6 +110,7 @@ export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
{centerLat && centerLon {centerLat && centerLon
? `ЖК: ${centerLat.toFixed(4)}, ${centerLon.toFixed(4)}` ? `ЖК: ${centerLat.toFixed(4)}, ${centerLon.toFixed(4)}`
: "координаты ЖК не заданы"} : "координаты ЖК не заданы"}
{isFetching ? " · обновление…" : ""}
</div> </div>
<table <table
style={{ width: "100%", borderCollapse: "collapse", fontSize: 12 }} style={{ width: "100%", borderCollapse: "collapse", fontSize: 12 }}
@ -123,7 +133,7 @@ export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{(data ?? []).slice(0, 50).map((p, i) => ( {(data ?? []).slice(0, shown).map((p, i) => (
<tr <tr
key={`${p.poi_name}-${p.lat}-${p.lon}`} key={`${p.poi_name}-${p.lat}-${p.lon}`}
style={{ style={{
@ -163,6 +173,30 @@ export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
))} ))}
</tbody> </tbody>
</table> </table>
{(data?.length ?? 0) > shown ? (
<div
style={{
padding: 10,
textAlign: "center",
background: "#f9fafb",
borderTop: "1px solid #eef0f3",
}}
>
<button
onClick={() => setShown((n) => n + PAGE_SIZE)}
style={{
padding: "4px 12px",
fontSize: 12,
border: "1px solid #d1d5db",
borderRadius: 12,
background: "#fff",
cursor: "pointer",
}}
>
Показать ещё {Math.min(PAGE_SIZE, (data?.length ?? 0) - shown)}
</button>
</div>
) : null}
</div> </div>
)} )}
</div> </div>

View file

@ -1,23 +1,46 @@
"use client"; "use client";
import { useState } from "react"; import Image from "next/image";
import { useEffect, useState } from "react";
import { useObjectPhotos } from "@/lib/analytics-api"; import { useObjectPhotos } from "@/lib/analytics-api";
const INITIAL_LIMIT = 24;
export function ObjectPhotoGallery({ objId }: { objId: number | string }) { export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
const { data, isLoading } = useObjectPhotos(objId, 60); const { data, isLoading } = useObjectPhotos(objId, 200);
const [active, setActive] = useState<string | null>(null); const [active, setActive] = useState<string | null>(null);
const [showAll, setShowAll] = useState(false);
useEffect(() => {
if (!active) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setActive(null);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [active]);
if (isLoading) { if (isLoading) {
return <div style={{ color: "#5b6066" }}>Загрузка фото</div>; return <div style={{ color: "#5b6066" }}>Загрузка фото</div>;
} }
const photos = data ?? []; const photos = (data ?? []).filter((p) => p.photo_url);
if (photos.length === 0) { if (photos.length === 0) {
return <div style={{ color: "#9ca3af" }}>Фото отсутствуют.</div>; return <div style={{ color: "#9ca3af" }}>Фото отсутствуют.</div>;
} }
const activePhoto = photos.find((p) => p.obj_file_id === active); const visible = showAll ? photos : photos.slice(0, INITIAL_LIMIT);
const activeIdx = active
? photos.findIndex((p) => p.obj_file_id === active)
: -1;
const activePhoto = activeIdx >= 0 ? photos[activeIdx] : null;
const move = (delta: number) => {
if (activeIdx < 0) return;
const next = (activeIdx + delta + photos.length) % photos.length;
setActive(photos[next].obj_file_id);
};
return ( return (
<> <>
@ -28,7 +51,7 @@ export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
gap: 8, gap: 8,
}} }}
> >
{photos.map((p) => ( {visible.map((p) => (
<button <button
key={p.obj_file_id} key={p.obj_file_id}
onClick={() => setActive(p.obj_file_id)} onClick={() => setActive(p.obj_file_id)}
@ -37,21 +60,22 @@ export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
border: "1px solid #e6e8ec", border: "1px solid #e6e8ec",
borderRadius: 6, borderRadius: 6,
overflow: "hidden", overflow: "hidden",
cursor: "pointer", cursor: "zoom-in",
background: "#f3f4f6", background: "#f3f4f6",
aspectRatio: "4/3", aspectRatio: "4/3",
position: "relative", position: "relative",
}} }}
> >
{p.photo_url ? ( <Image
// eslint-disable-next-line @next/next/no-img-element src={p.photo_url!}
<img alt={p.photo_name ?? "фото прогресса"}
src={p.photo_url} fill
alt={p.photo_name ?? "фото прогресса"} sizes="160px"
loading="lazy" quality={60}
style={{ width: "100%", height: "100%", objectFit: "cover" }} style={{ objectFit: "cover" }}
/> loading="lazy"
) : null} unoptimized={false}
/>
<div <div
style={{ style={{
position: "absolute", position: "absolute",
@ -62,6 +86,7 @@ export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
color: "#fff", color: "#fff",
fontSize: 11, fontSize: 11,
padding: "3px 6px", padding: "3px 6px",
pointerEvents: "none",
}} }}
> >
{p.period_dt?.slice(0, 7) ?? "—"} {p.period_dt?.slice(0, 7) ?? "—"}
@ -70,13 +95,31 @@ export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
))} ))}
</div> </div>
{photos.length > INITIAL_LIMIT && !showAll ? (
<div style={{ textAlign: "center", marginTop: 12 }}>
<button
onClick={() => setShowAll(true)}
style={{
padding: "6px 14px",
fontSize: 13,
border: "1px solid #d1d5db",
borderRadius: 16,
background: "#fff",
cursor: "pointer",
}}
>
Показать ещё {photos.length - INITIAL_LIMIT}
</button>
</div>
) : null}
{activePhoto ? ( {activePhoto ? (
<div <div
onClick={() => setActive(null)} onClick={() => setActive(null)}
style={{ style={{
position: "fixed", position: "fixed",
inset: 0, inset: 0,
background: "rgba(0,0,0,0.85)", background: "rgba(0,0,0,0.9)",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
@ -84,18 +127,58 @@ export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
cursor: "zoom-out", cursor: "zoom-out",
}} }}
> >
{activePhoto.photo_url ? ( {/* eslint-disable-next-line @next/next/no-img-element */}
// eslint-disable-next-line @next/next/no-img-element <img
<img src={activePhoto.photo_url!}
src={activePhoto.photo_url} alt={activePhoto.photo_name ?? ""}
alt={activePhoto.photo_name ?? ""} style={{
style={{ maxWidth: "92vw",
maxWidth: "92vw", maxHeight: "92vh",
maxHeight: "92vh", objectFit: "contain",
objectFit: "contain", }}
}} />
/> <button
) : null} onClick={(e) => {
e.stopPropagation();
move(-1);
}}
aria-label="Предыдущее"
style={navBtnStyle("left")}
>
</button>
<button
onClick={(e) => {
e.stopPropagation();
move(1);
}}
aria-label="Следующее"
style={navBtnStyle("right")}
>
</button>
<button
onClick={(e) => {
e.stopPropagation();
setActive(null);
}}
aria-label="Закрыть"
style={{
position: "absolute",
top: 16,
right: 16,
width: 36,
height: 36,
borderRadius: 18,
border: "none",
background: "rgba(255,255,255,0.15)",
color: "#fff",
fontSize: 20,
cursor: "pointer",
}}
>
×
</button>
<div <div
style={{ style={{
position: "absolute", position: "absolute",
@ -105,8 +188,10 @@ export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
color: "#fff", color: "#fff",
textAlign: "center", textAlign: "center",
fontSize: 13, fontSize: 13,
pointerEvents: "none",
}} }}
> >
{activeIdx + 1} / {photos.length} ·{" "}
{activePhoto.period_dt?.slice(0, 10)} · {activePhoto.photo_name} {activePhoto.period_dt?.slice(0, 10)} · {activePhoto.photo_name}
</div> </div>
</div> </div>
@ -114,3 +199,21 @@ export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
</> </>
); );
} }
function navBtnStyle(side: "left" | "right"): React.CSSProperties {
return {
position: "absolute",
top: "50%",
[side]: 16,
transform: "translateY(-50%)",
width: 44,
height: 44,
borderRadius: 22,
border: "none",
background: "rgba(255,255,255,0.15)",
color: "#fff",
fontSize: 28,
lineHeight: 1,
cursor: "pointer",
};
}

File diff suppressed because one or more lines are too long