налажена логика добавления промптов через диалог с ИИ, отлажена работа с задачами, настроена авторизация в веб интерфейсе.
This commit is contained in:
+61
-65
@@ -18,41 +18,22 @@ from .schemas import TOOLS_SCHEMA
|
|||||||
OLLAMA_URL = "http://192.168.11.3:11434/api/chat"
|
OLLAMA_URL = "http://192.168.11.3:11434/api/chat"
|
||||||
MODEL_NAME = "qwen2.5:14b"
|
MODEL_NAME = "qwen2.5:14b"
|
||||||
|
|
||||||
|
def format_rules_output(rules: List[Dict[str, Any]]) -> str:
|
||||||
|
if not rules:
|
||||||
|
return "База знаний пока пуста."
|
||||||
|
lines = [f"📚 База знаний и правила арбитража ({len(rules)}):\n"]
|
||||||
|
for idx, r in enumerate(rules, 1):
|
||||||
|
rule_text = r.get("rule_text", "").strip()
|
||||||
|
lines.append(f"{idx}. {rule_text}\n")
|
||||||
|
return "\n".join(lines).strip()
|
||||||
|
|
||||||
def process_chat_message(user_id: int, user_message: str, chat_history: List[Dict[str, Any]] = None) -> Tuple[str, List[Dict[str, Any]]]:
|
def process_chat_message(user_id: int, user_message: str, chat_history: List[Dict[str, Any]] = None) -> Tuple[str, List[Dict[str, Any]]]:
|
||||||
if chat_history is None:
|
if chat_history is None:
|
||||||
chat_history = []
|
chat_history = []
|
||||||
|
|
||||||
current_now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
current_now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||||
user_lower = user_message.lower().strip()
|
|
||||||
|
# Запрос к локальной модели Qwen (полный цикл Function Calling)
|
||||||
# Быстрый прямой ответ для списка задач текущего пользователя
|
|
||||||
if any(phrase in user_lower for phrase in ["все задачи", "покажи задачи", "список задач", "реестр задач"]):
|
|
||||||
tasks = db_get_tasks(user_id)
|
|
||||||
if not tasks:
|
|
||||||
formatted_text = "Ваш список задач пуст."
|
|
||||||
else:
|
|
||||||
def get_task_num(t):
|
|
||||||
tid = str(t.get("task_id", ""))
|
|
||||||
try:
|
|
||||||
return int(tid.upper().replace("TASK-", "").strip())
|
|
||||||
except ValueError:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
tasks_sorted = sorted(tasks, key=get_task_num, reverse=True)
|
|
||||||
lines = [f"📋 Ваш реестр задач ({len(tasks_sorted)}):\n"]
|
|
||||||
for t in tasks_sorted:
|
|
||||||
tid = t.get('task_id', '')
|
|
||||||
title = t.get('title', 'Без названия')
|
|
||||||
lines.append(f"• {tid}: {title}")
|
|
||||||
formatted_text = "\n".join(lines)
|
|
||||||
|
|
||||||
return formatted_text, chat_history + [{"role": "user", "content": user_message}, {"role": "assistant", "content": formatted_text}]
|
|
||||||
|
|
||||||
if any(phrase in user_lower for phrase in ["системный промпт", "покажи промпт", "промпт системы"]):
|
|
||||||
prompt_text = db_get_active_system_prompt()
|
|
||||||
formatted_text = f"⚙️ Актуальный системный промпт ИИ:\n\n{prompt_text}"
|
|
||||||
return formatted_text, chat_history + [{"role": "user", "content": user_message}, {"role": "assistant", "content": formatted_text}]
|
|
||||||
|
|
||||||
dynamic_prompt_text = db_get_active_system_prompt()
|
dynamic_prompt_text = db_get_active_system_prompt()
|
||||||
system_prompt = {
|
system_prompt = {
|
||||||
"role": "system",
|
"role": "system",
|
||||||
@@ -69,63 +50,78 @@ def process_chat_message(user_id: int, user_message: str, chat_history: List[Dic
|
|||||||
"options": {"num_predict": 2048, "num_ctx": 8192, "temperature": 0.1}
|
"options": {"num_predict": 2048, "num_ctx": 8192, "temperature": 0.1}
|
||||||
}
|
}
|
||||||
|
|
||||||
req = urllib.request.Request(
|
|
||||||
OLLAMA_URL,
|
|
||||||
data=json.dumps(payload).encode("utf-8"),
|
|
||||||
headers={"Content-Type": "application/json"}
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
OLLAMA_URL,
|
||||||
|
data=json.dumps(payload).encode("utf-8"),
|
||||||
|
headers={"Content-Type": "application/json"}
|
||||||
|
)
|
||||||
with urllib.request.urlopen(req) as response:
|
with urllib.request.urlopen(req) as response:
|
||||||
res_data = json.loads(response.read().decode("utf-8"))
|
res_data = json.loads(response.read().decode("utf-8"))
|
||||||
msg = res_data.get("message", {})
|
msg = res_data.get("message", {})
|
||||||
tool_calls = msg.get("tool_calls", [])
|
tool_calls = msg.get("tool_calls", [])
|
||||||
content_str = msg.get("content", "").strip().replace("**", "")
|
|
||||||
|
|
||||||
if tool_calls:
|
if tool_calls:
|
||||||
|
messages.append(msg)
|
||||||
|
|
||||||
for tool in tool_calls:
|
for tool in tool_calls:
|
||||||
fn_name = tool["function"]["name"]
|
fn_name = tool["function"]["name"]
|
||||||
fn_args = tool["function"].get("arguments", {})
|
fn_args = tool["function"].get("arguments", {})
|
||||||
|
tool_result_content = ""
|
||||||
|
|
||||||
if fn_name == "db_get_tasks":
|
if fn_name == "db_get_tasks":
|
||||||
tasks = db_get_tasks(user_id)
|
tasks = db_get_tasks(user_id)
|
||||||
if not tasks:
|
tool_result_content = json.dumps(tasks, ensure_ascii=False)
|
||||||
formatted_text = "Ваш список задач пуст."
|
|
||||||
else:
|
|
||||||
def get_task_num(t):
|
|
||||||
tid = str(t.get("task_id", ""))
|
|
||||||
try:
|
|
||||||
return int(tid.upper().replace("TASK-", "").strip())
|
|
||||||
except ValueError:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
tasks_sorted = sorted(tasks, key=get_task_num, reverse=True)
|
elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]:
|
||||||
lines = [f"📋 Ваш реестр задач ({len(tasks_sorted)}):\n"]
|
tool_result_content = db_get_active_system_prompt()
|
||||||
for t in tasks_sorted:
|
|
||||||
tid = t.get('task_id', '')
|
|
||||||
title = t.get('title', 'Без названия')
|
|
||||||
lines.append(f"• {tid}: {title}")
|
|
||||||
formatted_text = "\n".join(lines)
|
|
||||||
|
|
||||||
return formatted_text, chat_history + [{"role": "user", "content": user_message}, {"role": "assistant", "content": formatted_text}]
|
elif fn_name == "db_add_system_prompt":
|
||||||
|
res = db_add_system_prompt(
|
||||||
|
name=fn_args.get("name", "main_agent"),
|
||||||
|
prompt_text=fn_args.get("prompt_text")
|
||||||
|
)
|
||||||
|
tool_result_content = json.dumps(res, ensure_ascii=False)
|
||||||
|
|
||||||
|
elif fn_name == "db_get_rules":
|
||||||
|
tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False)
|
||||||
|
|
||||||
elif fn_name == "db_add_task":
|
elif fn_name == "db_add_task":
|
||||||
res = db_add_task(user_id=user_id, module=fn_args.get("module", "general"), title=fn_args.get("title"), priority=fn_args.get("priority", "MEDIUM"), due_date=fn_args.get("due_date"))
|
res = db_add_task(user_id=user_id, module=fn_args.get("module", "general"), title=fn_args.get("title"), priority=fn_args.get("priority", "MEDIUM"), due_date=fn_args.get("due_date"))
|
||||||
formatted_text = f"[✓] Задача {res.get('task_id', 'TASK')} успешно создана!" if "status" in res else f"❌ Ошибка: {res.get('error')}"
|
tool_result_content = json.dumps(res, ensure_ascii=False)
|
||||||
return formatted_text, chat_history + [{"role": "user", "content": user_message}, {"role": "assistant", "content": formatted_text}]
|
|
||||||
|
|
||||||
elif fn_name == "db_update_task_status":
|
elif fn_name == "db_update_task_status":
|
||||||
res = db_update_task_status(user_id=user_id, task_id=fn_args.get("task_id"), status=fn_args.get("status"), due_date=fn_args.get("due_date"))
|
res = db_update_task_status(user_id=user_id, task_id=str(fn_args.get("task_id")), status=fn_args.get("status", "COMPLETED"), due_date=fn_args.get("due_date"))
|
||||||
formatted_text = f"[✓] Статус задачи {fn_args.get('task_id')} обновлен!" if "status" in res else f"❌ Ошибка: {res.get('error')}"
|
tool_result_content = json.dumps(res, ensure_ascii=False)
|
||||||
return formatted_text, chat_history + [{"role": "user", "content": user_message}, {"role": "assistant", "content": formatted_text}]
|
|
||||||
|
|
||||||
elif fn_name == "db_delete_task":
|
elif fn_name == "db_delete_task":
|
||||||
task_id_to_del = fn_args.get("task_id", "").upper()
|
res = db_delete_task(user_id=user_id, task_id=str(fn_args.get("task_id", "")).upper())
|
||||||
res = db_delete_task(user_id=user_id, task_id=task_id_to_del)
|
tool_result_content = json.dumps(res, ensure_ascii=False)
|
||||||
formatted_text = f"[✓] Задача {task_id_to_del} успешно удалена из вашей базы!" if "status" in res else f"❌ Ошибка: {res.get('error')}"
|
|
||||||
return formatted_text, chat_history + [{"role": "user", "content": user_message}, {"role": "assistant", "content": formatted_text}]
|
|
||||||
|
|
||||||
return content_str, chat_history + [{"role": "user", "content": user_message}, {"role": "assistant", "content": content_str}]
|
messages.append({
|
||||||
|
"role": "tool",
|
||||||
|
"content": tool_result_content
|
||||||
|
})
|
||||||
|
|
||||||
|
# Вторичный запрос модели для формирования итогового ответа оператору
|
||||||
|
second_payload = {
|
||||||
|
"model": MODEL_NAME,
|
||||||
|
"messages": messages,
|
||||||
|
"stream": False,
|
||||||
|
"options": {"num_predict": 2048, "num_ctx": 8192, "temperature": 0.1}
|
||||||
|
}
|
||||||
|
sec_req = urllib.request.Request(
|
||||||
|
OLLAMA_URL,
|
||||||
|
data=json.dumps(second_payload).encode("utf-8"),
|
||||||
|
headers={"Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(sec_req) as sec_response:
|
||||||
|
sec_res_data = json.loads(sec_response.read().decode("utf-8"))
|
||||||
|
final_content = sec_res_data.get("message", {}).get("content", "").strip().replace("**", "")
|
||||||
|
return final_content, chat_history + [{"role": "user", "content": user_message}, {"role": "assistant", "content": final_content}]
|
||||||
|
|
||||||
|
content_str = msg.get("content", "").strip().replace("**", "")
|
||||||
|
return content_str or "Запрос обработан.", chat_history + [{"role": "user", "content": user_message}, {"role": "assistant", "content": content_str}]
|
||||||
|
|
||||||
except urllib.error.URLError as e:
|
except urllib.error.URLError as e:
|
||||||
return f"Ошибка связи с Ollama ({OLLAMA_URL}): {e}", chat_history
|
return f"Ошибка связи с Ollama ({OLLAMA_URL}): {e}", chat_history
|
||||||
+43
-16
@@ -8,6 +8,16 @@ def get_db_connection():
|
|||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
def normalize_task_id(task_id_input: str) -> str:
|
||||||
|
"""Преобразует 6, '6', 'task-6' в красивый формат TASK-06 или TASK-006"""
|
||||||
|
if not task_id_input:
|
||||||
|
return ""
|
||||||
|
clean_id = str(task_id_input).strip().upper().replace("TASK-", "").replace("TASK", "")
|
||||||
|
if clean_id.isdigit():
|
||||||
|
num = int(clean_id)
|
||||||
|
return f"TASK-{num:02d}" if num < 100 else f"TASK-{num:03d}"
|
||||||
|
return f"TASK-{clean_id}"
|
||||||
|
|
||||||
# === ЗАДАЧИ С ФИЛЬТРАЦИЕЙ ПО USER_ID ===
|
# === ЗАДАЧИ С ФИЛЬТРАЦИЕЙ ПО USER_ID ===
|
||||||
|
|
||||||
def db_get_tasks(user_id: int) -> List[Dict[str, Any]]:
|
def db_get_tasks(user_id: int) -> List[Dict[str, Any]]:
|
||||||
@@ -40,47 +50,51 @@ def db_add_task(user_id: int, module: str, title: str, priority: str = "MEDIUM",
|
|||||||
conn.close()
|
conn.close()
|
||||||
return {"status": "success", "task_id": new_task_id, "message": f"Задача {new_task_id} создана"}
|
return {"status": "success", "task_id": new_task_id, "message": f"Задача {new_task_id} создана"}
|
||||||
|
|
||||||
def db_update_task_status(user_id: int, task_id: str, status: str, due_date: Optional[str] = None) -> Dict[str, Any]:
|
def db_update_task_status(user_id: int, task_id: str, status: str = "COMPLETED", due_date: Optional[str] = None) -> Dict[str, Any]:
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
task_id_upper = task_id.upper().strip()
|
formatted_id = normalize_task_id(task_id)
|
||||||
|
|
||||||
if due_date:
|
if due_date:
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
UPDATE tasks
|
UPDATE tasks
|
||||||
SET status = ?, due_date = ?
|
SET status = ?, due_date = ?
|
||||||
WHERE UPPER(task_id) = ? AND user_id = ?
|
WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
|
||||||
""", (status.upper(), due_date, task_id_upper, user_id))
|
""", (status.upper(), due_date, formatted_id, f"%{task_id.strip()}", user_id))
|
||||||
else:
|
else:
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
UPDATE tasks
|
UPDATE tasks
|
||||||
SET status = ?
|
SET status = ?
|
||||||
WHERE UPPER(task_id) = ? AND user_id = ?
|
WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
|
||||||
""", (status.upper(), task_id_upper, user_id))
|
""", (status.upper(), formatted_id, f"%{task_id.strip()}", user_id))
|
||||||
|
|
||||||
if cursor.rowcount == 0:
|
if cursor.rowcount == 0:
|
||||||
conn.close()
|
conn.close()
|
||||||
return {"error": f"Задача {task_id_upper} не найдена или принадлежит другому пользователю"}
|
return {"error": f"Задача {task_id} не найдена или принадлежит другому пользователю"}
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return {"status": "success", "message": f"Статус {task_id_upper} обновлен"}
|
return {"status": "success", "message": f"Статус задачи {formatted_id} обновлен на {status.upper()}"}
|
||||||
|
|
||||||
def db_delete_task(user_id: int, task_id: str) -> Dict[str, Any]:
|
def db_delete_task(user_id: int, task_id: str) -> Dict[str, Any]:
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
task_id_upper = task_id.upper().strip()
|
|
||||||
|
|
||||||
cursor.execute("DELETE FROM tasks WHERE UPPER(task_id) = ? AND user_id = ?", (task_id_upper, user_id))
|
formatted_id = normalize_task_id(task_id)
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
DELETE FROM tasks
|
||||||
|
WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
|
||||||
|
""", (formatted_id, f"%{task_id.strip()}", user_id))
|
||||||
|
|
||||||
if cursor.rowcount == 0:
|
if cursor.rowcount == 0:
|
||||||
conn.close()
|
conn.close()
|
||||||
return {"error": f"Задача {task_id_upper} не найдена или принадлежит другому пользователю"}
|
return {"error": f"Задача {task_id} не найдена"}
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return {"status": "success", "message": f"Задача {task_id_upper} удалена"}
|
return {"status": "success", "message": f"Задача {formatted_id} удалена"}
|
||||||
|
|
||||||
# === ОБЩИЕ СИСТЕМНЫЕ РЕСУРСЫ ===
|
# === ОБЩИЕ СИСТЕМНЫЕ РЕСУРСЫ ===
|
||||||
|
|
||||||
@@ -95,11 +109,24 @@ def db_get_active_system_prompt() -> str:
|
|||||||
def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]:
|
def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]:
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("UPDATE system_prompts SET is_active = 0")
|
|
||||||
cursor.execute("INSERT INTO system_prompts (name, prompt_text, is_active) VALUES (?, ?, 1)", (name, prompt_text))
|
# Проверяем наличие промпта с таким именем, чтобы не плодить мусор
|
||||||
|
cursor.execute("SELECT id FROM system_prompts WHERE name = ?", (name,))
|
||||||
|
existing = cursor.fetchone()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE system_prompts
|
||||||
|
SET prompt_text = ?, updated_at = CURRENT_TIMESTAMP, is_active = 1
|
||||||
|
WHERE name = ?
|
||||||
|
""", (prompt_text, name))
|
||||||
|
else:
|
||||||
|
cursor.execute("UPDATE system_prompts SET is_active = 0")
|
||||||
|
cursor.execute("INSERT INTO system_prompts (name, prompt_text, is_active) VALUES (?, ?, 1)", (name, prompt_text))
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return {"status": "success", "message": "Системный промпт обновлен"}
|
return {"status": "success", "message": "Системный промпт успешно обновлен"}
|
||||||
|
|
||||||
def db_get_rules() -> List[Dict[str, Any]]:
|
def db_get_rules() -> List[Dict[str, Any]]:
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
@@ -107,4 +134,4 @@ def db_get_rules() -> List[Dict[str, Any]]:
|
|||||||
cursor.execute("SELECT id, rule_text FROM ai_knowledge_base ORDER BY id ASC")
|
cursor.execute("SELECT id, rule_text FROM ai_knowledge_base ORDER BY id ASC")
|
||||||
rows = cursor.fetchall()
|
rows = cursor.fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
+2
-2
@@ -35,11 +35,11 @@ TOOLS_SCHEMA = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "db_add_system_prompt",
|
"name": "db_add_system_prompt",
|
||||||
"description": "Обновить или добавить системный промпт ИИ в базу данных. Вызывай при командах 'задай системный промпт', 'измени промпт', 'обнови системный промпт'.",
|
"description": "Изменить, записать или добавить новый системный промпт ассистента в базу данных. Вызывай этот инструмент ВСЕГДА, когда пользователь просит 'добавь системный промпт', 'запиши промпт', 'измени инструкции ИИ'.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"prompt_text": {"type": "string", "description": "Полный новый текст системного промпта"},
|
"prompt_text": {"type": "string", "description": "Полный текст нового системного промпта"},
|
||||||
"name": {"type": "string", "description": "Имя промпта, по умолчанию main_agent"}
|
"name": {"type": "string", "description": "Имя промпта, по умолчанию main_agent"}
|
||||||
},
|
},
|
||||||
"required": ["prompt_text"]
|
"required": ["prompt_text"]
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import json
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import logging
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
from fastapi import FastAPI, Depends, HTTPException, status
|
from fastapi import FastAPI, Depends, HTTPException, status
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
@@ -8,45 +15,196 @@ from fastapi.responses import FileResponse
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from llm.agent import process_chat_message
|
from llm.agent import process_chat_message
|
||||||
from llm.db_tools import db_get_tasks
|
from llm.db_tools import db_get_tasks, DB_PATH
|
||||||
|
|
||||||
API_TOKEN = "scud_secret_token_2026"
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
handlers=[logging.StreamHandler()]
|
||||||
|
)
|
||||||
|
|
||||||
|
JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure"
|
||||||
|
ALGORITHM = "HS256"
|
||||||
OLLAMA_URL = "http://192.168.11.3:11434/api/chat"
|
OLLAMA_URL = "http://192.168.11.3:11434/api/chat"
|
||||||
MODEL_NAME = "qwen2.5:14b"
|
MODEL_NAME = "qwen2.5:14b"
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
|
|
||||||
def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
def get_db():
|
||||||
if credentials.credentials != API_TOKEN:
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def create_access_token(user_id: int, username: str, is_admin: bool) -> str:
|
||||||
|
payload = {
|
||||||
|
"sub": str(user_id),
|
||||||
|
"username": username,
|
||||||
|
"is_admin": is_admin,
|
||||||
|
"exp": datetime.utcnow() + timedelta(days=30)
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
token = credentials.credentials
|
||||||
|
payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM])
|
||||||
|
user_id = int(payload.get("sub"))
|
||||||
|
username = payload.get("username")
|
||||||
|
is_admin = bool(payload.get("is_admin", False))
|
||||||
|
return {"id": user_id, "username": username, "is_admin": is_admin}
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning(f"Auth error: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Неверный токен доступа",
|
detail="Недействительный или просроченный токен авторизации",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
return credentials.credentials
|
|
||||||
|
|
||||||
app = FastAPI(title="SCUD Orion AI Context API")
|
app = FastAPI(title="SCUD Orion AI Context API")
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
if os.path.exists("static"):
|
||||||
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
|
||||||
|
class AuthRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
class CreateUserRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
is_admin: Optional[bool] = False
|
||||||
|
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
old_password: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
class ChatRequest(BaseModel):
|
class ChatRequest(BaseModel):
|
||||||
session_id: str
|
session_id: str
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
# === API МАРШРУТЫ ===
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def read_root():
|
def read_root():
|
||||||
return FileResponse("static/index.html")
|
return FileResponse("static/index.html")
|
||||||
|
|
||||||
|
@app.post("/api/v1/auth/login")
|
||||||
|
def login(req: AuthRequest):
|
||||||
|
username = req.username.strip().lower()
|
||||||
|
logging.info(f"===> Попытка входа для пользователя: {username}")
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", (username,))
|
||||||
|
user = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
logging.warning(f"===> Ошибка: Пользователь {username} не найден")
|
||||||
|
raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль")
|
||||||
|
|
||||||
|
if not pwd_context.verify(req.password, user["password_hash"]):
|
||||||
|
logging.warning(f"===> Ошибка: Неверный пароль для {username}")
|
||||||
|
raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль")
|
||||||
|
|
||||||
|
is_admin = bool(user["is_admin"]) or (user["username"] == "puh")
|
||||||
|
token = create_access_token(user["id"], user["username"], is_admin)
|
||||||
|
logging.info(f"===> УСПЕХ: Авторизован пользователь {username}")
|
||||||
|
|
||||||
|
return {"status": "success", "token": token, "username": user["username"], "is_admin": is_admin}
|
||||||
|
|
||||||
|
@app.post("/api/v1/auth/change-password")
|
||||||
|
def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = Depends(get_current_user)):
|
||||||
|
if not req.new_password or len(req.new_password) < 4:
|
||||||
|
raise HTTPException(status_code=400, detail="Новый пароль должен содержать минимум 4 символа")
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT password_hash FROM users WHERE id = ?", (current_user["id"],))
|
||||||
|
user = cursor.fetchone()
|
||||||
|
|
||||||
|
if not user or not pwd_context.verify(req.old_password, user["password_hash"]):
|
||||||
|
conn.close()
|
||||||
|
raise HTTPException(status_code=400, detail="Неверный старый пароль")
|
||||||
|
|
||||||
|
new_hash = pwd_context.hash(req.new_password)
|
||||||
|
cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, current_user["id"]))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
logging.info(f"Пароль изменен для пользователя ID: {current_user['id']}")
|
||||||
|
return {"status": "success", "message": "Пароль успешно изменен"}
|
||||||
|
|
||||||
|
@app.get("/api/v1/admin/users")
|
||||||
|
def list_users(current_user: Dict[str, Any] = Depends(get_current_user)):
|
||||||
|
if not current_user["is_admin"]:
|
||||||
|
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT id, username, full_name, is_admin, created_at FROM users ORDER BY id ASC")
|
||||||
|
users = [dict(r) for r in cursor.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
return users
|
||||||
|
|
||||||
|
@app.post("/api/v1/admin/users")
|
||||||
|
def create_user(req: CreateUserRequest, current_user: Dict[str, Any] = Depends(get_current_user)):
|
||||||
|
if not current_user["is_admin"]:
|
||||||
|
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
|
||||||
|
|
||||||
|
username = req.username.strip().lower()
|
||||||
|
if not username or not req.password:
|
||||||
|
raise HTTPException(status_code=400, detail="Заполните имя пользователя и пароль")
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT id FROM users WHERE username = ?", (username,))
|
||||||
|
if cursor.fetchone():
|
||||||
|
conn.close()
|
||||||
|
raise HTTPException(status_code=400, detail="Пользователь с таким именем уже существует")
|
||||||
|
|
||||||
|
pwd_hash = pwd_context.hash(req.password)
|
||||||
|
full_name = req.full_name.strip() if req.full_name else None
|
||||||
|
is_admin = 1 if req.is_admin else 0
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO users (username, password_hash, full_name, is_admin) VALUES (?, ?, ?, ?)",
|
||||||
|
(username, pwd_hash, full_name, is_admin)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
logging.info(f"Создан пользователь: {username} (admin={is_admin}) админом {current_user['username']}")
|
||||||
|
return {"status": "success", "message": f"Пользователь {username} создан"}
|
||||||
|
|
||||||
|
@app.delete("/api/v1/admin/users/{user_id}")
|
||||||
|
def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current_user)):
|
||||||
|
if not current_user["is_admin"]:
|
||||||
|
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
|
||||||
|
|
||||||
|
if user_id == current_user["id"]:
|
||||||
|
raise HTTPException(status_code=400, detail="Нельзя удалить самого себя")
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
logging.info(f"Удален пользователь ID: {user_id}")
|
||||||
|
return {"status": "success", "message": "Пользователь удален"}
|
||||||
|
|
||||||
@app.get("/api/v1/tasks")
|
@app.get("/api/v1/tasks")
|
||||||
def get_tasks(token: str = Depends(verify_token)):
|
def get_tasks(user: Dict[str, Any] = Depends(get_current_user)):
|
||||||
return db_get_tasks()
|
return db_get_tasks(user_id=user["id"])
|
||||||
|
|
||||||
@app.post("/api/v1/chat")
|
@app.post("/api/v1/chat")
|
||||||
def chat_endpoint(req: ChatRequest, token: str = Depends(verify_token)):
|
def chat_endpoint(req: ChatRequest, user: Dict[str, Any] = Depends(get_current_user)):
|
||||||
reply, _ = process_chat_message(req.message)
|
reply, _ = process_chat_message(user_id=user["id"], user_message=req.message)
|
||||||
return {"reply": reply}
|
return {"reply": reply}
|
||||||
|
|
||||||
# Эндпоинт для гостевого режима (без авторизации и без привязки к проекту)
|
|
||||||
@app.post("/api/v1/chat/guest")
|
@app.post("/api/v1/chat/guest")
|
||||||
def guest_chat_endpoint(req: ChatRequest):
|
def guest_chat_endpoint(req: ChatRequest):
|
||||||
payload = {
|
payload = {
|
||||||
@@ -58,7 +216,6 @@ def guest_chat_endpoint(req: ChatRequest):
|
|||||||
"stream": False,
|
"stream": False,
|
||||||
"options": {"num_predict": 2048, "temperature": 0.3}
|
"options": {"num_predict": 2048, "temperature": 0.3}
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
req_ollama = urllib.request.Request(
|
req_ollama = urllib.request.Request(
|
||||||
OLLAMA_URL,
|
OLLAMA_URL,
|
||||||
@@ -71,3 +228,24 @@ def guest_chat_endpoint(req: ChatRequest):
|
|||||||
return {"reply": reply}
|
return {"reply": reply}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"reply": f"Ошибка связи с локальной нейросетью: {e}"}
|
return {"reply": f"Ошибка связи с локальной нейросетью: {e}"}
|
||||||
|
|
||||||
|
# === СТРОГО В КОНЦЕ: ФОЛЛБЭК СТАТИКИ ===
|
||||||
|
|
||||||
|
@app.get("/{file_path:path}")
|
||||||
|
def serve_static_fallback(file_path: str):
|
||||||
|
clean_path = file_path.lstrip("/")
|
||||||
|
|
||||||
|
target = os.path.join("static", clean_path)
|
||||||
|
if os.path.isfile(target):
|
||||||
|
return FileResponse(target)
|
||||||
|
|
||||||
|
filename = os.path.basename(clean_path)
|
||||||
|
target_js = os.path.join("static/js", filename)
|
||||||
|
if filename.endswith(".js") and os.path.isfile(target_js):
|
||||||
|
return FileResponse(target_js, media_type="application/javascript")
|
||||||
|
|
||||||
|
target_css = os.path.join("static/css", filename)
|
||||||
|
if filename.endswith(".css") and os.path.isfile(target_css):
|
||||||
|
return FileResponse(target_css, media_type="text/css")
|
||||||
|
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
+104
-31
@@ -10,7 +10,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body class="bg-slate-100 text-slate-800 h-[100dvh] w-full flex flex-col font-sans overflow-hidden">
|
<body class="bg-slate-100 text-slate-800 h-[100dvh] w-full flex flex-col font-sans overflow-hidden">
|
||||||
|
|
||||||
<!-- Окно авторизации / Регистрации -->
|
<!-- Окно авторизации -->
|
||||||
<div id="auth-modal" class="fixed inset-0 bg-slate-900/80 backdrop-blur-md z-50 flex items-center justify-center p-4">
|
<div id="auth-modal" class="fixed inset-0 bg-slate-900/80 backdrop-blur-md z-50 flex items-center justify-center p-4">
|
||||||
<div class="bg-white rounded-2xl p-6 sm:p-8 max-w-md w-full shadow-2xl border border-slate-200">
|
<div class="bg-white rounded-2xl p-6 sm:p-8 max-w-md w-full shadow-2xl border border-slate-200">
|
||||||
<div class="flex items-center space-x-3 mb-6">
|
<div class="flex items-center space-x-3 mb-6">
|
||||||
@@ -19,36 +19,28 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-bold text-slate-900">SCUD Orion AI</h2>
|
<h2 class="text-lg font-bold text-slate-900">SCUD Orion AI</h2>
|
||||||
<p id="auth-title" class="text-xs text-slate-500">Авторизация в системе</p>
|
<p class="text-xs text-slate-500">Авторизация в системе</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="auth-form" onsubmit="handleLogin(event)" class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1">Имя пользователя</label>
|
<label class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1">Имя пользователя</label>
|
||||||
<input type="text" id="auth-username-input" placeholder="Введите логин..." required
|
<input type="text" id="auth-username-input" placeholder="Введите логин..." required autocomplete="username"
|
||||||
class="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 focus:outline-none focus:border-indigo-600 focus:bg-white transition">
|
class="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 focus:outline-none focus:border-indigo-600 focus:bg-white transition">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1">Пароль</label>
|
<label class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1">Пароль</label>
|
||||||
<input type="password" id="auth-password-input" placeholder="Введите пароль..." required
|
<input type="password" id="auth-password-input" placeholder="Введите пароль..." required autocomplete="current-password"
|
||||||
class="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 focus:outline-none focus:border-indigo-600 focus:bg-white transition">
|
class="w-full bg-slate-50 border border-slate-300 rounded-xl px-4 py-2.5 text-sm text-slate-900 focus:outline-none focus:border-indigo-600 focus:bg-white transition">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="auth-error" class="hidden text-xs text-red-600 font-medium bg-red-50 p-3 rounded-xl border border-red-200">
|
<div id="auth-error" class="hidden text-xs text-red-600 font-medium bg-red-50 p-3 rounded-xl border border-red-200"></div>
|
||||||
Неверный токен доступа.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" id="auth-btn" class="w-full bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white font-semibold py-3 rounded-xl text-sm transition shadow-md flex items-center justify-center gap-2">
|
<button type="button" onclick="handleLogin()" id="auth-btn" class="w-full bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white font-semibold py-3 rounded-xl text-sm transition shadow-md flex items-center justify-center gap-2">
|
||||||
<i class="fa-solid fa-right-to-bracket"></i>
|
<i class="fa-solid fa-right-to-bracket"></i>
|
||||||
<span id="auth-submit-text">Войти в систему</span>
|
<span>Войти в систему</span>
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="mt-3 text-center">
|
|
||||||
<button type="button" id="auth-toggle-btn" onclick="toggleAuthMode()" class="text-xs text-indigo-600 hover:underline font-medium">
|
|
||||||
Создать новый аккаунт
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -57,13 +49,86 @@
|
|||||||
<div class="relative flex justify-center text-xs uppercase"><span class="bg-white px-2 text-slate-400 font-medium">Или</span></div>
|
<div class="relative flex justify-center text-xs uppercase"><span class="bg-white px-2 text-slate-400 font-medium">Или</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button onclick="enableGuestMode()" class="w-full bg-slate-100 hover:bg-slate-200 text-slate-700 font-semibold py-2.5 rounded-xl text-xs transition border border-slate-300 flex items-center justify-center gap-2">
|
<button type="button" onclick="enableGuestMode()" class="w-full bg-slate-100 hover:bg-slate-200 text-slate-700 font-semibold py-2.5 rounded-xl text-xs transition border border-slate-300 flex items-center justify-center gap-2">
|
||||||
<i class="fa-solid fa-user-ninja"></i>
|
<i class="fa-solid fa-user-ninja"></i>
|
||||||
<span>Войти как гость (Локальный ИИ)</span>
|
<span>Войти как гость (Локальный ИИ)</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно смены пароля -->
|
||||||
|
<div id="change-pwd-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
|
||||||
|
<div class="bg-white rounded-2xl p-6 max-w-sm w-full shadow-2xl border border-slate-200">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="font-bold text-slate-800 text-sm flex items-center gap-2">
|
||||||
|
<i class="fa-solid fa-key text-indigo-600"></i> Смена пароля
|
||||||
|
</h3>
|
||||||
|
<button type="button" onclick="closeChangePasswordModal()" class="text-slate-400 hover:text-slate-700">
|
||||||
|
<i class="fa-solid fa-xmark text-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-[11px] font-semibold text-slate-600 uppercase mb-1">Старый пароль</label>
|
||||||
|
<input type="password" id="old-pwd-input" required class="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2 text-xs text-slate-900 focus:outline-none focus:border-indigo-600">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-[11px] font-semibold text-slate-600 uppercase mb-1">Новый пароль</label>
|
||||||
|
<input type="password" id="new-pwd-input" required class="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2 text-xs text-slate-900 focus:outline-none focus:border-indigo-600">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-[11px] font-semibold text-slate-600 uppercase mb-1">Повторите новый пароль</label>
|
||||||
|
<input type="password" id="confirm-pwd-input" required class="w-full bg-slate-50 border border-slate-300 rounded-xl px-3 py-2 text-xs text-slate-900 focus:outline-none focus:border-indigo-600">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pwd-error" class="hidden text-xs text-red-600 bg-red-50 p-2 rounded-lg border border-red-200"></div>
|
||||||
|
<div id="pwd-success" class="hidden text-xs text-emerald-600 bg-emerald-50 p-2 rounded-lg border border-emerald-200"></div>
|
||||||
|
|
||||||
|
<button type="button" onclick="handleChangePassword()" class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2.5 rounded-xl text-xs transition shadow-sm mt-2">
|
||||||
|
Сохранить новый пароль
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно управления пользователями -->
|
||||||
|
<div id="admin-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
|
||||||
|
<div class="bg-white rounded-2xl p-6 max-w-lg w-full shadow-2xl border border-slate-200 flex flex-col max-h-[85vh]">
|
||||||
|
<div class="flex justify-between items-center mb-4 pb-2 border-b border-slate-200">
|
||||||
|
<h3 class="font-bold text-slate-800 text-sm flex items-center gap-2">
|
||||||
|
<i class="fa-solid fa-users-gear text-indigo-600"></i> Управление пользователями
|
||||||
|
</h3>
|
||||||
|
<button type="button" onclick="closeAdminModal()" class="text-slate-400 hover:text-slate-700">
|
||||||
|
<i class="fa-solid fa-xmark text-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2 mb-4 bg-slate-50 p-3.5 rounded-xl border border-slate-200 shrink-0">
|
||||||
|
<p class="text-[11px] font-bold text-slate-700 uppercase">Создать нового пользователя</p>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<input type="text" id="new-user-name" placeholder="Логин *" required class="bg-white border border-slate-300 rounded-lg px-2.5 py-1.5 text-xs">
|
||||||
|
<input type="password" id="new-user-pwd" placeholder="Пароль *" required class="bg-white border border-slate-300 rounded-lg px-2.5 py-1.5 text-xs">
|
||||||
|
</div>
|
||||||
|
<input type="text" id="new-user-fullname" placeholder="ФИО (необязательно)" class="w-full bg-white border border-slate-300 rounded-lg px-2.5 py-1.5 text-xs">
|
||||||
|
<div class="flex items-center justify-between pt-1">
|
||||||
|
<label class="flex items-center gap-2 text-xs text-slate-700 cursor-pointer">
|
||||||
|
<input type="checkbox" id="new-user-is-admin" class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
||||||
|
<span>Права администратора</span>
|
||||||
|
</label>
|
||||||
|
<button type="button" onclick="handleCreateUser()" class="bg-indigo-600 hover:bg-indigo-700 text-white font-semibold px-4 py-1.5 rounded-lg text-xs transition">
|
||||||
|
+ Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="admin-msg" class="hidden text-[11px] text-red-600 pt-1"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto space-y-2 pr-1" id="admin-users-list">
|
||||||
|
<div class="text-xs text-slate-400 py-4 text-center">Загрузка пользователей...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Хедер -->
|
<!-- Хедер -->
|
||||||
<header class="bg-white border-b border-slate-200 px-4 py-2.5 flex justify-between items-center shadow-sm shrink-0 z-20">
|
<header class="bg-white border-b border-slate-200 px-4 py-2.5 flex justify-between items-center shadow-sm shrink-0 z-20">
|
||||||
<div class="flex items-center space-x-2.5">
|
<div class="flex items-center space-x-2.5">
|
||||||
@@ -76,21 +141,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-1.5">
|
||||||
<span id="guest-badge" class="hidden text-[10px] text-amber-700 font-semibold bg-amber-50 px-2 py-0.5 rounded-full border border-amber-200">
|
<span id="guest-badge" class="hidden text-[10px] text-amber-700 font-semibold bg-amber-50 px-2 py-0.5 rounded-full border border-amber-200">
|
||||||
Гость
|
Гость
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span id="username-badge" class="hidden text-xs text-indigo-700 font-bold bg-indigo-50 px-2.5 py-1 rounded-full border border-indigo-200">
|
<span id="username-badge" class="hidden text-xs text-indigo-700 font-bold bg-indigo-50 px-2.5 py-1 rounded-full border border-indigo-200">
|
||||||
Admin
|
puh
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<button id="tasks-drawer-btn" onclick="toggleDrawer()" class="bg-indigo-600 active:bg-indigo-700 text-white px-3 py-1.5 rounded-xl text-xs font-semibold flex items-center gap-1.5 shadow-sm">
|
<button id="admin-users-btn" type="button" onclick="openAdminModal()" class="hidden text-slate-500 hover:text-indigo-600 transition p-2 rounded-xl" title="Управление пользователями">
|
||||||
|
<i class="fa-solid fa-users-gear text-base"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button id="change-pwd-btn" type="button" onclick="openChangePasswordModal()" class="hidden text-slate-500 hover:text-indigo-600 transition p-2 rounded-xl" title="Сменить пароль">
|
||||||
|
<i class="fa-solid fa-key text-base"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button id="tasks-drawer-btn" type="button" onclick="toggleDrawer()" class="bg-indigo-600 active:bg-indigo-700 text-white px-3 py-1.5 rounded-xl text-xs font-semibold flex items-center gap-1.5 shadow-sm">
|
||||||
<i class="fa-solid fa-list-check"></i>
|
<i class="fa-solid fa-list-check"></i>
|
||||||
<span>Задачи</span>
|
<span>Задачи</span>
|
||||||
<span id="task-count-badge" class="bg-white text-indigo-700 text-[10px] font-bold px-1.5 py-0.2 rounded-full">0</span>
|
<span id="task-count-badge" class="bg-white text-indigo-700 text-[10px] font-bold px-1.5 py-0.2 rounded-full">0</span>
|
||||||
</button>
|
</button>
|
||||||
<button onclick="logout()" class="text-slate-400 hover:text-red-600 transition p-2 rounded-xl" title="Выйти">
|
|
||||||
|
<button type="button" onclick="logout()" class="text-slate-400 hover:text-red-600 transition p-2 rounded-xl" title="Выйти">
|
||||||
<i class="fa-solid fa-right-from-bracket text-base"></i>
|
<i class="fa-solid fa-right-from-bracket text-base"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,19 +183,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Поле ввода -->
|
|
||||||
<div class="p-2.5 pb-6 bg-white border-t border-slate-200 shrink-0 z-10 shadow-lg">
|
<div class="p-2.5 pb-6 bg-white border-t border-slate-200 shrink-0 z-10 shadow-lg">
|
||||||
<form id="chat-form" onsubmit="sendMessage(event)" class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="flex-1 bg-slate-100 border border-slate-300 rounded-2xl px-3 py-1.5 focus-within:border-indigo-600 focus-within:bg-white transition">
|
<div class="flex-1 bg-slate-100 border border-slate-300 rounded-2xl px-3 py-1.5 focus-within:border-indigo-600 focus-within:bg-white transition">
|
||||||
<textarea id="user-input" rows="1" autocomplete="off" autocorrect="off" spellcheck="false"
|
<textarea id="user-input" rows="1" autocomplete="off" autocorrect="off" spellcheck="false"
|
||||||
placeholder="Команда или вопрос..."
|
placeholder="Команда или вопрос..."
|
||||||
class="w-full bg-transparent text-slate-900 text-sm focus:outline-none resize-none overflow-y-auto max-h-[80px] leading-normal no-scrollbar"></textarea>
|
class="w-full bg-transparent text-slate-900 text-sm focus:outline-none resize-none overflow-y-auto max-h-[80px] leading-normal no-scrollbar"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" id="send-btn" class="bg-indigo-600 active:bg-indigo-800 text-white font-semibold px-3.5 py-2.5 rounded-2xl text-xs sm:text-sm transition flex items-center justify-center gap-1.5 shrink-0 shadow-sm">
|
<button type="button" id="send-btn" onclick="sendMessage()" class="bg-indigo-600 active:bg-indigo-800 text-white font-semibold px-3.5 py-2.5 rounded-2xl text-xs sm:text-sm transition flex items-center justify-center gap-1.5 shrink-0 shadow-sm">
|
||||||
<span>Отправить</span>
|
<span>Отправить</span>
|
||||||
<i class="fa-solid fa-paper-plane text-xs"></i>
|
<i class="fa-solid fa-paper-plane text-xs"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -134,20 +207,20 @@
|
|||||||
<i class="fa-solid fa-list-check text-indigo-600"></i> Мой реестр задач
|
<i class="fa-solid fa-list-check text-indigo-600"></i> Мой реестр задач
|
||||||
</h2>
|
</h2>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button onclick="loadTasks()" class="text-xs text-slate-500 hover:text-indigo-600 transition p-1" title="Обновить">
|
<button type="button" onclick="loadTasks()" class="text-xs text-slate-500 hover:text-indigo-600 transition p-1" title="Обновить">
|
||||||
<i class="fa-solid fa-rotate-right text-sm"></i>
|
<i class="fa-solid fa-rotate-right text-sm"></i>
|
||||||
</button>
|
</button>
|
||||||
<button onclick="toggleDrawer()" class="text-slate-500 hover:text-slate-800 transition p-1">
|
<button type="button" onclick="toggleDrawer()" class="text-slate-500 hover:text-slate-800 transition p-1">
|
||||||
<i class="fa-solid fa-xmark text-lg"></i>
|
<i class="fa-solid fa-xmark text-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex border-b border-slate-200 bg-white px-2 pt-2 text-xs font-semibold text-slate-500 gap-1 overflow-x-auto no-scrollbar shrink-0">
|
<div class="flex border-b border-slate-200 bg-white px-2 pt-2 text-xs font-semibold text-slate-500 gap-1 overflow-x-auto no-scrollbar shrink-0">
|
||||||
<button onclick="setFilter('ALL')" id="filter-ALL" class="px-3 py-1.5 rounded-t-lg border-b-2 border-indigo-600 text-indigo-600 font-bold whitespace-nowrap">Все</button>
|
<button type="button" onclick="setFilter('ALL')" id="filter-ALL" class="px-3 py-1.5 rounded-t-lg border-b-2 border-indigo-600 text-indigo-600 font-bold whitespace-nowrap">Все</button>
|
||||||
<button onclick="setFilter('IN_PROGRESS')" id="filter-IN_PROGRESS" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap">В работе</button>
|
<button type="button" onclick="setFilter('IN_PROGRESS')" id="filter-IN_PROGRESS" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap">В работе</button>
|
||||||
<button onclick="setFilter('BACKLOG')" id="filter-BACKLOG" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap">Бэклог</button>
|
<button type="button" onclick="setFilter('BACKLOG')" id="filter-BACKLOG" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap">Бэклог</button>
|
||||||
<button onclick="setFilter('COMPLETED')" id="filter-COMPLETED" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap">Завершено</button>
|
<button type="button" onclick="setFilter('COMPLETED')" id="filter-COMPLETED" class="px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap">Завершено</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="tasks-container" class="flex-1 overflow-y-auto p-3.5 space-y-3 bg-slate-50/50 pb-8">
|
<div id="tasks-container" class="flex-1 overflow-y-auto p-3.5 space-y-3 bg-slate-50/50 pb-8">
|
||||||
|
|||||||
+39
-21
@@ -1,31 +1,46 @@
|
|||||||
const AUTH_TOKEN_KEY = "scud_api_auth_token";
|
const AUTH_TOKEN_KEY = "scud_api_auth_token";
|
||||||
const SESSION_ID = "web_session_main";
|
const SESSION_ID = "web_session_main";
|
||||||
const STORAGE_KEY = 'scud_chat_input_history';
|
const STORAGE_KEY = "scud_chat_input_history";
|
||||||
|
|
||||||
let API_TOKEN = localStorage.getItem(AUTH_TOKEN_KEY) || "";
|
let API_TOKEN = localStorage.getItem(AUTH_TOKEN_KEY) || "";
|
||||||
let CURRENT_USERNAME = localStorage.getItem('scud_username') || "";
|
let CURRENT_USERNAME = localStorage.getItem("scud_username") || "";
|
||||||
let IS_GUEST = localStorage.getItem('scud_is_guest') === 'true';
|
let IS_ADMIN = localStorage.getItem("scud_is_admin") === "true";
|
||||||
let currentFilter = 'ALL';
|
let IS_GUEST = localStorage.getItem("scud_is_guest") === "true";
|
||||||
let allTasks = [];
|
|
||||||
|
|
||||||
let inputHistory = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
|
let inputHistory = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
|
||||||
let historyIndex = -1;
|
let historyIndex = -1;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const userInputEl = document.getElementById('user-input');
|
const userInputEl = document.getElementById("user-input");
|
||||||
|
|
||||||
if (userInputEl) {
|
if (userInputEl) {
|
||||||
userInputEl.addEventListener('input', function() {
|
userInputEl.addEventListener("input", function() {
|
||||||
this.style.height = 'auto';
|
this.style.height = "auto";
|
||||||
this.style.height = Math.min(this.scrollHeight, 80) + 'px';
|
this.style.height = Math.min(this.scrollHeight, 80) + "px";
|
||||||
});
|
});
|
||||||
|
|
||||||
userInputEl.addEventListener('keydown', function(e) {
|
userInputEl.addEventListener("keydown", function(e) {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
// Отправка по Enter
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
document.getElementById('chat-form').requestSubmit();
|
|
||||||
|
const val = this.value.trim();
|
||||||
|
if (val) {
|
||||||
|
// Сохраняем команду в историю
|
||||||
|
if (inputHistory.length === 0 || inputHistory[inputHistory.length - 1] !== val) {
|
||||||
|
inputHistory.push(val);
|
||||||
|
if (inputHistory.length > 50) inputHistory.shift();
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(inputHistory));
|
||||||
|
}
|
||||||
|
historyIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof sendMessage === "function") {
|
||||||
|
sendMessage(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if (e.key === 'ArrowUp') {
|
// История: стрелка ВВЕРХ
|
||||||
|
else if (e.key === "ArrowUp") {
|
||||||
if (inputHistory.length > 0 && historyIndex < inputHistory.length - 1) {
|
if (inputHistory.length > 0 && historyIndex < inputHistory.length - 1) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (historyIndex === -1) {
|
if (historyIndex === -1) {
|
||||||
@@ -33,11 +48,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
historyIndex++;
|
historyIndex++;
|
||||||
this.value = inputHistory[inputHistory.length - 1 - historyIndex];
|
this.value = inputHistory[inputHistory.length - 1 - historyIndex];
|
||||||
this.dispatchEvent(new Event('input'));
|
this.dispatchEvent(new Event("input"));
|
||||||
setTimeout(() => this.setSelectionRange(this.value.length, this.value.length), 0);
|
setTimeout(() => this.setSelectionRange(this.value.length, this.value.length), 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (e.key === 'ArrowDown') {
|
// История: стрелка ВНИЗ
|
||||||
|
else if (e.key === "ArrowDown") {
|
||||||
if (historyIndex !== -1) {
|
if (historyIndex !== -1) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (historyIndex > 0) {
|
if (historyIndex > 0) {
|
||||||
@@ -45,9 +61,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
this.value = inputHistory[inputHistory.length - 1 - historyIndex];
|
this.value = inputHistory[inputHistory.length - 1 - historyIndex];
|
||||||
} else {
|
} else {
|
||||||
historyIndex = -1;
|
historyIndex = -1;
|
||||||
this.value = this.dataset.draft || '';
|
this.value = this.dataset.draft || "";
|
||||||
}
|
}
|
||||||
this.dispatchEvent(new Event('input'));
|
this.dispatchEvent(new Event("input"));
|
||||||
setTimeout(() => this.setSelectionRange(this.value.length, this.value.length), 0);
|
setTimeout(() => this.setSelectionRange(this.value.length, this.value.length), 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,8 +76,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
} else if (API_TOKEN) {
|
} else if (API_TOKEN) {
|
||||||
hideAuthModal();
|
hideAuthModal();
|
||||||
updateUIState();
|
updateUIState();
|
||||||
loadTasks();
|
if (typeof loadTasks === "function") {
|
||||||
|
loadTasks();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
showAuthModal();
|
showAuthModal();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
+252
-60
@@ -1,79 +1,68 @@
|
|||||||
let authMode = 'login'; // 'login' или 'register'
|
|
||||||
|
|
||||||
function showAuthModal() {
|
function showAuthModal() {
|
||||||
document.getElementById('auth-modal').classList.remove('hidden');
|
const el = document.getElementById("auth-modal");
|
||||||
|
if (el) el.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideAuthModal() {
|
function hideAuthModal() {
|
||||||
document.getElementById('auth-modal').classList.add('hidden');
|
const el = document.getElementById("auth-modal");
|
||||||
}
|
if (el) el.classList.add("hidden");
|
||||||
|
|
||||||
function setAuthMode(mode) {
|
|
||||||
authMode = mode;
|
|
||||||
const titleEl = document.getElementById('auth-title');
|
|
||||||
const submitBtnText = document.getElementById('auth-submit-text');
|
|
||||||
const toggleBtn = document.getElementById('auth-toggle-btn');
|
|
||||||
const errorEl = document.getElementById('auth-error');
|
|
||||||
|
|
||||||
errorEl.classList.add('hidden');
|
|
||||||
|
|
||||||
if (mode === 'register') {
|
|
||||||
titleEl.innerText = "Регистрация нового пользователя";
|
|
||||||
submitBtnText.innerText = "Зарегистрироваться";
|
|
||||||
toggleBtn.innerText = "Уже есть аккаунт? Войти";
|
|
||||||
} else {
|
|
||||||
titleEl.innerText = "Авторизация в системе";
|
|
||||||
submitBtnText.innerText = "Войти в систему";
|
|
||||||
toggleBtn.innerText = "Создать новый аккаунт";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleAuthMode() {
|
|
||||||
setAuthMode(authMode === 'login' ? 'register' : 'login');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogin(e) {
|
async function handleLogin(e) {
|
||||||
e.preventDefault();
|
if (e && e.preventDefault) e.preventDefault();
|
||||||
const usernameInput = document.getElementById('auth-username-input');
|
|
||||||
const passwordInput = document.getElementById('auth-password-input');
|
const usernameInput = document.getElementById("auth-username-input");
|
||||||
const errorEl = document.getElementById('auth-error');
|
const passwordInput = document.getElementById("auth-password-input");
|
||||||
|
const errorEl = document.getElementById("auth-error");
|
||||||
|
|
||||||
|
if (!usernameInput || !passwordInput) return;
|
||||||
|
|
||||||
const username = usernameInput.value.trim();
|
const username = usernameInput.value.trim();
|
||||||
const password = passwordInput.value;
|
const password = passwordInput.value;
|
||||||
|
|
||||||
if (!username || !password) return;
|
if (!username || !password) return;
|
||||||
|
|
||||||
errorEl.classList.add('hidden');
|
if (errorEl) errorEl.classList.add("hidden");
|
||||||
const endpoint = authMode === 'register' ? '/api/v1/auth/register' : '/api/v1/auth/login';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(endpoint, {
|
const res = await fetch("/api/v1/auth/login", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ username, password })
|
body: JSON.stringify({ username, password })
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
|
// Используем прямой строковый ключ, чтобы избежать ошибки ReferenceError
|
||||||
API_TOKEN = data.token;
|
API_TOKEN = data.token;
|
||||||
CURRENT_USERNAME = data.username;
|
CURRENT_USERNAME = data.username;
|
||||||
|
IS_ADMIN = data.is_admin;
|
||||||
IS_GUEST = false;
|
IS_GUEST = false;
|
||||||
|
|
||||||
localStorage.setItem(AUTH_TOKEN_KEY, data.token);
|
localStorage.setItem("scud_api_auth_token", data.token);
|
||||||
localStorage.setItem('scud_username', data.username);
|
localStorage.setItem("scud_username", data.username);
|
||||||
localStorage.removeItem('scud_is_guest');
|
localStorage.setItem("scud_is_admin", data.is_admin ? "true" : "false");
|
||||||
|
localStorage.removeItem("scud_is_guest");
|
||||||
|
|
||||||
hideAuthModal();
|
hideAuthModal();
|
||||||
updateUIState();
|
updateUIState();
|
||||||
loadTasks();
|
|
||||||
|
if (typeof loadTasks === 'function') {
|
||||||
|
loadTasks();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
errorEl.innerText = data.detail || "Ошибка авторизации";
|
if (errorEl) {
|
||||||
errorEl.classList.remove('hidden');
|
errorEl.innerText = data.detail || "Ошибка авторизации";
|
||||||
|
errorEl.classList.remove("hidden");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorEl.innerText = "Ошибка соединения с сервером";
|
console.error("[Auth Error]", err);
|
||||||
errorEl.classList.remove('hidden');
|
if (errorEl) {
|
||||||
|
errorEl.innerText = "Ошибка соединения с сервером";
|
||||||
|
errorEl.classList.remove("hidden");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,36 +70,239 @@ function enableGuestMode() {
|
|||||||
IS_GUEST = true;
|
IS_GUEST = true;
|
||||||
API_TOKEN = "";
|
API_TOKEN = "";
|
||||||
CURRENT_USERNAME = "Гость";
|
CURRENT_USERNAME = "Гость";
|
||||||
localStorage.setItem('scud_is_guest', 'true');
|
IS_ADMIN = false;
|
||||||
|
localStorage.setItem("scud_is_guest", "true");
|
||||||
hideAuthModal();
|
hideAuthModal();
|
||||||
updateUIState();
|
updateUIState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
localStorage.removeItem(AUTH_TOKEN_KEY);
|
localStorage.removeItem("scud_api_auth_token");
|
||||||
localStorage.removeItem('scud_username');
|
localStorage.removeItem("scud_username");
|
||||||
localStorage.removeItem('scud_is_guest');
|
localStorage.removeItem("scud_is_admin");
|
||||||
|
localStorage.removeItem("scud_is_guest");
|
||||||
API_TOKEN = "";
|
API_TOKEN = "";
|
||||||
CURRENT_USERNAME = "";
|
CURRENT_USERNAME = "";
|
||||||
|
IS_ADMIN = false;
|
||||||
IS_GUEST = false;
|
IS_GUEST = false;
|
||||||
showAuthModal();
|
showAuthModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateUIState() {
|
function updateUIState() {
|
||||||
const tasksBtn = document.getElementById('tasks-drawer-btn');
|
const tasksBtn = document.getElementById("tasks-drawer-btn");
|
||||||
const guestBadge = document.getElementById('guest-badge');
|
const adminBtn = document.getElementById("admin-users-btn");
|
||||||
const usernameBadge = document.getElementById('username-badge');
|
const changePwdBtn = document.getElementById("change-pwd-btn");
|
||||||
|
const guestBadge = document.getElementById("guest-badge");
|
||||||
|
const usernameBadge = document.getElementById("username-badge");
|
||||||
|
|
||||||
if (IS_GUEST) {
|
if (typeof IS_GUEST !== 'undefined' && IS_GUEST) {
|
||||||
if (tasksBtn) tasksBtn.classList.add('hidden');
|
if (tasksBtn) tasksBtn.classList.add("hidden");
|
||||||
if (guestBadge) guestBadge.classList.remove('hidden');
|
if (adminBtn) adminBtn.classList.add("hidden");
|
||||||
if (usernameBadge) usernameBadge.classList.add('hidden');
|
if (changePwdBtn) changePwdBtn.classList.add("hidden");
|
||||||
|
if (guestBadge) guestBadge.classList.remove("hidden");
|
||||||
|
if (usernameBadge) usernameBadge.classList.add("hidden");
|
||||||
} else {
|
} else {
|
||||||
if (tasksBtn) tasksBtn.classList.remove('hidden');
|
if (tasksBtn) tasksBtn.classList.remove("hidden");
|
||||||
if (guestBadge) guestBadge.classList.add('hidden');
|
if (changePwdBtn) changePwdBtn.classList.remove("hidden");
|
||||||
|
if (guestBadge) guestBadge.classList.add("hidden");
|
||||||
|
|
||||||
if (usernameBadge) {
|
if (usernameBadge) {
|
||||||
usernameBadge.innerText = CURRENT_USERNAME || 'User';
|
usernameBadge.innerText = (typeof CURRENT_USERNAME !== 'undefined' && CURRENT_USERNAME) ? CURRENT_USERNAME : "User";
|
||||||
usernameBadge.classList.remove('hidden');
|
usernameBadge.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminBtn) {
|
||||||
|
const isAdminUser = (typeof IS_ADMIN !== 'undefined' && IS_ADMIN) || (typeof CURRENT_USERNAME !== 'undefined' && CURRENT_USERNAME === "puh");
|
||||||
|
if (isAdminUser) {
|
||||||
|
adminBtn.classList.remove("hidden");
|
||||||
|
} else {
|
||||||
|
adminBtn.classList.add("hidden");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openChangePasswordModal() {
|
||||||
|
const el = document.getElementById("change-pwd-modal");
|
||||||
|
if (el) el.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeChangePasswordModal() {
|
||||||
|
const el = document.getElementById("change-pwd-modal");
|
||||||
|
if (el) el.classList.add("hidden");
|
||||||
|
|
||||||
|
const err = document.getElementById("pwd-error");
|
||||||
|
const succ = document.getElementById("pwd-success");
|
||||||
|
if (err) err.classList.add("hidden");
|
||||||
|
if (succ) succ.classList.add("hidden");
|
||||||
|
|
||||||
|
document.getElementById("old-pwd-input").value = "";
|
||||||
|
document.getElementById("new-pwd-input").value = "";
|
||||||
|
const confirmInput = document.getElementById("confirm-pwd-input");
|
||||||
|
if (confirmInput) confirmInput.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleChangePassword(e) {
|
||||||
|
if (e && e.preventDefault) e.preventDefault();
|
||||||
|
const old_password = document.getElementById("old-pwd-input").value;
|
||||||
|
const new_password = document.getElementById("new-pwd-input").value;
|
||||||
|
const confirmInput = document.getElementById("confirm-pwd-input");
|
||||||
|
const confirm_password = confirmInput ? confirmInput.value : new_password;
|
||||||
|
const errorEl = document.getElementById("pwd-error");
|
||||||
|
const successEl = document.getElementById("pwd-success");
|
||||||
|
|
||||||
|
if (errorEl) errorEl.classList.add("hidden");
|
||||||
|
if (successEl) successEl.classList.add("hidden");
|
||||||
|
|
||||||
|
if (new_password !== confirm_password) {
|
||||||
|
if (errorEl) {
|
||||||
|
errorEl.innerText = "Новые пароли не совпадают";
|
||||||
|
errorEl.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
|
||||||
|
const res = await fetch("/api/v1/auth/change-password", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer " + token
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ old_password, new_password })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.status === 200) {
|
||||||
|
if (successEl) {
|
||||||
|
successEl.innerText = "Пароль успешно изменен!";
|
||||||
|
successEl.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
setTimeout(closeChangePasswordModal, 1500);
|
||||||
|
} else {
|
||||||
|
if (errorEl) {
|
||||||
|
errorEl.innerText = data.detail || "Ошибка при смене пароля";
|
||||||
|
errorEl.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (errorEl) {
|
||||||
|
errorEl.innerText = "Ошибка соединения с сервером";
|
||||||
|
errorEl.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAdminModal() {
|
||||||
|
const el = document.getElementById("admin-modal");
|
||||||
|
if (el) el.classList.remove("hidden");
|
||||||
|
loadUsersList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAdminModal() {
|
||||||
|
const el = document.getElementById("admin-modal");
|
||||||
|
if (el) el.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsersList() {
|
||||||
|
const listEl = document.getElementById("admin-users-list");
|
||||||
|
if (!listEl) return;
|
||||||
|
listEl.innerHTML = '<div class="text-xs text-slate-400 py-4 text-center">Загрузка пользователей...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
|
||||||
|
const res = await fetch("/api/v1/admin/users", {
|
||||||
|
headers: { "Authorization": "Bearer " + token }
|
||||||
|
});
|
||||||
|
const users = await res.json();
|
||||||
|
|
||||||
|
if (res.status === 200) {
|
||||||
|
listEl.innerHTML = users.map(u => {
|
||||||
|
const adminTag = u.is_admin ? '<span class="ml-1.5 text-[9px] bg-indigo-100 text-indigo-700 px-1.5 py-0.5 rounded font-bold">ADMIN</span>' : '<span class="ml-1.5 text-[9px] bg-slate-100 text-slate-600 px-1.5 py-0.5 rounded">USER</span>';
|
||||||
|
const fullNameHtml = u.full_name ? `<div class="text-[11px] text-slate-500 font-normal">${u.full_name}</div>` : '';
|
||||||
|
const dateStr = u.created_at ? u.created_at.split(' ')[0] : '—';
|
||||||
|
const deleteBtn = u.username !== CURRENT_USERNAME ? `<button type="button" onclick="deleteUser(${u.id}, '${u.username}')" class="text-red-500 hover:text-red-700 p-1"><i class="fa-solid fa-trash-can"></i></button>` : '<span class="text-[10px] text-slate-400">Вы</span>';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="flex justify-between items-center bg-slate-50 border border-slate-200 p-2.5 rounded-xl text-xs">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="font-bold text-slate-800">${u.username}</span>
|
||||||
|
${adminTag}
|
||||||
|
</div>
|
||||||
|
${fullNameHtml}
|
||||||
|
<div class="text-[10px] text-slate-400 mt-0.5">Создан: ${dateStr}</div>
|
||||||
|
</div>
|
||||||
|
${deleteBtn}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
} else {
|
||||||
|
listEl.innerHTML = `<div class="text-xs text-red-500 py-2">${users.detail}</div>`;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
listEl.innerHTML = '<div class="text-xs text-red-500 py-2">Ошибка загрузки пользователей</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateUser(e) {
|
||||||
|
if (e && e.preventDefault) e.preventDefault();
|
||||||
|
const username = document.getElementById("new-user-name").value.trim();
|
||||||
|
const password = document.getElementById("new-user-pwd").value;
|
||||||
|
const fullNameInput = document.getElementById("new-user-fullname");
|
||||||
|
const full_name = fullNameInput ? fullNameInput.value.trim() : "";
|
||||||
|
const adminCheckbox = document.getElementById("new-user-is-admin");
|
||||||
|
const is_admin = adminCheckbox ? adminCheckbox.checked : false;
|
||||||
|
const msgEl = document.getElementById("admin-msg");
|
||||||
|
|
||||||
|
if (msgEl) msgEl.classList.add("hidden");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
|
||||||
|
const res = await fetch("/api/v1/admin/users", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer " + token
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password, full_name, is_admin })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.status === 200) {
|
||||||
|
document.getElementById("new-user-name").value = "";
|
||||||
|
document.getElementById("new-user-pwd").value = "";
|
||||||
|
if (fullNameInput) fullNameInput.value = "";
|
||||||
|
if (adminCheckbox) adminCheckbox.checked = false;
|
||||||
|
loadUsersList();
|
||||||
|
} else {
|
||||||
|
if (msgEl) {
|
||||||
|
msgEl.innerText = data.detail || "Ошибка";
|
||||||
|
msgEl.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (msgEl) {
|
||||||
|
msgEl.innerText = "Ошибка связи с сервером";
|
||||||
|
msgEl.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser(userId, username) {
|
||||||
|
if (!confirm("Удалить пользователя " + username + "?")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
|
||||||
|
await fetch("/api/v1/admin/users/" + userId, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "Authorization": "Bearer " + token }
|
||||||
|
});
|
||||||
|
loadUsersList();
|
||||||
|
} catch (err) {
|
||||||
|
alert("Ошибка при удалении");
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
-36
@@ -1,71 +1,97 @@
|
|||||||
async function sendMessage(e) {
|
async function sendMessage(e) {
|
||||||
e.preventDefault();
|
if (e && e.preventDefault) e.preventDefault();
|
||||||
const input = document.getElementById('user-input');
|
|
||||||
const chatWindow = document.getElementById('chat-window');
|
const input = document.getElementById("user-input");
|
||||||
const sendBtn = document.getElementById('send-btn');
|
const chatWindow = document.getElementById("chat-window");
|
||||||
|
const sendBtn = document.getElementById("send-btn");
|
||||||
|
|
||||||
|
if (!input || !chatWindow) return;
|
||||||
const text = input.value.trim();
|
const text = input.value.trim();
|
||||||
|
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
|
|
||||||
if (inputHistory[inputHistory.length - 1] !== text) {
|
// Вывод сообщения пользователя
|
||||||
inputHistory.push(text);
|
const userMsgHtml = `
|
||||||
if (inputHistory.length > 50) inputHistory.shift();
|
<div class="flex justify-end mb-3">
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(inputHistory));
|
|
||||||
}
|
|
||||||
historyIndex = -1;
|
|
||||||
|
|
||||||
chatWindow.innerHTML += `
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<div class="bg-indigo-600 text-white rounded-2xl px-4 py-2.5 max-w-2xl text-xs sm:text-sm shadow-sm">
|
<div class="bg-indigo-600 text-white rounded-2xl px-4 py-2.5 max-w-2xl text-xs sm:text-sm shadow-sm">
|
||||||
${text}
|
${escapeHtml(text)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
input.value = '';
|
chatWindow.insertAdjacentHTML("beforeend", userMsgHtml);
|
||||||
input.style.height = 'auto';
|
|
||||||
|
input.value = "";
|
||||||
|
input.style.height = "auto";
|
||||||
chatWindow.scrollTop = chatWindow.scrollHeight;
|
chatWindow.scrollTop = chatWindow.scrollHeight;
|
||||||
|
|
||||||
sendBtn.disabled = true;
|
if (sendBtn) {
|
||||||
sendBtn.classList.add('opacity-50');
|
sendBtn.disabled = true;
|
||||||
|
sendBtn.classList.add("opacity-50");
|
||||||
|
}
|
||||||
|
|
||||||
const endpoint = IS_GUEST ? '/api/v1/chat/guest' : '/api/v1/chat';
|
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
|
||||||
const headers = { 'Content-Type': 'application/json' };
|
const isGuest = typeof IS_GUEST !== 'undefined' ? IS_GUEST : (localStorage.getItem("scud_is_guest") === "true");
|
||||||
if (!IS_GUEST) {
|
|
||||||
headers['Authorization'] = `Bearer ${API_TOKEN}`;
|
const endpoint = isGuest ? "/api/v1/chat/guest" : "/api/v1/chat";
|
||||||
|
const headers = { "Content-Type": "application/json" };
|
||||||
|
if (!isGuest && token) {
|
||||||
|
headers["Authorization"] = "Bearer " + token;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(endpoint, {
|
const res = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: headers,
|
headers: headers,
|
||||||
body: JSON.stringify({ session_id: SESSION_ID, message: text })
|
body: JSON.stringify({ session_id: "web_session_main", message: text })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 401 && !IS_GUEST) {
|
if (res.status === 401 && !isGuest) {
|
||||||
logout();
|
if (typeof logout === 'function') logout();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const assistantTitle = IS_GUEST ? "Локальная нейросеть (Гость)" : "ИИ-Ассистент SCUD Orion AI";
|
const assistantTitle = isGuest ? "Локальная нейросеть (Гость)" : "ИИ-Ассистент SCUD Orion AI";
|
||||||
|
const replyText = data.reply || "Пустой ответ от нейросети";
|
||||||
|
|
||||||
chatWindow.innerHTML += `
|
const botMsgHtml = `
|
||||||
<div class="bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm max-w-2xl">
|
<div class="bg-white border border-slate-200 rounded-2xl p-3.5 shadow-sm max-w-2xl mb-3">
|
||||||
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1"><i class="fa-solid fa-robot mr-1"></i> ${assistantTitle}</p>
|
<p class="text-[11px] font-bold text-indigo-600 uppercase tracking-wider mb-1">
|
||||||
<p class="text-slate-800 text-xs sm:text-sm whitespace-pre-wrap leading-relaxed">${data.reply}</p>
|
<i class="fa-solid fa-robot mr-1"></i> ${assistantTitle}
|
||||||
|
</p>
|
||||||
|
<p class="text-slate-800 text-xs sm:text-sm whitespace-pre-wrap leading-relaxed">${escapeHtml(replyText)}</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
chatWindow.insertAdjacentHTML("beforeend", botMsgHtml);
|
||||||
chatWindow.scrollTop = chatWindow.scrollHeight;
|
chatWindow.scrollTop = chatWindow.scrollHeight;
|
||||||
if (!IS_GUEST) loadTasks();
|
|
||||||
|
if (!isGuest && typeof loadTasks === 'function') {
|
||||||
|
loadTasks();
|
||||||
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
chatWindow.innerHTML += `
|
console.error("[Chat Error]", err);
|
||||||
<div class="bg-red-50 border border-red-200 rounded-2xl p-3.5 max-w-2xl text-red-700 text-xs sm:text-sm">
|
const errorHtml = `
|
||||||
|
<div class="bg-red-50 border border-red-200 rounded-2xl p-3.5 max-w-2xl text-red-700 text-xs sm:text-sm mb-3">
|
||||||
Ошибка связи с сервером.
|
Ошибка связи с сервером.
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
chatWindow.insertAdjacentHTML("beforeend", errorHtml);
|
||||||
|
chatWindow.scrollTop = chatWindow.scrollHeight;
|
||||||
} finally {
|
} finally {
|
||||||
sendBtn.disabled = false;
|
if (sendBtn) {
|
||||||
sendBtn.classList.remove('opacity-50');
|
sendBtn.disabled = false;
|
||||||
|
sendBtn.classList.remove("opacity-50");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (!text) return "";
|
||||||
|
return text
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
+88
-36
@@ -1,20 +1,26 @@
|
|||||||
|
let currentFilter = 'ALL';
|
||||||
|
let allTasks = [];
|
||||||
|
|
||||||
function toggleDrawer() {
|
function toggleDrawer() {
|
||||||
if (IS_GUEST) return;
|
if (typeof IS_GUEST !== 'undefined' && IS_GUEST) return;
|
||||||
const drawer = document.getElementById('task-drawer');
|
const drawer = document.getElementById("task-drawer");
|
||||||
const backdrop = document.getElementById('drawer-backdrop');
|
const backdrop = document.getElementById("drawer-backdrop");
|
||||||
const isHidden = drawer.classList.contains('translate-x-full');
|
if (!drawer) return;
|
||||||
|
|
||||||
|
const isHidden = drawer.classList.contains("translate-x-full");
|
||||||
if (isHidden) {
|
if (isHidden) {
|
||||||
drawer.classList.remove('translate-x-full');
|
drawer.classList.remove("translate-x-full");
|
||||||
backdrop.classList.remove('hidden');
|
if (backdrop) backdrop.classList.remove("hidden");
|
||||||
|
loadTasks();
|
||||||
} else {
|
} else {
|
||||||
drawer.classList.add('translate-x-full');
|
drawer.classList.add("translate-x-full");
|
||||||
backdrop.classList.add('hidden');
|
if (backdrop) backdrop.classList.add("hidden");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFilter(status) {
|
function setFilter(status) {
|
||||||
currentFilter = status;
|
currentFilter = status;
|
||||||
['ALL', 'IN_PROGRESS', 'BACKLOG', 'COMPLETED'].forEach(f => {
|
["ALL", "IN_PROGRESS", "BACKLOG", "COMPLETED"].forEach(f => {
|
||||||
const btn = document.getElementById(`filter-${f}`);
|
const btn = document.getElementById(`filter-${f}`);
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.className = (f === status)
|
btn.className = (f === status)
|
||||||
@@ -26,26 +32,71 @@ function setFilter(status) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadTasks() {
|
async function loadTasks() {
|
||||||
if (IS_GUEST || !API_TOKEN) return;
|
const badge = document.getElementById("task-count-badge");
|
||||||
|
const container = document.getElementById("tasks-container");
|
||||||
|
|
||||||
|
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
|
||||||
|
const isGuest = typeof IS_GUEST !== 'undefined' ? IS_GUEST : (localStorage.getItem("scud_is_guest") === "true");
|
||||||
|
|
||||||
|
if (isGuest || !token) {
|
||||||
|
if (badge) badge.innerText = "0";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v1/tasks', {
|
const res = await fetch("/api/v1/tasks", {
|
||||||
headers: { 'Authorization': `Bearer ${API_TOKEN}` }
|
headers: {
|
||||||
|
"Authorization": "Bearer " + token,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
logout();
|
if (typeof logout === 'function') logout();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
allTasks = await res.json();
|
|
||||||
document.getElementById('task-count-badge').innerText = allTasks.length;
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
// Гибкое определение структуры данных (массив или объект с ключом tasks)
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
allTasks = data;
|
||||||
|
} else if (data && Array.isArray(data.tasks)) {
|
||||||
|
allTasks = data.tasks;
|
||||||
|
} else if (data && typeof data === 'object') {
|
||||||
|
allTasks = Object.values(data).find(val => Array.isArray(val)) || [];
|
||||||
|
} else {
|
||||||
|
allTasks = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (badge) {
|
||||||
|
badge.innerText = allTasks.length.toString();
|
||||||
|
}
|
||||||
|
|
||||||
renderTasks();
|
renderTasks();
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
document.getElementById('tasks-container').innerHTML = `<div class="text-red-500 text-xs py-4 text-center">Ошибка загрузки задач</div>`;
|
console.error("[Tasks Error]", err);
|
||||||
|
if (badge) badge.innerText = "0";
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = `<div class="text-red-500 text-xs py-8 text-center font-medium">Ошибка обработки списка задач</div>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTasks() {
|
function renderTasks() {
|
||||||
const container = document.getElementById('tasks-container');
|
const container = document.getElementById("tasks-container");
|
||||||
const filtered = allTasks.filter(t => currentFilter === 'ALL' || t.status === currentFilter);
|
if (!container) return;
|
||||||
|
|
||||||
|
if (!Array.isArray(allTasks)) {
|
||||||
|
allTasks = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = allTasks.filter(t => currentFilter === "ALL" || t.status === currentFilter);
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
container.innerHTML = `<div class="text-slate-400 text-xs py-8 text-center">Нет задач с выбранным фильтром</div>`;
|
container.innerHTML = `<div class="text-slate-400 text-xs py-8 text-center">Нет задач с выбранным фильтром</div>`;
|
||||||
@@ -53,41 +104,42 @@ function renderTasks() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = filtered.map(t => {
|
container.innerHTML = filtered.map(t => {
|
||||||
let statusBadge = 'bg-slate-100 text-slate-600 border-slate-200';
|
let statusBadge = "bg-slate-100 text-slate-600 border-slate-200";
|
||||||
let cardBg = 'bg-white';
|
let cardBg = "bg-white";
|
||||||
if (t.status === 'COMPLETED') {
|
|
||||||
statusBadge = 'bg-emerald-50 text-emerald-700 border-emerald-300 font-semibold';
|
if (t.status === "COMPLETED") {
|
||||||
cardBg = 'bg-emerald-50/20';
|
statusBadge = "bg-emerald-50 text-emerald-700 border-emerald-300 font-semibold";
|
||||||
} else if (t.status === 'IN_PROGRESS') {
|
cardBg = "bg-emerald-50/20";
|
||||||
statusBadge = 'bg-amber-50 text-amber-700 border-amber-300 font-bold';
|
} else if (t.status === "IN_PROGRESS") {
|
||||||
cardBg = 'bg-amber-50/20 border-amber-200';
|
statusBadge = "bg-amber-50 text-amber-700 border-amber-300 font-bold";
|
||||||
|
cardBg = "bg-amber-50/20 border-amber-200";
|
||||||
}
|
}
|
||||||
|
|
||||||
let priorityBadge = 'text-slate-500 bg-slate-100 border-slate-200';
|
let priorityBadge = "text-slate-500 bg-slate-100 border-slate-200";
|
||||||
if (t.priority === 'HIGH') priorityBadge = 'text-red-700 bg-red-50 border-red-200 font-bold';
|
if (t.priority === "HIGH") priorityBadge = "text-red-700 bg-red-50 border-red-200 font-bold";
|
||||||
|
|
||||||
let dueDateHtml = t.due_date ? `
|
let dueDateHtml = t.due_date ? `
|
||||||
<div class="mt-2 text-[11px] text-amber-800 bg-amber-50 border border-amber-200 px-2 py-0.5 rounded-md flex items-center gap-1.5 w-fit font-medium">
|
<div class="mt-2 text-[11px] text-amber-800 bg-amber-50 border border-amber-200 px-2 py-0.5 rounded-md flex items-center gap-1.5 w-fit font-medium">
|
||||||
<i class="fa-solid fa-clock text-amber-600"></i>
|
<i class="fa-solid fa-clock text-amber-600"></i>
|
||||||
<span>Срок: ${t.due_date}</span>
|
<span>Срок: ${t.due_date}</span>
|
||||||
</div>` : '';
|
</div>` : "";
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="${cardBg} border border-slate-200 rounded-xl p-3.5 shadow-sm hover:shadow-md transition">
|
<div class="${cardBg} border border-slate-200 rounded-xl p-3.5 shadow-sm hover:shadow-md transition">
|
||||||
<div class="flex justify-between items-center mb-1.5">
|
<div class="flex justify-between items-center mb-1.5">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<span class="font-mono text-xs font-bold text-slate-900 bg-slate-100 px-2 py-0.5 rounded border border-slate-200">${t.task_id}</span>
|
<span class="font-mono text-xs font-bold text-slate-900 bg-slate-100 px-2 py-0.5 rounded border border-slate-200">${t.task_id || t.id || 'TASK'}</span>
|
||||||
<span class="text-[10px] uppercase px-1.5 py-0.5 rounded border ${priorityBadge}">${t.priority || 'HIGH'}</span>
|
<span class="text-[10px] uppercase px-1.5 py-0.5 rounded border ${priorityBadge}">${t.priority || 'MEDIUM'}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-[10px] uppercase px-2 py-0.5 rounded border ${statusBadge}">${t.status}</span>
|
<span class="text-[10px] uppercase px-2 py-0.5 rounded border ${statusBadge}">${t.status || 'BACKLOG'}</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-xs font-semibold text-slate-800 mb-1 leading-snug">${t.title}</h3>
|
<h3 class="text-xs font-semibold text-slate-800 mb-1 leading-snug">${t.title || t.description || ''}</h3>
|
||||||
<div class="text-[10px] text-slate-400 font-mono flex items-center gap-1">
|
<div class="text-[10px] text-slate-400 font-mono flex items-center gap-1">
|
||||||
<i class="fa-solid fa-folder-closed text-slate-300"></i>
|
<i class="fa-solid fa-folder-closed text-slate-300"></i>
|
||||||
<span>${t.module}</span>
|
<span>${t.module || 'General'}</span>
|
||||||
</div>
|
</div>
|
||||||
${dueDateHtml}
|
${dueDateHtml}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join("");
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user