refactor: 拆分管理后台页面并按需加载
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user