fix photo
This commit is contained in:
parent
5056438fcf
commit
c079ac2a77
5 changed files with 224 additions and 44 deletions
|
|
@ -3,6 +3,14 @@ import type { NextConfig } from "next";
|
|||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
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
|
||||
// so the browser can use same-origin relative URLs.
|
||||
// In prod Caddy intercepts these paths before they reach Next.js,
|
||||
|
|
|
|||
|
|
@ -118,16 +118,47 @@ export default function ScrapeAdminPage() {
|
|||
});
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: (task_id: string) =>
|
||||
apiFetch<{ revoked: boolean }>("/api/v1/admin/scrape/revoke", {
|
||||
method: "POST",
|
||||
headers: { "X-Admin-Token": token },
|
||||
body: JSON.stringify({ task_id, terminate: false }),
|
||||
}),
|
||||
onSuccess: () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["scrape-queue"] }),
|
||||
mutationFn: ({
|
||||
task_id,
|
||||
terminate,
|
||||
}: {
|
||||
task_id: string;
|
||||
terminate: boolean;
|
||||
}) =>
|
||||
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({
|
||||
queryKey: ["scrape-failures", failuresRunId, token],
|
||||
queryFn: () => {
|
||||
|
|
@ -420,10 +451,12 @@ export default function ScrapeAdminPage() {
|
|||
</td>
|
||||
<td style={td}>
|
||||
<button
|
||||
onClick={() => revokeMutation.mutate(t.task_id)}
|
||||
onClick={() => cancelActive(t.task_id)}
|
||||
disabled={revokeMutation.isPending}
|
||||
style={dangerBtn}
|
||||
title="SIGTERM прерывает запущенный скрейп мгновенно"
|
||||
>
|
||||
Отменить
|
||||
🛑 Прервать
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -487,10 +520,12 @@ export default function ScrapeAdminPage() {
|
|||
</td>
|
||||
<td style={td}>
|
||||
<button
|
||||
onClick={() => revokeMutation.mutate(t.task_id)}
|
||||
onClick={() => cancelReserved(t.task_id)}
|
||||
disabled={revokeMutation.isPending}
|
||||
style={dangerBtn}
|
||||
title="Удалить из очереди — worker не запустит этот таск"
|
||||
>
|
||||
Отменить
|
||||
⊘ Удалить из очереди
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useObjectInfrastructure } from "@/lib/analytics-api";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
Спорт: "#0a7a3a",
|
||||
Продукты: "#1d4ed8",
|
||||
|
|
@ -23,11 +25,16 @@ interface Props {
|
|||
export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
|
||||
const [maxDist, setMaxDist] = useState(2000);
|
||||
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,
|
||||
max_distance: maxDist,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setShown(PAGE_SIZE);
|
||||
}, [activeCat, maxDist]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const byCat: Record<string, number> = {};
|
||||
for (const p of data ?? []) {
|
||||
|
|
@ -87,6 +94,8 @@ export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
|
|||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
opacity: isFetching ? 0.7 : 1,
|
||||
transition: "opacity 120ms",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
|
@ -101,6 +110,7 @@ export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
|
|||
{centerLat && centerLon
|
||||
? `ЖК: ${centerLat.toFixed(4)}, ${centerLon.toFixed(4)}`
|
||||
: "координаты ЖК не заданы"}
|
||||
{isFetching ? " · обновление…" : ""}
|
||||
</div>
|
||||
<table
|
||||
style={{ width: "100%", borderCollapse: "collapse", fontSize: 12 }}
|
||||
|
|
@ -123,7 +133,7 @@ export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data ?? []).slice(0, 50).map((p, i) => (
|
||||
{(data ?? []).slice(0, shown).map((p, i) => (
|
||||
<tr
|
||||
key={`${p.poi_name}-${p.lat}-${p.lon}`}
|
||||
style={{
|
||||
|
|
@ -163,6 +173,30 @@ export function ObjectInfraMap({ objId, centerLat, centerLon }: Props) {
|
|||
))}
|
||||
</tbody>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -1,23 +1,46 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useObjectPhotos } from "@/lib/analytics-api";
|
||||
|
||||
const INITIAL_LIMIT = 24;
|
||||
|
||||
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 [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) {
|
||||
return <div style={{ color: "#5b6066" }}>Загрузка фото…</div>;
|
||||
}
|
||||
|
||||
const photos = data ?? [];
|
||||
const photos = (data ?? []).filter((p) => p.photo_url);
|
||||
if (photos.length === 0) {
|
||||
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 (
|
||||
<>
|
||||
|
|
@ -28,7 +51,7 @@ export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
|
|||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{photos.map((p) => (
|
||||
{visible.map((p) => (
|
||||
<button
|
||||
key={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",
|
||||
borderRadius: 6,
|
||||
overflow: "hidden",
|
||||
cursor: "pointer",
|
||||
cursor: "zoom-in",
|
||||
background: "#f3f4f6",
|
||||
aspectRatio: "4/3",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{p.photo_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={p.photo_url}
|
||||
alt={p.photo_name ?? "фото прогресса"}
|
||||
loading="lazy"
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
) : null}
|
||||
<Image
|
||||
src={p.photo_url!}
|
||||
alt={p.photo_name ?? "фото прогресса"}
|
||||
fill
|
||||
sizes="160px"
|
||||
quality={60}
|
||||
style={{ objectFit: "cover" }}
|
||||
loading="lazy"
|
||||
unoptimized={false}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
|
|
@ -62,6 +86,7 @@ export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
|
|||
color: "#fff",
|
||||
fontSize: 11,
|
||||
padding: "3px 6px",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{p.period_dt?.slice(0, 7) ?? "—"}
|
||||
|
|
@ -70,13 +95,31 @@ export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
|
|||
))}
|
||||
</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 ? (
|
||||
<div
|
||||
onClick={() => setActive(null)}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.85)",
|
||||
background: "rgba(0,0,0,0.9)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
|
|
@ -84,18 +127,58 @@ export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
|
|||
cursor: "zoom-out",
|
||||
}}
|
||||
>
|
||||
{activePhoto.photo_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={activePhoto.photo_url}
|
||||
alt={activePhoto.photo_name ?? ""}
|
||||
style={{
|
||||
maxWidth: "92vw",
|
||||
maxHeight: "92vh",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={activePhoto.photo_url!}
|
||||
alt={activePhoto.photo_name ?? ""}
|
||||
style={{
|
||||
maxWidth: "92vw",
|
||||
maxHeight: "92vh",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
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
|
||||
style={{
|
||||
position: "absolute",
|
||||
|
|
@ -105,8 +188,10 @@ export function ObjectPhotoGallery({ objId }: { objId: number | string }) {
|
|||
color: "#fff",
|
||||
textAlign: "center",
|
||||
fontSize: 13,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{activeIdx + 1} / {photos.length} ·{" "}
|
||||
{activePhoto.period_dt?.slice(0, 10)} · {activePhoto.photo_name}
|
||||
</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
Loading…
Add table
Reference in a new issue