feat: add class share drafts

This commit is contained in:
2026-07-31 16:07:18 +08:00
parent d8fde93b69
commit 429636f3e8
15 changed files with 410 additions and 5 deletions

View File

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

View File

@@ -398,6 +398,7 @@ export interface ChatDetail {
aiLogs: AiLogRecord[];
topics?: TopicSessionRecord[];
helpCards?: TeacherHelpCardRecord[];
shareDrafts?: ShareDraftRecord[];
}
export interface TeacherHelpCardRecord {
@@ -412,6 +413,18 @@ export interface TeacherHelpCardRecord {
createdAt: string;
}
export interface ShareDraftRecord {
id: number;
userId: number;
topicSessionId: number;
summaryId?: number | null;
content: string;
source: string;
copied: boolean;
copiedAt?: string | null;
createdAt: string;
}
export interface TopicSummaryRecord {
id: number;
topicSessionId: number;

View File

@@ -0,0 +1,51 @@
"""add share drafts
Revision ID: 0017_share_drafts
Revises: 0016_teacher_help_cards
"""
from alembic import op
import sqlalchemy as sa
revision = "0017_share_drafts"
down_revision = "0016_teacher_help_cards"
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_share_draft" in tables:
return
op.create_table(
"sys_share_draft",
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_share_draft_user_id", "sys_share_draft", ["user_id"])
op.create_index("ix_sys_share_draft_summary_id", "sys_share_draft", ["summary_id"])
op.create_index("ix_sys_share_draft_user_created", "sys_share_draft", ["user_id", "created_at"])
op.create_index("ix_sys_share_draft_topic_created", "sys_share_draft", ["topic_session_id", "created_at"])
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "sys_share_draft" not in tables:
return
op.drop_index("ix_sys_share_draft_topic_created", table_name="sys_share_draft")
op.drop_index("ix_sys_share_draft_user_created", table_name="sys_share_draft")
op.drop_index("ix_sys_share_draft_summary_id", table_name="sys_share_draft")
op.drop_index("ix_sys_share_draft_user_id", table_name="sys_share_draft")
op.drop_table("sys_share_draft")

View File

@@ -16,13 +16,14 @@ 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 TeacherHelpCard, TopicSummary
from app.models.growth import ShareDraft, 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
from app.services.share_draft_service import share_draft_dict
router = APIRouter()
@@ -141,6 +142,7 @@ def chat_detail(
topics = _topic_rows(db, session_id)
topic_ids = [item["id"] for item in topics]
help_cards = []
share_drafts = []
if topic_ids:
help_cards = list(
db.scalars(
@@ -149,6 +151,13 @@ def chat_detail(
.order_by(TeacherHelpCard.created_at.desc(), TeacherHelpCard.id.desc())
)
)
share_drafts = list(
db.scalars(
select(ShareDraft)
.where(ShareDraft.topic_session_id.in_(topic_ids))
.order_by(ShareDraft.created_at.desc(), ShareDraft.id.desc())
)
)
return api_success(
{
"session": _chat_row_dict(session, user),
@@ -162,6 +171,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],
"shareDrafts": [share_draft_dict(item) for item in share_drafts],
}
)

View File

@@ -33,6 +33,7 @@ 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
from app.services.share_draft_service import ShareDraftService, share_draft_dict
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -126,6 +127,35 @@ def mark_help_card_copied(
return api_success(help_card_dict(HelpCardService.mark_copied(db, user=current_user, card_id=card_id)))
@router.post("/session/{session_id}/share-draft")
def generate_share_draft(
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)
draft = ShareDraftService.generate_for_session(db, user=current_user, session=session)
return api_success(share_draft_dict(draft))
@router.get("/share-draft/list")
def list_share_drafts(
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([share_draft_dict(draft) for draft in ShareDraftService.list_user_drafts(db, user=current_user, limit=limit)])
@router.post("/share-draft/{draft_id}/copied")
def mark_share_draft_copied(
draft_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
return api_success(share_draft_dict(ShareDraftService.mark_copied(db, user=current_user, draft_id=draft_id)))
@router.post("/completions")
def completions(
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.chat import ChatMessage, ChatSession, TopicSession
from app.models.entitlement import EntitlementPlan, UserEntitlement, UserEntitlementLog
from app.models.growth import GrowthProfileRevision, TeacherHelpCard, TopicSummary, UserGrowthProfile
from app.models.growth import GrowthProfileRevision, ShareDraft, TeacherHelpCard, TopicSummary, UserGrowthProfile
from app.models.knowledge import (
HumanAttentionHistory,
HumanAttentionRecord,
@@ -55,6 +55,7 @@ __all__ = [
"Prompt",
"Role",
"SystemConfig",
"ShareDraft",
"TeacherHelpCard",
"User",
"UserEntitlement",

View File

@@ -82,3 +82,21 @@ class TeacherHelpCard(Base):
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)
class ShareDraft(Base):
__tablename__ = "sys_share_draft"
__table_args__ = (
Index("ix_sys_share_draft_user_created", "user_id", "created_at"),
Index("ix_sys_share_draft_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,113 @@
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 ShareDraft, 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 ShareDraftService:
@staticmethod
def generate_for_session(db: Session, *, user: User, session: ChatSession) -> ShareDraft:
entitlement = EntitlementService.active_entitlement(db, user)
if not entitlement.allow_share_draft:
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)
draft = ShareDraft(
user_id=user.id,
topic_session_id=topic.id,
summary_id=summary.id,
content=_render_share_draft(topic=topic, summary=summary),
source="topic_summary",
)
topic.share_draft_generated = 1
db.add_all([topic, draft])
db.commit()
db.refresh(draft)
return draft
@staticmethod
def list_user_drafts(db: Session, *, user: User, limit: int = 20) -> list[ShareDraft]:
return list(
db.scalars(
select(ShareDraft)
.where(ShareDraft.user_id == user.id)
.order_by(ShareDraft.created_at.desc(), ShareDraft.id.desc())
.limit(limit)
)
)
@staticmethod
def mark_copied(db: Session, *, user: User, draft_id: int) -> ShareDraft:
draft = db.scalar(select(ShareDraft).where(ShareDraft.id == draft_id, ShareDraft.user_id == user.id))
if draft is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分享稿不存在")
draft.copied = 1
draft.copied_at = _now()
db.add(draft)
db.commit()
db.refresh(draft)
return draft
def share_draft_dict(draft: ShareDraft) -> dict:
return {
"id": draft.id,
"userId": draft.user_id,
"topicSessionId": draft.topic_session_id,
"summaryId": draft.summary_id,
"content": draft.content,
"source": draft.source,
"copied": bool(draft.copied),
"copiedAt": draft.copied_at,
"createdAt": draft.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_share_draft(*, topic: TopicSession, summary: TopicSummary) -> str:
data = topic_summary_dict(summary)
return (
"【实修分享稿草稿】\n"
"说明:这是根据我本次对话整理出的分享草稿,系统不会自动发送到任何群,"
"我会按真实情况删改后再决定是否发到班级群。\n\n"
"大家好,我想分享一下这次实修里看到的一点东西。\n\n"
"1. 我这次观察到的议题\n"
f"{topic.core_question or topic.title}\n\n"
"2. 我看见了什么\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('insights') or '(请补充真实变化,不需要夸大)'}\n\n"
"6. 我还在继续观察的方向\n"
f"{data.get('nextObservation') or '(请补充)'}\n\n"
"备注:这只是我的阶段性观察,不代表已经彻底解决,也不是建议别人照搬。"
)
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 ShareDraft
from app.models.user import User
from app.services.share_draft_service import ShareDraftService
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_share_draft_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_share_draft=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()
draft = ShareDraftService.generate_for_session(db, user=user, session=session)
assert "实修分享稿草稿" in draft.content
assert "系统不会自动发送到任何群" in draft.content
assert "不代表已经彻底解决" in draft.content
assert "我看见自己不敢表达" in draft.content
assert db.get(TopicSession, 1).share_draft_generated == 1
assert db.query(ShareDraft).count() == 1
copied = ShareDraftService.mark_copied(db, user=user, draft_id=draft.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 SessionQuota from "./components/SessionQuota.vue";
import { ApiError, api, clearToken, getToken, streamChat } from "./services/api";
import type { ChatMessage as ApiMessage, ChatSession, GrowthProfileResult, TeacherHelpCard, UserProfile } from "./types/api";
import type { ChatMessage as ApiMessage, ChatSession, GrowthProfileResult, ShareDraft, TeacherHelpCard, UserProfile } from "./types/api";
const user = ref<UserProfile | null>(null);
const sessions = ref<ChatSession[]>([]);
@@ -24,12 +24,16 @@ const sessionOperationPending = ref(false);
const logoutDialogOpen = ref(false);
const profileDialogOpen = ref(false);
const helpCardDialogOpen = ref(false);
const shareDraftDialogOpen = ref(false);
const growthProfile = ref<GrowthProfileResult | null>(null);
const helpCard = ref<TeacherHelpCard | null>(null);
const helpCardContent = ref("");
const shareDraft = ref<ShareDraft | null>(null);
const shareDraftContent = ref("");
const profileLoading = ref(false);
const finishingTopic = ref(false);
const generatingHelpCard = ref(false);
const generatingShareDraft = ref(false);
const statusText = ref("连接后端中");
const toastText = ref("");
const followingOutput = ref(true);
@@ -251,6 +255,39 @@ async function copyHelpCard() {
}
}
async function generateShareDraft() {
if (!activeSessionId.value || sending.value || generatingShareDraft.value) return;
generatingShareDraft.value = true;
try {
const result = await api.generateShareDraft(activeSessionId.value);
shareDraft.value = result;
shareDraftContent.value = result.content;
shareDraftDialogOpen.value = true;
showToast("分享稿已生成,可编辑后复制");
await refreshProfile();
} catch (error) {
handleError(error, "分享稿生成失败");
} finally {
generatingShareDraft.value = false;
}
}
async function copyShareDraft() {
if (!shareDraftContent.value.trim()) {
showToast("分享稿内容为空");
return;
}
try {
await copyText(shareDraftContent.value);
if (shareDraft.value) {
shareDraft.value = await api.markShareDraftCopied(shareDraft.value.id);
}
showToast("已复制,可粘贴到班级群");
} catch (error) {
handleError(error, "复制失败,请手动选择文本复制");
}
}
async function openGrowthProfile() {
profileDialogOpen.value = true;
profileLoading.value = true;
@@ -394,9 +431,11 @@ async function copyText(text: string) {
:entitlement="user.entitlement"
:finishing="finishingTopic"
:generating-help-card="generatingHelpCard"
:generating-share-draft="generatingShareDraft"
@finish-topic="finishCurrentTopic"
@open-profile="openGrowthProfile"
@generate-help-card="generateHelpCard"
@generate-share-draft="generateShareDraft"
/>
<MessageList
ref="messageList"
@@ -459,6 +498,19 @@ async function copyText(text: string) {
</template>
</AppDialog>
<AppDialog v-if="shareDraftDialogOpen" title="班级分享稿" labelled-by="share-draft-title" @close="shareDraftDialogOpen = false">
<section class="help-card-dialog">
<p>这只是分享草稿系统不会自动发送到任何群请删掉不想公开的隐私内容并按自己的真实状态修改后再复制</p>
<textarea v-model="shareDraftContent" aria-label="班级分享稿内容" />
</section>
<template #footer>
<div class="dialog-actions">
<button type="button" class="dialog-secondary" @click="shareDraftDialogOpen = false">关闭</button>
<button type="button" class="dialog-primary" @click="copyShareDraft">复制分享稿</button>
</div>
</template>
</AppDialog>
<AppDialog v-if="logoutDialogOpen" title="退出登录" labelled-by="logout-dialog-title" @close="logoutDialogOpen = false">
<p class="confirm-copy">确定退出当前账号吗</p>
<template #footer>

View File

@@ -10,12 +10,14 @@ const props = defineProps<{
entitlement?: UserEntitlementSummary | null;
finishing?: boolean;
generatingHelpCard?: boolean;
generatingShareDraft?: boolean;
}>();
defineEmits<{
finishTopic: [];
openProfile: [];
generateHelpCard: [];
generateShareDraft: [];
}>();
const hasEntitlement = computed(() => Boolean(props.entitlement));
@@ -54,6 +56,15 @@ const limitText = computed(() => displayLimit.value === null ? "不限" : String
>
{{ generatingHelpCard ? '生成中' : '求助卡' }}
</button>
<button
v-if="entitlement?.allowShareDraft"
type="button"
class="quota-link"
:disabled="generatingShareDraft"
@click="$emit('generateShareDraft')"
>
{{ generatingShareDraft ? '生成中' : '分享稿' }}
</button>
<strong>{{ displayUsed }}/{{ limitText }}</strong>
</div>
</section>

View File

@@ -6,6 +6,7 @@ import type {
FinishTopicResult,
GrowthProfileResult,
LoginResult,
ShareDraft,
TeacherHelpCard,
UserProfile,
} from "../types/api";
@@ -90,6 +91,9 @@ export const api = {
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}`),
generateShareDraft: (sessionId: number) => request<ShareDraft>(`/chat/session/${sessionId}/share-draft`, { method: "POST", body: JSON.stringify({}) }),
markShareDraftCopied: (draftId: number) => request<ShareDraft>(`/chat/share-draft/${draftId}/copied`, { method: "POST", body: JSON.stringify({}) }),
shareDrafts: (limit = 20) => request<ShareDraft[]>(`/chat/share-draft/list?limit=${limit}`),
growthProfile: () => request<GrowthProfileResult>("/user/growth-profile"),
stop: (sessionId: number) => request<null>("/chat/stop", { method: "POST", body: JSON.stringify({ sessionId }) }),
};

View File

@@ -1204,9 +1204,10 @@ textarea:focus-visible {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin: 12px 16px 0;
padding: 0 14px;
padding: 8px 14px;
border: 1px solid rgba(217, 230, 225, 0.75);
border-radius: 13px;
background: var(--chat-brand-soft);
@@ -1234,6 +1235,8 @@ textarea:focus-visible {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
flex: 0 0 auto;
}

View File

@@ -79,6 +79,18 @@ export interface TeacherHelpCard {
createdAt: string;
}
export interface ShareDraft {
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;