fixed rp api
This commit is contained in:
@@ -208,6 +208,12 @@ lookup wger + Open Food Facts, напоминания в чат (`💪`), вкл
|
|||||||
|
|
||||||
Чат: «обед: гречка 200г, курица 150г», «выпил 300 мл воды», «жим 80×5×3».
|
Чат: «обед: гречка 200г, курица 150г», «выпил 300 мл воды», «жим 80×5×3».
|
||||||
|
|
||||||
|
## Списки покупок
|
||||||
|
|
||||||
|
Несколько списков, позиции с количеством, отметка «куплено». Вкладка `/shopping`, tools в чате (`add_shopping_items`, `list_shopping_lists`, …).
|
||||||
|
|
||||||
|
Чат: «добавь молоко и хлеб в продукты», «что в списке покупок», «отметь молоко купленным».
|
||||||
|
|
||||||
## Homelab API (фаза 4)
|
## Homelab API (фаза 4)
|
||||||
|
|
||||||
Интеграции с домашней инфраструктурой:
|
Интеграции с домашней инфраструктурой:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.routes import character, chat, fitness, health, homelab, media, memory, pomodoro, projects, webhooks
|
from app.api.routes import character, chat, fitness, health, homelab, media, memory, pomodoro, projects, shopping, webhooks
|
||||||
|
|
||||||
api_router = APIRouter(prefix="/api/v1")
|
api_router = APIRouter(prefix="/api/v1")
|
||||||
api_router.include_router(health.router, tags=["health"])
|
api_router.include_router(health.router, tags=["health"])
|
||||||
@@ -11,5 +11,6 @@ api_router.include_router(character.router, tags=["character"])
|
|||||||
api_router.include_router(projects.router, tags=["projects"])
|
api_router.include_router(projects.router, tags=["projects"])
|
||||||
api_router.include_router(memory.router, tags=["memory"])
|
api_router.include_router(memory.router, tags=["memory"])
|
||||||
api_router.include_router(fitness.router, tags=["fitness"])
|
api_router.include_router(fitness.router, tags=["fitness"])
|
||||||
|
api_router.include_router(shopping.router, prefix="/shopping", tags=["shopping"])
|
||||||
api_router.include_router(webhooks.router, tags=["webhooks"])
|
api_router.include_router(webhooks.router, tags=["webhooks"])
|
||||||
api_router.include_router(media.router, tags=["media"])
|
api_router.include_router(media.router, tags=["media"])
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.base import get_db
|
||||||
|
from app.shopping.service import ShoppingService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class ListCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class ListRename(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class ItemInput(BaseModel):
|
||||||
|
text: str = Field(min_length=1, max_length=500)
|
||||||
|
quantity: float | None = None
|
||||||
|
unit: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class ItemsAdd(BaseModel):
|
||||||
|
list_id: int | None = None
|
||||||
|
list_name: str | None = None
|
||||||
|
items: list[ItemInput] = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class ItemChecked(BaseModel):
|
||||||
|
checked: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def get_snapshot(db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
|
return ShoppingService(db).snapshot()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/lists")
|
||||||
|
def list_lists(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
|
||||||
|
return ShoppingService(db).list_lists(include_items=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/lists")
|
||||||
|
def create_list(payload: ListCreate, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ShoppingService(db).create_list(payload.name)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/lists/{list_id}")
|
||||||
|
def get_list(list_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
|
data = ShoppingService(db).get_list(list_id=list_id)
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(status_code=404, detail="List not found")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/lists/{list_id}")
|
||||||
|
def rename_list(list_id: int, payload: ListRename, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ShoppingService(db).rename_list(list_id, payload.name)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/lists/{list_id}")
|
||||||
|
def delete_list(list_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ShoppingService(db).delete_list(list_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/items")
|
||||||
|
def add_items(payload: ItemsAdd, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ShoppingService(db).add_items(
|
||||||
|
[i.model_dump() for i in payload.items],
|
||||||
|
list_id=payload.list_id,
|
||||||
|
list_name=payload.list_name,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/items/{item_id}")
|
||||||
|
def set_item_checked(
|
||||||
|
item_id: int,
|
||||||
|
payload: ItemChecked,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ShoppingService(db).set_item_checked(item_id, payload.checked)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/items/{item_id}")
|
||||||
|
def remove_item(item_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ShoppingService(db).remove_item(item_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/lists/{list_id}/clear-checked")
|
||||||
|
def clear_checked(list_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return ShoppingService(db).clear_checked(list_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
@@ -23,6 +23,8 @@ TOOLS_INSTRUCTIONS = """
|
|||||||
- Погода: get_weather или блок [Погода] в контексте; «что на улице» / «будет ли дождь» — не выдумывай.
|
- Погода: get_weather или блок [Погода] в контексте; «что на улице» / «будет ли дождь» — не выдумывай.
|
||||||
- Утренний брифинг (погода + новости) → get_morning_briefing.
|
- Утренний брифинг (погода + новости) → get_morning_briefing.
|
||||||
- Картинки: generate_image — «нарисуй себя» → draw_self=true; иначе scene_description на английском (booru-теги). Внешность из карточки персонажа. Не злоупотребляй.
|
- Картинки: generate_image — «нарисуй себя» → draw_self=true; иначе scene_description на английском (booru-теги). Внешность из карточки персонажа. Не злоупотребляй.
|
||||||
|
- Покупки: list_shopping_lists, create_shopping_list, add_shopping_items, check_shopping_item, remove_shopping_item, delete_shopping_list.
|
||||||
|
- «Добавь в список покупок» → add_shopping_items (list_name + товары). «Что купить» → list_shopping_lists. Не выдумывай списки.
|
||||||
""".strip()
|
""".strip()
|
||||||
|
|
||||||
DEFAULT_CARD: dict[str, Any] = {
|
DEFAULT_CARD: dict[str, Any] = {
|
||||||
|
|||||||
@@ -69,6 +69,15 @@ FITNESS_TOOL_NAMES = frozenset({
|
|||||||
})
|
})
|
||||||
|
|
||||||
# Не засорять чат служебными ответами
|
# Не засорять чат служебными ответами
|
||||||
|
SHOPPING_TOOL_NAMES = frozenset({
|
||||||
|
"list_shopping_lists",
|
||||||
|
"create_shopping_list",
|
||||||
|
"add_shopping_items",
|
||||||
|
"check_shopping_item",
|
||||||
|
"remove_shopping_item",
|
||||||
|
"delete_shopping_list",
|
||||||
|
})
|
||||||
|
|
||||||
TOOLS_SKIP_CHAT_NOTICE = frozenset({
|
TOOLS_SKIP_CHAT_NOTICE = frozenset({
|
||||||
"get_pomodoro_status",
|
"get_pomodoro_status",
|
||||||
"recall_memories",
|
"recall_memories",
|
||||||
@@ -78,6 +87,7 @@ TOOLS_SKIP_CHAT_NOTICE = frozenset({
|
|||||||
"calc_fitness_targets",
|
"calc_fitness_targets",
|
||||||
"get_weather",
|
"get_weather",
|
||||||
"get_morning_briefing",
|
"get_morning_briefing",
|
||||||
|
"list_shopping_lists",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -97,6 +107,8 @@ def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
|
|||||||
prefix = "🧠"
|
prefix = "🧠"
|
||||||
elif tool_name in FITNESS_TOOL_NAMES:
|
elif tool_name in FITNESS_TOOL_NAMES:
|
||||||
prefix = "💪"
|
prefix = "💪"
|
||||||
|
elif tool_name in SHOPPING_TOOL_NAMES:
|
||||||
|
prefix = "🛒"
|
||||||
else:
|
else:
|
||||||
prefix = "📋"
|
prefix = "📋"
|
||||||
return f"{prefix} {data['error']}"
|
return f"{prefix} {data['error']}"
|
||||||
@@ -196,6 +208,29 @@ def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
|
|||||||
url = data.get("url", "")
|
url = data.get("url", "")
|
||||||
return f"🎨 **Картинка готова**\n\n"
|
return f"🎨 **Картинка готова**\n\n"
|
||||||
|
|
||||||
|
if tool_name == "create_shopping_list" and data.get("ok"):
|
||||||
|
lst = data.get("list") or {}
|
||||||
|
action = "создан" if data.get("created") else "уже был"
|
||||||
|
return f"🛒 **Список {action}** · «{lst.get('name')}» (#{lst.get('id')})"
|
||||||
|
|
||||||
|
if tool_name == "add_shopping_items" and data.get("ok"):
|
||||||
|
added = data.get("added") or []
|
||||||
|
names = ", ".join(i.get("text", "") for i in added[:5])
|
||||||
|
extra = f" +{len(added) - 5}" if len(added) > 5 else ""
|
||||||
|
return f"🛒 **Добавлено в «{data.get('list_name')}»** · {names}{extra}"
|
||||||
|
|
||||||
|
if tool_name == "check_shopping_item" and data.get("ok"):
|
||||||
|
item = data.get("item") or {}
|
||||||
|
state = "куплено" if item.get("checked") else "снята отметка"
|
||||||
|
return f"🛒 **{state}** · #{item.get('id')} {item.get('text')}"
|
||||||
|
|
||||||
|
if tool_name == "remove_shopping_item" and data.get("ok"):
|
||||||
|
removed = data.get("removed") or {}
|
||||||
|
return f"🛒 **Удалено** · {removed.get('text')}"
|
||||||
|
|
||||||
|
if tool_name == "delete_shopping_list" and data.get("ok"):
|
||||||
|
return f"🛒 **Список удалён** · «{data.get('name')}»"
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from app.memory.context import (
|
|||||||
)
|
)
|
||||||
from app.memory.extract import extract_after_turn
|
from app.memory.extract import extract_after_turn
|
||||||
from app.projects.context import format_projects_context, get_projects_snapshot
|
from app.projects.context import format_projects_context, get_projects_snapshot
|
||||||
|
from app.shopping.context import format_shopping_context, get_shopping_snapshot
|
||||||
from app.db.models import ChatSession, Message
|
from app.db.models import ChatSession, Message
|
||||||
from app.llm.client import LLMClient
|
from app.llm.client import LLMClient
|
||||||
from app.pomodoro.service import PomodoroService
|
from app.pomodoro.service import PomodoroService
|
||||||
@@ -63,12 +64,14 @@ class ChatService:
|
|||||||
status = PomodoroService(self.db).get_status()
|
status = PomodoroService(self.db).get_status()
|
||||||
memory_snapshot = get_memory_snapshot(self.db, session_id)
|
memory_snapshot = get_memory_snapshot(self.db, session_id)
|
||||||
fitness_snapshot = get_fitness_snapshot(self.db)
|
fitness_snapshot = get_fitness_snapshot(self.db)
|
||||||
|
shopping_snapshot = get_shopping_snapshot(self.db)
|
||||||
projects_snapshot = get_projects_snapshot(self.db)
|
projects_snapshot = get_projects_snapshot(self.db)
|
||||||
return (
|
return (
|
||||||
f"{self.character.get_system_prompt()}\n\n"
|
f"{self.character.get_system_prompt()}\n\n"
|
||||||
f"{format_datetime_context(self.db)}\n\n"
|
f"{format_datetime_context(self.db)}\n\n"
|
||||||
f"{format_memory_context(memory_snapshot)}\n\n"
|
f"{format_memory_context(memory_snapshot)}\n\n"
|
||||||
f"{format_fitness_context(fitness_snapshot)}\n\n"
|
f"{format_fitness_context(fitness_snapshot)}\n\n"
|
||||||
|
f"{format_shopping_context(shopping_snapshot)}\n\n"
|
||||||
f"{format_weather_snapshot()}\n\n"
|
f"{format_weather_snapshot()}\n\n"
|
||||||
f"{format_pomodoro_context(status)}\n\n"
|
f"{format_pomodoro_context(status)}\n\n"
|
||||||
f"{format_projects_context(projects_snapshot)}"
|
f"{format_projects_context(projects_snapshot)}"
|
||||||
|
|||||||
@@ -215,6 +215,41 @@ class FitnessReminder(Base):
|
|||||||
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ShoppingList(Base):
|
||||||
|
__tablename__ = "shopping_lists"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
items: Mapped[list["ShoppingListItem"]] = relationship(
|
||||||
|
back_populates="shopping_list",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="ShoppingListItem.sort_order, ShoppingListItem.id",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ShoppingListItem(Base):
|
||||||
|
__tablename__ = "shopping_list_items"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
list_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("shopping_lists.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
text: Mapped[str] = mapped_column(String(500))
|
||||||
|
quantity: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
unit: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
checked: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
shopping_list: Mapped["ShoppingList"] = relationship(back_populates="items")
|
||||||
|
|
||||||
|
|
||||||
class AssistantState(Base):
|
class AssistantState(Base):
|
||||||
__tablename__ = "assistant_state"
|
__tablename__ = "assistant_state"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.shopping.service import ShoppingService
|
||||||
|
|
||||||
|
MAX_LISTS_IN_CONTEXT = 8
|
||||||
|
MAX_ITEMS_PER_LIST = 12
|
||||||
|
|
||||||
|
|
||||||
|
def get_shopping_snapshot(db: Session) -> dict[str, Any]:
|
||||||
|
return ShoppingService(db).snapshot()
|
||||||
|
|
||||||
|
|
||||||
|
def format_shopping_context(snapshot: dict[str, Any]) -> str:
|
||||||
|
lines = ["[Списки покупок]"]
|
||||||
|
lists = snapshot.get("lists") or []
|
||||||
|
|
||||||
|
if not lists:
|
||||||
|
lines.append("Списков пока нет. create_shopping_list или add_shopping_items.")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
lines.append(
|
||||||
|
f"Всего списков: {snapshot.get('list_count', len(lists))}, "
|
||||||
|
f"неотмеченных позиций: {snapshot.get('unchecked_items', 0)}."
|
||||||
|
)
|
||||||
|
lines.append("Для изменений вызывай tools: list_shopping_lists, add_shopping_items, check_shopping_item.")
|
||||||
|
|
||||||
|
for lst in lists[:MAX_LISTS_IN_CONTEXT]:
|
||||||
|
items = lst.get("items") or []
|
||||||
|
unchecked = [i for i in items if not i.get("checked")]
|
||||||
|
preview = unchecked[:MAX_ITEMS_PER_LIST]
|
||||||
|
parts = []
|
||||||
|
for item in preview:
|
||||||
|
qty = item.get("quantity")
|
||||||
|
unit = (item.get("unit") or "").strip()
|
||||||
|
label = item["text"]
|
||||||
|
if qty is not None:
|
||||||
|
label = f"{label} ({qty}{' ' + unit if unit else ''})"
|
||||||
|
parts.append(f"#{item['id']} {label}")
|
||||||
|
tail = f" +{len(unchecked) - len(preview)} ещё" if len(unchecked) > len(preview) else ""
|
||||||
|
if parts:
|
||||||
|
lines.append(f"- «{lst['name']}» (#{lst['id']}): {', '.join(parts)}{tail}")
|
||||||
|
else:
|
||||||
|
lines.append(f"- «{lst['name']}» (#{lst['id']}): всё отмечено или пусто")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
|
from app.db.models import ShoppingList, ShoppingListItem
|
||||||
|
|
||||||
|
|
||||||
|
class ShoppingService:
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def snapshot(self) -> dict[str, Any]:
|
||||||
|
lists = self.list_lists(include_items=True)
|
||||||
|
total_items = sum(len(lst.get("items") or []) for lst in lists)
|
||||||
|
unchecked = sum(
|
||||||
|
1
|
||||||
|
for lst in lists
|
||||||
|
for item in (lst.get("items") or [])
|
||||||
|
if not item.get("checked")
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"lists": lists,
|
||||||
|
"list_count": len(lists),
|
||||||
|
"total_items": total_items,
|
||||||
|
"unchecked_items": unchecked,
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_lists(self, *, include_items: bool = False) -> list[dict[str, Any]]:
|
||||||
|
stmt = select(ShoppingList).order_by(ShoppingList.sort_order, ShoppingList.name)
|
||||||
|
if include_items:
|
||||||
|
stmt = stmt.options(selectinload(ShoppingList.items))
|
||||||
|
rows = list(self.db.scalars(stmt).all())
|
||||||
|
return [self._list_to_dict(row, include_items=include_items) for row in rows]
|
||||||
|
|
||||||
|
def get_list(
|
||||||
|
self,
|
||||||
|
list_id: int | None = None,
|
||||||
|
*,
|
||||||
|
name: str | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
row = self._resolve_list(list_id=list_id, name=name)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return self._list_to_dict(row, include_items=True)
|
||||||
|
|
||||||
|
def create_list(self, name: str) -> dict[str, Any]:
|
||||||
|
clean = name.strip()
|
||||||
|
if not clean:
|
||||||
|
raise ValueError("Название списка не может быть пустым")
|
||||||
|
existing = self.db.scalar(select(ShoppingList).where(ShoppingList.name == clean))
|
||||||
|
if existing:
|
||||||
|
return {"ok": True, "list": self._list_to_dict(existing, include_items=True), "created": False}
|
||||||
|
|
||||||
|
max_order = self.db.scalar(select(func.max(ShoppingList.sort_order))) or 0
|
||||||
|
row = ShoppingList(name=clean, sort_order=max_order + 1)
|
||||||
|
self.db.add(row)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(row)
|
||||||
|
return {"ok": True, "list": self._list_to_dict(row, include_items=True), "created": True}
|
||||||
|
|
||||||
|
def rename_list(self, list_id: int, name: str) -> dict[str, Any]:
|
||||||
|
row = self._require_list(list_id)
|
||||||
|
clean = name.strip()
|
||||||
|
if not clean:
|
||||||
|
raise ValueError("Название списка не может быть пустым")
|
||||||
|
conflict = self.db.scalar(
|
||||||
|
select(ShoppingList).where(ShoppingList.name == clean, ShoppingList.id != list_id)
|
||||||
|
)
|
||||||
|
if conflict:
|
||||||
|
raise ValueError(f"Список «{clean}» уже существует")
|
||||||
|
row.name = clean
|
||||||
|
row.updated_at = datetime.now(timezone.utc)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(row)
|
||||||
|
return {"ok": True, "list": self._list_to_dict(row, include_items=True)}
|
||||||
|
|
||||||
|
def delete_list(self, list_id: int) -> dict[str, Any]:
|
||||||
|
row = self._require_list(list_id)
|
||||||
|
name = row.name
|
||||||
|
self.db.delete(row)
|
||||||
|
self.db.commit()
|
||||||
|
return {"ok": True, "deleted_list_id": list_id, "name": name}
|
||||||
|
|
||||||
|
def add_items(
|
||||||
|
self,
|
||||||
|
items: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
list_id: int | None = None,
|
||||||
|
list_name: str | None = None,
|
||||||
|
create_list: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not items:
|
||||||
|
raise ValueError("Нужен хотя бы один товар")
|
||||||
|
|
||||||
|
row = self._resolve_list(list_id=list_id, name=list_name)
|
||||||
|
if not row and list_name and create_list:
|
||||||
|
created = self.create_list(list_name)
|
||||||
|
row = self._require_list(created["list"]["id"])
|
||||||
|
if not row:
|
||||||
|
raise ValueError("Укажи list_id или list_name")
|
||||||
|
|
||||||
|
max_order = self.db.scalar(
|
||||||
|
select(func.max(ShoppingListItem.sort_order)).where(ShoppingListItem.list_id == row.id)
|
||||||
|
) or 0
|
||||||
|
added: list[dict[str, Any]] = []
|
||||||
|
for idx, raw in enumerate(items, start=1):
|
||||||
|
text = (raw.get("text") or "").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
item = ShoppingListItem(
|
||||||
|
list_id=row.id,
|
||||||
|
text=text,
|
||||||
|
quantity=raw.get("quantity"),
|
||||||
|
unit=(raw.get("unit") or "").strip(),
|
||||||
|
sort_order=max_order + idx,
|
||||||
|
)
|
||||||
|
self.db.add(item)
|
||||||
|
added.append(item)
|
||||||
|
|
||||||
|
if not added:
|
||||||
|
raise ValueError("Нет валидных товаров для добавления")
|
||||||
|
|
||||||
|
row.updated_at = datetime.now(timezone.utc)
|
||||||
|
self.db.commit()
|
||||||
|
for item in added:
|
||||||
|
self.db.refresh(item)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"list_id": row.id,
|
||||||
|
"list_name": row.name,
|
||||||
|
"added": [self._item_to_dict(i) for i in added],
|
||||||
|
"list": self._list_to_dict(self._require_list(row.id), include_items=True),
|
||||||
|
}
|
||||||
|
|
||||||
|
def set_item_checked(self, item_id: int, checked: bool) -> dict[str, Any]:
|
||||||
|
item = self._require_item(item_id)
|
||||||
|
item.checked = checked
|
||||||
|
item.shopping_list.updated_at = datetime.now(timezone.utc)
|
||||||
|
self.db.commit()
|
||||||
|
return {"ok": True, "item": self._item_to_dict(item)}
|
||||||
|
|
||||||
|
def remove_item(self, item_id: int) -> dict[str, Any]:
|
||||||
|
item = self._require_item(item_id)
|
||||||
|
data = self._item_to_dict(item)
|
||||||
|
list_id = item.list_id
|
||||||
|
self.db.delete(item)
|
||||||
|
self.db.commit()
|
||||||
|
return {"ok": True, "removed": data, "list_id": list_id}
|
||||||
|
|
||||||
|
def clear_checked(self, list_id: int) -> dict[str, Any]:
|
||||||
|
row = self._require_list(list_id)
|
||||||
|
removed = 0
|
||||||
|
for item in list(row.items):
|
||||||
|
if item.checked:
|
||||||
|
self.db.delete(item)
|
||||||
|
removed += 1
|
||||||
|
row.updated_at = datetime.now(timezone.utc)
|
||||||
|
self.db.commit()
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"list_id": list_id,
|
||||||
|
"removed_count": removed,
|
||||||
|
"list": self._list_to_dict(self._require_list(list_id), include_items=True),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _resolve_list(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
list_id: int | None = None,
|
||||||
|
name: str | None = None,
|
||||||
|
) -> ShoppingList | None:
|
||||||
|
if list_id is not None:
|
||||||
|
return self.db.scalar(
|
||||||
|
select(ShoppingList)
|
||||||
|
.where(ShoppingList.id == list_id)
|
||||||
|
.options(selectinload(ShoppingList.items))
|
||||||
|
)
|
||||||
|
if name:
|
||||||
|
clean = name.strip()
|
||||||
|
return self.db.scalar(
|
||||||
|
select(ShoppingList)
|
||||||
|
.where(ShoppingList.name == clean)
|
||||||
|
.options(selectinload(ShoppingList.items))
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _require_list(self, list_id: int) -> ShoppingList:
|
||||||
|
row = self._resolve_list(list_id=list_id)
|
||||||
|
if not row:
|
||||||
|
raise ValueError(f"Список #{list_id} не найден")
|
||||||
|
return row
|
||||||
|
|
||||||
|
def _require_item(self, item_id: int) -> ShoppingListItem:
|
||||||
|
item = self.db.get(ShoppingListItem, item_id)
|
||||||
|
if not item:
|
||||||
|
raise ValueError(f"Позиция #{item_id} не найдена")
|
||||||
|
return item
|
||||||
|
|
||||||
|
def _item_to_dict(self, item: ShoppingListItem) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": item.id,
|
||||||
|
"list_id": item.list_id,
|
||||||
|
"text": item.text,
|
||||||
|
"quantity": item.quantity,
|
||||||
|
"unit": item.unit,
|
||||||
|
"checked": item.checked,
|
||||||
|
"sort_order": item.sort_order,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _list_to_dict(self, row: ShoppingList, *, include_items: bool) -> dict[str, Any]:
|
||||||
|
data: dict[str, Any] = {
|
||||||
|
"id": row.id,
|
||||||
|
"name": row.name,
|
||||||
|
"sort_order": row.sort_order,
|
||||||
|
"item_count": len(row.items) if row.items is not None else 0,
|
||||||
|
"unchecked_count": sum(1 for i in (row.items or []) if not i.checked),
|
||||||
|
}
|
||||||
|
if include_items:
|
||||||
|
data["items"] = [self._item_to_dict(i) for i in (row.items or [])]
|
||||||
|
return data
|
||||||
@@ -13,6 +13,7 @@ from app.integrations.wger import WgerClient
|
|||||||
from app.memory.service import MemoryService
|
from app.memory.service import MemoryService
|
||||||
from app.pomodoro.service import PomodoroService
|
from app.pomodoro.service import PomodoroService
|
||||||
from app.projects.service import ProjectService
|
from app.projects.service import ProjectService
|
||||||
|
from app.shopping.service import ShoppingService
|
||||||
|
|
||||||
TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
||||||
{
|
{
|
||||||
@@ -496,6 +497,94 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "list_shopping_lists",
|
||||||
|
"description": "Все списки покупок с позициями. «Что купить», «покажи списки».",
|
||||||
|
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "create_shopping_list",
|
||||||
|
"description": "Создать новый список покупок.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string", "description": "Название списка, например «Продукты»"},
|
||||||
|
},
|
||||||
|
"required": ["name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "add_shopping_items",
|
||||||
|
"description": "Добавить товары в список. Список создаётся, если не существует.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"list_name": {"type": "string", "description": "Название списка"},
|
||||||
|
"list_id": {"type": "integer"},
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"text": {"type": "string"},
|
||||||
|
"quantity": {"type": "number"},
|
||||||
|
"unit": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["text"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["items"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "check_shopping_item",
|
||||||
|
"description": "Отметить позицию как купленную (checked=true) или снять отметку (false).",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"item_id": {"type": "integer"},
|
||||||
|
"checked": {"type": "boolean"},
|
||||||
|
},
|
||||||
|
"required": ["item_id", "checked"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "remove_shopping_item",
|
||||||
|
"description": "Удалить позицию из списка по item_id.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"item_id": {"type": "integer"}},
|
||||||
|
"required": ["item_id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "delete_shopping_list",
|
||||||
|
"description": "Удалить весь список покупок.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"list_id": {"type": "integer"}},
|
||||||
|
"required": ["list_id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -528,6 +617,7 @@ async def execute_tool(
|
|||||||
projects = ProjectService(db)
|
projects = ProjectService(db)
|
||||||
memory = MemoryService(db)
|
memory = MemoryService(db)
|
||||||
fitness = FitnessService(db)
|
fitness = FitnessService(db)
|
||||||
|
shopping = ShoppingService(db)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if name == "get_pomodoro_status":
|
if name == "get_pomodoro_status":
|
||||||
@@ -680,6 +770,25 @@ async def execute_tool(
|
|||||||
draw_self=bool(arguments.get("draw_self")),
|
draw_self=bool(arguments.get("draw_self")),
|
||||||
scene_description=arguments.get("scene_description", ""),
|
scene_description=arguments.get("scene_description", ""),
|
||||||
)
|
)
|
||||||
|
elif name == "list_shopping_lists":
|
||||||
|
result = shopping.list_lists(include_items=True)
|
||||||
|
elif name == "create_shopping_list":
|
||||||
|
result = shopping.create_list(arguments.get("name", ""))
|
||||||
|
elif name == "add_shopping_items":
|
||||||
|
result = shopping.add_items(
|
||||||
|
arguments.get("items") or [],
|
||||||
|
list_id=arguments.get("list_id"),
|
||||||
|
list_name=arguments.get("list_name"),
|
||||||
|
)
|
||||||
|
elif name == "check_shopping_item":
|
||||||
|
result = shopping.set_item_checked(
|
||||||
|
int(arguments["item_id"]),
|
||||||
|
bool(arguments.get("checked", True)),
|
||||||
|
)
|
||||||
|
elif name == "remove_shopping_item":
|
||||||
|
result = shopping.remove_item(int(arguments["item_id"]))
|
||||||
|
elif name == "delete_shopping_list":
|
||||||
|
result = shopping.delete_list(int(arguments["list_id"]))
|
||||||
else:
|
else:
|
||||||
return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False)
|
return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
- Вопросы о погоде, дожде, «что на улице» — используй get_weather или данные из блока [Погода]
|
- Вопросы о погоде, дожде, «что на улице» — используй get_weather или данные из блока [Погода]
|
||||||
- Утренний брифинг — get_morning_briefing
|
- Утренний брифинг — get_morning_briefing
|
||||||
|
|
||||||
|
Списки покупок:
|
||||||
|
- Несколько списков (Продукты, Дом, и т.д.)
|
||||||
|
- add_shopping_items, list_shopping_lists, check_shopping_item
|
||||||
|
|
||||||
Картинки:
|
Картинки:
|
||||||
- «Нарисуй себя» → generate_image с draw_self=true
|
- «Нарисуй себя» → generate_image с draw_self=true
|
||||||
- Другая сцена → generate_image с scene_description на английском (booru-теги)
|
- Другая сцена → generate_image с scene_description на английском (booru-теги)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { PomodoroProvider } from "./context/PomodoroContext";
|
|||||||
import Character from "./pages/Character";
|
import Character from "./pages/Character";
|
||||||
import Chat from "./pages/Chat";
|
import Chat from "./pages/Chat";
|
||||||
import Fitness from "./pages/Fitness";
|
import Fitness from "./pages/Fitness";
|
||||||
|
import Shopping from "./pages/Shopping";
|
||||||
import Memory from "./pages/Memory";
|
import Memory from "./pages/Memory";
|
||||||
import Pomodoro from "./pages/Pomodoro";
|
import Pomodoro from "./pages/Pomodoro";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
@@ -22,6 +23,7 @@ export default function App() {
|
|||||||
<NavLink to="/character">Персонаж</NavLink>
|
<NavLink to="/character">Персонаж</NavLink>
|
||||||
<NavLink to="/memory">Память</NavLink>
|
<NavLink to="/memory">Память</NavLink>
|
||||||
<NavLink to="/fitness">Фитнес</NavLink>
|
<NavLink to="/fitness">Фитнес</NavLink>
|
||||||
|
<NavLink to="/shopping">Покупки</NavLink>
|
||||||
<PomodoroWidget compact />
|
<PomodoroWidget compact />
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
@@ -32,6 +34,7 @@ export default function App() {
|
|||||||
<Route path="/character" element={<Character />} />
|
<Route path="/character" element={<Character />} />
|
||||||
<Route path="/memory" element={<Memory />} />
|
<Route path="/memory" element={<Memory />} />
|
||||||
<Route path="/fitness" element={<Fitness />} />
|
<Route path="/fitness" element={<Fitness />} />
|
||||||
|
<Route path="/shopping" element={<Shopping />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -363,4 +363,75 @@ export const api = {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(updates),
|
body: JSON.stringify(updates),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getShoppingSnapshot: () => request<ShoppingSnapshot>("/api/v1/shopping"),
|
||||||
|
|
||||||
|
createShoppingList: (name: string) =>
|
||||||
|
request<{ ok: boolean; list: ShoppingList }>("/api/v1/shopping/lists", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
renameShoppingList: (listId: number, name: string) =>
|
||||||
|
request<{ ok: boolean; list: ShoppingList }>(`/api/v1/shopping/lists/${listId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteShoppingList: (listId: number) =>
|
||||||
|
request<{ ok: boolean }>(`/api/v1/shopping/lists/${listId}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
addShoppingItems: (payload: {
|
||||||
|
list_id?: number;
|
||||||
|
list_name?: string;
|
||||||
|
items: { text: string; quantity?: number; unit?: string }[];
|
||||||
|
}) =>
|
||||||
|
request<{ ok: boolean }>("/api/v1/shopping/items", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
|
||||||
|
setShoppingItemChecked: (itemId: number, checked: boolean) =>
|
||||||
|
request<{ ok: boolean }>(`/api/v1/shopping/items/${itemId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ checked }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
removeShoppingItem: (itemId: number) =>
|
||||||
|
request<{ ok: boolean }>(`/api/v1/shopping/items/${itemId}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
clearShoppingChecked: (listId: number) =>
|
||||||
|
request<{ ok: boolean }>(`/api/v1/shopping/lists/${listId}/clear-checked`, {
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface ShoppingListItem {
|
||||||
|
id: number;
|
||||||
|
list_id: number;
|
||||||
|
text: string;
|
||||||
|
quantity: number | null;
|
||||||
|
unit: string;
|
||||||
|
checked: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShoppingList {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
sort_order: number;
|
||||||
|
item_count: number;
|
||||||
|
unchecked_count: number;
|
||||||
|
items?: ShoppingListItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShoppingSnapshot {
|
||||||
|
lists: ShoppingList[];
|
||||||
|
list_count: number;
|
||||||
|
total_items: number;
|
||||||
|
unchecked_items: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ function noticeLabel(content: string): string {
|
|||||||
if (content.startsWith("🌤")) return "погода";
|
if (content.startsWith("🌤")) return "погода";
|
||||||
if (content.startsWith("🎨")) return "картинка";
|
if (content.startsWith("🎨")) return "картинка";
|
||||||
if (content.startsWith("⚠️")) return "сервер";
|
if (content.startsWith("⚠️")) return "сервер";
|
||||||
|
if (content.startsWith("🛒")) return "покупки";
|
||||||
return "система";
|
return "система";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
.shopping-page {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 240px 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
height: calc(100vh - 80px);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-sidebar,
|
||||||
|
.shopping-main {
|
||||||
|
background: #12151c;
|
||||||
|
border: 1px solid #2f3748;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-sidebar h3,
|
||||||
|
.shopping-main h2 {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-list-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-list-btn {
|
||||||
|
text-align: left;
|
||||||
|
background: #1a1f2b;
|
||||||
|
border: 1px solid #2f3748;
|
||||||
|
color: inherit;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-list-btn.active {
|
||||||
|
border-color: #4a7cff;
|
||||||
|
background: #1c2740;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-list-btn small {
|
||||||
|
display: block;
|
||||||
|
color: #8b95a5;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-inline-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-inline-form input {
|
||||||
|
flex: 1;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #2f3748;
|
||||||
|
background: #0f1218;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-inline-form button,
|
||||||
|
.shopping-toolbar button {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #3a4558;
|
||||||
|
background: #2b3445;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-items {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #1a1f2b;
|
||||||
|
border: 1px solid #2a3140;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-item.checked {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-item.checked .shopping-item-text {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-item-text {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-item-meta {
|
||||||
|
color: #8b95a5;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-empty {
|
||||||
|
color: #8b95a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-message {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
color: #8b95a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.shopping-page {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { FormEvent, useCallback, useEffect, useState } from "react";
|
||||||
|
import { api, ShoppingList, ShoppingSnapshot } from "../api/client";
|
||||||
|
import "./Shopping.css";
|
||||||
|
|
||||||
|
function formatItemLabel(text: string, quantity: number | null, unit: string) {
|
||||||
|
if (quantity == null) return text;
|
||||||
|
const u = unit.trim();
|
||||||
|
return u ? `${text} — ${quantity} ${u}` : `${text} — ${quantity}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Shopping() {
|
||||||
|
const [snapshot, setSnapshot] = useState<ShoppingSnapshot | null>(null);
|
||||||
|
const [activeId, setActiveId] = useState<number | null>(null);
|
||||||
|
const [newListName, setNewListName] = useState("");
|
||||||
|
const [newItemText, setNewItemText] = useState("");
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await api.getShoppingSnapshot();
|
||||||
|
setSnapshot(data);
|
||||||
|
setActiveId((current) => {
|
||||||
|
if (!current && data.lists.length > 0) return data.lists[0].id;
|
||||||
|
if (current && !data.lists.some((l) => l.id === current)) {
|
||||||
|
return data.lists[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setMessage(err instanceof Error ? err.message : "Ошибка загрузки");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load().catch(console.error);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const activeList: ShoppingList | undefined = snapshot?.lists.find((l) => l.id === activeId);
|
||||||
|
|
||||||
|
const handleCreateList = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newListName.trim()) return;
|
||||||
|
try {
|
||||||
|
const res = await api.createShoppingList(newListName.trim());
|
||||||
|
setNewListName("");
|
||||||
|
setActiveId(res.list.id);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setMessage(err instanceof Error ? err.message : "Ошибка");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddItem = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!activeList || !newItemText.trim()) return;
|
||||||
|
try {
|
||||||
|
await api.addShoppingItems({
|
||||||
|
list_id: activeList.id,
|
||||||
|
items: [{ text: newItemText.trim() }],
|
||||||
|
});
|
||||||
|
setNewItemText("");
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setMessage(err instanceof Error ? err.message : "Ошибка");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleItem = async (itemId: number, checked: boolean) => {
|
||||||
|
await api.setShoppingItemChecked(itemId, !checked);
|
||||||
|
await load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = async (itemId: number) => {
|
||||||
|
await api.removeShoppingItem(itemId);
|
||||||
|
await load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearChecked = async () => {
|
||||||
|
if (!activeList) return;
|
||||||
|
await api.clearShoppingChecked(activeList.id);
|
||||||
|
await load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteList = async () => {
|
||||||
|
if (!activeList) return;
|
||||||
|
if (!confirm(`Удалить список «${activeList.name}»?`)) return;
|
||||||
|
await api.deleteShoppingList(activeList.id);
|
||||||
|
setActiveId(null);
|
||||||
|
await load();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shopping-page">
|
||||||
|
<aside className="shopping-sidebar">
|
||||||
|
<h3>Списки</h3>
|
||||||
|
<div className="shopping-list-nav">
|
||||||
|
{(snapshot?.lists ?? []).map((list) => (
|
||||||
|
<button
|
||||||
|
key={list.id}
|
||||||
|
type="button"
|
||||||
|
className={`shopping-list-btn ${list.id === activeId ? "active" : ""}`}
|
||||||
|
onClick={() => setActiveId(list.id)}
|
||||||
|
>
|
||||||
|
{list.name}
|
||||||
|
<small>
|
||||||
|
{list.unchecked_count} к покупке · {list.item_count} всего
|
||||||
|
</small>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!snapshot?.lists.length && <div className="shopping-empty">Пока пусто</div>}
|
||||||
|
</div>
|
||||||
|
<form className="shopping-inline-form" onSubmit={handleCreateList}>
|
||||||
|
<input
|
||||||
|
value={newListName}
|
||||||
|
onChange={(e) => setNewListName(e.target.value)}
|
||||||
|
placeholder="Новый список"
|
||||||
|
/>
|
||||||
|
<button type="submit">+</button>
|
||||||
|
</form>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="shopping-main">
|
||||||
|
{activeList ? (
|
||||||
|
<>
|
||||||
|
<h2>{activeList.name}</h2>
|
||||||
|
<div className="shopping-toolbar">
|
||||||
|
<button type="button" onClick={() => load()} disabled={loading}>
|
||||||
|
Обновить
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={clearChecked}>
|
||||||
|
Убрать купленное
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={deleteList}>
|
||||||
|
Удалить список
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="shopping-items">
|
||||||
|
{(activeList.items ?? []).map((item) => (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
className={`shopping-item ${item.checked ? "checked" : ""}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={item.checked}
|
||||||
|
onChange={() => toggleItem(item.id, item.checked)}
|
||||||
|
/>
|
||||||
|
<span className="shopping-item-text">
|
||||||
|
{formatItemLabel(item.text, item.quantity, item.unit)}
|
||||||
|
</span>
|
||||||
|
<span className="shopping-item-meta">#{item.id}</span>
|
||||||
|
<button type="button" onClick={() => removeItem(item.id)}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form className="shopping-inline-form" onSubmit={handleAddItem}>
|
||||||
|
<input
|
||||||
|
value={newItemText}
|
||||||
|
onChange={(e) => setNewItemText(e.target.value)}
|
||||||
|
placeholder="Добавить товар"
|
||||||
|
/>
|
||||||
|
<button type="submit">Добавить</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="shopping-empty">Выбери список или создай новый</div>
|
||||||
|
)}
|
||||||
|
{message && <div className="shopping-message">{message}</div>}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user