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
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:
parent
414d185765
commit
e4cba50ab6
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
164
src/food-market.web/src/components/ProductImageGallery.tsx
Normal file
164
src/food-market.web/src/components/ProductImageGallery.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue