feat: refine topic insights and learner experience
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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({}) },
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user