fixed rp api

This commit is contained in:
2026-06-10 12:03:05 +03:00
parent 5844551038
commit 8eb6505724
17 changed files with 969 additions and 1 deletions
+6
View File
@@ -208,6 +208,12 @@ lookup wger + Open Food Facts, напоминания в чат (`💪`), вкл
Чат: «обед: гречка 200г, курица 150г», «выпил 300 мл воды», «жим 80×5×3».
## Списки покупок
Несколько списков, позиции с количеством, отметка «куплено». Вкладка `/shopping`, tools в чате (`add_shopping_items`, `list_shopping_lists`, …).
Чат: «добавь молоко и хлеб в продукты», «что в списке покупок», «отметь молоко купленным».
## Homelab API (фаза 4)
Интеграции с домашней инфраструктурой:
+2 -1
View File
@@ -1,6 +1,6 @@
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.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(memory.router, tags=["memory"])
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(media.router, tags=["media"])
+116
View File
@@ -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
+2
View File
@@ -23,6 +23,8 @@ TOOLS_INSTRUCTIONS = """
- Погода: get_weather или блок [Погода] в контексте; «что на улице» / «будет ли дождь» — не выдумывай.
- Утренний брифинг (погода + новости) → get_morning_briefing.
- Картинки: 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()
DEFAULT_CARD: dict[str, Any] = {
+35
View File
@@ -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({
"get_pomodoro_status",
"recall_memories",
@@ -78,6 +87,7 @@ TOOLS_SKIP_CHAT_NOTICE = frozenset({
"calc_fitness_targets",
"get_weather",
"get_morning_briefing",
"list_shopping_lists",
})
@@ -97,6 +107,8 @@ def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
prefix = "🧠"
elif tool_name in FITNESS_TOOL_NAMES:
prefix = "💪"
elif tool_name in SHOPPING_TOOL_NAMES:
prefix = "🛒"
else:
prefix = "📋"
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", "")
return f"🎨 **Картинка готова**\n\n![image]({url})"
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
+3
View File
@@ -22,6 +22,7 @@ from app.memory.context import (
)
from app.memory.extract import extract_after_turn
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.llm.client import LLMClient
from app.pomodoro.service import PomodoroService
@@ -63,12 +64,14 @@ class ChatService:
status = PomodoroService(self.db).get_status()
memory_snapshot = get_memory_snapshot(self.db, session_id)
fitness_snapshot = get_fitness_snapshot(self.db)
shopping_snapshot = get_shopping_snapshot(self.db)
projects_snapshot = get_projects_snapshot(self.db)
return (
f"{self.character.get_system_prompt()}\n\n"
f"{format_datetime_context(self.db)}\n\n"
f"{format_memory_context(memory_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_pomodoro_context(status)}\n\n"
f"{format_projects_context(projects_snapshot)}"
+35
View File
@@ -215,6 +215,41 @@ class FitnessReminder(Base):
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):
__tablename__ = "assistant_state"
View File
+47
View File
@@ -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)
+223
View File
@@ -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
+109
View File
@@ -13,6 +13,7 @@ from app.integrations.wger import WgerClient
from app.memory.service import MemoryService
from app.pomodoro.service import PomodoroService
from app.projects.service import ProjectService
from app.shopping.service import ShoppingService
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",
"function": {
@@ -528,6 +617,7 @@ async def execute_tool(
projects = ProjectService(db)
memory = MemoryService(db)
fitness = FitnessService(db)
shopping = ShoppingService(db)
try:
if name == "get_pomodoro_status":
@@ -680,6 +770,25 @@ async def execute_tool(
draw_self=bool(arguments.get("draw_self")),
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:
return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False)
+4
View File
@@ -25,6 +25,10 @@
- Вопросы о погоде, дожде, «что на улице» — используй get_weather или данные из блока [Погода]
- Утренний брифинг — get_morning_briefing
Списки покупок:
- Несколько списков (Продукты, Дом, и т.д.)
- add_shopping_items, list_shopping_lists, check_shopping_item
Картинки:
- «Нарисуй себя» → generate_image с draw_self=true
- Другая сцена → generate_image с scene_description на английском (booru-теги)
+3
View File
@@ -4,6 +4,7 @@ import { PomodoroProvider } from "./context/PomodoroContext";
import Character from "./pages/Character";
import Chat from "./pages/Chat";
import Fitness from "./pages/Fitness";
import Shopping from "./pages/Shopping";
import Memory from "./pages/Memory";
import Pomodoro from "./pages/Pomodoro";
import "./App.css";
@@ -22,6 +23,7 @@ export default function App() {
<NavLink to="/character">Персонаж</NavLink>
<NavLink to="/memory">Память</NavLink>
<NavLink to="/fitness">Фитнес</NavLink>
<NavLink to="/shopping">Покупки</NavLink>
<PomodoroWidget compact />
</nav>
</header>
@@ -32,6 +34,7 @@ export default function App() {
<Route path="/character" element={<Character />} />
<Route path="/memory" element={<Memory />} />
<Route path="/fitness" element={<Fitness />} />
<Route path="/shopping" element={<Shopping />} />
</Routes>
</main>
</div>
+71
View File
@@ -363,4 +363,75 @@ export const api = {
headers: { "Content-Type": "application/json" },
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;
}
+1
View File
@@ -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 "покупки";
return "система";
}
+132
View File
@@ -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;
}
}
+180
View File
@@ -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>
);
}