feat: refine topic insights and learner experience

This commit is contained in:
2026-08-17 12:42:16 +08:00
parent 77399df060
commit 833763c461
27 changed files with 848 additions and 182 deletions

View File

@@ -25,7 +25,7 @@ const loadingSessions = ref(false);
const sessionOperationPending = ref(false);
const logoutDialogOpen = ref(false);
const personalCenterOpen = ref(false);
const personalCenterSection = ref<"overview" | "review" | "records">("overview");
const personalCenterSection = ref<"overview" | "review" | "reports" | "records">("overview");
const helpCardDialogOpen = ref(false);
const shareDraftDialogOpen = ref(false);
const practiceReview = ref<PracticeReviewResult | null>(null);
@@ -38,7 +38,6 @@ const shareDraftHistory = ref<ShareDraft[]>([]);
const reportHistory = ref<PeriodicReport[]>([]);
const personalCenterLoading = ref(false);
const cardOperationPending = ref(false);
const finishingTopic = ref(false);
const generatingHelpCard = ref(false);
const generatingShareDraft = ref(false);
const statusText = ref("连接后端中");
@@ -51,7 +50,6 @@ const feedbackContent = ref("");
const feedbackSubmitting = ref(false);
const activeAbortController = ref<AbortController | null>(null);
let toastTimer: number | null = null;
let settlementPollVersion = 0;
onMounted(bootstrap);
@@ -96,7 +94,6 @@ async function bootstrap() {
}
onBeforeUnmount(() => {
settlementPollVersion += 1;
activeAbortController.value?.abort();
if (toastTimer) window.clearTimeout(toastTimer);
document.body.classList.remove("drawer-open");
@@ -129,14 +126,13 @@ async function loadSessions() {
async function createSession() {
if (sessionOperationPending.value) return;
composerKey.value += 1;
const existingBlank = sessions.value.find((session) => session.messageCount === 0);
if (existingBlank) {
await selectSession(existingBlank.id);
const activeSession = sessions.value.find((session) => session.id === activeSessionId.value);
if (activeSession?.messageCount === 0) {
return;
}
sessionOperationPending.value = true;
try {
const result = await api.createSession();
const result = await api.createSession(activeSessionId.value ?? undefined);
sessions.value = await api.listSessions();
await selectSession(result.sessionId, true);
} catch (error) {
@@ -306,48 +302,6 @@ async function stop() {
}
}
async function finishCurrentTopic() {
if (!activeSessionId.value || sending.value || finishingTopic.value) return;
finishingTopic.value = true;
try {
const result = await api.finishTopic(activeSessionId.value);
if (result.settlementStatus === "success" || result.settlementStatus === "fallback") {
showToast(result.growthProfileEnabled ? "本主题已沉淀,并更新了近期实修回顾" : "本主题已沉淀");
} else {
showToast("主题已结束,实修记录正在后台沉淀,可以继续使用其他功能");
void watchTopicSettlement(result.summary.id);
}
await refreshSessionList();
await refreshProfile();
} catch (error) {
handleError(error, "主题沉淀失败");
} finally {
finishingTopic.value = false;
}
}
async function watchTopicSettlement(summaryId: number) {
const version = ++settlementPollVersion;
for (let attempt = 0; attempt < 40; attempt += 1) {
await new Promise((resolve) => window.setTimeout(resolve, 3000));
if (version !== settlementPollVersion || !user.value) return;
try {
const result = await api.topicSettlement(summaryId);
if (result.settlementStatus === "success" || result.settlementStatus === "fallback") {
showToast(result.growthProfileEnabled ? "实修记录已沉淀,并更新了近期实修回顾" : "实修记录已沉淀完成");
await refreshProfile();
return;
}
if (result.settlementStatus === "failed") {
showToast("实修记录暂时沉淀失败,系统已保留对话,可稍后重试");
return;
}
} catch {
// 轮询失败不影响聊天;下一轮继续查询持久化任务状态。
}
}
}
async function generateHelpCard() {
if (!activeSessionId.value || sending.value || generatingHelpCard.value) return;
generatingHelpCard.value = true;
@@ -470,7 +424,7 @@ async function deleteHistoryShareDraft(draftId: number, done: (success: boolean)
}
}
async function openPersonalCenter(section: "overview" | "review" | "records" = "overview") {
async function openPersonalCenter(section: "overview" | "review" | "reports" | "records" = "overview") {
personalCenterSection.value = section;
personalCenterOpen.value = true;
personalCenterLoading.value = true;
@@ -629,11 +583,8 @@ async function copyText(text: string) {
:used="user.todayUsed"
:limit="user.dailyLimit"
:entitlement="user.entitlement"
:finishing="finishingTopic"
:generating-help-card="generatingHelpCard"
:generating-share-draft="generatingShareDraft"
@finish-topic="finishCurrentTopic"
@open-profile="openPersonalCenter('review')"
@generate-help-card="generateHelpCard"
@generate-share-draft="generateShareDraft"
/>

View File

@@ -12,12 +12,12 @@ import {
Sparkles,
Trash2,
} from "@lucide/vue";
import { computed, ref } from "vue";
import { computed, ref, watch } from "vue";
import type { PeriodicReport, PracticeReviewResult, ShareDraft, TeacherHelpCard, UserProfile } from "../types/api";
import AppDialog from "./AppDialog.vue";
type CenterSection = "overview" | "review" | "records";
type CenterSection = "overview" | "review" | "reports" | "records";
type CardSection = "help" | "share";
const props = defineProps<{
@@ -44,6 +44,11 @@ const activeSection = ref<CenterSection>(props.initialSection);
const activeCardSection = ref<CardSection>("help");
const deletingCard = ref<{ type: CardSection; id: number } | null>(null);
const entitlement = computed(() => props.user.entitlement);
const canReview = computed(() => Boolean(entitlement.value?.enableGrowthProfile));
const canReports = computed(() => Boolean(entitlement.value?.enablePeriodicReports));
const canHelpCards = computed(() => Boolean(entitlement.value?.allowHelpCard));
const canShareDrafts = computed(() => Boolean(entitlement.value?.allowShareDraft));
const canCards = computed(() => canHelpCards.value || canShareDrafts.value);
const displayName = computed(() => props.user.nickname?.trim() || props.user.name);
const nameInitial = computed(() => displayName.value.slice(0, 1));
const maskedPhone = computed(() => props.user.phone.replace(/^(\d{3})\d{4}(\d{4})$/, "$1****$2"));
@@ -62,6 +67,37 @@ const capabilities = computed(() => [
{ label: "班级分享稿", enabled: Boolean(entitlement.value?.allowShareDraft) },
]);
watch(
[() => props.initialSection, canReview, canReports, canCards],
([initialSection]) => {
const requested = initialSection as CenterSection;
const available = requested === "overview"
|| (requested === "review" && canReview.value)
|| (requested === "reports" && canReports.value)
|| (requested === "records" && canCards.value);
if (!available || activeSection.value === "review" && !canReview.value
|| activeSection.value === "reports" && !canReports.value
|| activeSection.value === "records" && !canCards.value) {
activeSection.value = "overview";
} else {
activeSection.value = requested;
}
},
{ immediate: true },
);
watch(
[canHelpCards, canShareDrafts],
() => {
if (activeCardSection.value === "help" && !canHelpCards.value && canShareDrafts.value) {
activeCardSection.value = "share";
} else if (activeCardSection.value === "share" && !canShareDrafts.value && canHelpCards.value) {
activeCardSection.value = "help";
}
},
{ immediate: true },
);
function usagePercent(used: number, limit: number | null) {
if (limit === null || limit <= 0) return 0;
return Math.min(100, Math.max(0, Math.round((used / limit) * 100)));
@@ -112,8 +148,9 @@ function submitDeleteCard() {
<nav class="center-tabs" role="tablist" aria-label="个人中心栏目">
<button type="button" role="tab" :aria-selected="activeSection === 'overview'" @click="activeSection = 'overview'">账号权益</button>
<button type="button" role="tab" :aria-selected="activeSection === 'review'" @click="activeSection = 'review'">实修回顾</button>
<button type="button" role="tab" :aria-selected="activeSection === 'records'" @click="activeSection = 'records'">我的卡片</button>
<button v-if="canReview" type="button" role="tab" :aria-selected="activeSection === 'review'" @click="activeSection = 'review'">实修回顾</button>
<button v-if="canReports" type="button" role="tab" :aria-selected="activeSection === 'reports'" @click="activeSection = 'reports'">周期报告</button>
<button v-if="canCards" type="button" role="tab" :aria-selected="activeSection === 'records'" @click="activeSection = 'records'">我的卡片</button>
</nav>
<div v-if="activeSection === 'overview'" class="center-section">
@@ -207,8 +244,19 @@ function submitDeleteCard() {
</article>
</section>
<section v-if="reports.length" class="record-group">
<h3>周期实修回顾</h3>
</div>
<div v-else-if="activeSection === 'reports'" class="center-section reports-section">
<header class="card-history-head">
<div>
<h3>周期实修报告</h3>
<p>系统会根据已经沉淀的主题自动整理完整自然周和自然月的实修回顾</p>
</div>
<span>{{ reports.length }} </span>
</header>
<p v-if="loading" class="center-empty">正在加载周期报告...</p>
<p v-else-if="!entitlement?.enablePeriodicReports" class="center-empty">当前权益未包含周期报告已有聊天和主题回顾不会受到影响</p>
<section v-else-if="reports.length" class="record-group report-history-list">
<details v-for="item in reports" :key="item.id" class="report-record">
<summary>
<CalendarDays :size="16" aria-hidden="true" />
@@ -219,11 +267,13 @@ function submitDeleteCard() {
<pre>{{ item.content }}</pre>
</details>
</section>
<p v-else class="center-empty">暂时还没有周期报告完成主题沉淀后系统会在完整自然周或自然月结束后自动生成并展示在这里</p>
</div>
<div v-else class="center-section records-section">
<nav class="card-type-tabs" role="tablist" aria-label="我的卡片类型">
<button
v-if="canHelpCards"
type="button"
role="tab"
:aria-selected="activeCardSection === 'help'"
@@ -234,6 +284,7 @@ function submitDeleteCard() {
<small>{{ helpCards.length }}</small>
</button>
<button
v-if="canShareDrafts"
type="button"
role="tab"
:aria-selected="activeCardSection === 'share'"
@@ -364,7 +415,8 @@ function submitDeleteCard() {
.member-copy strong { overflow: hidden; font-size: 16px; text-overflow: ellipsis; white-space: nowrap; }
.member-copy span { color: var(--chat-muted); font-size: 12px; }
.member-plan { max-width: 125px; overflow: hidden; padding: 6px 9px; border-radius: 999px; background: var(--chat-brand-soft); color: #2f6d59; font-size: 11px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
.center-tabs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; padding: 4px; border-radius: 13px; background: #f1f5f3; }
.center-tabs { display: flex; gap: 4px; padding: 4px; border-radius: 13px; background: #f1f5f3; }
.center-tabs button { flex: 1 1 0; min-width: 0; }
.center-tabs button { min-height: 36px; padding: 0 8px; border: 0; border-radius: 10px; background: transparent; color: var(--chat-muted); font-size: 13px; font-weight: 650; white-space: nowrap; }
.center-tabs button[aria-selected="true"] { background: #fff; color: var(--chat-brand-dark); box-shadow: 0 3px 12px rgba(27, 68, 55, 0.08); }
.center-section { display: grid; gap: 15px; }
@@ -408,6 +460,8 @@ function submitDeleteCard() {
.report-record summary small { color: var(--chat-weak); font-size: 10px; }
.report-record > time { display: block; padding: 0 12px 6px; }
.report-record pre { max-height: 260px; overflow: auto; margin: 0; padding: 11px 12px; border-top: 1px solid var(--chat-border); background: #fbfcfc; color: var(--chat-muted); font: inherit; font-size: 12px; line-height: 1.7; white-space: pre-wrap; }
.reports-section { align-content: start; }
.report-history-list { gap: 8px; }
.records-section { align-content: start; }
.card-type-tabs { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 2px 0 4px; background: #fff; }
.card-type-tabs button { min-width: 0; min-height: 52px; display: grid; grid-template-columns: 20px minmax(0, 1fr) auto; align-items: center; gap: 7px; padding: 0 11px; border: 1px solid var(--chat-border); border-radius: 13px; background: #fff; color: var(--chat-muted); font-size: 12px; font-weight: 700; text-align: left; }

View File

@@ -8,14 +8,11 @@ const props = defineProps<{
used: number;
limit: number;
entitlement?: UserEntitlementSummary | null;
finishing?: boolean;
generatingHelpCard?: boolean;
generatingShareDraft?: boolean;
}>();
defineEmits<{
finishTopic: [];
openProfile: [];
generateHelpCard: [];
generateShareDraft: [];
}>();
@@ -43,8 +40,8 @@ const guidanceText = computed(() => {
if (props.entitlement?.lifecycleStatus === "expiring_7" || props.entitlement?.lifecycleStatus === "expiring_30") {
return `当前权益将于 ${formatDate(props.entitlement.expiredAt)} 到期;如需继续使用,可提前联系运营老师确认续期。`;
}
if (exhausted.value) return "本月深度主题使用较多,建议先完成已有功课;如需继续高频陪伴,可联系运营老师确认权益。";
if (nearLimit.value) return "本月深度主题接近上限,可以先沉淀已有主题,继续新的议题。";
if (exhausted.value) return "本月新主题额度已用完;当前对话仍受每日聊天额度管理,如需开启新议题可联系运营老师确认权益。";
if (nearLimit.value) return "本月主题接近上限;只有主动创建新对话才会计入新主题,继续当前对话不会重复扣减。";
return "";
});
</script>
@@ -57,18 +54,6 @@ const guidanceText = computed(() => {
<small v-if="hasEntitlement">{{ entitlement?.name }}</small>
</div>
<div class="session-quota-actions">
<button
v-if="entitlement?.enableGrowthProfile"
type="button"
class="quota-link"
@click="$emit('openProfile')"
>
近期回顾
</button>
<button type="button" class="quota-link" :disabled="finishing" @click="$emit('finishTopic')">
<LoaderCircle v-if="finishing" class="spinning" :size="14" aria-hidden="true" />
{{ finishing ? '沉淀中' : '沉淀本主题' }}
</button>
<button
v-if="entitlement?.allowHelpCard"
type="button"

View File

@@ -3,7 +3,6 @@ import type {
CaptchaResult,
ChatMessage,
ChatSession,
FinishTopicResult,
PracticeReviewResult,
LoginResult,
PeriodicReport,
@@ -91,14 +90,15 @@ export const api = {
request<LoginResult>("/auth/sso/exchange", { method: "POST", body: JSON.stringify({ code }) }),
logout: () => request<null>("/auth/logout", { method: "POST", body: JSON.stringify({}) }),
profile: () => request<UserProfile>("/user/profile"),
createSession: () => request<{ sessionId: number }>("/chat/session", { method: "POST", body: JSON.stringify({}) }),
createSession: (currentSessionId?: number) => request<{ sessionId: number }>("/chat/session", {
method: "POST",
body: JSON.stringify({ currentSessionId: currentSessionId ?? null }),
}),
listSessions: () => request<ChatSession[]>("/chat/session/list"),
history: (sessionId: number) => request<ChatMessage[]>(`/chat/history?sessionId=${sessionId}`),
renameSession: (sessionId: number, title: string) =>
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({}) }),
topicSettlement: (summaryId: number) => request<FinishTopicResult>(`/chat/topic/settlement/${summaryId}`),
generateHelpCard: (sessionId: number) => request<TeacherHelpCard>(
`/chat/session/${sessionId}/help-card`,
{ method: "POST", body: JSON.stringify({}) },

View File

@@ -83,14 +83,6 @@ export interface PeriodicReport {
generatedAt: string;
}
export interface FinishTopicResult {
topic: Record<string, unknown>;
summary: TopicSummary & Record<string, unknown>;
profile: PracticeReview | null;
growthProfileEnabled: boolean;
settlementStatus: string;
}
export interface TeacherHelpCard {
id: number;
userId: number;