mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 15:04:50 +00:00
roles: multi-role foundation (manager / client / measurer / assembler)
Users.role теперь хранит CSV-список ролей: 'manager,measurer'.
Парсим, добавляем, отзываем — все через sheets.parse_roles / grant_role /
revoke_role / list_users_with_role. Старые однострочные значения работают
как раньше (legacy compat).
Backend:
- /api/me возвращает roles[] (массив), role (главная для legacy-UI),
plus capabilities {measurer, assembler} для staff
- /api/grant_role (admin-only) — добавить/отозвать роль
- /api/staff_list (manager-only) — список сотрудников по роли
(будет использоваться в dropdown «выбрать замерщика»)
- При role=staff отдаём отдельный кабинет; если у юзера нет measurer/
assembler — возвращаем error=no_staff_role
Bot:
- /start — 3-я reply-кнопка [🔧 Я сотрудник]. При тапе MiniApp получает
?role=staff и решает кабинет по capabilities.
- /whoami — сотрудник присылает свой Telegram ID, пересылает куратору
чтобы тот выдал роль через /api/grant_role.
MiniApp:
- renderStaff() — заглушка кабинета сотрудника с шапкой (имя, аватар,
список ролей) и пустым inbox («Пока пусто»). Если есть measurer —
быстрая кнопка «Сделать новый замер».
- При error=no_staff_role — экран с инструкцией как получить роль.
- CSS .staff-head / .staff-no-role.
Cache bust v=20260513f.
This commit is contained in:
parent
6d57372b0b
commit
d859e9791c
@ -105,6 +105,8 @@ async def _dispatch_post(request: Request):
|
||||
"podbor": _handle_podbor,
|
||||
"clients": _handle_clients,
|
||||
"lead": _handle_lead,
|
||||
"grant_role": _handle_grant_role,
|
||||
"staff_list": _handle_staff_list,
|
||||
"ping": lambda b: {"pong": True, "time": _now_iso()},
|
||||
"seed_admin": lambda b: _handle_seed_admin(),
|
||||
"test_ai": lambda b: _handle_test_ai(),
|
||||
@ -177,6 +179,20 @@ async def api_measurement_detail(request: Request):
|
||||
return _handle_measurement_detail(body)
|
||||
|
||||
|
||||
@app.post("/api/grant_role")
|
||||
async def api_grant_role(request: Request):
|
||||
"""Админ выдаёт роль другому пользователю.
|
||||
body: {initData, target_tg_id, role: 'measurer'|'assembler'|'manager'|'client', action: 'grant'|'revoke'}"""
|
||||
body = await _safe_json(request)
|
||||
return _handle_grant_role(body)
|
||||
|
||||
|
||||
@app.post("/api/staff_list")
|
||||
async def api_staff_list(request: Request):
|
||||
body = await _safe_json(request)
|
||||
return _handle_staff_list(body)
|
||||
|
||||
|
||||
@app.get("/api/photo/{measurement_id}/{filename}")
|
||||
async def api_photo(measurement_id: str, filename: str):
|
||||
"""Отдаёт фото замера. Защита от path traversal — только разрешённые id и имена."""
|
||||
@ -393,16 +409,48 @@ def _handle_me(body: dict[str, Any]) -> dict[str, Any]:
|
||||
tg_user = auth["user"]
|
||||
tg_id = tg_user["id"]
|
||||
start_param = body.get("startParam") or auth.get("start_param")
|
||||
explicit_role = body.get("role") if body.get("role") in ("manager", "client") else None
|
||||
explicit_role = body.get("role") if body.get("role") in ("manager", "client", "staff") else None
|
||||
user = sheets.get_or_create_user(tg_user, start_param, explicit_role)
|
||||
roles = sheets.parse_roles(user.get("role", ""))
|
||||
|
||||
if user.get("role") == "manager":
|
||||
# Staff (замерщик / сборщик) — отдельный кабинет, доступен только тем у кого роль выдана
|
||||
if explicit_role == "staff":
|
||||
has_measurer = "measurer" in roles
|
||||
has_assembler = "assembler" in roles
|
||||
if not (has_measurer or has_assembler):
|
||||
return {
|
||||
"role": "staff",
|
||||
"roles": roles,
|
||||
"error": "no_staff_role",
|
||||
"user": {
|
||||
"tg_id": tg_id,
|
||||
"full_name": user.get("full_name", ""),
|
||||
"avatar_initial": _initial(user.get("full_name") or tg_user.get("first_name", "")),
|
||||
},
|
||||
}
|
||||
full_name = user.get("full_name", "") or tg_user.get("first_name", "")
|
||||
return {
|
||||
"role": "staff",
|
||||
"roles": roles,
|
||||
"user": {
|
||||
"tg_id": tg_id,
|
||||
"full_name": full_name,
|
||||
"avatar_initial": _initial(full_name),
|
||||
},
|
||||
"capabilities": {
|
||||
"measurer": has_measurer,
|
||||
"assembler": has_assembler,
|
||||
},
|
||||
}
|
||||
|
||||
if "manager" in roles:
|
||||
m = sheets.get_manager_profile(tg_id) or {
|
||||
"full_name": user.get("full_name", ""), "salon": "",
|
||||
"is_zov_employee": False, "status": "lapsed", "active_until": None,
|
||||
}
|
||||
return {
|
||||
"role": "manager",
|
||||
"roles": roles,
|
||||
"user": {
|
||||
"tg_id": tg_id,
|
||||
"full_name": m.get("full_name") or user.get("full_name", ""),
|
||||
@ -429,6 +477,7 @@ def _handle_me(body: dict[str, Any]) -> dict[str, Any]:
|
||||
full_name = c.get("full_name") or user.get("full_name", "")
|
||||
return {
|
||||
"role": "client",
|
||||
"roles": roles,
|
||||
"user": {
|
||||
"tg_id": tg_id,
|
||||
"full_name": full_name,
|
||||
@ -764,6 +813,63 @@ def _handle_lead(body: dict[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _handle_grant_role(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Только админ может выдавать/отзывать роли."""
|
||||
cfg = get_config()
|
||||
auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
|
||||
if not auth or not auth.get("user"):
|
||||
return {"error": "invalid_init_data"}
|
||||
tg_id = auth["user"]["id"]
|
||||
if int(tg_id) != int(cfg.admin_tg_id):
|
||||
return {"error": "admin_only"}
|
||||
|
||||
target = body.get("target_tg_id")
|
||||
role = body.get("role", "").strip()
|
||||
action = body.get("action", "grant")
|
||||
if not target or not role:
|
||||
return {"error": "missing_fields"}
|
||||
try:
|
||||
target_int = int(target)
|
||||
except (TypeError, ValueError):
|
||||
return {"error": "bad_target"}
|
||||
|
||||
if role not in sheets.VALID_ROLES:
|
||||
return {"error": "unknown_role", "valid": sorted(sheets.VALID_ROLES)}
|
||||
|
||||
if action == "revoke":
|
||||
changed = sheets.revoke_role(target_int, role)
|
||||
else:
|
||||
changed = sheets.grant_role(target_int, role)
|
||||
|
||||
sheets.log_event("role_changed", tg_id, {"target": target_int, "role": role, "action": action, "changed": changed})
|
||||
|
||||
updated_user = sheets.find_user(target_int) or {}
|
||||
return {
|
||||
"ok": True,
|
||||
"target_tg_id": target_int,
|
||||
"changed": changed,
|
||||
"roles": sheets.parse_roles(updated_user.get("role", "")),
|
||||
}
|
||||
|
||||
|
||||
def _handle_staff_list(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Список сотрудников с указанной ролью — для dropdown «выбрать замерщика»."""
|
||||
cfg = get_config()
|
||||
auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
|
||||
if not auth or not auth.get("user"):
|
||||
return {"error": "invalid_init_data"}
|
||||
tg_id = auth["user"]["id"]
|
||||
user = sheets.find_user(tg_id)
|
||||
if not user or not sheets.has_role(user, "manager"):
|
||||
return {"error": "only_manager"}
|
||||
|
||||
role = (body.get("role") or "").strip()
|
||||
if role not in sheets.VALID_ROLES:
|
||||
return {"error": "unknown_role"}
|
||||
|
||||
return {"ok": True, "role": role, "staff": sheets.list_users_with_role(role)}
|
||||
|
||||
|
||||
def _handle_measurement_detail(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Возвращает один замер целиком — для детальной страницы и печати."""
|
||||
cfg = get_config()
|
||||
|
||||
@ -108,7 +108,87 @@ def find_user(tg_id: int) -> dict[str, Any] | None:
|
||||
return None
|
||||
full_name = (f"{row.get('first_name', '')} {row.get('last_name', '')}".strip()
|
||||
or row.get("tg_username", ""))
|
||||
return {**row, "full_name": full_name}
|
||||
roles = parse_roles(row.get("role", ""))
|
||||
return {**row, "full_name": full_name, "roles": roles}
|
||||
|
||||
|
||||
# ---- Multi-role helpers ----
|
||||
|
||||
VALID_ROLES = {"manager", "client", "measurer", "assembler"}
|
||||
|
||||
|
||||
def parse_roles(role_str: str) -> list[str]:
|
||||
"""Парсит CSV-роли: 'manager,measurer' → ['manager', 'measurer'].
|
||||
Старые однострочные значения тоже работают: 'manager' → ['manager']."""
|
||||
if not role_str:
|
||||
return []
|
||||
parts = [p.strip() for p in str(role_str).split(",") if p.strip()]
|
||||
return [p for p in parts if p in VALID_ROLES]
|
||||
|
||||
|
||||
def has_role(user: dict[str, Any] | None, role: str) -> bool:
|
||||
if not user:
|
||||
return False
|
||||
return role in parse_roles(user.get("role", ""))
|
||||
|
||||
|
||||
def primary_role(user: dict[str, Any] | None) -> str:
|
||||
"""Первая (главная) роль для legacy-кода: manager > measurer > assembler > client."""
|
||||
if not user:
|
||||
return ""
|
||||
roles = parse_roles(user.get("role", ""))
|
||||
for r in ("manager", "measurer", "assembler", "client"):
|
||||
if r in roles:
|
||||
return r
|
||||
return roles[0] if roles else ""
|
||||
|
||||
|
||||
def grant_role(tg_id: int, role: str) -> bool:
|
||||
"""Добавляет роль пользователю (если её ещё нет). Возвращает True если что-то изменилось."""
|
||||
if role not in VALID_ROLES:
|
||||
return False
|
||||
user = find_user(tg_id)
|
||||
if not user:
|
||||
return False
|
||||
current = parse_roles(user.get("role", ""))
|
||||
if role in current:
|
||||
return False
|
||||
current.append(role)
|
||||
return update_cell_by_key("Users", "tg_id", tg_id, "role", ",".join(current))
|
||||
|
||||
|
||||
def revoke_role(tg_id: int, role: str) -> bool:
|
||||
user = find_user(tg_id)
|
||||
if not user:
|
||||
return False
|
||||
current = parse_roles(user.get("role", ""))
|
||||
if role not in current:
|
||||
return False
|
||||
current.remove(role)
|
||||
new_val = ",".join(current) if current else "client" # fallback роль
|
||||
return update_cell_by_key("Users", "tg_id", tg_id, "role", new_val)
|
||||
|
||||
|
||||
def list_users_with_role(role: str) -> list[dict[str, Any]]:
|
||||
"""Все пользователи, у которых есть указанная роль (для dropdown «выбрать замерщика»)."""
|
||||
s = sheet("Users")
|
||||
rows = s.get_all_values()
|
||||
if not rows:
|
||||
return []
|
||||
headers = rows[0]
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in rows[1:]:
|
||||
row = dict(zip(headers, r + [""] * (len(headers) - len(r))))
|
||||
if role in parse_roles(row.get("role", "")):
|
||||
full_name = (f"{row.get('first_name', '')} {row.get('last_name', '')}".strip()
|
||||
or row.get("tg_username", ""))
|
||||
out.append({
|
||||
"tg_id": row.get("tg_id"),
|
||||
"full_name": full_name,
|
||||
"tg_username": row.get("tg_username", ""),
|
||||
"roles": parse_roles(row.get("role", "")),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def get_or_create_user(tg_user: dict[str, Any], start_param: str | None,
|
||||
@ -122,14 +202,18 @@ def get_or_create_user(tg_user: dict[str, Any], start_param: str | None,
|
||||
|
||||
if existing:
|
||||
update_cell_by_key("Users", "tg_id", tg_id, "last_seen_at", now_str)
|
||||
# Админ всегда manager
|
||||
if tg_id == admin_id and existing.get("role") != "manager":
|
||||
update_cell_by_key("Users", "tg_id", tg_id, "role", "manager")
|
||||
# Админ всегда имеет роль manager (могут быть и другие)
|
||||
if tg_id == admin_id and not has_role(existing, "manager"):
|
||||
grant_role(tg_id, "manager")
|
||||
ensure_admin_manager(tg_user)
|
||||
existing["role"] = "manager"
|
||||
elif explicit_role and tg_id != admin_id and existing.get("role") != explicit_role:
|
||||
update_cell_by_key("Users", "tg_id", tg_id, "role", explicit_role)
|
||||
existing["role"] = explicit_role
|
||||
existing["roles"] = parse_roles((find_user(tg_id) or {}).get("role", ""))
|
||||
# explicit_role из query (?role=manager|client|staff) — не перетираем уже выданные роли,
|
||||
# только добавляем если человек впервые открыл эту секцию
|
||||
elif explicit_role and explicit_role in VALID_ROLES and not has_role(existing, explicit_role):
|
||||
# client/manager — стандартные роли любой может получить через выбор в боте
|
||||
if explicit_role in ("manager", "client"):
|
||||
grant_role(tg_id, explicit_role)
|
||||
existing["roles"] = parse_roles((find_user(tg_id) or {}).get("role", ""))
|
||||
return existing
|
||||
|
||||
# Новый пользователь
|
||||
@ -148,7 +232,7 @@ def get_or_create_user(tg_user: dict[str, Any], start_param: str | None,
|
||||
tg_user.get("username", ""),
|
||||
tg_user.get("first_name", ""),
|
||||
tg_user.get("last_name", ""),
|
||||
role,
|
||||
role, # хранится как CSV; для новых = одна роль
|
||||
now_str,
|
||||
now_str,
|
||||
invite_code,
|
||||
|
||||
@ -36,7 +36,7 @@ def _wapp(miniapp_url: str, role: str) -> WebAppInfo:
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Reply keyboard — выбор роли. Оба кнопки сразу открывают MiniApp.
|
||||
# Reply keyboard — выбор роли. Три кнопки, все WebApp.
|
||||
# ============================================================
|
||||
|
||||
def role_choice_kb(miniapp_url: str) -> ReplyKeyboardMarkup:
|
||||
@ -46,6 +46,9 @@ def role_choice_kb(miniapp_url: str) -> ReplyKeyboardMarkup:
|
||||
KeyboardButton(text="👤 Я менеджер", web_app=_wapp(miniapp_url, "manager")),
|
||||
KeyboardButton(text="🏠 Я клиент", web_app=_wapp(miniapp_url, "client")),
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="🔧 Я сотрудник", web_app=_wapp(miniapp_url, "staff")),
|
||||
],
|
||||
],
|
||||
resize_keyboard=True,
|
||||
is_persistent=True,
|
||||
@ -61,7 +64,8 @@ def role_choice_kb(miniapp_url: str) -> ReplyKeyboardMarkup:
|
||||
async def cmd_start(message: Message, config: Config) -> None:
|
||||
await message.answer(
|
||||
"👋 Здравствуйте, я бот-помощник от Руслана ВАСИЛЬЕВА.\n\n"
|
||||
"Выберите, кто вы — кабинет откроется одним тапом.",
|
||||
"Выберите, кто вы — кабинет откроется одним тапом.\n\n"
|
||||
"<i>«Сотрудник» — для замерщиков и сборщиков ЗОВ. Если вы менеджер или клиент — выбирайте свою роль.</i>",
|
||||
reply_markup=role_choice_kb(config.miniapp_url),
|
||||
)
|
||||
|
||||
@ -74,3 +78,21 @@ async def cmd_menu(message: Message, config: Config) -> None:
|
||||
@router.message(Command("hide"))
|
||||
async def cmd_hide(message: Message) -> None:
|
||||
await message.answer("Клавиатура скрыта. Вернуть — /menu", reply_markup=ReplyKeyboardRemove())
|
||||
|
||||
|
||||
# ============================================================
|
||||
# /whoami — сотрудник присылает свой ID куратору, чтобы тот выдал роль
|
||||
# ============================================================
|
||||
|
||||
@router.message(Command("whoami"))
|
||||
async def cmd_whoami(message: Message) -> None:
|
||||
user = message.from_user
|
||||
if not user:
|
||||
return
|
||||
await message.answer(
|
||||
f"<b>Ваш Telegram ID:</b> <code>{user.id}</code>\n"
|
||||
f"Username: @{user.username or '—'}\n"
|
||||
f"Имя: {user.first_name or ''} {user.last_name or ''}".strip()
|
||||
+ "\n\n"
|
||||
"<i>Перешлите это сообщение куратору @wasrusgen чтобы вам выдали роль замерщика/сборщика.</i>"
|
||||
)
|
||||
|
||||
@ -343,6 +343,72 @@ function buildMenu(items) {
|
||||
return menu;
|
||||
}
|
||||
|
||||
/* ----------------- Staff (замерщик / сборщик) ----------------- */
|
||||
function renderStaff(me) {
|
||||
app.innerHTML = "";
|
||||
|
||||
if (me.error === "no_staff_role") {
|
||||
app.appendChild(el(`
|
||||
<div class="staff-no-role">
|
||||
<div class="staff-no-role-ico">🔒</div>
|
||||
<h2 class="display-title">У вас нет<br><span class="accent">прав сотрудника</span></h2>
|
||||
<p class="lede">Чтобы получить роль замерщика или сборщика — отправьте куратору ваш Telegram ID.</p>
|
||||
<div class="block">
|
||||
<div class="kv"><span>Ваш ID</span><strong><code>${me.user?.tg_id || "—"}</code></strong></div>
|
||||
<div class="kv"><span>Имя</span><strong>${me.user?.full_name || "—"}</strong></div>
|
||||
</div>
|
||||
<p class="muted" style="text-align:center;margin-top:16px;">
|
||||
В боте отправьте <code>/whoami</code> и перешлите ответ
|
||||
<a href="https://t.me/wasrusgen" target="_blank">@wasrusgen</a>.
|
||||
</p>
|
||||
</div>
|
||||
`));
|
||||
return;
|
||||
}
|
||||
|
||||
const caps = me.capabilities || {};
|
||||
const labels = [];
|
||||
if (caps.measurer) labels.push("замерщик");
|
||||
if (caps.assembler) labels.push("сборщик");
|
||||
const subtitle = labels.length ? labels.join(" · ") : "сотрудник";
|
||||
|
||||
app.appendChild(el(`
|
||||
<div class="staff-head">
|
||||
<div class="staff-avatar">${me.user?.avatar_initial || "?"}</div>
|
||||
<div>
|
||||
<div class="kicker">${subtitle}</div>
|
||||
<h2 class="display-title">${me.user?.full_name || "Сотрудник"}</h2>
|
||||
</div>
|
||||
</div>
|
||||
`));
|
||||
|
||||
// Заглушка — реальный инбокс заявок будет в следующем коммите
|
||||
const inbox = el(`
|
||||
<section class="block">
|
||||
<div class="block-head">📥 Входящие заявки</div>
|
||||
<div class="empty" style="padding:24px 12px;text-align:center;color:var(--muted);">
|
||||
Пока пусто — менеджеры ещё не назначили вам заявки.<br>
|
||||
Здесь появятся ${caps.measurer ? "замеры" : ""}${caps.measurer && caps.assembler ? " и " : ""}${caps.assembler ? "сборки" : ""}.
|
||||
</div>
|
||||
</section>
|
||||
`);
|
||||
app.appendChild(inbox);
|
||||
|
||||
// Если у сотрудника также есть роль measurer — показываем быструю кнопку «Сделать замер»
|
||||
if (caps.measurer) {
|
||||
const quick = el(`
|
||||
<div class="podbor-cta-row" style="margin-top:16px;">
|
||||
<button class="btn-primary" id="newMeasure">📐 Сделать новый замер</button>
|
||||
</div>
|
||||
`);
|
||||
quick.querySelector("#newMeasure").addEventListener("click", () => {
|
||||
haptic && haptic("impact");
|
||||
location.hash = "#/measure";
|
||||
});
|
||||
app.appendChild(quick);
|
||||
}
|
||||
}
|
||||
|
||||
function renderError() {
|
||||
app.innerHTML = "";
|
||||
app.appendChild(el(`
|
||||
@ -403,8 +469,13 @@ async function init() {
|
||||
hideSplash();
|
||||
return;
|
||||
}
|
||||
if (me.role === "manager") renderManager(me);
|
||||
else renderClient(me);
|
||||
if (me.role === "staff") {
|
||||
renderStaff(me);
|
||||
} else if (me.role === "manager") {
|
||||
renderManager(me);
|
||||
} else {
|
||||
renderClient(me);
|
||||
}
|
||||
hideSplash();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@ -424,7 +495,8 @@ function routeByHash() {
|
||||
// Главный экран по роли
|
||||
const me = window.__zovMe;
|
||||
if (!me) { init(); return; }
|
||||
if (me.role === "manager") renderManager(me);
|
||||
if (me.role === "staff") renderStaff(me);
|
||||
else if (me.role === "manager") renderManager(me);
|
||||
else renderClient(me);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1989,6 +1989,46 @@
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* ===== Кабинет сотрудника (замерщик/сборщик) ===== */
|
||||
.staff-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin: 16px 4px 22px;
|
||||
}
|
||||
.staff-avatar {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
background: var(--walnut, #6B4A2B);
|
||||
color: var(--paper, #FBF7F0);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
font-family: var(--font-display, "Newsreader", serif);
|
||||
}
|
||||
.staff-no-role {
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.staff-no-role-ico {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.staff-no-role .block {
|
||||
margin-top: 18px;
|
||||
text-align: left;
|
||||
}
|
||||
.staff-no-role code {
|
||||
background: var(--warm, rgba(107, 74, 43, 0.08));
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-mono, "JetBrains Mono", monospace);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ===== Фото замера ===== */
|
||||
.photo-uploader { margin: 12px 0 14px; }
|
||||
|
||||
|
||||
@ -12,8 +12,8 @@
|
||||
<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&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&display=swap">
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
<link rel="stylesheet" href="assets/styles.css?v=20260513e">
|
||||
<link rel="stylesheet" href="assets/podbor.css?v=20260513e">
|
||||
<link rel="stylesheet" href="assets/styles.css?v=20260513f">
|
||||
<link rel="stylesheet" href="assets/podbor.css?v=20260513f">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Splash — за пределами #app, render-функции его не смывают -->
|
||||
@ -34,12 +34,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<main id="app"></main>
|
||||
<script src="assets/icons.js?v=20260513e"></script>
|
||||
<script src="assets/podbor.config.js?v=20260513e"></script>
|
||||
<script src="assets/podbor.picts.js?v=20260513e"></script>
|
||||
<script src="assets/podbor.js?v=20260513e"></script>
|
||||
<script src="assets/clients.js?v=20260513e"></script>
|
||||
<script src="assets/measurements.js?v=20260513e"></script>
|
||||
<script src="assets/app.js?v=20260513e"></script>
|
||||
<script src="assets/icons.js?v=20260513f"></script>
|
||||
<script src="assets/podbor.config.js?v=20260513f"></script>
|
||||
<script src="assets/podbor.picts.js?v=20260513f"></script>
|
||||
<script src="assets/podbor.js?v=20260513f"></script>
|
||||
<script src="assets/clients.js?v=20260513f"></script>
|
||||
<script src="assets/measurements.js?v=20260513f"></script>
|
||||
<script src="assets/app.js?v=20260513f"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user