feat: add teacher help cards
This commit is contained in:
@@ -1603,6 +1603,25 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
|
||||
</el-collapse>
|
||||
<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>
|
||||
<AdminPagination
|
||||
class="chat-detail-pagination top"
|
||||
|
||||
@@ -397,6 +397,19 @@ export interface ChatDetail {
|
||||
messagesPage?: PageResult<ChatMessageRecord>;
|
||||
aiLogs: AiLogRecord[];
|
||||
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 {
|
||||
|
||||
@@ -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")
|
||||
@@ -16,12 +16,13 @@ from app.core.dependencies import get_current_admin
|
||||
from app.core.responses import api_success
|
||||
from app.models.admin import Admin
|
||||
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.user import User
|
||||
from app.api.pagination import page_result
|
||||
from app.services.question_insight_service import QuestionInsightService
|
||||
from app.services.growth_profile_service import topic_dict, topic_summary_dict
|
||||
from app.services.help_card_service import help_card_dict
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -138,6 +139,16 @@ def chat_detail(
|
||||
.order_by(AiRequestLog.created_at.asc(), AiRequestLog.id.asc())
|
||||
).all()
|
||||
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(
|
||||
{
|
||||
"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],
|
||||
"topics": topics,
|
||||
"helpCards": [help_card_dict(item) for item in help_cards],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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_stream_service import ChatStreamService
|
||||
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
|
||||
|
||||
router = APIRouter()
|
||||
@@ -96,6 +97,35 @@ def finish_topic(
|
||||
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")
|
||||
def completions(
|
||||
payload: ChatCompletionRequest,
|
||||
|
||||
@@ -3,7 +3,7 @@ from app.models.ai_config import ModelConfig, Prompt, SystemConfig
|
||||
from app.models.base import Base
|
||||
from app.models.chat import ChatMessage, ChatSession, TopicSession
|
||||
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 (
|
||||
HumanAttentionHistory,
|
||||
HumanAttentionRecord,
|
||||
@@ -55,6 +55,7 @@ __all__ = [
|
||||
"Prompt",
|
||||
"Role",
|
||||
"SystemConfig",
|
||||
"TeacherHelpCard",
|
||||
"User",
|
||||
"UserEntitlement",
|
||||
"UserEntitlementLog",
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
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 app.models.base import Base
|
||||
@@ -64,3 +64,21 @@ class GrowthProfileRevision(Base):
|
||||
after_json: Mapped[str] = mapped_column(Text, 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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
64
ai_knowledge_base_v2/apps/backend/tests/test_help_cards.py
Normal file
64
ai_knowledge_base_v2/apps/backend/tests/test_help_cards.py
Normal 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
|
||||
|
||||
@@ -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