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
+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;
}