feat: add teacher help cards
This commit is contained in:
@@ -9,7 +9,7 @@ import MessageList, { type DisplayMessage } from "./components/MessageList.vue";
|
||||
import SessionDrawer from "./components/SessionDrawer.vue";
|
||||
import SessionQuota from "./components/SessionQuota.vue";
|
||||
import { ApiError, api, clearToken, getToken, streamChat } from "./services/api";
|
||||
import type { ChatMessage as ApiMessage, ChatSession, GrowthProfileResult, UserProfile } from "./types/api";
|
||||
import type { ChatMessage as ApiMessage, ChatSession, GrowthProfileResult, TeacherHelpCard, UserProfile } from "./types/api";
|
||||
|
||||
const user = ref<UserProfile | null>(null);
|
||||
const sessions = ref<ChatSession[]>([]);
|
||||
@@ -23,9 +23,13 @@ const loadingSessions = ref(false);
|
||||
const sessionOperationPending = ref(false);
|
||||
const logoutDialogOpen = ref(false);
|
||||
const profileDialogOpen = ref(false);
|
||||
const helpCardDialogOpen = ref(false);
|
||||
const growthProfile = ref<GrowthProfileResult | null>(null);
|
||||
const helpCard = ref<TeacherHelpCard | null>(null);
|
||||
const helpCardContent = ref("");
|
||||
const profileLoading = ref(false);
|
||||
const finishingTopic = ref(false);
|
||||
const generatingHelpCard = ref(false);
|
||||
const statusText = ref("连接后端中");
|
||||
const toastText = ref("");
|
||||
const followingOutput = ref(true);
|
||||
@@ -214,6 +218,39 @@ async function finishCurrentTopic() {
|
||||
}
|
||||
}
|
||||
|
||||
async function generateHelpCard() {
|
||||
if (!activeSessionId.value || sending.value || generatingHelpCard.value) return;
|
||||
generatingHelpCard.value = true;
|
||||
try {
|
||||
const result = await api.generateHelpCard(activeSessionId.value);
|
||||
helpCard.value = result;
|
||||
helpCardContent.value = result.content;
|
||||
helpCardDialogOpen.value = true;
|
||||
showToast("求助卡已生成,可编辑后复制给老师");
|
||||
await refreshProfile();
|
||||
} catch (error) {
|
||||
handleError(error, "求助卡生成失败");
|
||||
} finally {
|
||||
generatingHelpCard.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyHelpCard() {
|
||||
if (!helpCardContent.value.trim()) {
|
||||
showToast("求助卡内容为空");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await copyText(helpCardContent.value);
|
||||
if (helpCard.value) {
|
||||
helpCard.value = await api.markHelpCardCopied(helpCard.value.id);
|
||||
}
|
||||
showToast("已复制,可粘贴给老师或班级群");
|
||||
} catch (error) {
|
||||
handleError(error, "复制失败,请手动选择文本复制");
|
||||
}
|
||||
}
|
||||
|
||||
async function openGrowthProfile() {
|
||||
profileDialogOpen.value = true;
|
||||
profileLoading.value = true;
|
||||
@@ -326,6 +363,22 @@ function showToast(message: string) {
|
||||
toastTimer = null;
|
||||
}, 3200);
|
||||
}
|
||||
|
||||
async function copyText(text: string) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "true");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.left = "-9999px";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -340,8 +393,10 @@ function showToast(message: string) {
|
||||
:limit="user.dailyLimit"
|
||||
:entitlement="user.entitlement"
|
||||
:finishing="finishingTopic"
|
||||
:generating-help-card="generatingHelpCard"
|
||||
@finish-topic="finishCurrentTopic"
|
||||
@open-profile="openGrowthProfile"
|
||||
@generate-help-card="generateHelpCard"
|
||||
/>
|
||||
<MessageList
|
||||
ref="messageList"
|
||||
@@ -391,6 +446,19 @@ function showToast(message: string) {
|
||||
</section>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-if="helpCardDialogOpen" title="老师求助卡" labelled-by="help-card-title" @close="helpCardDialogOpen = false">
|
||||
<section class="help-card-dialog">
|
||||
<p>这不是转人工工单,系统不会自动发送给老师。你可以按真实情况删改后,自行复制给老师或班级群确认。</p>
|
||||
<textarea v-model="helpCardContent" aria-label="求助卡内容" />
|
||||
</section>
|
||||
<template #footer>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" class="dialog-secondary" @click="helpCardDialogOpen = false">关闭</button>
|
||||
<button type="button" class="dialog-primary" @click="copyHelpCard">复制求助卡</button>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-if="logoutDialogOpen" title="退出登录" labelled-by="logout-dialog-title" @close="logoutDialogOpen = false">
|
||||
<p class="confirm-copy">确定退出当前账号吗?</p>
|
||||
<template #footer>
|
||||
|
||||
@@ -9,11 +9,13 @@ const props = defineProps<{
|
||||
limit: number;
|
||||
entitlement?: UserEntitlementSummary | null;
|
||||
finishing?: boolean;
|
||||
generatingHelpCard?: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
finishTopic: [];
|
||||
openProfile: [];
|
||||
generateHelpCard: [];
|
||||
}>();
|
||||
|
||||
const hasEntitlement = computed(() => Boolean(props.entitlement));
|
||||
@@ -43,6 +45,15 @@ const limitText = computed(() => displayLimit.value === null ? "不限" : String
|
||||
<button type="button" class="quota-link" :disabled="finishing" @click="$emit('finishTopic')">
|
||||
{{ finishing ? '沉淀中' : '沉淀本主题' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="entitlement?.allowHelpCard"
|
||||
type="button"
|
||||
class="quota-link"
|
||||
:disabled="generatingHelpCard"
|
||||
@click="$emit('generateHelpCard')"
|
||||
>
|
||||
{{ generatingHelpCard ? '生成中' : '求助卡' }}
|
||||
</button>
|
||||
<strong>{{ displayUsed }}/{{ limitText }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
FinishTopicResult,
|
||||
GrowthProfileResult,
|
||||
LoginResult,
|
||||
TeacherHelpCard,
|
||||
UserProfile,
|
||||
} from "../types/api";
|
||||
|
||||
@@ -86,6 +87,9 @@ export const api = {
|
||||
request<ChatSession>("/chat/session/title", { method: "PUT", body: JSON.stringify({ sessionId, title }) }),
|
||||
deleteSession: (sessionId: number) => request<null>(`/chat/session/${sessionId}`, { method: "DELETE" }),
|
||||
finishTopic: (sessionId: number) => request<FinishTopicResult>(`/chat/session/${sessionId}/topic/finish`, { method: "POST", body: JSON.stringify({}) }),
|
||||
generateHelpCard: (sessionId: number) => request<TeacherHelpCard>(`/chat/session/${sessionId}/help-card`, { method: "POST", body: JSON.stringify({}) }),
|
||||
markHelpCardCopied: (cardId: number) => request<TeacherHelpCard>(`/chat/help-card/${cardId}/copied`, { method: "POST", body: JSON.stringify({}) }),
|
||||
helpCards: (limit = 20) => request<TeacherHelpCard[]>(`/chat/help-card/list?limit=${limit}`),
|
||||
growthProfile: () => request<GrowthProfileResult>("/user/growth-profile"),
|
||||
stop: (sessionId: number) => request<null>("/chat/stop", { method: "POST", body: JSON.stringify({ sessionId }) }),
|
||||
};
|
||||
|
||||
@@ -1717,6 +1717,39 @@ textarea:focus-visible {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.help-card-dialog {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.help-card-dialog p {
|
||||
margin: 0;
|
||||
color: var(--chat-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.help-card-dialog textarea {
|
||||
width: 100%;
|
||||
min-height: min(48vh, 420px);
|
||||
resize: vertical;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--chat-border);
|
||||
border-radius: 14px;
|
||||
outline: 0;
|
||||
background: #ffffff;
|
||||
color: var(--chat-text);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.help-card-dialog textarea:focus {
|
||||
border-color: var(--chat-brand);
|
||||
box-shadow: 0 0 0 3px rgba(20, 148, 119, 0.1);
|
||||
}
|
||||
|
||||
.growth-profile-fields,
|
||||
.recent-topic-summaries {
|
||||
display: grid;
|
||||
|
||||
@@ -67,6 +67,18 @@ export interface FinishTopicResult {
|
||||
growthProfileEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface TeacherHelpCard {
|
||||
id: number;
|
||||
userId: number;
|
||||
topicSessionId: number;
|
||||
summaryId?: number | null;
|
||||
content: string;
|
||||
source: string;
|
||||
copied: boolean;
|
||||
copiedAt?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
token: string;
|
||||
expiredAt: string;
|
||||
|
||||
Reference in New Issue
Block a user