feat: 完善周期报告与用户行为分析
- 支持可配置周报月报模板与登录后异步补生成\n- 增加用户行为埋点和后台分析页面\n- 移除主题额度并保留实修回顾结算\n- 修复历史会话续聊上下文丢失
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user