refactor: 拆分管理后台页面并按需加载

This commit is contained in:
2026-08-03 16:22:03 +08:00
parent b2b56f43bb
commit 861a0ae7d5
11 changed files with 2829 additions and 1445 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,125 @@
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { ref, watch } from "vue";
import { api } from "../services/api";
import type { AiLogRecord } from "../types/api";
const props = defineProps<{ modelValue: boolean; logId: number | null }>();
const emit = defineEmits<{ "update:modelValue": [value: boolean] }>();
const detail = ref<AiLogRecord | null>(null);
const loading = ref(false);
watch(
() => [props.modelValue, props.logId] as const,
async ([open, logId]) => {
if (!open || !logId) return;
detail.value = null;
loading.value = true;
try {
detail.value = await api.aiLogDetail(logId);
} catch (error) {
ElMessage.error(
error instanceof Error ? error.message : "AI 请求详情加载失败",
);
} finally {
loading.value = false;
}
},
);
function formatMoney(value?: number | null, currency = "CNY") {
if (typeof value !== "number") return "-";
return `${currency || "CNY"} ${value.toFixed(6)}`;
}
</script>
<template>
<el-drawer
:model-value="modelValue"
size="820px"
title="AI 请求详情"
@update:model-value="emit('update:modelValue', $event)"
>
<div v-loading="loading">
<template v-if="detail">
<section class="chat-summary">
<div>
<span>请求ID</span><strong>{{ detail.id }}</strong>
</div>
<div>
<span>会话ID</span><strong>{{ detail.sessionId || "-" }}</strong>
</div>
<div>
<span>消息ID</span><strong>{{ detail.messageId || "-" }}</strong>
</div>
<div>
<span>用户ID</span><strong>{{ detail.userId || "-" }}</strong>
</div>
<div>
<span>模型</span><strong>{{ detail.modelName || "-" }}</strong>
</div>
<div>
<span>状态</span><strong>{{ detail.status }}</strong>
</div>
</section>
<section class="audit-detail-grid">
<span>知识库{{ detail.knowledgeIds || "-" }}</span
><span>命中数{{ detail.retrieveCount }}</span
><span>片段数{{ detail.retrievedChunks?.length || 0 }}</span
><span>输入 Token{{ detail.inputToken || "-" }}</span
><span>输出 Token{{ detail.outputToken || "-" }}</span
><span> Token{{ detail.totalToken || "-" }}</span
><span
>估算成本{{
formatMoney(detail.estimatedCost, detail.currency || "CNY")
}}</span
><span>问题类型{{ detail.questionType || "-" }}</span
><span>知识命中{{ detail.knowledgeHit ? "是" : "否" }}</span
><span>路由原因{{ detail.routeReason || "-" }}</span
><span>耗时{{ detail.costMs || "-" }}ms</span
><span>时间{{ detail.createdAt }}</span>
</section>
<p v-if="detail.errorMessage" class="error-text">
{{ detail.errorMessage }}
</p>
<h3 class="detail-title">召回知识片段</h3>
<section v-if="detail.retrievedChunks?.length" class="retrieval-chunks">
<article
v-for="chunk in detail.retrievedChunks"
:key="`${detail.id}-${chunk.index}`"
class="retrieval-chunk"
>
<div class="chunk-head">
<strong
>片段 {{ chunk.index }}/{{
detail.retrievedChunks.length
}}</strong
><span>{{ chunk.knowledgeName || "未知知识库" }}</span
><span v-if="chunk.knowledgeId"
>知识库ID{{ chunk.knowledgeId }}</span
>
</div>
<div class="chunk-title">{{ chunk.title || "未命名片段" }}</div>
<pre>{{ chunk.content }}</pre>
<a
v-if="chunk.sourceUrl"
:href="chunk.sourceUrl"
target="_blank"
rel="noreferrer"
>查看来源</a
>
</article>
</section>
<el-empty
v-else
description="本条请求未保存召回片段"
:image-size="72"
/>
<h3 class="detail-title">完整 Prompt</h3>
<pre class="prompt-preview">{{
detail.prompt || "无 Prompt 记录"
}}</pre>
</template>
</div>
</el-drawer>
</template>

View File

@@ -0,0 +1,134 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { onMounted, reactive, ref } from "vue";
import { api } from "../services/api";
import type { AttentionItem } from "../types/api";
import AdminPagination from "./AdminPagination.vue";
import ChatDetailDrawer from "./ChatDetailDrawer.vue";
const loading = ref(false);
const records = ref<AttentionItem[]>([]);
const pager = reactive({ page: 1, pageSize: 20, total: 0 });
const chatDetailOpen = ref(false);
const sessionId = ref<number | null>(null);
onMounted(() => loadRecords());
async function loadRecords(page = 1, pageSize = pager.pageSize) {
loading.value = true;
try {
const result = await api.attentionList({ page, pageSize });
records.value = result.items;
Object.assign(pager, {
page: result.page,
pageSize: result.pageSize,
total: result.total,
});
} finally {
loading.value = false;
}
}
async function updateStatus(row: AttentionItem, status: string) {
await api.updateAttention(row.id, status);
ElMessage.success("人工关注状态已更新");
await loadRecords(pager.page, pager.pageSize);
}
async function deleteRecord(row: AttentionItem) {
try {
await ElMessageBox.confirm(
"确定删除这条人工关注记录吗?操作将写入审计日志。",
"删除人工关注记录",
{
confirmButtonText: "确认删除",
cancelButtonText: "取消",
type: "warning",
},
);
} catch {
return;
}
await api.deleteAttention(row.id);
ElMessage.success("人工关注记录已删除");
await loadRecords(pager.page, pager.pageSize);
}
function openChat(id: number) {
sessionId.value = id;
chatDetailOpen.value = true;
}
function priorityLabel(priority: string) {
return (
{ urgent: "紧急", important: "重要", normal: "普通" }[
priority as "urgent" | "important" | "normal"
] || priority
);
}
</script>
<template>
<section v-loading="loading" class="feature-page">
<div class="page-head">
<h2>人工关注</h2>
<p>处理安全风险复杂卡住知识缺失和用户主动求助记录</p>
</div>
<el-table :data="records" stripe
><el-table-column label="优先级" width="100"
><template #default="{ row }"
><el-tag
:type="
row.priority === 'urgent'
? 'danger'
: row.priority === 'important'
? 'warning'
: 'info'
"
>{{ priorityLabel(row.priority) }}</el-tag
></template
></el-table-column
><el-table-column
prop="problemSummary"
label="问题摘要"
min-width="220"
/><el-table-column
prop="triggerReason"
label="触发原因"
min-width="220"
/><el-table-column
prop="userId"
label="用户"
width="80"
/><el-table-column
prop="sessionId"
label="会话"
width="80"
/><el-table-column label="状态" width="150"
><template #default="{ row }"
><el-select
:model-value="row.status"
size="small"
@change="updateStatus(row, $event)"
><el-option label="待处理" value="pending" /><el-option
label="处理中"
value="processing" /><el-option
label="已处理"
value="resolved" /><el-option
label="无需处理"
value="ignored" /></el-select></template></el-table-column
><el-table-column
prop="createdAt"
label="创建时间"
width="180"
/><el-table-column label="操作" width="130" fixed="right"
><template #default="{ row }"
><el-button link type="primary" @click="openChat(row.sessionId)"
>原会话</el-button
><el-button link type="danger" @click="deleteRecord(row)"
>删除</el-button
></template
></el-table-column
></el-table
><AdminPagination
:page="pager.page"
:page-size="pager.pageSize"
:total="pager.total"
@change="loadRecords"
/><ChatDetailDrawer v-model="chatDetailOpen" :session-id="sessionId" />
</section>
</template>

View File

@@ -0,0 +1,295 @@
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { nextTick, reactive, ref, watch } from "vue";
import { api } from "../services/api";
import type { ChatDetail } from "../types/api";
import AdminPagination from "./AdminPagination.vue";
const props = defineProps<{ modelValue: boolean; sessionId: number | null }>();
const emit = defineEmits<{ "update:modelValue": [value: boolean] }>();
const detail = ref<ChatDetail | null>(null);
const loading = ref(false);
const messagesLoading = ref(false);
const bodyRef = ref<HTMLElement | null>(null);
const pager = reactive({ page: 1, pageSize: 20, total: 0 });
watch(
() => [props.modelValue, props.sessionId] as const,
async ([open, sessionId]) => {
if (!open || !sessionId) return;
detail.value = null;
Object.assign(pager, { page: 1, total: 0 });
await loadDetail(sessionId, 1, pager.pageSize);
},
);
async function loadDetail(sessionId: number, page: number, pageSize: number) {
loading.value = true;
try {
detail.value = await api.chatDetail(sessionId, {
messagePage: page,
messagePageSize: pageSize,
});
const messagePage = detail.value.messagesPage;
Object.assign(pager, {
page: messagePage?.page ?? page,
pageSize: messagePage?.pageSize ?? pageSize,
total: messagePage?.total ?? detail.value.messages.length,
});
} catch (error) {
ElMessage.error(
error instanceof Error ? error.message : "聊天详情加载失败",
);
} finally {
loading.value = false;
}
}
async function changeMessagePage(page: number, pageSize: number) {
if (!props.sessionId || !detail.value) return;
messagesLoading.value = true;
try {
const result = await api.chatMessages(props.sessionId, { page, pageSize });
detail.value = {
...detail.value,
messages: result.items,
messagesPage: result,
};
Object.assign(pager, {
page: result.page,
pageSize: result.pageSize,
total: result.total,
});
await nextTick();
bodyRef.value?.scrollTo({ top: 0, behavior: "smooth" });
} catch (error) {
ElMessage.error(
error instanceof Error ? error.message : "聊天消息加载失败",
);
} finally {
messagesLoading.value = false;
}
}
</script>
<template>
<el-drawer
:model-value="modelValue"
size="760px"
title="聊天详情"
@update:model-value="emit('update:modelValue', $event)"
>
<div ref="bodyRef" v-loading="loading" class="chat-detail-body">
<template v-if="detail">
<section class="chat-summary">
<div>
<span>会话ID</span><strong>{{ detail.session.id }}</strong>
</div>
<div>
<span>用户</span
><strong>{{ detail.session.userName || "-" }}</strong>
</div>
<div>
<span>手机号</span
><strong>{{ detail.session.userPhone || "-" }}</strong>
</div>
<div>
<span>消息数</span
><strong>{{ detail.session.messageCount }}</strong>
</div>
</section>
<h3 class="detail-title">主题会话</h3>
<el-collapse
v-if="detail.topics?.length"
class="topic-summary-collapse"
>
<el-collapse-item
v-for="topic in detail.topics"
:key="topic.id"
:title="`#${topic.id} ${topic.title} / ${topic.status}`"
>
<section class="topic-summary-card">
<div class="topic-summary-meta">
<span>核心问题{{ topic.coreQuestion }}</span
><span>消息数{{ topic.messageCount }}</span
><span
>Token{{ topic.tokenInput }}/{{ topic.tokenOutput }}</span
><span
>额度{{ topic.quotaDeducted ? "已计入" : "不计入" }}</span
><span>开始{{ topic.startedAt }}</span
><span>结束{{ topic.endedAt || "-" }}</span>
</div>
<template v-if="topic.summary">
<pre>{{ topic.summary.summary }}</pre>
<div class="topic-summary-fields">
<p v-if="topic.summary.currentFocus">
<strong>本次关注</strong>{{ topic.summary.currentFocus }}
</p>
<p v-if="topic.summary.nextObservation">
<strong>可以继续留意</strong
>{{ topic.summary.nextObservation }}
</p>
</div></template
>
<el-empty
v-else
description="该主题尚未沉淀摘要"
:image-size="60"
/>
</section>
</el-collapse-item>
</el-collapse>
<el-empty v-else description="该会话暂无主题记录" :image-size="64" />
<h3 class="detail-title">
老师求助卡{{ detail.helpCards?.length || 0 }}
</h3>
<el-collapse
v-if="detail.helpCards?.length"
class="topic-summary-collapse"
><el-collapse-item
v-for="card in detail.helpCards"
:key="card.id"
:title="`#${card.id} / 主题 #${card.topicSessionId} / ${card.copied ? '用户已复制' : '未复制'}`"
><section class="topic-summary-card">
<div class="topic-summary-meta">
<span>生成{{ card.createdAt }}</span
><span>复制{{ card.copiedAt || "-" }}</span
><span>来源{{ card.source }}</span>
</div>
<pre>{{ card.content }}</pre>
</section></el-collapse-item
></el-collapse
>
<el-empty
v-else
description="该会话暂无用户生成的求助卡"
:image-size="64"
/>
<h3 class="detail-title">
班级分享稿{{ detail.shareDrafts?.length || 0 }}
</h3>
<el-collapse
v-if="detail.shareDrafts?.length"
class="topic-summary-collapse"
><el-collapse-item
v-for="draft in detail.shareDrafts"
:key="draft.id"
:title="`#${draft.id} / 主题 #${draft.topicSessionId} / ${draft.copied ? '用户已复制' : '未复制'}`"
><section class="topic-summary-card">
<div class="topic-summary-meta">
<span>生成{{ draft.createdAt }}</span
><span>复制{{ draft.copiedAt || "-" }}</span
><span>来源{{ draft.source }}</span>
</div>
<pre>{{ draft.content }}</pre>
</section></el-collapse-item
></el-collapse
>
<el-empty
v-else
description="该会话暂无用户生成的班级分享稿"
:image-size="64"
/>
<h3 class="detail-title">完整对话</h3>
<AdminPagination
class="chat-detail-pagination top"
:page="pager.page"
:page-size="pager.pageSize"
:total="pager.total"
@change="changeMessagePage"
/>
<section v-loading="messagesLoading" class="conversation-list">
<article
v-for="message in detail.messages"
:key="message.id"
class="conversation-message"
:class="message.role"
>
<div class="message-meta">
<strong>{{
message.role === "user" ? "用户" : "大本营答疑助手"
}}</strong
><span>{{ message.createdAt }}</span>
</div>
<pre>{{ message.content }}</pre>
<div class="message-extra">
<span>状态{{ message.messageStatus }}</span
><span v-if="message.tokenInput"
>输入 Token{{ message.tokenInput }}</span
><span v-if="message.tokenOutput"
>输出 Token{{ message.tokenOutput }}</span
><span v-if="message.responseTimeMs"
>耗时{{ message.responseTimeMs }}ms</span
>
</div>
</article>
</section>
<AdminPagination
class="chat-detail-pagination"
:page="pager.page"
:page-size="pager.pageSize"
:total="pager.total"
@change="changeMessagePage"
/>
<h3 class="detail-title">关联 AI 请求</h3>
<el-collapse
><el-collapse-item
v-for="log in detail.aiLogs"
:key="log.id"
:title="`请求 #${log.id} / ${log.status}`"
><div class="ai-log-grid">
<span>模型{{ log.modelName || "-" }}</span
><span>知识库{{ log.knowledgeIds || "-" }}</span
><span>命中数{{ log.retrieveCount }}</span
><span>Token{{ log.totalToken || "-" }}</span
><span>耗时{{ log.costMs || "-" }}ms</span
><span>时间{{ log.createdAt }}</span>
</div>
<section
v-if="log.retrievedChunks?.length"
class="retrieval-chunks"
>
<article
v-for="chunk in log.retrievedChunks"
:key="`${log.id}-${chunk.index}`"
class="retrieval-chunk"
>
<div class="chunk-head">
<strong
>#{{ chunk.index }}
{{ chunk.knowledgeName || "未知知识库" }}</strong
><span>{{ chunk.title || "未命名片段" }}</span>
</div>
<pre>{{ chunk.content }}</pre>
<a
v-if="chunk.sourceUrl"
:href="chunk.sourceUrl"
target="_blank"
rel="noreferrer"
>查看来源</a
>
</article>
</section>
<el-empty
v-else
description="本条请求未保存召回片段"
:image-size="64"
/>
<pre class="prompt-preview">{{
log.prompt || "无 Prompt 记录"
}}</pre>
<p v-if="log.errorMessage" class="error-text">
{{ log.errorMessage }}
</p></el-collapse-item
></el-collapse
>
</template>
<el-empty
v-else-if="!loading"
description="聊天详情加载失败或暂无数据"
:image-size="80"
/>
</div>
</el-drawer>
</template>

View File

@@ -0,0 +1,221 @@
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { onMounted, reactive, ref } from "vue";
import { api } from "../services/api";
import type { DashboardStats } from "../types/api";
const stats = ref<DashboardStats | null>(null);
const storageStats = ref<Record<string, any> | null>(null);
const storageLoading = ref(false);
const loading = ref(false);
const filters = reactive({ start: "", end: "" });
onMounted(loadDashboard);
async function loadDashboard() {
loading.value = true;
try {
stats.value = await api.dashboard(filters.start, filters.end);
storageLoading.value = true;
api
.storageStats()
.then((value) => {
storageStats.value = value;
})
.catch(() => {
storageStats.value = null;
})
.finally(() => {
storageLoading.value = false;
});
} finally {
loading.value = false;
}
}
async function refreshStorage() {
storageLoading.value = true;
try {
storageStats.value = await api.storageStats(true);
ElMessage.success("存储统计已刷新");
} finally {
storageLoading.value = false;
}
}
function formatBytes(value: unknown) {
if (typeof value !== "number") return "无法统计";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = value;
let index = 0;
while (size >= 1024 && index < units.length - 1) {
size /= 1024;
index += 1;
}
return `${size.toFixed(index ? 2 : 0)} ${units[index]}`;
}
function formatMoney(value?: number | null, currency = "CNY") {
if (typeof value !== "number") return "-";
if (currency === "MIXED") return "多币种,见明细";
return `${currency || "CNY"} ${value.toFixed(6)}`;
}
function modelSceneLabel(scene: string) {
return (
(
{
background_report: "周期报告",
background_summary: "摘要沉淀",
fixed_info: "固定信息问答",
simple_knowledge: "简单知识问答",
knowledge_grounded: "知识问答",
general_chat: "通用对话",
} as Record<string, string>
)[scene] ||
scene ||
"未分类"
);
}
</script>
<template>
<section v-loading="loading" class="feature-page">
<div class="page-head inline">
<div>
<h2>数据看板</h2>
<p>核心业务数据概览</p>
</div>
<div class="dashboard-date-range" aria-label="看板日期范围">
<label class="record-date-field dashboard-date-field">
<span>开始日期</span>
<input
v-model="filters.start"
type="date"
aria-label="开始日期"
@change="loadDashboard"
/>
</label>
<span class="record-date-sep"></span>
<label class="record-date-field dashboard-date-field">
<span>结束日期</span>
<input
v-model="filters.end"
type="date"
aria-label="结束日期"
@change="loadDashboard"
/>
</label>
</div>
</div>
<div class="stats-grid">
<div class="stat">
<span>用户数</span><strong>{{ stats?.userCount ?? 0 }}</strong>
</div>
<div class="stat">
<span>会话数</span><strong>{{ stats?.sessionCount ?? 0 }}</strong>
</div>
<div class="stat">
<span>消息数</span><strong>{{ stats?.messageCount ?? 0 }}</strong>
</div>
<div class="stat">
<span>AI 请求</span><strong>{{ stats?.aiRequestCount ?? 0 }}</strong>
</div>
<div class="stat">
<span>知识库</span><strong>{{ stats?.knowledgeCount ?? 0 }}</strong>
</div>
<div class="stat">
<span>输入 Token</span><strong>{{ stats?.inputToken ?? 0 }}</strong>
</div>
<div class="stat">
<span>输出 Token</span><strong>{{ stats?.outputToken ?? 0 }}</strong>
</div>
<div class="stat">
<span> Token</span><strong>{{ stats?.totalToken ?? 0 }}</strong>
</div>
<div class="stat">
<span>估算成本</span
><strong>{{
formatMoney(stats?.estimatedCost, stats?.costCurrency || "CNY")
}}</strong>
</div>
</div>
<section class="cost-breakdown-panel">
<div class="storage-panel-head">
<div>
<h3>模型使用与成本</h3>
<p>按实际调用场景和模型统计报告摘要分流是否生效可在这里核对</p>
</div>
</div>
<el-table
:data="stats?.costBreakdown || []"
empty-text="当前日期范围内暂无模型调用"
>
<el-table-column label="调用场景" min-width="130"
><template #default="{ row }">{{
modelSceneLabel(row.scene)
}}</template></el-table-column
>
<el-table-column
prop="modelName"
label="实际模型"
min-width="180"
show-overflow-tooltip
/>
<el-table-column prop="requestCount" label="请求数" width="100" />
<el-table-column prop="inputToken" label="输入 Token" width="130" />
<el-table-column prop="outputToken" label="输出 Token" width="130" />
<el-table-column label="估算成本" width="170"
><template #default="{ row }">{{
formatMoney(row.estimatedCost, row.currency || "CNY")
}}</template></el-table-column
>
</el-table>
</section>
<section class="storage-panel" v-loading="storageLoading">
<div class="storage-panel-head">
<div>
<h3>项目存储占用</h3>
<p>数据库Redis 与附件分别统计无权限或不支持时显示无法统计</p>
</div>
<el-button :loading="storageLoading" @click="refreshStorage"
>手动刷新</el-button
>
</div>
<div class="storage-grid">
<div>
<span>项目总量</span
><strong>{{ formatBytes(storageStats?.totalBytes) }}</strong>
</div>
<div>
<span>数据库</span
><strong>{{
formatBytes(storageStats?.detail?.database?.bytes)
}}</strong>
</div>
<div>
<span>Redis</span
><strong>{{
formatBytes(storageStats?.detail?.redis?.bytes)
}}</strong>
</div>
<div>
<span>附件/文件</span
><strong>{{
formatBytes(storageStats?.detail?.files?.bytes)
}}</strong>
</div>
<div>
<span>近7天增长</span
><strong>{{ formatBytes(storageStats?.growth7DaysBytes) }}</strong>
</div>
<div>
<span>近30天增长</span
><strong>{{ formatBytes(storageStats?.growth30DaysBytes) }}</strong>
</div>
</div>
<small>最后统计{{ storageStats?.createdAt || "尚未统计" }}</small>
</section>
</section>
</template>

View File

@@ -0,0 +1,528 @@
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { onMounted, reactive, ref, watch } from "vue";
import { api } from "../services/api";
import type {
AiLogRecord,
ChatRecord,
QuestionInsightSummary,
} from "../types/api";
import AdminPagination from "./AdminPagination.vue";
import AiLogDetailDrawer from "./AiLogDetailDrawer.vue";
import ChatDetailDrawer from "./ChatDetailDrawer.vue";
type RecordTab = "chats" | "questionInsights" | "aiLogs" | "operationLogs";
const loading = ref(false);
const activeTab = ref<RecordTab>("chats");
const chats = ref<ChatRecord[]>([]);
const aiLogs = ref<AiLogRecord[]>([]);
const operationLogs = ref<Record<string, unknown>[]>([]);
const insights = ref<QuestionInsightSummary | null>(null);
const detailSessionId = ref<number | null>(null);
const chatDetailOpen = ref(false);
const aiLogId = ref<number | null>(null);
const aiLogDetailOpen = ref(false);
const pagers = reactive({
chats: { page: 1, pageSize: 20, total: 0 },
aiLogs: { page: 1, pageSize: 20, total: 0 },
operationLogs: { page: 1, pageSize: 20, total: 0 },
questionInsights: { page: 1, pageSize: 20, total: 0 },
});
const chatFilters = reactive({
keyword: "",
userId: undefined as number | undefined,
status: "",
dateFrom: "",
dateTo: "",
});
const insightFilters = reactive({
dateFrom: "",
dateTo: "",
minCount: 2,
maxMessages: 5000,
});
onMounted(() => loadTab("chats"));
watch(activeTab, (tab) => loadTab(tab));
async function loadTab(tab: RecordTab = activeTab.value) {
loading.value = true;
try {
if (tab === "chats") {
const result = await api.chats({
...buildChatQuery(),
page: pagers.chats.page,
pageSize: pagers.chats.pageSize,
});
chats.value = result.items;
Object.assign(pagers.chats, {
page: result.page,
pageSize: result.pageSize,
total: result.total,
});
} else if (tab === "aiLogs") {
const result = await api.aiLogs({
page: pagers.aiLogs.page,
pageSize: pagers.aiLogs.pageSize,
});
aiLogs.value = result.items;
Object.assign(pagers.aiLogs, {
page: result.page,
pageSize: result.pageSize,
total: result.total,
});
} else if (tab === "operationLogs") {
const result = await api.operationLogs({
page: pagers.operationLogs.page,
pageSize: pagers.operationLogs.pageSize,
});
operationLogs.value = result.items;
Object.assign(pagers.operationLogs, {
page: result.page,
pageSize: result.pageSize,
total: result.total,
});
} else
await loadInsights(
pagers.questionInsights.page,
pagers.questionInsights.pageSize,
);
} finally {
loading.value = false;
}
}
async function changePage(tab: RecordTab, page: number, pageSize: number) {
Object.assign(pagers[tab], { page, pageSize });
await loadTab(tab);
}
async function loadInsights(
page = 1,
pageSize = pagers.questionInsights.pageSize,
) {
insights.value = await api.questionInsights({
dateFrom: formatDateTime(insightFilters.dateFrom, "start"),
dateTo: formatDateTime(insightFilters.dateTo, "end"),
minCount: insightFilters.minCount,
maxMessages: insightFilters.maxMessages,
page,
pageSize,
});
Object.assign(pagers.questionInsights, {
page: insights.value.page,
pageSize: insights.value.pageSize,
total: insights.value.total,
});
}
async function searchChats() {
pagers.chats.page = 1;
await loadTab("chats");
}
async function resetChatFilters() {
Object.assign(chatFilters, {
keyword: "",
userId: undefined,
status: "",
dateFrom: "",
dateTo: "",
});
await searchChats();
}
async function refreshInsights() {
pagers.questionInsights.page = 1;
loading.value = true;
try {
const result = await api.refreshQuestionInsights({
dateFrom: formatDateTime(insightFilters.dateFrom, "start"),
dateTo: formatDateTime(insightFilters.dateTo, "end"),
maxMessages: insightFilters.maxMessages,
});
await loadInsights(1);
ElMessage.success(
result.processedMessages
? `新增清洗 ${result.processedMessages} 条消息,得到 ${result.acceptedQuestions} 个有效问题${result.hasMore ? ";仍有历史消息待清洗" : ""}`
: "没有新增消息,已直接使用持久化清洗结果统计",
);
} catch (error) {
ElMessage.error(
error instanceof Error ? error.message : "问题洞察刷新失败",
);
} finally {
loading.value = false;
}
}
async function resetInsightFilters() {
Object.assign(insightFilters, {
dateFrom: "",
dateTo: "",
minCount: 2,
maxMessages: 5000,
});
await refreshInsights();
}
function openChat(sessionId: number) {
detailSessionId.value = sessionId;
chatDetailOpen.value = true;
}
function openAiLog(row: AiLogRecord) {
aiLogId.value = row.id;
aiLogDetailOpen.value = true;
}
function buildChatQuery() {
return {
keyword: chatFilters.keyword,
userId: chatFilters.userId,
status: chatFilters.status,
dateFrom: formatDateTime(chatFilters.dateFrom, "start"),
dateTo: formatDateTime(chatFilters.dateTo, "end"),
};
}
function formatDateTime(value: string, boundary: "start" | "end") {
if (!value) return "";
const [date, time = ""] = value.trim().replace("T", " ").split(" ");
if (!date) return "";
const [hour = "00", minute = "00", second] = time.split(":");
return `${date} ${hour.padStart(2, "0")}:${minute.padStart(2, "0")}:${(second ?? (boundary === "end" ? "59" : "00")).padStart(2, "0")}`;
}
function formatMoney(value?: number | null, currency = "CNY") {
return typeof value === "number"
? `${currency || "CNY"} ${value.toFixed(6)}`
: "-";
}
</script>
<template>
<section class="feature-page">
<div class="page-head">
<h2>记录审计</h2>
<p>聊天AI 请求和后台操作记录</p>
</div>
<el-tabs v-model="activeTab">
<el-tab-pane label="聊天记录" name="chats">
<div class="record-filter">
<el-input
v-model="chatFilters.keyword"
placeholder="搜索标题、手机号、姓名、聊天内容"
clearable
/><el-input-number
v-model="chatFilters.userId"
:min="1"
placeholder="用户ID"
/><el-select
v-model="chatFilters.status"
placeholder="消息状态"
clearable
><el-option label="全部状态" value="" /><el-option
label="已完成"
value="FINISHED" /><el-option
label="生成中"
value="GENERATING" /><el-option
label="已停止"
value="STOPPED" /><el-option label="失败" value="FAILED"
/></el-select>
<div class="record-date-range" aria-label="时间范围">
<label class="record-date-field"
><span>开始时间</span
><input
v-model="chatFilters.dateFrom"
type="datetime-local"
aria-label="开始时间" /></label
><span class="record-date-sep"></span
><label class="record-date-field"
><span>结束时间</span
><input
v-model="chatFilters.dateTo"
type="datetime-local"
aria-label="结束时间"
/></label>
</div>
<div class="record-filter-actions">
<el-button type="primary" @click="searchChats">搜索</el-button
><el-button @click="resetChatFilters">重置</el-button
><el-button @click="api.exportChats(buildChatQuery())"
>导出</el-button
>
</div>
</div>
<el-table v-loading="loading" :data="chats" stripe
><el-table-column
prop="id"
label="会话ID"
width="90"
/><el-table-column
prop="userPhone"
label="手机号"
width="140"
/><el-table-column
prop="userName"
label="用户"
width="120"
/><el-table-column
prop="title"
label="会话标题"
min-width="220"
show-overflow-tooltip
/><el-table-column
prop="messageCount"
label="消息数"
width="90"
/><el-table-column
prop="lastMessageAt"
label="最后消息"
width="180"
/><el-table-column label="操作" width="100" fixed="right"
><template #default="{ row }"
><el-button size="small" @click="openChat(row.id)"
>详情</el-button
></template
></el-table-column
></el-table
>
<AdminPagination
:page="pagers.chats.page"
:page-size="pagers.chats.pageSize"
:total="pagers.chats.total"
@change="(page, size) => changePage('chats', page, size)"
/>
</el-tab-pane>
<el-tab-pane label="问题洞察" name="questionInsights">
<div class="record-filter question-insight-filter">
<div class="record-date-range" aria-label="问题时间范围">
<label class="record-date-field"
><span>开始时间</span
><input
v-model="insightFilters.dateFrom"
type="datetime-local"
aria-label="问题开始时间" /></label
><span class="record-date-sep"></span
><label class="record-date-field"
><span>结束时间</span
><input
v-model="insightFilters.dateTo"
type="datetime-local"
aria-label="问题结束时间"
/></label>
</div>
<label class="insight-number-field"
><span>最低频次</span
><el-input-number
v-model="insightFilters.minCount"
:min="1"
:max="50"
controls-position="right" /></label
><label class="insight-number-field"
><span>单次清洗上限</span
><el-input-number
v-model="insightFilters.maxMessages"
:min="100"
:max="20000"
:step="500"
controls-position="right"
/></label>
<div class="record-filter-actions">
<el-button
type="primary"
:loading="loading"
@click="refreshInsights"
>刷新并统计</el-button
><el-button @click="resetInsightFilters">重置</el-button>
</div>
</div>
<p class="question-insight-help">
清洗结果会持久化保存点击刷新并统计只处理尚未清洗的用户消息再按当前时间范围聚合翻页不会重新扫描聊天原文
</p>
<section v-if="insights" class="question-insight-summary">
<div>
<span>纳入清洗消息</span
><strong>{{ insights.summary.scannedMessages }}</strong>
</div>
<div>
<span>有效问题</span
><strong>{{ insights.summary.cleanedQuestions }}</strong>
</div>
<div>
<span>过滤低价值</span
><strong>{{ insights.summary.filteredMessages }}</strong>
</div>
<div>
<span>高频问题组</span
><strong>{{ insights.summary.visibleClusterCount }}</strong>
</div>
</section>
<section v-loading="loading" class="question-insight-list">
<article
v-for="cluster in insights?.items || []"
:key="`${cluster.rank}-${cluster.normalized}`"
class="question-insight-card"
>
<header>
<div>
<small>TOP {{ cluster.rank }}</small>
<h3>{{ cluster.title }}</h3>
</div>
<div class="question-insight-metrics">
<span>{{ cluster.count }} </span
><span>{{ cluster.userCount }} </span
><span>{{ cluster.sessionCount }} 个会话</span>
</div>
</header>
<div class="question-insight-tags">
<el-tag size="small" effect="plain">{{
cluster.categoryLabel
}}</el-tag
><el-tag
v-if="cluster.needsKnowledgeFollowUp"
size="small"
type="warning"
effect="plain"
>需补知识/查召回</el-tag
><el-tag
v-if="cluster.noHitCount"
size="small"
type="danger"
effect="plain"
>无命中 {{ cluster.noHitCount }}</el-tag
><el-tag
v-if="cluster.failedCount"
size="small"
type="danger"
effect="dark"
>失败 {{ cluster.failedCount }}</el-tag
><el-tag
v-for="term in cluster.topTerms"
:key="term"
size="small"
type="success"
effect="plain"
>{{ term }}</el-tag
>
</div>
<p class="question-insight-action">{{ cluster.suggestedAction }}</p>
<el-collapse
><el-collapse-item
title="查看相似问法和原始样例"
:name="cluster.normalized"
><div class="question-variants">
<span v-for="variant in cluster.variants" :key="variant.text"
>{{ variant.text }} × {{ variant.count }}</span
>
</div>
<div class="question-samples">
<article
v-for="sample in cluster.samples"
:key="sample.messageId"
>
<div>
<strong>{{
sample.userName ||
sample.userPhone ||
`用户 #${sample.userId}`
}}</strong
><span>{{ sample.createdAt }}</span>
</div>
<p>清洗后{{ sample.cleaned }}</p>
<pre>{{ sample.raw }}</pre>
<el-button
link
type="primary"
@click="openChat(sample.sessionId)"
>查看原会话</el-button
>
</article>
</div></el-collapse-item
></el-collapse
>
</article>
<el-empty
v-if="insights && insights.items.length === 0"
description="当前条件下暂无达到频次的问题组"
:image-size="72"
/><el-empty
v-else-if="!insights && !loading"
description="选择时间范围后点击统计问题"
:image-size="72"
/>
</section>
<AdminPagination
:page="pagers.questionInsights.page"
:page-size="pagers.questionInsights.pageSize"
:total="pagers.questionInsights.total"
@change="(page, size) => changePage('questionInsights', page, size)"
/>
</el-tab-pane>
<el-tab-pane label="AI 请求" name="aiLogs"
><el-table v-loading="loading" :data="aiLogs" stripe
><el-table-column
prop="sessionId"
label="会话ID"
width="90"
/><el-table-column
prop="modelName"
label="模型"
width="160"
/><el-table-column
prop="knowledgeIds"
label="知识库"
width="120"
/><el-table-column
prop="retrieveCount"
label="命中"
width="80"
/><el-table-column label="片段" width="80"
><template #default="{ row }">{{
row.retrievedChunks?.length || 0
}}</template></el-table-column
><el-table-column
prop="totalToken"
label="Token"
width="90"
/><el-table-column label="估算成本" width="130"
><template #default="{ row }">{{
formatMoney(row.estimatedCost, row.currency || "CNY")
}}</template></el-table-column
><el-table-column
prop="costMs"
label="耗时(ms)"
width="100"
/><el-table-column
prop="status"
label="状态"
width="100"
/><el-table-column
prop="errorMessage"
label="错误"
min-width="220"
show-overflow-tooltip
/><el-table-column label="操作" width="100" fixed="right"
><template #default="{ row }"
><el-button size="small" @click="openAiLog(row)"
>详情</el-button
></template
></el-table-column
></el-table
><AdminPagination
:page="pagers.aiLogs.page"
:page-size="pagers.aiLogs.pageSize"
:total="pagers.aiLogs.total"
@change="(page, size) => changePage('aiLogs', page, size)"
/></el-tab-pane>
<el-tab-pane label="操作日志" name="operationLogs"
><el-table v-loading="loading" :data="operationLogs" stripe
><el-table-column prop="module" label="模块" /><el-table-column
prop="action"
label="动作" /><el-table-column
prop="result"
label="结果" /><el-table-column
prop="createdAt"
label="时间" /></el-table
><AdminPagination
:page="pagers.operationLogs.page"
:page-size="pagers.operationLogs.pageSize"
:total="pagers.operationLogs.total"
@change="(page, size) => changePage('operationLogs', page, size)"
/></el-tab-pane>
</el-tabs>
<ChatDetailDrawer v-model="chatDetailOpen" :session-id="detailSessionId" />
<AiLogDetailDrawer v-model="aiLogDetailOpen" :log-id="aiLogId" />
</section>
</template>

View File

@@ -0,0 +1,217 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, onMounted, reactive, ref } from "vue";
import { api } from "../services/api";
import type { RetrievalLogItem } from "../types/api";
import AdminPagination from "./AdminPagination.vue";
const loading = ref(false);
const logs = ref<RetrievalLogItem[]>([]);
const pager = reactive({ page: 1, pageSize: 20, total: 0 });
const cleanupBefore = ref("");
const retentionDays = ref<number | null>(90);
const retentionChoice = computed({
get: () => retentionDays.value ?? -1,
set: (value: number) => {
retentionDays.value = value === -1 ? null : value;
},
});
const retentionState = ref<{
lastRunAt: string | null;
lastResult: string | null;
} | null>(null);
const selected = ref<Record<string, any> | null>(null);
const detailOpen = ref(false);
onMounted(async () => {
loading.value = true;
try {
await Promise.all([loadLogs(), loadRetention()]);
} finally {
loading.value = false;
}
});
async function loadLogs(page = 1, pageSize = pager.pageSize) {
const result = await api.retrievalLogs({ page, pageSize });
logs.value = result.items;
Object.assign(pager, {
page: result.page,
pageSize: result.pageSize,
total: result.total,
});
}
async function loadRetention() {
const result = await api.retrievalRetention();
retentionDays.value = result.retentionDays;
retentionState.value = result;
}
async function cleanup() {
if (!cleanupBefore.value) return ElMessage.warning("请选择清理日期");
const estimate = await api.estimateRetrievalCleanup(cleanupBefore.value);
await ElMessageBox.confirm(
`预计清理 ${estimate.estimatedCount} 条检索日志及关联明细,操作日志不会被删除。`,
"确认清理",
{ type: "warning" },
);
const result = await api.cleanupRetrievalLogs(cleanupBefore.value);
ElMessage.success(`已清理 ${result.deleted} 条日志`);
await loadLogs(1);
}
async function saveRetention() {
await api.saveRetrievalRetention(retentionDays.value);
ElMessage.success(
retentionDays.value == null
? "已设为永久保留"
: `已设为保留 ${retentionDays.value}`,
);
await loadRetention();
}
async function openDetail(row: RetrievalLogItem) {
selected.value = await api.retrievalLogDetail(row.id);
detailOpen.value = true;
}
</script>
<template>
<section v-loading="loading" class="feature-page">
<div class="page-head inline">
<div>
<h2>检索日志</h2>
<p>
查看每条回答使用的知识目录Agent 决策搜索候选重排和章节回读
</p>
</div>
<div class="cleanup-controls">
<el-date-picker
v-model="cleanupBefore"
value-format="YYYY-MM-DD"
type="date"
placeholder="清理此日期前"
/><el-button type="danger" plain @click="cleanup">清理日志</el-button>
</div>
</div>
<section class="retention-panel">
<div>
<strong>定期清理</strong
><span>每日自动分批删除过期检索日志和关联候选明细</span>
</div>
<el-select v-model="retentionChoice" placeholder="保留时间"
><el-option label="永久保留" :value="-1" /><el-option
label="30天"
:value="30" /><el-option label="90天" :value="90" /><el-option
label="180天"
:value="180" /></el-select
><el-button type="primary" @click="saveRetention">保存规则</el-button
><small>最近执行{{ retentionState?.lastRunAt || "尚未执行" }}</small>
</section>
<el-table :data="logs" stripe
><el-table-column prop="id" label="ID" width="80" /><el-table-column
prop="sessionId"
label="会话"
width="90"
/><el-table-column
prop="question"
label="用户问题"
min-width="280"
show-overflow-tooltip
/><el-table-column label="调用知识" width="100"
><template #default="{ row }"
><el-tag :type="row.knowledgeCalled ? 'success' : 'info'">{{
row.knowledgeCalled ? "已调用" : "未调用"
}}</el-tag></template
></el-table-column
><el-table-column
prop="selectedKnowledgeIds"
label="知识库"
width="120"
/><el-table-column
prop="status"
label="状态"
width="100"
/><el-table-column
prop="toolCostMs"
label="工具耗时"
width="110"
/><el-table-column
prop="totalCostMs"
label="总耗时"
width="100"
/><el-table-column
prop="createdAt"
label="时间"
width="180"
/><el-table-column label="操作" width="90" fixed="right"
><template #default="{ row }"
><el-button link type="primary" @click="openDetail(row)"
>详情</el-button
></template
></el-table-column
></el-table
>
<AdminPagination
:page="pager.page"
:page-size="pager.pageSize"
:total="pager.total"
@change="loadLogs"
/>
<el-drawer v-model="detailOpen" size="820px" title="检索日志详情"
><template v-if="selected"
><dl class="knowledge-meta">
<dt>问题</dt>
<dd>{{ selected.question }}</dd>
<dt>安全规则</dt>
<dd>{{ selected.safetyRuleVersion }}</dd>
<dt>目录哈希</dt>
<dd>{{ selected.catalogHash }}</dd>
<dt>最终章节</dt>
<dd>{{ selected.finalSectionIds || "未读取知识章节" }}</dd>
</dl>
<h3>本轮 KnowledgeList</h3>
<pre class="trace-json">{{
JSON.stringify(selected.catalogSnapshot, null, 2)
}}</pre>
<h3>Agent 工具轨迹</h3>
<pre class="trace-json">{{
JSON.stringify(selected.toolTrace, null, 2)
}}</pre>
<h3>候选切片与取舍</h3>
<el-table :data="selected.candidates"
><el-table-column
prop="knowledgeId"
label="知识库"
width="90" /><el-table-column
prop="knowledgeName"
label="名称"
min-width="150"
show-overflow-tooltip /><el-table-column
prop="knowledgeTypeLabel"
label="类型优先级"
width="170" /><el-table-column
prop="versionId"
label="版本"
width="80" /><el-table-column
prop="sectionId"
label="章节"
width="80" /><el-table-column
prop="lexicalScore"
label="词法分"
width="100" /><el-table-column
prop="rerankScore"
label="重排分"
width="100" /><el-table-column label="采用" width="80"
><template #default="{ row }">{{
row.selected ? "是" : "否"
}}</template></el-table-column
><el-table-column
prop="discardReason"
label="舍弃原因"
min-width="180"
/></el-table>
<h3>最终回答</h3>
<pre class="trace-json">{{
selected.finalAnswer || "尚未生成回答"
}}</pre>
</template></el-drawer
>
</section>
</template>

View File

@@ -0,0 +1,444 @@
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { ref, watch } from "vue";
import { api } from "../services/api";
import type { AdminUserDetail } from "../types/api";
const props = defineProps<{ modelValue: boolean; userId: number | null }>();
defineEmits<{ "update:modelValue": [value: boolean] }>();
const detail = ref<AdminUserDetail | null>(null);
const loading = ref(false);
const reportGenerating = ref("");
const settlementRetrying = ref<number | null>(null);
watch(
() => [props.modelValue, props.userId] as const,
([open, userId]) => {
if (open && userId) void loadDetail(userId);
},
{ immediate: true },
);
async function loadDetail(userId = props.userId) {
if (!userId) return;
loading.value = true;
try {
detail.value = await api.userDetail(userId);
} finally {
loading.value = false;
}
}
async function generateReport(type: "weekly" | "monthly" | "stage") {
if (!detail.value) return;
reportGenerating.value = type;
try {
await api.generateUserReport(detail.value.user.id, { reportType: type });
await loadDetail(detail.value.user.id);
ElMessage.success("报告任务已进入后台队列,不会阻塞聊天服务");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "报告生成失败");
} finally {
reportGenerating.value = "";
}
}
async function retryTopicSettlement(topicId: number) {
if (!detail.value || settlementRetrying.value !== null) return;
settlementRetrying.value = topicId;
try {
await api.retryTopicSettlement(detail.value.user.id, topicId);
await loadDetail(detail.value.user.id);
ElMessage.success("主题沉淀任务已重新进入后台队列");
} catch (error) {
ElMessage.error(
error instanceof Error ? error.message : "主题沉淀重试失败",
);
} finally {
settlementRetrying.value = null;
}
}
function reportStatusLabel(status: string) {
return (
(
{
pending: "等待生成",
running: "生成中",
success: "已完成",
fallback: "已降级沉淀",
empty: "暂无沉淀",
failed: "生成失败",
} as Record<string, string>
)[status] || status
);
}
function formatMoney(value?: number | null, currency = "CNY") {
if (typeof value !== "number") return "-";
if (currency === "MIXED") return "多币种,见明细";
return `${currency || "CNY"} ${value.toFixed(6)}`;
}
</script>
<template>
<el-drawer
:model-value="modelValue"
size="860px"
title="用户详情"
@update:model-value="$emit('update:modelValue', $event)"
>
<div v-loading="loading" class="user-detail-body">
<template v-if="detail">
<section class="user-detail-head">
<div>
<h3>
{{
detail.user.name || detail.user.nickname || detail.user.phone
}}
</h3>
<p>
{{ detail.user.phone }} ·
{{ detail.user.status === 1 ? "已启用" : "已停用" }}
</p>
</div>
<div class="user-detail-tags">
<el-tag
v-if="detail.metrics.isHighFrequency"
type="warning"
effect="plain"
>高频使用</el-tag
>
<el-tag v-if="detail.metrics.isInactive" type="info" effect="plain"
>近30天低活跃</el-tag
>
<el-tag type="success" effect="plain">{{
detail.user.entitlement?.name || "默认基础版"
}}</el-tag>
</div>
</section>
<section class="chat-summary user-metric-grid">
<div>
<span>本月主题</span
><strong
>{{ detail.metrics.monthTopics }}/{{
detail.user.entitlement?.monthlyTopicLimit ?? "不限"
}}</strong
>
</div>
<div>
<span>近30天活跃</span
><strong>{{ detail.metrics.recentActiveDays }} </strong>
</div>
<div>
<span>总会话</span
><strong>{{ detail.metrics.totalSessions }}</strong>
</div>
<div>
<span>总消息</span
><strong>{{ detail.metrics.totalMessages }}</strong>
</div>
<div>
<span>主题总数</span
><strong>{{ detail.metrics.totalTopics }}</strong>
</div>
<div>
<span>AI 请求</span
><strong>{{ detail.metrics.aiRequestCount }}</strong>
</div>
<div>
<span> Token</span
><strong>{{ detail.metrics.totalToken }}</strong>
</div>
<div>
<span>估算成本</span
><strong>{{
formatMoney(
detail.metrics.estimatedCost,
detail.metrics.costCurrency,
)
}}</strong>
</div>
</section>
<section class="audit-detail-grid">
<div>
<span>权益有效期</span
><strong>{{
detail.user.entitlement?.expiredAt ||
detail.user.expiredAt ||
"长期有效"
}}</strong>
</div>
<div>
<span>最近登录</span
><strong>{{ detail.user.lastLoginAt || "-" }}</strong>
</div>
<div>
<span>最近发言</span
><strong>{{ detail.metrics.lastMessageAt || "-" }}</strong>
</div>
<div>
<span>生成记录</span
><strong
>求助卡 {{ detail.metrics.helpCardCount }} / 分享稿
{{ detail.metrics.shareDraftCount }}</strong
>
</div>
</section>
<h3 class="detail-title">近期实修回顾</h3>
<section v-if="detail.growthProfile" class="topic-summary-card">
<pre>{{ detail.growthProfile.reviewText || "暂无近期回顾" }}</pre>
<div
v-if="detail.growthProfile.currentFocus"
class="topic-summary-fields"
>
<p>
<strong>近期关注</strong>{{ detail.growthProfile.currentFocus }}
</p>
</div>
</section>
<el-empty
v-else
description="该用户暂无新版近期实修回顾,将在下一次主题沉淀后生成"
:image-size="64"
/>
<section class="user-report-head">
<div>
<h3 class="detail-title">周期实修回顾</h3>
<p>
只基于本周期主题摘要回顾近期关注不评价成长结果同一周期重复生成会覆盖旧版本
</p>
</div>
<div class="user-report-actions">
<el-button size="small" @click="loadDetail()">刷新状态</el-button>
<el-button
size="small"
:loading="reportGenerating === 'weekly'"
@click="generateReport('weekly')"
>生成周报</el-button
>
<el-button
size="small"
:loading="reportGenerating === 'monthly'"
@click="generateReport('monthly')"
>生成月报</el-button
>
<el-button
size="small"
:loading="reportGenerating === 'stage'"
@click="generateReport('stage')"
>生成阶段回顾</el-button
>
</div>
</section>
<el-collapse
v-if="detail.recentReports.length"
class="topic-summary-collapse"
>
<el-collapse-item
v-for="report in detail.recentReports"
:key="report.id"
:title="`#${report.id} ${report.title} / ${reportStatusLabel(report.status)}`"
>
<section class="topic-summary-card">
<div class="topic-summary-meta">
<span>状态{{ reportStatusLabel(report.status) }}</span
><span>类型{{ report.reportTypeLabel }}</span>
<span
>周期{{ report.periodStart }}
{{ report.periodEnd }}</span
><span>来源主题{{ report.sourceTopicIds.length }}</span>
<span>来源摘要{{ report.sourceSummaryIds.length }}</span
><span>模型{{ report.modelName || "-" }}</span>
<span
>触发方式{{
report.generatedBy.startsWith("schedule:")
? "系统定时"
: "管理员手动"
}}</span
>
<span
>执行次数{{ report.attemptCount }}/{{
report.maxAttempts
}}</span
>
<span v-if="report.nextRunAt"
>下次执行{{ report.nextRunAt }}</span
><span>完成时间{{ report.finishedAt || "-" }}</span>
</div>
<p v-if="report.errorMessage" class="report-error">
最近错误{{ report.errorMessage }}
</p>
<pre v-if="report.content">{{ report.content }}</pre>
<p
v-else-if="
report.status === 'pending' || report.status === 'running'
"
class="report-pending"
>
后台正在生成报告,可以先关闭抽屉,稍后回来查看。
</p>
</section>
</el-collapse-item>
</el-collapse>
<el-empty v-else description="该用户暂无周期报告" :image-size="64" />
<h3 class="detail-title">最近主题</h3>
<el-collapse
v-if="detail.recentTopics.length"
class="topic-summary-collapse"
>
<el-collapse-item
v-for="topic in detail.recentTopics"
:key="topic.id"
:title="`#${topic.id} ${topic.title} / ${topic.status}`"
>
<section class="topic-summary-card">
<div class="topic-summary-meta">
<span>核心问题:{{ topic.coreQuestion }}</span
><span>消息数:{{ topic.messageCount }}</span>
<span
>Token{{ topic.tokenInput }}/{{ topic.tokenOutput }}</span
><span
>额度:{{ topic.quotaDeducted ? "已计入" : "不计入" }}</span
>
<span>开始:{{ topic.startedAt }}</span
><span>更新:{{ topic.updatedAt }}</span>
</div>
<template v-if="topic.summary">
<div class="topic-summary-meta">
<span
>沉淀状态:{{
reportStatusLabel(topic.summary.status)
}}</span
>
<span
>执行次数:{{ topic.summary.attemptCount }}/{{
topic.summary.maxAttempts
}}</span
>
<span v-if="topic.summary.lastStartedAt"
>开始执行:{{ topic.summary.lastStartedAt }}</span
>
<span v-if="topic.summary.finishedAt"
>完成:{{ topic.summary.finishedAt }}</span
>
</div>
<p v-if="topic.summary.errorMessage" class="report-error">
最近错误:{{ topic.summary.errorMessage }}
</p>
<pre
v-if="
topic.summary.status === 'success' ||
topic.summary.status === 'fallback'
"
>{{ topic.summary.summary }}</pre
>
<p
v-else-if="
topic.summary.status === 'pending' ||
topic.summary.status === 'running'
"
class="report-pending"
>
主题已结束,摘要和近期实修回顾正在后台生成。
</p>
<div
v-if="
topic.summary.status === 'success' ||
topic.summary.status === 'fallback'
"
class="topic-summary-fields"
>
<p v-if="topic.summary.currentFocus">
<strong>本次关注</strong>{{ topic.summary.currentFocus }}
</p>
<p v-if="topic.summary.nextObservation">
<strong>可以继续留意</strong
>{{ topic.summary.nextObservation }}
</p>
</div>
<el-button
v-if="topic.summary.status === 'failed'"
size="small"
type="primary"
:loading="settlementRetrying === topic.id"
@click="retryTopicSettlement(topic.id)"
>重新沉淀</el-button
>
</template>
<el-empty
v-else
description="该主题尚未沉淀摘要"
:image-size="60"
/>
</section>
</el-collapse-item>
</el-collapse>
<el-empty v-else description="该用户暂无主题记录" :image-size="64" />
<h3 class="detail-title">
最近老师求助卡({{ detail.recentHelpCards.length }}
</h3>
<el-collapse
v-if="detail.recentHelpCards.length"
class="topic-summary-collapse"
>
<el-collapse-item
v-for="card in detail.recentHelpCards"
:key="card.id"
:title="`#${card.id} / 主题 #${card.topicSessionId} / ${card.copied ? '用户已复制' : '未复制'}`"
>
<section class="topic-summary-card">
<div class="topic-summary-meta">
<span>生成:{{ card.createdAt }}</span
><span>复制:{{ card.copiedAt || "-" }}</span
><span>来源:{{ card.source }}</span>
</div>
<pre>{{ card.content }}</pre>
</section>
</el-collapse-item>
</el-collapse>
<el-empty
v-else
description="该用户暂无求助卡生成记录"
:image-size="64"
/>
<h3 class="detail-title">
最近班级分享稿({{ detail.recentShareDrafts.length }}
</h3>
<el-collapse
v-if="detail.recentShareDrafts.length"
class="topic-summary-collapse"
>
<el-collapse-item
v-for="draft in detail.recentShareDrafts"
:key="draft.id"
:title="`#${draft.id} / 主题 #${draft.topicSessionId} / ${draft.copied ? '用户已复制' : '未复制'}`"
>
<section class="topic-summary-card">
<div class="topic-summary-meta">
<span>生成:{{ draft.createdAt }}</span
><span>复制:{{ draft.copiedAt || "-" }}</span
><span>来源:{{ draft.source }}</span>
</div>
<pre>{{ draft.content }}</pre>
</section>
</el-collapse-item>
</el-collapse>
<el-empty
v-else
description="该用户暂无分享稿生成记录"
:image-size="64"
/>
</template>
</div>
</el-drawer>
</template>

View File

@@ -0,0 +1,466 @@
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { onMounted, reactive, ref } from "vue";
import { api } from "../services/api";
import type {
AdminUser,
EntitlementPlan,
UserImportResult,
} from "../types/api";
import AdminPagination from "./AdminPagination.vue";
import TableRowActions from "./TableRowActions.vue";
import UserDetailDrawer from "./UserDetailDrawer.vue";
import UserEntitlementRenewDialog from "./UserEntitlementRenewDialog.vue";
const loading = ref(false);
const users = ref<AdminUser[]>([]);
const entitlementPlans = ref<EntitlementPlan[]>([]);
const pager = reactive({ page: 1, pageSize: 20, total: 0 });
const keyword = ref("");
const filters = reactive({
planId: undefined as number | undefined,
status: "all",
});
const selectedUsers = ref<AdminUser[]>([]);
const renewDialogOpen = ref(false);
const renewTargets = ref<AdminUser[]>([]);
const renewSubmitting = ref(false);
const detailOpen = ref(false);
const detailUserId = ref<number | null>(null);
const studentImportFile = ref<File | null>(null);
const studentImportInput = ref<HTMLInputElement | null>(null);
const studentImportResult = ref<UserImportResult | null>(null);
const studentForm = reactive({
phone: "",
name: "",
nickname: "",
status: 1,
dailyChatLimit: 100,
expiredAt: "",
});
onMounted(async () => {
loading.value = true;
try {
await Promise.all([loadEntitlementPlans(), loadUsers()]);
} finally {
loading.value = false;
}
});
async function loadUsers(page = 1, pageSize = pager.pageSize) {
const result = await api.users({
keyword: keyword.value,
planId: filters.planId,
entitlementStatus: filters.status,
page,
pageSize,
});
users.value = result.items;
selectedUsers.value = [];
Object.assign(pager, {
page: result.page,
pageSize: result.pageSize,
total: result.total,
});
}
async function reloadCurrentPage() {
await loadUsers(pager.page, pager.pageSize);
}
async function loadEntitlementPlans() {
entitlementPlans.value = await api.entitlementPlans(true);
}
function resetFilters() {
keyword.value = "";
filters.planId = undefined;
filters.status = "all";
void loadUsers(1);
}
function handleSelectionChange(rows: AdminUser[]) {
selectedUsers.value = rows;
}
function openRenewDialog(targets: AdminUser[]) {
if (!targets.length) return ElMessage.warning("请先选择需要续期的用户");
renewTargets.value = targets;
renewDialogOpen.value = true;
}
async function submitEntitlementRenew(payload: {
extensionDays: number;
remark: string;
}) {
if (!renewTargets.value.length || renewSubmitting.value) return;
renewSubmitting.value = true;
const idempotencyKey =
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `renew-${Date.now()}-${Math.random().toString(36).slice(2)}`;
try {
if (renewTargets.value.length === 1) {
await api.renewUserEntitlement(renewTargets.value[0].id, {
...payload,
idempotencyKey,
});
ElMessage.success("权益续期成功");
} else {
const result = await api.batchRenewUserEntitlement({
userIds: renewTargets.value.map((user) => user.id),
...payload,
idempotencyKey,
});
if (result.failureCount) {
const failure = result.failed[0];
ElMessage.warning(
`成功 ${result.successCount} 人,失败 ${result.failureCount}${failure ? `${failure.name || failure.userId}${failure.reason}` : ""}`,
);
} else {
ElMessage.success(`已为 ${result.successCount} 位用户续期`);
}
}
renewDialogOpen.value = false;
await reloadCurrentPage();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "权益续期失败");
} finally {
renewSubmitting.value = false;
}
}
function entitlementStatusLabel(row: AdminUser) {
const status = row.entitlement?.lifecycleStatus;
if (status === "expired_fallback") return "已到期,已降级";
if (status === "expiring_7") return "7 天内到期";
if (status === "expiring_30") return "30 天内到期";
if (status === "long_term") return "长期有效";
if (status === "default") return "默认权益";
return "生效中";
}
function entitlementStatusType(row: AdminUser) {
const status = row.entitlement?.lifecycleStatus;
if (status === "expired_fallback") return "danger";
if (status === "expiring_7" || status === "expiring_30") return "warning";
if (status === "active" || status === "long_term") return "success";
return "info";
}
function formatEntitlementDate(value?: string | null) {
if (!value) return "长期";
const date = new Date(value);
return Number.isNaN(date.getTime())
? value
: date.toLocaleDateString("zh-CN");
}
async function assignUserEntitlement(row: AdminUser, planId: number) {
row.entitlement = await api.assignUserEntitlement(row.id, { planId });
ElMessage.success("用户权益已更新");
}
function openUserDetail(row: AdminUser) {
detailUserId.value = row.id;
detailOpen.value = true;
}
async function saveUser(row: AdminUser) {
await api.updateUser(row.id, {
status: row.status,
dailyChatLimit: row.dailyChatLimit,
});
ElMessage.success("用户已更新");
await reloadCurrentPage();
}
async function createStudent() {
if (!studentForm.phone || !studentForm.name)
return ElMessage.error("请填写手机号和姓名");
await api.createUser({
phone: studentForm.phone,
name: studentForm.name,
nickname: studentForm.nickname || null,
status: studentForm.status,
dailyChatLimit: studentForm.dailyChatLimit,
expiredAt: studentForm.expiredAt
? `${studentForm.expiredAt}T23:59:59`
: null,
});
ElMessage.success("学员已添加");
resetStudentForm();
await loadUsers(1);
}
function handleStudentImportFile(event: Event) {
studentImportFile.value =
(event.target as HTMLInputElement).files?.[0] ?? null;
}
async function importStudents() {
if (!studentImportFile.value) return ElMessage.error("请先选择 Excel 文件");
studentImportResult.value = await api.importUsersExcel(
studentImportFile.value,
);
ElMessage.success(
`导入完成:新增 ${studentImportResult.value.created},更新 ${studentImportResult.value.updated},失败 ${studentImportResult.value.failed}`,
);
studentImportFile.value = null;
if (studentImportInput.value) studentImportInput.value.value = "";
await loadUsers(1);
}
function resetStudentForm() {
Object.assign(studentForm, {
phone: "",
name: "",
nickname: "",
status: 1,
dailyChatLimit: 100,
expiredAt: "",
});
}
async function deleteUser(row: AdminUser) {
if (
!window.confirm(
`确认删除用户 ${row.phone} 吗?删除后前台将无法继续使用该账号。`,
)
)
return;
await api.deleteUser(row.id);
ElMessage.success("用户已删除");
await reloadCurrentPage();
}
</script>
<template>
<section v-loading="loading" class="feature-page">
<div class="page-head inline">
<div>
<h2>用户管理</h2>
<p>维护学员名单只有名单内启用学员可以登录用户端</p>
</div>
</div>
<section class="student-tools">
<div class="student-tool-panel">
<h3>手动添加学员</h3>
<div class="student-form-grid">
<el-input v-model="studentForm.phone" placeholder="手机号" />
<el-input v-model="studentForm.name" placeholder="姓名" />
<el-input v-model="studentForm.nickname" placeholder="昵称(可选)" />
<el-input-number v-model="studentForm.dailyChatLimit" :min="0" />
<label class="record-date-field student-expiry-field">
<span>有效期</span>
<input
v-model="studentForm.expiredAt"
type="date"
aria-label="学员有效期"
title="留空表示长期有效"
/>
</label>
<div class="student-status-field">
<span>启用</span>
<el-switch
v-model="studentForm.status"
:active-value="1"
:inactive-value="0"
aria-label="启用学员账号"
/>
</div>
</div>
<div class="actions compact">
<el-button type="primary" @click="createStudent">添加学员</el-button>
<el-button @click="resetStudentForm">清空</el-button>
</div>
</div>
<div class="student-tool-panel">
<h3>批量导入学员</h3>
<p class="tool-help">下载模板后填写学员信息再上传 .xlsx 文件导入</p>
<div class="student-file-import">
<input
ref="studentImportInput"
class="file-input"
type="file"
accept=".xlsx"
@change="handleStudentImportFile"
/>
<span class="file-name">{{
studentImportFile?.name || "未选择文件"
}}</span>
</div>
<div class="actions compact">
<el-button @click="api.downloadStudentTemplate()">下载模板</el-button>
<el-button type="primary" @click="importStudents"
>导入 Excel</el-button
>
<span v-if="studentImportResult" class="import-result"
>新增 {{ studentImportResult.created }}更新
{{ studentImportResult.updated }}失败
{{ studentImportResult.failed }}</span
>
</div>
<div
v-if="studentImportResult?.failures.length"
class="import-failures"
>
<span
v-for="failure in studentImportResult.failures.slice(0, 5)"
:key="`${failure.row}-${failure.phone}`"
> {{ failure.row }} {{ failure.phone || "-" }}{{
failure.reason
}}</span
>
</div>
</div>
</section>
<section class="user-list-toolbar">
<el-input
v-model="keyword"
placeholder="手机号或姓名"
clearable
@keyup.enter="loadUsers(1)"
/>
<el-select v-model="filters.planId" placeholder="全部权益版本" clearable>
<el-option
v-for="plan in entitlementPlans.filter((item) => item.status === 1)"
:key="plan.id"
:label="plan.name"
:value="plan.id"
/>
</el-select>
<el-select v-model="filters.status" aria-label="权益状态">
<el-option label="全部权益状态" value="all" /><el-option
label="生效中"
value="active"
/>
<el-option label="7 天内到期" value="expiring7" /><el-option
label="8-30 天内到期"
value="expiring30"
/>
<el-option label="已到期" value="expired" /><el-option
label="使用默认权益"
value="default"
/>
</el-select>
<el-button type="primary" @click="loadUsers(1)">筛选</el-button>
<el-button @click="resetFilters">重置</el-button>
<el-button
class="batch-renew-button"
:disabled="!selectedUsers.length"
@click="openRenewDialog(selectedUsers)"
>批量续期{{
selectedUsers.length ? `${selectedUsers.length}` : ""
}}</el-button
>
</section>
<el-table :data="users" stripe @selection-change="handleSelectionChange">
<el-table-column type="selection" width="48" /><el-table-column
prop="id"
label="ID"
width="80"
/>
<el-table-column
prop="phone"
label="手机号"
width="150"
/><el-table-column prop="name" label="姓名" width="140" />
<el-table-column prop="nickname" label="昵称" width="140" />
<el-table-column label="状态" width="130"
><template #default="{ row }"
><el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0" /></template
></el-table-column>
<el-table-column label="每日额度" width="150"
><template #default="{ row }"
><el-input-number
v-model="row.dailyChatLimit"
:min="0"
size="small" /></template
></el-table-column>
<el-table-column prop="dailyChatUsed" label="已用" width="90" />
<el-table-column label="权益版本" min-width="300">
<template #default="{ row }">
<div class="user-entitlement-cell">
<el-select
:model-value="row.entitlement?.planId"
placeholder="默认基础版"
size="small"
@change="(planId: number) => assignUserEntitlement(row, planId)"
>
<el-option
v-for="plan in entitlementPlans.filter(
(item) => item.status === 1,
)"
:key="plan.id"
:label="plan.name"
:value="plan.id"
/>
</el-select>
<small
>{{ row.entitlement?.name || "默认基础版" }} ·
{{
row.entitlement?.lifecycleStatus === "expired_fallback"
? `原 ${row.entitlement?.previousPlanName || "权益"} ${formatEntitlementDate(row.entitlement?.previousExpiredAt)} 到期`
: `有效至 ${formatEntitlementDate(row.entitlement?.expiredAt)}`
}}</small
>
<div class="entitlement-meta-row">
<el-tag size="small" :type="entitlementStatusType(row)">{{
entitlementStatusLabel(row)
}}</el-tag
><span
>本月主题 {{ row.entitlement?.monthlyTopicUsed ?? 0 }}/{{
row.entitlement?.monthlyTopicLimit ?? "不限"
}}</span
>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="lastLoginAt" label="最近登录" width="180" />
<el-table-column label="操作" width="210" fixed="right">
<template #default="{ row }">
<TableRowActions>
<el-button size="small" @click="openUserDetail(row)"
>详情</el-button
>
<el-button size="small" type="primary" @click="saveUser(row)"
>保存</el-button
>
<el-dropdown trigger="click"
><el-button size="small">更多</el-button
><template #dropdown
><el-dropdown-menu
><el-dropdown-item @click="openRenewDialog([row])"
>权益续期</el-dropdown-item
><el-dropdown-item @click="deleteUser(row)"
>删除用户</el-dropdown-item
></el-dropdown-menu
></template
></el-dropdown
>
</TableRowActions>
</template>
</el-table-column>
</el-table>
<AdminPagination
:page="pager.page"
:page-size="pager.pageSize"
:total="pager.total"
@change="loadUsers"
/>
<UserEntitlementRenewDialog
v-model="renewDialogOpen"
:users="renewTargets"
:submitting="renewSubmitting"
@submit="submitEntitlementRenew"
/>
<UserDetailDrawer v-model="detailOpen" :user-id="detailUserId" />
</section>
</template>