feat: 完善周期报告与用户行为分析

- 支持可配置周报月报模板与登录后异步补生成\n- 增加用户行为埋点和后台分析页面\n- 移除主题额度并保留实修回顾结算\n- 修复历史会话续聊上下文丢失
This commit is contained in:
2026-08-19 11:53:34 +08:00
parent 833763c461
commit efe835be81
75 changed files with 3325 additions and 578 deletions

View File

@@ -10,6 +10,7 @@ import PersonalCenterDialog from "./components/PersonalCenterDialog.vue";
import SessionDrawer from "./components/SessionDrawer.vue";
import SessionQuota from "./components/SessionQuota.vue";
import { ApiError, api, clearToken, getToken, saveToken, streamChat } from "./services/api";
import { clearBehaviorEvents, flushBehaviorEvents, trackBehavior, type BehaviorEventCode } from "./services/behaviorTracker";
import type { ChatMessage as ApiMessage, ChatSession, PeriodicReport, PracticeReviewResult, ShareDraft, TeacherHelpCard, UserProfile } from "./types/api";
const user = ref<UserProfile | null>(null);
@@ -50,6 +51,10 @@ const feedbackContent = ref("");
const feedbackSubmitting = ref(false);
const activeAbortController = ref<AbortController | null>(null);
let toastTimer: number | null = null;
let reportPollTimer: number | null = null;
let reportPollStartedAt = 0;
let reportRefreshPending = false;
let trackedPersonalCenterSection: typeof personalCenterSection.value | null = null;
onMounted(bootstrap);
@@ -66,6 +71,7 @@ async function bootstrap() {
saveToken(result.token);
if (result.returnUrl) window.sessionStorage.setItem("ai-kb-sso-return-url", result.returnUrl);
user.value = result.user;
trackBehavior("app_open");
await loadSessions();
statusText.value = "已连接大本营答疑服务";
} catch (error) {
@@ -84,6 +90,7 @@ async function bootstrap() {
}
try {
user.value = await api.profile();
trackBehavior("app_open");
await loadSessions();
statusText.value = "已连接大本营答疑服务";
} catch (error) {
@@ -96,11 +103,13 @@ async function bootstrap() {
onBeforeUnmount(() => {
activeAbortController.value?.abort();
if (toastTimer) window.clearTimeout(toastTimer);
stopReportPolling();
document.body.classList.remove("drawer-open");
});
async function onLoggedIn(profile: UserProfile) {
user.value = profile;
trackBehavior("app_open");
statusText.value = "已连接大本营答疑服务";
try {
await loadSessions();
@@ -114,7 +123,7 @@ async function loadSessions() {
try {
sessions.value = await api.listSessions();
if (sessions.value.length === 0) {
await createSession();
await createSession(false);
return;
}
await selectSession(sessions.value[0].id, true);
@@ -123,7 +132,7 @@ async function loadSessions() {
}
}
async function createSession() {
async function createSession(trackClick = true) {
if (sessionOperationPending.value) return;
composerKey.value += 1;
const activeSession = sessions.value.find((session) => session.id === activeSessionId.value);
@@ -131,6 +140,7 @@ async function createSession() {
return;
}
sessionOperationPending.value = true;
if (trackClick) trackBehavior("new_chat_click");
try {
const result = await api.createSession(activeSessionId.value ?? undefined);
sessions.value = await api.listSessions();
@@ -148,6 +158,7 @@ async function selectSession(sessionId: number, force = false) {
return;
}
loadingSession.value = true;
if (!force) trackBehavior("switch_chat_click", { type: "session", id: sessionId });
try {
const history = await api.history(sessionId);
activeSessionId.value = sessionId;
@@ -170,6 +181,7 @@ async function send(message: string, complete: (success: boolean) => void) {
return;
}
const now = new Date().toISOString();
trackBehavior("send_question_click", { type: "session", id: sessionId });
const userMessage: DisplayMessage = { id: `user-${Date.now()}`, role: "user", content: message, createdAt: now };
const assistantMessage: DisplayMessage = {
id: `assistant-${Date.now()}`,
@@ -191,6 +203,8 @@ async function retryMessage(messageId: string) {
const assistantIndex = messages.value.findIndex((item) => item.id === messageId);
const assistantMessage = messages.value[assistantIndex];
if (!assistantMessage?.retryQuestion) return;
const numericMessageId = Number(messageId);
trackBehavior("retry_answer_click", Number.isInteger(numericMessageId) ? { type: "message", id: numericMessageId } : undefined);
await messageList.value?.scrollToMessage(messageId);
await runGeneration(sessionId, assistantMessage.retryQuestion, assistantIndex, true);
}
@@ -267,11 +281,13 @@ function openFeedback(messageId: string) {
feedbackMessageId.value = parsed;
feedbackContent.value = "";
feedbackDialogOpen.value = true;
trackBehavior("feedback_dialog_open", { type: "message", id: parsed });
}
async function submitFeedback() {
const content = feedbackContent.value.trim();
if (!feedbackMessageId.value || !content || feedbackSubmitting.value) return;
trackBehavior("feedback_submit_click", { type: "message", id: feedbackMessageId.value });
feedbackSubmitting.value = true;
try {
await api.submitFeedback(feedbackMessageId.value, content);
@@ -294,6 +310,7 @@ function chatErrorMessage(error: unknown) {
async function stop() {
if (!activeSessionId.value || !sending.value) return;
trackBehavior("stop_answer_click", { type: "session", id: activeSessionId.value });
activeAbortController.value?.abort();
try {
await api.stop(activeSessionId.value);
@@ -304,12 +321,14 @@ async function stop() {
async function generateHelpCard() {
if (!activeSessionId.value || sending.value || generatingHelpCard.value) return;
trackBehavior("help_card_generate_click", { type: "session", id: activeSessionId.value });
generatingHelpCard.value = true;
try {
const result = await api.generateHelpCard(activeSessionId.value);
helpCard.value = result;
helpCardContent.value = result.content;
helpCardDialogOpen.value = true;
trackBehavior("help_card_dialog_open", { type: "help_card", id: result.id });
showToast("求助卡已生成,可编辑后复制给老师");
await refreshProfile();
} catch (error) {
@@ -325,6 +344,7 @@ async function copyHelpCard() {
return;
}
try {
if (helpCard.value) trackBehavior("help_card_copy_click", { type: "help_card", id: helpCard.value.id });
await copyText(helpCardContent.value);
if (helpCard.value) {
helpCard.value = await api.markHelpCardCopied(helpCard.value.id);
@@ -337,12 +357,14 @@ async function copyHelpCard() {
async function generateShareDraft() {
if (!activeSessionId.value || sending.value || generatingShareDraft.value) return;
trackBehavior("share_draft_generate_click", { type: "session", id: activeSessionId.value });
generatingShareDraft.value = true;
try {
const result = await api.generateShareDraft(activeSessionId.value);
shareDraft.value = result;
shareDraftContent.value = result.content;
shareDraftDialogOpen.value = true;
trackBehavior("share_draft_dialog_open", { type: "share_draft", id: result.id });
showToast("分享稿已生成,可编辑后复制");
await refreshProfile();
} catch (error) {
@@ -358,6 +380,7 @@ async function copyShareDraft() {
return;
}
try {
if (shareDraft.value) trackBehavior("share_draft_copy_click", { type: "share_draft", id: shareDraft.value.id });
await copyText(shareDraftContent.value);
if (shareDraft.value) {
shareDraft.value = await api.markShareDraftCopied(shareDraft.value.id);
@@ -370,6 +393,7 @@ async function copyShareDraft() {
async function copyHistoryHelpCard(card: TeacherHelpCard) {
try {
trackBehavior("help_card_copy_click", { type: "help_card", id: card.id });
await copyText(card.content);
const updated = await api.markHelpCardCopied(card.id);
helpCardHistory.value = helpCardHistory.value.map((item) => item.id === updated.id ? updated : item);
@@ -381,6 +405,7 @@ async function copyHistoryHelpCard(card: TeacherHelpCard) {
async function copyHistoryShareDraft(draft: ShareDraft) {
try {
trackBehavior("share_draft_copy_click", { type: "share_draft", id: draft.id });
await copyText(draft.content);
const updated = await api.markShareDraftCopied(draft.id);
shareDraftHistory.value = shareDraftHistory.value.map((item) => item.id === updated.id ? updated : item);
@@ -393,6 +418,7 @@ async function copyHistoryShareDraft(draft: ShareDraft) {
async function deleteHistoryHelpCard(cardId: number, done: (success: boolean) => void) {
if (cardOperationPending.value) return;
cardOperationPending.value = true;
trackBehavior("help_card_delete_click", { type: "help_card", id: cardId });
try {
await api.deleteHelpCard(cardId);
helpCardHistory.value = helpCardHistory.value.filter((item) => item.id !== cardId);
@@ -410,6 +436,7 @@ async function deleteHistoryHelpCard(cardId: number, done: (success: boolean) =>
async function deleteHistoryShareDraft(draftId: number, done: (success: boolean) => void) {
if (cardOperationPending.value) return;
cardOperationPending.value = true;
trackBehavior("share_draft_delete_click", { type: "share_draft", id: draftId });
try {
await api.deleteShareDraft(draftId);
shareDraftHistory.value = shareDraftHistory.value.filter((item) => item.id !== draftId);
@@ -427,6 +454,7 @@ async function deleteHistoryShareDraft(draftId: number, done: (success: boolean)
async function openPersonalCenter(section: "overview" | "review" | "reports" | "records" = "overview") {
personalCenterSection.value = section;
personalCenterOpen.value = true;
trackPersonalCenterSection(section);
personalCenterLoading.value = true;
try {
const [latestUser, profileResult, helpCards, shareDrafts, reports] = await Promise.all([
@@ -441,6 +469,7 @@ async function openPersonalCenter(section: "overview" | "review" | "reports" | "
helpCardHistory.value = helpCards;
shareDraftHistory.value = shareDrafts;
reportHistory.value = reports;
if (section === "reports") startReportPolling();
} catch (error) {
handleError(error, "个人中心加载失败");
} finally {
@@ -448,9 +477,68 @@ async function openPersonalCenter(section: "overview" | "review" | "reports" | "
}
}
function closePersonalCenter() {
personalCenterOpen.value = false;
stopReportPolling();
trackedPersonalCenterSection = null;
}
function onPersonalCenterSectionChange(section: "overview" | "review" | "reports" | "records") {
personalCenterSection.value = section;
trackPersonalCenterSection(section);
if (section === "reports") {
void refreshReportsAndPoll(false);
} else {
stopReportPolling();
}
}
async function refreshReportsAndPoll(showFailure: boolean) {
trackBehavior("reports_refresh_click");
await refreshPeriodicReports(showFailure, true);
startReportPolling();
}
async function refreshPeriodicReports(showFailure = false, ensure = false) {
if (reportRefreshPending || !personalCenterOpen.value) return;
reportRefreshPending = true;
try {
reportHistory.value = await api.periodicReports(20, ensure);
} catch (error) {
if (showFailure) handleError(error, "周期报告状态刷新失败");
} finally {
reportRefreshPending = false;
}
}
function startReportPolling() {
stopReportPolling();
if (!personalCenterOpen.value || personalCenterSection.value !== "reports") return;
const hasGenerating = reportHistory.value.some((item) => item.status === "pending" || item.status === "running");
if (!hasGenerating) return;
reportPollStartedAt = Date.now();
const poll = async () => {
if (!personalCenterOpen.value || personalCenterSection.value !== "reports") return;
await refreshPeriodicReports(false);
const stillGenerating = reportHistory.value.some((item) => item.status === "pending" || item.status === "running");
if (stillGenerating && Date.now() - reportPollStartedAt < 60_000) {
reportPollTimer = window.setTimeout(poll, 4_000);
} else {
reportPollTimer = null;
}
};
reportPollTimer = window.setTimeout(poll, 4_000);
}
function stopReportPolling() {
if (reportPollTimer !== null) window.clearTimeout(reportPollTimer);
reportPollTimer = null;
}
async function renameSession(sessionId: number, title: string, done: (success: boolean) => void) {
if (sessionOperationPending.value) return;
sessionOperationPending.value = true;
trackBehavior("rename_chat_click", { type: "session", id: sessionId });
try {
await api.renameSession(sessionId, title);
await refreshSessionList();
@@ -467,6 +555,7 @@ async function renameSession(sessionId: number, title: string, done: (success: b
async function deleteSession(sessionId: number, done: (success: boolean) => void) {
if (sessionOperationPending.value) return;
sessionOperationPending.value = true;
trackBehavior("delete_chat_click", { type: "session", id: sessionId });
try {
await api.deleteSession(sessionId);
sessions.value = await api.listSessions();
@@ -479,7 +568,7 @@ async function deleteSession(sessionId: number, done: (success: boolean) => void
if (nextSession) await selectSession(nextSession.id, true);
else {
sessionOperationPending.value = false;
await createSession();
await createSession(false);
}
}
} catch (error) {
@@ -491,6 +580,11 @@ async function deleteSession(sessionId: number, done: (success: boolean) => void
}
async function confirmLogout() {
trackBehavior("logout_confirm_click");
await Promise.race([
flushBehaviorEvents(),
new Promise<void>((resolve) => window.setTimeout(resolve, 800)),
]);
sessionOperationPending.value = true;
try {
await api.logout();
@@ -503,6 +597,29 @@ async function confirmLogout() {
}
}
function openHistory() {
drawerOpen.value = true;
trackBehavior("history_open");
}
function openLogoutDialog() {
closePersonalCenter();
logoutDialogOpen.value = true;
trackBehavior("logout_dialog_open");
}
function trackPersonalCenterSection(section: "overview" | "review" | "reports" | "records") {
if (trackedPersonalCenterSection === section) return;
trackedPersonalCenterSection = section;
const codes: Record<typeof section, BehaviorEventCode> = {
overview: "personal_center_overview_open",
review: "personal_center_review_open",
reports: "personal_center_reports_open",
records: "personal_center_cards_open",
};
trackBehavior(codes[section]);
}
async function refreshSessionList() {
sessions.value = await api.listSessions();
}
@@ -532,6 +649,8 @@ function handleError(error: unknown, fallback: string) {
}
function clearUserState() {
clearBehaviorEvents();
trackedPersonalCenterSection = null;
clearToken();
user.value = null;
sessions.value = [];
@@ -576,7 +695,7 @@ async function copyText(text: string) {
<ChatHeader
:user="user"
:status-text="statusText"
@open-history="drawerOpen = true"
@open-history="openHistory"
@open-personal-center="openPersonalCenter('overview')"
/>
<SessionQuota
@@ -596,7 +715,7 @@ async function copyText(text: string) {
@retry="retryMessage"
@feedback="openFeedback"
/>
<ChatComposer :key="composerKey" :loading="sending" :disabled="!activeSessionId || loadingSession" @send="send" @stop="stop" />
<ChatComposer :key="composerKey" :loading="sending" :disabled="!activeSessionId || loadingSession" @send="send" @stop="stop" @behavior="trackBehavior" />
<SessionDrawer
:open="drawerOpen"
:sessions="sessions"
@@ -632,8 +751,10 @@ async function copyText(text: string) {
:help-cards="helpCardHistory"
:share-drafts="shareDraftHistory"
:operation-pending="cardOperationPending"
@close="personalCenterOpen = false"
@logout="personalCenterOpen = false; logoutDialogOpen = true"
@close="closePersonalCenter"
@logout="openLogoutDialog"
@section-change="onPersonalCenterSectionChange"
@refresh-reports="refreshReportsAndPoll(true)"
@copy-help-card="copyHistoryHelpCard"
@copy-share-draft="copyHistoryShareDraft"
@delete-help-card="deleteHistoryHelpCard"

View File

@@ -4,6 +4,7 @@ import { nextTick, onBeforeUnmount, onMounted, ref } from "vue";
import { useVoiceRecorder } from "../composables/useVoiceRecorder";
import { api, transcribeVoice } from "../services/api";
import type { BehaviorEventCode } from "../services/behaviorTracker";
defineProps<{
loading: boolean;
@@ -13,6 +14,7 @@ defineProps<{
const emit = defineEmits<{
send: [message: string, complete: (success: boolean) => void];
stop: [];
behavior: [eventCode: BehaviorEventCode];
}>();
const input = ref("");
@@ -47,7 +49,8 @@ const { recording, elapsedSeconds, start: startRecording, stop: stopRecording, c
},
);
function cancelTranscription() {
function cancelTranscription(track = true) {
if (track) emit("behavior", "voice_cancel_click");
transcriptionVersion += 1;
transcriptionController?.abort();
transcriptionController = null;
@@ -55,7 +58,7 @@ function cancelTranscription() {
voiceError.value = "";
}
onBeforeUnmount(cancelTranscription);
onBeforeUnmount(() => cancelTranscription(false));
async function refreshVoiceConfig() {
try {
@@ -108,6 +111,7 @@ function onKeydown(event: KeyboardEvent) {
async function beginVoiceInput() {
voiceError.value = "";
emit("behavior", "voice_start_click");
try {
await startRecording(voiceMaxDuration.value);
} catch (error) {
@@ -118,6 +122,16 @@ async function beginVoiceInput() {
}
}
async function finishVoiceInput() {
emit("behavior", "voice_finish_click");
await stopRecording();
}
function cancelVoiceInput() {
emit("behavior", "voice_cancel_click");
cancelRecording();
}
async function insertTranscription(text: string) {
const element = textarea.value;
const start = element?.selectionStart ?? input.value.length;
@@ -156,10 +170,10 @@ function formatSeconds(seconds: number) {
@input="resize"
@keydown="onKeydown"
/>
<button v-if="recording" type="button" class="composer-voice cancel" aria-label="取消录音" @click="cancelRecording">
<button v-if="recording" type="button" class="composer-voice cancel" aria-label="取消录音" @click="cancelVoiceInput">
<X :size="18" aria-hidden="true" />
</button>
<button v-else-if="transcribing && !loading" type="button" class="composer-voice transcribing" aria-label="取消语音识别" @click="cancelTranscription">
<button v-else-if="transcribing && !loading" type="button" class="composer-voice transcribing" aria-label="取消语音识别" @click="cancelTranscription()">
<LoaderCircle class="spinning" :size="19" aria-hidden="true" />
<span>识别中 · 取消</span>
</button>
@@ -167,7 +181,7 @@ function formatSeconds(seconds: number) {
<Mic :size="19" aria-hidden="true" />
<span>语音</span>
</button>
<button v-if="recording" type="button" class="composer-voice finish" aria-label="完成录音" @click="stopRecording">
<button v-if="recording" type="button" class="composer-voice finish" aria-label="完成录音" @click="finishVoiceInput">
<Square :size="16" fill="currentColor" aria-hidden="true" />
<span>完成</span>
</button>

View File

@@ -8,6 +8,7 @@ import {
LifeBuoy,
LogOut,
PackageCheck,
RefreshCw,
Share2,
Sparkles,
Trash2,
@@ -38,6 +39,8 @@ const emit = defineEmits<{
copyShareDraft: [draft: ShareDraft];
deleteHelpCard: [cardId: number, done: (success: boolean) => void];
deleteShareDraft: [draftId: number, done: (success: boolean) => void];
sectionChange: [section: CenterSection];
refreshReports: [];
}>();
const activeSection = ref<CenterSection>(props.initialSection);
@@ -52,17 +55,13 @@ 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"));
const topicUsed = computed(() => entitlement.value?.monthlyTopicUsed ?? 0);
const topicLimit = computed(() => entitlement.value?.monthlyTopicLimit ?? null);
const topicRemaining = computed(() => entitlement.value?.monthlyTopicRemaining ?? null);
const topicPercent = computed(() => usagePercent(topicUsed.value, topicLimit.value));
const dailyRemaining = computed(() => Math.max(0, props.user.dailyLimit - props.user.todayUsed));
const dailyPercent = computed(() => usagePercent(props.user.todayUsed, props.user.dailyLimit));
const capabilities = computed(() => [
{ label: "知识问答", enabled: true },
{ label: "近期实修回顾", enabled: Boolean(entitlement.value?.enableGrowthProfile) },
{ label: "周期实修回顾", enabled: Boolean(entitlement.value?.enablePeriodicReports) },
{ label: "实修回顾", enabled: Boolean(entitlement.value?.enableGrowthProfile) },
{ label: "周期报告", enabled: Boolean(entitlement.value?.enablePeriodicReports) },
{ label: "老师求助卡", enabled: Boolean(entitlement.value?.allowHelpCard) },
{ label: "班级分享稿", enabled: Boolean(entitlement.value?.allowShareDraft) },
]);
@@ -86,6 +85,8 @@ watch(
{ immediate: true },
);
watch(activeSection, (section) => emit("sectionChange", section), { immediate: true });
watch(
[canHelpCards, canShareDrafts],
() => {
@@ -120,6 +121,16 @@ function settlementStatusLabel(status: string) {
}[status] || status;
}
function reportStatusLabel(status: string) {
return {
pending: "等待生成",
running: "正在生成",
success: "已完成",
failed: "生成失败",
empty: "暂无可整理内容",
}[status] || status;
}
function submitDeleteCard() {
if (!deletingCard.value || props.operationPending) return;
const target = deletingCard.value;
@@ -185,14 +196,6 @@ function submitDeleteCard() {
<section class="usage-section">
<h3>使用情况</h3>
<article class="usage-card">
<div>
<span>本月主题</span>
<strong>{{ topicLimit === null ? `${topicUsed} 个 · 不限` : `${topicUsed}/${topicLimit}` }}</strong>
</div>
<div class="usage-track" aria-hidden="true"><i :style="{ width: `${topicPercent}%` }" /></div>
<small>{{ topicRemaining === null ? '当前权益不限制本月主题数量' : `本月还可开启 ${topicRemaining} 个主题` }}</small>
</article>
<article class="usage-card">
<div>
<span>今日问答</span>
@@ -252,22 +255,48 @@ function submitDeleteCard() {
<h3>周期实修报告</h3>
<p>系统会根据已经沉淀的主题自动整理完整自然周和自然月的实修回顾</p>
</div>
<span>{{ reports.length }} </span>
<button type="button" class="report-refresh" :disabled="loading" @click="$emit('refreshReports')">
<RefreshCw :size="14" :class="{ spinning: loading }" aria-hidden="true" />
<span>{{ loading ? "刷新中" : "刷新状态" }}</span>
</button>
</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" />
<span>{{ item.title }}</span>
<small>{{ item.reportTypeLabel }}</small>
</summary>
<time>{{ formatDate(item.periodStart) }} {{ formatDate(item.periodEnd) }}</time>
<pre>{{ item.content }}</pre>
</details>
<section v-else-if="reports.length" class="record-group report-history-list" aria-live="polite">
<template v-for="item in reports" :key="item.id">
<article v-if="item.status === 'pending' || item.status === 'running'" class="report-record report-state generating">
<span class="report-spinner" aria-hidden="true" />
<div>
<strong>{{ item.title }}</strong>
<small>{{ reportStatusLabel(item.status) }}你可以先使用其他功能稍后回来查看</small>
</div>
</article>
<article v-else-if="item.status === 'failed'" class="report-record report-state failed">
<CalendarDays :size="17" aria-hidden="true" />
<div>
<strong>{{ item.title }}</strong>
<small>本期回顾暂时未能生成可以稍后刷新或联系运营老师</small>
</div>
</article>
<article v-else-if="item.status === 'empty'" class="report-record report-state empty">
<CalendarDays :size="17" aria-hidden="true" />
<div>
<strong>{{ item.title }}</strong>
<small>这个周期还没有可用于整理的对话记录</small>
</div>
</article>
<details v-else class="report-record">
<summary>
<CalendarDays :size="16" aria-hidden="true" />
<span>{{ item.title }}</span>
<small>{{ item.reportTypeLabel }}</small>
</summary>
<time>{{ formatDate(item.periodStart) }} {{ formatDate(item.periodEnd) }}</time>
<pre>{{ item.content }}</pre>
</details>
</template>
</section>
<p v-else class="center-empty">暂时还没有周期报告完成主题沉淀系统会在完整自然周或自然月结束后自动生成并展示在这里</p>
<p v-else class="center-empty">暂时还没有周期报告完成对话回顾整理系统会在完整自然周或自然月结束后自动生成并展示在这里</p>
</div>
<div v-else class="center-section records-section">
@@ -455,6 +484,21 @@ function submitDeleteCard() {
.compact-record { padding: 11px 12px; }
.compact-record p { margin: 5px 0 0; color: var(--chat-muted); font-size: 12px; line-height: 1.65; white-space: pre-wrap; }
.report-record { overflow: hidden; }
.report-refresh { flex: 0 0 auto; min-width: 94px; min-height: 34px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 0 12px; border: 1px solid rgba(31, 118, 93, .24); border-radius: 10px; background: #fff; color: var(--chat-brand-dark); box-shadow: 0 2px 8px rgba(27, 68, 55, .04); cursor: pointer; font-size: 12px; font-weight: 700; line-height: 1; white-space: nowrap; transition: border-color .18s ease, background .18s ease, box-shadow .18s ease, transform .18s ease; }
.report-refresh:hover:not(:disabled) { border-color: rgba(31, 118, 93, .42); background: var(--chat-brand-soft); box-shadow: 0 4px 12px rgba(27, 68, 55, .08); }
.report-refresh:active:not(:disabled) { transform: translateY(1px); }
.report-refresh:focus-visible { outline: 2px solid rgba(31, 118, 93, .28); outline-offset: 2px; }
.report-refresh:disabled { cursor: default; opacity: .58; }
.report-refresh .spinning { animation: report-spin .8s linear infinite; }
.report-state { display: grid; grid-template-columns: 22px minmax(0, 1fr); align-items: center; gap: 9px; padding: 12px; }
.report-state div { display: grid; gap: 4px; min-width: 0; }
.report-state strong { overflow: hidden; color: var(--chat-brand-dark); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.report-state small { color: var(--chat-weak); font-size: 11px; line-height: 1.5; }
.report-state.failed { border-color: #ead6d1; background: #fff9f7; }
.report-state.empty { background: #f7f9f8; }
.report-spinner { width: 16px; height: 16px; border: 2px solid #dce9e5; border-top-color: var(--chat-brand); border-radius: 50%; animation: report-spin .8s linear infinite; }
@keyframes report-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .report-spinner, .report-refresh .spinning { animation: none; } }
.report-record summary { min-height: 44px; display: grid; grid-template-columns: 20px minmax(0, 1fr) auto; align-items: center; gap: 7px; padding: 0 11px; color: var(--chat-brand-dark); cursor: pointer; }
.report-record summary span { overflow: hidden; font-size: 12px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
.report-record summary small { color: var(--chat-weak); font-size: 10px; }

View File

@@ -25,9 +25,9 @@ defineEmits<{ close: [] }>();
<div v-else class="policy-copy">
<p>我们重视你的个人信息安全并按照最小必要原则处理登录和服务数据</p>
<h3>收集的信息</h3>
<p>登录时处理手机号和验证码使用过程中记录必要的会话问答和安全审计信息</p>
<p>登录时处理手机号和验证码使用过程中记录必要的会话问答和安全审计信息为了了解功能使用情况还会记录打开页面或弹窗点击关键按钮等最小必要的交互事件不在行为记录中保存你输入的文字或聊天正文</p>
<h3>使用目的</h3>
<p>上述信息仅用于身份验证账号登录提供答疑服务保障系统安全和排查服务问题</p>
<p>上述信息仅用于身份验证账号登录提供答疑服务统计功能使用频率优化产品体验保障系统安全和排查服务问题行为事件默认仅保留最近30天</p>
<h3>信息保护</h3>
<p>我们采取访问控制传输保护日志审计等措施保护信息不会将验证码或登录凭证用于无关用途</p>
<h3>你的权利</h3>

View File

@@ -18,15 +18,13 @@ defineEmits<{
}>();
const hasEntitlement = computed(() => Boolean(props.entitlement));
const displayUsed = computed(() => props.entitlement?.monthlyTopicUsed ?? props.used);
const displayLimit = computed(() => props.entitlement?.monthlyTopicLimit ?? props.limit);
const exhausted = computed(() => displayLimit.value !== null && displayLimit.value > 0 && displayUsed.value >= displayLimit.value);
const nearLimit = computed(() => displayLimit.value !== null && displayLimit.value > 0 && displayUsed.value / displayLimit.value >= 0.8);
const title = computed(() => props.entitlement ? "本月主题使用情况" : "今日陪伴使用情况");
const displayUsed = computed(() => props.used);
const displayLimit = computed(() => props.limit);
const exhausted = computed(() => displayLimit.value > 0 && displayUsed.value >= displayLimit.value);
const title = computed(() => "今日问答使用情况");
const usageText = computed(() => {
if (displayLimit.value === null) return props.entitlement ? "本月不限" : "不限";
if (props.entitlement) return `已使用 ${displayUsed.value}/${displayLimit.value} 个主题`;
return `今日 ${displayUsed.value}/${displayLimit.value}`;
if (displayLimit.value <= 0) return "今日不可用";
return `今日 ${displayUsed.value}/${displayLimit.value} `;
});
function formatDate(value?: string | null) {
if (!value) return "";
@@ -40,8 +38,7 @@ 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 "今日问答次数已用完,次日会自动恢复;如需继续使用,可联系运营老师确认权益。";
return "";
});
</script>

View File

@@ -116,10 +116,21 @@ export const api = {
shareDrafts: (limit = 20) => request<ShareDraft[]>(`/chat/share-draft/list?limit=${limit}`),
deleteShareDraft: (draftId: number) => request<null>(`/chat/share-draft/${draftId}`, { method: "DELETE" }),
practiceReview: () => request<PracticeReviewResult>("/user/growth-profile"),
periodicReports: (limit = 10) => request<PeriodicReport[]>(`/user/periodic-report/list?limit=${limit}`),
periodicReports: (limit = 10, ensure = false) => request<PeriodicReport[]>(`/user/periodic-report/list?limit=${limit}&ensure=${ensure ? "true" : "false"}`),
stop: (sessionId: number) => request<null>("/chat/stop", { method: "POST", body: JSON.stringify({ sessionId }) }),
voiceConfig: () => request<VoiceInputConfig>("/voice/config"),
submitFeedback: (messageId: number, content: string) => request<{ id: number }>("/feedback", { method: "POST", body: JSON.stringify({ messageId, content }) }),
recordBehaviorEvents: (events: Array<{
clientEventId: string;
eventCode: string;
targetType?: string;
targetId?: number;
occurredAt: string;
}>) => request<{ accepted: number }>("/behavior/events", {
method: "POST",
body: JSON.stringify({ events }),
keepalive: true,
}),
};
export async function transcribeVoice(audio: Blob, signal?: AbortSignal): Promise<VoiceTranscriptionResult> {

View File

@@ -0,0 +1,120 @@
import { api, getToken } from "./api";
export type BehaviorEventCode =
| "app_open"
| "history_open"
| "personal_center_overview_open"
| "personal_center_review_open"
| "personal_center_reports_open"
| "personal_center_cards_open"
| "feedback_dialog_open"
| "help_card_dialog_open"
| "share_draft_dialog_open"
| "logout_dialog_open"
| "new_chat_click"
| "switch_chat_click"
| "rename_chat_click"
| "delete_chat_click"
| "send_question_click"
| "stop_answer_click"
| "retry_answer_click"
| "voice_start_click"
| "voice_finish_click"
| "voice_cancel_click"
| "feedback_submit_click"
| "help_card_generate_click"
| "help_card_copy_click"
| "help_card_delete_click"
| "share_draft_generate_click"
| "share_draft_copy_click"
| "share_draft_delete_click"
| "reports_refresh_click"
| "logout_confirm_click";
type TargetType = "session" | "message" | "help_card" | "share_draft" | "report";
interface QueuedBehaviorEvent {
clientEventId: string;
eventCode: BehaviorEventCode;
targetType?: TargetType;
targetId?: number;
occurredAt: string;
}
const queue: QueuedBehaviorEvent[] = [];
let flushTimer: number | null = null;
let flushing = false;
let retryDelayMs = 2_000;
let queueGeneration = 0;
export function trackBehavior(eventCode: BehaviorEventCode, target?: { type: TargetType; id: number }) {
if (!getToken()) return;
try {
queue.push({
clientEventId: createEventId(),
eventCode,
...(target ? { targetType: target.type, targetId: target.id } : {}),
occurredAt: new Date().toISOString(),
});
} catch {
// 行为分析永远不应影响用户的核心操作。
return;
}
if (queue.length >= 10) {
void flushBehaviorEvents();
return;
}
if (flushTimer === null) {
flushTimer = window.setTimeout(() => void flushBehaviorEvents(), 800);
}
}
export async function flushBehaviorEvents() {
if (flushing || queue.length === 0 || !getToken()) return;
if (flushTimer !== null) {
window.clearTimeout(flushTimer);
flushTimer = null;
}
const batch = queue.splice(0, 50);
const generation = queueGeneration;
flushing = true;
try {
await api.recordBehaviorEvents(batch);
retryDelayMs = 2_000;
} catch {
if (generation === queueGeneration) {
queue.unshift(...batch);
if (queue.length > 100) queue.splice(0, queue.length - 100);
retryDelayMs = Math.min(retryDelayMs * 2, 30_000);
}
} finally {
flushing = false;
if (generation === queueGeneration && queue.length > 0 && flushTimer === null) {
flushTimer = window.setTimeout(() => void flushBehaviorEvents(), retryDelayMs);
}
}
}
export function clearBehaviorEvents() {
queueGeneration += 1;
queue.length = 0;
if (flushTimer !== null) window.clearTimeout(flushTimer);
flushTimer = null;
retryDelayMs = 2_000;
}
function createEventId() {
if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
const bytes = new Uint8Array(16);
if (typeof crypto.getRandomValues === "function") crypto.getRandomValues(bytes);
else for (let index = 0; index < bytes.length; index += 1) bytes[index] = Math.floor(Math.random() * 256);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
window.addEventListener("pagehide", () => void flushBehaviorEvents());
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") void flushBehaviorEvents();
});

View File

@@ -21,14 +21,10 @@ export interface UserEntitlementSummary {
planType: string;
description?: string | null;
validityDays?: number | null;
monthlyTopicLimit: number | null;
monthlyTopicUsed: number;
monthlyTopicRemaining: number | null;
enableGrowthProfile: boolean;
enablePeriodicReports: boolean;
allowHelpCard: boolean;
allowShareDraft: boolean;
deductQuota: boolean;
effectiveAt?: string | null;
expiredAt?: string | null;
source: string;
@@ -79,7 +75,9 @@ export interface PeriodicReport {
periodEnd: string;
title: string;
content: string;
status: "success" | "failed" | "empty" | string;
status: "pending" | "running" | "success" | "failed" | "empty" | string;
nextRunAt?: string | null;
finishedAt?: string | null;
generatedAt: string;
}