rebuild(stage-10): deliver mentor and unified llm streaming
This commit is contained in:
@@ -5,6 +5,7 @@ import { useRoute } from "vue-router";
|
||||
import EmptyState from "../../shared/components/EmptyState.vue";
|
||||
import MarketWorkspaceView from "../../pages/market/MarketWorkspaceView.vue";
|
||||
import ScreenerPage from "../../pages/screener/ScreenerPage.vue";
|
||||
import MentorPage from "../../pages/mentor/MentorPage.vue";
|
||||
import { useMarketStore } from "../../shared/stores/market";
|
||||
import { useSessionStore } from "../../shared/stores/session";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
@@ -31,6 +32,7 @@ const implementedMarket = computed(() =>
|
||||
<template>
|
||||
<MarketWorkspaceView v-if="implementedMarket" :workspace-key="workspace.key" />
|
||||
<ScreenerPage v-else-if="workspace.key === 'screener'" />
|
||||
<MentorPage v-else-if="workspace.key === 'mentor'" />
|
||||
<main v-else class="page-frame">
|
||||
<header class="page-header">
|
||||
<h1>{{ workspace.title }}</h1>
|
||||
|
||||
@@ -14,6 +14,7 @@ import "./shared/styles/market.css";
|
||||
import "./shared/styles/market-workspace.css";
|
||||
import "./shared/styles/market-insights.css";
|
||||
import "./shared/styles/screener.css";
|
||||
import "./shared/styles/mentor.css";
|
||||
import "./shared/styles/system.css";
|
||||
import "./shared/styles/mobile.css";
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{ content: string }>();
|
||||
const blocks = computed(() => props.content.split(/\n{2,}/).map((text) => text.trim()).filter(Boolean));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mentor-answer">
|
||||
<p v-for="(block, index) in blocks" :key="index">{{ block }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
import type { Mentor } from "../../shared/api/mentor";
|
||||
|
||||
const props = defineProps<{
|
||||
mentors: Mentor[];
|
||||
selectedId: string;
|
||||
disabled: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
select: [mentor: Mentor];
|
||||
pin: [mentor: Mentor];
|
||||
move: [mentor: Mentor, delta: number];
|
||||
reorder: [sourceId: string, targetId: string];
|
||||
}>();
|
||||
const grade = defineModel<"all" | "A" | "B" | "C">("grade", { required: true });
|
||||
const query = defineModel<string>("query", { required: true });
|
||||
const filtered = computed(() => {
|
||||
const needle = query.value.trim().toLocaleLowerCase();
|
||||
return props.mentors.filter((mentor) => {
|
||||
if (grade.value !== "all" && mentor.grade !== grade.value) return false;
|
||||
const haystack = [mentor.name, mentor.description, mentor.tagline, ...mentor.focus].join(" ").toLocaleLowerCase();
|
||||
return !needle || haystack.includes(needle);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="card mentor-library" :aria-disabled="disabled">
|
||||
<header class="mentor-library-header">
|
||||
<div><h2>思维模型</h2><span class="muted">{{ filtered.length }} 位</span></div>
|
||||
<input v-model="query" class="input mentor-search" type="search" placeholder="搜索姓名、流派或标签" aria-label="搜索思维模型" />
|
||||
<div class="seg-control mentor-grade-filter">
|
||||
<button v-for="item in ['all', 'A', 'B', 'C']" :key="item" type="button" :class="{ active: grade === item }" @click="grade = item as typeof grade">{{ item === 'all' ? '全部' : `${item}级` }}</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="mentor-list">
|
||||
<article
|
||||
v-for="mentor in filtered"
|
||||
:key="mentor.id"
|
||||
class="mentor-item"
|
||||
:class="{ active: selectedId === mentor.id }"
|
||||
draggable="true"
|
||||
@click="emit('select', mentor)"
|
||||
@dragstart="($event.dataTransfer as DataTransfer).setData('text/plain', mentor.id)"
|
||||
@dragover.prevent
|
||||
@drop="emit('reorder', ($event.dataTransfer as DataTransfer).getData('text/plain'), mentor.id)"
|
||||
>
|
||||
<div class="mentor-item-main">
|
||||
<strong>{{ mentor.name }}</strong><span class="tag" :class="`mentor-grade-${mentor.grade.toLowerCase()}`">{{ mentor.grade }}</span>
|
||||
<span v-if="mentor.private" class="tag tag-admin">私有</span>
|
||||
</div>
|
||||
<p>{{ mentor.description || mentor.tagline }}</p>
|
||||
<div class="mentor-item-actions">
|
||||
<button class="icon-button" type="button" :title="mentor.pinned ? '取消置顶' : '置顶'" :aria-label="mentor.pinned ? '取消置顶' : '置顶'" @click.stop="emit('pin', mentor)">{{ mentor.pinned ? '★' : '☆' }}</button>
|
||||
<button class="icon-button" type="button" title="上移" aria-label="上移" @click.stop="emit('move', mentor, -1)">↑</button>
|
||||
<button class="icon-button" type="button" title="下移" aria-label="下移" @click.stop="emit('move', mentor, 1)">↓</button>
|
||||
</div>
|
||||
</article>
|
||||
<p v-if="!filtered.length" class="mentor-list-empty">没有匹配的思维模型</p>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,230 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
|
||||
import { mentorApi, type Mentor, type MentorMessage, type MentorStreamEvent } from "../../shared/api/mentor";
|
||||
import EmptyState from "../../shared/components/EmptyState.vue";
|
||||
import { useMarketStore } from "../../shared/stores/market";
|
||||
import { useSessionStore } from "../../shared/stores/session";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
import MentorAnswer from "./MentorAnswer.vue";
|
||||
import MentorLibrary from "./MentorLibrary.vue";
|
||||
|
||||
const session = useSessionStore();
|
||||
const market = useMarketStore();
|
||||
const ui = useUiStore();
|
||||
const mentors = ref<Mentor[]>([]);
|
||||
const tradeDate = ref("");
|
||||
const selectedId = ref("");
|
||||
const messages = ref<MentorMessage[]>([]);
|
||||
const grade = ref<"all" | "A" | "B" | "C">("all");
|
||||
const query = ref("");
|
||||
const question = ref("");
|
||||
const lastQuestion = ref("");
|
||||
const loading = ref(false);
|
||||
const historyLoading = ref(false);
|
||||
const generating = ref(false);
|
||||
const streamError = ref("");
|
||||
const mobileLibrary = ref(false);
|
||||
let controller: AbortController | null = null;
|
||||
|
||||
const locked = computed(() => !session.account?.smart_access);
|
||||
const selected = computed(() => mentors.value.find((item) => item.id === selectedId.value));
|
||||
const quickPrompts = computed(() => [
|
||||
"今天市场情绪处于什么位置?",
|
||||
"当前最值得关注的风险是什么?",
|
||||
"请按你的方法复盘今天的核心机会。",
|
||||
"如果明天出现分歧,应观察哪些确认信号?",
|
||||
]);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
streamError.value = "";
|
||||
try {
|
||||
const setup = await mentorApi.setup(market.selectedDate);
|
||||
mentors.value = setup.mentors;
|
||||
tradeDate.value = setup.trade_date;
|
||||
if (!mentors.value.some((item) => item.id === selectedId.value)) {
|
||||
selectedId.value = mentors.value[0]?.id ?? "";
|
||||
}
|
||||
await loadMessages();
|
||||
} catch (error) {
|
||||
streamError.value = error instanceof Error ? error.message : "问师数据读取失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMessages(): Promise<void> {
|
||||
messages.value = [];
|
||||
if (locked.value || !selectedId.value || !tradeDate.value) return;
|
||||
historyLoading.value = true;
|
||||
try {
|
||||
messages.value = await mentorApi.messages(selectedId.value, tradeDate.value);
|
||||
} catch (error) {
|
||||
ui.showToast(error instanceof Error ? error.message : "历史记录读取失败");
|
||||
} finally {
|
||||
historyLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectMentor(mentor: Mentor): Promise<void> {
|
||||
if (generating.value) stop();
|
||||
selectedId.value = mentor.id;
|
||||
mobileLibrary.value = false;
|
||||
streamError.value = "";
|
||||
await loadMessages();
|
||||
}
|
||||
|
||||
async function savePreferences(): Promise<void> {
|
||||
try {
|
||||
await mentorApi.preferences(
|
||||
mentors.value.map((item) => item.id),
|
||||
mentors.value.filter((item) => item.pinned).map((item) => item.id),
|
||||
);
|
||||
} catch (error) {
|
||||
ui.showToast(error instanceof Error ? error.message : "排序保存失败");
|
||||
await load();
|
||||
}
|
||||
}
|
||||
|
||||
async function pin(mentor: Mentor): Promise<void> {
|
||||
mentor.pinned = !mentor.pinned;
|
||||
mentors.value.sort((left, right) => Number(right.pinned) - Number(left.pinned));
|
||||
await savePreferences();
|
||||
}
|
||||
|
||||
async function move(mentor: Mentor, delta: number): Promise<void> {
|
||||
const current = mentors.value.findIndex((item) => item.id === mentor.id);
|
||||
const target = current + delta;
|
||||
if (current < 0 || target < 0 || target >= mentors.value.length) return;
|
||||
const [item] = mentors.value.splice(current, 1);
|
||||
if (!item) return;
|
||||
mentors.value.splice(target, 0, item);
|
||||
mentors.value = [...mentors.value];
|
||||
await savePreferences();
|
||||
}
|
||||
|
||||
async function reorder(sourceId: string, targetId: string): Promise<void> {
|
||||
if (!sourceId || sourceId === targetId) return;
|
||||
const source = mentors.value.findIndex((item) => item.id === sourceId);
|
||||
const target = mentors.value.findIndex((item) => item.id === targetId);
|
||||
if (source < 0 || target < 0) return;
|
||||
const [item] = mentors.value.splice(source, 1);
|
||||
if (!item) return;
|
||||
mentors.value.splice(target, 0, item);
|
||||
mentors.value.sort((left, right) => Number(right.pinned) - Number(left.pinned));
|
||||
mentors.value = [...mentors.value];
|
||||
await savePreferences();
|
||||
}
|
||||
|
||||
async function send(value = question.value): Promise<void> {
|
||||
const normalized = value.trim();
|
||||
if (!normalized || !selected.value || generating.value || locked.value) return;
|
||||
question.value = "";
|
||||
lastQuestion.value = normalized;
|
||||
streamError.value = "";
|
||||
messages.value.push({ role: "user", content: normalized, status: "complete" });
|
||||
const answer: MentorMessage = { role: "assistant", content: "", status: "complete" };
|
||||
messages.value.push(answer);
|
||||
generating.value = true;
|
||||
controller = new AbortController();
|
||||
try {
|
||||
await mentorApi.chat(
|
||||
selected.value.id,
|
||||
tradeDate.value,
|
||||
normalized,
|
||||
(event: MentorStreamEvent) => {
|
||||
if (event.type === "delta") answer.content += event.content ?? "";
|
||||
if (event.type === "error") {
|
||||
answer.status = "error";
|
||||
streamError.value = event.message ?? "回答生成失败";
|
||||
}
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
answer.status = "stopped";
|
||||
} else {
|
||||
answer.status = "error";
|
||||
streamError.value = error instanceof Error ? error.message : "回答生成失败";
|
||||
}
|
||||
} finally {
|
||||
generating.value = false;
|
||||
controller = null;
|
||||
if (!answer.content && answer.status !== "complete") messages.value.pop();
|
||||
}
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
controller?.abort();
|
||||
}
|
||||
|
||||
async function clearHistory(): Promise<void> {
|
||||
if (!selected.value || !messages.value.length) return;
|
||||
const confirmed = await ui.askConfirmation({
|
||||
title: "清空当前对话",
|
||||
message: `仅清空 ${selected.value.name} 在 ${tradeDate.value} 的对话记录,其他模型和日期不受影响。`,
|
||||
confirmLabel: "清空",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
await mentorApi.clear(selected.value.id, tradeDate.value);
|
||||
messages.value = [];
|
||||
ui.showToast("当前对话已清空");
|
||||
}
|
||||
|
||||
function keydown(event: KeyboardEvent): void {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
onBeforeUnmount(stop);
|
||||
watch(() => market.selectedDate, load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="page-frame mentor-page">
|
||||
<header class="page-header mentor-page-header">
|
||||
<div><h1>问师</h1><p class="page-subtitle">公开资料蒸馏的思维模型 · 数据日期 {{ tradeDate || market.selectedDate }}</p></div>
|
||||
<button class="btn mentor-mobile-directory" type="button" @click="mobileLibrary = !mobileLibrary">模型库</button>
|
||||
</header>
|
||||
<div v-if="locked" class="notice notice-warning membership-lock">
|
||||
<span><strong>问师仅对会员开放</strong>,开通会员后可使用思维模型复盘对话。</span>
|
||||
<button class="btn btn-small" type="button" @click="ui.openDialog('membership')">查看会员状态</button>
|
||||
</div>
|
||||
<div v-if="loading" class="card workspace-state">正在读取思维模型</div>
|
||||
<EmptyState v-else-if="streamError && !mentors.length" class="card" title="问师暂不可用" :description="streamError" />
|
||||
<section v-else class="mentor-workspace" :class="{ 'locked-content': locked, 'show-library': mobileLibrary }">
|
||||
<MentorLibrary v-model:grade="grade" v-model:query="query" :mentors="mentors" :selected-id="selectedId" :disabled="locked" @select="selectMentor" @pin="pin" @move="move" @reorder="reorder" />
|
||||
<article class="card mentor-chat">
|
||||
<header v-if="selected" class="mentor-chat-header">
|
||||
<div class="mentor-identity"><strong>{{ selected.name }}</strong><span class="tag" :class="`mentor-grade-${selected.grade.toLowerCase()}`">{{ selected.grade }}</span><span>{{ selected.description }}</span></div>
|
||||
<button class="btn btn-small" type="button" :disabled="!messages.length || generating" @click="clearHistory">清空</button>
|
||||
</header>
|
||||
<div class="mentor-messages" aria-live="polite">
|
||||
<p v-if="historyLoading" class="mentor-chat-empty">正在读取当前对话</p>
|
||||
<div v-else-if="!messages.length" class="mentor-welcome">
|
||||
<strong>{{ selected?.tagline || '从问题出发,按模型的方法拆解市场。' }}</strong>
|
||||
<p>回答基于公开资料蒸馏,不是真人本人,不构成投资建议。</p>
|
||||
<div class="mentor-prompts"><button v-for="prompt in quickPrompts" :key="prompt" class="btn" type="button" @click="send(prompt)">{{ prompt }}</button></div>
|
||||
</div>
|
||||
<div v-for="(message, index) in messages" :key="message.id ?? index" class="mentor-message" :class="`mentor-message-${message.role}`">
|
||||
<span class="mentor-role">{{ message.role === 'user' ? '我' : selected?.name }}</span>
|
||||
<MentorAnswer v-if="message.role === 'assistant'" :content="message.content || (generating && index === messages.length - 1 ? '正在思考…' : '')" />
|
||||
<p v-else>{{ message.content }}</p>
|
||||
<small v-if="message.status === 'stopped'">已停止生成</small>
|
||||
<small v-else-if="message.status === 'error'">回答未完整生成</small>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="mentor-composer">
|
||||
<div v-if="streamError" class="mentor-stream-error"><span>{{ streamError }}</span><button v-if="lastQuestion && !generating" class="btn btn-small" type="button" @click="send(lastQuestion)">重试</button></div>
|
||||
<textarea v-model="question" class="textarea" rows="2" maxlength="2000" placeholder="输入你的复盘问题" :disabled="locked || generating || !selected" @keydown="keydown" />
|
||||
<div class="mentor-composer-actions"><span class="muted">Enter 发送 · Shift+Enter 换行</span><button v-if="generating" class="btn" type="button" @click="stop">停止</button><button v-else class="btn btn-primary" type="button" :disabled="!question.trim() || locked" @click="send()">发送</button></div>
|
||||
</footer>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -64,4 +64,28 @@ describe("api client", () => {
|
||||
expect(headers.get("X-CSRF-Token")).toBe("csrf-token-123");
|
||||
expect(headers.get("Content-Type")).toBe("application/json");
|
||||
});
|
||||
|
||||
it("parses split NDJSON events through the single streaming client", async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode('{"type":"delta","content":"回'));
|
||||
controller.enqueue(encoder.encode('答"}\n{"type":"done"}\n'));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(body, { status: 200 })));
|
||||
const events: Array<{ type: string; content?: string }> = [];
|
||||
|
||||
await api.stream<{ type: string; content?: string }>(
|
||||
"/mentors/chat",
|
||||
{},
|
||||
(event) => events.push(event),
|
||||
);
|
||||
|
||||
expect(events).toEqual([
|
||||
{ type: "delta", content: "回答" },
|
||||
{ type: "done" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,47 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function streamNdjson<T>(
|
||||
path: string,
|
||||
body: unknown,
|
||||
onEvent: (event: T) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const csrfToken = readCookie("xiaobai_csrf");
|
||||
const headers = new Headers({ Accept: "application/x-ndjson", "Content-Type": "application/json" });
|
||||
if (csrfToken) headers.set("X-CSRF-Token", csrfToken);
|
||||
const response = await fetch(`/api${path}`, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => ({}))) as ApiErrorPayload;
|
||||
throw new ApiError(
|
||||
payload.error?.message ?? "请求失败,请稍后重试。",
|
||||
response.status,
|
||||
payload.error?.code ?? "request_failed",
|
||||
payload.error?.request_id,
|
||||
);
|
||||
}
|
||||
if (!response.body) throw new ApiError("响应内容不可用。", 503, "stream_unavailable");
|
||||
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
|
||||
let pending = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
pending += value ?? "";
|
||||
const lines = pending.split("\n");
|
||||
pending = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
if (line.trim()) onEvent(JSON.parse(line) as T);
|
||||
}
|
||||
if (done) break;
|
||||
}
|
||||
if (pending.trim()) onEvent(JSON.parse(pending) as T);
|
||||
}
|
||||
|
||||
function readCookie(name: string): string | undefined {
|
||||
if (typeof document === "undefined") return undefined;
|
||||
const prefix = `${encodeURIComponent(name)}=`;
|
||||
@@ -71,4 +112,7 @@ export const api = {
|
||||
delete<T>(path: string): Promise<T> {
|
||||
return request<T>(path, json("DELETE"));
|
||||
},
|
||||
stream<T>(path: string, body: unknown, onEvent: (event: T) => void, signal?: AbortSignal): Promise<void> {
|
||||
return streamNdjson(path, body, onEvent, signal);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { api } from "./client";
|
||||
|
||||
export type Mentor = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
tagline: string;
|
||||
focus: string[];
|
||||
grade: "A" | "B" | "C";
|
||||
evidence_label: string;
|
||||
evidence_note: string;
|
||||
private: boolean;
|
||||
pinned: boolean;
|
||||
sort_order: number;
|
||||
};
|
||||
export type MentorSetup = { trade_date: string; mentors: Mentor[] };
|
||||
export type MentorMessage = {
|
||||
id?: number;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
status: "complete" | "stopped" | "error";
|
||||
created_at?: string;
|
||||
};
|
||||
export type MentorStreamEvent = {
|
||||
type: "delta" | "done" | "error";
|
||||
content?: string;
|
||||
code?: string;
|
||||
message?: string;
|
||||
partial?: boolean;
|
||||
request_id: string;
|
||||
};
|
||||
|
||||
export const mentorApi = {
|
||||
setup(date: string): Promise<MentorSetup> {
|
||||
return api.get(`/mentors/setup?date=${encodeURIComponent(date)}`);
|
||||
},
|
||||
preferences(order: string[], pinned: string[]): Promise<{ order: string[]; pinned: string[] }> {
|
||||
return api.put("/mentors/preferences", { order, pinned });
|
||||
},
|
||||
messages(mentorId: string, date: string): Promise<MentorMessage[]> {
|
||||
return api.get(`/mentors/messages?mentor_id=${encodeURIComponent(mentorId)}&date=${encodeURIComponent(date)}`);
|
||||
},
|
||||
clear(mentorId: string, date: string): Promise<{ deleted: number }> {
|
||||
return api.delete(`/mentors/messages?mentor_id=${encodeURIComponent(mentorId)}&date=${encodeURIComponent(date)}`);
|
||||
},
|
||||
chat(
|
||||
mentorId: string,
|
||||
tradeDate: string,
|
||||
question: string,
|
||||
onEvent: (event: MentorStreamEvent) => void,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
return api.stream("/mentors/chat", { mentor_id: mentorId, trade_date: tradeDate, question }, onEvent, signal);
|
||||
},
|
||||
};
|
||||
@@ -17,4 +17,13 @@ const titles = { profile: "个人资料", membership: "会员状态", password:
|
||||
<PasswordPanel v-else-if="ui.dialog === 'password'" />
|
||||
<SearchPanel v-else />
|
||||
</BaseDialog>
|
||||
<BaseDialog v-else-if="ui.confirmation" :title="ui.confirmation.title" @close="ui.resolveConfirmation(false)">
|
||||
<div class="form-grid">
|
||||
<p class="muted">{{ ui.confirmation.message }}</p>
|
||||
<div class="form-actions">
|
||||
<button class="btn" type="button" @click="ui.resolveConfirmation(false)">取消</button>
|
||||
<button class="btn btn-primary" type="button" @click="ui.resolveConfirmation(true)">{{ ui.confirmation.confirmLabel }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref } from "vue";
|
||||
|
||||
export type DialogName = "profile" | "membership" | "password" | "search" | null;
|
||||
export type Theme = "light" | "dark";
|
||||
export type Confirmation = { title: string; message: string; confirmLabel: string };
|
||||
|
||||
const THEME_KEY = "xiaobai-theme";
|
||||
|
||||
@@ -20,8 +21,10 @@ export function applyTheme(theme: Theme): void {
|
||||
export const useUiStore = defineStore("ui", () => {
|
||||
const theme = ref<Theme>(preferredTheme());
|
||||
const dialog = ref<DialogName>(null);
|
||||
const confirmation = ref<Confirmation | null>(null);
|
||||
const toast = ref("");
|
||||
let toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let confirmationResolve: ((value: boolean) => void) | undefined;
|
||||
|
||||
function setTheme(next: Theme): void {
|
||||
theme.value = next;
|
||||
@@ -34,6 +37,7 @@ export const useUiStore = defineStore("ui", () => {
|
||||
}
|
||||
|
||||
function openDialog(name: Exclude<DialogName, null>): void {
|
||||
resolveConfirmation(false);
|
||||
dialog.value = name;
|
||||
}
|
||||
|
||||
@@ -41,6 +45,21 @@ export const useUiStore = defineStore("ui", () => {
|
||||
dialog.value = null;
|
||||
}
|
||||
|
||||
function askConfirmation(value: Confirmation): Promise<boolean> {
|
||||
resolveConfirmation(false);
|
||||
dialog.value = null;
|
||||
confirmation.value = value;
|
||||
return new Promise((resolve) => {
|
||||
confirmationResolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveConfirmation(value: boolean): void {
|
||||
confirmation.value = null;
|
||||
confirmationResolve?.(value);
|
||||
confirmationResolve = undefined;
|
||||
}
|
||||
|
||||
function showToast(message: string): void {
|
||||
toast.value = message;
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
@@ -49,5 +68,8 @@ export const useUiStore = defineStore("ui", () => {
|
||||
}, 2200);
|
||||
}
|
||||
|
||||
return { theme, dialog, toast, setTheme, toggleTheme, openDialog, closeDialog, showToast };
|
||||
return {
|
||||
theme, dialog, confirmation, toast, setTheme, toggleTheme, openDialog, closeDialog,
|
||||
askConfirmation, resolveConfirmation, showToast,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
.mentor-page {
|
||||
height: calc(100vh - var(--shell-topbar-height) - var(--shell-summary-height) - var(--shell-status-height));
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mentor-page-header {
|
||||
min-height: var(--s-44);
|
||||
}
|
||||
|
||||
.mentor-mobile-directory {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mentor-page > .membership-lock {
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.mentor-page > .workspace-state,
|
||||
.mentor-page > .empty-state,
|
||||
.mentor-workspace {
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
.mentor-workspace {
|
||||
min-height: var(--s-400);
|
||||
display: grid;
|
||||
grid-template-columns: var(--s-320) minmax(0, 1fr);
|
||||
gap: var(--layout-gap);
|
||||
}
|
||||
|
||||
.mentor-library,
|
||||
.mentor-chat {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mentor-library {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.mentor-library-header {
|
||||
display: grid;
|
||||
gap: var(--s-8);
|
||||
padding: var(--s-12);
|
||||
border-bottom: var(--s-1) solid var(--color-divider);
|
||||
}
|
||||
|
||||
.mentor-library-header > div:first-child,
|
||||
.mentor-chat-header,
|
||||
.mentor-identity,
|
||||
.mentor-item-main,
|
||||
.mentor-item-actions,
|
||||
.mentor-composer-actions,
|
||||
.mentor-stream-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-8);
|
||||
}
|
||||
|
||||
.mentor-library-header h2,
|
||||
.mentor-chat-header strong {
|
||||
font-size: var(--font-14);
|
||||
}
|
||||
|
||||
.mentor-library-header > div:first-child .muted {
|
||||
margin-left: auto;
|
||||
font-size: var(--font-11);
|
||||
}
|
||||
|
||||
.mentor-search {
|
||||
min-height: var(--s-32);
|
||||
}
|
||||
|
||||
.mentor-grade-filter {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mentor-grade-filter button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mentor-list,
|
||||
.mentor-messages {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mentor-list {
|
||||
padding: var(--s-6);
|
||||
}
|
||||
|
||||
.mentor-item {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: var(--s-4);
|
||||
padding: var(--s-10);
|
||||
border: var(--s-1) solid var(--c-transparent);
|
||||
border-radius: var(--control-radius);
|
||||
cursor: pointer;
|
||||
transition: color var(--duration-fast) var(--ease-standard),
|
||||
background var(--duration-fast) var(--ease-standard),
|
||||
border-color var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.mentor-item:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.mentor-item.active {
|
||||
border-color: var(--color-primary-border);
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.mentor-item-main strong {
|
||||
font-size: var(--font-13);
|
||||
}
|
||||
|
||||
.mentor-item p {
|
||||
overflow: hidden;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-11-5);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mentor-item-actions {
|
||||
position: absolute;
|
||||
top: var(--s-6);
|
||||
right: var(--s-6);
|
||||
opacity: 0;
|
||||
transition: opacity var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.mentor-item:hover .mentor-item-actions,
|
||||
.mentor-item:focus-within .mentor-item-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mentor-item-actions .icon-button {
|
||||
width: var(--s-26);
|
||||
height: var(--s-26);
|
||||
min-height: var(--s-26);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.mentor-grade-a {
|
||||
color: var(--color-up);
|
||||
border-color: var(--color-up);
|
||||
background: var(--color-up-soft);
|
||||
}
|
||||
|
||||
.mentor-grade-b {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary-border);
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.mentor-grade-c {
|
||||
color: var(--color-warning);
|
||||
border-color: var(--color-warning);
|
||||
background: var(--color-warning-soft);
|
||||
}
|
||||
|
||||
.mentor-list-empty,
|
||||
.mentor-chat-empty {
|
||||
padding: var(--s-24);
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mentor-chat {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.mentor-chat-header {
|
||||
min-height: var(--s-44);
|
||||
padding: var(--s-8) var(--s-14);
|
||||
border-bottom: var(--s-1) solid var(--color-divider);
|
||||
}
|
||||
|
||||
.mentor-identity {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mentor-identity > span:last-child {
|
||||
overflow: hidden;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-12);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mentor-messages {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: var(--s-12);
|
||||
padding: var(--s-16);
|
||||
}
|
||||
|
||||
.mentor-welcome {
|
||||
max-width: var(--s-dialog-wide);
|
||||
display: grid;
|
||||
gap: var(--s-10);
|
||||
margin: auto;
|
||||
padding: var(--s-24) 0;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mentor-welcome strong {
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-15);
|
||||
}
|
||||
|
||||
.mentor-prompts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--s-8);
|
||||
}
|
||||
|
||||
.mentor-prompts .btn {
|
||||
justify-content: flex-start;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mentor-message {
|
||||
max-width: min(var(--s-dialog-wide), 88%);
|
||||
display: grid;
|
||||
gap: var(--s-4);
|
||||
}
|
||||
|
||||
.mentor-message-user {
|
||||
justify-self: end;
|
||||
padding: var(--s-9) var(--s-12);
|
||||
border-radius: var(--control-radius);
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.mentor-role {
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--font-11);
|
||||
}
|
||||
|
||||
.mentor-message small {
|
||||
color: var(--color-warning);
|
||||
font-size: var(--font-11);
|
||||
}
|
||||
|
||||
.mentor-answer {
|
||||
display: grid;
|
||||
gap: var(--s-8);
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-13);
|
||||
line-height: var(--s-20);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.mentor-composer {
|
||||
display: grid;
|
||||
gap: var(--s-6);
|
||||
padding: var(--s-10) var(--s-12);
|
||||
border-top: var(--s-1) solid var(--color-divider);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.mentor-composer .textarea {
|
||||
min-height: var(--s-64);
|
||||
max-height: var(--s-200);
|
||||
}
|
||||
|
||||
.mentor-composer-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.mentor-composer-actions .muted {
|
||||
margin-right: auto;
|
||||
font-size: var(--font-11);
|
||||
}
|
||||
|
||||
.mentor-stream-error {
|
||||
justify-content: space-between;
|
||||
color: var(--color-warning);
|
||||
font-size: var(--font-12);
|
||||
}
|
||||
@@ -333,4 +333,56 @@
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.mentor-page {
|
||||
height: auto;
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.mentor-page-header {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mentor-mobile-directory {
|
||||
display: inline-flex;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.mentor-workspace {
|
||||
min-height: calc(100vh - var(--shell-topbar-height) - var(--shell-summary-height) - var(--shell-mobile-nav-height) - var(--s-200));
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mentor-library {
|
||||
display: none;
|
||||
margin-bottom: var(--layout-gap);
|
||||
}
|
||||
|
||||
.mentor-workspace.show-library .mentor-library {
|
||||
max-height: var(--s-400);
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.mentor-chat {
|
||||
min-height: var(--s-400);
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.mentor-messages {
|
||||
max-height: var(--s-400);
|
||||
}
|
||||
|
||||
.mentor-prompts {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.mentor-message {
|
||||
max-width: 96%;
|
||||
}
|
||||
|
||||
.mentor-composer-actions .muted {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user