rebuild(stage-12): deliver private review workflows

This commit is contained in:
leefer
2026-07-30 07:45:55 +08:00
parent 0a055867a4
commit b93fb3ec41
32 changed files with 2292 additions and 9 deletions
+5
View File
@@ -25,6 +25,8 @@ from backend.features.mentor.context import MentorContextBuilder
from backend.features.mentor.repository import MentorRepository
from backend.features.mentor.service import MentorService
from backend.features.mentor.skills import MentorSkillRegistry
from backend.features.review import ReviewService
from backend.features.review.repository import ReviewRepository
from backend.features.screener.repository import ScreenerRepository
from backend.features.screener.service import ScreenerService
from backend.llm import LLMGateway
@@ -46,6 +48,7 @@ class ApplicationContainer:
llm: LLMGateway
mentor: MentorService
heaven: HeavenService
review: ReviewService
def build_container(settings: Settings) -> ApplicationContainer:
@@ -100,6 +103,7 @@ def build_container(settings: Settings) -> ApplicationContainer:
llm,
PROJECT_ROOT / "config" / "heaven" / "iching_zh.json",
)
review = ReviewService(database, ReviewRepository(), gateway, screener, llm)
return ApplicationContainer(
settings=settings,
database=database,
@@ -113,4 +117,5 @@ def build_container(settings: Settings) -> ApplicationContainer:
llm=llm,
mentor=mentor,
heaven=heaven,
review=review,
)
@@ -0,0 +1,112 @@
from __future__ import annotations
import sqlite3
from backend.database.migrations.runner import Migration
def upgrade(connection: sqlite3.Connection) -> None:
connection.execute("ALTER TABLE watchlist_entries ADD COLUMN remark TEXT NOT NULL DEFAULT ''")
statements = (
"""
CREATE TABLE review_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code TEXT NOT NULL DEFAULT '',
stock_name TEXT NOT NULL DEFAULT '',
trade_date TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
plan TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(user_id, code, trade_date)
)
""",
"""
CREATE TABLE trade_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
trade_date TEXT NOT NULL,
code TEXT NOT NULL,
name TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('buy','sell','add','trim','watch')),
price REAL NOT NULL CHECK (price >= 0),
quantity INTEGER NOT NULL DEFAULT 0 CHECK (quantity >= 0),
position_pct REAL CHECK (position_pct BETWEEN 0 AND 100),
pnl_amount REAL,
pnl_pct REAL,
emotion TEXT NOT NULL CHECK (
emotion IN ('calm','confident','hesitant','anxious','impulsive')
),
tags_json TEXT NOT NULL DEFAULT '[]',
thesis TEXT NOT NULL DEFAULT '',
execution TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""",
"""
CREATE INDEX trade_entries_user_date_idx
ON trade_entries(user_id, trade_date DESC, id DESC)
""",
"""
CREATE TABLE alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('manual','strategy_t1','strategy_t5')),
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
available_date TEXT NOT NULL,
code TEXT NOT NULL DEFAULT '',
dedupe_key TEXT NOT NULL,
is_read INTEGER NOT NULL DEFAULT 0 CHECK (is_read IN (0,1)),
read_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(user_id, dedupe_key)
)
""",
"""
CREATE INDEX alerts_user_due_idx
ON alerts(user_id, available_date, is_read, id DESC)
""",
"""
CREATE TABLE review_assistant_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('user','assistant')),
content TEXT NOT NULL,
context_date TEXT NOT NULL,
request_id TEXT REFERENCES llm_requests(id) ON DELETE SET NULL,
status TEXT NOT NULL CHECK (status IN ('complete','stopped','error')),
created_at TEXT NOT NULL
)
""",
"""
CREATE INDEX review_assistant_user_idx
ON review_assistant_messages(user_id, id DESC)
""",
)
for statement in statements:
connection.execute(statement)
def downgrade(connection: sqlite3.Connection) -> None:
for table in (
"review_assistant_messages",
"alerts",
"trade_entries",
"review_notes",
):
connection.execute(f"DROP TABLE {table}")
connection.execute("ALTER TABLE watchlist_entries DROP COLUMN remark")
MIGRATION = Migration(
version=10,
name="create_private_review_domain",
signature="review:v1:notes-trades-alerts-assistant",
upgrade=upgrade,
downgrade=downgrade,
)
@@ -7,6 +7,7 @@ from backend.database.migrations.m0006_watchlists import MIGRATION as WATCHLISTS
from backend.database.migrations.m0007_screener import MIGRATION as SCREENER
from backend.database.migrations.m0008_mentor_llm import MIGRATION as MENTOR_LLM
from backend.database.migrations.m0009_heaven import MIGRATION as HEAVEN
from backend.database.migrations.m0010_review import MIGRATION as REVIEW
from backend.database.migrations.runner import Migration
MIGRATIONS: tuple[Migration, ...] = (
@@ -19,4 +20,5 @@ MIGRATIONS: tuple[Migration, ...] = (
SCREENER,
MENTOR_LLM,
HEAVEN,
REVIEW,
)
+3
View File
@@ -0,0 +1,3 @@
from backend.features.review.service import ReviewService
__all__ = ["ReviewService"]
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
import json
from typing import Any
PROMPT_VERSION = "review-assistant-v1"
def messages(
context: dict[str, Any], history: list[dict[str, str]], question: str
) -> list[dict[str, str]]:
return [
{
"role": "system",
"content": (
"你是复盘助手,只能基于提供的数据帮助用户复盘,不修改数据、不执行交易。"
"回答依次区分【市场事实】【用户记录】【推断】【条件化计划】;缺失信息明确留空。"
"不得把推断写成事实,不给无条件买卖指令。默认不超过800个中文字符,"
"只有用户明确要求展开时才可更长。"
),
},
{
"role": "user",
"content": "复盘上下文:"
+ json.dumps(context, ensure_ascii=False, separators=(",", ":")),
},
*history,
{"role": "user", "content": question},
]
+306
View File
@@ -0,0 +1,306 @@
from __future__ import annotations
import json
import sqlite3
class ReviewRepository:
def watchlist(self, connection: sqlite3.Connection, user_id: int) -> tuple[sqlite3.Row, ...]:
return tuple(
connection.execute(
"""SELECT identifier, name, sector, remark, created_at FROM watchlist_entries
WHERE user_id = ? ORDER BY created_at, identifier""",
(user_id,),
).fetchall()
)
def save_watch(
self, connection: sqlite3.Connection, user_id: int, item: dict, now: str
) -> None:
connection.execute(
"""INSERT INTO watchlist_entries (user_id, identifier, name, sector, remark, created_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, identifier) DO UPDATE SET
name=excluded.name, sector=excluded.sector,
remark=CASE
WHEN excluded.remark = '' THEN watchlist_entries.remark
ELSE excluded.remark
END""",
(
user_id,
item["identifier"],
item["name"],
item.get("sector"),
item.get("remark") or "",
now,
),
)
def delete_watch(self, connection: sqlite3.Connection, user_id: int, identifier: str) -> bool:
cursor = connection.execute(
"DELETE FROM watchlist_entries WHERE user_id = ? AND identifier = ?",
(user_id, identifier),
)
return cursor.rowcount > 0
def save_watch_remark(
self, connection: sqlite3.Connection, user_id: int, identifier: str, remark: str
) -> bool:
cursor = connection.execute(
"UPDATE watchlist_entries SET remark=? WHERE user_id=? AND identifier=?",
(remark, user_id, identifier),
)
return cursor.rowcount > 0
def latest_factors(self, connection: sqlite3.Connection, through: str) -> dict[str, dict]:
snapshot = connection.execute(
"""SELECT id, trade_date FROM screener_factor_snapshots
WHERE trade_date <= ? ORDER BY trade_date DESC, id DESC LIMIT 1""",
(through,),
).fetchone()
if snapshot is None:
return {}
return {
str(row["identifier"]): json.loads(str(row["payload_json"]))
for row in connection.execute(
"SELECT identifier, payload_json FROM screener_factor_values WHERE snapshot_id = ?",
(int(snapshot["id"]),),
).fetchall()
}
def latest_auction_scores(
self, connection: sqlite3.Connection, through: str
) -> dict[str, float]:
row = connection.execute(
"""SELECT payload_json FROM market_insight_snapshots
WHERE kind = 'auction' AND trade_date <= ? AND entity_key = ''
ORDER BY trade_date DESC LIMIT 1""",
(through,),
).fetchone()
if row is None:
return {}
payload = json.loads(str(row["payload_json"]))
scores = {}
for key in ("_market_rows", "rows", "one_price_rows"):
for item in payload.get(key) or []:
identifier = str(item.get("identifier") or item.get("ts_code") or "")
score = item.get("attention_score")
if identifier and isinstance(score, (int, float)):
scores[identifier] = float(score)
return scores
def note(
self, connection: sqlite3.Connection, user_id: int, code: str, trade_date: str
) -> sqlite3.Row | None:
return connection.execute(
"SELECT * FROM review_notes WHERE user_id = ? AND code = ? AND trade_date = ?",
(user_id, code, trade_date),
).fetchone()
def notes(
self, connection: sqlite3.Connection, user_id: int, code: str = "", limit: int = 60
) -> tuple[sqlite3.Row, ...]:
return tuple(
connection.execute(
"""SELECT * FROM review_notes WHERE user_id = ? AND code = ?
ORDER BY trade_date DESC, id DESC LIMIT ?""",
(user_id, code, limit),
).fetchall()
)
def save_note(self, connection: sqlite3.Connection, user_id: int, data: dict, now: str) -> int:
connection.execute(
"""INSERT INTO review_notes (
user_id, code, stock_name, trade_date, summary, content, plan,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, code, trade_date) DO UPDATE SET
stock_name=excluded.stock_name, summary=excluded.summary,
content=excluded.content, plan=excluded.plan, updated_at=excluded.updated_at""",
(
user_id,
data["code"],
data["stock_name"],
data["trade_date"],
data["summary"],
data["content"],
data["plan"],
now,
now,
),
)
row = self.note(connection, user_id, data["code"], data["trade_date"])
if row is None:
raise RuntimeError("复盘记录写入失败")
return int(row["id"])
def delete_note(self, connection: sqlite3.Connection, user_id: int, note_id: int) -> bool:
cursor = connection.execute(
"DELETE FROM review_notes WHERE user_id = ? AND id = ?", (user_id, note_id)
)
return cursor.rowcount > 0
def trades(
self, connection: sqlite3.Connection, user_id: int, limit: int = 300
) -> tuple[sqlite3.Row, ...]:
return tuple(
connection.execute(
"""SELECT * FROM trade_entries WHERE user_id = ?
ORDER BY trade_date DESC, id DESC LIMIT ?""",
(user_id, limit),
).fetchall()
)
def trade(
self, connection: sqlite3.Connection, user_id: int, trade_id: int
) -> sqlite3.Row | None:
return connection.execute(
"SELECT * FROM trade_entries WHERE user_id = ? AND id = ?", (user_id, trade_id)
).fetchone()
def save_trade(self, connection: sqlite3.Connection, user_id: int, data: dict, now: str) -> int:
values = (
data["trade_date"],
data["code"],
data["name"],
data["action"],
data["price"],
data["quantity"],
data["position_pct"],
data["pnl_amount"],
data["pnl_pct"],
data["emotion"],
json.dumps(data["tags"], ensure_ascii=False),
data["thesis"],
data["execution"],
now,
)
if data.get("id"):
cursor = connection.execute(
"""UPDATE trade_entries SET trade_date=?, code=?, name=?, action=?, price=?,
quantity=?, position_pct=?, pnl_amount=?, pnl_pct=?, emotion=?, tags_json=?,
thesis=?, execution=?, updated_at=? WHERE user_id=? AND id=?""",
(*values, user_id, int(data["id"])),
)
if cursor.rowcount == 0:
raise ValueError("交易记录不存在或无权修改。")
return int(data["id"])
cursor = connection.execute(
"""INSERT INTO trade_entries (
trade_date, code, name, action, price, quantity, position_pct, pnl_amount,
pnl_pct, emotion, tags_json, thesis, execution, created_at, updated_at, user_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(*values, now, user_id),
)
return int(cursor.lastrowid)
def delete_trade(self, connection: sqlite3.Connection, user_id: int, trade_id: int) -> bool:
cursor = connection.execute(
"DELETE FROM trade_entries WHERE user_id = ? AND id = ?", (user_id, trade_id)
)
return cursor.rowcount > 0
def save_alert(self, connection: sqlite3.Connection, user_id: int, data: dict, now: str) -> int:
connection.execute(
"""INSERT INTO alerts (
user_id, kind, title, content, available_date, code, dedupe_key,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, dedupe_key) DO NOTHING""",
(
user_id,
data["kind"],
data["title"],
data["content"],
data["available_date"],
data["code"],
data["dedupe_key"],
now,
now,
),
)
row = connection.execute(
"SELECT id FROM alerts WHERE user_id = ? AND dedupe_key = ?",
(user_id, data["dedupe_key"]),
).fetchone()
return int(row["id"])
def alerts(
self, connection: sqlite3.Connection, user_id: int, unread: bool
) -> tuple[sqlite3.Row, ...]:
clause = "AND is_read = 0" if unread else ""
return tuple(
connection.execute(
f"""SELECT * FROM alerts WHERE user_id = ? {clause}
ORDER BY available_date > date('now'), is_read,
available_date DESC, id DESC LIMIT 100""",
(user_id,),
).fetchall()
)
def unread_count(self, connection: sqlite3.Connection, user_id: int, today: str) -> int:
row = connection.execute(
"""SELECT COUNT(*) total FROM alerts
WHERE user_id = ? AND available_date <= ? AND is_read = 0""",
(user_id, today),
).fetchone()
return int(row["total"])
def mark_alert(
self, connection: sqlite3.Connection, user_id: int, alert_id: int, now: str
) -> bool:
cursor = connection.execute(
"UPDATE alerts SET is_read=1, read_at=?, updated_at=? WHERE user_id=? AND id=?",
(now, now, user_id, alert_id),
)
return cursor.rowcount > 0
def mark_all_alerts(
self, connection: sqlite3.Connection, user_id: int, today: str, now: str
) -> int:
cursor = connection.execute(
"""UPDATE alerts SET is_read=1, read_at=?, updated_at=?
WHERE user_id=? AND available_date <= ? AND is_read=0""",
(now, now, user_id, today),
)
return int(cursor.rowcount)
def delete_alert(self, connection: sqlite3.Connection, user_id: int, alert_id: int) -> bool:
cursor = connection.execute(
"DELETE FROM alerts WHERE user_id=? AND id=?", (user_id, alert_id)
)
return cursor.rowcount > 0
def messages(
self, connection: sqlite3.Connection, user_id: int, limit: int = 100
) -> tuple[sqlite3.Row, ...]:
rows = connection.execute(
"""SELECT * FROM review_assistant_messages WHERE user_id=?
ORDER BY id DESC LIMIT ?""",
(user_id, limit),
).fetchall()
return tuple(reversed(rows))
def add_message(
self, connection: sqlite3.Connection, user_id: int, data: dict, now: str
) -> None:
connection.execute(
"""INSERT INTO review_assistant_messages
(user_id, role, content, context_date, request_id, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(
user_id,
data["role"],
data["content"],
data["context_date"],
data.get("request_id"),
data["status"],
now,
),
)
def clear_messages(self, connection: sqlite3.Connection, user_id: int) -> int:
cursor = connection.execute(
"DELETE FROM review_assistant_messages WHERE user_id=?", (user_id,)
)
return int(cursor.rowcount)
+183
View File
@@ -0,0 +1,183 @@
from __future__ import annotations
import json
from collections.abc import Iterator
from typing import Annotated
from fastapi import APIRouter, Path, Query, Request
from fastapi.responses import StreamingResponse
from backend.features.accounts.auth import (
AuthenticatedPrincipal,
CsrfPrincipal,
SmartAccessPrincipal,
SmartWritePrincipal,
)
from backend.features.review.schemas import (
AlertInput,
AssistantInput,
NoteInput,
TradeInput,
WatchInput,
WatchRemarkInput,
)
from backend.features.review.service import ReviewError
from backend.http.errors import AppError
from backend.llm.gateway import LLMGatewayError
router = APIRouter(prefix="/review", tags=["review"])
@router.get("", response_model=dict)
def workspace(
request: Request,
principal: AuthenticatedPrincipal,
trade_date: Annotated[str, Query(alias="date")],
) -> dict:
return _call(request, "workspace", principal, trade_date)
@router.post("/watchlist", response_model=dict)
def add_watch(payload: WatchInput, request: Request, principal: CsrfPrincipal) -> dict:
return _call(request, "add_watch", principal, payload.identifier)
@router.patch("/watchlist/{identifier}/remark", response_model=dict)
def save_watch_remark(
payload: WatchRemarkInput,
request: Request,
principal: CsrfPrincipal,
identifier: Annotated[str, Path(min_length=1, max_length=40)],
) -> dict:
_call(request, "save_watch_remark", principal, identifier, payload.remark)
return {"message": "跟踪备注已保存。"}
@router.delete("/watchlist/{identifier}", response_model=dict)
def delete_watch(
request: Request,
principal: CsrfPrincipal,
identifier: Annotated[str, Path(min_length=1, max_length=40)],
) -> dict:
_call(request, "delete_watch", principal, identifier)
return {"message": "已移出自选。"}
@router.put("/notes", response_model=dict)
def save_note(payload: NoteInput, request: Request, principal: CsrfPrincipal) -> dict:
identifier = _call(request, "save_note", principal, payload.model_dump())
return {"id": identifier}
@router.get("/stock-notes/{code}", response_model=list[dict])
def stock_notes(
request: Request,
principal: AuthenticatedPrincipal,
code: Annotated[str, Path(min_length=1, max_length=12)],
) -> list[dict]:
return _call(request, "stock_notes", principal, code)
@router.delete("/notes/{note_id}", response_model=dict)
def delete_note(
request: Request,
principal: CsrfPrincipal,
note_id: Annotated[int, Path(gt=0)],
) -> dict:
_call(request, "delete_note", principal, note_id)
return {"message": "复盘记录已删除。"}
@router.put("/trades", response_model=dict)
def save_trade(payload: TradeInput, request: Request, principal: CsrfPrincipal) -> dict:
identifier = _call(request, "save_trade", principal, payload.model_dump())
return {"id": identifier}
@router.delete("/trades/{trade_id}", response_model=dict)
def delete_trade(
request: Request,
principal: CsrfPrincipal,
trade_id: Annotated[int, Path(gt=0)],
) -> dict:
_call(request, "delete_trade", principal, trade_id)
return {"message": "交易记录已删除。"}
@router.get("/alerts", response_model=dict)
def alerts(
request: Request,
principal: AuthenticatedPrincipal,
unread: Annotated[bool, Query()] = False,
) -> dict:
return _call(request, "alert_center", principal, unread)
@router.post("/alerts", response_model=dict)
def create_alert(payload: AlertInput, request: Request, principal: CsrfPrincipal) -> dict:
identifier = _call(request, "create_alert", principal, payload.model_dump())
return {"id": identifier}
@router.patch("/alerts/read-all", response_model=dict)
def mark_all_alerts(request: Request, principal: CsrfPrincipal) -> dict:
return {"updated": _call(request, "mark_all_alerts", principal)}
@router.patch("/alerts/{alert_id}/read", response_model=dict)
def mark_alert(
request: Request,
principal: CsrfPrincipal,
alert_id: Annotated[int, Path(gt=0)],
) -> dict:
_call(request, "mark_alert", principal, alert_id)
return {"message": "提醒已读。"}
@router.delete("/alerts/{alert_id}", response_model=dict)
def delete_alert(
request: Request,
principal: CsrfPrincipal,
alert_id: Annotated[int, Path(gt=0)],
) -> dict:
_call(request, "delete_alert", principal, alert_id)
return {"message": "提醒已删除。"}
@router.get("/assistant/messages", response_model=list[dict])
def assistant_messages(request: Request, principal: SmartAccessPrincipal) -> list[dict]:
return _call(request, "messages", principal)
@router.delete("/assistant/messages", response_model=dict)
def clear_assistant_messages(request: Request, principal: SmartWritePrincipal) -> dict:
return {"deleted": _call(request, "clear_messages", principal)}
@router.post("/assistant/chat")
def assistant_chat(
payload: AssistantInput,
request: Request,
principal: SmartWritePrincipal,
) -> StreamingResponse:
prepared = _call(request, "prepare_assistant", principal, payload.trade_date, payload.question)
def body() -> Iterator[bytes]:
for event in request.app.state.container.review.stream_assistant(prepared):
yield (json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n").encode()
return StreamingResponse(
body(),
media_type="application/x-ndjson",
headers={"Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no"},
)
def _call(request: Request, method: str, *args):
try:
return getattr(request.app.state.container.review, method)(*args)
except ReviewError as exc:
raise AppError("review_unavailable", str(exc), 409) from exc
except LLMGatewayError as exc:
status = 403 if exc.code in {"membership_required", "quota_exhausted"} else 503
raise AppError(exc.code, str(exc), status) from exc
+51
View File
@@ -0,0 +1,51 @@
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
class WatchInput(BaseModel):
identifier: str = Field(min_length=1, max_length=40)
class WatchRemarkInput(BaseModel):
remark: str = Field(max_length=500)
class NoteInput(BaseModel):
trade_date: str
code: str = Field(default="", max_length=12)
stock_name: str = Field(default="", max_length=40)
summary: str = Field(default="", max_length=500)
content: str = Field(default="", max_length=5000)
plan: str = Field(default="", max_length=2000)
class TradeInput(BaseModel):
id: int | None = Field(default=None, gt=0)
trade_date: str
code: str = Field(min_length=6, max_length=12)
name: str = Field(min_length=1, max_length=40)
action: Literal["buy", "sell", "add", "trim", "watch"]
price: float = Field(ge=0, le=1_000_000)
quantity: int = Field(default=0, ge=0, le=100_000_000)
position_pct: float | None = Field(default=None, ge=0, le=100)
pnl_amount: float | None = Field(default=None, ge=-1e12, le=1e12)
pnl_pct: float | None = Field(default=None, ge=-1000, le=10000)
emotion: Literal["calm", "confident", "hesitant", "anxious", "impulsive"]
tags: list[str] = Field(default_factory=list, max_length=8)
thesis: str = Field(default="", max_length=2000)
execution: str = Field(default="", max_length=2000)
class AlertInput(BaseModel):
title: str = Field(min_length=1, max_length=80)
remind_date: str
code: str = Field(default="", max_length=12)
content: str = Field(default="", max_length=500)
class AssistantInput(BaseModel):
trade_date: str
question: str = Field(min_length=1, max_length=2000)
+368
View File
@@ -0,0 +1,368 @@
from __future__ import annotations
import secrets
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import date, datetime
from typing import Any
from zoneinfo import ZoneInfo
from backend.data.gateway import DataGateway
from backend.database.connection import Database
from backend.features.accounts.models import Principal
from backend.features.review.prompt import PROMPT_VERSION, messages
from backend.features.review.repository import ReviewRepository
from backend.features.review.views import alert as alert_view
from backend.features.review.views import note as note_view
from backend.features.review.views import trade_summary, trades
from backend.features.screener.service import ScreenerService
from backend.llm.gateway import LLMCall, LLMGateway, LLMGatewayError
SHANGHAI = ZoneInfo("Asia/Shanghai")
class ReviewError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class PreparedAssistant:
call: LLMCall
prompt: list[dict[str, str]]
context_date: str
class ReviewService:
def __init__(
self,
database: Database,
repository: ReviewRepository,
gateway: DataGateway,
screener: ScreenerService,
llm: LLMGateway,
) -> None:
self._database = database
self._repository = repository
self._gateway = gateway
self._screener = screener
self._llm = llm
def workspace(self, principal: Principal, requested_date: str) -> dict[str, Any]:
trade_date = self._trade_date(requested_date)
with self._database.read() as connection:
watches = self._watch_rows(connection, principal.user.id, trade_date)
daily = self._repository.note(connection, principal.user.id, "", trade_date)
history = self._repository.notes(connection, principal.user.id, "", 60)
trade_rows = trades(self._repository.trades(connection, principal.user.id))
return {
"trade_date": trade_date,
"watchlist": watches,
"daily": note_view(daily),
"history": [note_view(row) for row in history],
"trades": trade_rows,
"trade_summary": trade_summary(trade_rows),
}
def add_watch(self, principal: Principal, identifier: str) -> dict[str, Any]:
normalized = identifier.strip().upper()
candidates = self._gateway.search(normalized)
item = next(
(
candidate
for candidate in candidates
if candidate.entity_type == "stock" and candidate.identifier == normalized
),
None,
)
if item is None:
raise ReviewError("未找到可加入自选的股票。")
with self._database.transaction() as connection:
self._repository.save_watch(
connection,
principal.user.id,
{"identifier": item.identifier, "name": item.name, "sector": item.sector},
_now(),
)
return {"identifier": item.identifier, "name": item.name}
def save_watch_remark(self, principal: Principal, identifier: str, remark: str) -> None:
normalized = _text(remark, 500)
with self._database.transaction() as connection:
if not self._repository.save_watch_remark(
connection, principal.user.id, identifier, normalized
):
raise ReviewError("自选记录不存在。")
def delete_watch(self, principal: Principal, identifier: str) -> None:
with self._database.transaction() as connection:
if not self._repository.delete_watch(connection, principal.user.id, identifier):
raise ReviewError("自选记录不存在。")
def save_note(self, principal: Principal, payload: dict[str, Any]) -> int:
data = {
"trade_date": _date(payload["trade_date"]),
"code": payload.get("code", "").strip().upper(),
"stock_name": _text(payload.get("stock_name", ""), 40),
"summary": _text(payload.get("summary", ""), 500),
"content": _text(payload.get("content", ""), 5000),
"plan": _text(payload.get("plan", ""), 2000),
}
if data["code"] and not data["stock_name"]:
raise ReviewError("个股名称不能为空。")
with self._database.transaction() as connection:
return self._repository.save_note(connection, principal.user.id, data, _now())
def stock_notes(self, principal: Principal, code: str) -> list[dict[str, Any]]:
with self._database.read() as connection:
return [
note_view(row)
for row in self._repository.notes(
connection, principal.user.id, code.strip().upper()
)
]
def delete_note(self, principal: Principal, note_id: int) -> None:
with self._database.transaction() as connection:
if not self._repository.delete_note(connection, principal.user.id, note_id):
raise ReviewError("复盘记录不存在。")
def save_trade(self, principal: Principal, payload: dict[str, Any]) -> int:
data = dict(payload)
data["trade_date"] = _date(str(data["trade_date"]))
data["code"] = str(data["code"]).strip().upper()
data["name"] = _text(data["name"], 40, True)
data["thesis"] = _text(data.get("thesis", ""), 2000)
data["execution"] = _text(data.get("execution", ""), 2000)
data["tags"] = [_text(item, 20, True) for item in data.get("tags", [])][:8]
with self._database.transaction() as connection:
return self._repository.save_trade(connection, principal.user.id, data, _now())
def delete_trade(self, principal: Principal, trade_id: int) -> None:
with self._database.transaction() as connection:
if not self._repository.delete_trade(connection, principal.user.id, trade_id):
raise ReviewError("交易记录不存在。")
def alert_center(self, principal: Principal, unread: bool = False) -> dict[str, Any]:
self._sync_tracking_alerts(principal.user.id)
today = date.today().isoformat()
with self._database.read() as connection:
rows = self._repository.alerts(connection, principal.user.id, unread)
items = [alert_view(row, today) for row in rows]
if unread:
items = [item for item in items if item["due"]]
count = self._repository.unread_count(connection, principal.user.id, today)
return {"items": items, "unread_count": count, "as_of": today}
def create_alert(self, principal: Principal, payload: dict[str, Any]) -> int:
data = {
"kind": "manual",
"title": _text(payload["title"], 80, True),
"content": _text(payload.get("content", ""), 500),
"available_date": _date(payload["remind_date"]),
"code": _text(payload.get("code", ""), 12),
"dedupe_key": f"manual:{secrets.token_hex(12)}",
}
with self._database.transaction() as connection:
return self._repository.save_alert(connection, principal.user.id, data, _now())
def mark_alert(self, principal: Principal, alert_id: int) -> None:
with self._database.transaction() as connection:
if not self._repository.mark_alert(connection, principal.user.id, alert_id, _now()):
raise ReviewError("提醒不存在。")
def mark_all_alerts(self, principal: Principal) -> int:
with self._database.transaction() as connection:
return self._repository.mark_all_alerts(
connection, principal.user.id, date.today().isoformat(), _now()
)
def delete_alert(self, principal: Principal, alert_id: int) -> None:
with self._database.transaction() as connection:
if not self._repository.delete_alert(connection, principal.user.id, alert_id):
raise ReviewError("提醒不存在。")
def messages(self, principal: Principal) -> list[dict[str, Any]]:
with self._database.read() as connection:
return [dict(row) for row in self._repository.messages(connection, principal.user.id)]
def clear_messages(self, principal: Principal) -> int:
with self._database.transaction() as connection:
return self._repository.clear_messages(connection, principal.user.id)
def prepare_assistant(
self, principal: Principal, requested_date: str, question: str
) -> PreparedAssistant:
context_date = self._trade_date(requested_date)
normalized = _text(question, 2000, True)
context = self._assistant_context(principal, context_date)
history = self._history(principal.user.id)
prompt = messages(context, history, normalized)
call = self._llm.prepare(
principal,
feature="review_assistant",
prompt_version=PROMPT_VERSION,
business_id=context_date,
input_chars=sum(len(item["content"]) for item in prompt),
)
with self._database.transaction() as connection:
self._repository.add_message(
connection,
principal.user.id,
{
"role": "user",
"content": normalized,
"context_date": context_date,
"request_id": call.request_id,
"status": "complete",
},
_now(),
)
return PreparedAssistant(call, prompt, context_date)
def stream_assistant(self, prepared: PreparedAssistant) -> Iterator[dict[str, Any]]:
answer = ""
stream = self._llm.stream(prepared.call, prepared.prompt)
try:
for event in stream:
if event.type == "delta":
answer += event.content
yield {
"type": "delta",
"content": event.content,
"request_id": event.request_id,
}
elif event.type == "done":
self._save_assistant(prepared, answer, "complete")
yield {"type": "done", "request_id": event.request_id}
except GeneratorExit:
stream.close()
if answer:
self._save_assistant(prepared, answer, "stopped")
raise
except LLMGatewayError as exc:
if answer:
self._save_assistant(prepared, answer, "error")
yield {"type": "error", "code": exc.code, "message": str(exc), "partial": exc.partial}
def _watch_rows(self, connection, user_id: int, trade_date: str) -> list[dict[str, Any]]:
factors = self._repository.latest_factors(connection, trade_date)
auction_scores = self._repository.latest_auction_scores(connection, trade_date)
rows = []
for watch in self._repository.watchlist(connection, user_id):
factor = factors.get(str(watch["identifier"]), {})
rows.append(
{
**dict(watch),
"code": str(watch["identifier"]).split(".")[0],
"pct_chg": factor.get("pct_chg"),
"return_5d": factor.get("return_5d"),
"attention_score": auction_scores.get(str(watch["identifier"])),
"remark": str(watch["remark"]),
}
)
return rows
def _sync_tracking_alerts(self, user_id: int) -> None:
tracks = self._screener.tracks(user_id)
batches: dict[int, list[dict]] = {}
for item in tracks:
batches.setdefault(int(item["run_id"]), []).append(item)
with self._database.transaction() as connection:
for run_id, items in batches.items():
name = str(items[0]["strategy_name"])
if any(item.get("t1_return") is not None for item in items):
self._repository.save_alert(
connection,
user_id,
{
"kind": "strategy_t1",
"title": f"{name} 已有 T+1 反馈",
"content": (
f"{sum(item.get('t1_return') is not None for item in items)}"
f"/{len(items)} 只标的已有首日表现。"
),
"available_date": date.today().isoformat(),
"code": "",
"dedupe_key": f"strategy:{run_id}:t1",
},
_now(),
)
if items and all(item.get("t5_return") is not None for item in items):
self._repository.save_alert(
connection,
user_id,
{
"kind": "strategy_t5",
"title": f"{name} 五日跟踪完成",
"content": f"本批 {len(items)} 只标的已完成 T+5 跟踪。",
"available_date": date.today().isoformat(),
"code": "",
"dedupe_key": f"strategy:{run_id}:t5",
},
_now(),
)
def _assistant_context(self, principal: Principal, trade_date: str) -> dict[str, Any]:
summary = self._gateway.summary(trade_date)
workspace = self.workspace(principal, trade_date)
alerts = self.alert_center(principal)["items"][:20]
return {
"data_date": trade_date,
"market_facts": summary["values"],
"user_records": {
"watchlist": workspace["watchlist"][:30],
"daily_reviews": workspace["history"][:10],
"strategy_tracking": self._screener.tracks(principal.user.id)[:30],
"alerts": alerts,
"trade_summary": workspace["trade_summary"],
"trade_entries": workspace["trades"][:30],
},
}
def _history(self, user_id: int) -> list[dict[str, str]]:
with self._database.read() as connection:
rows = self._repository.messages(connection, user_id, 20)
return [
{"role": str(row["role"]), "content": str(row["content"])[:4000]} for row in rows[-12:]
]
def _save_assistant(self, prepared: PreparedAssistant, answer: str, status: str) -> None:
if not answer:
return
with self._database.transaction() as connection:
self._repository.add_message(
connection,
prepared.call.user_id,
{
"role": "assistant",
"content": answer,
"context_date": prepared.context_date,
"request_id": prepared.call.request_id,
"status": status,
},
_now(),
)
def _trade_date(self, value: str) -> str:
requested = _date(value)
return self._gateway.trade_context(requested).actual_date or requested
def _date(value: str) -> str:
try:
return date.fromisoformat(value).isoformat()
except ValueError as exc:
raise ReviewError("日期格式无效。") from exc
def _text(value: Any, limit: int, required: bool = False) -> str:
normalized = " ".join(str(value or "").split())
if required and not normalized:
raise ReviewError("必填内容不能为空。")
if len(normalized) > limit:
raise ReviewError(f"内容不能超过{limit}个字符。")
return normalized
def _now() -> str:
return datetime.now(SHANGHAI).isoformat(timespec="seconds")
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
import json
from typing import Any
ACTIONS = {"buy": "买入", "sell": "卖出", "add": "加仓", "trim": "减仓", "watch": "观察"}
EMOTIONS = {
"calm": "平静",
"confident": "笃定",
"hesitant": "犹豫",
"anxious": "焦虑",
"impulsive": "冲动",
}
def note(row) -> dict[str, Any] | None:
return dict(row) if row is not None else None
def trades(rows) -> list[dict[str, Any]]:
result = []
for row in rows:
item = dict(row)
item["tags"] = json.loads(str(item.pop("tags_json")))
item["action_label"] = ACTIONS[str(item["action"])]
item["emotion_label"] = EMOTIONS[str(item["emotion"])]
result.append(item)
return result
def trade_summary(items: list[dict[str, Any]]) -> dict[str, Any]:
realized = [
item for item in items if item["pnl_amount"] is not None or item["pnl_pct"] is not None
]
wins = sum(
float(item["pnl_pct"] if item["pnl_pct"] is not None else item["pnl_amount"]) > 0
for item in realized
)
amounts = [float(item["pnl_amount"]) for item in items if item["pnl_amount"] is not None]
positions = [float(item["position_pct"]) for item in items if item["position_pct"] is not None]
return {
"total": len(items),
"realized": len(realized),
"win_rate": round(wins / len(realized) * 100, 1) if realized else None,
"pnl_amount": round(sum(amounts), 2) if amounts else None,
"average_position": round(sum(positions) / len(positions), 1) if positions else None,
}
def alert(row, today: str) -> dict[str, Any]:
item = dict(row)
item["is_read"] = bool(item["is_read"])
item["due"] = str(item["available_date"]) <= today
return item
+2
View File
@@ -4,6 +4,7 @@ from backend.features.accounts.routes import router as accounts_router
from backend.features.heaven.routes import router as heaven_router
from backend.features.market.routes import router as market_router
from backend.features.mentor.routes import router as mentor_router
from backend.features.review.routes import router as review_router
from backend.features.screener.routes import router as screener_router
from backend.http.routes.health import router as health_router
@@ -14,3 +15,4 @@ api_router.include_router(market_router)
api_router.include_router(screener_router)
api_router.include_router(mentor_router)
api_router.include_router(heaven_router)
api_router.include_router(review_router)
+46
View File
@@ -0,0 +1,46 @@
# 阶段12验收记录
## 完成范围
- 我的复盘:账户独立的自选追踪、交易日志、每日复盘、历史记录和个股复盘笔记。
- 提醒中心:手动提醒、策略T+1/T+5提醒、未读状态、批量已读和持久化去重。
- 复盘助手:统一LLM网关、结构化复盘上下文、流式回答、历史与清空。
- 权限:复盘私有数据严格按账号隔离;非会员保留完整助手界面并灰化锁定。
- 前端:PC与移动端、日间与夜间、顶栏提醒入口和个股详情笔记入口。
## 关键行为
- 每日复盘按账号和交易日唯一更新,三个输入区分别保存盘面摘要、得失和明日计划。
- 交易日志只统计已填写盈亏字段的已实现记录;零收益进入胜率分母但不计为盈利。
- 重复加入已有自选不会清空跟踪备注;自选、笔记、交易、提醒和助手历史均不可跨账号读取或修改。
- 策略跟踪提醒以运行批次和T+1/T+5阶段生成稳定去重键,重复读取提醒中心不会重复创建。
- 复盘助手只读取本地市场快照和当前账号记录,不执行交易,不建立第二套模型、配额或流式实现。
## 自动验收
- Ruff:通过。
- Pytest92项通过。
- Vue TypeScript:通过。
- Vitest3个文件、7项通过。
- Vite生产构建:通过。
- Playwright:阶段4至12共14项通过,固定单工作进程避免共享验收账号并发互相撤销会话。
- 数据库:版本10前进、回退、账户隔离和每日复盘唯一更新测试通过。
- Git差异检查:通过。
- 密钥扫描:已提供的账号密码和Token值无匹配。
## 视觉证据
- `review-dark-1920x1080.jpg`
- `review-dark-390x844.jpg`
## 减法审计
- 未导入旧复盘页面、旧自选存储、旧助手脚本或旧弹窗覆盖层。
- 复盘领域只有一组Repository、Service和Routes;响应转换拆入54行的`views.py`,业务Service保持368行。
- 复盘助手继续复用阶段10唯一LLM网关,顶栏提醒和助手继续复用全局弹窗Host。
- 页面按复盘、交易弹窗、提醒、助手和个股笔记拆分;页面样式397行,没有新增令牌外色值或间距字面值。
## 残余边界
- 策略提醒依赖盘后跟踪指标已写入;缺少T+1/T+5结果时不会伪造提醒。
- NAS生产容器保持不变,最终切换仍需人工确认。
Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+9 -2
View File
@@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { marketApi } from "../../shared/api/market";
import { reviewApi } from "../../shared/api/review";
import { useMarketStore } from "../../shared/stores/market";
import { useSessionStore } from "../../shared/stores/session";
import { useUiStore } from "../../shared/stores/ui";
@@ -70,7 +71,12 @@ async function refreshMarket(): Promise<void> {
}
}
onMounted(() => document.addEventListener("mousedown", closeOnOutside));
async function refreshAlerts(): Promise<void> {
try { ui.alertUnread = (await reviewApi.alerts()).unread_count; }
catch { ui.alertUnread = 0; }
}
onMounted(() => { document.addEventListener("mousedown", closeOnOutside); void refreshAlerts(); });
onBeforeUnmount(() => document.removeEventListener("mousedown", closeOnOutside));
</script>
@@ -84,7 +90,8 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", closeOnOutside))
<button class="icon-button" type="button" aria-label="后一日" title="后一日" @click="market.moveDate(1)"></button>
</div>
<button class="icon-button" type="button" aria-label="全局搜索" title="全局搜索(Ctrl+K" @click="ui.openDialog('search')"></button>
<button class="icon-button desktop-only" type="button" aria-label="提醒中心" title="提醒中心" @click="ui.showToast('暂无新提醒')">!</button>
<button class="icon-button topbar-alert desktop-only" type="button" aria-label="提醒中心" title="提醒中心" @click="ui.openDialog('alerts')">!<span v-if="ui.alertUnread" class="alert-badge">{{ ui.alertUnread > 99 ? '99+' : ui.alertUnread }}</span></button>
<button class="icon-button desktop-only" type="button" aria-label="复盘助手" title="复盘助手" @click="ui.openDialog('assistant')"></button>
<button class="btn btn-small" type="button" :title="ui.theme === 'light' ? '切换夜间模式' : '切换日间模式'" @click="ui.toggleTheme">
{{ ui.theme === "light" ? "夜间" : "日间" }}
</button>
@@ -4,6 +4,7 @@ import { useRoute } from "vue-router";
import type { MarketEntity } from "../../shared/api/market";
import MarketPreviewPanel from "../../shared/market/MarketPreviewPanel.vue";
import StockNotes from "../../pages/review/StockNotes.vue";
const route = useRoute();
const entity = computed<MarketEntity>(() => ({
@@ -22,5 +23,6 @@ const entity = computed<MarketEntity>(() => ({
<span class="tag">最新真实行情</span>
</header>
<MarketPreviewPanel :entity="entity" class="card entity-chart" />
<StockNotes v-if="entity.entity_type === 'stock'" :code="entity.code" :name="entity.name" />
</main>
</template>
@@ -7,6 +7,7 @@ import MarketWorkspaceView from "../../pages/market/MarketWorkspaceView.vue";
import ScreenerPage from "../../pages/screener/ScreenerPage.vue";
import MentorPage from "../../pages/mentor/MentorPage.vue";
import HeavenPage from "../../pages/heaven/HeavenPage.vue";
import ReviewPage from "../../pages/review/ReviewPage.vue";
import { useMarketStore } from "../../shared/stores/market";
import { useSessionStore } from "../../shared/stores/session";
import { useUiStore } from "../../shared/stores/ui";
@@ -35,6 +36,7 @@ const implementedMarket = computed(() =>
<ScreenerPage v-else-if="workspace.key === 'screener'" />
<MentorPage v-else-if="workspace.key === 'mentor'" />
<HeavenPage v-else-if="workspace.key === 'heaven'" />
<ReviewPage v-else-if="workspace.key === 'review'" />
<main v-else class="page-frame">
<header class="page-header">
<h1>{{ workspace.title }}</h1>
+1
View File
@@ -18,6 +18,7 @@ import "./shared/styles/mentor.css";
import "./shared/styles/heaven.css";
import "./shared/styles/heaven-fortune.css";
import "./shared/styles/heaven-heart.css";
import "./shared/styles/review.css";
import "./shared/styles/system.css";
import "./shared/styles/mobile.css";
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { reviewApi, type AlertItem } from "../../shared/api/review";
import EmptyState from "../../shared/components/EmptyState.vue";
import { useUiStore } from "../../shared/stores/ui";
const ui = useUiStore();
const items = ref<AlertItem[]>([]);
const unreadOnly = ref(false);
const loading = ref(true);
const form = reactive({ title: "", remind_date: new Date().toISOString().slice(0, 10), code: "", content: "" });
async function load(): Promise<void> {
loading.value = true;
try {
const result = await reviewApi.alerts(unreadOnly.value);
items.value = result.items;
ui.alertUnread = result.unread_count;
} catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "提醒读取失败"); }
finally { loading.value = false; }
}
async function create(): Promise<void> {
try {
await reviewApi.createAlert(form);
Object.assign(form, { title: "", code: "", content: "" });
ui.showToast("提醒已保存"); await load();
} catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "提醒保存失败"); }
}
async function mark(row: AlertItem): Promise<void> { await reviewApi.markAlert(row.id); await load(); }
async function markAll(): Promise<void> { await reviewApi.markAllAlerts(); await load(); }
async function remove(row: AlertItem): Promise<void> { await reviewApi.deleteAlert(row.id); await load(); }
function select(unread: boolean): void { unreadOnly.value = unread; void load(); }
onMounted(load);
</script>
<template>
<div class="alerts-panel">
<div class="alerts-toolbar"><div class="segmented"><button type="button" :class="{ active: !unreadOnly }" @click="select(false)">全部</button><button type="button" :class="{ active: unreadOnly }" @click="select(true)">未读</button></div><button class="btn btn-small" type="button" @click="markAll">全部已读</button></div>
<form class="alert-create" @submit.prevent="create"><div class="alert-create-grid"><label class="field"><span class="field-label">标题</span><input v-model="form.title" class="input" maxlength="80" required /></label><label class="field"><span class="field-label">提醒日期</span><input v-model="form.remind_date" class="input" type="date" required /></label><label class="field"><span class="field-label">股票代码(可选)</span><input v-model="form.code" class="input" maxlength="12" /></label></div><label class="field"><span class="field-label">提醒内容(可选)</span><textarea v-model="form.content" class="textarea" maxlength="500"></textarea></label><div class="form-actions"><button class="btn btn-primary" type="submit">保存提醒</button></div></form>
<div v-if="loading" class="workspace-state">正在读取提醒</div>
<div v-else-if="items.length" class="alert-list"><article v-for="item in items" :key="item.id" :class="{ unread: !item.is_read && item.due }"><div><strong>{{ item.title }}</strong><span class="tag">{{ item.due ? (item.is_read ? "已读" : "未读") : "未到期" }}</span></div><p>{{ item.content }}</p><small>{{ item.available_date }}<template v-if="item.code"> · {{ item.code }}</template></small><div class="table-actions"><button v-if="item.due && !item.is_read" class="btn btn-small" type="button" @click="mark(item)">标为已读</button><button class="btn btn-small" type="button" @click="remove(item)">删除</button></div></article></div>
<EmptyState v-else title="暂无提醒" description="可以创建站内提醒,策略跟踪反馈也会自动汇总到这里。" />
</div>
</template>
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from "vue";
import { reviewApi, type AssistantMessage } from "../../shared/api/review";
import { useMarketStore } from "../../shared/stores/market";
import { useSessionStore } from "../../shared/stores/session";
import { useUiStore } from "../../shared/stores/ui";
const market = useMarketStore();
const session = useSessionStore();
const ui = useUiStore();
const messages = ref<AssistantMessage[]>([]);
const question = ref("");
const loading = ref(false);
const messageRoot = ref<HTMLElement | null>(null);
let controller: AbortController | undefined;
const locked = computed(() => !session.account?.smart_access);
const prompts: Array<[string, string]> = [
["市场位置", "结合近十日情绪和今天的数据,当前市场处于什么位置?"],
["市场主线", "总结当前市场主线,并说明证据和可能的失效条件。"],
["交易复盘", "结合我的策略跟踪和交易日志,找出最值得修正的一个行为模式。"],
["明日清单", "为下一个交易日给出条件化观察清单,不给无条件买卖指令。"],
];
async function load(): Promise<void> {
if (locked.value) return;
try { messages.value = await reviewApi.assistantMessages(); }
catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "对话读取失败"); }
}
async function send(): Promise<void> {
const value = question.value.trim();
if (!value || loading.value || locked.value) return;
question.value = "";
const user = { id: Date.now(), role: "user", content: value, context_date: market.selectedDate, status: "complete", created_at: "" } as AssistantMessage;
const answer = { id: Date.now() + 1, role: "assistant", content: "", context_date: market.selectedDate, status: "complete", created_at: "" } as AssistantMessage;
messages.value.push(user, answer);
loading.value = true;
controller = new AbortController();
try {
await reviewApi.chat(market.selectedDate, value, (event) => {
if (event.type === "delta") answer.content += event.content ?? "";
if (event.type === "error") ui.showToast(event.message ?? "复盘助手回答中断");
void scrollEnd();
}, controller.signal);
} catch (reason) {
if (!(reason instanceof DOMException && reason.name === "AbortError")) ui.showToast(reason instanceof Error ? reason.message : "回答失败");
} finally { loading.value = false; controller = undefined; }
}
async function clear(): Promise<void> {
await reviewApi.clearAssistant(); messages.value = [];
}
function usePrompt(value: string): void { question.value = value; }
function stop(): void { controller?.abort(); }
async function scrollEnd(): Promise<void> { await nextTick(); if (messageRoot.value) messageRoot.value.scrollTop = messageRoot.value.scrollHeight; }
onMounted(load);
</script>
<template>
<div class="assistant-panel">
<div v-if="locked" class="notice notice-warning"><strong>复盘助手仅对会员开放</strong><span>开通会员后可读取市场统计策略跟踪与个人复盘记录进行统一复盘分析</span></div>
<div class="assistant-content" :class="{ 'locked-content': locked }" :aria-disabled="locked">
<div ref="messageRoot" class="assistant-messages"><article v-for="message in messages" :key="message.id" :class="message.role"><span>{{ message.role === 'user' ? '我' : '复盘助手' }}</span><p>{{ message.content || (loading ? "正在整理复盘材料" : "") }}</p></article><div v-if="!messages.length" class="assistant-empty">可以从市场策略或自己的交易记录开始复盘</div></div>
<div class="assistant-prompts"><button v-for="item in prompts" :key="item[0]" type="button" :disabled="locked" @click="usePrompt(item[1])">{{ item[0] }}</button></div>
<form class="assistant-compose" @submit.prevent="send"><textarea v-model="question" class="textarea" maxlength="2000" placeholder="询问市场位置、主线、策略表现或自己的交易模式" :disabled="locked" required></textarea><div><button v-if="loading" class="btn" type="button" @click="stop">停止</button><button v-else class="btn btn-primary" type="submit" :disabled="locked">发送</button><button class="btn btn-ghost" type="button" :disabled="locked" @click="clear">清空对话</button></div></form>
</div>
</div>
</template>
@@ -0,0 +1,144 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { marketApi, type MarketEntity } from "../../shared/api/market";
import { reviewApi, type ReviewWorkspace, type TradeEntry, type WatchItem } from "../../shared/api/review";
import BaseDialog from "../../shared/components/BaseDialog.vue";
import EmptyState from "../../shared/components/EmptyState.vue";
import { useMarketStore } from "../../shared/stores/market";
import { useUiStore } from "../../shared/stores/ui";
import TradeDialog from "./TradeDialog.vue";
const market = useMarketStore();
const ui = useUiStore();
const data = ref<ReviewWorkspace | null>(null);
const loading = ref(true);
const error = ref("");
const historyOpen = ref(false);
const tradeDialog = ref(false);
const editingTrade = ref<TradeEntry | null>(null);
const watchDialog = ref(false);
const watchQuery = ref("");
const watchResults = ref<MarketEntity[]>([]);
const daily = ref({ summary: "", content: "", plan: "" });
let searchTimer: ReturnType<typeof setTimeout> | undefined;
const summary = computed(() => data.value?.trade_summary);
function display(value: number | null | undefined, suffix = ""): string {
return value === null || value === undefined ? "" : `${value.toFixed(2)}${suffix}`;
}
async function load(): Promise<void> {
loading.value = true;
error.value = "";
try {
data.value = await reviewApi.workspace(market.selectedDate);
daily.value = {
summary: data.value.daily?.summary ?? "",
content: data.value.daily?.content ?? "",
plan: data.value.daily?.plan ?? "",
};
} catch (reason) {
error.value = reason instanceof Error ? reason.message : "复盘记录读取失败";
} finally { loading.value = false; }
}
async function saveDaily(): Promise<void> {
try {
await reviewApi.saveNote({
trade_date: market.selectedDate, code: "", stock_name: "", ...daily.value,
});
ui.showToast("每日复盘已保存");
await load();
} catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "保存失败"); }
}
function searchWatch(): void {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(async () => {
const query = watchQuery.value.trim();
if (!query) { watchResults.value = []; return; }
try {
const result = await marketApi.search(query);
watchResults.value = result.groups.find((group) => group.entity_type === "stock")?.items ?? [];
} catch { watchResults.value = []; }
}, 160);
}
async function addWatch(item: MarketEntity): Promise<void> {
try {
await reviewApi.addWatch(item.identifier);
watchDialog.value = false;
watchQuery.value = "";
watchResults.value = [];
ui.showToast(`${item.name} 已加入自选`);
await load();
} catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "添加失败"); }
}
async function saveRemark(item: WatchItem, event: Event): Promise<void> {
try {
await reviewApi.saveRemark(item.identifier, (event.target as HTMLInputElement).value);
ui.showToast("跟踪备注已保存");
} catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "保存失败"); }
}
async function removeWatch(item: WatchItem): Promise<void> {
if (!await ui.askConfirmation({ title: "移出自选", message: `确定移出 ${item.name}`, confirmLabel: "移出" })) return;
try { await reviewApi.removeWatch(item.identifier); ui.showToast("已移出自选"); await load(); }
catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "操作失败"); }
}
function openTrade(row: TradeEntry | null = null): void { editingTrade.value = row; tradeDialog.value = true; }
async function saveTrade(value: Omit<TradeEntry, "action_label" | "emotion_label">): Promise<void> {
try { await reviewApi.saveTrade(value); tradeDialog.value = false; ui.showToast("交易记录已保存"); await load(); }
catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "保存失败"); }
}
async function deleteTrade(row: TradeEntry): Promise<void> {
if (!await ui.askConfirmation({ title: "删除交易记录", message: `确定删除 ${row.name} 的交易记录?`, confirmLabel: "删除" })) return;
try { await reviewApi.deleteTrade(row.id); ui.showToast("交易记录已删除"); await load(); }
catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "删除失败"); }
}
watch(() => market.selectedDate, load);
onMounted(load);
</script>
<template>
<main class="page-frame review-page">
<header class="page-header review-heading">
<div><h1>我的复盘</h1><p class="page-subtitle">数据日期 {{ data?.trade_date ?? market.selectedDate }} · 当前账号私有</p></div>
<button class="btn btn-primary" type="button" @click="watchDialog = true">添加自选</button>
</header>
<div v-if="loading" class="card workspace-state">正在读取个人复盘记录</div>
<EmptyState v-else-if="error" class="card" title="复盘记录暂不可用" :description="error" />
<template v-else-if="data">
<section class="card review-watch-card">
<header class="card-header"><h2>自选追踪</h2><span class="tag">{{ data.watchlist.length }} </span></header>
<div v-if="data.watchlist.length" class="data-table-wrap review-watch-table"><table class="data-table"><thead><tr><th>标记</th><th>代码</th><th>股票</th><th>板块</th><th class="numeric">今日涨幅(%</th><th class="numeric">5日涨幅(%</th><th class="numeric">竞价关注分</th><th>跟踪备注</th><th>操作</th></tr></thead><tbody><tr v-for="item in data.watchlist" :key="item.identifier"><td class="watch-star">★</td><td>{{ item.code }}</td><td>{{ item.name }}</td><td>{{ item.sector ?? "" }}</td><td class="numeric" :class="item.pct_chg !== null && item.pct_chg >= 0 ? 'up' : 'down'">{{ display(item.pct_chg) }}</td><td class="numeric" :class="item.return_5d !== null && item.return_5d >= 0 ? 'up' : 'down'">{{ display(item.return_5d) }}</td><td class="numeric">{{ display(item.attention_score) }}</td><td><input :value="item.remark" class="review-remark" maxlength="500" aria-label="跟踪备注" @change="saveRemark(item, $event)" /></td><td><div class="table-actions"><button class="btn btn-small" type="button" @click="removeWatch(item)">移除</button></div></td></tr></tbody></table></div>
<EmptyState v-else title="暂无自选" description="添加股票后可集中查看涨幅、竞价关注分和跟踪备注。" />
</section>
<div class="review-main-grid">
<section class="card trade-card">
<header class="card-header"><h2>交易日志</h2><span class="tag">{{ data.trades.length }} </span><button class="btn btn-primary btn-small review-card-action" type="button" @click="openTrade()">交易日志</button></header>
<div class="review-summary"><div><span>总记录</span><strong>{{ summary?.total }}</strong></div><div><span>已实现</span><strong>{{ summary?.realized }}</strong></div><div><span>胜率</span><strong>{{ summary?.win_rate === null ? "" : display(summary?.win_rate, "%") }}</strong></div><div><span>累计盈亏</span><strong>{{ display(summary?.pnl_amount) }}</strong></div><div><span>平均仓位</span><strong>{{ summary?.average_position === null ? "" : display(summary?.average_position, "%") }}</strong></div></div>
<div v-if="data.trades.length" class="trade-scroll"><table class="data-table"><thead><tr><th>日期</th><th>代码</th><th>股票</th><th>动作</th><th class="numeric">价格(元)</th><th class="numeric">仓位(%</th><th class="numeric">盈亏(%</th><th>操作</th></tr></thead><tbody><tr v-for="row in data.trades" :key="row.id"><td>{{ row.trade_date }}</td><td>{{ row.code }}</td><td>{{ row.name }}</td><td>{{ row.action_label }}</td><td class="numeric">{{ display(row.price) }}</td><td class="numeric">{{ display(row.position_pct) }}</td><td class="numeric" :class="row.pnl_pct !== null && row.pnl_pct >= 0 ? 'up' : 'down'">{{ display(row.pnl_pct) }}</td><td><div class="table-actions"><button class="btn btn-small" type="button" @click="openTrade(row)">编辑</button><button class="btn btn-small" type="button" @click="deleteTrade(row)">删除</button></div></td></tr></tbody></table></div>
<EmptyState v-else title="暂无交易日志" description="记录交易后,摘要与明细会保留在当前页面。" />
</section>
<section class="card daily-review-card">
<header class="card-header"><h2>每日复盘</h2><span class="tag">{{ market.selectedDate }}</span></header>
<form class="daily-review-form" @submit.prevent="saveDaily">
<label class="field"><span class="field-label">今日盘面一句话</span><textarea v-model="daily.summary" class="textarea daily-short" maxlength="500"></textarea></label>
<label class="field"><span class="field-label">今日做对了什么 / 做错了什么</span><textarea v-model="daily.content" class="textarea" maxlength="5000"></textarea></label>
<label class="field"><span class="field-label">明日策略</span><textarea v-model="daily.plan" class="textarea" maxlength="2000"></textarea></label>
<div class="form-actions"><button class="btn btn-primary" type="submit">保存复盘</button></div>
</form>
</section>
</div>
<section class="card review-history-card">
<button class="review-history-toggle" type="button" :aria-expanded="historyOpen" @click="historyOpen = !historyOpen"><span>最近复盘</span><span>{{ historyOpen ? "收起" : `展开 ${data.history.length}` }}</span></button>
<div v-if="historyOpen" class="review-history-list"><article v-for="note in data.history" :key="note.id"><time>{{ note.trade_date }}</time><strong>{{ note.summary || "未填写盘面一句话" }}</strong><p>{{ note.content }}</p><small>{{ note.plan }}</small></article><EmptyState v-if="!data.history.length" title="暂无每日复盘" description="保存后会按日期显示在这里。" /></div>
</section>
</template>
</main>
<BaseDialog v-if="watchDialog" title="添加自选" @close="watchDialog = false">
<div class="form-grid"><label class="field"><span class="field-label">股票代码或名称</span><input v-model="watchQuery" class="input" autofocus @input="searchWatch" /></label><div class="watch-search-results"><button v-for="item in watchResults" :key="item.identifier" type="button" @click="addWatch(item)"><span><strong>{{ item.name }}</strong><small>{{ item.code }} · {{ item.sector || "行业待补" }}</small></span><span>添加</span></button><p v-if="watchQuery && !watchResults.length" class="muted">暂无匹配股票</p></div></div>
</BaseDialog>
<TradeDialog v-if="tradeDialog" :value="editingTrade" :date="market.selectedDate" @close="tradeDialog = false" @save="saveTrade" />
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { onMounted, ref, watch } from "vue";
import { reviewApi, type ReviewNote } from "../../shared/api/review";
import { useMarketStore } from "../../shared/stores/market";
import { useUiStore } from "../../shared/stores/ui";
const props = defineProps<{ code: string; name: string }>();
const market = useMarketStore();
const ui = useUiStore();
const rows = ref<ReviewNote[]>([]);
const content = ref("");
const plan = ref("");
async function load(): Promise<void> { rows.value = await reviewApi.stockNotes(props.code); }
async function save(): Promise<void> {
try {
await reviewApi.saveNote({ trade_date: market.selectedDate, code: props.code, stock_name: props.name, summary: "", content: content.value, plan: plan.value });
ui.showToast("个股复盘笔记已保存"); await load();
} catch (reason) { ui.showToast(reason instanceof Error ? reason.message : "保存失败"); }
}
async function remove(id: number): Promise<void> { await reviewApi.deleteNote(id); await load(); }
watch(() => props.code, load);
onMounted(load);
</script>
<template>
<section class="card stock-notes-card"><header class="card-header"><h2>个股复盘笔记</h2><span class="tag">当前账号私有</span></header><form class="stock-note-form" @submit.prevent="save"><label class="field"><span class="field-label">复盘内容</span><textarea v-model="content" class="textarea" maxlength="5000"></textarea></label><label class="field"><span class="field-label">明日计划</span><textarea v-model="plan" class="textarea" maxlength="2000"></textarea></label><div class="form-actions"><button class="btn btn-primary" type="submit">保存笔记</button></div></form><div v-if="rows.length" class="stock-note-history"><article v-for="row in rows" :key="row.id"><time>{{ row.trade_date }}</time><p>{{ row.content }}</p><small>{{ row.plan }}</small><button class="btn btn-small" type="button" @click="remove(row.id)">删除</button></article></div></section>
</template>
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { reactive, watch } from "vue";
import type { TradeEntry } from "../../shared/api/review";
import BaseDialog from "../../shared/components/BaseDialog.vue";
const props = defineProps<{ value: TradeEntry | null; date: string }>();
const emit = defineEmits<{ close: []; save: [value: Omit<TradeEntry, "action_label" | "emotion_label">] }>();
const form = reactive({
id: 0, trade_date: "", code: "", name: "", action: "buy" as TradeEntry["action"],
price: 0, quantity: 0, position_pct: null as number | null, pnl_amount: null as number | null,
pnl_pct: null as number | null, emotion: "calm" as TradeEntry["emotion"], tags: "",
thesis: "", execution: "",
});
watch(() => [props.value, props.date] as const, () => {
const row = props.value;
Object.assign(form, row ? { ...row, tags: row.tags.join("") } : {
id: 0, trade_date: props.date, code: "", name: "", action: "buy", price: 0,
quantity: 0, position_pct: null, pnl_amount: null, pnl_pct: null,
emotion: "calm", tags: "", thesis: "", execution: "",
});
}, { immediate: true });
function submit(): void {
emit("save", {
id: form.id, trade_date: form.trade_date, code: form.code, name: form.name,
action: form.action, price: Number(form.price), quantity: Number(form.quantity),
position_pct: form.position_pct === null ? null : Number(form.position_pct),
pnl_amount: form.pnl_amount === null ? null : Number(form.pnl_amount),
pnl_pct: form.pnl_pct === null ? null : Number(form.pnl_pct), emotion: form.emotion,
tags: form.tags.split(/[,]/).map((item) => item.trim()).filter(Boolean).slice(0, 8),
thesis: form.thesis, execution: form.execution,
});
}
</script>
<template>
<BaseDialog :title="form.id ? '编辑交易日志' : '交易日志'" wide @close="emit('close')">
<form class="trade-form" @submit.prevent="submit">
<div class="trade-form-grid">
<label class="field"><span class="field-label">交易日</span><input v-model="form.trade_date" class="input" type="date" required /></label>
<label class="field"><span class="field-label">代码</span><input v-model="form.code" class="input" maxlength="12" required /></label>
<label class="field"><span class="field-label">名称</span><input v-model="form.name" class="input" maxlength="40" required /></label>
<label class="field"><span class="field-label">动作</span><select v-model="form.action" class="select"><option value="buy">买入</option><option value="sell">卖出</option><option value="add">加仓</option><option value="trim">减仓</option><option value="watch">观察</option></select></label>
<label class="field"><span class="field-label">价格</span><input v-model.number="form.price" class="input" type="number" min="0" step="0.01" required /></label>
<label class="field"><span class="field-label">数量</span><input v-model.number="form.quantity" class="input" type="number" min="0" step="1" /></label>
<label class="field"><span class="field-label">仓位%</span><input v-model.number="form.position_pct" class="input" type="number" min="0" max="100" step="0.1" /></label>
<label class="field"><span class="field-label">盈亏金额</span><input v-model.number="form.pnl_amount" class="input" type="number" step="0.01" /></label>
<label class="field"><span class="field-label">盈亏%</span><input v-model.number="form.pnl_pct" class="input" type="number" step="0.01" /></label>
<label class="field"><span class="field-label">情绪</span><select v-model="form.emotion" class="select"><option value="calm">平静</option><option value="confident">笃定</option><option value="hesitant">犹豫</option><option value="anxious">焦虑</option><option value="impulsive">冲动</option></select></label>
<label class="field trade-wide"><span class="field-label">标签</span><input v-model="form.tags" class="input" maxlength="160" placeholder="回踩确认,计划内" /></label>
<label class="field trade-wide"><span class="field-label">交易逻辑</span><textarea v-model="form.thesis" class="textarea" maxlength="2000"></textarea></label>
<label class="field trade-wide"><span class="field-label">执行复核</span><textarea v-model="form.execution" class="textarea" maxlength="2000"></textarea></label>
</div>
<div class="form-actions"><button class="btn" type="button" @click="emit('close')">取消</button><button class="btn btn-primary" type="submit">保存</button></div>
</form>
</BaseDialog>
</template>
+92
View File
@@ -0,0 +1,92 @@
import { api } from "./client";
export type WatchItem = {
identifier: string; code: string; name: string; sector: string | null; remark: string;
pct_chg: number | null; return_5d: number | null; attention_score: number | null;
};
export type ReviewNote = {
id: number; code: string; stock_name: string; trade_date: string;
summary: string; content: string; plan: string; updated_at: string;
};
export type TradeEntry = {
id: number; trade_date: string; code: string; name: string;
action: "buy" | "sell" | "add" | "trim" | "watch"; action_label: string;
price: number; quantity: number; position_pct: number | null;
pnl_amount: number | null; pnl_pct: number | null;
emotion: "calm" | "confident" | "hesitant" | "anxious" | "impulsive";
emotion_label: string; tags: string[]; thesis: string; execution: string;
};
export type TradeSummary = {
total: number; realized: number; win_rate: number | null;
pnl_amount: number | null; average_position: number | null;
};
export type ReviewWorkspace = {
trade_date: string; watchlist: WatchItem[]; daily: ReviewNote | null;
history: ReviewNote[]; trades: TradeEntry[]; trade_summary: TradeSummary;
};
export type AlertItem = {
id: number; kind: string; title: string; content: string; available_date: string;
code: string; is_read: boolean; due: boolean; created_at: string;
};
export type AlertCenter = { items: AlertItem[]; unread_count: number; as_of: string };
export type AssistantMessage = {
id: number; role: "user" | "assistant"; content: string;
context_date: string; status: "complete" | "stopped" | "error"; created_at: string;
};
export type AssistantEvent = {
type: "delta" | "done" | "error"; content?: string; message?: string; partial?: boolean;
};
export const reviewApi = {
workspace(date: string): Promise<ReviewWorkspace> {
return api.get(`/review?date=${encodeURIComponent(date)}`);
},
addWatch(identifier: string): Promise<{ identifier: string; name: string }> {
return api.post("/review/watchlist", { identifier });
},
saveRemark(identifier: string, remark: string): Promise<{ message: string }> {
return api.patch(`/review/watchlist/${encodeURIComponent(identifier)}/remark`, { remark });
},
removeWatch(identifier: string): Promise<{ message: string }> {
return api.delete(`/review/watchlist/${encodeURIComponent(identifier)}`);
},
saveNote(note: Omit<ReviewNote, "id" | "updated_at">): Promise<{ id: number }> {
return api.put("/review/notes", note);
},
stockNotes(code: string): Promise<ReviewNote[]> {
return api.get(`/review/stock-notes/${encodeURIComponent(code)}`);
},
deleteNote(id: number): Promise<{ message: string }> {
return api.delete(`/review/notes/${id}`);
},
saveTrade(trade: Omit<TradeEntry, "action_label" | "emotion_label">): Promise<{ id: number }> {
return api.put("/review/trades", trade);
},
deleteTrade(id: number): Promise<{ message: string }> {
return api.delete(`/review/trades/${id}`);
},
alerts(unread = false): Promise<AlertCenter> {
return api.get(`/review/alerts?unread=${unread}`);
},
createAlert(value: { title: string; remind_date: string; code: string; content: string }): Promise<{ id: number }> {
return api.post("/review/alerts", value);
},
markAlert(id: number): Promise<{ message: string }> {
return api.patch(`/review/alerts/${id}/read`);
},
markAllAlerts(): Promise<{ updated: number }> {
return api.patch("/review/alerts/read-all");
},
deleteAlert(id: number): Promise<{ message: string }> {
return api.delete(`/review/alerts/${id}`);
},
assistantMessages(): Promise<AssistantMessage[]> {
return api.get("/review/assistant/messages");
},
clearAssistant(): Promise<{ deleted: number }> {
return api.delete("/review/assistant/messages");
},
chat(date: string, question: string, onEvent: (event: AssistantEvent) => void, signal?: AbortSignal): Promise<void> {
return api.stream("/review/assistant/chat", { trade_date: date, question }, onEvent, signal);
},
};
@@ -3,19 +3,26 @@ import ProfilePanel from "../account/ProfilePanel.vue";
import MembershipPanel from "../account/MembershipPanel.vue";
import PasswordPanel from "../account/PasswordPanel.vue";
import SearchPanel from "../account/SearchPanel.vue";
import AlertsPanel from "../../pages/review/AlertsPanel.vue";
import AssistantPanel from "../../pages/review/AssistantPanel.vue";
import { useUiStore } from "../stores/ui";
import BaseDialog from "./BaseDialog.vue";
const ui = useUiStore();
const titles = { profile: "个人资料", membership: "会员状态", password: "修改密码", search: "全局搜索" } as const;
const titles = {
profile: "个人资料", membership: "会员状态", password: "修改密码",
search: "全局搜索", alerts: "提醒中心", assistant: "复盘助手",
} as const;
</script>
<template>
<BaseDialog v-if="ui.dialog" :title="titles[ui.dialog]" :wide="ui.dialog === 'membership' || ui.dialog === 'search'" @close="ui.closeDialog">
<BaseDialog v-if="ui.dialog" :title="titles[ui.dialog]" :wide="['membership', 'search', 'alerts', 'assistant'].includes(ui.dialog)" @close="ui.closeDialog">
<ProfilePanel v-if="ui.dialog === 'profile'" />
<MembershipPanel v-else-if="ui.dialog === 'membership'" />
<PasswordPanel v-else-if="ui.dialog === 'password'" />
<SearchPanel v-else />
<SearchPanel v-else-if="ui.dialog === 'search'" />
<AlertsPanel v-else-if="ui.dialog === 'alerts'" />
<AssistantPanel v-else />
</BaseDialog>
<BaseDialog v-else-if="ui.confirmation" :title="ui.confirmation.title" @close="ui.resolveConfirmation(false)">
<div class="form-grid">
+3 -2
View File
@@ -1,7 +1,7 @@
import { defineStore } from "pinia";
import { ref } from "vue";
export type DialogName = "profile" | "membership" | "password" | "search" | null;
export type DialogName = "profile" | "membership" | "password" | "search" | "alerts" | "assistant" | null;
export type Theme = "light" | "dark";
export type Confirmation = { title: string; message: string; confirmLabel: string };
@@ -23,6 +23,7 @@ export const useUiStore = defineStore("ui", () => {
const dialog = ref<DialogName>(null);
const confirmation = ref<Confirmation | null>(null);
const toast = ref("");
const alertUnread = ref(0);
let toastTimer: ReturnType<typeof setTimeout> | undefined;
let confirmationResolve: ((value: boolean) => void) | undefined;
@@ -69,7 +70,7 @@ export const useUiStore = defineStore("ui", () => {
}
return {
theme, dialog, confirmation, toast, setTheme, toggleTheme, openDialog, closeDialog,
theme, dialog, confirmation, toast, alertUnread, setTheme, toggleTheme, openDialog, closeDialog,
askConfirmation, resolveConfirmation, showToast,
};
});
@@ -246,6 +246,20 @@
}
}
@media (max-width: 1023px) {
.review-main-grid { grid-template-columns: minmax(0, 1fr); }
.trade-card, .daily-review-card { min-height: auto; }
.review-watch-table { max-height: var(--s-320); }
.trade-scroll { max-height: var(--s-320); }
.trade-form-grid, .alert-create-grid { grid-template-columns: minmax(0, 1fr); }
.review-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.review-summary > div:last-child { grid-column: 1 / -1; }
.review-history-list article, .stock-note-history article { grid-template-columns: minmax(0, 1fr); }
.review-history-list p, .review-history-list small,
.stock-note-history p, .stock-note-history small { grid-column: 1; }
.assistant-messages article { max-width: 94%; }
}
@media (max-width: 1399px) {
.auction-layout {
grid-template-columns: minmax(0, 1fr);
+397
View File
@@ -0,0 +1,397 @@
.review-page {
min-width: 0;
display: grid;
gap: var(--layout-gap);
}
.review-heading,
.review-watch-card .card-header,
.trade-card .card-header {
display: flex;
align-items: center;
}
.review-heading > div,
.review-watch-card .card-header h2,
.trade-card .card-header h2 {
flex: 1;
}
.review-watch-table {
width: 100%;
max-height: var(--s-260);
}
.review-watch-card,
.review-main-grid,
.review-main-grid > *,
.trade-scroll {
min-width: 0;
}
.review-watch-table th:first-child,
.review-watch-table td:first-child {
width: var(--s-44);
text-align: center;
}
.review-watch-table th:nth-child(8),
.review-watch-table td:nth-child(8) {
min-width: var(--s-200);
}
.watch-star {
color: var(--color-warning);
}
.review-remark {
width: 100%;
min-height: var(--s-30);
padding: var(--s-4) var(--s-7);
border: var(--s-1) solid var(--c-transparent);
border-radius: var(--control-radius);
color: var(--color-text);
background: var(--color-surface-muted);
font-size: var(--font-12);
}
.review-remark:focus {
border-color: var(--color-primary-border);
outline: none;
}
.review-main-grid {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(var(--s-360), 0.8fr);
gap: var(--layout-gap);
align-items: stretch;
}
.trade-card,
.daily-review-card {
min-height: var(--s-480);
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
overflow: hidden;
}
.review-card-action {
margin-left: var(--s-8);
}
.review-summary {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--s-1);
border-bottom: var(--s-1) solid var(--color-divider);
background: var(--color-divider);
}
.review-summary > div {
display: grid;
gap: var(--s-2);
padding: var(--s-8) var(--s-10);
background: var(--color-surface-muted);
}
.review-summary span {
color: var(--color-text-secondary);
font-size: var(--font-11);
}
.review-summary strong {
min-height: var(--s-18);
font-size: var(--font-14);
font-variant-numeric: tabular-nums;
}
.trade-scroll {
min-height: 0;
overflow: auto;
}
.trade-scroll .data-table {
min-width: var(--s-dialog-wide);
}
.daily-review-card {
grid-template-rows: auto minmax(0, 1fr);
}
.daily-review-form {
display: grid;
grid-template-rows: auto minmax(var(--s-96), 1fr) minmax(var(--s-80), 0.8fr) auto;
gap: var(--s-10);
padding: var(--s-14);
}
.daily-review-form .field,
.daily-review-form .textarea {
min-height: 0;
}
.daily-short {
height: var(--s-64);
}
.review-history-toggle {
width: 100%;
min-height: var(--s-44);
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--s-10) var(--s-14);
color: var(--color-text);
background: var(--c-transparent);
font-size: var(--font-13);
font-weight: var(--weight-600);
}
.review-history-toggle span:last-child {
color: var(--color-text-secondary);
font-size: var(--font-11);
font-weight: var(--weight-400);
}
.review-history-list {
max-height: var(--s-320);
overflow: auto;
border-top: var(--s-1) solid var(--color-divider);
}
.review-history-list article,
.stock-note-history article {
display: grid;
grid-template-columns: var(--s-96) minmax(0, 1fr);
gap: var(--s-6) var(--s-12);
padding: var(--s-12) var(--s-14);
border-bottom: var(--s-1) solid var(--color-divider);
}
.review-history-list time,
.stock-note-history time {
color: var(--color-text-secondary);
font-size: var(--font-11);
}
.review-history-list p,
.review-history-list small,
.stock-note-history p,
.stock-note-history small {
grid-column: 2;
color: var(--color-text-secondary);
line-height: var(--s-20);
white-space: pre-wrap;
}
.watch-search-results {
display: grid;
gap: var(--s-4);
}
.watch-search-results button {
min-height: var(--s-44);
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--s-7) var(--s-10);
border-radius: var(--control-radius);
color: var(--color-text);
background: var(--color-surface-muted);
text-align: left;
}
.watch-search-results button:hover {
background: var(--color-primary-soft);
}
.watch-search-results button span:first-child {
display: grid;
gap: var(--s-2);
}
.watch-search-results small {
color: var(--color-text-secondary);
}
.trade-form-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--s-10);
}
.trade-wide {
grid-column: 1 / -1;
}
.alerts-panel,
.assistant-panel {
display: grid;
gap: var(--s-12);
}
.alerts-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
}
.segmented {
display: inline-flex;
padding: var(--s-2);
border-radius: var(--control-radius);
background: var(--color-surface-muted);
}
.segmented button,
.assistant-prompts button {
min-height: var(--s-30);
padding: var(--s-4) var(--s-10);
border-radius: var(--control-radius);
color: var(--color-text-secondary);
font-size: var(--font-12);
}
.segmented button.active,
.assistant-prompts button:hover {
color: var(--color-primary);
background: var(--color-surface-raised);
}
.alert-create {
display: grid;
gap: var(--s-10);
padding: var(--s-12);
border: var(--s-1) solid var(--color-border);
border-radius: var(--card-radius);
background: var(--color-surface-muted);
}
.alert-create-grid {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(var(--s-160), 0.8fr) minmax(var(--s-120), 0.7fr);
gap: var(--s-10);
}
.alert-list {
max-height: var(--s-320);
overflow: auto;
border: var(--s-1) solid var(--color-border);
border-radius: var(--card-radius);
}
.alert-list article {
display: grid;
gap: var(--s-6);
padding: var(--s-12);
border-bottom: var(--s-1) solid var(--color-divider);
}
.alert-list article.unread {
background: var(--color-primary-soft);
}
.alert-list article > div:first-child {
display: flex;
align-items: center;
gap: var(--s-8);
}
.alert-list p,
.alert-list small {
color: var(--color-text-secondary);
}
.assistant-panel .notice {
display: grid;
gap: var(--s-2);
}
.assistant-content {
display: grid;
grid-template-rows: minmax(var(--s-240), 1fr) auto auto;
gap: var(--s-10);
}
.assistant-messages {
max-height: var(--s-400);
overflow: auto;
padding: var(--s-10);
border: var(--s-1) solid var(--color-border);
border-radius: var(--card-radius);
background: var(--color-surface-muted);
}
.assistant-messages article {
max-width: 86%;
display: grid;
gap: var(--s-4);
margin-bottom: var(--s-10);
padding: var(--s-9) var(--s-12);
border-radius: var(--card-radius);
background: var(--color-surface-raised);
}
.assistant-messages article.user {
margin-left: auto;
background: var(--color-primary-soft);
}
.assistant-messages article span,
.assistant-empty {
color: var(--color-text-secondary);
font-size: var(--font-11);
}
.assistant-messages article p {
line-height: var(--s-22);
white-space: pre-wrap;
}
.assistant-prompts,
.assistant-compose > div {
display: flex;
flex-wrap: wrap;
gap: var(--s-6);
}
.assistant-compose {
display: grid;
gap: var(--s-8);
}
.stock-notes-card {
margin-top: var(--layout-gap);
}
.stock-note-form {
display: grid;
gap: var(--s-10);
padding: var(--s-14);
}
.stock-note-history {
max-height: var(--s-320);
overflow: auto;
border-top: var(--s-1) solid var(--color-divider);
}
.topbar-alert {
position: relative;
}
.alert-badge {
position: absolute;
top: calc(var(--s-6) * -1);
right: calc(var(--s-8) * -1);
min-width: var(--s-18);
height: var(--s-18);
display: grid;
place-items: center;
padding: 0 var(--s-4);
border-radius: var(--radius-round);
color: var(--c-gray-050);
background: var(--color-up);
font-size: var(--font-10-5);
font-variant-numeric: tabular-nums;
}
+1
View File
@@ -5,6 +5,7 @@ module.exports = defineConfig({
outputDir: "./data/playwright",
timeout: 30_000,
fullyParallel: false,
workers: 1,
reporter: "line",
use: {
baseURL: "http://127.0.0.1:5173",
+102
View File
@@ -0,0 +1,102 @@
const fs = require("node:fs");
const path = require("node:path");
const { expect, test } = require("@playwright/test");
const evidence = path.resolve(__dirname, "../../docs/evidence/stage-12");
test.beforeAll(() => fs.mkdirSync(evidence, { recursive: true }));
async function authenticate(page, username, password) {
await page.goto("/");
await page.getByLabel("账号名").fill(username);
await page.getByLabel("密码").fill(password);
await page.getByRole("button", { name: "登录", exact: true }).click();
await expect(page.locator(".sidebar, .field-error")).toBeVisible();
if (!(await page.locator(".sidebar").isVisible())) {
await page.getByRole("tab", { name: "注册" }).click();
await page.getByRole("button", { name: "注册并登录" }).click();
}
}
function reviewPayload(trades = []) {
return {
trade_date: "2026-07-30",
watchlist: [{ identifier: "000001.SZ", code: "000001", name: "平安银行", sector: "银行", remark: "观察承接", pct_chg: 1.28, return_5d: 3.6, attention_score: 72 }],
daily: { id: 1, code: "", stock_name: "", trade_date: "2026-07-30", summary: "缩量分化", content: "按计划等待", plan: "观察主线承接", updated_at: "now" },
history: [{ id: 1, code: "", stock_name: "", trade_date: "2026-07-30", summary: "缩量分化", content: "按计划等待", plan: "观察主线承接", updated_at: "now" }],
trades,
trade_summary: { total: trades.length, realized: trades.length, win_rate: trades.length ? 100 : null, pnl_amount: trades.length ? 120 : null, average_position: trades.length ? 20 : null },
};
}
async function mockReview(page) {
let trades = [];
await page.route("**/api/review?*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify(reviewPayload(trades)) }));
await page.route("**/api/review/trades", async (route) => {
const value = route.request().postDataJSON();
trades = [{ ...value, id: 11, action_label: "买入", emotion_label: "平静" }];
await route.fulfill({ contentType: "application/json", body: JSON.stringify({ id: 11 }) });
});
await page.route("**/api/review/notes", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ id: 1 }) }));
await page.route("**/api/review/watchlist/**", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ message: "已保存" }) }));
await page.route("**/api/review/alerts?*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ items: [{ id: 1, kind: "manual", title: "复核承接", content: "开盘后观察", available_date: "2026-07-30", code: "000001", is_read: false, due: true, created_at: "now" }], unread_count: 1, as_of: "2026-07-30" }) }));
await page.route("**/api/review/alerts", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ id: 2 }) }));
await page.route("**/api/review/assistant/messages", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify(route.request().method() === "DELETE" ? { deleted: 0 } : []) }));
await page.route("**/api/review/assistant/chat", (route) => route.fulfill({ contentType: "application/x-ndjson", body: `${JSON.stringify({ type: "delta", content: "【市场事实】温度回落。" })}\n${JSON.stringify({ type: "delta", content: "【条件化计划】等待承接确认。" })}\n${JSON.stringify({ type: "done" })}\n` }));
await page.route("**/api/review/stock-notes/**", (route) => route.fulfill({ contentType: "application/json", body: "[]" }));
}
test("stage 12 private review, alerts and assistant remain usable", async ({ page }) => {
const consoleErrors = [];
page.on("console", (message) => { if (message.type() === "error" && !message.text().includes("401 (Unauthorized)")) consoleErrors.push(message.text()); });
await mockReview(page);
await authenticate(page, "stage4admin", "Stage4-pass-123!");
await page.goto("/workspace/review");
await expect(page.getByRole("heading", { name: "我的复盘" })).toBeVisible();
await expect(page.getByText("平安银行", { exact: true })).toBeVisible();
await expect(page.getByLabel("跟踪备注")).toHaveValue("观察承接");
await expect(page.getByLabel("今日盘面一句话")).toHaveValue("缩量分化");
await expect(page.getByLabel("今日做对了什么 / 做错了什么")).toHaveValue("按计划等待");
await expect(page.getByLabel("明日策略")).toHaveValue("观察主线承接");
await page.getByRole("button", { name: "交易日志", exact: true }).click();
const tradeDialog = page.getByRole("dialog", { name: "交易日志" });
await tradeDialog.getByLabel("代码").fill("000001");
await tradeDialog.getByLabel("名称").fill("平安银行");
await tradeDialog.getByLabel("价格(元)").fill("10.20");
await tradeDialog.getByLabel("仓位(%").fill("20");
await tradeDialog.getByLabel("盈亏(%").fill("1.2");
await tradeDialog.getByRole("button", { name: "保存", exact: true }).click();
await expect(tradeDialog).toHaveCount(0);
await expect(page.getByRole("status")).toContainText("交易记录已保存");
await expect(page.locator(".trade-scroll").getByText("平安银行", { exact: true })).toBeVisible();
await expect(page.locator(".dialog")).toHaveCount(0);
await page.getByRole("button", { name: "提醒中心" }).click();
await expect(page.getByRole("dialog", { name: "提醒中心" })).toContainText("复核承接");
await page.getByLabel("关闭").click();
await page.getByRole("button", { name: "复盘助手" }).click();
const assistant = page.getByRole("dialog", { name: "复盘助手" });
await assistant.getByRole("button", { name: "市场位置" }).click();
await assistant.getByRole("button", { name: "发送", exact: true }).click();
await expect(assistant.getByText("【市场事实】温度回落。【条件化计划】等待承接确认。", { exact: true })).toBeVisible();
await page.getByLabel("关闭").click();
await page.getByRole("button", { name: "夜间" }).click();
await page.screenshot({ path: path.join(evidence, "review-dark-1920x1080.jpg"), type: "jpeg", quality: 82 });
await page.setViewportSize({ width: 390, height: 844 });
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
await expect(page.locator(".mobile-nav")).toBeVisible();
await page.screenshot({ path: path.join(evidence, "review-dark-390x844.jpg"), type: "jpeg", quality: 82 });
expect(consoleErrors).toEqual([]);
});
test("nonmembers see the complete disabled review assistant", async ({ page }) => {
await mockReview(page);
await authenticate(page, "stage4user", "Stage4-user-123!");
await page.getByRole("button", { name: "复盘助手" }).click();
const dialog = page.getByRole("dialog", { name: "复盘助手" });
await expect(dialog.getByText("复盘助手仅对会员开放")).toBeVisible();
await expect(dialog.getByPlaceholder(/询问市场位置/)).toBeDisabled();
await expect(dialog.getByRole("button", { name: "市场位置" })).toBeVisible();
});
+17 -2
View File
@@ -110,7 +110,7 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
database = Database(tmp_path / "app.db")
runner = MigrationRunner(database)
assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4, 5, 6, 7, 8, 9)
assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
assert {
"users",
"memberships",
@@ -140,8 +140,23 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
"llm_requests",
"llm_attempts",
"heaven_readings",
"review_notes",
"trade_entries",
"alerts",
"review_assistant_messages",
} <= table_names(database)
assert runner.downgrade(MIGRATIONS, target_version=0) == (9, 8, 7, 6, 5, 4, 3, 2, 1)
assert runner.downgrade(MIGRATIONS, target_version=0) == (
10,
9,
8,
7,
6,
5,
4,
3,
2,
1,
)
assert "users" not in table_names(database)
assert "llm_models" not in table_names(database)
+142
View File
@@ -0,0 +1,142 @@
from __future__ import annotations
from backend.database import MIGRATIONS, Database, MigrationRunner
from backend.features.review.prompt import messages
from backend.features.review.repository import ReviewRepository
from backend.features.review.views import trade_summary
def _database(tmp_path) -> Database:
database = Database(tmp_path / "review.db")
MigrationRunner(database).upgrade(MIGRATIONS)
with database.transaction() as connection:
connection.executemany(
"""INSERT INTO users (
id, username, username_key, password_hash, is_admin,
status, created_at, updated_at
) VALUES (?, ?, ?, 'hash', 0, 'active', 'now', 'now')""",
((1, "first", "first"), (2, "second", "second")),
)
return database
def test_private_review_records_are_strictly_scoped_and_daily_notes_upsert(tmp_path) -> None:
database = _database(tmp_path)
repository = ReviewRepository()
note = {
"code": "",
"stock_name": "",
"trade_date": "2026-07-30",
"summary": "缩量分化",
"content": "追高一次",
"plan": "等待承接",
}
trade = {
"trade_date": "2026-07-30",
"code": "000001",
"name": "平安银行",
"action": "buy",
"price": 10.0,
"quantity": 100,
"position_pct": 20.0,
"pnl_amount": None,
"pnl_pct": None,
"emotion": "calm",
"tags": ["计划内"],
"thesis": "承接",
"execution": "符合",
"id": None,
}
with database.transaction() as connection:
repository.save_watch(
connection,
1,
{
"identifier": "000001.SZ",
"name": "平安银行",
"sector": "银行",
},
"now",
)
repository.save_watch(
connection,
2,
{
"identifier": "600000.SH",
"name": "浦发银行",
"sector": "银行",
},
"now",
)
assert repository.save_watch_remark(connection, 1, "000001.SZ", "观察承接")
repository.save_watch(
connection,
1,
{"identifier": "000001.SZ", "name": "平安银行", "sector": "银行"},
"later",
)
note_id = repository.save_note(connection, 1, note, "now")
note["summary"] = "更新后的盘面"
assert repository.save_note(connection, 1, note, "later") == note_id
trade_id = repository.save_trade(connection, 1, trade, "now")
repository.save_alert(
connection,
1,
{
"kind": "manual",
"title": "复核",
"content": "看承接",
"available_date": "2026-07-30",
"code": "000001",
"dedupe_key": "one",
},
"now",
)
repository.add_message(
connection,
1,
{
"role": "user",
"content": "今天如何",
"context_date": "2026-07-30",
"request_id": None,
"status": "complete",
},
"now",
)
with database.read() as connection:
assert [row["identifier"] for row in repository.watchlist(connection, 1)] == ["000001.SZ"]
assert repository.watchlist(connection, 1)[0]["remark"] == "观察承接"
assert [row["identifier"] for row in repository.watchlist(connection, 2)] == ["600000.SH"]
assert repository.note(connection, 1, "", "2026-07-30")["summary"] == "更新后的盘面"
assert repository.note(connection, 2, "", "2026-07-30") is None
assert [row["id"] for row in repository.trades(connection, 1)] == [trade_id]
assert repository.trades(connection, 2) == ()
assert len(repository.alerts(connection, 1, False)) == 1
assert repository.alerts(connection, 2, False) == ()
assert len(repository.messages(connection, 1)) == 1
assert repository.messages(connection, 2) == ()
def test_trade_summary_uses_only_realized_fields_and_keeps_zero_in_denominator() -> None:
rows = [
{"pnl_amount": None, "pnl_pct": None, "position_pct": None},
{"pnl_amount": 100.0, "pnl_pct": None, "position_pct": 20.0},
{"pnl_amount": None, "pnl_pct": 0.0, "position_pct": 40.0},
{"pnl_amount": -20.0, "pnl_pct": -2.0, "position_pct": 60.0},
]
summary = trade_summary(rows)
assert summary == {
"total": 4,
"realized": 3,
"win_rate": 33.3,
"pnl_amount": 80.0,
"average_position": 40.0,
}
def test_review_prompt_separates_facts_records_inference_and_plan() -> None:
prompt = messages({"market_facts": {"temperature": 42}}, [], "明天怎么看")
system = prompt[0]["content"]
assert all(label in system for label in ("市场事实", "用户记录", "推断", "条件化计划"))
assert "不执行交易" in system