mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 17:04:48 +00:00
feat: загрузка фото к замеру из карточки менеджера
- backend: _handle_measurement_add_photos — дозагрузка фото (до 20 шт за раз) сохраняет в PHOTOS_DIR, дописывает в колонку photos через update_cell_by_key - clients.js: renderPhotoUploadBlock — выбор файлов, превью сетка 3 колонки, конвертация в dataURL, POST /api/measurement_add_photos, счётчик в заголовке - podbor.css: .photo-upload-block, .photo-preview-grid, .photo-upload-actions - index.html: cache bump → v=20260514p Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
5186afe0e0
commit
7a25ee3d36
@ -119,6 +119,7 @@ async def _dispatch_post(request: Request):
|
|||||||
"client_update": _handle_client_update,
|
"client_update": _handle_client_update,
|
||||||
"client_delete": _handle_client_delete,
|
"client_delete": _handle_client_delete,
|
||||||
"measurement_design_upload": _handle_measurement_design_upload,
|
"measurement_design_upload": _handle_measurement_design_upload,
|
||||||
|
"measurement_add_photos": _handle_measurement_add_photos,
|
||||||
"measurement_decision": _handle_measurement_decision,
|
"measurement_decision": _handle_measurement_decision,
|
||||||
"measurement_set_status": _handle_measurement_set_status,
|
"measurement_set_status": _handle_measurement_set_status,
|
||||||
"manager_pending": _handle_manager_pending,
|
"manager_pending": _handle_manager_pending,
|
||||||
@ -275,6 +276,12 @@ async def api_measurement_set_status(request: Request):
|
|||||||
return _handle_measurement_set_status(body)
|
return _handle_measurement_set_status(body)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/measurement_add_photos")
|
||||||
|
async def api_measurement_add_photos(request: Request):
|
||||||
|
body = await _safe_json(request)
|
||||||
|
return _handle_measurement_add_photos(body)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/manager_pending")
|
@app.post("/api/manager_pending")
|
||||||
async def api_manager_pending(request: Request):
|
async def api_manager_pending(request: Request):
|
||||||
body = await _safe_json(request)
|
body = await _safe_json(request)
|
||||||
@ -1755,6 +1762,63 @@ def _handle_measurement_decision(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
return {"ok": True, "id": measurement_id, "decision": decision}
|
return {"ok": True, "id": measurement_id, "decision": decision}
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_measurement_add_photos(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Дозагрузка фото к существующему замеру.
|
||||||
|
body: {initData, measurement_id, photos: [{data_url, label?}, ...]}
|
||||||
|
label: before | after | general | extra (необязательно)."""
|
||||||
|
cfg = get_config()
|
||||||
|
auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
|
||||||
|
if not auth or not auth.get("user"):
|
||||||
|
unsafe = body.get("initDataUnsafe") or {}
|
||||||
|
if isinstance(unsafe, dict) and unsafe.get("user", {}).get("id"):
|
||||||
|
auth = {"user": unsafe["user"]}
|
||||||
|
else:
|
||||||
|
return {"error": "invalid_init_data"}
|
||||||
|
tg_id = auth["user"]["id"]
|
||||||
|
user = sheets.find_user(tg_id)
|
||||||
|
if not user:
|
||||||
|
return {"error": "user_not_found"}
|
||||||
|
|
||||||
|
measurement_id = (body.get("measurement_id") or "").strip()
|
||||||
|
if not measurement_id or not _SAFE_ID_RE.match(measurement_id):
|
||||||
|
return {"error": "missing_measurement_id"}
|
||||||
|
|
||||||
|
row = sheets.find_row("Measurements", "id", measurement_id)
|
||||||
|
if not row:
|
||||||
|
return {"error": "measurement_not_found"}
|
||||||
|
|
||||||
|
is_owner = (str(row.get("manager_tg_id")) == str(tg_id)
|
||||||
|
or str(row.get("requested_by_tg_id")) == str(tg_id)
|
||||||
|
or str(row.get("assigned_to_tg_id")) == str(tg_id))
|
||||||
|
if not is_owner and not sheets.has_role(user, "manager"):
|
||||||
|
return {"error": "forbidden"}
|
||||||
|
|
||||||
|
photos_input = body.get("photos") or []
|
||||||
|
if not isinstance(photos_input, list) or not photos_input:
|
||||||
|
return {"error": "no_photos"}
|
||||||
|
|
||||||
|
existing = [p for p in (row.get("photos") or "").split(",") if p]
|
||||||
|
saved: list[str] = []
|
||||||
|
for i, p in enumerate(photos_input[:20]):
|
||||||
|
if not isinstance(p, dict):
|
||||||
|
continue
|
||||||
|
data_url = p.get("data_url") or ""
|
||||||
|
label = (p.get("label") or "extra").strip()
|
||||||
|
if not isinstance(data_url, str) or not data_url.startswith("data:"):
|
||||||
|
continue
|
||||||
|
fn = _save_measurement_photo(measurement_id, len(existing) + i, data_url, kind=label)
|
||||||
|
if fn:
|
||||||
|
saved.append(fn)
|
||||||
|
|
||||||
|
if not saved:
|
||||||
|
return {"error": "no_photos_saved"}
|
||||||
|
|
||||||
|
all_photos = existing + saved
|
||||||
|
sheets.update_cell_by_key("Measurements", "id", measurement_id, "photos", ",".join(all_photos))
|
||||||
|
sheets.log_event("measurement_photos_added", tg_id, {"id": measurement_id, "count": len(saved)})
|
||||||
|
return {"ok": True, "id": measurement_id, "saved": saved, "total": len(all_photos)}
|
||||||
|
|
||||||
|
|
||||||
def _handle_measurement_set_status(body: dict[str, Any]) -> dict[str, Any]:
|
def _handle_measurement_set_status(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Менеджер меняет статус замера из карточки.
|
"""Менеджер меняет статус замера из карточки.
|
||||||
body: {initData, measurement_id, status}
|
body: {initData, measurement_id, status}
|
||||||
|
|||||||
@ -1326,10 +1326,111 @@ const Clients = (function () {
|
|||||||
root.appendChild(list);
|
root.appendChild(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Фото: загрузка дополнительных фото
|
||||||
|
root.appendChild(renderPhotoUploadBlock(m));
|
||||||
|
|
||||||
// Чертежи / DWG
|
// Чертежи / DWG
|
||||||
root.appendChild(renderDesignFilesBlock(m));
|
root.appendChild(renderDesignFilesBlock(m));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===================== Загрузка фото замера ===================== */
|
||||||
|
|
||||||
|
function renderPhotoUploadBlock(m) {
|
||||||
|
const section = el(`
|
||||||
|
<section class="block photo-upload-block">
|
||||||
|
<div class="block-head">📷 Фото${m.photos && m.photos.length ? ` · уже ${m.photos.length} шт.` : ""}</div>
|
||||||
|
<div class="photo-preview-grid" id="photoPreviewGrid"></div>
|
||||||
|
<label class="design-upload-label" style="margin-top:8px;">
|
||||||
|
Добавить фото (до/после/общий план)
|
||||||
|
</label>
|
||||||
|
<input type="file" class="design-upload-input" id="photoFileInput"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/heic,image/*"
|
||||||
|
multiple capture="environment">
|
||||||
|
<div class="photo-upload-actions" id="photoUploadActions" style="display:none;margin-top:10px;">
|
||||||
|
<button class="btn-primary" id="photoUploadBtn" type="button">Загрузить фото</button>
|
||||||
|
<button class="btn-secondary" id="photoClearBtn" type="button">Очистить</button>
|
||||||
|
</div>
|
||||||
|
<div class="design-upload-status" id="photoUploadStatus"></div>
|
||||||
|
</section>
|
||||||
|
`);
|
||||||
|
|
||||||
|
const previewGrid = section.querySelector("#photoPreviewGrid");
|
||||||
|
const fileInput = section.querySelector("#photoFileInput");
|
||||||
|
const actionsRow = section.querySelector("#photoUploadActions");
|
||||||
|
const uploadBtn = section.querySelector("#photoUploadBtn");
|
||||||
|
const clearBtn = section.querySelector("#photoClearBtn");
|
||||||
|
const statusEl = section.querySelector("#photoUploadStatus");
|
||||||
|
let pendingFiles = [];
|
||||||
|
|
||||||
|
fileInput.addEventListener("change", () => {
|
||||||
|
pendingFiles = Array.from(fileInput.files || []);
|
||||||
|
previewGrid.innerHTML = "";
|
||||||
|
if (!pendingFiles.length) { actionsRow.style.display = "none"; return; }
|
||||||
|
actionsRow.style.display = "";
|
||||||
|
pendingFiles.forEach(f => {
|
||||||
|
const url = URL.createObjectURL(f);
|
||||||
|
const tile = el(`<div class="photo-tile static"><img src="${url}" alt=""></div>`);
|
||||||
|
previewGrid.appendChild(tile);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
clearBtn.addEventListener("click", () => {
|
||||||
|
pendingFiles = [];
|
||||||
|
fileInput.value = "";
|
||||||
|
previewGrid.innerHTML = "";
|
||||||
|
actionsRow.style.display = "none";
|
||||||
|
statusEl.textContent = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadBtn.addEventListener("click", async () => {
|
||||||
|
if (!pendingFiles.length) return;
|
||||||
|
uploadBtn.disabled = true; uploadBtn.textContent = `Загружаем (0/${pendingFiles.length})…`;
|
||||||
|
statusEl.textContent = "";
|
||||||
|
const photos = [];
|
||||||
|
for (let i = 0; i < pendingFiles.length; i++) {
|
||||||
|
const f = pendingFiles[i];
|
||||||
|
const dataUrl = await new Promise((res, rej) => {
|
||||||
|
const r = new FileReader();
|
||||||
|
r.onload = () => res(r.result);
|
||||||
|
r.onerror = rej;
|
||||||
|
r.readAsDataURL(f);
|
||||||
|
});
|
||||||
|
photos.push({ data_url: dataUrl, label: "extra" });
|
||||||
|
uploadBtn.textContent = `Загружаем (${i + 1}/${pendingFiles.length})…`;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BACKEND_URL}/api/measurement_add_photos`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
initData: tg?.initData || "",
|
||||||
|
initDataUnsafe: tg?.initDataUnsafe || null,
|
||||||
|
measurement_id: m.id,
|
||||||
|
photos,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.ok) {
|
||||||
|
haptic && haptic("success");
|
||||||
|
statusEl.innerHTML = `<span class="ct-ok">✓ Загружено ${data.saved.length} фото. Всего: ${data.total}</span>`;
|
||||||
|
pendingFiles = []; fileInput.value = "";
|
||||||
|
previewGrid.innerHTML = "";
|
||||||
|
actionsRow.style.display = "none";
|
||||||
|
uploadBtn.disabled = false; uploadBtn.textContent = "Загрузить фото";
|
||||||
|
// Обновляем заголовок блока
|
||||||
|
section.querySelector(".block-head").textContent = `📷 Фото · всего ${data.total} шт.`;
|
||||||
|
} else {
|
||||||
|
statusEl.innerHTML = `<span class="ct-err">Ошибка: ${escHtml(data.msg || data.error)}</span>`;
|
||||||
|
uploadBtn.disabled = false; uploadBtn.textContent = "Загрузить фото";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
statusEl.innerHTML = `<span class="ct-err">Сеть: ${escHtml(e.message)}</span>`;
|
||||||
|
uploadBtn.disabled = false; uploadBtn.textContent = "Загрузить фото";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===================== Смена статуса замера ===================== */
|
/* ===================== Смена статуса замера ===================== */
|
||||||
|
|
||||||
async function setMeasurementStatus(measurementId, newStatus, container) {
|
async function setMeasurementStatus(measurementId, newStatus, container) {
|
||||||
|
|||||||
@ -3412,6 +3412,21 @@
|
|||||||
.geo-ok { color: #27AE60; }
|
.geo-ok { color: #27AE60; }
|
||||||
.geo-warn { color: #C0392B; }
|
.geo-warn { color: #C0392B; }
|
||||||
|
|
||||||
|
/* ===== Загрузка фото замера ===== */
|
||||||
|
.photo-upload-block {}
|
||||||
|
.photo-preview-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.photo-upload-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.photo-upload-actions .btn-primary { flex: 2; }
|
||||||
|
.photo-upload-actions .btn-secondary { flex: 1; }
|
||||||
|
|
||||||
/* ===== Статус замера ===== */
|
/* ===== Статус замера ===== */
|
||||||
.mz-status-badge {
|
.mz-status-badge {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@ -12,14 +12,14 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&family=Cormorant+Garamond:ital,wght@1,400;1,500;1,600&family=Caveat:wght@500;700&display=swap">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&family=Cormorant+Garamond:ital,wght@1,400;1,500;1,600&family=Caveat:wght@500;700&display=swap">
|
||||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||||
<link rel="stylesheet" href="assets/styles.css?v=20260514o">
|
<link rel="stylesheet" href="assets/styles.css?v=20260514p">
|
||||||
<link rel="stylesheet" href="assets/podbor.css?v=20260514o">
|
<link rel="stylesheet" href="assets/podbor.css?v=20260514p">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Splash — лого @wasrusgen1 + опилки (16) + вращающийся диск -->
|
<!-- Splash — лого @wasrusgen1 + опилки (16) + вращающийся диск -->
|
||||||
<div class="loader splash" id="splash">
|
<div class="loader splash" id="splash">
|
||||||
<div class="brand-logo-wrap">
|
<div class="brand-logo-wrap">
|
||||||
<img class="brand-logo" src="assets/wasrusgen-logo.svg?v=20260514o" alt="@wasrusgen1">
|
<img class="brand-logo" src="assets/wasrusgen-logo.svg?v=20260514p" alt="@wasrusgen1">
|
||||||
<div class="splash-dust" aria-hidden="true">
|
<div class="splash-dust" aria-hidden="true">
|
||||||
<span class="dust d1"></span> <span class="dust d2"></span>
|
<span class="dust d1"></span> <span class="dust d2"></span>
|
||||||
<span class="dust d3"></span> <span class="dust d4"></span>
|
<span class="dust d3"></span> <span class="dust d4"></span>
|
||||||
@ -35,15 +35,15 @@
|
|||||||
<div class="brand-tagline-gold">CRM</div>
|
<div class="brand-tagline-gold">CRM</div>
|
||||||
</div>
|
</div>
|
||||||
<main id="app"></main>
|
<main id="app"></main>
|
||||||
<script src="assets/icons.js?v=20260514o"></script>
|
<script src="assets/icons.js?v=20260514p"></script>
|
||||||
<script src="assets/podbor.config.js?v=20260514o"></script>
|
<script src="assets/podbor.config.js?v=20260514p"></script>
|
||||||
<script src="assets/podbor.picts.js?v=20260514o"></script>
|
<script src="assets/podbor.picts.js?v=20260514p"></script>
|
||||||
<script src="assets/podbor.js?v=20260514o"></script>
|
<script src="assets/podbor.js?v=20260514p"></script>
|
||||||
<script src="assets/clients.js?v=20260514o"></script>
|
<script src="assets/clients.js?v=20260514p"></script>
|
||||||
<script src="assets/zamer-picts.js?v=20260514o"></script>
|
<script src="assets/zamer-picts.js?v=20260514p"></script>
|
||||||
<script src="assets/measurements.js?v=20260514o"></script>
|
<script src="assets/measurements.js?v=20260514p"></script>
|
||||||
<script src="assets/request.js?v=20260514o"></script>
|
<script src="assets/request.js?v=20260514p"></script>
|
||||||
<script src="assets/assembly.js?v=20260514o"></script>
|
<script src="assets/assembly.js?v=20260514p"></script>
|
||||||
<script src="assets/app.js?v=20260514o"></script>
|
<script src="assets/app.js?v=20260514p"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user