feat(product-images): загрузка на диск сервера + галерея с лайтбоксом
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 31s
CI / Web (React + Vite) (push) Successful in 23s
Docker Images / API image (push) Successful in 42s
Docker Images / Web image (push) Successful in 27s
Docker Images / Deploy stage (push) Successful in 18s

Backend:
- ProductImagesController: GET list / POST multipart upload /
  DELETE / POST set-main.
- Файлы лежат в $ContentRoot/uploads/products/{productId}/{guid}.{ext}
  (volume /opt/food-market-data/uploads:/app/uploads в compose).
- В БД хранится относительный URL /uploads/products/{id}/{file}.
- UseStaticFiles на /uploads — публичная раздача (без auth).
- Допустимые расширения: jpg/jpeg/png/webp/gif, до 10 МБ.
- При первой загрузке картинка становится основной; Product.ImageUrl
  синхронизируется с "основной".
- Удаление основной переводит "основной" флаг на следующую оставшуюся.

Web-nginx: /uploads/ проксируется на api:8080.

Web UI:
- Компонент <ProductImageGallery>: превьюшки 80×80 в грид,
  при наведении — кнопки "сделать основным" и "удалить",
  клик на превью → fullscreen lightbox с навигацией ←→ и счётчиком.
- В ProductEditPage убран инпут "URL изображения" (был технической
  строкой для копипаста), вместо него блок "Изображения" с галереей.
  Показывается только для уже сохранённого товара (есть id).

Docker compose: добавлен bind-mount /opt/food-market-data/uploads.
This commit is contained in:
nurdotnet 2026-04-24 11:12:27 +05:00
parent 414d185765
commit e4cba50ab6
6 changed files with 336 additions and 3 deletions

View file

@ -37,6 +37,7 @@ services:
volumes: volumes:
- api-data:/app/App_Data - api-data:/app/App_Data
- api-logs:/app/logs - api-logs:/app/logs
- /opt/food-market-data/uploads:/app/uploads
web: web:
image: ${REGISTRY:-127.0.0.1:5001}/food-market-web:${WEB_TAG:-latest} image: ${REGISTRY:-127.0.0.1:5001}/food-market-web:${WEB_TAG:-latest}

View file

@ -40,6 +40,13 @@ server {
proxy_pass http://api:8080; proxy_pass http://api:8080;
} }
# Статика изображений товаров api раздаёт /uploads/... из volume.
location /uploads/ {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
# SPA fallback all other routes return index.html # SPA fallback all other routes return index.html
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;

View file

@ -0,0 +1,147 @@
using foodmarket.Application.Common.Tenancy;
using foodmarket.Domain.Catalog;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Catalog;
/// <summary>Локальное хранилище изображений товаров: multipart upload →
/// /app/uploads/products/{productId}/{guid}.{ext}; в БД лежит относительный путь
/// типа "/uploads/products/{id}/{file}", раздаётся nginx'ом как статика.</summary>
[ApiController]
[Authorize]
[Route("api/catalog/products/{productId:guid}/images")]
public class ProductImagesController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
private readonly IWebHostEnvironment _env;
public ProductImagesController(AppDbContext db, ITenantContext tenant, IWebHostEnvironment env)
{
_db = db;
_tenant = tenant;
_env = env;
}
private static readonly HashSet<string> AllowedExt = new(StringComparer.OrdinalIgnoreCase)
{ ".jpg", ".jpeg", ".png", ".webp", ".gif" };
private const long MaxBytes = 10 * 1024 * 1024;
private string UploadRoot => Path.Combine(_env.ContentRootPath, "uploads", "products");
public record ImageDto(Guid Id, string Url, bool IsMain, int SortOrder);
[HttpGet]
public async Task<ActionResult<IReadOnlyList<ImageDto>>> List(Guid productId, CancellationToken ct)
{
var product = await _db.Products.FirstOrDefaultAsync(p => p.Id == productId, ct);
if (product is null) return NotFound();
var images = await _db.ProductImages
.Where(i => i.ProductId == productId)
.OrderBy(i => i.SortOrder)
.Select(i => new ImageDto(i.Id, i.Url, i.IsMain, i.SortOrder))
.ToListAsync(ct);
return images;
}
[HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")]
[RequestSizeLimit(MaxBytes)]
public async Task<ActionResult<ImageDto>> Upload(Guid productId, IFormFile file, CancellationToken ct)
{
if (file is null || file.Length == 0) return BadRequest(new { error = "No file." });
if (file.Length > MaxBytes) return BadRequest(new { error = $"File too large (max {MaxBytes / 1024 / 1024} MB)." });
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExt.Contains(ext)) return BadRequest(new { error = "Only JPG/PNG/WEBP/GIF are allowed." });
var product = await _db.Products.FirstOrDefaultAsync(p => p.Id == productId, ct);
if (product is null) return NotFound();
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var dir = Path.Combine(UploadRoot, productId.ToString());
Directory.CreateDirectory(dir);
var fileName = $"{Guid.NewGuid():N}{ext}";
var fullPath = Path.Combine(dir, fileName);
using (var stream = System.IO.File.Create(fullPath))
{
await file.CopyToAsync(stream, ct);
}
var relativeUrl = $"/uploads/products/{productId}/{fileName}";
var sortOrder = await _db.ProductImages.Where(i => i.ProductId == productId).CountAsync(ct);
var isMain = sortOrder == 0; // первое загруженное — основное
var entity = new ProductImage
{
OrganizationId = orgId,
ProductId = productId,
Url = relativeUrl,
IsMain = isMain,
SortOrder = sortOrder,
};
_db.ProductImages.Add(entity);
if (isMain) product.ImageUrl = relativeUrl;
await _db.SaveChangesAsync(ct);
return new ImageDto(entity.Id, entity.Url, entity.IsMain, entity.SortOrder);
}
[HttpDelete("{imageId:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")]
public async Task<IActionResult> Delete(Guid productId, Guid imageId, CancellationToken ct)
{
var image = await _db.ProductImages.FirstOrDefaultAsync(i => i.Id == imageId && i.ProductId == productId, ct);
if (image is null) return NotFound();
// Удаляем файл с диска (не фейлим если отсутствует).
var fileName = Path.GetFileName(image.Url);
var fullPath = Path.Combine(UploadRoot, productId.ToString(), fileName);
try { if (System.IO.File.Exists(fullPath)) System.IO.File.Delete(fullPath); }
catch { /* ignore */ }
_db.ProductImages.Remove(image);
// Если удалили основное — назначаем основным оставшуюся первую.
if (image.IsMain)
{
var next = await _db.ProductImages
.Where(i => i.ProductId == productId && i.Id != imageId)
.OrderBy(i => i.SortOrder)
.FirstOrDefaultAsync(ct);
if (next is not null)
{
next.IsMain = true;
var product = await _db.Products.FirstOrDefaultAsync(p => p.Id == productId, ct);
if (product is not null) product.ImageUrl = next.Url;
}
else
{
var product = await _db.Products.FirstOrDefaultAsync(p => p.Id == productId, ct);
if (product is not null) product.ImageUrl = null;
}
}
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{imageId:guid}/main"), Authorize(Roles = "Admin,Manager,Storekeeper")]
public async Task<IActionResult> SetMain(Guid productId, Guid imageId, CancellationToken ct)
{
var image = await _db.ProductImages.FirstOrDefaultAsync(i => i.Id == imageId && i.ProductId == productId, ct);
if (image is null) return NotFound();
await _db.ProductImages.Where(i => i.ProductId == productId).ExecuteUpdateAsync(
s => s.SetProperty(i => i.IsMain, false), ct);
image.IsMain = true;
var product = await _db.Products.FirstOrDefaultAsync(p => p.Id == productId, ct);
if (product is not null) product.ImageUrl = image.Url;
await _db.SaveChangesAsync(ct);
return NoContent();
}
}

View file

@ -150,6 +150,16 @@
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
// Статика товарных изображений: физически /app/uploads (volume в compose),
// публичный URL /uploads/... — раздаются public, без auth.
var uploadsDir = System.IO.Path.Combine(app.Environment.ContentRootPath, "uploads");
System.IO.Directory.CreateDirectory(uploadsDir);
app.UseStaticFiles(new Microsoft.AspNetCore.Builder.StaticFileOptions
{
FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(uploadsDir),
RequestPath = "/uploads",
});
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.UseSwagger(); app.UseSwagger();

View file

@ -0,0 +1,164 @@
import { useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Trash2, Star, Upload, ChevronLeft, ChevronRight, X } from 'lucide-react'
import { api } from '@/lib/api'
import { Button } from '@/components/Button'
interface ImageDto { id: string; url: string; isMain: boolean; sortOrder: number }
interface Props { productId: string }
export function ProductImageGallery({ productId }: Props) {
const qc = useQueryClient()
const fileInput = useRef<HTMLInputElement>(null)
const [lightboxIdx, setLightboxIdx] = useState<number | null>(null)
const url = `/api/catalog/products/${productId}/images`
const list = useQuery({
queryKey: [url],
queryFn: async () => (await api.get<ImageDto[]>(url)).data,
})
const upload = useMutation({
mutationFn: async (file: File) => {
const fd = new FormData()
fd.append('file', file)
return (await api.post<ImageDto>(url, fd, { headers: { 'Content-Type': 'multipart/form-data' } })).data
},
onSuccess: () => qc.invalidateQueries({ queryKey: [url] }),
})
const remove = useMutation({
mutationFn: async (id: string) => { await api.delete(`${url}/${id}`) },
onSuccess: () => qc.invalidateQueries({ queryKey: [url] }),
})
const setMain = useMutation({
mutationFn: async (id: string) => { await api.post(`${url}/${id}/main`) },
onSuccess: () => qc.invalidateQueries({ queryKey: [url] }),
})
const images = list.data ?? []
const onFiles = async (files: FileList | null) => {
if (!files) return
for (const f of Array.from(files)) {
await upload.mutateAsync(f)
}
if (fileInput.current) fileInput.current.value = ''
}
const closeLb = () => setLightboxIdx(null)
const lbPrev = () => setLightboxIdx((i) => (i === null ? null : (i - 1 + images.length) % images.length))
const lbNext = () => setLightboxIdx((i) => (i === null ? null : (i + 1) % images.length))
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<input
ref={fileInput}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
multiple
hidden
onChange={(e) => onFiles(e.target.files)}
/>
<Button type="button" variant="secondary" size="sm" onClick={() => fileInput.current?.click()} disabled={upload.isPending}>
<Upload className="w-3.5 h-3.5" /> {upload.isPending ? 'Загружаю…' : 'Загрузить'}
</Button>
<span className="text-xs text-slate-500">JPG/PNG/WEBP/GIF, до 10 МБ</span>
</div>
{images.length === 0 ? (
<div className="text-sm text-slate-400">Изображений нет.</div>
) : (
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2">
{images.map((img, i) => (
<div key={img.id} className="relative group aspect-square rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-900">
<button
type="button"
onClick={() => setLightboxIdx(i)}
className="w-full h-full"
title="Увеличить"
>
<img
src={img.url}
alt=""
className="w-full h-full object-cover"
loading="lazy"
/>
</button>
{img.isMain && (
<div className="absolute top-1 left-1 bg-amber-400 text-amber-900 text-[10px] px-1 rounded font-semibold">основное</div>
)}
<div className="absolute bottom-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{!img.isMain && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); setMain.mutate(img.id) }}
className="p-1 rounded bg-white/90 hover:bg-white text-amber-600"
title="Сделать основным"
>
<Star className="w-3.5 h-3.5" />
</button>
)}
<button
type="button"
onClick={(e) => { e.stopPropagation(); if (confirm('Удалить это изображение?')) remove.mutate(img.id) }}
className="p-1 rounded bg-white/90 hover:bg-white text-red-600"
title="Удалить"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
)}
{lightboxIdx !== null && images[lightboxIdx] && (
<div
className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center"
onClick={closeLb}
>
<button
type="button"
onClick={(e) => { e.stopPropagation(); closeLb() }}
className="absolute top-4 right-4 text-white hover:text-slate-300"
title="Закрыть"
>
<X className="w-6 h-6" />
</button>
{images.length > 1 && (
<>
<button
type="button"
onClick={(e) => { e.stopPropagation(); lbPrev() }}
className="absolute left-4 text-white hover:text-slate-300 p-2"
>
<ChevronLeft className="w-8 h-8" />
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); lbNext() }}
className="absolute right-4 text-white hover:text-slate-300 p-2"
>
<ChevronRight className="w-8 h-8" />
</button>
</>
)}
<img
src={images[lightboxIdx].url}
alt=""
className="max-w-[90vw] max-h-[90vh] object-contain"
onClick={(e) => e.stopPropagation()}
/>
<div className="absolute bottom-4 text-white/80 text-sm">
{lightboxIdx + 1} / {images.length}
</div>
</div>
)}
</div>
)
}

View file

@ -10,6 +10,7 @@ import {
} from '@/lib/useLookups' } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings' import { useOrgSettings } from '@/lib/useOrgSettings'
import { BarcodeType, Packaging, type Product } from '@/lib/types' import { BarcodeType, Packaging, type Product } from '@/lib/types'
import { ProductImageGallery } from '@/components/ProductImageGallery'
interface PriceRow { id?: string; priceTypeId: string; amount: number; currencyId: string } interface PriceRow { id?: string; priceTypeId: string; amount: number; currencyId: string }
interface BarcodeRow { id?: string; code: string; type: BarcodeType; isPrimary: boolean } interface BarcodeRow { id?: string; code: string; type: BarcodeType; isPrimary: boolean }
@ -269,9 +270,6 @@ export function ProductEditPage() {
{suppliers.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)} {suppliers.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select> </Select>
</Field> </Field>
<Field label="URL изображения">
<TextInput value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} />
</Field>
</Grid> </Grid>
<Grid cols={3}> <Grid cols={3}>
<Field label="Фасовка"> <Field label="Фасовка">
@ -359,6 +357,12 @@ export function ProductEditPage() {
)} )}
</Section> </Section>
{!isNew && id && (
<Section title="Изображения">
<ProductImageGallery productId={id} />
</Section>
)}
<Section <Section
title="Штрихкоды" title="Штрихкоды"
action={<Button type="button" variant="secondary" size="sm" onClick={addBarcode}><Plus className="w-3.5 h-3.5" /> Добавить</Button>} action={<Button type="button" variant="secondary" size="sm" onClick={addBarcode}><Plus className="w-3.5 h-3.5" /> Добавить</Button>}