Initial deployable Flask application
This commit is contained in:
+101
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
DrHider — Managed Flask приложение на платформе Штурвал.
|
||||
|
||||
Приложение создаётся фабрикой create_app().
|
||||
Штурвал запускает Flask самостоятельно (без Dockerfile/gunicorn).
|
||||
|
||||
Роуты разнесены по blueprint'ам:
|
||||
- main_bp: GET / — веб-интерфейс
|
||||
- health_bp: GET /health — проверка живости
|
||||
- api_bp: POST /api/drhider — обфускация документов
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from flask import Flask
|
||||
|
||||
# Добавляем корень проекта в sys.path для импорта пакета drhider
|
||||
_sys_path_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if _sys_path_root not in sys.path:
|
||||
sys.path.insert(0, _sys_path_root)
|
||||
|
||||
# Версия приложения (меняется при изменениях)
|
||||
VERSION = "0.0.79"
|
||||
|
||||
|
||||
def setup_logging():
|
||||
"""Настроить логирование. Уровень из env LOG_LEVEL (DEBUG/INFO/WARNING).
|
||||
|
||||
Логи пишутся в stderr (для локального запуска) И в файл /tmp/drhider.log
|
||||
(для кубера, где stdout/stderr контейнера может не попадать в kubectl logs).
|
||||
Путь файла можно переопределить env LOG_FILE.
|
||||
"""
|
||||
level_name = os.environ.get("LOG_LEVEL", "INFO").upper()
|
||||
level = getattr(logging, level_name, logging.INFO)
|
||||
log_file = os.environ.get("LOG_FILE", "/tmp/drhider.log")
|
||||
fmt = logging.Formatter("%(asctime)s %(levelname)s [%(name)s] %(message)s", "%Y-%m-%d %H:%M:%S")
|
||||
root = logging.getLogger()
|
||||
root.setLevel(level)
|
||||
root.handlers.clear()
|
||||
sh = logging.StreamHandler(sys.stderr)
|
||||
sh.setLevel(level)
|
||||
sh.setFormatter(fmt)
|
||||
root.addHandler(sh)
|
||||
try:
|
||||
fh = logging.FileHandler(log_file, encoding="utf-8")
|
||||
fh.setLevel(level)
|
||||
fh.setFormatter(fmt)
|
||||
root.addHandler(fh)
|
||||
except Exception as e: # файл не открылся — не падаем
|
||||
sys.stderr.write(f"[app] cannot open log file {log_file}: {e}\n")
|
||||
for name in ("drhider", "app", "routes", "session", __name__):
|
||||
logging.getLogger(name).setLevel(level)
|
||||
log = logging.getLogger("app")
|
||||
log.info("Logging configured, level=%s, version=%s, file=%s", level_name, VERSION, log_file)
|
||||
|
||||
|
||||
def create_app():
|
||||
"""Создать и настроить Flask-приложение.
|
||||
|
||||
Штурвал запускает из директории site/:
|
||||
from app import app
|
||||
|
||||
Поэтому routes/ доступен как обычный подпакет.
|
||||
|
||||
Returns:
|
||||
Экземпляр Flask с зарегистрированными blueprint'ами.
|
||||
"""
|
||||
setup_logging()
|
||||
app = Flask(__name__)
|
||||
|
||||
# Конфигурация
|
||||
app.config["VERSION"] = VERSION
|
||||
app.config["MAX_CONTENT_LENGTH"] = 50 * 1024 * 1024 # 50 MB (лимит на один файл)
|
||||
|
||||
# Регистрируем blueprint'ы
|
||||
from routes import register_routes
|
||||
|
||||
register_routes(app)
|
||||
|
||||
# Запрещаем кэширование — F5 всегда грузит свежую страницу
|
||||
@app.after_request
|
||||
def no_cache(response):
|
||||
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
response.headers["Expires"] = "0"
|
||||
return response
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# Экземпляр приложения — точка входа для Штурвала
|
||||
app = create_app()
|
||||
|
||||
# Штурвал запускает: cd site && python app.py
|
||||
if __name__ == "__main__":
|
||||
from waitress import serve
|
||||
serve(app, host="0.0.0.0", port=5000, threads=8)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
from .main_bp import main_bp
|
||||
from .health_bp import health_bp
|
||||
from .api_bp import api_bp
|
||||
from upload.backend.upload_refs import create_upload_refs_blueprint
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
"""Зарегистрировать все blueprint'ы на Flask-приложении.
|
||||
|
||||
Args:
|
||||
app: Экземпляр Flask-приложения
|
||||
"""
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(health_bp)
|
||||
app.register_blueprint(api_bp)
|
||||
# Слой 2 (закачка через ВМ) — переиспользуемый blueprint из модуля upload
|
||||
app.register_blueprint(create_upload_refs_blueprint({
|
||||
"apiPrefix": "/api",
|
||||
"vmUploadPrefix": "https://upload.sinefo.ru/drhider-upload/",
|
||||
"maxFileBytes": 50 * 1024 * 1024,
|
||||
"maxSessionBytes": 500 * 1024 * 1024,
|
||||
"ttlSeconds": 30 * 60,
|
||||
"pullRetries": 3,
|
||||
"pullRetryDelay": 2,
|
||||
}))
|
||||
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
Blueprint: API DrHider.
|
||||
|
||||
Five endpoints:
|
||||
- POST /api/upload — upload one file -> {session_id}
|
||||
- GET /api/process_stream/<sid> — SSE: process files, per-file progress
|
||||
- POST /api/process/<sid> — process all session files -> {status:"done"} (legacy)
|
||||
- GET /api/download/<sid> — download ZIP (with timestamp name)
|
||||
- GET /api/csv/<sid> — download CSV separately
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
import zipfile
|
||||
import traceback
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, request, send_file, jsonify, Response, stream_with_context
|
||||
|
||||
from drhider import obfuscate_files, LLMClient
|
||||
from upload.backend.session import (create_session, add_file, get_files, store_result,
|
||||
get_result, store_csv, get_csv, cleanup, file_count,
|
||||
MAX_FILE_BYTES, pause_ttl, resume_ttl,
|
||||
request_cancel, get_cancel_event)
|
||||
from upload.backend.upload_refs import safe_name
|
||||
|
||||
api_bp = Blueprint("api", __name__, url_prefix="/api")
|
||||
log = logging.getLogger("routes.api_bp")
|
||||
|
||||
def _disconnect_exceptions():
|
||||
"""Исключения, означающие отключение клиента SSE."""
|
||||
return (GeneratorExit, BrokenPipeError, ConnectionResetError)
|
||||
|
||||
|
||||
@api_bp.route("/upload", methods=["POST"])
|
||||
def upload():
|
||||
"""Upload files to session (один или несколько)."""
|
||||
sid = request.form.get("session", "")
|
||||
if not sid:
|
||||
sid = create_session()
|
||||
uploaded = request.files.getlist("files")
|
||||
if not uploaded:
|
||||
log.warning("upload: no files, sid=%s", sid)
|
||||
return jsonify({"ok": False, "error": "No file"}), 400
|
||||
|
||||
added = 0
|
||||
had_unnamed = False
|
||||
for f in uploaded:
|
||||
name = safe_name(f.filename)
|
||||
if not name:
|
||||
had_unnamed = True
|
||||
continue
|
||||
data = f.read()
|
||||
log.info("upload: sid=%s file=%r size=%d", sid, name, len(data))
|
||||
if len(data) > MAX_FILE_BYTES:
|
||||
log.warning("upload: file exceeds %dMB, skipped sid=%s file=%r size=%d",
|
||||
MAX_FILE_BYTES // (1024 * 1024), sid, name, len(data))
|
||||
continue
|
||||
if not add_file(sid, name, data):
|
||||
log.warning("upload: session not found/limit, sid=%s file=%r", sid, f.filename)
|
||||
return jsonify({"ok": False, "error": "Session not found"}), 404
|
||||
added += 1
|
||||
|
||||
if added == 0:
|
||||
err = "No filename" if had_unnamed else "No file"
|
||||
log.warning("upload: %s, sid=%s", err, sid)
|
||||
return jsonify({"ok": False, "error": err}), 400
|
||||
|
||||
log.info("upload: done sid=%s added=%d total=%d", sid, added, file_count(sid))
|
||||
return jsonify({"ok": True, "session": sid, "count": file_count(sid)})
|
||||
|
||||
|
||||
@api_bp.route("/session_files/<sid>", methods=["GET"])
|
||||
def session_files(sid):
|
||||
"""Отладка: список файлов сессии с размерами (для теста загрузки)."""
|
||||
files = get_files(sid)
|
||||
if files is None:
|
||||
return jsonify({"ok": False, "error": "Session not found"}), 404
|
||||
return jsonify({
|
||||
"ok": True, "count": len(files),
|
||||
"files": [{"name": n, "size": len(b)} for n, b in files],
|
||||
})
|
||||
|
||||
|
||||
@api_bp.route("/cancel/<sid>", methods=["POST"])
|
||||
def cancel(sid):
|
||||
"""Запросить мягкое прерывание обработки сессии.
|
||||
|
||||
Воркер останавливается на ближайшей границе файла (текущий добирается),
|
||||
собирает частичный результат (готовые файлы + mapping) и шлёт SSE-событие `cancelled`.
|
||||
"""
|
||||
if request_cancel(sid):
|
||||
log.info("cancel: requested sid=%s", sid)
|
||||
return jsonify({"ok": True}), 200
|
||||
log.warning("cancel: session not found sid=%s", sid)
|
||||
return jsonify({"ok": False, "error": "Session not found"}), 404
|
||||
|
||||
|
||||
@api_bp.route("/process_stream/<sid>", methods=["GET"])
|
||||
def process_stream(sid):
|
||||
"""SSE: process all session files, streaming per-file progress.
|
||||
|
||||
Все файлы обрабатываются ЕДИНЫМ вызовом obfuscate_files (общий mapping,
|
||||
согласованные токены). Обработка идёт в отдельном потоке; прогресс
|
||||
передаётся через очередь. Разрыв соединения клиента корректно
|
||||
перехватывается и останавливает генератор (и воркер — через cancel_event).
|
||||
"""
|
||||
files = get_files(sid)
|
||||
if files is None:
|
||||
return jsonify({"ok": False, "error": "Session not found"}), 404
|
||||
if not files:
|
||||
return jsonify({"ok": False, "error": "No files"}), 400
|
||||
|
||||
all_files = [(fname, content, "") for fname, content in files]
|
||||
log.info("process_stream: start sid=%s files=%d", sid, len(all_files))
|
||||
# Сессия живёт, пока идёт обработка (TTL возобновляется в finally генератора)
|
||||
pause_ttl(sid)
|
||||
|
||||
def generate():
|
||||
llm = LLMClient()
|
||||
q = queue.Queue()
|
||||
cancel = threading.Event() # локальный: разрыв клиента (стоп heartbeat)
|
||||
cancel_event = get_cancel_event(sid) # из сессии: мягкая отмена (кнопка «Прервать»)
|
||||
|
||||
# Состояние для глобальной ETA (символы)
|
||||
eta = {"total_chars": 0, "done_chars": 0, "cur_chars": 0, "cur_total": 0, "cur_done": 0}
|
||||
_TOKENS_PER_CHAR = 8.0 # эмпирический коэффициент символов -> токенов LLM
|
||||
|
||||
def progress(phase, idx, total_, name, elapsed):
|
||||
q.put(("progress", phase, idx, name, total_, elapsed))
|
||||
|
||||
# Состояние для per-file ETA (чанки)
|
||||
fstate = {"t0": 0.0, "total": 0, "done": 0}
|
||||
|
||||
def file_progress(event, fname, **fields):
|
||||
if event == "file_start":
|
||||
fstate["t0"] = time.time()
|
||||
fstate["total"] = fields.get("chunks", 0)
|
||||
fstate["done"] = 0
|
||||
elif event == "file_chunk":
|
||||
fstate["done"] = fields.get("chunks_done", 0)
|
||||
elapsed = time.time() - fstate["t0"]
|
||||
rate = fstate["done"] / elapsed if elapsed > 0 else 0
|
||||
rem = fstate["total"] - fstate["done"]
|
||||
if rate > 0:
|
||||
fields["eta_sec"] = max(0, round(rem / rate))
|
||||
q.put(("file", event, fname, fields))
|
||||
|
||||
def worker():
|
||||
log.info("worker: start sid=%s files=%d", sid, len(all_files))
|
||||
t0 = datetime.utcnow()
|
||||
try:
|
||||
zip_data, csv_str, meta = obfuscate_files(
|
||||
all_files, llm_client=llm, progress_cb=progress,
|
||||
cancel_event=cancel_event, file_progress=file_progress,
|
||||
)
|
||||
stats = {
|
||||
"tokens": llm.tokens_total,
|
||||
"llm_sec": round(llm.llm_sec, 1),
|
||||
}
|
||||
dt = (datetime.utcnow() - t0).total_seconds()
|
||||
if meta and meta.get("cancelled"):
|
||||
log.info("worker: cancelled sid=%s in %.1fs processed=%d/%d",
|
||||
sid, dt, meta.get("processed", 0), meta.get("total", 0))
|
||||
q.put(("cancelled", zip_data, csv_str, stats, meta))
|
||||
else:
|
||||
log.info("worker: done sid=%s in %.1fs tokens=%d llm_sec=%.1f zip_len=%d",
|
||||
sid, dt, llm.tokens_total, llm.llm_sec, len(zip_data))
|
||||
q.put(("result", zip_data, csv_str, stats))
|
||||
except Exception as e:
|
||||
log.error("worker: exception sid=%s: %r\n%s", sid, e, traceback.format_exc())
|
||||
q.put(("error", repr(e)))
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
log.debug("process_stream: worker thread started sid=%s", sid)
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
evt = q.get(timeout=1)
|
||||
except queue.Empty:
|
||||
if cancel.is_set():
|
||||
log.info("process_stream: cancelled sid=%s (generator exit)", sid)
|
||||
return
|
||||
# Heartbeat: живая статистика LLM + глобальная ETA
|
||||
tokens = llm.tokens_total
|
||||
elapsed = llm.llm_elapsed_now()
|
||||
eta_sec = None
|
||||
if eta["total_chars"] > 0 and tokens > 0 and elapsed > 0:
|
||||
est_total_tokens = eta["total_chars"] * _TOKENS_PER_CHAR
|
||||
rate = tokens / elapsed
|
||||
if rate > 0:
|
||||
eta_sec = max(0, int((est_total_tokens - tokens) / rate))
|
||||
done_chars = eta["done_chars"]
|
||||
if eta["cur_total"] > 0:
|
||||
done_chars += int(eta["cur_chars"] * (eta["cur_done"] / eta["cur_total"]))
|
||||
try:
|
||||
yield (
|
||||
f"event: llm\n"
|
||||
f"data: {json.dumps({'active': llm.llm_active, 'elapsed': round(elapsed, 1), 'tokens': tokens, 'eta_sec': eta_sec, 'done_chars': done_chars, 'total_chars': eta['total_chars']})}\n\n"
|
||||
)
|
||||
except _disconnect_exceptions() as e:
|
||||
log.warning("process_stream: disconnect during heartbeat sid=%s err=%r", sid, e)
|
||||
cancel.set()
|
||||
if cancel_event:
|
||||
cancel_event.set()
|
||||
return
|
||||
continue
|
||||
|
||||
kind = evt[0]
|
||||
|
||||
if kind == "progress":
|
||||
_, phase, idx, name, total_, elapsed = evt
|
||||
log.debug("process_stream: event=%s idx=%d name=%r elapsed=%s sid=%s",
|
||||
phase, idx, name, elapsed, sid)
|
||||
try:
|
||||
yield (
|
||||
f"event: {phase}\n"
|
||||
f"data: {json.dumps({'idx': idx, 'name': name, 'total': total_, 'elapsed': elapsed})}\n\n"
|
||||
)
|
||||
except _disconnect_exceptions() as e:
|
||||
log.warning("process_stream: disconnect on progress sid=%s phase=%s err=%r", sid, phase, e)
|
||||
cancel.set()
|
||||
if cancel_event:
|
||||
cancel_event.set()
|
||||
return
|
||||
|
||||
elif kind == "file":
|
||||
_, event, fname, fields = evt
|
||||
# Обновляем состояние для глобальной ETA
|
||||
if event == "extract_done":
|
||||
eta["total_chars"] = fields.get("total_chars", 0)
|
||||
elif event == "file_start":
|
||||
eta["cur_chars"] = fields.get("chars", 0)
|
||||
eta["cur_total"] = fields.get("chunks", 0)
|
||||
eta["cur_done"] = 0
|
||||
elif event == "file_chunk":
|
||||
eta["cur_done"] = fields.get("chunks_done", 0)
|
||||
elif event == "file_done":
|
||||
eta["done_chars"] += eta["cur_chars"]
|
||||
eta["cur_chars"] = 0
|
||||
eta["cur_total"] = 0
|
||||
eta["cur_done"] = 0
|
||||
try:
|
||||
yield (
|
||||
f"event: {event}\n"
|
||||
f"data: {json.dumps({'name': fname, **fields})}\n\n"
|
||||
)
|
||||
except _disconnect_exceptions() as e:
|
||||
log.warning("process_stream: disconnect on file event sid=%s err=%r", sid, e)
|
||||
cancel.set()
|
||||
if cancel_event:
|
||||
cancel_event.set()
|
||||
return
|
||||
|
||||
elif kind == "result":
|
||||
_, zip_data, csv_str, stats = evt
|
||||
log.info("process_stream: result sid=%s, storing result", sid)
|
||||
store_result(sid, zip_data)
|
||||
if csv_str:
|
||||
store_csv(sid, csv_str)
|
||||
count = 0
|
||||
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
||||
count = len([n for n in zf.namelist() if n != "mapping.csv"])
|
||||
log.info("process_stream: complete sid=%s count=%d stats=%r", sid, count, stats)
|
||||
try:
|
||||
yield (
|
||||
f"event: complete\n"
|
||||
f"data: {json.dumps({'total': count, **stats})}\n\n"
|
||||
)
|
||||
except _disconnect_exceptions() as e:
|
||||
log.warning("process_stream: disconnect on complete sid=%s err=%r", sid, e)
|
||||
return
|
||||
return
|
||||
|
||||
elif kind == "cancelled":
|
||||
_, zip_data, csv_str, stats, meta = evt
|
||||
log.info("process_stream: cancelled sid=%s, storing partial result", sid)
|
||||
store_result(sid, zip_data)
|
||||
if csv_str:
|
||||
store_csv(sid, csv_str)
|
||||
try:
|
||||
yield (
|
||||
f"event: cancelled\n"
|
||||
f"data: {json.dumps({'saved': meta.get('processed', 0), 'total': meta.get('total', 0), **stats})}\n\n"
|
||||
)
|
||||
except _disconnect_exceptions() as e:
|
||||
log.warning("process_stream: disconnect on cancelled sid=%s err=%r", sid, e)
|
||||
return
|
||||
return
|
||||
|
||||
elif kind == "error":
|
||||
_, msg = evt
|
||||
log.error("process_stream: error event sid=%s msg=%r", sid, msg)
|
||||
try:
|
||||
yield f"event: proc_error\ndata: {json.dumps({'error': msg})}\n\n"
|
||||
except _disconnect_exceptions() as e:
|
||||
log.warning("process_stream: disconnect on error sid=%s err=%r", sid, e)
|
||||
return
|
||||
return
|
||||
finally:
|
||||
resume_ttl(sid)
|
||||
log.debug("process_stream: generator exit sid=%s, TTL resumed", sid)
|
||||
|
||||
return Response(
|
||||
stream_with_context(generate()),
|
||||
content_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
|
||||
)
|
||||
|
||||
|
||||
@api_bp.route("/process/<sid>", methods=["POST"])
|
||||
def process(sid):
|
||||
"""Process all session files -> ZIP (единым вызовом, общий mapping)."""
|
||||
files = get_files(sid)
|
||||
if files is None:
|
||||
return jsonify({"ok": False, "error": "Session not found"}), 404
|
||||
if not files:
|
||||
return jsonify({"ok": False, "error": "No files"}), 400
|
||||
try:
|
||||
llm = LLMClient()
|
||||
all_files = [(fname, content, "") for fname, content in files]
|
||||
pause_ttl(sid)
|
||||
try:
|
||||
zip_data, csv_str, _ = obfuscate_files(all_files, llm_client=llm)
|
||||
finally:
|
||||
resume_ttl(sid)
|
||||
store_result(sid, zip_data)
|
||||
if csv_str:
|
||||
store_csv(sid, csv_str)
|
||||
return jsonify({"ok": True, "status": "done"})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"ok": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route("/download/<sid>", methods=["GET"])
|
||||
def download(sid):
|
||||
"""Download result and cleanup session."""
|
||||
zip_data = get_result(sid)
|
||||
if zip_data is None:
|
||||
return jsonify({"ok": False, "error": "Not found"}), 404
|
||||
ts = (datetime.now() + timedelta(hours=3)).strftime("%Y-%m-%d_%H-%M-%S")
|
||||
return send_file(io.BytesIO(zip_data), mimetype="application/zip",
|
||||
as_attachment=True, download_name=f"drhider_{ts}.zip")
|
||||
|
||||
|
||||
@api_bp.route("/csv/<sid>", methods=["GET"])
|
||||
def csv_download(sid):
|
||||
"""Download CSV separately."""
|
||||
csv_str = get_csv(sid)
|
||||
if csv_str is None:
|
||||
return jsonify({"ok": False, "error": "Not found"}), 404
|
||||
ts = (datetime.now() + timedelta(hours=3)).strftime("%Y-%m-%d_%H-%M-%S")
|
||||
buf = io.BytesIO()
|
||||
buf.write('\ufeff'.encode('utf-8') + csv_str.encode('utf-8'))
|
||||
buf.seek(0)
|
||||
return send_file(buf, mimetype="text/csv",
|
||||
as_attachment=True, download_name=f"mapping_{ts}.csv")
|
||||
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
Blueprint: health-check (GET /health).
|
||||
|
||||
Используется платформой Штурвал для проверки живости приложения.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, current_app
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Blueprint: health check
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
health_bp = Blueprint("health", __name__)
|
||||
|
||||
|
||||
@health_bp.route("/health")
|
||||
def health():
|
||||
"""Эндпоинт проверки живости.
|
||||
|
||||
Возвращает JSON с версией приложения.
|
||||
Используется платформой для readiness/liveness probes.
|
||||
|
||||
Returns:
|
||||
{"ok": true, "version": "X.Y.Z"}
|
||||
"""
|
||||
version = current_app.config.get("VERSION", "0.0.0")
|
||||
return jsonify({"ok": True, "version": version})
|
||||
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Blueprint: главная страница (GET /).
|
||||
|
||||
Отдаёт HTML-интерфейс DrHider.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from flask import Blueprint, render_template, current_app, send_from_directory
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Blueprint: главная страница
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# Корень модуля upload/frontend — для раздачи ES-модулей браузеру
|
||||
_ROOT_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
_UPLOAD_FRONTEND_DIR = os.path.join(_ROOT_DIR, "upload", "frontend")
|
||||
_DIST_DIR = os.path.join(_ROOT_DIR, "dist")
|
||||
|
||||
main_bp = Blueprint("main", __name__)
|
||||
|
||||
|
||||
@main_bp.route("/")
|
||||
def index():
|
||||
"""Главная страница — веб-интерфейс DrHider.
|
||||
|
||||
Передаёт в шаблон текущую версию приложения.
|
||||
"""
|
||||
version = current_app.config.get("VERSION", "0.0.0")
|
||||
return render_template("index.html", version=version)
|
||||
|
||||
|
||||
@main_bp.route("/upload/<path:filename>")
|
||||
def upload_frontend(filename):
|
||||
"""Раздаёт ES-модули переиспользуемого слоя загрузки (upload/frontend)."""
|
||||
return send_from_directory(_UPLOAD_FRONTEND_DIR, filename)
|
||||
|
||||
|
||||
@main_bp.route("/file-picker/<path:filename>")
|
||||
def file_picker_dist(filename):
|
||||
"""Отдаёт собранный бандл из каталога dist."""
|
||||
return send_from_directory(_DIST_DIR, filename)
|
||||
|
||||
|
||||
@main_bp.route("/dist/<path:filename>")
|
||||
def dist_files(filename):
|
||||
"""Отдаёт файлы из каталога dist."""
|
||||
return send_from_directory(_DIST_DIR, filename)
|
||||
Vendored
+1194
File diff suppressed because it is too large
Load Diff
Vendored
+1218
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Creator: CorelDRAW -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="82.3711mm" height="18.2443mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
|
||||
viewBox="0 0 8221.93 1821.07"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
|
||||
<defs>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
.fil0 {fill:#001C34}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Слой_x0020_1">
|
||||
<metadata id="CorelCorpID_0Corel-Layer"/>
|
||||
<g id="_2283355517888">
|
||||
<path class="fil0" d="M477.49 479.69l-413.51 0 -63.98 276.48 178.44 0 1.17 1028.71 0 26.75 0.03 0 276.39 0 0 -871.72c0,-101.28 82.43,-183.69 183.75,-183.69l589.25 3.44c101.34,0 183.76,82.46 183.76,183.74l0 871.72 276.44 0 0 -871.72c0,-253.77 -206.46,-460.15 -460.05,-460.15l-589.52 -3.53 -162.17 0.45 0 -0.48z"/>
|
||||
<path class="fil0" d="M6664.65 1813.37l1181.04 0c212.14,0 376.24,-175.01 376.24,-387.08 0,-212.13 -172.55,-384.68 -384.69,-384.68l-653.66 0c-60.22,0 -109.16,-48.94 -109.16,-109.18l0 -66.18c0,-60.19 48.94,-109.14 109.16,-109.14l695.76 0 63.74 -275.49 -759.5 0c-212.13,0 -384.66,172.53 -384.66,384.63l0 66.18c0,212.13 172.53,384.67 384.66,384.67l653.66 0c60.2,0 109.2,49 109.2,109.19 0,60.13 -49,116.1 -109.2,116.1l-1118.9 0 -53.68 270.98z"/>
|
||||
<path class="fil0" d="M6200.79 483.5l-723.21 0c-215.88,0 -391.47,175.6 -391.47,391.51l0 485.5c0,248.38 202.05,450.4 450.4,450.4l989.38 0 62.13 -268.51 -1051.51 0c-96.41,0 -174.95,-85.37 -174.95,-181.88l-1.5 -39.46 923.62 0 308.51 -1.84 0 -237.95 0 -206.26c0,-215.91 -175.59,-391.51 -391.41,-391.51zm115.96 560.28l-955.19 0 0 -168.77c0,-63.96 52.07,-116.05 116.02,-116.05l723.21 0c63.91,0 115.96,52.08 115.96,116.05l0 168.77z"/>
|
||||
<path class="fil0" d="M4683.49 1194.24l0 168.28c0,100.92 -81.7,178.37 -182.67,178.37l-589.87 1.23c-93.18,0 -170.28,-69.96 -181.63,-160.09l0 -458.13c11.36,-90.1 88.46,-160.07 181.63,-160.07l589.44 3.5c100.97,0 183.1,82.18 183.1,183.1l0 30.42 0 213.4zm-1230.2 -345.53l-0.89 -795.03 276.91 -53.68 0 526.07c55.74,-24.16 117.03,-37.74 181.5,-37.74l589.71 3.5c252.69,0 458.42,205.72 458.42,458.59l0 30.42 0 213.4 0 168.28c0,252.86 -205.73,458.54 -458.42,458.54l-589.71 -3.5c-252.7,0 -458.41,-205.67 -458.41,-458.56l0 -171.61 0 -240.48 0.89 -98.2z"/>
|
||||
<path class="fil0" d="M3041.25 1150.84l0 204.28c0,100.93 -82.15,183.05 -183.11,183.05l-582.11 -3.46c-100.96,0 -183.11,-82.16 -183.11,-183.08l0 -67.42 1.48 0.24 0 -862.65 -276.91 53.68 0.12 103.41 -0.12 0.04 0 772.69c0,252.87 205.72,458.54 458.39,458.54l582.4 3.5c252.67,0 458.4,-205.68 458.4,-458.55l0 -70.79 0.71 0.12 0 -862.65 -276.91 53.68 0.76 675.35z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
Executable
+25
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Creator: CorelDRAW -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="82.3711mm" height="18.2443mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
|
||||
viewBox="0 0 8221.93 1821.07"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
|
||||
<defs>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
.fil0 {fill:#001C34}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Слой_x0020_1">
|
||||
<metadata id="CorelCorpID_0Corel-Layer"/>
|
||||
<g id="_2283355517888">
|
||||
<path class="fil0" d="M477.49 479.69l-413.51 0 -63.98 276.48 178.44 0 1.17 1028.71 0 26.75 0.03 0 276.39 0 0 -871.72c0,-101.28 82.43,-183.69 183.75,-183.69l589.25 3.44c101.34,0 183.76,82.46 183.76,183.74l0 871.72 276.44 0 0 -871.72c0,-253.77 -206.46,-460.15 -460.05,-460.15l-589.52 -3.53 -162.17 0.45 0 -0.48z"/>
|
||||
<path class="fil0" d="M6664.65 1813.37l1181.04 0c212.14,0 376.24,-175.01 376.24,-387.08 0,-212.13 -172.55,-384.68 -384.69,-384.68l-653.66 0c-60.22,0 -109.16,-48.94 -109.16,-109.18l0 -66.18c0,-60.19 48.94,-109.14 109.16,-109.14l695.76 0 63.74 -275.49 -759.5 0c-212.13,0 -384.66,172.53 -384.66,384.63l0 66.18c0,212.13 172.53,384.67 384.66,384.67l653.66 0c60.2,0 109.2,49 109.2,109.19 0,60.13 -49,116.1 -109.2,116.1l-1118.9 0 -53.68 270.98z"/>
|
||||
<path class="fil0" d="M6200.79 483.5l-723.21 0c-215.88,0 -391.47,175.6 -391.47,391.51l0 485.5c0,248.38 202.05,450.4 450.4,450.4l989.38 0 62.13 -268.51 -1051.51 0c-96.41,0 -174.95,-85.37 -174.95,-181.88l-1.5 -39.46 923.62 0 308.51 -1.84 0 -237.95 0 -206.26c0,-215.91 -175.59,-391.51 -391.41,-391.51zm115.96 560.28l-955.19 0 0 -168.77c0,-63.96 52.07,-116.05 116.02,-116.05l723.21 0c63.91,0 115.96,52.08 115.96,116.05l0 168.77z"/>
|
||||
<path class="fil0" d="M4683.49 1194.24l0 168.28c0,100.92 -81.7,178.37 -182.67,178.37l-589.87 1.23c-93.18,0 -170.28,-69.96 -181.63,-160.09l0 -458.13c11.36,-90.1 88.46,-160.07 181.63,-160.07l589.44 3.5c100.97,0 183.1,82.18 183.1,183.1l0 30.42 0 213.4zm-1230.2 -345.53l-0.89 -795.03 276.91 -53.68 0 526.07c55.74,-24.16 117.03,-37.74 181.5,-37.74l589.71 3.5c252.69,0 458.42,205.72 458.42,458.59l0 30.42 0 213.4 0 168.28c0,252.86 -205.73,458.54 -458.42,458.54l-589.71 -3.5c-252.7,0 -458.41,-205.67 -458.41,-458.56l0 -171.61 0 -240.48 0.89 -98.2z"/>
|
||||
<path class="fil0" d="M3041.25 1150.84l0 204.28c0,100.93 -82.15,183.05 -183.11,183.05l-582.11 -3.46c-100.96,0 -183.11,-82.16 -183.11,-183.08l0 -67.42 1.48 0.24 0 -862.65 -276.91 53.68 0.12 103.41 -0.12 0.04 0 772.69c0,252.87 205.72,458.54 458.39,458.54l582.4 3.5c252.67,0 458.4,-205.68 458.4,-458.55l0 -70.79 0.71 0.12 0 -862.65 -276.91 53.68 0.76 675.35z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,958 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<title>DrHider — обфускация документов</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<style>
|
||||
:root {
|
||||
--brand-primary: #2563eb;
|
||||
--brand-gray: #d1d5db;
|
||||
--brand-grey-light: #f3f4f6;
|
||||
--bg: #f9fafb;
|
||||
--card: #ffffff;
|
||||
--text: #1a1a1a;
|
||||
--muted: #6b7280;
|
||||
--red: #ef4444;
|
||||
--green: #22c55e;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font: 14px system-ui, sans-serif;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
background: var(--card);
|
||||
border-bottom: 1px solid var(--brand-gray);
|
||||
padding: 0 16px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.topbar img {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.topbar .title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.topbar .ver {
|
||||
font-weight: 400;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.topbar .help {
|
||||
margin-left: auto;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
user-select: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 5px 14px;
|
||||
border-radius: 6px;
|
||||
background: #1d4ed8;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: .06em;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, .25);
|
||||
}
|
||||
|
||||
.topbar .help:hover {
|
||||
background: #1e40af;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: 820px;
|
||||
margin: 20px auto;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--brand-gray);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .04);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: var(--brand-grey-light);
|
||||
padding: 10px 12px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
border-bottom: 1px solid var(--brand-gray);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* FilePicker styling */
|
||||
.file-picker {
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.file-picker .fp-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.file-picker .fp-toolbar button {
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--brand-gray);
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.file-picker .fp-toolbar button#fp-files-btn {
|
||||
background: #eff6ff;
|
||||
border-color: var(--brand-primary);
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.file-picker .fp-toolbar button#fp-folder-btn {
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.file-picker .fp-toolbar button#fp-clear-btn {
|
||||
color: var(--muted);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.file-picker .fp-toolbar button#fp-clear-btn:hover {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.file-picker .fp-table-wrap {
|
||||
border: 1px solid var(--brand-gray);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.file-picker .fp-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.file-picker .fp-table th {
|
||||
background: var(--brand-grey-light);
|
||||
text-transform: uppercase;
|
||||
padding: 6px 8px;
|
||||
border-right: 1px solid var(--brand-gray);
|
||||
border-bottom: 1px solid var(--brand-gray);
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.file-picker .fp-table td {
|
||||
padding: 5px 8px;
|
||||
border-right: 1px solid var(--brand-gray);
|
||||
border-bottom: 1px solid var(--brand-gray);
|
||||
}
|
||||
|
||||
.file-picker .tree-row:hover td {
|
||||
background: rgba(37, 99, 235, .03);
|
||||
}
|
||||
|
||||
.file-picker .tree-row.row-current td {
|
||||
background: rgba(37, 99, 235, .12) !important;
|
||||
color: #1d4ed8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.file-picker .tree-name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-picker .tree-chevron {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
color: var(--muted);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.file-picker .fp-status {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.file-picker .fp-count {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-picker .remove-btn {
|
||||
cursor: pointer;
|
||||
color: #f87171;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 2px 4px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.file-picker .remove-btn:hover {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
body.busy .file-picker button {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
body.busy .file-picker .remove-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.footer-bar {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.btn {
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--brand-gray);
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--brand-primary);
|
||||
border-color: var(--brand-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: .9;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: .4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.status.progress {
|
||||
background: #eff6ff;
|
||||
border: 1px solid var(--brand-primary);
|
||||
color: var(--brand-primary);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.status.done {
|
||||
background: #f0fdf4;
|
||||
border: 1px solid var(--green);
|
||||
color: #16a34a;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
background: #fef2f2;
|
||||
border: 1px solid var(--red);
|
||||
color: #991b1b;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dl-btns {
|
||||
display: none;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.dl-btns.show {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.stats-block {
|
||||
display: none;
|
||||
margin-top: 12px;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #f0fdf4, #ecfdf5);
|
||||
border: 2px solid var(--green);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.stats-block.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.stats-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #14532d;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
min-width: 130px;
|
||||
background: #fff;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
color: var(--muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.live-block {
|
||||
display: none;
|
||||
margin-top: 12px;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #eff6ff, #dbeafe);
|
||||
border: 2px solid var(--brand-primary);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.live-block.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.live-title {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: #1e40af;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.live-file {
|
||||
font-size: 15px;
|
||||
color: #1e3a8a;
|
||||
margin-bottom: 10px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.live-timer {
|
||||
font-size: 40px;
|
||||
font-weight: 800;
|
||||
color: #1d4ed8;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.live-llm {
|
||||
display: none;
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: #fef3c7;
|
||||
border: 1px solid #f59e0b;
|
||||
color: #92400e;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.live-llm.show {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.live-eta {
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
color: #1d4ed8;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<img src="/static/logo.svg" alt="Nubes">
|
||||
<span class="title">DrHider</span>
|
||||
<span class="ver">v{{ version }}</span>
|
||||
<span class="help" onclick="document.getElementById('helpModal').style.display='flex'">HELP</span>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<div class="card-header">Обфускация документов</div>
|
||||
<div class="card-body">
|
||||
<p class="sub">
|
||||
Загрузите документы (.docx, .doc, .pdf, .txt, .md, .zip) — получите ZIP с обезличенными копиями.<br>
|
||||
CSV с таблицей замен скачивается отдельно.<br>
|
||||
<span style="color:#c0392b;">Ограничения: один файл не более 50 МБ, суммарно не более 500 МБ. Файлы сверх
|
||||
лимитов помечаются статусом «пропущен» и не участвуют в обфускации. Пустые, повреждённые файлы и сканы без
|
||||
текстового слоя получают статус «не извлечён» — они тоже не попадают в результат.</span><br>
|
||||
<span style="color:#7d3c98;">⏱ Время обработки каждого файла — ориентировочное (обновляется по факту).</span>
|
||||
</p>
|
||||
|
||||
<div id="filePicker"></div>
|
||||
|
||||
<div class="footer-bar">
|
||||
<span id="fileCount">0 файлов</span>
|
||||
<button class="btn" id="cancelBtn" style="display:none" onclick="confirmCancel()">⏹ Прервать</button>
|
||||
<button class="btn" id="newSessionBtn" style="display:none" onclick="resetAll()">🔄 Новая сессия</button>
|
||||
<button class="btn btn-primary" id="uploadBtn" disabled onclick="uploadFiles()">🛡️ Обфусцировать</button>
|
||||
</div>
|
||||
<div class="status" id="status"></div>
|
||||
<div class="dl-btns" id="dlBtns">
|
||||
<button class="btn btn-primary" id="dlZip" onclick="downloadZip()">📦 Скачать ZIP</button>
|
||||
<button class="btn" id="dlCsv" onclick="downloadCsv()">📋 Скачать CSV</button>
|
||||
</div>
|
||||
<div class="live-block" id="liveBlock">
|
||||
<div class="live-title">⏱ Обработка…</div>
|
||||
<div class="live-file" id="liveFile">—</div>
|
||||
<div class="live-timer" id="liveTimer">0.0 с</div>
|
||||
<div class="live-llm" id="liveLlm">🤖 ИИ обрабатывает… <span id="liveLlmTime">0.0 с</span></div>
|
||||
<div class="live-eta" id="liveEta"></div>
|
||||
</div>
|
||||
<div class="stats-block" id="statsBlock">
|
||||
<div class="stats-title">📊 Итоги обработки</div>
|
||||
<div class="stats-row">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Общее время</div>
|
||||
<div class="stat-value" id="stTotalTime">—</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Из них ИИ</div>
|
||||
<div class="stat-value" id="stLlmTime">—</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Токены ИИ</div>
|
||||
<div class="stat-value" id="stLlmTokens">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/file-picker/file-picker.iife.js?v={{ version }}"></script>
|
||||
<script>
|
||||
const DOC_EXTS = ['.pdf', '.doc', '.docx', '.txt', '.md'];
|
||||
const ub = document.getElementById('uploadBtn');
|
||||
const cb = document.getElementById('cancelBtn');
|
||||
const nb = document.getElementById('newSessionBtn');
|
||||
const fc = document.getElementById('fileCount');
|
||||
const st = document.getElementById('status');
|
||||
const db = document.getElementById('dlBtns');
|
||||
const MAX_FILE_BYTES = 50 * 1024 * 1024; // 50 МБ на один файл
|
||||
const MAX_SESSION_BYTES = 500 * 1024 * 1024; // 500 МБ суммарно на сессию
|
||||
const VM_UPLOAD_URL = 'https://upload.sinefo.ru/drhider-upload/'; // ВМ-буфер
|
||||
const TEST_UPLOAD_ONLY = new URLSearchParams(location.search).get('upload-only') === '1';
|
||||
|
||||
let currentSid = '';
|
||||
let activeES = null;
|
||||
let abortCtrl = null;
|
||||
let busy = false;
|
||||
let sessionDone = false;
|
||||
let ptimer = null, liveRefresh = null;
|
||||
|
||||
const picker = FilePicker.initFilePicker({
|
||||
mount: '#filePicker',
|
||||
allowedExt: DOC_EXTS,
|
||||
labels: {
|
||||
pickFiles: '📄 Выбрать файлы',
|
||||
pickFolder: '📁 Выбрать папку',
|
||||
clear: 'Очистить',
|
||||
empty: 'Нет выбранных файлов',
|
||||
statusReady: 'готов',
|
||||
remove: '✕',
|
||||
columns: { path: 'Имя', size: 'Размер', status: 'Статус' },
|
||||
},
|
||||
limits: {
|
||||
maxEntryBytes: MAX_FILE_BYTES,
|
||||
maxTotalBytes: MAX_SESSION_BYTES,
|
||||
maxEntries: 1000,
|
||||
maxDepth: 20,
|
||||
},
|
||||
onChange: (files) => {
|
||||
ub.disabled = files.length === 0 || busy;
|
||||
fc.textContent = files.length ? `${files.length} файл(ов)` : '0 файлов';
|
||||
}
|
||||
});
|
||||
|
||||
function setBusy(b) {
|
||||
busy = b;
|
||||
document.body.classList.toggle('busy', b);
|
||||
}
|
||||
|
||||
function fmtSec(s) {
|
||||
s = Math.max(0, Math.round(s));
|
||||
return s >= 60 ? Math.floor(s / 60) + 'м ' + (s % 60) + 'с' : s + 'с';
|
||||
}
|
||||
|
||||
function confirmCancel() {
|
||||
document.getElementById('cancelModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function doCancel() {
|
||||
document.getElementById('cancelModal').style.display = 'none';
|
||||
if (abortCtrl) {
|
||||
abortCtrl.abort();
|
||||
}
|
||||
if (currentSid) {
|
||||
fetch('/api/cancel/' + currentSid, { method: 'POST' }).catch(() => { });
|
||||
st.textContent = '⏹ Остановка… завершаем текущий файл';
|
||||
}
|
||||
}
|
||||
|
||||
function finishProcUI() {
|
||||
if (activeES) { activeES.close(); activeES = null; }
|
||||
if (ptimer) { clearInterval(ptimer); ptimer = null; }
|
||||
if (liveRefresh) { clearInterval(liveRefresh); liveRefresh = null; }
|
||||
document.getElementById('liveBlock').classList.remove('show');
|
||||
cb.style.display = 'none';
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
if (activeES) { activeES.close(); activeES = null; }
|
||||
if (abortCtrl) { abortCtrl.abort(); abortCtrl = null; }
|
||||
if (ptimer) { clearInterval(ptimer); ptimer = null; }
|
||||
if (liveRefresh) { clearInterval(liveRefresh); liveRefresh = null; }
|
||||
currentSid = '';
|
||||
sessionDone = false;
|
||||
setBusy(false);
|
||||
picker.clear();
|
||||
nb.style.display = 'none';
|
||||
cb.style.display = 'none';
|
||||
st.className = '';
|
||||
st.textContent = '';
|
||||
db.classList.remove('show');
|
||||
document.getElementById('statsBlock').classList.remove('show');
|
||||
document.getElementById('liveBlock').classList.remove('show');
|
||||
ub.disabled = true;
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', () => resetAll());
|
||||
|
||||
async function uploadFiles() {
|
||||
if (busy) return;
|
||||
const files = picker.getFiles();
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
setBusy(true);
|
||||
sessionDone = false;
|
||||
nb.style.display = 'none';
|
||||
cb.style.display = 'inline-block';
|
||||
ub.disabled = true;
|
||||
|
||||
if (activeES) { activeES.close(); activeES = null; }
|
||||
if (abortCtrl) { abortCtrl.abort(); abortCtrl = null; }
|
||||
abortCtrl = new AbortController();
|
||||
currentSid = '';
|
||||
st.className = 'status progress';
|
||||
st.textContent = 'Подготовка к загрузке через ВМ-буфер…';
|
||||
db.classList.remove('show');
|
||||
document.getElementById('statsBlock').classList.remove('show');
|
||||
document.getElementById('liveBlock').classList.remove('show');
|
||||
|
||||
const total = files.length;
|
||||
let res;
|
||||
try {
|
||||
res = await FilePicker.uploadViaVM(files, {
|
||||
vmUploadUrl: VM_UPLOAD_URL,
|
||||
backendUploadUrl: '/api/upload_refs',
|
||||
signal: abortCtrl.signal,
|
||||
onStatus: (msg) => {
|
||||
st.className = 'status progress';
|
||||
st.textContent = msg;
|
||||
},
|
||||
onFileStatus: (k, statusText) => {
|
||||
const rows = document.querySelectorAll('.tree-row.tree-file');
|
||||
if (rows[k]) {
|
||||
const td = rows[k].querySelectorAll('td')[2];
|
||||
if (td) td.innerHTML = statusText;
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
st.className = 'status error';
|
||||
st.textContent = 'Ошибка загрузки: ' + err.message;
|
||||
setBusy(false);
|
||||
cb.style.display = 'none';
|
||||
ub.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.aborted) {
|
||||
st.className = 'status';
|
||||
st.textContent = '⏹ Загрузка отменена пользователем.';
|
||||
} else {
|
||||
st.className = 'status error';
|
||||
st.textContent = res.error || 'Ошибка загрузки';
|
||||
}
|
||||
setBusy(false);
|
||||
cb.style.display = 'none';
|
||||
ub.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
currentSid = res.session;
|
||||
const uploadedCount = res.count || total;
|
||||
|
||||
if (TEST_UPLOAD_ONLY) {
|
||||
st.className = 'status done';
|
||||
st.textContent = '✅ Загружено ' + uploadedCount + ' файлов (тест: обработка пропущена). Сессия: ' + currentSid;
|
||||
setBusy(false);
|
||||
cb.style.display = 'none';
|
||||
ub.disabled = false;
|
||||
nb.style.display = 'inline-block';
|
||||
return;
|
||||
}
|
||||
|
||||
await startProcessingPhase(total, files);
|
||||
}
|
||||
|
||||
async function startProcessingPhase(total, files) {
|
||||
st.className = 'status progress';
|
||||
st.textContent = 'Обработка (этап 2/2)…';
|
||||
cb.style.display = 'inline-block';
|
||||
|
||||
const rows = document.querySelectorAll('.tree-row.tree-file');
|
||||
rows.forEach(r => {
|
||||
const td = r.querySelectorAll('td')[2];
|
||||
if (td) td.innerHTML = '<span style="color:#2563eb">⏳</span>';
|
||||
});
|
||||
|
||||
const liveBlock = document.getElementById('liveBlock');
|
||||
const liveTimerEl = document.getElementById('liveTimer');
|
||||
const liveFileEl = document.getElementById('liveFile');
|
||||
const liveLlm = document.getElementById('liveLlm');
|
||||
const liveLlmTime = document.getElementById('liveLlmTime');
|
||||
const liveEta = document.getElementById('liveEta');
|
||||
let currentLiveFile = '';
|
||||
liveLlm.classList.remove('show');
|
||||
liveEta.textContent = '';
|
||||
liveTimerEl.textContent = '0.0 с';
|
||||
liveFileEl.textContent = 'Подготовка…';
|
||||
liveBlock.classList.add('show');
|
||||
|
||||
const t0 = performance.now();
|
||||
if (ptimer) clearInterval(ptimer);
|
||||
ptimer = setInterval(() => {
|
||||
const sec = Math.round((performance.now() - t0) / 1000);
|
||||
st.textContent = 'Обработка (этап 2/2)… ' + sec + 'с';
|
||||
}, 1000);
|
||||
|
||||
if (liveRefresh) clearInterval(liveRefresh);
|
||||
liveRefresh = setInterval(() => {
|
||||
const sec = ((performance.now() - t0) / 1000).toFixed(1);
|
||||
liveTimerEl.textContent = sec + ' с';
|
||||
}, 200);
|
||||
|
||||
let procExtractDone = false;
|
||||
const fileTimers = {};
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
activeES = new EventSource('/api/process_stream/' + currentSid);
|
||||
activeES.addEventListener('start', function (e) {
|
||||
const d = JSON.parse(e.data);
|
||||
const idx = d.idx;
|
||||
const curRows = document.querySelectorAll('.tree-row.tree-file');
|
||||
if (curRows[idx]) {
|
||||
curRows.forEach(r => r.classList.remove('row-current'));
|
||||
curRows[idx].classList.add('row-current');
|
||||
const td = curRows[idx].querySelectorAll('td')[2];
|
||||
if (td) td.innerHTML = '<span style="color:#2563eb">⚙️</span>';
|
||||
}
|
||||
currentLiveFile = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name;
|
||||
liveFileEl.textContent = currentLiveFile;
|
||||
});
|
||||
activeES.addEventListener('extract_done', function (e) {
|
||||
procExtractDone = true;
|
||||
const curRows = document.querySelectorAll('.tree-row.tree-file');
|
||||
curRows.forEach(r => r.classList.remove('row-current'));
|
||||
});
|
||||
activeES.addEventListener('file_start', function (e) {
|
||||
const d = JSON.parse(e.data);
|
||||
const idx = d.idx;
|
||||
fileTimers[idx] = performance.now();
|
||||
const curRows = document.querySelectorAll('.tree-row.tree-file');
|
||||
if (curRows[idx]) {
|
||||
curRows.forEach(r => r.classList.remove('row-current'));
|
||||
curRows[idx].classList.add('row-current');
|
||||
const td = curRows[idx].querySelectorAll('td')[2];
|
||||
if (td) td.innerHTML = '<span style="color:#2563eb">⚙️ анализ…</span>';
|
||||
}
|
||||
liveFileEl.textContent = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name;
|
||||
});
|
||||
activeES.addEventListener('file_chunk', function (e) {
|
||||
const d = JSON.parse(e.data);
|
||||
liveFileEl.textContent = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name +
|
||||
(typeof d.eta_sec === 'number' ? ' — осталось ~' + fmtSec(d.eta_sec) : '');
|
||||
});
|
||||
activeES.addEventListener('file_done', function (e) {
|
||||
const d = JSON.parse(e.data);
|
||||
const idx = d.idx;
|
||||
const curRows = document.querySelectorAll('.tree-row.tree-file');
|
||||
if (curRows[idx]) {
|
||||
curRows[idx].classList.remove('row-current');
|
||||
const td = curRows[idx].querySelectorAll('td')[2];
|
||||
if (td) td.innerHTML = '✅ готов';
|
||||
}
|
||||
});
|
||||
activeES.addEventListener('llm', function (e) {
|
||||
const d = JSON.parse(e.data);
|
||||
if (d.active) {
|
||||
liveLlmTime.textContent = d.elapsed + ' с';
|
||||
liveLlm.classList.add('show');
|
||||
liveFileEl.textContent = '🤖 ИИ анализирует все файлы…';
|
||||
if (typeof d.eta_sec === 'number' && d.eta_sec >= 0) liveEta.textContent = '⏳ осталось ~' + fmtSec(d.eta_sec);
|
||||
else liveEta.textContent = '';
|
||||
} else {
|
||||
liveLlm.classList.remove('show');
|
||||
if (currentLiveFile) liveFileEl.textContent = currentLiveFile;
|
||||
}
|
||||
});
|
||||
activeES.addEventListener('done', function (e) {
|
||||
const d = JSON.parse(e.data);
|
||||
const idx = d.idx;
|
||||
const curRows = document.querySelectorAll('.tree-row.tree-file');
|
||||
if (!procExtractDone) {
|
||||
if (curRows[idx]) {
|
||||
const td = curRows[idx].querySelectorAll('td')[2];
|
||||
if (td) td.innerHTML = '<span style="color:#c0392b;">не извлечён</span>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
const sec = (typeof d.elapsed === 'number' && d.elapsed > 0)
|
||||
? d.elapsed.toFixed(1)
|
||||
: ((performance.now() - (fileTimers[idx] || performance.now())) / 1000).toFixed(1);
|
||||
if (curRows[idx]) {
|
||||
const td = curRows[idx].querySelectorAll('td')[2];
|
||||
if (td) td.innerHTML = '✅ ' + sec + 'с';
|
||||
}
|
||||
});
|
||||
activeES.addEventListener('cancelled', function (e) {
|
||||
const d = JSON.parse(e.data);
|
||||
finishProcUI();
|
||||
const totalSec = ((performance.now() - t0) / 1000).toFixed(1);
|
||||
st.className = 'status done';
|
||||
st.textContent = '⏹ Остановлено. Сохранено ' + d.saved + ' из ' + d.total + ' файлов + таблица замен (общее ' + totalSec + 'с)';
|
||||
const sb = document.getElementById('statsBlock');
|
||||
document.getElementById('stTotalTime').textContent = totalSec + ' с';
|
||||
if (d.llm_sec > 0) {
|
||||
document.getElementById('stLlmTime').textContent = d.llm_sec + ' с';
|
||||
document.getElementById('stLlmTokens').textContent = d.tokens > 0 ? d.tokens : '—';
|
||||
} else {
|
||||
document.getElementById('stLlmTime').textContent = '—';
|
||||
document.getElementById('stLlmTokens').textContent = '—';
|
||||
}
|
||||
sb.classList.add('show');
|
||||
db.classList.add('show');
|
||||
sessionDone = true;
|
||||
ub.disabled = true;
|
||||
nb.style.display = 'inline-block';
|
||||
resolve();
|
||||
});
|
||||
activeES.addEventListener('complete', function (e) {
|
||||
finishProcUI();
|
||||
const d = JSON.parse(e.data);
|
||||
const totalSec = ((performance.now() - t0) / 1000).toFixed(1);
|
||||
st.className = 'status done';
|
||||
st.textContent = '✅ Обработано ' + d.total + ' файлов: общее ' + totalSec + 'с, из них ИИ ' + (d.llm_sec > 0 ? d.llm_sec + 'с' : '0с');
|
||||
const sb = document.getElementById('statsBlock');
|
||||
document.getElementById('stTotalTime').textContent = totalSec + ' с';
|
||||
if (d.llm_sec > 0) {
|
||||
document.getElementById('stLlmTime').textContent = d.llm_sec + ' с';
|
||||
document.getElementById('stLlmTokens').textContent = d.tokens > 0 ? d.tokens : '—';
|
||||
} else {
|
||||
document.getElementById('stLlmTime').textContent = '—';
|
||||
document.getElementById('stLlmTokens').textContent = '—';
|
||||
}
|
||||
sb.classList.add('show');
|
||||
db.classList.add('show');
|
||||
sessionDone = true;
|
||||
ub.disabled = true;
|
||||
nb.style.display = 'inline-block';
|
||||
resolve();
|
||||
});
|
||||
activeES.addEventListener('proc_error', function (e) {
|
||||
const d = JSON.parse(e.data);
|
||||
finishProcUI();
|
||||
setBusy(false);
|
||||
st.className = 'status error';
|
||||
st.textContent = 'Ошибка обработки: ' + (d.error || 'неизвестная ошибка');
|
||||
resolve();
|
||||
});
|
||||
activeES.onerror = function () {
|
||||
finishProcUI();
|
||||
reject(new Error('SSE connection failed'));
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
finishProcUI();
|
||||
setBusy(false);
|
||||
st.className = 'status error';
|
||||
st.textContent = 'Ошибка: ' + err.message;
|
||||
}
|
||||
if (!sessionDone) ub.disabled = false;
|
||||
}
|
||||
|
||||
function downloadZip() {
|
||||
if (!currentSid) return;
|
||||
fetch('/api/download/' + currentSid)
|
||||
.then(r => {
|
||||
const disp = r.headers.get('Content-Disposition');
|
||||
const m = disp && disp.match(/filename="?(.+?)"?$/);
|
||||
return Promise.all([r.blob(), m ? m[1] : 'drhider.zip']);
|
||||
})
|
||||
.then(([blob, fname]) => {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = fname;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(a.href);
|
||||
});
|
||||
}
|
||||
|
||||
function downloadCsv() {
|
||||
if (!currentSid) return;
|
||||
fetch('/api/csv/' + currentSid)
|
||||
.then(r => {
|
||||
const disp = r.headers.get('Content-Disposition');
|
||||
const m = disp && disp.match(/filename="?(.+?)"?$/);
|
||||
return Promise.all([r.blob(), m ? m[1] : 'mapping.csv']);
|
||||
})
|
||||
.then(([blob, fname]) => {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = fname;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(a.href);
|
||||
});
|
||||
}
|
||||
|
||||
window.uploadFiles = uploadFiles;
|
||||
window.resetAll = resetAll;
|
||||
window.confirmCancel = confirmCancel;
|
||||
window.doCancel = doCancel;
|
||||
window.downloadZip = downloadZip;
|
||||
window.downloadCsv = downloadCsv;
|
||||
|
||||
fetch('/health').catch(() => { });
|
||||
</script>
|
||||
<div id="helpModal"
|
||||
style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5);z-index:999;justify-content:center;align-items:center"
|
||||
onclick="this.style.display='none'">
|
||||
<div
|
||||
style="background:var(--card);border-radius:12px;padding:24px;max-width:420px;box-shadow:0 4px 24px rgba(0,0,0,.15)"
|
||||
onclick="event.stopPropagation()">
|
||||
<h3 style="margin-bottom:12px">DrHider — обфускация документов</h3>
|
||||
<p style="font-size:13px;color:var(--muted);line-height:1.6">
|
||||
Загрузите документы (.docx, .doc, .pdf, .txt, .md, .zip) — получите ZIP-архив с обезличенными копиями.
|
||||
Персональные данные, реквизиты компаний, телефоны, email и другие приватные сведения заменяются на обезличенные
|
||||
коды.
|
||||
CSV-файл с таблицей соответствия оригинал→замена скачивается отдельно.
|
||||
</p>
|
||||
<button class="btn" style="margin-top:12px;width:100%"
|
||||
onclick="document.getElementById('helpModal').style.display='none'">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cancelModal"
|
||||
style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5);z-index:999;justify-content:center;align-items:center"
|
||||
onclick="this.style.display='none'">
|
||||
<div
|
||||
style="background:var(--card);border-radius:12px;padding:24px;max-width:440px;box-shadow:0 4px 24px rgba(0,0,0,.15)"
|
||||
onclick="event.stopPropagation()">
|
||||
<h3 style="margin-bottom:12px">⏹ Остановить обработку?</h3>
|
||||
<p style="font-size:13px;color:var(--muted);line-height:1.6" id="cancelModalText">
|
||||
Будут сохранены: полностью обработанные файлы и таблица замен.
|
||||
Необработанные файлы в результат не попадут.
|
||||
Текущий анализ будет доведён до конца.
|
||||
</p>
|
||||
<div style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end">
|
||||
<button class="btn" onclick="document.getElementById('cancelModal').style.display='none'">Отмена</button>
|
||||
<button class="btn btn-primary" onclick="doCancel()">Остановить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user