feat: add teacher help cards

This commit is contained in:
2026-07-31 15:53:47 +08:00
parent 79094d0832
commit d8fde93b69
15 changed files with 467 additions and 4 deletions

View File

@@ -1603,6 +1603,25 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
</el-collapse> </el-collapse>
<el-empty v-else description="该会话暂无主题记录" :image-size="64" /> <el-empty v-else description="该会话暂无主题记录" :image-size="64" />
<h3 class="detail-title">老师求助卡</h3>
<el-collapse v-if="chatDetail.helpCards?.length" class="topic-summary-collapse">
<el-collapse-item
v-for="card in chatDetail.helpCards"
:key="card.id"
:title="`#${card.id} / 主题 #${card.topicSessionId} / ${card.copied ? '用户已复制' : '未复制'}`"
>
<section class="topic-summary-card">
<div class="topic-summary-meta">
<span>生成{{ card.createdAt }}</span>
<span>复制{{ card.copiedAt || '-' }}</span>
<span>来源{{ card.source }}</span>
</div>
<pre>{{ card.content }}</pre>
</section>
</el-collapse-item>
</el-collapse>
<el-empty v-else description="该会话暂无用户生成的求助卡" :image-size="64" />
<h3 class="detail-title">完整对话</h3> <h3 class="detail-title">完整对话</h3>
<AdminPagination <AdminPagination
class="chat-detail-pagination top" class="chat-detail-pagination top"

View File

@@ -397,6 +397,19 @@ export interface ChatDetail {
messagesPage?: PageResult<ChatMessageRecord>; messagesPage?: PageResult<ChatMessageRecord>;
aiLogs: AiLogRecord[]; aiLogs: AiLogRecord[];
topics?: TopicSessionRecord[]; topics?: TopicSessionRecord[];
helpCards?: TeacherHelpCardRecord[];
}
export interface TeacherHelpCardRecord {
id: number;
userId: number;
topicSessionId: number;
summaryId?: number | null;
content: string;
source: string;
copied: boolean;
copiedAt?: string | null;
createdAt: string;
} }
export interface TopicSummaryRecord { export interface TopicSummaryRecord {

View File

@@ -0,0 +1,51 @@
"""add teacher help cards
Revision ID: 0016_teacher_help_cards
Revises: 0015_growth_profiles
"""
from alembic import op
import sqlalchemy as sa
revision = "0016_teacher_help_cards"
down_revision = "0015_growth_profiles"
branch_labels = None
depends_on = None
PRIMARY_KEY_TYPE = sa.BigInteger().with_variant(sa.Integer(), "sqlite")
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "sys_teacher_help_card" in tables:
return
op.create_table(
"sys_teacher_help_card",
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("sys_user.id"), nullable=False),
sa.Column("topic_session_id", sa.BigInteger(), sa.ForeignKey("sys_topic_session.id"), nullable=False),
sa.Column("summary_id", sa.BigInteger(), nullable=True),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("source", sa.String(30), nullable=False, server_default="topic_summary"),
sa.Column("copied", sa.Integer(), nullable=False, server_default="0"),
sa.Column("copied_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_sys_teacher_help_card_user_id", "sys_teacher_help_card", ["user_id"])
op.create_index("ix_sys_teacher_help_card_summary_id", "sys_teacher_help_card", ["summary_id"])
op.create_index("ix_sys_teacher_help_card_user_created", "sys_teacher_help_card", ["user_id", "created_at"])
op.create_index("ix_sys_teacher_help_card_topic_created", "sys_teacher_help_card", ["topic_session_id", "created_at"])
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "sys_teacher_help_card" not in tables:
return
op.drop_index("ix_sys_teacher_help_card_topic_created", table_name="sys_teacher_help_card")
op.drop_index("ix_sys_teacher_help_card_user_created", table_name="sys_teacher_help_card")
op.drop_index("ix_sys_teacher_help_card_summary_id", table_name="sys_teacher_help_card")
op.drop_index("ix_sys_teacher_help_card_user_id", table_name="sys_teacher_help_card")
op.drop_table("sys_teacher_help_card")

View File

@@ -16,12 +16,13 @@ from app.core.dependencies import get_current_admin
from app.core.responses import api_success from app.core.responses import api_success
from app.models.admin import Admin from app.models.admin import Admin
from app.models.chat import ChatMessage, ChatSession, TopicSession from app.models.chat import ChatMessage, ChatSession, TopicSession
from app.models.growth import TopicSummary from app.models.growth import TeacherHelpCard, TopicSummary
from app.models.logs import AiRequestLog, OperationLog from app.models.logs import AiRequestLog, OperationLog
from app.models.user import User from app.models.user import User
from app.api.pagination import page_result from app.api.pagination import page_result
from app.services.question_insight_service import QuestionInsightService from app.services.question_insight_service import QuestionInsightService
from app.services.growth_profile_service import topic_dict, topic_summary_dict from app.services.growth_profile_service import topic_dict, topic_summary_dict
from app.services.help_card_service import help_card_dict
router = APIRouter() router = APIRouter()
@@ -138,6 +139,16 @@ def chat_detail(
.order_by(AiRequestLog.created_at.asc(), AiRequestLog.id.asc()) .order_by(AiRequestLog.created_at.asc(), AiRequestLog.id.asc())
).all() ).all()
topics = _topic_rows(db, session_id) topics = _topic_rows(db, session_id)
topic_ids = [item["id"] for item in topics]
help_cards = []
if topic_ids:
help_cards = list(
db.scalars(
select(TeacherHelpCard)
.where(TeacherHelpCard.topic_session_id.in_(topic_ids))
.order_by(TeacherHelpCard.created_at.desc(), TeacherHelpCard.id.desc())
)
)
return api_success( return api_success(
{ {
"session": _chat_row_dict(session, user), "session": _chat_row_dict(session, user),
@@ -150,6 +161,7 @@ def chat_detail(
), ),
"aiLogs": [_ai_log_dict(item, include_prompt=True) for item in ai_logs], "aiLogs": [_ai_log_dict(item, include_prompt=True) for item in ai_logs],
"topics": topics, "topics": topics,
"helpCards": [help_card_dict(item) for item in help_cards],
} }
) )

View File

@@ -31,6 +31,7 @@ from app.services.chat_queue_runtime import (
from app.services.chat_queue_service import load_chat_queue_config from app.services.chat_queue_service import load_chat_queue_config
from app.services.chat_stream_service import ChatStreamService from app.services.chat_stream_service import ChatStreamService
from app.services.growth_profile_service import GrowthProfileService from app.services.growth_profile_service import GrowthProfileService
from app.services.help_card_service import HelpCardService, help_card_dict
from app.services.reasoning_policy_service import ReasoningPolicyService from app.services.reasoning_policy_service import ReasoningPolicyService
router = APIRouter() router = APIRouter()
@@ -96,6 +97,35 @@ def finish_topic(
return api_success(GrowthProfileService.finish_active_topic(db, user=current_user, session=session)) return api_success(GrowthProfileService.finish_active_topic(db, user=current_user, session=session))
@router.post("/session/{session_id}/help-card")
def generate_help_card(
session_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
session = ChatService._get_user_session(db, current_user, session_id)
card = HelpCardService.generate_for_session(db, user=current_user, session=session)
return api_success(help_card_dict(card))
@router.get("/help-card/list")
def list_help_cards(
limit: int = Query(default=20, ge=1, le=50),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
return api_success([help_card_dict(card) for card in HelpCardService.list_user_cards(db, user=current_user, limit=limit)])
@router.post("/help-card/{card_id}/copied")
def mark_help_card_copied(
card_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
return api_success(help_card_dict(HelpCardService.mark_copied(db, user=current_user, card_id=card_id)))
@router.post("/completions") @router.post("/completions")
def completions( def completions(
payload: ChatCompletionRequest, payload: ChatCompletionRequest,

View File

@@ -3,7 +3,7 @@ from app.models.ai_config import ModelConfig, Prompt, SystemConfig
from app.models.base import Base from app.models.base import Base
from app.models.chat import ChatMessage, ChatSession, TopicSession from app.models.chat import ChatMessage, ChatSession, TopicSession
from app.models.entitlement import EntitlementPlan, UserEntitlement, UserEntitlementLog from app.models.entitlement import EntitlementPlan, UserEntitlement, UserEntitlementLog
from app.models.growth import GrowthProfileRevision, TopicSummary, UserGrowthProfile from app.models.growth import GrowthProfileRevision, TeacherHelpCard, TopicSummary, UserGrowthProfile
from app.models.knowledge import ( from app.models.knowledge import (
HumanAttentionHistory, HumanAttentionHistory,
HumanAttentionRecord, HumanAttentionRecord,
@@ -55,6 +55,7 @@ __all__ = [
"Prompt", "Prompt",
"Role", "Role",
"SystemConfig", "SystemConfig",
"TeacherHelpCard",
"User", "User",
"UserEntitlement", "UserEntitlement",
"UserEntitlementLog", "UserEntitlementLog",

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base from app.models.base import Base
@@ -64,3 +64,21 @@ class GrowthProfileRevision(Base):
after_json: Mapped[str] = mapped_column(Text, nullable=False) after_json: Mapped[str] = mapped_column(Text, nullable=False)
reason: Mapped[str] = mapped_column(String(50), default="topic_summary", nullable=False) reason: Mapped[str] = mapped_column(String(50), default="topic_summary", nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
class TeacherHelpCard(Base):
__tablename__ = "sys_teacher_help_card"
__table_args__ = (
Index("ix_sys_teacher_help_card_user_created", "user_id", "created_at"),
Index("ix_sys_teacher_help_card_topic_created", "topic_session_id", "created_at"),
)
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False)
topic_session_id: Mapped[int] = mapped_column(ForeignKey("sys_topic_session.id"), index=True, nullable=False)
summary_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
content: Mapped[str] = mapped_column(Text, nullable=False)
source: Mapped[str] = mapped_column(String(30), default="topic_summary", nullable=False)
copied: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
copied_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)

View File

@@ -0,0 +1,123 @@
from __future__ import annotations
from datetime import UTC, datetime
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.chat import ChatSession, TopicSession
from app.models.growth import TeacherHelpCard, TopicSummary
from app.models.user import User
from app.services.entitlement_service import EntitlementService
from app.services.growth_profile_service import GrowthProfileService, topic_summary_dict
class HelpCardService:
@staticmethod
def generate_for_session(db: Session, *, user: User, session: ChatSession) -> TeacherHelpCard:
entitlement = EntitlementService.active_entitlement(db, user)
if not entitlement.allow_help_card:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="当前权益暂不支持生成老师求助卡")
topic = _latest_topic(db, user=user, session=session)
if topic is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="当前会话还没有可生成求助卡的主题")
summary = GrowthProfileService.generate_topic_summary(db, user=user, topic=topic)
content = _render_help_card(user=user, topic=topic, summary=summary)
card = TeacherHelpCard(
user_id=user.id,
topic_session_id=topic.id,
summary_id=summary.id,
content=content,
source="topic_summary",
)
topic.help_card_generated = 1
db.add_all([topic, card])
db.commit()
db.refresh(card)
return card
@staticmethod
def list_user_cards(db: Session, *, user: User, limit: int = 20) -> list[TeacherHelpCard]:
return list(
db.scalars(
select(TeacherHelpCard)
.where(TeacherHelpCard.user_id == user.id)
.order_by(TeacherHelpCard.created_at.desc(), TeacherHelpCard.id.desc())
.limit(limit)
)
)
@staticmethod
def mark_copied(db: Session, *, user: User, card_id: int) -> TeacherHelpCard:
card = db.scalar(select(TeacherHelpCard).where(TeacherHelpCard.id == card_id, TeacherHelpCard.user_id == user.id))
if card is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="求助卡不存在")
card.copied = 1
card.copied_at = _now()
db.add(card)
db.commit()
db.refresh(card)
return card
def help_card_dict(card: TeacherHelpCard) -> dict:
return {
"id": card.id,
"userId": card.user_id,
"topicSessionId": card.topic_session_id,
"summaryId": card.summary_id,
"content": card.content,
"source": card.source,
"copied": bool(card.copied),
"copiedAt": card.copied_at,
"createdAt": card.created_at,
}
def _latest_topic(db: Session, *, user: User, session: ChatSession) -> TopicSession | None:
return db.scalar(
select(TopicSession)
.where(TopicSession.user_id == user.id, TopicSession.chat_session_id == session.id)
.order_by(TopicSession.updated_at.desc(), TopicSession.id.desc())
.limit(1)
)
def _render_help_card(*, user: User, topic: TopicSession, summary: TopicSummary) -> str:
data = topic_summary_dict(summary)
return (
"【给老师的求助卡】\n"
"说明:这是我根据本次 AI 对话整理出的求助信息,请老师帮我确认方向。"
"我会按实际情况自行删改后再发送。\n\n"
f"学员:{user.name or user.nickname or user.phone}\n"
f"主题:{topic.title}\n"
f"主题时间:{_format_time(topic.started_at)} - {_format_time(topic.ended_at) if topic.ended_at else '进行中'}\n\n"
"1. 我遇到的问题\n"
f"{topic.core_question or '(请补充)'}\n\n"
"2. AI 已经帮我梳理出的重点\n"
f"{data.get('summary') or '(暂无摘要)'}\n\n"
"3. 我现在最明显的情绪 / 身体感受\n"
f"情绪:{data.get('emotions') or '(请补充)'}\n"
f"身体感受:{data.get('bodyFeelings') or '(请补充)'}\n\n"
"4. 我已经尝试过或被建议的功课\n"
f"{data.get('recommendedHomework') or '(请补充)'}\n\n"
"5. 我仍然卡住的地方\n"
f"{data.get('beliefs') or data.get('nextObservation') or '(请补充)'}\n\n"
"6. 我想请老师确认的问题\n"
"(请把最想确认的一两个问题写在这里)\n\n"
"备注:这张卡片不会自动发送给老师,也不代表已经转人工处理。"
)
def _format_time(value: datetime | None) -> str:
if value is None:
return "未知"
return value.strftime("%Y-%m-%d %H:%M")
def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)

View File

@@ -0,0 +1,64 @@
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.models import Base
from app.models.chat import ChatMessage, ChatSession, TopicSession
from app.models.entitlement import EntitlementPlan
from app.models.growth import TeacherHelpCard
from app.models.user import User
from app.services.help_card_service import HelpCardService
def _db() -> Session:
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
return Session(engine)
def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)
def test_generate_help_card_from_topic_summary_and_mark_copied():
with _db() as db:
user = User(id=1, phone="13800000001", name="测试学员", daily_chat_limit=100, daily_chat_used=0)
plan = EntitlementPlan(id=10, name="基础版", plan_type="basic", allow_help_card=1, status=1)
session = ChatSession(id=1, user_id=1, title="阴影人格", message_count=2, last_message_at=_now(), is_deleted=0)
topic = TopicSession(
id=1,
user_id=1,
chat_session_id=1,
title="阴影人格练习",
core_question="阴影人格练习步骤是否正确",
status="active",
message_count=2,
quota_deducted=1,
started_at=_now(),
)
db.add_all([user, plan, session, topic])
db.add_all(
[
ChatMessage(id=1, session_id=1, topic_session_id=1, user_id=1, role="user", content="我练习时身体抗拒。", created_at=_now()),
ChatMessage(id=2, session_id=1, topic_session_id=1, user_id=1, role="assistant", content="先回到身体感受,不急着分析。", created_at=_now()),
]
)
db.commit()
card = HelpCardService.generate_for_session(db, user=user, session=session)
assert "给老师的求助卡" in card.content
assert "不会自动发送给老师" in card.content
assert "阴影人格练习步骤是否正确" in card.content
assert db.get(TopicSession, 1).help_card_generated == 1
assert db.query(TeacherHelpCard).count() == 1
copied = HelpCardService.mark_copied(db, user=user, card_id=card.id)
assert copied.copied == 1
assert copied.copied_at is not None

View File

@@ -9,7 +9,7 @@ import MessageList, { type DisplayMessage } from "./components/MessageList.vue";
import SessionDrawer from "./components/SessionDrawer.vue"; import SessionDrawer from "./components/SessionDrawer.vue";
import SessionQuota from "./components/SessionQuota.vue"; import SessionQuota from "./components/SessionQuota.vue";
import { ApiError, api, clearToken, getToken, streamChat } from "./services/api"; 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 user = ref<UserProfile | null>(null);
const sessions = ref<ChatSession[]>([]); const sessions = ref<ChatSession[]>([]);
@@ -23,9 +23,13 @@ const loadingSessions = ref(false);
const sessionOperationPending = ref(false); const sessionOperationPending = ref(false);
const logoutDialogOpen = ref(false); const logoutDialogOpen = ref(false);
const profileDialogOpen = ref(false); const profileDialogOpen = ref(false);
const helpCardDialogOpen = ref(false);
const growthProfile = ref<GrowthProfileResult | null>(null); const growthProfile = ref<GrowthProfileResult | null>(null);
const helpCard = ref<TeacherHelpCard | null>(null);
const helpCardContent = ref("");
const profileLoading = ref(false); const profileLoading = ref(false);
const finishingTopic = ref(false); const finishingTopic = ref(false);
const generatingHelpCard = ref(false);
const statusText = ref("连接后端中"); const statusText = ref("连接后端中");
const toastText = ref(""); const toastText = ref("");
const followingOutput = ref(true); 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() { async function openGrowthProfile() {
profileDialogOpen.value = true; profileDialogOpen.value = true;
profileLoading.value = true; profileLoading.value = true;
@@ -326,6 +363,22 @@ function showToast(message: string) {
toastTimer = null; toastTimer = null;
}, 3200); }, 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> </script>
<template> <template>
@@ -340,8 +393,10 @@ function showToast(message: string) {
:limit="user.dailyLimit" :limit="user.dailyLimit"
:entitlement="user.entitlement" :entitlement="user.entitlement"
:finishing="finishingTopic" :finishing="finishingTopic"
:generating-help-card="generatingHelpCard"
@finish-topic="finishCurrentTopic" @finish-topic="finishCurrentTopic"
@open-profile="openGrowthProfile" @open-profile="openGrowthProfile"
@generate-help-card="generateHelpCard"
/> />
<MessageList <MessageList
ref="messageList" ref="messageList"
@@ -391,6 +446,19 @@ function showToast(message: string) {
</section> </section>
</AppDialog> </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"> <AppDialog v-if="logoutDialogOpen" title="退出登录" labelled-by="logout-dialog-title" @close="logoutDialogOpen = false">
<p class="confirm-copy">确定退出当前账号吗</p> <p class="confirm-copy">确定退出当前账号吗</p>
<template #footer> <template #footer>

View File

@@ -9,11 +9,13 @@ const props = defineProps<{
limit: number; limit: number;
entitlement?: UserEntitlementSummary | null; entitlement?: UserEntitlementSummary | null;
finishing?: boolean; finishing?: boolean;
generatingHelpCard?: boolean;
}>(); }>();
defineEmits<{ defineEmits<{
finishTopic: []; finishTopic: [];
openProfile: []; openProfile: [];
generateHelpCard: [];
}>(); }>();
const hasEntitlement = computed(() => Boolean(props.entitlement)); 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')"> <button type="button" class="quota-link" :disabled="finishing" @click="$emit('finishTopic')">
{{ finishing ? '沉淀中' : '沉淀本主题' }} {{ finishing ? '沉淀中' : '沉淀本主题' }}
</button> </button>
<button
v-if="entitlement?.allowHelpCard"
type="button"
class="quota-link"
:disabled="generatingHelpCard"
@click="$emit('generateHelpCard')"
>
{{ generatingHelpCard ? '生成中' : '求助卡' }}
</button>
<strong>{{ displayUsed }}/{{ limitText }}</strong> <strong>{{ displayUsed }}/{{ limitText }}</strong>
</div> </div>
</section> </section>

View File

@@ -6,6 +6,7 @@ import type {
FinishTopicResult, FinishTopicResult,
GrowthProfileResult, GrowthProfileResult,
LoginResult, LoginResult,
TeacherHelpCard,
UserProfile, UserProfile,
} from "../types/api"; } from "../types/api";
@@ -86,6 +87,9 @@ export const api = {
request<ChatSession>("/chat/session/title", { method: "PUT", body: JSON.stringify({ sessionId, title }) }), request<ChatSession>("/chat/session/title", { method: "PUT", body: JSON.stringify({ sessionId, title }) }),
deleteSession: (sessionId: number) => request<null>(`/chat/session/${sessionId}`, { method: "DELETE" }), 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({}) }), 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"), growthProfile: () => request<GrowthProfileResult>("/user/growth-profile"),
stop: (sessionId: number) => request<null>("/chat/stop", { method: "POST", body: JSON.stringify({ sessionId }) }), stop: (sessionId: number) => request<null>("/chat/stop", { method: "POST", body: JSON.stringify({ sessionId }) }),
}; };

View File

@@ -1717,6 +1717,39 @@ textarea:focus-visible {
white-space: pre-wrap; 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, .growth-profile-fields,
.recent-topic-summaries { .recent-topic-summaries {
display: grid; display: grid;

View File

@@ -67,6 +67,18 @@ export interface FinishTopicResult {
growthProfileEnabled: boolean; 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 { export interface LoginResult {
token: string; token: string;
expiredAt: string; expiredAt: string;

View File

@@ -432,6 +432,10 @@ Agent 已经可以:
- 不出现“已转人工”“等待老师处理”等文案; - 不出现“已转人工”“等待老师处理”等文案;
- 后台只记录生成历史,不做派单。 - 后台只记录生成历史,不做派单。
#### 开发进度
- 2026-07-31已新增 `sys_teacher_help_card`,用户端支持从当前会话生成“老师求助卡”、弹窗编辑并复制;复制后仅记录已复制时间。后台聊天详情可查看生成过的求助卡内容和复制状态,不做派单、不做处理状态。
--- ---
### 06. 班级分享稿 ### 06. 班级分享稿