feat: improve audit exports and admin analytics

This commit is contained in:
2026-08-25 18:49:56 +08:00
parent 910e67bd89
commit 5740bd26e7
55 changed files with 1654 additions and 269 deletions

View File

@@ -1137,8 +1137,8 @@ async function clearFeishuCache() {
:can-detail="can('feedback.detail')"
:can-export="can('feedback.export')"
/>
<UserBehaviorAnalysisView v-if="activeMenu === 'behavior'" />
<RecordAuditView v-if="activeMenu === 'records'" />
<UserBehaviorAnalysisView v-if="activeMenu === 'behavior'" :can-export="can('behavior.export')" />
<RecordAuditView v-if="activeMenu === 'records'" :can-export="can('records.export')" />
<AdminManagementView v-if="activeMenu === 'admins'" />
</section>
</section>

View File

@@ -5,6 +5,7 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { api } from "../services/api";
import type { AgentBatchItem, AgentBatchJob, AgentBatchJobDetail } from "../types/api";
import AdminPagination from "./AdminPagination.vue";
import { formatDateTime } from "../utils/dateTime";
const props = defineProps<{
config: Record<string, unknown>;
@@ -165,10 +166,6 @@ function executionLabel(job: AgentBatchJob) {
return job.executionMode === "concurrent" ? `并发 · ${job.concurrencyLimit}` : "逐条串行";
}
function formatDate(value?: string | null) {
return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "—";
}
function itemDuration(item: AgentBatchItem) {
return item.durationMs == null ? "—" : `${(item.durationMs / 1000).toFixed(1)}`;
}
@@ -213,7 +210,7 @@ function errorMessage(error: unknown, fallback: string) {
<div class="agent-batch-list-head"><div><h4>测试记录</h4><p>任务在后台执行离开页面不会中断</p></div><el-button :loading="loading" @click="loadJobs()">刷新</el-button></div>
<el-table v-loading="loading" :data="jobs" stripe>
<el-table-column label="任务" min-width="190"><template #default="{ row }"><strong>{{ row.name }}</strong><small class="agent-batch-cell-note">#{{ row.id }} · {{ formatDate(row.createdAt) }}</small></template></el-table-column>
<el-table-column label="任务" min-width="190"><template #default="{ row }"><strong>{{ row.name }}</strong><small class="agent-batch-cell-note">#{{ row.id }} · {{ formatDateTime(row.createdAt, "—") }}</small></template></el-table-column>
<el-table-column label="配置快照" min-width="180"><template #default="{ row }"><span>{{ row.modelName }}</span><small class="agent-batch-cell-note">{{ row.knowledgeNames.join('、') }}</small></template></el-table-column>
<el-table-column label="进度" min-width="190"><template #default="{ row }"><el-progress :percentage="row.progress" :status="row.status === 'completed' ? 'success' : undefined" /><small class="agent-batch-cell-note">{{ row.processedCount }}/{{ row.totalCount }} · 成功 {{ row.successCount }} · 失败 {{ row.failedCount }}</small></template></el-table-column>
<el-table-column label="执行方式" width="118"><template #default="{ row }"><el-tag type="info">{{ executionLabel(row) }}</el-tag></template></el-table-column>

View File

@@ -9,6 +9,7 @@ import AgentBatchTestPanel from "./AgentBatchTestPanel.vue";
import AgentResponseDepthControl from "./AgentResponseDepthControl.vue";
import AdminPagination from "./AdminPagination.vue";
import StreamingMarkdownMessage from "./StreamingMarkdownMessage.vue";
import { formatDateTime } from "../utils/dateTime";
const props = withDefaults(defineProps<{ previewKnowledgeId?: number | null; canBatch?: boolean }>(), { canBatch: false });
const emit = defineEmits<{ consumedPreview: [] }>();
@@ -479,11 +480,6 @@ function changeTypeTag(value?: string) {
return "info";
}
function formatDate(value?: string | null) {
if (!value) return "尚未保存";
return new Date(value).toLocaleString("zh-CN", { hour12: false });
}
function errorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
@@ -497,7 +493,7 @@ function errorMessage(error: unknown, fallback: string) {
</div>
<div class="agent-current-summary">
<el-tag type="success">当前版本 {{ prompt?.id ? `#${prompt.id}` : "系统默认" }}</el-tag>
<span>{{ prompt?.updatedByName || "系统默认" }} · {{ formatDate(prompt?.updatedAt) }}</span>
<span>{{ prompt?.updatedByName || "系统默认" }} · {{ formatDateTime(prompt?.updatedAt, "尚未保存") }}</span>
</div>
</div>
@@ -697,7 +693,7 @@ function errorMessage(error: unknown, fallback: string) {
<strong>版本 #{{ item.id }}</strong>
<el-tag v-if="item.isCurrent" type="success" size="small">当前</el-tag>
<el-tag :type="changeTypeTag(item.changeType)" size="small">{{ changeTypeLabel(item.changeType) }}</el-tag>
<span>{{ item.updatedByName }} · {{ formatDate(item.updatedAt) }}</span>
<span>{{ item.updatedByName }} · {{ formatDateTime(item.updatedAt, "尚未保存") }}</span>
</div>
<p>{{ item.preview || "空内容" }}</p>
<div class="prompt-history-foot">
@@ -759,7 +755,7 @@ function errorMessage(error: unknown, fallback: string) {
<el-dialog v-model="historyDetailOpen" width="min(820px, 92vw)" title="主提示词历史版本" destroy-on-close>
<div v-loading="historyDetailLoading" class="prompt-history-detail">
<template v-if="selectedHistory">
<div class="prompt-history-detail-meta"><el-tag :type="changeTypeTag(selectedHistory.changeType)">{{ changeTypeLabel(selectedHistory.changeType) }}</el-tag><strong>版本 #{{ selectedHistory.id }}</strong><span>{{ selectedHistory.updatedByName }} · {{ formatDate(selectedHistory.updatedAt) }} · {{ selectedHistory.charCount }} 字符</span></div>
<div class="prompt-history-detail-meta"><el-tag :type="changeTypeTag(selectedHistory.changeType)">{{ changeTypeLabel(selectedHistory.changeType) }}</el-tag><strong>版本 #{{ selectedHistory.id }}</strong><span>{{ selectedHistory.updatedByName }} · {{ formatDateTime(selectedHistory.updatedAt, "尚未保存") }} · {{ selectedHistory.charCount }} 字符</span></div>
<pre>{{ selectedHistory.promptContent }}</pre>
</template>
</div>

View File

@@ -3,6 +3,7 @@ import { ElMessage } from "element-plus";
import { ref, watch } from "vue";
import { api } from "../services/api";
import type { AiLogRecord } from "../types/api";
import { formatDateTime } from "../utils/dateTime";
const props = defineProps<{ modelValue: boolean; logId: number | null }>();
const emit = defineEmits<{ "update:modelValue": [value: boolean] }>();
@@ -77,7 +78,7 @@ function formatMoney(value?: number | null, currency = "CNY") {
><span>知识命中{{ detail.knowledgeHit ? "是" : "否" }}</span
><span>路由原因{{ detail.routeReason || "-" }}</span
><span>耗时{{ detail.costMs || "-" }}ms</span
><span>时间{{ detail.createdAt }}</span>
><span>时间{{ formatDateTime(detail.createdAt) }}</span>
</section>
<p v-if="detail.errorMessage" class="error-text">
{{ detail.errorMessage }}

View File

@@ -6,19 +6,28 @@ import type { AttentionItem } from "../types/api";
import AdminPagination from "./AdminPagination.vue";
import AttentionRuleConfigPanel from "./AttentionRuleConfigPanel.vue";
import ChatDetailDrawer from "./ChatDetailDrawer.vue";
import { formatDateTime, toUtcIsoFromBusinessDate } from "../utils/dateTime";
defineProps<{ canConfig: boolean; canPreview: boolean }>();
const loading = ref(false);
const records = ref<AttentionItem[]>([]);
const pager = reactive({ page: 1, pageSize: 20, total: 0 });
const filters = reactive({ priority: "", dateFrom: "", dateTo: "" });
const chatDetailOpen = ref(false);
const sessionId = ref<number | null>(null);
const focusMessageId = 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 });
const result = await api.attentionList({
priority: filters.priority || undefined,
dateFrom: toUtcIsoFromBusinessDate(filters.dateFrom, "start"),
dateTo: toUtcIsoFromBusinessDate(filters.dateTo, "end"),
page,
pageSize,
});
records.value = result.items;
Object.assign(pager, {
page: result.page,
@@ -29,6 +38,17 @@ async function loadRecords(page = 1, pageSize = pager.pageSize) {
loading.value = false;
}
}
function applyFilters() {
if (filters.dateFrom && filters.dateTo && filters.dateFrom > filters.dateTo) {
ElMessage.warning("开始日期不能晚于结束日期");
return;
}
void loadRecords(1, pager.pageSize);
}
function resetFilters() {
Object.assign(filters, { priority: "", dateFrom: "", dateTo: "" });
void loadRecords(1, pager.pageSize);
}
async function updateStatus(row: AttentionItem, status: string) {
await api.updateAttention(row.id, status);
ElMessage.success("人工关注状态已更新");
@@ -52,8 +72,9 @@ async function deleteRecord(row: AttentionItem) {
ElMessage.success("人工关注记录已删除");
await loadRecords(pager.page, pager.pageSize);
}
function openChat(id: number) {
sessionId.value = id;
function openChat(row: AttentionItem) {
sessionId.value = row.sessionId;
focusMessageId.value = row.messageId;
chatDetailOpen.value = true;
}
function priorityLabel(priority: string) {
@@ -73,6 +94,22 @@ function priorityLabel(priority: string) {
</div>
<el-tabs class="attention-tabs">
<el-tab-pane label="关注记录">
<section class="record-filter attention-toolbar" aria-label="人工关注筛选">
<el-select v-model="filters.priority" placeholder="全部优先级" clearable aria-label="优先级">
<el-option label="紧急" value="urgent" />
<el-option label="重要" value="important" />
<el-option label="普通" value="normal" />
</el-select>
<div class="record-date-range" aria-label="触发时间范围">
<label class="record-date-field"><span>开始日期</span><input v-model="filters.dateFrom" type="date" aria-label="人工关注开始日期" /></label>
<span class="record-date-sep"></span>
<label class="record-date-field"><span>结束日期</span><input v-model="filters.dateTo" type="date" aria-label="人工关注结束日期" /></label>
</div>
<div class="record-filter-actions">
<el-button type="primary" @click="applyFilters">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
</section>
<el-table :data="records" stripe
><el-table-column label="优先级" width="100"
><template #default="{ row }"
@@ -116,13 +153,18 @@ function priorityLabel(priority: string) {
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"
><el-table-column label="触发时间" width="180"
><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template
></el-table-column><el-table-column label="操作" width="130" fixed="right"
><template #default="{ row }"
><el-button link type="primary" @click="openChat(row.sessionId)"
><el-tooltip
v-if="row.targetAvailable === false"
content="原会话或触发消息已删除"
placement="top"
>
<span><el-button link disabled>原会话</el-button></span>
</el-tooltip>
<el-button v-else link type="primary" @click="openChat(row)"
>原会话</el-button
><el-button link type="danger" @click="deleteRecord(row)"
>删除</el-button
@@ -134,7 +176,11 @@ function priorityLabel(priority: string) {
:page-size="pager.pageSize"
:total="pager.total"
@change="loadRecords"
/><ChatDetailDrawer v-model="chatDetailOpen" :session-id="sessionId" />
/><ChatDetailDrawer
v-model="chatDetailOpen"
:session-id="sessionId"
:focus-message-id="focusMessageId"
/>
</el-tab-pane>
<el-tab-pane v-if="canConfig || canPreview" label="筛选规则与预览" lazy>
<AttentionRuleConfigPanel :can-config="canConfig" :can-preview="canPreview" />
@@ -145,4 +191,18 @@ function priorityLabel(priority: string) {
<style scoped>
.attention-tabs { margin-top: 14px; }
.attention-toolbar {
align-items: center;
margin-bottom: 16px;
padding: 14px 16px;
border: 1px solid #e7ebf0;
border-radius: 12px;
background: #fafbfc;
}
@media (max-width: 900px) {
.attention-toolbar { align-items: stretch; }
.attention-toolbar .record-date-range { width: 100%; }
.attention-toolbar .record-date-field { flex: 1; width: auto; }
.attention-toolbar .record-filter-actions { width: 100%; }
}
</style>

View File

@@ -5,6 +5,7 @@ import { computed, onMounted, reactive, ref } from "vue";
import { api } from "../services/api";
import type { AttentionConfig, AttentionPreviewMessage, AttentionPreviewResult } from "../types/api";
import AdminPagination from "./AdminPagination.vue";
import { formatDateTime } from "../utils/dateTime";
const props = defineProps<{ canConfig: boolean; canPreview: boolean }>();
@@ -182,7 +183,7 @@ function sourceLabel(value: string) {
<el-table-column prop="userName" label="用户" width="120" />
<el-table-column prop="sessionTitle" label="会话" min-width="180" show-overflow-tooltip />
<el-table-column label="知识缺失" width="100"><template #default="{ row }"><el-tag :type="row.knowledgeMissing ? 'warning' : 'info'">{{ row.knowledgeMissing ? "是" : "否" }}</el-tag></template></el-table-column>
<el-table-column prop="createdAt" label="提问时间" width="180" />
<el-table-column label="提问时间" width="180"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
</el-table>
<AdminPagination :page="pager.page" :page-size="pager.pageSize" :total="pager.total" @change="loadPreviewMessages" />
</article>

View File

@@ -5,17 +5,24 @@ import { nextTick, reactive, ref, watch } from "vue";
import { api } from "../services/api";
import type { ChatDetail } from "../types/api";
import AdminPagination from "./AdminPagination.vue";
import { formatDateTime } from "../utils/dateTime";
const props = defineProps<{ modelValue: boolean; sessionId: number | null }>();
const props = defineProps<{
modelValue: boolean;
sessionId: number | null;
focusMessageId?: number | null;
canExport?: boolean;
}>();
const emit = defineEmits<{ "update:modelValue": [value: boolean] }>();
const detail = ref<ChatDetail | null>(null);
const loading = ref(false);
const messagesLoading = ref(false);
const exporting = ref(false);
const bodyRef = ref<HTMLElement | null>(null);
const pager = reactive({ page: 1, pageSize: 20, total: 0 });
watch(
() => [props.modelValue, props.sessionId] as const,
() => [props.modelValue, props.sessionId, props.focusMessageId] as const,
async ([open, sessionId]) => {
if (!open || !sessionId) return;
detail.value = null;
@@ -30,6 +37,7 @@ async function loadDetail(sessionId: number, page: number, pageSize: number) {
detail.value = await api.chatDetail(sessionId, {
messagePage: page,
messagePageSize: pageSize,
focusMessageId: props.focusMessageId || undefined,
});
const messagePage = detail.value.messagesPage;
Object.assign(pager, {
@@ -37,6 +45,7 @@ async function loadDetail(sessionId: number, page: number, pageSize: number) {
pageSize: messagePage?.pageSize ?? pageSize,
total: messagePage?.total ?? detail.value.messages.length,
});
await scrollToFocusedMessage();
} catch (error) {
ElMessage.error(
error instanceof Error ? error.message : "聊天详情加载失败",
@@ -46,6 +55,15 @@ async function loadDetail(sessionId: number, page: number, pageSize: number) {
}
}
async function scrollToFocusedMessage() {
if (!props.focusMessageId) return;
await nextTick();
const target = bodyRef.value?.querySelector<HTMLElement>(
`[data-message-id="${props.focusMessageId}"]`,
);
target?.scrollIntoView({ behavior: "smooth", block: "center" });
}
async function changeMessagePage(page: number, pageSize: number) {
if (!props.sessionId || !detail.value) return;
messagesLoading.value = true;
@@ -71,6 +89,19 @@ async function changeMessagePage(page: number, pageSize: number) {
messagesLoading.value = false;
}
}
async function exportConversation() {
if (!props.sessionId) return;
exporting.value = true;
try {
await api.exportChatDetail(props.sessionId);
ElMessage.success("当前完整对话已导出");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "完整对话导出失败");
} finally {
exporting.value = false;
}
}
</script>
<template>
@@ -82,6 +113,9 @@ async function changeMessagePage(page: number, pageSize: number) {
>
<div ref="bodyRef" v-loading="loading" class="chat-detail-body">
<template v-if="detail">
<div v-if="canExport" class="chat-detail-actions">
<el-button type="primary" plain :loading="exporting" @click="exportConversation">导出完整对话</el-button>
</div>
<section class="chat-summary">
<div>
<span>会话ID</span><strong>{{ detail.session.id }}</strong>
@@ -115,8 +149,8 @@ async function changeMessagePage(page: number, pageSize: number) {
><span>消息数{{ topic.messageCount }}</span
><span
>Token{{ topic.tokenInput }}/{{ topic.tokenOutput }}</span
><span>开始{{ topic.startedAt }}</span
><span>结束{{ topic.endedAt || "-" }}</span>
><span>开始{{ formatDateTime(topic.startedAt) }}</span
><span>结束{{ formatDateTime(topic.endedAt) }}</span>
</div>
<template v-if="topic.summary">
<pre>{{ topic.summary.summary }}</pre>
@@ -151,8 +185,8 @@ async function changeMessagePage(page: number, pageSize: number) {
: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>生成{{ formatDateTime(card.createdAt) }}</span
><span>复制{{ formatDateTime(card.copiedAt) }}</span
><span>来源{{ card.source }}</span>
</div>
<pre>{{ card.content }}</pre>
@@ -176,8 +210,8 @@ async function changeMessagePage(page: number, pageSize: number) {
: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>生成{{ formatDateTime(draft.createdAt) }}</span
><span>复制{{ formatDateTime(draft.copiedAt) }}</span
><span>来源{{ draft.source }}</span>
</div>
<pre>{{ draft.content }}</pre>
@@ -202,13 +236,22 @@ async function changeMessagePage(page: number, pageSize: number) {
v-for="message in detail.messages"
:key="message.id"
class="conversation-message"
:class="message.role"
:class="[
message.role,
{ 'focus-message': message.id === focusMessageId },
]"
:data-message-id="message.id"
>
<div class="message-meta">
<strong>{{
message.role === "user" ? "用户" : "大本营答疑助手"
}}</strong
><span>{{ message.createdAt }}</span>
><el-tag
v-if="message.id === focusMessageId"
type="warning"
effect="light"
>触发人工关注的消息</el-tag
><span>{{ formatDateTime(message.createdAt) }}</span>
</div>
<pre>{{ message.content }}</pre>
<div class="message-extra">
@@ -242,7 +285,7 @@ async function changeMessagePage(page: number, pageSize: number) {
><span>命中数{{ log.retrieveCount }}</span
><span>Token{{ log.totalToken || "-" }}</span
><span>耗时{{ log.costMs || "-" }}ms</span
><span>时间{{ log.createdAt }}</span>
><span>时间{{ formatDateTime(log.createdAt) }}</span>
</div>
<section
v-if="log.retrievedChunks?.length"
@@ -291,3 +334,26 @@ async function changeMessagePage(page: number, pageSize: number) {
</div>
</el-drawer>
</template>
<style scoped>
.chat-detail-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 14px;
}
.conversation-message.focus-message {
border: 2px solid #e6a23c;
background: #fffaf0;
box-shadow: 0 0 0 4px rgb(230 162 60 / 10%);
}
.conversation-message.focus-message .message-meta {
align-items: center;
gap: 10px;
}
.conversation-message.focus-message .message-meta span:last-child {
margin-left: auto;
}
</style>

View File

@@ -12,6 +12,7 @@ import type {
} from "../types/api";
import AdminPagination from "./AdminPagination.vue";
import ContentGenerationVariableEditor from "./ContentGenerationVariableEditor.vue";
import { formatDateTime } from "../utils/dateTime";
const activeType = ref<ContentGenerationType>("help_card");
const loading = ref(false);
@@ -259,11 +260,6 @@ async function restoreHistory(id: number) {
}
}
function formatTime(value?: string | null) {
if (!value) return "系统默认";
return new Date(value).toLocaleString("zh-CN", { hour12: false });
}
function changeTypeLabel(value: string) {
return { save: "保存", reset: "恢复默认", restore: "历史回滚", default: "系统默认" }[value] || value;
}
@@ -374,7 +370,7 @@ function changeTypeLabel(value: string) {
<section class="generation-history generation-panel">
<header>
<div><h3>版本记录</h3><p>当前配置{{ current?.updatedByName }} · {{ formatTime(current?.updatedAt) }}</p></div>
<div><h3>版本记录</h3><p>当前配置{{ current?.updatedByName }} · {{ formatDateTime(current?.updatedAt, "系统默认") }}</p></div>
<el-tag v-if="current" type="success">{{ changeTypeLabel(current.changeType) }}</el-tag>
</header>
<el-empty v-if="!history.length" description="尚未保存过版本,当前使用系统默认配置" :image-size="72" />
@@ -383,7 +379,7 @@ function changeTypeLabel(value: string) {
<div>
<strong>版本 #{{ item.id }} <el-tag v-if="item.isCurrent" size="small" type="success">当前</el-tag></strong>
<p>{{ item.preview }}</p>
<small>{{ changeTypeLabel(item.changeType) }} · {{ item.updatedByName }} · {{ formatTime(item.updatedAt) }}</small>
<small>{{ changeTypeLabel(item.changeType) }} · {{ item.updatedByName }} · {{ formatDateTime(item.updatedAt, "系统默认") }}</small>
</div>
<el-button @click="openHistory(item)">查看</el-button>
</article>
@@ -404,7 +400,7 @@ function changeTypeLabel(value: string) {
<div><dt>版本</dt><dd>#{{ selectedHistory.id }}</dd></div>
<div><dt>操作</dt><dd>{{ changeTypeLabel(selectedHistory.changeType) }}</dd></div>
<div><dt>操作人</dt><dd>{{ selectedHistory.updatedByName }}</dd></div>
<div><dt>时间</dt><dd>{{ formatTime(selectedHistory.updatedAt) }}</dd></div>
<div><dt>时间</dt><dd>{{ formatDateTime(selectedHistory.updatedAt, "系统默认") }}</dd></div>
</dl>
<h4>变量定义</h4>
<div class="history-variables">

View File

@@ -8,6 +8,7 @@ import { nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { api } from "../services/api";
import type { DashboardStats, PeakTrafficResult, TrafficGrain } from "../types/api";
import { formatDateTime } from "../utils/dateTime";
use([LineChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, MarkPointComponent, CanvasRenderer]);
@@ -160,7 +161,7 @@ function modelSceneLabel(scene: string) {
<section class="dashboard-panel" v-loading="storageLoading">
<div class="panel-heading"><div><span class="section-kicker">基础设施</span><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 class="storage-updated">最后统计{{ storageStats?.createdAt || "尚未统计" }}</small>
<small class="storage-updated">最后统计{{ formatDateTime(storageStats?.createdAt, "尚未统计") }}</small>
</section>
</section>
</template>

View File

@@ -5,6 +5,7 @@ import { onMounted, ref } from "vue";
import { api } from "../services/api";
import type { FeedbackDetail, FeedbackItem } from "../types/api";
import AdminPagination from "./AdminPagination.vue";
import { formatDateTime } from "../utils/dateTime";
defineProps<{ canDelete: boolean; canDetail: boolean; canExport: boolean }>();
@@ -119,13 +120,13 @@ onMounted(load);
<el-table-column prop="userName" label="用户" width="130" />
<el-table-column prop="content" label="反馈内容" min-width="240" show-overflow-tooltip />
<el-table-column prop="messageContent" label="反馈的 AI 回答" min-width="280" show-overflow-tooltip />
<el-table-column prop="createdAt" label="提交时间" width="180" />
<el-table-column label="提交时间" width="180"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
<el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><el-button v-if="canDetail" link type="primary" @click="open(row)">查看</el-button><el-button v-if="canDelete" link type="danger" @click="remove(row)">删除</el-button></template></el-table-column>
</el-table>
<AdminPagination v-if="total" :page="page" :page-size="pageSize" :total="total" @change="changePage" />
<el-drawer :model-value="Boolean(detail)" title="反馈详情" size="620px" @close="detail = null">
<template v-if="detail">
<section class="feedback-summary"><span>用户反馈</span><strong>{{ detail.content }}</strong><small>{{ detail.userName }} · {{ detail.createdAt }}</small></section>
<section class="feedback-summary"><span>用户反馈</span><strong>{{ detail.content }}</strong><small>{{ detail.userName }} · {{ formatDateTime(detail.createdAt) }}</small></section>
<h3>当时对话记录</h3>
<div class="feedback-history"><article v-for="message in detail.messages" :key="message.id" :class="[message.role, { target: message.isTarget }]">
<div><b>{{ message.role === 'user' ? '用户' : 'AI' }}</b><el-tag v-if="message.isTarget" type="warning">用户反馈的这条回答</el-tag></div><p>{{ message.content }}</p>

View File

@@ -3,6 +3,7 @@ import { ElMessage, ElMessageBox } from "element-plus";
import { onMounted, reactive, ref } from "vue";
import { api } from "../services/api";
import { formatDateTime } from "../utils/dateTime";
import KnowledgeContentPreview from "./KnowledgeContentPreview.vue";
import TableRowActions from "./TableRowActions.vue";
import AdminPagination from "./AdminPagination.vue";
@@ -545,7 +546,7 @@ async function confirmAction(
<el-table-column label="类型" width="140"><template #default="{ row }">{{ typeLabel(row.knowledgeType) }}</template></el-table-column>
<el-table-column label="当前版本" width="130"><template #default="{ row }"><span>{{ row.currentVersionId ? `#${row.currentVersionId}` : '尚未同步' }}</span></template></el-table-column>
<el-table-column label="状态" width="210"><template #default="{ row }"><div class="knowledge-status-cell"><el-switch v-model="row.status" :active-value="1" :inactive-value="0" active-text="开放" inactive-text="关闭" inline-prompt :loading="operatingId === row.id" :disabled="row.lifecycleStatus === 'archived'" :before-change="() => toggleOpen(row)" /><el-tag :type="statusType(row.sourceStatus)">{{ row.sourceStatus === 'normal' ? '源正常' : '源异常' }}</el-tag></div></template></el-table-column>
<el-table-column prop="lastSyncedAt" label="最近同步" width="180" />
<el-table-column label="最近同步" width="180"><template #default="{ row }">{{ formatDateTime(row.lastSyncedAt) }}</template></el-table-column>
<el-table-column label="操作" width="220" fixed="right"><template #default="{ row }"><TableRowActions><el-button link type="primary" @click="openDetail(row)">详情</el-button><el-button link type="primary" :disabled="row.lifecycleStatus === 'archived'" :loading="operatingId === row.id" @click="sync(row)">同步最新版</el-button><el-dropdown trigger="click" :teleported="true" @command="handleMore($event, row)"><el-button link type="primary">更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="detail">查看详情</el-dropdown-item><el-dropdown-item command="preview">进入 Agent 预览</el-dropdown-item><el-dropdown-item v-if="row.lifecycleStatus === 'active'" command="archive" divided>归档</el-dropdown-item><el-dropdown-item v-else command="restore" divided>恢复</el-dropdown-item><el-dropdown-item command="delete" divided>永久删除</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="load" />

View File

@@ -11,6 +11,9 @@ import type {
import AdminPagination from "./AdminPagination.vue";
import AiLogDetailDrawer from "./AiLogDetailDrawer.vue";
import ChatDetailDrawer from "./ChatDetailDrawer.vue";
import { formatDateTime, toUtcIsoFromBusinessDateTime } from "../utils/dateTime";
defineProps<{ canExport: boolean }>();
type RecordTab = "chats" | "questionInsights" | "aiLogs" | "operationLogs";
const loading = ref(false);
@@ -115,8 +118,8 @@ async function loadInsights(
pageSize = pagers.questionInsights.pageSize,
) {
insights.value = await api.questionInsights({
dateFrom: formatDateTime(insightFilters.dateFrom, "start"),
dateTo: formatDateTime(insightFilters.dateTo, "end"),
dateFrom: toUtcIsoFromBusinessDateTime(insightFilters.dateFrom),
dateTo: toUtcIsoFromBusinessDateTime(insightFilters.dateTo),
minCount: insightFilters.minCount,
maxMessages: insightFilters.maxMessages,
page,
@@ -149,8 +152,8 @@ async function refreshInsights() {
loading.value = true;
try {
const result = await api.refreshQuestionInsights({
dateFrom: formatDateTime(insightFilters.dateFrom, "start"),
dateTo: formatDateTime(insightFilters.dateTo, "end"),
dateFrom: toUtcIsoFromBusinessDateTime(insightFilters.dateFrom),
dateTo: toUtcIsoFromBusinessDateTime(insightFilters.dateTo),
maxMessages: insightFilters.maxMessages,
});
await loadInsights(1);
@@ -177,9 +180,9 @@ async function resetInsightFilters() {
await refreshInsights();
}
async function exportInsights() {
const dateFrom = formatDateTime(insightFilters.dateFrom, "start");
const dateTo = formatDateTime(insightFilters.dateTo, "end");
if (dateFrom && dateTo && new Date(dateFrom.replace(" ", "T")) > new Date(dateTo.replace(" ", "T"))) {
const dateFrom = toUtcIsoFromBusinessDateTime(insightFilters.dateFrom);
const dateTo = toUtcIsoFromBusinessDateTime(insightFilters.dateTo);
if (dateFrom && dateTo && dateFrom > dateTo) {
return ElMessage.warning("开始时间不能晚于结束时间");
}
insightExporting.value = true;
@@ -217,17 +220,10 @@ function buildChatQuery() {
status: chatFilters.status,
sourceType: chatFilters.sourceType,
sourceClientId: chatFilters.sourceClientId,
dateFrom: formatDateTime(chatFilters.dateFrom, "start"),
dateTo: formatDateTime(chatFilters.dateTo, "end"),
dateFrom: toUtcIsoFromBusinessDateTime(chatFilters.dateFrom),
dateTo: toUtcIsoFromBusinessDateTime(chatFilters.dateTo),
};
}
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)}`
@@ -290,7 +286,7 @@ function formatMoney(value?: number | null, currency = "CNY") {
<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 v-if="canExport" @click="api.exportChats(buildChatQuery())"
>导出</el-button
>
</div>
@@ -322,11 +318,9 @@ function formatMoney(value?: number | null, currency = "CNY") {
prop="messageCount"
label="消息数"
width="90"
/><el-table-column
prop="lastMessageAt"
label="最后消息"
width="180"
/><el-table-column label="操作" width="100" fixed="right"
/><el-table-column label="最后消息" width="180"
><template #default="{ row }">{{ formatDateTime(row.lastMessageAt) }}</template
></el-table-column><el-table-column label="操作" width="100" fixed="right"
><template #default="{ row }"
><el-button size="small" @click="openChat(row.id)"
>详情</el-button
@@ -382,7 +376,7 @@ function formatMoney(value?: number | null, currency = "CNY") {
@click="refreshInsights"
>刷新并统计</el-button
><el-button @click="resetInsightFilters">重置</el-button>
<el-button :loading="insightExporting" @click="exportInsights">导出洞察</el-button>
<el-button v-if="canExport" :loading="insightExporting" @click="exportInsights">导出洞察</el-button>
</div>
</div>
<p class="question-insight-help">
@@ -475,7 +469,7 @@ function formatMoney(value?: number | null, currency = "CNY") {
sample.userPhone ||
`用户 #${sample.userId}`
}}</strong
><span>{{ sample.createdAt }}</span>
><span>{{ formatDateTime(sample.createdAt) }}</span>
</div>
<p>清洗后{{ sample.cleaned }}</p>
<pre>{{ sample.raw }}</pre>
@@ -569,9 +563,9 @@ function formatMoney(value?: number | null, currency = "CNY") {
prop="action"
label="动作" /><el-table-column
prop="result"
label="结果" /><el-table-column
prop="createdAt"
label="时间" /></el-table
label="结果" /><el-table-column label="时间" width="180"
><template #default="{ row }">{{ formatDateTime(String(row.createdAt || "")) }}</template
></el-table-column></el-table
><AdminPagination
:page="pagers.operationLogs.page"
:page-size="pagers.operationLogs.pageSize"
@@ -579,7 +573,7 @@ function formatMoney(value?: number | null, currency = "CNY") {
@change="(page, size) => changePage('operationLogs', page, size)"
/></el-tab-pane>
</el-tabs>
<ChatDetailDrawer v-model="chatDetailOpen" :session-id="detailSessionId" />
<ChatDetailDrawer v-model="chatDetailOpen" :session-id="detailSessionId" :can-export="canExport" />
<AiLogDetailDrawer v-model="aiLogDetailOpen" :log-id="aiLogId" />
</section>
</template>

View File

@@ -4,6 +4,7 @@ import { computed, onMounted, reactive, ref } from "vue";
import { api } from "../services/api";
import type { RetrievalLogItem } from "../types/api";
import AdminPagination from "./AdminPagination.vue";
import { formatDateTime } from "../utils/dateTime";
const loading = ref(false);
const logs = ref<RetrievalLogItem[]>([]);
@@ -102,7 +103,7 @@ async function openDetail(row: RetrievalLogItem) {
label="180天"
:value="180" /></el-select
><el-button type="primary" @click="saveRetention">保存规则</el-button
><small>最近执行{{ retentionState?.lastRunAt || "尚未执行" }}</small>
><small>最近执行{{ formatDateTime(retentionState?.lastRunAt, "尚未执行") }}</small>
</section>
<el-table :data="logs" stripe
><el-table-column prop="id" label="ID" width="80" /><el-table-column
@@ -136,11 +137,9 @@ async function openDetail(row: RetrievalLogItem) {
prop="totalCostMs"
label="总耗时"
width="100"
/><el-table-column
prop="createdAt"
label="时间"
width="180"
/><el-table-column label="操作" width="90" fixed="right"
/><el-table-column label="时间" width="180"
><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template
></el-table-column><el-table-column label="操作" width="90" fixed="right"
><template #default="{ row }"
><el-button link type="primary" @click="openDetail(row)"
>详情</el-button

View File

@@ -5,6 +5,7 @@ import { computed, onMounted, reactive, ref } from "vue";
import { api } from "../services/api";
import type { EntitlementPlan, SsoAuditItem, SsoClientItem, SsoIdentityItem } from "../types/api";
import AdminPagination from "./AdminPagination.vue";
import { formatDateTime } from "../utils/dateTime";
const loading = ref(false);
const savingUrl = ref(false);
@@ -266,11 +267,6 @@ async function changeAuditPage(page: number, pageSize: number) {
await loadAudits();
}
function formatTime(value?: string | null) {
if (!value) return "-";
return new Date(value).toLocaleString("zh-CN", { hour12: false });
}
function maskPhone(phone: string) {
return phone.replace(/^(\d{3})\d{4}(\d{4})$/, "$1****$2");
}
@@ -327,7 +323,7 @@ function showError(error: unknown, fallback: string) {
<template #default="{ row }">
<div class="sso-status-cell">
<div><el-switch v-model="row.status" :active-value="1" :inactive-value="0" @change="toggleClient(row)" /><span>{{ row.status === 1 ? "已启用" : "已停用" }}</span></div>
<small>最近使用{{ formatTime(row.lastUsedAt) }}</small>
<small>最近使用{{ formatDateTime(row.lastUsedAt) }}</small>
</div>
</template>
</el-table-column>
@@ -351,7 +347,7 @@ function showError(error: unknown, fallback: string) {
<dl>
<div><dt>账号策略</dt><dd>{{ client.allowAutoRegister ? `自动注册 · ${client.defaultEntitlementPlanName || "未配置权益"}` : "仅已有学员" }}已绑定 {{ client.identityCount }} </dd></div>
<div><dt>回跳地址</dt><dd>{{ client.redirectUris.join("、") || "未配置" }}</dd></div>
<div><dt>最近使用</dt><dd>{{ formatTime(client.lastUsedAt) }}</dd></div>
<div><dt>最近使用</dt><dd>{{ formatDateTime(client.lastUsedAt) }}</dd></div>
</dl>
<div class="sso-card-actions"><el-button size="small" @click="openEditDialog(client)">编辑</el-button><el-button size="small" @click="rotateSecret(client)">更新密钥</el-button></div>
</article>
@@ -374,7 +370,7 @@ function showError(error: unknown, fallback: string) {
</el-table-column>
<el-table-column prop="externalUserId" label="外部用户ID" min-width="190" />
<el-table-column label="最近免登录" width="180">
<template #default="{ row }">{{ formatTime(row.lastLoginAt) }}</template>
<template #default="{ row }">{{ formatDateTime(row.lastLoginAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="120" align="center">
<template #default="{ row }"><el-button size="small" type="danger" plain @click="unlinkIdentity(row)">解除绑定</el-button></template>
@@ -405,7 +401,7 @@ function showError(error: unknown, fallback: string) {
</el-table-column>
<el-table-column prop="errorMessage" label="失败原因" min-width="220" show-overflow-tooltip />
<el-table-column prop="ip" label="请求IP" width="140" />
<el-table-column label="时间" width="180"><template #default="{ row }">{{ formatTime(row.createdAt) }}</template></el-table-column>
<el-table-column label="时间" width="180"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
</el-table>
<AdminPagination :page="auditPage" :page-size="auditPageSize" :total="auditTotal" @change="changeAuditPage" />
</el-tab-pane>

View File

@@ -9,16 +9,23 @@ import type {
UserBehaviorUserSummary,
} from "../types/api";
import AdminPagination from "./AdminPagination.vue";
import { formatDateTime } from "../utils/dateTime";
defineProps<{ canExport: boolean }>();
const activeView = ref<"overview" | "users">("overview");
const overviewLoading = ref(false);
const usersLoading = ref(false);
const exporting = ref(false);
const overview = ref<UserBehaviorOverview | null>(null);
const users = ref<UserBehaviorUserSummary[]>([]);
const userTotal = ref(0);
const userPage = ref(1);
const userPageSize = ref(20);
const keyword = ref("");
type UserSortField = "userName" | "phone" | "eventCount" | "lastEventAt";
const userSortBy = ref<UserSortField>("lastEventAt");
const userSortOrder = ref<"asc" | "desc">("desc");
const timeline = ref<UserBehaviorTimeline | null>(null);
const timelineLoading = ref(false);
const timelinePage = ref(1);
@@ -56,6 +63,8 @@ async function loadUsers() {
keyword: keyword.value.trim() || undefined,
page: userPage.value,
pageSize: userPageSize.value,
sortBy: userSortBy.value,
sortOrder: userSortOrder.value,
});
users.value = result.items;
userTotal.value = result.total;
@@ -67,15 +76,29 @@ async function loadUsers() {
}
function applyDateRange() {
if (!filters.start || !filters.end) return ElMessage.warning("请选择完整时间范围");
if (filters.start > filters.end) return ElMessage.warning("开始日期不能晚于结束日期");
const span = (new Date(filters.end).getTime() - new Date(filters.start).getTime()) / 86_400_000;
if (span > 30) return ElMessage.warning("行为数据最多查询31天请缩小时间范围");
if (!validateDateRange()) return;
userPage.value = 1;
timeline.value = null;
void loadAll();
}
function validateDateRange() {
if (!filters.start || !filters.end) {
ElMessage.warning("请选择完整时间范围");
return false;
}
if (filters.start > filters.end) {
ElMessage.warning("开始日期不能晚于结束日期");
return false;
}
const span = (new Date(filters.end).getTime() - new Date(filters.start).getTime()) / 86_400_000;
if (span > 30) {
ElMessage.warning("行为数据最多查询 31 天,请缩小时间范围");
return false;
}
return true;
}
function switchView(view: "overview" | "users") {
activeView.value = view;
if (view === "users" && !users.value.length) void loadUsers();
@@ -92,6 +115,32 @@ function changeUserPage(page: number, pageSize: number) {
void loadUsers();
}
function changeUserSort({ prop, order }: { prop: string; order: "ascending" | "descending" | null }) {
const supported: UserSortField[] = ["userName", "phone", "eventCount", "lastEventAt"];
userSortBy.value = supported.includes(prop as UserSortField) ? prop as UserSortField : "lastEventAt";
userSortOrder.value = order === "ascending" ? "asc" : "desc";
userPage.value = 1;
void loadUsers();
}
async function exportReport() {
if (!validateDateRange() || exporting.value) return;
exporting.value = true;
try {
await api.exportUserBehavior({
...filters,
keyword: activeView.value === "users" ? keyword.value.trim() || undefined : undefined,
sortBy: userSortBy.value,
sortOrder: userSortOrder.value,
});
ElMessage.success("用户行为分析报表已导出");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "用户行为报表导出失败");
} finally {
exporting.value = false;
}
}
async function openTimeline(row: UserBehaviorUserSummary, page = 1, pageSize = timelinePageSize.value) {
timelineLoading.value = true;
timelinePage.value = page;
@@ -131,10 +180,6 @@ function targetLabel(type?: string | null, id?: number | null) {
return `${name} #${id}`;
}
function formatTime(value: string) {
return new Date(value).toLocaleString("zh-CN", { hour12: false });
}
function formatInputDate(value: Date) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
@@ -164,6 +209,7 @@ function formatInputDate(value: Date) {
<i></i>
<label><span>结束日期</span><input v-model="filters.end" type="date" aria-label="行为结束日期" /></label>
<el-button type="primary" @click="applyDateRange">查询</el-button>
<el-button v-if="canExport" :loading="exporting" @click="exportReport">导出 Excel</el-button>
</div>
</section>
@@ -207,11 +253,18 @@ function formatInputDate(value: Date) {
<div><h3>用户交互记录</h3><p>选择用户后可查看选定时间范围内的完整关键操作流程</p></div>
<div class="user-search"><el-input v-model="keyword" clearable placeholder="姓名或手机号" @keyup.enter="searchUsers" /><el-button type="primary" @click="searchUsers">搜索</el-button></div>
</div>
<el-table v-loading="usersLoading" :data="users" stripe empty-text="当前范围暂无用户行为记录">
<el-table-column prop="userName" label="用户" min-width="150" />
<el-table-column prop="phone" label="手机号" width="150" />
<el-table-column prop="eventCount" label="交互次数" width="120" align="center" />
<el-table-column label="最后交互时间" width="190"><template #default="{ row }">{{ formatTime(row.lastEventAt) }}</template></el-table-column>
<el-table
v-loading="usersLoading"
:data="users"
:default-sort="{ prop: userSortBy, order: userSortOrder === 'asc' ? 'ascending' : 'descending' }"
stripe
empty-text="当前范围暂无用户行为记录"
@sort-change="changeUserSort"
>
<el-table-column prop="userName" label="用户" min-width="150" sortable="custom" />
<el-table-column prop="phone" label="手机号" width="150" sortable="custom" />
<el-table-column prop="eventCount" label="交互次数" width="120" align="center" sortable="custom" />
<el-table-column prop="lastEventAt" label="最后交互时间" width="190" sortable="custom"><template #default="{ row }">{{ formatDateTime(row.lastEventAt) }}</template></el-table-column>
<el-table-column label="操作" width="120" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openTimeline(row)">查看流程</el-button></template></el-table-column>
</el-table>
<AdminPagination v-if="userTotal" :page="userPage" :page-size="userPageSize" :total="userTotal" @change="changeUserPage" />
@@ -227,7 +280,7 @@ function formatInputDate(value: Date) {
<article v-for="item in timeline.items" :key="item.id">
<i :class="item.eventType" />
<div>
<time>{{ formatTime(item.occurredAt) }}</time>
<time>{{ formatDateTime(item.occurredAt) }}</time>
<strong>{{ item.eventName }}</strong>
<small>{{ eventTypeLabel(item.eventType) }}<template v-if="targetLabel(item.targetType, item.targetId)"> · {{ targetLabel(item.targetType, item.targetId) }}</template></small>
</div>

View File

@@ -4,6 +4,7 @@ import { ref, watch } from "vue";
import { api } from "../services/api";
import type { AdminUserDetail } from "../types/api";
import { formatDateTime } from "../utils/dateTime";
const props = defineProps<{ modelValue: boolean; userId: number | null }>();
defineEmits<{ "update:modelValue": [value: boolean] }>();
@@ -171,11 +172,11 @@ function reportTriggerLabel(value: string) {
</div>
<div>
<span>最近登录</span
><strong>{{ detail.user.lastLoginAt || "-" }}</strong>
><strong>{{ formatDateTime(detail.user.lastLoginAt) }}</strong>
</div>
<div>
<span>最近发言</span
><strong>{{ detail.metrics.lastMessageAt || "-" }}</strong>
><strong>{{ formatDateTime(detail.metrics.lastMessageAt) }}</strong>
</div>
<div>
<span>生成记录</span
@@ -265,9 +266,9 @@ function reportTriggerLabel(value: string) {
report.maxAttempts
}}</span
>
<span v-if="report.nextRunAt"
>下次执行{{ report.nextRunAt }}</span
><span>完成时间{{ report.finishedAt || "-" }}</span>
<span v-if="report.nextRunAt"
>下次执行{{ formatDateTime(report.nextRunAt) }}</span
><span>完成时间{{ formatDateTime(report.finishedAt) }}</span>
</div>
<p v-if="report.errorMessage" class="report-error">
最近错误{{ report.errorMessage }}
@@ -303,8 +304,8 @@ function reportTriggerLabel(value: string) {
<span
>Token{{ topic.tokenInput }}/{{ topic.tokenOutput }}</span
>
<span>开始:{{ topic.startedAt }}</span
><span>更新:{{ topic.updatedAt }}</span>
<span>开始:{{ formatDateTime(topic.startedAt) }}</span
><span>更新:{{ formatDateTime(topic.updatedAt) }}</span>
</div>
<template v-if="topic.summary">
<div class="topic-summary-meta">
@@ -319,10 +320,10 @@ function reportTriggerLabel(value: string) {
}}</span
>
<span v-if="topic.summary.lastStartedAt"
>开始执行:{{ topic.summary.lastStartedAt }}</span
>开始执行:{{ formatDateTime(topic.summary.lastStartedAt) }}</span
>
<span v-if="topic.summary.finishedAt"
>完成:{{ topic.summary.finishedAt }}</span
>完成:{{ formatDateTime(topic.summary.finishedAt) }}</span
>
</div>
<p v-if="topic.summary.errorMessage" class="report-error">
@@ -392,8 +393,8 @@ function reportTriggerLabel(value: string) {
>
<section class="topic-summary-card">
<div class="topic-summary-meta">
<span>生成:{{ card.createdAt }}</span
><span>复制:{{ card.copiedAt || "-" }}</span
<span>生成:{{ formatDateTime(card.createdAt) }}</span
><span>复制:{{ formatDateTime(card.copiedAt) }}</span
><span>来源:{{ card.source }}</span>
</div>
<pre>{{ card.content }}</pre>
@@ -420,8 +421,8 @@ function reportTriggerLabel(value: string) {
>
<section class="topic-summary-card">
<div class="topic-summary-meta">
<span>生成:{{ draft.createdAt }}</span
><span>复制:{{ draft.copiedAt || "-" }}</span
<span>生成:{{ formatDateTime(draft.createdAt) }}</span
><span>复制:{{ formatDateTime(draft.copiedAt) }}</span
><span>来源:{{ draft.source }}</span>
</div>
<pre>{{ draft.content }}</pre>

View File

@@ -12,6 +12,7 @@ import AdminPagination from "./AdminPagination.vue";
import TableRowActions from "./TableRowActions.vue";
import UserDetailDrawer from "./UserDetailDrawer.vue";
import UserEntitlementRenewDialog from "./UserEntitlementRenewDialog.vue";
import { formatDate, formatDateTime, toUtcIsoFromBusinessDate } from "../utils/dateTime";
const loading = ref(false);
const users = ref<AdminUser[]>([]);
@@ -150,14 +151,6 @@ function entitlementStatusType(row: AdminUser) {
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("用户权益已更新");
@@ -186,9 +179,7 @@ async function createStudent() {
nickname: studentForm.nickname || null,
status: studentForm.status,
dailyChatLimit: studentForm.dailyChatLimit,
expiredAt: studentForm.expiredAt
? `${studentForm.expiredAt}T23:59:59`
: null,
expiredAt: toUtcIsoFromBusinessDate(studentForm.expiredAt, "end") ?? null,
});
ElMessage.success("学员已添加");
resetStudentForm();
@@ -412,8 +403,8 @@ async function deleteUser(row: AdminUser) {
>{{ row.entitlement?.name || "默认基础版" }} ·
{{
row.entitlement?.lifecycleStatus === "expired_fallback"
? `原 ${row.entitlement?.previousPlanName || "权益"} ${formatEntitlementDate(row.entitlement?.previousExpiredAt)} 到期`
: `有效至 ${formatEntitlementDate(row.entitlement?.expiredAt)}`
? `原 ${row.entitlement?.previousPlanName || "权益"} ${formatDate(row.entitlement?.previousExpiredAt, "长期")} 到期`
: `有效至 ${formatDate(row.entitlement?.expiredAt, "长期")}`
}}</small
>
<div class="entitlement-meta-row">
@@ -424,7 +415,7 @@ async function deleteUser(row: AdminUser) {
</div>
</template>
</el-table-column>
<el-table-column prop="lastLoginAt" label="最近登录" width="180" />
<el-table-column label="最近登录" width="180"><template #default="{ row }">{{ formatDateTime(row.lastLoginAt) }}</template></el-table-column>
<el-table-column label="操作" width="210" fixed="right">
<template #default="{ row }">
<TableRowActions>

View File

@@ -159,10 +159,15 @@ export const api = {
request<PeakTrafficResult>(`/admin/dashboard/traffic${queryString({ grain })}`),
userBehaviorOverview: (query: { start?: string; end?: string } = {}) =>
request<UserBehaviorOverview>(`/admin/user-behavior/overview${queryString(query)}`),
userBehaviorUsers: (query: { start?: string; end?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
userBehaviorUsers: (query: { start?: string; end?: string; keyword?: string; page?: number; pageSize?: number; sortBy?: string; sortOrder?: "asc" | "desc" } = {}) =>
request<PageResult<UserBehaviorUserSummary>>(`/admin/user-behavior/users${queryString(query)}`),
userBehaviorTimeline: (userId: number, query: { start?: string; end?: string; page?: number; pageSize?: number } = {}) =>
request<UserBehaviorTimeline>(`/admin/user-behavior/user/${userId}/timeline${queryString(query)}`),
exportUserBehavior: (query: { start?: string; end?: string; keyword?: string; sortBy?: string; sortOrder?: "asc" | "desc" } = {}) =>
download(
`/admin/user-behavior/export${queryString(query)}`,
`用户行为分析_${query.start || "开始"}_${query.end || "当前"}.xlsx`,
),
users: (query: { keyword?: string; planId?: number; entitlementStatus?: string; page?: number; pageSize?: number } = {}) => request<PageResult<AdminUser>>(`/admin/user/list${queryString(query)}`),
userDetail: (id: number) => request<AdminUserDetail>(`/admin/user/${id}/detail`),
userTopics: (id: number, query: { keyword?: string; page?: number; pageSize?: number } = {}) =>
@@ -335,10 +340,12 @@ export const api = {
ssoAudits: (query: { clientId?: number; auditStatus?: string; page?: number; pageSize?: number } = {}) =>
request<PageResult<SsoAuditItem>>(`/admin/sso/audit/list${queryString(query)}`),
chats: (query: ChatRecordQuery = {}) => request<PageResult<ChatRecord>>(`/admin/chat/list${queryString(query)}`),
chatDetail: (sessionId: number, query: { messagePage?: number; messagePageSize?: number } = {}) =>
chatDetail: (sessionId: number, query: { messagePage?: number; messagePageSize?: number; focusMessageId?: number } = {}) =>
request<ChatDetail>(`/admin/chat/${sessionId}${queryString(query)}`),
chatMessages: (sessionId: number, query: { page?: number; pageSize?: number } = {}) =>
request<PageResult<ChatMessageRecord>>(`/admin/chat/${sessionId}/messages${queryString(query)}`),
exportChatDetail: (sessionId: number, filename = `完整对话_会话${sessionId}.xlsx`) =>
download(`/admin/chat/${sessionId}/export`, filename),
exportChats: (query: ChatRecordQuery = {}) => download(`/admin/chat/export${queryString(query)}`, "chat_records.csv"),
aiLogs: (query: { sessionId?: number; userId?: number; status?: string; page?: number; pageSize?: number } = {}) =>
request<PageResult<AiLogRecord>>(`/admin/ai-log/list${queryString(query)}`),
@@ -359,7 +366,7 @@ export const api = {
saveRetrievalRetention: (retentionDays: number | null) => request<{ enabled: boolean; retentionDays: number | null }>("/admin/retrieval-log/retention", { method: "PUT", body: JSON.stringify({ retentionDays }) }),
storageStats: (refresh = false) => request<Record<string, any>>(`/admin/dashboard/storage${refresh ? "?refresh=true" : ""}`),
retrievalLogDetail: (id: number) => request<Record<string, unknown>>(`/admin/retrieval-log/${id}`),
attentionList: (query: { page?: number; pageSize?: number } = {}) => request<PageResult<AttentionItem>>(`/admin/attention/list${queryString(query)}`),
attentionList: (query: { priority?: string; status?: string; dateFrom?: string; dateTo?: string; page?: number; pageSize?: number } = {}) => request<PageResult<AttentionItem>>(`/admin/attention/list${queryString(query)}`),
attentionConfig: () => request<AttentionConfig>("/admin/attention/config"),
attentionPreviewConfig: () => request<AttentionConfig>("/admin/attention/preview/config"),
saveAttentionConfig: (payload: AttentionConfig) => request<AttentionConfig>("/admin/attention/config", { method: "PUT", body: JSON.stringify(payload) }),

View File

@@ -608,6 +608,7 @@ export interface AttentionItem {
priority: string;
status: string;
handlerNote?: string | null;
targetAvailable?: boolean;
createdAt: string;
}
@@ -725,6 +726,7 @@ export interface ChatDetail {
session: ChatRecord;
messages: ChatMessageRecord[];
messagesPage?: PageResult<ChatMessageRecord>;
focusedMessageId?: number | null;
aiLogs: AiLogRecord[];
topics?: TopicSessionRecord[];
helpCards?: TeacherHelpCardRecord[];

View File

@@ -0,0 +1,81 @@
export const BUSINESS_TIME_ZONE = "Asia/Shanghai";
const BUSINESS_OFFSET = "+08:00";
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const EXPLICIT_OFFSET_PATTERN = /(?:Z|[+-]\d{2}:?\d{2})$/i;
export function parseApiDateTime(value?: string | Date | null): Date | null {
if (!value) return null;
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value;
}
const text = value.trim();
if (!text) return null;
const isoText = text.includes(" ") ? text.replace(" ", "T") : text;
const normalized = DATE_ONLY_PATTERN.test(isoText)
? `${isoText}T00:00:00${BUSINESS_OFFSET}`
: EXPLICIT_OFFSET_PATTERN.test(isoText)
? isoText
: `${isoText}Z`;
const parsed = new Date(normalized);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function businessParts(value?: string | Date | null) {
const parsed = parseApiDateTime(value);
if (!parsed) return null;
const parts = new Intl.DateTimeFormat("zh-CN", {
timeZone: BUSINESS_TIME_ZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
hourCycle: "h23",
}).formatToParts(parsed);
return Object.fromEntries(parts.map((part) => [part.type, part.value]));
}
export function formatDateTime(
value?: string | Date | null,
fallback = "-",
): string {
const parts = businessParts(value);
if (!parts) return fallback;
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`;
}
export function formatDate(
value?: string | Date | null,
fallback = "-",
): string {
const parts = businessParts(value);
if (!parts) return fallback;
return `${parts.year}-${parts.month}-${parts.day}`;
}
export function formatTime(
value?: string | Date | null,
fallback = "-",
): string {
const parts = businessParts(value);
if (!parts) return fallback;
return `${parts.hour}:${parts.minute}`;
}
export function toUtcIsoFromBusinessDate(
value: string,
boundary: "start" | "end" = "start",
): string | undefined {
if (!DATE_ONLY_PATTERN.test(value)) return undefined;
const localTime = boundary === "start" ? "00:00:00.000" : "23:59:59.999";
return new Date(`${value}T${localTime}${BUSINESS_OFFSET}`).toISOString();
}
export function toUtcIsoFromBusinessDateTime(value: string): string | undefined {
if (!value) return undefined;
const normalized = value.length === 16 ? `${value}:00` : value;
const parsed = new Date(`${normalized}${BUSINESS_OFFSET}`);
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
}