feat: 完善人工关注与内容合规配置

This commit is contained in:
2026-08-24 17:49:56 +08:00
parent 6ffdd2c259
commit 910e67bd89
53 changed files with 1808 additions and 49 deletions

View File

@@ -9,11 +9,14 @@ import MessageList, { type DisplayMessage } from "./components/MessageList.vue";
import PersonalCenterDialog from "./components/PersonalCenterDialog.vue";
import SessionDrawer from "./components/SessionDrawer.vue";
import SessionQuota from "./components/SessionQuota.vue";
import SiteFilingFooter from "./components/SiteFilingFooter.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";
import type { ChatMessage as ApiMessage, ChatSession, PeriodicReport, PracticeReviewResult, PublicSiteConfig, ShareDraft, TeacherHelpCard, UserProfile } from "./types/api";
import { withAiGeneratedNotice } from "./utils/aiGenerated";
const user = ref<UserProfile | null>(null);
const publicSiteConfig = ref<PublicSiteConfig>({ filingText: "", filingUrl: "" });
const sessions = ref<ChatSession[]>([]);
const activeSessionId = ref<number | null>(null);
const composerKey = ref(0);
@@ -56,7 +59,18 @@ let reportPollStartedAt = 0;
let reportRefreshPending = false;
let trackedPersonalCenterSection: typeof personalCenterSection.value | null = null;
onMounted(bootstrap);
onMounted(() => {
void loadPublicSiteConfig();
void bootstrap();
});
async function loadPublicSiteConfig() {
try {
publicSiteConfig.value = await api.publicSiteConfig();
} catch {
publicSiteConfig.value = { filingText: "", filingUrl: "" };
}
}
async function bootstrap() {
const search = new URLSearchParams(window.location.search);
@@ -345,7 +359,7 @@ async function copyHelpCard() {
}
try {
if (helpCard.value) trackBehavior("help_card_copy_click", { type: "help_card", id: helpCard.value.id });
await copyText(helpCardContent.value);
await copyText(withAiGeneratedNotice(helpCardContent.value));
if (helpCard.value) {
helpCard.value = await api.markHelpCardCopied(helpCard.value.id);
}
@@ -381,7 +395,7 @@ async function copyShareDraft() {
}
try {
if (shareDraft.value) trackBehavior("share_draft_copy_click", { type: "share_draft", id: shareDraft.value.id });
await copyText(shareDraftContent.value);
await copyText(withAiGeneratedNotice(shareDraftContent.value));
if (shareDraft.value) {
shareDraft.value = await api.markShareDraftCopied(shareDraft.value.id);
}
@@ -394,7 +408,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);
await copyText(withAiGeneratedNotice(card.content));
const updated = await api.markHelpCardCopied(card.id);
helpCardHistory.value = helpCardHistory.value.map((item) => item.id === updated.id ? updated : item);
showToast("求助卡已复制,可再次粘贴给老师");
@@ -406,7 +420,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);
await copyText(withAiGeneratedNotice(draft.content));
const updated = await api.markShareDraftCopied(draft.id);
shareDraftHistory.value = shareDraftHistory.value.map((item) => item.id === updated.id ? updated : item);
showToast("分享稿已复制,可再次粘贴到班级群");
@@ -688,9 +702,9 @@ async function copyText(text: string) {
<template>
<main class="app-shell">
<section class="phone-frame" :class="{ 'login-mode': !user && !booting, 'chat-mode': Boolean(user) }">
<section class="phone-frame" :class="{ 'login-mode': !user && !booting, 'chat-mode': Boolean(user), 'has-filing': Boolean(publicSiteConfig.filingText) }">
<div v-if="booting" class="booting">正在启动...</div>
<LoginPanel v-else-if="!user" @logged-in="onLoggedIn" />
<LoginPanel v-else-if="!user" :filing-text="publicSiteConfig.filingText" :filing-url="publicSiteConfig.filingUrl" @logged-in="onLoggedIn" />
<template v-else>
<ChatHeader
:user="user"
@@ -716,6 +730,7 @@ async function copyText(text: string) {
@feedback="openFeedback"
/>
<ChatComposer :key="composerKey" :loading="sending" :disabled="!activeSessionId || loadingSession" @send="send" @stop="stop" @behavior="trackBehavior" />
<SiteFilingFooter :text="publicSiteConfig.filingText" :url="publicSiteConfig.filingUrl" />
<SessionDrawer
:open="drawerOpen"
:sessions="sessions"
@@ -763,6 +778,7 @@ async function copyText(text: string) {
<AppDialog v-if="helpCardDialogOpen" title="老师求助卡" labelled-by="help-card-title" @close="helpCardDialogOpen = false">
<section class="help-card-dialog">
<span class="ai-generated-badge">AI生成</span>
<p>这不是转人工工单系统不会自动发送给老师你可以按真实情况删改后自行复制给老师或班级群确认</p>
<textarea v-model="helpCardContent" aria-label="求助卡内容" />
</section>
@@ -776,6 +792,7 @@ async function copyText(text: string) {
<AppDialog v-if="shareDraftDialogOpen" title="班级分享稿" labelled-by="share-draft-title" @close="shareDraftDialogOpen = false">
<section class="help-card-dialog">
<span class="ai-generated-badge">AI生成</span>
<p>这只是分享草稿系统不会自动发送到任何群请删掉不想公开的隐私内容并按自己的真实状态修改后再复制</p>
<textarea v-model="shareDraftContent" aria-label="班级分享稿内容" />
</section>

View File

@@ -111,6 +111,7 @@ const displayTime = computed(() => {
</div>
</template>
<div v-else class="message-content">{{ renderedContent }}</div>
<span v-if="role === 'assistant' && renderedContent" class="ai-generated-badge">AI生成</span>
<time v-if="displayTime" :datetime="createdAt">{{ displayTime }}</time>
<button v-if="role === 'assistant' && !streaming && !errorMessage && /^\d+$/.test(messageId)" type="button" class="message-feedback-button" @click="emit('feedback')">
<MessageSquareWarning :size="14" aria-hidden="true" />

View File

@@ -14,6 +14,12 @@ import type { UserProfile } from "../types/api";
import AppDialog from "./AppDialog.vue";
import HelpDialog from "./HelpDialog.vue";
import PolicyDialog from "./PolicyDialog.vue";
import SiteFilingFooter from "./SiteFilingFooter.vue";
defineProps<{
filingText?: string;
filingUrl?: string;
}>();
const emit = defineEmits<{
loggedIn: [user: UserProfile];
@@ -324,6 +330,7 @@ async function keepFieldVisible(event: FocusEvent) {
<span></span><ShieldCheck :size="20" aria-hidden="true" /><span></span>
<p>仅用于身份验证与账号登录不会泄露你的信息</p>
</footer>
<SiteFilingFooter :text="filingText || ''" :url="filingUrl" />
</div>
<div v-if="toast" class="login-toast" role="status" aria-live="polite">{{ toast }}</div>

View File

@@ -226,6 +226,7 @@ function submitDeleteCard() {
<div class="section-heading">
<Sparkles :size="18" aria-hidden="true" />
<strong>近期实修回顾</strong>
<span class="ai-generated-badge">AI生成</span>
</div>
<p>{{ practiceReview.profile.reviewText }}</p>
<div v-if="practiceReview.profile.currentFocus" class="current-focus">
@@ -289,7 +290,7 @@ function submitDeleteCard() {
<summary>
<CalendarDays :size="16" aria-hidden="true" />
<span>{{ item.title }}</span>
<small>{{ item.reportTypeLabel }}</small>
<small>{{ item.reportTypeLabel }} · AI生成</small>
</summary>
<time>{{ formatDate(item.periodStart) }} {{ formatDate(item.periodEnd) }}</time>
<pre>{{ item.content }}</pre>
@@ -343,7 +344,7 @@ function submitDeleteCard() {
<summary>
<div>
<time>{{ formatDate(item.createdAt) }}</time>
<strong>{{ item.copied ? "已复制给老师" : "尚未复制" }}</strong>
<strong>{{ item.copied ? "已复制给老师" : "尚未复制" }} · AI生成</strong>
<p>{{ item.content.slice(0, 72) }}{{ item.content.length > 72 ? "…" : "" }}</p>
</div>
<ChevronDown :size="17" aria-hidden="true" />
@@ -382,7 +383,7 @@ function submitDeleteCard() {
<summary>
<div>
<time>{{ formatDate(item.createdAt) }}</time>
<strong>{{ item.copied ? "已复制" : "尚未复制" }}</strong>
<strong>{{ item.copied ? "已复制" : "尚未复制" }} · AI生成</strong>
<p>{{ item.content.slice(0, 72) }}{{ item.content.length > 72 ? "…" : "" }}</p>
</div>
<ChevronDown :size="17" aria-hidden="true" />

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
defineProps<{
text: string;
url?: string;
}>();
</script>
<template>
<footer v-if="text" class="site-filing-footer" aria-label="网站备案信息">
<a v-if="url" :href="url" target="_blank" rel="noopener noreferrer nofollow">{{ text }}</a>
<span v-else>{{ text }}</span>
</footer>
</template>
<style scoped>
.site-filing-footer {
flex: 0 0 auto;
padding: 6px 14px max(6px, env(safe-area-inset-bottom));
border-top: 1px solid rgba(121, 143, 134, 0.14);
background: rgba(248, 251, 250, 0.96);
color: #82918b;
font-size: 11px;
line-height: 1.45;
text-align: center;
}
.site-filing-footer a {
color: inherit;
text-decoration: none;
}
.site-filing-footer a:hover,
.site-filing-footer a:focus-visible {
color: #0f705d;
text-decoration: underline;
}
</style>

View File

@@ -6,6 +6,7 @@ import type {
PracticeReviewResult,
LoginResult,
PeriodicReport,
PublicSiteConfig,
ShareDraft,
TeacherHelpCard,
UserProfile,
@@ -78,6 +79,7 @@ async function request<T>(path: string, init: RequestInit = {}, options: Request
}
export const api = {
publicSiteConfig: () => request<PublicSiteConfig>("/site/config"),
captcha: () => request<CaptchaResult>("/auth/captcha"),
sendSms: (phone: string, captchaId?: string, captchaCode?: string) =>
request<null>("/auth/sms/send", {

View File

@@ -1126,6 +1126,14 @@ textarea:focus-visible {
background: var(--chat-bg);
}
.phone-frame.chat-mode.has-filing {
grid-template-rows: auto auto minmax(0, 1fr) auto auto;
}
.phone-frame.chat-mode.has-filing .chat-composer {
padding-bottom: 10px;
}
.chat-header {
position: relative;
z-index: 5;
@@ -1603,6 +1611,7 @@ textarea:focus-visible {
.message-feedback-button { margin-top: 8px; display: inline-flex; align-items: center; gap: 5px; padding: 4px 8px; border: 0; border-radius: 7px; background: transparent; color: var(--chat-weak); font-size: 12px; }
.message-feedback-button:hover { background: rgba(20, 148, 119, 0.08); color: var(--chat-brand-deep); }
.ai-generated-badge { display: inline-flex; width: fit-content; align-items: center; justify-content: center; margin-top: 7px; padding: 2px 6px; border: 1px solid rgba(31, 118, 93, 0.18); border-radius: 999px; background: #f1f8f5; color: #4f7568; font-size: 10px; font-weight: 700; line-height: 1.4; }
.feedback-dialog p { margin: 0 0 12px; color: var(--chat-muted); line-height: 1.65; }
.feedback-dialog textarea { width: 100%; resize: vertical; padding: 12px; border: 1px solid var(--chat-control-border); border-radius: 10px; font: inherit; }
.feedback-dialog-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; color: var(--chat-weak); font-size: 12px; }

View File

@@ -4,6 +4,11 @@ export interface ApiResponse<T> {
data: T;
}
export interface PublicSiteConfig {
filingText: string;
filingUrl: string;
}
export interface UserProfile {
id: number;
phone: string;
@@ -75,6 +80,7 @@ export interface PeriodicReport {
periodEnd: string;
title: string;
content: string;
aiGenerated: boolean;
status: "pending" | "running" | "success" | "failed" | "empty" | string;
nextRunAt?: string | null;
finishedAt?: string | null;
@@ -87,6 +93,7 @@ export interface TeacherHelpCard {
topicSessionId: number;
summaryId?: number | null;
content: string;
aiGenerated: boolean;
source: string;
copied: boolean;
copiedAt?: string | null;
@@ -99,6 +106,7 @@ export interface ShareDraft {
topicSessionId: number;
summaryId?: number | null;
content: string;
aiGenerated: boolean;
source: string;
copied: boolean;
copiedAt?: string | null;

View File

@@ -0,0 +1,7 @@
export const AI_GENERATED_NOTICE = "AI生成内容请结合实际情况核对后使用。";
export function withAiGeneratedNotice(content: string) {
const text = content.trim();
if (!text || text.includes(AI_GENERATED_NOTICE)) return text;
return `${text}\n\n—— ${AI_GENERATED_NOTICE}`;
}