rebuild(stage-12): deliver private review workflows
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user